Files
user-system/internal/service/captcha.go

344 lines
8.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bytes"
"context"
crand "crypto/rand"
"encoding/hex"
"errors"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"math/big"
"math/rand"
"strings"
"time"
"github.com/user-management-system/internal/cache"
)
const (
captchaWidth = 120
captchaHeight = 40
captchaLength = 4 // 验证码位数
captchaTTL = 5 * time.Minute
)
// captchaChars 验证码字符集(去掉容易混淆的字符 0/O/1/I/l
const captchaChars = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz"
// CaptchaService 图形验证码服务
type CaptchaService struct {
cache *cache.CacheManager
}
// NewCaptchaService 创建验证码服务
func NewCaptchaService(cache *cache.CacheManager) *CaptchaService {
return &CaptchaService{cache: cache}
}
// CaptchaResult 验证码生成结果
type CaptchaResult struct {
CaptchaID string // 验证码IDUUID
ImageData []byte // PNG图片字节
}
// Generate 生成图形验证码
func (s *CaptchaService) Generate(ctx context.Context) (*CaptchaResult, error) {
// 生成随机验证码文字
text, err := s.randomText(captchaLength)
if err != nil {
return nil, fmt.Errorf("生成验证码文本失败: %w", err)
}
// 生成验证码ID
captchaID, err := s.generateID()
if err != nil {
return nil, fmt.Errorf("生成验证码ID失败: %w", err)
}
// 生成图片
imgData, err := s.renderImage(text)
if err != nil {
return nil, fmt.Errorf("生成验证码图片失败: %w", err)
}
// 存入缓存(不区分大小写,存小写)
cacheKey := "captcha:" + captchaID
s.cache.Set(ctx, cacheKey, strings.ToLower(text), captchaTTL, captchaTTL)
return &CaptchaResult{
CaptchaID: captchaID,
ImageData: imgData,
}, nil
}
// Verify 验证验证码(验证后立即删除,防止重放)
func (s *CaptchaService) Verify(ctx context.Context, captchaID, answer string) bool {
if captchaID == "" || answer == "" {
return false
}
cacheKey := "captcha:" + captchaID
val, ok := s.cache.Get(ctx, cacheKey)
if !ok {
return false
}
// 删除验证码(一次性使用)
s.cache.Delete(ctx, cacheKey)
expected, ok := val.(string)
if !ok {
return false
}
return strings.ToLower(answer) == expected
}
// VerifyWithoutDelete 验证验证码但不删除(用于测试)
func (s *CaptchaService) VerifyWithoutDelete(ctx context.Context, captchaID, answer string) bool {
if captchaID == "" || answer == "" {
return false
}
cacheKey := "captcha:" + captchaID
val, ok := s.cache.Get(ctx, cacheKey)
if !ok {
return false
}
expected, ok := val.(string)
if !ok {
return false
}
return strings.ToLower(answer) == expected
}
// ValidateCaptcha 验证验证码(对外暴露,验证后删除)
func (s *CaptchaService) ValidateCaptcha(ctx context.Context, captchaID, answer string) error {
if captchaID == "" {
return errors.New("验证码ID不能为空")
}
if answer == "" {
return errors.New("验证码答案不能为空")
}
if !s.Verify(ctx, captchaID, answer) {
return errors.New("验证码错误或已过期")
}
return nil
}
// randomText 生成随机验证码文字
func (s *CaptchaService) randomText(length int) (string, error) {
chars := []byte(captchaChars)
result := make([]byte, length)
for i := range result {
n, err := crand.Int(crand.Reader, big.NewInt(int64(len(chars))))
if err != nil {
return "", err
}
result[i] = chars[n.Int64()]
}
return string(result), nil
}
// generateID 生成验证码IDcrypto/rand 保证全局唯一,无碰撞)
func (s *CaptchaService) generateID() (string, error) {
b := make([]byte, 16)
if _, err := crand.Read(b); err != nil {
return "", err
}
return fmt.Sprintf("%d-%s", time.Now().UnixNano(), hex.EncodeToString(b)), nil
}
// renderImage 将文字渲染为PNG验证码图片纯Go实现无外部字体依赖
func (s *CaptchaService) renderImage(text string) ([]byte, error) {
// 创建 RGBA 图像
img := image.NewRGBA(image.Rect(0, 0, captchaWidth, captchaHeight))
// 随机背景色(浅色)
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
bgColor := color.RGBA{
R: uint8(220 + rng.Intn(35)),
G: uint8(220 + rng.Intn(35)),
B: uint8(220 + rng.Intn(35)),
A: 255,
}
draw.Draw(img, img.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src)
// 绘制干扰线
for i := 0; i < 5; i++ {
lineColor := color.RGBA{
R: uint8(rng.Intn(200)),
G: uint8(rng.Intn(200)),
B: uint8(rng.Intn(200)),
A: 255,
}
x1 := rng.Intn(captchaWidth)
y1 := rng.Intn(captchaHeight)
x2 := rng.Intn(captchaWidth)
y2 := rng.Intn(captchaHeight)
drawLine(img, x1, y1, x2, y2, lineColor)
}
// 绘制文字(使用像素字体)
for i, ch := range text {
charColor := color.RGBA{
R: uint8(rng.Intn(150)),
G: uint8(rng.Intn(150)),
B: uint8(rng.Intn(150)),
A: 255,
}
x := 10 + i*25 + rng.Intn(5)
y := 8 + rng.Intn(12)
drawChar(img, x, y, byte(ch), charColor)
}
// 绘制干扰点
for i := 0; i < 80; i++ {
dotColor := color.RGBA{
R: uint8(rng.Intn(255)),
G: uint8(rng.Intn(255)),
B: uint8(rng.Intn(255)),
A: uint8(100 + rng.Intn(100)),
}
img.Set(rng.Intn(captchaWidth), rng.Intn(captchaHeight), dotColor)
}
// 编码为 PNG
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// drawLine 画直线Bresenham算法
func drawLine(img *image.RGBA, x1, y1, x2, y2 int, c color.RGBA) {
dx := abs(x2 - x1)
dy := abs(y2 - y1)
sx, sy := 1, 1
if x1 > x2 {
sx = -1
}
if y1 > y2 {
sy = -1
}
err := dx - dy
for {
img.Set(x1, y1, c)
if x1 == x2 && y1 == y2 {
break
}
e2 := 2 * err
if e2 > -dy {
err -= dy
x1 += sx
}
if e2 < dx {
err += dx
y1 += sy
}
}
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
// pixelFont 5x7 像素字体位图ASCII 32-127
// 每个字符用5个uint8表示5列每个uint8的低7位是每行是否亮起
var pixelFont = map[byte][5]uint8{
'0': {0x3E, 0x51, 0x49, 0x45, 0x3E},
'1': {0x00, 0x42, 0x7F, 0x40, 0x00},
'2': {0x42, 0x61, 0x51, 0x49, 0x46},
'3': {0x21, 0x41, 0x45, 0x4B, 0x31},
'4': {0x18, 0x14, 0x12, 0x7F, 0x10},
'5': {0x27, 0x45, 0x45, 0x45, 0x39},
'6': {0x3C, 0x4A, 0x49, 0x49, 0x30},
'7': {0x01, 0x71, 0x09, 0x05, 0x03},
'8': {0x36, 0x49, 0x49, 0x49, 0x36},
'9': {0x06, 0x49, 0x49, 0x29, 0x1E},
'A': {0x7E, 0x11, 0x11, 0x11, 0x7E},
'B': {0x7F, 0x49, 0x49, 0x49, 0x36},
'C': {0x3E, 0x41, 0x41, 0x41, 0x22},
'D': {0x7F, 0x41, 0x41, 0x22, 0x1C},
'E': {0x7F, 0x49, 0x49, 0x49, 0x41},
'F': {0x7F, 0x09, 0x09, 0x09, 0x01},
'G': {0x3E, 0x41, 0x49, 0x49, 0x7A},
'H': {0x7F, 0x08, 0x08, 0x08, 0x7F},
'J': {0x20, 0x40, 0x41, 0x3F, 0x01},
'K': {0x7F, 0x08, 0x14, 0x22, 0x41},
'L': {0x7F, 0x40, 0x40, 0x40, 0x40},
'M': {0x7F, 0x02, 0x0C, 0x02, 0x7F},
'N': {0x7F, 0x04, 0x08, 0x10, 0x7F},
'P': {0x7F, 0x09, 0x09, 0x09, 0x06},
'Q': {0x3E, 0x41, 0x51, 0x21, 0x5E},
'R': {0x7F, 0x09, 0x19, 0x29, 0x46},
'S': {0x46, 0x49, 0x49, 0x49, 0x31},
'T': {0x01, 0x01, 0x7F, 0x01, 0x01},
'U': {0x3F, 0x40, 0x40, 0x40, 0x3F},
'V': {0x1F, 0x20, 0x40, 0x20, 0x1F},
'W': {0x3F, 0x40, 0x38, 0x40, 0x3F},
'X': {0x63, 0x14, 0x08, 0x14, 0x63},
'Y': {0x07, 0x08, 0x70, 0x08, 0x07},
'Z': {0x61, 0x51, 0x49, 0x45, 0x43},
'a': {0x20, 0x54, 0x54, 0x54, 0x78},
'b': {0x7F, 0x48, 0x44, 0x44, 0x38},
'c': {0x38, 0x44, 0x44, 0x44, 0x20},
'd': {0x38, 0x44, 0x44, 0x48, 0x7F},
'e': {0x38, 0x54, 0x54, 0x54, 0x18},
'f': {0x08, 0x7E, 0x09, 0x01, 0x02},
'g': {0x0C, 0x52, 0x52, 0x52, 0x3E},
'h': {0x7F, 0x08, 0x04, 0x04, 0x78},
'j': {0x20, 0x40, 0x44, 0x3D, 0x00},
'k': {0x7F, 0x10, 0x28, 0x44, 0x00},
'm': {0x7C, 0x04, 0x18, 0x04, 0x78},
'n': {0x7C, 0x08, 0x04, 0x04, 0x78},
'p': {0x7C, 0x14, 0x14, 0x14, 0x08},
'q': {0x08, 0x14, 0x14, 0x18, 0x7C},
'r': {0x7C, 0x08, 0x04, 0x04, 0x08},
's': {0x48, 0x54, 0x54, 0x54, 0x20},
't': {0x04, 0x3F, 0x44, 0x40, 0x20},
'u': {0x3C, 0x40, 0x40, 0x20, 0x7C},
'v': {0x1C, 0x20, 0x40, 0x20, 0x1C},
'w': {0x3C, 0x40, 0x30, 0x40, 0x3C},
'x': {0x44, 0x28, 0x10, 0x28, 0x44},
'y': {0x0C, 0x50, 0x50, 0x50, 0x3C},
'z': {0x44, 0x64, 0x54, 0x4C, 0x44},
}
// drawChar 在图像上绘制单个字符
func drawChar(img *image.RGBA, x, y int, ch byte, c color.RGBA) {
glyph, ok := pixelFont[ch]
if !ok {
// 未知字符画个方块
for dy := 0; dy < 7; dy++ {
for dx := 0; dx < 5; dx++ {
img.Set(x+dx*2, y+dy*2, c)
}
}
return
}
for col, colData := range glyph {
for row := 0; row < 7; row++ {
if colData&(1<<uint(row)) != 0 {
// 放大2倍绘制
img.Set(x+col*2, y+row*2, c)
img.Set(x+col*2+1, y+row*2, c)
img.Set(x+col*2, y+row*2+1, c)
img.Set(x+col*2+1, y+row*2+1, c)
}
}
}
}