2e2c161770
- backed/: Go Fiber backend with cpdaily integration - frondend/: Vue 3 + Vite frontend - Dockerfile: multi-stage build (Node → Go → Alpine) - deploy/docker-compose.yml: production compose file - .woodpecker/build.yml: CI/CD with blue-green zero-downtime deploy - /health endpoint for deployment health checks - Port 8884/8885 blue-green swap via 1Panel OpenResty
446 lines
10 KiB
Go
446 lines
10 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"cpdaily-new/internal/config"
|
|
"encoding/base64"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
"image/png"
|
|
"math/rand"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
captchaStore = sync.Map{}
|
|
rng = rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
)
|
|
|
|
type CaptchaResult struct {
|
|
CaptchaID string `json:"captchaId"`
|
|
ImageBase64 string `json:"imageBase64"`
|
|
}
|
|
|
|
// 二次元风格配色 — 亮色系
|
|
var animeColors = []color.RGBA{
|
|
{0xFF, 0x14, 0x93, 0xFF}, // 深粉红
|
|
{0x00, 0xCE, 0xD1, 0xFF}, // 青色
|
|
{0xFF, 0xD7, 0x00, 0xFF}, // 金色
|
|
{0x93, 0x70, 0xDB, 0xFF}, // 中紫
|
|
{0xFF, 0x8C, 0x00, 0xFF}, // 橙色
|
|
{0x00, 0xBF, 0xFF, 0xFF}, // 天蓝
|
|
{0xBA, 0x55, 0xD3, 0xFF}, // 紫红
|
|
{0x32, 0xCD, 0x32, 0xFF}, // 酸橙
|
|
{0xFF, 0x69, 0xB4, 0xFF}, // 亮粉
|
|
{0xFF, 0x63, 0x47, 0xFF}, // 番茄
|
|
}
|
|
|
|
// 马卡龙色系背景
|
|
var pastelBG = []color.RGBA{
|
|
{0xFF, 0xE4, 0xE1, 0xFF}, // 淡粉
|
|
{0xE6, 0xE6, 0xFA, 0xFF}, // 淡紫
|
|
{0xF0, 0xFF, 0xF0, 0xFF}, // 薄荷
|
|
{0xFF, 0xDA, 0xB9, 0xFF}, // 桃色
|
|
{0xE0, 0xF0, 0xFF, 0xFF}, // 天蓝
|
|
{0xFF, 0xF0, 0xF5, 0xFF}, // 薰衣草
|
|
{0xF5, 0xFF, 0xFA, 0xFF}, // 薄荷奶油
|
|
{0xFF, 0xF8, 0xDC, 0xFF}, // 玉米丝
|
|
}
|
|
|
|
// 生成随机验证码
|
|
func generateCode(length int) string {
|
|
digits := "0123456789"
|
|
var sb strings.Builder
|
|
for i := 0; i < length; i++ {
|
|
sb.WriteByte(digits[rng.Intn(len(digits))])
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// 生成图片验证码
|
|
func GenerateCaptcha() (*CaptchaResult, error) {
|
|
cfg := config.Global.Captcha
|
|
code := generateCode(cfg.Length)
|
|
|
|
// 生成图片
|
|
img := image.NewRGBA(image.Rect(0, 0, cfg.Width, cfg.Height))
|
|
|
|
// 随机马卡龙色背景
|
|
bg := pastelBG[rng.Intn(len(pastelBG))]
|
|
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
|
|
|
|
// ── 干扰线(粗线,高不透明度)──
|
|
for i := 0; i < 10; i++ {
|
|
col := animeColors[rng.Intn(len(animeColors))]
|
|
col.A = uint8(rng.Intn(80) + 150) // alpha 150~230
|
|
|
|
x1 := rng.Intn(cfg.Width)
|
|
y1 := rng.Intn(cfg.Height)
|
|
x2 := rng.Intn(cfg.Width)
|
|
y2 := rng.Intn(cfg.Height)
|
|
|
|
thick := rng.Intn(2) + 2 // 2~3px 粗
|
|
drawThickLine(img, x1, y1, x2, y2, col, thick)
|
|
}
|
|
|
|
// ── 二次元风格彩色折线 ──
|
|
for i := 0; i < 6; i++ {
|
|
col := animeColors[rng.Intn(len(animeColors))]
|
|
col.A = uint8(rng.Intn(60) + 130)
|
|
|
|
x1 := rng.Intn(cfg.Width)
|
|
y1 := rng.Intn(cfg.Height)
|
|
xm := rng.Intn(cfg.Width)
|
|
ym := rng.Intn(cfg.Height)
|
|
x2 := rng.Intn(cfg.Width)
|
|
y2 := rng.Intn(cfg.Height)
|
|
drawThickLine(img, x1, y1, xm, ym, col, 2)
|
|
drawThickLine(img, xm, ym, x2, y2, col, 2)
|
|
}
|
|
|
|
// ── 装饰圆圈(半透明彩色气泡)──
|
|
for i := 0; i < 4; i++ {
|
|
cx := rng.Intn(cfg.Width)
|
|
cy := rng.Intn(cfg.Height)
|
|
radius := rng.Intn(5) + 3
|
|
col := animeColors[rng.Intn(len(animeColors))]
|
|
col.A = 40
|
|
drawCircle(img, cx, cy, radius, col)
|
|
}
|
|
|
|
// 小星星/闪光装饰点
|
|
for i := 0; i < 6; i++ {
|
|
x := rng.Intn(cfg.Width)
|
|
y := rng.Intn(cfg.Height)
|
|
starCol := animeColors[rng.Intn(len(animeColors))]
|
|
starCol.A = 100
|
|
drawStarDot(img, x, y, starCol)
|
|
}
|
|
|
|
// 彩色噪点
|
|
for i := 0; i < 100; i++ {
|
|
x := rng.Intn(cfg.Width)
|
|
y := rng.Intn(cfg.Height)
|
|
col := animeColors[rng.Intn(len(animeColors))]
|
|
col.A = uint8(rng.Intn(100) + 80)
|
|
img.Set(x, y, col)
|
|
}
|
|
|
|
// ── 绘制文字 — 4px 像素块,带倾斜效果 ──
|
|
charBlockW := cfg.Width / cfg.Length
|
|
charPixelSize := 4 // 4px 像素块
|
|
charMapW := 7 // 字符矩阵宽度
|
|
charMapH := 9 // 字符矩阵高度
|
|
charW := charMapW * charPixelSize
|
|
charH := charMapH * charPixelSize
|
|
|
|
for i, ch := range code {
|
|
// 每个字符在其块中居中
|
|
blockCenterX := i*charBlockW + charBlockW/2
|
|
charX := blockCenterX - charW/2
|
|
charY := (cfg.Height - charH) / 2
|
|
|
|
// 微调位置
|
|
charX += rng.Intn(5) - 2
|
|
charY += rng.Intn(3) - 1
|
|
|
|
// 每个字符选择鲜艳的二次元颜色
|
|
charColor := animeColors[rng.Intn(len(animeColors))]
|
|
|
|
// 随机倾斜程度 (shear: 每行偏移像素数)
|
|
shear := rng.Intn(2) + 1 // 1~2
|
|
|
|
// 1/3 概率向右倾斜,2/3 向左倾斜
|
|
if rng.Intn(3) != 0 {
|
|
shear = -shear
|
|
}
|
|
|
|
drawCharLarge(img, charX, charY, string(ch), charColor, charPixelSize, shear)
|
|
}
|
|
|
|
// 编码为 PNG base64
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, img); err != nil {
|
|
return nil, err
|
|
}
|
|
base64Str := base64.StdEncoding.EncodeToString(buf.Bytes())
|
|
|
|
// 生成唯一 ID 并存储验证码
|
|
captchaID := generateCode(16)
|
|
captchaStore.Store(captchaID, CaptchaValue{
|
|
Code: code,
|
|
ExpiresAt: time.Now().Add(5 * time.Minute),
|
|
})
|
|
|
|
return &CaptchaResult{
|
|
CaptchaID: captchaID,
|
|
ImageBase64: "data:image/png;base64," + base64Str,
|
|
}, nil
|
|
}
|
|
|
|
type CaptchaValue struct {
|
|
Code string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
func VerifyCaptcha(captchaID, code string) bool {
|
|
if captchaID == "" || code == "" {
|
|
return false
|
|
}
|
|
val, ok := captchaStore.Load(captchaID)
|
|
if !ok {
|
|
return false
|
|
}
|
|
captchaStore.Delete(captchaID)
|
|
cv := val.(CaptchaValue)
|
|
if time.Now().After(cv.ExpiresAt) {
|
|
return false
|
|
}
|
|
return strings.EqualFold(cv.Code, code)
|
|
}
|
|
|
|
// GetCaptchaDebug 读取验证码(调试用)
|
|
func GetCaptchaDebug(captchaID string) (string, bool) {
|
|
val, ok := captchaStore.Load(captchaID)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
cv := val.(CaptchaValue)
|
|
return cv.Code, true
|
|
}
|
|
|
|
// 画粗线(绘制多条平行线模拟粗线)
|
|
func drawThickLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color, thickness int) {
|
|
dx, dy := x2-x1, y2-y1
|
|
steps := max(abs(dx), abs(dy))
|
|
if steps == 0 {
|
|
return
|
|
}
|
|
for i := 0; i <= steps; i++ {
|
|
cx := x1 + dx*i/steps
|
|
cy := y1 + dy*i/steps
|
|
// 在垂直方向偏移绘制粗线效果
|
|
for t := -(thickness - 1); t < thickness; t++ {
|
|
// 根据线的方向决定偏移方向
|
|
var px, py int
|
|
if abs(dx) > abs(dy) {
|
|
px = cx
|
|
py = cy + t
|
|
} else {
|
|
px = cx + t
|
|
py = cy
|
|
}
|
|
if px >= 0 && px < img.Bounds().Max.X && py >= 0 && py < img.Bounds().Max.Y {
|
|
img.Set(px, py, col)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 划线(单像素)
|
|
func drawLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color) {
|
|
dx, dy := x2-x1, y2-y1
|
|
steps := max(abs(dx), abs(dy))
|
|
if steps == 0 {
|
|
return
|
|
}
|
|
for i := 0; i <= steps; i++ {
|
|
x := x1 + dx*i/steps
|
|
y := y1 + dy*i/steps
|
|
if x >= 0 && x < img.Bounds().Max.X && y >= 0 && y < img.Bounds().Max.Y {
|
|
img.Set(x, y, col)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 绘制半透明圆圈
|
|
func drawCircle(img *image.RGBA, cx, cy, radius int, col color.Color) {
|
|
for dy := -radius; dy <= radius; dy++ {
|
|
for dx := -radius; dx <= radius; dx++ {
|
|
if dx*dx+dy*dy <= radius*radius {
|
|
x := cx + dx
|
|
y := cy + dy
|
|
if x >= 0 && x < img.Bounds().Max.X && y >= 0 && y < img.Bounds().Max.Y {
|
|
img.Set(x, y, col)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 绘制小星星闪光点
|
|
func drawStarDot(img *image.RGBA, x, y int, col color.Color) {
|
|
offsets := [][2]int{
|
|
{0, 0}, {1, 0}, {-1, 0}, {0, 1}, {0, -1},
|
|
{1, 1}, {-1, -1}, {1, -1}, {-1, 1},
|
|
}
|
|
for _, off := range offsets {
|
|
px := x + off[0]
|
|
py := y + off[1]
|
|
if px >= 0 && px < img.Bounds().Max.X && py >= 0 && py < img.Bounds().Max.Y {
|
|
img.Set(px, py, col)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 使用 7x9 点阵绘制大号字符(带倾斜 shear 效果)
|
|
func drawCharLarge(img *image.RGBA, x, y int, char string, col color.Color, pixelSize int, shear int) {
|
|
// 7x9 数字点阵映射
|
|
charMaps := map[string][][]int{
|
|
"0": {
|
|
{0, 1, 1, 1, 1, 1, 0},
|
|
{1, 1, 1, 1, 1, 1, 1},
|
|
{1, 1, 0, 0, 0, 1, 1},
|
|
{1, 1, 0, 0, 0, 1, 1},
|
|
{1, 1, 0, 0, 0, 1, 1},
|
|
{1, 1, 0, 0, 0, 1, 1},
|
|
{1, 1, 0, 0, 0, 1, 1},
|
|
{1, 1, 1, 1, 1, 1, 1},
|
|
{0, 1, 1, 1, 1, 1, 0},
|
|
},
|
|
"1": {
|
|
{0, 0, 0, 1, 0, 0, 0},
|
|
{0, 0, 1, 1, 0, 0, 0},
|
|
{0, 1, 1, 1, 0, 0, 0},
|
|
{0, 0, 0, 1, 0, 0, 0},
|
|
{0, 0, 0, 1, 0, 0, 0},
|
|
{0, 0, 0, 1, 0, 0, 0},
|
|
{0, 0, 0, 1, 0, 0, 0},
|
|
{0, 0, 0, 1, 0, 0, 0},
|
|
{0, 1, 1, 1, 1, 1, 0},
|
|
},
|
|
"2": {
|
|
{0, 1, 1, 1, 1, 1, 0},
|
|
{1, 1, 1, 1, 1, 1, 1},
|
|
{0, 0, 0, 0, 0, 1, 1},
|
|
{0, 0, 0, 0, 0, 1, 1},
|
|
{0, 1, 1, 1, 1, 1, 0},
|
|
{1, 1, 0, 0, 0, 0, 0},
|
|
{1, 1, 0, 0, 0, 0, 0},
|
|
{1, 1, 1, 1, 1, 1, 1},
|
|
{0, 1, 1, 1, 1, 1, 0},
|
|
},
|
|
"3": {
|
|
{0, 1, 1, 1, 1, 1, 0},
|
|
{1, 1, 1, 1, 1, 1, 1},
|
|
{0, 0, 0, 0, 0, 1, 1},
|
|
{0, 0, 0, 0, 0, 1, 1},
|
|
{0, 1, 1, 1, 1, 1, 0},
|
|
{0, 0, 0, 0, 0, 1, 1},
|
|
{0, 0, 0, 0, 0, 1, 1},
|
|
{1, 1, 1, 1, 1, 1, 1},
|
|
{0, 1, 1, 1, 1, 1, 0},
|
|
},
|
|
"4": {
|
|
{0, 0, 0, 0, 1, 1, 0},
|
|
{0, 0, 0, 1, 1, 1, 0},
|
|
{0, 0, 1, 1, 1, 1, 0},
|
|
{0, 1, 1, 0, 1, 1, 0},
|
|
{1, 1, 0, 0, 1, 1, 0},
|
|
{1, 1, 1, 1, 1, 1, 1},
|
|
{0, 0, 0, 0, 1, 1, 0},
|
|
{0, 0, 0, 0, 1, 1, 0},
|
|
{0, 0, 0, 0, 1, 1, 0},
|
|
},
|
|
"5": {
|
|
{1, 1, 1, 1, 1, 1, 1},
|
|
{1, 1, 1, 1, 1, 1, 1},
|
|
{1, 1, 0, 0, 0, 0, 0},
|
|
{1, 1, 0, 0, 0, 0, 0},
|
|
{0, 1, 1, 1, 1, 1, 0},
|
|
{0, 0, 0, 0, 0, 1, 1},
|
|
{0, 0, 0, 0, 0, 1, 1},
|
|
{1, 1, 1, 1, 1, 1, 1},
|
|
{0, 1, 1, 1, 1, 1, 0},
|
|
},
|
|
"6": {
|
|
{0, 1, 1, 1, 1, 1, 0},
|
|
{1, 1, 1, 1, 1, 1, 1},
|
|
{1, 1, 0, 0, 0, 0, 0},
|
|
{1, 1, 0, 0, 0, 0, 0},
|
|
{1, 1, 1, 1, 1, 1, 0},
|
|
{1, 1, 0, 0, 0, 1, 1},
|
|
{1, 1, 0, 0, 0, 1, 1},
|
|
{1, 1, 1, 1, 1, 1, 1},
|
|
{0, 1, 1, 1, 1, 1, 0},
|
|
},
|
|
"7": {
|
|
{1, 1, 1, 1, 1, 1, 1},
|
|
{1, 1, 1, 1, 1, 1, 1},
|
|
{0, 0, 0, 0, 0, 1, 1},
|
|
{0, 0, 0, 0, 0, 1, 1},
|
|
{0, 0, 0, 0, 1, 1, 0},
|
|
{0, 0, 0, 1, 1, 0, 0},
|
|
{0, 0, 1, 1, 0, 0, 0},
|
|
{0, 0, 1, 1, 0, 0, 0},
|
|
{0, 0, 1, 1, 0, 0, 0},
|
|
},
|
|
"8": {
|
|
{0, 1, 1, 1, 1, 1, 0},
|
|
{1, 1, 1, 1, 1, 1, 1},
|
|
{1, 1, 0, 0, 0, 1, 1},
|
|
{1, 1, 0, 0, 0, 1, 1},
|
|
{0, 1, 1, 1, 1, 1, 0},
|
|
{1, 1, 0, 0, 0, 1, 1},
|
|
{1, 1, 0, 0, 0, 1, 1},
|
|
{1, 1, 1, 1, 1, 1, 1},
|
|
{0, 1, 1, 1, 1, 1, 0},
|
|
},
|
|
"9": {
|
|
{0, 1, 1, 1, 1, 1, 0},
|
|
{1, 1, 1, 1, 1, 1, 1},
|
|
{1, 1, 0, 0, 0, 1, 1},
|
|
{1, 1, 0, 0, 0, 1, 1},
|
|
{0, 1, 1, 1, 1, 1, 1},
|
|
{0, 0, 0, 0, 0, 1, 1},
|
|
{0, 0, 0, 0, 0, 1, 1},
|
|
{1, 1, 1, 1, 1, 1, 1},
|
|
{0, 1, 1, 1, 1, 1, 0},
|
|
},
|
|
}
|
|
|
|
pattern, ok := charMaps[char]
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// shear: 每行相对中心行(4)的水平偏移
|
|
// shear=1: 行0偏移-4, 行8偏移+4 → 右倾
|
|
// shear=-1: 行0偏移+4, 行8偏移-4 → 左倾
|
|
for py, row := range pattern {
|
|
// 行偏移量 = (py - 4) * shear
|
|
rowShear := (py - 4) * shear
|
|
|
|
for px, v := range row {
|
|
if v == 1 {
|
|
ix := x + px*pixelSize + rowShear
|
|
iy := y + py*pixelSize
|
|
for dx := 0; dx < pixelSize; dx++ {
|
|
for dy := 0; dy < pixelSize; dy++ {
|
|
if ix+dx < img.Bounds().Max.X && iy+dy < img.Bounds().Max.Y {
|
|
img.Set(ix+dx, iy+dy, col)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func abs(x int) int {
|
|
if x < 0 {
|
|
return -x
|
|
}
|
|
return x
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|