java和vue实现滑动拼图验证码

java和vue实现滑动拼图验证码

  • 前言
  • 效果图
  • 正文
    • 后端Java
      • 依赖
      • 核心代码
      • Controller
    • 前端vue
  • 最后成品
  • 扩展GIf动态拼图
  • 引用

前言

由于最近想做一个滑块验证码,苦于后端没有精美好看的样式,纯前端的验证码倒是挺好看的,但是不安全啊。找了很久一直没有找到想要的效果。无奈只能自己尝试着来干了。
网络上很多java生成拼图验证码的代码。但是都不太灵活,下面我们来看下。

效果图

java和vue实现滑动拼图验证码_第1张图片
java和vue实现滑动拼图验证码_第2张图片
这里要感谢作者javaLuo[^1]开源的vue-puzzle-vcode,我们的前端样式基本上基于该开源代码来修改。

正文

后端Java

依赖


<dependency>
    <groupId>com.jhlabsgroupId>
    <artifactId>filtersartifactId>
    <version>2.0.235version>
dependency>

<dependency>
    <groupId>cn.hutoolgroupId>
    <artifactId>hutool-allartifactId>
    <version>5.4.1version>
dependency>

<dependency>
    <groupId>org.projectlombokgroupId>
    <artifactId>lombokartifactId>
    <optional>trueoptional>
dependency>

核心代码

主要技术点
1.使用GeneralPath描绘选区,这里区别其他文章方式。
2.使用jhlabs滤镜库来做滑块的阴影,主图的内阴影,做出立体的效果来
新增了描边功能

import cn.hutool.core.img.ImgUtil;
import cn.hutool.core.io.resource.Resource;
import cn.hutool.core.util.NumberUtil;
import com.jhlabs.image.ImageUtils;
import com.jhlabs.image.InvertAlphaFilter;
import com.jhlabs.image.ShadowFilter;
import lombok.Data;

import javax.imageio.stream.ImageInputStream;
import java.awt.*;
import java.awt.geom.Arc2D;
import java.awt.geom.GeneralPath;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.InputStream;
import java.util.Random;

/**
 * 滑块验证码
 *
 * @author hexm
 * @date 2020/10/23
 */
@Data
public class PuzzleCaptcha {
    /** 默认宽度,用来计算阴影基本长度 */
    private static final int DEFAULT_WIDTH = 280;
    /** 随机数 */
    private static final Random RANDOM = new Random();
    /** 蒙版 */
    private static Color color = new Color(255, 255, 255, 204);
    /** alpha通道过滤器 */
    private static InvertAlphaFilter alphaFilter = new InvertAlphaFilter();
    /** 边距 */
    private static int margin = 10;

    /** 生成图片的宽度 */
    private int width = DEFAULT_WIDTH;
    /** 生成图片高度 */
    private int height = 150;
    /** x轴的坐标,由算法决定 */
    private int x;
    /** y轴的坐标,由算法决定 */
    private int y;
    /** 拼图长宽 */
    private int vwh = 10 * 3;
    /** 原图 */
    private Image image;
    /** 大图 */
    private Image artwork;
    /** 小图 */
    private Image vacancy;
    /** 是否注重速度 */
    private boolean isFast = false;
    /** 小图描边颜色 */
    private Color vacancyBorderColor;
    /** 小图描边线条的宽度 */
    private float vacancyBorderWidth = 2.5f;
    /** 主图描边的颜色 */
    private Color artworkBorderColor;
    /** 主图描边线条的宽度 */
    private float artworkBorderWidth = 5f;
    /**
     * 最高放大倍数,合理的放大倍数可以使图像平滑且提高渲染速度
     * 当isFast为false时,此属性生效
     * 放大倍数越高,生成的图像越平滑,受原始图片大小的影响。
     */
    private double maxRatio = 2;
    /**
     * 画质
     *
     * @see Image#SCALE_DEFAULT
     * @see Image#SCALE_FAST
     * @see Image#SCALE_SMOOTH
     * @see Image#SCALE_REPLICATE
     * @see Image#SCALE_AREA_AVERAGING
     */
    private int imageQuality = Image.SCALE_SMOOTH;

    /**
     * 从文件中读取图片
     *
     * @param file
     */
    public PuzzleCaptcha(File file) {
        image = ImgUtil.read(file);
    }

    /**
     * 从文件中读取图片,请使用绝对路径,使用相对路径会相对于ClassPath
     *
     * @param imageFilePath
     */
    public PuzzleCaptcha(String imageFilePath) {
        image = ImgUtil.read(imageFilePath);
    }

    /**
     * 从{@link Resource}中读取图片
     *
     * @param resource
     */
    public PuzzleCaptcha(Resource resource) {
        image = ImgUtil.read(resource);
    }

    /**
     * 从流中读取图片
     *
     * @param imageStream
     */
    public PuzzleCaptcha(InputStream imageStream) {
        image = ImgUtil.read(imageStream);
    }

    /**
     * 从图片流中读取图片
     *
     * @param imageStream
     */
    public PuzzleCaptcha(ImageInputStream imageStream) {
        image = ImgUtil.read(imageStream);
    }

    /**
     * 加载图片
     *
     * @param image
     */
    public PuzzleCaptcha(Image image) {
        this.image = image;
    }

    /**
     * 加载图片
     *
     * @param bytes
     */
    public PuzzleCaptcha(byte[] bytes) {
        this.image = ImgUtil.read(new ByteArrayInputStream(bytes));
    }

    /**
     * 生成随机x、y坐标
     */
    private void init() {
        if (x == 0 || y == 0) {
            this.x = random(vwh, this.width - vwh - margin);
            this.y = random(margin, this.height - vwh - margin);
        }
    }

