From 53e6a1b864c72af5dc754326416eb1084cd8607a Mon Sep 17 00:00:00 2001 From: yingcheng1026 Date: Thu, 30 Apr 2026 15:48:44 +0800 Subject: [PATCH 001/153] feat(scheduler): respect account.LoadFactor in weighted selection When multiple OpenAI accounts share similar scores (which is the common case once errors/load are stable), buildOpenAIWeightedSelectionOrder used to fall back to near-uniform random and ignored the configured load_factor entirely (only Concurrency was used as a slot cap). This caused operators with mixed Pro 20x + Plus pools to see Plus accounts taking the same share of traffic as Pro 20x, defeating the whole point of capacity-based pool sizing. See upstream issue #979. The fix multiplies each candidate's weight by account.EffectiveLoadFactor() so that, when scores tie, traffic distributes proportionally to capacity. Existing score-based dynamic adjustments (priority / load / queue / error rate / TTFT) still apply on top of this multiplier. Includes two new unit tests covering 15:1 weighting and the LoadFactor=nil fallback to Concurrency. Co-Authored-By: Claude Opus 4.7 --- .../service/openai_account_scheduler.go | 14 ++- .../service/openai_account_scheduler_test.go | 88 +++++++++++++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index 7a0a6636ec8..bbd25ffd4cd 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -548,11 +548,17 @@ func buildOpenAIWeightedSelectionOrder( } for i := range pool { // 将 top-K 分值平移到正区间,避免“单一最高分账号”长期垄断。 - weight := (pool[i].score - minScore) + 1.0 - if math.IsNaN(weight) || math.IsInf(weight, 0) || weight <= 0 { - weight = 1.0 + baseWeight := (pool[i].score - minScore) + 1.0 + if math.IsNaN(baseWeight) || math.IsInf(baseWeight, 0) || baseWeight <= 0 { + baseWeight = 1.0 } - weights[i] = weight + // 按账号容量配比加权:Pro 20x 类大号配 LoadFactor=20,Plus 配 1, + // 抽样比例自动按 LoadFactor 比分摊流量,不再"单一高分号长期垄断"。 + capacityMultiplier := float64(pool[i].account.EffectiveLoadFactor()) + if capacityMultiplier <= 0 { + capacityMultiplier = 1.0 + } + weights[i] = baseWeight * capacityMultiplier } order := make([]openAIAccountCandidateScore, 0, len(pool)) diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index 0950ee54767..8b714eae89d 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -1424,3 +1424,91 @@ func TestDefaultOpenAIAccountScheduler_IsAccountTransportCompatible_Branches(t * func int64PtrForTest(v int64) *int64 { return &v } + +// TestBuildOpenAIWeightedSelectionOrder_LoadFactorWeighting 验证当多个账号 +// 评分相同时,buildOpenAIWeightedSelectionOrder 会按 account.EffectiveLoadFactor() +// 比例分配选中概率(Pro 20x 类大号 LoadFactor=15 配 9 个 Plus LoadFactor=1, +// 期望 Pro 占比 ≈ 15/24 ≈ 62.5%,每个 Plus ≈ 4.17%)。 +func TestBuildOpenAIWeightedSelectionOrder_LoadFactorWeighting(t *testing.T) { + const ( + proID = int64(12) + proLoadFactor = 15 + plusCount = 9 + iterations = 5000 + ) + + candidates := []openAIAccountCandidateScore{ + { + account: &Account{ID: proID, LoadFactor: intPtrForTest(proLoadFactor)}, + loadInfo: &AccountLoadInfo{LoadRate: 10, WaitingCount: 0}, + score: 1.0, + }, + } + for i := 0; i < plusCount; i++ { + candidates = append(candidates, openAIAccountCandidateScore{ + account: &Account{ID: int64(100 + i), LoadFactor: intPtrForTest(1)}, + loadInfo: &AccountLoadInfo{LoadRate: 10, WaitingCount: 0}, + score: 1.0, + }) + } + + hits := make(map[int64]int, len(candidates)) + for i := 0; i < iterations; i++ { + req := OpenAIAccountScheduleRequest{ + SessionHash: fmt.Sprintf("loadfactor_test_%d", i), + RequestedModel: "gpt-5.1", + } + order := buildOpenAIWeightedSelectionOrder(candidates, req) + require.Len(t, order, len(candidates)) + hits[order[0].account.ID]++ + } + + totalWeight := float64(proLoadFactor + plusCount) + expectedProRatio := float64(proLoadFactor) / totalWeight + expectedPlusRatio := 1.0 / totalWeight + + proRatio := float64(hits[proID]) / float64(iterations) + require.InDelta(t, expectedProRatio, proRatio, 0.05, + "Pro 20x (LoadFactor=%d) 实际占比 %.2f%% 与期望 %.2f%% 偏差超过 5%%", + proLoadFactor, proRatio*100, expectedProRatio*100) + + for i := 0; i < plusCount; i++ { + plusID := int64(100 + i) + plusRatio := float64(hits[plusID]) / float64(iterations) + require.InDelta(t, expectedPlusRatio, plusRatio, 0.03, + "Plus 号 id=%d 实际占比 %.2f%% 与期望 %.2f%% 偏差超过 3%%", + plusID, plusRatio*100, expectedPlusRatio*100) + } +} + +// TestBuildOpenAIWeightedSelectionOrder_LoadFactorNilFallsBackToConcurrency 验证 +// LoadFactor=nil 的旧账号会 fallback 到 Concurrency,不会被当作权重 0 而抽不到。 +func TestBuildOpenAIWeightedSelectionOrder_LoadFactorNilFallsBackToConcurrency(t *testing.T) { + candidates := []openAIAccountCandidateScore{ + { + account: &Account{ID: 201, LoadFactor: nil, Concurrency: 5}, + loadInfo: &AccountLoadInfo{LoadRate: 10, WaitingCount: 0}, + score: 1.0, + }, + { + account: &Account{ID: 202, LoadFactor: nil, Concurrency: 5}, + loadInfo: &AccountLoadInfo{LoadRate: 10, WaitingCount: 0}, + score: 1.0, + }, + } + + hits := make(map[int64]int) + for i := 0; i < 1000; i++ { + req := OpenAIAccountScheduleRequest{ + SessionHash: fmt.Sprintf("nil_loadfactor_%d", i), + RequestedModel: "gpt-5.1", + } + order := buildOpenAIWeightedSelectionOrder(candidates, req) + require.Len(t, order, len(candidates)) + hits[order[0].account.ID]++ + } + + // LoadFactor=nil 但 Concurrency 相等,应该接近 50:50。 + require.InDelta(t, 0.5, float64(hits[201])/1000.0, 0.07) + require.InDelta(t, 0.5, float64(hits[202])/1000.0, 0.07) +} From aa21fe136eb39e5ebe9b888111e7ae0d0005ffd9 Mon Sep 17 00:00:00 2001 From: yingcheng1026 Date: Thu, 30 Apr 2026 15:55:48 +0800 Subject: [PATCH 002/153] build(docker): bump Node heap to 3GB for low-memory build hosts vite build OOMs at default ~960MB heap when running on a 2GB VPS even with 4GB swap added (cgroup heap limits don't extend to host swap for the buildkit user namespace). Setting NODE_OPTIONS=--max-old-space-size =3072 lets the frontend build succeed on the relay box without changing where we run the build. Co-Authored-By: Claude Opus 4.7 --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 890bda0bfa8..521aff8efd1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,8 @@ RUN pnpm install --frozen-lockfile # Copy frontend source and build COPY frontend/ ./ -RUN pnpm run build +# 在低内存环境(≤2GB)显式提高 Node 堆限制,避免 vite build 的 OOM。 +RUN NODE_OPTIONS="--max-old-space-size=3072" pnpm run build # ----------------------------------------------------------------------------- # Stage 2: Backend Builder From b2bcd1e1097db6253b4639c50b52cc8b901812dd Mon Sep 17 00:00:00 2001 From: yingcheng1026 Date: Thu, 30 Apr 2026 21:46:07 +0800 Subject: [PATCH 003/153] feat(scheduler): respect LoadFactor in selectBestAccount path too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigation on production showed that /v1/chat/completions and the common OpenAI gateway path go through selectAccountForModelWithExclusions -> selectBestAccount, NOT selectByLoadBalance. The previous patch only fixed the load-balance path; the priority+LRU path still ignored load_factor entirely, so once all accounts shared the same priority an N-account pool ended up distributing traffic by LRU instead of by capacity. Real-world test on a 1×Pro20x + 8×Plus group showed Pro20x receiving 9.8% of traffic instead of the expected ~65%. This commit replaces the inner LRU tiebreaker in selectBestAccount with a weighted reservoir sampling step keyed on account.EffectiveLoadFactor(). Priority remains a hard tier (lower number always wins); compact-tier ordering remains untouched. Within the same (priority, compactTier) tier, each candidate is admitted with probability weight/runningTotal, so the final winner is sampled proportional to LoadFactor. Co-Authored-By: Claude Opus 4.7 --- .../service/openai_gateway_service.go | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index ed69730c23f..8be202c9a8a 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -1430,6 +1430,9 @@ func (s *OpenAIGatewayService) tryStickySessionHit(ctx context.Context, groupID func (s *OpenAIGatewayService) selectBestAccount(ctx context.Context, groupID *int64, accounts []Account, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool) (*Account, bool) { var selected *Account selectedCompactTier := -1 + // tierWeightSum 是当前 tier(同 priority 同 compactTier)内累计的 LoadFactor 之和, + // 用于加权 reservoir sampling:进入新候选时按 weight/tierWeightSum 概率替换 selected。 + var tierWeightSum float64 compactBlocked := false needsUpstreamCheck := s.needsUpstreamChannelRestrictionCheck(ctx, groupID) @@ -1462,24 +1465,46 @@ func (s *OpenAIGatewayService) selectBestAccount(ctx context.Context, groupID *i } } - // 选择优先级最高且最久未使用的账号 - // Select highest priority and least recently used + freshWeight := float64(fresh.EffectiveLoadFactor()) + if freshWeight <= 0 { + freshWeight = 1 + } + if selected == nil { selected = fresh selectedCompactTier = compactTier + tierWeightSum = freshWeight continue } - // compact 模式下高 tier 优先;同 tier 内才比较 priority/LRU。 + // compact 模式下高 tier 严格优先;同 tier 内才比较 priority。 if requireCompact && compactTier != selectedCompactTier { if compactTier > selectedCompactTier { selected = fresh selectedCompactTier = compactTier + tierWeightSum = freshWeight } continue } - if s.isBetterAccount(fresh, selected) { + // priority 严格分层:数值更小的 priority 永远胜出。 + // Priority is a hard tier: lower number always wins. + if fresh.Priority < selected.Priority { + selected = fresh + selectedCompactTier = compactTier + tierWeightSum = freshWeight + continue + } + if fresh.Priority > selected.Priority { + continue + } + + // 同 priority + 同 compactTier:按 LoadFactor 做加权 reservoir sampling。 + // Pro 20x 类大号 LoadFactor=20 会按 20:1 的概率长期赢过 LoadFactor=1 的 Plus。 + // Within the same priority/compact tier, perform weighted reservoir sampling + // by EffectiveLoadFactor so that traffic distributes proportionally to capacity. + tierWeightSum += freshWeight + if rand.Float64()*tierWeightSum < freshWeight { selected = fresh selectedCompactTier = compactTier } From a9af824f505629f09a8bf3702a505fe84b245ce0 Mon Sep 17 00:00:00 2001 From: Ethan Wang <3242822589@qq.COM> Date: Thu, 30 Apr 2026 22:22:19 +0800 Subject: [PATCH 004/153] feat(scheduler): weight Layer-2 LoadAwareness selection by LoadFactor This is the actual production path when openai_advanced_scheduler_enabled=false (the default). Previous patches on selectByLoadBalance (advanced scheduler) and selectBestAccount (legacy fallback) never ran for OpenAI ChatCompletions traffic in production, which is why the 15:1 load_factor ratio for chong pro20x kept showing up as ~10-22% uniform-ish distribution. Selection flow that runs in prod: ChatCompletions handler -> SelectAccountWithScheduler -> selectAccountWithScheduler (advanced=disabled, scheduler==nil) -> selectAccountWithLoadAwareness Layer 2 (load-aware, LoadBatchEnabled=true) -> shuffleWithinSortGroups <-- selection randomness lives here -> for _, item := range selectionOrder { try acquire } Patch: - Add weightedShuffleByLoadFactorWithinSortGroups (OpenAI-only helper). - Group only by (Priority, LoadRate); within each group, weighted Fisher-Yates by EffectiveLoadFactor so position 0 (the position that gets tried first) is picked with probability w_i / Sum(w_j). - When all weights in a segment are equal, fall back to shuffleWithinSortGroups to preserve LRU/never-used semantics for legacy uniform-LoadFactor pools. - Revert selectBestAccount to strict priority+LRU; that path is shared with Gemini/Claude flows and the LoadFactor weighting was breaking their tests while not running on the prod OpenAI path anyway. Tests: - TestWeightedShuffleByLoadFactor_RatioMatchesLoadFactor: 1xPro(LF=15) + 9xPlus(LF=1), 5000 iterations, Pro position-0 ratio within 5% of 15/24, each Plus within 3% of 1/24. - TestWeightedShuffleByLoadFactor_PreservesPriorityGrouping: Priority=1 accounts strictly precede Priority=2 across 1000 iterations. - TestWeightedShuffleByLoadFactor_LoadFactorNilFallsBack: nil LoadFactor with equal Concurrency yields ~50:50. - Existing TestOpenAISelectAccountWithLoadAwareness_PreferNeverUsed and TestOpenAISelectAccountForModelWithExclusions_LeastRecentlyUsed continue to pass (LRU preserved when LoadFactor uniform). --- .../service/openai_gateway_service.go | 127 +++++++++++++----- .../service/openai_gateway_service_test.go | 103 ++++++++++++++ 2 files changed, 198 insertions(+), 32 deletions(-) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 8be202c9a8a..d892056ccf7 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -1430,9 +1430,6 @@ func (s *OpenAIGatewayService) tryStickySessionHit(ctx context.Context, groupID func (s *OpenAIGatewayService) selectBestAccount(ctx context.Context, groupID *int64, accounts []Account, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool) (*Account, bool) { var selected *Account selectedCompactTier := -1 - // tierWeightSum 是当前 tier(同 priority 同 compactTier)内累计的 LoadFactor 之和, - // 用于加权 reservoir sampling:进入新候选时按 weight/tierWeightSum 概率替换 selected。 - var tierWeightSum float64 compactBlocked := false needsUpstreamCheck := s.needsUpstreamChannelRestrictionCheck(ctx, groupID) @@ -1465,46 +1462,19 @@ func (s *OpenAIGatewayService) selectBestAccount(ctx context.Context, groupID *i } } - freshWeight := float64(fresh.EffectiveLoadFactor()) - if freshWeight <= 0 { - freshWeight = 1 - } - if selected == nil { selected = fresh selectedCompactTier = compactTier - tierWeightSum = freshWeight continue } - - // compact 模式下高 tier 严格优先;同 tier 内才比较 priority。 if requireCompact && compactTier != selectedCompactTier { if compactTier > selectedCompactTier { selected = fresh selectedCompactTier = compactTier - tierWeightSum = freshWeight } continue } - - // priority 严格分层:数值更小的 priority 永远胜出。 - // Priority is a hard tier: lower number always wins. - if fresh.Priority < selected.Priority { - selected = fresh - selectedCompactTier = compactTier - tierWeightSum = freshWeight - continue - } - if fresh.Priority > selected.Priority { - continue - } - - // 同 priority + 同 compactTier:按 LoadFactor 做加权 reservoir sampling。 - // Pro 20x 类大号 LoadFactor=20 会按 20:1 的概率长期赢过 LoadFactor=1 的 Plus。 - // Within the same priority/compact tier, perform weighted reservoir sampling - // by EffectiveLoadFactor so that traffic distributes proportionally to capacity. - tierWeightSum += freshWeight - if rand.Float64()*tierWeightSum < freshWeight { + if s.isBetterAccount(fresh, selected) { selected = fresh selectedCompactTier = compactTier } @@ -1546,6 +1516,96 @@ func (s *OpenAIGatewayService) isBetterAccount(candidate, current *Account) bool } } +// weightedShuffleByLoadFactorWithinSortGroups 对已经按 (Priority, LoadRate) 排过序的 +// accountWithLoad 切片进行原地加权洗牌: +// - 分组键只用 (Priority, LoadRate),不再绑定 LastUsedAt——这样 load_factor 加权 +// 可以盖掉 LRU 平均化效应,让高 capacity 的账号按比例占据更多前排位置; +// - 每组内做加权 Fisher–Yates,权重取 account.EffectiveLoadFactor()。 +// +// 选第一位的概率 ≈ w_i / Σw_j(与号池其它账号一致),用于多账号场景下的「按容量分流」。 +// 该函数仅在 OpenAI 选号路径中调用,不影响其它平台的 shuffleWithinSortGroups 行为。 +func weightedShuffleByLoadFactorWithinSortGroups(accounts []accountWithLoad) { + if len(accounts) <= 1 { + return + } + i := 0 + for i < len(accounts) { + j := i + 1 + for j < len(accounts) && samePriorityAndLoadRateGroup(accounts[i], accounts[j]) { + j++ + } + if j-i > 1 { + weightedShuffleSegmentByLoadFactor(accounts[i:j]) + } + i = j + } +} + +func samePriorityAndLoadRateGroup(a, b accountWithLoad) bool { + if a.account.Priority != b.account.Priority { + return false + } + return a.loadInfo.LoadRate == b.loadInfo.LoadRate +} + +// weightedShuffleSegmentByLoadFactor 在 seg 上做加权 Fisher–Yates: +// 每轮从 seg[i:] 中按 EffectiveLoadFactor 抽 1 个,交换到位置 i。 +// 池规模通常 < 100,O(n^2) 完全够用。 +// +// 当所有账号的 EffectiveLoadFactor 都相等(典型的旧部署:全部 LoadFactor=nil 且 +// Concurrency 相同)时,回退到 shuffleWithinSortGroups 的旧逻辑——按 LastUsedAt +// 子组打散,保留 LRU/never-used 优先的语义;仅在权重存在差异时才用加权抽样。 +func weightedShuffleSegmentByLoadFactor(seg []accountWithLoad) { + if !hasNonUniformLoadFactor(seg) { + shuffleWithinSortGroups(seg) + return + } + n := len(seg) + for i := 0; i < n-1; i++ { + var sumW float64 + for k := i; k < n; k++ { + w := float64(seg[k].account.EffectiveLoadFactor()) + if w <= 0 { + w = 1 + } + sumW += w + } + if sumW <= 0 { + return + } + r := rand.Float64() * sumW + var cum float64 + chosen := n - 1 + for k := i; k < n; k++ { + w := float64(seg[k].account.EffectiveLoadFactor()) + if w <= 0 { + w = 1 + } + cum += w + if r < cum { + chosen = k + break + } + } + if chosen != i { + seg[i], seg[chosen] = seg[chosen], seg[i] + } + } +} + +func hasNonUniformLoadFactor(seg []accountWithLoad) bool { + if len(seg) <= 1 { + return false + } + first := seg[0].account.EffectiveLoadFactor() + for k := 1; k < len(seg); k++ { + if seg[k].account.EffectiveLoadFactor() != first { + return true + } + } + return false +} + // SelectAccountWithLoadAwareness selects an account with load-awareness and wait plan. func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*AccountSelectionResult, error) { return s.selectAccountWithLoadAwareness(ctx, groupID, sessionHash, requestedModel, excludedIDs, false) @@ -1747,7 +1807,10 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex return a.account.LastUsedAt.Before(*b.account.LastUsedAt) } }) - shuffleWithinSortGroups(available) + // loadfactor-v3: OpenAI 路径下用按 EffectiveLoadFactor 加权的 Fisher–Yates, + // 让 Pro 20x(load_factor=15)按比例占 ≈15/(15+9) ≈62.5% 流量; + // 不再让 LastUsedAt(LRU)和 0% LoadRate 平均化把高容量账号拉回 1/N 份额。 + weightedShuffleByLoadFactorWithinSortGroups(available) selectionOrder := make([]accountWithLoad, 0, len(available)) if requireCompact { diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index b55f0d2ce8e..23a995f0285 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -2270,3 +2270,106 @@ func TestHandleSSEToJSON_ResponseFailedReturnsProtocolError(t *testing.T) { require.Contains(t, rec.Body.String(), "upstream rejected request") require.Contains(t, rec.Header().Get("Content-Type"), "application/json") } + +// TestWeightedShuffleByLoadFactor_RatioMatchesLoadFactor 验证当所有候选账号 +// (Priority, LoadRate) 相同时,weightedShuffleByLoadFactorWithinSortGroups 把 +// 按 EffectiveLoadFactor 加权的概率分配到位置 0:1 个 Pro 20x(LF=15)+ 9 个 +// Plus(LF=1),位置 0 落在 Pro 上的概率应 ≈ 15/24 ≈ 62.5%,每个 Plus ≈ 4.17%。 +// +// 这是 selectAccountWithLoadAwareness Layer 2 的实际选号入口,覆盖生产 +// `openai_advanced_scheduler_enabled=false` 时唯一的 OpenAI 选号路径。 +func TestWeightedShuffleByLoadFactor_RatioMatchesLoadFactor(t *testing.T) { + const ( + proID = int64(12) + proLoadFactor = 15 + plusCount = 9 + iterations = 5000 + ) + + build := func() []accountWithLoad { + out := []accountWithLoad{ + { + account: &Account{ + ID: proID, + Priority: 1, + LoadFactor: intPtrForTest(proLoadFactor), + }, + loadInfo: &AccountLoadInfo{LoadRate: 0}, + }, + } + for i := 0; i < plusCount; i++ { + out = append(out, accountWithLoad{ + account: &Account{ + ID: int64(100 + i), + Priority: 1, + LoadFactor: intPtrForTest(1), + }, + loadInfo: &AccountLoadInfo{LoadRate: 0}, + }) + } + return out + } + + hits := make(map[int64]int) + for i := 0; i < iterations; i++ { + pool := build() + weightedShuffleByLoadFactorWithinSortGroups(pool) + hits[pool[0].account.ID]++ + } + + totalWeight := float64(proLoadFactor + plusCount) + expectedProRatio := float64(proLoadFactor) / totalWeight + expectedPlusRatio := 1.0 / totalWeight + + proRatio := float64(hits[proID]) / float64(iterations) + require.InDelta(t, expectedProRatio, proRatio, 0.05, + "Pro 20x LoadFactor=%d 实际占比 %.2f%% 与期望 %.2f%% 偏差超过 5%%", + proLoadFactor, proRatio*100, expectedProRatio*100) + + for i := 0; i < plusCount; i++ { + plusID := int64(100 + i) + plusRatio := float64(hits[plusID]) / float64(iterations) + require.InDelta(t, expectedPlusRatio, plusRatio, 0.03, + "Plus 号 id=%d 实际占比 %.2f%% 与期望 %.2f%% 偏差超过 3%%", + plusID, plusRatio*100, expectedPlusRatio*100) + } +} + +// TestWeightedShuffleByLoadFactor_PreservesPriorityGrouping 验证不同 Priority +// 的账号即使在同一切片里也会被严格分组:高 Priority(数值小)的账号永远 +// 排在低 Priority 前面,加权抽样只在同 Priority+LoadRate 的子组内生效。 +func TestWeightedShuffleByLoadFactor_PreservesPriorityGrouping(t *testing.T) { + const iterations = 1000 + for i := 0; i < iterations; i++ { + pool := []accountWithLoad{ + {account: &Account{ID: 1, Priority: 1, LoadFactor: intPtrForTest(15)}, loadInfo: &AccountLoadInfo{LoadRate: 0}}, + {account: &Account{ID: 2, Priority: 1, LoadFactor: intPtrForTest(1)}, loadInfo: &AccountLoadInfo{LoadRate: 0}}, + {account: &Account{ID: 3, Priority: 2, LoadFactor: intPtrForTest(100)}, loadInfo: &AccountLoadInfo{LoadRate: 0}}, + } + weightedShuffleByLoadFactorWithinSortGroups(pool) + require.Contains(t, []int64{1, 2}, pool[0].account.ID, + "Priority=1 的账号必须排在 Priority=2 之前") + require.Contains(t, []int64{1, 2}, pool[1].account.ID, + "Priority=1 的账号必须排在 Priority=2 之前") + require.Equal(t, int64(3), pool[2].account.ID, + "Priority=2 的账号必须排在最后") + } +} + +// TestWeightedShuffleByLoadFactor_LoadFactorNilFallsBack 验证 LoadFactor=nil +// 的旧账号会 fallback 到 Concurrency(再不够 fallback 到 1),不会被当作 +// 权重 0 而抽不到。 +func TestWeightedShuffleByLoadFactor_LoadFactorNilFallsBack(t *testing.T) { + const iterations = 2000 + hits := make(map[int64]int) + for i := 0; i < iterations; i++ { + pool := []accountWithLoad{ + {account: &Account{ID: 201, Priority: 1, LoadFactor: nil, Concurrency: 5}, loadInfo: &AccountLoadInfo{LoadRate: 0}}, + {account: &Account{ID: 202, Priority: 1, LoadFactor: nil, Concurrency: 5}, loadInfo: &AccountLoadInfo{LoadRate: 0}}, + } + weightedShuffleByLoadFactorWithinSortGroups(pool) + hits[pool[0].account.ID]++ + } + require.InDelta(t, 0.5, float64(hits[201])/float64(iterations), 0.05) + require.InDelta(t, 0.5, float64(hits[202])/float64(iterations), 0.05) +} From d83a94c0b9d4fb40fc3c559ebeec61730d89318f Mon Sep 17 00:00:00 2001 From: Ethan Wang <3242822589@qq.COM> Date: Fri, 1 May 2026 11:29:52 +0800 Subject: [PATCH 005/153] fix(scheduler): respect StickySessionTTLSeconds in load-aware set/refresh paths Production observation on loadfactor-v3: with sticky_session_ttl_seconds defaulting to 1h and only one Plus account (Mark4) actively binding sessions, weighted load distribution within the Plus tier collapses to a single account. Over a 12h window, chong pro20x correctly received 63.5% (target 65-75%) but the remaining traffic landed entirely on Mark4 (46/46) while 4 other healthy Plus accounts received 0 chat completions despite being schedulable. Root cause: SelectAccountWithScheduler -> selectAccountWithLoadAwareness writes new sticky bindings via setStickySessionAccountID(..., openaiStickySessionTTL) and refreshes existing ones via refreshStickySessionTTL(..., openaiStickySessionTTL), both of which hardcode the package-level constant time.Hour. The cfg field Gateway.OpenAIWS.StickySessionTTLSeconds is exposed and validated, but the load- aware path never reads it - only BindStickySession (used by Gemini handler) does. Fix: - Replace 5 hardcoded openaiStickySessionTTL call sites in the OpenAI load-aware set/refresh paths with s.openAIWSSessionStickyTTL(), which already implements cfg-aware lookup with the constant as a sane fallback. - Sites changed: - openai_gateway_service.go:1360 selectAccountForModelWithExclusions write - openai_gateway_service.go:1419 tryStickySessionHit refresh - openai_gateway_service.go:1693 selectAccountWithLoadAwareness sticky-hit refresh - openai_gateway_service.go:1770 selectAccountWithLoadAwareness Layer-2 write - openai_gateway_service.go:1849 selectAccountWithLoadAwareness Layer-3 write - BindStickySession (used by Gemini) was already cfg-aware, untouched. Tests: - TestOpenAIWSSessionStickyTTL_ConfigOverridesDefault: cfg=600 -> ttl=10min - TestOpenAIWSSessionStickyTTL_DefaultWhenUnset: cfg=0 -> ttl=1h - All 14 existing Sticky* tests continue to pass. Operational impact: - Default behavior unchanged (still 1h when env unset). - Operators can now set GATEWAY_OPENAI_WS_STICKY_SESSION_TTL_SECONDS=600 to shorten sticky binding to 10min, allowing weighted load balancing within same-priority tier to spread traffic across all healthy accounts. Co-Authored-By: Claude Opus 4.7 --- .../service/openai_gateway_service.go | 10 +++---- .../service/openai_sticky_compat_test.go | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index d892056ccf7..e6669259a61 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -1357,7 +1357,7 @@ func (s *OpenAIGatewayService) selectAccountForModelWithExclusions(ctx context.C // 4. 设置粘性会话绑定 // Set sticky session binding if sessionHash != "" { - _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, selected.ID, openaiStickySessionTTL) + _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, selected.ID, s.openAIWSSessionStickyTTL()) } return s.hydrateSelectedAccount(ctx, selected) @@ -1416,7 +1416,7 @@ func (s *OpenAIGatewayService) tryStickySessionHit(ctx context.Context, groupID // 刷新会话 TTL 并返回账号 // Refresh session TTL and return account - _ = s.refreshStickySessionTTL(ctx, groupID, sessionHash, openaiStickySessionTTL) + _ = s.refreshStickySessionTTL(ctx, groupID, sessionHash, s.openAIWSSessionStickyTTL()) return account } @@ -1690,7 +1690,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex } else { result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency) if err == nil && result.Acquired { - _ = s.refreshStickySessionTTL(ctx, groupID, sessionHash, openaiStickySessionTTL) + _ = s.refreshStickySessionTTL(ctx, groupID, sessionHash, s.openAIWSSessionStickyTTL()) return s.newSelectionResult(ctx, account, true, result.ReleaseFunc, nil) } @@ -1767,7 +1767,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex result, err := s.tryAcquireAccountSlot(ctx, fresh.ID, fresh.Concurrency) if err == nil && result.Acquired { if sessionHash != "" { - _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, fresh.ID, openaiStickySessionTTL) + _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, fresh.ID, s.openAIWSSessionStickyTTL()) } return s.newSelectionResult(ctx, fresh, true, result.ReleaseFunc, nil) } @@ -1846,7 +1846,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex result, err := s.tryAcquireAccountSlot(ctx, fresh.ID, fresh.Concurrency) if err == nil && result.Acquired { if sessionHash != "" { - _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, fresh.ID, openaiStickySessionTTL) + _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, fresh.ID, s.openAIWSSessionStickyTTL()) } return s.newSelectionResult(ctx, fresh, true, result.ReleaseFunc, nil) } diff --git a/backend/internal/service/openai_sticky_compat_test.go b/backend/internal/service/openai_sticky_compat_test.go index 9f57c35808a..b8891a121ff 100644 --- a/backend/internal/service/openai_sticky_compat_test.go +++ b/backend/internal/service/openai_sticky_compat_test.go @@ -3,12 +3,39 @@ package service import ( "context" "testing" + "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/stretchr/testify/require" ) +// 验证 openAIWSSessionStickyTTL 在 cfg 设置后真生效(修复前 set/refresh 流程 +// hardcode openaiStickySessionTTL=1h,cfg 字段被静默忽略)。 +func TestOpenAIWSSessionStickyTTL_ConfigOverridesDefault(t *testing.T) { + svc := &OpenAIGatewayService{ + cfg: &config.Config{ + Gateway: config.GatewayConfig{ + OpenAIWS: config.GatewayOpenAIWSConfig{ + StickySessionTTLSeconds: 600, + }, + }, + }, + } + require.Equal(t, 600*time.Second, svc.openAIWSSessionStickyTTL()) +} + +func TestOpenAIWSSessionStickyTTL_DefaultWhenUnset(t *testing.T) { + svc := &OpenAIGatewayService{ + cfg: &config.Config{ + Gateway: config.GatewayConfig{ + OpenAIWS: config.GatewayOpenAIWSConfig{}, + }, + }, + } + require.Equal(t, openaiStickySessionTTL, svc.openAIWSSessionStickyTTL()) +} + func TestGetStickySessionAccountID_FallbackToLegacyKey(t *testing.T) { beforeFallbackTotal, beforeFallbackHit, _ := openAIStickyCompatStats() From da441ee2a31da8f742f3758b535e9062510951a6 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 2 May 2026 19:53:42 +0800 Subject: [PATCH 006/153] fix usage model display defaults --- .../handler/admin/dashboard_handler.go | 2 +- .../dashboard_handler_request_type_test.go | 40 +++++++++++++++++++ .../admin/dashboard_snapshot_v2_handler.go | 2 +- backend/internal/handler/dto/mappers.go | 19 ++++++--- .../handler/dto/mappers_usage_test.go | 25 ++++++------ backend/internal/repository/usage_log_repo.go | 23 ++++++----- .../usage_log_repo_breakdown_test.go | 6 +-- backend/internal/service/dashboard_service.go | 7 ++-- 8 files changed, 89 insertions(+), 35 deletions(-) diff --git a/backend/internal/handler/admin/dashboard_handler.go b/backend/internal/handler/admin/dashboard_handler.go index 460f63578ef..c10477cc27e 100644 --- a/backend/internal/handler/admin/dashboard_handler.go +++ b/backend/internal/handler/admin/dashboard_handler.go @@ -273,7 +273,7 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) { // Parse optional filter params var userID, apiKeyID, accountID, groupID int64 - modelSource := usagestats.ModelSourceRequested + modelSource := usagestats.ModelSourceUpstream var requestType *int16 var stream *bool var billingType *int8 diff --git a/backend/internal/handler/admin/dashboard_handler_request_type_test.go b/backend/internal/handler/admin/dashboard_handler_request_type_test.go index 6056f725b5b..a747c79140e 100644 --- a/backend/internal/handler/admin/dashboard_handler_request_type_test.go +++ b/backend/internal/handler/admin/dashboard_handler_request_type_test.go @@ -19,6 +19,7 @@ type dashboardUsageRepoCapture struct { trendStream *bool modelRequestType *int16 modelStream *bool + modelSource string rankingLimit int ranking []usagestats.UserSpendingRankingItem rankingTotal float64 @@ -52,6 +53,21 @@ func (s *dashboardUsageRepoCapture) GetModelStatsWithFilters( return []usagestats.ModelStat{}, nil } +func (s *dashboardUsageRepoCapture) GetModelStatsWithFiltersBySource( + ctx context.Context, + startTime, endTime time.Time, + userID, apiKeyID, accountID, groupID int64, + requestType *int16, + stream *bool, + billingType *int8, + source string, +) ([]usagestats.ModelStat, error) { + s.modelRequestType = requestType + s.modelStream = stream + s.modelSource = source + return []usagestats.ModelStat{}, nil +} + func (s *dashboardUsageRepoCapture) GetUserSpendingRanking( ctx context.Context, startTime, endTime time.Time, @@ -127,6 +143,30 @@ func TestDashboardModelStatsRequestTypePriority(t *testing.T) { require.Nil(t, repo.modelStream) } +func TestDashboardModelStatsDefaultsToUpstreamSource(t *testing.T) { + repo := &dashboardUsageRepoCapture{} + router := newDashboardRequestTypeTestRouter(repo) + + req := httptest.NewRequest(http.MethodGet, "/admin/dashboard/models", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, usagestats.ModelSourceUpstream, repo.modelSource) +} + +func TestDashboardModelStatsRequestedSourcePassesThrough(t *testing.T) { + repo := &dashboardUsageRepoCapture{} + router := newDashboardRequestTypeTestRouter(repo) + + req := httptest.NewRequest(http.MethodGet, "/admin/dashboard/models?model_source=requested", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, usagestats.ModelSourceRequested, repo.modelSource) +} + func TestDashboardModelStatsInvalidRequestType(t *testing.T) { repo := &dashboardUsageRepoCapture{} router := newDashboardRequestTypeTestRouter(repo) diff --git a/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go b/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go index 517ae7bd14b..51d28250be4 100644 --- a/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go +++ b/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go @@ -200,7 +200,7 @@ func (h *DashboardHandler) buildSnapshotV2Response( filters.APIKeyID, filters.AccountID, filters.GroupID, - usagestats.ModelSourceRequested, + usagestats.ModelSourceUpstream, filters.RequestType, filters.Stream, filters.BillingType, diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index f7503c2ea15..e18d71026c5 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -3,6 +3,7 @@ package dto import ( "strconv" + "strings" "time" "github.com/Wei-Shaw/sub2api/internal/service" @@ -559,17 +560,13 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog { // 普通用户 DTO:严禁包含管理员字段(例如 account_rate_multiplier、ip_address、account)。 requestType := l.EffectiveRequestType() stream, openAIWSMode := service.ApplyLegacyRequestFields(requestType, l.Stream, l.OpenAIWSMode) - requestedModel := l.RequestedModel - if requestedModel == "" { - requestedModel = l.Model - } return UsageLog{ ID: l.ID, UserID: l.UserID, APIKeyID: l.APIKeyID, AccountID: l.AccountID, RequestID: l.RequestID, - Model: requestedModel, + Model: visibleUsageLogModel(l), ServiceTier: l.ServiceTier, ReasoningEffort: l.ReasoningEffort, InboundEndpoint: l.InboundEndpoint, @@ -609,6 +606,18 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog { } } +func visibleUsageLogModel(l *service.UsageLog) string { + if l.UpstreamModel != nil { + if upstream := strings.TrimSpace(*l.UpstreamModel); upstream != "" { + return upstream + } + } + if model := strings.TrimSpace(l.Model); model != "" { + return model + } + return strings.TrimSpace(l.RequestedModel) +} + // UsageLogFromService converts a service UsageLog to DTO for regular users. // It excludes Account details and IP address - users should not see these. func UsageLogFromService(l *service.UsageLog) *UsageLog { diff --git a/backend/internal/handler/dto/mappers_usage_test.go b/backend/internal/handler/dto/mappers_usage_test.go index c2635e339a5..211279beaaf 100644 --- a/backend/internal/handler/dto/mappers_usage_test.go +++ b/backend/internal/handler/dto/mappers_usage_test.go @@ -107,22 +107,22 @@ func TestUsageLogFromService_IncludesServiceTierForUserAndAdmin(t *testing.T) { require.InDelta(t, 1.5, *adminDTO.AccountRateMultiplier, 1e-12) } -func TestUsageLogFromService_UsesRequestedModelAndKeepsUpstreamAdminOnly(t *testing.T) { +func TestUsageLogFromService_UsesUpstreamModelAndKeepsUpstreamAdminOnly(t *testing.T) { t.Parallel() - upstreamModel := "claude-sonnet-4-20250514" + upstreamModel := "gpt-5.4" log := &service.UsageLog{ RequestID: "req_4", - Model: upstreamModel, - RequestedModel: "claude-sonnet-4", + Model: "claude-sonnet-4-6", + RequestedModel: "claude-sonnet-4-6", UpstreamModel: &upstreamModel, } userDTO := UsageLogFromService(log) adminDTO := UsageLogFromServiceAdmin(log) - require.Equal(t, "claude-sonnet-4", userDTO.Model) - require.Equal(t, "claude-sonnet-4", adminDTO.Model) + require.Equal(t, "gpt-5.4", userDTO.Model) + require.Equal(t, "gpt-5.4", adminDTO.Model) userJSON, err := json.Marshal(userDTO) require.NoError(t, err) @@ -130,22 +130,23 @@ func TestUsageLogFromService_UsesRequestedModelAndKeepsUpstreamAdminOnly(t *test adminJSON, err := json.Marshal(adminDTO) require.NoError(t, err) - require.Contains(t, string(adminJSON), `"upstream_model":"claude-sonnet-4-20250514"`) + require.Contains(t, string(adminJSON), `"upstream_model":"gpt-5.4"`) } -func TestUsageLogFromService_FallsBackToLegacyModelWhenRequestedModelMissing(t *testing.T) { +func TestUsageLogFromService_FallsBackToStoredModelWhenUpstreamMissing(t *testing.T) { t.Parallel() log := &service.UsageLog{ - RequestID: "req_legacy", - Model: "claude-3", + RequestID: "req_model_fallback", + Model: "gpt-5.4", + RequestedModel: "claude-sonnet-4-6", } userDTO := UsageLogFromService(log) adminDTO := UsageLogFromServiceAdmin(log) - require.Equal(t, "claude-3", userDTO.Model) - require.Equal(t, "claude-3", adminDTO.Model) + require.Equal(t, "gpt-5.4", userDTO.Model) + require.Equal(t, "gpt-5.4", adminDTO.Model) } func f64Ptr(value float64) *float64 { diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index f2fb87da33e..4bf973f47ab 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -87,10 +87,11 @@ var usageLogInsertArgTypes = [...]string{ } const rawUsageLogModelColumn = "model" +const visibleUsageLogModelExpr = "COALESCE(NULLIF(TRIM(upstream_model), ''), model)" // rawUsageLogModelColumn preserves the exact stored usage_logs.model semantics for direct filters. // Historical rows may contain upstream/billing model values, while newer rows store requested_model. -// Requested/upstream/mapping analytics must use resolveModelDimensionExpression instead. +// Customer-visible model analytics must use visibleUsageLogModelExpr or resolveModelDimensionExpression instead. // dateFormatWhitelist 将 granularity 参数映射为 PostgreSQL TO_CHAR 格式字符串,防止外部输入直接拼入 SQL var dateFormatWhitelist = map[string]string{ @@ -2594,9 +2595,9 @@ func (r *usageLogRepository) GetUserUsageTrendByUserID(ctx context.Context, user // GetUserModelStats 获取指定用户的模型统计 func (r *usageLogRepository) GetUserModelStats(ctx context.Context, userID int64, startTime, endTime time.Time) (results []ModelStat, err error) { - query := ` + query := fmt.Sprintf(` SELECT - model, + %s as model, COUNT(*) as requests, COALESCE(SUM(input_tokens), 0) as input_tokens, COALESCE(SUM(output_tokens), 0) as output_tokens, @@ -2608,9 +2609,9 @@ func (r *usageLogRepository) GetUserModelStats(ctx context.Context, userID int64 COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as account_cost FROM usage_logs WHERE user_id = $1 AND created_at >= $2 AND created_at < $3 - GROUP BY model + GROUP BY %s ORDER BY total_tokens DESC - ` + `, visibleUsageLogModelExpr, visibleUsageLogModelExpr) rows, err := r.sql.QueryContext(ctx, query, userID, startTime, endTime) if err != nil { @@ -2997,7 +2998,7 @@ func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, st // GetModelStatsWithFilters returns model statistics with optional filters func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, usagestats.ModelSourceRequested) + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, usagestats.ModelSourceUpstream) } // GetModelStatsWithFiltersBySource returns model statistics with optional filters and model source dimension. @@ -3279,13 +3280,15 @@ func (r *usageLogRepository) GetAllGroupUsageSummary(ctx context.Context, todayS // resolveModelDimensionExpression maps model source type to a safe SQL expression. func resolveModelDimensionExpression(modelType string) string { requestedExpr := "COALESCE(NULLIF(TRIM(requested_model), ''), model)" - switch usagestats.NormalizeModelSource(modelType) { + switch strings.ToLower(strings.TrimSpace(modelType)) { + case usagestats.ModelSourceRequested: + return requestedExpr case usagestats.ModelSourceUpstream: - return fmt.Sprintf("COALESCE(NULLIF(TRIM(upstream_model), ''), %s)", requestedExpr) + return visibleUsageLogModelExpr case usagestats.ModelSourceMapping: return fmt.Sprintf("(%s || ' -> ' || COALESCE(NULLIF(TRIM(upstream_model), ''), %s))", requestedExpr, requestedExpr) default: - return requestedExpr + return visibleUsageLogModelExpr } } @@ -3839,7 +3842,7 @@ func usageLogOrderBy(params pagination.PaginationParams) string { var column string switch sortBy { case "model": - column = "COALESCE(NULLIF(TRIM(requested_model), ''), model)" + column = visibleUsageLogModelExpr case "created_at": column = "created_at" default: diff --git a/backend/internal/repository/usage_log_repo_breakdown_test.go b/backend/internal/repository/usage_log_repo_breakdown_test.go index da62e8dd562..2a056a31400 100644 --- a/backend/internal/repository/usage_log_repo_breakdown_test.go +++ b/backend/internal/repository/usage_log_repo_breakdown_test.go @@ -35,10 +35,10 @@ func TestResolveModelDimensionExpression(t *testing.T) { want string }{ {usagestats.ModelSourceRequested, "COALESCE(NULLIF(TRIM(requested_model), ''), model)"}, - {usagestats.ModelSourceUpstream, "COALESCE(NULLIF(TRIM(upstream_model), ''), COALESCE(NULLIF(TRIM(requested_model), ''), model))"}, + {usagestats.ModelSourceUpstream, "COALESCE(NULLIF(TRIM(upstream_model), ''), model)"}, {usagestats.ModelSourceMapping, "(COALESCE(NULLIF(TRIM(requested_model), ''), model) || ' -> ' || COALESCE(NULLIF(TRIM(upstream_model), ''), COALESCE(NULLIF(TRIM(requested_model), ''), model)))"}, - {"", "COALESCE(NULLIF(TRIM(requested_model), ''), model)"}, - {"invalid", "COALESCE(NULLIF(TRIM(requested_model), ''), model)"}, + {"", "COALESCE(NULLIF(TRIM(upstream_model), ''), model)"}, + {"invalid", "COALESCE(NULLIF(TRIM(upstream_model), ''), model)"}, } for _, tc := range tests { diff --git a/backend/internal/service/dashboard_service.go b/backend/internal/service/dashboard_service.go index 3e059e30695..d6cda062c4e 100644 --- a/backend/internal/service/dashboard_service.go +++ b/backend/internal/service/dashboard_service.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "sync/atomic" "time" @@ -141,9 +142,9 @@ func (s *DashboardService) GetModelStatsWithFilters(ctx context.Context, startTi } func (s *DashboardService) GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, modelSource string) ([]usagestats.ModelStat, error) { - normalizedSource := usagestats.NormalizeModelSource(modelSource) - if normalizedSource == usagestats.ModelSourceRequested { - return s.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType) + normalizedSource := usagestats.ModelSourceUpstream + if rawSource := strings.TrimSpace(modelSource); rawSource != "" { + normalizedSource = usagestats.NormalizeModelSource(rawSource) } type modelStatsBySourceRepo interface { From 50f69113dc7ab03bd6c06f6a47b3f5ea54ed4c10 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 3 May 2026 15:39:03 +0800 Subject: [PATCH 007/153] feat: guard openai accounts near weekly quota --- backend/internal/service/account.go | 3 + .../internal/service/account_usage_service.go | 6 +- .../service/openai_gateway_service.go | 6 +- .../service/openai_gateway_service_test.go | 4 + .../internal/service/openai_quota_guard.go | 120 ++++++++++++++ .../service/openai_quota_guard_test.go | 150 ++++++++++++++++++ backend/internal/service/ratelimit_service.go | 2 + 7 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 backend/internal/service/openai_quota_guard.go create mode 100644 backend/internal/service/openai_quota_guard_test.go diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index cd06ffa3c49..37c391b77ae 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -121,6 +121,9 @@ func (a *Account) IsSchedulable() bool { if a.TempUnschedulableUntil != nil && now.Before(*a.TempUnschedulableUntil) { return false } + if a.IsOpenAIQuotaGuardedAt(now) { + return false + } if a.IsAPIKeyOrBedrock() && a.IsQuotaExceeded() { return false } diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 68ba8f8ce98..6c466d028c1 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -668,7 +668,11 @@ func (s *AccountUsageService) persistOpenAICodexProbeSnapshot(accountID int64, u go func() { updateCtx, updateCancel := context.WithTimeout(context.Background(), 5*time.Second) defer updateCancel() - _ = s.accountRepo.UpdateExtra(updateCtx, accountID, updates) + if err := s.accountRepo.UpdateExtra(updateCtx, accountID, updates); err != nil { + slog.Warn("openai_codex_probe_snapshot_update_extra_failed", "account_id", accountID, "error", err) + return + } + applyOpenAIQuotaGuardFromUpdates(updateCtx, s.accountRepo, accountID, updates, time.Now()) }() } diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index e6669259a61..10448d2e205 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -5570,7 +5570,11 @@ func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, acc go func() { updateCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _ = s.accountRepo.UpdateExtra(updateCtx, accountID, updates) + if err := s.accountRepo.UpdateExtra(updateCtx, accountID, updates); err != nil { + slog.Warn("openai_codex_snapshot_update_extra_failed", "account_id", accountID, "error", err) + return + } + applyOpenAIQuotaGuardFromUpdates(updateCtx, s.accountRepo, accountID, updates, now) }() } diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 23a995f0285..aa41f79f757 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -46,6 +46,10 @@ func (r *snapshotUpdateAccountRepo) UpdateExtra(ctx context.Context, id int64, u return nil } +func (r stubOpenAIAccountRepo) SetTempUnschedulable(ctx context.Context, id int64, until time.Time, reason string) error { + return nil +} + func (r stubOpenAIAccountRepo) GetByID(ctx context.Context, id int64) (*Account, error) { for i := range r.accounts { if r.accounts[i].ID == id { diff --git a/backend/internal/service/openai_quota_guard.go b/backend/internal/service/openai_quota_guard.go new file mode 100644 index 00000000000..e922257930f --- /dev/null +++ b/backend/internal/service/openai_quota_guard.go @@ -0,0 +1,120 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" +) + +const ( + OpenAIQuotaGuardThresholdPercent = 95.0 + OpenAIQuotaGuardReasonPrefix = "hfc-quota-guard:" + OpenAIQuotaGuardReasonCodex7d = OpenAIQuotaGuardReasonPrefix + "openai-codex-7d>=95" + OpenAIQuotaGuardReasonNoReset = OpenAIQuotaGuardReasonCodex7d + ":no-reset-at" + OpenAIQuotaGuardNoResetCooldown = 6 * time.Hour +) + +type OpenAIQuotaGuardDecision struct { + Until time.Time + Reason string + UsedPercent float64 +} + +func (a *Account) OpenAIQuotaGuardDecision(now time.Time) *OpenAIQuotaGuardDecision { + if a == nil || a.Platform != PlatformOpenAI || a.Type != AccountTypeOAuth { + return nil + } + return openAIQuotaGuardDecisionFromExtra(a.Extra, now) +} + +func (a *Account) IsOpenAIQuotaGuardedAt(now time.Time) bool { + return a.OpenAIQuotaGuardDecision(now) != nil +} + +func openAIQuotaGuardDecisionFromExtra(extra map[string]any, now time.Time) *OpenAIQuotaGuardDecision { + if len(extra) == 0 { + return nil + } + usedPercent := parseExtraFloat64(extra["codex_7d_used_percent"]) + if usedPercent < OpenAIQuotaGuardThresholdPercent { + return nil + } + + if resetAt, ok := parseOpenAIQuotaGuardTime(extra["codex_7d_reset_at"]); ok { + if resetAt.After(now) { + return &OpenAIQuotaGuardDecision{ + Until: resetAt, + Reason: OpenAIQuotaGuardReasonCodex7d, + UsedPercent: usedPercent, + } + } + return nil + } + + base := now + if updatedAt, ok := parseOpenAIQuotaGuardTime(extra["codex_usage_updated_at"]); ok { + base = updatedAt + } + until := base.Add(OpenAIQuotaGuardNoResetCooldown) + if !until.After(now) { + return nil + } + return &OpenAIQuotaGuardDecision{ + Until: until, + Reason: OpenAIQuotaGuardReasonNoReset, + UsedPercent: usedPercent, + } +} + +func parseOpenAIQuotaGuardTime(value any) (time.Time, bool) { + switch v := value.(type) { + case time.Time: + if v.IsZero() { + return time.Time{}, false + } + return v, true + case *time.Time: + if v == nil || v.IsZero() { + return time.Time{}, false + } + return *v, true + } + + raw := strings.TrimSpace(fmt.Sprint(value)) + if raw == "" || raw == "" { + return time.Time{}, false + } + parsed, err := parseTime(raw) + if err != nil || parsed.IsZero() { + return time.Time{}, false + } + return parsed, true +} + +func applyOpenAIQuotaGuardFromUpdates(ctx context.Context, repo AccountRepository, accountID int64, updates map[string]any, now time.Time) { + if repo == nil || accountID <= 0 { + return + } + decision := openAIQuotaGuardDecisionFromExtra(updates, now) + if decision == nil { + return + } + if err := repo.SetTempUnschedulable(ctx, accountID, decision.Until, decision.Reason); err != nil { + slog.Warn("openai_quota_guard_set_temp_unschedulable_failed", + "account_id", accountID, + "used_percent", decision.UsedPercent, + "until", decision.Until, + "reason", decision.Reason, + "error", err, + ) + return + } + slog.Info("openai_quota_guard_temp_unschedulable_set", + "account_id", accountID, + "used_percent", decision.UsedPercent, + "until", decision.Until, + "reason", decision.Reason, + ) +} diff --git a/backend/internal/service/openai_quota_guard_test.go b/backend/internal/service/openai_quota_guard_test.go new file mode 100644 index 00000000000..da6e9e54d7d --- /dev/null +++ b/backend/internal/service/openai_quota_guard_test.go @@ -0,0 +1,150 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestOpenAIQuotaGuardDecisionFromExtra(t *testing.T) { + now := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC) + resetAt := now.Add(3 * time.Hour) + + tests := []struct { + name string + extra map[string]any + wantGuard bool + wantReason string + }{ + { + name: "below threshold", + extra: map[string]any{ + "codex_7d_used_percent": 94.9, + "codex_7d_reset_at": resetAt.Format(time.RFC3339), + }, + wantGuard: false, + }, + { + name: "threshold with future reset", + extra: map[string]any{ + "codex_7d_used_percent": 95.0, + "codex_7d_reset_at": resetAt.Format(time.RFC3339), + }, + wantGuard: true, + wantReason: OpenAIQuotaGuardReasonCodex7d, + }, + { + name: "above threshold with expired reset", + extra: map[string]any{ + "codex_7d_used_percent": 99.0, + "codex_7d_reset_at": now.Add(-1 * time.Minute).Format(time.RFC3339), + }, + wantGuard: false, + }, + { + name: "above threshold without reset uses short guard", + extra: map[string]any{ + "codex_7d_used_percent": 95.0, + "codex_usage_updated_at": now.Add(-1 * time.Hour). + Format(time.RFC3339), + }, + wantGuard: true, + wantReason: OpenAIQuotaGuardReasonNoReset, + }, + { + name: "stale no-reset guard expires", + extra: map[string]any{ + "codex_7d_used_percent": 95.0, + "codex_usage_updated_at": now.Add(-7 * time.Hour). + Format(time.RFC3339), + }, + wantGuard: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := openAIQuotaGuardDecisionFromExtra(tt.extra, now) + if !tt.wantGuard { + require.Nil(t, got) + return + } + require.NotNil(t, got) + require.Equal(t, tt.wantReason, got.Reason) + require.True(t, got.Until.After(now)) + }) + } +} + +func TestAccountIsSchedulable_OpenAIQuotaGuard(t *testing.T) { + resetAt := time.Now().Add(2 * time.Hour).UTC() + + guarded := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Extra: map[string]any{ + "codex_7d_used_percent": 95.0, + "codex_7d_reset_at": resetAt.Format(time.RFC3339), + }, + } + require.False(t, guarded.IsSchedulable()) + + apiKey := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Extra: map[string]any{ + "codex_7d_used_percent": 95.0, + "codex_7d_reset_at": resetAt.Format(time.RFC3339), + }, + } + require.True(t, apiKey.IsSchedulable()) +} + +type quotaGuardRepoStub struct { + AccountRepository + calls int + until time.Time + reason string +} + +func (r *quotaGuardRepoStub) SetTempUnschedulable(_ context.Context, _ int64, until time.Time, reason string) error { + r.calls++ + r.until = until + r.reason = reason + return nil +} + +func TestApplyOpenAIQuotaGuardFromUpdates(t *testing.T) { + now := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC) + resetAt := now.Add(4 * time.Hour) + repo := "aGuardRepoStub{} + + applyOpenAIQuotaGuardFromUpdates(context.Background(), repo, 123, map[string]any{ + "codex_7d_used_percent": 95.0, + "codex_7d_reset_at": resetAt.Format(time.RFC3339), + }, now) + + require.Equal(t, 1, repo.calls) + require.Equal(t, OpenAIQuotaGuardReasonCodex7d, repo.reason) + require.WithinDuration(t, resetAt, repo.until, time.Second) +} + +func TestApplyOpenAIQuotaGuardFromUpdatesBelowThreshold(t *testing.T) { + now := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC) + repo := "aGuardRepoStub{} + + applyOpenAIQuotaGuardFromUpdates(context.Background(), repo, 123, map[string]any{ + "codex_7d_used_percent": 94.9, + "codex_7d_reset_at": now.Add(4 * time.Hour).Format(time.RFC3339), + }, now) + + require.Zero(t, repo.calls) +} diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 9344de47d86..f3ba2eecc52 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -1101,7 +1101,9 @@ func (s *RateLimitService) persistOpenAICodexSnapshot(ctx context.Context, accou } if err := s.accountRepo.UpdateExtra(ctx, account.ID, updates); err != nil { slog.Warn("openai_codex_snapshot_persist_failed", "account_id", account.ID, "error", err) + return } + applyOpenAIQuotaGuardFromUpdates(ctx, s.accountRepo, account.ID, updates, time.Now()) } // parseOpenAIRateLimitResetTime 解析 OpenAI 格式的 429 响应,返回重置时间的 Unix 时间戳 From c47716ca643b816ebc5f80fb64b9e5247887b9c1 Mon Sep 17 00:00:00 2001 From: Ethan Wang Date: Sun, 3 May 2026 17:18:15 +0800 Subject: [PATCH 008/153] wip(prod-snapshot): page-vertical-scroll for usage table + claude dispatch billing source preserve Captured from /opt/relay/sub2api-fork worktree currently running as hfc/sub2api:agent-platform-adapters-cdf2e2e65593-20260503-155939 (built 15:59 CST 2026-05-03). Commiting so the prod source state is visible on GitHub for reconciliation with MarkDonish/sub2api:feat/loadfactor-weighting. Frontend (no overlap with Marks PR): - UsageTable + DataTable add pageVerticalScroll prop so the usage log viewport stops swallowing wheel events when content fits in the visible area. Backend (overlaps with Marks billing-source commits, needs reconciliation): - applyOpenAIMessagesDispatchBillingSource preserves BillingModelSourceRequested for Claude requests routed through the OpenAI Messages dispatch path. - usage_log_repo defaults the customer-facing model dimension to requested_model (requestedUsageLogModelExpr) so Claude product tiers do not collapse into upstream model labels. Adjusts GetUserModelStats / GetModelStatsWithFilters / resolveModelDimensionExpression default and the corresponding test cases. Co-Authored-By: Claude Opus 4.7 --- .../handler/openai_gateway_handler.go | 19 ++++- .../handler/openai_gateway_handler_test.go | 19 +++++ backend/internal/repository/usage_log_repo.go | 15 ++-- .../usage_log_repo_breakdown_test.go | 4 +- .../src/components/admin/usage/UsageTable.vue | 10 ++- .../admin/usage/__tests__/UsageTable.spec.ts | 25 +++++- frontend/src/components/common/DataTable.vue | 85 +++++++++++++++++-- 7 files changed, 155 insertions(+), 22 deletions(-) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 7676ffa32d5..71c0bb2dfe5 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -768,7 +768,10 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { clientIP := ip.GetClientIP(c) requestPayloadHash := service.HashUsageRequestPayload(body) + dispatchMappedModel := effectiveMappedModel h.submitUsageRecordTask(func(ctx context.Context) { + usageFields := channelMappingMsg.ToUsageFields(reqModel, result.UpstreamModel) + usageFields = applyOpenAIMessagesDispatchBillingSource(usageFields, reqModel, dispatchMappedModel) if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ Result: result, APIKey: apiKey, @@ -781,7 +784,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { IPAddress: clientIP, RequestPayloadHash: requestPayloadHash, APIKeyService: h.apiKeyService, - ChannelUsageFields: channelMappingMsg.ToUsageFields(reqModel, result.UpstreamModel), + ChannelUsageFields: usageFields, }); err != nil { logger.L().With( zap.String("component", "handler.openai_gateway.messages"), @@ -1363,6 +1366,20 @@ func (h *OpenAIGatewayHandler) recoverAnthropicMessagesPanic(c *gin.Context, str } } +func applyOpenAIMessagesDispatchBillingSource(fields service.ChannelUsageFields, reqModel, mappedModel string) service.ChannelUsageFields { + if strings.TrimSpace(fields.BillingModelSource) != "" { + return fields + } + if strings.TrimSpace(mappedModel) == "" { + return fields + } + if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(reqModel)), "claude") { + return fields + } + fields.BillingModelSource = service.BillingModelSourceRequested + return fields +} + func (h *OpenAIGatewayHandler) ensureResponsesDependencies(c *gin.Context, reqLog *zap.Logger) bool { missing := h.missingResponsesDependencies() if len(missing) == 0 { diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index 8ecee59ae79..752800e84bc 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -415,6 +415,25 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) { }) } +func TestApplyOpenAIMessagesDispatchBillingSource(t *testing.T) { + t.Run("claude_dispatch_bills_requested_model", func(t *testing.T) { + fields := applyOpenAIMessagesDispatchBillingSource(service.ChannelUsageFields{}, "claude-opus-4-7", "gpt-5.5") + require.Equal(t, service.BillingModelSourceRequested, fields.BillingModelSource) + }) + + t.Run("preserves_explicit_channel_billing_source", func(t *testing.T) { + fields := applyOpenAIMessagesDispatchBillingSource(service.ChannelUsageFields{ + BillingModelSource: service.BillingModelSourceChannelMapped, + }, "claude-opus-4-7", "gpt-5.5") + require.Equal(t, service.BillingModelSourceChannelMapped, fields.BillingModelSource) + }) + + t.Run("openai_native_model_keeps_default_billing", func(t *testing.T) { + fields := applyOpenAIMessagesDispatchBillingSource(service.ChannelUsageFields{}, "gpt-5.5", "gpt-5.5") + require.Empty(t, fields.BillingModelSource) + }) +} + func TestOpenAIResponses_MissingDependencies_ReturnsServiceUnavailable(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 4bf973f47ab..fb8700c48e6 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -87,11 +87,13 @@ var usageLogInsertArgTypes = [...]string{ } const rawUsageLogModelColumn = "model" +const requestedUsageLogModelExpr = "COALESCE(NULLIF(TRIM(requested_model), ''), model)" const visibleUsageLogModelExpr = "COALESCE(NULLIF(TRIM(upstream_model), ''), model)" // rawUsageLogModelColumn preserves the exact stored usage_logs.model semantics for direct filters. // Historical rows may contain upstream/billing model values, while newer rows store requested_model. -// Customer-visible model analytics must use visibleUsageLogModelExpr or resolveModelDimensionExpression instead. +// Product/billing analytics must default to requestedUsageLogModelExpr so mapped upstream models +// do not collapse customer-facing product tiers. Admin upstream views should request upstream explicitly. // dateFormatWhitelist 将 granularity 参数映射为 PostgreSQL TO_CHAR 格式字符串,防止外部输入直接拼入 SQL var dateFormatWhitelist = map[string]string{ @@ -2611,7 +2613,7 @@ func (r *usageLogRepository) GetUserModelStats(ctx context.Context, userID int64 WHERE user_id = $1 AND created_at >= $2 AND created_at < $3 GROUP BY %s ORDER BY total_tokens DESC - `, visibleUsageLogModelExpr, visibleUsageLogModelExpr) + `, requestedUsageLogModelExpr, requestedUsageLogModelExpr) rows, err := r.sql.QueryContext(ctx, query, userID, startTime, endTime) if err != nil { @@ -2998,7 +3000,7 @@ func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, st // GetModelStatsWithFilters returns model statistics with optional filters func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, usagestats.ModelSourceUpstream) + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, usagestats.ModelSourceRequested) } // GetModelStatsWithFiltersBySource returns model statistics with optional filters and model source dimension. @@ -3279,16 +3281,15 @@ func (r *usageLogRepository) GetAllGroupUsageSummary(ctx context.Context, todayS // resolveModelDimensionExpression maps model source type to a safe SQL expression. func resolveModelDimensionExpression(modelType string) string { - requestedExpr := "COALESCE(NULLIF(TRIM(requested_model), ''), model)" switch strings.ToLower(strings.TrimSpace(modelType)) { case usagestats.ModelSourceRequested: - return requestedExpr + return requestedUsageLogModelExpr case usagestats.ModelSourceUpstream: return visibleUsageLogModelExpr case usagestats.ModelSourceMapping: - return fmt.Sprintf("(%s || ' -> ' || COALESCE(NULLIF(TRIM(upstream_model), ''), %s))", requestedExpr, requestedExpr) + return fmt.Sprintf("(%s || ' -> ' || COALESCE(NULLIF(TRIM(upstream_model), ''), %s))", requestedUsageLogModelExpr, requestedUsageLogModelExpr) default: - return visibleUsageLogModelExpr + return requestedUsageLogModelExpr } } diff --git a/backend/internal/repository/usage_log_repo_breakdown_test.go b/backend/internal/repository/usage_log_repo_breakdown_test.go index 2a056a31400..b9db69dda6e 100644 --- a/backend/internal/repository/usage_log_repo_breakdown_test.go +++ b/backend/internal/repository/usage_log_repo_breakdown_test.go @@ -37,8 +37,8 @@ func TestResolveModelDimensionExpression(t *testing.T) { {usagestats.ModelSourceRequested, "COALESCE(NULLIF(TRIM(requested_model), ''), model)"}, {usagestats.ModelSourceUpstream, "COALESCE(NULLIF(TRIM(upstream_model), ''), model)"}, {usagestats.ModelSourceMapping, "(COALESCE(NULLIF(TRIM(requested_model), ''), model) || ' -> ' || COALESCE(NULLIF(TRIM(upstream_model), ''), COALESCE(NULLIF(TRIM(requested_model), ''), model)))"}, - {"", "COALESCE(NULLIF(TRIM(upstream_model), ''), model)"}, - {"invalid", "COALESCE(NULLIF(TRIM(upstream_model), ''), model)"}, + {"", "COALESCE(NULLIF(TRIM(requested_model), ''), model)"}, + {"invalid", "COALESCE(NULLIF(TRIM(requested_model), ''), model)"}, } for _, tc := range tests { diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index adcb3cc627c..3e814ac0f2f 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -1,6 +1,6 @@ diff --git a/frontend/src/components/user/dashboard/__tests__/UserDashboardQuickActions.spec.ts b/frontend/src/components/user/dashboard/__tests__/UserDashboardQuickActions.spec.ts new file mode 100644 index 00000000000..d2150f54ccc --- /dev/null +++ b/frontend/src/components/user/dashboard/__tests__/UserDashboardQuickActions.spec.ts @@ -0,0 +1,128 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi, beforeEach } from 'vitest' +import UserDashboardQuickActions from '../UserDashboardQuickActions.vue' + +const { createChatBridgeCode, routerPush } = vi.hoisted(() => ({ + createChatBridgeCode: vi.fn(), + routerPush: vi.fn() +})) + +vi.mock('@/api', () => ({ + authAPI: { + createChatBridgeCode + } +})) + +vi.mock('vue-router', () => ({ + useRouter: () => ({ + push: routerPush + }) +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => + ({ + 'dashboard.quickActions': '快捷操作', + 'dashboard.openChat': '网页 AI', + 'dashboard.openChatHint': '聊天和生图都可以直接打开', + 'dashboard.chatModeLabel': '选择入口', + 'dashboard.chatModeChat': '聊天', + 'dashboard.chatModeImage': '生图', + 'dashboard.chatModelLabel': '选择聊天模型', + 'dashboard.imageModelLabel': '选择生图模型', + 'dashboard.chatOpenButton': '打开', + 'dashboard.chatOpening': '打开中', + 'dashboard.chatModels.gpt55': '默认推荐,综合体验最好', + 'dashboard.chatModels.claudeOpus47': '适合复杂推理和长文写作', + 'dashboard.chatModels.claudeSonnet46': '适合日常工作,稳定均衡', + 'dashboard.chatModels.gemini31Pro': '适合长内容和多模态任务', + 'dashboard.chatModels.gpt54Mini': '速度快,成本更低', + 'dashboard.imageModels.gptImage2': '默认推荐,画质最好', + 'dashboard.imageModels.gptImage15': '速度快,适合反复改图', + 'dashboard.imageModels.gptImage1': '稳定通用', + 'dashboard.imageModels.dalle3': '经典文生图', + 'dashboard.createApiKey': '创建 API 密钥', + 'dashboard.generateNewKey': '生成新的 API 密钥', + 'dashboard.viewUsage': '查看使用记录', + 'dashboard.checkDetailedLogs': '查看详细的使用日志', + 'dashboard.redeemCode': '兑换码', + 'dashboard.addBalanceWithCode': '使用兑换码充值' + })[key] || key + }) +})) + +const mountQuickActions = () => + mount(UserDashboardQuickActions, { + global: { + stubs: { + Icon: { + props: ['name', 'size'], + template: '' + } + } + } + }) + +describe('UserDashboardQuickActions', () => { + beforeEach(() => { + createChatBridgeCode.mockReset() + routerPush.mockReset() + }) + + it('renders visible top model choices in the chat quick action', () => { + const wrapper = mountQuickActions() + const options = wrapper.findAll('#launch-model-select option').map((option) => option.text()) + + expect(options).toEqual( + expect.arrayContaining([ + expect.stringContaining('GPT-5.5'), + expect.stringContaining('Claude Opus 4.7'), + expect.stringContaining('Claude Sonnet 4.6'), + expect.stringContaining('Gemini 3.1 Pro'), + expect.stringContaining('GPT-5.4 Mini') + ]) + ) + }) + + it('renders image generation as a direct launch mode with image models', async () => { + const wrapper = mountQuickActions() + + await wrapper.get('[data-test-id="launch-mode-image"]').trigger('click') + + const options = wrapper.findAll('#launch-model-select option').map((option) => option.text()) + expect(options).toEqual( + expect.arrayContaining([ + expect.stringContaining('GPT Image 2'), + expect.stringContaining('GPT Image 1.5'), + expect.stringContaining('DALL·E 3') + ]) + ) + }) + + it('opens chat with the selected model in the launch path', async () => { + createChatBridgeCode.mockReturnValue(new Promise(() => undefined)) + + const wrapper = mountQuickActions() + await wrapper.get('#launch-model-select').setValue('claude-opus-4-7') + await wrapper.findAll('button').find((button) => button.text().includes('打开'))?.trigger('click') + + expect(createChatBridgeCode).toHaveBeenCalledWith({ + redirect_path: '/agent/inbox?hfc_model=claude-opus-4-7&hfc_provider=openai' + }) + }) + + it('opens image generation with the selected image model in the launch path', async () => { + createChatBridgeCode.mockReturnValue(new Promise(() => undefined)) + + const wrapper = mountQuickActions() + await wrapper.get('[data-test-id="launch-mode-image"]').trigger('click') + await wrapper.get('#launch-model-select').setValue('gpt-image-1.5') + await wrapper.findAll('button').find((button) => button.text().includes('打开'))?.trigger('click') + + expect(createChatBridgeCode).toHaveBeenCalledWith({ + redirect_path: + '/image?hfc_launch=image&hfc_image_model=gpt-image-1.5&hfc_image_provider=openai' + }) + }) +}) diff --git a/frontend/src/components/user/dashboard/__tests__/chatModelPresets.spec.ts b/frontend/src/components/user/dashboard/__tests__/chatModelPresets.spec.ts new file mode 100644 index 00000000000..1efca6fa5c2 --- /dev/null +++ b/frontend/src/components/user/dashboard/__tests__/chatModelPresets.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' + +import { + buildImageFallbackURL, + buildImageRedirectPath, + buildChatFallbackURL, + buildChatRedirectPath, + chatModelChoices, + defaultChatModelChoice, + defaultImageModelChoice, + imageModelChoices +} from '../chatModelPresets' + +describe('chat model presets', () => { + it('offers visible top model choices for non-technical users', () => { + expect(defaultChatModelChoice.model).toBe('gpt-5.5') + expect(chatModelChoices.map((choice) => choice.model)).toEqual( + expect.arrayContaining([ + 'gpt-5.5', + 'claude-opus-4-7', + 'claude-sonnet-4-6', + 'gemini-3.1-pro-preview', + 'gpt-5.4-mini' + ]) + ) + }) + + it('builds a chat redirect path with the selected model and provider', () => { + const path = buildChatRedirectPath({ + label: 'Claude Opus 4.7', + model: 'claude-opus-4-7', + provider: 'openai', + tagline: 'Deep reasoning' + }) + + expect(path).toBe('/agent/inbox?hfc_model=claude-opus-4-7&hfc_provider=openai') + }) + + it('keeps the selected model when falling back to a locally built chat URL', () => { + const url = new URL( + buildChatFallbackURL('code-123', { + label: 'Gemini 3.1 Pro', + model: 'gemini-3.1-pro-preview', + provider: 'openai', + tagline: 'Long context' + }) + ) + + expect(url.origin).toBe('https://chat.handsfreeclub.com') + expect(url.pathname).toBe('/agent/inbox') + expect(url.searchParams.get('hfc_chat_code')).toBe('code-123') + expect(url.searchParams.get('hfc_model')).toBe('gemini-3.1-pro-preview') + expect(url.searchParams.get('hfc_provider')).toBe('openai') + }) + + it('offers a direct image generation launch with image models', () => { + expect(defaultImageModelChoice.model).toBe('gpt-image-2') + expect(imageModelChoices.map((choice) => choice.model)).toEqual( + expect.arrayContaining(['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3']) + ) + }) + + it('builds an image redirect path with the selected image model', () => { + const path = buildImageRedirectPath({ + label: 'GPT Image 2', + model: 'gpt-image-2', + provider: 'openai', + tagline: 'Best image quality' + }) + + expect(path).toBe( + '/image?hfc_launch=image&hfc_image_model=gpt-image-2&hfc_image_provider=openai' + ) + }) + + it('keeps the selected image model when falling back to a locally built image URL', () => { + const url = new URL( + buildImageFallbackURL('code-123', { + label: 'GPT Image 1.5', + model: 'gpt-image-1.5', + provider: 'openai', + tagline: 'Fast edits' + }) + ) + + expect(url.origin).toBe('https://chat.handsfreeclub.com') + expect(url.pathname).toBe('/image') + expect(url.searchParams.get('hfc_chat_code')).toBe('code-123') + expect(url.searchParams.get('hfc_launch')).toBe('image') + expect(url.searchParams.get('hfc_image_model')).toBe('gpt-image-1.5') + expect(url.searchParams.get('hfc_image_provider')).toBe('openai') + }) +}) diff --git a/frontend/src/components/user/dashboard/chatModelPresets.ts b/frontend/src/components/user/dashboard/chatModelPresets.ts new file mode 100644 index 00000000000..cfd74b1841c --- /dev/null +++ b/frontend/src/components/user/dashboard/chatModelPresets.ts @@ -0,0 +1,109 @@ +export interface ChatModelChoice { + label: string + model: string + provider: string + tagline: string + taglineKey?: string +} + +export const chatModelChoices: ChatModelChoice[] = [ + { + label: 'GPT-5.5', + model: 'gpt-5.5', + provider: 'openai', + tagline: 'Best overall experience', + taglineKey: 'dashboard.chatModels.gpt55' + }, + { + label: 'Claude Opus 4.7', + model: 'claude-opus-4-7', + provider: 'openai', + tagline: 'Deep reasoning and long writing', + taglineKey: 'dashboard.chatModels.claudeOpus47' + }, + { + label: 'Claude Sonnet 4.6', + model: 'claude-sonnet-4-6', + provider: 'openai', + tagline: 'Balanced daily work', + taglineKey: 'dashboard.chatModels.claudeSonnet46' + }, + { + label: 'Gemini 3.1 Pro', + model: 'gemini-3.1-pro-preview', + provider: 'openai', + tagline: 'Long context and multimodal work', + taglineKey: 'dashboard.chatModels.gemini31Pro' + }, + { + label: 'GPT-5.4 Mini', + model: 'gpt-5.4-mini', + provider: 'openai', + tagline: 'Fast and economical', + taglineKey: 'dashboard.chatModels.gpt54Mini' + } +] + +export const defaultChatModelChoice = chatModelChoices[0] + +export const imageModelChoices: ChatModelChoice[] = [ + { + label: 'GPT Image 2', + model: 'gpt-image-2', + provider: 'openai', + tagline: 'Best image quality', + taglineKey: 'dashboard.imageModels.gptImage2' + }, + { + label: 'GPT Image 1.5', + model: 'gpt-image-1.5', + provider: 'openai', + tagline: 'Fast iteration and image edits', + taglineKey: 'dashboard.imageModels.gptImage15' + }, + { + label: 'GPT Image 1', + model: 'gpt-image-1', + provider: 'openai', + tagline: 'Stable general image generation', + taglineKey: 'dashboard.imageModels.gptImage1' + }, + { + label: 'DALL·E 3', + model: 'dall-e-3', + provider: 'openai', + tagline: 'Classic text-to-image', + taglineKey: 'dashboard.imageModels.dalle3' + } +] + +export const defaultImageModelChoice = imageModelChoices[0] + +export const buildChatRedirectPath = (choice: ChatModelChoice) => { + const params = new URLSearchParams() + params.set('hfc_model', choice.model) + params.set('hfc_provider', choice.provider) + return `/agent/inbox?${params.toString()}` +} + +export const buildChatFallbackURL = (code: string, choice: ChatModelChoice) => { + const baseURL = import.meta.env.VITE_HFC_CHAT_URL || 'https://chat.handsfreeclub.com' + const target = new URL(buildChatRedirectPath(choice), baseURL) + target.searchParams.set('hfc_chat_code', code) + return target.toString() +} + +export const buildImageRedirectPath = (choice: ChatModelChoice) => { + const params = new URLSearchParams() + params.set('hfc_launch', 'image') + params.set('hfc_image_model', choice.model) + params.set('hfc_image_provider', choice.provider) + return `/image?${params.toString()}` +} + +export const buildImageFallbackURL = (code: string, choice: ChatModelChoice) => { + const baseURL = import.meta.env.VITE_HFC_CHAT_URL || 'https://chat.handsfreeclub.com' + const target = new URL(buildImageRedirectPath(choice), baseURL) + target.searchParams.set('hfc_chat_code', code) + return target.toString() +} diff --git a/frontend/src/components/user/profile/ProfileInfoCard.vue b/frontend/src/components/user/profile/ProfileInfoCard.vue index 37ee8a55c97..2c190715bdb 100644 --- a/frontend/src/components/user/profile/ProfileInfoCard.vue +++ b/frontend/src/components/user/profile/ProfileInfoCard.vue @@ -263,7 +263,9 @@ const providerLabels = computed>(() => ({ email: t('profile.authBindings.providers.email'), linuxdo: t('profile.authBindings.providers.linuxdo'), oidc: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }), - wechat: t('profile.authBindings.providers.wechat') + wechat: t('profile.authBindings.providers.wechat'), + github: 'GitHub', + google: 'Google' })) function formatCurrency(value: number): string { @@ -272,7 +274,13 @@ function formatCurrency(value: number): string { function normalizeProvider(value: string): UserAuthProvider | null { const normalized = value.trim().toLowerCase() - if (normalized === 'email' || normalized === 'linuxdo' || normalized === 'wechat') { + if ( + normalized === 'email' || + normalized === 'linuxdo' || + normalized === 'wechat' || + normalized === 'github' || + normalized === 'google' + ) { return normalized } if (normalized === 'oidc' || normalized.startsWith('oidc:') || normalized.startsWith('oidc/')) { diff --git a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts index d35e3b12907..29ec513e361 100644 --- a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts +++ b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts @@ -13,6 +13,7 @@ describe('useModelWhitelist', () => { expect(models).toContain('gpt-5.4') expect(models).toContain('gpt-5.4-mini') expect(models).toContain('gpt-5.4-2026-03-05') + expect(models).toContain('codex-auto-review') }) it('openai 模型列表不再暴露已下线的 ChatGPT 登录 Codex 模型', () => { diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index c511b451ad4..4d9b7fe2a1b 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -11,8 +11,8 @@ const openaiModels = [ 'gpt-5.5', // GPT-5.4 系列 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-2026-03-05', - // GPT-5.3 系列 - 'gpt-5.3-codex', 'gpt-5.3-codex-spark', + // GPT-5.3 / Codex 系列 + 'gpt-5.3-codex', 'gpt-5.3-codex-spark', 'codex-auto-review', 'gpt-4o-audio-preview', 'gpt-4o-realtime-preview', // GPT Image 系列 'gpt-image-1', 'gpt-image-1.5', 'gpt-image-2' diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 2da121fbff9..31d83f4ac7f 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -347,6 +347,10 @@ export default { usage: 'Usage', redeem: 'Redeem', affiliate: 'Affiliate Rebates', + affiliateManagement: 'Affiliate Rebates', + affiliateInviteRecords: 'Invite Records', + affiliateRebateRecords: 'Rebate Records', + affiliateTransferRecords: 'Transfer Records', profile: 'Profile', users: 'Users', groups: 'Groups', @@ -378,6 +382,7 @@ export default { channelPricing: 'Channel Pricing', channelMonitor: 'Channel Monitor', channelStatus: 'Channel Status', + riskControl: 'Risk Control', }, // Auth @@ -406,6 +411,9 @@ export default { passwordRequired: 'Password is required', passwordMinLength: 'Password must be at least 6 characters', loginFailed: 'Login failed. Please check your credentials and try again.', + errors: { + USER_NOT_ACTIVE: 'Account has been disabled.', + }, registrationFailed: 'Registration failed. Please try again.', emailSuffixNotAllowed: 'This email domain is not allowed for registration.', emailSuffixNotAllowedWithAllowed: @@ -468,6 +476,9 @@ export default { completing: 'Completing registration…', completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.' }, + emailOAuth: { + signIn: 'Continue with {providerName}' + }, oidc: { signIn: 'Continue with {providerName}', callbackTitle: 'Signing you in with {providerName}', @@ -527,6 +538,8 @@ export default { oauth: { callbackTitle: 'OAuth Callback', callbackHint: 'Copy the code and state back to the admin authorization flow when needed.', + invalidCallbackTitle: 'Invalid sign-in callback', + invalidCallbackHint: 'This page does not contain a valid authorization result. Return to the login page and start quick sign-in again.', code: 'Code', state: 'State', fullUrl: 'Full URL' @@ -602,6 +615,28 @@ export default { quickActions: 'Quick Actions', createApiKey: 'Create API Key', generateNewKey: 'Generate a new API key', + openChat: 'Web AI', + openChatHint: 'Open chat or image generation directly', + chatModeLabel: 'Launch', + chatModeChat: 'Chat', + chatModeImage: 'Image', + chatModelLabel: 'Chat model', + imageModelLabel: 'Image model', + chatOpenButton: 'Open', + chatOpening: 'Opening', + chatModels: { + gpt55: 'Default pick, best overall experience', + claudeOpus47: 'For deep reasoning and long writing', + claudeSonnet46: 'Stable and balanced for daily work', + gemini31Pro: 'For long content and multimodal tasks', + gpt54Mini: 'Fast and economical' + }, + imageModels: { + gptImage2: 'Default pick, best image quality', + gptImage15: 'Fast iteration and edits', + gptImage1: 'Stable general image generation', + dalle3: 'Classic text-to-image' + }, viewUsage: 'View Usage', checkDetailedLogs: 'Check detailed usage logs', redeemCode: 'Redeem Code', @@ -647,6 +682,12 @@ export default { namePlaceholder: 'My API Key', groupLabel: 'Group', selectGroup: 'Select a group', + walletAnyKey: 'Universal key (auto-route by model)', + walletAnyKeyHint: 'Claude, GPT, and Gemini requests automatically use the matching group and charge your wallet.', + walletAnyKeyBadge: 'Universal · Wallet mode', + walletAnyKeySelectPlaceholder: 'Auto-route by model', + walletKeyBadge: 'Wallet', + walletKeyBadgeHint: 'Auto-created wallet key. All wallet keys share the same balance.', statusLabel: 'Status', selectStatus: 'Select status', saving: 'Saving...', @@ -840,6 +881,8 @@ export default { perMillionTokens: '/ 1M tokens', unitPrice: 'Per-request price', imageUnitPrice: 'Per-image price', + imageTotalPrice: 'Image total price', + imageCount: 'Image count', cacheRead: 'Read', cacheWrite: 'Write', serviceTier: 'Service tier', @@ -1046,6 +1089,7 @@ export default { recentActivity: 'Recent Activity', historyWillAppear: 'Your redemption history will appear here', balanceAddedRedeem: 'Balance Added (Redeem)', + balanceAddedAffiliate: 'Balance Added (Affiliate Transfer)', balanceAddedAdmin: 'Balance Added (Admin)', balanceDeductedAdmin: 'Balance Deducted (Admin)', concurrencyAddedRedeem: 'Concurrency Added (Redeem)', @@ -1635,6 +1679,49 @@ export default { } }, + affiliates: { + invitesDescription: 'View site-wide inviter and invitee relationships', + rebatesDescription: 'View recharge orders that generated affiliate rebates', + transfersDescription: 'View affiliate quota transfers into account balance', + errors: { + loadFailed: 'Failed to load affiliate records' + }, + records: { + search: 'Search', + searchPlaceholder: 'Email, username, user ID, or order number', + startAt: 'Start date', + endAt: 'End date', + inviter: 'Inviter', + invitee: 'Invitee', + user: 'User', + affCode: 'Invite Code', + order: 'Order', + totalRebate: 'Total Rebate', + orderAmount: 'Top-up Amount', + payAmount: 'Paid Amount', + rebateAmount: 'Rebate Amount', + paymentType: 'Payment Method', + orderStatus: 'Order Status', + transferAmount: 'Transfer Amount', + balanceAfter: 'Balance After', + availableQuotaAfter: 'Available After', + frozenQuotaAfter: 'Frozen After', + historyQuotaAfter: 'Historical Rebate After', + invitedAt: 'Invited At', + rebatedAt: 'Rebated At', + transferredAt: 'Transferred At' + }, + overview: { + title: 'Affiliate User Overview', + affCode: 'Invite Code', + rebateRate: 'Rebate Rate', + invitedCount: 'Invited Users', + rebatedInviteeCount: 'Rebated Invitees', + availableQuota: 'Available Quota', + historyQuota: 'Historical Rebate' + } + }, + // Users users: { title: 'User Management', @@ -1787,6 +1874,7 @@ export default { noBalanceHistory: 'No records found for this user', allTypes: 'All Types', typeBalance: 'Balance (Redeem)', + typeAffiliateBalance: 'Balance (Affiliate Transfer)', typeAdminBalance: 'Balance (Admin)', typeConcurrency: 'Concurrency (Redeem)', typeAdminConcurrency: 'Concurrency (Admin)', @@ -2001,7 +2089,13 @@ export default { }, imagePricing: { title: 'Image Generation Pricing', - description: 'Configure pricing for image generation models. Leave empty to use default prices.' + description: 'Configure image generation access and base image prices. Leave empty to use default prices.', + allowImageGeneration: 'Allow image generation for this group', + independentMultiplier: 'Use independent image multiplier', + imageMultiplier: 'Image multiplier', + modeHint: 'By default, image billing uses image price × current effective group multiplier. Independent mode uses image price × image multiplier.', + finalPricePreview: 'Final per-image price preview', + notConfigured: 'Not configured' }, claudeCode: { title: 'Claude Code Client Restriction', @@ -2226,6 +2320,8 @@ export default { webSearchEmulation: 'Web Search Emulation', webSearchEmulationHint: '⚠️ When enabled, all accounts in this channel\'s Anthropic groups will intercept web_search requests. Use with caution.', webSearchEmulationGlobalDisabled: 'Please enable the global switch first in Settings → Gateway → Web Search Emulation', + codexImageGenerationBridge: 'Codex Image Generation Bridge', + codexImageGenerationBridgeHint: 'When enabled, Codex /responses text requests in OpenAI groups may be automatically given the image_generation tool. Keep off unless the routed accounts support image generation.', basicSettings: 'Basic Settings', addPlatform: 'Add Platform', noPlatforms: 'Click "Add Platform" to start configuring the channel', @@ -2248,6 +2344,216 @@ export default { } }, + riskControl: { + title: 'Risk Control', + description: 'Configure content moderation and review audit records', + loadFailed: 'Failed to load risk control', + saveFailed: 'Failed to save content moderation config', + logsFailed: 'Failed to load audit records', + saved: 'Content moderation config saved', + refresh: 'Refresh', + config: 'Content Moderation Config', + configHint: 'Use OpenAI Moderations to score request content and handle threshold hits by mode.', + openSettings: 'Moderation Settings', + settingsTitle: 'Content Moderation Settings', + refreshStatus: 'Refresh Status', + records: 'Audit Records', + recordsHint: 'Shows hits, blocks, errors, and sampled records.', + saveConfig: 'Save Moderation Config', + statusFailed: 'Failed to load runtime status', + enabled: 'Enable Content Moderation', + enabledHint: 'When off, gateway requests are not moderated even if the menu is enabled.', + mode: 'Global Mode', + modePreBlock: 'Pre-Block', + modePreBlockDesc: 'Synchronously reviews the latest user input before every request and rejects hits immediately.', + modeObserve: 'Observe Only', + modeObserveDesc: 'Requests pass through while the latest user input is queued for async review; hits are recorded, notified, and counted.', + modeOff: 'Off', + modeOffDesc: 'Content moderation is disabled and no audit records are written.', + baseUrl: 'OpenAI Base URL', + model: 'Model', + apiKey: 'OpenAI API Key', + apiKeys: 'OpenAI API Keys', + apiKeyCount: '{count} keys', + apiKeyPlaceholder: 'Enter API Key', + apiKeysPlaceholder: 'Add API Keys, one per line. They will be appended on save.', + apiKeysPlaceholderReplace: 'Replace API Keys, one per line. Stored keys will be replaced on save.', + apiKeysPlaceholderKeep: 'Add API Keys, one per line. They will be appended on save.', + apiKeysHint: '{count} keys are currently stored. This input only adds keys; save appends and de-duplicates them.', + apiKeysWriteMode: 'Write mode', + apiKeysModeAppend: 'Add', + apiKeysModeReplace: 'Replace', + apiKeysModeAppendHint: 'Default: save appends input keys and keeps stored keys.', + apiKeysModeReplaceHint: 'Replace mode: save replaces all stored keys with input keys.', + apiKeysReplaceWarning: 'Replace mode', + apiKeysReplaceNoInput: 'Replace mode requires at least 1 API Key', + apiKeyPlaceholderKeep: 'Leave empty to keep current key', + apiKeyWillClear: 'Configured key will be cleared on save', + apiKeyConfigured: 'Configured', + apiKeyTemporary: 'Pending', + apiKeyPendingDelete: 'Pending delete', + apiKeyPendingDeleteCount: '{count} keys pending deletion', + deleteApiKey: 'Delete this key', + undoDeleteApiKey: 'Undo delete', + inputApiKeyCount: '{count} keys in input', + storedApiKeyCount: '{count} stored keys', + testInputApiKeys: 'Test input keys', + testStoredApiKeys: 'Test stored keys', + testContentWithStoredApiKey: 'Test content with stored key', + testingApiKeys: 'Testing', + apiKeyTestNoInput: 'Enter OpenAI API Keys to test first', + apiKeyTestDone: 'Key test completed for {count} keys', + apiKeyTestFailed: 'Failed to test OpenAI API Keys', + apiKeyHealth: 'Key Availability', + apiKeyFreezeRule: '400 does not freeze; 401/403 freeze for 10 minutes; 429/529 freeze for 1 minute; other HTTP errors freeze for 10 seconds.', + apiKeyRows: '{count} keys', + apiKeyRowsCollapsed: '{count} keys hidden', + apiKeyRowsExpanded: 'Showing all {count} keys', + expandApiKeyRows: 'Expand', + collapseApiKeyRows: 'Collapse', + apiKeyHealthEmpty: 'No key status yet', + apiKeyHealthEmptyHint: 'Save keys or test input keys to see availability.', + apiKeyStatusOk: 'Available', + apiKeyStatusError: 'Error', + apiKeyStatusFrozen: 'Frozen', + apiKeyStatusUnknown: 'Untested', + apiKeyFailureCount: '{count} failures', + apiKeyLatency: '{ms} ms', + apiKeyHTTPStatus: 'HTTP {status}', + apiKeyFrozenUntil: 'Frozen until {time}', + apiKeyLastChecked: 'Checked at {time}', + apiKeyNotTested: 'Not tested', + auditTestInput: 'Audit Test Input', + auditTestInputHint: 'Enter a prompt and upload or paste images; images are sent as base64 and are not stored.', + auditTestPromptPlaceholder: 'Enter a user prompt to test; leave empty to only test key availability.', + auditTestImages: 'Test Images', + auditTestImagesHint: 'Upload, drag, or paste images. Up to 1 image, 8MB each.', + addAuditTestImage: 'Add image', + clearAuditTest: 'Clear test', + auditTestImageLimit: 'You can add up to {count} test images', + auditTestImageTooLarge: 'Each test image must be 8MB or smaller', + auditTestImageReadFailed: 'Failed to read test image', + auditTestResult: 'Audit Test Result', + auditTestHighest: 'Top category {category}, score {score}', + auditTestComposite: 'Composite score', + auditTestFlagged: 'Threshold hit', + auditTestPassed: 'Pass', + notConfigured: 'Not configured', + clearApiKey: 'Clear stored key', + keepApiKey: 'Keep stored key', + timeoutMs: 'HTTP Timeout (ms)', + retryCount: 'Retry Count', + sampleRate: 'Sample Rate', + recordNonHits: 'Record Non-Hits', + recordNonHitsHint: 'When enabled, sampled non-hit request summaries are redacted before storage.', + preHashCheck: 'Enable Pre-Hash Check', + preHashCheckHint: 'Hashes from async hits are blocked before moderation; this does not send email or increment ban counters.', + flaggedHashCount: 'Current hash collection size: {count}', + flaggedHashHint: 'Hashes are stored permanently in Redis; paste a full 64-character hash to remove a false block, or clear all stored hashes.', + flaggedHashPlaceholder: 'Paste full 64-character input hash', + deleteFlaggedHash: 'Delete hash', + clearFlaggedHashes: 'Clear all', + clearFlaggedHashesConfirm: 'Clear all risk input hashes? This does not delete audit records, but removes all historical hash blocks.', + flaggedHashDeleted: 'Risk hash deleted', + flaggedHashNotFound: 'Risk hash not found', + flaggedHashDeleteFailed: 'Failed to delete risk hash', + flaggedHashesCleared: 'Cleared {count} risk hashes', + flaggedHashesClearFailed: 'Failed to clear risk hashes', + workerCount: 'Worker Count', + queueSize: 'Async Queue Size', + blockStatus: 'Block HTTP Status', + blockMessage: 'Custom Block Message', + emailOnHit: 'Email on Hit', + emailOnHitHint: 'When enabled, send a risk-control email on every hit; auto-ban notices are always sent.', + autoBan: 'Auto Ban User', + autoBanHint: 'Disable the user, invalidate auth cache, and send a ban notice after the hit threshold is reached.', + banThreshold: 'Ban Threshold', + violationWindowHours: 'Count Window (hours)', + hitRetentionDays: 'Hit Record Retention (days)', + nonHitRetentionDays: 'Non-Hit Record Retention (days, max 3)', + violationCount: '{count} hits', + emailSent: 'Email sent', + emailNotSent: 'No email', + autoBanned: 'Banned', + unbanUser: 'Unban', + unbanSuccess: 'User has been unbanned', + unbanFailed: 'Failed to unban user', + inputDetailTitle: 'Input Summary Detail', + inputDetailContent: 'Full Content', + queueDelay: 'Queued {ms} ms', + allGroups: 'All Groups', + allGroupsHint: 'Auditing all groups', + selectedGroupsHint: 'Auditing selected groups', + groupScope: 'Audit Groups', + groupScopeHint: 'Switch on for all groups, or turn off to choose specific groups.', + selectedGroups: 'Selected Groups', + searchGroups: 'Search group name or platform', + noGroups: 'No groups available', + emptyLogs: 'No audit records', + workerStatus: 'Worker Runtime', + workerStatusHint: 'Queue and worker pool status for asynchronous observation tasks.', + workerPool: 'Worker Pool', + workerPoolMeta: '{active} processing, {idle} idle and ready, {total} total', + queueUsage: 'Queue Usage', + activeWorkers: 'Processing', + idleWorkers: 'Idle Ready', + workerActive: 'Processing an asynchronous audit task', + workerIdle: 'Started, idle and ready', + workerDisabled: 'Risk control or content audit is disabled', + processed: 'Processed', + droppedErrors: 'Dropped / Errors', + autoRefresh: 'Auto refresh every 15s', + lastCleanup: 'Last cleanup: {time}', + cleanupStats: 'Last cleanup deleted {hit} hits and {nonHit} non-hits', + riskSwitchOff: 'System switch off', + tabs: { + basic: 'Basic', + scope: 'Scope', + runtime: 'Runtime', + response: 'Hit Notice', + retention: 'Retention', + }, + overview: { + status: 'Status', + enabled: 'Enabled', + disabled: 'Disabled', + apiKey: 'API Key', + groupScope: 'Scope', + logs: 'Audit Records', + currentFilter: 'Current filter', + }, + filters: { + search: 'Search user/key/summary', + from: 'From', + to: 'To', + allGroups: 'All Groups', + allEndpoints: 'All Endpoints', + }, + table: { + time: 'Time', + group: 'Group', + user: 'User', + apiKey: 'API Key', + endpoint: 'Endpoint', + result: 'Result', + highest: 'Highest', + actionMeta: 'Action', + latency: 'Latency', + input: 'Input Summary', + }, + result: { + all: 'All Results', + hit: 'Hit', + blocked: 'Blocked', + pass: 'Pass', + error: 'Error', + }, + action: { + block: 'Blocked', + error: 'Error', + }, + }, + // Channel Monitor channelMonitor: { title: 'Channel Monitor', @@ -2412,12 +2718,19 @@ export default { user: 'User', group: 'Subscription Group', validityDays: 'Validity (Days)', - adjustDays: 'Adjust by (Days)' + adjustDays: 'Adjust by (Days)', + mode: 'Subscription Mode', + modeGroup: 'Specific Group', + modeWallet: 'Wallet (user-level)', + modeHint: 'Specific Group = legacy v3 subscription; Wallet = user-level shared balance, billed across all groups by their rate multipliers', + walletInitialUSD: 'Initial Balance (USD)', + walletInitialHint: 'Initial wallet balance in USD. Example: ¥299 plan = 1500.00' }, selectUser: 'Select a user', selectGroup: 'Select a subscription group', groupHint: 'Only groups with subscription billing type are shown', validityHint: 'Number of days the subscription will be valid', + walletInitialRequired: 'Please enter a wallet initial balance greater than 0', adjustingFor: 'Adjusting subscription for', currentExpiration: 'Current expiration', adjustDaysPlaceholder: 'Positive to extend, negative to shorten', @@ -2874,6 +3187,18 @@ export default { codexCLIOnly: 'Codex official clients only', codexCLIOnlyDesc: 'Only applies to OpenAI OAuth. When enabled, only Codex official client families are allowed; when disabled, the gateway bypasses this restriction and keeps existing behavior.', + codexImageGenerationBridge: 'Codex image-generation bridge', + codexImageGenerationBridgeDesc: + 'Account policy takes precedence over channel and global settings. Only controls whether Codex requests through the /responses text endpoint receive the image_generation tool; standalone image-generation endpoints are unaffected.', + codexImageGenerationBridgeInherit: 'Follow channel', + codexImageGenerationBridgeInheritDesc: 'Do not write an account override; use the channel or global policy.', + codexImageGenerationBridgeEnabled: 'Force on', + codexImageGenerationBridgeEnabledDesc: 'Allow image tool injection for Codex /responses requests.', + codexImageGenerationBridgeDisabled: 'Force off', + codexImageGenerationBridgeDisabledDesc: 'Block image tool injection for Codex /responses requests.', + codexImageGenerationBridgeBadgeInherit: 'Channel policy', + codexImageGenerationBridgeBadgeEnabled: 'Account on', + codexImageGenerationBridgeBadgeDisabled: 'Account off', compactMode: 'Compact mode', compactModeDesc: 'Controls how this account participates in /responses/compact routing. Auto follows probe results, Force On always allows, Force Off always excludes.', @@ -2885,7 +3210,8 @@ export default { 'Only applies to /responses/compact. Use this when the upstream compact endpoint requires a special compact model.', compactSupported: 'Compact supported', compactUnsupported: 'Compact unsupported', - compactUnknown: 'Compact unknown', + compactAuto: 'Compact Auto', + compactUnknown: 'Compact Auto', compactLastChecked: 'Last compact probe', testMode: 'Test mode', testModeDefault: 'Default request', @@ -2919,7 +3245,7 @@ export default { targetNoWildcard: 'Target model cannot contain wildcard *', searchModels: 'Search models...', noMatchingModels: 'No matching models', - fillRelatedModels: 'Fill related models', + fillRelatedModels: 'Sync latest supported models', clearAllModels: 'Clear all models', customModelName: 'Custom model name', enterCustomModelName: 'Enter custom model name', @@ -4780,6 +5106,7 @@ export default { description: 'Manage registration, email verification, default values, and SMTP settings', tabs: { general: 'General', + agreement: 'Agreement', features: 'Feature Switches', security: 'Security', users: 'Users', @@ -4805,6 +5132,13 @@ export default { enabled: 'Enable Available Channels', enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.', }, + riskControl: { + title: 'Risk Control', + description: 'Enable the content moderation menu and gateway audit entry point. Disabled by default.', + configureLink: 'Configure content moderation in Risk Control', + enabled: 'Enable Risk Control', + enabledHint: 'When off, the admin sidebar entry is hidden and gateway moderation is skipped.', + }, affiliate: { title: 'Affiliate (Invite Rebate)', description: 'Existing users invite new ones; the inviter earns a percentage rebate on the invitee’s recharges. Disabled by default.', @@ -5486,6 +5820,16 @@ export default { saved: 'Overload cooldown settings saved', saveFailed: 'Failed to save overload cooldown settings' }, + rateLimit429Cooldown: { + title: '429 Default Cooldown', + description: 'Configure the default account cooldown when upstream returns 429 without an explicit reset time', + enabled: 'Enable 429 Default Cooldown', + enabledHint: 'Pause account scheduling when a 429 has no reset time, then auto-recover after cooldown', + cooldownSeconds: 'Cooldown Duration (seconds)', + cooldownSecondsHint: 'Default cooldown duration (1-7200 seconds); explicit upstream reset times still take precedence', + saved: '429 default cooldown settings saved', + saveFailed: 'Failed to save 429 default cooldown settings' + }, streamTimeout: { title: 'Stream Timeout Handling', description: 'Configure account handling strategy when upstream response times out', @@ -5912,7 +6256,24 @@ export default { expiresOn: 'Expires on {date}', resetIn: 'Resets in {time}', windowNotActive: 'Awaiting first use', - usageOf: '{used} of {limit}' + usageOf: '{used} of {limit}', + wallet: { + title: 'Wallet Balance', + subtitle: 'Shared across all groups, charged by rate multiplier', + remaining: 'Remaining', + usedPercent: 'Used', + lowWarning: 'Only ${amount} left — consider renewing soon', + exhausted: 'Wallet exhausted — please renew to keep using', + rateListTitle: 'Group Rate Multipliers', + rateListDesc: 'Higher multiplier groups burn the wallet faster', + rateListEmpty: 'No groups available', + userOverride: 'Custom', + userOverrideHint: 'Admin set a custom multiplier for you (base ×{base})', + routeListTitle: 'Your key routes automatically', + routeListDesc: 'The request model chooses the group and multiplier', + routeListEmpty: 'No routes available', + routeModel: 'Use {model}' + } }, // Onboarding Tour @@ -6308,6 +6669,11 @@ export default { deleteChannelConfirm: 'Are you sure you want to delete this channel?', planName: 'Plan Name', planDescription: 'Plan Description', + planType: 'Plan Type', + planTypeSubscription: 'Subscription (monthly)', + planTypeCredits: 'Credits (permanent)', + planTypeSubscriptionHint: 'Pay per validity period; balance freezes when it expires.', + planTypeCreditsHint: 'One-time credit purchase, never expires; multiple credit packs stack into the same wallet.', createPlan: 'Create Plan', editPlan: 'Edit Plan', deletePlan: 'Delete Plan', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 7d266522499..b343d30beda 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -347,6 +347,10 @@ export default { usage: '使用记录', redeem: '兑换', affiliate: '邀请返利', + affiliateManagement: '邀请返利', + affiliateInviteRecords: '邀请记录', + affiliateRebateRecords: '返利记录', + affiliateTransferRecords: '提取记录', profile: '个人资料', users: '用户管理', groups: '分组管理', @@ -378,6 +382,7 @@ export default { channelPricing: '渠道定价', channelMonitor: '渠道监控', channelStatus: '渠道状态', + riskControl: '风控中心', }, // Auth @@ -406,6 +411,9 @@ export default { passwordRequired: '请输入密码', passwordMinLength: '密码至少需要 6 个字符', loginFailed: '登录失败,请检查您的凭据后重试。', + errors: { + USER_NOT_ACTIVE: '账号已被禁用', + }, registrationFailed: '注册失败,请重试。', emailSuffixNotAllowed: '该邮箱域名不在允许注册范围内。', emailSuffixNotAllowedWithAllowed: '该邮箱域名不被允许。可用域名:{suffixes}', @@ -467,6 +475,9 @@ export default { completing: '正在完成注册...', completeRegistrationFailed: '注册失败,请检查邀请码后重试。' }, + emailOAuth: { + signIn: '使用 {providerName} 登录' + }, oidc: { signIn: '使用 {providerName} 登录', callbackTitle: '正在完成 {providerName} 登录', @@ -525,6 +536,8 @@ export default { oauth: { callbackTitle: 'OAuth 回调', callbackHint: '按需将授权码和状态值复制回后台授权流程。', + invalidCallbackTitle: '无效的登录回调', + invalidCallbackHint: '当前页面缺少有效的授权结果,请返回登录页重新发起快捷登录。', code: '授权码', state: '状态', fullUrl: '完整URL' @@ -601,6 +614,28 @@ export default { quickActions: '快捷操作', createApiKey: '创建 API 密钥', generateNewKey: '生成新的 API 密钥', + openChat: '网页 AI', + openChatHint: '聊天和生图都可以直接打开', + chatModeLabel: '选择入口', + chatModeChat: '聊天', + chatModeImage: '生图', + chatModelLabel: '选择聊天模型', + imageModelLabel: '选择生图模型', + chatOpenButton: '打开', + chatOpening: '打开中', + chatModels: { + gpt55: '默认推荐,综合体验最好', + claudeOpus47: '适合复杂推理和长文写作', + claudeSonnet46: '适合日常工作,稳定均衡', + gemini31Pro: '适合长内容和多模态任务', + gpt54Mini: '速度快,成本更低' + }, + imageModels: { + gptImage2: '默认推荐,画质最好', + gptImage15: '速度快,适合反复改图', + gptImage1: '稳定通用', + dalle3: '经典文生图' + }, viewUsage: '查看使用记录', checkDetailedLogs: '查看详细的使用日志', redeemCode: '兑换码', @@ -646,6 +681,12 @@ export default { namePlaceholder: '我的 API 密钥', groupLabel: '分组', selectGroup: '选择分组', + walletAnyKey: '通用 key(自动按模型路由分组)', + walletAnyKeyHint: '调 Claude、GPT 或 Gemini 时会自动选择对应分组,并从钱包扣费。', + walletAnyKeyBadge: '通用 · 钱包模式', + walletAnyKeySelectPlaceholder: '自动按模型路由', + walletKeyBadge: '钱包', + walletKeyBadgeHint: '钱包模式自动建的 key,所有 key 共享同一个余额。', statusLabel: '状态', selectStatus: '选择状态', saving: '保存中...', @@ -844,6 +885,8 @@ export default { perMillionTokens: '/ 1M Token', unitPrice: '单次价格', imageUnitPrice: '单张价格', + imageTotalPrice: '图片总价', + imageCount: '图片张数', cacheRead: '读取', cacheWrite: '写入', serviceTier: '服务档位', @@ -1050,6 +1093,7 @@ export default { recentActivity: '最近活动', historyWillAppear: '您的兑换历史将显示在这里', balanceAddedRedeem: '余额充值(兑换)', + balanceAddedAffiliate: '余额充值(返利转入)', balanceAddedAdmin: '余额充值(管理员)', balanceDeductedAdmin: '余额扣除(管理员)', concurrencyAddedRedeem: '并发增加(兑换)', @@ -1656,6 +1700,49 @@ export default { } }, + affiliates: { + invitesDescription: '查看全站邀请关系和被邀请用户累计返利', + rebatesDescription: '查看每一笔产生返利的充值订单', + transfersDescription: '查看返利额度转入账户余额的提取流水', + errors: { + loadFailed: '加载邀请返利记录失败' + }, + records: { + search: '搜索', + searchPlaceholder: '邮箱、用户名、用户 ID、订单号', + startAt: '开始日期', + endAt: '结束日期', + inviter: '邀请人', + invitee: '被邀请人', + user: '用户', + affCode: '邀请码', + order: '订单', + totalRebate: '累计返利', + orderAmount: '充值金额', + payAmount: '支付金额', + rebateAmount: '返利金额', + paymentType: '支付方式', + orderStatus: '订单状态', + transferAmount: '提取金额', + balanceAfter: '提取后余额', + availableQuotaAfter: '提取后可提', + frozenQuotaAfter: '提取后冻结', + historyQuotaAfter: '提取后历史返利', + invitedAt: '邀请时间', + rebatedAt: '返利时间', + transferredAt: '提取时间' + }, + overview: { + title: '用户返利概览', + affCode: '邀请码', + rebateRate: '返利比例', + invitedCount: '邀请人数', + rebatedInviteeCount: '已产生返利人数', + availableQuota: '可提余额', + historyQuota: '历史返利' + } + }, + // Users Management users: { title: '用户管理', @@ -1844,6 +1931,7 @@ export default { noBalanceHistory: '暂无变动记录', allTypes: '全部类型', typeBalance: '余额(兑换码)', + typeAffiliateBalance: '余额(返利转入)', typeAdminBalance: '余额(管理员调整)', typeConcurrency: '并发(兑换码)', typeAdminConcurrency: '并发(管理员调整)', @@ -2084,7 +2172,13 @@ export default { }, imagePricing: { title: '图片生成计费', - description: '配置图片生成模型的图片生成价格,留空则使用默认价格' + description: '配置图片生成能力和图片基础单价,留空则使用默认价格', + allowImageGeneration: '允许当前分组生图', + independentMultiplier: '生图倍率独立', + imageMultiplier: '生图独立倍率', + modeHint: '默认关闭独立倍率时,图片费用 = 图片价格 × 当前分组有效倍率;开启独立倍率后,图片费用 = 图片价格 × 生图独立倍率。', + finalPricePreview: '最终单张价格预览', + notConfigured: '未配置' }, claudeCode: { title: 'Claude Code 客户端限制', @@ -2303,6 +2397,8 @@ export default { webSearchEmulation: 'Web Search 模拟', webSearchEmulationHint: '⚠️ 开启后该渠道下所有 Anthropic 分组的账号将自动拦截 web_search 请求,请谨慎操作', webSearchEmulationGlobalDisabled: '请先在系统设置 → 网关 → Web Search 模拟中启用全局开关', + codexImageGenerationBridge: 'Codex 图片生成桥接', + codexImageGenerationBridgeHint: '开启后,OpenAI 分组的 Codex /responses 文本请求可能会被自动注入 image_generation 工具。仅在路由账号支持图片生成时开启。', basicSettings: '基础设置', addPlatform: '添加平台', noPlatforms: '点击"添加平台"开始配置渠道', @@ -2325,6 +2421,216 @@ export default { } }, + riskControl: { + title: '风控中心', + description: '配置内容审计策略并查看审核记录', + loadFailed: '加载风控中心失败', + saveFailed: '保存内容审计配置失败', + logsFailed: '加载审核记录失败', + saved: '内容审计配置已保存', + refresh: '刷新', + config: '内容审计配置', + configHint: '调用 OpenAI Moderations 进行请求内容评分,命中阈值后按模式处理。', + openSettings: '内容审计设置', + settingsTitle: '内容审计设置', + refreshStatus: '刷新状态', + records: '审核记录', + recordsHint: '展示命中、拦截、异常和已采样记录。', + saveConfig: '保存内容审计配置', + statusFailed: '加载运行状态失败', + enabled: '开启内容审计', + enabledHint: '关闭后即使风控中心菜单启用,也不会审核网关请求。', + mode: '全局模式', + modePreBlock: '前置拦截', + modePreBlockDesc: '每次请求先同步审核最新用户输入,命中后立即拒绝请求。', + modeObserve: '仅观察', + modeObserveDesc: '请求直接放行,最新用户输入进入异步审核队列;命中后只记录、通知和按规则累计。', + modeOff: '关闭', + modeOffDesc: '不执行内容审计,也不会写入审核记录。', + baseUrl: 'OpenAI Base URL', + model: '模型名', + apiKey: 'OpenAI API Key', + apiKeys: 'OpenAI API Keys', + apiKeyCount: '{count} 个 Key', + apiKeyPlaceholder: '请输入 API Key', + apiKeysPlaceholder: '新增 API Key,每行一个;保存后会追加到已保存 Key', + apiKeysPlaceholderReplace: '覆盖保存 API Key,每行一个;保存后会替换全部已保存 Key', + apiKeysPlaceholderKeep: '新增 API Key,每行一个;保存后会追加到已保存 Key', + apiKeysHint: '当前已保存 {count} 个 Key;输入区只用于新增,保存时会增量追加并自动去重。', + apiKeysWriteMode: '写入方式', + apiKeysModeAppend: '增量添加', + apiKeysModeReplace: '覆盖保存', + apiKeysModeAppendHint: '默认模式:保存时追加输入区 Key,并保留已保存 Key。', + apiKeysModeReplaceHint: '覆盖模式:保存时用输入区 Key 替换全部已保存 Key。', + apiKeysReplaceWarning: '覆盖模式', + apiKeysReplaceNoInput: '覆盖保存至少需要输入 1 个 API Key', + apiKeyPlaceholderKeep: '留空保持不变', + apiKeyWillClear: '保存后清除已配置 Key', + apiKeyConfigured: '已配置', + apiKeyTemporary: '待保存', + apiKeyPendingDelete: '待删除', + apiKeyPendingDeleteCount: '待删除 {count} 个 Key', + deleteApiKey: '删除这个 Key', + undoDeleteApiKey: '撤销删除', + inputApiKeyCount: '输入区 {count} 个 Key', + storedApiKeyCount: '已保存 {count} 个 Key', + testInputApiKeys: '测试输入区 Key', + testStoredApiKeys: '测试已保存 Key', + testContentWithStoredApiKey: '用已保存 Key 试跑内容', + testingApiKeys: '测试中', + apiKeyTestNoInput: '请先输入需要测试的 OpenAI API Key', + apiKeyTestDone: 'Key 测试完成,共 {count} 个', + apiKeyTestFailed: '测试 OpenAI API Key 失败', + apiKeyHealth: 'Key 可用状态', + apiKeyFreezeRule: '400 不冻结;401/403 冻结 10 分钟;429/529 冻结 1 分钟;其他 HTTP 错误冻结 10 秒。', + apiKeyRows: '{count} 个 Key', + apiKeyRowsCollapsed: '已隐藏 {count} 个 Key', + apiKeyRowsExpanded: '正在显示全部 {count} 个 Key', + expandApiKeyRows: '展开', + collapseApiKeyRows: '收起', + apiKeyHealthEmpty: '暂无 Key 状态', + apiKeyHealthEmptyHint: '保存 Key 或测试输入区 Key 后会显示可用性。', + apiKeyStatusOk: '可用', + apiKeyStatusError: '异常', + apiKeyStatusFrozen: '冻结', + apiKeyStatusUnknown: '未测试', + apiKeyFailureCount: '失败 {count} 次', + apiKeyLatency: '{ms} ms', + apiKeyHTTPStatus: 'HTTP {status}', + apiKeyFrozenUntil: '冻结至 {time}', + apiKeyLastChecked: '检查于 {time}', + apiKeyNotTested: '尚未测试', + auditTestInput: '审计试跑输入', + auditTestInputHint: '可填写提示词并上传或粘贴图片;图片以 base64 发送,不会保存文件。', + auditTestPromptPlaceholder: '输入要测试的用户提示词;留空时仅测试 Key 可用性。', + auditTestImages: '测试图片', + auditTestImagesHint: '支持上传、拖拽或粘贴图片,最多 1 张,每张不超过 8MB。', + addAuditTestImage: '添加图片', + clearAuditTest: '清空试跑', + auditTestImageLimit: '最多只能添加 {count} 张测试图片', + auditTestImageTooLarge: '单张测试图片不能超过 8MB', + auditTestImageReadFailed: '读取测试图片失败', + auditTestResult: '审计试跑结果', + auditTestHighest: '最高分类 {category},分数 {score}', + auditTestComposite: '综合评分', + auditTestFlagged: '命中阈值', + auditTestPassed: '未命中', + notConfigured: '未配置', + clearApiKey: '清除已保存 Key', + keepApiKey: '保留已保存 Key', + timeoutMs: 'HTTP 超时 (ms)', + retryCount: '失败重试次数', + sampleRate: '采样率', + recordNonHits: '记录未命中输入', + recordNonHitsHint: '开启后会记录抽样但未命中的请求摘要,摘要会先脱敏再入库。', + preHashCheck: '启用前置哈希比对', + preHashCheckHint: '异步审核命中过的输入哈希会被前置拦截;该拦截不发送邮件,也不累计封禁次数。', + flaggedHashCount: '当前哈希集合数量:{count} 个', + flaggedHashHint: '哈希永久保存在 Redis 集合中;可粘贴完整 64 位哈希删除误拦截项,或一键清空全部风险哈希。', + flaggedHashPlaceholder: '粘贴完整 64 位输入哈希', + deleteFlaggedHash: '删除指定哈希', + clearFlaggedHashes: '一键清空', + clearFlaggedHashesConfirm: '确定要清空全部风险输入哈希吗?此操作不会删除审核记录,但会取消所有历史哈希拦截。', + flaggedHashDeleted: '风险哈希已删除', + flaggedHashNotFound: '该风险哈希不存在', + flaggedHashDeleteFailed: '删除风险哈希失败', + flaggedHashesCleared: '已清空 {count} 个风险哈希', + flaggedHashesClearFailed: '清空风险哈希失败', + workerCount: 'Worker 数', + queueSize: '异步队列大小', + blockStatus: '拦截 HTTP 状态码', + blockMessage: '自定义拦截提示', + emailOnHit: '命中后发送邮件', + emailOnHitHint: '开启后每次达到阈值都会向用户发送风控提醒邮件;自动封禁通知始终发送。', + autoBan: '自动封禁用户', + autoBanHint: '命中次数达到阈值后将禁用用户账号、刷新认证缓存并发送封禁通知邮件。', + banThreshold: '封禁触发次数', + violationWindowHours: '累计窗口(小时)', + hitRetentionDays: '命中记录保留(天)', + nonHitRetentionDays: '未命中记录保留(天,最多 3 天)', + violationCount: '{count} 次', + emailSent: '已发邮件', + emailNotSent: '未发邮件', + autoBanned: '已封禁', + unbanUser: '解封', + unbanSuccess: '用户已解封', + unbanFailed: '解封用户失败', + inputDetailTitle: '输入摘要详情', + inputDetailContent: '完整内容', + queueDelay: '排队 {ms} ms', + allGroups: '全部分组', + allGroupsHint: '当前审计全部分组', + selectedGroupsHint: '当前审计指定分组', + groupScope: '审计分组', + groupScopeHint: '开启右侧开关表示全部分组,关闭后选择指定分组。', + selectedGroups: '指定分组', + searchGroups: '搜索分组名称或平台', + noGroups: '暂无可用分组', + emptyLogs: '暂无审核记录', + workerStatus: 'Worker 运行状态', + workerStatusHint: '异步观察任务的队列和 worker 池状态。', + workerPool: 'Worker 池', + workerPoolMeta: '{active} 个处理中,{idle} 个空闲可用,共 {total} 个', + queueUsage: '队列占用', + activeWorkers: '处理中', + idleWorkers: '空闲可用', + workerActive: '正在处理异步审计任务', + workerIdle: '已启动,当前空闲可用', + workerDisabled: '风控或内容审计未启用', + processed: '已处理', + droppedErrors: '丢弃/异常', + autoRefresh: '每 15 秒自动刷新', + lastCleanup: '上次清理:{time}', + cleanupStats: '上次清理删除命中 {hit} 条,未命中 {nonHit} 条', + riskSwitchOff: '系统开关关闭', + tabs: { + basic: '基础', + scope: '审计范围', + runtime: '运行队列', + response: '命中通知', + retention: '日志保留', + }, + overview: { + status: '运行状态', + enabled: '已启用', + disabled: '未启用', + apiKey: 'API Key', + groupScope: '审计范围', + logs: '审核记录', + currentFilter: '当前筛选结果', + }, + filters: { + search: '按用户/Key/摘要搜索', + from: '开始时间', + to: '结束时间', + allGroups: '全部分组', + allEndpoints: '全部端点', + }, + table: { + time: '时间', + group: '分组', + user: '用户', + apiKey: 'API Key', + endpoint: '端点', + result: '结果', + highest: '最高分', + actionMeta: '处置', + latency: '上游耗时', + input: '输入摘要', + }, + result: { + all: '全部结果', + hit: '命中', + blocked: '已拦截', + pass: '未命中', + error: '异常', + }, + action: { + block: '拦截', + error: '异常', + }, + }, + // Channel Monitor channelMonitor: { title: '渠道监控', @@ -2489,12 +2795,19 @@ export default { user: '用户', group: '订阅分组', validityDays: '有效期(天)', - adjustDays: '调整天数' + adjustDays: '调整天数', + mode: '订阅模式', + modeGroup: '指定分组', + modeWallet: '钱包(用户级)', + modeHint: '指定分组 = 老 v3 订阅;钱包模式 = 用户级共享余额,跨所有分组按倍率扣费', + walletInitialUSD: '初始余额(USD)', + walletInitialHint: '钱包初始余额,单位 USD。例:¥299 套餐对应 1500.00' }, selectUser: '选择用户', selectGroup: '选择订阅分组', groupHint: '仅显示订阅计费类型的分组', validityHint: '订阅的有效天数', + walletInitialRequired: '请填写钱包初始余额(必须大于 0)', adjustingFor: '为以下用户调整订阅', currentExpiration: '当前到期时间', adjustDaysPlaceholder: '正数延长,负数缩短', @@ -3019,6 +3332,18 @@ export default { responsesWebsocketsV2PassthroughHint: '当前已开启自动透传:仅影响 HTTP 透传链路,不影响 WS mode。', codexCLIOnly: '仅允许 Codex 官方客户端', codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。', + codexImageGenerationBridge: 'Codex 图片生成桥接', + codexImageGenerationBridgeDesc: + '账号级策略优先于渠道和全局配置。仅控制 Codex 走 /responses 文本端点时是否注入 image_generation 工具;不影响独立图片生成接口。', + codexImageGenerationBridgeInherit: '跟随渠道', + codexImageGenerationBridgeInheritDesc: '不写入账号覆盖,继续使用渠道或全局策略。', + codexImageGenerationBridgeEnabled: '强制开启', + codexImageGenerationBridgeEnabledDesc: '允许 Codex /responses 请求获得图片工具注入。', + codexImageGenerationBridgeDisabled: '强制关闭', + codexImageGenerationBridgeDisabledDesc: '阻断 Codex /responses 的图片工具注入。', + codexImageGenerationBridgeBadgeInherit: '渠道策略', + codexImageGenerationBridgeBadgeEnabled: '账号开启', + codexImageGenerationBridgeBadgeDisabled: '账号关闭', compactMode: 'Compact 模式', compactModeDesc: '控制本账号在 /responses/compact 调度中的参与方式。Auto 跟随探测结果,Force On 强制允许,Force Off 强制排除。', @@ -3030,7 +3355,8 @@ export default { '仅在 /responses/compact 请求中生效。当上游 compact 端点需要特殊 compact 模型时使用。', compactSupported: '支持 Compact', compactUnsupported: '不支持 Compact', - compactUnknown: 'Compact 未知', + compactAuto: 'Compact Auto', + compactUnknown: 'Compact Auto', compactLastChecked: '最近探测', testMode: '测试模式', testModeDefault: '常规请求', @@ -3063,7 +3389,7 @@ export default { targetNoWildcard: '目标模型不能包含通配符 *', searchModels: '搜索模型...', noMatchingModels: '没有匹配的模型', - fillRelatedModels: '填入相关模型', + fillRelatedModels: '同步最新支持模型', clearAllModels: '清除所有模型', customModelName: '自定义模型名称', enterCustomModelName: '输入自定义模型名称', @@ -4943,6 +5269,7 @@ export default { description: '管理注册、邮箱验证、默认值和 SMTP 设置', tabs: { general: '通用设置', + agreement: '登录条款', features: '功能开关', security: '安全与认证', users: '用户默认值', @@ -4968,6 +5295,13 @@ export default { enabled: '启用可用渠道', enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。', }, + riskControl: { + title: '风控中心', + description: '启用内容审计菜单和全端点请求审核入口。默认关闭。', + configureLink: '前往 风控中心 配置内容审计', + enabled: '启用风控中心', + enabledHint: '关闭后管理员侧边栏入口隐藏,网关内容审计不会执行。', + }, affiliate: { title: '邀请返利', description: '老用户邀请新用户注册,新用户充值后老用户按比例获得返利额度。默认关闭。', @@ -5646,6 +5980,16 @@ export default { saved: '过载冷却设置保存成功', saveFailed: '保存过载冷却设置失败' }, + rateLimit429Cooldown: { + title: '429 默认回避', + description: '配置上游返回 429 且没有明确重置时间时的默认账号回避策略', + enabled: '启用 429 默认回避', + enabledHint: '收到无重置时间的 429 时暂停该账号调度,冷却后自动恢复', + cooldownSeconds: '回避时长(秒)', + cooldownSecondsHint: '默认回避持续时间(1-7200 秒);上游返回明确 reset 时仍优先使用上游时间', + saved: '429 默认回避设置保存成功', + saveFailed: '保存 429 默认回避设置失败' + }, streamTimeout: { title: '流超时处理', description: '配置上游响应超时时的账户处理策略,避免问题账户持续被选中', @@ -6070,7 +6414,24 @@ export default { expiresOn: '{date} 到期', resetIn: '{time} 后重置', windowNotActive: '等待首次使用', - usageOf: '已用 {used} / {limit}' + usageOf: '已用 {used} / {limit}', + wallet: { + title: '钱包余额', + subtitle: '所有 group 共享一笔额度,按倍率扣费', + remaining: '剩余余额', + usedPercent: '已用', + lowWarning: '余额仅剩 ${amount},建议尽快续费', + exhausted: '余额已耗尽,请续费后继续使用', + rateListTitle: '各 group 倍率', + rateListDesc: '不同 group 倍率不同,倍率越高扣费越快', + rateListEmpty: '暂无可用 group', + userOverride: '专属', + userOverrideHint: '管理员为您设置了专属倍率(基础倍率 ×{base})', + routeListTitle: '你的 key 自动路由', + routeListDesc: '请求里的 model 会决定使用哪个 group 和倍率', + routeListEmpty: '暂无可用路由', + routeModel: '调 {model}' + } }, // Onboarding Tour @@ -6492,6 +6853,11 @@ export default { deleteChannelConfirm: '确定要删除此渠道吗?', planName: '套餐名称', planDescription: '套餐描述', + planType: '套餐类型', + planTypeSubscription: '月卡(订阅)', + planTypeCredits: '额度卡(永久)', + planTypeSubscriptionHint: '按有效期付费,到期冻结剩余余额', + planTypeCreditsHint: '一次性购买额度,永久有效;多张额度卡自动叠加到同一钱包', createPlan: '创建套餐', editPlan: '编辑套餐', deletePlan: '删除套餐', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index c6f201678b5..6f979208148 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -68,6 +68,7 @@ const routes: RouteRecordRaw[] = [ { path: '/auth/callback', name: 'OAuthCallback', + alias: '/auth/oauth/callback', component: () => import('@/views/auth/OAuthCallbackView.vue'), meta: { requiresAuth: false, @@ -143,6 +144,15 @@ const routes: RouteRecordRaw[] = [ title: 'Key Usage', } }, + { + path: '/legal/:documentId', + name: 'LegalDocument', + component: () => import('@/views/public/LegalDocumentView.vue'), + meta: { + requiresAuth: false, + title: 'Legal Document' + } + }, // ==================== User Routes ==================== { @@ -505,6 +515,19 @@ const routes: RouteRecordRaw[] = [ descriptionKey: 'admin.settings.description' } }, + { + path: '/admin/risk-control', + name: 'AdminRiskControl', + component: () => import('@/views/admin/RiskControlView.vue'), + meta: { + requiresAuth: true, + requiresAdmin: true, + title: 'Risk Control', + titleKey: 'admin.riskControl.title', + descriptionKey: 'admin.riskControl.description', + requiresRiskControl: true + } + }, { path: '/admin/usage', name: 'AdminUsage', @@ -517,6 +540,46 @@ const routes: RouteRecordRaw[] = [ descriptionKey: 'admin.usage.description' } }, + { + path: '/admin/affiliates', + redirect: '/admin/affiliates/invites' + }, + { + path: '/admin/affiliates/invites', + name: 'AdminAffiliateInvites', + component: () => import('@/views/admin/affiliates/AdminAffiliateInvitesView.vue'), + meta: { + requiresAuth: true, + requiresAdmin: true, + title: 'Affiliate Invite Records', + titleKey: 'nav.affiliateInviteRecords', + descriptionKey: 'admin.affiliates.invitesDescription' + } + }, + { + path: '/admin/affiliates/rebates', + name: 'AdminAffiliateRebates', + component: () => import('@/views/admin/affiliates/AdminAffiliateRebatesView.vue'), + meta: { + requiresAuth: true, + requiresAdmin: true, + title: 'Affiliate Rebate Records', + titleKey: 'nav.affiliateRebateRecords', + descriptionKey: 'admin.affiliates.rebatesDescription' + } + }, + { + path: '/admin/affiliates/transfers', + name: 'AdminAffiliateTransfers', + component: () => import('@/views/admin/affiliates/AdminAffiliateTransfersView.vue'), + meta: { + requiresAuth: true, + requiresAdmin: true, + title: 'Affiliate Transfer Records', + titleKey: 'nav.affiliateTransferRecords', + descriptionKey: 'admin.affiliates.transfersDescription' + } + }, // ==================== Payment Admin Routes ==================== @@ -593,7 +656,7 @@ let authInitialized = false const navigationLoading = useNavigationLoadingState() // 延迟初始化预加载,传入 router 实例 let routePrefetch: ReturnType | null = null -const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/result'] +const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/result', '/legal'] const BACKEND_MODE_CALLBACK_PATHS = [ '/auth/callback', '/auth/linuxdo/callback', @@ -707,6 +770,14 @@ router.beforeEach((to, _from, next) => { } } + if (to.meta.requiresRiskControl) { + const riskControlEnabled = appStore.cachedPublicSettings?.risk_control_enabled === true + if (!riskControlEnabled) { + next(authStore.isAdmin ? '/admin/settings' : '/dashboard') + return + } + } + // 简易模式只限制普通用户自助页面;管理员管理页面保持可访问。 if (authStore.isSimpleMode && !authStore.isAdmin) { const restrictedPaths = [ diff --git a/frontend/src/router/meta.d.ts b/frontend/src/router/meta.d.ts index 7b2777c29de..5c468016457 100644 --- a/frontend/src/router/meta.d.ts +++ b/frontend/src/router/meta.d.ts @@ -49,6 +49,12 @@ declare module 'vue-router' { */ requiresPayment?: boolean + /** + * 是否要求风控中心功能开关已启用 + * @default false + */ + requiresRiskControl?: boolean + /** * i18n key for the page title */ diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 876ab5c0172..4d701b2efb9 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -347,6 +347,8 @@ export const useAppStore = defineStore('app', () => { wechat_oauth_mobile_enabled: false, oidc_oauth_enabled: false, oidc_oauth_provider_name: 'OIDC', + github_oauth_enabled: false, + google_oauth_enabled: false, backend_mode_enabled: false, version: siteVersion.value, balance_low_notify_enabled: false, @@ -355,6 +357,7 @@ export const useAppStore = defineStore('app', () => { channel_monitor_enabled: true, channel_monitor_default_interval_seconds: 60, available_channels_enabled: false, + risk_control_enabled: false, affiliate_enabled: false, } } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 479a8d953e9..8bd4fc4b0e2 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -34,7 +34,7 @@ export interface NotifyEmailEntry { // ==================== User & Auth Types ==================== -export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat' +export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat' | 'github' | 'google' export interface UserAuthBindingStatus { bound?: boolean @@ -168,6 +168,7 @@ export interface CustomMenuItem { label: string icon_svg: string url: string + page_slug?: string visibility: 'user' | 'admin' sort_order: number } @@ -178,6 +179,12 @@ export interface CustomEndpoint { description: string } +export interface LoginAgreementDocument { + id: string + title: string + content_md: string +} + export interface PublicSettings { registration_enabled: boolean email_verify_enabled: boolean @@ -186,6 +193,11 @@ export interface PublicSettings { promo_code_enabled: boolean password_reset_enabled: boolean invitation_code_enabled: boolean + login_agreement_enabled?: boolean + login_agreement_mode?: 'modal' | 'checkbox' | string + login_agreement_updated_at?: string + login_agreement_revision?: string + login_agreement_documents?: LoginAgreementDocument[] turnstile_enabled: boolean turnstile_site_key: string site_name: string @@ -197,6 +209,7 @@ export interface PublicSettings { home_content: string hide_ccs_import_button: boolean payment_enabled: boolean + risk_control_enabled: boolean table_default_page_size: number table_page_size_options: number[] custom_menu_items: CustomMenuItem[] @@ -208,6 +221,8 @@ export interface PublicSettings { wechat_oauth_mobile_enabled?: boolean oidc_oauth_enabled: boolean oidc_oauth_provider_name: string + github_oauth_enabled: boolean + google_oauth_enabled: boolean backend_mode_enabled: boolean version: string balance_low_notify_enabled: boolean @@ -227,6 +242,16 @@ export interface AuthResponse { user: User & { run_mode?: 'standard' | 'simple' } } +export interface ChatBridgeCodeRequest { + redirect_path?: string +} + +export interface ChatBridgeCodeResponse { + code: string + chat_url?: string + expires_in: number +} + export interface CurrentUserResponse extends User { run_mode?: 'standard' | 'simple' } @@ -492,7 +517,10 @@ export interface Group { daily_limit_usd: number | null weekly_limit_usd: number | null monthly_limit_usd: number | null - // 图片生成计费配置(仅 antigravity 平台使用) + // 图片生成计费配置 + allow_image_generation: boolean + image_rate_independent: boolean + image_rate_multiplier: number image_price_1k: number | null image_price_2k: number | null image_price_4k: number | null @@ -602,6 +630,9 @@ export interface CreateGroupRequest { daily_limit_usd?: number | null weekly_limit_usd?: number | null monthly_limit_usd?: number | null + allow_image_generation?: boolean + image_rate_independent?: boolean + image_rate_multiplier?: number image_price_1k?: number | null image_price_2k?: number | null image_price_4k?: number | null @@ -627,6 +658,9 @@ export interface UpdateGroupRequest { daily_limit_usd?: number | null weekly_limit_usd?: number | null monthly_limit_usd?: number | null + allow_image_generation?: boolean + image_rate_independent?: boolean + image_rate_multiplier?: number image_price_1k?: number | null image_price_2k?: number | null image_price_4k?: number | null @@ -1409,7 +1443,9 @@ export interface ChangePasswordRequest { export interface UserSubscription { id: number user_id: number - group_id: number + // 钱包模式 (v4) 下 group_id 为 null(订阅跨 group 共享一个钱包); + // 老的单 group 订阅 (v3) 下必填。 + group_id: number | null status: 'active' | 'expired' | 'revoked' daily_usage_usd: number weekly_usage_usd: number @@ -1417,6 +1453,9 @@ export interface UserSubscription { daily_window_start: string | null weekly_window_start: string | null monthly_window_start: string | null + // 钱包模式字段;老订阅为 undefined / null。 + wallet_balance_usd?: number | null + wallet_initial_usd?: number | null created_at: string updated_at: string expires_at: string | null @@ -1450,8 +1489,11 @@ export interface SubscriptionProgress { export interface AssignSubscriptionRequest { user_id: number - group_id: number + group_id?: number validity_days?: number + notes?: string + /** 钱包模式 (v4):>0 时走钱包路径,group_id 忽略;用户级钱包,与具体 group 解耦 */ + wallet_initial_usd?: number } export interface BulkAssignSubscriptionRequest { diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index 77fbe6891c5..4ef160fdb9d 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -117,6 +117,8 @@ export interface SubscriptionPlan { features: string[] for_sale: boolean sort_order: number + /** 月卡 / 额度卡。subscription = 月卡,validity_days 控时长;credits = 额度卡,永久有效。 */ + plan_type?: 'subscription' | 'credits' } export interface PaymentChannel { diff --git a/frontend/src/utils/__tests__/walletKeys.spec.ts b/frontend/src/utils/__tests__/walletKeys.spec.ts new file mode 100644 index 00000000000..1ff853e234b --- /dev/null +++ b/frontend/src/utils/__tests__/walletKeys.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { + WALLET_KEY_NAME_PREFIX, + WALLET_UNIVERSAL_KEY_NAME, + getCreateKeyGroupId, + isWalletKeyName, + isWalletUniversalKey, + isWalletUniversalKeyName, + shouldRequireGroupForKeySubmit +} from '../walletKeys' + +describe('wallet key helpers', () => { + it('allows a wallet user to create a universal key without selecting a group', () => { + expect(shouldRequireGroupForKeySubmit({ + isEdit: false, + hasActiveWallet: true, + walletAnyKey: true, + groupId: null + })).toBe(false) + expect(getCreateKeyGroupId({ + hasActiveWallet: true, + walletAnyKey: true, + groupId: 3 + })).toBeNull() + }) + + it('still requires a group for legacy key creation and edits', () => { + expect(shouldRequireGroupForKeySubmit({ + isEdit: false, + hasActiveWallet: false, + walletAnyKey: false, + groupId: null + })).toBe(true) + expect(shouldRequireGroupForKeySubmit({ + isEdit: true, + hasActiveWallet: true, + walletAnyKey: true, + groupId: null + })).toBe(true) + }) + + it('marks null-group keys as wallet universal only for active wallet users', () => { + expect(isWalletUniversalKey({ group_id: null }, true)).toBe(true) + expect(isWalletUniversalKey({ group_id: null }, false)).toBe(false) + expect(isWalletUniversalKey({ group_id: 3 }, true)).toBe(false) + }) + + // B2.6:多 key 命名「钱包-{group}」靠 isWalletKeyName 识别。 + it('detects wallet keys by their 钱包- prefix', () => { + expect(WALLET_KEY_NAME_PREFIX).toBe('钱包-') + expect(isWalletKeyName('钱包-gpt-5')).toBe(true) + expect(isWalletKeyName('钱包-claude-sonnet')).toBe(true) + expect(isWalletKeyName('my-key')).toBe(false) + expect(isWalletKeyName('')).toBe(false) + expect(isWalletKeyName(null)).toBe(false) + expect(isWalletKeyName(undefined)).toBe(false) + }) + + // 5/14 反转决策:单 key 模式 universal key 也走 isWalletKeyName 显示徽章。 + it('also matches single-key universal name', () => { + expect(WALLET_UNIVERSAL_KEY_NAME).toBe('钱包通用 key(自动路由)') + expect(isWalletUniversalKeyName(WALLET_UNIVERSAL_KEY_NAME)).toBe(true) + expect(isWalletUniversalKeyName('钱包-gpt-5')).toBe(false) + expect(isWalletUniversalKeyName(null)).toBe(false) + // isWalletKeyName 同时覆盖多 key 前缀和 universal key 全名 + expect(isWalletKeyName(WALLET_UNIVERSAL_KEY_NAME)).toBe(true) + }) +}) diff --git a/frontend/src/utils/featureFlags.ts b/frontend/src/utils/featureFlags.ts index e066869494a..403e7cdc9b9 100644 --- a/frontend/src/utils/featureFlags.ts +++ b/frontend/src/utils/featureFlags.ts @@ -109,6 +109,11 @@ export const FeatureFlags = { mode: 'opt-out', label: 'Payment', }), + riskControl: defineFlag({ + key: 'risk_control_enabled', + mode: 'opt-in', + label: 'Risk Control', + }), affiliate: defineFlag({ key: 'affiliate_enabled', mode: 'opt-in', diff --git a/frontend/src/utils/walletKeys.ts b/frontend/src/utils/walletKeys.ts new file mode 100644 index 00000000000..d7e5c93d43b --- /dev/null +++ b/frontend/src/utils/walletKeys.ts @@ -0,0 +1,60 @@ +/** 钱包模式多 key 的命名前缀,与后端 SubscriptionActivationService 保持一致。 */ +export const WALLET_KEY_NAME_PREFIX = '钱包-' + +/** + * 单 key 模式(5/14 反转决策回归)自动建的钱包通用 key 命名, + * 必须与后端 service.WalletUniversalAPIKeyName 字符串完全一致。 + */ +export const WALLET_UNIVERSAL_KEY_NAME = '钱包通用 key(自动路由)' + +/** 单 key 模式 universal key 名匹配,对应后端 IsWalletUniversalKeyName。 */ +export function isWalletUniversalKeyName(name: string | null | undefined): boolean { + return name === WALLET_UNIVERSAL_KEY_NAME +} + +/** + * 判断一个 api_key.name 是否为钱包模式自动建的 key。 + * 命中两类:多 key 模式「钱包-{group}」前缀 + 单 key 模式「钱包通用 key(自动路由)」全名。 + * 用于在 KeysView 给两类 key 都打绿色「钱包」徽章。 + */ +export function isWalletKeyName(name: string | null | undefined): boolean { + if (typeof name !== 'string') { + return false + } + return name.startsWith(WALLET_KEY_NAME_PREFIX) || isWalletUniversalKeyName(name) +} + +interface GroupSubmitState { + isEdit: boolean + hasActiveWallet: boolean + walletAnyKey: boolean + groupId: number | null +} + +interface CreateKeyGroupState { + hasActiveWallet: boolean + walletAnyKey: boolean + groupId: number | null +} + +interface KeyLike { + group_id: number | null +} + +export function shouldRequireGroupForKeySubmit(state: GroupSubmitState): boolean { + if (!state.isEdit && state.hasActiveWallet && state.walletAnyKey) { + return false + } + return state.groupId === null +} + +export function getCreateKeyGroupId(state: CreateKeyGroupState): number | null { + if (state.hasActiveWallet && state.walletAnyKey) { + return null + } + return state.groupId +} + +export function isWalletUniversalKey(key: KeyLike, hasActiveWallet: boolean): boolean { + return hasActiveWallet && key.group_id === null +} diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 126e4a61fb8..04c376cc1f9 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -196,21 +196,27 @@ - +