Compare commits
14 Commits
2026-04
...
9da61452a9
| Author | SHA1 | Date | |
|---|---|---|---|
| 9da61452a9 | |||
| c892f71711 | |||
| 8231b1e0e1 | |||
| f076dcd9b2 | |||
| 16974d04f9 | |||
| cbfee6b156 | |||
| 99c8bc3636 | |||
|
|
c2bbdd865d | ||
|
|
e9f66ca0b2 | ||
|
|
8f86c563f0 | ||
|
|
db15799440 | ||
|
|
c4b56a727c | ||
|
|
fe26aa8670 | ||
|
|
9343799290 |
@@ -4,6 +4,7 @@ import com.njcn.msgpush.module.push.checker.impl.BlacklistChecker;
|
|||||||
import com.njcn.msgpush.module.push.checker.impl.QuotaChecker;
|
import com.njcn.msgpush.module.push.checker.impl.QuotaChecker;
|
||||||
import com.njcn.msgpush.module.push.checker.impl.RateLimitChecker;
|
import com.njcn.msgpush.module.push.checker.impl.RateLimitChecker;
|
||||||
import com.njcn.msgpush.module.push.dal.dataobject.message.MessageRecordDO;
|
import com.njcn.msgpush.module.push.dal.dataobject.message.MessageRecordDO;
|
||||||
|
import com.njcn.msgpush.module.push.enums.RetrySourceEnum;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@@ -24,9 +25,15 @@ public class MsgPushGuardChain {
|
|||||||
private RateLimitChecker rateLimitChecker;
|
private RateLimitChecker rateLimitChecker;
|
||||||
|
|
||||||
|
|
||||||
public void checkAll(List<MessageRecordDO> messageRecordList) {
|
public void checkAll(List<MessageRecordDO> messageRecordList, RetrySourceEnum retrySource) {
|
||||||
|
// 黑名单:首发与重发(含限流自动重试)都必须执行
|
||||||
blacklistChecker.check(messageRecordList);
|
blacklistChecker.check(messageRecordList);
|
||||||
quotaChecker.check(messageRecordList);
|
// 配额/接收者频率:系统自动重试(限流类无限重试)完全豁免——否则会被守卫置终态、队列记录被删,
|
||||||
rateLimitChecker.check(messageRecordList);
|
// 无限重试静默失效。重发节奏由 120s 固定间隔 + 12h 兜底天然封顶,无需配额墙再约束。
|
||||||
|
// 首发(retrySource==null)与人工重试(MANUAL_RETRY)仍正常受这两道守卫。
|
||||||
|
if (retrySource != RetrySourceEnum.AUTO_RETRY) {
|
||||||
|
quotaChecker.check(messageRecordList);
|
||||||
|
rateLimitChecker.check(messageRecordList);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ public class AliyunProviderFactory implements MessageProviderFactory {
|
|||||||
@Override
|
@Override
|
||||||
public SmsSender createSmsSender(ChannelProviderConfigDO config, Sender sender) {
|
public SmsSender createSmsSender(ChannelProviderConfigDO config, Sender sender) {
|
||||||
// 配置缺失时返回固定结果 sender,由主流程统一落库为配置异常。
|
// 配置缺失时返回固定结果 sender,由主流程统一落库为配置异常。
|
||||||
if (ProviderFactorySupport.hasBlank(config.getAppKey(), config.getAppSecret())) {
|
if (ProviderFactorySupport.hasBlank(config.getAppKey(), config.getSecret())) {
|
||||||
return new FixedResultSmsSender(SendOutcome.CONFIG_INVALID, "当前服务商短信渠道配置不完整");
|
return new FixedResultSmsSender(SendOutcome.CONFIG_INVALID, "当前服务商短信渠道配置不完整");
|
||||||
}
|
}
|
||||||
AliYunMailSetting aliYunSmsSetting = AliYunMailSetting.builder()
|
AliYunMailSetting aliYunSmsSetting = AliYunMailSetting.builder()
|
||||||
.accessKeyId(config.getAppKey())
|
.accessKeyId(config.getAppKey())
|
||||||
.accessKeySecret(config.getAppSecret())
|
.accessKeySecret(config.getSecret())
|
||||||
.regionId("cn-hangzhou")
|
.regionId("cn-hangzhou")
|
||||||
.endpoint("dysmsapi.aliyuncs.com")
|
.endpoint("dysmsapi.aliyuncs.com")
|
||||||
.build();
|
.build();
|
||||||
@@ -35,12 +35,12 @@ public class AliyunProviderFactory implements MessageProviderFactory {
|
|||||||
@Override
|
@Override
|
||||||
public EmailSender createEmailSender(ChannelProviderConfigDO config, Sender sender) {
|
public EmailSender createEmailSender(ChannelProviderConfigDO config, Sender sender) {
|
||||||
// 配置缺失时返回固定结果 sender,由主流程统一落库为配置异常。
|
// 配置缺失时返回固定结果 sender,由主流程统一落库为配置异常。
|
||||||
if (ProviderFactorySupport.hasBlank(config.getAppKey(), config.getAppSecret())) {
|
if (ProviderFactorySupport.hasBlank(config.getAppKey(), config.getSecret())) {
|
||||||
return new FixedResultEmailSender(SendOutcome.CONFIG_INVALID, "当前服务商邮件渠道配置不完整");
|
return new FixedResultEmailSender(SendOutcome.CONFIG_INVALID, "当前服务商邮件渠道配置不完整");
|
||||||
}
|
}
|
||||||
AliYunMailSetting aliYunMailSetting = AliYunMailSetting.builder()
|
AliYunMailSetting aliYunMailSetting = AliYunMailSetting.builder()
|
||||||
.accessKeyId(config.getAppKey())
|
.accessKeyId(config.getAppKey())
|
||||||
.accessKeySecret(config.getAppSecret())
|
.accessKeySecret(config.getSecret())
|
||||||
.regionId("cn-hangzhou")
|
.regionId("cn-hangzhou")
|
||||||
.endpoint("dm.aliyuncs.com")
|
.endpoint("dm.aliyuncs.com")
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ public class TelecomProviderFactory implements MessageProviderFactory {
|
|||||||
@Override
|
@Override
|
||||||
public SmsSender createSmsSender(ChannelProviderConfigDO config, Sender sender) {
|
public SmsSender createSmsSender(ChannelProviderConfigDO config, Sender sender) {
|
||||||
// 配置缺失时返回固定结果 sender,由主流程统一落库为配置异常。
|
// 配置缺失时返回固定结果 sender,由主流程统一落库为配置异常。
|
||||||
if (ProviderFactorySupport.hasBlank(config.getAppKey(), config.getAppSecret(), config.getApiUrl())) {
|
if (ProviderFactorySupport.hasBlank(config.getAppKey(), config.getSecret(), config.getApiUrl())) {
|
||||||
return new FixedResultSmsSender(SendOutcome.CONFIG_INVALID, "当前服务商短信渠道配置不完整");
|
return new FixedResultSmsSender(SendOutcome.CONFIG_INVALID, "当前服务商短信渠道配置不完整");
|
||||||
}
|
}
|
||||||
TelecomSmsSetting telecomSmsSetting = TelecomSmsSetting.builder()
|
TelecomSmsSetting telecomSmsSetting = TelecomSmsSetting.builder()
|
||||||
.account(config.getAppKey())
|
.account(config.getAppKey())
|
||||||
.password(config.getAppSecret())
|
.password(config.getSecret())
|
||||||
.apiUrl(config.getApiUrl())
|
.apiUrl(config.getApiUrl())
|
||||||
.extno(config.getExtno())
|
.extno(config.getExtno())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
package com.njcn.msgpush.module.push.client.factory.impl;
|
package com.njcn.msgpush.module.push.client.factory.impl;
|
||||||
|
|
||||||
import cn.hutool.core.util.StrUtil;
|
|
||||||
import com.alibaba.fastjson.JSON;
|
|
||||||
import com.alibaba.fastjson.JSONObject;
|
|
||||||
import com.njcn.msgpush.module.push.client.factory.MessageProviderFactory;
|
import com.njcn.msgpush.module.push.client.factory.MessageProviderFactory;
|
||||||
import com.njcn.msgpush.module.push.client.factory.ProviderFactorySupport;
|
import com.njcn.msgpush.module.push.client.factory.ProviderFactorySupport;
|
||||||
import com.njcn.msgpush.module.push.client.sender.AppPushSender;
|
import com.njcn.msgpush.module.push.client.sender.*;
|
||||||
import com.njcn.msgpush.module.push.client.sender.EmailSender;
|
|
||||||
import com.njcn.msgpush.module.push.client.sender.SendOutcome;
|
|
||||||
import com.njcn.msgpush.module.push.client.sender.Sender;
|
|
||||||
import com.njcn.msgpush.module.push.client.sender.SmsSender;
|
|
||||||
import com.njcn.msgpush.module.push.client.sender.fallback.FixedResultAppPushSender;
|
import com.njcn.msgpush.module.push.client.sender.fallback.FixedResultAppPushSender;
|
||||||
import com.njcn.msgpush.module.push.client.sender.fallback.FixedResultEmailSender;
|
import com.njcn.msgpush.module.push.client.sender.fallback.FixedResultEmailSender;
|
||||||
import com.njcn.msgpush.module.push.client.sender.fallback.FixedResultSmsSender;
|
import com.njcn.msgpush.module.push.client.sender.fallback.FixedResultSmsSender;
|
||||||
@@ -19,8 +12,6 @@ import com.njcn.msgpush.module.push.dal.dataobject.channel.ChannelProviderConfig
|
|||||||
|
|
||||||
public class UniPushProviderFactory implements MessageProviderFactory {
|
public class UniPushProviderFactory implements MessageProviderFactory {
|
||||||
|
|
||||||
private static final String APP_ID = "appId";
|
|
||||||
private static final String MASTER_SECRET = "masterSecret";
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public SmsSender createSmsSender(ChannelProviderConfigDO config, Sender sender) {
|
public SmsSender createSmsSender(ChannelProviderConfigDO config, Sender sender) {
|
||||||
@@ -36,18 +27,15 @@ public class UniPushProviderFactory implements MessageProviderFactory {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AppPushSender createAppPushSender(ChannelProviderConfigDO config, Sender sender) {
|
public AppPushSender createAppPushSender(ChannelProviderConfigDO config, Sender sender) {
|
||||||
JSONObject extraConfig = StrUtil.isBlank(config.getExtraConfig()) ? new JSONObject() : JSON.parseObject(config.getExtraConfig());
|
|
||||||
String appId = extraConfig.getString(APP_ID);
|
|
||||||
String masterSecret = extraConfig.getString(MASTER_SECRET);
|
|
||||||
// 配置缺失时返回固定结果 sender,由主流程统一落库为配置异常。
|
// 配置缺失时返回固定结果 sender,由主流程统一落库为配置异常。
|
||||||
if (ProviderFactorySupport.hasBlank(appId, config.getAppKey(), config.getAppSecret(), masterSecret)) {
|
if (ProviderFactorySupport.hasBlank(config.getSecret())) {
|
||||||
return new FixedResultAppPushSender(SendOutcome.CONFIG_INVALID, "当前服务商 APP 推送渠道配置不完整");
|
return new FixedResultAppPushSender(SendOutcome.CONFIG_INVALID, "当前服务商 APP 推送渠道配置不完整");
|
||||||
}
|
}
|
||||||
UniPushAppPushSetting uniPushAppPushSetting = new UniPushAppPushSetting(
|
UniPushAppPushSetting uniPushAppPushSetting = new UniPushAppPushSetting(
|
||||||
appId,
|
config.getAppId(),
|
||||||
config.getAppKey(),
|
config.getAppKey(),
|
||||||
config.getAppSecret(),
|
config.getSecret(),
|
||||||
masterSecret
|
config.getApiUrl()
|
||||||
);
|
);
|
||||||
return new UniPushAppPushSender(uniPushAppPushSetting, sender);
|
return new UniPushAppPushSender(uniPushAppPushSetting, sender);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
package com.njcn.msgpush.module.push.client.sender.impl.apppush;
|
package com.njcn.msgpush.module.push.client.sender.impl.apppush;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
import com.getui.push.v2.sdk.ApiHelper;
|
import com.getui.push.v2.sdk.ApiHelper;
|
||||||
import com.getui.push.v2.sdk.GtApiConfiguration;
|
import com.getui.push.v2.sdk.GtApiConfiguration;
|
||||||
import com.getui.push.v2.sdk.api.PushApi;
|
import com.getui.push.v2.sdk.api.PushApi;
|
||||||
import com.getui.push.v2.sdk.common.ApiResult;
|
import com.getui.push.v2.sdk.common.ApiResult;
|
||||||
import com.getui.push.v2.sdk.dto.req.Audience;
|
import com.getui.push.v2.sdk.dto.req.Audience;
|
||||||
import com.getui.push.v2.sdk.dto.req.Settings;
|
import com.getui.push.v2.sdk.dto.req.Settings;
|
||||||
|
import com.getui.push.v2.sdk.dto.req.message.PushChannel;
|
||||||
import com.getui.push.v2.sdk.dto.req.message.PushDTO;
|
import com.getui.push.v2.sdk.dto.req.message.PushDTO;
|
||||||
import com.getui.push.v2.sdk.dto.req.message.PushMessage;
|
import com.getui.push.v2.sdk.dto.req.message.PushMessage;
|
||||||
|
import com.getui.push.v2.sdk.dto.req.message.android.AndroidDTO;
|
||||||
import com.getui.push.v2.sdk.dto.req.message.android.GTNotification;
|
import com.getui.push.v2.sdk.dto.req.message.android.GTNotification;
|
||||||
|
import com.getui.push.v2.sdk.dto.req.message.android.ThirdNotification;
|
||||||
|
import com.getui.push.v2.sdk.dto.req.message.android.Ups;
|
||||||
|
import com.getui.push.v2.sdk.dto.req.message.ios.Alert;
|
||||||
|
import com.getui.push.v2.sdk.dto.req.message.ios.Aps;
|
||||||
|
import com.getui.push.v2.sdk.dto.req.message.ios.IosDTO;
|
||||||
import com.njcn.msgpush.module.push.client.sender.AppPushSender;
|
import com.njcn.msgpush.module.push.client.sender.AppPushSender;
|
||||||
import com.njcn.msgpush.module.push.client.sender.SendResult;
|
import com.njcn.msgpush.module.push.client.sender.SendResult;
|
||||||
import com.njcn.msgpush.module.push.client.sender.Sender;
|
import com.njcn.msgpush.module.push.client.sender.Sender;
|
||||||
@@ -34,7 +42,13 @@ public class UniPushAppPushSender implements AppPushSender {
|
|||||||
gtApiConfiguration.setAppId(uniPushAppPushSetting.getAppId());
|
gtApiConfiguration.setAppId(uniPushAppPushSetting.getAppId());
|
||||||
gtApiConfiguration.setAppKey(uniPushAppPushSetting.getAppKey());
|
gtApiConfiguration.setAppKey(uniPushAppPushSetting.getAppKey());
|
||||||
gtApiConfiguration.setMasterSecret(uniPushAppPushSetting.getMasterSecret());
|
gtApiConfiguration.setMasterSecret(uniPushAppPushSetting.getMasterSecret());
|
||||||
gtApiConfiguration.setDomain("https://restapi.getui.com/v2/");
|
// 使用配置的 API 地址,如果未配置则使用默认地址
|
||||||
|
String apiUrl = uniPushAppPushSetting.getApiUrl();
|
||||||
|
if (StrUtil.isNotBlank(apiUrl)) {
|
||||||
|
gtApiConfiguration.setDomain(apiUrl);
|
||||||
|
} else {
|
||||||
|
gtApiConfiguration.setDomain("https://restapi.getui.com/v2/");
|
||||||
|
}
|
||||||
ApiHelper apiHelper = ApiHelper.build(gtApiConfiguration);
|
ApiHelper apiHelper = ApiHelper.build(gtApiConfiguration);
|
||||||
this.pushApi = apiHelper.creatApi(PushApi.class);
|
this.pushApi = apiHelper.creatApi(PushApi.class);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -89,13 +103,48 @@ public class UniPushAppPushSender implements AppPushSender {
|
|||||||
settings.setTtl(3600000);
|
settings.setTtl(3600000);
|
||||||
pushDTO.setSettings(settings);
|
pushDTO.setSettings(settings);
|
||||||
|
|
||||||
|
// 创建 PushMessage,设置透传消息(可选)
|
||||||
PushMessage pushMessage = new PushMessage();
|
PushMessage pushMessage = new PushMessage();
|
||||||
GTNotification notification = new GTNotification();
|
pushMessage.setTransmission("{\"title\":\"" + title + "\",\"body\":\"" + content + "\"}");
|
||||||
notification.setTitle(title);
|
|
||||||
notification.setBody(content);
|
// 在线推送 - 使用 GTNotification
|
||||||
notification.setClickType("startapp");
|
GTNotification gtNotification = new GTNotification();
|
||||||
pushMessage.setNotification(notification);
|
gtNotification.setTitle(title);
|
||||||
|
gtNotification.setBody(content);
|
||||||
|
gtNotification.setClickType("startapp");
|
||||||
|
pushMessage.setNotification(gtNotification);
|
||||||
|
|
||||||
pushDTO.setPushMessage(pushMessage);
|
pushDTO.setPushMessage(pushMessage);
|
||||||
|
|
||||||
|
// 创建 PushChannel 用于配置各平台的通知
|
||||||
|
PushChannel pushChannel = new PushChannel();
|
||||||
|
|
||||||
|
// Android 离线推送配置 - 使用 ThirdNotification
|
||||||
|
AndroidDTO androidDTO = new AndroidDTO();
|
||||||
|
Ups ups = new Ups();
|
||||||
|
ThirdNotification thirdNotification = new ThirdNotification();
|
||||||
|
thirdNotification.setTitle(title);
|
||||||
|
thirdNotification.setBody(content);
|
||||||
|
thirdNotification.setClickType("startapp");
|
||||||
|
ups.setNotification(thirdNotification);
|
||||||
|
androidDTO.setUps(ups);
|
||||||
|
pushChannel.setAndroid(androidDTO);
|
||||||
|
|
||||||
|
// iOS 通知配置
|
||||||
|
IosDTO iosDTO = new IosDTO();
|
||||||
|
Aps aps = new Aps();
|
||||||
|
Alert alert = new Alert();
|
||||||
|
alert.setTitle(title);
|
||||||
|
alert.setBody(content);
|
||||||
|
aps.setAlert(alert);
|
||||||
|
aps.setSound("default");
|
||||||
|
aps.setContentAvailable(0);
|
||||||
|
iosDTO.setAps(aps);
|
||||||
|
pushChannel.setIos(iosDTO);
|
||||||
|
|
||||||
|
// 将 PushChannel 设置到 PushDTO 中
|
||||||
|
pushDTO.setPushChannel(pushChannel);
|
||||||
|
|
||||||
return pushDTO;
|
return pushDTO;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
package com.njcn.msgpush.module.push.client.sender.impl.email;
|
package com.njcn.msgpush.module.push.client.sender.impl.email;
|
||||||
|
|
||||||
import cn.hutool.core.util.ObjectUtil;
|
import cn.hutool.core.util.ObjectUtil;
|
||||||
import com.alibaba.fastjson.JSON;
|
|
||||||
import com.alibaba.fastjson.JSONObject;
|
|
||||||
import com.aliyun.dm20151123.Client;
|
import com.aliyun.dm20151123.Client;
|
||||||
import com.aliyun.dm20151123.models.SingleSendMailRequest;
|
import com.aliyun.dm20151123.models.*;
|
||||||
import com.aliyun.dm20151123.models.SingleSendMailResponse;
|
|
||||||
import com.aliyun.teaopenapi.models.Config;
|
import com.aliyun.teaopenapi.models.Config;
|
||||||
import com.aliyun.teautil.models.RuntimeOptions;
|
import com.aliyun.teautil.models.RuntimeOptions;
|
||||||
import com.njcn.msgpush.module.push.client.sender.EmailSender;
|
import com.njcn.msgpush.module.push.client.sender.EmailSender;
|
||||||
@@ -18,6 +15,8 @@ import org.springframework.http.HttpStatus;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.Future;
|
import java.util.concurrent.Future;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
@@ -25,9 +24,10 @@ import java.util.concurrent.TimeUnit;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class AliyunEmailSender implements EmailSender {
|
public class AliyunEmailSender implements EmailSender {
|
||||||
|
|
||||||
private static final String ACCOUNT_NAME = "accountName";
|
private static final String ACCOUNT_NAME = "njcn@shining-electric.cn";
|
||||||
private static final String REPLY_TO_ADDRESS = "replyToAddress";
|
private static final Integer ADDRESS_TYPE = 1;
|
||||||
private static final String FROM_ALIAS = "fromAlias";
|
private static final Boolean REPLY_TO_ADDRESS = false;
|
||||||
|
private static final String FROM_ALIAS = "南京灿能";
|
||||||
|
|
||||||
private final Sender sender;
|
private final Sender sender;
|
||||||
|
|
||||||
@@ -60,17 +60,15 @@ public class AliyunEmailSender implements EmailSender {
|
|||||||
RuntimeOptions runtimeOptions = new RuntimeOptions();
|
RuntimeOptions runtimeOptions = new RuntimeOptions();
|
||||||
runtimeOptions.autoretry = true;
|
runtimeOptions.autoretry = true;
|
||||||
|
|
||||||
// JSONObject jsonObject = JSON.parseObject(message.getExtraInfo());
|
|
||||||
JSONObject jsonObject = null;
|
|
||||||
SingleSendMailRequest request = new SingleSendMailRequest()
|
SingleSendMailRequest request = new SingleSendMailRequest()
|
||||||
.setAccountName(jsonObject.getString(ACCOUNT_NAME))
|
.setAccountName(ACCOUNT_NAME)
|
||||||
.setAddressType(1)
|
.setAddressType(ADDRESS_TYPE)
|
||||||
.setReplyToAddress(jsonObject.getBooleanValue(REPLY_TO_ADDRESS))
|
.setReplyToAddress(REPLY_TO_ADDRESS)
|
||||||
.setToAddress(message.getReceiver())
|
.setToAddress(message.getReceiver())
|
||||||
.setSubject(message.getTitle())
|
.setSubject(message.getTitle())
|
||||||
.setHtmlBody(message.getContent())
|
.setHtmlBody(message.getContent())
|
||||||
.setTextBody("")
|
.setTextBody("")
|
||||||
.setFromAlias(jsonObject.getString(FROM_ALIAS));
|
.setFromAlias(FROM_ALIAS);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
@@ -82,8 +80,11 @@ public class AliyunEmailSender implements EmailSender {
|
|||||||
message.setCostTime(costTime);
|
message.setCostTime(costTime);
|
||||||
|
|
||||||
if (HttpStatus.OK.value() == response.getStatusCode()) {
|
if (HttpStatus.OK.value() == response.getStatusCode()) {
|
||||||
|
message.setThirdPartyId(response.getBody().getEnvId());
|
||||||
|
// 电信短信同步返回成功同样只代表已受理,最终状态以后续回执为准。
|
||||||
|
this.getDownInfo(message);
|
||||||
// 邮件接口同步返回成功时,当前平台直接认定本次发送成功。
|
// 邮件接口同步返回成功时,当前平台直接认定本次发送成功。
|
||||||
return SendResult.success(now, costTime, null);
|
return SendResult.accepted(now, costTime, response.getBody().getEnvId());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 邮件服务失败同样统一转换为平台错误码和中文错误信息。
|
// 邮件服务失败同样统一转换为平台错误码和中文错误信息。
|
||||||
@@ -108,4 +109,50 @@ public class AliyunEmailSender implements EmailSender {
|
|||||||
return this.sender.buildTimeoutResult();
|
return this.sender.buildTimeoutResult();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void getDownInfo(MessageRecordDO message) {
|
||||||
|
// 回执查询延后执行,给第三方落库和状态变更留出时间。
|
||||||
|
this.sender.MSG_CALLBACK_THREAD_POOL_SCHEDULER.schedule(() -> {
|
||||||
|
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
SenderStatisticsDetailByParamRequest request = new SenderStatisticsDetailByParamRequest()
|
||||||
|
.setToAddress(message.getReceiver())
|
||||||
|
.setLength(1);
|
||||||
|
try {
|
||||||
|
SenderStatisticsDetailByParamResponse detail = this.emailClient.senderStatisticsDetailByParam(request);
|
||||||
|
if (detail.statusCode == 200) {
|
||||||
|
List<SenderStatisticsDetailByParamResponseBody.SenderStatisticsDetailByParamResponseBodyDataMailDetail> mailDetailList = detail.body.getData().mailDetail;
|
||||||
|
|
||||||
|
if (mailDetailList.size() > 0) {
|
||||||
|
SenderStatisticsDetailByParamResponseBody.SenderStatisticsDetailByParamResponseBodyDataMailDetail mailDetail = mailDetailList.get(0);
|
||||||
|
if (mailDetail.getStatus() == 0) {
|
||||||
|
// 回执确认成功后,复用统一成功落库逻辑。
|
||||||
|
this.sender.applyCallbackResult(message, SendResult.success(message.getSendTime(), message.getCostTime(), message.getThirdPartyId()));
|
||||||
|
} else {
|
||||||
|
SendResult failedResult = this.sender.buildFailureResult(
|
||||||
|
message,
|
||||||
|
mailDetail.getErrorClassification(),
|
||||||
|
mailDetail.getErrorClassification(),
|
||||||
|
"THIRD_PARTY_CALLBACK_FAILED",
|
||||||
|
"邮件回执返回失败",
|
||||||
|
false
|
||||||
|
);
|
||||||
|
this.sender.applyCallbackResult(message, failedResult);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SendResult failedResult = this.sender.buildFailureResult(
|
||||||
|
message,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"THIRD_PARTY_CALLBACK_FAILED",
|
||||||
|
"邮件回执返回失败",
|
||||||
|
false
|
||||||
|
);
|
||||||
|
this.sender.applyCallbackResult(message, failedResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("电信短信回执查询失败", e);
|
||||||
|
}
|
||||||
|
}, 30, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,7 +200,9 @@ public class TelecomSmsSender implements SmsSender {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
TelecomSmsSelectDetailRes detailRes = selectResponse.getList().get(0);
|
TelecomSmsSelectDetailRes detailRes = selectResponse.getList().get(0);
|
||||||
if (detailRes.getStatus() == 5) {
|
// status=5 为明确失败;status=6 为“短期频繁发送”等场景(stat 如 MA:0038),
|
||||||
|
// 同样走错误码映射,由 push_provider_error_code_mapping.should_retry 决定是否重试。
|
||||||
|
if (detailRes.getStatus() == 5 || detailRes.getStatus() == 6) {
|
||||||
SendResult failedResult = this.sender.buildFailureResult(
|
SendResult failedResult = this.sender.buildFailureResult(
|
||||||
message,
|
message,
|
||||||
detailRes.getStat(),
|
detailRes.getStat(),
|
||||||
@@ -214,22 +216,6 @@ public class TelecomSmsSender implements SmsSender {
|
|||||||
// 回执确认成功后,复用统一成功落库逻辑。
|
// 回执确认成功后,复用统一成功落库逻辑。
|
||||||
this.sender.applyCallbackResult(message, SendResult.success(message.getSendTime(), message.getCostTime(), message.getThirdPartyId()));
|
this.sender.applyCallbackResult(message, SendResult.success(message.getSendTime(), message.getCostTime(), message.getThirdPartyId()));
|
||||||
}
|
}
|
||||||
// double random = Math.random();
|
|
||||||
// System.out.println(random + " aaaa");
|
|
||||||
// if (random > 0.5) {
|
|
||||||
// SendResult failedResult = this.sender.buildFailureResult(
|
|
||||||
// message,
|
|
||||||
// detailRes.getStat(),
|
|
||||||
// null,
|
|
||||||
// "THIRD_PARTY_CALLBACK_FAILED",
|
|
||||||
// "短信回执返回失败",
|
|
||||||
// true
|
|
||||||
// );
|
|
||||||
// this.sender.applyCallbackResult(message, failedResult);
|
|
||||||
// } else {
|
|
||||||
// // 回执确认成功后,复用统一成功落库逻辑。
|
|
||||||
// this.sender.applyCallbackResult(message, SendResult.success(message.getSendTime(), message.getCostTime(), message.getThirdPartyId()));
|
|
||||||
// }
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("电信短信回执查询失败", e);
|
log.error("电信短信回执查询失败", e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,17 @@ import lombok.EqualsAndHashCode;
|
|||||||
@Data
|
@Data
|
||||||
@EqualsAndHashCode(callSuper = true)
|
@EqualsAndHashCode(callSuper = true)
|
||||||
public class UniPushAppPushSetting extends AppPushSetting {
|
public class UniPushAppPushSetting extends AppPushSetting {
|
||||||
|
// 个推应用ID
|
||||||
private String appId;
|
private String appId;
|
||||||
private String appKey;
|
private String appKey;
|
||||||
private String uniAppSecret;
|
// 个推主密钥
|
||||||
private String masterSecret;
|
private String masterSecret;
|
||||||
|
private String apiUrl;
|
||||||
|
|
||||||
public UniPushAppPushSetting(String appId, String appKey, String uniAppSecret, String masterSecret) {
|
public UniPushAppPushSetting(String appId, String appKey, String masterSecret, String apiUrl) {
|
||||||
this.appId = appId;
|
this.appId = appId;
|
||||||
this.appKey = appKey;
|
this.appKey = appKey;
|
||||||
this.uniAppSecret = uniAppSecret;
|
|
||||||
this.masterSecret = masterSecret;
|
this.masterSecret = masterSecret;
|
||||||
|
this.apiUrl = apiUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.njcn.msgpush.module.push.controller.admin.message;
|
|||||||
|
|
||||||
import cn.hutool.core.bean.BeanUtil;
|
import cn.hutool.core.bean.BeanUtil;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.njcn.msgpush.framework.common.exception.enums.GlobalErrorCodeConstants;
|
||||||
import com.njcn.msgpush.framework.common.pojo.CommonResult;
|
import com.njcn.msgpush.framework.common.pojo.CommonResult;
|
||||||
import com.njcn.msgpush.framework.idempotent.core.annotation.Idempotent;
|
import com.njcn.msgpush.framework.idempotent.core.annotation.Idempotent;
|
||||||
import com.njcn.msgpush.module.push.controller.admin.message.vo.MessageRecordReqVO;
|
import com.njcn.msgpush.module.push.controller.admin.message.vo.MessageRecordReqVO;
|
||||||
@@ -19,7 +20,12 @@ import jakarta.validation.Valid;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -41,43 +47,47 @@ public class MessageRecordController {
|
|||||||
@Operation(summary = "短信推送")
|
@Operation(summary = "短信推送")
|
||||||
@Idempotent(timeout = 2)
|
@Idempotent(timeout = 2)
|
||||||
public CommonResult<List<MessageSendResultVO>> sendSms(@Valid @RequestBody List<MessageRecordReqVO> reqVOList,
|
public CommonResult<List<MessageSendResultVO>> sendSms(@Valid @RequestBody List<MessageRecordReqVO> reqVOList,
|
||||||
@RequestHeader(value = "X-Credential-Token", required = false) String credentialToken) {
|
@RequestHeader(value = "X-Credential-Token", required = true) String credentialToken) {
|
||||||
CredentialServiceImpl.CredentialInfo credentialInfo = credentialService.verifyCredential(credentialToken);
|
return doSend(reqVOList, credentialToken, ChannelTypeEnum.SMS);
|
||||||
String systemName = credentialInfo.getSystemName();
|
|
||||||
return success(messageRecordService.send(reqVOList, ChannelTypeEnum.SMS, systemName));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PermitAll
|
@PermitAll
|
||||||
@PostMapping("/send/email")
|
@PostMapping("/send/email")
|
||||||
@Operation(summary = "邮箱推送")
|
@Operation(summary = "邮件推送")
|
||||||
@Idempotent(timeout = 2)
|
@Idempotent(timeout = 2)
|
||||||
public CommonResult<List<MessageSendResultVO>> sendEmail(@Valid @RequestBody List<MessageRecordReqVO> reqVOList,
|
public CommonResult<List<MessageSendResultVO>> sendEmail(@Valid @RequestBody List<MessageRecordReqVO> reqVOList,
|
||||||
@RequestHeader(value = "X-Credential-Token", required = false) String credentialToken) {
|
@RequestHeader(value = "X-Credential-Token", required = true) String credentialToken) {
|
||||||
CredentialServiceImpl.CredentialInfo credentialInfo = credentialService.verifyCredential(credentialToken);
|
return doSend(reqVOList, credentialToken, ChannelTypeEnum.EMAIL);
|
||||||
String systemName = credentialInfo.getSystemName();
|
|
||||||
return success(messageRecordService.send(reqVOList, ChannelTypeEnum.EMAIL, systemName));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PermitAll
|
@PermitAll
|
||||||
@PostMapping("/send/app")
|
@PostMapping("/send/app")
|
||||||
@Operation(summary = "app推送")
|
@Operation(summary = "APP 推送")
|
||||||
@Idempotent(timeout = 2)
|
@Idempotent(timeout = 2)
|
||||||
public CommonResult<List<MessageSendResultVO>> sendApp(@Valid @RequestBody List<MessageRecordReqVO> reqVOList,
|
public CommonResult<List<MessageSendResultVO>> sendApp(@Valid @RequestBody List<MessageRecordReqVO> reqVOList,
|
||||||
@RequestHeader(value = "X-Credential-Token", required = false) String credentialToken) {
|
@RequestHeader(value = "X-Credential-Token", required = true) String credentialToken) {
|
||||||
|
return doSend(reqVOList, credentialToken, ChannelTypeEnum.APP);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommonResult<List<MessageSendResultVO>> doSend(List<MessageRecordReqVO> reqVOList,
|
||||||
|
String credentialToken,
|
||||||
|
ChannelTypeEnum channelTypeEnum) {
|
||||||
CredentialServiceImpl.CredentialInfo credentialInfo = credentialService.verifyCredential(credentialToken);
|
CredentialServiceImpl.CredentialInfo credentialInfo = credentialService.verifyCredential(credentialToken);
|
||||||
String systemName = credentialInfo.getSystemName();
|
if (credentialInfo == null) {
|
||||||
return success(messageRecordService.send(reqVOList, ChannelTypeEnum.APP, systemName));
|
return CommonResult.error(GlobalErrorCodeConstants.UNAUTHORIZED.getCode(), "凭证无效或已过期");
|
||||||
|
}
|
||||||
|
return success(messageRecordService.send(reqVOList, channelTypeEnum, credentialInfo.getSystemName()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/page")
|
@PostMapping("/page")
|
||||||
@Operation(summary = "分页查询渠道服务商列表")
|
@Operation(summary = "分页查询消息记录")
|
||||||
@PreAuthorize("@ss.hasPermission('push:message:page')")
|
@PreAuthorize("@ss.hasPermission('push:message:page')")
|
||||||
public CommonResult<Page<MessageRecordDO>> pageChannelProviderConfig(@Validated @RequestBody MessageRecordReqVO reqVO) {
|
public CommonResult<Page<MessageRecordDO>> pageChannelProviderConfig(@Validated @RequestBody MessageRecordReqVO reqVO) {
|
||||||
return success(messageRecordService.getPage(reqVO));
|
return success(messageRecordService.getPage(reqVO));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/add")
|
@PostMapping("/add")
|
||||||
@Operation(summary = "添加消息记录")
|
@Operation(summary = "新增消息记录")
|
||||||
@PreAuthorize("@ss.hasPermission('push:message:add')")
|
@PreAuthorize("@ss.hasPermission('push:message:add')")
|
||||||
public CommonResult<Boolean> add(@Validated @RequestBody MessageRecordReqVO reqVO) {
|
public CommonResult<Boolean> add(@Validated @RequestBody MessageRecordReqVO reqVO) {
|
||||||
return success(messageRecordService.add(reqVO));
|
return success(messageRecordService.add(reqVO));
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public class MessageRecordReqVO extends PageParam {
|
|||||||
|
|
||||||
@Schema(description = "接收者", requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(description = "接收者", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
@NotBlank(message = "接收者不能为空")
|
@NotBlank(message = "接收者不能为空")
|
||||||
@Pattern(regexp = RegexPool.EMAIL + "|" + RegexPool.MOBILE, message = "必须是有效的邮箱或手机号格式")
|
// @Pattern(regexp = RegexPool.EMAIL + "|" + RegexPool.MOBILE, message = "必须是有效的邮箱或手机号格式")
|
||||||
private String receiver;
|
private String receiver;
|
||||||
|
|
||||||
@Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
|||||||
@@ -32,30 +32,30 @@ public class ChannelProviderConfigDO extends BaseDO {
|
|||||||
private String providerName;
|
private String providerName;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 服务商类型:telecom/cmcc/aliyun/twilio/unipush
|
* 服务商类型:telecom/aliyun/unipush
|
||||||
*/
|
*/
|
||||||
private String providerType;
|
private String providerType;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API地址
|
* API地址
|
||||||
|
* 对于 UniPush,可配置为自定义API地址,如未配置则使用默认地址 https://restapi.getui.com/v2/
|
||||||
*/
|
*/
|
||||||
private String apiUrl;
|
private String apiUrl;
|
||||||
|
|
||||||
/**
|
|
||||||
* AppKey
|
|
||||||
*/
|
|
||||||
private String appKey;
|
private String appKey;
|
||||||
|
|
||||||
/**
|
private String secret;
|
||||||
* AppSecret
|
|
||||||
*/
|
|
||||||
private String appSecret;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 电信sms服务所需接入码
|
* 电信sms服务所需接入码
|
||||||
*/
|
*/
|
||||||
private String extno;
|
private String extno;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* app推送服务所需的appId
|
||||||
|
*/
|
||||||
|
private String appId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 额外配置(JSON格式)
|
* 额外配置(JSON格式)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ public class MessageRetryRedisDAO {
|
|||||||
* @param message 消息
|
* @param message 消息
|
||||||
*/
|
*/
|
||||||
public boolean addToRetryQueue(MessageRecordDO message) {
|
public boolean addToRetryQueue(MessageRecordDO message) {
|
||||||
|
// 兜底守卫:nextRetryTime 为空则跳过单条,绝不让一条坏数据把整批/整个定时任务带崩
|
||||||
|
if (message.getNextRetryTime() == null) {
|
||||||
|
log.warn("跳过入队:nextRetryTime 为空, messageId={}, channel={}", message.getId(), message.getChannel());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
String key = getRetryQueueKey(message.getChannel());
|
String key = getRetryQueueKey(message.getChannel());
|
||||||
double score = message.getNextRetryTime().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
|
double score = message.getNextRetryTime().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ public class CredentialAuthenticationFilter extends ApiRequestFilter implements
|
|||||||
|
|
||||||
// 2. 如果没有凭证,继续过滤链
|
// 2. 如果没有凭证,继续过滤链
|
||||||
if (StrUtil.isEmpty(credentialToken)) {
|
if (StrUtil.isEmpty(credentialToken)) {
|
||||||
filterChain.doFilter(request, response);
|
ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.UNAUTHORIZED.getCode(), "缺少凭证"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,4 +112,4 @@ public class CredentialAuthenticationFilter extends ApiRequestFilter implements
|
|||||||
public int getOrder() {
|
public int getOrder() {
|
||||||
return 1000;
|
return 1000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.njcn.msgpush.module.push.service.credential;
|
package com.njcn.msgpush.module.push.service.credential;
|
||||||
|
|
||||||
import cn.hutool.core.codec.Base64;
|
import cn.hutool.core.codec.Base64;
|
||||||
|
import cn.hutool.core.util.ObjectUtil;
|
||||||
|
import cn.hutool.core.util.RandomUtil;
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
import cn.hutool.crypto.SecureUtil;
|
import cn.hutool.crypto.Mode;
|
||||||
|
import cn.hutool.crypto.Padding;
|
||||||
import cn.hutool.crypto.symmetric.AES;
|
import cn.hutool.crypto.symmetric.AES;
|
||||||
import com.njcn.msgpush.framework.common.exception.ServiceException;
|
import com.njcn.msgpush.framework.common.exception.ServiceException;
|
||||||
import com.njcn.msgpush.framework.common.exception.enums.ServiceErrorCodeRange;
|
import com.njcn.msgpush.framework.common.exception.enums.ServiceErrorCodeRange;
|
||||||
@@ -12,8 +15,10 @@ import com.njcn.msgpush.module.push.dal.dataobject.credential.dto.CredentialReqD
|
|||||||
import com.njcn.msgpush.module.push.dal.dataobject.credential.dto.CredentialRespDTO;
|
import com.njcn.msgpush.module.push.dal.dataobject.credential.dto.CredentialRespDTO;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.redisson.api.RLock;
|
||||||
|
import org.redisson.api.RedissonClient;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -28,186 +33,186 @@ import java.util.concurrent.TimeUnit;
|
|||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class CredentialServiceImpl implements ICredentialService {
|
public class CredentialServiceImpl implements ICredentialService {
|
||||||
/**
|
|
||||||
* 凭证加密密钥(生产环境应通过配置中心或环境变量注入)
|
|
||||||
*/
|
|
||||||
private String credentialSecretKey = "88888888888888888888888888888888"; // 32 字节
|
|
||||||
|
|
||||||
@Autowired
|
private static final long CREDENTIAL_EXPIRE_HOURS = 24L;
|
||||||
private ISystemSecretService systemSecretService;
|
private static final long LOCK_WAIT_SECONDS = 3L;
|
||||||
|
private static final long LOCK_LEASE_SECONDS = 5L;
|
||||||
|
private static final String SYSTEM_KEY_PREFIX = "credential:";
|
||||||
|
private static final String LOCK_KEY_PREFIX = "credential:lock:";
|
||||||
|
|
||||||
|
private String credentialSecretKey = "88888888888888888888888888888888"; // 32 瀛楄妭
|
||||||
|
|
||||||
|
private final ISystemSecretService systemSecretService;
|
||||||
private final StringRedisTemplate stringRedisTemplate;
|
private final StringRedisTemplate stringRedisTemplate;
|
||||||
|
private final RedissonClient redissonClient;
|
||||||
public CredentialServiceImpl(StringRedisTemplate stringRedisTemplate) {
|
|
||||||
this.stringRedisTemplate = stringRedisTemplate;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CredentialRespDTO generateCredential(CredentialReqDTO reqDTO) {
|
public CredentialRespDTO generateCredential(CredentialReqDTO reqDTO) {
|
||||||
|
String systemName = reqDTO.getSystemName();
|
||||||
|
|
||||||
|
boolean validatedSecretRes = validateSecret(systemName, reqDTO.getSecretKey());
|
||||||
|
if (!validatedSecretRes) {
|
||||||
|
throw new ServiceException(ServiceErrorCodeRange.VALIDATE_SYSTEM_SECRET_FAIL);
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDateTime expiresTime = LocalDateTime.now().plusHours(CREDENTIAL_EXPIRE_HOURS);
|
||||||
|
String credential = encryptCredential(systemName, expiresTime);
|
||||||
|
|
||||||
|
RLock lock = redissonClient.getLock(formatLockKey(systemName));
|
||||||
|
boolean locked = false;
|
||||||
try {
|
try {
|
||||||
// 1. 验证系统密钥
|
locked = lock.tryLock(LOCK_WAIT_SECONDS, LOCK_LEASE_SECONDS, TimeUnit.SECONDS);
|
||||||
boolean validatedSecretRes = validateSecret(reqDTO.getSystemName(), reqDTO.getSecretKey());
|
if (!locked) {
|
||||||
if (!validatedSecretRes) {
|
log.warn("[generateCredential][systemName({}) failed to acquire lock within {} seconds]",
|
||||||
throw new ServiceException(ServiceErrorCodeRange.VALIDATE_SYSTEM_SECRET_FAIL);
|
systemName, LOCK_WAIT_SECONDS);
|
||||||
|
throw new ServiceException(ServiceErrorCodeRange.GENERATE_CREDENTIAL_FAIL);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 创建凭证信息
|
deleteCredential(systemName);
|
||||||
CredentialInfo credentialInfo = new CredentialInfo(
|
CredentialInfo credentialInfo = new CredentialInfo(systemName, expiresTime, credential);
|
||||||
reqDTO.getSystemName(),
|
cacheCredential(systemName, credentialInfo);
|
||||||
LocalDateTime.now().plusHours(24)
|
} catch (InterruptedException e) {
|
||||||
);
|
Thread.currentThread().interrupt();
|
||||||
|
log.warn("[generateCredential][systemName({}) interrupted while waiting for lock]", systemName, e);
|
||||||
// 3. 生成凭证 token(加密后的 JSON)
|
|
||||||
String token = encryptCredential(credentialInfo);
|
|
||||||
|
|
||||||
// 4. 缓存凭证信息到 Redis
|
|
||||||
cacheCredential(token, credentialInfo);
|
|
||||||
|
|
||||||
// 5. 构建响应
|
|
||||||
return new CredentialRespDTO()
|
|
||||||
.setCredentialToken(token)
|
|
||||||
.setSystemName(reqDTO.getSystemName())
|
|
||||||
.setExpiresTime(credentialInfo.getExpiresTime());
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new ServiceException(ServiceErrorCodeRange.GENERATE_CREDENTIAL_FAIL);
|
throw new ServiceException(ServiceErrorCodeRange.GENERATE_CREDENTIAL_FAIL);
|
||||||
}
|
} finally {
|
||||||
}
|
if (locked && lock.isHeldByCurrentThread()) {
|
||||||
|
lock.unlock();
|
||||||
public CredentialInfo verifyCredential(String token) {
|
|
||||||
if (StrUtil.isEmpty(token)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. 从 Redis 获取凭证信息
|
|
||||||
CredentialInfo credentialInfo = getCredentialFromRedis(token);
|
|
||||||
if (credentialInfo == null) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 检查是否过期
|
|
||||||
if (credentialInfo.getExpiresTime().isBefore(LocalDateTime.now())) {
|
|
||||||
// 删除过期的凭证
|
|
||||||
deleteCredential(token);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return credentialInfo;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("[verifyCredential] 验证凭证失败,token={}", token, e);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return new CredentialRespDTO()
|
||||||
|
.setCredentialToken(systemName + StrUtil.COLON + credential)
|
||||||
|
.setSystemName(systemName)
|
||||||
|
.setExpiresTime(expiresTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证系统密钥
|
* 验证凭证
|
||||||
*
|
*
|
||||||
* @param systemName 系统名称
|
* @param credentialToken 凭证 credentialToken
|
||||||
* @param secretKey 密钥
|
* @return
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public CredentialInfo verifyCredential(String credentialToken) {
|
||||||
|
if (StrUtil.isEmpty(credentialToken)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String[] split = credentialToken.split(StrUtil.COLON);
|
||||||
|
if (split.length != 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String systemName = split[0];
|
||||||
|
CredentialInfo credentialInfo = getCredentialFromRedis(systemName);
|
||||||
|
if (ObjectUtil.isNull(credentialInfo)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credentialInfo.getExpiresTime().isBefore(LocalDateTime.now()) || !credentialInfo.getCredential().equals(split[1])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return credentialInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验系统secretKey
|
||||||
|
*
|
||||||
|
* @param systemName
|
||||||
|
* @param secretKey
|
||||||
|
* @return
|
||||||
*/
|
*/
|
||||||
private boolean validateSecret(String systemName, String secretKey) {
|
private boolean validateSecret(String systemName, String secretKey) {
|
||||||
SystemSecretDO systemSecretDO = systemSecretService.getBySystemName(systemName);
|
SystemSecretDO systemSecretDO = systemSecretService.getBySystemName(systemName);
|
||||||
|
return systemSecretDO != null && StrUtil.equals(systemSecretDO.getSecret(), secretKey);
|
||||||
String storedSecret = systemSecretDO.getSecret();
|
|
||||||
if (!storedSecret.equals(secretKey)) {
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加密凭证信息
|
* 获取加密后的凭证credential
|
||||||
*
|
*
|
||||||
* @param info 凭证信息
|
* @param systemName 系统名称
|
||||||
* @return 加密后的 token
|
* @param expiresTime 过期时间
|
||||||
|
* @return
|
||||||
*/
|
*/
|
||||||
private String encryptCredential(CredentialInfo info) {
|
private String encryptCredential(String systemName, LocalDateTime expiresTime) {
|
||||||
AES aes = SecureUtil.aes(credentialSecretKey.getBytes(StandardCharsets.UTF_8));
|
// 生成随机 IV
|
||||||
String json = JsonUtils.toJsonString(info);
|
byte[] iv = RandomUtil.randomBytes(16);
|
||||||
byte[] encrypted = aes.encrypt(json.getBytes(StandardCharsets.UTF_8));
|
|
||||||
|
// 创建 AES 实例
|
||||||
|
AES aes = new AES(
|
||||||
|
Mode.CBC,
|
||||||
|
Padding.PKCS5Padding,
|
||||||
|
credentialSecretKey.getBytes(StandardCharsets.UTF_8),
|
||||||
|
iv
|
||||||
|
);
|
||||||
|
String origin = systemName + StrUtil.C_COLON + expiresTime.getNano();
|
||||||
|
byte[] encrypted = aes.encrypt(origin.getBytes(StandardCharsets.UTF_8));
|
||||||
return Base64.encode(encrypted);
|
return Base64.encode(encrypted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解密凭证信息
|
* 缓存系统凭证credential
|
||||||
*
|
*
|
||||||
* @param token 凭证 token
|
* @param systemName
|
||||||
* @return 凭证信息
|
* @param info
|
||||||
*/
|
*/
|
||||||
private CredentialInfo decryptCredential(String token) {
|
private void cacheCredential(String systemName, CredentialInfo info) {
|
||||||
try {
|
String credentialKey = formatCredentialKey(systemName);
|
||||||
AES aes = SecureUtil.aes(credentialSecretKey.getBytes(StandardCharsets.UTF_8));
|
|
||||||
byte[] decrypted = aes.decrypt(Base64.decode(token));
|
|
||||||
String json = new String(decrypted, StandardCharsets.UTF_8);
|
|
||||||
return JsonUtils.parseObject(json, CredentialInfo.class);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[decryptCredential] 解密凭证失败", e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 缓存凭证信息到 Redis
|
|
||||||
*
|
|
||||||
* @param token 凭证 token
|
|
||||||
* @param info 凭证信息
|
|
||||||
*/
|
|
||||||
private void cacheCredential(String token, CredentialInfo info) {
|
|
||||||
String redisKey = formatRedisKey(token);
|
|
||||||
String jsonValue = JsonUtils.toJsonString(info);
|
String jsonValue = JsonUtils.toJsonString(info);
|
||||||
|
|
||||||
// 计算剩余过期时间(秒)
|
|
||||||
long remainingSeconds = Duration.between(LocalDateTime.now(), info.getExpiresTime()).getSeconds();
|
long remainingSeconds = Duration.between(LocalDateTime.now(), info.getExpiresTime()).getSeconds();
|
||||||
if (remainingSeconds > 0) {
|
if (remainingSeconds > 0) {
|
||||||
stringRedisTemplate.opsForValue().set(redisKey, jsonValue, remainingSeconds, TimeUnit.SECONDS);
|
stringRedisTemplate.opsForValue().set(credentialKey, jsonValue, remainingSeconds, TimeUnit.SECONDS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 Redis 获取凭证信息
|
* 从redis中获取系统凭证信息
|
||||||
*
|
*
|
||||||
* @param token 凭证 token
|
* @param systemName
|
||||||
* @return 凭证信息
|
* @return
|
||||||
*/
|
*/
|
||||||
private CredentialInfo getCredentialFromRedis(String token) {
|
private CredentialInfo getCredentialFromRedis(String systemName) {
|
||||||
String redisKey = formatRedisKey(token);
|
String credentialKey = formatCredentialKey(systemName);
|
||||||
String jsonValue = stringRedisTemplate.opsForValue().get(redisKey);
|
String jsonValue = stringRedisTemplate.opsForValue().get(credentialKey);
|
||||||
if (StrUtil.isEmpty(jsonValue)) {
|
if (StrUtil.isEmpty(jsonValue)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return JsonUtils.parseObject(jsonValue, CredentialInfo.class);
|
return JsonUtils.parseObject(jsonValue, CredentialInfo.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除凭证
|
private void deleteCredential(String systemName) {
|
||||||
*
|
String credentialKey = formatCredentialKey(systemName);
|
||||||
* @param token 凭证 token
|
String oldToken = stringRedisTemplate.opsForValue().get(credentialKey);
|
||||||
*/
|
if (StrUtil.isNotEmpty(oldToken)) {
|
||||||
private void deleteCredential(String token) {
|
stringRedisTemplate.delete(credentialKey);
|
||||||
String redisKey = formatRedisKey(token);
|
}
|
||||||
stringRedisTemplate.delete(redisKey);
|
}
|
||||||
|
|
||||||
|
private String formatCredentialKey(String systemName) {
|
||||||
|
return SYSTEM_KEY_PREFIX + systemName;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式化 Redis Key
|
* 获取锁的key(用于针对同一时刻多个请求,保证多个请求只对同一个key进行加锁)
|
||||||
*
|
*
|
||||||
* @param token 凭证 token
|
* @param systemName
|
||||||
* @return Redis Key
|
* @return
|
||||||
*/
|
*/
|
||||||
private String formatRedisKey(String token) {
|
private String formatLockKey(String systemName) {
|
||||||
return "credential:token:" + token;
|
return LOCK_KEY_PREFIX + systemName;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 凭证信息内部类
|
|
||||||
*/
|
|
||||||
@Data
|
@Data
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public static class CredentialInfo {
|
public static class CredentialInfo {
|
||||||
private String systemName;
|
private String systemName;
|
||||||
private LocalDateTime expiresTime;
|
private LocalDateTime expiresTime;
|
||||||
|
private String credential;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ public class MessageRecordServiceImpl extends ServiceImpl<MessageRecordMapper, M
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public List<MessageSendResultVO> processSendMsg(List<MessageRecordDO> messageRecordDOList, RetrySourceEnum retrySource) {
|
public List<MessageSendResultVO> processSendMsg(List<MessageRecordDO> messageRecordDOList, RetrySourceEnum retrySource) {
|
||||||
msgPushGuardChain.checkAll(messageRecordDOList);
|
msgPushGuardChain.checkAll(messageRecordDOList, retrySource);
|
||||||
List<MessageSendResultVO> resultList = new ArrayList<>();
|
List<MessageSendResultVO> resultList = new ArrayList<>();
|
||||||
for (MessageRecordDO messageRecordDO : messageRecordDOList) {
|
for (MessageRecordDO messageRecordDO : messageRecordDOList) {
|
||||||
try {
|
try {
|
||||||
@@ -131,11 +131,11 @@ public class MessageRecordServiceImpl extends ServiceImpl<MessageRecordMapper, M
|
|||||||
if (sendResult == null) {
|
if (sendResult == null) {
|
||||||
sendResult = this.sendMessage(messageRecordDO, channelProviderConfigDO, messageProviderFactory);
|
sendResult = this.sendMessage(messageRecordDO, channelProviderConfigDO, messageProviderFactory);
|
||||||
}
|
}
|
||||||
this.applySendResult(messageRecordDO, sendResult);
|
this.applySendResult(messageRecordDO, sendResult, retrySource);
|
||||||
this.recordRetryHistory(messageRecordDO, retrySource);
|
this.recordRetryHistory(messageRecordDO, retrySource);
|
||||||
} else {
|
} else {
|
||||||
sendResult = this.validateProviderAndChannel(messageRecordDO, null, null);
|
sendResult = this.validateProviderAndChannel(messageRecordDO, null, null);
|
||||||
this.applySendResult(messageRecordDO, sendResult);
|
this.applySendResult(messageRecordDO, sendResult, retrySource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ public class MessageRecordServiceImpl extends ServiceImpl<MessageRecordMapper, M
|
|||||||
* 主流程在这里统一做状态落库、重试编排和健康度更新。
|
* 主流程在这里统一做状态落库、重试编排和健康度更新。
|
||||||
*/
|
*/
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
|
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
|
||||||
public void applySendResult(MessageRecordDO messageRecordDO, SendResult sendResult) {
|
public void applySendResult(MessageRecordDO messageRecordDO, SendResult sendResult, RetrySourceEnum retrySource) {
|
||||||
messageRecordDO.setStatus(this.mapOutcomeToStatus(sendResult.getOutcome()));
|
messageRecordDO.setStatus(this.mapOutcomeToStatus(sendResult.getOutcome()));
|
||||||
messageRecordDO.setErrorCode(sendResult.getErrorCode());
|
messageRecordDO.setErrorCode(sendResult.getErrorCode());
|
||||||
messageRecordDO.setErrorMsg(sendResult.getMessage());
|
messageRecordDO.setErrorMsg(sendResult.getMessage());
|
||||||
@@ -193,6 +193,13 @@ public class MessageRecordServiceImpl extends ServiceImpl<MessageRecordMapper, M
|
|||||||
// 成功或已受理都视为本次调用已正常投递,更新成功健康度并消耗配额。
|
// 成功或已受理都视为本次调用已正常投递,更新成功健康度并消耗配额。
|
||||||
if (SendOutcome.ACCEPTED.equals(sendResult.getOutcome())) {
|
if (SendOutcome.ACCEPTED.equals(sendResult.getOutcome())) {
|
||||||
messageConfirmRedisDAO.addToConfirmQueue(messageRecordDO);
|
messageConfirmRedisDAO.addToConfirmQueue(messageRecordDO);
|
||||||
|
|
||||||
|
// 仅首发(retrySource==null)消耗每日配额/接收者频率计数;重发(AUTO_RETRY/MANUAL_RETRY)
|
||||||
|
// 是同一条消息的再次投递,不重复扣额——与守卫豁免配套,避免重发把自己的配额耗尽再被拦。
|
||||||
|
if (retrySource == null) {
|
||||||
|
systemQuotaRedisDAO.set(messageRecordDO.getChannel(), messageRecordDO.getAppName());
|
||||||
|
rateLimitRedisDAO.set(messageRecordDO.getChannel(), messageRecordDO.getAppName(), messageRecordDO.getReceiver());
|
||||||
|
}
|
||||||
} else if (SendOutcome.RETRYABLE_FAILED.equals(sendResult.getOutcome())) {
|
} else if (SendOutcome.RETRYABLE_FAILED.equals(sendResult.getOutcome())) {
|
||||||
messageRetryQueueService.saveOrUpdateRetryMessage(messageRecordDO);
|
messageRetryQueueService.saveOrUpdateRetryMessage(messageRecordDO);
|
||||||
channelProviderConfigService.failureUpdate(messageRecordDO.getProviderType(), messageRecordDO.getChannel());
|
channelProviderConfigService.failureUpdate(messageRecordDO.getProviderType(), messageRecordDO.getChannel());
|
||||||
@@ -273,7 +280,7 @@ public class MessageRecordServiceImpl extends ServiceImpl<MessageRecordMapper, M
|
|||||||
public Page<MessageRecordDO> getPage(MessageRecordReqVO reqVO) {
|
public Page<MessageRecordDO> getPage(MessageRecordReqVO reqVO) {
|
||||||
QueryWrapper<MessageRecordDO> wrapper = new QueryWrapper<>();
|
QueryWrapper<MessageRecordDO> wrapper = new QueryWrapper<>();
|
||||||
wrapper.lambda()
|
wrapper.lambda()
|
||||||
.eq(StrUtil.isNotBlank(reqVO.getMessageType()), MessageRecordDO::getChannel, reqVO.getChannel());
|
.eq(StrUtil.isNotBlank(reqVO.getMessageType()), MessageRecordDO::getMessageType, reqVO.getMessageType());
|
||||||
return this.page(new Page<>(PageUtils.getPageNum(reqVO), PageUtils.getPageSize(reqVO)), wrapper);
|
return this.page(new Page<>(PageUtils.getPageNum(reqVO), PageUtils.getPageSize(reqVO)), wrapper);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ import org.springframework.scheduling.annotation.Scheduled;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -41,6 +43,13 @@ public class MessageRetryQueueServiceImpl extends ServiceImpl<MessageRetryQueueM
|
|||||||
private static final int PROCESS_STATUS_PROCESSING = 1;
|
private static final int PROCESS_STATUS_PROCESSING = 1;
|
||||||
private static final int DEFAULT_LOCK_SECONDS = 120;
|
private static final int DEFAULT_LOCK_SECONDS = 120;
|
||||||
|
|
||||||
|
/** 限流类失败的统一错误码(回执后由 Sender/MessageRecordServiceImpl 写入 message.errorCode) */
|
||||||
|
private static final String RATE_LIMIT_ERROR_CODE = "RATE_LIMIT";
|
||||||
|
/** 限流类固定重试间隔:2 分钟(不走阶梯) */
|
||||||
|
private static final int RATE_LIMIT_INTERVAL_SECONDS = 120;
|
||||||
|
/** 限流类时间兜底:首次失败起满 12 小时未成功才放弃 */
|
||||||
|
private static final long RATE_LIMIT_MAX_DURATION_SECONDS = 43200;
|
||||||
|
|
||||||
private static final List<String> CHANNELS = Arrays.asList(
|
private static final List<String> CHANNELS = Arrays.asList(
|
||||||
ChannelTypeEnum.SMS.getCode(),
|
ChannelTypeEnum.SMS.getCode(),
|
||||||
ChannelTypeEnum.EMAIL.getCode(),
|
ChannelTypeEnum.EMAIL.getCode(),
|
||||||
@@ -67,13 +76,15 @@ public class MessageRetryQueueServiceImpl extends ServiceImpl<MessageRetryQueueM
|
|||||||
retryRecord.setMessageId(message.getId());
|
retryRecord.setMessageId(message.getId());
|
||||||
retryRecord.setChannel(message.getChannel());
|
retryRecord.setChannel(message.getChannel());
|
||||||
retryRecord.setReceiver(message.getReceiver());
|
retryRecord.setReceiver(message.getReceiver());
|
||||||
retryRecord.setRetryCount(1);
|
boolean rateLimit = isRateLimit(message);
|
||||||
|
// I3:首次失败若是限流,普通失败计数从 0 起(限流不占普通次数预算);普通失败则从 1 起
|
||||||
|
retryRecord.setRetryCount(rateLimit ? 0 : 1);
|
||||||
RetryStrategyConfigDO strategyConfig = retryStrategyConfigService.getStrategyConfig(message.getChannel());
|
RetryStrategyConfigDO strategyConfig = retryStrategyConfigService.getStrategyConfig(message.getChannel());
|
||||||
retryRecord.setMaxRetry(ObjectUtil.isNull(strategyConfig) ? DEFAULT_MAX_RETRY_COUNT : strategyConfig.getMaxRetryCount());
|
retryRecord.setMaxRetry(ObjectUtil.isNull(strategyConfig) ? DEFAULT_MAX_RETRY_COUNT : strategyConfig.getMaxRetryCount());
|
||||||
retryRecord.setFirstFailTime(message.getSendTime());
|
retryRecord.setFirstFailTime(message.getSendTime());
|
||||||
retryRecord.setLastRetryTime(message.getSendTime());
|
retryRecord.setLastRetryTime(message.getSendTime());
|
||||||
retryRecord.setLastErrorMsg(message.getErrorMsg());
|
retryRecord.setLastErrorMsg(message.getErrorMsg());
|
||||||
LocalDateTime nextRetryTime = calculateNextRetryTime(message.getChannel(), retryRecord.getRetryCount());
|
LocalDateTime nextRetryTime = calculateNextRetryTime(message.getChannel(), retryRecord.getRetryCount(), rateLimit);
|
||||||
retryRecord.setNextRetryTime(nextRetryTime);
|
retryRecord.setNextRetryTime(nextRetryTime);
|
||||||
retryRecord.setProcessStatus(PROCESS_STATUS_WAITING);
|
retryRecord.setProcessStatus(PROCESS_STATUS_WAITING);
|
||||||
retryRecord.setLockUntil(null);
|
retryRecord.setLockUntil(null);
|
||||||
@@ -85,11 +96,20 @@ public class MessageRetryQueueServiceImpl extends ServiceImpl<MessageRetryQueueM
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
int newRetryCount = existing.getRetryCount() + 1;
|
applyRetryUpdate(message, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyRetryUpdate(MessageRecordDO message, MessageRetryQueueDO existing) {
|
||||||
|
boolean rateLimit = isRateLimit(message);
|
||||||
|
// I3:限流重试冻结 retryCount(不消耗普通失败的次数预算),普通失败才累加
|
||||||
|
int newRetryCount = nextRetryCount(existing.getRetryCount(), rateLimit);
|
||||||
existing.setLastRetryTime(message.getSendTime());
|
existing.setLastRetryTime(message.getSendTime());
|
||||||
existing.setLastErrorMsg(message.getErrorMsg());
|
existing.setLastErrorMsg(message.getErrorMsg());
|
||||||
|
|
||||||
if (newRetryCount >= existing.getMaxRetry()) {
|
boolean exhausted = rateLimit
|
||||||
|
? isRateLimitExhausted(existing.getFirstFailTime(), LocalDateTime.now())
|
||||||
|
: newRetryCount >= existing.getMaxRetry();
|
||||||
|
if (exhausted) {
|
||||||
message.setStatus(MsgStatusConstant.FINALFAILED);
|
message.setStatus(MsgStatusConstant.FINALFAILED);
|
||||||
message.setNextRetryTime(null);
|
message.setNextRetryTime(null);
|
||||||
existing.setNextRetryTime(null);
|
existing.setNextRetryTime(null);
|
||||||
@@ -99,7 +119,7 @@ public class MessageRetryQueueServiceImpl extends ServiceImpl<MessageRetryQueueM
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
LocalDateTime nextRetryTime = calculateNextRetryTime(message.getChannel(), newRetryCount);
|
LocalDateTime nextRetryTime = calculateNextRetryTime(message.getChannel(), newRetryCount, rateLimit);
|
||||||
existing.setRetryCount(newRetryCount);
|
existing.setRetryCount(newRetryCount);
|
||||||
existing.setNextRetryTime(nextRetryTime);
|
existing.setNextRetryTime(nextRetryTime);
|
||||||
existing.setProcessStatus(PROCESS_STATUS_WAITING);
|
existing.setProcessStatus(PROCESS_STATUS_WAITING);
|
||||||
@@ -111,7 +131,36 @@ public class MessageRetryQueueServiceImpl extends ServiceImpl<MessageRetryQueueM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private LocalDateTime calculateNextRetryTime(String channel, int retryCount) {
|
/**
|
||||||
|
* 计算本次失败后队列记录的新 retryCount。
|
||||||
|
* I3:限流重试不计入次数预算 —— 限流时冻结(返回原值),普通失败时累加。
|
||||||
|
* 这样普通失败的 maxRetry 上限只统计普通失败,既不被限流重试撑爆提前 FINALFAILED,
|
||||||
|
* 也不会让普通失败借限流的 12h 兜底绕过次数上限。
|
||||||
|
*/
|
||||||
|
static int nextRetryCount(int currentRetryCount, boolean rateLimit) {
|
||||||
|
return rateLimit ? currentRetryCount : currentRetryCount + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 限流类识别:仅当统一错误码为 RATE_LIMIT */
|
||||||
|
static boolean isRateLimit(MessageRecordDO message) {
|
||||||
|
return message != null && RATE_LIMIT_ERROR_CODE.equals(message.getErrorCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 限流类时间兜底判定:首次失败起是否已满 12 小时。
|
||||||
|
* firstFailTime 为 null(历史数据)时视为未满,继续重试。
|
||||||
|
*/
|
||||||
|
static boolean isRateLimitExhausted(LocalDateTime firstFailTime, LocalDateTime now) {
|
||||||
|
if (firstFailTime == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Duration.between(firstFailTime, now).getSeconds() >= RATE_LIMIT_MAX_DURATION_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDateTime calculateNextRetryTime(String channel, int retryCount, boolean rateLimit) {
|
||||||
|
if (rateLimit) {
|
||||||
|
return LocalDateTime.now().plusSeconds(RATE_LIMIT_INTERVAL_SECONDS);
|
||||||
|
}
|
||||||
RetryStrategyConfigDO strategyConfig = retryStrategyConfigService.getStrategyConfig(channel);
|
RetryStrategyConfigDO strategyConfig = retryStrategyConfigService.getStrategyConfig(channel);
|
||||||
long plusSeconds = 0;
|
long plusSeconds = 0;
|
||||||
if (ObjectUtil.isNull(strategyConfig)) {
|
if (ObjectUtil.isNull(strategyConfig)) {
|
||||||
@@ -196,6 +245,7 @@ public class MessageRetryQueueServiceImpl extends ServiceImpl<MessageRetryQueueM
|
|||||||
messageRecordService.updateRetryCount(messageRecordDO.getId());
|
messageRecordService.updateRetryCount(messageRecordDO.getId());
|
||||||
finalizeRetryLease(messageRecordDO);
|
finalizeRetryLease(messageRecordDO);
|
||||||
} else {
|
} else {
|
||||||
|
// 能被重新认领说明 120s 租约已过期、20s 回执窗口早过 —— SENDING/PENDING 即卡住,与终态一并清理
|
||||||
deleteByMessageIds(Collections.singletonList(messageRecordDO.getId()));
|
deleteByMessageIds(Collections.singletonList(messageRecordDO.getId()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,6 +311,8 @@ public class MessageRetryQueueServiceImpl extends ServiceImpl<MessageRetryQueueM
|
|||||||
log.warn("渠道 [{}] 的消息记录不存在", channel);
|
log.warn("渠道 [{}] 的消息记录不存在", channel);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// push_message_record.next_retry_time 不保证落库,用队列记录(权威调度时间)回填后再入队,避免 NPE
|
||||||
|
fillNextRetryTimeFromQueue(messages, dbRecords);
|
||||||
|
|
||||||
int successCount = messageRetryRedisDAO.batchAddToRetryQueue(messages);
|
int successCount = messageRetryRedisDAO.batchAddToRetryQueue(messages);
|
||||||
log.info("渠道 [{}] 重建完成:数据库记录数={}, 成功入队 Redis 数={}", channel, dbRecords.size(), successCount);
|
log.info("渠道 [{}] 重建完成:数据库记录数={}, 成功入队 Redis 数={}", channel, dbRecords.size(), successCount);
|
||||||
@@ -271,6 +323,7 @@ public class MessageRetryQueueServiceImpl extends ServiceImpl<MessageRetryQueueM
|
|||||||
log.info("========== 重试队列重建完成 ==========");
|
log.info("========== 重试队列重建完成 ==========");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 每60分钟主动从数据库中扫描一次,将消息重试数据同步到 Redis 中
|
||||||
@Scheduled(fixedRate = 600000)
|
@Scheduled(fixedRate = 600000)
|
||||||
@Override
|
@Override
|
||||||
public void syncRetryQueueConsistency() {
|
public void syncRetryQueueConsistency() {
|
||||||
@@ -328,12 +381,34 @@ public class MessageRetryQueueServiceImpl extends ServiceImpl<MessageRetryQueueM
|
|||||||
if (CollUtil.isEmpty(messages)) {
|
if (CollUtil.isEmpty(messages)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// push_message_record.next_retry_time 不保证落库,用队列记录(权威调度时间)回填后再入队,避免 NPE
|
||||||
|
fillNextRetryTimeFromQueue(messages, dbRecords);
|
||||||
|
|
||||||
if (messageRetryRedisDAO.isRedisAvailable()) {
|
if (messageRetryRedisDAO.isRedisAvailable()) {
|
||||||
messageRetryRedisDAO.batchAddToRetryQueue(messages);
|
messageRetryRedisDAO.batchAddToRetryQueue(messages);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用队列记录(push_message_retry_queue,调度权威)的 nextRetryTime 回填消息对象。
|
||||||
|
* push_message_record.next_retry_time 只在内存设置、不保证持久化,批量入队若直接取它会 NPE。
|
||||||
|
* 这些 message 仅用于 Redis 入队、不会回写库,故内存回填安全。
|
||||||
|
*/
|
||||||
|
private void fillNextRetryTimeFromQueue(List<MessageRecordDO> messages, List<MessageRetryQueueDO> queueRecords) {
|
||||||
|
Map<Long, LocalDateTime> nextRetryTimeMap = queueRecords.stream()
|
||||||
|
.filter(r -> r.getNextRetryTime() != null)
|
||||||
|
.collect(Collectors.toMap(MessageRetryQueueDO::getMessageId, MessageRetryQueueDO::getNextRetryTime, (a, b) -> a));
|
||||||
|
messages.forEach(m -> {
|
||||||
|
LocalDateTime queueTime = nextRetryTimeMap.get(m.getId());
|
||||||
|
if (queueTime != null) {
|
||||||
|
m.setNextRetryTime(queueTime);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过设置lockUntil时间、process_status=1来从数据库层抢占该条重试消息。
|
||||||
|
*/
|
||||||
private boolean claimRetryMessage(Long messageId, String channel, LocalDateTime currentTime, LocalDateTime lockUntil) {
|
private boolean claimRetryMessage(Long messageId, String channel, LocalDateTime currentTime, LocalDateTime lockUntil) {
|
||||||
return baseMapper.claimRetryMessage(messageId, channel, currentTime, lockUntil) > 0;
|
return baseMapper.claimRetryMessage(messageId, channel, currentTime, lockUntil) > 0;
|
||||||
}
|
}
|
||||||
@@ -341,18 +416,27 @@ public class MessageRetryQueueServiceImpl extends ServiceImpl<MessageRetryQueueM
|
|||||||
/**
|
/**
|
||||||
* 完成重试租约处理,根据消息状态执行相应的清理或重置操作
|
* 完成重试租约处理,根据消息状态执行相应的清理或重置操作
|
||||||
* - 如果消息状态为可重试失败,直接返回,不进行任何处理
|
* - 如果消息状态为可重试失败,直接返回,不进行任何处理
|
||||||
|
* - SENDING(两段式中间态)直接返回,保留记录与租约,等回执推进
|
||||||
* - 如果消息状态为待处理,重置队列记录的处理状态和锁时间,并将消息重新加入Redis重试队列
|
* - 如果消息状态为待处理,重置队列记录的处理状态和锁时间,并将消息重新加入Redis重试队列
|
||||||
* - 其他状态(成功、最终失败等),从Redis重试队列移除并删除数据库中的重试队列记录
|
* - 其他状态(成功、最终失败等),从Redis重试队列移除并删除数据库中的重试队列记录
|
||||||
*
|
*
|
||||||
* @param messageRecordDO 消息记录对象
|
* @param messageRecordDO 消息记录对象
|
||||||
*/
|
*/
|
||||||
private void finalizeRetryLease(MessageRecordDO messageRecordDO) {
|
private void finalizeRetryLease(MessageRecordDO messageRecordDO) {
|
||||||
if (MsgStatusConstant.RETRYABLE_FAILED.equals(messageRecordDO.getStatus())) {
|
String status = messageRecordDO.getStatus();
|
||||||
|
|
||||||
|
// 可重试失败:保留记录,等下一轮认领重发 —— 不变
|
||||||
|
if (MsgStatusConstant.RETRYABLE_FAILED.equals(status)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// “处理过程中断,但消息仍停留在 PENDING 时,主动释放 lease 并回队列”的兜底补偿逻辑
|
// 两段式发送的中间态:重发已受理、消息在途,保留记录、保持租约锁住、不回 Redis,等 20s 回执推进
|
||||||
if (MsgStatusConstant.PENDING.equals(messageRecordDO.getStatus())) {
|
if (MsgStatusConstant.SENDING.equals(status)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理过程中断但仍停留在 PENDING:主动释放 lease 并回队列(兜底补偿)—— 不变
|
||||||
|
if (MsgStatusConstant.PENDING.equals(status)) {
|
||||||
lambdaUpdate()
|
lambdaUpdate()
|
||||||
.eq(MessageRetryQueueDO::getMessageId, messageRecordDO.getId())
|
.eq(MessageRetryQueueDO::getMessageId, messageRecordDO.getId())
|
||||||
.set(MessageRetryQueueDO::getProcessStatus, PROCESS_STATUS_WAITING)
|
.set(MessageRetryQueueDO::getProcessStatus, PROCESS_STATUS_WAITING)
|
||||||
@@ -364,6 +448,7 @@ public class MessageRetryQueueServiceImpl extends ServiceImpl<MessageRetryQueueM
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 其余即终态:从 Redis 移除并删除数据库重试队列记录
|
||||||
messageRetryRedisDAO.removeFromRetryQueue(messageRecordDO.getChannel(), messageRecordDO.getId());
|
messageRetryRedisDAO.removeFromRetryQueue(messageRecordDO.getChannel(), messageRecordDO.getId());
|
||||||
deleteByMessageIds(Collections.singletonList(messageRecordDO.getId()));
|
deleteByMessageIds(Collections.singletonList(messageRecordDO.getId()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- 电信短信「短期频繁发送」限流码 MA:0038 配置为可重试
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- 背景:电信下行回执查询(action=select)对该类消息返回 status=6 / stat=MA:0038。
|
||||||
|
-- 1) 代码侧已在 TelecomSmsSender.getDownInfo 把 status=6 纳入失败映射分支;
|
||||||
|
-- 2) 本脚本在错误码映射表为 MA:0038 配置 should_retry=1,
|
||||||
|
-- 使其经 Sender.buildFailureResult 判定为 RETRYABLE_FAILED,进入重试队列。
|
||||||
|
-- 两者缺一不可。SMS 首次重试间隔默认 5 分钟,足以越过服务商 1 分钟限流窗口。
|
||||||
|
--
|
||||||
|
-- 该脚本幂等:已存在同 (provider_type, channel, original_code) 的有效记录时不重复插入。
|
||||||
|
-- ============================================================================
|
||||||
|
INSERT INTO push_provider_error_code_mapping
|
||||||
|
(provider_type, channel, original_code, original_message,
|
||||||
|
unified_error_code, unified_error_message, error_category,
|
||||||
|
should_retry, final_status, remark,
|
||||||
|
creator, updater, create_time, update_time, deleted)
|
||||||
|
SELECT
|
||||||
|
'telecom', 'sms', 'MA:0038', '短期频繁发送',
|
||||||
|
'RATE_LIMIT', '服务商短期频繁发送限流', 'PROVIDER_ERROR',
|
||||||
|
1, 'retryable_failed', '电信限流码,需自动重试',
|
||||||
|
'system', 'system', NOW(), NOW(), 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM push_provider_error_code_mapping
|
||||||
|
WHERE provider_type = 'telecom'
|
||||||
|
AND channel = 'sms'
|
||||||
|
AND original_code = 'MA:0038'
|
||||||
|
AND deleted = 0
|
||||||
|
);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- 普通类短信失败的重试阶梯配置(rootfix 后)
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- 背景:限流类(电信 MA:0038)已由代码硬编码 120s 间隔 + 12h 兜底,忽略本表。
|
||||||
|
-- 故 push_retry_strategy_config.retry_intervals 现仅作用于「普通类」sms 可
|
||||||
|
-- 重试失败(超时 / 通道故障 / 第三方调用失败等),与限流类无关。
|
||||||
|
--
|
||||||
|
-- 决策(TD-3,2026-06-20):普通类用收紧的阶梯退避,替代旧 quickfix 的 60s 平铺。
|
||||||
|
-- retry_intervals = '60,120,300,600' → 1→2→5→10 分钟,超出后按 10 分钟重复
|
||||||
|
-- max_retry_count = 6 → 第 6 次普通失败转 final_failed(约 28 分钟放弃)
|
||||||
|
--
|
||||||
|
-- 幂等:可重复执行。⚠ retry_intervals 不能含空格(代码 Long.parseLong 不容忍空格)。
|
||||||
|
-- 关系:本文件取代 sms_ratelimit_retry_quickfix.sql 的 ② 段;
|
||||||
|
-- ① 错误码映射见 provider-error-code-mapping/telecom_sms_MA0038_retryable.sql。
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- 1) 已存在启用中的 sms 策略行 → 覆盖为新阶梯
|
||||||
|
-- (同时纠正 max_retry_count,覆盖旧 quickfix 写入的 '60' / max 10)
|
||||||
|
UPDATE push_retry_strategy_config
|
||||||
|
SET retry_intervals = '60,120,300,600',
|
||||||
|
max_retry_count = 6,
|
||||||
|
update_time = NOW()
|
||||||
|
WHERE channel = 'sms'
|
||||||
|
AND enabled = 1
|
||||||
|
AND deleted = 0;
|
||||||
|
|
||||||
|
-- 2) 没有启用中的 sms 策略行 → 插一条(否则代码回落硬编码 300/600/1800、max 5)
|
||||||
|
INSERT INTO push_retry_strategy_config
|
||||||
|
(channel, max_retry_count, retry_intervals, enabled,
|
||||||
|
creator, updater, create_time, update_time, deleted)
|
||||||
|
SELECT 'sms', 6, '60,120,300,600', 1,
|
||||||
|
'system', 'system', NOW(), NOW(), 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM push_retry_strategy_config
|
||||||
|
WHERE channel = 'sms'
|
||||||
|
AND enabled = 1
|
||||||
|
AND deleted = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- 执行后自检(可选):确认普通类阶梯已就位
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- SELECT channel, max_retry_count, retry_intervals, enabled
|
||||||
|
-- FROM push_retry_strategy_config
|
||||||
|
-- WHERE channel = 'sms' AND enabled = 1 AND deleted = 0;
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.njcn.msgpush.module.push.service.retry;
|
||||||
|
|
||||||
|
import com.njcn.msgpush.module.push.dal.dataobject.message.MessageRecordDO;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 限流重试治本核心的纯决策逻辑单测(无 Spring / 无 DB / 无 Redis)。
|
||||||
|
*/
|
||||||
|
class RetryDecisionLogicTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isRateLimit_trueOnlyForRateLimitErrorCode() {
|
||||||
|
MessageRecordDO rateLimited = new MessageRecordDO();
|
||||||
|
rateLimited.setErrorCode("RATE_LIMIT");
|
||||||
|
assertTrue(MessageRetryQueueServiceImpl.isRateLimit(rateLimited));
|
||||||
|
|
||||||
|
MessageRecordDO other = new MessageRecordDO();
|
||||||
|
other.setErrorCode("SOMETHING_ELSE");
|
||||||
|
assertFalse(MessageRetryQueueServiceImpl.isRateLimit(other));
|
||||||
|
|
||||||
|
MessageRecordDO noCode = new MessageRecordDO();
|
||||||
|
assertFalse(MessageRetryQueueServiceImpl.isRateLimit(noCode));
|
||||||
|
|
||||||
|
assertFalse(MessageRetryQueueServiceImpl.isRateLimit(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isRateLimitExhausted_nullFirstFailTimeMeansNotExhausted() {
|
||||||
|
assertFalse(MessageRetryQueueServiceImpl.isRateLimitExhausted(null, LocalDateTime.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isRateLimitExhausted_falseBelow12h_trueAtOrAbove12h() {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
// 43199s < 12h → 未满
|
||||||
|
assertFalse(MessageRetryQueueServiceImpl.isRateLimitExhausted(now.minusSeconds(43199), now));
|
||||||
|
// 43200s == 12h → 满(>=)
|
||||||
|
assertTrue(MessageRetryQueueServiceImpl.isRateLimitExhausted(now.minusSeconds(43200), now));
|
||||||
|
// 43201s > 12h → 满
|
||||||
|
assertTrue(MessageRetryQueueServiceImpl.isRateLimitExhausted(now.minusSeconds(43201), now));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void calculateNextRetryTime_rateLimitUsesFixed120s() {
|
||||||
|
MessageRetryQueueServiceImpl service = new MessageRetryQueueServiceImpl();
|
||||||
|
LocalDateTime before = LocalDateTime.now();
|
||||||
|
LocalDateTime next = service.calculateNextRetryTime("SMS", 1, true);
|
||||||
|
long seconds = Duration.between(before, next).getSeconds();
|
||||||
|
// rate-limit 分支在使用任何字段前就 return,故 new 裸实例可调;允许执行抖动 ±2s
|
||||||
|
assertTrue(seconds >= 118 && seconds <= 122, "expected ~120s, got " + seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nextRetryCount_rateLimitFreezes_normalIncrements() {
|
||||||
|
// 普通失败:累加,消耗次数预算
|
||||||
|
assertEquals(2, MessageRetryQueueServiceImpl.nextRetryCount(1, false));
|
||||||
|
// 限流失败:冻结,不消耗次数预算(靠 12h 兜底终止)
|
||||||
|
assertEquals(1, MessageRetryQueueServiceImpl.nextRetryCount(1, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nextRetryCount_rateLimitRetriesDoNotConsumeNormalBudget() {
|
||||||
|
// I3 回归:首次普通失败后,连续多次限流重试不应吃掉普通失败的次数预算
|
||||||
|
int rc = 1; // 首次普通失败入队
|
||||||
|
rc = MessageRetryQueueServiceImpl.nextRetryCount(rc, true); // 限流重试
|
||||||
|
rc = MessageRetryQueueServiceImpl.nextRetryCount(rc, true); // 限流重试
|
||||||
|
rc = MessageRetryQueueServiceImpl.nextRetryCount(rc, true); // 限流重试
|
||||||
|
assertEquals(1, rc, "限流重试不应增加普通失败计数");
|
||||||
|
rc = MessageRetryQueueServiceImpl.nextRetryCount(rc, false); // 第 2 次普通失败
|
||||||
|
assertEquals(2, rc, "普通失败才累加,且计数从未被限流污染");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,7 +74,7 @@ public class MsgPushClientTest {
|
|||||||
ChannelProviderConfigDO config = new ChannelProviderConfigDO();
|
ChannelProviderConfigDO config = new ChannelProviderConfigDO();
|
||||||
config.setApiUrl("https://sms.ymeeting.cn/smsv2");
|
config.setApiUrl("https://sms.ymeeting.cn/smsv2");
|
||||||
config.setAppKey("925631");
|
config.setAppKey("925631");
|
||||||
config.setAppSecret("AMW2pOVrdky");
|
config.setSecret("AMW2pOVrdky");
|
||||||
config.setExtno("106905631");
|
config.setExtno("106905631");
|
||||||
SmsSender smsSender = messageProviderFactory.createSmsSender(config, sender);
|
SmsSender smsSender = messageProviderFactory.createSmsSender(config, sender);
|
||||||
String templateIdentifier = "SMS_481710295";
|
String templateIdentifier = "SMS_481710295";
|
||||||
|
|||||||
Reference in New Issue
Block a user