mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
会话数量限制(max_sessions)此前只有空闲超时自动过期,没有任何主动释放 机制:请求在选号阶段注册会话后,即使转发失败(上游错误/客户端断开/换号 耗尽),该 session hash 也会占住槽位直到空闲超时(默认 5 分钟),导致 max_sessions 受限的账号在窗口内拒绝所有新会话。 修复: - SessionLimitCache 新增 UnregisterSession(Redis ZREM),立即移除会话注册 - GatewayService 新增 ReleaseAccountSession,适用条件与 checkAndRegisterSession 对齐 - Messages 主路径:记录 failover 链上注册过会话槽的账号,请求最终失败时 统一释放;failover continue / profit veto / 兜底分组切换时按次即时释放; 成功及"上游已计量部分结果"(#5148)保持既有空闲超时语义 - CountTokens:转发失败时同样释放选号时注册的会话槽 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
93 lines
3.6 KiB
Go
93 lines
3.6 KiB
Go
//go:build integration
|
|
|
|
package repository
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
"github.com/stretchr/testify/suite"
|
|
)
|
|
|
|
type SessionLimitCacheSuite struct {
|
|
IntegrationRedisSuite
|
|
cache service.SessionLimitCache
|
|
}
|
|
|
|
func (s *SessionLimitCacheSuite) SetupTest() {
|
|
s.IntegrationRedisSuite.SetupTest()
|
|
s.cache = NewSessionLimitCache(s.rdb, 5)
|
|
}
|
|
|
|
// TestUnregisterSession_FreesSlotForNewSession 复现并验证修复场景:
|
|
// max_sessions=1 的账号上,会话 A 注册后若请求转发失败,会话槽会被 A 占住
|
|
// 整个空闲超时窗口(默认 5 分钟),导致窗口内新会话 B 全部被拒。
|
|
// UnregisterSession 必须立即释放槽位,让 B 无需等待空闲超时即可注册。
|
|
func (s *SessionLimitCacheSuite) TestUnregisterSession_FreesSlotForNewSession() {
|
|
const accountID = int64(101)
|
|
maxSessions := 1
|
|
idleTimeout := 5 * time.Minute
|
|
|
|
// 会话 A 注册成功(请求开始转发)
|
|
allowed, err := s.cache.RegisterSession(s.ctx, accountID, "session-A", maxSessions, idleTimeout)
|
|
s.RequireNoError(err, "RegisterSession A")
|
|
s.True(allowed, "首个会话应被允许")
|
|
|
|
// 转发失败后的新会话 B 在空闲超时窗口内被拒(修复前的行为)
|
|
allowed, err = s.cache.RegisterSession(s.ctx, accountID, "session-B", maxSessions, idleTimeout)
|
|
s.RequireNoError(err, "RegisterSession B before release")
|
|
s.False(allowed, "槽位被 A 占用时新会话应被拒绝")
|
|
|
|
// 请求最终失败:立即释放 A 的会话注册(不等待空闲超时)
|
|
s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "session-A"), "UnregisterSession A")
|
|
|
|
// A 释放后 B 立即可注册,无需等待空闲超时
|
|
allowed, err = s.cache.RegisterSession(s.ctx, accountID, "session-B", maxSessions, idleTimeout)
|
|
s.RequireNoError(err, "RegisterSession B after release")
|
|
s.True(allowed, "释放后新会话应立即可注册")
|
|
|
|
count, err := s.cache.GetActiveSessionCount(s.ctx, accountID)
|
|
s.RequireNoError(err, "GetActiveSessionCount")
|
|
s.Equal(1, count, "释放后活跃会话数应为 1(仅 B)")
|
|
}
|
|
|
|
// TestUnregisterSession_IdempotentAndMissing 验证释放操作幂等:
|
|
// 重复释放与释放不存在的会话均不应报错。
|
|
func (s *SessionLimitCacheSuite) TestUnregisterSession_IdempotentAndMissing() {
|
|
const accountID = int64(102)
|
|
|
|
s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "missing"), "释放不存在的会话")
|
|
s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "missing"), "重复释放")
|
|
|
|
s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, ""), "空 sessionUUID 应为 no-op")
|
|
}
|
|
|
|
// TestUnregisterSession_OnlyTargetsSpecifiedSession 验证释放只影响目标会话,
|
|
// 不影响同账号上的其他活跃会话。
|
|
func (s *SessionLimitCacheSuite) TestUnregisterSession_OnlyTargetsSpecifiedSession() {
|
|
const accountID = int64(103)
|
|
maxSessions := 2
|
|
idleTimeout := 5 * time.Minute
|
|
|
|
for _, sid := range []string{"session-A", "session-B"} {
|
|
allowed, err := s.cache.RegisterSession(s.ctx, accountID, sid, maxSessions, idleTimeout)
|
|
s.RequireNoError(err, "RegisterSession "+sid)
|
|
s.True(allowed)
|
|
}
|
|
|
|
s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "session-A"), "UnregisterSession A")
|
|
|
|
active, err := s.cache.IsSessionActive(s.ctx, accountID, "session-A")
|
|
s.RequireNoError(err, "IsSessionActive A")
|
|
s.False(active, "A 应已释放")
|
|
|
|
active, err = s.cache.IsSessionActive(s.ctx, accountID, "session-B")
|
|
s.RequireNoError(err, "IsSessionActive B")
|
|
s.True(active, "B 不应受影响")
|
|
}
|
|
|
|
func TestSessionLimitCacheSuite(t *testing.T) {
|
|
suite.Run(t, new(SessionLimitCacheSuite))
|
|
}
|