From 089a7b7fae04e1898e06229e101b224376d88f43 Mon Sep 17 00:00:00 2001
From: "Bestony@Homelab"
Date: Thu, 2 Jul 2026 15:58:29 +0800
Subject: [PATCH 01/16] feat(keys): add api key concurrency stats
---
backend/cmd/server/wire_gen.go | 10 +-
.../dto/api_key_mapper_last_used_test.go | 14 ++-
backend/internal/handler/dto/mappers.go | 51 ++++----
backend/internal/handler/dto/types.go | 2 +
backend/internal/handler/gateway_helper.go | 43 ++++++-
.../handler/gateway_helper_hotpath_test.go | 69 ++++++++++
.../handler/openai_gateway_handler.go | 6 +-
.../internal/repository/concurrency_cache.go | 78 +++++++++++-
.../concurrency_cache_integration_test.go | 51 ++++++++
backend/internal/service/api_key.go | 1 +
backend/internal/service/api_key_service.go | 39 ++++++
.../service/api_key_service_delete_test.go | 34 +++++
.../internal/service/concurrency_service.go | 79 ++++++++++++
.../service/concurrency_service_test.go | 118 +++++++++++++++---
backend/internal/service/wire.go | 2 +
frontend/src/i18n/locales/en.ts | 1 +
frontend/src/i18n/locales/zh.ts | 1 +
frontend/src/types/index.ts | 1 +
frontend/src/views/user/KeysView.vue | 14 +++
.../src/views/user/__tests__/KeysView.spec.ts | 14 +++
20 files changed, 569 insertions(+), 59 deletions(-)
diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go
index a6fb5266aa..a412563a6c 100644
--- a/backend/cmd/server/wire_gen.go
+++ b/backend/cmd/server/wire_gen.go
@@ -67,7 +67,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
serviceUserPlatformQuotaRepository := repository.NewUserPlatformQuotaServiceAdapter(userPlatformQuotaRepository)
billingCacheService := service.ProvideBillingCacheService(billingCache, userRepository, userSubscriptionRepository, apiKeyRepository, userRPMCache, userGroupRateRepository, configConfig, serviceUserPlatformQuotaRepository)
apiKeyCache := repository.NewAPIKeyCache(redisClient)
- apiKeyService := service.ProvideAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, userGroupRateRepository, apiKeyCache, configConfig, billingCacheService)
+ concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
+ schedulerCache := repository.ProvideSchedulerCache(redisClient, configConfig)
+ accountRepository := repository.NewAccountRepository(client, db, schedulerCache)
+ concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig)
+ apiKeyService := service.ProvideAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, userGroupRateRepository, apiKeyCache, configConfig, billingCacheService, concurrencyService)
apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService)
promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator)
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig)
@@ -92,10 +96,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
usageLogRepository := repository.NewUsageLogRepository(client, db)
usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator)
opsRepository := repository.NewOpsRepository(db)
- schedulerCache := repository.ProvideSchedulerCache(redisClient, configConfig)
- accountRepository := repository.NewAccountRepository(client, db, schedulerCache)
- concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
- concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig)
usageBillingRepository := repository.NewUsageBillingRepository(client, db)
gatewayCache := repository.NewGatewayCache(redisClient)
schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db)
diff --git a/backend/internal/handler/dto/api_key_mapper_last_used_test.go b/backend/internal/handler/dto/api_key_mapper_last_used_test.go
index 99644ced7f..d63baba91a 100644
--- a/backend/internal/handler/dto/api_key_mapper_last_used_test.go
+++ b/backend/internal/handler/dto/api_key_mapper_last_used_test.go
@@ -11,18 +11,20 @@ import (
func TestAPIKeyFromService_MapsLastUsedAt(t *testing.T) {
lastUsed := time.Now().UTC().Truncate(time.Second)
src := &service.APIKey{
- ID: 1,
- UserID: 2,
- Key: "sk-map-last-used",
- Name: "Mapper",
- Status: service.StatusActive,
- LastUsedAt: &lastUsed,
+ ID: 1,
+ UserID: 2,
+ Key: "sk-map-last-used",
+ Name: "Mapper",
+ Status: service.StatusActive,
+ LastUsedAt: &lastUsed,
+ CurrentConcurrency: 3,
}
out := APIKeyFromService(src)
require.NotNil(t, out)
require.NotNil(t, out.LastUsedAt)
require.WithinDuration(t, lastUsed, *out.LastUsedAt, time.Second)
+ require.Equal(t, 3, out.CurrentConcurrency)
}
func TestAPIKeyFromService_MapsNilLastUsedAt(t *testing.T) {
diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go
index 72679de37b..02a0a38d7b 100644
--- a/backend/internal/handler/dto/mappers.go
+++ b/backend/internal/handler/dto/mappers.go
@@ -79,31 +79,32 @@ func APIKeyFromService(k *service.APIKey) *APIKey {
return nil
}
out := &APIKey{
- ID: k.ID,
- UserID: k.UserID,
- Key: k.Key,
- Name: k.Name,
- GroupID: k.GroupID,
- Status: k.Status,
- IPWhitelist: k.IPWhitelist,
- IPBlacklist: k.IPBlacklist,
- LastUsedAt: k.LastUsedAt,
- Quota: k.Quota,
- QuotaUsed: k.QuotaUsed,
- ExpiresAt: k.ExpiresAt,
- CreatedAt: k.CreatedAt,
- UpdatedAt: k.UpdatedAt,
- RateLimit5h: k.RateLimit5h,
- RateLimit1d: k.RateLimit1d,
- RateLimit7d: k.RateLimit7d,
- Usage5h: k.EffectiveUsage5h(),
- Usage1d: k.EffectiveUsage1d(),
- Usage7d: k.EffectiveUsage7d(),
- Window5hStart: k.Window5hStart,
- Window1dStart: k.Window1dStart,
- Window7dStart: k.Window7dStart,
- User: UserFromServiceShallow(k.User),
- Group: GroupFromServiceShallow(k.Group),
+ ID: k.ID,
+ UserID: k.UserID,
+ Key: k.Key,
+ Name: k.Name,
+ GroupID: k.GroupID,
+ Status: k.Status,
+ IPWhitelist: k.IPWhitelist,
+ IPBlacklist: k.IPBlacklist,
+ LastUsedAt: k.LastUsedAt,
+ Quota: k.Quota,
+ QuotaUsed: k.QuotaUsed,
+ ExpiresAt: k.ExpiresAt,
+ CreatedAt: k.CreatedAt,
+ UpdatedAt: k.UpdatedAt,
+ CurrentConcurrency: k.CurrentConcurrency,
+ RateLimit5h: k.RateLimit5h,
+ RateLimit1d: k.RateLimit1d,
+ RateLimit7d: k.RateLimit7d,
+ Usage5h: k.EffectiveUsage5h(),
+ Usage1d: k.EffectiveUsage1d(),
+ Usage7d: k.EffectiveUsage7d(),
+ Window5hStart: k.Window5hStart,
+ Window1dStart: k.Window1dStart,
+ Window7dStart: k.Window7dStart,
+ User: UserFromServiceShallow(k.User),
+ Group: GroupFromServiceShallow(k.Group),
}
if k.Window5hStart != nil && !service.IsWindowExpired(k.Window5hStart, service.RateLimitWindow5h) {
t := k.Window5hStart.Add(service.RateLimitWindow5h)
diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go
index cef84465ef..06dce2beaa 100644
--- a/backend/internal/handler/dto/types.go
+++ b/backend/internal/handler/dto/types.go
@@ -63,6 +63,8 @@ type APIKey struct {
ExpiresAt *time.Time `json:"expires_at"` // Expiration time (nil = never expires)
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
+ // CurrentConcurrency is the real-time active request count for this API key.
+ CurrentConcurrency int `json:"current_concurrency"`
// Rate limit fields
RateLimit5h float64 `json:"rate_limit_5h"`
diff --git a/backend/internal/handler/gateway_helper.go b/backend/internal/handler/gateway_helper.go
index b948ac8fc7..48110da93f 100644
--- a/backend/internal/handler/gateway_helper.go
+++ b/backend/internal/handler/gateway_helper.go
@@ -10,6 +10,7 @@ import (
"sync"
"time"
+ middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
@@ -211,6 +212,14 @@ func (h *ConcurrencyHelper) TryAcquireUserSlot(ctx context.Context, userID int64
return result.ReleaseFunc, true, nil
}
+func (h *ConcurrencyHelper) TryAcquireUserSlotForAPIKey(ctx context.Context, userID int64, maxConcurrency int, apiKeyID int64) (func(), bool, error) {
+ releaseFunc, acquired, err := h.TryAcquireUserSlot(ctx, userID, maxConcurrency)
+ if err != nil || !acquired {
+ return releaseFunc, acquired, err
+ }
+ return h.withAPIKeySlot(ctx, apiKeyID, releaseFunc), true, nil
+}
+
// TryAcquireAccountSlot 尝试立即获取账号并发槽位。
// 返回值: (releaseFunc, acquired, error)
func (h *ConcurrencyHelper) TryAcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int) (func(), bool, error) {
@@ -241,7 +250,7 @@ func (h *ConcurrencyHelper) acquireUserSlotWithWaitTimeout(c *gin.Context, userI
}
if acquired {
- return releaseFunc, nil
+ return h.withAPIKeySlotFromGin(c, releaseFunc), nil
}
queueLimit := service.CalculateMaxWait(maxConcurrency) - maxConcurrency
@@ -258,7 +267,37 @@ func (h *ConcurrencyHelper) acquireUserSlotWithWaitTimeout(c *gin.Context, userI
defer h.DecrementWaitCount(ctx, userID)
// Need to wait - handle streaming ping if needed
- return h.waitForSlotWithPingTimeout(c, "user", userID, maxConcurrency, timeout, isStream, streamStarted, false)
+ releaseFunc, err = h.waitForSlotWithPingTimeout(c, "user", userID, maxConcurrency, timeout, isStream, streamStarted, false)
+ if err != nil {
+ return nil, err
+ }
+ return h.withAPIKeySlotFromGin(c, releaseFunc), nil
+}
+
+func (h *ConcurrencyHelper) withAPIKeySlotFromGin(c *gin.Context, releaseFunc func()) func() {
+ if c == nil {
+ return releaseFunc
+ }
+ apiKey, ok := middleware2.GetAPIKeyFromContext(c)
+ if !ok || apiKey == nil {
+ return releaseFunc
+ }
+ return h.withAPIKeySlot(c.Request.Context(), apiKey.ID, releaseFunc)
+}
+
+func (h *ConcurrencyHelper) withAPIKeySlot(ctx context.Context, apiKeyID int64, releaseFunc func()) func() {
+ if h == nil || h.concurrencyService == nil || apiKeyID <= 0 {
+ return releaseFunc
+ }
+ apiKeyReleaseFunc := h.concurrencyService.TrackAPIKeySlot(ctx, apiKeyID)
+ return func() {
+ if releaseFunc != nil {
+ releaseFunc()
+ }
+ if apiKeyReleaseFunc != nil {
+ apiKeyReleaseFunc()
+ }
+ }
}
// AcquireAccountSlotWithWait acquires an account concurrency slot, waiting if necessary.
diff --git a/backend/internal/handler/gateway_helper_hotpath_test.go b/backend/internal/handler/gateway_helper_hotpath_test.go
index 65dc849683..2f0c3261c7 100644
--- a/backend/internal/handler/gateway_helper_hotpath_test.go
+++ b/backend/internal/handler/gateway_helper_hotpath_test.go
@@ -9,6 +9,7 @@ import (
"testing"
"time"
+ middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
@@ -29,6 +30,9 @@ type helperConcurrencyCacheStub struct {
waitDecrementCalls int
waitMaxWait int
waitIncrementHook func()
+ apiKeyTrackCalls int
+ apiKeyReleaseCalls int
+ apiKeyTrackIDs []int64
}
func (s *helperConcurrencyCacheStub) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) {
@@ -97,6 +101,29 @@ func (s *helperConcurrencyCacheStub) GetUserConcurrency(ctx context.Context, use
return 0, nil
}
+func (s *helperConcurrencyCacheStub) TrackAPIKeySlot(ctx context.Context, apiKeyID int64, requestID string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.apiKeyTrackCalls++
+ s.apiKeyTrackIDs = append(s.apiKeyTrackIDs, apiKeyID)
+ return nil
+}
+
+func (s *helperConcurrencyCacheStub) ReleaseAPIKeySlot(ctx context.Context, apiKeyID int64, requestID string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.apiKeyReleaseCalls++
+ return nil
+}
+
+func (s *helperConcurrencyCacheStub) GetAPIKeyConcurrencyBatch(ctx context.Context, apiKeyIDs []int64) (map[int64]int, error) {
+ out := make(map[int64]int, len(apiKeyIDs))
+ for _, apiKeyID := range apiKeyIDs {
+ out[apiKeyID] = 0
+ }
+ return out, nil
+}
+
func (s *helperConcurrencyCacheStub) IncrementWaitCount(ctx context.Context, userID int64, maxWait int) (bool, error) {
s.mu.Lock()
s.waitIncrementCalls++
@@ -266,6 +293,48 @@ func TestAcquireUserSlotWithWait_ImmediateAcquireSkipsWaitQueue(t *testing.T) {
require.Equal(t, 1, cache.userReleaseCalls)
}
+func TestAcquireUserSlotWithWait_TracksAPIKeySlot(t *testing.T) {
+ cache := &helperConcurrencyCacheStub{
+ userSeq: []bool{true},
+ }
+ concurrency := service.NewConcurrencyService(cache)
+ helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
+ c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
+ c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ID: 77})
+ streamStarted := false
+
+ release, err := helper.acquireUserSlotWithWaitTimeout(c, 202, 3, time.Second, false, &streamStarted)
+ require.NoError(t, err)
+ require.NotNil(t, release)
+ require.Equal(t, 1, cache.apiKeyTrackCalls)
+ require.Equal(t, []int64{77}, cache.apiKeyTrackIDs)
+
+ release()
+
+ require.Equal(t, 1, cache.userReleaseCalls)
+ require.Equal(t, 1, cache.apiKeyReleaseCalls)
+}
+
+func TestTryAcquireUserSlotForAPIKey_TracksAPIKeySlot(t *testing.T) {
+ cache := &helperConcurrencyCacheStub{
+ userSeq: []bool{true},
+ }
+ concurrency := service.NewConcurrencyService(cache)
+ helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
+
+ release, acquired, err := helper.TryAcquireUserSlotForAPIKey(context.Background(), 202, 3, 77)
+ require.NoError(t, err)
+ require.True(t, acquired)
+ require.NotNil(t, release)
+ require.Equal(t, 1, cache.apiKeyTrackCalls)
+ require.Equal(t, []int64{77}, cache.apiKeyTrackIDs)
+
+ release()
+
+ require.Equal(t, 1, cache.userReleaseCalls)
+ require.Equal(t, 1, cache.apiKeyReleaseCalls)
+}
+
func TestAcquireUserSlotWithWait_WaitSuccessDecrementsBeforeReturn(t *testing.T) {
cache := &helperConcurrencyCacheStub{
userSeq: []bool{false, true},
diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go
index ac346fea3f..995c29f936 100644
--- a/backend/internal/handler/openai_gateway_handler.go
+++ b/backend/internal/handler/openai_gateway_handler.go
@@ -1318,7 +1318,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
// 必须尽早注册,确保任何 early return 都能释放已获取的并发槽位。
defer releaseTurnSlots()
- userReleaseFunc, userAcquired, err := h.concurrencyHelper.TryAcquireUserSlot(ctx, subject.UserID, subject.Concurrency)
+ userReleaseFunc, userAcquired, err := h.concurrencyHelper.TryAcquireUserSlotForAPIKey(ctx, subject.UserID, subject.Concurrency, apiKey.ID)
if err != nil {
reqLog.Warn("openai.websocket_user_slot_acquire_failed", zap.Error(err))
closeOpenAIClientWS(wsConn, coderws.StatusInternalError, "failed to acquire user concurrency slot")
@@ -1333,7 +1333,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
if currentUserRelease != nil {
return true
}
- userReleaseFunc, userAcquired, err := h.concurrencyHelper.TryAcquireUserSlot(ctx, subject.UserID, subject.Concurrency)
+ userReleaseFunc, userAcquired, err := h.concurrencyHelper.TryAcquireUserSlotForAPIKey(ctx, subject.UserID, subject.Concurrency, apiKey.ID)
if err != nil {
reqLog.Warn("openai.websocket_user_slot_reacquire_failed", zap.Error(err))
closeOpenAIClientWS(wsConn, coderws.StatusInternalError, "failed to acquire user concurrency slot")
@@ -1484,7 +1484,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
// 防御式清理:避免异常路径下旧槽位覆盖导致泄漏。
releaseTurnSlots()
// 非首轮 turn 需要重新抢占并发槽位,避免长连接空闲占槽。
- userReleaseFunc, userAcquired, err := h.concurrencyHelper.TryAcquireUserSlot(ctx, subject.UserID, subject.Concurrency)
+ userReleaseFunc, userAcquired, err := h.concurrencyHelper.TryAcquireUserSlotForAPIKey(ctx, subject.UserID, subject.Concurrency, apiKey.ID)
if err != nil {
return service.NewOpenAIWSClientCloseError(coderws.StatusInternalError, "failed to acquire user concurrency slot", err)
}
diff --git a/backend/internal/repository/concurrency_cache.go b/backend/internal/repository/concurrency_cache.go
index 5e6f10062f..eb827309a3 100644
--- a/backend/internal/repository/concurrency_cache.go
+++ b/backend/internal/repository/concurrency_cache.go
@@ -27,6 +27,8 @@ const (
accountSlotKeyPrefix = "concurrency:account:"
// 格式: concurrency:user:{userID}
userSlotKeyPrefix = "concurrency:user:"
+ // 格式: concurrency:api_key:{apiKeyID}
+ apiKeySlotKeyPrefix = "concurrency:api_key:"
// 等待队列计数器格式: concurrency:wait:{userID}
waitQueueKeyPrefix = "concurrency:wait:"
// 账号级等待队列计数器格式: wait:account:{accountID}
@@ -99,6 +101,28 @@ var (
return redis.call('ZCARD', key)
`)
+ // trackSlotScript 记录 stats-only 槽位,不做并发上限判断。
+ // KEYS[1] = 有序集合键
+ // ARGV[1] = TTL(秒)
+ // ARGV[2] = requestID
+ trackSlotScript = redis.NewScript(`
+ -- Redis 3.2-4.x compat: opt into effects replication so redis.call('TIME')
+ -- replicates correctly. No-op on Redis 5.0+ (effects replication is default).
+ redis.replicate_commands()
+ local key = KEYS[1]
+ local ttl = tonumber(ARGV[1])
+ local requestID = ARGV[2]
+
+ local timeResult = redis.call('TIME')
+ local now = tonumber(timeResult[1])
+ local expireBefore = now - ttl
+
+ redis.call('ZREMRANGEBYSCORE', key, '-inf', expireBefore)
+ redis.call('ZADD', key, now, requestID)
+ redis.call('EXPIRE', key, ttl)
+ return 1
+ `)
+
// incrementWaitScript - refreshes TTL on each increment to keep queue depth accurate
// KEYS[1] = wait queue key
// ARGV[1] = maxWait
@@ -231,6 +255,10 @@ func userSlotKey(userID int64) string {
return fmt.Sprintf("%s%d", userSlotKeyPrefix, userID)
}
+func apiKeySlotKey(apiKeyID int64) string {
+ return fmt.Sprintf("%s%d", apiKeySlotKeyPrefix, apiKeyID)
+}
+
func waitQueueKey(userID int64) string {
return fmt.Sprintf("%s%d", waitQueueKeyPrefix, userID)
}
@@ -330,6 +358,54 @@ func (c *concurrencyCache) GetUserConcurrency(ctx context.Context, userID int64)
return result, nil
}
+func (c *concurrencyCache) TrackAPIKeySlot(ctx context.Context, apiKeyID int64, requestID string) error {
+ key := apiKeySlotKey(apiKeyID)
+ _, err := trackSlotScript.Run(ctx, c.rdb, []string{key}, c.slotTTLSeconds, requestID).Result()
+ return err
+}
+
+func (c *concurrencyCache) ReleaseAPIKeySlot(ctx context.Context, apiKeyID int64, requestID string) error {
+ key := apiKeySlotKey(apiKeyID)
+ return c.rdb.ZRem(ctx, key, requestID).Err()
+}
+
+func (c *concurrencyCache) GetAPIKeyConcurrencyBatch(ctx context.Context, apiKeyIDs []int64) (map[int64]int, error) {
+ if len(apiKeyIDs) == 0 {
+ return map[int64]int{}, nil
+ }
+
+ now, err := c.rdb.Time(ctx).Result()
+ if err != nil {
+ return nil, fmt.Errorf("redis TIME: %w", err)
+ }
+ cutoffTime := now.Unix() - int64(c.slotTTLSeconds)
+
+ pipe := c.rdb.Pipeline()
+ type apiKeyCmd struct {
+ apiKeyID int64
+ zcardCmd *redis.IntCmd
+ }
+ cmds := make([]apiKeyCmd, 0, len(apiKeyIDs))
+ for _, apiKeyID := range apiKeyIDs {
+ slotKey := apiKeySlotKeyPrefix + strconv.FormatInt(apiKeyID, 10)
+ pipe.ZRemRangeByScore(ctx, slotKey, "-inf", strconv.FormatInt(cutoffTime, 10))
+ cmds = append(cmds, apiKeyCmd{
+ apiKeyID: apiKeyID,
+ zcardCmd: pipe.ZCard(ctx, slotKey),
+ })
+ }
+
+ if _, err := pipe.Exec(ctx); err != nil && !errors.Is(err, redis.Nil) {
+ return nil, fmt.Errorf("pipeline exec: %w", err)
+ }
+
+ result := make(map[int64]int, len(apiKeyIDs))
+ for _, cmd := range cmds {
+ result[cmd.apiKeyID] = int(cmd.zcardCmd.Val())
+ }
+ return result, nil
+}
+
// Wait queue operations
func (c *concurrencyCache) IncrementWaitCount(ctx context.Context, userID int64, maxWait int) (bool, error) {
@@ -509,7 +585,7 @@ func (c *concurrencyCache) CleanupStaleProcessSlots(ctx context.Context, activeR
}
// 1. 清理有序集合中非当前进程前缀的成员
- slotPatterns := []string{accountSlotKeyPrefix + "*", userSlotKeyPrefix + "*"}
+ slotPatterns := []string{accountSlotKeyPrefix + "*", userSlotKeyPrefix + "*", apiKeySlotKeyPrefix + "*"}
for _, pattern := range slotPatterns {
if err := c.cleanupSlotsByPattern(ctx, pattern, activeRequestPrefix); err != nil {
return err
diff --git a/backend/internal/repository/concurrency_cache_integration_test.go b/backend/internal/repository/concurrency_cache_integration_test.go
index 5da94fc258..bcc5249c84 100644
--- a/backend/internal/repository/concurrency_cache_integration_test.go
+++ b/backend/internal/repository/concurrency_cache_integration_test.go
@@ -3,6 +3,7 @@
package repository
import (
+ "context"
"errors"
"fmt"
"testing"
@@ -34,6 +35,18 @@ func (s *ConcurrencyCacheSuite) SetupTest() {
s.cache = NewConcurrencyCache(s.rdb, testSlotTTLMinutes, int(testSlotTTL.Seconds()))
}
+type apiKeyConcurrencyCacheForTest interface {
+ TrackAPIKeySlot(ctx context.Context, apiKeyID int64, requestID string) error
+ ReleaseAPIKeySlot(ctx context.Context, apiKeyID int64, requestID string) error
+ GetAPIKeyConcurrencyBatch(ctx context.Context, apiKeyIDs []int64) (map[int64]int, error)
+}
+
+func (s *ConcurrencyCacheSuite) apiKeyConcurrencyCache() apiKeyConcurrencyCacheForTest {
+ cache, ok := s.cache.(apiKeyConcurrencyCacheForTest)
+ require.True(s.T(), ok)
+ return cache
+}
+
func (s *ConcurrencyCacheSuite) TestAccountSlot_AcquireAndRelease() {
accountID := int64(10)
reqID1, reqID2, reqID3 := "req1", "req2", "req3"
@@ -160,6 +173,34 @@ func (s *ConcurrencyCacheSuite) TestUserSlot_TTL() {
s.AssertTTLWithin(ttl, 1*time.Second, testSlotTTL)
}
+func (s *ConcurrencyCacheSuite) TestAPIKeySlot_TrackReleaseAndBatchCount() {
+ cache := s.apiKeyConcurrencyCache()
+ apiKeyID := int64(300)
+ emptyAPIKeyID := int64(301)
+ slotKey := fmt.Sprintf("%s%d", apiKeySlotKeyPrefix, apiKeyID)
+
+ require.NoError(s.T(), cache.TrackAPIKeySlot(s.ctx, apiKeyID, "req1"))
+ require.NoError(s.T(), cache.TrackAPIKeySlot(s.ctx, apiKeyID, "req2"))
+
+ counts, err := cache.GetAPIKeyConcurrencyBatch(s.ctx, []int64{apiKeyID, emptyAPIKeyID})
+ require.NoError(s.T(), err)
+ require.Equal(s.T(), map[int64]int{apiKeyID: 2, emptyAPIKeyID: 0}, counts)
+
+ ttl, err := s.rdb.TTL(s.ctx, slotKey).Result()
+ require.NoError(s.T(), err, "TTL")
+ s.AssertTTLWithin(ttl, 1*time.Second, testSlotTTL)
+
+ require.NoError(s.T(), cache.ReleaseAPIKeySlot(s.ctx, apiKeyID, "req1"))
+ counts, err = cache.GetAPIKeyConcurrencyBatch(s.ctx, []int64{apiKeyID})
+ require.NoError(s.T(), err)
+ require.Equal(s.T(), 1, counts[apiKeyID])
+
+ require.NoError(s.T(), cache.ReleaseAPIKeySlot(s.ctx, apiKeyID, "req2"))
+ counts, err = cache.GetAPIKeyConcurrencyBatch(s.ctx, []int64{apiKeyID})
+ require.NoError(s.T(), err)
+ require.Equal(s.T(), 0, counts[apiKeyID])
+}
+
func (s *ConcurrencyCacheSuite) TestWaitQueue_IncrementAndDecrement() {
userID := int64(20)
waitKey := fmt.Sprintf("%s%d", waitQueueKeyPrefix, userID)
@@ -254,8 +295,10 @@ func (s *ConcurrencyCacheSuite) TestAccountWaitQueue_IncrementAndDecrement() {
func (s *ConcurrencyCacheSuite) TestCleanupStaleProcessSlots() {
accountID := int64(901)
userID := int64(902)
+ apiKeyID := int64(903)
accountKey := fmt.Sprintf("%s%d", accountSlotKeyPrefix, accountID)
userKey := fmt.Sprintf("%s%d", userSlotKeyPrefix, userID)
+ apiKeyKey := fmt.Sprintf("%s%d", apiKeySlotKeyPrefix, apiKeyID)
userWaitKey := fmt.Sprintf("%s%d", waitQueueKeyPrefix, userID)
accountWaitKey := fmt.Sprintf("%s%d", accountWaitKeyPrefix, accountID)
@@ -268,6 +311,10 @@ func (s *ConcurrencyCacheSuite) TestCleanupStaleProcessSlots() {
redis.Z{Score: float64(now), Member: "oldproc-2"},
redis.Z{Score: float64(now), Member: "keep-2"},
).Err())
+ require.NoError(s.T(), s.rdb.ZAdd(s.ctx, apiKeyKey,
+ redis.Z{Score: float64(now), Member: "oldproc-3"},
+ redis.Z{Score: float64(now), Member: "keep-3"},
+ ).Err())
require.NoError(s.T(), s.rdb.Set(s.ctx, userWaitKey, 3, time.Minute).Err())
require.NoError(s.T(), s.rdb.Set(s.ctx, accountWaitKey, 2, time.Minute).Err())
@@ -281,6 +328,10 @@ func (s *ConcurrencyCacheSuite) TestCleanupStaleProcessSlots() {
require.NoError(s.T(), err)
require.Equal(s.T(), []string{"keep-2"}, userMembers)
+ apiKeyMembers, err := s.rdb.ZRange(s.ctx, apiKeyKey, 0, -1).Result()
+ require.NoError(s.T(), err)
+ require.Equal(s.T(), []string{"keep-3"}, apiKeyMembers)
+
_, err = s.rdb.Get(s.ctx, userWaitKey).Result()
require.True(s.T(), errors.Is(err, redis.Nil))
diff --git a/backend/internal/service/api_key.go b/backend/internal/service/api_key.go
index ec20b0a9bf..dfc3ec1c5a 100644
--- a/backend/internal/service/api_key.go
+++ b/backend/internal/service/api_key.go
@@ -44,6 +44,7 @@ type APIKey struct {
UpdatedAt time.Time
User *User
Group *Group
+ CurrentConcurrency int
// Quota fields
Quota float64 // Quota limit in USD (0 = unlimited)
diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go
index de9b908dc7..8903be65ee 100644
--- a/backend/internal/service/api_key_service.go
+++ b/backend/internal/service/api_key_service.go
@@ -203,6 +203,7 @@ type APIKeyService struct {
userGroupRateRepo UserGroupRateRepository
cache APIKeyCache
rateLimitCacheInvalid RateLimitCacheInvalidator // optional: invalidate Redis rate limit cache
+ concurrencyService *ConcurrencyService
cfg *config.Config
authCacheL1 *ristretto.Cache
authCfg apiKeyAuthCacheConfig
@@ -240,6 +241,10 @@ func (s *APIKeyService) SetRateLimitCacheInvalidator(inv RateLimitCacheInvalidat
s.rateLimitCacheInvalid = inv
}
+func (s *APIKeyService) SetConcurrencyService(concurrencyService *ConcurrencyService) {
+ s.concurrencyService = concurrencyService
+}
+
func (s *APIKeyService) compileAPIKeyIPRules(apiKey *APIKey) {
if apiKey == nil {
return
@@ -436,9 +441,40 @@ func (s *APIKeyService) List(ctx context.Context, userID int64, params paginatio
if err != nil {
return nil, nil, fmt.Errorf("list api keys: %w", err)
}
+ s.fillCurrentConcurrency(ctx, keys)
return keys, pagination, nil
}
+func (s *APIKeyService) fillCurrentConcurrency(ctx context.Context, keys []APIKey) {
+ if s == nil || s.concurrencyService == nil || len(keys) == 0 {
+ return
+ }
+ ids := make([]int64, 0, len(keys))
+ for i := range keys {
+ if keys[i].ID > 0 {
+ ids = append(ids, keys[i].ID)
+ }
+ }
+ counts, err := s.concurrencyService.GetAPIKeyConcurrencyBatch(ctx, ids)
+ if err != nil {
+ return
+ }
+ for i := range keys {
+ keys[i].CurrentConcurrency = counts[keys[i].ID]
+ }
+}
+
+func (s *APIKeyService) currentConcurrencyForAPIKey(ctx context.Context, apiKeyID int64) int {
+ if s == nil || s.concurrencyService == nil || apiKeyID <= 0 {
+ return 0
+ }
+ counts, err := s.concurrencyService.GetAPIKeyConcurrencyBatch(ctx, []int64{apiKeyID})
+ if err != nil {
+ return 0
+ }
+ return counts[apiKeyID]
+}
+
func (s *APIKeyService) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
if len(apiKeyIDs) == 0 {
return []int64{}, nil
@@ -458,6 +494,9 @@ func (s *APIKeyService) GetByID(ctx context.Context, id int64) (*APIKey, error)
return nil, fmt.Errorf("get api key: %w", err)
}
s.compileAPIKeyIPRules(apiKey)
+ if apiKey != nil {
+ apiKey.CurrentConcurrency = s.currentConcurrencyForAPIKey(ctx, apiKey.ID)
+ }
return apiKey, nil
}
diff --git a/backend/internal/service/api_key_service_delete_test.go b/backend/internal/service/api_key_service_delete_test.go
index 8664c03bd7..25ad1edb15 100644
--- a/backend/internal/service/api_key_service_delete_test.go
+++ b/backend/internal/service/api_key_service_delete_test.go
@@ -300,6 +300,40 @@ func TestApiKeyService_Delete_NotFound(t *testing.T) {
require.Empty(t, cache.deleteAuthKeys)
}
+func TestAPIKeyService_List_FillsCurrentConcurrency(t *testing.T) {
+ repo := &apiKeyRepoStub{
+ allowListByUserID: true,
+ listByUserIDKeys: []APIKey{
+ {ID: 10, UserID: 7, Key: "sk-10", Name: "key-10"},
+ {ID: 11, UserID: 7, Key: "sk-11", Name: "key-11"},
+ },
+ }
+ concurrency := NewConcurrencyService(&stubConcurrencyCacheForTest{
+ apiKeyConcurrency: map[int64]int{10: 2, 11: 0},
+ })
+ svc := &APIKeyService{apiKeyRepo: repo, concurrencyService: concurrency}
+
+ keys, _, err := svc.List(context.Background(), 7, pagination.PaginationParams{Page: 1, PageSize: 20}, APIKeyListFilters{})
+ require.NoError(t, err)
+ require.Len(t, keys, 2)
+ require.Equal(t, 2, keys[0].CurrentConcurrency)
+ require.Equal(t, 0, keys[1].CurrentConcurrency)
+}
+
+func TestAPIKeyService_GetByID_FillsCurrentConcurrency(t *testing.T) {
+ repo := &apiKeyRepoStub{
+ apiKey: &APIKey{ID: 10, UserID: 7, Key: "sk-10", Name: "key-10"},
+ }
+ concurrency := NewConcurrencyService(&stubConcurrencyCacheForTest{
+ apiKeyConcurrency: map[int64]int{10: 4},
+ })
+ svc := &APIKeyService{apiKeyRepo: repo, concurrencyService: concurrency}
+
+ key, err := svc.GetByID(context.Background(), 10)
+ require.NoError(t, err)
+ require.Equal(t, 4, key.CurrentConcurrency)
+}
+
// TestApiKeyService_Delete_DeleteFails 测试删除操作失败时的错误处理。
// 预期行为:
// - GetKeyAndOwnerID 返回正确的所有者 ID
diff --git a/backend/internal/service/concurrency_service.go b/backend/internal/service/concurrency_service.go
index 712fc1a749..49b34b2457 100644
--- a/backend/internal/service/concurrency_service.go
+++ b/backend/internal/service/concurrency_service.go
@@ -52,6 +52,12 @@ type ConcurrencyCache interface {
CleanupStaleProcessSlots(ctx context.Context, activeRequestPrefix string) error
}
+type APIKeyConcurrencyCache interface {
+ TrackAPIKeySlot(ctx context.Context, apiKeyID int64, requestID string) error
+ ReleaseAPIKeySlot(ctx context.Context, apiKeyID int64, requestID string) error
+ GetAPIKeyConcurrencyBatch(ctx context.Context, apiKeyIDs []int64) (map[int64]int, error)
+}
+
var (
requestIDPrefix = initRequestIDPrefix()
requestIDCounter atomic.Uint64
@@ -89,6 +95,8 @@ const (
defaultAccountLoadBatchCacheTTL = 200 * time.Millisecond
accountLoadBatchFetchTimeout = 3 * time.Second
maxAccountLoadBatchCacheEntries = 256
+ apiKeyConcurrencyFetchTimeout = 3 * time.Second
+ apiKeySlotTrackTimeout = 2 * time.Second
)
// ConcurrencyService 管理账号和用户的并发限制。
@@ -237,6 +245,77 @@ func (s *ConcurrencyService) AcquireUserSlot(ctx context.Context, userID int64,
}, nil
}
+// TrackAPIKeySlot records one active request slot for an API key without
+// applying key-level concurrency limits. It is fail-open: Redis errors are
+// logged and return a no-op release function.
+func (s *ConcurrencyService) TrackAPIKeySlot(ctx context.Context, apiKeyID int64) func() {
+ if s == nil || s.cache == nil || apiKeyID <= 0 {
+ return func() {}
+ }
+ cache, ok := s.cache.(APIKeyConcurrencyCache)
+ if !ok {
+ return func() {}
+ }
+
+ requestID := generateRequestID()
+ baseCtx := context.Background()
+ if ctx != nil {
+ baseCtx = context.WithoutCancel(ctx)
+ }
+ trackCtx, cancel := context.WithTimeout(baseCtx, apiKeySlotTrackTimeout)
+ err := cache.TrackAPIKeySlot(trackCtx, apiKeyID, requestID)
+ cancel()
+ if err != nil {
+ logger.LegacyPrintf("service.concurrency", "Warning: failed to track api key slot for %d (req=%s): %v", apiKeyID, requestID, err)
+ return func() {}
+ }
+
+ return func() {
+ bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := cache.ReleaseAPIKeySlot(bgCtx, apiKeyID, requestID); err != nil {
+ logger.LegacyPrintf("service.concurrency", "Warning: failed to release api key slot for %d (req=%s): %v", apiKeyID, requestID, err)
+ }
+ }
+}
+
+// GetAPIKeyConcurrencyBatch gets real-time active request counts for API keys.
+// Stats are best-effort: missing Redis support or Redis errors return zeroes.
+func (s *ConcurrencyService) GetAPIKeyConcurrencyBatch(ctx context.Context, apiKeyIDs []int64) (map[int64]int, error) {
+ result := zeroAPIKeyConcurrencyMap(apiKeyIDs)
+ if len(apiKeyIDs) == 0 {
+ return result, nil
+ }
+ if s == nil || s.cache == nil {
+ return result, nil
+ }
+ cache, ok := s.cache.(APIKeyConcurrencyCache)
+ if !ok {
+ return result, nil
+ }
+
+ redisCtx, cancel := context.WithTimeout(context.Background(), apiKeyConcurrencyFetchTimeout)
+ defer cancel()
+
+ counts, err := cache.GetAPIKeyConcurrencyBatch(redisCtx, apiKeyIDs)
+ if err != nil {
+ logger.LegacyPrintf("service.concurrency", "Warning: get api key concurrency batch failed: %v", err)
+ return result, nil
+ }
+ for _, apiKeyID := range apiKeyIDs {
+ result[apiKeyID] = counts[apiKeyID]
+ }
+ return result, nil
+}
+
+func zeroAPIKeyConcurrencyMap(apiKeyIDs []int64) map[int64]int {
+ result := make(map[int64]int, len(apiKeyIDs))
+ for _, apiKeyID := range apiKeyIDs {
+ result[apiKeyID] = 0
+ }
+ return result
+}
+
// ============================================
// Wait Queue Count Methods
// ============================================
diff --git a/backend/internal/service/concurrency_service_test.go b/backend/internal/service/concurrency_service_test.go
index 7d5f501dc5..504ec1ee48 100644
--- a/backend/internal/service/concurrency_service_test.go
+++ b/backend/internal/service/concurrency_service_test.go
@@ -16,25 +16,33 @@ import (
// 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
+ 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
+ apiKeyTrackErr error
+ apiKeyReleaseErr error
+ apiKeyConcurrency map[int64]int
+ apiKeyConcurrencyErr error
// 记录调用
- releasedAccountIDs []int64
- releasedRequestIDs []string
- loadBatchCalls atomic.Int64
+ releasedAccountIDs []int64
+ releasedRequestIDs []string
+ loadBatchCalls atomic.Int64
+ trackedAPIKeyIDs []int64
+ trackedAPIKeyRequestIDs []string
+ releasedAPIKeyIDs []int64
+ releasedAPIKeyRequestIDs []string
}
var _ ConcurrencyCache = (*stubConcurrencyCacheForTest)(nil)
@@ -78,6 +86,26 @@ func (c *stubConcurrencyCacheForTest) ReleaseUserSlot(_ context.Context, _ int64
func (c *stubConcurrencyCacheForTest) GetUserConcurrency(_ context.Context, _ int64) (int, error) {
return c.concurrency, c.concurrencyErr
}
+func (c *stubConcurrencyCacheForTest) TrackAPIKeySlot(_ context.Context, apiKeyID int64, requestID string) error {
+ c.trackedAPIKeyIDs = append(c.trackedAPIKeyIDs, apiKeyID)
+ c.trackedAPIKeyRequestIDs = append(c.trackedAPIKeyRequestIDs, requestID)
+ return c.apiKeyTrackErr
+}
+func (c *stubConcurrencyCacheForTest) ReleaseAPIKeySlot(_ context.Context, apiKeyID int64, requestID string) error {
+ c.releasedAPIKeyIDs = append(c.releasedAPIKeyIDs, apiKeyID)
+ c.releasedAPIKeyRequestIDs = append(c.releasedAPIKeyRequestIDs, requestID)
+ return c.apiKeyReleaseErr
+}
+func (c *stubConcurrencyCacheForTest) GetAPIKeyConcurrencyBatch(_ context.Context, apiKeyIDs []int64) (map[int64]int, error) {
+ if c.apiKeyConcurrencyErr != nil {
+ return nil, c.apiKeyConcurrencyErr
+ }
+ result := make(map[int64]int, len(apiKeyIDs))
+ for _, apiKeyID := range apiKeyIDs {
+ result[apiKeyID] = c.apiKeyConcurrency[apiKeyID]
+ }
+ return result, nil
+}
func (c *stubConcurrencyCacheForTest) IncrementWaitCount(_ context.Context, _ int64, _ int) (bool, error) {
return c.waitAllowed, c.waitErr
}
@@ -197,6 +225,62 @@ func TestAcquireUserSlot_UnlimitedConcurrency(t *testing.T) {
require.True(t, result.Acquired)
}
+func TestTrackAPIKeySlot_ReleaseDecrements(t *testing.T) {
+ cache := &stubConcurrencyCacheForTest{}
+ svc := NewConcurrencyService(cache)
+
+ release := svc.TrackAPIKeySlot(context.Background(), 88)
+ require.NotNil(t, release)
+ require.Equal(t, []int64{88}, cache.trackedAPIKeyIDs)
+ require.Len(t, cache.trackedAPIKeyRequestIDs, 1)
+ require.NotEmpty(t, cache.trackedAPIKeyRequestIDs[0])
+
+ release()
+
+ require.Equal(t, []int64{88}, cache.releasedAPIKeyIDs)
+ require.Equal(t, cache.trackedAPIKeyRequestIDs, cache.releasedAPIKeyRequestIDs)
+}
+
+func TestTrackAPIKeySlot_FailOpen(t *testing.T) {
+ cache := &stubConcurrencyCacheForTest{apiKeyTrackErr: errors.New("redis down")}
+ svc := NewConcurrencyService(cache)
+
+ release := svc.TrackAPIKeySlot(context.Background(), 88)
+ require.NotNil(t, release)
+ require.Equal(t, []int64{88}, cache.trackedAPIKeyIDs)
+
+ require.NotPanics(t, release)
+ require.Empty(t, cache.releasedAPIKeyIDs)
+}
+
+func TestGetAPIKeyConcurrencyBatch_Fallbacks(t *testing.T) {
+ t.Run("nil cache returns zeroes", func(t *testing.T) {
+ svc := &ConcurrencyService{cache: nil}
+
+ counts, err := svc.GetAPIKeyConcurrencyBatch(context.Background(), []int64{1, 2})
+ require.NoError(t, err)
+ require.Equal(t, map[int64]int{1: 0, 2: 0}, counts)
+ })
+
+ t.Run("redis error returns zeroes", func(t *testing.T) {
+ cache := &stubConcurrencyCacheForTest{apiKeyConcurrencyErr: errors.New("redis down")}
+ svc := NewConcurrencyService(cache)
+
+ counts, err := svc.GetAPIKeyConcurrencyBatch(context.Background(), []int64{1, 2})
+ require.NoError(t, err)
+ require.Equal(t, map[int64]int{1: 0, 2: 0}, counts)
+ })
+
+ t.Run("success returns counts", func(t *testing.T) {
+ cache := &stubConcurrencyCacheForTest{apiKeyConcurrency: map[int64]int{1: 3, 2: 0}}
+ svc := NewConcurrencyService(cache)
+
+ counts, err := svc.GetAPIKeyConcurrencyBatch(context.Background(), []int64{1, 2})
+ require.NoError(t, err)
+ require.Equal(t, map[int64]int{1: 3, 2: 0}, counts)
+ })
+}
+
func TestGenerateRequestID_UsesStablePrefixAndMonotonicCounter(t *testing.T) {
id1 := generateRequestID()
id2 := generateRequestID()
diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go
index 7278a4e0c5..908783eb88 100644
--- a/backend/internal/service/wire.go
+++ b/backend/internal/service/wire.go
@@ -537,9 +537,11 @@ func ProvideAPIKeyService(
cache APIKeyCache,
cfg *config.Config,
billingCacheService *BillingCacheService,
+ concurrencyService *ConcurrencyService,
) *APIKeyService {
svc := NewAPIKeyService(apiKeyRepo, userRepo, groupRepo, userSubRepo, userGroupRateRepo, cache, cfg)
svc.SetRateLimitCacheInvalidator(billingCacheService)
+ svc.SetConcurrencyService(concurrencyService)
return svc
}
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 3469d3fff2..5a2e1d1a12 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -743,6 +743,7 @@ export default {
deleteConfirmMessage: "Are you sure you want to delete '{name}'? This action cannot be undone.",
apiKey: 'API Key',
group: 'Group',
+ currentConcurrency: 'Current Concurrency',
noGroup: 'No group',
searchGroup: 'Search groups...',
noGroupFound: 'No groups found',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 3fa1e1371d..a6cb37bf95 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -742,6 +742,7 @@ export default {
deleteConfirmMessage: "确定要删除 '{name}' 吗?此操作无法撤销。",
apiKey: 'API 密钥',
group: '分组',
+ currentConcurrency: '当前并发',
noGroup: '无分组',
searchGroup: '搜索分组...',
noGroupFound: '未找到匹配的分组',
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 9e185b0fc2..8b74679e68 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -577,6 +577,7 @@ export interface ApiKey {
expires_at: string | null // Expiration time (null = never expires)
created_at: string
updated_at: string
+ current_concurrency: number
group?: Group
rate_limit_5h: number
rate_limit_1d: number
diff --git a/frontend/src/views/user/KeysView.vue b/frontend/src/views/user/KeysView.vue
index 598e88ed96..a704e0ae2f 100644
--- a/frontend/src/views/user/KeysView.vue
+++ b/frontend/src/views/user/KeysView.vue
@@ -166,6 +166,19 @@
+
+
+ {{ value ?? 0 }}
+
+
+
@@ -1135,6 +1148,7 @@ const allColumns = computed
(() => [
{ key: 'name', label: t('common.name'), sortable: true },
{ key: 'key', label: t('keys.apiKey'), sortable: false },
{ key: 'group', label: t('keys.group'), sortable: false },
+ { key: 'current_concurrency', label: t('keys.currentConcurrency'), sortable: false },
{ key: 'usage', label: t('keys.usage'), sortable: false },
{ key: 'rate_limit', label: t('keys.rateLimitColumn'), sortable: false },
{ key: 'expires_at', label: t('keys.expiresAt'), sortable: true },
diff --git a/frontend/src/views/user/__tests__/KeysView.spec.ts b/frontend/src/views/user/__tests__/KeysView.spec.ts
index 4f671ad427..2417cd9e5c 100644
--- a/frontend/src/views/user/__tests__/KeysView.spec.ts
+++ b/frontend/src/views/user/__tests__/KeysView.spec.ts
@@ -42,6 +42,7 @@ const messages: Record = {
'keys.created': 'Created',
'keys.expiresAt': 'Expires',
'keys.group': 'Group',
+ 'keys.currentConcurrency': 'Current Concurrency',
'keys.lastUsedAt': 'Last Used',
'keys.rateLimitColumn': 'Rate Limit',
'keys.searchPlaceholder': 'Search name or key...',
@@ -117,6 +118,7 @@ const createApiKey = (): ApiKey => ({
expires_at: null,
created_at: '2026-06-27T00:00:00Z',
updated_at: '2026-06-27T00:00:00Z',
+ current_concurrency: 3,
rate_limit_5h: 0,
rate_limit_1d: 0,
rate_limit_7d: 0,
@@ -154,6 +156,9 @@ const DataTableStub = {
{{ columns.map((col) => col.key).join(',') }}
@@ -251,6 +256,7 @@ describe('user KeysView column settings', () => {
'name',
'key',
'group',
+ 'current_concurrency',
'usage',
'expires_at',
'status',
@@ -282,6 +288,7 @@ describe('user KeysView column settings', () => {
expect(visibleColumnKeys(wrapper)).toEqual([
'name',
'key',
+ 'current_concurrency',
'usage',
'rate_limit',
'expires_at',
@@ -299,8 +306,15 @@ describe('user KeysView column settings', () => {
const columnMenuText = wrapper.text()
expect(columnMenuText).toContain('API Key')
+ expect(columnMenuText).toContain('Current Concurrency')
expect(columnMenuText).toContain('Rate Limit')
expect(columnMenuText).not.toContain('Name')
expect(columnMenuText).not.toContain('Actions')
})
+
+ it('renders the current concurrency value', async () => {
+ const wrapper = await mountView()
+
+ expect(wrapper.get('[data-test="current-concurrency"]').text()).toBe('3')
+ })
})
From fa70a7217cc0fceb645752f2dcbbaf61de0ff224 Mon Sep 17 00:00:00 2001
From: "Bestony@Homelab"
Date: Thu, 2 Jul 2026 16:12:24 +0800
Subject: [PATCH 02/16] test(keys): update api key contract concurrency field
---
backend/internal/server/api_contract_test.go | 2 ++
1 file changed, 2 insertions(+)
diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go
index 533e0b404a..ce04c190f8 100644
--- a/backend/internal/server/api_contract_test.go
+++ b/backend/internal/server/api_contract_test.go
@@ -233,6 +233,7 @@ func TestAPIContracts(t *testing.T) {
"ip_whitelist": null,
"ip_blacklist": null,
"last_used_at": null,
+ "current_concurrency": 0,
"quota": 0,
"quota_used": 0,
"rate_limit_5h": 0,
@@ -282,6 +283,7 @@ func TestAPIContracts(t *testing.T) {
"ip_whitelist": null,
"ip_blacklist": null,
"last_used_at": null,
+ "current_concurrency": 0,
"quota": 0,
"quota_used": 0,
"rate_limit_5h": 0,
From b408edf97b0e7780ff1960ffb46f48edd28ac823 Mon Sep 17 00:00:00 2001
From: wucm667
Date: Mon, 6 Jul 2026 10:56:43 +0800
Subject: [PATCH 03/16] fix(payment): convert subscription CNY pay amount
---
.../service/payment_fulfillment_test.go | 12 +++----
backend/internal/service/payment_order.go | 24 +++++++++++--
.../service/payment_order_result_test.go | 28 ++++++++++-----
frontend/src/views/user/PaymentView.vue | 13 ++++---
.../views/user/__tests__/PaymentView.spec.ts | 35 +++++++++----------
5 files changed, 73 insertions(+), 39 deletions(-)
diff --git a/backend/internal/service/payment_fulfillment_test.go b/backend/internal/service/payment_fulfillment_test.go
index b46d6a1fc8..a8c78d713c 100644
--- a/backend/internal/service/payment_fulfillment_test.go
+++ b/backend/internal/service/payment_fulfillment_test.go
@@ -602,8 +602,8 @@ func TestExecuteSubscriptionFulfillmentAppliesAffiliateRebate(t *testing.T) {
SetUserID(user.ID).
SetUserEmail(user.Email).
SetUserName(user.Username).
- SetAmount(120).
- SetPayAmount(120).
+ SetAmount(9.99).
+ SetPayAmount(71.36).
SetFeeRate(0).
SetRechargeCode("PAY-SUB-AFFILIATE").
SetOutTradeNo("sub2_subscription_affiliate").
@@ -636,7 +636,7 @@ func TestExecuteSubscriptionFulfillmentAppliesAffiliateRebate(t *testing.T) {
}
settingSvc := NewSettingService(&paymentFulfillmentSettingRepoStub{values: map[string]string{
SettingKeyAffiliateEnabled: "true",
- SettingKeyAffiliateRebateRate: "20",
+ SettingKeyAffiliateRebateRate: "15",
SettingKeyAffiliateRebateFreezeHours: "0",
}}, nil)
subRepo := newSubscriptionUserSubRepoStub()
@@ -659,7 +659,7 @@ func TestExecuteSubscriptionFulfillmentAppliesAffiliateRebate(t *testing.T) {
require.Len(t, affiliateRepo.accrueCalls, 1)
require.Equal(t, inviterID, affiliateRepo.accrueCalls[0].inviterID)
require.Equal(t, user.ID, affiliateRepo.accrueCalls[0].inviteeUserID)
- require.Equal(t, 24.0, affiliateRepo.accrueCalls[0].amount)
+ require.InDelta(t, 1.4985, affiliateRepo.accrueCalls[0].amount, 0.00000001)
require.NotNil(t, affiliateRepo.accrueCalls[0].sourceOrderID)
require.Equal(t, order.ID, *affiliateRepo.accrueCalls[0].sourceOrderID)
require.Equal(t, 1, subRepo.createCalls)
@@ -668,8 +668,8 @@ func TestExecuteSubscriptionFulfillmentAppliesAffiliateRebate(t *testing.T) {
Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("AFFILIATE_REBATE_APPLIED")).
Only(ctx)
require.NoError(t, err)
- require.Contains(t, applied.Detail, `"baseAmount":120`)
- require.Contains(t, applied.Detail, `"rebateAmount":24`)
+ require.Contains(t, applied.Detail, `"baseAmount":9.99`)
+ require.Contains(t, applied.Detail, `"rebateAmount":1.4985`)
}
func TestExecuteSubscriptionFulfillmentDoesNotDuplicateWorkAfterLegacySuccessAudit(t *testing.T) {
diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go
index 154159b932..7f4bcf7c2d 100644
--- a/backend/internal/service/payment_order.go
+++ b/backend/internal/service/payment_order.go
@@ -16,6 +16,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/Wei-Shaw/sub2api/internal/payment/provider"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/shopspring/decimal"
)
// --- Order Creation ---
@@ -67,8 +68,7 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
return nil, err
}
}
- // 订阅套餐 price 是直付价,余额充值倍率只影响余额充值到账,不参与订阅 pay_amount 计算。
- payAmountStr, payAmount, err := calculateCreateOrderPayAmount(limitAmount, feeRate, methodCurrency)
+ payAmountStr, payAmount, err := calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate, methodCurrency, req.OrderType, cfg.BalanceRechargeMultiplier)
if err != nil {
return nil, err
}
@@ -84,7 +84,7 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
selectedCurrency = paymentProviderConfigCurrency(sel.ProviderKey, sel.Config)
}
if selectedCurrency != methodCurrency {
- payAmountStr, payAmount, err = calculateCreateOrderPayAmount(limitAmount, feeRate, selectedCurrency)
+ payAmountStr, payAmount, err = calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate, selectedCurrency, req.OrderType, cfg.BalanceRechargeMultiplier)
if err != nil {
return nil, err
}
@@ -630,6 +630,24 @@ func calculateCreateOrderPayAmount(limitAmount, feeRate float64, currency string
return payAmountStr, payAmount, nil
}
+func calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate float64, currency, orderType string, multiplier float64) (string, float64, error) {
+ paymentAmount := limitAmount
+ if orderType == payment.OrderTypeSubscription {
+ paymentAmount = calculateSubscriptionGatewayBaseAmount(limitAmount, multiplier, currency)
+ }
+ return calculateCreateOrderPayAmount(paymentAmount, feeRate, currency)
+}
+
+func calculateSubscriptionGatewayBaseAmount(amount, multiplier float64, currency string) float64 {
+ if currency != payment.DefaultPaymentCurrency {
+ return amount
+ }
+ return decimal.NewFromFloat(amount).
+ Div(decimal.NewFromFloat(normalizeBalanceRechargeMultiplier(multiplier))).
+ Round(int32(payment.CurrencyMaxFractionDigits(currency))).
+ InexactFloat64()
+}
+
func validateCreateOrderAmountCurrency(amount float64, currency string) error {
amountStr := strconv.FormatFloat(amount, 'f', -1, 64)
if _, err := payment.AmountToMinorUnit(amountStr, currency); err != nil {
diff --git a/backend/internal/service/payment_order_result_test.go b/backend/internal/service/payment_order_result_test.go
index 14192bd6cd..930643d3e0 100644
--- a/backend/internal/service/payment_order_result_test.go
+++ b/backend/internal/service/payment_order_result_test.go
@@ -161,27 +161,39 @@ func TestCalculateCreateOrderPayAmountUsesCurrencyPrecision(t *testing.T) {
}
}
-func TestCalculateCreateOrderPayAmountForSubscriptionKeepsDirectPrice(t *testing.T) {
+func TestCalculateCreateOrderPayAmountForSubscriptionConvertsCNYPrice(t *testing.T) {
t.Parallel()
- amountStr, amount, err := calculateCreateOrderPayAmount(5, 0, "CNY")
+ amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 0, "CNY", payment.OrderTypeSubscription, 0.14)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- if amountStr != "5.00" || amount != 5 {
- t.Fatalf("subscription CNY pay amount = (%q, %v), want (5.00, 5)", amountStr, amount)
+ if amountStr != "71.36" || amount != 71.36 {
+ t.Fatalf("subscription CNY pay amount = (%q, %v), want (71.36, 71.36)", amountStr, amount)
}
}
-func TestCalculateCreateOrderPayAmountForSubscriptionAppliesFeeToDirectPrice(t *testing.T) {
+func TestCalculateCreateOrderPayAmountForSubscriptionAppliesFeeAfterCNYConversion(t *testing.T) {
t.Parallel()
- amountStr, amount, err := calculateCreateOrderPayAmount(5, 2.5, "CNY")
+ amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 2.5, "CNY", payment.OrderTypeSubscription, 0.14)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- if amountStr != "5.13" || amount != 5.13 {
- t.Fatalf("subscription CNY pay amount with fee = (%q, %v), want (5.13, 5.13)", amountStr, amount)
+ if amountStr != "73.15" || amount != 73.15 {
+ t.Fatalf("subscription CNY pay amount with fee = (%q, %v), want (73.15, 73.15)", amountStr, amount)
+ }
+}
+
+func TestCalculateCreateOrderPayAmountForSubscriptionKeepsNonCNYPrice(t *testing.T) {
+ t.Parallel()
+
+ amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 0, "USD", payment.OrderTypeSubscription, 0.14)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if amountStr != "9.99" || amount != 9.99 {
+ t.Fatalf("subscription USD pay amount = (%q, %v), want (9.99, 9.99)", amountStr, amount)
}
}
diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue
index e3901ea0af..2443e5873b 100644
--- a/frontend/src/views/user/PaymentView.vue
+++ b/frontend/src/views/user/PaymentView.vue
@@ -283,7 +283,7 @@ import { platformAccentBarClass, platformBadgeLightClass, platformBadgeClass, pl
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
import Icon from '@/components/icons/Icon.vue'
-import { formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
+import { DEFAULT_PAYMENT_CURRENCY, formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
import { buildPaymentErrorToastMessage, describePaymentScenarioError } from './paymentUx'
import { hasWechatResumeQuery, parseWechatResumeRoute, stripWechatResumeQuery } from './paymentWechatResume'
@@ -579,12 +579,17 @@ function ceilPaymentAmount(value: number, currency: string): number {
return Math.ceil(value * factor) / factor
}
+function subscriptionPaymentAmountForCurrency(value: number, currency: string): number {
+ if (currency !== DEFAULT_PAYMENT_CURRENCY) return roundPaymentAmount(value, currency)
+ return roundPaymentAmount(value / balanceRechargeMultiplier.value, currency)
+}
+
function formatSelectedPaymentAmount(value: number): string {
return formatPaymentAmount(value, selectedCurrency.value, localeCode.value)
}
function formatSelectedSubscriptionPaymentAmount(value: number): string {
- return formatSelectedPaymentAmount(roundPaymentAmount(value, selectedCurrency.value))
+ return formatSelectedPaymentAmount(subscriptionPaymentAmountForCurrency(value, selectedCurrency.value))
}
const methodOptions = computed(() =>
@@ -633,7 +638,7 @@ const canSubmit = computed(() =>
const subPaymentAmount = computed(() => {
const price = selectedPlan.value?.price ?? 0
- return roundPaymentAmount(price, selectedCurrency.value)
+ return subscriptionPaymentAmountForCurrency(price, selectedCurrency.value)
})
const subFeeAmount = computed(() => {
@@ -647,7 +652,7 @@ const subTotalAmount = computed(() => {
})
function subscriptionTotalAmountForCurrency(value: number, currency: string): number {
- const paymentAmount = roundPaymentAmount(value, currency)
+ const paymentAmount = subscriptionPaymentAmountForCurrency(value, currency)
if (feeRate.value <= 0 || paymentAmount <= 0) return paymentAmount
const fee = ceilPaymentAmount((paymentAmount * feeRate.value) / 100, currency)
return roundPaymentAmount(paymentAmount + fee, currency)
diff --git a/frontend/src/views/user/__tests__/PaymentView.spec.ts b/frontend/src/views/user/__tests__/PaymentView.spec.ts
index 3b16d42af1..6dbae1f538 100644
--- a/frontend/src/views/user/__tests__/PaymentView.spec.ts
+++ b/frontend/src/views/user/__tests__/PaymentView.spec.ts
@@ -236,29 +236,28 @@ async function mountSubscriptionConfirm(options: Parameters {
- it('keeps subscription plan price independent from balance recharge multiplier', async () => {
+ it('shows converted CNY pay amount for plan price, original price, and create button', async () => {
const wrapper = await mountSubscriptionConfirm({
checkout: {
- balance_recharge_multiplier: 4,
+ balance_recharge_multiplier: 0.14,
},
method: {
currency: 'CNY',
},
plan: {
- price: 200,
- original_price: 300,
+ price: 9.99,
+ original_price: 12.99,
},
})
const text = wrapper.text()
- const planPrice = formatPaymentAmount(200, 'CNY')
- const originalPrice = formatPaymentAmount(300, 'CNY')
- const convertedByRechargeMultiplier = formatPaymentAmount(50, 'CNY')
+ const convertedPrice = formatPaymentAmount(71.36, 'CNY')
+ const convertedOriginalPrice = formatPaymentAmount(92.79, 'CNY')
- expect(text).toContain(planPrice)
- expect(text).toContain(originalPrice)
- expect(text).not.toContain(convertedByRechargeMultiplier)
- expect(wrapper.findAll('button').some(button => button.text().includes(planPrice))).toBe(true)
+ expect(text).toContain(convertedPrice)
+ expect(text).toContain(convertedOriginalPrice)
+ expect(text).not.toContain(formatPaymentAmount(9.99, 'CNY'))
+ expect(wrapper.findAll('button').some(button => button.text().includes(convertedPrice))).toBe(true)
})
it('keeps plan price when multiplier is not configured or payment currency is not CNY', async () => {
@@ -294,26 +293,26 @@ describe('PaymentView subscription confirmation amounts', () => {
expect(usdWrapper.text()).toContain(formatPaymentAmount(9.99, 'USD'))
})
- it('adds fee rate to the direct subscription plan price to match backend pay_amount', async () => {
+ it('adds fee rate after CNY multiplier conversion to match backend pay_amount', async () => {
const wrapper = await mountSubscriptionConfirm({
checkout: {
- balance_recharge_multiplier: 4,
+ balance_recharge_multiplier: 0.14,
recharge_fee_rate: 2.5,
},
method: {
currency: 'CNY',
},
plan: {
- price: 7.99,
+ price: 9.99,
},
})
const text = wrapper.text()
- const price = formatPaymentAmount(7.99, 'CNY')
- const fee = formatPaymentAmount(0.20, 'CNY')
- const total = formatPaymentAmount(8.19, 'CNY')
+ const convertedPrice = formatPaymentAmount(71.36, 'CNY')
+ const fee = formatPaymentAmount(1.79, 'CNY')
+ const total = formatPaymentAmount(73.15, 'CNY')
- expect(text).toContain(price)
+ expect(text).toContain(convertedPrice)
expect(text).toContain(fee)
expect(text).toContain(total)
expect(wrapper.findAll('button').some(button => button.text().includes(total))).toBe(true)
From 0fd2e9216d296b67054fab70d07c3bf36cada679 Mon Sep 17 00:00:00 2001
From: shaw
Date: Mon, 6 Jul 2026 11:43:16 +0800
Subject: [PATCH 04/16] =?UTF-8?q?fix(scheduler):=20=E4=BF=AE=E5=A4=8D=20Op?=
=?UTF-8?q?enAI=20=E9=AB=98=E7=BA=A7=E8=B0=83=E5=BA=A6=E5=99=A8=E5=AE=A1?=
=?UTF-8?q?=E8=AE=A1=E5=8F=91=E7=8E=B0=E7=9A=84=E6=AD=A3=E7=A1=AE=E6=80=A7?=
=?UTF-8?q?=E4=B8=8E=E6=80=A7=E8=83=BD=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
针对 #3692 合并后审计发现的问题集中修复:
- previous_response_id 剥离条件改为按 call_id 全覆盖校验,
部分可重建的工具续链不再被误剥离(不受开关门控的行为回归)
- 粘性加权回退路径补分组归属校验并清理失效绑定,杜绝跨分组账号泄漏
- 账号列表页:无 OpenAI 账号时跳过分数计算、过滤池限定 openai 平台、
负载批查合并为账号并集一次查询,消除全表扫描与 Redis N+1
- 订阅优先模式下常规池不可用时回退订阅池等待计划,
busy-but-waitable 的订阅账号不再导致请求硬失败
- TopK/权重 DB 覆盖显式受总开关门控,与兄弟子开关语义一致
- 前端未分组 OpenAI 账号回退展示基础分,不再显示 "-"
- ListAllWithFilters 等能力正式进入 AccountRepository/AdminService 接口,
移除匿名接口断言与静默降级;负载批查失败补 warn 日志
- SelectAccountWithSchedulerForCapability 增加显式 previousResponseCanMove
参数,移除 "previous_response_can_move" 魔法字符串哨兵
- 设置写入路径补"基础权重不得全为零"聚合校验;
运行时设置批量读取失败的降级路径覆盖全部键并留痕
---
.../internal/handler/admin/account_handler.go | 173 +++++++++-----
backend/internal/handler/grok_media.go | 1 +
.../handler/openai_chat_completions.go | 1 +
backend/internal/handler/openai_embeddings.go | 1 +
.../handler/openai_gateway_count_tokens.go | 1 +
.../handler/openai_gateway_handler.go | 13 +-
backend/internal/server/api_contract_test.go | 4 +
backend/internal/service/account_service.go | 3 +
.../service/account_service_delete_test.go | 4 +
backend/internal/service/admin_service.go | 14 +-
.../service/admin_service_bulk_update_test.go | 4 +
.../service/admin_service_search_test.go | 4 +
.../service/gateway_multiplatform_test.go | 3 +
.../service/gemini_multiplatform_test.go | 3 +
.../service/openai_account_scheduler.go | 66 ++++--
.../service/openai_account_scheduler_test.go | 145 +++++++++++-
.../service/openai_tool_continuation.go | 78 ++++++
.../service/openai_tool_continuation_test.go | 106 +++++++++
.../service/ratelimit_session_window_test.go | 3 +
backend/internal/service/setting_service.go | 30 ++-
frontend/src/views/admin/AccountsView.vue | 13 +-
.../AccountsView.schedulerScore.spec.ts | 224 ++++++++++++++++++
22 files changed, 798 insertions(+), 96 deletions(-)
create mode 100644 frontend/src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts
diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go
index efe95801c3..8c91245fbf 100644
--- a/backend/internal/handler/admin/account_handler.go
+++ b/backend/internal/handler/admin/account_handler.go
@@ -196,14 +196,6 @@ type AccountSchedulerGroupScore struct {
const accountListGroupUngroupedQueryValue = "ungrouped"
-type openAIAccountSchedulerScorePoolLister interface {
- ListOpenAISchedulableAccountsForSchedulerScore(ctx context.Context, groupID *int64) ([]service.Account, error)
-}
-
-type accountSchedulerScoreFilterPoolLister interface {
- ListAccountsForSchedulerScoreFilter(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]service.Account, error)
-}
-
func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency {
item := AccountWithConcurrency{
Account: dto.AccountFromService(account),
@@ -250,33 +242,27 @@ func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, ac
return item
}
-func (h *AccountHandler) scoreOpenAIAccountSchedulerPool(ctx context.Context, accounts []service.Account) map[int64]AccountSchedulerScore {
+// scoreOpenAIAccountSchedulerPool 对池内 OpenAI 账号计算调度分数快照。
+// loadMap 为共享的账号负载数据(含池内全部账号即可,多余条目无害);传 nil 时自行批查。
+func (h *AccountHandler) scoreOpenAIAccountSchedulerPool(ctx context.Context, accounts []service.Account, loadMap map[int64]*service.AccountLoadInfo) map[int64]AccountSchedulerScore {
if len(accounts) == 0 {
return nil
}
openAIAccounts := make([]*service.Account, 0, len(accounts))
- loadReq := make([]service.AccountWithConcurrency, 0, len(accounts))
for i := range accounts {
account := &accounts[i]
if account.Platform != service.PlatformOpenAI {
continue
}
openAIAccounts = append(openAIAccounts, account)
- loadReq = append(loadReq, service.AccountWithConcurrency{
- ID: account.ID,
- MaxConcurrency: account.EffectiveLoadFactor(),
- })
}
if len(openAIAccounts) == 0 {
return nil
}
- loadMap := map[int64]*service.AccountLoadInfo{}
- if h.concurrencyService != nil {
- if batchLoad, err := h.concurrencyService.GetAccountsLoadBatch(ctx, loadReq); err == nil && batchLoad != nil {
- loadMap = batchLoad
- }
+ if loadMap == nil {
+ loadMap = h.fetchOpenAIAccountLoadMap(ctx, openAIAccounts)
}
var scores map[int64]service.OpenAIAccountSchedulerScoreSnapshot
@@ -297,6 +283,36 @@ func (h *AccountHandler) scoreOpenAIAccountSchedulerPool(ctx context.Context, ac
return result
}
+// fetchOpenAIAccountLoadMap 一次性批查给定 OpenAI 账号的负载数据;
+// 失败时记录日志并返回空表(分数按零负载计算,属可接受降级)。
+func (h *AccountHandler) fetchOpenAIAccountLoadMap(ctx context.Context, openAIAccounts []*service.Account) map[int64]*service.AccountLoadInfo {
+ loadMap := map[int64]*service.AccountLoadInfo{}
+ if h.concurrencyService == nil || len(openAIAccounts) == 0 {
+ return loadMap
+ }
+ seen := make(map[int64]struct{}, len(openAIAccounts))
+ loadReq := make([]service.AccountWithConcurrency, 0, len(openAIAccounts))
+ for _, account := range openAIAccounts {
+ if account == nil {
+ continue
+ }
+ if _, ok := seen[account.ID]; ok {
+ continue
+ }
+ seen[account.ID] = struct{}{}
+ loadReq = append(loadReq, service.AccountWithConcurrency{
+ ID: account.ID,
+ MaxConcurrency: account.EffectiveLoadFactor(),
+ })
+ }
+ if batchLoad, err := h.concurrencyService.GetAccountsLoadBatch(ctx, loadReq); err != nil {
+ slog.Warn("openai_scheduler_score_load_batch_failed", "error", err)
+ } else if batchLoad != nil {
+ loadMap = batchLoad
+ }
+ return loadMap
+}
+
func (h *AccountHandler) buildOpenAIAccountSchedulerScores(
ctx context.Context,
accounts []service.Account,
@@ -309,12 +325,6 @@ func (h *AccountHandler) buildOpenAIAccountSchedulerScores(
filterPool = accounts
}
- baseScores := make(map[int64]*AccountSchedulerScore)
- for accountID, score := range h.scoreOpenAIAccountSchedulerPool(ctx, filterPool) {
- copiedScore := score
- baseScores[accountID] = &copiedScore
- }
-
pageOpenAIAccountIDs := make(map[int64]struct{})
groupIDs := make(map[int64]struct{})
for i := range accounts {
@@ -338,7 +348,48 @@ func (h *AccountHandler) buildOpenAIAccountSchedulerScores(
}
}
if len(pageOpenAIAccountIDs) == 0 {
- return baseScores, nil
+ return nil, nil
+ }
+
+ // 先取各分组池,再对"过滤池 ∪ 分组池"的账号并集做一次负载批查,
+ // 避免每个池各查一次 Redis 的 N+1。
+ groupIDList := make([]int64, 0, len(groupIDs))
+ for groupID := range groupIDs {
+ groupIDList = append(groupIDList, groupID)
+ }
+ sort.Slice(groupIDList, func(i, j int) bool { return groupIDList[i] < groupIDList[j] })
+
+ groupPools := make(map[int64][]service.Account, len(groupIDList))
+ if h.adminService != nil {
+ for _, groupID := range groupIDList {
+ gid := groupID
+ pool, err := h.adminService.ListOpenAISchedulableAccountsForSchedulerScore(ctx, &gid)
+ if err != nil {
+ slog.Warn("openai_scheduler_group_score_pool_failed", "group_id", gid, "error", err)
+ continue
+ }
+ groupPools[gid] = pool
+ }
+ }
+
+ loadUnion := make([]*service.Account, 0, len(filterPool))
+ collectOpenAIAccounts := func(pool []service.Account) {
+ for i := range pool {
+ if pool[i].Platform == service.PlatformOpenAI {
+ loadUnion = append(loadUnion, &pool[i])
+ }
+ }
+ }
+ collectOpenAIAccounts(filterPool)
+ for _, pool := range groupPools {
+ collectOpenAIAccounts(pool)
+ }
+ loadMap := h.fetchOpenAIAccountLoadMap(ctx, loadUnion)
+
+ baseScores := make(map[int64]*AccountSchedulerScore)
+ for accountID, score := range h.scoreOpenAIAccountSchedulerPool(ctx, filterPool, loadMap) {
+ copiedScore := score
+ baseScores[accountID] = &copiedScore
}
groupScoresByAccount := make(map[int64][]AccountSchedulerGroupScore)
@@ -346,7 +397,7 @@ func (h *AccountHandler) buildOpenAIAccountSchedulerScores(
if len(pool) == 0 {
return
}
- scores := h.scoreOpenAIAccountSchedulerPool(ctx, pool)
+ scores := h.scoreOpenAIAccountSchedulerPool(ctx, pool, loadMap)
for accountID, schedulerScore := range scores {
if _, ok := pageOpenAIAccountIDs[accountID]; !ok {
continue
@@ -365,37 +416,27 @@ func (h *AccountHandler) buildOpenAIAccountSchedulerScores(
}
}
- if lister, ok := h.adminService.(openAIAccountSchedulerScorePoolLister); ok {
- groupIDList := make([]int64, 0, len(groupIDs))
- for groupID := range groupIDs {
- groupIDList = append(groupIDList, groupID)
+ for _, groupID := range groupIDList {
+ gid := groupID
+ pool, ok := groupPools[gid]
+ if !ok {
+ continue
}
- sort.Slice(groupIDList, func(i, j int) bool { return groupIDList[i] < groupIDList[j] })
-
- for _, groupID := range groupIDList {
- gid := groupID
- pool, err := lister.ListOpenAISchedulableAccountsForSchedulerScore(ctx, &gid)
- if err != nil {
- slog.Warn("openai_scheduler_group_score_pool_failed", "group_id", gid, "error", err)
- continue
- }
- groupNameByID := make(map[int64]string)
- groupPriorityByAccount := make(map[int64]int)
- for i := range pool {
- account := &pool[i]
- for _, accountGroup := range account.AccountGroups {
- if accountGroup.GroupID != gid {
- continue
- }
- groupPriorityByAccount[account.ID] = accountGroup.Priority
- if accountGroup.Group != nil {
- groupNameByID[gid] = accountGroup.Group.Name
- }
+ groupNameByID := make(map[int64]string)
+ groupPriorityByAccount := make(map[int64]int)
+ for i := range pool {
+ account := &pool[i]
+ for _, accountGroup := range account.AccountGroups {
+ if accountGroup.GroupID != gid {
+ continue
+ }
+ groupPriorityByAccount[account.ID] = accountGroup.Priority
+ if accountGroup.Group != nil {
+ groupNameByID[gid] = accountGroup.Group.Name
}
}
- scoreGroupPool(&gid, groupNameByID, groupPriorityByAccount, pool)
}
-
+ scoreGroupPool(&gid, groupNameByID, groupPriorityByAccount, pool)
}
for accountID := range groupScoresByAccount {
@@ -417,11 +458,9 @@ func (h *AccountHandler) listAccountSchedulerScoreFilterPool(
if h.adminService == nil || (platform != "" && platform != service.PlatformOpenAI) {
return nil
}
- lister, ok := h.adminService.(accountSchedulerScoreFilterPoolLister)
- if !ok {
- return nil
- }
- accounts, err := lister.ListAccountsForSchedulerScoreFilter(ctx, platform, accountType, status, search, groupID, privacyMode)
+ // 池只用于 OpenAI 分数计算(非 OpenAI 账号会在打分时被丢弃),
+ // 无论列表页平台过滤为何,查询一律限定 openai,避免无过滤时全表扫描。
+ accounts, err := h.adminService.ListAccountsForSchedulerScoreFilter(ctx, service.PlatformOpenAI, accountType, status, search, groupID, privacyMode)
if err != nil {
slog.Warn("openai_scheduler_filter_score_pool_failed", "error", err)
return nil
@@ -481,8 +520,20 @@ func (h *AccountHandler) List(c *gin.Context) {
var windowCosts map[int64]float64
var activeSessions map[int64]int
var rpmCounts map[int64]int
- schedulerFilterPool := h.listAccountSchedulerScoreFilterPool(c.Request.Context(), platform, accountType, status, search, groupID, privacyMode)
- schedulerScores, schedulerGroupScores := h.buildOpenAIAccountSchedulerScores(c.Request.Context(), accounts, schedulerFilterPool)
+ // 仅当前页存在 OpenAI 账号时才计算调度分数,避免为空结果付出池查询开销。
+ var schedulerScores map[int64]*AccountSchedulerScore
+ var schedulerGroupScores map[int64][]AccountSchedulerGroupScore
+ pageHasOpenAIAccounts := false
+ for i := range accounts {
+ if accounts[i].Platform == service.PlatformOpenAI {
+ pageHasOpenAIAccounts = true
+ break
+ }
+ }
+ if pageHasOpenAIAccounts {
+ schedulerFilterPool := h.listAccountSchedulerScoreFilterPool(c.Request.Context(), platform, accountType, status, search, groupID, privacyMode)
+ schedulerScores, schedulerGroupScores = h.buildOpenAIAccountSchedulerScores(c.Request.Context(), accounts, schedulerFilterPool)
+ }
// 始终获取并发数(Redis ZCARD,极低开销)
if h.concurrencyService != nil {
diff --git a/backend/internal/handler/grok_media.go b/backend/internal/handler/grok_media.go
index 8e236ea49f..4fd1411b23 100644
--- a/backend/internal/handler/grok_media.go
+++ b/backend/internal/handler/grok_media.go
@@ -174,6 +174,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
service.OpenAIUpstreamTransportHTTPSSE,
"",
false,
+ false,
service.PlatformGrok,
)
if err != nil {
diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go
index ca43a2cff3..baff1dcbd6 100644
--- a/backend/internal/handler/openai_chat_completions.go
+++ b/backend/internal/handler/openai_chat_completions.go
@@ -145,6 +145,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
service.OpenAIUpstreamTransportAny,
service.OpenAIEndpointCapabilityChatCompletions,
false,
+ false,
requestPlatform,
)
if err != nil {
diff --git a/backend/internal/handler/openai_embeddings.go b/backend/internal/handler/openai_embeddings.go
index a80c7f7d96..8be533c723 100644
--- a/backend/internal/handler/openai_embeddings.go
+++ b/backend/internal/handler/openai_embeddings.go
@@ -117,6 +117,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
service.OpenAIUpstreamTransportHTTPSSE,
service.OpenAIEndpointCapabilityEmbeddings,
false,
+ false,
)
if err != nil {
reqLog.Warn("openai_embeddings.account_select_failed",
diff --git a/backend/internal/handler/openai_gateway_count_tokens.go b/backend/internal/handler/openai_gateway_count_tokens.go
index ec530e8ab6..fc9c4d5df7 100644
--- a/backend/internal/handler/openai_gateway_count_tokens.go
+++ b/backend/internal/handler/openai_gateway_count_tokens.go
@@ -110,6 +110,7 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) {
service.OpenAIUpstreamTransportAny,
service.OpenAIEndpointCapabilityChatCompletions,
false,
+ false,
openAICompatibleRequestPlatform(apiKey),
)
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go
index ccafde7b02..e177edb43d 100644
--- a/backend/internal/handler/openai_gateway_handler.go
+++ b/backend/internal/handler/openai_gateway_handler.go
@@ -350,6 +350,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
service.OpenAIUpstreamTransportAny,
service.OpenAIEndpointCapabilityChatCompletions,
requireCompact,
+ false,
requestPlatform,
)
if err != nil {
@@ -783,6 +784,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
service.OpenAIUpstreamTransportAny,
service.OpenAIEndpointCapabilityChatCompletions,
false,
+ false,
requestPlatform,
)
if err != nil {
@@ -1266,8 +1268,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "previous_response_id must be a response.id (resp_*), not a message id")
return
}
- firstMessageToolContext := service.ValidateFunctionCallOutputContextBytes(firstMessage)
- previousResponseCanMove := !firstMessageToolContext.HasFunctionCallOutput || firstMessageToolContext.HasToolCallContext
+ firstMessageToolCoverage := service.AnalyzeToolCallOutputContextCoverageBytes(firstMessage)
+ previousResponseCanMove := !firstMessageToolCoverage.HasFunctionCallOutput || firstMessageToolCoverage.ContextCoversAllCallIDs
reqLog = reqLog.With(
zap.Bool("ws_ingress", true),
zap.String("model", reqModel),
@@ -1383,13 +1385,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
requiredTransport,
service.OpenAIEndpointCapabilityChatCompletions,
false,
+ previousResponseCanMove,
requestPlatform,
- func() string {
- if previousResponseCanMove {
- return "previous_response_can_move"
- }
- return ""
- }(),
)
if err != nil {
reqLog.Warn("openai.websocket_account_select_failed",
diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go
index dfa480dd18..9b3f2dcdd1 100644
--- a/backend/internal/server/api_contract_test.go
+++ b/backend/internal/server/api_contract_test.go
@@ -1736,6 +1736,10 @@ func (s *stubAccountRepo) List(ctx context.Context, params pagination.Pagination
return nil, nil, errors.New("not implemented")
}
+func (s *stubAccountRepo) ListAllWithFilters(context.Context, string, string, string, string, int64, string) ([]service.Account, error) {
+ return nil, nil
+}
+
func (s *stubAccountRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64, privacyMode string) ([]service.Account, *pagination.PaginationResult, error) {
return nil, nil, errors.New("not implemented")
}
diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go
index dcba614c2c..5956684f98 100644
--- a/backend/internal/service/account_service.go
+++ b/backend/internal/service/account_service.go
@@ -39,6 +39,9 @@ type AccountRepository interface {
List(ctx context.Context, params pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error)
ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, *pagination.PaginationResult, error)
+ // ListAllWithFilters 返回符合过滤条件的全部账号(不分页),用于账号列表页
+ // 计算 OpenAI 调度分数的过滤范围池。
+ ListAllWithFilters(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, error)
ListByGroup(ctx context.Context, groupID int64) ([]Account, error)
ListActive(ctx context.Context) ([]Account, error)
ListOAuthRefreshCandidates(ctx context.Context) ([]Account, error)
diff --git a/backend/internal/service/account_service_delete_test.go b/backend/internal/service/account_service_delete_test.go
index a304356c09..ee6163239e 100644
--- a/backend/internal/service/account_service_delete_test.go
+++ b/backend/internal/service/account_service_delete_test.go
@@ -79,6 +79,10 @@ func (s *accountRepoStub) List(ctx context.Context, params pagination.Pagination
panic("unexpected List call")
}
+func (s *accountRepoStub) ListAllWithFilters(context.Context, string, string, string, string, int64, string) ([]Account, error) {
+ return nil, nil
+}
+
func (s *accountRepoStub) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, *pagination.PaginationResult, error) {
panic("unexpected ListWithFilters call")
}
diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go
index ce59c34475..ebf1e7e404 100644
--- a/backend/internal/service/admin_service.go
+++ b/backend/internal/service/admin_service.go
@@ -78,6 +78,12 @@ type AdminService interface {
// Account management
ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64, privacyMode string, sortBy, sortOrder string) ([]Account, int64, error)
+ // ListAccountsForSchedulerScoreFilter 返回符合过滤条件的全部账号(不分页),
+ // 作为账号列表页计算 OpenAI 调度分数的过滤范围池。
+ ListAccountsForSchedulerScoreFilter(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, error)
+ // ListOpenAISchedulableAccountsForSchedulerScore 返回指定分组(nil 为未分组)内
+ // 可调度的 OpenAI 账号,用于按组计算调度分数。
+ ListOpenAISchedulableAccountsForSchedulerScore(ctx context.Context, groupID *int64) ([]Account, error)
GetAccount(ctx context.Context, id int64) (*Account, error)
GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error)
CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error)
@@ -2622,13 +2628,7 @@ func (s *adminServiceImpl) ListAccountsForSchedulerScoreFilter(ctx context.Conte
if s == nil || s.accountRepo == nil {
return nil, nil
}
- lister, ok := s.accountRepo.(interface {
- ListAllWithFilters(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, error)
- })
- if !ok {
- return nil, nil
- }
- return lister.ListAllWithFilters(ctx, platform, accountType, status, search, groupID, privacyMode)
+ return s.accountRepo.ListAllWithFilters(ctx, platform, accountType, status, search, groupID, privacyMode)
}
func (s *adminServiceImpl) ListOpenAISchedulableAccountsForSchedulerScore(ctx context.Context, groupID *int64) ([]Account, error) {
diff --git a/backend/internal/service/admin_service_bulk_update_test.go b/backend/internal/service/admin_service_bulk_update_test.go
index df415295b1..2f44b1741d 100644
--- a/backend/internal/service/admin_service_bulk_update_test.go
+++ b/backend/internal/service/admin_service_bulk_update_test.go
@@ -88,6 +88,10 @@ func (s *accountRepoStubForBulkUpdate) ListByGroup(_ context.Context, groupID in
return nil, nil
}
+func (s *accountRepoStubForBulkUpdate) ListAllWithFilters(context.Context, string, string, string, string, int64, string) ([]Account, error) {
+ return nil, nil
+}
+
func (s *accountRepoStubForBulkUpdate) ListWithFilters(_ context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, *pagination.PaginationResult, error) {
s.listCalled = true
s.lastListParams = params
diff --git a/backend/internal/service/admin_service_search_test.go b/backend/internal/service/admin_service_search_test.go
index 595e99e344..76acd1b5e2 100644
--- a/backend/internal/service/admin_service_search_test.go
+++ b/backend/internal/service/admin_service_search_test.go
@@ -25,6 +25,10 @@ type accountRepoStubForAdminList struct {
listWithFiltersErr error
}
+func (s *accountRepoStubForAdminList) ListAllWithFilters(context.Context, string, string, string, string, int64, string) ([]Account, error) {
+ return nil, nil
+}
+
func (s *accountRepoStubForAdminList) ListWithFilters(_ context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, *pagination.PaginationResult, error) {
s.listWithFiltersCalls++
s.listWithFiltersParams = params
diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go
index f843ba3e45..35a60c8124 100644
--- a/backend/internal/service/gateway_multiplatform_test.go
+++ b/backend/internal/service/gateway_multiplatform_test.go
@@ -95,6 +95,9 @@ func (m *mockAccountRepoForPlatform) List(ctx context.Context, params pagination
func (m *mockAccountRepoForPlatform) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, *pagination.PaginationResult, error) {
return nil, nil, nil
}
+func (m *mockAccountRepoForPlatform) ListAllWithFilters(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, error) {
+ return nil, nil
+}
func (m *mockAccountRepoForPlatform) ListByGroup(ctx context.Context, groupID int64) ([]Account, error) {
return nil, nil
}
diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go
index c021e88edf..7d5ed0ec9e 100644
--- a/backend/internal/service/gemini_multiplatform_test.go
+++ b/backend/internal/service/gemini_multiplatform_test.go
@@ -82,6 +82,9 @@ func (m *mockAccountRepoForGemini) List(ctx context.Context, params pagination.P
func (m *mockAccountRepoForGemini) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, *pagination.PaginationResult, error) {
return nil, nil, nil
}
+func (m *mockAccountRepoForGemini) ListAllWithFilters(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, error) {
+ return nil, nil
+}
func (m *mockAccountRepoForGemini) ListByGroup(ctx context.Context, groupID int64) ([]Account, error) {
return nil, nil
}
diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go
index dd65163abc..a0a4fbff3e 100644
--- a/backend/internal/service/openai_account_scheduler.go
+++ b/backend/internal/service/openai_account_scheduler.go
@@ -1037,6 +1037,14 @@ func (s *defaultOpenAIAccountScheduler) tryFallbackToWeightedSticky(
if account == nil || !s.isAccountRequestCompatible(ctx, account, req) || !s.isAccountTransportCompatible(account, req.RequiredTransport) {
continue
}
+ // 粘性绑定只证明绑定时账号在分组内;账号被移出分组后绑定仍会在 TTL 内存活,
+ // 必须与 selectBySessionHash 一样重验分组归属,否则会把分组流量泄漏到组外账号。
+ if !openAIStickyAccountMatchesGroup(account, req.GroupID) {
+ if accountID == req.StickyAccountID && strings.TrimSpace(req.SessionHash) != "" {
+ _ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, req.SessionHash)
+ }
+ continue
+ }
if req.RequireCompact && openAICompactSupportTier(account) == 0 {
continue
}
@@ -1145,13 +1153,29 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance(
}
if len(regularAccounts) > 0 {
regularAttempt := s.trySelectByLoadBalancePool(ctx, req, regularAccounts, loadMap)
- if regularAttempt.err != nil {
+ if regularAttempt.err != nil && !regularAttempt.noCompactCandidates {
return nil, regularAttempt.candidateCount, regularAttempt.topK, regularAttempt.loadSkew, regularAttempt.err
}
if regularAttempt.result != nil {
return regularAttempt.result, regularAttempt.candidateCount, regularAttempt.topK, regularAttempt.loadSkew, nil
}
- return s.finishLoadBalanceSelectionFallback(ctx, req, regularAttempt)
+ var result *AccountSelectionResult
+ candidateCount, topK, loadSkew := regularAttempt.candidateCount, regularAttempt.topK, regularAttempt.loadSkew
+ fallbackErr := regularAttempt.err
+ if regularAttempt.err == nil {
+ result, candidateCount, topK, loadSkew, fallbackErr = s.finishLoadBalanceSelectionFallback(ctx, req, regularAttempt)
+ if fallbackErr == nil && result != nil {
+ return result, candidateCount, topK, loadSkew, nil
+ }
+ }
+ // 常规池既无法获取也无法排队(含仅剩不支持 compact 的候选)时,
+ // 回退到订阅池的等待计划:busy-but-waitable 的订阅账号不应因常规池存在
+ // 而被丢弃,否则开启订阅优先反而让本可排队成功的请求硬失败。
+ subResult, subCandidateCount, subTopK, subLoadSkew, subErr := s.finishLoadBalanceSelectionFallback(ctx, req, attempt)
+ if subErr == nil && subResult != nil {
+ return subResult, subCandidateCount, subTopK, subLoadSkew, nil
+ }
+ return result, candidateCount, topK, loadSkew, fallbackErr
}
return s.finishLoadBalanceSelectionFallback(ctx, req, attempt)
}
@@ -1464,15 +1488,20 @@ func (s *OpenAIGatewayService) openAIAdvancedSchedulerRuntimeSettings(ctx contex
lbTopKOverride = parsePositiveIntOverride(values[SettingKeyOpenAIAdvancedSchedulerLBTopK])
weightOverrides = parseOpenAIAdvancedSchedulerWeightOverrides(values)
} else {
- if value, err := repo.GetValue(dbCtx, openAIAdvancedSchedulerSettingKey); err == nil {
- enabled = strings.EqualFold(strings.TrimSpace(value), "true")
- }
- if value, err := repo.GetValue(dbCtx, SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled); err == nil {
- stickyWeightedEnabled = strings.EqualFold(strings.TrimSpace(value), "true")
- }
- if value, err := repo.GetValue(dbCtx, SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled); err == nil {
- subscriptionPriorityEnabled = strings.EqualFold(strings.TrimSpace(value), "true")
+ // 批量读取失败时逐键降级,覆盖全部键(含 TopK/权重),避免只加载布尔开关
+ // 而静默丢弃管理员配置的覆盖值;降级状态会被缓存一个 TTL,必须留痕。
+ slog.Warn("openai_advanced_scheduler_settings_batch_load_failed", "error", err)
+ fallbackValues := make(map[string]string)
+ for _, key := range openAIAdvancedSchedulerRuntimeSettingKeys() {
+ if value, valueErr := repo.GetValue(dbCtx, key); valueErr == nil {
+ fallbackValues[key] = value
+ }
}
+ enabled = strings.EqualFold(strings.TrimSpace(fallbackValues[openAIAdvancedSchedulerSettingKey]), "true")
+ stickyWeightedEnabled = strings.EqualFold(strings.TrimSpace(fallbackValues[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled]), "true")
+ subscriptionPriorityEnabled = strings.EqualFold(strings.TrimSpace(fallbackValues[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled]), "true")
+ lbTopKOverride = parsePositiveIntOverride(fallbackValues[SettingKeyOpenAIAdvancedSchedulerLBTopK])
+ weightOverrides = parseOpenAIAdvancedSchedulerWeightOverrides(fallbackValues)
}
}
@@ -1618,6 +1647,9 @@ func (s *OpenAIGatewayService) SelectAccountWithScheduler(
return s.selectAccountWithScheduler(ctx, groupID, previousResponseID, sessionHash, requestedModel, excludedIDs, requiredTransport, "", "", requireCompact, PlatformOpenAI, false)
}
+// SelectAccountWithSchedulerForCapability 按能力要求调度账号。
+// previousResponseCanMove 表示首包 input 可自行重建工具续链,previous_response_id 允许跨账号迁移
+// (粘性加权模式下改为加权偏好而非硬粘连)。
func (s *OpenAIGatewayService) SelectAccountWithSchedulerForCapability(
ctx context.Context,
groupID *int64,
@@ -1628,16 +1660,13 @@ func (s *OpenAIGatewayService) SelectAccountWithSchedulerForCapability(
requiredTransport OpenAIUpstreamTransport,
requiredCapability OpenAIEndpointCapability,
requireCompact bool,
+ previousResponseCanMove bool,
platformOverride ...string,
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
platform := PlatformOpenAI
- previousResponseCanMove := false
if len(platformOverride) > 0 {
platform = platformOverride[0]
}
- if len(platformOverride) > 1 {
- previousResponseCanMove = strings.EqualFold(platformOverride[1], "previous_response_can_move")
- }
return s.selectAccountWithScheduler(ctx, groupID, previousResponseID, sessionHash, requestedModel, excludedIDs, requiredTransport, requiredCapability, "", requireCompact, platform, previousResponseCanMove)
}
@@ -1853,6 +1882,11 @@ func (s *OpenAIGatewayService) openAIWSLBTopK() int {
func (s *OpenAIGatewayService) openAIWSLBTopKForRequest(ctx context.Context) int {
base := s.openAIWSLBTopK()
settings := s.openAIAdvancedSchedulerRuntimeSettings(ctx)
+ // DB 覆盖值与 stickyWeighted/subscriptionPriority 一样受总开关门控:
+ // 关闭高级调度器后所有调用方(含管理页分数快照)都应回到配置/默认行为。
+ if !settings.enabled {
+ return base
+ }
if settings.lbTopKOverride > 0 {
return settings.lbTopKOverride
}
@@ -1920,6 +1954,10 @@ func (s *OpenAIGatewayService) openAIWSSchedulerWeights() GatewayOpenAIWSSchedul
func (s *OpenAIGatewayService) openAIWSSchedulerWeightsForRequest(ctx context.Context) GatewayOpenAIWSSchedulerScoreWeightsView {
weights := s.openAIWSSchedulerWeights()
settings := s.openAIAdvancedSchedulerRuntimeSettings(ctx)
+ // 同 openAIWSLBTopKForRequest:总开关关闭时不应用 DB 覆盖值。
+ if !settings.enabled {
+ return weights
+ }
return applyOpenAIAdvancedSchedulerWeightOverrides(weights, settings.weightOverrides)
}
diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go
index a61a923053..2ff7c25e5d 100644
--- a/backend/internal/service/openai_account_scheduler_test.go
+++ b/backend/internal/service/openai_account_scheduler_test.go
@@ -529,6 +529,7 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_DefaultDisabled_Embeddi
OpenAIUpstreamTransportHTTPSSE,
OpenAIEndpointCapabilityEmbeddings,
false,
+ false,
)
require.NoError(t, err)
require.NotNil(t, selection)
@@ -572,6 +573,7 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_DefaultDisabled_AllowsG
OpenAIUpstreamTransportAny,
OpenAIEndpointCapabilityChatCompletions,
false,
+ false,
PlatformGrok,
)
require.NoError(t, err)
@@ -774,6 +776,7 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_StickyWeightedPreviousR
OpenAIUpstreamTransportAny,
OpenAIEndpointCapabilityChatCompletions,
false,
+ false,
PlatformOpenAI,
)
require.NoError(t, err)
@@ -796,8 +799,8 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_StickyWeightedPreviousR
OpenAIUpstreamTransportAny,
OpenAIEndpointCapabilityChatCompletions,
false,
+ true,
PlatformOpenAI,
- "previous_response_can_move",
)
require.NoError(t, err)
require.NotNil(t, selection)
@@ -938,6 +941,7 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_Enabled_EmbeddingsSkips
OpenAIUpstreamTransportHTTPSSE,
OpenAIEndpointCapabilityEmbeddings,
false,
+ false,
)
require.NoError(t, err)
require.NotNil(t, selection)
@@ -1011,6 +1015,7 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_Enabled_EmbeddingsSkips
OpenAIUpstreamTransportHTTPSSE,
OpenAIEndpointCapabilityEmbeddings,
false,
+ false,
)
require.NoError(t, err)
require.NotNil(t, selection)
@@ -2935,3 +2940,141 @@ func TestDefaultOpenAIAccountScheduler_IsAccountTransportCompatible_Branches(t *
func int64PtrForTest(v int64) *int64 {
return &v
}
+
+func TestOpenAIGatewayService_SelectAccountWithScheduler_StickyWeightedFallbackSkipsOutOfGroupStickyAccount(t *testing.T) {
+ resetOpenAIAdvancedSchedulerSettingCacheForTest()
+
+ ctx := context.Background()
+ groupID := int64(101081)
+ otherGroupID := int64(101082)
+ accounts := []Account{
+ {
+ ID: 38001,
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Status: StatusActive,
+ Schedulable: true,
+ Concurrency: 1,
+ Priority: 10,
+ GroupIDs: []int64{groupID},
+ },
+ {
+ // 会话粘连绑定指向的账号已被移出请求分组(绑定 TTL 内账号改组的场景)。
+ ID: 38002,
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Status: StatusActive,
+ Schedulable: true,
+ Concurrency: 1,
+ Priority: 0,
+ GroupIDs: []int64{otherGroupID},
+ },
+ }
+ cfg := &config.Config{}
+ cfg.Gateway.OpenAIWS.LBTopK = 2
+ cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 1
+ cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 1
+ cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0.7
+ cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0.8
+ cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0.5
+ cfg.Gateway.OpenAIWS.SchedulerScoreWeights.SessionSticky = 3
+ cache := &schedulerTestGatewayCache{sessionBindings: map[string]int64{
+ "openai:session_weighted_out_of_group": 38002,
+ }}
+ concurrencyCache := schedulerTestConcurrencyCache{
+ acquireResults: map[int64]bool{38001: false, 38002: true},
+ }
+ svc := &OpenAIGatewayService{
+ accountRepo: schedulerGroupAwareOpenAIAccountRepo{schedulerTestOpenAIAccountRepo{accounts: accounts}},
+ cache: cache,
+ cfg: cfg,
+ rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true", "true"),
+ concurrencyService: NewConcurrencyService(concurrencyCache),
+ }
+
+ selection, decision, err := svc.SelectAccountWithScheduler(
+ ctx,
+ &groupID,
+ "",
+ "session_weighted_out_of_group",
+ "gpt-5.1",
+ nil,
+ OpenAIUpstreamTransportAny,
+ false,
+ )
+ require.NoError(t, err)
+ require.NotNil(t, selection)
+ require.NotNil(t, selection.Account)
+ // 组内唯一候选 38001 满并发:必须返回其等待计划,绝不能把请求泄漏到组外的粘连账号 38002。
+ require.Equal(t, int64(38001), selection.Account.ID)
+ require.False(t, selection.Acquired)
+ require.NotNil(t, selection.WaitPlan)
+ require.Equal(t, int64(38001), selection.WaitPlan.AccountID)
+ require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer)
+ // 失效的粘连绑定应被清理,避免后续请求反复走同一条泄漏路径。
+ require.Positive(t, cache.deletedSessions["openai:session_weighted_out_of_group"])
+}
+
+func TestOpenAIGatewayService_SelectAccountWithScheduler_SubscriptionPriorityWaitsOnBusySubscriptionWhenRegularUnusable(t *testing.T) {
+ resetOpenAIAdvancedSchedulerSettingCacheForTest()
+
+ ctx := context.Background()
+ groupID := int64(101091)
+ accounts := []Account{
+ {
+ // 订阅账号:支持 compact,但并发已满(busy-but-waitable)。
+ ID: 38011,
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Status: StatusActive,
+ Schedulable: true,
+ Concurrency: 1,
+ Priority: 0,
+ GroupIDs: []int64{groupID},
+ Credentials: map[string]any{"plan_type": "team"},
+ Extra: map[string]any{"openai_compact_supported": true},
+ },
+ {
+ // 常规账号:明确不支持 compact,无法服务本次请求。
+ ID: 38012,
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Status: StatusActive,
+ Schedulable: true,
+ Concurrency: 1,
+ Priority: 9,
+ GroupIDs: []int64{groupID},
+ Extra: map[string]any{"openai_compact_supported": false},
+ },
+ }
+ concurrencyCache := schedulerTestConcurrencyCache{
+ acquireResults: map[int64]bool{38011: false, 38012: true},
+ }
+ svc := &OpenAIGatewayService{
+ accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts},
+ cache: &schedulerTestGatewayCache{},
+ cfg: newSchedulerTestSubscriptionPriorityConfig(),
+ rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true", "", "true"),
+ concurrencyService: NewConcurrencyService(concurrencyCache),
+ }
+
+ selection, decision, err := svc.SelectAccountWithScheduler(
+ ctx,
+ &groupID,
+ "",
+ "session_subscription_wait",
+ "gpt-5.1",
+ nil,
+ OpenAIUpstreamTransportAny,
+ true,
+ )
+ // 常规池无可用候选时,忙碌的订阅账号应产生等待计划,而不是直接返回 no available accounts。
+ require.NoError(t, err)
+ require.NotNil(t, selection)
+ require.NotNil(t, selection.Account)
+ require.Equal(t, int64(38011), selection.Account.ID)
+ require.False(t, selection.Acquired)
+ require.NotNil(t, selection.WaitPlan)
+ require.Equal(t, int64(38011), selection.WaitPlan.AccountID)
+ require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer)
+}
diff --git a/backend/internal/service/openai_tool_continuation.go b/backend/internal/service/openai_tool_continuation.go
index 6515c0c4e5..a507213701 100644
--- a/backend/internal/service/openai_tool_continuation.go
+++ b/backend/internal/service/openai_tool_continuation.go
@@ -215,6 +215,84 @@ func ValidateFunctionCallOutputContextBytes(body []byte) FunctionCallOutputValid
return result
}
+// ToolCallOutputContextCoverage 描述 input 中工具输出与可重建上下文的覆盖关系,
+// 用于判断剥离 previous_response_id 后上游能否仅凭 input 重建工具续链。
+type ToolCallOutputContextCoverage struct {
+ HasFunctionCallOutput bool
+ // ContextCoversAllCallIDs 表示每个工具输出的 call_id 都能在 input 内找到
+ // 同 call_id 的工具调用上下文项或同 id 的 item_reference,且不存在缺失 call_id 的输出。
+ // 任一输出无法由 input 自身重建时为 false,此时剥离 previous_response_id 会导致
+ // 上游以 "No tool call found for function call output" 拒绝请求。
+ ContextCoversAllCallIDs bool
+}
+
+// AnalyzeToolCallOutputContextCoverageBytes 全量扫描 input,按 call_id 精确匹配工具输出
+// 与可重建上下文。不能复用 ValidateFunctionCallOutputContextBytes 的 HasToolCallContext:
+// 该标志只代表"存在某一个上下文项",部分覆盖的续链仍会被上游拒绝。
+func AnalyzeToolCallOutputContextCoverageBytes(body []byte) ToolCallOutputContextCoverage {
+ coverage := ToolCallOutputContextCoverage{}
+ if len(body) == 0 {
+ return coverage
+ }
+ input := parseRawJSONView(body).Get("input")
+ if !input.IsArray() {
+ return coverage
+ }
+
+ missingCallID := false
+ var outputCallIDs map[string]struct{}
+ var contextIDs map[string]struct{}
+ input.ForEach(func(_, item gjson.Result) bool {
+ if !item.IsObject() {
+ return true
+ }
+ itemType := item.Get("type").String()
+ switch {
+ case isCodexToolCallOutputItemType(itemType):
+ coverage.HasFunctionCallOutput = true
+ callID := strings.TrimSpace(item.Get("call_id").String())
+ if callID == "" {
+ missingCallID = true
+ return true
+ }
+ if outputCallIDs == nil {
+ outputCallIDs = make(map[string]struct{})
+ }
+ outputCallIDs[callID] = struct{}{}
+ case isCodexToolCallContextItemType(itemType):
+ callID := strings.TrimSpace(item.Get("call_id").String())
+ if callID == "" {
+ return true
+ }
+ if contextIDs == nil {
+ contextIDs = make(map[string]struct{})
+ }
+ contextIDs[callID] = struct{}{}
+ case itemType == "item_reference":
+ idValue := strings.TrimSpace(item.Get("id").String())
+ if idValue == "" {
+ return true
+ }
+ if contextIDs == nil {
+ contextIDs = make(map[string]struct{})
+ }
+ contextIDs[idValue] = struct{}{}
+ }
+ return true
+ })
+
+ if !coverage.HasFunctionCallOutput || missingCallID {
+ return coverage
+ }
+ for callID := range outputCallIDs {
+ if _, ok := contextIDs[callID]; !ok {
+ return coverage
+ }
+ }
+ coverage.ContextCoversAllCallIDs = true
+ return coverage
+}
+
// ValidateFunctionCallOutputContext 为 handler 提供低开销校验结果:
// 1) 无工具输出直接返回
// 2) 若已存在工具调用上下文则提前返回
diff --git a/backend/internal/service/openai_tool_continuation_test.go b/backend/internal/service/openai_tool_continuation_test.go
index 4610652b6c..569d89eff0 100644
--- a/backend/internal/service/openai_tool_continuation_test.go
+++ b/backend/internal/service/openai_tool_continuation_test.go
@@ -184,3 +184,109 @@ func TestValidateFunctionCallOutputContextBytesMatchesMapValidation(t *testing.T
})
}
}
+
+func TestAnalyzeToolCallOutputContextCoverageBytes(t *testing.T) {
+ cases := []struct {
+ name string
+ body map[string]any
+ hasOutput bool
+ coversAllIDs bool
+ }{
+ {
+ name: "no_input",
+ body: map[string]any{"model": "gpt-5.1"},
+ hasOutput: false,
+ coversAllIDs: false,
+ },
+ {
+ name: "no_tool_output",
+ body: map[string]any{"input": []any{
+ map[string]any{"type": "message", "content": "hi"},
+ }},
+ hasOutput: false,
+ coversAllIDs: false,
+ },
+ {
+ name: "all_outputs_covered_by_context",
+ body: map[string]any{"input": []any{
+ map[string]any{"type": "function_call", "call_id": "call_a"},
+ map[string]any{"type": "function_call_output", "call_id": "call_a"},
+ }},
+ hasOutput: true,
+ coversAllIDs: true,
+ },
+ {
+ name: "all_outputs_covered_by_item_reference",
+ body: map[string]any{"input": []any{
+ map[string]any{"type": "function_call_output", "call_id": "call_a"},
+ map[string]any{"type": "item_reference", "id": "call_a"},
+ }},
+ hasOutput: true,
+ coversAllIDs: true,
+ },
+ {
+ // 关键回归用例:input 内存在某一个上下文项,但另一个输出的 call_id
+ // 只能由上游会话链(previous_response_id)解析——不可剥离。
+ name: "partial_coverage_not_movable",
+ body: map[string]any{"input": []any{
+ map[string]any{"type": "function_call", "call_id": "call_a"},
+ map[string]any{"type": "function_call_output", "call_id": "call_a"},
+ map[string]any{"type": "function_call_output", "call_id": "call_b"},
+ }},
+ hasOutput: true,
+ coversAllIDs: false,
+ },
+ {
+ name: "unrelated_context_does_not_cover",
+ body: map[string]any{"input": []any{
+ map[string]any{"type": "function_call", "call_id": "call_x"},
+ map[string]any{"type": "function_call_output", "call_id": "call_b"},
+ }},
+ hasOutput: true,
+ coversAllIDs: false,
+ },
+ {
+ name: "output_missing_call_id_not_movable",
+ body: map[string]any{"input": []any{
+ map[string]any{"type": "function_call", "call_id": "call_a"},
+ map[string]any{"type": "function_call_output"},
+ map[string]any{"type": "function_call_output", "call_id": "call_a"},
+ }},
+ hasOutput: true,
+ coversAllIDs: false,
+ },
+ {
+ name: "mixed_context_and_reference_cover_all",
+ body: map[string]any{"input": []any{
+ map[string]any{"type": "function_call", "call_id": "call_a"},
+ map[string]any{"type": "function_call_output", "call_id": "call_a"},
+ map[string]any{"type": "function_call_output", "call_id": "call_b"},
+ map[string]any{"type": "item_reference", "id": "call_b"},
+ }},
+ hasOutput: true,
+ coversAllIDs: true,
+ },
+ {
+ name: "all_codex_output_types_covered",
+ body: map[string]any{"input": []any{
+ map[string]any{"type": "tool_search_output", "call_id": "call_s"},
+ map[string]any{"type": "tool_search_call", "call_id": "call_s"},
+ map[string]any{"type": "mcp_tool_call_output", "call_id": "call_m"},
+ map[string]any{"type": "mcp_tool_call", "call_id": "call_m"},
+ }},
+ hasOutput: true,
+ coversAllIDs: true,
+ },
+ }
+
+ for _, tt := range cases {
+ t.Run(tt.name, func(t *testing.T) {
+ bodyBytes, err := json.Marshal(tt.body)
+ require.NoError(t, err)
+
+ coverage := AnalyzeToolCallOutputContextCoverageBytes(bodyBytes)
+ require.Equal(t, tt.hasOutput, coverage.HasFunctionCallOutput, "HasFunctionCallOutput")
+ require.Equal(t, tt.coversAllIDs, coverage.ContextCoversAllCallIDs, "ContextCoversAllCallIDs")
+ })
+ }
+}
diff --git a/backend/internal/service/ratelimit_session_window_test.go b/backend/internal/service/ratelimit_session_window_test.go
index cb19227e54..279a31ccdc 100644
--- a/backend/internal/service/ratelimit_session_window_test.go
+++ b/backend/internal/service/ratelimit_session_window_test.go
@@ -87,6 +87,9 @@ func (m *sessionWindowMockRepo) List(context.Context, pagination.PaginationParam
func (m *sessionWindowMockRepo) ListWithFilters(context.Context, pagination.PaginationParams, string, string, string, string, int64, string) ([]Account, *pagination.PaginationResult, error) {
panic("unexpected")
}
+func (m *sessionWindowMockRepo) ListAllWithFilters(context.Context, string, string, string, string, int64, string) ([]Account, error) {
+ panic("unexpected")
+}
func (m *sessionWindowMockRepo) ListByGroup(context.Context, int64) ([]Account, error) {
panic("unexpected")
}
diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go
index 1024243bea..3aeb611418 100644
--- a/backend/internal/service/setting_service.go
+++ b/backend/internal/service/setting_service.go
@@ -1921,7 +1921,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
if err != nil {
return nil, err
}
- if err := normalizeOpenAIAdvancedSchedulerOverrides(settings); err != nil {
+ if err := s.normalizeOpenAIAdvancedSchedulerOverrides(settings); err != nil {
return nil, err
}
settings.PaymentVisibleMethodAlipaySource = alipaySource
@@ -3940,7 +3940,7 @@ func formatOpenAIAdvancedSchedulerFloat(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64)
}
-func normalizeOpenAIAdvancedSchedulerOverrides(settings *SystemSettings) error {
+func (s *SettingService) normalizeOpenAIAdvancedSchedulerOverrides(settings *SystemSettings) error {
lbTopK, err := normalizeOptionalPositiveIntString(settings.OpenAIAdvancedSchedulerLBTopK)
if err != nil {
return infraerrors.BadRequest("INVALID_OPENAI_ADVANCED_SCHEDULER_LB_TOP_K", "openai advanced scheduler TopK must be a positive integer or empty")
@@ -3965,9 +3965,35 @@ func normalizeOpenAIAdvancedSchedulerOverrides(settings *SystemSettings) error {
}
*target = normalized
}
+
+ // 与 config.Validate 的 "scheduler_score_weights must not all be zero" 保持一致:
+ // 覆盖值(空则回退到生效的配置值)叠加后的基础权重和不允许为 0,
+ // 否则调度会静默退化为 TopK 内均匀随机。
+ effective := s.openAIAdvancedSchedulerEffectiveWeights()
+ baseSum := resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightPriority, effective.Priority) +
+ resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightLoad, effective.Load) +
+ resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightQueue, effective.Queue) +
+ resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightErrorRate, effective.ErrorRate) +
+ resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightTTFT, effective.TTFT) +
+ resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, effective.QuotaHeadroom)
+ if baseSum <= 0 {
+ return infraerrors.BadRequest("INVALID_OPENAI_ADVANCED_SCHEDULER_WEIGHT", "openai advanced scheduler base weights must not all be zero")
+ }
return nil
}
+// resolveOpenAIAdvancedSchedulerWeight 返回覆盖值(已归一化的非空字符串),空则回退默认值。
+func resolveOpenAIAdvancedSchedulerWeight(normalized string, fallback float64) float64 {
+ if normalized == "" {
+ return fallback
+ }
+ value, err := strconv.ParseFloat(normalized, 64)
+ if err != nil {
+ return fallback
+ }
+ return value
+}
+
func normalizeOptionalPositiveIntString(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue
index bd8fd6067a..b4e1630a85 100644
--- a/frontend/src/views/admin/AccountsView.vue
+++ b/frontend/src/views/admin/AccountsView.vue
@@ -679,14 +679,21 @@ const formatStickySchedulerScore = (score: AccountSchedulerGroupScore): string =
}
const getSchedulerScoreRows = (account: Account): AccountSchedulerGroupScore[] => {
- if (!Array.isArray(account.scheduler_scores)) return []
- return account.scheduler_scores.filter(score => score.group_id != null)
+ const groupRows = Array.isArray(account.scheduler_scores)
+ ? account.scheduler_scores.filter(score => score.group_id != null)
+ : []
+ if (groupRows.length) return groupRows
+ // 未分组账号没有分组维度分数,回退展示后端返回的基础分
+ if (account.scheduler_score) {
+ return [{ group_id: null, ...account.scheduler_score }]
+ }
+ return []
}
const formatSchedulerScoreGroup = (score: AccountSchedulerGroupScore): string => {
if ('group_name' in score && score.group_name) return score.group_name
if ('group_id' in score && score.group_id != null) return `#${score.group_id}`
- return '-'
+ return t('admin.accounts.schedulerScore.ungrouped')
}
const loadSavedColumns = () => {
diff --git a/frontend/src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts
new file mode 100644
index 0000000000..0865a6ec91
--- /dev/null
+++ b/frontend/src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts
@@ -0,0 +1,224 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+
+import AccountsView from '../AccountsView.vue'
+
+const {
+ listAccounts,
+ listWithEtag,
+ getBatchTodayStats,
+ getAllProxies,
+ getAllGroups
+} = vi.hoisted(() => ({
+ listAccounts: vi.fn(),
+ listWithEtag: vi.fn(),
+ getBatchTodayStats: vi.fn(),
+ getAllProxies: vi.fn(),
+ getAllGroups: vi.fn()
+}))
+
+vi.mock('@/api/admin', () => ({
+ adminAPI: {
+ accounts: {
+ list: listAccounts,
+ listWithEtag,
+ getBatchTodayStats,
+ delete: vi.fn(),
+ batchClearError: vi.fn(),
+ batchRefresh: vi.fn(),
+ toggleSchedulable: vi.fn()
+ },
+ proxies: {
+ getAll: getAllProxies
+ },
+ groups: {
+ getAll: getAllGroups
+ }
+ }
+}))
+
+vi.mock('@/stores/app', () => ({
+ useAppStore: () => ({
+ showError: vi.fn(),
+ showSuccess: vi.fn(),
+ showInfo: vi.fn()
+ })
+}))
+
+vi.mock('@/stores/auth', () => ({
+ useAuthStore: () => ({
+ token: 'test-token'
+ })
+}))
+
+vi.mock('vue-i18n', async () => {
+ const actual = await vi.importActual('vue-i18n')
+ return {
+ ...actual,
+ useI18n: () => ({
+ t: (key: string) => key
+ })
+ }
+})
+
+// Render the scheduler-score cell slot for every row so the fallback logic is observable.
+const DataTableStub = {
+ props: ['columns', 'data'],
+ template: `
+
+ `
+}
+
+function mountView() {
+ return mount(AccountsView, {
+ global: {
+ stubs: {
+ AppLayout: { template: '
' },
+ TablePageLayout: {
+ template: '
'
+ },
+ DataTable: DataTableStub,
+ HelpTooltip: true,
+ Pagination: true,
+ ConfirmDialog: true,
+ AccountTableActions: { template: '
' },
+ AccountTableFilters: { template: '' },
+ AccountBulkActionsBar: true,
+ AccountActionMenu: true,
+ ImportDataModal: true,
+ ReAuthAccountModal: true,
+ AccountTestModal: true,
+ AccountStatsModal: true,
+ ScheduledTestsPanel: true,
+ SyncFromCrsModal: true,
+ TempUnschedStatusModal: true,
+ ErrorPassthroughRulesModal: true,
+ TLSFingerprintProfilesModal: true,
+ CreateAccountModal: true,
+ EditAccountModal: true,
+ BulkEditAccountModal: true,
+ PlatformTypeBadge: true,
+ AccountCapacityCell: true,
+ AccountStatusIndicator: true,
+ AccountTodayStatsCell: true,
+ AccountGroupsCell: true,
+ AccountUsageCell: true,
+ Icon: true
+ }
+ }
+ })
+}
+
+const baseAccount = {
+ platform: 'openai',
+ type: 'apikey',
+ status: 'active',
+ schedulable: true,
+ concurrency: 1,
+ priority: 0,
+ error_message: null,
+ last_used_at: null,
+ expires_at: null,
+ auto_pause_on_expired: false,
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z'
+}
+
+describe('admin AccountsView scheduler score column', () => {
+ beforeEach(() => {
+ localStorage.clear()
+
+ listAccounts.mockReset()
+ listWithEtag.mockReset()
+ getBatchTodayStats.mockReset()
+ getAllProxies.mockReset()
+ getAllGroups.mockReset()
+
+ listAccounts.mockResolvedValue({
+ items: [
+ {
+ ...baseAccount,
+ id: 1,
+ name: 'ungrouped-openai',
+ // 未分组账号:后端只返回基础分(scheduler_score),无分组维度分数
+ scheduler_score: {
+ base_score: 1.234567,
+ sticky_score: 0,
+ sticky_weighted_enabled: false
+ }
+ },
+ {
+ ...baseAccount,
+ id: 2,
+ name: 'grouped-openai',
+ scheduler_score: {
+ base_score: 2,
+ sticky_score: 3,
+ sticky_weighted_enabled: true
+ },
+ scheduler_scores: [
+ {
+ group_id: 5,
+ group_name: 'group-five',
+ base_score: 2,
+ sticky_score: 3,
+ sticky_weighted_enabled: true
+ }
+ ]
+ },
+ {
+ ...baseAccount,
+ id: 3,
+ name: 'no-score',
+ platform: 'anthropic'
+ }
+ ],
+ total: 3,
+ page: 1,
+ page_size: 20,
+ pages: 1
+ })
+ listWithEtag.mockResolvedValue({
+ notModified: true,
+ etag: null,
+ data: null
+ })
+ getBatchTodayStats.mockResolvedValue({ stats: {} })
+ getAllProxies.mockResolvedValue([])
+ getAllGroups.mockResolvedValue([])
+ })
+
+ it('falls back to the base score for ungrouped accounts instead of showing a dash', async () => {
+ const wrapper = mountView()
+ await flushPromises()
+
+ const ungroupedCell = wrapper.find('[data-test="scheduler-score-1"]')
+ expect(ungroupedCell.exists()).toBe(true)
+ expect(ungroupedCell.text()).toContain('1.234567')
+ expect(ungroupedCell.text()).toContain('admin.accounts.schedulerScore.ungrouped')
+ expect(ungroupedCell.text()).not.toBe('-')
+ })
+
+ it('renders per-group scores for grouped accounts', async () => {
+ const wrapper = mountView()
+ await flushPromises()
+
+ const groupedCell = wrapper.find('[data-test="scheduler-score-2"]')
+ expect(groupedCell.exists()).toBe(true)
+ expect(groupedCell.text()).toContain('group-five')
+ expect(groupedCell.text()).toContain('2')
+ })
+
+ it('still shows a dash when no scheduler score is available', async () => {
+ const wrapper = mountView()
+ await flushPromises()
+
+ const emptyCell = wrapper.find('[data-test="scheduler-score-3"]')
+ expect(emptyCell.exists()).toBe(true)
+ expect(emptyCell.text()).toBe('-')
+ })
+})
From d56e94b8753cf8c33f2e48d1259a94248383912c Mon Sep 17 00:00:00 2001
From: shaw
Date: Mon, 6 Jul 2026 14:34:17 +0800
Subject: [PATCH 05/16] =?UTF-8?q?feat(payment):=20=E8=AE=A2=E9=98=85=20CNY?=
=?UTF-8?q?=20=E6=8D=A2=E7=AE=97=E6=94=B9=E4=B8=BA=E7=8B=AC=E7=AB=8B?=
=?UTF-8?q?=E6=B1=87=E7=8E=87=E9=85=8D=E7=BD=AE=E7=9A=84=E6=98=BE=E5=BC=8F?=
=?UTF-8?q?=20opt-in?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 SUBSCRIPTION_USD_TO_CNY_RATE 配置(1 USD = X CNY,默认 0=关闭),
替代复用 balance_recharge_multiplier 的隐式换算,促销倍率与订阅定价解耦
- 未配置汇率时订阅保持 price 直付的存量行为,存量部署升级零影响
- 前端确认页/原价/手续费/方式限额与后端换算条件严格镜像(rate>0 且币种为 CNY)
- 管理后台新增汇率配置输入(zh/en 文案),checkout-info 透出 subscription_usd_to_cny_rate
- 单测锁定:汇率未配置时不换算、换算使用汇率而非余额倍率、余额订单不受影响、返利仍按 USD price
---
.../internal/handler/admin/setting_handler.go | 7 ++-
backend/internal/handler/dto/settings.go | 1 +
backend/internal/handler/payment_handler.go | 2 +
backend/internal/server/api_contract_test.go | 2 +
backend/internal/service/payment_amounts.go | 9 +++
.../service/payment_config_service.go | 60 +++++++++++++------
backend/internal/service/payment_order.go | 18 +++---
.../service/payment_order_result_test.go | 43 ++++++++++---
frontend/src/api/admin/payment.ts | 2 +
frontend/src/api/admin/settings.ts | 2 +
frontend/src/i18n/locales/en.ts | 4 ++
frontend/src/i18n/locales/zh.ts | 4 ++
frontend/src/types/payment.ts | 3 +
frontend/src/views/admin/SettingsView.vue | 31 ++++++++++
.../admin/__tests__/SettingsView.spec.ts | 1 +
frontend/src/views/user/PaymentView.vue | 12 +++-
.../views/user/__tests__/PaymentView.spec.ts | 27 +++++----
17 files changed, 180 insertions(+), 48 deletions(-)
diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go
index 624fddb1e6..529e46c575 100644
--- a/backend/internal/handler/admin/setting_handler.go
+++ b/backend/internal/handler/admin/setting_handler.go
@@ -311,6 +311,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
PaymentEnabledTypes: paymentCfg.EnabledTypes,
PaymentBalanceDisabled: paymentCfg.BalanceDisabled,
PaymentBalanceRechargeMultiplier: paymentCfg.BalanceRechargeMultiplier,
+ PaymentSubscriptionUSDToCNYRate: paymentCfg.SubscriptionUSDToCNYRate,
PaymentRechargeFeeRate: paymentCfg.RechargeFeeRate,
PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy,
PaymentProductNamePrefix: paymentCfg.ProductNamePrefix,
@@ -672,6 +673,7 @@ type UpdateSettingsRequest struct {
PaymentEnabledTypes []string `json:"payment_enabled_types"`
PaymentBalanceDisabled *bool `json:"payment_balance_disabled"`
PaymentBalanceRechargeMultiplier *float64 `json:"payment_balance_recharge_multiplier"`
+ PaymentSubscriptionUSDToCNYRate *float64 `json:"payment_subscription_usd_to_cny_rate"`
PaymentRechargeFeeRate *float64 `json:"payment_recharge_fee_rate"`
PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"`
PaymentProductNamePrefix *string `json:"payment_product_name_prefix"`
@@ -2015,6 +2017,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EnabledTypes: req.PaymentEnabledTypes,
BalanceDisabled: req.PaymentBalanceDisabled,
BalanceRechargeMultiplier: req.PaymentBalanceRechargeMultiplier,
+ SubscriptionUSDToCNYRate: req.PaymentSubscriptionUSDToCNYRate,
RechargeFeeRate: req.PaymentRechargeFeeRate,
LoadBalanceStrategy: req.PaymentLoadBalanceStrat,
ProductNamePrefix: req.PaymentProductNamePrefix,
@@ -2258,6 +2261,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PaymentEnabledTypes: updatedPaymentCfg.EnabledTypes,
PaymentBalanceDisabled: updatedPaymentCfg.BalanceDisabled,
PaymentBalanceRechargeMultiplier: updatedPaymentCfg.BalanceRechargeMultiplier,
+ PaymentSubscriptionUSDToCNYRate: updatedPaymentCfg.SubscriptionUSDToCNYRate,
PaymentRechargeFeeRate: updatedPaymentCfg.RechargeFeeRate,
PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy,
PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix,
@@ -2316,7 +2320,8 @@ func hasPaymentFields(req UpdateSettingsRequest) bool {
req.PaymentMaxAmount != nil || req.PaymentDailyLimit != nil ||
req.PaymentOrderTimeoutMin != nil || req.PaymentMaxPendingOrders != nil ||
req.PaymentEnabledTypes != nil || req.PaymentBalanceDisabled != nil ||
- req.PaymentBalanceRechargeMultiplier != nil || req.PaymentRechargeFeeRate != nil ||
+ req.PaymentBalanceRechargeMultiplier != nil || req.PaymentSubscriptionUSDToCNYRate != nil ||
+ req.PaymentRechargeFeeRate != nil ||
req.PaymentLoadBalanceStrat != nil || req.PaymentProductNamePrefix != nil ||
req.PaymentProductNameSuffix != nil || req.PaymentHelpImageURL != nil ||
req.PaymentHelpText != nil || req.PaymentCancelRateLimitEnabled != nil ||
diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go
index 9a20191a0c..99fba54980 100644
--- a/backend/internal/handler/dto/settings.go
+++ b/backend/internal/handler/dto/settings.go
@@ -242,6 +242,7 @@ type SystemSettings struct {
PaymentEnabledTypes []string `json:"payment_enabled_types"`
PaymentBalanceDisabled bool `json:"payment_balance_disabled"`
PaymentBalanceRechargeMultiplier float64 `json:"payment_balance_recharge_multiplier"`
+ PaymentSubscriptionUSDToCNYRate float64 `json:"payment_subscription_usd_to_cny_rate"`
PaymentRechargeFeeRate float64 `json:"payment_recharge_fee_rate"`
PaymentLoadBalanceStrat string `json:"payment_load_balance_strategy"`
PaymentProductNamePrefix string `json:"payment_product_name_prefix"`
diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go
index 7cdf73cd3d..a267d73724 100644
--- a/backend/internal/handler/payment_handler.go
+++ b/backend/internal/handler/payment_handler.go
@@ -150,6 +150,7 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) {
Plans: planList,
BalanceDisabled: cfg.BalanceDisabled,
BalanceRechargeMultiplier: cfg.BalanceRechargeMultiplier,
+ SubscriptionUSDToCNYRate: cfg.SubscriptionUSDToCNYRate,
RechargeFeeRate: cfg.RechargeFeeRate,
HelpText: cfg.HelpText,
HelpImageURL: cfg.HelpImageURL,
@@ -165,6 +166,7 @@ type checkoutInfoResponse struct {
Plans []checkoutPlan `json:"plans"`
BalanceDisabled bool `json:"balance_disabled"`
BalanceRechargeMultiplier float64 `json:"balance_recharge_multiplier"`
+ SubscriptionUSDToCNYRate float64 `json:"subscription_usd_to_cny_rate"`
RechargeFeeRate float64 `json:"recharge_fee_rate"`
HelpText string `json:"help_text"`
HelpImageURL string `json:"help_image_url"`
diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go
index 9b3f2dcdd1..2432d570cb 100644
--- a/backend/internal/server/api_contract_test.go
+++ b/backend/internal/server/api_contract_test.go
@@ -899,6 +899,7 @@ func TestAPIContracts(t *testing.T) {
"payment_max_pending_orders": 0,
"payment_balance_disabled": false,
"payment_balance_recharge_multiplier": 0,
+ "payment_subscription_usd_to_cny_rate": 0,
"payment_recharge_fee_rate": 0,
"payment_load_balance_strategy": "",
"payment_product_name_prefix": "",
@@ -1169,6 +1170,7 @@ func TestAPIContracts(t *testing.T) {
"payment_enabled_types": null,
"payment_balance_disabled": false,
"payment_balance_recharge_multiplier": 0,
+ "payment_subscription_usd_to_cny_rate": 0,
"payment_recharge_fee_rate": 0,
"payment_load_balance_strategy": "",
"payment_product_name_prefix": "",
diff --git a/backend/internal/service/payment_amounts.go b/backend/internal/service/payment_amounts.go
index a7f620d33e..2fd00c5957 100644
--- a/backend/internal/service/payment_amounts.go
+++ b/backend/internal/service/payment_amounts.go
@@ -16,6 +16,15 @@ func normalizeBalanceRechargeMultiplier(multiplier float64) float64 {
return multiplier
}
+// normalizeSubscriptionUSDToCNYRate 将非法值归一为 0(换算关闭)。
+// 与余额倍率不同,0 是合法状态:表示订阅保持 price 直付的存量行为。
+func normalizeSubscriptionUSDToCNYRate(rate float64) float64 {
+ if math.IsNaN(rate) || math.IsInf(rate, 0) || rate < 0 {
+ return 0
+ }
+ return rate
+}
+
func calculateCreditedBalance(paymentAmount, multiplier float64) float64 {
return decimal.NewFromFloat(paymentAmount).
Mul(decimal.NewFromFloat(normalizeBalanceRechargeMultiplier(multiplier))).
diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go
index 022b1b0156..0050013645 100644
--- a/backend/internal/service/payment_config_service.go
+++ b/backend/internal/service/payment_config_service.go
@@ -24,17 +24,20 @@ const (
SettingLoadBalanceStrategy = "LOAD_BALANCE_STRATEGY"
SettingBalancePayDisabled = "BALANCE_PAYMENT_DISABLED"
SettingBalanceRechargeMult = "BALANCE_RECHARGE_MULTIPLIER"
- SettingRechargeFeeRate = "RECHARGE_FEE_RATE"
- SettingProductNamePrefix = "PRODUCT_NAME_PREFIX"
- SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX"
- SettingHelpImageURL = "PAYMENT_HELP_IMAGE_URL"
- SettingHelpText = "PAYMENT_HELP_TEXT"
- SettingCancelRateLimitOn = "CANCEL_RATE_LIMIT_ENABLED"
- SettingCancelRateLimitMax = "CANCEL_RATE_LIMIT_MAX"
- SettingCancelWindowSize = "CANCEL_RATE_LIMIT_WINDOW"
- SettingCancelWindowUnit = "CANCEL_RATE_LIMIT_UNIT"
- SettingCancelWindowMode = "CANCEL_RATE_LIMIT_WINDOW_MODE"
- SettingAlipayForceQRCode = "ALIPAY_FORCE_QRCODE"
+ // SettingSubscriptionUSDToCNYRate 是订阅 CNY 换算汇率(1 USD = X CNY)。
+ // 0/未配置 = 关闭换算(订阅按 price 数值直付),显式配置后 CNY 通道订阅按 price × rate 收款。
+ SettingSubscriptionUSDToCNYRate = "SUBSCRIPTION_USD_TO_CNY_RATE"
+ SettingRechargeFeeRate = "RECHARGE_FEE_RATE"
+ SettingProductNamePrefix = "PRODUCT_NAME_PREFIX"
+ SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX"
+ SettingHelpImageURL = "PAYMENT_HELP_IMAGE_URL"
+ SettingHelpText = "PAYMENT_HELP_TEXT"
+ SettingCancelRateLimitOn = "CANCEL_RATE_LIMIT_ENABLED"
+ SettingCancelRateLimitMax = "CANCEL_RATE_LIMIT_MAX"
+ SettingCancelWindowSize = "CANCEL_RATE_LIMIT_WINDOW"
+ SettingCancelWindowUnit = "CANCEL_RATE_LIMIT_UNIT"
+ SettingCancelWindowMode = "CANCEL_RATE_LIMIT_WINDOW_MODE"
+ SettingAlipayForceQRCode = "ALIPAY_FORCE_QRCODE"
)
// Default values for payment configuration settings.
@@ -54,13 +57,15 @@ type PaymentConfig struct {
EnabledTypes []string `json:"enabled_payment_types"`
BalanceDisabled bool `json:"balance_disabled"`
BalanceRechargeMultiplier float64 `json:"balance_recharge_multiplier"`
- RechargeFeeRate float64 `json:"recharge_fee_rate"`
- LoadBalanceStrategy string `json:"load_balance_strategy"`
- ProductNamePrefix string `json:"product_name_prefix"`
- ProductNameSuffix string `json:"product_name_suffix"`
- HelpImageURL string `json:"help_image_url"`
- HelpText string `json:"help_text"`
- StripePublishableKey string `json:"stripe_publishable_key,omitempty"`
+ // SubscriptionUSDToCNYRate 为 0 时订阅换算关闭(兼容存量行为)。
+ SubscriptionUSDToCNYRate float64 `json:"subscription_usd_to_cny_rate"`
+ RechargeFeeRate float64 `json:"recharge_fee_rate"`
+ LoadBalanceStrategy string `json:"load_balance_strategy"`
+ ProductNamePrefix string `json:"product_name_prefix"`
+ ProductNameSuffix string `json:"product_name_suffix"`
+ HelpImageURL string `json:"help_image_url"`
+ HelpText string `json:"help_text"`
+ StripePublishableKey string `json:"stripe_publishable_key,omitempty"`
// Cancel rate limit settings
CancelRateLimitEnabled bool `json:"cancel_rate_limit_enabled"`
@@ -84,6 +89,7 @@ type UpdatePaymentConfigRequest struct {
EnabledTypes []string `json:"enabled_payment_types"`
BalanceDisabled *bool `json:"balance_disabled"`
BalanceRechargeMultiplier *float64 `json:"balance_recharge_multiplier"`
+ SubscriptionUSDToCNYRate *float64 `json:"subscription_usd_to_cny_rate"`
RechargeFeeRate *float64 `json:"recharge_fee_rate"`
LoadBalanceStrategy *string `json:"load_balance_strategy"`
ProductNamePrefix *string `json:"product_name_prefix"`
@@ -204,7 +210,7 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo
keys := []string{
SettingPaymentEnabled, SettingMinRechargeAmount, SettingMaxRechargeAmount,
SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders,
- SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingBalanceRechargeMult, SettingRechargeFeeRate, SettingLoadBalanceStrategy,
+ SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingBalanceRechargeMult, SettingSubscriptionUSDToCNYRate, SettingRechargeFeeRate, SettingLoadBalanceStrategy,
SettingProductNamePrefix, SettingProductNameSuffix,
SettingHelpImageURL, SettingHelpText,
SettingCancelRateLimitOn, SettingCancelRateLimitMax,
@@ -233,6 +239,7 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme
MaxPendingOrders: pcParseInt(vals[SettingMaxPendingOrders], defaultMaxPendingOrders),
BalanceDisabled: vals[SettingBalancePayDisabled] == "true",
BalanceRechargeMultiplier: normalizeBalanceRechargeMultiplier(pcParseFloat(vals[SettingBalanceRechargeMult], defaultBalanceRechargeMultiplier)),
+ SubscriptionUSDToCNYRate: normalizeSubscriptionUSDToCNYRate(pcParseFloat(vals[SettingSubscriptionUSDToCNYRate], 0)),
RechargeFeeRate: pcParseFloat(vals[SettingRechargeFeeRate], 0),
LoadBalanceStrategy: vals[SettingLoadBalanceStrategy],
ProductNamePrefix: vals[SettingProductNamePrefix],
@@ -294,6 +301,12 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda
return infraerrors.BadRequest("INVALID_BALANCE_RECHARGE_MULTIPLIER", "balance recharge multiplier must be greater than 0")
}
}
+ if req.SubscriptionUSDToCNYRate != nil {
+ v := *req.SubscriptionUSDToCNYRate
+ if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 {
+ return infraerrors.BadRequest("INVALID_SUBSCRIPTION_USD_TO_CNY_RATE", "subscription USD to CNY rate must be 0 (disabled) or a positive number")
+ }
+ }
if req.RechargeFeeRate != nil {
v := *req.RechargeFeeRate
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 || v > 100 {
@@ -313,6 +326,7 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda
SettingMaxPendingOrders: formatPositiveInt(req.MaxPendingOrders),
SettingBalancePayDisabled: formatBoolOrEmpty(req.BalanceDisabled),
SettingBalanceRechargeMult: formatPositiveFloat(req.BalanceRechargeMultiplier),
+ SettingSubscriptionUSDToCNYRate: formatPositiveFloatExact(req.SubscriptionUSDToCNYRate),
SettingRechargeFeeRate: formatNonNegativeFloat(req.RechargeFeeRate),
SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy),
SettingProductNamePrefix: derefStr(req.ProductNamePrefix),
@@ -352,6 +366,14 @@ func formatPositiveFloat(v *float64) string {
return strconv.FormatFloat(*v, 'f', 2, 64)
}
+// formatPositiveFloatExact 保留完整精度,用于汇率等对小数位敏感的配置。
+func formatPositiveFloatExact(v *float64) string {
+ if v == nil || *v <= 0 {
+ return "" // empty → parsePaymentConfig 视为未配置(换算关闭)
+ }
+ return strconv.FormatFloat(*v, 'f', -1, 64)
+}
+
func formatNonNegativeFloat(v *float64) string {
if v == nil || *v < 0 {
return ""
diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go
index 7f4bcf7c2d..04feb8002a 100644
--- a/backend/internal/service/payment_order.go
+++ b/backend/internal/service/payment_order.go
@@ -68,7 +68,7 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
return nil, err
}
}
- payAmountStr, payAmount, err := calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate, methodCurrency, req.OrderType, cfg.BalanceRechargeMultiplier)
+ payAmountStr, payAmount, err := calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate, methodCurrency, req.OrderType, cfg.SubscriptionUSDToCNYRate)
if err != nil {
return nil, err
}
@@ -84,7 +84,7 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
selectedCurrency = paymentProviderConfigCurrency(sel.ProviderKey, sel.Config)
}
if selectedCurrency != methodCurrency {
- payAmountStr, payAmount, err = calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate, selectedCurrency, req.OrderType, cfg.BalanceRechargeMultiplier)
+ payAmountStr, payAmount, err = calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate, selectedCurrency, req.OrderType, cfg.SubscriptionUSDToCNYRate)
if err != nil {
return nil, err
}
@@ -630,20 +630,24 @@ func calculateCreateOrderPayAmount(limitAmount, feeRate float64, currency string
return payAmountStr, payAmount, nil
}
-func calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate float64, currency, orderType string, multiplier float64) (string, float64, error) {
+func calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate float64, currency, orderType string, usdToCnyRate float64) (string, float64, error) {
paymentAmount := limitAmount
if orderType == payment.OrderTypeSubscription {
- paymentAmount = calculateSubscriptionGatewayBaseAmount(limitAmount, multiplier, currency)
+ paymentAmount = calculateSubscriptionGatewayBaseAmount(limitAmount, usdToCnyRate, currency)
}
return calculateCreateOrderPayAmount(paymentAmount, feeRate, currency)
}
-func calculateSubscriptionGatewayBaseAmount(amount, multiplier float64, currency string) float64 {
- if currency != payment.DefaultPaymentCurrency {
+// calculateSubscriptionGatewayBaseAmount 计算订阅订单的网关扣款基数。
+// 换算是显式 opt-in:仅当管理员配置了订阅汇率(rate > 0,1 USD = rate CNY)
+// 且网关币种为 CNY 时,按 price × rate 换算;未配置时保持 price 直付的存量行为。
+func calculateSubscriptionGatewayBaseAmount(amount, usdToCnyRate float64, currency string) float64 {
+ rate := normalizeSubscriptionUSDToCNYRate(usdToCnyRate)
+ if rate <= 0 || currency != payment.DefaultPaymentCurrency {
return amount
}
return decimal.NewFromFloat(amount).
- Div(decimal.NewFromFloat(normalizeBalanceRechargeMultiplier(multiplier))).
+ Mul(decimal.NewFromFloat(rate)).
Round(int32(payment.CurrencyMaxFractionDigits(currency))).
InexactFloat64()
}
diff --git a/backend/internal/service/payment_order_result_test.go b/backend/internal/service/payment_order_result_test.go
index 930643d3e0..ac439ee6f2 100644
--- a/backend/internal/service/payment_order_result_test.go
+++ b/backend/internal/service/payment_order_result_test.go
@@ -161,34 +161,34 @@ func TestCalculateCreateOrderPayAmountUsesCurrencyPrecision(t *testing.T) {
}
}
-func TestCalculateCreateOrderPayAmountForSubscriptionConvertsCNYPrice(t *testing.T) {
+func TestCalculateCreateOrderPayAmountForSubscriptionConvertsCNYPriceWhenRateConfigured(t *testing.T) {
t.Parallel()
- amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 0, "CNY", payment.OrderTypeSubscription, 0.14)
+ amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 0, "CNY", payment.OrderTypeSubscription, 7.15)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- if amountStr != "71.36" || amount != 71.36 {
- t.Fatalf("subscription CNY pay amount = (%q, %v), want (71.36, 71.36)", amountStr, amount)
+ if amountStr != "71.43" || amount != 71.43 {
+ t.Fatalf("subscription CNY pay amount = (%q, %v), want (71.43, 71.43)", amountStr, amount)
}
}
func TestCalculateCreateOrderPayAmountForSubscriptionAppliesFeeAfterCNYConversion(t *testing.T) {
t.Parallel()
- amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 2.5, "CNY", payment.OrderTypeSubscription, 0.14)
+ amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 2.5, "CNY", payment.OrderTypeSubscription, 7.15)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- if amountStr != "73.15" || amount != 73.15 {
- t.Fatalf("subscription CNY pay amount with fee = (%q, %v), want (73.15, 73.15)", amountStr, amount)
+ if amountStr != "73.22" || amount != 73.22 {
+ t.Fatalf("subscription CNY pay amount with fee = (%q, %v), want (73.22, 73.22)", amountStr, amount)
}
}
func TestCalculateCreateOrderPayAmountForSubscriptionKeepsNonCNYPrice(t *testing.T) {
t.Parallel()
- amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 0, "USD", payment.OrderTypeSubscription, 0.14)
+ amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 0, "USD", payment.OrderTypeSubscription, 7.15)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -197,6 +197,33 @@ func TestCalculateCreateOrderPayAmountForSubscriptionKeepsNonCNYPrice(t *testing
}
}
+// 换算是 opt-in:未配置汇率(rate=0)时,CNY 订阅保持 price 直付的存量行为。
+// 该测试锁住存量部署升级后行为不变的兼容承诺。
+func TestCalculateCreateOrderPayAmountForSubscriptionKeepsDirectPriceWhenRateDisabled(t *testing.T) {
+ t.Parallel()
+
+ amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 0, "CNY", payment.OrderTypeSubscription, 0)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if amountStr != "9.99" || amount != 9.99 {
+ t.Fatalf("subscription CNY pay amount without rate = (%q, %v), want (9.99, 9.99)", amountStr, amount)
+ }
+}
+
+// 汇率只作用于订阅订单,余额充值订单不受影响。
+func TestCalculateCreateOrderPayAmountForBalanceIgnoresSubscriptionRate(t *testing.T) {
+ t.Parallel()
+
+ amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(50, 0, "CNY", payment.OrderTypeBalance, 7.15)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if amountStr != "50.00" || amount != 50 {
+ t.Fatalf("balance CNY pay amount = (%q, %v), want (50.00, 50)", amountStr, amount)
+ }
+}
+
func TestCalculateCreditedBalanceStillUsesRechargeMultiplier(t *testing.T) {
t.Parallel()
diff --git a/frontend/src/api/admin/payment.ts b/frontend/src/api/admin/payment.ts
index 49efcc355d..9bab627218 100644
--- a/frontend/src/api/admin/payment.ts
+++ b/frontend/src/api/admin/payment.ts
@@ -24,6 +24,7 @@ export interface AdminPaymentConfig {
enabled_payment_types: string[]
balance_disabled: boolean
balance_recharge_multiplier: number
+ subscription_usd_to_cny_rate: number
load_balance_strategy: string
product_name_prefix: string
product_name_suffix: string
@@ -42,6 +43,7 @@ export interface UpdatePaymentConfigRequest {
enabled_payment_types?: string[]
balance_disabled?: boolean
balance_recharge_multiplier?: number
+ subscription_usd_to_cny_rate?: number
load_balance_strategy?: string
product_name_prefix?: string
product_name_suffix?: string
diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts
index 457775870d..f5da990930 100644
--- a/frontend/src/api/admin/settings.ts
+++ b/frontend/src/api/admin/settings.ts
@@ -589,6 +589,7 @@ export interface SystemSettings {
payment_enabled_types: string[];
payment_balance_disabled: boolean;
payment_balance_recharge_multiplier: number;
+ payment_subscription_usd_to_cny_rate: number;
payment_recharge_fee_rate: number;
payment_load_balance_strategy: string;
payment_product_name_prefix: string;
@@ -860,6 +861,7 @@ export interface UpdateSettingsRequest {
payment_enabled_types?: string[];
payment_balance_disabled?: boolean;
payment_balance_recharge_multiplier?: number;
+ payment_subscription_usd_to_cny_rate?: number;
payment_recharge_fee_rate?: number;
payment_load_balance_strategy?: string;
payment_product_name_prefix?: string;
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 259801d7ad..5808e4a9df 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -6183,6 +6183,10 @@ export default {
balanceRechargeMultiplier: 'Balance Recharge Multiplier',
balanceRechargeMultiplierHint: 'How many USD balance the user receives for each 1 CNY paid',
balanceRechargePreview: 'Preview: 1 CNY = {usd} USD',
+ subscriptionUsdToCnyRate: 'Subscription USD to CNY Rate',
+ subscriptionUsdToCnyRateHint:
+ 'CNY charged per 1 USD of plan price on CNY channels (e.g. 7.15). 0 or empty = disabled, plan price is charged as-is. When enabled, all plan prices must be set in USD',
+ subscriptionUsdToCnyRateDisabled: 'Disabled (price charged as-is)',
rechargeFeeRate: 'Recharge Fee Rate',
rechargeFeeRateHint: 'Percentage of service fee charged on top of recharge amount, 0 means no fee',
rechargeFeePreview: 'Preview: Recharge 100, fee {fee}',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 2e8d9f83fc..5dd37a987b 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -6338,6 +6338,10 @@ export default {
balanceRechargeMultiplier: '余额充值倍率',
balanceRechargeMultiplierHint: '用户每支付 1 CNY 可获得多少 USD 余额',
balanceRechargePreview: '预览:1 CNY = {usd} USD',
+ subscriptionUsdToCnyRate: '订阅 CNY 换算汇率',
+ subscriptionUsdToCnyRateHint:
+ 'CNY 支付通道下,套餐每 1 USD 价格收取多少 CNY(如 7.15)。0 或留空 = 不换算,订阅按 price 数值直接收款。启用后所有套餐 price 必须按 USD 定价',
+ subscriptionUsdToCnyRateDisabled: '未启用(按 price 直付)',
rechargeFeeRate: '充值手续费率',
rechargeFeeRateHint: '用户充值时额外收取的手续费百分比,0 表示不收取手续费',
rechargeFeePreview: '预览:充值 100 元,手续费 {fee} 元',
diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts
index a02ef1b78f..98dab93e8c 100644
--- a/frontend/src/types/payment.ts
+++ b/frontend/src/types/payment.ts
@@ -34,6 +34,7 @@ export interface PaymentConfig {
order_timeout_minutes: number
balance_disabled: boolean
balance_recharge_multiplier: number
+ subscription_usd_to_cny_rate: number
enabled_payment_types: PaymentType[]
help_image_url: string
help_text: string
@@ -66,6 +67,8 @@ export interface CheckoutInfoResponse {
plans: SubscriptionPlan[]
balance_disabled: boolean
balance_recharge_multiplier: number
+ /** Subscription CNY conversion rate (1 USD = X CNY); 0 = disabled, plan price is charged as-is */
+ subscription_usd_to_cny_rate: number
recharge_fee_rate: number
help_text: string
help_image_url: string
diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue
index 78f642d761..c1072cddbb 100644
--- a/frontend/src/views/admin/SettingsView.vue
+++ b/frontend/src/views/admin/SettingsView.vue
@@ -6480,6 +6480,34 @@
}}
+
+
+
+
+ {{
+ t("admin.settings.payment.subscriptionUsdToCnyRateHint")
+ }}
+
+
+
+
+
+
+ {{ t('admin.settings.payment.easypayCustomMethods') }}
+
+
+ {{ t('admin.settings.payment.easypayCustomMethodsHint') }}
+
+
+
+
+
+
+
@@ -270,7 +313,7 @@ import Select from '@/components/common/Select.vue'
import type { SelectOption } from '@/components/common/Select.vue'
import ToggleSwitch from './ToggleSwitch.vue'
import type { ProviderInstance } from '@/types/payment'
-import type { TypeOption } from './providerConfig'
+import type { EasyPayCustomMethod, TypeOption } from './providerConfig'
import {
PROVIDER_CONFIG_FIELDS,
PROVIDER_SUPPORTED_TYPES,
@@ -282,6 +325,8 @@ import {
STRIPE_SDK_API_VERSION,
getAvailableTypes,
extractBaseUrl,
+ parseEasyPayCustomMethods,
+ serializeEasyPayCustomMethods,
} from './providerConfig'
/** Default payment_mode per provider key — "" means "no preference, use
@@ -365,6 +410,7 @@ const notifyBaseUrl = ref('')
const returnBaseUrl = ref('')
const limitsExpanded = ref(false)
const visibleFields = reactive
>({})
+const easyPayCustomMethods = reactive([])
// --- Computed ---
const defaultBaseUrl = typeof window !== 'undefined' ? window.location.origin : ''
@@ -404,6 +450,16 @@ const paymentModeOptions = computed(() => {
const availableTypes = computed(() => {
const base = getAvailableTypes(form.provider_key, props.allPaymentTypes, props.redirectLabel)
+ if (form.provider_key === 'easypay') {
+ for (const method of normalizedEasyPayCustomMethods()) {
+ if (!base.some(opt => opt.value === method.type)) {
+ base.push({
+ value: method.type,
+ label: method.displayName || method.type,
+ })
+ }
+ }
+ }
// Resolve i18n labels for types not in allPaymentTypes (e.g. card, link inside stripe)
return base.map(opt =>
opt.label === opt.value
@@ -510,6 +566,28 @@ function toggleType(type: string) {
}
}
+function normalizedEasyPayCustomMethods(): EasyPayCustomMethod[] {
+ return easyPayCustomMethods
+ .map(method => ({
+ type: normalizeEasyPayCustomMethodCode(method.type),
+ upstreamType: normalizeEasyPayCustomMethodCode(method.upstreamType),
+ displayName: method.displayName.trim(),
+ }))
+ .filter(method => method.type || method.upstreamType || method.displayName)
+}
+
+function normalizeEasyPayCustomMethodCode(value: string): string {
+ return value.trim().toLowerCase()
+}
+
+function addEasyPayCustomMethod() {
+ easyPayCustomMethods.push({ type: '', upstreamType: '', displayName: '' })
+}
+
+function removeEasyPayCustomMethod(index: number) {
+ easyPayCustomMethods.splice(index, 1)
+}
+
function onKeyChange() {
form.supported_types = [...(PROVIDER_SUPPORTED_TYPES[form.provider_key] || [])]
form.payment_mode = defaultPaymentMode(form.provider_key)
@@ -524,6 +602,7 @@ function clearConfig() {
notifyBaseUrl.value = ''
returnBaseUrl.value = ''
limitsExpanded.value = false
+ easyPayCustomMethods.splice(0, easyPayCustomMethods.length)
}
function applyDefaults() {
@@ -581,6 +660,14 @@ function handleSave() {
emitValidationError(t('admin.settings.payment.validationNameRequired'))
return
}
+ if (form.provider_key === 'easypay') {
+ const validationError = validateEasyPayCustomMethods()
+ if (validationError) {
+ emitValidationError(validationError)
+ return
+ }
+ syncEasyPayCustomMethods()
+ }
// Validate required config fields — all non-optional fields must be filled.
// In edit mode, sensitive fields may be left blank to preserve the stored
// value (backend merges blanks by preserving the existing secret).
@@ -610,6 +697,9 @@ function handleSave() {
}
filteredConfig[k] = v
}
+ if (form.provider_key === 'easypay') {
+ filteredConfig.customMethods = serializeEasyPayCustomMethods(normalizedEasyPayCustomMethods())
+ }
// Inject computed callback URLs (each URL = independent base + fixed path)
// If base URL is empty, auto-fill with current domain
@@ -636,6 +726,56 @@ function handleSave() {
})
}
+function syncEasyPayCustomMethods(): string[] {
+ if (form.provider_key !== 'easypay') return []
+ const baseTypes = new Set(PROVIDER_SUPPORTED_TYPES.easypay || [])
+ const customTypes: string[] = []
+ const seen = new Set()
+ for (const method of normalizedEasyPayCustomMethods()) {
+ if (!method.type || !method.upstreamType) continue
+ if (seen.has(method.type)) continue
+ seen.add(method.type)
+ customTypes.push(method.type)
+ }
+ form.supported_types = form.supported_types
+ .map(type => normalizeEasyPayCustomMethodCode(type))
+ .filter(type => baseTypes.has(type) || customTypes.includes(type))
+ for (const customType of customTypes) {
+ if (!form.supported_types.includes(customType)) {
+ form.supported_types.push(customType)
+ }
+ }
+ return customTypes
+}
+
+function validateEasyPayCustomMethods(): string | null {
+ const seen = new Set()
+ for (const method of normalizedEasyPayCustomMethods()) {
+ const hasAnyValue = Boolean(method.type || method.upstreamType || method.displayName)
+ if (!hasAnyValue) continue
+ if (!method.type || !method.upstreamType) {
+ return t('admin.settings.payment.validationEasyPayCustomMethodRequired')
+ }
+ if (!/^[a-z0-9_-]+$/.test(method.type)) {
+ return t('admin.settings.payment.validationEasyPayCustomMethodTypeInvalid')
+ }
+ if (!/^[a-z0-9_-]+$/.test(method.upstreamType)) {
+ return t('admin.settings.payment.validationEasyPayCustomMethodUpstreamTypeInvalid')
+ }
+ if ((PROVIDER_SUPPORTED_TYPES.easypay || []).includes(method.type)) {
+ return t('admin.settings.payment.validationEasyPayCustomMethodReserved')
+ }
+ if (method.type.startsWith('alipay') || method.type.startsWith('wxpay')) {
+ return t('admin.settings.payment.validationEasyPayCustomMethodPrefixReserved')
+ }
+ if (seen.has(method.type)) {
+ return t('admin.settings.payment.validationEasyPayCustomMethodDuplicate')
+ }
+ seen.add(method.type)
+ }
+ return null
+}
+
function emitValidationError(msg: string) {
// Use a custom event or inject appStore — for now use window alert fallback
// The parent handles this via the save event validation
@@ -677,6 +817,10 @@ function loadProvider(provider: ProviderInstance) {
for (const [k, v] of Object.entries(provider.config)) {
// Skip notifyUrl/returnUrl — they are derived from callbackBaseUrl
if (k === 'notifyUrl' || k === 'returnUrl') continue
+ if (k === 'customMethods' && provider.provider_key === 'easypay') {
+ easyPayCustomMethods.push(...parseEasyPayCustomMethods(v))
+ continue
+ }
config[k] = v
}
// Extract base URLs from existing callback URLs
diff --git a/frontend/src/components/payment/__tests__/PaymentMethodSelector.spec.ts b/frontend/src/components/payment/__tests__/PaymentMethodSelector.spec.ts
new file mode 100644
index 0000000000..3e3bfcc4c0
--- /dev/null
+++ b/frontend/src/components/payment/__tests__/PaymentMethodSelector.spec.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: (key: string, fallback?: string) => fallback ?? key,
+ }),
+}))
+
+describe('PaymentMethodSelector', () => {
+ it('shows the configured display name for custom EasyPay methods', () => {
+ const wrapper = mount(PaymentMethodSelector, {
+ props: {
+ selected: 'ldc',
+ methods: [{ type: 'ldc', display_name: 'LDC Pay', fee_rate: 0, available: true }],
+ },
+ })
+
+ expect(wrapper.text()).toContain('LDC Pay')
+ expect(wrapper.text()).not.toContain('ldc')
+ expect(wrapper.text()).not.toContain('payment.methods.ldc')
+ })
+})
diff --git a/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts b/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts
index 099152d8a3..9221455275 100644
--- a/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts
+++ b/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts
@@ -7,6 +7,12 @@ import type { ProviderInstance } from '@/types/payment'
const messages: Record = {
'admin.settings.payment.providerConfig': 'Credentials',
+ 'admin.settings.payment.easypayCustomMethods': 'Custom EasyPay methods',
+ 'admin.settings.payment.easypayCustomMethodsHint': 'Add provider-specific EasyPay type values.',
+ 'admin.settings.payment.addCustomMethod': 'Add method',
+ 'admin.settings.payment.customMethodType': 'Payment type',
+ 'admin.settings.payment.customMethodUpstreamType': 'Upstream type',
+ 'admin.settings.payment.customMethodDisplayName': 'Display name',
'admin.settings.payment.paymentGuideTrigger': 'View payment guide',
'admin.settings.payment.alipayGuideSummary': 'Desktop prefers QR precreate and falls back to cashier; mobile prefers WAP checkout.',
'admin.settings.payment.wxpayGuideSummary': 'Desktop prefers Native QR; mobile routes to JSAPI or H5 based on browser context.',
@@ -53,12 +59,14 @@ function mountDialog(options: { editing?: ProviderInstance | null } = {}) {
saving: false,
editing: options.editing ?? null,
allKeyOptions: [
+ { value: 'easypay', label: 'EasyPay' },
{ value: 'alipay', label: 'Alipay' },
{ value: 'wxpay', label: 'WeChat Pay' },
{ value: 'stripe', label: 'Stripe' },
{ value: 'airwallex', label: 'Airwallex' },
],
enabledKeyOptions: [
+ { value: 'easypay', label: 'EasyPay' },
{ value: 'alipay', label: 'Alipay' },
{ value: 'wxpay', label: 'WeChat Pay' },
{ value: 'airwallex', label: 'Airwallex' },
@@ -156,4 +164,83 @@ describe('PaymentProviderDialog payment guide', () => {
const payload = wrapper.emitted('save')?.[0]?.[0] as { config: Record }
expect(payload.config.accountId).toBe('')
})
+
+ it('serializes EasyPay custom methods and adds them to supported_types', async () => {
+ const provider = providerFactory({
+ provider_key: 'easypay',
+ name: 'EasyPay',
+ config: {
+ pid: 'pid-1',
+ apiBase: 'https://pay.example.com',
+ notifyUrl: 'https://example.com/api/v1/payment/webhook/easypay',
+ returnUrl: 'https://example.com/payment/result',
+ },
+ supported_types: ['alipay', 'wxpay'],
+ payment_mode: 'qrcode',
+ })
+ const wrapper = mountDialog({ editing: provider })
+
+ ;(wrapper.vm as unknown as { loadProvider: (provider: ProviderInstance) => void }).loadProvider(provider)
+ await nextTick()
+
+ await wrapper.find('button.btn-sm').trigger('click')
+ await nextTick()
+
+ const inputs = wrapper.findAll('input[type="text"]')
+ const ldcTypeInput = inputs.find(input => (input.element as HTMLInputElement).placeholder === 'ldc')
+ const upstreamTypeInput = inputs.find(input => (input.element as HTMLInputElement).placeholder === 'epay')
+ const displayNameInput = inputs.find(input => (input.element as HTMLInputElement).placeholder === 'LDC')
+ if (!ldcTypeInput || !upstreamTypeInput || !displayNameInput) {
+ throw new Error('custom method inputs not found')
+ }
+
+ await ldcTypeInput.setValue('ldc')
+ await upstreamTypeInput.setValue('epay')
+ await displayNameInput.setValue('LDC')
+ await wrapper.find('form').trigger('submit.prevent')
+
+ const payload = wrapper.emitted('save')?.[0]?.[0] as {
+ config: Record
+ supported_types: string[]
+ }
+ expect(payload.config.customMethods).toBe('[{"type":"ldc","upstreamType":"epay","displayName":"LDC"}]')
+ expect(payload.supported_types).toEqual(['alipay', 'wxpay', 'ldc'])
+ })
+
+ it('rejects custom EasyPay method types with built-in payment prefixes', async () => {
+ const provider = providerFactory({
+ provider_key: 'easypay',
+ name: 'EasyPay',
+ config: {
+ pid: 'pid-1',
+ apiBase: 'https://pay.example.com',
+ notifyUrl: 'https://example.com/api/v1/payment/webhook/easypay',
+ returnUrl: 'https://example.com/payment/result',
+ },
+ supported_types: ['alipay', 'wxpay'],
+ payment_mode: 'qrcode',
+ })
+ const wrapper = mountDialog({ editing: provider })
+
+ ;(wrapper.vm as unknown as { loadProvider: (provider: ProviderInstance) => void }).loadProvider(provider)
+ await nextTick()
+
+ await wrapper.find('button.btn-sm').trigger('click')
+ await nextTick()
+
+ const inputs = wrapper.findAll('input[type="text"]')
+ const typeInput = inputs.find(input => (input.element as HTMLInputElement).placeholder === 'ldc')
+ const upstreamTypeInput = inputs.find(input => (input.element as HTMLInputElement).placeholder === 'epay')
+ const displayNameInput = inputs.find(input => (input.element as HTMLInputElement).placeholder === 'LDC')
+ if (!typeInput || !upstreamTypeInput || !displayNameInput) {
+ throw new Error('custom method inputs not found')
+ }
+
+ await typeInput.setValue('alipay_hk')
+ await upstreamTypeInput.setValue('hkpay')
+ await displayNameInput.setValue('Hong Kong Alipay')
+ await wrapper.find('form').trigger('submit.prevent')
+
+ expect(wrapper.emitted('save')).toBeUndefined()
+ })
})
diff --git a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts
index 7eda7a0df4..85e79de6bf 100644
--- a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts
+++ b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts
@@ -59,6 +59,18 @@ describe('getVisibleMethods', () => {
expect(visible.alipay.single_min).toBe(2)
expect(visible.wxpay.fee_rate).toBe(1.2)
})
+
+ it('keeps custom EasyPay methods as visible methods', () => {
+ const visible = getVisibleMethods({
+ ldc: methodLimit({ single_min: 3 }),
+ usdt_trc20: methodLimit({ fee_rate: 1 }),
+ })
+
+ expect(visible).toEqual({
+ ldc: methodLimit({ single_min: 3 }),
+ usdt_trc20: methodLimit({ fee_rate: 1 }),
+ })
+ })
})
describe('decidePaymentLaunch', () => {
diff --git a/frontend/src/components/payment/__tests__/providerConfig.spec.ts b/frontend/src/components/payment/__tests__/providerConfig.spec.ts
index bafc7cd754..4c20ae725c 100644
--- a/frontend/src/components/payment/__tests__/providerConfig.spec.ts
+++ b/frontend/src/components/payment/__tests__/providerConfig.spec.ts
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
-import { PAYMENT_CURRENCY_OPTIONS, PROVIDER_CONFIG_FIELDS } from '@/components/payment/providerConfig'
+import {
+ PAYMENT_CURRENCY_OPTIONS,
+ PROVIDER_CONFIG_FIELDS,
+ parseEasyPayCustomMethods,
+ serializeEasyPayCustomMethods,
+} from '@/components/payment/providerConfig'
function findField(providerKey: string, key: string) {
const fields = PROVIDER_CONFIG_FIELDS[providerKey] || []
@@ -50,3 +55,27 @@ describe('PROVIDER_CONFIG_FIELDS.stripe', () => {
expect(currency?.options).toBe(PAYMENT_CURRENCY_OPTIONS)
})
})
+
+describe('EasyPay custom methods config', () => {
+ it('parses customMethods from the JSON string stored in provider config', () => {
+ expect(parseEasyPayCustomMethods(
+ '[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt","displayName":"USDT-TRC20"}]',
+ )).toEqual([
+ { type: 'ldc', upstreamType: 'epay', displayName: 'LDC' },
+ { type: 'usdt_trc20', upstreamType: 'usdt', displayName: 'USDT-TRC20' },
+ ])
+ })
+
+ it('serializes non-empty custom methods into the config string format', () => {
+ expect(serializeEasyPayCustomMethods([
+ { type: 'ldc', upstreamType: 'epay', displayName: 'LDC' },
+ { type: ' ', upstreamType: 'ignored', displayName: 'Ignored' },
+ { type: 'usdt_trc20', upstreamType: 'usdt', displayName: '' },
+ ])).toBe('[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt","displayName":""}]')
+ })
+
+ it('returns an empty string for invalid or empty custom methods', () => {
+ expect(parseEasyPayCustomMethods('not-json')).toEqual([])
+ expect(serializeEasyPayCustomMethods([{ type: '', upstreamType: 'epay', displayName: 'LDC' }])).toBe('')
+ })
+})
diff --git a/frontend/src/components/payment/paymentFlow.ts b/frontend/src/components/payment/paymentFlow.ts
index ab5acf26db..a8176f7472 100644
--- a/frontend/src/components/payment/paymentFlow.ts
+++ b/frontend/src/components/payment/paymentFlow.ts
@@ -99,7 +99,7 @@ export function getVisibleMethods(methods: Record): Record<
const visible: Record = {}
Object.entries(methods).forEach(([type, limit]) => {
- const normalized = normalizeVisibleMethod(type)
+ const normalized = normalizeVisibleMethod(type) || type.trim()
if (!normalized) return
const isCanonical = type === normalized
diff --git a/frontend/src/components/payment/providerConfig.ts b/frontend/src/components/payment/providerConfig.ts
index 2b612b4302..203bfb6818 100644
--- a/frontend/src/components/payment/providerConfig.ts
+++ b/frontend/src/components/payment/providerConfig.ts
@@ -21,6 +21,12 @@ export interface TypeOption {
[key: string]: unknown
}
+export interface EasyPayCustomMethod {
+ type: string
+ upstreamType: string
+ displayName: string
+}
+
/** Callback URL paths for a provider. */
export interface CallbackPaths {
notifyUrl?: string
@@ -171,6 +177,34 @@ export function getAvailableTypes(
return types.map(t => resolveTypeLabel(t, providerKey, allTypes, redirectLabel))
}
+export function parseEasyPayCustomMethods(raw: string | undefined): EasyPayCustomMethod[] {
+ if (!raw || !raw.trim()) return []
+ try {
+ const parsed = JSON.parse(raw)
+ if (!Array.isArray(parsed)) return []
+ return parsed
+ .map(item => ({
+ type: String(item?.type || '').trim(),
+ upstreamType: String(item?.upstreamType || '').trim(),
+ displayName: String(item?.displayName || '').trim(),
+ }))
+ .filter(item => item.type && item.upstreamType)
+ } catch {
+ return []
+ }
+}
+
+export function serializeEasyPayCustomMethods(methods: EasyPayCustomMethod[]): string {
+ const clean = methods
+ .map(method => ({
+ type: method.type.trim(),
+ upstreamType: method.upstreamType.trim(),
+ displayName: method.displayName.trim(),
+ }))
+ .filter(method => method.type && method.upstreamType)
+ return clean.length ? JSON.stringify(clean) : ''
+}
+
/** Extract base URL from a full callback URL by removing the known path suffix. */
export function extractBaseUrl(fullUrl: string, path: string): string {
if (!fullUrl) return ''
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 5808e4a9df..3d7ee87b5c 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -6232,6 +6232,12 @@ export default {
validationNameRequired: 'Provider name is required',
validationTypesRequired: 'Please select at least one supported payment type',
validationFieldRequired: '{field} is required',
+ validationEasyPayCustomMethodRequired: 'Each custom EasyPay method requires both a payment type and an upstream type',
+ validationEasyPayCustomMethodTypeInvalid: 'Custom EasyPay payment types may only contain lowercase letters, digits, underscores, and hyphens',
+ validationEasyPayCustomMethodUpstreamTypeInvalid: 'EasyPay upstream types may only contain lowercase letters, digits, underscores, and hyphens',
+ validationEasyPayCustomMethodReserved: 'Custom EasyPay payment types cannot use built-in alipay or wxpay',
+ validationEasyPayCustomMethodPrefixReserved: 'Custom EasyPay payment types cannot start with alipay or wxpay',
+ validationEasyPayCustomMethodDuplicate: 'Custom EasyPay payment types must be unique',
field_apiBase: 'API Base URL',
field_notifyUrl: 'Notify URL',
field_returnUrl: 'Return URL',
@@ -6261,6 +6267,12 @@ export default {
field_cid: 'Channel ID',
field_cidAlipay: 'Alipay Channel ID',
field_cidWxpay: 'WeChat Channel ID',
+ easypayCustomMethods: 'Custom EasyPay methods',
+ easypayCustomMethodsHint: 'Add provider-specific methods supported by this EasyPay endpoint. The payment type is stored on Sub2API orders; the upstream type is sent as EasyPay type.',
+ addCustomMethod: 'Add method',
+ customMethodType: 'Payment type',
+ customMethodUpstreamType: 'Upstream type',
+ customMethodDisplayName: 'Display name',
stripeWebhookHint: 'Configure the following URL as a Webhook endpoint in Stripe Dashboard:',
stripeWebhookApiVersionHint: 'Set this Webhook endpoint API version to match the integrated Stripe SDK. Recommended: {version}. A mismatch can cause webhook parsing errors.',
airwallexWebhookHint: 'Configure the following URL as a Webhook endpoint in Airwallex. Select at least Payment Intent -> Succeeded (payment_intent.succeeded), preferably also Payment Intent -> Cancelled (payment_intent.cancelled). Use the account default or latest stable API version.',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 5dd37a987b..f4b38b2d85 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -6387,6 +6387,12 @@ export default {
validationNameRequired: '服务商名称不能为空',
validationTypesRequired: '请至少选择一种支持的支付方式',
validationFieldRequired: '{field} 不能为空',
+ validationEasyPayCustomMethodRequired: '每个易支付自定义方式都必须填写支付方式和上游 type',
+ validationEasyPayCustomMethodTypeInvalid: '易支付自定义支付方式只能包含小写字母、数字、下划线和短横线',
+ validationEasyPayCustomMethodUpstreamTypeInvalid: '易支付上游 type 只能包含小写字母、数字、下划线和短横线',
+ validationEasyPayCustomMethodReserved: '易支付自定义支付方式不能使用内置的 alipay 或 wxpay',
+ validationEasyPayCustomMethodPrefixReserved: '易支付自定义支付方式不能以 alipay 或 wxpay 开头',
+ validationEasyPayCustomMethodDuplicate: '易支付自定义支付方式不能重复',
field_apiBase: 'API 基础地址',
field_notifyUrl: '异步通知地址',
field_returnUrl: '同步跳转地址',
@@ -6416,6 +6422,12 @@ export default {
field_cid: '支付渠道 ID',
field_cidAlipay: '支付宝渠道 ID',
field_cidWxpay: '微信渠道 ID',
+ easypayCustomMethods: '易支付自定义方式',
+ easypayCustomMethodsHint: '添加当前易支付接口额外支持的方式。支付方式会记录到 Sub2API 订单中,上游 type 会作为易支付 type 参数提交。',
+ addCustomMethod: '添加方式',
+ customMethodType: '支付方式',
+ customMethodUpstreamType: '上游 type',
+ customMethodDisplayName: '显示名称',
stripeWebhookHint: '请在 Stripe Dashboard 中将以下地址配置为 Webhook 端点:',
stripeWebhookApiVersionHint: 'Webhook 端点的 API 版本请与当前集成的 Stripe SDK 对齐,建议选择 {version};版本不一致可能导致回调事件解析失败。',
airwallexWebhookHint: '请在 Airwallex 后台将以下地址配置为 Webhook 端点;事件至少选择 Payment Intent -> Succeeded(payment_intent.succeeded),建议同时选择 Payment Intent -> Cancelled(payment_intent.cancelled);API version 选择账户默认或最新稳定版本。',
diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts
index 98dab93e8c..303ad9961f 100644
--- a/frontend/src/types/payment.ts
+++ b/frontend/src/types/payment.ts
@@ -43,6 +43,7 @@ export interface PaymentConfig {
export interface MethodLimit {
currency?: string
+ display_name?: string
daily_limit: number
daily_used: number
daily_remaining: number
diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue
index 3ae2f76ea7..ed3288a550 100644
--- a/frontend/src/views/user/PaymentView.vue
+++ b/frontend/src/views/user/PaymentView.vue
@@ -603,6 +603,7 @@ const methodOptions = computed(() =>
const ml = visibleMethods.value[type]
return {
type,
+ display_name: ml?.display_name,
fee_rate: ml?.fee_rate ?? 0,
available: ml?.available !== false && amountFitsMethod(validAmount.value, type),
}
@@ -672,6 +673,7 @@ const subMethodOptions = computed(() => {
const currency = normalizePaymentCurrency(ml?.currency)
return {
type,
+ display_name: ml?.display_name,
fee_rate: ml?.fee_rate ?? 0,
available: ml?.available !== false && amountFitsMethod(subscriptionTotalAmountForCurrency(price, currency), type),
}
From 0dc6e56aae10f9eaacbfa12dbb477fdad9e93eef Mon Sep 17 00:00:00 2001
From: Albert Coady
Date: Mon, 6 Jul 2026 09:37:11 +0800
Subject: [PATCH 08/16] fix: harden easypay custom method validation
---
.../service/payment_config_providers.go | 11 ++-
.../service/payment_config_providers_test.go | 18 ++++
frontend/src/views/user/PaymentView.vue | 1 +
.../views/user/__tests__/PaymentView.spec.ts | 82 +++++++++++++++++++
4 files changed, 108 insertions(+), 4 deletions(-)
diff --git a/backend/internal/service/payment_config_providers.go b/backend/internal/service/payment_config_providers.go
index a00eb9c32f..d1bf2de7aa 100644
--- a/backend/internal/service/payment_config_providers.go
+++ b/backend/internal/service/payment_config_providers.go
@@ -226,7 +226,7 @@ func validateProviderRequest(providerKey, name, supportedTypes string) error {
var easyPayCustomMethodCodePattern = regexp.MustCompile(`^[a-z0-9_-]+$`)
type easyPayCustomMethodConfig struct {
- Type string `json:"type"`
+ Type string `json:"type"`
UpstreamType string `json:"upstreamType"`
DisplayName string `json:"displayName"`
}
@@ -245,8 +245,8 @@ func validateEasyPayCustomMethods(config map[string]string, supportedTypes strin
customTypes := make(map[string]struct{}, len(methods))
for _, method := range methods {
- method.Type = strings.TrimSpace(strings.ToLower(method.Type))
- method.UpstreamType = strings.TrimSpace(strings.ToLower(method.UpstreamType))
+ method.Type = strings.TrimSpace(method.Type)
+ method.UpstreamType = strings.TrimSpace(method.UpstreamType)
if method.Type == "" || method.UpstreamType == "" {
return infraerrors.BadRequest("VALIDATION_ERROR", "customMethods upstreamType is required")
}
@@ -266,10 +266,13 @@ func validateEasyPayCustomMethods(config map[string]string, supportedTypes strin
}
for _, supportedType := range splitTypes(supportedTypes) {
- supportedType = strings.TrimSpace(strings.ToLower(supportedType))
+ supportedType = strings.TrimSpace(supportedType)
if supportedType == "" || supportedType == payment.TypeAlipay || supportedType == payment.TypeWxpay {
continue
}
+ if !easyPayCustomMethodCodePattern.MatchString(supportedType) {
+ return infraerrors.BadRequest("VALIDATION_ERROR", fmt.Sprintf("supported EasyPay custom type %s may only contain lowercase letters, digits, underscores, and hyphens", supportedType))
+ }
if _, exists := customTypes[supportedType]; !exists {
return infraerrors.BadRequest("VALIDATION_ERROR", fmt.Sprintf("supported EasyPay custom type %s has no customMethods mapping", supportedType))
}
diff --git a/backend/internal/service/payment_config_providers_test.go b/backend/internal/service/payment_config_providers_test.go
index 5ff9bfe159..74fd2a3467 100644
--- a/backend/internal/service/payment_config_providers_test.go
+++ b/backend/internal/service/payment_config_providers_test.go
@@ -146,6 +146,18 @@ func TestValidateEasyPayCustomMethods(t *testing.T) {
supportedTypes: "alipay,wxpay,ldc",
wantErr: "duplicate customMethods type",
},
+ {
+ name: "custom type must already be lowercase",
+ config: map[string]string{"customMethods": `[{"type":"LDC","upstreamType":"epay"}]`},
+ supportedTypes: "alipay,wxpay,ldc",
+ wantErr: "customMethods type may only contain lowercase letters",
+ },
+ {
+ name: "upstream type must already be lowercase",
+ config: map[string]string{"customMethods": `[{"type":"ldc","upstreamType":"ALIPAY"}]`},
+ supportedTypes: "alipay,wxpay,ldc",
+ wantErr: "customMethods upstreamType may only contain lowercase letters",
+ },
{
name: "custom type uses alipay prefix",
config: map[string]string{"customMethods": `[{"type":"alipay_hk","upstreamType":"hkpay"}]`},
@@ -164,6 +176,12 @@ func TestValidateEasyPayCustomMethods(t *testing.T) {
supportedTypes: "alipay,wxpay,ldc,usdt_trc20",
wantErr: "supported EasyPay custom type usdt_trc20 has no customMethods mapping",
},
+ {
+ name: "supported custom type must already be lowercase",
+ config: map[string]string{"customMethods": `[{"type":"ldc","upstreamType":"epay"}]`},
+ supportedTypes: "alipay,wxpay,LDC",
+ wantErr: "supported EasyPay custom type LDC may only contain lowercase letters",
+ },
}
for _, tc := range tests {
diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue
index ed3288a550..6e29061013 100644
--- a/frontend/src/views/user/PaymentView.vue
+++ b/frontend/src/views/user/PaymentView.vue
@@ -1119,6 +1119,7 @@ onMounted(async () => {
paymentState.value = restored
paymentPhase.value = 'paying'
const restoredMethod = normalizeVisibleMethod(restored.paymentType)
+ || (visibleMethods.value[restored.paymentType] ? restored.paymentType : '')
if (restoredMethod) {
selectedMethod.value = restoredMethod
}
diff --git a/frontend/src/views/user/__tests__/PaymentView.spec.ts b/frontend/src/views/user/__tests__/PaymentView.spec.ts
index 7591db37a0..aa7c401349 100644
--- a/frontend/src/views/user/__tests__/PaymentView.spec.ts
+++ b/frontend/src/views/user/__tests__/PaymentView.spec.ts
@@ -326,6 +326,88 @@ describe('PaymentView subscription confirmation amounts', () => {
})
})
+describe('PaymentView payment recovery', () => {
+ beforeEach(() => {
+ vi.useRealTimers()
+ routeState.path = '/purchase'
+ routeState.query = {}
+ routerReplace.mockReset().mockResolvedValue(undefined)
+ routerPush.mockReset().mockResolvedValue(undefined)
+ routerResolve.mockClear()
+ createOrder.mockReset()
+ refreshUser.mockReset()
+ fetchActiveSubscriptions.mockReset().mockResolvedValue(undefined)
+ showError.mockReset()
+ showInfo.mockReset()
+ showWarning.mockReset()
+ bridgeInvoke.mockReset()
+ window.localStorage.clear()
+ ;(window as Window & { WeixinJSBridge?: { invoke: typeof bridgeInvoke } }).WeixinJSBridge = undefined
+ })
+
+ it('restores a custom EasyPay method as the selected payment method', async () => {
+ getCheckoutInfo.mockResolvedValue(checkoutInfoFixture({
+ methods: {
+ wxpay: checkoutInfoFixture().data.methods.wxpay,
+ ldc: {
+ daily_limit: 0,
+ daily_used: 0,
+ daily_remaining: 0,
+ single_min: 0,
+ single_max: 0,
+ fee_rate: 0,
+ available: true,
+ display_name: 'LDC Pay',
+ },
+ },
+ }))
+ window.localStorage.setItem(PAYMENT_RECOVERY_STORAGE_KEY, JSON.stringify({
+ orderId: 888,
+ amount: 66,
+ qrCode: 'ldc-qr',
+ expiresAt: '2099-01-01T00:10:00.000Z',
+ paymentType: 'ldc',
+ payUrl: 'https://pay.example.com/ldc',
+ outTradeNo: 'sub2_ldc_888',
+ clientSecret: '',
+ intentId: '',
+ currency: '',
+ countryCode: '',
+ paymentEnv: '',
+ payAmount: 66,
+ orderType: 'balance',
+ paymentMode: 'popup',
+ resumeToken: '',
+ createdAt: Date.now(),
+ }))
+
+ const wrapper = shallowMount(PaymentView, {
+ global: {
+ stubs: {
+ AppLayout: {
+ template: '
',
+ },
+ PaymentStatusPanel: {
+ template: '',
+ },
+ PaymentMethodSelector: {
+ props: ['selected'],
+ template: '{{ selected }}
',
+ },
+ Teleport: true,
+ Transition: false,
+ },
+ },
+ })
+ await flushPromises()
+ await flushPromises()
+ await wrapper.find('[data-test="payment-done"]').trigger('click')
+ await flushPromises()
+
+ expect(wrapper.find('[data-test="method-selector"]').text()).toBe('ldc')
+ })
+})
+
describe('PaymentView WeChat JSAPI flow', () => {
beforeEach(() => {
routeState.path = '/purchase'
From a5a2fea04533511334002955386d9b178b4d0bfb Mon Sep 17 00:00:00 2001
From: Albert Coady
Date: Mon, 6 Jul 2026 10:59:04 +0800
Subject: [PATCH 09/16] Polish EasyPay custom method UI
---
frontend/src/assets/icons/payment.svg | 9 ++
.../payment/PaymentMethodSelector.vue | 4 +-
.../payment/PaymentProviderDialog.vue | 86 +++++++++----------
.../__tests__/PaymentProviderDialog.spec.ts | 14 +--
frontend/src/i18n/locales/zh.ts | 4 +-
5 files changed, 65 insertions(+), 52 deletions(-)
create mode 100644 frontend/src/assets/icons/payment.svg
diff --git a/frontend/src/assets/icons/payment.svg b/frontend/src/assets/icons/payment.svg
new file mode 100644
index 0000000000..c78bea4cf7
--- /dev/null
+++ b/frontend/src/assets/icons/payment.svg
@@ -0,0 +1,9 @@
+
diff --git a/frontend/src/components/payment/PaymentMethodSelector.vue b/frontend/src/components/payment/PaymentMethodSelector.vue
index 8ed565053c..ed31578c27 100644
--- a/frontend/src/components/payment/PaymentMethodSelector.vue
+++ b/frontend/src/components/payment/PaymentMethodSelector.vue
@@ -44,6 +44,7 @@ import alipayIcon from '@/assets/icons/alipay.svg'
import wxpayIcon from '@/assets/icons/wxpay.svg'
import stripeIcon from '@/assets/icons/stripe.svg'
import airwallexIcon from '@/assets/icons/airwallex.svg'
+import paymentIcon from '@/assets/icons/payment.svg'
export interface PaymentMethodOption {
type: string
@@ -68,6 +69,7 @@ const METHOD_ICONS: Record = {
wxpay: wxpayIcon,
stripe: stripeIcon,
airwallex: airwallexIcon,
+ credit_card: paymentIcon,
}
const sortedMethods = computed(() => {
@@ -83,7 +85,7 @@ function methodIcon(type: string): string {
if (type.includes('alipay')) return METHOD_ICONS.alipay
if (type.includes('wxpay')) return METHOD_ICONS.wxpay
if (type === 'airwallex') return METHOD_ICONS.airwallex
- return METHOD_ICONS[type] || stripeIcon
+ return METHOD_ICONS[type] || paymentIcon
}
function methodLabel(method: PaymentMethodOption): string {
diff --git a/frontend/src/components/payment/PaymentProviderDialog.vue b/frontend/src/components/payment/PaymentProviderDialog.vue
index 4c2dccbab5..838a33dd9a 100644
--- a/frontend/src/components/payment/PaymentProviderDialog.vue
+++ b/frontend/src/components/payment/PaymentProviderDialog.vue
@@ -70,6 +70,49 @@
+
+
+
+
+ {{ t('admin.settings.payment.easypayCustomMethods') }}
+
+
+ {{ t('admin.settings.payment.easypayCustomMethodsHint') }}
+
+
+
+
+
+
+
@@ -168,49 +211,6 @@
-
-
-
-
- {{ t('admin.settings.payment.easypayCustomMethods') }}
-
-
- {{ t('admin.settings.payment.easypayCustomMethodsHint') }}
-
-
-
-
-
-
-
diff --git a/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts b/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts
index 9221455275..a84ff4cbda 100644
--- a/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts
+++ b/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts
@@ -187,9 +187,10 @@ describe('PaymentProviderDialog payment guide', () => {
await nextTick()
const inputs = wrapper.findAll('input[type="text"]')
- const ldcTypeInput = inputs.find(input => (input.element as HTMLInputElement).placeholder === 'ldc')
- const upstreamTypeInput = inputs.find(input => (input.element as HTMLInputElement).placeholder === 'epay')
- const displayNameInput = inputs.find(input => (input.element as HTMLInputElement).placeholder === 'LDC')
+ const customTypeInputs = inputs.filter(input => (input.element as HTMLInputElement).placeholder === 'credit_card')
+ const ldcTypeInput = customTypeInputs[0]
+ const upstreamTypeInput = customTypeInputs[1]
+ const displayNameInput = inputs.find(input => (input.element as HTMLInputElement).placeholder === '信用卡')
if (!ldcTypeInput || !upstreamTypeInput || !displayNameInput) {
throw new Error('custom method inputs not found')
}
@@ -229,9 +230,10 @@ describe('PaymentProviderDialog payment guide', () => {
await nextTick()
const inputs = wrapper.findAll('input[type="text"]')
- const typeInput = inputs.find(input => (input.element as HTMLInputElement).placeholder === 'ldc')
- const upstreamTypeInput = inputs.find(input => (input.element as HTMLInputElement).placeholder === 'epay')
- const displayNameInput = inputs.find(input => (input.element as HTMLInputElement).placeholder === 'LDC')
+ const customTypeInputs = inputs.filter(input => (input.element as HTMLInputElement).placeholder === 'credit_card')
+ const typeInput = customTypeInputs[0]
+ const upstreamTypeInput = customTypeInputs[1]
+ const displayNameInput = inputs.find(input => (input.element as HTMLInputElement).placeholder === '信用卡')
if (!typeInput || !upstreamTypeInput || !displayNameInput) {
throw new Error('custom method inputs not found')
}
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index f4b38b2d85..bd49ba0ccf 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -6422,8 +6422,8 @@ export default {
field_cid: '支付渠道 ID',
field_cidAlipay: '支付宝渠道 ID',
field_cidWxpay: '微信渠道 ID',
- easypayCustomMethods: '易支付自定义方式',
- easypayCustomMethodsHint: '添加当前易支付接口额外支持的方式。支付方式会记录到 Sub2API 订单中,上游 type 会作为易支付 type 参数提交。',
+ easypayCustomMethods: '易支付自定义支付方式',
+ easypayCustomMethodsHint: '添加当前易支付服务商额外支持的支付方式。支付方式会记录到 Sub2API 订单中,上游 type 会作为易支付 type 参数提交。',
addCustomMethod: '添加方式',
customMethodType: '支付方式',
customMethodUpstreamType: '上游 type',
From b197ba61cefd1552f397a1524354c0374271f190 Mon Sep 17 00:00:00 2001
From: Albert Coady
Date: Mon, 6 Jul 2026 14:06:43 +0800
Subject: [PATCH 10/16] test: align antigravity mapping preset label
---
.../components/account/__tests__/BulkEditAccountModal.spec.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
index 31f6e3bd26..d094f5366d 100644
--- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
+++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
@@ -107,7 +107,7 @@ describe('BulkEditAccountModal', () => {
expect(mappingTab).toBeTruthy()
await mappingTab!.trigger('click')
- expect(wrapper.text()).toContain('3.1-Flash-Image透传')
+ expect(wrapper.text()).toContain('3.1-Flash-Image passthrough')
expect(wrapper.text()).toContain('3-Pro-Image→3.1')
expect(wrapper.text()).not.toContain('GPT-5.3 Codex Spark')
})
From 22ec77b5706ed52d133122275dc4c4b941286116 Mon Sep 17 00:00:00 2001
From: Albert Coady
Date: Mon, 6 Jul 2026 14:16:53 +0800
Subject: [PATCH 11/16] fix: match built-in payment methods exactly
---
.../components/payment/PaymentMethodSelector.vue | 16 ++++++++++++----
.../__tests__/PaymentMethodSelector.spec.ts | 13 +++++++++++++
2 files changed, 25 insertions(+), 4 deletions(-)
diff --git a/frontend/src/components/payment/PaymentMethodSelector.vue b/frontend/src/components/payment/PaymentMethodSelector.vue
index ed31578c27..a88f91f2e5 100644
--- a/frontend/src/components/payment/PaymentMethodSelector.vue
+++ b/frontend/src/components/payment/PaymentMethodSelector.vue
@@ -82,8 +82,8 @@ const sortedMethods = computed(() => {
})
function methodIcon(type: string): string {
- if (type.includes('alipay')) return METHOD_ICONS.alipay
- if (type.includes('wxpay')) return METHOD_ICONS.wxpay
+ if (isAlipayMethod(type)) return METHOD_ICONS.alipay
+ if (isWxpayMethod(type)) return METHOD_ICONS.wxpay
if (type === 'airwallex') return METHOD_ICONS.airwallex
return METHOD_ICONS[type] || paymentIcon
}
@@ -93,10 +93,18 @@ function methodLabel(method: PaymentMethodOption): string {
}
function methodSelectedClass(type: string): string {
- if (type.includes('alipay')) return 'border-[#02A9F1] bg-blue-50 text-gray-900 shadow-sm dark:bg-blue-950 dark:text-gray-100'
- if (type.includes('wxpay')) return 'border-[#09BB07] bg-green-50 text-gray-900 shadow-sm dark:bg-green-950 dark:text-gray-100'
+ if (isAlipayMethod(type)) return 'border-[#02A9F1] bg-blue-50 text-gray-900 shadow-sm dark:bg-blue-950 dark:text-gray-100'
+ if (isWxpayMethod(type)) return 'border-[#09BB07] bg-green-50 text-gray-900 shadow-sm dark:bg-green-950 dark:text-gray-100'
if (type === 'stripe') return 'border-[#676BE5] bg-indigo-50 text-gray-900 shadow-sm dark:bg-indigo-950 dark:text-gray-100'
if (type === 'airwallex') return 'border-[#FF6B3D] bg-orange-50 text-gray-900 shadow-sm dark:border-[#FF8E3C] dark:bg-orange-950 dark:text-gray-100'
return 'border-primary-500 bg-primary-50 text-gray-900 shadow-sm dark:bg-primary-950 dark:text-gray-100'
}
+
+function isAlipayMethod(type: string): boolean {
+ return type === 'alipay' || type === 'alipay_direct'
+}
+
+function isWxpayMethod(type: string): boolean {
+ return type === 'wxpay' || type === 'wxpay_direct'
+}
diff --git a/frontend/src/components/payment/__tests__/PaymentMethodSelector.spec.ts b/frontend/src/components/payment/__tests__/PaymentMethodSelector.spec.ts
index 3e3bfcc4c0..e481325fe7 100644
--- a/frontend/src/components/payment/__tests__/PaymentMethodSelector.spec.ts
+++ b/frontend/src/components/payment/__tests__/PaymentMethodSelector.spec.ts
@@ -21,4 +21,17 @@ describe('PaymentMethodSelector', () => {
expect(wrapper.text()).not.toContain('ldc')
expect(wrapper.text()).not.toContain('payment.methods.ldc')
})
+
+ it('uses the generic selected style for custom methods that contain built-in names', () => {
+ const wrapper = mount(PaymentMethodSelector, {
+ props: {
+ selected: 'card_alipay',
+ methods: [{ type: 'card_alipay', display_name: 'Card Pay', fee_rate: 0, available: true }],
+ },
+ })
+
+ const button = wrapper.get('button')
+ expect(button.classes()).toContain('border-primary-500')
+ expect(button.classes()).not.toContain('border-[#02A9F1]')
+ })
})
From 27cb485d55f71898f767742f72d7f0efc4f8d0ea Mon Sep 17 00:00:00 2001
From: Albert Coady
Date: Mon, 6 Jul 2026 14:26:42 +0800
Subject: [PATCH 12/16] fix: share built-in payment method matching
---
.../payment/PaymentMethodSelector.vue | 18 +++++----------
.../components/payment/PaymentQRDialog.vue | 6 ++---
.../components/payment/PaymentStatusPanel.vue | 15 +++++++++----
.../__tests__/PaymentStatusPanel.spec.ts | 22 +++++++++++++++++++
.../payment/__tests__/providerConfig.spec.ts | 14 ++++++++++++
.../src/components/payment/providerConfig.ts | 8 +++++++
frontend/src/views/user/PaymentQRCodeView.vue | 5 +++--
frontend/src/views/user/PaymentView.vue | 6 ++---
8 files changed, 69 insertions(+), 25 deletions(-)
diff --git a/frontend/src/components/payment/PaymentMethodSelector.vue b/frontend/src/components/payment/PaymentMethodSelector.vue
index a88f91f2e5..2c02340ed4 100644
--- a/frontend/src/components/payment/PaymentMethodSelector.vue
+++ b/frontend/src/components/payment/PaymentMethodSelector.vue
@@ -39,7 +39,7 @@
diff --git a/frontend/src/components/payment/PaymentQRDialog.vue b/frontend/src/components/payment/PaymentQRDialog.vue
index f6278e93e0..7dff831a6a 100644
--- a/frontend/src/components/payment/PaymentQRDialog.vue
+++ b/frontend/src/components/payment/PaymentQRDialog.vue
@@ -79,7 +79,7 @@ import { usePaymentStore } from '@/stores/payment'
import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment'
import { extractI18nErrorMessage } from '@/utils/apiError'
-import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
+import { getPaymentPopupFeatures, isBuiltInAlipayMethod, isBuiltInWxpayMethod } from '@/components/payment/providerConfig'
import type { PaymentOrder } from '@/types/payment'
import { currencySymbol } from '@/components/payment/currency'
import QRCode from 'qrcode'
@@ -122,8 +122,8 @@ let lastVerifyAt = 0
const VERIFY_RETRY_INTERVAL_MS = 15000
const VERIFY_RETRY_MAX_ATTEMPTS = 6
-const isAlipay = computed(() => props.paymentType.includes('alipay'))
-const isWxpay = computed(() => props.paymentType.includes('wxpay'))
+const isAlipay = computed(() => isBuiltInAlipayMethod(props.paymentType))
+const isWxpay = computed(() => isBuiltInWxpayMethod(props.paymentType))
const dialogTitle = computed(() => {
if (success.value) return t('payment.result.success')
diff --git a/frontend/src/components/payment/PaymentStatusPanel.vue b/frontend/src/components/payment/PaymentStatusPanel.vue
index d77db58a2f..c7232fd640 100644
--- a/frontend/src/components/payment/PaymentStatusPanel.vue
+++ b/frontend/src/components/payment/PaymentStatusPanel.vue
@@ -79,7 +79,7 @@
-
+
@@ -128,13 +128,14 @@ import { usePaymentStore } from '@/stores/payment'
import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment'
import { extractI18nErrorMessage } from '@/utils/apiError'
-import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
+import { getPaymentPopupFeatures, isBuiltInAlipayMethod, isBuiltInWxpayMethod } from '@/components/payment/providerConfig'
import { currencySymbol, formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import type { PaymentOrder } from '@/types/payment'
import Icon from '@/components/icons/Icon.vue'
import QRCode from 'qrcode'
import alipayIcon from '@/assets/icons/alipay.svg'
import wxpayIcon from '@/assets/icons/wxpay.svg'
+import paymentIcon from '@/assets/icons/payment.svg'
const props = defineProps<{
orderId: number
@@ -182,8 +183,8 @@ let lastVerifyAt = 0
const VERIFY_RETRY_INTERVAL_MS = 15000
const VERIFY_RETRY_MAX_ATTEMPTS = 6
-const isAlipay = computed(() => props.paymentType.includes('alipay'))
-const isWxpay = computed(() => props.paymentType.includes('wxpay'))
+const isAlipay = computed(() => isBuiltInAlipayMethod(props.paymentType))
+const isWxpay = computed(() => isBuiltInWxpayMethod(props.paymentType))
const qrBorderClass = computed(() => {
if (isAlipay.value) return 'border-[#00AEEF] bg-blue-50 dark:border-[#00AEEF]/70 dark:bg-blue-950/20'
@@ -197,6 +198,12 @@ const qrLogoBgClass = computed(() => {
return 'bg-gray-400'
})
+const qrLogoIcon = computed(() => {
+ if (isAlipay.value) return alipayIcon
+ if (isWxpay.value) return wxpayIcon
+ return paymentIcon
+})
+
const scanTitle = computed(() => {
if (isAlipay.value) return t('payment.qr.scanAlipay')
if (isWxpay.value) return t('payment.qr.scanWxpay')
diff --git a/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts b/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts
index 7e39247831..d5919867c5 100644
--- a/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts
+++ b/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts
@@ -132,6 +132,28 @@ describe('PaymentStatusPanel', () => {
openSpy.mockRestore()
})
+ it('uses generic QR copy for custom methods that contain built-in names', async () => {
+ const wrapper = mount(PaymentStatusPanel, {
+ props: {
+ orderId: 42,
+ qrCode: 'https://pay.example.com/qr/42',
+ expiresAt: '2099-01-01T12:30:00Z',
+ paymentType: 'card_alipay',
+ orderType: 'balance',
+ },
+ global: {
+ stubs: {
+ Icon: true,
+ },
+ },
+ })
+
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('payment.qr.scanToPay')
+ expect(wrapper.text()).not.toContain('payment.qr.scanAlipay')
+ })
+
it('actively verifies a stuck pending order and settles it when upstream confirms payment', async () => {
pollOrderStatus.mockResolvedValue(orderFactory('PENDING'))
verifyOrder.mockResolvedValue({
diff --git a/frontend/src/components/payment/__tests__/providerConfig.spec.ts b/frontend/src/components/payment/__tests__/providerConfig.spec.ts
index 4c20ae725c..267693b5cb 100644
--- a/frontend/src/components/payment/__tests__/providerConfig.spec.ts
+++ b/frontend/src/components/payment/__tests__/providerConfig.spec.ts
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
import {
PAYMENT_CURRENCY_OPTIONS,
PROVIDER_CONFIG_FIELDS,
+ isBuiltInAlipayMethod,
+ isBuiltInWxpayMethod,
parseEasyPayCustomMethods,
serializeEasyPayCustomMethods,
} from '@/components/payment/providerConfig'
@@ -79,3 +81,15 @@ describe('EasyPay custom methods config', () => {
expect(serializeEasyPayCustomMethods([{ type: '', upstreamType: 'epay', displayName: 'LDC' }])).toBe('')
})
})
+
+describe('built-in payment method helpers', () => {
+ it('only treats exact built-in aliases as Alipay or WeChat Pay', () => {
+ expect(isBuiltInAlipayMethod('alipay')).toBe(true)
+ expect(isBuiltInAlipayMethod('alipay_direct')).toBe(true)
+ expect(isBuiltInAlipayMethod('card_alipay')).toBe(false)
+
+ expect(isBuiltInWxpayMethod('wxpay')).toBe(true)
+ expect(isBuiltInWxpayMethod('wxpay_direct')).toBe(true)
+ expect(isBuiltInWxpayMethod('card_wxpay')).toBe(false)
+ })
+})
diff --git a/frontend/src/components/payment/providerConfig.ts b/frontend/src/components/payment/providerConfig.ts
index 203bfb6818..395c32725f 100644
--- a/frontend/src/components/payment/providerConfig.ts
+++ b/frontend/src/components/payment/providerConfig.ts
@@ -50,6 +50,14 @@ export const EASYPAY_PAYMENT_MODES = ['qrcode', 'popup'] as const
/** Fixed display order for user-facing payment methods */
export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct', 'stripe', 'airwallex'] as const
+export function isBuiltInAlipayMethod(type: string): boolean {
+ return type === 'alipay' || type === 'alipay_direct'
+}
+
+export function isBuiltInWxpayMethod(type: string): boolean {
+ return type === 'wxpay' || type === 'wxpay_direct'
+}
+
/** Payment mode constants */
export const PAYMENT_MODE_QRCODE = 'qrcode'
export const PAYMENT_MODE_POPUP = 'popup'
diff --git a/frontend/src/views/user/PaymentQRCodeView.vue b/frontend/src/views/user/PaymentQRCodeView.vue
index f844858daf..5df67d0fe9 100644
--- a/frontend/src/views/user/PaymentQRCodeView.vue
+++ b/frontend/src/views/user/PaymentQRCodeView.vue
@@ -41,6 +41,7 @@ import { usePaymentStore } from '@/stores/payment'
import { paymentAPI } from '@/api/payment'
import { extractI18nErrorMessage } from '@/utils/apiError'
import { useAppStore } from '@/stores'
+import { isBuiltInAlipayMethod, isBuiltInWxpayMethod } from '@/components/payment/providerConfig'
import QRCode from 'qrcode'
import alipayIcon from '@/assets/icons/alipay.svg'
import wxpayIcon from '@/assets/icons/wxpay.svg'
@@ -69,8 +70,8 @@ const countdownDisplay = computed(() => {
return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0')
})
-const isAlipay = computed(() => paymentType.value.includes('alipay'))
-const isWxpay = computed(() => paymentType.value.includes('wxpay'))
+const isAlipay = computed(() => isBuiltInAlipayMethod(paymentType.value))
+const isWxpay = computed(() => isBuiltInWxpayMethod(paymentType.value))
const scanTitle = computed(() => {
if (isAlipay.value) return t('payment.qr.scanAlipay')
diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue
index 6e29061013..6d1d2fd31e 100644
--- a/frontend/src/views/user/PaymentView.vue
+++ b/frontend/src/views/user/PaymentView.vue
@@ -267,7 +267,7 @@ import type { SubscriptionPlan, CheckoutInfoResponse, CreateOrderResult, OrderTy
import AppLayout from '@/components/layout/AppLayout.vue'
import AmountInput from '@/components/payment/AmountInput.vue'
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
-import { METHOD_ORDER, getPaymentPopupFeatures } from '@/components/payment/providerConfig'
+import { METHOD_ORDER, getPaymentPopupFeatures, isBuiltInAlipayMethod, isBuiltInWxpayMethod } from '@/components/payment/providerConfig'
import {
PAYMENT_RECOVERY_STORAGE_KEY,
buildCreateOrderPayload,
@@ -697,8 +697,8 @@ watch(() => [validAmount.value, selectedMethod.value] as const, ([amt, method])
const paymentButtonClass = computed(() => {
const m = selectedMethod.value
if (!m) return 'btn-primary'
- if (m.includes('alipay')) return 'btn-alipay'
- if (m.includes('wxpay')) return 'btn-wxpay'
+ if (isBuiltInAlipayMethod(m)) return 'btn-alipay'
+ if (isBuiltInWxpayMethod(m)) return 'btn-wxpay'
if (m === 'stripe') return 'btn-stripe'
if (m === 'airwallex') return 'btn-airwallex'
return 'btn-primary'
From 76bb7b0338882ed6a02f9e51ac012b4f878ac460 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Mon, 6 Jul 2026 08:30:51 +0000
Subject: [PATCH 13/16] chore: sync VERSION to 0.1.145 [skip ci]
---
backend/cmd/server/VERSION | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION
index 3ef481c326..a0e8ec1d4e 100644
--- a/backend/cmd/server/VERSION
+++ b/backend/cmd/server/VERSION
@@ -1 +1 @@
-0.1.144
+0.1.145
From 6cea1c35bb0e4a86ab6b00370e9cede9540da8de Mon Sep 17 00:00:00 2001
From: shaw
Date: Mon, 6 Jul 2026 17:34:22 +0800
Subject: [PATCH 14/16] =?UTF-8?q?feat:=20=E9=80=82=E9=85=8D=20OpenAI=20?=
=?UTF-8?q?=E6=96=B0=E6=A8=A1=E5=9E=8B=20gpt-5.6-sol/terra/luna?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/internal/pkg/openai/constants.go | 3 +
backend/internal/service/billing_service.go | 14 +-
.../service/openai_codex_transform.go | 6 +
.../internal/service/openai_model_alias.go | 6 +
backend/internal/service/pricing_service.go | 7 +
.../model_prices_and_context_window.json | 144 ++++++++++++++++++
frontend/src/components/keys/UseKeyModal.vue | 48 ++++++
frontend/src/composables/useModelWhitelist.ts | 5 +
8 files changed, 232 insertions(+), 1 deletion(-)
diff --git a/backend/internal/pkg/openai/constants.go b/backend/internal/pkg/openai/constants.go
index f658cf0675..c9d391df4e 100644
--- a/backend/internal/pkg/openai/constants.go
+++ b/backend/internal/pkg/openai/constants.go
@@ -18,6 +18,9 @@ type Model struct {
// DefaultModels OpenAI models list
var DefaultModels = []Model{
+ {ID: "gpt-5.6-sol", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 Sol"},
+ {ID: "gpt-5.6-terra", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 Terra"},
+ {ID: "gpt-5.6-luna", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 Luna"},
{ID: "gpt-5.5", Object: "model", Created: 1776873600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.5"},
{ID: "gpt-5.4", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.4"},
{ID: "gpt-5.4-mini", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.4 Mini"},
diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go
index a781936598..dc54a1b1f3 100644
--- a/backend/internal/service/billing_service.go
+++ b/backend/internal/service/billing_service.go
@@ -280,6 +280,11 @@ func (s *BillingService) initFallbackPricing() {
s.fallbackPrices["gpt-5.5"] = s.fallbackPrices["gpt-5.4"]
s.fallbackPrices["gpt-5.5-pro"] = s.fallbackPrices["gpt-5.4"]
+ // GPT-5.6(sol / terra / luna)暂无独立定价,回退到 GPT-5.4。
+ s.fallbackPrices["gpt-5.6-sol"] = s.fallbackPrices["gpt-5.4"]
+ s.fallbackPrices["gpt-5.6-terra"] = s.fallbackPrices["gpt-5.4"]
+ s.fallbackPrices["gpt-5.6-luna"] = s.fallbackPrices["gpt-5.4"]
+
s.fallbackPrices["gpt-5.4-mini"] = &ModelPricing{
InputPricePerToken: 7.5e-7,
OutputPricePerToken: 4.5e-6,
@@ -667,6 +672,12 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
// OpenAI(GPT-5 / Codex 族):仅匹配已知型号,避免未知 OpenAI 型号误计价。
if normalized := normalizeKnownOpenAICodexModel(modelLower); normalized != "" {
switch normalized {
+ case "gpt-5.6-sol":
+ return s.fallbackPrices["gpt-5.6-sol"]
+ case "gpt-5.6-terra":
+ return s.fallbackPrices["gpt-5.6-terra"]
+ case "gpt-5.6-luna":
+ return s.fallbackPrices["gpt-5.6-luna"]
case "gpt-5.5-pro":
return s.fallbackPrices["gpt-5.5-pro"]
case "gpt-5.5":
@@ -1060,7 +1071,8 @@ func isOpenAIGPT54Model(model string) bool {
// normalizeCodexModel 的默认兜底把非 OpenAI 模型(claude-*、gemini-*、gpt-4o)
// 误识别为 gpt-5.4。
normalized := normalizeKnownOpenAICodexModel(model)
- return normalized == "gpt-5.4" || normalized == "gpt-5.5" || normalized == "gpt-5.5-pro"
+ return normalized == "gpt-5.4" || normalized == "gpt-5.5" || normalized == "gpt-5.5-pro" ||
+ normalized == "gpt-5.6-sol" || normalized == "gpt-5.6-terra" || normalized == "gpt-5.6-luna"
}
// CalculateCostWithConfig 使用配置中的默认倍率计算费用
diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go
index c33ac97f0f..0666293deb 100644
--- a/backend/internal/service/openai_codex_transform.go
+++ b/backend/internal/service/openai_codex_transform.go
@@ -9,6 +9,9 @@ import (
)
var codexModelMap = map[string]string{
+ "gpt-5.6-sol": "gpt-5.6-sol",
+ "gpt-5.6-terra": "gpt-5.6-terra",
+ "gpt-5.6-luna": "gpt-5.6-luna",
"gpt-5.5": "gpt-5.5",
"gpt-5.5-pro": "gpt-5.5-pro",
"codex-auto-review": "codex-auto-review",
@@ -54,6 +57,9 @@ var codexVersionModelPrefixes = []struct {
prefix string
target string
}{
+ {prefix: "gpt-5.6-sol", target: "gpt-5.6-sol"},
+ {prefix: "gpt-5.6-terra", target: "gpt-5.6-terra"},
+ {prefix: "gpt-5.6-luna", target: "gpt-5.6-luna"},
{prefix: "gpt-5.3-codex-spark", target: "gpt-5.3-codex-spark"},
{prefix: "gpt-5.3-codex", target: "gpt-5.3-codex"},
{prefix: "gpt-5.4-mini", target: "gpt-5.4-mini"},
diff --git a/backend/internal/service/openai_model_alias.go b/backend/internal/service/openai_model_alias.go
index ac2a8cf942..4e3d3b2d9a 100644
--- a/backend/internal/service/openai_model_alias.go
+++ b/backend/internal/service/openai_model_alias.go
@@ -65,6 +65,12 @@ func normalizeKnownOpenAICodexModel(model string) string {
}
switch {
+ case strings.Contains(normalized, "gpt-5.6-sol"):
+ return "gpt-5.6-sol"
+ case strings.Contains(normalized, "gpt-5.6-terra"):
+ return "gpt-5.6-terra"
+ case strings.Contains(normalized, "gpt-5.6-luna"):
+ return "gpt-5.6-luna"
case strings.Contains(normalized, "gpt-5.5-pro"):
return "gpt-5.5-pro"
case strings.Contains(normalized, "gpt-5.5"):
diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go
index bd0c30df45..1a0b603169 100644
--- a/backend/internal/service/pricing_service.go
+++ b/backend/internal/service/pricing_service.go
@@ -798,6 +798,13 @@ func (s *PricingService) matchOpenAIModel(model string) *LiteLLMModelPricing {
}
}
+ // GPT-5.6(sol / terra / luna)回退到 GPT-5.4 定价
+ if strings.HasPrefix(model, "gpt-5.6") {
+ logger.With(zap.String("component", "service.pricing")).
+ Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.4(static)"))
+ return openAIGPT54FallbackPricing
+ }
+
// GPT-5.5 回退到 GPT-5.4 定价
if strings.HasPrefix(model, "gpt-5.5") {
logger.With(zap.String("component", "service.pricing")).
diff --git a/backend/resources/model-pricing/model_prices_and_context_window.json b/backend/resources/model-pricing/model_prices_and_context_window.json
index e88ed2da22..e7d4e7ded3 100644
--- a/backend/resources/model-pricing/model_prices_and_context_window.json
+++ b/backend/resources/model-pricing/model_prices_and_context_window.json
@@ -4886,6 +4886,150 @@
"supports_web_search": true,
"supports_xhigh_reasoning_effort": true
},
+ "gpt-5.6-sol": {
+ "cache_read_input_token_cost": 5e-07,
+ "cache_read_input_token_cost_above_272k_tokens": 1e-06,
+ "cache_read_input_token_cost_flex": 2.5e-07,
+ "cache_read_input_token_cost_priority": 1e-06,
+ "input_cost_per_token": 5e-06,
+ "input_cost_per_token_above_272k_tokens": 1e-05,
+ "input_cost_per_token_batches": 2.5e-06,
+ "input_cost_per_token_flex": 2.5e-06,
+ "input_cost_per_token_priority": 1e-05,
+ "litellm_provider": "openai",
+ "max_input_tokens": 1050000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 3e-05,
+ "output_cost_per_token_above_272k_tokens": 4.5e-05,
+ "output_cost_per_token_batches": 1.5e-05,
+ "output_cost_per_token_flex": 1.5e-05,
+ "output_cost_per_token_priority": 6e-05,
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/v1/batch",
+ "/v1/responses"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_function_calling": true,
+ "supports_minimal_reasoning_effort": false,
+ "supports_native_streaming": true,
+ "supports_none_reasoning_effort": true,
+ "supports_parallel_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_service_tier": true,
+ "supports_system_messages": true,
+ "supports_tool_choice": true,
+ "supports_vision": true,
+ "supports_web_search": true,
+ "supports_xhigh_reasoning_effort": true
+ },
+ "gpt-5.6-terra": {
+ "cache_read_input_token_cost": 5e-07,
+ "cache_read_input_token_cost_above_272k_tokens": 1e-06,
+ "cache_read_input_token_cost_flex": 2.5e-07,
+ "cache_read_input_token_cost_priority": 1e-06,
+ "input_cost_per_token": 5e-06,
+ "input_cost_per_token_above_272k_tokens": 1e-05,
+ "input_cost_per_token_batches": 2.5e-06,
+ "input_cost_per_token_flex": 2.5e-06,
+ "input_cost_per_token_priority": 1e-05,
+ "litellm_provider": "openai",
+ "max_input_tokens": 1050000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 3e-05,
+ "output_cost_per_token_above_272k_tokens": 4.5e-05,
+ "output_cost_per_token_batches": 1.5e-05,
+ "output_cost_per_token_flex": 1.5e-05,
+ "output_cost_per_token_priority": 6e-05,
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/v1/batch",
+ "/v1/responses"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_function_calling": true,
+ "supports_minimal_reasoning_effort": false,
+ "supports_native_streaming": true,
+ "supports_none_reasoning_effort": true,
+ "supports_parallel_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_service_tier": true,
+ "supports_system_messages": true,
+ "supports_tool_choice": true,
+ "supports_vision": true,
+ "supports_web_search": true,
+ "supports_xhigh_reasoning_effort": true
+ },
+ "gpt-5.6-luna": {
+ "cache_read_input_token_cost": 5e-07,
+ "cache_read_input_token_cost_above_272k_tokens": 1e-06,
+ "cache_read_input_token_cost_flex": 2.5e-07,
+ "cache_read_input_token_cost_priority": 1e-06,
+ "input_cost_per_token": 5e-06,
+ "input_cost_per_token_above_272k_tokens": 1e-05,
+ "input_cost_per_token_batches": 2.5e-06,
+ "input_cost_per_token_flex": 2.5e-06,
+ "input_cost_per_token_priority": 1e-05,
+ "litellm_provider": "openai",
+ "max_input_tokens": 1050000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 3e-05,
+ "output_cost_per_token_above_272k_tokens": 4.5e-05,
+ "output_cost_per_token_batches": 1.5e-05,
+ "output_cost_per_token_flex": 1.5e-05,
+ "output_cost_per_token_priority": 6e-05,
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/v1/batch",
+ "/v1/responses"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_function_calling": true,
+ "supports_minimal_reasoning_effort": false,
+ "supports_native_streaming": true,
+ "supports_none_reasoning_effort": true,
+ "supports_parallel_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_service_tier": true,
+ "supports_system_messages": true,
+ "supports_tool_choice": true,
+ "supports_vision": true,
+ "supports_web_search": true,
+ "supports_xhigh_reasoning_effort": true
+ },
"gpt-5.5": {
"cache_read_input_token_cost": 5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue
index 6a08fb1722..5900644814 100644
--- a/frontend/src/components/keys/UseKeyModal.vue
+++ b/frontend/src/components/keys/UseKeyModal.vue
@@ -636,6 +636,54 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
xhigh: {}
}
},
+ 'gpt-5.6-sol': {
+ name: 'GPT-5.6 Sol',
+ limit: {
+ context: 1050000,
+ output: 128000
+ },
+ options: {
+ store: false
+ },
+ variants: {
+ low: {},
+ medium: {},
+ high: {},
+ xhigh: {}
+ }
+ },
+ 'gpt-5.6-terra': {
+ name: 'GPT-5.6 Terra',
+ limit: {
+ context: 1050000,
+ output: 128000
+ },
+ options: {
+ store: false
+ },
+ variants: {
+ low: {},
+ medium: {},
+ high: {},
+ xhigh: {}
+ }
+ },
+ 'gpt-5.6-luna': {
+ name: 'GPT-5.6 Luna',
+ limit: {
+ context: 1050000,
+ output: 128000
+ },
+ options: {
+ store: false
+ },
+ variants: {
+ low: {},
+ medium: {},
+ high: {},
+ xhigh: {}
+ }
+ },
'gpt-5.5': {
name: 'GPT-5.5',
limit: {
diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts
index f5f933520b..06cb95a393 100644
--- a/frontend/src/composables/useModelWhitelist.ts
+++ b/frontend/src/composables/useModelWhitelist.ts
@@ -7,6 +7,8 @@ const openaiModels = [
// GPT-5.2 系列
'gpt-5.2', 'gpt-5.2-2025-12-11', 'gpt-5.2-chat-latest',
'gpt-5.2-pro', 'gpt-5.2-pro-2025-12-11',
+ // GPT-5.6 系列
+ 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna',
// GPT-5.5 系列
'gpt-5.5',
// GPT-5.4 系列
@@ -272,6 +274,9 @@ const openaiPresetMappings = [
{ label: 'o3', from: 'o3', to: 'o3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
{ label: 'GPT-5.3 Codex Spark', from: 'gpt-5.3-codex-spark', to: 'gpt-5.3-codex-spark', color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400' },
{ label: 'GPT-5.2', from: 'gpt-5.2', to: 'gpt-5.2', color: 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400' },
+ { label: 'GPT-5.6 Sol', from: 'gpt-5.6-sol', to: 'gpt-5.6-sol', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
+ { label: 'GPT-5.6 Terra', from: 'gpt-5.6-terra', to: 'gpt-5.6-terra', color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400' },
+ { label: 'GPT-5.6 Luna', from: 'gpt-5.6-luna', to: 'gpt-5.6-luna', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
{ label: 'GPT-5.5', from: 'gpt-5.5', to: 'gpt-5.5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
{ label: 'GPT-5.4', from: 'gpt-5.4', to: 'gpt-5.4', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
{ label: 'Haiku→5.4', from: 'claude-haiku-4-5-20251001', to: 'gpt-5.4', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
From ec7b20649e4fa5b0482f4520045bd38b99433cae Mon Sep 17 00:00:00 2001
From: shaw
Date: Mon, 6 Jul 2026 19:11:08 +0800
Subject: [PATCH 15/16] =?UTF-8?q?feat:=20apikey=20=E8=B4=A6=E5=8F=B7?=
=?UTF-8?q?=E6=94=AF=E6=8C=81=E8=AF=B7=E6=B1=82=E5=A4=B4=E8=A6=86=E5=86=99?=
=?UTF-8?q?=EF=BC=88Anthropic/OpenAI=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 账号 credentials 新增 header_override_enabled / header_overrides,
仅对 anthropic/openai 平台的 api_key 账号生效
- 转发前对同名请求头做大小写不敏感覆盖(EqualFold 全量删除后按
wire casing 写入单值),值为空的条目视为占位不参与覆盖
- 覆盖全部出站路径:/v1/messages(标准+透传)、count_tokens、
/v1/responses、chat completions、embeddings、images、全部 WS 模式、
账号测试/探测、上游模型列表
- 创建/编辑/批量更新统一校验:RFC 7230 名称格式、去重、长度/条目上限、
24 个禁止覆写头(认证/连接控制/accept-encoding/sec-websocket-*/
会话隔离头),应用时二次防御过滤
- 前端三个账号弹窗新增开关+键值行编辑器+模板按钮(Claude Code CLI /
Codex CLI 标准头,值为空),本地校验与后端规则对齐,i18n 中英文
---
.../service/account_header_override.go | 226 ++++++++++++
.../service/account_header_override_test.go | 335 ++++++++++++++++++
.../internal/service/account_test_service.go | 15 +
backend/internal/service/admin_service.go | 14 +
backend/internal/service/gateway_service.go | 13 +
.../service/openai_apikey_responses_probe.go | 3 +
backend/internal/service/openai_embeddings.go | 3 +
.../openai_gateway_chat_completions_raw.go | 3 +
.../service/openai_gateway_count_tokens.go | 3 +
.../openai_gateway_responses_chat_fallback.go | 3 +
.../service/openai_gateway_service.go | 6 +
backend/internal/service/openai_images.go | 2 +
.../internal/service/openai_ws_forwarder.go | 4 +
backend/internal/service/upstream_models.go | 4 +
.../account/BulkEditAccountModal.vue | 191 ++++++++++
.../components/account/CreateAccountModal.vue | 150 +++++++-
.../components/account/EditAccountModal.vue | 163 ++++++++-
.../__tests__/credentialsBuilder.spec.ts | 192 +++++++++-
.../components/account/credentialsBuilder.ts | 167 +++++++++
frontend/src/i18n/locales/en.ts | 16 +
frontend/src/i18n/locales/zh.ts | 16 +
21 files changed, 1526 insertions(+), 3 deletions(-)
create mode 100644 backend/internal/service/account_header_override.go
create mode 100644 backend/internal/service/account_header_override_test.go
diff --git a/backend/internal/service/account_header_override.go b/backend/internal/service/account_header_override.go
new file mode 100644
index 0000000000..80c32b648d
--- /dev/null
+++ b/backend/internal/service/account_header_override.go
@@ -0,0 +1,226 @@
+package service
+
+import (
+ "net/http"
+ "sort"
+ "strings"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+
+ "golang.org/x/net/http/httpguts"
+)
+
+// 请求头覆写(header override):仅对 Anthropic / OpenAI 平台的 api_key 账号生效。
+// 管理员在账号上配置一组 header name -> value,转发到上游前用配置值覆盖同名请求头
+// (匹配不区分大小写);value 为空的条目视为"未填写",不参与覆盖。
+const (
+ credKeyHeaderOverrideEnabled = "header_override_enabled"
+ credKeyHeaderOverrides = "header_overrides"
+
+ maxHeaderOverrideEntries = 64
+ maxHeaderOverrideNameLength = 200
+ maxHeaderOverrideValueLength = 8192
+)
+
+// headerOverrideBlockedNames 禁止覆写的请求头(小写)。
+// - 连接控制/逐跳头:由 HTTP 栈管理,覆写会破坏请求传输;
+// - host/content-length:由 Go 的 Request.Host / ContentLength 字段管理,header 覆写不生效或产生冲突;
+// - authorization/x-api-key:上游认证头由账号凭据统一注入,禁止通过覆写篡改;
+// - accept-encoding:强制压缩会破坏网关对上游流式响应(SSE/usage)的解析;
+// - sec-websocket-*:WebSocket 握手头由拨号器管理(OpenAI WS 模式);
+// - session_id/conversation_id 等:逐请求会话隔离头,固定值会造成会话串扰。
+var headerOverrideBlockedNames = map[string]struct{}{
+ "host": {},
+ "content-length": {},
+ "transfer-encoding": {},
+ "connection": {},
+ "keep-alive": {},
+ "proxy-authenticate": {},
+ "proxy-authorization": {},
+ "proxy-connection": {},
+ "te": {},
+ "trailer": {},
+ "upgrade": {},
+ "authorization": {},
+ "x-api-key": {},
+ "accept-encoding": {},
+ "sec-websocket-key": {},
+ "sec-websocket-version": {},
+ "sec-websocket-extensions": {},
+ "sec-websocket-protocol": {},
+ "sec-websocket-accept": {},
+ "session_id": {},
+ "conversation_id": {},
+ "x-codex-turn-state": {},
+ "x-codex-turn-metadata": {},
+ "chatgpt-account-id": {},
+}
+
+func isHeaderOverrideBlockedName(lowerName string) bool {
+ _, blocked := headerOverrideBlockedNames[lowerName]
+ return blocked
+}
+
+// IsHeaderOverrideEligible 报告账号类型是否支持请求头覆写。
+// 目前仅开放 Anthropic / OpenAI 两个平台的 api_key 账号。
+func (a *Account) IsHeaderOverrideEligible() bool {
+ if a == nil || a.Type != AccountTypeAPIKey {
+ return false
+ }
+ return a.Platform == PlatformAnthropic || a.Platform == PlatformOpenAI
+}
+
+// IsHeaderOverrideEnabled 报告账号是否启用了请求头覆写。
+func (a *Account) IsHeaderOverrideEnabled() bool {
+ if !a.IsHeaderOverrideEligible() || a.Credentials == nil {
+ return false
+ }
+ enabled, ok := a.Credentials[credKeyHeaderOverrideEnabled].(bool)
+ return ok && enabled
+}
+
+// GetHeaderOverrides 返回生效的请求头覆写表(key 统一小写)。
+// 未启用、不符合平台/类型条件或配置为空时返回 nil。
+// 空 value 的条目(模板占位)与非法/禁止的 header 名会被跳过。
+func (a *Account) GetHeaderOverrides() map[string]string {
+ if !a.IsHeaderOverrideEnabled() {
+ return nil
+ }
+ raw := stringMappingFromRaw(a.Credentials[credKeyHeaderOverrides])
+ if len(raw) == 0 {
+ return nil
+ }
+ result := make(map[string]string, len(raw))
+ for name, value := range raw {
+ lowerName := strings.ToLower(strings.TrimSpace(name))
+ value = strings.TrimSpace(value)
+ if lowerName == "" || value == "" {
+ continue
+ }
+ // 防御性过滤:保存路径已做校验,这里兜底未经 Normalize 落库的数据
+ if len(lowerName) > maxHeaderOverrideNameLength || len(value) > maxHeaderOverrideValueLength {
+ continue
+ }
+ if isHeaderOverrideBlockedName(lowerName) {
+ continue
+ }
+ if !httpguts.ValidHeaderFieldName(lowerName) || !httpguts.ValidHeaderFieldValue(value) {
+ continue
+ }
+ result[lowerName] = value
+ }
+ if len(result) == 0 {
+ return nil
+ }
+ return result
+}
+
+// ApplyHeaderOverrides 将账号配置的请求头覆写应用到出站请求头。
+// 对每个覆写条目:先删除所有大小写变体(转发链路会以 wire casing 直接写入 map,
+// 可能存在非 canonical key),再按已知 wire casing 写入,避免产生重复头。
+// 账号未启用或不符合条件时为 no-op,可安全地在 OAuth/api_key 共用的构建器中调用。
+func (a *Account) ApplyHeaderOverrides(h http.Header) {
+ if h == nil {
+ return
+ }
+ overrides := a.GetHeaderOverrides()
+ if len(overrides) == 0 {
+ return
+ }
+ names := make([]string, 0, len(overrides))
+ for name := range overrides {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ for existing := range h {
+ if strings.EqualFold(existing, name) {
+ delete(h, existing)
+ }
+ }
+ h[resolveWireCasing(name)] = []string{overrides[name]}
+ }
+}
+
+// NormalizeHeaderOverrideCredentials 校验并原地规范化 credentials 中的请求头覆写字段。
+// 供账号创建/更新/批量更新的保存路径调用;credentials 未携带相关字段时为 no-op。
+// 规范化内容:header 名转小写并去除首尾空白,value 去除首尾空白,丢弃名和值均为空的条目。
+func NormalizeHeaderOverrideCredentials(credentials map[string]any) error {
+ if credentials == nil {
+ return nil
+ }
+ if raw, ok := credentials[credKeyHeaderOverrideEnabled]; ok && raw != nil {
+ if _, isBool := raw.(bool); !isBool {
+ return infraerrors.New(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header_override_enabled must be a boolean")
+ }
+ }
+ raw, ok := credentials[credKeyHeaderOverrides]
+ if !ok || raw == nil {
+ return nil
+ }
+
+ var entries map[string]any
+ switch m := raw.(type) {
+ case map[string]any:
+ entries = m
+ case map[string]string:
+ entries = make(map[string]any, len(m))
+ for k, v := range m {
+ entries[k] = v
+ }
+ default:
+ return infraerrors.New(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header_overrides must be an object of header name to string value")
+ }
+
+ if len(entries) > maxHeaderOverrideEntries {
+ return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header_overrides supports at most %d entries", maxHeaderOverrideEntries)
+ }
+
+ normalized := make(map[string]any, len(entries))
+ for name, rawValue := range entries {
+ value, isString := rawValue.(string)
+ if !isString {
+ return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header %q value must be a string", name)
+ }
+ lowerName := strings.ToLower(strings.TrimSpace(name))
+ value = strings.TrimSpace(value)
+ if lowerName == "" {
+ if value == "" {
+ continue // 丢弃完全为空的占位行
+ }
+ return infraerrors.New(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header name must not be empty")
+ }
+ if len(lowerName) > maxHeaderOverrideNameLength {
+ return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header name %q exceeds %d characters", lowerName, maxHeaderOverrideNameLength)
+ }
+ if !httpguts.ValidHeaderFieldName(lowerName) {
+ return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "invalid header name %q", lowerName)
+ }
+ if isHeaderOverrideBlockedName(lowerName) {
+ return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header %q is not allowed to be overridden", lowerName)
+ }
+ if len(value) > maxHeaderOverrideValueLength {
+ return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header %q value exceeds %d characters", lowerName, maxHeaderOverrideValueLength)
+ }
+ if !httpguts.ValidHeaderFieldValue(value) {
+ return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header %q has an invalid value", lowerName)
+ }
+ if _, dup := normalized[lowerName]; dup {
+ return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "duplicate header name %q (matching is case-insensitive)", lowerName)
+ }
+ normalized[lowerName] = value
+ }
+ credentials[credKeyHeaderOverrides] = normalized
+ return nil
+}
diff --git a/backend/internal/service/account_header_override_test.go b/backend/internal/service/account_header_override_test.go
new file mode 100644
index 0000000000..56c95b3fcb
--- /dev/null
+++ b/backend/internal/service/account_header_override_test.go
@@ -0,0 +1,335 @@
+//go:build unit
+
+package service
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func headerOverrideTestAccount(platform, accountType string, credentials map[string]any) *Account {
+ return &Account{
+ Platform: platform,
+ Type: accountType,
+ Credentials: credentials,
+ }
+}
+
+func TestIsHeaderOverrideEligible(t *testing.T) {
+ tests := []struct {
+ name string
+ platform string
+ accType string
+ want bool
+ }{
+ {"anthropic apikey", PlatformAnthropic, AccountTypeAPIKey, true},
+ {"openai apikey", PlatformOpenAI, AccountTypeAPIKey, true},
+ {"anthropic oauth", PlatformAnthropic, AccountTypeOAuth, false},
+ {"openai oauth", PlatformOpenAI, AccountTypeOAuth, false},
+ {"gemini apikey", PlatformGemini, AccountTypeAPIKey, false},
+ {"grok apikey", PlatformGrok, AccountTypeAPIKey, false},
+ {"antigravity apikey", PlatformAntigravity, AccountTypeAPIKey, false},
+ {"anthropic bedrock", PlatformAnthropic, AccountTypeBedrock, false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ acc := headerOverrideTestAccount(tt.platform, tt.accType, nil)
+ require.Equal(t, tt.want, acc.IsHeaderOverrideEligible())
+ })
+ }
+
+ var nilAccount *Account
+ require.False(t, nilAccount.IsHeaderOverrideEligible())
+ require.False(t, nilAccount.IsHeaderOverrideEnabled())
+ require.Nil(t, nilAccount.GetHeaderOverrides())
+}
+
+func TestIsHeaderOverrideEnabled(t *testing.T) {
+ acc := headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrideEnabled: true,
+ })
+ require.True(t, acc.IsHeaderOverrideEnabled())
+
+ // 未配置 / 非 bool / false 均视为未启用
+ require.False(t, headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, nil).IsHeaderOverrideEnabled())
+ require.False(t, headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrideEnabled: "true",
+ }).IsHeaderOverrideEnabled())
+ require.False(t, headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrideEnabled: false,
+ }).IsHeaderOverrideEnabled())
+
+ // 不符合平台/类型条件时即使配置了 true 也不启用
+ require.False(t, headerOverrideTestAccount(PlatformAnthropic, AccountTypeOAuth, map[string]any{
+ credKeyHeaderOverrideEnabled: true,
+ }).IsHeaderOverrideEnabled())
+ require.False(t, headerOverrideTestAccount(PlatformGemini, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrideEnabled: true,
+ }).IsHeaderOverrideEnabled())
+}
+
+func TestGetHeaderOverrides(t *testing.T) {
+ acc := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrideEnabled: true,
+ credKeyHeaderOverrides: map[string]any{
+ "User-Agent": "my-agent/1.0", // 大写 key 归一化为小写
+ " X-App ": "cli", // 名称去空白
+ "x-empty": "", // 空 value(模板占位)跳过
+ "authorization": "Bearer leaked", // 禁止覆写的头跳过
+ "bad name": "value", // 非法 header 名跳过
+ "x-padded": " padded ", // value 去空白
+ },
+ })
+ overrides := acc.GetHeaderOverrides()
+ require.Equal(t, map[string]string{
+ "user-agent": "my-agent/1.0",
+ "x-app": "cli",
+ "x-padded": "padded",
+ }, overrides)
+
+ // 未启用时返回 nil
+ disabled := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrides: map[string]any{"user-agent": "x"},
+ })
+ require.Nil(t, disabled.GetHeaderOverrides())
+
+ // 启用但全部为空 value 时返回 nil
+ empty := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrideEnabled: true,
+ credKeyHeaderOverrides: map[string]any{"user-agent": ""},
+ })
+ require.Nil(t, empty.GetHeaderOverrides())
+
+ // 未经 Normalize 落库的超长数据 / WebSocket 握手头在应用时被防御性跳过
+ oversizedValue := strings.Repeat("a", maxHeaderOverrideValueLength+1)
+ defensive := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrideEnabled: true,
+ credKeyHeaderOverrides: map[string]any{
+ "x-big": oversizedValue,
+ "sec-websocket-key": "forged",
+ "x-ok": "ok",
+ },
+ })
+ require.Equal(t, map[string]string{"x-ok": "ok"}, defensive.GetHeaderOverrides())
+}
+
+func TestApplyHeaderOverrides(t *testing.T) {
+ acc := headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrideEnabled: true,
+ credKeyHeaderOverrides: map[string]any{
+ "user-agent": "override-agent/2.0",
+ "anthropic-beta": "custom-beta-1",
+ "x-custom": "custom-value",
+ },
+ })
+
+ h := http.Header{}
+ // 模拟转发链路:canonical key 与 wire casing 原样 key 混合存在
+ h.Set("User-Agent", "claude-cli/2.1.161 (external, cli)")
+ h["anthropic-beta"] = []string{"claude-code-20250219,oauth-2025-04-20"} // 非 canonical 原样 key
+ h.Set("Content-Type", "application/json")
+
+ acc.ApplyHeaderOverrides(h)
+
+ // user-agent 覆盖且只有一个值(已知头恢复 wire casing)
+ require.Equal(t, []string{"override-agent/2.0"}, h["User-Agent"])
+ // anthropic-beta:非 canonical 旧值被清除,写入 wire casing(小写)
+ require.Equal(t, []string{"custom-beta-1"}, h["anthropic-beta"])
+ require.Empty(t, h["Anthropic-Beta"])
+ // 新增头(未知头以小写原样键写入,与转发链路 wire casing 约定一致)
+ require.Equal(t, []string{"custom-value"}, h["x-custom"])
+ require.Equal(t, "custom-value", getHeaderRaw(h, "x-custom"))
+ // 未覆写的头不受影响
+ require.Equal(t, "application/json", h.Get("Content-Type"))
+
+ // 覆盖后不存在任何大小写重复
+ count := 0
+ for k := range h {
+ if k == "anthropic-beta" || k == "Anthropic-Beta" {
+ count++
+ }
+ }
+ require.Equal(t, 1, count)
+}
+
+func TestApplyHeaderOverridesNoOpPaths(t *testing.T) {
+ baseline := func() http.Header {
+ h := http.Header{}
+ h.Set("User-Agent", "orig")
+ return h
+ }
+
+ // OAuth 账号:即使配置了覆写也不生效
+ oauth := headerOverrideTestAccount(PlatformAnthropic, AccountTypeOAuth, map[string]any{
+ credKeyHeaderOverrideEnabled: true,
+ credKeyHeaderOverrides: map[string]any{"user-agent": "hacked"},
+ })
+ h := baseline()
+ oauth.ApplyHeaderOverrides(h)
+ require.Equal(t, "orig", h.Get("User-Agent"))
+
+ // 未启用开关
+ off := headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrides: map[string]any{"user-agent": "hacked"},
+ })
+ h = baseline()
+ off.ApplyHeaderOverrides(h)
+ require.Equal(t, "orig", h.Get("User-Agent"))
+
+ // 禁止覆写的头(authorization / x-api-key / host 等)不会被应用
+ blocked := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrideEnabled: true,
+ credKeyHeaderOverrides: map[string]any{
+ "Authorization": "Bearer evil",
+ "X-Api-Key": "evil",
+ "Host": "evil.example.com",
+ "Content-Length": "0",
+ },
+ })
+ h = http.Header{}
+ h.Set("Authorization", "Bearer real-key")
+ blocked.ApplyHeaderOverrides(h)
+ require.Equal(t, "Bearer real-key", h.Get("Authorization"))
+ require.Empty(t, h.Get("X-Api-Key"))
+ require.Empty(t, h.Get("Host"))
+
+ // nil header 不 panic
+ blocked.ApplyHeaderOverrides(nil)
+}
+
+func TestNormalizeHeaderOverrideCredentials(t *testing.T) {
+ t.Run("nil credentials no-op", func(t *testing.T) {
+ require.NoError(t, NormalizeHeaderOverrideCredentials(nil))
+ })
+
+ t.Run("missing keys no-op", func(t *testing.T) {
+ creds := map[string]any{"api_key": "sk-xxx"}
+ require.NoError(t, NormalizeHeaderOverrideCredentials(creds))
+ _, exists := creds[credKeyHeaderOverrides]
+ require.False(t, exists)
+ })
+
+ t.Run("normalizes names and values", func(t *testing.T) {
+ creds := map[string]any{
+ credKeyHeaderOverrideEnabled: true,
+ credKeyHeaderOverrides: map[string]any{
+ " User-Agent ": " my-agent ",
+ "X-App": "",
+ "": "", // 完全空行被丢弃
+ },
+ }
+ require.NoError(t, NormalizeHeaderOverrideCredentials(creds))
+ require.Equal(t, map[string]any{
+ "user-agent": "my-agent",
+ "x-app": "",
+ }, creds[credKeyHeaderOverrides])
+ })
+
+ t.Run("accepts map[string]string input", func(t *testing.T) {
+ creds := map[string]any{
+ credKeyHeaderOverrides: map[string]string{"X-App": "cli"},
+ }
+ require.NoError(t, NormalizeHeaderOverrideCredentials(creds))
+ require.Equal(t, map[string]any{"x-app": "cli"}, creds[credKeyHeaderOverrides])
+ })
+
+ t.Run("rejects non-bool enabled", func(t *testing.T) {
+ err := NormalizeHeaderOverrideCredentials(map[string]any{
+ credKeyHeaderOverrideEnabled: "yes",
+ })
+ require.Error(t, err)
+ })
+
+ t.Run("rejects non-object overrides", func(t *testing.T) {
+ err := NormalizeHeaderOverrideCredentials(map[string]any{
+ credKeyHeaderOverrides: []any{"user-agent"},
+ })
+ require.Error(t, err)
+ })
+
+ t.Run("rejects non-string value", func(t *testing.T) {
+ err := NormalizeHeaderOverrideCredentials(map[string]any{
+ credKeyHeaderOverrides: map[string]any{"x-app": 123},
+ })
+ require.Error(t, err)
+ })
+
+ t.Run("rejects invalid header name", func(t *testing.T) {
+ for _, name := range []string{"bad name", "bad:name", "bad\nname", "值"} {
+ err := NormalizeHeaderOverrideCredentials(map[string]any{
+ credKeyHeaderOverrides: map[string]any{name: "v"},
+ })
+ require.Error(t, err, "name %q should be rejected", name)
+ }
+ })
+
+ t.Run("rejects empty name with value", func(t *testing.T) {
+ err := NormalizeHeaderOverrideCredentials(map[string]any{
+ credKeyHeaderOverrides: map[string]any{" ": "v"},
+ })
+ require.Error(t, err)
+ })
+
+ t.Run("rejects blocked headers", func(t *testing.T) {
+ for _, name := range []string{
+ "Authorization", "x-api-key", "Host", "content-length", "Transfer-Encoding",
+ "connection", "accept-encoding", "Sec-WebSocket-Key", "session_id",
+ "conversation_id", "x-codex-turn-state", "chatgpt-account-id",
+ } {
+ err := NormalizeHeaderOverrideCredentials(map[string]any{
+ credKeyHeaderOverrides: map[string]any{name: "v"},
+ })
+ require.Error(t, err, "blocked header %q should be rejected", name)
+ }
+ })
+
+ t.Run("allows tab inside value", func(t *testing.T) {
+ creds := map[string]any{
+ credKeyHeaderOverrides: map[string]any{"x-app": "a\tb"},
+ }
+ require.NoError(t, NormalizeHeaderOverrideCredentials(creds))
+ require.Equal(t, map[string]any{"x-app": "a\tb"}, creds[credKeyHeaderOverrides])
+ })
+
+ t.Run("rejects invalid value", func(t *testing.T) {
+ err := NormalizeHeaderOverrideCredentials(map[string]any{
+ credKeyHeaderOverrides: map[string]any{"x-app": "bad\nvalue"},
+ })
+ require.Error(t, err)
+ })
+
+ t.Run("rejects duplicate names case-insensitively", func(t *testing.T) {
+ err := NormalizeHeaderOverrideCredentials(map[string]any{
+ credKeyHeaderOverrides: map[string]any{
+ "User-Agent": "a",
+ "user-agent": "b",
+ },
+ })
+ require.Error(t, err)
+ })
+
+ t.Run("rejects too many entries", func(t *testing.T) {
+ entries := make(map[string]any, maxHeaderOverrideEntries+1)
+ for i := 0; i <= maxHeaderOverrideEntries; i++ {
+ entries["x-h-"+string(rune('a'+i%26))+string(rune('a'+(i/26)%26))+string(rune('a'+(i/676)%26))] = "v"
+ }
+ err := NormalizeHeaderOverrideCredentials(map[string]any{
+ credKeyHeaderOverrides: entries,
+ })
+ require.Error(t, err)
+ })
+
+ t.Run("rejects oversized value", func(t *testing.T) {
+ big := make([]byte, maxHeaderOverrideValueLength+1)
+ for i := range big {
+ big[i] = 'a'
+ }
+ err := NormalizeHeaderOverrideCredentials(map[string]any{
+ credKeyHeaderOverrides: map[string]any{"x-app": string(big)},
+ })
+ require.Error(t, err)
+ })
+}
diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go
index 80a862b971..b598ac8ced 100644
--- a/backend/internal/service/account_test_service.go
+++ b/backend/internal/service/account_test_service.go
@@ -295,6 +295,9 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
setAnthropicAPIKeyAuthHeader(req.Header, account, authToken)
}
+ // 账号级请求头覆写:测试请求与真实转发保持一致的最终头
+ account.ApplyHeaderOverrides(req.Header)
+
// Get proxy URL
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
@@ -603,6 +606,9 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount)
}
+ // 账号级请求头覆写:测试请求与真实转发保持一致的最终头
+ credentialAccount.ApplyHeaderOverrides(req.Header)
+
// Get proxy URL
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
@@ -756,6 +762,9 @@ func (s *AccountTestService) testOpenAIChatCompletionsConnection(
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Authorization", "Bearer "+authToken)
+ // 账号级请求头覆写:测试请求与真实转发保持一致的最终头
+ account.ApplyHeaderOverrides(req.Header)
+
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
@@ -848,6 +857,9 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account
setOpenAIChatGPTAccountHeaders(req.Header, account)
}
+ // 账号级请求头覆写:测试请求与真实转发保持一致的最终头
+ account.ApplyHeaderOverrides(req.Header)
+
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
@@ -1599,6 +1611,9 @@ func (s *AccountTestService) testOpenAIImageAPIKey(c *gin.Context, ctx context.C
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+authToken)
+ // 账号级请求头覆写:测试请求与真实转发保持一致的最终头
+ account.ApplyHeaderOverrides(req.Header)
+
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go
index ebf1e7e404..f1de60eb47 100644
--- a/backend/internal/service/admin_service.go
+++ b/backend/internal/service/admin_service.go
@@ -2691,6 +2691,11 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
}
}
+ // 校验并规范化请求头覆写配置(header 名小写化、格式检查)
+ if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil {
+ return nil, err
+ }
+
account := &Account{
Name: input.Name,
Notes: normalizeAccountNotes(input.Notes),
@@ -2821,6 +2826,10 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
// 敏感子键采用"incoming 没提供就保留"的合并语义:前端响应已脱敏,
// 全对象 PUT 编辑时不会再带回 token,避免覆盖时清空已有凭证。
account.Credentials = MergePreservingSensitiveCreds(account.Credentials, input.Credentials)
+ // 校验并规范化请求头覆写配置(header 名小写化、格式检查)
+ if err := NormalizeHeaderOverrideCredentials(account.Credentials); err != nil {
+ return nil, err
+ }
}
// Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。
// 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。
@@ -3039,6 +3048,11 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
}
}
+ // 校验并规范化请求头覆写配置(批量路径为 JSONB 顶层 key 合并,直接校验增量即可)
+ if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil {
+ return nil, err
+ }
+
// Prepare bulk updates for columns and JSONB fields.
repoUpdates := AccountBulkUpdate{
Credentials: input.Credentials,
diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go
index 54035345d9..e683fc5463 100644
--- a/backend/internal/service/gateway_service.go
+++ b/backend/internal/service/gateway_service.go
@@ -5956,6 +5956,9 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
}
+ // 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头)
+ account.ApplyHeaderOverrides(req.Header)
+
return req, body, nil
}
@@ -6959,6 +6962,10 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
}
}
+ // 账号级请求头覆写(仅 anthropic/openai api_key 账号启用时生效;OAuth 路径 no-op)。
+ // 放在所有 header 逻辑之后,确保配置值对同名头拥有最终决定权。
+ account.ApplyHeaderOverrides(req.Header)
+
// === DEBUG: 打印上游转发请求(headers + body 摘要),与 CLIENT_ORIGINAL 对比 ===
s.debugLogGatewaySnapshot("UPSTREAM_FORWARD", req.Header, body, map[string]string{
"url": req.URL.String(),
@@ -10445,6 +10452,9 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough(
req.Header.Set("anthropic-version", "2023-06-01")
}
+ // 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头)
+ account.ApplyHeaderOverrides(req.Header)
+
return req, nil
}
@@ -10578,6 +10588,9 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
}
}
+ // 账号级请求头覆写(仅 anthropic/openai api_key 账号启用时生效;OAuth 路径 no-op)
+ account.ApplyHeaderOverrides(req.Header)
+
if c != nil && tokenType == "oauth" {
c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode))
}
diff --git a/backend/internal/service/openai_apikey_responses_probe.go b/backend/internal/service/openai_apikey_responses_probe.go
index 64f963ab9b..10cf050029 100644
--- a/backend/internal/service/openai_apikey_responses_probe.go
+++ b/backend/internal/service/openai_apikey_responses_probe.go
@@ -149,6 +149,9 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Accept", "application/json")
+ // 账号级请求头覆写:能力探测与真实转发保持一致的最终头
+ account.ApplyHeaderOverrides(req.Header)
+
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
diff --git a/backend/internal/service/openai_embeddings.go b/backend/internal/service/openai_embeddings.go
index 0fb3fff1f7..fb2dc5ccbb 100644
--- a/backend/internal/service/openai_embeddings.go
+++ b/backend/internal/service/openai_embeddings.go
@@ -82,6 +82,9 @@ func (s *OpenAIGatewayService) ForwardEmbeddings(
upstreamReq.Header.Set("user-agent", customUA)
}
+ // 账号级请求头覆写(仅 openai api_key 账号启用时生效)
+ account.ApplyHeaderOverrides(upstreamReq.Header)
+
proxyURL := ""
if account.Proxy != nil {
proxyURL = account.Proxy.URL()
diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go
index 6bcb6718b7..348213a992 100644
--- a/backend/internal/service/openai_gateway_chat_completions_raw.go
+++ b/backend/internal/service/openai_gateway_chat_completions_raw.go
@@ -166,6 +166,9 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions(
upstreamReq.Header.Set("user-agent", "sub2api-grok/1.0")
}
+ // 账号级请求头覆写(仅 openai api_key 账号启用时生效)
+ account.ApplyHeaderOverrides(upstreamReq.Header)
+
// 6. Send request
proxyURL := ""
if account.Proxy != nil {
diff --git a/backend/internal/service/openai_gateway_count_tokens.go b/backend/internal/service/openai_gateway_count_tokens.go
index 4a01b143e9..7518a6073a 100644
--- a/backend/internal/service/openai_gateway_count_tokens.go
+++ b/backend/internal/service/openai_gateway_count_tokens.go
@@ -231,6 +231,9 @@ func (s *OpenAIGatewayService) buildInputTokensUpstreamRequest(
}
}
+ // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
+ account.ApplyHeaderOverrides(req.Header)
+
return req, nil
}
diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go
index d33df4c19d..c499bec778 100644
--- a/backend/internal/service/openai_gateway_responses_chat_fallback.go
+++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go
@@ -138,6 +138,9 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions(
upstreamReq.Header.Set("user-agent", customUA)
}
+ // 账号级请求头覆写(仅 openai api_key 账号启用时生效)
+ account.ApplyHeaderOverrides(upstreamReq.Header)
+
proxyURL := ""
if account.Proxy != nil {
proxyURL = account.Proxy.URL()
diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go
index dd3d48aed0..f13c44f3a6 100644
--- a/backend/internal/service/openai_gateway_service.go
+++ b/backend/internal/service/openai_gateway_service.go
@@ -3783,6 +3783,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
req.Header.Set("content-type", "application/json")
}
+ // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
+ account.ApplyHeaderOverrides(req.Header)
+
return req, nil
}
@@ -4568,6 +4571,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
req.Header.Set("content-type", "application/json")
}
+ // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
+ account.ApplyHeaderOverrides(req.Header)
+
return req, nil
}
diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go
index 7081653d80..09472fbaf1 100644
--- a/backend/internal/service/openai_images.go
+++ b/backend/internal/service/openai_images.go
@@ -760,6 +760,8 @@ func (s *OpenAIGatewayService) buildOpenAIImagesRequest(
if strings.TrimSpace(contentType) != "" {
req.Header.Set("Content-Type", contentType)
}
+ // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
+ account.ApplyHeaderOverrides(req.Header)
return req, nil
}
diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go
index fcc4b98064..bbca9776ab 100644
--- a/backend/internal/service/openai_ws_forwarder.go
+++ b/backend/internal/service/openai_ws_forwarder.go
@@ -1183,6 +1183,10 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders(
headers.Set("user-agent", codexCLIUserAgent)
}
+ // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)。
+ // 覆盖所有 WS 模式(ctx_pool/dedicated/passthrough)的握手头。
+ account.ApplyHeaderOverrides(headers)
+
return headers, sessionResolution, nil
}
diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go
index ee3e6bfc04..e9fa7de451 100644
--- a/backend/internal/service/upstream_models.go
+++ b/backend/internal/service/upstream_models.go
@@ -208,6 +208,8 @@ func (s *AccountTestService) buildAnthropicUpstreamModelsRequest(ctx context.Con
} else {
setAnthropicAPIKeyAuthHeader(req.Header, account, apiKeyAuthToken)
}
+ // 账号级请求头覆写:模型列表探测与真实转发保持一致的最终头
+ account.ApplyHeaderOverrides(req.Header)
return req, nil
}
@@ -277,6 +279,8 @@ func (s *AccountTestService) buildOpenAIUpstreamModelsRequest(ctx context.Contex
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
+ // 账号级请求头覆写:模型列表探测与真实转发保持一致的最终头
+ account.ApplyHeaderOverrides(req.Header)
return req, nil
}
diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue
index d24488b47a..7ce016810e 100644
--- a/frontend/src/components/account/BulkEditAccountModal.vue
+++ b/frontend/src/components/account/BulkEditAccountModal.vue
@@ -486,6 +486,125 @@
+
+
+
+
+
+
+ {{ t('admin.accounts.headerOverride.hint') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.accounts.headerOverride.info') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.accounts.headerOverride.emptyValueHint') }}
+
+
+
+ {{ t('admin.accounts.headerOverride.bulkDisableHint') }}
+
+
+
+
@@ -1149,6 +1268,16 @@ import {
buildModelMappingObject as buildModelMappingPayload,
getPresetMappingsByPlatform
} from '@/composables/useModelWhitelist'
+import {
+ buildHeaderOverridesObject,
+ getHeaderOverrideTemplate,
+ isHeaderOverridePlatform,
+ validateHeaderOverrideRows,
+ HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
+ HEADER_OVERRIDES_CREDENTIAL_KEY,
+ type HeaderOverrideRow
+} from '@/components/account/credentialsBuilder'
+import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import {
OPENAI_WS_MODE_CTX_POOL,
OPENAI_WS_MODE_OFF,
@@ -1217,6 +1346,16 @@ const allOpenAIAPIKey = computed(() => {
)
})
+// 是否全部为 anthropic/openai 平台的 apikey 账号(请求头覆写仅在此条件下显示)
+const allHeaderOverrideCapable = computed(() => {
+ return (
+ targetSelectedPlatforms.value.length > 0 &&
+ targetSelectedPlatforms.value.every(p => isHeaderOverridePlatform(p)) &&
+ targetSelectedTypes.value.length > 0 &&
+ targetSelectedTypes.value.every(t => t === 'apikey')
+ )
+})
+
// 是否全部为 Anthropic OAuth/SetupToken(RPM 配置仅在此条件下显示)
const allAnthropicOAuthOrSetupToken = computed(() => {
return (
@@ -1253,6 +1392,7 @@ const enableBaseUrl = ref(false)
const enableModelRestriction = ref(false)
const enableCustomErrorCodes = ref(false)
const enableInterceptWarmup = ref(false)
+const enableHeaderOverride = ref(false)
const enableProxy = ref(false)
const enableConcurrency = ref(false)
const enableLoadFactor = ref(false)
@@ -1281,6 +1421,36 @@ const modelMappings = ref([])
const selectedErrorCodes = ref([])
const customErrorCodeInput = ref(null)
const interceptWarmupRequests = ref(false)
+const headerOverrideEnabled = ref(false)
+const headerOverrideRows = ref([])
+const getHeaderOverrideRowKey = createStableObjectKeyResolver('bulk-header-override-row')
+
+const addHeaderOverrideRow = () => {
+ headerOverrideRows.value.push({ name: '', value: '' })
+}
+
+const removeHeaderOverrideRow = (index: number) => {
+ headerOverrideRows.value.splice(index, 1)
+}
+
+// 模板按钮:填入标准客户端请求头名称(值留空),跳过已存在的同名行。
+// 目标全为 openai 时用 Codex 模板,否则用 Claude Code 模板。
+const fillHeaderOverrideTemplate = () => {
+ const platform =
+ targetSelectedPlatforms.value.length === 1 && targetSelectedPlatforms.value[0] === 'openai'
+ ? 'openai'
+ : 'anthropic'
+ const existing = new Set(
+ headerOverrideRows.value.map((row) => row.name.trim().toLowerCase()).filter(Boolean)
+ )
+ const rows = headerOverrideRows.value.filter((row) => row.name.trim() || row.value.trim())
+ for (const row of getHeaderOverrideTemplate(platform)) {
+ if (!existing.has(row.name)) {
+ rows.push(row)
+ }
+ }
+ headerOverrideRows.value = rows
+}
const proxyId = ref(null)
const concurrency = ref(1)
const loadFactor = ref(null)
@@ -1523,6 +1693,15 @@ const buildUpdatePayload = (): Record | null => {
credentialsChanged = true
}
+ if (enableHeaderOverride.value) {
+ // 后端使用 JSONB || merge 语义:关闭时显式写入 false + 空对象以清除旧配置
+ credentials[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY] = headerOverrideEnabled.value
+ credentials[HEADER_OVERRIDES_CREDENTIAL_KEY] = headerOverrideEnabled.value
+ ? buildHeaderOverridesObject(headerOverrideRows.value)
+ : {}
+ credentialsChanged = true
+ }
+
if (enableOpenAIWSMode.value) {
const extra = ensureExtra()
extra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value
@@ -1651,6 +1830,7 @@ const handleSubmit = async () => {
enableModelRestriction.value ||
enableCustomErrorCodes.value ||
enableInterceptWarmup.value ||
+ enableHeaderOverride.value ||
enableProxy.value ||
enableConcurrency.value ||
enableLoadFactor.value ||
@@ -1672,6 +1852,14 @@ const handleSubmit = async () => {
return
}
+ if (enableHeaderOverride.value && headerOverrideEnabled.value) {
+ const headerError = validateHeaderOverrideRows(headerOverrideRows.value)
+ if (headerError) {
+ appStore.showError(t(`admin.accounts.headerOverride.${headerError}`))
+ return
+ }
+ }
+
const built = buildUpdatePayload()
if (!built) {
appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected'))
@@ -1753,6 +1941,7 @@ watch(
enableModelRestriction.value = false
enableCustomErrorCodes.value = false
enableInterceptWarmup.value = false
+ enableHeaderOverride.value = false
enableProxy.value = false
enableConcurrency.value = false
enableLoadFactor.value = false
@@ -1778,6 +1967,8 @@ watch(
selectedErrorCodes.value = []
customErrorCodeInput.value = null
interceptWarmupRequests.value = false
+ headerOverrideEnabled.value = false
+ headerOverrideRows.value = []
proxyId.value = null
concurrency.value = 1
loadFactor.value = null
diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue
index 3514153c82..a00b49cfa0 100644
--- a/frontend/src/components/account/CreateAccountModal.vue
+++ b/frontend/src/components/account/CreateAccountModal.vue
@@ -1468,6 +1468,110 @@
+
+
+
+
+
+
+ {{ t('admin.accounts.headerOverride.hint') }}
+
+
+
+
+
+
+
+
+
+ {{ t('admin.accounts.headerOverride.info') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.accounts.headerOverride.emptyValueHint') }}
+
+
+
+
@@ -3328,7 +3432,12 @@ import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
import {
applyAntigravityProjectID,
- applyInterceptWarmup
+ applyHeaderOverride,
+ applyInterceptWarmup,
+ getHeaderOverrideTemplate,
+ isHeaderOverridePlatform,
+ validateHeaderOverrideRows,
+ type HeaderOverrideRow
} from '@/components/account/credentialsBuilder'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
@@ -3512,6 +3621,30 @@ function parsePoolModeRetryStatusCodes(input: string): number[] {
const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref([])
const customErrorCodeInput = ref(null)
+const headerOverrideEnabled = ref(false)
+const headerOverrideRows = ref([])
+
+const addHeaderOverrideRow = () => {
+ headerOverrideRows.value.push({ name: '', value: '' })
+}
+
+const removeHeaderOverrideRow = (index: number) => {
+ headerOverrideRows.value.splice(index, 1)
+}
+
+// 模板按钮:填入标准客户端请求头名称(值留空),跳过已存在的同名行
+const fillHeaderOverrideTemplate = () => {
+ const existing = new Set(
+ headerOverrideRows.value.map((row) => row.name.trim().toLowerCase()).filter(Boolean)
+ )
+ const rows = headerOverrideRows.value.filter((row) => row.name.trim() || row.value.trim())
+ for (const row of getHeaderOverrideTemplate(form.platform)) {
+ if (!existing.has(row.name)) {
+ rows.push(row)
+ }
+ }
+ headerOverrideRows.value = rows
+}
const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(true)
const openaiPassthroughEnabled = ref(false)
@@ -3569,6 +3702,7 @@ const vertexServiceAccountDragActive = ref(false)
const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref([])
const getModelMappingKey = createStableObjectKeyResolver('create-model-mapping')
+const getHeaderOverrideRowKey = createStableObjectKeyResolver('create-header-override-row')
const getOpenAICompactModelMappingKey = createStableObjectKeyResolver('create-openai-compact-model-mapping')
const getAntigravityModelMappingKey = createStableObjectKeyResolver('create-antigravity-model-mapping')
const getTempUnschedRuleKey = createStableObjectKeyResolver('create-temp-unsched-rule')
@@ -4359,6 +4493,8 @@ const resetForm = () => {
customErrorCodesEnabled.value = false
selectedErrorCodes.value = []
customErrorCodeInput.value = null
+ headerOverrideEnabled.value = false
+ headerOverrideRows.value = []
interceptWarmupRequests.value = false
autoPauseOnExpired.value = true
openaiPassthroughEnabled.value = false
@@ -4789,6 +4925,18 @@ const handleSubmit = async () => {
credentials.custom_error_codes = [...selectedErrorCodes.value]
}
+ // Add header override if enabled (anthropic/openai apikey only)
+ if (isHeaderOverridePlatform(form.platform)) {
+ if (headerOverrideEnabled.value) {
+ const headerError = validateHeaderOverrideRows(headerOverrideRows.value)
+ if (headerError) {
+ appStore.showError(t(`admin.accounts.headerOverride.${headerError}`))
+ return
+ }
+ }
+ applyHeaderOverride(credentials, headerOverrideEnabled.value, headerOverrideRows.value, 'create')
+ }
+
applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create')
if (!applyTempUnschedConfig(credentials)) {
return
diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue
index 3670f233e3..9b5ab82fc5 100644
--- a/frontend/src/components/account/EditAccountModal.vue
+++ b/frontend/src/components/account/EditAccountModal.vue
@@ -417,6 +417,110 @@
+
+
+
+
+
+
+ {{ t('admin.accounts.headerOverride.hint') }}
+
+
+
+
+
+
+
+
+
+ {{ t('admin.accounts.headerOverride.info') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.accounts.headerOverride.emptyValueHint') }}
+
+
+
+
@@ -2433,7 +2537,15 @@ import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
import {
applyAntigravityProjectID,
- applyInterceptWarmup
+ applyHeaderOverride,
+ applyInterceptWarmup,
+ getHeaderOverrideTemplate,
+ isHeaderOverridePlatform,
+ splitHeaderOverridesObject,
+ validateHeaderOverrideRows,
+ HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
+ HEADER_OVERRIDES_CREDENTIAL_KEY,
+ type HeaderOverrideRow
} from '@/components/account/credentialsBuilder'
import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
@@ -2564,6 +2676,30 @@ function formatPoolModeRetryStatusCodes(value: unknown): string {
const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref([])
const customErrorCodeInput = ref(null)
+const headerOverrideEnabled = ref(false)
+const headerOverrideRows = ref([])
+
+const addHeaderOverrideRow = () => {
+ headerOverrideRows.value.push({ name: '', value: '' })
+}
+
+const removeHeaderOverrideRow = (index: number) => {
+ headerOverrideRows.value.splice(index, 1)
+}
+
+// 模板按钮:填入标准客户端请求头名称(值留空),跳过已存在的同名行
+const fillHeaderOverrideTemplate = () => {
+ const existing = new Set(
+ headerOverrideRows.value.map((row) => row.name.trim().toLowerCase()).filter(Boolean)
+ )
+ const rows = headerOverrideRows.value.filter((row) => row.name.trim() || row.value.trim())
+ for (const row of getHeaderOverrideTemplate(props.account?.platform || '')) {
+ if (!existing.has(row.name)) {
+ rows.push(row)
+ }
+ }
+ headerOverrideRows.value = rows
+}
const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(false)
const autoPause5hThreshold = ref(null)
@@ -2580,6 +2716,7 @@ const isSyncingAntigravityUpstream = ref(false)
const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref([])
const getModelMappingKey = createStableObjectKeyResolver('edit-model-mapping')
+const getHeaderOverrideRowKey = createStableObjectKeyResolver('edit-header-override-row')
const getOpenAICompactModelMappingKey = createStableObjectKeyResolver('edit-openai-compact-model-mapping')
const getAntigravityModelMappingKey = createStableObjectKeyResolver('edit-antigravity-model-mapping')
const getTempUnschedRuleKey = createStableObjectKeyResolver('edit-temp-unsched-rule')
@@ -3186,6 +3323,10 @@ const syncFormFromAccount = (newAccount: Account | null) => {
loadTempUnschedRules(credentials)
+ // Reset header override state (loaded below only for apikey accounts)
+ headerOverrideEnabled.value = false
+ headerOverrideRows.value = []
+
// Initialize API Key fields for apikey type
if (newAccount.type === 'apikey' && newAccount.credentials) {
const credentials = newAccount.credentials as Record
@@ -3215,6 +3356,14 @@ const syncFormFromAccount = (newAccount: Account | null) => {
} else {
selectedErrorCodes.value = []
}
+
+ // Load header override (anthropic/openai apikey only)
+ headerOverrideEnabled.value =
+ isHeaderOverridePlatform(newAccount.platform) &&
+ credentials[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY] === true
+ headerOverrideRows.value = splitHeaderOverridesObject(
+ credentials[HEADER_OVERRIDES_CREDENTIAL_KEY]
+ )
} else if (newAccount.type === 'bedrock' && newAccount.credentials) {
const bedrockCreds = newAccount.credentials as Record
const authMode = (bedrockCreds.auth_mode as string) || 'sigv4'
@@ -3850,6 +3999,18 @@ const handleSubmit = async () => {
delete newCredentials.custom_error_codes
}
+ // Add header override if enabled (anthropic/openai apikey only)
+ if (isHeaderOverridePlatform(props.account.platform)) {
+ if (headerOverrideEnabled.value) {
+ const headerError = validateHeaderOverrideRows(headerOverrideRows.value)
+ if (headerError) {
+ appStore.showError(t(`admin.accounts.headerOverride.${headerError}`))
+ return
+ }
+ }
+ applyHeaderOverride(newCredentials, headerOverrideEnabled.value, headerOverrideRows.value, 'edit')
+ }
+
// Add intercept warmup requests setting
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
if (!applyTempUnschedConfig(newCredentials)) {
diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts
index 665b1732e7..cbad111f45 100644
--- a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts
+++ b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts
@@ -1,8 +1,16 @@
import { describe, it, expect } from 'vitest'
import {
ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY,
+ HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
+ HEADER_OVERRIDES_CREDENTIAL_KEY,
applyAntigravityProjectID,
- applyInterceptWarmup
+ applyHeaderOverride,
+ applyInterceptWarmup,
+ buildHeaderOverridesObject,
+ getHeaderOverrideTemplate,
+ isHeaderOverridePlatform,
+ splitHeaderOverridesObject,
+ validateHeaderOverrideRows
} from '../credentialsBuilder'
describe('applyInterceptWarmup', () => {
@@ -82,3 +90,185 @@ describe('applyAntigravityProjectID', () => {
expect(creds[ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY]).toBe('configured-project')
})
})
+
+describe('isHeaderOverridePlatform', () => {
+ it('only anthropic and openai are supported', () => {
+ expect(isHeaderOverridePlatform('anthropic')).toBe(true)
+ expect(isHeaderOverridePlatform('openai')).toBe(true)
+ expect(isHeaderOverridePlatform('gemini')).toBe(false)
+ expect(isHeaderOverridePlatform('grok')).toBe(false)
+ expect(isHeaderOverridePlatform('antigravity')).toBe(false)
+ expect(isHeaderOverridePlatform('')).toBe(false)
+ })
+})
+
+describe('validateHeaderOverrideRows', () => {
+ it('accepts valid rows and empty placeholder rows', () => {
+ expect(
+ validateHeaderOverrideRows([
+ { name: 'user-agent', value: 'my-agent/1.0' },
+ { name: 'x-app', value: '' },
+ { name: '', value: '' }
+ ])
+ ).toBeNull()
+ })
+
+ it('rejects empty name with non-empty value', () => {
+ expect(validateHeaderOverrideRows([{ name: '', value: 'v' }])).toBe('invalidName')
+ })
+
+ it('rejects invalid header names', () => {
+ expect(validateHeaderOverrideRows([{ name: 'bad name', value: '' }])).toBe('invalidName')
+ expect(validateHeaderOverrideRows([{ name: 'bad:name', value: '' }])).toBe('invalidName')
+ expect(validateHeaderOverrideRows([{ name: '名称', value: '' }])).toBe('invalidName')
+ })
+
+ it('rejects blocked header names case-insensitively', () => {
+ expect(validateHeaderOverrideRows([{ name: 'Authorization', value: '' }])).toBe('blockedName')
+ expect(validateHeaderOverrideRows([{ name: 'X-Api-Key', value: '' }])).toBe('blockedName')
+ expect(validateHeaderOverrideRows([{ name: 'host', value: '' }])).toBe('blockedName')
+ expect(validateHeaderOverrideRows([{ name: 'Content-Length', value: '' }])).toBe('blockedName')
+ })
+
+ it('rejects duplicate names case-insensitively', () => {
+ expect(
+ validateHeaderOverrideRows([
+ { name: 'User-Agent', value: 'a' },
+ { name: 'user-agent', value: 'b' }
+ ])
+ ).toBe('duplicateName')
+ })
+})
+
+describe('buildHeaderOverridesObject / splitHeaderOverridesObject', () => {
+ it('lowercases names, trims values and drops empty-name rows', () => {
+ expect(
+ buildHeaderOverridesObject([
+ { name: ' User-Agent ', value: ' my-agent ' },
+ { name: 'X-App', value: '' },
+ { name: '', value: 'ignored' }
+ ])
+ ).toEqual({ 'user-agent': 'my-agent', 'x-app': '' })
+ })
+
+ it('splits an object into sorted rows and ignores non-string values', () => {
+ expect(
+ splitHeaderOverridesObject({ 'x-app': 'cli', 'user-agent': 'ua', bogus: 42 })
+ ).toEqual([
+ { name: 'user-agent', value: 'ua' },
+ { name: 'x-app', value: 'cli' }
+ ])
+ expect(splitHeaderOverridesObject(null)).toEqual([])
+ expect(splitHeaderOverridesObject(['a'])).toEqual([])
+ expect(splitHeaderOverridesObject('str')).toEqual([])
+ })
+
+ it('roundtrips through build and split', () => {
+ const rows = [
+ { name: 'user-agent', value: 'ua' },
+ { name: 'x-app', value: 'cli' }
+ ]
+ expect(splitHeaderOverridesObject(buildHeaderOverridesObject(rows))).toEqual(rows)
+ })
+})
+
+describe('getHeaderOverrideTemplate', () => {
+ it('returns Claude Code CLI headers with empty values for anthropic', () => {
+ const rows = getHeaderOverrideTemplate('anthropic')
+ expect(rows.every((r) => r.value === '')).toBe(true)
+ const names = rows.map((r) => r.name)
+ expect(names).toContain('user-agent')
+ expect(names).toContain('x-app')
+ expect(names).toContain('anthropic-beta')
+ expect(names).toContain('x-stainless-lang')
+ expect(validateHeaderOverrideRows(rows)).toBeNull()
+ })
+
+ it('returns Codex CLI headers with empty values for openai', () => {
+ const rows = getHeaderOverrideTemplate('openai')
+ expect(rows.every((r) => r.value === '')).toBe(true)
+ const names = rows.map((r) => r.name)
+ expect(names).toContain('user-agent')
+ expect(names).toContain('originator')
+ expect(names).toContain('openai-beta')
+ expect(validateHeaderOverrideRows(rows)).toBeNull()
+ })
+})
+
+describe('applyHeaderOverride', () => {
+ it('create + enabled: writes enabled flag and overrides object', () => {
+ const creds: Record = { api_key: 'sk' }
+ applyHeaderOverride(creds, true, [{ name: 'User-Agent', value: 'ua' }], 'create')
+ expect(creds[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY]).toBe(true)
+ expect(creds[HEADER_OVERRIDES_CREDENTIAL_KEY]).toEqual({ 'user-agent': 'ua' })
+ })
+
+ it('create + disabled: does not add fields', () => {
+ const creds: Record = { api_key: 'sk' }
+ applyHeaderOverride(creds, false, [{ name: 'user-agent', value: 'ua' }], 'create')
+ expect(HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY in creds).toBe(false)
+ expect(HEADER_OVERRIDES_CREDENTIAL_KEY in creds).toBe(false)
+ })
+
+ it('edit + disabled: deletes existing fields', () => {
+ const creds: Record = {
+ api_key: 'sk',
+ [HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY]: true,
+ [HEADER_OVERRIDES_CREDENTIAL_KEY]: { 'user-agent': 'ua' }
+ }
+ applyHeaderOverride(creds, false, [], 'edit')
+ expect(HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY in creds).toBe(false)
+ expect(HEADER_OVERRIDES_CREDENTIAL_KEY in creds).toBe(false)
+ expect(creds.api_key).toBe('sk')
+ })
+
+ it('edit + enabled: replaces overrides object wholesale', () => {
+ const creds: Record = {
+ [HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY]: true,
+ [HEADER_OVERRIDES_CREDENTIAL_KEY]: { 'x-old': 'old' }
+ }
+ applyHeaderOverride(creds, true, [{ name: 'x-new', value: 'new' }], 'edit')
+ expect(creds[HEADER_OVERRIDES_CREDENTIAL_KEY]).toEqual({ 'x-new': 'new' })
+ })
+})
+
+describe('validateHeaderOverrideRows value/entry limits', () => {
+ it('rejects websocket handshake headers', () => {
+ expect(validateHeaderOverrideRows([{ name: 'Sec-WebSocket-Key', value: '' }])).toBe(
+ 'blockedName'
+ )
+ })
+
+ it('rejects control characters in values', () => {
+ expect(validateHeaderOverrideRows([{ name: 'x-app', value: 'a\x0bb' }])).toBe('invalidValue')
+ })
+
+ it('rejects oversized values', () => {
+ expect(validateHeaderOverrideRows([{ name: 'x-app', value: 'a'.repeat(8193) }])).toBe(
+ 'invalidValue'
+ )
+ })
+
+ it('rejects too many entries', () => {
+ const rows = Array.from({ length: 65 }, (_, i) => ({ name: `x-h-${i}`, value: 'v' }))
+ expect(validateHeaderOverrideRows(rows)).toBe('tooManyEntries')
+ })
+})
+
+describe('validateHeaderOverrideRows session isolation headers', () => {
+ it('rejects per-request session headers', () => {
+ expect(validateHeaderOverrideRows([{ name: 'session_id', value: '' }])).toBe('blockedName')
+ expect(validateHeaderOverrideRows([{ name: 'Conversation_ID', value: '' }])).toBe('blockedName')
+ expect(validateHeaderOverrideRows([{ name: 'x-codex-turn-state', value: '' }])).toBe(
+ 'blockedName'
+ )
+ })
+
+ it('allows tab inside value', () => {
+ expect(validateHeaderOverrideRows([{ name: 'x-app', value: 'a\tb' }])).toBeNull()
+ })
+
+ it('rejects oversized names', () => {
+ expect(validateHeaderOverrideRows([{ name: 'x'.repeat(201), value: 'v' }])).toBe('invalidName')
+ })
+})
diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts
index f138976519..9e175a2712 100644
--- a/frontend/src/components/account/credentialsBuilder.ts
+++ b/frontend/src/components/account/credentialsBuilder.ts
@@ -24,3 +24,170 @@ export function applyAntigravityProjectID(
delete credentials[ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY]
}
}
+
+// ========== 请求头覆写(仅 anthropic/openai 平台的 api_key 账号) ==========
+
+export const HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY = 'header_override_enabled'
+export const HEADER_OVERRIDES_CREDENTIAL_KEY = 'header_overrides'
+
+export interface HeaderOverrideRow {
+ name: string
+ value: string
+}
+
+/** 请求头覆写支持的平台(与后端 IsHeaderOverrideEligible 保持一致) */
+export function isHeaderOverridePlatform(platform: string): boolean {
+ return platform === 'anthropic' || platform === 'openai'
+}
+
+/** 禁止覆写的请求头(与后端 headerOverrideBlockedNames 保持一致) */
+const HEADER_OVERRIDE_BLOCKED_NAMES = new Set([
+ 'host',
+ 'content-length',
+ 'transfer-encoding',
+ 'connection',
+ 'keep-alive',
+ 'proxy-authenticate',
+ 'proxy-authorization',
+ 'proxy-connection',
+ 'te',
+ 'trailer',
+ 'upgrade',
+ 'authorization',
+ 'x-api-key',
+ 'accept-encoding',
+ 'sec-websocket-key',
+ 'sec-websocket-version',
+ 'sec-websocket-extensions',
+ 'sec-websocket-protocol',
+ 'sec-websocket-accept',
+ 'session_id',
+ 'conversation_id',
+ 'x-codex-turn-state',
+ 'x-codex-turn-metadata',
+ 'chatgpt-account-id'
+])
+
+/** RFC 7230 token:合法的 HTTP header 名称字符集 */
+const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
+
+export function isValidHeaderOverrideName(name: string): boolean {
+ return HEADER_NAME_PATTERN.test(name)
+}
+
+export function isBlockedHeaderOverrideName(name: string): boolean {
+ return HEADER_OVERRIDE_BLOCKED_NAMES.has(name.trim().toLowerCase())
+}
+
+/** 模板:Claude Code CLI 标准客户端请求头(值留空由管理员填写) */
+const ANTHROPIC_HEADER_OVERRIDE_TEMPLATE = [
+ 'user-agent',
+ 'x-app',
+ 'anthropic-beta',
+ 'anthropic-version',
+ 'anthropic-dangerous-direct-browser-access',
+ 'x-stainless-lang',
+ 'x-stainless-package-version',
+ 'x-stainless-os',
+ 'x-stainless-arch',
+ 'x-stainless-runtime',
+ 'x-stainless-runtime-version',
+ 'x-stainless-retry-count',
+ 'x-stainless-timeout'
+]
+
+/** 模板:Codex CLI 标准客户端请求头(值留空由管理员填写) */
+const OPENAI_HEADER_OVERRIDE_TEMPLATE = [
+ 'user-agent',
+ 'originator',
+ 'openai-beta',
+ 'version',
+ 'accept',
+ 'accept-language'
+]
+
+export function getHeaderOverrideTemplate(platform: string): HeaderOverrideRow[] {
+ const names =
+ platform === 'openai' ? OPENAI_HEADER_OVERRIDE_TEMPLATE : ANTHROPIC_HEADER_OVERRIDE_TEMPLATE
+ return names.map((name) => ({ name, value: '' }))
+}
+
+/** 与后端 maxHeaderOverride* 常量保持一致 */
+const HEADER_OVERRIDE_MAX_ENTRIES = 64
+const HEADER_OVERRIDE_MAX_NAME_LENGTH = 200
+const HEADER_OVERRIDE_MAX_VALUE_LENGTH = 8192
+
+/** header value 不允许包含控制字符(与后端 httpguts.ValidHeaderFieldValue 对齐) */
+// eslint-disable-next-line no-control-regex
+const HEADER_VALUE_INVALID_PATTERN = /[\x00-\x08\x0a-\x1f\x7f]/
+
+/**
+ * 校验请求头覆写行,返回首个错误的 i18n key(无错误返回 null)。
+ * 名称为空但值非空 → invalidName;名称非法 → invalidName;
+ * 禁止覆写 → blockedName;大小写不敏感重名 → duplicateName;
+ * 值含控制字符或超长 → invalidValue;条目过多 → tooManyEntries。
+ */
+export function validateHeaderOverrideRows(
+ rows: HeaderOverrideRow[]
+): 'invalidName' | 'blockedName' | 'duplicateName' | 'invalidValue' | 'tooManyEntries' | null {
+ const seen = new Set()
+ for (const row of rows) {
+ const name = row.name.trim()
+ const value = row.value.trim()
+ if (!name) {
+ if (value) return 'invalidName'
+ continue
+ }
+ if (!isValidHeaderOverrideName(name) || name.length > HEADER_OVERRIDE_MAX_NAME_LENGTH) {
+ return 'invalidName'
+ }
+ const lower = name.toLowerCase()
+ if (HEADER_OVERRIDE_BLOCKED_NAMES.has(lower)) return 'blockedName'
+ if (seen.has(lower)) return 'duplicateName'
+ if (HEADER_VALUE_INVALID_PATTERN.test(value) || value.length > HEADER_OVERRIDE_MAX_VALUE_LENGTH) {
+ return 'invalidValue'
+ }
+ seen.add(lower)
+ }
+ if (seen.size > HEADER_OVERRIDE_MAX_ENTRIES) return 'tooManyEntries'
+ return null
+}
+
+/** 行数组 → credentials 存储对象(名称小写化,丢弃空行) */
+export function buildHeaderOverridesObject(rows: HeaderOverrideRow[]): Record {
+ const result: Record = {}
+ for (const row of rows) {
+ const name = row.name.trim().toLowerCase()
+ if (!name) continue
+ result[name] = row.value.trim()
+ }
+ return result
+}
+
+/** credentials 存储对象 → 行数组(按名称排序保证稳定展示) */
+export function splitHeaderOverridesObject(record: unknown): HeaderOverrideRow[] {
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return []
+ return Object.entries(record as Record)
+ .filter(([, value]) => typeof value === 'string')
+ .map(([name, value]) => ({ name, value: value as string }))
+ .sort((a, b) => a.name.localeCompare(b.name))
+}
+
+/**
+ * 将请求头覆写写入 credentials。
+ * create 模式:关闭时不写入任何字段;edit 模式:关闭时删除字段(全量替换语义)。
+ */
+export function applyHeaderOverride(
+ credentials: Record,
+ enabled: boolean,
+ rows: HeaderOverrideRow[],
+ mode: 'create' | 'edit'
+): void {
+ if (enabled) {
+ credentials[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY] = true
+ credentials[HEADER_OVERRIDES_CREDENTIAL_KEY] = buildHeaderOverridesObject(rows)
+ } else if (mode === 'edit') {
+ delete credentials[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY]
+ delete credentials[HEADER_OVERRIDES_CREDENTIAL_KEY]
+ }
+}
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 749d2dcbcd..6e5aa18b28 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -3674,6 +3674,22 @@ export default {
interceptWarmupRequests: 'Intercept Warmup Requests',
interceptWarmupRequestsDesc:
'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens',
+ headerOverride: {
+ title: 'Header Override',
+ hint: 'Override same-named request headers on forwarding (case-insensitive)',
+ info: 'Applies to outbound requests of this account only: configured headers override client/gateway-generated headers of the same name before forwarding. Auth headers (authorization, x-api-key) and connection-control headers cannot be overridden.',
+ namePlaceholder: 'Header name (e.g. user-agent)',
+ valuePlaceholder: 'Override value (leave empty to skip)',
+ addRow: 'Add Header',
+ fillTemplate: 'Fill Template',
+ emptyValueHint: 'Rows with an empty value are placeholders and do not override anything.',
+ bulkDisableHint: 'Saving will disable header override and clear existing configuration on the selected accounts.',
+ invalidName: 'Invalid header name (only letters, digits and !#$%&\'*+-.^_`|~ are allowed)',
+ blockedName: 'This header cannot be overridden (auth and connection-control headers are managed by the system)',
+ duplicateName: 'Duplicate header name (matching is case-insensitive)',
+ invalidValue: 'Invalid header value (control characters are not allowed; max length 8192)',
+ tooManyEntries: 'Too many header override entries (max 64)'
+ },
autoPauseOnExpired: 'Auto Pause On Expired',
autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires',
autoPause5hThreshold: '5h Usage Threshold (%)',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index a0b1337796..3ec15e2ec6 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -3842,6 +3842,22 @@ export default {
errorCodeExists: '该错误码已被选中',
interceptWarmupRequests: '拦截预热请求',
interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token',
+ headerOverride: {
+ title: '请求头覆写',
+ hint: '转发时用配置值覆盖同名请求头(不区分大小写)',
+ info: '仅对本账号的出站请求生效:配置的请求头会在转发前覆盖客户端/网关生成的同名头。认证头(authorization、x-api-key)与连接控制头不允许覆写。',
+ namePlaceholder: '请求头名称(如 user-agent)',
+ valuePlaceholder: '覆写值(留空表示不覆写)',
+ addRow: '添加请求头',
+ fillTemplate: '填入模板',
+ emptyValueHint: '值留空的行不会参与覆盖,仅作为待填写的占位。',
+ bulkDisableHint: '保存后将关闭所选账号的请求头覆写并清空已有配置。',
+ invalidName: '请求头名称格式不正确(仅允许字母、数字和 !#$%&\'*+-.^_`|~ 字符)',
+ blockedName: '该请求头不允许覆写(认证头与连接控制头由系统管理)',
+ duplicateName: '存在重复的请求头名称(匹配不区分大小写)',
+ invalidValue: '请求头值不合法(不允许控制字符,长度不超过 8192)',
+ tooManyEntries: '请求头覆写条目过多(最多 64 条)'
+ },
autoPauseOnExpired: '过期自动暂停调度',
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',
autoPause5hThreshold: '5h 用量阈值(%)',
From 31b6e0d94accdbc9a10e947b3a3ea7479a0bb7e7 Mon Sep 17 00:00:00 2001
From: shaw
Date: Mon, 6 Jul 2026 20:05:50 +0800
Subject: [PATCH 16/16] =?UTF-8?q?fix:=20=E8=AF=B7=E6=B1=82=E5=A4=B4?=
=?UTF-8?q?=E8=A6=86=E5=86=99=E5=AE=A1=E8=AE=A1=E9=97=AE=E9=A2=98=E4=BF=AE?=
=?UTF-8?q?=E5=A4=8D=EF=BC=88=E7=A6=81=E6=AD=A2=E5=90=8D=E5=8D=95=E7=BC=BA?=
=?UTF-8?q?=E5=8F=A3/beta=20=E5=AF=B9=E7=A7=B0=E6=80=A7/=E6=89=B9=E9=87=8F?=
=?UTF-8?q?=E6=B8=85=E7=A9=BA=E9=98=B2=E6=8A=A4=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
后端:
- 禁止名单补充 content-type(multipart boundary 为每请求随机值,静态覆写必坏
images 路径)、x-claude-code-session-id / x-client-request-id(会击穿每请求
会话同步,与已禁的 session_id 等同类)、cookie / x-goog-api-key(与透传路径
的入站鉴权残留清洗保持一致)
- anthropic-beta 覆写与 body 净化对称:四个 Anthropic 构建器在
sanitizeAnthropicBodyForBetaTokens 前以覆写值为有效 finalBeta,避免覆写
丢 token 后 header/body 不对称被上游 400
- 保存/应用两路径共用 normalizeHeaderOverrideEntry,消除双份校验规则漂移
- GetHeaderOverrides 按 modelMappingCache 先例增加热路径缓存(WS 每消息
重建头场景收益最大);ApplyHeaderOverrides 移除无观测效果的排序
前端:
- 禁止名单镜像同步新增项;值长度改按 UTF-8 字节校验(与后端 len() 对齐)
- Create 弹窗切换平台时重置覆写配置,避免上一平台模板行串台
- BulkEdit:开启覆写但无有效行时拦截保存(防止整键替换静默清空所选账号
既有配置);开启分支显示整体替换警告;混合平台选择时隐藏模板按钮
- 删除未使用的 isBlockedHeaderOverrideName 导出
---
backend/internal/service/account.go | 8 +
.../service/account_header_override.go | 156 ++++++++++++------
.../service/account_header_override_test.go | 10 +-
backend/internal/service/gateway_service.go | 19 +++
.../account/BulkEditAccountModal.vue | 27 ++-
.../components/account/CreateAccountModal.vue | 4 +
.../__tests__/credentialsBuilder.spec.ts | 17 ++
.../components/account/credentialsBuilder.ts | 24 ++-
frontend/src/i18n/locales/en.ts | 2 +
frontend/src/i18n/locales/zh.ts | 2 +
10 files changed, 201 insertions(+), 68 deletions(-)
diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go
index 8db5805e9d..ae25bf387d 100644
--- a/backend/internal/service/account.go
+++ b/backend/internal/service/account.go
@@ -70,6 +70,14 @@ type Account struct {
modelMappingCacheRawPtr uintptr
modelMappingCacheRawLen int
modelMappingCacheRawSig uint64
+
+ // header_overrides 热路径缓存(非持久化字段,同 model_mapping 缓存先例)
+ headerOverrideCache map[string]string
+ headerOverrideCacheReady bool
+ headerOverrideCacheCredentialsPtr uintptr
+ headerOverrideCacheRawPtr uintptr
+ headerOverrideCacheRawLen int
+ headerOverrideCacheRawSig uint64
}
type OpenAIEndpointCapability string
diff --git a/backend/internal/service/account_header_override.go b/backend/internal/service/account_header_override.go
index 80c32b648d..8882bbef91 100644
--- a/backend/internal/service/account_header_override.go
+++ b/backend/internal/service/account_header_override.go
@@ -2,7 +2,6 @@ package service
import (
"net/http"
- "sort"
"strings"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
@@ -25,13 +24,15 @@ const (
// headerOverrideBlockedNames 禁止覆写的请求头(小写)。
// - 连接控制/逐跳头:由 HTTP 栈管理,覆写会破坏请求传输;
// - host/content-length:由 Go 的 Request.Host / ContentLength 字段管理,header 覆写不生效或产生冲突;
-// - authorization/x-api-key:上游认证头由账号凭据统一注入,禁止通过覆写篡改;
+// - content-type:承载报文框架信息(multipart boundary 为每请求随机值),静态覆写必然与 body 不匹配;
+// - authorization/x-api-key/cookie 等:上游认证头由账号凭据统一注入,禁止通过覆写篡改或重新引入;
// - accept-encoding:强制压缩会破坏网关对上游流式响应(SSE/usage)的解析;
// - sec-websocket-*:WebSocket 握手头由拨号器管理(OpenAI WS 模式);
-// - session_id/conversation_id 等:逐请求会话隔离头,固定值会造成会话串扰。
+// - session_id/x-claude-code-session-id 等:逐请求会话隔离头,固定值会造成会话串扰。
var headerOverrideBlockedNames = map[string]struct{}{
"host": {},
"content-length": {},
+ "content-type": {},
"transfer-encoding": {},
"connection": {},
"keep-alive": {},
@@ -43,6 +44,8 @@ var headerOverrideBlockedNames = map[string]struct{}{
"upgrade": {},
"authorization": {},
"x-api-key": {},
+ "x-goog-api-key": {},
+ "cookie": {},
"accept-encoding": {},
"sec-websocket-key": {},
"sec-websocket-version": {},
@@ -54,6 +57,8 @@ var headerOverrideBlockedNames = map[string]struct{}{
"x-codex-turn-state": {},
"x-codex-turn-metadata": {},
"chatgpt-account-id": {},
+ "x-claude-code-session-id": {},
+ "x-client-request-id": {},
}
func isHeaderOverrideBlockedName(lowerName string) bool {
@@ -82,29 +87,59 @@ func (a *Account) IsHeaderOverrideEnabled() bool {
// GetHeaderOverrides 返回生效的请求头覆写表(key 统一小写)。
// 未启用、不符合平台/类型条件或配置为空时返回 nil。
// 空 value 的条目(模板占位)与非法/禁止的 header 名会被跳过。
+// 结果带热路径缓存(同 GetModelMapping 先例):同一 credentials 映射在
+// 一次请求 / 一条 WS 会话内的多次调用只做一次解析与校验。
func (a *Account) GetHeaderOverrides() map[string]string {
if !a.IsHeaderOverrideEnabled() {
return nil
}
- raw := stringMappingFromRaw(a.Credentials[credKeyHeaderOverrides])
+ rawMapping, rawIsAnyMap := a.Credentials[credKeyHeaderOverrides].(map[string]any)
+ if !rawIsAnyMap {
+ // 非 JSON 反序列化产物(如直接注入的 map[string]string):直接解析,不缓存
+ return resolveHeaderOverrides(stringMappingFromRaw(a.Credentials[credKeyHeaderOverrides]))
+ }
+
+ credentialsPtr := mapPtr(a.Credentials)
+ rawPtr := mapPtr(rawMapping)
+ rawLen := len(rawMapping)
+ rawSig := uint64(0)
+ rawSigReady := false
+
+ if a.headerOverrideCacheReady &&
+ a.headerOverrideCacheCredentialsPtr == credentialsPtr &&
+ a.headerOverrideCacheRawPtr == rawPtr &&
+ a.headerOverrideCacheRawLen == rawLen {
+ rawSig = modelMappingSignature(rawMapping)
+ rawSigReady = true
+ if a.headerOverrideCacheRawSig == rawSig {
+ return a.headerOverrideCache
+ }
+ }
+
+ overrides := resolveHeaderOverrides(stringMappingFromRaw(rawMapping))
+ if !rawSigReady {
+ rawSig = modelMappingSignature(rawMapping)
+ }
+
+ a.headerOverrideCache = overrides
+ a.headerOverrideCacheReady = true
+ a.headerOverrideCacheCredentialsPtr = credentialsPtr
+ a.headerOverrideCacheRawPtr = rawPtr
+ a.headerOverrideCacheRawLen = rawLen
+ a.headerOverrideCacheRawSig = rawSig
+ return overrides
+}
+
+// resolveHeaderOverrides 解析并防御性过滤原始覆写表:保存路径已做校验,
+// 这里兜底未经 Normalize 落库的数据(含名单扩充前保存的旧配置),非法条目直接跳过。
+func resolveHeaderOverrides(raw map[string]string) map[string]string {
if len(raw) == 0 {
return nil
}
result := make(map[string]string, len(raw))
for name, value := range raw {
- lowerName := strings.ToLower(strings.TrimSpace(name))
- value = strings.TrimSpace(value)
- if lowerName == "" || value == "" {
- continue
- }
- // 防御性过滤:保存路径已做校验,这里兜底未经 Normalize 落库的数据
- if len(lowerName) > maxHeaderOverrideNameLength || len(value) > maxHeaderOverrideValueLength {
- continue
- }
- if isHeaderOverrideBlockedName(lowerName) {
- continue
- }
- if !httpguts.ValidHeaderFieldName(lowerName) || !httpguts.ValidHeaderFieldValue(value) {
+ lowerName, value, err := normalizeHeaderOverrideEntry(name, value)
+ if err != nil || lowerName == "" || value == "" {
continue
}
result[lowerName] = value
@@ -115,6 +150,13 @@ func (a *Account) GetHeaderOverrides() map[string]string {
return result
}
+// HeaderOverrideValue 返回指定 header(小写名)的生效覆写值。
+// 供转发链路在 header 写入前感知覆写结果(如 anthropic-beta 需要参与 body 净化)。
+func (a *Account) HeaderOverrideValue(lowerName string) (string, bool) {
+ value, ok := a.GetHeaderOverrides()[lowerName]
+ return value, ok
+}
+
// ApplyHeaderOverrides 将账号配置的请求头覆写应用到出站请求头。
// 对每个覆写条目:先删除所有大小写变体(转发链路会以 wire casing 直接写入 map,
// 可能存在非 canonical key),再按已知 wire casing 写入,避免产生重复头。
@@ -127,18 +169,16 @@ func (a *Account) ApplyHeaderOverrides(h http.Header) {
if len(overrides) == 0 {
return
}
- names := make([]string, 0, len(overrides))
- for name := range overrides {
- names = append(names, name)
- }
- sort.Strings(names)
- for _, name := range names {
+ // 覆写名两两不同(大小写不敏感)且各自只操作同名键,应用顺序不影响结果。
+ // 全量 EqualFold 扫描兜底删除任意 casing 的既有键:透传链路可能保留客户端
+ // 原始 casing,非 canonical/wire casing 的键 deleteHeaderAllForms 覆盖不到。
+ for name, value := range overrides {
for existing := range h {
if strings.EqualFold(existing, name) {
delete(h, existing)
}
}
- h[resolveWireCasing(name)] = []string{overrides[name]}
+ h[resolveWireCasing(name)] = []string{value}
}
}
@@ -186,34 +226,12 @@ func NormalizeHeaderOverrideCredentials(credentials map[string]any) error {
return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
"header %q value must be a string", name)
}
- lowerName := strings.ToLower(strings.TrimSpace(name))
- value = strings.TrimSpace(value)
+ lowerName, value, err := normalizeHeaderOverrideEntry(name, value)
+ if err != nil {
+ return err
+ }
if lowerName == "" {
- if value == "" {
- continue // 丢弃完全为空的占位行
- }
- return infraerrors.New(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
- "header name must not be empty")
- }
- if len(lowerName) > maxHeaderOverrideNameLength {
- return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
- "header name %q exceeds %d characters", lowerName, maxHeaderOverrideNameLength)
- }
- if !httpguts.ValidHeaderFieldName(lowerName) {
- return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
- "invalid header name %q", lowerName)
- }
- if isHeaderOverrideBlockedName(lowerName) {
- return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
- "header %q is not allowed to be overridden", lowerName)
- }
- if len(value) > maxHeaderOverrideValueLength {
- return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
- "header %q value exceeds %d characters", lowerName, maxHeaderOverrideValueLength)
- }
- if !httpguts.ValidHeaderFieldValue(value) {
- return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
- "header %q has an invalid value", lowerName)
+ continue // 丢弃完全为空的占位行
}
if _, dup := normalized[lowerName]; dup {
return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
@@ -224,3 +242,39 @@ func NormalizeHeaderOverrideCredentials(credentials map[string]any) error {
credentials[credKeyHeaderOverrides] = normalized
return nil
}
+
+// normalizeHeaderOverrideEntry 校验并规范化单个覆写条目,保存路径(Normalize,err → 400)
+// 与应用路径(resolveHeaderOverrides,err → 跳过)共用同一套规则,避免两处校验漂移。
+// 名和值均为空表示空占位行,返回 ("", "", nil);空 value 的具名条目合法(模板占位)。
+func normalizeHeaderOverrideEntry(name, value string) (string, string, error) {
+ lowerName := strings.ToLower(strings.TrimSpace(name))
+ value = strings.TrimSpace(value)
+ if lowerName == "" {
+ if value == "" {
+ return "", "", nil
+ }
+ return "", "", infraerrors.New(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header name must not be empty")
+ }
+ if len(lowerName) > maxHeaderOverrideNameLength {
+ return "", "", infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header name %q exceeds %d characters", lowerName, maxHeaderOverrideNameLength)
+ }
+ if !httpguts.ValidHeaderFieldName(lowerName) {
+ return "", "", infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "invalid header name %q", lowerName)
+ }
+ if isHeaderOverrideBlockedName(lowerName) {
+ return "", "", infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header %q is not allowed to be overridden", lowerName)
+ }
+ if len(value) > maxHeaderOverrideValueLength {
+ return "", "", infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header %q value exceeds %d characters", lowerName, maxHeaderOverrideValueLength)
+ }
+ if !httpguts.ValidHeaderFieldValue(value) {
+ return "", "", infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header %q has an invalid value", lowerName)
+ }
+ return lowerName, value, nil
+}
diff --git a/backend/internal/service/account_header_override_test.go b/backend/internal/service/account_header_override_test.go
index 56c95b3fcb..c89b5e0587 100644
--- a/backend/internal/service/account_header_override_test.go
+++ b/backend/internal/service/account_header_override_test.go
@@ -108,9 +108,11 @@ func TestGetHeaderOverrides(t *testing.T) {
defensive := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
credKeyHeaderOverrideEnabled: true,
credKeyHeaderOverrides: map[string]any{
- "x-big": oversizedValue,
- "sec-websocket-key": "forged",
- "x-ok": "ok",
+ "x-big": oversizedValue,
+ "sec-websocket-key": "forged",
+ "content-type": "application/json", // 名单扩充前落库的数据也要被拦截
+ "x-claude-code-session-id": "pinned-session",
+ "x-ok": "ok",
},
})
require.Equal(t, map[string]string{"x-ok": "ok"}, defensive.GetHeaderOverrides())
@@ -278,6 +280,8 @@ func TestNormalizeHeaderOverrideCredentials(t *testing.T) {
"Authorization", "x-api-key", "Host", "content-length", "Transfer-Encoding",
"connection", "accept-encoding", "Sec-WebSocket-Key", "session_id",
"conversation_id", "x-codex-turn-state", "chatgpt-account-id",
+ "Content-Type", "Cookie", "x-goog-api-key",
+ "X-Claude-Code-Session-Id", "x-client-request-id",
} {
err := NormalizeHeaderOverrideCredentials(map[string]any{
credKeyHeaderOverrides: map[string]any{name: "v"},
diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go
index e683fc5463..dcaf3a645c 100644
--- a/backend/internal/service/gateway_service.go
+++ b/backend/internal/service/gateway_service.go
@@ -5920,6 +5920,10 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
if c != nil && c.Request != nil {
clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta")
}
+ // 账号覆写了 anthropic-beta 时,覆写值即最终上游值:净化以覆写值为准
+ if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok {
+ clientBeta = beta
+ }
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta); changed {
body = sanitized
}
@@ -6889,6 +6893,12 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
tokenType, mimicClaudeCode, modelID, clientHeaders, body, effectiveDropSet,
)
+ // 账号覆写了 anthropic-beta 时,覆写值即最终上游值(由下方 ApplyHeaderOverrides 写入):
+ // body 能力净化必须以覆写值为准,否则 header/body 不对称会被上游 400。
+ if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok {
+ finalBetaHeader, finalBetaShouldSet = beta, true
+ }
+
// 能力维度 body sanitize:与最终 anthropic-beta header 对称
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed {
body = sanitized
@@ -10417,6 +10427,10 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough(
if c != nil && c.Request != nil {
clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta")
}
+ // 账号覆写了 anthropic-beta 时,覆写值即最终上游值:净化以覆写值为准
+ if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok {
+ clientBeta = beta
+ }
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta); changed {
body = sanitized
}
@@ -10522,6 +10536,11 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
tokenType, mimicClaudeCode, modelID, clientHeaders, body, ctEffectiveDropSet,
)
+ // 账号覆写了 anthropic-beta 时,覆写值即最终上游值:净化以覆写值为准
+ if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok {
+ finalBetaHeader, finalBetaShouldSet = beta, true
+ }
+
// 能力维度 body sanitize:与最终 anthropic-beta header 对称
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed {
body = sanitized
diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue
index 7ce016810e..91656b196d 100644
--- a/frontend/src/components/account/BulkEditAccountModal.vue
+++ b/frontend/src/components/account/BulkEditAccountModal.vue
@@ -534,6 +534,10 @@
+
+ {{ t('admin.accounts.headerOverride.bulkReplaceHint') }}
+
+
-
+