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<