Files
team-api/internal/logic/common/cache.go
T
Qianandchengqian be9e707bfd 修复异步图片导致的多个问题 (#12)
* fix:修复响应污染问题

* fix:修改存储相关文件为中文注释

* fix:修复二次退款问题

* fix:修复多个考虑不全面问题
1.误删用户数据
2.R2存储默认区域
3.删除失败导致孤儿记录
4.网络抖动事变时重试
5.前端无限次请求获取图片
6.文件管理权限

* fix:修复字符串截断异常问题和文件下载超时问题

* fix:修复多图问题

* fix:异步生成图片时未配置oss提示错误信息

* fix:修复关闭异步后同步生成出错问题

* feat:新增文件存储测试功能,防止设置后不生效

* fix:修复同步图片转异步后计费错误

* fix:修复无下限扣款问题

* fix:修复结算重复扣款问题

* fix:修复计费出错导致的0费用结算

* fix:修复回调双重履约

* fix:修复预扣事务

* fix:修复费用字段类型问题

* fix:修复缓存问题

* fix:修复安全问题

* fix:修复数据库操作事务问题

* refactor:重构事务使用方式,改为官方推荐的dao形式

* fix:修复余额更新隐患

* fix:修复N+1查询问题

* fix:修复密钥安全问题

* fix:结算失败时打印错误日志

* improve:优化扣款

* fix:修复用户接口越权

---------

Co-authored-by: chengqian <wuchengqian@kkidc.com>
2026-07-22 17:53:18 +08:00

248 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package common
import (
"context"
"encoding/json"
"time"
"github.com/gogf/gf/v2/database/gredis"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcache"
"github.com/gogf/gf/v2/util/gconv"
)
// Cache provides a two-level caching mechanism:
// L1: In-memory cache (GoFrame gcache, handles Go structs natively)
// L2: Redis cache (stores JSON-serialized values, deserializes on read)
//
// Write: Set L1 (native) + Set L2 (JSON)
// Read: Get L1 → miss → Get L2 (JSON → struct) → miss → call fn → Set L1+L2
type Cache struct {
prefix string
ttl time.Duration
}
// NewCache creates a new Cache instance with the given prefix and default TTL.
func NewCache(prefix string, ttl time.Duration) *Cache {
return &Cache{
prefix: prefix,
ttl: ttl,
}
}
// fullKey returns the full cache key with prefix.
func (c *Cache) fullKey(key string) string {
return c.prefix + ":" + key
}
// Set sets a value in both L1 (memory) and L2 (Redis) caches.
// L1 stores the value natively (Go struct pointers work).
// L2 stores a JSON-serialized copy for cross-process compatibility.
func (c *Cache) Set(ctx context.Context, key string, value any, ttl ...time.Duration) {
expire := c.ttl
if len(ttl) > 0 && ttl[0] > 0 {
expire = ttl[0]
}
fullKey := c.fullKey(key)
// L1: memory cache (native Go struct storage)
gcache.Set(ctx, fullKey, value, expire)
// L2: Redis cache (JSON serialized)
ttlSeconds := int64(expire.Seconds())
jsonBytes, err := json.Marshal(value)
if err != nil {
g.Log().Warningf(ctx, "[Cache] JSON marshal failed key=%s: %v", fullKey, err)
return
}
if ttlSeconds > 0 {
_, _ = g.Redis().Do(ctx, "SETEX", fullKey, ttlSeconds, string(jsonBytes))
} else {
// 兜底:ttl ≤ 0 时使用 24h 兜底 TTL,避免产生永久 key
g.Log().Warningf(ctx, "[Cache] TTL <= 0 for key=%s, using fallback 24h TTL", fullKey)
_, _ = g.Redis().Do(ctx, "SETEX", fullKey, 86400, string(jsonBytes))
}
}
// Get retrieves a value: L1 → L2 → miss.
// L1 returns native Go types. L2 deserializes JSON back to the original type.
func (c *Cache) Get(ctx context.Context, key string) (any, bool) {
fullKey := c.fullKey(key)
// Try L1: memory (native Go struct)
val, err := gcache.Get(ctx, fullKey)
if err == nil && val != nil {
return val.Interface(), true
}
// Try L2: Redis (JSON → deserialize)
redisVal, err := g.Redis().Do(ctx, "GET", fullKey)
if err == nil && !redisVal.IsNil() {
jsonStr := redisVal.String()
if jsonStr != "" {
// Unmarshal JSON to get the original Go value
var raw any
if unmarshalErr := json.Unmarshal([]byte(jsonStr), &raw); unmarshalErr == nil {
gcache.Set(ctx, fullKey, raw, c.ttl)
return raw, true
}
// Fallback: treat as plain string (backward compat)
gcache.Set(ctx, fullKey, jsonStr, c.ttl)
return jsonStr, true
}
}
return nil, false
}
// GetJSON retrieves a JSON-serialized value from L2 and unmarshals it into target.
// Use this when caching Go struct pointers to avoid type assertion panics.
func (c *Cache) GetJSON(ctx context.Context, key string, target any) bool {
fullKey := c.fullKey(key)
// Try L1: memory (native Go struct — correct type already)
val, err := gcache.Get(ctx, fullKey)
if err == nil && val != nil {
// L1 has native value; copy via JSON round-trip to populate target
if jsonBytes, err := json.Marshal(val.Interface()); err == nil {
return json.Unmarshal(jsonBytes, target) == nil
}
}
// Try L2: Redis (JSON string)
redisVal, err := g.Redis().Do(ctx, "GET", fullKey)
if err == nil && !redisVal.IsNil() {
jsonStr := redisVal.String()
if jsonStr != "" {
if unmarshalErr := json.Unmarshal([]byte(jsonStr), target); unmarshalErr != nil {
g.Log().Warningf(ctx, "[Cache] JSON unmarshal failed key=%s: %v", fullKey, unmarshalErr)
return false
}
// Backfill L1
gcache.Set(ctx, fullKey, target, c.ttl)
return true
}
}
return false
}
// GetOrSet retrieves a value or calls fn to set it if missing.
func (c *Cache) GetOrSet(ctx context.Context, key string, fn func(ctx context.Context) (any, error)) (any, error) {
if val, ok := c.Get(ctx, key); ok {
return val, nil
}
val, err := fn(ctx)
if err != nil {
return nil, err
}
c.Set(ctx, key, val)
return val, nil
}
// Delete removes a value from both L1 and L2.
func (c *Cache) Delete(ctx context.Context, key string) {
fullKey := c.fullKey(key)
// L1
gcache.Remove(ctx, fullKey)
// L2
_, _ = g.Redis().Do(ctx, "DEL", fullKey)
// Publish invalidation for other instances
_, _ = g.Redis().Do(ctx, "PUBLISH", "cache:invalidate", fullKey)
}
// DeleteByPattern removes all cache entries matching the pattern.
// Uses SCAN + DEL to properly handle wildcard patterns (Redis DEL does not support wildcards).
func (c *Cache) DeleteByPattern(ctx context.Context, pattern string) {
fullPattern := c.fullKey(pattern)
// L2: Redis — SCAN matching keys and DEL them
cursor := int64(0)
for {
result, err := g.Redis().Do(ctx, "SCAN", cursor, "MATCH", fullPattern, "COUNT", 100)
if err != nil {
g.Log().Warningf(ctx, "[Cache] SCAN failed pattern=%s: %v", fullPattern, err)
break
}
slice := result.Slice()
if len(slice) < 2 {
break
}
cursor = gconv.Int64(slice[0])
keys := gconv.Strings(slice[1])
if len(keys) > 0 {
delArgs := make([]any, len(keys))
for i, k := range keys {
delArgs[i] = k
}
_, _ = g.Redis().Do(ctx, "DEL", delArgs...)
}
if cursor == 0 {
break
}
}
// L1: memory cache — gcache doesn't support pattern delete,
// but entries will expire via TTL anyway
}
// PublishInvalidation publishes a cache invalidation message via Redis Pub/Sub.
func PublishInvalidation(ctx context.Context, fullKey string) {
_, _ = g.Redis().Do(ctx, "PUBLISH", "cache:invalidate", fullKey)
}
// StartCacheInvalidationSubscriber 订阅 Redis "cache:invalidate" 频道,收到失效通知时移除本进程 L1(gcache)中对应 key。
// C1 修复:此前 Cache.Delete / PublishInvalidation 只向 cache:invalidate 频道 PUBLISH,全仓库无人 SUBSCRIBE,
// 多实例部署下某实例删除 key 后,其他实例的 L1 gcache 会一直陈旧到自然 TTL。此订阅方补齐跨实例 L1 失效。
// 在 cmd.go 启动时调用一次;断线自动重连,语义与 ConfigService.StartSubscriber 一致。
func StartCacheInvalidationSubscriber(ctx context.Context) {
go func() {
var reconnectCount int
for {
conn, _, err := g.Redis().Subscribe(ctx, "cache:invalidate")
if err != nil {
reconnectCount++
g.Log().Warningf(ctx, "[PubSub:cache] 连接失败 (第%d次): %v", reconnectCount, err)
time.Sleep(5 * time.Second)
continue
}
if reconnectCount > 0 {
g.Log().Infof(ctx, "[PubSub:cache] 重连成功 (此前失败%d次)", reconnectCount)
reconnectCount = 0
} else {
g.Log().Info(ctx, "[PubSub:cache] 订阅已启动")
}
for {
v, err := conn.Receive(ctx)
if err != nil {
reconnectCount++
g.Log().Warningf(ctx, "[PubSub:cache] 接收错误 (第%d次): %v", reconnectCount, err)
time.Sleep(5 * time.Second)
break // reconnect
}
msg, ok := v.Val().(*gredis.Message)
if !ok {
continue // skip Subscription/Pong 等
}
// 仅移除本进程 L1;L2(Redis) 由发布方已 DEL,无需重复处理
gcache.Remove(ctx, msg.Payload)
g.Log().Debugf(ctx, "[PubSub:cache] L1 已失效: %s", msg.Payload)
}
conn.Close(ctx)
}
}()
}
// TenantGroupModelCache 缓存租户通过分组可访问的模型集合,TTL 300s
var TenantGroupModelCache = NewCache("tenant_group_models", 300*time.Second)