从 PHP 到 AI + Golang,程序员自救转型手记(二十):前端点选验证码组件实现

从 PHP 到 AI + Golang,程序员自救转型手记(二十):前端点选验证码组件实现 这是一个系列 Blog作者将以一个 PHP 全栈工程师的身份利用 AI 工具claude code、codex、deepseek、豆包等从零开始学习 golang 语言并最终完成 ai-go-mallgithub | gitee开源项目的制作全程记录分享。在上一期我们已经完成 “点选验证码包逐行目检”本期将完成前端点选验证码组件实现前端点选验证码组件实现原理这里额外讲一下点选验证码的流程和原理服务端预设了多张350x200的背景图片服务端准备了 ICON带中文名称的图标文件、中文文字、英文大写字母 作为验证元素服务端随机抽取 1 张背景图片和 2 个 正确的元素2 个用于混淆的元素将它们以随机的角度和颜色绘制在单张图片上并返回图片的base64至前端图片大概长这样实际使用场景中每一次刷新验证码的背景图、元素都会改变人类可以轻松找到正确元素并完成点击程序记录点击坐标和数据库中的正确元素坐标对比最终达到区分人类与机器的目的。为尽可能安全所以我们后续还需要增加接口节流功能防止机器快速且暴力的获取到所有可用的背景和元素然后还建议开发者定期更换背景和元素等。以登录接口为例验证数据检查一般会分为两步前端预检将点选数据发送至/common/captcha/verify接口接口返回预检结果正确则继续流程失败则刷新验证码需用户重新操作。验证码预检正确时验证码组件内会调用callback(captchaData)继续流程如下登录内接口需要接受组件回调的captchaData参数服务端对其进行二次验证后才执行实际的业务逻辑即第 1 步的预检此时应被认为是不可靠的不能因为有预验就直接执行登录接口内的密码验证等工作。clickCaptcha((captchaData){// 将点选数据 captchaData通过 adminLogin 请求函数发送到服务端adminLogin({...loginForm,captcha:captchaData}).then((res){adminInfo.dataFill(res.data.data,false)router.push(/admin)})})// 服务端调用 captcha.Check第二个参数传递 true 表示验证后彻底删除验证码ifok,err:captcha.Check(req.Captcha,true);!ok{iferr!nil{returnnil,err}returnnil,errors.New(验证码错误)}// 验证通过后才执行密码验证等登录逻辑...前端组件实现前端这边只需要渲染服务端提供的图片然后记录好用户点击了图片的那几个点即可后端会完成点击坐标的正确性及点击顺序的验证验证数据检查。BuildAdmin/web 已经实现了 clickCaptcha 组件这里直接先拷贝过来让 AI 完成点选验证码组件和服务端接口的整合把能发现的明显问题都描述一下即可优化我复制过来的web\src\components\clickCaptcha组件使其适用于当前项目建立缺少的语言翻译建立缺少的接口请求函数或全局样式完成获取验证码和前端预验证接口的对接即/common/captcha/create和/common/captcha/verify纯前端代码而且是技术栈相同的组件迁移AI 做起来得心应手最终做出来最大的问题有验证数据格式改变BuildAdmin 原始的 clickCaptcha 组件中点选数据是拼接为了字符串进行传输大概格式如1x,1y;2x,2y;350,200;拼接为字符串再传输至服务端验证这一点本身没有任何问题不过本项目服务端的验证数据检查函数本身不支持这种字符串的解析基于四层架构的设计我们应该在服务端完成解析然后传递给验证数据检查函数由于未来可能会有不少接口使用点选验证码且考虑到这种字符串可读性很差也没有实际上的加密效果在本项目中我们将其改为验证数据检查函数直接支持的json格式并在前端定义好数据类型如下/** * 点击坐标 */exportinterfaceClickPoint{x:numbery:numberelement:string}/** * 验证请求数据 */exportinterfaceClickRequest{key:stringclicks:ClickPoint[]// 渲染图片宽度rendered_width:number// 渲染图片高度rendered_height:number}现在起验证码预检接口接受的是如上验证请求数据格式数据未来使用点选验证码的接口也都接受该格式数据以便服务端使用captcha.Check(req.Captcha, true)直接复验。最终完整代码// src\components\clickCaptcha\index.ts 文件import{createVNode,render}fromvueimportClickCaptchaConstructorfrom./index.vue/** * 点击坐标 */exportinterfaceClickPoint{x:numbery:numberelement:string}/** * 验证请求数据 */exportinterfaceClickRequest{key:stringclicks:ClickPoint[]// 渲染图片宽度rendered_width:number// 渲染图片高度rendered_height:number}/** * 验证成功回调 */exporttypeClickCaptchaCallback(data:ClickRequest)voidinterfaceClickCaptchaOptions{class?:stringerror?:stringsuccess?:string// 自定义 API 基础 URL默认使用 VITE_AXIOS_BASE_URLapiBaseURL?:string}/** * 弹出点选验证码 * param callback 验证成功的回调 */constclickCaptcha(callback?:ClickCaptchaCallback,options:ClickCaptchaOptions{}){constcontainerdocument.createElement(div)constvnodecreateVNode(ClickCaptchaConstructor,{callback,...options,onDestroy:(){render(null,container)},})render(vnode,container)document.body.appendChild(container.firstElementChild!)}exportinterfacePropsextendsClickCaptchaOptions{callback?:ClickCaptchaCallback}exportdefaultclickCaptcha!-- src\components\clickCaptcha\index.vue 文件 -- template div div classai-go-click-captcha :classprops.class div v-ifstate.loading classloading{{ i18n.global.t(common.loading) }}.../div div v-else classcaptcha-img-box img refcaptchaImgRef classcaptcha-img :srcstate.captcha.image_base64 :alti18n.global.t(common.Captcha loading failed, please click refresh button) click.preventonRecord($event) / span v-for(item, index) in state.clicks :keyindex classstep clickonCancelRecord(index) :styleleft:${item.x - 13}px;top:${item.y - 13}px {{ index 1 }} /span /div div classcaptcha-prompt v-ifstate.tip {{ state.tip }} /div div v-else classcaptcha-prompt {{ i18n.global.t(common.Please click) }} span v-for(text, index) in state.captcha.elements :keyindex :classstate.clicks.length index ? clicked : {{ text }} /span /div div classcaptcha-refresh-box div classcaptcha-refresh-line captcha-refresh-line-l/div span classcaptcha-refresh-btn :titlei18n.global.t(common.refresh) clickload⟳/span div classcaptcha-refresh-line captcha-refresh-line-r/div /div /div div classai-go-mask clickonClose/div /div /template script setup langts import { reactive, ref } from vue import { checkClickCaptcha, getClickCaptcha } from //api/common import type { ClickPoint, ClickRequest, Props } from //components/clickCaptcha/index import i18n from //lang import { SYSTEM_ZINDEX } from //stores/constant/common const props withDefaults(definePropsProps(), { class: , callback: () {}, error: i18n.global.t(common.The correct area is not clicked, please try again!), success: i18n.global.t(common.Verification is successful!), }) const captchaImgRef refHTMLImageElement | null(null) const state reactive({ tip: , loading: true, clicks: [] as ClickPoint[], captcha: { key: , elements: [] as string[], image_width: 350, image_height: 200, image_base64: , }, }) const emits defineEmits{ (e: destroy): void }() const load () { state.loading true getClickCaptcha(props.apiBaseURL) .then((res) { state.tip state.clicks [] state.captcha res.data.data }) .finally(() { state.loading false }) } const onRecord (event: MouseEvent) { if (state.clicks.length state.captcha.elements.length) { state.clicks.push({ x: event.offsetX, y: event.offsetY, element: state.captcha.elements[state.clicks.length], }) if (state.clicks.length state.captcha.elements.length) { const data: ClickRequest { key: state.captcha.key, clicks: [...state.clicks], rendered_width: captchaImgRef.value!.width, rendered_height: captchaImgRef.value!.height, } checkClickCaptcha(data, props.apiBaseURL) .then(() { state.tip props.success setTimeout(() { props.callback?.(data) onClose() }, 1500) }) .catch(() { state.tip props.error setTimeout(() { load() }, 1500) }) } } } const onCancelRecord (index: number) { state.clicks.splice(index, 1) } const onClose () { emits(destroy) } load() /script style scoped langscss .ai-go-click-captcha { padding: 12px; border: 1px solid var(--el-border-color-extra-light); background-color: var(--el-color-white); position: fixed; z-index: v-bind(SYSTEM_ZINDEX); left: calc(50% - v-bind(state.captcha.image_width 24) / 2 * 1px); top: calc(50% - v-bind(state.captcha.image_height 200) / 2 * 1px); border-radius: 10px; box-shadow: 0 0 0 1px hsla(0, 0%, 100%, 0.3) inset, 0 0.5em 1em rgba(0, 0, 0, 0.6); .loading { color: var(--el-color-info); width: 350px; text-align: center; line-height: 200px; } .captcha-img-box { position: relative; .captcha-img { width: v-bind(state.captcha.image_width) px; height: v-bind(state.captcha.image_height) px; border: none; cursor: pointer; } .step { box-sizing: border-box; position: absolute; width: 20px; height: 20px; line-height: 20px; font-size: var(--el-font-size-small); font-weight: bold; text-align: center; color: var(--el-color-white); border: 1px solid var(--el-border-color-extra-light); background-color: var(--el-color-primary); border-radius: 30px; box-shadow: 0 0 10px var(--el-color-white); user-select: none; cursor: pointer; } } .captcha-prompt { height: 40px; line-height: 40px; font-size: var(--el-font-size-base); text-align: center; color: var(--el-color-info); span { margin-left: 10px; font-size: var(--el-font-size-medium); font-weight: bold; color: var(--el-color-error); .clicked { color: var(--el-color-primary); } } } .captcha-refresh-box { position: relative; margin-top: 10px; .captcha-refresh-line { position: absolute; top: 16px; width: 140px; height: 1px; background-color: #ccc; } .captcha-refresh-line-l { left: 5px; } .captcha-refresh-line-r { right: 5px; } .captcha-refresh-btn { cursor: pointer; display: block; margin: 0 auto; width: 32px; height: 32px; font-size: 24px; line-height: 32px; text-align: center; color: var(--el-color-info); } } } /style// src\api\common.ts 文件importtype{ClickRequest}from//components/clickCaptcha/indeximportrequestfrom//utils/requestexportfunctiongetClickCaptcha(apiBaseURL?:string){returnrequest({url:/common/captcha/create,method:GET,...(apiBaseURL?{baseURL:apiBaseURL}:{}),})}exportfunctioncheckClickCaptcha(data:ClickRequest,apiBaseURL?:string){returnrequest({url:/common/captcha/verify,method:POST,data,...(apiBaseURL?{baseURL:apiBaseURL}:{}),requestOptions:{showErrorMessage:false,},})}