Commit 16131c3d authored by yangjianbo's avatar yangjianbo
Browse files
parents 836ba14b 7d66f7ff
...@@ -6,26 +6,11 @@ import ( ...@@ -6,26 +6,11 @@ import (
"encoding/json" "encoding/json"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/cespare/xxhash/v2" "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 字符) // shortHash 使用 XXHash64 + Base36 生成短 hash(16 字符)
// XXHash64 比 SHA256 快约 10 倍,Base36 比 Hex 短约 20% // XXHash64 比 SHA256 快约 10 倍,Base36 比 Hex 短约 20%
func shortHash(data []byte) string { func shortHash(data []byte) string {
...@@ -79,35 +64,6 @@ func GenerateGeminiPrefixHash(userID, apiKeyID int64, ip, userAgent, platform, m ...@@ -79,35 +64,6 @@ func GenerateGeminiPrefixHash(userID, apiKeyID int64, ip, userAgent, platform, m
return base64.RawURLEncoding.EncodeToString(hash[:12]) 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 会话缓存值 // ParseGeminiSessionValue 解析 Gemini 会话缓存值
// 格式: {uuid}:{accountID} // 格式: {uuid}:{accountID}
func ParseGeminiSessionValue(value string) (uuid string, accountID int64, ok bool) { func ParseGeminiSessionValue(value string) (uuid string, accountID int64, ok bool) {
...@@ -139,15 +95,6 @@ func FormatGeminiSessionValue(uuid string, accountID int64) string { ...@@ -139,15 +95,6 @@ func FormatGeminiSessionValue(uuid string, accountID int64) string {
// geminiDigestSessionKeyPrefix Gemini 摘要 fallback 会话 key 前缀 // geminiDigestSessionKeyPrefix Gemini 摘要 fallback 会话 key 前缀
const geminiDigestSessionKeyPrefix = "gemini:digest:" 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 // GenerateGeminiDigestSessionKey 生成 Gemini 摘要 fallback 的 sessionKey
// 组合 prefixHash 前 8 位 + uuid 前 8 位,确保不同会话产生不同的 sessionKey // 组合 prefixHash 前 8 位 + uuid 前 8 位,确保不同会话产生不同的 sessionKey
// 用于在 SelectAccountWithLoadAwareness 中保持粘性会话 // 用于在 SelectAccountWithLoadAwareness 中保持粘性会话
......
package service package service
import ( import (
"context"
"testing" "testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "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 测试连续会话的摘要链匹配 // TestGeminiSessionContinuousConversation 测试连续会话的摘要链匹配
func TestGeminiSessionContinuousConversation(t *testing.T) { func TestGeminiSessionContinuousConversation(t *testing.T) {
cache := newMockGeminiSessionCache() store := NewDigestSessionStore()
groupID := int64(1) groupID := int64(1)
prefixHash := "test_prefix_hash" prefixHash := "test_prefix_hash"
sessionUUID := "session-uuid-12345" sessionUUID := "session-uuid-12345"
...@@ -54,13 +27,13 @@ func TestGeminiSessionContinuousConversation(t *testing.T) { ...@@ -54,13 +27,13 @@ func TestGeminiSessionContinuousConversation(t *testing.T) {
t.Logf("Round 1 chain: %s", chain1) t.Logf("Round 1 chain: %s", chain1)
// 第一轮:没有找到会话,创建新会话 // 第一轮:没有找到会话,创建新会话
_, _, found := cache.Find(groupID, prefixHash, chain1) _, _, _, found := store.Find(groupID, prefixHash, chain1)
if found { if found {
t.Error("Round 1: should not find existing session") t.Error("Round 1: should not find existing session")
} }
// 保存第一轮会话 // 保存第一轮会话(首轮无旧 chain)
cache.Save(groupID, prefixHash, chain1, sessionUUID, accountID) store.Save(groupID, prefixHash, chain1, sessionUUID, accountID, "")
// 模拟第二轮对话(用户继续对话) // 模拟第二轮对话(用户继续对话)
req2 := &antigravity.GeminiRequest{ req2 := &antigravity.GeminiRequest{
...@@ -77,7 +50,7 @@ func TestGeminiSessionContinuousConversation(t *testing.T) { ...@@ -77,7 +50,7 @@ func TestGeminiSessionContinuousConversation(t *testing.T) {
t.Logf("Round 2 chain: %s", chain2) t.Logf("Round 2 chain: %s", chain2)
// 第二轮:应该能找到会话(通过前缀匹配) // 第二轮:应该能找到会话(通过前缀匹配)
foundUUID, foundAccID, found := cache.Find(groupID, prefixHash, chain2) foundUUID, foundAccID, matchedChain, found := store.Find(groupID, prefixHash, chain2)
if !found { if !found {
t.Error("Round 2: should find session via prefix matching") t.Error("Round 2: should find session via prefix matching")
} }
...@@ -88,8 +61,8 @@ func TestGeminiSessionContinuousConversation(t *testing.T) { ...@@ -88,8 +61,8 @@ func TestGeminiSessionContinuousConversation(t *testing.T) {
t.Errorf("Round 2: expected accountID %d, got %d", accountID, foundAccID) t.Errorf("Round 2: expected accountID %d, got %d", accountID, foundAccID)
} }
// 保存第二轮会话 // 保存第二轮会话,传入 Find 返回的 matchedChain 以删旧 key
cache.Save(groupID, prefixHash, chain2, sessionUUID, accountID) store.Save(groupID, prefixHash, chain2, sessionUUID, accountID, matchedChain)
// 模拟第三轮对话 // 模拟第三轮对话
req3 := &antigravity.GeminiRequest{ req3 := &antigravity.GeminiRequest{
...@@ -108,7 +81,7 @@ func TestGeminiSessionContinuousConversation(t *testing.T) { ...@@ -108,7 +81,7 @@ func TestGeminiSessionContinuousConversation(t *testing.T) {
t.Logf("Round 3 chain: %s", chain3) t.Logf("Round 3 chain: %s", chain3)
// 第三轮:应该能找到会话(通过第二轮的前缀匹配) // 第三轮:应该能找到会话(通过第二轮的前缀匹配)
foundUUID, foundAccID, found = cache.Find(groupID, prefixHash, chain3) foundUUID, foundAccID, _, found = store.Find(groupID, prefixHash, chain3)
if !found { if !found {
t.Error("Round 3: should find session via prefix matching") t.Error("Round 3: should find session via prefix matching")
} }
...@@ -118,13 +91,11 @@ func TestGeminiSessionContinuousConversation(t *testing.T) { ...@@ -118,13 +91,11 @@ func TestGeminiSessionContinuousConversation(t *testing.T) {
if foundAccID != accountID { if foundAccID != accountID {
t.Errorf("Round 3: expected accountID %d, got %d", accountID, foundAccID) t.Errorf("Round 3: expected accountID %d, got %d", accountID, foundAccID)
} }
t.Log("✓ Continuous conversation session matching works correctly!")
} }
// TestGeminiSessionDifferentConversations 测试不同会话不会错误匹配 // TestGeminiSessionDifferentConversations 测试不同会话不会错误匹配
func TestGeminiSessionDifferentConversations(t *testing.T) { func TestGeminiSessionDifferentConversations(t *testing.T) {
cache := newMockGeminiSessionCache() store := NewDigestSessionStore()
groupID := int64(1) groupID := int64(1)
prefixHash := "test_prefix_hash" prefixHash := "test_prefix_hash"
...@@ -135,7 +106,7 @@ func TestGeminiSessionDifferentConversations(t *testing.T) { ...@@ -135,7 +106,7 @@ func TestGeminiSessionDifferentConversations(t *testing.T) {
}, },
} }
chain1 := BuildGeminiDigestChain(req1) chain1 := BuildGeminiDigestChain(req1)
cache.Save(groupID, prefixHash, chain1, "session-1", 100) store.Save(groupID, prefixHash, chain1, "session-1", 100, "")
// 第二个完全不同的会话 // 第二个完全不同的会话
req2 := &antigravity.GeminiRequest{ req2 := &antigravity.GeminiRequest{
...@@ -146,61 +117,29 @@ func TestGeminiSessionDifferentConversations(t *testing.T) { ...@@ -146,61 +117,29 @@ func TestGeminiSessionDifferentConversations(t *testing.T) {
chain2 := BuildGeminiDigestChain(req2) chain2 := BuildGeminiDigestChain(req2)
// 不同会话不应该匹配 // 不同会话不应该匹配
_, _, found := cache.Find(groupID, prefixHash, chain2) _, _, _, found := store.Find(groupID, prefixHash, chain2)
if found { if found {
t.Error("Different conversations should not match") t.Error("Different conversations should not match")
} }
t.Log("✓ Different conversations are correctly isolated!")
} }
// TestGeminiSessionPrefixMatchingOrder 测试前缀匹配的优先级(最长匹配优先) // TestGeminiSessionPrefixMatchingOrder 测试前缀匹配的优先级(最长匹配优先)
func TestGeminiSessionPrefixMatchingOrder(t *testing.T) { func TestGeminiSessionPrefixMatchingOrder(t *testing.T) {
cache := newMockGeminiSessionCache() store := NewDigestSessionStore()
groupID := int64(1) groupID := int64(1)
prefixHash := "test_prefix_hash" 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 store.Save(groupID, prefixHash, "s:sys-u:q1", "session-round1", 1, "")
cache.Save(groupID, prefixHash, prefixes[3], "session-round1", 1) store.Save(groupID, prefixHash, "s:sys-u:q1-m:a1", "session-round2", 2, "")
// 第二轮 -> 账号 2 store.Save(groupID, prefixHash, "s:sys-u:q1-m:a1-u:q2", "session-round3", 3, "")
cache.Save(groupID, prefixHash, prefixes[2], "session-round2", 2)
// 第三轮(最长前缀,完整链)-> 账号 3 // 查找更长的链,应该返回最长匹配(账号 3)
cache.Save(groupID, prefixHash, prefixes[0], "session-round3", 3) _, accID, _, found := store.Find(groupID, prefixHash, "s:sys-u:q1-m:a1-u:q2-m:a2")
// 查找应该返回最长匹配(账号 3)
_, accID, found := cache.Find(groupID, prefixHash, fullChain)
if !found { if !found {
t.Error("Should find session") t.Error("Should find session")
} }
if accID != 3 { if accID != 3 {
t.Errorf("Should match longest prefix (account 3), got account %d", accID) t.Errorf("Should match longest prefix (account 3), got account %d", accID)
} }
t.Log("✓ Longest prefix matching works correctly!")
} }
// 确保 context 包被使用(避免未使用的导入警告)
var _ = context.Background
...@@ -152,61 +152,6 @@ func TestGenerateGeminiPrefixHash(t *testing.T) { ...@@ -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) { func TestParseGeminiSessionValue(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
...@@ -442,40 +387,3 @@ func TestGenerateGeminiDigestSessionKey(t *testing.T) { ...@@ -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)
}
})
}
}
//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")
}
...@@ -45,6 +45,9 @@ type Group struct { ...@@ -45,6 +45,9 @@ type Group struct {
// 可选值: claude, gemini_text, gemini_image // 可选值: claude, gemini_text, gemini_image
SupportedModelScopes []string SupportedModelScopes []string
// 分组排序
SortOrder int
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
......
...@@ -33,6 +33,14 @@ type GroupRepository interface { ...@@ -33,6 +33,14 @@ type GroupRepository interface {
GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error) GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error)
// BindAccountsToGroup 将多个账号绑定到指定分组 // BindAccountsToGroup 将多个账号绑定到指定分组
BindAccountsToGroup(ctx context.Context, groupID int64, accountIDs []int64) error 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 创建分组请求 // CreateGroupRequest 创建分组请求
......
...@@ -318,110 +318,6 @@ func TestGetModelRateLimitRemainingTime(t *testing.T) { ...@@ -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) { func TestGetRateLimitRemainingTime(t *testing.T) {
now := time.Now() now := time.Now()
future15m := now.Add(15 * time.Minute).Format(time.RFC3339) future15m := now.Add(15 * time.Minute).Format(time.RFC3339)
...@@ -442,45 +338,19 @@ func TestGetRateLimitRemainingTime(t *testing.T) { ...@@ -442,45 +338,19 @@ func TestGetRateLimitRemainingTime(t *testing.T) {
maxExpected: 0, maxExpected: 0,
}, },
{ {
name: "model remaining > scope remaining - returns model", 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": 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",
account: &Account{ account: &Account{
Platform: PlatformAntigravity, Platform: PlatformAntigravity,
Extra: map[string]any{ Extra: map[string]any{
modelRateLimitsKey: map[string]any{ modelRateLimitsKey: map[string]any{
"claude-sonnet-4-5": map[string]any{ "claude-sonnet-4-5": map[string]any{
"rate_limit_reset_at": future5m, // 5 分钟 "rate_limit_reset_at": future15m,
},
},
antigravityQuotaScopesKey: map[string]any{
"claude": map[string]any{
"rate_limit_reset_at": future15m, // 15 分钟
}, },
}, },
}, },
}, },
requestedModel: "claude-sonnet-4-5", requestedModel: "claude-sonnet-4-5",
minExpected: 14 * time.Minute, // 应返回较大的 15 分钟 minExpected: 14 * time.Minute,
maxExpected: 16 * time.Minute, maxExpected: 16 * time.Minute,
}, },
{ {
...@@ -499,22 +369,6 @@ func TestGetRateLimitRemainingTime(t *testing.T) { ...@@ -499,22 +369,6 @@ func TestGetRateLimitRemainingTime(t *testing.T) {
minExpected: 4 * time.Minute, minExpected: 4 * time.Minute,
maxExpected: 6 * 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", name: "neither rate limited",
account: &Account{ account: &Account{
......
...@@ -582,10 +582,6 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex ...@@ -582,10 +582,6 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex
} }
} }
} else { } else {
type accountWithLoad struct {
account *Account
loadInfo *AccountLoadInfo
}
var available []accountWithLoad var available []accountWithLoad
for _, acc := range candidates { for _, acc := range candidates {
loadInfo := loadMap[acc.ID] loadInfo := loadMap[acc.ID]
...@@ -620,6 +616,7 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex ...@@ -620,6 +616,7 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex
return a.account.LastUsedAt.Before(*b.account.LastUsedAt) return a.account.LastUsedAt.Before(*b.account.LastUsedAt)
} }
}) })
shuffleWithinSortGroups(available)
for _, item := range available { for _, item := range available {
result, err := s.tryAcquireAccountSlot(ctx, item.account.ID, item.account.Concurrency) result, err := s.tryAcquireAccountSlot(ctx, item.account.ID, item.account.Concurrency)
......
...@@ -205,22 +205,6 @@ func (c *stubGatewayCache) DeleteSessionAccountID(ctx context.Context, groupID i ...@@ -205,22 +205,6 @@ func (c *stubGatewayCache) DeleteSessionAccountID(ctx context.Context, groupID i
return nil 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) { func TestOpenAISelectAccountWithLoadAwareness_FiltersUnschedulable(t *testing.T) {
now := time.Now() now := time.Now()
resetAt := now.Add(10 * time.Minute) resetAt := now.Add(10 * time.Minute)
......
...@@ -66,7 +66,6 @@ func (s *OpsService) GetAccountAvailabilityStats(ctx context.Context, platformFi ...@@ -66,7 +66,6 @@ func (s *OpsService) GetAccountAvailabilityStats(ctx context.Context, platformFi
} }
isAvailable := acc.Status == StatusActive && acc.Schedulable && !isRateLimited && !isOverloaded && !isTempUnsched isAvailable := acc.Status == StatusActive && acc.Schedulable && !isRateLimited && !isOverloaded && !isTempUnsched
scopeRateLimits := acc.GetAntigravityScopeRateLimits()
if acc.Platform != "" { if acc.Platform != "" {
if _, ok := platform[acc.Platform]; !ok { if _, ok := platform[acc.Platform]; !ok {
...@@ -85,14 +84,6 @@ func (s *OpsService) GetAccountAvailabilityStats(ctx context.Context, platformFi ...@@ -85,14 +84,6 @@ func (s *OpsService) GetAccountAvailabilityStats(ctx context.Context, platformFi
if hasError { if hasError {
p.ErrorCount++ 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 { for _, grp := range acc.Groups {
...@@ -117,14 +108,6 @@ func (s *OpsService) GetAccountAvailabilityStats(ctx context.Context, platformFi ...@@ -117,14 +108,6 @@ func (s *OpsService) GetAccountAvailabilityStats(ctx context.Context, platformFi
if hasError { if hasError {
g.ErrorCount++ 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) displayGroupID := int64(0)
...@@ -157,9 +140,6 @@ func (s *OpsService) GetAccountAvailabilityStats(ctx context.Context, platformFi ...@@ -157,9 +140,6 @@ func (s *OpsService) GetAccountAvailabilityStats(ctx context.Context, platformFi
item.RateLimitRemainingSec = &remainingSec item.RateLimitRemainingSec = &remainingSec
} }
} }
if len(scopeRateLimits) > 0 {
item.ScopeRateLimits = scopeRateLimits
}
if isOverloaded && acc.OverloadUntil != nil { if isOverloaded && acc.OverloadUntil != nil {
item.OverloadUntil = acc.OverloadUntil item.OverloadUntil = acc.OverloadUntil
remainingSec := int64(time.Until(*acc.OverloadUntil).Seconds()) remainingSec := int64(time.Until(*acc.OverloadUntil).Seconds())
......
...@@ -50,24 +50,22 @@ type UserConcurrencyInfo struct { ...@@ -50,24 +50,22 @@ type UserConcurrencyInfo struct {
// PlatformAvailability aggregates account availability by platform. // PlatformAvailability aggregates account availability by platform.
type PlatformAvailability struct { type PlatformAvailability struct {
Platform string `json:"platform"` Platform string `json:"platform"`
TotalAccounts int64 `json:"total_accounts"` TotalAccounts int64 `json:"total_accounts"`
AvailableCount int64 `json:"available_count"` AvailableCount int64 `json:"available_count"`
RateLimitCount int64 `json:"rate_limit_count"` RateLimitCount int64 `json:"rate_limit_count"`
ScopeRateLimitCount map[string]int64 `json:"scope_rate_limit_count,omitempty"` ErrorCount int64 `json:"error_count"`
ErrorCount int64 `json:"error_count"`
} }
// GroupAvailability aggregates account availability by group. // GroupAvailability aggregates account availability by group.
type GroupAvailability struct { type GroupAvailability struct {
GroupID int64 `json:"group_id"` GroupID int64 `json:"group_id"`
GroupName string `json:"group_name"` GroupName string `json:"group_name"`
Platform string `json:"platform"` Platform string `json:"platform"`
TotalAccounts int64 `json:"total_accounts"` TotalAccounts int64 `json:"total_accounts"`
AvailableCount int64 `json:"available_count"` AvailableCount int64 `json:"available_count"`
RateLimitCount int64 `json:"rate_limit_count"` RateLimitCount int64 `json:"rate_limit_count"`
ScopeRateLimitCount map[string]int64 `json:"scope_rate_limit_count,omitempty"` ErrorCount int64 `json:"error_count"`
ErrorCount int64 `json:"error_count"`
} }
// AccountAvailability represents current availability for a single account. // AccountAvailability represents current availability for a single account.
...@@ -85,11 +83,10 @@ type AccountAvailability struct { ...@@ -85,11 +83,10 @@ type AccountAvailability struct {
IsOverloaded bool `json:"is_overloaded"` IsOverloaded bool `json:"is_overloaded"`
HasError bool `json:"has_error"` HasError bool `json:"has_error"`
RateLimitResetAt *time.Time `json:"rate_limit_reset_at"` RateLimitResetAt *time.Time `json:"rate_limit_reset_at"`
RateLimitRemainingSec *int64 `json:"rate_limit_remaining_sec"` RateLimitRemainingSec *int64 `json:"rate_limit_remaining_sec"`
ScopeRateLimits map[string]int64 `json:"scope_rate_limits,omitempty"` OverloadUntil *time.Time `json:"overload_until"`
OverloadUntil *time.Time `json:"overload_until"` OverloadRemainingSec *int64 `json:"overload_remaining_sec"`
OverloadRemainingSec *int64 `json:"overload_remaining_sec"` ErrorMessage string `json:"error_message"`
ErrorMessage string `json:"error_message"` TempUnschedulableUntil *time.Time `json:"temp_unschedulable_until,omitempty"`
TempUnschedulableUntil *time.Time `json:"temp_unschedulable_until,omitempty"`
} }
...@@ -12,6 +12,7 @@ import ( ...@@ -12,6 +12,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
...@@ -528,7 +529,7 @@ func (s *OpsService) selectAccountForRetry(ctx context.Context, reqType opsRetry ...@@ -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) { func extractRetryModelAndStream(reqType opsRetryRequestType, errorLog *OpsErrorLogDetail, body []byte) (model string, stream bool, err error) {
switch reqType { switch reqType {
case opsRetryTypeMessages: case opsRetryTypeMessages:
parsed, parseErr := ParseGatewayRequest(body) parsed, parseErr := ParseGatewayRequest(body, domain.PlatformAnthropic)
if parseErr != nil { if parseErr != nil {
return "", false, fmt.Errorf("failed to parse messages request body: %w", parseErr) 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 ...@@ -596,7 +597,7 @@ func (s *OpsService) executeWithAccount(ctx context.Context, reqType opsRetryReq
if s.gatewayService == nil { if s.gatewayService == nil {
return &opsRetryExecution{status: opsRetryStatusFailed, errorMessage: "gateway service not available"} return &opsRetryExecution{status: opsRetryStatusFailed, errorMessage: "gateway service not available"}
} }
parsedReq, parseErr := ParseGatewayRequest(body) parsedReq, parseErr := ParseGatewayRequest(body, domain.PlatformAnthropic)
if parseErr != nil { if parseErr != nil {
return &opsRetryExecution{status: opsRetryStatusFailed, errorMessage: "failed to parse request body"} return &opsRetryExecution{status: opsRetryStatusFailed, errorMessage: "failed to parse request body"}
} }
......
...@@ -62,6 +62,32 @@ func (s *RateLimitService) SetTokenCacheInvalidator(invalidator TokenCacheInvali ...@@ -62,6 +62,32 @@ func (s *RateLimitService) SetTokenCacheInvalidator(invalidator TokenCacheInvali
s.tokenCacheInvalidator = invalidator 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 处理上游错误响应,标记账号状态 // HandleUpstreamError 处理上游错误响应,标记账号状态
// 返回是否应该停止该账号的调度 // 返回是否应该停止该账号的调度
func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, responseBody []byte) (shouldDisable bool) { func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, responseBody []byte) (shouldDisable bool) {
......
//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)
})
}
...@@ -23,8 +23,7 @@ import ( ...@@ -23,8 +23,7 @@ import (
// - 临时不可调度且未过期:清理 // - 临时不可调度且未过期:清理
// - 临时不可调度已过期:不清理 // - 临时不可调度已过期:不清理
// - 正常可调度状态:不清理 // - 正常可调度状态:不清理
// - 模型限流超过阈值:清理 // - 模型限流(任意时长):清理
// - 模型限流未超过阈值:不清理
// //
// TestShouldClearStickySession tests the sticky session clearing logic. // TestShouldClearStickySession tests the sticky session clearing logic.
// Verifies correct behavior for various account states including: // Verifies correct behavior for various account states including:
...@@ -35,9 +34,9 @@ func TestShouldClearStickySession(t *testing.T) { ...@@ -35,9 +34,9 @@ func TestShouldClearStickySession(t *testing.T) {
future := now.Add(1 * time.Hour) future := now.Add(1 * time.Hour)
past := now.Add(-1 * time.Hour) past := now.Add(-1 * time.Hour)
// 短限流时间(低于阈值,不应清除粘性会话) // 短限流时间(有限流即清除粘性会话)
shortRateLimitReset := now.Add(5 * time.Second).Format(time.RFC3339) shortRateLimitReset := now.Add(5 * time.Second).Format(time.RFC3339)
// 长限流时间(超过阈值,应清除粘性会话) // 长限流时间(有限流即清除粘性会话)
longRateLimitReset := now.Add(30 * time.Second).Format(time.RFC3339) longRateLimitReset := now.Add(30 * time.Second).Format(time.RFC3339)
tests := []struct { tests := []struct {
...@@ -53,7 +52,7 @@ func TestShouldClearStickySession(t *testing.T) { ...@@ -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", 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: "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: "active schedulable", account: &Account{Status: StatusActive, Schedulable: true}, requestedModel: "", want: false},
// 模型限流测试 // 模型限流测试:有限流即清除
{ {
name: "model rate limited short duration", name: "model rate limited short duration",
account: &Account{ account: &Account{
...@@ -68,7 +67,7 @@ func TestShouldClearStickySession(t *testing.T) { ...@@ -68,7 +67,7 @@ func TestShouldClearStickySession(t *testing.T) {
}, },
}, },
requestedModel: "claude-sonnet-4", requestedModel: "claude-sonnet-4",
want: false, // 低于阈值,不清除 want: true, // 有限流即清除
}, },
{ {
name: "model rate limited long duration", name: "model rate limited long duration",
...@@ -84,7 +83,7 @@ func TestShouldClearStickySession(t *testing.T) { ...@@ -84,7 +83,7 @@ func TestShouldClearStickySession(t *testing.T) {
}, },
}, },
requestedModel: "claude-sonnet-4", requestedModel: "claude-sonnet-4",
want: true, // 超过阈值,清除 want: true, // 有限流即清除
}, },
{ {
name: "model rate limited different model", name: "model rate limited different model",
......
...@@ -275,4 +275,5 @@ var ProviderSet = wire.NewSet( ...@@ -275,4 +275,5 @@ var ProviderSet = wire.NewSet(
NewUsageCache, NewUsageCache,
NewTotpService, NewTotpService,
NewErrorPassthroughService, NewErrorPassthroughService,
NewDigestSessionStore,
) )
-- 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);
-- 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;
...@@ -27,6 +27,7 @@ ...@@ -27,6 +27,7 @@
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-chartjs": "^5.3.0", "vue-chartjs": "^5.3.0",
"vue-draggable-plus": "^0.6.1",
"vue-i18n": "^9.14.5", "vue-i18n": "^9.14.5",
"vue-router": "^4.2.5", "vue-router": "^4.2.5",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
......
...@@ -44,6 +44,9 @@ importers: ...@@ -44,6 +44,9 @@ importers:
vue-chartjs: vue-chartjs:
specifier: ^5.3.0 specifier: ^5.3.0
version: 5.3.3(chart.js@4.5.1)(vue@3.5.26(typescript@5.6.3)) version: 5.3.3(chart.js@4.5.1)(vue@3.5.26(typescript@5.6.3))
vue-draggable-plus:
specifier: ^0.6.1
version: 0.6.1(@types/sortablejs@1.15.9)
vue-i18n: vue-i18n:
specifier: ^9.14.5 specifier: ^9.14.5
version: 9.14.5(vue@3.5.26(typescript@5.6.3)) version: 9.14.5(vue@3.5.26(typescript@5.6.3))
...@@ -1254,67 +1257,56 @@ packages: ...@@ -1254,67 +1257,56 @@ packages:
resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.54.0': '@rollup/rollup-linux-arm-musleabihf@4.54.0':
resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.54.0': '@rollup/rollup-linux-arm64-gnu@4.54.0':
resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.54.0': '@rollup/rollup-linux-arm64-musl@4.54.0':
resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.54.0': '@rollup/rollup-linux-loong64-gnu@4.54.0':
resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.54.0': '@rollup/rollup-linux-ppc64-gnu@4.54.0':
resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.54.0': '@rollup/rollup-linux-riscv64-gnu@4.54.0':
resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.54.0': '@rollup/rollup-linux-riscv64-musl@4.54.0':
resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.54.0': '@rollup/rollup-linux-s390x-gnu@4.54.0':
resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.54.0': '@rollup/rollup-linux-x64-gnu@4.54.0':
resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.54.0': '@rollup/rollup-linux-x64-musl@4.54.0':
resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.54.0': '@rollup/rollup-openharmony-arm64@4.54.0':
resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==}
...@@ -1515,6 +1507,9 @@ packages: ...@@ -1515,6 +1507,9 @@ packages:
'@types/react@19.2.7': '@types/react@19.2.7':
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
'@types/sortablejs@1.15.9':
resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==}
'@types/trusted-types@2.0.7': '@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
...@@ -4298,6 +4293,15 @@ packages: ...@@ -4298,6 +4293,15 @@ packages:
'@vue/composition-api': '@vue/composition-api':
optional: true optional: true
vue-draggable-plus@0.6.1:
resolution: {integrity: sha512-FbtQ/fuoixiOfTZzG3yoPl4JAo9HJXRHmBQZFB9x2NYCh6pq0TomHf7g5MUmpaDYv+LU2n6BPq2YN9sBO+FbIg==}
peerDependencies:
'@types/sortablejs': ^1.15.0
'@vue/composition-api': '*'
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-eslint-parser@9.4.3: vue-eslint-parser@9.4.3:
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==} resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
engines: {node: ^14.17.0 || >=16.0.0} engines: {node: ^14.17.0 || >=16.0.0}
...@@ -5958,6 +5962,8 @@ snapshots: ...@@ -5958,6 +5962,8 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@types/sortablejs@1.15.9': {}
'@types/trusted-types@2.0.7': {} '@types/trusted-types@2.0.7': {}
'@types/unist@2.0.11': {} '@types/unist@2.0.11': {}
...@@ -9401,6 +9407,10 @@ snapshots: ...@@ -9401,6 +9407,10 @@ snapshots:
dependencies: dependencies:
vue: 3.5.26(typescript@5.6.3) vue: 3.5.26(typescript@5.6.3)
vue-draggable-plus@0.6.1(@types/sortablejs@1.15.9):
dependencies:
'@types/sortablejs': 1.15.9
vue-eslint-parser@9.4.3(eslint@8.57.1): vue-eslint-parser@9.4.3(eslint@8.57.1):
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment