7 Commits

Author SHA1 Message Date
caozehui
c2bbdd865d 系统凭证调整 2026-05-13 09:47:54 +08:00
caozehui
e9f66ca0b2 微调 2026-05-12 16:02:11 +08:00
caozehui
8f86c563f0 微调 2026-05-12 11:22:26 +08:00
caozehui
db15799440 微调 2026-05-12 08:42:07 +08:00
caozehui
c4b56a727c 微调 2026-05-11 15:49:05 +08:00
caozehui
fe26aa8670 微调 2026-05-11 13:57:02 +08:00
caozehui
9343799290 引入阿里云邮件推送 2026-05-11 10:57:40 +08:00
15 changed files with 307 additions and 215 deletions

View File

@@ -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();

View File

@@ -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();

View File

@@ -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);
} }

View File

@@ -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;
} }
} }

View File

@@ -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);
}
} }

View File

@@ -214,22 +214,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);
} }

View File

@@ -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;
} }
} }

View File

@@ -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));

View File

@@ -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)

View File

@@ -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格式
*/ */

View File

@@ -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;
} }
} }

View File

@@ -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;
} }
} }

View File

@@ -193,6 +193,9 @@ 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);
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 +276,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);
} }
} }

View File

@@ -271,6 +271,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() {
@@ -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) { 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;
} }

View File

@@ -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";