仅用Nuxt3全栈复刻ChatGPT官网的3D图形验证码!

864 阅读3分钟
sample-gpt.png

近期使用ChatGPT频繁出现图形验证码真的很烦躁!!! but这种形式的图形验证码还是第一次见呢,索性就自己实现一下了

代码已开源 github

先看效果

output.gif

实现思路

验证码是一个3D场景渲染成多张图片,所以第一个想到的就是threeJS库.由于three依赖浏览器环境用node后端渲染就比较复杂而且调试麻烦,所以渲染页我用vue3 canvas threejs来写. 然后使用node的puppeteer库控制headless浏览器(无UI的浏览器)来运行渲染页然后面通过监听页面log获取到生成的验证码图片,最后通过http api返回. 项目我直接使用nuxt3在一个工程里实现前后端生成校验验证码.

下面上核心代码(注解很详细)

1.使用 uuid + multiavatar + canvas 生成验证码地面贴图

<script setup lang="ts">
import { v4 as uuidv4 } from 'uuid'
import multiavatar from "@multiavatar/multiavatar";
//生成图标验证码地面贴图
const textureCanvas = ref();
const ctxRef = ref<any>();
const config = {
  width:200,height:200,
  iconSize:50,//图标尺寸
  iconNum:5,//图标数量
}

const emit = defineEmits(['on-texture','on-sure-img'])//生成贴图和正确提示的图片
const sceneIsInit = useState('sceneIsInit',()=>false);//3d场景是否初始化

watchEffect(()=>{
  ctxRef.value = textureCanvas.value?.getContext('2d');
  if(sceneIsInit.value&&ctxRef.value)render();//场景初始化后渲染
})

onMounted(()=>{
  sceneIsInit.value=true
})

async function render(){
  const ctx = ctxRef.value;
  //ctx.clearRect(0, 0, textureCanvas.value.width, textureCanvas.value.height);//清空画布
  const positions = randomIconPosition();//随机生成icon坐标点
  drawBezierPath(positions); // 先绘制曲线
  await drawIcons(positions); // 然后绘制图标
  function getRandomIndex(array:any[]) {
    return Math.floor(Math.random() * array.length);
  }
  const sureIndex = getRandomIndex(positions);//随机正确坐标点
  const surePos = positions[sureIndex]
  //在three场景中的坐标百分比
  const scenePositions = positions.map(pos=>{ return { x:pos.x/config.width + config.iconSize/config.width/5 , y:pos.y/config.height + config.iconSize/config.height/5 } })
  emit('on-texture',scenePositions,textureCanvas.value);//回调让场景把canvas绘制到地面
  setTimeout(()=>{//延迟绘制正确坐标提示图
    drawSureHint(surePos.x+25, surePos.y+25, 30,3);
    const sureHintImg = textureCanvas.value.toDataURL('image/png',0.5);
    emit('on-sure-img',sureIndex,sureHintImg)
  },10)
}


// 检查两个位置是否重叠
function isOverlapping(pos1:any, pos2:any) {
  const buffer = 5; // 防止图标太近
  return !(pos1.x + config.iconSize + buffer < pos2.x ||
      pos1.x > pos2.x + config.iconSize + buffer ||
      pos1.y + config.iconSize + buffer < pos2.y ||
      pos1.y > pos2.y + config.iconSize + buffer);
}

// 生成随机位置,确保不重叠
function randomIconPosition() {

  const positions:any[] = [];
  while (positions.length < config.iconNum) {
    const x = Math.random() * (config.width - config.iconSize);
    const y = Math.random() * (config.height - config.iconSize);
    const newPos = { x, y };
    if (!positions.some(pos => isOverlapping(pos, newPos))) {
      positions.push(newPos);
    }
  }
  return positions;
}

