签入版本

This commit is contained in:
小蚂蚁云
2026-10-20 19:07:36 +08:00
commit 1b9bdac12f
882 changed files with 106953 additions and 0 deletions
+118
View File
@@ -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
}
+69
View File
@@ -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
}
+402
View File
@@ -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())
}
+97
View File
@@ -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)
}
}
}
+45
View File
@@ -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$")
}
+112
View File
@@ -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
}
+142
View File
@@ -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
}
+20
View File
@@ -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)
}
+125
View File
@@ -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)
}
+116
View File
@@ -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
}
+49
View File
@@ -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"
}
+290
View File
@@ -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)
}