From d2585097f38f8e495a0060f9b8ebf395e2139603 Mon Sep 17 00:00:00 2001
From: Tian Lee <498756723@qq.com>
Date: Fri, 17 Jul 2026 14:15:05 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84=E4=B8=8A=E6=B8=B8=20S?=
=?UTF-8?q?ub2API=20=E8=AE=A1=E8=B4=B9=E5=80=8D=E7=8E=87=E6=8E=A2=E6=B5=8B?=
=?UTF-8?q?=E4=B8=8E=E8=B4=A6=E5=8F=B7=E5=B1=95=E7=A4=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../internal/handler/admin/account_handler.go | 7 +-
.../account_handler_mixed_channel_test.go | 19 +++
.../handler/admin/admin_service_stub_test.go | 2 +
backend/internal/repository/account_repo.go | 76 ++++++++-
.../account_repo_sort_integration_test.go | 105 +++++++++++++
...repo_upstream_billing_probe_update_test.go | 40 +++++
backend/internal/service/account_service.go | 1 +
backend/internal/service/admin_account.go | 41 ++++-
...min_account_upstream_billing_probe_test.go | 83 ++++++++++
backend/internal/service/admin_service.go | 2 +
.../service/upstream_billing_probe.go | 25 ++-
.../service/upstream_billing_probe_test.go | 22 ++-
.../account/BulkEditAccountModal.vue | 52 +++++++
.../components/account/CreateAccountModal.vue | 35 ++++-
.../account/UpstreamBillingRateCell.vue | 111 +++++++++----
.../__tests__/BulkEditAccountModal.spec.ts | 67 ++++++++
.../__tests__/CreateAccountModal.spec.ts | 49 +++++-
.../__tests__/UpstreamBillingRateCell.spec.ts | 146 ++++++++++++++++--
frontend/src/components/common/DataTable.vue | 60 +++----
.../common/__tests__/DataTable.spec.ts | 4 +
.../src/i18n/locales/en/admin/accounts.ts | 20 ++-
.../src/i18n/locales/en/admin/settings.ts | 10 ++
.../src/i18n/locales/zh/admin/accounts.ts | 20 ++-
.../src/i18n/locales/zh/admin/settings.ts | 10 ++
frontend/src/types/index.ts | 1 +
frontend/src/views/admin/AccountsView.vue | 141 ++++++++---------
frontend/src/views/admin/SettingsView.vue | 129 ++++++++++++++++
.../__tests__/AccountsView.bulkEdit.spec.ts | 146 +++++++++++++++++-
.../AccountsView.usageWindowsHint.spec.ts | 2 +
.../admin/__tests__/SettingsView.spec.ts | 68 ++++++++
30 files changed, 1311 insertions(+), 183 deletions(-)
diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go
index d9b742003f..98adcf5142 100644
--- a/backend/internal/handler/admin/account_handler.go
+++ b/backend/internal/handler/admin/account_handler.go
@@ -121,6 +121,7 @@ type CreateAccountRequest struct {
GroupIDs []int64 `json:"group_ids"`
ExpiresAt *int64 `json:"expires_at"`
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
+ ProbeEnabled *bool `json:"upstream_billing_probe_enabled"`
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
}
@@ -159,6 +160,7 @@ type BulkUpdateAccountsRequest struct {
GroupIDs *[]int64 `json:"group_ids"`
Credentials map[string]any `json:"credentials"`
Extra map[string]any `json:"extra"`
+ ProbeEnabled *bool `json:"upstream_billing_probe_enabled"`
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
}
@@ -828,6 +830,7 @@ func (h *AccountHandler) Create(c *gin.Context) {
GroupIDs: req.GroupIDs,
ExpiresAt: req.ExpiresAt,
AutoPauseOnExpired: req.AutoPauseOnExpired,
+ ProbeEnabled: req.ProbeEnabled,
SkipMixedChannelCheck: skipCheck,
})
if execErr != nil {
@@ -1907,7 +1910,8 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
req.Schedulable != nil ||
req.GroupIDs != nil ||
len(req.Credentials) > 0 ||
- len(req.Extra) > 0
+ len(req.Extra) > 0 ||
+ req.ProbeEnabled != nil
if !hasUpdates {
response.BadRequest(c, "No updates provided")
@@ -1928,6 +1932,7 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
GroupIDs: req.GroupIDs,
Credentials: req.Credentials,
Extra: req.Extra,
+ ProbeEnabled: req.ProbeEnabled,
SkipMixedChannelCheck: skipCheck,
})
if err != nil {
diff --git a/backend/internal/handler/admin/account_handler_mixed_channel_test.go b/backend/internal/handler/admin/account_handler_mixed_channel_test.go
index a1dc25124e..bc4a3cf49b 100644
--- a/backend/internal/handler/admin/account_handler_mixed_channel_test.go
+++ b/backend/internal/handler/admin/account_handler_mixed_channel_test.go
@@ -222,3 +222,22 @@ func TestBulkUpdateAcceptsFilterTargetRequest(t *testing.T) {
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, float64(0), resp["code"])
}
+
+func TestBulkUpdateAcceptsDedicatedUpstreamBillingProbeSetting(t *testing.T) {
+ adminSvc := newStubAdminService()
+ router := setupAccountMixedChannelRouter(adminSvc)
+
+ body, _ := json.Marshal(map[string]any{
+ "account_ids": []int64{1, 2},
+ "upstream_billing_probe_enabled": false,
+ })
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/bulk-update", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ require.NotNil(t, adminSvc.lastBulkUpdateAccountInput)
+ require.NotNil(t, adminSvc.lastBulkUpdateAccountInput.ProbeEnabled)
+ require.False(t, *adminSvc.lastBulkUpdateAccountInput.ProbeEnabled)
+}
diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go
index f5c238a61e..035e171c9b 100644
--- a/backend/internal/handler/admin/admin_service_stub_test.go
+++ b/backend/internal/handler/admin/admin_service_stub_test.go
@@ -33,6 +33,7 @@ type stubAdminService struct {
createSparkShadowErr error
updateAccountErr error
bulkUpdateAccountErr error
+ lastBulkUpdateAccountInput *service.BulkUpdateAccountsInput
getAccountResult *service.Account
updateAccountCalls int
updateAccountExtraCalls int
@@ -478,6 +479,7 @@ func (s *stubAdminService) SetAccountSchedulable(ctx context.Context, id int64,
}
func (s *stubAdminService) BulkUpdateAccounts(ctx context.Context, input *service.BulkUpdateAccountsInput) (*service.BulkUpdateAccountsResult, error) {
+ s.lastBulkUpdateAccountInput = input
if s.bulkUpdateAccountErr != nil {
return nil, s.bulkUpdateAccountErr
}
diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go
index f014155bb2..52c19e143f 100644
--- a/backend/internal/repository/account_repo.go
+++ b/backend/internal/repository/account_repo.go
@@ -893,6 +893,20 @@ func (r *accountRepository) ListOpsAccountsForStats(ctx context.Context, platfor
func accountListOrder(params pagination.PaginationParams) []func(*entsql.Selector) {
sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
sortOrder := params.NormalizedSortOrder(pagination.SortOrderAsc)
+ if sortBy == "upstream_billing_rate" {
+ direction := "ASC"
+ tieOrder := entsql.Asc
+ if sortOrder == pagination.SortOrderDesc {
+ direction = "DESC"
+ tieOrder = entsql.Desc
+ }
+ return []func(*entsql.Selector){func(s *entsql.Selector) {
+ extra := s.C(dbaccount.FieldExtra)
+ expression := upstreamBillingRateSortExpression(extra)
+ s.OrderExpr(entsql.Expr(expression + " " + direction + " NULLS LAST"))
+ s.OrderBy(tieOrder(s.C(dbaccount.FieldID)))
+ }}
+ }
field := dbaccount.FieldName
defaultOrder := true
@@ -934,6 +948,40 @@ func accountListOrder(params pagination.PaginationParams) []func(*entsql.Selecto
return []func(*entsql.Selector){dbent.Asc(field), dbent.Asc(dbaccount.FieldID)}
}
+func upstreamBillingRateSortExpression(extra string) string {
+ status := extra + " #>> '{upstream_billing_probe,status}'"
+ effectiveJSON := extra + " #> '{upstream_billing_probe,data,effective_rate_multiplier}'"
+ effective := extra + " #>> '{upstream_billing_probe,data,effective_rate_multiplier}'"
+ resolvedJSON := extra + " #> '{upstream_billing_probe,data,resolved_rate_multiplier}'"
+ resolved := extra + " #>> '{upstream_billing_probe,data,resolved_rate_multiplier}'"
+ peakEnabledJSON := extra + " #> '{upstream_billing_probe,data,peak_rate_enabled}'"
+ peakEnabled := extra + " #>> '{upstream_billing_probe,data,peak_rate_enabled}'"
+ peakStart := extra + " #>> '{upstream_billing_probe,data,peak_start}'"
+ peakEnd := extra + " #>> '{upstream_billing_probe,data,peak_end}'"
+ peakMultiplierJSON := extra + " #> '{upstream_billing_probe,data,peak_rate_multiplier}'"
+ peakMultiplier := extra + " #>> '{upstream_billing_probe,data,peak_rate_multiplier}'"
+ peakMultiplierValue := "(CASE WHEN jsonb_typeof(" + peakMultiplierJSON + ") = 'number' THEN (" + peakMultiplier + ")::numeric END)"
+ billingScope := extra + " #>> '{upstream_billing_probe,data,billing_scope}'"
+ timezone := extra + " #>> '{upstream_billing_probe,data,timezone}'"
+ validClock := "'^([01][0-9]|2[0-3]):[0-5][0-9]$'"
+ startMinute := "(CASE WHEN " + peakStart + " ~ " + validClock + " THEN split_part(" + peakStart + ", ':', 1)::numeric * 60 + split_part(" + peakStart + ", ':', 2)::numeric END)"
+ endMinute := "(CASE WHEN " + peakEnd + " ~ " + validClock + " THEN split_part(" + peakEnd + ", ':', 1)::numeric * 60 + split_part(" + peakEnd + ", ':', 2)::numeric END)"
+ localMinute := "(EXTRACT(HOUR FROM (CURRENT_TIMESTAMP AT TIME ZONE (" + timezone + "))) * 60 + EXTRACT(MINUTE FROM (CURRENT_TIMESTAMP AT TIME ZONE (" + timezone + "))))"
+ validPeakWindow := peakStart + " ~ " + validClock + " AND " +
+ peakEnd + " ~ " + validClock + " AND " +
+ startMinute + " < " + endMinute
+ validPeakConfig := validPeakWindow + " AND " + peakMultiplierValue + " >= 0 AND " +
+ "EXISTS (SELECT 1 FROM pg_timezone_names WHERE name = " + timezone + ")"
+ dynamicRate := "CASE WHEN " + peakEnabled + " = 'false' THEN (" + resolved + ")::numeric WHEN " + peakEnabled + " = 'true' AND " + validPeakConfig +
+ " THEN (" + resolved + ")::numeric * CASE WHEN " + localMinute + " >= " + startMinute + " AND " + localMinute + " < " + endMinute +
+ " THEN " + peakMultiplierValue + " ELSE 1 END ELSE NULL END"
+ legacySnapshot := "jsonb_typeof(" + resolvedJSON + ") IS NULL AND jsonb_typeof(" + peakEnabledJSON + ") IS NULL"
+
+ return "CASE WHEN " + status + " IN ('ok', 'failed') AND (jsonb_typeof(" + resolvedJSON + ") = 'number' OR jsonb_typeof(" + effectiveJSON + ") = 'number') THEN CASE WHEN jsonb_typeof(" +
+ resolvedJSON + ") = 'number' AND jsonb_typeof(" + peakEnabledJSON + ") = 'boolean' THEN CASE WHEN " + billingScope + " = 'token' THEN " + dynamicRate + " ELSE NULL END WHEN " + legacySnapshot +
+ " AND jsonb_typeof(" + effectiveJSON + ") = 'number' THEN (" + effective + ")::numeric END END"
+}
+
func (r *accountRepository) ListByGroup(ctx context.Context, groupID int64) ([]service.Account, error) {
accounts, err := r.queryAccountsByGroup(ctx, groupID, accountGroupQueryOptions{
status: service.StatusActive,
@@ -2575,6 +2623,12 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
args = append(args, *updates.Schedulable)
idx++
}
+ if updates.ProbeEnabled != nil {
+ if updates.Extra == nil {
+ updates.Extra = make(map[string]any)
+ }
+ updates.Extra[service.UpstreamBillingProbeEnabledExtraKey] = *updates.ProbeEnabled
+ }
// JSONB 需要合并而非覆盖,使用 raw SQL 保持旧行为。
if len(updates.Credentials) > 0 {
payload, err := json.Marshal(updates.Credentials)
@@ -2605,8 +2659,14 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
setClauses = append(setClauses, "updated_at = NOW()")
- query := "UPDATE accounts SET " + joinClauses(setClauses, ", ") + " WHERE id = ANY($" + itoa(idx) + ") AND deleted_at IS NULL"
+ whereClause := " WHERE id = ANY($" + itoa(idx) + ") AND deleted_at IS NULL"
args = append(args, pq.Array(ids))
+ idx++
+ if updates.ProbeEnabled != nil {
+ whereClause += " AND platform = $" + itoa(idx) + " AND type = $" + itoa(idx+1)
+ args = append(args, service.PlatformOpenAI, service.AccountTypeAPIKey)
+ }
+ query := "UPDATE accounts SET " + joinClauses(setClauses, ", ") + whereClause
baseCtx := ctx
contextTx := dbent.TxFromContext(ctx)
@@ -2635,6 +2695,20 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
if err != nil {
return 0, err
}
+ if updates.ProbeEnabled != nil {
+ expectedRows := int64(0)
+ seenIDs := make(map[int64]struct{}, len(ids))
+ for _, id := range ids {
+ if _, seen := seenIDs[id]; seen {
+ continue
+ }
+ seenIDs[id] = struct{}{}
+ expectedRows++
+ }
+ if rows != expectedRows {
+ return 0, service.ErrUpstreamBillingProbeAccountInvalid
+ }
+ }
if rows > 0 {
payload := map[string]any{"account_ids": ids}
if err := enqueueSchedulerOutbox(ctx, exec, service.SchedulerOutboxEventAccountBulkChanged, nil, nil, payload); err != nil {
diff --git a/backend/internal/repository/account_repo_sort_integration_test.go b/backend/internal/repository/account_repo_sort_integration_test.go
index 098dde7bbd..19c974f37f 100644
--- a/backend/internal/repository/account_repo_sort_integration_test.go
+++ b/backend/internal/repository/account_repo_sort_integration_test.go
@@ -3,6 +3,9 @@
package repository
import (
+ "fmt"
+ "time"
+
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
)
@@ -33,3 +36,105 @@ func (s *AccountRepoSuite) TestListWithFilters_SortByPriorityDesc() {
s.Require().Equal("high-priority", accounts[0].Name)
s.Require().Equal("low-priority", accounts[1].Name)
}
+
+func (s *AccountRepoSuite) TestListWithFilters_SortByUpstreamBillingRateWithMissingLast() {
+ makeAccount := func(name, status string, rate any) {
+ extra := map[string]any{}
+ if rate != nil {
+ extra[service.UpstreamBillingProbeExtraKey] = map[string]any{
+ "status": status,
+ "data": map[string]any{"effective_rate_multiplier": rate},
+ }
+ }
+ mustCreateAccount(s.T(), s.client, &service.Account{Name: name, Extra: extra})
+ }
+ makeAccount("high-rate", service.UpstreamBillingProbeStatusOK, 0.8)
+ makeAccount("low-rate", service.UpstreamBillingProbeStatusOK, 0.03)
+ makeAccount("missing-rate", "", nil)
+ makeAccount("unsupported-with-retained-rate", service.UpstreamBillingProbeStatusUnsupported, 0.01)
+
+ for _, tc := range []struct {
+ order string
+ want []string
+ }{
+ {order: "asc", want: []string{"low-rate", "high-rate", "missing-rate", "unsupported-with-retained-rate"}},
+ {order: "desc", want: []string{"high-rate", "low-rate", "unsupported-with-retained-rate", "missing-rate"}},
+ } {
+ accounts, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{
+ Page: 1, PageSize: 10, SortBy: "upstream_billing_rate", SortOrder: tc.order,
+ }, "", "", "", "", 0, "")
+ s.Require().NoError(err)
+ s.Require().Len(accounts, 4)
+ for i, name := range tc.want {
+ s.Require().Equal(name, accounts[i].Name)
+ }
+ }
+}
+
+func (s *AccountRepoSuite) TestListWithFilters_SortByCurrentUpstreamBillingRateDuringPeak() {
+ now := time.Now()
+ locations := []string{"UTC", "Asia/Shanghai", "America/New_York", "Europe/London"}
+ var timezone string
+ var minute int
+ for _, name := range locations {
+ location, err := time.LoadLocation(name)
+ s.Require().NoError(err)
+ local := now.In(location)
+ candidate := local.Hour()*60 + local.Minute()
+ if candidate >= 2 && candidate <= 1436 {
+ timezone = name
+ minute = candidate
+ break
+ }
+ }
+ s.Require().NotEmpty(timezone)
+
+ peakStart := fmt.Sprintf("%02d:%02d", (minute-2)/60, (minute-2)%60)
+ peakEnd := fmt.Sprintf("%02d:%02d", (minute+3)/60, (minute+3)%60)
+ mustCreateAccount(s.T(), s.client, &service.Account{
+ Name: "current-peak-rate",
+ Extra: map[string]any{
+ service.UpstreamBillingProbeExtraKey: map[string]any{
+ "status": service.UpstreamBillingProbeStatusOK,
+ "data": map[string]any{
+ "billing_scope": "token",
+ "resolved_rate_multiplier": 1.0,
+ "effective_rate_multiplier": 1.0,
+ "peak_rate_enabled": true,
+ "peak_start": peakStart,
+ "peak_end": peakEnd,
+ "peak_rate_multiplier": 10.0,
+ "timezone": timezone,
+ },
+ },
+ },
+ })
+ mustCreateAccount(s.T(), s.client, &service.Account{
+ Name: "current-off-peak-rate",
+ Extra: map[string]any{
+ service.UpstreamBillingProbeExtraKey: map[string]any{
+ "status": service.UpstreamBillingProbeStatusOK,
+ "data": map[string]any{
+ "effective_rate_multiplier": 5.0,
+ },
+ },
+ },
+ })
+
+ for _, tc := range []struct {
+ order string
+ want []string
+ }{
+ {order: "asc", want: []string{"current-off-peak-rate", "current-peak-rate"}},
+ {order: "desc", want: []string{"current-peak-rate", "current-off-peak-rate"}},
+ } {
+ accounts, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{
+ Page: 1, PageSize: 10, SortBy: "upstream_billing_rate", SortOrder: tc.order,
+ }, "", "", "", "", 0, "")
+ s.Require().NoError(err)
+ s.Require().Len(accounts, 2)
+ for i, name := range tc.want {
+ s.Require().Equal(name, accounts[i].Name)
+ }
+ }
+}
diff --git a/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go b/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go
index 26fbb74dcf..2a78f38c10 100644
--- a/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go
+++ b/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go
@@ -163,6 +163,46 @@ func TestBulkUpdateNilProbeRemovesKeyInsteadOfWritingJSONNull(t *testing.T) {
require.Contains(t, normalizeSQLWhitespace(exec.execQueries[0]), "- 'upstream_billing_probe'")
}
+func TestBulkUpdateDisablingProbeRemovesSnapshot(t *testing.T) {
+ exec := &recordingSQLExecutor{result: rowsAffectedResult(1)}
+ repo := newAccountRepositoryWithSQL(nil, exec, nil)
+
+ _, err := repo.BulkUpdate(context.Background(), []int64{27}, service.AccountBulkUpdate{
+ Extra: map[string]any{service.UpstreamBillingProbeEnabledExtraKey: false},
+ })
+
+ require.NoError(t, err)
+ require.NotEmpty(t, exec.execQueries)
+ require.Contains(t, normalizeSQLWhitespace(exec.execQueries[0]), "- 'upstream_billing_probe'")
+ payload, ok := exec.execArgs[0][0].([]byte)
+ require.True(t, ok)
+ require.Equal(t, `{"upstream_billing_probe_enabled":false}`, string(payload))
+}
+
+func TestBulkUpdateProbeEligibilityMismatchRollsBack(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+ client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db)))
+ t.Cleanup(func() { _ = client.Close() })
+
+ enabled := true
+ mock.ExpectBegin()
+ mock.ExpectExec(`(?s)UPDATE accounts SET extra = .* WHERE id = ANY\(\$2\) AND deleted_at IS NULL AND platform = \$3 AND type = \$4`).
+ WithArgs(sqlmock.AnyArg(), `{27,28}`, service.PlatformOpenAI, service.AccountTypeAPIKey).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectRollback()
+
+ repo := newAccountRepositoryWithSQL(client, db, nil)
+ rows, err := repo.BulkUpdate(context.Background(), []int64{27, 28}, service.AccountBulkUpdate{
+ ProbeEnabled: &enabled,
+ })
+
+ require.ErrorIs(t, err, service.ErrUpstreamBillingProbeAccountInvalid)
+ require.Zero(t, rows)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
func TestUpdateCredentialsAtomicallyClearsProbeForOpenAIAPIKeyIdentityChange(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go
index b7fe0a68cc..d1a57e6d25 100644
--- a/backend/internal/service/account_service.go
+++ b/backend/internal/service/account_service.go
@@ -144,6 +144,7 @@ type AccountBulkUpdate struct {
Schedulable *bool
Credentials map[string]any
Extra map[string]any
+ ProbeEnabled *bool
}
// CreateAccountRequest 创建账号请求
diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go
index 899679221b..ae3a9da367 100644
--- a/backend/internal/service/admin_account.go
+++ b/backend/internal/service/admin_account.go
@@ -469,6 +469,15 @@ func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]an
Status: StatusActive,
Schedulable: true,
}
+ if input.ProbeEnabled != nil && *input.ProbeEnabled {
+ if !isUpstreamBillingProbeAccount(account) {
+ return nil, ErrUpstreamBillingProbeAccountInvalid
+ }
+ if account.Extra == nil {
+ account.Extra = make(map[string]any)
+ }
+ account.Extra[UpstreamBillingProbeEnabledExtraKey] = true
+ }
// 预计算固定时间重置的下次重置时间
if account.Extra != nil {
if err := ValidateQuotaResetConfig(account.Extra); err != nil {
@@ -842,7 +851,7 @@ func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, upd
// BulkUpdateAccounts updates multiple accounts in one request.
// It merges credentials/extra keys instead of overwriting the whole object.
func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) {
- // Probe state is updated only through its dedicated endpoints.
+ // Managed probe state may only enter through the dedicated typed field below.
delete(input.Extra, UpstreamBillingProbeEnabledExtraKey)
delete(input.Extra, UpstreamBillingProbeExtraKey)
@@ -874,13 +883,30 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
// 预取所有目标账号,供凭据守卫/代理守卫/混合渠道检查共用,避免多次 DB 查询。
var cachedTargets []*Account
- if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck || hasLongContextBillingUpdate {
+ if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck || hasLongContextBillingUpdate || input.ProbeEnabled != nil {
loaded, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs)
if err != nil {
return nil, err
}
cachedTargets = loaded
}
+ if input.ProbeEnabled != nil {
+ targetsByID := make(map[int64]*Account, len(cachedTargets))
+ for _, account := range cachedTargets {
+ if account != nil {
+ targetsByID[account.ID] = account
+ }
+ }
+ for _, accountID := range input.AccountIDs {
+ account, ok := targetsByID[accountID]
+ if !ok {
+ return nil, ErrAccountNotFound
+ }
+ if !isUpstreamBillingProbeAccount(account) {
+ return nil, ErrUpstreamBillingProbeAccountInvalid
+ }
+ }
+ }
if hasLongContextBillingUpdate {
for _, account := range cachedTargets {
if account == nil || account.Platform != PlatformOpenAI {
@@ -952,8 +978,15 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
// Prepare bulk updates for columns and JSONB fields.
repoUpdates := AccountBulkUpdate{
- Credentials: input.Credentials,
- Extra: input.Extra,
+ Credentials: input.Credentials,
+ Extra: input.Extra,
+ ProbeEnabled: input.ProbeEnabled,
+ }
+ if input.ProbeEnabled != nil {
+ if repoUpdates.Extra == nil {
+ repoUpdates.Extra = make(map[string]any)
+ }
+ repoUpdates.Extra[UpstreamBillingProbeEnabledExtraKey] = *input.ProbeEnabled
}
if updatesUpstreamBillingProbeIdentity(input.Credentials) || input.ProxyID != nil {
if repoUpdates.Extra == nil {
diff --git a/backend/internal/service/admin_account_upstream_billing_probe_test.go b/backend/internal/service/admin_account_upstream_billing_probe_test.go
index 727a66cf12..88db653861 100644
--- a/backend/internal/service/admin_account_upstream_billing_probe_test.go
+++ b/backend/internal/service/admin_account_upstream_billing_probe_test.go
@@ -38,6 +38,32 @@ func TestCreateAccountDropsManagedUpstreamBillingProbeState(t *testing.T) {
require.NotContains(t, created.Extra, UpstreamBillingProbeExtraKey)
}
+func TestCreateAccountAcceptsDedicatedUpstreamBillingProbeSetting(t *testing.T) {
+ enabled := true
+ repo := &upstreamBillingProbeAccountRepo{}
+ created, err := (&adminServiceImpl{accountRepo: repo}).CreateAccount(context.Background(), &CreateAccountInput{
+ Name: "upstream",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Credentials: map[string]any{"api_key": "sk-test"},
+ ProbeEnabled: &enabled,
+ SkipDefaultGroupBind: true,
+ })
+
+ require.NoError(t, err)
+ require.Equal(t, true, created.Extra[UpstreamBillingProbeEnabledExtraKey])
+
+ _, err = (&adminServiceImpl{accountRepo: repo}).CreateAccount(context.Background(), &CreateAccountInput{
+ Name: "oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Credentials: map[string]any{"access_token": "token"},
+ ProbeEnabled: &enabled,
+ SkipDefaultGroupBind: true,
+ })
+ require.ErrorIs(t, err, ErrUpstreamBillingProbeAccountInvalid)
+}
+
func TestUpdateAccountPreservesManagedUpstreamBillingProbeStateForUnrelatedEdit(t *testing.T) {
accountID := int64(110)
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
@@ -360,6 +386,63 @@ func TestBulkUpdateAccountsDropsManagedUpstreamBillingProbeState(t *testing.T) {
require.NotContains(t, repo.bulkUpdates[0].Extra, UpstreamBillingProbeExtraKey)
}
+func TestBulkUpdateAccountsAcceptsDedicatedUpstreamBillingProbeSetting(t *testing.T) {
+ for _, enabled := range []bool{true, false} {
+ t.Run(map[bool]string{true: "enable", false: "disable"}[enabled], func(t *testing.T) {
+ repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
+ 1: {ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
+ 2: {ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
+ }}
+
+ result, err := (&adminServiceImpl{accountRepo: repo}).BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
+ AccountIDs: []int64{1, 2},
+ ProbeEnabled: &enabled,
+ })
+
+ require.NoError(t, err)
+ require.Equal(t, 2, result.Success)
+ require.Len(t, repo.bulkUpdates, 1)
+ require.Equal(t, enabled, repo.bulkUpdates[0].Extra[UpstreamBillingProbeEnabledExtraKey])
+ require.NotNil(t, repo.bulkUpdates[0].ProbeEnabled)
+ require.Equal(t, enabled, *repo.bulkUpdates[0].ProbeEnabled)
+ })
+ }
+}
+
+func TestBulkUpdateAccountsRejectsProbeSettingForIneligibleTargetBeforeWrite(t *testing.T) {
+ for _, enabled := range []bool{true, false} {
+ t.Run(map[bool]string{true: "enable", false: "disable"}[enabled], func(t *testing.T) {
+ repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
+ 1: {ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
+ 2: {ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth},
+ }}
+
+ _, err := (&adminServiceImpl{accountRepo: repo}).BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
+ AccountIDs: []int64{1, 2},
+ ProbeEnabled: &enabled,
+ })
+
+ require.ErrorIs(t, err, ErrUpstreamBillingProbeAccountInvalid)
+ require.Empty(t, repo.bulkUpdates)
+ })
+ }
+}
+
+func TestBulkUpdateAccountsRejectsProbeSettingWhenTargetIsMissing(t *testing.T) {
+ enabled := true
+ repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
+ 1: {ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
+ }}
+
+ _, err := (&adminServiceImpl{accountRepo: repo}).BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
+ AccountIDs: []int64{1, 2},
+ ProbeEnabled: &enabled,
+ })
+
+ require.ErrorIs(t, err, ErrAccountNotFound)
+ require.Empty(t, repo.bulkUpdates)
+}
+
func TestBulkUpdateAccountsInvalidatesProbeSnapshotForIdentityCredentials(t *testing.T) {
repo := &upstreamBillingProbeAccountRepo{}
input := &BulkUpdateAccountsInput{
diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go
index c167b6be84..013cb04049 100644
--- a/backend/internal/service/admin_service.go
+++ b/backend/internal/service/admin_service.go
@@ -329,6 +329,7 @@ type CreateAccountInput struct {
GroupIDs []int64
ExpiresAt *int64
AutoPauseOnExpired *bool
+ ProbeEnabled *bool
// SkipDefaultGroupBind prevents auto-binding to platform default group when GroupIDs is empty.
SkipDefaultGroupBind bool
// SkipMixedChannelCheck skips the mixed channel risk check when binding groups.
@@ -378,6 +379,7 @@ type BulkUpdateAccountsInput struct {
GroupIDs *[]int64
Credentials map[string]any
Extra map[string]any
+ ProbeEnabled *bool
// SkipMixedChannelCheck skips the mixed channel risk check when binding groups.
// This should only be set when the caller has explicitly confirmed the risk.
SkipMixedChannelCheck bool
diff --git a/backend/internal/service/upstream_billing_probe.go b/backend/internal/service/upstream_billing_probe.go
index 70dbf8f8f2..d5c28e68b6 100644
--- a/backend/internal/service/upstream_billing_probe.go
+++ b/backend/internal/service/upstream_billing_probe.go
@@ -38,7 +38,7 @@ const (
upstreamBillingProbeMaxBodyBytes = 64 * 1024
upstreamBillingProbeMaxPerCycle = 20
upstreamBillingProbeConcurrency = 4
- upstreamBillingProbeMaxBackoff = 24 * time.Hour
+ upstreamBillingProbeMaxDelay = 24 * time.Hour
upstreamBillingProbeLeaderLockKey = "upstream:billing:probe:leader"
upstreamBillingProbeLeaderLockTTL = 2 * time.Minute
)
@@ -620,7 +620,7 @@ func (s *UpstreamBillingProbeService) probeLoadedAccount(ctx context.Context, ac
ReceivedAt: probeTimePtr(now),
FreshUntil: probeTimePtr(now.Add(2 * time.Duration(intervalMinutes) * time.Minute)),
LastAttemptAt: now,
- NextProbeAt: now.Add(nextProbeDelay(intervalMinutes, 0, 0)),
+ NextProbeAt: now.Add(nextProbeDelay(intervalMinutes, 0)),
HTTPStatus: resp.StatusCode,
}
if err := s.updateSnapshot(ctx, account, snapshot); err != nil {
@@ -650,7 +650,7 @@ func (s *UpstreamBillingProbeService) persistProbeFailure(
snapshot := &UpstreamBillingProbeSnapshot{
Status: status,
LastAttemptAt: now,
- NextProbeAt: now.Add(nextProbeDelay(intervalMinutes, failureCount, retryAfterDuration)),
+ NextProbeAt: now.Add(nextProbeDelay(intervalMinutes, retryAfterDuration)),
FailureCount: failureCount,
HTTPStatus: statusCode,
LastError: reason,
@@ -859,20 +859,13 @@ func (s *UpstreamBillingProbeService) currentTime() time.Time {
return time.Now()
}
-func nextProbeDelay(intervalMinutes, failureCount int, retryAfterDuration time.Duration) time.Duration {
+func nextProbeDelay(intervalMinutes int, retryAfterDuration time.Duration) time.Duration {
interval := time.Duration(intervalMinutes) * time.Minute
if interval < upstreamBillingProbeMinIntervalMinutes*time.Minute {
interval = upstreamBillingProbeMinIntervalMinutes * time.Minute
}
- if failureCount > 0 {
- shift := failureCount
- if shift > 5 {
- shift = 5
- }
- interval *= time.Duration(1 << shift)
- }
- if interval > upstreamBillingProbeMaxBackoff {
- interval = upstreamBillingProbeMaxBackoff
+ if interval > upstreamBillingProbeMaxDelay {
+ interval = upstreamBillingProbeMaxDelay
}
jitterRange := interval / 5
if jitterRange > 5*time.Minute {
@@ -883,11 +876,11 @@ func nextProbeDelay(intervalMinutes, failureCount int, retryAfterDuration time.D
}
if retryAfterDuration > interval {
// Retry-After is an explicit upstream instruction; do not shorten it
- // with the local exponential-backoff ceiling.
+ // with the local maximum delay.
return retryAfterDuration
}
- if interval > upstreamBillingProbeMaxBackoff {
- return upstreamBillingProbeMaxBackoff
+ if interval > upstreamBillingProbeMaxDelay {
+ return upstreamBillingProbeMaxDelay
}
return interval
}
diff --git a/backend/internal/service/upstream_billing_probe_test.go b/backend/internal/service/upstream_billing_probe_test.go
index 0ffcc6006e..eecd866a90 100644
--- a/backend/internal/service/upstream_billing_probe_test.go
+++ b/backend/internal/service/upstream_billing_probe_test.go
@@ -495,7 +495,7 @@ func TestUpstreamBillingProbeFailurePreservesLastSuccessAndRetryAfter(t *testing
}
func TestUpstreamBillingProbeRetryAfterIsNotShortened(t *testing.T) {
- delay := nextProbeDelay(30, 1, 48*time.Hour)
+ delay := nextProbeDelay(30, 48*time.Hour)
require.Equal(t, 48*time.Hour, delay)
}
@@ -510,11 +510,21 @@ func TestUpstreamBillingProbeEmptyResponseIsPersistedAsFailure(t *testing.T) {
}
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}}
svc := newUpstreamBillingProbeTestService(repo, &httpUpstreamRecorder{}, &upstreamBillingProbeSettingRepo{})
+ fixedNow := time.Date(2026, time.July, 13, 2, 0, 0, 0, time.UTC)
+ svc.now = func() time.Time { return fixedNow }
snapshot, err := svc.ProbeAccount(context.Background(), account.ID)
require.NoError(t, err)
require.Equal(t, UpstreamBillingProbeStatusFailed, snapshot.Status)
require.Equal(t, "empty_response", snapshot.LastError)
+ require.False(t, snapshot.NextProbeAt.Before(fixedNow.Add(24*time.Minute)))
+ require.False(t, snapshot.NextProbeAt.After(fixedNow.Add(36*time.Minute)))
+
+ snapshot, err = svc.ProbeAccount(context.Background(), account.ID)
+ require.NoError(t, err)
+ require.Equal(t, 2, snapshot.FailureCount)
+ require.False(t, snapshot.NextProbeAt.Before(fixedNow.Add(24*time.Minute)))
+ require.False(t, snapshot.NextProbeAt.After(fixedNow.Add(36*time.Minute)))
}
func TestUpstreamBillingProbeUnsupportedAndAccountToggle(t *testing.T) {
@@ -533,6 +543,8 @@ func TestUpstreamBillingProbeUnsupportedAndAccountToggle(t *testing.T) {
Body: io.NopCloser(strings.NewReader("not found")),
}}
svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{})
+ fixedNow := time.Date(2026, time.July, 13, 2, 0, 0, 0, time.UTC)
+ svc.now = func() time.Time { return fixedNow }
require.NoError(t, svc.SetAccountEnabled(context.Background(), account.ID, true))
require.Equal(t, true, account.Extra[UpstreamBillingProbeEnabledExtraKey])
@@ -540,6 +552,14 @@ func TestUpstreamBillingProbeUnsupportedAndAccountToggle(t *testing.T) {
require.NoError(t, err)
require.Equal(t, UpstreamBillingProbeStatusUnsupported, snapshot.Status)
require.Equal(t, "unsupported", snapshot.LastError)
+ require.False(t, snapshot.NextProbeAt.Before(fixedNow.Add(24*time.Minute)))
+ require.False(t, snapshot.NextProbeAt.After(fixedNow.Add(36*time.Minute)))
+
+ snapshot, err = svc.ProbeAccount(context.Background(), account.ID)
+ require.NoError(t, err)
+ require.Equal(t, 2, snapshot.FailureCount)
+ require.False(t, snapshot.NextProbeAt.Before(fixedNow.Add(24*time.Minute)))
+ require.False(t, snapshot.NextProbeAt.After(fixedNow.Add(36*time.Minute)))
invalid := &Account{ID: 20, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
repo.accounts[invalid.ID] = invalid
diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue
index 5f744c0fc1..a4268fb926 100644
--- a/frontend/src/components/account/BulkEditAccountModal.vue
+++ b/frontend/src/components/account/BulkEditAccountModal.vue
@@ -854,6 +854,45 @@
+
+
+
+
+
+
+ {{ t('admin.accounts.upstreamBilling.autoProbeHint') }}
+
+
+
+
+
+
+
+
+
@@ -1361,6 +1400,7 @@ const enableGroups = ref(false)
const enableOpenAIPassthrough = ref(false)
const enableOpenAIWSMode = ref(false)
const enableOpenAIAPIKeyWSMode = ref(false)
+const enableUpstreamBillingAutoProbe = ref(false)
const enableCodexCLIOnly = ref(false)
const enableCodexCLIOnlyAppServer = ref(false)
const enableOpenAICompactMode = ref(false)
@@ -1391,6 +1431,7 @@ const groupIds = ref
([])
const openaiPassthroughEnabled = ref(false)
const openaiOAuthResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF)
const openaiAPIKeyResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF)
+const upstreamBillingAutoProbeMode = ref<'enabled' | 'disabled'>('enabled')
const codexCLIOnlyEnabled = ref(false)
const codexCLIOnlyAppServerEnabled = ref(false)
const openAICompactMode = ref('auto')
@@ -1421,6 +1462,10 @@ const statusOptions = computed(() => [
{ value: 'active', label: t('common.active') },
{ value: 'inactive', label: t('common.inactive') }
])
+const upstreamBillingAutoProbeOptions = computed(() => [
+ { value: 'enabled', label: t('common.enabled') },
+ { value: 'disabled', label: t('common.disabled') }
+])
const isOpenAIModelRestrictionDisabled = computed(
() =>
allOpenAIPassthroughCapable.value &&
@@ -1648,6 +1693,10 @@ const buildUpdatePayload = (): Record | null => {
)
}
+ if (enableUpstreamBillingAutoProbe.value) {
+ updates.upstream_billing_probe_enabled = upstreamBillingAutoProbeMode.value === 'enabled'
+ }
+
if (enableCodexCLIOnly.value) {
const extra = ensureExtra()
extra.codex_cli_only = codexCLIOnlyEnabled.value
@@ -1770,6 +1819,7 @@ const handleSubmit = async () => {
enableGroups.value ||
enableOpenAIWSMode.value ||
enableOpenAIAPIKeyWSMode.value ||
+ enableUpstreamBillingAutoProbe.value ||
enableCodexCLIOnly.value ||
enableCodexCLIOnlyAppServer.value ||
enableOpenAICompactMode.value ||
@@ -1898,6 +1948,7 @@ watch(
enableOpenAIPassthrough.value = false
enableOpenAIWSMode.value = false
enableOpenAIAPIKeyWSMode.value = false
+ enableUpstreamBillingAutoProbe.value = false
enableCodexCLIOnly.value = false
enableCodexCLIOnlyAppServer.value = false
enableOpenAICompactMode.value = false
@@ -1924,6 +1975,7 @@ watch(
groupIds.value = []
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
+ upstreamBillingAutoProbeMode.value = 'enabled'
codexCLIOnlyEnabled.value = false
codexCLIOnlyAppServerEnabled.value = false
openAICompactMode.value = 'auto'
diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue
index 7eebce530a..f71bbbada8 100644
--- a/frontend/src/components/account/CreateAccountModal.vue
+++ b/frontend/src/components/account/CreateAccountModal.vue
@@ -1143,6 +1143,23 @@
{{ apiKeyHint }}
+
+
+
+
+ {{ t('admin.accounts.upstreamBilling.autoProbeHint') }}
+
+
+
+
+
@@ -3525,6 +3542,7 @@ import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
+import Toggle from '@/components/common/Toggle.vue'
import GrokBaseUrlPresets from '@/components/account/GrokBaseUrlPresets.vue'
import HeaderOverrideEditor from '@/components/account/HeaderOverrideEditor.vue'
import {
@@ -3668,6 +3686,7 @@ const accountCategory = ref<'oauth-based' | 'apikey' | 'bedrock' | 'service_acco
const addMethod = ref
('oauth') // For oauth-based: 'oauth' or 'setup-token'
const apiKeyBaseUrl = ref('https://api.anthropic.com')
const apiKeyValue = ref('')
+const upstreamBillingAutoProbeEnabled = ref(true)
const syncPreviewCredentials = computed(() => {
if (!apiKeyValue.value) return undefined
@@ -4553,7 +4572,18 @@ const ensureAntigravityMixedChannelConfirmed = async (onConfirm: () => Promise {
submitting.value = true
try {
- await adminAPI.accounts.create(withAntigravityConfirmFlag(payload))
+ const account = await adminAPI.accounts.create(withAntigravityConfirmFlag(payload))
+ if (
+ payload.platform === 'openai' &&
+ payload.type === 'apikey' &&
+ payload.upstream_billing_probe_enabled === true
+ ) {
+ try {
+ await adminAPI.accounts.probeUpstreamBilling(account.id)
+ } catch {
+ appStore.showWarning(t('admin.accounts.upstreamBilling.probeFailed'))
+ }
+ }
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
handleClose()
@@ -4593,6 +4623,7 @@ const resetForm = () => {
addMethod.value = 'oauth'
apiKeyBaseUrl.value = 'https://api.anthropic.com'
apiKeyValue.value = ''
+ upstreamBillingAutoProbeEnabled.value = true
editQuotaLimit.value = null
editQuotaDailyLimit.value = null
editQuotaWeeklyLimit.value = null
@@ -5092,6 +5123,8 @@ const handleSubmit = async () => {
...form,
group_ids: form.group_ids,
extra,
+ upstream_billing_probe_enabled:
+ form.platform === 'openai' ? upstreamBillingAutoProbeEnabled.value : undefined,
auto_pause_on_expired: autoPauseOnExpired.value
})
}
diff --git a/frontend/src/components/account/UpstreamBillingRateCell.vue b/frontend/src/components/account/UpstreamBillingRateCell.vue
index 73db04d851..98e5671b64 100644
--- a/frontend/src/components/account/UpstreamBillingRateCell.vue
+++ b/frontend/src/components/account/UpstreamBillingRateCell.vue
@@ -1,37 +1,71 @@
-
+
- {{ effectiveRate }}
+ {{ primaryValue }}
-
-
{{ t('admin.accounts.upstreamBilling.groupRate', { value: data.group_rate_multiplier }) }}
-
- {{ t('admin.accounts.upstreamBilling.userRate', { value: data.user_rate_multiplier }) }}
+
+
+ {{ t('admin.accounts.upstreamBilling.groupRate', { value: data.group_rate_multiplier }) }}
+
+ {{ t('admin.accounts.upstreamBilling.userRate', { value: data.user_rate_multiplier }) }}
+
+
+ {{
+ data.peak_rate_enabled
+ ? t('admin.accounts.upstreamBilling.peakRate', {
+ start: data.peak_start,
+ end: data.peak_end,
+ value: data.peak_rate_multiplier,
+ timezone: data.timezone
+ })
+ : t('admin.accounts.upstreamBilling.noPeakRate')
+ }}
+
+ {{ t('admin.accounts.upstreamBilling.effectiveRate', { value: currentEffectiveRate ?? '-' }) }}
+ {{ t('admin.accounts.upstreamBilling.updatedAt', { value: formatDate(snapshot?.received_at) }) }}
+
+
+
+ {{ t('admin.accounts.upstreamBilling.lastDetectedRate', { value: lastDetectedRate }) }}
+
+
+ {{ t('admin.accounts.upstreamBilling.lastDetectedAt', { value: formatDate(snapshot?.received_at) }) }}
+
+
+ {{ t('admin.accounts.upstreamBilling.elapsedSince', { value: elapsedSinceLastSuccess }) }}
+
+
+
{{ statusLabel || '-' }}
+
+ {{ t('admin.accounts.upstreamBilling.nextProbeAt', { value: formatDate(nextProbeAt) }) }}
-
- {{
- data.peak_rate_enabled
- ? t('admin.accounts.upstreamBilling.peakRate', {
- start: data.peak_start,
- end: data.peak_end,
- value: data.peak_rate_multiplier,
- timezone: data.timezone
- })
- : t('admin.accounts.upstreamBilling.noPeakRate')
- }}
+
+ {{ t('admin.accounts.upstreamBilling.accountProbeState') }}
+
+ {{ probeEnabled ? t('admin.accounts.upstreamBilling.enabled') : t('admin.accounts.upstreamBilling.disabled') }}
+
+
+
+ {{ t('admin.accounts.upstreamBilling.globalProbeState') }}
+ {{ t('admin.accounts.upstreamBilling.disabled') }}
-
{{ t('admin.accounts.upstreamBilling.effectiveRate', { value: currentEffectiveRate ?? '-' }) }}
-
{{ t('admin.accounts.upstreamBilling.updatedAt', { value: formatDate(snapshot?.received_at) }) }}
-
{{ statusLabel }}
-
+
{{ statusLabel }}
-
-
-
-
- {{ t('admin.accounts.upstreamBilling.autoProbeSettings') }}
-
-
-
-
-
-
-
-
-
-
@@ -375,15 +340,17 @@
-
+
{{ column.label }}
-
+
+
+
([])
const togglingSchedulable = ref(null)
const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null })
const exportingData = ref(false)
-const upstreamBillingProbeSettings = reactive({
- enabled: true,
- interval_minutes: 30
-})
-const upstreamBillingSettingsLoading = ref(false)
-const upstreamBillingSettingsSaving = ref(false)
const probingUpstreamBilling = reactive(new Set())
+const upstreamBillingProbeGloballyEnabled = ref(undefined)
const upstreamBillingNow = ref(Date.now())
+let lastUpstreamBillingSortRefreshMinute = -1
useIntervalFn(() => { upstreamBillingNow.value = Date.now() }, 60_000)
// Account tools dropdown
@@ -653,6 +615,7 @@ const ACCOUNT_SORTABLE_KEYS = new Set([
'schedulable',
'priority',
'rate_multiplier',
+ 'upstream_billing_rate',
'last_used_at',
'created_at',
'expires_at'
@@ -968,8 +931,15 @@ const resetAutoRefreshCache = () => {
const isFirstLoad = ref(true)
+function markUpstreamBillingSortRefresh() {
+ if (sortState.sort_by === 'upstream_billing_rate') {
+ lastUpstreamBillingSortRefreshMinute = Math.floor(Date.now() / 60_000)
+ }
+}
+
const load = async () => {
const requestParams = params as any
+ markUpstreamBillingSortRefresh()
syncAccountListDerivedParams()
hasPendingListSync.value = false
resetAutoRefreshCache()
@@ -986,6 +956,7 @@ const load = async () => {
}
const reload = async () => {
+ markUpstreamBillingSortRefresh()
syncAccountListDerivedParams()
hasPendingListSync.value = false
resetAutoRefreshCache()
@@ -994,6 +965,19 @@ const reload = async () => {
await refreshTodayStatsBatch()
}
+const refreshUpstreamBillingSortedList = async (force = false) => {
+ if (sortState.sort_by !== 'upstream_billing_rate') return
+
+ const minute = Math.floor(upstreamBillingNow.value / 60_000)
+ if (!force && lastUpstreamBillingSortRefreshMinute === minute) return
+ lastUpstreamBillingSortRefreshMinute = minute
+ try {
+ await reload()
+ } catch (error) {
+ console.error('Failed to refresh upstream billing sort:', error)
+ }
+}
+
const debouncedReload = () => {
syncAccountListDerivedParams()
hasPendingListSync.value = false
@@ -1033,6 +1017,9 @@ const handleSort = (key: string, order: AccountSortOrder) => {
}
watch(loading, (isLoading, wasLoading) => {
+ if (wasLoading && !isLoading) {
+ upstreamBillingNow.value = Date.now()
+ }
if (wasLoading && !isLoading && pendingTodayStatsRefresh.value) {
pendingTodayStatsRefresh.value = false
refreshTodayStatsBatch().catch((error) => {
@@ -1041,6 +1028,12 @@ watch(loading, (isLoading, wasLoading) => {
}
})
+watch(upstreamBillingNow, () => {
+ if (sortState.sort_by !== 'upstream_billing_rate' || loading.value) return
+ if (typeof document !== 'undefined' && document.hidden) return
+ void refreshUpstreamBillingSortedList()
+})
+
const isAnyModalOpen = computed(() => {
return (
showCreate.value ||
@@ -1152,7 +1145,9 @@ const refreshAccountsIncrementally = async () => {
pagination.pages = result.data.pages || 0
mergeAccountsIncrementally(result.data.items || [])
hasPendingListSync.value = false
+ markUpstreamBillingSortRefresh()
}
+ upstreamBillingNow.value = Date.now()
await refreshTodayStatsBatch()
} catch (error) {
@@ -1163,11 +1158,20 @@ const refreshAccountsIncrementally = async () => {
}
const handleManualRefresh = async () => {
- await load()
+ await Promise.all([load(), loadUpstreamBillingProbeGlobalState()])
// Force usage cells to refetch /usage on explicit user refresh.
usageManualRefreshToken.value += 1
}
+const loadUpstreamBillingProbeGlobalState = async () => {
+ try {
+ const settings = await adminAPI.accounts.getUpstreamBillingProbeSettings()
+ upstreamBillingProbeGloballyEnabled.value = settings.enabled
+ } catch (error) {
+ console.error('Failed to load upstream billing probe settings:', error)
+ }
+}
+
const closeAccountToolsDropdown = () => {
showAccountToolsDropdown.value = false
}
@@ -1197,31 +1201,6 @@ const openTLSFingerprintProfiles = () => {
showTLSFingerprintProfiles.value = true
}
-const loadUpstreamBillingProbeSettings = async () => {
- upstreamBillingSettingsLoading.value = true
- try {
- Object.assign(upstreamBillingProbeSettings, await adminAPI.accounts.getUpstreamBillingProbeSettings())
- } catch (error) {
- console.error('Failed to load upstream billing probe settings:', error)
- } finally {
- upstreamBillingSettingsLoading.value = false
- }
-}
-
-const saveUpstreamBillingProbeSettings = async () => {
- upstreamBillingSettingsSaving.value = true
- try {
- const saved = await adminAPI.accounts.updateUpstreamBillingProbeSettings({ ...upstreamBillingProbeSettings })
- Object.assign(upstreamBillingProbeSettings, saved)
- appStore.showSuccess(t('admin.accounts.upstreamBilling.settingsSaved'))
- } catch (error) {
- console.error('Failed to save upstream billing probe settings:', error)
- appStore.showError(extractApiErrorMessage(error, t('admin.accounts.upstreamBilling.settingsFailed')))
- } finally {
- upstreamBillingSettingsSaving.value = false
- }
-}
-
const syncPendingListChanges = async () => {
hasPendingListSync.value = false
await load()
@@ -1397,7 +1376,7 @@ const allColumns = computed(() => {
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
{ key: 'scheduler_score', label: t('admin.accounts.columns.schedulerScore'), sortable: false },
{ key: 'rate_multiplier', label: t('admin.accounts.columns.billingRateMultiplier'), sortable: true },
- { key: 'upstream_billing_rate', label: t('admin.accounts.columns.upstreamBillingRate'), sortable: false },
+ { key: 'upstream_billing_rate', label: t('admin.accounts.columns.upstreamBillingRate'), sortable: true },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
{ key: 'created_at', label: t('admin.accounts.columns.createdAt'), sortable: true },
{ key: 'expires_at', label: t('admin.accounts.columns.expiresAt'), sortable: true },
@@ -1521,9 +1500,14 @@ const handleBulkProbeUpstreamBilling = async () => {
accountIDs.forEach(id => probingUpstreamBilling.add(id))
try {
const results = await adminAPI.accounts.probeUpstreamBillingBatch(accountIDs)
+ let patched = false
results.forEach(result => {
- if (result.snapshot) patchUpstreamBillingSnapshot(result.account_id, result.snapshot)
+ if (result.snapshot) {
+ patchUpstreamBillingSnapshot(result.account_id, result.snapshot)
+ patched = true
+ }
})
+ if (patched) await refreshUpstreamBillingSortedList(true)
const failed = results.filter(result => result.error).length
if (failed > 0) {
appStore.showError(t('admin.accounts.upstreamBilling.batchPartial', { success: results.length - failed, failed }))
@@ -1789,6 +1773,8 @@ const patchAccountInList = (updatedAccount: Account) => {
const patchUpstreamBillingSnapshot = (accountID: number, snapshot: UpstreamBillingProbeSnapshot) => {
const account = accounts.value.find(item => item.id === accountID)
if (!account) return
+ markUpstreamBillingSortRefresh()
+ upstreamBillingNow.value = Date.now()
patchAccountInList({
...account,
extra: { ...account.extra, upstream_billing_probe: snapshot }
@@ -1799,7 +1785,10 @@ const handleProbeUpstreamBilling = async (account: Account) => {
probingUpstreamBilling.add(account.id)
try {
const result = await adminAPI.accounts.probeUpstreamBilling(account.id)
- if (result.snapshot) patchUpstreamBillingSnapshot(account.id, result.snapshot)
+ if (result.snapshot) {
+ patchUpstreamBillingSnapshot(account.id, result.snapshot)
+ await refreshUpstreamBillingSortedList(true)
+ }
} catch (error) {
console.error('Failed to probe upstream billing:', error)
appStore.showError(extractApiErrorMessage(error, t('admin.accounts.upstreamBilling.probeFailed')))
@@ -2062,7 +2051,7 @@ const handleClickOutside = (event: MouseEvent) => {
onMounted(async () => {
load()
- loadUpstreamBillingProbeSettings()
+ loadUpstreamBillingProbeGlobalState()
try {
const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()])
proxies.value = p
diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue
index 271ef3191f..b9b28d1bf7 100644
--- a/frontend/src/views/admin/SettingsView.vue
+++ b/frontend/src/views/admin/SettingsView.vue
@@ -4028,6 +4028,92 @@
+
+
+
+
+ {{ t("admin.settings.upstreamBillingProbe.title") }}
+
+
+ {{ t("admin.settings.upstreamBillingProbe.description") }}
+
+
+
+
+
+ {{ t("common.loading") }}
+
+
+
+
+
+
+
+ {{ t("admin.settings.upstreamBillingProbe.enabledHint") }}
+
+
+
+
+
+
+
+
+
+ {{ t("admin.settings.upstreamBillingProbe.intervalHint") }}
+
+
+
+
+
+
+
+
+
+
([]);
+// Upstream billing probe state
+const upstreamBillingProbeLoading = ref(true);
+const upstreamBillingProbeSaving = ref(false);
+const upstreamBillingProbeForm = reactive({
+ enabled: true,
+ interval_minutes: 30,
+});
+
// Overload Cooldown (529) 状态
const overloadCooldownLoading = ref(true);
const overloadCooldownSaving = ref(false);
@@ -10058,6 +10152,40 @@ function copyNewKey() {
});
}
+async function loadUpstreamBillingProbeSettings() {
+ upstreamBillingProbeLoading.value = true;
+ try {
+ Object.assign(
+ upstreamBillingProbeForm,
+ await adminAPI.accounts.getUpstreamBillingProbeSettings(),
+ );
+ } catch (_error: unknown) {
+ // Keep defaults when this optional setting cannot be loaded.
+ } finally {
+ upstreamBillingProbeLoading.value = false;
+ }
+}
+
+async function saveUpstreamBillingProbeSettings() {
+ upstreamBillingProbeSaving.value = true;
+ try {
+ const updated = await adminAPI.accounts.updateUpstreamBillingProbeSettings({
+ ...upstreamBillingProbeForm,
+ });
+ Object.assign(upstreamBillingProbeForm, updated);
+ appStore.showSuccess(t("admin.settings.upstreamBillingProbe.saved"));
+ } catch (error: unknown) {
+ appStore.showError(
+ extractApiErrorMessage(
+ error,
+ t("admin.settings.upstreamBillingProbe.saveFailed"),
+ ),
+ );
+ } finally {
+ upstreamBillingProbeSaving.value = false;
+ }
+}
+
// Overload Cooldown 方法
async function loadOverloadCooldownSettings() {
overloadCooldownLoading.value = true;
@@ -10754,6 +10882,7 @@ onMounted(() => {
loadSettings();
loadSubscriptionGroups();
loadAdminApiKey();
+ loadUpstreamBillingProbeSettings();
loadOverloadCooldownSettings();
loadRateLimit429CooldownSettings();
loadStreamTimeoutSettings();
diff --git a/frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts
index 89ccdf316d..39111c53e7 100644
--- a/frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts
+++ b/frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts
@@ -7,6 +7,7 @@ const {
listAccounts,
listWithEtag,
getBatchTodayStats,
+ getUpstreamBillingProbeSettings,
getAllProxies,
getAllGroups,
probeUpstreamBillingBatch
@@ -14,6 +15,7 @@ const {
listAccounts: vi.fn(),
listWithEtag: vi.fn(),
getBatchTodayStats: vi.fn(),
+ getUpstreamBillingProbeSettings: vi.fn(),
getAllProxies: vi.fn(),
getAllGroups: vi.fn(),
probeUpstreamBillingBatch: vi.fn()
@@ -25,7 +27,7 @@ vi.mock('@/api/admin', () => ({
list: listAccounts,
listWithEtag,
getBatchTodayStats,
- getUpstreamBillingProbeSettings: vi.fn().mockResolvedValue({ enabled: true, interval_minutes: 30 }),
+ getUpstreamBillingProbeSettings,
delete: vi.fn(),
batchClearError: vi.fn(),
batchRefresh: vi.fn(),
@@ -106,6 +108,7 @@ describe('admin AccountsView bulk edit scope', () => {
listAccounts.mockReset()
listWithEtag.mockReset()
getBatchTodayStats.mockReset()
+ getUpstreamBillingProbeSettings.mockReset()
getAllProxies.mockReset()
getAllGroups.mockReset()
probeUpstreamBillingBatch.mockReset()
@@ -123,6 +126,7 @@ describe('admin AccountsView bulk edit scope', () => {
data: null
})
getBatchTodayStats.mockResolvedValue({ stats: {} })
+ getUpstreamBillingProbeSettings.mockResolvedValue({ enabled: true, interval_minutes: 30 })
getAllProxies.mockResolvedValue([])
getAllGroups.mockResolvedValue([])
probeUpstreamBillingBatch.mockResolvedValue([])
@@ -242,6 +246,75 @@ describe('admin AccountsView bulk edit scope', () => {
})
})
+ it('passes the loaded global probe state to every upstream billing cell', async () => {
+ listAccounts.mockResolvedValue({
+ items: [
+ {
+ id: 1,
+ name: 'upstream',
+ platform: 'openai',
+ type: 'apikey',
+ status: 'active',
+ schedulable: true,
+ created_at: '2026-07-13T00:00:00Z',
+ updated_at: '2026-07-13T00:00:00Z'
+ }
+ ],
+ total: 1,
+ page: 1,
+ page_size: 20,
+ pages: 1
+ })
+ getUpstreamBillingProbeSettings.mockResolvedValue({ enabled: false, interval_minutes: 30 })
+
+ const wrapper = mount(AccountsView, {
+ global: {
+ stubs: {
+ AppLayout: { template: '
' },
+ TablePageLayout: { template: '
' },
+ DataTable: {
+ props: ['data'],
+ template: '
'
+ },
+ UpstreamBillingRateCell: {
+ props: ['globalProbeEnabled'],
+ template: '
'
+ },
+ Pagination: true,
+ ConfirmDialog: true,
+ AccountTableActions: true,
+ AccountTableFilters: true,
+ AccountBulkActionsBar: true,
+ AccountActionMenu: true,
+ ImportDataModal: true,
+ ReAuthAccountModal: true,
+ AccountTestModal: true,
+ AccountStatsModal: true,
+ ScheduledTestsPanel: true,
+ SyncFromCrsModal: true,
+ TempUnschedStatusModal: true,
+ ErrorPassthroughRulesModal: true,
+ TLSFingerprintProfilesModal: true,
+ CreateAccountModal: true,
+ EditAccountModal: true,
+ BulkEditAccountModal: true,
+ PlatformTypeBadge: true,
+ AccountCapacityCell: true,
+ AccountStatusIndicator: true,
+ AccountTodayStatsCell: true,
+ AccountGroupsCell: true,
+ AccountUsageCell: true,
+ Icon: true
+ }
+ }
+ })
+
+ await flushPromises()
+
+ expect(getUpstreamBillingProbeSettings).toHaveBeenCalledTimes(1)
+ expect(wrapper.get('[data-test="upstream-billing-cell"]').attributes('data-global-enabled')).toBe('false')
+ })
+
it('submits selected account IDs from every page for backend eligibility checks', async () => {
const account = (id: number) => ({
id,
@@ -302,4 +375,75 @@ describe('admin AccountsView bulk edit scope', () => {
expect(probeUpstreamBillingBatch).toHaveBeenCalledWith([7, 11])
})
+
+ it('reloads the server-sorted list after a batch probe changes a snapshot', async () => {
+ localStorage.setItem('account-table-sort', JSON.stringify({ key: 'upstream_billing_rate', order: 'asc' }))
+ const account = (id: number) => ({
+ id,
+ name: `account-${id}`,
+ platform: 'openai',
+ type: 'apikey',
+ status: 'active',
+ schedulable: true,
+ created_at: '2026-07-13T00:00:00Z',
+ updated_at: '2026-07-13T00:00:00Z'
+ })
+ listAccounts
+ .mockResolvedValueOnce({ items: [account(7)], total: 1, page: 1, page_size: 20, pages: 1 })
+ .mockResolvedValueOnce({ items: [account(7)], total: 1, page: 1, page_size: 20, pages: 1 })
+ probeUpstreamBillingBatch.mockResolvedValue([
+ {
+ account_id: 7,
+ snapshot: {
+ status: 'ok',
+ data: { effective_rate_multiplier: 0.5 },
+ last_attempt_at: '2026-07-13T00:00:00Z',
+ next_probe_at: '2026-07-13T00:30:00Z'
+ }
+ }
+ ])
+
+ const wrapper = mount(AccountsView, {
+ global: {
+ stubs: {
+ AppLayout: { template: '
' },
+ TablePageLayout: { template: '
' },
+ DataTable: DataTableStub,
+ AccountBulkActionsBar: AccountBulkActionsBarStub,
+ AccountTableActions: true,
+ AccountTableFilters: true,
+ AccountActionMenu: true,
+ Pagination: true,
+ ConfirmDialog: true,
+ ImportDataModal: true,
+ ReAuthAccountModal: true,
+ AccountTestModal: true,
+ AccountStatsModal: true,
+ ScheduledTestsPanel: true,
+ SyncFromCrsModal: true,
+ TempUnschedStatusModal: true,
+ ErrorPassthroughRulesModal: true,
+ TLSFingerprintProfilesModal: true,
+ CreateAccountModal: true,
+ EditAccountModal: true,
+ BulkEditAccountModal: BulkEditAccountModalStub,
+ PlatformTypeBadge: true,
+ AccountCapacityCell: true,
+ AccountStatusIndicator: true,
+ AccountTodayStatsCell: true,
+ AccountGroupsCell: true,
+ AccountUsageCell: true,
+ Icon: true
+ }
+ }
+ })
+
+ await flushPromises()
+ await wrapper.get('[data-test="select-row"] input').trigger('change')
+ await wrapper.get('[data-test="probe-upstream-billing"]').trigger('click')
+ await flushPromises()
+
+ expect(probeUpstreamBillingBatch).toHaveBeenCalledWith([7])
+ expect(listAccounts).toHaveBeenCalledTimes(2)
+ })
})
diff --git a/frontend/src/views/admin/__tests__/AccountsView.usageWindowsHint.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.usageWindowsHint.spec.ts
index 0edd426ac1..3e8f36f355 100644
--- a/frontend/src/views/admin/__tests__/AccountsView.usageWindowsHint.spec.ts
+++ b/frontend/src/views/admin/__tests__/AccountsView.usageWindowsHint.spec.ts
@@ -176,5 +176,7 @@ describe('admin AccountsView usage windows hint', () => {
expect(wrapper.findAll('[data-test="usage-windows-hint"]').some(node =>
node.text() === 'admin.accounts.upstreamBilling.trustWarning'
)).toBe(true)
+ const columns = wrapper.getComponent(DataTableStub).props('columns') as Array<{ key: string; sortable: boolean }>
+ expect(columns.find(column => column.key === 'upstream_billing_rate')?.sortable).toBe(true)
})
})
diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts
index de4666d970..8f73963362 100644
--- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts
+++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts
@@ -16,6 +16,8 @@ const {
getStreamTimeoutSettings,
getRectifierSettings,
getBetaPolicySettings,
+ getUpstreamBillingProbeSettings,
+ updateUpstreamBillingProbeSettings,
getGroups,
listProxies,
getProviders,
@@ -38,6 +40,11 @@ const {
getStreamTimeoutSettings: vi.fn(),
getRectifierSettings: vi.fn(),
getBetaPolicySettings: vi.fn(),
+ getUpstreamBillingProbeSettings: vi.fn().mockResolvedValue({
+ enabled: true,
+ interval_minutes: 30,
+ }),
+ updateUpstreamBillingProbeSettings: vi.fn().mockImplementation(async (payload) => payload),
getGroups: vi.fn(),
listProxies: vi.fn(),
getProviders: vi.fn(),
@@ -67,6 +74,10 @@ vi.mock("@/api", () => ({
getRectifierSettings,
getBetaPolicySettings,
},
+ accounts: {
+ getUpstreamBillingProbeSettings,
+ updateUpstreamBillingProbeSettings,
+ },
groups: {
getAll: getGroups,
},
@@ -184,6 +195,14 @@ vi.mock("vue-i18n", async () => {
"admin.settings.openaiExperimentalScheduler.upstreamCostWeight": "计费倍率",
"admin.settings.openaiExperimentalScheduler.previousResponseWeight": "previous_response 粘性",
"admin.settings.openaiExperimentalScheduler.sessionStickyWeight": "session_hash 粘性",
+ "admin.settings.upstreamBillingProbe.title": "上游倍率自动探测",
+ "admin.settings.upstreamBillingProbe.description": "定期获取 OpenAI API Key 所连接上游 Sub2API 站点声明的计费倍率。",
+ "admin.settings.upstreamBillingProbe.enabled": "启用全局自动探测",
+ "admin.settings.upstreamBillingProbe.enabledHint": "开启后,仅对账号自身已启用自动检测的账号执行定时探测。",
+ "admin.settings.upstreamBillingProbe.intervalMinutes": "探测周期(分钟)",
+ "admin.settings.upstreamBillingProbe.intervalHint": "范围 5–1440 分钟。",
+ "admin.settings.upstreamBillingProbe.saved": "上游倍率自动探测设置已保存",
+ "admin.settings.upstreamBillingProbe.saveFailed": "保存上游倍率自动探测设置失败",
"admin.settings.site.uploadImage": "上传图片",
"admin.settings.site.remove": "移除",
"admin.settings.platformQuota.platform": "平台",
@@ -517,6 +536,16 @@ async function openSecurityTab(wrapper: ReturnType
) {
await flushPromises();
}
+async function openGatewayTab(wrapper: ReturnType) {
+ const gatewayTabButton = wrapper
+ .findAll("button")
+ .find((node) => node.text().includes("admin.settings.tabs.gateway"));
+
+ expect(gatewayTabButton).toBeDefined();
+ await gatewayTabButton?.trigger("click");
+ await flushPromises();
+}
+
async function openUsersTab(wrapper: ReturnType) {
const usersTabButton = wrapper
.findAll("button")
@@ -540,6 +569,8 @@ describe("admin SettingsView payment visible method controls", () => {
getStreamTimeoutSettings.mockReset();
getRectifierSettings.mockReset();
getBetaPolicySettings.mockReset();
+ getUpstreamBillingProbeSettings.mockReset();
+ updateUpstreamBillingProbeSettings.mockReset();
getGroups.mockReset();
listProxies.mockReset();
getProviders.mockReset();
@@ -595,6 +626,11 @@ describe("admin SettingsView payment visible method controls", () => {
getBetaPolicySettings.mockResolvedValue({
rules: [],
});
+ getUpstreamBillingProbeSettings.mockResolvedValue({
+ enabled: true,
+ interval_minutes: 30,
+ });
+ updateUpstreamBillingProbeSettings.mockImplementation(async (payload) => payload);
getGroups.mockResolvedValue([]);
listProxies.mockResolvedValue({
items: [],
@@ -848,6 +884,38 @@ describe("admin SettingsView payment visible method controls", () => {
expect(wrapper.text()).not.toContain("OpenAI 高级调度器");
});
+ it("loads and saves upstream billing probe settings from the gateway tab", async () => {
+ getUpstreamBillingProbeSettings.mockResolvedValueOnce({
+ enabled: false,
+ interval_minutes: 45,
+ });
+
+ const wrapper = mountView();
+
+ await flushPromises();
+ await openGatewayTab(wrapper);
+
+ const card = wrapper.get('[data-testid="upstream-billing-probe-settings"]');
+ expect(card.isVisible()).toBe(true);
+ expect(card.text()).toContain("上游倍率自动探测");
+ expect(
+ (card.get('[data-testid="upstream-billing-probe-enabled"]').element as HTMLInputElement)
+ .checked,
+ ).toBe(false);
+ expect(card.find('[data-testid="upstream-billing-probe-interval"]').exists()).toBe(false);
+
+ await card.get('[data-testid="upstream-billing-probe-enabled"]').setValue(true);
+ await card.get('[data-testid="upstream-billing-probe-interval"]').setValue(60);
+ await card.get('[data-testid="upstream-billing-probe-save"]').trigger("click");
+ await flushPromises();
+
+ expect(updateUpstreamBillingProbeSettings).toHaveBeenCalledWith({
+ enabled: true,
+ interval_minutes: 60,
+ });
+ expect(showSuccess).toHaveBeenCalledWith("上游倍率自动探测设置已保存");
+ });
+
it("places and explains rate controls for both scheduling modes", async () => {
const wrapper = mountView();