// 计算贝塞尔曲线控制点
function getControlPoints(p0:any, p1:any, p2:any, t:any) {
  const d01 = Math.hypot(p1.x - p0.x, p1.y - p0.y);
  const d12 = Math.hypot(p2.x - p1.x, p2.y - p1.y);

  const fa = t * d01 / (d01 + d12);   // scaling factor for triangle Ta
  const fb = t * d12 / (d01 + d12);   // ditto for Tb, simplifies to fb=t-fa

  const p1x = p1.x - fa * (p2.x - p0.x);    // x2-x0 is the width of triangle Ta
  const p1y = p1.y - fa * (p2.y - p0.y);    // y2-y0 is the height of Ta
  const p2x = p1.x + fb * (p2.x - p0.x);    // same for triangle Tb
  const p2y = p1.y + fb * (p2.y - p0.y);

  return [{x: p1x, y: p1y}, {x: p2x, y: p2y}];
}


// 绘制多阶贝塞尔曲线
function drawBezierPath(positions:any[]) {
  const ctx = ctxRef.value;
  ctx.strokeStyle = 'rgba(13,13,13,0.89)';
  ctx.lineWidth = 6;
  ctx.beginPath();
  ctx.moveTo(positions[0].x + config.iconSize / 2, positions[0].y + config.iconSize / 2);
  for (let i = 0; i < positions.length - 1; i++) {
    const p0 = positions[i];
    const p1 = positions[i + 1];
    const cp = getControlPoints(p0, p1, positions[i + 2] || positions[0], 0.3);
    ctx.bezierCurveTo(cp[0].x, cp[0].y, cp[1].x, cp[1].y, p1.x + config.iconSize / 2, p1.y + config.iconSize / 2);
  }
  ctx.stroke();
}


// 绘制图标
async function drawIcons(positions:any[]) {

  //使用uuid + multiavatar 生成随机svg图标 转为base64加载
  async function loadIcon(pos:any){
    return new Promise((ok)=>{
      let iconImage = new Image();
      iconImage.src = `data:image/svg+xml;base64,${btoa(multiavatar(uuidv4()))}`
      iconImage.addEventListener('load',()=>{
        pos.icon = iconImage;
        ok(pos)
      })
    })
  }

  const loads:any[] = []
  positions.forEach(pos=>{
    loads.push(loadIcon(pos))
  })

  await Promise.all(loads)//图标全部加载结束后

  positions.forEach(pos => {
    // 加载图标图片
    ctxRef.value.drawImage(pos.icon, pos.x, pos.y, config.iconSize, config.iconSize);
  });
}

//绘制正确位置的红圈提示
function drawSureHint(x:number, y:number, radius:number, lineWidth:number) {
  const ctx = ctxRef.value;
  ctx.beginPath();
  ctx.arc(x, y, radius, 0, Math.PI * 2);
  ctx.strokeStyle = 'red';
  ctx.lineWidth = lineWidth;
  ctx.stroke();
  ctx.closePath();
}



</script>

<template>
  <canvas ref="textureCanvas" :width="config.width" :height="config.height"></canvas>
</template>

<style scoped>

</style>

2.使用 threejs + canvas 渲染验证码多个位置视角的截图

<script setup lang="ts">

import * as THREE from 'three'
import {GLTFLoader} from 'three/examples/jsm/loaders/GLTFLoader'
import {getRandomInt, timeout} from "~/utils";
import {useRoute} from "#app";

const query = useRoute().query;

const sceneCanvas = ref<any>()
let renderer: any, scene: any, camera: any, robot: any;
const sceneIsInit = useState('sceneIsInit', () => false);

function callServer(event: 'render-end', any: any) {//通知服务端渲染结束
  const call = JSON.stringify({event, data: any});
  console.log(call)
  return call;
}



onMounted(init)


