fix(common): 新增文件类型检测工具并更新 Redis 依赖

This commit is contained in:
2026-09-03 13:07:27 +08:00
parent 7a94a208c0
commit 20ebcfa60b
10 changed files with 849 additions and 53 deletions
+49
View File
@@ -0,0 +1,49 @@
package utils
import (
"net/http"
"strings"
)
// DetectFileType 根据二进制内容推断 contentType + 扩展名(尽量稳定)。
// 纯 stdlib 实现(http.DetectContentType + 常见类型映射),不依赖模型网关。
// 本函数是统一实现;model-gateway/common/util/files.go 的本地副本后续委托到此处。
func DetectFileType(data []byte) (contentType string, ext string) {
if len(data) == 0 {
return "application/octet-stream", ""
}
ct := http.DetectContentType(data)
// DetectContentType 可能带 charset 等参数:text/plain; charset=utf-8
if idx := strings.Index(ct, ";"); idx > 0 {
ct = strings.TrimSpace(ct[:idx])
}
switch ct {
case "audio/mpeg":
return ct, ".mp3"
case "audio/wave", "audio/wav", "audio/x-wav":
return ct, ".wav"
case "video/mp4":
return ct, ".mp4"
case "image/png":
return ct, ".png"
case "image/jpeg":
return ct, ".jpg"
case "application/pdf":
return ct, ".pdf"
case "text/plain":
return ct, ".txt"
case "application/json":
return ct, ".json"
default:
// 兜底:尝试从 ct 截取 subtype 作为后缀(例如 application/json
if parts := strings.Split(ct, "/"); len(parts) == 2 {
sub := parts[1]
// 避免出现 "plain; charset=utf-8" 之类的后缀
if idx := strings.Index(sub, ";"); idx > 0 {
sub = strings.TrimSpace(sub[:idx])
}
return ct, "." + sub
}
return ct, ""
}
}
+54
View File
@@ -0,0 +1,54 @@
package utils
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
// HeadersOptions 控制请求头透传的差异层。基础两层恒有:
// 1. 透传 HTTP 请求头(含 Authorization/X-User-Info
// 2. X-User-Info 为空且 ctx 携带 user(恢复续跑/异步任务注入的合成用户)时补 X-User-Info
type HeadersOptions struct {
ResolveToken bool // X-User-Info 仍为空时,解析调用方 token 得到用户注入(直连场景)
TokenFromQuery bool // Authorization 为空时从 URL query ?token= 补 BearerWS 握手)
}
// HeadersFromCtx 统一构造调用方请求头透传 map。全项目唯一的拼头入口
//
// 放这里而非 common/http:本函数只依赖 GetUserInfo(同包),与 gclient/gsvc/consul/jaeger 客户端
// 基础设施零耦合;common/http 空导入 common/consul、common/jaegerinit 里 g.Cfg().MustGet 无默认值,
// 无配置文件环境会 panic),common/oss 等消费方特意避开它,放 utils 依赖链干净。
func HeadersFromCtx(ctx context.Context, opts ...HeadersOptions) map[string]string {
op := HeadersOptions{}
if len(opts) > 0 {
op = opts[0]
}
headers := make(map[string]string)
if r := g.RequestFromCtx(ctx); r != nil {
for k, v := range r.Request.Header {
if len(v) > 0 {
headers[k] = v[0]
}
}
}
if headers["X-User-Info"] == "" {
if u := ctx.Value("user"); !g.IsNil(u) {
headers["X-User-Info"] = gconv.String(u)
}
}
if op.TokenFromQuery && headers["Authorization"] == "" {
if r := g.RequestFromCtx(ctx); r != nil {
if t := r.Request.URL.Query().Get("token"); t != "" {
headers["Authorization"] = "Bearer " + t
}
}
}
if op.ResolveToken && headers["X-User-Info"] == "" {
if user, err := GetUserInfo(ctx); err == nil && user != nil {
headers["X-User-Info"] = gconv.String(user)
}
}
return headers
}
-41
View File
@@ -1,41 +0,0 @@
package utils
import (
"context"
"fmt"
"regexp"
"github.com/gogf/gf/v2/frame/g"
)
// ossObjectPathPattern 匹配 MinIO 上传生成的对象路径(不带 http 前缀的相对路径):
// /YYYY-MM-DD/32位uuid.扩展名,如 /2026-08-19/1e9d9e48-3f6b-4a2c-8d5e-1f2a3b4c.png
var ossObjectPathPattern = regexp.MustCompile(`^/\d{4}-\d{2}-\d{2}/[0-9a-fA-F-]{32}\.[a-zA-Z0-9]{1,10}$`)
// IsOSSPath 判断字符串是否为 MinIO 对象路径(无 http(s) 前缀)。
// 模型网关把结果转存 OSS 后返回该裸路径,消费方据此识别"已是文件路径"而不再重复上传。
// 对象命名规则见 oss/minio 的 ensureBucketAndObjectName,格式变化只需改这一处。
func IsOSSPath(s string) bool {
return ossObjectPathPattern.MatchString(s)
}
// GetFileAddressPrefix 拼接图片前缀地址
func GetFileAddressPrefix(ctx context.Context) (imageUrl string, err error) {
// 拼接图片前缀地址
bucketName, err := GetBucketName(ctx)
if err != nil {
return
}
imageUrl = fmt.Sprintf("%s/%s", g.Cfg().MustGet(ctx, "filePrefix").String(), bucketName)
return
}
// GetBucketName 获取bucket名称
func GetBucketName(ctx context.Context) (bucketName string, err error) {
user, err := GetUserInfo(ctx)
if err != nil {
return
}
bucketName = fmt.Sprintf("tenantid-%d", user.TenantId)
return
}
+171
View File
@@ -0,0 +1,171 @@
// Package utils 提供统一的 Redis 计数原语(与 redislock 的「互斥锁」互补,本包是「计数」一族)。
//
// 只保留 model-gateway 实际业务场景用到、且全仓有调用方的两个原语:
// - SemaphoreINCR 信号量(model-gateway/service/queue/semaphore.go),并发额度计数
// —— asyncWorker 按模型并发上限抢信号量,超限放回队列(task/worker.go:51);
// - SafeDecr:安全回减(model-gateway/model_call_service.go decrSlotLua),计数防负
// —— 并发名额释放、超限回滚时原子夹在 >=0。
//
// ZSET 槽位幂等闸门 queue_gate.go、固定窗口限流 rate_limiter.go 全仓无调用方,已删除;后续用到再加。)
//
// 与 redislock 一致,本包不写 Lua 脚本:
// - 多步原子操作(判定 + 写)经 go-redis WATCH/MULTI/EXEC 事务组合原生命令,
// 通过 gogf 官方 escape hatch GetAdapter().Client() 取底层客户端执行。
//
// 组合模式(如 model-gateway reserveSlot 的 锁+计数):
// redislock.WithLock + 本包 SafeDecr。
//
// 兼容红线:只用 gf v2.9.5 已有 APISet/Get/Incr/Decr、GetAdapter().Client())。
package utils
import (
"context"
"errors"
"fmt"
"time"
"github.com/gogf/gf/v2/frame/g"
goredis "github.com/redis/go-redis/v9"
)
// errSkip 内部信号:WATCH 事务里判定不通过,不执行写。
// 调用方据 errors.Is(err, errSkip) 视为「本次操作失败但非异常」。
var errSkip = errors.New("rediscount: 条件不满足,跳过写入")
// rawClient 取底层 go-redis 客户端(gogf 官方 escape hatch)。
// 各服务 main.go 空导入 contrib/nosql/redis/v2 后 Adapter 为 go-redis 实现,
// Client() 返回 redis.UniversalClient(单节点 / 哨兵 / 集群均实现 Watch)。
func rawClient() (goredis.UniversalClient, error) {
universal, ok := g.Redis().GetAdapter().Client().(goredis.UniversalClient)
if !ok {
return nil, errors.New("redis 底层客户端非 UniversalClient,无法执行 WATCH 原子事务")
}
return universal, nil
}
// tx 在 WATCH/MULTI/EXEC 事务里执行 fn(先读后写,无 Lua):
// fn 里先发读命令判定,命中条件后经 tx.TxPipelined 排队写命令,EXEC 原子提交。
// 判定不通过时 fn 返回 errSkip(不写);WATCH 冲突(TxFailedErr,读与写之间被并发改动)自动重试。
func tx(ctx context.Context, key string, fn func(tx *goredis.Tx) error) error {
c, err := rawClient()
if err != nil {
return err
}
for {
err := c.Watch(ctx, func(tx *goredis.Tx) error { return fn(tx) }, key)
if errors.Is(err, goredis.TxFailedErr) {
continue // 冲突,重读重试
}
return err
}
}
// pipedInt 取出事务写队列中第 n 条命令(INCR/DECR)的执行结果。
func pipedInt(cmds []goredis.Cmder, n int) (int64, error) {
return cmds[n].(*goredis.IntCmd).Result()
}
// SemaphoreAcquire 获取并发额度:WATCH key → GET 判满 → MULTI/INCR + 首设 EXPIRE/EXEC。
// 原子判定未满才 INCR;首次(计数从 0/不存在起)顺手设 TTL 自动回收防泄漏;超限不写、返回 false。
// 对应 model-gateway asyncWorker 按模型并发上限抢信号量。max<=0 表示不限制;ttlSeconds<=0 时按 3600 兜底。
func SemaphoreAcquire(ctx context.Context, key string, max int, ttlSeconds int64) (bool, error) {
if max <= 0 {
return true, nil
}
if ttlSeconds <= 0 {
ttlSeconds = 3600
}
err := tx(ctx, key, func(tx *goredis.Tx) error {
current, err := tx.Get(ctx, key).Int64()
if errors.Is(err, goredis.Nil) {
current = 0
} else if err != nil {
return err
}
if current >= int64(max) {
return errSkip // 已满
}
_, err = tx.TxPipelined(ctx, func(pipe goredis.Pipeliner) error {
pipe.Incr(ctx, key)
if current == 0 {
pipe.Expire(ctx, key, time.Duration(ttlSeconds)*time.Second)
}
return nil
})
return err
})
if errors.Is(err, errSkip) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("获取并发额度失败: %w", err)
}
return true, nil
}
// SemaphoreRelease 释放并发额度(幂等,计数归零自动删除 key)。
// WATCH key → GET → MULTI:计数>1 仅 DECR==1 时 DECR+DEL<=0 直接 DEL(清理残留,不造负)。
// 全部原子,避免「DECR 后发现归零、中间被并发 INCR 抢占、DEL 误删新额度」的丢失更新。
func SemaphoreRelease(ctx context.Context, key string) error {
err := tx(ctx, key, func(tx *goredis.Tx) error {
current, err := tx.Get(ctx, key).Int64()
if errors.Is(err, goredis.Nil) {
current = 0
} else if err != nil {
return err
}
_, err = tx.TxPipelined(ctx, func(pipe goredis.Pipeliner) error {
switch {
case current <= 0:
pipe.Del(ctx, key)
case current == 1:
pipe.Decr(ctx, key)
pipe.Del(ctx, key)
default:
pipe.Decr(ctx, key)
}
return nil
})
return err
})
if err != nil {
return fmt.Errorf("释放并发额度失败: %w", err)
}
return nil
}
// SafeDecr 安全回减:WATCH key → GET,仅当 key 存在且值>0 才 MULTI/DECR/EXEC
// 杜绝 Redis DECR 对缺失 Key 按 0-1 处理把计数造负。
// 对应 model-gateway decrSlot:并发名额释放 / 超限回滚时原子夹在 >=0。
// 返回回减后的计数值;未回减(key 不存在或已为 0)返回 -1。
func SafeDecr(ctx context.Context, key string) (int64, error) {
var result int64
err := tx(ctx, key, func(tx *goredis.Tx) error {
current, err := tx.Get(ctx, key).Int64()
if errors.Is(err, goredis.Nil) {
return errSkip // 不存在,不写
}
if err != nil {
return err
}
if current <= 0 {
return errSkip // 已为 0,不造负
}
cmds, err := tx.TxPipelined(ctx, func(pipe goredis.Pipeliner) error {
pipe.Decr(ctx, key)
return nil
})
if err != nil {
return err
}
result, err = pipedInt(cmds, 0)
return err
})
if errors.Is(err, errSkip) {
return -1, nil
}
if err != nil {
return -1, err
}
return result, nil
}
+183
View File
@@ -0,0 +1,183 @@
// Package utils 提供统一的 Redis 分布式锁(防并发互斥)。
//
// 收敛全仓「SET key token EX ttl NX + 归属校验释放」范式
// (此前 ai-agent / shop-user-trade / model-gateway 三处逐字复制)。
// 只暴露一个入口:
// - WithLock:抢锁 + 自动续期 + 临界区 fn + 返回时保证释放。
//
// 正确性要点:
// - value 存 uuid token 标识持有者(不存 true),key 标识资源;
// - 释放 / 续期均按 token 比对归属:锁 TTL 过期易主后,旧持有者的释放不会误删新持有者的锁,
// 续期也不会把锁无限续到他人头上;
// - 归属比对经 go-redis WATCH/MULTI/EXEC 原子事务完成(通过 gogf 官方 escape hatch
// GetAdapter().Client() 取底层客户端),不手写 Lua 脚本。
//
// 兼容红线:本包只用 gf v2.9.5 已有 APISet+SetOption{NX,EX}、GetAdapter().Client())。
package utils
import (
"context"
"errors"
"time"
"github.com/gogf/gf/v2/database/gredis"
"github.com/gogf/gf/v2/frame/g"
"github.com/google/uuid"
goredis "github.com/redis/go-redis/v9"
)
// Lock 分布式锁(防并发互斥),委托 common/redislock.WithLock4 次尝试,间隔 500ms)。
// 释放 / 续期按 token 归属比对(WATCH/MULTI/EXEC 原子事务,不写 Lua):
// 锁 TTL 过期易主后,旧持有者的释放不会误删新持有者的锁。
// fn 返回 err 时 success=false、err 上抛。
func Lock(ctx context.Context, key string, expireSeconds int64, fn func(ctx context.Context) error) (success bool, err error) {
_, err = WithLock(ctx, key, expireSeconds, fn, 4)
if err != nil {
return false, err
}
return true, nil
}
// WithLock 函数式锁:抢锁(可选重试次数),成功后自动续期(默认超过一半 TTL 就续期),
// 执行 fn,返回时停止续期并保证释放(WATCH/MULTI 按 token 归属释放,防误删他人锁)。
//
// retryTimes 传了循环次数:最多尝试那么多次,仍抢不到返回锁占用错误;
// retryTimes 未传:无限等待,一直重试直到抢到锁或 ctx 取消。
// 两次尝试间隔固定 500ms。
//
// 返回值:
// - (true, nil):抢到锁并执行成功;
// - (true, fn 的 err):抢到锁、fn 执行失败(业务错误由 err 单独表达);
// - (false, err):重试次数耗尽(锁占用)、Redis 故障或 ctx 被取消,均以 err 表达。
//
// 续期与释放均用 context.WithoutCancel(ctx):即使 fn 中途 ctx 被取消,锁仍能正常续期并释放,
// 不会残留到 TTL 造成下一个持有者等待。
func WithLock(ctx context.Context, key string, ttlSeconds int64, fn func(ctx context.Context) error, retryTimes ...int) (bool, error) {
maxRetries := -1
if len(retryTimes) > 0 {
maxRetries = retryTimes[0]
}
l := &lock{key: key, token: uuid.NewString(), ttl: ttlSeconds}
for attempt := 0; ; attempt++ {
if maxRetries >= 0 && attempt >= maxRetries {
return false, errors.New("redis lock busy")
}
ok, err := l.acquire(ctx)
if err != nil {
return false, err
}
if ok {
break
}
select {
case <-ctx.Done():
return false, ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
stop := make(chan struct{})
go l.renewLoop(context.WithoutCancel(ctx), stop)
defer func() {
close(stop)
_ = l.release(context.WithoutCancel(ctx))
}()
if err := fn(ctx); err != nil {
return true, err
}
return true, nil
}
// lock 单次锁会话:token 标识持有者身份,释放 / 续期按 token 原子比对。
// 不导出——生命周期全部由 WithLock 编排。
type lock struct {
key string
token string
ttl int64 // 秒
}
// acquire 抢锁;返回 true 表示抢到(SET NX 成功)。
// 注意:gogf 新版 SetNX(ctx,key,value) 不接收 TTL 参数,直接 SETNX 会永不过期(崩溃后死锁);
// 故改用 Set + SetOption{NX,TTLOption{EX}},原子地执行 `SET key token EX <ttl> NX`。
func (l *lock) acquire(ctx context.Context) (bool, error) {
r, err := g.Redis().Set(ctx, l.key, l.token, gredis.SetOption{
TTLOption: gredis.TTLOption{EX: &l.ttl},
NX: true,
})
if err != nil {
return false, err
}
// SET NX 失败时 Redis 返回空回复(nil),gogf 转为值 nil 的 gvar;成功时返回 "OK"
return !r.IsNil(), nil
}
// release 释放锁:WATCH key → GET 比对 token → MULTI/DEL/EXEC,仅当仍为本锁 token 时删除。
// 非持有者调用是 no-op:锁过期易主后,旧持有者的释放不会删掉新持有者的锁。
func (l *lock) release(ctx context.Context) error {
return l.compareAndWrite(ctx, false)
}
// renew 续期:WATCH key → GET 比对 token → MULTI/EXPIRE/EXEC,仅当仍为本锁 token 时重置 TTL。
// 锁已易主时 no-op,避免续到他人锁上(把新持有者的锁无限延长)。
func (l *lock) renew(ctx context.Context) error {
return l.compareAndWrite(ctx, true)
}
// compareAndWrite 归属比对 + 原子写(WATCH/MULTI/EXEC,无 Lua):
// - 锁不存在(已过期 / 被删):no-op;
// - key 值 ≠ 本锁 token(已易主):no-op
// - key 值 == 本锁 tokenrenew=true 时重置 TTL,否则删除。
//
// WATCH 保证:比对与写之间若 key 被其他客户端改动,EXEC 会被服务端中止(返回空),写不生效。
// 经 gogf 官方 escape hatchGetAdapter().Client())取底层 go-redis 客户端执行原子事务;
// 各服务 main.go 空导入 contrib/nosql/redis/v2 后 Adapter 为 go-redis 实现,
// Client() 返回 redis.UniversalClient(单节点 / 哨兵 / 集群均实现 Watch)。
func (l *lock) compareAndWrite(ctx context.Context, renew bool) error {
universal, ok := g.Redis().GetAdapter().Client().(goredis.UniversalClient)
if !ok {
return errors.New("redis 底层客户端非 UniversalClient,无法执行 WATCH 原子事务")
}
return universal.Watch(ctx, func(tx *goredis.Tx) error {
val, err := tx.Get(ctx, l.key).Result()
if errors.Is(err, goredis.Nil) {
return nil // 锁已过期 / 被删
}
if err != nil {
return err
}
if val != l.token {
return nil // 锁已易主,不动他人锁
}
_, err = tx.TxPipelined(ctx, func(pipe goredis.Pipeliner) error {
if renew {
pipe.Expire(ctx, l.key, time.Duration(l.ttl)*time.Second)
} else {
pipe.Del(ctx, l.key)
}
return nil
})
return err
}, l.key)
}
// renewLoop 自动续期看门狗:每 ttl/2 续一次(「超过一半就续期」)。
// 续期失败仅记日志,不打断临界区(WATCH 事务在锁易主时会因 token 不匹配安全地 no-op)。
// WithLock 内部启动,临界区结束即停止。
func (l *lock) renewLoop(ctx context.Context, stop <-chan struct{}) {
ticker := time.NewTicker(time.Duration(l.ttl) * time.Second / 2)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ctx.Done():
return
case <-ticker.C:
if err := l.renew(context.WithoutCancel(ctx)); err != nil {
g.Log().Warningf(ctx, "redislock 续期失败: key=%s err=%v", l.key, err)
}
}
}
}
+4 -39
View File
@@ -3,7 +3,6 @@ package utils
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"reflect"
@@ -14,12 +13,10 @@ import (
"time"
"gitea.redpowerfuture.com/red-future/common/beans"
"github.com/gogf/gf/v2/container/gvar"
"github.com/gogf/gf/v2/database/gredis"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/glog"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
"github.com/tiger1103/gfast-token/gftoken"
@@ -107,7 +104,8 @@ func GetUserInfo(ctx context.Context) (user *beans.User, err error) {
if !g.IsNil(ctx.Value("token")) {
var tokenData *gftoken.TokenData
tokenData, _, err = gft.GetTokenData(ctx, ctx.Value("token").(string))
tk := ctx.Value("token").(string)
tokenData, _, err = gft.GetTokenData(ctx, tk)
if err != nil {
return user, gerror.Wrap(err, "ctx token 解析失败")
}
@@ -117,7 +115,8 @@ func GetUserInfo(ctx context.Context) (user *beans.User, err error) {
}
} else if g.RequestFromCtx(ctx) != nil {
// 解析 token
data, err = gft.ParseToken(g.RequestFromCtx(ctx))
req := g.RequestFromCtx(ctx)
data, err = gft.ParseToken(req)
if err != nil {
return user, gerror.Wrap(err, "token 解析失败")
}
@@ -389,40 +388,6 @@ func intPow10(n int) int {
return result
}
// Lock 分布式锁
func Lock(ctx context.Context, key string, expireSeconds int64, fn func(ctx context.Context) error) (success bool, err error) {
limit := 3
LOOP:
if limit < 0 {
return false, errors.New("锁重试次数耗尽")
}
limit--
var val *gvar.Var
if val, err = g.Redis().Set(ctx, key, true, gredis.SetOption{
TTLOption: gredis.TTLOption{
EX: &expireSeconds,
},
NX: true,
}); err != nil {
return false, err
}
if val.Bool() {
defer func(ctx context.Context, key string) {
if _, err = g.Redis().Del(ctx, key); err != nil {
glog.Errorf(ctx, "redis client Del error: %v", err)
}
}(ctx, key)
if err = fn(ctx); err != nil {
return false, err
}
return true, nil
}
time.Sleep(time.Second)
goto LOOP
}
// IsLocalIP 判断是否是本地IP
func IsLocalIP(ip string) bool {
addrs, err := net.InterfaceAddrs()