    /**
     * 执行
     */
    public void run() {
        init();
        // 缩略图
        Image thumbnail;
        GeneralPath path;
        int realW = image.getWidth(null);
        int realH = image.getHeight(null);
        int w = realW, h = realH;
        double wScale = 1, hScale = 1;
        // 如果原始图片比执行的图片还小,则先拉伸再裁剪
        boolean isFast = this.isFast || w < this.width || h < this.height;
        if (isFast) {
            // 缩放,使用平滑模式
            thumbnail = image.getScaledInstance(width, height, imageQuality);
            path = paintBrick(1, 1);
            w = this.width;
            h = this.height;
        } else {
            // 缩小到一定的宽高,保证裁剪的圆润
            boolean flag = false;
            if (realW > width * maxRatio) {
                // 不超过最大倍数且不超过原始图片的宽
                w = Math.min((int) (width * maxRatio), realW);
                flag = true;
            }
            if (realH > height * maxRatio) {
                h = Math.min((int) (height * maxRatio), realH);
                flag = true;
            }
            if (flag) {
                // 若放大倍数生效,则缩小图片至最高放大倍数,再进行裁剪
                thumbnail = image.getScaledInstance(w, h, imageQuality);
            } else {
                thumbnail = image;
            }
            hScale = NumberUtil.div(h, height);
            wScale = NumberUtil.div(w, width);
            path = paintBrick(wScale, hScale);
        }

        // 创建阴影过滤器
        float radius = 5 * ((float) w / DEFAULT_WIDTH) * (float) wScale;
        int left = 1;
        ShadowFilter shadowFilter = new ShadowFilter(radius, 2 * (float) wScale, -1 * (float) hScale, 0.8f);

        // 创建空白的图片
        BufferedImage artwork = translucent(new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB));
        BufferedImage localVacancy = translucent(new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB));
        // 画小图
        Graphics2D vg = localVacancy.createGraphics();
        // 抗锯齿
        vg.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
        // 设置画图路径范围
        vg.setClip(path);
        // 将区域中的图像画到小图中
        vg.drawImage(thumbnail, null, null);
        //描边
        if (vacancyBorderColor != null) {
            vg.setColor(vacancyBorderColor);
            vg.setStroke(new BasicStroke(vacancyBorderWidth));
            vg.draw(path);
        }
        // 释放图像
        vg.dispose();

        // 画大图
        // 创建画笔
        Graphics2D g = artwork.createGraphics();
        // 抗锯齿
        g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
        // 画上图片
        g.drawImage(thumbnail, null, null);
        // 设置画图路径范围
        g.setClip(path);
        // 填充缺口透明度 颜色混合,不透明在上
        g.setComposite(AlphaComposite.SrcAtop);
        // 填充一层白色的透明蒙版,透明度越高,白色越深 alpha:0-255
        g.setColor(color);
        g.fill(path);
        //描边
        if (artworkBorderColor != null) {
            g.setColor(artworkBorderColor);
            g.setStroke(new BasicStroke(artworkBorderWidth));
            g.draw(path);
        }
        // 画上基于小图的内阴影,先反转alpha通道,然后创建阴影
        g.drawImage(shadowFilter.filter(alphaFilter.filter(localVacancy, null), null), null, null);
        // 释放图像
        g.dispose();

        // 裁剪掉多余的透明背景
        localVacancy = ImageUtils.getSubimage(localVacancy, (int) (x * wScale - left), 0, (int) Math.ceil(path.getBounds().getWidth() + radius) + left, h);
        if (isFast) {
            // 添加阴影
            this.vacancy = shadowFilter.filter(localVacancy, null);
            this.artwork = artwork;
        } else {
            // 小图添加阴影
            localVacancy = shadowFilter.filter(localVacancy, null);
            // 大图缩放
            this.artwork = artwork.getScaledInstance(width, height, imageQuality);
            // 缩放时,需要加上阴影的宽度,再除以放大比例
            this.vacancy = localVacancy.getScaledInstance((int) ((path.getBounds().getWidth() + radius) / wScale), height, imageQuality);
        }
    }

    /**
     * 绘制拼图块的路径
     *
     * @param xScale x轴放大比例
     * @param yScale y轴放大比例
     * @return
     */
    private GeneralPath paintBrick(double xScale, double yScale) {
        double x = this.x * xScale;
        double y = this.y * yScale;
        // 直线移动的基础距离
        double hMoveL = vwh / 3f * yScale;
        double wMoveL = vwh / 3f * xScale;
        GeneralPath path = new GeneralPath();
        path.moveTo(x, y);
        path.lineTo(x + wMoveL, y);
        // 上面的圆弧正东方向0°,顺时针负数,逆时针正数
        path.append(arc(x + wMoveL, y - hMoveL / 2, wMoveL, hMoveL, 180, -180), true);
        path.lineTo(x + wMoveL * 3, y);
        path.lineTo(x + wMoveL * 3, y + hMoveL);
        // 右边的圆弧
        path.append(arc(x + wMoveL * 2 + wMoveL / 2, y + hMoveL, wMoveL, hMoveL, 90, -180), true);
        path.lineTo(x + wMoveL * 3, y + hMoveL * 3);
        path.lineTo(x, y + hMoveL * 3);
        path.lineTo(x, y + hMoveL * 2);
        // 左边的内圆弧
        path.append(arc(x - wMoveL / 2, y + hMoveL, wMoveL, hMoveL, -90, 180), true);
        path.lineTo(x, y);
        path.closePath();
        return path;
    }

    /**
     * 绘制圆形、圆弧或者是椭圆形
     * 正东方向0°,顺时针负数,逆时针正数
     *
     * @param x      左上角的x坐标
     * @param y      左上角的y坐标
     * @param w      宽
     * @param h      高
     * @param start  开始的角度
     * @param extent 结束的角度
     * @return
     */
    private Arc2D arc(double x, double y, double w, double h, double start, double extent) {
        return new Arc2D.Double(x, y, w, h, start, extent, Arc2D.OPEN);
    }

    /**
     * 透明背景
     *
     * @param bufferedImage
     * @return
     */
    private BufferedImage translucent(BufferedImage bufferedImage) {
        Graphics2D g = bufferedImage.createGraphics();
        bufferedImage = g.getDeviceConfiguration().createCompatibleImage(bufferedImage.getWidth(), bufferedImage.getHeight(), Transparency.TRANSLUCENT);
        g.dispose();
        return bufferedImage;
    }

    /**
     * 随机数
     *
     * @param min
     * @param max
     * @return
     */
    private static int random(int min, int max) {
        return RANDOM.ints(min, max + 1).findFirst().getAsInt();
    }
}


