feat:手动推送消息
This commit is contained in:
1 parent
8f303f1c63
commit
a00f7aa80d
9 files changed
+600
-2
No files matched your search
+45
@@ -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;
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+1
-1
@@ -17,7 +17,7 @@ public class AppMessageVo implements Serializable {
|
||||
/** 固定业务事件编码。 */
|
||||
private String eventCode;
|
||||
|
||||
/** 事件展示分组,wallet或meal。 */
|
||||
/** 事件展示分组,wallet钱包、meal用餐、system人工通知。 */
|
||||
private String category;
|
||||
|
||||
/** 接收类型,OWNER本人或RELATION关系人。 */
|
||||
|
||||
+9
@@ -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";
|
||||
|
||||
|
||||
+21
@@ -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<AppManualPushResultVo> manualPush(@Valid @RequestBody AppManualPushDto dto) {
|
||||
requireMerchantAccess(dto.getMchId());
|
||||
return R.ok(manualPushService.send(dto, SecurityUtils.getUser().getId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定商户全事件策略矩阵。
|
||||
*
|
||||
|
||||
+17
@@ -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);
|
||||
}
|
||||
+235
@@ -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<Long> 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<ThirdAppMessageEntity> existing = findBatch(eventId);
|
||||
if (!existing.isEmpty()) {
|
||||
return replayResult(dto, userIds, payloadJson, existing);
|
||||
}
|
||||
R<MchManageVo> 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<ThirdAppMessageEntity> committed = findBatch(eventId);
|
||||
if (committed.isEmpty()) throw conflict;
|
||||
return replayResult(dto, userIds, payloadJson, committed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前登录令牌逐批复核用户,任何不存在、跨租户或不在数据范围内的ID都导致整批失败。
|
||||
*
|
||||
* @param userIds 去重后的完整接收人ID
|
||||
*/
|
||||
private void requireVisibleRecipients(List<Long> userIds) {
|
||||
for (int offset = 0; offset < userIds.size(); offset += USER_QUERY_BATCH_SIZE) {
|
||||
List<Long> batch = userIds.subList(offset, Math.min(offset + USER_QUERY_BATCH_SIZE, userIds.size()));
|
||||
UserDTO query = new UserDTO();
|
||||
query.setUserIds(batch);
|
||||
R<Page<UserVO>> 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<Long> 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<Long> userIds, String eventId, String payloadJson, String merchantName) {
|
||||
LocalDateTime occurredTime = LocalDateTime.now();
|
||||
List<ThirdAppMessageEntity> 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<ThirdAppMessageEntity> findBatch(String eventId) {
|
||||
return messageMapper.selectList(Wrappers.<ThirdAppMessageEntity>lambdaQuery().eq(ThirdAppMessageEntity::getEventId, eventId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验已存在批次与本次快照完全一致,禁止复用同一标识修改内容或追加接收人。
|
||||
*
|
||||
* @param dto 当前请求
|
||||
* @param userIds 本次规范化的接收人ID
|
||||
* @param payloadJson 本次请求指纹JSON
|
||||
* @param messages 已完整提交的消息快照
|
||||
* @return 与首次提交一致的入箱与投递汇总
|
||||
*/
|
||||
private AppManualPushResultVo replayResult(AppManualPushDto dto, List<Long> userIds, String payloadJson, List<ThirdAppMessageEntity> messages) {
|
||||
Set<Long> 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<ThirdAppMessageEntity> messages) {
|
||||
List<Long> messageIds = messages.stream().map(ThirdAppMessageEntity::getId).toList();
|
||||
long skipped = deliveryMapper.selectCount(Wrappers.<ThirdPushDeliveryEntity>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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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));
|
||||
|
||||
+248
@@ -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<ThirdAppMessageEntity> 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.<UserDTO>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<ThirdAppMessageEntity> 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<String> 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<Page<UserVO>> visibleUsers(List<Long> ids) {
|
||||
Page<UserVO> 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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user