diff --git a/admin/handler.go b/admin/handler.go index ff871184e..1ca0369e6 100644 --- a/admin/handler.go +++ b/admin/handler.go @@ -1156,7 +1156,6 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) { api.GET("/stats", h.GetStats) api.GET("/key-usage-account-permissions/:id", h.GetKeyUsageAccountPermissions) api.PUT("/key-usage-account-permissions/:id", h.SetKeyUsageAccountPermissions) - api.DELETE("/key-usage-reset-uncertain/:id", h.ClearKeyUsageResetUncertain) api.GET("/accounts", h.ListAccounts) api.GET("/accounts/analysis", h.GetAccountAnalysis) api.GET("/accounts/page-stats", h.GetAccountPageStats) diff --git a/admin/key_usage_accounts.go b/admin/key_usage_accounts.go index 84351d36d..dbba3f067 100644 --- a/admin/key_usage_accounts.go +++ b/admin/key_usage_accounts.go @@ -203,11 +203,6 @@ func (h *Handler) GetPublicKeyUsageAccounts(c *gin.Context) { writePublicKeyUsageInternalError(c, err) return } - uncertain, err := h.db.ListKeyUsageResetUncertain(ctx) - if err != nil { - writePublicKeyUsageInternalError(c, err) - return - } visibleGroups := make(map[int64]bool) items := make([]gin.H, 0) runtime := make(map[int64]*auth.Account) @@ -245,16 +240,15 @@ func (h *Handler) GetPublicKeyUsageAccounts(c *gin.Context) { for _, groupID := range groups { visibleGroups[groupID] = true } - item := gin.H{"id": id, "name": accountRow.Name, "email": accountRow.Email, "plan_type": accountRow.PlanType, "status": "unavailable", "group_ids": groups, "reset_allowed": false, "reset_uncertain": false, "usage_percent_5h": nil, "usage_percent_7d": nil} + item := gin.H{"id": id, "name": accountRow.Name, "email": accountRow.Email, "plan_type": accountRow.PlanType, "status": "unavailable", "group_ids": groups, "reset_allowed": false, "usage_percent_5h": nil, "usage_percent_7d": nil} if account == nil { items = append(items, item) continue } u := account.GetAccountLiveUsageSnapshot() - pending := uncertain[resetCreditDatabaseScope(account)] - canReset := p.ResetEnabled && !row.IsExpired(time.Now()) && !pending && isKeyUsageResetEligible(account) + canReset := p.ResetEnabled && !row.IsExpired(time.Now()) && isKeyUsageResetEligible(account) item["plan_type"], item["status"] = u.PlanType, u.Status - item["reset_allowed"], item["reset_uncertain"] = canReset, pending + item["reset_allowed"] = canReset item["active_requests"], item["dynamic_concurrency_limit"] = account.GetActiveRequests(), account.GetDynamicConcurrencyLimit() item["long_window_seconds"] = account.GetWindow7dSeconds() if u.UsagePercent5hValid { @@ -290,46 +284,6 @@ func (h *Handler) GetPublicKeyUsageAccounts(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"accounts": items, "groups": groups, "reset_enabled": p.ResetEnabled}) } -// ClearKeyUsageResetUncertain is an admin-only acknowledgement after checking -// the upstream account; it does not redeem a credit or clear the cooldown. -func (h *Handler) ClearKeyUsageResetUncertain(c *gin.Context) { - id, err := strconv.ParseInt(c.Param("id"), 10, 64) - if err != nil || id <= 0 { - writeError(c, http.StatusBadRequest, "无效的账号 ID") - return - } - if h.store == nil || h.db == nil { - writeError(c, http.StatusServiceUnavailable, "服务未就绪") - return - } - account := h.store.FindByID(id) - if account == nil { - writeError(c, http.StatusNotFound, "账号不存在") - return - } - lock := h.resetCreditLock(account) - lock.Lock() - defer lock.Unlock() - ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second) - defer cancel() - acquired, release, err := h.acquireResetCreditLease(ctx, account) - if err != nil { - writeInternalError(c, err) - return - } - if !acquired { - writeError(c, http.StatusConflict, "该工作区正在执行额度重置") - return - } - defer release() - if err := h.db.ClearKeyUsageResetUncertain(ctx, resetCreditDatabaseScope(account)); err != nil { - writeInternalError(c, err) - return - } - security.SecurityAuditLog("KEY_USAGE_RESET_RECONCILED", fmt.Sprintf("account_id=%d ip=%s", id, security.SanitizeLog(c.ClientIP()))) - c.JSON(http.StatusOK, gin.H{"message": "已解除待核实状态"}) -} - func isKeyUsageResetEligible(account *auth.Account) bool { return account != nil && !account.IsGrokAPI() && !account.IsAntigravityAPI() && !account.IsClaudeOAuth() && !account.IsOpenAIResponsesAPI() && account.GetAccessToken() != "" } @@ -427,11 +381,8 @@ func (h *Handler) ResetPublicKeyUsageAccountCredits(c *gin.Context) { security.SecurityAuditLog("KEY_USAGE_RESET_CREDIT", fmt.Sprintf("key_id=%d account_id=%d ip=%s", row.ID, id, security.SanitizeLog(c.ClientIP()))) outcome, failure := h.consumeResetCreditLocked(consumeCtx, account, requestID.String(), fmt.Sprintf("key_usage:key_id=%d", row.ID)) if failure != nil { - // The upstream may have consumed the credit even when its reply was lost. - // Conservatively fence new public requests until the normal cooldown ends. - h.markResetCreditSuccess(account) log.Printf("[key-usage] reset failed: key_id=%d account_id=%d upstream_status=%d request_error_type=%T refresh_error_type=%T", row.ID, id, failure.Status, failure.RequestErr, failure.RefreshErr) - writeError(c, http.StatusBadGateway, "使用重置卡失败,请联系管理员确认后再重试") + writeError(c, http.StatusBadGateway, "使用重置卡失败,请稍后重试或联系管理员") return } if outcome.InProgress { @@ -439,7 +390,7 @@ func (h *Handler) ResetPublicKeyUsageAccountCredits(c *gin.Context) { return } if outcome.AlreadyHandled { - writeError(c, http.StatusConflict, "该工作区近期已使用重置卡,请稍后重试") + writeError(c, http.StatusConflict, "该工作区暂时不能重复提交,请稍后重试") return } refreshed := waitUsageRefreshAfterReset(outcome.UsageRefreshed) diff --git a/admin/key_usage_accounts_test.go b/admin/key_usage_accounts_test.go index 4aef0dd45..451c49dc5 100644 --- a/admin/key_usage_accounts_test.go +++ b/admin/key_usage_accounts_test.go @@ -244,7 +244,7 @@ func TestPublicKeyUsageAccountAuthorizationAndReset(t *testing.T) { } } -func TestPublicResetAcrossInstancesLostResponseFailsClosed(t *testing.T) { +func TestPublicResetAcrossInstancesUsesAutomaticCooldown(t *testing.T) { gin.SetMode(gin.TestMode) ctx := context.Background() db := newTestAdminDB(t) @@ -304,51 +304,33 @@ func TestPublicResetAcrossInstancesLostResponseFailsClosed(t *testing.T) { t.Fatalf("lost response status=%d", got) } scope := resetCreditDatabaseScope(h2.store.FindByID(id)) - if uncertain, err := db.KeyUsageResetUncertain(ctx, scope); err != nil || !uncertain { - t.Fatalf("uncertain=%v err=%v", uncertain, err) - } if got := call(r2, uuid.NewString()); got != 409 { t.Fatalf("cooldown status=%d", got) } - if got := call(r2, firstID); got != 409 { - t.Fatalf("idempotent replay status=%d", got) - } - // Expire the ordinary cooldown: uncertainty must still fence automatic work. now := time.Now().Unix() if ok, err := db.ReserveKeyUsageResetCooldown(ctx, scope, now+360, now-1); err != nil || !ok { t.Fatalf("expire cooldown: ok=%v err=%v", ok, err) } - outcome, failure := h2.consumeResetCreditLocked(ctx, h2.store.FindByID(id), uuid.NewString(), "auto") - if failure != nil || !outcome.AlreadyHandled { - t.Fatalf("automatic retry after ambiguous result: outcome=%+v failure=%+v", outcome, failure) - } - if calls.Load() != 1 { - t.Fatalf("upstream calls=%d, want 1", calls.Load()) - } - adminRouter := gin.New() - adminRouter.DELETE("/uncertain/:id", h2.ClearKeyUsageResetUncertain) - recorder := httptest.NewRecorder() - adminRouter.ServeHTTP(recorder, httptest.NewRequest("DELETE", fmt.Sprintf("/uncertain/%d", id), nil)) - if recorder.Code != 200 { - t.Fatalf("admin reconcile status=%d body=%s", recorder.Code, recorder.Body.String()) + if got := call(r2, firstID); got != 409 { + t.Fatalf("idempotent replay status=%d", got) } - if uncertain, err := db.KeyUsageResetUncertain(ctx, scope); err != nil || uncertain { - t.Fatalf("reconciled uncertain=%v err=%v", uncertain, err) + if ok, err := db.ReserveKeyUsageResetCooldown(ctx, scope, now+360, now-1); err != nil || !ok { + t.Fatalf("expire cooldown: ok=%v err=%v", ok, err) } - // A prior automatic/manual attempt on another instance must fence a new - // public action even after explicit uncertainty reconciliation. - if err := db.RecordKeyUsageResetCooldown(ctx, scope, time.Now().Add(5*time.Minute).Unix()); err != nil { - t.Fatal(err) + h2.consumeResetCredit = func(context.Context, *auth.Account, string, string) (*proxy.WhamResetResult, *http.Response, error) { + calls.Add(1) + return &proxy.WhamResetResult{WindowsReset: 1}, &http.Response{StatusCode: http.StatusOK}, nil } - if got := call(r2, uuid.NewString()); got != 409 { - t.Fatalf("prior automatic reset status=%d", got) + h2.probeUsage = func(context.Context, *auth.Account) error { return nil } + if got := call(r2, uuid.NewString()); got != 200 { + t.Fatalf("retry after automatic cooldown status=%d", got) } - if calls.Load() != 1 { - t.Fatalf("upstream calls after cooldown=%d", calls.Load()) + if calls.Load() != 2 { + t.Fatalf("upstream calls=%d, want 2", calls.Load()) } } -func TestAutomaticLostResetResponseBlocksPublicAfterCooldown(t *testing.T) { +func TestFailedAutoResetDoesNotRequireManualReconciliation(t *testing.T) { gin.SetMode(gin.TestMode) ctx := context.Background() db := newTestAdminDB(t) @@ -368,23 +350,16 @@ func TestAutomaticLostResetResponseBlocksPublicAfterCooldown(t *testing.T) { account.SetRateLimitResetCredits(3) store.AddAccount(account) var calls atomic.Int32 - autoID := uuid.NewString() - h := &Handler{db: db, store: store, probeUsage: func(context.Context, *auth.Account) error { return nil }, consumeResetCredit: func(_ context.Context, _ *auth.Account, _ string, requestID string) (*proxy.WhamResetResult, *http.Response, error) { - if requestID != autoID { - t.Errorf("unexpected auto id %s", requestID) - } + h := &Handler{db: db, store: store, probeUsage: func(context.Context, *auth.Account) error { return nil }, consumeResetCredit: func(context.Context, *auth.Account, string, string) (*proxy.WhamResetResult, *http.Response, error) { if calls.Add(1) == 1 { return nil, nil, fmt.Errorf("synthetic lost response") } return &proxy.WhamResetResult{WindowsReset: 1}, &http.Response{StatusCode: http.StatusOK}, nil }} - if _, failure := h.consumeResetCreditLocked(ctx, account, autoID, "auto"); failure == nil { + if _, failure := h.consumeResetCreditLocked(ctx, account, uuid.NewString(), "auto"); failure == nil { t.Fatal("expected lost response") } scope := resetCreditDatabaseScope(account) - if uncertain, err := db.KeyUsageResetUncertain(ctx, scope); err != nil || !uncertain { - t.Fatalf("uncertain=%v err=%v", uncertain, err) - } now := time.Now().Unix() if ok, err := db.ReserveKeyUsageResetCooldown(ctx, scope, now+360, now-1); err != nil || !ok { t.Fatalf("expire cooldown: ok=%v err=%v", ok, err) @@ -396,16 +371,7 @@ func TestAutomaticLostResetResponseBlocksPublicAfterCooldown(t *testing.T) { req.Header.Set("Idempotency-Key", uuid.NewString()) w := httptest.NewRecorder() r.ServeHTTP(w, req) - if w.Code != 409 || calls.Load() != 1 { + if w.Code != 200 || calls.Load() != 2 { t.Fatalf("public reset status=%d calls=%d body=%s", w.Code, calls.Load(), w.Body.String()) } - if outcome, failure := h.consumeResetCreditLocked(ctx, account, uuid.NewString(), "auto"); failure != nil || !outcome.AlreadyHandled { - t.Fatalf("different auto id: outcome=%+v failure=%+v", outcome, failure) - } - if outcome, failure := h.consumeResetCreditLocked(ctx, account, autoID, "auto"); failure != nil || outcome.AlreadyHandled || calls.Load() != 2 { - t.Fatalf("same auto id retry: outcome=%+v failure=%+v calls=%d", outcome, failure, calls.Load()) - } - if uncertain, err := db.KeyUsageResetUncertain(ctx, scope); err != nil || uncertain { - t.Fatalf("successful retry uncertain=%v err=%v", uncertain, err) - } } diff --git a/admin/reset_credits.go b/admin/reset_credits.go index 369485830..88f675ba8 100644 --- a/admin/reset_credits.go +++ b/admin/reset_credits.go @@ -212,17 +212,6 @@ func (h *Handler) consumeResetCreditLocked(ctx context.Context, account *auth.Ac return resetCreditConsumeOutcome{InProgress: true}, nil } defer releaseLease() - sameAutoRetry := false - if h.db != nil && (source == "auto" || strings.HasPrefix(source, "key_usage:")) { - previousID, previousSource, uncertain, err := h.db.GetKeyUsageResetUncertain(ctx, resetCreditDatabaseScope(account)) - if err != nil { - return resetCreditConsumeOutcome{}, &resetCreditConsumeFailure{RequestErr: fmt.Errorf("read reset uncertainty: %w", err)} - } - sameAutoRetry = uncertain && source == "auto" && previousSource == "auto" && redeemRequestID != "" && previousID == redeemRequestID - if uncertain && !sameAutoRetry { - return resetCreditConsumeOutcome{AlreadyHandled: true}, nil - } - } if source == "auto" || strings.HasPrefix(source, "key_usage:") { coolingDown, cooldownErr := h.resetCreditCooldownActive(ctx, account) if cooldownErr != nil { @@ -262,15 +251,6 @@ func (h *Handler) consumeResetCreditLocked(ctx context.Context, account *auth.Ac return resetCreditConsumeOutcome{AlreadyHandled: true}, nil } } - if h.db != nil && (source == "auto" || strings.HasPrefix(source, "key_usage:")) { - reserved, err := h.db.ReserveKeyUsageResetUncertain(ctx, resetCreditDatabaseScope(account), redeemRequestID, source) - if err != nil { - return resetCreditConsumeOutcome{}, &resetCreditConsumeFailure{RequestErr: fmt.Errorf("reserve reset uncertainty: %w", err)} - } - if !reserved && !sameAutoRetry { - return resetCreditConsumeOutcome{AlreadyHandled: true}, nil - } - } proxyURL := h.store.ResolveProxyForAccount(account) result, resp, err := h.consumeResetCreditUpstream(ctx, account, proxyURL, redeemRequestID) status := upstreamResetStatus(resp) @@ -294,14 +274,6 @@ func (h *Handler) consumeResetCreditLocked(ctx context.Context, account *auth.Ac if err != nil { return resetCreditConsumeOutcome{}, &resetCreditConsumeFailure{Status: status, RequestErr: err} } - if h.db != nil && (source == "auto" || strings.HasPrefix(source, "key_usage:")) { - if err := h.db.ClearKeyUsageResetUncertain(ctx, resetCreditDatabaseScope(account)); err != nil { - // The upstream succeeded. Keep the uncertainty fence and return a - // generic failure so an administrator can reconcile it safely. - return resetCreditConsumeOutcome{}, &resetCreditConsumeFailure{RequestErr: fmt.Errorf("clear reset uncertainty: %w", err)} - } - } - outcome := resetCreditConsumeOutcome{Remaining: h.applyOptimisticResetDecrement(account)} if result != nil { outcome.WindowsReset = result.WindowsReset diff --git a/database/key_usage_account_permissions.go b/database/key_usage_account_permissions.go index d76013402..943a6d542 100644 --- a/database/key_usage_account_permissions.go +++ b/database/key_usage_account_permissions.go @@ -110,51 +110,3 @@ func (db *DB) RecordKeyUsageResetCooldown(ctx context.Context, scope string, exp _, err := db.conn.ExecContext(ctx, `INSERT INTO key_usage_reset_cooldowns (scope, expires_at) VALUES ($1, $2) ON CONFLICT(scope) DO UPDATE SET expires_at = CASE WHEN key_usage_reset_cooldowns.expires_at > excluded.expires_at THEN key_usage_reset_cooldowns.expires_at ELSE excluded.expires_at END`, scope, expires) return err } - -// ReserveKeyUsageResetUncertain is written before contacting upstream and is -// retained until a confirmed success or explicit administrator reconciliation. -func (db *DB) ReserveKeyUsageResetUncertain(ctx context.Context, scope, requestID, source string) (bool, error) { - r, err := db.conn.ExecContext(ctx, `INSERT INTO key_usage_reset_uncertain (scope, request_id, source) VALUES ($1, $2, $3) ON CONFLICT(scope) DO NOTHING`, scope, requestID, source) - if err != nil { - return false, err - } - n, err := r.RowsAffected() - return n == 1, err -} - -func (db *DB) GetKeyUsageResetUncertain(ctx context.Context, scope string) (string, string, bool, error) { - var requestID, source string - err := db.conn.QueryRowContext(ctx, `SELECT request_id, source FROM key_usage_reset_uncertain WHERE scope=$1`, scope).Scan(&requestID, &source) - if errors.Is(err, sql.ErrNoRows) { - return "", "", false, nil - } - return requestID, source, err == nil, err -} - -func (db *DB) KeyUsageResetUncertain(ctx context.Context, scope string) (bool, error) { - var exists bool - err := db.conn.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM key_usage_reset_uncertain WHERE scope=$1)`, scope).Scan(&exists) - return exists, err -} - -func (db *DB) ClearKeyUsageResetUncertain(ctx context.Context, scope string) error { - _, err := db.conn.ExecContext(ctx, `DELETE FROM key_usage_reset_uncertain WHERE scope=$1`, scope) - return err -} - -func (db *DB) ListKeyUsageResetUncertain(ctx context.Context) (map[string]bool, error) { - rows, err := db.conn.QueryContext(ctx, `SELECT scope FROM key_usage_reset_uncertain`) - if err != nil { - return nil, err - } - defer rows.Close() - result := make(map[string]bool) - for rows.Next() { - var scope string - if err := rows.Scan(&scope); err != nil { - return nil, err - } - result[scope] = true - } - return result, rows.Err() -} diff --git a/database/key_usage_account_permissions_test.go b/database/key_usage_account_permissions_test.go index e7eb1b581..d2b7454a8 100644 --- a/database/key_usage_account_permissions_test.go +++ b/database/key_usage_account_permissions_test.go @@ -72,7 +72,6 @@ func TestKeyUsageAccountPermissionsPostgres(t *testing.T) { _, _ = db.conn.ExecContext(ctx, `DELETE FROM key_usage_reset_attempts WHERE request_id=$1`, requestID) _, _ = db.conn.ExecContext(ctx, `DELETE FROM key_usage_reset_leases WHERE scope=$1`, scope) _, _ = db.conn.ExecContext(ctx, `DELETE FROM key_usage_reset_cooldowns WHERE scope=$1`, scope) - _, _ = db.conn.ExecContext(ctx, `DELETE FROM key_usage_reset_uncertain WHERE scope=$1`, scope) }() accounts, err := db.ListKeyUsageAccountAccess(ctx) if err != nil { @@ -113,16 +112,4 @@ func TestKeyUsageAccountPermissionsPostgres(t *testing.T) { if err := db.RecordKeyUsageResetCooldown(ctx, scope, 102); err != nil { t.Fatal(err) } - if ok, err := db.ReserveKeyUsageResetUncertain(ctx, scope, requestID, "auto"); err != nil || !ok { - t.Fatalf("uncertain=%v err=%v", ok, err) - } - if gotID, gotSource, ok, err := db.GetKeyUsageResetUncertain(ctx, scope); err != nil || !ok || gotID != requestID || gotSource != "auto" { - t.Fatalf("uncertain detail id=%q source=%q exists=%v err=%v", gotID, gotSource, ok, err) - } - if ok, err := db.KeyUsageResetUncertain(ctx, scope); err != nil || !ok { - t.Fatalf("uncertain lookup=%v err=%v", ok, err) - } - if err := db.ClearKeyUsageResetUncertain(ctx, scope); err != nil { - t.Fatal(err) - } } diff --git a/database/postgres.go b/database/postgres.go index 3d9503bdc..2060c0c5f 100644 --- a/database/postgres.go +++ b/database/postgres.go @@ -1129,7 +1129,6 @@ func (db *DB) migrate(ctx context.Context) error { CREATE TABLE IF NOT EXISTS key_usage_reset_attempts (request_id TEXT PRIMARY KEY, key_id BIGINT NOT NULL, account_id BIGINT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); CREATE TABLE IF NOT EXISTS key_usage_reset_leases (scope TEXT PRIMARY KEY, owner TEXT NOT NULL, expires_at BIGINT NOT NULL); CREATE TABLE IF NOT EXISTS key_usage_reset_cooldowns (scope TEXT PRIMARY KEY, expires_at BIGINT NOT NULL); - CREATE TABLE IF NOT EXISTS key_usage_reset_uncertain (scope TEXT PRIMARY KEY, request_id TEXT NOT NULL, source TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); CREATE TABLE IF NOT EXISTS accounts ( id SERIAL PRIMARY KEY, name VARCHAR(255) DEFAULT '', diff --git a/database/sqlite.go b/database/sqlite.go index 2ef33109e..59bc29d1e 100644 --- a/database/sqlite.go +++ b/database/sqlite.go @@ -115,7 +115,6 @@ func (db *DB) migrateSQLite(ctx context.Context) error { `CREATE TABLE IF NOT EXISTS key_usage_reset_attempts (request_id TEXT PRIMARY KEY, key_id INTEGER NOT NULL, account_id INTEGER NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP);`, `CREATE TABLE IF NOT EXISTS key_usage_reset_leases (scope TEXT PRIMARY KEY, owner TEXT NOT NULL, expires_at BIGINT NOT NULL);`, `CREATE TABLE IF NOT EXISTS key_usage_reset_cooldowns (scope TEXT PRIMARY KEY, expires_at BIGINT NOT NULL);`, - `CREATE TABLE IF NOT EXISTS key_usage_reset_uncertain (scope TEXT PRIMARY KEY, request_id TEXT NOT NULL, source TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP);`, `CREATE TABLE IF NOT EXISTS accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT DEFAULT '', diff --git a/docs/API.md b/docs/API.md index 7ee8b9ff9..064645b42 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2842,8 +2842,7 @@ curl -X DELETE http://localhost:8080/api/admin/images/jobs/1 \ - `GET /api/admin/key-usage-account-permissions/:id` / `PUT /api/admin/key-usage-account-permissions/:id`:需要管理鉴权;`id=0` 为全局,其他值为 API Key ID。PUT 的 JSON 为 `{"view_enabled":true,"reset_enabled":false}`,两个字段均必填。 - `GET /api/key-usage/accounts`:使用自助页的 API Key 鉴权,返回当前允许范围内的账号、套餐、状态、用量窗口、更新时间和缓存的重置卡数量;不返回上游凭据、代理或自定义请求头。未装载到运行池的账号显示为 `unavailable`,用量未知且不能重置;当这类账号的渠道或套餐权限无法确定时不予返回。已删除账号不在列表中。页面或查看权限关闭返回 404。 -- `POST /api/key-usage/accounts/:id/reset-credits`:使用同一 API Key 鉴权,必须传入每次操作唯一的 `Idempotency-Key` UUID;同一请求 ID 再次提交返回 409,不重复调用上游。需要查看和重置权限,并重新校验 Key、账号分组、渠道、套餐和账号反向授权。过期 Key 不能重置。只允许支持的 Codex 账号。操作消耗该账号的上游重置卡,会影响共享同一工作区的其他用户;近期已重置或结果不确定的工作区会暂时拒绝新操作。网络异常后请先检查账号状态,勿盲目重试。 -- 自助或自动重置发出后若结果不明确,会持久保留待核实状态,禁止该工作区的自助重置以及新请求 ID 的自动重置;自动流程可以用原有稳定请求 ID 重试,确认成功后清除标记。管理员确认上游卡券及用量后,也可在 API Key 管理页输入账号 ID 解除待核实,或使用管理鉴权调用 `DELETE /api/admin/key-usage-reset-uncertain/:id`;该操作不使用重置卡,也不会清除短时冷却。 +- `POST /api/key-usage/accounts/:id/reset-credits`:使用同一 API Key 鉴权,必须传入每次操作唯一的 `Idempotency-Key` UUID;同一请求 ID 再次提交返回 409,不重复调用上游。需要查看和重置权限,并重新校验 Key、账号分组、渠道、套餐和账号反向授权。过期 Key 不能重置。只允许支持的 Codex 账号。操作消耗该账号的上游重置卡,会影响共享同一工作区的其他用户;近期已提交重置的工作区会暂时拒绝重复操作。网络异常后请先检查账号状态,勿盲目重试。 自助账号列表的 5h/长窗口是账号运行态缓存,可能滞后;未知值为 `null`,不是 0。`long_window_seconds` 给出长窗口的实际上游周期(未知为 0);部分套餐可能是月窗口,而非 7 天。API Key 的金额与请求预算和上游账号用量是不同的额度。空的 API Key 分组限制表示不限分组;公开账号信息之前,管理员应检查 Key 的授权范围。 diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f307580f6..19a00f6cb 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -342,7 +342,6 @@ export interface PublicKeyUsageAccount { usage_updated_at_7d?: string reset_credits?: number reset_allowed: boolean - reset_uncertain: boolean } async function requestAPIKeyUsage(path: string, apiKey: string, options: RequestInit = {}): Promise { @@ -592,8 +591,7 @@ export const api = { getKeyUsageAccountPermissions: (id: number) => request<{ view_enabled: boolean; reset_enabled: boolean }>(`/key-usage-account-permissions/${id}`), setKeyUsageAccountPermissions: (id: number, data: { view_enabled: boolean; reset_enabled: boolean }) => request<{ view_enabled: boolean; reset_enabled: boolean }>(`/key-usage-account-permissions/${id}`, { method: 'PUT', body: JSON.stringify(data) }), - clearKeyUsageResetUncertain: (accountID: number) => request<{ message: string }>(`/key-usage-reset-uncertain/${accountID}`, { method: 'DELETE' }), - getPublicKeyUsageAccounts: (key: string) => requestAPIKeyUsage<{ accounts: PublicKeyUsageAccount[]; groups: { id: number; name: string }[]; reset_enabled: boolean }>('/accounts', key), + getPublicKeyUsageAccounts: (key: string, options: RequestInit = {}) => requestAPIKeyUsage<{ accounts: PublicKeyUsageAccount[]; groups: { id: number; name: string }[]; reset_enabled: boolean }>('/accounts', key, options), resetPublicKeyUsageCredits: (key: string, id: number, requestID: string) => requestAPIKeyUsage<{ message: string; usage_refreshed: boolean }>(`/accounts/${id}/reset-credits`, key, { method: 'POST', headers: { 'Idempotency-Key': requestID } }), getBranding: () => requestPublic('/api/branding'), // 公开账号自助门户:生成 OpenAI 授权链接(无鉴权)。 diff --git a/frontend/src/branding.tsx b/frontend/src/branding.tsx index fbcf1a56f..f0eda6ef3 100644 --- a/frontend/src/branding.tsx +++ b/frontend/src/branding.tsx @@ -1,3 +1,4 @@ +import { normalizeKeyUsageView } from './lib/keyUsageViews' import { createContext, type PropsWithChildren, useCallback, useContext, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useLocation } from 'react-router-dom' @@ -128,7 +129,7 @@ export function BrandingProvider({ children }: PropsWithChildren) { useEffect(() => { const [, portal, view] = pathname.split('/') if (portal === 'key-usage') { - const activeView = view === 'quota' || view === 'logs' ? view : 'overview' + const activeView = normalizeKeyUsageView(view) document.title = `${t(`keyUsage.views.${activeView}`)} · ${t('keyUsage.title')} · ${siteName}` } else if (portal === 'image-studio') { document.title = `${t('imageStudioPortal.title')} · ${siteName}` diff --git a/frontend/src/components/AccountCardSurface.tsx b/frontend/src/components/AccountCardSurface.tsx new file mode 100644 index 000000000..ff36acbe1 --- /dev/null +++ b/frontend/src/components/AccountCardSurface.tsx @@ -0,0 +1,9 @@ +import type { ComponentProps } from 'react' +import { cn } from '@/lib/utils' +import './account-cards.css' + +// Shared by management cards and the read-only API key account portal. Actions +// are supplied by each caller; this surface never invokes a management API. +export function AccountCardSurface({ className, ...props }: ComponentProps<'article'>) { + return
+} diff --git a/frontend/src/components/AccountPlanBadge.tsx b/frontend/src/components/AccountPlanBadge.tsx new file mode 100644 index 000000000..e823cb8a1 --- /dev/null +++ b/frontend/src/components/AccountPlanBadge.tsx @@ -0,0 +1,115 @@ +import { useTranslation } from "react-i18next"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +export function normalizePlanType(planType?: string): string { + const raw = (planType || "").toLowerCase().trim(); + if (raw === "prolite" || raw === "pro_lite" || raw === "pro-lite") + return "pro"; + if (raw === "promax" || raw === "pro_max" || raw === "pro-max") + return "pro"; + if ( + raw === "ent26" || + raw === "enterprise_cbp_usage_based" || + raw === "enterprise_cbp_automation" + ) + return "enterprise"; + if (raw === "edu_plus" || raw === "edu_pro") return "edu"; + return raw; +} + +function formatPlanLabel(planType?: string): string { + const raw = (planType || "").trim(); + if (!raw) return "-"; + const lower = raw.toLowerCase(); + if (lower === "prolite" || lower === "pro_lite" || lower === "pro-lite") + return "ProLite"; + if (lower === "promax" || lower === "pro_max" || lower === "pro-max") + return "ProMax"; + if (lower === "self_serve_business_prolite") return "team5x"; + if (lower === "self_serve_business_usage_based") return "Business"; + if (lower === "ent26" || lower === "enterprise_cbp_usage_based") + return "Enterprise"; + if (lower === "enterprise_cbp_automation") return "Enterprise (Automation)"; + if (lower === "edu_plus") return "Edu Plus"; + if (lower === "edu_pro") return "Edu Pro"; + return raw; +} + +function isWorkspacePlan(planType?: string): boolean { + const normalized = normalizePlanType(planType); + return ( + normalized === "team" || + normalized === "teamplus" || + normalized === "k12" || + normalized === "edu" || + normalized === "education" + ); +} + +export default function PlanBadge({ + planType, + workspaceId, +}: { + planType?: string; + workspaceId?: string; +}) { + const { t } = useTranslation(); + const label = formatPlanLabel(planType); + if (label === "-") + return -; + + const style: Record = { + pro: "bg-violet-100 text-violet-700 ring-violet-500/30 dark:bg-violet-500/20 dark:text-violet-300 dark:ring-violet-400/30", + prolite: + "bg-purple-50 text-purple-600 ring-purple-400/25 dark:bg-purple-500/15 dark:text-purple-300 dark:ring-purple-400/25", + promax: + "bg-fuchsia-100 text-fuchsia-700 ring-fuchsia-500/35 dark:bg-fuchsia-500/20 dark:text-fuchsia-300 dark:ring-fuchsia-400/35", + plus: "bg-blue-100 text-blue-700 ring-blue-500/30 dark:bg-blue-500/20 dark:text-blue-300 dark:ring-blue-400/30", + team: "bg-amber-100 text-amber-700 ring-amber-500/30 dark:bg-amber-500/20 dark:text-amber-300 dark:ring-amber-400/30", + k12: "bg-emerald-100 text-emerald-700 ring-emerald-500/30 dark:bg-emerald-500/20 dark:text-emerald-300 dark:ring-emerald-400/30", + free: "bg-zinc-100 text-zinc-500 ring-zinc-400/20 dark:bg-zinc-500/10 dark:text-zinc-400 dark:ring-zinc-400/15", + }; + + const normalized = normalizePlanType(planType); + const key = + normalized === "pro" && label === "ProLite" + ? "prolite" + : normalized === "pro" && label === "ProMax" + ? "promax" + : label === "team5x" + ? "team" + : normalized; + const cls = + style[key] || + "bg-slate-100 text-slate-600 ring-slate-400/20 dark:bg-slate-500/15 dark:text-slate-300 dark:ring-slate-400/20"; + const trimmedWorkspaceId = workspaceId?.trim() ?? ""; + const badge = ( + + {label} + + ); + if (!isWorkspacePlan(planType) || !trimmedWorkspaceId) { + return badge; + } + + return ( + + + + + {badge} + + + + {t("accounts.planWorkspaceId", { id: trimmedWorkspaceId })} + + + + ); +} diff --git a/frontend/src/components/AccountUsageBar.tsx b/frontend/src/components/AccountUsageBar.tsx new file mode 100644 index 000000000..81015887e --- /dev/null +++ b/frontend/src/components/AccountUsageBar.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from 'react-i18next' +import { formatBeijingTime } from '../utils/time' + +export default function AccountUsageBar({ label, pct, resetAt, detailText }: { + label: string + pct: number | null + resetAt?: string + detailText?: string +}) { + const { t } = useTranslation() + const known = pct !== null && Number.isFinite(pct) + const resetTime = resetAt && Date.parse(resetAt) > Date.now() + ? formatBeijingTime(resetAt, '') : '' + const color = known && pct >= 90 ? 'bg-red-500' + : known && pct >= 70 ? 'bg-amber-500' : 'bg-emerald-500' + return
+
+ {label} +
+ {known &&
} +
+ {known ? `${pct.toFixed(1)}%` : t('keyUsageAccounts.unknown')} +
+
+ {detailText &&
{detailText}
} + {resetTime &&
⏱ {resetTime.slice(5)}
} +
+
+} diff --git a/frontend/src/components/KeyUsageAccountPermissions.tsx b/frontend/src/components/KeyUsageAccountPermissions.tsx index f0076c845..cb2940a4b 100644 --- a/frontend/src/components/KeyUsageAccountPermissions.tsx +++ b/frontend/src/components/KeyUsageAccountPermissions.tsx @@ -1,68 +1,58 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import { Eye, RotateCcw, Loader2 } from 'lucide-react' import { api } from '../api' -import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' -import { Select } from '@/components/ui/select' -import { Input } from '@/components/ui/input' -import { useConfirmDialog } from '../hooks/useConfirmDialog' import { getErrorMessage } from '../utils/error' type Policy = { view_enabled: boolean; reset_enabled: boolean } -export default function KeyUsageAccountPermissions({ keys }: { keys: { id: number; name: string }[] }) { +export default function KeyUsageAccountPermissions({ keyID = 0 }: { keyID?: number }) { const { t } = useTranslation() - const [id, setId] = useState(0) const [policy, setPolicy] = useState(null) - const [message, setMessage] = useState('') + const [error, setError] = useState('') const [saving, setSaving] = useState(false) - const [accountID, setAccountID] = useState('') - const { confirm, confirmDialog } = useConfirmDialog() + const [saved, setSaved] = useState(false) + const global = keyID === 0 useEffect(() => { let cancelled = false setPolicy(null) - setMessage('') - void api.getKeyUsageAccountPermissions(id).then(p => { if (!cancelled) setPolicy(p) }).catch(e => { if (!cancelled) setMessage(getErrorMessage(e)) }) + setError('') + setSaved(false) + void api.getKeyUsageAccountPermissions(keyID).then(value => { if (!cancelled) setPolicy(value) }).catch(e => { if (!cancelled) setError(getErrorMessage(e)) }) return () => { cancelled = true } - }, [id]) + }, [keyID]) - const save = async () => { - if (!policy) return + const update = async (patch: Partial) => { + if (!policy || saving) return setSaving(true) - setMessage('') - try { await api.setKeyUsageAccountPermissions(id, policy); setMessage(t('keyUsageAccounts.saved')) } - catch (e) { setMessage(getErrorMessage(e)) } + setSaved(false) + setError('') + try { + const value = await api.setKeyUsageAccountPermissions(keyID, { ...policy, ...patch }) + setPolicy(value) + setSaved(true) + } catch (e) { setError(getErrorMessage(e)) } finally { setSaving(false) } } - const reconcile = async () => { - const account = Number(accountID) - if (!Number.isSafeInteger(account) || account <= 0 || !await confirm({ title: t('keyUsageAccounts.reconcile'), description: t('keyUsageAccounts.reconcileConfirm'), tone: 'destructive' })) return - setSaving(true); setMessage('') - try { await api.clearKeyUsageResetUncertain(account); setMessage(t('keyUsageAccounts.saved')) } - catch (e) { setMessage(getErrorMessage(e)) } - finally { setSaving(false) } - } - - return
- {confirmDialog} -
{t('keyUsageAccounts.title')}
-