注释很详细了,就不多描述了。

Controller

技术要点
1.生成验证码图片,将宽度、当前时间戳与偏移量x存入缓存redis中。
2.验证前端的宽度与原始图片的宽度比例。比较偏移量x的时候需要乘以这个比例,否则会出现后端图片大,前端图片小,偏移量和后端差距过大不通过。
3.与生成照片的时间戳比较,如果小于500毫秒,认为是非人操作^_^,我们让它失败重来.
4.最后将验证结果存入redis中。删除本地的验证码缓存信息,防止二次使用。
5.登陆的时候,从redis中获取验证结果,如果结果成功,删除验证结果,进入登陆流程。

import cn.hutool.core.util.NumberUtil;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.awt.*;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;

/**
 * 验证码
 *
 * @author hexm
 * @date 2020/10/27 10:58
 */
@RestController
@RequestMapping("/captcha")
public class CaptchaController {
    private static final int X_OFFSET = 8;

    private static final int SPEED = 500;

    @Autowired
    private Cache<Object> cache;

    @ApiOperation("获取验证码")
    @GetMapping(value = "/")
    public CaptchaVo captcha() {
        // 删除上次验证结果
        cache.remove(CacheConstant.CAPTCHA_RESULT + ThreadContextHolder.getSession().getId());

        PuzzleCaptcha puzzleCaptcha = new PuzzleCaptcha(CaptchaUtil.randomImage());
        puzzleCaptcha.setImageQuality(Image.SCALE_AREA_AVERAGING);
        puzzleCaptcha.run();

        Map<String, Object> cacheMap = new HashMap<>();
        CaptchaVo captchaVo = new CaptchaVo();
        captchaVo.setImage1(ImageConvertUtil.toDataUri(puzzleCaptcha.getArtwork(), "png"));
        captchaVo.setImage2(ImageConvertUtil.toDataUri(puzzleCaptcha.getVacancy(), "png"));

        // 偏移量
        cacheMap.put("x", puzzleCaptcha.getX());
        cacheMap.put("time", System.currentTimeMillis());
        cacheMap.put("width", puzzleCaptcha.getWidth());
        cache.put(CacheConstant.CAPTCHA + ThreadContextHolder.getSession().getId(), cacheMap, 5 * 60);
        return captchaVo;
    }

    @ApiOperation("验证码验证")
    @PostMapping(value = "/verify")
    public CaptchaResult verify(@RequestBody Map<String, Object> map) {
        CaptchaResult result = new CaptchaResult();
        result.setSuccess(false);

        String key = CacheConstant.CAPTCHA + ThreadContextHolder.getSession().getId();
        // 偏移量
        Integer vx = StrUtil.toInt(map.get("x"));
        // 宽度
        Integer width = StrUtil.toInt(map.get("width"), 1);

        //缓存
        Map<String, Object> cacheMap = cache.get(key);
        if (cacheMap == null) {
            result.setMessage(ServiceErrorCode.E1008.desc());
            return result;
        }
        Integer x = StrUtil.toInt(cacheMap.get("x"));
        Integer realWidth = StrUtil.toInt(cacheMap.get("width"));
        Long time = StrUtil.toLong(cacheMap.get("time"));
        // 验证速度
        long s = System.currentTimeMillis() - time;

        // 查看前端的缩放比例
        double ratio = NumberUtil.div(realWidth, width).doubleValue();

        if (x == null || vx == null) {
            result.setMessage(ServiceErrorCode.E1008.desc());
            cache.remove(key);
            return result;
        } else if (Math.abs(x - (vx * ratio)) > X_OFFSET * ratio || s < SPEED) {
            result.setMessage(ServiceErrorCode.E1009.desc());
            cache.remove(key);
            return result;
        }
        result.setSuccess(true);
        cache.remove(key);
        cache.put(CacheConstant.CAPTCHA_RESULT + ThreadContextHolder.getSession().getId(), result, 5 * 60);
        return result;
    }
}

图片转base64 ImageConvertUtil

这里没有使用hutool的ImgUtil.toBase64是因为该工具类png格式的图片透明背景会变成黑色。希望更新版本后会修复这个bug。

import com.jhlabs.image.ImageUtils;

import javax.imageio.ImageIO;
import java.awt.*;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Base64;

/**
 * @author hexm
 * @date 2020/10/27 15:37
 */
public class ImageConvertUtil {

    /**
     * 将image对象转为base64字符串
     *
     * @param image
     * @return
     */
    public static String toBase64(Image image, String format) {
        return Base64.getEncoder().encodeToString(toBytes(image, format));
    }

    /**
     * 将image对象转为前端img标签识别的base64字符串
     *
     * @param image
     * @param format
     * @return
     */
    public static String toDataUri(Image image, String format) {
        return String.format("data:image/%s;base64,%s", format, toBase64(image, format));
    }

    /**
     * 将image对象转为字节
     *
     * @param image
     * @param format
     * @return
     */
    public static byte[] toBytes(Image image, String format) {
        ByteArrayOutputStream stream = new ByteArrayOutputStream();
        try {
            ImageIO.write(ImageUtils.convertImageToARGB(image), format, stream);
        } catch (IOException e) {
            e.printStackTrace();
        }
        return stream.toByteArray();
    }
}

前端vue

修改的地方不会很多,大部分删减后移动到了后端

<template>
  
  <div
    :id="id"
    :class="['vue-puzzle-vcode', { show_: show }]"
    @mousedown="onCloseMouseDown"
    @mouseup="onCloseMouseUp"
    @touchstart="onCloseMouseDown"
    @touchend="onCloseMouseUp"
  >
    <div class="vue-auth-box_" :style="{'border-radius':borderRadius+'px'}" @mousedown.stop @touchstart.stop>
      <div class="auth-body_" :style="`height: ${canvasHeight}px`">
        
        <img ref="img1" :src="image1" alt="" :width="canvasWidth" :height="canvasHeight">
        
        <img
          ref="img2"
          :src="image2"
          class="auth-canvas2_"
          :height="canvasHeight"
          :style="`transform:translateX(${styleWidth - sliderBaseSize}px)`"
          alt=""
        >
        <div :class="['loading-box_', { hide_: !loading }]">
          <div class="loading-gif_">
            <span />
            <span />
            <span />
            <span />
            <span />
          div>
        div>
        <div :class="['info-box_', { show: infoBoxShow }, { fail: infoBoxFail }]">
          {{ infoText }}
        div>
        <div
          :class="['flash_', { show: isSuccess }]"
          :style="
            `transform: translateX(${
              isSuccess
                ? `${canvasWidth + canvasHeight * 0.578}px`
                : `-${canvasHeight * 0.578}px`
            }) skew(-30deg, 0);`
          "
        />
        <img class="reset_" :src="resetSvg" @click="reset">
      div>
      <div class="auth-control_">
        <div class="range-box" :style="`height:${sliderBaseSize}px`">
          <div class="range-text">{{ sliderText }}div>
          <div ref="range-slider" class="range-slider" :style="`width:${styleWidth}px`">
            <div :class="['range-btn', { isDown: mouseDown }]" :style="`width:${sliderBaseSize}px`" @mousedown="onRangeMouseDown($event)" @touchstart="onRangeMouseDown($event)">
              <div />
              <div />
              <div />
            div>
          div>
        div>
      div>
    div>
  div>
template>

<script>
import { captcha, captchaVerify } from '@/api/common/common'

