package com.ruoyi.web.controller.miniapp; import java.awt.image.BufferedImage; import java.util.concurrent.TimeUnit; import javax.imageio.ImageIO; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.util.FastByteArrayOutputStream; import com.google.code.kaptcha.Producer; import com.ruoyi.common.config.RuoYiConfig; import com.ruoyi.common.constant.Constants; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.redis.RedisCache; import com.ruoyi.common.utils.sign.Base64; import com.ruoyi.common.utils.uuid.IdUtils; import com.ruoyi.system.service.ISysConfigService; import jakarta.annotation.Resource; /** * 学生端图形验证码:生成 + 一次性消费校验。 * 供 MiniappAccountController(登录/注册/找回)与 MiniappController(绑定/开放登记)共用, * 堵死"无验证码枚举花名册"的匿名爆破面。 */ @Service public class MiniappCaptchaService { /** 学生端独立验证码 key 前缀(不与管理端 captcha_codes: 互串) */ public static final String CAPTCHA_KEY = "miniapp_captcha:"; @Resource(name = "captchaProducer") private Producer captchaProducer; @Resource(name = "captchaProducerMath") private Producer captchaProducerMath; @Autowired private RedisCache redisCache; @Autowired private ISysConfigService configService; /** 生成验证码,返回 { captchaEnabled, uuid, img };开关与管理端一致 */ public AjaxResult generate() { AjaxResult ajax = AjaxResult.success(); boolean captchaEnabled = configService.selectCaptchaEnabled(); ajax.put("captchaEnabled", captchaEnabled); if (!captchaEnabled) { return ajax; } String uuid = IdUtils.simpleUUID(); String capStr; String code; BufferedImage image; if ("math".equals(RuoYiConfig.getCaptchaType())) { String capText = captchaProducerMath.createText(); capStr = capText.substring(0, capText.lastIndexOf("@")); code = capText.substring(capText.lastIndexOf("@") + 1); image = captchaProducerMath.createImage(capStr); } else { capStr = code = captchaProducer.createText(); image = captchaProducer.createImage(capStr); } redisCache.setCacheObject(CAPTCHA_KEY + uuid, code, Constants.CAPTCHA_EXPIRATION, TimeUnit.MINUTES); try (FastByteArrayOutputStream os = new FastByteArrayOutputStream()) { ImageIO.write(image, "jpg", os); ajax.put("uuid", uuid); ajax.put("img", Base64.encode(os.toByteArray())); } catch (Exception e) { return AjaxResult.error("验证码生成失败"); } return ajax; } /** 一次性校验:通过返回 null,否则返回错误消息(先删 key 防重放) */ public String verify(String code, String uuid) { if (!configService.selectCaptchaEnabled()) return null; if (code == null || code.trim().isEmpty() || uuid == null || uuid.trim().isEmpty()) return "请输入图形验证码"; String key = CAPTCHA_KEY + uuid; Object expected = redisCache.getCacheObject(key); if (expected == null) return "验证码已失效,请刷新后重试"; redisCache.deleteObject(key); if (!code.trim().equalsIgnoreCase(expected.toString())) return "验证码错误"; return null; } /** 失败计数:固定 10 分钟窗口内累计(首次失败设定窗口,后续只增量,不滚存续命) */ public int incrFail(String key, int max) { long cnt = redisCache.redisTemplate.opsForValue().increment(key); if (cnt == 1L) { redisCache.redisTemplate.expire(key, 10, TimeUnit.MINUTES); } return (int) cnt; } public int failCount(String key) { Object v = redisCache.getCacheObject(key); return v instanceof Number ? ((Number) v).intValue() : 0; } public void clearFail(String key) { redisCache.deleteObject(key); } }