{t('keyUsageAccounts.intro')}

-
{t('keyUsageAccounts.specific')} - setAccountID(e.target.value)} disabled={saving} />
+ return
+
+

{t(global ? 'keyUsageAccounts.globalTitle' : 'keyUsageAccounts.keyTitle')}

{saving && }
+

{t(global ? 'keyUsageAccounts.globalDesc' : 'keyUsageAccounts.keyDesc')}

+
+
+ {([ + ['view_enabled', Eye, global ? 'keyUsageAccounts.view' : 'keyUsageAccounts.inheritView', 'keyUsageAccounts.viewHint'], + ['reset_enabled', RotateCcw, global ? 'keyUsageAccounts.reset' : 'keyUsageAccounts.inheritReset', 'keyUsageAccounts.resetHint'], + ] as const).map(([field, Icon, label, hint]) =>
+

{t(hint)}

+ void update({ [field]: value })} disabled={!policy || saving} /> +
)}
-
+
+ {error ? {error} : t(saved ? 'keyUsageAccounts.saved' : 'keyUsageAccounts.immediateSave')} +
+ } diff --git a/frontend/src/components/KeyUsageAccounts.tsx b/frontend/src/components/KeyUsageAccounts.tsx index 4fe0b9666..a5869cd5d 100644 --- a/frontend/src/components/KeyUsageAccounts.tsx +++ b/frontend/src/components/KeyUsageAccounts.tsx @@ -1,61 +1,123 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { Clock3, Gauge, RefreshCw, RotateCcw, Search, Ticket } from 'lucide-react' import { AdminAPIError, api, type PublicKeyUsageAccount } from '../api' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' import { Select } from '@/components/ui/select' +import { Badge } from '@/components/ui/badge' +import { AccountCardSurface } from './AccountCardSurface' +import AccountUsageBar from './AccountUsageBar' +import PlanBadge from './AccountPlanBadge' +import StatusBadge from './StatusBadge' +import StateShell from './StateShell' import { getErrorMessage } from '../utils/error' +import { formatBeijingTime, formatRelativeTime } from '../utils/time' import { useConfirmDialog } from '../hooks/useConfirmDialog' import { createUUID } from '../lib/uuid' export default function KeyUsageAccounts({ apiKey }: { apiKey: string }) { const { t } = useTranslation() const { confirm, confirmDialog } = useConfirmDialog() - const [accounts, setAccounts] = useState(null) + const [accounts, setAccounts] = useState([]) const [groups, setGroups] = useState<{ id: number; name: string }[]>([]) const [group, setGroup] = useState('all') + const [search, setSearch] = useState('') + const [loading, setLoading] = useState(true) + const [disabled, setDisabled] = useState(false) const [error, setError] = useState('') const [busy, setBusy] = useState(null) const [notice, setNotice] = useState('') const [generation, setGeneration] = useState(0) + const actionPending = useRef(false) useEffect(() => { - let cancelled = false - setAccounts(null) + const controller = new AbortController() + setLoading(true) setError('') - void api.getPublicKeyUsageAccounts(apiKey).then(data => { if (!cancelled) { setAccounts(data.accounts); setGroups(data.groups); setGroup(current => current === 'all' || current === 'ungrouped' || data.groups.some(g => String(g.id) === current) ? current : 'all') } }).catch(e => { - if (!cancelled && !(e instanceof AdminAPIError && e.status === 404)) setError(getErrorMessage(e)) - }) - return () => { cancelled = true } + void api.getPublicKeyUsageAccounts(apiKey, { signal: controller.signal }).then(data => { + if (controller.signal.aborted) return + setAccounts(data.accounts) + setGroups(data.groups) + setDisabled(false) + setGroup(current => current === 'all' || current === 'ungrouped' || data.groups.some(g => String(g.id) === current) ? current : 'all') + }).catch(e => { + if (controller.signal.aborted) return + if (e instanceof AdminAPIError && e.status === 404) { + setDisabled(true) + setAccounts([]) + setGroups([]) + } else setError(getErrorMessage(e)) + }).finally(() => { if (!controller.signal.aborted) setLoading(false) }) + return () => controller.abort() }, [apiKey, generation]) - const redeem = async (id: number) => { - if (busy !== null || !await confirm({ title: t('keyUsageAccounts.redeem'), description: t('keyUsageAccounts.confirm'), confirmText: t('keyUsageAccounts.redeem'), tone: 'destructive' })) return - setBusy(id) - setError('') - setNotice('') - try { await api.resetPublicKeyUsageCredits(apiKey, id, createUUID()); setNotice(t('keyUsageAccounts.done')); setGeneration(n => n + 1) } - catch (e) { setError(getErrorMessage(e)) } - finally { setBusy(null) } + const redeem = async (account: PublicKeyUsageAccount) => { + if (actionPending.current) return + actionPending.current = true + try { + const accepted = await confirm({ + title: t('keyUsageAccounts.redeem'), + description:

