@@ -250,6 +250,11 @@ | |||||
<groupId>com.ningdatech</groupId> | <groupId>com.ningdatech</groupId> | ||||
<artifactId>nd-flowable-starter</artifactId> | <artifactId>nd-flowable-starter</artifactId> | ||||
</dependency> | </dependency> | ||||
<dependency> | |||||
<groupId>com.ningdatech</groupId> | |||||
<artifactId>nd-yxt-starter</artifactId> | |||||
<version>1.0.0</version> | |||||
</dependency> | |||||
<!--浙政钉--> | <!--浙政钉--> | ||||
<dependency> | <dependency> | ||||
<groupId>com.alibaba.xxpt</groupId> | <groupId>com.alibaba.xxpt</groupId> | ||||
@@ -0,0 +1,35 @@ | |||||
package com.ningdatech.pmapi.sms.constant; | |||||
/** | |||||
* <p> | |||||
* DatePattern | |||||
* </p> | |||||
* | |||||
* @author WendyYang | |||||
* @since 09:28 2022/5/10 | |||||
*/ | |||||
public interface DatePattern { | |||||
String TIME_ZONE = "GMT+8"; | |||||
String T = "'T'"; | |||||
String GMT = "XXX"; | |||||
String YMD_HMS = "yyyy-MM-dd HH:mm:ss"; | |||||
String YMD_HMS_1 = "yyyyMMddHHmmss"; | |||||
String YMD_HMS_S = "yyyy-MM-dd HH:mm:ss.SSS"; | |||||
String YMD = "yyyy-MM-dd"; | |||||
String YMD_0 = "yyyy/MM/dd"; | |||||
String YMD_1 = "yyyyMMdd"; | |||||
String HMS = "HH:mm:ss"; | |||||
String HMS_0 = "HH/mm/ss"; | |||||
String HMS_1 = "HHmmss"; | |||||
String YMD_HMS_GMT = YMD + T + HMS + GMT; | |||||
String EEE = "EEE"; | |||||
} |
@@ -0,0 +1,68 @@ | |||||
package com.ningdatech.pmapi.sms.constant; | |||||
import io.swagger.annotations.ApiModel; | |||||
import io.swagger.annotations.ApiModelProperty; | |||||
import lombok.AllArgsConstructor; | |||||
import lombok.Getter; | |||||
import lombok.NoArgsConstructor; | |||||
import java.util.stream.Stream; | |||||
/** | |||||
* @author liuxinxin | |||||
* @date 2023/2/16 下午4:50 | |||||
*/ | |||||
@Getter | |||||
@NoArgsConstructor | |||||
@AllArgsConstructor | |||||
@ApiModel(value = "VerificationCodeType", description = "验证码类型") | |||||
public enum VerificationCodeType { | |||||
/** | |||||
* 用户注册 | |||||
*/ | |||||
LOGIN("用户登录", 1, 5, 10); | |||||
@ApiModelProperty(value = "描述") | |||||
private String desc; | |||||
@ApiModelProperty(value = "发送间隔(单位:分钟)") | |||||
private Integer sendInterval; | |||||
@ApiModelProperty(value = "过期时间(单位:分钟)") | |||||
private Integer expireTime; | |||||
/** | |||||
* 小于等于0则不限制次数,超出次数限制则锁定验证码发送 | |||||
*/ | |||||
@ApiModelProperty(value = "每日发送次数") | |||||
private Integer sendTimesByDay; | |||||
public static VerificationCodeType match(String val, VerificationCodeType def) { | |||||
return Stream.of(values()).filter(item -> item.name().equalsIgnoreCase(val)).findAny().orElse(def); | |||||
} | |||||
public static VerificationCodeType get(String val) { | |||||
return match(val, null); | |||||
} | |||||
public boolean eq(VerificationCodeType val) { | |||||
return val != null && this.name().equals(val.name()); | |||||
} | |||||
@ApiModelProperty(value = "编码") | |||||
public String getCode() { | |||||
return this.name(); | |||||
} | |||||
public static VerificationCodeType of(String key) { | |||||
for (VerificationCodeType statusEnum : VerificationCodeType.values()) { | |||||
if (statusEnum.name().equals(key)) { | |||||
return statusEnum; | |||||
} | |||||
} | |||||
throw new IllegalArgumentException(String.format("Illegal VerificationCodeType = %s", key)); | |||||
} | |||||
} |
@@ -0,0 +1,13 @@ | |||||
package com.ningdatech.pmapi.sms.constant; | |||||
/** | |||||
* @author liuxinxin | |||||
* @date 2022/8/8 下午5:05 | |||||
*/ | |||||
public interface YxtSmsTemplateConst { | |||||
/** | |||||
* 短信登陆验证码 | |||||
*/ | |||||
String SMS_LOGIN_TEMPLATE = "验证码:%s(有效期为%s分钟),请勿泄露给他人,如非本人操作,请忽略此信息。"; | |||||
} |
@@ -0,0 +1,41 @@ | |||||
package com.ningdatech.pmapi.sms.controller; | |||||
import com.ningdatech.pmapi.sms.constant.VerificationCodeType; | |||||
import com.ningdatech.pmapi.sms.manage.SmsManage; | |||||
import com.ningdatech.pmapi.sms.model.po.ReqVerificationCodePO; | |||||
import io.swagger.annotations.Api; | |||||
import io.swagger.annotations.ApiOperation; | |||||
import lombok.RequiredArgsConstructor; | |||||
import lombok.extern.slf4j.Slf4j; | |||||
import org.springframework.validation.annotation.Validated; | |||||
import org.springframework.web.bind.annotation.PostMapping; | |||||
import org.springframework.web.bind.annotation.RequestBody; | |||||
import org.springframework.web.bind.annotation.RequestMapping; | |||||
import org.springframework.web.bind.annotation.RestController; | |||||
/** | |||||
* @author liuxinxin | |||||
* @date 2023/2/16 下午4:40 | |||||
*/ | |||||
@Slf4j | |||||
@RestController | |||||
@RequestMapping("/api/v1/verification") | |||||
@Api(tags = "验证码相关接口") | |||||
@RequiredArgsConstructor | |||||
public class VerificationCodeController { | |||||
private final SmsManage smsManage; | |||||
/** | |||||
* 通用的发送验证码功能 | |||||
* 一个系统可能有很多地方需要发送验证码(注册、找回密码等) | |||||
* 每增加一个场景{@link VerificationCodeType}就需要增加一个枚举值 | |||||
*/ | |||||
@ApiOperation(value = "发送验证码", notes = "发送验证码") | |||||
@PostMapping(value = "/send") | |||||
public void send(@Validated @RequestBody ReqVerificationCodePO request) { | |||||
smsManage.sendVerificationCode(request); | |||||
} | |||||
} |
@@ -0,0 +1,97 @@ | |||||
package com.ningdatech.pmapi.sms.manage; | |||||
import cn.hutool.core.util.PhoneUtil; | |||||
import cn.hutool.core.util.RandomUtil; | |||||
import com.ningdatech.basic.exception.BizException; | |||||
import com.ningdatech.cache.model.cache.CacheKey; | |||||
import com.ningdatech.cache.repository.CachePlusOps; | |||||
import com.ningdatech.pmapi.sms.constant.VerificationCodeType; | |||||
import com.ningdatech.pmapi.sms.constant.YxtSmsTemplateConst; | |||||
import com.ningdatech.pmapi.sms.model.dto.VerifyCodeCacheDTO; | |||||
import com.ningdatech.pmapi.sms.model.po.ReqVerificationCodePO; | |||||
import com.ningdatech.pmapi.sms.utils.DateUtil; | |||||
import com.ningdatech.pmapi.sms.utils.SmsRedisKeyUtils; | |||||
import com.ningdatech.yxt.client.YxtClient; | |||||
import com.ningdatech.yxt.constants.YxtSmsSignEnum; | |||||
import com.ningdatech.yxt.model.cmd.SendSmsCmd; | |||||
import lombok.RequiredArgsConstructor; | |||||
import lombok.extern.slf4j.Slf4j; | |||||
import org.apache.commons.lang3.StringUtils; | |||||
import org.springframework.stereotype.Component; | |||||
import org.springframework.util.Assert; | |||||
import java.time.Duration; | |||||
import java.time.LocalDateTime; | |||||
import java.util.Collections; | |||||
/** | |||||
* @author liuxinxin | |||||
* @date 2023/2/16 下午4:42 | |||||
*/ | |||||
@Slf4j | |||||
@Component | |||||
@RequiredArgsConstructor | |||||
public class SmsManage { | |||||
private final YxtClient yxtClient; | |||||
private final CachePlusOps cachePlusOps; | |||||
public void sendVerificationCode(ReqVerificationCodePO request) { | |||||
Assert.isTrue(PhoneUtil.isMobile(request.getMobile()), "手机号码格式不正确"); | |||||
String verificationType = request.getVerificationType(); | |||||
VerificationCodeType verificationCodeTypeEnum = VerificationCodeType.of(verificationType); | |||||
// 验证是否被锁定 | |||||
String lockKey = SmsRedisKeyUtils.smsSendLockKey(verificationCodeTypeEnum, request.getMobile()); | |||||
if (StringUtils.isNotBlank(cachePlusOps.get(lockKey))) { | |||||
throw BizException.wrap("今日" + verificationCodeTypeEnum.getDesc() + "的验证码发送次数过多,已被锁定"); | |||||
} | |||||
// 验证发送间隔 | |||||
String cacheKey = SmsRedisKeyUtils.smsCodeVerifyKey(verificationCodeTypeEnum, request.getMobile()); | |||||
VerifyCodeCacheDTO preCache = (VerifyCodeCacheDTO) cachePlusOps.get(cacheKey); | |||||
if (preCache != null) { | |||||
if (LocalDateTime.now().minusMinutes(verificationCodeTypeEnum.getSendInterval()) | |||||
.isBefore(preCache.getSendTime())) { | |||||
throw BizException.wrap(verificationCodeTypeEnum.getSendInterval() + "分钟之内已发送过验证码,请稍后重试"); | |||||
} | |||||
} | |||||
String code = RandomUtil.randomNumbers(6); | |||||
VerifyCodeCacheDTO cache = VerifyCodeCacheDTO.builder() | |||||
.code(code) | |||||
.sendTime(LocalDateTime.now()) | |||||
.mobile(request.getMobile()) | |||||
.build(); | |||||
// 创建短信内容 | |||||
SendSmsCmd sendSmsCmd = new SendSmsCmd(); | |||||
switch (verificationCodeTypeEnum) { | |||||
case LOGIN: | |||||
SendSmsCmd.SendSmsContext sendSmsContext = new SendSmsCmd.SendSmsContext(); | |||||
sendSmsContext.setReceiveNumber(request.getMobile()); | |||||
sendSmsContext.setContent(String.format(YxtSmsTemplateConst.SMS_LOGIN_TEMPLATE, code, verificationCodeTypeEnum.getExpireTime())); | |||||
sendSmsCmd.setContextList(Collections.singletonList(sendSmsContext)); | |||||
sendSmsCmd.setSmsSignEnum(YxtSmsSignEnum.ZJS_ELECTRONIC_EXPERT_LIB); | |||||
break; | |||||
default: | |||||
throw new IllegalArgumentException("非法的短信发送类型"); | |||||
} | |||||
// 发送 短信 | |||||
yxtClient.submitSmsTask(sendSmsCmd); | |||||
log.info("send verificationCode mobile = {},code = {}", request.getMobile(), code); | |||||
cachePlusOps.set(new CacheKey(cacheKey, Duration.ofMinutes(verificationCodeTypeEnum.getExpireTime())), cache); | |||||
String limitKey = SmsRedisKeyUtils.smsSendLimitKey(verificationCodeTypeEnum, request.getMobile()); | |||||
if (StringUtils.isNotBlank(cachePlusOps.get(limitKey))) { | |||||
long limitCount = cachePlusOps.incr(new CacheKey(limitKey, Duration.ofSeconds(DateUtil.restSecondsFromNowToNoon()))); | |||||
// 超出单日发送次数之后直接锁定 | |||||
if (limitCount >= verificationCodeTypeEnum.getSendTimesByDay().longValue()) { | |||||
cachePlusOps.set(new CacheKey(lockKey, Duration.ofSeconds(DateUtil.restSecondsFromNowToNoon())), request.getMobile()); | |||||
} | |||||
} else { | |||||
cachePlusOps.set(new CacheKey(limitKey, Duration.ofSeconds(DateUtil.restSecondsFromNowToNoon())), 1); | |||||
} | |||||
} | |||||
} |
@@ -0,0 +1,27 @@ | |||||
package com.ningdatech.pmapi.sms.model.dto; | |||||
import lombok.Builder; | |||||
import lombok.Data; | |||||
import lombok.experimental.Tolerate; | |||||
import java.time.LocalDateTime; | |||||
/** | |||||
* @author liuxinxin | |||||
* @date 2023/2/16 下午4:42 | |||||
*/ | |||||
@Data | |||||
@Builder | |||||
public class VerifyCodeCacheDTO { | |||||
@Tolerate | |||||
public VerifyCodeCacheDTO() { | |||||
} | |||||
private String mobile; | |||||
private LocalDateTime sendTime; | |||||
private String code; | |||||
} |
@@ -0,0 +1,26 @@ | |||||
package com.ningdatech.pmapi.sms.model.po; | |||||
import io.swagger.annotations.ApiModel; | |||||
import io.swagger.annotations.ApiModelProperty; | |||||
import lombok.Data; | |||||
import javax.validation.constraints.NotBlank; | |||||
import java.io.Serializable; | |||||
/** | |||||
* @author liuxinxin | |||||
* @date 2023/2/16 下午4:40 | |||||
*/ | |||||
@Data | |||||
@ApiModel(value = "VerificationCodeRequest", description = "验证码发送验证") | |||||
public class ReqVerificationCodePO implements Serializable { | |||||
@ApiModelProperty(value = "手机号") | |||||
@NotBlank(message = "手机号不能为空") | |||||
private String mobile; | |||||
@ApiModelProperty(value = "短信类型", allowableValues = "LOGIN,RECOMMENDATION_PROOF_FILE_SUBMIT") | |||||
@NotBlank(message = "短信类型不能为空") | |||||
private String verificationType; | |||||
} |
@@ -0,0 +1,206 @@ | |||||
package com.ningdatech.pmapi.sms.utils; | |||||
import com.ningdatech.pmapi.sms.constant.DatePattern; | |||||
import java.time.*; | |||||
import java.time.format.DateTimeFormatter; | |||||
import java.time.temporal.ChronoUnit; | |||||
import java.util.Date; | |||||
/** | |||||
* @author qinxianyun | |||||
* JDK 8 新日期类 格式化与字符串转换 工具类 | |||||
*/ | |||||
public class DateUtil { | |||||
public static final DateTimeFormatter DTF_YMD_HMS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); | |||||
public static final DateTimeFormatter DTF_YMD_HMS_COMPRESS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); | |||||
public static final DateTimeFormatter DTF_YMD_HM = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); | |||||
public static final DateTimeFormatter DTF_YMD = DateTimeFormatter.ofPattern("yyyy-MM-dd"); | |||||
public static final DateTimeFormatter DTF_YMD_SLASH = DateTimeFormatter.ofPattern("yyyy/MM/dd"); | |||||
public static final DateTimeFormatter DTF_HMS = DateTimeFormatter.ofPattern("HH:mm:ss"); | |||||
/** | |||||
* 最大时间,三位小数 | |||||
*/ | |||||
public static final LocalTime LOCAL_TIME_3D = LocalTime.of(23, 59, 59, 999_000_00); | |||||
/** | |||||
* LocalDateTime 转时间戳 | |||||
* | |||||
* @param localDateTime / | |||||
* @return / | |||||
*/ | |||||
public static Long getTimeStamp(LocalDateTime localDateTime) { | |||||
return localDateTime.atZone(ZoneId.systemDefault()).toEpochSecond(); | |||||
} | |||||
/** | |||||
* 时间戳转LocalDateTime | |||||
* | |||||
* @param timeStamp / | |||||
* @return / | |||||
*/ | |||||
public static LocalDateTime fromTimeStamp(Long timeStamp) { | |||||
return LocalDateTime.ofEpochSecond(timeStamp, 0, OffsetDateTime.now().getOffset()); | |||||
} | |||||
/** | |||||
* LocalDateTime 转 Date | |||||
* Jdk8 后 不推荐使用 {@link Date} Date | |||||
* | |||||
* @param localDateTime / | |||||
* @return / | |||||
*/ | |||||
public static Date toDate(LocalDateTime localDateTime) { | |||||
return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant()); | |||||
} | |||||
/** | |||||
* LocalDate 转 Date | |||||
* Jdk8 后 不推荐使用 {@link Date} Date | |||||
* | |||||
* @param localDate / | |||||
* @return / | |||||
*/ | |||||
public static Date toDate(LocalDate localDate) { | |||||
return toDate(localDate.atTime(LocalTime.now(ZoneId.systemDefault()))); | |||||
} | |||||
/** | |||||
* Date转 LocalDateTime | |||||
* Jdk8 后 不推荐使用 {@link Date} Date | |||||
* | |||||
* @param date / | |||||
* @return / | |||||
*/ | |||||
public static LocalDateTime toLocalDateTime(Date date) { | |||||
return LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault()); | |||||
} | |||||
/** | |||||
* 日期 格式化 | |||||
* | |||||
* @param localDateTime / | |||||
* @param patten / | |||||
* @return / | |||||
*/ | |||||
public static String localDateTimeFormat(LocalDateTime localDateTime, String patten) { | |||||
DateTimeFormatter df = DateTimeFormatter.ofPattern(patten); | |||||
return df.format(localDateTime); | |||||
} | |||||
/** | |||||
* 日期 格式化 | |||||
* | |||||
* @param localDateTime / | |||||
* @param df / | |||||
* @return / | |||||
*/ | |||||
public static String localDateTimeFormat(LocalDateTime localDateTime, DateTimeFormatter df) { | |||||
return df.format(localDateTime); | |||||
} | |||||
/** | |||||
* 日期格式化 yyyy-MM-dd HH:mm:ss | |||||
* | |||||
* @param localDateTime / | |||||
* @return / | |||||
*/ | |||||
public static String localDateTimeFormatyMdHms(LocalDateTime localDateTime) { | |||||
return DTF_YMD_HMS.format(localDateTime); | |||||
} | |||||
/** | |||||
* 日期格式化 yyyy-MM-dd | |||||
* | |||||
* @param localDateTime / | |||||
* @return / | |||||
*/ | |||||
public String localDateTimeFormatyMd(LocalDateTime localDateTime) { | |||||
return DTF_YMD.format(localDateTime); | |||||
} | |||||
/** | |||||
* 字符串转 LocalDateTime ,字符串格式 yyyy-MM-dd | |||||
* | |||||
* @param localDateTime / | |||||
* @return / | |||||
*/ | |||||
public static LocalDateTime parseLocalDateTimeFormat(String localDateTime, String pattern) { | |||||
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(pattern); | |||||
return LocalDateTime.from(dateTimeFormatter.parse(localDateTime)); | |||||
} | |||||
/** | |||||
* 字符串转 LocalDateTime ,字符串格式 yyyy-MM-dd | |||||
* | |||||
* @param localDateTime / | |||||
* @return / | |||||
*/ | |||||
public static LocalDateTime parseLocalDateTimeFormat(String localDateTime, DateTimeFormatter dateTimeFormatter) { | |||||
return LocalDateTime.from(dateTimeFormatter.parse(localDateTime)); | |||||
} | |||||
/** | |||||
* 字符串转 LocalDateTime ,字符串格式 yyyy-MM-dd HH:mm:ss | |||||
* | |||||
* @param localDateTime / | |||||
* @return / | |||||
*/ | |||||
public static LocalDateTime parseLocalDateTimeFormatyMdHms(String localDateTime) { | |||||
return LocalDateTime.from(DTF_YMD_HMS.parse(localDateTime)); | |||||
} | |||||
public static String weekday(LocalDate date) { | |||||
return date.format(DateTimeFormatter.ofPattern(DatePattern.EEE)); | |||||
} | |||||
public static String weekdayWithChou(LocalDate date) { | |||||
return date.format(DateTimeFormatter.ofPattern(DatePattern.EEE)).replace("星期", "周"); | |||||
} | |||||
public static boolean between(LocalDate target, LocalDate start, LocalDate end) { | |||||
return !(target.isBefore(start) || target.isAfter(end)); | |||||
} | |||||
public static boolean between(LocalDateTime target, LocalDateTime start, LocalDateTime end) { | |||||
return !(target.isBefore(start) || target.isAfter(end)); | |||||
} | |||||
public static boolean between(LocalTime target, LocalTime start, LocalTime end) { | |||||
return !(target.isBefore(start) || target.isAfter(end)); | |||||
} | |||||
public static boolean between(Date target, Date start, Date end) { | |||||
return !(target.before(start) || target.after(end)); | |||||
} | |||||
public static LocalDateTime min(LocalDateTime time1, LocalDateTime time2) { | |||||
return time1.isAfter(time2) ? time2 : time1; | |||||
} | |||||
public static LocalDateTime max(LocalDateTime time1, LocalDateTime time2) { | |||||
return time1.isBefore(time2) ? time2 : time1; | |||||
} | |||||
public static long restSecondsFromNowToNoon() { | |||||
return ChronoUnit.SECONDS.between(LocalDateTime.now(), LocalDate.now().atTime(LocalTime.MAX)); | |||||
} | |||||
public static boolean intersect(LocalDateTime startG1, LocalDateTime endG1, LocalDateTime startG2, LocalDateTime endG2) { | |||||
if (!startG1.isBefore(startG2) && !startG1.isAfter(endG2)) { | |||||
return Boolean.TRUE; | |||||
} | |||||
return !endG1.isBefore(startG2) && !endG1.isAfter(endG2); | |||||
} | |||||
} |
@@ -0,0 +1,35 @@ | |||||
package com.ningdatech.pmapi.sms.utils; | |||||
import cn.hutool.core.text.StrPool; | |||||
import com.ningdatech.pmapi.sms.constant.VerificationCodeType; | |||||
/** | |||||
* <p> | |||||
* SmsRedisKeyUtils | |||||
* </p> | |||||
* | |||||
* @author WendyYang | |||||
* @since 11:21 2022/7/25 | |||||
*/ | |||||
public class SmsRedisKeyUtils { | |||||
private SmsRedisKeyUtils() { | |||||
} | |||||
private static final String SMS_CODE_VERIFY_PREFIX = "sms:verify:"; | |||||
private static final String SMS_SEND_LIMIT = "sms:limit:"; | |||||
private static final String SMS_SEND_LOCK = "sms:lock:"; | |||||
public static String smsCodeVerifyKey(VerificationCodeType type, String mobile) { | |||||
return SMS_CODE_VERIFY_PREFIX + StrPool.COLON + type.name() + StrPool.COLON + mobile; | |||||
} | |||||
public static String smsSendLimitKey(VerificationCodeType type, String mobile) { | |||||
return SMS_SEND_LIMIT + StrPool.COLON + type.name() + StrPool.COLON + mobile; | |||||
} | |||||
public static String smsSendLockKey(VerificationCodeType type, String mobile) { | |||||
return SMS_SEND_LOCK + StrPool.COLON + type.name() + StrPool.COLON + mobile; | |||||
} | |||||
} |