package com.dsh.account.service; import com.dsh.account.model.ImageCaptchaVo; import com.dsh.account.util.RedisUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import javax.imageio.ImageIO; import java.awt.Color; import java.awt.Font; import java.awt.Graphics2D; import java.awt.RenderingHints; import java.awt.geom.AffineTransform; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.security.SecureRandom; import java.util.Base64; import java.util.Locale; import java.util.UUID; /** * 短信发送前置图形验证码服务。 */ @Service public class ImageCaptchaService { private static final String CAPTCHA_KEY_PREFIX = "security:sms:image-captcha:"; private static final String CREATE_LIMIT_KEY_PREFIX = "security:sms:image-captcha:create:"; private static final char[] CAPTCHA_CHARS = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ".toCharArray(); private static final int CAPTCHA_LENGTH = 4; private static final int IMAGE_WIDTH = 120; private static final int IMAGE_HEIGHT = 44; private final SecureRandom secureRandom = new SecureRandom(); @Autowired private RedisUtil redisUtil; @Value("${security.sms.image-captcha-expire-seconds:120}") private int expireSeconds; @Value("${security.sms.image-captcha-max-per-minute:10}") private int maxPerMinute; /** * 创建与手机号、短信用途绑定的图形验证码。 * * @return 达到生成频率上限时返回null */ public ImageCaptchaVo create(String phone, Integer type, String clientIp) throws IOException { if (!redisUtil.tryAcquireFixedWindow( CREATE_LIMIT_KEY_PREFIX + clientIp, maxPerMinute, 60)) { return null; } String captchaId = UUID.randomUUID().toString().replace("-", ""); String captchaCode = randomCode(); redisUtil.setStrValue( CAPTCHA_KEY_PREFIX + captchaId, captchaValue(phone, type, captchaCode), expireSeconds ); return new ImageCaptchaVo(captchaId, renderImage(captchaCode), expireSeconds); } /** * 原子校验并删除验证码。无论输入是否正确,每个验证码都只能提交一次。 */ public boolean verifyAndConsume(String captchaId, String captchaCode, String phone, Integer type) { if (captchaId == null || captchaCode == null) { return false; } String normalizedId = captchaId.trim().toLowerCase(Locale.ROOT); String normalizedCode = captchaCode.trim().toUpperCase(Locale.ROOT); if (!normalizedId.matches("[0-9a-f]{32}") || !normalizedCode.matches("[0-9A-Z]{4}")) { return false; } return redisUtil.consumeIfEquals( CAPTCHA_KEY_PREFIX + normalizedId, captchaValue(phone, type, normalizedCode) ); } private String randomCode() { StringBuilder code = new StringBuilder(CAPTCHA_LENGTH); for (int i = 0; i < CAPTCHA_LENGTH; i++) { code.append(CAPTCHA_CHARS[secureRandom.nextInt(CAPTCHA_CHARS.length)]); } return code.toString(); } private String renderImage(String code) throws IOException { BufferedImage image = new BufferedImage(IMAGE_WIDTH, IMAGE_HEIGHT, BufferedImage.TYPE_INT_RGB); Graphics2D graphics = image.createGraphics(); try { graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); graphics.setColor(new Color(245, 247, 250)); graphics.fillRect(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT); drawNoise(graphics); drawCode(graphics, code); } finally { graphics.dispose(); } try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { ImageIO.write(image, "png", output); return "data:image/png;base64," + Base64.getEncoder().encodeToString(output.toByteArray()); } } private void drawNoise(Graphics2D graphics) { for (int i = 0; i < 7; i++) { graphics.setColor(randomColor(150, 210)); graphics.drawLine( secureRandom.nextInt(IMAGE_WIDTH), secureRandom.nextInt(IMAGE_HEIGHT), secureRandom.nextInt(IMAGE_WIDTH), secureRandom.nextInt(IMAGE_HEIGHT) ); } for (int i = 0; i < 45; i++) { graphics.setColor(randomColor(120, 220)); graphics.fillOval(secureRandom.nextInt(IMAGE_WIDTH), secureRandom.nextInt(IMAGE_HEIGHT), 2, 2); } } private void drawCode(Graphics2D graphics, String code) { graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 27)); for (int i = 0; i < code.length(); i++) { Graphics2D characterGraphics = (Graphics2D) graphics.create(); try { characterGraphics.setColor(randomColor(25, 115)); double angle = (secureRandom.nextDouble() - 0.5D) * 0.45D; int x = 12 + i * 26; int y = 31 + secureRandom.nextInt(6) - 3; characterGraphics.setTransform(AffineTransform.getRotateInstance(angle, x + 9, y - 10)); characterGraphics.drawString(String.valueOf(code.charAt(i)), x, y); } finally { characterGraphics.dispose(); } } } private Color randomColor(int min, int max) { int bound = max - min + 1; return new Color( min + secureRandom.nextInt(bound), min + secureRandom.nextInt(bound), min + secureRandom.nextInt(bound) ); } private String captchaValue(String phone, Integer type, String code) { return phone + ":" + type + ":" + code; } }