From a00f7aa80d38711717af52011fe52e30c9f7c0da Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Mon, 19 Oct 2026 13:25:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=89=8B=E5=8A=A8=E6=8E=A8=E9=80=81?= =?UTF-8?q?=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/appmessage/AppManualPushDto.java | 45 ++++ .../vo/appmessage/AppManualPushResultVo.java | 22 ++ .../vo/appmessage/AppMessageVo.java | 2 +- .../appmessage/AppMessageConstants.java | 9 + .../ThirdAppMessagePcController.java | 21 ++ .../appmessage/ThirdAppManualPushService.java | 17 ++ .../ThirdAppManualPushServiceImpl.java | 235 +++++++++++++++++ .../ThirdAppMessageServiceImpl.java | 3 +- .../ThirdAppManualPushServiceImplTest.java | 248 ++++++++++++++++++ 9 files changed, 600 insertions(+), 2 deletions(-) create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppManualPushDto.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppManualPushResultVo.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdAppManualPushService.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppManualPushServiceImpl.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppManualPushServiceImplTest.java diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppManualPushDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppManualPushDto.java new file mode 100644 index 000000000..a24a7a234 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppManualPushDto.java @@ -0,0 +1,45 @@ +package com.bsd.pjcloud.thirdparty.dto.appmessage; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +/** PC手动批量通知请求,统一文案只用于本次发送,不保存为可复用模板。 */ +@Data +public class AppManualPushDto implements Serializable { + + /** 单次入箱人数上限,限制事务规模,与PC选择器保持一致。 */ + public static final int MAX_RECIPIENTS = 500; + + /** 当前来源商户ID,服务端必须复核管理员管理范围。 */ + @NotNull(message = "商户ID不能为空") + @Positive(message = "商户ID无效") + private Long mchId; + + /** 当前租户内的接收用户ID,服务端按当前登录人的数据权限再次复核。 */ + @NotEmpty(message = "请选择接收用户") + @Size(max = MAX_RECIPIENTS, message = "单次最多推送500位用户") + private List<@NotNull(message = "用户ID不能为空") @Positive(message = "用户ID无效") Long> userIds; + + /** 统一纯文本标题,长度与现有消息标题列宽128字符一致。 */ + @NotBlank(message = "通知标题不能为空") + @Size(max = 128, message = "通知标题最多128字符") + private String title; + + /** 统一纯文本正文,长度与现有消息正文列宽2000字符一致。 */ + @NotBlank(message = "通知正文不能为空") + @Size(max = 2000, message = "通知正文最多2000字符") + private String content; + + /** 客户端为本次草稿快照生成的幂等标识,网络重试不变,修改草稿后重新生成。 */ + @NotBlank(message = "提交标识不能为空") + @Pattern(regexp = "[A-Za-z0-9_-]{16,64}", message = "提交标识格式无效") + private String requestId; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppManualPushResultVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppManualPushResultVo.java new file mode 100644 index 000000000..02a4e9d34 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppManualPushResultVo.java @@ -0,0 +1,22 @@ +package com.bsd.pjcloud.thirdparty.vo.appmessage; + +import lombok.Data; + +import java.io.Serializable; + +/** 手动推送入箱结果,投递任务已创建不代表通知已经到达设备。 */ +@Data +public class AppManualPushResultVo implements Serializable { + + /** 本次已确认的客户端提交标识。 */ + private String requestId; + + /** 已写入App消息列表的接收人数。 */ + private int recipientCount; + + /** 已加入Push投递流程的人数,重试查询包含已处理的有效任务。 */ + private int queuedCount; + + /** 因用户关闭Push或无有效设备而跳过通知栏推送的人数,不影响入箱。 */ + private int skippedCount; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppMessageVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppMessageVo.java index f3e832787..a03c0873f 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppMessageVo.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppMessageVo.java @@ -17,7 +17,7 @@ public class AppMessageVo implements Serializable { /** 固定业务事件编码。 */ private String eventCode; - /** 事件展示分组,wallet或meal。 */ + /** 事件展示分组,wallet钱包、meal用餐、system人工通知。 */ private String category; /** 接收类型,OWNER本人或RELATION关系人。 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageConstants.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageConstants.java index 45a63c9c4..57fef5c6c 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageConstants.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageConstants.java @@ -34,6 +34,15 @@ public class AppMessageConstants { /** 用餐核销事件来源服务。 */ public static final String SOURCE_HARDWARE = "HARDWARE"; + /** PC人工通知来源服务,区别于自动钱包和用餐事件。 */ + public static final String SOURCE_THIRD = "THIRD"; + + /** 手动推送的稳定事件编码,只用于消息和投递,不加入自动事件策略或关系订阅矩阵。 */ + public static final String EVENT_MANUAL_PUSH = "MANUAL_PUSH"; + + /** 手动通知在App中的展示分类,跨端协议值为system。 */ + public static final String CATEGORY_SYSTEM = "system"; + /** 新建后等待供应商投递的状态。 */ public static final String DELIVERY_PENDING = "PENDING"; diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessagePcController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessagePcController.java index f9b589a03..4c5402b63 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessagePcController.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessagePcController.java @@ -6,12 +6,15 @@ import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.common.security.util.SecurityUtils; +import com.bsd.pjcloud.thirdparty.dto.appmessage.AppManualPushDto; import com.bsd.pjcloud.thirdparty.dto.appmessage.AppPushDeliveryRetryDto; import com.bsd.pjcloud.thirdparty.dto.appmessage.AppPushPolicySaveDto; import com.bsd.pjcloud.thirdparty.dto.appmessage.AppPushTestDto; import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdAppMessageSettingService; +import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdAppManualPushService; import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdPushDeliveryService; import com.bsd.pjcloud.thirdparty.vo.appmessage.AppMessageEventOptionVo; +import com.bsd.pjcloud.thirdparty.vo.appmessage.AppManualPushResultVo; import com.bsd.pjcloud.thirdparty.vo.appmessage.AppPushDeliveryAttemptVo; import com.bsd.pjcloud.thirdparty.vo.appmessage.AppPushDeliveryVo; import com.bsd.pjcloud.thirdparty.vo.appmessage.AppPushPolicyVo; @@ -56,6 +59,24 @@ public class ThirdAppMessagePcController { /** Wallet商户内部服务,用于复核当前管理员可管理的商户数据范围。 */ private final RemoteMchService remoteMchService; + /** 手动批量通知入箱服务,复用既有异步Push队列。 */ + private final ThirdAppManualPushService manualPushService; + + /** + * 为当前可管理商户向选定用户发送统一通知,同时写入App消息列表。 + * + * @param dto 接收用户、纯文本内容与幂等提交标识 + * @return 入箱、加入投递及跳过人数,不表示设备已经收到通知 + */ + @Operation(summary = "手动批量发送App通知") + @SysLog("手动批量发送App通知") + @PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')") + @PostMapping("/manual-push") + public R manualPush(@Valid @RequestBody AppManualPushDto dto) { + requireMerchantAccess(dto.getMchId()); + return R.ok(manualPushService.send(dto, SecurityUtils.getUser().getId())); + } + /** * 查询指定商户全事件策略矩阵。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdAppManualPushService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdAppManualPushService.java new file mode 100644 index 000000000..b90537415 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdAppManualPushService.java @@ -0,0 +1,17 @@ +package com.bsd.pjcloud.thirdparty.service.appmessage; + +import com.bsd.pjcloud.thirdparty.dto.appmessage.AppManualPushDto; +import com.bsd.pjcloud.thirdparty.vo.appmessage.AppManualPushResultVo; + +/** 手动批量通知入箱服务,不参与钱包和用餐的自动事件策略。 */ +public interface ThirdAppManualPushService { + + /** + * 复核接收人权限后原子创建消息与Push任务,相同管理员、商户及提交标识保持幂等。 + * + * @param dto 已通过参数校验且商户管理权限已复核的请求 + * @param operatorId 当前登录管理员ID,不接受客户端提供 + * @return 已入箱、已加入投递及跳过的人数;任一接收人不可用时整批拒绝 + */ + AppManualPushResultVo send(AppManualPushDto dto, Long operatorId); +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppManualPushServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppManualPushServiceImpl.java new file mode 100644 index 000000000..89f7d615c --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppManualPushServiceImpl.java @@ -0,0 +1,235 @@ +package com.bsd.pjcloud.thirdparty.service.impl.appmessage; + +import cn.hutool.crypto.digest.DigestUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.dto.UserDTO; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.admin.api.vo.UserVO; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageConstants; +import com.bsd.pjcloud.thirdparty.dto.appmessage.AppManualPushDto; +import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppMessageEntity; +import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdPushDeliveryEntity; +import com.bsd.pjcloud.thirdparty.mapper.appmessage.ThirdAppMessageMapper; +import com.bsd.pjcloud.thirdparty.mapper.appmessage.ThirdPushDeliveryMapper; +import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdAppManualPushService; +import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdAppMessageSettingService; +import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdPushDeliveryService; +import com.bsd.pjcloud.thirdparty.vo.appmessage.AppManualPushResultVo; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** 手动通知复用现有收件箱和异步投递链路,以数据库唯一约束保证重试不重复入箱。 */ +@Service +@RequiredArgsConstructor +public class ThirdAppManualPushServiceImpl implements ThirdAppManualPushService { + + /** 每次按权限回查的用户数,避免单个远程查询携带过多ID。 */ + private static final int USER_QUERY_BATCH_SIZE = 100; + + /** 当前请求携带登录令牌的UPMS客户端,不使用绕过数据权限的内部用户查询。 */ + private final RemoteUserService remoteUserService; + + /** 商户详情客户端,仅用于读取已经过管理权限校验的商户名称快照。 */ + private final RemoteMchService remoteMchService; + + /** 消息持久化入口,自动应用租户和逻辑删除条件。 */ + private final ThirdAppMessageMapper messageMapper; + + /** 投递任务查询入口,用于统计实际跳过人数。 */ + private final ThirdPushDeliveryMapper deliveryMapper; + + /** 用户Push总开关查询服务,手动通知不读取自动事件或关系订阅开关。 */ + private final ThirdAppMessageSettingService settingService; + + /** 既有投递服务,负责设备校验和异步任务创建,不在请求中调用供应商。 */ + private final ThirdPushDeliveryService deliveryService; + + /** 统一JSON序列化器,用于规范化请求指纹和保存幂等快照。 */ + private final ObjectMapper objectMapper; + + /** 显式事务边界,唯一键冲突必须先回滚,再读取另一并发请求的完整结果。 */ + private final TransactionTemplate transactionTemplate; + + /** + * 校验全部用户后在一个本地事务内写入消息及Push任务;相同提交快照返回既有结果。 + * + * @param dto 已完成参数和商户权限校验的统一消息请求 + * @param operatorId 当前登录管理员ID + * @return 完整入箱结果,任一写入失败时不保留部分接收人消息 + */ + @Override + public AppManualPushResultVo send(AppManualPushDto dto, Long operatorId) { + if (operatorId == null || TenantContextHolder.getTenantId() == null) { + throw new IllegalArgumentException("登录信息无效,请重新登录"); + } + List userIds = dto.getUserIds().stream().distinct().sorted().toList(); + if (userIds.isEmpty() || userIds.size() > AppManualPushDto.MAX_RECIPIENTS) { + throw new IllegalArgumentException("请选择1至500位接收用户"); + } + requireVisibleRecipients(userIds); + String eventId = "MANUAL:" + operatorId + ":" + dto.getMchId() + ":" + dto.getRequestId(); + String fingerprint = DigestUtil.sha256Hex(serialize(List.of(operatorId, dto.getMchId(), userIds, + dto.getTitle().trim(), dto.getContent().trim()))); + String payloadJson = serialize(Map.of("requestHash", fingerprint)); + List existing = findBatch(eventId); + if (!existing.isEmpty()) { + return replayResult(dto, userIds, payloadJson, existing); + } + R merchant = remoteMchService.getById(dto.getMchId()); + if (merchant == null || !merchant.isOk() || merchant.getData() == null) { + throw new IllegalArgumentException("来源商户不存在或暂时无法读取"); + } + try { + return Objects.requireNonNull(transactionTemplate.execute(status -> + createBatch(dto, userIds, eventId, payloadJson, merchant.getData().getName()))); + } catch (DuplicateKeyException conflict) { + // 两个相同请求同时通过首次查询时,唯一键会让其中一个事务整体回滚;此处已在事务外。 + List committed = findBatch(eventId); + if (committed.isEmpty()) throw conflict; + return replayResult(dto, userIds, payloadJson, committed); + } + } + + /** + * 按当前登录令牌逐批复核用户,任何不存在、跨租户或不在数据范围内的ID都导致整批失败。 + * + * @param userIds 去重后的完整接收人ID + */ + private void requireVisibleRecipients(List userIds) { + for (int offset = 0; offset < userIds.size(); offset += USER_QUERY_BATCH_SIZE) { + List batch = userIds.subList(offset, Math.min(offset + USER_QUERY_BATCH_SIZE, userIds.size())); + UserDTO query = new UserDTO(); + query.setUserIds(batch); + R> response = remoteUserService.getScopeBasicUserPageWithCountOption(1L, + (long) USER_QUERY_BATCH_SIZE, false, query); + if (response == null || !response.isOk() || response.getData() == null || response.getData().getRecords() == null) { + throw new IllegalStateException("复核接收用户失败,请稍后重试"); + } + Set visible = response.getData().getRecords().stream().map(UserVO::getUserId).collect(Collectors.toSet()); + if (!visible.containsAll(batch)) { + throw new IllegalArgumentException("部分接收用户已不可用或不在当前权限范围内,请重新选择"); + } + } + } + + /** + * 创建整个批次的本人消息与投递任务,调用方必须已开启本地事务。 + * + * @param dto 本次通知请求 + * @param userIds 已经复核权限的接收人ID + * @param eventId 管理员、商户与提交标识组成的稳定事件ID + * @param payloadJson 请求指纹快照,用于阻止同一标识被不同内容复用 + * @param merchantName 来源商户名称快照 + * @return 全部接收人的入箱与投递汇总 + */ + private AppManualPushResultVo createBatch(AppManualPushDto dto, List userIds, String eventId, String payloadJson, String merchantName) { + LocalDateTime occurredTime = LocalDateTime.now(); + List messages = new ArrayList<>(); + for (Long userId : userIds) { + ThirdAppMessageEntity message = new ThirdAppMessageEntity(); + message.setTenantId(TenantContextHolder.getTenantId()); + message.setEventId(eventId); + message.setEventCode(AppMessageConstants.EVENT_MANUAL_PUSH); + message.setRecipientUserId(userId); + message.setSubjectUserId(userId); + message.setRecipientType(AppMessageConstants.RECIPIENT_OWNER); + message.setSourceService(AppMessageConstants.SOURCE_THIRD); + message.setSourceBizType(AppMessageConstants.EVENT_MANUAL_PUSH); + message.setSourceBizId(dto.getRequestId()); + message.setSourceMchId(dto.getMchId()); + message.setSourceMchName(merchantName); + message.setTitle(dto.getTitle().trim()); + message.setContent(dto.getContent().trim()); + message.setPayloadJson(payloadJson); + message.setDeepLinkPath(AppMessageConstants.MESSAGE_DETAIL_PATH); + message.setReadStatus(AppMessageConstants.UNREAD); + message.setOccurredTime(occurredTime); + if (messageMapper.insert(message) != 1 || message.getId() == null) { + throw new IllegalStateException("写入通知消息失败"); + } + String skipReason = settingService.isUserPushEnabled(userId) ? null : AppMessageConstants.SKIP_USER_DISABLED; + deliveryService.createDelivery(message, skipReason); + messages.add(message); + } + return summarize(dto.getRequestId(), messages); + } + + /** + * 查询当前租户下一个稳定事件的全部消息,供首次判断和并发冲突后回查。 + * + * @param eventId 本次稳定事件ID + * @return 已提交的完整消息集合,未提交时为空 + */ + private List findBatch(String eventId) { + return messageMapper.selectList(Wrappers.lambdaQuery().eq(ThirdAppMessageEntity::getEventId, eventId)); + } + + /** + * 校验已存在批次与本次快照完全一致,禁止复用同一标识修改内容或追加接收人。 + * + * @param dto 当前请求 + * @param userIds 本次规范化的接收人ID + * @param payloadJson 本次请求指纹JSON + * @param messages 已完整提交的消息快照 + * @return 与首次提交一致的入箱与投递汇总 + */ + private AppManualPushResultVo replayResult(AppManualPushDto dto, List userIds, String payloadJson, List messages) { + Set recipients = messages.stream().map(ThirdAppMessageEntity::getRecipientUserId).collect(Collectors.toSet()); + if (messages.size() != userIds.size() || !recipients.equals(Set.copyOf(userIds)) + || messages.stream().anyMatch(message -> !payloadJson.equals(message.getPayloadJson()))) { + throw new IllegalArgumentException("提交标识已用于其他通知内容,请重新发起发送"); + } + return summarize(dto.getRequestId(), messages); + } + + /** + * 从实际投递任务统计跳过人数,重放时已处理任务仍计为已加入投递。 + * + * @param requestId 客户端幂等标识 + * @param messages 已成功入箱的消息集合 + * @return 不包含设备已送达承诺的汇总结果 + */ + private AppManualPushResultVo summarize(String requestId, List messages) { + List messageIds = messages.stream().map(ThirdAppMessageEntity::getId).toList(); + long skipped = deliveryMapper.selectCount(Wrappers.lambdaQuery() + .in(ThirdPushDeliveryEntity::getMessageId, messageIds) + .eq(ThirdPushDeliveryEntity::getStatus, AppMessageConstants.DELIVERY_SKIPPED)); + AppManualPushResultVo result = new AppManualPushResultVo(); + result.setRequestId(requestId); + result.setRecipientCount(messages.size()); + result.setSkippedCount((int) skipped); + result.setQueuedCount(messages.size() - (int) skipped); + return result; + } + + /** + * 使用统一序列化器生成确定性的快照,失败时在写库前终止请求。 + * + * @param value 接收人、正文或单字段指纹快照 + * @return JSON字符串 + */ + private String serialize(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("生成通知快照失败", exception); + } + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java index dccc14ea9..a55c2a2a4 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java @@ -625,7 +625,8 @@ public class ThirdAppMessageServiceImpl implements ThirdAppMessageService { AppMessageVo vo = new AppMessageVo(); BeanUtils.copyProperties(entity, vo); AppMessageEventEnum event = AppMessageEventEnum.fromCode(entity.getEventCode()); - vo.setCategory(event == null ? null : event.getCategory()); + vo.setCategory(AppMessageConstants.EVENT_MANUAL_PUSH.equals(entity.getEventCode()) + ? AppMessageConstants.CATEGORY_SYSTEM : event == null ? null : event.getCategory()); vo.setRead(AppMessageConstants.READ.equals(entity.getReadStatus())); vo.setTitle(resolveLocalizedTitle(entity, event)); vo.setContent(resolveLocalizedContent(entity, event)); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppManualPushServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppManualPushServiceImplTest.java new file mode 100644 index 000000000..117b858e2 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppManualPushServiceImplTest.java @@ -0,0 +1,248 @@ +package com.bsd.pjcloud.thirdparty.service.impl.appmessage; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.dto.UserDTO; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.admin.api.vo.UserVO; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageConstants; +import com.bsd.pjcloud.thirdparty.dto.appmessage.AppManualPushDto; +import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppMessageEntity; +import com.bsd.pjcloud.thirdparty.mapper.appmessage.ThirdAppMessageMapper; +import com.bsd.pjcloud.thirdparty.mapper.appmessage.ThirdPushDeliveryMapper; +import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdAppMessageSettingService; +import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdPushDeliveryService; +import com.bsd.pjcloud.thirdparty.vo.appmessage.AppManualPushResultVo; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.Validation; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.transaction.support.SimpleTransactionStatus; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.LongStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** 手动通知的接收人权限、入箱快照、跳过统计和提交幂等规则单元测试。 */ +class ThirdAppManualPushServiceImplTest { + + /** 测试租户,验证入箱记录继承当前租户上下文。 */ + private static final Long TENANT_ID = 10L; + + /** 当前管理员ID,参与幂等事件隔离。 */ + private static final Long OPERATOR_ID = 20L; + + /** 已通过Controller商户管理权限校验的来源商户。 */ + private static final Long MERCHANT_ID = 30L; + + /** 权限分页客户端Mock,默认返回本批次全部可见用户。 */ + private final RemoteUserService remoteUserService = mock(RemoteUserService.class); + + /** 来源商户名称快照客户端Mock。 */ + private final RemoteMchService remoteMchService = mock(RemoteMchService.class); + + /** 消息Mapper Mock,使用内存集合观察是否重复写入。 */ + private final ThirdAppMessageMapper messageMapper = mock(ThirdAppMessageMapper.class); + + /** 投递统计Mapper Mock,具体测试按需模拟跳过人数。 */ + private final ThirdPushDeliveryMapper deliveryMapper = mock(ThirdPushDeliveryMapper.class); + + /** 用户Push总开关Mock,默认允许通知栏推送。 */ + private final ThirdAppMessageSettingService settingService = mock(ThirdAppMessageSettingService.class); + + /** 既有投递服务Mock,用于核对关闭开关时传入的跳过原因。 */ + private final ThirdPushDeliveryService deliveryService = mock(ThirdPushDeliveryService.class); + + /** 事务模板Mock,仅执行回调;真实数据库回滚和唯一约束仍需集成验证。 */ + private final TransactionTemplate transactionTemplate = mock(TransactionTemplate.class); + + /** 当前测试观察到的消息快照,模拟完整批次的已提交查询。 */ + private final List messages = new ArrayList<>(); + + /** 设置当前租户和最小依赖响应,不启动Spring上下文或调用推送供应商。 */ + @BeforeEach + void setUp() { + TenantContextHolder.setTenantId(TENANT_ID); + when(remoteUserService.getScopeBasicUserPageWithCountOption(eq(1L), eq(100L), eq(false), any(UserDTO.class))) + .thenAnswer(invocation -> visibleUsers(invocation.getArgument(3).getUserIds())); + MchManageVo merchant = new MchManageVo(); + merchant.setName("测试商户"); + when(remoteMchService.getById(MERCHANT_ID)).thenReturn(R.ok(merchant)); + when(messageMapper.selectList(any())).thenAnswer(invocation -> List.copyOf(messages)); + when(messageMapper.insert(any(ThirdAppMessageEntity.class))).thenAnswer(invocation -> { + ThirdAppMessageEntity message = invocation.getArgument(0); + message.setId(1000L + messages.size()); + messages.add(message); + return 1; + }); + when(settingService.isUserPushEnabled(any(Long.class))).thenReturn(true); + when(deliveryMapper.selectCount(any())).thenReturn(0L); + when(transactionTemplate.execute(any())).thenAnswer(invocation -> { + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(new SimpleTransactionStatus()); + }); + } + + /** 释放线程租户,避免影响同一进程的其他测试。 */ + @AfterEach + void tearDown() { + TenantContextHolder.clear(); + } + + /** 去重接收人后为每人写入一条未读原文消息,且不会扩散为关系消息。 */ + @Test + void sendsOneOwnerMessagePerDistinctVisibleUser() { + AppManualPushDto dto = request(); + dto.setUserIds(List.of(2L, 1L, 1L)); + + AppManualPushResultVo result = service().send(dto, OPERATOR_ID); + + assertEquals(2, result.getRecipientCount()); + assertEquals(2, result.getQueuedCount()); + assertEquals(0, result.getSkippedCount()); + assertEquals(List.of(1L, 2L), messages.stream().map(ThirdAppMessageEntity::getRecipientUserId).toList()); + messages.forEach(message -> { + assertEquals(TENANT_ID, message.getTenantId()); + assertEquals(MERCHANT_ID, message.getSourceMchId()); + assertEquals(AppMessageConstants.EVENT_MANUAL_PUSH, message.getEventCode()); + assertEquals(AppMessageConstants.RECIPIENT_OWNER, message.getRecipientType()); + assertEquals(message.getRecipientUserId(), message.getSubjectUserId()); + assertEquals(AppMessageConstants.UNREAD, message.getReadStatus()); + assertEquals("第一行\n第二行", message.getContent()); + assertNull(message.getRelationId()); + }); + verify(deliveryService, times(2)).createDelivery(any(ThirdAppMessageEntity.class), eq(null)); + } + + /** 任一用户不在当前数据权限内时,在写库之前整批拒绝。 */ + @Test + void rejectsEntireBatchWhenOneRecipientIsNotVisible() { + when(remoteUserService.getScopeBasicUserPageWithCountOption(eq(1L), eq(100L), eq(false), any(UserDTO.class))) + .thenReturn(visibleUsers(List.of(1L))); + + assertThrows(IllegalArgumentException.class, () -> service().send(request(), OPERATOR_ID)); + + verify(messageMapper, never()).insert(any(ThirdAppMessageEntity.class)); + verify(transactionTemplate, never()).execute(any()); + } + + /** 用户关闭Push总开关时仍然入箱,统计结果以实际投递跳过任务为准。 */ + @Test + void keepsInboxWhenUserDisabledPushOrHasNoDevice() { + when(settingService.isUserPushEnabled(1L)).thenReturn(false); + when(deliveryMapper.selectCount(any())).thenReturn(2L); + + AppManualPushResultVo result = service().send(request(), OPERATOR_ID); + + assertEquals(2, messages.size()); + assertEquals(0, result.getQueuedCount()); + assertEquals(2, result.getSkippedCount()); + verify(deliveryService).createDelivery(eq(messages.get(0)), eq(AppMessageConstants.SKIP_USER_DISABLED)); + verify(deliveryService).createDelivery(eq(messages.get(1)), eq(null)); + } + + /** 相同提交标识和快照重试时仅返回既有结果,不重复入箱或创建投递任务。 */ + @Test + void replaysSameRequestWithoutDuplicateMessages() { + ThirdAppManualPushServiceImpl service = service(); + service.send(request(), OPERATOR_ID); + + AppManualPushResultVo replay = service.send(request(), OPERATOR_ID); + + assertEquals(2, replay.getRecipientCount()); + assertEquals(2, messages.size()); + verify(messageMapper, times(2)).insert(any(ThirdAppMessageEntity.class)); + verify(transactionTemplate, times(1)).execute(any()); + } + + /** 同一提交标识不能用于修改正文,否则可能被误当成已成功发送。 */ + @Test + void rejectsChangedContentWithSameRequestId() { + ThirdAppManualPushServiceImpl service = service(); + service.send(request(), OPERATOR_ID); + AppManualPushDto changed = request(); + changed.setContent("另一条通知"); + + assertThrows(IllegalArgumentException.class, () -> service.send(changed, OPERATOR_ID)); + assertEquals(2, messages.size()); + } + + /** 模拟并发事务已提交后的唯一键冲突,回滚退出后必须回读完整结果。 */ + @Test + void readsCommittedResultAfterConcurrentDuplicateKey() { + ThirdAppManualPushServiceImpl service = service(); + service.send(request(), OPERATOR_ID); + List committed = List.copyOf(messages); + when(messageMapper.selectList(any())).thenReturn(List.of(), committed); + when(transactionTemplate.execute(any())).thenThrow(new DuplicateKeyException("concurrent request")); + + AppManualPushResultVo replay = service.send(request(), OPERATOR_ID); + + assertEquals(2, replay.getRecipientCount()); + assertEquals(2, messages.size()); + } + + /** API参数校验拒绝超过500人、纯空白标题和超过现有列宽的正文。 */ + @Test + void validatesRecipientLimitAndPlainTextBounds() { + AppManualPushDto dto = request(); + dto.setUserIds(LongStream.rangeClosed(1, 501).boxed().toList()); + dto.setTitle(" "); + dto.setContent("字".repeat(2001)); + try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) { + Set fields = factory.getValidator().validate(dto).stream() + .map(violation -> violation.getPropertyPath().toString()).collect(Collectors.toSet()); + assertTrue(fields.containsAll(Set.of("userIds", "title", "content"))); + } + } + + /** 创建独立服务实例,事务Mock不代替真实数据库集成验证。 */ + private ThirdAppManualPushServiceImpl service() { + return new ThirdAppManualPushServiceImpl(remoteUserService, remoteMchService, messageMapper, deliveryMapper, + settingService, deliveryService, new ObjectMapper(), transactionTemplate); + } + + /** 构造具备稳定标识、两位用户及多行正文的有效提交快照。 */ + private AppManualPushDto request() { + AppManualPushDto dto = new AppManualPushDto(); + dto.setMchId(MERCHANT_ID); + dto.setUserIds(List.of(1L, 2L)); + dto.setTitle("统一通知"); + dto.setContent("第一行\n第二行"); + dto.setRequestId("manual_push_request_001"); + return dto; + } + + /** 构造当前权限分页响应,仅返回本测试指定的可见用户ID。 */ + private R> visibleUsers(List ids) { + Page page = new Page<>(1, 100); + page.setRecords(ids.stream().map(id -> { + UserVO user = new UserVO(); + user.setUserId(id); + return user; + }).toList()); + return R.ok(page); + } +}