const resetSvg = require('@/components/PuzzleCode/reset.png')
export default {
  name: 'PuzzleCode',
  /** 父级参数 **/
  props: {
    id: { type: String, default: undefined },
    canvasWidth: { type: Number, default: 280 }, // 主canvas的宽
    canvasHeight: { type: Number, default: 150 }, // 主canvas的高
    // 是否出现,由父级控制
    show: { type: Boolean, default: false },
    sliderSize: { type: Number, default: 35 }, // 滑块的大小
    successText: {
      type: String,
      default: '验证通过!'
    },
    failText: {
      type: String,
      default: '验证失败,请重试'
    },
    sliderText: {
      type: String,
      default: '拖动滑块完成拼图'
    },
    borderRadius: {
      type: Number,
      default: 10
    }
  },
  /** 私有数据 **/
  data() {
    return {
      image1: undefined, // 大图
      image2: undefined, // 小图
      mouseDown: false, // 鼠标是否在按钮上按下
      startWidth: 50, // 鼠标点下去时父级的width
      startX: 0, // 鼠标按下时的X
      newX: 0, // 鼠标当前的偏移X
      loading: true, // 是否正在加载中,主要是等图片onload
      isCanSlide: false, // 是否可以拉动滑动条
      error: false, // 图片加在失败会出现这个,提示用户手动刷新
      infoBoxShow: false, // 提示信息是否出现
      infoText: '', // 提示等信息
      infoBoxFail: false, // 是否验证失败
      timer1: null, // setTimout1
      closeDown: false, // 为了解决Mac上的click BUG
      isSuccess: false, // 验证成功
      resetSvg,
      isSubmit: false // 是否正在验证中
    }
  },
  /** 计算属性 **/
  computed: {
    // styleWidth是底部用户操作的滑块的父级,就是轨道在鼠标的作用下应该具有的宽度
    styleWidth() {
      const w = this.startWidth + this.newX - this.startX
      return w < this.sliderBaseSize
        ? this.sliderBaseSize
        : w > this.canvasWidth
          ? this.canvasWidth
          : w
    },
    // 处理一下sliderSize,弄成整数,以免计算有偏差
    sliderBaseSize() {
      return Math.max(
        Math.min(
          Math.round(this.sliderSize),
          Math.round(this.canvasWidth * 0.5)
        ),
        10
      )
    }
  },

  /** 监听 **/
  watch: {
    show(newV) {
      // 每次出现都应该重新初始化
      if (newV) {
        document.body.classList.add('vue-puzzle-overflow')
        this.reset()
      } else {
        document.body.classList.remove('vue-puzzle-overflow')
      }
    }
  },

  /** 生命周期 **/
  mounted() {
    document.body.appendChild(this.$el)
    document.addEventListener('mousemove', this.onRangeMouseMove, false)
    document.addEventListener('mouseup', this.onRangeMouseUp, false)

    document.addEventListener('touchmove', this.onRangeMouseMove, {
      passive: false
    })
    document.addEventListener('touchend', this.onRangeMouseUp, false)
    if (this.show) {
      document.body.classList.add('vue-puzzle-overflow')
      this.reset()
    }
  },
  beforeDestroy() {
    clearTimeout(this.timer1)
    document.body.removeChild(this.$el)
    document.removeEventListener('mousemove', this.onRangeMouseMove, false)
    document.removeEventListener('mouseup', this.onRangeMouseUp, false)

    document.removeEventListener('touchmove', this.onRangeMouseMove, {
      passive: false
    })
    document.removeEventListener('touchend', this.onRangeMouseUp, false)
  },

  /** 方法 **/
  methods: {
    // 关闭
    onClose() {
      if (!this.mouseDown) {
        clearTimeout(this.timer1)
        this.$emit('close')
      }
    },
    onCloseMouseDown() {
      this.closeDown = true
    },
    onCloseMouseUp() {
      if (this.closeDown) {
        this.onClose()
      }
      this.closeDown = false
    },
    // 鼠标按下准备拖动
    onRangeMouseDown(e) {
      if (this.isCanSlide) {
        this.mouseDown = true
        this.startWidth = this.$refs['range-slider'].clientWidth
        this.newX = e.clientX || e.changedTouches[0].clientX
        this.startX = e.clientX || e.changedTouches[0].clientX
      }
    },
    // 鼠标移动
    onRangeMouseMove(e) {
      if (this.mouseDown) {
        e.preventDefault()
        this.newX = e.clientX || e.changedTouches[0].clientX
      }
    },
    // 鼠标抬起
    onRangeMouseUp() {
      if (this.mouseDown) {
        this.mouseDown = false
        this.submit()
      }
    },
    /**
     * 开始进行
     */
    init() {
      this.loading = true
      this.isCanSlide = false
      captcha().then(res => {
        this.image1 = res.image1
        this.image2 = res.image2
        this.loading = false
        this.isCanSlide = true
        this.startTime = new Date().getTime()
      })
    },
    // 开始判定
    submit() {
      // 关闭拖动
      this.isCanSlide = false
      this.isSubmit = true
      const x = this.newX - this.startX
      this.loading = true
      captchaVerify({ x, width: this.canvasWidth }).then(res => {
      	this.isSubmit = false
        this.loading = false
        if (res.success) {
          // 成功
          this.infoText = this.successText
          this.infoBoxFail = false
          this.infoBoxShow = true
          this.isCanSlide = false
          this.isSuccess = true
          // 成功后准备关闭
          clearTimeout(this.timer1)
          this.timer1 = setTimeout(() => {
            // 成功的回调
            this.$emit('success', x)
          }, 800)
        } else {
          // 失败
          this.infoText = this.failText
          this.infoBoxFail = true
          this.infoBoxShow = true
          this.isCanSlide = false
          // 失败的回调
          this.$emit('fail', x)
          // 800ms后重置
          clearTimeout(this.timer1)
          this.timer1 = setTimeout(() => {
            this.reset()
          }, 800)
        }
      })
    },
    // 重置
    reset() {
      this.infoBoxFail = false
      this.infoBoxShow = false
      this.isCanSlide = true
      this.isSuccess = false
      this.startWidth = this.sliderBaseSize // 鼠标点下去时父级的width
      this.startX = 0 // 鼠标按下时的X
      this.newX = 0 // 鼠标当前的偏移X
      this.init()
    }
  }
}
script>
<style lang="scss">
.vue-puzzle-vcode {
  position: fixed;
  top: 0;
  left: 0;
  bottom: 0;
  right: 0;
  background-color: rgba(0, 0, 0, 0.3);
  z-index: 999;
  opacity: 0;
  pointer-events: none;
  transition: opacity 200ms;
  &.show_ {
    opacity: 1;
    pointer-events: auto;
  }
}
.vue-auth-box_ {
  position: absolute;
  top: 40%;
  left: 50%;
  transform: translate(-50%, -50%);
  padding: 20px;
  background: #fff;
  user-select: none;
  border-radius: 3px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
  .auth-body_ {
    position: relative;
    overflow: hidden;
    border-radius: 3px;
    .loading-box_ {
      position: absolute;
      top: 0;
      left: 0;
      bottom: 0;
      right: 0;
      background-color: rgba(0, 0, 0, 0.8);
      z-index: 20;
      opacity: 1;
      transition: opacity 200ms;
      display: flex;
      align-items: center;
      justify-content: center;
      &.hide_ {
        opacity: 0;
        pointer-events: none;
        .loading-gif_ {
          span {
            animation-play-state: paused;
          }
        }
      }
      .loading-gif_ {
        flex: none;
        height: 5px;
        line-height: 0;
        @keyframes load {
          0% {
            opacity: 1;
            transform: scale(1.3);
          }
          100% {
            opacity: 0.2;
            transform: scale(0.3);
          }
        }
        span {
          display: inline-block;
          width: 5px;
          height: 100%;
          margin-left: 2px;
          border-radius: 50%;
          background-color: #888;
          animation: load 1.04s ease infinite;
          &:nth-child(1) {
            margin-left: 0;
          }
          &:nth-child(2) {
            animation-delay: 0.13s;
          }
          &:nth-child(3) {
            animation-delay: 0.26s;
          }
          &:nth-child(4) {
            animation-delay: 0.39s;
          }
          &:nth-child(5) {
            animation-delay: 0.52s;
          }
        }
      }
    }
    .info-box_ {
      position: absolute;
      bottom: 0;
      left: 0;
      width: 100%;
      height: 24px;
      line-height: 24px;
      text-align: center;
      overflow: hidden;
      font-size: 13px;
      background-color: #83ce3f;
      opacity: 0;
      transform: translateY(24px);
      transition: all 200ms;
      color: #fff;
      z-index: 10;
      &.show {
        opacity: 0.95;
        transform: translateY(0);
      }
      &.fail {
        background-color: #ce594b;
      }
    }
    .auth-canvas2_ {
      position: absolute;
      top: 0;
      left: 0;
      z-index: 2;
    }
    .auth-canvas3_ {
      position: absolute;
      top: 0;
      left: 0;
      opacity: 0;
      transition: opacity 600ms;
      z-index: 3;
      &.show {
        opacity: 1;
      }
    }
    .flash_ {
      position: absolute;
      top: 0;
      left: 0;
      width: 30px;
      height: 100%;
      background-color: rgba(255, 255, 255, 0.1);
      z-index: 3;
      &.show {
        transition: transform 600ms;
      }
    }
    .reset_ {
      position: absolute;
      top: 2px;
      right: 2px;
      width: 35px;
      height: auto;
      z-index: 12;
      cursor: pointer;
      transition: transform 200ms;
      transform: rotate(0deg);
      &:hover {
        transform: rotate(-90deg);
      }
    }
  }
  .auth-control_ {
    .range-box {
      position: relative;
      width: 100%;
      background-color: #eef1f8;
      margin-top: 20px;
      border-radius: 3px;
      box-shadow: 0 0 8px rgba(240, 240, 240, 0.6) inset;
      .range-text {
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        font-size: 14px;
        color: #b7bcd1;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        text-align: center;
        width: 100%;
      }
      .range-slider {
        position: absolute;
        height: 100%;
        width: 50px;
        background-color: rgba(106, 160, 255, 0.8);
        border-radius: 3px;
        .range-btn {
          position: absolute;
          display: flex;
          align-items: center;
          justify-content: center;
          right: 0;
          width: 50px;
          height: 100%;
          background-color: #fff;
          border-radius: 3px;
          box-shadow: 0 0 4px #ccc;
          cursor: pointer;
          & > div {
            width: 0;
            height: 40%;

            transition: all 200ms;
            &:nth-child(2) {
              margin: 0 4px;
            }
            border: solid 1px #6aa0ff;
          }
          &:hover,
          &.isDown {
            & > div:first-child {
              border: solid 4px transparent;
              height: 0;
              border-right-color: #6aa0ff;
            }
            & > div:nth-child(2) {
              border-width: 3px;
              height: 0;
              border-radius: 3px;
              margin: 0 6px;
              border-right-color: #6aa0ff;
            }
            & > div:nth-child(3) {
              border: solid 4px transparent;
              height: 0;
              border-left-color: #6aa0ff;
            }
          }
        }
      }
    }
  }
}
.vue-puzzle-overflow {
  overflow: hidden !important;
}
style>

