近期使用ChatGPT频繁出现图形验证码真的很烦躁!!! but这种形式的图形验证码还是第一次见呢,索性就自己实现一下了
代码已开源 github
先看效果
实现思路
验证码是一个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 });
})