@@ -2,38 +2,29 @@ package cn.iocoder.yudao.module.system.framework.sms.core.client.impl;
import cn.hutool.core.lang.Assert ;
import cn.hutool.core.util.StrUtil ;
import cn.hutool.http.HttpRequest ;
import cn.hutool.http.HttpResponse ;
import cn.hutool.json.JSONArray ;
import cn.hutool.json.JSONObject ;
import cn.hutool.json.JSONUtil ;
import cn.iocoder.yudao.framework.common.core.KeyValue ;
import cn.iocoder.yudao.framework.common.util.collection.ArrayUtils ;
import cn.iocoder.yudao.framework.common.util.json.Json Utils ;
import cn.iocoder.yudao.framework.common.util.http.Http Utils ;
import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsReceiveRespDTO ;
import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsSendRespDTO ;
import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsTemplateRespDTO ;
import cn.iocoder.yudao.module.system.framework.sms.core.enums.SmsTemplateAuditStatusEnum ;
import cn.iocoder.yudao.module.system.framework.sms.core.property.SmsChannelProperties ;
import com.fasterxml.jackson.annotation.JsonFormat ;
import com.fasterxml.jackson.annotation.JsonProperty ;
import com.google.common.annotations.VisibleForTesting ;
import jakarta.xml.bind.DatatypeConverter ;
import lombok.Data ;
import javax.crypto.Mac ;
import javax.crypto.spec.SecretKeySpec ;
import java.nio.charset.StandardCharsets ;
import java.text.SimpleDateFormat ;
import java.time.LocalDateTime ;
import java.util.* ;
import static cn.hutool.crypto.digest.DigestUtil.sha256Hex ;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList ;
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 ;
// TODO @scholar 建议参考 AliyunSmsClient 优化下
/**
* 腾讯云短信功能实现
*
@@ -43,6 +34,9 @@ import static cn.iocoder.yudao.framework.common.util.date.DateUtils.TIME_ZONE_DE
*/
public class TencentSmsClient extends AbstractSmsClient {
private static final String VERSION = " 2021-01-11 " ;
private static final String REGION = " ap-guangzhou " ;
/**
* 调用成功 code
*/
@@ -56,7 +50,6 @@ public class TencentSmsClient extends AbstractSmsClient {
*/
private static final long INTERNATIONAL_CHINA = 0L ;
public TencentSmsClient ( SmsChannelProperties properties ) {
super ( properties ) ;
Assert . notEmpty ( properties . getApiSecret ( ) , " apiSecret 不能为空 " ) ;
@@ -65,7 +58,6 @@ public class TencentSmsClient extends AbstractSmsClient {
@Override
protected void doInit ( ) {
}
/**
@@ -95,31 +87,96 @@ public class TencentSmsClient extends AbstractSmsClient {
@Override
public SmsSendRespDTO sendSms ( Long sendLogId , String mobile ,
String apiTemplateId , List < KeyValue < String , Object > > templateParams ) throws Throwable {
// 构建 请求
// 1. 执行 请求
// 参考链接 https://cloud.tencent.com/document/product/382/55981
TreeMap < String , Object > body = new TreeMap < > ( ) ;
String [ ] pho nes = { mobile } ;
body . put ( " PhoneNumberSet " , phones ) ;
body . put ( " SmsSdkAppId " , getSdkAppId ( ) ) ;
body . put ( " SignName " , properties . getSignature ( ) ) ;
body . put ( " PhoneNumberSet " , new String [ ] { mobile } ) ;
body . put ( " SmsSdkAppId " , getSdkAppId ( ) ) ;
body . put ( " SignName " , properties . getSignature ( ) ) ;
body . put ( " TemplateId " , apiTemplateId ) ;
body . put ( " TemplateParamSet " , ArrayUtils . toArray ( templateParams , e - > String . valueOf ( e . getValue ( ) ) ) ) ;
JSONObject JsonResponse = sendSmsRequest ( body , " SendSms " , " 2021-01-11 " , " ap-guangzhou " ) ;
SmsResponse smsResponse = getSmsSendResponse ( JsonResponse ) ;
return new SmsSendRespDTO ( ) . setSuccess ( smsResponse . success ) . setApiMsg ( smsResponse . data . toString ( ) ) ;
body . put ( " TemplateParamSet " , ArrayUtils . toArray ( templateParams , param - > String . valueOf ( param . getValue ( ) ) ) ) ;
JSONObject response = request ( " SendSms " , body ) ;
// 2. 解析请求
JSONObject responseResult = response . getJSONObject ( " Response " ) ;
JSONObject error = responseResult . getJSONObject ( " Error " ) ;
if ( error ! = null ) {
return new SmsSendRespDTO ( ) . setSuccess ( false )
. setApiRequestId ( responseResult . getStr ( " RequestId " ) )
. setApiCode ( error . getStr ( " Code " ) )
. setApiMsg ( error . getStr ( " Message " ) ) ;
}
JSONObject responseData = responseResult . getJSONArray ( " SendStatusSet " ) . getJSONObject ( 0 ) ;
return new SmsSendRespDTO ( ) . setSuccess ( Objects . equals ( API_CODE_SUCCESS , responseData . getStr ( " Code " ) ) )
. setApiRequestId ( responseResult . getStr ( " RequestId " ) )
. setSerialNo ( responseData . getStr ( " SerialNo " ) )
. setApiMsg ( responseData . getStr ( " Message " ) ) ;
}
JSONObject sendSmsRequest ( TreeMap < String , Object > body , String action , String version , String region ) throws Exception {
@Override
public List < SmsReceiveRespDTO > parseSmsReceiveStatus ( String text ) {
JSONArray statuses = JSONUtil . parseArray ( text ) ;
// 字段参考
return convertList ( statuses , status - > {
JSONObject statusObj = ( JSONObject ) status ;
return new SmsReceiveRespDTO ( )
. setSuccess ( " SUCCESS " . equals ( statusObj . getStr ( " report_status " ) ) ) // 是否接收成功
. setErrorCode ( statusObj . getStr ( " errmsg " ) ) // 状态报告编码
. setMobile ( statusObj . getStr ( " mobile " ) ) // 手机号
. setReceiveTime ( statusObj . getLocalDateTime ( " user_receive_time " , null ) ) // 状态报告时间
. setSerialNo ( statusObj . getStr ( " sid " ) ) ; // 发送序列号
} ) ;
}
@Override
public SmsTemplateRespDTO getSmsTemplate ( String apiTemplateId ) throws Throwable {
// 1. 构建请求
// 参考链接 https://cloud.tencent.com/document/product/382/52067
TreeMap < String , Object > body = new TreeMap < > ( ) ;
body . put ( " International " , INTERNATIONAL_CHINA ) ;
body . put ( " TemplateIdSet " , new Integer [ ] { Integer . valueOf ( apiTemplateId ) } ) ;
JSONObject response = request ( " DescribeSmsTemplateList " , body ) ;
// TODO @scholar: 会有请求失败的情况么? 类似发送的( 那块逻辑我补充了)
JSONObject TemplateStatusSet = response . getJSONObject ( " Response " ) . getJSONArray ( " DescribeTemplateStatusSet " ) . getJSONObject ( 0 ) ;
String content = TemplateStatusSet . get ( " TemplateContent " ) . toString ( ) ;
int templateStatus = Integer . parseInt ( TemplateStatusSet . get ( " StatusCode " ) . toString ( ) ) ;
String auditReason = TemplateStatusSet . get ( " ReviewReply " ) . toString ( ) ;
return new SmsTemplateRespDTO ( ) . setId ( apiTemplateId ) . setContent ( content )
. setAuditStatus ( convertSmsTemplateAuditStatus ( templateStatus ) ) . setAuditReason ( auditReason ) ;
}
@VisibleForTesting
Integer convertSmsTemplateAuditStatus ( int templateStatus ) {
switch ( templateStatus ) {
case 1 : return SmsTemplateAuditStatusEnum . CHECKING . getStatus ( ) ;
case 0 : return SmsTemplateAuditStatusEnum . SUCCESS . getStatus ( ) ;
case - 1 : return SmsTemplateAuditStatusEnum . FAIL . getStatus ( ) ;
default : throw new IllegalArgumentException ( String . format ( " 未知审核状态(%d) " , templateStatus ) ) ;
}
}
/**
* 请求腾讯云短信
*
* @see <a href="https://cloud.tencent.com/document/product/382/52072">签名方法 v3</a>
*
* @param action 请求的 API 名称
* @param body 请求参数
* @return 请求结果
*/
private JSONObject request ( String action , TreeMap < String , Object > body ) throws Exception {
String timestamp = String . valueOf ( System . currentTimeMillis ( ) / 1000 ) ;
// TODO @scholar: 这个 format, 看看怎么写的可以简化点
SimpleDateFormat sdf = new SimpleDateFormat ( " yyyy-MM-dd " ) ;
// 注意时区,否则容易出错
sdf . setTimeZone ( TimeZone . getTimeZone ( " UTC " ) ) ;
String date = sdf . format ( new Date ( Long . valueOf ( timestamp + " 000 " ) ) ) ;
// TODO @scholar: 这个步骤, 看看怎么参考阿里云 client, 归类下; 1. 2.1 2.2 这种
// ************* 步骤 1: 拼接规范请求串 *************
// TODO @scholar: 这个 hsot 枚举下;
String host = " sms.tencentcloudapi.com " ; //APP接入地址+接口访问URI
String httpMethod = " POST " ; // 请求方式
String canonicalUri = " / " ;
@@ -129,6 +186,7 @@ public class TencentSmsClient extends AbstractSmsClient {
+ " host: " + host + " \ n " + " x-tc-action: " + action . toLowerCase ( ) + " \ n " ;
String signedHeaders = " content-type;host;x-tc-action " ;
String hashedRequestBody = sha256Hex ( JSONUtil . toJsonStr ( body ) ) ;
// TODO @scholar: 换行下, 不然单行太长了
String canonicalRequest = httpMethod + " \ n " + canonicalUri + " \ n " + canonicalQueryString + " \ n " + canonicalHeaders + " \ n " + signedHeaders + " \ n " + hashedRequestBody ;
// ************* 步骤 2: 拼接待签名字符串 *************
@@ -153,205 +211,19 @@ public class TencentSmsClient extends AbstractSmsClient {
headers . put ( " Host " , host ) ;
headers . put ( " X-TC-Action " , action ) ;
headers . put ( " X-TC-Timestamp " , timestamp ) ;
headers . put ( " X-TC-Version " , version ) ;
headers . put ( " X-TC-Region " , region ) ;
headers . put ( " X-TC-Version " , VERSION ) ;
headers . put ( " X-TC-Region " , REGION ) ;
HttpResponse response = HttpRequest . post ( " https:// " + host )
. addHeaders ( headers )
. body ( JSONUtil . toJsonStr ( body ) )
. execute ( ) ;
String responseBody = HttpUtils . post ( " https:// " + host , headers , JSONUtil . toJsonStr ( body ) ) ;
return JSONUtil . parseObj ( response. body ( ) ) ;
return JSONUtil . parseObj ( responseBody ) ;
}
public static byte [ ] hmac256 ( byte [ ] key , String msg ) throws Exception {
// TODO @scholar: 使用 hutool 简化下
private static byte [ ] hmac256 ( byte [ ] key , String msg ) throws Exception {
Mac mac = Mac . getInstance ( " HmacSHA256 " ) ;
SecretKeySpec secretKeySpec = new SecretKeySpec ( key , mac . getAlgorithm ( ) ) ;
mac . init ( secretKeySpec ) ;
return mac . doFinal ( msg . getBytes ( StandardCharsets . UTF_8 ) ) ;
}
private SmsResponse getSmsSendResponse ( JSONObject resJson ) {
SmsResponse smsResponse = new SmsResponse ( ) ;
JSONArray statusJson = resJson . getJSONObject ( " Response " ) . getJSONArray ( " SendStatusSet " ) ;
smsResponse . setSuccess ( " Ok " . equals ( statusJson . getJSONObject ( 0 ) . getStr ( " Code " ) ) ) ;
smsResponse . setData ( resJson ) ;
return smsResponse ;
}
@Override
public List < SmsReceiveRespDTO > parseSmsReceiveStatus ( String text ) {
List < SmsReceiveStatus > callback = JsonUtils . parseArray ( text , SmsReceiveStatus . class ) ;
return convertList ( callback , status - > new SmsReceiveRespDTO ( )
. setSuccess ( SmsReceiveStatus . SUCCESS_CODE . equalsIgnoreCase ( status . getStatus ( ) ) )
. setErrorCode ( status . getErrCode ( ) ) . setErrorMsg ( status . getDescription ( ) )
. setMobile ( status . getMobile ( ) ) . setReceiveTime ( status . getReceiveTime ( ) )
. setSerialNo ( status . getSerialNo ( ) ) . setLogId ( status . getSessionContext ( ) . getLogId ( ) ) ) ;
}
@Override
public SmsTemplateRespDTO getSmsTemplate ( String apiTemplateId ) throws Throwable {
// 构建请求
TreeMap < String , Object > body = new TreeMap < > ( ) ;
body . put ( " International " , 0 ) ;
Integer [ ] templateIds = { Integer . valueOf ( apiTemplateId ) } ;
body . put ( " TemplateIdSet " , templateIds ) ;
JSONObject JsonResponse = sendSmsRequest ( body , " DescribeSmsTemplateList " , " 2021-01-11 " , " ap-guangzhou " ) ;
QuerySmsTemplateResponse smsTemplateResponse = getSmsTemplateResponse ( JsonResponse ) ;
String templateId = Integer . toString ( smsTemplateResponse . getDescribeTemplateStatusSet ( ) . get ( 0 ) . getTemplateId ( ) ) ;
String content = smsTemplateResponse . getDescribeTemplateStatusSet ( ) . get ( 0 ) . getTemplateContent ( ) ;
Integer templateStatus = smsTemplateResponse . getDescribeTemplateStatusSet ( ) . get ( 0 ) . getStatusCode ( ) ;
String auditReason = smsTemplateResponse . getDescribeTemplateStatusSet ( ) . get ( 0 ) . getReviewReply ( ) ;
return new SmsTemplateRespDTO ( ) . setId ( templateId ) . setContent ( content )
. setAuditStatus ( convertSmsTemplateAuditStatus ( templateStatus ) ) . setAuditReason ( auditReason ) ;
}
private QuerySmsTemplateResponse getSmsTemplateResponse ( JSONObject resJson ) {
QuerySmsTemplateResponse smsTemplateResponse = new QuerySmsTemplateResponse ( ) ;
smsTemplateResponse . setRequestId ( resJson . getJSONObject ( " Response " ) . getStr ( " RequestId " ) ) ;
smsTemplateResponse . setDescribeTemplateStatusSet ( new ArrayList < > ( ) ) ;
QuerySmsTemplateResponse . TemplateInfo templateInfo = new QuerySmsTemplateResponse . TemplateInfo ( ) ;
Object statusObject = resJson . getJSONObject ( " Response " ) . getJSONArray ( " DescribeTemplateStatusSet " ) . get ( 0 ) ;
JSONObject statusJSON = new JSONObject ( statusObject ) ;
templateInfo . setTemplateContent ( statusJSON . get ( " TemplateContent " ) . toString ( ) ) ;
templateInfo . setStatusCode ( Integer . parseInt ( statusJSON . get ( " StatusCode " ) . toString ( ) ) ) ;
templateInfo . setReviewReply ( statusJSON . get ( " ReviewReply " ) . toString ( ) ) ;
templateInfo . setTemplateId ( Integer . parseInt ( statusJSON . get ( " TemplateId " ) . toString ( ) ) ) ;
smsTemplateResponse . getDescribeTemplateStatusSet ( ) . add ( templateInfo ) ;
return smsTemplateResponse ;
}
@VisibleForTesting
Integer convertSmsTemplateAuditStatus ( int templateStatus ) {
switch ( templateStatus ) {
case 1 : return SmsTemplateAuditStatusEnum . CHECKING . getStatus ( ) ;
case 0 : return SmsTemplateAuditStatusEnum . SUCCESS . getStatus ( ) ;
case - 1 : return SmsTemplateAuditStatusEnum . FAIL . getStatus ( ) ;
default : throw new IllegalArgumentException ( String . format ( " 未知审核状态(%d) " , templateStatus ) ) ;
}
}
@Data
public static class SmsResponse {
/**
* 是否成功
*/
private boolean success ;
/**
* 厂商原返回体
*/
private Object data ;
}
/**
* <p>类名: QuerySmsTemplateResponse
* <p>说明: sms模板查询返回信息
*
* @author :scholar
* 2024/07/17 0:25
**/
@Data
public static class QuerySmsTemplateResponse {
private List < TemplateInfo > DescribeTemplateStatusSet ;
private String RequestId ;
@Data
static class TemplateInfo {
private String TemplateName ;
private Integer TemplateId ;
private Integer International ;
private String ReviewReply ;
private long CreateTime ;
private String TemplateContent ;
private Integer StatusCode ;
}
}
@Data
private static class SmsReceiveStatus {
/**
* 短信接受成功 code
*/
public static final String SUCCESS_CODE = " SUCCESS " ;
/**
* 用户实际接收到短信的时间
*/
@JsonProperty ( " user_receive_time " )
@JsonFormat ( pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND , timezone = TIME_ZONE_DEFAULT )
private LocalDateTime 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 ;
}
}
}