api

/**
 * 验证码
 * @returns {*}
 */
export function captcha() {
  return request({
    url: `/captcha/`,
    method: 'get'
  })
}

/**
 * 验证码验证
 * @returns {*}
 */
export function captchaVerify(data) {
  return request({
    url: `/captcha/verify`,
    method: 'post',
    headers: { 'Content-Type': 'application/json' },
    data
  })
}

好了,代码基本已经结束,reset.png图片找不到的,可以到vue-puzzle-vcode里面下载,或者替换成其他图片也是可以的。

最后成品

扩展GIf动态拼图

既然已经实现了png的拼图,我们来看一下gif动态拼图的可能性。
首先,gif格式不支持半透明的效果,也就是说小图的外阴影不能用了,会有白色的边,我们改成内阴影

看下效果,其实还行,就是处理速度有点慢。

总体的差别不多,使用多线程加速一下,否则多帧图像处理速度会比较慢。
下面是实现的代码,使用上没有太大的差别

import cn.hutool.core.img.gif.AnimatedGifEncoder;
import cn.hutool.core.img.gif.GifDecoder;
import cn.hutool.core.util.NumberUtil;
import com.jhlabs.image.ImageUtils;
import com.jhlabs.image.InvertAlphaFilter;
import com.jhlabs.image.ShadowFilter;
import lombok.Data;

import java.awt.*;
import java.awt.geom.Arc2D;
import java.awt.geom.GeneralPath;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.Random;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

/**
 * gif滑块验证码
 *
 * @author hexm
 * @date 2020/10/23
 */
@Data
public class PuzzleGifCaptcha {
    /** 默认宽度,用来计算阴影基本长度 */
    private static final int DEFAULT_WIDTH = 280;
    /** 随机数 */
    private static final Random RANDOM = new Random();
    /** 蒙版 */
    private static Color color = new Color(255, 255, 255, 204);
    /** alpha通道过滤器 */
    private static InvertAlphaFilter alphaFilter = new InvertAlphaFilter();
    /** 边距 */
    private static int margin = 10;

