package store import ( "context" "fmt" "time" "github.com/redis/go-redis/v9" ) // Redis Key 设计见 doc/backend-architecture.html 第七章。 func KeyQuotaBalance(uid string) string { return "quota:" + uid + ":balance" } func KeyQuotaTrial(uid string, day string) string { return "quota:" + uid + ":trial:" + day } func KeyAuthQr(state string) string { return "authqr:" + state } func KeyJwtBlock(jti string) string { return "jwt:block:" + jti } func KeyRateCnt(did string) string { return "rate:" + did + ":asr:cnt" } func KeyRateSecs(did string) string { return "rate:" + did + ":asr:secs" } func KeyRateFb(uid, day string) string { return "rate:" + uid + ":fb:" + day } func KeyActiveSession(did string) string { return "asr:active:" + did } func OpenRedis(addr string, db int) *redis.Client { return redis.NewClient(&redis.Options{Addr: addr, DB: db}) } // Day 返回服务端时区(Asia/Shanghai)的自然日,作为试用与反馈限频的键。 var cst = time.FixedZone("CST", 8*3600) func Day(t time.Time) string { return t.In(cst).Format("2006-01-02") } // ─── 设备 30 分钟滑动窗口限制(ZSET,member 唯一、score 为时间戳秒)─────────────── // slideWindow 原子地:清理过期成员 → 检查阈值 → 通过则记录本次。 // cnt 窗口按"次"记 1;secs 窗口按本次秒数记。 var slideScript = redis.NewScript(` local key, now, window, limit, val = KEYS[1], tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4]) redis.call('ZREMRANGEBYSCORE', key, 0, now - window) local sum = 0 if ARGV[5] == 'count' then sum = redis.call('ZCARD', key) else local members = redis.call('ZRANGE', key, 0, -1) for _, m in ipairs(members) do local v = string.match(m, ':(%d+)$') if v then sum = sum + tonumber(v) end end end if sum + val > limit then return 0 end redis.call('ZADD', key, now, now .. '-' .. redis.call('INCR', key .. ':seq') .. ':' .. val) redis.call('EXPIRE', key, window + 60) return 1 `) // AllowSession 设备维度新会话准入:30 分钟内 ≤30 次。 func AllowSession(ctx context.Context, rdb *redis.Client, deviceID string, now time.Time) (bool, error) { ok, err := slideScript.Run(ctx, rdb, []string{KeyRateCnt(deviceID)}, now.Unix(), 30*60, 30, 1, "count").Int() return ok == 1, err } // AllowAudioSeconds 设备维度时长准入(原子检查并记录):30 分钟内累计 ≤1800s。 func AllowAudioSeconds(ctx context.Context, rdb *redis.Client, deviceID string, seconds int, now time.Time) (bool, error) { ok, err := slideScript.Run(ctx, rdb, []string{KeyRateSecs(deviceID)}, now.Unix(), 30*60, 30*60, seconds, "sum").Int() return ok == 1, err } // AudioWindowExhausted 会话 start 时检查时长窗口是否已满(只查不记;本次秒数在结束时 // 经 RecordAudioSeconds 记录——音频已实际消耗,结束时无条件记账)。 func AudioWindowExhausted(ctx context.Context, rdb *redis.Client, deviceID string, now time.Time) (bool, error) { ok, err := slideScript.Run(ctx, rdb, []string{KeyRateSecs(deviceID)}, now.Unix(), 30*60, 30*60, 0, "sum").Int() return ok == 0, err } // RecordAudioSeconds 会话结束记录本次识别秒数(无条件,limit 取大数)。 func RecordAudioSeconds(ctx context.Context, rdb *redis.Client, deviceID string, seconds int, now time.Time) error { return slideScript.Run(ctx, rdb, []string{KeyRateSecs(deviceID)}, now.Unix(), 30*60, 1<<30, seconds, "sum").Err() } // AcquireDeviceSlot 单设备同时仅 1 路识别会话(SET NX + TTL 兜底防泄漏)。 func AcquireDeviceSlot(ctx context.Context, rdb *redis.Client, deviceID, sessionID string) (bool, error) { return rdb.SetNX(ctx, KeyActiveSession(deviceID), sessionID, 4*time.Minute).Result() } func ReleaseDeviceSlot(ctx context.Context, rdb *redis.Client, deviceID, sessionID string) error { // 仅当持有者是自己时释放 script := redis.NewScript(` if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0`) return script.Run(ctx, rdb, []string{KeyActiveSession(deviceID)}, sessionID).Err() } // IncrDailyCounter 自然日计数器(反馈限频等),返回自增后的值。 func IncrDailyCounter(ctx context.Context, rdb *redis.Client, key string) (int64, error) { pipe := rdb.TxPipeline() incr := pipe.Incr(ctx, key) pipe.Expire(ctx, key, 48*time.Hour) if _, err := pipe.Exec(ctx); err != nil { return 0, err } return incr.Val(), nil } var _ = fmt.Sprintf // keep fmt for future use