diff --git a/hangtag-module-system/hangtag-module-system-api/src/main/java/cn/hangtag/module/system/api/mail/dto/MailSendSingleToUserReqDTO.java b/hangtag-module-system/hangtag-module-system-api/src/main/java/cn/hangtag/module/system/api/mail/dto/MailSendSingleToUserReqDTO.java index fa202be..1301092 100644 --- a/hangtag-module-system/hangtag-module-system-api/src/main/java/cn/hangtag/module/system/api/mail/dto/MailSendSingleToUserReqDTO.java +++ b/hangtag-module-system/hangtag-module-system-api/src/main/java/cn/hangtag/module/system/api/mail/dto/MailSendSingleToUserReqDTO.java @@ -7,7 +7,7 @@ import javax.validation.constraints.NotNull; import java.util.Map; /** - * 邮件发送 Request DTO + * 邮箱发送 Request DTO * * @author wangjingqi */ diff --git a/hangtag-module-system/hangtag-module-system-api/src/main/java/cn/hangtag/module/system/api/mail/dto/code/MailCodeSendReqDTO.java b/hangtag-module-system/hangtag-module-system-api/src/main/java/cn/hangtag/module/system/api/mail/dto/code/MailCodeSendReqDTO.java new file mode 100644 index 0000000..f2de4df --- /dev/null +++ b/hangtag-module-system/hangtag-module-system-api/src/main/java/cn/hangtag/module/system/api/mail/dto/code/MailCodeSendReqDTO.java @@ -0,0 +1,37 @@ +package cn.hangtag.module.system.api.mail.dto.code; + +import cn.hangtag.framework.common.validation.InEnum; +import cn.hangtag.framework.common.validation.Mobile; +import cn.hangtag.module.system.enums.mail.MailSceneEnum; +import lombok.Data; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +/** + * 邮箱验证码的发送 Request DTO + * + * @author 芋道源码 + */ +@Data +public class MailCodeSendReqDTO { + + /** + * 邮箱 + */ + @Mobile + @NotEmpty(message = "邮箱不能为空") + private String mail; + /** + * 发送场景 + */ + @NotNull(message = "发送场景不能为空") + @InEnum(MailSceneEnum.class) + private Integer scene; + /** + * 发送 IP + */ + @NotEmpty(message = "发送 IP 不能为空") + private String createIp; + +} diff --git a/hangtag-module-system/hangtag-module-system-api/src/main/java/cn/hangtag/module/system/api/mail/dto/code/MailCodeUseReqDTO.java b/hangtag-module-system/hangtag-module-system-api/src/main/java/cn/hangtag/module/system/api/mail/dto/code/MailCodeUseReqDTO.java new file mode 100644 index 0000000..b12378f --- /dev/null +++ b/hangtag-module-system/hangtag-module-system-api/src/main/java/cn/hangtag/module/system/api/mail/dto/code/MailCodeUseReqDTO.java @@ -0,0 +1,42 @@ +package cn.hangtag.module.system.api.mail.dto.code; + +import cn.hangtag.framework.common.validation.InEnum; +import cn.hangtag.framework.common.validation.Mobile; +import cn.hangtag.module.system.enums.sms.SmsSceneEnum; +import lombok.Data; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +/** + * 邮箱验证码的使用 Request DTO + * + * @author 芋道源码 + */ +@Data +public class MailCodeUseReqDTO { + + /** + * 邮箱 + */ + @Mobile + @NotEmpty(message = "邮箱不能为空") + private String mail; + /** + * 发送场景 + */ + @NotNull(message = "发送场景不能为空") + @InEnum(SmsSceneEnum.class) + private Integer scene; + /** + * 验证码 + */ + @NotEmpty(message = "验证码") + private String code; + /** + * 使用 IP + */ + @NotEmpty(message = "使用 IP 不能为空") + private String usedIp; + +} diff --git a/hangtag-module-system/hangtag-module-system-api/src/main/java/cn/hangtag/module/system/api/mail/dto/code/MailCodeValidateReqDTO.java b/hangtag-module-system/hangtag-module-system-api/src/main/java/cn/hangtag/module/system/api/mail/dto/code/MailCodeValidateReqDTO.java new file mode 100644 index 0000000..67b7bdf --- /dev/null +++ b/hangtag-module-system/hangtag-module-system-api/src/main/java/cn/hangtag/module/system/api/mail/dto/code/MailCodeValidateReqDTO.java @@ -0,0 +1,37 @@ +package cn.hangtag.module.system.api.mail.dto.code; + +import cn.hangtag.framework.common.validation.InEnum; +import cn.hangtag.framework.common.validation.Mobile; +import cn.hangtag.module.system.enums.sms.SmsSceneEnum; +import lombok.Data; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +/** + * 邮箱验证码的校验 Request DTO + * + * @author 芋道源码 + */ +@Data +public class MailCodeValidateReqDTO { + + /** + * 手机号 + */ + @Mobile + @NotEmpty(message = "邮箱不能为空") + private String mail; + /** + * 发送场景 + */ + @NotNull(message = "发送场景不能为空") + @InEnum(SmsSceneEnum.class) + private Integer scene; + /** + * 验证码 + */ + @NotEmpty(message = "验证码") + private String code; + +} diff --git a/hangtag-module-system/hangtag-module-system-api/src/main/java/cn/hangtag/module/system/enums/mail/MailSceneEnum.java b/hangtag-module-system/hangtag-module-system-api/src/main/java/cn/hangtag/module/system/enums/mail/MailSceneEnum.java new file mode 100644 index 0000000..00f8a95 --- /dev/null +++ b/hangtag-module-system/hangtag-module-system-api/src/main/java/cn/hangtag/module/system/enums/mail/MailSceneEnum.java @@ -0,0 +1,51 @@ +package cn.hangtag.module.system.enums.mail; + +import cn.hangtag.framework.common.core.IntArrayValuable; +import cn.hutool.core.util.ArrayUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 用户短信验证码发送场景的枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum MailSceneEnum implements IntArrayValuable { + + MEMBER_LOGIN(1, "user-sms-login", "会员用户 - 邮箱登陆"), + MEMBER_UPDATE_MOBILE(2, "user-update-mobile", "会员用户 - 修改手机"), + MEMBER_UPDATE_PASSWORD(3, "user-update-password", "会员用户 - 修改密码"), + MEMBER_RESET_PASSWORD(4, "user-reset-password", "会员用户 - 忘记密码"), + + ADMIN_MEMBER_LOGIN(21, "admin-sms-login", "后台用户 - 邮箱登录"); + + public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(MailSceneEnum::getScene).toArray(); + + /** + * 验证场景的编号 + */ + private final Integer scene; + /** + * 模版编码 + */ + private final String templateCode; + /** + * 描述 + */ + private final String description; + + @Override + public int[] array() { + return ARRAYS; + } + + public static MailSceneEnum getCodeByScene(Integer scene) { + return ArrayUtil.firstMatch(sceneEnum -> sceneEnum.getScene().equals(scene), + values()); + } + +} diff --git a/hangtag-module-system/hangtag-module-system-biz/src/main/java/cn/hangtag/module/system/service/mail/MailCodeService.java b/hangtag-module-system/hangtag-module-system-biz/src/main/java/cn/hangtag/module/system/service/mail/MailCodeService.java new file mode 100644 index 0000000..712edef --- /dev/null +++ b/hangtag-module-system/hangtag-module-system-biz/src/main/java/cn/hangtag/module/system/service/mail/MailCodeService.java @@ -0,0 +1,40 @@ +package cn.hangtag.module.system.service.mail; + +import cn.hangtag.framework.common.exception.ServiceException; +import cn.hangtag.module.system.api.mail.dto.code.MailCodeSendReqDTO; +import cn.hangtag.module.system.api.sms.dto.code.SmsCodeUseReqDTO; +import cn.hangtag.module.system.api.sms.dto.code.SmsCodeValidateReqDTO; + +import javax.validation.Valid; + +/** + * 短信验证码 Service 接口 + * + * @author 芋道源码 + */ +public interface MailCodeService { + + /** + * 创建邮件验证码,并进行发送 + * + * @param reqDTO 发送请求 + */ + void sendMailCode(@Valid MailCodeSendReqDTO reqDTO); + + /** + * 验证邮件验证码,并进行使用 + * 如果正确,则将验证码标记成已使用 + * 如果错误,则抛出 {@link ServiceException} 异常 + * + * @param reqDTO 使用请求 + */ + void useMailCode(@Valid SmsCodeUseReqDTO reqDTO); + + /** + * 检查验证码是否有效 + * + * @param reqDTO 校验请求 + */ + void validateMailCode(@Valid SmsCodeValidateReqDTO reqDTO); + +} diff --git a/hangtag-module-system/hangtag-module-system-biz/src/main/java/cn/hangtag/module/system/service/mail/MailCodeServiceImpl.java b/hangtag-module-system/hangtag-module-system-biz/src/main/java/cn/hangtag/module/system/service/mail/MailCodeServiceImpl.java new file mode 100644 index 0000000..69eda20 --- /dev/null +++ b/hangtag-module-system/hangtag-module-system-biz/src/main/java/cn/hangtag/module/system/service/mail/MailCodeServiceImpl.java @@ -0,0 +1,113 @@ +package cn.hangtag.module.system.service.mail; + +import cn.hangtag.module.system.api.mail.dto.code.MailCodeSendReqDTO; +import cn.hangtag.module.system.api.sms.dto.code.SmsCodeUseReqDTO; +import cn.hangtag.module.system.api.sms.dto.code.SmsCodeValidateReqDTO; +import cn.hangtag.module.system.dal.dataobject.sms.SmsCodeDO; +import cn.hangtag.module.system.dal.mysql.sms.SmsCodeMapper; +import cn.hangtag.module.system.enums.sms.SmsSceneEnum; +import cn.hangtag.module.system.framework.sms.config.SmsCodeProperties; +import cn.hangtag.module.system.service.sms.SmsCodeService; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.map.MapUtil; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.time.LocalDateTime; + +import static cn.hangtag.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.hangtag.framework.common.util.date.DateUtils.isToday; +import static cn.hangtag.module.system.enums.ErrorCodeConstants.*; +import static cn.hutool.core.util.RandomUtil.randomInt; + +/** + * 邮箱验证码 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class MailCodeServiceImpl implements MailCodeService { + + @Resource + private SmsCodeProperties smsCodeProperties; + + @Resource + private SmsCodeMapper smsCodeMapper; + + @Resource + private MailSendService mailSendService; + + @Override + public void sendMailCode(MailCodeSendReqDTO reqDTO) { + SmsSceneEnum sceneEnum = SmsSceneEnum.getCodeByScene(reqDTO.getScene()); + Assert.notNull(sceneEnum, "验证码场景({}) 查找不到配置", reqDTO.getScene()); + // 创建验证码 + String code = createMailCode(reqDTO.getMail(), reqDTO.getScene(), reqDTO.getCreateIp()); + // 发送验证码 + mailSendService.sendSingleMail(reqDTO.getMail(), null, null, + sceneEnum.getTemplateCode(), MapUtil.of("code", code)); + } + + private String createMailCode(String mobile, Integer scene, String ip) { + // 校验是否可以发送验证码,不用筛选场景 + SmsCodeDO lastSmsCode = smsCodeMapper.selectLastByMobile(mobile, null, null); + if (lastSmsCode != null) { + if (LocalDateTimeUtil.between(lastSmsCode.getCreateTime(), LocalDateTime.now()).toMillis() + < smsCodeProperties.getSendFrequency().toMillis()) { // 发送过于频繁 + throw exception(SMS_CODE_SEND_TOO_FAST); + } + if (isToday(lastSmsCode.getCreateTime()) && // 必须是今天,才能计算超过当天的上限 + lastSmsCode.getTodayIndex() >= smsCodeProperties.getSendMaximumQuantityPerDay()) { // 超过当天发送的上限。 + throw exception(SMS_CODE_EXCEED_SEND_MAXIMUM_QUANTITY_PER_DAY); + } + // TODO 芋艿:提升,每个 IP 每天可发送数量 + // TODO 芋艿:提升,每个 IP 每小时可发送数量 + } + + // 创建验证码记录 + String code = String.format("%0" + smsCodeProperties.getEndCode().toString().length() + "d", + randomInt(smsCodeProperties.getBeginCode(), smsCodeProperties.getEndCode() + 1)); + SmsCodeDO newSmsCode = SmsCodeDO.builder().mobile(mobile).code(code).scene(scene) + .todayIndex(lastSmsCode != null && isToday(lastSmsCode.getCreateTime()) ? lastSmsCode.getTodayIndex() + 1 : 1) + .createIp(ip).used(false).build(); + smsCodeMapper.insert(newSmsCode); + return code; + } + + @Override + public void useMailCode(SmsCodeUseReqDTO reqDTO) { + // 检测验证码是否有效 + SmsCodeDO lastSmsCode = validateMailCode0(reqDTO.getMobile(), reqDTO.getCode(), reqDTO.getScene()); + // 使用验证码 + smsCodeMapper.updateById(SmsCodeDO.builder().id(lastSmsCode.getId()) + .used(true).usedTime(LocalDateTime.now()).usedIp(reqDTO.getUsedIp()).build()); + } + + @Override + public void validateMailCode(SmsCodeValidateReqDTO reqDTO) { + validateMailCode0(reqDTO.getMobile(), reqDTO.getCode(), reqDTO.getScene()); + } + + private SmsCodeDO validateMailCode0(String mobile, String code, Integer scene) { + // 校验验证码 + SmsCodeDO lastSmsCode = smsCodeMapper.selectLastByMobile(mobile, code, scene); + // 若验证码不存在,抛出异常 + if (lastSmsCode == null) { + throw exception(SMS_CODE_NOT_FOUND); + } + // 超过时间 + if (LocalDateTimeUtil.between(lastSmsCode.getCreateTime(), LocalDateTime.now()).toMillis() + >= smsCodeProperties.getExpireTimes().toMillis()) { // 验证码已过期 + throw exception(SMS_CODE_EXPIRED); + } + // 判断验证码是否已被使用 + if (Boolean.TRUE.equals(lastSmsCode.getUsed())) { + throw exception(SMS_CODE_USED); + } + return lastSmsCode; + } + +} diff --git a/hangtag-ui/hangtag-ui-front/pnpm-lock.yaml b/hangtag-ui/hangtag-ui-front/pnpm-lock.yaml index b6d86b8..43f0e22 100644 --- a/hangtag-ui/hangtag-ui-front/pnpm-lock.yaml +++ b/hangtag-ui/hangtag-ui-front/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: diagram-js: specifier: ^12.8.0 version: 12.8.1 + dom-to-image: + specifier: ^2.6.0 + version: 2.6.0 driver.js: specifier: ^1.3.1 version: 1.3.1 @@ -2802,6 +2805,9 @@ packages: dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dom-to-image@2.6.0: + resolution: {integrity: sha512-Dt0QdaHmLpjURjU7Tnu3AgYSF2LuOmksSGsUcE6ItvJoCWTBEmiMXcqBdNSAm9+QbbwD7JMoVsuuKX6ZVQv1qA==} + dom-walk@0.1.2: resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==} @@ -7410,7 +7416,7 @@ snapshots: '@types/web-bluetooth': 0.0.16 '@vueuse/metadata': 9.13.0 '@vueuse/shared': 9.13.0(vue@3.4.21(typescript@5.3.3)) - vue-demi: 0.14.7(vue@3.4.21(typescript@5.3.3)) + vue-demi: 0.14.10(vue@3.4.21(typescript@5.3.3)) transitivePeerDependencies: - '@vue/composition-api' - vue @@ -7421,14 +7427,14 @@ snapshots: '@vueuse/shared@10.9.0(vue@3.4.21(typescript@5.3.3))': dependencies: - vue-demi: 0.14.7(vue@3.4.21(typescript@5.3.3)) + vue-demi: 0.14.10(vue@3.4.21(typescript@5.3.3)) transitivePeerDependencies: - '@vue/composition-api' - vue '@vueuse/shared@9.13.0(vue@3.4.21(typescript@5.3.3))': dependencies: - vue-demi: 0.14.7(vue@3.4.21(typescript@5.3.3)) + vue-demi: 0.14.10(vue@3.4.21(typescript@5.3.3)) transitivePeerDependencies: - '@vue/composition-api' - vue @@ -8217,6 +8223,8 @@ snapshots: domhandler: 5.0.3 entities: 4.5.0 + dom-to-image@2.6.0: {} + dom-walk@0.1.2: {} dom7@3.0.0: