☰
从 PHP 到 AI + Golang,程序员自救转型手记(十九):点选验证码初版落地,TaoToken 统一 Key 打通随机数与文件系统包
2026/10/3 6:33:11 网站建设 项目流程

1. 从 PHP 到 Golang:点选验证码初版落地踩了哪些坑

点选验证码是什么?简单说就是给你一张图,让你按顺序点中图里指定的文字或图标,用来区分人和机器。能做什么?拦住批量注册、刷接口的脚本。适合谁?正在从 PHP 转 Golang、想用 AI 辅助写业务代码的同学。我这次的目标很明确:在 ai-go-mall 项目里把点选验证码的最小闭环跑通,顺便把随机数生成和文件系统操作抽成公共包,为后面复用做准备。

PHP 里做验证码,很多人习惯直接imagecreate加imagestring,几行就出图,但 Golang 的image标准库更偏底层,画字要自己加载字体、算坐标、处理颜色。更麻烦的是,验证码天然涉及随机数、文件读取、坐标计算,如果全塞在一个文件里,后面维护会很难受。所以这一期的核心不是"把验证码写出来",而是"用合理的包结构把它写出来"。

我试过让 AI 一次性生成整个模块,结果它把 AES-256 加解密、定期清理、资源预缓存全塞进来了,代码 600 多行,看着就头大。后来我改成先写细需求、再让 AI 生成、最后人工 review 精简,才得到能用的版本。下面把目录结构、随机数包、文件系统包、验证码生成与校验的完整流程拆开讲,每一步都能跟着做。

2. TaoToken 统一 Key 打通 AI 辅助编码链路

转型过程中我用 AI 工具辅助写 Golang,最大的痛点是多个模型、多个 Key 管理混乱。Claude Code、Codex、DeepSeek 各一套凭证,切换起来很烦。TaoToken 提供统一 Key 和 API 通道,把模型调用收敛到一个入口,配置一次就能在多个工具里复用。

先说清楚它是什么:TaoToken 是一个统一的大模型 API 接入服务,你拿到一个 Key,就能通过兼容 OpenAI 协议的接口调用不同模型。对转型期的程序员来说,好处是不用为每个工具单独申请和轮换 Key,Base URL 和 Key 配好,模型 ID 按需切换即可。

接入前你需要准备三样东西,我称为"三件套":

配置项值说明
Base URLhttps://taotoken.net/api兼容 OpenAI 协议的接口地址
API Key在控制台创建统一凭证,替代多套 Key
Model ID按需选择如 claude 系列、gpt 系列等

如果你用 Claude Code 这类工具,配置通常写在 settings 文件里;如果用 Cline 或 Codex,则写在对应的 MCP 或 auth 配置中。以 Claude Code 的 settings 为例,配置片段长这样:

