diff --git a/pmapi/pom.xml b/pmapi/pom.xml index f4e7b97..3db2e93 100644 --- a/pmapi/pom.xml +++ b/pmapi/pom.xml @@ -250,6 +250,11 @@ com.ningdatech nd-flowable-starter + + com.ningdatech + nd-yxt-starter + 1.0.0 + com.alibaba.xxpt diff --git a/pmapi/src/main/java/com/ningdatech/pmapi/sms/constant/DatePattern.java b/pmapi/src/main/java/com/ningdatech/pmapi/sms/constant/DatePattern.java new file mode 100644 index 0000000..0861a36 --- /dev/null +++ b/pmapi/src/main/java/com/ningdatech/pmapi/sms/constant/DatePattern.java @@ -0,0 +1,35 @@ +package com.ningdatech.pmapi.sms.constant; + +/** + *

+ * DatePattern + *

+ * + * @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"; + +} diff --git a/pmapi/src/main/java/com/ningdatech/pmapi/sms/constant/VerificationCodeType.java b/pmapi/src/main/java/com/ningdatech/pmapi/sms/constant/VerificationCodeType.java new file mode 100644 index 0000000..9761d18 --- /dev/null +++ b/pmapi/src/main/java/com/ningdatech/pmapi/sms/constant/VerificationCodeType.java @@ -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)); + } +} diff --git a/pmapi/src/main/java/com/ningdatech/pmapi/sms/constant/YxtSmsTemplateConst.java b/pmapi/src/main/java/com/ningdatech/pmapi/sms/constant/YxtSmsTemplateConst.java new file mode 100644 index 0000000..02ad6a5 --- /dev/null +++ b/pmapi/src/main/java/com/ningdatech/pmapi/sms/constant/YxtSmsTemplateConst.java @@ -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分钟),请勿泄露给他人,如非本人操作,请忽略此信息。"; +} diff --git a/pmapi/src/main/java/com/ningdatech/pmapi/sms/controller/VerificationCodeController.java b/pmapi/src/main/java/com/ningdatech/pmapi/sms/controller/VerificationCodeController.java new file mode 100644 index 0000000..1b6733e --- /dev/null +++ b/pmapi/src/main/java/com/ningdatech/pmapi/sms/controller/VerificationCodeController.java @@ -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); + } + +} diff --git a/pmapi/src/main/java/com/ningdatech/pmapi/sms/helper/VerifyCodeCheckHelper.java b/pmapi/src/main/java/com/ningdatech/pmapi/sms/helper/VerifyCodeCheckHelper.java new file mode 100644 index 0000000..c1a5034 --- /dev/null +++ b/pmapi/src/main/java/com/ningdatech/pmapi/sms/helper/VerifyCodeCheckHelper.java @@ -0,0 +1,43 @@ +package com.ningdatech.pmapi.sms.helper; + +import com.baomidou.mybatisplus.core.toolkit.StringUtils; +import com.ningdatech.cache.repository.CachePlusOps; +import com.ningdatech.pmapi.sms.constant.VerificationCodeType; +import com.ningdatech.pmapi.sms.model.dto.VerifyCodeCacheDTO; +import com.ningdatech.pmapi.sms.utils.SmsRedisKeyUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Objects; + +/** + * @author liuxinxin + * @date 2022/8/16 上午11:43 + */ +@Component +@RequiredArgsConstructor +public class VerifyCodeCheckHelper { + + private final CachePlusOps cachePlusOps; + + /** + * 对某种类型的验证码进行校验 + * @param type + * @param mobile + * @param verificationCode + * @return + */ + public boolean verification(VerificationCodeType type, String mobile, String verificationCode) { + if (StringUtils.isBlank(mobile) || Objects.isNull(type)) { + return false; + } + String key = SmsRedisKeyUtils.smsCodeVerifyKey(type, mobile); + Object cacheObj = cachePlusOps.get(key); + if (Objects.isNull(cacheObj)) { + return false; + } + VerifyCodeCacheDTO cache = (VerifyCodeCacheDTO) cacheObj; + return verificationCode.trim().equals(cache.getCode()); + } + +} diff --git a/pmapi/src/main/java/com/ningdatech/pmapi/sms/manage/SmsManage.java b/pmapi/src/main/java/com/ningdatech/pmapi/sms/manage/SmsManage.java new file mode 100644 index 0000000..8f3e276 --- /dev/null +++ b/pmapi/src/main/java/com/ningdatech/pmapi/sms/manage/SmsManage.java @@ -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); + } + + } + + +} diff --git a/pmapi/src/main/java/com/ningdatech/pmapi/sms/model/dto/VerifyCodeCacheDTO.java b/pmapi/src/main/java/com/ningdatech/pmapi/sms/model/dto/VerifyCodeCacheDTO.java new file mode 100644 index 0000000..68d2fad --- /dev/null +++ b/pmapi/src/main/java/com/ningdatech/pmapi/sms/model/dto/VerifyCodeCacheDTO.java @@ -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; + +} diff --git a/pmapi/src/main/java/com/ningdatech/pmapi/sms/model/po/ReqVerificationCodePO.java b/pmapi/src/main/java/com/ningdatech/pmapi/sms/model/po/ReqVerificationCodePO.java new file mode 100644 index 0000000..98712ca --- /dev/null +++ b/pmapi/src/main/java/com/ningdatech/pmapi/sms/model/po/ReqVerificationCodePO.java @@ -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; + +} diff --git a/pmapi/src/main/java/com/ningdatech/pmapi/sms/utils/DateUtil.java b/pmapi/src/main/java/com/ningdatech/pmapi/sms/utils/DateUtil.java new file mode 100644 index 0000000..74ac293 --- /dev/null +++ b/pmapi/src/main/java/com/ningdatech/pmapi/sms/utils/DateUtil.java @@ -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); + } + + +} diff --git a/pmapi/src/main/java/com/ningdatech/pmapi/sms/utils/SmsRedisKeyUtils.java b/pmapi/src/main/java/com/ningdatech/pmapi/sms/utils/SmsRedisKeyUtils.java new file mode 100644 index 0000000..23e3e30 --- /dev/null +++ b/pmapi/src/main/java/com/ningdatech/pmapi/sms/utils/SmsRedisKeyUtils.java @@ -0,0 +1,35 @@ +package com.ningdatech.pmapi.sms.utils; + +import cn.hutool.core.text.StrPool; +import com.ningdatech.pmapi.sms.constant.VerificationCodeType; + +/** + *

+ * SmsRedisKeyUtils + *

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