@@ -250,6 +250,11 @@ | |||
<groupId>com.ningdatech</groupId> | |||
<artifactId>nd-flowable-starter</artifactId> | |||
</dependency> | |||
<dependency> | |||
<groupId>com.ningdatech</groupId> | |||
<artifactId>nd-yxt-starter</artifactId> | |||
<version>1.0.0</version> | |||
</dependency> | |||
<!--浙政钉--> | |||
<dependency> | |||
<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; | |||
} | |||
} |