merge: sync batch image branch with origin main

This commit is contained in:
Turtle_Li
2026-07-07 03:27:11 +08:00
107 changed files with 4469 additions and 263 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.144
0.1.145
+5 -5
View File
@@ -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,13 +96,9 @@ 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)
batchImageRepository := repository.NewBatchImageRepository(db)
batchImageQueue := repository.NewBatchImageQueue(redisClient, configConfig)
batchImageDownloadLimiter := repository.NewBatchImageDownloadLimiter(redisClient, configConfig)
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig)
usageBillingRepository := repository.NewUsageBillingRepository(client, db)
gatewayCache := repository.NewGatewayCache(redisClient)
schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db)
+112 -61
View File
@@ -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 {
@@ -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 ||
@@ -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) {
+26 -25
View File
@@ -80,31 +80,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)
+1
View File
@@ -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"`
+2
View File
@@ -64,6 +64,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"`
+41 -2
View File
@@ -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.
@@ -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++
@@ -270,6 +297,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},
+1
View File
@@ -174,6 +174,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
service.OpenAIUpstreamTransportHTTPSSE,
"",
false,
false,
service.PlatformGrok,
)
if err != nil {
@@ -145,6 +145,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
service.OpenAIUpstreamTransportAny,
service.OpenAIEndpointCapabilityChatCompletions,
false,
false,
requestPlatform,
)
if err != nil {
@@ -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",
@@ -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())
@@ -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),
@@ -1320,7 +1322,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")
@@ -1335,7 +1337,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")
@@ -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",
@@ -1492,7 +1489,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)
}
@@ -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"`
+54 -5
View File
@@ -39,6 +39,12 @@ type EasyPay struct {
httpClient *http.Client
}
type easyPayCustomMethod struct {
Type string `json:"type"`
UpstreamType string `json:"upstreamType"`
DisplayName string `json:"displayName"`
}
// NewEasyPay creates a new EasyPay provider.
// config keys: pid, pkey, apiBase, notifyUrl, returnUrl, cid, cidAlipay, cidWxpay
func NewEasyPay(instanceID string, config map[string]string) (*EasyPay, error) {
@@ -95,7 +101,13 @@ func (e *EasyPay) apiBase() string {
func (e *EasyPay) Name() string { return "EasyPay" }
func (e *EasyPay) ProviderKey() string { return payment.TypeEasyPay }
func (e *EasyPay) SupportedTypes() []payment.PaymentType {
return []payment.PaymentType{payment.TypeAlipay, payment.TypeWxpay}
types := []payment.PaymentType{payment.TypeAlipay, payment.TypeWxpay}
for _, method := range e.customMethods() {
if method.Type != "" {
types = append(types, method.Type)
}
}
return types
}
func (e *EasyPay) MerchantIdentityMetadata() map[string]string {
@@ -124,13 +136,14 @@ func (e *EasyPay) CreatePayment(ctx context.Context, req payment.CreatePaymentRe
// TradeNo is empty; it arrives via the notify callback after payment.
func (e *EasyPay) createRedirectPayment(req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
notifyURL, returnURL := e.resolveURLs(req)
paymentType := e.upstreamPaymentType(req.PaymentType)
params := map[string]string{
"pid": e.config["pid"], "type": req.PaymentType,
"pid": e.config["pid"], "type": paymentType,
"out_trade_no": req.OrderID, "notify_url": notifyURL,
"return_url": returnURL, "name": req.Subject,
"money": req.Amount,
}
if cid := e.resolveCID(req.PaymentType); cid != "" {
if cid := e.resolveCID(paymentType); cid != "" {
params["cid"] = cid
}
if req.IsMobile {
@@ -150,13 +163,14 @@ func (e *EasyPay) createRedirectPayment(req payment.CreatePaymentRequest) (*paym
// createAPIPayment calls mapi.php to get payurl/qrcode (existing behavior).
func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
notifyURL, returnURL := e.resolveURLs(req)
paymentType := e.upstreamPaymentType(req.PaymentType)
params := map[string]string{
"pid": e.config["pid"], "type": req.PaymentType,
"pid": e.config["pid"], "type": paymentType,
"out_trade_no": req.OrderID, "notify_url": notifyURL,
"return_url": returnURL, "name": req.Subject,
"money": req.Amount, "clientip": req.ClientIP,
}
if cid := e.resolveCID(req.PaymentType); cid != "" {
if cid := e.resolveCID(paymentType); cid != "" {
params["cid"] = cid
}
if req.IsMobile {
@@ -204,6 +218,41 @@ func (e *EasyPay) resolveURLs(req payment.CreatePaymentRequest) (string, string)
return notifyURL, returnURL
}
func (e *EasyPay) customMethods() []easyPayCustomMethod {
if e == nil {
return nil
}
raw := strings.TrimSpace(e.config["customMethods"])
if raw == "" {
return nil
}
var methods []easyPayCustomMethod
if err := json.Unmarshal([]byte(raw), &methods); err != nil {
return nil
}
result := make([]easyPayCustomMethod, 0, len(methods))
for _, method := range methods {
method.Type = strings.TrimSpace(method.Type)
method.UpstreamType = strings.TrimSpace(method.UpstreamType)
method.DisplayName = strings.TrimSpace(method.DisplayName)
if method.Type == "" || method.UpstreamType == "" {
continue
}
result = append(result, method)
}
return result
}
func (e *EasyPay) upstreamPaymentType(paymentType string) string {
paymentType = strings.TrimSpace(paymentType)
for _, method := range e.customMethods() {
if paymentType == method.Type {
return method.UpstreamType
}
}
return paymentType
}
func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
params := map[string]string{
"act": "order", "pid": e.config["pid"],
@@ -179,6 +179,102 @@ func TestEasyPayRefundResponseErrors(t *testing.T) {
}
}
func TestEasyPayCustomMethodsUseConfiguredUpstreamType(t *testing.T) {
t.Parallel()
provider, err := NewEasyPay("test-instance", map[string]string{
"pid": "pid-1",
"pkey": "pkey-1",
"apiBase": "https://pay.example.com",
"notifyUrl": "https://example.com/notify",
"returnUrl": "https://example.com/return",
"paymentMode": paymentModePopup,
"customMethods": `[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt","displayName":"USDT-TRC20"}]`,
})
if err != nil {
t.Fatalf("NewEasyPay: %v", err)
}
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
OrderID: "sub2-custom-1",
Amount: "1.00",
PaymentType: "usdt_trc20",
Subject: "Custom EasyPay",
})
if err != nil {
t.Fatalf("CreatePayment: %v", err)
}
payURL, err := url.Parse(resp.PayURL)
if err != nil {
t.Fatalf("parse pay url: %v", err)
}
if got := payURL.Query().Get("type"); got != "usdt" {
t.Fatalf("pay url type = %q, want usdt (%s)", got, resp.PayURL)
}
}
func TestEasyPayCustomMethodsResolveCIDFromConfiguredUpstreamType(t *testing.T) {
t.Parallel()
provider, err := NewEasyPay("test-instance", map[string]string{
"pid": "pid-1",
"pkey": "pkey-1",
"apiBase": "https://pay.example.com",
"notifyUrl": "https://example.com/notify",
"returnUrl": "https://example.com/return",
"paymentMode": paymentModePopup,
"cidAlipay": "cid-alipay",
"cidWxpay": "cid-wxpay",
"customMethods": `[{"type":"ldc","upstreamType":"alipay","displayName":"LDC"}]`,
})
if err != nil {
t.Fatalf("NewEasyPay: %v", err)
}
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
OrderID: "sub2-custom-cid",
Amount: "1.00",
PaymentType: "ldc",
Subject: "Custom EasyPay CID",
})
if err != nil {
t.Fatalf("CreatePayment: %v", err)
}
payURL, err := url.Parse(resp.PayURL)
if err != nil {
t.Fatalf("parse pay url: %v", err)
}
if got := payURL.Query().Get("type"); got != "alipay" {
t.Fatalf("pay url type = %q, want alipay (%s)", got, resp.PayURL)
}
if got := payURL.Query().Get("cid"); got != "cid-alipay" {
t.Fatalf("pay url cid = %q, want cid-alipay (%s)", got, resp.PayURL)
}
}
func TestEasyPaySupportedTypesIncludeCustomMethods(t *testing.T) {
t.Parallel()
provider, err := NewEasyPay("test-instance", map[string]string{
"pid": "pid-1",
"pkey": "pkey-1",
"apiBase": "https://pay.example.com",
"notifyUrl": "https://example.com/notify",
"returnUrl": "https://example.com/return",
"customMethods": `[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt","displayName":"USDT-TRC20"}]`,
})
if err != nil {
t.Fatalf("NewEasyPay: %v", err)
}
got := strings.Join(provider.SupportedTypes(), ",")
for _, want := range []string{"alipay", "wxpay", "ldc", "usdt_trc20"} {
if !strings.Contains(got, want) {
t.Fatalf("SupportedTypes() = %q, want it to include %q", got, want)
}
}
}
func newTestEasyPay(t *testing.T, apiBase string) *EasyPay {
t.Helper()
+3
View File
@@ -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"},
@@ -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
@@ -254,6 +278,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)
}
@@ -353,6 +381,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) {
@@ -536,7 +612,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
@@ -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))
@@ -234,6 +234,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,
@@ -283,6 +284,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,
@@ -903,6 +905,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": "",
@@ -1173,6 +1176,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": "",
@@ -1740,6 +1744,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")
}
+8
View File
@@ -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
@@ -0,0 +1,280 @@
package service
import (
"net/http"
"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 覆写不生效或产生冲突;
// - content-type:承载报文框架信息(multipart boundary 为每请求随机值),静态覆写必然与 body 不匹配;
// - authorization/x-api-key/cookie 等:上游认证头由账号凭据统一注入,禁止通过覆写篡改或重新引入;
// - accept-encoding:强制压缩会破坏网关对上游流式响应(SSE/usage)的解析;
// - sec-websocket-*:WebSocket 握手头由拨号器管理(OpenAI WS 模式);
// - session_id/x-claude-code-session-id 等:逐请求会话隔离头,固定值会造成会话串扰。
var headerOverrideBlockedNames = map[string]struct{}{
"host": {},
"content-length": {},
"content-type": {},
"transfer-encoding": {},
"connection": {},
"keep-alive": {},
"proxy-authenticate": {},
"proxy-authorization": {},
"proxy-connection": {},
"te": {},
"trailer": {},
"upgrade": {},
"authorization": {},
"x-api-key": {},
"x-goog-api-key": {},
"cookie": {},
"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": {},
"x-claude-code-session-id": {},
"x-client-request-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 名会被跳过。
// 结果带热路径缓存(同 GetModelMapping 先例):同一 credentials 映射在
// 一次请求 / 一条 WS 会话内的多次调用只做一次解析与校验。
func (a *Account) GetHeaderOverrides() map[string]string {
if !a.IsHeaderOverrideEnabled() {
return nil
}
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, value, err := normalizeHeaderOverrideEntry(name, value)
if err != nil || lowerName == "" || value == "" {
continue
}
result[lowerName] = value
}
if len(result) == 0 {
return nil
}
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 写入,避免产生重复头。
// 账号未启用或不符合条件时为 no-op,可安全地在 OAuth/api_key 共用的构建器中调用。
func (a *Account) ApplyHeaderOverrides(h http.Header) {
if h == nil {
return
}
overrides := a.GetHeaderOverrides()
if len(overrides) == 0 {
return
}
// 覆写名两两不同(大小写不敏感)且各自只操作同名键,应用顺序不影响结果。
// 全量 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{value}
}
}
// 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, value, err := normalizeHeaderOverrideEntry(name, value)
if err != nil {
return err
}
if lowerName == "" {
continue // 丢弃完全为空的占位行
}
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
}
// 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
}
@@ -0,0 +1,339 @@
//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",
"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())
}
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",
"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"},
})
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)
})
}
@@ -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)
@@ -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")
}
@@ -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()
+21 -7
View File
@@ -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)
@@ -2664,13 +2670,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) {
@@ -2733,6 +2733,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),
@@ -2863,6 +2868,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:{},此时也必须落库。
@@ -3081,6 +3090,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,
@@ -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
@@ -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
+1
View File
@@ -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)
@@ -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
}
@@ -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
+13 -1
View File
@@ -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 使用配置中的默认倍率计算费用
@@ -53,6 +53,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
@@ -90,6 +96,8 @@ const (
defaultAccountLoadBatchCacheTTL = 200 * time.Millisecond
accountLoadBatchFetchTimeout = 3 * time.Second
maxAccountLoadBatchCacheEntries = 256
apiKeyConcurrencyFetchTimeout = 3 * time.Second
apiKeySlotTrackTimeout = 2 * time.Second
)
// ConcurrencyService 管理账号和用户的并发限制。
@@ -238,6 +246,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
// ============================================
@@ -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
}
@@ -201,6 +229,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()
@@ -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
}
@@ -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
}
@@ -5956,6 +5960,9 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
}
// 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头)
account.ApplyHeaderOverrides(req.Header)
return req, body, nil
}
@@ -6886,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
@@ -6959,6 +6972,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(),
@@ -10410,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
}
@@ -10445,6 +10466,9 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough(
req.Header.Set("anthropic-version", "2023-06-01")
}
// 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头)
account.ApplyHeaderOverrides(req.Header)
return req, nil
}
@@ -10512,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
@@ -10578,6 +10607,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))
}
@@ -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
}
@@ -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)
}
@@ -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)
}
@@ -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()
@@ -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"},
@@ -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()
@@ -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 {
@@ -231,6 +231,9 @@ func (s *OpenAIGatewayService) buildInputTokensUpstreamRequest(
}
}
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
account.ApplyHeaderOverrides(req.Header)
return req, nil
}
@@ -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()
@@ -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
}
@@ -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
}
@@ -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"):
@@ -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) 若已存在工具调用上下文则提前返回
@@ -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")
})
}
}
@@ -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
}
@@ -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))).
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
@@ -31,6 +32,7 @@ func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) (*M
continue
}
ml := pcAggregateMethodLimits(pt, insts)
ml.DisplayName = s.pcAggregateMethodDisplayName(pt, insts)
ml.Currency = currency
resp.Methods[ml.PaymentType] = ml
}
@@ -93,6 +95,7 @@ func (s *PaymentConfigService) GetMethodLimits(ctx context.Context, types []stri
continue
}
ml := pcAggregateMethodLimits(pt, matching)
ml.DisplayName = s.pcAggregateMethodDisplayName(pt, matching)
ml.Currency = currency
result = append(result, ml)
}
@@ -163,6 +166,53 @@ func (s *PaymentConfigService) pcInstancePaymentCurrency(inst *dbent.PaymentProv
return paymentProviderConfigCurrency(inst.ProviderKey, cfg)
}
type easyPayCustomMethodDisplayConfig struct {
Type string `json:"type"`
DisplayName string `json:"displayName"`
}
func (s *PaymentConfigService) pcAggregateMethodDisplayName(pt string, instances []*dbent.PaymentProviderInstance) string {
pt = strings.TrimSpace(pt)
if pt == "" {
return ""
}
for _, inst := range instances {
displayName := s.pcInstanceEasyPayCustomMethodDisplayName(inst, pt)
if displayName != "" {
return displayName
}
}
return ""
}
func (s *PaymentConfigService) pcInstanceEasyPayCustomMethodDisplayName(inst *dbent.PaymentProviderInstance, pt string) string {
if inst == nil || inst.ProviderKey != payment.TypeEasyPay {
return ""
}
cfg := map[string]string{}
if s != nil {
decrypted, err := s.decryptConfig(inst.Config)
if err == nil && decrypted != nil {
cfg = decrypted
}
}
raw := strings.TrimSpace(cfg["customMethods"])
if raw == "" {
return ""
}
var methods []easyPayCustomMethodDisplayConfig
if err := json.Unmarshal([]byte(raw), &methods); err != nil {
return ""
}
for _, method := range methods {
if strings.TrimSpace(method.Type) == pt {
return strings.TrimSpace(method.DisplayName)
}
}
return ""
}
// pcGroupByPaymentType groups instances by user-facing payment type.
// For Stripe providers, ALL sub-types (card, link, alipay, wxpay) map to "stripe"
// because the user sees a single "Stripe" button, not individual sub-methods.
@@ -255,6 +255,28 @@ func TestGetAvailableMethodLimitsOmitsMixedCurrencyMethod(t *testing.T) {
require.Equal(t, "PAYMENT_METHOD_CURRENCY_CONFLICT", appErr.Reason)
}
func TestGetAvailableMethodLimitsIncludesEasyPayCustomMethodDisplayName(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
_, err := client.PaymentProviderInstance.Create().
SetProviderKey(payment.TypeEasyPay).
SetName("EasyPay Custom").
SetConfig(`{"customMethods":"[{\"type\":\"ldc\",\"upstreamType\":\"ldc\",\"displayName\":\"LDC Pay\"}]"}`).
SetSupportedTypes("alipay,wxpay,ldc").
SetEnabled(true).
Save(ctx)
require.NoError(t, err)
svc := &PaymentConfigService{entClient: client}
resp, err := svc.GetAvailableMethodLimits(ctx)
require.NoError(t, err)
limits, ok := resp.Methods["ldc"]
require.True(t, ok, "expected custom EasyPay method limits to be visible")
require.Equal(t, "LDC Pay", limits.DisplayName)
}
func TestPcComputeGlobalRange(t *testing.T) {
t.Parallel()
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"regexp"
"strconv"
"strings"
@@ -185,6 +186,11 @@ func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req C
if err := validateProviderRequest(req.ProviderKey, req.Name, typesStr); err != nil {
return nil, err
}
if req.ProviderKey == payment.TypeEasyPay {
if err := validateEasyPayCustomMethods(req.Config, typesStr); err != nil {
return nil, err
}
}
if err := s.validateVisibleMethodEnablementConflicts(ctx, 0, req.ProviderKey, typesStr, req.Enabled); err != nil {
return nil, err
}
@@ -217,6 +223,67 @@ func validateProviderRequest(providerKey, name, supportedTypes string) error {
return nil
}
var easyPayCustomMethodCodePattern = regexp.MustCompile(`^[a-z0-9_-]+$`)
type easyPayCustomMethodConfig struct {
Type string `json:"type"`
UpstreamType string `json:"upstreamType"`
DisplayName string `json:"displayName"`
}
func validateEasyPayCustomMethods(config map[string]string, supportedTypes string) error {
if config == nil {
config = map[string]string{}
}
raw := strings.TrimSpace(config["customMethods"])
methods := make([]easyPayCustomMethodConfig, 0)
if raw != "" {
if err := json.Unmarshal([]byte(raw), &methods); err != nil {
return infraerrors.BadRequest("VALIDATION_ERROR", "customMethods must be a JSON array")
}
}
customTypes := make(map[string]struct{}, len(methods))
for _, method := range methods {
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")
}
if !easyPayCustomMethodCodePattern.MatchString(method.Type) {
return infraerrors.BadRequest("VALIDATION_ERROR", "customMethods type may only contain lowercase letters, digits, underscores, and hyphens")
}
if !easyPayCustomMethodCodePattern.MatchString(method.UpstreamType) {
return infraerrors.BadRequest("VALIDATION_ERROR", "customMethods upstreamType may only contain lowercase letters, digits, underscores, and hyphens")
}
if easyPayCustomMethodTypeConflictsWithBuiltin(method.Type) {
return infraerrors.BadRequest("VALIDATION_ERROR", "customMethods type cannot start with alipay or wxpay")
}
if _, exists := customTypes[method.Type]; exists {
return infraerrors.BadRequest("VALIDATION_ERROR", "duplicate customMethods type")
}
customTypes[method.Type] = struct{}{}
}
for _, supportedType := range splitTypes(supportedTypes) {
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))
}
}
return nil
}
func easyPayCustomMethodTypeConflictsWithBuiltin(methodType string) bool {
return strings.HasPrefix(methodType, payment.TypeAlipay) || strings.HasPrefix(methodType, payment.TypeWxpay)
}
// UpdateProviderInstance updates a provider instance by ID (patch semantics).
// NOTE: This function exceeds 30 lines due to per-field nil-check patch update
// boilerplate and pending-order safety checks.
@@ -279,6 +346,18 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
WithMetadata(map[string]string{"count": strconv.Itoa(count)})
}
}
configToValidate := mergedConfig
if configToValidate == nil {
configToValidate, err = s.decryptConfig(current.Config)
if err != nil {
return nil, fmt.Errorf("decrypt existing config: %w", err)
}
}
if current.ProviderKey == payment.TypeEasyPay {
if err := validateEasyPayCustomMethods(configToValidate, nextSupportedTypes); err != nil {
return nil, err
}
}
// Validate merged config when the instance will end up enabled.
// This surfaces provider-level errors (e.g. wxpay missing certSerial) at save time,
// so admins see them in the dialog instead of only when an order is created.
@@ -287,13 +366,6 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
finalEnabled = *req.Enabled
}
if finalEnabled {
configToValidate := mergedConfig
if configToValidate == nil {
configToValidate, err = s.decryptConfig(current.Config)
if err != nil {
return nil, fmt.Errorf("decrypt existing config: %w", err)
}
}
if err := s.validateProviderConfig(current.ProviderKey, configToValidate); err != nil {
return nil, err
}
@@ -114,6 +114,92 @@ func TestValidateProviderRequest(t *testing.T) {
}
}
func TestValidateEasyPayCustomMethods(t *testing.T) {
t.Parallel()
tests := []struct {
name string
config map[string]string
supportedTypes string
wantErr string
}{
{
name: "valid custom methods",
config: map[string]string{"customMethods": `[{"type":"ldc","upstreamType":"epay","displayName":"LDC"}]`},
supportedTypes: "alipay,wxpay,ldc",
},
{
name: "malformed custom methods json",
config: map[string]string{"customMethods": `not-json`},
supportedTypes: "alipay,wxpay,ldc",
wantErr: "customMethods must be a JSON array",
},
{
name: "missing upstream type",
config: map[string]string{"customMethods": `[{"type":"ldc","displayName":"LDC"}]`},
supportedTypes: "alipay,wxpay,ldc",
wantErr: "customMethods upstreamType is required",
},
{
name: "duplicate custom type",
config: map[string]string{"customMethods": `[{"type":"ldc","upstreamType":"epay"},{"type":"ldc","upstreamType":"epay2"}]`},
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"}]`},
supportedTypes: "alipay,wxpay,alipay_hk",
wantErr: "customMethods type cannot start with alipay or wxpay",
},
{
name: "custom type uses wxpay prefix",
config: map[string]string{"customMethods": `[{"type":"wxpay_usdt","upstreamType":"usdt"}]`},
supportedTypes: "alipay,wxpay,wxpay_usdt",
wantErr: "customMethods type cannot start with alipay or wxpay",
},
{
name: "supported custom type missing mapping",
config: map[string]string{"customMethods": `[{"type":"ldc","upstreamType":"epay"}]`},
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 {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := validateEasyPayCustomMethods(tc.config, tc.supportedTypes)
if tc.wantErr == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
require.Contains(t, err.Error(), tc.wantErr)
})
}
}
func TestIsSensitiveProviderConfigField(t *testing.T) {
t.Parallel()
@@ -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"`
@@ -110,6 +116,7 @@ type UpdatePaymentConfigRequest struct {
// MethodLimits holds per-payment-type limits.
type MethodLimits struct {
PaymentType string `json:"payment_type"`
DisplayName string `json:"display_name,omitempty"`
Currency string `json:"currency"`
FeeRate float64 `json:"fee_rate"`
DailyLimit float64 `json:"daily_limit"`
@@ -204,7 +211,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 +240,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 +302,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 +327,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 +367,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 ""
@@ -187,6 +187,23 @@ func TestParsePaymentConfig(t *testing.T) {
}
})
t.Run("custom enabled types are preserved", func(t *testing.T) {
t.Parallel()
vals := map[string]string{
SettingEnabledPaymentTypes: "alipay,ldc,usdt_trc20",
}
cfg := svc.parsePaymentConfig(vals)
want := []string{"alipay", "ldc", "usdt_trc20"}
if len(cfg.EnabledTypes) != len(want) {
t.Fatalf("EnabledTypes len = %d, want %d (%v)", len(cfg.EnabledTypes), len(want), cfg.EnabledTypes)
}
for i := range want {
if cfg.EnabledTypes[i] != want[i] {
t.Fatalf("EnabledTypes[%d] = %q, want %q (full=%v)", i, cfg.EnabledTypes[i], want[i], cfg.EnabledTypes)
}
}
})
t.Run("empty enabled types string", func(t *testing.T) {
t.Parallel()
vals := map[string]string{
@@ -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) {
+25 -3
View File
@@ -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.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 = calculateCreateOrderPayAmount(limitAmount, feeRate, selectedCurrency)
payAmountStr, payAmount, err = calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate, selectedCurrency, req.OrderType, cfg.SubscriptionUSDToCNYRate)
if err != nil {
return nil, err
}
@@ -630,6 +630,28 @@ func calculateCreateOrderPayAmount(limitAmount, feeRate float64, currency string
return payAmountStr, payAmount, nil
}
func calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate float64, currency, orderType string, usdToCnyRate float64) (string, float64, error) {
paymentAmount := limitAmount
if orderType == payment.OrderTypeSubscription {
paymentAmount = calculateSubscriptionGatewayBaseAmount(limitAmount, usdToCnyRate, currency)
}
return calculateCreateOrderPayAmount(paymentAmount, feeRate, currency)
}
// 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).
Mul(decimal.NewFromFloat(rate)).
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 {
@@ -161,27 +161,66 @@ func TestCalculateCreateOrderPayAmountUsesCurrencyPrecision(t *testing.T) {
}
}
func TestCalculateCreateOrderPayAmountForSubscriptionKeepsDirectPrice(t *testing.T) {
func TestCalculateCreateOrderPayAmountForSubscriptionConvertsCNYPriceWhenRateConfigured(t *testing.T) {
t.Parallel()
amountStr, amount, err := calculateCreateOrderPayAmount(5, 0, "CNY")
amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 0, "CNY", payment.OrderTypeSubscription, 7.15)
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.43" || amount != 71.43 {
t.Fatalf("subscription CNY pay amount = (%q, %v), want (71.43, 71.43)", 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, 7.15)
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.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, 7.15)
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)
}
}
// 换算是 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)
}
}
@@ -26,9 +26,10 @@ func TestNormalizeVisibleMethods(t *testing.T) {
" wxpay_direct ",
"wxpay",
"stripe",
"ldc",
})
want := []string{"alipay", "wxpay", "stripe"}
want := []string{"alipay", "wxpay", "stripe", "ldc"}
if len(got) != len(want) {
t.Fatalf("NormalizeVisibleMethods len = %d, want %d (%v)", len(got), len(want), got)
}
@@ -39,6 +40,21 @@ func TestNormalizeVisibleMethods(t *testing.T) {
}
}
func TestEnabledVisibleMethodsForEasyPayIncludesCustomSupportedTypes(t *testing.T) {
t.Parallel()
got := enabledVisibleMethodsForProvider(payment.TypeEasyPay, "alipay,ldc,usdt_trc20")
want := []string{"alipay", "ldc", "usdt_trc20"}
if len(got) != len(want) {
t.Fatalf("enabledVisibleMethodsForProvider len = %d, want %d (%v)", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("enabledVisibleMethodsForProvider[%d] = %q, want %q (full=%v)", i, got[i], want[i], got)
}
}
}
func TestNormalizePaymentSource(t *testing.T) {
t.Parallel()
@@ -16,8 +16,7 @@ func enabledVisibleMethodsForProvider(providerKey, supportedTypes string) []stri
methodSet := make(map[string]struct{}, 2)
addMethod := func(method string) {
method = NormalizeVisibleMethod(method)
switch method {
case payment.TypeAlipay, payment.TypeWxpay:
if method != "" {
methodSet[method] = struct{}{}
}
}
@@ -55,6 +54,14 @@ func enabledVisibleMethodsForProvider(providerKey, supportedTypes string) []stri
for _, method := range []string{payment.TypeAlipay, payment.TypeWxpay} {
if _, ok := methodSet[method]; ok {
methods = append(methods, method)
delete(methodSet, method)
}
}
for _, supportedType := range splitTypes(supportedTypes) {
method := NormalizeVisibleMethod(supportedType)
if _, ok := methodSet[method]; ok {
methods = append(methods, method)
delete(methodSet, method)
}
}
return methods
@@ -215,7 +222,7 @@ func (s *PaymentConfigService) resolveEnabledVisibleMethodInstance(
}
method = NormalizeVisibleMethod(method)
if method != payment.TypeAlipay && method != payment.TypeWxpay {
if method == "" {
return nil, nil
}
@@ -831,6 +831,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")).
@@ -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")
}
+28 -2
View File
@@ -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 == "" {
@@ -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
}
+2
View File
@@ -547,9 +547,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
}
@@ -4960,6 +4960,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,
+2
View File
@@ -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
+2
View File
@@ -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;
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="Payment">
<path d="M512 64c247.424 0 448 200.576 448 448S759.424 960 512 960 64 759.424 64 512 264.576 64 512 64Z" fill="#4F46E5"/>
<path d="M307 329c0-39.765 32.235-72 72-72h274c39.765 0 72 32.235 72 72v36H371c-35.346 0-64 28.654-64 64V329Z" fill="#C7D2FE"/>
<path d="M260 413c0-35.346 28.654-64 64-64h392c35.346 0 64 28.654 64 64v258c0 35.346-28.654 64-64 64H324c-35.346 0-64-28.654-64-64V413Z" fill="#FFFFFF"/>
<path d="M636 481c0-30.928 25.072-56 56-56h88v214h-88c-30.928 0-56-25.072-56-56V481Z" fill="#EEF2FF"/>
<path d="M708 494c30.928 0 56 25.072 56 56s-25.072 56-56 56-56-25.072-56-56 25.072-56 56-56Z" fill="#FBBF24"/>
<path d="M348 457c0-17.673 14.327-32 32-32h172c17.673 0 32 14.327 32 32s-14.327 32-32 32H380c-17.673 0-32-14.327-32-32Z" fill="#4F46E5"/>
<path d="M348 557c0-15.464 12.536-28 28-28h152c15.464 0 28 12.536 28 28s-12.536 28-28 28H376c-15.464 0-28-12.536-28-28Z" fill="#A5B4FC"/>
</svg>

After

Width:  |  Height:  |  Size: 1012 B

@@ -486,6 +486,129 @@
</div>
</div>
<!-- Header Override (anthropic/openai apikey only) -->
<div v-if="allHeaderOverrideCapable" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="flex items-center justify-between">
<div class="flex-1 pr-4">
<label
id="bulk-edit-header-override-label"
class="input-label mb-0"
for="bulk-edit-header-override-enabled"
>
{{ t('admin.accounts.headerOverride.title') }}
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.hint') }}
</p>
</div>
<input
v-model="enableHeaderOverride"
id="bulk-edit-header-override-enabled"
type="checkbox"
aria-controls="bulk-edit-header-override-body"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</div>
<div v-if="enableHeaderOverride" id="bulk-edit-header-override-body" class="mt-3 space-y-3">
<button
type="button"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
headerOverrideEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
@click="headerOverrideEnabled = !headerOverrideEnabled"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
headerOverrideEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
<div v-if="headerOverrideEnabled" class="space-y-3">
<div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<p class="text-xs text-blue-700 dark:text-blue-400">
<Icon name="exclamationCircle" size="sm" class="mr-1 inline" :stroke-width="2" />
{{ t('admin.accounts.headerOverride.info') }}
</p>
</div>
<p class="text-xs text-amber-600 dark:text-amber-400">
{{ t('admin.accounts.headerOverride.bulkReplaceHint') }}
</p>
<div v-if="headerOverrideRows.length > 0" class="space-y-2">
<div
v-for="(row, index) in headerOverrideRows"
:key="getHeaderOverrideRowKey(row)"
class="flex items-center gap-2"
>
<input
v-model="row.name"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.namePlaceholder')"
/>
<input
v-model="row.value"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.valuePlaceholder')"
/>
<button
type="button"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
@click="removeHeaderOverrideRow(index)"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<button
type="button"
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
@click="addHeaderOverrideRow"
>
<svg class="mr-1 inline h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
{{ t('admin.accounts.headerOverride.addRow') }}
</button>
<div v-if="headerOverrideTemplatePlatform" class="flex flex-wrap gap-2">
<button
type="button"
class="rounded-lg bg-primary-50 px-3 py-1 text-xs text-primary-700 transition-colors hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
@click="fillHeaderOverrideTemplate"
>
+ {{ t('admin.accounts.headerOverride.fillTemplate') }}
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.emptyValueHint') }}
</p>
</div>
<p v-else class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.bulkDisableHint') }}
</p>
</div>
</div>
<!-- Proxy -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
@@ -1149,6 +1272,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 +1350,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 +1396,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 +1425,39 @@ const modelMappings = ref<ModelMapping[]>([])
const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false)
const headerOverrideEnabled = ref(false)
const headerOverrideRows = ref<HeaderOverrideRow[]>([])
const getHeaderOverrideRowKey = createStableObjectKeyResolver<HeaderOverrideRow>('bulk-header-override-row')
const addHeaderOverrideRow = () => {
headerOverrideRows.value.push({ name: '', value: '' })
}
const removeHeaderOverrideRow = (index: number) => {
headerOverrideRows.value.splice(index, 1)
}
// 模板仅在所选账号平台唯一时可用:混合 anthropic+openai 选择无法确定用哪套模板,
// 误填会把另一平台的专有头写进所有所选账号
const headerOverrideTemplatePlatform = computed(() => {
return targetSelectedPlatforms.value.length === 1 ? targetSelectedPlatforms.value[0] : null
})
// 模板按钮:填入所选平台的标准客户端请求头名称(值留空),跳过已存在的同名行
const fillHeaderOverrideTemplate = () => {
const platform = headerOverrideTemplatePlatform.value
if (!platform) return
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<number | null>(null)
const concurrency = ref(1)
const loadFactor = ref<number | null>(null)
@@ -1523,6 +1700,15 @@ const buildUpdatePayload = (): Record<string, unknown> | 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 +1837,7 @@ const handleSubmit = async () => {
enableModelRestriction.value ||
enableCustomErrorCodes.value ||
enableInterceptWarmup.value ||
enableHeaderOverride.value ||
enableProxy.value ||
enableConcurrency.value ||
enableLoadFactor.value ||
@@ -1672,6 +1859,20 @@ const handleSubmit = async () => {
return
}
if (enableHeaderOverride.value && headerOverrideEnabled.value) {
// 批量保存对 header_overrides 是整键替换:开启但没有任何有效行会把所选账号的
// 既有覆写配置静默清空,必须显式拦截(清空请走关闭开关的路径,有专门提示)
if (!headerOverrideRows.value.some((row) => row.name.trim())) {
appStore.showError(t('admin.accounts.headerOverride.bulkEmptyRows'))
return
}
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 +1954,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 +1980,8 @@ watch(
selectedErrorCodes.value = []
customErrorCodeInput.value = null
interceptWarmupRequests.value = false
headerOverrideEnabled.value = false
headerOverrideRows.value = []
proxyId.value = null
concurrency.value = 1
loadFactor.value = null
@@ -1468,6 +1468,110 @@
</div>
</div>
<!-- Header Override Section (anthropic/openai apikey only) -->
<div
v-if="isHeaderOverridePlatform(form.platform)"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.headerOverride.title') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.hint') }}
</p>
</div>
<button
type="button"
@click="headerOverrideEnabled = !headerOverrideEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
headerOverrideEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
headerOverrideEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="headerOverrideEnabled" class="space-y-3">
<div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<p class="text-xs text-blue-700 dark:text-blue-400">
<Icon name="exclamationCircle" size="sm" class="mr-1 inline" :stroke-width="2" />
{{ t('admin.accounts.headerOverride.info') }}
</p>
</div>
<div v-if="headerOverrideRows.length > 0" class="space-y-2">
<div
v-for="(row, index) in headerOverrideRows"
:key="getHeaderOverrideRowKey(row)"
class="flex items-center gap-2"
>
<input
v-model="row.name"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.namePlaceholder')"
/>
<input
v-model="row.value"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.valuePlaceholder')"
/>
<button
type="button"
@click="removeHeaderOverrideRow(index)"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<button
type="button"
@click="addHeaderOverrideRow"
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
>
<svg class="mr-1 inline h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
{{ t('admin.accounts.headerOverride.addRow') }}
</button>
<div class="flex flex-wrap gap-2">
<button
type="button"
@click="fillHeaderOverrideTemplate"
class="rounded-lg bg-primary-50 px-3 py-1 text-xs text-primary-700 transition-colors hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
>
+ {{ t('admin.accounts.headerOverride.fillTemplate') }}
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.emptyValueHint') }}
</p>
</div>
</div>
</div>
<!-- Bedrock credentials (only for Anthropic Bedrock type) -->
@@ -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<number[]>([])
const customErrorCodeInput = ref<number | null>(null)
const headerOverrideEnabled = ref(false)
const headerOverrideRows = ref<HeaderOverrideRow[]>([])
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<TempUnschedRuleForm[]>([])
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-model-mapping')
const getHeaderOverrideRowKey = createStableObjectKeyResolver<HeaderOverrideRow>('create-header-override-row')
const getOpenAICompactModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-openai-compact-model-mapping')
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-antigravity-model-mapping')
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('create-temp-unsched-rule')
@@ -3970,6 +4104,10 @@ watch(
anthropicAPIKeyAuthScheme.value = 'x_api_key'
webSearchEmulationMode.value = 'default'
}
// 请求头覆写为平台相关配置(模板/常用头集合不同),切换平台时清空,
// 避免上一平台的模板行被提交到新平台账号
headerOverrideEnabled.value = false
headerOverrideRows.value = []
// Reset OAuth states
oauth.resetState()
openaiOAuth.resetState()
@@ -4359,6 +4497,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 +4929,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
@@ -417,6 +417,110 @@
</div>
</div>
<!-- Header Override Section (anthropic/openai apikey only) -->
<div
v-if="isHeaderOverridePlatform(account.platform)"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.headerOverride.title') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.hint') }}
</p>
</div>
<button
type="button"
@click="headerOverrideEnabled = !headerOverrideEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
headerOverrideEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
headerOverrideEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="headerOverrideEnabled" class="space-y-3">
<div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<p class="text-xs text-blue-700 dark:text-blue-400">
<Icon name="exclamationCircle" size="sm" class="mr-1 inline" :stroke-width="2" />
{{ t('admin.accounts.headerOverride.info') }}
</p>
</div>
<div v-if="headerOverrideRows.length > 0" class="space-y-2">
<div
v-for="(row, index) in headerOverrideRows"
:key="getHeaderOverrideRowKey(row)"
class="flex items-center gap-2"
>
<input
v-model="row.name"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.namePlaceholder')"
/>
<input
v-model="row.value"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.valuePlaceholder')"
/>
<button
type="button"
@click="removeHeaderOverrideRow(index)"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<button
type="button"
@click="addHeaderOverrideRow"
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
>
<svg class="mr-1 inline h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
{{ t('admin.accounts.headerOverride.addRow') }}
</button>
<div class="flex flex-wrap gap-2">
<button
type="button"
@click="fillHeaderOverrideTemplate"
class="rounded-lg bg-primary-50 px-3 py-1 text-xs text-primary-700 transition-colors hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
>
+ {{ t('admin.accounts.headerOverride.fillTemplate') }}
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.emptyValueHint') }}
</p>
</div>
</div>
</div>
<!-- OpenAI/Grok OAuth Model Mapping (OAuth 类型没有 apikey 容器,需要独立的模型映射区域) -->
@@ -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<number[]>([])
const customErrorCodeInput = ref<number | null>(null)
const headerOverrideEnabled = ref(false)
const headerOverrideRows = ref<HeaderOverrideRow[]>([])
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<number | null>(null)
@@ -2580,6 +2716,7 @@ const isSyncingAntigravityUpstream = ref(false)
const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-model-mapping')
const getHeaderOverrideRowKey = createStableObjectKeyResolver<HeaderOverrideRow>('edit-header-override-row')
const getOpenAICompactModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-openai-compact-model-mapping')
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-antigravity-model-mapping')
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('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<string, unknown>
@@ -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<string, unknown>
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)) {
@@ -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')
})
@@ -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,202 @@ 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')
expect(validateHeaderOverrideRows([{ name: 'Content-Type', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'Cookie', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'x-goog-api-key', 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = {
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<string, unknown> = {
[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('measures value length in UTF-8 bytes to match backend', () => {
// 3000 个 CJK 字符 = 3000 UTF-16 code units,但 9000 UTF-8 字节 > 8192
expect(validateHeaderOverrideRows([{ name: 'x-app', value: '测'.repeat(3000) }])).toBe(
'invalidValue'
)
expect(validateHeaderOverrideRows([{ name: 'x-app', value: '测'.repeat(2000) }])).toBeNull()
})
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'
)
expect(validateHeaderOverrideRows([{ name: 'X-Claude-Code-Session-Id', value: '' }])).toBe(
'blockedName'
)
expect(validateHeaderOverrideRows([{ name: 'x-client-request-id', 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')
})
})
@@ -24,3 +24,180 @@ 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',
'content-type',
'transfer-encoding',
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'proxy-connection',
'te',
'trailer',
'upgrade',
'authorization',
'x-api-key',
'x-goog-api-key',
'cookie',
'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',
'x-claude-code-session-id',
'x-client-request-id'
])
/** RFC 7230 token:合法的 HTTP header 名称字符集 */
const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
function isValidHeaderOverrideName(name: string): boolean {
return HEADER_NAME_PATTERN.test(name)
}
/** 模板: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]/
/** 长度限制按 UTF-8 字节计(与后端 Go len() 对齐,避免多字节值前端放行后端 400) */
const HEADER_TEXT_ENCODER = new TextEncoder()
function utf8ByteLength(value: string): number {
return HEADER_TEXT_ENCODER.encode(value).length
}
/**
* 校验请求头覆写行,返回首个错误的 i18n key(无错误返回 null)。
* 名称为空但值非空 → invalidName;名称非法 → invalidName;
* 禁止覆写 → blockedName;大小写不敏感重名 → duplicateName;
* 值含控制字符或超长 → invalidValue;条目过多 → tooManyEntries。
*/
export function validateHeaderOverrideRows(
rows: HeaderOverrideRow[]
): 'invalidName' | 'blockedName' | 'duplicateName' | 'invalidValue' | 'tooManyEntries' | null {
const seen = new Set<string>()
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) ||
utf8ByteLength(value) > 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<string, string> {
const result: Record<string, string> = {}
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<string, unknown>)
.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<string, unknown>,
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]
}
}
@@ -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: {
@@ -20,9 +20,9 @@
@click="method.available && emit('select', method.type)"
>
<span class="flex items-center gap-2">
<img :src="methodIcon(method.type)" :alt="t(`payment.methods.${method.type}`)" class="h-7 w-7 object-contain" />
<img :src="methodIcon(method.type)" :alt="methodLabel(method)" class="h-7 w-7 object-contain" />
<span class="flex flex-col items-start leading-none">
<span class="text-base font-semibold">{{ t(`payment.methods.${method.type}`) }}</span>
<span class="text-base font-semibold">{{ methodLabel(method) }}</span>
<span
v-if="method.fee_rate > 0"
class="text-[10px] tracking-wide text-gray-500 dark:text-dark-400"
@@ -39,14 +39,16 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { METHOD_ORDER } from './providerConfig'
import { METHOD_ORDER, isBuiltInAlipayMethod, isBuiltInWxpayMethod } from './providerConfig'
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
display_name?: string
fee_rate: number
available: boolean
}
@@ -67,6 +69,7 @@ const METHOD_ICONS: Record<string, string> = {
wxpay: wxpayIcon,
stripe: stripeIcon,
airwallex: airwallexIcon,
credit_card: paymentIcon,
}
const sortedMethods = computed(() => {
@@ -79,15 +82,19 @@ 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 (isBuiltInAlipayMethod(type)) return METHOD_ICONS.alipay
if (isBuiltInWxpayMethod(type)) return METHOD_ICONS.wxpay
if (type === 'airwallex') return METHOD_ICONS.airwallex
return METHOD_ICONS[type] || alipayIcon
return METHOD_ICONS[type] || paymentIcon
}
function methodLabel(method: PaymentMethodOption): string {
return method.display_name || t(`payment.methods.${method.type}`, method.type)
}
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 (isBuiltInAlipayMethod(type)) return 'border-[#02A9F1] bg-blue-50 text-gray-900 shadow-sm dark:bg-blue-950 dark:text-gray-100'
if (isBuiltInWxpayMethod(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'
@@ -70,6 +70,49 @@
</div>
</div>
<div v-if="form.provider_key === 'easypay'" class="space-y-3 rounded-lg border border-gray-100 p-3 dark:border-dark-700">
<div class="flex items-center justify-between gap-3">
<div>
<h5 class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.settings.payment.easypayCustomMethods') }}
</h5>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.payment.easypayCustomMethodsHint') }}
</p>
</div>
<button type="button" class="btn btn-secondary btn-sm" @click="addEasyPayCustomMethod">
{{ t('admin.settings.payment.addCustomMethod') }}
</button>
</div>
<div v-if="easyPayCustomMethods.length" class="space-y-2">
<div
v-for="(method, index) in easyPayCustomMethods"
:key="index"
class="grid grid-cols-[1fr_1fr_1fr_auto] items-end gap-2"
>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.customMethodType') }}</label>
<input v-model="method.type" type="text" class="input mt-0.5" placeholder="credit_card" />
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.customMethodUpstreamType') }}</label>
<input v-model="method.upstreamType" type="text" class="input mt-0.5" placeholder="credit_card" />
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.customMethodDisplayName') }}</label>
<input v-model="method.displayName" type="text" class="input mt-0.5" placeholder="信用卡" />
</div>
<button
type="button"
class="rounded-lg border border-red-200 px-2.5 py-2 text-xs font-medium text-red-600 transition-colors hover:bg-red-50 dark:border-red-800/60 dark:text-red-300 dark:hover:bg-red-900/20"
@click="removeEasyPayCustomMethod(index)"
>
{{ t('common.delete') }}
</button>
</div>
</div>
</div>
<!-- Config fields -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-700">
@@ -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<Record<string, boolean>>({})
const easyPayCustomMethods = reactive<EasyPayCustomMethod[]>([])
// --- 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<string>()
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<string>()
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
@@ -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')
@@ -79,7 +79,7 @@
<!-- Brand logo overlay -->
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<span :class="['rounded-full p-2 shadow ring-2 ring-white', qrLogoBgClass]">
<img :src="isAlipay ? alipayIcon : wxpayIcon" alt="" class="h-5 w-5 brightness-0 invert" />
<img :src="qrLogoIcon" alt="" class="h-5 w-5 brightness-0 invert" />
</span>
</div>
</div>
@@ -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')
@@ -0,0 +1,37 @@
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')
})
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]')
})
})
@@ -7,6 +7,12 @@ import type { ProviderInstance } from '@/types/payment'
const messages: Record<string, string> = {
'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,85 @@ describe('PaymentProviderDialog payment guide', () => {
const payload = wrapper.emitted('save')?.[0]?.[0] as { config: Record<string, string> }
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 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')
}
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<string, string>
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 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')
}
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()
})
})
@@ -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({
@@ -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', () => {
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest'
import { PAYMENT_CURRENCY_OPTIONS, PROVIDER_CONFIG_FIELDS } from '@/components/payment/providerConfig'
import {
PAYMENT_CURRENCY_OPTIONS,
PROVIDER_CONFIG_FIELDS,
isBuiltInAlipayMethod,
isBuiltInWxpayMethod,
parseEasyPayCustomMethods,
serializeEasyPayCustomMethods,
} from '@/components/payment/providerConfig'
function findField(providerKey: string, key: string) {
const fields = PROVIDER_CONFIG_FIELDS[providerKey] || []
@@ -50,3 +57,39 @@ 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('')
})
})
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)
})
})
@@ -99,7 +99,7 @@ export function getVisibleMethods(methods: Record<string, MethodLimit>): Record<
const visible: Record<string, MethodLimit> = {}
Object.entries(methods).forEach(([type, limit]) => {
const normalized = normalizeVisibleMethod(type)
const normalized = normalizeVisibleMethod(type) || type.trim()
if (!normalized) return
const isCanonical = type === normalized
@@ -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
@@ -44,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'
@@ -171,6 +185,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 ''
@@ -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' },
+35
View File
@@ -755,6 +755,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',
@@ -3693,6 +3694,24 @@ 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.',
bulkReplaceHint: 'Saving will replace the existing header override configuration on all selected accounts with the rows below.',
bulkEmptyRows: 'Add at least one header row before saving, or turn the toggle off to clear existing configuration.',
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 (%)',
@@ -6203,6 +6222,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}',
@@ -6248,6 +6271,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',
@@ -6277,6 +6306,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.',
+35
View File
@@ -754,6 +754,7 @@ export default {
deleteConfirmMessage: "确定要删除 '{name}' 吗?此操作无法撤销。",
apiKey: 'API 密钥',
group: '分组',
currentConcurrency: '当前并发',
noGroup: '无分组',
searchGroup: '搜索分组...',
noGroupFound: '未找到匹配的分组',
@@ -3860,6 +3861,24 @@ export default {
errorCodeExists: '该错误码已被选中',
interceptWarmupRequests: '拦截预热请求',
interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token',
headerOverride: {
title: '请求头覆写',
hint: '转发时用配置值覆盖同名请求头(不区分大小写)',
info: '仅对本账号的出站请求生效:配置的请求头会在转发前覆盖客户端/网关生成的同名头。认证头(authorization、x-api-key)与连接控制头不允许覆写。',
namePlaceholder: '请求头名称(如 user-agent)',
valuePlaceholder: '覆写值(留空表示不覆写)',
addRow: '添加请求头',
fillTemplate: '填入模板',
emptyValueHint: '值留空的行不会参与覆盖,仅作为待填写的占位。',
bulkDisableHint: '保存后将关闭所选账号的请求头覆写并清空已有配置。',
bulkReplaceHint: '保存后将用下方配置整体替换所选账号已有的请求头覆写配置。',
bulkEmptyRows: '请至少添加一行请求头再保存;如需清空已有配置,请关闭上方开关。',
invalidName: '请求头名称格式不正确(仅允许字母、数字和 !#$%&\'*+-.^_`|~ 字符)',
blockedName: '该请求头不允许覆写(认证头与连接控制头由系统管理)',
duplicateName: '存在重复的请求头名称(匹配不区分大小写)',
invalidValue: '请求头值不合法(不允许控制字符,长度不超过 8192)',
tooManyEntries: '请求头覆写条目过多(最多 64 条)'
},
autoPauseOnExpired: '过期自动暂停调度',
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',
autoPause5hThreshold: '5h 用量阈值(%)',
@@ -6357,6 +6376,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} 元',
@@ -6402,6 +6425,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: '同步跳转地址',
@@ -6431,6 +6460,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 选择账户默认或最新稳定版本。',
+1
View File
@@ -590,6 +590,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
+4
View File
@@ -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
@@ -42,6 +43,7 @@ export interface PaymentConfig {
export interface MethodLimit {
currency?: string
display_name?: string
daily_limit: number
daily_used: number
daily_remaining: number
@@ -66,6 +68,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
+10 -3
View File
@@ -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 = () => {
+31
View File
@@ -6480,6 +6480,34 @@
}}
</p>
</div>
<div>
<label class="input-label">{{
t("admin.settings.payment.subscriptionUsdToCnyRate")
}}</label>
<input
:value="form.payment_subscription_usd_to_cny_rate || ''"
@input="
form.payment_subscription_usd_to_cny_rate =
parseFloat(
($event.target as HTMLInputElement).value,
) || 0
"
type="number"
step="0.01"
min="0"
class="input"
:placeholder="
t(
'admin.settings.payment.subscriptionUsdToCnyRateDisabled',
)
"
/>
<p class="mt-0.5 text-xs text-gray-400">
{{
t("admin.settings.payment.subscriptionUsdToCnyRateHint")
}}
</p>
</div>
<div>
<label class="input-label">{{
t("admin.settings.payment.rechargeFeeRate")
@@ -8037,6 +8065,7 @@ const form = reactive<SettingsForm>({
payment_order_timeout_minutes: 30,
payment_balance_disabled: false,
payment_balance_recharge_multiplier: 1,
payment_subscription_usd_to_cny_rate: 0,
payment_recharge_fee_rate: 0,
payment_enabled_types: [],
payment_help_image_url: "",
@@ -9538,6 +9567,8 @@ async function saveSettings() {
payment_balance_disabled: form.payment_balance_disabled,
payment_balance_recharge_multiplier:
Number(form.payment_balance_recharge_multiplier) || 1,
payment_subscription_usd_to_cny_rate:
Number(form.payment_subscription_usd_to_cny_rate) || 0,
payment_recharge_fee_rate: Number(form.payment_recharge_fee_rate) || 0,
payment_enabled_types: form.payment_enabled_types,
payment_load_balance_strategy: form.payment_load_balance_strategy,

Some files were not shown because too many files have changed in this diff Show More