接入腾讯云短信

This commit is contained in:
FinallySays
2022-03-27 12:08:56 +08:00
parent e923bc661d
commit 6fd52dfbb6
15 changed files with 9120 additions and 8229 deletions

View File

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

View File

@@ -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);
// 实例化一个认证对象入参需要传入腾讯云账户密钥对secretIdsecretKey。
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;
}
}
}

View File

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

View File

@@ -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", "华为云"),
;

View File

@@ -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, "调用异常");