Merge branch 'main' of https://github.com/james-6-23/sub2api
This commit is contained in:
@@ -3,11 +3,14 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"hash/fnv"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
)
|
||||
|
||||
@@ -50,6 +53,14 @@ type Account struct {
|
||||
AccountGroups []AccountGroup
|
||||
GroupIDs []int64
|
||||
Groups []*Group
|
||||
|
||||
// model_mapping 热路径缓存(非持久化字段)
|
||||
modelMappingCache map[string]string
|
||||
modelMappingCacheReady bool
|
||||
modelMappingCacheCredentialsPtr uintptr
|
||||
modelMappingCacheRawPtr uintptr
|
||||
modelMappingCacheRawLen int
|
||||
modelMappingCacheRawSig uint64
|
||||
}
|
||||
|
||||
type TempUnschedulableRule struct {
|
||||
@@ -349,6 +360,39 @@ func parseTempUnschedInt(value any) int {
|
||||
}
|
||||
|
||||
func (a *Account) GetModelMapping() map[string]string {
|
||||
credentialsPtr := mapPtr(a.Credentials)
|
||||
rawMapping, _ := a.Credentials["model_mapping"].(map[string]any)
|
||||
rawPtr := mapPtr(rawMapping)
|
||||
rawLen := len(rawMapping)
|
||||
rawSig := uint64(0)
|
||||
rawSigReady := false
|
||||
|
||||
if a.modelMappingCacheReady &&
|
||||
a.modelMappingCacheCredentialsPtr == credentialsPtr &&
|
||||
a.modelMappingCacheRawPtr == rawPtr &&
|
||||
a.modelMappingCacheRawLen == rawLen {
|
||||
rawSig = modelMappingSignature(rawMapping)
|
||||
rawSigReady = true
|
||||
if a.modelMappingCacheRawSig == rawSig {
|
||||
return a.modelMappingCache
|
||||
}
|
||||
}
|
||||
|
||||
mapping := a.resolveModelMapping(rawMapping)
|
||||
if !rawSigReady {
|
||||
rawSig = modelMappingSignature(rawMapping)
|
||||
}
|
||||
|
||||
a.modelMappingCache = mapping
|
||||
a.modelMappingCacheReady = true
|
||||
a.modelMappingCacheCredentialsPtr = credentialsPtr
|
||||
a.modelMappingCacheRawPtr = rawPtr
|
||||
a.modelMappingCacheRawLen = rawLen
|
||||
a.modelMappingCacheRawSig = rawSig
|
||||
return mapping
|
||||
}
|
||||
|
||||
func (a *Account) resolveModelMapping(rawMapping map[string]any) map[string]string {
|
||||
if a.Credentials == nil {
|
||||
// Antigravity 平台使用默认映射
|
||||
if a.Platform == domain.PlatformAntigravity {
|
||||
@@ -356,25 +400,31 @@ func (a *Account) GetModelMapping() map[string]string {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
raw, ok := a.Credentials["model_mapping"]
|
||||
if !ok || raw == nil {
|
||||
if len(rawMapping) == 0 {
|
||||
// Antigravity 平台使用默认映射
|
||||
if a.Platform == domain.PlatformAntigravity {
|
||||
return domain.DefaultAntigravityModelMapping
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if m, ok := raw.(map[string]any); ok {
|
||||
result := make(map[string]string)
|
||||
for k, v := range m {
|
||||
if s, ok := v.(string); ok {
|
||||
result[k] = s
|
||||
}
|
||||
}
|
||||
if len(result) > 0 {
|
||||
return result
|
||||
|
||||
result := make(map[string]string)
|
||||
for k, v := range rawMapping {
|
||||
if s, ok := v.(string); ok {
|
||||
result[k] = s
|
||||
}
|
||||
}
|
||||
if len(result) > 0 {
|
||||
if a.Platform == domain.PlatformAntigravity {
|
||||
ensureAntigravityDefaultPassthroughs(result, []string{
|
||||
"gemini-3-flash",
|
||||
"gemini-3.1-pro-high",
|
||||
"gemini-3.1-pro-low",
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Antigravity 平台使用默认映射
|
||||
if a.Platform == domain.PlatformAntigravity {
|
||||
return domain.DefaultAntigravityModelMapping
|
||||
@@ -382,6 +432,58 @@ func (a *Account) GetModelMapping() map[string]string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapPtr(m map[string]any) uintptr {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
return reflect.ValueOf(m).Pointer()
|
||||
}
|
||||
|
||||
func modelMappingSignature(rawMapping map[string]any) uint64 {
|
||||
if len(rawMapping) == 0 {
|
||||
return 0
|
||||
}
|
||||
keys := make([]string, 0, len(rawMapping))
|
||||
for k := range rawMapping {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
h := fnv.New64a()
|
||||
for _, k := range keys {
|
||||
_, _ = h.Write([]byte(k))
|
||||
_, _ = h.Write([]byte{0})
|
||||
if v, ok := rawMapping[k].(string); ok {
|
||||
_, _ = h.Write([]byte(v))
|
||||
} else {
|
||||
_, _ = h.Write([]byte{1})
|
||||
}
|
||||
_, _ = h.Write([]byte{0xff})
|
||||
}
|
||||
return h.Sum64()
|
||||
}
|
||||
|
||||
func ensureAntigravityDefaultPassthrough(mapping map[string]string, model string) {
|
||||
if mapping == nil || model == "" {
|
||||
return
|
||||
}
|
||||
if _, exists := mapping[model]; exists {
|
||||
return
|
||||
}
|
||||
for pattern := range mapping {
|
||||
if matchWildcard(pattern, model) {
|
||||
return
|
||||
}
|
||||
}
|
||||
mapping[model] = model
|
||||
}
|
||||
|
||||
func ensureAntigravityDefaultPassthroughs(mapping map[string]string, models []string) {
|
||||
for _, model := range models {
|
||||
ensureAntigravityDefaultPassthrough(mapping, model)
|
||||
}
|
||||
}
|
||||
|
||||
// IsModelSupported 检查模型是否在 model_mapping 中(支持通配符)
|
||||
// 如果未配置 mapping,返回 true(允许所有模型)
|
||||
func (a *Account) IsModelSupported(requestedModel string) bool {
|
||||
@@ -696,6 +798,204 @@ func (a *Account) IsMixedSchedulingEnabled() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsOpenAIPassthroughEnabled 返回 OpenAI 账号是否启用“自动透传(仅替换认证)”。
|
||||
//
|
||||
// 新字段:accounts.extra.openai_passthrough。
|
||||
// 兼容字段:accounts.extra.openai_oauth_passthrough(历史 OAuth 开关)。
|
||||
// 字段缺失或类型不正确时,按 false(关闭)处理。
|
||||
func (a *Account) IsOpenAIPassthroughEnabled() bool {
|
||||
if a == nil || !a.IsOpenAI() || a.Extra == nil {
|
||||
return false
|
||||
}
|
||||
if enabled, ok := a.Extra["openai_passthrough"].(bool); ok {
|
||||
return enabled
|
||||
}
|
||||
if enabled, ok := a.Extra["openai_oauth_passthrough"].(bool); ok {
|
||||
return enabled
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsOpenAIResponsesWebSocketV2Enabled 返回 OpenAI 账号是否开启 Responses WebSocket v2。
|
||||
//
|
||||
// 分类型新字段:
|
||||
// - OAuth 账号:accounts.extra.openai_oauth_responses_websockets_v2_enabled
|
||||
// - API Key 账号:accounts.extra.openai_apikey_responses_websockets_v2_enabled
|
||||
//
|
||||
// 兼容字段:
|
||||
// - accounts.extra.responses_websockets_v2_enabled
|
||||
// - accounts.extra.openai_ws_enabled(历史开关)
|
||||
//
|
||||
// 优先级:
|
||||
// 1. 按账号类型读取分类型字段
|
||||
// 2. 分类型字段缺失时,回退兼容字段
|
||||
func (a *Account) IsOpenAIResponsesWebSocketV2Enabled() bool {
|
||||
if a == nil || !a.IsOpenAI() || a.Extra == nil {
|
||||
return false
|
||||
}
|
||||
if a.IsOpenAIOAuth() {
|
||||
if enabled, ok := a.Extra["openai_oauth_responses_websockets_v2_enabled"].(bool); ok {
|
||||
return enabled
|
||||
}
|
||||
}
|
||||
if a.IsOpenAIApiKey() {
|
||||
if enabled, ok := a.Extra["openai_apikey_responses_websockets_v2_enabled"].(bool); ok {
|
||||
return enabled
|
||||
}
|
||||
}
|
||||
if enabled, ok := a.Extra["responses_websockets_v2_enabled"].(bool); ok {
|
||||
return enabled
|
||||
}
|
||||
if enabled, ok := a.Extra["openai_ws_enabled"].(bool); ok {
|
||||
return enabled
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const (
|
||||
OpenAIWSIngressModeOff = "off"
|
||||
OpenAIWSIngressModeShared = "shared"
|
||||
OpenAIWSIngressModeDedicated = "dedicated"
|
||||
)
|
||||
|
||||
func normalizeOpenAIWSIngressMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case OpenAIWSIngressModeOff:
|
||||
return OpenAIWSIngressModeOff
|
||||
case OpenAIWSIngressModeShared:
|
||||
return OpenAIWSIngressModeShared
|
||||
case OpenAIWSIngressModeDedicated:
|
||||
return OpenAIWSIngressModeDedicated
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeOpenAIWSIngressDefaultMode(mode string) string {
|
||||
if normalized := normalizeOpenAIWSIngressMode(mode); normalized != "" {
|
||||
return normalized
|
||||
}
|
||||
return OpenAIWSIngressModeShared
|
||||
}
|
||||
|
||||
// ResolveOpenAIResponsesWebSocketV2Mode 返回账号在 WSv2 ingress 下的有效模式(off/shared/dedicated)。
|
||||
//
|
||||
// 优先级:
|
||||
// 1. 分类型 mode 新字段(string)
|
||||
// 2. 分类型 enabled 旧字段(bool)
|
||||
// 3. 兼容 enabled 旧字段(bool)
|
||||
// 4. defaultMode(非法时回退 shared)
|
||||
func (a *Account) ResolveOpenAIResponsesWebSocketV2Mode(defaultMode string) string {
|
||||
resolvedDefault := normalizeOpenAIWSIngressDefaultMode(defaultMode)
|
||||
if a == nil || !a.IsOpenAI() {
|
||||
return OpenAIWSIngressModeOff
|
||||
}
|
||||
if a.Extra == nil {
|
||||
return resolvedDefault
|
||||
}
|
||||
|
||||
resolveModeString := func(key string) (string, bool) {
|
||||
raw, ok := a.Extra[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
mode, ok := raw.(string)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
normalized := normalizeOpenAIWSIngressMode(mode)
|
||||
if normalized == "" {
|
||||
return "", false
|
||||
}
|
||||
return normalized, true
|
||||
}
|
||||
resolveBoolMode := func(key string) (string, bool) {
|
||||
raw, ok := a.Extra[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
enabled, ok := raw.(bool)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if enabled {
|
||||
return OpenAIWSIngressModeShared, true
|
||||
}
|
||||
return OpenAIWSIngressModeOff, true
|
||||
}
|
||||
|
||||
if a.IsOpenAIOAuth() {
|
||||
if mode, ok := resolveModeString("openai_oauth_responses_websockets_v2_mode"); ok {
|
||||
return mode
|
||||
}
|
||||
if mode, ok := resolveBoolMode("openai_oauth_responses_websockets_v2_enabled"); ok {
|
||||
return mode
|
||||
}
|
||||
}
|
||||
if a.IsOpenAIApiKey() {
|
||||
if mode, ok := resolveModeString("openai_apikey_responses_websockets_v2_mode"); ok {
|
||||
return mode
|
||||
}
|
||||
if mode, ok := resolveBoolMode("openai_apikey_responses_websockets_v2_enabled"); ok {
|
||||
return mode
|
||||
}
|
||||
}
|
||||
if mode, ok := resolveBoolMode("responses_websockets_v2_enabled"); ok {
|
||||
return mode
|
||||
}
|
||||
if mode, ok := resolveBoolMode("openai_ws_enabled"); ok {
|
||||
return mode
|
||||
}
|
||||
return resolvedDefault
|
||||
}
|
||||
|
||||
// IsOpenAIWSForceHTTPEnabled 返回账号级“强制 HTTP”开关。
|
||||
// 字段:accounts.extra.openai_ws_force_http。
|
||||
func (a *Account) IsOpenAIWSForceHTTPEnabled() bool {
|
||||
if a == nil || !a.IsOpenAI() || a.Extra == nil {
|
||||
return false
|
||||
}
|
||||
enabled, ok := a.Extra["openai_ws_force_http"].(bool)
|
||||
return ok && enabled
|
||||
}
|
||||
|
||||
// IsOpenAIWSAllowStoreRecoveryEnabled 返回账号级 store 恢复开关。
|
||||
// 字段:accounts.extra.openai_ws_allow_store_recovery。
|
||||
func (a *Account) IsOpenAIWSAllowStoreRecoveryEnabled() bool {
|
||||
if a == nil || !a.IsOpenAI() || a.Extra == nil {
|
||||
return false
|
||||
}
|
||||
enabled, ok := a.Extra["openai_ws_allow_store_recovery"].(bool)
|
||||
return ok && enabled
|
||||
}
|
||||
|
||||
// IsOpenAIOAuthPassthroughEnabled 兼容旧接口,等价于 OAuth 账号的 IsOpenAIPassthroughEnabled。
|
||||
func (a *Account) IsOpenAIOAuthPassthroughEnabled() bool {
|
||||
return a != nil && a.IsOpenAIOAuth() && a.IsOpenAIPassthroughEnabled()
|
||||
}
|
||||
|
||||
// IsAnthropicAPIKeyPassthroughEnabled 返回 Anthropic API Key 账号是否启用“自动透传(仅替换认证)”。
|
||||
// 字段:accounts.extra.anthropic_passthrough。
|
||||
// 字段缺失或类型不正确时,按 false(关闭)处理。
|
||||
func (a *Account) IsAnthropicAPIKeyPassthroughEnabled() bool {
|
||||
if a == nil || a.Platform != PlatformAnthropic || a.Type != AccountTypeAPIKey || a.Extra == nil {
|
||||
return false
|
||||
}
|
||||
enabled, ok := a.Extra["anthropic_passthrough"].(bool)
|
||||
return ok && enabled
|
||||
}
|
||||
|
||||
// IsCodexCLIOnlyEnabled 返回 OpenAI OAuth 账号是否启用“仅允许 Codex 官方客户端”。
|
||||
// 字段:accounts.extra.codex_cli_only。
|
||||
// 字段缺失或类型不正确时,按 false(关闭)处理。
|
||||
func (a *Account) IsCodexCLIOnlyEnabled() bool {
|
||||
if a == nil || !a.IsOpenAIOAuth() || a.Extra == nil {
|
||||
return false
|
||||
}
|
||||
enabled, ok := a.Extra["codex_cli_only"].(bool)
|
||||
return ok && enabled
|
||||
}
|
||||
|
||||
// WindowCostSchedulability 窗口费用调度状态
|
||||
type WindowCostSchedulability int
|
||||
|
||||
@@ -733,6 +1033,26 @@ func (a *Account) IsTLSFingerprintEnabled() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// GetUserMsgQueueMode 获取用户消息队列模式
|
||||
// "serialize" = 串行队列, "throttle" = 软性限速, "" = 未设置(使用全局配置)
|
||||
func (a *Account) GetUserMsgQueueMode() string {
|
||||
if a.Extra == nil {
|
||||
return ""
|
||||
}
|
||||
// 优先读取新字段 user_msg_queue_mode(白名单校验,非法值视为未设置)
|
||||
if mode, ok := a.Extra["user_msg_queue_mode"].(string); ok && mode != "" {
|
||||
if mode == config.UMQModeSerialize || mode == config.UMQModeThrottle {
|
||||
return mode
|
||||
}
|
||||
return "" // 非法值 fallback 到全局配置
|
||||
}
|
||||
// 向后兼容: user_msg_queue_enabled: true → "serialize"
|
||||
if enabled, ok := a.Extra["user_msg_queue_enabled"].(bool); ok && enabled {
|
||||
return config.UMQModeSerialize
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsSessionIDMaskingEnabled 检查是否启用会话ID伪装
|
||||
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
|
||||
// 启用后将在一段时间内(15分钟)固定 metadata.user_id 中的 session ID,
|
||||
@@ -752,6 +1072,38 @@ func (a *Account) IsSessionIDMaskingEnabled() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsCacheTTLOverrideEnabled 检查是否启用缓存 TTL 强制替换
|
||||
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
|
||||
// 启用后将所有 cache creation tokens 归入指定的 TTL 类型(5m 或 1h)
|
||||
func (a *Account) IsCacheTTLOverrideEnabled() bool {
|
||||
if !a.IsAnthropicOAuthOrSetupToken() {
|
||||
return false
|
||||
}
|
||||
if a.Extra == nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := a.Extra["cache_ttl_override_enabled"]; ok {
|
||||
if enabled, ok := v.(bool); ok {
|
||||
return enabled
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetCacheTTLOverrideTarget 获取缓存 TTL 强制替换的目标类型
|
||||
// 返回 "5m" 或 "1h",默认 "5m"
|
||||
func (a *Account) GetCacheTTLOverrideTarget() string {
|
||||
if a.Extra == nil {
|
||||
return "5m"
|
||||
}
|
||||
if v, ok := a.Extra["cache_ttl_override_target"]; ok {
|
||||
if target, ok := v.(string); ok && (target == "5m" || target == "1h") {
|
||||
return target
|
||||
}
|
||||
}
|
||||
return "5m"
|
||||
}
|
||||
|
||||
// GetWindowCostLimit 获取 5h 窗口费用阈值(美元)
|
||||
// 返回 0 表示未启用
|
||||
func (a *Account) GetWindowCostLimit() float64 {
|
||||
@@ -806,6 +1158,80 @@ func (a *Account) GetSessionIdleTimeoutMinutes() int {
|
||||
return 5
|
||||
}
|
||||
|
||||
// GetBaseRPM 获取基础 RPM 限制
|
||||
// 返回 0 表示未启用(负数视为无效配置,按 0 处理)
|
||||
func (a *Account) GetBaseRPM() int {
|
||||
if a.Extra == nil {
|
||||
return 0
|
||||
}
|
||||
if v, ok := a.Extra["base_rpm"]; ok {
|
||||
val := parseExtraInt(v)
|
||||
if val > 0 {
|
||||
return val
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetRPMStrategy 获取 RPM 策略
|
||||
// "tiered" = 三区模型(默认), "sticky_exempt" = 粘性豁免
|
||||
func (a *Account) GetRPMStrategy() string {
|
||||
if a.Extra == nil {
|
||||
return "tiered"
|
||||
}
|
||||
if v, ok := a.Extra["rpm_strategy"]; ok {
|
||||
if s, ok := v.(string); ok && s == "sticky_exempt" {
|
||||
return "sticky_exempt"
|
||||
}
|
||||
}
|
||||
return "tiered"
|
||||
}
|
||||
|
||||
// GetRPMStickyBuffer 获取 RPM 粘性缓冲数量
|
||||
// tiered 模式下的黄区大小,默认为 base_rpm 的 20%(至少 1)
|
||||
func (a *Account) GetRPMStickyBuffer() int {
|
||||
if a.Extra == nil {
|
||||
return 0
|
||||
}
|
||||
if v, ok := a.Extra["rpm_sticky_buffer"]; ok {
|
||||
val := parseExtraInt(v)
|
||||
if val > 0 {
|
||||
return val
|
||||
}
|
||||
}
|
||||
base := a.GetBaseRPM()
|
||||
buffer := base / 5
|
||||
if buffer < 1 && base > 0 {
|
||||
buffer = 1
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
// CheckRPMSchedulability 根据当前 RPM 计数检查调度状态
|
||||
// 复用 WindowCostSchedulability 三态:Schedulable / StickyOnly / NotSchedulable
|
||||
func (a *Account) CheckRPMSchedulability(currentRPM int) WindowCostSchedulability {
|
||||
baseRPM := a.GetBaseRPM()
|
||||
if baseRPM <= 0 {
|
||||
return WindowCostSchedulable
|
||||
}
|
||||
|
||||
if currentRPM < baseRPM {
|
||||
return WindowCostSchedulable
|
||||
}
|
||||
|
||||
strategy := a.GetRPMStrategy()
|
||||
if strategy == "sticky_exempt" {
|
||||
return WindowCostStickyOnly // 粘性豁免无红区
|
||||
}
|
||||
|
||||
// tiered: 黄区 + 红区
|
||||
buffer := a.GetRPMStickyBuffer()
|
||||
if currentRPM < baseRPM+buffer {
|
||||
return WindowCostStickyOnly
|
||||
}
|
||||
return WindowCostNotSchedulable
|
||||
}
|
||||
|
||||
// CheckWindowCostSchedulability 根据当前窗口费用检查调度状态
|
||||
// - 费用 < 阈值: WindowCostSchedulable(可正常调度)
|
||||
// - 费用 >= 阈值 且 < 阈值+预留: WindowCostStickyOnly(仅粘性会话)
|
||||
@@ -869,6 +1295,12 @@ func parseExtraFloat64(value any) float64 {
|
||||
}
|
||||
|
||||
// parseExtraInt 从 extra 字段解析 int 值
|
||||
// ParseExtraInt 从 extra 字段的 any 值解析为 int。
|
||||
// 支持 int, int64, float64, json.Number, string 类型,无法解析时返回 0。
|
||||
func ParseExtraInt(value any) int {
|
||||
return parseExtraInt(value)
|
||||
}
|
||||
|
||||
func parseExtraInt(value any) int {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAccount_IsAnthropicAPIKeyPassthroughEnabled(t *testing.T) {
|
||||
t.Run("Anthropic API Key 开启", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{
|
||||
"anthropic_passthrough": true,
|
||||
},
|
||||
}
|
||||
require.True(t, account.IsAnthropicAPIKeyPassthroughEnabled())
|
||||
})
|
||||
|
||||
t.Run("Anthropic API Key 关闭", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{
|
||||
"anthropic_passthrough": false,
|
||||
},
|
||||
}
|
||||
require.False(t, account.IsAnthropicAPIKeyPassthroughEnabled())
|
||||
})
|
||||
|
||||
t.Run("字段类型非法默认关闭", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{
|
||||
"anthropic_passthrough": "true",
|
||||
},
|
||||
}
|
||||
require.False(t, account.IsAnthropicAPIKeyPassthroughEnabled())
|
||||
})
|
||||
|
||||
t.Run("非 Anthropic API Key 账号始终关闭", func(t *testing.T) {
|
||||
oauth := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{
|
||||
"anthropic_passthrough": true,
|
||||
},
|
||||
}
|
||||
require.False(t, oauth.IsAnthropicAPIKeyPassthroughEnabled())
|
||||
|
||||
openai := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{
|
||||
"anthropic_passthrough": true,
|
||||
},
|
||||
}
|
||||
require.False(t, openai.IsAnthropicAPIKeyPassthroughEnabled())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAccount_IsInterceptWarmupEnabled(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
credentials map[string]any
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "nil credentials",
|
||||
credentials: nil,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty map",
|
||||
credentials: map[string]any{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "field not present",
|
||||
credentials: map[string]any{"access_token": "tok"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "field is true",
|
||||
credentials: map[string]any{"intercept_warmup_requests": true},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "field is false",
|
||||
credentials: map[string]any{"intercept_warmup_requests": false},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "field is string true",
|
||||
credentials: map[string]any{"intercept_warmup_requests": "true"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "field is int 1",
|
||||
credentials: map[string]any{"intercept_warmup_requests": 1},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "field is nil",
|
||||
credentials: map[string]any{"intercept_warmup_requests": nil},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
a := &Account{Credentials: tt.credentials}
|
||||
result := a.IsInterceptWarmupEnabled()
|
||||
require.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAccount_IsOpenAIPassthroughEnabled(t *testing.T) {
|
||||
t.Run("新字段开启", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{
|
||||
"openai_passthrough": true,
|
||||
},
|
||||
}
|
||||
require.True(t, account.IsOpenAIPassthroughEnabled())
|
||||
})
|
||||
|
||||
t.Run("兼容旧字段", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{
|
||||
"openai_oauth_passthrough": true,
|
||||
},
|
||||
}
|
||||
require.True(t, account.IsOpenAIPassthroughEnabled())
|
||||
})
|
||||
|
||||
t.Run("非OpenAI账号始终关闭", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{
|
||||
"openai_passthrough": true,
|
||||
},
|
||||
}
|
||||
require.False(t, account.IsOpenAIPassthroughEnabled())
|
||||
})
|
||||
|
||||
t.Run("空额外配置默认关闭", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
}
|
||||
require.False(t, account.IsOpenAIPassthroughEnabled())
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_IsOpenAIOAuthPassthroughEnabled(t *testing.T) {
|
||||
t.Run("仅OAuth类型允许返回开启", func(t *testing.T) {
|
||||
oauthAccount := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{
|
||||
"openai_passthrough": true,
|
||||
},
|
||||
}
|
||||
require.True(t, oauthAccount.IsOpenAIOAuthPassthroughEnabled())
|
||||
|
||||
apiKeyAccount := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{
|
||||
"openai_passthrough": true,
|
||||
},
|
||||
}
|
||||
require.False(t, apiKeyAccount.IsOpenAIOAuthPassthroughEnabled())
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_IsCodexCLIOnlyEnabled(t *testing.T) {
|
||||
t.Run("OpenAI OAuth 开启", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{
|
||||
"codex_cli_only": true,
|
||||
},
|
||||
}
|
||||
require.True(t, account.IsCodexCLIOnlyEnabled())
|
||||
})
|
||||
|
||||
t.Run("OpenAI OAuth 关闭", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{
|
||||
"codex_cli_only": false,
|
||||
},
|
||||
}
|
||||
require.False(t, account.IsCodexCLIOnlyEnabled())
|
||||
})
|
||||
|
||||
t.Run("字段缺失默认关闭", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{},
|
||||
}
|
||||
require.False(t, account.IsCodexCLIOnlyEnabled())
|
||||
})
|
||||
|
||||
t.Run("类型非法默认关闭", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{
|
||||
"codex_cli_only": "true",
|
||||
},
|
||||
}
|
||||
require.False(t, account.IsCodexCLIOnlyEnabled())
|
||||
})
|
||||
|
||||
t.Run("非 OAuth 账号始终关闭", func(t *testing.T) {
|
||||
apiKeyAccount := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{
|
||||
"codex_cli_only": true,
|
||||
},
|
||||
}
|
||||
require.False(t, apiKeyAccount.IsCodexCLIOnlyEnabled())
|
||||
|
||||
otherPlatform := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{
|
||||
"codex_cli_only": true,
|
||||
},
|
||||
}
|
||||
require.False(t, otherPlatform.IsCodexCLIOnlyEnabled())
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_IsOpenAIResponsesWebSocketV2Enabled(t *testing.T) {
|
||||
t.Run("OAuth使用OAuth专用开关", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{
|
||||
"openai_oauth_responses_websockets_v2_enabled": true,
|
||||
},
|
||||
}
|
||||
require.True(t, account.IsOpenAIResponsesWebSocketV2Enabled())
|
||||
})
|
||||
|
||||
t.Run("API Key使用API Key专用开关", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{
|
||||
"openai_apikey_responses_websockets_v2_enabled": true,
|
||||
},
|
||||
}
|
||||
require.True(t, account.IsOpenAIResponsesWebSocketV2Enabled())
|
||||
})
|
||||
|
||||
t.Run("OAuth账号不会读取API Key专用开关", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{
|
||||
"openai_apikey_responses_websockets_v2_enabled": true,
|
||||
},
|
||||
}
|
||||
require.False(t, account.IsOpenAIResponsesWebSocketV2Enabled())
|
||||
})
|
||||
|
||||
t.Run("分类型新键优先于兼容键", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{
|
||||
"openai_oauth_responses_websockets_v2_enabled": false,
|
||||
"responses_websockets_v2_enabled": true,
|
||||
"openai_ws_enabled": true,
|
||||
},
|
||||
}
|
||||
require.False(t, account.IsOpenAIResponsesWebSocketV2Enabled())
|
||||
})
|
||||
|
||||
t.Run("分类型键缺失时回退兼容键", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{
|
||||
"responses_websockets_v2_enabled": true,
|
||||
},
|
||||
}
|
||||
require.True(t, account.IsOpenAIResponsesWebSocketV2Enabled())
|
||||
})
|
||||
|
||||
t.Run("非OpenAI账号默认关闭", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{
|
||||
"responses_websockets_v2_enabled": true,
|
||||
},
|
||||
}
|
||||
require.False(t, account.IsOpenAIResponsesWebSocketV2Enabled())
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_ResolveOpenAIResponsesWebSocketV2Mode(t *testing.T) {
|
||||
t.Run("default fallback to shared", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{},
|
||||
}
|
||||
require.Equal(t, OpenAIWSIngressModeShared, account.ResolveOpenAIResponsesWebSocketV2Mode(""))
|
||||
require.Equal(t, OpenAIWSIngressModeShared, account.ResolveOpenAIResponsesWebSocketV2Mode("invalid"))
|
||||
})
|
||||
|
||||
t.Run("oauth mode field has highest priority", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{
|
||||
"openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeDedicated,
|
||||
"openai_oauth_responses_websockets_v2_enabled": false,
|
||||
"responses_websockets_v2_enabled": false,
|
||||
},
|
||||
}
|
||||
require.Equal(t, OpenAIWSIngressModeDedicated, account.ResolveOpenAIResponsesWebSocketV2Mode(OpenAIWSIngressModeShared))
|
||||
})
|
||||
|
||||
t.Run("legacy enabled maps to shared", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{
|
||||
"responses_websockets_v2_enabled": true,
|
||||
},
|
||||
}
|
||||
require.Equal(t, OpenAIWSIngressModeShared, account.ResolveOpenAIResponsesWebSocketV2Mode(OpenAIWSIngressModeOff))
|
||||
})
|
||||
|
||||
t.Run("legacy disabled maps to off", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{
|
||||
"openai_apikey_responses_websockets_v2_enabled": false,
|
||||
"responses_websockets_v2_enabled": true,
|
||||
},
|
||||
}
|
||||
require.Equal(t, OpenAIWSIngressModeOff, account.ResolveOpenAIResponsesWebSocketV2Mode(OpenAIWSIngressModeShared))
|
||||
})
|
||||
|
||||
t.Run("non openai always off", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{
|
||||
"openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeDedicated,
|
||||
},
|
||||
}
|
||||
require.Equal(t, OpenAIWSIngressModeOff, account.ResolveOpenAIResponsesWebSocketV2Mode(OpenAIWSIngressModeDedicated))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_OpenAIWSExtraFlags(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{
|
||||
"openai_ws_force_http": true,
|
||||
"openai_ws_allow_store_recovery": true,
|
||||
},
|
||||
}
|
||||
require.True(t, account.IsOpenAIWSForceHTTPEnabled())
|
||||
require.True(t, account.IsOpenAIWSAllowStoreRecoveryEnabled())
|
||||
|
||||
off := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{}}
|
||||
require.False(t, off.IsOpenAIWSForceHTTPEnabled())
|
||||
require.False(t, off.IsOpenAIWSAllowStoreRecoveryEnabled())
|
||||
|
||||
var nilAccount *Account
|
||||
require.False(t, nilAccount.IsOpenAIWSAllowStoreRecoveryEnabled())
|
||||
|
||||
nonOpenAI := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{
|
||||
"openai_ws_allow_store_recovery": true,
|
||||
},
|
||||
}
|
||||
require.False(t, nonOpenAI.IsOpenAIWSAllowStoreRecoveryEnabled())
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetBaseRPM(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
extra map[string]any
|
||||
expected int
|
||||
}{
|
||||
{"nil extra", nil, 0},
|
||||
{"no key", map[string]any{}, 0},
|
||||
{"zero", map[string]any{"base_rpm": 0}, 0},
|
||||
{"int value", map[string]any{"base_rpm": 15}, 15},
|
||||
{"float value", map[string]any{"base_rpm": 15.0}, 15},
|
||||
{"string value", map[string]any{"base_rpm": "15"}, 15},
|
||||
{"negative value", map[string]any{"base_rpm": -5}, 0},
|
||||
{"int64 value", map[string]any{"base_rpm": int64(20)}, 20},
|
||||
{"json.Number value", map[string]any{"base_rpm": json.Number("25")}, 25},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
a := &Account{Extra: tt.extra}
|
||||
if got := a.GetBaseRPM(); got != tt.expected {
|
||||
t.Errorf("GetBaseRPM() = %d, want %d", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRPMStrategy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
extra map[string]any
|
||||
expected string
|
||||
}{
|
||||
{"nil extra", nil, "tiered"},
|
||||
{"no key", map[string]any{}, "tiered"},
|
||||
{"tiered", map[string]any{"rpm_strategy": "tiered"}, "tiered"},
|
||||
{"sticky_exempt", map[string]any{"rpm_strategy": "sticky_exempt"}, "sticky_exempt"},
|
||||
{"invalid", map[string]any{"rpm_strategy": "foobar"}, "tiered"},
|
||||
{"empty string fallback", map[string]any{"rpm_strategy": ""}, "tiered"},
|
||||
{"numeric value fallback", map[string]any{"rpm_strategy": 123}, "tiered"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
a := &Account{Extra: tt.extra}
|
||||
if got := a.GetRPMStrategy(); got != tt.expected {
|
||||
t.Errorf("GetRPMStrategy() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRPMSchedulability(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
extra map[string]any
|
||||
currentRPM int
|
||||
expected WindowCostSchedulability
|
||||
}{
|
||||
{"disabled", map[string]any{}, 100, WindowCostSchedulable},
|
||||
{"green zone", map[string]any{"base_rpm": 15}, 10, WindowCostSchedulable},
|
||||
{"yellow zone tiered", map[string]any{"base_rpm": 15}, 15, WindowCostStickyOnly},
|
||||
{"red zone tiered", map[string]any{"base_rpm": 15}, 18, WindowCostNotSchedulable},
|
||||
{"sticky_exempt at limit", map[string]any{"base_rpm": 15, "rpm_strategy": "sticky_exempt"}, 15, WindowCostStickyOnly},
|
||||
{"sticky_exempt over limit", map[string]any{"base_rpm": 15, "rpm_strategy": "sticky_exempt"}, 100, WindowCostStickyOnly},
|
||||
{"custom buffer", map[string]any{"base_rpm": 10, "rpm_sticky_buffer": 5}, 14, WindowCostStickyOnly},
|
||||
{"custom buffer red", map[string]any{"base_rpm": 10, "rpm_sticky_buffer": 5}, 15, WindowCostNotSchedulable},
|
||||
{"base_rpm=1 green", map[string]any{"base_rpm": 1}, 0, WindowCostSchedulable},
|
||||
{"base_rpm=1 yellow (at limit)", map[string]any{"base_rpm": 1}, 1, WindowCostStickyOnly},
|
||||
{"base_rpm=1 red (at limit+buffer)", map[string]any{"base_rpm": 1}, 2, WindowCostNotSchedulable},
|
||||
{"negative currentRPM", map[string]any{"base_rpm": 15}, -1, WindowCostSchedulable},
|
||||
{"base_rpm negative disabled", map[string]any{"base_rpm": -5}, 10, WindowCostSchedulable},
|
||||
{"very high currentRPM", map[string]any{"base_rpm": 10}, 9999, WindowCostNotSchedulable},
|
||||
{"sticky_exempt very high currentRPM", map[string]any{"base_rpm": 10, "rpm_strategy": "sticky_exempt"}, 9999, WindowCostStickyOnly},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
a := &Account{Extra: tt.extra}
|
||||
if got := a.CheckRPMSchedulability(tt.currentRPM); got != tt.expected {
|
||||
t.Errorf("CheckRPMSchedulability(%d) = %d, want %d", tt.currentRPM, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRPMStickyBuffer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
extra map[string]any
|
||||
expected int
|
||||
}{
|
||||
{"nil extra", nil, 0},
|
||||
{"no keys", map[string]any{}, 0},
|
||||
{"base_rpm=0", map[string]any{"base_rpm": 0}, 0},
|
||||
{"base_rpm=1 min buffer 1", map[string]any{"base_rpm": 1}, 1},
|
||||
{"base_rpm=4 min buffer 1", map[string]any{"base_rpm": 4}, 1},
|
||||
{"base_rpm=5 buffer 1", map[string]any{"base_rpm": 5}, 1},
|
||||
{"base_rpm=10 buffer 2", map[string]any{"base_rpm": 10}, 2},
|
||||
{"base_rpm=15 buffer 3", map[string]any{"base_rpm": 15}, 3},
|
||||
{"base_rpm=100 buffer 20", map[string]any{"base_rpm": 100}, 20},
|
||||
{"custom buffer=5", map[string]any{"base_rpm": 10, "rpm_sticky_buffer": 5}, 5},
|
||||
{"custom buffer=0 fallback to default", map[string]any{"base_rpm": 10, "rpm_sticky_buffer": 0}, 2},
|
||||
{"custom buffer negative fallback", map[string]any{"base_rpm": 10, "rpm_sticky_buffer": -1}, 2},
|
||||
{"custom buffer with float", map[string]any{"base_rpm": 10, "rpm_sticky_buffer": float64(7)}, 7},
|
||||
{"json.Number base_rpm", map[string]any{"base_rpm": json.Number("10")}, 2},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
a := &Account{Extra: tt.extra}
|
||||
if got := a.GetRPMStickyBuffer(); got != tt.expected {
|
||||
t.Errorf("GetRPMStickyBuffer() = %d, want %d", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ type AccountRepository interface {
|
||||
// GetByCRSAccountID finds an account previously synced from CRS.
|
||||
// Returns (nil, nil) if not found.
|
||||
GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error)
|
||||
// FindByExtraField 根据 extra 字段中的键值对查找账号(限定 platform='sora')
|
||||
// 用于查找通过 linked_openai_account_id 关联的 Sora 账号
|
||||
FindByExtraField(ctx context.Context, key string, value any) ([]Account, error)
|
||||
// ListCRSAccountIDs returns a map of crs_account_id -> local account ID
|
||||
// for all accounts that have been synced from CRS.
|
||||
ListCRSAccountIDs(ctx context.Context) (map[string]int64, error)
|
||||
@@ -51,6 +54,8 @@ type AccountRepository interface {
|
||||
ListSchedulableByGroupIDAndPlatform(ctx context.Context, groupID int64, platform string) ([]Account, error)
|
||||
ListSchedulableByPlatforms(ctx context.Context, platforms []string) ([]Account, error)
|
||||
ListSchedulableByGroupIDAndPlatforms(ctx context.Context, groupID int64, platforms []string) ([]Account, error)
|
||||
ListSchedulableUngroupedByPlatform(ctx context.Context, platform string) ([]Account, error)
|
||||
ListSchedulableUngroupedByPlatforms(ctx context.Context, platforms []string) ([]Account, error)
|
||||
|
||||
SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error
|
||||
SetModelRateLimit(ctx context.Context, id int64, scope string, resetAt time.Time) error
|
||||
@@ -116,6 +121,10 @@ type AccountService struct {
|
||||
groupRepo GroupRepository
|
||||
}
|
||||
|
||||
type groupExistenceBatchChecker interface {
|
||||
ExistsByIDs(ctx context.Context, ids []int64) (map[int64]bool, error)
|
||||
}
|
||||
|
||||
// NewAccountService 创建账号服务实例
|
||||
func NewAccountService(accountRepo AccountRepository, groupRepo GroupRepository) *AccountService {
|
||||
return &AccountService{
|
||||
@@ -128,11 +137,8 @@ func NewAccountService(accountRepo AccountRepository, groupRepo GroupRepository)
|
||||
func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) (*Account, error) {
|
||||
// 验证分组是否存在(如果指定了分组)
|
||||
if len(req.GroupIDs) > 0 {
|
||||
for _, groupID := range req.GroupIDs {
|
||||
_, err := s.groupRepo.GetByID(ctx, groupID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get group: %w", err)
|
||||
}
|
||||
if err := s.validateGroupIDsExist(ctx, req.GroupIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,11 +259,8 @@ func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccount
|
||||
|
||||
// 先验证分组是否存在(在任何写操作之前)
|
||||
if req.GroupIDs != nil {
|
||||
for _, groupID := range *req.GroupIDs {
|
||||
_, err := s.groupRepo.GetByID(ctx, groupID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get group: %w", err)
|
||||
}
|
||||
if err := s.validateGroupIDsExist(ctx, *req.GroupIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +300,39 @@ func (s *AccountService) Delete(ctx context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AccountService) validateGroupIDsExist(ctx context.Context, groupIDs []int64) error {
|
||||
if len(groupIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if s.groupRepo == nil {
|
||||
return fmt.Errorf("group repository not configured")
|
||||
}
|
||||
|
||||
if batchChecker, ok := s.groupRepo.(groupExistenceBatchChecker); ok {
|
||||
existsByID, err := batchChecker.ExistsByIDs(ctx, groupIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check groups exists: %w", err)
|
||||
}
|
||||
for _, groupID := range groupIDs {
|
||||
if groupID <= 0 {
|
||||
return fmt.Errorf("get group: %w", ErrGroupNotFound)
|
||||
}
|
||||
if !existsByID[groupID] {
|
||||
return fmt.Errorf("get group: %w", ErrGroupNotFound)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, groupID := range groupIDs {
|
||||
_, err := s.groupRepo.GetByID(ctx, groupID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get group: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateStatus 更新账号状态
|
||||
func (s *AccountService) UpdateStatus(ctx context.Context, id int64, status string, errorMessage string) error {
|
||||
account, err := s.accountRepo.GetByID(ctx, id)
|
||||
|
||||
@@ -54,6 +54,10 @@ func (s *accountRepoStub) GetByCRSAccountID(ctx context.Context, crsAccountID st
|
||||
panic("unexpected GetByCRSAccountID call")
|
||||
}
|
||||
|
||||
func (s *accountRepoStub) FindByExtraField(ctx context.Context, key string, value any) ([]Account, error) {
|
||||
panic("unexpected FindByExtraField call")
|
||||
}
|
||||
|
||||
func (s *accountRepoStub) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) {
|
||||
panic("unexpected ListCRSAccountIDs call")
|
||||
}
|
||||
@@ -143,6 +147,14 @@ func (s *accountRepoStub) ListSchedulableByGroupIDAndPlatforms(ctx context.Conte
|
||||
panic("unexpected ListSchedulableByGroupIDAndPlatforms call")
|
||||
}
|
||||
|
||||
func (s *accountRepoStub) ListSchedulableUngroupedByPlatform(ctx context.Context, platform string) ([]Account, error) {
|
||||
panic("unexpected ListSchedulableUngroupedByPlatform call")
|
||||
}
|
||||
|
||||
func (s *accountRepoStub) ListSchedulableUngroupedByPlatforms(ctx context.Context, platforms []string) ([]Account, error) {
|
||||
panic("unexpected ListSchedulableUngroupedByPlatforms call")
|
||||
}
|
||||
|
||||
func (s *accountRepoStub) SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error {
|
||||
panic("unexpected SetRateLimited call")
|
||||
}
|
||||
|
||||
@@ -12,13 +12,17 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/soraerror"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -31,6 +35,11 @@ var sseDataPrefix = regexp.MustCompile(`^data:\s*`)
|
||||
const (
|
||||
testClaudeAPIURL = "https://api.anthropic.com/v1/messages"
|
||||
chatgptCodexAPIURL = "https://chatgpt.com/backend-api/codex/responses"
|
||||
soraMeAPIURL = "https://sora.chatgpt.com/backend/me" // Sora 用户信息接口,用于测试连接
|
||||
soraBillingAPIURL = "https://sora.chatgpt.com/backend/billing/subscriptions"
|
||||
soraInviteMineURL = "https://sora.chatgpt.com/backend/project_y/invite/mine"
|
||||
soraBootstrapURL = "https://sora.chatgpt.com/backend/m/bootstrap"
|
||||
soraRemainingURL = "https://sora.chatgpt.com/backend/nf/check"
|
||||
)
|
||||
|
||||
// TestEvent represents a SSE event for account testing
|
||||
@@ -38,6 +47,9 @@ type TestEvent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Success bool `json:"success,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -49,8 +61,13 @@ type AccountTestService struct {
|
||||
antigravityGatewayService *AntigravityGatewayService
|
||||
httpUpstream HTTPUpstream
|
||||
cfg *config.Config
|
||||
soraTestGuardMu sync.Mutex
|
||||
soraTestLastRun map[int64]time.Time
|
||||
soraTestCooldown time.Duration
|
||||
}
|
||||
|
||||
const defaultSoraTestCooldown = 10 * time.Second
|
||||
|
||||
// NewAccountTestService creates a new AccountTestService
|
||||
func NewAccountTestService(
|
||||
accountRepo AccountRepository,
|
||||
@@ -65,6 +82,8 @@ func NewAccountTestService(
|
||||
antigravityGatewayService: antigravityGatewayService,
|
||||
httpUpstream: httpUpstream,
|
||||
cfg: cfg,
|
||||
soraTestLastRun: make(map[int64]time.Time),
|
||||
soraTestCooldown: defaultSoraTestCooldown,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +182,10 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
|
||||
return s.testAntigravityAccountConnection(c, account, modelID)
|
||||
}
|
||||
|
||||
if account.Platform == PlatformSora {
|
||||
return s.testSoraAccountConnection(c, account)
|
||||
}
|
||||
|
||||
return s.testClaudeAccountConnection(c, account, modelID)
|
||||
}
|
||||
|
||||
@@ -462,6 +485,697 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account
|
||||
return s.processGeminiStream(c, resp.Body)
|
||||
}
|
||||
|
||||
type soraProbeStep struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
HTTPStatus int `json:"http_status,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type soraProbeSummary struct {
|
||||
Status string `json:"status"`
|
||||
Steps []soraProbeStep `json:"steps"`
|
||||
}
|
||||
|
||||
type soraProbeRecorder struct {
|
||||
steps []soraProbeStep
|
||||
}
|
||||
|
||||
func (r *soraProbeRecorder) addStep(name, status string, httpStatus int, errorCode, message string) {
|
||||
r.steps = append(r.steps, soraProbeStep{
|
||||
Name: name,
|
||||
Status: status,
|
||||
HTTPStatus: httpStatus,
|
||||
ErrorCode: strings.TrimSpace(errorCode),
|
||||
Message: strings.TrimSpace(message),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *soraProbeRecorder) finalize() soraProbeSummary {
|
||||
meSuccess := false
|
||||
partial := false
|
||||
for _, step := range r.steps {
|
||||
if step.Name == "me" {
|
||||
meSuccess = strings.EqualFold(step.Status, "success")
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(step.Status, "failed") {
|
||||
partial = true
|
||||
}
|
||||
}
|
||||
|
||||
status := "success"
|
||||
if !meSuccess {
|
||||
status = "failed"
|
||||
} else if partial {
|
||||
status = "partial_success"
|
||||
}
|
||||
|
||||
return soraProbeSummary{
|
||||
Status: status,
|
||||
Steps: append([]soraProbeStep(nil), r.steps...),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AccountTestService) emitSoraProbeSummary(c *gin.Context, rec *soraProbeRecorder) {
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
summary := rec.finalize()
|
||||
code := ""
|
||||
for _, step := range summary.Steps {
|
||||
if strings.EqualFold(step.Status, "failed") && strings.TrimSpace(step.ErrorCode) != "" {
|
||||
code = step.ErrorCode
|
||||
break
|
||||
}
|
||||
}
|
||||
s.sendEvent(c, TestEvent{
|
||||
Type: "sora_test_result",
|
||||
Status: summary.Status,
|
||||
Code: code,
|
||||
Data: summary,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *AccountTestService) acquireSoraTestPermit(accountID int64) (time.Duration, bool) {
|
||||
if accountID <= 0 {
|
||||
return 0, true
|
||||
}
|
||||
s.soraTestGuardMu.Lock()
|
||||
defer s.soraTestGuardMu.Unlock()
|
||||
|
||||
if s.soraTestLastRun == nil {
|
||||
s.soraTestLastRun = make(map[int64]time.Time)
|
||||
}
|
||||
cooldown := s.soraTestCooldown
|
||||
if cooldown <= 0 {
|
||||
cooldown = defaultSoraTestCooldown
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if lastRun, ok := s.soraTestLastRun[accountID]; ok {
|
||||
elapsed := now.Sub(lastRun)
|
||||
if elapsed < cooldown {
|
||||
return cooldown - elapsed, false
|
||||
}
|
||||
}
|
||||
s.soraTestLastRun[accountID] = now
|
||||
return 0, true
|
||||
}
|
||||
|
||||
func ceilSeconds(d time.Duration) int {
|
||||
if d <= 0 {
|
||||
return 1
|
||||
}
|
||||
sec := int(d / time.Second)
|
||||
if d%time.Second != 0 {
|
||||
sec++
|
||||
}
|
||||
if sec < 1 {
|
||||
sec = 1
|
||||
}
|
||||
return sec
|
||||
}
|
||||
|
||||
// testSoraAPIKeyAccountConnection 测试 Sora apikey 类型账号的连通性。
|
||||
// 向上游 base_url 发送轻量级 prompt-enhance 请求验证连通性和 API Key 有效性。
|
||||
func (s *AccountTestService) testSoraAPIKeyAccountConnection(c *gin.Context, account *Account) error {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
apiKey := account.GetCredential("api_key")
|
||||
if apiKey == "" {
|
||||
return s.sendErrorAndEnd(c, "Sora apikey 账号缺少 api_key 凭证")
|
||||
}
|
||||
|
||||
baseURL := account.GetBaseURL()
|
||||
if baseURL == "" {
|
||||
return s.sendErrorAndEnd(c, "Sora apikey 账号缺少 base_url")
|
||||
}
|
||||
|
||||
// 验证 base_url 格式
|
||||
normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("base_url 无效: %s", err.Error()))
|
||||
}
|
||||
upstreamURL := strings.TrimSuffix(normalizedBaseURL, "/") + "/sora/v1/chat/completions"
|
||||
|
||||
// 设置 SSE 头
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||
c.Writer.Flush()
|
||||
|
||||
if wait, ok := s.acquireSoraTestPermit(account.ID); !ok {
|
||||
msg := fmt.Sprintf("Sora 账号测试过于频繁,请 %d 秒后重试", ceilSeconds(wait))
|
||||
return s.sendErrorAndEnd(c, msg)
|
||||
}
|
||||
|
||||
s.sendEvent(c, TestEvent{Type: "test_start", Model: "sora-upstream"})
|
||||
|
||||
// 构建轻量级 prompt-enhance 请求作为连通性测试
|
||||
testPayload := map[string]any{
|
||||
"model": "prompt-enhance-short-10s",
|
||||
"messages": []map[string]string{{"role": "user", "content": "test"}},
|
||||
"stream": false,
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(testPayload)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(payloadBytes))
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, "构建测试请求失败")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
// 获取代理 URL
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("上游连接失败: %s", err.Error()))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("上游连接成功 (%s)", upstreamURL)})
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("API Key 有效 (HTTP %d)", resp.StatusCode)})
|
||||
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
||||
return nil
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("上游认证失败 (HTTP %d),请检查 API Key 是否正确", resp.StatusCode))
|
||||
}
|
||||
|
||||
// 其他错误但能连通(如 400 参数错误)也算连通性测试通过
|
||||
if resp.StatusCode == http.StatusBadRequest {
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("上游连接成功 (%s)", upstreamURL)})
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("API Key 有效(上游返回 %d,参数校验错误属正常)", resp.StatusCode)})
|
||||
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("上游返回异常 HTTP %d: %s", resp.StatusCode, truncateSoraErrorBody(respBody, 256)))
|
||||
}
|
||||
|
||||
// testSoraAccountConnection 测试 Sora 账号的连接
|
||||
// OAuth 类型:调用 /backend/me 接口验证 access_token 有效性
|
||||
// APIKey 类型:向上游 base_url 发送轻量级 prompt-enhance 请求验证连通性
|
||||
func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *Account) error {
|
||||
// apikey 类型走独立测试流程
|
||||
if account.Type == AccountTypeAPIKey {
|
||||
return s.testSoraAPIKeyAccountConnection(c, account)
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
recorder := &soraProbeRecorder{}
|
||||
|
||||
authToken := account.GetCredential("access_token")
|
||||
if authToken == "" {
|
||||
recorder.addStep("me", "failed", http.StatusUnauthorized, "missing_access_token", "No access token available")
|
||||
s.emitSoraProbeSummary(c, recorder)
|
||||
return s.sendErrorAndEnd(c, "No access token available")
|
||||
}
|
||||
|
||||
// Set SSE headers
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||
c.Writer.Flush()
|
||||
|
||||
if wait, ok := s.acquireSoraTestPermit(account.ID); !ok {
|
||||
msg := fmt.Sprintf("Sora 账号测试过于频繁,请 %d 秒后重试", ceilSeconds(wait))
|
||||
recorder.addStep("rate_limit", "failed", http.StatusTooManyRequests, "test_rate_limited", msg)
|
||||
s.emitSoraProbeSummary(c, recorder)
|
||||
return s.sendErrorAndEnd(c, msg)
|
||||
}
|
||||
|
||||
// Send test_start event
|
||||
s.sendEvent(c, TestEvent{Type: "test_start", Model: "sora"})
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", soraMeAPIURL, nil)
|
||||
if err != nil {
|
||||
recorder.addStep("me", "failed", 0, "request_build_failed", err.Error())
|
||||
s.emitSoraProbeSummary(c, recorder)
|
||||
return s.sendErrorAndEnd(c, "Failed to create request")
|
||||
}
|
||||
|
||||
// 使用 Sora 客户端标准请求头
|
||||
req.Header.Set("Authorization", "Bearer "+authToken)
|
||||
req.Header.Set("User-Agent", "Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
req.Header.Set("Origin", "https://sora.chatgpt.com")
|
||||
req.Header.Set("Referer", "https://sora.chatgpt.com/")
|
||||
|
||||
// Get proxy URL
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
enableSoraTLSFingerprint := s.shouldEnableSoraTLSFingerprint()
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, enableSoraTLSFingerprint)
|
||||
if err != nil {
|
||||
recorder.addStep("me", "failed", 0, "network_error", err.Error())
|
||||
s.emitSoraProbeSummary(c, recorder)
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if isCloudflareChallengeResponse(resp.StatusCode, resp.Header, body) {
|
||||
recorder.addStep("me", "failed", resp.StatusCode, "cf_challenge", "Cloudflare challenge detected")
|
||||
s.emitSoraProbeSummary(c, recorder)
|
||||
s.logSoraCloudflareChallenge(account, proxyURL, soraMeAPIURL, resp.Header, body)
|
||||
return s.sendErrorAndEnd(c, formatCloudflareChallengeMessage(fmt.Sprintf("Sora request blocked by Cloudflare challenge (HTTP %d). Please switch to a clean proxy/network and retry.", resp.StatusCode), resp.Header, body))
|
||||
}
|
||||
upstreamCode, upstreamMessage := soraerror.ExtractUpstreamErrorCodeAndMessage(body)
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusUnauthorized && strings.EqualFold(upstreamCode, "token_invalidated"):
|
||||
recorder.addStep("me", "failed", resp.StatusCode, "token_invalidated", "Sora token invalidated")
|
||||
s.emitSoraProbeSummary(c, recorder)
|
||||
return s.sendErrorAndEnd(c, "Sora token 已失效(token_invalidated),请重新授权账号")
|
||||
case strings.EqualFold(upstreamCode, "unsupported_country_code"):
|
||||
recorder.addStep("me", "failed", resp.StatusCode, "unsupported_country_code", "Sora is unavailable in current egress region")
|
||||
s.emitSoraProbeSummary(c, recorder)
|
||||
return s.sendErrorAndEnd(c, "Sora 在当前网络出口地区不可用(unsupported_country_code),请切换到支持地区后重试")
|
||||
case strings.TrimSpace(upstreamMessage) != "":
|
||||
recorder.addStep("me", "failed", resp.StatusCode, upstreamCode, upstreamMessage)
|
||||
s.emitSoraProbeSummary(c, recorder)
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Sora API returned %d: %s", resp.StatusCode, upstreamMessage))
|
||||
default:
|
||||
recorder.addStep("me", "failed", resp.StatusCode, upstreamCode, "Sora me endpoint failed")
|
||||
s.emitSoraProbeSummary(c, recorder)
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Sora API returned %d: %s", resp.StatusCode, truncateSoraErrorBody(body, 512)))
|
||||
}
|
||||
}
|
||||
recorder.addStep("me", "success", resp.StatusCode, "", "me endpoint ok")
|
||||
|
||||
// 解析 /me 响应,提取用户信息
|
||||
var meResp map[string]any
|
||||
if err := json.Unmarshal(body, &meResp); err != nil {
|
||||
// 能收到 200 就说明 token 有效
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: "Sora connection OK (token valid)"})
|
||||
} else {
|
||||
// 尝试提取用户名或邮箱信息
|
||||
info := "Sora connection OK"
|
||||
if name, ok := meResp["name"].(string); ok && name != "" {
|
||||
info = fmt.Sprintf("Sora connection OK - User: %s", name)
|
||||
} else if email, ok := meResp["email"].(string); ok && email != "" {
|
||||
info = fmt.Sprintf("Sora connection OK - Email: %s", email)
|
||||
}
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: info})
|
||||
}
|
||||
|
||||
// 追加轻量能力检查:订阅信息查询(失败仅告警,不中断连接测试)
|
||||
subReq, err := http.NewRequestWithContext(ctx, "GET", soraBillingAPIURL, nil)
|
||||
if err == nil {
|
||||
subReq.Header.Set("Authorization", "Bearer "+authToken)
|
||||
subReq.Header.Set("User-Agent", "Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)")
|
||||
subReq.Header.Set("Accept", "application/json")
|
||||
subReq.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
subReq.Header.Set("Origin", "https://sora.chatgpt.com")
|
||||
subReq.Header.Set("Referer", "https://sora.chatgpt.com/")
|
||||
|
||||
subResp, subErr := s.httpUpstream.DoWithTLS(subReq, proxyURL, account.ID, account.Concurrency, enableSoraTLSFingerprint)
|
||||
if subErr != nil {
|
||||
recorder.addStep("subscription", "failed", 0, "network_error", subErr.Error())
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("Subscription check skipped: %s", subErr.Error())})
|
||||
} else {
|
||||
subBody, _ := io.ReadAll(subResp.Body)
|
||||
_ = subResp.Body.Close()
|
||||
if subResp.StatusCode == http.StatusOK {
|
||||
recorder.addStep("subscription", "success", subResp.StatusCode, "", "subscription endpoint ok")
|
||||
if summary := parseSoraSubscriptionSummary(subBody); summary != "" {
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: summary})
|
||||
} else {
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: "Subscription check OK"})
|
||||
}
|
||||
} else {
|
||||
if isCloudflareChallengeResponse(subResp.StatusCode, subResp.Header, subBody) {
|
||||
recorder.addStep("subscription", "failed", subResp.StatusCode, "cf_challenge", "Cloudflare challenge detected")
|
||||
s.logSoraCloudflareChallenge(account, proxyURL, soraBillingAPIURL, subResp.Header, subBody)
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: formatCloudflareChallengeMessage(fmt.Sprintf("Subscription check blocked by Cloudflare challenge (HTTP %d)", subResp.StatusCode), subResp.Header, subBody)})
|
||||
} else {
|
||||
upstreamCode, upstreamMessage := soraerror.ExtractUpstreamErrorCodeAndMessage(subBody)
|
||||
recorder.addStep("subscription", "failed", subResp.StatusCode, upstreamCode, upstreamMessage)
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("Subscription check returned %d", subResp.StatusCode)})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 追加 Sora2 能力探测(对齐 sora2api 的测试思路):邀请码 + 剩余额度。
|
||||
s.testSora2Capabilities(c, ctx, account, authToken, proxyURL, enableSoraTLSFingerprint, recorder)
|
||||
|
||||
s.emitSoraProbeSummary(c, recorder)
|
||||
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AccountTestService) testSora2Capabilities(
|
||||
c *gin.Context,
|
||||
ctx context.Context,
|
||||
account *Account,
|
||||
authToken string,
|
||||
proxyURL string,
|
||||
enableTLSFingerprint bool,
|
||||
recorder *soraProbeRecorder,
|
||||
) {
|
||||
inviteStatus, inviteHeader, inviteBody, err := s.fetchSoraTestEndpoint(
|
||||
ctx,
|
||||
account,
|
||||
authToken,
|
||||
soraInviteMineURL,
|
||||
proxyURL,
|
||||
enableTLSFingerprint,
|
||||
)
|
||||
if err != nil {
|
||||
if recorder != nil {
|
||||
recorder.addStep("sora2_invite", "failed", 0, "network_error", err.Error())
|
||||
}
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("Sora2 invite check skipped: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
if inviteStatus == http.StatusUnauthorized {
|
||||
bootstrapStatus, _, _, bootstrapErr := s.fetchSoraTestEndpoint(
|
||||
ctx,
|
||||
account,
|
||||
authToken,
|
||||
soraBootstrapURL,
|
||||
proxyURL,
|
||||
enableTLSFingerprint,
|
||||
)
|
||||
if bootstrapErr == nil && bootstrapStatus == http.StatusOK {
|
||||
if recorder != nil {
|
||||
recorder.addStep("sora2_bootstrap", "success", bootstrapStatus, "", "bootstrap endpoint ok")
|
||||
}
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: "Sora2 bootstrap OK, retry invite check"})
|
||||
inviteStatus, inviteHeader, inviteBody, err = s.fetchSoraTestEndpoint(
|
||||
ctx,
|
||||
account,
|
||||
authToken,
|
||||
soraInviteMineURL,
|
||||
proxyURL,
|
||||
enableTLSFingerprint,
|
||||
)
|
||||
if err != nil {
|
||||
if recorder != nil {
|
||||
recorder.addStep("sora2_invite", "failed", 0, "network_error", err.Error())
|
||||
}
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("Sora2 invite retry failed: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
} else if recorder != nil {
|
||||
code := ""
|
||||
msg := ""
|
||||
if bootstrapErr != nil {
|
||||
code = "network_error"
|
||||
msg = bootstrapErr.Error()
|
||||
}
|
||||
recorder.addStep("sora2_bootstrap", "failed", bootstrapStatus, code, msg)
|
||||
}
|
||||
}
|
||||
|
||||
if inviteStatus != http.StatusOK {
|
||||
if isCloudflareChallengeResponse(inviteStatus, inviteHeader, inviteBody) {
|
||||
if recorder != nil {
|
||||
recorder.addStep("sora2_invite", "failed", inviteStatus, "cf_challenge", "Cloudflare challenge detected")
|
||||
}
|
||||
s.logSoraCloudflareChallenge(account, proxyURL, soraInviteMineURL, inviteHeader, inviteBody)
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: formatCloudflareChallengeMessage(fmt.Sprintf("Sora2 invite check blocked by Cloudflare challenge (HTTP %d)", inviteStatus), inviteHeader, inviteBody)})
|
||||
return
|
||||
}
|
||||
upstreamCode, upstreamMessage := soraerror.ExtractUpstreamErrorCodeAndMessage(inviteBody)
|
||||
if recorder != nil {
|
||||
recorder.addStep("sora2_invite", "failed", inviteStatus, upstreamCode, upstreamMessage)
|
||||
}
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("Sora2 invite check returned %d", inviteStatus)})
|
||||
return
|
||||
}
|
||||
if recorder != nil {
|
||||
recorder.addStep("sora2_invite", "success", inviteStatus, "", "invite endpoint ok")
|
||||
}
|
||||
|
||||
if summary := parseSoraInviteSummary(inviteBody); summary != "" {
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: summary})
|
||||
} else {
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: "Sora2 invite check OK"})
|
||||
}
|
||||
|
||||
remainingStatus, remainingHeader, remainingBody, remainingErr := s.fetchSoraTestEndpoint(
|
||||
ctx,
|
||||
account,
|
||||
authToken,
|
||||
soraRemainingURL,
|
||||
proxyURL,
|
||||
enableTLSFingerprint,
|
||||
)
|
||||
if remainingErr != nil {
|
||||
if recorder != nil {
|
||||
recorder.addStep("sora2_remaining", "failed", 0, "network_error", remainingErr.Error())
|
||||
}
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("Sora2 remaining check skipped: %s", remainingErr.Error())})
|
||||
return
|
||||
}
|
||||
if remainingStatus != http.StatusOK {
|
||||
if isCloudflareChallengeResponse(remainingStatus, remainingHeader, remainingBody) {
|
||||
if recorder != nil {
|
||||
recorder.addStep("sora2_remaining", "failed", remainingStatus, "cf_challenge", "Cloudflare challenge detected")
|
||||
}
|
||||
s.logSoraCloudflareChallenge(account, proxyURL, soraRemainingURL, remainingHeader, remainingBody)
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: formatCloudflareChallengeMessage(fmt.Sprintf("Sora2 remaining check blocked by Cloudflare challenge (HTTP %d)", remainingStatus), remainingHeader, remainingBody)})
|
||||
return
|
||||
}
|
||||
upstreamCode, upstreamMessage := soraerror.ExtractUpstreamErrorCodeAndMessage(remainingBody)
|
||||
if recorder != nil {
|
||||
recorder.addStep("sora2_remaining", "failed", remainingStatus, upstreamCode, upstreamMessage)
|
||||
}
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("Sora2 remaining check returned %d", remainingStatus)})
|
||||
return
|
||||
}
|
||||
if recorder != nil {
|
||||
recorder.addStep("sora2_remaining", "success", remainingStatus, "", "remaining endpoint ok")
|
||||
}
|
||||
if summary := parseSoraRemainingSummary(remainingBody); summary != "" {
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: summary})
|
||||
} else {
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: "Sora2 remaining check OK"})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AccountTestService) fetchSoraTestEndpoint(
|
||||
ctx context.Context,
|
||||
account *Account,
|
||||
authToken string,
|
||||
url string,
|
||||
proxyURL string,
|
||||
enableTLSFingerprint bool,
|
||||
) (int, http.Header, []byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+authToken)
|
||||
req.Header.Set("User-Agent", "Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
req.Header.Set("Origin", "https://sora.chatgpt.com")
|
||||
req.Header.Set("Referer", "https://sora.chatgpt.com/")
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, enableTLSFingerprint)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return resp.StatusCode, resp.Header, nil, readErr
|
||||
}
|
||||
return resp.StatusCode, resp.Header, body, nil
|
||||
}
|
||||
|
||||
func parseSoraSubscriptionSummary(body []byte) string {
|
||||
var subResp struct {
|
||||
Data []struct {
|
||||
Plan struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
} `json:"plan"`
|
||||
EndTS string `json:"end_ts"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &subResp); err != nil {
|
||||
return ""
|
||||
}
|
||||
if len(subResp.Data) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
first := subResp.Data[0]
|
||||
parts := make([]string, 0, 3)
|
||||
if first.Plan.Title != "" {
|
||||
parts = append(parts, first.Plan.Title)
|
||||
}
|
||||
if first.Plan.ID != "" {
|
||||
parts = append(parts, first.Plan.ID)
|
||||
}
|
||||
if first.EndTS != "" {
|
||||
parts = append(parts, "end="+first.EndTS)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "Subscription: " + strings.Join(parts, " | ")
|
||||
}
|
||||
|
||||
func parseSoraInviteSummary(body []byte) string {
|
||||
var inviteResp struct {
|
||||
InviteCode string `json:"invite_code"`
|
||||
RedeemedCount int64 `json:"redeemed_count"`
|
||||
TotalCount int64 `json:"total_count"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &inviteResp); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := []string{"Sora2: supported"}
|
||||
if inviteResp.InviteCode != "" {
|
||||
parts = append(parts, "invite="+inviteResp.InviteCode)
|
||||
}
|
||||
if inviteResp.TotalCount > 0 {
|
||||
parts = append(parts, fmt.Sprintf("used=%d/%d", inviteResp.RedeemedCount, inviteResp.TotalCount))
|
||||
}
|
||||
return strings.Join(parts, " | ")
|
||||
}
|
||||
|
||||
func parseSoraRemainingSummary(body []byte) string {
|
||||
var remainingResp struct {
|
||||
RateLimitAndCreditBalance struct {
|
||||
EstimatedNumVideosRemaining int64 `json:"estimated_num_videos_remaining"`
|
||||
RateLimitReached bool `json:"rate_limit_reached"`
|
||||
AccessResetsInSeconds int64 `json:"access_resets_in_seconds"`
|
||||
} `json:"rate_limit_and_credit_balance"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &remainingResp); err != nil {
|
||||
return ""
|
||||
}
|
||||
info := remainingResp.RateLimitAndCreditBalance
|
||||
parts := []string{fmt.Sprintf("Sora2 remaining: %d", info.EstimatedNumVideosRemaining)}
|
||||
if info.RateLimitReached {
|
||||
parts = append(parts, "rate_limited=true")
|
||||
}
|
||||
if info.AccessResetsInSeconds > 0 {
|
||||
parts = append(parts, fmt.Sprintf("reset_in=%ds", info.AccessResetsInSeconds))
|
||||
}
|
||||
return strings.Join(parts, " | ")
|
||||
}
|
||||
|
||||
func (s *AccountTestService) shouldEnableSoraTLSFingerprint() bool {
|
||||
if s == nil || s.cfg == nil {
|
||||
return true
|
||||
}
|
||||
return !s.cfg.Sora.Client.DisableTLSFingerprint
|
||||
}
|
||||
|
||||
func isCloudflareChallengeResponse(statusCode int, headers http.Header, body []byte) bool {
|
||||
return soraerror.IsCloudflareChallengeResponse(statusCode, headers, body)
|
||||
}
|
||||
|
||||
func formatCloudflareChallengeMessage(base string, headers http.Header, body []byte) string {
|
||||
return soraerror.FormatCloudflareChallengeMessage(base, headers, body)
|
||||
}
|
||||
|
||||
func extractCloudflareRayID(headers http.Header, body []byte) string {
|
||||
return soraerror.ExtractCloudflareRayID(headers, body)
|
||||
}
|
||||
|
||||
func extractSoraEgressIPHint(headers http.Header) string {
|
||||
if headers == nil {
|
||||
return "unknown"
|
||||
}
|
||||
candidates := []string{
|
||||
"x-openai-public-ip",
|
||||
"x-envoy-external-address",
|
||||
"cf-connecting-ip",
|
||||
"x-forwarded-for",
|
||||
}
|
||||
for _, key := range candidates {
|
||||
if value := strings.TrimSpace(headers.Get(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func sanitizeProxyURLForLog(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "<invalid_proxy_url>"
|
||||
}
|
||||
if u.User != nil {
|
||||
u.User = nil
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func endpointPathForLog(endpoint string) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(endpoint))
|
||||
if err != nil || parsed.Path == "" {
|
||||
return endpoint
|
||||
}
|
||||
return parsed.Path
|
||||
}
|
||||
|
||||
func (s *AccountTestService) logSoraCloudflareChallenge(account *Account, proxyURL, endpoint string, headers http.Header, body []byte) {
|
||||
accountID := int64(0)
|
||||
platform := ""
|
||||
proxyID := "none"
|
||||
if account != nil {
|
||||
accountID = account.ID
|
||||
platform = account.Platform
|
||||
if account.ProxyID != nil {
|
||||
proxyID = fmt.Sprintf("%d", *account.ProxyID)
|
||||
}
|
||||
}
|
||||
cfRay := extractCloudflareRayID(headers, body)
|
||||
if cfRay == "" {
|
||||
cfRay = "unknown"
|
||||
}
|
||||
log.Printf(
|
||||
"[SoraCFChallenge] account_id=%d platform=%s endpoint=%s path=%s proxy_id=%s proxy_url=%s cf_ray=%s egress_ip_hint=%s",
|
||||
accountID,
|
||||
platform,
|
||||
endpoint,
|
||||
endpointPathForLog(endpoint),
|
||||
proxyID,
|
||||
sanitizeProxyURLForLog(proxyURL),
|
||||
cfRay,
|
||||
extractSoraEgressIPHint(headers),
|
||||
)
|
||||
}
|
||||
|
||||
func truncateSoraErrorBody(body []byte, max int) string {
|
||||
return soraerror.TruncateBody(body, max)
|
||||
}
|
||||
|
||||
// testAntigravityAccountConnection tests an Antigravity account's connection
|
||||
// 支持 Claude 和 Gemini 两种协议,使用非流式请求
|
||||
func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, account *Account, modelID string) error {
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type queuedHTTPUpstream struct {
|
||||
responses []*http.Response
|
||||
requests []*http.Request
|
||||
tlsFlags []bool
|
||||
}
|
||||
|
||||
func (u *queuedHTTPUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("unexpected Do call")
|
||||
}
|
||||
|
||||
func (u *queuedHTTPUpstream) DoWithTLS(req *http.Request, _ string, _ int64, _ int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
u.requests = append(u.requests, req)
|
||||
u.tlsFlags = append(u.tlsFlags, enableTLSFingerprint)
|
||||
if len(u.responses) == 0 {
|
||||
return nil, fmt.Errorf("no mocked response")
|
||||
}
|
||||
resp := u.responses[0]
|
||||
u.responses = u.responses[1:]
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func newJSONResponse(status int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func newJSONResponseWithHeader(status int, body, key, value string) *http.Response {
|
||||
resp := newJSONResponse(status, body)
|
||||
resp.Header.Set(key, value)
|
||||
return resp
|
||||
}
|
||||
|
||||
func newSoraTestContext() (*gin.Context, *httptest.ResponseRecorder) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/1/test", nil)
|
||||
return c, rec
|
||||
}
|
||||
|
||||
func TestAccountTestService_testSoraAccountConnection_WithSubscription(t *testing.T) {
|
||||
upstream := &queuedHTTPUpstream{
|
||||
responses: []*http.Response{
|
||||
newJSONResponse(http.StatusOK, `{"email":"demo@example.com"}`),
|
||||
newJSONResponse(http.StatusOK, `{"data":[{"plan":{"id":"chatgpt_plus","title":"ChatGPT Plus"},"end_ts":"2026-12-31T00:00:00Z"}]}`),
|
||||
newJSONResponse(http.StatusOK, `{"invite_code":"inv_abc","redeemed_count":3,"total_count":50}`),
|
||||
newJSONResponse(http.StatusOK, `{"rate_limit_and_credit_balance":{"estimated_num_videos_remaining":27,"rate_limit_reached":false,"access_resets_in_seconds":46833}}`),
|
||||
},
|
||||
}
|
||||
svc := &AccountTestService{
|
||||
httpUpstream: upstream,
|
||||
cfg: &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
TLSFingerprint: config.TLSFingerprintConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
Sora: config.SoraConfig{
|
||||
Client: config.SoraClientConfig{
|
||||
DisableTLSFingerprint: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Platform: PlatformSora,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "test_token",
|
||||
},
|
||||
}
|
||||
|
||||
c, rec := newSoraTestContext()
|
||||
err := svc.testSoraAccountConnection(c, account)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, upstream.requests, 4)
|
||||
require.Equal(t, soraMeAPIURL, upstream.requests[0].URL.String())
|
||||
require.Equal(t, soraBillingAPIURL, upstream.requests[1].URL.String())
|
||||
require.Equal(t, soraInviteMineURL, upstream.requests[2].URL.String())
|
||||
require.Equal(t, soraRemainingURL, upstream.requests[3].URL.String())
|
||||
require.Equal(t, "Bearer test_token", upstream.requests[0].Header.Get("Authorization"))
|
||||
require.Equal(t, "Bearer test_token", upstream.requests[1].Header.Get("Authorization"))
|
||||
require.Equal(t, []bool{true, true, true, true}, upstream.tlsFlags)
|
||||
|
||||
body := rec.Body.String()
|
||||
require.Contains(t, body, `"type":"test_start"`)
|
||||
require.Contains(t, body, "Sora connection OK - Email: demo@example.com")
|
||||
require.Contains(t, body, "Subscription: ChatGPT Plus | chatgpt_plus | end=2026-12-31T00:00:00Z")
|
||||
require.Contains(t, body, "Sora2: supported | invite=inv_abc | used=3/50")
|
||||
require.Contains(t, body, "Sora2 remaining: 27 | reset_in=46833s")
|
||||
require.Contains(t, body, `"type":"sora_test_result"`)
|
||||
require.Contains(t, body, `"status":"success"`)
|
||||
require.Contains(t, body, `"type":"test_complete","success":true`)
|
||||
}
|
||||
|
||||
func TestAccountTestService_testSoraAccountConnection_SubscriptionFailedStillSuccess(t *testing.T) {
|
||||
upstream := &queuedHTTPUpstream{
|
||||
responses: []*http.Response{
|
||||
newJSONResponse(http.StatusOK, `{"name":"demo-user"}`),
|
||||
newJSONResponse(http.StatusForbidden, `{"error":{"message":"forbidden"}}`),
|
||||
newJSONResponse(http.StatusUnauthorized, `{"error":{"message":"Unauthorized"}}`),
|
||||
newJSONResponse(http.StatusForbidden, `{"error":{"message":"forbidden"}}`),
|
||||
},
|
||||
}
|
||||
svc := &AccountTestService{httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Platform: PlatformSora,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "test_token",
|
||||
},
|
||||
}
|
||||
|
||||
c, rec := newSoraTestContext()
|
||||
err := svc.testSoraAccountConnection(c, account)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, upstream.requests, 4)
|
||||
body := rec.Body.String()
|
||||
require.Contains(t, body, "Sora connection OK - User: demo-user")
|
||||
require.Contains(t, body, "Subscription check returned 403")
|
||||
require.Contains(t, body, "Sora2 invite check returned 401")
|
||||
require.Contains(t, body, `"type":"sora_test_result"`)
|
||||
require.Contains(t, body, `"status":"partial_success"`)
|
||||
require.Contains(t, body, `"type":"test_complete","success":true`)
|
||||
}
|
||||
|
||||
func TestAccountTestService_testSoraAccountConnection_CloudflareChallenge(t *testing.T) {
|
||||
upstream := &queuedHTTPUpstream{
|
||||
responses: []*http.Response{
|
||||
newJSONResponseWithHeader(http.StatusForbidden, `<!DOCTYPE html><html><head><title>Just a moment...</title></head><body><script>window._cf_chl_opt={};</script><noscript>Enable JavaScript and cookies to continue</noscript></body></html>`, "cf-ray", "9cff2d62d83bb98d"),
|
||||
},
|
||||
}
|
||||
svc := &AccountTestService{httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Platform: PlatformSora,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "test_token",
|
||||
},
|
||||
}
|
||||
|
||||
c, rec := newSoraTestContext()
|
||||
err := svc.testSoraAccountConnection(c, account)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "Cloudflare challenge")
|
||||
require.Contains(t, err.Error(), "cf-ray: 9cff2d62d83bb98d")
|
||||
body := rec.Body.String()
|
||||
require.Contains(t, body, `"type":"error"`)
|
||||
require.Contains(t, body, "Cloudflare challenge")
|
||||
require.Contains(t, body, "cf-ray: 9cff2d62d83bb98d")
|
||||
}
|
||||
|
||||
func TestAccountTestService_testSoraAccountConnection_CloudflareChallenge429WithHeader(t *testing.T) {
|
||||
upstream := &queuedHTTPUpstream{
|
||||
responses: []*http.Response{
|
||||
newJSONResponseWithHeader(http.StatusTooManyRequests, `<!DOCTYPE html><html><head><title>Just a moment...</title></head><body></body></html>`, "cf-mitigated", "challenge"),
|
||||
},
|
||||
}
|
||||
svc := &AccountTestService{httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Platform: PlatformSora,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "test_token",
|
||||
},
|
||||
}
|
||||
|
||||
c, rec := newSoraTestContext()
|
||||
err := svc.testSoraAccountConnection(c, account)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "Cloudflare challenge")
|
||||
require.Contains(t, err.Error(), "HTTP 429")
|
||||
body := rec.Body.String()
|
||||
require.Contains(t, body, "Cloudflare challenge")
|
||||
}
|
||||
|
||||
func TestAccountTestService_testSoraAccountConnection_TokenInvalidated(t *testing.T) {
|
||||
upstream := &queuedHTTPUpstream{
|
||||
responses: []*http.Response{
|
||||
newJSONResponse(http.StatusUnauthorized, `{"error":{"code":"token_invalidated","message":"Token invalid"}}`),
|
||||
},
|
||||
}
|
||||
svc := &AccountTestService{httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Platform: PlatformSora,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "test_token",
|
||||
},
|
||||
}
|
||||
|
||||
c, rec := newSoraTestContext()
|
||||
err := svc.testSoraAccountConnection(c, account)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "token_invalidated")
|
||||
body := rec.Body.String()
|
||||
require.Contains(t, body, `"type":"sora_test_result"`)
|
||||
require.Contains(t, body, `"status":"failed"`)
|
||||
require.Contains(t, body, "token_invalidated")
|
||||
require.NotContains(t, body, `"type":"test_complete","success":true`)
|
||||
}
|
||||
|
||||
func TestAccountTestService_testSoraAccountConnection_RateLimited(t *testing.T) {
|
||||
upstream := &queuedHTTPUpstream{
|
||||
responses: []*http.Response{
|
||||
newJSONResponse(http.StatusOK, `{"email":"demo@example.com"}`),
|
||||
},
|
||||
}
|
||||
svc := &AccountTestService{
|
||||
httpUpstream: upstream,
|
||||
soraTestCooldown: time.Hour,
|
||||
}
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Platform: PlatformSora,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "test_token",
|
||||
},
|
||||
}
|
||||
|
||||
c1, _ := newSoraTestContext()
|
||||
err := svc.testSoraAccountConnection(c1, account)
|
||||
require.NoError(t, err)
|
||||
|
||||
c2, rec2 := newSoraTestContext()
|
||||
err = svc.testSoraAccountConnection(c2, account)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "测试过于频繁")
|
||||
body := rec2.Body.String()
|
||||
require.Contains(t, body, `"type":"sora_test_result"`)
|
||||
require.Contains(t, body, `"code":"test_rate_limited"`)
|
||||
require.Contains(t, body, `"status":"failed"`)
|
||||
require.NotContains(t, body, `"type":"test_complete","success":true`)
|
||||
}
|
||||
|
||||
func TestAccountTestService_testSoraAccountConnection_SubscriptionCloudflareChallengeWithRay(t *testing.T) {
|
||||
upstream := &queuedHTTPUpstream{
|
||||
responses: []*http.Response{
|
||||
newJSONResponse(http.StatusOK, `{"name":"demo-user"}`),
|
||||
newJSONResponse(http.StatusForbidden, `<!DOCTYPE html><html><head><title>Just a moment...</title></head><body><script>window._cf_chl_opt={cRay: '9cff2d62d83bb98d'};</script><noscript>Enable JavaScript and cookies to continue</noscript></body></html>`),
|
||||
newJSONResponse(http.StatusForbidden, `<!DOCTYPE html><html><head><title>Just a moment...</title></head><body><script>window._cf_chl_opt={cRay: '9cff2d62d83bb98d'};</script><noscript>Enable JavaScript and cookies to continue</noscript></body></html>`),
|
||||
},
|
||||
}
|
||||
svc := &AccountTestService{httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Platform: PlatformSora,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "test_token",
|
||||
},
|
||||
}
|
||||
|
||||
c, rec := newSoraTestContext()
|
||||
err := svc.testSoraAccountConnection(c, account)
|
||||
|
||||
require.NoError(t, err)
|
||||
body := rec.Body.String()
|
||||
require.Contains(t, body, "Subscription check blocked by Cloudflare challenge (HTTP 403)")
|
||||
require.Contains(t, body, "Sora2 invite check blocked by Cloudflare challenge (HTTP 403)")
|
||||
require.Contains(t, body, "cf-ray: 9cff2d62d83bb98d")
|
||||
require.Contains(t, body, `"type":"test_complete","success":true`)
|
||||
}
|
||||
|
||||
func TestSanitizeProxyURLForLog(t *testing.T) {
|
||||
require.Equal(t, "http://proxy.example.com:8080", sanitizeProxyURLForLog("http://user:pass@proxy.example.com:8080"))
|
||||
require.Equal(t, "", sanitizeProxyURLForLog(""))
|
||||
require.Equal(t, "<invalid_proxy_url>", sanitizeProxyURLForLog("://invalid"))
|
||||
}
|
||||
|
||||
func TestExtractSoraEgressIPHint(t *testing.T) {
|
||||
h := make(http.Header)
|
||||
h.Set("x-openai-public-ip", "203.0.113.10")
|
||||
require.Equal(t, "203.0.113.10", extractSoraEgressIPHint(h))
|
||||
|
||||
h2 := make(http.Header)
|
||||
h2.Set("x-envoy-external-address", "198.51.100.9")
|
||||
require.Equal(t, "198.51.100.9", extractSoraEgressIPHint(h2))
|
||||
|
||||
require.Equal(t, "unknown", extractSoraEgressIPHint(nil))
|
||||
require.Equal(t, "unknown", extractSoraEgressIPHint(http.Header{}))
|
||||
}
|
||||
@@ -4,11 +4,14 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type UsageLogRepository interface {
|
||||
@@ -32,12 +35,13 @@ type UsageLogRepository interface {
|
||||
|
||||
// Admin dashboard stats
|
||||
GetDashboardStats(ctx context.Context) (*usagestats.DashboardStats, error)
|
||||
GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, stream *bool, billingType *int8) ([]usagestats.TrendDataPoint, error)
|
||||
GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, stream *bool, billingType *int8) ([]usagestats.ModelStat, error)
|
||||
GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]usagestats.TrendDataPoint, error)
|
||||
GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.ModelStat, error)
|
||||
GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error)
|
||||
GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error)
|
||||
GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error)
|
||||
GetBatchUserUsageStats(ctx context.Context, userIDs []int64) (map[int64]*usagestats.BatchUserUsageStats, error)
|
||||
GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*usagestats.BatchAPIKeyUsageStats, error)
|
||||
GetBatchUserUsageStats(ctx context.Context, userIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchUserUsageStats, error)
|
||||
GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchAPIKeyUsageStats, error)
|
||||
|
||||
// User dashboard stats
|
||||
GetUserDashboardStats(ctx context.Context, userID int64) (*usagestats.UserDashboardStats, error)
|
||||
@@ -61,6 +65,10 @@ type UsageLogRepository interface {
|
||||
GetDailyStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) ([]map[string]any, error)
|
||||
}
|
||||
|
||||
type accountWindowStatsBatchReader interface {
|
||||
GetAccountWindowStatsBatch(ctx context.Context, accountIDs []int64, startTime time.Time) (map[int64]*usagestats.AccountStats, error)
|
||||
}
|
||||
|
||||
// apiUsageCache 缓存从 Anthropic API 获取的使用率数据(utilization, resets_at)
|
||||
type apiUsageCache struct {
|
||||
response *ClaudeUsageResponse
|
||||
@@ -217,12 +225,20 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U
|
||||
}
|
||||
|
||||
if account.Platform == PlatformGemini {
|
||||
return s.getGeminiUsage(ctx, account)
|
||||
usage, err := s.getGeminiUsage(ctx, account)
|
||||
if err == nil {
|
||||
s.tryClearRecoverableAccountError(ctx, account)
|
||||
}
|
||||
return usage, err
|
||||
}
|
||||
|
||||
// Antigravity 平台:使用 AntigravityQuotaFetcher 获取额度
|
||||
if account.Platform == PlatformAntigravity {
|
||||
return s.getAntigravityUsage(ctx, account)
|
||||
usage, err := s.getAntigravityUsage(ctx, account)
|
||||
if err == nil {
|
||||
s.tryClearRecoverableAccountError(ctx, account)
|
||||
}
|
||||
return usage, err
|
||||
}
|
||||
|
||||
// 只有oauth类型账号可以通过API获取usage(有profile scope)
|
||||
@@ -256,6 +272,7 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U
|
||||
// 4. 添加窗口统计(有独立缓存,1 分钟)
|
||||
s.addWindowStats(ctx, account, usage)
|
||||
|
||||
s.tryClearRecoverableAccountError(ctx, account)
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
@@ -287,7 +304,7 @@ func (s *AccountUsageService) getGeminiUsage(ctx context.Context, account *Accou
|
||||
}
|
||||
|
||||
dayStart := geminiDailyWindowStart(now)
|
||||
stats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, dayStart, now, 0, 0, account.ID, 0, nil, nil)
|
||||
stats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, dayStart, now, 0, 0, account.ID, 0, nil, nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get gemini usage stats failed: %w", err)
|
||||
}
|
||||
@@ -309,7 +326,7 @@ func (s *AccountUsageService) getGeminiUsage(ctx context.Context, account *Accou
|
||||
// Minute window (RPM) - fixed-window approximation: current minute [truncate(now), truncate(now)+1m)
|
||||
minuteStart := now.Truncate(time.Minute)
|
||||
minuteResetAt := minuteStart.Add(time.Minute)
|
||||
minuteStats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, minuteStart, now, 0, 0, account.ID, 0, nil, nil)
|
||||
minuteStats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, minuteStart, now, 0, 0, account.ID, 0, nil, nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get gemini minute usage stats failed: %w", err)
|
||||
}
|
||||
@@ -430,6 +447,78 @@ func (s *AccountUsageService) GetTodayStats(ctx context.Context, accountID int64
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetTodayStatsBatch 批量获取账号今日统计,优先走批量 SQL,失败时回退单账号查询。
|
||||
func (s *AccountUsageService) GetTodayStatsBatch(ctx context.Context, accountIDs []int64) (map[int64]*WindowStats, error) {
|
||||
uniqueIDs := make([]int64, 0, len(accountIDs))
|
||||
seen := make(map[int64]struct{}, len(accountIDs))
|
||||
for _, accountID := range accountIDs {
|
||||
if accountID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[accountID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[accountID] = struct{}{}
|
||||
uniqueIDs = append(uniqueIDs, accountID)
|
||||
}
|
||||
|
||||
result := make(map[int64]*WindowStats, len(uniqueIDs))
|
||||
if len(uniqueIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
startTime := timezone.Today()
|
||||
if batchReader, ok := s.usageLogRepo.(accountWindowStatsBatchReader); ok {
|
||||
statsByAccount, err := batchReader.GetAccountWindowStatsBatch(ctx, uniqueIDs, startTime)
|
||||
if err == nil {
|
||||
for _, accountID := range uniqueIDs {
|
||||
result[accountID] = windowStatsFromAccountStats(statsByAccount[accountID])
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
g, gctx := errgroup.WithContext(ctx)
|
||||
g.SetLimit(8)
|
||||
|
||||
for _, accountID := range uniqueIDs {
|
||||
id := accountID
|
||||
g.Go(func() error {
|
||||
stats, err := s.usageLogRepo.GetAccountWindowStats(gctx, id, startTime)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
mu.Lock()
|
||||
result[id] = windowStatsFromAccountStats(stats)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
_ = g.Wait()
|
||||
|
||||
for _, accountID := range uniqueIDs {
|
||||
if _, ok := result[accountID]; !ok {
|
||||
result[accountID] = &WindowStats{}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func windowStatsFromAccountStats(stats *usagestats.AccountStats) *WindowStats {
|
||||
if stats == nil {
|
||||
return &WindowStats{}
|
||||
}
|
||||
return &WindowStats{
|
||||
Requests: stats.Requests,
|
||||
Tokens: stats.Tokens,
|
||||
Cost: stats.Cost,
|
||||
StandardCost: stats.StandardCost,
|
||||
UserCost: stats.UserCost,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AccountUsageService) GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.AccountUsageStatsResponse, error) {
|
||||
stats, err := s.usageLogRepo.GetAccountUsageStats(ctx, accountID, startTime, endTime)
|
||||
if err != nil {
|
||||
@@ -486,6 +575,32 @@ func parseTime(s string) (time.Time, error) {
|
||||
return time.Time{}, fmt.Errorf("unable to parse time: %s", s)
|
||||
}
|
||||
|
||||
func (s *AccountUsageService) tryClearRecoverableAccountError(ctx context.Context, account *Account) {
|
||||
if account == nil || account.Status != StatusError {
|
||||
return
|
||||
}
|
||||
|
||||
msg := strings.ToLower(strings.TrimSpace(account.ErrorMessage))
|
||||
if msg == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.Contains(msg, "token refresh failed") &&
|
||||
!strings.Contains(msg, "invalid_client") &&
|
||||
!strings.Contains(msg, "missing_project_id") &&
|
||||
!strings.Contains(msg, "unauthenticated") {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.accountRepo.ClearError(ctx, account.ID); err != nil {
|
||||
log.Printf("[usage] failed to clear recoverable account error for account %d: %v", account.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
account.Status = StatusActive
|
||||
account.ErrorMessage = ""
|
||||
}
|
||||
|
||||
// buildUsageInfo 构建UsageInfo
|
||||
func (s *AccountUsageService) buildUsageInfo(resp *ClaudeUsageResponse, updatedAt *time.Time) *UsageInfo {
|
||||
info := &UsageInfo{
|
||||
|
||||
@@ -267,3 +267,119 @@ func TestAccountGetMappedModel(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountGetModelMapping_AntigravityEnsuresGeminiDefaultPassthroughs(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"gemini-3-pro-high": "gemini-3.1-pro-high",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mapping := account.GetModelMapping()
|
||||
if mapping["gemini-3-flash"] != "gemini-3-flash" {
|
||||
t.Fatalf("expected gemini-3-flash passthrough to be auto-filled, got: %q", mapping["gemini-3-flash"])
|
||||
}
|
||||
if mapping["gemini-3.1-pro-high"] != "gemini-3.1-pro-high" {
|
||||
t.Fatalf("expected gemini-3.1-pro-high passthrough to be auto-filled, got: %q", mapping["gemini-3.1-pro-high"])
|
||||
}
|
||||
if mapping["gemini-3.1-pro-low"] != "gemini-3.1-pro-low" {
|
||||
t.Fatalf("expected gemini-3.1-pro-low passthrough to be auto-filled, got: %q", mapping["gemini-3.1-pro-low"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountGetModelMapping_AntigravityRespectsWildcardOverride(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"gemini-3*": "gemini-3.1-pro-high",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mapping := account.GetModelMapping()
|
||||
if _, exists := mapping["gemini-3-flash"]; exists {
|
||||
t.Fatalf("did not expect explicit gemini-3-flash passthrough when wildcard already exists")
|
||||
}
|
||||
if _, exists := mapping["gemini-3.1-pro-high"]; exists {
|
||||
t.Fatalf("did not expect explicit gemini-3.1-pro-high passthrough when wildcard already exists")
|
||||
}
|
||||
if _, exists := mapping["gemini-3.1-pro-low"]; exists {
|
||||
t.Fatalf("did not expect explicit gemini-3.1-pro-low passthrough when wildcard already exists")
|
||||
}
|
||||
if mapped := account.GetMappedModel("gemini-3-flash"); mapped != "gemini-3.1-pro-high" {
|
||||
t.Fatalf("expected wildcard mapping to stay effective, got: %q", mapped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountGetModelMapping_CacheInvalidatesOnCredentialsReplace(t *testing.T) {
|
||||
account := &Account{
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"claude-3-5-sonnet": "upstream-a",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
first := account.GetModelMapping()
|
||||
if first["claude-3-5-sonnet"] != "upstream-a" {
|
||||
t.Fatalf("unexpected first mapping: %v", first)
|
||||
}
|
||||
|
||||
account.Credentials = map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"claude-3-5-sonnet": "upstream-b",
|
||||
},
|
||||
}
|
||||
second := account.GetModelMapping()
|
||||
if second["claude-3-5-sonnet"] != "upstream-b" {
|
||||
t.Fatalf("expected cache invalidated after credentials replace, got: %v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountGetModelMapping_CacheInvalidatesOnMappingLenChange(t *testing.T) {
|
||||
rawMapping := map[string]any{
|
||||
"claude-sonnet": "sonnet-a",
|
||||
}
|
||||
account := &Account{
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": rawMapping,
|
||||
},
|
||||
}
|
||||
|
||||
first := account.GetModelMapping()
|
||||
if len(first) != 1 {
|
||||
t.Fatalf("unexpected first mapping length: %d", len(first))
|
||||
}
|
||||
|
||||
rawMapping["claude-opus"] = "opus-b"
|
||||
second := account.GetModelMapping()
|
||||
if second["claude-opus"] != "opus-b" {
|
||||
t.Fatalf("expected cache invalidated after mapping len change, got: %v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountGetModelMapping_CacheInvalidatesOnInPlaceValueChange(t *testing.T) {
|
||||
rawMapping := map[string]any{
|
||||
"claude-sonnet": "sonnet-a",
|
||||
}
|
||||
account := &Account{
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": rawMapping,
|
||||
},
|
||||
}
|
||||
|
||||
first := account.GetModelMapping()
|
||||
if first["claude-sonnet"] != "sonnet-a" {
|
||||
t.Fatalf("unexpected first mapping: %v", first)
|
||||
}
|
||||
|
||||
rawMapping["claude-sonnet"] = "sonnet-b"
|
||||
second := account.GetModelMapping()
|
||||
if second["claude-sonnet"] != "sonnet-b" {
|
||||
t.Fatalf("expected cache invalidated after in-place value change, got: %v", second)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,429 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stubs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// userRepoStubForGroupUpdate implements UserRepository for AdminUpdateAPIKeyGroupID tests.
|
||||
type userRepoStubForGroupUpdate struct {
|
||||
addGroupErr error
|
||||
addGroupCalled bool
|
||||
addedUserID int64
|
||||
addedGroupID int64
|
||||
}
|
||||
|
||||
func (s *userRepoStubForGroupUpdate) AddGroupToAllowedGroups(_ context.Context, userID int64, groupID int64) error {
|
||||
s.addGroupCalled = true
|
||||
s.addedUserID = userID
|
||||
s.addedGroupID = groupID
|
||||
return s.addGroupErr
|
||||
}
|
||||
|
||||
func (s *userRepoStubForGroupUpdate) Create(context.Context, *User) error { panic("unexpected") }
|
||||
func (s *userRepoStubForGroupUpdate) GetByID(context.Context, int64) (*User, error) { panic("unexpected") }
|
||||
func (s *userRepoStubForGroupUpdate) GetByEmail(context.Context, string) (*User, error) { panic("unexpected") }
|
||||
func (s *userRepoStubForGroupUpdate) GetFirstAdmin(context.Context) (*User, error) { panic("unexpected") }
|
||||
func (s *userRepoStubForGroupUpdate) Update(context.Context, *User) error { panic("unexpected") }
|
||||
func (s *userRepoStubForGroupUpdate) Delete(context.Context, int64) error { panic("unexpected") }
|
||||
func (s *userRepoStubForGroupUpdate) List(context.Context, pagination.PaginationParams) ([]User, *pagination.PaginationResult, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *userRepoStubForGroupUpdate) ListWithFilters(context.Context, pagination.PaginationParams, UserListFilters) ([]User, *pagination.PaginationResult, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *userRepoStubForGroupUpdate) UpdateBalance(context.Context, int64, float64) error { panic("unexpected") }
|
||||
func (s *userRepoStubForGroupUpdate) DeductBalance(context.Context, int64, float64) error { panic("unexpected") }
|
||||
func (s *userRepoStubForGroupUpdate) UpdateConcurrency(context.Context, int64, int) error { panic("unexpected") }
|
||||
func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (bool, error) { panic("unexpected") }
|
||||
func (s *userRepoStubForGroupUpdate) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *userRepoStubForGroupUpdate) UpdateTotpSecret(context.Context, int64, *string) error { panic("unexpected") }
|
||||
func (s *userRepoStubForGroupUpdate) EnableTotp(context.Context, int64) error { panic("unexpected") }
|
||||
func (s *userRepoStubForGroupUpdate) DisableTotp(context.Context, int64) error { panic("unexpected") }
|
||||
|
||||
// apiKeyRepoStubForGroupUpdate implements APIKeyRepository for AdminUpdateAPIKeyGroupID tests.
|
||||
type apiKeyRepoStubForGroupUpdate struct {
|
||||
key *APIKey
|
||||
getErr error
|
||||
updateErr error
|
||||
updated *APIKey // captures what was passed to Update
|
||||
}
|
||||
|
||||
func (s *apiKeyRepoStubForGroupUpdate) GetByID(_ context.Context, _ int64) (*APIKey, error) {
|
||||
if s.getErr != nil {
|
||||
return nil, s.getErr
|
||||
}
|
||||
clone := *s.key
|
||||
return &clone, nil
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) Update(_ context.Context, key *APIKey) error {
|
||||
if s.updateErr != nil {
|
||||
return s.updateErr
|
||||
}
|
||||
clone := *key
|
||||
s.updated = &clone
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unused methods – panic on unexpected call.
|
||||
func (s *apiKeyRepoStubForGroupUpdate) Create(context.Context, *APIKey) error { panic("unexpected") }
|
||||
func (s *apiKeyRepoStubForGroupUpdate) GetKeyAndOwnerID(context.Context, int64) (string, int64, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) GetByKey(context.Context, string) (*APIKey, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) GetByKeyForAuth(context.Context, string) (*APIKey, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) Delete(context.Context, int64) error { panic("unexpected") }
|
||||
func (s *apiKeyRepoStubForGroupUpdate) ListByUserID(context.Context, int64, pagination.PaginationParams, APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) VerifyOwnership(context.Context, int64, []int64) ([]int64, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) CountByUserID(context.Context, int64) (int64, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) ExistsByKey(context.Context, string) (bool, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) ListByGroupID(context.Context, int64, pagination.PaginationParams) ([]APIKey, *pagination.PaginationResult, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) SearchAPIKeys(context.Context, int64, string, int) ([]APIKey, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) ClearGroupIDByGroupID(context.Context, int64) (int64, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) CountByGroupID(context.Context, int64) (int64, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) ListKeysByUserID(context.Context, int64) ([]string, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) ListKeysByGroupID(context.Context, int64) ([]string, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) IncrementQuotaUsed(context.Context, int64, float64) (float64, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) UpdateLastUsed(context.Context, int64, time.Time) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) IncrementRateLimitUsage(context.Context, int64, float64) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) ResetRateLimitWindows(context.Context, int64) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) GetRateLimitData(context.Context, int64) (*APIKeyRateLimitData, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
|
||||
// groupRepoStubForGroupUpdate implements GroupRepository for AdminUpdateAPIKeyGroupID tests.
|
||||
type groupRepoStubForGroupUpdate struct {
|
||||
group *Group
|
||||
getErr error
|
||||
lastGetByIDArg int64
|
||||
}
|
||||
|
||||
func (s *groupRepoStubForGroupUpdate) GetByID(_ context.Context, id int64) (*Group, error) {
|
||||
s.lastGetByIDArg = id
|
||||
if s.getErr != nil {
|
||||
return nil, s.getErr
|
||||
}
|
||||
clone := *s.group
|
||||
return &clone, nil
|
||||
}
|
||||
|
||||
// Unused methods – panic on unexpected call.
|
||||
func (s *groupRepoStubForGroupUpdate) Create(context.Context, *Group) error { panic("unexpected") }
|
||||
func (s *groupRepoStubForGroupUpdate) GetByIDLite(context.Context, int64) (*Group, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *groupRepoStubForGroupUpdate) Update(context.Context, *Group) error { panic("unexpected") }
|
||||
func (s *groupRepoStubForGroupUpdate) Delete(context.Context, int64) error { panic("unexpected") }
|
||||
func (s *groupRepoStubForGroupUpdate) DeleteCascade(context.Context, int64) ([]int64, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *groupRepoStubForGroupUpdate) List(context.Context, pagination.PaginationParams) ([]Group, *pagination.PaginationResult, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *groupRepoStubForGroupUpdate) ListWithFilters(context.Context, pagination.PaginationParams, string, string, string, *bool) ([]Group, *pagination.PaginationResult, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *groupRepoStubForGroupUpdate) ListActive(context.Context) ([]Group, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *groupRepoStubForGroupUpdate) ListActiveByPlatform(context.Context, string) ([]Group, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *groupRepoStubForGroupUpdate) ExistsByName(context.Context, string) (bool, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *groupRepoStubForGroupUpdate) GetAccountCount(context.Context, int64) (int64, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *groupRepoStubForGroupUpdate) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *groupRepoStubForGroupUpdate) GetAccountIDsByGroupIDs(context.Context, []int64) ([]int64, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *groupRepoStubForGroupUpdate) BindAccountsToGroup(context.Context, int64, []int64) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *groupRepoStubForGroupUpdate) UpdateSortOrders(context.Context, []GroupSortOrderUpdate) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestAdminService_AdminUpdateAPIKeyGroupID_KeyNotFound(t *testing.T) {
|
||||
repo := &apiKeyRepoStubForGroupUpdate{getErr: ErrAPIKeyNotFound}
|
||||
svc := &adminServiceImpl{apiKeyRepo: repo}
|
||||
|
||||
_, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 999, int64Ptr(1))
|
||||
require.ErrorIs(t, err, ErrAPIKeyNotFound)
|
||||
}
|
||||
|
||||
func TestAdminService_AdminUpdateAPIKeyGroupID_NilGroupID_NoOp(t *testing.T) {
|
||||
existing := &APIKey{ID: 1, Key: "sk-test", GroupID: int64Ptr(5)}
|
||||
repo := &apiKeyRepoStubForGroupUpdate{key: existing}
|
||||
svc := &adminServiceImpl{apiKeyRepo: repo}
|
||||
|
||||
got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), got.APIKey.ID)
|
||||
// Update should NOT have been called (updated stays nil)
|
||||
require.Nil(t, repo.updated)
|
||||
}
|
||||
|
||||
func TestAdminService_AdminUpdateAPIKeyGroupID_Unbind(t *testing.T) {
|
||||
existing := &APIKey{ID: 1, Key: "sk-test", GroupID: int64Ptr(5), Group: &Group{ID: 5, Name: "Old"}}
|
||||
repo := &apiKeyRepoStubForGroupUpdate{key: existing}
|
||||
cache := &authCacheInvalidatorStub{}
|
||||
svc := &adminServiceImpl{apiKeyRepo: repo, authCacheInvalidator: cache}
|
||||
|
||||
got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(0))
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, got.APIKey.GroupID, "group_id should be nil after unbind")
|
||||
require.Nil(t, got.APIKey.Group, "group object should be nil after unbind")
|
||||
require.NotNil(t, repo.updated, "Update should have been called")
|
||||
require.Nil(t, repo.updated.GroupID)
|
||||
require.Equal(t, []string{"sk-test"}, cache.keys, "cache should be invalidated")
|
||||
}
|
||||
|
||||
func TestAdminService_AdminUpdateAPIKeyGroupID_BindActiveGroup(t *testing.T) {
|
||||
existing := &APIKey{ID: 1, Key: "sk-test", GroupID: nil}
|
||||
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
|
||||
groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 10, Name: "Pro", Status: StatusActive}}
|
||||
cache := &authCacheInvalidatorStub{}
|
||||
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, authCacheInvalidator: cache}
|
||||
|
||||
got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.APIKey.GroupID)
|
||||
require.Equal(t, int64(10), *got.APIKey.GroupID)
|
||||
require.Equal(t, int64(10), *apiKeyRepo.updated.GroupID)
|
||||
require.Equal(t, []string{"sk-test"}, cache.keys)
|
||||
// M3: verify correct group ID was passed to repo
|
||||
require.Equal(t, int64(10), groupRepo.lastGetByIDArg)
|
||||
// C1 fix: verify Group object is populated
|
||||
require.NotNil(t, got.APIKey.Group)
|
||||
require.Equal(t, "Pro", got.APIKey.Group.Name)
|
||||
}
|
||||
|
||||
func TestAdminService_AdminUpdateAPIKeyGroupID_SameGroup_Idempotent(t *testing.T) {
|
||||
existing := &APIKey{ID: 1, Key: "sk-test", GroupID: int64Ptr(10), Group: &Group{ID: 10, Name: "Pro"}}
|
||||
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
|
||||
groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 10, Name: "Pro", Status: StatusActive}}
|
||||
cache := &authCacheInvalidatorStub{}
|
||||
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, authCacheInvalidator: cache}
|
||||
|
||||
got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.APIKey.GroupID)
|
||||
require.Equal(t, int64(10), *got.APIKey.GroupID)
|
||||
// Update is still called (current impl doesn't short-circuit on same group)
|
||||
require.NotNil(t, apiKeyRepo.updated)
|
||||
require.Equal(t, []string{"sk-test"}, cache.keys)
|
||||
}
|
||||
|
||||
func TestAdminService_AdminUpdateAPIKeyGroupID_GroupNotFound(t *testing.T) {
|
||||
existing := &APIKey{ID: 1, Key: "sk-test"}
|
||||
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
|
||||
groupRepo := &groupRepoStubForGroupUpdate{getErr: ErrGroupNotFound}
|
||||
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo}
|
||||
|
||||
_, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(99))
|
||||
require.ErrorIs(t, err, ErrGroupNotFound)
|
||||
}
|
||||
|
||||
func TestAdminService_AdminUpdateAPIKeyGroupID_GroupNotActive(t *testing.T) {
|
||||
existing := &APIKey{ID: 1, Key: "sk-test"}
|
||||
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
|
||||
groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 5, Status: StatusDisabled}}
|
||||
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo}
|
||||
|
||||
_, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(5))
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "GROUP_NOT_ACTIVE", infraerrors.Reason(err))
|
||||
}
|
||||
|
||||
func TestAdminService_AdminUpdateAPIKeyGroupID_UpdateFails(t *testing.T) {
|
||||
existing := &APIKey{ID: 1, Key: "sk-test", GroupID: int64Ptr(3)}
|
||||
repo := &apiKeyRepoStubForGroupUpdate{key: existing, updateErr: errors.New("db write error")}
|
||||
svc := &adminServiceImpl{apiKeyRepo: repo}
|
||||
|
||||
_, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(0))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "update api key")
|
||||
}
|
||||
|
||||
func TestAdminService_AdminUpdateAPIKeyGroupID_NegativeGroupID(t *testing.T) {
|
||||
existing := &APIKey{ID: 1, Key: "sk-test"}
|
||||
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
|
||||
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo}
|
||||
|
||||
_, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(-5))
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "INVALID_GROUP_ID", infraerrors.Reason(err))
|
||||
}
|
||||
|
||||
func TestAdminService_AdminUpdateAPIKeyGroupID_PointerIsolation(t *testing.T) {
|
||||
existing := &APIKey{ID: 1, Key: "sk-test", GroupID: nil}
|
||||
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
|
||||
groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 10, Name: "Pro", Status: StatusActive}}
|
||||
cache := &authCacheInvalidatorStub{}
|
||||
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, authCacheInvalidator: cache}
|
||||
|
||||
inputGID := int64(10)
|
||||
got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, &inputGID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.APIKey.GroupID)
|
||||
// Mutating the input pointer must NOT affect the stored value
|
||||
inputGID = 999
|
||||
require.Equal(t, int64(10), *got.APIKey.GroupID)
|
||||
require.Equal(t, int64(10), *apiKeyRepo.updated.GroupID)
|
||||
}
|
||||
|
||||
func TestAdminService_AdminUpdateAPIKeyGroupID_NilCacheInvalidator(t *testing.T) {
|
||||
existing := &APIKey{ID: 1, Key: "sk-test"}
|
||||
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
|
||||
groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 7, Status: StatusActive}}
|
||||
// authCacheInvalidator is nil – should not panic
|
||||
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo}
|
||||
|
||||
got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(7))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.APIKey.GroupID)
|
||||
require.Equal(t, int64(7), *got.APIKey.GroupID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: AllowedGroup auto-sync
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestAdminService_AdminUpdateAPIKeyGroupID_ExclusiveGroup_AddsAllowedGroup(t *testing.T) {
|
||||
existing := &APIKey{ID: 1, UserID: 42, Key: "sk-test", GroupID: nil}
|
||||
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
|
||||
groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 10, Name: "Exclusive", Status: StatusActive, IsExclusive: true, SubscriptionType: SubscriptionTypeStandard}}
|
||||
userRepo := &userRepoStubForGroupUpdate{}
|
||||
cache := &authCacheInvalidatorStub{}
|
||||
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, userRepo: userRepo, authCacheInvalidator: cache}
|
||||
|
||||
got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.APIKey.GroupID)
|
||||
require.Equal(t, int64(10), *got.APIKey.GroupID)
|
||||
// 验证 AddGroupToAllowedGroups 被调用,且参数正确
|
||||
require.True(t, userRepo.addGroupCalled)
|
||||
require.Equal(t, int64(42), userRepo.addedUserID)
|
||||
require.Equal(t, int64(10), userRepo.addedGroupID)
|
||||
// 验证 result 标记了自动授权
|
||||
require.True(t, got.AutoGrantedGroupAccess)
|
||||
require.NotNil(t, got.GrantedGroupID)
|
||||
require.Equal(t, int64(10), *got.GrantedGroupID)
|
||||
require.Equal(t, "Exclusive", got.GrantedGroupName)
|
||||
}
|
||||
|
||||
func TestAdminService_AdminUpdateAPIKeyGroupID_NonExclusiveGroup_NoAllowedGroupUpdate(t *testing.T) {
|
||||
existing := &APIKey{ID: 1, UserID: 42, Key: "sk-test", GroupID: nil}
|
||||
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
|
||||
groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 10, Name: "Public", Status: StatusActive, IsExclusive: false, SubscriptionType: SubscriptionTypeStandard}}
|
||||
userRepo := &userRepoStubForGroupUpdate{}
|
||||
cache := &authCacheInvalidatorStub{}
|
||||
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, userRepo: userRepo, authCacheInvalidator: cache}
|
||||
|
||||
got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.APIKey.GroupID)
|
||||
// 非专属分组不触发 AddGroupToAllowedGroups
|
||||
require.False(t, userRepo.addGroupCalled)
|
||||
require.False(t, got.AutoGrantedGroupAccess)
|
||||
}
|
||||
|
||||
func TestAdminService_AdminUpdateAPIKeyGroupID_SubscriptionGroup_Blocked(t *testing.T) {
|
||||
existing := &APIKey{ID: 1, UserID: 42, Key: "sk-test", GroupID: nil}
|
||||
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
|
||||
groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 10, Name: "Sub", Status: StatusActive, IsExclusive: true, SubscriptionType: SubscriptionTypeSubscription}}
|
||||
userRepo := &userRepoStubForGroupUpdate{}
|
||||
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, userRepo: userRepo}
|
||||
|
||||
// 订阅类型分组应被阻止绑定
|
||||
_, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10))
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "SUBSCRIPTION_GROUP_NOT_ALLOWED", infraerrors.Reason(err))
|
||||
require.False(t, userRepo.addGroupCalled)
|
||||
}
|
||||
|
||||
func TestAdminService_AdminUpdateAPIKeyGroupID_ExclusiveGroup_AllowedGroupAddFails_ReturnsError(t *testing.T) {
|
||||
existing := &APIKey{ID: 1, UserID: 42, Key: "sk-test", GroupID: nil}
|
||||
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
|
||||
groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 10, Name: "Exclusive", Status: StatusActive, IsExclusive: true, SubscriptionType: SubscriptionTypeStandard}}
|
||||
userRepo := &userRepoStubForGroupUpdate{addGroupErr: errors.New("db error")}
|
||||
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, userRepo: userRepo}
|
||||
|
||||
// 严格模式:AddGroupToAllowedGroups 失败时,整体操作报错
|
||||
_, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "add group to user allowed groups")
|
||||
require.True(t, userRepo.addGroupCalled)
|
||||
// apiKey 不应被更新
|
||||
require.Nil(t, apiKeyRepo.updated)
|
||||
}
|
||||
|
||||
func TestAdminService_AdminUpdateAPIKeyGroupID_Unbind_NoAllowedGroupUpdate(t *testing.T) {
|
||||
existing := &APIKey{ID: 1, UserID: 42, Key: "sk-test", GroupID: int64Ptr(10), Group: &Group{ID: 10, Name: "Exclusive"}}
|
||||
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
|
||||
userRepo := &userRepoStubForGroupUpdate{}
|
||||
cache := &authCacheInvalidatorStub{}
|
||||
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, userRepo: userRepo, authCacheInvalidator: cache}
|
||||
|
||||
got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(0))
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, got.APIKey.GroupID)
|
||||
// 解绑时不修改 allowed_groups
|
||||
require.False(t, userRepo.addGroupCalled)
|
||||
require.False(t, got.AutoGrantedGroupAccess)
|
||||
}
|
||||
@@ -15,6 +15,16 @@ type accountRepoStubForBulkUpdate struct {
|
||||
bulkUpdateErr error
|
||||
bulkUpdateIDs []int64
|
||||
bindGroupErrByID map[int64]error
|
||||
bindGroupsCalls []int64
|
||||
getByIDsAccounts []*Account
|
||||
getByIDsErr error
|
||||
getByIDsCalled bool
|
||||
getByIDsIDs []int64
|
||||
getByIDAccounts map[int64]*Account
|
||||
getByIDErrByID map[int64]error
|
||||
getByIDCalled []int64
|
||||
listByGroupData map[int64][]Account
|
||||
listByGroupErr map[int64]error
|
||||
}
|
||||
|
||||
func (s *accountRepoStubForBulkUpdate) BulkUpdate(_ context.Context, ids []int64, _ AccountBulkUpdate) (int64, error) {
|
||||
@@ -26,12 +36,43 @@ func (s *accountRepoStubForBulkUpdate) BulkUpdate(_ context.Context, ids []int64
|
||||
}
|
||||
|
||||
func (s *accountRepoStubForBulkUpdate) BindGroups(_ context.Context, accountID int64, _ []int64) error {
|
||||
s.bindGroupsCalls = append(s.bindGroupsCalls, accountID)
|
||||
if err, ok := s.bindGroupErrByID[accountID]; ok {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *accountRepoStubForBulkUpdate) GetByIDs(_ context.Context, ids []int64) ([]*Account, error) {
|
||||
s.getByIDsCalled = true
|
||||
s.getByIDsIDs = append([]int64{}, ids...)
|
||||
if s.getByIDsErr != nil {
|
||||
return nil, s.getByIDsErr
|
||||
}
|
||||
return s.getByIDsAccounts, nil
|
||||
}
|
||||
|
||||
func (s *accountRepoStubForBulkUpdate) GetByID(_ context.Context, id int64) (*Account, error) {
|
||||
s.getByIDCalled = append(s.getByIDCalled, id)
|
||||
if err, ok := s.getByIDErrByID[id]; ok {
|
||||
return nil, err
|
||||
}
|
||||
if account, ok := s.getByIDAccounts[id]; ok {
|
||||
return account, nil
|
||||
}
|
||||
return nil, errors.New("account not found")
|
||||
}
|
||||
|
||||
func (s *accountRepoStubForBulkUpdate) ListByGroup(_ context.Context, groupID int64) ([]Account, error) {
|
||||
if err, ok := s.listByGroupErr[groupID]; ok {
|
||||
return nil, err
|
||||
}
|
||||
if rows, ok := s.listByGroupData[groupID]; ok {
|
||||
return rows, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TestAdminService_BulkUpdateAccounts_AllSuccessIDs 验证批量更新成功时返回 success_ids/failed_ids。
|
||||
func TestAdminService_BulkUpdateAccounts_AllSuccessIDs(t *testing.T) {
|
||||
repo := &accountRepoStubForBulkUpdate{}
|
||||
@@ -59,7 +100,10 @@ func TestAdminService_BulkUpdateAccounts_PartialFailureIDs(t *testing.T) {
|
||||
2: errors.New("bind failed"),
|
||||
},
|
||||
}
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
svc := &adminServiceImpl{
|
||||
accountRepo: repo,
|
||||
groupRepo: &groupRepoStubForAdmin{getByID: &Group{ID: 10, Name: "g10"}},
|
||||
}
|
||||
|
||||
groupIDs := []int64{10}
|
||||
schedulable := false
|
||||
@@ -78,3 +122,51 @@ func TestAdminService_BulkUpdateAccounts_PartialFailureIDs(t *testing.T) {
|
||||
require.ElementsMatch(t, []int64{2}, result.FailedIDs)
|
||||
require.Len(t, result.Results, 3)
|
||||
}
|
||||
|
||||
func TestAdminService_BulkUpdateAccounts_NilGroupRepoReturnsError(t *testing.T) {
|
||||
repo := &accountRepoStubForBulkUpdate{}
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
|
||||
groupIDs := []int64{10}
|
||||
input := &BulkUpdateAccountsInput{
|
||||
AccountIDs: []int64{1},
|
||||
GroupIDs: &groupIDs,
|
||||
}
|
||||
|
||||
result, err := svc.BulkUpdateAccounts(context.Background(), input)
|
||||
require.Nil(t, result)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "group repository not configured")
|
||||
}
|
||||
|
||||
// TestAdminService_BulkUpdateAccounts_MixedChannelPreCheckBlocksOnExistingConflict verifies
|
||||
// that the global pre-check detects a conflict with existing group members and returns an
|
||||
// error before any DB write is performed.
|
||||
func TestAdminService_BulkUpdateAccounts_MixedChannelPreCheckBlocksOnExistingConflict(t *testing.T) {
|
||||
repo := &accountRepoStubForBulkUpdate{
|
||||
getByIDsAccounts: []*Account{
|
||||
{ID: 1, Platform: PlatformAntigravity},
|
||||
},
|
||||
// Group 10 already contains an Anthropic account.
|
||||
listByGroupData: map[int64][]Account{
|
||||
10: {{ID: 99, Platform: PlatformAnthropic}},
|
||||
},
|
||||
}
|
||||
svc := &adminServiceImpl{
|
||||
accountRepo: repo,
|
||||
groupRepo: &groupRepoStubForAdmin{getByID: &Group{ID: 10, Name: "target-group"}},
|
||||
}
|
||||
|
||||
groupIDs := []int64{10}
|
||||
input := &BulkUpdateAccountsInput{
|
||||
AccountIDs: []int64{1},
|
||||
GroupIDs: &groupIDs,
|
||||
}
|
||||
|
||||
result, err := svc.BulkUpdateAccounts(context.Background(), input)
|
||||
require.Nil(t, result)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "mixed channel")
|
||||
// No BindGroups should have been called since the check runs before any write.
|
||||
require.Empty(t, repo.bindGroupsCalls)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -65,3 +66,32 @@ func TestAdminService_CreateUser_CreateError(t *testing.T) {
|
||||
require.ErrorIs(t, err, createErr)
|
||||
require.Empty(t, repo.created)
|
||||
}
|
||||
|
||||
func TestAdminService_CreateUser_AssignsDefaultSubscriptions(t *testing.T) {
|
||||
repo := &userRepoStub{nextID: 21}
|
||||
assigner := &defaultSubscriptionAssignerStub{}
|
||||
cfg := &config.Config{
|
||||
Default: config.DefaultConfig{
|
||||
UserBalance: 0,
|
||||
UserConcurrency: 1,
|
||||
},
|
||||
}
|
||||
settingService := NewSettingService(&settingRepoStub{values: map[string]string{
|
||||
SettingKeyDefaultSubscriptions: `[{"group_id":5,"validity_days":30}]`,
|
||||
}}, cfg)
|
||||
svc := &adminServiceImpl{
|
||||
userRepo: repo,
|
||||
settingService: settingService,
|
||||
defaultSubAssigner: assigner,
|
||||
}
|
||||
|
||||
_, err := svc.CreateUser(context.Background(), &CreateUserInput{
|
||||
Email: "new-user@test.com",
|
||||
Password: "password",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, assigner.calls, 1)
|
||||
require.Equal(t, int64(21), assigner.calls[0].UserID)
|
||||
require.Equal(t, int64(5), assigner.calls[0].GroupID)
|
||||
require.Equal(t, 30, assigner.calls[0].ValidityDays)
|
||||
}
|
||||
|
||||
@@ -93,6 +93,10 @@ func (s *userRepoStub) RemoveGroupFromAllowedGroups(ctx context.Context, groupID
|
||||
panic("unexpected RemoveGroupFromAllowedGroups call")
|
||||
}
|
||||
|
||||
func (s *userRepoStub) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
|
||||
panic("unexpected AddGroupToAllowedGroups call")
|
||||
}
|
||||
|
||||
func (s *userRepoStub) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error {
|
||||
panic("unexpected UpdateTotpSecret call")
|
||||
}
|
||||
@@ -344,6 +348,19 @@ func (s *billingCacheStub) InvalidateSubscriptionCache(ctx context.Context, user
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *billingCacheStub) GetAPIKeyRateLimit(ctx context.Context, keyID int64) (*APIKeyRateLimitCacheData, error) {
|
||||
panic("unexpected GetAPIKeyRateLimit call")
|
||||
}
|
||||
func (s *billingCacheStub) SetAPIKeyRateLimit(ctx context.Context, keyID int64, data *APIKeyRateLimitCacheData) error {
|
||||
panic("unexpected SetAPIKeyRateLimit call")
|
||||
}
|
||||
func (s *billingCacheStub) UpdateAPIKeyRateLimitUsage(ctx context.Context, keyID int64, cost float64) error {
|
||||
panic("unexpected UpdateAPIKeyRateLimitUsage call")
|
||||
}
|
||||
func (s *billingCacheStub) InvalidateAPIKeyRateLimit(ctx context.Context, keyID int64) error {
|
||||
panic("unexpected InvalidateAPIKeyRateLimit call")
|
||||
}
|
||||
|
||||
func waitForInvalidations(t *testing.T, ch <-chan subscriptionInvalidateCall, expected int) []subscriptionInvalidateCall {
|
||||
t.Helper()
|
||||
calls := make([]subscriptionInvalidateCall, 0, expected)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type userRepoStubForListUsers struct {
|
||||
userRepoStub
|
||||
users []User
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *userRepoStubForListUsers) ListWithFilters(_ context.Context, params pagination.PaginationParams, _ UserListFilters) ([]User, *pagination.PaginationResult, error) {
|
||||
if s.err != nil {
|
||||
return nil, nil, s.err
|
||||
}
|
||||
out := make([]User, len(s.users))
|
||||
copy(out, s.users)
|
||||
return out, &pagination.PaginationResult{
|
||||
Total: int64(len(out)),
|
||||
Page: params.Page,
|
||||
PageSize: params.PageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type userGroupRateRepoStubForListUsers struct {
|
||||
batchCalls int
|
||||
singleCall []int64
|
||||
|
||||
batchErr error
|
||||
batchData map[int64]map[int64]float64
|
||||
|
||||
singleErr map[int64]error
|
||||
singleData map[int64]map[int64]float64
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForListUsers) GetByUserIDs(_ context.Context, _ []int64) (map[int64]map[int64]float64, error) {
|
||||
s.batchCalls++
|
||||
if s.batchErr != nil {
|
||||
return nil, s.batchErr
|
||||
}
|
||||
return s.batchData, nil
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForListUsers) GetByUserID(_ context.Context, userID int64) (map[int64]float64, error) {
|
||||
s.singleCall = append(s.singleCall, userID)
|
||||
if err, ok := s.singleErr[userID]; ok {
|
||||
return nil, err
|
||||
}
|
||||
if rates, ok := s.singleData[userID]; ok {
|
||||
return rates, nil
|
||||
}
|
||||
return map[int64]float64{}, nil
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForListUsers) GetByUserAndGroup(_ context.Context, userID, groupID int64) (*float64, error) {
|
||||
panic("unexpected GetByUserAndGroup call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForListUsers) SyncUserGroupRates(_ context.Context, userID int64, rates map[int64]*float64) error {
|
||||
panic("unexpected SyncUserGroupRates call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForListUsers) DeleteByGroupID(_ context.Context, groupID int64) error {
|
||||
panic("unexpected DeleteByGroupID call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForListUsers) DeleteByUserID(_ context.Context, userID int64) error {
|
||||
panic("unexpected DeleteByUserID call")
|
||||
}
|
||||
|
||||
func TestAdminService_ListUsers_BatchRateFallbackToSingle(t *testing.T) {
|
||||
userRepo := &userRepoStubForListUsers{
|
||||
users: []User{
|
||||
{ID: 101, Username: "u1"},
|
||||
{ID: 202, Username: "u2"},
|
||||
},
|
||||
}
|
||||
rateRepo := &userGroupRateRepoStubForListUsers{
|
||||
batchErr: errors.New("batch unavailable"),
|
||||
singleData: map[int64]map[int64]float64{
|
||||
101: {11: 1.1},
|
||||
202: {22: 2.2},
|
||||
},
|
||||
}
|
||||
svc := &adminServiceImpl{
|
||||
userRepo: userRepo,
|
||||
userGroupRateRepo: rateRepo,
|
||||
}
|
||||
|
||||
users, total, err := svc.ListUsers(context.Background(), 1, 20, UserListFilters{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), total)
|
||||
require.Len(t, users, 2)
|
||||
require.Equal(t, 1, rateRepo.batchCalls)
|
||||
require.ElementsMatch(t, []int64{101, 202}, rateRepo.singleCall)
|
||||
require.Equal(t, 1.1, users[0].GroupRates[11])
|
||||
require.Equal(t, 2.2, users[1].GroupRates[22])
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFinalizeProxyQualityResult_ScoreAndGrade(t *testing.T) {
|
||||
result := &ProxyQualityCheckResult{
|
||||
PassedCount: 2,
|
||||
WarnCount: 1,
|
||||
FailedCount: 1,
|
||||
ChallengeCount: 1,
|
||||
}
|
||||
|
||||
finalizeProxyQualityResult(result)
|
||||
|
||||
require.Equal(t, 38, result.Score)
|
||||
require.Equal(t, "F", result.Grade)
|
||||
require.Contains(t, result.Summary, "通过 2 项")
|
||||
require.Contains(t, result.Summary, "告警 1 项")
|
||||
require.Contains(t, result.Summary, "失败 1 项")
|
||||
require.Contains(t, result.Summary, "挑战 1 项")
|
||||
}
|
||||
|
||||
func TestRunProxyQualityTarget_SoraChallenge(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Header().Set("cf-ray", "test-ray-123")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte("<!DOCTYPE html><title>Just a moment...</title><script>window._cf_chl_opt={};</script>"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
target := proxyQualityTarget{
|
||||
Target: "sora",
|
||||
URL: server.URL,
|
||||
Method: http.MethodGet,
|
||||
AllowedStatuses: map[int]struct{}{
|
||||
http.StatusUnauthorized: {},
|
||||
},
|
||||
}
|
||||
|
||||
item := runProxyQualityTarget(context.Background(), server.Client(), target)
|
||||
require.Equal(t, "challenge", item.Status)
|
||||
require.Equal(t, http.StatusForbidden, item.HTTPStatus)
|
||||
require.Equal(t, "test-ray-123", item.CFRay)
|
||||
}
|
||||
|
||||
func TestRunProxyQualityTarget_AllowedStatusPass(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"models":[]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
target := proxyQualityTarget{
|
||||
Target: "gemini",
|
||||
URL: server.URL,
|
||||
Method: http.MethodGet,
|
||||
AllowedStatuses: map[int]struct{}{
|
||||
http.StatusOK: {},
|
||||
},
|
||||
}
|
||||
|
||||
item := runProxyQualityTarget(context.Background(), server.Client(), target)
|
||||
require.Equal(t, "pass", item.Status)
|
||||
require.Equal(t, http.StatusOK, item.HTTPStatus)
|
||||
}
|
||||
|
||||
func TestRunProxyQualityTarget_AllowedStatusWarnForUnauthorized(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
target := proxyQualityTarget{
|
||||
Target: "openai",
|
||||
URL: server.URL,
|
||||
Method: http.MethodGet,
|
||||
AllowedStatuses: map[int]struct{}{
|
||||
http.StatusUnauthorized: {},
|
||||
},
|
||||
}
|
||||
|
||||
item := runProxyQualityTarget(context.Background(), server.Client(), target)
|
||||
require.Equal(t, "warn", item.Status)
|
||||
require.Equal(t, http.StatusUnauthorized, item.HTTPStatus)
|
||||
require.Contains(t, item.Message, "目标可达")
|
||||
}
|
||||
@@ -21,9 +21,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -85,7 +86,6 @@ var (
|
||||
)
|
||||
|
||||
const (
|
||||
antigravityBillingModelEnv = "GATEWAY_ANTIGRAVITY_BILL_WITH_MAPPED_MODEL"
|
||||
antigravityForwardBaseURLEnv = "GATEWAY_ANTIGRAVITY_FORWARD_BASE_URL"
|
||||
antigravityFallbackSecondsEnv = "GATEWAY_ANTIGRAVITY_FALLBACK_COOLDOWN_SECONDS"
|
||||
)
|
||||
@@ -184,7 +184,7 @@ type smartRetryResult struct {
|
||||
func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParams, resp *http.Response, respBody []byte, baseURL string, urlIdx int, availableURLs []string) *smartRetryResult {
|
||||
// "Resource has been exhausted" 是 URL 级别限流,切换 URL(仅 429)
|
||||
if resp.StatusCode == http.StatusTooManyRequests && isURLLevelRateLimit(respBody) && urlIdx < len(availableURLs)-1 {
|
||||
log.Printf("%s URL fallback (429): %s -> %s", p.prefix, baseURL, availableURLs[urlIdx+1])
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s URL fallback (429): %s -> %s", p.prefix, baseURL, availableURLs[urlIdx+1])
|
||||
return &smartRetryResult{action: smartRetryActionContinueURL}
|
||||
}
|
||||
|
||||
@@ -204,13 +204,13 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam
|
||||
if rateLimitDuration <= 0 {
|
||||
rateLimitDuration = antigravityDefaultRateLimitDuration
|
||||
}
|
||||
log.Printf("%s status=%d oauth_long_delay model=%s account=%d upstream_retry_delay=%v body=%s (model rate limit, switch account)",
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d oauth_long_delay model=%s account=%d upstream_retry_delay=%v body=%s (model rate limit, switch account)",
|
||||
p.prefix, resp.StatusCode, modelName, p.account.ID, rateLimitDuration, truncateForLog(respBody, 200))
|
||||
|
||||
resetAt := time.Now().Add(rateLimitDuration)
|
||||
if !setModelRateLimitByModelName(p.ctx, p.accountRepo, p.account.ID, modelName, p.prefix, resp.StatusCode, resetAt, false) {
|
||||
p.handleError(p.ctx, p.prefix, p.account, resp.StatusCode, resp.Header, respBody, p.requestedModel, p.groupID, p.sessionHash, p.isStickySession)
|
||||
log.Printf("%s status=%d rate_limited account=%d (no model mapping)", p.prefix, resp.StatusCode, p.account.ID)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d rate_limited account=%d (no model mapping)", p.prefix, resp.StatusCode, p.account.ID)
|
||||
} else {
|
||||
s.updateAccountModelRateLimitInCache(p.ctx, p.account, modelName, resetAt)
|
||||
}
|
||||
@@ -273,7 +273,7 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam
|
||||
// 智能重试:创建新请求
|
||||
retryReq, err := antigravity.NewAPIRequestWithURL(p.ctx, baseURL, p.action, p.accessToken, p.body)
|
||||
if err != nil {
|
||||
log.Printf("%s status=smart_retry_request_build_failed error=%v", p.prefix, err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=smart_retry_request_build_failed error=%v", p.prefix, err)
|
||||
p.handleError(p.ctx, p.prefix, p.account, resp.StatusCode, resp.Header, respBody, p.requestedModel, p.groupID, p.sessionHash, p.isStickySession)
|
||||
return &smartRetryResult{
|
||||
action: smartRetryActionBreakWithResp,
|
||||
@@ -356,7 +356,7 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam
|
||||
// 单账号 503 退避重试模式:智能重试耗尽后不设限流、不切换账号,
|
||||
// 直接返回 503 让 Handler 层的单账号退避循环做最终处理。
|
||||
if resp.StatusCode == http.StatusServiceUnavailable && isSingleAccountRetry(p.ctx) {
|
||||
log.Printf("%s status=%d smart_retry_exhausted_single_account attempts=%d model=%s account=%d body=%s (return 503 directly)",
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d smart_retry_exhausted_single_account attempts=%d model=%s account=%d body=%s (return 503 directly)",
|
||||
p.prefix, resp.StatusCode, antigravitySmartRetryMaxAttempts, modelName, p.account.ID, truncateForLog(retryBody, 200))
|
||||
return &smartRetryResult{
|
||||
action: smartRetryActionBreakWithResp,
|
||||
@@ -374,9 +374,9 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam
|
||||
resetAt := time.Now().Add(rateLimitDuration)
|
||||
if p.accountRepo != nil && modelName != "" {
|
||||
if err := p.accountRepo.SetModelRateLimit(p.ctx, p.account.ID, modelName, resetAt); err != nil {
|
||||
log.Printf("%s status=%d model_rate_limit_failed model=%s error=%v", p.prefix, resp.StatusCode, modelName, err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limit_failed model=%s error=%v", p.prefix, resp.StatusCode, modelName, err)
|
||||
} else {
|
||||
log.Printf("%s status=%d model_rate_limited_after_smart_retry model=%s account=%d reset_in=%v",
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limited_after_smart_retry model=%s account=%d reset_in=%v",
|
||||
p.prefix, resp.StatusCode, modelName, p.account.ID, rateLimitDuration)
|
||||
s.updateAccountModelRateLimitInCache(p.ctx, p.account, modelName, resetAt)
|
||||
}
|
||||
@@ -431,7 +431,7 @@ func (s *AntigravityGatewayService) handleSingleAccountRetryInPlace(
|
||||
waitDuration = antigravitySmartRetryMinWait
|
||||
}
|
||||
|
||||
log.Printf("%s status=%d single_account_503_retry_in_place model=%s account=%d upstream_retry_delay=%v (retrying in-place instead of rate-limiting)",
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d single_account_503_retry_in_place model=%s account=%d upstream_retry_delay=%v (retrying in-place instead of rate-limiting)",
|
||||
p.prefix, resp.StatusCode, modelName, p.account.ID, waitDuration)
|
||||
|
||||
var lastRetryResp *http.Response
|
||||
@@ -443,21 +443,21 @@ func (s *AntigravityGatewayService) handleSingleAccountRetryInPlace(
|
||||
if totalWaited+waitDuration > antigravitySingleAccountSmartRetryTotalMaxWait {
|
||||
remaining := antigravitySingleAccountSmartRetryTotalMaxWait - totalWaited
|
||||
if remaining <= 0 {
|
||||
log.Printf("%s single_account_503_retry: total_wait_exceeded total=%v max=%v, giving up",
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s single_account_503_retry: total_wait_exceeded total=%v max=%v, giving up",
|
||||
p.prefix, totalWaited, antigravitySingleAccountSmartRetryTotalMaxWait)
|
||||
break
|
||||
}
|
||||
waitDuration = remaining
|
||||
}
|
||||
|
||||
log.Printf("%s status=%d single_account_503_retry attempt=%d/%d delay=%v total_waited=%v model=%s account=%d",
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d single_account_503_retry attempt=%d/%d delay=%v total_waited=%v model=%s account=%d",
|
||||
p.prefix, resp.StatusCode, attempt, antigravitySingleAccountSmartRetryMaxAttempts, waitDuration, totalWaited, modelName, p.account.ID)
|
||||
|
||||
timer := time.NewTimer(waitDuration)
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
timer.Stop()
|
||||
log.Printf("%s status=context_canceled_during_single_account_retry", p.prefix)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=context_canceled_during_single_account_retry", p.prefix)
|
||||
return &smartRetryResult{action: smartRetryActionBreakWithResp, err: p.ctx.Err()}
|
||||
case <-timer.C:
|
||||
}
|
||||
@@ -466,13 +466,13 @@ func (s *AntigravityGatewayService) handleSingleAccountRetryInPlace(
|
||||
// 创建新请求
|
||||
retryReq, err := antigravity.NewAPIRequestWithURL(p.ctx, baseURL, p.action, p.accessToken, p.body)
|
||||
if err != nil {
|
||||
log.Printf("%s single_account_503_retry: request_build_failed error=%v", p.prefix, err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s single_account_503_retry: request_build_failed error=%v", p.prefix, err)
|
||||
break
|
||||
}
|
||||
|
||||
retryResp, retryErr := p.httpUpstream.Do(retryReq, p.proxyURL, p.account.ID, p.account.Concurrency)
|
||||
if retryErr == nil && retryResp != nil && retryResp.StatusCode != http.StatusTooManyRequests && retryResp.StatusCode != http.StatusServiceUnavailable {
|
||||
log.Printf("%s status=%d single_account_503_retry_success attempt=%d/%d total_waited=%v",
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d single_account_503_retry_success attempt=%d/%d total_waited=%v",
|
||||
p.prefix, retryResp.StatusCode, attempt, antigravitySingleAccountSmartRetryMaxAttempts, totalWaited)
|
||||
// 关闭之前的响应
|
||||
if lastRetryResp != nil {
|
||||
@@ -483,7 +483,7 @@ func (s *AntigravityGatewayService) handleSingleAccountRetryInPlace(
|
||||
|
||||
// 网络错误时继续重试
|
||||
if retryErr != nil || retryResp == nil {
|
||||
log.Printf("%s single_account_503_retry: network_error attempt=%d/%d error=%v",
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s single_account_503_retry: network_error attempt=%d/%d error=%v",
|
||||
p.prefix, attempt, antigravitySingleAccountSmartRetryMaxAttempts, retryErr)
|
||||
continue
|
||||
}
|
||||
@@ -517,7 +517,7 @@ func (s *AntigravityGatewayService) handleSingleAccountRetryInPlace(
|
||||
if retryBody == nil {
|
||||
retryBody = respBody
|
||||
}
|
||||
log.Printf("%s status=%d single_account_503_retry_exhausted attempts=%d total_waited=%v model=%s account=%d body=%s (return 503 directly)",
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d single_account_503_retry_exhausted attempts=%d total_waited=%v model=%s account=%d body=%s (return 503 directly)",
|
||||
p.prefix, resp.StatusCode, antigravitySingleAccountSmartRetryMaxAttempts, totalWaited, modelName, p.account.ID, truncateForLog(retryBody, 200))
|
||||
|
||||
return &smartRetryResult{
|
||||
@@ -540,10 +540,10 @@ func (s *AntigravityGatewayService) antigravityRetryLoop(p antigravityRetryLoopP
|
||||
// 如果上游确实还不可用,handleSmartRetry → handleSingleAccountRetryInPlace
|
||||
// 会在 Service 层原地等待+重试,不需要在预检查这里等。
|
||||
if isSingleAccountRetry(p.ctx) {
|
||||
log.Printf("%s pre_check: single_account_retry skipping rate_limit remaining=%v model=%s account=%d (will retry in-place if 503)",
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s pre_check: single_account_retry skipping rate_limit remaining=%v model=%s account=%d (will retry in-place if 503)",
|
||||
p.prefix, remaining.Truncate(time.Millisecond), p.requestedModel, p.account.ID)
|
||||
} else {
|
||||
log.Printf("%s pre_check: rate_limit_switch remaining=%v model=%s account=%d",
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s pre_check: rate_limit_switch remaining=%v model=%s account=%d",
|
||||
p.prefix, remaining.Truncate(time.Millisecond), p.requestedModel, p.account.ID)
|
||||
return nil, &AntigravityAccountSwitchError{
|
||||
OriginalAccountID: p.account.ID,
|
||||
@@ -580,7 +580,7 @@ urlFallbackLoop:
|
||||
for attempt := 1; attempt <= antigravityMaxRetries; attempt++ {
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
log.Printf("%s status=context_canceled error=%v", p.prefix, p.ctx.Err())
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=context_canceled error=%v", p.prefix, p.ctx.Err())
|
||||
return nil, p.ctx.Err()
|
||||
default:
|
||||
}
|
||||
@@ -610,18 +610,18 @@ urlFallbackLoop:
|
||||
Message: safeErr,
|
||||
})
|
||||
if shouldAntigravityFallbackToNextURL(err, 0) && urlIdx < len(availableURLs)-1 {
|
||||
log.Printf("%s URL fallback (connection error): %s -> %s", p.prefix, baseURL, availableURLs[urlIdx+1])
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s URL fallback (connection error): %s -> %s", p.prefix, baseURL, availableURLs[urlIdx+1])
|
||||
continue urlFallbackLoop
|
||||
}
|
||||
if attempt < antigravityMaxRetries {
|
||||
log.Printf("%s status=request_failed retry=%d/%d error=%v", p.prefix, attempt, antigravityMaxRetries, err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=request_failed retry=%d/%d error=%v", p.prefix, attempt, antigravityMaxRetries, err)
|
||||
if !sleepAntigravityBackoffWithContext(p.ctx, attempt) {
|
||||
log.Printf("%s status=context_canceled_during_backoff", p.prefix)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=context_canceled_during_backoff", p.prefix)
|
||||
return nil, p.ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
log.Printf("%s status=request_failed retries_exhausted error=%v", p.prefix, err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=request_failed retries_exhausted error=%v", p.prefix, err)
|
||||
setOpsUpstreamError(p.c, 0, safeErr, "")
|
||||
return nil, fmt.Errorf("upstream request failed after retries: %w", err)
|
||||
}
|
||||
@@ -678,9 +678,9 @@ urlFallbackLoop:
|
||||
Message: upstreamMsg,
|
||||
Detail: getUpstreamDetail(respBody),
|
||||
})
|
||||
log.Printf("%s status=%d retry=%d/%d body=%s", p.prefix, resp.StatusCode, attempt, antigravityMaxRetries, truncateForLog(respBody, 200))
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d retry=%d/%d body=%s", p.prefix, resp.StatusCode, attempt, antigravityMaxRetries, truncateForLog(respBody, 200))
|
||||
if !sleepAntigravityBackoffWithContext(p.ctx, attempt) {
|
||||
log.Printf("%s status=context_canceled_during_backoff", p.prefix)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=context_canceled_during_backoff", p.prefix)
|
||||
return nil, p.ctx.Err()
|
||||
}
|
||||
continue
|
||||
@@ -688,7 +688,7 @@ urlFallbackLoop:
|
||||
|
||||
// 重试用尽,标记账户限流
|
||||
p.handleError(p.ctx, p.prefix, p.account, resp.StatusCode, resp.Header, respBody, p.requestedModel, p.groupID, p.sessionHash, p.isStickySession)
|
||||
log.Printf("%s status=%d rate_limited base_url=%s body=%s", p.prefix, resp.StatusCode, baseURL, truncateForLog(respBody, 200))
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d rate_limited base_url=%s body=%s", p.prefix, resp.StatusCode, baseURL, truncateForLog(respBody, 200))
|
||||
resp = &http.Response{
|
||||
StatusCode: resp.StatusCode,
|
||||
Header: resp.Header.Clone(),
|
||||
@@ -712,9 +712,9 @@ urlFallbackLoop:
|
||||
Message: upstreamMsg,
|
||||
Detail: getUpstreamDetail(respBody),
|
||||
})
|
||||
log.Printf("%s status=%d retry=%d/%d body=%s", p.prefix, resp.StatusCode, attempt, antigravityMaxRetries, truncateForLog(respBody, 500))
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d retry=%d/%d body=%s", p.prefix, resp.StatusCode, attempt, antigravityMaxRetries, truncateForLog(respBody, 500))
|
||||
if !sleepAntigravityBackoffWithContext(p.ctx, attempt) {
|
||||
log.Printf("%s status=context_canceled_during_backoff", p.prefix)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=context_canceled_during_backoff", p.prefix)
|
||||
return nil, p.ctx.Err()
|
||||
}
|
||||
continue
|
||||
@@ -1012,14 +1012,14 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account
|
||||
}
|
||||
|
||||
// 调试日志:Test 请求信息
|
||||
log.Printf("[antigravity-Test] account=%s request_size=%d url=%s", account.Name, len(requestBody), req.URL.String())
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Test] account=%s request_size=%d url=%s", account.Name, len(requestBody), req.URL.String())
|
||||
|
||||
// 发送请求
|
||||
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("请求失败: %w", err)
|
||||
if shouldAntigravityFallbackToNextURL(err, 0) && urlIdx < len(availableURLs)-1 {
|
||||
log.Printf("[antigravity-Test] URL fallback: %s -> %s", baseURL, availableURLs[urlIdx+1])
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Test] URL fallback: %s -> %s", baseURL, availableURLs[urlIdx+1])
|
||||
continue
|
||||
}
|
||||
return nil, lastErr
|
||||
@@ -1034,7 +1034,7 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account
|
||||
|
||||
// 检查是否需要 URL 降级
|
||||
if shouldAntigravityFallbackToNextURL(nil, resp.StatusCode) && urlIdx < len(availableURLs)-1 {
|
||||
log.Printf("[antigravity-Test] URL fallback (HTTP %d): %s -> %s", resp.StatusCode, baseURL, availableURLs[urlIdx+1])
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Test] URL fallback (HTTP %d): %s -> %s", resp.StatusCode, baseURL, availableURLs[urlIdx+1])
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1243,16 +1243,12 @@ func (s *AntigravityGatewayService) wrapV1InternalRequest(projectID, model strin
|
||||
}
|
||||
|
||||
// unwrapV1InternalResponse 解包 v1internal 响应
|
||||
// 使用 gjson 零拷贝提取 response 字段,避免 Unmarshal+Marshal 双重开销
|
||||
func (s *AntigravityGatewayService) unwrapV1InternalResponse(body []byte) ([]byte, error) {
|
||||
var outer map[string]any
|
||||
if err := json.Unmarshal(body, &outer); err != nil {
|
||||
return nil, err
|
||||
result := gjson.GetBytes(body, "response")
|
||||
if result.Exists() {
|
||||
return []byte(result.Raw), nil
|
||||
}
|
||||
|
||||
if resp, ok := outer["response"]; ok {
|
||||
return json.Marshal(resp)
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
@@ -1311,6 +1307,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
||||
// 应用 thinking 模式自动后缀:如果 thinking 开启且目标是 claude-sonnet-4-5,自动改为 thinking 版本
|
||||
thinkingEnabled := claudeReq.Thinking != nil && (claudeReq.Thinking.Type == "enabled" || claudeReq.Thinking.Type == "adaptive")
|
||||
mappedModel = applyThinkingModelSuffix(mappedModel, thinkingEnabled)
|
||||
billingModel := mappedModel
|
||||
|
||||
// 获取 access_token
|
||||
if s.tokenProvider == nil {
|
||||
@@ -1372,6 +1369,10 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
||||
ForceCacheBilling: switchErr.IsStickySession,
|
||||
}
|
||||
}
|
||||
// 区分客户端取消和真正的上游失败,返回更准确的错误消息
|
||||
if c.Request.Context().Err() != nil {
|
||||
return nil, s.writeClaudeError(c, http.StatusBadGateway, "client_disconnected", "Client disconnected before upstream response")
|
||||
}
|
||||
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed after retries")
|
||||
}
|
||||
resp := result.resp
|
||||
@@ -1420,7 +1421,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("Antigravity account %d: detected signature-related 400, retrying once (%s)", account.ID, stage.name)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Antigravity account %d: detected signature-related 400, retrying once (%s)", account.ID, stage.name)
|
||||
|
||||
retryGeminiBody, txErr := antigravity.TransformClaudeToGeminiWithOptions(&retryClaudeReq, projectID, mappedModel, s.getClaudeTransformOptions(ctx))
|
||||
if txErr != nil {
|
||||
@@ -1453,7 +1454,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
||||
Kind: "signature_retry_request_error",
|
||||
Message: sanitizeUpstreamErrorMessage(retryErr.Error()),
|
||||
})
|
||||
log.Printf("Antigravity account %d: signature retry request failed (%s): %v", account.ID, stage.name, retryErr)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Antigravity account %d: signature retry request failed (%s): %v", account.ID, stage.name, retryErr)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1472,7 +1473,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
||||
if retryResp.Request != nil && retryResp.Request.URL != nil {
|
||||
retryBaseURL = retryResp.Request.URL.Scheme + "://" + retryResp.Request.URL.Host
|
||||
}
|
||||
log.Printf("%s status=429 rate_limited base_url=%s retry_stage=%s body=%s", prefix, retryBaseURL, stage.name, truncateForLog(retryBody, 200))
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 rate_limited base_url=%s retry_stage=%s body=%s", prefix, retryBaseURL, stage.name, truncateForLog(retryBody, 200))
|
||||
}
|
||||
kind := "signature_retry"
|
||||
if strings.TrimSpace(stage.name) != "" {
|
||||
@@ -1525,7 +1526,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
||||
upstreamDetail := s.getUpstreamErrorDetail(respBody)
|
||||
logBody, maxBytes := s.getLogConfig()
|
||||
if logBody {
|
||||
log.Printf("%s status=400 prompt_too_long=true upstream_message=%q request_id=%s body=%s", prefix, upstreamMsg, resp.Header.Get("x-request-id"), truncateForLog(respBody, maxBytes))
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=400 prompt_too_long=true upstream_message=%q request_id=%s body=%s", prefix, upstreamMsg, resp.Header.Get("x-request-id"), truncateForLog(respBody, maxBytes))
|
||||
}
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
@@ -1600,7 +1601,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
||||
// 客户端要求流式,直接透传转换
|
||||
streamRes, err := s.handleClaudeStreamingResponse(c, resp, startTime, originalModel)
|
||||
if err != nil {
|
||||
log.Printf("%s status=stream_error error=%v", prefix, err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_error error=%v", prefix, err)
|
||||
return nil, err
|
||||
}
|
||||
usage = streamRes.usage
|
||||
@@ -1610,7 +1611,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
||||
// 客户端要求非流式,收集流式响应后转换返回
|
||||
streamRes, err := s.handleClaudeStreamToNonStreaming(c, resp, startTime, originalModel)
|
||||
if err != nil {
|
||||
log.Printf("%s status=stream_collect_error error=%v", prefix, err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_collect_error error=%v", prefix, err)
|
||||
return nil, err
|
||||
}
|
||||
usage = streamRes.usage
|
||||
@@ -1620,7 +1621,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
||||
return &ForwardResult{
|
||||
RequestID: requestID,
|
||||
Usage: *usage,
|
||||
Model: originalModel, // 使用原始模型用于计费和日志
|
||||
Model: billingModel, // 使用映射模型用于计费和日志
|
||||
Stream: claudeReq.Stream,
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
@@ -1963,7 +1964,7 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
|
||||
Usage: ClaudeUsage{},
|
||||
Model: originalModel,
|
||||
Stream: false,
|
||||
Duration: time.Since(time.Now()),
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: nil,
|
||||
}, nil
|
||||
default:
|
||||
@@ -1974,6 +1975,7 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
|
||||
if mappedModel == "" {
|
||||
return nil, s.writeGoogleError(c, http.StatusForbidden, fmt.Sprintf("model %s not in whitelist", originalModel))
|
||||
}
|
||||
billingModel := mappedModel
|
||||
|
||||
// 获取 access_token
|
||||
if s.tokenProvider == nil {
|
||||
@@ -2002,9 +2004,9 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
|
||||
// 清理 Schema
|
||||
if cleanedBody, err := cleanGeminiRequest(injectedBody); err == nil {
|
||||
injectedBody = cleanedBody
|
||||
log.Printf("[Antigravity] Cleaned request schema in forwarded request for account %s", account.Name)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] Cleaned request schema in forwarded request for account %s", account.Name)
|
||||
} else {
|
||||
log.Printf("[Antigravity] Failed to clean schema: %v", err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] Failed to clean schema: %v", err)
|
||||
}
|
||||
|
||||
// 包装请求
|
||||
@@ -2044,6 +2046,10 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
|
||||
ForceCacheBilling: switchErr.IsStickySession,
|
||||
}
|
||||
}
|
||||
// 区分客户端取消和真正的上游失败,返回更准确的错误消息
|
||||
if c.Request.Context().Err() != nil {
|
||||
return nil, s.writeGoogleError(c, http.StatusBadGateway, "Client disconnected before upstream response")
|
||||
}
|
||||
return nil, s.writeGoogleError(c, http.StatusBadGateway, "Upstream request failed after retries")
|
||||
}
|
||||
resp := result.resp
|
||||
@@ -2066,7 +2072,7 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
|
||||
isModelNotFoundError(resp.StatusCode, respBody) {
|
||||
fallbackModel := s.settingService.GetFallbackModel(ctx, PlatformAntigravity)
|
||||
if fallbackModel != "" && fallbackModel != mappedModel {
|
||||
log.Printf("[Antigravity] Model not found (%s), retrying with fallback model %s (account: %s)", mappedModel, fallbackModel, account.Name)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] Model not found (%s), retrying with fallback model %s (account: %s)", mappedModel, fallbackModel, account.Name)
|
||||
|
||||
fallbackWrapped, err := s.wrapV1InternalRequest(projectID, fallbackModel, injectedBody)
|
||||
if err == nil {
|
||||
@@ -2149,7 +2155,7 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
|
||||
Message: upstreamMsg,
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
log.Printf("[antigravity-Forward] upstream error status=%d body=%s", resp.StatusCode, truncateForLog(unwrappedForOps, 500))
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] upstream error status=%d body=%s", resp.StatusCode, truncateForLog(unwrappedForOps, 500))
|
||||
c.Data(resp.StatusCode, contentType, unwrappedForOps)
|
||||
return nil, fmt.Errorf("antigravity upstream error: %d", resp.StatusCode)
|
||||
}
|
||||
@@ -2168,7 +2174,7 @@ handleSuccess:
|
||||
// 客户端要求流式,直接透传
|
||||
streamRes, err := s.handleGeminiStreamingResponse(c, resp, startTime)
|
||||
if err != nil {
|
||||
log.Printf("%s status=stream_error error=%v", prefix, err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_error error=%v", prefix, err)
|
||||
return nil, err
|
||||
}
|
||||
usage = streamRes.usage
|
||||
@@ -2178,7 +2184,7 @@ handleSuccess:
|
||||
// 客户端要求非流式,收集流式响应后返回
|
||||
streamRes, err := s.handleGeminiStreamToNonStreaming(c, resp, startTime)
|
||||
if err != nil {
|
||||
log.Printf("%s status=stream_collect_error error=%v", prefix, err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_collect_error error=%v", prefix, err)
|
||||
return nil, err
|
||||
}
|
||||
usage = streamRes.usage
|
||||
@@ -2199,7 +2205,7 @@ handleSuccess:
|
||||
return &ForwardResult{
|
||||
RequestID: requestID,
|
||||
Usage: *usage,
|
||||
Model: originalModel,
|
||||
Model: billingModel,
|
||||
Stream: stream,
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
@@ -2284,7 +2290,7 @@ func sleepAntigravityBackoffWithContext(ctx context.Context, attempt int) bool {
|
||||
|
||||
// isSingleAccountRetry 检查 context 中是否设置了单账号退避重试标记
|
||||
func isSingleAccountRetry(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(ctxkey.SingleAccountRetry).(bool)
|
||||
v, _ := SingleAccountRetryFromContext(ctx)
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -2297,13 +2303,13 @@ func setModelRateLimitByModelName(ctx context.Context, repo AccountRepository, a
|
||||
}
|
||||
// 直接使用官方模型 ID 作为 key,不再转换为 scope
|
||||
if err := repo.SetModelRateLimit(ctx, accountID, modelName, resetAt); err != nil {
|
||||
log.Printf("%s status=%d model_rate_limit_failed model=%s error=%v", prefix, statusCode, modelName, err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limit_failed model=%s error=%v", prefix, statusCode, modelName, err)
|
||||
return false
|
||||
}
|
||||
if afterSmartRetry {
|
||||
log.Printf("%s status=%d model_rate_limited_after_smart_retry model=%s account=%d reset_in=%v", prefix, statusCode, modelName, accountID, time.Until(resetAt).Truncate(time.Second))
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limited_after_smart_retry model=%s account=%d reset_in=%v", prefix, statusCode, modelName, accountID, time.Until(resetAt).Truncate(time.Second))
|
||||
} else {
|
||||
log.Printf("%s status=%d model_rate_limited model=%s account=%d reset_in=%v", prefix, statusCode, modelName, accountID, time.Until(resetAt).Truncate(time.Second))
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limited model=%s account=%d reset_in=%v", prefix, statusCode, modelName, accountID, time.Until(resetAt).Truncate(time.Second))
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -2411,7 +2417,7 @@ func parseAntigravitySmartRetryInfo(body []byte) *antigravitySmartRetryInfo {
|
||||
// 例如: "0.5s", "10s", "4m50s", "1h30m", "200ms" 等
|
||||
dur, err := time.ParseDuration(delay)
|
||||
if err != nil {
|
||||
log.Printf("[Antigravity] failed to parse retryDelay: %s error=%v", delay, err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] failed to parse retryDelay: %s error=%v", delay, err)
|
||||
continue
|
||||
}
|
||||
retryDelay = dur
|
||||
@@ -2532,7 +2538,7 @@ func (s *AntigravityGatewayService) handleModelRateLimit(p *handleModelRateLimit
|
||||
|
||||
// RATE_LIMIT_EXCEEDED: < antigravityRateLimitThreshold: 等待后重试
|
||||
if info.RetryDelay < antigravityRateLimitThreshold {
|
||||
log.Printf("%s status=%d model_rate_limit_wait model=%s wait=%v",
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limit_wait model=%s wait=%v",
|
||||
p.prefix, p.statusCode, info.ModelName, info.RetryDelay)
|
||||
return &handleModelRateLimitResult{
|
||||
Handled: true,
|
||||
@@ -2557,12 +2563,12 @@ func (s *AntigravityGatewayService) handleModelRateLimit(p *handleModelRateLimit
|
||||
// setModelRateLimitAndClearSession 设置模型限流并清除粘性会话
|
||||
func (s *AntigravityGatewayService) setModelRateLimitAndClearSession(p *handleModelRateLimitParams, info *antigravitySmartRetryInfo) {
|
||||
resetAt := time.Now().Add(info.RetryDelay)
|
||||
log.Printf("%s status=%d model_rate_limited model=%s account=%d reset_in=%v",
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d model_rate_limited model=%s account=%d reset_in=%v",
|
||||
p.prefix, p.statusCode, info.ModelName, p.account.ID, info.RetryDelay)
|
||||
|
||||
// 设置模型限流状态(数据库)
|
||||
if err := s.accountRepo.SetModelRateLimit(p.ctx, p.account.ID, info.ModelName, resetAt); err != nil {
|
||||
log.Printf("%s model_rate_limit_failed model=%s error=%v", p.prefix, info.ModelName, err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s model_rate_limit_failed model=%s error=%v", p.prefix, info.ModelName, err)
|
||||
}
|
||||
|
||||
// 立即更新 Redis 快照中账号的限流状态,避免并发请求重复选中
|
||||
@@ -2598,7 +2604,7 @@ func (s *AntigravityGatewayService) updateAccountModelRateLimitInCache(ctx conte
|
||||
|
||||
// 更新 Redis 快照
|
||||
if err := s.schedulerSnapshot.UpdateAccountInCache(ctx, account); err != nil {
|
||||
log.Printf("[antigravity-Forward] cache_update_failed account=%d model=%s err=%v", account.ID, modelKey, err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] cache_update_failed account=%d model=%s err=%v", account.ID, modelKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2637,20 +2643,29 @@ func (s *AntigravityGatewayService) handleUpstreamError(
|
||||
// 429:尝试解析模型级限流,解析失败时兜底为账号级限流
|
||||
if statusCode == 429 {
|
||||
if logBody, maxBytes := s.getLogConfig(); logBody {
|
||||
log.Printf("[Antigravity-Debug] 429 response body: %s", truncateString(string(body), maxBytes))
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity-Debug] 429 response body: %s", truncateString(string(body), maxBytes))
|
||||
}
|
||||
|
||||
resetAt := ParseGeminiRateLimitResetTime(body)
|
||||
defaultDur := s.getDefaultRateLimitDuration()
|
||||
|
||||
// 尝试解析模型 key 并设置模型级限流
|
||||
modelKey := resolveAntigravityModelKey(requestedModel)
|
||||
//
|
||||
// 注意:requestedModel 可能是"映射前"的请求模型名(例如 claude-opus-4-6),
|
||||
// 调度与限流判定使用的是 Antigravity 最终模型名(包含映射与 thinking 后缀)。
|
||||
// 因此这里必须写入最终模型 key,确保后续调度能正确避开已限流模型。
|
||||
modelKey := resolveFinalAntigravityModelKey(ctx, account, requestedModel)
|
||||
if strings.TrimSpace(modelKey) == "" {
|
||||
// 极少数情况下无法映射(理论上不应发生:能转发成功说明映射已通过),
|
||||
// 保持旧行为作为兜底,避免完全丢失模型级限流记录。
|
||||
modelKey = resolveAntigravityModelKey(requestedModel)
|
||||
}
|
||||
if modelKey != "" {
|
||||
ra := s.resolveResetTime(resetAt, defaultDur)
|
||||
if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, modelKey, ra); err != nil {
|
||||
log.Printf("%s status=429 model_rate_limit_set_failed model=%s error=%v", prefix, modelKey, err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 model_rate_limit_set_failed model=%s error=%v", prefix, modelKey, err)
|
||||
} else {
|
||||
log.Printf("%s status=429 model_rate_limited model=%s account=%d reset_at=%v reset_in=%v",
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 model_rate_limited model=%s account=%d reset_at=%v reset_in=%v",
|
||||
prefix, modelKey, account.ID, ra.Format("15:04:05"), time.Until(ra).Truncate(time.Second))
|
||||
s.updateAccountModelRateLimitInCache(ctx, account, modelKey, ra)
|
||||
}
|
||||
@@ -2659,10 +2674,10 @@ func (s *AntigravityGatewayService) handleUpstreamError(
|
||||
|
||||
// 无法解析模型 key,兜底为账号级限流
|
||||
ra := s.resolveResetTime(resetAt, defaultDur)
|
||||
log.Printf("%s status=429 rate_limited account=%d reset_at=%v reset_in=%v (fallback)",
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 rate_limited account=%d reset_at=%v reset_in=%v (fallback)",
|
||||
prefix, account.ID, ra.Format("15:04:05"), time.Until(ra).Truncate(time.Second))
|
||||
if err := s.accountRepo.SetRateLimited(ctx, account.ID, ra); err != nil {
|
||||
log.Printf("%s status=429 rate_limit_set_failed account=%d error=%v", prefix, account.ID, err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 rate_limit_set_failed account=%d error=%v", prefix, account.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -2672,7 +2687,7 @@ func (s *AntigravityGatewayService) handleUpstreamError(
|
||||
}
|
||||
shouldDisable := s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, headers, body)
|
||||
if shouldDisable {
|
||||
log.Printf("%s status=%d marked_error", prefix, statusCode)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=%d marked_error", prefix, statusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -2746,18 +2761,18 @@ func (cw *antigravityClientWriter) Disconnected() bool { return cw.disconnected
|
||||
|
||||
func (cw *antigravityClientWriter) markDisconnected() {
|
||||
cw.disconnected = true
|
||||
log.Printf("Client disconnected during streaming (%s), continuing to drain upstream for billing", cw.prefix)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Client disconnected during streaming (%s), continuing to drain upstream for billing", cw.prefix)
|
||||
}
|
||||
|
||||
// handleStreamReadError 处理上游读取错误的通用逻辑。
|
||||
// 返回 (clientDisconnect, handled):handled=true 表示错误已处理,调用方应返回已收集的 usage。
|
||||
func handleStreamReadError(err error, clientDisconnected bool, prefix string) (disconnect bool, handled bool) {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
log.Printf("Context canceled during streaming (%s), returning collected usage", prefix)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Context canceled during streaming (%s), returning collected usage", prefix)
|
||||
return true, true
|
||||
}
|
||||
if clientDisconnected {
|
||||
log.Printf("Upstream read error after client disconnect (%s): %v, returning collected usage", prefix, err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Upstream read error after client disconnect (%s): %v, returning collected usage", prefix, err)
|
||||
return true, true
|
||||
}
|
||||
return false, false
|
||||
@@ -2786,7 +2801,8 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
|
||||
if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 {
|
||||
maxLineSize = s.settingService.cfg.Gateway.MaxLineSize
|
||||
}
|
||||
scanner.Buffer(make([]byte, 64*1024), maxLineSize)
|
||||
scanBuf := getSSEScannerBuf64K()
|
||||
scanner.Buffer(scanBuf[:0], maxLineSize)
|
||||
usage := &ClaudeUsage{}
|
||||
var firstTokenMs *int
|
||||
|
||||
@@ -2807,7 +2823,8 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
|
||||
}
|
||||
var lastReadAt int64
|
||||
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
|
||||
go func() {
|
||||
go func(scanBuf *sseScannerBuf64K) {
|
||||
defer putSSEScannerBuf64K(scanBuf)
|
||||
defer close(events)
|
||||
for scanner.Scan() {
|
||||
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
|
||||
@@ -2818,7 +2835,7 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
|
||||
if err := scanner.Err(); err != nil {
|
||||
_ = sendEvent(scanEvent{err: err})
|
||||
}
|
||||
}()
|
||||
}(scanBuf)
|
||||
defer close(done)
|
||||
|
||||
// 上游数据间隔超时保护(防止上游挂起长期占用连接)
|
||||
@@ -2860,7 +2877,7 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
|
||||
return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: disconnect}, nil
|
||||
}
|
||||
if errors.Is(ev.err, bufio.ErrTooLong) {
|
||||
log.Printf("SSE line too long (antigravity): max_size=%d error=%v", maxLineSize, ev.err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "SSE line too long (antigravity): max_size=%d error=%v", maxLineSize, ev.err)
|
||||
sendErrorEvent("response_too_large")
|
||||
return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs}, ev.err
|
||||
}
|
||||
@@ -2884,19 +2901,19 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
|
||||
}
|
||||
|
||||
// 解析 usage
|
||||
if u := extractGeminiUsage(inner); u != nil {
|
||||
usage = u
|
||||
}
|
||||
var parsed map[string]any
|
||||
if json.Unmarshal(inner, &parsed) == nil {
|
||||
if u := extractGeminiUsage(parsed); u != nil {
|
||||
usage = u
|
||||
}
|
||||
// Check for MALFORMED_FUNCTION_CALL
|
||||
if candidates, ok := parsed["candidates"].([]any); ok && len(candidates) > 0 {
|
||||
if cand, ok := candidates[0].(map[string]any); ok {
|
||||
if fr, ok := cand["finishReason"].(string); ok && fr == "MALFORMED_FUNCTION_CALL" {
|
||||
log.Printf("[Antigravity] MALFORMED_FUNCTION_CALL detected in forward stream")
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] MALFORMED_FUNCTION_CALL detected in forward stream")
|
||||
if content, ok := cand["content"]; ok {
|
||||
if b, err := json.Marshal(content); err == nil {
|
||||
log.Printf("[Antigravity] Malformed content: %s", string(b))
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] Malformed content: %s", string(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2921,10 +2938,10 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
|
||||
continue
|
||||
}
|
||||
if cw.Disconnected() {
|
||||
log.Printf("Upstream timeout after client disconnect (antigravity gemini), returning collected usage")
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Upstream timeout after client disconnect (antigravity gemini), returning collected usage")
|
||||
return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil
|
||||
}
|
||||
log.Printf("Stream data interval timeout (antigravity)")
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity)")
|
||||
sendErrorEvent("stream_timeout")
|
||||
return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream data interval timeout")
|
||||
}
|
||||
@@ -2939,7 +2956,8 @@ func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Cont
|
||||
if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 {
|
||||
maxLineSize = s.settingService.cfg.Gateway.MaxLineSize
|
||||
}
|
||||
scanner.Buffer(make([]byte, 64*1024), maxLineSize)
|
||||
scanBuf := getSSEScannerBuf64K()
|
||||
scanner.Buffer(scanBuf[:0], maxLineSize)
|
||||
|
||||
usage := &ClaudeUsage{}
|
||||
var firstTokenMs *int
|
||||
@@ -2967,7 +2985,8 @@ func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Cont
|
||||
|
||||
var lastReadAt int64
|
||||
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
|
||||
go func() {
|
||||
go func(scanBuf *sseScannerBuf64K) {
|
||||
defer putSSEScannerBuf64K(scanBuf)
|
||||
defer close(events)
|
||||
for scanner.Scan() {
|
||||
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
|
||||
@@ -2978,7 +2997,7 @@ func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Cont
|
||||
if err := scanner.Err(); err != nil {
|
||||
_ = sendEvent(scanEvent{err: err})
|
||||
}
|
||||
}()
|
||||
}(scanBuf)
|
||||
defer close(done)
|
||||
|
||||
// 上游数据间隔超时保护(防止上游挂起长期占用连接)
|
||||
@@ -3005,7 +3024,7 @@ func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Cont
|
||||
}
|
||||
if ev.err != nil {
|
||||
if errors.Is(ev.err, bufio.ErrTooLong) {
|
||||
log.Printf("SSE line too long (antigravity non-stream): max_size=%d error=%v", maxLineSize, ev.err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "SSE line too long (antigravity non-stream): max_size=%d error=%v", maxLineSize, ev.err)
|
||||
}
|
||||
return nil, ev.err
|
||||
}
|
||||
@@ -3042,7 +3061,7 @@ func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Cont
|
||||
last = parsed
|
||||
|
||||
// 提取 usage
|
||||
if u := extractGeminiUsage(parsed); u != nil {
|
||||
if u := extractGeminiUsage(inner); u != nil {
|
||||
usage = u
|
||||
}
|
||||
|
||||
@@ -3050,10 +3069,10 @@ func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Cont
|
||||
if candidates, ok := parsed["candidates"].([]any); ok && len(candidates) > 0 {
|
||||
if cand, ok := candidates[0].(map[string]any); ok {
|
||||
if fr, ok := cand["finishReason"].(string); ok && fr == "MALFORMED_FUNCTION_CALL" {
|
||||
log.Printf("[Antigravity] MALFORMED_FUNCTION_CALL detected in forward non-stream collect")
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] MALFORMED_FUNCTION_CALL detected in forward non-stream collect")
|
||||
if content, ok := cand["content"]; ok {
|
||||
if b, err := json.Marshal(content); err == nil {
|
||||
log.Printf("[Antigravity] Malformed content: %s", string(b))
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] Malformed content: %s", string(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3080,7 +3099,7 @@ func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Cont
|
||||
if time.Since(lastRead) < streamInterval {
|
||||
continue
|
||||
}
|
||||
log.Printf("Stream data interval timeout (antigravity non-stream)")
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity non-stream)")
|
||||
return nil, fmt.Errorf("stream data interval timeout")
|
||||
}
|
||||
}
|
||||
@@ -3091,7 +3110,7 @@ returnResponse:
|
||||
|
||||
// 处理空响应情况 — 触发同账号重试 + failover 切换账号
|
||||
if last == nil && lastWithParts == nil {
|
||||
log.Printf("[antigravity-Forward] warning: empty stream response (gemini non-stream), triggering failover")
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] warning: empty stream response (gemini non-stream), triggering failover")
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
ResponseBody: []byte(`{"error":"empty stream response from upstream"}`),
|
||||
@@ -3311,7 +3330,7 @@ func (s *AntigravityGatewayService) writeMappedClaudeError(c *gin.Context, accou
|
||||
|
||||
// 记录上游错误详情便于排障(可选:由配置控制;不回显到客户端)
|
||||
if logBody {
|
||||
log.Printf("[antigravity-Forward] upstream_error status=%d body=%s", upstreamStatus, truncateForLog(body, maxBytes))
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] upstream_error status=%d body=%s", upstreamStatus, truncateForLog(body, maxBytes))
|
||||
}
|
||||
|
||||
// 检查错误透传规则
|
||||
@@ -3402,7 +3421,8 @@ func (s *AntigravityGatewayService) handleClaudeStreamToNonStreaming(c *gin.Cont
|
||||
if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 {
|
||||
maxLineSize = s.settingService.cfg.Gateway.MaxLineSize
|
||||
}
|
||||
scanner.Buffer(make([]byte, 64*1024), maxLineSize)
|
||||
scanBuf := getSSEScannerBuf64K()
|
||||
scanner.Buffer(scanBuf[:0], maxLineSize)
|
||||
|
||||
var firstTokenMs *int
|
||||
var last map[string]any
|
||||
@@ -3428,7 +3448,8 @@ func (s *AntigravityGatewayService) handleClaudeStreamToNonStreaming(c *gin.Cont
|
||||
|
||||
var lastReadAt int64
|
||||
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
|
||||
go func() {
|
||||
go func(scanBuf *sseScannerBuf64K) {
|
||||
defer putSSEScannerBuf64K(scanBuf)
|
||||
defer close(events)
|
||||
for scanner.Scan() {
|
||||
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
|
||||
@@ -3439,7 +3460,7 @@ func (s *AntigravityGatewayService) handleClaudeStreamToNonStreaming(c *gin.Cont
|
||||
if err := scanner.Err(); err != nil {
|
||||
_ = sendEvent(scanEvent{err: err})
|
||||
}
|
||||
}()
|
||||
}(scanBuf)
|
||||
defer close(done)
|
||||
|
||||
// 上游数据间隔超时保护(防止上游挂起长期占用连接)
|
||||
@@ -3466,7 +3487,7 @@ func (s *AntigravityGatewayService) handleClaudeStreamToNonStreaming(c *gin.Cont
|
||||
}
|
||||
if ev.err != nil {
|
||||
if errors.Is(ev.err, bufio.ErrTooLong) {
|
||||
log.Printf("SSE line too long (antigravity claude non-stream): max_size=%d error=%v", maxLineSize, ev.err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "SSE line too long (antigravity claude non-stream): max_size=%d error=%v", maxLineSize, ev.err)
|
||||
}
|
||||
return nil, ev.err
|
||||
}
|
||||
@@ -3515,7 +3536,7 @@ func (s *AntigravityGatewayService) handleClaudeStreamToNonStreaming(c *gin.Cont
|
||||
if time.Since(lastRead) < streamInterval {
|
||||
continue
|
||||
}
|
||||
log.Printf("Stream data interval timeout (antigravity claude non-stream)")
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity claude non-stream)")
|
||||
return nil, fmt.Errorf("stream data interval timeout")
|
||||
}
|
||||
}
|
||||
@@ -3526,7 +3547,7 @@ returnResponse:
|
||||
|
||||
// 处理空响应情况 — 触发同账号重试 + failover 切换账号
|
||||
if last == nil && lastWithParts == nil {
|
||||
log.Printf("[antigravity-Forward] warning: empty stream response (claude non-stream), triggering failover")
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] warning: empty stream response (claude non-stream), triggering failover")
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
ResponseBody: []byte(`{"error":"empty stream response from upstream"}`),
|
||||
@@ -3548,7 +3569,7 @@ returnResponse:
|
||||
// 转换 Gemini 响应为 Claude 格式
|
||||
claudeResp, agUsage, err := antigravity.TransformGeminiToClaude(geminiBody, originalModel)
|
||||
if err != nil {
|
||||
log.Printf("[antigravity-Forward] transform_error error=%v body=%s", err, string(geminiBody))
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] transform_error error=%v body=%s", err, string(geminiBody))
|
||||
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to parse upstream response")
|
||||
}
|
||||
|
||||
@@ -3586,7 +3607,8 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context
|
||||
if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 {
|
||||
maxLineSize = s.settingService.cfg.Gateway.MaxLineSize
|
||||
}
|
||||
scanner.Buffer(make([]byte, 64*1024), maxLineSize)
|
||||
scanBuf := getSSEScannerBuf64K()
|
||||
scanner.Buffer(scanBuf[:0], maxLineSize)
|
||||
|
||||
// 辅助函数:转换 antigravity.ClaudeUsage 到 service.ClaudeUsage
|
||||
convertUsage := func(agUsage *antigravity.ClaudeUsage) *ClaudeUsage {
|
||||
@@ -3618,7 +3640,8 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context
|
||||
}
|
||||
var lastReadAt int64
|
||||
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
|
||||
go func() {
|
||||
go func(scanBuf *sseScannerBuf64K) {
|
||||
defer putSSEScannerBuf64K(scanBuf)
|
||||
defer close(events)
|
||||
for scanner.Scan() {
|
||||
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
|
||||
@@ -3629,7 +3652,7 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context
|
||||
if err := scanner.Err(); err != nil {
|
||||
_ = sendEvent(scanEvent{err: err})
|
||||
}
|
||||
}()
|
||||
}(scanBuf)
|
||||
defer close(done)
|
||||
|
||||
streamInterval := time.Duration(0)
|
||||
@@ -3681,7 +3704,7 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context
|
||||
return &antigravityStreamResult{usage: finishUsage(), firstTokenMs: firstTokenMs, clientDisconnect: disconnect}, nil
|
||||
}
|
||||
if errors.Is(ev.err, bufio.ErrTooLong) {
|
||||
log.Printf("SSE line too long (antigravity): max_size=%d error=%v", maxLineSize, ev.err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "SSE line too long (antigravity): max_size=%d error=%v", maxLineSize, ev.err)
|
||||
sendErrorEvent("response_too_large")
|
||||
return &antigravityStreamResult{usage: convertUsage(nil), firstTokenMs: firstTokenMs}, ev.err
|
||||
}
|
||||
@@ -3705,10 +3728,10 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context
|
||||
continue
|
||||
}
|
||||
if cw.Disconnected() {
|
||||
log.Printf("Upstream timeout after client disconnect (antigravity claude), returning collected usage")
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Upstream timeout after client disconnect (antigravity claude), returning collected usage")
|
||||
return &antigravityStreamResult{usage: finishUsage(), firstTokenMs: firstTokenMs, clientDisconnect: true}, nil
|
||||
}
|
||||
log.Printf("Stream data interval timeout (antigravity)")
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity)")
|
||||
sendErrorEvent("stream_timeout")
|
||||
return &antigravityStreamResult{usage: convertUsage(nil), firstTokenMs: firstTokenMs}, fmt.Errorf("stream data interval timeout")
|
||||
}
|
||||
@@ -3733,14 +3756,17 @@ func (s *AntigravityGatewayService) extractImageSize(body []byte) string {
|
||||
}
|
||||
|
||||
// isImageGenerationModel 判断模型是否为图片生成模型
|
||||
// 支持的模型:gemini-3-pro-image, gemini-3-pro-image-preview, gemini-2.5-flash-image 等
|
||||
// 支持的模型:gemini-3.1-flash-image, gemini-3-pro-image, gemini-2.5-flash-image 等
|
||||
func isImageGenerationModel(model string) bool {
|
||||
modelLower := strings.ToLower(model)
|
||||
// 移除 models/ 前缀
|
||||
modelLower = strings.TrimPrefix(modelLower, "models/")
|
||||
|
||||
// 精确匹配或前缀匹配
|
||||
return modelLower == "gemini-3-pro-image" ||
|
||||
return modelLower == "gemini-3.1-flash-image" ||
|
||||
modelLower == "gemini-3.1-flash-image-preview" ||
|
||||
strings.HasPrefix(modelLower, "gemini-3.1-flash-image-") ||
|
||||
modelLower == "gemini-3-pro-image" ||
|
||||
modelLower == "gemini-3-pro-image-preview" ||
|
||||
strings.HasPrefix(modelLower, "gemini-3-pro-image-") ||
|
||||
modelLower == "gemini-2.5-flash-image" ||
|
||||
@@ -3875,7 +3901,6 @@ func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin.
|
||||
return nil, fmt.Errorf("missing model")
|
||||
}
|
||||
originalModel := claudeReq.Model
|
||||
billingModel := originalModel
|
||||
|
||||
// 构建上游请求 URL
|
||||
upstreamURL := baseURL + "/v1/messages"
|
||||
@@ -3908,7 +3933,7 @@ func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin.
|
||||
// 发送请求
|
||||
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
|
||||
if err != nil {
|
||||
log.Printf("%s upstream request failed: %v", prefix, err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s upstream request failed: %v", prefix, err)
|
||||
return nil, fmt.Errorf("upstream request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
@@ -3928,7 +3953,7 @@ func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin.
|
||||
_, _ = c.Writer.Write(respBody)
|
||||
|
||||
return &ForwardResult{
|
||||
Model: billingModel,
|
||||
Model: originalModel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -3966,10 +3991,10 @@ func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin.
|
||||
|
||||
// 构建计费结果
|
||||
duration := time.Since(startTime)
|
||||
log.Printf("%s status=success duration_ms=%d", prefix, duration.Milliseconds())
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=success duration_ms=%d", prefix, duration.Milliseconds())
|
||||
|
||||
return &ForwardResult{
|
||||
Model: billingModel,
|
||||
Model: originalModel,
|
||||
Stream: claudeReq.Stream,
|
||||
Duration: duration,
|
||||
FirstTokenMs: firstTokenMs,
|
||||
@@ -4052,7 +4077,7 @@ func (s *AntigravityGatewayService) streamUpstreamResponse(c *gin.Context, resp
|
||||
if disconnect, handled := handleStreamReadError(ev.err, cw.Disconnected(), "antigravity upstream"); handled {
|
||||
return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: disconnect}
|
||||
}
|
||||
log.Printf("Stream read error (antigravity upstream): %v", ev.err)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Stream read error (antigravity upstream): %v", ev.err)
|
||||
return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs}
|
||||
}
|
||||
|
||||
@@ -4076,10 +4101,10 @@ func (s *AntigravityGatewayService) streamUpstreamResponse(c *gin.Context, resp
|
||||
continue
|
||||
}
|
||||
if cw.Disconnected() {
|
||||
log.Printf("Upstream timeout after client disconnect (antigravity upstream), returning collected usage")
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Upstream timeout after client disconnect (antigravity upstream), returning collected usage")
|
||||
return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}
|
||||
}
|
||||
log.Printf("Stream data interval timeout (antigravity upstream)")
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity upstream)")
|
||||
return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs}
|
||||
}
|
||||
}
|
||||
@@ -4111,6 +4136,15 @@ func (s *AntigravityGatewayService) extractSSEUsage(line string, usage *ClaudeUs
|
||||
if v, ok := u["cache_creation_input_tokens"].(float64); ok && int(v) > 0 {
|
||||
usage.CacheCreationInputTokens = int(v)
|
||||
}
|
||||
// 解析嵌套的 cache_creation 对象中的 5m/1h 明细
|
||||
if cc, ok := u["cache_creation"].(map[string]any); ok {
|
||||
if v, ok := cc["ephemeral_5m_input_tokens"].(float64); ok {
|
||||
usage.CacheCreation5mTokens = int(v)
|
||||
}
|
||||
if v, ok := cc["ephemeral_1h_input_tokens"].(float64); ok {
|
||||
usage.CacheCreation1hTokens = int(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractClaudeUsage 从非流式 Claude 响应提取 usage
|
||||
@@ -4133,6 +4167,15 @@ func (s *AntigravityGatewayService) extractClaudeUsage(body []byte) *ClaudeUsage
|
||||
if v, ok := u["cache_creation_input_tokens"].(float64); ok {
|
||||
usage.CacheCreationInputTokens = int(v)
|
||||
}
|
||||
// 解析嵌套的 cache_creation 对象中的 5m/1h 明细
|
||||
if cc, ok := u["cache_creation"].(map[string]any); ok {
|
||||
if v, ok := cc["ephemeral_5m_input_tokens"].(float64); ok {
|
||||
usage.CacheCreation5mTokens = int(v)
|
||||
}
|
||||
if v, ok := cc["ephemeral_1h_input_tokens"].(float64); ok {
|
||||
usage.CacheCreation1hTokens = int(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -133,6 +134,36 @@ func (s *httpUpstreamStub) DoWithTLS(_ *http.Request, _ string, _ int64, _ int,
|
||||
return s.resp, s.err
|
||||
}
|
||||
|
||||
type antigravitySettingRepoStub struct{}
|
||||
|
||||
func (s *antigravitySettingRepoStub) Get(ctx context.Context, key string) (*Setting, error) {
|
||||
panic("unexpected Get call")
|
||||
}
|
||||
|
||||
func (s *antigravitySettingRepoStub) GetValue(ctx context.Context, key string) (string, error) {
|
||||
return "", ErrSettingNotFound
|
||||
}
|
||||
|
||||
func (s *antigravitySettingRepoStub) Set(ctx context.Context, key, value string) error {
|
||||
panic("unexpected Set call")
|
||||
}
|
||||
|
||||
func (s *antigravitySettingRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
|
||||
panic("unexpected GetMultiple call")
|
||||
}
|
||||
|
||||
func (s *antigravitySettingRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error {
|
||||
panic("unexpected SetMultiple call")
|
||||
}
|
||||
|
||||
func (s *antigravitySettingRepoStub) GetAll(ctx context.Context) (map[string]string, error) {
|
||||
panic("unexpected GetAll call")
|
||||
}
|
||||
|
||||
func (s *antigravitySettingRepoStub) Delete(ctx context.Context, key string) error {
|
||||
panic("unexpected Delete call")
|
||||
}
|
||||
|
||||
func TestAntigravityGatewayService_Forward_PromptTooLong(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
writer := httptest.NewRecorder()
|
||||
@@ -159,8 +190,9 @@ func TestAntigravityGatewayService_Forward_PromptTooLong(t *testing.T) {
|
||||
}
|
||||
|
||||
svc := &AntigravityGatewayService{
|
||||
tokenProvider: &AntigravityTokenProvider{},
|
||||
httpUpstream: &httpUpstreamStub{resp: resp},
|
||||
settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
|
||||
tokenProvider: &AntigravityTokenProvider{},
|
||||
httpUpstream: &httpUpstreamStub{resp: resp},
|
||||
}
|
||||
|
||||
account := &Account{
|
||||
@@ -417,6 +449,151 @@ func TestAntigravityGatewayService_ForwardGemini_StickySessionForceCacheBilling(
|
||||
require.True(t, failoverErr.ForceCacheBilling, "ForceCacheBilling should be true for sticky session switch")
|
||||
}
|
||||
|
||||
// TestAntigravityGatewayService_Forward_BillsWithMappedModel
|
||||
// 验证:Antigravity Claude 转发返回的计费模型使用映射后的模型
|
||||
func TestAntigravityGatewayService_Forward_BillsWithMappedModel(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
writer := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(writer)
|
||||
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"model": "claude-sonnet-4-5",
|
||||
"messages": []map[string]any{
|
||||
{"role": "user", "content": "hello"},
|
||||
},
|
||||
"max_tokens": 16,
|
||||
"stream": true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
|
||||
c.Request = req
|
||||
|
||||
upstreamBody := []byte("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":3}}}\n\n")
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"X-Request-Id": []string{"req-bill-1"}},
|
||||
Body: io.NopCloser(bytes.NewReader(upstreamBody)),
|
||||
}
|
||||
|
||||
svc := &AntigravityGatewayService{
|
||||
settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
|
||||
tokenProvider: &AntigravityTokenProvider{},
|
||||
httpUpstream: &httpUpstreamStub{resp: resp},
|
||||
}
|
||||
|
||||
const mappedModel = "gemini-3-pro-high"
|
||||
account := &Account{
|
||||
ID: 5,
|
||||
Name: "acc-forward-billing",
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token",
|
||||
"model_mapping": map[string]any{
|
||||
"claude-sonnet-4-5": mappedModel,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, body, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, mappedModel, result.Model)
|
||||
}
|
||||
|
||||
// TestAntigravityGatewayService_ForwardGemini_BillsWithMappedModel
|
||||
// 验证:Antigravity Gemini 转发返回的计费模型使用映射后的模型
|
||||
func TestAntigravityGatewayService_ForwardGemini_BillsWithMappedModel(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
writer := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(writer)
|
||||
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"contents": []map[string]any{
|
||||
{"role": "user", "parts": []map[string]any{{"text": "hello"}}},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", bytes.NewReader(body))
|
||||
c.Request = req
|
||||
|
||||
upstreamBody := []byte("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":3}}}\n\n")
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"X-Request-Id": []string{"req-bill-2"}},
|
||||
Body: io.NopCloser(bytes.NewReader(upstreamBody)),
|
||||
}
|
||||
|
||||
svc := &AntigravityGatewayService{
|
||||
settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
|
||||
tokenProvider: &AntigravityTokenProvider{},
|
||||
httpUpstream: &httpUpstreamStub{resp: resp},
|
||||
}
|
||||
|
||||
const mappedModel = "gemini-3-pro-high"
|
||||
account := &Account{
|
||||
ID: 6,
|
||||
Name: "acc-gemini-billing",
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token",
|
||||
"model_mapping": map[string]any{
|
||||
"gemini-2.5-flash": mappedModel,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "generateContent", true, body, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, mappedModel, result.Model)
|
||||
}
|
||||
|
||||
// TestStreamUpstreamResponse_UsageAndFirstToken
|
||||
// 验证:usage 字段可被累积/覆盖更新,并且能记录首 token 时间
|
||||
func TestStreamUpstreamResponse_UsageAndFirstToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newAntigravityTestService(&config.Config{
|
||||
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: pr}
|
||||
|
||||
go func() {
|
||||
defer func() { _ = pw.Close() }()
|
||||
fmt.Fprintln(pw, `data: {"usage":{"input_tokens":1,"output_tokens":2,"cache_read_input_tokens":3,"cache_creation_input_tokens":4}}`)
|
||||
fmt.Fprintln(pw, `data: {"usage":{"output_tokens":5}}`)
|
||||
}()
|
||||
|
||||
start := time.Now().Add(-10 * time.Millisecond)
|
||||
result := svc.streamUpstreamResponse(c, resp, start)
|
||||
_ = pr.Close()
|
||||
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.usage)
|
||||
require.Equal(t, 1, result.usage.InputTokens)
|
||||
// 第二次事件覆盖 output_tokens
|
||||
require.Equal(t, 5, result.usage.OutputTokens)
|
||||
require.Equal(t, 3, result.usage.CacheReadInputTokens)
|
||||
require.Equal(t, 4, result.usage.CacheCreationInputTokens)
|
||||
require.NotNil(t, result.firstTokenMs)
|
||||
|
||||
// 确保有透传输出
|
||||
require.Contains(t, rec.Body.String(), "data:")
|
||||
}
|
||||
|
||||
// --- 流式 happy path 测试 ---
|
||||
|
||||
// TestStreamUpstreamResponse_NormalComplete
|
||||
@@ -920,3 +1097,144 @@ func TestAntigravityClientWriter(t *testing.T) {
|
||||
require.True(t, cw.Disconnected())
|
||||
})
|
||||
}
|
||||
|
||||
// TestUnwrapV1InternalResponse 测试 unwrapV1InternalResponse 的各种输入场景
|
||||
func TestUnwrapV1InternalResponse(t *testing.T) {
|
||||
svc := &AntigravityGatewayService{}
|
||||
|
||||
// 构造 >50KB 的大型 JSON
|
||||
largePadding := strings.Repeat("x", 50*1024)
|
||||
largeInput := []byte(fmt.Sprintf(`{"response":{"id":"big","pad":"%s"}}`, largePadding))
|
||||
largeExpected := fmt.Sprintf(`{"id":"big","pad":"%s"}`, largePadding)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
expected string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "正常 response 包装",
|
||||
input: []byte(`{"response":{"id":"123","content":"hello"}}`),
|
||||
expected: `{"id":"123","content":"hello"}`,
|
||||
},
|
||||
{
|
||||
name: "无 response 透传",
|
||||
input: []byte(`{"id":"456"}`),
|
||||
expected: `{"id":"456"}`,
|
||||
},
|
||||
{
|
||||
name: "空 JSON",
|
||||
input: []byte(`{}`),
|
||||
expected: `{}`,
|
||||
},
|
||||
{
|
||||
name: "response 为 null",
|
||||
input: []byte(`{"response":null}`),
|
||||
expected: `null`,
|
||||
},
|
||||
{
|
||||
name: "response 为基础类型 string",
|
||||
input: []byte(`{"response":"hello"}`),
|
||||
expected: `"hello"`,
|
||||
},
|
||||
{
|
||||
name: "非法 JSON",
|
||||
input: []byte(`not json`),
|
||||
expected: `not json`,
|
||||
},
|
||||
{
|
||||
name: "嵌套 response 只解一层",
|
||||
input: []byte(`{"response":{"response":{"inner":true}}}`),
|
||||
expected: `{"response":{"inner":true}}`,
|
||||
},
|
||||
{
|
||||
name: "大型 JSON >50KB",
|
||||
input: largeInput,
|
||||
expected: largeExpected,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := svc.unwrapV1InternalResponse(tt.input)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.expected, strings.TrimSpace(string(got)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- unwrapV1InternalResponse benchmark 对照组 ---
|
||||
|
||||
// unwrapV1InternalResponseOld 旧实现:Unmarshal+Marshal 双重开销(仅用于 benchmark 对照)
|
||||
func unwrapV1InternalResponseOld(body []byte) ([]byte, error) {
|
||||
var outer map[string]any
|
||||
if err := json.Unmarshal(body, &outer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp, ok := outer["response"]; ok {
|
||||
return json.Marshal(resp)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func BenchmarkUnwrapV1Internal_Old_Small(b *testing.B) {
|
||||
body := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"hello world"}]}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}}`)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = unwrapV1InternalResponseOld(body)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnwrapV1Internal_New_Small(b *testing.B) {
|
||||
body := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"hello world"}]}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}}`)
|
||||
svc := &AntigravityGatewayService{}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = svc.unwrapV1InternalResponse(body)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnwrapV1Internal_Old_Large(b *testing.B) {
|
||||
body := generateLargeUnwrapJSON(10 * 1024) // ~10KB
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = unwrapV1InternalResponseOld(body)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnwrapV1Internal_New_Large(b *testing.B) {
|
||||
body := generateLargeUnwrapJSON(10 * 1024) // ~10KB
|
||||
svc := &AntigravityGatewayService{}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = svc.unwrapV1InternalResponse(body)
|
||||
}
|
||||
}
|
||||
|
||||
// generateLargeUnwrapJSON 生成指定最小大小的包含 response 包装的 JSON
|
||||
func generateLargeUnwrapJSON(minSize int) []byte {
|
||||
parts := make([]map[string]string, 0)
|
||||
current := 0
|
||||
for current < minSize {
|
||||
text := fmt.Sprintf("这是第 %d 段内容,用于填充 JSON 到目标大小。", len(parts)+1)
|
||||
parts = append(parts, map[string]string{"text": text})
|
||||
current += len(text) + 20 // 估算 JSON 编码开销
|
||||
}
|
||||
inner := map[string]any{
|
||||
"candidates": []map[string]any{
|
||||
{"content": map[string]any{"parts": parts}},
|
||||
},
|
||||
"usageMetadata": map[string]any{
|
||||
"promptTokenCount": 100,
|
||||
"candidatesTokenCount": 50,
|
||||
},
|
||||
}
|
||||
outer := map[string]any{"response": inner}
|
||||
b, _ := json.Marshal(outer)
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -76,6 +76,12 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) {
|
||||
},
|
||||
|
||||
// 3. 默认映射中的透传(映射到自己)
|
||||
{
|
||||
name: "默认映射透传 - claude-sonnet-4-6",
|
||||
requestedModel: "claude-sonnet-4-6",
|
||||
accountMapping: nil,
|
||||
expected: "claude-sonnet-4-6",
|
||||
},
|
||||
{
|
||||
name: "默认映射透传 - claude-sonnet-4-5",
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
|
||||
@@ -112,7 +112,10 @@ func (s *AntigravityOAuthService) ExchangeCode(ctx context.Context, input *Antig
|
||||
}
|
||||
}
|
||||
|
||||
client := antigravity.NewClient(proxyURL)
|
||||
client, err := antigravity.NewClient(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create antigravity client failed: %w", err)
|
||||
}
|
||||
|
||||
// 交换 token
|
||||
tokenResp, err := client.ExchangeCode(ctx, input.Code, session.CodeVerifier)
|
||||
@@ -167,7 +170,10 @@ func (s *AntigravityOAuthService) RefreshToken(ctx context.Context, refreshToken
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
|
||||
client := antigravity.NewClient(proxyURL)
|
||||
client, err := antigravity.NewClient(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create antigravity client failed: %w", err)
|
||||
}
|
||||
tokenResp, err := client.RefreshToken(ctx, refreshToken)
|
||||
if err == nil {
|
||||
now := time.Now()
|
||||
@@ -209,7 +215,10 @@ func (s *AntigravityOAuthService) ValidateRefreshToken(ctx context.Context, refr
|
||||
}
|
||||
|
||||
// 获取用户信息(email)
|
||||
client := antigravity.NewClient(proxyURL)
|
||||
client, err := antigravity.NewClient(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create antigravity client failed: %w", err)
|
||||
}
|
||||
userInfo, err := client.GetUserInfo(ctx, tokenInfo.AccessToken)
|
||||
if err != nil {
|
||||
fmt.Printf("[AntigravityOAuth] 警告: 获取用户信息失败: %v\n", err)
|
||||
@@ -309,7 +318,10 @@ func (s *AntigravityOAuthService) loadProjectIDWithRetry(ctx context.Context, ac
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
|
||||
client := antigravity.NewClient(proxyURL)
|
||||
client, err := antigravity.NewClient(proxyURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create antigravity client failed: %w", err)
|
||||
}
|
||||
loadResp, loadRaw, err := client.LoadCodeAssist(ctx, accessToken)
|
||||
|
||||
if err == nil && loadResp != nil && loadResp.CloudAICompanionProject != "" {
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
@@ -31,7 +32,10 @@ func (f *AntigravityQuotaFetcher) FetchQuota(ctx context.Context, account *Accou
|
||||
accessToken := account.GetCredential("access_token")
|
||||
projectID := account.GetCredential("project_id")
|
||||
|
||||
client := antigravity.NewClient(proxyURL)
|
||||
client, err := antigravity.NewClient(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create antigravity client failed: %w", err)
|
||||
}
|
||||
|
||||
// 调用 API 获取配额
|
||||
modelsResp, modelsRaw, err := client.FetchAvailableModels(ctx, accessToken, projectID)
|
||||
|
||||
@@ -15,6 +15,12 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 编译期接口断言
|
||||
var _ HTTPUpstream = (*stubAntigravityUpstream)(nil)
|
||||
var _ HTTPUpstream = (*recordingOKUpstream)(nil)
|
||||
var _ AccountRepository = (*stubAntigravityAccountRepo)(nil)
|
||||
var _ SchedulerCache = (*stubSchedulerCache)(nil)
|
||||
|
||||
type stubAntigravityUpstream struct {
|
||||
firstBase string
|
||||
secondBase string
|
||||
@@ -191,6 +197,22 @@ func TestHandleUpstreamError_429_NonModelRateLimit(t *testing.T) {
|
||||
require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].modelKey)
|
||||
}
|
||||
|
||||
// TestHandleUpstreamError_429_NonModelRateLimit_UsesMappedModelKey 测试 429 非模型限流场景
|
||||
// 验证:requestedModel 会被映射到 Antigravity 最终模型(例如 claude-opus-4-6 -> claude-opus-4-6-thinking)
|
||||
func TestHandleUpstreamError_429_NonModelRateLimit_UsesMappedModelKey(t *testing.T) {
|
||||
repo := &stubAntigravityAccountRepo{}
|
||||
svc := &AntigravityGatewayService{accountRepo: repo}
|
||||
account := &Account{ID: 20, Name: "acc-20", Platform: PlatformAntigravity}
|
||||
|
||||
body := buildGeminiRateLimitBody("5s")
|
||||
|
||||
result := svc.handleUpstreamError(context.Background(), "[test]", account, http.StatusTooManyRequests, http.Header{}, body, "claude-opus-4-6", 0, "", false)
|
||||
|
||||
require.Nil(t, result)
|
||||
require.Len(t, repo.modelRateLimitCalls, 1)
|
||||
require.Equal(t, "claude-opus-4-6-thinking", repo.modelRateLimitCalls[0].modelKey)
|
||||
}
|
||||
|
||||
// TestHandleUpstreamError_503_ModelCapacityExhausted 测试 503 模型容量不足场景
|
||||
// MODEL_CAPACITY_EXHAUSTED 时应等待重试,不切换账号
|
||||
func TestHandleUpstreamError_503_ModelCapacityExhausted(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package service
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
)
|
||||
|
||||
// API Key status constants
|
||||
const (
|
||||
@@ -19,21 +23,41 @@ type APIKey struct {
|
||||
Status string
|
||||
IPWhitelist []string
|
||||
IPBlacklist []string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
User *User
|
||||
Group *Group
|
||||
// 预编译的 IP 规则,用于认证热路径避免重复 ParseIP/ParseCIDR。
|
||||
CompiledIPWhitelist *ip.CompiledIPRules `json:"-"`
|
||||
CompiledIPBlacklist *ip.CompiledIPRules `json:"-"`
|
||||
LastUsedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
User *User
|
||||
Group *Group
|
||||
|
||||
// Quota fields
|
||||
Quota float64 // Quota limit in USD (0 = unlimited)
|
||||
QuotaUsed float64 // Used quota amount
|
||||
ExpiresAt *time.Time // Expiration time (nil = never expires)
|
||||
|
||||
// Rate limit fields
|
||||
RateLimit5h float64 // Rate limit in USD per 5h (0 = unlimited)
|
||||
RateLimit1d float64 // Rate limit in USD per 1d (0 = unlimited)
|
||||
RateLimit7d float64 // Rate limit in USD per 7d (0 = unlimited)
|
||||
Usage5h float64 // Used amount in current 5h window
|
||||
Usage1d float64 // Used amount in current 1d window
|
||||
Usage7d float64 // Used amount in current 7d window
|
||||
Window5hStart *time.Time // Start of current 5h window
|
||||
Window1dStart *time.Time // Start of current 1d window
|
||||
Window7dStart *time.Time // Start of current 7d window
|
||||
}
|
||||
|
||||
func (k *APIKey) IsActive() bool {
|
||||
return k.Status == StatusActive
|
||||
}
|
||||
|
||||
// HasRateLimits returns true if any rate limit window is configured
|
||||
func (k *APIKey) HasRateLimits() bool {
|
||||
return k.RateLimit5h > 0 || k.RateLimit1d > 0 || k.RateLimit7d > 0
|
||||
}
|
||||
|
||||
// IsExpired checks if the API key has expired
|
||||
func (k *APIKey) IsExpired() bool {
|
||||
if k.ExpiresAt == nil {
|
||||
@@ -73,3 +97,10 @@ func (k *APIKey) GetDaysUntilExpiry() int {
|
||||
}
|
||||
return int(duration.Hours() / 24)
|
||||
}
|
||||
|
||||
// APIKeyListFilters holds optional filtering parameters for listing API keys.
|
||||
type APIKeyListFilters struct {
|
||||
Search string
|
||||
Status string
|
||||
GroupID *int64 // nil=不筛选, 0=无分组, >0=指定分组
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ type APIKeyAuthSnapshot struct {
|
||||
|
||||
// Expiration field for API Key expiration feature
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"` // Expiration time (nil = never expires)
|
||||
|
||||
// Rate limit configuration (only limits, not usage - usage read from Redis at check time)
|
||||
RateLimit5h float64 `json:"rate_limit_5h"`
|
||||
RateLimit1d float64 `json:"rate_limit_1d"`
|
||||
RateLimit7d float64 `json:"rate_limit_7d"`
|
||||
}
|
||||
|
||||
// APIKeyAuthUserSnapshot 用户快照
|
||||
@@ -44,6 +49,10 @@ type APIKeyAuthGroupSnapshot struct {
|
||||
ImagePrice1K *float64 `json:"image_price_1k,omitempty"`
|
||||
ImagePrice2K *float64 `json:"image_price_2k,omitempty"`
|
||||
ImagePrice4K *float64 `json:"image_price_4k,omitempty"`
|
||||
SoraImagePrice360 *float64 `json:"sora_image_price_360,omitempty"`
|
||||
SoraImagePrice540 *float64 `json:"sora_image_price_540,omitempty"`
|
||||
SoraVideoPricePerRequest *float64 `json:"sora_video_price_per_request,omitempty"`
|
||||
SoraVideoPricePerRequestHD *float64 `json:"sora_video_price_per_request_hd,omitempty"`
|
||||
ClaudeCodeOnly bool `json:"claude_code_only"`
|
||||
FallbackGroupID *int64 `json:"fallback_group_id,omitempty"`
|
||||
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request,omitempty"`
|
||||
|
||||
@@ -6,8 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
@@ -23,12 +22,6 @@ type apiKeyAuthCacheConfig struct {
|
||||
singleflight bool
|
||||
}
|
||||
|
||||
var (
|
||||
jitterRandMu sync.Mutex
|
||||
// 认证缓存抖动使用独立随机源,避免全局 Seed
|
||||
jitterRand = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
)
|
||||
|
||||
func newAPIKeyAuthCacheConfig(cfg *config.Config) apiKeyAuthCacheConfig {
|
||||
if cfg == nil {
|
||||
return apiKeyAuthCacheConfig{}
|
||||
@@ -56,6 +49,8 @@ func (c apiKeyAuthCacheConfig) negativeEnabled() bool {
|
||||
return c.negativeTTL > 0
|
||||
}
|
||||
|
||||
// jitterTTL 为缓存 TTL 添加抖动,避免多个请求在同一时刻同时过期触发集中回源。
|
||||
// 这里直接使用 rand/v2 的顶层函数:并发安全,无需全局互斥锁。
|
||||
func (c apiKeyAuthCacheConfig) jitterTTL(ttl time.Duration) time.Duration {
|
||||
if ttl <= 0 {
|
||||
return ttl
|
||||
@@ -68,9 +63,7 @@ func (c apiKeyAuthCacheConfig) jitterTTL(ttl time.Duration) time.Duration {
|
||||
percent = 100
|
||||
}
|
||||
delta := float64(percent) / 100
|
||||
jitterRandMu.Lock()
|
||||
randVal := jitterRand.Float64()
|
||||
jitterRandMu.Unlock()
|
||||
randVal := rand.Float64()
|
||||
factor := 1 - delta + randVal*(2*delta)
|
||||
if factor <= 0 {
|
||||
return ttl
|
||||
@@ -216,6 +209,9 @@ func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot {
|
||||
Quota: apiKey.Quota,
|
||||
QuotaUsed: apiKey.QuotaUsed,
|
||||
ExpiresAt: apiKey.ExpiresAt,
|
||||
RateLimit5h: apiKey.RateLimit5h,
|
||||
RateLimit1d: apiKey.RateLimit1d,
|
||||
RateLimit7d: apiKey.RateLimit7d,
|
||||
User: APIKeyAuthUserSnapshot{
|
||||
ID: apiKey.User.ID,
|
||||
Status: apiKey.User.Status,
|
||||
@@ -238,6 +234,10 @@ func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot {
|
||||
ImagePrice1K: apiKey.Group.ImagePrice1K,
|
||||
ImagePrice2K: apiKey.Group.ImagePrice2K,
|
||||
ImagePrice4K: apiKey.Group.ImagePrice4K,
|
||||
SoraImagePrice360: apiKey.Group.SoraImagePrice360,
|
||||
SoraImagePrice540: apiKey.Group.SoraImagePrice540,
|
||||
SoraVideoPricePerRequest: apiKey.Group.SoraVideoPricePerRequest,
|
||||
SoraVideoPricePerRequestHD: apiKey.Group.SoraVideoPricePerRequestHD,
|
||||
ClaudeCodeOnly: apiKey.Group.ClaudeCodeOnly,
|
||||
FallbackGroupID: apiKey.Group.FallbackGroupID,
|
||||
FallbackGroupIDOnInvalidRequest: apiKey.Group.FallbackGroupIDOnInvalidRequest,
|
||||
@@ -265,6 +265,9 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
|
||||
Quota: snapshot.Quota,
|
||||
QuotaUsed: snapshot.QuotaUsed,
|
||||
ExpiresAt: snapshot.ExpiresAt,
|
||||
RateLimit5h: snapshot.RateLimit5h,
|
||||
RateLimit1d: snapshot.RateLimit1d,
|
||||
RateLimit7d: snapshot.RateLimit7d,
|
||||
User: &User{
|
||||
ID: snapshot.User.ID,
|
||||
Status: snapshot.User.Status,
|
||||
@@ -288,6 +291,10 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
|
||||
ImagePrice1K: snapshot.Group.ImagePrice1K,
|
||||
ImagePrice2K: snapshot.Group.ImagePrice2K,
|
||||
ImagePrice4K: snapshot.Group.ImagePrice4K,
|
||||
SoraImagePrice360: snapshot.Group.SoraImagePrice360,
|
||||
SoraImagePrice540: snapshot.Group.SoraImagePrice540,
|
||||
SoraVideoPricePerRequest: snapshot.Group.SoraVideoPricePerRequest,
|
||||
SoraVideoPricePerRequestHD: snapshot.Group.SoraVideoPricePerRequestHD,
|
||||
ClaudeCodeOnly: snapshot.Group.ClaudeCodeOnly,
|
||||
FallbackGroupID: snapshot.Group.FallbackGroupID,
|
||||
FallbackGroupIDOnInvalidRequest: snapshot.Group.FallbackGroupIDOnInvalidRequest,
|
||||
@@ -297,5 +304,6 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
|
||||
SupportedModelScopes: snapshot.Group.SupportedModelScopes,
|
||||
}
|
||||
}
|
||||
s.compileAPIKeyIPRules(apiKey)
|
||||
return apiKey
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
@@ -28,10 +30,18 @@ var (
|
||||
ErrAPIKeyExpired = infraerrors.Forbidden("API_KEY_EXPIRED", "api key 已过期")
|
||||
// ErrAPIKeyQuotaExhausted = infraerrors.TooManyRequests("API_KEY_QUOTA_EXHAUSTED", "api key quota exhausted")
|
||||
ErrAPIKeyQuotaExhausted = infraerrors.TooManyRequests("API_KEY_QUOTA_EXHAUSTED", "api key 额度已用完")
|
||||
|
||||
// Rate limit errors
|
||||
ErrAPIKeyRateLimit5hExceeded = infraerrors.TooManyRequests("API_KEY_RATE_5H_EXCEEDED", "api key 5小时限额已用完")
|
||||
ErrAPIKeyRateLimit1dExceeded = infraerrors.TooManyRequests("API_KEY_RATE_1D_EXCEEDED", "api key 日限额已用完")
|
||||
ErrAPIKeyRateLimit7dExceeded = infraerrors.TooManyRequests("API_KEY_RATE_7D_EXCEEDED", "api key 7天限额已用完")
|
||||
)
|
||||
|
||||
const (
|
||||
apiKeyMaxErrorsPerHour = 20
|
||||
apiKeyLastUsedMinTouch = 30 * time.Second
|
||||
// DB 写失败后的短退避,避免请求路径持续同步重试造成写风暴与高延迟。
|
||||
apiKeyLastUsedFailBackoff = 5 * time.Second
|
||||
)
|
||||
|
||||
type APIKeyRepository interface {
|
||||
@@ -45,7 +55,7 @@ type APIKeyRepository interface {
|
||||
Update(ctx context.Context, key *APIKey) error
|
||||
Delete(ctx context.Context, id int64) error
|
||||
|
||||
ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]APIKey, *pagination.PaginationResult, error)
|
||||
ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error)
|
||||
VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error)
|
||||
CountByUserID(ctx context.Context, userID int64) (int64, error)
|
||||
ExistsByKey(ctx context.Context, key string) (bool, error)
|
||||
@@ -58,6 +68,22 @@ type APIKeyRepository interface {
|
||||
|
||||
// Quota methods
|
||||
IncrementQuotaUsed(ctx context.Context, id int64, amount float64) (float64, error)
|
||||
UpdateLastUsed(ctx context.Context, id int64, usedAt time.Time) error
|
||||
|
||||
// Rate limit methods
|
||||
IncrementRateLimitUsage(ctx context.Context, id int64, cost float64) error
|
||||
ResetRateLimitWindows(ctx context.Context, id int64) error
|
||||
GetRateLimitData(ctx context.Context, id int64) (*APIKeyRateLimitData, error)
|
||||
}
|
||||
|
||||
// APIKeyRateLimitData holds rate limit usage and window state for an API key.
|
||||
type APIKeyRateLimitData struct {
|
||||
Usage5h float64
|
||||
Usage1d float64
|
||||
Usage7d float64
|
||||
Window5hStart *time.Time
|
||||
Window1dStart *time.Time
|
||||
Window7dStart *time.Time
|
||||
}
|
||||
|
||||
// APIKeyCache defines cache operations for API key service
|
||||
@@ -96,6 +122,11 @@ type CreateAPIKeyRequest struct {
|
||||
// Quota fields
|
||||
Quota float64 `json:"quota"` // Quota limit in USD (0 = unlimited)
|
||||
ExpiresInDays *int `json:"expires_in_days"` // Days until expiry (nil = never expires)
|
||||
|
||||
// Rate limit fields (0 = unlimited)
|
||||
RateLimit5h float64 `json:"rate_limit_5h"`
|
||||
RateLimit1d float64 `json:"rate_limit_1d"`
|
||||
RateLimit7d float64 `json:"rate_limit_7d"`
|
||||
}
|
||||
|
||||
// UpdateAPIKeyRequest 更新API Key请求
|
||||
@@ -111,20 +142,34 @@ type UpdateAPIKeyRequest struct {
|
||||
ExpiresAt *time.Time `json:"expires_at"` // Expiration time (nil = no change)
|
||||
ClearExpiration bool `json:"-"` // Clear expiration (internal use)
|
||||
ResetQuota *bool `json:"reset_quota"` // Reset quota_used to 0
|
||||
|
||||
// Rate limit fields (nil = no change, 0 = unlimited)
|
||||
RateLimit5h *float64 `json:"rate_limit_5h"`
|
||||
RateLimit1d *float64 `json:"rate_limit_1d"`
|
||||
RateLimit7d *float64 `json:"rate_limit_7d"`
|
||||
ResetRateLimitUsage *bool `json:"reset_rate_limit_usage"` // Reset all usage counters to 0
|
||||
}
|
||||
|
||||
// APIKeyService API Key服务
|
||||
// RateLimitCacheInvalidator invalidates rate limit cache entries on manual reset.
|
||||
type RateLimitCacheInvalidator interface {
|
||||
InvalidateAPIKeyRateLimit(ctx context.Context, keyID int64) error
|
||||
}
|
||||
|
||||
type APIKeyService struct {
|
||||
apiKeyRepo APIKeyRepository
|
||||
userRepo UserRepository
|
||||
groupRepo GroupRepository
|
||||
userSubRepo UserSubscriptionRepository
|
||||
userGroupRateRepo UserGroupRateRepository
|
||||
cache APIKeyCache
|
||||
cfg *config.Config
|
||||
authCacheL1 *ristretto.Cache
|
||||
authCfg apiKeyAuthCacheConfig
|
||||
authGroup singleflight.Group
|
||||
apiKeyRepo APIKeyRepository
|
||||
userRepo UserRepository
|
||||
groupRepo GroupRepository
|
||||
userSubRepo UserSubscriptionRepository
|
||||
userGroupRateRepo UserGroupRateRepository
|
||||
cache APIKeyCache
|
||||
rateLimitCacheInvalid RateLimitCacheInvalidator // optional: invalidate Redis rate limit cache
|
||||
cfg *config.Config
|
||||
authCacheL1 *ristretto.Cache
|
||||
authCfg apiKeyAuthCacheConfig
|
||||
authGroup singleflight.Group
|
||||
lastUsedTouchL1 sync.Map // keyID -> nextAllowedAt(time.Time)
|
||||
lastUsedTouchSF singleflight.Group
|
||||
}
|
||||
|
||||
// NewAPIKeyService 创建API Key服务实例
|
||||
@@ -150,6 +195,20 @@ func NewAPIKeyService(
|
||||
return svc
|
||||
}
|
||||
|
||||
// SetRateLimitCacheInvalidator sets the optional rate limit cache invalidator.
|
||||
// Called after construction (e.g. in wire) to avoid circular dependencies.
|
||||
func (s *APIKeyService) SetRateLimitCacheInvalidator(inv RateLimitCacheInvalidator) {
|
||||
s.rateLimitCacheInvalid = inv
|
||||
}
|
||||
|
||||
func (s *APIKeyService) compileAPIKeyIPRules(apiKey *APIKey) {
|
||||
if apiKey == nil {
|
||||
return
|
||||
}
|
||||
apiKey.CompiledIPWhitelist = ip.CompileIPRules(apiKey.IPWhitelist)
|
||||
apiKey.CompiledIPBlacklist = ip.CompileIPRules(apiKey.IPBlacklist)
|
||||
}
|
||||
|
||||
// GenerateKey 生成随机API Key
|
||||
func (s *APIKeyService) GenerateKey() (string, error) {
|
||||
// 生成32字节随机数据
|
||||
@@ -311,6 +370,9 @@ func (s *APIKeyService) Create(ctx context.Context, userID int64, req CreateAPIK
|
||||
IPBlacklist: req.IPBlacklist,
|
||||
Quota: req.Quota,
|
||||
QuotaUsed: 0,
|
||||
RateLimit5h: req.RateLimit5h,
|
||||
RateLimit1d: req.RateLimit1d,
|
||||
RateLimit7d: req.RateLimit7d,
|
||||
}
|
||||
|
||||
// Set expiration time if specified
|
||||
@@ -324,13 +386,14 @@ func (s *APIKeyService) Create(ctx context.Context, userID int64, req CreateAPIK
|
||||
}
|
||||
|
||||
s.InvalidateAuthCacheByKey(ctx, apiKey.Key)
|
||||
s.compileAPIKeyIPRules(apiKey)
|
||||
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
// List 获取用户的API Key列表
|
||||
func (s *APIKeyService) List(ctx context.Context, userID int64, params pagination.PaginationParams) ([]APIKey, *pagination.PaginationResult, error) {
|
||||
keys, pagination, err := s.apiKeyRepo.ListByUserID(ctx, userID, params)
|
||||
func (s *APIKeyService) List(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) {
|
||||
keys, pagination, err := s.apiKeyRepo.ListByUserID(ctx, userID, params, filters)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("list api keys: %w", err)
|
||||
}
|
||||
@@ -355,6 +418,7 @@ func (s *APIKeyService) GetByID(ctx context.Context, id int64) (*APIKey, error)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get api key: %w", err)
|
||||
}
|
||||
s.compileAPIKeyIPRules(apiKey)
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
@@ -367,6 +431,7 @@ func (s *APIKeyService) GetByKey(ctx context.Context, key string) (*APIKey, erro
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get api key: %w", err)
|
||||
}
|
||||
s.compileAPIKeyIPRules(apiKey)
|
||||
return apiKey, nil
|
||||
}
|
||||
}
|
||||
@@ -383,6 +448,7 @@ func (s *APIKeyService) GetByKey(ctx context.Context, key string) (*APIKey, erro
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get api key: %w", err)
|
||||
}
|
||||
s.compileAPIKeyIPRules(apiKey)
|
||||
return apiKey, nil
|
||||
}
|
||||
} else {
|
||||
@@ -394,6 +460,7 @@ func (s *APIKeyService) GetByKey(ctx context.Context, key string) (*APIKey, erro
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get api key: %w", err)
|
||||
}
|
||||
s.compileAPIKeyIPRules(apiKey)
|
||||
return apiKey, nil
|
||||
}
|
||||
}
|
||||
@@ -403,6 +470,7 @@ func (s *APIKeyService) GetByKey(ctx context.Context, key string) (*APIKey, erro
|
||||
return nil, fmt.Errorf("get api key: %w", err)
|
||||
}
|
||||
apiKey.Key = key
|
||||
s.compileAPIKeyIPRules(apiKey)
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
@@ -497,11 +565,37 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
|
||||
apiKey.IPWhitelist = req.IPWhitelist
|
||||
apiKey.IPBlacklist = req.IPBlacklist
|
||||
|
||||
// Update rate limit configuration
|
||||
if req.RateLimit5h != nil {
|
||||
apiKey.RateLimit5h = *req.RateLimit5h
|
||||
}
|
||||
if req.RateLimit1d != nil {
|
||||
apiKey.RateLimit1d = *req.RateLimit1d
|
||||
}
|
||||
if req.RateLimit7d != nil {
|
||||
apiKey.RateLimit7d = *req.RateLimit7d
|
||||
}
|
||||
resetRateLimit := req.ResetRateLimitUsage != nil && *req.ResetRateLimitUsage
|
||||
if resetRateLimit {
|
||||
apiKey.Usage5h = 0
|
||||
apiKey.Usage1d = 0
|
||||
apiKey.Usage7d = 0
|
||||
apiKey.Window5hStart = nil
|
||||
apiKey.Window1dStart = nil
|
||||
apiKey.Window7dStart = nil
|
||||
}
|
||||
|
||||
if err := s.apiKeyRepo.Update(ctx, apiKey); err != nil {
|
||||
return nil, fmt.Errorf("update api key: %w", err)
|
||||
}
|
||||
|
||||
s.InvalidateAuthCacheByKey(ctx, apiKey.Key)
|
||||
s.compileAPIKeyIPRules(apiKey)
|
||||
|
||||
// Invalidate Redis rate limit cache so reset takes effect immediately
|
||||
if resetRateLimit && s.rateLimitCacheInvalid != nil {
|
||||
_ = s.rateLimitCacheInvalid.InvalidateAPIKeyRateLimit(ctx, apiKey.ID)
|
||||
}
|
||||
|
||||
return apiKey, nil
|
||||
}
|
||||
@@ -527,6 +621,7 @@ func (s *APIKeyService) Delete(ctx context.Context, id int64, userID int64) erro
|
||||
if err := s.apiKeyRepo.Delete(ctx, id); err != nil {
|
||||
return fmt.Errorf("delete api key: %w", err)
|
||||
}
|
||||
s.lastUsedTouchL1.Delete(id)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -558,6 +653,38 @@ func (s *APIKeyService) ValidateKey(ctx context.Context, key string) (*APIKey, *
|
||||
return apiKey, user, nil
|
||||
}
|
||||
|
||||
// TouchLastUsed 通过防抖更新 api_keys.last_used_at,减少高频写放大。
|
||||
// 该操作为尽力而为,不应阻塞主请求链路。
|
||||
func (s *APIKeyService) TouchLastUsed(ctx context.Context, keyID int64) error {
|
||||
if keyID <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if v, ok := s.lastUsedTouchL1.Load(keyID); ok {
|
||||
if nextAllowedAt, ok := v.(time.Time); ok && now.Before(nextAllowedAt) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
_, err, _ := s.lastUsedTouchSF.Do(strconv.FormatInt(keyID, 10), func() (any, error) {
|
||||
latest := time.Now()
|
||||
if v, ok := s.lastUsedTouchL1.Load(keyID); ok {
|
||||
if nextAllowedAt, ok := v.(time.Time); ok && latest.Before(nextAllowedAt) {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.apiKeyRepo.UpdateLastUsed(ctx, keyID, latest); err != nil {
|
||||
s.lastUsedTouchL1.Store(keyID, latest.Add(apiKeyLastUsedFailBackoff))
|
||||
return nil, fmt.Errorf("touch api key last used: %w", err)
|
||||
}
|
||||
s.lastUsedTouchL1.Store(keyID, latest.Add(apiKeyLastUsedMinTouch))
|
||||
return nil, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// IncrementUsage 增加API Key使用次数(可选:用于统计)
|
||||
func (s *APIKeyService) IncrementUsage(ctx context.Context, keyID int64) error {
|
||||
// 使用Redis计数器
|
||||
@@ -690,3 +817,16 @@ func (s *APIKeyService) UpdateQuotaUsed(ctx context.Context, apiKeyID int64, cos
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRateLimitData returns rate limit usage and window state for an API key.
|
||||
func (s *APIKeyService) GetRateLimitData(ctx context.Context, id int64) (*APIKeyRateLimitData, error) {
|
||||
return s.apiKeyRepo.GetRateLimitData(ctx, id)
|
||||
}
|
||||
|
||||
// UpdateRateLimitUsage atomically increments rate limit usage counters in the DB.
|
||||
func (s *APIKeyService) UpdateRateLimitUsage(ctx context.Context, apiKeyID int64, cost float64) error {
|
||||
if cost <= 0 {
|
||||
return nil
|
||||
}
|
||||
return s.apiKeyRepo.IncrementRateLimitUsage(ctx, apiKeyID, cost)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func (s *authRepoStub) Delete(ctx context.Context, id int64) error {
|
||||
panic("unexpected Delete call")
|
||||
}
|
||||
|
||||
func (s *authRepoStub) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]APIKey, *pagination.PaginationResult, error) {
|
||||
func (s *authRepoStub) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) {
|
||||
panic("unexpected ListByUserID call")
|
||||
}
|
||||
|
||||
@@ -103,6 +103,19 @@ func (s *authRepoStub) IncrementQuotaUsed(ctx context.Context, id int64, amount
|
||||
panic("unexpected IncrementQuotaUsed call")
|
||||
}
|
||||
|
||||
func (s *authRepoStub) UpdateLastUsed(ctx context.Context, id int64, usedAt time.Time) error {
|
||||
panic("unexpected UpdateLastUsed call")
|
||||
}
|
||||
func (s *authRepoStub) IncrementRateLimitUsage(ctx context.Context, id int64, cost float64) error {
|
||||
panic("unexpected IncrementRateLimitUsage call")
|
||||
}
|
||||
func (s *authRepoStub) ResetRateLimitWindows(ctx context.Context, id int64) error {
|
||||
panic("unexpected ResetRateLimitWindows call")
|
||||
}
|
||||
func (s *authRepoStub) GetRateLimitData(ctx context.Context, id int64) (*APIKeyRateLimitData, error) {
|
||||
panic("unexpected GetRateLimitData call")
|
||||
}
|
||||
|
||||
type authCacheStub struct {
|
||||
getAuthCache func(ctx context.Context, key string) (*APIKeyAuthCacheEntry, error)
|
||||
setAuthKeys []string
|
||||
|
||||
@@ -24,10 +24,13 @@ import (
|
||||
// - deleteErr: 模拟 Delete 返回的错误
|
||||
// - deletedIDs: 记录被调用删除的 API Key ID,用于断言验证
|
||||
type apiKeyRepoStub struct {
|
||||
apiKey *APIKey // GetKeyAndOwnerID 的返回值
|
||||
getByIDErr error // GetKeyAndOwnerID 的错误返回值
|
||||
deleteErr error // Delete 的错误返回值
|
||||
deletedIDs []int64 // 记录已删除的 API Key ID 列表
|
||||
apiKey *APIKey // GetKeyAndOwnerID 的返回值
|
||||
getByIDErr error // GetKeyAndOwnerID 的错误返回值
|
||||
deleteErr error // Delete 的错误返回值
|
||||
deletedIDs []int64 // 记录已删除的 API Key ID 列表
|
||||
updateLastUsed func(ctx context.Context, id int64, usedAt time.Time) error
|
||||
touchedIDs []int64
|
||||
touchedUsedAts []time.Time
|
||||
}
|
||||
|
||||
// 以下方法在本测试中不应被调用,使用 panic 确保测试失败时能快速定位问题
|
||||
@@ -78,7 +81,7 @@ func (s *apiKeyRepoStub) Delete(ctx context.Context, id int64) error {
|
||||
|
||||
// 以下是接口要求实现但本测试不关心的方法
|
||||
|
||||
func (s *apiKeyRepoStub) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]APIKey, *pagination.PaginationResult, error) {
|
||||
func (s *apiKeyRepoStub) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) {
|
||||
panic("unexpected ListByUserID call")
|
||||
}
|
||||
|
||||
@@ -122,6 +125,27 @@ func (s *apiKeyRepoStub) IncrementQuotaUsed(ctx context.Context, id int64, amoun
|
||||
panic("unexpected IncrementQuotaUsed call")
|
||||
}
|
||||
|
||||
func (s *apiKeyRepoStub) UpdateLastUsed(ctx context.Context, id int64, usedAt time.Time) error {
|
||||
s.touchedIDs = append(s.touchedIDs, id)
|
||||
s.touchedUsedAts = append(s.touchedUsedAts, usedAt)
|
||||
if s.updateLastUsed != nil {
|
||||
return s.updateLastUsed(ctx, id, usedAt)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *apiKeyRepoStub) IncrementRateLimitUsage(ctx context.Context, id int64, cost float64) error {
|
||||
panic("unexpected IncrementRateLimitUsage call")
|
||||
}
|
||||
|
||||
func (s *apiKeyRepoStub) ResetRateLimitWindows(ctx context.Context, id int64) error {
|
||||
panic("unexpected ResetRateLimitWindows call")
|
||||
}
|
||||
|
||||
func (s *apiKeyRepoStub) GetRateLimitData(ctx context.Context, id int64) (*APIKeyRateLimitData, error) {
|
||||
panic("unexpected GetRateLimitData call")
|
||||
}
|
||||
|
||||
// apiKeyCacheStub 是 APIKeyCache 接口的测试桩实现。
|
||||
// 用于验证删除操作时缓存清理逻辑是否被正确调用。
|
||||
//
|
||||
@@ -214,12 +238,15 @@ func TestApiKeyService_Delete_Success(t *testing.T) {
|
||||
}
|
||||
cache := &apiKeyCacheStub{}
|
||||
svc := &APIKeyService{apiKeyRepo: repo, cache: cache}
|
||||
svc.lastUsedTouchL1.Store(int64(42), time.Now())
|
||||
|
||||
err := svc.Delete(context.Background(), 42, 7) // API Key ID=42, 调用者 userID=7
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []int64{42}, repo.deletedIDs) // 验证正确的 API Key 被删除
|
||||
require.Equal(t, []int64{7}, cache.invalidated) // 验证所有者的缓存被清除
|
||||
require.Equal(t, []string{svc.authCacheKey("k")}, cache.deleteAuthKeys)
|
||||
_, exists := svc.lastUsedTouchL1.Load(int64(42))
|
||||
require.False(t, exists, "delete should clear touch debounce cache")
|
||||
}
|
||||
|
||||
// TestApiKeyService_Delete_NotFound 测试删除不存在的 API Key 时返回正确的错误。
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAPIKeyService_TouchLastUsed_InvalidKeyID(t *testing.T) {
|
||||
repo := &apiKeyRepoStub{
|
||||
updateLastUsed: func(ctx context.Context, id int64, usedAt time.Time) error {
|
||||
return errors.New("should not be called")
|
||||
},
|
||||
}
|
||||
svc := &APIKeyService{apiKeyRepo: repo}
|
||||
|
||||
require.NoError(t, svc.TouchLastUsed(context.Background(), 0))
|
||||
require.NoError(t, svc.TouchLastUsed(context.Background(), -1))
|
||||
require.Empty(t, repo.touchedIDs)
|
||||
}
|
||||
|
||||
func TestAPIKeyService_TouchLastUsed_FirstTouchSucceeds(t *testing.T) {
|
||||
repo := &apiKeyRepoStub{}
|
||||
svc := &APIKeyService{apiKeyRepo: repo}
|
||||
|
||||
err := svc.TouchLastUsed(context.Background(), 123)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []int64{123}, repo.touchedIDs)
|
||||
require.Len(t, repo.touchedUsedAts, 1)
|
||||
require.False(t, repo.touchedUsedAts[0].IsZero())
|
||||
|
||||
cached, ok := svc.lastUsedTouchL1.Load(int64(123))
|
||||
require.True(t, ok, "successful touch should update debounce cache")
|
||||
_, isTime := cached.(time.Time)
|
||||
require.True(t, isTime)
|
||||
}
|
||||
|
||||
func TestAPIKeyService_TouchLastUsed_DebouncedWithinWindow(t *testing.T) {
|
||||
repo := &apiKeyRepoStub{}
|
||||
svc := &APIKeyService{apiKeyRepo: repo}
|
||||
|
||||
require.NoError(t, svc.TouchLastUsed(context.Background(), 123))
|
||||
require.NoError(t, svc.TouchLastUsed(context.Background(), 123))
|
||||
|
||||
require.Equal(t, []int64{123}, repo.touchedIDs, "second touch within debounce window should not hit repository")
|
||||
}
|
||||
|
||||
func TestAPIKeyService_TouchLastUsed_ExpiredDebounceTouchesAgain(t *testing.T) {
|
||||
repo := &apiKeyRepoStub{}
|
||||
svc := &APIKeyService{apiKeyRepo: repo}
|
||||
|
||||
require.NoError(t, svc.TouchLastUsed(context.Background(), 123))
|
||||
|
||||
// 强制将 debounce 时间回拨到窗口之外,触发第二次写库。
|
||||
svc.lastUsedTouchL1.Store(int64(123), time.Now().Add(-apiKeyLastUsedMinTouch-time.Second))
|
||||
|
||||
require.NoError(t, svc.TouchLastUsed(context.Background(), 123))
|
||||
require.Len(t, repo.touchedIDs, 2)
|
||||
require.Equal(t, int64(123), repo.touchedIDs[0])
|
||||
require.Equal(t, int64(123), repo.touchedIDs[1])
|
||||
}
|
||||
|
||||
func TestAPIKeyService_TouchLastUsed_RepoError(t *testing.T) {
|
||||
repo := &apiKeyRepoStub{
|
||||
updateLastUsed: func(ctx context.Context, id int64, usedAt time.Time) error {
|
||||
return errors.New("db write failed")
|
||||
},
|
||||
}
|
||||
svc := &APIKeyService{apiKeyRepo: repo}
|
||||
|
||||
err := svc.TouchLastUsed(context.Background(), 123)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "touch api key last used")
|
||||
require.Equal(t, []int64{123}, repo.touchedIDs)
|
||||
|
||||
cached, ok := svc.lastUsedTouchL1.Load(int64(123))
|
||||
require.True(t, ok, "failed touch should still update retry debounce cache")
|
||||
_, isTime := cached.(time.Time)
|
||||
require.True(t, isTime)
|
||||
}
|
||||
|
||||
func TestAPIKeyService_TouchLastUsed_RepoErrorDebounced(t *testing.T) {
|
||||
repo := &apiKeyRepoStub{
|
||||
updateLastUsed: func(ctx context.Context, id int64, usedAt time.Time) error {
|
||||
return errors.New("db write failed")
|
||||
},
|
||||
}
|
||||
svc := &APIKeyService{apiKeyRepo: repo}
|
||||
|
||||
firstErr := svc.TouchLastUsed(context.Background(), 456)
|
||||
require.Error(t, firstErr)
|
||||
require.ErrorContains(t, firstErr, "touch api key last used")
|
||||
|
||||
secondErr := svc.TouchLastUsed(context.Background(), 456)
|
||||
require.NoError(t, secondErr, "failed touch should be debounced and skip immediate retry")
|
||||
require.Equal(t, []int64{456}, repo.touchedIDs, "debounced retry should not hit repository again")
|
||||
}
|
||||
|
||||
type touchSingleflightRepo struct {
|
||||
*apiKeyRepoStub
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
blockCh chan struct{}
|
||||
}
|
||||
|
||||
func (r *touchSingleflightRepo) UpdateLastUsed(ctx context.Context, id int64, usedAt time.Time) error {
|
||||
r.mu.Lock()
|
||||
r.calls++
|
||||
r.mu.Unlock()
|
||||
<-r.blockCh
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAPIKeyService_TouchLastUsed_ConcurrentFirstTouchDeduplicated(t *testing.T) {
|
||||
repo := &touchSingleflightRepo{
|
||||
apiKeyRepoStub: &apiKeyRepoStub{},
|
||||
blockCh: make(chan struct{}),
|
||||
}
|
||||
svc := &APIKeyService{apiKeyRepo: repo}
|
||||
|
||||
const workers = 20
|
||||
startCh := make(chan struct{})
|
||||
errCh := make(chan error, workers)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-startCh
|
||||
errCh <- svc.TouchLastUsed(context.Background(), 321)
|
||||
}()
|
||||
}
|
||||
|
||||
close(startCh)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
return repo.calls >= 1
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
close(repo.blockCh)
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
|
||||
for err := range errCh {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
require.Equal(t, 1, repo.calls, "并发首次 touch 只应写库一次")
|
||||
}
|
||||
@@ -7,13 +7,14 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/mail"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -33,6 +34,7 @@ var (
|
||||
ErrRefreshTokenExpired = infraerrors.Unauthorized("REFRESH_TOKEN_EXPIRED", "refresh token has expired")
|
||||
ErrRefreshTokenReused = infraerrors.Unauthorized("REFRESH_TOKEN_REUSED", "refresh token has been reused")
|
||||
ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required")
|
||||
ErrEmailSuffixNotAllowed = infraerrors.BadRequest("EMAIL_SUFFIX_NOT_ALLOWED", "email suffix is not allowed")
|
||||
ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
|
||||
ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")
|
||||
ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required")
|
||||
@@ -56,15 +58,20 @@ type JWTClaims struct {
|
||||
|
||||
// AuthService 认证服务
|
||||
type AuthService struct {
|
||||
userRepo UserRepository
|
||||
redeemRepo RedeemCodeRepository
|
||||
refreshTokenCache RefreshTokenCache
|
||||
cfg *config.Config
|
||||
settingService *SettingService
|
||||
emailService *EmailService
|
||||
turnstileService *TurnstileService
|
||||
emailQueueService *EmailQueueService
|
||||
promoService *PromoService
|
||||
userRepo UserRepository
|
||||
redeemRepo RedeemCodeRepository
|
||||
refreshTokenCache RefreshTokenCache
|
||||
cfg *config.Config
|
||||
settingService *SettingService
|
||||
emailService *EmailService
|
||||
turnstileService *TurnstileService
|
||||
emailQueueService *EmailQueueService
|
||||
promoService *PromoService
|
||||
defaultSubAssigner DefaultSubscriptionAssigner
|
||||
}
|
||||
|
||||
type DefaultSubscriptionAssigner interface {
|
||||
AssignOrExtendSubscription(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, bool, error)
|
||||
}
|
||||
|
||||
// NewAuthService 创建认证服务实例
|
||||
@@ -78,17 +85,19 @@ func NewAuthService(
|
||||
turnstileService *TurnstileService,
|
||||
emailQueueService *EmailQueueService,
|
||||
promoService *PromoService,
|
||||
defaultSubAssigner DefaultSubscriptionAssigner,
|
||||
) *AuthService {
|
||||
return &AuthService{
|
||||
userRepo: userRepo,
|
||||
redeemRepo: redeemRepo,
|
||||
refreshTokenCache: refreshTokenCache,
|
||||
cfg: cfg,
|
||||
settingService: settingService,
|
||||
emailService: emailService,
|
||||
turnstileService: turnstileService,
|
||||
emailQueueService: emailQueueService,
|
||||
promoService: promoService,
|
||||
userRepo: userRepo,
|
||||
redeemRepo: redeemRepo,
|
||||
refreshTokenCache: refreshTokenCache,
|
||||
cfg: cfg,
|
||||
settingService: settingService,
|
||||
emailService: emailService,
|
||||
turnstileService: turnstileService,
|
||||
emailQueueService: emailQueueService,
|
||||
promoService: promoService,
|
||||
defaultSubAssigner: defaultSubAssigner,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +117,9 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
|
||||
if isReservedEmail(email) {
|
||||
return "", nil, ErrEmailReserved
|
||||
}
|
||||
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
// 检查是否需要邀请码
|
||||
var invitationRedeemCode *RedeemCode
|
||||
@@ -118,12 +130,12 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
|
||||
// 验证邀请码
|
||||
redeemCode, err := s.redeemRepo.GetByCode(ctx, invitationCode)
|
||||
if err != nil {
|
||||
log.Printf("[Auth] Invalid invitation code: %s, error: %v", invitationCode, err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Invalid invitation code: %s, error: %v", invitationCode, err)
|
||||
return "", nil, ErrInvitationCodeInvalid
|
||||
}
|
||||
// 检查类型和状态
|
||||
if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUnused {
|
||||
log.Printf("[Auth] Invitation code invalid: type=%s, status=%s", redeemCode.Type, redeemCode.Status)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Invitation code invalid: type=%s, status=%s", redeemCode.Type, redeemCode.Status)
|
||||
return "", nil, ErrInvitationCodeInvalid
|
||||
}
|
||||
invitationRedeemCode = redeemCode
|
||||
@@ -134,7 +146,7 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
|
||||
// 如果邮件验证已开启但邮件服务未配置,拒绝注册
|
||||
// 这是一个配置错误,不应该允许绕过验证
|
||||
if s.emailService == nil {
|
||||
log.Println("[Auth] Email verification enabled but email service not configured, rejecting registration")
|
||||
logger.LegacyPrintf("service.auth", "%s", "[Auth] Email verification enabled but email service not configured, rejecting registration")
|
||||
return "", nil, ErrServiceUnavailable
|
||||
}
|
||||
if verifyCode == "" {
|
||||
@@ -149,7 +161,7 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
|
||||
// 检查邮箱是否已存在
|
||||
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
|
||||
if err != nil {
|
||||
log.Printf("[Auth] Database error checking email exists: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Database error checking email exists: %v", err)
|
||||
return "", nil, ErrServiceUnavailable
|
||||
}
|
||||
if existsEmail {
|
||||
@@ -185,22 +197,23 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
|
||||
if errors.Is(err, ErrEmailExists) {
|
||||
return "", nil, ErrEmailExists
|
||||
}
|
||||
log.Printf("[Auth] Database error creating user: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Database error creating user: %v", err)
|
||||
return "", nil, ErrServiceUnavailable
|
||||
}
|
||||
s.assignDefaultSubscriptions(ctx, user.ID)
|
||||
|
||||
// 标记邀请码为已使用(如果使用了邀请码)
|
||||
if invitationRedeemCode != nil {
|
||||
if err := s.redeemRepo.Use(ctx, invitationRedeemCode.ID, user.ID); err != nil {
|
||||
// 邀请码标记失败不影响注册,只记录日志
|
||||
log.Printf("[Auth] Failed to mark invitation code as used for user %d: %v", user.ID, err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to mark invitation code as used for user %d: %v", user.ID, err)
|
||||
}
|
||||
}
|
||||
// 应用优惠码(如果提供且功能已启用)
|
||||
if promoCode != "" && s.promoService != nil && s.settingService != nil && s.settingService.IsPromoCodeEnabled(ctx) {
|
||||
if err := s.promoService.ApplyPromoCode(ctx, user.ID, promoCode); err != nil {
|
||||
// 优惠码应用失败不影响注册,只记录日志
|
||||
log.Printf("[Auth] Failed to apply promo code for user %d: %v", user.ID, err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to apply promo code for user %d: %v", user.ID, err)
|
||||
} else {
|
||||
// 重新获取用户信息以获取更新后的余额
|
||||
if updatedUser, err := s.userRepo.GetByID(ctx, user.ID); err == nil {
|
||||
@@ -233,11 +246,14 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error {
|
||||
if isReservedEmail(email) {
|
||||
return ErrEmailReserved
|
||||
}
|
||||
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
|
||||
if err != nil {
|
||||
log.Printf("[Auth] Database error checking email exists: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Database error checking email exists: %v", err)
|
||||
return ErrServiceUnavailable
|
||||
}
|
||||
if existsEmail {
|
||||
@@ -260,32 +276,35 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error {
|
||||
|
||||
// SendVerifyCodeAsync 异步发送邮箱验证码并返回倒计时
|
||||
func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*SendVerifyCodeResult, error) {
|
||||
log.Printf("[Auth] SendVerifyCodeAsync called for email: %s", email)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] SendVerifyCodeAsync called for email: %s", email)
|
||||
|
||||
// 检查是否开放注册(默认关闭)
|
||||
if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
|
||||
log.Println("[Auth] Registration is disabled")
|
||||
logger.LegacyPrintf("service.auth", "%s", "[Auth] Registration is disabled")
|
||||
return nil, ErrRegDisabled
|
||||
}
|
||||
|
||||
if isReservedEmail(email) {
|
||||
return nil, ErrEmailReserved
|
||||
}
|
||||
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
|
||||
if err != nil {
|
||||
log.Printf("[Auth] Database error checking email exists: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Database error checking email exists: %v", err)
|
||||
return nil, ErrServiceUnavailable
|
||||
}
|
||||
if existsEmail {
|
||||
log.Printf("[Auth] Email already exists: %s", email)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Email already exists: %s", email)
|
||||
return nil, ErrEmailExists
|
||||
}
|
||||
|
||||
// 检查邮件队列服务是否配置
|
||||
if s.emailQueueService == nil {
|
||||
log.Println("[Auth] Email queue service not configured")
|
||||
logger.LegacyPrintf("service.auth", "%s", "[Auth] Email queue service not configured")
|
||||
return nil, errors.New("email queue service not configured")
|
||||
}
|
||||
|
||||
@@ -296,45 +315,56 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*S
|
||||
}
|
||||
|
||||
// 异步发送
|
||||
log.Printf("[Auth] Enqueueing verify code for: %s", email)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Enqueueing verify code for: %s", email)
|
||||
if err := s.emailQueueService.EnqueueVerifyCode(email, siteName); err != nil {
|
||||
log.Printf("[Auth] Failed to enqueue: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to enqueue: %v", err)
|
||||
return nil, fmt.Errorf("enqueue verify code: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[Auth] Verify code enqueued successfully for: %s", email)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Verify code enqueued successfully for: %s", email)
|
||||
return &SendVerifyCodeResult{
|
||||
Countdown: 60, // 60秒倒计时
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyTurnstileForRegister 在注册场景下验证 Turnstile。
|
||||
// 当邮箱验证开启且已提交验证码时,说明验证码发送阶段已完成 Turnstile 校验,
|
||||
// 此处跳过二次校验,避免一次性 token 在注册提交时重复使用导致误报失败。
|
||||
func (s *AuthService) VerifyTurnstileForRegister(ctx context.Context, token, remoteIP, verifyCode string) error {
|
||||
if s.IsEmailVerifyEnabled(ctx) && strings.TrimSpace(verifyCode) != "" {
|
||||
logger.LegacyPrintf("service.auth", "%s", "[Auth] Email verify flow detected, skip duplicate Turnstile check on register")
|
||||
return nil
|
||||
}
|
||||
return s.VerifyTurnstile(ctx, token, remoteIP)
|
||||
}
|
||||
|
||||
// VerifyTurnstile 验证Turnstile token
|
||||
func (s *AuthService) VerifyTurnstile(ctx context.Context, token string, remoteIP string) error {
|
||||
required := s.cfg != nil && s.cfg.Server.Mode == "release" && s.cfg.Turnstile.Required
|
||||
|
||||
if required {
|
||||
if s.settingService == nil {
|
||||
log.Println("[Auth] Turnstile required but settings service is not configured")
|
||||
logger.LegacyPrintf("service.auth", "%s", "[Auth] Turnstile required but settings service is not configured")
|
||||
return ErrTurnstileNotConfigured
|
||||
}
|
||||
enabled := s.settingService.IsTurnstileEnabled(ctx)
|
||||
secretConfigured := s.settingService.GetTurnstileSecretKey(ctx) != ""
|
||||
if !enabled || !secretConfigured {
|
||||
log.Printf("[Auth] Turnstile required but not configured (enabled=%v, secret_configured=%v)", enabled, secretConfigured)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Turnstile required but not configured (enabled=%v, secret_configured=%v)", enabled, secretConfigured)
|
||||
return ErrTurnstileNotConfigured
|
||||
}
|
||||
}
|
||||
|
||||
if s.turnstileService == nil {
|
||||
if required {
|
||||
log.Println("[Auth] Turnstile required but service not configured")
|
||||
logger.LegacyPrintf("service.auth", "%s", "[Auth] Turnstile required but service not configured")
|
||||
return ErrTurnstileNotConfigured
|
||||
}
|
||||
return nil // 服务未配置则跳过验证
|
||||
}
|
||||
|
||||
if !required && s.settingService != nil && s.settingService.IsTurnstileEnabled(ctx) && s.settingService.GetTurnstileSecretKey(ctx) == "" {
|
||||
log.Println("[Auth] Turnstile enabled but secret key not configured")
|
||||
logger.LegacyPrintf("service.auth", "%s", "[Auth] Turnstile enabled but secret key not configured")
|
||||
}
|
||||
|
||||
return s.turnstileService.VerifyToken(ctx, token, remoteIP)
|
||||
@@ -373,7 +403,7 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (string
|
||||
return "", nil, ErrInvalidCredentials
|
||||
}
|
||||
// 记录数据库错误但不暴露给用户
|
||||
log.Printf("[Auth] Database error during login: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Database error during login: %v", err)
|
||||
return "", nil, ErrServiceUnavailable
|
||||
}
|
||||
|
||||
@@ -426,7 +456,7 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username
|
||||
|
||||
randomPassword, err := randomHexString(32)
|
||||
if err != nil {
|
||||
log.Printf("[Auth] Failed to generate random password for oauth signup: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to generate random password for oauth signup: %v", err)
|
||||
return "", nil, ErrServiceUnavailable
|
||||
}
|
||||
hashedPassword, err := s.HashPassword(randomPassword)
|
||||
@@ -457,18 +487,19 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username
|
||||
// 并发场景:GetByEmail 与 Create 之间用户被创建。
|
||||
user, err = s.userRepo.GetByEmail(ctx, email)
|
||||
if err != nil {
|
||||
log.Printf("[Auth] Database error getting user after conflict: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Database error getting user after conflict: %v", err)
|
||||
return "", nil, ErrServiceUnavailable
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Auth] Database error creating oauth user: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Database error creating oauth user: %v", err)
|
||||
return "", nil, ErrServiceUnavailable
|
||||
}
|
||||
} else {
|
||||
user = newUser
|
||||
s.assignDefaultSubscriptions(ctx, user.ID)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Auth] Database error during oauth login: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Database error during oauth login: %v", err)
|
||||
return "", nil, ErrServiceUnavailable
|
||||
}
|
||||
}
|
||||
@@ -481,7 +512,7 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username
|
||||
if user.Username == "" && username != "" {
|
||||
user.Username = username
|
||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||
log.Printf("[Auth] Failed to update username after oauth login: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to update username after oauth login: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,7 +554,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
|
||||
|
||||
randomPassword, err := randomHexString(32)
|
||||
if err != nil {
|
||||
log.Printf("[Auth] Failed to generate random password for oauth signup: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to generate random password for oauth signup: %v", err)
|
||||
return nil, nil, ErrServiceUnavailable
|
||||
}
|
||||
hashedPassword, err := s.HashPassword(randomPassword)
|
||||
@@ -552,18 +583,19 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
|
||||
if errors.Is(err, ErrEmailExists) {
|
||||
user, err = s.userRepo.GetByEmail(ctx, email)
|
||||
if err != nil {
|
||||
log.Printf("[Auth] Database error getting user after conflict: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Database error getting user after conflict: %v", err)
|
||||
return nil, nil, ErrServiceUnavailable
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Auth] Database error creating oauth user: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Database error creating oauth user: %v", err)
|
||||
return nil, nil, ErrServiceUnavailable
|
||||
}
|
||||
} else {
|
||||
user = newUser
|
||||
s.assignDefaultSubscriptions(ctx, user.ID)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Auth] Database error during oauth login: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Database error during oauth login: %v", err)
|
||||
return nil, nil, ErrServiceUnavailable
|
||||
}
|
||||
}
|
||||
@@ -575,7 +607,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
|
||||
if user.Username == "" && username != "" {
|
||||
user.Username = username
|
||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||
log.Printf("[Auth] Failed to update username after oauth login: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to update username after oauth login: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,6 +618,49 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
|
||||
return tokenPair, user, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) assignDefaultSubscriptions(ctx context.Context, userID int64) {
|
||||
if s.settingService == nil || s.defaultSubAssigner == nil || userID <= 0 {
|
||||
return
|
||||
}
|
||||
items := s.settingService.GetDefaultSubscriptions(ctx)
|
||||
for _, item := range items {
|
||||
if _, _, err := s.defaultSubAssigner.AssignOrExtendSubscription(ctx, &AssignSubscriptionInput{
|
||||
UserID: userID,
|
||||
GroupID: item.GroupID,
|
||||
ValidityDays: item.ValidityDays,
|
||||
Notes: "auto assigned by default user subscriptions setting",
|
||||
}); err != nil {
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to assign default subscription: user_id=%d group_id=%d err=%v", userID, item.GroupID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) validateRegistrationEmailPolicy(ctx context.Context, email string) error {
|
||||
if s.settingService == nil {
|
||||
return nil
|
||||
}
|
||||
whitelist := s.settingService.GetRegistrationEmailSuffixWhitelist(ctx)
|
||||
if !IsRegistrationEmailSuffixAllowed(email, whitelist) {
|
||||
return buildEmailSuffixNotAllowedError(whitelist)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildEmailSuffixNotAllowedError(whitelist []string) error {
|
||||
if len(whitelist) == 0 {
|
||||
return ErrEmailSuffixNotAllowed
|
||||
}
|
||||
|
||||
allowed := strings.Join(whitelist, ", ")
|
||||
return infraerrors.BadRequest(
|
||||
"EMAIL_SUFFIX_NOT_ALLOWED",
|
||||
fmt.Sprintf("email suffix is not allowed, allowed suffixes: %s", allowed),
|
||||
).WithMetadata(map[string]string{
|
||||
"allowed_suffixes": strings.Join(whitelist, ","),
|
||||
"allowed_suffix_count": strconv.Itoa(len(whitelist)),
|
||||
})
|
||||
}
|
||||
|
||||
// ValidateToken 验证JWT token并返回用户声明
|
||||
func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) {
|
||||
// 先做长度校验,尽早拒绝异常超长 token,降低 DoS 风险。
|
||||
@@ -715,7 +790,7 @@ func (s *AuthService) RefreshToken(ctx context.Context, oldTokenString string) (
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
return "", ErrInvalidToken
|
||||
}
|
||||
log.Printf("[Auth] Database error refreshing token: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Database error refreshing token: %v", err)
|
||||
return "", ErrServiceUnavailable
|
||||
}
|
||||
|
||||
@@ -756,16 +831,16 @@ func (s *AuthService) preparePasswordReset(ctx context.Context, email, frontendB
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
// Security: Log but don't reveal that user doesn't exist
|
||||
log.Printf("[Auth] Password reset requested for non-existent email: %s", email)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Password reset requested for non-existent email: %s", email)
|
||||
return "", "", false
|
||||
}
|
||||
log.Printf("[Auth] Database error checking email for password reset: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Database error checking email for password reset: %v", err)
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if !user.IsActive() {
|
||||
log.Printf("[Auth] Password reset requested for inactive user: %s", email)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Password reset requested for inactive user: %s", email)
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
@@ -797,11 +872,11 @@ func (s *AuthService) RequestPasswordReset(ctx context.Context, email, frontendB
|
||||
}
|
||||
|
||||
if err := s.emailService.SendPasswordResetEmail(ctx, email, siteName, resetURL); err != nil {
|
||||
log.Printf("[Auth] Failed to send password reset email to %s: %v", email, err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to send password reset email to %s: %v", email, err)
|
||||
return nil // Silent success to prevent enumeration
|
||||
}
|
||||
|
||||
log.Printf("[Auth] Password reset email sent to: %s", email)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Password reset email sent to: %s", email)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -821,11 +896,11 @@ func (s *AuthService) RequestPasswordResetAsync(ctx context.Context, email, fron
|
||||
}
|
||||
|
||||
if err := s.emailQueueService.EnqueuePasswordReset(email, siteName, resetURL); err != nil {
|
||||
log.Printf("[Auth] Failed to enqueue password reset email for %s: %v", email, err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to enqueue password reset email for %s: %v", email, err)
|
||||
return nil // Silent success to prevent enumeration
|
||||
}
|
||||
|
||||
log.Printf("[Auth] Password reset email enqueued for: %s", email)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Password reset email enqueued for: %s", email)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -852,7 +927,7 @@ func (s *AuthService) ResetPassword(ctx context.Context, email, token, newPasswo
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
return ErrInvalidResetToken // Token was valid but user was deleted
|
||||
}
|
||||
log.Printf("[Auth] Database error getting user for password reset: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Database error getting user for password reset: %v", err)
|
||||
return ErrServiceUnavailable
|
||||
}
|
||||
|
||||
@@ -872,17 +947,17 @@ func (s *AuthService) ResetPassword(ctx context.Context, email, token, newPasswo
|
||||
user.TokenVersion++ // Invalidate all existing tokens
|
||||
|
||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||
log.Printf("[Auth] Database error updating password for user %d: %v", user.ID, err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Database error updating password for user %d: %v", user.ID, err)
|
||||
return ErrServiceUnavailable
|
||||
}
|
||||
|
||||
// Also revoke all refresh tokens for this user
|
||||
if err := s.RevokeAllUserSessions(ctx, user.ID); err != nil {
|
||||
log.Printf("[Auth] Failed to revoke refresh tokens for user %d: %v", user.ID, err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to revoke refresh tokens for user %d: %v", user.ID, err)
|
||||
// Don't return error - password was already changed successfully
|
||||
}
|
||||
|
||||
log.Printf("[Auth] Password reset successful for user: %s", email)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Password reset successful for user: %s", email)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -961,13 +1036,13 @@ func (s *AuthService) generateRefreshToken(ctx context.Context, user *User, fami
|
||||
|
||||
// 添加到用户Token集合
|
||||
if err := s.refreshTokenCache.AddToUserTokenSet(ctx, user.ID, tokenHash, ttl); err != nil {
|
||||
log.Printf("[Auth] Failed to add token to user set: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to add token to user set: %v", err)
|
||||
// 不影响主流程
|
||||
}
|
||||
|
||||
// 添加到家族Token集合
|
||||
if err := s.refreshTokenCache.AddToFamilyTokenSet(ctx, familyID, tokenHash, ttl); err != nil {
|
||||
log.Printf("[Auth] Failed to add token to family set: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to add token to family set: %v", err)
|
||||
// 不影响主流程
|
||||
}
|
||||
|
||||
@@ -994,10 +1069,10 @@ func (s *AuthService) RefreshTokenPair(ctx context.Context, refreshToken string)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrRefreshTokenNotFound) {
|
||||
// Token不存在,可能是已被使用(Token轮转)或已过期
|
||||
log.Printf("[Auth] Refresh token not found, possible reuse attack")
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Refresh token not found, possible reuse attack")
|
||||
return nil, ErrRefreshTokenInvalid
|
||||
}
|
||||
log.Printf("[Auth] Error getting refresh token: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Error getting refresh token: %v", err)
|
||||
return nil, ErrServiceUnavailable
|
||||
}
|
||||
|
||||
@@ -1016,7 +1091,7 @@ func (s *AuthService) RefreshTokenPair(ctx context.Context, refreshToken string)
|
||||
_ = s.refreshTokenCache.DeleteTokenFamily(ctx, data.FamilyID)
|
||||
return nil, ErrRefreshTokenInvalid
|
||||
}
|
||||
log.Printf("[Auth] Database error getting user for token refresh: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Database error getting user for token refresh: %v", err)
|
||||
return nil, ErrServiceUnavailable
|
||||
}
|
||||
|
||||
@@ -1036,7 +1111,7 @@ func (s *AuthService) RefreshTokenPair(ctx context.Context, refreshToken string)
|
||||
|
||||
// Token轮转:立即使旧Token失效
|
||||
if err := s.refreshTokenCache.DeleteRefreshToken(ctx, tokenHash); err != nil {
|
||||
log.Printf("[Auth] Failed to delete old refresh token: %v", err)
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to delete old refresh token: %v", err)
|
||||
// 继续处理,不影响主流程
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -56,6 +57,21 @@ type emailCacheStub struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type defaultSubscriptionAssignerStub struct {
|
||||
calls []AssignSubscriptionInput
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *defaultSubscriptionAssignerStub) AssignOrExtendSubscription(_ context.Context, input *AssignSubscriptionInput) (*UserSubscription, bool, error) {
|
||||
if input != nil {
|
||||
s.calls = append(s.calls, *input)
|
||||
}
|
||||
if s.err != nil {
|
||||
return nil, false, s.err
|
||||
}
|
||||
return &UserSubscription{UserID: input.UserID, GroupID: input.GroupID}, false, nil
|
||||
}
|
||||
|
||||
func (s *emailCacheStub) GetVerificationCode(ctx context.Context, email string) (*VerificationCodeData, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
@@ -123,6 +139,7 @@ func newAuthService(repo *userRepoStub, settings map[string]string, emailCache E
|
||||
nil,
|
||||
nil,
|
||||
nil, // promoService
|
||||
nil, // defaultSubAssigner
|
||||
)
|
||||
}
|
||||
|
||||
@@ -215,6 +232,51 @@ func TestAuthService_Register_ReservedEmail(t *testing.T) {
|
||||
require.ErrorIs(t, err, ErrEmailReserved)
|
||||
}
|
||||
|
||||
func TestAuthService_Register_EmailSuffixNotAllowed(t *testing.T) {
|
||||
repo := &userRepoStub{}
|
||||
service := newAuthService(repo, map[string]string{
|
||||
SettingKeyRegistrationEnabled: "true",
|
||||
SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`,
|
||||
}, nil)
|
||||
|
||||
_, _, err := service.Register(context.Background(), "user@other.com", "password")
|
||||
require.ErrorIs(t, err, ErrEmailSuffixNotAllowed)
|
||||
appErr := infraerrors.FromError(err)
|
||||
require.Contains(t, appErr.Message, "@example.com")
|
||||
require.Contains(t, appErr.Message, "@company.com")
|
||||
require.Equal(t, "EMAIL_SUFFIX_NOT_ALLOWED", appErr.Reason)
|
||||
require.Equal(t, "2", appErr.Metadata["allowed_suffix_count"])
|
||||
require.Equal(t, "@example.com,@company.com", appErr.Metadata["allowed_suffixes"])
|
||||
}
|
||||
|
||||
func TestAuthService_Register_EmailSuffixAllowed(t *testing.T) {
|
||||
repo := &userRepoStub{nextID: 8}
|
||||
service := newAuthService(repo, map[string]string{
|
||||
SettingKeyRegistrationEnabled: "true",
|
||||
SettingKeyRegistrationEmailSuffixWhitelist: `["example.com"]`,
|
||||
}, nil)
|
||||
|
||||
_, user, err := service.Register(context.Background(), "user@example.com", "password")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, user)
|
||||
require.Equal(t, int64(8), user.ID)
|
||||
}
|
||||
|
||||
func TestAuthService_SendVerifyCode_EmailSuffixNotAllowed(t *testing.T) {
|
||||
repo := &userRepoStub{}
|
||||
service := newAuthService(repo, map[string]string{
|
||||
SettingKeyRegistrationEnabled: "true",
|
||||
SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`,
|
||||
}, nil)
|
||||
|
||||
err := service.SendVerifyCode(context.Background(), "user@other.com")
|
||||
require.ErrorIs(t, err, ErrEmailSuffixNotAllowed)
|
||||
appErr := infraerrors.FromError(err)
|
||||
require.Contains(t, appErr.Message, "@example.com")
|
||||
require.Contains(t, appErr.Message, "@company.com")
|
||||
require.Equal(t, "2", appErr.Metadata["allowed_suffix_count"])
|
||||
}
|
||||
|
||||
func TestAuthService_Register_CreateError(t *testing.T) {
|
||||
repo := &userRepoStub{createErr: errors.New("create failed")}
|
||||
service := newAuthService(repo, map[string]string{
|
||||
@@ -315,3 +377,89 @@ func TestAuthService_RefreshToken_ExpiredTokenNoPanic(t *testing.T) {
|
||||
require.NotEmpty(t, newToken)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthService_GetAccessTokenExpiresIn_FallbackToExpireHour(t *testing.T) {
|
||||
service := newAuthService(&userRepoStub{}, nil, nil)
|
||||
service.cfg.JWT.ExpireHour = 24
|
||||
service.cfg.JWT.AccessTokenExpireMinutes = 0
|
||||
|
||||
require.Equal(t, 24*3600, service.GetAccessTokenExpiresIn())
|
||||
}
|
||||
|
||||
func TestAuthService_GetAccessTokenExpiresIn_MinutesHasPriority(t *testing.T) {
|
||||
service := newAuthService(&userRepoStub{}, nil, nil)
|
||||
service.cfg.JWT.ExpireHour = 24
|
||||
service.cfg.JWT.AccessTokenExpireMinutes = 90
|
||||
|
||||
require.Equal(t, 90*60, service.GetAccessTokenExpiresIn())
|
||||
}
|
||||
|
||||
func TestAuthService_GenerateToken_UsesExpireHourWhenMinutesZero(t *testing.T) {
|
||||
service := newAuthService(&userRepoStub{}, nil, nil)
|
||||
service.cfg.JWT.ExpireHour = 24
|
||||
service.cfg.JWT.AccessTokenExpireMinutes = 0
|
||||
|
||||
user := &User{
|
||||
ID: 1,
|
||||
Email: "test@test.com",
|
||||
Role: RoleUser,
|
||||
Status: StatusActive,
|
||||
TokenVersion: 1,
|
||||
}
|
||||
|
||||
token, err := service.GenerateToken(user)
|
||||
require.NoError(t, err)
|
||||
|
||||
claims, err := service.ValidateToken(token)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, claims)
|
||||
require.NotNil(t, claims.IssuedAt)
|
||||
require.NotNil(t, claims.ExpiresAt)
|
||||
|
||||
require.WithinDuration(t, claims.IssuedAt.Time.Add(24*time.Hour), claims.ExpiresAt.Time, 2*time.Second)
|
||||
}
|
||||
|
||||
func TestAuthService_GenerateToken_UsesMinutesWhenConfigured(t *testing.T) {
|
||||
service := newAuthService(&userRepoStub{}, nil, nil)
|
||||
service.cfg.JWT.ExpireHour = 24
|
||||
service.cfg.JWT.AccessTokenExpireMinutes = 90
|
||||
|
||||
user := &User{
|
||||
ID: 2,
|
||||
Email: "test2@test.com",
|
||||
Role: RoleUser,
|
||||
Status: StatusActive,
|
||||
TokenVersion: 1,
|
||||
}
|
||||
|
||||
token, err := service.GenerateToken(user)
|
||||
require.NoError(t, err)
|
||||
|
||||
claims, err := service.ValidateToken(token)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, claims)
|
||||
require.NotNil(t, claims.IssuedAt)
|
||||
require.NotNil(t, claims.ExpiresAt)
|
||||
|
||||
require.WithinDuration(t, claims.IssuedAt.Time.Add(90*time.Minute), claims.ExpiresAt.Time, 2*time.Second)
|
||||
}
|
||||
|
||||
func TestAuthService_Register_AssignsDefaultSubscriptions(t *testing.T) {
|
||||
repo := &userRepoStub{nextID: 42}
|
||||
assigner := &defaultSubscriptionAssignerStub{}
|
||||
service := newAuthService(repo, map[string]string{
|
||||
SettingKeyRegistrationEnabled: "true",
|
||||
SettingKeyDefaultSubscriptions: `[{"group_id":11,"validity_days":30},{"group_id":12,"validity_days":7}]`,
|
||||
}, nil)
|
||||
service.defaultSubAssigner = assigner
|
||||
|
||||
_, user, err := service.Register(context.Background(), "default-sub@test.com", "password")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, user)
|
||||
require.Len(t, assigner.calls, 2)
|
||||
require.Equal(t, int64(42), assigner.calls[0].UserID)
|
||||
require.Equal(t, int64(11), assigner.calls[0].GroupID)
|
||||
require.Equal(t, 30, assigner.calls[0].ValidityDays)
|
||||
require.Equal(t, int64(12), assigner.calls[1].GroupID)
|
||||
require.Equal(t, 7, assigner.calls[1].ValidityDays)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type turnstileVerifierSpy struct {
|
||||
called int
|
||||
lastToken string
|
||||
result *TurnstileVerifyResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *turnstileVerifierSpy) VerifyToken(_ context.Context, _ string, token, _ string) (*TurnstileVerifyResponse, error) {
|
||||
s.called++
|
||||
s.lastToken = token
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
if s.result != nil {
|
||||
return s.result, nil
|
||||
}
|
||||
return &TurnstileVerifyResponse{Success: true}, nil
|
||||
}
|
||||
|
||||
func newAuthServiceForRegisterTurnstileTest(settings map[string]string, verifier TurnstileVerifier) *AuthService {
|
||||
cfg := &config.Config{
|
||||
Server: config.ServerConfig{
|
||||
Mode: "release",
|
||||
},
|
||||
Turnstile: config.TurnstileConfig{
|
||||
Required: true,
|
||||
},
|
||||
}
|
||||
|
||||
settingService := NewSettingService(&settingRepoStub{values: settings}, cfg)
|
||||
turnstileService := NewTurnstileService(settingService, verifier)
|
||||
|
||||
return NewAuthService(
|
||||
&userRepoStub{},
|
||||
nil, // redeemRepo
|
||||
nil, // refreshTokenCache
|
||||
cfg,
|
||||
settingService,
|
||||
nil, // emailService
|
||||
turnstileService,
|
||||
nil, // emailQueueService
|
||||
nil, // promoService
|
||||
nil, // defaultSubAssigner
|
||||
)
|
||||
}
|
||||
|
||||
func TestAuthService_VerifyTurnstileForRegister_SkipWhenEmailVerifyCodeProvided(t *testing.T) {
|
||||
verifier := &turnstileVerifierSpy{}
|
||||
service := newAuthServiceForRegisterTurnstileTest(map[string]string{
|
||||
SettingKeyEmailVerifyEnabled: "true",
|
||||
SettingKeyTurnstileEnabled: "true",
|
||||
SettingKeyTurnstileSecretKey: "secret",
|
||||
SettingKeyRegistrationEnabled: "true",
|
||||
}, verifier)
|
||||
|
||||
err := service.VerifyTurnstileForRegister(context.Background(), "", "127.0.0.1", "123456")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, verifier.called)
|
||||
}
|
||||
|
||||
func TestAuthService_VerifyTurnstileForRegister_RequireWhenVerifyCodeMissing(t *testing.T) {
|
||||
verifier := &turnstileVerifierSpy{}
|
||||
service := newAuthServiceForRegisterTurnstileTest(map[string]string{
|
||||
SettingKeyEmailVerifyEnabled: "true",
|
||||
SettingKeyTurnstileEnabled: "true",
|
||||
SettingKeyTurnstileSecretKey: "secret",
|
||||
}, verifier)
|
||||
|
||||
err := service.VerifyTurnstileForRegister(context.Background(), "", "127.0.0.1", "")
|
||||
require.ErrorIs(t, err, ErrTurnstileVerificationFailed)
|
||||
}
|
||||
|
||||
func TestAuthService_VerifyTurnstileForRegister_NoSkipWhenEmailVerifyDisabled(t *testing.T) {
|
||||
verifier := &turnstileVerifierSpy{}
|
||||
service := newAuthServiceForRegisterTurnstileTest(map[string]string{
|
||||
SettingKeyEmailVerifyEnabled: "false",
|
||||
SettingKeyTurnstileEnabled: "true",
|
||||
SettingKeyTurnstileSecretKey: "secret",
|
||||
}, verifier)
|
||||
|
||||
err := service.VerifyTurnstileForRegister(context.Background(), "turnstile-token", "127.0.0.1", "123456")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, verifier.called)
|
||||
require.Equal(t, "turnstile-token", verifier.lastToken)
|
||||
}
|
||||
@@ -3,13 +3,15 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
// 错误定义
|
||||
@@ -38,6 +40,7 @@ const (
|
||||
cacheWriteSetSubscription
|
||||
cacheWriteUpdateSubscriptionUsage
|
||||
cacheWriteDeductBalance
|
||||
cacheWriteUpdateRateLimitUsage
|
||||
)
|
||||
|
||||
// 异步缓存写入工作池配置
|
||||
@@ -58,6 +61,7 @@ const (
|
||||
cacheWriteBufferSize = 1000 // 任务队列缓冲大小
|
||||
cacheWriteTimeout = 2 * time.Second // 单个写入操作超时
|
||||
cacheWriteDropLogInterval = 5 * time.Second // 丢弃日志节流间隔
|
||||
balanceLoadTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
// cacheWriteTask 缓存写入任务
|
||||
@@ -65,23 +69,33 @@ type cacheWriteTask struct {
|
||||
kind cacheWriteKind
|
||||
userID int64
|
||||
groupID int64
|
||||
apiKeyID int64
|
||||
balance float64
|
||||
amount float64
|
||||
subscriptionData *subscriptionCacheData
|
||||
}
|
||||
|
||||
// apiKeyRateLimitLoader defines the interface for loading rate limit data from DB.
|
||||
type apiKeyRateLimitLoader interface {
|
||||
GetRateLimitData(ctx context.Context, keyID int64) (*APIKeyRateLimitData, error)
|
||||
}
|
||||
|
||||
// BillingCacheService 计费缓存服务
|
||||
// 负责余额和订阅数据的缓存管理,提供高性能的计费资格检查
|
||||
type BillingCacheService struct {
|
||||
cache BillingCache
|
||||
userRepo UserRepository
|
||||
subRepo UserSubscriptionRepository
|
||||
cfg *config.Config
|
||||
circuitBreaker *billingCircuitBreaker
|
||||
cache BillingCache
|
||||
userRepo UserRepository
|
||||
subRepo UserSubscriptionRepository
|
||||
apiKeyRateLimitLoader apiKeyRateLimitLoader
|
||||
cfg *config.Config
|
||||
circuitBreaker *billingCircuitBreaker
|
||||
|
||||
cacheWriteChan chan cacheWriteTask
|
||||
cacheWriteWg sync.WaitGroup
|
||||
cacheWriteStopOnce sync.Once
|
||||
cacheWriteMu sync.RWMutex
|
||||
stopped atomic.Bool
|
||||
balanceLoadSF singleflight.Group
|
||||
// 丢弃日志节流计数器(减少高负载下日志噪音)
|
||||
cacheWriteDropFullCount uint64
|
||||
cacheWriteDropFullLastLog int64
|
||||
@@ -90,12 +104,13 @@ type BillingCacheService struct {
|
||||
}
|
||||
|
||||
// NewBillingCacheService 创建计费缓存服务
|
||||
func NewBillingCacheService(cache BillingCache, userRepo UserRepository, subRepo UserSubscriptionRepository, cfg *config.Config) *BillingCacheService {
|
||||
func NewBillingCacheService(cache BillingCache, userRepo UserRepository, subRepo UserSubscriptionRepository, apiKeyRepo APIKeyRepository, cfg *config.Config) *BillingCacheService {
|
||||
svc := &BillingCacheService{
|
||||
cache: cache,
|
||||
userRepo: userRepo,
|
||||
subRepo: subRepo,
|
||||
cfg: cfg,
|
||||
cache: cache,
|
||||
userRepo: userRepo,
|
||||
subRepo: subRepo,
|
||||
apiKeyRateLimitLoader: apiKeyRepo,
|
||||
cfg: cfg,
|
||||
}
|
||||
svc.circuitBreaker = newBillingCircuitBreaker(cfg.Billing.CircuitBreaker)
|
||||
svc.startCacheWriteWorkers()
|
||||
@@ -105,35 +120,52 @@ func NewBillingCacheService(cache BillingCache, userRepo UserRepository, subRepo
|
||||
// Stop 关闭缓存写入工作池
|
||||
func (s *BillingCacheService) Stop() {
|
||||
s.cacheWriteStopOnce.Do(func() {
|
||||
if s.cacheWriteChan == nil {
|
||||
s.stopped.Store(true)
|
||||
|
||||
s.cacheWriteMu.Lock()
|
||||
ch := s.cacheWriteChan
|
||||
if ch != nil {
|
||||
close(ch)
|
||||
}
|
||||
s.cacheWriteMu.Unlock()
|
||||
|
||||
if ch == nil {
|
||||
return
|
||||
}
|
||||
close(s.cacheWriteChan)
|
||||
s.cacheWriteWg.Wait()
|
||||
s.cacheWriteChan = nil
|
||||
|
||||
s.cacheWriteMu.Lock()
|
||||
if s.cacheWriteChan == ch {
|
||||
s.cacheWriteChan = nil
|
||||
}
|
||||
s.cacheWriteMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BillingCacheService) startCacheWriteWorkers() {
|
||||
s.cacheWriteChan = make(chan cacheWriteTask, cacheWriteBufferSize)
|
||||
ch := make(chan cacheWriteTask, cacheWriteBufferSize)
|
||||
s.cacheWriteChan = ch
|
||||
for i := 0; i < cacheWriteWorkerCount; i++ {
|
||||
s.cacheWriteWg.Add(1)
|
||||
go s.cacheWriteWorker()
|
||||
go s.cacheWriteWorker(ch)
|
||||
}
|
||||
}
|
||||
|
||||
// enqueueCacheWrite 尝试将任务入队,队列满时返回 false(并记录告警)。
|
||||
func (s *BillingCacheService) enqueueCacheWrite(task cacheWriteTask) (enqueued bool) {
|
||||
if s.cacheWriteChan == nil {
|
||||
if s.stopped.Load() {
|
||||
s.logCacheWriteDrop(task, "closed")
|
||||
return false
|
||||
}
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
// 队列已关闭时可能触发 panic,记录后静默失败。
|
||||
s.logCacheWriteDrop(task, "closed")
|
||||
enqueued = false
|
||||
}
|
||||
}()
|
||||
|
||||
s.cacheWriteMu.RLock()
|
||||
defer s.cacheWriteMu.RUnlock()
|
||||
|
||||
if s.cacheWriteChan == nil {
|
||||
s.logCacheWriteDrop(task, "closed")
|
||||
return false
|
||||
}
|
||||
|
||||
select {
|
||||
case s.cacheWriteChan <- task:
|
||||
return true
|
||||
@@ -144,9 +176,9 @@ func (s *BillingCacheService) enqueueCacheWrite(task cacheWriteTask) (enqueued b
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BillingCacheService) cacheWriteWorker() {
|
||||
func (s *BillingCacheService) cacheWriteWorker(ch <-chan cacheWriteTask) {
|
||||
defer s.cacheWriteWg.Done()
|
||||
for task := range s.cacheWriteChan {
|
||||
for task := range ch {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cacheWriteTimeout)
|
||||
switch task.kind {
|
||||
case cacheWriteSetBalance:
|
||||
@@ -156,13 +188,19 @@ func (s *BillingCacheService) cacheWriteWorker() {
|
||||
case cacheWriteUpdateSubscriptionUsage:
|
||||
if s.cache != nil {
|
||||
if err := s.cache.UpdateSubscriptionUsage(ctx, task.userID, task.groupID, task.amount); err != nil {
|
||||
log.Printf("Warning: update subscription cache failed for user %d group %d: %v", task.userID, task.groupID, err)
|
||||
logger.LegacyPrintf("service.billing_cache", "Warning: update subscription cache failed for user %d group %d: %v", task.userID, task.groupID, err)
|
||||
}
|
||||
}
|
||||
case cacheWriteDeductBalance:
|
||||
if s.cache != nil {
|
||||
if err := s.cache.DeductUserBalance(ctx, task.userID, task.amount); err != nil {
|
||||
log.Printf("Warning: deduct balance cache failed for user %d: %v", task.userID, err)
|
||||
logger.LegacyPrintf("service.billing_cache", "Warning: deduct balance cache failed for user %d: %v", task.userID, err)
|
||||
}
|
||||
}
|
||||
case cacheWriteUpdateRateLimitUsage:
|
||||
if s.cache != nil {
|
||||
if err := s.cache.UpdateAPIKeyRateLimitUsage(ctx, task.apiKeyID, task.amount); err != nil {
|
||||
logger.LegacyPrintf("service.billing_cache", "Warning: update rate limit usage cache failed for api key %d: %v", task.apiKeyID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,6 +219,8 @@ func cacheWriteKindName(kind cacheWriteKind) string {
|
||||
return "update_subscription_usage"
|
||||
case cacheWriteDeductBalance:
|
||||
return "deduct_balance"
|
||||
case cacheWriteUpdateRateLimitUsage:
|
||||
return "update_rate_limit_usage"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
@@ -216,7 +256,7 @@ func (s *BillingCacheService) logCacheWriteDrop(task cacheWriteTask, reason stri
|
||||
if dropped == 0 {
|
||||
return
|
||||
}
|
||||
log.Printf("Warning: cache write queue %s, dropped %d tasks in last %s (latest kind=%s user %d group %d)",
|
||||
logger.LegacyPrintf("service.billing_cache", "Warning: cache write queue %s, dropped %d tasks in last %s (latest kind=%s user %d group %d)",
|
||||
reason,
|
||||
dropped,
|
||||
cacheWriteDropLogInterval,
|
||||
@@ -243,19 +283,31 @@ func (s *BillingCacheService) GetUserBalance(ctx context.Context, userID int64)
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
// 缓存未命中,从数据库读取
|
||||
balance, err = s.getUserBalanceFromDB(ctx, userID)
|
||||
// 缓存未命中:singleflight 合并同一 userID 的并发回源请求。
|
||||
value, err, _ := s.balanceLoadSF.Do(strconv.FormatInt(userID, 10), func() (any, error) {
|
||||
loadCtx, cancel := context.WithTimeout(context.Background(), balanceLoadTimeout)
|
||||
defer cancel()
|
||||
|
||||
balance, err := s.getUserBalanceFromDB(loadCtx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 异步建立缓存
|
||||
_ = s.enqueueCacheWrite(cacheWriteTask{
|
||||
kind: cacheWriteSetBalance,
|
||||
userID: userID,
|
||||
balance: balance,
|
||||
})
|
||||
return balance, nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 异步建立缓存
|
||||
_ = s.enqueueCacheWrite(cacheWriteTask{
|
||||
kind: cacheWriteSetBalance,
|
||||
userID: userID,
|
||||
balance: balance,
|
||||
})
|
||||
|
||||
balance, ok := value.(float64)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unexpected balance type: %T", value)
|
||||
}
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
@@ -274,7 +326,7 @@ func (s *BillingCacheService) setBalanceCache(ctx context.Context, userID int64,
|
||||
return
|
||||
}
|
||||
if err := s.cache.SetUserBalance(ctx, userID, balance); err != nil {
|
||||
log.Printf("Warning: set balance cache failed for user %d: %v", userID, err)
|
||||
logger.LegacyPrintf("service.billing_cache", "Warning: set balance cache failed for user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +354,7 @@ func (s *BillingCacheService) QueueDeductBalance(userID int64, amount float64) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cacheWriteTimeout)
|
||||
defer cancel()
|
||||
if err := s.DeductBalanceCache(ctx, userID, amount); err != nil {
|
||||
log.Printf("Warning: deduct balance cache fallback failed for user %d: %v", userID, err)
|
||||
logger.LegacyPrintf("service.billing_cache", "Warning: deduct balance cache fallback failed for user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,7 +364,7 @@ func (s *BillingCacheService) InvalidateUserBalance(ctx context.Context, userID
|
||||
return nil
|
||||
}
|
||||
if err := s.cache.InvalidateUserBalance(ctx, userID); err != nil {
|
||||
log.Printf("Warning: invalidate balance cache failed for user %d: %v", userID, err)
|
||||
logger.LegacyPrintf("service.billing_cache", "Warning: invalidate balance cache failed for user %d: %v", userID, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -396,7 +448,7 @@ func (s *BillingCacheService) setSubscriptionCache(ctx context.Context, userID,
|
||||
return
|
||||
}
|
||||
if err := s.cache.SetSubscriptionCache(ctx, userID, groupID, s.convertToPortsData(data)); err != nil {
|
||||
log.Printf("Warning: set subscription cache failed for user %d group %d: %v", userID, groupID, err)
|
||||
logger.LegacyPrintf("service.billing_cache", "Warning: set subscription cache failed for user %d group %d: %v", userID, groupID, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,7 +477,7 @@ func (s *BillingCacheService) QueueUpdateSubscriptionUsage(userID, groupID int64
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cacheWriteTimeout)
|
||||
defer cancel()
|
||||
if err := s.UpdateSubscriptionUsage(ctx, userID, groupID, costUSD); err != nil {
|
||||
log.Printf("Warning: update subscription cache fallback failed for user %d group %d: %v", userID, groupID, err)
|
||||
logger.LegacyPrintf("service.billing_cache", "Warning: update subscription cache fallback failed for user %d group %d: %v", userID, groupID, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,12 +487,143 @@ func (s *BillingCacheService) InvalidateSubscription(ctx context.Context, userID
|
||||
return nil
|
||||
}
|
||||
if err := s.cache.InvalidateSubscriptionCache(ctx, userID, groupID); err != nil {
|
||||
log.Printf("Warning: invalidate subscription cache failed for user %d group %d: %v", userID, groupID, err)
|
||||
logger.LegacyPrintf("service.billing_cache", "Warning: invalidate subscription cache failed for user %d group %d: %v", userID, groupID, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API Key 限速缓存方法
|
||||
// ============================================
|
||||
|
||||
// checkAPIKeyRateLimits checks rate limit windows for an API key.
|
||||
// It loads usage from Redis cache (falling back to DB on cache miss),
|
||||
// resets expired windows in-memory and triggers async DB reset,
|
||||
// and returns an error if any window limit is exceeded.
|
||||
func (s *BillingCacheService) checkAPIKeyRateLimits(ctx context.Context, apiKey *APIKey) error {
|
||||
if s.cache == nil {
|
||||
// No cache: fall back to reading from DB directly
|
||||
if s.apiKeyRateLimitLoader == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := s.apiKeyRateLimitLoader.GetRateLimitData(ctx, apiKey.ID)
|
||||
if err != nil {
|
||||
return nil // Don't block requests on DB errors
|
||||
}
|
||||
return s.evaluateRateLimits(ctx, apiKey, data.Usage5h, data.Usage1d, data.Usage7d,
|
||||
data.Window5hStart, data.Window1dStart, data.Window7dStart)
|
||||
}
|
||||
|
||||
cacheData, err := s.cache.GetAPIKeyRateLimit(ctx, apiKey.ID)
|
||||
if err != nil {
|
||||
// Cache miss: load from DB and populate cache
|
||||
if s.apiKeyRateLimitLoader == nil {
|
||||
return nil
|
||||
}
|
||||
dbData, dbErr := s.apiKeyRateLimitLoader.GetRateLimitData(ctx, apiKey.ID)
|
||||
if dbErr != nil {
|
||||
return nil // Don't block requests on DB errors
|
||||
}
|
||||
// Build cache entry from DB data
|
||||
cacheEntry := &APIKeyRateLimitCacheData{
|
||||
Usage5h: dbData.Usage5h,
|
||||
Usage1d: dbData.Usage1d,
|
||||
Usage7d: dbData.Usage7d,
|
||||
}
|
||||
if dbData.Window5hStart != nil {
|
||||
cacheEntry.Window5h = dbData.Window5hStart.Unix()
|
||||
}
|
||||
if dbData.Window1dStart != nil {
|
||||
cacheEntry.Window1d = dbData.Window1dStart.Unix()
|
||||
}
|
||||
if dbData.Window7dStart != nil {
|
||||
cacheEntry.Window7d = dbData.Window7dStart.Unix()
|
||||
}
|
||||
_ = s.cache.SetAPIKeyRateLimit(ctx, apiKey.ID, cacheEntry)
|
||||
cacheData = cacheEntry
|
||||
}
|
||||
|
||||
var w5h, w1d, w7d *time.Time
|
||||
if cacheData.Window5h > 0 {
|
||||
t := time.Unix(cacheData.Window5h, 0)
|
||||
w5h = &t
|
||||
}
|
||||
if cacheData.Window1d > 0 {
|
||||
t := time.Unix(cacheData.Window1d, 0)
|
||||
w1d = &t
|
||||
}
|
||||
if cacheData.Window7d > 0 {
|
||||
t := time.Unix(cacheData.Window7d, 0)
|
||||
w7d = &t
|
||||
}
|
||||
return s.evaluateRateLimits(ctx, apiKey, cacheData.Usage5h, cacheData.Usage1d, cacheData.Usage7d, w5h, w1d, w7d)
|
||||
}
|
||||
|
||||
// evaluateRateLimits checks usage against limits, triggering async resets for expired windows.
|
||||
func (s *BillingCacheService) evaluateRateLimits(ctx context.Context, apiKey *APIKey, usage5h, usage1d, usage7d float64, w5h, w1d, w7d *time.Time) error {
|
||||
needsReset := false
|
||||
|
||||
// Reset expired windows in-memory for check purposes
|
||||
if w5h != nil && time.Since(*w5h) >= 5*time.Hour {
|
||||
usage5h = 0
|
||||
needsReset = true
|
||||
}
|
||||
if w1d != nil && time.Since(*w1d) >= 24*time.Hour {
|
||||
usage1d = 0
|
||||
needsReset = true
|
||||
}
|
||||
if w7d != nil && time.Since(*w7d) >= 7*24*time.Hour {
|
||||
usage7d = 0
|
||||
needsReset = true
|
||||
}
|
||||
|
||||
// Trigger async DB reset if any window expired
|
||||
if needsReset {
|
||||
keyID := apiKey.ID
|
||||
go func() {
|
||||
resetCtx, cancel := context.WithTimeout(context.Background(), cacheWriteTimeout)
|
||||
defer cancel()
|
||||
if s.apiKeyRateLimitLoader != nil {
|
||||
// Use the repo directly - reset then reload cache
|
||||
if loader, ok := s.apiKeyRateLimitLoader.(interface {
|
||||
ResetRateLimitWindows(ctx context.Context, id int64) error
|
||||
}); ok {
|
||||
_ = loader.ResetRateLimitWindows(resetCtx, keyID)
|
||||
}
|
||||
}
|
||||
// Invalidate cache so next request loads fresh data
|
||||
if s.cache != nil {
|
||||
_ = s.cache.InvalidateAPIKeyRateLimit(resetCtx, keyID)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Check limits
|
||||
if apiKey.RateLimit5h > 0 && usage5h >= apiKey.RateLimit5h {
|
||||
return ErrAPIKeyRateLimit5hExceeded
|
||||
}
|
||||
if apiKey.RateLimit1d > 0 && usage1d >= apiKey.RateLimit1d {
|
||||
return ErrAPIKeyRateLimit1dExceeded
|
||||
}
|
||||
if apiKey.RateLimit7d > 0 && usage7d >= apiKey.RateLimit7d {
|
||||
return ErrAPIKeyRateLimit7dExceeded
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QueueUpdateAPIKeyRateLimitUsage asynchronously updates rate limit usage in the cache.
|
||||
func (s *BillingCacheService) QueueUpdateAPIKeyRateLimitUsage(apiKeyID int64, cost float64) {
|
||||
if s.cache == nil {
|
||||
return
|
||||
}
|
||||
s.enqueueCacheWrite(cacheWriteTask{
|
||||
kind: cacheWriteUpdateRateLimitUsage,
|
||||
apiKeyID: apiKeyID,
|
||||
amount: cost,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 统一检查方法
|
||||
// ============================================
|
||||
@@ -461,10 +644,23 @@ func (s *BillingCacheService) CheckBillingEligibility(ctx context.Context, user
|
||||
isSubscriptionMode := group != nil && group.IsSubscriptionType() && subscription != nil
|
||||
|
||||
if isSubscriptionMode {
|
||||
return s.checkSubscriptionEligibility(ctx, user.ID, group, subscription)
|
||||
if err := s.checkSubscriptionEligibility(ctx, user.ID, group, subscription); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := s.checkBalanceEligibility(ctx, user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return s.checkBalanceEligibility(ctx, user.ID)
|
||||
// Check API Key rate limits (applies to both billing modes)
|
||||
if apiKey != nil && apiKey.HasRateLimits() {
|
||||
if err := s.checkAPIKeyRateLimits(ctx, apiKey); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkBalanceEligibility 检查余额模式资格
|
||||
@@ -474,7 +670,7 @@ func (s *BillingCacheService) checkBalanceEligibility(ctx context.Context, userI
|
||||
if s.circuitBreaker != nil {
|
||||
s.circuitBreaker.OnFailure(err)
|
||||
}
|
||||
log.Printf("ALERT: billing balance check failed for user %d: %v", userID, err)
|
||||
logger.LegacyPrintf("service.billing_cache", "ALERT: billing balance check failed for user %d: %v", userID, err)
|
||||
return ErrBillingServiceUnavailable.WithCause(err)
|
||||
}
|
||||
if s.circuitBreaker != nil {
|
||||
@@ -496,7 +692,7 @@ func (s *BillingCacheService) checkSubscriptionEligibility(ctx context.Context,
|
||||
if s.circuitBreaker != nil {
|
||||
s.circuitBreaker.OnFailure(err)
|
||||
}
|
||||
log.Printf("ALERT: billing subscription check failed for user %d group %d: %v", userID, group.ID, err)
|
||||
logger.LegacyPrintf("service.billing_cache", "ALERT: billing subscription check failed for user %d group %d: %v", userID, group.ID, err)
|
||||
return ErrBillingServiceUnavailable.WithCause(err)
|
||||
}
|
||||
if s.circuitBreaker != nil {
|
||||
@@ -585,7 +781,7 @@ func (b *billingCircuitBreaker) Allow() bool {
|
||||
}
|
||||
b.state = billingCircuitHalfOpen
|
||||
b.halfOpenRemaining = b.halfOpenRequests
|
||||
log.Printf("ALERT: billing circuit breaker entering half-open state")
|
||||
logger.LegacyPrintf("service.billing_cache", "ALERT: billing circuit breaker entering half-open state")
|
||||
fallthrough
|
||||
case billingCircuitHalfOpen:
|
||||
if b.halfOpenRemaining <= 0 {
|
||||
@@ -612,7 +808,7 @@ func (b *billingCircuitBreaker) OnFailure(err error) {
|
||||
b.state = billingCircuitOpen
|
||||
b.openedAt = time.Now()
|
||||
b.halfOpenRemaining = 0
|
||||
log.Printf("ALERT: billing circuit breaker opened after half-open failure: %v", err)
|
||||
logger.LegacyPrintf("service.billing_cache", "ALERT: billing circuit breaker opened after half-open failure: %v", err)
|
||||
return
|
||||
default:
|
||||
b.failures++
|
||||
@@ -620,7 +816,7 @@ func (b *billingCircuitBreaker) OnFailure(err error) {
|
||||
b.state = billingCircuitOpen
|
||||
b.openedAt = time.Now()
|
||||
b.halfOpenRemaining = 0
|
||||
log.Printf("ALERT: billing circuit breaker opened after %d failures: %v", b.failures, err)
|
||||
logger.LegacyPrintf("service.billing_cache", "ALERT: billing circuit breaker opened after %d failures: %v", b.failures, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -641,9 +837,9 @@ func (b *billingCircuitBreaker) OnSuccess() {
|
||||
|
||||
// 只有状态真正发生变化时才记录日志
|
||||
if previousState != billingCircuitClosed {
|
||||
log.Printf("ALERT: billing circuit breaker closed (was %s)", circuitStateString(previousState))
|
||||
logger.LegacyPrintf("service.billing_cache", "ALERT: billing circuit breaker closed (was %s)", circuitStateString(previousState))
|
||||
} else if previousFailures > 0 {
|
||||
log.Printf("INFO: billing circuit breaker failures reset from %d", previousFailures)
|
||||
logger.LegacyPrintf("service.billing_cache", "INFO: billing circuit breaker failures reset from %d", previousFailures)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type billingCacheMissStub struct {
|
||||
setBalanceCalls atomic.Int64
|
||||
}
|
||||
|
||||
func (s *billingCacheMissStub) GetUserBalance(ctx context.Context, userID int64) (float64, error) {
|
||||
return 0, errors.New("cache miss")
|
||||
}
|
||||
|
||||
func (s *billingCacheMissStub) SetUserBalance(ctx context.Context, userID int64, balance float64) error {
|
||||
s.setBalanceCalls.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *billingCacheMissStub) DeductUserBalance(ctx context.Context, userID int64, amount float64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *billingCacheMissStub) InvalidateUserBalance(ctx context.Context, userID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *billingCacheMissStub) GetSubscriptionCache(ctx context.Context, userID, groupID int64) (*SubscriptionCacheData, error) {
|
||||
return nil, errors.New("cache miss")
|
||||
}
|
||||
|
||||
func (s *billingCacheMissStub) SetSubscriptionCache(ctx context.Context, userID, groupID int64, data *SubscriptionCacheData) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *billingCacheMissStub) UpdateSubscriptionUsage(ctx context.Context, userID, groupID int64, cost float64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *billingCacheMissStub) InvalidateSubscriptionCache(ctx context.Context, userID, groupID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *billingCacheMissStub) GetAPIKeyRateLimit(ctx context.Context, keyID int64) (*APIKeyRateLimitCacheData, error) {
|
||||
return nil, errors.New("cache miss")
|
||||
}
|
||||
|
||||
func (s *billingCacheMissStub) SetAPIKeyRateLimit(ctx context.Context, keyID int64, data *APIKeyRateLimitCacheData) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *billingCacheMissStub) UpdateAPIKeyRateLimitUsage(ctx context.Context, keyID int64, cost float64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *billingCacheMissStub) InvalidateAPIKeyRateLimit(ctx context.Context, keyID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type balanceLoadUserRepoStub struct {
|
||||
mockUserRepo
|
||||
calls atomic.Int64
|
||||
delay time.Duration
|
||||
balance float64
|
||||
}
|
||||
|
||||
func (s *balanceLoadUserRepoStub) GetByID(ctx context.Context, id int64) (*User, error) {
|
||||
s.calls.Add(1)
|
||||
if s.delay > 0 {
|
||||
select {
|
||||
case <-time.After(s.delay):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
return &User{ID: id, Balance: s.balance}, nil
|
||||
}
|
||||
|
||||
func TestBillingCacheServiceGetUserBalance_Singleflight(t *testing.T) {
|
||||
cache := &billingCacheMissStub{}
|
||||
userRepo := &balanceLoadUserRepoStub{
|
||||
delay: 80 * time.Millisecond,
|
||||
balance: 12.34,
|
||||
}
|
||||
svc := NewBillingCacheService(cache, userRepo, nil, nil, &config.Config{})
|
||||
t.Cleanup(svc.Stop)
|
||||
|
||||
const goroutines = 16
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
errCh := make(chan error, goroutines)
|
||||
balCh := make(chan float64, goroutines)
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
bal, err := svc.GetUserBalance(context.Background(), 99)
|
||||
errCh <- err
|
||||
balCh <- bal
|
||||
}()
|
||||
}
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
close(balCh)
|
||||
|
||||
for err := range errCh {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
for bal := range balCh {
|
||||
require.Equal(t, 12.34, bal)
|
||||
}
|
||||
|
||||
require.Equal(t, int64(1), userRepo.calls.Load(), "并发穿透应被 singleflight 合并")
|
||||
require.Eventually(t, func() bool {
|
||||
return cache.setBalanceCalls.Load() >= 1
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
}
|
||||
@@ -52,9 +52,25 @@ func (b *billingCacheWorkerStub) InvalidateSubscriptionCache(ctx context.Context
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *billingCacheWorkerStub) GetAPIKeyRateLimit(ctx context.Context, keyID int64) (*APIKeyRateLimitCacheData, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (b *billingCacheWorkerStub) SetAPIKeyRateLimit(ctx context.Context, keyID int64, data *APIKeyRateLimitCacheData) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *billingCacheWorkerStub) UpdateAPIKeyRateLimitUsage(ctx context.Context, keyID int64, cost float64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *billingCacheWorkerStub) InvalidateAPIKeyRateLimit(ctx context.Context, keyID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestBillingCacheServiceQueueHighLoad(t *testing.T) {
|
||||
cache := &billingCacheWorkerStub{}
|
||||
svc := NewBillingCacheService(cache, nil, nil, &config.Config{})
|
||||
svc := NewBillingCacheService(cache, nil, nil, nil, &config.Config{})
|
||||
t.Cleanup(svc.Stop)
|
||||
|
||||
start := time.Now()
|
||||
@@ -73,3 +89,16 @@ func TestBillingCacheServiceQueueHighLoad(t *testing.T) {
|
||||
return atomic.LoadInt64(&cache.subscriptionUpdates) > 0
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestBillingCacheServiceEnqueueAfterStopReturnsFalse(t *testing.T) {
|
||||
cache := &billingCacheWorkerStub{}
|
||||
svc := NewBillingCacheService(cache, nil, nil, nil, &config.Config{})
|
||||
svc.Stop()
|
||||
|
||||
enqueued := svc.enqueueCacheWrite(cacheWriteTask{
|
||||
kind: cacheWriteDeductBalance,
|
||||
userID: 1,
|
||||
amount: 1,
|
||||
})
|
||||
require.False(t, enqueued)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,16 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
)
|
||||
|
||||
// APIKeyRateLimitCacheData holds rate limit usage data cached in Redis.
|
||||
type APIKeyRateLimitCacheData struct {
|
||||
Usage5h float64 `json:"usage_5h"`
|
||||
Usage1d float64 `json:"usage_1d"`
|
||||
Usage7d float64 `json:"usage_7d"`
|
||||
Window5h int64 `json:"window_5h"` // unix timestamp, 0 = not started
|
||||
Window1d int64 `json:"window_1d"`
|
||||
Window7d int64 `json:"window_7d"`
|
||||
}
|
||||
|
||||
// BillingCache defines cache operations for billing service
|
||||
type BillingCache interface {
|
||||
// Balance operations
|
||||
@@ -23,6 +33,12 @@ type BillingCache interface {
|
||||
SetSubscriptionCache(ctx context.Context, userID, groupID int64, data *SubscriptionCacheData) error
|
||||
UpdateSubscriptionUsage(ctx context.Context, userID, groupID int64, cost float64) error
|
||||
InvalidateSubscriptionCache(ctx context.Context, userID, groupID int64) error
|
||||
|
||||
// API Key rate limit operations
|
||||
GetAPIKeyRateLimit(ctx context.Context, keyID int64) (*APIKeyRateLimitCacheData, error)
|
||||
SetAPIKeyRateLimit(ctx context.Context, keyID int64, data *APIKeyRateLimitCacheData) error
|
||||
UpdateAPIKeyRateLimitUsage(ctx context.Context, keyID int64, cost float64) error
|
||||
InvalidateAPIKeyRateLimit(ctx context.Context, keyID int64) error
|
||||
}
|
||||
|
||||
// ModelPricing 模型价格配置(per-token价格,与LiteLLM格式一致)
|
||||
@@ -31,8 +47,8 @@ type ModelPricing struct {
|
||||
OutputPricePerToken float64 // 每token输出价格 (USD)
|
||||
CacheCreationPricePerToken float64 // 缓存创建每token价格 (USD)
|
||||
CacheReadPricePerToken float64 // 缓存读取每token价格 (USD)
|
||||
CacheCreation5mPrice float64 // 5分钟缓存创建价格(每百万token)- 仅用于硬编码回退
|
||||
CacheCreation1hPrice float64 // 1小时缓存创建价格(每百万token)- 仅用于硬编码回退
|
||||
CacheCreation5mPrice float64 // 5分钟缓存创建每token价格 (USD)
|
||||
CacheCreation1hPrice float64 // 1小时缓存创建每token价格 (USD)
|
||||
SupportsCacheBreakdown bool // 是否支持详细的缓存分类
|
||||
}
|
||||
|
||||
@@ -133,6 +149,18 @@ func (s *BillingService) initFallbackPricing() {
|
||||
CacheReadPricePerToken: 0.03e-6, // $0.03 per MTok
|
||||
SupportsCacheBreakdown: false,
|
||||
}
|
||||
|
||||
// Claude 4.6 Opus (与4.5同价)
|
||||
s.fallbackPrices["claude-opus-4.6"] = s.fallbackPrices["claude-opus-4.5"]
|
||||
|
||||
// Gemini 3.1 Pro
|
||||
s.fallbackPrices["gemini-3.1-pro"] = &ModelPricing{
|
||||
InputPricePerToken: 2e-6, // $2 per MTok
|
||||
OutputPricePerToken: 12e-6, // $12 per MTok
|
||||
CacheCreationPricePerToken: 2e-6, // $2 per MTok
|
||||
CacheReadPricePerToken: 0.2e-6, // $0.20 per MTok
|
||||
SupportsCacheBreakdown: false,
|
||||
}
|
||||
}
|
||||
|
||||
// getFallbackPricing 根据模型系列获取回退价格
|
||||
@@ -141,6 +169,9 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
|
||||
|
||||
// 按模型系列匹配
|
||||
if strings.Contains(modelLower, "opus") {
|
||||
if strings.Contains(modelLower, "4.6") || strings.Contains(modelLower, "4-6") {
|
||||
return s.fallbackPrices["claude-opus-4.6"]
|
||||
}
|
||||
if strings.Contains(modelLower, "4.5") || strings.Contains(modelLower, "4-5") {
|
||||
return s.fallbackPrices["claude-opus-4.5"]
|
||||
}
|
||||
@@ -158,6 +189,9 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
|
||||
}
|
||||
return s.fallbackPrices["claude-3-haiku"]
|
||||
}
|
||||
if strings.Contains(modelLower, "gemini-3.1-pro") || strings.Contains(modelLower, "gemini-3-1-pro") {
|
||||
return s.fallbackPrices["gemini-3.1-pro"]
|
||||
}
|
||||
|
||||
// 默认使用Sonnet价格
|
||||
return s.fallbackPrices["claude-sonnet-4"]
|
||||
@@ -172,12 +206,20 @@ func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) {
|
||||
if s.pricingService != nil {
|
||||
litellmPricing := s.pricingService.GetModelPricing(model)
|
||||
if litellmPricing != nil {
|
||||
// 启用 5m/1h 分类计费的条件:
|
||||
// 1. 存在 1h 价格
|
||||
// 2. 1h 价格 > 5m 价格(防止 LiteLLM 数据错误导致少收费)
|
||||
price5m := litellmPricing.CacheCreationInputTokenCost
|
||||
price1h := litellmPricing.CacheCreationInputTokenCostAbove1hr
|
||||
enableBreakdown := price1h > 0 && price1h > price5m
|
||||
return &ModelPricing{
|
||||
InputPricePerToken: litellmPricing.InputCostPerToken,
|
||||
OutputPricePerToken: litellmPricing.OutputCostPerToken,
|
||||
CacheCreationPricePerToken: litellmPricing.CacheCreationInputTokenCost,
|
||||
CacheReadPricePerToken: litellmPricing.CacheReadInputTokenCost,
|
||||
SupportsCacheBreakdown: false,
|
||||
CacheCreation5mPrice: price5m,
|
||||
CacheCreation1hPrice: price1h,
|
||||
SupportsCacheBreakdown: enableBreakdown,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -209,9 +251,14 @@ func (s *BillingService) CalculateCost(model string, tokens UsageTokens, rateMul
|
||||
|
||||
// 计算缓存费用
|
||||
if pricing.SupportsCacheBreakdown && (pricing.CacheCreation5mPrice > 0 || pricing.CacheCreation1hPrice > 0) {
|
||||
// 支持详细缓存分类的模型(5分钟/1小时缓存)
|
||||
breakdown.CacheCreationCost = float64(tokens.CacheCreation5mTokens)/1_000_000*pricing.CacheCreation5mPrice +
|
||||
float64(tokens.CacheCreation1hTokens)/1_000_000*pricing.CacheCreation1hPrice
|
||||
// 支持详细缓存分类的模型(5分钟/1小时缓存,价格为 per-token)
|
||||
if tokens.CacheCreation5mTokens == 0 && tokens.CacheCreation1hTokens == 0 && tokens.CacheCreationTokens > 0 {
|
||||
// API 未返回 ephemeral 明细,回退到全部按 5m 单价计费
|
||||
breakdown.CacheCreationCost = float64(tokens.CacheCreationTokens) * pricing.CacheCreation5mPrice
|
||||
} else {
|
||||
breakdown.CacheCreationCost = float64(tokens.CacheCreation5mTokens)*pricing.CacheCreation5mPrice +
|
||||
float64(tokens.CacheCreation1hTokens)*pricing.CacheCreation1hPrice
|
||||
}
|
||||
} else {
|
||||
// 标准缓存创建价格(per-token)
|
||||
breakdown.CacheCreationCost = float64(tokens.CacheCreationTokens) * pricing.CacheCreationPricePerToken
|
||||
@@ -280,10 +327,12 @@ func (s *BillingService) CalculateCostWithLongContext(model string, tokens Usage
|
||||
|
||||
// 范围内部分:正常计费
|
||||
inRangeTokens := UsageTokens{
|
||||
InputTokens: inRangeInputTokens,
|
||||
OutputTokens: tokens.OutputTokens, // 输出只算一次
|
||||
CacheCreationTokens: tokens.CacheCreationTokens,
|
||||
CacheReadTokens: inRangeCacheTokens,
|
||||
InputTokens: inRangeInputTokens,
|
||||
OutputTokens: tokens.OutputTokens, // 输出只算一次
|
||||
CacheCreationTokens: tokens.CacheCreationTokens,
|
||||
CacheReadTokens: inRangeCacheTokens,
|
||||
CacheCreation5mTokens: tokens.CacheCreation5mTokens,
|
||||
CacheCreation1hTokens: tokens.CacheCreation1hTokens,
|
||||
}
|
||||
inRangeCost, err := s.CalculateCost(model, inRangeTokens, rateMultiplier)
|
||||
if err != nil {
|
||||
@@ -297,7 +346,7 @@ func (s *BillingService) CalculateCostWithLongContext(model string, tokens Usage
|
||||
}
|
||||
outRangeCost, err := s.CalculateCost(model, outRangeTokens, rateMultiplier*extraMultiplier)
|
||||
if err != nil {
|
||||
return inRangeCost, nil // 出错时返回范围内成本
|
||||
return inRangeCost, fmt.Errorf("out-range cost: %w", err)
|
||||
}
|
||||
|
||||
// 合并成本
|
||||
@@ -373,6 +422,14 @@ type ImagePriceConfig struct {
|
||||
Price4K *float64 // 4K 尺寸价格(nil 表示使用默认值)
|
||||
}
|
||||
|
||||
// SoraPriceConfig Sora 按次计费配置
|
||||
type SoraPriceConfig struct {
|
||||
ImagePrice360 *float64
|
||||
ImagePrice540 *float64
|
||||
VideoPricePerRequest *float64
|
||||
VideoPricePerRequestHD *float64
|
||||
}
|
||||
|
||||
// CalculateImageCost 计算图片生成费用
|
||||
// model: 请求的模型名称(用于获取 LiteLLM 默认价格)
|
||||
// imageSize: 图片尺寸 "1K", "2K", "4K"
|
||||
@@ -402,6 +459,65 @@ func (s *BillingService) CalculateImageCost(model string, imageSize string, imag
|
||||
}
|
||||
}
|
||||
|
||||
// CalculateSoraImageCost 计算 Sora 图片按次费用
|
||||
func (s *BillingService) CalculateSoraImageCost(imageSize string, imageCount int, groupConfig *SoraPriceConfig, rateMultiplier float64) *CostBreakdown {
|
||||
if imageCount <= 0 {
|
||||
return &CostBreakdown{}
|
||||
}
|
||||
|
||||
unitPrice := 0.0
|
||||
if groupConfig != nil {
|
||||
switch imageSize {
|
||||
case "540":
|
||||
if groupConfig.ImagePrice540 != nil {
|
||||
unitPrice = *groupConfig.ImagePrice540
|
||||
}
|
||||
default:
|
||||
if groupConfig.ImagePrice360 != nil {
|
||||
unitPrice = *groupConfig.ImagePrice360
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totalCost := unitPrice * float64(imageCount)
|
||||
if rateMultiplier <= 0 {
|
||||
rateMultiplier = 1.0
|
||||
}
|
||||
actualCost := totalCost * rateMultiplier
|
||||
|
||||
return &CostBreakdown{
|
||||
TotalCost: totalCost,
|
||||
ActualCost: actualCost,
|
||||
}
|
||||
}
|
||||
|
||||
// CalculateSoraVideoCost 计算 Sora 视频按次费用
|
||||
func (s *BillingService) CalculateSoraVideoCost(model string, groupConfig *SoraPriceConfig, rateMultiplier float64) *CostBreakdown {
|
||||
unitPrice := 0.0
|
||||
if groupConfig != nil {
|
||||
modelLower := strings.ToLower(model)
|
||||
if strings.Contains(modelLower, "sora2pro-hd") {
|
||||
if groupConfig.VideoPricePerRequestHD != nil {
|
||||
unitPrice = *groupConfig.VideoPricePerRequestHD
|
||||
}
|
||||
}
|
||||
if unitPrice <= 0 && groupConfig.VideoPricePerRequest != nil {
|
||||
unitPrice = *groupConfig.VideoPricePerRequest
|
||||
}
|
||||
}
|
||||
|
||||
totalCost := unitPrice
|
||||
if rateMultiplier <= 0 {
|
||||
rateMultiplier = 1.0
|
||||
}
|
||||
actualCost := totalCost * rateMultiplier
|
||||
|
||||
return &CostBreakdown{
|
||||
TotalCost: totalCost,
|
||||
ActualCost: actualCost,
|
||||
}
|
||||
}
|
||||
|
||||
// getImageUnitPrice 获取图片单价
|
||||
func (s *BillingService) getImageUnitPrice(model string, imageSize string, groupConfig *ImagePriceConfig) float64 {
|
||||
// 优先使用分组配置的价格
|
||||
@@ -443,7 +559,10 @@ func (s *BillingService) getDefaultImagePrice(model string, imageSize string) fl
|
||||
basePrice = 0.134
|
||||
}
|
||||
|
||||
// 4K 尺寸翻倍
|
||||
// 2K 尺寸 1.5 倍,4K 尺寸翻倍
|
||||
if imageSize == "2K" {
|
||||
return basePrice * 1.5
|
||||
}
|
||||
if imageSize == "4K" {
|
||||
return basePrice * 2
|
||||
}
|
||||
|
||||
@@ -12,14 +12,14 @@ import (
|
||||
func TestCalculateImageCost_DefaultPricing(t *testing.T) {
|
||||
svc := &BillingService{} // pricingService 为 nil,使用硬编码默认值
|
||||
|
||||
// 2K 尺寸,默认价格 $0.134
|
||||
// 2K 尺寸,默认价格 $0.134 * 1.5 = $0.201
|
||||
cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 1.0)
|
||||
require.InDelta(t, 0.134, cost.TotalCost, 0.0001)
|
||||
require.InDelta(t, 0.134, cost.ActualCost, 0.0001)
|
||||
require.InDelta(t, 0.201, cost.TotalCost, 0.0001)
|
||||
require.InDelta(t, 0.201, cost.ActualCost, 0.0001)
|
||||
|
||||
// 多张图片
|
||||
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 3, nil, 1.0)
|
||||
require.InDelta(t, 0.402, cost.TotalCost, 0.0001)
|
||||
require.InDelta(t, 0.603, cost.TotalCost, 0.0001)
|
||||
}
|
||||
|
||||
// TestCalculateImageCost_GroupCustomPricing 测试分组自定义价格
|
||||
@@ -63,13 +63,13 @@ func TestCalculateImageCost_RateMultiplier(t *testing.T) {
|
||||
|
||||
// 费率倍数 1.5x
|
||||
cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 1.5)
|
||||
require.InDelta(t, 0.134, cost.TotalCost, 0.0001) // TotalCost 不变
|
||||
require.InDelta(t, 0.201, cost.ActualCost, 0.0001) // ActualCost = 0.134 * 1.5
|
||||
require.InDelta(t, 0.201, cost.TotalCost, 0.0001) // TotalCost = 0.134 * 1.5
|
||||
require.InDelta(t, 0.3015, cost.ActualCost, 0.0001) // ActualCost = 0.201 * 1.5
|
||||
|
||||
// 费率倍数 2.0x
|
||||
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 2, nil, 2.0)
|
||||
require.InDelta(t, 0.268, cost.TotalCost, 0.0001)
|
||||
require.InDelta(t, 0.536, cost.ActualCost, 0.0001)
|
||||
require.InDelta(t, 0.402, cost.TotalCost, 0.0001)
|
||||
require.InDelta(t, 0.804, cost.ActualCost, 0.0001)
|
||||
}
|
||||
|
||||
// TestCalculateImageCost_ZeroCount 测试 imageCount=0
|
||||
@@ -95,8 +95,8 @@ func TestCalculateImageCost_ZeroRateMultiplier(t *testing.T) {
|
||||
svc := &BillingService{}
|
||||
|
||||
cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 0)
|
||||
require.InDelta(t, 0.134, cost.TotalCost, 0.0001)
|
||||
require.InDelta(t, 0.134, cost.ActualCost, 0.0001) // 0 倍率当作 1.0 处理
|
||||
require.InDelta(t, 0.201, cost.TotalCost, 0.0001)
|
||||
require.InDelta(t, 0.201, cost.ActualCost, 0.0001) // 0 倍率当作 1.0 处理
|
||||
}
|
||||
|
||||
// TestGetImageUnitPrice_GroupPriorityOverDefault 测试分组价格优先于默认价格
|
||||
@@ -127,9 +127,9 @@ func TestGetImageUnitPrice_PartialGroupConfig(t *testing.T) {
|
||||
cost := svc.CalculateImageCost("gemini-3-pro-image", "1K", 1, groupConfig, 1.0)
|
||||
require.InDelta(t, 0.10, cost.TotalCost, 0.0001)
|
||||
|
||||
// 2K 回退默认价格 $0.134
|
||||
// 2K 回退默认价格 $0.201 (1.5倍)
|
||||
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, groupConfig, 1.0)
|
||||
require.InDelta(t, 0.134, cost.TotalCost, 0.0001)
|
||||
require.InDelta(t, 0.201, cost.TotalCost, 0.0001)
|
||||
|
||||
// 4K 回退默认价格 $0.268 (翻倍)
|
||||
cost = svc.CalculateImageCost("gemini-3-pro-image", "4K", 1, groupConfig, 1.0)
|
||||
@@ -140,10 +140,10 @@ func TestGetImageUnitPrice_PartialGroupConfig(t *testing.T) {
|
||||
func TestGetDefaultImagePrice_FallbackHardcoded(t *testing.T) {
|
||||
svc := &BillingService{} // pricingService 为 nil
|
||||
|
||||
// 1K 和 2K 使用相同的默认价格 $0.134
|
||||
// 1K 默认价格 $0.134,2K 默认价格 $0.201 (1.5倍)
|
||||
cost := svc.CalculateImageCost("gemini-3-pro-image", "1K", 1, nil, 1.0)
|
||||
require.InDelta(t, 0.134, cost.TotalCost, 0.0001)
|
||||
|
||||
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 1.0)
|
||||
require.InDelta(t, 0.134, cost.TotalCost, 0.0001)
|
||||
require.InDelta(t, 0.201, cost.TotalCost, 0.0001)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestBillingService() *BillingService {
|
||||
return NewBillingService(&config.Config{}, nil)
|
||||
}
|
||||
|
||||
func TestCalculateCost_BasicComputation(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
// 使用 claude-sonnet-4 的回退价格:Input $3/MTok, Output $15/MTok
|
||||
tokens := UsageTokens{
|
||||
InputTokens: 1000,
|
||||
OutputTokens: 500,
|
||||
}
|
||||
cost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 1000 * 3e-6 = 0.003, 500 * 15e-6 = 0.0075
|
||||
expectedInput := 1000 * 3e-6
|
||||
expectedOutput := 500 * 15e-6
|
||||
require.InDelta(t, expectedInput, cost.InputCost, 1e-10)
|
||||
require.InDelta(t, expectedOutput, cost.OutputCost, 1e-10)
|
||||
require.InDelta(t, expectedInput+expectedOutput, cost.TotalCost, 1e-10)
|
||||
require.InDelta(t, expectedInput+expectedOutput, cost.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestCalculateCost_WithCacheTokens(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
tokens := UsageTokens{
|
||||
InputTokens: 1000,
|
||||
OutputTokens: 500,
|
||||
CacheCreationTokens: 2000,
|
||||
CacheReadTokens: 3000,
|
||||
}
|
||||
cost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedCacheCreation := 2000 * 3.75e-6
|
||||
expectedCacheRead := 3000 * 0.3e-6
|
||||
require.InDelta(t, expectedCacheCreation, cost.CacheCreationCost, 1e-10)
|
||||
require.InDelta(t, expectedCacheRead, cost.CacheReadCost, 1e-10)
|
||||
|
||||
expectedTotal := cost.InputCost + cost.OutputCost + expectedCacheCreation + expectedCacheRead
|
||||
require.InDelta(t, expectedTotal, cost.TotalCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestCalculateCost_RateMultiplier(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
tokens := UsageTokens{InputTokens: 1000, OutputTokens: 500}
|
||||
|
||||
cost1x, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
cost2x, err := svc.CalculateCost("claude-sonnet-4", tokens, 2.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// TotalCost 不受倍率影响,ActualCost 翻倍
|
||||
require.InDelta(t, cost1x.TotalCost, cost2x.TotalCost, 1e-10)
|
||||
require.InDelta(t, cost1x.ActualCost*2, cost2x.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestCalculateCost_ZeroMultiplierDefaultsToOne(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
tokens := UsageTokens{InputTokens: 1000}
|
||||
|
||||
costZero, err := svc.CalculateCost("claude-sonnet-4", tokens, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
costOne, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.InDelta(t, costOne.ActualCost, costZero.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestCalculateCost_NegativeMultiplierDefaultsToOne(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
tokens := UsageTokens{InputTokens: 1000}
|
||||
|
||||
costNeg, err := svc.CalculateCost("claude-sonnet-4", tokens, -1.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
costOne, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.InDelta(t, costOne.ActualCost, costNeg.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestGetModelPricing_FallbackMatchesByFamily(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
tests := []struct {
|
||||
model string
|
||||
expectedInput float64
|
||||
}{
|
||||
{"claude-opus-4.5-20250101", 5e-6},
|
||||
{"claude-3-opus-20240229", 15e-6},
|
||||
{"claude-sonnet-4-20250514", 3e-6},
|
||||
{"claude-3-5-sonnet-20241022", 3e-6},
|
||||
{"claude-3-5-haiku-20241022", 1e-6},
|
||||
{"claude-3-haiku-20240307", 0.25e-6},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
pricing, err := svc.GetModelPricing(tt.model)
|
||||
require.NoError(t, err, "模型 %s", tt.model)
|
||||
require.InDelta(t, tt.expectedInput, pricing.InputPricePerToken, 1e-12, "模型 %s 输入价格", tt.model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetModelPricing_CaseInsensitive(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
p1, err := svc.GetModelPricing("Claude-Sonnet-4")
|
||||
require.NoError(t, err)
|
||||
|
||||
p2, err := svc.GetModelPricing("claude-sonnet-4")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, p1.InputPricePerToken, p2.InputPricePerToken)
|
||||
}
|
||||
|
||||
func TestGetModelPricing_UnknownModelFallsBackToSonnet(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
// 不包含 opus/sonnet/haiku 关键词的 Claude 模型会走默认 Sonnet 价格
|
||||
pricing, err := svc.GetModelPricing("claude-unknown-model")
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 3e-6, pricing.InputPricePerToken, 1e-12)
|
||||
}
|
||||
|
||||
func TestCalculateCostWithLongContext_BelowThreshold(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
tokens := UsageTokens{
|
||||
InputTokens: 50000,
|
||||
OutputTokens: 1000,
|
||||
CacheReadTokens: 100000,
|
||||
}
|
||||
// 总输入 150k < 200k 阈值,应走正常计费
|
||||
cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 2.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
normalCost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.InDelta(t, normalCost.ActualCost, cost.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestCalculateCostWithLongContext_AboveThreshold_CacheExceedsThreshold(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
// 缓存 210k + 输入 10k = 220k > 200k 阈值
|
||||
// 缓存已超阈值:范围内 200k 缓存,范围外 10k 缓存 + 10k 输入
|
||||
tokens := UsageTokens{
|
||||
InputTokens: 10000,
|
||||
OutputTokens: 1000,
|
||||
CacheReadTokens: 210000,
|
||||
}
|
||||
cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 2.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 范围内:200k cache + 0 input + 1k output
|
||||
inRange, _ := svc.CalculateCost("claude-sonnet-4", UsageTokens{
|
||||
InputTokens: 0,
|
||||
OutputTokens: 1000,
|
||||
CacheReadTokens: 200000,
|
||||
}, 1.0)
|
||||
|
||||
// 范围外:10k cache + 10k input,倍率 2.0
|
||||
outRange, _ := svc.CalculateCost("claude-sonnet-4", UsageTokens{
|
||||
InputTokens: 10000,
|
||||
CacheReadTokens: 10000,
|
||||
}, 2.0)
|
||||
|
||||
require.InDelta(t, inRange.ActualCost+outRange.ActualCost, cost.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestCalculateCostWithLongContext_AboveThreshold_CacheBelowThreshold(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
// 缓存 100k + 输入 150k = 250k > 200k 阈值
|
||||
// 缓存未超阈值:范围内 100k 缓存 + 100k 输入,范围外 50k 输入
|
||||
tokens := UsageTokens{
|
||||
InputTokens: 150000,
|
||||
OutputTokens: 1000,
|
||||
CacheReadTokens: 100000,
|
||||
}
|
||||
cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 2.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, cost.ActualCost > 0, "费用应大于 0")
|
||||
|
||||
// 正常费用不含长上下文
|
||||
normalCost, _ := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||
require.True(t, cost.ActualCost > normalCost.ActualCost, "长上下文费用应高于正常费用")
|
||||
}
|
||||
|
||||
func TestCalculateCostWithLongContext_DisabledThreshold(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
tokens := UsageTokens{InputTokens: 300000, CacheReadTokens: 0}
|
||||
|
||||
// threshold <= 0 应禁用长上下文计费
|
||||
cost1, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 0, 2.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
cost2, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.InDelta(t, cost2.ActualCost, cost1.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestCalculateCostWithLongContext_ExtraMultiplierLessEqualOne(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
tokens := UsageTokens{InputTokens: 300000}
|
||||
|
||||
// extraMultiplier <= 1 应禁用长上下文计费
|
||||
cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 1.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
normalCost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.InDelta(t, normalCost.ActualCost, cost.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestCalculateImageCost(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
price := 0.134
|
||||
cfg := &ImagePriceConfig{Price1K: &price}
|
||||
cost := svc.CalculateImageCost("gpt-image-1", "1K", 3, cfg, 1.0)
|
||||
|
||||
require.InDelta(t, 0.134*3, cost.TotalCost, 1e-10)
|
||||
require.InDelta(t, 0.134*3, cost.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestCalculateSoraVideoCost(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
price := 0.5
|
||||
cfg := &SoraPriceConfig{VideoPricePerRequest: &price}
|
||||
cost := svc.CalculateSoraVideoCost("sora-video", cfg, 1.0)
|
||||
|
||||
require.InDelta(t, 0.5, cost.TotalCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestCalculateSoraVideoCost_HDModel(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
hdPrice := 1.0
|
||||
normalPrice := 0.5
|
||||
cfg := &SoraPriceConfig{
|
||||
VideoPricePerRequest: &normalPrice,
|
||||
VideoPricePerRequestHD: &hdPrice,
|
||||
}
|
||||
cost := svc.CalculateSoraVideoCost("sora2pro-hd", cfg, 1.0)
|
||||
require.InDelta(t, 1.0, cost.TotalCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestIsModelSupported(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
require.True(t, svc.IsModelSupported("claude-sonnet-4"))
|
||||
require.True(t, svc.IsModelSupported("Claude-Opus-4.5"))
|
||||
require.True(t, svc.IsModelSupported("claude-3-haiku"))
|
||||
require.False(t, svc.IsModelSupported("gpt-4o"))
|
||||
require.False(t, svc.IsModelSupported("gemini-pro"))
|
||||
}
|
||||
|
||||
func TestCalculateCost_ZeroTokens(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
cost, err := svc.CalculateCost("claude-sonnet-4", UsageTokens{}, 1.0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0.0, cost.TotalCost)
|
||||
require.Equal(t, 0.0, cost.ActualCost)
|
||||
}
|
||||
|
||||
func TestCalculateCostWithConfig(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Default.RateMultiplier = 1.5
|
||||
svc := NewBillingService(cfg, nil)
|
||||
|
||||
tokens := UsageTokens{InputTokens: 1000, OutputTokens: 500}
|
||||
cost, err := svc.CalculateCostWithConfig("claude-sonnet-4", tokens)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected, _ := svc.CalculateCost("claude-sonnet-4", tokens, 1.5)
|
||||
require.InDelta(t, expected.ActualCost, cost.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestCalculateCostWithConfig_ZeroMultiplier(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Default.RateMultiplier = 0
|
||||
svc := NewBillingService(cfg, nil)
|
||||
|
||||
tokens := UsageTokens{InputTokens: 1000}
|
||||
cost, err := svc.CalculateCostWithConfig("claude-sonnet-4", tokens)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 倍率 <=0 时默认 1.0
|
||||
expected, _ := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||
require.InDelta(t, expected.ActualCost, cost.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestGetEstimatedCost(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
est, err := svc.GetEstimatedCost("claude-sonnet-4", 1000, 500)
|
||||
require.NoError(t, err)
|
||||
require.True(t, est > 0)
|
||||
}
|
||||
|
||||
func TestListSupportedModels(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
models := svc.ListSupportedModels()
|
||||
require.NotEmpty(t, models)
|
||||
require.GreaterOrEqual(t, len(models), 6)
|
||||
}
|
||||
|
||||
func TestGetPricingServiceStatus_NilService(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
status := svc.GetPricingServiceStatus()
|
||||
require.NotNil(t, status)
|
||||
require.Equal(t, "using fallback", status["last_updated"])
|
||||
}
|
||||
|
||||
func TestForceUpdatePricing_NilService(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
err := svc.ForceUpdatePricing()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "not initialized")
|
||||
}
|
||||
|
||||
func TestCalculateSoraImageCost(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
price360 := 0.05
|
||||
price540 := 0.08
|
||||
cfg := &SoraPriceConfig{ImagePrice360: &price360, ImagePrice540: &price540}
|
||||
|
||||
cost := svc.CalculateSoraImageCost("360", 2, cfg, 1.0)
|
||||
require.InDelta(t, 0.10, cost.TotalCost, 1e-10)
|
||||
|
||||
cost540 := svc.CalculateSoraImageCost("540", 1, cfg, 2.0)
|
||||
require.InDelta(t, 0.08, cost540.TotalCost, 1e-10)
|
||||
require.InDelta(t, 0.16, cost540.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestCalculateSoraImageCost_ZeroCount(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
cost := svc.CalculateSoraImageCost("360", 0, nil, 1.0)
|
||||
require.Equal(t, 0.0, cost.TotalCost)
|
||||
}
|
||||
|
||||
func TestCalculateSoraVideoCost_NilConfig(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
cost := svc.CalculateSoraVideoCost("sora-video", nil, 1.0)
|
||||
require.Equal(t, 0.0, cost.TotalCost)
|
||||
}
|
||||
|
||||
func TestCalculateCostWithLongContext_PropagatesError(t *testing.T) {
|
||||
// 使用空的 fallback prices 让 GetModelPricing 失败
|
||||
svc := &BillingService{
|
||||
cfg: &config.Config{},
|
||||
fallbackPrices: make(map[string]*ModelPricing),
|
||||
}
|
||||
|
||||
tokens := UsageTokens{InputTokens: 300000, CacheReadTokens: 0}
|
||||
_, err := svc.CalculateCostWithLongContext("unknown-model", tokens, 1.0, 200000, 2.0)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "pricing not found")
|
||||
}
|
||||
|
||||
func TestCalculateCost_SupportsCacheBreakdown(t *testing.T) {
|
||||
svc := &BillingService{
|
||||
cfg: &config.Config{},
|
||||
fallbackPrices: map[string]*ModelPricing{
|
||||
"claude-sonnet-4": {
|
||||
InputPricePerToken: 3e-6,
|
||||
OutputPricePerToken: 15e-6,
|
||||
SupportsCacheBreakdown: true,
|
||||
CacheCreation5mPrice: 4e-6, // per token
|
||||
CacheCreation1hPrice: 5e-6, // per token
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tokens := UsageTokens{
|
||||
InputTokens: 1000,
|
||||
OutputTokens: 500,
|
||||
CacheCreation5mTokens: 100000,
|
||||
CacheCreation1hTokens: 50000,
|
||||
}
|
||||
cost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected5m := float64(tokens.CacheCreation5mTokens) * 4e-6
|
||||
expected1h := float64(tokens.CacheCreation1hTokens) * 5e-6
|
||||
require.InDelta(t, expected5m+expected1h, cost.CacheCreationCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestCalculateCost_LargeTokenCount(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
tokens := UsageTokens{
|
||||
InputTokens: 1_000_000,
|
||||
OutputTokens: 1_000_000,
|
||||
}
|
||||
cost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Input: 1M * 3e-6 = $3, Output: 1M * 15e-6 = $15
|
||||
require.InDelta(t, 3.0, cost.InputCost, 1e-6)
|
||||
require.InDelta(t, 15.0, cost.OutputCost, 1e-6)
|
||||
require.False(t, math.IsNaN(cost.TotalCost))
|
||||
require.False(t, math.IsInf(cost.TotalCost, 0))
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestValidator() *ClaudeCodeValidator {
|
||||
return NewClaudeCodeValidator()
|
||||
}
|
||||
|
||||
// validClaudeCodeBody 构造一个完整有效的 Claude Code 请求体
|
||||
func validClaudeCodeBody() map[string]any {
|
||||
return map[string]any{
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"system": []any{
|
||||
map[string]any{
|
||||
"type": "text",
|
||||
"text": "You are Claude Code, Anthropic's official CLI for Claude.",
|
||||
},
|
||||
},
|
||||
"metadata": map[string]any{
|
||||
"user_id": "user_" + "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + "_account__session_" + "12345678-1234-1234-1234-123456789abc",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_ClaudeCLIUserAgent(t *testing.T) {
|
||||
v := newTestValidator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ua string
|
||||
want bool
|
||||
}{
|
||||
{"标准版本号", "claude-cli/1.0.0", true},
|
||||
{"多位版本号", "claude-cli/12.34.56", true},
|
||||
{"大写开头", "Claude-CLI/1.0.0", true},
|
||||
{"非 claude-cli", "curl/7.64.1", false},
|
||||
{"空 User-Agent", "", false},
|
||||
{"部分匹配", "not-claude-cli/1.0.0", false},
|
||||
{"缺少版本号", "claude-cli/", false},
|
||||
{"版本格式不对", "claude-cli/1.0", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, v.ValidateUserAgent(tt.ua), "UA: %q", tt.ua)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_NonMessagesPath_UAOnly(t *testing.T) {
|
||||
v := newTestValidator()
|
||||
|
||||
// 非 messages 路径只检查 UA
|
||||
req := httptest.NewRequest("GET", "/v1/models", nil)
|
||||
req.Header.Set("User-Agent", "claude-cli/1.0.0")
|
||||
|
||||
result := v.Validate(req, nil)
|
||||
require.True(t, result, "非 messages 路径只需 UA 匹配")
|
||||
}
|
||||
|
||||
func TestValidate_NonMessagesPath_InvalidUA(t *testing.T) {
|
||||
v := newTestValidator()
|
||||
|
||||
req := httptest.NewRequest("GET", "/v1/models", nil)
|
||||
req.Header.Set("User-Agent", "curl/7.64.1")
|
||||
|
||||
result := v.Validate(req, nil)
|
||||
require.False(t, result, "UA 不匹配时应返回 false")
|
||||
}
|
||||
|
||||
func TestValidate_MessagesPath_FullValid(t *testing.T) {
|
||||
v := newTestValidator()
|
||||
|
||||
req := httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
req.Header.Set("User-Agent", "claude-cli/1.0.0")
|
||||
req.Header.Set("X-App", "claude-code")
|
||||
req.Header.Set("anthropic-beta", "max-tokens-3-5-sonnet-2024-07-15")
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
result := v.Validate(req, validClaudeCodeBody())
|
||||
require.True(t, result, "完整有效请求应通过")
|
||||
}
|
||||
|
||||
func TestValidate_MessagesPath_MissingHeaders(t *testing.T) {
|
||||
v := newTestValidator()
|
||||
body := validClaudeCodeBody()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
missingHeader string
|
||||
}{
|
||||
{"缺少 X-App", "X-App"},
|
||||
{"缺少 anthropic-beta", "anthropic-beta"},
|
||||
{"缺少 anthropic-version", "anthropic-version"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
req.Header.Set("User-Agent", "claude-cli/1.0.0")
|
||||
req.Header.Set("X-App", "claude-code")
|
||||
req.Header.Set("anthropic-beta", "beta")
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
req.Header.Del(tt.missingHeader)
|
||||
|
||||
result := v.Validate(req, body)
|
||||
require.False(t, result, "缺少 %s 应返回 false", tt.missingHeader)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_MessagesPath_InvalidMetadataUserID(t *testing.T) {
|
||||
v := newTestValidator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
metadata map[string]any
|
||||
}{
|
||||
{"缺少 metadata", nil},
|
||||
{"缺少 user_id", map[string]any{"other": "value"}},
|
||||
{"空 user_id", map[string]any{"user_id": ""}},
|
||||
{"格式错误", map[string]any{"user_id": "invalid-format"}},
|
||||
{"hex 长度不足", map[string]any{"user_id": "user_abc_account__session_uuid"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
req.Header.Set("User-Agent", "claude-cli/1.0.0")
|
||||
req.Header.Set("X-App", "claude-code")
|
||||
req.Header.Set("anthropic-beta", "beta")
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
body := map[string]any{
|
||||
"model": "claude-sonnet-4",
|
||||
"system": []any{
|
||||
map[string]any{
|
||||
"type": "text",
|
||||
"text": "You are Claude Code, Anthropic's official CLI for Claude.",
|
||||
},
|
||||
},
|
||||
}
|
||||
if tt.metadata != nil {
|
||||
body["metadata"] = tt.metadata
|
||||
}
|
||||
|
||||
result := v.Validate(req, body)
|
||||
require.False(t, result, "metadata.user_id: %v", tt.metadata)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_MessagesPath_InvalidSystemPrompt(t *testing.T) {
|
||||
v := newTestValidator()
|
||||
|
||||
req := httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
req.Header.Set("User-Agent", "claude-cli/1.0.0")
|
||||
req.Header.Set("X-App", "claude-code")
|
||||
req.Header.Set("anthropic-beta", "beta")
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
body := map[string]any{
|
||||
"model": "claude-sonnet-4",
|
||||
"system": []any{
|
||||
map[string]any{
|
||||
"type": "text",
|
||||
"text": "Generate JSON data for testing database migrations.",
|
||||
},
|
||||
},
|
||||
"metadata": map[string]any{
|
||||
"user_id": "user_" + "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + "_account__session_12345678-1234-1234-1234-123456789abc",
|
||||
},
|
||||
}
|
||||
|
||||
result := v.Validate(req, body)
|
||||
require.False(t, result, "无关系统提示词应返回 false")
|
||||
}
|
||||
|
||||
func TestValidate_MaxTokensOneHaikuBypass(t *testing.T) {
|
||||
v := newTestValidator()
|
||||
|
||||
req := httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
req.Header.Set("User-Agent", "claude-cli/1.0.0")
|
||||
// 不设置 X-App 等头,通过 context 标记为 haiku 探测请求
|
||||
ctx := context.WithValue(req.Context(), ctxkey.IsMaxTokensOneHaikuRequest, true)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
// 即使 body 不包含 system prompt,也应通过
|
||||
result := v.Validate(req, map[string]any{"model": "claude-3-haiku", "max_tokens": 1})
|
||||
require.True(t, result, "max_tokens=1+haiku 探测请求应绕过严格验证")
|
||||
}
|
||||
|
||||
func TestSystemPromptSimilarity(t *testing.T) {
|
||||
v := newTestValidator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
prompt string
|
||||
want bool
|
||||
}{
|
||||
{"精确匹配", "You are Claude Code, Anthropic's official CLI for Claude.", true},
|
||||
{"带多余空格", "You are Claude Code, Anthropic's official CLI for Claude.", true},
|
||||
{"Agent SDK 模板", "You are a Claude agent, built on Anthropic's Claude Agent SDK.", true},
|
||||
{"文件搜索专家模板", "You are a file search specialist for Claude Code, Anthropic's official CLI for Claude.", true},
|
||||
{"对话摘要模板", "You are a helpful AI assistant tasked with summarizing conversations.", true},
|
||||
{"交互式 CLI 模板", "You are an interactive CLI tool that helps users", true},
|
||||
{"无关文本", "Write me a poem about cats", false},
|
||||
{"空文本", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"model": "claude-sonnet-4",
|
||||
"system": []any{
|
||||
map[string]any{"type": "text", "text": tt.prompt},
|
||||
},
|
||||
}
|
||||
result := v.IncludesClaudeCodeSystemPrompt(body)
|
||||
require.Equal(t, tt.want, result, "提示词: %q", tt.prompt)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiceCoefficient(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a string
|
||||
b string
|
||||
want float64
|
||||
tol float64
|
||||
}{
|
||||
{"相同字符串", "hello", "hello", 1.0, 0.001},
|
||||
{"完全不同", "abc", "xyz", 0.0, 0.001},
|
||||
{"空字符串", "", "hello", 0.0, 0.001},
|
||||
{"单字符", "a", "b", 0.0, 0.001},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := diceCoefficient(tt.a, tt.b)
|
||||
require.InDelta(t, tt.want, result, tt.tol)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsClaudeCodeClient_Context(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// 默认应为 false
|
||||
require.False(t, IsClaudeCodeClient(ctx))
|
||||
|
||||
// 设置为 true
|
||||
ctx = SetClaudeCodeClient(ctx, true)
|
||||
require.True(t, IsClaudeCodeClient(ctx))
|
||||
|
||||
// 设置为 false
|
||||
ctx = SetClaudeCodeClient(ctx, false)
|
||||
require.False(t, IsClaudeCodeClient(ctx))
|
||||
}
|
||||
|
||||
func TestValidate_NilBody_MessagesPath(t *testing.T) {
|
||||
v := newTestValidator()
|
||||
|
||||
req := httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
req.Header.Set("User-Agent", "claude-cli/1.0.0")
|
||||
req.Header.Set("X-App", "claude-code")
|
||||
req.Header.Set("anthropic-beta", "beta")
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
result := v.Validate(req, nil)
|
||||
require.False(t, result, "nil body 的 messages 请求应返回 false")
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
@@ -17,6 +18,9 @@ var (
|
||||
// User-Agent 匹配: claude-cli/x.x.x (仅支持官方 CLI,大小写不敏感)
|
||||
claudeCodeUAPattern = regexp.MustCompile(`(?i)^claude-cli/\d+\.\d+\.\d+`)
|
||||
|
||||
// 带捕获组的版本提取正则
|
||||
claudeCodeUAVersionPattern = regexp.MustCompile(`(?i)^claude-cli/(\d+\.\d+\.\d+)`)
|
||||
|
||||
// metadata.user_id 格式: user_{64位hex}_account__session_{uuid}
|
||||
userIDPattern = regexp.MustCompile(`^user_[a-fA-F0-9]{64}_account__session_[\w-]+$`)
|
||||
|
||||
@@ -78,7 +82,7 @@ func (v *ClaudeCodeValidator) Validate(r *http.Request, body map[string]any) boo
|
||||
|
||||
// Step 3: 检查 max_tokens=1 + haiku 探测请求绕过
|
||||
// 这类请求用于 Claude Code 验证 API 连通性,不携带 system prompt
|
||||
if isMaxTokensOneHaiku, ok := r.Context().Value(ctxkey.IsMaxTokensOneHaikuRequest).(bool); ok && isMaxTokensOneHaiku {
|
||||
if isMaxTokensOneHaiku, ok := IsMaxTokensOneHaikuRequestFromContext(r.Context()); ok && isMaxTokensOneHaiku {
|
||||
return true // 绕过 system prompt 检查,UA 已在 Step 1 验证
|
||||
}
|
||||
|
||||
@@ -270,3 +274,55 @@ func IsClaudeCodeClient(ctx context.Context) bool {
|
||||
func SetClaudeCodeClient(ctx context.Context, isClaudeCode bool) context.Context {
|
||||
return context.WithValue(ctx, ctxkey.IsClaudeCodeClient, isClaudeCode)
|
||||
}
|
||||
|
||||
// ExtractVersion 从 User-Agent 中提取 Claude Code 版本号
|
||||
// 返回 "2.1.22" 形式的版本号,如果不匹配返回空字符串
|
||||
func (v *ClaudeCodeValidator) ExtractVersion(ua string) string {
|
||||
matches := claudeCodeUAVersionPattern.FindStringSubmatch(ua)
|
||||
if len(matches) >= 2 {
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SetClaudeCodeVersion 将 Claude Code 版本号设置到 context 中
|
||||
func SetClaudeCodeVersion(ctx context.Context, version string) context.Context {
|
||||
return context.WithValue(ctx, ctxkey.ClaudeCodeVersion, version)
|
||||
}
|
||||
|
||||
// GetClaudeCodeVersion 从 context 中获取 Claude Code 版本号
|
||||
func GetClaudeCodeVersion(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(ctxkey.ClaudeCodeVersion).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CompareVersions 比较两个 semver 版本号
|
||||
// 返回: -1 (a < b), 0 (a == b), 1 (a > b)
|
||||
func CompareVersions(a, b string) int {
|
||||
aParts := parseSemver(a)
|
||||
bParts := parseSemver(b)
|
||||
for i := 0; i < 3; i++ {
|
||||
if aParts[i] < bParts[i] {
|
||||
return -1
|
||||
}
|
||||
if aParts[i] > bParts[i] {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseSemver 解析 semver 版本号为 [major, minor, patch]
|
||||
func parseSemver(v string) [3]int {
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
parts := strings.Split(v, ".")
|
||||
result := [3]int{0, 0, 0}
|
||||
for i := 0; i < len(parts) && i < 3; i++ {
|
||||
if parsed, err := strconv.Atoi(parts[i]); err == nil {
|
||||
result[i] = parsed
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -56,3 +56,51 @@ func TestClaudeCodeValidator_NonMessagesPathUAOnly(t *testing.T) {
|
||||
ok := validator.Validate(req, nil)
|
||||
require.True(t, ok)
|
||||
}
|
||||
|
||||
func TestExtractVersion(t *testing.T) {
|
||||
v := NewClaudeCodeValidator()
|
||||
tests := []struct {
|
||||
ua string
|
||||
want string
|
||||
}{
|
||||
{"claude-cli/2.1.22 (darwin; arm64)", "2.1.22"},
|
||||
{"claude-cli/1.0.0", "1.0.0"},
|
||||
{"Claude-CLI/3.10.5 (linux; x86_64)", "3.10.5"}, // 大小写不敏感
|
||||
{"curl/8.0.0", ""}, // 非 Claude CLI
|
||||
{"", ""}, // 空字符串
|
||||
{"claude-cli/", ""}, // 无版本号
|
||||
{"claude-cli/2.1.22-beta", "2.1.22"}, // 带后缀仍提取主版本号
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := v.ExtractVersion(tt.ua)
|
||||
require.Equal(t, tt.want, got, "ExtractVersion(%q)", tt.ua)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVersions(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"2.1.0", "2.1.0", 0}, // 相等
|
||||
{"2.1.1", "2.1.0", 1}, // patch 更大
|
||||
{"2.0.0", "2.1.0", -1}, // minor 更小
|
||||
{"3.0.0", "2.99.99", 1}, // major 更大
|
||||
{"1.0.0", "2.0.0", -1}, // major 更小
|
||||
{"0.0.1", "0.0.0", 1}, // patch 差异
|
||||
{"", "1.0.0", -1}, // 空字符串 vs 正常版本
|
||||
{"v2.1.0", "2.1.0", 0}, // v 前缀处理
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := CompareVersions(tt.a, tt.b)
|
||||
require.Equal(t, tt.want, got, "CompareVersions(%q, %q)", tt.a, tt.b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetClaudeCodeVersion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
require.Equal(t, "", GetClaudeCodeVersion(ctx), "empty context should return empty string")
|
||||
|
||||
ctx = SetClaudeCodeVersion(ctx, "2.1.63")
|
||||
require.Equal(t, "2.1.63", GetClaudeCodeVersion(ctx))
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
)
|
||||
|
||||
// ConcurrencyCache 定义并发控制的缓存接口
|
||||
@@ -17,6 +20,7 @@ type ConcurrencyCache interface {
|
||||
AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error)
|
||||
ReleaseAccountSlot(ctx context.Context, accountID int64, requestID string) error
|
||||
GetAccountConcurrency(ctx context.Context, accountID int64) (int, error)
|
||||
GetAccountConcurrencyBatch(ctx context.Context, accountIDs []int64) (map[int64]int, error)
|
||||
|
||||
// 账号等待队列(账号级)
|
||||
IncrementAccountWaitCount(ctx context.Context, accountID int64, maxWait int) (bool, error)
|
||||
@@ -41,15 +45,25 @@ type ConcurrencyCache interface {
|
||||
CleanupExpiredAccountSlots(ctx context.Context, accountID int64) error
|
||||
}
|
||||
|
||||
// generateRequestID generates a unique request ID for concurrency slot tracking
|
||||
// Uses 8 random bytes (16 hex chars) for uniqueness
|
||||
func generateRequestID() string {
|
||||
var (
|
||||
requestIDPrefix = initRequestIDPrefix()
|
||||
requestIDCounter atomic.Uint64
|
||||
)
|
||||
|
||||
func initRequestIDPrefix() string {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// Fallback to nanosecond timestamp (extremely rare case)
|
||||
return fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
if _, err := rand.Read(b); err == nil {
|
||||
return "r" + strconv.FormatUint(binary.BigEndian.Uint64(b), 36)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
fallback := uint64(time.Now().UnixNano()) ^ (uint64(os.Getpid()) << 16)
|
||||
return "r" + strconv.FormatUint(fallback, 36)
|
||||
}
|
||||
|
||||
// generateRequestID generates a unique request ID for concurrency slot tracking.
|
||||
// Format: {process_random_prefix}-{base36_counter}
|
||||
func generateRequestID() string {
|
||||
seq := requestIDCounter.Add(1)
|
||||
return requestIDPrefix + "-" + strconv.FormatUint(seq, 36)
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -124,7 +138,7 @@ func (s *ConcurrencyService) AcquireAccountSlot(ctx context.Context, accountID i
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.cache.ReleaseAccountSlot(bgCtx, accountID, requestID); err != nil {
|
||||
log.Printf("Warning: failed to release account slot for %d (req=%s): %v", accountID, requestID, err)
|
||||
logger.LegacyPrintf("service.concurrency", "Warning: failed to release account slot for %d (req=%s): %v", accountID, requestID, err)
|
||||
}
|
||||
},
|
||||
}, nil
|
||||
@@ -163,7 +177,7 @@ func (s *ConcurrencyService) AcquireUserSlot(ctx context.Context, userID int64,
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.cache.ReleaseUserSlot(bgCtx, userID, requestID); err != nil {
|
||||
log.Printf("Warning: failed to release user slot for %d (req=%s): %v", userID, requestID, err)
|
||||
logger.LegacyPrintf("service.concurrency", "Warning: failed to release user slot for %d (req=%s): %v", userID, requestID, err)
|
||||
}
|
||||
},
|
||||
}, nil
|
||||
@@ -191,7 +205,7 @@ func (s *ConcurrencyService) IncrementWaitCount(ctx context.Context, userID int6
|
||||
result, err := s.cache.IncrementWaitCount(ctx, userID, maxWait)
|
||||
if err != nil {
|
||||
// On error, allow the request to proceed (fail open)
|
||||
log.Printf("Warning: increment wait count failed for user %d: %v", userID, err)
|
||||
logger.LegacyPrintf("service.concurrency", "Warning: increment wait count failed for user %d: %v", userID, err)
|
||||
return true, nil
|
||||
}
|
||||
return result, nil
|
||||
@@ -209,7 +223,7 @@ func (s *ConcurrencyService) DecrementWaitCount(ctx context.Context, userID int6
|
||||
defer cancel()
|
||||
|
||||
if err := s.cache.DecrementWaitCount(bgCtx, userID); err != nil {
|
||||
log.Printf("Warning: decrement wait count failed for user %d: %v", userID, err)
|
||||
logger.LegacyPrintf("service.concurrency", "Warning: decrement wait count failed for user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +235,7 @@ func (s *ConcurrencyService) IncrementAccountWaitCount(ctx context.Context, acco
|
||||
|
||||
result, err := s.cache.IncrementAccountWaitCount(ctx, accountID, maxWait)
|
||||
if err != nil {
|
||||
log.Printf("Warning: increment wait count failed for account %d: %v", accountID, err)
|
||||
logger.LegacyPrintf("service.concurrency", "Warning: increment wait count failed for account %d: %v", accountID, err)
|
||||
return true, nil
|
||||
}
|
||||
return result, nil
|
||||
@@ -237,7 +251,7 @@ func (s *ConcurrencyService) DecrementAccountWaitCount(ctx context.Context, acco
|
||||
defer cancel()
|
||||
|
||||
if err := s.cache.DecrementAccountWaitCount(bgCtx, accountID); err != nil {
|
||||
log.Printf("Warning: decrement wait count failed for account %d: %v", accountID, err)
|
||||
logger.LegacyPrintf("service.concurrency", "Warning: decrement wait count failed for account %d: %v", accountID, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +307,7 @@ func (s *ConcurrencyService) StartSlotCleanupWorker(accountRepo AccountRepositor
|
||||
accounts, err := accountRepo.ListSchedulable(listCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("Warning: list schedulable accounts failed: %v", err)
|
||||
logger.LegacyPrintf("service.concurrency", "Warning: list schedulable accounts failed: %v", err)
|
||||
return
|
||||
}
|
||||
for _, account := range accounts {
|
||||
@@ -301,7 +315,7 @@ func (s *ConcurrencyService) StartSlotCleanupWorker(accountRepo AccountRepositor
|
||||
err := s.cache.CleanupExpiredAccountSlots(accountCtx, account.ID)
|
||||
accountCancel()
|
||||
if err != nil {
|
||||
log.Printf("Warning: cleanup expired slots failed for account %d: %v", account.ID, err)
|
||||
logger.LegacyPrintf("service.concurrency", "Warning: cleanup expired slots failed for account %d: %v", account.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,16 +334,15 @@ func (s *ConcurrencyService) StartSlotCleanupWorker(accountRepo AccountRepositor
|
||||
// GetAccountConcurrencyBatch gets current concurrency counts for multiple accounts
|
||||
// Returns a map of accountID -> current concurrency count
|
||||
func (s *ConcurrencyService) GetAccountConcurrencyBatch(ctx context.Context, accountIDs []int64) (map[int64]int, error) {
|
||||
result := make(map[int64]int)
|
||||
|
||||
for _, accountID := range accountIDs {
|
||||
count, err := s.cache.GetAccountConcurrency(ctx, accountID)
|
||||
if err != nil {
|
||||
// If key doesn't exist in Redis, count is 0
|
||||
count = 0
|
||||
}
|
||||
result[accountID] = count
|
||||
if len(accountIDs) == 0 {
|
||||
return map[int64]int{}, nil
|
||||
}
|
||||
|
||||
return result, nil
|
||||
if s.cache == nil {
|
||||
result := make(map[int64]int, len(accountIDs))
|
||||
for _, accountID := range accountIDs {
|
||||
result[accountID] = 0
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
return s.cache.GetAccountConcurrencyBatch(ctx, accountIDs)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// stubConcurrencyCacheForTest 用于并发服务单元测试的缓存桩
|
||||
type stubConcurrencyCacheForTest struct {
|
||||
acquireResult bool
|
||||
acquireErr error
|
||||
releaseErr error
|
||||
concurrency int
|
||||
concurrencyErr error
|
||||
waitAllowed bool
|
||||
waitErr error
|
||||
waitCount int
|
||||
waitCountErr error
|
||||
loadBatch map[int64]*AccountLoadInfo
|
||||
loadBatchErr error
|
||||
usersLoadBatch map[int64]*UserLoadInfo
|
||||
usersLoadErr error
|
||||
cleanupErr error
|
||||
|
||||
// 记录调用
|
||||
releasedAccountIDs []int64
|
||||
releasedRequestIDs []string
|
||||
}
|
||||
|
||||
var _ ConcurrencyCache = (*stubConcurrencyCacheForTest)(nil)
|
||||
|
||||
func (c *stubConcurrencyCacheForTest) AcquireAccountSlot(_ context.Context, _ int64, _ int, _ string) (bool, error) {
|
||||
return c.acquireResult, c.acquireErr
|
||||
}
|
||||
func (c *stubConcurrencyCacheForTest) ReleaseAccountSlot(_ context.Context, accountID int64, requestID string) error {
|
||||
c.releasedAccountIDs = append(c.releasedAccountIDs, accountID)
|
||||
c.releasedRequestIDs = append(c.releasedRequestIDs, requestID)
|
||||
return c.releaseErr
|
||||
}
|
||||
func (c *stubConcurrencyCacheForTest) GetAccountConcurrency(_ context.Context, _ int64) (int, error) {
|
||||
return c.concurrency, c.concurrencyErr
|
||||
}
|
||||
func (c *stubConcurrencyCacheForTest) GetAccountConcurrencyBatch(_ context.Context, accountIDs []int64) (map[int64]int, error) {
|
||||
result := make(map[int64]int, len(accountIDs))
|
||||
for _, accountID := range accountIDs {
|
||||
if c.concurrencyErr != nil {
|
||||
return nil, c.concurrencyErr
|
||||
}
|
||||
result[accountID] = c.concurrency
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (c *stubConcurrencyCacheForTest) IncrementAccountWaitCount(_ context.Context, _ int64, _ int) (bool, error) {
|
||||
return c.waitAllowed, c.waitErr
|
||||
}
|
||||
func (c *stubConcurrencyCacheForTest) DecrementAccountWaitCount(_ context.Context, _ int64) error {
|
||||
return nil
|
||||
}
|
||||
func (c *stubConcurrencyCacheForTest) GetAccountWaitingCount(_ context.Context, _ int64) (int, error) {
|
||||
return c.waitCount, c.waitCountErr
|
||||
}
|
||||
func (c *stubConcurrencyCacheForTest) AcquireUserSlot(_ context.Context, _ int64, _ int, _ string) (bool, error) {
|
||||
return c.acquireResult, c.acquireErr
|
||||
}
|
||||
func (c *stubConcurrencyCacheForTest) ReleaseUserSlot(_ context.Context, _ int64, _ string) error {
|
||||
return c.releaseErr
|
||||
}
|
||||
func (c *stubConcurrencyCacheForTest) GetUserConcurrency(_ context.Context, _ int64) (int, error) {
|
||||
return c.concurrency, c.concurrencyErr
|
||||
}
|
||||
func (c *stubConcurrencyCacheForTest) IncrementWaitCount(_ context.Context, _ int64, _ int) (bool, error) {
|
||||
return c.waitAllowed, c.waitErr
|
||||
}
|
||||
func (c *stubConcurrencyCacheForTest) DecrementWaitCount(_ context.Context, _ int64) error {
|
||||
return nil
|
||||
}
|
||||
func (c *stubConcurrencyCacheForTest) GetAccountsLoadBatch(_ context.Context, _ []AccountWithConcurrency) (map[int64]*AccountLoadInfo, error) {
|
||||
return c.loadBatch, c.loadBatchErr
|
||||
}
|
||||
func (c *stubConcurrencyCacheForTest) GetUsersLoadBatch(_ context.Context, _ []UserWithConcurrency) (map[int64]*UserLoadInfo, error) {
|
||||
return c.usersLoadBatch, c.usersLoadErr
|
||||
}
|
||||
func (c *stubConcurrencyCacheForTest) CleanupExpiredAccountSlots(_ context.Context, _ int64) error {
|
||||
return c.cleanupErr
|
||||
}
|
||||
|
||||
func TestAcquireAccountSlot_Success(t *testing.T) {
|
||||
cache := &stubConcurrencyCacheForTest{acquireResult: true}
|
||||
svc := NewConcurrencyService(cache)
|
||||
|
||||
result, err := svc.AcquireAccountSlot(context.Background(), 1, 5)
|
||||
require.NoError(t, err)
|
||||
require.True(t, result.Acquired)
|
||||
require.NotNil(t, result.ReleaseFunc)
|
||||
}
|
||||
|
||||
func TestAcquireAccountSlot_Failure(t *testing.T) {
|
||||
cache := &stubConcurrencyCacheForTest{acquireResult: false}
|
||||
svc := NewConcurrencyService(cache)
|
||||
|
||||
result, err := svc.AcquireAccountSlot(context.Background(), 1, 5)
|
||||
require.NoError(t, err)
|
||||
require.False(t, result.Acquired)
|
||||
require.Nil(t, result.ReleaseFunc)
|
||||
}
|
||||
|
||||
func TestAcquireAccountSlot_UnlimitedConcurrency(t *testing.T) {
|
||||
svc := NewConcurrencyService(&stubConcurrencyCacheForTest{})
|
||||
|
||||
for _, maxConcurrency := range []int{0, -1} {
|
||||
result, err := svc.AcquireAccountSlot(context.Background(), 1, maxConcurrency)
|
||||
require.NoError(t, err)
|
||||
require.True(t, result.Acquired, "maxConcurrency=%d 应无限制通过", maxConcurrency)
|
||||
require.NotNil(t, result.ReleaseFunc, "ReleaseFunc 应为 no-op 函数")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireAccountSlot_CacheError(t *testing.T) {
|
||||
cache := &stubConcurrencyCacheForTest{acquireErr: errors.New("redis down")}
|
||||
svc := NewConcurrencyService(cache)
|
||||
|
||||
result, err := svc.AcquireAccountSlot(context.Background(), 1, 5)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestAcquireAccountSlot_ReleaseDecrements(t *testing.T) {
|
||||
cache := &stubConcurrencyCacheForTest{acquireResult: true}
|
||||
svc := NewConcurrencyService(cache)
|
||||
|
||||
result, err := svc.AcquireAccountSlot(context.Background(), 42, 5)
|
||||
require.NoError(t, err)
|
||||
require.True(t, result.Acquired)
|
||||
|
||||
// 调用 ReleaseFunc 应释放槽位
|
||||
result.ReleaseFunc()
|
||||
|
||||
require.Len(t, cache.releasedAccountIDs, 1)
|
||||
require.Equal(t, int64(42), cache.releasedAccountIDs[0])
|
||||
require.Len(t, cache.releasedRequestIDs, 1)
|
||||
require.NotEmpty(t, cache.releasedRequestIDs[0], "requestID 不应为空")
|
||||
}
|
||||
|
||||
func TestAcquireUserSlot_IndependentFromAccount(t *testing.T) {
|
||||
cache := &stubConcurrencyCacheForTest{acquireResult: true}
|
||||
svc := NewConcurrencyService(cache)
|
||||
|
||||
// 用户槽位获取应独立于账户槽位
|
||||
result, err := svc.AcquireUserSlot(context.Background(), 100, 3)
|
||||
require.NoError(t, err)
|
||||
require.True(t, result.Acquired)
|
||||
require.NotNil(t, result.ReleaseFunc)
|
||||
}
|
||||
|
||||
func TestAcquireUserSlot_UnlimitedConcurrency(t *testing.T) {
|
||||
svc := NewConcurrencyService(&stubConcurrencyCacheForTest{})
|
||||
|
||||
result, err := svc.AcquireUserSlot(context.Background(), 1, 0)
|
||||
require.NoError(t, err)
|
||||
require.True(t, result.Acquired)
|
||||
}
|
||||
|
||||
func TestGenerateRequestID_UsesStablePrefixAndMonotonicCounter(t *testing.T) {
|
||||
id1 := generateRequestID()
|
||||
id2 := generateRequestID()
|
||||
require.NotEmpty(t, id1)
|
||||
require.NotEmpty(t, id2)
|
||||
|
||||
p1 := strings.Split(id1, "-")
|
||||
p2 := strings.Split(id2, "-")
|
||||
require.Len(t, p1, 2)
|
||||
require.Len(t, p2, 2)
|
||||
require.Equal(t, p1[0], p2[0], "同一进程前缀应保持一致")
|
||||
|
||||
n1, err := strconv.ParseUint(p1[1], 36, 64)
|
||||
require.NoError(t, err)
|
||||
n2, err := strconv.ParseUint(p2[1], 36, 64)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, n1+1, n2, "计数器应单调递增")
|
||||
}
|
||||
|
||||
func TestGetAccountsLoadBatch_ReturnsCorrectData(t *testing.T) {
|
||||
expected := map[int64]*AccountLoadInfo{
|
||||
1: {AccountID: 1, CurrentConcurrency: 3, WaitingCount: 0, LoadRate: 60},
|
||||
2: {AccountID: 2, CurrentConcurrency: 5, WaitingCount: 2, LoadRate: 100},
|
||||
}
|
||||
cache := &stubConcurrencyCacheForTest{loadBatch: expected}
|
||||
svc := NewConcurrencyService(cache)
|
||||
|
||||
accounts := []AccountWithConcurrency{
|
||||
{ID: 1, MaxConcurrency: 5},
|
||||
{ID: 2, MaxConcurrency: 5},
|
||||
}
|
||||
result, err := svc.GetAccountsLoadBatch(context.Background(), accounts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expected, result)
|
||||
}
|
||||
|
||||
func TestGetAccountsLoadBatch_NilCache(t *testing.T) {
|
||||
svc := &ConcurrencyService{cache: nil}
|
||||
|
||||
result, err := svc.GetAccountsLoadBatch(context.Background(), nil)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestIncrementWaitCount_Success(t *testing.T) {
|
||||
cache := &stubConcurrencyCacheForTest{waitAllowed: true}
|
||||
svc := NewConcurrencyService(cache)
|
||||
|
||||
allowed, err := svc.IncrementWaitCount(context.Background(), 1, 25)
|
||||
require.NoError(t, err)
|
||||
require.True(t, allowed)
|
||||
}
|
||||
|
||||
func TestIncrementWaitCount_QueueFull(t *testing.T) {
|
||||
cache := &stubConcurrencyCacheForTest{waitAllowed: false}
|
||||
svc := NewConcurrencyService(cache)
|
||||
|
||||
allowed, err := svc.IncrementWaitCount(context.Background(), 1, 25)
|
||||
require.NoError(t, err)
|
||||
require.False(t, allowed)
|
||||
}
|
||||
|
||||
func TestIncrementWaitCount_FailOpen(t *testing.T) {
|
||||
// Redis 错误时应 fail-open(允许请求通过)
|
||||
cache := &stubConcurrencyCacheForTest{waitErr: errors.New("redis timeout")}
|
||||
svc := NewConcurrencyService(cache)
|
||||
|
||||
allowed, err := svc.IncrementWaitCount(context.Background(), 1, 25)
|
||||
require.NoError(t, err, "Redis 错误不应传播")
|
||||
require.True(t, allowed, "Redis 错误时应 fail-open")
|
||||
}
|
||||
|
||||
func TestIncrementWaitCount_NilCache(t *testing.T) {
|
||||
svc := &ConcurrencyService{cache: nil}
|
||||
|
||||
allowed, err := svc.IncrementWaitCount(context.Background(), 1, 25)
|
||||
require.NoError(t, err)
|
||||
require.True(t, allowed, "nil cache 应 fail-open")
|
||||
}
|
||||
|
||||
func TestCalculateMaxWait(t *testing.T) {
|
||||
tests := []struct {
|
||||
concurrency int
|
||||
expected int
|
||||
}{
|
||||
{5, 25}, // 5 + 20
|
||||
{1, 21}, // 1 + 20
|
||||
{0, 21}, // min(1) + 20
|
||||
{-1, 21}, // min(1) + 20
|
||||
{10, 30}, // 10 + 20
|
||||
}
|
||||
for _, tt := range tests {
|
||||
result := CalculateMaxWait(tt.concurrency)
|
||||
require.Equal(t, tt.expected, result, "CalculateMaxWait(%d)", tt.concurrency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAccountWaitingCount(t *testing.T) {
|
||||
cache := &stubConcurrencyCacheForTest{waitCount: 5}
|
||||
svc := NewConcurrencyService(cache)
|
||||
|
||||
count, err := svc.GetAccountWaitingCount(context.Background(), 1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 5, count)
|
||||
}
|
||||
|
||||
func TestGetAccountWaitingCount_NilCache(t *testing.T) {
|
||||
svc := &ConcurrencyService{cache: nil}
|
||||
|
||||
count, err := svc.GetAccountWaitingCount(context.Background(), 1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, count)
|
||||
}
|
||||
|
||||
func TestGetAccountConcurrencyBatch(t *testing.T) {
|
||||
cache := &stubConcurrencyCacheForTest{concurrency: 3}
|
||||
svc := NewConcurrencyService(cache)
|
||||
|
||||
result, err := svc.GetAccountConcurrencyBatch(context.Background(), []int64{1, 2, 3})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 3)
|
||||
for _, id := range []int64{1, 2, 3} {
|
||||
require.Equal(t, 3, result[id])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrementAccountWaitCount_FailOpen(t *testing.T) {
|
||||
cache := &stubConcurrencyCacheForTest{waitErr: errors.New("redis error")}
|
||||
svc := NewConcurrencyService(cache)
|
||||
|
||||
allowed, err := svc.IncrementAccountWaitCount(context.Background(), 1, 10)
|
||||
require.NoError(t, err, "Redis 错误不应传播")
|
||||
require.True(t, allowed, "Redis 错误时应 fail-open")
|
||||
}
|
||||
|
||||
func TestIncrementAccountWaitCount_NilCache(t *testing.T) {
|
||||
svc := &ConcurrencyService{cache: nil}
|
||||
|
||||
allowed, err := svc.IncrementAccountWaitCount(context.Background(), 1, 10)
|
||||
require.NoError(t, err)
|
||||
require.True(t, allowed)
|
||||
}
|
||||
@@ -221,7 +221,7 @@ func (s *CRSSyncService) fetchCRSExport(ctx context.Context, baseURL, username,
|
||||
AllowPrivateHosts: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
|
||||
})
|
||||
if err != nil {
|
||||
client = &http.Client{Timeout: 20 * time.Second}
|
||||
return nil, fmt.Errorf("create http client failed: %w", err)
|
||||
}
|
||||
|
||||
adminToken, err := crsLogin(ctx, client, normalizedURL, username, password)
|
||||
|
||||
@@ -3,11 +3,12 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -65,7 +66,7 @@ func (s *DashboardAggregationService) Start() {
|
||||
return
|
||||
}
|
||||
if !s.cfg.Enabled {
|
||||
log.Printf("[DashboardAggregation] 聚合作业已禁用")
|
||||
logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 聚合作业已禁用")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -81,9 +82,9 @@ func (s *DashboardAggregationService) Start() {
|
||||
s.timingWheel.ScheduleRecurring("dashboard:aggregation", interval, func() {
|
||||
s.runScheduledAggregation()
|
||||
})
|
||||
log.Printf("[DashboardAggregation] 聚合作业启动 (interval=%v, lookback=%ds)", interval, s.cfg.LookbackSeconds)
|
||||
logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 聚合作业启动 (interval=%v, lookback=%ds)", interval, s.cfg.LookbackSeconds)
|
||||
if !s.cfg.BackfillEnabled {
|
||||
log.Printf("[DashboardAggregation] 回填已禁用,如需补齐保留窗口以外历史数据请手动回填")
|
||||
logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 回填已禁用,如需补齐保留窗口以外历史数据请手动回填")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +94,7 @@ func (s *DashboardAggregationService) TriggerBackfill(start, end time.Time) erro
|
||||
return errors.New("聚合服务未初始化")
|
||||
}
|
||||
if !s.cfg.BackfillEnabled {
|
||||
log.Printf("[DashboardAggregation] 回填被拒绝: backfill_enabled=false")
|
||||
logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 回填被拒绝: backfill_enabled=false")
|
||||
return ErrDashboardBackfillDisabled
|
||||
}
|
||||
if !end.After(start) {
|
||||
@@ -110,7 +111,7 @@ func (s *DashboardAggregationService) TriggerBackfill(start, end time.Time) erro
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultDashboardAggregationBackfillTimeout)
|
||||
defer cancel()
|
||||
if err := s.backfillRange(ctx, start, end); err != nil {
|
||||
log.Printf("[DashboardAggregation] 回填失败: %v", err)
|
||||
logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 回填失败: %v", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
@@ -141,12 +142,12 @@ func (s *DashboardAggregationService) TriggerRecomputeRange(start, end time.Time
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, errDashboardAggregationRunning) {
|
||||
log.Printf("[DashboardAggregation] 重新计算失败: %v", err)
|
||||
logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 重新计算失败: %v", err)
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
log.Printf("[DashboardAggregation] 重新计算放弃: 聚合作业持续占用")
|
||||
logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 重新计算放弃: 聚合作业持续占用")
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
@@ -162,7 +163,7 @@ func (s *DashboardAggregationService) recomputeRecentDays() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultDashboardAggregationBackfillTimeout)
|
||||
defer cancel()
|
||||
if err := s.backfillRange(ctx, start, now); err != nil {
|
||||
log.Printf("[DashboardAggregation] 启动重算失败: %v", err)
|
||||
logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 启动重算失败: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -177,7 +178,7 @@ func (s *DashboardAggregationService) recomputeRange(ctx context.Context, start,
|
||||
if err := s.repo.RecomputeRange(ctx, start, end); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("[DashboardAggregation] 重新计算完成 (start=%s end=%s duration=%s)",
|
||||
logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 重新计算完成 (start=%s end=%s duration=%s)",
|
||||
start.UTC().Format(time.RFC3339),
|
||||
end.UTC().Format(time.RFC3339),
|
||||
time.Since(jobStart).String(),
|
||||
@@ -198,7 +199,7 @@ func (s *DashboardAggregationService) runScheduledAggregation() {
|
||||
now := time.Now().UTC()
|
||||
last, err := s.repo.GetAggregationWatermark(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[DashboardAggregation] 读取水位失败: %v", err)
|
||||
logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 读取水位失败: %v", err)
|
||||
last = time.Unix(0, 0).UTC()
|
||||
}
|
||||
|
||||
@@ -216,19 +217,19 @@ func (s *DashboardAggregationService) runScheduledAggregation() {
|
||||
}
|
||||
|
||||
if err := s.aggregateRange(ctx, start, now); err != nil {
|
||||
log.Printf("[DashboardAggregation] 聚合失败: %v", err)
|
||||
logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 聚合失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
updateErr := s.repo.UpdateAggregationWatermark(ctx, now)
|
||||
if updateErr != nil {
|
||||
log.Printf("[DashboardAggregation] 更新水位失败: %v", updateErr)
|
||||
logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 更新水位失败: %v", updateErr)
|
||||
}
|
||||
log.Printf("[DashboardAggregation] 聚合完成 (start=%s end=%s duration=%s watermark_updated=%t)",
|
||||
start.Format(time.RFC3339),
|
||||
now.Format(time.RFC3339),
|
||||
time.Since(jobStart).String(),
|
||||
updateErr == nil,
|
||||
slog.Debug("[DashboardAggregation] 聚合完成",
|
||||
"start", start.Format(time.RFC3339),
|
||||
"end", now.Format(time.RFC3339),
|
||||
"duration", time.Since(jobStart).String(),
|
||||
"watermark_updated", updateErr == nil,
|
||||
)
|
||||
|
||||
s.maybeCleanupRetention(ctx, now)
|
||||
@@ -261,9 +262,9 @@ func (s *DashboardAggregationService) backfillRange(ctx context.Context, start,
|
||||
|
||||
updateErr := s.repo.UpdateAggregationWatermark(ctx, endUTC)
|
||||
if updateErr != nil {
|
||||
log.Printf("[DashboardAggregation] 更新水位失败: %v", updateErr)
|
||||
logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 更新水位失败: %v", updateErr)
|
||||
}
|
||||
log.Printf("[DashboardAggregation] 回填聚合完成 (start=%s end=%s duration=%s watermark_updated=%t)",
|
||||
logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 回填聚合完成 (start=%s end=%s duration=%s watermark_updated=%t)",
|
||||
startUTC.Format(time.RFC3339),
|
||||
endUTC.Format(time.RFC3339),
|
||||
time.Since(jobStart).String(),
|
||||
@@ -279,7 +280,7 @@ func (s *DashboardAggregationService) aggregateRange(ctx context.Context, start,
|
||||
return nil
|
||||
}
|
||||
if err := s.repo.EnsureUsageLogsPartitions(ctx, end); err != nil {
|
||||
log.Printf("[DashboardAggregation] 分区检查失败: %v", err)
|
||||
logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 分区检查失败: %v", err)
|
||||
}
|
||||
return s.repo.AggregateRange(ctx, start, end)
|
||||
}
|
||||
@@ -298,11 +299,11 @@ func (s *DashboardAggregationService) maybeCleanupRetention(ctx context.Context,
|
||||
|
||||
aggErr := s.repo.CleanupAggregates(ctx, hourlyCutoff, dailyCutoff)
|
||||
if aggErr != nil {
|
||||
log.Printf("[DashboardAggregation] 聚合保留清理失败: %v", aggErr)
|
||||
logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 聚合保留清理失败: %v", aggErr)
|
||||
}
|
||||
usageErr := s.repo.CleanupUsageLogs(ctx, usageCutoff)
|
||||
if usageErr != nil {
|
||||
log.Printf("[DashboardAggregation] usage_logs 保留清理失败: %v", usageErr)
|
||||
logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] usage_logs 保留清理失败: %v", usageErr)
|
||||
}
|
||||
if aggErr == nil && usageErr == nil {
|
||||
s.lastRetentionCleanup.Store(now)
|
||||
|
||||
@@ -5,11 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||
)
|
||||
|
||||
@@ -113,7 +113,7 @@ func (s *DashboardService) GetDashboardStats(ctx context.Context) (*usagestats.D
|
||||
return cached, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, ErrDashboardStatsCacheMiss) {
|
||||
log.Printf("[Dashboard] 仪表盘缓存读取失败: %v", err)
|
||||
logger.LegacyPrintf("service.dashboard", "[Dashboard] 仪表盘缓存读取失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,22 +124,30 @@ func (s *DashboardService) GetDashboardStats(ctx context.Context) (*usagestats.D
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *DashboardService) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, stream *bool, billingType *int8) ([]usagestats.TrendDataPoint, error) {
|
||||
trend, err := s.usageRepo.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, stream, billingType)
|
||||
func (s *DashboardService) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]usagestats.TrendDataPoint, error) {
|
||||
trend, err := s.usageRepo.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get usage trend with filters: %w", err)
|
||||
}
|
||||
return trend, nil
|
||||
}
|
||||
|
||||
func (s *DashboardService) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, stream *bool, billingType *int8) ([]usagestats.ModelStat, error) {
|
||||
stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, stream, billingType)
|
||||
func (s *DashboardService) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.ModelStat, error) {
|
||||
stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get model stats with filters: %w", err)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *DashboardService) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error) {
|
||||
stats, err := s.usageRepo.GetGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get group stats with filters: %w", err)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *DashboardService) getCachedDashboardStats(ctx context.Context) (*usagestats.DashboardStats, bool, error) {
|
||||
data, err := s.cache.GetDashboardStats(ctx)
|
||||
if err != nil {
|
||||
@@ -188,7 +196,7 @@ func (s *DashboardService) refreshDashboardStatsAsync() {
|
||||
|
||||
stats, err := s.fetchDashboardStats(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[Dashboard] 仪表盘缓存异步刷新失败: %v", err)
|
||||
logger.LegacyPrintf("service.dashboard", "[Dashboard] 仪表盘缓存异步刷新失败: %v", err)
|
||||
return
|
||||
}
|
||||
s.applyAggregationStatus(ctx, stats)
|
||||
@@ -220,12 +228,12 @@ func (s *DashboardService) saveDashboardStatsCache(ctx context.Context, stats *u
|
||||
}
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
log.Printf("[Dashboard] 仪表盘缓存序列化失败: %v", err)
|
||||
logger.LegacyPrintf("service.dashboard", "[Dashboard] 仪表盘缓存序列化失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.cache.SetDashboardStats(ctx, string(data), s.cacheTTL); err != nil {
|
||||
log.Printf("[Dashboard] 仪表盘缓存写入失败: %v", err)
|
||||
logger.LegacyPrintf("service.dashboard", "[Dashboard] 仪表盘缓存写入失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,10 +245,10 @@ func (s *DashboardService) evictDashboardStatsCache(reason error) {
|
||||
defer cancel()
|
||||
|
||||
if err := s.cache.DeleteDashboardStats(cacheCtx); err != nil {
|
||||
log.Printf("[Dashboard] 仪表盘缓存清理失败: %v", err)
|
||||
logger.LegacyPrintf("service.dashboard", "[Dashboard] 仪表盘缓存清理失败: %v", err)
|
||||
}
|
||||
if reason != nil {
|
||||
log.Printf("[Dashboard] 仪表盘缓存异常,已清理: %v", reason)
|
||||
logger.LegacyPrintf("service.dashboard", "[Dashboard] 仪表盘缓存异常,已清理: %v", reason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +279,7 @@ func (s *DashboardService) fetchAggregationUpdatedAt(ctx context.Context) time.T
|
||||
}
|
||||
updatedAt, err := s.aggRepo.GetAggregationWatermark(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[Dashboard] 读取聚合水位失败: %v", err)
|
||||
logger.LegacyPrintf("service.dashboard", "[Dashboard] 读取聚合水位失败: %v", err)
|
||||
return time.Unix(0, 0).UTC()
|
||||
}
|
||||
if updatedAt.IsZero() {
|
||||
@@ -319,16 +327,16 @@ func (s *DashboardService) GetUserUsageTrend(ctx context.Context, startTime, end
|
||||
return trend, nil
|
||||
}
|
||||
|
||||
func (s *DashboardService) GetBatchUserUsageStats(ctx context.Context, userIDs []int64) (map[int64]*usagestats.BatchUserUsageStats, error) {
|
||||
stats, err := s.usageRepo.GetBatchUserUsageStats(ctx, userIDs)
|
||||
func (s *DashboardService) GetBatchUserUsageStats(ctx context.Context, userIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchUserUsageStats, error) {
|
||||
stats, err := s.usageRepo.GetBatchUserUsageStats(ctx, userIDs, startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get batch user usage stats: %w", err)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *DashboardService) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*usagestats.BatchAPIKeyUsageStats, error) {
|
||||
stats, err := s.usageRepo.GetBatchAPIKeyUsageStats(ctx, apiKeyIDs)
|
||||
func (s *DashboardService) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchAPIKeyUsageStats, error) {
|
||||
stats, err := s.usageRepo.GetBatchAPIKeyUsageStats(ctx, apiKeyIDs, startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get batch api key usage stats: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package service
|
||||
|
||||
import "context"
|
||||
|
||||
type DataManagementPostgresConfig struct {
|
||||
Host string `json:"host"`
|
||||
Port int32 `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password,omitempty"`
|
||||
PasswordConfigured bool `json:"password_configured"`
|
||||
Database string `json:"database"`
|
||||
SSLMode string `json:"ssl_mode"`
|
||||
ContainerName string `json:"container_name"`
|
||||
}
|
||||
|
||||
type DataManagementRedisConfig struct {
|
||||
Addr string `json:"addr"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
PasswordConfigured bool `json:"password_configured"`
|
||||
DB int32 `json:"db"`
|
||||
ContainerName string `json:"container_name"`
|
||||
}
|
||||
|
||||
type DataManagementS3Config struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Region string `json:"region"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccessKeyID string `json:"access_key_id"`
|
||||
SecretAccessKey string `json:"secret_access_key,omitempty"`
|
||||
SecretAccessKeyConfigured bool `json:"secret_access_key_configured"`
|
||||
Prefix string `json:"prefix"`
|
||||
ForcePathStyle bool `json:"force_path_style"`
|
||||
UseSSL bool `json:"use_ssl"`
|
||||
}
|
||||
|
||||
type DataManagementConfig struct {
|
||||
SourceMode string `json:"source_mode"`
|
||||
BackupRoot string `json:"backup_root"`
|
||||
SQLitePath string `json:"sqlite_path,omitempty"`
|
||||
RetentionDays int32 `json:"retention_days"`
|
||||
KeepLast int32 `json:"keep_last"`
|
||||
ActivePostgresID string `json:"active_postgres_profile_id"`
|
||||
ActiveRedisID string `json:"active_redis_profile_id"`
|
||||
Postgres DataManagementPostgresConfig `json:"postgres"`
|
||||
Redis DataManagementRedisConfig `json:"redis"`
|
||||
S3 DataManagementS3Config `json:"s3"`
|
||||
ActiveS3ProfileID string `json:"active_s3_profile_id"`
|
||||
}
|
||||
|
||||
type DataManagementTestS3Result struct {
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type DataManagementCreateBackupJobInput struct {
|
||||
BackupType string
|
||||
UploadToS3 bool
|
||||
TriggeredBy string
|
||||
IdempotencyKey string
|
||||
S3ProfileID string
|
||||
PostgresID string
|
||||
RedisID string
|
||||
}
|
||||
|
||||
type DataManagementListBackupJobsInput struct {
|
||||
PageSize int32
|
||||
PageToken string
|
||||
Status string
|
||||
BackupType string
|
||||
}
|
||||
|
||||
type DataManagementArtifactInfo struct {
|
||||
LocalPath string `json:"local_path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
type DataManagementS3ObjectInfo struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Key string `json:"key"`
|
||||
ETag string `json:"etag"`
|
||||
}
|
||||
|
||||
type DataManagementBackupJob struct {
|
||||
JobID string `json:"job_id"`
|
||||
BackupType string `json:"backup_type"`
|
||||
Status string `json:"status"`
|
||||
TriggeredBy string `json:"triggered_by"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
UploadToS3 bool `json:"upload_to_s3"`
|
||||
S3ProfileID string `json:"s3_profile_id,omitempty"`
|
||||
PostgresID string `json:"postgres_profile_id,omitempty"`
|
||||
RedisID string `json:"redis_profile_id,omitempty"`
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
FinishedAt string `json:"finished_at,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
Artifact DataManagementArtifactInfo `json:"artifact"`
|
||||
S3Object DataManagementS3ObjectInfo `json:"s3"`
|
||||
}
|
||||
|
||||
type DataManagementSourceProfile struct {
|
||||
SourceType string `json:"source_type"`
|
||||
ProfileID string `json:"profile_id"`
|
||||
Name string `json:"name"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Config DataManagementSourceConfig `json:"config"`
|
||||
PasswordConfigured bool `json:"password_configured"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type DataManagementSourceConfig struct {
|
||||
Host string `json:"host"`
|
||||
Port int32 `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Database string `json:"database"`
|
||||
SSLMode string `json:"ssl_mode"`
|
||||
Addr string `json:"addr"`
|
||||
Username string `json:"username"`
|
||||
DB int32 `json:"db"`
|
||||
ContainerName string `json:"container_name"`
|
||||
}
|
||||
|
||||
type DataManagementCreateSourceProfileInput struct {
|
||||
SourceType string
|
||||
ProfileID string
|
||||
Name string
|
||||
Config DataManagementSourceConfig
|
||||
SetActive bool
|
||||
}
|
||||
|
||||
type DataManagementUpdateSourceProfileInput struct {
|
||||
SourceType string
|
||||
ProfileID string
|
||||
Name string
|
||||
Config DataManagementSourceConfig
|
||||
}
|
||||
|
||||
type DataManagementS3Profile struct {
|
||||
ProfileID string `json:"profile_id"`
|
||||
Name string `json:"name"`
|
||||
IsActive bool `json:"is_active"`
|
||||
S3 DataManagementS3Config `json:"s3"`
|
||||
SecretAccessKeyConfigured bool `json:"secret_access_key_configured"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type DataManagementCreateS3ProfileInput struct {
|
||||
ProfileID string
|
||||
Name string
|
||||
S3 DataManagementS3Config
|
||||
SetActive bool
|
||||
}
|
||||
|
||||
type DataManagementUpdateS3ProfileInput struct {
|
||||
ProfileID string
|
||||
Name string
|
||||
S3 DataManagementS3Config
|
||||
}
|
||||
|
||||
type DataManagementListBackupJobsResult struct {
|
||||
Items []DataManagementBackupJob `json:"items"`
|
||||
NextPageToken string `json:"next_page_token,omitempty"`
|
||||
}
|
||||
|
||||
func (s *DataManagementService) GetConfig(ctx context.Context) (DataManagementConfig, error) {
|
||||
_ = ctx
|
||||
return DataManagementConfig{}, s.deprecatedError()
|
||||
}
|
||||
|
||||
func (s *DataManagementService) UpdateConfig(ctx context.Context, cfg DataManagementConfig) (DataManagementConfig, error) {
|
||||
_, _ = ctx, cfg
|
||||
return DataManagementConfig{}, s.deprecatedError()
|
||||
}
|
||||
|
||||
func (s *DataManagementService) ListSourceProfiles(ctx context.Context, sourceType string) ([]DataManagementSourceProfile, error) {
|
||||
_, _ = ctx, sourceType
|
||||
return nil, s.deprecatedError()
|
||||
}
|
||||
|
||||
func (s *DataManagementService) CreateSourceProfile(ctx context.Context, input DataManagementCreateSourceProfileInput) (DataManagementSourceProfile, error) {
|
||||
_, _ = ctx, input
|
||||
return DataManagementSourceProfile{}, s.deprecatedError()
|
||||
}
|
||||
|
||||
func (s *DataManagementService) UpdateSourceProfile(ctx context.Context, input DataManagementUpdateSourceProfileInput) (DataManagementSourceProfile, error) {
|
||||
_, _ = ctx, input
|
||||
return DataManagementSourceProfile{}, s.deprecatedError()
|
||||
}
|
||||
|
||||
func (s *DataManagementService) DeleteSourceProfile(ctx context.Context, sourceType, profileID string) error {
|
||||
_, _, _ = ctx, sourceType, profileID
|
||||
return s.deprecatedError()
|
||||
}
|
||||
|
||||
func (s *DataManagementService) SetActiveSourceProfile(ctx context.Context, sourceType, profileID string) (DataManagementSourceProfile, error) {
|
||||
_, _, _ = ctx, sourceType, profileID
|
||||
return DataManagementSourceProfile{}, s.deprecatedError()
|
||||
}
|
||||
|
||||
func (s *DataManagementService) ValidateS3(ctx context.Context, cfg DataManagementS3Config) (DataManagementTestS3Result, error) {
|
||||
_, _ = ctx, cfg
|
||||
return DataManagementTestS3Result{}, s.deprecatedError()
|
||||
}
|
||||
|
||||
func (s *DataManagementService) ListS3Profiles(ctx context.Context) ([]DataManagementS3Profile, error) {
|
||||
_ = ctx
|
||||
return nil, s.deprecatedError()
|
||||
}
|
||||
|
||||
func (s *DataManagementService) CreateS3Profile(ctx context.Context, input DataManagementCreateS3ProfileInput) (DataManagementS3Profile, error) {
|
||||
_, _ = ctx, input
|
||||
return DataManagementS3Profile{}, s.deprecatedError()
|
||||
}
|
||||
|
||||
func (s *DataManagementService) UpdateS3Profile(ctx context.Context, input DataManagementUpdateS3ProfileInput) (DataManagementS3Profile, error) {
|
||||
_, _ = ctx, input
|
||||
return DataManagementS3Profile{}, s.deprecatedError()
|
||||
}
|
||||
|
||||
func (s *DataManagementService) DeleteS3Profile(ctx context.Context, profileID string) error {
|
||||
_, _ = ctx, profileID
|
||||
return s.deprecatedError()
|
||||
}
|
||||
|
||||
func (s *DataManagementService) SetActiveS3Profile(ctx context.Context, profileID string) (DataManagementS3Profile, error) {
|
||||
_, _ = ctx, profileID
|
||||
return DataManagementS3Profile{}, s.deprecatedError()
|
||||
}
|
||||
|
||||
func (s *DataManagementService) CreateBackupJob(ctx context.Context, input DataManagementCreateBackupJobInput) (DataManagementBackupJob, error) {
|
||||
_, _ = ctx, input
|
||||
return DataManagementBackupJob{}, s.deprecatedError()
|
||||
}
|
||||
|
||||
func (s *DataManagementService) ListBackupJobs(ctx context.Context, input DataManagementListBackupJobsInput) (DataManagementListBackupJobsResult, error) {
|
||||
_, _ = ctx, input
|
||||
return DataManagementListBackupJobsResult{}, s.deprecatedError()
|
||||
}
|
||||
|
||||
func (s *DataManagementService) GetBackupJob(ctx context.Context, jobID string) (DataManagementBackupJob, error) {
|
||||
_, _ = ctx, jobID
|
||||
return DataManagementBackupJob{}, s.deprecatedError()
|
||||
}
|
||||
|
||||
func (s *DataManagementService) deprecatedError() error {
|
||||
return ErrDataManagementDeprecated.WithMetadata(map[string]string{"socket_path": s.SocketPath()})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDataManagementService_DeprecatedRPCMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(t.TempDir(), "datamanagement.sock")
|
||||
svc := NewDataManagementServiceWithOptions(socketPath, 0)
|
||||
|
||||
_, err := svc.GetConfig(context.Background())
|
||||
assertDeprecatedDataManagementError(t, err, socketPath)
|
||||
|
||||
_, err = svc.CreateBackupJob(context.Background(), DataManagementCreateBackupJobInput{BackupType: "full"})
|
||||
assertDeprecatedDataManagementError(t, err, socketPath)
|
||||
|
||||
err = svc.DeleteS3Profile(context.Background(), "s3-default")
|
||||
assertDeprecatedDataManagementError(t, err, socketPath)
|
||||
}
|
||||
|
||||
func assertDeprecatedDataManagementError(t *testing.T, err error, socketPath string) {
|
||||
t.Helper()
|
||||
|
||||
require.Error(t, err)
|
||||
statusCode, status := infraerrors.ToHTTP(err)
|
||||
require.Equal(t, 503, statusCode)
|
||||
require.Equal(t, DataManagementDeprecatedReason, status.Reason)
|
||||
require.Equal(t, socketPath, status.Metadata["socket_path"])
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultDataManagementAgentSocketPath = "/tmp/sub2api-datamanagement.sock"
|
||||
LegacyBackupAgentSocketPath = "/tmp/sub2api-backup.sock"
|
||||
|
||||
DataManagementDeprecatedReason = "DATA_MANAGEMENT_DEPRECATED"
|
||||
DataManagementAgentSocketMissingReason = "DATA_MANAGEMENT_AGENT_SOCKET_MISSING"
|
||||
DataManagementAgentUnavailableReason = "DATA_MANAGEMENT_AGENT_UNAVAILABLE"
|
||||
|
||||
// Deprecated: keep old names for compatibility.
|
||||
DefaultBackupAgentSocketPath = DefaultDataManagementAgentSocketPath
|
||||
BackupAgentSocketMissingReason = DataManagementAgentSocketMissingReason
|
||||
BackupAgentUnavailableReason = DataManagementAgentUnavailableReason
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDataManagementDeprecated = infraerrors.ServiceUnavailable(
|
||||
DataManagementDeprecatedReason,
|
||||
"data management feature is deprecated",
|
||||
)
|
||||
ErrDataManagementAgentSocketMissing = infraerrors.ServiceUnavailable(
|
||||
DataManagementAgentSocketMissingReason,
|
||||
"data management agent socket is missing",
|
||||
)
|
||||
ErrDataManagementAgentUnavailable = infraerrors.ServiceUnavailable(
|
||||
DataManagementAgentUnavailableReason,
|
||||
"data management agent is unavailable",
|
||||
)
|
||||
|
||||
// Deprecated: keep old names for compatibility.
|
||||
ErrBackupAgentSocketMissing = ErrDataManagementAgentSocketMissing
|
||||
ErrBackupAgentUnavailable = ErrDataManagementAgentUnavailable
|
||||
)
|
||||
|
||||
type DataManagementAgentHealth struct {
|
||||
Enabled bool
|
||||
Reason string
|
||||
SocketPath string
|
||||
Agent *DataManagementAgentInfo
|
||||
}
|
||||
|
||||
type DataManagementAgentInfo struct {
|
||||
Status string
|
||||
Version string
|
||||
UptimeSeconds int64
|
||||
}
|
||||
|
||||
type DataManagementService struct {
|
||||
socketPath string
|
||||
}
|
||||
|
||||
func NewDataManagementService() *DataManagementService {
|
||||
return NewDataManagementServiceWithOptions(DefaultDataManagementAgentSocketPath, 500*time.Millisecond)
|
||||
}
|
||||
|
||||
func NewDataManagementServiceWithOptions(socketPath string, dialTimeout time.Duration) *DataManagementService {
|
||||
_ = dialTimeout
|
||||
path := strings.TrimSpace(socketPath)
|
||||
if path == "" {
|
||||
path = DefaultDataManagementAgentSocketPath
|
||||
}
|
||||
return &DataManagementService{
|
||||
socketPath: path,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DataManagementService) SocketPath() string {
|
||||
if s == nil || strings.TrimSpace(s.socketPath) == "" {
|
||||
return DefaultDataManagementAgentSocketPath
|
||||
}
|
||||
return s.socketPath
|
||||
}
|
||||
|
||||
func (s *DataManagementService) GetAgentHealth(ctx context.Context) DataManagementAgentHealth {
|
||||
_ = ctx
|
||||
return DataManagementAgentHealth{
|
||||
Enabled: false,
|
||||
Reason: DataManagementDeprecatedReason,
|
||||
SocketPath: s.SocketPath(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DataManagementService) EnsureAgentEnabled(ctx context.Context) error {
|
||||
_ = ctx
|
||||
return ErrDataManagementDeprecated.WithMetadata(map[string]string{"socket_path": s.SocketPath()})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDataManagementService_GetAgentHealth_Deprecated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(t.TempDir(), "unused.sock")
|
||||
svc := NewDataManagementServiceWithOptions(socketPath, 0)
|
||||
health := svc.GetAgentHealth(context.Background())
|
||||
|
||||
require.False(t, health.Enabled)
|
||||
require.Equal(t, DataManagementDeprecatedReason, health.Reason)
|
||||
require.Equal(t, socketPath, health.SocketPath)
|
||||
require.Nil(t, health.Agent)
|
||||
}
|
||||
|
||||
func TestDataManagementService_EnsureAgentEnabled_Deprecated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(t.TempDir(), "unused.sock")
|
||||
svc := NewDataManagementServiceWithOptions(socketPath, 100)
|
||||
err := svc.EnsureAgentEnabled(context.Background())
|
||||
require.Error(t, err)
|
||||
|
||||
statusCode, status := infraerrors.ToHTTP(err)
|
||||
require.Equal(t, 503, statusCode)
|
||||
require.Equal(t, DataManagementDeprecatedReason, status.Reason)
|
||||
require.Equal(t, socketPath, status.Metadata["socket_path"])
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
PlatformOpenAI = domain.PlatformOpenAI
|
||||
PlatformGemini = domain.PlatformGemini
|
||||
PlatformAntigravity = domain.PlatformAntigravity
|
||||
PlatformSora = domain.PlatformSora
|
||||
)
|
||||
|
||||
// Account type constants
|
||||
@@ -73,11 +74,12 @@ const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid"
|
||||
// Setting keys
|
||||
const (
|
||||
// 注册设置
|
||||
SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册
|
||||
SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证
|
||||
SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能
|
||||
SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
|
||||
SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
|
||||
SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册
|
||||
SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证
|
||||
SettingKeyRegistrationEmailSuffixWhitelist = "registration_email_suffix_whitelist" // 注册邮箱后缀白名单(JSON 数组)
|
||||
SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能
|
||||
SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
|
||||
SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
|
||||
|
||||
// 邮件服务设置
|
||||
SettingKeySMTPHost = "smtp_host" // SMTP服务器地址
|
||||
@@ -103,6 +105,7 @@ const (
|
||||
SettingKeyLinuxDoConnectRedirectURL = "linuxdo_connect_redirect_url"
|
||||
|
||||
// OEM设置
|
||||
SettingKeySoraClientEnabled = "sora_client_enabled" // 是否启用 Sora 客户端(管理员手动控制)
|
||||
SettingKeySiteName = "site_name" // 网站名称
|
||||
SettingKeySiteLogo = "site_logo" // 网站Logo (base64)
|
||||
SettingKeySiteSubtitle = "site_subtitle" // 网站副标题
|
||||
@@ -111,12 +114,14 @@ const (
|
||||
SettingKeyDocURL = "doc_url" // 文档链接
|
||||
SettingKeyHomeContent = "home_content" // 首页内容(支持 Markdown/HTML,或 URL 作为 iframe src)
|
||||
SettingKeyHideCcsImportButton = "hide_ccs_import_button" // 是否隐藏 API Keys 页面的导入 CCS 按钮
|
||||
SettingKeyPurchaseSubscriptionEnabled = "purchase_subscription_enabled" // 是否展示“购买订阅”页面入口
|
||||
SettingKeyPurchaseSubscriptionURL = "purchase_subscription_url" // “购买订阅”页面 URL(作为 iframe src)
|
||||
SettingKeyPurchaseSubscriptionEnabled = "purchase_subscription_enabled" // 是否展示"购买订阅"页面入口
|
||||
SettingKeyPurchaseSubscriptionURL = "purchase_subscription_url" // "购买订阅"页面 URL(作为 iframe src)
|
||||
SettingKeyCustomMenuItems = "custom_menu_items" // 自定义菜单项(JSON 数组)
|
||||
|
||||
// 默认配置
|
||||
SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量
|
||||
SettingKeyDefaultBalance = "default_balance" // 新用户默认余额
|
||||
SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量
|
||||
SettingKeyDefaultBalance = "default_balance" // 新用户默认余额
|
||||
SettingKeyDefaultSubscriptions = "default_subscriptions" // 新用户默认订阅列表(JSON)
|
||||
|
||||
// 管理员 API Key
|
||||
SettingKeyAdminAPIKey = "admin_api_key" // 全局管理员 API Key(用于外部系统集成)
|
||||
@@ -160,12 +165,46 @@ const (
|
||||
// SettingKeyOpsAdvancedSettings stores JSON config for ops advanced settings (data retention, aggregation).
|
||||
SettingKeyOpsAdvancedSettings = "ops_advanced_settings"
|
||||
|
||||
// SettingKeyOpsRuntimeLogConfig stores JSON config for runtime log settings.
|
||||
SettingKeyOpsRuntimeLogConfig = "ops_runtime_log_config"
|
||||
|
||||
// =========================
|
||||
// Stream Timeout Handling
|
||||
// =========================
|
||||
|
||||
// SettingKeyStreamTimeoutSettings stores JSON config for stream timeout handling.
|
||||
SettingKeyStreamTimeoutSettings = "stream_timeout_settings"
|
||||
|
||||
// =========================
|
||||
// Sora S3 存储配置
|
||||
// =========================
|
||||
|
||||
SettingKeySoraS3Enabled = "sora_s3_enabled" // 是否启用 Sora S3 存储
|
||||
SettingKeySoraS3Endpoint = "sora_s3_endpoint" // S3 端点地址
|
||||
SettingKeySoraS3Region = "sora_s3_region" // S3 区域
|
||||
SettingKeySoraS3Bucket = "sora_s3_bucket" // S3 存储桶名称
|
||||
SettingKeySoraS3AccessKeyID = "sora_s3_access_key_id" // S3 Access Key ID
|
||||
SettingKeySoraS3SecretAccessKey = "sora_s3_secret_access_key" // S3 Secret Access Key(加密存储)
|
||||
SettingKeySoraS3Prefix = "sora_s3_prefix" // S3 对象键前缀
|
||||
SettingKeySoraS3ForcePathStyle = "sora_s3_force_path_style" // 是否强制 Path Style(兼容 MinIO 等)
|
||||
SettingKeySoraS3CDNURL = "sora_s3_cdn_url" // CDN 加速 URL(可选)
|
||||
SettingKeySoraS3Profiles = "sora_s3_profiles" // Sora S3 多配置(JSON)
|
||||
|
||||
// =========================
|
||||
// Sora 用户存储配额
|
||||
// =========================
|
||||
|
||||
SettingKeySoraDefaultStorageQuotaBytes = "sora_default_storage_quota_bytes" // 新用户默认 Sora 存储配额(字节)
|
||||
|
||||
// =========================
|
||||
// Claude Code Version Check
|
||||
// =========================
|
||||
|
||||
// SettingKeyMinClaudeCodeVersion 最低 Claude Code 版本号要求 (semver, 如 "2.1.0",空值=不检查)
|
||||
SettingKeyMinClaudeCodeVersion = "min_claude_code_version"
|
||||
|
||||
// SettingKeyAllowUngroupedKeyScheduling 允许未分组 API Key 调度(默认 false:未分组 Key 返回 403)
|
||||
SettingKeyAllowUngroupedKeyScheduling = "allow_ungrouped_key_scheduling"
|
||||
)
|
||||
|
||||
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).
|
||||
|
||||
@@ -3,9 +3,10 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
)
|
||||
|
||||
// Task type constants
|
||||
@@ -56,7 +57,7 @@ func (s *EmailQueueService) start() {
|
||||
s.wg.Add(1)
|
||||
go s.worker(i)
|
||||
}
|
||||
log.Printf("[EmailQueue] Started %d workers", s.workers)
|
||||
logger.LegacyPrintf("service.email_queue", "[EmailQueue] Started %d workers", s.workers)
|
||||
}
|
||||
|
||||
// worker 工作协程
|
||||
@@ -68,7 +69,7 @@ func (s *EmailQueueService) worker(id int) {
|
||||
case task := <-s.taskChan:
|
||||
s.processTask(id, task)
|
||||
case <-s.stopChan:
|
||||
log.Printf("[EmailQueue] Worker %d stopping", id)
|
||||
logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d stopping", id)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -82,18 +83,18 @@ func (s *EmailQueueService) processTask(workerID int, task EmailTask) {
|
||||
switch task.TaskType {
|
||||
case TaskTypeVerifyCode:
|
||||
if err := s.emailService.SendVerifyCode(ctx, task.Email, task.SiteName); err != nil {
|
||||
log.Printf("[EmailQueue] Worker %d failed to send verify code to %s: %v", workerID, task.Email, err)
|
||||
logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d failed to send verify code to %s: %v", workerID, task.Email, err)
|
||||
} else {
|
||||
log.Printf("[EmailQueue] Worker %d sent verify code to %s", workerID, task.Email)
|
||||
logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d sent verify code to %s", workerID, task.Email)
|
||||
}
|
||||
case TaskTypePasswordReset:
|
||||
if err := s.emailService.SendPasswordResetEmailWithCooldown(ctx, task.Email, task.SiteName, task.ResetURL); err != nil {
|
||||
log.Printf("[EmailQueue] Worker %d failed to send password reset to %s: %v", workerID, task.Email, err)
|
||||
logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d failed to send password reset to %s: %v", workerID, task.Email, err)
|
||||
} else {
|
||||
log.Printf("[EmailQueue] Worker %d sent password reset to %s", workerID, task.Email)
|
||||
logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d sent password reset to %s", workerID, task.Email)
|
||||
}
|
||||
default:
|
||||
log.Printf("[EmailQueue] Worker %d unknown task type: %s", workerID, task.TaskType)
|
||||
logger.LegacyPrintf("service.email_queue", "[EmailQueue] Worker %d unknown task type: %s", workerID, task.TaskType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +108,7 @@ func (s *EmailQueueService) EnqueueVerifyCode(email, siteName string) error {
|
||||
|
||||
select {
|
||||
case s.taskChan <- task:
|
||||
log.Printf("[EmailQueue] Enqueued verify code task for %s", email)
|
||||
logger.LegacyPrintf("service.email_queue", "[EmailQueue] Enqueued verify code task for %s", email)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("email queue is full")
|
||||
@@ -125,7 +126,7 @@ func (s *EmailQueueService) EnqueuePasswordReset(email, siteName, resetURL strin
|
||||
|
||||
select {
|
||||
case s.taskChan <- task:
|
||||
log.Printf("[EmailQueue] Enqueued password reset task for %s", email)
|
||||
logger.LegacyPrintf("service.email_queue", "[EmailQueue] Enqueued password reset task for %s", email)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("email queue is full")
|
||||
@@ -136,5 +137,5 @@ func (s *EmailQueueService) EnqueuePasswordReset(email, siteName, resetURL strin
|
||||
func (s *EmailQueueService) Stop() {
|
||||
close(s.stopChan)
|
||||
s.wg.Wait()
|
||||
log.Println("[EmailQueue] All workers stopped")
|
||||
logger.LegacyPrintf("service.email_queue", "%s", "[EmailQueue] All workers stopped")
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestOpenAIHandleErrorResponse_NoRuleKeepsDefault(t *testing.T) {
|
||||
}
|
||||
account := &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
|
||||
_, err := svc.handleErrorResponse(context.Background(), resp, c, account)
|
||||
_, err := svc.handleErrorResponse(context.Background(), resp, c, account, nil)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusBadGateway, rec.Code)
|
||||
|
||||
@@ -157,7 +157,7 @@ func TestOpenAIHandleErrorResponse_AppliesRuleFor422(t *testing.T) {
|
||||
}
|
||||
account := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
|
||||
_, err := svc.handleErrorResponse(context.Background(), resp, c, account)
|
||||
_, err := svc.handleErrorResponse(context.Background(), resp, c, account, nil)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusTeapot, rec.Code)
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
)
|
||||
|
||||
// ErrorPassthroughRepository 定义错误透传规则的数据访问接口
|
||||
@@ -72,9 +72,9 @@ func NewErrorPassthroughService(
|
||||
// 启动时加载规则到本地缓存
|
||||
ctx := context.Background()
|
||||
if err := svc.reloadRulesFromDB(ctx); err != nil {
|
||||
log.Printf("[ErrorPassthroughService] Failed to load rules from DB on startup: %v", err)
|
||||
logger.LegacyPrintf("service.error_passthrough", "[ErrorPassthroughService] Failed to load rules from DB on startup: %v", err)
|
||||
if fallbackErr := svc.refreshLocalCache(ctx); fallbackErr != nil {
|
||||
log.Printf("[ErrorPassthroughService] Failed to load rules from cache fallback on startup: %v", fallbackErr)
|
||||
logger.LegacyPrintf("service.error_passthrough", "[ErrorPassthroughService] Failed to load rules from cache fallback on startup: %v", fallbackErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ func NewErrorPassthroughService(
|
||||
if cache != nil {
|
||||
cache.SubscribeUpdates(ctx, func() {
|
||||
if err := svc.refreshLocalCache(context.Background()); err != nil {
|
||||
log.Printf("[ErrorPassthroughService] Failed to refresh cache on notification: %v", err)
|
||||
logger.LegacyPrintf("service.error_passthrough", "[ErrorPassthroughService] Failed to refresh cache on notification: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -192,7 +192,7 @@ func (s *ErrorPassthroughService) getCachedRules() []*cachedPassthroughRule {
|
||||
// 如果本地缓存为空,尝试刷新
|
||||
ctx := context.Background()
|
||||
if err := s.refreshLocalCache(ctx); err != nil {
|
||||
log.Printf("[ErrorPassthroughService] Failed to refresh cache: %v", err)
|
||||
logger.LegacyPrintf("service.error_passthrough", "[ErrorPassthroughService] Failed to refresh cache: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ func (s *ErrorPassthroughService) reloadRulesFromDB(ctx context.Context) error {
|
||||
// 更新 Redis 缓存
|
||||
if s.cache != nil {
|
||||
if err := s.cache.Set(ctx, rules); err != nil {
|
||||
log.Printf("[ErrorPassthroughService] Failed to set cache: %v", err)
|
||||
logger.LegacyPrintf("service.error_passthrough", "[ErrorPassthroughService] Failed to set cache: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,13 +288,13 @@ func (s *ErrorPassthroughService) invalidateAndNotify(ctx context.Context) {
|
||||
// 先失效缓存,避免后续刷新读到陈旧规则。
|
||||
if s.cache != nil {
|
||||
if err := s.cache.Invalidate(ctx); err != nil {
|
||||
log.Printf("[ErrorPassthroughService] Failed to invalidate cache: %v", err)
|
||||
logger.LegacyPrintf("service.error_passthrough", "[ErrorPassthroughService] Failed to invalidate cache: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新本地缓存
|
||||
if err := s.reloadRulesFromDB(ctx); err != nil {
|
||||
log.Printf("[ErrorPassthroughService] Failed to refresh local cache: %v", err)
|
||||
logger.LegacyPrintf("service.error_passthrough", "[ErrorPassthroughService] Failed to refresh local cache: %v", err)
|
||||
// 刷新失败时清空本地缓存,避免继续使用陈旧规则。
|
||||
s.clearLocalCache()
|
||||
}
|
||||
@@ -302,7 +302,7 @@ func (s *ErrorPassthroughService) invalidateAndNotify(ctx context.Context) {
|
||||
// 通知其他实例
|
||||
if s.cache != nil {
|
||||
if err := s.cache.NotifyUpdate(ctx); err != nil {
|
||||
log.Printf("[ErrorPassthroughService] Failed to notify cache update: %v", err)
|
||||
logger.LegacyPrintf("service.error_passthrough", "[ErrorPassthroughService] Failed to notify cache update: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func testTimePtr(t time.Time) *time.Time { return &t }
|
||||
|
||||
func makeAccWithLoad(id int64, priority int, loadRate int, lastUsed *time.Time, accType string) accountWithLoad {
|
||||
return accountWithLoad{
|
||||
account: &Account{
|
||||
ID: id,
|
||||
Priority: priority,
|
||||
LastUsedAt: lastUsed,
|
||||
Type: accType,
|
||||
Schedulable: true,
|
||||
Status: StatusActive,
|
||||
},
|
||||
loadInfo: &AccountLoadInfo{
|
||||
AccountID: id,
|
||||
CurrentConcurrency: 0,
|
||||
LoadRate: loadRate,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- sortAccountsByPriorityAndLastUsed ---
|
||||
|
||||
func TestSortAccountsByPriorityAndLastUsed_ByPriority(t *testing.T) {
|
||||
now := time.Now()
|
||||
accounts := []*Account{
|
||||
{ID: 1, Priority: 5, LastUsedAt: testTimePtr(now)},
|
||||
{ID: 2, Priority: 1, LastUsedAt: testTimePtr(now)},
|
||||
{ID: 3, Priority: 3, LastUsedAt: testTimePtr(now)},
|
||||
}
|
||||
sortAccountsByPriorityAndLastUsed(accounts, false)
|
||||
require.Equal(t, int64(2), accounts[0].ID, "优先级最低的排第一")
|
||||
require.Equal(t, int64(3), accounts[1].ID)
|
||||
require.Equal(t, int64(1), accounts[2].ID)
|
||||
}
|
||||
|
||||
func TestSortAccountsByPriorityAndLastUsed_SamePriorityByLastUsed(t *testing.T) {
|
||||
now := time.Now()
|
||||
accounts := []*Account{
|
||||
{ID: 1, Priority: 1, LastUsedAt: testTimePtr(now)},
|
||||
{ID: 2, Priority: 1, LastUsedAt: testTimePtr(now.Add(-1 * time.Hour))},
|
||||
{ID: 3, Priority: 1, LastUsedAt: nil},
|
||||
}
|
||||
sortAccountsByPriorityAndLastUsed(accounts, false)
|
||||
require.Equal(t, int64(3), accounts[0].ID, "nil LastUsedAt 排最前")
|
||||
require.Equal(t, int64(2), accounts[1].ID, "更早使用的排前面")
|
||||
require.Equal(t, int64(1), accounts[2].ID)
|
||||
}
|
||||
|
||||
func TestSortAccountsByPriorityAndLastUsed_PreferOAuth(t *testing.T) {
|
||||
accounts := []*Account{
|
||||
{ID: 1, Priority: 1, LastUsedAt: nil, Type: AccountTypeAPIKey},
|
||||
{ID: 2, Priority: 1, LastUsedAt: nil, Type: AccountTypeOAuth},
|
||||
}
|
||||
sortAccountsByPriorityAndLastUsed(accounts, true)
|
||||
require.Equal(t, int64(2), accounts[0].ID, "preferOAuth 时 OAuth 账号排前面")
|
||||
}
|
||||
|
||||
func TestSortAccountsByPriorityAndLastUsed_StableSort(t *testing.T) {
|
||||
accounts := []*Account{
|
||||
{ID: 1, Priority: 1, LastUsedAt: nil, Type: AccountTypeAPIKey},
|
||||
{ID: 2, Priority: 1, LastUsedAt: nil, Type: AccountTypeAPIKey},
|
||||
{ID: 3, Priority: 1, LastUsedAt: nil, Type: AccountTypeAPIKey},
|
||||
}
|
||||
|
||||
// sortAccountsByPriorityAndLastUsed 内部会在同组(Priority+LastUsedAt)内做随机打散,
|
||||
// 因此这里不再断言“稳定排序”。我们只验证:
|
||||
// 1) 元素集合不变;2) 多次运行能产生不同的顺序。
|
||||
seenFirst := map[int64]bool{}
|
||||
for i := 0; i < 100; i++ {
|
||||
cpy := make([]*Account, len(accounts))
|
||||
copy(cpy, accounts)
|
||||
sortAccountsByPriorityAndLastUsed(cpy, false)
|
||||
seenFirst[cpy[0].ID] = true
|
||||
|
||||
ids := map[int64]bool{}
|
||||
for _, a := range cpy {
|
||||
ids[a.ID] = true
|
||||
}
|
||||
require.True(t, ids[1] && ids[2] && ids[3])
|
||||
}
|
||||
require.GreaterOrEqual(t, len(seenFirst), 2, "同组账号应能被随机打散")
|
||||
}
|
||||
|
||||
func TestSortAccountsByPriorityAndLastUsed_MixedPriorityAndTime(t *testing.T) {
|
||||
now := time.Now()
|
||||
accounts := []*Account{
|
||||
{ID: 1, Priority: 2, LastUsedAt: nil},
|
||||
{ID: 2, Priority: 1, LastUsedAt: testTimePtr(now)},
|
||||
{ID: 3, Priority: 1, LastUsedAt: testTimePtr(now.Add(-1 * time.Hour))},
|
||||
{ID: 4, Priority: 2, LastUsedAt: testTimePtr(now.Add(-2 * time.Hour))},
|
||||
}
|
||||
sortAccountsByPriorityAndLastUsed(accounts, false)
|
||||
// 优先级1排前:nil < earlier
|
||||
require.Equal(t, int64(3), accounts[0].ID, "优先级1 + 更早")
|
||||
require.Equal(t, int64(2), accounts[1].ID, "优先级1 + 现在")
|
||||
// 优先级2排后:nil < time
|
||||
require.Equal(t, int64(1), accounts[2].ID, "优先级2 + nil")
|
||||
require.Equal(t, int64(4), accounts[3].ID, "优先级2 + 有时间")
|
||||
}
|
||||
|
||||
// --- filterByMinPriority ---
|
||||
|
||||
func TestFilterByMinPriority_Empty(t *testing.T) {
|
||||
result := filterByMinPriority(nil)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestFilterByMinPriority_SelectsMinPriority(t *testing.T) {
|
||||
accounts := []accountWithLoad{
|
||||
makeAccWithLoad(1, 5, 10, nil, AccountTypeAPIKey),
|
||||
makeAccWithLoad(2, 1, 10, nil, AccountTypeAPIKey),
|
||||
makeAccWithLoad(3, 1, 20, nil, AccountTypeAPIKey),
|
||||
makeAccWithLoad(4, 2, 10, nil, AccountTypeAPIKey),
|
||||
}
|
||||
result := filterByMinPriority(accounts)
|
||||
require.Len(t, result, 2)
|
||||
require.Equal(t, int64(2), result[0].account.ID)
|
||||
require.Equal(t, int64(3), result[1].account.ID)
|
||||
}
|
||||
|
||||
// --- filterByMinLoadRate ---
|
||||
|
||||
func TestFilterByMinLoadRate_Empty(t *testing.T) {
|
||||
result := filterByMinLoadRate(nil)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestFilterByMinLoadRate_SelectsMinLoadRate(t *testing.T) {
|
||||
accounts := []accountWithLoad{
|
||||
makeAccWithLoad(1, 1, 30, nil, AccountTypeAPIKey),
|
||||
makeAccWithLoad(2, 1, 10, nil, AccountTypeAPIKey),
|
||||
makeAccWithLoad(3, 1, 10, nil, AccountTypeAPIKey),
|
||||
makeAccWithLoad(4, 1, 20, nil, AccountTypeAPIKey),
|
||||
}
|
||||
result := filterByMinLoadRate(accounts)
|
||||
require.Len(t, result, 2)
|
||||
require.Equal(t, int64(2), result[0].account.ID)
|
||||
require.Equal(t, int64(3), result[1].account.ID)
|
||||
}
|
||||
|
||||
// --- selectByLRU ---
|
||||
|
||||
func TestSelectByLRU_Empty(t *testing.T) {
|
||||
result := selectByLRU(nil, false)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestSelectByLRU_Single(t *testing.T) {
|
||||
accounts := []accountWithLoad{makeAccWithLoad(1, 1, 10, nil, AccountTypeAPIKey)}
|
||||
result := selectByLRU(accounts, false)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(1), result.account.ID)
|
||||
}
|
||||
|
||||
func TestSelectByLRU_NilLastUsedAtWins(t *testing.T) {
|
||||
now := time.Now()
|
||||
accounts := []accountWithLoad{
|
||||
makeAccWithLoad(1, 1, 10, testTimePtr(now), AccountTypeAPIKey),
|
||||
makeAccWithLoad(2, 1, 10, nil, AccountTypeAPIKey),
|
||||
makeAccWithLoad(3, 1, 10, testTimePtr(now.Add(-1*time.Hour)), AccountTypeAPIKey),
|
||||
}
|
||||
result := selectByLRU(accounts, false)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(2), result.account.ID)
|
||||
}
|
||||
|
||||
func TestSelectByLRU_EarliestTimeWins(t *testing.T) {
|
||||
now := time.Now()
|
||||
accounts := []accountWithLoad{
|
||||
makeAccWithLoad(1, 1, 10, testTimePtr(now), AccountTypeAPIKey),
|
||||
makeAccWithLoad(2, 1, 10, testTimePtr(now.Add(-1*time.Hour)), AccountTypeAPIKey),
|
||||
makeAccWithLoad(3, 1, 10, testTimePtr(now.Add(-2*time.Hour)), AccountTypeAPIKey),
|
||||
}
|
||||
result := selectByLRU(accounts, false)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(3), result.account.ID)
|
||||
}
|
||||
|
||||
func TestSelectByLRU_TiePreferOAuth(t *testing.T) {
|
||||
now := time.Now()
|
||||
// 账号 1/2 LastUsedAt 相同,且同为最小值。
|
||||
accounts := []accountWithLoad{
|
||||
makeAccWithLoad(1, 1, 10, testTimePtr(now), AccountTypeAPIKey),
|
||||
makeAccWithLoad(2, 1, 10, testTimePtr(now), AccountTypeOAuth),
|
||||
makeAccWithLoad(3, 1, 10, testTimePtr(now.Add(1*time.Hour)), AccountTypeAPIKey),
|
||||
}
|
||||
for i := 0; i < 50; i++ {
|
||||
result := selectByLRU(accounts, true)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, AccountTypeOAuth, result.account.Type)
|
||||
require.Equal(t, int64(2), result.account.ID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func BenchmarkGatewayService_ParseSSEUsage_MessageStart(b *testing.B) {
|
||||
svc := &GatewayService{}
|
||||
data := `{"type":"message_start","message":{"usage":{"input_tokens":123,"cache_creation_input_tokens":45,"cache_read_input_tokens":6,"cached_tokens":6,"cache_creation":{"ephemeral_5m_input_tokens":20,"ephemeral_1h_input_tokens":25}}}}`
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
usage := &ClaudeUsage{}
|
||||
svc.parseSSEUsage(data, usage)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart(b *testing.B) {
|
||||
svc := &GatewayService{}
|
||||
data := `{"type":"message_start","message":{"usage":{"input_tokens":123,"cache_creation_input_tokens":45,"cache_read_input_tokens":6,"cached_tokens":6,"cache_creation":{"ephemeral_5m_input_tokens":20,"ephemeral_1h_input_tokens":25}}}}`
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
usage := &ClaudeUsage{}
|
||||
svc.parseSSEUsagePassthrough(data, usage)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGatewayService_ParseSSEUsage_MessageDelta(b *testing.B) {
|
||||
svc := &GatewayService{}
|
||||
data := `{"type":"message_delta","usage":{"output_tokens":456,"cache_creation_input_tokens":30,"cache_read_input_tokens":7,"cached_tokens":7,"cache_creation":{"ephemeral_5m_input_tokens":10,"ephemeral_1h_input_tokens":20}}}`
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
usage := &ClaudeUsage{}
|
||||
svc.parseSSEUsage(data, usage)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta(b *testing.B) {
|
||||
svc := &GatewayService{}
|
||||
data := `{"type":"message_delta","usage":{"output_tokens":456,"cache_creation_input_tokens":30,"cache_read_input_tokens":7,"cached_tokens":7,"cache_creation":{"ephemeral_5m_input_tokens":10,"ephemeral_1h_input_tokens":20}}}`
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
usage := &ClaudeUsage{}
|
||||
svc.parseSSEUsagePassthrough(data, usage)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkParseClaudeUsageFromResponseBody(b *testing.B) {
|
||||
body := []byte(`{"id":"msg_123","type":"message","usage":{"input_tokens":123,"output_tokens":456,"cache_creation_input_tokens":45,"cache_read_input_tokens":6,"cached_tokens":6,"cache_creation":{"ephemeral_5m_input_tokens":20,"ephemeral_1h_input_tokens":25}}}`)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = parseClaudeUsageFromResponseBody(body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,875 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
type anthropicHTTPUpstreamRecorder struct {
|
||||
lastReq *http.Request
|
||||
lastBody []byte
|
||||
resp *http.Response
|
||||
err error
|
||||
}
|
||||
|
||||
func newAnthropicAPIKeyAccountForTest() *Account {
|
||||
return &Account{
|
||||
ID: 201,
|
||||
Name: "anthropic-apikey-pass-test",
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "upstream-anthropic-key",
|
||||
"base_url": "https://api.anthropic.com",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
"anthropic_passthrough": true,
|
||||
},
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *anthropicHTTPUpstreamRecorder) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
|
||||
u.lastReq = req
|
||||
if req != nil && req.Body != nil {
|
||||
b, _ := io.ReadAll(req.Body)
|
||||
u.lastBody = b
|
||||
_ = req.Body.Close()
|
||||
req.Body = io.NopCloser(bytes.NewReader(b))
|
||||
}
|
||||
if u.err != nil {
|
||||
return nil, u.err
|
||||
}
|
||||
return u.resp, nil
|
||||
}
|
||||
|
||||
func (u *anthropicHTTPUpstreamRecorder) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
return u.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
type streamReadCloser struct {
|
||||
payload []byte
|
||||
sent bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *streamReadCloser) Read(p []byte) (int, error) {
|
||||
if !r.sent {
|
||||
r.sent = true
|
||||
n := copy(p, r.payload)
|
||||
return n, nil
|
||||
}
|
||||
if r.err != nil {
|
||||
return 0, r.err
|
||||
}
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
func (r *streamReadCloser) Close() error { return nil }
|
||||
|
||||
type failWriteResponseWriter struct {
|
||||
gin.ResponseWriter
|
||||
}
|
||||
|
||||
func (w *failWriteResponseWriter) Write(data []byte) (int, error) {
|
||||
return 0, errors.New("client disconnected")
|
||||
}
|
||||
|
||||
func (w *failWriteResponseWriter) WriteString(_ string) (int, error) {
|
||||
return 0, errors.New("client disconnected")
|
||||
}
|
||||
|
||||
func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardStreamPreservesBodyAndAuthReplacement(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
c.Request.Header.Set("User-Agent", "claude-cli/1.0.0")
|
||||
c.Request.Header.Set("Authorization", "Bearer inbound-token")
|
||||
c.Request.Header.Set("X-Api-Key", "inbound-api-key")
|
||||
c.Request.Header.Set("X-Goog-Api-Key", "inbound-goog-key")
|
||||
c.Request.Header.Set("Cookie", "secret=1")
|
||||
c.Request.Header.Set("Anthropic-Beta", "interleaved-thinking-2025-05-14")
|
||||
|
||||
body := []byte(`{"model":"claude-3-7-sonnet-20250219","stream":true,"system":[{"type":"text","text":"x-anthropic-billing-header keep"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
|
||||
parsed := &ParsedRequest{
|
||||
Body: body,
|
||||
Model: "claude-3-7-sonnet-20250219",
|
||||
Stream: true,
|
||||
}
|
||||
|
||||
upstreamSSE := strings.Join([]string{
|
||||
`data: {"type":"message_start","message":{"usage":{"input_tokens":9,"cached_tokens":7}}}`,
|
||||
"",
|
||||
`data: {"type":"message_delta","usage":{"output_tokens":3}}`,
|
||||
"",
|
||||
"data: [DONE]",
|
||||
"",
|
||||
}, "\n")
|
||||
upstream := &anthropicHTTPUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/event-stream"},
|
||||
"x-request-id": []string{"rid-anthropic-pass"},
|
||||
"Set-Cookie": []string{"secret=upstream"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
|
||||
},
|
||||
}
|
||||
|
||||
svc := &GatewayService{
|
||||
cfg: &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
MaxLineSize: defaultMaxLineSize,
|
||||
},
|
||||
},
|
||||
httpUpstream: upstream,
|
||||
rateLimitService: &RateLimitService{},
|
||||
deferredService: &DeferredService{},
|
||||
billingCacheService: nil,
|
||||
}
|
||||
|
||||
account := &Account{
|
||||
ID: 101,
|
||||
Name: "anthropic-apikey-pass",
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "upstream-anthropic-key",
|
||||
"base_url": "https://api.anthropic.com",
|
||||
"model_mapping": map[string]any{"claude-3-7-sonnet-20250219": "claude-3-haiku-20240307"},
|
||||
},
|
||||
Extra: map[string]any{
|
||||
"anthropic_passthrough": true,
|
||||
},
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, parsed)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.True(t, result.Stream)
|
||||
|
||||
require.Equal(t, body, upstream.lastBody, "透传模式不应改写上游请求体")
|
||||
require.Equal(t, "claude-3-7-sonnet-20250219", gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
|
||||
require.Equal(t, "upstream-anthropic-key", upstream.lastReq.Header.Get("x-api-key"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("authorization"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("x-goog-api-key"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("cookie"))
|
||||
require.Equal(t, "2023-06-01", upstream.lastReq.Header.Get("anthropic-version"))
|
||||
require.Equal(t, "interleaved-thinking-2025-05-14", upstream.lastReq.Header.Get("anthropic-beta"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("x-stainless-lang"), "API Key 透传不应注入 OAuth 指纹头")
|
||||
|
||||
require.Contains(t, rec.Body.String(), `"cached_tokens":7`)
|
||||
require.NotContains(t, rec.Body.String(), `"cache_read_input_tokens":7`, "透传输出不应被网关改写")
|
||||
require.Equal(t, 7, result.Usage.CacheReadInputTokens, "计费 usage 解析应保留 cached_tokens 兼容")
|
||||
require.Empty(t, rec.Header().Get("Set-Cookie"), "响应头应经过安全过滤")
|
||||
rawBody, ok := c.Get(OpsUpstreamRequestBodyKey)
|
||||
require.True(t, ok)
|
||||
bodyBytes, ok := rawBody.([]byte)
|
||||
require.True(t, ok, "应以 []byte 形式缓存上游请求体,避免重复 string 拷贝")
|
||||
require.Equal(t, body, bodyBytes)
|
||||
}
|
||||
|
||||
func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardCountTokensPreservesBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
|
||||
c.Request.Header.Set("Authorization", "Bearer inbound-token")
|
||||
c.Request.Header.Set("X-Api-Key", "inbound-api-key")
|
||||
c.Request.Header.Set("Cookie", "secret=1")
|
||||
|
||||
body := []byte(`{"model":"claude-3-5-sonnet-latest","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}],"thinking":{"type":"enabled"}}`)
|
||||
parsed := &ParsedRequest{
|
||||
Body: body,
|
||||
Model: "claude-3-5-sonnet-latest",
|
||||
}
|
||||
|
||||
upstreamRespBody := `{"input_tokens":42}`
|
||||
upstream := &anthropicHTTPUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"x-request-id": []string{"rid-count"},
|
||||
"Set-Cookie": []string{"secret=upstream"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamRespBody)),
|
||||
},
|
||||
}
|
||||
|
||||
svc := &GatewayService{
|
||||
cfg: &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
MaxLineSize: defaultMaxLineSize,
|
||||
},
|
||||
},
|
||||
httpUpstream: upstream,
|
||||
rateLimitService: &RateLimitService{},
|
||||
}
|
||||
|
||||
account := &Account{
|
||||
ID: 102,
|
||||
Name: "anthropic-apikey-pass-count",
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "upstream-anthropic-key",
|
||||
"base_url": "https://api.anthropic.com",
|
||||
"model_mapping": map[string]any{"claude-3-5-sonnet-latest": "claude-3-opus-20240229"},
|
||||
},
|
||||
Extra: map[string]any{
|
||||
"anthropic_passthrough": true,
|
||||
},
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
|
||||
err := svc.ForwardCountTokens(context.Background(), c, account, parsed)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, body, upstream.lastBody, "count_tokens 透传模式不应改写请求体")
|
||||
require.Equal(t, "claude-3-5-sonnet-latest", gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
require.Equal(t, "upstream-anthropic-key", upstream.lastReq.Header.Get("x-api-key"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("authorization"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("cookie"))
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.JSONEq(t, upstreamRespBody, rec.Body.String())
|
||||
require.Empty(t, rec.Header().Get("Set-Cookie"))
|
||||
}
|
||||
|
||||
func TestGatewayService_AnthropicAPIKeyPassthrough_CountTokens404PassthroughNotError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
respBody string
|
||||
wantPassthrough bool
|
||||
}{
|
||||
{
|
||||
name: "404 endpoint not found passes through as 404",
|
||||
statusCode: http.StatusNotFound,
|
||||
respBody: `{"error":{"message":"Not found: /v1/messages/count_tokens","type":"not_found_error"}}`,
|
||||
wantPassthrough: true,
|
||||
},
|
||||
{
|
||||
name: "404 generic not found does not passthrough",
|
||||
statusCode: http.StatusNotFound,
|
||||
respBody: `{"error":{"message":"resource not found","type":"not_found_error"}}`,
|
||||
wantPassthrough: false,
|
||||
},
|
||||
{
|
||||
name: "400 Invalid URL does not passthrough",
|
||||
statusCode: http.StatusBadRequest,
|
||||
respBody: `{"error":{"message":"Invalid URL (POST /v1/messages/count_tokens)","type":"invalid_request_error"}}`,
|
||||
wantPassthrough: false,
|
||||
},
|
||||
{
|
||||
name: "400 model error does not passthrough",
|
||||
statusCode: http.StatusBadRequest,
|
||||
respBody: `{"error":{"message":"model not found: claude-unknown","type":"invalid_request_error"}}`,
|
||||
wantPassthrough: false,
|
||||
},
|
||||
{
|
||||
name: "500 internal error does not passthrough",
|
||||
statusCode: http.StatusInternalServerError,
|
||||
respBody: `{"error":{"message":"internal error","type":"api_error"}}`,
|
||||
wantPassthrough: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
|
||||
|
||||
body := []byte(`{"model":"claude-sonnet-4-5-20250929","messages":[{"role":"user","content":"hi"}]}`)
|
||||
parsed := &ParsedRequest{Body: body, Model: "claude-sonnet-4-5-20250929"}
|
||||
|
||||
upstream := &anthropicHTTPUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: tt.statusCode,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(tt.respBody)),
|
||||
},
|
||||
}
|
||||
|
||||
svc := &GatewayService{
|
||||
cfg: &config.Config{
|
||||
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
|
||||
},
|
||||
httpUpstream: upstream,
|
||||
rateLimitService: nil,
|
||||
}
|
||||
|
||||
account := &Account{
|
||||
ID: 200,
|
||||
Name: "proxy-acc",
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-proxy",
|
||||
"base_url": "https://proxy.example.com",
|
||||
},
|
||||
Extra: map[string]any{"anthropic_passthrough": true},
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
|
||||
err := svc.ForwardCountTokens(context.Background(), c, account, parsed)
|
||||
|
||||
if tt.wantPassthrough {
|
||||
// 返回 nil(不记录为错误),HTTP 状态码 404 + Anthropic 错误体
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusNotFound, rec.Code)
|
||||
var errResp map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &errResp))
|
||||
require.Equal(t, "error", errResp["type"])
|
||||
errObj, ok := errResp["error"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "not_found_error", errObj["type"])
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
require.Equal(t, tt.statusCode, rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayService_AnthropicAPIKeyPassthrough_BuildRequestRejectsInvalidBaseURL(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
svc := &GatewayService{
|
||||
cfg: &config.Config{
|
||||
Security: config.SecurityConfig{
|
||||
URLAllowlist: config.URLAllowlistConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
account := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "k",
|
||||
"base_url": "://invalid-url",
|
||||
},
|
||||
}
|
||||
|
||||
_, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, []byte(`{}`), "k")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGatewayService_AnthropicOAuth_NotAffectedByAPIKeyPassthroughToggle(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
svc := &GatewayService{
|
||||
cfg: &config.Config{
|
||||
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
|
||||
},
|
||||
}
|
||||
account := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{
|
||||
"anthropic_passthrough": true,
|
||||
},
|
||||
}
|
||||
|
||||
require.False(t, account.IsAnthropicAPIKeyPassthroughEnabled())
|
||||
|
||||
req, err := svc.buildUpstreamRequest(context.Background(), c, account, []byte(`{"model":"claude-3-7-sonnet-20250219"}`), "oauth-token", "oauth", "claude-3-7-sonnet-20250219", true, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Bearer oauth-token", req.Header.Get("authorization"))
|
||||
require.Contains(t, req.Header.Get("anthropic-beta"), claude.BetaOAuth, "OAuth 链路仍应按原逻辑补齐 oauth beta")
|
||||
}
|
||||
|
||||
func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingStillCollectsUsageAfterClientDisconnect(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Use a canceled context recorder to simulate client disconnect behavior.
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
cancel()
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
|
||||
svc := &GatewayService{
|
||||
cfg: &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
MaxLineSize: defaultMaxLineSize,
|
||||
},
|
||||
},
|
||||
rateLimitService: &RateLimitService{},
|
||||
}
|
||||
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(strings.Join([]string{
|
||||
`data: {"type":"message_start","message":{"usage":{"input_tokens":11}}}`,
|
||||
"",
|
||||
`data: {"type":"message_delta","usage":{"output_tokens":5}}`,
|
||||
"",
|
||||
"data: [DONE]",
|
||||
"",
|
||||
}, "\n"))),
|
||||
}
|
||||
|
||||
result, err := svc.handleStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "claude-3-7-sonnet-20250219")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.usage)
|
||||
require.Equal(t, 11, result.usage.InputTokens)
|
||||
require.Equal(t, 5, result.usage.OutputTokens)
|
||||
}
|
||||
|
||||
func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardDirect_NonStreamingSuccess(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
body := []byte(`{"model":"claude-3-5-sonnet-latest","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
|
||||
upstreamJSON := `{"id":"msg_1","type":"message","usage":{"input_tokens":12,"output_tokens":7,"cache_creation":{"ephemeral_5m_input_tokens":2,"ephemeral_1h_input_tokens":3},"cached_tokens":4}}`
|
||||
upstream := &anthropicHTTPUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"x-request-id": []string{"rid-nonstream"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamJSON)),
|
||||
},
|
||||
}
|
||||
svc := &GatewayService{
|
||||
cfg: &config.Config{},
|
||||
httpUpstream: upstream,
|
||||
rateLimitService: &RateLimitService{},
|
||||
}
|
||||
|
||||
result, err := svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, newAnthropicAPIKeyAccountForTest(), body, "claude-3-5-sonnet-latest", false, time.Now())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, 12, result.Usage.InputTokens)
|
||||
require.Equal(t, 7, result.Usage.OutputTokens)
|
||||
require.Equal(t, 5, result.Usage.CacheCreationInputTokens)
|
||||
require.Equal(t, 4, result.Usage.CacheReadInputTokens)
|
||||
require.Equal(t, upstreamJSON, rec.Body.String())
|
||||
}
|
||||
|
||||
func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardDirect_InvalidTokenType(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
account := &Account{
|
||||
ID: 202,
|
||||
Name: "anthropic-oauth",
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "oauth-token",
|
||||
},
|
||||
}
|
||||
svc := &GatewayService{}
|
||||
|
||||
result, err := svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, account, []byte(`{}`), "claude-3-5-sonnet-latest", false, time.Now())
|
||||
require.Nil(t, result)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "requires apikey token")
|
||||
}
|
||||
|
||||
func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardDirect_UpstreamRequestError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
upstream := &anthropicHTTPUpstreamRecorder{
|
||||
err: errors.New("dial tcp timeout"),
|
||||
}
|
||||
svc := &GatewayService{
|
||||
cfg: &config.Config{
|
||||
Security: config.SecurityConfig{
|
||||
URLAllowlist: config.URLAllowlistConfig{Enabled: false},
|
||||
},
|
||||
},
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
account := newAnthropicAPIKeyAccountForTest()
|
||||
|
||||
result, err := svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, account, []byte(`{"model":"x"}`), "x", false, time.Now())
|
||||
require.Nil(t, result)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "upstream request failed")
|
||||
require.Equal(t, http.StatusBadGateway, rec.Code)
|
||||
rawBody, ok := c.Get(OpsUpstreamRequestBodyKey)
|
||||
require.True(t, ok)
|
||||
_, ok = rawBody.([]byte)
|
||||
require.True(t, ok)
|
||||
}
|
||||
|
||||
func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardDirect_EmptyResponseBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
upstream := &anthropicHTTPUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"x-request-id": []string{"rid-empty-body"}},
|
||||
Body: nil,
|
||||
},
|
||||
}
|
||||
svc := &GatewayService{
|
||||
cfg: &config.Config{
|
||||
Security: config.SecurityConfig{
|
||||
URLAllowlist: config.URLAllowlistConfig{Enabled: false},
|
||||
},
|
||||
},
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
|
||||
result, err := svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, newAnthropicAPIKeyAccountForTest(), []byte(`{"model":"x"}`), "x", false, time.Now())
|
||||
require.Nil(t, result)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "empty response")
|
||||
}
|
||||
|
||||
func TestExtractAnthropicSSEDataLine(t *testing.T) {
|
||||
t.Run("valid data line with spaces", func(t *testing.T) {
|
||||
data, ok := extractAnthropicSSEDataLine("data: {\"type\":\"message_start\"}")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, `{"type":"message_start"}`, data)
|
||||
})
|
||||
|
||||
t.Run("non data line", func(t *testing.T) {
|
||||
data, ok := extractAnthropicSSEDataLine("event: message_start")
|
||||
require.False(t, ok)
|
||||
require.Empty(t, data)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGatewayService_ParseSSEUsagePassthrough_MessageStartFallbacks(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
usage := &ClaudeUsage{}
|
||||
data := `{"type":"message_start","message":{"usage":{"input_tokens":12,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cached_tokens":9,"cache_creation":{"ephemeral_5m_input_tokens":3,"ephemeral_1h_input_tokens":4}}}}`
|
||||
|
||||
svc.parseSSEUsagePassthrough(data, usage)
|
||||
|
||||
require.Equal(t, 12, usage.InputTokens)
|
||||
require.Equal(t, 9, usage.CacheReadInputTokens, "应兼容 cached_tokens 字段")
|
||||
require.Equal(t, 7, usage.CacheCreationInputTokens, "聚合字段为空时应从 5m/1h 明细回填")
|
||||
require.Equal(t, 3, usage.CacheCreation5mTokens)
|
||||
require.Equal(t, 4, usage.CacheCreation1hTokens)
|
||||
}
|
||||
|
||||
func TestGatewayService_ParseSSEUsagePassthrough_MessageDeltaSelectiveOverwrite(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
usage := &ClaudeUsage{
|
||||
InputTokens: 10,
|
||||
CacheCreation5mTokens: 2,
|
||||
CacheCreation1hTokens: 6,
|
||||
}
|
||||
data := `{"type":"message_delta","usage":{"input_tokens":0,"output_tokens":5,"cache_creation_input_tokens":8,"cache_read_input_tokens":0,"cached_tokens":11,"cache_creation":{"ephemeral_5m_input_tokens":1,"ephemeral_1h_input_tokens":0}}}`
|
||||
|
||||
svc.parseSSEUsagePassthrough(data, usage)
|
||||
|
||||
require.Equal(t, 10, usage.InputTokens, "message_delta 中 0 值不应覆盖已有 input_tokens")
|
||||
require.Equal(t, 5, usage.OutputTokens)
|
||||
require.Equal(t, 8, usage.CacheCreationInputTokens)
|
||||
require.Equal(t, 11, usage.CacheReadInputTokens, "cache_read_input_tokens 为空时应回退到 cached_tokens")
|
||||
require.Equal(t, 1, usage.CacheCreation5mTokens)
|
||||
require.Equal(t, 6, usage.CacheCreation1hTokens, "message_delta 中 0 值不应覆盖已有 1h 明细")
|
||||
}
|
||||
|
||||
func TestGatewayService_ParseSSEUsagePassthrough_NoopCases(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
|
||||
usage := &ClaudeUsage{InputTokens: 3}
|
||||
svc.parseSSEUsagePassthrough("", usage)
|
||||
require.Equal(t, 3, usage.InputTokens)
|
||||
|
||||
svc.parseSSEUsagePassthrough("[DONE]", usage)
|
||||
require.Equal(t, 3, usage.InputTokens)
|
||||
|
||||
svc.parseSSEUsagePassthrough("not-json", usage)
|
||||
require.Equal(t, 3, usage.InputTokens)
|
||||
|
||||
// nil usage 不应 panic
|
||||
svc.parseSSEUsagePassthrough(`{"type":"message_start"}`, nil)
|
||||
}
|
||||
|
||||
func TestGatewayService_ParseSSEUsagePassthrough_FallbackFromUsageNode(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
usage := &ClaudeUsage{}
|
||||
data := `{"type":"content_block_delta","usage":{"cached_tokens":6,"cache_creation":{"ephemeral_5m_input_tokens":2,"ephemeral_1h_input_tokens":1}}}`
|
||||
|
||||
svc.parseSSEUsagePassthrough(data, usage)
|
||||
|
||||
require.Equal(t, 6, usage.CacheReadInputTokens)
|
||||
require.Equal(t, 3, usage.CacheCreationInputTokens)
|
||||
}
|
||||
|
||||
func TestParseClaudeUsageFromResponseBody(t *testing.T) {
|
||||
t.Run("empty or missing usage", func(t *testing.T) {
|
||||
got := parseClaudeUsageFromResponseBody(nil)
|
||||
require.NotNil(t, got)
|
||||
require.Equal(t, 0, got.InputTokens)
|
||||
|
||||
got = parseClaudeUsageFromResponseBody([]byte(`{"id":"x"}`))
|
||||
require.NotNil(t, got)
|
||||
require.Equal(t, 0, got.OutputTokens)
|
||||
})
|
||||
|
||||
t.Run("parse all usage fields and fallback", func(t *testing.T) {
|
||||
body := []byte(`{"usage":{"input_tokens":21,"output_tokens":34,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cached_tokens":13,"cache_creation":{"ephemeral_5m_input_tokens":5,"ephemeral_1h_input_tokens":8}}}`)
|
||||
got := parseClaudeUsageFromResponseBody(body)
|
||||
require.Equal(t, 21, got.InputTokens)
|
||||
require.Equal(t, 34, got.OutputTokens)
|
||||
require.Equal(t, 13, got.CacheReadInputTokens, "cache_read_input_tokens 为空时应回退 cached_tokens")
|
||||
require.Equal(t, 13, got.CacheCreationInputTokens, "聚合字段为空时应由 5m/1h 回填")
|
||||
require.Equal(t, 5, got.CacheCreation5mTokens)
|
||||
require.Equal(t, 8, got.CacheCreation1hTokens)
|
||||
})
|
||||
|
||||
t.Run("keep explicit aggregate values", func(t *testing.T) {
|
||||
body := []byte(`{"usage":{"input_tokens":1,"output_tokens":2,"cache_creation_input_tokens":9,"cache_read_input_tokens":7,"cached_tokens":99,"cache_creation":{"ephemeral_5m_input_tokens":4,"ephemeral_1h_input_tokens":5}}}`)
|
||||
got := parseClaudeUsageFromResponseBody(body)
|
||||
require.Equal(t, 9, got.CacheCreationInputTokens, "已显式提供聚合字段时不应被明细覆盖")
|
||||
require.Equal(t, 7, got.CacheReadInputTokens, "已显式提供 cache_read_input_tokens 时不应回退 cached_tokens")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingErrTooLong(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
svc := &GatewayService{
|
||||
cfg: &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
MaxLineSize: 32,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Scanner 初始缓冲为 64KB,构造更长单行触发 bufio.ErrTooLong。
|
||||
longLine := "data: " + strings.Repeat("x", 80*1024)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(longLine)),
|
||||
}
|
||||
|
||||
result, err := svc.handleStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, &Account{ID: 2}, time.Now(), "claude-3-7-sonnet-20250219")
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, bufio.ErrTooLong)
|
||||
require.NotNil(t, result)
|
||||
}
|
||||
|
||||
func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingDataIntervalTimeout(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
svc := &GatewayService{
|
||||
cfg: &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
StreamDataIntervalTimeout: 1,
|
||||
MaxLineSize: defaultMaxLineSize,
|
||||
},
|
||||
},
|
||||
rateLimitService: &RateLimitService{},
|
||||
}
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: pr,
|
||||
}
|
||||
|
||||
result, err := svc.handleStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, &Account{ID: 5}, time.Now(), "claude-3-7-sonnet-20250219")
|
||||
_ = pw.Close()
|
||||
_ = pr.Close()
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "stream data interval timeout")
|
||||
require.NotNil(t, result)
|
||||
require.False(t, result.clientDisconnect)
|
||||
}
|
||||
|
||||
func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingReadError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
svc := &GatewayService{
|
||||
cfg: &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
MaxLineSize: defaultMaxLineSize,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: &streamReadCloser{
|
||||
err: io.ErrUnexpectedEOF,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.handleStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, &Account{ID: 6}, time.Now(), "claude-3-7-sonnet-20250219")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "stream read error")
|
||||
require.NotNil(t, result)
|
||||
require.False(t, result.clientDisconnect)
|
||||
}
|
||||
|
||||
func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingTimeoutAfterClientDisconnect(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
c.Writer = &failWriteResponseWriter{ResponseWriter: c.Writer}
|
||||
|
||||
svc := &GatewayService{
|
||||
cfg: &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
StreamDataIntervalTimeout: 1,
|
||||
MaxLineSize: defaultMaxLineSize,
|
||||
},
|
||||
},
|
||||
rateLimitService: &RateLimitService{},
|
||||
}
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: pr,
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
_, _ = pw.Write([]byte(`data: {"type":"message_start","message":{"usage":{"input_tokens":9}}}` + "\n"))
|
||||
// 保持上游连接静默,触发数据间隔超时分支。
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
_ = pw.Close()
|
||||
}()
|
||||
|
||||
result, err := svc.handleStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, &Account{ID: 7}, time.Now(), "claude-3-7-sonnet-20250219")
|
||||
_ = pr.Close()
|
||||
<-done
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.True(t, result.clientDisconnect)
|
||||
require.Equal(t, 9, result.usage.InputTokens)
|
||||
}
|
||||
|
||||
func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingContextCanceled(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
svc := &GatewayService{
|
||||
cfg: &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
MaxLineSize: defaultMaxLineSize,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: &streamReadCloser{
|
||||
err: context.Canceled,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.handleStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, &Account{ID: 3}, time.Now(), "claude-3-7-sonnet-20250219")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.True(t, result.clientDisconnect)
|
||||
}
|
||||
|
||||
func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingUpstreamReadErrorAfterClientDisconnect(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
c.Writer = &failWriteResponseWriter{ResponseWriter: c.Writer}
|
||||
|
||||
svc := &GatewayService{
|
||||
cfg: &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
MaxLineSize: defaultMaxLineSize,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: &streamReadCloser{
|
||||
payload: []byte(`data: {"type":"message_start","message":{"usage":{"input_tokens":8}}}` + "\n\n"),
|
||||
err: io.ErrUnexpectedEOF,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.handleStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, &Account{ID: 4}, time.Now(), "claude-3-7-sonnet-20250219")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.True(t, result.clientDisconnect)
|
||||
require.Equal(t, 8, result.usage.InputTokens)
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package service
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -21,3 +23,180 @@ func TestMergeAnthropicBeta_EmptyIncoming(t *testing.T) {
|
||||
)
|
||||
require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14", got)
|
||||
}
|
||||
|
||||
func TestStripBetaTokens(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
tokens []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "single token in middle",
|
||||
header: "oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14",
|
||||
tokens: []string{"context-1m-2025-08-07"},
|
||||
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||
},
|
||||
{
|
||||
name: "single token at start",
|
||||
header: "context-1m-2025-08-07,oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||
tokens: []string{"context-1m-2025-08-07"},
|
||||
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||
},
|
||||
{
|
||||
name: "single token at end",
|
||||
header: "oauth-2025-04-20,interleaved-thinking-2025-05-14,context-1m-2025-08-07",
|
||||
tokens: []string{"context-1m-2025-08-07"},
|
||||
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||
},
|
||||
{
|
||||
name: "token not present",
|
||||
header: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||
tokens: []string{"context-1m-2025-08-07"},
|
||||
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||
},
|
||||
{
|
||||
name: "empty header",
|
||||
header: "",
|
||||
tokens: []string{"context-1m-2025-08-07"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "with spaces",
|
||||
header: "oauth-2025-04-20, context-1m-2025-08-07 , interleaved-thinking-2025-05-14",
|
||||
tokens: []string{"context-1m-2025-08-07"},
|
||||
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||
},
|
||||
{
|
||||
name: "only token",
|
||||
header: "context-1m-2025-08-07",
|
||||
tokens: []string{"context-1m-2025-08-07"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "nil tokens",
|
||||
header: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||
tokens: nil,
|
||||
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||
},
|
||||
{
|
||||
name: "multiple tokens removed",
|
||||
header: "oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14,fast-mode-2026-02-01",
|
||||
tokens: []string{"context-1m-2025-08-07", "fast-mode-2026-02-01"},
|
||||
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||
},
|
||||
{
|
||||
name: "DroppedBetas removes both context-1m and fast-mode",
|
||||
header: "oauth-2025-04-20,context-1m-2025-08-07,fast-mode-2026-02-01,interleaved-thinking-2025-05-14",
|
||||
tokens: claude.DroppedBetas,
|
||||
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := stripBetaTokens(tt.header, tt.tokens)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeAnthropicBetaDropping_Context1M(t *testing.T) {
|
||||
required := []string{"oauth-2025-04-20", "interleaved-thinking-2025-05-14"}
|
||||
incoming := "context-1m-2025-08-07,foo-beta,oauth-2025-04-20"
|
||||
drop := map[string]struct{}{"context-1m-2025-08-07": {}}
|
||||
|
||||
got := mergeAnthropicBetaDropping(required, incoming, drop)
|
||||
require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14,foo-beta", got)
|
||||
require.NotContains(t, got, "context-1m-2025-08-07")
|
||||
}
|
||||
|
||||
func TestMergeAnthropicBetaDropping_DroppedBetas(t *testing.T) {
|
||||
required := []string{"oauth-2025-04-20", "interleaved-thinking-2025-05-14"}
|
||||
incoming := "context-1m-2025-08-07,fast-mode-2026-02-01,foo-beta,oauth-2025-04-20"
|
||||
drop := droppedBetaSet()
|
||||
|
||||
got := mergeAnthropicBetaDropping(required, incoming, drop)
|
||||
require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14,foo-beta", got)
|
||||
require.NotContains(t, got, "context-1m-2025-08-07")
|
||||
require.NotContains(t, got, "fast-mode-2026-02-01")
|
||||
}
|
||||
|
||||
func TestDroppedBetaSet(t *testing.T) {
|
||||
// Base set contains DroppedBetas
|
||||
base := droppedBetaSet()
|
||||
require.Contains(t, base, claude.BetaContext1M)
|
||||
require.Contains(t, base, claude.BetaFastMode)
|
||||
require.Len(t, base, len(claude.DroppedBetas))
|
||||
|
||||
// With extra tokens
|
||||
extended := droppedBetaSet(claude.BetaClaudeCode)
|
||||
require.Contains(t, extended, claude.BetaContext1M)
|
||||
require.Contains(t, extended, claude.BetaFastMode)
|
||||
require.Contains(t, extended, claude.BetaClaudeCode)
|
||||
require.Len(t, extended, len(claude.DroppedBetas)+1)
|
||||
}
|
||||
|
||||
func TestBuildBetaTokenSet(t *testing.T) {
|
||||
got := buildBetaTokenSet([]string{"foo", "", "bar", "foo"})
|
||||
require.Len(t, got, 2)
|
||||
require.Contains(t, got, "foo")
|
||||
require.Contains(t, got, "bar")
|
||||
require.NotContains(t, got, "")
|
||||
|
||||
empty := buildBetaTokenSet(nil)
|
||||
require.Empty(t, empty)
|
||||
}
|
||||
|
||||
func TestStripBetaTokensWithSet_EmptyDropSet(t *testing.T) {
|
||||
header := "oauth-2025-04-20,interleaved-thinking-2025-05-14"
|
||||
got := stripBetaTokensWithSet(header, map[string]struct{}{})
|
||||
require.Equal(t, header, got)
|
||||
}
|
||||
|
||||
func TestIsCountTokensUnsupported404(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "exact endpoint not found",
|
||||
statusCode: 404,
|
||||
body: `{"error":{"message":"Not found: /v1/messages/count_tokens","type":"not_found_error"}}`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "contains count_tokens and not found",
|
||||
statusCode: 404,
|
||||
body: `{"error":{"message":"count_tokens route not found","type":"not_found_error"}}`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "generic 404",
|
||||
statusCode: 404,
|
||||
body: `{"error":{"message":"resource not found","type":"not_found_error"}}`,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "404 with empty error message",
|
||||
statusCode: 404,
|
||||
body: `{"error":{"message":"","type":"not_found_error"}}`,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non-404 status",
|
||||
statusCode: 400,
|
||||
body: `{"error":{"message":"Not found: /v1/messages/count_tokens","type":"invalid_request_error"}}`,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isCountTokensUnsupported404(tt.statusCode, []byte(tt.body))
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Part 1: isAccountInGroup 单元测试
|
||||
// ============================================================================
|
||||
|
||||
func TestIsAccountInGroup(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
groupID100 := int64(100)
|
||||
groupID200 := int64(200)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
groupID *int64
|
||||
expected bool
|
||||
}{
|
||||
// groupID == nil(无分组 API Key)
|
||||
{
|
||||
"nil_groupID_ungrouped_account_nil_groups",
|
||||
&Account{ID: 1, AccountGroups: nil},
|
||||
nil, true,
|
||||
},
|
||||
{
|
||||
"nil_groupID_ungrouped_account_empty_slice",
|
||||
&Account{ID: 2, AccountGroups: []AccountGroup{}},
|
||||
nil, true,
|
||||
},
|
||||
{
|
||||
"nil_groupID_grouped_account_single",
|
||||
&Account{ID: 3, AccountGroups: []AccountGroup{{GroupID: 100}}},
|
||||
nil, false,
|
||||
},
|
||||
{
|
||||
"nil_groupID_grouped_account_multiple",
|
||||
&Account{ID: 4, AccountGroups: []AccountGroup{{GroupID: 100}, {GroupID: 200}}},
|
||||
nil, false,
|
||||
},
|
||||
// groupID != nil(有分组 API Key)
|
||||
{
|
||||
"with_groupID_account_in_group",
|
||||
&Account{ID: 5, AccountGroups: []AccountGroup{{GroupID: 100}}},
|
||||
&groupID100, true,
|
||||
},
|
||||
{
|
||||
"with_groupID_account_not_in_group",
|
||||
&Account{ID: 6, AccountGroups: []AccountGroup{{GroupID: 200}}},
|
||||
&groupID100, false,
|
||||
},
|
||||
{
|
||||
"with_groupID_ungrouped_account",
|
||||
&Account{ID: 7, AccountGroups: nil},
|
||||
&groupID100, false,
|
||||
},
|
||||
{
|
||||
"with_groupID_multi_group_account_match_one",
|
||||
&Account{ID: 8, AccountGroups: []AccountGroup{{GroupID: 100}, {GroupID: 200}}},
|
||||
&groupID200, true,
|
||||
},
|
||||
{
|
||||
"with_groupID_multi_group_account_no_match",
|
||||
&Account{ID: 9, AccountGroups: []AccountGroup{{GroupID: 300}, {GroupID: 400}}},
|
||||
&groupID100, false,
|
||||
},
|
||||
// 防御性边界
|
||||
{
|
||||
"nil_account_nil_groupID",
|
||||
nil,
|
||||
nil, false,
|
||||
},
|
||||
{
|
||||
"nil_account_with_groupID",
|
||||
nil,
|
||||
&groupID100, false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := svc.isAccountInGroup(tt.account, tt.groupID)
|
||||
require.Equal(t, tt.expected, got, "isAccountInGroup 结果不符预期")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Part 2: 分组隔离端到端调度测试
|
||||
// ============================================================================
|
||||
|
||||
// groupAwareMockAccountRepo 嵌入 mockAccountRepoForPlatform,覆写分组隔离相关方法。
|
||||
// allAccounts 存储所有账号,分组查询方法按 AccountGroups 字段进行真实过滤。
|
||||
type groupAwareMockAccountRepo struct {
|
||||
*mockAccountRepoForPlatform
|
||||
allAccounts []Account
|
||||
}
|
||||
|
||||
// ListSchedulableUngroupedByPlatform 仅返回未分组账号(AccountGroups 为空)
|
||||
func (m *groupAwareMockAccountRepo) ListSchedulableUngroupedByPlatform(ctx context.Context, platform string) ([]Account, error) {
|
||||
var result []Account
|
||||
for _, acc := range m.allAccounts {
|
||||
if acc.Platform == platform && acc.IsSchedulable() && len(acc.AccountGroups) == 0 {
|
||||
result = append(result, acc)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListSchedulableUngroupedByPlatforms 仅返回未分组账号(多平台版本)
|
||||
func (m *groupAwareMockAccountRepo) ListSchedulableUngroupedByPlatforms(ctx context.Context, platforms []string) ([]Account, error) {
|
||||
platformSet := make(map[string]bool, len(platforms))
|
||||
for _, p := range platforms {
|
||||
platformSet[p] = true
|
||||
}
|
||||
var result []Account
|
||||
for _, acc := range m.allAccounts {
|
||||
if platformSet[acc.Platform] && acc.IsSchedulable() && len(acc.AccountGroups) == 0 {
|
||||
result = append(result, acc)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListSchedulableByGroupIDAndPlatform 返回属于指定分组的账号
|
||||
func (m *groupAwareMockAccountRepo) ListSchedulableByGroupIDAndPlatform(ctx context.Context, groupID int64, platform string) ([]Account, error) {
|
||||
var result []Account
|
||||
for _, acc := range m.allAccounts {
|
||||
if acc.Platform == platform && acc.IsSchedulable() && accountBelongsToGroup(acc, groupID) {
|
||||
result = append(result, acc)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListSchedulableByGroupIDAndPlatforms 返回属于指定分组的账号(多平台版本)
|
||||
func (m *groupAwareMockAccountRepo) ListSchedulableByGroupIDAndPlatforms(ctx context.Context, groupID int64, platforms []string) ([]Account, error) {
|
||||
platformSet := make(map[string]bool, len(platforms))
|
||||
for _, p := range platforms {
|
||||
platformSet[p] = true
|
||||
}
|
||||
var result []Account
|
||||
for _, acc := range m.allAccounts {
|
||||
if platformSet[acc.Platform] && acc.IsSchedulable() && accountBelongsToGroup(acc, groupID) {
|
||||
result = append(result, acc)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// accountBelongsToGroup 检查账号是否属于指定分组
|
||||
func accountBelongsToGroup(acc Account, groupID int64) bool {
|
||||
for _, ag := range acc.AccountGroups {
|
||||
if ag.GroupID == groupID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify interface implementation
|
||||
var _ AccountRepository = (*groupAwareMockAccountRepo)(nil)
|
||||
|
||||
// newGroupAwareMockRepo 创建分组感知的 mock repo
|
||||
func newGroupAwareMockRepo(accounts []Account) *groupAwareMockAccountRepo {
|
||||
byID := make(map[int64]*Account, len(accounts))
|
||||
for i := range accounts {
|
||||
byID[accounts[i].ID] = &accounts[i]
|
||||
}
|
||||
return &groupAwareMockAccountRepo{
|
||||
mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
|
||||
accounts: accounts,
|
||||
accountsByID: byID,
|
||||
},
|
||||
allAccounts: accounts,
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupIsolation_UngroupedKey_ShouldNotScheduleGroupedAccounts(t *testing.T) {
|
||||
// 场景:无分组 API Key(groupID=nil),池中只有已分组账号 → 应返回错误
|
||||
ctx := context.Background()
|
||||
|
||||
accounts := []Account{
|
||||
{ID: 1, Platform: PlatformOpenAI, Priority: 1, Status: StatusActive, Schedulable: true,
|
||||
AccountGroups: []AccountGroup{{GroupID: 100}}},
|
||||
{ID: 2, Platform: PlatformOpenAI, Priority: 2, Status: StatusActive, Schedulable: true,
|
||||
AccountGroups: []AccountGroup{{GroupID: 200}}},
|
||||
}
|
||||
repo := newGroupAwareMockRepo(accounts)
|
||||
cache := &mockGatewayCacheForPlatform{}
|
||||
|
||||
svc := &GatewayService{
|
||||
accountRepo: repo,
|
||||
cache: cache,
|
||||
cfg: testConfig(),
|
||||
}
|
||||
|
||||
acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "", "", nil, PlatformOpenAI)
|
||||
require.Error(t, err, "无分组 Key 不应调度到已分组账号")
|
||||
require.Nil(t, acc)
|
||||
}
|
||||
|
||||
func TestGroupIsolation_GroupedKey_ShouldNotScheduleUngroupedAccounts(t *testing.T) {
|
||||
// 场景:有分组 API Key(groupID=100),池中只有未分组账号 → 应返回错误
|
||||
ctx := context.Background()
|
||||
groupID := int64(100)
|
||||
|
||||
accounts := []Account{
|
||||
{ID: 1, Platform: PlatformOpenAI, Priority: 1, Status: StatusActive, Schedulable: true,
|
||||
AccountGroups: nil},
|
||||
{ID: 2, Platform: PlatformOpenAI, Priority: 2, Status: StatusActive, Schedulable: true,
|
||||
AccountGroups: []AccountGroup{}},
|
||||
}
|
||||
repo := newGroupAwareMockRepo(accounts)
|
||||
cache := &mockGatewayCacheForPlatform{}
|
||||
|
||||
svc := &GatewayService{
|
||||
accountRepo: repo,
|
||||
cache: cache,
|
||||
cfg: testConfig(),
|
||||
}
|
||||
|
||||
acc, err := svc.selectAccountForModelWithPlatform(ctx, &groupID, "", "", nil, PlatformOpenAI)
|
||||
require.Error(t, err, "有分组 Key 不应调度到未分组账号")
|
||||
require.Nil(t, acc)
|
||||
}
|
||||
|
||||
func TestGroupIsolation_UngroupedKey_ShouldOnlyScheduleUngroupedAccounts(t *testing.T) {
|
||||
// 场景:无分组 API Key(groupID=nil),池中有未分组和已分组账号 → 应只选中未分组的
|
||||
ctx := context.Background()
|
||||
|
||||
accounts := []Account{
|
||||
{ID: 1, Platform: PlatformOpenAI, Priority: 1, Status: StatusActive, Schedulable: true,
|
||||
AccountGroups: []AccountGroup{{GroupID: 100}}}, // 已分组,不应被选中
|
||||
{ID: 2, Platform: PlatformOpenAI, Priority: 2, Status: StatusActive, Schedulable: true,
|
||||
AccountGroups: nil}, // 未分组,应被选中
|
||||
{ID: 3, Platform: PlatformOpenAI, Priority: 3, Status: StatusActive, Schedulable: true,
|
||||
AccountGroups: []AccountGroup{{GroupID: 200}}}, // 已分组,不应被选中
|
||||
}
|
||||
repo := newGroupAwareMockRepo(accounts)
|
||||
cache := &mockGatewayCacheForPlatform{}
|
||||
|
||||
svc := &GatewayService{
|
||||
accountRepo: repo,
|
||||
cache: cache,
|
||||
cfg: testConfig(),
|
||||
}
|
||||
|
||||
acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "", "", nil, PlatformOpenAI)
|
||||
require.NoError(t, err, "应成功调度未分组账号")
|
||||
require.NotNil(t, acc)
|
||||
require.Equal(t, int64(2), acc.ID, "应选中未分组的账号 ID=2")
|
||||
}
|
||||
|
||||
func TestGroupIsolation_GroupedKey_ShouldOnlyScheduleMatchingGroupAccounts(t *testing.T) {
|
||||
// 场景:有分组 API Key(groupID=100),池中有未分组和多个分组账号 → 应只选中分组 100 内的
|
||||
ctx := context.Background()
|
||||
groupID := int64(100)
|
||||
|
||||
accounts := []Account{
|
||||
{ID: 1, Platform: PlatformOpenAI, Priority: 1, Status: StatusActive, Schedulable: true,
|
||||
AccountGroups: nil}, // 未分组,不应被选中
|
||||
{ID: 2, Platform: PlatformOpenAI, Priority: 2, Status: StatusActive, Schedulable: true,
|
||||
AccountGroups: []AccountGroup{{GroupID: 200}}}, // 属于分组 200,不应被选中
|
||||
{ID: 3, Platform: PlatformOpenAI, Priority: 3, Status: StatusActive, Schedulable: true,
|
||||
AccountGroups: []AccountGroup{{GroupID: 100}}}, // 属于分组 100,应被选中
|
||||
}
|
||||
repo := newGroupAwareMockRepo(accounts)
|
||||
cache := &mockGatewayCacheForPlatform{}
|
||||
|
||||
svc := &GatewayService{
|
||||
accountRepo: repo,
|
||||
cache: cache,
|
||||
cfg: testConfig(),
|
||||
}
|
||||
|
||||
acc, err := svc.selectAccountForModelWithPlatform(ctx, &groupID, "", "", nil, PlatformOpenAI)
|
||||
require.NoError(t, err, "应成功调度分组内账号")
|
||||
require.NotNil(t, acc)
|
||||
require.Equal(t, int64(3), acc.ID, "应选中分组 100 内的账号 ID=3")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Part 3: SimpleMode 旁路测试
|
||||
// ============================================================================
|
||||
|
||||
func TestGroupIsolation_SimpleMode_SkipsGroupIsolation(t *testing.T) {
|
||||
// SimpleMode 应跳过分组隔离,使用 ListSchedulableByPlatform 返回所有账号。
|
||||
// 测试非 useMixed 路径(platform=openai,不会触发 mixed 调度逻辑)。
|
||||
ctx := context.Background()
|
||||
|
||||
// 混合未分组和已分组账号,SimpleMode 下应全部可调度
|
||||
accounts := []Account{
|
||||
{ID: 1, Platform: PlatformOpenAI, Priority: 2, Status: StatusActive, Schedulable: true,
|
||||
AccountGroups: []AccountGroup{{GroupID: 100}}}, // 已分组
|
||||
{ID: 2, Platform: PlatformOpenAI, Priority: 1, Status: StatusActive, Schedulable: true,
|
||||
AccountGroups: nil}, // 未分组
|
||||
}
|
||||
|
||||
// 使用基础 mock(ListSchedulableByPlatform 返回所有匹配平台的账号,不做分组过滤)
|
||||
byID := make(map[int64]*Account, len(accounts))
|
||||
for i := range accounts {
|
||||
byID[accounts[i].ID] = &accounts[i]
|
||||
}
|
||||
repo := &mockAccountRepoForPlatform{
|
||||
accounts: accounts,
|
||||
accountsByID: byID,
|
||||
}
|
||||
cache := &mockGatewayCacheForPlatform{}
|
||||
|
||||
svc := &GatewayService{
|
||||
accountRepo: repo,
|
||||
cache: cache,
|
||||
cfg: &config.Config{RunMode: config.RunModeSimple},
|
||||
}
|
||||
|
||||
// groupID=nil 时,SimpleMode 应使用 ListSchedulableByPlatform(不过滤分组)
|
||||
acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "", "", nil, PlatformOpenAI)
|
||||
require.NoError(t, err, "SimpleMode 应跳过分组隔离直接返回账号")
|
||||
require.NotNil(t, acc)
|
||||
// 应选择优先级最高的账号(Priority=1, ID=2),即使它未分组
|
||||
require.Equal(t, int64(2), acc.ID, "SimpleMode 应按优先级选择,不考虑分组")
|
||||
}
|
||||
|
||||
func TestGroupIsolation_SimpleMode_GroupedAccountAlsoSchedulable(t *testing.T) {
|
||||
// SimpleMode + groupID=nil 时,已分组账号也应该可被调度
|
||||
ctx := context.Background()
|
||||
|
||||
// 只有已分组账号,在 standard 模式下 groupID=nil 会报错,但 simple 模式应正常
|
||||
accounts := []Account{
|
||||
{ID: 1, Platform: PlatformOpenAI, Priority: 1, Status: StatusActive, Schedulable: true,
|
||||
AccountGroups: []AccountGroup{{GroupID: 100}}},
|
||||
}
|
||||
|
||||
byID := make(map[int64]*Account, len(accounts))
|
||||
for i := range accounts {
|
||||
byID[accounts[i].ID] = &accounts[i]
|
||||
}
|
||||
repo := &mockAccountRepoForPlatform{
|
||||
accounts: accounts,
|
||||
accountsByID: byID,
|
||||
}
|
||||
cache := &mockGatewayCacheForPlatform{}
|
||||
|
||||
svc := &GatewayService{
|
||||
accountRepo: repo,
|
||||
cache: cache,
|
||||
cfg: &config.Config{RunMode: config.RunModeSimple},
|
||||
}
|
||||
|
||||
acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "", "", nil, PlatformOpenAI)
|
||||
require.NoError(t, err, "SimpleMode 下已分组账号也应可调度")
|
||||
require.NotNil(t, acc)
|
||||
require.Equal(t, int64(1), acc.ID, "SimpleMode 应能调度已分组账号")
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||
gocache "github.com/patrickmn/go-cache"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type userGroupRateRepoHotpathStub struct {
|
||||
UserGroupRateRepository
|
||||
|
||||
rate *float64
|
||||
err error
|
||||
wait <-chan struct{}
|
||||
calls atomic.Int64
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoHotpathStub) GetByUserAndGroup(ctx context.Context, userID, groupID int64) (*float64, error) {
|
||||
s.calls.Add(1)
|
||||
if s.wait != nil {
|
||||
<-s.wait
|
||||
}
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return s.rate, nil
|
||||
}
|
||||
|
||||
type usageLogWindowBatchRepoStub struct {
|
||||
UsageLogRepository
|
||||
|
||||
batchResult map[int64]*usagestats.AccountStats
|
||||
batchErr error
|
||||
batchCalls atomic.Int64
|
||||
|
||||
singleResult map[int64]*usagestats.AccountStats
|
||||
singleErr error
|
||||
singleCalls atomic.Int64
|
||||
}
|
||||
|
||||
func (s *usageLogWindowBatchRepoStub) GetAccountWindowStatsBatch(ctx context.Context, accountIDs []int64, startTime time.Time) (map[int64]*usagestats.AccountStats, error) {
|
||||
s.batchCalls.Add(1)
|
||||
if s.batchErr != nil {
|
||||
return nil, s.batchErr
|
||||
}
|
||||
out := make(map[int64]*usagestats.AccountStats, len(accountIDs))
|
||||
for _, id := range accountIDs {
|
||||
if stats, ok := s.batchResult[id]; ok {
|
||||
out[id] = stats
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *usageLogWindowBatchRepoStub) GetAccountWindowStats(ctx context.Context, accountID int64, startTime time.Time) (*usagestats.AccountStats, error) {
|
||||
s.singleCalls.Add(1)
|
||||
if s.singleErr != nil {
|
||||
return nil, s.singleErr
|
||||
}
|
||||
if stats, ok := s.singleResult[accountID]; ok {
|
||||
return stats, nil
|
||||
}
|
||||
return &usagestats.AccountStats{}, nil
|
||||
}
|
||||
|
||||
type sessionLimitCacheHotpathStub struct {
|
||||
SessionLimitCache
|
||||
|
||||
batchData map[int64]float64
|
||||
batchErr error
|
||||
|
||||
setData map[int64]float64
|
||||
setErr error
|
||||
}
|
||||
|
||||
func (s *sessionLimitCacheHotpathStub) GetWindowCostBatch(ctx context.Context, accountIDs []int64) (map[int64]float64, error) {
|
||||
if s.batchErr != nil {
|
||||
return nil, s.batchErr
|
||||
}
|
||||
out := make(map[int64]float64, len(accountIDs))
|
||||
for _, id := range accountIDs {
|
||||
if v, ok := s.batchData[id]; ok {
|
||||
out[id] = v
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *sessionLimitCacheHotpathStub) SetWindowCost(ctx context.Context, accountID int64, cost float64) error {
|
||||
if s.setErr != nil {
|
||||
return s.setErr
|
||||
}
|
||||
if s.setData == nil {
|
||||
s.setData = make(map[int64]float64)
|
||||
}
|
||||
s.setData[accountID] = cost
|
||||
return nil
|
||||
}
|
||||
|
||||
type modelsListAccountRepoStub struct {
|
||||
AccountRepository
|
||||
|
||||
byGroup map[int64][]Account
|
||||
all []Account
|
||||
err error
|
||||
|
||||
listByGroupCalls atomic.Int64
|
||||
listAllCalls atomic.Int64
|
||||
}
|
||||
|
||||
type stickyGatewayCacheHotpathStub struct {
|
||||
GatewayCache
|
||||
|
||||
stickyID int64
|
||||
getCalls atomic.Int64
|
||||
}
|
||||
|
||||
func (s *stickyGatewayCacheHotpathStub) GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error) {
|
||||
s.getCalls.Add(1)
|
||||
if s.stickyID > 0 {
|
||||
return s.stickyID, nil
|
||||
}
|
||||
return 0, errors.New("not found")
|
||||
}
|
||||
|
||||
func (s *stickyGatewayCacheHotpathStub) SetSessionAccountID(ctx context.Context, groupID int64, sessionHash string, accountID int64, ttl time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stickyGatewayCacheHotpathStub) RefreshSessionTTL(ctx context.Context, groupID int64, sessionHash string, ttl time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stickyGatewayCacheHotpathStub) DeleteSessionAccountID(ctx context.Context, groupID int64, sessionHash string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *modelsListAccountRepoStub) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]Account, error) {
|
||||
s.listByGroupCalls.Add(1)
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
accounts, ok := s.byGroup[groupID]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]Account, len(accounts))
|
||||
copy(out, accounts)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *modelsListAccountRepoStub) ListSchedulable(ctx context.Context) ([]Account, error) {
|
||||
s.listAllCalls.Add(1)
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
out := make([]Account, len(s.all))
|
||||
copy(out, s.all)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func resetGatewayHotpathStatsForTest() {
|
||||
windowCostPrefetchCacheHitTotal.Store(0)
|
||||
windowCostPrefetchCacheMissTotal.Store(0)
|
||||
windowCostPrefetchBatchSQLTotal.Store(0)
|
||||
windowCostPrefetchFallbackTotal.Store(0)
|
||||
windowCostPrefetchErrorTotal.Store(0)
|
||||
|
||||
userGroupRateCacheHitTotal.Store(0)
|
||||
userGroupRateCacheMissTotal.Store(0)
|
||||
userGroupRateCacheLoadTotal.Store(0)
|
||||
userGroupRateCacheSFSharedTotal.Store(0)
|
||||
userGroupRateCacheFallbackTotal.Store(0)
|
||||
|
||||
modelsListCacheHitTotal.Store(0)
|
||||
modelsListCacheMissTotal.Store(0)
|
||||
modelsListCacheStoreTotal.Store(0)
|
||||
}
|
||||
|
||||
func TestGetUserGroupRateMultiplier_UsesCacheAndSingleflight(t *testing.T) {
|
||||
resetGatewayHotpathStatsForTest()
|
||||
|
||||
rate := 1.7
|
||||
unblock := make(chan struct{})
|
||||
repo := &userGroupRateRepoHotpathStub{
|
||||
rate: &rate,
|
||||
wait: unblock,
|
||||
}
|
||||
svc := &GatewayService{
|
||||
userGroupRateRepo: repo,
|
||||
userGroupRateCache: gocache.New(time.Minute, time.Minute),
|
||||
cfg: &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
UserGroupRateCacheTTLSeconds: 30,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const concurrent = 12
|
||||
results := make([]float64, concurrent)
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(concurrent)
|
||||
for i := 0; i < concurrent; i++ {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
results[idx] = svc.getUserGroupRateMultiplier(context.Background(), 101, 202, 1.2)
|
||||
}(i)
|
||||
}
|
||||
|
||||
close(start)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
close(unblock)
|
||||
wg.Wait()
|
||||
|
||||
for _, got := range results {
|
||||
require.Equal(t, rate, got)
|
||||
}
|
||||
require.Equal(t, int64(1), repo.calls.Load())
|
||||
|
||||
// 再次读取应命中缓存,不再回源。
|
||||
got := svc.getUserGroupRateMultiplier(context.Background(), 101, 202, 1.2)
|
||||
require.Equal(t, rate, got)
|
||||
require.Equal(t, int64(1), repo.calls.Load())
|
||||
|
||||
hit, miss, load, sfShared, fallback := GatewayUserGroupRateCacheStats()
|
||||
require.GreaterOrEqual(t, hit, int64(1))
|
||||
require.Equal(t, int64(12), miss)
|
||||
require.Equal(t, int64(1), load)
|
||||
require.GreaterOrEqual(t, sfShared, int64(1))
|
||||
require.Equal(t, int64(0), fallback)
|
||||
}
|
||||
|
||||
func TestGetUserGroupRateMultiplier_FallbackOnRepoError(t *testing.T) {
|
||||
resetGatewayHotpathStatsForTest()
|
||||
|
||||
repo := &userGroupRateRepoHotpathStub{
|
||||
err: errors.New("db down"),
|
||||
}
|
||||
svc := &GatewayService{
|
||||
userGroupRateRepo: repo,
|
||||
userGroupRateCache: gocache.New(time.Minute, time.Minute),
|
||||
cfg: &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
UserGroupRateCacheTTLSeconds: 30,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := svc.getUserGroupRateMultiplier(context.Background(), 101, 202, 1.25)
|
||||
require.Equal(t, 1.25, got)
|
||||
require.Equal(t, int64(1), repo.calls.Load())
|
||||
|
||||
_, _, _, _, fallback := GatewayUserGroupRateCacheStats()
|
||||
require.Equal(t, int64(1), fallback)
|
||||
}
|
||||
|
||||
func TestGetUserGroupRateMultiplier_CacheHitAndNilRepo(t *testing.T) {
|
||||
resetGatewayHotpathStatsForTest()
|
||||
|
||||
repo := &userGroupRateRepoHotpathStub{
|
||||
err: errors.New("should not be called"),
|
||||
}
|
||||
svc := &GatewayService{
|
||||
userGroupRateRepo: repo,
|
||||
userGroupRateCache: gocache.New(time.Minute, time.Minute),
|
||||
}
|
||||
key := "101:202"
|
||||
svc.userGroupRateCache.Set(key, 2.3, time.Minute)
|
||||
|
||||
got := svc.getUserGroupRateMultiplier(context.Background(), 101, 202, 1.1)
|
||||
require.Equal(t, 2.3, got)
|
||||
|
||||
hit, miss, load, _, fallback := GatewayUserGroupRateCacheStats()
|
||||
require.Equal(t, int64(1), hit)
|
||||
require.Equal(t, int64(0), miss)
|
||||
require.Equal(t, int64(0), load)
|
||||
require.Equal(t, int64(0), fallback)
|
||||
require.Equal(t, int64(0), repo.calls.Load())
|
||||
|
||||
// 无 repo 时直接返回分组默认倍率
|
||||
svc2 := &GatewayService{
|
||||
userGroupRateCache: gocache.New(time.Minute, time.Minute),
|
||||
}
|
||||
svc2.userGroupRateCache.Set(key, 1.9, time.Minute)
|
||||
require.Equal(t, 1.9, svc2.getUserGroupRateMultiplier(context.Background(), 101, 202, 1.4))
|
||||
require.Equal(t, 1.4, svc2.getUserGroupRateMultiplier(context.Background(), 0, 202, 1.4))
|
||||
svc2.userGroupRateCache.Delete(key)
|
||||
require.Equal(t, 1.4, svc2.getUserGroupRateMultiplier(context.Background(), 101, 202, 1.4))
|
||||
}
|
||||
|
||||
func TestWithWindowCostPrefetch_BatchReadAndContextReuse(t *testing.T) {
|
||||
resetGatewayHotpathStatsForTest()
|
||||
|
||||
windowStart := time.Now().Add(-30 * time.Minute).Truncate(time.Hour)
|
||||
windowEnd := windowStart.Add(5 * time.Hour)
|
||||
accounts := []Account{
|
||||
{
|
||||
ID: 1,
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"window_cost_limit": 100.0},
|
||||
SessionWindowStart: &windowStart,
|
||||
SessionWindowEnd: &windowEnd,
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeSetupToken,
|
||||
Extra: map[string]any{"window_cost_limit": 100.0},
|
||||
SessionWindowStart: &windowStart,
|
||||
SessionWindowEnd: &windowEnd,
|
||||
},
|
||||
{
|
||||
ID: 3,
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{"window_cost_limit": 100.0},
|
||||
},
|
||||
}
|
||||
|
||||
cache := &sessionLimitCacheHotpathStub{
|
||||
batchData: map[int64]float64{
|
||||
1: 11.0,
|
||||
},
|
||||
}
|
||||
repo := &usageLogWindowBatchRepoStub{
|
||||
batchResult: map[int64]*usagestats.AccountStats{
|
||||
2: {StandardCost: 22.0},
|
||||
},
|
||||
}
|
||||
svc := &GatewayService{
|
||||
sessionLimitCache: cache,
|
||||
usageLogRepo: repo,
|
||||
}
|
||||
|
||||
outCtx := svc.withWindowCostPrefetch(context.Background(), accounts)
|
||||
require.NotNil(t, outCtx)
|
||||
|
||||
cost1, ok1 := windowCostFromPrefetchContext(outCtx, 1)
|
||||
require.True(t, ok1)
|
||||
require.Equal(t, 11.0, cost1)
|
||||
|
||||
cost2, ok2 := windowCostFromPrefetchContext(outCtx, 2)
|
||||
require.True(t, ok2)
|
||||
require.Equal(t, 22.0, cost2)
|
||||
|
||||
_, ok3 := windowCostFromPrefetchContext(outCtx, 3)
|
||||
require.False(t, ok3)
|
||||
|
||||
require.Equal(t, int64(1), repo.batchCalls.Load())
|
||||
require.Equal(t, 22.0, cache.setData[2])
|
||||
|
||||
hit, miss, batchSQL, fallback, errCount := GatewayWindowCostPrefetchStats()
|
||||
require.Equal(t, int64(1), hit)
|
||||
require.Equal(t, int64(1), miss)
|
||||
require.Equal(t, int64(1), batchSQL)
|
||||
require.Equal(t, int64(0), fallback)
|
||||
require.Equal(t, int64(0), errCount)
|
||||
}
|
||||
|
||||
func TestWithWindowCostPrefetch_AllHitNoSQL(t *testing.T) {
|
||||
resetGatewayHotpathStatsForTest()
|
||||
|
||||
windowStart := time.Now().Add(-30 * time.Minute).Truncate(time.Hour)
|
||||
windowEnd := windowStart.Add(5 * time.Hour)
|
||||
accounts := []Account{
|
||||
{
|
||||
ID: 1,
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"window_cost_limit": 100.0},
|
||||
SessionWindowStart: &windowStart,
|
||||
SessionWindowEnd: &windowEnd,
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeSetupToken,
|
||||
Extra: map[string]any{"window_cost_limit": 100.0},
|
||||
SessionWindowStart: &windowStart,
|
||||
SessionWindowEnd: &windowEnd,
|
||||
},
|
||||
}
|
||||
|
||||
cache := &sessionLimitCacheHotpathStub{
|
||||
batchData: map[int64]float64{
|
||||
1: 11.0,
|
||||
2: 22.0,
|
||||
},
|
||||
}
|
||||
repo := &usageLogWindowBatchRepoStub{}
|
||||
svc := &GatewayService{
|
||||
sessionLimitCache: cache,
|
||||
usageLogRepo: repo,
|
||||
}
|
||||
|
||||
outCtx := svc.withWindowCostPrefetch(context.Background(), accounts)
|
||||
cost1, ok1 := windowCostFromPrefetchContext(outCtx, 1)
|
||||
cost2, ok2 := windowCostFromPrefetchContext(outCtx, 2)
|
||||
require.True(t, ok1)
|
||||
require.True(t, ok2)
|
||||
require.Equal(t, 11.0, cost1)
|
||||
require.Equal(t, 22.0, cost2)
|
||||
require.Equal(t, int64(0), repo.batchCalls.Load())
|
||||
require.Equal(t, int64(0), repo.singleCalls.Load())
|
||||
|
||||
hit, miss, batchSQL, fallback, errCount := GatewayWindowCostPrefetchStats()
|
||||
require.Equal(t, int64(2), hit)
|
||||
require.Equal(t, int64(0), miss)
|
||||
require.Equal(t, int64(0), batchSQL)
|
||||
require.Equal(t, int64(0), fallback)
|
||||
require.Equal(t, int64(0), errCount)
|
||||
}
|
||||
|
||||
func TestWithWindowCostPrefetch_BatchErrorFallbackSingleQuery(t *testing.T) {
|
||||
resetGatewayHotpathStatsForTest()
|
||||
|
||||
windowStart := time.Now().Add(-30 * time.Minute).Truncate(time.Hour)
|
||||
windowEnd := windowStart.Add(5 * time.Hour)
|
||||
accounts := []Account{
|
||||
{
|
||||
ID: 2,
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeSetupToken,
|
||||
Extra: map[string]any{"window_cost_limit": 100.0},
|
||||
SessionWindowStart: &windowStart,
|
||||
SessionWindowEnd: &windowEnd,
|
||||
},
|
||||
}
|
||||
|
||||
cache := &sessionLimitCacheHotpathStub{}
|
||||
repo := &usageLogWindowBatchRepoStub{
|
||||
batchErr: errors.New("batch failed"),
|
||||
singleResult: map[int64]*usagestats.AccountStats{
|
||||
2: {StandardCost: 33.0},
|
||||
},
|
||||
}
|
||||
svc := &GatewayService{
|
||||
sessionLimitCache: cache,
|
||||
usageLogRepo: repo,
|
||||
}
|
||||
|
||||
outCtx := svc.withWindowCostPrefetch(context.Background(), accounts)
|
||||
cost, ok := windowCostFromPrefetchContext(outCtx, 2)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 33.0, cost)
|
||||
require.Equal(t, int64(1), repo.batchCalls.Load())
|
||||
require.Equal(t, int64(1), repo.singleCalls.Load())
|
||||
|
||||
_, _, _, fallback, errCount := GatewayWindowCostPrefetchStats()
|
||||
require.Equal(t, int64(1), fallback)
|
||||
require.Equal(t, int64(1), errCount)
|
||||
}
|
||||
|
||||
func TestGetAvailableModels_UsesShortCacheAndSupportsInvalidation(t *testing.T) {
|
||||
resetGatewayHotpathStatsForTest()
|
||||
|
||||
groupID := int64(9)
|
||||
repo := &modelsListAccountRepoStub{
|
||||
byGroup: map[int64][]Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 1,
|
||||
Platform: PlatformAnthropic,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"claude-3-5-sonnet": "claude-3-5-sonnet",
|
||||
"claude-3-5-haiku": "claude-3-5-haiku",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Platform: PlatformGemini,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"gemini-2.5-pro": "gemini-2.5-pro",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc := &GatewayService{
|
||||
accountRepo: repo,
|
||||
modelsListCache: gocache.New(time.Minute, time.Minute),
|
||||
modelsListCacheTTL: time.Minute,
|
||||
}
|
||||
|
||||
models1 := svc.GetAvailableModels(context.Background(), &groupID, PlatformAnthropic)
|
||||
require.Equal(t, []string{"claude-3-5-haiku", "claude-3-5-sonnet"}, models1)
|
||||
require.Equal(t, int64(1), repo.listByGroupCalls.Load())
|
||||
|
||||
// TTL 内再次请求应命中缓存,不回源。
|
||||
models2 := svc.GetAvailableModels(context.Background(), &groupID, PlatformAnthropic)
|
||||
require.Equal(t, models1, models2)
|
||||
require.Equal(t, int64(1), repo.listByGroupCalls.Load())
|
||||
|
||||
// 更新仓储数据,但缓存未失效前应继续返回旧值。
|
||||
repo.byGroup[groupID] = []Account{
|
||||
{
|
||||
ID: 3,
|
||||
Platform: PlatformAnthropic,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"claude-3-7-sonnet": "claude-3-7-sonnet",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
models3 := svc.GetAvailableModels(context.Background(), &groupID, PlatformAnthropic)
|
||||
require.Equal(t, []string{"claude-3-5-haiku", "claude-3-5-sonnet"}, models3)
|
||||
require.Equal(t, int64(1), repo.listByGroupCalls.Load())
|
||||
|
||||
svc.InvalidateAvailableModelsCache(&groupID, PlatformAnthropic)
|
||||
models4 := svc.GetAvailableModels(context.Background(), &groupID, PlatformAnthropic)
|
||||
require.Equal(t, []string{"claude-3-7-sonnet"}, models4)
|
||||
require.Equal(t, int64(2), repo.listByGroupCalls.Load())
|
||||
|
||||
hit, miss, store := GatewayModelsListCacheStats()
|
||||
require.Equal(t, int64(2), hit)
|
||||
require.Equal(t, int64(2), miss)
|
||||
require.Equal(t, int64(2), store)
|
||||
}
|
||||
|
||||
func TestGetAvailableModels_ErrorAndGlobalListBranches(t *testing.T) {
|
||||
resetGatewayHotpathStatsForTest()
|
||||
|
||||
errRepo := &modelsListAccountRepoStub{
|
||||
err: errors.New("db error"),
|
||||
}
|
||||
svcErr := &GatewayService{
|
||||
accountRepo: errRepo,
|
||||
modelsListCache: gocache.New(time.Minute, time.Minute),
|
||||
modelsListCacheTTL: time.Minute,
|
||||
}
|
||||
require.Nil(t, svcErr.GetAvailableModels(context.Background(), nil, ""))
|
||||
|
||||
okRepo := &modelsListAccountRepoStub{
|
||||
all: []Account{
|
||||
{
|
||||
ID: 1,
|
||||
Platform: PlatformAnthropic,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"claude-3-5-sonnet": "claude-3-5-sonnet",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Platform: PlatformGemini,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"gemini-2.5-pro": "gemini-2.5-pro",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
svcOK := &GatewayService{
|
||||
accountRepo: okRepo,
|
||||
modelsListCache: gocache.New(time.Minute, time.Minute),
|
||||
modelsListCacheTTL: time.Minute,
|
||||
}
|
||||
models := svcOK.GetAvailableModels(context.Background(), nil, "")
|
||||
require.Equal(t, []string{"claude-3-5-sonnet", "gemini-2.5-pro"}, models)
|
||||
require.Equal(t, int64(1), okRepo.listAllCalls.Load())
|
||||
}
|
||||
|
||||
func TestGatewayHotpathHelpers_CacheTTLAndStickyContext(t *testing.T) {
|
||||
t.Run("resolve_user_group_rate_cache_ttl", func(t *testing.T) {
|
||||
require.Equal(t, defaultUserGroupRateCacheTTL, resolveUserGroupRateCacheTTL(nil))
|
||||
|
||||
cfg := &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
UserGroupRateCacheTTLSeconds: 45,
|
||||
},
|
||||
}
|
||||
require.Equal(t, 45*time.Second, resolveUserGroupRateCacheTTL(cfg))
|
||||
})
|
||||
|
||||
t.Run("resolve_models_list_cache_ttl", func(t *testing.T) {
|
||||
require.Equal(t, defaultModelsListCacheTTL, resolveModelsListCacheTTL(nil))
|
||||
|
||||
cfg := &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
ModelsListCacheTTLSeconds: 20,
|
||||
},
|
||||
}
|
||||
require.Equal(t, 20*time.Second, resolveModelsListCacheTTL(cfg))
|
||||
})
|
||||
|
||||
t.Run("prefetched_sticky_account_id_from_context", func(t *testing.T) {
|
||||
require.Equal(t, int64(0), prefetchedStickyAccountIDFromContext(context.TODO(), nil))
|
||||
require.Equal(t, int64(0), prefetchedStickyAccountIDFromContext(context.Background(), nil))
|
||||
|
||||
ctx := context.WithValue(context.Background(), ctxkey.PrefetchedStickyAccountID, int64(123))
|
||||
ctx = context.WithValue(ctx, ctxkey.PrefetchedStickyGroupID, int64(0))
|
||||
require.Equal(t, int64(123), prefetchedStickyAccountIDFromContext(ctx, nil))
|
||||
|
||||
groupID := int64(9)
|
||||
ctx2 := context.WithValue(context.Background(), ctxkey.PrefetchedStickyAccountID, 456)
|
||||
ctx2 = context.WithValue(ctx2, ctxkey.PrefetchedStickyGroupID, groupID)
|
||||
require.Equal(t, int64(456), prefetchedStickyAccountIDFromContext(ctx2, &groupID))
|
||||
|
||||
ctx3 := context.WithValue(context.Background(), ctxkey.PrefetchedStickyAccountID, "invalid")
|
||||
ctx3 = context.WithValue(ctx3, ctxkey.PrefetchedStickyGroupID, groupID)
|
||||
require.Equal(t, int64(0), prefetchedStickyAccountIDFromContext(ctx3, &groupID))
|
||||
|
||||
ctx4 := context.WithValue(context.Background(), ctxkey.PrefetchedStickyAccountID, int64(789))
|
||||
ctx4 = context.WithValue(ctx4, ctxkey.PrefetchedStickyGroupID, int64(10))
|
||||
require.Equal(t, int64(0), prefetchedStickyAccountIDFromContext(ctx4, &groupID))
|
||||
})
|
||||
|
||||
t.Run("window_cost_from_prefetch_context", func(t *testing.T) {
|
||||
require.Equal(t, false, func() bool {
|
||||
_, ok := windowCostFromPrefetchContext(context.TODO(), 0)
|
||||
return ok
|
||||
}())
|
||||
require.Equal(t, false, func() bool {
|
||||
_, ok := windowCostFromPrefetchContext(context.Background(), 1)
|
||||
return ok
|
||||
}())
|
||||
|
||||
ctx := context.WithValue(context.Background(), windowCostPrefetchContextKey, map[int64]float64{
|
||||
9: 12.34,
|
||||
})
|
||||
cost, ok := windowCostFromPrefetchContext(ctx, 9)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 12.34, cost)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInvalidateAvailableModelsCache_ByDimensions(t *testing.T) {
|
||||
svc := &GatewayService{
|
||||
modelsListCache: gocache.New(time.Minute, time.Minute),
|
||||
}
|
||||
group9 := int64(9)
|
||||
group10 := int64(10)
|
||||
svc.modelsListCache.Set(modelsListCacheKey(&group9, PlatformAnthropic), []string{"a"}, time.Minute)
|
||||
svc.modelsListCache.Set(modelsListCacheKey(&group9, PlatformGemini), []string{"b"}, time.Minute)
|
||||
svc.modelsListCache.Set(modelsListCacheKey(&group10, PlatformAnthropic), []string{"c"}, time.Minute)
|
||||
svc.modelsListCache.Set("invalid-key", []string{"d"}, time.Minute)
|
||||
|
||||
t.Run("invalidate_group_and_platform", func(t *testing.T) {
|
||||
svc.InvalidateAvailableModelsCache(&group9, PlatformAnthropic)
|
||||
_, found := svc.modelsListCache.Get(modelsListCacheKey(&group9, PlatformAnthropic))
|
||||
require.False(t, found)
|
||||
_, stillFound := svc.modelsListCache.Get(modelsListCacheKey(&group9, PlatformGemini))
|
||||
require.True(t, stillFound)
|
||||
})
|
||||
|
||||
t.Run("invalidate_group_only", func(t *testing.T) {
|
||||
svc.InvalidateAvailableModelsCache(&group9, "")
|
||||
_, foundA := svc.modelsListCache.Get(modelsListCacheKey(&group9, PlatformAnthropic))
|
||||
_, foundB := svc.modelsListCache.Get(modelsListCacheKey(&group9, PlatformGemini))
|
||||
require.False(t, foundA)
|
||||
require.False(t, foundB)
|
||||
_, foundOtherGroup := svc.modelsListCache.Get(modelsListCacheKey(&group10, PlatformAnthropic))
|
||||
require.True(t, foundOtherGroup)
|
||||
})
|
||||
|
||||
t.Run("invalidate_platform_only", func(t *testing.T) {
|
||||
// 重建数据后仅按 platform 失效
|
||||
svc.modelsListCache.Set(modelsListCacheKey(&group9, PlatformAnthropic), []string{"a"}, time.Minute)
|
||||
svc.modelsListCache.Set(modelsListCacheKey(&group9, PlatformGemini), []string{"b"}, time.Minute)
|
||||
svc.modelsListCache.Set(modelsListCacheKey(&group10, PlatformAnthropic), []string{"c"}, time.Minute)
|
||||
|
||||
svc.InvalidateAvailableModelsCache(nil, PlatformAnthropic)
|
||||
_, found9Anthropic := svc.modelsListCache.Get(modelsListCacheKey(&group9, PlatformAnthropic))
|
||||
_, found10Anthropic := svc.modelsListCache.Get(modelsListCacheKey(&group10, PlatformAnthropic))
|
||||
_, found9Gemini := svc.modelsListCache.Get(modelsListCacheKey(&group9, PlatformGemini))
|
||||
require.False(t, found9Anthropic)
|
||||
require.False(t, found10Anthropic)
|
||||
require.True(t, found9Gemini)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSelectAccountWithLoadAwareness_StickyReadReuse(t *testing.T) {
|
||||
now := time.Now().Add(-time.Minute)
|
||||
account := Account{
|
||||
ID: 88,
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 4,
|
||||
Priority: 1,
|
||||
LastUsedAt: &now,
|
||||
}
|
||||
|
||||
repo := stubOpenAIAccountRepo{accounts: []Account{account}}
|
||||
concurrency := NewConcurrencyService(stubConcurrencyCache{})
|
||||
|
||||
cfg := &config.Config{
|
||||
RunMode: config.RunModeStandard,
|
||||
Gateway: config.GatewayConfig{
|
||||
Scheduling: config.GatewaySchedulingConfig{
|
||||
LoadBatchEnabled: true,
|
||||
StickySessionMaxWaiting: 3,
|
||||
StickySessionWaitTimeout: time.Second,
|
||||
FallbackWaitTimeout: time.Second,
|
||||
FallbackMaxWaiting: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
baseCtx := context.WithValue(context.Background(), ctxkey.ForcePlatform, PlatformAnthropic)
|
||||
|
||||
t.Run("without_prefetch_reads_cache_once", func(t *testing.T) {
|
||||
cache := &stickyGatewayCacheHotpathStub{stickyID: account.ID}
|
||||
svc := &GatewayService{
|
||||
accountRepo: repo,
|
||||
cache: cache,
|
||||
cfg: cfg,
|
||||
concurrencyService: concurrency,
|
||||
userGroupRateCache: gocache.New(time.Minute, time.Minute),
|
||||
modelsListCache: gocache.New(time.Minute, time.Minute),
|
||||
modelsListCacheTTL: time.Minute,
|
||||
}
|
||||
|
||||
result, err := svc.SelectAccountWithLoadAwareness(baseCtx, nil, "sess-hash", "", nil, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Account)
|
||||
require.Equal(t, account.ID, result.Account.ID)
|
||||
require.Equal(t, int64(1), cache.getCalls.Load())
|
||||
})
|
||||
|
||||
t.Run("with_prefetch_skips_cache_read", func(t *testing.T) {
|
||||
cache := &stickyGatewayCacheHotpathStub{stickyID: account.ID}
|
||||
svc := &GatewayService{
|
||||
accountRepo: repo,
|
||||
cache: cache,
|
||||
cfg: cfg,
|
||||
concurrencyService: concurrency,
|
||||
userGroupRateCache: gocache.New(time.Minute, time.Minute),
|
||||
modelsListCache: gocache.New(time.Minute, time.Minute),
|
||||
modelsListCacheTTL: time.Minute,
|
||||
}
|
||||
|
||||
ctx := context.WithValue(baseCtx, ctxkey.PrefetchedStickyAccountID, account.ID)
|
||||
ctx = context.WithValue(ctx, ctxkey.PrefetchedStickyGroupID, int64(0))
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sess-hash", "", nil, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Account)
|
||||
require.Equal(t, account.ID, result.Account.ID)
|
||||
require.Equal(t, int64(0), cache.getCalls.Load())
|
||||
})
|
||||
|
||||
t.Run("with_prefetch_group_mismatch_reads_cache", func(t *testing.T) {
|
||||
cache := &stickyGatewayCacheHotpathStub{stickyID: account.ID}
|
||||
svc := &GatewayService{
|
||||
accountRepo: repo,
|
||||
cache: cache,
|
||||
cfg: cfg,
|
||||
concurrencyService: concurrency,
|
||||
userGroupRateCache: gocache.New(time.Minute, time.Minute),
|
||||
modelsListCache: gocache.New(time.Minute, time.Minute),
|
||||
modelsListCacheTTL: time.Minute,
|
||||
}
|
||||
|
||||
ctx := context.WithValue(baseCtx, ctxkey.PrefetchedStickyAccountID, int64(999))
|
||||
ctx = context.WithValue(ctx, ctxkey.PrefetchedStickyGroupID, int64(77))
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sess-hash", "", nil, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Account)
|
||||
require.Equal(t, account.ID, result.Account.ID)
|
||||
require.Equal(t, int64(1), cache.getCalls.Load())
|
||||
})
|
||||
}
|
||||
@@ -77,6 +77,11 @@ func (m *mockAccountRepoForPlatform) Create(ctx context.Context, account *Accoun
|
||||
func (m *mockAccountRepoForPlatform) GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockAccountRepoForPlatform) FindByExtraField(ctx context.Context, key string, value any) ([]Account, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockAccountRepoForPlatform) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -142,6 +147,12 @@ func (m *mockAccountRepoForPlatform) ListSchedulableByPlatforms(ctx context.Cont
|
||||
func (m *mockAccountRepoForPlatform) ListSchedulableByGroupIDAndPlatforms(ctx context.Context, groupID int64, platforms []string) ([]Account, error) {
|
||||
return m.ListSchedulableByPlatforms(ctx, platforms)
|
||||
}
|
||||
func (m *mockAccountRepoForPlatform) ListSchedulableUngroupedByPlatform(ctx context.Context, platform string) ([]Account, error) {
|
||||
return m.ListSchedulableByPlatform(ctx, platform)
|
||||
}
|
||||
func (m *mockAccountRepoForPlatform) ListSchedulableUngroupedByPlatforms(ctx context.Context, platforms []string) ([]Account, error) {
|
||||
return m.ListSchedulableByPlatforms(ctx, platforms)
|
||||
}
|
||||
func (m *mockAccountRepoForPlatform) SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error {
|
||||
return nil
|
||||
}
|
||||
@@ -890,6 +901,55 @@ func TestGatewayService_SelectAccountForModelWithPlatform_GeminiPreferOAuth(t *t
|
||||
require.Equal(t, int64(2), acc.ID)
|
||||
}
|
||||
|
||||
func TestGatewayService_SelectAccountForModelWithPlatform_GeminiAPIKeyModelMappingFilter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
repo := &mockAccountRepoForPlatform{
|
||||
accounts: []Account{
|
||||
{
|
||||
ID: 1,
|
||||
Platform: PlatformGemini,
|
||||
Type: AccountTypeAPIKey,
|
||||
Priority: 1,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Credentials: map[string]any{"model_mapping": map[string]any{"gemini-2.5-pro": "gemini-2.5-pro"}},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Platform: PlatformGemini,
|
||||
Type: AccountTypeAPIKey,
|
||||
Priority: 2,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Credentials: map[string]any{"model_mapping": map[string]any{"gemini-2.5-flash": "gemini-2.5-flash"}},
|
||||
},
|
||||
},
|
||||
accountsByID: map[int64]*Account{},
|
||||
}
|
||||
for i := range repo.accounts {
|
||||
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
||||
}
|
||||
|
||||
cache := &mockGatewayCacheForPlatform{}
|
||||
|
||||
svc := &GatewayService{
|
||||
accountRepo: repo,
|
||||
cache: cache,
|
||||
cfg: testConfig(),
|
||||
}
|
||||
|
||||
acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "", "gemini-2.5-flash", nil, PlatformGemini)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, acc)
|
||||
require.Equal(t, int64(2), acc.ID, "应过滤不支持请求模型的 APIKey 账号")
|
||||
|
||||
acc, err = svc.selectAccountForModelWithPlatform(ctx, nil, "", "gemini-3-pro-preview", nil, PlatformGemini)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, acc)
|
||||
require.Contains(t, err.Error(), "supporting model")
|
||||
}
|
||||
|
||||
func TestGatewayService_SelectAccountForModelWithPlatform_StickyInGroup(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
groupID := int64(50)
|
||||
@@ -1065,6 +1125,36 @@ func TestGatewayService_isModelSupportedByAccount(t *testing.T) {
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Gemini平台-无映射配置-支持所有模型",
|
||||
account: &Account{Platform: PlatformGemini, Type: AccountTypeAPIKey},
|
||||
model: "gemini-2.5-flash",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Gemini平台-有映射配置-只支持配置的模型",
|
||||
account: &Account{
|
||||
Platform: PlatformGemini,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"gemini-2.5-pro": "gemini-2.5-pro"},
|
||||
},
|
||||
},
|
||||
model: "gemini-2.5-flash",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Gemini平台-有映射配置-支持配置的模型",
|
||||
account: &Account{
|
||||
Platform: PlatformGemini,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"gemini-2.5-pro": "gemini-2.5-pro"},
|
||||
},
|
||||
},
|
||||
model: "gemini-2.5-pro",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -1808,6 +1898,14 @@ func (m *mockConcurrencyCache) GetAccountConcurrency(ctx context.Context, accoun
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockConcurrencyCache) GetAccountConcurrencyBatch(ctx context.Context, accountIDs []int64) (map[int64]int, error) {
|
||||
result := make(map[int64]int, len(accountIDs))
|
||||
for _, accountID := range accountIDs {
|
||||
result[accountID] = 0
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockConcurrencyCache) IncrementAccountWaitCount(ctx context.Context, accountID int64, maxWait int) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -5,9 +5,28 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"unsafe"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
var (
|
||||
// 这些字节模式用于 fast-path 判断,避免每次 []byte("...") 产生临时分配。
|
||||
patternTypeThinking = []byte(`"type":"thinking"`)
|
||||
patternTypeThinkingSpaced = []byte(`"type": "thinking"`)
|
||||
patternTypeRedactedThinking = []byte(`"type":"redacted_thinking"`)
|
||||
patternTypeRedactedSpaced = []byte(`"type": "redacted_thinking"`)
|
||||
|
||||
patternThinkingField = []byte(`"thinking":`)
|
||||
patternThinkingFieldSpaced = []byte(`"thinking" :`)
|
||||
|
||||
patternEmptyContent = []byte(`"content":[]`)
|
||||
patternEmptyContentSpaced = []byte(`"content": []`)
|
||||
patternEmptyContentSp1 = []byte(`"content" : []`)
|
||||
patternEmptyContentSp2 = []byte(`"content" :[]`)
|
||||
)
|
||||
|
||||
// SessionContext 粘性会话上下文,用于区分不同来源的请求。
|
||||
@@ -42,119 +61,137 @@ type ParsedRequest struct {
|
||||
ThinkingEnabled bool // 是否开启 thinking(部分平台会影响最终模型名)
|
||||
MaxTokens int // max_tokens 值(用于探测请求拦截)
|
||||
SessionContext *SessionContext // 可选:请求上下文区分因子(nil 时行为不变)
|
||||
|
||||
// OnUpstreamAccepted 上游接受请求后立即调用(用于提前释放串行锁)
|
||||
// 流式请求在收到 2xx 响应头后调用,避免持锁等流完成
|
||||
OnUpstreamAccepted func()
|
||||
}
|
||||
|
||||
// ParseGatewayRequest 解析网关请求体并返回结构化结果。
|
||||
// protocol 指定请求协议格式(domain.PlatformAnthropic / domain.PlatformGemini),
|
||||
// 不同协议使用不同的 system/messages 字段名。
|
||||
func ParseGatewayRequest(body []byte, protocol string) (*ParsedRequest, error) {
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, err
|
||||
// 保持与旧实现一致:请求体必须是合法 JSON。
|
||||
// 注意:gjson.GetBytes 对非法 JSON 不会报错,因此需要显式校验。
|
||||
if !gjson.ValidBytes(body) {
|
||||
return nil, fmt.Errorf("invalid json")
|
||||
}
|
||||
|
||||
// 性能:
|
||||
// - gjson.GetBytes 会把匹配的 Raw/Str 安全复制成 string(对于巨大 messages 会产生额外拷贝)。
|
||||
// - 这里将 body 通过 unsafe 零拷贝视为 string,仅在本函数内使用,且 body 不会被修改。
|
||||
jsonStr := *(*string)(unsafe.Pointer(&body))
|
||||
|
||||
parsed := &ParsedRequest{
|
||||
Body: body,
|
||||
}
|
||||
|
||||
if rawModel, exists := req["model"]; exists {
|
||||
model, ok := rawModel.(string)
|
||||
if !ok {
|
||||
// --- gjson 提取简单字段(避免完整 Unmarshal) ---
|
||||
|
||||
// model: 需要严格类型校验,非 string 返回错误
|
||||
modelResult := gjson.Get(jsonStr, "model")
|
||||
if modelResult.Exists() {
|
||||
if modelResult.Type != gjson.String {
|
||||
return nil, fmt.Errorf("invalid model field type")
|
||||
}
|
||||
parsed.Model = model
|
||||
parsed.Model = modelResult.String()
|
||||
}
|
||||
if rawStream, exists := req["stream"]; exists {
|
||||
stream, ok := rawStream.(bool)
|
||||
if !ok {
|
||||
|
||||
// stream: 需要严格类型校验,非 bool 返回错误
|
||||
streamResult := gjson.Get(jsonStr, "stream")
|
||||
if streamResult.Exists() {
|
||||
if streamResult.Type != gjson.True && streamResult.Type != gjson.False {
|
||||
return nil, fmt.Errorf("invalid stream field type")
|
||||
}
|
||||
parsed.Stream = stream
|
||||
parsed.Stream = streamResult.Bool()
|
||||
}
|
||||
if metadata, ok := req["metadata"].(map[string]any); ok {
|
||||
if userID, ok := metadata["user_id"].(string); ok {
|
||||
parsed.MetadataUserID = userID
|
||||
|
||||
// metadata.user_id: 直接路径提取,不需要严格类型校验
|
||||
parsed.MetadataUserID = gjson.Get(jsonStr, "metadata.user_id").String()
|
||||
|
||||
// thinking.type: enabled/adaptive 都视为开启
|
||||
thinkingType := gjson.Get(jsonStr, "thinking.type").String()
|
||||
if thinkingType == "enabled" || thinkingType == "adaptive" {
|
||||
parsed.ThinkingEnabled = true
|
||||
}
|
||||
|
||||
// max_tokens: 仅接受整数值
|
||||
maxTokensResult := gjson.Get(jsonStr, "max_tokens")
|
||||
if maxTokensResult.Exists() && maxTokensResult.Type == gjson.Number {
|
||||
f := maxTokensResult.Float()
|
||||
if !math.IsNaN(f) && !math.IsInf(f, 0) && f == math.Trunc(f) &&
|
||||
f <= float64(math.MaxInt) && f >= float64(math.MinInt) {
|
||||
parsed.MaxTokens = int(f)
|
||||
}
|
||||
}
|
||||
|
||||
// --- system/messages 提取 ---
|
||||
// 避免把整个 body Unmarshal 到 map(会产生大量 map/接口分配)。
|
||||
// 使用 gjson 抽取目标字段的 Raw,再对该子树进行 Unmarshal。
|
||||
|
||||
switch protocol {
|
||||
case domain.PlatformGemini:
|
||||
// Gemini 原生格式: systemInstruction.parts / contents
|
||||
if sysInst, ok := req["systemInstruction"].(map[string]any); ok {
|
||||
if parts, ok := sysInst["parts"].([]any); ok {
|
||||
parsed.System = parts
|
||||
if sysParts := gjson.Get(jsonStr, "systemInstruction.parts"); sysParts.Exists() && sysParts.IsArray() {
|
||||
var parts []any
|
||||
if err := json.Unmarshal(sliceRawFromBody(body, sysParts), &parts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed.System = parts
|
||||
}
|
||||
if contents, ok := req["contents"].([]any); ok {
|
||||
parsed.Messages = contents
|
||||
|
||||
if contents := gjson.Get(jsonStr, "contents"); contents.Exists() && contents.IsArray() {
|
||||
var msgs []any
|
||||
if err := json.Unmarshal(sliceRawFromBody(body, contents), &msgs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed.Messages = msgs
|
||||
}
|
||||
default:
|
||||
// Anthropic / OpenAI 格式: system / messages
|
||||
// system 字段只要存在就视为显式提供(即使为 null),
|
||||
// 以避免客户端传 null 时被默认 system 误注入。
|
||||
if system, ok := req["system"]; ok {
|
||||
if sys := gjson.Get(jsonStr, "system"); sys.Exists() {
|
||||
parsed.HasSystem = true
|
||||
parsed.System = system
|
||||
switch sys.Type {
|
||||
case gjson.Null:
|
||||
parsed.System = nil
|
||||
case gjson.String:
|
||||
// 与 encoding/json 的 Unmarshal 行为一致:返回解码后的字符串。
|
||||
parsed.System = sys.String()
|
||||
default:
|
||||
var system any
|
||||
if err := json.Unmarshal(sliceRawFromBody(body, sys), &system); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed.System = system
|
||||
}
|
||||
}
|
||||
if messages, ok := req["messages"].([]any); ok {
|
||||
|
||||
if msgs := gjson.Get(jsonStr, "messages"); msgs.Exists() && msgs.IsArray() {
|
||||
var messages []any
|
||||
if err := json.Unmarshal(sliceRawFromBody(body, msgs), &messages); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed.Messages = messages
|
||||
}
|
||||
}
|
||||
|
||||
// thinking: {type: "enabled" | "adaptive"}
|
||||
if rawThinking, ok := req["thinking"].(map[string]any); ok {
|
||||
if t, ok := rawThinking["type"].(string); ok && (t == "enabled" || t == "adaptive") {
|
||||
parsed.ThinkingEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
// max_tokens
|
||||
if rawMaxTokens, exists := req["max_tokens"]; exists {
|
||||
if maxTokens, ok := parseIntegralNumber(rawMaxTokens); ok {
|
||||
parsed.MaxTokens = maxTokens
|
||||
}
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// parseIntegralNumber 将 JSON 解码后的数字安全转换为 int。
|
||||
// 仅接受“整数值”的输入,小数/NaN/Inf/越界值都会返回 false。
|
||||
func parseIntegralNumber(raw any) (int, bool) {
|
||||
switch v := raw.(type) {
|
||||
case float64:
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) || v != math.Trunc(v) {
|
||||
return 0, false
|
||||
// sliceRawFromBody 返回 Result.Raw 对应的原始字节切片。
|
||||
// 优先使用 Result.Index 直接从 body 切片,避免对大字段(如 messages)产生额外拷贝。
|
||||
// 当 Index 不可用时,退化为复制(理论上极少发生)。
|
||||
func sliceRawFromBody(body []byte, r gjson.Result) []byte {
|
||||
if r.Index > 0 {
|
||||
end := r.Index + len(r.Raw)
|
||||
if end <= len(body) {
|
||||
return body[r.Index:end]
|
||||
}
|
||||
if v > float64(math.MaxInt) || v < float64(math.MinInt) {
|
||||
return 0, false
|
||||
}
|
||||
return int(v), true
|
||||
case int:
|
||||
return v, true
|
||||
case int8:
|
||||
return int(v), true
|
||||
case int16:
|
||||
return int(v), true
|
||||
case int32:
|
||||
return int(v), true
|
||||
case int64:
|
||||
if v > int64(math.MaxInt) || v < int64(math.MinInt) {
|
||||
return 0, false
|
||||
}
|
||||
return int(v), true
|
||||
case json.Number:
|
||||
i64, err := v.Int64()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
if i64 > int64(math.MaxInt) || i64 < int64(math.MinInt) {
|
||||
return 0, false
|
||||
}
|
||||
return int(i64), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
// fallback: 不影响正确性,但会产生一次拷贝
|
||||
return []byte(r.Raw)
|
||||
}
|
||||
|
||||
// FilterThinkingBlocks removes thinking blocks from request body
|
||||
@@ -184,49 +221,63 @@ func FilterThinkingBlocks(body []byte) []byte {
|
||||
// - Remove `redacted_thinking` blocks (cannot be converted to text).
|
||||
// - Ensure no message ends up with empty content.
|
||||
func FilterThinkingBlocksForRetry(body []byte) []byte {
|
||||
hasThinkingContent := bytes.Contains(body, []byte(`"type":"thinking"`)) ||
|
||||
bytes.Contains(body, []byte(`"type": "thinking"`)) ||
|
||||
bytes.Contains(body, []byte(`"type":"redacted_thinking"`)) ||
|
||||
bytes.Contains(body, []byte(`"type": "redacted_thinking"`)) ||
|
||||
bytes.Contains(body, []byte(`"thinking":`)) ||
|
||||
bytes.Contains(body, []byte(`"thinking" :`))
|
||||
hasThinkingContent := bytes.Contains(body, patternTypeThinking) ||
|
||||
bytes.Contains(body, patternTypeThinkingSpaced) ||
|
||||
bytes.Contains(body, patternTypeRedactedThinking) ||
|
||||
bytes.Contains(body, patternTypeRedactedSpaced) ||
|
||||
bytes.Contains(body, patternThinkingField) ||
|
||||
bytes.Contains(body, patternThinkingFieldSpaced)
|
||||
|
||||
// Also check for empty content arrays that need fixing.
|
||||
// Note: This is a heuristic check; the actual empty content handling is done below.
|
||||
hasEmptyContent := bytes.Contains(body, []byte(`"content":[]`)) ||
|
||||
bytes.Contains(body, []byte(`"content": []`)) ||
|
||||
bytes.Contains(body, []byte(`"content" : []`)) ||
|
||||
bytes.Contains(body, []byte(`"content" :[]`))
|
||||
hasEmptyContent := bytes.Contains(body, patternEmptyContent) ||
|
||||
bytes.Contains(body, patternEmptyContentSpaced) ||
|
||||
bytes.Contains(body, patternEmptyContentSp1) ||
|
||||
bytes.Contains(body, patternEmptyContentSp2)
|
||||
|
||||
// Fast path: nothing to process
|
||||
if !hasThinkingContent && !hasEmptyContent {
|
||||
return body
|
||||
}
|
||||
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
// 尽量避免把整个 body Unmarshal 成 map(会产生大量 map/接口分配)。
|
||||
// 这里先用 gjson 把 messages 子树摘出来,后续只对 messages 做 Unmarshal/Marshal。
|
||||
jsonStr := *(*string)(unsafe.Pointer(&body))
|
||||
msgsRes := gjson.Get(jsonStr, "messages")
|
||||
if !msgsRes.Exists() || !msgsRes.IsArray() {
|
||||
return body
|
||||
}
|
||||
|
||||
// Fast path:只需要删除顶层 thinking,不需要改 messages。
|
||||
// 注意:patternThinkingField 可能来自嵌套字段(如 tool_use.input.thinking),因此必须用 gjson 判断顶层字段是否存在。
|
||||
containsThinkingBlocks := bytes.Contains(body, patternTypeThinking) ||
|
||||
bytes.Contains(body, patternTypeThinkingSpaced) ||
|
||||
bytes.Contains(body, patternTypeRedactedThinking) ||
|
||||
bytes.Contains(body, patternTypeRedactedSpaced) ||
|
||||
bytes.Contains(body, patternThinkingFieldSpaced)
|
||||
if !hasEmptyContent && !containsThinkingBlocks {
|
||||
if topThinking := gjson.Get(jsonStr, "thinking"); topThinking.Exists() {
|
||||
if out, err := sjson.DeleteBytes(body, "thinking"); err == nil {
|
||||
return out
|
||||
}
|
||||
return body
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
var messages []any
|
||||
if err := json.Unmarshal(sliceRawFromBody(body, msgsRes), &messages); err != nil {
|
||||
return body
|
||||
}
|
||||
|
||||
modified := false
|
||||
|
||||
messages, ok := req["messages"].([]any)
|
||||
if !ok {
|
||||
return body
|
||||
}
|
||||
|
||||
// Disable top-level thinking mode for retry to avoid structural/signature constraints upstream.
|
||||
if _, exists := req["thinking"]; exists {
|
||||
delete(req, "thinking")
|
||||
modified = true
|
||||
}
|
||||
deleteTopLevelThinking := gjson.Get(jsonStr, "thinking").Exists()
|
||||
|
||||
newMessages := make([]any, 0, len(messages))
|
||||
|
||||
for _, msg := range messages {
|
||||
msgMap, ok := msg.(map[string]any)
|
||||
for i := 0; i < len(messages); i++ {
|
||||
msgMap, ok := messages[i].(map[string]any)
|
||||
if !ok {
|
||||
newMessages = append(newMessages, msg)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -234,17 +285,30 @@ func FilterThinkingBlocksForRetry(body []byte) []byte {
|
||||
content, ok := msgMap["content"].([]any)
|
||||
if !ok {
|
||||
// String content or other format - keep as is
|
||||
newMessages = append(newMessages, msg)
|
||||
continue
|
||||
}
|
||||
|
||||
newContent := make([]any, 0, len(content))
|
||||
// 延迟分配:只有检测到需要修改的块,才构建新 slice。
|
||||
var newContent []any
|
||||
modifiedThisMsg := false
|
||||
|
||||
for _, block := range content {
|
||||
ensureNewContent := func(prefixLen int) {
|
||||
if newContent != nil {
|
||||
return
|
||||
}
|
||||
newContent = make([]any, 0, len(content))
|
||||
if prefixLen > 0 {
|
||||
newContent = append(newContent, content[:prefixLen]...)
|
||||
}
|
||||
}
|
||||
|
||||
for bi := 0; bi < len(content); bi++ {
|
||||
block := content[bi]
|
||||
blockMap, ok := block.(map[string]any)
|
||||
if !ok {
|
||||
newContent = append(newContent, block)
|
||||
if newContent != nil {
|
||||
newContent = append(newContent, block)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -254,17 +318,15 @@ func FilterThinkingBlocksForRetry(body []byte) []byte {
|
||||
switch blockType {
|
||||
case "thinking":
|
||||
modifiedThisMsg = true
|
||||
ensureNewContent(bi)
|
||||
thinkingText, _ := blockMap["thinking"].(string)
|
||||
if thinkingText == "" {
|
||||
continue
|
||||
if thinkingText != "" {
|
||||
newContent = append(newContent, map[string]any{"type": "text", "text": thinkingText})
|
||||
}
|
||||
newContent = append(newContent, map[string]any{
|
||||
"type": "text",
|
||||
"text": thinkingText,
|
||||
})
|
||||
continue
|
||||
case "redacted_thinking":
|
||||
modifiedThisMsg = true
|
||||
ensureNewContent(bi)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -272,6 +334,7 @@ func FilterThinkingBlocksForRetry(body []byte) []byte {
|
||||
if blockType == "" {
|
||||
if rawThinking, hasThinking := blockMap["thinking"]; hasThinking {
|
||||
modifiedThisMsg = true
|
||||
ensureNewContent(bi)
|
||||
switch v := rawThinking.(type) {
|
||||
case string:
|
||||
if v != "" {
|
||||
@@ -286,40 +349,64 @@ func FilterThinkingBlocksForRetry(body []byte) []byte {
|
||||
}
|
||||
}
|
||||
|
||||
newContent = append(newContent, block)
|
||||
if newContent != nil {
|
||||
newContent = append(newContent, block)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle empty content: either from filtering or originally empty
|
||||
if newContent == nil {
|
||||
if len(content) == 0 {
|
||||
modified = true
|
||||
placeholder := "(content removed)"
|
||||
if role == "assistant" {
|
||||
placeholder = "(assistant content removed)"
|
||||
}
|
||||
msgMap["content"] = []any{map[string]any{"type": "text", "text": placeholder}}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if len(newContent) == 0 {
|
||||
modified = true
|
||||
placeholder := "(content removed)"
|
||||
if role == "assistant" {
|
||||
placeholder = "(assistant content removed)"
|
||||
}
|
||||
newContent = append(newContent, map[string]any{
|
||||
"type": "text",
|
||||
"text": placeholder,
|
||||
})
|
||||
msgMap["content"] = newContent
|
||||
} else if modifiedThisMsg {
|
||||
msgMap["content"] = []any{map[string]any{"type": "text", "text": placeholder}}
|
||||
continue
|
||||
}
|
||||
|
||||
if modifiedThisMsg {
|
||||
modified = true
|
||||
msgMap["content"] = newContent
|
||||
}
|
||||
newMessages = append(newMessages, msgMap)
|
||||
}
|
||||
|
||||
if modified {
|
||||
req["messages"] = newMessages
|
||||
} else {
|
||||
if !modified && !deleteTopLevelThinking {
|
||||
// Avoid rewriting JSON when no changes are needed.
|
||||
return body
|
||||
}
|
||||
|
||||
newBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return body
|
||||
out := body
|
||||
if deleteTopLevelThinking {
|
||||
if b, err := sjson.DeleteBytes(out, "thinking"); err == nil {
|
||||
out = b
|
||||
} else {
|
||||
return body
|
||||
}
|
||||
}
|
||||
return newBody
|
||||
if modified {
|
||||
msgsBytes, err := json.Marshal(messages)
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
out, err = sjson.SetRawBytes(out, "messages", msgsBytes)
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FilterSignatureSensitiveBlocksForRetry is a stronger retry filter for cases where upstream errors indicate
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
@@ -434,3 +438,341 @@ func TestFilterSignatureSensitiveBlocksForRetry_DowngradesTools(t *testing.T) {
|
||||
require.Contains(t, content0["text"], "tool_use")
|
||||
require.Contains(t, content1["text"], "tool_result")
|
||||
}
|
||||
|
||||
// ============ Group 7: ParseGatewayRequest 补充单元测试 ============
|
||||
|
||||
// Task 7.1 — 类型校验边界测试
|
||||
func TestParseGatewayRequest_TypeValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantErr bool
|
||||
errSubstr string // 期望的错误信息子串(为空则不检查)
|
||||
}{
|
||||
{
|
||||
name: "model 为 int",
|
||||
body: `{"model":123}`,
|
||||
wantErr: true,
|
||||
errSubstr: "invalid model field type",
|
||||
},
|
||||
{
|
||||
name: "model 为 array",
|
||||
body: `{"model":[]}`,
|
||||
wantErr: true,
|
||||
errSubstr: "invalid model field type",
|
||||
},
|
||||
{
|
||||
name: "model 为 bool",
|
||||
body: `{"model":true}`,
|
||||
wantErr: true,
|
||||
errSubstr: "invalid model field type",
|
||||
},
|
||||
{
|
||||
name: "model 为 null — gjson Null 类型触发类型校验错误",
|
||||
body: `{"model":null}`,
|
||||
wantErr: true, // gjson: Exists()=true, Type=Null != String → 返回错误
|
||||
errSubstr: "invalid model field type",
|
||||
},
|
||||
{
|
||||
name: "stream 为 string",
|
||||
body: `{"stream":"true"}`,
|
||||
wantErr: true,
|
||||
errSubstr: "invalid stream field type",
|
||||
},
|
||||
{
|
||||
name: "stream 为 int",
|
||||
body: `{"stream":1}`,
|
||||
wantErr: true,
|
||||
errSubstr: "invalid stream field type",
|
||||
},
|
||||
{
|
||||
name: "stream 为 null — gjson Null 类型触发类型校验错误",
|
||||
body: `{"stream":null}`,
|
||||
wantErr: true, // gjson: Exists()=true, Type=Null != True && != False → 返回错误
|
||||
errSubstr: "invalid stream field type",
|
||||
},
|
||||
{
|
||||
name: "model 为 object",
|
||||
body: `{"model":{}}`,
|
||||
wantErr: true,
|
||||
errSubstr: "invalid model field type",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := ParseGatewayRequest([]byte(tt.body), "")
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
if tt.errSubstr != "" {
|
||||
require.Contains(t, err.Error(), tt.errSubstr)
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Task 7.2 — 可选字段缺失测试
|
||||
func TestParseGatewayRequest_OptionalFieldsMissing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantModel string
|
||||
wantStream bool
|
||||
wantMetadataUID string
|
||||
wantHasSystem bool
|
||||
wantThinking bool
|
||||
wantMaxTokens int
|
||||
wantMessagesNil bool
|
||||
wantMessagesLen int
|
||||
}{
|
||||
{
|
||||
name: "完全空 JSON — 所有字段零值",
|
||||
body: `{}`,
|
||||
wantModel: "",
|
||||
wantStream: false,
|
||||
wantMetadataUID: "",
|
||||
wantHasSystem: false,
|
||||
wantThinking: false,
|
||||
wantMaxTokens: 0,
|
||||
wantMessagesNil: true,
|
||||
},
|
||||
{
|
||||
name: "metadata 无 user_id",
|
||||
body: `{"model":"test"}`,
|
||||
wantModel: "test",
|
||||
wantMetadataUID: "",
|
||||
wantHasSystem: false,
|
||||
wantThinking: false,
|
||||
},
|
||||
{
|
||||
name: "thinking 非 enabled(type=disabled)",
|
||||
body: `{"model":"test","thinking":{"type":"disabled"}}`,
|
||||
wantModel: "test",
|
||||
wantThinking: false,
|
||||
},
|
||||
{
|
||||
name: "thinking 字段缺失",
|
||||
body: `{"model":"test"}`,
|
||||
wantModel: "test",
|
||||
wantThinking: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parsed, err := ParseGatewayRequest([]byte(tt.body), "")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, tt.wantModel, parsed.Model)
|
||||
require.Equal(t, tt.wantStream, parsed.Stream)
|
||||
require.Equal(t, tt.wantMetadataUID, parsed.MetadataUserID)
|
||||
require.Equal(t, tt.wantHasSystem, parsed.HasSystem)
|
||||
require.Equal(t, tt.wantThinking, parsed.ThinkingEnabled)
|
||||
require.Equal(t, tt.wantMaxTokens, parsed.MaxTokens)
|
||||
|
||||
if tt.wantMessagesNil {
|
||||
require.Nil(t, parsed.Messages)
|
||||
}
|
||||
if tt.wantMessagesLen > 0 {
|
||||
require.Len(t, parsed.Messages, tt.wantMessagesLen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Task 7.3 — Gemini 协议分支测试
|
||||
// 已有测试覆盖:
|
||||
// - TestParseGatewayRequest_GeminiSystemInstruction: 正常 systemInstruction+contents
|
||||
// - TestParseGatewayRequest_GeminiNoContents: 缺失 contents
|
||||
// - TestParseGatewayRequest_GeminiContents: 正常 contents(无 systemInstruction)
|
||||
// 因此跳过。
|
||||
|
||||
// Task 7.4 — max_tokens 边界测试
|
||||
func TestParseGatewayRequest_MaxTokensBoundary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantMaxTokens int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "正常整数",
|
||||
body: `{"max_tokens":1024}`,
|
||||
wantMaxTokens: 1024,
|
||||
},
|
||||
{
|
||||
name: "浮点数(非整数)被忽略",
|
||||
body: `{"max_tokens":10.5}`,
|
||||
wantMaxTokens: 0,
|
||||
},
|
||||
{
|
||||
name: "负整数可以通过",
|
||||
body: `{"max_tokens":-1}`,
|
||||
wantMaxTokens: -1,
|
||||
},
|
||||
{
|
||||
name: "超大值不 panic",
|
||||
body: `{"max_tokens":9999999999999999}`,
|
||||
wantMaxTokens: 10000000000000000, // float64 精度导致 9999999999999999 → 1e16
|
||||
},
|
||||
{
|
||||
name: "null 值被忽略",
|
||||
body: `{"max_tokens":null}`,
|
||||
wantMaxTokens: 0, // gjson Type=Null != Number → 条件不满足,跳过
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parsed, err := ParseGatewayRequest([]byte(tt.body), "")
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantMaxTokens, parsed.MaxTokens)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Task 7.5: Benchmark 测试 ============
|
||||
|
||||
// parseGatewayRequestOld 是基于完整 json.Unmarshal 的旧实现,用于 benchmark 对比基线。
|
||||
// 核心路径:先 Unmarshal 到 map[string]any,再逐字段提取。
|
||||
func parseGatewayRequestOld(body []byte, protocol string) (*ParsedRequest, error) {
|
||||
parsed := &ParsedRequest{
|
||||
Body: body,
|
||||
}
|
||||
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// model
|
||||
if raw, ok := req["model"]; ok {
|
||||
s, ok := raw.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid model field type")
|
||||
}
|
||||
parsed.Model = s
|
||||
}
|
||||
|
||||
// stream
|
||||
if raw, ok := req["stream"]; ok {
|
||||
b, ok := raw.(bool)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid stream field type")
|
||||
}
|
||||
parsed.Stream = b
|
||||
}
|
||||
|
||||
// metadata.user_id
|
||||
if meta, ok := req["metadata"].(map[string]any); ok {
|
||||
if uid, ok := meta["user_id"].(string); ok {
|
||||
parsed.MetadataUserID = uid
|
||||
}
|
||||
}
|
||||
|
||||
// thinking.type
|
||||
if thinking, ok := req["thinking"].(map[string]any); ok {
|
||||
if thinkType, ok := thinking["type"].(string); ok && thinkType == "enabled" {
|
||||
parsed.ThinkingEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
// max_tokens
|
||||
if raw, ok := req["max_tokens"]; ok {
|
||||
if n, ok := parseIntegralNumber(raw); ok {
|
||||
parsed.MaxTokens = n
|
||||
}
|
||||
}
|
||||
|
||||
// system / messages(按协议分支)
|
||||
switch protocol {
|
||||
case domain.PlatformGemini:
|
||||
if sysInst, ok := req["systemInstruction"].(map[string]any); ok {
|
||||
if parts, ok := sysInst["parts"].([]any); ok {
|
||||
parsed.System = parts
|
||||
}
|
||||
}
|
||||
if contents, ok := req["contents"].([]any); ok {
|
||||
parsed.Messages = contents
|
||||
}
|
||||
default:
|
||||
if system, ok := req["system"]; ok {
|
||||
parsed.HasSystem = true
|
||||
parsed.System = system
|
||||
}
|
||||
if messages, ok := req["messages"].([]any); ok {
|
||||
parsed.Messages = messages
|
||||
}
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// buildSmallJSON 构建 ~500B 的小型测试 JSON
|
||||
func buildSmallJSON() []byte {
|
||||
return []byte(`{"model":"claude-sonnet-4-5","stream":true,"max_tokens":4096,"metadata":{"user_id":"user-abc123"},"thinking":{"type":"enabled","budget_tokens":2048},"system":"You are a helpful assistant.","messages":[{"role":"user","content":"What is the meaning of life?"},{"role":"assistant","content":"The meaning of life is a philosophical question."},{"role":"user","content":"Can you elaborate?"}]}`)
|
||||
}
|
||||
|
||||
// buildLargeJSON 构建 ~50KB 的大型测试 JSON(大量 messages)
|
||||
func buildLargeJSON() []byte {
|
||||
var b strings.Builder
|
||||
b.WriteString(`{"model":"claude-sonnet-4-5","stream":true,"max_tokens":8192,"metadata":{"user_id":"user-xyz789"},"system":[{"type":"text","text":"You are a detailed assistant.","cache_control":{"type":"ephemeral"}}],"messages":[`)
|
||||
|
||||
msgCount := 200
|
||||
for i := 0; i < msgCount; i++ {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
if i%2 == 0 {
|
||||
b.WriteString(fmt.Sprintf(`{"role":"user","content":"This is user message number %d with some extra padding text to make the message reasonably long for benchmarking purposes. Lorem ipsum dolor sit amet."}`, i))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf(`{"role":"assistant","content":[{"type":"text","text":"This is assistant response number %d. I will provide a detailed answer with multiple sentences to simulate real conversation content for benchmark testing."}]}`, i))
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString(`]}`)
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
func BenchmarkParseGatewayRequest_Old_Small(b *testing.B) {
|
||||
data := buildSmallJSON()
|
||||
b.SetBytes(int64(len(data)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = parseGatewayRequestOld(data, "")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkParseGatewayRequest_New_Small(b *testing.B) {
|
||||
data := buildSmallJSON()
|
||||
b.SetBytes(int64(len(data)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = ParseGatewayRequest(data, "")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkParseGatewayRequest_Old_Large(b *testing.B) {
|
||||
data := buildLargeJSON()
|
||||
b.SetBytes(int64(len(data)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = parseGatewayRequestOld(data, "")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkParseGatewayRequest_New_Large(b *testing.B) {
|
||||
data := buildLargeJSON()
|
||||
b.SetBytes(int64(len(data)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = ParseGatewayRequest(data, "")
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCollectSelectionFailureStats(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
model := "sora2-landscape-10s"
|
||||
resetAt := time.Now().Add(2 * time.Minute).Format(time.RFC3339)
|
||||
|
||||
accounts := []Account{
|
||||
// excluded
|
||||
{
|
||||
ID: 1,
|
||||
Platform: PlatformSora,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
},
|
||||
// unschedulable
|
||||
{
|
||||
ID: 2,
|
||||
Platform: PlatformSora,
|
||||
Status: StatusActive,
|
||||
Schedulable: false,
|
||||
},
|
||||
// platform filtered
|
||||
{
|
||||
ID: 3,
|
||||
Platform: PlatformOpenAI,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
},
|
||||
// model unsupported
|
||||
{
|
||||
ID: 4,
|
||||
Platform: PlatformSora,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"gpt-image": "gpt-image",
|
||||
},
|
||||
},
|
||||
},
|
||||
// model rate limited
|
||||
{
|
||||
ID: 5,
|
||||
Platform: PlatformSora,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Extra: map[string]any{
|
||||
"model_rate_limits": map[string]any{
|
||||
model: map[string]any{
|
||||
"rate_limit_reset_at": resetAt,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// eligible
|
||||
{
|
||||
ID: 6,
|
||||
Platform: PlatformSora,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
},
|
||||
}
|
||||
|
||||
excluded := map[int64]struct{}{1: {}}
|
||||
stats := svc.collectSelectionFailureStats(context.Background(), accounts, model, PlatformSora, excluded, false)
|
||||
|
||||
if stats.Total != 6 {
|
||||
t.Fatalf("total=%d want=6", stats.Total)
|
||||
}
|
||||
if stats.Excluded != 1 {
|
||||
t.Fatalf("excluded=%d want=1", stats.Excluded)
|
||||
}
|
||||
if stats.Unschedulable != 1 {
|
||||
t.Fatalf("unschedulable=%d want=1", stats.Unschedulable)
|
||||
}
|
||||
if stats.PlatformFiltered != 1 {
|
||||
t.Fatalf("platform_filtered=%d want=1", stats.PlatformFiltered)
|
||||
}
|
||||
if stats.ModelUnsupported != 1 {
|
||||
t.Fatalf("model_unsupported=%d want=1", stats.ModelUnsupported)
|
||||
}
|
||||
if stats.ModelRateLimited != 1 {
|
||||
t.Fatalf("model_rate_limited=%d want=1", stats.ModelRateLimited)
|
||||
}
|
||||
if stats.Eligible != 1 {
|
||||
t.Fatalf("eligible=%d want=1", stats.Eligible)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnoseSelectionFailure_SoraUnschedulableDetail(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
acc := &Account{
|
||||
ID: 7,
|
||||
Platform: PlatformSora,
|
||||
Status: StatusActive,
|
||||
Schedulable: false,
|
||||
}
|
||||
|
||||
diagnosis := svc.diagnoseSelectionFailure(context.Background(), acc, "sora2-landscape-10s", PlatformSora, map[int64]struct{}{}, false)
|
||||
if diagnosis.Category != "unschedulable" {
|
||||
t.Fatalf("category=%s want=unschedulable", diagnosis.Category)
|
||||
}
|
||||
if diagnosis.Detail != "schedulable=false" {
|
||||
t.Fatalf("detail=%s want=schedulable=false", diagnosis.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnoseSelectionFailure_SoraModelRateLimitedDetail(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
model := "sora2-landscape-10s"
|
||||
resetAt := time.Now().Add(2 * time.Minute).UTC().Format(time.RFC3339)
|
||||
acc := &Account{
|
||||
ID: 8,
|
||||
Platform: PlatformSora,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Extra: map[string]any{
|
||||
"model_rate_limits": map[string]any{
|
||||
model: map[string]any{
|
||||
"rate_limit_reset_at": resetAt,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
diagnosis := svc.diagnoseSelectionFailure(context.Background(), acc, model, PlatformSora, map[int64]struct{}{}, false)
|
||||
if diagnosis.Category != "model_rate_limited" {
|
||||
t.Fatalf("category=%s want=model_rate_limited", diagnosis.Category)
|
||||
}
|
||||
if !strings.Contains(diagnosis.Detail, "remaining=") {
|
||||
t.Fatalf("detail=%s want contains remaining=", diagnosis.Detail)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGatewayServiceIsModelSupportedByAccount_SoraNoMappingAllowsAll(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
account := &Account{
|
||||
Platform: PlatformSora,
|
||||
Credentials: map[string]any{},
|
||||
}
|
||||
|
||||
if !svc.isModelSupportedByAccount(account, "sora2-landscape-10s") {
|
||||
t.Fatalf("expected sora model to be supported when model_mapping is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayServiceIsModelSupportedByAccount_SoraLegacyNonSoraMappingDoesNotBlock(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
account := &Account{
|
||||
Platform: PlatformSora,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"gpt-4o": "gpt-4o",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if !svc.isModelSupportedByAccount(account, "sora2-landscape-10s") {
|
||||
t.Fatalf("expected sora model to be supported when mapping has no sora selectors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayServiceIsModelSupportedByAccount_SoraFamilyAlias(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
account := &Account{
|
||||
Platform: PlatformSora,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"sora2": "sora2",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if !svc.isModelSupportedByAccount(account, "sora2-landscape-15s") {
|
||||
t.Fatalf("expected family selector sora2 to support sora2-landscape-15s")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayServiceIsModelSupportedByAccount_SoraUnderlyingModelAlias(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
account := &Account{
|
||||
Platform: PlatformSora,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"sy_8": "sy_8",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if !svc.isModelSupportedByAccount(account, "sora2-landscape-10s") {
|
||||
t.Fatalf("expected underlying model selector sy_8 to support sora2-landscape-10s")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayServiceIsModelSupportedByAccount_SoraExplicitImageSelectorBlocksVideo(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
account := &Account{
|
||||
Platform: PlatformSora,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"gpt-image": "gpt-image",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if svc.isModelSupportedByAccount(account, "sora2-landscape-10s") {
|
||||
t.Fatalf("expected video model to be blocked when mapping explicitly only allows gpt-image")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGatewayServiceIsAccountSchedulableForSelectionSoraIgnoresGenericWindows(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
now := time.Now()
|
||||
past := now.Add(-1 * time.Minute)
|
||||
future := now.Add(5 * time.Minute)
|
||||
|
||||
acc := &Account{
|
||||
Platform: PlatformSora,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
AutoPauseOnExpired: true,
|
||||
ExpiresAt: &past,
|
||||
OverloadUntil: &future,
|
||||
RateLimitResetAt: &future,
|
||||
}
|
||||
|
||||
if !svc.isAccountSchedulableForSelection(acc) {
|
||||
t.Fatalf("expected sora account to ignore generic expiry/overload/rate-limit windows")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayServiceIsAccountSchedulableForSelectionNonSoraKeepsGenericLogic(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
future := time.Now().Add(5 * time.Minute)
|
||||
|
||||
acc := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
RateLimitResetAt: &future,
|
||||
}
|
||||
|
||||
if svc.isAccountSchedulableForSelection(acc) {
|
||||
t.Fatalf("expected non-sora account to keep generic schedulable checks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayServiceIsAccountSchedulableForModelSelectionSoraChecksModelScopeOnly(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
model := "sora2-landscape-10s"
|
||||
resetAt := time.Now().Add(2 * time.Minute).UTC().Format(time.RFC3339)
|
||||
globalResetAt := time.Now().Add(2 * time.Minute)
|
||||
|
||||
acc := &Account{
|
||||
Platform: PlatformSora,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
RateLimitResetAt: &globalResetAt,
|
||||
Extra: map[string]any{
|
||||
"model_rate_limits": map[string]any{
|
||||
model: map[string]any{
|
||||
"rate_limit_reset_at": resetAt,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if svc.isAccountSchedulableForModelSelection(context.Background(), acc, model) {
|
||||
t.Fatalf("expected sora account to be blocked by model scope rate limit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectSelectionFailureStatsSoraIgnoresGenericUnschedulableWindows(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
future := time.Now().Add(3 * time.Minute)
|
||||
|
||||
accounts := []Account{
|
||||
{
|
||||
ID: 1,
|
||||
Platform: PlatformSora,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
RateLimitResetAt: &future,
|
||||
},
|
||||
}
|
||||
|
||||
stats := svc.collectSelectionFailureStats(context.Background(), accounts, "sora2-landscape-10s", PlatformSora, map[int64]struct{}{}, false)
|
||||
if stats.Unschedulable != 0 || stats.Eligible != 1 {
|
||||
t.Fatalf("unexpected stats: unschedulable=%d eligible=%d", stats.Unschedulable, stats.Eligible)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGatewayService_StreamingReusesScannerBufferAndStillParsesUsage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
StreamDataIntervalTimeout: 0,
|
||||
MaxLineSize: defaultMaxLineSize,
|
||||
},
|
||||
}
|
||||
|
||||
svc := &GatewayService{
|
||||
cfg: cfg,
|
||||
rateLimitService: &RateLimitService{},
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: pr}
|
||||
|
||||
go func() {
|
||||
defer func() { _ = pw.Close() }()
|
||||
// Minimal SSE event to trigger parseSSEUsage
|
||||
_, _ = pw.Write([]byte("data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":3}}}\n\n"))
|
||||
_, _ = pw.Write([]byte("data: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":7}}\n\n"))
|
||||
_, _ = pw.Write([]byte("data: [DONE]\n\n"))
|
||||
}()
|
||||
|
||||
result, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "model", "model", false)
|
||||
_ = pr.Close()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.usage)
|
||||
require.Equal(t, 3, result.usage.InputTokens)
|
||||
require.Equal(t, 7, result.usage.OutputTokens)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- parseSSEUsage 测试 ---
|
||||
|
||||
func newMinimalGatewayService() *GatewayService {
|
||||
return &GatewayService{
|
||||
cfg: &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
StreamDataIntervalTimeout: 0,
|
||||
MaxLineSize: defaultMaxLineSize,
|
||||
},
|
||||
},
|
||||
rateLimitService: &RateLimitService{},
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEUsage_MessageStart(t *testing.T) {
|
||||
svc := newMinimalGatewayService()
|
||||
usage := &ClaudeUsage{}
|
||||
|
||||
data := `{"type":"message_start","message":{"usage":{"input_tokens":100,"cache_creation_input_tokens":50,"cache_read_input_tokens":200}}}`
|
||||
svc.parseSSEUsage(data, usage)
|
||||
|
||||
require.Equal(t, 100, usage.InputTokens)
|
||||
require.Equal(t, 50, usage.CacheCreationInputTokens)
|
||||
require.Equal(t, 200, usage.CacheReadInputTokens)
|
||||
require.Equal(t, 0, usage.OutputTokens, "message_start 不应设置 output_tokens")
|
||||
}
|
||||
|
||||
func TestParseSSEUsage_MessageDelta(t *testing.T) {
|
||||
svc := newMinimalGatewayService()
|
||||
usage := &ClaudeUsage{}
|
||||
|
||||
data := `{"type":"message_delta","usage":{"output_tokens":42}}`
|
||||
svc.parseSSEUsage(data, usage)
|
||||
|
||||
require.Equal(t, 42, usage.OutputTokens)
|
||||
require.Equal(t, 0, usage.InputTokens, "message_delta 的 output_tokens 不应影响已有的 input_tokens")
|
||||
}
|
||||
|
||||
func TestParseSSEUsage_DeltaDoesNotOverwriteStartValues(t *testing.T) {
|
||||
svc := newMinimalGatewayService()
|
||||
usage := &ClaudeUsage{}
|
||||
|
||||
// 先处理 message_start
|
||||
svc.parseSSEUsage(`{"type":"message_start","message":{"usage":{"input_tokens":100}}}`, usage)
|
||||
require.Equal(t, 100, usage.InputTokens)
|
||||
|
||||
// 再处理 message_delta(output_tokens > 0, input_tokens = 0)
|
||||
svc.parseSSEUsage(`{"type":"message_delta","usage":{"output_tokens":50}}`, usage)
|
||||
require.Equal(t, 100, usage.InputTokens, "delta 中 input_tokens=0 不应覆盖 start 中的值")
|
||||
require.Equal(t, 50, usage.OutputTokens)
|
||||
}
|
||||
|
||||
func TestParseSSEUsage_DeltaOverwritesWithNonZero(t *testing.T) {
|
||||
svc := newMinimalGatewayService()
|
||||
usage := &ClaudeUsage{}
|
||||
|
||||
// GLM 等 API 会在 delta 中包含所有 usage 信息
|
||||
svc.parseSSEUsage(`{"type":"message_delta","usage":{"input_tokens":200,"output_tokens":100,"cache_creation_input_tokens":30,"cache_read_input_tokens":60}}`, usage)
|
||||
require.Equal(t, 200, usage.InputTokens)
|
||||
require.Equal(t, 100, usage.OutputTokens)
|
||||
require.Equal(t, 30, usage.CacheCreationInputTokens)
|
||||
require.Equal(t, 60, usage.CacheReadInputTokens)
|
||||
}
|
||||
|
||||
func TestParseSSEUsage_DeltaDoesNotResetCacheCreationBreakdown(t *testing.T) {
|
||||
svc := newMinimalGatewayService()
|
||||
usage := &ClaudeUsage{}
|
||||
|
||||
// 先在 message_start 中写入非零 5m/1h 明细
|
||||
svc.parseSSEUsage(`{"type":"message_start","message":{"usage":{"input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":30,"ephemeral_1h_input_tokens":70}}}}`, usage)
|
||||
require.Equal(t, 30, usage.CacheCreation5mTokens)
|
||||
require.Equal(t, 70, usage.CacheCreation1hTokens)
|
||||
|
||||
// 后续 delta 带默认 0,不应覆盖已有非零值
|
||||
svc.parseSSEUsage(`{"type":"message_delta","usage":{"output_tokens":12,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0}}}`, usage)
|
||||
require.Equal(t, 30, usage.CacheCreation5mTokens, "delta 的 0 值不应重置 5m 明细")
|
||||
require.Equal(t, 70, usage.CacheCreation1hTokens, "delta 的 0 值不应重置 1h 明细")
|
||||
require.Equal(t, 12, usage.OutputTokens)
|
||||
}
|
||||
|
||||
func TestParseSSEUsage_InvalidJSON(t *testing.T) {
|
||||
svc := newMinimalGatewayService()
|
||||
usage := &ClaudeUsage{}
|
||||
|
||||
// 无效 JSON 不应 panic
|
||||
svc.parseSSEUsage("not json", usage)
|
||||
require.Equal(t, 0, usage.InputTokens)
|
||||
require.Equal(t, 0, usage.OutputTokens)
|
||||
}
|
||||
|
||||
func TestParseSSEUsage_UnknownType(t *testing.T) {
|
||||
svc := newMinimalGatewayService()
|
||||
usage := &ClaudeUsage{}
|
||||
|
||||
// 不是 message_start 或 message_delta 的类型
|
||||
svc.parseSSEUsage(`{"type":"content_block_delta","delta":{"text":"hello"}}`, usage)
|
||||
require.Equal(t, 0, usage.InputTokens)
|
||||
require.Equal(t, 0, usage.OutputTokens)
|
||||
}
|
||||
|
||||
func TestParseSSEUsage_EmptyString(t *testing.T) {
|
||||
svc := newMinimalGatewayService()
|
||||
usage := &ClaudeUsage{}
|
||||
|
||||
svc.parseSSEUsage("", usage)
|
||||
require.Equal(t, 0, usage.InputTokens)
|
||||
}
|
||||
|
||||
func TestParseSSEUsage_DoneEvent(t *testing.T) {
|
||||
svc := newMinimalGatewayService()
|
||||
usage := &ClaudeUsage{}
|
||||
|
||||
// [DONE] 事件不应影响 usage
|
||||
svc.parseSSEUsage("[DONE]", usage)
|
||||
require.Equal(t, 0, usage.InputTokens)
|
||||
}
|
||||
|
||||
// --- 流式响应端到端测试 ---
|
||||
|
||||
func TestHandleStreamingResponse_CacheTokens(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newMinimalGatewayService()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: pr}
|
||||
|
||||
go func() {
|
||||
defer func() { _ = pw.Close() }()
|
||||
_, _ = pw.Write([]byte("data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":10,\"cache_creation_input_tokens\":20,\"cache_read_input_tokens\":30}}}\n\n"))
|
||||
_, _ = pw.Write([]byte("data: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":15}}\n\n"))
|
||||
_, _ = pw.Write([]byte("data: [DONE]\n\n"))
|
||||
}()
|
||||
|
||||
result, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "model", "model", false)
|
||||
_ = pr.Close()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.usage)
|
||||
require.Equal(t, 10, result.usage.InputTokens)
|
||||
require.Equal(t, 15, result.usage.OutputTokens)
|
||||
require.Equal(t, 20, result.usage.CacheCreationInputTokens)
|
||||
require.Equal(t, 30, result.usage.CacheReadInputTokens)
|
||||
}
|
||||
|
||||
func TestHandleStreamingResponse_EmptyStream(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newMinimalGatewayService()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: pr}
|
||||
|
||||
go func() {
|
||||
// 直接关闭,不发送任何事件
|
||||
_ = pw.Close()
|
||||
}()
|
||||
|
||||
result, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "model", "model", false)
|
||||
_ = pr.Close()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
}
|
||||
|
||||
func TestHandleStreamingResponse_SpecialCharactersInJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newMinimalGatewayService()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: pr}
|
||||
|
||||
go func() {
|
||||
defer func() { _ = pw.Close() }()
|
||||
// 包含特殊字符的 content_block_delta(引号、换行、Unicode)
|
||||
_, _ = pw.Write([]byte("data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello \\\"world\\\"\\n你好\"}}\n\n"))
|
||||
_, _ = pw.Write([]byte("data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":5}}}\n\n"))
|
||||
_, _ = pw.Write([]byte("data: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":3}}\n\n"))
|
||||
_, _ = pw.Write([]byte("data: [DONE]\n\n"))
|
||||
}()
|
||||
|
||||
result, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "model", "model", false)
|
||||
_ = pr.Close()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.usage)
|
||||
require.Equal(t, 5, result.usage.InputTokens)
|
||||
require.Equal(t, 3, result.usage.OutputTokens)
|
||||
|
||||
// 验证响应中包含转发的数据
|
||||
body := rec.Body.String()
|
||||
require.Contains(t, body, "content_block_delta", "响应应包含转发的 SSE 事件")
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestDecrementWaitCount_NilCache 确保 nil cache 不会 panic
|
||||
func TestDecrementWaitCount_NilCache(t *testing.T) {
|
||||
svc := &ConcurrencyService{cache: nil}
|
||||
// 不应 panic
|
||||
svc.DecrementWaitCount(context.Background(), 1)
|
||||
}
|
||||
|
||||
// TestDecrementWaitCount_CacheError 确保 cache 错误不会传播
|
||||
func TestDecrementWaitCount_CacheError(t *testing.T) {
|
||||
cache := &stubConcurrencyCacheForTest{}
|
||||
svc := NewConcurrencyService(cache)
|
||||
// DecrementWaitCount 使用 background context,错误只记录日志不传播
|
||||
svc.DecrementWaitCount(context.Background(), 1)
|
||||
}
|
||||
|
||||
// TestDecrementAccountWaitCount_NilCache 确保 nil cache 不会 panic
|
||||
func TestDecrementAccountWaitCount_NilCache(t *testing.T) {
|
||||
svc := &ConcurrencyService{cache: nil}
|
||||
svc.DecrementAccountWaitCount(context.Background(), 1)
|
||||
}
|
||||
|
||||
// TestDecrementAccountWaitCount_CacheError 确保 cache 错误不会传播
|
||||
func TestDecrementAccountWaitCount_CacheError(t *testing.T) {
|
||||
cache := &stubConcurrencyCacheForTest{}
|
||||
svc := NewConcurrencyService(cache)
|
||||
svc.DecrementAccountWaitCount(context.Background(), 1)
|
||||
}
|
||||
|
||||
// TestWaitingQueueFlow_IncrementThenDecrement 测试完整的等待队列增减流程
|
||||
func TestWaitingQueueFlow_IncrementThenDecrement(t *testing.T) {
|
||||
cache := &stubConcurrencyCacheForTest{waitAllowed: true}
|
||||
svc := NewConcurrencyService(cache)
|
||||
|
||||
// 进入等待队列
|
||||
allowed, err := svc.IncrementWaitCount(context.Background(), 1, 25)
|
||||
require.NoError(t, err)
|
||||
require.True(t, allowed)
|
||||
|
||||
// 离开等待队列(不应 panic)
|
||||
svc.DecrementWaitCount(context.Background(), 1)
|
||||
}
|
||||
|
||||
// TestWaitingQueueFlow_AccountLevel 测试账号级等待队列流程
|
||||
func TestWaitingQueueFlow_AccountLevel(t *testing.T) {
|
||||
cache := &stubConcurrencyCacheForTest{waitAllowed: true}
|
||||
svc := NewConcurrencyService(cache)
|
||||
|
||||
// 进入账号等待队列
|
||||
allowed, err := svc.IncrementAccountWaitCount(context.Background(), 42, 10)
|
||||
require.NoError(t, err)
|
||||
require.True(t, allowed)
|
||||
|
||||
// 离开账号等待队列
|
||||
svc.DecrementAccountWaitCount(context.Background(), 42)
|
||||
}
|
||||
|
||||
// TestWaitingQueueFull_Returns429Signal 测试等待队列满时返回 false
|
||||
func TestWaitingQueueFull_Returns429Signal(t *testing.T) {
|
||||
// waitAllowed=false 模拟队列已满
|
||||
cache := &stubConcurrencyCacheForTest{waitAllowed: false}
|
||||
svc := NewConcurrencyService(cache)
|
||||
|
||||
// 用户级等待队列满
|
||||
allowed, err := svc.IncrementWaitCount(context.Background(), 1, 25)
|
||||
require.NoError(t, err)
|
||||
require.False(t, allowed, "等待队列满时应返回 false(调用方根据此返回 429)")
|
||||
|
||||
// 账号级等待队列满
|
||||
allowed, err = svc.IncrementAccountWaitCount(context.Background(), 1, 10)
|
||||
require.NoError(t, err)
|
||||
require.False(t, allowed, "账号等待队列满时应返回 false")
|
||||
}
|
||||
|
||||
// TestWaitingQueue_FailOpen_OnCacheError 测试 Redis 故障时 fail-open
|
||||
func TestWaitingQueue_FailOpen_OnCacheError(t *testing.T) {
|
||||
cache := &stubConcurrencyCacheForTest{waitErr: errors.New("redis connection refused")}
|
||||
svc := NewConcurrencyService(cache)
|
||||
|
||||
// 用户级:Redis 错误时允许通过
|
||||
allowed, err := svc.IncrementWaitCount(context.Background(), 1, 25)
|
||||
require.NoError(t, err, "Redis 错误不应向调用方传播")
|
||||
require.True(t, allowed, "Redis 故障时应 fail-open 放行")
|
||||
|
||||
// 账号级:同样 fail-open
|
||||
allowed, err = svc.IncrementAccountWaitCount(context.Background(), 1, 10)
|
||||
require.NoError(t, err, "Redis 错误不应向调用方传播")
|
||||
require.True(t, allowed, "Redis 故障时应 fail-open 放行")
|
||||
}
|
||||
|
||||
// TestCalculateMaxWait_Scenarios 测试最大等待队列大小计算
|
||||
func TestCalculateMaxWait_Scenarios(t *testing.T) {
|
||||
tests := []struct {
|
||||
concurrency int
|
||||
expected int
|
||||
}{
|
||||
{5, 25}, // 5 + 20
|
||||
{10, 30}, // 10 + 20
|
||||
{1, 21}, // 1 + 20
|
||||
{0, 21}, // min(1) + 20
|
||||
{-1, 21}, // min(1) + 20
|
||||
{-10, 21}, // min(1) + 20
|
||||
{100, 120}, // 100 + 20
|
||||
}
|
||||
for _, tt := range tests {
|
||||
result := CalculateMaxWait(tt.concurrency)
|
||||
require.Equal(t, tt.expected, result, "CalculateMaxWait(%d)", tt.concurrency)
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,12 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/googleapi"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const geminiStickySessionTTL = time.Hour
|
||||
@@ -51,6 +53,7 @@ type GeminiMessagesCompatService struct {
|
||||
httpUpstream HTTPUpstream
|
||||
antigravityGatewayService *AntigravityGatewayService
|
||||
cfg *config.Config
|
||||
responseHeaderFilter *responseheaders.CompiledHeaderFilter
|
||||
}
|
||||
|
||||
func NewGeminiMessagesCompatService(
|
||||
@@ -74,6 +77,7 @@ func NewGeminiMessagesCompatService(
|
||||
httpUpstream: httpUpstream,
|
||||
antigravityGatewayService: antigravityGatewayService,
|
||||
cfg: cfg,
|
||||
responseHeaderFilter: compileResponseHeaderFilter(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +231,16 @@ func (s *GeminiMessagesCompatService) isAccountUsableForRequest(
|
||||
account *Account,
|
||||
requestedModel, platform string,
|
||||
useMixedScheduling bool,
|
||||
) bool {
|
||||
return s.isAccountUsableForRequestWithPrecheck(ctx, account, requestedModel, platform, useMixedScheduling, nil)
|
||||
}
|
||||
|
||||
func (s *GeminiMessagesCompatService) isAccountUsableForRequestWithPrecheck(
|
||||
ctx context.Context,
|
||||
account *Account,
|
||||
requestedModel, platform string,
|
||||
useMixedScheduling bool,
|
||||
precheckResult map[int64]bool,
|
||||
) bool {
|
||||
// 检查模型调度能力
|
||||
// Check model scheduling capability
|
||||
@@ -248,7 +262,7 @@ func (s *GeminiMessagesCompatService) isAccountUsableForRequest(
|
||||
|
||||
// 速率限制预检
|
||||
// Rate limit precheck
|
||||
if !s.passesRateLimitPreCheck(ctx, account, requestedModel) {
|
||||
if !s.passesRateLimitPreCheckWithCache(ctx, account, requestedModel, precheckResult) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -270,18 +284,20 @@ func (s *GeminiMessagesCompatService) isAccountValidForPlatform(account *Account
|
||||
return false
|
||||
}
|
||||
|
||||
// passesRateLimitPreCheck 执行速率限制预检。
|
||||
// 返回 true 表示通过预检或无需预检。
|
||||
//
|
||||
// passesRateLimitPreCheck performs rate limit precheck.
|
||||
// Returns true if passed or precheck not required.
|
||||
func (s *GeminiMessagesCompatService) passesRateLimitPreCheck(ctx context.Context, account *Account, requestedModel string) bool {
|
||||
func (s *GeminiMessagesCompatService) passesRateLimitPreCheckWithCache(ctx context.Context, account *Account, requestedModel string, precheckResult map[int64]bool) bool {
|
||||
if s.rateLimitService == nil || requestedModel == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
if precheckResult != nil {
|
||||
if ok, exists := precheckResult[account.ID]; exists {
|
||||
return ok
|
||||
}
|
||||
}
|
||||
|
||||
ok, err := s.rateLimitService.PreCheckUsage(ctx, account, requestedModel)
|
||||
if err != nil {
|
||||
log.Printf("[Gemini PreCheck] Account %d precheck error: %v", account.ID, err)
|
||||
logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini PreCheck] Account %d precheck error: %v", account.ID, err)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
@@ -300,6 +316,7 @@ func (s *GeminiMessagesCompatService) selectBestGeminiAccount(
|
||||
useMixedScheduling bool,
|
||||
) *Account {
|
||||
var selected *Account
|
||||
precheckResult := s.buildPreCheckUsageResultMap(ctx, accounts, requestedModel)
|
||||
|
||||
for i := range accounts {
|
||||
acc := &accounts[i]
|
||||
@@ -310,7 +327,7 @@ func (s *GeminiMessagesCompatService) selectBestGeminiAccount(
|
||||
}
|
||||
|
||||
// 检查账号是否可用于当前请求
|
||||
if !s.isAccountUsableForRequest(ctx, acc, requestedModel, platform, useMixedScheduling) {
|
||||
if !s.isAccountUsableForRequestWithPrecheck(ctx, acc, requestedModel, platform, useMixedScheduling, precheckResult) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -328,6 +345,23 @@ func (s *GeminiMessagesCompatService) selectBestGeminiAccount(
|
||||
return selected
|
||||
}
|
||||
|
||||
func (s *GeminiMessagesCompatService) buildPreCheckUsageResultMap(ctx context.Context, accounts []Account, requestedModel string) map[int64]bool {
|
||||
if s.rateLimitService == nil || requestedModel == "" || len(accounts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
candidates := make([]*Account, 0, len(accounts))
|
||||
for i := range accounts {
|
||||
candidates = append(candidates, &accounts[i])
|
||||
}
|
||||
|
||||
result, err := s.rateLimitService.PreCheckUsageBatch(ctx, candidates, requestedModel)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini PreCheckBatch] failed: %v", err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// isBetterGeminiAccount 判断 candidate 是否比 current 更优。
|
||||
// 规则:优先级更高(数值更小)优先;同优先级时,未使用过的优先(OAuth > 非 OAuth),其次是最久未使用的。
|
||||
//
|
||||
@@ -397,7 +431,10 @@ func (s *GeminiMessagesCompatService) listSchedulableAccountsOnce(ctx context.Co
|
||||
if groupID != nil {
|
||||
return s.accountRepo.ListSchedulableByGroupIDAndPlatforms(ctx, *groupID, queryPlatforms)
|
||||
}
|
||||
return s.accountRepo.ListSchedulableByPlatforms(ctx, queryPlatforms)
|
||||
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
|
||||
return s.accountRepo.ListSchedulableByPlatforms(ctx, queryPlatforms)
|
||||
}
|
||||
return s.accountRepo.ListSchedulableUngroupedByPlatforms(ctx, queryPlatforms)
|
||||
}
|
||||
|
||||
func (s *GeminiMessagesCompatService) validateUpstreamBaseURL(raw string) (string, error) {
|
||||
@@ -697,7 +734,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
|
||||
Message: safeErr,
|
||||
})
|
||||
if attempt < geminiMaxRetries {
|
||||
log.Printf("Gemini account %d: upstream request failed, retry %d/%d: %v", account.ID, attempt, geminiMaxRetries, err)
|
||||
logger.LegacyPrintf("service.gemini_messages_compat", "Gemini account %d: upstream request failed, retry %d/%d: %v", account.ID, attempt, geminiMaxRetries, err)
|
||||
sleepGeminiBackoff(attempt)
|
||||
continue
|
||||
}
|
||||
@@ -753,7 +790,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
|
||||
}
|
||||
retryGeminiReq, txErr := convertClaudeMessagesToGeminiGenerateContent(strippedClaudeBody)
|
||||
if txErr == nil {
|
||||
log.Printf("Gemini account %d: detected signature-related 400, retrying with downgraded Claude blocks (%s)", account.ID, stageName)
|
||||
logger.LegacyPrintf("service.gemini_messages_compat", "Gemini account %d: detected signature-related 400, retrying with downgraded Claude blocks (%s)", account.ID, stageName)
|
||||
geminiReq = retryGeminiReq
|
||||
// Consume one retry budget attempt and continue with the updated request payload.
|
||||
sleepGeminiBackoff(1)
|
||||
@@ -820,7 +857,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
|
||||
log.Printf("Gemini account %d: upstream status %d, retry %d/%d", account.ID, resp.StatusCode, attempt, geminiMaxRetries)
|
||||
logger.LegacyPrintf("service.gemini_messages_compat", "Gemini account %d: upstream status %d, retry %d/%d", account.ID, resp.StatusCode, attempt, geminiMaxRetries)
|
||||
sleepGeminiBackoff(attempt)
|
||||
continue
|
||||
}
|
||||
@@ -968,7 +1005,8 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
|
||||
if err != nil {
|
||||
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to read upstream stream")
|
||||
}
|
||||
claudeResp, usageObj2 := convertGeminiToClaudeMessage(collected, originalModel)
|
||||
collectedBytes, _ := json.Marshal(collected)
|
||||
claudeResp, usageObj2 := convertGeminiToClaudeMessage(collected, originalModel, collectedBytes)
|
||||
c.JSON(http.StatusOK, claudeResp)
|
||||
usage = usageObj2
|
||||
if usageObj != nil && (usageObj.InputTokens > 0 || usageObj.OutputTokens > 0) {
|
||||
@@ -1195,7 +1233,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
|
||||
Message: safeErr,
|
||||
})
|
||||
if attempt < geminiMaxRetries {
|
||||
log.Printf("Gemini account %d: upstream request failed, retry %d/%d: %v", account.ID, attempt, geminiMaxRetries, err)
|
||||
logger.LegacyPrintf("service.gemini_messages_compat", "Gemini account %d: upstream request failed, retry %d/%d: %v", account.ID, attempt, geminiMaxRetries, err)
|
||||
sleepGeminiBackoff(attempt)
|
||||
continue
|
||||
}
|
||||
@@ -1264,7 +1302,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
|
||||
log.Printf("Gemini account %d: upstream status %d, retry %d/%d", account.ID, resp.StatusCode, attempt, geminiMaxRetries)
|
||||
logger.LegacyPrintf("service.gemini_messages_compat", "Gemini account %d: upstream status %d, retry %d/%d", account.ID, resp.StatusCode, attempt, geminiMaxRetries)
|
||||
sleepGeminiBackoff(attempt)
|
||||
continue
|
||||
}
|
||||
@@ -1424,7 +1462,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
|
||||
maxBytes = 2048
|
||||
}
|
||||
upstreamDetail = truncateString(string(respBody), maxBytes)
|
||||
log.Printf("[Gemini] native upstream error %d: %s", resp.StatusCode, truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes))
|
||||
logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini] native upstream error %d: %s", resp.StatusCode, truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes))
|
||||
}
|
||||
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
@@ -1601,7 +1639,7 @@ func (s *GeminiMessagesCompatService) writeGeminiMappedError(c *gin.Context, acc
|
||||
})
|
||||
|
||||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
log.Printf("[Gemini] upstream error %d: %s", upstreamStatus, truncateForLog(body, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes))
|
||||
logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini] upstream error %d: %s", upstreamStatus, truncateForLog(body, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes))
|
||||
}
|
||||
|
||||
if status, errType, errMsg, matched := applyErrorPassthroughRule(
|
||||
@@ -1821,12 +1859,17 @@ func (s *GeminiMessagesCompatService) handleNonStreamingResponse(c *gin.Context,
|
||||
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to read upstream response")
|
||||
}
|
||||
|
||||
geminiResp, err := unwrapGeminiResponse(body)
|
||||
unwrappedBody, err := unwrapGeminiResponse(body)
|
||||
if err != nil {
|
||||
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to parse upstream response")
|
||||
}
|
||||
|
||||
claudeResp, usage := convertGeminiToClaudeMessage(geminiResp, originalModel)
|
||||
var geminiResp map[string]any
|
||||
if err := json.Unmarshal(unwrappedBody, &geminiResp); err != nil {
|
||||
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to parse upstream response")
|
||||
}
|
||||
|
||||
claudeResp, usage := convertGeminiToClaudeMessage(geminiResp, originalModel, unwrappedBody)
|
||||
c.JSON(http.StatusOK, claudeResp)
|
||||
|
||||
return usage, nil
|
||||
@@ -1899,11 +1942,16 @@ func (s *GeminiMessagesCompatService) handleStreamingResponse(c *gin.Context, re
|
||||
continue
|
||||
}
|
||||
|
||||
geminiResp, err := unwrapGeminiResponse([]byte(payload))
|
||||
unwrappedBytes, err := unwrapGeminiResponse([]byte(payload))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var geminiResp map[string]any
|
||||
if err := json.Unmarshal(unwrappedBytes, &geminiResp); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if fr := extractGeminiFinishReason(geminiResp); fr != "" {
|
||||
finishReason = fr
|
||||
}
|
||||
@@ -2030,7 +2078,7 @@ func (s *GeminiMessagesCompatService) handleStreamingResponse(c *gin.Context, re
|
||||
}
|
||||
}
|
||||
|
||||
if u := extractGeminiUsage(geminiResp); u != nil {
|
||||
if u := extractGeminiUsage(unwrappedBytes); u != nil {
|
||||
usage = *u
|
||||
}
|
||||
|
||||
@@ -2121,11 +2169,7 @@ func unwrapIfNeeded(isOAuth bool, raw []byte) []byte {
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
b, err := json.Marshal(inner)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return b
|
||||
return inner
|
||||
}
|
||||
|
||||
func collectGeminiSSE(body io.Reader, isOAuth bool) (map[string]any, *ClaudeUsage, error) {
|
||||
@@ -2149,17 +2193,20 @@ func collectGeminiSSE(body io.Reader, isOAuth bool) (map[string]any, *ClaudeUsag
|
||||
}
|
||||
default:
|
||||
var parsed map[string]any
|
||||
var rawBytes []byte
|
||||
if isOAuth {
|
||||
inner, err := unwrapGeminiResponse([]byte(payload))
|
||||
if err == nil && inner != nil {
|
||||
parsed = inner
|
||||
innerBytes, err := unwrapGeminiResponse([]byte(payload))
|
||||
if err == nil {
|
||||
rawBytes = innerBytes
|
||||
_ = json.Unmarshal(innerBytes, &parsed)
|
||||
}
|
||||
} else {
|
||||
_ = json.Unmarshal([]byte(payload), &parsed)
|
||||
rawBytes = []byte(payload)
|
||||
_ = json.Unmarshal(rawBytes, &parsed)
|
||||
}
|
||||
if parsed != nil {
|
||||
last = parsed
|
||||
if u := extractGeminiUsage(parsed); u != nil {
|
||||
if u := extractGeminiUsage(rawBytes); u != nil {
|
||||
usage = u
|
||||
}
|
||||
if parts := extractGeminiParts(parsed); len(parts) > 0 {
|
||||
@@ -2288,53 +2335,27 @@ func isGeminiInsufficientScope(headers http.Header, body []byte) bool {
|
||||
}
|
||||
|
||||
func estimateGeminiCountTokens(reqBody []byte) int {
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(reqBody, &obj); err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
var texts []string
|
||||
total := 0
|
||||
|
||||
// systemInstruction.parts[].text
|
||||
if si, ok := obj["systemInstruction"].(map[string]any); ok {
|
||||
if parts, ok := si["parts"].([]any); ok {
|
||||
for _, p := range parts {
|
||||
if pm, ok := p.(map[string]any); ok {
|
||||
if t, ok := pm["text"].(string); ok && strings.TrimSpace(t) != "" {
|
||||
texts = append(texts, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
gjson.GetBytes(reqBody, "systemInstruction.parts").ForEach(func(_, part gjson.Result) bool {
|
||||
if t := strings.TrimSpace(part.Get("text").String()); t != "" {
|
||||
total += estimateTokensForText(t)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// contents[].parts[].text
|
||||
if contents, ok := obj["contents"].([]any); ok {
|
||||
for _, c := range contents {
|
||||
cm, ok := c.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
gjson.GetBytes(reqBody, "contents").ForEach(func(_, content gjson.Result) bool {
|
||||
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
|
||||
if t := strings.TrimSpace(part.Get("text").String()); t != "" {
|
||||
total += estimateTokensForText(t)
|
||||
}
|
||||
parts, ok := cm["parts"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, p := range parts {
|
||||
pm, ok := p.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if t, ok := pm["text"].(string); ok && strings.TrimSpace(t) != "" {
|
||||
texts = append(texts, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
total := 0
|
||||
for _, t := range texts {
|
||||
total += estimateTokensForText(t)
|
||||
}
|
||||
if total < 0 {
|
||||
return 0
|
||||
}
|
||||
@@ -2372,31 +2393,39 @@ type UpstreamHTTPResult struct {
|
||||
}
|
||||
|
||||
func (s *GeminiMessagesCompatService) handleNativeNonStreamingResponse(c *gin.Context, resp *http.Response, isOAuth bool) (*ClaudeUsage, error) {
|
||||
// Log response headers for debugging
|
||||
log.Printf("[GeminiAPI] ========== Response Headers ==========")
|
||||
for key, values := range resp.Header {
|
||||
if strings.HasPrefix(strings.ToLower(key), "x-ratelimit") {
|
||||
log.Printf("[GeminiAPI] %s: %v", key, values)
|
||||
if s.cfg != nil && s.cfg.Gateway.GeminiDebugResponseHeaders {
|
||||
logger.LegacyPrintf("service.gemini_messages_compat", "[GeminiAPI] ========== Response Headers ==========")
|
||||
for key, values := range resp.Header {
|
||||
if strings.HasPrefix(strings.ToLower(key), "x-ratelimit") {
|
||||
logger.LegacyPrintf("service.gemini_messages_compat", "[GeminiAPI] %s: %v", key, values)
|
||||
}
|
||||
}
|
||||
logger.LegacyPrintf("service.gemini_messages_compat", "[GeminiAPI] ========================================")
|
||||
}
|
||||
log.Printf("[GeminiAPI] ========================================")
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
maxBytes := resolveUpstreamResponseReadLimit(s.cfg)
|
||||
respBody, err := readUpstreamResponseBodyLimited(resp.Body, maxBytes)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUpstreamResponseBodyTooLarge) {
|
||||
setOpsUpstreamError(c, http.StatusBadGateway, "upstream response too large", "")
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"error": gin.H{
|
||||
"type": "upstream_error",
|
||||
"message": "Upstream response too large",
|
||||
},
|
||||
})
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if isOAuth {
|
||||
parsed, err = unwrapGeminiResponse(respBody)
|
||||
if err == nil && parsed != nil {
|
||||
respBody, _ = json.Marshal(parsed)
|
||||
unwrappedBody, uwErr := unwrapGeminiResponse(respBody)
|
||||
if uwErr == nil {
|
||||
respBody = unwrappedBody
|
||||
}
|
||||
} else {
|
||||
_ = json.Unmarshal(respBody, &parsed)
|
||||
}
|
||||
|
||||
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.cfg.Security.ResponseHeaders)
|
||||
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
@@ -2404,26 +2433,25 @@ func (s *GeminiMessagesCompatService) handleNativeNonStreamingResponse(c *gin.Co
|
||||
}
|
||||
c.Data(resp.StatusCode, contentType, respBody)
|
||||
|
||||
if parsed != nil {
|
||||
if u := extractGeminiUsage(parsed); u != nil {
|
||||
return u, nil
|
||||
}
|
||||
if u := extractGeminiUsage(respBody); u != nil {
|
||||
return u, nil
|
||||
}
|
||||
return &ClaudeUsage{}, nil
|
||||
}
|
||||
|
||||
func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Context, resp *http.Response, startTime time.Time, isOAuth bool) (*geminiNativeStreamResult, error) {
|
||||
// Log response headers for debugging
|
||||
log.Printf("[GeminiAPI] ========== Streaming Response Headers ==========")
|
||||
for key, values := range resp.Header {
|
||||
if strings.HasPrefix(strings.ToLower(key), "x-ratelimit") {
|
||||
log.Printf("[GeminiAPI] %s: %v", key, values)
|
||||
if s.cfg != nil && s.cfg.Gateway.GeminiDebugResponseHeaders {
|
||||
logger.LegacyPrintf("service.gemini_messages_compat", "[GeminiAPI] ========== Streaming Response Headers ==========")
|
||||
for key, values := range resp.Header {
|
||||
if strings.HasPrefix(strings.ToLower(key), "x-ratelimit") {
|
||||
logger.LegacyPrintf("service.gemini_messages_compat", "[GeminiAPI] %s: %v", key, values)
|
||||
}
|
||||
}
|
||||
logger.LegacyPrintf("service.gemini_messages_compat", "[GeminiAPI] ====================================================")
|
||||
}
|
||||
log.Printf("[GeminiAPI] ====================================================")
|
||||
|
||||
if s.cfg != nil {
|
||||
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.cfg.Security.ResponseHeaders)
|
||||
if s.responseHeaderFilter != nil {
|
||||
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
||||
}
|
||||
|
||||
c.Status(resp.StatusCode)
|
||||
@@ -2460,23 +2488,19 @@ func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Conte
|
||||
var rawToWrite string
|
||||
rawToWrite = payload
|
||||
|
||||
var parsed map[string]any
|
||||
var rawBytes []byte
|
||||
if isOAuth {
|
||||
inner, err := unwrapGeminiResponse([]byte(payload))
|
||||
if err == nil && inner != nil {
|
||||
parsed = inner
|
||||
if b, err := json.Marshal(inner); err == nil {
|
||||
rawToWrite = string(b)
|
||||
}
|
||||
innerBytes, err := unwrapGeminiResponse([]byte(payload))
|
||||
if err == nil {
|
||||
rawToWrite = string(innerBytes)
|
||||
rawBytes = innerBytes
|
||||
}
|
||||
} else {
|
||||
_ = json.Unmarshal([]byte(payload), &parsed)
|
||||
rawBytes = []byte(payload)
|
||||
}
|
||||
|
||||
if parsed != nil {
|
||||
if u := extractGeminiUsage(parsed); u != nil {
|
||||
usage = u
|
||||
}
|
||||
if u := extractGeminiUsage(rawBytes); u != nil {
|
||||
usage = u
|
||||
}
|
||||
|
||||
if firstTokenMs == nil {
|
||||
@@ -2568,7 +2592,7 @@ func (s *GeminiMessagesCompatService) ForwardAIStudioGET(ctx context.Context, ac
|
||||
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
wwwAuthenticate := resp.Header.Get("Www-Authenticate")
|
||||
filteredHeaders := responseheaders.FilterHeaders(resp.Header, s.cfg.Security.ResponseHeaders)
|
||||
filteredHeaders := responseheaders.FilterHeaders(resp.Header, s.responseHeaderFilter)
|
||||
if wwwAuthenticate != "" {
|
||||
filteredHeaders.Set("Www-Authenticate", wwwAuthenticate)
|
||||
}
|
||||
@@ -2579,19 +2603,18 @@ func (s *GeminiMessagesCompatService) ForwardAIStudioGET(ctx context.Context, ac
|
||||
}, nil
|
||||
}
|
||||
|
||||
func unwrapGeminiResponse(raw []byte) (map[string]any, error) {
|
||||
var outer map[string]any
|
||||
if err := json.Unmarshal(raw, &outer); err != nil {
|
||||
return nil, err
|
||||
// unwrapGeminiResponse 解包 Gemini OAuth 响应中的 response 字段
|
||||
// 使用 gjson 零拷贝提取,避免完整 Unmarshal+Marshal
|
||||
func unwrapGeminiResponse(raw []byte) ([]byte, error) {
|
||||
result := gjson.GetBytes(raw, "response")
|
||||
if result.Exists() && result.Type == gjson.JSON {
|
||||
return []byte(result.Raw), nil
|
||||
}
|
||||
if resp, ok := outer["response"].(map[string]any); ok && resp != nil {
|
||||
return resp, nil
|
||||
}
|
||||
return outer, nil
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func convertGeminiToClaudeMessage(geminiResp map[string]any, originalModel string) (map[string]any, *ClaudeUsage) {
|
||||
usage := extractGeminiUsage(geminiResp)
|
||||
func convertGeminiToClaudeMessage(geminiResp map[string]any, originalModel string, rawData []byte) (map[string]any, *ClaudeUsage) {
|
||||
usage := extractGeminiUsage(rawData)
|
||||
if usage == nil {
|
||||
usage = &ClaudeUsage{}
|
||||
}
|
||||
@@ -2655,15 +2678,15 @@ func convertGeminiToClaudeMessage(geminiResp map[string]any, originalModel strin
|
||||
return resp, usage
|
||||
}
|
||||
|
||||
func extractGeminiUsage(geminiResp map[string]any) *ClaudeUsage {
|
||||
usageMeta, ok := geminiResp["usageMetadata"].(map[string]any)
|
||||
if !ok || usageMeta == nil {
|
||||
func extractGeminiUsage(data []byte) *ClaudeUsage {
|
||||
usage := gjson.GetBytes(data, "usageMetadata")
|
||||
if !usage.Exists() {
|
||||
return nil
|
||||
}
|
||||
prompt, _ := asInt(usageMeta["promptTokenCount"])
|
||||
cand, _ := asInt(usageMeta["candidatesTokenCount"])
|
||||
cached, _ := asInt(usageMeta["cachedContentTokenCount"])
|
||||
thoughts, _ := asInt(usageMeta["thoughtsTokenCount"])
|
||||
prompt := int(usage.Get("promptTokenCount").Int())
|
||||
cand := int(usage.Get("candidatesTokenCount").Int())
|
||||
cached := int(usage.Get("cachedContentTokenCount").Int())
|
||||
thoughts := int(usage.Get("thoughtsTokenCount").Int())
|
||||
// 注意:Gemini 的 promptTokenCount 包含 cachedContentTokenCount,
|
||||
// 但 Claude 的 input_tokens 不包含 cache_read_input_tokens,需要减去
|
||||
return &ClaudeUsage{
|
||||
@@ -2721,16 +2744,16 @@ func (s *GeminiMessagesCompatService) handleGeminiUpstreamError(ctx context.Cont
|
||||
cooldown = s.rateLimitService.GeminiCooldown(ctx, account)
|
||||
}
|
||||
ra = time.Now().Add(cooldown)
|
||||
log.Printf("[Gemini 429] Account %d (Code Assist, tier=%s, project=%s) rate limited, cooldown=%v", account.ID, tierID, projectID, time.Until(ra).Truncate(time.Second))
|
||||
logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini 429] Account %d (Code Assist, tier=%s, project=%s) rate limited, cooldown=%v", account.ID, tierID, projectID, time.Until(ra).Truncate(time.Second))
|
||||
} else {
|
||||
// API Key / AI Studio OAuth: PST 午夜
|
||||
if ts := nextGeminiDailyResetUnix(); ts != nil {
|
||||
ra = time.Unix(*ts, 0)
|
||||
log.Printf("[Gemini 429] Account %d (API Key/AI Studio, type=%s) rate limited, reset at PST midnight (%v)", account.ID, account.Type, ra)
|
||||
logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini 429] Account %d (API Key/AI Studio, type=%s) rate limited, reset at PST midnight (%v)", account.ID, account.Type, ra)
|
||||
} else {
|
||||
// 兜底:5 分钟
|
||||
ra = time.Now().Add(5 * time.Minute)
|
||||
log.Printf("[Gemini 429] Account %d rate limited, fallback to 5min", account.ID)
|
||||
logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini 429] Account %d rate limited, fallback to 5min", account.ID)
|
||||
}
|
||||
}
|
||||
_ = s.accountRepo.SetRateLimited(ctx, account.ID, ra)
|
||||
@@ -2740,45 +2763,41 @@ func (s *GeminiMessagesCompatService) handleGeminiUpstreamError(ctx context.Cont
|
||||
// 使用解析到的重置时间
|
||||
resetTime := time.Unix(*resetAt, 0)
|
||||
_ = s.accountRepo.SetRateLimited(ctx, account.ID, resetTime)
|
||||
log.Printf("[Gemini 429] Account %d rate limited until %v (oauth_type=%s, tier=%s)",
|
||||
logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini 429] Account %d rate limited until %v (oauth_type=%s, tier=%s)",
|
||||
account.ID, resetTime, oauthType, tierID)
|
||||
}
|
||||
|
||||
// ParseGeminiRateLimitResetTime 解析 Gemini 格式的 429 响应,返回重置时间的 Unix 时间戳
|
||||
func ParseGeminiRateLimitResetTime(body []byte) *int64 {
|
||||
// Try to parse metadata.quotaResetDelay like "12.345s"
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(body, &parsed); err == nil {
|
||||
if errObj, ok := parsed["error"].(map[string]any); ok {
|
||||
if msg, ok := errObj["message"].(string); ok {
|
||||
if looksLikeGeminiDailyQuota(msg) {
|
||||
if ts := nextGeminiDailyResetUnix(); ts != nil {
|
||||
return ts
|
||||
}
|
||||
}
|
||||
}
|
||||
if details, ok := errObj["details"].([]any); ok {
|
||||
for _, d := range details {
|
||||
dm, ok := d.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if meta, ok := dm["metadata"].(map[string]any); ok {
|
||||
if v, ok := meta["quotaResetDelay"].(string); ok {
|
||||
if dur, err := time.ParseDuration(v); err == nil {
|
||||
// Use ceil to avoid undercounting fractional seconds (e.g. 10.1s should not become 10s),
|
||||
// which can affect scheduling decisions around thresholds (like 10s).
|
||||
ts := time.Now().Unix() + int64(math.Ceil(dur.Seconds()))
|
||||
return &ts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 第一阶段:gjson 结构化提取
|
||||
errMsg := gjson.GetBytes(body, "error.message").String()
|
||||
if looksLikeGeminiDailyQuota(errMsg) {
|
||||
if ts := nextGeminiDailyResetUnix(); ts != nil {
|
||||
return ts
|
||||
}
|
||||
}
|
||||
|
||||
// Match "Please retry in Xs"
|
||||
// 遍历 error.details 查找 quotaResetDelay
|
||||
var found *int64
|
||||
gjson.GetBytes(body, "error.details").ForEach(func(_, detail gjson.Result) bool {
|
||||
v := detail.Get("metadata.quotaResetDelay").String()
|
||||
if v == "" {
|
||||
return true
|
||||
}
|
||||
if dur, err := time.ParseDuration(v); err == nil {
|
||||
// Use ceil to avoid undercounting fractional seconds (e.g. 10.1s should not become 10s),
|
||||
// which can affect scheduling decisions around thresholds (like 10s).
|
||||
ts := time.Now().Unix() + int64(math.Ceil(dur.Seconds()))
|
||||
found = &ts
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if found != nil {
|
||||
return found
|
||||
}
|
||||
|
||||
// 第二阶段:regex 回退匹配 "Please retry in Xs"
|
||||
matches := retryInRegex.FindStringSubmatch(string(body))
|
||||
if len(matches) == 2 {
|
||||
if dur, err := time.ParseDuration(matches[1] + "s"); err == nil {
|
||||
|
||||
@@ -2,9 +2,16 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -131,6 +138,38 @@ func TestConvertClaudeToolsToGeminiTools_CustomType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiHandleNativeNonStreamingResponse_DebugDisabledDoesNotEmitHeaderLogs(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logSink, restore := captureStructuredLog(t)
|
||||
defer restore()
|
||||
|
||||
svc := &GeminiMessagesCompatService{
|
||||
cfg: &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
GeminiDebugResponseHeaders: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"X-RateLimit-Limit": []string{"60"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(`{"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":2}}`)),
|
||||
}
|
||||
|
||||
usage, err := svc.handleNativeNonStreamingResponse(c, resp, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, usage)
|
||||
require.False(t, logSink.ContainsMessage("[GeminiAPI]"), "debug 关闭时不应输出 Gemini 响应头日志")
|
||||
}
|
||||
|
||||
func TestConvertClaudeMessagesToGeminiGenerateContent_AddsThoughtSignatureForToolUse(t *testing.T) {
|
||||
claudeReq := map[string]any{
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
@@ -206,69 +245,323 @@ func TestEnsureGeminiFunctionCallThoughtSignatures_InsertsWhenMissing(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGeminiUsage_ThoughtsTokenCount(t *testing.T) {
|
||||
// TestUnwrapGeminiResponse 测试 unwrapGeminiResponse 的各种输入场景
|
||||
// 关键区别:只有 response 为 JSON 对象/数组时才解包
|
||||
func TestUnwrapGeminiResponse(t *testing.T) {
|
||||
// 构造 >50KB 的大型 JSON 对象
|
||||
largePadding := strings.Repeat("x", 50*1024)
|
||||
largeInput := []byte(fmt.Sprintf(`{"response":{"id":"big","pad":"%s"}}`, largePadding))
|
||||
largeExpected := fmt.Sprintf(`{"id":"big","pad":"%s"}`, largePadding)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
resp map[string]any
|
||||
wantInput int
|
||||
wantOutput int
|
||||
wantCacheRead int
|
||||
wantNil bool
|
||||
name string
|
||||
input []byte
|
||||
expected string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "with thoughtsTokenCount",
|
||||
resp: map[string]any{
|
||||
"usageMetadata": map[string]any{
|
||||
"promptTokenCount": float64(100),
|
||||
"candidatesTokenCount": float64(20),
|
||||
"thoughtsTokenCount": float64(50),
|
||||
},
|
||||
},
|
||||
wantInput: 100,
|
||||
wantOutput: 70,
|
||||
name: "正常 response 包装(JSON 对象)",
|
||||
input: []byte(`{"response":{"key":"val"}}`),
|
||||
expected: `{"key":"val"}`,
|
||||
},
|
||||
{
|
||||
name: "with thoughtsTokenCount and cache",
|
||||
resp: map[string]any{
|
||||
"usageMetadata": map[string]any{
|
||||
"promptTokenCount": float64(100),
|
||||
"candidatesTokenCount": float64(20),
|
||||
"cachedContentTokenCount": float64(30),
|
||||
"thoughtsTokenCount": float64(50),
|
||||
},
|
||||
},
|
||||
wantInput: 70,
|
||||
wantOutput: 70,
|
||||
wantCacheRead: 30,
|
||||
name: "无包装直接返回",
|
||||
input: []byte(`{"key":"val"}`),
|
||||
expected: `{"key":"val"}`,
|
||||
},
|
||||
{
|
||||
name: "without thoughtsTokenCount (old model)",
|
||||
resp: map[string]any{
|
||||
"usageMetadata": map[string]any{
|
||||
"promptTokenCount": float64(100),
|
||||
"candidatesTokenCount": float64(20),
|
||||
},
|
||||
},
|
||||
wantInput: 100,
|
||||
wantOutput: 20,
|
||||
name: "空 JSON",
|
||||
input: []byte(`{}`),
|
||||
expected: `{}`,
|
||||
},
|
||||
{
|
||||
name: "no usageMetadata",
|
||||
resp: map[string]any{},
|
||||
wantNil: true,
|
||||
name: "null response 返回原始 body",
|
||||
input: []byte(`{"response":null}`),
|
||||
expected: `{"response":null}`,
|
||||
},
|
||||
{
|
||||
name: "非法 JSON 返回原始 body",
|
||||
input: []byte(`not json`),
|
||||
expected: `not json`,
|
||||
},
|
||||
{
|
||||
name: "response 为基础类型 string 返回原始 body",
|
||||
input: []byte(`{"response":"hello"}`),
|
||||
expected: `{"response":"hello"}`,
|
||||
},
|
||||
{
|
||||
name: "嵌套 response 只解一层",
|
||||
input: []byte(`{"response":{"response":{"inner":true}}}`),
|
||||
expected: `{"response":{"inner":true}}`,
|
||||
},
|
||||
{
|
||||
name: "大型 JSON >50KB",
|
||||
input: largeInput,
|
||||
expected: largeExpected,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
usage := extractGeminiUsage(tt.resp)
|
||||
if tt.wantNil {
|
||||
require.Nil(t, usage)
|
||||
got, err := unwrapGeminiResponse(tt.input)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, tt.wantInput, usage.InputTokens)
|
||||
require.Equal(t, tt.wantOutput, usage.OutputTokens)
|
||||
require.Equal(t, tt.wantCacheRead, usage.CacheReadInputTokens)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.expected, strings.TrimSpace(string(got)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task 8.1 — extractGeminiUsage 测试
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestExtractGeminiUsage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantNil bool
|
||||
wantUsage *ClaudeUsage
|
||||
}{
|
||||
{
|
||||
name: "完整 usageMetadata",
|
||||
input: `{"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"cachedContentTokenCount":20}}`,
|
||||
wantNil: false,
|
||||
wantUsage: &ClaudeUsage{
|
||||
InputTokens: 80,
|
||||
OutputTokens: 50,
|
||||
CacheReadInputTokens: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "包含 thoughtsTokenCount",
|
||||
input: `{"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"thoughtsTokenCount":50}}`,
|
||||
wantNil: false,
|
||||
wantUsage: &ClaudeUsage{
|
||||
InputTokens: 100,
|
||||
OutputTokens: 70,
|
||||
CacheReadInputTokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "包含 thoughtsTokenCount 与缓存",
|
||||
input: `{"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"cachedContentTokenCount":30,"thoughtsTokenCount":50}}`,
|
||||
wantNil: false,
|
||||
wantUsage: &ClaudeUsage{
|
||||
InputTokens: 70,
|
||||
OutputTokens: 70,
|
||||
CacheReadInputTokens: 30,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "缺失 cachedContentTokenCount",
|
||||
input: `{"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50}}`,
|
||||
wantNil: false,
|
||||
wantUsage: &ClaudeUsage{
|
||||
InputTokens: 100,
|
||||
OutputTokens: 50,
|
||||
CacheReadInputTokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "无 usageMetadata",
|
||||
input: `{"candidates":[]}`,
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
// gjson 对 null 返回 Exists()=true,因此函数不会返回 nil,
|
||||
// 而是返回全零的 ClaudeUsage。
|
||||
name: "null usageMetadata — gjson Exists 为 true",
|
||||
input: `{"usageMetadata":null}`,
|
||||
wantNil: false,
|
||||
wantUsage: &ClaudeUsage{
|
||||
InputTokens: 0,
|
||||
OutputTokens: 0,
|
||||
CacheReadInputTokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "零值字段",
|
||||
input: `{"usageMetadata":{"promptTokenCount":0,"candidatesTokenCount":0,"cachedContentTokenCount":0}}`,
|
||||
wantNil: false,
|
||||
wantUsage: &ClaudeUsage{
|
||||
InputTokens: 0,
|
||||
OutputTokens: 0,
|
||||
CacheReadInputTokens: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractGeminiUsage([]byte(tt.input))
|
||||
if tt.wantNil {
|
||||
if got != nil {
|
||||
t.Fatalf("期望返回 nil,实际返回 %+v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("期望返回非 nil,实际返回 nil")
|
||||
}
|
||||
if got.InputTokens != tt.wantUsage.InputTokens {
|
||||
t.Errorf("InputTokens: 期望 %d,实际 %d", tt.wantUsage.InputTokens, got.InputTokens)
|
||||
}
|
||||
if got.OutputTokens != tt.wantUsage.OutputTokens {
|
||||
t.Errorf("OutputTokens: 期望 %d,实际 %d", tt.wantUsage.OutputTokens, got.OutputTokens)
|
||||
}
|
||||
if got.CacheReadInputTokens != tt.wantUsage.CacheReadInputTokens {
|
||||
t.Errorf("CacheReadInputTokens: 期望 %d,实际 %d", tt.wantUsage.CacheReadInputTokens, got.CacheReadInputTokens)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task 8.2 — estimateGeminiCountTokens 测试
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestEstimateGeminiCountTokens(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantGt0 bool // 期望结果 > 0
|
||||
wantExact *int // 如果非 nil,期望精确匹配
|
||||
}{
|
||||
{
|
||||
name: "含 systemInstruction 和 contents",
|
||||
input: `{
|
||||
"systemInstruction":{"parts":[{"text":"You are a helpful assistant."}]},
|
||||
"contents":[{"parts":[{"text":"Hello, how are you?"}]}]
|
||||
}`,
|
||||
wantGt0: true,
|
||||
},
|
||||
{
|
||||
name: "仅 contents,无 systemInstruction",
|
||||
input: `{
|
||||
"contents":[{"parts":[{"text":"Hello, how are you?"}]}]
|
||||
}`,
|
||||
wantGt0: true,
|
||||
},
|
||||
{
|
||||
name: "空 parts",
|
||||
input: `{"contents":[{"parts":[]}]}`,
|
||||
wantGt0: false,
|
||||
wantExact: intPtr(0),
|
||||
},
|
||||
{
|
||||
name: "非文本 parts(inlineData)",
|
||||
input: `{"contents":[{"parts":[{"inlineData":{"mimeType":"image/png"}}]}]}`,
|
||||
wantGt0: false,
|
||||
wantExact: intPtr(0),
|
||||
},
|
||||
{
|
||||
name: "空白文本",
|
||||
input: `{"contents":[{"parts":[{"text":" "}]}]}`,
|
||||
wantGt0: false,
|
||||
wantExact: intPtr(0),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := estimateGeminiCountTokens([]byte(tt.input))
|
||||
if tt.wantExact != nil {
|
||||
if got != *tt.wantExact {
|
||||
t.Errorf("期望精确值 %d,实际 %d", *tt.wantExact, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.wantGt0 && got <= 0 {
|
||||
t.Errorf("期望返回 > 0,实际 %d", got)
|
||||
}
|
||||
if !tt.wantGt0 && got != 0 {
|
||||
t.Errorf("期望返回 0,实际 %d", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task 8.3 — ParseGeminiRateLimitResetTime 测试
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseGeminiRateLimitResetTime(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantNil bool
|
||||
approxDelta int64 // 预期的 (返回值 - now) 大约是多少秒
|
||||
}{
|
||||
{
|
||||
name: "正常 quotaResetDelay",
|
||||
input: `{"error":{"details":[{"metadata":{"quotaResetDelay":"12.345s"}}]}}`,
|
||||
wantNil: false,
|
||||
approxDelta: 13, // 向上取整 12.345 -> 13
|
||||
},
|
||||
{
|
||||
name: "daily quota",
|
||||
input: `{"error":{"message":"quota per day exceeded"}}`,
|
||||
wantNil: false,
|
||||
approxDelta: -1, // 不检查精确 delta,仅检查非 nil
|
||||
},
|
||||
{
|
||||
name: "无 details 且无 regex 匹配",
|
||||
input: `{"error":{"message":"rate limit"}}`,
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "regex 回退匹配",
|
||||
input: `Please retry in 30s`,
|
||||
wantNil: false,
|
||||
approxDelta: 30,
|
||||
},
|
||||
{
|
||||
name: "完全无匹配",
|
||||
input: `{"error":{"code":429}}`,
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "非法 JSON 但 regex 回退仍工作",
|
||||
input: `not json but Please retry in 10s`,
|
||||
wantNil: false,
|
||||
approxDelta: 10,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
now := time.Now().Unix()
|
||||
got := ParseGeminiRateLimitResetTime([]byte(tt.input))
|
||||
|
||||
if tt.wantNil {
|
||||
if got != nil {
|
||||
t.Fatalf("期望返回 nil,实际返回 %d", *got)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if got == nil {
|
||||
t.Fatalf("期望返回非 nil,实际返回 nil")
|
||||
}
|
||||
|
||||
// approxDelta == -1 表示只检查非 nil,不检查具体值(如 daily quota 场景)
|
||||
if tt.approxDelta == -1 {
|
||||
// 仅验证返回的时间戳在合理范围内(未来的某个时间)
|
||||
if *got < now {
|
||||
t.Errorf("期望返回的时间戳 >= now(%d),实际 %d", now, *got)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 +/-2 秒容差进行范围检查
|
||||
delta := *got - now
|
||||
if delta < tt.approxDelta-2 || delta > tt.approxDelta+2 {
|
||||
t.Errorf("期望 delta 约为 %d 秒(+/-2),实际 delta = %d 秒(返回值=%d, now=%d)",
|
||||
tt.approxDelta, delta, *got, now)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,11 @@ 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 any) ([]Account, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockAccountRepoForGemini) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -133,6 +138,12 @@ func (m *mockAccountRepoForGemini) ListSchedulableByGroupIDAndPlatforms(ctx cont
|
||||
}
|
||||
return m.ListSchedulableByPlatforms(ctx, platforms)
|
||||
}
|
||||
func (m *mockAccountRepoForGemini) ListSchedulableUngroupedByPlatform(ctx context.Context, platform string) ([]Account, error) {
|
||||
return m.ListSchedulableByPlatform(ctx, platform)
|
||||
}
|
||||
func (m *mockAccountRepoForGemini) ListSchedulableUngroupedByPlatforms(ctx context.Context, platforms []string) ([]Account, error) {
|
||||
return m.ListSchedulableByPlatforms(ctx, platforms)
|
||||
}
|
||||
func (m *mockAccountRepoForGemini) SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -16,6 +15,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -54,6 +54,7 @@ type GeminiOAuthService struct {
|
||||
proxyRepo ProxyRepository
|
||||
oauthClient GeminiOAuthClient
|
||||
codeAssist GeminiCliCodeAssistClient
|
||||
driveClient geminicli.DriveClient
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
@@ -66,6 +67,7 @@ func NewGeminiOAuthService(
|
||||
proxyRepo ProxyRepository,
|
||||
oauthClient GeminiOAuthClient,
|
||||
codeAssist GeminiCliCodeAssistClient,
|
||||
driveClient geminicli.DriveClient,
|
||||
cfg *config.Config,
|
||||
) *GeminiOAuthService {
|
||||
return &GeminiOAuthService{
|
||||
@@ -73,6 +75,7 @@ func NewGeminiOAuthService(
|
||||
proxyRepo: proxyRepo,
|
||||
oauthClient: oauthClient,
|
||||
codeAssist: codeAssist,
|
||||
driveClient: driveClient,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
@@ -81,8 +84,7 @@ func (s *GeminiOAuthService) GetOAuthConfig() *GeminiOAuthCapabilities {
|
||||
// AI Studio OAuth is only enabled when the operator configures a custom OAuth client.
|
||||
clientID := strings.TrimSpace(s.cfg.Gemini.OAuth.ClientID)
|
||||
clientSecret := strings.TrimSpace(s.cfg.Gemini.OAuth.ClientSecret)
|
||||
enabled := clientID != "" && clientSecret != "" &&
|
||||
(clientID != geminicli.GeminiCLIOAuthClientID || clientSecret != geminicli.GeminiCLIOAuthClientSecret)
|
||||
enabled := clientID != "" && clientSecret != "" && clientID != geminicli.GeminiCLIOAuthClientID
|
||||
|
||||
return &GeminiOAuthCapabilities{
|
||||
AIStudioOAuthEnabled: enabled,
|
||||
@@ -151,8 +153,7 @@ func (s *GeminiOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64
|
||||
return nil, err
|
||||
}
|
||||
|
||||
isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID &&
|
||||
effectiveCfg.ClientSecret == geminicli.GeminiCLIOAuthClientSecret
|
||||
isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID
|
||||
|
||||
// AI Studio OAuth requires a user-provided OAuth client (built-in Gemini CLI client is scope-restricted).
|
||||
if oauthType == "ai_studio" && isBuiltinClient {
|
||||
@@ -330,27 +331,27 @@ func extractTierIDFromAllowedTiers(allowedTiers []geminicli.AllowedTier) string
|
||||
|
||||
// inferGoogleOneTier infers Google One tier from Drive storage limit
|
||||
func inferGoogleOneTier(storageBytes int64) string {
|
||||
log.Printf("[GeminiOAuth] inferGoogleOneTier - input: %d bytes (%.2f TB)", storageBytes, float64(storageBytes)/float64(TB))
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] inferGoogleOneTier - input: %d bytes (%.2f TB)", storageBytes, float64(storageBytes)/float64(TB))
|
||||
|
||||
if storageBytes <= 0 {
|
||||
log.Printf("[GeminiOAuth] inferGoogleOneTier - storageBytes <= 0, returning UNKNOWN")
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] inferGoogleOneTier - storageBytes <= 0, returning UNKNOWN")
|
||||
return GeminiTierGoogleOneUnknown
|
||||
}
|
||||
|
||||
if storageBytes > StorageTierUnlimited {
|
||||
log.Printf("[GeminiOAuth] inferGoogleOneTier - > %d bytes (100TB), returning UNLIMITED", StorageTierUnlimited)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] inferGoogleOneTier - > %d bytes (100TB), returning UNLIMITED", StorageTierUnlimited)
|
||||
return GeminiTierGoogleAIUltra
|
||||
}
|
||||
if storageBytes >= StorageTierAIPremium {
|
||||
log.Printf("[GeminiOAuth] inferGoogleOneTier - >= %d bytes (2TB), returning google_ai_pro", StorageTierAIPremium)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] inferGoogleOneTier - >= %d bytes (2TB), returning google_ai_pro", StorageTierAIPremium)
|
||||
return GeminiTierGoogleAIPro
|
||||
}
|
||||
if storageBytes >= StorageTierFree {
|
||||
log.Printf("[GeminiOAuth] inferGoogleOneTier - >= %d bytes (15GB), returning FREE", StorageTierFree)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] inferGoogleOneTier - >= %d bytes (15GB), returning FREE", StorageTierFree)
|
||||
return GeminiTierGoogleOneFree
|
||||
}
|
||||
|
||||
log.Printf("[GeminiOAuth] inferGoogleOneTier - < %d bytes (15GB), returning UNKNOWN", StorageTierFree)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] inferGoogleOneTier - < %d bytes (15GB), returning UNKNOWN", StorageTierFree)
|
||||
return GeminiTierGoogleOneUnknown
|
||||
}
|
||||
|
||||
@@ -360,30 +361,29 @@ func inferGoogleOneTier(storageBytes int64) string {
|
||||
// 2. Personal accounts will get 403/404 from cloudaicompanion.googleapis.com
|
||||
// 3. Google consumer (Google One) and enterprise (GCP) systems are physically isolated
|
||||
func (s *GeminiOAuthService) FetchGoogleOneTier(ctx context.Context, accessToken, proxyURL string) (string, *geminicli.DriveStorageInfo, error) {
|
||||
log.Printf("[GeminiOAuth] Starting FetchGoogleOneTier (Google One personal account)")
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Starting FetchGoogleOneTier (Google One personal account)")
|
||||
|
||||
// Use Drive API to infer tier from storage quota (requires drive.readonly scope)
|
||||
log.Printf("[GeminiOAuth] Calling Drive API for storage quota...")
|
||||
driveClient := geminicli.NewDriveClient()
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Calling Drive API for storage quota...")
|
||||
|
||||
storageInfo, err := driveClient.GetStorageQuota(ctx, accessToken, proxyURL)
|
||||
storageInfo, err := s.driveClient.GetStorageQuota(ctx, accessToken, proxyURL)
|
||||
if err != nil {
|
||||
// Check if it's a 403 (scope not granted)
|
||||
if strings.Contains(err.Error(), "status 403") {
|
||||
log.Printf("[GeminiOAuth] Drive API scope not available (403): %v", err)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Drive API scope not available (403): %v", err)
|
||||
return GeminiTierGoogleOneUnknown, nil, err
|
||||
}
|
||||
// Other errors
|
||||
log.Printf("[GeminiOAuth] Failed to fetch Drive storage: %v", err)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Failed to fetch Drive storage: %v", err)
|
||||
return GeminiTierGoogleOneUnknown, nil, err
|
||||
}
|
||||
|
||||
log.Printf("[GeminiOAuth] Drive API response - Limit: %d bytes (%.2f TB), Usage: %d bytes (%.2f GB)",
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Drive API response - Limit: %d bytes (%.2f TB), Usage: %d bytes (%.2f GB)",
|
||||
storageInfo.Limit, float64(storageInfo.Limit)/float64(TB),
|
||||
storageInfo.Usage, float64(storageInfo.Usage)/float64(GB))
|
||||
|
||||
tierID := inferGoogleOneTier(storageInfo.Limit)
|
||||
log.Printf("[GeminiOAuth] Inferred tier from storage: %s", tierID)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Inferred tier from storage: %s", tierID)
|
||||
|
||||
return tierID, storageInfo, nil
|
||||
}
|
||||
@@ -443,16 +443,16 @@ func (s *GeminiOAuthService) RefreshAccountGoogleOneTier(
|
||||
}
|
||||
|
||||
func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExchangeCodeInput) (*GeminiTokenInfo, error) {
|
||||
log.Printf("[GeminiOAuth] ========== ExchangeCode START ==========")
|
||||
log.Printf("[GeminiOAuth] SessionID: %s", input.SessionID)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] ========== ExchangeCode START ==========")
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] SessionID: %s", input.SessionID)
|
||||
|
||||
session, ok := s.sessionStore.Get(input.SessionID)
|
||||
if !ok {
|
||||
log.Printf("[GeminiOAuth] ERROR: Session not found or expired")
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] ERROR: Session not found or expired")
|
||||
return nil, fmt.Errorf("session not found or expired")
|
||||
}
|
||||
if strings.TrimSpace(input.State) == "" || input.State != session.State {
|
||||
log.Printf("[GeminiOAuth] ERROR: Invalid state")
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] ERROR: Invalid state")
|
||||
return nil, fmt.Errorf("invalid state")
|
||||
}
|
||||
|
||||
@@ -463,7 +463,7 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
log.Printf("[GeminiOAuth] ProxyURL: %s", proxyURL)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] ProxyURL: %s", proxyURL)
|
||||
|
||||
redirectURI := session.RedirectURI
|
||||
|
||||
@@ -472,8 +472,8 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
||||
if oauthType == "" {
|
||||
oauthType = "code_assist"
|
||||
}
|
||||
log.Printf("[GeminiOAuth] OAuth Type: %s", oauthType)
|
||||
log.Printf("[GeminiOAuth] Project ID from session: %s", session.ProjectID)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] OAuth Type: %s", oauthType)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Project ID from session: %s", session.ProjectID)
|
||||
|
||||
// If the session was created for AI Studio OAuth, ensure a custom OAuth client is configured.
|
||||
if oauthType == "ai_studio" {
|
||||
@@ -485,26 +485,25 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID &&
|
||||
effectiveCfg.ClientSecret == geminicli.GeminiCLIOAuthClientSecret
|
||||
isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID
|
||||
if isBuiltinClient {
|
||||
return nil, fmt.Errorf("AI Studio OAuth requires a custom OAuth Client. Please use an AI Studio API Key account, or configure GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET and re-authorize")
|
||||
}
|
||||
}
|
||||
|
||||
// code_assist always uses the built-in client and its fixed redirect URI.
|
||||
if oauthType == "code_assist" {
|
||||
// code_assist/google_one always uses the built-in client and its fixed redirect URI.
|
||||
if oauthType == "code_assist" || oauthType == "google_one" {
|
||||
redirectURI = geminicli.GeminiCLIRedirectURI
|
||||
}
|
||||
|
||||
tokenResp, err := s.oauthClient.ExchangeCode(ctx, oauthType, input.Code, session.CodeVerifier, redirectURI, proxyURL)
|
||||
if err != nil {
|
||||
log.Printf("[GeminiOAuth] ERROR: Failed to exchange code: %v", err)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] ERROR: Failed to exchange code: %v", err)
|
||||
return nil, fmt.Errorf("failed to exchange code: %w", err)
|
||||
}
|
||||
log.Printf("[GeminiOAuth] Token exchange successful")
|
||||
log.Printf("[GeminiOAuth] Token scope: %s", tokenResp.Scope)
|
||||
log.Printf("[GeminiOAuth] Token expires_in: %d seconds", tokenResp.ExpiresIn)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Token exchange successful")
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Token scope: %s", tokenResp.Scope)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Token expires_in: %d seconds", tokenResp.ExpiresIn)
|
||||
|
||||
sessionProjectID := strings.TrimSpace(session.ProjectID)
|
||||
s.sessionStore.Delete(input.SessionID)
|
||||
@@ -526,40 +525,40 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
||||
fallbackTierID = canonicalGeminiTierIDForOAuthType(oauthType, session.TierID)
|
||||
}
|
||||
|
||||
log.Printf("[GeminiOAuth] ========== Account Type Detection START ==========")
|
||||
log.Printf("[GeminiOAuth] OAuth Type: %s", oauthType)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] ========== Account Type Detection START ==========")
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] OAuth Type: %s", oauthType)
|
||||
|
||||
// 对于 code_assist 模式,project_id 是必需的,需要调用 Code Assist API
|
||||
// 对于 google_one 模式,使用个人 Google 账号,不需要 project_id,配额由 Google 网关自动识别
|
||||
// 对于 ai_studio 模式,project_id 是可选的(不影响使用 AI Studio API)
|
||||
switch oauthType {
|
||||
case "code_assist":
|
||||
log.Printf("[GeminiOAuth] Processing code_assist OAuth type")
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Processing code_assist OAuth type")
|
||||
if projectID == "" {
|
||||
log.Printf("[GeminiOAuth] No project_id provided, attempting to fetch from LoadCodeAssist API...")
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] No project_id provided, attempting to fetch from LoadCodeAssist API...")
|
||||
var err error
|
||||
projectID, tierID, err = s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL)
|
||||
if err != nil {
|
||||
// 记录警告但不阻断流程,允许后续补充 project_id
|
||||
fmt.Printf("[GeminiOAuth] Warning: Failed to fetch project_id during token exchange: %v\n", err)
|
||||
log.Printf("[GeminiOAuth] WARNING: Failed to fetch project_id: %v", err)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] WARNING: Failed to fetch project_id: %v", err)
|
||||
} else {
|
||||
log.Printf("[GeminiOAuth] Successfully fetched project_id: %s, tier_id: %s", projectID, tierID)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Successfully fetched project_id: %s, tier_id: %s", projectID, tierID)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[GeminiOAuth] User provided project_id: %s, fetching tier_id...", projectID)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] User provided project_id: %s, fetching tier_id...", projectID)
|
||||
// 用户手动填了 project_id,仍需调用 LoadCodeAssist 获取 tierID
|
||||
_, fetchedTierID, err := s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL)
|
||||
if err != nil {
|
||||
fmt.Printf("[GeminiOAuth] Warning: Failed to fetch tierID: %v\n", err)
|
||||
log.Printf("[GeminiOAuth] WARNING: Failed to fetch tier_id: %v", err)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] WARNING: Failed to fetch tier_id: %v", err)
|
||||
} else {
|
||||
tierID = fetchedTierID
|
||||
log.Printf("[GeminiOAuth] Successfully fetched tier_id: %s", tierID)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Successfully fetched tier_id: %s", tierID)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
log.Printf("[GeminiOAuth] ERROR: Missing project_id for Code Assist OAuth")
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] ERROR: Missing project_id for Code Assist OAuth")
|
||||
return nil, fmt.Errorf("missing project_id for Code Assist OAuth: please fill Project ID (optional field) and regenerate the auth URL, or ensure your Google account has an ACTIVE GCP project")
|
||||
}
|
||||
// Prefer auto-detected tier; fall back to user-selected tier.
|
||||
@@ -567,31 +566,31 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
||||
if tierID == "" {
|
||||
if fallbackTierID != "" {
|
||||
tierID = fallbackTierID
|
||||
log.Printf("[GeminiOAuth] Using fallback tier_id from user/session: %s", tierID)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Using fallback tier_id from user/session: %s", tierID)
|
||||
} else {
|
||||
tierID = GeminiTierGCPStandard
|
||||
log.Printf("[GeminiOAuth] Using default tier_id: %s", tierID)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Using default tier_id: %s", tierID)
|
||||
}
|
||||
}
|
||||
log.Printf("[GeminiOAuth] Final code_assist result - project_id: %s, tier_id: %s", projectID, tierID)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Final code_assist result - project_id: %s, tier_id: %s", projectID, tierID)
|
||||
|
||||
case "google_one":
|
||||
log.Printf("[GeminiOAuth] Processing google_one OAuth type")
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Processing google_one OAuth type")
|
||||
|
||||
// Google One accounts use cloudaicompanion API, which requires a project_id.
|
||||
// For personal accounts, Google auto-assigns a project_id via the LoadCodeAssist API.
|
||||
if projectID == "" {
|
||||
log.Printf("[GeminiOAuth] No project_id provided, attempting to fetch from LoadCodeAssist API...")
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] No project_id provided, attempting to fetch from LoadCodeAssist API...")
|
||||
var err error
|
||||
projectID, _, err = s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL)
|
||||
if err != nil {
|
||||
log.Printf("[GeminiOAuth] ERROR: Failed to fetch project_id: %v", err)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] ERROR: Failed to fetch project_id: %v", err)
|
||||
return nil, fmt.Errorf("google One accounts require a project_id, failed to auto-detect: %w", err)
|
||||
}
|
||||
log.Printf("[GeminiOAuth] Successfully fetched project_id: %s", projectID)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Successfully fetched project_id: %s", projectID)
|
||||
}
|
||||
|
||||
log.Printf("[GeminiOAuth] Attempting to fetch Google One tier from Drive API...")
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Attempting to fetch Google One tier from Drive API...")
|
||||
// Attempt to fetch Drive storage tier
|
||||
var storageInfo *geminicli.DriveStorageInfo
|
||||
var err error
|
||||
@@ -599,12 +598,12 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
||||
if err != nil {
|
||||
// Log warning but don't block - use fallback
|
||||
fmt.Printf("[GeminiOAuth] Warning: Failed to fetch Drive tier: %v\n", err)
|
||||
log.Printf("[GeminiOAuth] WARNING: Failed to fetch Drive tier: %v", err)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] WARNING: Failed to fetch Drive tier: %v", err)
|
||||
tierID = ""
|
||||
} else {
|
||||
log.Printf("[GeminiOAuth] Successfully fetched Drive tier: %s", tierID)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Successfully fetched Drive tier: %s", tierID)
|
||||
if storageInfo != nil {
|
||||
log.Printf("[GeminiOAuth] Drive storage - Limit: %d bytes (%.2f TB), Usage: %d bytes (%.2f GB)",
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Drive storage - Limit: %d bytes (%.2f TB), Usage: %d bytes (%.2f GB)",
|
||||
storageInfo.Limit, float64(storageInfo.Limit)/float64(TB),
|
||||
storageInfo.Usage, float64(storageInfo.Usage)/float64(GB))
|
||||
}
|
||||
@@ -613,10 +612,10 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
||||
if tierID == "" || tierID == GeminiTierGoogleOneUnknown {
|
||||
if fallbackTierID != "" {
|
||||
tierID = fallbackTierID
|
||||
log.Printf("[GeminiOAuth] Using fallback tier_id from user/session: %s", tierID)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Using fallback tier_id from user/session: %s", tierID)
|
||||
} else {
|
||||
tierID = GeminiTierGoogleOneFree
|
||||
log.Printf("[GeminiOAuth] Using default tier_id: %s", tierID)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Using default tier_id: %s", tierID)
|
||||
}
|
||||
}
|
||||
fmt.Printf("[GeminiOAuth] Google One tierID after normalization: %s\n", tierID)
|
||||
@@ -639,7 +638,7 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
||||
"drive_tier_updated_at": time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
log.Printf("[GeminiOAuth] ========== ExchangeCode END (google_one with storage info) ==========")
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] ========== ExchangeCode END (google_one with storage info) ==========")
|
||||
return tokenInfo, nil
|
||||
}
|
||||
|
||||
@@ -652,10 +651,10 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
||||
}
|
||||
|
||||
default:
|
||||
log.Printf("[GeminiOAuth] Processing %s OAuth type (no tier detection)", oauthType)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Processing %s OAuth type (no tier detection)", oauthType)
|
||||
}
|
||||
|
||||
log.Printf("[GeminiOAuth] ========== Account Type Detection END ==========")
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] ========== Account Type Detection END ==========")
|
||||
|
||||
result := &GeminiTokenInfo{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
@@ -668,8 +667,8 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
||||
TierID: tierID,
|
||||
OAuthType: oauthType,
|
||||
}
|
||||
log.Printf("[GeminiOAuth] Final result - OAuth Type: %s, Project ID: %s, Tier ID: %s", result.OAuthType, result.ProjectID, result.TierID)
|
||||
log.Printf("[GeminiOAuth] ========== ExchangeCode END ==========")
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Final result - OAuth Type: %s, Project ID: %s, Tier ID: %s", result.OAuthType, result.ProjectID, result.TierID)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] ========== ExchangeCode END ==========")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -952,23 +951,23 @@ func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, pr
|
||||
registeredTierID := strings.TrimSpace(loadResp.GetTier())
|
||||
if registeredTierID != "" {
|
||||
// 已注册但未返回 cloudaicompanionProject,这在 Google One 用户中较常见:需要用户自行提供 project_id。
|
||||
log.Printf("[GeminiOAuth] User has tier (%s) but no cloudaicompanionProject, trying Cloud Resource Manager...", registeredTierID)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] User has tier (%s) but no cloudaicompanionProject, trying Cloud Resource Manager...", registeredTierID)
|
||||
|
||||
// Try to get project from Cloud Resource Manager
|
||||
fallback, fbErr := fetchProjectIDFromResourceManager(ctx, accessToken, proxyURL)
|
||||
if fbErr == nil && strings.TrimSpace(fallback) != "" {
|
||||
log.Printf("[GeminiOAuth] Found project from Cloud Resource Manager: %s", fallback)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Found project from Cloud Resource Manager: %s", fallback)
|
||||
return strings.TrimSpace(fallback), tierID, nil
|
||||
}
|
||||
|
||||
// No project found - user must provide project_id manually
|
||||
log.Printf("[GeminiOAuth] No project found from Cloud Resource Manager, user must provide project_id manually")
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] No project found from Cloud Resource Manager, user must provide project_id manually")
|
||||
return "", tierID, fmt.Errorf("user is registered (tier: %s) but no project_id available. Please provide Project ID manually in the authorization form, or create a project at https://console.cloud.google.com", registeredTierID)
|
||||
}
|
||||
}
|
||||
|
||||
// 未检测到 currentTier/paidTier,视为新用户,继续调用 onboardUser
|
||||
log.Printf("[GeminiOAuth] No currentTier/paidTier found, proceeding with onboardUser (tierID: %s)", tierID)
|
||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] No currentTier/paidTier found, proceeding with onboardUser (tierID: %s)", tierID)
|
||||
|
||||
req := &geminicli.OnboardUserRequest{
|
||||
TierID: tierID,
|
||||
@@ -1046,7 +1045,7 @@ func fetchProjectIDFromResourceManager(ctx context.Context, accessToken, proxyUR
|
||||
ValidateResolvedIP: true,
|
||||
})
|
||||
if err != nil {
|
||||
client = &http.Client{Timeout: 30 * time.Second}
|
||||
return "", fmt.Errorf("create http client failed: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,15 @@ type Group struct {
|
||||
ImagePrice2K *float64
|
||||
ImagePrice4K *float64
|
||||
|
||||
// Sora 按次计费配置(阶段 1)
|
||||
SoraImagePrice360 *float64
|
||||
SoraImagePrice540 *float64
|
||||
SoraVideoPricePerRequest *float64
|
||||
SoraVideoPricePerRequestHD *float64
|
||||
|
||||
// Sora 存储配额
|
||||
SoraStorageQuotaBytes int64
|
||||
|
||||
// Claude Code 客户端限制
|
||||
ClaudeCodeOnly bool
|
||||
FallbackGroupID *int64
|
||||
@@ -95,6 +104,18 @@ func (g *Group) GetImagePrice(imageSize string) *float64 {
|
||||
}
|
||||
}
|
||||
|
||||
// GetSoraImagePrice 根据 Sora 图片尺寸返回价格(360/540)
|
||||
func (g *Group) GetSoraImagePrice(imageSize string) *float64 {
|
||||
switch imageSize {
|
||||
case "360":
|
||||
return g.SoraImagePrice360
|
||||
case "540":
|
||||
return g.SoraImagePrice540
|
||||
default:
|
||||
return g.SoraImagePrice360
|
||||
}
|
||||
}
|
||||
|
||||
// IsGroupContextValid reports whether a group from context has the fields required for routing decisions.
|
||||
func IsGroupContextValid(group *Group) bool {
|
||||
if group == nil {
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
|
||||
)
|
||||
|
||||
const (
|
||||
IdempotencyStatusProcessing = "processing"
|
||||
IdempotencyStatusSucceeded = "succeeded"
|
||||
IdempotencyStatusFailedRetryable = "failed_retryable"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrIdempotencyKeyRequired = infraerrors.BadRequest("IDEMPOTENCY_KEY_REQUIRED", "idempotency key is required")
|
||||
ErrIdempotencyKeyInvalid = infraerrors.BadRequest("IDEMPOTENCY_KEY_INVALID", "idempotency key is invalid")
|
||||
ErrIdempotencyKeyConflict = infraerrors.Conflict("IDEMPOTENCY_KEY_CONFLICT", "idempotency key reused with different payload")
|
||||
ErrIdempotencyInProgress = infraerrors.Conflict("IDEMPOTENCY_IN_PROGRESS", "idempotent request is still processing")
|
||||
ErrIdempotencyRetryBackoff = infraerrors.Conflict("IDEMPOTENCY_RETRY_BACKOFF", "idempotent request is in retry backoff window")
|
||||
ErrIdempotencyStoreUnavail = infraerrors.ServiceUnavailable("IDEMPOTENCY_STORE_UNAVAILABLE", "idempotency store unavailable")
|
||||
ErrIdempotencyInvalidPayload = infraerrors.BadRequest("IDEMPOTENCY_PAYLOAD_INVALID", "failed to normalize request payload")
|
||||
)
|
||||
|
||||
type IdempotencyRecord struct {
|
||||
ID int64
|
||||
Scope string
|
||||
IdempotencyKeyHash string
|
||||
RequestFingerprint string
|
||||
Status string
|
||||
ResponseStatus *int
|
||||
ResponseBody *string
|
||||
ErrorReason *string
|
||||
LockedUntil *time.Time
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type IdempotencyRepository interface {
|
||||
CreateProcessing(ctx context.Context, record *IdempotencyRecord) (bool, error)
|
||||
GetByScopeAndKeyHash(ctx context.Context, scope, keyHash string) (*IdempotencyRecord, error)
|
||||
TryReclaim(ctx context.Context, id int64, fromStatus string, now, newLockedUntil, newExpiresAt time.Time) (bool, error)
|
||||
ExtendProcessingLock(ctx context.Context, id int64, requestFingerprint string, newLockedUntil, newExpiresAt time.Time) (bool, error)
|
||||
MarkSucceeded(ctx context.Context, id int64, responseStatus int, responseBody string, expiresAt time.Time) error
|
||||
MarkFailedRetryable(ctx context.Context, id int64, errorReason string, lockedUntil, expiresAt time.Time) error
|
||||
DeleteExpired(ctx context.Context, now time.Time, limit int) (int64, error)
|
||||
}
|
||||
|
||||
type IdempotencyConfig struct {
|
||||
DefaultTTL time.Duration
|
||||
SystemOperationTTL time.Duration
|
||||
ProcessingTimeout time.Duration
|
||||
FailedRetryBackoff time.Duration
|
||||
MaxStoredResponseLen int
|
||||
ObserveOnly bool
|
||||
}
|
||||
|
||||
func DefaultIdempotencyConfig() IdempotencyConfig {
|
||||
return IdempotencyConfig{
|
||||
DefaultTTL: 24 * time.Hour,
|
||||
SystemOperationTTL: 1 * time.Hour,
|
||||
ProcessingTimeout: 30 * time.Second,
|
||||
FailedRetryBackoff: 5 * time.Second,
|
||||
MaxStoredResponseLen: 64 * 1024,
|
||||
ObserveOnly: true, // 默认先观察再强制,避免老客户端立刻中断
|
||||
}
|
||||
}
|
||||
|
||||
type IdempotencyExecuteOptions struct {
|
||||
Scope string
|
||||
ActorScope string
|
||||
Method string
|
||||
Route string
|
||||
IdempotencyKey string
|
||||
Payload any
|
||||
TTL time.Duration
|
||||
RequireKey bool
|
||||
}
|
||||
|
||||
type IdempotencyExecuteResult struct {
|
||||
Data any
|
||||
Replayed bool
|
||||
}
|
||||
|
||||
type IdempotencyCoordinator struct {
|
||||
repo IdempotencyRepository
|
||||
cfg IdempotencyConfig
|
||||
}
|
||||
|
||||
var (
|
||||
defaultIdempotencyMu sync.RWMutex
|
||||
defaultIdempotencySvc *IdempotencyCoordinator
|
||||
)
|
||||
|
||||
func SetDefaultIdempotencyCoordinator(svc *IdempotencyCoordinator) {
|
||||
defaultIdempotencyMu.Lock()
|
||||
defaultIdempotencySvc = svc
|
||||
defaultIdempotencyMu.Unlock()
|
||||
}
|
||||
|
||||
func DefaultIdempotencyCoordinator() *IdempotencyCoordinator {
|
||||
defaultIdempotencyMu.RLock()
|
||||
defer defaultIdempotencyMu.RUnlock()
|
||||
return defaultIdempotencySvc
|
||||
}
|
||||
|
||||
func DefaultWriteIdempotencyTTL() time.Duration {
|
||||
defaultTTL := DefaultIdempotencyConfig().DefaultTTL
|
||||
if coordinator := DefaultIdempotencyCoordinator(); coordinator != nil && coordinator.cfg.DefaultTTL > 0 {
|
||||
return coordinator.cfg.DefaultTTL
|
||||
}
|
||||
return defaultTTL
|
||||
}
|
||||
|
||||
func DefaultSystemOperationIdempotencyTTL() time.Duration {
|
||||
defaultTTL := DefaultIdempotencyConfig().SystemOperationTTL
|
||||
if coordinator := DefaultIdempotencyCoordinator(); coordinator != nil && coordinator.cfg.SystemOperationTTL > 0 {
|
||||
return coordinator.cfg.SystemOperationTTL
|
||||
}
|
||||
return defaultTTL
|
||||
}
|
||||
|
||||
func NewIdempotencyCoordinator(repo IdempotencyRepository, cfg IdempotencyConfig) *IdempotencyCoordinator {
|
||||
return &IdempotencyCoordinator{
|
||||
repo: repo,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeIdempotencyKey(raw string) (string, error) {
|
||||
key := strings.TrimSpace(raw)
|
||||
if key == "" {
|
||||
return "", nil
|
||||
}
|
||||
if len(key) > 128 {
|
||||
return "", ErrIdempotencyKeyInvalid
|
||||
}
|
||||
for _, r := range key {
|
||||
if r < 33 || r > 126 {
|
||||
return "", ErrIdempotencyKeyInvalid
|
||||
}
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func HashIdempotencyKey(key string) string {
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func BuildIdempotencyFingerprint(method, route, actorScope string, payload any) (string, error) {
|
||||
if method == "" {
|
||||
method = "POST"
|
||||
}
|
||||
if route == "" {
|
||||
route = "/"
|
||||
}
|
||||
if actorScope == "" {
|
||||
actorScope = "anonymous"
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", ErrIdempotencyInvalidPayload.WithCause(err)
|
||||
}
|
||||
sum := sha256.Sum256([]byte(
|
||||
strings.ToUpper(method) + "\n" + route + "\n" + actorScope + "\n" + string(raw),
|
||||
))
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func RetryAfterSecondsFromError(err error) int {
|
||||
appErr := new(infraerrors.ApplicationError)
|
||||
if !errors.As(err, &appErr) || appErr == nil || appErr.Metadata == nil {
|
||||
return 0
|
||||
}
|
||||
v := strings.TrimSpace(appErr.Metadata["retry_after"])
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
seconds, convErr := strconv.Atoi(v)
|
||||
if convErr != nil || seconds <= 0 {
|
||||
return 0
|
||||
}
|
||||
return seconds
|
||||
}
|
||||
|
||||
func (c *IdempotencyCoordinator) Execute(
|
||||
ctx context.Context,
|
||||
opts IdempotencyExecuteOptions,
|
||||
execute func(context.Context) (any, error),
|
||||
) (*IdempotencyExecuteResult, error) {
|
||||
if execute == nil {
|
||||
return nil, infraerrors.InternalServer("IDEMPOTENCY_EXECUTOR_NIL", "idempotency executor is nil")
|
||||
}
|
||||
|
||||
key, err := NormalizeIdempotencyKey(opts.IdempotencyKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if key == "" {
|
||||
if opts.RequireKey && !c.cfg.ObserveOnly {
|
||||
return nil, ErrIdempotencyKeyRequired
|
||||
}
|
||||
data, execErr := execute(ctx)
|
||||
if execErr != nil {
|
||||
return nil, execErr
|
||||
}
|
||||
return &IdempotencyExecuteResult{Data: data}, nil
|
||||
}
|
||||
if c.repo == nil {
|
||||
RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "repo_nil")
|
||||
return nil, ErrIdempotencyStoreUnavail
|
||||
}
|
||||
|
||||
if opts.Scope == "" {
|
||||
return nil, infraerrors.BadRequest("IDEMPOTENCY_SCOPE_REQUIRED", "idempotency scope is required")
|
||||
}
|
||||
|
||||
fingerprint, err := BuildIdempotencyFingerprint(opts.Method, opts.Route, opts.ActorScope, opts.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ttl := opts.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = c.cfg.DefaultTTL
|
||||
}
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(ttl)
|
||||
lockedUntil := now.Add(c.cfg.ProcessingTimeout)
|
||||
keyHash := HashIdempotencyKey(key)
|
||||
|
||||
record := &IdempotencyRecord{
|
||||
Scope: opts.Scope,
|
||||
IdempotencyKeyHash: keyHash,
|
||||
RequestFingerprint: fingerprint,
|
||||
Status: IdempotencyStatusProcessing,
|
||||
LockedUntil: &lockedUntil,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
owner, err := c.repo.CreateProcessing(ctx, record)
|
||||
if err != nil {
|
||||
RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "create_processing_error")
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "unknown->store_unavailable", false, map[string]string{
|
||||
"operation": "create_processing",
|
||||
})
|
||||
return nil, ErrIdempotencyStoreUnavail.WithCause(err)
|
||||
}
|
||||
if owner {
|
||||
recordIdempotencyClaim(opts.Route, opts.Scope, map[string]string{"mode": "new_claim"})
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "none->processing", false, map[string]string{
|
||||
"claim_mode": "new",
|
||||
})
|
||||
}
|
||||
if !owner {
|
||||
existing, getErr := c.repo.GetByScopeAndKeyHash(ctx, opts.Scope, keyHash)
|
||||
if getErr != nil {
|
||||
RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "get_existing_error")
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "unknown->store_unavailable", false, map[string]string{
|
||||
"operation": "get_existing",
|
||||
})
|
||||
return nil, ErrIdempotencyStoreUnavail.WithCause(getErr)
|
||||
}
|
||||
if existing == nil {
|
||||
RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "missing_existing")
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "unknown->store_unavailable", false, map[string]string{
|
||||
"operation": "missing_existing",
|
||||
})
|
||||
return nil, ErrIdempotencyStoreUnavail
|
||||
}
|
||||
if existing.RequestFingerprint != fingerprint {
|
||||
recordIdempotencyConflict(opts.Route, opts.Scope, map[string]string{"reason": "fingerprint_mismatch"})
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "existing->fingerprint_mismatch", false, nil)
|
||||
return nil, ErrIdempotencyKeyConflict
|
||||
}
|
||||
reclaimedByExpired := false
|
||||
if !existing.ExpiresAt.After(now) {
|
||||
taken, reclaimErr := c.repo.TryReclaim(ctx, existing.ID, existing.Status, now, lockedUntil, expiresAt)
|
||||
if reclaimErr != nil {
|
||||
RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "try_reclaim_expired_error")
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, existing.Status+"->store_unavailable", false, map[string]string{
|
||||
"operation": "try_reclaim_expired",
|
||||
})
|
||||
return nil, ErrIdempotencyStoreUnavail.WithCause(reclaimErr)
|
||||
}
|
||||
if taken {
|
||||
reclaimedByExpired = true
|
||||
recordIdempotencyClaim(opts.Route, opts.Scope, map[string]string{"mode": "expired_reclaim"})
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, existing.Status+"->processing", false, map[string]string{
|
||||
"claim_mode": "expired_reclaim",
|
||||
})
|
||||
record.ID = existing.ID
|
||||
} else {
|
||||
latest, latestErr := c.repo.GetByScopeAndKeyHash(ctx, opts.Scope, keyHash)
|
||||
if latestErr != nil {
|
||||
RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "get_existing_after_expired_reclaim_error")
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "unknown->store_unavailable", false, map[string]string{
|
||||
"operation": "get_existing_after_expired_reclaim",
|
||||
})
|
||||
return nil, ErrIdempotencyStoreUnavail.WithCause(latestErr)
|
||||
}
|
||||
if latest == nil {
|
||||
RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "missing_existing_after_expired_reclaim")
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "unknown->store_unavailable", false, map[string]string{
|
||||
"operation": "missing_existing_after_expired_reclaim",
|
||||
})
|
||||
return nil, ErrIdempotencyStoreUnavail
|
||||
}
|
||||
if latest.RequestFingerprint != fingerprint {
|
||||
recordIdempotencyConflict(opts.Route, opts.Scope, map[string]string{"reason": "fingerprint_mismatch"})
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "existing->fingerprint_mismatch", false, nil)
|
||||
return nil, ErrIdempotencyKeyConflict
|
||||
}
|
||||
existing = latest
|
||||
}
|
||||
}
|
||||
|
||||
if !reclaimedByExpired {
|
||||
switch existing.Status {
|
||||
case IdempotencyStatusSucceeded:
|
||||
data, parseErr := c.decodeStoredResponse(existing.ResponseBody)
|
||||
if parseErr != nil {
|
||||
RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "decode_stored_response_error")
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "succeeded->store_unavailable", false, map[string]string{
|
||||
"operation": "decode_stored_response",
|
||||
})
|
||||
return nil, ErrIdempotencyStoreUnavail.WithCause(parseErr)
|
||||
}
|
||||
recordIdempotencyReplay(opts.Route, opts.Scope, nil)
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "succeeded->replayed", true, nil)
|
||||
return &IdempotencyExecuteResult{Data: data, Replayed: true}, nil
|
||||
case IdempotencyStatusProcessing:
|
||||
recordIdempotencyConflict(opts.Route, opts.Scope, map[string]string{"reason": "in_progress"})
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "processing->conflict", false, nil)
|
||||
return nil, c.conflictWithRetryAfter(ErrIdempotencyInProgress, existing.LockedUntil, now)
|
||||
case IdempotencyStatusFailedRetryable:
|
||||
if existing.LockedUntil != nil && existing.LockedUntil.After(now) {
|
||||
recordIdempotencyConflict(opts.Route, opts.Scope, map[string]string{"reason": "retry_backoff"})
|
||||
recordIdempotencyRetryBackoff(opts.Route, opts.Scope, nil)
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "failed_retryable->retry_backoff_conflict", false, nil)
|
||||
return nil, c.conflictWithRetryAfter(ErrIdempotencyRetryBackoff, existing.LockedUntil, now)
|
||||
}
|
||||
taken, reclaimErr := c.repo.TryReclaim(ctx, existing.ID, IdempotencyStatusFailedRetryable, now, lockedUntil, expiresAt)
|
||||
if reclaimErr != nil {
|
||||
RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "try_reclaim_error")
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "failed_retryable->store_unavailable", false, map[string]string{
|
||||
"operation": "try_reclaim",
|
||||
})
|
||||
return nil, ErrIdempotencyStoreUnavail.WithCause(reclaimErr)
|
||||
}
|
||||
if !taken {
|
||||
recordIdempotencyConflict(opts.Route, opts.Scope, map[string]string{"reason": "reclaim_race"})
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "failed_retryable->conflict", false, map[string]string{
|
||||
"conflict": "reclaim_race",
|
||||
})
|
||||
return nil, c.conflictWithRetryAfter(ErrIdempotencyInProgress, existing.LockedUntil, now)
|
||||
}
|
||||
recordIdempotencyClaim(opts.Route, opts.Scope, map[string]string{"mode": "reclaim"})
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "failed_retryable->processing", false, map[string]string{
|
||||
"claim_mode": "reclaim",
|
||||
})
|
||||
record.ID = existing.ID
|
||||
default:
|
||||
recordIdempotencyConflict(opts.Route, opts.Scope, map[string]string{"reason": "unexpected_status"})
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "existing->conflict", false, map[string]string{
|
||||
"status": existing.Status,
|
||||
})
|
||||
return nil, ErrIdempotencyKeyConflict
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if record.ID == 0 {
|
||||
RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "record_id_missing")
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "processing->store_unavailable", false, map[string]string{
|
||||
"operation": "record_id_missing",
|
||||
})
|
||||
return nil, ErrIdempotencyStoreUnavail
|
||||
}
|
||||
|
||||
execStart := time.Now()
|
||||
defer func() {
|
||||
recordIdempotencyProcessingDuration(opts.Route, opts.Scope, time.Since(execStart), nil)
|
||||
}()
|
||||
|
||||
data, execErr := execute(ctx)
|
||||
if execErr != nil {
|
||||
backoffUntil := time.Now().Add(c.cfg.FailedRetryBackoff)
|
||||
reason := infraerrors.Reason(execErr)
|
||||
if reason == "" {
|
||||
reason = "EXECUTION_FAILED"
|
||||
}
|
||||
recordIdempotencyRetryBackoff(opts.Route, opts.Scope, nil)
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "processing->failed_retryable", false, map[string]string{
|
||||
"reason": reason,
|
||||
})
|
||||
if markErr := c.repo.MarkFailedRetryable(ctx, record.ID, reason, backoffUntil, expiresAt); markErr != nil {
|
||||
RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "mark_failed_retryable_error")
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "processing->store_unavailable", false, map[string]string{
|
||||
"operation": "mark_failed_retryable",
|
||||
})
|
||||
}
|
||||
return nil, execErr
|
||||
}
|
||||
|
||||
storedBody, marshalErr := c.marshalStoredResponse(data)
|
||||
if marshalErr != nil {
|
||||
RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "marshal_response_error")
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "processing->store_unavailable", false, map[string]string{
|
||||
"operation": "marshal_response",
|
||||
})
|
||||
return nil, ErrIdempotencyStoreUnavail.WithCause(marshalErr)
|
||||
}
|
||||
if markErr := c.repo.MarkSucceeded(ctx, record.ID, 200, storedBody, expiresAt); markErr != nil {
|
||||
RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "mark_succeeded_error")
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "processing->store_unavailable", false, map[string]string{
|
||||
"operation": "mark_succeeded",
|
||||
})
|
||||
return nil, ErrIdempotencyStoreUnavail.WithCause(markErr)
|
||||
}
|
||||
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "processing->succeeded", false, nil)
|
||||
|
||||
return &IdempotencyExecuteResult{Data: data}, nil
|
||||
}
|
||||
|
||||
func (c *IdempotencyCoordinator) conflictWithRetryAfter(base *infraerrors.ApplicationError, lockedUntil *time.Time, now time.Time) error {
|
||||
if lockedUntil == nil {
|
||||
return base
|
||||
}
|
||||
sec := int(lockedUntil.Sub(now).Seconds())
|
||||
if sec <= 0 {
|
||||
sec = 1
|
||||
}
|
||||
return base.WithMetadata(map[string]string{"retry_after": strconv.Itoa(sec)})
|
||||
}
|
||||
|
||||
func (c *IdempotencyCoordinator) marshalStoredResponse(data any) (string, error) {
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
redacted := logredact.RedactText(string(raw))
|
||||
if c.cfg.MaxStoredResponseLen > 0 && len(redacted) > c.cfg.MaxStoredResponseLen {
|
||||
redacted = redacted[:c.cfg.MaxStoredResponseLen] + "...(truncated)"
|
||||
}
|
||||
return redacted, nil
|
||||
}
|
||||
|
||||
func (c *IdempotencyCoordinator) decodeStoredResponse(stored *string) (any, error) {
|
||||
if stored == nil || strings.TrimSpace(*stored) == "" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var out any
|
||||
if err := json.Unmarshal([]byte(*stored), &out); err != nil {
|
||||
return nil, fmt.Errorf("decode stored response: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
)
|
||||
|
||||
// IdempotencyCleanupService 定期清理已过期的幂等记录,避免表无限增长。
|
||||
type IdempotencyCleanupService struct {
|
||||
repo IdempotencyRepository
|
||||
interval time.Duration
|
||||
batch int
|
||||
|
||||
startOnce sync.Once
|
||||
stopOnce sync.Once
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func NewIdempotencyCleanupService(repo IdempotencyRepository, cfg *config.Config) *IdempotencyCleanupService {
|
||||
interval := 60 * time.Second
|
||||
batch := 500
|
||||
if cfg != nil {
|
||||
if cfg.Idempotency.CleanupIntervalSeconds > 0 {
|
||||
interval = time.Duration(cfg.Idempotency.CleanupIntervalSeconds) * time.Second
|
||||
}
|
||||
if cfg.Idempotency.CleanupBatchSize > 0 {
|
||||
batch = cfg.Idempotency.CleanupBatchSize
|
||||
}
|
||||
}
|
||||
return &IdempotencyCleanupService{
|
||||
repo: repo,
|
||||
interval: interval,
|
||||
batch: batch,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *IdempotencyCleanupService) Start() {
|
||||
if s == nil || s.repo == nil {
|
||||
return
|
||||
}
|
||||
s.startOnce.Do(func() {
|
||||
logger.LegacyPrintf("service.idempotency_cleanup", "[IdempotencyCleanup] started interval=%s batch=%d", s.interval, s.batch)
|
||||
go s.runLoop()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *IdempotencyCleanupService) Stop() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.stopOnce.Do(func() {
|
||||
close(s.stopCh)
|
||||
logger.LegacyPrintf("service.idempotency_cleanup", "[IdempotencyCleanup] stopped")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *IdempotencyCleanupService) runLoop() {
|
||||
ticker := time.NewTicker(s.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// 启动后先清理一轮,防止重启后积压。
|
||||
s.cleanupOnce()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.cleanupOnce()
|
||||
case <-s.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *IdempotencyCleanupService) cleanupOnce() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
deleted, err := s.repo.DeleteExpired(ctx, time.Now(), s.batch)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.idempotency_cleanup", "[IdempotencyCleanup] cleanup failed err=%v", err)
|
||||
return
|
||||
}
|
||||
if deleted > 0 {
|
||||
logger.LegacyPrintf("service.idempotency_cleanup", "[IdempotencyCleanup] cleaned expired records count=%d", deleted)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type idempotencyCleanupRepoStub struct {
|
||||
deleteCalls int
|
||||
lastLimit int
|
||||
deleteErr error
|
||||
}
|
||||
|
||||
func (r *idempotencyCleanupRepoStub) CreateProcessing(context.Context, *IdempotencyRecord) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (r *idempotencyCleanupRepoStub) GetByScopeAndKeyHash(context.Context, string, string) (*IdempotencyRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *idempotencyCleanupRepoStub) TryReclaim(context.Context, int64, string, time.Time, time.Time, time.Time) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (r *idempotencyCleanupRepoStub) ExtendProcessingLock(context.Context, int64, string, time.Time, time.Time) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (r *idempotencyCleanupRepoStub) MarkSucceeded(context.Context, int64, int, string, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (r *idempotencyCleanupRepoStub) MarkFailedRetryable(context.Context, int64, string, time.Time, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (r *idempotencyCleanupRepoStub) DeleteExpired(_ context.Context, _ time.Time, limit int) (int64, error) {
|
||||
r.deleteCalls++
|
||||
r.lastLimit = limit
|
||||
if r.deleteErr != nil {
|
||||
return 0, r.deleteErr
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func TestNewIdempotencyCleanupService_UsesConfig(t *testing.T) {
|
||||
repo := &idempotencyCleanupRepoStub{}
|
||||
cfg := &config.Config{
|
||||
Idempotency: config.IdempotencyConfig{
|
||||
CleanupIntervalSeconds: 7,
|
||||
CleanupBatchSize: 321,
|
||||
},
|
||||
}
|
||||
svc := NewIdempotencyCleanupService(repo, cfg)
|
||||
require.Equal(t, 7*time.Second, svc.interval)
|
||||
require.Equal(t, 321, svc.batch)
|
||||
}
|
||||
|
||||
func TestIdempotencyCleanupService_CleanupOnce(t *testing.T) {
|
||||
repo := &idempotencyCleanupRepoStub{}
|
||||
svc := NewIdempotencyCleanupService(repo, &config.Config{
|
||||
Idempotency: config.IdempotencyConfig{
|
||||
CleanupBatchSize: 99,
|
||||
},
|
||||
})
|
||||
|
||||
svc.cleanupOnce()
|
||||
require.Equal(t, 1, repo.deleteCalls)
|
||||
require.Equal(t, 99, repo.lastLimit)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
)
|
||||
|
||||
// IdempotencyMetricsSnapshot 提供幂等核心指标快照(进程内累计)。
|
||||
type IdempotencyMetricsSnapshot struct {
|
||||
ClaimTotal uint64 `json:"claim_total"`
|
||||
ReplayTotal uint64 `json:"replay_total"`
|
||||
ConflictTotal uint64 `json:"conflict_total"`
|
||||
RetryBackoffTotal uint64 `json:"retry_backoff_total"`
|
||||
ProcessingDurationCount uint64 `json:"processing_duration_count"`
|
||||
ProcessingDurationTotalMs float64 `json:"processing_duration_total_ms"`
|
||||
StoreUnavailableTotal uint64 `json:"store_unavailable_total"`
|
||||
}
|
||||
|
||||
type idempotencyMetrics struct {
|
||||
claimTotal atomic.Uint64
|
||||
replayTotal atomic.Uint64
|
||||
conflictTotal atomic.Uint64
|
||||
retryBackoffTotal atomic.Uint64
|
||||
processingDurationCount atomic.Uint64
|
||||
processingDurationMicros atomic.Uint64
|
||||
storeUnavailableTotal atomic.Uint64
|
||||
}
|
||||
|
||||
var defaultIdempotencyMetrics idempotencyMetrics
|
||||
|
||||
// GetIdempotencyMetricsSnapshot 返回当前幂等指标快照。
|
||||
func GetIdempotencyMetricsSnapshot() IdempotencyMetricsSnapshot {
|
||||
totalMicros := defaultIdempotencyMetrics.processingDurationMicros.Load()
|
||||
return IdempotencyMetricsSnapshot{
|
||||
ClaimTotal: defaultIdempotencyMetrics.claimTotal.Load(),
|
||||
ReplayTotal: defaultIdempotencyMetrics.replayTotal.Load(),
|
||||
ConflictTotal: defaultIdempotencyMetrics.conflictTotal.Load(),
|
||||
RetryBackoffTotal: defaultIdempotencyMetrics.retryBackoffTotal.Load(),
|
||||
ProcessingDurationCount: defaultIdempotencyMetrics.processingDurationCount.Load(),
|
||||
ProcessingDurationTotalMs: float64(totalMicros) / 1000.0,
|
||||
StoreUnavailableTotal: defaultIdempotencyMetrics.storeUnavailableTotal.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
func recordIdempotencyClaim(endpoint, scope string, attrs map[string]string) {
|
||||
defaultIdempotencyMetrics.claimTotal.Add(1)
|
||||
logIdempotencyMetric("idempotency_claim_total", endpoint, scope, "1", attrs)
|
||||
}
|
||||
|
||||
func recordIdempotencyReplay(endpoint, scope string, attrs map[string]string) {
|
||||
defaultIdempotencyMetrics.replayTotal.Add(1)
|
||||
logIdempotencyMetric("idempotency_replay_total", endpoint, scope, "1", attrs)
|
||||
}
|
||||
|
||||
func recordIdempotencyConflict(endpoint, scope string, attrs map[string]string) {
|
||||
defaultIdempotencyMetrics.conflictTotal.Add(1)
|
||||
logIdempotencyMetric("idempotency_conflict_total", endpoint, scope, "1", attrs)
|
||||
}
|
||||
|
||||
func recordIdempotencyRetryBackoff(endpoint, scope string, attrs map[string]string) {
|
||||
defaultIdempotencyMetrics.retryBackoffTotal.Add(1)
|
||||
logIdempotencyMetric("idempotency_retry_backoff_total", endpoint, scope, "1", attrs)
|
||||
}
|
||||
|
||||
func recordIdempotencyProcessingDuration(endpoint, scope string, duration time.Duration, attrs map[string]string) {
|
||||
if duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
defaultIdempotencyMetrics.processingDurationCount.Add(1)
|
||||
defaultIdempotencyMetrics.processingDurationMicros.Add(uint64(duration.Microseconds()))
|
||||
logIdempotencyMetric("idempotency_processing_duration_ms", endpoint, scope, strconv.FormatFloat(duration.Seconds()*1000, 'f', 3, 64), attrs)
|
||||
}
|
||||
|
||||
// RecordIdempotencyStoreUnavailable 记录幂等存储不可用事件(用于降级路径观测)。
|
||||
func RecordIdempotencyStoreUnavailable(endpoint, scope, strategy string) {
|
||||
defaultIdempotencyMetrics.storeUnavailableTotal.Add(1)
|
||||
attrs := map[string]string{}
|
||||
if strategy != "" {
|
||||
attrs["strategy"] = strategy
|
||||
}
|
||||
logIdempotencyMetric("idempotency_store_unavailable_total", endpoint, scope, "1", attrs)
|
||||
}
|
||||
|
||||
func logIdempotencyAudit(endpoint, scope, keyHash, stateTransition string, replayed bool, attrs map[string]string) {
|
||||
var b strings.Builder
|
||||
builderWriteString(&b, "[IdempotencyAudit]")
|
||||
builderWriteString(&b, " endpoint=")
|
||||
builderWriteString(&b, safeAuditField(endpoint))
|
||||
builderWriteString(&b, " scope=")
|
||||
builderWriteString(&b, safeAuditField(scope))
|
||||
builderWriteString(&b, " key_hash=")
|
||||
builderWriteString(&b, safeAuditField(keyHash))
|
||||
builderWriteString(&b, " state_transition=")
|
||||
builderWriteString(&b, safeAuditField(stateTransition))
|
||||
builderWriteString(&b, " replayed=")
|
||||
builderWriteString(&b, strconv.FormatBool(replayed))
|
||||
if len(attrs) > 0 {
|
||||
appendSortedAttrs(&b, attrs)
|
||||
}
|
||||
logger.LegacyPrintf("service.idempotency", "%s", b.String())
|
||||
}
|
||||
|
||||
func logIdempotencyMetric(name, endpoint, scope, value string, attrs map[string]string) {
|
||||
var b strings.Builder
|
||||
builderWriteString(&b, "[IdempotencyMetric]")
|
||||
builderWriteString(&b, " name=")
|
||||
builderWriteString(&b, safeAuditField(name))
|
||||
builderWriteString(&b, " endpoint=")
|
||||
builderWriteString(&b, safeAuditField(endpoint))
|
||||
builderWriteString(&b, " scope=")
|
||||
builderWriteString(&b, safeAuditField(scope))
|
||||
builderWriteString(&b, " value=")
|
||||
builderWriteString(&b, safeAuditField(value))
|
||||
if len(attrs) > 0 {
|
||||
appendSortedAttrs(&b, attrs)
|
||||
}
|
||||
logger.LegacyPrintf("service.idempotency", "%s", b.String())
|
||||
}
|
||||
|
||||
func appendSortedAttrs(builder *strings.Builder, attrs map[string]string) {
|
||||
if len(attrs) == 0 {
|
||||
return
|
||||
}
|
||||
keys := make([]string, 0, len(attrs))
|
||||
for k := range attrs {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
builderWriteByte(builder, ' ')
|
||||
builderWriteString(builder, k)
|
||||
builderWriteByte(builder, '=')
|
||||
builderWriteString(builder, safeAuditField(attrs[k]))
|
||||
}
|
||||
}
|
||||
|
||||
func safeAuditField(v string) string {
|
||||
value := strings.TrimSpace(v)
|
||||
if value == "" {
|
||||
return "-"
|
||||
}
|
||||
// 日志按 key=value 输出,替换空白避免解析歧义。
|
||||
value = strings.ReplaceAll(value, "\n", "_")
|
||||
value = strings.ReplaceAll(value, "\r", "_")
|
||||
value = strings.ReplaceAll(value, "\t", "_")
|
||||
value = strings.ReplaceAll(value, " ", "_")
|
||||
return value
|
||||
}
|
||||
|
||||
func resetIdempotencyMetricsForTest() {
|
||||
defaultIdempotencyMetrics.claimTotal.Store(0)
|
||||
defaultIdempotencyMetrics.replayTotal.Store(0)
|
||||
defaultIdempotencyMetrics.conflictTotal.Store(0)
|
||||
defaultIdempotencyMetrics.retryBackoffTotal.Store(0)
|
||||
defaultIdempotencyMetrics.processingDurationCount.Store(0)
|
||||
defaultIdempotencyMetrics.processingDurationMicros.Store(0)
|
||||
defaultIdempotencyMetrics.storeUnavailableTotal.Store(0)
|
||||
}
|
||||
|
||||
func builderWriteString(builder *strings.Builder, value string) {
|
||||
_, _ = builder.WriteString(value)
|
||||
}
|
||||
|
||||
func builderWriteByte(builder *strings.Builder, value byte) {
|
||||
_ = builder.WriteByte(value)
|
||||
}
|
||||
@@ -0,0 +1,805 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type inMemoryIdempotencyRepo struct {
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
data map[string]*IdempotencyRecord
|
||||
}
|
||||
|
||||
func newInMemoryIdempotencyRepo() *inMemoryIdempotencyRepo {
|
||||
return &inMemoryIdempotencyRepo{
|
||||
nextID: 1,
|
||||
data: make(map[string]*IdempotencyRecord),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *inMemoryIdempotencyRepo) key(scope, hash string) string {
|
||||
return scope + "|" + hash
|
||||
}
|
||||
|
||||
func cloneRecord(in *IdempotencyRecord) *IdempotencyRecord {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
if in.ResponseStatus != nil {
|
||||
v := *in.ResponseStatus
|
||||
out.ResponseStatus = &v
|
||||
}
|
||||
if in.ResponseBody != nil {
|
||||
v := *in.ResponseBody
|
||||
out.ResponseBody = &v
|
||||
}
|
||||
if in.ErrorReason != nil {
|
||||
v := *in.ErrorReason
|
||||
out.ErrorReason = &v
|
||||
}
|
||||
if in.LockedUntil != nil {
|
||||
v := *in.LockedUntil
|
||||
out.LockedUntil = &v
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
func (r *inMemoryIdempotencyRepo) CreateProcessing(_ context.Context, record *IdempotencyRecord) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
k := r.key(record.Scope, record.IdempotencyKeyHash)
|
||||
if _, ok := r.data[k]; ok {
|
||||
return false, nil
|
||||
}
|
||||
rec := cloneRecord(record)
|
||||
rec.ID = r.nextID
|
||||
rec.CreatedAt = time.Now()
|
||||
rec.UpdatedAt = rec.CreatedAt
|
||||
r.nextID++
|
||||
r.data[k] = rec
|
||||
record.ID = rec.ID
|
||||
record.CreatedAt = rec.CreatedAt
|
||||
record.UpdatedAt = rec.UpdatedAt
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *inMemoryIdempotencyRepo) GetByScopeAndKeyHash(_ context.Context, scope, keyHash string) (*IdempotencyRecord, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return cloneRecord(r.data[r.key(scope, keyHash)]), nil
|
||||
}
|
||||
|
||||
func (r *inMemoryIdempotencyRepo) TryReclaim(_ context.Context, id int64, fromStatus string, now, newLockedUntil, newExpiresAt time.Time) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, rec := range r.data {
|
||||
if rec.ID != id {
|
||||
continue
|
||||
}
|
||||
if rec.Status != fromStatus {
|
||||
return false, nil
|
||||
}
|
||||
if rec.LockedUntil != nil && rec.LockedUntil.After(now) {
|
||||
return false, nil
|
||||
}
|
||||
rec.Status = IdempotencyStatusProcessing
|
||||
rec.LockedUntil = &newLockedUntil
|
||||
rec.ExpiresAt = newExpiresAt
|
||||
rec.ErrorReason = nil
|
||||
rec.UpdatedAt = time.Now()
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (r *inMemoryIdempotencyRepo) ExtendProcessingLock(_ context.Context, id int64, requestFingerprint string, newLockedUntil, newExpiresAt time.Time) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
for _, rec := range r.data {
|
||||
if rec.ID != id {
|
||||
continue
|
||||
}
|
||||
if rec.Status != IdempotencyStatusProcessing || rec.RequestFingerprint != requestFingerprint {
|
||||
return false, nil
|
||||
}
|
||||
rec.LockedUntil = &newLockedUntil
|
||||
rec.ExpiresAt = newExpiresAt
|
||||
rec.UpdatedAt = time.Now()
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (r *inMemoryIdempotencyRepo) MarkSucceeded(_ context.Context, id int64, responseStatus int, responseBody string, expiresAt time.Time) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, rec := range r.data {
|
||||
if rec.ID != id {
|
||||
continue
|
||||
}
|
||||
rec.Status = IdempotencyStatusSucceeded
|
||||
rec.LockedUntil = nil
|
||||
rec.ExpiresAt = expiresAt
|
||||
rec.UpdatedAt = time.Now()
|
||||
rec.ErrorReason = nil
|
||||
rec.ResponseStatus = &responseStatus
|
||||
rec.ResponseBody = &responseBody
|
||||
return nil
|
||||
}
|
||||
return errors.New("record not found")
|
||||
}
|
||||
|
||||
func (r *inMemoryIdempotencyRepo) MarkFailedRetryable(_ context.Context, id int64, errorReason string, lockedUntil, expiresAt time.Time) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, rec := range r.data {
|
||||
if rec.ID != id {
|
||||
continue
|
||||
}
|
||||
rec.Status = IdempotencyStatusFailedRetryable
|
||||
rec.LockedUntil = &lockedUntil
|
||||
rec.ExpiresAt = expiresAt
|
||||
rec.UpdatedAt = time.Now()
|
||||
rec.ErrorReason = &errorReason
|
||||
return nil
|
||||
}
|
||||
return errors.New("record not found")
|
||||
}
|
||||
|
||||
func (r *inMemoryIdempotencyRepo) DeleteExpired(_ context.Context, now time.Time, _ int) (int64, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var deleted int64
|
||||
for k, rec := range r.data {
|
||||
if !rec.ExpiresAt.After(now) {
|
||||
delete(r.data, k)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_RequireKey(t *testing.T) {
|
||||
resetIdempotencyMetricsForTest()
|
||||
repo := newInMemoryIdempotencyRepo()
|
||||
cfg := DefaultIdempotencyConfig()
|
||||
cfg.ObserveOnly = false
|
||||
coordinator := NewIdempotencyCoordinator(repo, cfg)
|
||||
|
||||
_, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "test.scope",
|
||||
Method: "POST",
|
||||
Route: "/test",
|
||||
ActorScope: "admin:1",
|
||||
RequireKey: true,
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(err), infraerrors.Code(ErrIdempotencyKeyRequired))
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_ReplaySucceededResult(t *testing.T) {
|
||||
resetIdempotencyMetricsForTest()
|
||||
repo := newInMemoryIdempotencyRepo()
|
||||
cfg := DefaultIdempotencyConfig()
|
||||
coordinator := NewIdempotencyCoordinator(repo, cfg)
|
||||
|
||||
execCount := 0
|
||||
exec := func(ctx context.Context) (any, error) {
|
||||
execCount++
|
||||
return map[string]any{"count": execCount}, nil
|
||||
}
|
||||
|
||||
opts := IdempotencyExecuteOptions{
|
||||
Scope: "test.scope",
|
||||
Method: "POST",
|
||||
Route: "/test",
|
||||
ActorScope: "user:1",
|
||||
RequireKey: true,
|
||||
IdempotencyKey: "case-1",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}
|
||||
|
||||
first, err := coordinator.Execute(context.Background(), opts, exec)
|
||||
require.NoError(t, err)
|
||||
require.False(t, first.Replayed)
|
||||
|
||||
second, err := coordinator.Execute(context.Background(), opts, exec)
|
||||
require.NoError(t, err)
|
||||
require.True(t, second.Replayed)
|
||||
require.Equal(t, 1, execCount, "second request should replay without executing business logic")
|
||||
|
||||
metrics := GetIdempotencyMetricsSnapshot()
|
||||
require.Equal(t, uint64(1), metrics.ClaimTotal)
|
||||
require.Equal(t, uint64(1), metrics.ReplayTotal)
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_ReclaimExpiredSucceededRecord(t *testing.T) {
|
||||
resetIdempotencyMetricsForTest()
|
||||
repo := newInMemoryIdempotencyRepo()
|
||||
coordinator := NewIdempotencyCoordinator(repo, DefaultIdempotencyConfig())
|
||||
|
||||
opts := IdempotencyExecuteOptions{
|
||||
Scope: "test.scope.expired",
|
||||
Method: "POST",
|
||||
Route: "/test/expired",
|
||||
ActorScope: "user:99",
|
||||
RequireKey: true,
|
||||
IdempotencyKey: "expired-case",
|
||||
Payload: map[string]any{"k": "v"},
|
||||
}
|
||||
|
||||
execCount := 0
|
||||
exec := func(ctx context.Context) (any, error) {
|
||||
execCount++
|
||||
return map[string]any{"count": execCount}, nil
|
||||
}
|
||||
|
||||
first, err := coordinator.Execute(context.Background(), opts, exec)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, first)
|
||||
require.False(t, first.Replayed)
|
||||
require.Equal(t, 1, execCount)
|
||||
|
||||
keyHash := HashIdempotencyKey(opts.IdempotencyKey)
|
||||
repo.mu.Lock()
|
||||
existing := repo.data[repo.key(opts.Scope, keyHash)]
|
||||
require.NotNil(t, existing)
|
||||
existing.ExpiresAt = time.Now().Add(-time.Second)
|
||||
repo.mu.Unlock()
|
||||
|
||||
second, err := coordinator.Execute(context.Background(), opts, exec)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, second)
|
||||
require.False(t, second.Replayed, "expired record should be reclaimed and execute business logic again")
|
||||
require.Equal(t, 2, execCount)
|
||||
|
||||
third, err := coordinator.Execute(context.Background(), opts, exec)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, third)
|
||||
require.True(t, third.Replayed)
|
||||
payload, ok := third.Data.(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, float64(2), payload["count"])
|
||||
|
||||
metrics := GetIdempotencyMetricsSnapshot()
|
||||
require.GreaterOrEqual(t, metrics.ClaimTotal, uint64(2))
|
||||
require.GreaterOrEqual(t, metrics.ReplayTotal, uint64(1))
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_SameKeyDifferentPayloadConflict(t *testing.T) {
|
||||
resetIdempotencyMetricsForTest()
|
||||
repo := newInMemoryIdempotencyRepo()
|
||||
cfg := DefaultIdempotencyConfig()
|
||||
coordinator := NewIdempotencyCoordinator(repo, cfg)
|
||||
|
||||
_, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "test.scope",
|
||||
Method: "POST",
|
||||
Route: "/test",
|
||||
ActorScope: "user:1",
|
||||
RequireKey: true,
|
||||
IdempotencyKey: "case-2",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "test.scope",
|
||||
Method: "POST",
|
||||
Route: "/test",
|
||||
ActorScope: "user:1",
|
||||
RequireKey: true,
|
||||
IdempotencyKey: "case-2",
|
||||
Payload: map[string]any{"a": 2},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(err), infraerrors.Code(ErrIdempotencyKeyConflict))
|
||||
|
||||
metrics := GetIdempotencyMetricsSnapshot()
|
||||
require.Equal(t, uint64(1), metrics.ConflictTotal)
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_BackoffAfterRetryableFailure(t *testing.T) {
|
||||
resetIdempotencyMetricsForTest()
|
||||
repo := newInMemoryIdempotencyRepo()
|
||||
cfg := DefaultIdempotencyConfig()
|
||||
cfg.FailedRetryBackoff = 2 * time.Second
|
||||
coordinator := NewIdempotencyCoordinator(repo, cfg)
|
||||
|
||||
opts := IdempotencyExecuteOptions{
|
||||
Scope: "test.scope",
|
||||
Method: "POST",
|
||||
Route: "/test",
|
||||
ActorScope: "user:1",
|
||||
RequireKey: true,
|
||||
IdempotencyKey: "case-3",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}
|
||||
|
||||
_, err := coordinator.Execute(context.Background(), opts, func(ctx context.Context) (any, error) {
|
||||
return nil, infraerrors.InternalServer("UPSTREAM_ERROR", "upstream error")
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = coordinator.Execute(context.Background(), opts, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(err), infraerrors.Code(ErrIdempotencyRetryBackoff))
|
||||
require.Greater(t, RetryAfterSecondsFromError(err), 0)
|
||||
|
||||
metrics := GetIdempotencyMetricsSnapshot()
|
||||
require.GreaterOrEqual(t, metrics.RetryBackoffTotal, uint64(2))
|
||||
require.GreaterOrEqual(t, metrics.ConflictTotal, uint64(1))
|
||||
require.GreaterOrEqual(t, metrics.ProcessingDurationCount, uint64(1))
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_ConcurrentSameKeySingleSideEffect(t *testing.T) {
|
||||
resetIdempotencyMetricsForTest()
|
||||
repo := newInMemoryIdempotencyRepo()
|
||||
cfg := DefaultIdempotencyConfig()
|
||||
cfg.ProcessingTimeout = 2 * time.Second
|
||||
coordinator := NewIdempotencyCoordinator(repo, cfg)
|
||||
|
||||
opts := IdempotencyExecuteOptions{
|
||||
Scope: "test.scope.concurrent",
|
||||
Method: "POST",
|
||||
Route: "/test/concurrent",
|
||||
ActorScope: "user:7",
|
||||
RequireKey: true,
|
||||
IdempotencyKey: "concurrent-case",
|
||||
Payload: map[string]any{"v": 1},
|
||||
}
|
||||
|
||||
var execCount int32
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = coordinator.Execute(context.Background(), opts, func(ctx context.Context) (any, error) {
|
||||
atomic.AddInt32(&execCount, 1)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
replayed, err := coordinator.Execute(context.Background(), opts, func(ctx context.Context) (any, error) {
|
||||
atomic.AddInt32(&execCount, 1)
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, replayed.Replayed)
|
||||
require.Equal(t, int32(1), atomic.LoadInt32(&execCount), "concurrent same-key requests should execute business side-effect once")
|
||||
|
||||
metrics := GetIdempotencyMetricsSnapshot()
|
||||
require.Equal(t, uint64(1), metrics.ClaimTotal)
|
||||
require.Equal(t, uint64(1), metrics.ReplayTotal)
|
||||
require.GreaterOrEqual(t, metrics.ConflictTotal, uint64(1))
|
||||
}
|
||||
|
||||
type failingIdempotencyRepo struct{}
|
||||
|
||||
func (failingIdempotencyRepo) CreateProcessing(context.Context, *IdempotencyRecord) (bool, error) {
|
||||
return false, errors.New("store unavailable")
|
||||
}
|
||||
func (failingIdempotencyRepo) GetByScopeAndKeyHash(context.Context, string, string) (*IdempotencyRecord, error) {
|
||||
return nil, errors.New("store unavailable")
|
||||
}
|
||||
func (failingIdempotencyRepo) TryReclaim(context.Context, int64, string, time.Time, time.Time, time.Time) (bool, error) {
|
||||
return false, errors.New("store unavailable")
|
||||
}
|
||||
func (failingIdempotencyRepo) ExtendProcessingLock(context.Context, int64, string, time.Time, time.Time) (bool, error) {
|
||||
return false, errors.New("store unavailable")
|
||||
}
|
||||
func (failingIdempotencyRepo) MarkSucceeded(context.Context, int64, int, string, time.Time) error {
|
||||
return errors.New("store unavailable")
|
||||
}
|
||||
func (failingIdempotencyRepo) MarkFailedRetryable(context.Context, int64, string, time.Time, time.Time) error {
|
||||
return errors.New("store unavailable")
|
||||
}
|
||||
func (failingIdempotencyRepo) DeleteExpired(context.Context, time.Time, int) (int64, error) {
|
||||
return 0, errors.New("store unavailable")
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_StoreUnavailableMetrics(t *testing.T) {
|
||||
resetIdempotencyMetricsForTest()
|
||||
coordinator := NewIdempotencyCoordinator(failingIdempotencyRepo{}, DefaultIdempotencyConfig())
|
||||
|
||||
_, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "test.scope.unavailable",
|
||||
Method: "POST",
|
||||
Route: "/test/unavailable",
|
||||
ActorScope: "admin:1",
|
||||
RequireKey: true,
|
||||
IdempotencyKey: "case-unavailable",
|
||||
Payload: map[string]any{"v": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
|
||||
require.GreaterOrEqual(t, GetIdempotencyMetricsSnapshot().StoreUnavailableTotal, uint64(1))
|
||||
}
|
||||
|
||||
func TestDefaultIdempotencyCoordinatorAndTTLs(t *testing.T) {
|
||||
SetDefaultIdempotencyCoordinator(nil)
|
||||
require.Nil(t, DefaultIdempotencyCoordinator())
|
||||
require.Equal(t, DefaultIdempotencyConfig().DefaultTTL, DefaultWriteIdempotencyTTL())
|
||||
require.Equal(t, DefaultIdempotencyConfig().SystemOperationTTL, DefaultSystemOperationIdempotencyTTL())
|
||||
|
||||
coordinator := NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), IdempotencyConfig{
|
||||
DefaultTTL: 2 * time.Hour,
|
||||
SystemOperationTTL: 15 * time.Minute,
|
||||
ProcessingTimeout: 10 * time.Second,
|
||||
FailedRetryBackoff: 3 * time.Second,
|
||||
ObserveOnly: false,
|
||||
})
|
||||
SetDefaultIdempotencyCoordinator(coordinator)
|
||||
t.Cleanup(func() {
|
||||
SetDefaultIdempotencyCoordinator(nil)
|
||||
})
|
||||
|
||||
require.Same(t, coordinator, DefaultIdempotencyCoordinator())
|
||||
require.Equal(t, 2*time.Hour, DefaultWriteIdempotencyTTL())
|
||||
require.Equal(t, 15*time.Minute, DefaultSystemOperationIdempotencyTTL())
|
||||
}
|
||||
|
||||
func TestNormalizeIdempotencyKeyAndFingerprint(t *testing.T) {
|
||||
key, err := NormalizeIdempotencyKey(" abc-123 ")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "abc-123", key)
|
||||
|
||||
key, err = NormalizeIdempotencyKey("")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "", key)
|
||||
|
||||
_, err = NormalizeIdempotencyKey(string(make([]byte, 129)))
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = NormalizeIdempotencyKey("bad\nkey")
|
||||
require.Error(t, err)
|
||||
|
||||
fp1, err := BuildIdempotencyFingerprint("", "", "", map[string]any{"a": 1})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, fp1)
|
||||
fp2, err := BuildIdempotencyFingerprint("POST", "/", "anonymous", map[string]any{"a": 1})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fp1, fp2)
|
||||
|
||||
_, err = BuildIdempotencyFingerprint("POST", "/x", "u:1", map[string]any{"bad": make(chan int)})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(ErrIdempotencyInvalidPayload), infraerrors.Code(err))
|
||||
}
|
||||
|
||||
func TestRetryAfterSecondsFromErrorBranches(t *testing.T) {
|
||||
require.Equal(t, 0, RetryAfterSecondsFromError(nil))
|
||||
require.Equal(t, 0, RetryAfterSecondsFromError(errors.New("plain")))
|
||||
|
||||
err := ErrIdempotencyInProgress.WithMetadata(map[string]string{"retry_after": "12"})
|
||||
require.Equal(t, 12, RetryAfterSecondsFromError(err))
|
||||
|
||||
err = ErrIdempotencyInProgress.WithMetadata(map[string]string{"retry_after": "bad"})
|
||||
require.Equal(t, 0, RetryAfterSecondsFromError(err))
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_ExecuteNilExecutorAndNoKeyPassThrough(t *testing.T) {
|
||||
repo := newInMemoryIdempotencyRepo()
|
||||
coordinator := NewIdempotencyCoordinator(repo, DefaultIdempotencyConfig())
|
||||
|
||||
_, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope",
|
||||
IdempotencyKey: "k",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, nil)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "IDEMPOTENCY_EXECUTOR_NIL", infraerrors.Reason(err))
|
||||
|
||||
called := 0
|
||||
result, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope",
|
||||
RequireKey: true,
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
called++
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, called)
|
||||
require.NotNil(t, result)
|
||||
require.False(t, result.Replayed)
|
||||
}
|
||||
|
||||
type noIDOwnerRepo struct{}
|
||||
|
||||
func (noIDOwnerRepo) CreateProcessing(context.Context, *IdempotencyRecord) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
func (noIDOwnerRepo) GetByScopeAndKeyHash(context.Context, string, string) (*IdempotencyRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (noIDOwnerRepo) TryReclaim(context.Context, int64, string, time.Time, time.Time, time.Time) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (noIDOwnerRepo) ExtendProcessingLock(context.Context, int64, string, time.Time, time.Time) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (noIDOwnerRepo) MarkSucceeded(context.Context, int64, int, string, time.Time) error { return nil }
|
||||
func (noIDOwnerRepo) MarkFailedRetryable(context.Context, int64, string, time.Time, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (noIDOwnerRepo) DeleteExpired(context.Context, time.Time, int) (int64, error) { return 0, nil }
|
||||
|
||||
func TestIdempotencyCoordinator_RepoNilScopeRequiredAndRecordIDMissing(t *testing.T) {
|
||||
cfg := DefaultIdempotencyConfig()
|
||||
coordinator := NewIdempotencyCoordinator(nil, cfg)
|
||||
|
||||
_, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope",
|
||||
IdempotencyKey: "k",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
|
||||
|
||||
coordinator = NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), cfg)
|
||||
_, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
IdempotencyKey: "k2",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "IDEMPOTENCY_SCOPE_REQUIRED", infraerrors.Reason(err))
|
||||
|
||||
coordinator = NewIdempotencyCoordinator(noIDOwnerRepo{}, cfg)
|
||||
_, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope-no-id",
|
||||
IdempotencyKey: "k3",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
|
||||
}
|
||||
|
||||
type conflictBranchRepo struct {
|
||||
existing *IdempotencyRecord
|
||||
tryReclaimErr error
|
||||
tryReclaimOK bool
|
||||
}
|
||||
|
||||
func (r *conflictBranchRepo) CreateProcessing(context.Context, *IdempotencyRecord) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (r *conflictBranchRepo) GetByScopeAndKeyHash(context.Context, string, string) (*IdempotencyRecord, error) {
|
||||
return cloneRecord(r.existing), nil
|
||||
}
|
||||
func (r *conflictBranchRepo) TryReclaim(context.Context, int64, string, time.Time, time.Time, time.Time) (bool, error) {
|
||||
if r.tryReclaimErr != nil {
|
||||
return false, r.tryReclaimErr
|
||||
}
|
||||
return r.tryReclaimOK, nil
|
||||
}
|
||||
func (r *conflictBranchRepo) ExtendProcessingLock(context.Context, int64, string, time.Time, time.Time) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (r *conflictBranchRepo) MarkSucceeded(context.Context, int64, int, string, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (r *conflictBranchRepo) MarkFailedRetryable(context.Context, int64, string, time.Time, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (r *conflictBranchRepo) DeleteExpired(context.Context, time.Time, int) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_ConflictBranchesAndDecodeError(t *testing.T) {
|
||||
now := time.Now()
|
||||
fp, err := BuildIdempotencyFingerprint("POST", "/x", "u:1", map[string]any{"a": 1})
|
||||
require.NoError(t, err)
|
||||
badBody := "{bad-json"
|
||||
repo := &conflictBranchRepo{
|
||||
existing: &IdempotencyRecord{
|
||||
ID: 1,
|
||||
Scope: "scope",
|
||||
IdempotencyKeyHash: HashIdempotencyKey("k"),
|
||||
RequestFingerprint: fp,
|
||||
Status: IdempotencyStatusSucceeded,
|
||||
ResponseBody: &badBody,
|
||||
ExpiresAt: now.Add(time.Hour),
|
||||
},
|
||||
}
|
||||
coordinator := NewIdempotencyCoordinator(repo, DefaultIdempotencyConfig())
|
||||
_, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope",
|
||||
IdempotencyKey: "k",
|
||||
Method: "POST",
|
||||
Route: "/x",
|
||||
ActorScope: "u:1",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
|
||||
|
||||
repo.existing = &IdempotencyRecord{
|
||||
ID: 2,
|
||||
Scope: "scope",
|
||||
IdempotencyKeyHash: HashIdempotencyKey("k"),
|
||||
RequestFingerprint: fp,
|
||||
Status: "unknown",
|
||||
ExpiresAt: now.Add(time.Hour),
|
||||
}
|
||||
_, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope",
|
||||
IdempotencyKey: "k",
|
||||
Method: "POST",
|
||||
Route: "/x",
|
||||
ActorScope: "u:1",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(ErrIdempotencyKeyConflict), infraerrors.Code(err))
|
||||
|
||||
repo.existing = &IdempotencyRecord{
|
||||
ID: 3,
|
||||
Scope: "scope",
|
||||
IdempotencyKeyHash: HashIdempotencyKey("k"),
|
||||
RequestFingerprint: fp,
|
||||
Status: IdempotencyStatusFailedRetryable,
|
||||
LockedUntil: ptrTime(now.Add(-time.Second)),
|
||||
ExpiresAt: now.Add(time.Hour),
|
||||
}
|
||||
repo.tryReclaimErr = errors.New("reclaim down")
|
||||
_, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope",
|
||||
IdempotencyKey: "k",
|
||||
Method: "POST",
|
||||
Route: "/x",
|
||||
ActorScope: "u:1",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
|
||||
|
||||
repo.tryReclaimErr = nil
|
||||
repo.tryReclaimOK = false
|
||||
_, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope",
|
||||
IdempotencyKey: "k",
|
||||
Method: "POST",
|
||||
Route: "/x",
|
||||
ActorScope: "u:1",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(ErrIdempotencyInProgress), infraerrors.Code(err))
|
||||
}
|
||||
|
||||
type markBehaviorRepo struct {
|
||||
inMemoryIdempotencyRepo
|
||||
failMarkSucceeded bool
|
||||
failMarkFailed bool
|
||||
}
|
||||
|
||||
func (r *markBehaviorRepo) MarkSucceeded(ctx context.Context, id int64, responseStatus int, responseBody string, expiresAt time.Time) error {
|
||||
if r.failMarkSucceeded {
|
||||
return errors.New("mark succeeded failed")
|
||||
}
|
||||
return r.inMemoryIdempotencyRepo.MarkSucceeded(ctx, id, responseStatus, responseBody, expiresAt)
|
||||
}
|
||||
|
||||
func (r *markBehaviorRepo) MarkFailedRetryable(ctx context.Context, id int64, errorReason string, lockedUntil, expiresAt time.Time) error {
|
||||
if r.failMarkFailed {
|
||||
return errors.New("mark failed retryable failed")
|
||||
}
|
||||
return r.inMemoryIdempotencyRepo.MarkFailedRetryable(ctx, id, errorReason, lockedUntil, expiresAt)
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_MarkAndMarshalBranches(t *testing.T) {
|
||||
repo := &markBehaviorRepo{inMemoryIdempotencyRepo: *newInMemoryIdempotencyRepo()}
|
||||
coordinator := NewIdempotencyCoordinator(repo, DefaultIdempotencyConfig())
|
||||
|
||||
repo.failMarkSucceeded = true
|
||||
_, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope-success",
|
||||
IdempotencyKey: "k1",
|
||||
Method: "POST",
|
||||
Route: "/ok",
|
||||
ActorScope: "u:1",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
|
||||
|
||||
repo.failMarkSucceeded = false
|
||||
_, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope-marshal",
|
||||
IdempotencyKey: "k2",
|
||||
Method: "POST",
|
||||
Route: "/bad",
|
||||
ActorScope: "u:1",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"bad": make(chan int)}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
|
||||
|
||||
repo.failMarkFailed = true
|
||||
_, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope-fail",
|
||||
IdempotencyKey: "k3",
|
||||
Method: "POST",
|
||||
Route: "/fail",
|
||||
ActorScope: "u:1",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return nil, errors.New("plain failure")
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "plain failure", err.Error())
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_HelperBranches(t *testing.T) {
|
||||
c := NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), IdempotencyConfig{
|
||||
DefaultTTL: time.Hour,
|
||||
SystemOperationTTL: time.Hour,
|
||||
ProcessingTimeout: time.Second,
|
||||
FailedRetryBackoff: time.Second,
|
||||
MaxStoredResponseLen: 12,
|
||||
ObserveOnly: false,
|
||||
})
|
||||
|
||||
// conflictWithRetryAfter without locked_until should return base error.
|
||||
base := ErrIdempotencyInProgress
|
||||
err := c.conflictWithRetryAfter(base, nil, time.Now())
|
||||
require.Equal(t, infraerrors.Code(base), infraerrors.Code(err))
|
||||
|
||||
// marshalStoredResponse should truncate.
|
||||
body, err := c.marshalStoredResponse(map[string]any{"long": "abcdefghijklmnopqrstuvwxyz"})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, body, "...(truncated)")
|
||||
|
||||
// decodeStoredResponse empty and invalid json.
|
||||
out, err := c.decodeStoredResponse(nil)
|
||||
require.NoError(t, err)
|
||||
_, ok := out.(map[string]any)
|
||||
require.True(t, ok)
|
||||
|
||||
invalid := "{invalid"
|
||||
_, err = c.decodeStoredResponse(&invalid)
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -7,13 +7,14 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
)
|
||||
|
||||
// 预编译正则表达式(避免每次调用重新编译)
|
||||
@@ -45,6 +46,7 @@ type Fingerprint struct {
|
||||
StainlessArch string
|
||||
StainlessRuntime string
|
||||
StainlessRuntimeVersion string
|
||||
UpdatedAt int64 `json:",omitempty"` // Unix timestamp,用于判断是否需要续期TTL
|
||||
}
|
||||
|
||||
// IdentityCache defines cache operations for identity service
|
||||
@@ -77,14 +79,26 @@ func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID
|
||||
// 尝试从缓存获取指纹
|
||||
cached, err := s.cache.GetFingerprint(ctx, accountID)
|
||||
if err == nil && cached != nil {
|
||||
needWrite := false
|
||||
|
||||
// 检查客户端的user-agent是否是更新版本
|
||||
clientUA := headers.Get("User-Agent")
|
||||
if clientUA != "" && isNewerVersion(clientUA, cached.UserAgent) {
|
||||
// 更新user-agent
|
||||
cached.UserAgent = clientUA
|
||||
// 保存更新后的指纹
|
||||
_ = s.cache.SetFingerprint(ctx, accountID, cached)
|
||||
log.Printf("Updated fingerprint user-agent for account %d: %s", accountID, clientUA)
|
||||
// 版本升级:merge 语义 — 仅更新请求中实际携带的字段,保留缓存值
|
||||
// 避免缺失的头被硬编码默认值覆盖(如新 CLI 版本 + 旧 SDK 默认值的不一致)
|
||||
mergeHeadersIntoFingerprint(cached, headers)
|
||||
needWrite = true
|
||||
logger.LegacyPrintf("service.identity", "Updated fingerprint for account %d: %s (merge update)", accountID, clientUA)
|
||||
} else if time.Since(time.Unix(cached.UpdatedAt, 0)) > 24*time.Hour {
|
||||
// 距上次写入超过24小时,续期TTL
|
||||
needWrite = true
|
||||
}
|
||||
|
||||
if needWrite {
|
||||
cached.UpdatedAt = time.Now().Unix()
|
||||
if err := s.cache.SetFingerprint(ctx, accountID, cached); err != nil {
|
||||
logger.LegacyPrintf("service.identity", "Warning: failed to refresh fingerprint for account %d: %v", accountID, err)
|
||||
}
|
||||
}
|
||||
return cached, nil
|
||||
}
|
||||
@@ -94,13 +108,14 @@ func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID
|
||||
|
||||
// 生成随机ClientID
|
||||
fp.ClientID = generateClientID()
|
||||
fp.UpdatedAt = time.Now().Unix()
|
||||
|
||||
// 保存到缓存(永不过期)
|
||||
// 保存到缓存(7天TTL,每24小时自动续期)
|
||||
if err := s.cache.SetFingerprint(ctx, accountID, fp); err != nil {
|
||||
log.Printf("Warning: failed to cache fingerprint for account %d: %v", accountID, err)
|
||||
logger.LegacyPrintf("service.identity", "Warning: failed to cache fingerprint for account %d: %v", accountID, err)
|
||||
}
|
||||
|
||||
log.Printf("Created new fingerprint for account %d with client_id: %s", accountID, fp.ClientID)
|
||||
logger.LegacyPrintf("service.identity", "Created new fingerprint for account %d with client_id: %s", accountID, fp.ClientID)
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
@@ -126,6 +141,31 @@ func (s *IdentityService) createFingerprintFromHeaders(headers http.Header) *Fin
|
||||
return fp
|
||||
}
|
||||
|
||||
// mergeHeadersIntoFingerprint 将请求头中实际存在的字段合并到现有指纹中(用于版本升级场景)
|
||||
// 关键语义:请求中有的字段 → 用新值覆盖;缺失的头 → 保留缓存中的已有值
|
||||
// 与 createFingerprintFromHeaders 的区别:后者用于首次创建,缺失头回退到 defaultFingerprint;
|
||||
// 本函数用于升级更新,缺失头保留缓存值,避免将已知的真实值退化为硬编码默认值
|
||||
func mergeHeadersIntoFingerprint(fp *Fingerprint, headers http.Header) {
|
||||
// User-Agent:版本升级的触发条件,一定存在
|
||||
if ua := headers.Get("User-Agent"); ua != "" {
|
||||
fp.UserAgent = ua
|
||||
}
|
||||
// X-Stainless-* 头:仅在请求中实际携带时才更新,否则保留缓存值
|
||||
mergeHeader(headers, "X-Stainless-Lang", &fp.StainlessLang)
|
||||
mergeHeader(headers, "X-Stainless-Package-Version", &fp.StainlessPackageVersion)
|
||||
mergeHeader(headers, "X-Stainless-OS", &fp.StainlessOS)
|
||||
mergeHeader(headers, "X-Stainless-Arch", &fp.StainlessArch)
|
||||
mergeHeader(headers, "X-Stainless-Runtime", &fp.StainlessRuntime)
|
||||
mergeHeader(headers, "X-Stainless-Runtime-Version", &fp.StainlessRuntimeVersion)
|
||||
}
|
||||
|
||||
// mergeHeader 如果请求头中存在该字段则更新目标值,否则保留原值
|
||||
func mergeHeader(headers http.Header, key string, target *string) {
|
||||
if v := headers.Get(key); v != "" {
|
||||
*target = v
|
||||
}
|
||||
}
|
||||
|
||||
// getHeaderOrDefault 获取header值,如果不存在则返回默认值
|
||||
func getHeaderOrDefault(headers http.Header, key, defaultValue string) string {
|
||||
if v := headers.Get(key); v != "" {
|
||||
@@ -277,19 +317,19 @@ func (s *IdentityService) RewriteUserIDWithMasking(ctx context.Context, body []b
|
||||
// 获取或生成固定的伪装 session ID
|
||||
maskedSessionID, err := s.cache.GetMaskedSessionID(ctx, account.ID)
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to get masked session ID for account %d: %v", account.ID, err)
|
||||
logger.LegacyPrintf("service.identity", "Warning: failed to get masked session ID for account %d: %v", account.ID, err)
|
||||
return newBody, nil
|
||||
}
|
||||
|
||||
if maskedSessionID == "" {
|
||||
// 首次或已过期,生成新的伪装 session ID
|
||||
maskedSessionID = generateRandomUUID()
|
||||
log.Printf("Generated new masked session ID for account %d: %s", account.ID, maskedSessionID)
|
||||
logger.LegacyPrintf("service.identity", "Generated new masked session ID for account %d: %s", account.ID, maskedSessionID)
|
||||
}
|
||||
|
||||
// 刷新 TTL(每次请求都刷新,保持 15 分钟有效期)
|
||||
if err := s.cache.SetMaskedSessionID(ctx, account.ID, maskedSessionID); err != nil {
|
||||
log.Printf("Warning: failed to set masked session ID for account %d: %v", account.ID, err)
|
||||
logger.LegacyPrintf("service.identity", "Warning: failed to set masked session ID for account %d: %v", account.ID, err)
|
||||
}
|
||||
|
||||
// 替换 session 部分:保留 _session_ 之前的内容,替换之后的内容
|
||||
@@ -335,7 +375,7 @@ func generateClientID() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// 极罕见的情况,使用时间戳+固定值作为fallback
|
||||
log.Printf("Warning: crypto/rand.Read failed: %v, using fallback", err)
|
||||
logger.LegacyPrintf("service.identity", "Warning: crypto/rand.Read failed: %v, using fallback", err)
|
||||
// 使用SHA256(当前纳秒时间)作为fallback
|
||||
h := sha256.Sum256([]byte(fmt.Sprintf("%d", time.Now().UnixNano())))
|
||||
return hex.EncodeToString(h[:])
|
||||
@@ -370,8 +410,25 @@ func parseUserAgentVersion(ua string) (major, minor, patch int, ok bool) {
|
||||
return major, minor, patch, true
|
||||
}
|
||||
|
||||
// extractProduct 提取 User-Agent 中 "/" 前的产品名
|
||||
// 例如:claude-cli/2.1.22 (external, cli) -> "claude-cli"
|
||||
func extractProduct(ua string) string {
|
||||
if idx := strings.Index(ua, "/"); idx > 0 {
|
||||
return strings.ToLower(ua[:idx])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isNewerVersion 比较版本号,判断newUA是否比cachedUA更新
|
||||
// 要求产品名一致(防止浏览器 UA 如 Mozilla/5.0 误判为更新版本)
|
||||
func isNewerVersion(newUA, cachedUA string) bool {
|
||||
// 校验产品名一致性
|
||||
newProduct := extractProduct(newUA)
|
||||
cachedProduct := extractProduct(cachedUA)
|
||||
if newProduct == "" || cachedProduct == "" || newProduct != cachedProduct {
|
||||
return false
|
||||
}
|
||||
|
||||
newMajor, newMinor, newPatch, newOk := parseUserAgentVersion(newUA)
|
||||
cachedMajor, cachedMinor, cachedPatch, cachedOk := parseUserAgentVersion(cachedUA)
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
)
|
||||
|
||||
const modelRateLimitsKey = "model_rate_limits"
|
||||
@@ -73,7 +71,7 @@ func resolveFinalAntigravityModelKey(ctx context.Context, account *Account, requ
|
||||
return ""
|
||||
}
|
||||
// thinking 会影响 Antigravity 最终模型名(例如 claude-sonnet-4-5 -> claude-sonnet-4-5-thinking)
|
||||
if enabled, ok := ctx.Value(ctxkey.ThinkingEnabled).(bool); ok {
|
||||
if enabled, ok := ThinkingEnabledFromContext(ctx); ok {
|
||||
modelKey = applyThinkingModelSuffix(modelKey, enabled)
|
||||
}
|
||||
return modelKey
|
||||
|
||||
@@ -12,8 +12,9 @@ import (
|
||||
|
||||
// OpenAIOAuthClient interface for OpenAI OAuth operations
|
||||
type OpenAIOAuthClient interface {
|
||||
ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL string) (*openai.TokenResponse, error)
|
||||
ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL, clientID string) (*openai.TokenResponse, error)
|
||||
RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*openai.TokenResponse, error)
|
||||
RefreshTokenWithClientID(ctx context.Context, refreshToken, proxyURL string, clientID string) (*openai.TokenResponse, error)
|
||||
}
|
||||
|
||||
// ClaudeOAuthClient handles HTTP requests for Claude OAuth flows
|
||||
@@ -217,7 +218,7 @@ func (s *OAuthService) CookieAuth(ctx context.Context, input *CookieAuthInput) (
|
||||
// Ensure org_uuid is set (from step 1 if not from token response)
|
||||
if tokenInfo.OrgUUID == "" && orgUUID != "" {
|
||||
tokenInfo.OrgUUID = orgUUID
|
||||
log.Printf("[OAuth] Set org_uuid from cookie auth: %s", orgUUID)
|
||||
log.Printf("[OAuth] Set org_uuid from cookie auth")
|
||||
}
|
||||
|
||||
return tokenInfo, nil
|
||||
@@ -251,16 +252,16 @@ func (s *OAuthService) exchangeCodeForToken(ctx context.Context, code, codeVerif
|
||||
|
||||
if tokenResp.Organization != nil && tokenResp.Organization.UUID != "" {
|
||||
tokenInfo.OrgUUID = tokenResp.Organization.UUID
|
||||
log.Printf("[OAuth] Got org_uuid: %s", tokenInfo.OrgUUID)
|
||||
log.Printf("[OAuth] Got org_uuid")
|
||||
}
|
||||
if tokenResp.Account != nil {
|
||||
if tokenResp.Account.UUID != "" {
|
||||
tokenInfo.AccountUUID = tokenResp.Account.UUID
|
||||
log.Printf("[OAuth] Got account_uuid: %s", tokenInfo.AccountUUID)
|
||||
log.Printf("[OAuth] Got account_uuid")
|
||||
}
|
||||
if tokenResp.Account.EmailAddress != "" {
|
||||
tokenInfo.EmailAddress = tokenResp.Account.EmailAddress
|
||||
log.Printf("[OAuth] Got email_address: %s", tokenInfo.EmailAddress)
|
||||
log.Printf("[OAuth] Got email_address")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
)
|
||||
|
||||
// --- mock: ClaudeOAuthClient ---
|
||||
|
||||
type mockClaudeOAuthClient struct {
|
||||
getOrgUUIDFunc func(ctx context.Context, sessionKey, proxyURL string) (string, error)
|
||||
getAuthCodeFunc func(ctx context.Context, sessionKey, orgUUID, scope, codeChallenge, state, proxyURL string) (string, error)
|
||||
exchangeCodeFunc func(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*oauth.TokenResponse, error)
|
||||
refreshTokenFunc func(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error)
|
||||
}
|
||||
|
||||
func (m *mockClaudeOAuthClient) GetOrganizationUUID(ctx context.Context, sessionKey, proxyURL string) (string, error) {
|
||||
if m.getOrgUUIDFunc != nil {
|
||||
return m.getOrgUUIDFunc(ctx, sessionKey, proxyURL)
|
||||
}
|
||||
panic("GetOrganizationUUID not implemented")
|
||||
}
|
||||
|
||||
func (m *mockClaudeOAuthClient) GetAuthorizationCode(ctx context.Context, sessionKey, orgUUID, scope, codeChallenge, state, proxyURL string) (string, error) {
|
||||
if m.getAuthCodeFunc != nil {
|
||||
return m.getAuthCodeFunc(ctx, sessionKey, orgUUID, scope, codeChallenge, state, proxyURL)
|
||||
}
|
||||
panic("GetAuthorizationCode not implemented")
|
||||
}
|
||||
|
||||
func (m *mockClaudeOAuthClient) ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*oauth.TokenResponse, error) {
|
||||
if m.exchangeCodeFunc != nil {
|
||||
return m.exchangeCodeFunc(ctx, code, codeVerifier, state, proxyURL, isSetupToken)
|
||||
}
|
||||
panic("ExchangeCodeForToken not implemented")
|
||||
}
|
||||
|
||||
func (m *mockClaudeOAuthClient) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error) {
|
||||
if m.refreshTokenFunc != nil {
|
||||
return m.refreshTokenFunc(ctx, refreshToken, proxyURL)
|
||||
}
|
||||
panic("RefreshToken not implemented")
|
||||
}
|
||||
|
||||
// --- mock: ProxyRepository (最小实现,仅覆盖 OAuthService 依赖的方法) ---
|
||||
|
||||
type mockProxyRepoForOAuth struct {
|
||||
getByIDFunc func(ctx context.Context, id int64) (*Proxy, error)
|
||||
}
|
||||
|
||||
func (m *mockProxyRepoForOAuth) Create(ctx context.Context, proxy *Proxy) error {
|
||||
panic("Create not implemented")
|
||||
}
|
||||
func (m *mockProxyRepoForOAuth) GetByID(ctx context.Context, id int64) (*Proxy, error) {
|
||||
if m.getByIDFunc != nil {
|
||||
return m.getByIDFunc(ctx, id)
|
||||
}
|
||||
return nil, fmt.Errorf("proxy not found")
|
||||
}
|
||||
func (m *mockProxyRepoForOAuth) ListByIDs(ctx context.Context, ids []int64) ([]Proxy, error) {
|
||||
panic("ListByIDs not implemented")
|
||||
}
|
||||
func (m *mockProxyRepoForOAuth) Update(ctx context.Context, proxy *Proxy) error {
|
||||
panic("Update not implemented")
|
||||
}
|
||||
func (m *mockProxyRepoForOAuth) Delete(ctx context.Context, id int64) error {
|
||||
panic("Delete not implemented")
|
||||
}
|
||||
func (m *mockProxyRepoForOAuth) List(ctx context.Context, params pagination.PaginationParams) ([]Proxy, *pagination.PaginationResult, error) {
|
||||
panic("List not implemented")
|
||||
}
|
||||
func (m *mockProxyRepoForOAuth) ListWithFilters(ctx context.Context, params pagination.PaginationParams, protocol, status, search string) ([]Proxy, *pagination.PaginationResult, error) {
|
||||
panic("ListWithFilters not implemented")
|
||||
}
|
||||
func (m *mockProxyRepoForOAuth) ListWithFiltersAndAccountCount(ctx context.Context, params pagination.PaginationParams, protocol, status, search string) ([]ProxyWithAccountCount, *pagination.PaginationResult, error) {
|
||||
panic("ListWithFiltersAndAccountCount not implemented")
|
||||
}
|
||||
func (m *mockProxyRepoForOAuth) ListActive(ctx context.Context) ([]Proxy, error) {
|
||||
panic("ListActive not implemented")
|
||||
}
|
||||
func (m *mockProxyRepoForOAuth) ListActiveWithAccountCount(ctx context.Context) ([]ProxyWithAccountCount, error) {
|
||||
panic("ListActiveWithAccountCount not implemented")
|
||||
}
|
||||
func (m *mockProxyRepoForOAuth) ExistsByHostPortAuth(ctx context.Context, host string, port int, username, password string) (bool, error) {
|
||||
panic("ExistsByHostPortAuth not implemented")
|
||||
}
|
||||
func (m *mockProxyRepoForOAuth) CountAccountsByProxyID(ctx context.Context, proxyID int64) (int64, error) {
|
||||
panic("CountAccountsByProxyID not implemented")
|
||||
}
|
||||
func (m *mockProxyRepoForOAuth) ListAccountSummariesByProxyID(ctx context.Context, proxyID int64) ([]ProxyAccountSummary, error) {
|
||||
panic("ListAccountSummariesByProxyID not implemented")
|
||||
}
|
||||
|
||||
// =====================
|
||||
// 测试用例
|
||||
// =====================
|
||||
|
||||
func TestNewOAuthService(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
proxyRepo := &mockProxyRepoForOAuth{}
|
||||
client := &mockClaudeOAuthClient{}
|
||||
svc := NewOAuthService(proxyRepo, client)
|
||||
|
||||
if svc == nil {
|
||||
t.Fatal("NewOAuthService 返回 nil")
|
||||
}
|
||||
if svc.proxyRepo != proxyRepo {
|
||||
t.Fatal("proxyRepo 未正确设置")
|
||||
}
|
||||
if svc.oauthClient != client {
|
||||
t.Fatal("oauthClient 未正确设置")
|
||||
}
|
||||
if svc.sessionStore == nil {
|
||||
t.Fatal("sessionStore 应被自动初始化")
|
||||
}
|
||||
|
||||
// 清理
|
||||
svc.Stop()
|
||||
}
|
||||
|
||||
func TestOAuthService_GenerateAuthURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := NewOAuthService(&mockProxyRepoForOAuth{}, &mockClaudeOAuthClient{})
|
||||
defer svc.Stop()
|
||||
|
||||
result, err := svc.GenerateAuthURL(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAuthURL 返回错误: %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("GenerateAuthURL 返回 nil")
|
||||
}
|
||||
if result.AuthURL == "" {
|
||||
t.Fatal("AuthURL 为空")
|
||||
}
|
||||
if result.SessionID == "" {
|
||||
t.Fatal("SessionID 为空")
|
||||
}
|
||||
|
||||
// 验证 session 已存储
|
||||
session, ok := svc.sessionStore.Get(result.SessionID)
|
||||
if !ok {
|
||||
t.Fatal("session 未在 sessionStore 中找到")
|
||||
}
|
||||
if session.Scope != oauth.ScopeOAuth {
|
||||
t.Fatalf("scope 不匹配: got=%q want=%q", session.Scope, oauth.ScopeOAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthService_GenerateAuthURL_WithProxy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
proxyRepo := &mockProxyRepoForOAuth{
|
||||
getByIDFunc: func(ctx context.Context, id int64) (*Proxy, error) {
|
||||
return &Proxy{
|
||||
ID: 1,
|
||||
Protocol: "http",
|
||||
Host: "proxy.example.com",
|
||||
Port: 8080,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
svc := NewOAuthService(proxyRepo, &mockClaudeOAuthClient{})
|
||||
defer svc.Stop()
|
||||
|
||||
proxyID := int64(1)
|
||||
result, err := svc.GenerateAuthURL(context.Background(), &proxyID)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAuthURL 返回错误: %v", err)
|
||||
}
|
||||
|
||||
session, ok := svc.sessionStore.Get(result.SessionID)
|
||||
if !ok {
|
||||
t.Fatal("session 未在 sessionStore 中找到")
|
||||
}
|
||||
if session.ProxyURL != "http://proxy.example.com:8080" {
|
||||
t.Fatalf("ProxyURL 不匹配: got=%q", session.ProxyURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthService_GenerateSetupTokenURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := NewOAuthService(&mockProxyRepoForOAuth{}, &mockClaudeOAuthClient{})
|
||||
defer svc.Stop()
|
||||
|
||||
result, err := svc.GenerateSetupTokenURL(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSetupTokenURL 返回错误: %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("GenerateSetupTokenURL 返回 nil")
|
||||
}
|
||||
|
||||
// 验证 scope 是 inference
|
||||
session, ok := svc.sessionStore.Get(result.SessionID)
|
||||
if !ok {
|
||||
t.Fatal("session 未在 sessionStore 中找到")
|
||||
}
|
||||
if session.Scope != oauth.ScopeInference {
|
||||
t.Fatalf("scope 不匹配: got=%q want=%q", session.Scope, oauth.ScopeInference)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthService_ExchangeCode_SessionNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := NewOAuthService(&mockProxyRepoForOAuth{}, &mockClaudeOAuthClient{})
|
||||
defer svc.Stop()
|
||||
|
||||
_, err := svc.ExchangeCode(context.Background(), &ExchangeCodeInput{
|
||||
SessionID: "nonexistent-session",
|
||||
Code: "test-code",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ExchangeCode 应返回错误(session 不存在)")
|
||||
}
|
||||
if err.Error() != "session not found or expired" {
|
||||
t.Fatalf("错误信息不匹配: got=%q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthService_ExchangeCode_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
exchangeCalled := false
|
||||
client := &mockClaudeOAuthClient{
|
||||
exchangeCodeFunc: func(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*oauth.TokenResponse, error) {
|
||||
exchangeCalled = true
|
||||
if code != "auth-code-123" {
|
||||
t.Errorf("code 不匹配: got=%q", code)
|
||||
}
|
||||
if isSetupToken {
|
||||
t.Error("isSetupToken 应为 false(ScopeOAuth)")
|
||||
}
|
||||
return &oauth.TokenResponse{
|
||||
AccessToken: "access-token-abc",
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
RefreshToken: "refresh-token-xyz",
|
||||
Scope: oauth.ScopeOAuth,
|
||||
Organization: &oauth.OrgInfo{UUID: "org-uuid-111"},
|
||||
Account: &oauth.AccountInfo{UUID: "acc-uuid-222", EmailAddress: "test@example.com"},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewOAuthService(&mockProxyRepoForOAuth{}, client)
|
||||
defer svc.Stop()
|
||||
|
||||
// 先生成 URL 以创建 session
|
||||
result, err := svc.GenerateAuthURL(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAuthURL 返回错误: %v", err)
|
||||
}
|
||||
|
||||
// 交换 code
|
||||
tokenInfo, err := svc.ExchangeCode(context.Background(), &ExchangeCodeInput{
|
||||
SessionID: result.SessionID,
|
||||
Code: "auth-code-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExchangeCode 返回错误: %v", err)
|
||||
}
|
||||
|
||||
if !exchangeCalled {
|
||||
t.Fatal("ExchangeCodeForToken 未被调用")
|
||||
}
|
||||
if tokenInfo.AccessToken != "access-token-abc" {
|
||||
t.Fatalf("AccessToken 不匹配: got=%q", tokenInfo.AccessToken)
|
||||
}
|
||||
if tokenInfo.TokenType != "Bearer" {
|
||||
t.Fatalf("TokenType 不匹配: got=%q", tokenInfo.TokenType)
|
||||
}
|
||||
if tokenInfo.RefreshToken != "refresh-token-xyz" {
|
||||
t.Fatalf("RefreshToken 不匹配: got=%q", tokenInfo.RefreshToken)
|
||||
}
|
||||
if tokenInfo.OrgUUID != "org-uuid-111" {
|
||||
t.Fatalf("OrgUUID 不匹配: got=%q", tokenInfo.OrgUUID)
|
||||
}
|
||||
if tokenInfo.AccountUUID != "acc-uuid-222" {
|
||||
t.Fatalf("AccountUUID 不匹配: got=%q", tokenInfo.AccountUUID)
|
||||
}
|
||||
if tokenInfo.EmailAddress != "test@example.com" {
|
||||
t.Fatalf("EmailAddress 不匹配: got=%q", tokenInfo.EmailAddress)
|
||||
}
|
||||
if tokenInfo.ExpiresIn != 3600 {
|
||||
t.Fatalf("ExpiresIn 不匹配: got=%d", tokenInfo.ExpiresIn)
|
||||
}
|
||||
if tokenInfo.ExpiresAt == 0 {
|
||||
t.Fatal("ExpiresAt 不应为 0")
|
||||
}
|
||||
|
||||
// 验证 session 已被删除
|
||||
_, ok := svc.sessionStore.Get(result.SessionID)
|
||||
if ok {
|
||||
t.Fatal("session 应在交换成功后被删除")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthService_ExchangeCode_SetupToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &mockClaudeOAuthClient{
|
||||
exchangeCodeFunc: func(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*oauth.TokenResponse, error) {
|
||||
if !isSetupToken {
|
||||
t.Error("isSetupToken 应为 true(ScopeInference)")
|
||||
}
|
||||
return &oauth.TokenResponse{
|
||||
AccessToken: "setup-token",
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
Scope: oauth.ScopeInference,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewOAuthService(&mockProxyRepoForOAuth{}, client)
|
||||
defer svc.Stop()
|
||||
|
||||
// 使用 SetupToken URL(inference scope)
|
||||
result, err := svc.GenerateSetupTokenURL(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSetupTokenURL 返回错误: %v", err)
|
||||
}
|
||||
|
||||
tokenInfo, err := svc.ExchangeCode(context.Background(), &ExchangeCodeInput{
|
||||
SessionID: result.SessionID,
|
||||
Code: "setup-code",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExchangeCode 返回错误: %v", err)
|
||||
}
|
||||
if tokenInfo.AccessToken != "setup-token" {
|
||||
t.Fatalf("AccessToken 不匹配: got=%q", tokenInfo.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthService_ExchangeCode_ClientError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &mockClaudeOAuthClient{
|
||||
exchangeCodeFunc: func(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*oauth.TokenResponse, error) {
|
||||
return nil, fmt.Errorf("upstream error: invalid code")
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewOAuthService(&mockProxyRepoForOAuth{}, client)
|
||||
defer svc.Stop()
|
||||
|
||||
result, _ := svc.GenerateAuthURL(context.Background(), nil)
|
||||
_, err := svc.ExchangeCode(context.Background(), &ExchangeCodeInput{
|
||||
SessionID: result.SessionID,
|
||||
Code: "bad-code",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ExchangeCode 应返回错误")
|
||||
}
|
||||
if err.Error() != "upstream error: invalid code" {
|
||||
t.Fatalf("错误信息不匹配: got=%q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthService_RefreshToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &mockClaudeOAuthClient{
|
||||
refreshTokenFunc: func(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error) {
|
||||
if refreshToken != "my-refresh-token" {
|
||||
t.Errorf("refreshToken 不匹配: got=%q", refreshToken)
|
||||
}
|
||||
if proxyURL != "" {
|
||||
t.Errorf("proxyURL 应为空: got=%q", proxyURL)
|
||||
}
|
||||
return &oauth.TokenResponse{
|
||||
AccessToken: "new-access-token",
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 7200,
|
||||
RefreshToken: "new-refresh-token",
|
||||
Scope: oauth.ScopeOAuth,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewOAuthService(&mockProxyRepoForOAuth{}, client)
|
||||
defer svc.Stop()
|
||||
|
||||
tokenInfo, err := svc.RefreshToken(context.Background(), "my-refresh-token", "")
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshToken 返回错误: %v", err)
|
||||
}
|
||||
if tokenInfo.AccessToken != "new-access-token" {
|
||||
t.Fatalf("AccessToken 不匹配: got=%q", tokenInfo.AccessToken)
|
||||
}
|
||||
if tokenInfo.RefreshToken != "new-refresh-token" {
|
||||
t.Fatalf("RefreshToken 不匹配: got=%q", tokenInfo.RefreshToken)
|
||||
}
|
||||
if tokenInfo.ExpiresIn != 7200 {
|
||||
t.Fatalf("ExpiresIn 不匹配: got=%d", tokenInfo.ExpiresIn)
|
||||
}
|
||||
if tokenInfo.ExpiresAt == 0 {
|
||||
t.Fatal("ExpiresAt 不应为 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthService_RefreshToken_Error(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &mockClaudeOAuthClient{
|
||||
refreshTokenFunc: func(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error) {
|
||||
return nil, fmt.Errorf("invalid_grant: token expired")
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewOAuthService(&mockProxyRepoForOAuth{}, client)
|
||||
defer svc.Stop()
|
||||
|
||||
_, err := svc.RefreshToken(context.Background(), "expired-token", "")
|
||||
if err == nil {
|
||||
t.Fatal("RefreshToken 应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthService_RefreshAccountToken_NoRefreshToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := NewOAuthService(&mockProxyRepoForOAuth{}, &mockClaudeOAuthClient{})
|
||||
defer svc.Stop()
|
||||
|
||||
// 无 refresh_token 的账号
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "some-token",
|
||||
},
|
||||
}
|
||||
_, err := svc.RefreshAccountToken(context.Background(), account)
|
||||
if err == nil {
|
||||
t.Fatal("RefreshAccountToken 应返回错误(无 refresh_token)")
|
||||
}
|
||||
if err.Error() != "no refresh token available" {
|
||||
t.Fatalf("错误信息不匹配: got=%q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthService_RefreshAccountToken_EmptyRefreshToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := NewOAuthService(&mockProxyRepoForOAuth{}, &mockClaudeOAuthClient{})
|
||||
defer svc.Stop()
|
||||
|
||||
account := &Account{
|
||||
ID: 2,
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "some-token",
|
||||
"refresh_token": "",
|
||||
},
|
||||
}
|
||||
_, err := svc.RefreshAccountToken(context.Background(), account)
|
||||
if err == nil {
|
||||
t.Fatal("RefreshAccountToken 应返回错误(refresh_token 为空)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthService_RefreshAccountToken_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &mockClaudeOAuthClient{
|
||||
refreshTokenFunc: func(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error) {
|
||||
if refreshToken != "account-refresh-token" {
|
||||
t.Errorf("refreshToken 不匹配: got=%q", refreshToken)
|
||||
}
|
||||
return &oauth.TokenResponse{
|
||||
AccessToken: "refreshed-access",
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
RefreshToken: "new-refresh",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewOAuthService(&mockProxyRepoForOAuth{}, client)
|
||||
defer svc.Stop()
|
||||
|
||||
account := &Account{
|
||||
ID: 3,
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "old-access",
|
||||
"refresh_token": "account-refresh-token",
|
||||
},
|
||||
}
|
||||
|
||||
tokenInfo, err := svc.RefreshAccountToken(context.Background(), account)
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshAccountToken 返回错误: %v", err)
|
||||
}
|
||||
if tokenInfo.AccessToken != "refreshed-access" {
|
||||
t.Fatalf("AccessToken 不匹配: got=%q", tokenInfo.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthService_RefreshAccountToken_WithProxy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
proxyRepo := &mockProxyRepoForOAuth{
|
||||
getByIDFunc: func(ctx context.Context, id int64) (*Proxy, error) {
|
||||
return &Proxy{
|
||||
Protocol: "socks5",
|
||||
Host: "socks.example.com",
|
||||
Port: 1080,
|
||||
Username: "user",
|
||||
Password: "pass",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
client := &mockClaudeOAuthClient{
|
||||
refreshTokenFunc: func(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error) {
|
||||
if proxyURL != "socks5://user:pass@socks.example.com:1080" {
|
||||
t.Errorf("proxyURL 不匹配: got=%q", proxyURL)
|
||||
}
|
||||
return &oauth.TokenResponse{
|
||||
AccessToken: "refreshed",
|
||||
ExpiresIn: 3600,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewOAuthService(proxyRepo, client)
|
||||
defer svc.Stop()
|
||||
|
||||
proxyID := int64(10)
|
||||
account := &Account{
|
||||
ID: 4,
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeOAuth,
|
||||
ProxyID: &proxyID,
|
||||
Credentials: map[string]any{
|
||||
"refresh_token": "rt-with-proxy",
|
||||
},
|
||||
}
|
||||
|
||||
_, err := svc.RefreshAccountToken(context.Background(), account)
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshAccountToken 返回错误: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthService_ExchangeCode_NilOrg(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &mockClaudeOAuthClient{
|
||||
exchangeCodeFunc: func(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*oauth.TokenResponse, error) {
|
||||
return &oauth.TokenResponse{
|
||||
AccessToken: "token-no-org",
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
Organization: nil,
|
||||
Account: nil,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewOAuthService(&mockProxyRepoForOAuth{}, client)
|
||||
defer svc.Stop()
|
||||
|
||||
result, _ := svc.GenerateAuthURL(context.Background(), nil)
|
||||
tokenInfo, err := svc.ExchangeCode(context.Background(), &ExchangeCodeInput{
|
||||
SessionID: result.SessionID,
|
||||
Code: "code",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExchangeCode 返回错误: %v", err)
|
||||
}
|
||||
if tokenInfo.OrgUUID != "" {
|
||||
t.Fatalf("OrgUUID 应为空: got=%q", tokenInfo.OrgUUID)
|
||||
}
|
||||
if tokenInfo.AccountUUID != "" {
|
||||
t.Fatalf("AccountUUID 应为空: got=%q", tokenInfo.AccountUUID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthService_Stop_NoPanic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := NewOAuthService(&mockProxyRepoForOAuth{}, &mockClaudeOAuthClient{})
|
||||
|
||||
// 调用 Stop 不应 panic
|
||||
svc.Stop()
|
||||
|
||||
// 多次调用也不应 panic
|
||||
svc.Stop()
|
||||
}
|
||||
@@ -0,0 +1,909 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"context"
|
||||
"errors"
|
||||
"hash/fnv"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
openAIAccountScheduleLayerPreviousResponse = "previous_response_id"
|
||||
openAIAccountScheduleLayerSessionSticky = "session_hash"
|
||||
openAIAccountScheduleLayerLoadBalance = "load_balance"
|
||||
)
|
||||
|
||||
type OpenAIAccountScheduleRequest struct {
|
||||
GroupID *int64
|
||||
SessionHash string
|
||||
StickyAccountID int64
|
||||
PreviousResponseID string
|
||||
RequestedModel string
|
||||
RequiredTransport OpenAIUpstreamTransport
|
||||
ExcludedIDs map[int64]struct{}
|
||||
}
|
||||
|
||||
type OpenAIAccountScheduleDecision struct {
|
||||
Layer string
|
||||
StickyPreviousHit bool
|
||||
StickySessionHit bool
|
||||
CandidateCount int
|
||||
TopK int
|
||||
LatencyMs int64
|
||||
LoadSkew float64
|
||||
SelectedAccountID int64
|
||||
SelectedAccountType string
|
||||
}
|
||||
|
||||
type OpenAIAccountSchedulerMetricsSnapshot struct {
|
||||
SelectTotal int64
|
||||
StickyPreviousHitTotal int64
|
||||
StickySessionHitTotal int64
|
||||
LoadBalanceSelectTotal int64
|
||||
AccountSwitchTotal int64
|
||||
SchedulerLatencyMsTotal int64
|
||||
SchedulerLatencyMsAvg float64
|
||||
StickyHitRatio float64
|
||||
AccountSwitchRate float64
|
||||
LoadSkewAvg float64
|
||||
RuntimeStatsAccountCount int
|
||||
}
|
||||
|
||||
type OpenAIAccountScheduler interface {
|
||||
Select(ctx context.Context, req OpenAIAccountScheduleRequest) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error)
|
||||
ReportResult(accountID int64, success bool, firstTokenMs *int)
|
||||
ReportSwitch()
|
||||
SnapshotMetrics() OpenAIAccountSchedulerMetricsSnapshot
|
||||
}
|
||||
|
||||
type openAIAccountSchedulerMetrics struct {
|
||||
selectTotal atomic.Int64
|
||||
stickyPreviousHitTotal atomic.Int64
|
||||
stickySessionHitTotal atomic.Int64
|
||||
loadBalanceSelectTotal atomic.Int64
|
||||
accountSwitchTotal atomic.Int64
|
||||
latencyMsTotal atomic.Int64
|
||||
loadSkewMilliTotal atomic.Int64
|
||||
}
|
||||
|
||||
func (m *openAIAccountSchedulerMetrics) recordSelect(decision OpenAIAccountScheduleDecision) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.selectTotal.Add(1)
|
||||
m.latencyMsTotal.Add(decision.LatencyMs)
|
||||
m.loadSkewMilliTotal.Add(int64(math.Round(decision.LoadSkew * 1000)))
|
||||
if decision.StickyPreviousHit {
|
||||
m.stickyPreviousHitTotal.Add(1)
|
||||
}
|
||||
if decision.StickySessionHit {
|
||||
m.stickySessionHitTotal.Add(1)
|
||||
}
|
||||
if decision.Layer == openAIAccountScheduleLayerLoadBalance {
|
||||
m.loadBalanceSelectTotal.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *openAIAccountSchedulerMetrics) recordSwitch() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.accountSwitchTotal.Add(1)
|
||||
}
|
||||
|
||||
type openAIAccountRuntimeStats struct {
|
||||
accounts sync.Map
|
||||
accountCount atomic.Int64
|
||||
}
|
||||
|
||||
type openAIAccountRuntimeStat struct {
|
||||
errorRateEWMABits atomic.Uint64
|
||||
ttftEWMABits atomic.Uint64
|
||||
}
|
||||
|
||||
func newOpenAIAccountRuntimeStats() *openAIAccountRuntimeStats {
|
||||
return &openAIAccountRuntimeStats{}
|
||||
}
|
||||
|
||||
func (s *openAIAccountRuntimeStats) loadOrCreate(accountID int64) *openAIAccountRuntimeStat {
|
||||
if value, ok := s.accounts.Load(accountID); ok {
|
||||
stat, _ := value.(*openAIAccountRuntimeStat)
|
||||
if stat != nil {
|
||||
return stat
|
||||
}
|
||||
}
|
||||
|
||||
stat := &openAIAccountRuntimeStat{}
|
||||
stat.ttftEWMABits.Store(math.Float64bits(math.NaN()))
|
||||
actual, loaded := s.accounts.LoadOrStore(accountID, stat)
|
||||
if !loaded {
|
||||
s.accountCount.Add(1)
|
||||
return stat
|
||||
}
|
||||
existing, _ := actual.(*openAIAccountRuntimeStat)
|
||||
if existing != nil {
|
||||
return existing
|
||||
}
|
||||
return stat
|
||||
}
|
||||
|
||||
func updateEWMAAtomic(target *atomic.Uint64, sample float64, alpha float64) {
|
||||
for {
|
||||
oldBits := target.Load()
|
||||
oldValue := math.Float64frombits(oldBits)
|
||||
newValue := alpha*sample + (1-alpha)*oldValue
|
||||
if target.CompareAndSwap(oldBits, math.Float64bits(newValue)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *openAIAccountRuntimeStats) report(accountID int64, success bool, firstTokenMs *int) {
|
||||
if s == nil || accountID <= 0 {
|
||||
return
|
||||
}
|
||||
const alpha = 0.2
|
||||
stat := s.loadOrCreate(accountID)
|
||||
|
||||
errorSample := 1.0
|
||||
if success {
|
||||
errorSample = 0.0
|
||||
}
|
||||
updateEWMAAtomic(&stat.errorRateEWMABits, errorSample, alpha)
|
||||
|
||||
if firstTokenMs != nil && *firstTokenMs > 0 {
|
||||
ttft := float64(*firstTokenMs)
|
||||
ttftBits := math.Float64bits(ttft)
|
||||
for {
|
||||
oldBits := stat.ttftEWMABits.Load()
|
||||
oldValue := math.Float64frombits(oldBits)
|
||||
if math.IsNaN(oldValue) {
|
||||
if stat.ttftEWMABits.CompareAndSwap(oldBits, ttftBits) {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
newValue := alpha*ttft + (1-alpha)*oldValue
|
||||
if stat.ttftEWMABits.CompareAndSwap(oldBits, math.Float64bits(newValue)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *openAIAccountRuntimeStats) snapshot(accountID int64) (errorRate float64, ttft float64, hasTTFT bool) {
|
||||
if s == nil || accountID <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
value, ok := s.accounts.Load(accountID)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
stat, _ := value.(*openAIAccountRuntimeStat)
|
||||
if stat == nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
errorRate = clamp01(math.Float64frombits(stat.errorRateEWMABits.Load()))
|
||||
ttftValue := math.Float64frombits(stat.ttftEWMABits.Load())
|
||||
if math.IsNaN(ttftValue) {
|
||||
return errorRate, 0, false
|
||||
}
|
||||
return errorRate, ttftValue, true
|
||||
}
|
||||
|
||||
func (s *openAIAccountRuntimeStats) size() int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
return int(s.accountCount.Load())
|
||||
}
|
||||
|
||||
type defaultOpenAIAccountScheduler struct {
|
||||
service *OpenAIGatewayService
|
||||
metrics openAIAccountSchedulerMetrics
|
||||
stats *openAIAccountRuntimeStats
|
||||
}
|
||||
|
||||
func newDefaultOpenAIAccountScheduler(service *OpenAIGatewayService, stats *openAIAccountRuntimeStats) OpenAIAccountScheduler {
|
||||
if stats == nil {
|
||||
stats = newOpenAIAccountRuntimeStats()
|
||||
}
|
||||
return &defaultOpenAIAccountScheduler{
|
||||
service: service,
|
||||
stats: stats,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *defaultOpenAIAccountScheduler) Select(
|
||||
ctx context.Context,
|
||||
req OpenAIAccountScheduleRequest,
|
||||
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
|
||||
decision := OpenAIAccountScheduleDecision{}
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
decision.LatencyMs = time.Since(start).Milliseconds()
|
||||
s.metrics.recordSelect(decision)
|
||||
}()
|
||||
|
||||
previousResponseID := strings.TrimSpace(req.PreviousResponseID)
|
||||
if previousResponseID != "" {
|
||||
selection, err := s.service.SelectAccountByPreviousResponseID(
|
||||
ctx,
|
||||
req.GroupID,
|
||||
previousResponseID,
|
||||
req.RequestedModel,
|
||||
req.ExcludedIDs,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, decision, err
|
||||
}
|
||||
if selection != nil && selection.Account != nil {
|
||||
if !s.isAccountTransportCompatible(selection.Account, req.RequiredTransport) {
|
||||
selection = nil
|
||||
}
|
||||
}
|
||||
if selection != nil && selection.Account != nil {
|
||||
decision.Layer = openAIAccountScheduleLayerPreviousResponse
|
||||
decision.StickyPreviousHit = true
|
||||
decision.SelectedAccountID = selection.Account.ID
|
||||
decision.SelectedAccountType = selection.Account.Type
|
||||
if req.SessionHash != "" {
|
||||
_ = s.service.BindStickySession(ctx, req.GroupID, req.SessionHash, selection.Account.ID)
|
||||
}
|
||||
return selection, decision, nil
|
||||
}
|
||||
}
|
||||
|
||||
selection, err := s.selectBySessionHash(ctx, req)
|
||||
if err != nil {
|
||||
return nil, decision, err
|
||||
}
|
||||
if selection != nil && selection.Account != nil {
|
||||
decision.Layer = openAIAccountScheduleLayerSessionSticky
|
||||
decision.StickySessionHit = true
|
||||
decision.SelectedAccountID = selection.Account.ID
|
||||
decision.SelectedAccountType = selection.Account.Type
|
||||
return selection, decision, nil
|
||||
}
|
||||
|
||||
selection, candidateCount, topK, loadSkew, err := s.selectByLoadBalance(ctx, req)
|
||||
decision.Layer = openAIAccountScheduleLayerLoadBalance
|
||||
decision.CandidateCount = candidateCount
|
||||
decision.TopK = topK
|
||||
decision.LoadSkew = loadSkew
|
||||
if err != nil {
|
||||
return nil, decision, err
|
||||
}
|
||||
if selection != nil && selection.Account != nil {
|
||||
decision.SelectedAccountID = selection.Account.ID
|
||||
decision.SelectedAccountType = selection.Account.Type
|
||||
}
|
||||
return selection, decision, nil
|
||||
}
|
||||
|
||||
func (s *defaultOpenAIAccountScheduler) selectBySessionHash(
|
||||
ctx context.Context,
|
||||
req OpenAIAccountScheduleRequest,
|
||||
) (*AccountSelectionResult, error) {
|
||||
sessionHash := strings.TrimSpace(req.SessionHash)
|
||||
if sessionHash == "" || s == nil || s.service == nil || s.service.cache == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
accountID := req.StickyAccountID
|
||||
if accountID <= 0 {
|
||||
var err error
|
||||
accountID, err = s.service.getStickySessionAccountID(ctx, req.GroupID, sessionHash)
|
||||
if err != nil || accountID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
if accountID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if req.ExcludedIDs != nil {
|
||||
if _, excluded := req.ExcludedIDs[accountID]; excluded {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
account, err := s.service.getSchedulableAccount(ctx, accountID)
|
||||
if err != nil || account == nil {
|
||||
_ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash)
|
||||
return nil, nil
|
||||
}
|
||||
if shouldClearStickySession(account, req.RequestedModel) || !account.IsOpenAI() {
|
||||
_ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash)
|
||||
return nil, nil
|
||||
}
|
||||
if req.RequestedModel != "" && !account.IsModelSupported(req.RequestedModel) {
|
||||
return nil, nil
|
||||
}
|
||||
if !s.isAccountTransportCompatible(account, req.RequiredTransport) {
|
||||
_ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result, acquireErr := s.service.tryAcquireAccountSlot(ctx, accountID, account.Concurrency)
|
||||
if acquireErr == nil && result.Acquired {
|
||||
_ = s.service.refreshStickySessionTTL(ctx, req.GroupID, sessionHash, s.service.openAIWSSessionStickyTTL())
|
||||
return &AccountSelectionResult{
|
||||
Account: account,
|
||||
Acquired: true,
|
||||
ReleaseFunc: result.ReleaseFunc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
cfg := s.service.schedulingConfig()
|
||||
if s.service.concurrencyService != nil {
|
||||
return &AccountSelectionResult{
|
||||
Account: account,
|
||||
WaitPlan: &AccountWaitPlan{
|
||||
AccountID: accountID,
|
||||
MaxConcurrency: account.Concurrency,
|
||||
Timeout: cfg.StickySessionWaitTimeout,
|
||||
MaxWaiting: cfg.StickySessionMaxWaiting,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type openAIAccountCandidateScore struct {
|
||||
account *Account
|
||||
loadInfo *AccountLoadInfo
|
||||
score float64
|
||||
errorRate float64
|
||||
ttft float64
|
||||
hasTTFT bool
|
||||
}
|
||||
|
||||
type openAIAccountCandidateHeap []openAIAccountCandidateScore
|
||||
|
||||
func (h openAIAccountCandidateHeap) Len() int {
|
||||
return len(h)
|
||||
}
|
||||
|
||||
func (h openAIAccountCandidateHeap) Less(i, j int) bool {
|
||||
// 最小堆根节点保存“最差”候选,便于 O(log k) 维护 topK。
|
||||
return isOpenAIAccountCandidateBetter(h[j], h[i])
|
||||
}
|
||||
|
||||
func (h openAIAccountCandidateHeap) Swap(i, j int) {
|
||||
h[i], h[j] = h[j], h[i]
|
||||
}
|
||||
|
||||
func (h *openAIAccountCandidateHeap) Push(x any) {
|
||||
candidate, ok := x.(openAIAccountCandidateScore)
|
||||
if !ok {
|
||||
panic("openAIAccountCandidateHeap: invalid element type")
|
||||
}
|
||||
*h = append(*h, candidate)
|
||||
}
|
||||
|
||||
func (h *openAIAccountCandidateHeap) Pop() any {
|
||||
old := *h
|
||||
n := len(old)
|
||||
last := old[n-1]
|
||||
*h = old[:n-1]
|
||||
return last
|
||||
}
|
||||
|
||||
func isOpenAIAccountCandidateBetter(left openAIAccountCandidateScore, right openAIAccountCandidateScore) bool {
|
||||
if left.score != right.score {
|
||||
return left.score > right.score
|
||||
}
|
||||
if left.account.Priority != right.account.Priority {
|
||||
return left.account.Priority < right.account.Priority
|
||||
}
|
||||
if left.loadInfo.LoadRate != right.loadInfo.LoadRate {
|
||||
return left.loadInfo.LoadRate < right.loadInfo.LoadRate
|
||||
}
|
||||
if left.loadInfo.WaitingCount != right.loadInfo.WaitingCount {
|
||||
return left.loadInfo.WaitingCount < right.loadInfo.WaitingCount
|
||||
}
|
||||
return left.account.ID < right.account.ID
|
||||
}
|
||||
|
||||
func selectTopKOpenAICandidates(candidates []openAIAccountCandidateScore, topK int) []openAIAccountCandidateScore {
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
if topK <= 0 {
|
||||
topK = 1
|
||||
}
|
||||
if topK >= len(candidates) {
|
||||
ranked := append([]openAIAccountCandidateScore(nil), candidates...)
|
||||
sort.Slice(ranked, func(i, j int) bool {
|
||||
return isOpenAIAccountCandidateBetter(ranked[i], ranked[j])
|
||||
})
|
||||
return ranked
|
||||
}
|
||||
|
||||
best := make(openAIAccountCandidateHeap, 0, topK)
|
||||
for _, candidate := range candidates {
|
||||
if len(best) < topK {
|
||||
heap.Push(&best, candidate)
|
||||
continue
|
||||
}
|
||||
if isOpenAIAccountCandidateBetter(candidate, best[0]) {
|
||||
best[0] = candidate
|
||||
heap.Fix(&best, 0)
|
||||
}
|
||||
}
|
||||
|
||||
ranked := make([]openAIAccountCandidateScore, len(best))
|
||||
copy(ranked, best)
|
||||
sort.Slice(ranked, func(i, j int) bool {
|
||||
return isOpenAIAccountCandidateBetter(ranked[i], ranked[j])
|
||||
})
|
||||
return ranked
|
||||
}
|
||||
|
||||
type openAISelectionRNG struct {
|
||||
state uint64
|
||||
}
|
||||
|
||||
func newOpenAISelectionRNG(seed uint64) openAISelectionRNG {
|
||||
if seed == 0 {
|
||||
seed = 0x9e3779b97f4a7c15
|
||||
}
|
||||
return openAISelectionRNG{state: seed}
|
||||
}
|
||||
|
||||
func (r *openAISelectionRNG) nextUint64() uint64 {
|
||||
// xorshift64*
|
||||
x := r.state
|
||||
x ^= x >> 12
|
||||
x ^= x << 25
|
||||
x ^= x >> 27
|
||||
r.state = x
|
||||
return x * 2685821657736338717
|
||||
}
|
||||
|
||||
func (r *openAISelectionRNG) nextFloat64() float64 {
|
||||
// [0,1)
|
||||
return float64(r.nextUint64()>>11) / (1 << 53)
|
||||
}
|
||||
|
||||
func deriveOpenAISelectionSeed(req OpenAIAccountScheduleRequest) uint64 {
|
||||
hasher := fnv.New64a()
|
||||
writeValue := func(value string) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return
|
||||
}
|
||||
_, _ = hasher.Write([]byte(trimmed))
|
||||
_, _ = hasher.Write([]byte{0})
|
||||
}
|
||||
|
||||
writeValue(req.SessionHash)
|
||||
writeValue(req.PreviousResponseID)
|
||||
writeValue(req.RequestedModel)
|
||||
if req.GroupID != nil {
|
||||
_, _ = hasher.Write([]byte(strconv.FormatInt(*req.GroupID, 10)))
|
||||
}
|
||||
|
||||
seed := hasher.Sum64()
|
||||
// 对“无会话锚点”的纯负载均衡请求引入时间熵,避免固定命中同一账号。
|
||||
if strings.TrimSpace(req.SessionHash) == "" && strings.TrimSpace(req.PreviousResponseID) == "" {
|
||||
seed ^= uint64(time.Now().UnixNano())
|
||||
}
|
||||
if seed == 0 {
|
||||
seed = uint64(time.Now().UnixNano()) ^ 0x9e3779b97f4a7c15
|
||||
}
|
||||
return seed
|
||||
}
|
||||
|
||||
func buildOpenAIWeightedSelectionOrder(
|
||||
candidates []openAIAccountCandidateScore,
|
||||
req OpenAIAccountScheduleRequest,
|
||||
) []openAIAccountCandidateScore {
|
||||
if len(candidates) <= 1 {
|
||||
return append([]openAIAccountCandidateScore(nil), candidates...)
|
||||
}
|
||||
|
||||
pool := append([]openAIAccountCandidateScore(nil), candidates...)
|
||||
weights := make([]float64, len(pool))
|
||||
minScore := pool[0].score
|
||||
for i := 1; i < len(pool); i++ {
|
||||
if pool[i].score < minScore {
|
||||
minScore = pool[i].score
|
||||
}
|
||||
}
|
||||
for i := range pool {
|
||||
// 将 top-K 分值平移到正区间,避免“单一最高分账号”长期垄断。
|
||||
weight := (pool[i].score - minScore) + 1.0
|
||||
if math.IsNaN(weight) || math.IsInf(weight, 0) || weight <= 0 {
|
||||
weight = 1.0
|
||||
}
|
||||
weights[i] = weight
|
||||
}
|
||||
|
||||
order := make([]openAIAccountCandidateScore, 0, len(pool))
|
||||
rng := newOpenAISelectionRNG(deriveOpenAISelectionSeed(req))
|
||||
for len(pool) > 0 {
|
||||
total := 0.0
|
||||
for _, w := range weights {
|
||||
total += w
|
||||
}
|
||||
|
||||
selectedIdx := 0
|
||||
if total > 0 {
|
||||
r := rng.nextFloat64() * total
|
||||
acc := 0.0
|
||||
for i, w := range weights {
|
||||
acc += w
|
||||
if r <= acc {
|
||||
selectedIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
selectedIdx = int(rng.nextUint64() % uint64(len(pool)))
|
||||
}
|
||||
|
||||
order = append(order, pool[selectedIdx])
|
||||
pool = append(pool[:selectedIdx], pool[selectedIdx+1:]...)
|
||||
weights = append(weights[:selectedIdx], weights[selectedIdx+1:]...)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
func (s *defaultOpenAIAccountScheduler) selectByLoadBalance(
|
||||
ctx context.Context,
|
||||
req OpenAIAccountScheduleRequest,
|
||||
) (*AccountSelectionResult, int, int, float64, error) {
|
||||
accounts, err := s.service.listSchedulableAccounts(ctx, req.GroupID)
|
||||
if err != nil {
|
||||
return nil, 0, 0, 0, err
|
||||
}
|
||||
if len(accounts) == 0 {
|
||||
return nil, 0, 0, 0, errors.New("no available OpenAI accounts")
|
||||
}
|
||||
|
||||
filtered := make([]*Account, 0, len(accounts))
|
||||
loadReq := make([]AccountWithConcurrency, 0, len(accounts))
|
||||
for i := range accounts {
|
||||
account := &accounts[i]
|
||||
if req.ExcludedIDs != nil {
|
||||
if _, excluded := req.ExcludedIDs[account.ID]; excluded {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !account.IsSchedulable() || !account.IsOpenAI() {
|
||||
continue
|
||||
}
|
||||
if req.RequestedModel != "" && !account.IsModelSupported(req.RequestedModel) {
|
||||
continue
|
||||
}
|
||||
if !s.isAccountTransportCompatible(account, req.RequiredTransport) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, account)
|
||||
loadReq = append(loadReq, AccountWithConcurrency{
|
||||
ID: account.ID,
|
||||
MaxConcurrency: account.Concurrency,
|
||||
})
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil, 0, 0, 0, errors.New("no available OpenAI accounts")
|
||||
}
|
||||
|
||||
loadMap := map[int64]*AccountLoadInfo{}
|
||||
if s.service.concurrencyService != nil {
|
||||
if batchLoad, loadErr := s.service.concurrencyService.GetAccountsLoadBatch(ctx, loadReq); loadErr == nil {
|
||||
loadMap = batchLoad
|
||||
}
|
||||
}
|
||||
|
||||
minPriority, maxPriority := filtered[0].Priority, filtered[0].Priority
|
||||
maxWaiting := 1
|
||||
loadRateSum := 0.0
|
||||
loadRateSumSquares := 0.0
|
||||
minTTFT, maxTTFT := 0.0, 0.0
|
||||
hasTTFTSample := false
|
||||
candidates := make([]openAIAccountCandidateScore, 0, len(filtered))
|
||||
for _, account := range filtered {
|
||||
loadInfo := loadMap[account.ID]
|
||||
if loadInfo == nil {
|
||||
loadInfo = &AccountLoadInfo{AccountID: account.ID}
|
||||
}
|
||||
if account.Priority < minPriority {
|
||||
minPriority = account.Priority
|
||||
}
|
||||
if account.Priority > maxPriority {
|
||||
maxPriority = account.Priority
|
||||
}
|
||||
if loadInfo.WaitingCount > maxWaiting {
|
||||
maxWaiting = loadInfo.WaitingCount
|
||||
}
|
||||
errorRate, ttft, hasTTFT := s.stats.snapshot(account.ID)
|
||||
if hasTTFT && ttft > 0 {
|
||||
if !hasTTFTSample {
|
||||
minTTFT, maxTTFT = ttft, ttft
|
||||
hasTTFTSample = true
|
||||
} else {
|
||||
if ttft < minTTFT {
|
||||
minTTFT = ttft
|
||||
}
|
||||
if ttft > maxTTFT {
|
||||
maxTTFT = ttft
|
||||
}
|
||||
}
|
||||
}
|
||||
loadRate := float64(loadInfo.LoadRate)
|
||||
loadRateSum += loadRate
|
||||
loadRateSumSquares += loadRate * loadRate
|
||||
candidates = append(candidates, openAIAccountCandidateScore{
|
||||
account: account,
|
||||
loadInfo: loadInfo,
|
||||
errorRate: errorRate,
|
||||
ttft: ttft,
|
||||
hasTTFT: hasTTFT,
|
||||
})
|
||||
}
|
||||
loadSkew := calcLoadSkewByMoments(loadRateSum, loadRateSumSquares, len(candidates))
|
||||
|
||||
weights := s.service.openAIWSSchedulerWeights()
|
||||
for i := range candidates {
|
||||
item := &candidates[i]
|
||||
priorityFactor := 1.0
|
||||
if maxPriority > minPriority {
|
||||
priorityFactor = 1 - float64(item.account.Priority-minPriority)/float64(maxPriority-minPriority)
|
||||
}
|
||||
loadFactor := 1 - clamp01(float64(item.loadInfo.LoadRate)/100.0)
|
||||
queueFactor := 1 - clamp01(float64(item.loadInfo.WaitingCount)/float64(maxWaiting))
|
||||
errorFactor := 1 - clamp01(item.errorRate)
|
||||
ttftFactor := 0.5
|
||||
if item.hasTTFT && hasTTFTSample && maxTTFT > minTTFT {
|
||||
ttftFactor = 1 - clamp01((item.ttft-minTTFT)/(maxTTFT-minTTFT))
|
||||
}
|
||||
|
||||
item.score = weights.Priority*priorityFactor +
|
||||
weights.Load*loadFactor +
|
||||
weights.Queue*queueFactor +
|
||||
weights.ErrorRate*errorFactor +
|
||||
weights.TTFT*ttftFactor
|
||||
}
|
||||
|
||||
topK := s.service.openAIWSLBTopK()
|
||||
if topK > len(candidates) {
|
||||
topK = len(candidates)
|
||||
}
|
||||
if topK <= 0 {
|
||||
topK = 1
|
||||
}
|
||||
rankedCandidates := selectTopKOpenAICandidates(candidates, topK)
|
||||
selectionOrder := buildOpenAIWeightedSelectionOrder(rankedCandidates, req)
|
||||
|
||||
for i := 0; i < len(selectionOrder); i++ {
|
||||
candidate := selectionOrder[i]
|
||||
result, acquireErr := s.service.tryAcquireAccountSlot(ctx, candidate.account.ID, candidate.account.Concurrency)
|
||||
if acquireErr != nil {
|
||||
return nil, len(candidates), topK, loadSkew, acquireErr
|
||||
}
|
||||
if result != nil && result.Acquired {
|
||||
if req.SessionHash != "" {
|
||||
_ = s.service.BindStickySession(ctx, req.GroupID, req.SessionHash, candidate.account.ID)
|
||||
}
|
||||
return &AccountSelectionResult{
|
||||
Account: candidate.account,
|
||||
Acquired: true,
|
||||
ReleaseFunc: result.ReleaseFunc,
|
||||
}, len(candidates), topK, loadSkew, nil
|
||||
}
|
||||
}
|
||||
|
||||
cfg := s.service.schedulingConfig()
|
||||
candidate := selectionOrder[0]
|
||||
return &AccountSelectionResult{
|
||||
Account: candidate.account,
|
||||
WaitPlan: &AccountWaitPlan{
|
||||
AccountID: candidate.account.ID,
|
||||
MaxConcurrency: candidate.account.Concurrency,
|
||||
Timeout: cfg.FallbackWaitTimeout,
|
||||
MaxWaiting: cfg.FallbackMaxWaiting,
|
||||
},
|
||||
}, len(candidates), topK, loadSkew, nil
|
||||
}
|
||||
|
||||
func (s *defaultOpenAIAccountScheduler) isAccountTransportCompatible(account *Account, requiredTransport OpenAIUpstreamTransport) bool {
|
||||
// HTTP 入站可回退到 HTTP 线路,不需要在账号选择阶段做传输协议强过滤。
|
||||
if requiredTransport == OpenAIUpstreamTransportAny || requiredTransport == OpenAIUpstreamTransportHTTPSSE {
|
||||
return true
|
||||
}
|
||||
if s == nil || s.service == nil || account == nil {
|
||||
return false
|
||||
}
|
||||
return s.service.getOpenAIWSProtocolResolver().Resolve(account).Transport == requiredTransport
|
||||
}
|
||||
|
||||
func (s *defaultOpenAIAccountScheduler) ReportResult(accountID int64, success bool, firstTokenMs *int) {
|
||||
if s == nil || s.stats == nil {
|
||||
return
|
||||
}
|
||||
s.stats.report(accountID, success, firstTokenMs)
|
||||
}
|
||||
|
||||
func (s *defaultOpenAIAccountScheduler) ReportSwitch() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.metrics.recordSwitch()
|
||||
}
|
||||
|
||||
func (s *defaultOpenAIAccountScheduler) SnapshotMetrics() OpenAIAccountSchedulerMetricsSnapshot {
|
||||
if s == nil {
|
||||
return OpenAIAccountSchedulerMetricsSnapshot{}
|
||||
}
|
||||
|
||||
selectTotal := s.metrics.selectTotal.Load()
|
||||
prevHit := s.metrics.stickyPreviousHitTotal.Load()
|
||||
sessionHit := s.metrics.stickySessionHitTotal.Load()
|
||||
switchTotal := s.metrics.accountSwitchTotal.Load()
|
||||
latencyTotal := s.metrics.latencyMsTotal.Load()
|
||||
loadSkewTotal := s.metrics.loadSkewMilliTotal.Load()
|
||||
|
||||
snapshot := OpenAIAccountSchedulerMetricsSnapshot{
|
||||
SelectTotal: selectTotal,
|
||||
StickyPreviousHitTotal: prevHit,
|
||||
StickySessionHitTotal: sessionHit,
|
||||
LoadBalanceSelectTotal: s.metrics.loadBalanceSelectTotal.Load(),
|
||||
AccountSwitchTotal: switchTotal,
|
||||
SchedulerLatencyMsTotal: latencyTotal,
|
||||
RuntimeStatsAccountCount: s.stats.size(),
|
||||
}
|
||||
if selectTotal > 0 {
|
||||
snapshot.SchedulerLatencyMsAvg = float64(latencyTotal) / float64(selectTotal)
|
||||
snapshot.StickyHitRatio = float64(prevHit+sessionHit) / float64(selectTotal)
|
||||
snapshot.AccountSwitchRate = float64(switchTotal) / float64(selectTotal)
|
||||
snapshot.LoadSkewAvg = float64(loadSkewTotal) / 1000 / float64(selectTotal)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) getOpenAIAccountScheduler() OpenAIAccountScheduler {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.openaiSchedulerOnce.Do(func() {
|
||||
if s.openaiAccountStats == nil {
|
||||
s.openaiAccountStats = newOpenAIAccountRuntimeStats()
|
||||
}
|
||||
if s.openaiScheduler == nil {
|
||||
s.openaiScheduler = newDefaultOpenAIAccountScheduler(s, s.openaiAccountStats)
|
||||
}
|
||||
})
|
||||
return s.openaiScheduler
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) SelectAccountWithScheduler(
|
||||
ctx context.Context,
|
||||
groupID *int64,
|
||||
previousResponseID string,
|
||||
sessionHash string,
|
||||
requestedModel string,
|
||||
excludedIDs map[int64]struct{},
|
||||
requiredTransport OpenAIUpstreamTransport,
|
||||
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
|
||||
decision := OpenAIAccountScheduleDecision{}
|
||||
scheduler := s.getOpenAIAccountScheduler()
|
||||
if scheduler == nil {
|
||||
selection, err := s.SelectAccountWithLoadAwareness(ctx, groupID, sessionHash, requestedModel, excludedIDs)
|
||||
decision.Layer = openAIAccountScheduleLayerLoadBalance
|
||||
return selection, decision, err
|
||||
}
|
||||
|
||||
var stickyAccountID int64
|
||||
if sessionHash != "" && s.cache != nil {
|
||||
if accountID, err := s.getStickySessionAccountID(ctx, groupID, sessionHash); err == nil && accountID > 0 {
|
||||
stickyAccountID = accountID
|
||||
}
|
||||
}
|
||||
|
||||
return scheduler.Select(ctx, OpenAIAccountScheduleRequest{
|
||||
GroupID: groupID,
|
||||
SessionHash: sessionHash,
|
||||
StickyAccountID: stickyAccountID,
|
||||
PreviousResponseID: previousResponseID,
|
||||
RequestedModel: requestedModel,
|
||||
RequiredTransport: requiredTransport,
|
||||
ExcludedIDs: excludedIDs,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) ReportOpenAIAccountScheduleResult(accountID int64, success bool, firstTokenMs *int) {
|
||||
scheduler := s.getOpenAIAccountScheduler()
|
||||
if scheduler == nil {
|
||||
return
|
||||
}
|
||||
scheduler.ReportResult(accountID, success, firstTokenMs)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) RecordOpenAIAccountSwitch() {
|
||||
scheduler := s.getOpenAIAccountScheduler()
|
||||
if scheduler == nil {
|
||||
return
|
||||
}
|
||||
scheduler.ReportSwitch()
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) SnapshotOpenAIAccountSchedulerMetrics() OpenAIAccountSchedulerMetricsSnapshot {
|
||||
scheduler := s.getOpenAIAccountScheduler()
|
||||
if scheduler == nil {
|
||||
return OpenAIAccountSchedulerMetricsSnapshot{}
|
||||
}
|
||||
return scheduler.SnapshotMetrics()
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) openAIWSSessionStickyTTL() time.Duration {
|
||||
if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.StickySessionTTLSeconds > 0 {
|
||||
return time.Duration(s.cfg.Gateway.OpenAIWS.StickySessionTTLSeconds) * time.Second
|
||||
}
|
||||
return openaiStickySessionTTL
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) openAIWSLBTopK() int {
|
||||
if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.LBTopK > 0 {
|
||||
return s.cfg.Gateway.OpenAIWS.LBTopK
|
||||
}
|
||||
return 7
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) openAIWSSchedulerWeights() GatewayOpenAIWSSchedulerScoreWeightsView {
|
||||
if s != nil && s.cfg != nil {
|
||||
return GatewayOpenAIWSSchedulerScoreWeightsView{
|
||||
Priority: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority,
|
||||
Load: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load,
|
||||
Queue: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue,
|
||||
ErrorRate: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate,
|
||||
TTFT: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT,
|
||||
}
|
||||
}
|
||||
return GatewayOpenAIWSSchedulerScoreWeightsView{
|
||||
Priority: 1.0,
|
||||
Load: 1.0,
|
||||
Queue: 0.7,
|
||||
ErrorRate: 0.8,
|
||||
TTFT: 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
type GatewayOpenAIWSSchedulerScoreWeightsView struct {
|
||||
Priority float64
|
||||
Load float64
|
||||
Queue float64
|
||||
ErrorRate float64
|
||||
TTFT float64
|
||||
}
|
||||
|
||||
func clamp01(value float64) float64 {
|
||||
switch {
|
||||
case value < 0:
|
||||
return 0
|
||||
case value > 1:
|
||||
return 1
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func calcLoadSkewByMoments(sum float64, sumSquares float64, count int) float64 {
|
||||
if count <= 1 {
|
||||
return 0
|
||||
}
|
||||
mean := sum / float64(count)
|
||||
variance := sumSquares/float64(count) - mean*mean
|
||||
if variance < 0 {
|
||||
variance = 0
|
||||
}
|
||||
return math.Sqrt(variance)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func buildOpenAISchedulerBenchmarkCandidates(size int) []openAIAccountCandidateScore {
|
||||
if size <= 0 {
|
||||
return nil
|
||||
}
|
||||
candidates := make([]openAIAccountCandidateScore, 0, size)
|
||||
for i := 0; i < size; i++ {
|
||||
accountID := int64(10_000 + i)
|
||||
candidates = append(candidates, openAIAccountCandidateScore{
|
||||
account: &Account{
|
||||
ID: accountID,
|
||||
Priority: i % 7,
|
||||
},
|
||||
loadInfo: &AccountLoadInfo{
|
||||
AccountID: accountID,
|
||||
LoadRate: (i * 17) % 100,
|
||||
WaitingCount: (i * 11) % 13,
|
||||
},
|
||||
score: float64((i*29)%1000) / 100,
|
||||
errorRate: float64((i * 5) % 100 / 100),
|
||||
ttft: float64(30 + (i*3)%500),
|
||||
hasTTFT: i%3 != 0,
|
||||
})
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func selectTopKOpenAICandidatesBySortBenchmark(candidates []openAIAccountCandidateScore, topK int) []openAIAccountCandidateScore {
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
if topK <= 0 {
|
||||
topK = 1
|
||||
}
|
||||
ranked := append([]openAIAccountCandidateScore(nil), candidates...)
|
||||
sort.Slice(ranked, func(i, j int) bool {
|
||||
return isOpenAIAccountCandidateBetter(ranked[i], ranked[j])
|
||||
})
|
||||
if topK > len(ranked) {
|
||||
topK = len(ranked)
|
||||
}
|
||||
return ranked[:topK]
|
||||
}
|
||||
|
||||
func BenchmarkOpenAIAccountSchedulerSelectTopK(b *testing.B) {
|
||||
cases := []struct {
|
||||
name string
|
||||
size int
|
||||
topK int
|
||||
}{
|
||||
{name: "n_16_k_3", size: 16, topK: 3},
|
||||
{name: "n_64_k_3", size: 64, topK: 3},
|
||||
{name: "n_256_k_5", size: 256, topK: 5},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
candidates := buildOpenAISchedulerBenchmarkCandidates(tc.size)
|
||||
b.Run(tc.name+"/heap_topk", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
result := selectTopKOpenAICandidates(candidates, tc.topK)
|
||||
if len(result) == 0 {
|
||||
b.Fatal("unexpected empty result")
|
||||
}
|
||||
}
|
||||
})
|
||||
b.Run(tc.name+"/full_sort", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
result := selectTopKOpenAICandidatesBySortBenchmark(candidates, tc.topK)
|
||||
if len(result) == 0 {
|
||||
b.Fatal("unexpected empty result")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,841 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountWithScheduler_PreviousResponseSticky(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
groupID := int64(9)
|
||||
account := Account{
|
||||
ID: 1001,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 2,
|
||||
Extra: map[string]any{
|
||||
"openai_apikey_responses_websockets_v2_enabled": true,
|
||||
},
|
||||
}
|
||||
cache := &stubGatewayCache{}
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.OpenAIWS.Enabled = true
|
||||
cfg.Gateway.OpenAIWS.OAuthEnabled = true
|
||||
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
|
||||
cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
|
||||
cfg.Gateway.OpenAIWS.StickySessionTTLSeconds = 1800
|
||||
cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds = 3600
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
|
||||
cache: cache,
|
||||
cfg: cfg,
|
||||
concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
|
||||
}
|
||||
|
||||
store := svc.getOpenAIWSStateStore()
|
||||
require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_001", account.ID, time.Hour))
|
||||
|
||||
selection, decision, err := svc.SelectAccountWithScheduler(
|
||||
ctx,
|
||||
&groupID,
|
||||
"resp_prev_001",
|
||||
"session_hash_001",
|
||||
"gpt-5.1",
|
||||
nil,
|
||||
OpenAIUpstreamTransportAny,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, selection)
|
||||
require.NotNil(t, selection.Account)
|
||||
require.Equal(t, account.ID, selection.Account.ID)
|
||||
require.Equal(t, openAIAccountScheduleLayerPreviousResponse, decision.Layer)
|
||||
require.True(t, decision.StickyPreviousHit)
|
||||
require.Equal(t, account.ID, cache.sessionBindings["openai:session_hash_001"])
|
||||
if selection.ReleaseFunc != nil {
|
||||
selection.ReleaseFunc()
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionSticky(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
groupID := int64(10)
|
||||
account := Account{
|
||||
ID: 2001,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
}
|
||||
cache := &stubGatewayCache{
|
||||
sessionBindings: map[string]int64{
|
||||
"openai:session_hash_abc": account.ID,
|
||||
},
|
||||
}
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
|
||||
cache: cache,
|
||||
cfg: &config.Config{},
|
||||
concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
|
||||
}
|
||||
|
||||
selection, decision, err := svc.SelectAccountWithScheduler(
|
||||
ctx,
|
||||
&groupID,
|
||||
"",
|
||||
"session_hash_abc",
|
||||
"gpt-5.1",
|
||||
nil,
|
||||
OpenAIUpstreamTransportAny,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, selection)
|
||||
require.NotNil(t, selection.Account)
|
||||
require.Equal(t, account.ID, selection.Account.ID)
|
||||
require.Equal(t, openAIAccountScheduleLayerSessionSticky, decision.Layer)
|
||||
require.True(t, decision.StickySessionHit)
|
||||
if selection.ReleaseFunc != nil {
|
||||
selection.ReleaseFunc()
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyBusyKeepsSticky(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
groupID := int64(10100)
|
||||
accounts := []Account{
|
||||
{
|
||||
ID: 21001,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Priority: 0,
|
||||
},
|
||||
{
|
||||
ID: 21002,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Priority: 9,
|
||||
},
|
||||
}
|
||||
cache := &stubGatewayCache{
|
||||
sessionBindings: map[string]int64{
|
||||
"openai:session_hash_sticky_busy": 21001,
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.Scheduling.StickySessionMaxWaiting = 2
|
||||
cfg.Gateway.Scheduling.StickySessionWaitTimeout = 45 * time.Second
|
||||
cfg.Gateway.OpenAIWS.Enabled = true
|
||||
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
|
||||
cfg.Gateway.OpenAIWS.OAuthEnabled = true
|
||||
cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
|
||||
|
||||
concurrencyCache := stubConcurrencyCache{
|
||||
acquireResults: map[int64]bool{
|
||||
21001: false, // sticky 账号已满
|
||||
21002: true, // 若回退负载均衡会命中该账号(本测试要求不能切换)
|
||||
},
|
||||
waitCounts: map[int64]int{
|
||||
21001: 999,
|
||||
},
|
||||
loadMap: map[int64]*AccountLoadInfo{
|
||||
21001: {AccountID: 21001, LoadRate: 90, WaitingCount: 9},
|
||||
21002: {AccountID: 21002, LoadRate: 1, WaitingCount: 0},
|
||||
},
|
||||
}
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: accounts},
|
||||
cache: cache,
|
||||
cfg: cfg,
|
||||
concurrencyService: NewConcurrencyService(concurrencyCache),
|
||||
}
|
||||
|
||||
selection, decision, err := svc.SelectAccountWithScheduler(
|
||||
ctx,
|
||||
&groupID,
|
||||
"",
|
||||
"session_hash_sticky_busy",
|
||||
"gpt-5.1",
|
||||
nil,
|
||||
OpenAIUpstreamTransportAny,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, selection)
|
||||
require.NotNil(t, selection.Account)
|
||||
require.Equal(t, int64(21001), selection.Account.ID, "busy sticky account should remain selected")
|
||||
require.False(t, selection.Acquired)
|
||||
require.NotNil(t, selection.WaitPlan)
|
||||
require.Equal(t, int64(21001), selection.WaitPlan.AccountID)
|
||||
require.Equal(t, openAIAccountScheduleLayerSessionSticky, decision.Layer)
|
||||
require.True(t, decision.StickySessionHit)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionSticky_ForceHTTP(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
groupID := int64(1010)
|
||||
account := Account{
|
||||
ID: 2101,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Extra: map[string]any{
|
||||
"openai_ws_force_http": true,
|
||||
},
|
||||
}
|
||||
cache := &stubGatewayCache{
|
||||
sessionBindings: map[string]int64{
|
||||
"openai:session_hash_force_http": account.ID,
|
||||
},
|
||||
}
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
|
||||
cache: cache,
|
||||
cfg: &config.Config{},
|
||||
concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
|
||||
}
|
||||
|
||||
selection, decision, err := svc.SelectAccountWithScheduler(
|
||||
ctx,
|
||||
&groupID,
|
||||
"",
|
||||
"session_hash_force_http",
|
||||
"gpt-5.1",
|
||||
nil,
|
||||
OpenAIUpstreamTransportAny,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, selection)
|
||||
require.NotNil(t, selection.Account)
|
||||
require.Equal(t, account.ID, selection.Account.ID)
|
||||
require.Equal(t, openAIAccountScheduleLayerSessionSticky, decision.Layer)
|
||||
require.True(t, decision.StickySessionHit)
|
||||
if selection.ReleaseFunc != nil {
|
||||
selection.ReleaseFunc()
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountWithScheduler_RequiredWSV2_SkipsStickyHTTPAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
groupID := int64(1011)
|
||||
accounts := []Account{
|
||||
{
|
||||
ID: 2201,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Priority: 0,
|
||||
},
|
||||
{
|
||||
ID: 2202,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Priority: 5,
|
||||
Extra: map[string]any{
|
||||
"openai_apikey_responses_websockets_v2_enabled": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
cache := &stubGatewayCache{
|
||||
sessionBindings: map[string]int64{
|
||||
"openai:session_hash_ws_only": 2201,
|
||||
},
|
||||
}
|
||||
cfg := newOpenAIWSV2TestConfig()
|
||||
|
||||
// 构造“HTTP-only 账号负载更低”的场景,验证 required transport 会强制过滤。
|
||||
concurrencyCache := stubConcurrencyCache{
|
||||
loadMap: map[int64]*AccountLoadInfo{
|
||||
2201: {AccountID: 2201, LoadRate: 0, WaitingCount: 0},
|
||||
2202: {AccountID: 2202, LoadRate: 90, WaitingCount: 5},
|
||||
},
|
||||
}
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: accounts},
|
||||
cache: cache,
|
||||
cfg: cfg,
|
||||
concurrencyService: NewConcurrencyService(concurrencyCache),
|
||||
}
|
||||
|
||||
selection, decision, err := svc.SelectAccountWithScheduler(
|
||||
ctx,
|
||||
&groupID,
|
||||
"",
|
||||
"session_hash_ws_only",
|
||||
"gpt-5.1",
|
||||
nil,
|
||||
OpenAIUpstreamTransportResponsesWebsocketV2,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, selection)
|
||||
require.NotNil(t, selection.Account)
|
||||
require.Equal(t, int64(2202), selection.Account.ID)
|
||||
require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer)
|
||||
require.False(t, decision.StickySessionHit)
|
||||
require.Equal(t, 1, decision.CandidateCount)
|
||||
if selection.ReleaseFunc != nil {
|
||||
selection.ReleaseFunc()
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountWithScheduler_RequiredWSV2_NoAvailableAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
groupID := int64(1012)
|
||||
accounts := []Account{
|
||||
{
|
||||
ID: 2301,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
},
|
||||
}
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: accounts},
|
||||
cache: &stubGatewayCache{},
|
||||
cfg: newOpenAIWSV2TestConfig(),
|
||||
concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
|
||||
}
|
||||
|
||||
selection, decision, err := svc.SelectAccountWithScheduler(
|
||||
ctx,
|
||||
&groupID,
|
||||
"",
|
||||
"",
|
||||
"gpt-5.1",
|
||||
nil,
|
||||
OpenAIUpstreamTransportResponsesWebsocketV2,
|
||||
)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, selection)
|
||||
require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer)
|
||||
require.Equal(t, 0, decision.CandidateCount)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountWithScheduler_LoadBalanceTopKFallback(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
groupID := int64(11)
|
||||
accounts := []Account{
|
||||
{
|
||||
ID: 3001,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Priority: 0,
|
||||
},
|
||||
{
|
||||
ID: 3002,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Priority: 0,
|
||||
},
|
||||
{
|
||||
ID: 3003,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Priority: 0,
|
||||
},
|
||||
}
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.OpenAIWS.LBTopK = 2
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 0.4
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 1.0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 1.0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0.2
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0.1
|
||||
|
||||
concurrencyCache := stubConcurrencyCache{
|
||||
loadMap: map[int64]*AccountLoadInfo{
|
||||
3001: {AccountID: 3001, LoadRate: 95, WaitingCount: 8},
|
||||
3002: {AccountID: 3002, LoadRate: 20, WaitingCount: 1},
|
||||
3003: {AccountID: 3003, LoadRate: 10, WaitingCount: 0},
|
||||
},
|
||||
acquireResults: map[int64]bool{
|
||||
3003: false, // top1 失败,必须回退到 top-K 的下一候选
|
||||
3002: true,
|
||||
},
|
||||
}
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: accounts},
|
||||
cache: &stubGatewayCache{},
|
||||
cfg: cfg,
|
||||
concurrencyService: NewConcurrencyService(concurrencyCache),
|
||||
}
|
||||
|
||||
selection, decision, err := svc.SelectAccountWithScheduler(
|
||||
ctx,
|
||||
&groupID,
|
||||
"",
|
||||
"",
|
||||
"gpt-5.1",
|
||||
nil,
|
||||
OpenAIUpstreamTransportAny,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, selection)
|
||||
require.NotNil(t, selection.Account)
|
||||
require.Equal(t, int64(3002), selection.Account.ID)
|
||||
require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer)
|
||||
require.Equal(t, 3, decision.CandidateCount)
|
||||
require.Equal(t, 2, decision.TopK)
|
||||
require.Greater(t, decision.LoadSkew, 0.0)
|
||||
if selection.ReleaseFunc != nil {
|
||||
selection.ReleaseFunc()
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_OpenAIAccountSchedulerMetrics(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
groupID := int64(12)
|
||||
account := Account{
|
||||
ID: 4001,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
}
|
||||
cache := &stubGatewayCache{
|
||||
sessionBindings: map[string]int64{
|
||||
"openai:session_hash_metrics": account.ID,
|
||||
},
|
||||
}
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
|
||||
cache: cache,
|
||||
cfg: &config.Config{},
|
||||
concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
|
||||
}
|
||||
|
||||
selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_hash_metrics", "gpt-5.1", nil, OpenAIUpstreamTransportAny)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, selection)
|
||||
svc.ReportOpenAIAccountScheduleResult(account.ID, true, intPtrForTest(120))
|
||||
svc.RecordOpenAIAccountSwitch()
|
||||
|
||||
snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics()
|
||||
require.GreaterOrEqual(t, snapshot.SelectTotal, int64(1))
|
||||
require.GreaterOrEqual(t, snapshot.StickySessionHitTotal, int64(1))
|
||||
require.GreaterOrEqual(t, snapshot.AccountSwitchTotal, int64(1))
|
||||
require.GreaterOrEqual(t, snapshot.SchedulerLatencyMsAvg, float64(0))
|
||||
require.GreaterOrEqual(t, snapshot.StickyHitRatio, 0.0)
|
||||
require.GreaterOrEqual(t, snapshot.RuntimeStatsAccountCount, 1)
|
||||
}
|
||||
|
||||
func intPtrForTest(v int) *int {
|
||||
return &v
|
||||
}
|
||||
|
||||
func TestOpenAIAccountRuntimeStats_ReportAndSnapshot(t *testing.T) {
|
||||
stats := newOpenAIAccountRuntimeStats()
|
||||
stats.report(1001, true, nil)
|
||||
firstTTFT := 100
|
||||
stats.report(1001, false, &firstTTFT)
|
||||
secondTTFT := 200
|
||||
stats.report(1001, false, &secondTTFT)
|
||||
|
||||
errorRate, ttft, hasTTFT := stats.snapshot(1001)
|
||||
require.True(t, hasTTFT)
|
||||
require.InDelta(t, 0.36, errorRate, 1e-9)
|
||||
require.InDelta(t, 120.0, ttft, 1e-9)
|
||||
require.Equal(t, 1, stats.size())
|
||||
}
|
||||
|
||||
func TestOpenAIAccountRuntimeStats_ReportConcurrent(t *testing.T) {
|
||||
stats := newOpenAIAccountRuntimeStats()
|
||||
|
||||
const (
|
||||
accountCount = 4
|
||||
workers = 16
|
||||
iterations = 800
|
||||
)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers)
|
||||
for worker := 0; worker < workers; worker++ {
|
||||
worker := worker
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
accountID := int64(i%accountCount + 1)
|
||||
success := (i+worker)%3 != 0
|
||||
ttft := 80 + (i+worker)%40
|
||||
stats.report(accountID, success, &ttft)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
require.Equal(t, accountCount, stats.size())
|
||||
for accountID := int64(1); accountID <= accountCount; accountID++ {
|
||||
errorRate, ttft, hasTTFT := stats.snapshot(accountID)
|
||||
require.GreaterOrEqual(t, errorRate, 0.0)
|
||||
require.LessOrEqual(t, errorRate, 1.0)
|
||||
require.True(t, hasTTFT)
|
||||
require.Greater(t, ttft, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTopKOpenAICandidates(t *testing.T) {
|
||||
candidates := []openAIAccountCandidateScore{
|
||||
{
|
||||
account: &Account{ID: 11, Priority: 2},
|
||||
loadInfo: &AccountLoadInfo{LoadRate: 10, WaitingCount: 1},
|
||||
score: 10.0,
|
||||
},
|
||||
{
|
||||
account: &Account{ID: 12, Priority: 1},
|
||||
loadInfo: &AccountLoadInfo{LoadRate: 20, WaitingCount: 1},
|
||||
score: 9.5,
|
||||
},
|
||||
{
|
||||
account: &Account{ID: 13, Priority: 1},
|
||||
loadInfo: &AccountLoadInfo{LoadRate: 30, WaitingCount: 0},
|
||||
score: 10.0,
|
||||
},
|
||||
{
|
||||
account: &Account{ID: 14, Priority: 0},
|
||||
loadInfo: &AccountLoadInfo{LoadRate: 40, WaitingCount: 0},
|
||||
score: 8.0,
|
||||
},
|
||||
}
|
||||
|
||||
top2 := selectTopKOpenAICandidates(candidates, 2)
|
||||
require.Len(t, top2, 2)
|
||||
require.Equal(t, int64(13), top2[0].account.ID)
|
||||
require.Equal(t, int64(11), top2[1].account.ID)
|
||||
|
||||
topAll := selectTopKOpenAICandidates(candidates, 8)
|
||||
require.Len(t, topAll, len(candidates))
|
||||
require.Equal(t, int64(13), topAll[0].account.ID)
|
||||
require.Equal(t, int64(11), topAll[1].account.ID)
|
||||
require.Equal(t, int64(12), topAll[2].account.ID)
|
||||
require.Equal(t, int64(14), topAll[3].account.ID)
|
||||
}
|
||||
|
||||
func TestBuildOpenAIWeightedSelectionOrder_DeterministicBySessionSeed(t *testing.T) {
|
||||
candidates := []openAIAccountCandidateScore{
|
||||
{
|
||||
account: &Account{ID: 101},
|
||||
loadInfo: &AccountLoadInfo{LoadRate: 10, WaitingCount: 0},
|
||||
score: 4.2,
|
||||
},
|
||||
{
|
||||
account: &Account{ID: 102},
|
||||
loadInfo: &AccountLoadInfo{LoadRate: 30, WaitingCount: 1},
|
||||
score: 3.5,
|
||||
},
|
||||
{
|
||||
account: &Account{ID: 103},
|
||||
loadInfo: &AccountLoadInfo{LoadRate: 50, WaitingCount: 2},
|
||||
score: 2.1,
|
||||
},
|
||||
}
|
||||
req := OpenAIAccountScheduleRequest{
|
||||
GroupID: int64PtrForTest(99),
|
||||
SessionHash: "session_seed_fixed",
|
||||
RequestedModel: "gpt-5.1",
|
||||
}
|
||||
|
||||
first := buildOpenAIWeightedSelectionOrder(candidates, req)
|
||||
second := buildOpenAIWeightedSelectionOrder(candidates, req)
|
||||
require.Len(t, first, len(candidates))
|
||||
require.Len(t, second, len(candidates))
|
||||
for i := range first {
|
||||
require.Equal(t, first[i].account.ID, second[i].account.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountWithScheduler_LoadBalanceDistributesAcrossSessions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
groupID := int64(15)
|
||||
accounts := []Account{
|
||||
{
|
||||
ID: 5101,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 3,
|
||||
Priority: 0,
|
||||
},
|
||||
{
|
||||
ID: 5102,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 3,
|
||||
Priority: 0,
|
||||
},
|
||||
{
|
||||
ID: 5103,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 3,
|
||||
Priority: 0,
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.OpenAIWS.LBTopK = 3
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 1
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 1
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 1
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 1
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 1
|
||||
|
||||
concurrencyCache := stubConcurrencyCache{
|
||||
loadMap: map[int64]*AccountLoadInfo{
|
||||
5101: {AccountID: 5101, LoadRate: 20, WaitingCount: 1},
|
||||
5102: {AccountID: 5102, LoadRate: 20, WaitingCount: 1},
|
||||
5103: {AccountID: 5103, LoadRate: 20, WaitingCount: 1},
|
||||
},
|
||||
}
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: accounts},
|
||||
cache: &stubGatewayCache{sessionBindings: map[string]int64{}},
|
||||
cfg: cfg,
|
||||
concurrencyService: NewConcurrencyService(concurrencyCache),
|
||||
}
|
||||
|
||||
selected := make(map[int64]int, len(accounts))
|
||||
for i := 0; i < 60; i++ {
|
||||
sessionHash := fmt.Sprintf("session_hash_lb_%d", i)
|
||||
selection, decision, err := svc.SelectAccountWithScheduler(
|
||||
ctx,
|
||||
&groupID,
|
||||
"",
|
||||
sessionHash,
|
||||
"gpt-5.1",
|
||||
nil,
|
||||
OpenAIUpstreamTransportAny,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, selection)
|
||||
require.NotNil(t, selection.Account)
|
||||
require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer)
|
||||
selected[selection.Account.ID]++
|
||||
if selection.ReleaseFunc != nil {
|
||||
selection.ReleaseFunc()
|
||||
}
|
||||
}
|
||||
|
||||
// 多 session 应该能打散到多个账号,避免“恒定单账号命中”。
|
||||
require.GreaterOrEqual(t, len(selected), 2)
|
||||
}
|
||||
|
||||
func TestDeriveOpenAISelectionSeed_NoAffinityAddsEntropy(t *testing.T) {
|
||||
req := OpenAIAccountScheduleRequest{
|
||||
RequestedModel: "gpt-5.1",
|
||||
}
|
||||
seed1 := deriveOpenAISelectionSeed(req)
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
seed2 := deriveOpenAISelectionSeed(req)
|
||||
require.NotZero(t, seed1)
|
||||
require.NotZero(t, seed2)
|
||||
require.NotEqual(t, seed1, seed2)
|
||||
}
|
||||
|
||||
func TestBuildOpenAIWeightedSelectionOrder_HandlesInvalidScores(t *testing.T) {
|
||||
candidates := []openAIAccountCandidateScore{
|
||||
{
|
||||
account: &Account{ID: 901},
|
||||
loadInfo: &AccountLoadInfo{LoadRate: 5, WaitingCount: 0},
|
||||
score: math.NaN(),
|
||||
},
|
||||
{
|
||||
account: &Account{ID: 902},
|
||||
loadInfo: &AccountLoadInfo{LoadRate: 5, WaitingCount: 0},
|
||||
score: math.Inf(1),
|
||||
},
|
||||
{
|
||||
account: &Account{ID: 903},
|
||||
loadInfo: &AccountLoadInfo{LoadRate: 5, WaitingCount: 0},
|
||||
score: -1,
|
||||
},
|
||||
}
|
||||
req := OpenAIAccountScheduleRequest{
|
||||
SessionHash: "seed_invalid_scores",
|
||||
}
|
||||
|
||||
order := buildOpenAIWeightedSelectionOrder(candidates, req)
|
||||
require.Len(t, order, len(candidates))
|
||||
seen := map[int64]struct{}{}
|
||||
for _, item := range order {
|
||||
seen[item.account.ID] = struct{}{}
|
||||
}
|
||||
require.Len(t, seen, len(candidates))
|
||||
}
|
||||
|
||||
func TestOpenAISelectionRNG_SeedZeroStillWorks(t *testing.T) {
|
||||
rng := newOpenAISelectionRNG(0)
|
||||
v1 := rng.nextUint64()
|
||||
v2 := rng.nextUint64()
|
||||
require.NotEqual(t, v1, v2)
|
||||
require.GreaterOrEqual(t, rng.nextFloat64(), 0.0)
|
||||
require.Less(t, rng.nextFloat64(), 1.0)
|
||||
}
|
||||
|
||||
func TestOpenAIAccountCandidateHeap_PushPopAndInvalidType(t *testing.T) {
|
||||
h := openAIAccountCandidateHeap{}
|
||||
h.Push(openAIAccountCandidateScore{
|
||||
account: &Account{ID: 7001},
|
||||
loadInfo: &AccountLoadInfo{LoadRate: 0, WaitingCount: 0},
|
||||
score: 1.0,
|
||||
})
|
||||
require.Equal(t, 1, h.Len())
|
||||
popped, ok := h.Pop().(openAIAccountCandidateScore)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, int64(7001), popped.account.ID)
|
||||
require.Equal(t, 0, h.Len())
|
||||
|
||||
require.Panics(t, func() {
|
||||
h.Push("bad_element_type")
|
||||
})
|
||||
}
|
||||
|
||||
func TestClamp01_AllBranches(t *testing.T) {
|
||||
require.Equal(t, 0.0, clamp01(-0.2))
|
||||
require.Equal(t, 1.0, clamp01(1.3))
|
||||
require.Equal(t, 0.5, clamp01(0.5))
|
||||
}
|
||||
|
||||
func TestCalcLoadSkewByMoments_Branches(t *testing.T) {
|
||||
require.Equal(t, 0.0, calcLoadSkewByMoments(1, 1, 1))
|
||||
// variance < 0 分支:sumSquares/count - mean^2 为负值时应钳制为 0。
|
||||
require.Equal(t, 0.0, calcLoadSkewByMoments(1, 0, 2))
|
||||
require.GreaterOrEqual(t, calcLoadSkewByMoments(6, 20, 3), 0.0)
|
||||
}
|
||||
|
||||
func TestDefaultOpenAIAccountScheduler_ReportSwitchAndSnapshot(t *testing.T) {
|
||||
schedulerAny := newDefaultOpenAIAccountScheduler(&OpenAIGatewayService{}, nil)
|
||||
scheduler, ok := schedulerAny.(*defaultOpenAIAccountScheduler)
|
||||
require.True(t, ok)
|
||||
|
||||
ttft := 100
|
||||
scheduler.ReportResult(1001, true, &ttft)
|
||||
scheduler.ReportSwitch()
|
||||
scheduler.metrics.recordSelect(OpenAIAccountScheduleDecision{
|
||||
Layer: openAIAccountScheduleLayerLoadBalance,
|
||||
LatencyMs: 8,
|
||||
LoadSkew: 0.5,
|
||||
StickyPreviousHit: true,
|
||||
})
|
||||
scheduler.metrics.recordSelect(OpenAIAccountScheduleDecision{
|
||||
Layer: openAIAccountScheduleLayerSessionSticky,
|
||||
LatencyMs: 6,
|
||||
LoadSkew: 0.2,
|
||||
StickySessionHit: true,
|
||||
})
|
||||
|
||||
snapshot := scheduler.SnapshotMetrics()
|
||||
require.Equal(t, int64(2), snapshot.SelectTotal)
|
||||
require.Equal(t, int64(1), snapshot.StickyPreviousHitTotal)
|
||||
require.Equal(t, int64(1), snapshot.StickySessionHitTotal)
|
||||
require.Equal(t, int64(1), snapshot.LoadBalanceSelectTotal)
|
||||
require.Equal(t, int64(1), snapshot.AccountSwitchTotal)
|
||||
require.Greater(t, snapshot.SchedulerLatencyMsAvg, 0.0)
|
||||
require.Greater(t, snapshot.StickyHitRatio, 0.0)
|
||||
require.Greater(t, snapshot.LoadSkewAvg, 0.0)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SchedulerWrappersAndDefaults(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
ttft := 120
|
||||
svc.ReportOpenAIAccountScheduleResult(10, true, &ttft)
|
||||
svc.RecordOpenAIAccountSwitch()
|
||||
snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics()
|
||||
require.GreaterOrEqual(t, snapshot.AccountSwitchTotal, int64(1))
|
||||
require.Equal(t, 7, svc.openAIWSLBTopK())
|
||||
require.Equal(t, openaiStickySessionTTL, svc.openAIWSSessionStickyTTL())
|
||||
|
||||
defaultWeights := svc.openAIWSSchedulerWeights()
|
||||
require.Equal(t, 1.0, defaultWeights.Priority)
|
||||
require.Equal(t, 1.0, defaultWeights.Load)
|
||||
require.Equal(t, 0.7, defaultWeights.Queue)
|
||||
require.Equal(t, 0.8, defaultWeights.ErrorRate)
|
||||
require.Equal(t, 0.5, defaultWeights.TTFT)
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.OpenAIWS.LBTopK = 9
|
||||
cfg.Gateway.OpenAIWS.StickySessionTTLSeconds = 180
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 0.2
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 0.3
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0.4
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0.5
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0.6
|
||||
svcWithCfg := &OpenAIGatewayService{cfg: cfg}
|
||||
|
||||
require.Equal(t, 9, svcWithCfg.openAIWSLBTopK())
|
||||
require.Equal(t, 180*time.Second, svcWithCfg.openAIWSSessionStickyTTL())
|
||||
customWeights := svcWithCfg.openAIWSSchedulerWeights()
|
||||
require.Equal(t, 0.2, customWeights.Priority)
|
||||
require.Equal(t, 0.3, customWeights.Load)
|
||||
require.Equal(t, 0.4, customWeights.Queue)
|
||||
require.Equal(t, 0.5, customWeights.ErrorRate)
|
||||
require.Equal(t, 0.6, customWeights.TTFT)
|
||||
}
|
||||
|
||||
func TestDefaultOpenAIAccountScheduler_IsAccountTransportCompatible_Branches(t *testing.T) {
|
||||
scheduler := &defaultOpenAIAccountScheduler{}
|
||||
require.True(t, scheduler.isAccountTransportCompatible(nil, OpenAIUpstreamTransportAny))
|
||||
require.True(t, scheduler.isAccountTransportCompatible(nil, OpenAIUpstreamTransportHTTPSSE))
|
||||
require.False(t, scheduler.isAccountTransportCompatible(nil, OpenAIUpstreamTransportResponsesWebsocketV2))
|
||||
|
||||
cfg := newOpenAIWSV2TestConfig()
|
||||
scheduler.service = &OpenAIGatewayService{cfg: cfg}
|
||||
account := &Account{
|
||||
ID: 8801,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Extra: map[string]any{
|
||||
"openai_apikey_responses_websockets_v2_enabled": true,
|
||||
},
|
||||
}
|
||||
require.True(t, scheduler.isAccountTransportCompatible(account, OpenAIUpstreamTransportResponsesWebsocketV2))
|
||||
}
|
||||
|
||||
func int64PtrForTest(v int64) *int64 {
|
||||
return &v
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
// CodexClientRestrictionReasonDisabled 表示账号未开启 codex_cli_only。
|
||||
CodexClientRestrictionReasonDisabled = "codex_cli_only_disabled"
|
||||
// CodexClientRestrictionReasonMatchedUA 表示请求命中官方客户端 UA 白名单。
|
||||
CodexClientRestrictionReasonMatchedUA = "official_client_user_agent_matched"
|
||||
// CodexClientRestrictionReasonMatchedOriginator 表示请求命中官方客户端 originator 白名单。
|
||||
CodexClientRestrictionReasonMatchedOriginator = "official_client_originator_matched"
|
||||
// CodexClientRestrictionReasonNotMatchedUA 表示请求未命中官方客户端 UA 白名单。
|
||||
CodexClientRestrictionReasonNotMatchedUA = "official_client_user_agent_not_matched"
|
||||
// CodexClientRestrictionReasonForceCodexCLI 表示通过 ForceCodexCLI 配置兜底放行。
|
||||
CodexClientRestrictionReasonForceCodexCLI = "force_codex_cli_enabled"
|
||||
)
|
||||
|
||||
// CodexClientRestrictionDetectionResult 是 codex_cli_only 统一检测入口结果。
|
||||
type CodexClientRestrictionDetectionResult struct {
|
||||
Enabled bool
|
||||
Matched bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
// CodexClientRestrictionDetector 定义 codex_cli_only 统一检测入口。
|
||||
type CodexClientRestrictionDetector interface {
|
||||
Detect(c *gin.Context, account *Account) CodexClientRestrictionDetectionResult
|
||||
}
|
||||
|
||||
// OpenAICodexClientRestrictionDetector 为 OpenAI OAuth codex_cli_only 的默认实现。
|
||||
type OpenAICodexClientRestrictionDetector struct {
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewOpenAICodexClientRestrictionDetector(cfg *config.Config) *OpenAICodexClientRestrictionDetector {
|
||||
return &OpenAICodexClientRestrictionDetector{cfg: cfg}
|
||||
}
|
||||
|
||||
func (d *OpenAICodexClientRestrictionDetector) Detect(c *gin.Context, account *Account) CodexClientRestrictionDetectionResult {
|
||||
if account == nil || !account.IsCodexCLIOnlyEnabled() {
|
||||
return CodexClientRestrictionDetectionResult{
|
||||
Enabled: false,
|
||||
Matched: false,
|
||||
Reason: CodexClientRestrictionReasonDisabled,
|
||||
}
|
||||
}
|
||||
|
||||
if d != nil && d.cfg != nil && d.cfg.Gateway.ForceCodexCLI {
|
||||
return CodexClientRestrictionDetectionResult{
|
||||
Enabled: true,
|
||||
Matched: true,
|
||||
Reason: CodexClientRestrictionReasonForceCodexCLI,
|
||||
}
|
||||
}
|
||||
|
||||
userAgent := ""
|
||||
originator := ""
|
||||
if c != nil {
|
||||
userAgent = c.GetHeader("User-Agent")
|
||||
originator = c.GetHeader("originator")
|
||||
}
|
||||
if openai.IsCodexOfficialClientRequest(userAgent) {
|
||||
return CodexClientRestrictionDetectionResult{
|
||||
Enabled: true,
|
||||
Matched: true,
|
||||
Reason: CodexClientRestrictionReasonMatchedUA,
|
||||
}
|
||||
}
|
||||
if openai.IsCodexOfficialClientOriginator(originator) {
|
||||
return CodexClientRestrictionDetectionResult{
|
||||
Enabled: true,
|
||||
Matched: true,
|
||||
Reason: CodexClientRestrictionReasonMatchedOriginator,
|
||||
}
|
||||
}
|
||||
|
||||
return CodexClientRestrictionDetectionResult{
|
||||
Enabled: true,
|
||||
Matched: false,
|
||||
Reason: CodexClientRestrictionReasonNotMatchedUA,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newCodexDetectorTestContext(ua string, originator string) *gin.Context {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
if ua != "" {
|
||||
c.Request.Header.Set("User-Agent", ua)
|
||||
}
|
||||
if originator != "" {
|
||||
c.Request.Header.Set("originator", originator)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestOpenAICodexClientRestrictionDetector_Detect(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("未开启开关时绕过", func(t *testing.T) {
|
||||
detector := NewOpenAICodexClientRestrictionDetector(nil)
|
||||
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{}}
|
||||
|
||||
result := detector.Detect(newCodexDetectorTestContext("curl/8.0", ""), account)
|
||||
require.False(t, result.Enabled)
|
||||
require.False(t, result.Matched)
|
||||
require.Equal(t, CodexClientRestrictionReasonDisabled, result.Reason)
|
||||
})
|
||||
|
||||
t.Run("开启后 codex_cli_rs 命中", func(t *testing.T) {
|
||||
detector := NewOpenAICodexClientRestrictionDetector(nil)
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"codex_cli_only": true},
|
||||
}
|
||||
|
||||
result := detector.Detect(newCodexDetectorTestContext("codex_cli_rs/0.99.0", ""), account)
|
||||
require.True(t, result.Enabled)
|
||||
require.True(t, result.Matched)
|
||||
require.Equal(t, CodexClientRestrictionReasonMatchedUA, result.Reason)
|
||||
})
|
||||
|
||||
t.Run("开启后 codex_vscode 命中", func(t *testing.T) {
|
||||
detector := NewOpenAICodexClientRestrictionDetector(nil)
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"codex_cli_only": true},
|
||||
}
|
||||
|
||||
result := detector.Detect(newCodexDetectorTestContext("codex_vscode/1.0.0", ""), account)
|
||||
require.True(t, result.Enabled)
|
||||
require.True(t, result.Matched)
|
||||
require.Equal(t, CodexClientRestrictionReasonMatchedUA, result.Reason)
|
||||
})
|
||||
|
||||
t.Run("开启后 codex_app 命中", func(t *testing.T) {
|
||||
detector := NewOpenAICodexClientRestrictionDetector(nil)
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"codex_cli_only": true},
|
||||
}
|
||||
|
||||
result := detector.Detect(newCodexDetectorTestContext("codex_app/2.1.0", ""), account)
|
||||
require.True(t, result.Enabled)
|
||||
require.True(t, result.Matched)
|
||||
require.Equal(t, CodexClientRestrictionReasonMatchedUA, result.Reason)
|
||||
})
|
||||
|
||||
t.Run("开启后 originator 命中", func(t *testing.T) {
|
||||
detector := NewOpenAICodexClientRestrictionDetector(nil)
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"codex_cli_only": true},
|
||||
}
|
||||
|
||||
result := detector.Detect(newCodexDetectorTestContext("curl/8.0", "codex_chatgpt_desktop"), account)
|
||||
require.True(t, result.Enabled)
|
||||
require.True(t, result.Matched)
|
||||
require.Equal(t, CodexClientRestrictionReasonMatchedOriginator, result.Reason)
|
||||
})
|
||||
|
||||
t.Run("开启后非官方客户端拒绝", func(t *testing.T) {
|
||||
detector := NewOpenAICodexClientRestrictionDetector(nil)
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"codex_cli_only": true},
|
||||
}
|
||||
|
||||
result := detector.Detect(newCodexDetectorTestContext("curl/8.0", "my_client"), account)
|
||||
require.True(t, result.Enabled)
|
||||
require.False(t, result.Matched)
|
||||
require.Equal(t, CodexClientRestrictionReasonNotMatchedUA, result.Reason)
|
||||
})
|
||||
|
||||
t.Run("开启 ForceCodexCLI 时允许通过", func(t *testing.T) {
|
||||
detector := NewOpenAICodexClientRestrictionDetector(&config.Config{
|
||||
Gateway: config.GatewayConfig{ForceCodexCLI: true},
|
||||
})
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"codex_cli_only": true},
|
||||
}
|
||||
|
||||
result := detector.Detect(newCodexDetectorTestContext("curl/8.0", "my_client"), account)
|
||||
require.True(t, result.Enabled)
|
||||
require.True(t, result.Matched)
|
||||
require.Equal(t, CodexClientRestrictionReasonForceCodexCLI, result.Reason)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// OpenAIClientTransport 表示客户端入站协议类型。
|
||||
type OpenAIClientTransport string
|
||||
|
||||
const (
|
||||
OpenAIClientTransportUnknown OpenAIClientTransport = ""
|
||||
OpenAIClientTransportHTTP OpenAIClientTransport = "http"
|
||||
OpenAIClientTransportWS OpenAIClientTransport = "ws"
|
||||
)
|
||||
|
||||
const openAIClientTransportContextKey = "openai_client_transport"
|
||||
|
||||
// SetOpenAIClientTransport 标记当前请求的客户端入站协议。
|
||||
func SetOpenAIClientTransport(c *gin.Context, transport OpenAIClientTransport) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
normalized := normalizeOpenAIClientTransport(transport)
|
||||
if normalized == OpenAIClientTransportUnknown {
|
||||
return
|
||||
}
|
||||
c.Set(openAIClientTransportContextKey, string(normalized))
|
||||
}
|
||||
|
||||
// GetOpenAIClientTransport 读取当前请求的客户端入站协议。
|
||||
func GetOpenAIClientTransport(c *gin.Context) OpenAIClientTransport {
|
||||
if c == nil {
|
||||
return OpenAIClientTransportUnknown
|
||||
}
|
||||
raw, ok := c.Get(openAIClientTransportContextKey)
|
||||
if !ok || raw == nil {
|
||||
return OpenAIClientTransportUnknown
|
||||
}
|
||||
|
||||
switch v := raw.(type) {
|
||||
case OpenAIClientTransport:
|
||||
return normalizeOpenAIClientTransport(v)
|
||||
case string:
|
||||
return normalizeOpenAIClientTransport(OpenAIClientTransport(v))
|
||||
default:
|
||||
return OpenAIClientTransportUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeOpenAIClientTransport(transport OpenAIClientTransport) OpenAIClientTransport {
|
||||
switch strings.ToLower(strings.TrimSpace(string(transport))) {
|
||||
case string(OpenAIClientTransportHTTP), "http_sse", "sse":
|
||||
return OpenAIClientTransportHTTP
|
||||
case string(OpenAIClientTransportWS), "websocket":
|
||||
return OpenAIClientTransportWS
|
||||
default:
|
||||
return OpenAIClientTransportUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func resolveOpenAIWSDecisionByClientTransport(
|
||||
decision OpenAIWSProtocolDecision,
|
||||
clientTransport OpenAIClientTransport,
|
||||
) OpenAIWSProtocolDecision {
|
||||
if clientTransport == OpenAIClientTransportHTTP {
|
||||
return openAIWSHTTPDecision("client_protocol_http")
|
||||
}
|
||||
return decision
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpenAIClientTransport_SetAndGet(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
require.Equal(t, OpenAIClientTransportUnknown, GetOpenAIClientTransport(c))
|
||||
|
||||
SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
|
||||
require.Equal(t, OpenAIClientTransportHTTP, GetOpenAIClientTransport(c))
|
||||
|
||||
SetOpenAIClientTransport(c, OpenAIClientTransportWS)
|
||||
require.Equal(t, OpenAIClientTransportWS, GetOpenAIClientTransport(c))
|
||||
}
|
||||
|
||||
func TestOpenAIClientTransport_GetNormalizesRawContextValue(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rawValue any
|
||||
want OpenAIClientTransport
|
||||
}{
|
||||
{
|
||||
name: "type_value_ws",
|
||||
rawValue: OpenAIClientTransportWS,
|
||||
want: OpenAIClientTransportWS,
|
||||
},
|
||||
{
|
||||
name: "http_sse_alias",
|
||||
rawValue: "http_sse",
|
||||
want: OpenAIClientTransportHTTP,
|
||||
},
|
||||
{
|
||||
name: "sse_alias",
|
||||
rawValue: "sSe",
|
||||
want: OpenAIClientTransportHTTP,
|
||||
},
|
||||
{
|
||||
name: "websocket_alias",
|
||||
rawValue: "WebSocket",
|
||||
want: OpenAIClientTransportWS,
|
||||
},
|
||||
{
|
||||
name: "invalid_string",
|
||||
rawValue: "tcp",
|
||||
want: OpenAIClientTransportUnknown,
|
||||
},
|
||||
{
|
||||
name: "invalid_type",
|
||||
rawValue: 123,
|
||||
want: OpenAIClientTransportUnknown,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set(openAIClientTransportContextKey, tt.rawValue)
|
||||
require.Equal(t, tt.want, GetOpenAIClientTransport(c))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIClientTransport_NilAndUnknownInput(t *testing.T) {
|
||||
SetOpenAIClientTransport(nil, OpenAIClientTransportHTTP)
|
||||
require.Equal(t, OpenAIClientTransportUnknown, GetOpenAIClientTransport(nil))
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
SetOpenAIClientTransport(c, OpenAIClientTransportUnknown)
|
||||
_, exists := c.Get(openAIClientTransportContextKey)
|
||||
require.False(t, exists)
|
||||
|
||||
SetOpenAIClientTransport(c, OpenAIClientTransport(" "))
|
||||
_, exists = c.Get(openAIClientTransportContextKey)
|
||||
require.False(t, exists)
|
||||
}
|
||||
|
||||
func TestResolveOpenAIWSDecisionByClientTransport(t *testing.T) {
|
||||
base := OpenAIWSProtocolDecision{
|
||||
Transport: OpenAIUpstreamTransportResponsesWebsocketV2,
|
||||
Reason: "ws_v2_enabled",
|
||||
}
|
||||
|
||||
httpDecision := resolveOpenAIWSDecisionByClientTransport(base, OpenAIClientTransportHTTP)
|
||||
require.Equal(t, OpenAIUpstreamTransportHTTPSSE, httpDecision.Transport)
|
||||
require.Equal(t, "client_protocol_http", httpDecision.Reason)
|
||||
|
||||
wsDecision := resolveOpenAIWSDecisionByClientTransport(base, OpenAIClientTransportWS)
|
||||
require.Equal(t, base, wsDecision)
|
||||
|
||||
unknownDecision := resolveOpenAIWSDecisionByClientTransport(base, OpenAIClientTransportUnknown)
|
||||
require.Equal(t, base, unknownDecision)
|
||||
}
|
||||
@@ -2,73 +2,66 @@ package service
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
opencodeCodexHeaderURL = "https://raw.githubusercontent.com/anomalyco/opencode/dev/packages/opencode/src/session/prompt/codex_header.txt"
|
||||
codexCacheTTL = 15 * time.Minute
|
||||
)
|
||||
|
||||
//go:embed prompts/codex_cli_instructions.md
|
||||
var codexCLIInstructions string
|
||||
|
||||
var codexModelMap = map[string]string{
|
||||
"gpt-5.3": "gpt-5.3",
|
||||
"gpt-5.3-none": "gpt-5.3",
|
||||
"gpt-5.3-low": "gpt-5.3",
|
||||
"gpt-5.3-medium": "gpt-5.3",
|
||||
"gpt-5.3-high": "gpt-5.3",
|
||||
"gpt-5.3-xhigh": "gpt-5.3",
|
||||
"gpt-5.3-codex": "gpt-5.3-codex",
|
||||
"gpt-5.3-codex-low": "gpt-5.3-codex",
|
||||
"gpt-5.3-codex-medium": "gpt-5.3-codex",
|
||||
"gpt-5.3-codex-high": "gpt-5.3-codex",
|
||||
"gpt-5.3-codex-xhigh": "gpt-5.3-codex",
|
||||
"gpt-5.1-codex": "gpt-5.1-codex",
|
||||
"gpt-5.1-codex-low": "gpt-5.1-codex",
|
||||
"gpt-5.1-codex-medium": "gpt-5.1-codex",
|
||||
"gpt-5.1-codex-high": "gpt-5.1-codex",
|
||||
"gpt-5.1-codex-max": "gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex-max-low": "gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex-max-medium": "gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex-max-high": "gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex-max-xhigh": "gpt-5.1-codex-max",
|
||||
"gpt-5.2": "gpt-5.2",
|
||||
"gpt-5.2-none": "gpt-5.2",
|
||||
"gpt-5.2-low": "gpt-5.2",
|
||||
"gpt-5.2-medium": "gpt-5.2",
|
||||
"gpt-5.2-high": "gpt-5.2",
|
||||
"gpt-5.2-xhigh": "gpt-5.2",
|
||||
"gpt-5.2-codex": "gpt-5.2-codex",
|
||||
"gpt-5.2-codex-low": "gpt-5.2-codex",
|
||||
"gpt-5.2-codex-medium": "gpt-5.2-codex",
|
||||
"gpt-5.2-codex-high": "gpt-5.2-codex",
|
||||
"gpt-5.2-codex-xhigh": "gpt-5.2-codex",
|
||||
"gpt-5.1-codex-mini": "gpt-5.1-codex-mini",
|
||||
"gpt-5.1-codex-mini-medium": "gpt-5.1-codex-mini",
|
||||
"gpt-5.1-codex-mini-high": "gpt-5.1-codex-mini",
|
||||
"gpt-5.1": "gpt-5.1",
|
||||
"gpt-5.1-none": "gpt-5.1",
|
||||
"gpt-5.1-low": "gpt-5.1",
|
||||
"gpt-5.1-medium": "gpt-5.1",
|
||||
"gpt-5.1-high": "gpt-5.1",
|
||||
"gpt-5.1-chat-latest": "gpt-5.1",
|
||||
"gpt-5-codex": "gpt-5.1-codex",
|
||||
"codex-mini-latest": "gpt-5.1-codex-mini",
|
||||
"gpt-5-codex-mini": "gpt-5.1-codex-mini",
|
||||
"gpt-5-codex-mini-medium": "gpt-5.1-codex-mini",
|
||||
"gpt-5-codex-mini-high": "gpt-5.1-codex-mini",
|
||||
"gpt-5": "gpt-5.1",
|
||||
"gpt-5-mini": "gpt-5.1",
|
||||
"gpt-5-nano": "gpt-5.1",
|
||||
"gpt-5.3": "gpt-5.3-codex",
|
||||
"gpt-5.3-none": "gpt-5.3-codex",
|
||||
"gpt-5.3-low": "gpt-5.3-codex",
|
||||
"gpt-5.3-medium": "gpt-5.3-codex",
|
||||
"gpt-5.3-high": "gpt-5.3-codex",
|
||||
"gpt-5.3-xhigh": "gpt-5.3-codex",
|
||||
"gpt-5.3-codex": "gpt-5.3-codex",
|
||||
"gpt-5.3-codex-spark": "gpt-5.3-codex",
|
||||
"gpt-5.3-codex-spark-low": "gpt-5.3-codex",
|
||||
"gpt-5.3-codex-spark-medium": "gpt-5.3-codex",
|
||||
"gpt-5.3-codex-spark-high": "gpt-5.3-codex",
|
||||
"gpt-5.3-codex-spark-xhigh": "gpt-5.3-codex",
|
||||
"gpt-5.3-codex-low": "gpt-5.3-codex",
|
||||
"gpt-5.3-codex-medium": "gpt-5.3-codex",
|
||||
"gpt-5.3-codex-high": "gpt-5.3-codex",
|
||||
"gpt-5.3-codex-xhigh": "gpt-5.3-codex",
|
||||
"gpt-5.1-codex": "gpt-5.1-codex",
|
||||
"gpt-5.1-codex-low": "gpt-5.1-codex",
|
||||
"gpt-5.1-codex-medium": "gpt-5.1-codex",
|
||||
"gpt-5.1-codex-high": "gpt-5.1-codex",
|
||||
"gpt-5.1-codex-max": "gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex-max-low": "gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex-max-medium": "gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex-max-high": "gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex-max-xhigh": "gpt-5.1-codex-max",
|
||||
"gpt-5.2": "gpt-5.2",
|
||||
"gpt-5.2-none": "gpt-5.2",
|
||||
"gpt-5.2-low": "gpt-5.2",
|
||||
"gpt-5.2-medium": "gpt-5.2",
|
||||
"gpt-5.2-high": "gpt-5.2",
|
||||
"gpt-5.2-xhigh": "gpt-5.2",
|
||||
"gpt-5.2-codex": "gpt-5.2-codex",
|
||||
"gpt-5.2-codex-low": "gpt-5.2-codex",
|
||||
"gpt-5.2-codex-medium": "gpt-5.2-codex",
|
||||
"gpt-5.2-codex-high": "gpt-5.2-codex",
|
||||
"gpt-5.2-codex-xhigh": "gpt-5.2-codex",
|
||||
"gpt-5.1-codex-mini": "gpt-5.1-codex-mini",
|
||||
"gpt-5.1-codex-mini-medium": "gpt-5.1-codex-mini",
|
||||
"gpt-5.1-codex-mini-high": "gpt-5.1-codex-mini",
|
||||
"gpt-5.1": "gpt-5.1",
|
||||
"gpt-5.1-none": "gpt-5.1",
|
||||
"gpt-5.1-low": "gpt-5.1",
|
||||
"gpt-5.1-medium": "gpt-5.1",
|
||||
"gpt-5.1-high": "gpt-5.1",
|
||||
"gpt-5.1-chat-latest": "gpt-5.1",
|
||||
"gpt-5-codex": "gpt-5.1-codex",
|
||||
"codex-mini-latest": "gpt-5.1-codex-mini",
|
||||
"gpt-5-codex-mini": "gpt-5.1-codex-mini",
|
||||
"gpt-5-codex-mini-medium": "gpt-5.1-codex-mini",
|
||||
"gpt-5-codex-mini-high": "gpt-5.1-codex-mini",
|
||||
"gpt-5": "gpt-5.1",
|
||||
"gpt-5-mini": "gpt-5.1",
|
||||
"gpt-5-nano": "gpt-5.1",
|
||||
}
|
||||
|
||||
type codexTransformResult struct {
|
||||
@@ -77,12 +70,6 @@ type codexTransformResult struct {
|
||||
PromptCacheKey string
|
||||
}
|
||||
|
||||
type opencodeCacheMetadata struct {
|
||||
ETag string `json:"etag"`
|
||||
LastFetch string `json:"lastFetch,omitempty"`
|
||||
LastChecked int64 `json:"lastChecked"`
|
||||
}
|
||||
|
||||
func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool) codexTransformResult {
|
||||
result := codexTransformResult{}
|
||||
// 工具续链需求会影响存储策略与 input 过滤逻辑。
|
||||
@@ -112,13 +99,19 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool) codexTran
|
||||
result.Modified = true
|
||||
}
|
||||
|
||||
if _, ok := reqBody["max_output_tokens"]; ok {
|
||||
delete(reqBody, "max_output_tokens")
|
||||
result.Modified = true
|
||||
}
|
||||
if _, ok := reqBody["max_completion_tokens"]; ok {
|
||||
delete(reqBody, "max_completion_tokens")
|
||||
result.Modified = true
|
||||
// Strip parameters unsupported by codex models via the Responses API.
|
||||
for _, key := range []string{
|
||||
"max_output_tokens",
|
||||
"max_completion_tokens",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"frequency_penalty",
|
||||
"presence_penalty",
|
||||
} {
|
||||
if _, ok := reqBody[key]; ok {
|
||||
delete(reqBody, key)
|
||||
result.Modified = true
|
||||
}
|
||||
}
|
||||
|
||||
if normalizeCodexTools(reqBody) {
|
||||
@@ -171,7 +164,7 @@ func normalizeCodexModel(model string) string {
|
||||
return "gpt-5.3-codex"
|
||||
}
|
||||
if strings.Contains(normalized, "gpt-5.3") || strings.Contains(normalized, "gpt 5.3") {
|
||||
return "gpt-5.3"
|
||||
return "gpt-5.3-codex"
|
||||
}
|
||||
if strings.Contains(normalized, "gpt-5.1-codex-max") || strings.Contains(normalized, "gpt 5.1 codex max") {
|
||||
return "gpt-5.1-codex-max"
|
||||
@@ -216,54 +209,9 @@ func getNormalizedCodexModel(modelID string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func getOpenCodeCachedPrompt(url, cacheFileName, metaFileName string) string {
|
||||
cacheDir := codexCachePath("")
|
||||
if cacheDir == "" {
|
||||
return ""
|
||||
}
|
||||
cacheFile := filepath.Join(cacheDir, cacheFileName)
|
||||
metaFile := filepath.Join(cacheDir, metaFileName)
|
||||
|
||||
var cachedContent string
|
||||
if content, ok := readFile(cacheFile); ok {
|
||||
cachedContent = content
|
||||
}
|
||||
|
||||
var meta opencodeCacheMetadata
|
||||
if loadJSON(metaFile, &meta) && meta.LastChecked > 0 && cachedContent != "" {
|
||||
if time.Since(time.UnixMilli(meta.LastChecked)) < codexCacheTTL {
|
||||
return cachedContent
|
||||
}
|
||||
}
|
||||
|
||||
content, etag, status, err := fetchWithETag(url, meta.ETag)
|
||||
if err == nil && status == http.StatusNotModified && cachedContent != "" {
|
||||
return cachedContent
|
||||
}
|
||||
if err == nil && status >= 200 && status < 300 && content != "" {
|
||||
_ = writeFile(cacheFile, content)
|
||||
meta = opencodeCacheMetadata{
|
||||
ETag: etag,
|
||||
LastFetch: time.Now().UTC().Format(time.RFC3339),
|
||||
LastChecked: time.Now().UnixMilli(),
|
||||
}
|
||||
_ = writeJSON(metaFile, meta)
|
||||
return content
|
||||
}
|
||||
|
||||
return cachedContent
|
||||
}
|
||||
|
||||
func getOpenCodeCodexHeader() string {
|
||||
// 优先从 opencode 仓库缓存获取指令。
|
||||
opencodeInstructions := getOpenCodeCachedPrompt(opencodeCodexHeaderURL, "opencode-codex-header.txt", "opencode-codex-header-meta.json")
|
||||
|
||||
// 若 opencode 指令可用,直接返回。
|
||||
if opencodeInstructions != "" {
|
||||
return opencodeInstructions
|
||||
}
|
||||
|
||||
// 否则回退使用本地 Codex CLI 指令。
|
||||
// 兼容保留:历史上这里会从 opencode 仓库拉取 codex_header.txt。
|
||||
// 现在我们与 Codex CLI 一致,直接使用仓库内置的 instructions,避免读写缓存与外网依赖。
|
||||
return getCodexCLIInstructions()
|
||||
}
|
||||
|
||||
@@ -281,8 +229,8 @@ func GetCodexCLIInstructions() string {
|
||||
}
|
||||
|
||||
// applyInstructions 处理 instructions 字段
|
||||
// isCodexCLI=true: 仅补充缺失的 instructions(使用 opencode 指令)
|
||||
// isCodexCLI=false: 优先使用 opencode 指令覆盖
|
||||
// isCodexCLI=true: 仅补充缺失的 instructions(使用内置 Codex CLI 指令)
|
||||
// isCodexCLI=false: 优先使用内置 Codex CLI 指令覆盖
|
||||
func applyInstructions(reqBody map[string]any, isCodexCLI bool) bool {
|
||||
if isCodexCLI {
|
||||
return applyCodexCLIInstructions(reqBody)
|
||||
@@ -291,13 +239,13 @@ func applyInstructions(reqBody map[string]any, isCodexCLI bool) bool {
|
||||
}
|
||||
|
||||
// applyCodexCLIInstructions 为 Codex CLI 请求补充缺失的 instructions
|
||||
// 仅在 instructions 为空时添加 opencode 指令
|
||||
// 仅在 instructions 为空时添加内置 Codex CLI 指令(不依赖 opencode 缓存/回源)
|
||||
func applyCodexCLIInstructions(reqBody map[string]any) bool {
|
||||
if !isInstructionsEmpty(reqBody) {
|
||||
return false // 已有有效 instructions,不修改
|
||||
}
|
||||
|
||||
instructions := strings.TrimSpace(getOpenCodeCodexHeader())
|
||||
instructions := strings.TrimSpace(getCodexCLIInstructions())
|
||||
if instructions != "" {
|
||||
reqBody["instructions"] = instructions
|
||||
return true
|
||||
@@ -306,8 +254,8 @@ func applyCodexCLIInstructions(reqBody map[string]any) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// applyOpenCodeInstructions 为非 Codex CLI 请求应用 opencode 指令
|
||||
// 优先使用 opencode 指令覆盖
|
||||
// applyOpenCodeInstructions 为非 Codex CLI 请求应用内置 Codex CLI 指令(兼容历史函数名)
|
||||
// 优先使用内置 Codex CLI 指令覆盖
|
||||
func applyOpenCodeInstructions(reqBody map[string]any) bool {
|
||||
instructions := strings.TrimSpace(getOpenCodeCodexHeader())
|
||||
existingInstructions, _ := reqBody["instructions"].(string)
|
||||
@@ -489,85 +437,3 @@ func normalizeCodexTools(reqBody map[string]any) bool {
|
||||
|
||||
return modified
|
||||
}
|
||||
|
||||
func codexCachePath(filename string) string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
cacheDir := filepath.Join(home, ".opencode", "cache")
|
||||
if filename == "" {
|
||||
return cacheDir
|
||||
}
|
||||
return filepath.Join(cacheDir, filename)
|
||||
}
|
||||
|
||||
func readFile(path string) (string, bool) {
|
||||
if path == "" {
|
||||
return "", false
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(data), true
|
||||
}
|
||||
|
||||
func writeFile(path, content string) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("empty cache path")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
func loadJSON(path string, target any) bool {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal(data, target); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeJSON(path string, value any) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("empty json path")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
func fetchWithETag(url, etag string) (string, string, int, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "sub2api-codex")
|
||||
if etag != "" {
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", resp.StatusCode, err
|
||||
}
|
||||
return string(body), resp.Header.Get("etag"), resp.StatusCode, nil
|
||||
}
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApplyCodexOAuthTransform_ToolContinuationPreservesInput(t *testing.T) {
|
||||
// 续链场景:保留 item_reference 与 id,但不再强制 store=true。
|
||||
setupCodexCache(t)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.2",
|
||||
@@ -48,7 +43,6 @@ func TestApplyCodexOAuthTransform_ToolContinuationPreservesInput(t *testing.T) {
|
||||
|
||||
func TestApplyCodexOAuthTransform_ExplicitStoreFalsePreserved(t *testing.T) {
|
||||
// 续链场景:显式 store=false 不再强制为 true,保持 false。
|
||||
setupCodexCache(t)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.1",
|
||||
@@ -68,7 +62,6 @@ func TestApplyCodexOAuthTransform_ExplicitStoreFalsePreserved(t *testing.T) {
|
||||
|
||||
func TestApplyCodexOAuthTransform_ExplicitStoreTrueForcedFalse(t *testing.T) {
|
||||
// 显式 store=true 也会强制为 false。
|
||||
setupCodexCache(t)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.1",
|
||||
@@ -88,7 +81,6 @@ func TestApplyCodexOAuthTransform_ExplicitStoreTrueForcedFalse(t *testing.T) {
|
||||
|
||||
func TestApplyCodexOAuthTransform_NonContinuationDefaultsStoreFalseAndStripsIDs(t *testing.T) {
|
||||
// 非续链场景:未设置 store 时默认 false,并移除 input 中的 id。
|
||||
setupCodexCache(t)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.1",
|
||||
@@ -130,8 +122,6 @@ func TestFilterCodexInput_RemovesItemReferenceWhenNotPreserved(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApplyCodexOAuthTransform_NormalizeCodexTools_PreservesResponsesFunctionTools(t *testing.T) {
|
||||
setupCodexCache(t)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.1",
|
||||
"tools": []any{
|
||||
@@ -162,7 +152,6 @@ func TestApplyCodexOAuthTransform_NormalizeCodexTools_PreservesResponsesFunction
|
||||
|
||||
func TestApplyCodexOAuthTransform_EmptyInput(t *testing.T) {
|
||||
// 空 input 应保持为空且不触发异常。
|
||||
setupCodexCache(t)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.1",
|
||||
@@ -178,97 +167,39 @@ func TestApplyCodexOAuthTransform_EmptyInput(t *testing.T) {
|
||||
|
||||
func TestNormalizeCodexModel_Gpt53(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"gpt-5.3": "gpt-5.3",
|
||||
"gpt-5.3-codex": "gpt-5.3-codex",
|
||||
"gpt-5.3-codex-xhigh": "gpt-5.3-codex",
|
||||
"gpt 5.3 codex": "gpt-5.3-codex",
|
||||
"gpt-5.3": "gpt-5.3-codex",
|
||||
"gpt-5.3-codex": "gpt-5.3-codex",
|
||||
"gpt-5.3-codex-xhigh": "gpt-5.3-codex",
|
||||
"gpt-5.3-codex-spark": "gpt-5.3-codex",
|
||||
"gpt-5.3-codex-spark-high": "gpt-5.3-codex",
|
||||
"gpt-5.3-codex-spark-xhigh": "gpt-5.3-codex",
|
||||
"gpt 5.3 codex": "gpt-5.3-codex",
|
||||
}
|
||||
|
||||
for input, expected := range cases {
|
||||
require.Equal(t, expected, normalizeCodexModel(input))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestApplyCodexOAuthTransform_CodexCLI_PreservesExistingInstructions(t *testing.T) {
|
||||
// Codex CLI 场景:已有 instructions 时保持不变
|
||||
setupCodexCache(t)
|
||||
// Codex CLI 场景:已有 instructions 时不修改
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.1",
|
||||
"instructions": "user custom instructions",
|
||||
"input": []any{},
|
||||
"instructions": "existing instructions",
|
||||
}
|
||||
|
||||
result := applyCodexOAuthTransform(reqBody, true)
|
||||
result := applyCodexOAuthTransform(reqBody, true) // isCodexCLI=true
|
||||
|
||||
instructions, ok := reqBody["instructions"].(string)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "user custom instructions", instructions)
|
||||
// instructions 未变,但其他字段(如 store、stream)可能被修改
|
||||
require.True(t, result.Modified)
|
||||
}
|
||||
|
||||
func TestApplyCodexOAuthTransform_CodexCLI_AddsInstructionsWhenEmpty(t *testing.T) {
|
||||
// Codex CLI 场景:无 instructions 时补充内置指令
|
||||
setupCodexCache(t)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.1",
|
||||
"input": []any{},
|
||||
}
|
||||
|
||||
result := applyCodexOAuthTransform(reqBody, true)
|
||||
|
||||
instructions, ok := reqBody["instructions"].(string)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, instructions)
|
||||
require.True(t, result.Modified)
|
||||
}
|
||||
|
||||
func TestApplyCodexOAuthTransform_NonCodexCLI_UsesOpenCodeInstructions(t *testing.T) {
|
||||
// 非 Codex CLI 场景:使用 opencode 指令(缓存中有 header)
|
||||
setupCodexCache(t)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.1",
|
||||
"input": []any{},
|
||||
}
|
||||
|
||||
result := applyCodexOAuthTransform(reqBody, false)
|
||||
|
||||
instructions, ok := reqBody["instructions"].(string)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "header", instructions) // setupCodexCache 设置的缓存内容
|
||||
require.True(t, result.Modified)
|
||||
}
|
||||
|
||||
func setupCodexCache(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
// 使用临时 HOME 避免触发网络拉取 header。
|
||||
// Windows 使用 USERPROFILE,Unix 使用 HOME。
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("HOME", tempDir)
|
||||
t.Setenv("USERPROFILE", tempDir)
|
||||
|
||||
cacheDir := filepath.Join(tempDir, ".opencode", "cache")
|
||||
require.NoError(t, os.MkdirAll(cacheDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(cacheDir, "opencode-codex-header.txt"), []byte("header"), 0o644))
|
||||
|
||||
meta := map[string]any{
|
||||
"etag": "",
|
||||
"lastFetch": time.Now().UTC().Format(time.RFC3339),
|
||||
"lastChecked": time.Now().UnixMilli(),
|
||||
}
|
||||
data, err := json.Marshal(meta)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(cacheDir, "opencode-codex-header-meta.json"), data, 0o644))
|
||||
require.Equal(t, "existing instructions", instructions)
|
||||
// Modified 仍可能为 true(因为其他字段被修改),但 instructions 应保持不变
|
||||
_ = result
|
||||
}
|
||||
|
||||
func TestApplyCodexOAuthTransform_CodexCLI_SuppliesDefaultWhenEmpty(t *testing.T) {
|
||||
// Codex CLI 场景:无 instructions 时补充默认值
|
||||
setupCodexCache(t)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.1",
|
||||
@@ -284,8 +215,7 @@ func TestApplyCodexOAuthTransform_CodexCLI_SuppliesDefaultWhenEmpty(t *testing.T
|
||||
}
|
||||
|
||||
func TestApplyCodexOAuthTransform_NonCodexCLI_OverridesInstructions(t *testing.T) {
|
||||
// 非 Codex CLI 场景:使用 opencode 指令覆盖
|
||||
setupCodexCache(t)
|
||||
// 非 Codex CLI 场景:使用内置 Codex CLI 指令覆盖
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.1",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,266 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type stubCodexRestrictionDetector struct {
|
||||
result CodexClientRestrictionDetectionResult
|
||||
}
|
||||
|
||||
func (s *stubCodexRestrictionDetector) Detect(_ *gin.Context, _ *Account) CodexClientRestrictionDetectionResult {
|
||||
return s.result
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_GetCodexClientRestrictionDetector(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("使用注入的 detector", func(t *testing.T) {
|
||||
expected := &stubCodexRestrictionDetector{
|
||||
result: CodexClientRestrictionDetectionResult{Enabled: true, Matched: true, Reason: "stub"},
|
||||
}
|
||||
svc := &OpenAIGatewayService{codexDetector: expected}
|
||||
|
||||
got := svc.getCodexClientRestrictionDetector()
|
||||
require.Same(t, expected, got)
|
||||
})
|
||||
|
||||
t.Run("service 为 nil 时返回默认 detector", func(t *testing.T) {
|
||||
var svc *OpenAIGatewayService
|
||||
got := svc.getCodexClientRestrictionDetector()
|
||||
require.NotNil(t, got)
|
||||
})
|
||||
|
||||
t.Run("service 未注入 detector 时返回默认 detector", func(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: true}}}
|
||||
got := svc.getCodexClientRestrictionDetector()
|
||||
require.NotNil(t, got)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
c.Request.Header.Set("User-Agent", "curl/8.0")
|
||||
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{"codex_cli_only": true}}
|
||||
|
||||
result := got.Detect(c, account)
|
||||
require.True(t, result.Enabled)
|
||||
require.True(t, result.Matched)
|
||||
require.Equal(t, CodexClientRestrictionReasonForceCodexCLI, result.Reason)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetAPIKeyIDFromContext(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("context 为 nil", func(t *testing.T) {
|
||||
require.Equal(t, int64(0), getAPIKeyIDFromContext(nil))
|
||||
})
|
||||
|
||||
t.Run("上下文没有 api_key", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
require.Equal(t, int64(0), getAPIKeyIDFromContext(c))
|
||||
})
|
||||
|
||||
t.Run("api_key 类型错误", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Set("api_key", "not-api-key")
|
||||
require.Equal(t, int64(0), getAPIKeyIDFromContext(c))
|
||||
})
|
||||
|
||||
t.Run("api_key 指针为空", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
var k *APIKey
|
||||
c.Set("api_key", k)
|
||||
require.Equal(t, int64(0), getAPIKeyIDFromContext(c))
|
||||
})
|
||||
|
||||
t.Run("正常读取 api_key_id", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Set("api_key", &APIKey{ID: 12345})
|
||||
require.Equal(t, int64(12345), getAPIKeyIDFromContext(c))
|
||||
})
|
||||
}
|
||||
|
||||
func TestLogCodexCLIOnlyDetection_NilSafety(t *testing.T) {
|
||||
// 不校验日志内容,仅保证在 nil 入参下不会 panic。
|
||||
require.NotPanics(t, func() {
|
||||
logCodexCLIOnlyDetection(context.TODO(), nil, nil, 0, CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: "test"}, nil)
|
||||
logCodexCLIOnlyDetection(context.Background(), nil, nil, 0, CodexClientRestrictionDetectionResult{Enabled: false, Matched: false, Reason: "disabled"}, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLogCodexCLIOnlyDetection_OnlyLogsRejected(t *testing.T) {
|
||||
logSink, restore := captureStructuredLog(t)
|
||||
defer restore()
|
||||
|
||||
account := &Account{ID: 1001}
|
||||
logCodexCLIOnlyDetection(context.Background(), nil, account, 2002, CodexClientRestrictionDetectionResult{
|
||||
Enabled: true,
|
||||
Matched: true,
|
||||
Reason: CodexClientRestrictionReasonMatchedUA,
|
||||
}, nil)
|
||||
logCodexCLIOnlyDetection(context.Background(), nil, account, 2002, CodexClientRestrictionDetectionResult{
|
||||
Enabled: true,
|
||||
Matched: false,
|
||||
Reason: CodexClientRestrictionReasonNotMatchedUA,
|
||||
}, nil)
|
||||
|
||||
require.False(t, logSink.ContainsMessage("OpenAI codex_cli_only 允许官方客户端请求"))
|
||||
require.True(t, logSink.ContainsMessage("OpenAI codex_cli_only 拒绝非官方客户端请求"))
|
||||
}
|
||||
|
||||
func TestLogCodexCLIOnlyDetection_RejectedIncludesRequestDetails(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logSink, restore := captureStructuredLog(t)
|
||||
defer restore()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses?trace=1", bytes.NewReader(nil))
|
||||
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown")
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request.Header.Set("OpenAI-Beta", "assistants=v2")
|
||||
|
||||
body := []byte(`{"model":"gpt-5.2","stream":false,"prompt_cache_key":"pc-123","access_token":"secret-token","input":[{"type":"text","text":"hello"}]}`)
|
||||
account := &Account{ID: 1001}
|
||||
logCodexCLIOnlyDetection(context.Background(), c, account, 2002, CodexClientRestrictionDetectionResult{
|
||||
Enabled: true,
|
||||
Matched: false,
|
||||
Reason: CodexClientRestrictionReasonNotMatchedUA,
|
||||
}, body)
|
||||
|
||||
require.True(t, logSink.ContainsFieldValue("request_user_agent", "codex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown"))
|
||||
require.True(t, logSink.ContainsFieldValue("request_model", "gpt-5.2"))
|
||||
require.True(t, logSink.ContainsFieldValue("request_query", "trace=1"))
|
||||
require.True(t, logSink.ContainsFieldValue("request_prompt_cache_key_sha256", hashSensitiveValueForLog("pc-123")))
|
||||
require.True(t, logSink.ContainsFieldValue("request_headers", "openai-beta"))
|
||||
require.True(t, logSink.ContainsField("request_body_size"))
|
||||
require.False(t, logSink.ContainsField("request_body_preview"))
|
||||
}
|
||||
|
||||
func TestLogOpenAIInstructionsRequiredDebug_LogsRequestDetails(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logSink, restore := captureStructuredLog(t)
|
||||
defer restore()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses?trace=1", bytes.NewReader(nil))
|
||||
c.Request.Header.Set("User-Agent", "curl/8.0")
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request.Header.Set("OpenAI-Beta", "assistants=v2")
|
||||
|
||||
body := []byte(`{"model":"gpt-5.1-codex","stream":false,"prompt_cache_key":"pc-abc","access_token":"secret-token","input":[{"type":"text","text":"hello"}]}`)
|
||||
account := &Account{ID: 1001, Name: "codex max套餐"}
|
||||
|
||||
logOpenAIInstructionsRequiredDebug(
|
||||
context.Background(),
|
||||
c,
|
||||
account,
|
||||
http.StatusBadRequest,
|
||||
"Instructions are required",
|
||||
body,
|
||||
[]byte(`{"error":{"message":"Instructions are required","type":"invalid_request_error","param":"instructions","code":"missing_required_parameter"}}`),
|
||||
)
|
||||
|
||||
require.True(t, logSink.ContainsMessageAtLevel("OpenAI 上游返回 Instructions are required,已记录请求详情用于排查", "warn"))
|
||||
require.True(t, logSink.ContainsFieldValue("request_user_agent", "curl/8.0"))
|
||||
require.True(t, logSink.ContainsFieldValue("request_model", "gpt-5.1-codex"))
|
||||
require.True(t, logSink.ContainsFieldValue("request_query", "trace=1"))
|
||||
require.True(t, logSink.ContainsFieldValue("account_name", "codex max套餐"))
|
||||
require.True(t, logSink.ContainsFieldValue("request_headers", "openai-beta"))
|
||||
require.True(t, logSink.ContainsField("request_body_size"))
|
||||
require.False(t, logSink.ContainsField("request_body_preview"))
|
||||
}
|
||||
|
||||
func TestLogOpenAIInstructionsRequiredDebug_NonTargetErrorSkipped(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logSink, restore := captureStructuredLog(t)
|
||||
defer restore()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
|
||||
c.Request.Header.Set("User-Agent", "curl/8.0")
|
||||
body := []byte(`{"model":"gpt-5.1-codex","stream":false}`)
|
||||
|
||||
logOpenAIInstructionsRequiredDebug(
|
||||
context.Background(),
|
||||
c,
|
||||
&Account{ID: 1001},
|
||||
http.StatusForbidden,
|
||||
"forbidden",
|
||||
body,
|
||||
[]byte(`{"error":{"message":"forbidden"}}`),
|
||||
)
|
||||
|
||||
require.False(t, logSink.ContainsMessage("OpenAI 上游返回 Instructions are required,已记录请求详情用于排查"))
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_Forward_LogsInstructionsRequiredDetails(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logSink, restore := captureStructuredLog(t)
|
||||
defer restore()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses?trace=1", bytes.NewReader(nil))
|
||||
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.1.0")
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request.Header.Set("OpenAI-Beta", "assistants=v2")
|
||||
|
||||
upstream := &httpUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"x-request-id": []string{"rid-upstream"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"Missing required parameter: 'instructions'","type":"invalid_request_error","param":"instructions","code":"missing_required_parameter"}}`)),
|
||||
},
|
||||
}
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: &config.Config{
|
||||
Gateway: config.GatewayConfig{ForceCodexCLI: false},
|
||||
},
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
account := &Account{
|
||||
ID: 1001,
|
||||
Name: "codex max套餐",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{"api_key": "sk-test"},
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
RateMultiplier: f64p(1),
|
||||
}
|
||||
body := []byte(`{"model":"gpt-5.1-codex","stream":false,"input":[{"type":"text","text":"hello"}],"prompt_cache_key":"pc-forward","access_token":"secret-token"}`)
|
||||
|
||||
_, err := svc.Forward(context.Background(), c, account, body)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusBadGateway, rec.Code)
|
||||
require.Contains(t, err.Error(), "upstream error: 400")
|
||||
|
||||
require.True(t, logSink.ContainsMessageAtLevel("OpenAI 上游返回 Instructions are required,已记录请求详情用于排查", "warn"))
|
||||
require.True(t, logSink.ContainsFieldValue("request_user_agent", "codex_cli_rs/0.1.0"))
|
||||
require.True(t, logSink.ContainsFieldValue("request_model", "gpt-5.1-codex"))
|
||||
require.True(t, logSink.ContainsFieldValue("request_headers", "openai-beta"))
|
||||
require.True(t, logSink.ContainsField("request_body_size"))
|
||||
require.False(t, logSink.ContainsField("request_body_preview"))
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCodexSnapshotBaseTime(t *testing.T) {
|
||||
fallback := time.Date(2026, 2, 20, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("nil snapshot uses fallback", func(t *testing.T) {
|
||||
got := codexSnapshotBaseTime(nil, fallback)
|
||||
if !got.Equal(fallback) {
|
||||
t.Fatalf("got %v, want fallback %v", got, fallback)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty updatedAt uses fallback", func(t *testing.T) {
|
||||
got := codexSnapshotBaseTime(&OpenAICodexUsageSnapshot{}, fallback)
|
||||
if !got.Equal(fallback) {
|
||||
t.Fatalf("got %v, want fallback %v", got, fallback)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid updatedAt wins", func(t *testing.T) {
|
||||
got := codexSnapshotBaseTime(&OpenAICodexUsageSnapshot{UpdatedAt: "2026-02-16T10:00:00Z"}, fallback)
|
||||
want := time.Date(2026, 2, 16, 10, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid updatedAt uses fallback", func(t *testing.T) {
|
||||
got := codexSnapshotBaseTime(&OpenAICodexUsageSnapshot{UpdatedAt: "invalid"}, fallback)
|
||||
if !got.Equal(fallback) {
|
||||
t.Fatalf("got %v, want fallback %v", got, fallback)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCodexResetAtRFC3339(t *testing.T) {
|
||||
base := time.Date(2026, 2, 16, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("nil reset returns nil", func(t *testing.T) {
|
||||
if got := codexResetAtRFC3339(base, nil); got != nil {
|
||||
t.Fatalf("expected nil, got %v", *got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("positive seconds", func(t *testing.T) {
|
||||
sec := 90
|
||||
got := codexResetAtRFC3339(base, &sec)
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if *got != "2026-02-16T10:01:30Z" {
|
||||
t.Fatalf("got %s, want %s", *got, "2026-02-16T10:01:30Z")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("negative seconds clamp to base", func(t *testing.T) {
|
||||
sec := -3
|
||||
got := codexResetAtRFC3339(base, &sec)
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if *got != "2026-02-16T10:00:00Z" {
|
||||
t.Fatalf("got %s, want %s", *got, "2026-02-16T10:00:00Z")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildCodexUsageExtraUpdates_UsesSnapshotUpdatedAt(t *testing.T) {
|
||||
primaryUsed := 88.0
|
||||
primaryReset := 86400
|
||||
primaryWindow := 10080
|
||||
secondaryUsed := 12.0
|
||||
secondaryReset := 3600
|
||||
secondaryWindow := 300
|
||||
|
||||
snapshot := &OpenAICodexUsageSnapshot{
|
||||
PrimaryUsedPercent: &primaryUsed,
|
||||
PrimaryResetAfterSeconds: &primaryReset,
|
||||
PrimaryWindowMinutes: &primaryWindow,
|
||||
SecondaryUsedPercent: &secondaryUsed,
|
||||
SecondaryResetAfterSeconds: &secondaryReset,
|
||||
SecondaryWindowMinutes: &secondaryWindow,
|
||||
UpdatedAt: "2026-02-16T10:00:00Z",
|
||||
}
|
||||
|
||||
updates := buildCodexUsageExtraUpdates(snapshot, time.Date(2026, 2, 20, 8, 0, 0, 0, time.UTC))
|
||||
if updates == nil {
|
||||
t.Fatal("expected non-nil updates")
|
||||
}
|
||||
|
||||
if got := updates["codex_usage_updated_at"]; got != "2026-02-16T10:00:00Z" {
|
||||
t.Fatalf("codex_usage_updated_at = %v, want %s", got, "2026-02-16T10:00:00Z")
|
||||
}
|
||||
if got := updates["codex_5h_reset_at"]; got != "2026-02-16T11:00:00Z" {
|
||||
t.Fatalf("codex_5h_reset_at = %v, want %s", got, "2026-02-16T11:00:00Z")
|
||||
}
|
||||
if got := updates["codex_7d_reset_at"]; got != "2026-02-17T10:00:00Z" {
|
||||
t.Fatalf("codex_7d_reset_at = %v, want %s", got, "2026-02-17T10:00:00Z")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCodexUsageExtraUpdates_FallbackToNowWhenUpdatedAtInvalid(t *testing.T) {
|
||||
primaryUsed := 15.0
|
||||
primaryReset := 30
|
||||
primaryWindow := 300
|
||||
|
||||
fallbackNow := time.Date(2026, 2, 20, 8, 30, 0, 0, time.UTC)
|
||||
snapshot := &OpenAICodexUsageSnapshot{
|
||||
PrimaryUsedPercent: &primaryUsed,
|
||||
PrimaryResetAfterSeconds: &primaryReset,
|
||||
PrimaryWindowMinutes: &primaryWindow,
|
||||
UpdatedAt: "invalid-time",
|
||||
}
|
||||
|
||||
updates := buildCodexUsageExtraUpdates(snapshot, fallbackNow)
|
||||
if updates == nil {
|
||||
t.Fatal("expected non-nil updates")
|
||||
}
|
||||
|
||||
if got := updates["codex_usage_updated_at"]; got != "2026-02-20T08:30:00Z" {
|
||||
t.Fatalf("codex_usage_updated_at = %v, want %s", got, "2026-02-20T08:30:00Z")
|
||||
}
|
||||
if got := updates["codex_5h_reset_at"]; got != "2026-02-20T08:30:30Z" {
|
||||
t.Fatalf("codex_5h_reset_at = %v, want %s", got, "2026-02-20T08:30:30Z")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCodexUsageExtraUpdates_ClampNegativeResetSeconds(t *testing.T) {
|
||||
primaryUsed := 90.0
|
||||
primaryReset := 7200
|
||||
primaryWindow := 10080
|
||||
secondaryUsed := 100.0
|
||||
secondaryReset := -15
|
||||
secondaryWindow := 300
|
||||
|
||||
snapshot := &OpenAICodexUsageSnapshot{
|
||||
PrimaryUsedPercent: &primaryUsed,
|
||||
PrimaryResetAfterSeconds: &primaryReset,
|
||||
PrimaryWindowMinutes: &primaryWindow,
|
||||
SecondaryUsedPercent: &secondaryUsed,
|
||||
SecondaryResetAfterSeconds: &secondaryReset,
|
||||
SecondaryWindowMinutes: &secondaryWindow,
|
||||
UpdatedAt: "2026-02-16T10:00:00Z",
|
||||
}
|
||||
|
||||
updates := buildCodexUsageExtraUpdates(snapshot, time.Time{})
|
||||
if updates == nil {
|
||||
t.Fatal("expected non-nil updates")
|
||||
}
|
||||
|
||||
if got := updates["codex_5h_reset_after_seconds"]; got != -15 {
|
||||
t.Fatalf("codex_5h_reset_after_seconds = %v, want %d", got, -15)
|
||||
}
|
||||
if got := updates["codex_5h_reset_at"]; got != "2026-02-16T10:00:00Z" {
|
||||
t.Fatalf("codex_5h_reset_at = %v, want %s", got, "2026-02-16T10:00:00Z")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCodexUsageExtraUpdates_NilSnapshot(t *testing.T) {
|
||||
if got := buildCodexUsageExtraUpdates(nil, time.Now()); got != nil {
|
||||
t.Fatalf("expected nil updates, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCodexUsageExtraUpdates_WithoutNormalizedWindowFields(t *testing.T) {
|
||||
primaryUsed := 42.0
|
||||
fallbackNow := time.Date(2026, 2, 20, 9, 15, 0, 0, time.UTC)
|
||||
snapshot := &OpenAICodexUsageSnapshot{
|
||||
PrimaryUsedPercent: &primaryUsed,
|
||||
UpdatedAt: "",
|
||||
}
|
||||
|
||||
updates := buildCodexUsageExtraUpdates(snapshot, fallbackNow)
|
||||
if updates == nil {
|
||||
t.Fatal("expected non-nil updates")
|
||||
}
|
||||
|
||||
if got := updates["codex_usage_updated_at"]; got != "2026-02-20T09:15:00Z" {
|
||||
t.Fatalf("codex_usage_updated_at = %v, want %s", got, "2026-02-20T09:15:00Z")
|
||||
}
|
||||
if _, ok := updates["codex_5h_reset_at"]; ok {
|
||||
t.Fatalf("did not expect codex_5h_reset_at in updates: %v", updates["codex_5h_reset_at"])
|
||||
}
|
||||
if _, ok := updates["codex_7d_reset_at"]; ok {
|
||||
t.Fatalf("did not expect codex_7d_reset_at in updates: %v", updates["codex_7d_reset_at"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExtractOpenAIRequestMetaFromBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
wantModel string
|
||||
wantStream bool
|
||||
wantPromptKey string
|
||||
}{
|
||||
{
|
||||
name: "完整字段",
|
||||
body: []byte(`{"model":"gpt-5","stream":true,"prompt_cache_key":" ses-1 "}`),
|
||||
wantModel: "gpt-5",
|
||||
wantStream: true,
|
||||
wantPromptKey: "ses-1",
|
||||
},
|
||||
{
|
||||
name: "缺失可选字段",
|
||||
body: []byte(`{"model":"gpt-4"}`),
|
||||
wantModel: "gpt-4",
|
||||
wantStream: false,
|
||||
wantPromptKey: "",
|
||||
},
|
||||
{
|
||||
name: "空请求体",
|
||||
body: nil,
|
||||
wantModel: "",
|
||||
wantStream: false,
|
||||
wantPromptKey: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
model, stream, promptKey := extractOpenAIRequestMetaFromBody(tt.body)
|
||||
require.Equal(t, tt.wantModel, model)
|
||||
require.Equal(t, tt.wantStream, stream)
|
||||
require.Equal(t, tt.wantPromptKey, promptKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractOpenAIReasoningEffortFromBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
model string
|
||||
wantNil bool
|
||||
wantValue string
|
||||
}{
|
||||
{
|
||||
name: "优先读取 reasoning.effort",
|
||||
body: []byte(`{"reasoning":{"effort":"medium"}}`),
|
||||
model: "gpt-5-high",
|
||||
wantNil: false,
|
||||
wantValue: "medium",
|
||||
},
|
||||
{
|
||||
name: "兼容 reasoning_effort",
|
||||
body: []byte(`{"reasoning_effort":"x-high"}`),
|
||||
model: "",
|
||||
wantNil: false,
|
||||
wantValue: "xhigh",
|
||||
},
|
||||
{
|
||||
name: "minimal 归一化为空",
|
||||
body: []byte(`{"reasoning":{"effort":"minimal"}}`),
|
||||
model: "gpt-5-high",
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "缺失字段时从模型后缀推导",
|
||||
body: []byte(`{"input":"hi"}`),
|
||||
model: "gpt-5-high",
|
||||
wantNil: false,
|
||||
wantValue: "high",
|
||||
},
|
||||
{
|
||||
name: "未知后缀不返回",
|
||||
body: []byte(`{"input":"hi"}`),
|
||||
model: "gpt-5-unknown",
|
||||
wantNil: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractOpenAIReasoningEffortFromBody(tt.body, tt.model)
|
||||
if tt.wantNil {
|
||||
require.Nil(t, got)
|
||||
return
|
||||
}
|
||||
require.NotNil(t, got)
|
||||
require.Equal(t, tt.wantValue, *got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOpenAIRequestBodyMap_UsesContextCache(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
|
||||
cached := map[string]any{"model": "cached-model", "stream": true}
|
||||
c.Set(OpenAIParsedRequestBodyKey, cached)
|
||||
|
||||
got, err := getOpenAIRequestBodyMap(c, []byte(`{invalid-json`))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, cached, got)
|
||||
}
|
||||
|
||||
func TestGetOpenAIRequestBodyMap_ParseErrorWithoutCache(t *testing.T) {
|
||||
_, err := getOpenAIRequestBodyMap(nil, []byte(`{invalid-json`))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "parse request")
|
||||
}
|
||||
|
||||
func TestGetOpenAIRequestBodyMap_WriteBackContextCache(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
|
||||
got, err := getOpenAIRequestBodyMap(c, []byte(`{"model":"gpt-5","stream":true}`))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "gpt-5", got["model"])
|
||||
|
||||
cached, ok := c.Get(OpenAIParsedRequestBodyKey)
|
||||
require.True(t, ok)
|
||||
cachedMap, ok := cached.(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, got, cachedMap)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user