    /** 生成图片的宽度 */
    private int width = DEFAULT_WIDTH;
    /** 生成图片高度 */
    private int height = 150;
    /** x轴的坐标,由算法决定 */
    private int x;
    /** y轴的坐标,由算法决定 */
    private int y;
    /** 拼图长宽 */
    private int vwh = 10 * 3;
    /** 原图 */
    private InputStream image;
    /** 大图 */
    private ByteArrayOutputStream artwork;
    /** 小图 */
    private ByteArrayOutputStream vacancy;
    /** 是否注重速度 */
    private boolean isFast = false;
    /** 小图描边颜色 */
    private Color vacancyBorderColor;
    /** 小图描边线条的宽度 */
    private float vacancyBorderWidth = 2.5f;
    /** 主图描边的颜色 */
    private Color artworkBorderColor;
    /** 主图描边线条的宽度 */
    private float artworkBorderWidth = 5f;
    /**
     * 最高放大倍数,合理的放大倍数可以使图像平滑且提高渲染速度
     * 当isFast为false时,此属性生效
     * 放大倍数越高,生成的图像越平滑,受原始图片大小的影响。
     */
    private double maxRatio = 2;
    /**
     * 画质
     *
     * @see Image#SCALE_DEFAULT
     * @see Image#SCALE_FAST
     * @see Image#SCALE_SMOOTH
     * @see Image#SCALE_REPLICATE
     * @see Image#SCALE_AREA_AVERAGING
     */
    private int imageQuality = Image.SCALE_SMOOTH;
    /** 开启多线程处理 */
    private boolean multithreading = true;

    /**
     * 从流中读取图片
     *
     * @param is
     */
    public PuzzleGifCaptcha(InputStream is) {
        image = is;
    }

    /**
     * 从文件中读取图片
     *
     * @param fileName
     */
    public PuzzleGifCaptcha(String fileName) throws FileNotFoundException {
        image = new FileInputStream(fileName);
    }

    /**
     * 生成随机x、y坐标
     */
    private void init() {
        if (x == 0 || y == 0) {
            this.x = random(vwh, this.width - vwh - margin);
            this.y = random(margin, this.height - vwh - margin);
        }
    }

    /**
     * 执行
     */
    public void run() throws IOException {
        init();
        GifDecoder decoder = new GifDecoder();
        int status = decoder.read(image);
        if (status != GifDecoder.STATUS_OK) {
            throw new IOException("read image error!");
        }
        AnimatedGifEncoder e = new AnimatedGifEncoder();
        AnimatedGifEncoder e2 = new AnimatedGifEncoder();
        ByteArrayOutputStream b1 = new ByteArrayOutputStream();
        ByteArrayOutputStream b2 = new ByteArrayOutputStream();

        //保存的目标图片
        e.start(b1);
        e2.start(b2);
        e.setRepeat(decoder.getLoopCount());
        e2.setRepeat(decoder.getLoopCount());
        e.setDelay(decoder.getDelay(0));
        e2.setDelay(decoder.getDelay(0));
        e2.setTransparent(Color.white);
        if (multithreading) {
            // 多线程
            CompletableFuture<BufferedImage[]>[] futures = new CompletableFuture[decoder.getFrameCount()];
            for (int i = 0; i < decoder.getFrameCount(); i++) {
                int finalI = i;
                futures[i] = CompletableFuture.supplyAsync(() -> {
                    BufferedImage image = decoder.getFrame(finalI);
                    //可以加入对图片的处理,比如缩放,压缩质量
                    return process(image);
                });
            }
            CompletableFuture.allOf(futures).join();
            for (CompletableFuture<BufferedImage[]> future : futures) {
                try {
                    BufferedImage[] bufferedImages = future.get();
                    e.addFrame(bufferedImages[0]);
                    e2.addFrame(bufferedImages[1]);
                } catch (InterruptedException | ExecutionException interruptedException) {
                    interruptedException.printStackTrace();
                }
            }
        } else {
            // 单线程
            for (int i = 0; i < decoder.getFrameCount(); i++) {
                BufferedImage image = decoder.getFrame(i);
                //可以加入对图片的处理,比如缩放,压缩质量
                BufferedImage[] bufferedImages = process(image);
                e.addFrame(bufferedImages[0]);
                e2.addFrame(bufferedImages[1]);
            }
        }
        e.finish();
        e2.finish();
        this.artwork = b1;
        this.vacancy = b2;
        if (image != null) {
            image.close();
        }
    }

    private BufferedImage[] process(Image image) {
        // 缩略图
        Image thumbnail;
        GeneralPath path;
        int realW = image.getWidth(null);
        int realH = image.getHeight(null);
        int w = realW, h = realH;
        double wScale = 1, hScale = 1;
        // 如果原始图片比执行的图片还小,则先拉伸再裁剪
        boolean isFast = this.isFast || w < this.width || h < this.height;
        if (isFast) {
            // 缩放,使用平滑模式
            thumbnail = image.getScaledInstance(width, height, imageQuality);
            path = paintBrick(1, 1);
            w = this.width;
            h = this.height;
        } else {
            // 缩小到一定的宽高,保证裁剪的圆润
            boolean flag = false;
            if (realW > width * maxRatio) {
                // 不超过最大倍数且不超过原始图片的宽
                w = Math.min((int) (width * maxRatio), realW);
                flag = true;
            }
            if (realH > height * maxRatio) {
                h = Math.min((int) (height * maxRatio), realH);
                flag = true;
            }
            if (flag) {
                // 若放大倍数生效,则缩小图片至最高放大倍数,再进行裁剪
                thumbnail = image.getScaledInstance(w, h, imageQuality);
            } else {
                thumbnail = image;
            }
            hScale = NumberUtil.div(h, height);
            wScale = NumberUtil.div(w, width);
            path = paintBrick(wScale, hScale);
        }

        // 创建阴影过滤器
        float radius = 5 * ((float) w / DEFAULT_WIDTH) * (float) wScale;
        ShadowFilter shadowFilter = new ShadowFilter(radius, 2 * (float) wScale, -1 * (float) hScale, 0.8f);

        // 创建空白的图片
        BufferedImage artwork = translucent(new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB));
        BufferedImage localVacancy = translucent(new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB));
        // 画小图
        Graphics2D vg = localVacancy.createGraphics();
        // 抗锯齿
        vg.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
        // 设置画图路径范围
        vg.setClip(path);
        // 将区域中的图像画到小图中
        vg.drawImage(thumbnail, null, null);
        //描边
        if (vacancyBorderColor != null) {
            vg.setColor(vacancyBorderColor);
            vg.setStroke(new BasicStroke(vacancyBorderWidth));
            vg.draw(path);
        }
        // 画上基于小图的内阴影,先反转alpha通道,然后创建阴影
        BufferedImage shadowImage = shadowFilter.filter(alphaFilter.filter(localVacancy, null), null);
        // 画上内阴影小图
        vg.drawImage(shadowImage, null, null);
        // 释放图像
        vg.dispose();

        // 画大图
        // 创建画笔
        Graphics2D g = artwork.createGraphics();
        // 抗锯齿
        g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
        // 画上图片
        g.drawImage(thumbnail, null, null);
        // 设置画图路径范围
        g.setClip(path);
        // 填充缺口透明度 颜色混合,不透明在上
        g.setComposite(AlphaComposite.SrcAtop);
        // 填充一层白色的透明蒙版,透明度越高,白色越深 alpha:0-255
        g.setColor(color);
        g.fill(path);
        //描边
        if (artworkBorderColor != null) {
            g.setColor(artworkBorderColor);
            g.setStroke(new BasicStroke(artworkBorderWidth));
            g.draw(path);
        }
        // 画上内阴影小图
        g.drawImage(shadowImage, null, null);
        // 释放图像
        g.dispose();

        // 裁剪掉多余的透明背景
        localVacancy = ImageUtils.getSubimage(localVacancy, (int) (x * wScale), 0, (int) Math.ceil(path.getBounds().getWidth()), h);

        BufferedImage[] bufferedImages = new BufferedImage[2];
        if (isFast) {
            // 添加阴影
            bufferedImages[0] = localVacancy;
            bufferedImages[1] = artwork;
        } else {
            // 大图缩放
            bufferedImages[0] = ImageUtils.convertImageToARGB(artwork.getScaledInstance(width, height, imageQuality));
            // 缩放时,除以放大比例
            bufferedImages[1] = ImageUtils.convertImageToARGB(localVacancy.getScaledInstance((int) (path.getBounds().getWidth() / wScale), height, imageQuality));
        }
        return bufferedImages;
    }

    /**
     * 绘制拼图块的路径
     *
     * @param xScale x轴放大比例
     * @param yScale y轴放大比例
     * @return
     */
    private GeneralPath paintBrick(double xScale, double yScale) {
        double x = this.x * xScale;
        double y = this.y * yScale;
        // 直线移动的基础距离
        double hMoveL = vwh / 3f * yScale;
        double wMoveL = vwh / 3f * xScale;
        GeneralPath path = new GeneralPath();
        path.moveTo(x, y);
        path.lineTo(x + wMoveL, y);
        // 上面的圆弧正东方向0°,顺时针负数,逆时针正数
        path.append(arc(x + wMoveL, y - hMoveL / 2, wMoveL, hMoveL, 180, -180), true);
        path.lineTo(x + wMoveL * 3, y);
        path.lineTo(x + wMoveL * 3, y + hMoveL);
        // 右边的圆弧
        path.append(arc(x + wMoveL * 2 + wMoveL / 2, y + hMoveL, wMoveL, hMoveL, 90, -180), true);
        path.lineTo(x + wMoveL * 3, y + hMoveL * 3);
        path.lineTo(x, y + hMoveL * 3);
        path.lineTo(x, y + hMoveL * 2);
        // 左边的内圆弧
        path.append(arc(x - wMoveL / 2, y + hMoveL, wMoveL, hMoveL, -90, 180), true);
        path.lineTo(x, y);
        path.closePath();
        return path;
    }

    /**
     * 绘制圆形、圆弧或者是椭圆形
     * 正东方向0°,顺时针负数,逆时针正数
     *
     * @param x      左上角的x坐标
     * @param y      左上角的y坐标
     * @param w      宽
     * @param h      高
     * @param start  开始的角度
     * @param extent 结束的角度
     * @return
     */
    private Arc2D arc(double x, double y, double w, double h, double start, double extent) {
        return new Arc2D.Double(x, y, w, h, start, extent, Arc2D.OPEN);
    }

    /**
     * 透明背景
     *
     * @param bufferedImage
     * @return
     */
    private BufferedImage translucent(BufferedImage bufferedImage) {
        Graphics2D g = bufferedImage.createGraphics();
        bufferedImage = g.getDeviceConfiguration().createCompatibleImage(bufferedImage.getWidth(), bufferedImage.getHeight(), Transparency.TRANSLUCENT);
        g.dispose();
        return bufferedImage;
    }

    /**
     * 随机数
     *
     * @param min
     * @param max
     * @return
     */
    private static int random(int min, int max) {
        return RANDOM.ints(min, max + 1).findFirst().getAsInt();
    }
}

引用

  1. 纯前端验证码:https://github.com/javaLuo/vue-puzzle-vcode
  2. 源码示例:https://github.com/yixiaco/puzzle_captcha

你可能感兴趣的:(java,java,vue.js)