{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_API_KEY": "你的统一Key" } }

Codex 的auth.json则是另一种写法:

{ "base_url": "https://taotoken.net/api", "api_key": "你的统一Key", "model": "claude-sonnet-4-5" }

注意:Base URL 用https://taotoken.net/api,不要多加路径后缀,否则容易出现 404。Key 创建后只显示一次,记得及时保存。模型 ID 要和你的工具支持的名称对齐,写错了会报 model not found。

配好之后,你在终端里让 AI 帮你生成验证码代码、review 随机数包,请求都会走同一条通道。这样做的好处是:换模型只改 Model ID,不用重新申请 Key;排查问题时只看一个入口的日志;团队协作时统一凭证管理。对个人转型来说,省下的时间可以多写两行业务代码。

需要创建 Key 的话,去控制台操作:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有各工具的详细配置示例。

3. 可复制配置:目录结构、随机数包与文件系统包

这一节给出可以直接抄的目录结构和两个公共包的完整代码。先说目录规划,我采用的是分层结构,基础设施放internal/infra,通用工具放pkg:

ai-go-mall/ ├── config/ │ └── captcha.yaml ├── internal/ │ ├── infra/ │ │ ├── captcha/ │ │ │ └── click.go │ │ └── config/ │ │ └── config.go │ └── model/ │ └── common.go ├── pkg/ │ ├── random/ │ │ └── random.go │ └── filesystem/ │ └── filesystem.go └── asset/ ├── captcha/ │ └── click/ │ ├── bg/ # 背景图 │ └── icon/ # 图标 └── font/ └── SourceHanSansCN-Normal.ttf

配置文件config/captcha.yaml内容如下,元素类型、长度、混淆数、过期时间、资源路径全放这里,代码里不再硬编码:

captcha: elements: - chinese - uppercase - icon length: 2 confusion_length: 2 expire: 600 bg_path: asset/captcha/click/bg icon_path: asset/captcha/click/icon font_path: asset/font/SourceHanSansCN-Normal.ttf

随机数包pkg/random/random.go,我把它整理成两个函数加一个颜色生成器。Int用crypto/rand保证安全,适合验证码这种场景;FastInt用math/rand/v2,适合对安全不敏感的场合:

package random import ( "crypto/rand" "image/color" "math/big" mathrand "math/rand/v2" ) // Int 返回 [min, max) 范围的加密安全随机整数 func Int(min, max int) int { if max <= min { return min } n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min))) if err != nil { return min } return min + int(n.Int64()) } // FastInt 返回 [min, max) 范围的快速随机整数 func FastInt(min, max int) int { if max <= min { return min } return min + mathrand.IntN(max-min) } // RGB 返回随机 RGB 颜色,tone 可选 dark 或 light func RGB(tone string) color.Color { if tone == "light" { return color.RGBA{ R: uint8(180 + Int(0, 75)), G: uint8(180 + Int(0, 75)), B: uint8(180 + Int(0, 75)), A: 255, } } return color.RGBA{ R: uint8(20 + Int(0, 80)), G: uint8(20 + Int(0, 80)), B: uint8(20 + Int(0, 80)), A: 255, } }

文件系统包pkg/filesystem/filesystem.go,目前只放一个去扩展名的函数,后面用到再加:

package filesystem import "path/filepath" // TrimExt 返回去除了路径和扩展名的文件名 func TrimExt(path string) string { name := filepath.Base(path) ext := filepath.Ext(name) return name[:len(name)-len(ext)] }

这两个包的设计原则是:只放通用逻辑,不依赖 config、database 等基础设施。比如random.Int不关心验证码,任何需要安全随机数的地方都能用;filesystem.TrimExt不关心图标还是背景图,任何路径处理都能调。迁移时我把原来click.go里的cryptoRandInt和randomTextColor搬了过来,前者重写成接受min、max两个参数,后者去掉random前缀直接叫RGB,更通用。

模型层internal/model/common.go里新增 Captcha 模型,和 Token 放一起,通过注册函数自动迁移:

func init() { Register(&Token{}, &Captcha{}) } type Captcha struct { Key string `gorm:"comment:验证码查询键;type:varchar(64);primaryKey" json:"key"` Code string `gorm:"comment:验证码值;type:varchar(255)" json:"-"` Info string `gorm:"comment:验证码数据;type:text" json:"-"` ExpiredAt time.Time `gorm:"comment:过期时间;not null;index" json:"expired_at"` CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"` } func (Captcha) TableName() string { return "captchas" }

注意Code和Info都加了json:"-",防止敏感数据泄露到 API 响应。ExpiredAt加了索引,方便后续按过期时间清理。这里我取消了 AI 最初加的 AES-256 加密,因为Info字段本身是明文存坐标,加密Code等于加了个寂寞,直接用md5(md5(code))处理一下入库就够了。

4. 验证请求:跑通验证码生成与校验闭环

配置和包都就位后,接下来验证整个链路能不能跑通。验证码模块的核心是两个方法:Create生成验证码,Check校验用户点击。我把它们放在internal/infra/captcha/click.go里,按需初始化,不在main.go里提前调用。

Create的流程是这样的:先随机取一张背景图,然后根据配置随机生成正确元素(中文、大写字母、图标),再生成混淆元素,打乱全部元素后绘制到背景上,最后把正确元素的顺序和坐标存进数据库,返回给前端的是 key、宽高、元素数组和 base64 图片。关键代码片段:

func Create() (*CreateResult, error) { cfg := config.Get().Captcha cleanExpired() bg, err := pickRandomFile(cfg.BgPath) if err != nil { return nil, err } img, err := loadImage(bg) if err != nil { return nil, err } elements := generateElements(cfg) drawElements(img, elements) key := randomKey() info, _ := json.Marshal(elements) code := md5Hex(md5Hex(joinText(elements))) record := model.Captcha{ Key: key, Code: code, Info: string(info), ExpiredAt: time.Now().Add(time.Duration(cfg.Expire) * time.Second), } if err := gorm.G[model.Captcha](db).Create(ctx, &record); err != nil { return nil, err } return &CreateResult{ Key: key, Width: img.Bounds().Dx(), Height: img.Bounds().Dy(), Elements: elementTexts(elements), ImageBase64: encodeBase64(img), }, nil }

Check的流程是:按 key 查记录,检查是否过期,解密或比对正确元素序列,验证用户点击顺序,做坐标缩放适配前端渲染尺寸,用欧几里得距离做容差校验,最后按需删除记录。核心逻辑:

func Check(key string, clicks []ClickPoint, renderW, renderH int, deleteAfter bool) (bool, error) { record, err := gorm.G[model.Captcha](db). Where("key = ?", key).First(ctx) if err != nil { return false, err } if time.Now().After(record.ExpiredAt) { return false, nil } var elements []Element json.Unmarshal([]byte(record.Info), &elements) if len(clicks) != len(elements) { return false, nil } scaleX := float64(renderW) / float64(record.Width) scaleY := float64(renderH) / float64(record.Height) for i, el := range elements { dx := float64(clicks[i].X) - float64(el.X)*scaleX dy := float64(clicks[i].Y) - float64(el.Y)*scaleY if math.Sqrt(dx*dx+dy*dy) > tolerance { return false, nil } } if deleteAfter { gorm.G[model.Captcha](db).Where("key = ?", key).Delete(ctx) } return true, nil }

验证时我用 curl 发一个生成请求,返回的 JSON 里能看到 key、宽高、元素数组和 base64 图片。把 base64 解码成图片,肉眼确认元素绘制位置正确、没有重叠。然后模拟点击坐标调Check,返回 true 说明校验通过。这里有个细节:前端渲染尺寸可能和原图不一致,所以Check里做了缩放,用renderW/renderH除以原图宽高得到比例,再算距离。

如果你用 TaoToken 的模型对话功能让 AI 帮你 review 这段代码,可以把click.go贴进去,让它检查坐标缩放和容差逻辑。模型对话入口:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite 。长期做编码和 Agent 任务的话,Coding Plan 更划算:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。

5. 本篇常见错排查:401、坐标偏移与元素重叠

跑验证码的过程中我踩了几个坑,这里对照真实报错说清楚怎么排查。

第一个是 401 未授权。如果你在调用 AI 辅助生成代码时遇到401 Unauthorized,先检查三件套是否齐全:Base URL 是不是https://taotoken.net/api,Key 有没有复制完整(前后不能有空格),Model ID 是否拼写正确。常见错误是把 Base URL 写成带/v1的路径,或者 Key 用了别的平台的。排查方法是用 curl 直接测:

curl https://taotoken.net/api/v1/models \ -H "Authorization: Bearer 你的Key"

返回模型列表说明 Key 和 Base URL 没问题,报 401 就是 Key 错了。

第二个是坐标偏移。用户明明点对了位置,Check却返回 false。原因通常是前端渲染尺寸和原图不一致,但Check里没做缩放,或者缩放比例算反了。排查时打印renderW、renderH、record.Width、record.Height四个值,确认scaleX = renderW / record.Width方向正确。另外容差tolerance设太小也会误判,我一般设 15 到 20 像素。

第三个是元素重叠。AI 初版没做碰撞检测,极端情况下两个元素画在同一区域,用户根本没法点。我在 review 时加了碰撞检测:每生成一个新元素,就和已生成的元素比距离,小于阈值就重新生成。代码片段:

func hasCollision(newEl Element, existing []Element, minDist int) bool { for _, el := range existing { dx := newEl.X - el.X dy := newEl.Y - el.Y if math.Sqrt(float64(dx*dx+dy*dy)) < float64(minDist) { return true } } return false }

第四个是reading choices类报错。如果你用某些 AI 工具时看到类似error reading choices或local proxy failed,通常是工具的网络配置或代理设置有问题,检查工具的 Base URL 是否指向了正确的接口地址,以及本地是否有残留的代理环境变量干扰。清掉HTTP_PROXY、HTTPS_PROXY再试。

第五个是 OAuth 相关报错。Claude Code 这类工具如果用 OAuth 登录,配置统一 Key 时可能冲突。解决办法是在 settings 里显式指定ANTHROPIC_API_KEY,并确保没有同时启用 OAuth 登录态。如果报OAuth token expired,重新走一遍 Key 配置流程即可。

排查顺序建议:先确认三件套配置正确,再用 curl 测接口通不通,然后看业务代码的日志,最后才怀疑 AI 生成的逻辑。大部分问题出在配置层,不在代码层。

6. 继续用统一 Key 推进 ai-go-mall

验证码初版跑通后,下一步是把它接到登录接口上,替换掉原来的图形验证码。接入时注意Check的deleteAfter参数:登录场景传 true,验证成功立即删除,防止重复提交;预检场景传 false,只判断对错不删记录。

随机数包和文件系统包已经抽出来了,后面做短信验证码、邮箱验证码时可以直接复用random.Int生成数字码,用filesystem.TrimExt处理模板文件名。这就是提前封装的价值:验证码只是第一个使用者,不是唯一使用者。

如果你也在做 PHP 转 Golang 的转型,建议把 AI 当结对伙伴而不是代码生成器。先自己写细需求,让 AI 出初版,再人工 review 精简。我这次让 AI 生成的 600 行代码,精简后核心逻辑不到 300 行,去掉的都是过度设计和用不上的加密。统一 Key 的好处在这里也体现出来:review 代码时随时切换模型,让不同的 AI 从不同角度挑毛病,而不用为每个模型单独配 Key。

需要创建统一 Key 的话,控制台入口:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 。API Keys 管理页:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。接入文档里有各工具的完整配置示例,照着改 Base URL、Key、Model ID 三件套就能跑起来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询