签入版本

This commit is contained in:
小蚂蚁云
2026-10-20 19:08:49 +08:00
commit 1f248e89e4
890 changed files with 119227 additions and 0 deletions
+402
View File
@@ -0,0 +1,402 @@
package captcha
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"image"
"image/color"
"image/png"
"io"
"math"
"math/big"
"os"
"strings"
"sync"
"github.com/gogf/gf/v2/frame/g"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
"easygoadmin/internal/consts"
"easygoadmin/utility/cache"
)
// ============================== 图形验证码 ==============================
//
// 输出 PNG data URL,使用 TTF 字体渲染,可直接由 img 标签展示。
// 前端契约:GET /api/captcha 返回 data: {key, captcha}。
// 字体缺失或解析失败时降级为内置 SVG 矢量渲染,接口不失败。
// defaultChars 默认字符集,已排除易混淆字符(0/O、1/l/I)
// 可用配置 captcha.chars 与 captcha.length 覆盖
const defaultChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
// SVG 降级画布尺寸
// 仅在字体路径缺失时使用
const svgWidth, svgHeight = 120, 40
var (
fontOnce sync.Once // 保证字体仅加载一次
parsedFont *opentype.Font // 已解析的字体实例
)
// loadFont 加载并缓存 TTF 字体
//
// 进程内只读取一次,失败后永久降级为 SVG 渲染
func loadFont(ctx context.Context, path string) *opentype.Font {
fontOnce.Do(func() {
data, err := os.ReadFile(path)
if err != nil {
g.Log().Warningf(ctx, "captcha: 字体文件不可读 %s: %v(降级 SVG 渲染)", path, err)
return
}
f, err := opentype.Parse(data)
if err != nil {
g.Log().Warningf(ctx, "captcha: 字体解析失败 %s: %v(降级 SVG 渲染)", path, err)
return
}
parsedFont = f
})
return parsedFont
}
// Generate 生成验证码
//
// 返回验证码标识与 data URL,标识写入缓存,TTL 取配置 captcha.expire
func Generate(ctx context.Context) (key, dataURL string, err error) {
length := g.Cfg().MustGet(ctx, "captcha.length").Int()
if length < 4 || length > 8 {
length = 4
}
code := randomCode(length)
key = randomKey()
expire := g.Cfg().MustGet(ctx, "captcha.expire").Int()
if expire <= 0 {
expire = 300
}
if err = cache.Set(ctx, consts.CacheKeyCaptcha(key), code, expire); err != nil {
return "", "", err
}
fontPath := g.Cfg().MustGet(ctx, "captcha.fontPath").String()
if fontPath == "" {
fontPath = "resource/static/fonts/Vera.ttf"
}
if f := loadFont(ctx, fontPath); f != nil {
if s, e := renderPNG(ctx, f, code); e == nil {
return key, s, nil
} else {
g.Log().Warningf(ctx, "captcha: PNG 渲染失败: %v(降级 SVG 渲染)", e)
}
}
return key, renderSVG(code), nil
}
// Verify 校验并消费验证码
//
// 验证码为一次性使用;标识为空时返回 true,走宽松模式
func Verify(ctx context.Context, key, code string) bool {
if key == "" {
return true
}
// 万能验证码(配置 captcha.bypassCode,仅开发调试用,生产留空)
if bypass := g.Cfg().MustGet(ctx, "captcha.bypassCode").String(); bypass != "" &&
strings.EqualFold(bypass, strings.TrimSpace(code)) {
ck := consts.CacheKeyCaptcha(key)
_ = cache.Del(ctx, ck)
return true
}
ck := consts.CacheKeyCaptcha(key)
expected, ok := cache.Get(ctx, ck)
_ = cache.Del(ctx, ck) // 无论成败均消费,防爆破重放
if !ok || expected == "" {
return false
}
return strings.EqualFold(expected, strings.TrimSpace(code))
}
// randomCode 生成指定长度的随机验证码
func randomCode(n int) string {
charset := g.Cfg().MustGet(context.Background(), "captcha.chars").String()
if charset == "" {
charset = defaultChars
}
var sb strings.Builder
for i := 0; i < n; i++ {
idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
sb.WriteByte(charset[idx.Int64()])
}
return sb.String()
}
// randomKey 生成随机验证码标识
func randomKey() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return fmt.Sprintf("%x", b)
}
// ----------------------------------------------------------------------------
// PNG 渲染(主路径)
// 使用 TTF 字体,配合随机旋转、颜色与位置,叠加干扰线与噪点。
// 相关配置:captcha.imageWidth、captcha.imageHeight、captcha.fontSize、
// captcha.noiseLines、captcha.noiseDots
// ----------------------------------------------------------------------------
// pngColors 字符颜色池(绿色系)
var pngColors = []color.RGBA{
{0, 128, 0, 255}, // 绿色
{0, 150, 0, 255}, // 稍亮绿
{34, 139, 34, 255}, // 森林绿
{50, 205, 50, 255}, // 酸橙绿
{0, 100, 0, 255}, // 深绿
}
// renderPNG 渲染验证码为 PNG data URL
func renderPNG(ctx context.Context, f *opentype.Font, code string) (string, error) {
w := cfgInt(ctx, "captcha.imageWidth", 160)
h := cfgInt(ctx, "captcha.imageHeight", 50)
fs := cfgInt(ctx, "captcha.fontSize", 28)
lines := cfgInt(ctx, "captcha.noiseLines", 8)
dots := cfgInt(ctx, "captcha.noiseDots", 500)
face, err := opentype.NewFace(f, &opentype.FaceOptions{Size: float64(fs), DPI: 72, Hinting: font.HintingFull})
if err != nil {
return "", err
}
defer func() { // opentype face 实现了 io.Closer,防御性断言
if c, ok := face.(io.Closer); ok {
_ = c.Close()
}
}()
// 字符总宽(含字距),画布不够时自动加宽——防长验证码右侧被裁剪
const gap = 4
total := 0
for _, ch := range code {
adv, _ := face.GlyphAdvance(ch)
total += adv.Ceil() + gap
}
const margin = 16
if need := total + margin*2; w < need {
w = need
}
img := image.NewRGBA(image.Rect(0, 0, w, h))
fillRGBA(img, color.RGBA{247, 249, 252, 255})
// 干扰线:深绿色
lineColor := color.RGBA{0, 100, 0, 255}
for i := 0; i < lines; i++ {
drawLine(img, randInt(w), randInt(h), randInt(w), randInt(h), lineColor)
}
// 字符(逐字独立小画布 → 旋转 → 混合到主画布)
cellH := fs * 2
x := (w - total) / 2
for _, ch := range code {
adv, _ := face.GlyphAdvance(ch)
cw := adv.Ceil() + 8
cell := image.NewRGBA(image.Rect(0, 0, cw, cellH))
col := pngColors[randInt(len(pngColors))]
d := font.Drawer{
Dst: cell,
Src: image.NewUniform(col),
Face: face,
Dot: fixed.P(4, cellH/2+fs/3),
}
d.DrawString(string(ch))
angle := float64(randInt(50)-25) * math.Pi / 180
dx := x - 4 + randInt(5) - 2
dy := h/2 - cellH/2 + randInt(5) - 2
rotateBlend(img, cell, dx, dy, cw/2, cellH/2, angle)
x += adv.Ceil() + gap
}
// 噪点:半透明绿色
dotColor := color.RGBA{0, 128, 0, 90}
for i := 0; i < dots; i++ {
blendPixel(img, randInt(w), randInt(h), dotColor)
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return "", err
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}
// cfgInt 读取正整数配置
//
// 配置缺失或非正时返回默认值
func cfgInt(ctx context.Context, key string, def int) int {
if v := g.Cfg().MustGet(ctx, key).Int(); v > 0 {
return v
}
return def
}
// fillRGBA 用指定颜色填充整张图像
func fillRGBA(img *image.RGBA, c color.RGBA) {
for i := 0; i < len(img.Pix); i += 4 {
img.Pix[i] = c.R
img.Pix[i+1] = c.G
img.Pix[i+2] = c.B
img.Pix[i+3] = 255
}
}
// rotateBlend 旋转并混合
//
// 将 src(透明底)绕自身中心 (scx,scy) 旋转 angle 后,
// 放到 dst 的 (dx,dy) 处并做 alpha 混合
func rotateBlend(dst, src *image.RGBA, dx, dy, scx, scy int, angle float64) {
sin, cos := math.Sin(angle), math.Cos(angle)
sb := src.Bounds()
cx, cy := float64(dx+scx), float64(dy+scy)
for y := dy; y < dy+sb.Dy(); y++ {
for x := dx; x < dx+sb.Dx(); x++ {
// 逆旋转求源坐标
relX, relY := float64(x)-cx, float64(y)-cy
sx := int(relX*cos+relY*sin) + scx
sy := int(-relX*sin+relY*cos) + scy
if sx < 0 || sy < 0 || sx >= sb.Dx() || sy >= sb.Dy() {
continue
}
si := src.PixOffset(sx, sy)
a := src.Pix[si+3]
if a == 0 {
continue
}
di := imgOffset(dst, x, y)
if di < 0 || di+3 >= len(dst.Pix) {
continue
}
// src 为预乘 alpha(image.RGBA 约定):dst = src + dst*(1-a)
fa := 255 - uint32(a)
dst.Pix[di] = uint8(uint32(src.Pix[si]) + uint32(dst.Pix[di])*fa/255)
dst.Pix[di+1] = uint8(uint32(src.Pix[si+1]) + uint32(dst.Pix[di+1])*fa/255)
dst.Pix[di+2] = uint8(uint32(src.Pix[si+2]) + uint32(dst.Pix[di+2])*fa/255)
dst.Pix[di+3] = 255
}
}
}
// imgOffset 计算像素在图像数据中的偏移量
//
// 坐标越界时返回 -1
func imgOffset(img *image.RGBA, x, y int) int {
if !image.Pt(x, y).In(img.Bounds()) {
return -1
}
return (y-img.Rect.Min.Y)*img.Stride + (x-img.Rect.Min.X)*4
}
// drawLine 使用 Bresenham 算法绘制直线
//
// 不透明绘制,用于生成干扰线
func drawLine(img *image.RGBA, x0, y0, x1, y1 int, c color.RGBA) {
dx, dy := abs(x1-x0), -abs(y1-y0)
sx, sy := -1, -1
if x0 < x1 {
sx = 1
}
if y0 < y1 {
sy = 1
}
err := dx + dy
for {
if i := imgOffset(img, x0, y0); i >= 0 {
img.Pix[i] = c.R
img.Pix[i+1] = c.G
img.Pix[i+2] = c.B
img.Pix[i+3] = 255
}
if x0 == x1 && y0 == y1 {
break
}
e2 := 2 * err
if e2 >= dy {
err += dy
x0 += sx
}
if e2 <= dx {
err += dx
y0 += sy
}
}
}
// blendPixel 单像素 alpha 混合
//
// 用于绘制噪点,传入颜色为直 alpha,先转预乘再叠加
func blendPixel(img *image.RGBA, x, y int, c color.RGBA) {
i := imgOffset(img, x, y)
if i < 0 {
return
}
av := uint32(c.A)
pr, pg, pb := uint32(c.R)*av/255, uint32(c.G)*av/255, uint32(c.B)*av/255
fa := 255 - av
img.Pix[i] = uint8(pr + uint32(img.Pix[i])*fa/255)
img.Pix[i+1] = uint8(pg + uint32(img.Pix[i+1])*fa/255)
img.Pix[i+2] = uint8(pb + uint32(img.Pix[i+2])*fa/255)
}
// abs 计算整数绝对值
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
// ----------------------------------------------------------------------------
// SVG 渲染(降级路径)
// 字体缺失时兜底,保持接口可用。
// ----------------------------------------------------------------------------
// renderSVG 渲染带干扰的 SVG 验证码
func renderSVG(code string) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`,
svgWidth, svgHeight, svgWidth, svgHeight))
sb.WriteString(`<rect width="100%" height="100%" fill="#f7f9fc"/>`)
// 干扰线
for i := 0; i < 4; i++ {
x1 := randInt(svgWidth)
y1 := randInt(svgHeight)
x2 := randInt(svgWidth)
y2 := randInt(svgHeight)
sb.WriteString(fmt.Sprintf(`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="#c8d3e0" stroke-width="1"/>`, x1, y1, x2, y2))
}
// 字符
for i, ch := range code {
x := 14 + i*26 + randInt(6)
y := 27 + randInt(6)
rot := randInt(50) - 25
colorIdx := randInt(len(pngColors))
c := pngColors[colorIdx]
sb.WriteString(fmt.Sprintf(
`<text x="%d" y="%d" font-size="24" font-family="Arial, sans-serif" font-weight="bold" fill="#%02x%02x%02x" transform="rotate(%d %d %d)">%c</text>`,
x, y, c.R, c.G, c.B, rot, x, y, ch))
}
// 噪点
for i := 0; i < 18; i++ {
sb.WriteString(fmt.Sprintf(`<circle cx="%d" cy="%d" r="1" fill="#aab7c4"/>`, randInt(svgWidth), randInt(svgHeight)))
}
sb.WriteString(`</svg>`)
return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(sb.String()))
}
// randInt 生成指定上限内的随机整数
func randInt(n int) int {
if n <= 0 {
return 0
}
v, _ := rand.Int(rand.Reader, big.NewInt(int64(n)))
return int(v.Int64())
}