Skip to content

Commit f7c4425

Browse files
committed
feat(probe): configurable usage-probe concurrency + manual sampling trigger
- Default probe concurrency raised from hard-coded 4 to 16, exposed as system_settings.usage_probe_concurrency (1-128) and wired through postgres + sqlite migrations. - New POST /api/admin/accounts/usage/probe endpoint bypasses the snapshot cache and fans out a force-probe pass. - Quota distribution chart gets a "Sample now" button; Settings page reorganised into three cards (traffic / probe scheduling / strategy) so the layout no longer leaves a tall blank under traffic protection.
1 parent 47cae14 commit f7c4425

13 files changed

Lines changed: 214 additions & 41 deletions

File tree

admin/handler.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
232232
api.GET("/accounts/export", h.ExportAccounts)
233233
api.POST("/accounts/migrate", h.MigrateAccounts)
234234
api.GET("/accounts/event-trend", h.GetAccountEventTrend)
235+
api.POST("/accounts/usage/probe", h.ForceUsageProbe)
235236
api.GET("/usage/stats", h.GetUsageStats)
236237
api.GET("/usage/logs", h.GetUsageLogs)
237238
api.GET("/usage/chart-data", h.GetChartData)
@@ -3818,6 +3819,7 @@ type settingsResponse struct {
38183819
TestConcurrency int `json:"test_concurrency"`
38193820
BackgroundRefreshIntervalMinutes int `json:"background_refresh_interval_minutes"`
38203821
UsageProbeMaxAgeMinutes int `json:"usage_probe_max_age_minutes"`
3822+
UsageProbeConcurrency int `json:"usage_probe_concurrency"`
38213823
RecoveryProbeIntervalMinutes int `json:"recovery_probe_interval_minutes"`
38223824
LazyMode bool `json:"lazy_mode"`
38233825
ProxyURL string `json:"proxy_url"`
@@ -3880,6 +3882,7 @@ type updateSettingsReq struct {
38803882
TestConcurrency *int `json:"test_concurrency"`
38813883
BackgroundRefreshIntervalMinutes *int `json:"background_refresh_interval_minutes"`
38823884
UsageProbeMaxAgeMinutes *int `json:"usage_probe_max_age_minutes"`
3885+
UsageProbeConcurrency *int `json:"usage_probe_concurrency"`
38833886
RecoveryProbeIntervalMinutes *int `json:"recovery_probe_interval_minutes"`
38843887
LazyMode *bool `json:"lazy_mode"`
38853888
ProxyURL *string `json:"proxy_url"`
@@ -4022,6 +4025,7 @@ func (h *Handler) GetSettings(c *gin.Context) {
40224025
TestConcurrency: h.store.GetTestConcurrency(),
40234026
BackgroundRefreshIntervalMinutes: h.store.GetBackgroundRefreshIntervalMinutes(),
40244027
UsageProbeMaxAgeMinutes: h.store.GetUsageProbeMaxAgeMinutes(),
4028+
UsageProbeConcurrency: h.store.GetUsageProbeConcurrency(),
40254029
RecoveryProbeIntervalMinutes: h.store.GetRecoveryProbeIntervalMinutes(),
40264030
LazyMode: h.store.GetLazyMode(),
40274031
ProxyURL: h.store.GetProxyURL(),
@@ -4181,6 +4185,18 @@ func (h *Handler) UpdateSettings(c *gin.Context) {
41814185
log.Printf("设置已更新: usage_probe_max_age_minutes = %d", v)
41824186
}
41834187

4188+
if req.UsageProbeConcurrency != nil {
4189+
v := *req.UsageProbeConcurrency
4190+
if v < 1 {
4191+
v = 1
4192+
}
4193+
if v > 128 {
4194+
v = 128
4195+
}
4196+
h.store.SetUsageProbeConcurrency(v)
4197+
log.Printf("设置已更新: usage_probe_concurrency = %d", v)
4198+
}
4199+
41844200
if req.RecoveryProbeIntervalMinutes != nil {
41854201
v := *req.RecoveryProbeIntervalMinutes
41864202
if v < 1 {
@@ -4514,6 +4530,7 @@ func (h *Handler) UpdateSettings(c *gin.Context) {
45144530
TestConcurrency: h.store.GetTestConcurrency(),
45154531
BackgroundRefreshIntervalMinutes: h.store.GetBackgroundRefreshIntervalMinutes(),
45164532
UsageProbeMaxAgeMinutes: h.store.GetUsageProbeMaxAgeMinutes(),
4533+
UsageProbeConcurrency: h.store.GetUsageProbeConcurrency(),
45174534
RecoveryProbeIntervalMinutes: h.store.GetRecoveryProbeIntervalMinutes(),
45184535
LazyMode: h.store.GetLazyMode(),
45194536
ProxyURL: h.store.GetProxyURL(),
@@ -4579,6 +4596,7 @@ func (h *Handler) UpdateSettings(c *gin.Context) {
45794596
TestConcurrency: h.store.GetTestConcurrency(),
45804597
BackgroundRefreshIntervalMinutes: h.store.GetBackgroundRefreshIntervalMinutes(),
45814598
UsageProbeMaxAgeMinutes: h.store.GetUsageProbeMaxAgeMinutes(),
4599+
UsageProbeConcurrency: h.store.GetUsageProbeConcurrency(),
45824600
RecoveryProbeIntervalMinutes: h.store.GetRecoveryProbeIntervalMinutes(),
45834601
LazyMode: h.store.GetLazyMode(),
45844602
ProxyURL: h.store.GetProxyURL(),

admin/usage_probe.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/codex2api/auth"
1111
"github.com/codex2api/proxy"
12+
"github.com/gin-gonic/gin"
1213
)
1314

1415
// ProbeUsageSnapshot 主动发送最小探针请求刷新账号用量
@@ -60,3 +61,21 @@ func (h *Handler) ProbeUsageSnapshot(ctx context.Context, account *auth.Account)
6061
return fmt.Errorf("探针返回状态 %d", resp.StatusCode)
6162
}
6263
}
64+
65+
// ForceUsageProbe 主动触发一次"忽略缓存阈值"的全量用量探针,并立即返回。
66+
// 真正的探针在后台并发执行(受 usage_probe_concurrency 限制)。
67+
func (h *Handler) ForceUsageProbe(c *gin.Context) {
68+
if h.store.GetLazyMode() {
69+
c.JSON(http.StatusOK, gin.H{
70+
"triggered": false,
71+
"reason": "lazy_mode",
72+
"concurrency": h.store.GetUsageProbeConcurrency(),
73+
})
74+
return
75+
}
76+
h.store.TriggerUsageProbeForceAsync()
77+
c.JSON(http.StatusOK, gin.H{
78+
"triggered": true,
79+
"concurrency": h.store.GetUsageProbeConcurrency(),
80+
})
81+
}

auth/store.go

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ type AccountFilter func(*Account) bool
133133
const (
134134
defaultBackgroundRefreshInterval = 2 * time.Minute
135135
defaultUsageProbeMaxAge = 10 * time.Minute
136+
defaultUsageProbeConcurrency = 16
136137
defaultRecoveryProbeInterval = 30 * time.Minute
137138
premium5hUrgencyWindow = 4 * time.Hour
138139
premium5hUrgencyMaxBonus = 25.0
@@ -1487,6 +1488,7 @@ type Store struct {
14871488
maxRateLimitRetries int64 // 429 最大换号重试次数
14881489
backgroundRefreshInterval int64 // 后台刷新/探针巡检间隔(ns)
14891490
usageProbeMaxAge int64 // 用量探针快照最大缓存时长(ns)
1491+
usageProbeConcurrency int64 // 用量探针并行度
14901492
recoveryProbeInterval int64 // 恢复探测最小间隔(ns)
14911493
backgroundRefreshWakeCh chan struct{}
14921494
lazyRefreshInFlight sync.Map
@@ -1880,6 +1882,7 @@ func NewStore(db *database.DB, tc cache.TokenCache, settings *database.SystemSet
18801882
TestModel: "gpt-5.4",
18811883
BackgroundRefreshIntervalMinutes: 2,
18821884
UsageProbeMaxAgeMinutes: 10,
1885+
UsageProbeConcurrency: defaultUsageProbeConcurrency,
18831886
RecoveryProbeIntervalMinutes: 30,
18841887
LazyMode: false,
18851888
ProxyURL: "",
@@ -1901,6 +1904,7 @@ func NewStore(db *database.DB, tc cache.TokenCache, settings *database.SystemSet
19011904
s.testModel.Store(settings.TestModel)
19021905
s.SetBackgroundRefreshInterval(time.Duration(settings.BackgroundRefreshIntervalMinutes) * time.Minute)
19031906
s.SetUsageProbeMaxAge(time.Duration(settings.UsageProbeMaxAgeMinutes) * time.Minute)
1907+
s.SetUsageProbeConcurrency(settings.UsageProbeConcurrency)
19041908
s.SetRecoveryProbeInterval(time.Duration(settings.RecoveryProbeIntervalMinutes) * time.Minute)
19051909
s.autoCleanUnauthorized.Store(settings.AutoCleanUnauthorized)
19061910
s.autoCleanRateLimited.Store(settings.AutoCleanRateLimited)
@@ -2232,6 +2236,26 @@ func (s *Store) GetUsageProbeMaxAge() time.Duration {
22322236
return d
22332237
}
22342238

2239+
// SetUsageProbeConcurrency 设置用量探针并行度。
2240+
func (s *Store) SetUsageProbeConcurrency(n int) {
2241+
if n <= 0 {
2242+
n = defaultUsageProbeConcurrency
2243+
}
2244+
if n > 128 {
2245+
n = 128
2246+
}
2247+
atomic.StoreInt64(&s.usageProbeConcurrency, int64(n))
2248+
}
2249+
2250+
// GetUsageProbeConcurrency 获取用量探针并行度。
2251+
func (s *Store) GetUsageProbeConcurrency() int {
2252+
n := int(atomic.LoadInt64(&s.usageProbeConcurrency))
2253+
if n <= 0 {
2254+
return defaultUsageProbeConcurrency
2255+
}
2256+
return n
2257+
}
2258+
22352259
// SetRecoveryProbeInterval 设置恢复探测最小间隔。
22362260
func (s *Store) SetRecoveryProbeInterval(d time.Duration) {
22372261
if d <= 0 {
@@ -4233,6 +4257,12 @@ func (s *Store) RemoveAccounts(dbIDs []int64) {
42334257
}
42344258

42354259
func (s *Store) parallelProbeUsage(ctx context.Context) {
4260+
s.parallelProbeUsageWith(ctx, s.GetUsageProbeMaxAge())
4261+
}
4262+
4263+
// parallelProbeUsageWith 以指定 maxAge 阈值执行一次批量用量探针。
4264+
// maxAge<=0 时视为"立即探针"——只要账号能跑就刷一次。
4265+
func (s *Store) parallelProbeUsageWith(ctx context.Context, maxAge time.Duration) {
42364266
s.usageProbeMu.RLock()
42374267
probeFn := s.usageProbe
42384268
s.usageProbeMu.RUnlock()
@@ -4245,11 +4275,11 @@ func (s *Store) parallelProbeUsage(ctx context.Context) {
42454275
copy(accounts, s.accounts)
42464276
s.mu.RUnlock()
42474277

4248-
sem := make(chan struct{}, 4)
4278+
sem := make(chan struct{}, s.GetUsageProbeConcurrency())
42494279
var wg sync.WaitGroup
42504280

42514281
for _, acc := range accounts {
4252-
if !acc.NeedsUsageProbe(s.GetUsageProbeMaxAge()) {
4282+
if !acc.NeedsUsageProbe(maxAge) {
42534283
continue
42544284
}
42554285
if !acc.TryBeginUsageProbe() {
@@ -4274,6 +4304,22 @@ func (s *Store) parallelProbeUsage(ctx context.Context) {
42744304
wg.Wait()
42754305
}
42764306

4307+
// TriggerUsageProbeForceAsync 异步触发一次"无视缓存阈值"的批量用量探针。
4308+
// 用于管理端手动刷新场景。
4309+
func (s *Store) TriggerUsageProbeForceAsync() {
4310+
if s.GetLazyMode() {
4311+
return
4312+
}
4313+
if !s.usageProbeBatch.CompareAndSwap(false, true) {
4314+
return
4315+
}
4316+
4317+
go func() {
4318+
defer s.usageProbeBatch.Store(false)
4319+
s.parallelProbeUsageWith(context.Background(), 0)
4320+
}()
4321+
}
4322+
42774323
func (s *Store) parallelRecoveryProbe(ctx context.Context) {
42784324
s.usageProbeMu.RLock()
42794325
probeFn := s.usageProbe

database/postgres.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,7 @@ func (db *DB) migrate(ctx context.Context) error {
602602
auto_clean_rate_limited BOOLEAN DEFAULT FALSE,
603603
background_refresh_interval_minutes INT DEFAULT 2,
604604
usage_probe_max_age_minutes INT DEFAULT 10,
605+
usage_probe_concurrency INT DEFAULT 16,
605606
recovery_probe_interval_minutes INT DEFAULT 30,
606607
scheduler_mode VARCHAR(20) DEFAULT 'round_robin'
607608
);
@@ -633,6 +634,7 @@ func (db *DB) migrate(ctx context.Context) error {
633634
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS model_mapping TEXT DEFAULT '{}';
634635
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS background_refresh_interval_minutes INT DEFAULT 2;
635636
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS usage_probe_max_age_minutes INT DEFAULT 10;
637+
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS usage_probe_concurrency INT DEFAULT 16;
636638
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS recovery_probe_interval_minutes INT DEFAULT 30;
637639
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS scheduler_mode VARCHAR(20) DEFAULT 'round_robin';
638640
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS affinity_mode VARCHAR(16) DEFAULT 'bounded';
@@ -1168,6 +1170,7 @@ type SystemSettings struct {
11681170
ModelMapping string // JSON: {"anthropic_model": "codex_model", ...}
11691171
BackgroundRefreshIntervalMinutes int
11701172
UsageProbeMaxAgeMinutes int
1173+
UsageProbeConcurrency int
11711174
RecoveryProbeIntervalMinutes int
11721175
SchedulerMode string
11731176
AffinityMode string // session 粘性模式: bounded / off / strict
@@ -1210,6 +1213,7 @@ func (db *DB) GetSystemSettings(ctx context.Context) (*SystemSettings, error) {
12101213
COALESCE(model_mapping, '{}'),
12111214
COALESCE(background_refresh_interval_minutes, 2),
12121215
COALESCE(usage_probe_max_age_minutes, 10),
1216+
COALESCE(usage_probe_concurrency, 16),
12131217
COALESCE(recovery_probe_interval_minutes, 30),
12141218
COALESCE(scheduler_mode, 'round_robin'),
12151219
COALESCE(affinity_mode, 'bounded'),
@@ -1239,7 +1243,7 @@ func (db *DB) GetSystemSettings(ctx context.Context) (*SystemSettings, error) {
12391243
&s.AutoCleanUnauthorized, &s.AutoCleanRateLimited, &s.AdminSecret, &s.AutoCleanFullUsage,
12401244
&s.ProxyPoolEnabled, &s.FastSchedulerEnabled, &s.MaxRetries, &s.MaxRateLimitRetries, &s.AllowRemoteMigration,
12411245
&s.AutoCleanError, &s.AutoCleanExpired, &s.LazyMode, &s.ModelMapping,
1242-
&s.BackgroundRefreshIntervalMinutes, &s.UsageProbeMaxAgeMinutes, &s.RecoveryProbeIntervalMinutes,
1246+
&s.BackgroundRefreshIntervalMinutes, &s.UsageProbeMaxAgeMinutes, &s.UsageProbeConcurrency, &s.RecoveryProbeIntervalMinutes,
12431247
&s.SchedulerMode,
12441248
&s.AffinityMode,
12451249
&s.ResinURL, &s.ResinPlatformName,
@@ -1266,6 +1270,7 @@ func (db *DB) UpdateSystemSettings(ctx context.Context, s *SystemSettings) error
12661270
auto_clean_unauthorized, auto_clean_rate_limited, admin_secret, auto_clean_full_usage, proxy_pool_enabled,
12671271
fast_scheduler_enabled, max_retries, max_rate_limit_retries, allow_remote_migration, auto_clean_error, auto_clean_expired, lazy_mode, model_mapping,
12681272
background_refresh_interval_minutes, usage_probe_max_age_minutes, recovery_probe_interval_minutes,
1273+
usage_probe_concurrency,
12691274
resin_url, resin_platform_name, prompt_filter_enabled, prompt_filter_mode, prompt_filter_threshold,
12701275
prompt_filter_strict_threshold, prompt_filter_log_matches, prompt_filter_max_text_length,
12711276
prompt_filter_sensitive_words, prompt_filter_custom_patterns, prompt_filter_disabled_patterns,
@@ -1275,7 +1280,7 @@ func (db *DB) UpdateSystemSettings(ctx context.Context, s *SystemSettings) error
12751280
scheduler_mode,
12761281
affinity_mode
12771282
)
1278-
VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46)
1283+
VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47)
12791284
ON CONFLICT (id) DO UPDATE SET
12801285
site_name = EXCLUDED.site_name,
12811286
site_logo = EXCLUDED.site_logo,
@@ -1301,6 +1306,7 @@ func (db *DB) UpdateSystemSettings(ctx context.Context, s *SystemSettings) error
13011306
model_mapping = EXCLUDED.model_mapping,
13021307
background_refresh_interval_minutes = EXCLUDED.background_refresh_interval_minutes,
13031308
usage_probe_max_age_minutes = EXCLUDED.usage_probe_max_age_minutes,
1309+
usage_probe_concurrency = EXCLUDED.usage_probe_concurrency,
13041310
recovery_probe_interval_minutes = EXCLUDED.recovery_probe_interval_minutes,
13051311
resin_url = EXCLUDED.resin_url,
13061312
resin_platform_name = EXCLUDED.resin_platform_name,
@@ -1328,6 +1334,7 @@ func (db *DB) UpdateSystemSettings(ctx context.Context, s *SystemSettings) error
13281334
s.AutoCleanUnauthorized, s.AutoCleanRateLimited, s.AdminSecret, s.AutoCleanFullUsage, s.ProxyPoolEnabled,
13291335
s.FastSchedulerEnabled, s.MaxRetries, s.MaxRateLimitRetries, s.AllowRemoteMigration, s.AutoCleanError, s.AutoCleanExpired, s.LazyMode, s.ModelMapping,
13301336
s.BackgroundRefreshIntervalMinutes, s.UsageProbeMaxAgeMinutes, s.RecoveryProbeIntervalMinutes,
1337+
s.UsageProbeConcurrency,
13311338
s.ResinURL, s.ResinPlatformName, s.PromptFilterEnabled, s.PromptFilterMode, s.PromptFilterThreshold,
13321339
s.PromptFilterStrictThreshold, s.PromptFilterLogMatches, s.PromptFilterMaxTextLength,
13331340
s.PromptFilterSensitiveWords, s.PromptFilterCustomPatterns, s.PromptFilterDisabledPatterns,

database/sqlite.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ func (db *DB) migrateSQLite(ctx context.Context) error {
121121
auto_clean_rate_limited INTEGER DEFAULT 0,
122122
background_refresh_interval_minutes INTEGER DEFAULT 2,
123123
usage_probe_max_age_minutes INTEGER DEFAULT 10,
124+
usage_probe_concurrency INTEGER DEFAULT 16,
124125
recovery_probe_interval_minutes INTEGER DEFAULT 30,
125126
admin_secret TEXT DEFAULT '',
126127
auto_clean_full_usage INTEGER DEFAULT 0,
@@ -304,6 +305,7 @@ func (db *DB) migrateSQLite(ctx context.Context) error {
304305
{"system_settings", "auto_clean_rate_limited", "INTEGER DEFAULT 0"},
305306
{"system_settings", "background_refresh_interval_minutes", "INTEGER DEFAULT 2"},
306307
{"system_settings", "usage_probe_max_age_minutes", "INTEGER DEFAULT 10"},
308+
{"system_settings", "usage_probe_concurrency", "INTEGER DEFAULT 16"},
307309
{"system_settings", "recovery_probe_interval_minutes", "INTEGER DEFAULT 30"},
308310
{"system_settings", "admin_secret", "TEXT DEFAULT ''"},
309311
{"system_settings", "auto_clean_full_usage", "INTEGER DEFAULT 0"},

frontend/src/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,8 @@ export const api = {
203203
request<MessageResponse>(`/accounts/${id}`, { method: 'DELETE' }),
204204
refreshAccount: (id: number) =>
205205
request<MessageResponse>(`/accounts/${id}/refresh`, { method: 'POST' }),
206+
forceUsageProbe: () =>
207+
request<{ triggered: boolean; concurrency: number; reason?: string }>(`/accounts/usage/probe`, { method: 'POST' }),
206208
updateAccountScheduler: (id: number, data: UpdateAccountSchedulerRequest) =>
207209
request<MessageResponse>(`/accounts/${id}/scheduler`, { method: 'PATCH', body: JSON.stringify(data) }),
208210
listAccountGroups: () => request<AccountGroupsResponse>('/account-groups'),

0 commit comments

Comments
 (0)