344 lines
8.5 KiB
Go
344 lines
8.5 KiB
Go
|
|
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 // 验证码ID(UUID)
|
|||
|
|
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 生成验证码ID(crypto/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)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|