|
@@ -1,167 +1,18 @@
|
|
|
package cn.yoshop.api.service;
|
|
package cn.yoshop.api.service;
|
|
|
|
|
|
|
|
-import cn.hutool.captcha.CaptchaUtil;
|
|
|
|
|
-import cn.hutool.captcha.LineCaptcha;
|
|
|
|
|
-import cn.hutool.crypto.digest.DigestUtil;
|
|
|
|
|
-import cn.yoshop.api.dto.CheckCodeDto;
|
|
|
|
|
import cn.yoshop.api.req.SendSmsCaptchaReq;
|
|
import cn.yoshop.api.req.SendSmsCaptchaReq;
|
|
|
import cn.yoshop.api.resp.ImageCaptchaResp;
|
|
import cn.yoshop.api.resp.ImageCaptchaResp;
|
|
|
-import cn.yoshop.common.exception.BizException;
|
|
|
|
|
-import cn.yoshop.utils.RedisUtils;
|
|
|
|
|
-import com.alibaba.fastjson2.JSON;
|
|
|
|
|
-import lombok.RequiredArgsConstructor;
|
|
|
|
|
-import org.springframework.security.crypto.bcrypt.BCrypt;
|
|
|
|
|
-import org.springframework.stereotype.Service;
|
|
|
|
|
-import org.springframework.util.StringUtils;
|
|
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 图形验证码与短信验证码下发,对齐 PHP:
|
|
|
|
|
- * {@code app/api/service/passport/Captcha}、{@code app/api/service/passport/SmsCaptcha}
|
|
|
|
|
- * 与 {@code yiovo/captcha} 的 {@code CaptchaApi}。
|
|
|
|
|
- *
|
|
|
|
|
- * <p>图形验证码:
|
|
|
|
|
- * <ul>
|
|
|
|
|
- * <li>5 位字符(排除 0/1/l/o 等易混淆字符),PNG 图片以 base64 data URI 返回;</li>
|
|
|
|
|
- * <li>Redis 键 {@code yoshop:api:captcha:{key}},key 即验证码小写后的 BCrypt 摘要
|
|
|
|
|
- * (与 PHP 用 {@code password_hash} 当 key 的做法一致)。值为 {@code {"code","times"}},TTL 1800 秒;</li>
|
|
|
|
|
- * <li>校验失败次数用尽后删除;校验通过立即删除(用后即焚)。</li>
|
|
|
|
|
- * </ul>
|
|
|
|
|
- *
|
|
|
|
|
- * <p>短信验证码:
|
|
|
|
|
- * <ul>
|
|
|
|
|
- * <li>先校验图形验证码,再按手机号限制每天最多 10 次(Redis {@code yoshop:api:sms:send:{mobile}},TTL 86400 秒);</li>
|
|
|
|
|
- * <li>验证码 6 位数字,写入 {@link ApiSmsService}(键 {@code yoshop:api:sms:{mobile}});</li>
|
|
|
|
|
- * <li><b>短信通道尚未接入</b>:验证码只落缓存并写日志,接口仍返回成功文案(与 PHP 当前行为一致)。</li>
|
|
|
|
|
- * </ul>
|
|
|
|
|
- */
|
|
|
|
|
-@Service
|
|
|
|
|
-@RequiredArgsConstructor
|
|
|
|
|
-public class ApiCaptchaService {
|
|
|
|
|
|
|
+ * @author lie tan
|
|
|
|
|
+ * @description
|
|
|
|
|
+ * @date 2026-10-20 22:24
|
|
|
|
|
+ **/
|
|
|
|
|
+public interface ApiCaptchaService {
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * 图形验证码缓存键前缀
|
|
|
|
|
- */
|
|
|
|
|
- private static final String IMAGE_KEY_PREFIX = "yoshop:api:captcha:";
|
|
|
|
|
- /**
|
|
|
|
|
- * 短信发送次数缓存键前缀
|
|
|
|
|
- */
|
|
|
|
|
- private static final String SEND_KEY_PREFIX = "yoshop:api:sms:send:";
|
|
|
|
|
|
|
+ ImageCaptchaResp createImage();
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * 有效期(秒),对齐 PHP CaptchaApi::$expire
|
|
|
|
|
- */
|
|
|
|
|
- private static final int EXPIRE_SECONDS = 1800;
|
|
|
|
|
- /**
|
|
|
|
|
- * 允许的错误次数,对齐 PHP CaptchaApi::$checkTimes
|
|
|
|
|
- */
|
|
|
|
|
- private static final int CHECK_TIMES = 5;
|
|
|
|
|
|
|
+ boolean checkImage(String code, String key);
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * 每日最大发送次数,对齐 PHP SmsCaptcha::$sendTimes
|
|
|
|
|
- */
|
|
|
|
|
- private static final int SEND_TIMES = 10;
|
|
|
|
|
- /**
|
|
|
|
|
- * 发送次数统计窗口(秒),对齐 PHP SmsCaptcha::$safeTime
|
|
|
|
|
- */
|
|
|
|
|
- private static final int SEND_SAFE_SECONDS = 86400;
|
|
|
|
|
-
|
|
|
|
|
- private static final int IMAGE_WIDTH = 160;
|
|
|
|
|
- private static final int IMAGE_HEIGHT = 60;
|
|
|
|
|
- private static final int CODE_LENGTH = 5;
|
|
|
|
|
-
|
|
|
|
|
- private final RedisUtils redisUtils;
|
|
|
|
|
- private final ApiSmsService smsService;
|
|
|
|
|
-
|
|
|
|
|
- // ------------------------------------------------------------------ 图形验证码
|
|
|
|
|
-
|
|
|
|
|
- /**
|
|
|
|
|
- * 生成图形验证码:{base64, key, md5}
|
|
|
|
|
- */
|
|
|
|
|
- public ImageCaptchaResp createImage() {
|
|
|
|
|
- LineCaptcha captcha = CaptchaUtil.createLineCaptcha(IMAGE_WIDTH, IMAGE_HEIGHT, CODE_LENGTH, 40);
|
|
|
|
|
- String code = captcha.getCode() == null ? "" : captcha.getCode().toLowerCase();
|
|
|
|
|
- String key = BCrypt.hashpw(code, BCrypt.gensalt(10));
|
|
|
|
|
-
|
|
|
|
|
- CheckCodeDto cache = new CheckCodeDto();
|
|
|
|
|
- cache.setCode(code);
|
|
|
|
|
- cache.setTimes(CHECK_TIMES);
|
|
|
|
|
- redisUtils.setString(IMAGE_KEY_PREFIX + key, JSON.toJSONString(cache), EXPIRE_SECONDS);
|
|
|
|
|
-
|
|
|
|
|
- ImageCaptchaResp data = new ImageCaptchaResp();
|
|
|
|
|
- // 对齐 PHP:base64 带 data URI 前缀,且去掉换行
|
|
|
|
|
- data.setBase64("data:image/png;base64," + captcha.getImageBase64().replace("\r\n", "").replace("\n", ""));
|
|
|
|
|
- data.setKey(key);
|
|
|
|
|
- data.setMd5(DigestUtil.md5Hex(code));
|
|
|
|
|
- return data;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /**
|
|
|
|
|
- * 校验图形验证码,返回是否匹配(不匹配由调用方给文案,对齐 PHP CaptchaApi::check 返回 false)。
|
|
|
|
|
- * 记录不存在 / 次数用尽时直接抛业务异常。
|
|
|
|
|
- */
|
|
|
|
|
- public boolean checkImage(String code, String key) {
|
|
|
|
|
- if (!StringUtils.hasText(code) || !StringUtils.hasText(key)) {
|
|
|
|
|
- throw new BizException("很抱歉,图形验证码不正确");
|
|
|
|
|
- }
|
|
|
|
|
- String cacheKey = IMAGE_KEY_PREFIX + key;
|
|
|
|
|
- CheckCodeDto cache = JSON.parseObject(redisUtils.getString(cacheKey), CheckCodeDto.class);
|
|
|
|
|
- if (cache == null || cache.getCode() == null) {
|
|
|
|
|
- throw new BizException("图形验证码不存在,请重新获取");
|
|
|
|
|
- }
|
|
|
|
|
- int times = cache.getTimes();
|
|
|
|
|
- if (times <= 0) {
|
|
|
|
|
- redisUtils.deleteString(cacheKey);
|
|
|
|
|
- throw new BizException("图形验证码已超出错误次数,请重新获取");
|
|
|
|
|
- }
|
|
|
|
|
- if (!BCrypt.checkpw(code.trim().toLowerCase(), key)) {
|
|
|
|
|
- CheckCodeDto next = new CheckCodeDto();
|
|
|
|
|
- next.setCode(cache.getCode());
|
|
|
|
|
- next.setTimes(times - 1);
|
|
|
|
|
- redisUtils.setString(cacheKey, JSON.toJSONString(next), EXPIRE_SECONDS);
|
|
|
|
|
- return false;
|
|
|
|
|
- }
|
|
|
|
|
- redisUtils.deleteString(cacheKey);
|
|
|
|
|
- return true;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // ------------------------------------------------------------------ 短信验证码
|
|
|
|
|
-
|
|
|
|
|
- /**
|
|
|
|
|
- * 发送短信验证码:校验图形验证码 → 发送次数限制 → 生成并保存验证码。
|
|
|
|
|
- *
|
|
|
|
|
- * @return 固定文案(对齐 PHP controller 的 "发送成功,请注意查收")
|
|
|
|
|
- */
|
|
|
|
|
- public String sendSmsCaptcha(SendSmsCaptchaReq req) {
|
|
|
|
|
- if (!checkImage(req.getCaptchaCode(), req.getCaptchaKey())) {
|
|
|
|
|
- throw new BizException("很抱歉,图形验证码不正确");
|
|
|
|
|
- }
|
|
|
|
|
- // 发送次数限制(每日 10 次)
|
|
|
|
|
- String recordKey = SEND_KEY_PREFIX + req.getMobile();
|
|
|
|
|
- String raw = redisUtils.getString(recordKey);
|
|
|
|
|
- if (!StringUtils.hasText(raw)) {
|
|
|
|
|
- redisUtils.setString(recordKey, String.valueOf(SEND_TIMES - 1), SEND_SAFE_SECONDS);
|
|
|
|
|
- } else {
|
|
|
|
|
- int left = toInt(raw);
|
|
|
|
|
- if (left <= 0) {
|
|
|
|
|
- throw new BizException("很抱歉,已超出今日最大发送次数限制");
|
|
|
|
|
- }
|
|
|
|
|
- redisUtils.setString(recordKey, String.valueOf(left - 1), SEND_SAFE_SECONDS);
|
|
|
|
|
- }
|
|
|
|
|
- // 生成验证码并保存(短信通道未接入时由 ApiSmsService 记录日志)
|
|
|
|
|
- smsService.send(req.getMobile());
|
|
|
|
|
- return "发送成功,请注意查收";
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // ------------------------------------------------------------------ 工具
|
|
|
|
|
-
|
|
|
|
|
- private static int toInt(Object value) {
|
|
|
|
|
- if (value == null) {
|
|
|
|
|
- return 0;
|
|
|
|
|
- }
|
|
|
|
|
- try {
|
|
|
|
|
- return (int) Double.parseDouble(String.valueOf(value).trim());
|
|
|
|
|
- } catch (NumberFormatException e) {
|
|
|
|
|
- return 0;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ String sendSmsCaptcha(SendSmsCaptchaReq req);
|
|
|
}
|
|
}
|