Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
d367d1cd
Commit
d367d1cd
authored
Feb 09, 2026
by
yangjianbo
Browse files
Merge branch 'main' into test-sora
parents
d7011163
3c46f7d2
Changes
104
Show whitespace changes
Inline
Side-by-side
backend/internal/service/gemini_multiplatform_test.go
View file @
d367d1cd
...
...
@@ -66,7 +66,12 @@ func (m *mockAccountRepoForGemini) Create(ctx context.Context, account *Account)
func
(
m
*
mockAccountRepoForGemini
)
GetByCRSAccountID
(
ctx
context
.
Context
,
crsAccountID
string
)
(
*
Account
,
error
)
{
return
nil
,
nil
}
func
(
m
*
mockAccountRepoForGemini
)
FindByExtraField
(
ctx
context
.
Context
,
key
string
,
value
interface
{})
([]
Account
,
error
)
{
func
(
m
*
mockAccountRepoForGemini
)
FindByExtraField
(
ctx
context
.
Context
,
key
string
,
value
any
)
([]
Account
,
error
)
{
return
nil
,
nil
}
func
(
m
*
mockAccountRepoForGemini
)
ListCRSAccountIDs
(
ctx
context
.
Context
)
(
map
[
string
]
int64
,
error
)
{
return
nil
,
nil
}
func
(
m
*
mockAccountRepoForGemini
)
Update
(
ctx
context
.
Context
,
account
*
Account
)
error
{
return
nil
}
...
...
@@ -136,9 +141,6 @@ func (m *mockAccountRepoForGemini) ListSchedulableByGroupIDAndPlatforms(ctx cont
func
(
m
*
mockAccountRepoForGemini
)
SetRateLimited
(
ctx
context
.
Context
,
id
int64
,
resetAt
time
.
Time
)
error
{
return
nil
}
func
(
m
*
mockAccountRepoForGemini
)
SetAntigravityQuotaScopeLimit
(
ctx
context
.
Context
,
id
int64
,
scope
AntigravityQuotaScope
,
resetAt
time
.
Time
)
error
{
return
nil
}
func
(
m
*
mockAccountRepoForGemini
)
SetModelRateLimit
(
ctx
context
.
Context
,
id
int64
,
scope
string
,
resetAt
time
.
Time
)
error
{
return
nil
}
...
...
@@ -229,6 +231,10 @@ func (m *mockGroupRepoForGemini) GetAccountIDsByGroupIDs(ctx context.Context, gr
return
nil
,
nil
}
func
(
m
*
mockGroupRepoForGemini
)
UpdateSortOrders
(
ctx
context
.
Context
,
updates
[]
GroupSortOrderUpdate
)
error
{
return
nil
}
var
_
GroupRepository
=
(
*
mockGroupRepoForGemini
)(
nil
)
// mockGatewayCacheForGemini Gemini 测试用的 cache mock
...
...
@@ -268,22 +274,6 @@ func (m *mockGatewayCacheForGemini) DeleteSessionAccountID(ctx context.Context,
return
nil
}
func
(
m
*
mockGatewayCacheForGemini
)
IncrModelCallCount
(
ctx
context
.
Context
,
accountID
int64
,
model
string
)
(
int64
,
error
)
{
return
0
,
nil
}
func
(
m
*
mockGatewayCacheForGemini
)
GetModelLoadBatch
(
ctx
context
.
Context
,
accountIDs
[]
int64
,
model
string
)
(
map
[
int64
]
*
ModelLoadInfo
,
error
)
{
return
nil
,
nil
}
func
(
m
*
mockGatewayCacheForGemini
)
FindGeminiSession
(
ctx
context
.
Context
,
groupID
int64
,
prefixHash
,
digestChain
string
)
(
uuid
string
,
accountID
int64
,
found
bool
)
{
return
""
,
0
,
false
}
func
(
m
*
mockGatewayCacheForGemini
)
SaveGeminiSession
(
ctx
context
.
Context
,
groupID
int64
,
prefixHash
,
digestChain
,
uuid
string
,
accountID
int64
)
error
{
return
nil
}
// TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform 测试 Gemini 单平台选择
func
TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform
(
t
*
testing
.
T
)
{
ctx
:=
context
.
Background
()
...
...
backend/internal/service/gemini_session.go
View file @
d367d1cd
...
...
@@ -6,26 +6,11 @@ import (
"encoding/json"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/cespare/xxhash/v2"
)
// Gemini 会话 ID Fallback 相关常量
const
(
// geminiSessionTTLSeconds Gemini 会话缓存 TTL(5 分钟)
geminiSessionTTLSeconds
=
300
// geminiSessionKeyPrefix Gemini 会话 Redis key 前缀
geminiSessionKeyPrefix
=
"gemini:sess:"
)
// GeminiSessionTTL 返回 Gemini 会话缓存 TTL
func
GeminiSessionTTL
()
time
.
Duration
{
return
geminiSessionTTLSeconds
*
time
.
Second
}
// shortHash 使用 XXHash64 + Base36 生成短 hash(16 字符)
// XXHash64 比 SHA256 快约 10 倍,Base36 比 Hex 短约 20%
func
shortHash
(
data
[]
byte
)
string
{
...
...
@@ -79,35 +64,6 @@ func GenerateGeminiPrefixHash(userID, apiKeyID int64, ip, userAgent, platform, m
return
base64
.
RawURLEncoding
.
EncodeToString
(
hash
[
:
12
])
}
// BuildGeminiSessionKey 构建 Gemini 会话 Redis key
// 格式: gemini:sess:{groupID}:{prefixHash}:{digestChain}
func
BuildGeminiSessionKey
(
groupID
int64
,
prefixHash
,
digestChain
string
)
string
{
return
geminiSessionKeyPrefix
+
strconv
.
FormatInt
(
groupID
,
10
)
+
":"
+
prefixHash
+
":"
+
digestChain
}
// GenerateDigestChainPrefixes 生成摘要链的所有前缀(从长到短)
// 用于 MGET 批量查询最长匹配
func
GenerateDigestChainPrefixes
(
chain
string
)
[]
string
{
if
chain
==
""
{
return
nil
}
var
prefixes
[]
string
c
:=
chain
for
c
!=
""
{
prefixes
=
append
(
prefixes
,
c
)
// 找到最后一个 "-" 的位置
if
i
:=
strings
.
LastIndex
(
c
,
"-"
);
i
>
0
{
c
=
c
[
:
i
]
}
else
{
break
}
}
return
prefixes
}
// ParseGeminiSessionValue 解析 Gemini 会话缓存值
// 格式: {uuid}:{accountID}
func
ParseGeminiSessionValue
(
value
string
)
(
uuid
string
,
accountID
int64
,
ok
bool
)
{
...
...
@@ -139,15 +95,6 @@ func FormatGeminiSessionValue(uuid string, accountID int64) string {
// geminiDigestSessionKeyPrefix Gemini 摘要 fallback 会话 key 前缀
const
geminiDigestSessionKeyPrefix
=
"gemini:digest:"
// geminiTrieKeyPrefix Gemini Trie 会话 key 前缀
const
geminiTrieKeyPrefix
=
"gemini:trie:"
// BuildGeminiTrieKey 构建 Gemini Trie Redis key
// 格式: gemini:trie:{groupID}:{prefixHash}
func
BuildGeminiTrieKey
(
groupID
int64
,
prefixHash
string
)
string
{
return
geminiTrieKeyPrefix
+
strconv
.
FormatInt
(
groupID
,
10
)
+
":"
+
prefixHash
}
// GenerateGeminiDigestSessionKey 生成 Gemini 摘要 fallback 的 sessionKey
// 组合 prefixHash 前 8 位 + uuid 前 8 位,确保不同会话产生不同的 sessionKey
// 用于在 SelectAccountWithLoadAwareness 中保持粘性会话
...
...
backend/internal/service/gemini_session_integration_test.go
View file @
d367d1cd
package
service
import
(
"context"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
)
// mockGeminiSessionCache 模拟 Redis 缓存
type
mockGeminiSessionCache
struct
{
sessions
map
[
string
]
string
// key -> value
}
func
newMockGeminiSessionCache
()
*
mockGeminiSessionCache
{
return
&
mockGeminiSessionCache
{
sessions
:
make
(
map
[
string
]
string
)}
}
func
(
m
*
mockGeminiSessionCache
)
Save
(
groupID
int64
,
prefixHash
,
digestChain
,
uuid
string
,
accountID
int64
)
{
key
:=
BuildGeminiSessionKey
(
groupID
,
prefixHash
,
digestChain
)
value
:=
FormatGeminiSessionValue
(
uuid
,
accountID
)
m
.
sessions
[
key
]
=
value
}
func
(
m
*
mockGeminiSessionCache
)
Find
(
groupID
int64
,
prefixHash
,
digestChain
string
)
(
uuid
string
,
accountID
int64
,
found
bool
)
{
prefixes
:=
GenerateDigestChainPrefixes
(
digestChain
)
for
_
,
p
:=
range
prefixes
{
key
:=
BuildGeminiSessionKey
(
groupID
,
prefixHash
,
p
)
if
val
,
ok
:=
m
.
sessions
[
key
];
ok
{
return
ParseGeminiSessionValue
(
val
)
}
}
return
""
,
0
,
false
}
// TestGeminiSessionContinuousConversation 测试连续会话的摘要链匹配
func
TestGeminiSessionContinuousConversation
(
t
*
testing
.
T
)
{
cach
e
:=
n
ew
MockGemini
Session
Cach
e
()
stor
e
:=
N
ew
Digest
Session
Stor
e
()
groupID
:=
int64
(
1
)
prefixHash
:=
"test_prefix_hash"
sessionUUID
:=
"session-uuid-12345"
...
...
@@ -54,13 +27,13 @@ func TestGeminiSessionContinuousConversation(t *testing.T) {
t
.
Logf
(
"Round 1 chain: %s"
,
chain1
)
// 第一轮:没有找到会话,创建新会话
_
,
_
,
found
:=
cach
e
.
Find
(
groupID
,
prefixHash
,
chain1
)
_
,
_
,
_
,
found
:=
stor
e
.
Find
(
groupID
,
prefixHash
,
chain1
)
if
found
{
t
.
Error
(
"Round 1: should not find existing session"
)
}
// 保存第一轮会话
cach
e
.
Save
(
groupID
,
prefixHash
,
chain1
,
sessionUUID
,
accountID
)
// 保存第一轮会话
(首轮无旧 chain)
stor
e
.
Save
(
groupID
,
prefixHash
,
chain1
,
sessionUUID
,
accountID
,
""
)
// 模拟第二轮对话(用户继续对话)
req2
:=
&
antigravity
.
GeminiRequest
{
...
...
@@ -77,7 +50,7 @@ func TestGeminiSessionContinuousConversation(t *testing.T) {
t
.
Logf
(
"Round 2 chain: %s"
,
chain2
)
// 第二轮:应该能找到会话(通过前缀匹配)
foundUUID
,
foundAccID
,
found
:=
cach
e
.
Find
(
groupID
,
prefixHash
,
chain2
)
foundUUID
,
foundAccID
,
matchedChain
,
found
:=
stor
e
.
Find
(
groupID
,
prefixHash
,
chain2
)
if
!
found
{
t
.
Error
(
"Round 2: should find session via prefix matching"
)
}
...
...
@@ -88,8 +61,8 @@ func TestGeminiSessionContinuousConversation(t *testing.T) {
t
.
Errorf
(
"Round 2: expected accountID %d, got %d"
,
accountID
,
foundAccID
)
}
// 保存第二轮会话
cach
e
.
Save
(
groupID
,
prefixHash
,
chain2
,
sessionUUID
,
accountID
)
// 保存第二轮会话
,传入 Find 返回的 matchedChain 以删旧 key
stor
e
.
Save
(
groupID
,
prefixHash
,
chain2
,
sessionUUID
,
accountID
,
matchedChain
)
// 模拟第三轮对话
req3
:=
&
antigravity
.
GeminiRequest
{
...
...
@@ -108,7 +81,7 @@ func TestGeminiSessionContinuousConversation(t *testing.T) {
t
.
Logf
(
"Round 3 chain: %s"
,
chain3
)
// 第三轮:应该能找到会话(通过第二轮的前缀匹配)
foundUUID
,
foundAccID
,
found
=
cach
e
.
Find
(
groupID
,
prefixHash
,
chain3
)
foundUUID
,
foundAccID
,
_
,
found
=
stor
e
.
Find
(
groupID
,
prefixHash
,
chain3
)
if
!
found
{
t
.
Error
(
"Round 3: should find session via prefix matching"
)
}
...
...
@@ -118,13 +91,11 @@ func TestGeminiSessionContinuousConversation(t *testing.T) {
if
foundAccID
!=
accountID
{
t
.
Errorf
(
"Round 3: expected accountID %d, got %d"
,
accountID
,
foundAccID
)
}
t
.
Log
(
"✓ Continuous conversation session matching works correctly!"
)
}
// TestGeminiSessionDifferentConversations 测试不同会话不会错误匹配
func
TestGeminiSessionDifferentConversations
(
t
*
testing
.
T
)
{
cach
e
:=
n
ew
MockGemini
Session
Cach
e
()
stor
e
:=
N
ew
Digest
Session
Stor
e
()
groupID
:=
int64
(
1
)
prefixHash
:=
"test_prefix_hash"
...
...
@@ -135,7 +106,7 @@ func TestGeminiSessionDifferentConversations(t *testing.T) {
},
}
chain1
:=
BuildGeminiDigestChain
(
req1
)
cach
e
.
Save
(
groupID
,
prefixHash
,
chain1
,
"session-1"
,
100
)
stor
e
.
Save
(
groupID
,
prefixHash
,
chain1
,
"session-1"
,
100
,
""
)
// 第二个完全不同的会话
req2
:=
&
antigravity
.
GeminiRequest
{
...
...
@@ -146,61 +117,29 @@ func TestGeminiSessionDifferentConversations(t *testing.T) {
chain2
:=
BuildGeminiDigestChain
(
req2
)
// 不同会话不应该匹配
_
,
_
,
found
:=
cach
e
.
Find
(
groupID
,
prefixHash
,
chain2
)
_
,
_
,
_
,
found
:=
stor
e
.
Find
(
groupID
,
prefixHash
,
chain2
)
if
found
{
t
.
Error
(
"Different conversations should not match"
)
}
t
.
Log
(
"✓ Different conversations are correctly isolated!"
)
}
// TestGeminiSessionPrefixMatchingOrder 测试前缀匹配的优先级(最长匹配优先)
func
TestGeminiSessionPrefixMatchingOrder
(
t
*
testing
.
T
)
{
cach
e
:=
n
ew
MockGemini
Session
Cach
e
()
stor
e
:=
N
ew
Digest
Session
Stor
e
()
groupID
:=
int64
(
1
)
prefixHash
:=
"test_prefix_hash"
// 创建一个三轮对话
req
:=
&
antigravity
.
GeminiRequest
{
SystemInstruction
:
&
antigravity
.
GeminiContent
{
Parts
:
[]
antigravity
.
GeminiPart
{{
Text
:
"System prompt"
}},
},
Contents
:
[]
antigravity
.
GeminiContent
{
{
Role
:
"user"
,
Parts
:
[]
antigravity
.
GeminiPart
{{
Text
:
"Q1"
}}},
{
Role
:
"model"
,
Parts
:
[]
antigravity
.
GeminiPart
{{
Text
:
"A1"
}}},
{
Role
:
"user"
,
Parts
:
[]
antigravity
.
GeminiPart
{{
Text
:
"Q2"
}}},
},
}
fullChain
:=
BuildGeminiDigestChain
(
req
)
prefixes
:=
GenerateDigestChainPrefixes
(
fullChain
)
t
.
Logf
(
"Full chain: %s"
,
fullChain
)
t
.
Logf
(
"Prefixes (longest first): %v"
,
prefixes
)
// 验证前缀生成顺序(从长到短)
if
len
(
prefixes
)
!=
4
{
t
.
Errorf
(
"Expected 4 prefixes, got %d"
,
len
(
prefixes
))
}
// 保存不同轮次的会话到不同账号
// 第一轮(最短前缀)-> 账号 1
cache
.
Save
(
groupID
,
prefixHash
,
prefixes
[
3
],
"session-round1"
,
1
)
// 第二轮 -> 账号 2
cache
.
Save
(
groupID
,
prefixHash
,
prefixes
[
2
],
"session-round2"
,
2
)
// 第三轮(最长前缀,完整链)-> 账号 3
cache
.
Save
(
groupID
,
prefixHash
,
prefixes
[
0
],
"session-round3"
,
3
)
// 查找应该返回最长匹配(账号 3)
_
,
accID
,
found
:=
cache
.
Find
(
groupID
,
prefixHash
,
fullChain
)
store
.
Save
(
groupID
,
prefixHash
,
"s:sys-u:q1"
,
"session-round1"
,
1
,
""
)
store
.
Save
(
groupID
,
prefixHash
,
"s:sys-u:q1-m:a1"
,
"session-round2"
,
2
,
""
)
store
.
Save
(
groupID
,
prefixHash
,
"s:sys-u:q1-m:a1-u:q2"
,
"session-round3"
,
3
,
""
)
// 查找更长的链,应该返回最长匹配(账号 3)
_
,
accID
,
_
,
found
:=
store
.
Find
(
groupID
,
prefixHash
,
"s:sys-u:q1-m:a1-u:q2-m:a2"
)
if
!
found
{
t
.
Error
(
"Should find session"
)
}
if
accID
!=
3
{
t
.
Errorf
(
"Should match longest prefix (account 3), got account %d"
,
accID
)
}
t
.
Log
(
"✓ Longest prefix matching works correctly!"
)
}
// 确保 context 包被使用(避免未使用的导入警告)
var
_
=
context
.
Background
backend/internal/service/gemini_session_test.go
View file @
d367d1cd
...
...
@@ -152,61 +152,6 @@ func TestGenerateGeminiPrefixHash(t *testing.T) {
}
}
func
TestGenerateDigestChainPrefixes
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
name
string
chain
string
want
[]
string
wantLen
int
}{
{
name
:
"empty"
,
chain
:
""
,
wantLen
:
0
,
},
{
name
:
"single part"
,
chain
:
"u:abc123"
,
want
:
[]
string
{
"u:abc123"
},
wantLen
:
1
,
},
{
name
:
"two parts"
,
chain
:
"s:xyz-u:abc"
,
want
:
[]
string
{
"s:xyz-u:abc"
,
"s:xyz"
},
wantLen
:
2
,
},
{
name
:
"four parts"
,
chain
:
"s:a-u:b-m:c-u:d"
,
want
:
[]
string
{
"s:a-u:b-m:c-u:d"
,
"s:a-u:b-m:c"
,
"s:a-u:b"
,
"s:a"
},
wantLen
:
4
,
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
result
:=
GenerateDigestChainPrefixes
(
tt
.
chain
)
if
len
(
result
)
!=
tt
.
wantLen
{
t
.
Errorf
(
"expected %d prefixes, got %d: %v"
,
tt
.
wantLen
,
len
(
result
),
result
)
}
if
tt
.
want
!=
nil
{
for
i
,
want
:=
range
tt
.
want
{
if
i
>=
len
(
result
)
{
t
.
Errorf
(
"missing prefix at index %d"
,
i
)
continue
}
if
result
[
i
]
!=
want
{
t
.
Errorf
(
"prefix[%d]: expected %s, got %s"
,
i
,
want
,
result
[
i
])
}
}
}
})
}
}
func
TestParseGeminiSessionValue
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
name
string
...
...
@@ -442,40 +387,3 @@ func TestGenerateGeminiDigestSessionKey(t *testing.T) {
}
})
}
func
TestBuildGeminiTrieKey
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
name
string
groupID
int64
prefixHash
string
want
string
}{
{
name
:
"normal"
,
groupID
:
123
,
prefixHash
:
"abcdef12"
,
want
:
"gemini:trie:123:abcdef12"
,
},
{
name
:
"zero group"
,
groupID
:
0
,
prefixHash
:
"xyz"
,
want
:
"gemini:trie:0:xyz"
,
},
{
name
:
"empty prefix"
,
groupID
:
1
,
prefixHash
:
""
,
want
:
"gemini:trie:1:"
,
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
got
:=
BuildGeminiTrieKey
(
tt
.
groupID
,
tt
.
prefixHash
)
if
got
!=
tt
.
want
{
t
.
Errorf
(
"BuildGeminiTrieKey(%d, %q) = %q, want %q"
,
tt
.
groupID
,
tt
.
prefixHash
,
got
,
tt
.
want
)
}
})
}
}
backend/internal/service/generate_session_hash_test.go
0 → 100644
View file @
d367d1cd
//go:build unit
package
service
import
(
"testing"
"github.com/stretchr/testify/require"
)
// ============ 基础优先级测试 ============
func
TestGenerateSessionHash_NilParsedRequest
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
require
.
Empty
(
t
,
svc
.
GenerateSessionHash
(
nil
))
}
func
TestGenerateSessionHash_EmptyRequest
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
require
.
Empty
(
t
,
svc
.
GenerateSessionHash
(
&
ParsedRequest
{}))
}
func
TestGenerateSessionHash_MetadataHasHighestPriority
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
parsed
:=
&
ParsedRequest
{
MetadataUserID
:
"session_123e4567-e89b-12d3-a456-426614174000"
,
System
:
"You are a helpful assistant."
,
HasSystem
:
true
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
},
}
hash
:=
svc
.
GenerateSessionHash
(
parsed
)
require
.
Equal
(
t
,
"123e4567-e89b-12d3-a456-426614174000"
,
hash
,
"metadata session_id should have highest priority"
)
}
// ============ System + Messages 基础测试 ============
func
TestGenerateSessionHash_SystemPlusMessages
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
withSystem
:=
&
ParsedRequest
{
System
:
"You are a helpful assistant."
,
HasSystem
:
true
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
},
}
withoutSystem
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
},
}
h1
:=
svc
.
GenerateSessionHash
(
withSystem
)
h2
:=
svc
.
GenerateSessionHash
(
withoutSystem
)
require
.
NotEmpty
(
t
,
h1
)
require
.
NotEmpty
(
t
,
h2
)
require
.
NotEqual
(
t
,
h1
,
h2
,
"system prompt should be part of digest, producing different hash"
)
}
func
TestGenerateSessionHash_SystemOnlyProducesHash
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
parsed
:=
&
ParsedRequest
{
System
:
"You are a helpful assistant."
,
HasSystem
:
true
,
}
hash
:=
svc
.
GenerateSessionHash
(
parsed
)
require
.
NotEmpty
(
t
,
hash
,
"system prompt alone should produce a hash as part of full digest"
)
}
func
TestGenerateSessionHash_DifferentSystemsSameMessages
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
parsed1
:=
&
ParsedRequest
{
System
:
"You are assistant A."
,
HasSystem
:
true
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
},
}
parsed2
:=
&
ParsedRequest
{
System
:
"You are assistant B."
,
HasSystem
:
true
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
},
}
h1
:=
svc
.
GenerateSessionHash
(
parsed1
)
h2
:=
svc
.
GenerateSessionHash
(
parsed2
)
require
.
NotEqual
(
t
,
h1
,
h2
,
"different system prompts with same messages should produce different hashes"
)
}
func
TestGenerateSessionHash_SameSystemSameMessages
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
mk
:=
func
()
*
ParsedRequest
{
return
&
ParsedRequest
{
System
:
"You are a helpful assistant."
,
HasSystem
:
true
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
map
[
string
]
any
{
"role"
:
"assistant"
,
"content"
:
"hi"
},
},
}
}
h1
:=
svc
.
GenerateSessionHash
(
mk
())
h2
:=
svc
.
GenerateSessionHash
(
mk
())
require
.
Equal
(
t
,
h1
,
h2
,
"same system + same messages should produce identical hash"
)
}
func
TestGenerateSessionHash_DifferentMessagesProduceDifferentHash
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
parsed1
:=
&
ParsedRequest
{
System
:
"You are a helpful assistant."
,
HasSystem
:
true
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"help me with Go"
},
},
}
parsed2
:=
&
ParsedRequest
{
System
:
"You are a helpful assistant."
,
HasSystem
:
true
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"help me with Python"
},
},
}
h1
:=
svc
.
GenerateSessionHash
(
parsed1
)
h2
:=
svc
.
GenerateSessionHash
(
parsed2
)
require
.
NotEqual
(
t
,
h1
,
h2
,
"same system but different messages should produce different hashes"
)
}
// ============ SessionContext 核心测试 ============
func
TestGenerateSessionHash_DifferentSessionContextProducesDifferentHash
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
// 相同消息 + 不同 SessionContext → 不同 hash(解决碰撞问题的核心场景)
parsed1
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
},
SessionContext
:
&
SessionContext
{
ClientIP
:
"192.168.1.1"
,
UserAgent
:
"Mozilla/5.0"
,
APIKeyID
:
100
,
},
}
parsed2
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
},
SessionContext
:
&
SessionContext
{
ClientIP
:
"10.0.0.1"
,
UserAgent
:
"curl/7.0"
,
APIKeyID
:
200
,
},
}
h1
:=
svc
.
GenerateSessionHash
(
parsed1
)
h2
:=
svc
.
GenerateSessionHash
(
parsed2
)
require
.
NotEmpty
(
t
,
h1
)
require
.
NotEmpty
(
t
,
h2
)
require
.
NotEqual
(
t
,
h1
,
h2
,
"same messages but different SessionContext should produce different hashes"
)
}
func
TestGenerateSessionHash_SameSessionContextProducesSameHash
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
mk
:=
func
()
*
ParsedRequest
{
return
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
},
SessionContext
:
&
SessionContext
{
ClientIP
:
"192.168.1.1"
,
UserAgent
:
"Mozilla/5.0"
,
APIKeyID
:
100
,
},
}
}
h1
:=
svc
.
GenerateSessionHash
(
mk
())
h2
:=
svc
.
GenerateSessionHash
(
mk
())
require
.
Equal
(
t
,
h1
,
h2
,
"same messages + same SessionContext should produce identical hash"
)
}
func
TestGenerateSessionHash_MetadataOverridesSessionContext
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
parsed
:=
&
ParsedRequest
{
MetadataUserID
:
"session_123e4567-e89b-12d3-a456-426614174000"
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
},
SessionContext
:
&
SessionContext
{
ClientIP
:
"192.168.1.1"
,
UserAgent
:
"Mozilla/5.0"
,
APIKeyID
:
100
,
},
}
hash
:=
svc
.
GenerateSessionHash
(
parsed
)
require
.
Equal
(
t
,
"123e4567-e89b-12d3-a456-426614174000"
,
hash
,
"metadata session_id should take priority over SessionContext"
)
}
func
TestGenerateSessionHash_NilSessionContextBackwardCompatible
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
withCtx
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
},
SessionContext
:
nil
,
}
withoutCtx
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
},
}
h1
:=
svc
.
GenerateSessionHash
(
withCtx
)
h2
:=
svc
.
GenerateSessionHash
(
withoutCtx
)
require
.
Equal
(
t
,
h1
,
h2
,
"nil SessionContext should produce same hash as no SessionContext"
)
}
// ============ 多轮连续会话测试 ============
func
TestGenerateSessionHash_ContinuousConversation_HashChangesWithMessages
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
ctx
:=
&
SessionContext
{
ClientIP
:
"1.2.3.4"
,
UserAgent
:
"test"
,
APIKeyID
:
1
}
// 模拟连续会话:每增加一轮对话,hash 应该不同(内容累积变化)
round1
:=
&
ParsedRequest
{
System
:
"You are a helpful assistant."
,
HasSystem
:
true
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
},
SessionContext
:
ctx
,
}
round2
:=
&
ParsedRequest
{
System
:
"You are a helpful assistant."
,
HasSystem
:
true
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
map
[
string
]
any
{
"role"
:
"assistant"
,
"content"
:
"Hi there!"
},
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"How are you?"
},
},
SessionContext
:
ctx
,
}
round3
:=
&
ParsedRequest
{
System
:
"You are a helpful assistant."
,
HasSystem
:
true
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
map
[
string
]
any
{
"role"
:
"assistant"
,
"content"
:
"Hi there!"
},
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"How are you?"
},
map
[
string
]
any
{
"role"
:
"assistant"
,
"content"
:
"I'm doing well!"
},
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"Tell me a joke"
},
},
SessionContext
:
ctx
,
}
h1
:=
svc
.
GenerateSessionHash
(
round1
)
h2
:=
svc
.
GenerateSessionHash
(
round2
)
h3
:=
svc
.
GenerateSessionHash
(
round3
)
require
.
NotEmpty
(
t
,
h1
)
require
.
NotEmpty
(
t
,
h2
)
require
.
NotEmpty
(
t
,
h3
)
require
.
NotEqual
(
t
,
h1
,
h2
,
"different conversation rounds should produce different hashes"
)
require
.
NotEqual
(
t
,
h2
,
h3
,
"each new round should produce a different hash"
)
require
.
NotEqual
(
t
,
h1
,
h3
,
"round 1 and round 3 should differ"
)
}
func
TestGenerateSessionHash_ContinuousConversation_SameRoundSameHash
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
ctx
:=
&
SessionContext
{
ClientIP
:
"1.2.3.4"
,
UserAgent
:
"test"
,
APIKeyID
:
1
}
// 同一轮对话重复请求(如重试)应产生相同 hash
mk
:=
func
()
*
ParsedRequest
{
return
&
ParsedRequest
{
System
:
"You are a helpful assistant."
,
HasSystem
:
true
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
map
[
string
]
any
{
"role"
:
"assistant"
,
"content"
:
"Hi there!"
},
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"How are you?"
},
},
SessionContext
:
ctx
,
}
}
h1
:=
svc
.
GenerateSessionHash
(
mk
())
h2
:=
svc
.
GenerateSessionHash
(
mk
())
require
.
Equal
(
t
,
h1
,
h2
,
"same conversation state should produce identical hash on retry"
)
}
// ============ 消息回退测试 ============
func
TestGenerateSessionHash_MessageRollback
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
ctx
:=
&
SessionContext
{
ClientIP
:
"1.2.3.4"
,
UserAgent
:
"test"
,
APIKeyID
:
1
}
// 模拟消息回退:用户删掉最后一轮再重发
original
:=
&
ParsedRequest
{
System
:
"System prompt"
,
HasSystem
:
true
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"msg1"
},
map
[
string
]
any
{
"role"
:
"assistant"
,
"content"
:
"reply1"
},
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"msg2"
},
map
[
string
]
any
{
"role"
:
"assistant"
,
"content"
:
"reply2"
},
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"msg3"
},
},
SessionContext
:
ctx
,
}
// 回退到 msg2 后,用新的 msg3 替代
rollback
:=
&
ParsedRequest
{
System
:
"System prompt"
,
HasSystem
:
true
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"msg1"
},
map
[
string
]
any
{
"role"
:
"assistant"
,
"content"
:
"reply1"
},
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"msg2"
},
map
[
string
]
any
{
"role"
:
"assistant"
,
"content"
:
"reply2"
},
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"different msg3"
},
},
SessionContext
:
ctx
,
}
hOrig
:=
svc
.
GenerateSessionHash
(
original
)
hRollback
:=
svc
.
GenerateSessionHash
(
rollback
)
require
.
NotEqual
(
t
,
hOrig
,
hRollback
,
"rollback with different last message should produce different hash"
)
}
func
TestGenerateSessionHash_MessageRollbackSameContent
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
ctx
:=
&
SessionContext
{
ClientIP
:
"1.2.3.4"
,
UserAgent
:
"test"
,
APIKeyID
:
1
}
// 回退后重新发送相同内容 → 相同 hash(合理的粘性恢复)
mk
:=
func
()
*
ParsedRequest
{
return
&
ParsedRequest
{
System
:
"System prompt"
,
HasSystem
:
true
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"msg1"
},
map
[
string
]
any
{
"role"
:
"assistant"
,
"content"
:
"reply1"
},
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"msg2"
},
},
SessionContext
:
ctx
,
}
}
h1
:=
svc
.
GenerateSessionHash
(
mk
())
h2
:=
svc
.
GenerateSessionHash
(
mk
())
require
.
Equal
(
t
,
h1
,
h2
,
"rollback and resend same content should produce same hash"
)
}
// ============ 相同 System、不同用户消息 ============
func
TestGenerateSessionHash_SameSystemDifferentUsers
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
// 两个不同用户使用相同 system prompt 但发送不同消息
user1
:=
&
ParsedRequest
{
System
:
"You are a code reviewer."
,
HasSystem
:
true
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"Review this Go code"
},
},
SessionContext
:
&
SessionContext
{
ClientIP
:
"1.1.1.1"
,
UserAgent
:
"vscode"
,
APIKeyID
:
1
,
},
}
user2
:=
&
ParsedRequest
{
System
:
"You are a code reviewer."
,
HasSystem
:
true
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"Review this Python code"
},
},
SessionContext
:
&
SessionContext
{
ClientIP
:
"2.2.2.2"
,
UserAgent
:
"vscode"
,
APIKeyID
:
2
,
},
}
h1
:=
svc
.
GenerateSessionHash
(
user1
)
h2
:=
svc
.
GenerateSessionHash
(
user2
)
require
.
NotEqual
(
t
,
h1
,
h2
,
"different users with different messages should get different hashes"
)
}
func
TestGenerateSessionHash_SameSystemSameMessageDifferentContext
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
// 这是修复的核心场景:两个不同用户发送完全相同的 system + messages(如 "hello")
// 有了 SessionContext 后应该产生不同 hash
user1
:=
&
ParsedRequest
{
System
:
"You are a helpful assistant."
,
HasSystem
:
true
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
},
SessionContext
:
&
SessionContext
{
ClientIP
:
"1.1.1.1"
,
UserAgent
:
"Mozilla/5.0"
,
APIKeyID
:
10
,
},
}
user2
:=
&
ParsedRequest
{
System
:
"You are a helpful assistant."
,
HasSystem
:
true
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
},
SessionContext
:
&
SessionContext
{
ClientIP
:
"2.2.2.2"
,
UserAgent
:
"Mozilla/5.0"
,
APIKeyID
:
20
,
},
}
h1
:=
svc
.
GenerateSessionHash
(
user1
)
h2
:=
svc
.
GenerateSessionHash
(
user2
)
require
.
NotEqual
(
t
,
h1
,
h2
,
"CRITICAL: same system+messages but different users should get different hashes"
)
}
// ============ SessionContext 各字段独立影响测试 ============
func
TestGenerateSessionHash_SessionContext_IPDifference
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
base
:=
func
(
ip
string
)
*
ParsedRequest
{
return
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"test"
},
},
SessionContext
:
&
SessionContext
{
ClientIP
:
ip
,
UserAgent
:
"same-ua"
,
APIKeyID
:
1
,
},
}
}
h1
:=
svc
.
GenerateSessionHash
(
base
(
"1.1.1.1"
))
h2
:=
svc
.
GenerateSessionHash
(
base
(
"2.2.2.2"
))
require
.
NotEqual
(
t
,
h1
,
h2
,
"different IP should produce different hash"
)
}
func
TestGenerateSessionHash_SessionContext_UADifference
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
base
:=
func
(
ua
string
)
*
ParsedRequest
{
return
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"test"
},
},
SessionContext
:
&
SessionContext
{
ClientIP
:
"1.1.1.1"
,
UserAgent
:
ua
,
APIKeyID
:
1
,
},
}
}
h1
:=
svc
.
GenerateSessionHash
(
base
(
"Mozilla/5.0"
))
h2
:=
svc
.
GenerateSessionHash
(
base
(
"curl/7.0"
))
require
.
NotEqual
(
t
,
h1
,
h2
,
"different User-Agent should produce different hash"
)
}
func
TestGenerateSessionHash_SessionContext_APIKeyIDDifference
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
base
:=
func
(
keyID
int64
)
*
ParsedRequest
{
return
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"test"
},
},
SessionContext
:
&
SessionContext
{
ClientIP
:
"1.1.1.1"
,
UserAgent
:
"same-ua"
,
APIKeyID
:
keyID
,
},
}
}
h1
:=
svc
.
GenerateSessionHash
(
base
(
1
))
h2
:=
svc
.
GenerateSessionHash
(
base
(
2
))
require
.
NotEqual
(
t
,
h1
,
h2
,
"different APIKeyID should produce different hash"
)
}
// ============ 多用户并发相同消息场景 ============
func
TestGenerateSessionHash_MultipleUsersSameFirstMessage
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
// 模拟 5 个不同用户同时发送 "hello" → 应该产生 5 个不同的 hash
hashes
:=
make
(
map
[
string
]
bool
)
for
i
:=
0
;
i
<
5
;
i
++
{
parsed
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
},
SessionContext
:
&
SessionContext
{
ClientIP
:
"192.168.1."
+
string
(
rune
(
'1'
+
i
)),
UserAgent
:
"client-"
+
string
(
rune
(
'A'
+
i
)),
APIKeyID
:
int64
(
i
+
1
),
},
}
h
:=
svc
.
GenerateSessionHash
(
parsed
)
require
.
NotEmpty
(
t
,
h
)
require
.
False
(
t
,
hashes
[
h
],
"hash collision detected for user %d"
,
i
)
hashes
[
h
]
=
true
}
require
.
Len
(
t
,
hashes
,
5
,
"5 different users should produce 5 unique hashes"
)
}
// ============ 连续会话粘性:多轮对话同一用户 ============
func
TestGenerateSessionHash_SameUserGrowingConversation
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
ctx
:=
&
SessionContext
{
ClientIP
:
"1.2.3.4"
,
UserAgent
:
"browser"
,
APIKeyID
:
42
}
// 模拟同一用户的连续会话,每轮 hash 不同但同用户重试保持一致
messages
:=
[]
map
[
string
]
any
{
{
"role"
:
"user"
,
"content"
:
"msg1"
},
{
"role"
:
"assistant"
,
"content"
:
"reply1"
},
{
"role"
:
"user"
,
"content"
:
"msg2"
},
{
"role"
:
"assistant"
,
"content"
:
"reply2"
},
{
"role"
:
"user"
,
"content"
:
"msg3"
},
{
"role"
:
"assistant"
,
"content"
:
"reply3"
},
{
"role"
:
"user"
,
"content"
:
"msg4"
},
}
prevHash
:=
""
for
round
:=
1
;
round
<=
len
(
messages
);
round
+=
2
{
// 构建前 round 条消息
msgs
:=
make
([]
any
,
round
)
for
j
:=
0
;
j
<
round
;
j
++
{
msgs
[
j
]
=
messages
[
j
]
}
parsed
:=
&
ParsedRequest
{
System
:
"System"
,
HasSystem
:
true
,
Messages
:
msgs
,
SessionContext
:
ctx
,
}
h
:=
svc
.
GenerateSessionHash
(
parsed
)
require
.
NotEmpty
(
t
,
h
,
"round %d hash should not be empty"
,
round
)
if
prevHash
!=
""
{
require
.
NotEqual
(
t
,
prevHash
,
h
,
"round %d hash should differ from previous round"
,
round
)
}
prevHash
=
h
// 同一轮重试应该相同
h2
:=
svc
.
GenerateSessionHash
(
parsed
)
require
.
Equal
(
t
,
h
,
h2
,
"retry of round %d should produce same hash"
,
round
)
}
}
// ============ 多轮消息内容结构化测试 ============
func
TestGenerateSessionHash_MultipleUserMessages
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
ctx
:=
&
SessionContext
{
ClientIP
:
"1.2.3.4"
,
UserAgent
:
"test"
,
APIKeyID
:
1
}
// 5 条用户消息(无 assistant 回复)
parsed
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"first"
},
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"second"
},
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"third"
},
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"fourth"
},
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"fifth"
},
},
SessionContext
:
ctx
,
}
h
:=
svc
.
GenerateSessionHash
(
parsed
)
require
.
NotEmpty
(
t
,
h
)
// 修改中间一条消息应该改变 hash
parsed2
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"first"
},
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"CHANGED"
},
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"third"
},
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"fourth"
},
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"fifth"
},
},
SessionContext
:
ctx
,
}
h2
:=
svc
.
GenerateSessionHash
(
parsed2
)
require
.
NotEqual
(
t
,
h
,
h2
,
"changing any message should change the hash"
)
}
func
TestGenerateSessionHash_MessageOrderMatters
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
ctx
:=
&
SessionContext
{
ClientIP
:
"1.2.3.4"
,
UserAgent
:
"test"
,
APIKeyID
:
1
}
parsed1
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"alpha"
},
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"beta"
},
},
SessionContext
:
ctx
,
}
parsed2
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"beta"
},
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"alpha"
},
},
SessionContext
:
ctx
,
}
h1
:=
svc
.
GenerateSessionHash
(
parsed1
)
h2
:=
svc
.
GenerateSessionHash
(
parsed2
)
require
.
NotEqual
(
t
,
h1
,
h2
,
"message order should affect the hash"
)
}
// ============ 复杂内容格式测试 ============
func
TestGenerateSessionHash_StructuredContent
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
ctx
:=
&
SessionContext
{
ClientIP
:
"1.2.3.4"
,
UserAgent
:
"test"
,
APIKeyID
:
1
}
// 结构化 content(数组形式)
parsed
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
[]
any
{
map
[
string
]
any
{
"type"
:
"text"
,
"text"
:
"Look at this"
},
map
[
string
]
any
{
"type"
:
"text"
,
"text"
:
"And this too"
},
},
},
},
SessionContext
:
ctx
,
}
h
:=
svc
.
GenerateSessionHash
(
parsed
)
require
.
NotEmpty
(
t
,
h
,
"structured content should produce a hash"
)
}
func
TestGenerateSessionHash_ArraySystemPrompt
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
ctx
:=
&
SessionContext
{
ClientIP
:
"1.2.3.4"
,
UserAgent
:
"test"
,
APIKeyID
:
1
}
// 数组格式的 system prompt
parsed
:=
&
ParsedRequest
{
System
:
[]
any
{
map
[
string
]
any
{
"type"
:
"text"
,
"text"
:
"You are a helpful assistant."
},
map
[
string
]
any
{
"type"
:
"text"
,
"text"
:
"Be concise."
},
},
HasSystem
:
true
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
},
SessionContext
:
ctx
,
}
h
:=
svc
.
GenerateSessionHash
(
parsed
)
require
.
NotEmpty
(
t
,
h
,
"array system prompt should produce a hash"
)
}
// ============ SessionContext 与 cache_control 优先级 ============
func
TestGenerateSessionHash_CacheControlOverridesSessionContext
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
// 当有 cache_control: ephemeral 时,使用第 2 级优先级
// SessionContext 不应影响结果
parsed1
:=
&
ParsedRequest
{
System
:
[]
any
{
map
[
string
]
any
{
"type"
:
"text"
,
"text"
:
"You are a tool-specific assistant."
,
"cache_control"
:
map
[
string
]
any
{
"type"
:
"ephemeral"
},
},
},
HasSystem
:
true
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
},
SessionContext
:
&
SessionContext
{
ClientIP
:
"1.1.1.1"
,
UserAgent
:
"ua1"
,
APIKeyID
:
100
,
},
}
parsed2
:=
&
ParsedRequest
{
System
:
[]
any
{
map
[
string
]
any
{
"type"
:
"text"
,
"text"
:
"You are a tool-specific assistant."
,
"cache_control"
:
map
[
string
]
any
{
"type"
:
"ephemeral"
},
},
},
HasSystem
:
true
,
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"hello"
},
},
SessionContext
:
&
SessionContext
{
ClientIP
:
"2.2.2.2"
,
UserAgent
:
"ua2"
,
APIKeyID
:
200
,
},
}
h1
:=
svc
.
GenerateSessionHash
(
parsed1
)
h2
:=
svc
.
GenerateSessionHash
(
parsed2
)
require
.
Equal
(
t
,
h1
,
h2
,
"cache_control ephemeral has higher priority, SessionContext should not affect result"
)
}
// ============ 边界情况 ============
func
TestGenerateSessionHash_EmptyMessages
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
parsed
:=
&
ParsedRequest
{
Messages
:
[]
any
{},
SessionContext
:
&
SessionContext
{
ClientIP
:
"1.1.1.1"
,
UserAgent
:
"test"
,
APIKeyID
:
1
,
},
}
// 空 messages + 只有 SessionContext 时,combined.Len() > 0 因为有 context 写入
h
:=
svc
.
GenerateSessionHash
(
parsed
)
require
.
NotEmpty
(
t
,
h
,
"empty messages with SessionContext should still produce a hash from context"
)
}
func
TestGenerateSessionHash_EmptyMessagesNoContext
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
parsed
:=
&
ParsedRequest
{
Messages
:
[]
any
{},
}
h
:=
svc
.
GenerateSessionHash
(
parsed
)
require
.
Empty
(
t
,
h
,
"empty messages without SessionContext should produce empty hash"
)
}
func
TestGenerateSessionHash_SessionContextWithEmptyFields
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
// SessionContext 字段为空字符串和零值时仍应影响 hash
withEmptyCtx
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"test"
},
},
SessionContext
:
&
SessionContext
{
ClientIP
:
""
,
UserAgent
:
""
,
APIKeyID
:
0
,
},
}
withoutCtx
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"test"
},
},
}
h1
:=
svc
.
GenerateSessionHash
(
withEmptyCtx
)
h2
:=
svc
.
GenerateSessionHash
(
withoutCtx
)
// 有 SessionContext(即使字段为空)仍然会写入分隔符 "::" 等
require
.
NotEqual
(
t
,
h1
,
h2
,
"empty-field SessionContext should still differ from nil SessionContext"
)
}
// ============ 长对话历史测试 ============
func
TestGenerateSessionHash_LongConversation
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
ctx
:=
&
SessionContext
{
ClientIP
:
"1.2.3.4"
,
UserAgent
:
"test"
,
APIKeyID
:
1
}
// 构建 20 轮对话
messages
:=
make
([]
any
,
0
,
40
)
for
i
:=
0
;
i
<
20
;
i
++
{
messages
=
append
(
messages
,
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"user message "
+
string
(
rune
(
'A'
+
i
)),
})
messages
=
append
(
messages
,
map
[
string
]
any
{
"role"
:
"assistant"
,
"content"
:
"assistant reply "
+
string
(
rune
(
'A'
+
i
)),
})
}
parsed
:=
&
ParsedRequest
{
System
:
"System prompt"
,
HasSystem
:
true
,
Messages
:
messages
,
SessionContext
:
ctx
,
}
h
:=
svc
.
GenerateSessionHash
(
parsed
)
require
.
NotEmpty
(
t
,
h
)
// 再加一轮应该不同
moreMessages
:=
make
([]
any
,
len
(
messages
)
+
2
)
copy
(
moreMessages
,
messages
)
moreMessages
[
len
(
messages
)]
=
map
[
string
]
any
{
"role"
:
"user"
,
"content"
:
"one more"
}
moreMessages
[
len
(
messages
)
+
1
]
=
map
[
string
]
any
{
"role"
:
"assistant"
,
"content"
:
"ok"
}
parsed2
:=
&
ParsedRequest
{
System
:
"System prompt"
,
HasSystem
:
true
,
Messages
:
moreMessages
,
SessionContext
:
ctx
,
}
h2
:=
svc
.
GenerateSessionHash
(
parsed2
)
require
.
NotEqual
(
t
,
h
,
h2
,
"adding more messages to long conversation should change hash"
)
}
// ============ Gemini 原生格式 session hash 测试 ============
func
TestGenerateSessionHash_GeminiContentsProducesHash
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
// Gemini 格式: contents[].parts[].text
parsed
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"parts"
:
[]
any
{
map
[
string
]
any
{
"text"
:
"Hello from Gemini"
},
},
},
},
SessionContext
:
&
SessionContext
{
ClientIP
:
"1.2.3.4"
,
UserAgent
:
"gemini-cli"
,
APIKeyID
:
1
,
},
}
h
:=
svc
.
GenerateSessionHash
(
parsed
)
require
.
NotEmpty
(
t
,
h
,
"Gemini contents with parts should produce a non-empty hash"
)
}
func
TestGenerateSessionHash_GeminiDifferentContentsDifferentHash
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
ctx
:=
&
SessionContext
{
ClientIP
:
"1.2.3.4"
,
UserAgent
:
"gemini-cli"
,
APIKeyID
:
1
}
parsed1
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"parts"
:
[]
any
{
map
[
string
]
any
{
"text"
:
"Hello"
},
},
},
},
SessionContext
:
ctx
,
}
parsed2
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"parts"
:
[]
any
{
map
[
string
]
any
{
"text"
:
"Goodbye"
},
},
},
},
SessionContext
:
ctx
,
}
h1
:=
svc
.
GenerateSessionHash
(
parsed1
)
h2
:=
svc
.
GenerateSessionHash
(
parsed2
)
require
.
NotEqual
(
t
,
h1
,
h2
,
"different Gemini contents should produce different hashes"
)
}
func
TestGenerateSessionHash_GeminiSameContentsSameHash
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
ctx
:=
&
SessionContext
{
ClientIP
:
"1.2.3.4"
,
UserAgent
:
"gemini-cli"
,
APIKeyID
:
1
}
mk
:=
func
()
*
ParsedRequest
{
return
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"parts"
:
[]
any
{
map
[
string
]
any
{
"text"
:
"Hello"
},
},
},
map
[
string
]
any
{
"role"
:
"model"
,
"parts"
:
[]
any
{
map
[
string
]
any
{
"text"
:
"Hi there!"
},
},
},
},
SessionContext
:
ctx
,
}
}
h1
:=
svc
.
GenerateSessionHash
(
mk
())
h2
:=
svc
.
GenerateSessionHash
(
mk
())
require
.
Equal
(
t
,
h1
,
h2
,
"same Gemini contents should produce identical hash"
)
}
func
TestGenerateSessionHash_GeminiMultiTurnHashChanges
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
ctx
:=
&
SessionContext
{
ClientIP
:
"1.2.3.4"
,
UserAgent
:
"gemini-cli"
,
APIKeyID
:
1
}
round1
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"parts"
:
[]
any
{
map
[
string
]
any
{
"text"
:
"hello"
}},
},
},
SessionContext
:
ctx
,
}
round2
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"parts"
:
[]
any
{
map
[
string
]
any
{
"text"
:
"hello"
}},
},
map
[
string
]
any
{
"role"
:
"model"
,
"parts"
:
[]
any
{
map
[
string
]
any
{
"text"
:
"Hi!"
}},
},
map
[
string
]
any
{
"role"
:
"user"
,
"parts"
:
[]
any
{
map
[
string
]
any
{
"text"
:
"How are you?"
}},
},
},
SessionContext
:
ctx
,
}
h1
:=
svc
.
GenerateSessionHash
(
round1
)
h2
:=
svc
.
GenerateSessionHash
(
round2
)
require
.
NotEmpty
(
t
,
h1
)
require
.
NotEmpty
(
t
,
h2
)
require
.
NotEqual
(
t
,
h1
,
h2
,
"Gemini multi-turn should produce different hashes per round"
)
}
func
TestGenerateSessionHash_GeminiDifferentUsersSameContentDifferentHash
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
// 核心场景:两个不同用户发送相同 Gemini 格式消息应得到不同 hash
user1
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"parts"
:
[]
any
{
map
[
string
]
any
{
"text"
:
"hello"
}},
},
},
SessionContext
:
&
SessionContext
{
ClientIP
:
"1.1.1.1"
,
UserAgent
:
"gemini-cli"
,
APIKeyID
:
10
,
},
}
user2
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"parts"
:
[]
any
{
map
[
string
]
any
{
"text"
:
"hello"
}},
},
},
SessionContext
:
&
SessionContext
{
ClientIP
:
"2.2.2.2"
,
UserAgent
:
"gemini-cli"
,
APIKeyID
:
20
,
},
}
h1
:=
svc
.
GenerateSessionHash
(
user1
)
h2
:=
svc
.
GenerateSessionHash
(
user2
)
require
.
NotEqual
(
t
,
h1
,
h2
,
"CRITICAL: different Gemini users with same content must get different hashes"
)
}
func
TestGenerateSessionHash_GeminiSystemInstructionAffectsHash
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
ctx
:=
&
SessionContext
{
ClientIP
:
"1.2.3.4"
,
UserAgent
:
"gemini-cli"
,
APIKeyID
:
1
}
// systemInstruction 经 ParseGatewayRequest 解析后存入 parsed.System
withSys
:=
&
ParsedRequest
{
System
:
[]
any
{
map
[
string
]
any
{
"text"
:
"You are a coding assistant."
},
},
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"parts"
:
[]
any
{
map
[
string
]
any
{
"text"
:
"hello"
}},
},
},
SessionContext
:
ctx
,
}
withoutSys
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"parts"
:
[]
any
{
map
[
string
]
any
{
"text"
:
"hello"
}},
},
},
SessionContext
:
ctx
,
}
h1
:=
svc
.
GenerateSessionHash
(
withSys
)
h2
:=
svc
.
GenerateSessionHash
(
withoutSys
)
require
.
NotEqual
(
t
,
h1
,
h2
,
"systemInstruction should affect the hash"
)
}
func
TestGenerateSessionHash_GeminiMultiPartMessage
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
ctx
:=
&
SessionContext
{
ClientIP
:
"1.2.3.4"
,
UserAgent
:
"gemini-cli"
,
APIKeyID
:
1
}
// 多 parts 的消息
parsed
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"parts"
:
[]
any
{
map
[
string
]
any
{
"text"
:
"Part 1"
},
map
[
string
]
any
{
"text"
:
"Part 2"
},
map
[
string
]
any
{
"text"
:
"Part 3"
},
},
},
},
SessionContext
:
ctx
,
}
h
:=
svc
.
GenerateSessionHash
(
parsed
)
require
.
NotEmpty
(
t
,
h
,
"multi-part Gemini message should produce a hash"
)
// 不同内容的多 parts
parsed2
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"parts"
:
[]
any
{
map
[
string
]
any
{
"text"
:
"Part 1"
},
map
[
string
]
any
{
"text"
:
"CHANGED"
},
map
[
string
]
any
{
"text"
:
"Part 3"
},
},
},
},
SessionContext
:
ctx
,
}
h2
:=
svc
.
GenerateSessionHash
(
parsed2
)
require
.
NotEqual
(
t
,
h
,
h2
,
"changing a part should change the hash"
)
}
func
TestGenerateSessionHash_GeminiNonTextPartsIgnored
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
ctx
:=
&
SessionContext
{
ClientIP
:
"1.2.3.4"
,
UserAgent
:
"gemini-cli"
,
APIKeyID
:
1
}
// 含非 text 类型 parts(如 inline_data),应被跳过但不报错
parsed
:=
&
ParsedRequest
{
Messages
:
[]
any
{
map
[
string
]
any
{
"role"
:
"user"
,
"parts"
:
[]
any
{
map
[
string
]
any
{
"text"
:
"Describe this image"
},
map
[
string
]
any
{
"inline_data"
:
map
[
string
]
any
{
"mime_type"
:
"image/png"
,
"data"
:
"base64..."
}},
},
},
},
SessionContext
:
ctx
,
}
h
:=
svc
.
GenerateSessionHash
(
parsed
)
require
.
NotEmpty
(
t
,
h
,
"Gemini message with mixed parts should still produce a hash from text parts"
)
}
func
TestGenerateSessionHash_GeminiMultiTurnHashNotSticky
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
ctx
:=
&
SessionContext
{
ClientIP
:
"10.0.0.1"
,
UserAgent
:
"gemini-cli"
,
APIKeyID
:
42
}
// 模拟同一 Gemini 会话的三轮请求,每轮 contents 累积增长。
// 验证预期行为:每轮 hash 都不同,即 GenerateSessionHash 不具备跨轮粘性。
// 这是 by-design 的——Gemini 的跨轮粘性由 Digest Fallback(BuildGeminiDigestChain)负责。
round1Body
:=
[]
byte
(
`{
"systemInstruction": {"parts": [{"text": "You are a coding assistant."}]},
"contents": [
{"role": "user", "parts": [{"text": "Write a Go function"}]}
]
}`
)
round2Body
:=
[]
byte
(
`{
"systemInstruction": {"parts": [{"text": "You are a coding assistant."}]},
"contents": [
{"role": "user", "parts": [{"text": "Write a Go function"}]},
{"role": "model", "parts": [{"text": "func hello() {}"}]},
{"role": "user", "parts": [{"text": "Add error handling"}]}
]
}`
)
round3Body
:=
[]
byte
(
`{
"systemInstruction": {"parts": [{"text": "You are a coding assistant."}]},
"contents": [
{"role": "user", "parts": [{"text": "Write a Go function"}]},
{"role": "model", "parts": [{"text": "func hello() {}"}]},
{"role": "user", "parts": [{"text": "Add error handling"}]},
{"role": "model", "parts": [{"text": "func hello() error { return nil }"}]},
{"role": "user", "parts": [{"text": "Now add tests"}]}
]
}`
)
hashes
:=
make
([]
string
,
3
)
for
i
,
body
:=
range
[][]
byte
{
round1Body
,
round2Body
,
round3Body
}
{
parsed
,
err
:=
ParseGatewayRequest
(
body
,
"gemini"
)
require
.
NoError
(
t
,
err
)
parsed
.
SessionContext
=
ctx
hashes
[
i
]
=
svc
.
GenerateSessionHash
(
parsed
)
require
.
NotEmpty
(
t
,
hashes
[
i
],
"round %d hash should not be empty"
,
i
+
1
)
}
// 每轮 hash 都不同——这是预期行为
require
.
NotEqual
(
t
,
hashes
[
0
],
hashes
[
1
],
"round 1 vs 2 hash should differ (contents grow)"
)
require
.
NotEqual
(
t
,
hashes
[
1
],
hashes
[
2
],
"round 2 vs 3 hash should differ (contents grow)"
)
require
.
NotEqual
(
t
,
hashes
[
0
],
hashes
[
2
],
"round 1 vs 3 hash should differ"
)
// 同一轮重试应产生相同 hash
parsed1Again
,
err
:=
ParseGatewayRequest
(
round2Body
,
"gemini"
)
require
.
NoError
(
t
,
err
)
parsed1Again
.
SessionContext
=
ctx
h2Again
:=
svc
.
GenerateSessionHash
(
parsed1Again
)
require
.
Equal
(
t
,
hashes
[
1
],
h2Again
,
"retry of same round should produce same hash"
)
}
func
TestGenerateSessionHash_GeminiEndToEnd
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
// 端到端测试:模拟 ParseGatewayRequest + GenerateSessionHash 完整流程
body
:=
[]
byte
(
`{
"model": "gemini-2.5-pro",
"systemInstruction": {
"parts": [{"text": "You are a coding assistant."}]
},
"contents": [
{"role": "user", "parts": [{"text": "Write a Go function"}]},
{"role": "model", "parts": [{"text": "Here is a function..."}]},
{"role": "user", "parts": [{"text": "Now add error handling"}]}
]
}`
)
parsed
,
err
:=
ParseGatewayRequest
(
body
,
"gemini"
)
require
.
NoError
(
t
,
err
)
parsed
.
SessionContext
=
&
SessionContext
{
ClientIP
:
"10.0.0.1"
,
UserAgent
:
"gemini-cli/1.0"
,
APIKeyID
:
42
,
}
h
:=
svc
.
GenerateSessionHash
(
parsed
)
require
.
NotEmpty
(
t
,
h
,
"end-to-end Gemini flow should produce a hash"
)
// 同一请求再次解析应产生相同 hash
parsed2
,
err
:=
ParseGatewayRequest
(
body
,
"gemini"
)
require
.
NoError
(
t
,
err
)
parsed2
.
SessionContext
=
&
SessionContext
{
ClientIP
:
"10.0.0.1"
,
UserAgent
:
"gemini-cli/1.0"
,
APIKeyID
:
42
,
}
h2
:=
svc
.
GenerateSessionHash
(
parsed2
)
require
.
Equal
(
t
,
h
,
h2
,
"same request should produce same hash"
)
// 不同用户发送相同请求应产生不同 hash
parsed3
,
err
:=
ParseGatewayRequest
(
body
,
"gemini"
)
require
.
NoError
(
t
,
err
)
parsed3
.
SessionContext
=
&
SessionContext
{
ClientIP
:
"10.0.0.2"
,
UserAgent
:
"gemini-cli/1.0"
,
APIKeyID
:
99
,
}
h3
:=
svc
.
GenerateSessionHash
(
parsed3
)
require
.
NotEqual
(
t
,
h
,
h3
,
"different user with same Gemini request should get different hash"
)
}
backend/internal/service/group.go
View file @
d367d1cd
...
...
@@ -51,6 +51,9 @@ type Group struct {
// 可选值: claude, gemini_text, gemini_image
SupportedModelScopes
[]
string
// 分组排序
SortOrder
int
CreatedAt
time
.
Time
UpdatedAt
time
.
Time
...
...
backend/internal/service/group_service.go
View file @
d367d1cd
...
...
@@ -33,6 +33,14 @@ type GroupRepository interface {
GetAccountIDsByGroupIDs
(
ctx
context
.
Context
,
groupIDs
[]
int64
)
([]
int64
,
error
)
// BindAccountsToGroup 将多个账号绑定到指定分组
BindAccountsToGroup
(
ctx
context
.
Context
,
groupID
int64
,
accountIDs
[]
int64
)
error
// UpdateSortOrders 批量更新分组排序
UpdateSortOrders
(
ctx
context
.
Context
,
updates
[]
GroupSortOrderUpdate
)
error
}
// GroupSortOrderUpdate 分组排序更新
type
GroupSortOrderUpdate
struct
{
ID
int64
`json:"id"`
SortOrder
int
`json:"sort_order"`
}
// CreateGroupRequest 创建分组请求
...
...
backend/internal/service/model_rate_limit_test.go
View file @
d367d1cd
...
...
@@ -318,110 +318,6 @@ func TestGetModelRateLimitRemainingTime(t *testing.T) {
}
}
func
TestGetQuotaScopeRateLimitRemainingTime
(
t
*
testing
.
T
)
{
now
:=
time
.
Now
()
future10m
:=
now
.
Add
(
10
*
time
.
Minute
)
.
Format
(
time
.
RFC3339
)
past
:=
now
.
Add
(
-
10
*
time
.
Minute
)
.
Format
(
time
.
RFC3339
)
tests
:=
[]
struct
{
name
string
account
*
Account
requestedModel
string
minExpected
time
.
Duration
maxExpected
time
.
Duration
}{
{
name
:
"nil account"
,
account
:
nil
,
requestedModel
:
"claude-sonnet-4-5"
,
minExpected
:
0
,
maxExpected
:
0
,
},
{
name
:
"non-antigravity platform"
,
account
:
&
Account
{
Platform
:
PlatformAnthropic
,
Extra
:
map
[
string
]
any
{
antigravityQuotaScopesKey
:
map
[
string
]
any
{
"claude"
:
map
[
string
]
any
{
"rate_limit_reset_at"
:
future10m
,
},
},
},
},
requestedModel
:
"claude-sonnet-4-5"
,
minExpected
:
0
,
maxExpected
:
0
,
},
{
name
:
"claude scope rate limited"
,
account
:
&
Account
{
Platform
:
PlatformAntigravity
,
Extra
:
map
[
string
]
any
{
antigravityQuotaScopesKey
:
map
[
string
]
any
{
"claude"
:
map
[
string
]
any
{
"rate_limit_reset_at"
:
future10m
,
},
},
},
},
requestedModel
:
"claude-sonnet-4-5"
,
minExpected
:
9
*
time
.
Minute
,
maxExpected
:
11
*
time
.
Minute
,
},
{
name
:
"gemini_text scope rate limited"
,
account
:
&
Account
{
Platform
:
PlatformAntigravity
,
Extra
:
map
[
string
]
any
{
antigravityQuotaScopesKey
:
map
[
string
]
any
{
"gemini_text"
:
map
[
string
]
any
{
"rate_limit_reset_at"
:
future10m
,
},
},
},
},
requestedModel
:
"gemini-3-flash"
,
minExpected
:
9
*
time
.
Minute
,
maxExpected
:
11
*
time
.
Minute
,
},
{
name
:
"expired scope rate limit"
,
account
:
&
Account
{
Platform
:
PlatformAntigravity
,
Extra
:
map
[
string
]
any
{
antigravityQuotaScopesKey
:
map
[
string
]
any
{
"claude"
:
map
[
string
]
any
{
"rate_limit_reset_at"
:
past
,
},
},
},
},
requestedModel
:
"claude-sonnet-4-5"
,
minExpected
:
0
,
maxExpected
:
0
,
},
{
name
:
"unsupported model"
,
account
:
&
Account
{
Platform
:
PlatformAntigravity
,
},
requestedModel
:
"gpt-4"
,
minExpected
:
0
,
maxExpected
:
0
,
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
result
:=
tt
.
account
.
GetQuotaScopeRateLimitRemainingTime
(
tt
.
requestedModel
)
if
result
<
tt
.
minExpected
||
result
>
tt
.
maxExpected
{
t
.
Errorf
(
"GetQuotaScopeRateLimitRemainingTime() = %v, want between %v and %v"
,
result
,
tt
.
minExpected
,
tt
.
maxExpected
)
}
})
}
}
func
TestGetRateLimitRemainingTime
(
t
*
testing
.
T
)
{
now
:=
time
.
Now
()
future15m
:=
now
.
Add
(
15
*
time
.
Minute
)
.
Format
(
time
.
RFC3339
)
...
...
@@ -442,45 +338,19 @@ func TestGetRateLimitRemainingTime(t *testing.T) {
maxExpected
:
0
,
},
{
name
:
"model remaining > scope remaining - returns model"
,
account
:
&
Account
{
Platform
:
PlatformAntigravity
,
Extra
:
map
[
string
]
any
{
modelRateLimitsKey
:
map
[
string
]
any
{
"claude-sonnet-4-5"
:
map
[
string
]
any
{
"rate_limit_reset_at"
:
future15m
,
// 15 分钟
},
},
antigravityQuotaScopesKey
:
map
[
string
]
any
{
"claude"
:
map
[
string
]
any
{
"rate_limit_reset_at"
:
future5m
,
// 5 分钟
},
},
},
},
requestedModel
:
"claude-sonnet-4-5"
,
minExpected
:
14
*
time
.
Minute
,
// 应返回较大的 15 分钟
maxExpected
:
16
*
time
.
Minute
,
},
{
name
:
"scope remaining > model remaining - returns scope"
,
name
:
"model rate limited - 15 minutes"
,
account
:
&
Account
{
Platform
:
PlatformAntigravity
,
Extra
:
map
[
string
]
any
{
modelRateLimitsKey
:
map
[
string
]
any
{
"claude-sonnet-4-5"
:
map
[
string
]
any
{
"rate_limit_reset_at"
:
future5m
,
// 5 分钟
},
},
antigravityQuotaScopesKey
:
map
[
string
]
any
{
"claude"
:
map
[
string
]
any
{
"rate_limit_reset_at"
:
future15m
,
// 15 分钟
"rate_limit_reset_at"
:
future15m
,
},
},
},
},
requestedModel
:
"claude-sonnet-4-5"
,
minExpected
:
14
*
time
.
Minute
,
// 应返回较大的 15 分钟
minExpected
:
14
*
time
.
Minute
,
maxExpected
:
16
*
time
.
Minute
,
},
{
...
...
@@ -499,22 +369,6 @@ func TestGetRateLimitRemainingTime(t *testing.T) {
minExpected
:
4
*
time
.
Minute
,
maxExpected
:
6
*
time
.
Minute
,
},
{
name
:
"only scope rate limited"
,
account
:
&
Account
{
Platform
:
PlatformAntigravity
,
Extra
:
map
[
string
]
any
{
antigravityQuotaScopesKey
:
map
[
string
]
any
{
"claude"
:
map
[
string
]
any
{
"rate_limit_reset_at"
:
future5m
,
},
},
},
},
requestedModel
:
"claude-sonnet-4-5"
,
minExpected
:
4
*
time
.
Minute
,
maxExpected
:
6
*
time
.
Minute
,
},
{
name
:
"neither rate limited"
,
account
:
&
Account
{
...
...
backend/internal/service/openai_gateway_service.go
View file @
d367d1cd
...
...
@@ -582,10 +582,6 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex
}
}
}
else
{
type
accountWithLoad
struct
{
account
*
Account
loadInfo
*
AccountLoadInfo
}
var
available
[]
accountWithLoad
for
_
,
acc
:=
range
candidates
{
loadInfo
:=
loadMap
[
acc
.
ID
]
...
...
@@ -620,6 +616,7 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex
return
a
.
account
.
LastUsedAt
.
Before
(
*
b
.
account
.
LastUsedAt
)
}
})
shuffleWithinSortGroups
(
available
)
for
_
,
item
:=
range
available
{
result
,
err
:=
s
.
tryAcquireAccountSlot
(
ctx
,
item
.
account
.
ID
,
item
.
account
.
Concurrency
)
...
...
backend/internal/service/openai_gateway_service_test.go
View file @
d367d1cd
...
...
@@ -209,22 +209,6 @@ func (c *stubGatewayCache) DeleteSessionAccountID(ctx context.Context, groupID i
return
nil
}
func
(
c
*
stubGatewayCache
)
IncrModelCallCount
(
ctx
context
.
Context
,
accountID
int64
,
model
string
)
(
int64
,
error
)
{
return
0
,
nil
}
func
(
c
*
stubGatewayCache
)
GetModelLoadBatch
(
ctx
context
.
Context
,
accountIDs
[]
int64
,
model
string
)
(
map
[
int64
]
*
ModelLoadInfo
,
error
)
{
return
nil
,
nil
}
func
(
c
*
stubGatewayCache
)
FindGeminiSession
(
ctx
context
.
Context
,
groupID
int64
,
prefixHash
,
digestChain
string
)
(
uuid
string
,
accountID
int64
,
found
bool
)
{
return
""
,
0
,
false
}
func
(
c
*
stubGatewayCache
)
SaveGeminiSession
(
ctx
context
.
Context
,
groupID
int64
,
prefixHash
,
digestChain
,
uuid
string
,
accountID
int64
)
error
{
return
nil
}
func
TestOpenAISelectAccountWithLoadAwareness_FiltersUnschedulable
(
t
*
testing
.
T
)
{
now
:=
time
.
Now
()
resetAt
:=
now
.
Add
(
10
*
time
.
Minute
)
...
...
backend/internal/service/ops_account_availability.go
View file @
d367d1cd
...
...
@@ -66,7 +66,6 @@ func (s *OpsService) GetAccountAvailabilityStats(ctx context.Context, platformFi
}
isAvailable
:=
acc
.
Status
==
StatusActive
&&
acc
.
Schedulable
&&
!
isRateLimited
&&
!
isOverloaded
&&
!
isTempUnsched
scopeRateLimits
:=
acc
.
GetAntigravityScopeRateLimits
()
if
acc
.
Platform
!=
""
{
if
_
,
ok
:=
platform
[
acc
.
Platform
];
!
ok
{
...
...
@@ -85,14 +84,6 @@ func (s *OpsService) GetAccountAvailabilityStats(ctx context.Context, platformFi
if
hasError
{
p
.
ErrorCount
++
}
if
len
(
scopeRateLimits
)
>
0
{
if
p
.
ScopeRateLimitCount
==
nil
{
p
.
ScopeRateLimitCount
=
make
(
map
[
string
]
int64
)
}
for
scope
:=
range
scopeRateLimits
{
p
.
ScopeRateLimitCount
[
scope
]
++
}
}
}
for
_
,
grp
:=
range
acc
.
Groups
{
...
...
@@ -117,14 +108,6 @@ func (s *OpsService) GetAccountAvailabilityStats(ctx context.Context, platformFi
if
hasError
{
g
.
ErrorCount
++
}
if
len
(
scopeRateLimits
)
>
0
{
if
g
.
ScopeRateLimitCount
==
nil
{
g
.
ScopeRateLimitCount
=
make
(
map
[
string
]
int64
)
}
for
scope
:=
range
scopeRateLimits
{
g
.
ScopeRateLimitCount
[
scope
]
++
}
}
}
displayGroupID
:=
int64
(
0
)
...
...
@@ -157,9 +140,6 @@ func (s *OpsService) GetAccountAvailabilityStats(ctx context.Context, platformFi
item
.
RateLimitRemainingSec
=
&
remainingSec
}
}
if
len
(
scopeRateLimits
)
>
0
{
item
.
ScopeRateLimits
=
scopeRateLimits
}
if
isOverloaded
&&
acc
.
OverloadUntil
!=
nil
{
item
.
OverloadUntil
=
acc
.
OverloadUntil
remainingSec
:=
int64
(
time
.
Until
(
*
acc
.
OverloadUntil
)
.
Seconds
())
...
...
backend/internal/service/ops_realtime_models.go
View file @
d367d1cd
...
...
@@ -54,7 +54,6 @@ type PlatformAvailability struct {
TotalAccounts
int64
`json:"total_accounts"`
AvailableCount
int64
`json:"available_count"`
RateLimitCount
int64
`json:"rate_limit_count"`
ScopeRateLimitCount
map
[
string
]
int64
`json:"scope_rate_limit_count,omitempty"`
ErrorCount
int64
`json:"error_count"`
}
...
...
@@ -66,7 +65,6 @@ type GroupAvailability struct {
TotalAccounts
int64
`json:"total_accounts"`
AvailableCount
int64
`json:"available_count"`
RateLimitCount
int64
`json:"rate_limit_count"`
ScopeRateLimitCount
map
[
string
]
int64
`json:"scope_rate_limit_count,omitempty"`
ErrorCount
int64
`json:"error_count"`
}
...
...
@@ -87,7 +85,6 @@ type AccountAvailability struct {
RateLimitResetAt
*
time
.
Time
`json:"rate_limit_reset_at"`
RateLimitRemainingSec
*
int64
`json:"rate_limit_remaining_sec"`
ScopeRateLimits
map
[
string
]
int64
`json:"scope_rate_limits,omitempty"`
OverloadUntil
*
time
.
Time
`json:"overload_until"`
OverloadRemainingSec
*
int64
`json:"overload_remaining_sec"`
ErrorMessage
string
`json:"error_message"`
...
...
backend/internal/service/ops_retry.go
View file @
d367d1cd
...
...
@@ -12,6 +12,7 @@ import (
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/gin-gonic/gin"
...
...
@@ -528,7 +529,7 @@ func (s *OpsService) selectAccountForRetry(ctx context.Context, reqType opsRetry
func
extractRetryModelAndStream
(
reqType
opsRetryRequestType
,
errorLog
*
OpsErrorLogDetail
,
body
[]
byte
)
(
model
string
,
stream
bool
,
err
error
)
{
switch
reqType
{
case
opsRetryTypeMessages
:
parsed
,
parseErr
:=
ParseGatewayRequest
(
body
)
parsed
,
parseErr
:=
ParseGatewayRequest
(
body
,
domain
.
PlatformAnthropic
)
if
parseErr
!=
nil
{
return
""
,
false
,
fmt
.
Errorf
(
"failed to parse messages request body: %w"
,
parseErr
)
}
...
...
@@ -596,7 +597,7 @@ func (s *OpsService) executeWithAccount(ctx context.Context, reqType opsRetryReq
if
s
.
gatewayService
==
nil
{
return
&
opsRetryExecution
{
status
:
opsRetryStatusFailed
,
errorMessage
:
"gateway service not available"
}
}
parsedReq
,
parseErr
:=
ParseGatewayRequest
(
body
)
parsedReq
,
parseErr
:=
ParseGatewayRequest
(
body
,
domain
.
PlatformAnthropic
)
if
parseErr
!=
nil
{
return
&
opsRetryExecution
{
status
:
opsRetryStatusFailed
,
errorMessage
:
"failed to parse request body"
}
}
...
...
backend/internal/service/ratelimit_service.go
View file @
d367d1cd
...
...
@@ -62,6 +62,32 @@ func (s *RateLimitService) SetTokenCacheInvalidator(invalidator TokenCacheInvali
s
.
tokenCacheInvalidator
=
invalidator
}
// ErrorPolicyResult 表示错误策略检查的结果
type
ErrorPolicyResult
int
const
(
ErrorPolicyNone
ErrorPolicyResult
=
iota
// 未命中任何策略,继续默认逻辑
ErrorPolicySkipped
// 自定义错误码开启但未命中,跳过处理
ErrorPolicyMatched
// 自定义错误码命中,应停止调度
ErrorPolicyTempUnscheduled
// 临时不可调度规则命中
)
// CheckErrorPolicy 检查自定义错误码和临时不可调度规则。
// 自定义错误码开启时覆盖后续所有逻辑(包括临时不可调度)。
func
(
s
*
RateLimitService
)
CheckErrorPolicy
(
ctx
context
.
Context
,
account
*
Account
,
statusCode
int
,
responseBody
[]
byte
)
ErrorPolicyResult
{
if
account
.
IsCustomErrorCodesEnabled
()
{
if
account
.
ShouldHandleErrorCode
(
statusCode
)
{
return
ErrorPolicyMatched
}
slog
.
Info
(
"account_error_code_skipped"
,
"account_id"
,
account
.
ID
,
"status_code"
,
statusCode
)
return
ErrorPolicySkipped
}
if
s
.
tryTempUnschedulable
(
ctx
,
account
,
statusCode
,
responseBody
)
{
return
ErrorPolicyTempUnscheduled
}
return
ErrorPolicyNone
}
// HandleUpstreamError 处理上游错误响应,标记账号状态
// 返回是否应该停止该账号的调度
func
(
s
*
RateLimitService
)
HandleUpstreamError
(
ctx
context
.
Context
,
account
*
Account
,
statusCode
int
,
headers
http
.
Header
,
responseBody
[]
byte
)
(
shouldDisable
bool
)
{
...
...
backend/internal/service/scheduler_shuffle_test.go
0 → 100644
View file @
d367d1cd
//go:build unit
package
service
import
(
"testing"
"time"
"github.com/stretchr/testify/require"
)
// ============ shuffleWithinSortGroups 测试 ============
func
TestShuffleWithinSortGroups_Empty
(
t
*
testing
.
T
)
{
shuffleWithinSortGroups
(
nil
)
shuffleWithinSortGroups
([]
accountWithLoad
{})
}
func
TestShuffleWithinSortGroups_SingleElement
(
t
*
testing
.
T
)
{
accounts
:=
[]
accountWithLoad
{
{
account
:
&
Account
{
ID
:
1
,
Priority
:
1
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
10
}},
}
shuffleWithinSortGroups
(
accounts
)
require
.
Equal
(
t
,
int64
(
1
),
accounts
[
0
]
.
account
.
ID
)
}
func
TestShuffleWithinSortGroups_DifferentGroups_OrderPreserved
(
t
*
testing
.
T
)
{
now
:=
time
.
Now
()
earlier
:=
now
.
Add
(
-
1
*
time
.
Hour
)
accounts
:=
[]
accountWithLoad
{
{
account
:
&
Account
{
ID
:
1
,
Priority
:
1
,
LastUsedAt
:
&
earlier
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
10
}},
{
account
:
&
Account
{
ID
:
2
,
Priority
:
1
,
LastUsedAt
:
&
now
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
20
}},
{
account
:
&
Account
{
ID
:
3
,
Priority
:
2
,
LastUsedAt
:
&
earlier
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
10
}},
}
// 每个元素都属于不同组(Priority 或 LoadRate 或 LastUsedAt 不同),顺序不变
for
i
:=
0
;
i
<
20
;
i
++
{
cpy
:=
make
([]
accountWithLoad
,
len
(
accounts
))
copy
(
cpy
,
accounts
)
shuffleWithinSortGroups
(
cpy
)
require
.
Equal
(
t
,
int64
(
1
),
cpy
[
0
]
.
account
.
ID
)
require
.
Equal
(
t
,
int64
(
2
),
cpy
[
1
]
.
account
.
ID
)
require
.
Equal
(
t
,
int64
(
3
),
cpy
[
2
]
.
account
.
ID
)
}
}
func
TestShuffleWithinSortGroups_SameGroup_Shuffled
(
t
*
testing
.
T
)
{
now
:=
time
.
Now
()
// 同一秒的时间戳视为同一组
sameSecond
:=
time
.
Unix
(
now
.
Unix
(),
0
)
sameSecond2
:=
time
.
Unix
(
now
.
Unix
(),
500
_000_000
)
// 同一秒但不同纳秒
accounts
:=
[]
accountWithLoad
{
{
account
:
&
Account
{
ID
:
1
,
Priority
:
1
,
LastUsedAt
:
&
sameSecond
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
10
}},
{
account
:
&
Account
{
ID
:
2
,
Priority
:
1
,
LastUsedAt
:
&
sameSecond2
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
10
}},
{
account
:
&
Account
{
ID
:
3
,
Priority
:
1
,
LastUsedAt
:
&
sameSecond
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
10
}},
}
// 多次执行,验证所有 ID 都出现在第一个位置(说明确实被打乱了)
seen
:=
map
[
int64
]
bool
{}
for
i
:=
0
;
i
<
100
;
i
++
{
cpy
:=
make
([]
accountWithLoad
,
len
(
accounts
))
copy
(
cpy
,
accounts
)
shuffleWithinSortGroups
(
cpy
)
seen
[
cpy
[
0
]
.
account
.
ID
]
=
true
// 无论怎么打乱,所有 ID 都应在候选中
ids
:=
map
[
int64
]
bool
{}
for
_
,
a
:=
range
cpy
{
ids
[
a
.
account
.
ID
]
=
true
}
require
.
True
(
t
,
ids
[
1
]
&&
ids
[
2
]
&&
ids
[
3
])
}
// 至少 2 个不同的 ID 出现在首位(随机性验证)
require
.
GreaterOrEqual
(
t
,
len
(
seen
),
2
,
"shuffle should produce different orderings"
)
}
func
TestShuffleWithinSortGroups_NilLastUsedAt_SameGroup
(
t
*
testing
.
T
)
{
accounts
:=
[]
accountWithLoad
{
{
account
:
&
Account
{
ID
:
1
,
Priority
:
1
,
LastUsedAt
:
nil
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
0
}},
{
account
:
&
Account
{
ID
:
2
,
Priority
:
1
,
LastUsedAt
:
nil
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
0
}},
{
account
:
&
Account
{
ID
:
3
,
Priority
:
1
,
LastUsedAt
:
nil
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
0
}},
}
seen
:=
map
[
int64
]
bool
{}
for
i
:=
0
;
i
<
100
;
i
++
{
cpy
:=
make
([]
accountWithLoad
,
len
(
accounts
))
copy
(
cpy
,
accounts
)
shuffleWithinSortGroups
(
cpy
)
seen
[
cpy
[
0
]
.
account
.
ID
]
=
true
}
require
.
GreaterOrEqual
(
t
,
len
(
seen
),
2
,
"nil LastUsedAt accounts should be shuffled"
)
}
func
TestShuffleWithinSortGroups_MixedGroups
(
t
*
testing
.
T
)
{
now
:=
time
.
Now
()
earlier
:=
now
.
Add
(
-
1
*
time
.
Hour
)
sameAsNow
:=
time
.
Unix
(
now
.
Unix
(),
0
)
// 组1: Priority=1, LoadRate=10, LastUsedAt=earlier (ID 1) — 单元素组
// 组2: Priority=1, LoadRate=20, LastUsedAt=now (ID 2, 3) — 双元素组
// 组3: Priority=2, LoadRate=10, LastUsedAt=earlier (ID 4) — 单元素组
accounts
:=
[]
accountWithLoad
{
{
account
:
&
Account
{
ID
:
1
,
Priority
:
1
,
LastUsedAt
:
&
earlier
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
10
}},
{
account
:
&
Account
{
ID
:
2
,
Priority
:
1
,
LastUsedAt
:
&
now
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
20
}},
{
account
:
&
Account
{
ID
:
3
,
Priority
:
1
,
LastUsedAt
:
&
sameAsNow
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
20
}},
{
account
:
&
Account
{
ID
:
4
,
Priority
:
2
,
LastUsedAt
:
&
earlier
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
10
}},
}
for
i
:=
0
;
i
<
20
;
i
++
{
cpy
:=
make
([]
accountWithLoad
,
len
(
accounts
))
copy
(
cpy
,
accounts
)
shuffleWithinSortGroups
(
cpy
)
// 组间顺序不变
require
.
Equal
(
t
,
int64
(
1
),
cpy
[
0
]
.
account
.
ID
,
"group 1 position fixed"
)
require
.
Equal
(
t
,
int64
(
4
),
cpy
[
3
]
.
account
.
ID
,
"group 3 position fixed"
)
// 组2 内部可以打乱,但仍在位置 1 和 2
mid
:=
map
[
int64
]
bool
{
cpy
[
1
]
.
account
.
ID
:
true
,
cpy
[
2
]
.
account
.
ID
:
true
}
require
.
True
(
t
,
mid
[
2
]
&&
mid
[
3
],
"group 2 elements should stay in positions 1-2"
)
}
}
// ============ shuffleWithinPriorityAndLastUsed 测试 ============
func
TestShuffleWithinPriorityAndLastUsed_Empty
(
t
*
testing
.
T
)
{
shuffleWithinPriorityAndLastUsed
(
nil
)
shuffleWithinPriorityAndLastUsed
([]
*
Account
{})
}
func
TestShuffleWithinPriorityAndLastUsed_SingleElement
(
t
*
testing
.
T
)
{
accounts
:=
[]
*
Account
{{
ID
:
1
,
Priority
:
1
}}
shuffleWithinPriorityAndLastUsed
(
accounts
)
require
.
Equal
(
t
,
int64
(
1
),
accounts
[
0
]
.
ID
)
}
func
TestShuffleWithinPriorityAndLastUsed_SameGroup_Shuffled
(
t
*
testing
.
T
)
{
accounts
:=
[]
*
Account
{
{
ID
:
1
,
Priority
:
1
,
LastUsedAt
:
nil
},
{
ID
:
2
,
Priority
:
1
,
LastUsedAt
:
nil
},
{
ID
:
3
,
Priority
:
1
,
LastUsedAt
:
nil
},
}
seen
:=
map
[
int64
]
bool
{}
for
i
:=
0
;
i
<
100
;
i
++
{
cpy
:=
make
([]
*
Account
,
len
(
accounts
))
copy
(
cpy
,
accounts
)
shuffleWithinPriorityAndLastUsed
(
cpy
)
seen
[
cpy
[
0
]
.
ID
]
=
true
}
require
.
GreaterOrEqual
(
t
,
len
(
seen
),
2
,
"same group should be shuffled"
)
}
func
TestShuffleWithinPriorityAndLastUsed_DifferentPriority_OrderPreserved
(
t
*
testing
.
T
)
{
accounts
:=
[]
*
Account
{
{
ID
:
1
,
Priority
:
1
,
LastUsedAt
:
nil
},
{
ID
:
2
,
Priority
:
2
,
LastUsedAt
:
nil
},
{
ID
:
3
,
Priority
:
3
,
LastUsedAt
:
nil
},
}
for
i
:=
0
;
i
<
20
;
i
++
{
cpy
:=
make
([]
*
Account
,
len
(
accounts
))
copy
(
cpy
,
accounts
)
shuffleWithinPriorityAndLastUsed
(
cpy
)
require
.
Equal
(
t
,
int64
(
1
),
cpy
[
0
]
.
ID
)
require
.
Equal
(
t
,
int64
(
2
),
cpy
[
1
]
.
ID
)
require
.
Equal
(
t
,
int64
(
3
),
cpy
[
2
]
.
ID
)
}
}
func
TestShuffleWithinPriorityAndLastUsed_DifferentLastUsedAt_OrderPreserved
(
t
*
testing
.
T
)
{
now
:=
time
.
Now
()
earlier
:=
now
.
Add
(
-
1
*
time
.
Hour
)
accounts
:=
[]
*
Account
{
{
ID
:
1
,
Priority
:
1
,
LastUsedAt
:
nil
},
{
ID
:
2
,
Priority
:
1
,
LastUsedAt
:
&
earlier
},
{
ID
:
3
,
Priority
:
1
,
LastUsedAt
:
&
now
},
}
for
i
:=
0
;
i
<
20
;
i
++
{
cpy
:=
make
([]
*
Account
,
len
(
accounts
))
copy
(
cpy
,
accounts
)
shuffleWithinPriorityAndLastUsed
(
cpy
)
require
.
Equal
(
t
,
int64
(
1
),
cpy
[
0
]
.
ID
)
require
.
Equal
(
t
,
int64
(
2
),
cpy
[
1
]
.
ID
)
require
.
Equal
(
t
,
int64
(
3
),
cpy
[
2
]
.
ID
)
}
}
// ============ sameLastUsedAt 测试 ============
func
TestSameLastUsedAt
(
t
*
testing
.
T
)
{
now
:=
time
.
Now
()
sameSecond
:=
time
.
Unix
(
now
.
Unix
(),
0
)
sameSecondDiffNano
:=
time
.
Unix
(
now
.
Unix
(),
999
_999_999
)
differentSecond
:=
now
.
Add
(
1
*
time
.
Second
)
t
.
Run
(
"both nil"
,
func
(
t
*
testing
.
T
)
{
require
.
True
(
t
,
sameLastUsedAt
(
nil
,
nil
))
})
t
.
Run
(
"one nil one not"
,
func
(
t
*
testing
.
T
)
{
require
.
False
(
t
,
sameLastUsedAt
(
nil
,
&
now
))
require
.
False
(
t
,
sameLastUsedAt
(
&
now
,
nil
))
})
t
.
Run
(
"same second different nanoseconds"
,
func
(
t
*
testing
.
T
)
{
require
.
True
(
t
,
sameLastUsedAt
(
&
sameSecond
,
&
sameSecondDiffNano
))
})
t
.
Run
(
"different seconds"
,
func
(
t
*
testing
.
T
)
{
require
.
False
(
t
,
sameLastUsedAt
(
&
now
,
&
differentSecond
))
})
t
.
Run
(
"exact same time"
,
func
(
t
*
testing
.
T
)
{
require
.
True
(
t
,
sameLastUsedAt
(
&
now
,
&
now
))
})
}
// ============ sameAccountWithLoadGroup 测试 ============
func
TestSameAccountWithLoadGroup
(
t
*
testing
.
T
)
{
now
:=
time
.
Now
()
sameSecond
:=
time
.
Unix
(
now
.
Unix
(),
0
)
t
.
Run
(
"same group"
,
func
(
t
*
testing
.
T
)
{
a
:=
accountWithLoad
{
account
:
&
Account
{
Priority
:
1
,
LastUsedAt
:
&
now
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
10
}}
b
:=
accountWithLoad
{
account
:
&
Account
{
Priority
:
1
,
LastUsedAt
:
&
sameSecond
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
10
}}
require
.
True
(
t
,
sameAccountWithLoadGroup
(
a
,
b
))
})
t
.
Run
(
"different priority"
,
func
(
t
*
testing
.
T
)
{
a
:=
accountWithLoad
{
account
:
&
Account
{
Priority
:
1
,
LastUsedAt
:
&
now
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
10
}}
b
:=
accountWithLoad
{
account
:
&
Account
{
Priority
:
2
,
LastUsedAt
:
&
now
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
10
}}
require
.
False
(
t
,
sameAccountWithLoadGroup
(
a
,
b
))
})
t
.
Run
(
"different load rate"
,
func
(
t
*
testing
.
T
)
{
a
:=
accountWithLoad
{
account
:
&
Account
{
Priority
:
1
,
LastUsedAt
:
&
now
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
10
}}
b
:=
accountWithLoad
{
account
:
&
Account
{
Priority
:
1
,
LastUsedAt
:
&
now
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
20
}}
require
.
False
(
t
,
sameAccountWithLoadGroup
(
a
,
b
))
})
t
.
Run
(
"different last used at"
,
func
(
t
*
testing
.
T
)
{
later
:=
now
.
Add
(
1
*
time
.
Second
)
a
:=
accountWithLoad
{
account
:
&
Account
{
Priority
:
1
,
LastUsedAt
:
&
now
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
10
}}
b
:=
accountWithLoad
{
account
:
&
Account
{
Priority
:
1
,
LastUsedAt
:
&
later
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
10
}}
require
.
False
(
t
,
sameAccountWithLoadGroup
(
a
,
b
))
})
t
.
Run
(
"both nil LastUsedAt"
,
func
(
t
*
testing
.
T
)
{
a
:=
accountWithLoad
{
account
:
&
Account
{
Priority
:
1
,
LastUsedAt
:
nil
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
0
}}
b
:=
accountWithLoad
{
account
:
&
Account
{
Priority
:
1
,
LastUsedAt
:
nil
},
loadInfo
:
&
AccountLoadInfo
{
LoadRate
:
0
}}
require
.
True
(
t
,
sameAccountWithLoadGroup
(
a
,
b
))
})
}
// ============ sameAccountGroup 测试 ============
func
TestSameAccountGroup
(
t
*
testing
.
T
)
{
now
:=
time
.
Now
()
t
.
Run
(
"same group"
,
func
(
t
*
testing
.
T
)
{
a
:=
&
Account
{
Priority
:
1
,
LastUsedAt
:
nil
}
b
:=
&
Account
{
Priority
:
1
,
LastUsedAt
:
nil
}
require
.
True
(
t
,
sameAccountGroup
(
a
,
b
))
})
t
.
Run
(
"different priority"
,
func
(
t
*
testing
.
T
)
{
a
:=
&
Account
{
Priority
:
1
,
LastUsedAt
:
nil
}
b
:=
&
Account
{
Priority
:
2
,
LastUsedAt
:
nil
}
require
.
False
(
t
,
sameAccountGroup
(
a
,
b
))
})
t
.
Run
(
"different LastUsedAt"
,
func
(
t
*
testing
.
T
)
{
later
:=
now
.
Add
(
1
*
time
.
Second
)
a
:=
&
Account
{
Priority
:
1
,
LastUsedAt
:
&
now
}
b
:=
&
Account
{
Priority
:
1
,
LastUsedAt
:
&
later
}
require
.
False
(
t
,
sameAccountGroup
(
a
,
b
))
})
}
// ============ sortAccountsByPriorityAndLastUsed 集成随机化测试 ============
func
TestSortAccountsByPriorityAndLastUsed_WithShuffle
(
t
*
testing
.
T
)
{
t
.
Run
(
"same priority and nil LastUsedAt are shuffled"
,
func
(
t
*
testing
.
T
)
{
accounts
:=
[]
*
Account
{
{
ID
:
1
,
Priority
:
1
,
LastUsedAt
:
nil
},
{
ID
:
2
,
Priority
:
1
,
LastUsedAt
:
nil
},
{
ID
:
3
,
Priority
:
1
,
LastUsedAt
:
nil
},
}
seen
:=
map
[
int64
]
bool
{}
for
i
:=
0
;
i
<
100
;
i
++
{
cpy
:=
make
([]
*
Account
,
len
(
accounts
))
copy
(
cpy
,
accounts
)
sortAccountsByPriorityAndLastUsed
(
cpy
,
false
)
seen
[
cpy
[
0
]
.
ID
]
=
true
}
require
.
GreaterOrEqual
(
t
,
len
(
seen
),
2
,
"identical sort keys should produce different orderings after shuffle"
)
})
t
.
Run
(
"different priorities still sorted correctly"
,
func
(
t
*
testing
.
T
)
{
now
:=
time
.
Now
()
accounts
:=
[]
*
Account
{
{
ID
:
3
,
Priority
:
3
,
LastUsedAt
:
&
now
},
{
ID
:
1
,
Priority
:
1
,
LastUsedAt
:
&
now
},
{
ID
:
2
,
Priority
:
2
,
LastUsedAt
:
&
now
},
}
sortAccountsByPriorityAndLastUsed
(
accounts
,
false
)
require
.
Equal
(
t
,
int64
(
1
),
accounts
[
0
]
.
ID
)
require
.
Equal
(
t
,
int64
(
2
),
accounts
[
1
]
.
ID
)
require
.
Equal
(
t
,
int64
(
3
),
accounts
[
2
]
.
ID
)
})
}
backend/internal/service/sticky_session_test.go
View file @
d367d1cd
...
...
@@ -23,8 +23,7 @@ import (
// - 临时不可调度且未过期:清理
// - 临时不可调度已过期:不清理
// - 正常可调度状态:不清理
// - 模型限流超过阈值:清理
// - 模型限流未超过阈值:不清理
// - 模型限流(任意时长):清理
//
// TestShouldClearStickySession tests the sticky session clearing logic.
// Verifies correct behavior for various account states including:
...
...
@@ -35,9 +34,9 @@ func TestShouldClearStickySession(t *testing.T) {
future
:=
now
.
Add
(
1
*
time
.
Hour
)
past
:=
now
.
Add
(
-
1
*
time
.
Hour
)
// 短限流时间(
低于阈值,不应
清除粘性会话)
// 短限流时间(
有限流即
清除粘性会话)
shortRateLimitReset
:=
now
.
Add
(
5
*
time
.
Second
)
.
Format
(
time
.
RFC3339
)
// 长限流时间(
超过阈值,应
清除粘性会话)
// 长限流时间(
有限流即
清除粘性会话)
longRateLimitReset
:=
now
.
Add
(
30
*
time
.
Second
)
.
Format
(
time
.
RFC3339
)
tests
:=
[]
struct
{
...
...
@@ -53,7 +52,7 @@ func TestShouldClearStickySession(t *testing.T) {
{
name
:
"temp unschedulable"
,
account
:
&
Account
{
Status
:
StatusActive
,
Schedulable
:
true
,
TempUnschedulableUntil
:
&
future
},
requestedModel
:
""
,
want
:
true
},
{
name
:
"temp unschedulable expired"
,
account
:
&
Account
{
Status
:
StatusActive
,
Schedulable
:
true
,
TempUnschedulableUntil
:
&
past
},
requestedModel
:
""
,
want
:
false
},
{
name
:
"active schedulable"
,
account
:
&
Account
{
Status
:
StatusActive
,
Schedulable
:
true
},
requestedModel
:
""
,
want
:
false
},
// 模型限流测试
// 模型限流测试
:有限流即清除
{
name
:
"model rate limited short duration"
,
account
:
&
Account
{
...
...
@@ -68,7 +67,7 @@ func TestShouldClearStickySession(t *testing.T) {
},
},
requestedModel
:
"claude-sonnet-4"
,
want
:
fals
e
,
//
低于阈值,不
清除
want
:
tru
e
,
//
有限流即
清除
},
{
name
:
"model rate limited long duration"
,
...
...
@@ -84,7 +83,7 @@ func TestShouldClearStickySession(t *testing.T) {
},
},
requestedModel
:
"claude-sonnet-4"
,
want
:
true
,
//
超过阈值,
清除
want
:
true
,
//
有限流即
清除
},
{
name
:
"model rate limited different model"
,
...
...
backend/internal/service/wire.go
View file @
d367d1cd
...
...
@@ -295,4 +295,5 @@ var ProviderSet = wire.NewSet(
NewUsageCache
,
NewTotpService
,
NewErrorPassthroughService
,
NewDigestSessionStore
,
)
backend/migrations/052_add_group_sort_order.sql
0 → 100644
View file @
d367d1cd
-- Add sort_order field to groups table for custom ordering
ALTER
TABLE
groups
ADD
COLUMN
IF
NOT
EXISTS
sort_order
INT
NOT
NULL
DEFAULT
0
;
-- Initialize existing groups with sort_order based on their ID
UPDATE
groups
SET
sort_order
=
id
WHERE
sort_order
=
0
;
-- Create index for efficient sorting
CREATE
INDEX
IF
NOT
EXISTS
idx_groups_sort_order
ON
groups
(
sort_order
);
backend/migrations/052_migrate_upstream_to_apikey.sql
0 → 100644
View file @
d367d1cd
-- Migrate upstream accounts to apikey type
-- Background: upstream type is no longer needed. Antigravity platform APIKey accounts
-- with base_url pointing to an upstream sub2api instance can reuse the standard
-- APIKey forwarding path. GetBaseURL()/GetGeminiBaseURL() automatically appends
-- /antigravity for Antigravity platform APIKey accounts.
UPDATE
accounts
SET
type
=
'apikey'
WHERE
type
=
'upstream'
AND
platform
=
'antigravity'
AND
deleted_at
IS
NULL
;
frontend/package.json
View file @
d367d1cd
...
...
@@ -27,6 +27,7 @@
"qrcode"
:
"^1.5.4"
,
"vue"
:
"^3.4.0"
,
"vue-chartjs"
:
"^5.3.0"
,
"vue-draggable-plus"
:
"^0.6.1"
,
"vue-i18n"
:
"^9.14.5"
,
"vue-router"
:
"^4.2.5"
,
"xlsx"
:
"^0.18.5"
...
...
Prev
1
2
3
4
5
6
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment