cf-framework:sms移到cf-module-system-biz

This commit is contained in:
gaoqr
2025-07-17 17:48:09 +08:00
parent 25e573d2c9
commit de742e6339
38 changed files with 110 additions and 602 deletions
+17 -8
View File
@@ -46,10 +46,6 @@
<groupId>com.cf.imes</groupId>
<artifactId>cf-spring-boot-starter-biz-operatelog</artifactId>
</dependency>
<dependency>
<groupId>com.cf.imes</groupId>
<artifactId>cf-spring-boot-starter-biz-sms</artifactId>
</dependency>
<dependency>
<groupId>com.cf.imes</groupId>
@@ -213,6 +209,23 @@
<artifactId>cf-spring-boot-starter-biz-pay</artifactId>
</dependency>
<!-- 三方云服务相关 -->
<!-- SMS SDK begin -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-dysmsapi</artifactId>
</dependency>
<dependency>
<groupId>com.tencentcloudapi</groupId>
<artifactId>tencentcloud-sdk-java-sms</artifactId>
</dependency>
<!-- SMS SDK end -->
</dependencies>
@@ -257,10 +270,6 @@
<groupId>com.cf.imes</groupId>
<artifactId>cf-spring-boot-starter-biz-organ</artifactId>
</include>
<include>
<groupId>com.cf.imes</groupId>
<artifactId>cf-spring-boot-starter-biz-sms</artifactId>
</include>
<include>
<groupId>com.cf.imes</groupId>
<artifactId>cf-spring-boot-starter-elasticsearch</artifactId>
@@ -3,7 +3,7 @@ package com.cf.imes.module.system.controller.admin.sms;
import com.cf.imes.framework.common.pojo.CommonResult;
import com.cf.imes.framework.common.util.servlet.ServletUtils;
import com.cf.imes.framework.operatelog.core.annotations.OperateLog;
import com.cf.imes.framework.sms.core.enums.SmsChannelEnum;
import com.cf.imes.module.system.framework.sms.core.enums.SmsChannelEnum;
import com.cf.imes.module.system.service.sms.SmsSendService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -2,7 +2,7 @@ package com.cf.imes.module.system.dal.dataobject.sms;
import com.cf.imes.framework.common.enums.CommonStatusEnum;
import com.cf.imes.framework.mybatis.core.dataobject.BaseDO;
import com.cf.imes.framework.sms.core.enums.SmsChannelEnum;
import com.cf.imes.module.system.framework.sms.core.enums.SmsChannelEnum;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@@ -0,0 +1,25 @@
package com.cf.imes.module.system.framework.sms.config;
import com.cf.imes.module.system.framework.sms.core.client.SmsClientFactory;
import com.cf.imes.module.system.framework.sms.core.client.impl.SmsClientFactoryImpl;
import com.cf.imes.module.system.framework.sms.core.property.SmsProperties;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 短信配置类
*
* @author 晨丰科技
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(SmsProperties.class)
public class ChenfengSmsAutoConfiguration {
@Bean
public SmsClientFactory smsClientFactory(SmsProperties smsProperties) {
return new SmsClientFactoryImpl(smsProperties);
}
}
@@ -1,4 +1,4 @@
package com.cf.imes.module.system.framework.sms;
package com.cf.imes.module.system.framework.sms.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@@ -1,4 +1,4 @@
package com.cf.imes.module.system.framework.sms;
package com.cf.imes.module.system.framework.sms.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
@@ -0,0 +1,46 @@
package com.cf.imes.module.system.framework.sms.core.client;
import cn.hutool.core.lang.Pair;
import com.cf.imes.module.system.framework.sms.core.client.dto.SmsReceiveRespDTO;
import com.cf.imes.module.system.framework.sms.core.client.dto.SmsSendRespDTO;
import com.cf.imes.module.system.framework.sms.core.client.dto.SmsTemplateRespDTO;
import java.util.List;
/**
* 短信客户端,用于对接各短信平台的 SDK,实现短信发送等功能
*
* @author zzf
* @since 2021/1/25 14:14
*/
public interface SmsClient {
/**
* 发送消息
*
* @param logId 日志编号
* @param mobile 手机号
* @param apiTemplateId 短信 API 的模板编号
* @param templateParams 短信模板参数。通过 List 数组,保证参数的顺序
* @return 短信发送结果
*/
SmsSendRespDTO sendSms(Long logId, String mobile, String apiTemplateId,
List<Pair<String, Object>> templateParams) throws Throwable;
/**
* 解析接收短信的接收结果
*
* @param text 结果
* @return 结果内容
* @throws Throwable 当解析 text 发生异常时,则会抛出异常
*/
List<SmsReceiveRespDTO> parseSmsReceiveStatus(String text) throws Throwable;
/**
* 查询指定的短信模板
*
* @param apiTemplateId 短信 API 的模板编号
* @return 短信模板
*/
SmsTemplateRespDTO getSmsTemplate(String apiTemplateId) throws Throwable;
}
@@ -0,0 +1,28 @@
package com.cf.imes.module.system.framework.sms.core.client;
import com.cf.imes.module.system.framework.sms.core.property.SmsProperties;
/**
* 短信客户端的工厂接口
*
* @author zzf
* @since 2021/1/28 14:01
*/
public interface SmsClientFactory {
/**
* 获得短信 Client
*
* @param channelCode 渠道编码
* @return 短信 Client
*/
SmsClient getSmsClient(String channelCode);
/**
* 获得短信 Client
*
* @param smsProperties 短信配置
* @return 短信 Client
*/
SmsClient getSmsClient(SmsProperties smsProperties);
}
@@ -0,0 +1,48 @@
package com.cf.imes.module.system.framework.sms.core.client.dto;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 消息接收 Response DTO
*
* @author 晨丰科技
*/
@Data
public class SmsReceiveRespDTO {
/**
* 是否接收成功
*/
private Boolean success;
/**
* API 接收结果的编码
*/
private String errorCode;
/**
* API 接收结果的说明
*/
private String errorMsg;
/**
* 手机号
*/
private String mobile;
/**
* 用户接收时间
*/
private LocalDateTime receiveTime;
/**
* 短信 API 发送返回的序号
*/
private String serialNo;
/**
* 短信日志编号
*
* 对应 SysSmsLogDO 的编号
*/
private Long logId;
}
@@ -0,0 +1,52 @@
package com.cf.imes.module.system.framework.sms.core.client.dto;
import lombok.Data;
/**
* 短信发送 Response DTO
*
* @author 晨丰科技
*/
@Data
public class SmsSendRespDTO {
/**
* 是否成功
*/
private Boolean success;
/**
* API 请求编号
*/
private String apiRequestId;
// ==================== 成功时字段 ====================
/**
* 短信 API 发送返回的序号
*/
private String serialNo;
// ==================== 失败时字段 ====================
/**
* API 返回错误码
*
* 由于第三方的错误码可能是字符串,所以使用 String 类型
*/
private String apiCode;
/**
* API 返回提示
*/
private String apiMsg;
/**
* 短信发送的目标手机号
*/
private String mobile;
/**
* 渠道编码
*/
private String channelCode;
}
@@ -0,0 +1,33 @@
package com.cf.imes.module.system.framework.sms.core.client.dto;
import com.cf.imes.module.system.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
import lombok.Data;
/**
* 短信模板 Response DTO
*
* @author 晨丰科技
*/
@Data
public class SmsTemplateRespDTO {
/**
* 模板编号
*/
private String id;
/**
* 短信内容
*/
private String content;
/**
* 审核状态
*
* 枚举 {@link SmsTemplateAuditStatusEnum}
*/
private Integer auditStatus;
/**
* 审核未通过的理由
*/
private String auditReason;
}
@@ -0,0 +1,57 @@
package com.cf.imes.module.system.framework.sms.core.client.impl;
import com.cf.imes.module.system.framework.sms.core.client.SmsClient;
import com.cf.imes.module.system.framework.sms.core.property.SmsProperties;
import lombok.extern.slf4j.Slf4j;
/**
* 短信客户端的抽象类,提供模板方法,减少子类的冗余代码
*
* @author zzf
* @since 2021/2/1 9:28
*/
@Slf4j
public abstract class AbstractSmsClient implements SmsClient {
/**
* 短信渠道配置
*/
protected volatile SmsProperties properties;
public AbstractSmsClient(SmsProperties properties) {
this.properties = properties;
}
/**
* 初始化
*/
public final void init() {
doInit();
log.debug("[init][配置({}) 初始化完成]", properties);
}
/**
* 自定义初始化
*/
protected abstract void doInit();
/**
* 配置是否发生改变
*
* @param properties
* @return
*/
protected abstract boolean propChanged(SmsProperties properties);
public final void refresh(SmsProperties properties) {
// 判断是否更新
if (properties.equals(this.properties)) {
return;
}
log.info("[refresh][配置({})发生变化,重新初始化]", properties);
this.properties = properties;
// 初始化
this.init();
}
}
@@ -0,0 +1,79 @@
package com.cf.imes.module.system.framework.sms.core.client.impl;
import cn.hutool.core.util.ObjectUtil;
import com.cf.imes.module.system.framework.sms.core.client.SmsClient;
import com.cf.imes.module.system.framework.sms.core.client.SmsClientFactory;
import com.cf.imes.module.system.framework.sms.core.client.impl.aliyun.AliyunSmsClient;
import com.cf.imes.module.system.framework.sms.core.client.impl.debug.DebugDingTalkSmsClient;
import com.cf.imes.module.system.framework.sms.core.client.impl.tencent.TencentSmsClient;
import com.cf.imes.module.system.framework.sms.core.enums.SmsChannelEnum;
import com.cf.imes.module.system.framework.sms.core.property.SmsChannelProperties;
import com.cf.imes.module.system.framework.sms.core.property.SmsProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import org.springframework.validation.annotation.Validated;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* 短信客户端工厂接口
*
* @author zzf
*/
@Validated
@Slf4j
@Service
public class SmsClientFactoryImpl implements SmsClientFactory {
/**
* 短信客户端 Map
* key:渠道编码,使用 {@link SmsChannelProperties#getCode()} ()}
*
* 注意,一些场景下,需要获得某个渠道类型的客户端,所以需要使用它。
* 例如说,解析短信接收结果,是相对通用的,不需要使用某个渠道编号的 {@link #channelIdClients}
*/
private final ConcurrentMap<String, AbstractSmsClient> channelCodeClients = new ConcurrentHashMap<>();
public SmsClientFactoryImpl(SmsProperties smsProperties) {
// 从配置初始化对应的client客户端
AbstractSmsClient client = createSmsClient(smsProperties);
client.init();
channelCodeClients.put(smsProperties.getChannel(), client);
}
@Override
public SmsClient getSmsClient(SmsProperties smsProperties) {
String channel = smsProperties.getChannel();
AbstractSmsClient client = channelCodeClients.get(channel);
// 1、新的短信类型;2、配置改变
if (ObjectUtil.isNull(client) || client.propChanged(smsProperties)) {
client = this.createSmsClient(smsProperties);
client.init();
channelCodeClients.put(channel, client);
}
return client;
}
@Override
public SmsClient getSmsClient(String channelCode) {
return channelCodeClients.get(channelCode);
}
private AbstractSmsClient createSmsClient(SmsProperties smsProperties) {
String channel = smsProperties.getChannel();
SmsChannelEnum channelEnum = SmsChannelEnum.getByCode(channel);
Assert.notNull(channelEnum, String.format("渠道类型(%s) 为空", channel));
// 创建客户端
switch (channelEnum) {
case ALIYUN: return new AliyunSmsClient(smsProperties);
case DEBUG_DING_TALK: return new DebugDingTalkSmsClient(smsProperties);
case TENCENT: return new TencentSmsClient(smsProperties);
}
// 创建失败,错误日志 + 抛出异常
log.error("[createSmsClient][配置({}) 找不到合适的客户端实现]", smsProperties);
throw new IllegalArgumentException(String.format("配置(%s) 找不到合适的客户端实现", smsProperties));
}
}
@@ -0,0 +1,202 @@
package com.cf.imes.module.system.framework.sms.core.client.impl.aliyun;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.lang.Pair;
import cn.hutool.core.util.ObjectUtil;
import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.auth.Credential;
import com.aliyuncs.dysmsapi.model.v20170525.QuerySmsTemplateRequest;
import com.aliyuncs.dysmsapi.model.v20170525.QuerySmsTemplateResponse;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse;
import com.aliyuncs.profile.DefaultProfile;
import com.aliyuncs.profile.IClientProfile;
import com.cf.imes.framework.common.util.collection.MapUtils;
import com.cf.imes.framework.common.util.json.JsonUtils;
import com.cf.imes.module.system.framework.sms.core.client.dto.SmsReceiveRespDTO;
import com.cf.imes.module.system.framework.sms.core.client.dto.SmsSendRespDTO;
import com.cf.imes.module.system.framework.sms.core.client.dto.SmsTemplateRespDTO;
import com.cf.imes.module.system.framework.sms.core.client.impl.AbstractSmsClient;
import com.cf.imes.module.system.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
import com.cf.imes.module.system.framework.sms.core.property.SmsProperties;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.annotations.VisibleForTesting;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import static com.cf.imes.framework.common.util.collection.CollectionUtils.convertList;
import static com.cf.imes.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
import static com.cf.imes.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT;
/**
* 阿里短信客户端的实现类
*
* @author zzf
* @since 2021/1/25 14:17
*/
@Slf4j
public class AliyunSmsClient extends AbstractSmsClient {
/**
* 调用成功 code
*/
public static final String API_CODE_SUCCESS = "OK";
/**
* REGION, 使用杭州
*/
private static final String ENDPOINT = "cn-hangzhou";
/**
* 阿里云客户端
*/
private volatile IAcsClient client;
public AliyunSmsClient(SmsProperties properties) {
super(properties);
Assert.notEmpty(properties.getApiKey(), "apiKey 不能为空");
Assert.notEmpty(properties.getApiSecret(), "apiSecret 不能为空");
}
@Override
protected void doInit() {
IClientProfile profile = DefaultProfile.getProfile(ENDPOINT, properties.getApiKey(), properties.getApiSecret());
client = new DefaultAcsClient(profile);
}
@Override
protected boolean propChanged(SmsProperties properties) {
DefaultAcsClient defaultAcsClient = (DefaultAcsClient) client;
if (ObjectUtil.isNotNull(defaultAcsClient)) {
Credential credential = defaultAcsClient.getProfile().getCredential();
String accessKeyId = credential.getAccessKeyId();
if (ObjectUtil.notEqual(properties.getApiKey(), accessKeyId)) {
return true;
}
String accessSecret = credential.getAccessSecret();
if (ObjectUtil.notEqual(properties.getApiSecret(), accessSecret)) {
return true;
}
}
return false;
}
@Override
public SmsSendRespDTO sendSms(Long sendLogId, String mobile, String apiTemplateId,
List<Pair<String, Object>> templateParams) throws Throwable {
// 构建请求
SendSmsRequest request = new SendSmsRequest();
request.setPhoneNumbers(mobile);
request.setSignName(properties.getSignature());
request.setTemplateCode(apiTemplateId);
request.setTemplateParam(JsonUtils.toJsonString(MapUtils.convertMap(templateParams)));
request.setOutId(String.valueOf(sendLogId));
// 执行请求
SendSmsResponse response = client.getAcsResponse(request);
return new SmsSendRespDTO().setSuccess(Objects.equals(response.getCode(), API_CODE_SUCCESS)).setSerialNo(response.getBizId())
.setApiRequestId(response.getRequestId()).setApiCode(response.getCode()).setApiMsg(response.getMessage()).setMobile(mobile).setChannelCode(properties.getChannel());
}
@Override
public List<SmsReceiveRespDTO> parseSmsReceiveStatus(String text) {
List<SmsReceiveStatus> statuses = JsonUtils.parseArray(text, SmsReceiveStatus.class);
return convertList(statuses, status -> new SmsReceiveRespDTO().setSuccess(status.getSuccess())
.setErrorCode(status.getErrCode()).setErrorMsg(status.getErrMsg())
.setMobile(status.getPhoneNumber()).setReceiveTime(status.getReportTime())
.setSerialNo(status.getBizId()).setLogId(Long.valueOf(status.getOutId())));
}
@Override
public SmsTemplateRespDTO getSmsTemplate(String apiTemplateId) throws Throwable {
// 构建请求
QuerySmsTemplateRequest request = new QuerySmsTemplateRequest();
request.setTemplateCode(apiTemplateId);
// 执行请求
QuerySmsTemplateResponse response = client.getAcsResponse(request);
if (response.getTemplateStatus() == null) {
return null;
}
return new SmsTemplateRespDTO().setId(response.getTemplateCode()).setContent(response.getTemplateContent())
.setAuditStatus(convertSmsTemplateAuditStatus(response.getTemplateStatus())).setAuditReason(response.getReason());
}
@VisibleForTesting
Integer convertSmsTemplateAuditStatus(Integer templateStatus) {
switch (templateStatus) {
case 0: return SmsTemplateAuditStatusEnum.CHECKING.getStatus();
case 1: return SmsTemplateAuditStatusEnum.SUCCESS.getStatus();
case 2: return SmsTemplateAuditStatusEnum.FAIL.getStatus();
default: throw new IllegalArgumentException(String.format("未知审核状态(%d)", templateStatus));
}
}
/**
* 短信接收状态
*
* 参见 <a href="https://help.aliyun.com/document_detail/101867.html">文档</a>
*
* @author 晨丰科技
*/
@Data
public static class SmsReceiveStatus {
/**
* 手机号
*/
@JsonProperty("phone_number")
private String phoneNumber;
/**
* 发送时间
*/
@JsonProperty("send_time")
@JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT)
private LocalDateTime sendTime;
/**
* 状态报告时间
*/
@JsonProperty("report_time")
@JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT)
private LocalDateTime reportTime;
/**
* 是否接收成功
*/
private Boolean success;
/**
* 状态报告说明
*/
@JsonProperty("err_msg")
private String errMsg;
/**
* 状态报告编码
*/
@JsonProperty("err_code")
private String errCode;
/**
* 发送序列号
*/
@JsonProperty("biz_id")
private String bizId;
/**
* 用户序列号
*
* 这里我们传递的是 SysSmsLogDO 的日志编号
*/
@JsonProperty("out_id")
private String outId;
/**
* 短信长度,例如说 1、2、3
*
* 140 字节算一条短信,短信长度超过 140 字节时会拆分成多条短信发送
*/
@JsonProperty("sms_size")
private Integer smsSize;
}
}
@@ -0,0 +1,103 @@
package com.cf.imes.module.system.framework.sms.core.client.impl.debug;
import cn.hutool.core.codec.Base64;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.lang.Pair;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.text.CharSequenceUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import cn.hutool.crypto.digest.HmacAlgorithm;
import cn.hutool.http.HttpUtil;
import com.cf.imes.framework.common.util.collection.MapUtils;
import com.cf.imes.framework.common.util.json.JsonUtils;
import com.cf.imes.module.system.framework.sms.core.client.dto.SmsReceiveRespDTO;
import com.cf.imes.module.system.framework.sms.core.client.dto.SmsSendRespDTO;
import com.cf.imes.module.system.framework.sms.core.client.dto.SmsTemplateRespDTO;
import com.cf.imes.module.system.framework.sms.core.client.impl.AbstractSmsClient;
import com.cf.imes.module.system.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
import com.cf.imes.module.system.framework.sms.core.property.SmsProperties;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 基于钉钉 WebHook 实现的调试的短信客户端实现类
*
* 考虑到省钱,我们使用钉钉 WebHook 模拟发送短信,方便调试。
*
* @author 晨丰科技
*/
public class DebugDingTalkSmsClient extends AbstractSmsClient {
public DebugDingTalkSmsClient(SmsProperties properties) {
super(properties);
Assert.notEmpty(properties.getApiKey(), "apiKey 不能为空");
Assert.notEmpty(properties.getApiSecret(), "apiSecret 不能为空");
}
@Override
protected void doInit() {
// nothing
}
@Override
protected boolean propChanged(SmsProperties properties) {
return false;
}
@Override
public SmsSendRespDTO sendSms(Long sendLogId, String mobile,
String apiTemplateId, List<Pair<String, Object>> templateParams) throws Throwable {
// 构建请求
String url = buildUrl("robot/send");
Map<String, Object> params = new HashMap<>();
params.put("msgtype", "text");
String content = String.format("【模拟短信】\n手机号:%s\n短信日志编号:%d\n模板参数:%s",
mobile, sendLogId, MapUtils.convertMap(templateParams));
params.put("text", MapUtil.builder().put("content", content).build());
// 执行请求
String responseText = HttpUtil.post(url, JsonUtils.toJsonString(params));
// 解析结果
Map<?, ?> responseObj = JsonUtils.parseObject(responseText, Map.class);
String errorCode = MapUtil.getStr(responseObj, "errcode");
return new SmsSendRespDTO().setSuccess(Objects.equals(errorCode, "0")).setSerialNo(StrUtil.uuid())
.setApiCode(errorCode).setApiMsg(MapUtil.getStr(responseObj, "errmsg")).setMobile(mobile).setChannelCode(properties.getChannel());
}
/**
* 构建请求地址
*
* 参见 <a href="https://developers.dingtalk.com/document/app/custom-robot-access/title-nfv-794-g71">文档</a>
*
* @param path 请求路径
* @return 请求地址
*/
@SuppressWarnings("SameParameterValue")
private String buildUrl(String path) {
// 生成 timestamp
long timestamp = System.currentTimeMillis();
// 生成 sign
String secret = properties.getApiSecret();
String stringToSign = timestamp + "\n" + secret;
byte[] signData = DigestUtil.hmac(HmacAlgorithm.HmacSHA256, CharSequenceUtil.bytes(secret)).digest(stringToSign);
String sign = Base64.encode(signData);
// 构建最终 URL
return String.format("https://oapi.dingtalk.com/%s?access_token=%s&timestamp=%d&sign=%s",
path, properties.getApiKey(), timestamp, sign);
}
@Override
public List<SmsReceiveRespDTO> parseSmsReceiveStatus(String text) {
throw new UnsupportedOperationException("模拟短信客户端,暂时无需解析回调");
}
@Override
public SmsTemplateRespDTO getSmsTemplate(String apiTemplateId) {
return new SmsTemplateRespDTO().setId(apiTemplateId).setContent("")
.setAuditStatus(SmsTemplateAuditStatusEnum.SUCCESS.getStatus()).setAuditReason("");
}
}
@@ -0,0 +1,229 @@
package com.cf.imes.module.system.framework.sms.core.client.impl.tencent;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.lang.Pair;
import cn.hutool.core.text.CharSequenceUtil;
import com.cf.imes.framework.common.util.collection.ArrayUtils;
import com.cf.imes.framework.common.util.json.JsonUtils;
import com.cf.imes.module.system.framework.sms.core.client.dto.SmsReceiveRespDTO;
import com.cf.imes.module.system.framework.sms.core.client.dto.SmsSendRespDTO;
import com.cf.imes.module.system.framework.sms.core.client.dto.SmsTemplateRespDTO;
import com.cf.imes.module.system.framework.sms.core.client.impl.AbstractSmsClient;
import com.cf.imes.module.system.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
import com.cf.imes.module.system.framework.sms.core.property.SmsProperties;
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.sms.v20210111.SmsClient;
import com.tencentcloudapi.sms.v20210111.models.DescribeSmsTemplateListRequest;
import com.tencentcloudapi.sms.v20210111.models.DescribeSmsTemplateListResponse;
import com.tencentcloudapi.sms.v20210111.models.DescribeTemplateListStatus;
import com.tencentcloudapi.sms.v20210111.models.SendSmsRequest;
import com.tencentcloudapi.sms.v20210111.models.SendSmsResponse;
import com.tencentcloudapi.sms.v20210111.models.SendStatus;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import static com.cf.imes.framework.common.util.collection.CollectionUtils.convertList;
import static com.cf.imes.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
import static com.cf.imes.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT;
/**
* 腾讯云短信功能实现
*
* 参见 <a href="https://cloud.tencent.com/document/product/382/52077">文档</a>
*
* @author shiwp
*/
public class TencentSmsClient extends AbstractSmsClient {
/**
* 调用成功 code
*/
public static final String API_CODE_SUCCESS = "Ok";
/**
* REGION,使用南京
*/
private static final String ENDPOINT = "ap-nanjing";
/**
* 是否国际/港澳台短信:
*
* 0:表示国内短信。
* 1:表示国际/港澳台短信。
*/
private static final long INTERNATIONAL_CHINA = 0L;
private SmsClient client;
public TencentSmsClient(SmsProperties properties) {
super(properties);
Assert.notEmpty(properties.getApiSecret(), "apiSecret 不能为空");
validateSdkAppId(properties);
}
@Override
protected void doInit() {
// 实例化一个认证对象,入参需要传入腾讯云账户密钥对 secretIdsecretKey
Credential credential = new Credential(getApiKey(), properties.getApiSecret());
client = new SmsClient(credential, ENDPOINT);
}
@Override
protected boolean propChanged(SmsProperties properties) {
return false;
}
/**
* 参数校验腾讯云的 SDK AppId
*
* 原因是:腾讯云发放短信的时候,需要额外的参数 sdkAppId
*
* 解决方案:考虑到不破坏原有的 apiKey + apiSecret 的结构,所以将 secretId 拼接到 apiKey 字段中,格式为 "secretId sdkAppId"。
*
* @param properties 配置
*/
private static void validateSdkAppId(SmsProperties properties) {
String combineKey = properties.getApiKey();
Assert.notEmpty(combineKey, "apiKey 不能为空");
String[] keys = combineKey.trim().split(" ");
Assert.isTrue(keys.length == 2, "腾讯云短信 apiKey 配置格式错误,请配置 为[secretId sdkAppId]");
}
private String getSdkAppId() {
return CharSequenceUtil.subAfter(properties.getApiKey(), " ", true);
}
private String getApiKey() {
return CharSequenceUtil.subBefore(properties.getApiKey(), " ", true);
}
@Override
public SmsSendRespDTO sendSms(Long sendLogId, String mobile,
String apiTemplateId, List<Pair<String, Object>> templateParams) throws Throwable {
// 构建请求
SendSmsRequest request = new SendSmsRequest();
request.setSmsSdkAppId(getSdkAppId());
request.setPhoneNumberSet(new String[]{mobile});
request.setSignName(properties.getSignature());
request.setTemplateId(apiTemplateId);
request.setTemplateParamSet(ArrayUtils.toArray(templateParams, e -> String.valueOf(e.getValue())));
request.setSessionContext(JsonUtils.toJsonString(new SessionContext().setLogId(sendLogId)));
// 执行请求
SendSmsResponse response = client.SendSms(request);
SendStatus status = response.getSendStatusSet()[0];
return new SmsSendRespDTO().setSuccess(Objects.equals(status.getCode(), API_CODE_SUCCESS)).setSerialNo(status.getSerialNo())
.setApiRequestId(response.getRequestId()).setApiCode(status.getCode()).setApiMsg(status.getMessage()).setChannelCode(properties.getChannel());
}
@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 {
// 构建请求
DescribeSmsTemplateListRequest request = new DescribeSmsTemplateListRequest();
request.setTemplateIdSet(new Long[]{Long.parseLong(apiTemplateId)});
request.setInternational(INTERNATIONAL_CHINA);
// 执行请求
DescribeSmsTemplateListResponse response = client.DescribeSmsTemplateList(request);
DescribeTemplateListStatus status = response.getDescribeTemplateStatusSet()[0];
if (status == null || status.getStatusCode() == null) {
return null;
}
return new SmsTemplateRespDTO().setId(status.getTemplateId().toString()).setContent(status.getTemplateContent())
.setAuditStatus(convertSmsTemplateAuditStatus(status.getStatusCode().intValue())).setAuditReason(status.getReviewReply());
}
@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
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;
}
}
@@ -0,0 +1,36 @@
package com.cf.imes.module.system.framework.sms.core.enums;
import cn.hutool.core.util.ArrayUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 短信渠道枚举
*
* @author zzf
* @since 2021/1/25 10:56
*/
@Getter
@AllArgsConstructor
public enum SmsChannelEnum {
DEBUG_DING_TALK("DEBUG_DING_TALK", "调试(钉钉)"),
ALIYUN("ALIYUN", "阿里云"),
TENCENT("TENCENT", "腾讯云"),
// HUA_WEI("HUA_WEI", "华为云"),
;
/**
* 编码
*/
private final String code;
/**
* 名字
*/
private final String name;
public static SmsChannelEnum getByCode(String code) {
return ArrayUtil.firstMatch(o -> o.getCode().equals(code), values());
}
}
@@ -0,0 +1,21 @@
package com.cf.imes.module.system.framework.sms.core.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 短信模板的审核状态枚举
*
* @author 晨丰科技
*/
@AllArgsConstructor
@Getter
public enum SmsTemplateAuditStatusEnum {
CHECKING(1),
SUCCESS(2),
FAIL(3);
private final Integer status;
}
@@ -0,0 +1,52 @@
package com.cf.imes.module.system.framework.sms.core.property;
import com.cf.imes.module.system.framework.sms.core.enums.SmsChannelEnum;
import lombok.Data;
import org.springframework.validation.annotation.Validated;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
/**
* 短信渠道配置类
*
* @author zzf
* @since 2021/1/25 17:01
*/
@Data
@Validated
public class SmsChannelProperties {
/**
* 渠道编号
*/
@NotNull(message = "短信渠道 ID 不能为空")
private Long id;
/**
* 短信签名
*/
@NotEmpty(message = "短信签名不能为空")
private String signature;
/**
* 渠道编码
*
* 枚举 {@link SmsChannelEnum}
*/
@NotEmpty(message = "渠道编码不能为空")
private String code;
/**
* 短信 API 的账号
*/
@NotEmpty(message = "短信 API 的账号不能为空")
private String apiKey;
/**
* 短信 API 的密钥
*/
@NotEmpty(message = "短信 API 的密钥不能为空")
private String apiSecret;
/**
* 短信发送回调 URL
*/
private String callbackUrl;
}
@@ -0,0 +1,76 @@
package com.cf.imes.module.system.framework.sms.core.property;
import com.cf.imes.framework.common.util.encrypt.AesUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import javax.validation.constraints.NotEmpty;
/**
* @author Gqr
* @since 2024/8/23 10:51
*/
@ConfigurationProperties(prefix = "chenfeng.sms")
@Validated
public class SmsProperties {
@Value("${chenfeng.encrypt.publicKey:}")
private String publicKey;
/**
* 短信渠道
*/
@NotEmpty(message = "短信渠道不能为空")
private String channel;
/**
* 短信签名
*/
private String signature;
/**
* 短信 API 的账号
*/
@NotEmpty(message = "短信apiKey不能为空")
private String apiKey;
/**
* 短信 API 的密钥
*/
@NotEmpty(message = "短信apiSecret不能为空")
private String apiSecret;
public String getChannel() {
return channel;
}
public void setChannel(String channel) {
this.channel = channel;
}
public String getSignature() {
return signature;
}
public SmsProperties setSignature(String signature) {
this.signature = AesUtils.decrypt(signature, publicKey);
return this;
}
public String getApiKey() {
return apiKey;
}
public SmsProperties setApiKey(String apiKey) {
this.apiKey = AesUtils.decrypt(apiKey, publicKey);
return this;
}
public String getApiSecret() {
return apiSecret;
}
public SmsProperties setApiSecret(String apiSecret) {
this.apiSecret = AesUtils.decrypt(apiSecret, publicKey);
return this;
}
}
@@ -1,7 +1,7 @@
package com.cf.imes.module.system.service.sms;
import com.cf.imes.framework.common.pojo.PageResult;
import com.cf.imes.framework.sms.core.client.SmsClient;
import com.cf.imes.module.system.framework.sms.core.client.SmsClient;
import com.cf.imes.module.system.controller.admin.sms.vo.channel.SmsChannelPageReqVO;
import com.cf.imes.module.system.controller.admin.sms.vo.channel.SmsChannelSaveReqVO;
import com.cf.imes.module.system.dal.dataobject.sms.SmsChannelDO;
@@ -4,9 +4,9 @@ import cn.hutool.core.text.CharSequenceUtil;
import com.cf.imes.framework.common.exception.util.ServiceExceptionUtil;
import com.cf.imes.framework.common.pojo.PageResult;
import com.cf.imes.framework.common.util.object.BeanUtils;
import com.cf.imes.framework.sms.core.client.SmsClient;
import com.cf.imes.framework.sms.core.client.SmsClientFactory;
import com.cf.imes.framework.sms.core.property.SmsProperties;
import com.cf.imes.module.system.framework.sms.core.client.SmsClient;
import com.cf.imes.module.system.framework.sms.core.client.SmsClientFactory;
import com.cf.imes.module.system.framework.sms.core.property.SmsProperties;
import com.cf.imes.module.system.controller.admin.sms.vo.channel.SmsChannelPageReqVO;
import com.cf.imes.module.system.controller.admin.sms.vo.channel.SmsChannelSaveReqVO;
import com.cf.imes.module.system.dal.dataobject.sms.SmsChannelDO;
@@ -17,7 +17,7 @@ import com.cf.imes.module.system.dal.dataobject.user.AdminUserDO;
import com.cf.imes.module.system.dal.mysql.sms.SmsCodeMapper;
import com.cf.imes.module.system.dal.redis.RedisKeyConstants;
import com.cf.imes.module.system.enums.sms.SmsSceneEnum;
import com.cf.imes.module.system.framework.sms.SmsCodeProperties;
import com.cf.imes.module.system.framework.sms.config.SmsCodeProperties;
import com.cf.imes.framework.common.exception.util.ServiceExceptionUtil;
import com.cf.imes.module.system.enums.ErrorCodeConstants;
import com.cf.imes.module.system.service.user.AdminUserService;
@@ -7,9 +7,9 @@ import cn.hutool.core.lang.Pair;
import com.cf.imes.framework.common.enums.CommonStatusEnum;
import com.cf.imes.framework.common.enums.UserTypeEnum;
import com.cf.imes.framework.common.exception.util.ServiceExceptionUtil;
import com.cf.imes.framework.sms.core.client.SmsClient;
import com.cf.imes.framework.sms.core.client.dto.SmsReceiveRespDTO;
import com.cf.imes.framework.sms.core.client.dto.SmsSendRespDTO;
import com.cf.imes.module.system.framework.sms.core.client.SmsClient;
import com.cf.imes.module.system.framework.sms.core.client.dto.SmsReceiveRespDTO;
import com.cf.imes.module.system.framework.sms.core.client.dto.SmsSendRespDTO;
import com.cf.imes.module.system.dal.dataobject.sms.SmsTemplateDO;
import com.cf.imes.module.system.dal.dataobject.user.AdminUserDO;
import com.cf.imes.module.system.mq.message.sms.SmsSendMessage;
@@ -9,9 +9,9 @@ import com.cf.imes.framework.common.enums.CommonStatusEnum;
import com.cf.imes.framework.common.exception.util.ServiceExceptionUtil;
import com.cf.imes.framework.common.pojo.PageResult;
import com.cf.imes.framework.common.util.object.BeanUtils;
import com.cf.imes.framework.sms.core.client.SmsClient;
import com.cf.imes.framework.sms.core.client.dto.SmsTemplateRespDTO;
import com.cf.imes.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
import com.cf.imes.module.system.framework.sms.core.client.SmsClient;
import com.cf.imes.module.system.framework.sms.core.client.dto.SmsTemplateRespDTO;
import com.cf.imes.module.system.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
import com.cf.imes.module.system.controller.admin.sms.vo.template.SmsTemplatePageReqVO;
import com.cf.imes.module.system.controller.admin.sms.vo.template.SmsTemplateSaveReqVO;
import com.cf.imes.module.system.dal.dataobject.sms.SmsChannelDO;
@@ -1,7 +1,7 @@
package com.cf.imes.module.system.service.sms.handler;
import cn.hutool.core.lang.Pair;
import com.cf.imes.framework.sms.core.client.dto.SmsSendRespDTO;
import com.cf.imes.module.system.framework.sms.core.client.dto.SmsSendRespDTO;
import java.util.List;
@@ -2,10 +2,10 @@ package com.cf.imes.module.system.service.sms.handler.impl;
import cn.hutool.core.lang.Pair;
import cn.hutool.core.util.ObjectUtil;
import com.cf.imes.framework.sms.core.client.dto.SmsSendRespDTO;
import com.cf.imes.module.system.framework.sms.core.client.dto.SmsSendRespDTO;
import com.cf.imes.module.system.dal.redis.RedisKeyConstants;
import com.cf.imes.module.system.enums.sms.SmsTemplateTypeEnum;
import com.cf.imes.module.system.framework.sms.SmsCodeProperties;
import com.cf.imes.module.system.framework.sms.config.SmsCodeProperties;
import com.cf.imes.module.system.service.sms.handler.SmsSendAfterSendHandler;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
@@ -3,7 +3,7 @@ package com.cf.imes.module.system.service.sms;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.thread.ThreadUtil;
import com.cf.imes.framework.common.enums.UserTypeEnum;
import com.cf.imes.framework.sms.config.ChenfengSmsAutoConfiguration;
import com.cf.imes.module.system.framework.sms.config.ChenfengSmsAutoConfiguration;
import com.cf.imes.module.system.test.BaseDbAndRedisIntegrationTest;
import com.cf.imes.module.system.mq.consumer.sms.SmsSendConsumer;
import com.cf.imes.module.system.mq.producer.sms.SmsProducer;
@@ -3,10 +3,10 @@ package com.cf.imes.module.system.service.sms;
import com.cf.imes.framework.common.enums.CommonStatusEnum;
import com.cf.imes.framework.common.pojo.PageResult;
import com.cf.imes.framework.common.util.object.BeanUtils;
import com.cf.imes.framework.sms.core.client.SmsClient;
import com.cf.imes.framework.sms.core.client.SmsClientFactory;
import com.cf.imes.framework.sms.core.enums.SmsChannelEnum;
import com.cf.imes.framework.sms.core.property.SmsChannelProperties;
import com.cf.imes.module.system.framework.sms.core.client.SmsClient;
import com.cf.imes.module.system.framework.sms.core.client.SmsClientFactory;
import com.cf.imes.module.system.framework.sms.core.enums.SmsChannelEnum;
import com.cf.imes.module.system.framework.sms.core.property.SmsChannelProperties;
import com.cf.imes.framework.test.core.ut.BaseDbUnitTest;
import com.cf.imes.module.system.controller.admin.sms.vo.channel.SmsChannelPageReqVO;
import com.cf.imes.module.system.controller.admin.sms.vo.channel.SmsChannelSaveReqVO;
@@ -9,7 +9,7 @@ import com.cf.imes.module.system.api.sms.dto.code.SmsCodeValidateReqDTO;
import com.cf.imes.module.system.dal.dataobject.sms.SmsCodeDO;
import com.cf.imes.module.system.dal.mysql.sms.SmsCodeMapper;
import com.cf.imes.module.system.enums.sms.SmsSceneEnum;
import com.cf.imes.module.system.framework.sms.SmsCodeProperties;
import com.cf.imes.module.system.framework.sms.config.SmsCodeProperties;
import com.baomidou.mybatisplus.annotation.DbType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -4,9 +4,9 @@ import cn.hutool.core.lang.Pair;
import cn.hutool.core.map.MapUtil;
import com.cf.imes.framework.common.enums.CommonStatusEnum;
import com.cf.imes.framework.common.enums.UserTypeEnum;
import com.cf.imes.framework.sms.core.client.SmsClient;
import com.cf.imes.framework.sms.core.client.dto.SmsReceiveRespDTO;
import com.cf.imes.framework.sms.core.client.dto.SmsSendRespDTO;
import com.cf.imes.module.system.framework.sms.core.client.SmsClient;
import com.cf.imes.module.system.framework.sms.core.client.dto.SmsReceiveRespDTO;
import com.cf.imes.module.system.framework.sms.core.client.dto.SmsSendRespDTO;
import com.cf.imes.framework.test.core.ut.BaseMockitoUnitTest;
import com.cf.imes.module.system.dal.dataobject.sms.SmsChannelDO;
import com.cf.imes.module.system.dal.dataobject.sms.SmsTemplateDO;
@@ -5,9 +5,9 @@ import com.cf.imes.framework.common.enums.CommonStatusEnum;
import com.cf.imes.framework.common.pojo.PageResult;
import com.cf.imes.framework.common.util.collection.ArrayUtils;
import com.cf.imes.framework.common.util.object.ObjectUtils;
import com.cf.imes.framework.sms.core.client.SmsClient;
import com.cf.imes.framework.sms.core.client.dto.SmsTemplateRespDTO;
import com.cf.imes.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
import com.cf.imes.module.system.framework.sms.core.client.SmsClient;
import com.cf.imes.module.system.framework.sms.core.client.dto.SmsTemplateRespDTO;
import com.cf.imes.module.system.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
import com.cf.imes.framework.test.core.ut.BaseDbUnitTest;
import com.cf.imes.module.system.controller.admin.sms.vo.template.SmsTemplatePageReqVO;
import com.cf.imes.module.system.controller.admin.sms.vo.template.SmsTemplateSaveReqVO;