session_limit_cache.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. package repository
  2. import (
  3. "context"
  4. "fmt"
  5. "log"
  6. "strconv"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/service"
  9. "github.com/redis/go-redis/v9"
  10. )
  11. // 会话限制缓存常量定义
  12. //
  13. // 设计说明:
  14. // 使用 Redis 有序集合(Sorted Set)跟踪每个账号的活跃会话:
  15. // - Key: session_limit:account:{accountID}
  16. // - Member: sessionUUID(从 metadata.user_id 中提取)
  17. // - Score: Unix 时间戳(会话最后活跃时间)
  18. //
  19. // 通过 ZREMRANGEBYSCORE 自动清理过期会话,无需手动管理 TTL
  20. const (
  21. // 会话限制键前缀
  22. // 格式: session_limit:account:{accountID}
  23. sessionLimitKeyPrefix = "session_limit:account:"
  24. // 窗口费用缓存键前缀
  25. // 格式: window_cost:account:{accountID}
  26. windowCostKeyPrefix = "window_cost:account:"
  27. // 窗口费用缓存 TTL(30秒)
  28. windowCostCacheTTL = 30 * time.Second
  29. )
  30. var (
  31. // registerSessionScript 注册会话活动
  32. // 使用 Redis TIME 命令获取服务器时间,避免多实例时钟不同步
  33. // KEYS[1] = session_limit:account:{accountID}
  34. // ARGV[1] = maxSessions
  35. // ARGV[2] = idleTimeout(秒)
  36. // ARGV[3] = sessionUUID
  37. // 返回: 1 = 允许, 0 = 拒绝
  38. registerSessionScript = redis.NewScript(`
  39. -- Redis 3.2-4.x compat: opt into effects replication so redis.call('TIME')
  40. -- replicates correctly. No-op on Redis 5.0+ (effects replication is default).
  41. redis.replicate_commands()
  42. local key = KEYS[1]
  43. local maxSessions = tonumber(ARGV[1])
  44. local idleTimeout = tonumber(ARGV[2])
  45. local sessionUUID = ARGV[3]
  46. -- 使用 Redis 服务器时间,确保多实例时钟一致
  47. local timeResult = redis.call('TIME')
  48. local now = tonumber(timeResult[1])
  49. local expireBefore = now - idleTimeout
  50. -- 清理过期会话
  51. redis.call('ZREMRANGEBYSCORE', key, '-inf', expireBefore)
  52. -- 检查会话是否已存在(支持刷新时间戳)
  53. local exists = redis.call('ZSCORE', key, sessionUUID)
  54. if exists ~= false then
  55. -- 会话已存在,刷新时间戳
  56. redis.call('ZADD', key, now, sessionUUID)
  57. redis.call('EXPIRE', key, idleTimeout + 60)
  58. return 1
  59. end
  60. -- 检查是否达到会话数量上限
  61. local count = redis.call('ZCARD', key)
  62. if count < maxSessions then
  63. -- 未达上限,添加新会话
  64. redis.call('ZADD', key, now, sessionUUID)
  65. redis.call('EXPIRE', key, idleTimeout + 60)
  66. return 1
  67. end
  68. -- 达到上限,拒绝新会话
  69. return 0
  70. `)
  71. // refreshSessionScript 刷新会话时间戳
  72. // KEYS[1] = session_limit:account:{accountID}
  73. // ARGV[1] = idleTimeout(秒)
  74. // ARGV[2] = sessionUUID
  75. refreshSessionScript = redis.NewScript(`
  76. -- Redis 3.2-4.x compat: opt into effects replication so redis.call('TIME')
  77. -- replicates correctly. No-op on Redis 5.0+ (effects replication is default).
  78. redis.replicate_commands()
  79. local key = KEYS[1]
  80. local idleTimeout = tonumber(ARGV[1])
  81. local sessionUUID = ARGV[2]
  82. local timeResult = redis.call('TIME')
  83. local now = tonumber(timeResult[1])
  84. -- 检查会话是否存在
  85. local exists = redis.call('ZSCORE', key, sessionUUID)
  86. if exists ~= false then
  87. redis.call('ZADD', key, now, sessionUUID)
  88. redis.call('EXPIRE', key, idleTimeout + 60)
  89. end
  90. return 1
  91. `)
  92. // getActiveSessionCountScript 获取活跃会话数
  93. // KEYS[1] = session_limit:account:{accountID}
  94. // ARGV[1] = idleTimeout(秒)
  95. getActiveSessionCountScript = redis.NewScript(`
  96. -- Redis 3.2-4.x compat: opt into effects replication so redis.call('TIME')
  97. -- replicates correctly. No-op on Redis 5.0+ (effects replication is default).
  98. redis.replicate_commands()
  99. local key = KEYS[1]
  100. local idleTimeout = tonumber(ARGV[1])
  101. local timeResult = redis.call('TIME')
  102. local now = tonumber(timeResult[1])
  103. local expireBefore = now - idleTimeout
  104. -- 清理过期会话
  105. redis.call('ZREMRANGEBYSCORE', key, '-inf', expireBefore)
  106. return redis.call('ZCARD', key)
  107. `)
  108. // isSessionActiveScript 检查会话是否活跃
  109. // KEYS[1] = session_limit:account:{accountID}
  110. // ARGV[1] = idleTimeout(秒)
  111. // ARGV[2] = sessionUUID
  112. isSessionActiveScript = redis.NewScript(`
  113. -- Redis 3.2-4.x compat: opt into effects replication so redis.call('TIME')
  114. -- replicates correctly. No-op on Redis 5.0+ (effects replication is default).
  115. redis.replicate_commands()
  116. local key = KEYS[1]
  117. local idleTimeout = tonumber(ARGV[1])
  118. local sessionUUID = ARGV[2]
  119. local timeResult = redis.call('TIME')
  120. local now = tonumber(timeResult[1])
  121. local expireBefore = now - idleTimeout
  122. -- 获取会话的时间戳
  123. local score = redis.call('ZSCORE', key, sessionUUID)
  124. if score == false then
  125. return 0
  126. end
  127. -- 检查是否过期
  128. if tonumber(score) <= expireBefore then
  129. return 0
  130. end
  131. return 1
  132. `)
  133. )
  134. type sessionLimitCache struct {
  135. rdb *redis.Client
  136. defaultIdleTimeout time.Duration // 默认空闲超时(用于 GetActiveSessionCount)
  137. }
  138. // NewSessionLimitCache 创建会话限制缓存
  139. // defaultIdleTimeoutMinutes: 默认空闲超时时间(分钟),用于无参数查询
  140. func NewSessionLimitCache(rdb *redis.Client, defaultIdleTimeoutMinutes int) service.SessionLimitCache {
  141. if defaultIdleTimeoutMinutes <= 0 {
  142. defaultIdleTimeoutMinutes = 5 // 默认 5 分钟
  143. }
  144. // 预加载 Lua 脚本到 Redis,避免 Pipeline 中出现 NOSCRIPT 错误
  145. ctx := context.Background()
  146. scripts := []*redis.Script{
  147. registerSessionScript,
  148. refreshSessionScript,
  149. getActiveSessionCountScript,
  150. isSessionActiveScript,
  151. }
  152. for _, script := range scripts {
  153. if err := script.Load(ctx, rdb).Err(); err != nil {
  154. log.Printf("[SessionLimitCache] Failed to preload Lua script: %v", err)
  155. }
  156. }
  157. return &sessionLimitCache{
  158. rdb: rdb,
  159. defaultIdleTimeout: time.Duration(defaultIdleTimeoutMinutes) * time.Minute,
  160. }
  161. }
  162. // sessionLimitKey 生成会话限制的 Redis 键
  163. func sessionLimitKey(accountID int64) string {
  164. return fmt.Sprintf("%s%d", sessionLimitKeyPrefix, accountID)
  165. }
  166. // windowCostKey 生成窗口费用缓存的 Redis 键
  167. func windowCostKey(accountID int64) string {
  168. return fmt.Sprintf("%s%d", windowCostKeyPrefix, accountID)
  169. }
  170. // RegisterSession 注册会话活动
  171. func (c *sessionLimitCache) RegisterSession(ctx context.Context, accountID int64, sessionUUID string, maxSessions int, idleTimeout time.Duration) (bool, error) {
  172. if sessionUUID == "" || maxSessions <= 0 {
  173. return true, nil // 无效参数,默认允许
  174. }
  175. key := sessionLimitKey(accountID)
  176. idleTimeoutSeconds := int(idleTimeout.Seconds())
  177. if idleTimeoutSeconds <= 0 {
  178. idleTimeoutSeconds = int(c.defaultIdleTimeout.Seconds())
  179. }
  180. result, err := registerSessionScript.Run(ctx, c.rdb, []string{key}, maxSessions, idleTimeoutSeconds, sessionUUID).Int()
  181. if err != nil {
  182. return true, err // 失败开放:缓存错误时允许请求通过
  183. }
  184. return result == 1, nil
  185. }
  186. // UnregisterSession 立即移除会话注册(不等待空闲超时)
  187. // 请求最终失败时调用:上游从未服务该会话,继续占槽会卡住 max_sessions 受限的账号
  188. func (c *sessionLimitCache) UnregisterSession(ctx context.Context, accountID int64, sessionUUID string) error {
  189. if sessionUUID == "" {
  190. return nil
  191. }
  192. return c.rdb.ZRem(ctx, sessionLimitKey(accountID), sessionUUID).Err()
  193. }
  194. // RefreshSession 刷新会话时间戳
  195. func (c *sessionLimitCache) RefreshSession(ctx context.Context, accountID int64, sessionUUID string, idleTimeout time.Duration) error {
  196. if sessionUUID == "" {
  197. return nil
  198. }
  199. key := sessionLimitKey(accountID)
  200. idleTimeoutSeconds := int(idleTimeout.Seconds())
  201. if idleTimeoutSeconds <= 0 {
  202. idleTimeoutSeconds = int(c.defaultIdleTimeout.Seconds())
  203. }
  204. _, err := refreshSessionScript.Run(ctx, c.rdb, []string{key}, idleTimeoutSeconds, sessionUUID).Result()
  205. return err
  206. }
  207. // GetActiveSessionCount 获取活跃会话数
  208. func (c *sessionLimitCache) GetActiveSessionCount(ctx context.Context, accountID int64) (int, error) {
  209. key := sessionLimitKey(accountID)
  210. idleTimeoutSeconds := int(c.defaultIdleTimeout.Seconds())
  211. result, err := getActiveSessionCountScript.Run(ctx, c.rdb, []string{key}, idleTimeoutSeconds).Int()
  212. if err != nil {
  213. return 0, err
  214. }
  215. return result, nil
  216. }
  217. // GetActiveSessionCountBatch 批量获取多个账号的活跃会话数
  218. func (c *sessionLimitCache) GetActiveSessionCountBatch(ctx context.Context, accountIDs []int64, idleTimeouts map[int64]time.Duration) (map[int64]int, error) {
  219. if len(accountIDs) == 0 {
  220. return make(map[int64]int), nil
  221. }
  222. results := make(map[int64]int, len(accountIDs))
  223. // 使用 pipeline 批量执行
  224. pipe := c.rdb.Pipeline()
  225. cmds := make(map[int64]*redis.Cmd, len(accountIDs))
  226. for _, accountID := range accountIDs {
  227. key := sessionLimitKey(accountID)
  228. // 使用各账号自己的 idleTimeout,如果没有则用默认值
  229. idleTimeout := c.defaultIdleTimeout
  230. if idleTimeouts != nil {
  231. if t, ok := idleTimeouts[accountID]; ok && t > 0 {
  232. idleTimeout = t
  233. }
  234. }
  235. idleTimeoutSeconds := int(idleTimeout.Seconds())
  236. cmds[accountID] = getActiveSessionCountScript.Run(ctx, pipe, []string{key}, idleTimeoutSeconds)
  237. }
  238. // 执行 pipeline,即使部分失败也尝试获取成功的结果
  239. _, _ = pipe.Exec(ctx)
  240. for accountID, cmd := range cmds {
  241. if result, err := cmd.Int(); err == nil {
  242. results[accountID] = result
  243. }
  244. }
  245. return results, nil
  246. }
  247. // IsSessionActive 检查会话是否活跃
  248. func (c *sessionLimitCache) IsSessionActive(ctx context.Context, accountID int64, sessionUUID string) (bool, error) {
  249. if sessionUUID == "" {
  250. return false, nil
  251. }
  252. key := sessionLimitKey(accountID)
  253. idleTimeoutSeconds := int(c.defaultIdleTimeout.Seconds())
  254. result, err := isSessionActiveScript.Run(ctx, c.rdb, []string{key}, idleTimeoutSeconds, sessionUUID).Int()
  255. if err != nil {
  256. return false, err
  257. }
  258. return result == 1, nil
  259. }
  260. // ========== 5h窗口费用缓存实现 ==========
  261. // GetWindowCost 获取缓存的窗口费用
  262. func (c *sessionLimitCache) GetWindowCost(ctx context.Context, accountID int64) (float64, bool, error) {
  263. key := windowCostKey(accountID)
  264. val, err := c.rdb.Get(ctx, key).Float64()
  265. if err == redis.Nil {
  266. return 0, false, nil // 缓存未命中
  267. }
  268. if err != nil {
  269. return 0, false, err
  270. }
  271. return val, true, nil
  272. }
  273. // SetWindowCost 设置窗口费用缓存
  274. func (c *sessionLimitCache) SetWindowCost(ctx context.Context, accountID int64, cost float64) error {
  275. key := windowCostKey(accountID)
  276. return c.rdb.Set(ctx, key, cost, windowCostCacheTTL).Err()
  277. }
  278. // GetWindowCostBatch 批量获取窗口费用缓存
  279. func (c *sessionLimitCache) GetWindowCostBatch(ctx context.Context, accountIDs []int64) (map[int64]float64, error) {
  280. if len(accountIDs) == 0 {
  281. return make(map[int64]float64), nil
  282. }
  283. // 构建批量查询的 keys
  284. keys := make([]string, len(accountIDs))
  285. for i, accountID := range accountIDs {
  286. keys[i] = windowCostKey(accountID)
  287. }
  288. // 使用 MGET 批量获取
  289. vals, err := c.rdb.MGet(ctx, keys...).Result()
  290. if err != nil {
  291. return nil, err
  292. }
  293. results := make(map[int64]float64, len(accountIDs))
  294. for i, val := range vals {
  295. if val == nil {
  296. continue // 缓存未命中
  297. }
  298. // 尝试解析为 float64
  299. switch v := val.(type) {
  300. case string:
  301. if cost, err := strconv.ParseFloat(v, 64); err == nil {
  302. results[accountIDs[i]] = cost
  303. }
  304. case float64:
  305. results[accountIDs[i]] = v
  306. }
  307. }
  308. return results, nil
  309. }