签入版本
This commit is contained in:
Vendored
+118
@@ -0,0 +1,118 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcache"
|
||||
)
|
||||
|
||||
// 统一缓存门面:Redis 优先(g.Redis,配置读取 manifest/config 的 redis 节),
|
||||
// Redis 不可用时自动降级为进程内存(gcache),保证单机开发可用。
|
||||
var memFallback atomic.Bool
|
||||
|
||||
// Init 探测 Redis 连通性,失败则切换内存模式
|
||||
func Init(ctx context.Context) {
|
||||
if _, err := g.Redis().Do(ctx, "PING"); err != nil {
|
||||
memFallback.Store(true)
|
||||
g.Log().Warningf(ctx, "Redis 不可用(%v),缓存降级为进程内存模式", err)
|
||||
return
|
||||
}
|
||||
g.Log().Info(ctx, "Redis 缓存已连接")
|
||||
}
|
||||
|
||||
// UsingMemory 是否处于内存降级模式
|
||||
func UsingMemory() bool { return memFallback.Load() }
|
||||
|
||||
// Set 写入缓存(ttlSeconds<=0 表示不过期)
|
||||
func Set(ctx context.Context, key, value string, ttlSeconds int) error {
|
||||
if !memFallback.Load() {
|
||||
var err error
|
||||
if ttlSeconds > 0 {
|
||||
_, err = g.Redis().Do(ctx, "SETEX", key, ttlSeconds, value)
|
||||
} else {
|
||||
_, err = g.Redis().Do(ctx, "SET", key, value)
|
||||
}
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
memFallback.Store(true)
|
||||
g.Log().Warningf(ctx, "Redis 写入失败(%v),缓存降级为内存模式", err)
|
||||
}
|
||||
if ttlSeconds > 0 {
|
||||
return gcache.Set(ctx, key, value, time.Duration(ttlSeconds)*time.Second)
|
||||
}
|
||||
return gcache.Set(ctx, key, value, -1)
|
||||
}
|
||||
|
||||
// Get 读取缓存;第二个返回值表示是否存在
|
||||
func Get(ctx context.Context, key string) (string, bool) {
|
||||
if !memFallback.Load() {
|
||||
v, err := g.Redis().Do(ctx, "GET", key)
|
||||
if err == nil {
|
||||
if v.IsNil() {
|
||||
return "", false
|
||||
}
|
||||
return v.String(), true
|
||||
}
|
||||
memFallback.Store(true)
|
||||
g.Log().Warningf(ctx, "Redis 读取失败(%v),缓存降级为内存模式", err)
|
||||
}
|
||||
v, err := gcache.Get(ctx, key)
|
||||
if err != nil || v.IsNil() {
|
||||
return "", false
|
||||
}
|
||||
return v.String(), true
|
||||
}
|
||||
|
||||
// Del 删除缓存
|
||||
func Del(ctx context.Context, keys ...string) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
if !memFallback.Load() {
|
||||
args := make([]any, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
args = append(args, k)
|
||||
}
|
||||
if _, err := g.Redis().Do(ctx, "DEL", args...); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
for _, k := range keys {
|
||||
_, _ = gcache.Remove(ctx, k)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exists 判断键是否存在
|
||||
func Exists(ctx context.Context, key string) bool {
|
||||
if !memFallback.Load() {
|
||||
v, err := g.Redis().Do(ctx, "EXISTS", key)
|
||||
if err == nil {
|
||||
return v.Int() > 0
|
||||
}
|
||||
}
|
||||
ok, err := gcache.Contains(ctx, key)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// SetNX 不存在时写入(防重复提交),成功返回 true
|
||||
func SetNX(ctx context.Context, key, value string, ttlSeconds int) bool {
|
||||
if !memFallback.Load() {
|
||||
v, err := g.Redis().Do(ctx, "SET", key, value, "NX", "EX", ttlSeconds)
|
||||
if err == nil {
|
||||
return !v.IsNil() && v.String() == "OK"
|
||||
}
|
||||
}
|
||||
if Exists(ctx, key) {
|
||||
return false
|
||||
}
|
||||
_ = Set(ctx, key, value, ttlSeconds)
|
||||
return true
|
||||
}
|
||||
Vendored
+69
@@ -0,0 +1,69 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// 有序集合(ZSET)门面:在线用户等需要按分数排序与区间查询的场景。
|
||||
// Redis 模式走 ZADD/ZREM/ZRANGEBYSCORE;
|
||||
// 内存降级用进程内 map,读取时由调用方自行排序(单机语义)。
|
||||
|
||||
var (
|
||||
memZMu sync.RWMutex
|
||||
memZSet = map[string]map[string]float64{} // key → member → score
|
||||
)
|
||||
|
||||
// ZAdd 添加有序集合成员
|
||||
func ZAdd(ctx context.Context, key, member string, score float64) error {
|
||||
if !memFallback.Load() {
|
||||
_, err := g.Redis().Do(ctx, "ZADD", key, score, member)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
memFallback.Store(true)
|
||||
g.Log().Warningf(ctx, "Redis 写入失败(%v),缓存降级为内存模式", err)
|
||||
}
|
||||
memZMu.Lock()
|
||||
defer memZMu.Unlock()
|
||||
if memZSet[key] == nil {
|
||||
memZSet[key] = map[string]float64{}
|
||||
}
|
||||
memZSet[key][member] = score
|
||||
return nil
|
||||
}
|
||||
|
||||
// ZRem 移除有序集合成员
|
||||
func ZRem(ctx context.Context, key, member string) error {
|
||||
if !memFallback.Load() {
|
||||
if _, err := g.Redis().Do(ctx, "ZREM", key, member); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
memZMu.Lock()
|
||||
defer memZMu.Unlock()
|
||||
delete(memZSet[key], member)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ZRangeByScore 按分数区间返回成员(闭区间;max 传 math.Inf(1) 表示无上限)
|
||||
func ZRangeByScore(ctx context.Context, key string, min, max float64) ([]string, error) {
|
||||
if !memFallback.Load() {
|
||||
v, err := g.Redis().Do(ctx, "ZRANGEBYSCORE", key, min, max)
|
||||
if err == nil {
|
||||
return v.Strings(), nil
|
||||
}
|
||||
}
|
||||
memZMu.RLock()
|
||||
defer memZMu.RUnlock()
|
||||
m := memZSet[key]
|
||||
out := make([]string, 0, len(m))
|
||||
for member, score := range m {
|
||||
if score >= min && score <= max {
|
||||
out = append(out, member)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Package config 提供配置读取的环境变量覆盖接线
|
||||
//
|
||||
// 覆盖规则遵循 12-Factor 标准优先级:GF_ 前缀环境变量 > 配置文件。
|
||||
// 键名规则:配置路径大写、点换下划线并加 GF_ 前缀
|
||||
// (database.default.link → GF_DATABASE_DEFAULT_LINK)。
|
||||
// 实现方式为包装默认文件适配器(gcfg.Adapter 是框架官方扩展点),
|
||||
// 注册后全部 Get/MustGet 读取点零改动地获得覆盖能力。
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
"github.com/gogf/gf/v2/os/genv"
|
||||
)
|
||||
|
||||
// adapterEnvFirst 环境变量优先的配置适配器
|
||||
//
|
||||
// 仅覆盖配置文件中已存在的键:环境变量名由配置路径正向推导(无歧义),
|
||||
// 而反向拆分下划线无法还原 uploadDir 这类驼峰键名,故不做补键注入。
|
||||
// 文件返回的 map 会被就地叠加环境变量值,进程内环境恒定,重复叠加幂等。
|
||||
type adapterEnvFirst struct {
|
||||
base gcfg.Adapter // 默认文件适配器,环境未命中时回落
|
||||
}
|
||||
|
||||
// RegisterEnvOverride 注册环境变量优先的配置适配器(包装并替换默认文件适配器)
|
||||
//
|
||||
// 须在首次读取配置之前调用(服务启动入口最前,含缓存/数据库/Redis 初始化);
|
||||
// 未设置任何 GF_ 环境变量时行为与默认文件适配器完全一致。
|
||||
func RegisterEnvOverride() {
|
||||
gcfg.Instance().SetAdapter(&adapterEnvFirst{base: gcfg.Instance().GetAdapter()})
|
||||
}
|
||||
|
||||
// Available 委托文件适配器
|
||||
func (a *adapterEnvFirst) Available(ctx context.Context, resource ...string) bool {
|
||||
return a.base.Available(ctx, resource...)
|
||||
}
|
||||
|
||||
// Get 先查 GF_ 前缀环境变量,未命中回落文件适配器;
|
||||
// 文件返回 map(如 database 整节点)时对已有键逐叶叠加环境变量覆盖
|
||||
func (a *adapterEnvFirst) Get(ctx context.Context, pattern string) (any, error) {
|
||||
if v := envValue(pattern); v != nil {
|
||||
return v, nil
|
||||
}
|
||||
value, err := a.base.Get(ctx, pattern)
|
||||
if err != nil || value == nil {
|
||||
return value, err
|
||||
}
|
||||
if m, ok := value.(map[string]any); ok {
|
||||
overlayMap(m, pattern)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// Data 返回全量配置并叠加环境变量覆盖
|
||||
func (a *adapterEnvFirst) Data(ctx context.Context) (map[string]any, error) {
|
||||
data, err := a.base.Data(ctx)
|
||||
if err != nil || data == nil {
|
||||
return data, err
|
||||
}
|
||||
overlayMap(data, "")
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// envValue 按键路径取 GF_ 前缀环境变量;未设置或空值返回 nil(空串视为未设置,
|
||||
// 避免容器编排注入的空占位把配置文件中的有效值清空)
|
||||
func envValue(pattern string) any {
|
||||
if pattern == "" || pattern == "." {
|
||||
return nil
|
||||
}
|
||||
v := genv.Get("GF_" + strings.ToUpper(strings.ReplaceAll(pattern, ".", "_")))
|
||||
if v == nil || v.String() == "" {
|
||||
return nil
|
||||
}
|
||||
return v.Val()
|
||||
}
|
||||
|
||||
// overlayMap 对 map 中已存在的键递归叠加环境变量覆盖
|
||||
//
|
||||
// prefix 为当前层级的配置路径(顶层为空);仅处理标量覆盖,
|
||||
// 切片/数组不支持环境变量替换(本项目可覆盖键均为标量)
|
||||
func overlayMap(m map[string]any, prefix string) {
|
||||
for k, val := range m {
|
||||
path := k
|
||||
if prefix != "" && prefix != "." {
|
||||
path = prefix + "." + k
|
||||
}
|
||||
if env := envValue(path); env != nil {
|
||||
m[k] = env
|
||||
continue
|
||||
}
|
||||
if child, ok := val.(map[string]any); ok {
|
||||
overlayMap(child, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// ============================== 密码加密与校验 ==============================
|
||||
|
||||
// GenerateSalt 生成随机外部盐值
|
||||
//
|
||||
// 取 8 字节随机数经 base64url 编码后截取 10 个字符,去除填充
|
||||
func GenerateSalt() string {
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)[:10]
|
||||
}
|
||||
|
||||
// HashPassword 双重加盐加密
|
||||
//
|
||||
// 使用 bcrypt 对 password 与 salt 拼接后的内容加密,返回哈希值;
|
||||
// 两值分别写入 password 与 salt 列,salt 为空时等价于直接加密密码,兼容旧数据
|
||||
func HashPassword(password, salt string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password+salt), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// VerifyPassword 校验密码
|
||||
//
|
||||
// 使用 bcrypt 比对哈希值与 password 加 salt 的组合,salt 为空时直接比对,兼容旧数据
|
||||
func VerifyPassword(hashedPassword, password, salt string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password+salt)) == nil
|
||||
}
|
||||
|
||||
// IsBcrypt 判断是否为 bcrypt 格式
|
||||
//
|
||||
// 旧版 md5 加 salt 的数据返回 false
|
||||
func IsBcrypt(hashed string) bool {
|
||||
return len(hashed) > 4 && (hashed[:4] == "$2a$" || hashed[:4] == "$2b$" || hashed[:4] == "$2y$")
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Package excel 导入导出封装
|
||||
//
|
||||
// 基于 xuri/excelize/v2 实现,支持 xls 与 xlsx
|
||||
package excel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// ============================== Excel 导入导出 ==============================
|
||||
|
||||
// FileOpener 可打开的上传文件接口
|
||||
//
|
||||
// ghttp.UploadFile 与 multipart.FileHeader 均满足该接口
|
||||
type FileOpener interface {
|
||||
Open() (multipart.File, error)
|
||||
}
|
||||
|
||||
// ReadSheet 读取上传文件的首个工作表
|
||||
//
|
||||
// 返回表头与数据行列表,首行视为表头,所有单元格值统一转为字符串;
|
||||
// 空行会被跳过
|
||||
func ReadSheet(file FileOpener) (headers []string, rows [][]string, err error) {
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
xl, err := excelize.OpenReader(f)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("解析Excel失败: %v", err)
|
||||
}
|
||||
defer xl.Close()
|
||||
sheet := xl.GetSheetName(0)
|
||||
all, err := xl.GetRows(sheet)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(all) == 0 {
|
||||
return nil, nil, fmt.Errorf("Excel内容为空")
|
||||
}
|
||||
for _, h := range all[0] {
|
||||
headers = append(headers, strings.TrimSpace(h))
|
||||
}
|
||||
for _, row := range all[1:] {
|
||||
if isBlankRow(row) {
|
||||
continue
|
||||
}
|
||||
cells := make([]string, len(headers))
|
||||
for i := range headers {
|
||||
if i < len(row) {
|
||||
cells[i] = strings.TrimSpace(row[i])
|
||||
}
|
||||
}
|
||||
rows = append(rows, cells)
|
||||
}
|
||||
return headers, rows, nil
|
||||
}
|
||||
|
||||
// WriteSheet 写出 xlsx 文件
|
||||
//
|
||||
// 文件写入指定目录,文件名由基础名与时间戳拼接而成;
|
||||
// 返回文件名与完整路径
|
||||
func WriteSheet(dir, baseName string, headers []string, rows [][]any) (fileName, filePath string, err error) {
|
||||
if err = os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
xl := excelize.NewFile()
|
||||
defer xl.Close()
|
||||
sheet := xl.GetSheetName(0)
|
||||
_ = xl.SetSheetName(sheet, "data")
|
||||
sheet = "data"
|
||||
for i, h := range headers {
|
||||
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||
if err = xl.SetCellValue(sheet, cell, h); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
for r, row := range rows {
|
||||
for c, v := range row {
|
||||
cell, _ := excelize.CoordinatesToCellName(c+1, r+2)
|
||||
if err = xl.SetCellValue(sheet, cell, v); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
fileName = fmt.Sprintf("%s_%s.xlsx", baseName, time.Now().Format("20060102150405"))
|
||||
filePath = filepath.Join(dir, fileName)
|
||||
if err = xl.SaveAs(filePath); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return fileName, filePath, nil
|
||||
}
|
||||
|
||||
// isBlankRow 判断是否为空行
|
||||
//
|
||||
// 所有单元格均为空时返回 true
|
||||
func isBlankRow(row []string) bool {
|
||||
for _, c := range row {
|
||||
if strings.TrimSpace(c) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Package ip2region 基于 ip2region xdb 的 IP 归属地查询
|
||||
//
|
||||
// 供登录日志与操作日志的 location 字段使用。
|
||||
// 数据文件为 manifest/data/ip2region.xdb(IPv4),路径可用配置 ip2region.path 覆盖。
|
||||
package ip2region
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/lionsoul2014/ip2region/binding/golang/xdb"
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once // 保证查询器仅初始化一次
|
||||
searcher *xdb.Searcher // 归属地查询器
|
||||
mu sync.RWMutex // Searcher 底层共享 ReadSeeker,读操作加读锁
|
||||
loadErr error // 初始化错误
|
||||
)
|
||||
|
||||
// initSearcher 懒加载查询器
|
||||
//
|
||||
// 失败时仅告警一次,后续查询返回空
|
||||
func initSearcher() {
|
||||
once.Do(func() {
|
||||
path := "manifest/data/ip2region.xdb"
|
||||
if v, err := g.Cfg().Get(context.Background(), "ip2region.path"); err == nil && v.String() != "" {
|
||||
path = v.String()
|
||||
}
|
||||
searcher, loadErr = xdb.NewWithFileOnly(xdb.IPv4, path)
|
||||
if loadErr != nil {
|
||||
g.Log().Warningf(context.Background(), "ip2region 加载失败(%s): %v(归属地将显示为 -)", path, loadErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// City 根据 IP 返回城市信息
|
||||
//
|
||||
// 优先返回市,其次返回省,失败或内网地址返回空串。
|
||||
// 区域串段位兼容两种格式,各段可能为空:
|
||||
//
|
||||
// 新版:国家|省|市|ISP|国家码
|
||||
// 经典:国家|区域|省|市|ISP
|
||||
func City(ip string) string {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" || ip == "-" {
|
||||
return ""
|
||||
}
|
||||
// 内网/回环直接归空(xdb 返回 Reserved/LAN 等无意义值)
|
||||
if isPrivateIP(ip) {
|
||||
return ""
|
||||
}
|
||||
initSearcher()
|
||||
if searcher == nil {
|
||||
return ""
|
||||
}
|
||||
mu.RLock()
|
||||
region, err := searcher.Search(ip)
|
||||
mu.RUnlock()
|
||||
if err != nil || region == "" {
|
||||
return ""
|
||||
}
|
||||
// 典型内网占位值
|
||||
for _, kw := range []string{"Reserved", "LAN", "None", "广播"} {
|
||||
if strings.Contains(region, kw) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
parts := strings.Split(region, "|")
|
||||
var prov, city string
|
||||
if len(parts) >= 4 {
|
||||
p1 := strings.TrimSpace(parts[1])
|
||||
if isISP(parts[3]) || hasAdminSuffix(p1) {
|
||||
prov, city = p1, strings.TrimSpace(parts[2]) // 新版:第二段即省份
|
||||
} else {
|
||||
prov, city = strings.TrimSpace(parts[2]), strings.TrimSpace(parts[3]) // 经典:第三段起为省/市
|
||||
}
|
||||
switch {
|
||||
case city != "" && city != "0" && prov != "" && prov != "0":
|
||||
return prov + city
|
||||
case city != "" && city != "0":
|
||||
return city
|
||||
case prov != "" && prov != "0":
|
||||
return prov
|
||||
}
|
||||
}
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" && p != "0" && !isISP(p) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Location 返回归属地字段值
|
||||
//
|
||||
// 供登录日志与操作日志使用,无归属地时返回 "-"
|
||||
func Location(ip string) string {
|
||||
if c := City(ip); c != "" {
|
||||
return c
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
|
||||
// isPrivateIP 判断是否为内网或回环地址
|
||||
func isPrivateIP(ip string) bool {
|
||||
return strings.HasPrefix(ip, "127.") ||
|
||||
strings.HasPrefix(ip, "10.") ||
|
||||
strings.HasPrefix(ip, "192.168.") ||
|
||||
strings.HasPrefix(ip, "172.16.") ||
|
||||
strings.HasPrefix(ip, "172.17.") || strings.HasPrefix(ip, "172.18.") ||
|
||||
strings.HasPrefix(ip, "172.19.") || strings.HasPrefix(ip, "172.2") ||
|
||||
strings.HasPrefix(ip, "172.30.") || strings.HasPrefix(ip, "172.31.") ||
|
||||
ip == "::1" || ip == "0.0.0.0"
|
||||
}
|
||||
|
||||
// isISP 粗略识别 ISP 文本
|
||||
//
|
||||
// 避免将运营商名称误判为城市
|
||||
func isISP(s string) bool {
|
||||
for _, kw := range []string{"电信", "联通", "移动", "铁通", "教育", "科研", "广电"} {
|
||||
if strings.Contains(s, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasAdminSuffix 判断是否为行政区划名称
|
||||
//
|
||||
// 识别省、市、自治区、特别行政区等后缀,用于识别新版段位的省份段
|
||||
func hasAdminSuffix(s string) bool {
|
||||
for _, kw := range []string{"省", "市", "自治区", "特别行政区"} {
|
||||
if strings.HasSuffix(s, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// ============================== 令牌标识 ==============================
|
||||
|
||||
// newJTI 生成令牌唯一标识
|
||||
//
|
||||
// 生成 16 字节随机数并转为 32 位十六进制字符串,用作黑名单键;
|
||||
// 随机源读取失败时返回固定兜底值
|
||||
func newJTI() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "jti-fallback"
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gcode"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// ============================== JWT 令牌 ==============================
|
||||
|
||||
// TokenType 令牌类型
|
||||
const (
|
||||
TokenTypeAccess = "access" // 访问令牌
|
||||
TokenTypeRefresh = "refresh" // 刷新令牌
|
||||
)
|
||||
|
||||
// Claims JWT 载荷
|
||||
//
|
||||
// 包含身份信息、租户信息与令牌语义
|
||||
type Claims struct {
|
||||
UserID int `json:"user_id"` // 用户ID
|
||||
TenantID int `json:"tenant_id"` // 租户ID
|
||||
Username string `json:"username"` // 用户名
|
||||
Realname string `json:"realname"` // 真实姓名
|
||||
Type string `json:"typ"` // 令牌类型:access 或 refresh
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// cfg 读取字符串配置
|
||||
//
|
||||
// 配置缺失或为空时返回默认值
|
||||
func cfg(ctx context.Context, key string, def ...string) string {
|
||||
v := g.Cfg().MustGet(ctx, key)
|
||||
if v.IsNil() || v.String() == "" {
|
||||
if len(def) > 0 {
|
||||
return def[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return v.String()
|
||||
}
|
||||
|
||||
// cfgDuration 读取时长配置
|
||||
//
|
||||
// 配置值为秒数,缺失或非正时返回默认值
|
||||
func cfgDuration(ctx context.Context, key string, def time.Duration) time.Duration {
|
||||
v := g.Cfg().MustGet(ctx, key)
|
||||
if v.IsNil() || v.Int() <= 0 {
|
||||
return def
|
||||
}
|
||||
return time.Duration(v.Int()) * time.Second
|
||||
}
|
||||
|
||||
// Sign 签发令牌
|
||||
//
|
||||
// 返回签名后的令牌与载荷,载荷中的 ID 即 jti,可作为黑名单键
|
||||
func Sign(ctx context.Context, tokenType string, userID, tenantID int, username, realname string) (string, *Claims, error) {
|
||||
now := time.Now()
|
||||
expireSec := cfgDuration(ctx, "jwt.accessTokenExpire", 2*time.Hour)
|
||||
if tokenType == TokenTypeRefresh {
|
||||
expireSec = cfgDuration(ctx, "jwt.refreshTokenExpire", 168*time.Hour)
|
||||
}
|
||||
claims := &Claims{
|
||||
UserID: userID,
|
||||
TenantID: tenantID,
|
||||
Username: username,
|
||||
Realname: realname,
|
||||
Type: tokenType,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: newJTI(),
|
||||
Issuer: cfg(ctx, "jwt.issuer", "easygoadmin"),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(expireSec)),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, err := token.SignedString([]byte(cfg(ctx, "jwt.secret", "easygoadmin")))
|
||||
if err != nil {
|
||||
return "", nil, gerror.Wrap(err, "令牌签发失败")
|
||||
}
|
||||
return signed, claims, nil
|
||||
}
|
||||
|
||||
// Parse 解析并校验令牌
|
||||
//
|
||||
// 校验签名算法、签名、过期时间与签发者
|
||||
func Parse(ctx context.Context, tokenStr string) (*Claims, error) {
|
||||
claims := &Claims{}
|
||||
token, err := jwt.ParseWithClaims(tokenStr, claims,
|
||||
func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errInvalidToken()
|
||||
}
|
||||
return []byte(cfg(ctx, "jwt.secret", "easygoadmin")), nil
|
||||
},
|
||||
jwt.WithValidMethods([]string{"HS256"}),
|
||||
jwt.WithIssuer(cfg(ctx, "jwt.issuer", "easygoadmin")),
|
||||
jwt.WithExpirationRequired(),
|
||||
)
|
||||
if err != nil || !token.Valid {
|
||||
return nil, errInvalidToken()
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// errInvalidToken 构造无效令牌错误
|
||||
//
|
||||
// 状态码保持 401,供前端识别并跳转登录页
|
||||
func errInvalidToken() error {
|
||||
return gerror.NewCode(gcode.New(401, "Token无效或已失效", nil))
|
||||
}
|
||||
|
||||
// ExpireDuration 返回令牌有效期
|
||||
//
|
||||
// 用于黑名单 TTL 计算
|
||||
func ExpireDuration(ctx context.Context, tokenType string) time.Duration {
|
||||
if tokenType == TokenTypeRefresh {
|
||||
return cfgDuration(ctx, "jwt.refreshTokenExpire", 168*time.Hour)
|
||||
}
|
||||
return cfgDuration(ctx, "jwt.accessTokenExpire", 2*time.Hour)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
|
||||
"easygoadmin/internal/consts"
|
||||
)
|
||||
|
||||
// ============================== 统一响应 ==============================
|
||||
|
||||
// Res 统一响应结构
|
||||
//
|
||||
// 前端按此结构解析:
|
||||
// - 成功:code=0,前端仅在 code 为 0 时返回 data
|
||||
// - 失败:code=1,前端弹出 msg 提示
|
||||
// - 登录过期:code=401,前端跳转登录页
|
||||
type Res struct {
|
||||
Code int `json:"code"` // 业务状态码
|
||||
Ok bool `json:"ok"` // code 为 0 时为 true
|
||||
Msg string `json:"msg"` // 提示信息
|
||||
Data any `json:"data"` // 响应数据
|
||||
}
|
||||
|
||||
// PageData 分页数据结构
|
||||
//
|
||||
// 对应前端分页列表契约
|
||||
type PageData struct {
|
||||
List any `json:"list"` // 记录列表
|
||||
Total int `json:"total"` // 总记录数
|
||||
}
|
||||
|
||||
// Ok 构造成功响应
|
||||
//
|
||||
// data 为 nil 时输出空对象,避免前端判空逻辑失败
|
||||
func Ok(data any) *Res {
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
return &Res{Code: 0, Msg: "操作成功", Data: data}
|
||||
}
|
||||
|
||||
// OkMsg 构造带自定义提示语的成功响应
|
||||
func OkMsg(msg string, data any) *Res {
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
return &Res{Code: 0, Msg: msg, Data: data}
|
||||
}
|
||||
|
||||
// Page 构造分页响应
|
||||
func Page(list any, total int) *Res {
|
||||
return Ok(PageData{List: list, Total: total})
|
||||
}
|
||||
|
||||
// Fail 构造错误响应
|
||||
//
|
||||
// 状态码 401 保持原样,其余错误码统一映射为 1,供前端弹出 msg
|
||||
func Fail(err error) *Res {
|
||||
intCode := gerror.Code(err).Code()
|
||||
msg := err.Error()
|
||||
if msg == "" {
|
||||
msg = consts.CodeFailed.Message()
|
||||
}
|
||||
if intCode == 401 {
|
||||
return &Res{Code: 401, Msg: msg, Data: map[string]any{}}
|
||||
}
|
||||
return &Res{Code: 1, Msg: msg, Data: map[string]any{}}
|
||||
}
|
||||
|
||||
// Write 输出 JSON 响应
|
||||
//
|
||||
// 响应码记入上下文,供操作日志判断正常或异常
|
||||
func Write(r *ghttp.Request, res *Res) {
|
||||
WriteWithCode(r, res, res.Code)
|
||||
}
|
||||
|
||||
// respCodeKey 与 respBodyKey 为请求上下文中的响应码与响应体键
|
||||
//
|
||||
// 供操作日志读取
|
||||
var (
|
||||
respCodeKey = struct{ name string }{"respCode"}
|
||||
respBodyKey = struct{ name string }{"respBody"}
|
||||
)
|
||||
|
||||
// WriteWithCode 输出 JSON 响应并记录指定状态码
|
||||
func WriteWithCode(r *ghttp.Request, res *Res, code int) {
|
||||
res.Ok = code == 0
|
||||
r.SetCtxVar(respCodeKey, code)
|
||||
r.SetCtxVar(respBodyKey, res)
|
||||
r.Response.WriteJson(res)
|
||||
}
|
||||
|
||||
// GetCode 读取响应码
|
||||
//
|
||||
// 第二个返回值表示是否已写出响应
|
||||
func GetCode(r *ghttp.Request) (int, bool) {
|
||||
v := r.Context().Value(respCodeKey)
|
||||
if v == nil {
|
||||
return 0, false
|
||||
}
|
||||
code, _ := v.(int)
|
||||
return code, true
|
||||
}
|
||||
|
||||
// GetBody 读取已写出的响应信封
|
||||
//
|
||||
// 供操作日志记录响应内容使用
|
||||
func GetBody(r *ghttp.Request) (*Res, bool) {
|
||||
v := r.Context().Value(respBodyKey)
|
||||
if v == nil {
|
||||
return nil, false
|
||||
}
|
||||
res, ok := v.(*Res)
|
||||
return res, ok
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package ua
|
||||
|
||||
import "strings"
|
||||
|
||||
// ============================== User-Agent 解析 ==============================
|
||||
|
||||
// ParseOS 从 User-Agent 解析操作系统
|
||||
//
|
||||
// 供操作日志与登录日志记录使用,无法识别时返回 Unknown
|
||||
func ParseOS(userAgent string) string {
|
||||
ua := strings.ToLower(userAgent)
|
||||
switch {
|
||||
case strings.Contains(ua, "windows nt 10"):
|
||||
return "Windows 10/11"
|
||||
case strings.Contains(ua, "windows nt"):
|
||||
return "Windows"
|
||||
case strings.Contains(ua, "iphone"), strings.Contains(ua, "ipad"):
|
||||
return "iOS"
|
||||
case strings.Contains(ua, "android"):
|
||||
return "Android"
|
||||
case strings.Contains(ua, "mac os x"), strings.Contains(ua, "macintosh"):
|
||||
return "macOS"
|
||||
case strings.Contains(ua, "linux"):
|
||||
return "Linux"
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// ParseBrowser 从 User-Agent 解析浏览器
|
||||
//
|
||||
// 供操作日志与登录日志记录使用,无法识别时返回 Unknown
|
||||
func ParseBrowser(userAgent string) string {
|
||||
ua := userAgent
|
||||
switch {
|
||||
case strings.Contains(ua, "Edg/"), strings.Contains(ua, "Edge/"):
|
||||
return "Edge"
|
||||
case strings.Contains(ua, "OPR/"), strings.Contains(ua, "Opera"):
|
||||
return "Opera"
|
||||
case strings.Contains(ua, "Firefox/"):
|
||||
return "Firefox"
|
||||
case strings.Contains(ua, "Chrome/"), strings.Contains(ua, "CriOS"):
|
||||
return "Chrome"
|
||||
case strings.Contains(ua, "Safari/"):
|
||||
return "Safari"
|
||||
case strings.Contains(ua, "curl/"):
|
||||
return "curl"
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
// Package upload 文件上传
|
||||
//
|
||||
// 采用两段式流程:先落入临时目录,提交保存时转正至正式目录。
|
||||
// 1. SaveTemp:上传到 {uploadDir}/{tempDir}/日期/ 目录,含白名单与大小校验
|
||||
// 2. SaveFromTemp:表单提交保存时,将临时文件移动到正式子目录(如 user/article)
|
||||
//
|
||||
// 路径契约:数据库中统一存储相对 uploadDir 的路径,例如 "tenant/20260923/x.png";
|
||||
// 对外访问地址由 GetFileUrl 拼接 file.domainUrl 生成。
|
||||
package upload
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
)
|
||||
|
||||
// cfgType 文件上传配置
|
||||
//
|
||||
// 对应 manifest/config/config.yaml 中的 file 配置项
|
||||
type cfgType struct {
|
||||
UploadDir string // 上传根目录
|
||||
TempDir string // 临时目录名
|
||||
DomainUrl string // 文件访问域名
|
||||
ImageExt string // 允许的图片扩展名
|
||||
FileExt string // 允许的文件扩展名
|
||||
MaxSizeMB int // 单文件大小上限(MB)
|
||||
}
|
||||
|
||||
// loadCfg 加载文件上传配置
|
||||
//
|
||||
// 临时目录名缺省为 temp,大小上限缺省为 10MB
|
||||
func loadCfg(ctx context.Context) cfgType {
|
||||
c := cfgType{
|
||||
UploadDir: g.Cfg().MustGet(ctx, "file.uploadDir").String(),
|
||||
TempDir: g.Cfg().MustGet(ctx, "file.tempDir").String(),
|
||||
DomainUrl: g.Cfg().MustGet(ctx, "file.domainUrl").String(),
|
||||
ImageExt: g.Cfg().MustGet(ctx, "file.imageExt").String(),
|
||||
FileExt: g.Cfg().MustGet(ctx, "file.fileExt").String(),
|
||||
MaxSizeMB: g.Cfg().MustGet(ctx, "file.maxSize").Int(),
|
||||
}
|
||||
if c.TempDir == "" {
|
||||
c.TempDir = "temp"
|
||||
}
|
||||
if c.MaxSizeMB <= 0 {
|
||||
c.MaxSizeMB = 10
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Result 上传结果
|
||||
type Result struct {
|
||||
Url string // 可访问地址:domainUrl + /temp/20261013/xxx.png
|
||||
Path string // 相对 uploadDir 的路径,入库形态
|
||||
Name string // 原始文件名
|
||||
Size int64 // 文件大小(字节)
|
||||
Ext string // 扩展名
|
||||
Mime string // MIME 类型(按文件头内容嗅探)
|
||||
}
|
||||
|
||||
// BaseDir 上传根目录
|
||||
//
|
||||
// 取配置 file.uploadDir,文件统一落盘于此
|
||||
func BaseDir(ctx context.Context) string {
|
||||
return loadCfg(ctx).UploadDir
|
||||
}
|
||||
|
||||
// TempBaseDir 临时目录绝对路径
|
||||
//
|
||||
// 供清理任务使用
|
||||
func TempBaseDir(ctx context.Context) string {
|
||||
c := loadCfg(ctx)
|
||||
return filepath.Join(c.UploadDir, c.TempDir)
|
||||
}
|
||||
|
||||
// GetAccessBase 访问基址
|
||||
//
|
||||
// 取配置 file.domainUrl,即 uploadDir 的对外访问地址,不含末尾斜杠
|
||||
func GetAccessBase(ctx context.Context) string {
|
||||
return strings.TrimRight(loadCfg(ctx).DomainUrl, "/")
|
||||
}
|
||||
|
||||
// GetFileUrl 相对路径转可访问地址
|
||||
//
|
||||
// 库中存储相对 uploadDir 的路径,输出为 file.domainUrl 加相对路径;
|
||||
// 空值与已是完整 URL 的值原样返回
|
||||
func GetFileUrl(ctx context.Context, path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
return path
|
||||
}
|
||||
return GetAccessBase(ctx) + "/" + strings.TrimLeft(path, "/")
|
||||
}
|
||||
|
||||
// SaveTemp 上传到临时目录
|
||||
//
|
||||
// typ 为 file 或 image,分别按 fileExt 与 imageExt 白名单及 maxSize 校验;
|
||||
// 图片类型额外做内容嗅探
|
||||
func SaveTemp(ctx context.Context, file *ghttp.UploadFile, typ string) (*Result, error) {
|
||||
if file == nil {
|
||||
return nil, fmt.Errorf("请选择文件")
|
||||
}
|
||||
c := loadCfg(ctx)
|
||||
|
||||
// 1) 大小校验
|
||||
if file.Size > int64(c.MaxSizeMB)*1024*1024 {
|
||||
return nil, fmt.Errorf("文件大小超过限制(%dMB)", c.MaxSizeMB)
|
||||
}
|
||||
|
||||
// 2) 扩展名白名单
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(file.Filename), "."))
|
||||
if ext == "" {
|
||||
return nil, fmt.Errorf("无法识别文件扩展名")
|
||||
}
|
||||
allow := c.FileExt
|
||||
if typ == "image" {
|
||||
allow = c.ImageExt
|
||||
}
|
||||
if !extIn(ext, allow) {
|
||||
return nil, fmt.Errorf("不允许的文件类型: .%s", ext)
|
||||
}
|
||||
|
||||
// 3) 读取内容做图片嗅探(type=image 时)
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer src.Close()
|
||||
head := make([]byte, 512)
|
||||
n, _ := io.ReadFull(src, head)
|
||||
head = head[:n]
|
||||
if typ == "image" && !isImage(head) {
|
||||
return nil, fmt.Errorf("图片文件内容无效或已损坏")
|
||||
}
|
||||
// MIME 嗅探:按文件头内容识别,识别不出时回退通用二进制类型
|
||||
mime := "application/octet-stream"
|
||||
if n > 0 {
|
||||
if m := http.DetectContentType(head); m != "" {
|
||||
mime = m
|
||||
}
|
||||
}
|
||||
if _, err = src.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4) 落盘 temp/{日期}/{32位随机}.{ext}
|
||||
dateDir := time.Now().Format("20060102")
|
||||
relDir := filepath.Join(c.TempDir, dateDir)
|
||||
fullDir := filepath.Join(c.UploadDir, relDir)
|
||||
if err = os.MkdirAll(fullDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("创建上传目录失败: %v", err)
|
||||
}
|
||||
name := randomHex(16) + "." + ext
|
||||
full := filepath.Join(fullDir, name)
|
||||
dst, err := os.Create(full)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size, err := io.Copy(dst, src)
|
||||
if cerr := dst.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err != nil {
|
||||
_ = os.Remove(full)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rel := filepath.ToSlash(filepath.Join(relDir, name))
|
||||
return &Result{
|
||||
Url: GetFileUrl(ctx, rel),
|
||||
Path: rel,
|
||||
Name: file.Filename,
|
||||
Size: size,
|
||||
Ext: ext,
|
||||
Mime: mime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaveFromTemp 将临时文件转正
|
||||
//
|
||||
// 返回值即入库形态,为相对 uploadDir 的路径:
|
||||
// - 空值返回空串
|
||||
// - 本站 URL 或相对路径:剥离域名前缀,临时文件移动到 {uploadDir}/{saveDir}/{日期}/{时分秒}_{随机}.{ext}
|
||||
// - 外部 URL:原样返回
|
||||
func SaveFromTemp(ctx context.Context, fileUrl, saveDir string) string {
|
||||
if fileUrl == "" {
|
||||
return ""
|
||||
}
|
||||
c := loadCfg(ctx)
|
||||
|
||||
rel := fileUrl
|
||||
// 剥离本站域名(domainUrl 配置),得到相对 uploadDir 的路径
|
||||
stripped := false
|
||||
if c.DomainUrl != "" && strings.HasPrefix(rel, c.DomainUrl) {
|
||||
rel = strings.TrimPrefix(rel, c.DomainUrl)
|
||||
stripped = true
|
||||
}
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
|
||||
// 仅 temp 需要转移;已是正式相对路径则归一化后返回(域名已剥离)
|
||||
if !strings.HasPrefix(rel, c.TempDir+"/") {
|
||||
if !stripped && (strings.HasPrefix(rel, "http://") || strings.HasPrefix(rel, "https://")) {
|
||||
return fileUrl // 外部URL(未匹配本站域名)原样保留
|
||||
}
|
||||
return rel
|
||||
}
|
||||
srcFull := filepath.Join(c.UploadDir, filepath.FromSlash(rel))
|
||||
if _, err := os.Stat(srcFull); err != nil {
|
||||
return rel // 文件不存在则维持相对路径(幂等)
|
||||
}
|
||||
|
||||
ext := strings.TrimPrefix(filepath.Ext(rel), ".")
|
||||
if ext == "" {
|
||||
ext = "bin"
|
||||
}
|
||||
dateDir := time.Now().Format("20060102")
|
||||
name := time.Now().Format("150405") + "_" + randomHex(4) + "." + ext
|
||||
newRelDir := filepath.ToSlash(filepath.Join(strings.Trim(saveDir, "/"), dateDir))
|
||||
if err := os.MkdirAll(filepath.Join(c.UploadDir, filepath.FromSlash(newRelDir)), 0o755); err != nil {
|
||||
return rel
|
||||
}
|
||||
newRel := newRelDir + "/" + name
|
||||
dstFull := filepath.Join(c.UploadDir, filepath.FromSlash(newRel))
|
||||
if err := os.Rename(srcFull, dstFull); err != nil {
|
||||
// 跨卷回退:复制后删源
|
||||
if data, rerr := os.ReadFile(srcFull); rerr == nil {
|
||||
if werr := os.WriteFile(dstFull, data, 0o644); werr == nil {
|
||||
_ = os.Remove(srcFull)
|
||||
} else {
|
||||
return rel
|
||||
}
|
||||
} else {
|
||||
return rel
|
||||
}
|
||||
}
|
||||
return newRel
|
||||
}
|
||||
|
||||
// SaveFromTempMulti 逗号分隔多文件批量转正
|
||||
//
|
||||
// 适用于 article.images 这类多图字段
|
||||
func SaveFromTempMulti(ctx context.Context, fileUrls, saveDir string) string {
|
||||
if fileUrls == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(fileUrls, ",")
|
||||
for i, p := range parts {
|
||||
parts[i] = SaveFromTemp(ctx, strings.TrimSpace(p), saveDir)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// extIn 判断扩展名是否在逗号分隔的白名单内
|
||||
func extIn(ext, allow string) bool {
|
||||
for _, a := range strings.Split(allow, ",") {
|
||||
if strings.TrimSpace(a) == ext {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isImage 按魔数嗅探常见图片格式
|
||||
func isImage(b []byte) bool {
|
||||
if len(b) < 4 {
|
||||
return false
|
||||
}
|
||||
mime := http.DetectContentType(b)
|
||||
return strings.HasPrefix(mime, "image/")
|
||||
}
|
||||
|
||||
// randomHex 生成随机十六进制串
|
||||
//
|
||||
// n 字节随机数转为 2n 位十六进制字符,使用加密随机源防猜测
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
Reference in New Issue
Block a user