mirror of
https://gitee.com/hhyykk/ipms-sjy.git
synced 2025-08-14 18:21:53 +08:00
接入腾讯云短信
This commit is contained in:
@@ -4,6 +4,7 @@ import cn.iocoder.yudao.framework.sms.core.client.SmsClient;
|
||||
import cn.iocoder.yudao.framework.sms.core.client.SmsClientFactory;
|
||||
import cn.iocoder.yudao.framework.sms.core.client.impl.aliyun.AliyunSmsClient;
|
||||
import cn.iocoder.yudao.framework.sms.core.client.impl.debug.DebugDingTalkSmsClient;
|
||||
import cn.iocoder.yudao.framework.sms.core.client.impl.tencent.TencentSmsClient;
|
||||
import cn.iocoder.yudao.framework.sms.core.client.impl.yunpian.YunpianSmsClient;
|
||||
import cn.iocoder.yudao.framework.sms.core.enums.SmsChannelEnum;
|
||||
import cn.iocoder.yudao.framework.sms.core.property.SmsChannelProperties;
|
||||
@@ -44,7 +45,7 @@ public class SmsClientFactoryImpl implements SmsClientFactory {
|
||||
Arrays.stream(SmsChannelEnum.values()).forEach(channel -> {
|
||||
// 创建一个空的 SmsChannelProperties 对象
|
||||
SmsChannelProperties properties = new SmsChannelProperties().setCode(channel.getCode())
|
||||
.setApiKey("default").setApiSecret("default");
|
||||
.setApiKey("default default").setApiSecret("default");
|
||||
// 创建 Sms 客户端
|
||||
AbstractSmsClient smsClient = createSmsClient(properties);
|
||||
channelCodeClients.put(channel.getCode(), smsClient);
|
||||
@@ -81,6 +82,7 @@ public class SmsClientFactoryImpl implements SmsClientFactory {
|
||||
case ALIYUN: return new AliyunSmsClient(properties);
|
||||
case YUN_PIAN: return new YunpianSmsClient(properties);
|
||||
case DEBUG_DING_TALK: return new DebugDingTalkSmsClient(properties);
|
||||
case TENCENT: return new TencentSmsClient(properties);
|
||||
}
|
||||
// 创建失败,错误日志 + 抛出异常
|
||||
log.error("[createSmsClient][配置({}) 找不到合适的客户端实现]", properties);
|
||||
|
@@ -0,0 +1,294 @@
|
||||
package cn.iocoder.yudao.framework.sms.core.client.impl.tencent;
|
||||
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.framework.common.core.KeyValue;
|
||||
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.framework.sms.core.client.SmsCommonResult;
|
||||
import cn.iocoder.yudao.framework.sms.core.client.dto.SmsReceiveRespDTO;
|
||||
import cn.iocoder.yudao.framework.sms.core.client.dto.SmsSendRespDTO;
|
||||
import cn.iocoder.yudao.framework.sms.core.client.dto.SmsTemplateRespDTO;
|
||||
import cn.iocoder.yudao.framework.sms.core.client.impl.AbstractSmsClient;
|
||||
import cn.iocoder.yudao.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
|
||||
import cn.iocoder.yudao.framework.sms.core.property.SmsChannelProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.tencentcloudapi.common.Credential;
|
||||
import com.tencentcloudapi.common.exception.TencentCloudSDKException;
|
||||
import com.tencentcloudapi.sms.v20210111.SmsClient;
|
||||
import com.tencentcloudapi.sms.v20210111.models.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
|
||||
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT;
|
||||
|
||||
/**
|
||||
* 腾讯云短信功能实现
|
||||
* <p>
|
||||
* 参见 https://cloud.tencent.com/document/product/382/52077
|
||||
*
|
||||
* @author : shiwp
|
||||
*/
|
||||
public class TencentSmsClient extends AbstractSmsClient {
|
||||
|
||||
private SmsClient client;
|
||||
|
||||
public TencentSmsClient(SmsChannelProperties properties) {
|
||||
// 腾讯云发放短信的时候需要额外的参数sdkAppId, 所以和secretId组合在一起放到apiKey字段中,格式为[secretId sdkAppId],
|
||||
// 这边需要做拆分重新封装到properties内
|
||||
super(TencentSmsChannelProperties.build(properties), new TencentSmsCodeMapping());
|
||||
Assert.notEmpty(properties.getApiSecret(), "apiSecret 不能为空");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doInit() {
|
||||
// init或者refresh时需要重新封装properties
|
||||
final SmsChannelProperties p = properties;
|
||||
properties = TencentSmsChannelProperties.build(p);
|
||||
// 实例化一个认证对象,入参需要传入腾讯云账户密钥对secretId,secretKey。
|
||||
Credential credential = new Credential(properties.getApiKey(), properties.getApiSecret());
|
||||
client = new SmsClient(credential, "ap-nanjing");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected SmsCommonResult<SmsSendRespDTO> doSendSms(Long sendLogId,
|
||||
String mobile,
|
||||
String apiTemplateId,
|
||||
List<KeyValue<String, Object>> templateParams) throws Throwable {
|
||||
|
||||
return invoke(() -> buildSendSmsRequest(sendLogId, mobile, apiTemplateId, templateParams),
|
||||
this::doSendSms0,
|
||||
response -> {
|
||||
SendStatus sendStatus = response.getSendStatusSet()[0];
|
||||
return SmsCommonResult.build(sendStatus.getCode(), sendStatus.getMessage(), response.getRequestId(),
|
||||
new SmsSendRespDTO().setSerialNo(sendStatus.getSerialNo()), codeMapping);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用腾讯云SDK发送短信
|
||||
*
|
||||
* @param request 发送短信请求
|
||||
* @return 发送短信响应
|
||||
* @throws TencentCloudSDKException SDK用来封装发送短信失败
|
||||
*/
|
||||
private SendSmsResponse doSendSms0(SendSmsRequest request) throws TencentCloudSDKException {
|
||||
return client.SendSms(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 封装腾讯云发送短信请求
|
||||
*
|
||||
* @param sendLogId 日志编号
|
||||
* @param mobile 手机号
|
||||
* @param apiTemplateId 短信 API 的模板编号
|
||||
* @param templateParams 短信模板参数。通过 List 数组,保证参数的顺序
|
||||
* @return 腾讯云发送短信请求
|
||||
*/
|
||||
private SendSmsRequest buildSendSmsRequest(Long sendLogId,
|
||||
String mobile,
|
||||
String apiTemplateId,
|
||||
List<KeyValue<String, Object>> templateParams) {
|
||||
SendSmsRequest request = new SendSmsRequest();
|
||||
request.setSmsSdkAppId(((TencentSmsChannelProperties) properties).getSdkAppId());
|
||||
request.setPhoneNumberSet(CollectionUtils.toArray(Collections.singletonList(mobile)));
|
||||
request.setSignName(properties.getSignature());
|
||||
request.setTemplateId(apiTemplateId);
|
||||
request.setTemplateParamSet(CollectionUtils.toArray(templateParams, e -> String.valueOf(e.getValue())));
|
||||
request.setSessionContext(JsonUtils.toJsonString(new SessionContext().setLogId(sendLogId)));
|
||||
return request;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<SmsReceiveRespDTO> doParseSmsReceiveStatus(String text) throws Throwable {
|
||||
List<SmsReceiveStatus> callback = JsonUtils.parseArray(text, SmsReceiveStatus.class);
|
||||
return CollectionUtils.convertList(callback, status -> {
|
||||
SmsReceiveRespDTO data = new SmsReceiveRespDTO();
|
||||
data.setErrorCode(status.getErrCode()).setErrorMsg(status.getDescription());
|
||||
data.setReceiveTime(status.getReceiveTime()).setSuccess("SUCCESS".equalsIgnoreCase(status.getStatus()));
|
||||
data.setMobile(status.getMobile()).setSerialNo(status.getSerialNo());
|
||||
Optional.ofNullable(status.getSessionContext()).map(SessionContext::getLogId)
|
||||
.ifPresentOrElse(data::setLogId, () -> {
|
||||
throw new IllegalStateException(StrUtil.format("未回传logId,需联系腾讯云解决。"));
|
||||
});
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected SmsCommonResult<SmsTemplateRespDTO> doGetSmsTemplate(String apiTemplateId) throws Throwable {
|
||||
return invoke(() -> this.buildSmsTemplateStatusRequest(apiTemplateId),
|
||||
this::doGetSmsTemplate0,
|
||||
response -> {
|
||||
SmsTemplateRespDTO data = convertTemplateStatusDTO(response.getDescribeTemplateStatusSet()[0]);
|
||||
return SmsCommonResult.build("Ok", null, response.getRequestId(), data, codeMapping);
|
||||
});
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
SmsTemplateRespDTO convertTemplateStatusDTO(DescribeTemplateListStatus templateStatus) {
|
||||
if (templateStatus == null) {
|
||||
return null;
|
||||
}
|
||||
SmsTemplateAuditStatusEnum auditStatus;
|
||||
Assert.notNull(templateStatus.getStatusCode(),
|
||||
StrUtil.format("短信模版审核状态为null,模版id{}", templateStatus.getTemplateId()));
|
||||
switch (templateStatus.getStatusCode().intValue()) {
|
||||
case -1:
|
||||
auditStatus = SmsTemplateAuditStatusEnum.FAIL;
|
||||
break;
|
||||
case 0:
|
||||
auditStatus = SmsTemplateAuditStatusEnum.SUCCESS;
|
||||
break;
|
||||
case 1:
|
||||
auditStatus = SmsTemplateAuditStatusEnum.CHECKING;
|
||||
break;
|
||||
default:
|
||||
throw new IllegalStateException(StrUtil.format("不能解析短信模版审核状态{},模版id{}",
|
||||
templateStatus.getStatusCode(), templateStatus.getTemplateId()));
|
||||
}
|
||||
SmsTemplateRespDTO data = new SmsTemplateRespDTO();
|
||||
data.setId(String.valueOf(templateStatus.getTemplateId())).setContent(templateStatus.getTemplateContent());
|
||||
data.setAuditStatus(auditStatus.getStatus()).setAuditReason(templateStatus.getReviewReply());
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 封装查询模版审核状态请求
|
||||
* @param apiTemplateId api的模版id
|
||||
* @return 查询模版审核状态请求
|
||||
*/
|
||||
private DescribeSmsTemplateListRequest buildSmsTemplateStatusRequest(String apiTemplateId) {
|
||||
DescribeSmsTemplateListRequest request = new DescribeSmsTemplateListRequest();
|
||||
request.setTemplateIdSet(CollectionUtils.toArray(Collections.singletonList(apiTemplateId), Long::parseLong));
|
||||
// 地区
|
||||
request.setInternational(0L);
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用腾讯云SDK查询短信模版状态
|
||||
*
|
||||
* @param request 查询短信模版状态请求
|
||||
* @return 查询短信模版状态响应
|
||||
* @throws TencentCloudSDKException SDK用来封装查询短信模版状态失败
|
||||
*/
|
||||
private DescribeSmsTemplateListResponse doGetSmsTemplate0(DescribeSmsTemplateListRequest request) throws TencentCloudSDKException {
|
||||
return client.DescribeSmsTemplateList(request);
|
||||
}
|
||||
|
||||
<Q, P, R> SmsCommonResult<R> invoke(Supplier<Q> requestSupplier,
|
||||
SdkFunction<Q, P> responseSupplier,
|
||||
Function<P, SmsCommonResult<R>> resultGen) {
|
||||
// 构建请求body
|
||||
Q request = requestSupplier.get();
|
||||
P response;
|
||||
// 调用腾讯云发送短信
|
||||
try {
|
||||
response = responseSupplier.apply(request);
|
||||
} catch (TencentCloudSDKException e) {
|
||||
// 调用异常,封装结果
|
||||
return SmsCommonResult.build(e.getErrorCode(), e.getMessage(), e.getRequestId(), null, codeMapping);
|
||||
}
|
||||
return resultGen.apply(response);
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class SmsReceiveStatus {
|
||||
|
||||
/**
|
||||
* 用户实际接收到短信的时间
|
||||
*/
|
||||
@JsonProperty("user_receive_time")
|
||||
@JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT)
|
||||
private Date receiveTime;
|
||||
|
||||
/**
|
||||
* 国家(或地区)码
|
||||
*/
|
||||
@JsonProperty("nationcode")
|
||||
private String nationCode;
|
||||
|
||||
/**
|
||||
* 手机号码
|
||||
*/
|
||||
private String mobile;
|
||||
|
||||
/**
|
||||
* 实际是否收到短信接收状态,SUCCESS(成功)、FAIL(失败)
|
||||
*/
|
||||
@JsonProperty("report_status")
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 用户接收短信状态码错误信息
|
||||
*/
|
||||
@JsonProperty("errmsg")
|
||||
private String errCode;
|
||||
|
||||
/**
|
||||
* 用户接收短信状态描述
|
||||
*/
|
||||
@JsonProperty("description")
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 本次发送标识 ID(与发送接口返回的SerialNo对应)
|
||||
*/
|
||||
@JsonProperty("sid")
|
||||
private String serialNo;
|
||||
|
||||
/**
|
||||
* 用户的 session 内容(与发送接口的请求参数SessionContext一致)
|
||||
*/
|
||||
@JsonProperty("ext")
|
||||
private SessionContext sessionContext;
|
||||
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
@Data
|
||||
static class SessionContext {
|
||||
|
||||
/**
|
||||
* 发送短信记录id
|
||||
*/
|
||||
private Long logId;
|
||||
}
|
||||
|
||||
private interface SdkFunction<T, R> {
|
||||
R apply(T t) throws TencentCloudSDKException;
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class TencentSmsChannelProperties extends SmsChannelProperties {
|
||||
|
||||
private String sdkAppId;
|
||||
|
||||
public static TencentSmsChannelProperties build(SmsChannelProperties properties) {
|
||||
if (properties instanceof TencentSmsChannelProperties) {
|
||||
return (TencentSmsChannelProperties) properties;
|
||||
}
|
||||
TencentSmsChannelProperties result = BeanUtil.toBean(properties, TencentSmsChannelProperties.class);
|
||||
String combKey = properties.getApiKey();
|
||||
Assert.notEmpty(combKey, "apiKey 不能为空");
|
||||
String[] keys = combKey.trim().split(" ");
|
||||
Assert.isTrue(keys.length == 2 && StrUtil.isNotBlank(keys[0]) && StrUtil.isNotBlank(keys[1]),
|
||||
"腾讯云短信api配置格式错误,请配置为[secretId sdkAppId]");
|
||||
result.setSdkAppId(keys[1]).setApiKey(keys[0]);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
@@ -0,0 +1,50 @@
|
||||
package cn.iocoder.yudao.framework.sms.core.client.impl.tencent;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.exception.ErrorCode;
|
||||
import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
|
||||
import cn.iocoder.yudao.framework.sms.core.client.SmsCodeMapping;
|
||||
import cn.iocoder.yudao.framework.sms.core.enums.SmsFrameworkErrorCodeConstants;
|
||||
|
||||
import static cn.iocoder.yudao.framework.sms.core.enums.SmsFrameworkErrorCodeConstants.*;
|
||||
|
||||
/**
|
||||
* 腾讯云的 SmsCodeMapping 实现类
|
||||
*
|
||||
* 参见 https://cloud.tencent.com/document/api/382/52075#.E5.85.AC.E5.85.B1.E9.94.99.E8.AF.AF.E7.A0.81
|
||||
*
|
||||
* @author : shiwp
|
||||
*/
|
||||
public class TencentSmsCodeMapping implements SmsCodeMapping {
|
||||
|
||||
@Override
|
||||
public ErrorCode apply(String apiCode) {
|
||||
switch (apiCode) {
|
||||
case "Ok": return GlobalErrorCodeConstants.SUCCESS;
|
||||
case "FailedOperation.ContainSensitiveWord": return SMS_SEND_CONTENT_INVALID;
|
||||
case "FailedOperation.JsonParseFail":
|
||||
case "MissingParameter.EmptyPhoneNumberSet":
|
||||
case "LimitExceeded.PhoneNumberCountLimit":
|
||||
case "FailedOperation.FailResolvePacket": return GlobalErrorCodeConstants.BAD_REQUEST;
|
||||
case "FailedOperation.InsufficientBalanceInSmsPackage": return SMS_ACCOUNT_MONEY_NOT_ENOUGH;
|
||||
case "FailedOperation.MarketingSendTimeConstraint": return SMS_SEND_MARKET_LIMIT_CONTROL;
|
||||
case "FailedOperation.PhoneNumberInBlacklist": return SMS_MOBILE_BLACK;
|
||||
case "FailedOperation.SignatureIncorrectOrUnapproved": return SMS_SIGN_INVALID;
|
||||
case "FailedOperation.MissingTemplateToModify":
|
||||
case "FailedOperation.TemplateIncorrectOrUnapproved": return SMS_TEMPLATE_INVALID;
|
||||
case "InvalidParameterValue.IncorrectPhoneNumber": return SMS_MOBILE_INVALID;
|
||||
case "InvalidParameterValue.SdkAppIdNotExist": return SMS_APP_ID_INVALID;
|
||||
case "InvalidParameterValue.TemplateParameterLengthLimit":
|
||||
case "InvalidParameterValue.TemplateParameterFormatError": return SMS_TEMPLATE_PARAM_ERROR;
|
||||
case "LimitExceeded.PhoneNumberDailyLimit": return SMS_SEND_DAY_LIMIT_CONTROL;
|
||||
case "LimitExceeded.PhoneNumberThirtySecondLimit":
|
||||
case "LimitExceeded.PhoneNumberOneHourLimit": return SMS_SEND_BUSINESS_LIMIT_CONTROL;
|
||||
case "UnauthorizedOperation.RequestPermissionDeny":
|
||||
case "FailedOperation.ForbidAddMarketingTemplates":
|
||||
case "FailedOperation.NotEnterpriseCertification":
|
||||
case "UnauthorizedOperation.IndividualUserMarketingSmsPermissionDeny": return SMS_PERMISSION_DENY;
|
||||
case "UnauthorizedOperation.RequestIpNotInWhitelist": return SMS_IP_DENY;
|
||||
case "AuthFailure.SecretIdNotFound": return SMS_ACCOUNT_INVALID;
|
||||
}
|
||||
return SmsFrameworkErrorCodeConstants.SMS_UNKNOWN;
|
||||
}
|
||||
}
|
@@ -17,7 +17,7 @@ public enum SmsChannelEnum {
|
||||
DEBUG_DING_TALK("DEBUG_DING_TALK", "调试(钉钉)"),
|
||||
YUN_PIAN("YUN_PIAN", "云片"),
|
||||
ALIYUN("ALIYUN", "阿里云"),
|
||||
// TENCENT("TENCENT", "腾讯云"),
|
||||
TENCENT("TENCENT", "腾讯云"),
|
||||
// HUA_WEI("HUA_WEI", "华为云"),
|
||||
;
|
||||
|
||||
|
@@ -26,6 +26,9 @@ public interface SmsFrameworkErrorCodeConstants {
|
||||
|
||||
ErrorCode SMS_SEND_CONTENT_INVALID = new ErrorCode(2001000104, "短信内容有敏感词");
|
||||
|
||||
// 腾讯云:为避免骚扰用户,营销短信只允许在8点到22点发送。
|
||||
ErrorCode SMS_SEND_MARKET_LIMIT_CONTROL = new ErrorCode(2001000105, "营销短信发送时间限制");
|
||||
|
||||
// ========== 模板相关 2001000200 ==========
|
||||
ErrorCode SMS_TEMPLATE_INVALID = new ErrorCode(2001000200, "短信模板不合法"); // 包括短信模板不存在
|
||||
ErrorCode SMS_TEMPLATE_PARAM_ERROR = new ErrorCode(2001000201, "模板参数不正确");
|
||||
@@ -41,6 +44,7 @@ public interface SmsFrameworkErrorCodeConstants {
|
||||
ErrorCode SMS_API_PARAM_ERROR = new ErrorCode(2001000900, "请求参数缺失");
|
||||
ErrorCode SMS_MOBILE_INVALID = new ErrorCode(2001000901, "手机格式不正确");
|
||||
ErrorCode SMS_MOBILE_BLACK = new ErrorCode(2001000902, "手机号在黑名单中");
|
||||
ErrorCode SMS_APP_ID_INVALID = new ErrorCode(2001000903, "SdkAppId不合法");
|
||||
|
||||
ErrorCode EXCEPTION = new ErrorCode(2001000999, "调用异常");
|
||||
|
||||
|
Reference in New Issue
Block a user