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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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