Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2bbdd865d | ||
|
|
e9f66ca0b2 | ||
|
|
8f86c563f0 | ||
|
|
db15799440 | ||
|
|
c4b56a727c | ||
|
|
fe26aa8670 | ||
|
|
9343799290 |
@@ -20,12 +20,12 @@ public class AliyunProviderFactory implements MessageProviderFactory {
|
||||
@Override
|
||||
public SmsSender createSmsSender(ChannelProviderConfigDO config, Sender sender) {
|
||||
// 配置缺失时返回固定结果 sender,由主流程统一落库为配置异常。
|
||||
if (ProviderFactorySupport.hasBlank(config.getAppKey(), config.getAppSecret())) {
|
||||
if (ProviderFactorySupport.hasBlank(config.getAppKey(), config.getSecret())) {
|
||||
return new FixedResultSmsSender(SendOutcome.CONFIG_INVALID, "当前服务商短信渠道配置不完整");
|
||||
}
|
||||
AliYunMailSetting aliYunSmsSetting = AliYunMailSetting.builder()
|
||||
.accessKeyId(config.getAppKey())
|
||||
.accessKeySecret(config.getAppSecret())
|
||||
.accessKeySecret(config.getSecret())
|
||||
.regionId("cn-hangzhou")
|
||||
.endpoint("dysmsapi.aliyuncs.com")
|
||||
.build();
|
||||
@@ -35,12 +35,12 @@ public class AliyunProviderFactory implements MessageProviderFactory {
|
||||
@Override
|
||||
public EmailSender createEmailSender(ChannelProviderConfigDO config, Sender sender) {
|
||||
// 配置缺失时返回固定结果 sender,由主流程统一落库为配置异常。
|
||||
if (ProviderFactorySupport.hasBlank(config.getAppKey(), config.getAppSecret())) {
|
||||
if (ProviderFactorySupport.hasBlank(config.getAppKey(), config.getSecret())) {
|
||||
return new FixedResultEmailSender(SendOutcome.CONFIG_INVALID, "当前服务商邮件渠道配置不完整");
|
||||
}
|
||||
AliYunMailSetting aliYunMailSetting = AliYunMailSetting.builder()
|
||||
.accessKeyId(config.getAppKey())
|
||||
.accessKeySecret(config.getAppSecret())
|
||||
.accessKeySecret(config.getSecret())
|
||||
.regionId("cn-hangzhou")
|
||||
.endpoint("dm.aliyuncs.com")
|
||||
.build();
|
||||
|
||||
@@ -19,12 +19,12 @@ public class TelecomProviderFactory implements MessageProviderFactory {
|
||||
@Override
|
||||
public SmsSender createSmsSender(ChannelProviderConfigDO config, 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, "当前服务商短信渠道配置不完整");
|
||||
}
|
||||
TelecomSmsSetting telecomSmsSetting = TelecomSmsSetting.builder()
|
||||
.account(config.getAppKey())
|
||||
.password(config.getAppSecret())
|
||||
.password(config.getSecret())
|
||||
.apiUrl(config.getApiUrl())
|
||||
.extno(config.getExtno())
|
||||
.build();
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
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.ProviderFactorySupport;
|
||||
import com.njcn.msgpush.module.push.client.sender.AppPushSender;
|
||||
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.*;
|
||||
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.FixedResultSmsSender;
|
||||
@@ -19,8 +12,6 @@ import com.njcn.msgpush.module.push.dal.dataobject.channel.ChannelProviderConfig
|
||||
|
||||
public class UniPushProviderFactory implements MessageProviderFactory {
|
||||
|
||||
private static final String APP_ID = "appId";
|
||||
private static final String MASTER_SECRET = "masterSecret";
|
||||
|
||||
@Override
|
||||
public SmsSender createSmsSender(ChannelProviderConfigDO config, Sender sender) {
|
||||
@@ -36,18 +27,15 @@ public class UniPushProviderFactory implements MessageProviderFactory {
|
||||
|
||||
@Override
|
||||
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,由主流程统一落库为配置异常。
|
||||
if (ProviderFactorySupport.hasBlank(appId, config.getAppKey(), config.getAppSecret(), masterSecret)) {
|
||||
if (ProviderFactorySupport.hasBlank(config.getSecret())) {
|
||||
return new FixedResultAppPushSender(SendOutcome.CONFIG_INVALID, "当前服务商 APP 推送渠道配置不完整");
|
||||
}
|
||||
UniPushAppPushSetting uniPushAppPushSetting = new UniPushAppPushSetting(
|
||||
appId,
|
||||
config.getAppId(),
|
||||
config.getAppKey(),
|
||||
config.getAppSecret(),
|
||||
masterSecret
|
||||
config.getSecret(),
|
||||
config.getApiUrl()
|
||||
);
|
||||
return new UniPushAppPushSender(uniPushAppPushSetting, sender);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
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.GtApiConfiguration;
|
||||
import com.getui.push.v2.sdk.api.PushApi;
|
||||
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.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.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.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.SendResult;
|
||||
import com.njcn.msgpush.module.push.client.sender.Sender;
|
||||
@@ -34,7 +42,13 @@ public class UniPushAppPushSender implements AppPushSender {
|
||||
gtApiConfiguration.setAppId(uniPushAppPushSetting.getAppId());
|
||||
gtApiConfiguration.setAppKey(uniPushAppPushSetting.getAppKey());
|
||||
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);
|
||||
this.pushApi = apiHelper.creatApi(PushApi.class);
|
||||
} catch (Exception e) {
|
||||
@@ -89,13 +103,48 @@ public class UniPushAppPushSender implements AppPushSender {
|
||||
settings.setTtl(3600000);
|
||||
pushDTO.setSettings(settings);
|
||||
|
||||
// 创建 PushMessage,设置透传消息(可选)
|
||||
PushMessage pushMessage = new PushMessage();
|
||||
GTNotification notification = new GTNotification();
|
||||
notification.setTitle(title);
|
||||
notification.setBody(content);
|
||||
notification.setClickType("startapp");
|
||||
pushMessage.setNotification(notification);
|
||||
pushMessage.setTransmission("{\"title\":\"" + title + "\",\"body\":\"" + content + "\"}");
|
||||
|
||||
// 在线推送 - 使用 GTNotification
|
||||
GTNotification gtNotification = new GTNotification();
|
||||
gtNotification.setTitle(title);
|
||||
gtNotification.setBody(content);
|
||||
gtNotification.setClickType("startapp");
|
||||
pushMessage.setNotification(gtNotification);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
package com.njcn.msgpush.module.push.client.sender.impl.email;
|
||||
|
||||
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.models.SingleSendMailRequest;
|
||||
import com.aliyun.dm20151123.models.SingleSendMailResponse;
|
||||
import com.aliyun.dm20151123.models.*;
|
||||
import com.aliyun.teaopenapi.models.Config;
|
||||
import com.aliyun.teautil.models.RuntimeOptions;
|
||||
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.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -25,9 +24,10 @@ import java.util.concurrent.TimeUnit;
|
||||
@Slf4j
|
||||
public class AliyunEmailSender implements EmailSender {
|
||||
|
||||
private static final String ACCOUNT_NAME = "accountName";
|
||||
private static final String REPLY_TO_ADDRESS = "replyToAddress";
|
||||
private static final String FROM_ALIAS = "fromAlias";
|
||||
private static final String ACCOUNT_NAME = "njcn@shining-electric.cn";
|
||||
private static final Integer ADDRESS_TYPE = 1;
|
||||
private static final Boolean REPLY_TO_ADDRESS = false;
|
||||
private static final String FROM_ALIAS = "南京灿能";
|
||||
|
||||
private final Sender sender;
|
||||
|
||||
@@ -60,17 +60,15 @@ public class AliyunEmailSender implements EmailSender {
|
||||
RuntimeOptions runtimeOptions = new RuntimeOptions();
|
||||
runtimeOptions.autoretry = true;
|
||||
|
||||
// JSONObject jsonObject = JSON.parseObject(message.getExtraInfo());
|
||||
JSONObject jsonObject = null;
|
||||
SingleSendMailRequest request = new SingleSendMailRequest()
|
||||
.setAccountName(jsonObject.getString(ACCOUNT_NAME))
|
||||
.setAddressType(1)
|
||||
.setReplyToAddress(jsonObject.getBooleanValue(REPLY_TO_ADDRESS))
|
||||
.setAccountName(ACCOUNT_NAME)
|
||||
.setAddressType(ADDRESS_TYPE)
|
||||
.setReplyToAddress(REPLY_TO_ADDRESS)
|
||||
.setToAddress(message.getReceiver())
|
||||
.setSubject(message.getTitle())
|
||||
.setHtmlBody(message.getContent())
|
||||
.setTextBody("")
|
||||
.setFromAlias(jsonObject.getString(FROM_ALIAS));
|
||||
.setFromAlias(FROM_ALIAS);
|
||||
|
||||
try {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
@@ -82,8 +80,11 @@ public class AliyunEmailSender implements EmailSender {
|
||||
message.setCostTime(costTime);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,22 +214,6 @@ public class TelecomSmsSender implements SmsSender {
|
||||
// 回执确认成功后,复用统一成功落库逻辑。
|
||||
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) {
|
||||
log.error("电信短信回执查询失败", e);
|
||||
}
|
||||
|
||||
@@ -12,15 +12,17 @@ import lombok.EqualsAndHashCode;
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class UniPushAppPushSetting extends AppPushSetting {
|
||||
// 个推应用ID
|
||||
private String appId;
|
||||
private String appKey;
|
||||
private String uniAppSecret;
|
||||
// 个推主密钥
|
||||
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.appKey = appKey;
|
||||
this.uniAppSecret = uniAppSecret;
|
||||
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 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.idempotent.core.annotation.Idempotent;
|
||||
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.security.access.prepost.PreAuthorize;
|
||||
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;
|
||||
|
||||
@@ -41,43 +47,47 @@ public class MessageRecordController {
|
||||
@Operation(summary = "短信推送")
|
||||
@Idempotent(timeout = 2)
|
||||
public CommonResult<List<MessageSendResultVO>> sendSms(@Valid @RequestBody List<MessageRecordReqVO> reqVOList,
|
||||
@RequestHeader(value = "X-Credential-Token", required = false) String credentialToken) {
|
||||
CredentialServiceImpl.CredentialInfo credentialInfo = credentialService.verifyCredential(credentialToken);
|
||||
String systemName = credentialInfo.getSystemName();
|
||||
return success(messageRecordService.send(reqVOList, ChannelTypeEnum.SMS, systemName));
|
||||
@RequestHeader(value = "X-Credential-Token", required = true) String credentialToken) {
|
||||
return doSend(reqVOList, credentialToken, ChannelTypeEnum.SMS);
|
||||
}
|
||||
|
||||
@PermitAll
|
||||
@PostMapping("/send/email")
|
||||
@Operation(summary = "邮箱推送")
|
||||
@Operation(summary = "邮件推送")
|
||||
@Idempotent(timeout = 2)
|
||||
public CommonResult<List<MessageSendResultVO>> sendEmail(@Valid @RequestBody List<MessageRecordReqVO> reqVOList,
|
||||
@RequestHeader(value = "X-Credential-Token", required = false) String credentialToken) {
|
||||
CredentialServiceImpl.CredentialInfo credentialInfo = credentialService.verifyCredential(credentialToken);
|
||||
String systemName = credentialInfo.getSystemName();
|
||||
return success(messageRecordService.send(reqVOList, ChannelTypeEnum.EMAIL, systemName));
|
||||
@RequestHeader(value = "X-Credential-Token", required = true) String credentialToken) {
|
||||
return doSend(reqVOList, credentialToken, ChannelTypeEnum.EMAIL);
|
||||
}
|
||||
|
||||
@PermitAll
|
||||
@PostMapping("/send/app")
|
||||
@Operation(summary = "app推送")
|
||||
@Operation(summary = "APP 推送")
|
||||
@Idempotent(timeout = 2)
|
||||
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);
|
||||
String systemName = credentialInfo.getSystemName();
|
||||
return success(messageRecordService.send(reqVOList, ChannelTypeEnum.APP, systemName));
|
||||
if (credentialInfo == null) {
|
||||
return CommonResult.error(GlobalErrorCodeConstants.UNAUTHORIZED.getCode(), "凭证无效或已过期");
|
||||
}
|
||||
return success(messageRecordService.send(reqVOList, channelTypeEnum, credentialInfo.getSystemName()));
|
||||
}
|
||||
|
||||
@PostMapping("/page")
|
||||
@Operation(summary = "分页查询渠道服务商列表")
|
||||
@Operation(summary = "分页查询消息记录")
|
||||
@PreAuthorize("@ss.hasPermission('push:message:page')")
|
||||
public CommonResult<Page<MessageRecordDO>> pageChannelProviderConfig(@Validated @RequestBody MessageRecordReqVO reqVO) {
|
||||
return success(messageRecordService.getPage(reqVO));
|
||||
}
|
||||
|
||||
@PostMapping("/add")
|
||||
@Operation(summary = "添加消息记录")
|
||||
@Operation(summary = "新增消息记录")
|
||||
@PreAuthorize("@ss.hasPermission('push:message:add')")
|
||||
public CommonResult<Boolean> add(@Validated @RequestBody MessageRecordReqVO reqVO) {
|
||||
return success(messageRecordService.add(reqVO));
|
||||
|
||||
@@ -25,7 +25,7 @@ public class MessageRecordReqVO extends PageParam {
|
||||
|
||||
@Schema(description = "接收者", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "接收者不能为空")
|
||||
@Pattern(regexp = RegexPool.EMAIL + "|" + RegexPool.MOBILE, message = "必须是有效的邮箱或手机号格式")
|
||||
// @Pattern(regexp = RegexPool.EMAIL + "|" + RegexPool.MOBILE, message = "必须是有效的邮箱或手机号格式")
|
||||
private String receiver;
|
||||
|
||||
@Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
|
||||
@@ -32,30 +32,30 @@ public class ChannelProviderConfigDO extends BaseDO {
|
||||
private String providerName;
|
||||
|
||||
/**
|
||||
* 服务商类型:telecom/cmcc/aliyun/twilio/unipush
|
||||
* 服务商类型:telecom/aliyun/unipush
|
||||
*/
|
||||
private String providerType;
|
||||
|
||||
/**
|
||||
* API地址
|
||||
* 对于 UniPush,可配置为自定义API地址,如未配置则使用默认地址 https://restapi.getui.com/v2/
|
||||
*/
|
||||
private String apiUrl;
|
||||
|
||||
/**
|
||||
* AppKey
|
||||
*/
|
||||
private String appKey;
|
||||
|
||||
/**
|
||||
* AppSecret
|
||||
*/
|
||||
private String appSecret;
|
||||
private String secret;
|
||||
|
||||
/**
|
||||
* 电信sms服务所需接入码
|
||||
*/
|
||||
private String extno;
|
||||
|
||||
/**
|
||||
* app推送服务所需的appId
|
||||
*/
|
||||
private String appId;
|
||||
|
||||
/**
|
||||
* 额外配置(JSON格式)
|
||||
*/
|
||||
|
||||
@@ -91,7 +91,7 @@ public class CredentialAuthenticationFilter extends ApiRequestFilter implements
|
||||
|
||||
// 2. 如果没有凭证,继续过滤链
|
||||
if (StrUtil.isEmpty(credentialToken)) {
|
||||
filterChain.doFilter(request, response);
|
||||
ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.UNAUTHORIZED.getCode(), "缺少凭证"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -112,4 +112,4 @@ public class CredentialAuthenticationFilter extends ApiRequestFilter implements
|
||||
public int getOrder() {
|
||||
return 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.njcn.msgpush.module.push.service.credential;
|
||||
|
||||
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.crypto.SecureUtil;
|
||||
import cn.hutool.crypto.Mode;
|
||||
import cn.hutool.crypto.Padding;
|
||||
import cn.hutool.crypto.symmetric.AES;
|
||||
import com.njcn.msgpush.framework.common.exception.ServiceException;
|
||||
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 lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.stereotype.Service;
|
||||
|
||||
@@ -28,186 +33,186 @@ import java.util.concurrent.TimeUnit;
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class CredentialServiceImpl implements ICredentialService {
|
||||
/**
|
||||
* 凭证加密密钥(生产环境应通过配置中心或环境变量注入)
|
||||
*/
|
||||
private String credentialSecretKey = "88888888888888888888888888888888"; // 32 字节
|
||||
|
||||
@Autowired
|
||||
private ISystemSecretService systemSecretService;
|
||||
private static final long CREDENTIAL_EXPIRE_HOURS = 24L;
|
||||
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;
|
||||
|
||||
public CredentialServiceImpl(StringRedisTemplate stringRedisTemplate) {
|
||||
this.stringRedisTemplate = stringRedisTemplate;
|
||||
}
|
||||
|
||||
private final RedissonClient redissonClient;
|
||||
|
||||
@Override
|
||||
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 {
|
||||
// 1. 验证系统密钥
|
||||
boolean validatedSecretRes = validateSecret(reqDTO.getSystemName(), reqDTO.getSecretKey());
|
||||
if (!validatedSecretRes) {
|
||||
throw new ServiceException(ServiceErrorCodeRange.VALIDATE_SYSTEM_SECRET_FAIL);
|
||||
locked = lock.tryLock(LOCK_WAIT_SECONDS, LOCK_LEASE_SECONDS, TimeUnit.SECONDS);
|
||||
if (!locked) {
|
||||
log.warn("[generateCredential][systemName({}) failed to acquire lock within {} seconds]",
|
||||
systemName, LOCK_WAIT_SECONDS);
|
||||
throw new ServiceException(ServiceErrorCodeRange.GENERATE_CREDENTIAL_FAIL);
|
||||
}
|
||||
|
||||
// 2. 创建凭证信息
|
||||
CredentialInfo credentialInfo = new CredentialInfo(
|
||||
reqDTO.getSystemName(),
|
||||
LocalDateTime.now().plusHours(24)
|
||||
);
|
||||
|
||||
// 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) {
|
||||
deleteCredential(systemName);
|
||||
CredentialInfo credentialInfo = new CredentialInfo(systemName, expiresTime, credential);
|
||||
cacheCredential(systemName, credentialInfo);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.warn("[generateCredential][systemName({}) interrupted while waiting for lock]", systemName, e);
|
||||
throw new ServiceException(ServiceErrorCodeRange.GENERATE_CREDENTIAL_FAIL);
|
||||
}
|
||||
}
|
||||
|
||||
public CredentialInfo verifyCredential(String token) {
|
||||
if (StrUtil.isEmpty(token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 从 Redis 获取凭证信息
|
||||
CredentialInfo credentialInfo = getCredentialFromRedis(token);
|
||||
if (credentialInfo == null) {
|
||||
return null;
|
||||
} finally {
|
||||
if (locked && lock.isHeldByCurrentThread()) {
|
||||
lock.unlock();
|
||||
}
|
||||
|
||||
// 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 secretKey 密钥
|
||||
* @param credentialToken 凭证 credentialToken
|
||||
* @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) {
|
||||
SystemSecretDO systemSecretDO = systemSecretService.getBySystemName(systemName);
|
||||
|
||||
String storedSecret = systemSecretDO.getSecret();
|
||||
if (!storedSecret.equals(secretKey)) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
return systemSecretDO != null && StrUtil.equals(systemSecretDO.getSecret(), secretKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密凭证信息
|
||||
* 获取加密后的凭证credential
|
||||
*
|
||||
* @param info 凭证信息
|
||||
* @return 加密后的 token
|
||||
* @param systemName 系统名称
|
||||
* @param expiresTime 过期时间
|
||||
* @return
|
||||
*/
|
||||
private String encryptCredential(CredentialInfo info) {
|
||||
AES aes = SecureUtil.aes(credentialSecretKey.getBytes(StandardCharsets.UTF_8));
|
||||
String json = JsonUtils.toJsonString(info);
|
||||
byte[] encrypted = aes.encrypt(json.getBytes(StandardCharsets.UTF_8));
|
||||
private String encryptCredential(String systemName, LocalDateTime expiresTime) {
|
||||
// 生成随机 IV
|
||||
byte[] iv = RandomUtil.randomBytes(16);
|
||||
|
||||
// 创建 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密凭证信息
|
||||
* 缓存系统凭证credential
|
||||
*
|
||||
* @param token 凭证 token
|
||||
* @return 凭证信息
|
||||
* @param systemName
|
||||
* @param info
|
||||
*/
|
||||
private CredentialInfo decryptCredential(String token) {
|
||||
try {
|
||||
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);
|
||||
private void cacheCredential(String systemName, CredentialInfo info) {
|
||||
String credentialKey = formatCredentialKey(systemName);
|
||||
String jsonValue = JsonUtils.toJsonString(info);
|
||||
|
||||
// 计算剩余过期时间(秒)
|
||||
long remainingSeconds = Duration.between(LocalDateTime.now(), info.getExpiresTime()).getSeconds();
|
||||
if (remainingSeconds > 0) {
|
||||
stringRedisTemplate.opsForValue().set(redisKey, jsonValue, remainingSeconds, TimeUnit.SECONDS);
|
||||
stringRedisTemplate.opsForValue().set(credentialKey, jsonValue, remainingSeconds, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Redis 获取凭证信息
|
||||
* 从redis中获取系统凭证信息
|
||||
*
|
||||
* @param token 凭证 token
|
||||
* @return 凭证信息
|
||||
* @param systemName
|
||||
* @return
|
||||
*/
|
||||
private CredentialInfo getCredentialFromRedis(String token) {
|
||||
String redisKey = formatRedisKey(token);
|
||||
String jsonValue = stringRedisTemplate.opsForValue().get(redisKey);
|
||||
private CredentialInfo getCredentialFromRedis(String systemName) {
|
||||
String credentialKey = formatCredentialKey(systemName);
|
||||
String jsonValue = stringRedisTemplate.opsForValue().get(credentialKey);
|
||||
if (StrUtil.isEmpty(jsonValue)) {
|
||||
return null;
|
||||
}
|
||||
return JsonUtils.parseObject(jsonValue, CredentialInfo.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除凭证
|
||||
*
|
||||
* @param token 凭证 token
|
||||
*/
|
||||
private void deleteCredential(String token) {
|
||||
String redisKey = formatRedisKey(token);
|
||||
stringRedisTemplate.delete(redisKey);
|
||||
|
||||
private void deleteCredential(String systemName) {
|
||||
String credentialKey = formatCredentialKey(systemName);
|
||||
String oldToken = stringRedisTemplate.opsForValue().get(credentialKey);
|
||||
if (StrUtil.isNotEmpty(oldToken)) {
|
||||
stringRedisTemplate.delete(credentialKey);
|
||||
}
|
||||
}
|
||||
|
||||
private String formatCredentialKey(String systemName) {
|
||||
return SYSTEM_KEY_PREFIX + systemName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 Redis Key
|
||||
* 获取锁的key(用于针对同一时刻多个请求,保证多个请求只对同一个key进行加锁)
|
||||
*
|
||||
* @param token 凭证 token
|
||||
* @return Redis Key
|
||||
* @param systemName
|
||||
* @return
|
||||
*/
|
||||
private String formatRedisKey(String token) {
|
||||
return "credential:token:" + token;
|
||||
private String formatLockKey(String systemName) {
|
||||
return LOCK_KEY_PREFIX + systemName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 凭证信息内部类
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public static class CredentialInfo {
|
||||
private String systemName;
|
||||
private LocalDateTime expiresTime;
|
||||
private String credential;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,9 @@ public class MessageRecordServiceImpl extends ServiceImpl<MessageRecordMapper, M
|
||||
// 成功或已受理都视为本次调用已正常投递,更新成功健康度并消耗配额。
|
||||
if (SendOutcome.ACCEPTED.equals(sendResult.getOutcome())) {
|
||||
messageConfirmRedisDAO.addToConfirmQueue(messageRecordDO);
|
||||
|
||||
systemQuotaRedisDAO.set(messageRecordDO.getChannel(), messageRecordDO.getAppName());
|
||||
rateLimitRedisDAO.set(messageRecordDO.getChannel(), messageRecordDO.getAppName(), messageRecordDO.getReceiver());
|
||||
} else if (SendOutcome.RETRYABLE_FAILED.equals(sendResult.getOutcome())) {
|
||||
messageRetryQueueService.saveOrUpdateRetryMessage(messageRecordDO);
|
||||
channelProviderConfigService.failureUpdate(messageRecordDO.getProviderType(), messageRecordDO.getChannel());
|
||||
@@ -273,7 +276,7 @@ public class MessageRecordServiceImpl extends ServiceImpl<MessageRecordMapper, M
|
||||
public Page<MessageRecordDO> getPage(MessageRecordReqVO reqVO) {
|
||||
QueryWrapper<MessageRecordDO> wrapper = new QueryWrapper<>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +271,7 @@ public class MessageRetryQueueServiceImpl extends ServiceImpl<MessageRetryQueueM
|
||||
log.info("========== 重试队列重建完成 ==========");
|
||||
}
|
||||
|
||||
// 每60分钟主动从数据库中扫描一次,将消息重试数据同步到 Redis 中
|
||||
@Scheduled(fixedRate = 600000)
|
||||
@Override
|
||||
public void syncRetryQueueConsistency() {
|
||||
@@ -334,6 +335,9 @@ public class MessageRetryQueueServiceImpl extends ServiceImpl<MessageRetryQueueM
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过设置lockUntil时间、process_status=1来从数据库层抢占该条重试消息。
|
||||
*/
|
||||
private boolean claimRetryMessage(Long messageId, String channel, LocalDateTime currentTime, LocalDateTime lockUntil) {
|
||||
return baseMapper.claimRetryMessage(messageId, channel, currentTime, lockUntil) > 0;
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ public class MsgPushClientTest {
|
||||
ChannelProviderConfigDO config = new ChannelProviderConfigDO();
|
||||
config.setApiUrl("https://sms.ymeeting.cn/smsv2");
|
||||
config.setAppKey("925631");
|
||||
config.setAppSecret("AMW2pOVrdky");
|
||||
config.setSecret("AMW2pOVrdky");
|
||||
config.setExtno("106905631");
|
||||
SmsSender smsSender = messageProviderFactory.createSmsSender(config, sender);
|
||||
String templateIdentifier = "SMS_481710295";
|
||||
|
||||
Reference in New Issue
Block a user