{account.name || account.email || `#${account.id}`}

{t('keyUsageAccounts.confirm')}

, + confirmText: t('keyUsageAccounts.redeem'), tone: 'destructive', + }) + if (!accepted) return + setBusy(account.id) + setError('') + setNotice('') + const result = await api.resetPublicKeyUsageCredits(apiKey, account.id, createUUID()) + setNotice(t(result.usage_refreshed ? 'keyUsageAccounts.done' : 'keyUsageAccounts.refreshPending')) + setGeneration(value => value + 1) + } catch (e) { setError(getErrorMessage(e)) } + finally { setBusy(null); actionPending.current = false } } - if (!accounts && !error && !notice) return null - const fmt = (s?: string) => s ? new Date(s).toLocaleString() : t('keyUsageAccounts.unknown') - const visible = accounts?.filter(a => group === 'all' || (group === 'ungrouped' ? a.group_ids.length === 0 : a.group_ids.includes(Number(group)))) - return
+ + const visible = useMemo(() => accounts.filter(account => { + const matchesGroup = group === 'all' || (group === 'ungrouped' ? account.group_ids.length === 0 : account.group_ids.includes(Number(group))) + const needle = search.trim().toLowerCase() + return matchesGroup && (!needle || `${account.name} ${account.email}`.toLowerCase().includes(needle)) + }), [accounts, group, search]) + + return
{confirmDialog} -

{t('keyUsageAccounts.title')}

-

{t('keyUsageAccounts.resetWarning')}

- setSearch(event.target.value)} className="pl-9" />
+