package com.imyeyu.api.util; import com.imyeyu.java.TimiJava; import com.imyeyu.java.bean.timi.TimiCode; import com.imyeyu.java.bean.timi.TimiException; import com.imyeyu.spring.TimiSpring; import com.imyeyu.spring.util.Redis; import com.imyeyu.utils.Calc; import com.imyeyu.utils.Encoder; import com.imyeyu.utils.Time; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.imageio.ImageIO; import java.awt.AlphaComposite; import java.awt.Color; import java.awt.Font; import java.awt.GradientPaint; import java.awt.Graphics2D; import java.awt.geom.AffineTransform; import java.awt.geom.QuadCurve2D; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.UUID; /** * 验证码绘制 * * @author 夜雨 * @version 2021-05-20 16:48 */ @Slf4j @Component @RequiredArgsConstructor public class CaptchaManager { /** 会话频率限制键,插值会话 ID */ private static final String LOCK_KEY = "CAPTCHA:LOCK:%s"; /** 会话值缓存键,插值唯一 ID */ private static final String CACHE_KEY = "CAPTCHA:%s"; /** 绘制字符,移除 O,o,I,l */ private static final char[] CHARS = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'm', 'n', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z' }; @Value("${spring.profiles.active}") private String env; private final Redis redisCaptcha; public Result generate(int width, int height) { return generate(width, height, 4); } /** * 生成并缓存验证码 * * @param width 宽度 * @param height 高度 * @param length 长度 * @return 图片流 */ public Result generate(int width, int height, int length) { long lockTime = 1000; // 加锁 String lockKey = LOCK_KEY.formatted(TimiSpring.getSession().getId()); Long lockAt = TimiSpring.getSessionAttr(lockKey, Long.class); TimiSpring.setSessionAttr(lockKey, Time.now()); if (lockAt != null) { long diff = Time.now() - lockAt; if (diff < lockTime) { // 限制频率 synchronized (this) { try { wait(lockTime - diff); } catch (InterruptedException e) { log.error("lock error", e); throw new RuntimeException(e); } } } } int fontWidth = (int) (width * .9 / 4); int fontSize = (int) (height * .7); int yOffset = (int) (height * .4); StringBuilder value = new StringBuilder(); // 图片流 BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D g = (Graphics2D) image.getGraphics(); // 渐变背景 GradientPaint gradient = new GradientPaint( 0, 0, new Color(Calc.random(200, 255), Calc.random(200, 255), Calc.random(200, 255)), width, height, new Color(Calc.random(200, 255), Calc.random(200, 255), Calc.random(200, 255)) ); g.setPaint(gradient); g.fillRect(0, 0, width, height); // 半透明干扰层 g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.4F)); for (int i = 0; i < 15; i++) { g.setColor(new Color(Calc.random(0, 150), Calc.random(0, 150), Calc.random(0, 150))); g.drawString(String.valueOf(CHARS[Calc.random(0, CHARS.length - 1)]), Calc.random(0, width), Calc.random(0, height)); } g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER)); // 绘制验证码字符 g.setFont(new Font("Fixedsys", Font.BOLD, Calc.random(fontSize - 2, fontSize + 2))); for (int i = 0; i < length; i++) { char c = CHARS[Calc.random(0, CHARS.length - 1)]; value.append(c); // 颜色 g.setColor(new Color(Calc.random(50, 150), Calc.random(50, 150), Calc.random(50, 150))); // 位置 int x = fontWidth * i + Calc.random(0, 10); int y = height - yOffset + Calc.random(-4, 4); // 随机角度 AffineTransform original = g.getTransform(); AffineTransform rotate = AffineTransform.getRotateInstance(Math.toRadians(Calc.random(0, 15)), x + fontSize / 2D, y - fontSize / 2D); g.setTransform(rotate); g.drawString(String.valueOf(c), x, y); g.setTransform(original); } // 干扰线 for (int i = 0; i < height / 2; i++) { int x1 = Calc.random(0, width); int y1 = Calc.random(0, height); int x2 = x1 + Calc.random(-20, 20); int y2 = y1 + Calc.random(-20, 20); g.setColor(new Color(Calc.random(100, 150), Calc.random(100, 150), Calc.random(100, 150))); if (Calc.randomBoolean()) { g.drawLine(x1, y1, x2, y2); } else { QuadCurve2D curve = new QuadCurve2D.Float(); double ctrlX = (x1 + x2) / 2D + Calc.random(-15, 15); double ctrlY = (y1 + y2) / 2D + Calc.random(-15, 15); curve.setCurve(x1, y1, ctrlX, ctrlY, x2, y2); g.draw(curve); } } try { String uniqueId = UUID.randomUUID().toString(); // 写入缓存 redisCaptcha.set(CACHE_KEY.formatted(uniqueId), value.toString(), Time.parseToMS("3m")); // 返回 ByteArrayOutputStream os = new ByteArrayOutputStream(); ImageIO.write(image, "png", os); return new Result(uniqueId, "data:image/png;base64," + Encoder.base64(os.toByteArray())); } catch (IOException e) { throw new TimiException(TimiCode.ERROR); } } /** @return 错误回调图像 */ public BufferedImage error(TimiCode code) { final int width = 74, height = 24; BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D g = (Graphics2D) img.getGraphics(); g.setColor(Color.RED); // 文本 g.setFont(new Font("Fixedsys", Font.BOLD, 13)); g.drawString("ERR." + code.getValue(), 2, 18); g.setColor(Color.DARK_GRAY); return img; } /** * 验证 * * * * @param uniqueId 唯一识别 ID * @param value 提交的验证码 * @throws TimiException 验证异常 */ public void verify(String uniqueId, String value) { if (TimiJava.isEmpty(value)) { throw new TimiException(TimiCode.ARG_MISS, "not found value"); } if (env.startsWith("dev")) { return; } String cacheKey = CACHE_KEY.formatted(uniqueId); String cacheValue = redisCaptcha.get(cacheKey); if (TimiJava.isEmpty(cacheValue)) { throw new TimiException(TimiCode.ARG_EXPIRED, "captcha.expire"); } if (!value.trim().equalsIgnoreCase(cacheValue)) { throw new TimiException(TimiCode.RESULT_BAD, "captcha.error"); } // 清除缓存 redisCaptcha.destroy(cacheKey); } /** * @author 夜雨 * @since 2025-10-30 14:48 */ public record Result(String id, String data) { } }