function init() {

  scene = new THREE.Scene();
  const clock = new THREE.Clock();
  // 初始化相机
  camera = new THREE.PerspectiveCamera(80, sceneCanvas.value?.clientWidth / sceneCanvas.value?.clientHeight, 0.1, 1000);
  camera.position.set(10, 10, 2);
  camera.lookAt(0, 0, 0);

  // 渲染器
  renderer = new THREE.WebGLRenderer({
    canvas: sceneCanvas.value,
    preserveDrawingBuffer: true,
    alpha: true,
  });

  // 灯光
  const light = new THREE.PointLight(0xffffff, 1, 100);
  light.position.set(10, 10, 10);
  scene.add(light);
  const hemiLight = new THREE.HemisphereLight(0xffffff, 0x8d8d8d, 3);
  hemiLight.position.set(0, 20, 0);
  scene.add(hemiLight);
  const dirLight = new THREE.DirectionalLight(0xffffff, 3);
  dirLight.position.set(0, 20, 10);
  scene.add(dirLight);


  // 加载地面纹理
  const groundTexture = new THREE.TextureLoader().load('ground.png');
  // THREE.RepeatWrapping:平铺重复。
  groundTexture.wrapS = groundTexture.wrapT = THREE.RepeatWrapping;
  // 设置重复次数
  groundTexture.repeat.set(5, 5)

  // 创建地面材质
  const groundMaterial = new THREE.MeshBasicMaterial({map: groundTexture});

  // 创建圆形地面
  const radius = 80; // 圆形地面的半径
  const segments = 64; // 圆形地面的分段数,分段数越高,圆形越平滑
  const groundGeometry = new THREE.CircleGeometry(radius, segments);

// 创建地面网格并添加到场景中
  const ground = new THREE.Mesh(groundGeometry, groundMaterial);
  ground.rotation.x = -Math.PI / 2; // 将圆形地面旋转到水平面
  scene.add(ground);


  // 加载全景图作为天空盒
  const textureLoader = new THREE.TextureLoader();
  textureLoader.load('sky.jpg', function (texture) {
    const geometry = new THREE.SphereGeometry(500, 60, 40)
    texture.wrapS = THREE.RepeatWrapping
    texture.wrapT = THREE.ClampToEdgeWrapping
    texture.repeat.x = -1 // 水平方向翻转

    const material = new THREE.MeshBasicMaterial({
      map: texture,
      side: THREE.BackSide // 确保材质应用在球体内部
    })

    const skybox = new THREE.Mesh(geometry, material)
    scene.add(skybox)
  })


  //加载机器人
  const loader = new GLTFLoader();
  let mixer: any;
  loader.load('RobotExpressive.glb', function (gltf: any) {
    robot = gltf.scene;
    const scale = 0.8;
    robot.scale.x = scale
    robot.scale.y = scale
    robot.scale.z = scale
    robot.rotation.y = getRandomInt(0, 3600)//随机旋转角度
    scene.add(robot);
    const anim = gltf.animations[0]
    mixer = new THREE.AnimationMixer(robot);
    const action = mixer.clipAction(anim);
    action.clampWhenFinished = true;
    action.loop = THREE.LoopRepeat;
    action.play()
    sceneIsInit.value = true;//场景初始化成功
  });


  // 渲染循环
  function animate() {
    const dt = clock.getDelta();
    if (mixer) mixer.update(dt);
    requestAnimationFrame(animate);
    // scene.rotation.y += 0.01;
    renderer.render(scene, camera);
  }

  animate();
}

const taskResult: any =
    {
      sureIndex: null,//正确图片下标
      sureTipImage: null,//正确提示图
      images: null,//所有图
    }
const preview = ref<typeof taskResult>()

function taskEnd(){//渲染结束通知后端
  if(taskResult.images&&taskResult.sureTipImage&&taskResult.sureIndex>=0){
    callServer('render-end',taskResult)
    preview.value = taskResult;
    //console.log('任务成功')
  }else{
    //console.log('任务失败',taskResult)
  }
}

