소스 검색

分离service

tanlie 6 일 전
부모
커밋
aee570a49b

+ 4 - 3
yoshop-java/src/main/java/cn/yoshop/api/controller/ApiCaptchaController.java

@@ -4,8 +4,8 @@ import cn.yoshop.api.req.SendSmsCaptchaReq;
 import cn.yoshop.api.resp.ImageCaptchaResp;
 import cn.yoshop.api.service.ApiCaptchaService;
 import cn.yoshop.common.api.R;
+import jakarta.annotation.Resource;
 import jakarta.validation.Valid;
-import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.*;
 
 /**
@@ -13,10 +13,11 @@ import org.springframework.web.bind.annotation.*;
  */
 @RestController
 @RequestMapping("/api")
-@RequiredArgsConstructor
+
 public class ApiCaptchaController {
 
-    private final ApiCaptchaService apiCaptchaService;
+    @Resource
+    ApiCaptchaService apiCaptchaService;
 
     /**
      * /api/captcha/image 图形验证码(base64 / key / md5)

+ 8 - 157
yoshop-java/src/main/java/cn/yoshop/api/service/ApiCaptchaService.java

@@ -1,167 +1,18 @@
 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.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);
 }

+ 171 - 0
yoshop-java/src/main/java/cn/yoshop/api/service/impl/ApiCaptchaServiceImpl.java

@@ -0,0 +1,171 @@
+package cn.yoshop.api.service.impl;
+
+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.resp.ImageCaptchaResp;
+import cn.yoshop.api.service.ApiCaptchaService;
+import cn.yoshop.api.service.ApiSmsService;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.utils.RedisUtils;
+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 ApiCaptchaServiceImpl implements ApiCaptchaService {
+
+    /**
+     * 图形验证码缓存键前缀
+     */
+    private static final String IMAGE_KEY_PREFIX = "yoshop:api:captcha:";
+    /**
+     * 短信发送次数缓存键前缀
+     */
+    private static final String SEND_KEY_PREFIX = "yoshop:api:sms:send:";
+
+    /**
+     * 有效期(秒),对齐 PHP CaptchaApi::$expire
+     */
+    private static final int EXPIRE_SECONDS = 1800;
+    /**
+     * 允许的错误次数,对齐 PHP CaptchaApi::$checkTimes
+     */
+    private static final int CHECK_TIMES = 5;
+
+    /**
+     * 每日最大发送次数,对齐 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}
+     */
+    @Override
+    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.set(IMAGE_KEY_PREFIX + key, 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)。
+     * 记录不存在 / 次数用尽时直接抛业务异常。
+     */
+    @Override
+    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 = (CheckCodeDto) redisUtils.get(cacheKey);
+        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.set(cacheKey, next, EXPIRE_SECONDS);
+            return false;
+        }
+        redisUtils.deleteString(cacheKey);
+        return true;
+    }
+
+    // ------------------------------------------------------------------ 短信验证码
+
+    /**
+     * 发送短信验证码:校验图形验证码 → 发送次数限制 → 生成并保存验证码。
+     *
+     * @return 固定文案(对齐 PHP controller 的 "发送成功,请注意查收")
+     */
+    @Override
+    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;
+        }
+    }
+}