const positionsImages = ref<any[]>([]);//机器人在所有图标位置的截图
async function onTexture(positions: any[], canvas: any) {
  //中心为0.0 贴图模型宽*坐标百分比 - 宽/2
  const size = 16
  const texture = new THREE.CanvasTexture(canvas);
  const geometry = new THREE.BoxGeometry(size, size, 0.1);
  const material = new THREE.MeshBasicMaterial({map: texture, transparent: true});
  const authCube = new THREE.Mesh(geometry, material);

  authCube.rotation.x = -Math.PI / 2; // 使其平行于地面
  authCube.position.set(0, 0.02, 0); // 稍微抬起以避免与地面Z冲突
  scene.add(authCube);
  await timeout(20)
  for (const pos of positions) {
    const mx = pos.x * size - size / 2
    const my = pos.y * size - size / 2
    // console.log('robot位置:', mx, my, robot);
    robot?.position?.set(mx, 0, my);
    scene.rotation.y += 60;
    await timeout(20);
    //等待渲染保存压缩截图
    positionsImages.value.push(renderer.domElement.toDataURL('image/jpeg', 0.8));
    console.log('生成',pos)
  }

  taskResult.images = positionsImages.value;
  taskEnd();
}

function onSureImg(index:number,sureImg:any) {
  taskResult.sureIndex=index;
  taskResult.sureTipImage = sureImg;
  taskEnd();
}


</script>

<template>
  <div class="box">

<!--    <h1 >答案提示图</h1>-->
<!--    <img :src="preview?.sureTipImage" v-if="!query.headless">-->
<!--    <h1 >所有位置图</h1>-->
<!--    <div  class="pv"><img :src="img" v-for="img in preview?.images"></div>-->

    <h1>验证码贴图</h1>
    <captcha-texture @on-texture="onTexture" @on-sure-img="onSureImg"></captcha-texture>
    <h1>场景渲染</h1>
    <canvas ref="sceneCanvas" width="300" height="300"></canvas>
  </div>

</template>

<style scoped>
.box{
  display: flex;
  flex-direction: column;
  width: fit-content;
  max-width: 300px;
}

.pv{
  display: flex;
  flex-direction: row;
  gap: 5px;
}

</style>

3.nuxt server 添加http接口 通过puppeteer在后端生成验证码

import puppeteer, {Page} from "puppeteer";
import { timeout } from "~/utils";
import { v4 as uuidv4 } from 'uuid'
import {captchaCache, errRet, sucRet} from "~/server/utils";

//node headless浏览器渲染验证码图片

let browser:any;
puppeteer.launch({ headless : true }).then(res=>browser=res);//headless:false 预览渲染过程
export default defineEventHandler(async (event) => {

    let ts = Date.now();
    function logTime(tip:string){
        console.log(`${tip}耗时`,Date.now()-ts);
        ts = Date.now();
    }
    // const browser = await puppeteer.launch({ headless : true })//
    if(!browser)return errRet(event,'未初始化');
    const page = await browser.newPage();
    await page.goto(`http://localhost:3000/render`);
    logTime('打开页面')
    let ret:any
    page.on('console', (msg:any) => {
        try{
            // console.log('捕获事件',msg.text())
            const call = JSON.parse(msg.text())
            if(!call?.event)return;
            switch (call.event){
                case 'render-end':
                    ret = call.data;
                    break;
            }
        }catch (e){
            //console.log('异常',e)
        }
    });

    let time = 0
    while (!ret){
        await timeout(50);
        time+=50;
        if(time>=7000){
            await page.close()
            return errRet(event,'生成超时');//生成超时
        }
    }
    logTime('生成成功')
    await page.close()
    logTime('关闭页面')
    ret = { id:uuidv4() , ...ret }
    captchaCache.set(ret.id,ret);//验证码信息存到内存 生产存到redis或数据库
    return sucRet(event,{ ...ret , sureIndex:null });
})