luckfun233 commited on
Commit
766bd1c
·
1 Parent(s): 534def0

feat: Implement account role management and banning functionality

Browse files

- Added role management for accounts, allowing accounts to be set as "normal" or "standby".
- Introduced banning functionality, marking accounts as banned and preventing their use.
- Updated account pool logic to exclude banned and standby accounts from active usage.
- Implemented API endpoints for setting account roles and banning/unbanning accounts.
- Enhanced UI components to support role selection and display account statuses (banned, standby).
- Added localization strings for new features in English and Chinese.
- Updated runtime settings to include a backup release count for standby account promotions.

internal/account/pool_core.go CHANGED
@@ -46,9 +46,14 @@ func (p *Pool) Reset() {
46
  ids := make([]string, 0, len(accounts))
47
  for _, a := range accounts {
48
  id := a.Identifier()
49
- if id != "" {
50
- ids = append(ids, id)
51
  }
 
 
 
 
 
52
  }
53
  if p.store != nil {
54
  p.maxInflightPerAccount = p.store.RuntimeAccountMaxInflight()
@@ -117,10 +122,25 @@ func (p *Pool) Status() map[string]any {
117
  }
118
  }
119
  sort.Strings(inUseAccounts)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  return map[string]any{
121
  "available": len(available),
122
  "in_use": inUseSlots,
123
- "total": len(p.store.Accounts()),
124
  "available_accounts": available,
125
  "in_use_accounts": inUseAccounts,
126
  "max_inflight_per_account": p.maxInflightPerAccount,
@@ -128,5 +148,8 @@ func (p *Pool) Status() map[string]any {
128
  "recommended_concurrency": p.recommendedConcurrency,
129
  "waiting": len(p.waiters),
130
  "max_queue_size": p.maxQueueSize,
 
 
 
131
  }
132
  }
 
46
  ids := make([]string, 0, len(accounts))
47
  for _, a := range accounts {
48
  id := a.Identifier()
49
+ if id == "" {
50
+ continue
51
  }
52
+ // Only include normal (non-standby) and non-banned accounts in the pool.
53
+ if a.Role == "standby" || a.Banned {
54
+ continue
55
+ }
56
+ ids = append(ids, id)
57
  }
58
  if p.store != nil {
59
  p.maxInflightPerAccount = p.store.RuntimeAccountMaxInflight()
 
122
  }
123
  }
124
  sort.Strings(inUseAccounts)
125
+ allAccounts := p.store.Accounts()
126
+ normalCount := 0
127
+ standbyCount := 0
128
+ bannedCount := 0
129
+ for _, acc := range allAccounts {
130
+ if acc.Banned {
131
+ bannedCount++
132
+ continue
133
+ }
134
+ if acc.Role == "standby" {
135
+ standbyCount++
136
+ } else {
137
+ normalCount++
138
+ }
139
+ }
140
  return map[string]any{
141
  "available": len(available),
142
  "in_use": inUseSlots,
143
+ "total": len(allAccounts),
144
  "available_accounts": available,
145
  "in_use_accounts": inUseAccounts,
146
  "max_inflight_per_account": p.maxInflightPerAccount,
 
148
  "recommended_concurrency": p.recommendedConcurrency,
149
  "waiting": len(p.waiters),
150
  "max_queue_size": p.maxQueueSize,
151
+ "normal_count": normalCount,
152
+ "standby_count": standbyCount,
153
+ "banned_count": bannedCount,
154
  }
155
  }
internal/auth/request.go CHANGED
@@ -152,6 +152,10 @@ func FromContext(ctx context.Context) (*RequestAuth, bool) {
152
  func (r *Resolver) loginAndPersist(ctx context.Context, a *RequestAuth) error {
153
  token, err := r.Login(ctx, a.Account)
154
  if err != nil {
 
 
 
 
155
  return err
156
  }
157
  a.Account.Token = token
@@ -160,6 +164,39 @@ func (r *Resolver) loginAndPersist(ctx context.Context, a *RequestAuth) error {
160
  return r.Store.UpdateAccountToken(a.AccountID, token)
161
  }
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  func (r *Resolver) RefreshToken(ctx context.Context, a *RequestAuth) bool {
164
  if !a.UseConfigToken || a.AccountID == "" {
165
  return false
 
152
  func (r *Resolver) loginAndPersist(ctx context.Context, a *RequestAuth) error {
153
  token, err := r.Login(ctx, a.Account)
154
  if err != nil {
155
+ // Detect USER_IS_BANNED: auto-ban the account and promote standby accounts.
156
+ if strings.Contains(strings.ToUpper(err.Error()), "USER_IS_BANNED") {
157
+ r.handleBannedAccount(a.AccountID)
158
+ }
159
  return err
160
  }
161
  a.Account.Token = token
 
164
  return r.Store.UpdateAccountToken(a.AccountID, token)
165
  }
166
 
167
+ // handleBannedAccount marks an account as banned and promotes standby accounts
168
+ // based on the segment-based rule: if active normal accounts fall below half of
169
+ // total normal accounts (per segment), promote standby accounts.
170
+ func (r *Resolver) handleBannedAccount(accountID string) {
171
+ if r.Store == nil || r.Pool == nil {
172
+ return
173
+ }
174
+ acc, err := r.Store.SetAccountBanned(accountID, true)
175
+ if err != nil {
176
+ config.Logger.Error("[banned] failed to mark account as banned", "account", accountID, "error", err)
177
+ return
178
+ }
179
+ // Only auto-promote if the banned account was a normal (non-standby) account.
180
+ if acc.Role == "standby" {
181
+ return
182
+ }
183
+ config.Logger.Warn("[banned] account banned, checking standby promotion", "account", accountID)
184
+
185
+ activeNormal := r.Store.ActiveNormalAccounts()
186
+ totalNormal := r.Store.TotalNormalAccounts()
187
+ // Segment rule: promote when active normal accounts < half of total normal accounts.
188
+ // e.g. 2 normal -> both must be banned; 4 normal -> promote when only 1 active.
189
+ threshold := (totalNormal + 1) / 2 // ceiling of totalNormal/2
190
+ if activeNormal < threshold {
191
+ releaseCount := r.Store.RuntimeBackupReleaseCount()
192
+ promoted := r.Store.PromoteStandbyAccounts(releaseCount)
193
+ if len(promoted) > 0 {
194
+ config.Logger.Info("[banned] promoted standby accounts", "count", len(promoted), "accounts", promoted)
195
+ r.Pool.Reset()
196
+ }
197
+ }
198
+ }
199
+
200
  func (r *Resolver) RefreshToken(ctx context.Context, a *RequestAuth) bool {
201
  if !a.UseConfigToken || a.AccountID == "" {
202
  return false
internal/config/config.go CHANGED
@@ -34,6 +34,8 @@ type Account struct {
34
  Password string `json:"password,omitempty"`
35
  Token string `json:"token,omitempty"`
36
  ProxyID string `json:"proxy_id,omitempty"`
 
 
37
  }
38
 
39
  type APIKey struct {
@@ -154,6 +156,7 @@ type RuntimeConfig struct {
154
  AccountMaxQueue int `json:"account_max_queue,omitempty"`
155
  GlobalMaxInflight int `json:"global_max_inflight,omitempty"`
156
  TokenRefreshIntervalHours int `json:"token_refresh_interval_hours,omitempty"`
 
157
  }
158
 
159
  type ResponsesConfig struct {
 
34
  Password string `json:"password,omitempty"`
35
  Token string `json:"token,omitempty"`
36
  ProxyID string `json:"proxy_id,omitempty"`
37
+ Role string `json:"role,omitempty"` // "normal" (default) or "standby"
38
+ Banned bool `json:"banned,omitempty"` // true when USER_IS_BANNED detected
39
  }
40
 
41
  type APIKey struct {
 
156
  AccountMaxQueue int `json:"account_max_queue,omitempty"`
157
  GlobalMaxInflight int `json:"global_max_inflight,omitempty"`
158
  TokenRefreshIntervalHours int `json:"token_refresh_interval_hours,omitempty"`
159
+ BackupReleaseCount int `json:"backup_release_count,omitempty"`
160
  }
161
 
162
  type ResponsesConfig struct {
internal/config/store_accessors.go CHANGED
@@ -1,6 +1,8 @@
1
  package config
2
 
3
  import (
 
 
4
  "os"
5
  "strconv"
6
  "strings"
@@ -174,3 +176,108 @@ func (s *Store) ThinkingInjectionPrompt() string {
174
  defer s.mu.RUnlock()
175
  return strings.TrimSpace(s.cfg.ThinkingInjection.Prompt)
176
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  package config
2
 
3
  import (
4
+ "errors"
5
+ "fmt"
6
  "os"
7
  "strconv"
8
  "strings"
 
176
  defer s.mu.RUnlock()
177
  return strings.TrimSpace(s.cfg.ThinkingInjection.Prompt)
178
  }
179
+
180
+ func (s *Store) RuntimeBackupReleaseCount() int {
181
+ s.mu.RLock()
182
+ defer s.mu.RUnlock()
183
+ if s.cfg.Runtime.BackupReleaseCount > 0 {
184
+ return s.cfg.Runtime.BackupReleaseCount
185
+ }
186
+ return 2
187
+ }
188
+
189
+ // SetAccountBanned marks an account as banned or unbanned. Returns the updated account.
190
+ func (s *Store) SetAccountBanned(identifier string, banned bool) (Account, error) {
191
+ identifier = strings.TrimSpace(identifier)
192
+ s.mu.Lock()
193
+ defer s.mu.Unlock()
194
+ idx, ok := s.findAccountIndexLocked(identifier)
195
+ if !ok {
196
+ return Account{}, errors.New("account not found")
197
+ }
198
+ s.cfg.Accounts[idx].Banned = banned
199
+ if banned {
200
+ s.cfg.Accounts[idx].Token = ""
201
+ }
202
+ return s.cfg.Accounts[idx], s.saveLocked()
203
+ }
204
+
205
+ // SetAccountRole changes an account's role. Returns the updated account.
206
+ func (s *Store) SetAccountRole(identifier string, role string) (Account, error) {
207
+ identifier = strings.TrimSpace(identifier)
208
+ role = strings.ToLower(strings.TrimSpace(role))
209
+ if role != "normal" && role != "standby" {
210
+ return Account{}, fmt.Errorf("invalid role: %s, must be normal or standby", role)
211
+ }
212
+ s.mu.Lock()
213
+ defer s.mu.Unlock()
214
+ idx, ok := s.findAccountIndexLocked(identifier)
215
+ if !ok {
216
+ return Account{}, errors.New("account not found")
217
+ }
218
+ s.cfg.Accounts[idx].Role = role
219
+ return s.cfg.Accounts[idx], s.saveLocked()
220
+ }
221
+
222
+ // ActiveNormalAccounts returns the count of normal accounts that are not banned.
223
+ func (s *Store) ActiveNormalAccounts() int {
224
+ s.mu.RLock()
225
+ defer s.mu.RUnlock()
226
+ count := 0
227
+ for _, acc := range s.cfg.Accounts {
228
+ if acc.Role != "standby" && !acc.Banned {
229
+ count++
230
+ }
231
+ }
232
+ return count
233
+ }
234
+
235
+ // TotalNormalAccounts returns the total count of normal accounts (including banned).
236
+ func (s *Store) TotalNormalAccounts() int {
237
+ s.mu.RLock()
238
+ defer s.mu.RUnlock()
239
+ count := 0
240
+ for _, acc := range s.cfg.Accounts {
241
+ if acc.Role != "standby" {
242
+ count++
243
+ }
244
+ }
245
+ return count
246
+ }
247
+
248
+ // StandbyAccounts returns all standby accounts that are not banned.
249
+ func (s *Store) StandbyAccounts() []Account {
250
+ s.mu.RLock()
251
+ defer s.mu.RUnlock()
252
+ var result []Account
253
+ for _, acc := range s.cfg.Accounts {
254
+ if acc.Role == "standby" && !acc.Banned {
255
+ result = append(result, acc)
256
+ }
257
+ }
258
+ return result
259
+ }
260
+
261
+ // PromoteStandbyAccounts promotes up to n standby accounts to normal role.
262
+ // Returns the identifiers of promoted accounts.
263
+ func (s *Store) PromoteStandbyAccounts(n int) []string {
264
+ if n <= 0 {
265
+ return nil
266
+ }
267
+ s.mu.Lock()
268
+ defer s.mu.Unlock()
269
+ var promoted []string
270
+ for i := range s.cfg.Accounts {
271
+ if s.cfg.Accounts[i].Role == "standby" && !s.cfg.Accounts[i].Banned {
272
+ s.cfg.Accounts[i].Role = "normal"
273
+ promoted = append(promoted, s.cfg.Accounts[i].Identifier())
274
+ if len(promoted) >= n {
275
+ break
276
+ }
277
+ }
278
+ }
279
+ if len(promoted) > 0 {
280
+ _ = s.saveLocked()
281
+ }
282
+ return promoted
283
+ }
internal/config/validation.go CHANGED
@@ -96,6 +96,9 @@ func ValidateRuntimeConfig(runtime RuntimeConfig) error {
96
  if err := ValidateIntRange("runtime.token_refresh_interval_hours", runtime.TokenRefreshIntervalHours, 1, 720, false); err != nil {
97
  return err
98
  }
 
 
 
99
  if runtime.AccountMaxInflight > 0 && runtime.GlobalMaxInflight > 0 && runtime.GlobalMaxInflight < runtime.AccountMaxInflight {
100
  return fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
101
  }
 
96
  if err := ValidateIntRange("runtime.token_refresh_interval_hours", runtime.TokenRefreshIntervalHours, 1, 720, false); err != nil {
97
  return err
98
  }
99
+ if err := ValidateIntRange("runtime.backup_release_count", runtime.BackupReleaseCount, 1, 100, false); err != nil {
100
+ return err
101
+ }
102
  if runtime.AccountMaxInflight > 0 && runtime.GlobalMaxInflight > 0 && runtime.GlobalMaxInflight < runtime.AccountMaxInflight {
103
  return fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
104
  }
internal/httpapi/admin/accounts/handler_account_role.go ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package accounts
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "net/url"
7
+
8
+ "github.com/go-chi/chi/v5"
9
+ )
10
+
11
+ func (h *Handler) setAccountRole(w http.ResponseWriter, r *http.Request) {
12
+ identifier := chi.URLParam(r, "identifier")
13
+ if decoded, err := url.PathUnescape(identifier); err == nil {
14
+ identifier = decoded
15
+ }
16
+
17
+ var req struct {
18
+ Role string `json:"role"`
19
+ }
20
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
21
+ writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"})
22
+ return
23
+ }
24
+
25
+ acc, err := h.Store.SetAccountRole(identifier, req.Role)
26
+ if err != nil {
27
+ writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
28
+ return
29
+ }
30
+
31
+ h.Pool.Reset()
32
+ writeJSON(w, http.StatusOK, map[string]any{
33
+ "success": true,
34
+ "identifier": acc.Identifier(),
35
+ "role": acc.Role,
36
+ })
37
+ }
38
+
39
+ func (h *Handler) setAccountBanned(w http.ResponseWriter, r *http.Request) {
40
+ identifier := chi.URLParam(r, "identifier")
41
+ if decoded, err := url.PathUnescape(identifier); err == nil {
42
+ identifier = decoded
43
+ }
44
+
45
+ var req struct {
46
+ Banned bool `json:"banned"`
47
+ }
48
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
49
+ writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"})
50
+ return
51
+ }
52
+
53
+ acc, err := h.Store.SetAccountBanned(identifier, req.Banned)
54
+ if err != nil {
55
+ writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
56
+ return
57
+ }
58
+
59
+ h.Pool.Reset()
60
+ writeJSON(w, http.StatusOK, map[string]any{
61
+ "success": true,
62
+ "identifier": acc.Identifier(),
63
+ "banned": acc.Banned,
64
+ })
65
+ }
66
+
67
+ func (h *Handler) promoteStandby(w http.ResponseWriter, r *http.Request) {
68
+ var req struct {
69
+ Count int `json:"count"`
70
+ }
71
+ _ = json.NewDecoder(r.Body).Decode(&req)
72
+ if req.Count <= 0 {
73
+ req.Count = h.Store.RuntimeBackupReleaseCount()
74
+ }
75
+
76
+ promoted := h.Store.PromoteStandbyAccounts(req.Count)
77
+ if len(promoted) > 0 {
78
+ h.Pool.Reset()
79
+ }
80
+
81
+ writeJSON(w, http.StatusOK, map[string]any{
82
+ "success": true,
83
+ "promoted": promoted,
84
+ "count": len(promoted),
85
+ })
86
+ }
internal/httpapi/admin/accounts/handler_accounts_crud.go CHANGED
@@ -69,6 +69,8 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
69
  "has_token": token != "",
70
  "token_preview": maskSecretPreview(token),
71
  "test_status": testStatus,
 
 
72
  })
73
  }
74
  writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages})
 
69
  "has_token": token != "",
70
  "token_preview": maskSecretPreview(token),
71
  "test_status": testStatus,
72
+ "role": acc.Role,
73
+ "banned": acc.Banned,
74
  })
75
  }
76
  writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages})
internal/httpapi/admin/accounts/routes.go CHANGED
@@ -14,6 +14,9 @@ func RegisterRoutes(r chi.Router, h *Handler) {
14
  r.Post("/accounts", h.addAccount)
15
  r.Put("/accounts/{identifier}", h.updateAccount)
16
  r.Delete("/accounts/{identifier}", h.deleteAccount)
 
 
 
17
  r.Get("/queue/status", h.queueStatus)
18
  r.Post("/accounts/test", h.testSingleAccount)
19
  r.Post("/accounts/test-all", h.testAllAccounts)
 
14
  r.Post("/accounts", h.addAccount)
15
  r.Put("/accounts/{identifier}", h.updateAccount)
16
  r.Delete("/accounts/{identifier}", h.deleteAccount)
17
+ r.Put("/accounts/{identifier}/role", h.setAccountRole)
18
+ r.Put("/accounts/{identifier}/banned", h.setAccountBanned)
19
+ r.Post("/accounts/promote-standby", h.promoteStandby)
20
  r.Get("/queue/status", h.queueStatus)
21
  r.Post("/accounts/test", h.testSingleAccount)
22
  r.Post("/accounts/test-all", h.testAllAccounts)
internal/httpapi/admin/settings/handler_settings_parse.go CHANGED
@@ -75,6 +75,13 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
75
  }
76
  cfg.TokenRefreshIntervalHours = n
77
  }
 
 
 
 
 
 
 
78
  if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
79
  return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
80
  }
 
75
  }
76
  cfg.TokenRefreshIntervalHours = n
77
  }
78
+ if v, exists := raw["backup_release_count"]; exists {
79
+ n := intFrom(v)
80
+ if err := config.ValidateIntRange("runtime.backup_release_count", n, 1, 100, true); err != nil {
81
+ return nil, nil, nil, nil, nil, nil, nil, nil, err
82
+ }
83
+ cfg.BackupReleaseCount = n
84
+ }
85
  if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
86
  return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
87
  }
internal/httpapi/admin/settings/handler_settings_read.go CHANGED
@@ -24,6 +24,7 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
24
  "account_max_queue": snap.Runtime.AccountMaxQueue,
25
  "global_max_inflight": snap.Runtime.GlobalMaxInflight,
26
  "token_refresh_interval_hours": snap.Runtime.TokenRefreshIntervalHours,
 
27
  },
28
  "responses": snap.Responses,
29
  "embeddings": snap.Embeddings,
@@ -49,6 +50,7 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
49
  "account_max_queue": h.Store.RuntimeAccountMaxQueue(recommended),
50
  "global_max_inflight": h.Store.RuntimeGlobalMaxInflight(recommended),
51
  "token_refresh_interval_hours": h.Store.RuntimeTokenRefreshIntervalHours(),
 
52
  },
53
  "responses": snap.Responses,
54
  "embeddings": snap.Embeddings,
 
24
  "account_max_queue": snap.Runtime.AccountMaxQueue,
25
  "global_max_inflight": snap.Runtime.GlobalMaxInflight,
26
  "token_refresh_interval_hours": snap.Runtime.TokenRefreshIntervalHours,
27
+ "backup_release_count": snap.Runtime.BackupReleaseCount,
28
  },
29
  "responses": snap.Responses,
30
  "embeddings": snap.Embeddings,
 
50
  "account_max_queue": h.Store.RuntimeAccountMaxQueue(recommended),
51
  "global_max_inflight": h.Store.RuntimeGlobalMaxInflight(recommended),
52
  "token_refresh_interval_hours": h.Store.RuntimeTokenRefreshIntervalHours(),
53
+ "backup_release_count": h.Store.RuntimeBackupReleaseCount(),
54
  },
55
  "responses": snap.Responses,
56
  "embeddings": snap.Embeddings,
internal/httpapi/admin/settings/handler_settings_runtime.go CHANGED
@@ -17,6 +17,9 @@ func validateMergedRuntimeSettings(current config.RuntimeConfig, incoming *confi
17
  if incoming.TokenRefreshIntervalHours > 0 {
18
  merged.TokenRefreshIntervalHours = incoming.TokenRefreshIntervalHours
19
  }
 
 
 
20
  }
21
  return validateRuntimeSettings(merged)
22
  }
 
17
  if incoming.TokenRefreshIntervalHours > 0 {
18
  merged.TokenRefreshIntervalHours = incoming.TokenRefreshIntervalHours
19
  }
20
+ if incoming.BackupReleaseCount > 0 {
21
+ merged.BackupReleaseCount = incoming.BackupReleaseCount
22
+ }
23
  }
24
  return validateRuntimeSettings(merged)
25
  }
internal/httpapi/admin/settings/handler_settings_write.go CHANGED
@@ -52,6 +52,9 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
52
  if runtimeCfg.TokenRefreshIntervalHours > 0 {
53
  c.Runtime.TokenRefreshIntervalHours = runtimeCfg.TokenRefreshIntervalHours
54
  }
 
 
 
55
  }
56
  if responsesCfg != nil && responsesCfg.StoreTTLSeconds > 0 {
57
  c.Responses.StoreTTLSeconds = responsesCfg.StoreTTLSeconds
 
52
  if runtimeCfg.TokenRefreshIntervalHours > 0 {
53
  c.Runtime.TokenRefreshIntervalHours = runtimeCfg.TokenRefreshIntervalHours
54
  }
55
+ if runtimeCfg.BackupReleaseCount > 0 {
56
+ c.Runtime.BackupReleaseCount = runtimeCfg.BackupReleaseCount
57
+ }
58
  }
59
  if responsesCfg != nil && responsesCfg.StoreTTLSeconds > 0 {
60
  c.Responses.StoreTTLSeconds = responsesCfg.StoreTTLSeconds
internal/httpapi/admin/shared/deps.go CHANGED
@@ -32,12 +32,18 @@ type ConfigStore interface {
32
  RuntimeAccountMaxQueue(defaultSize int) int
33
  RuntimeGlobalMaxInflight(defaultSize int) int
34
  RuntimeTokenRefreshIntervalHours() int
 
35
  AutoDeleteMode() string
36
  CurrentInputFileEnabled() bool
37
  CurrentInputFileMinChars() int
38
  ThinkingInjectionEnabled() bool
39
  ThinkingInjectionPrompt() string
40
  AutoDeleteSessions() bool
 
 
 
 
 
41
  }
42
 
43
  type PoolController interface {
 
32
  RuntimeAccountMaxQueue(defaultSize int) int
33
  RuntimeGlobalMaxInflight(defaultSize int) int
34
  RuntimeTokenRefreshIntervalHours() int
35
+ RuntimeBackupReleaseCount() int
36
  AutoDeleteMode() string
37
  CurrentInputFileEnabled() bool
38
  CurrentInputFileMinChars() int
39
  ThinkingInjectionEnabled() bool
40
  ThinkingInjectionPrompt() string
41
  AutoDeleteSessions() bool
42
+ SetAccountBanned(identifier string, banned bool) (config.Account, error)
43
+ SetAccountRole(identifier string, role string) (config.Account, error)
44
+ ActiveNormalAccounts() int
45
+ TotalNormalAccounts() int
46
+ PromoteStandbyAccounts(n int) []string
47
  }
48
 
49
  type PoolController interface {
internal/httpapi/admin/shared/helpers.go CHANGED
@@ -161,6 +161,10 @@ func toStringSlice(v any) ([]string, bool) {
161
  func toAccount(m map[string]any) config.Account {
162
  email := fieldString(m, "email")
163
  mobile := config.NormalizeMobileForStorage(fieldString(m, "mobile"))
 
 
 
 
164
  return config.Account{
165
  Name: fieldString(m, "name"),
166
  Remark: fieldString(m, "remark"),
@@ -168,6 +172,7 @@ func toAccount(m map[string]any) config.Account {
168
  Mobile: mobile,
169
  Password: fieldString(m, "password"),
170
  ProxyID: fieldString(m, "proxy_id"),
 
171
  }
172
  }
173
 
@@ -324,6 +329,10 @@ func normalizeAccountForStorage(acc config.Account) config.Account {
324
  acc.Email = strings.TrimSpace(acc.Email)
325
  acc.Mobile = config.NormalizeMobileForStorage(acc.Mobile)
326
  acc.ProxyID = strings.TrimSpace(acc.ProxyID)
 
 
 
 
327
  return acc
328
  }
329
 
 
161
  func toAccount(m map[string]any) config.Account {
162
  email := fieldString(m, "email")
163
  mobile := config.NormalizeMobileForStorage(fieldString(m, "mobile"))
164
+ role := strings.ToLower(strings.TrimSpace(fieldString(m, "role")))
165
+ if role != "normal" && role != "standby" {
166
+ role = "" // default to normal (empty string treated as normal)
167
+ }
168
  return config.Account{
169
  Name: fieldString(m, "name"),
170
  Remark: fieldString(m, "remark"),
 
172
  Mobile: mobile,
173
  Password: fieldString(m, "password"),
174
  ProxyID: fieldString(m, "proxy_id"),
175
+ Role: role,
176
  }
177
  }
178
 
 
329
  acc.Email = strings.TrimSpace(acc.Email)
330
  acc.Mobile = config.NormalizeMobileForStorage(acc.Mobile)
331
  acc.ProxyID = strings.TrimSpace(acc.ProxyID)
332
+ acc.Role = strings.ToLower(strings.TrimSpace(acc.Role))
333
+ if acc.Role != "normal" && acc.Role != "standby" {
334
+ acc.Role = ""
335
+ }
336
  return acc
337
  }
338
 
webui/src/features/account/AccountManagerContainer.jsx CHANGED
@@ -66,6 +66,8 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
66
  testAllAccounts,
67
  deleteAllSessions,
68
  updateAccountProxy,
 
 
69
  } = useAccountActions({
70
  apiFetch,
71
  t,
@@ -136,6 +138,8 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
136
  onDeleteAccount={deleteAccount}
137
  onDeleteAllSessions={deleteAllSessions}
138
  onUpdateAccountProxy={updateAccountProxy}
 
 
139
  onPrevPage={() => fetchAccounts(page - 1)}
140
  onNextPage={() => fetchAccounts(page + 1)}
141
  onPageSizeChange={changePageSize}
 
66
  testAllAccounts,
67
  deleteAllSessions,
68
  updateAccountProxy,
69
+ toggleRole,
70
+ toggleBanned,
71
  } = useAccountActions({
72
  apiFetch,
73
  t,
 
138
  onDeleteAccount={deleteAccount}
139
  onDeleteAllSessions={deleteAllSessions}
140
  onUpdateAccountProxy={updateAccountProxy}
141
+ onToggleRole={toggleRole}
142
+ onToggleBanned={toggleBanned}
143
  onPrevPage={() => fetchAccounts(page - 1)}
144
  onNextPage={() => fetchAccounts(page + 1)}
145
  onPageSizeChange={changePageSize}
webui/src/features/account/AccountsTable.jsx CHANGED
@@ -1,5 +1,5 @@
1
  import { useState } from 'react'
2
- import { ChevronLeft, ChevronRight, Check, Copy, Pencil, Play, Plus, Trash2, FolderX } from 'lucide-react'
3
  import clsx from 'clsx'
4
 
5
  export default function AccountsTable({
@@ -25,6 +25,8 @@ export default function AccountsTable({
25
  onDeleteAccount,
26
  onDeleteAllSessions,
27
  onUpdateAccountProxy,
 
 
28
  onPrevPage,
29
  onNextPage,
30
  onPageSizeChange,
@@ -109,13 +111,20 @@ export default function AccountsTable({
109
  const assignedProxy = proxies.find(proxy => proxy.id === acc.proxy_id)
110
  const runtimeUnknown = envBacked && !acc.test_status
111
  const isActive = acc.test_status === 'ok' || acc.has_token
 
 
112
  return (
113
- <div key={i} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-muted/50 transition-colors">
 
 
 
114
  <div className="flex items-center gap-3 min-w-0">
115
  <div className={clsx(
116
  "w-2 h-2 rounded-full shrink-0",
 
117
  acc.test_status === 'failed' ? "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" :
118
- isActive ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" :
 
119
  runtimeUnknown ? "bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]" : "bg-amber-500"
120
  )} />
121
  <div className="min-w-0">
@@ -134,7 +143,17 @@ export default function AccountsTable({
134
  <div className="text-xs text-muted-foreground truncate mt-0.5">{acc.remark}</div>
135
  )}
136
  <div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
137
- <span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : isActive ? t('accountManager.sessionActive') : runtimeUnknown ? t('accountManager.runtimeStatusUnknown') : t('accountManager.reauthRequired')}</span>
 
 
 
 
 
 
 
 
 
 
138
  {acc.token_preview && (
139
  <span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
140
  {acc.token_preview}
@@ -181,6 +200,20 @@ export default function AccountsTable({
181
  </option>
182
  ))}
183
  </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  <button
185
  onClick={() => onEditAccount(acc)}
186
  disabled={!id}
 
1
  import { useState } from 'react'
2
+ import { ChevronLeft, ChevronRight, Check, Copy, Pencil, Play, Plus, Trash2, FolderX, Shield, ShieldOff, ArrowLeftRight } from 'lucide-react'
3
  import clsx from 'clsx'
4
 
5
  export default function AccountsTable({
 
25
  onDeleteAccount,
26
  onDeleteAllSessions,
27
  onUpdateAccountProxy,
28
+ onToggleRole,
29
+ onToggleBanned,
30
  onPrevPage,
31
  onNextPage,
32
  onPageSizeChange,
 
111
  const assignedProxy = proxies.find(proxy => proxy.id === acc.proxy_id)
112
  const runtimeUnknown = envBacked && !acc.test_status
113
  const isActive = acc.test_status === 'ok' || acc.has_token
114
+ const isStandby = acc.role === 'standby'
115
+ const isBanned = acc.banned
116
  return (
117
+ <div key={i} className={clsx(
118
+ "p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 transition-colors",
119
+ isBanned ? "bg-red-500/5 hover:bg-red-500/10" : isStandby ? "bg-amber-500/5 hover:bg-amber-500/10" : "hover:bg-muted/50"
120
+ )}>
121
  <div className="flex items-center gap-3 min-w-0">
122
  <div className={clsx(
123
  "w-2 h-2 rounded-full shrink-0",
124
+ isBanned ? "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" :
125
  acc.test_status === 'failed' ? "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" :
126
+ isActive && !isStandby ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" :
127
+ isStandby ? "bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.5)]" :
128
  runtimeUnknown ? "bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]" : "bg-amber-500"
129
  )} />
130
  <div className="min-w-0">
 
143
  <div className="text-xs text-muted-foreground truncate mt-0.5">{acc.remark}</div>
144
  )}
145
  <div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
146
+ <span>{isBanned ? t('accountManager.bannedStatus') : isStandby ? t('accountManager.standbyStatus') : acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : isActive ? t('accountManager.sessionActive') : runtimeUnknown ? t('accountManager.runtimeStatusUnknown') : t('accountManager.reauthRequired')}</span>
147
+ {isStandby && !isBanned && (
148
+ <span className="font-mono bg-amber-500/10 text-amber-500 px-1.5 py-0.5 rounded text-[10px]">
149
+ {t('accountManager.roleStandby')}
150
+ </span>
151
+ )}
152
+ {isBanned && (
153
+ <span className="font-mono bg-red-500/10 text-red-500 px-1.5 py-0.5 rounded text-[10px]">
154
+ {t('accountManager.bannedBadge')}
155
+ </span>
156
+ )}
157
  {acc.token_preview && (
158
  <span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
159
  {acc.token_preview}
 
200
  </option>
201
  ))}
202
  </select>
203
+ <button
204
+ onClick={() => onToggleRole(id, isStandby ? 'normal' : 'standby')}
205
+ className="p-1 lg:p-1.5 text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 rounded-md transition-colors"
206
+ title={isStandby ? t('accountManager.switchToNormal') : t('accountManager.switchToStandby')}
207
+ >
208
+ <ArrowLeftRight className="w-3.5 h-3.5 lg:w-4 lg:h-4" />
209
+ </button>
210
+ <button
211
+ onClick={() => onToggleBanned(id, !isBanned)}
212
+ className="p-1 lg:p-1.5 text-muted-foreground hover:text-red-500 hover:bg-red-500/10 rounded-md transition-colors"
213
+ title={isBanned ? t('accountManager.unbanAccount') : t('accountManager.banAccount')}
214
+ >
215
+ {isBanned ? <ShieldOff className="w-3.5 h-3.5 lg:w-4 lg:h-4" /> : <Shield className="w-3.5 h-3.5 lg:w-4 lg:h-4" />}
216
+ </button>
217
  <button
218
  onClick={() => onEditAccount(acc)}
219
  disabled={!id}
webui/src/features/account/AddAccountModal.jsx CHANGED
@@ -23,6 +23,57 @@ export default function AddAccountModal({
23
  </button>
24
  </div>
25
  <div className="p-6 space-y-4">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  <div>
27
  <label className="block text-sm font-medium mb-1.5">{t('accountManager.nameOptional')}</label>
28
  <input
 
23
  </button>
24
  </div>
25
  <div className="p-6 space-y-4">
26
+ <div>
27
+ <label className="block text-sm font-medium mb-1.5">{t('accountManager.accountTypeLabel')}</label>
28
+ <div className="flex gap-3">
29
+ <label className={`flex-1 flex items-center gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${
30
+ (newAccount.role || 'normal') === 'normal'
31
+ ? 'border-primary bg-primary/10 text-primary'
32
+ : 'border-border hover:border-primary/50'
33
+ }`}>
34
+ <input
35
+ type="radio"
36
+ name="account-role"
37
+ value="normal"
38
+ checked={(newAccount.role || 'normal') === 'normal'}
39
+ onChange={() => setNewAccount({ ...newAccount, role: 'normal' })}
40
+ className="sr-only"
41
+ />
42
+ <div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
43
+ (newAccount.role || 'normal') === 'normal' ? 'border-primary' : 'border-muted-foreground'
44
+ }`}>
45
+ {(newAccount.role || 'normal') === 'normal' && <div className="w-2 h-2 rounded-full bg-primary" />}
46
+ </div>
47
+ <div>
48
+ <div className="text-sm font-medium">{t('accountManager.roleNormal')}</div>
49
+ <div className="text-xs text-muted-foreground">{t('accountManager.roleNormalDesc')}</div>
50
+ </div>
51
+ </label>
52
+ <label className={`flex-1 flex items-center gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${
53
+ newAccount.role === 'standby'
54
+ ? 'border-amber-500 bg-amber-500/10 text-amber-500'
55
+ : 'border-border hover:border-amber-500/50'
56
+ }`}>
57
+ <input
58
+ type="radio"
59
+ name="account-role"
60
+ value="standby"
61
+ checked={newAccount.role === 'standby'}
62
+ onChange={() => setNewAccount({ ...newAccount, role: 'standby' })}
63
+ className="sr-only"
64
+ />
65
+ <div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
66
+ newAccount.role === 'standby' ? 'border-amber-500' : 'border-muted-foreground'
67
+ }`}>
68
+ {newAccount.role === 'standby' && <div className="w-2 h-2 rounded-full bg-amber-500" />}
69
+ </div>
70
+ <div>
71
+ <div className="text-sm font-medium">{t('accountManager.roleStandby')}</div>
72
+ <div className="text-xs text-muted-foreground">{t('accountManager.roleStandbyDesc')}</div>
73
+ </div>
74
+ </label>
75
+ </div>
76
+ </div>
77
  <div>
78
  <label className="block text-sm font-medium mb-1.5">{t('accountManager.nameOptional')}</label>
79
  <input
webui/src/features/account/QueueCards.jsx CHANGED
@@ -1,4 +1,4 @@
1
- import { CheckCircle2, Server, ShieldCheck } from 'lucide-react'
2
 
3
  export default function QueueCards({ queueStatus, t }) {
4
  if (!queueStatus) {
@@ -6,7 +6,7 @@ export default function QueueCards({ queueStatus, t }) {
6
  }
7
 
8
  return (
9
- <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
10
  <div className="bg-card border border-border rounded-xl p-4 flex flex-col justify-between shadow-sm relative overflow-hidden group">
11
  <div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
12
  <CheckCircle2 className="w-16 h-16" />
@@ -31,9 +31,29 @@ export default function QueueCards({ queueStatus, t }) {
31
  <div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
32
  <ShieldCheck className="w-16 h-16" />
33
  </div>
34
- <p className="text-xs font-medium text-muted-foreground uppercase tracking-widest">{t('accountManager.totalPool')}</p>
35
  <div className="mt-2 flex items-baseline gap-2">
36
- <span className="text-3xl font-bold text-foreground">{queueStatus.total}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  <span className="text-xs text-muted-foreground">{t('accountManager.accountsUnit')}</span>
38
  </div>
39
  </div>
 
1
+ import { CheckCircle2, Server, ShieldCheck, ShieldAlert, ShieldOff } from 'lucide-react'
2
 
3
  export default function QueueCards({ queueStatus, t }) {
4
  if (!queueStatus) {
 
6
  }
7
 
8
  return (
9
+ <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
10
  <div className="bg-card border border-border rounded-xl p-4 flex flex-col justify-between shadow-sm relative overflow-hidden group">
11
  <div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
12
  <CheckCircle2 className="w-16 h-16" />
 
31
  <div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
32
  <ShieldCheck className="w-16 h-16" />
33
  </div>
34
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-widest">{t('accountManager.roleNormal')}</p>
35
  <div className="mt-2 flex items-baseline gap-2">
36
+ <span className="text-3xl font-bold text-foreground">{queueStatus.normal_count ?? '-'}</span>
37
+ <span className="text-xs text-muted-foreground">{t('accountManager.accountsUnit')}</span>
38
+ </div>
39
+ </div>
40
+ <div className="bg-card border border-amber-500/20 rounded-xl p-4 flex flex-col justify-between shadow-sm relative overflow-hidden group">
41
+ <div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
42
+ <ShieldAlert className="w-16 h-16 text-amber-500" />
43
+ </div>
44
+ <p className="text-xs font-medium text-amber-500/80 uppercase tracking-widest">{t('accountManager.roleStandby')}</p>
45
+ <div className="mt-2 flex items-baseline gap-2">
46
+ <span className="text-3xl font-bold text-amber-500">{queueStatus.standby_count ?? '-'}</span>
47
+ <span className="text-xs text-muted-foreground">{t('accountManager.accountsUnit')}</span>
48
+ </div>
49
+ </div>
50
+ <div className="bg-card border border-red-500/20 rounded-xl p-4 flex flex-col justify-between shadow-sm relative overflow-hidden group">
51
+ <div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
52
+ <ShieldOff className="w-16 h-16 text-red-500" />
53
+ </div>
54
+ <p className="text-xs font-medium text-red-500/80 uppercase tracking-widest">{t('accountManager.bannedBadge')}</p>
55
+ <div className="mt-2 flex items-baseline gap-2">
56
+ <span className="text-3xl font-bold text-red-500">{queueStatus.banned_count ?? '-'}</span>
57
  <span className="text-xs text-muted-foreground">{t('accountManager.accountsUnit')}</span>
58
  </div>
59
  </div>
webui/src/features/account/useAccountActions.js CHANGED
@@ -8,7 +8,7 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
8
  const [editingAccount, setEditingAccount] = useState(null)
9
  const [newKey, setNewKey] = useState({ key: '', name: '', remark: '' })
10
  const [copiedKey, setCopiedKey] = useState(null)
11
- const [newAccount, setNewAccount] = useState({ name: '', remark: '', email: '', mobile: '', password: '' })
12
  const [editAccount, setEditAccount] = useState({ name: '', remark: '' })
13
  const [loading, setLoading] = useState(false)
14
  const [testing, setTesting] = useState({})
@@ -45,13 +45,13 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
45
  setShowEditAccount(false)
46
  setEditingAccount(null)
47
  setEditAccount({ name: '', remark: '' })
48
- setNewAccount({ name: '', remark: '', email: '', mobile: '', password: '' })
49
  setShowAddAccount(true)
50
  }
51
 
52
  const closeAddAccount = () => {
53
  setShowAddAccount(false)
54
- setNewAccount({ name: '', remark: '', email: '', mobile: '', password: '' })
55
  }
56
 
57
  const openEditAccount = (account) => {
@@ -345,6 +345,56 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
345
  }
346
  }
347
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  return {
349
  showAddKey,
350
  openAddKey,
@@ -382,5 +432,7 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
382
  testAllAccounts,
383
  deleteAllSessions,
384
  updateAccountProxy,
 
 
385
  }
386
  }
 
8
  const [editingAccount, setEditingAccount] = useState(null)
9
  const [newKey, setNewKey] = useState({ key: '', name: '', remark: '' })
10
  const [copiedKey, setCopiedKey] = useState(null)
11
+ const [newAccount, setNewAccount] = useState({ name: '', remark: '', email: '', mobile: '', password: '', role: 'normal' })
12
  const [editAccount, setEditAccount] = useState({ name: '', remark: '' })
13
  const [loading, setLoading] = useState(false)
14
  const [testing, setTesting] = useState({})
 
45
  setShowEditAccount(false)
46
  setEditingAccount(null)
47
  setEditAccount({ name: '', remark: '' })
48
+ setNewAccount({ name: '', remark: '', email: '', mobile: '', password: '', role: 'normal' })
49
  setShowAddAccount(true)
50
  }
51
 
52
  const closeAddAccount = () => {
53
  setShowAddAccount(false)
54
+ setNewAccount({ name: '', remark: '', email: '', mobile: '', password: '', role: 'normal' })
55
  }
56
 
57
  const openEditAccount = (account) => {
 
345
  }
346
  }
347
 
348
+ const toggleRole = async (identifier, newRole) => {
349
+ const accountID = String(identifier || '').trim()
350
+ if (!accountID) {
351
+ onMessage('error', t('accountManager.invalidIdentifier'))
352
+ return
353
+ }
354
+ try {
355
+ const res = await apiFetch(`/admin/accounts/${encodeURIComponent(accountID)}/role`, {
356
+ method: 'PUT',
357
+ headers: { 'Content-Type': 'application/json' },
358
+ body: JSON.stringify({ role: newRole }),
359
+ })
360
+ const data = await res.json()
361
+ if (!res.ok) {
362
+ onMessage('error', data.detail || t('messages.requestFailed'))
363
+ return
364
+ }
365
+ onMessage('success', newRole === 'standby' ? t('accountManager.switchToStandbySuccess') : t('accountManager.switchToNormalSuccess'))
366
+ fetchAccounts()
367
+ onRefresh()
368
+ } catch (_err) {
369
+ onMessage('error', t('messages.networkError'))
370
+ }
371
+ }
372
+
373
+ const toggleBanned = async (identifier, banned) => {
374
+ const accountID = String(identifier || '').trim()
375
+ if (!accountID) {
376
+ onMessage('error', t('accountManager.invalidIdentifier'))
377
+ return
378
+ }
379
+ try {
380
+ const res = await apiFetch(`/admin/accounts/${encodeURIComponent(accountID)}/banned`, {
381
+ method: 'PUT',
382
+ headers: { 'Content-Type': 'application/json' },
383
+ body: JSON.stringify({ banned }),
384
+ })
385
+ const data = await res.json()
386
+ if (!res.ok) {
387
+ onMessage('error', data.detail || t('messages.requestFailed'))
388
+ return
389
+ }
390
+ onMessage('success', banned ? t('accountManager.banSuccess') : t('accountManager.unbanSuccess'))
391
+ fetchAccounts()
392
+ onRefresh()
393
+ } catch (_err) {
394
+ onMessage('error', t('messages.networkError'))
395
+ }
396
+ }
397
+
398
  return {
399
  showAddKey,
400
  openAddKey,
 
432
  testAllAccounts,
433
  deleteAllSessions,
434
  updateAccountProxy,
435
+ toggleRole,
436
+ toggleBanned,
437
  }
438
  }
webui/src/features/settings/RuntimeSection.jsx CHANGED
@@ -2,7 +2,7 @@ export default function RuntimeSection({ t, form, setForm }) {
2
  return (
3
  <div className="bg-card border border-border rounded-xl p-5 space-y-4">
4
  <h3 className="font-semibold">{t('settings.runtimeTitle')}</h3>
5
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
6
  <label className="text-sm space-y-2">
7
  <span className="text-muted-foreground">{t('settings.accountMaxInflight')}</span>
8
  <input
@@ -57,6 +57,21 @@ export default function RuntimeSection({ t, form, setForm }) {
57
  className="w-full bg-background border border-border rounded-lg px-3 py-2"
58
  />
59
  </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  </div>
61
  </div>
62
  )
 
2
  return (
3
  <div className="bg-card border border-border rounded-xl p-5 space-y-4">
4
  <h3 className="font-semibold">{t('settings.runtimeTitle')}</h3>
5
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
6
  <label className="text-sm space-y-2">
7
  <span className="text-muted-foreground">{t('settings.accountMaxInflight')}</span>
8
  <input
 
57
  className="w-full bg-background border border-border rounded-lg px-3 py-2"
58
  />
59
  </label>
60
+ <label className="text-sm space-y-2">
61
+ <span className="text-muted-foreground">{t('settings.backupReleaseCount')}</span>
62
+ <input
63
+ type="number"
64
+ min={1}
65
+ max={100}
66
+ step={1}
67
+ value={form.runtime.backup_release_count}
68
+ onChange={(e) => setForm((prev) => ({
69
+ ...prev,
70
+ runtime: { ...prev.runtime, backup_release_count: Number(e.target.value || 2) },
71
+ }))}
72
+ className="w-full bg-background border border-border rounded-lg px-3 py-2"
73
+ />
74
+ </label>
75
  </div>
76
  </div>
77
  )
webui/src/features/settings/useSettingsForm.js CHANGED
@@ -12,7 +12,7 @@ const MAX_AUTO_FETCH_FAILURES = 3
12
 
13
  const DEFAULT_FORM = {
14
  admin: { jwt_expire_hours: 24 },
15
- runtime: { account_max_inflight: 2, account_max_queue: 10, global_max_inflight: 10, token_refresh_interval_hours: 6 },
16
  responses: { store_ttl_seconds: 900 },
17
  embeddings: { provider: '' },
18
  auto_delete: { mode: 'none' },
@@ -67,6 +67,7 @@ function fromServerForm(data) {
67
  account_max_queue: Number(runtimeSource?.account_max_queue || 10),
68
  global_max_inflight: Number(runtimeSource?.global_max_inflight || 10),
69
  token_refresh_interval_hours: Number(runtimeSource?.token_refresh_interval_hours || 6),
 
70
  },
71
  responses: {
72
  store_ttl_seconds: Number(responsesSource?.store_ttl_seconds || 900),
@@ -99,6 +100,7 @@ function toServerPayload(form) {
99
  account_max_queue: Number(form.runtime.account_max_queue),
100
  global_max_inflight: Number(form.runtime.global_max_inflight),
101
  token_refresh_interval_hours: Number(form.runtime.token_refresh_interval_hours),
 
102
  },
103
  responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) },
104
  embeddings: { provider: String(form.embeddings.provider || '').trim() },
 
12
 
13
  const DEFAULT_FORM = {
14
  admin: { jwt_expire_hours: 24 },
15
+ runtime: { account_max_inflight: 2, account_max_queue: 10, global_max_inflight: 10, token_refresh_interval_hours: 6, backup_release_count: 2 },
16
  responses: { store_ttl_seconds: 900 },
17
  embeddings: { provider: '' },
18
  auto_delete: { mode: 'none' },
 
67
  account_max_queue: Number(runtimeSource?.account_max_queue || 10),
68
  global_max_inflight: Number(runtimeSource?.global_max_inflight || 10),
69
  token_refresh_interval_hours: Number(runtimeSource?.token_refresh_interval_hours || 6),
70
+ backup_release_count: Number(runtimeSource?.backup_release_count || 2),
71
  },
72
  responses: {
73
  store_ttl_seconds: Number(responsesSource?.store_ttl_seconds || 900),
 
100
  account_max_queue: Number(form.runtime.account_max_queue),
101
  global_max_inflight: Number(form.runtime.global_max_inflight),
102
  token_refresh_interval_hours: Number(form.runtime.token_refresh_interval_hours),
103
+ backup_release_count: Number(form.runtime.backup_release_count),
104
  },
105
  responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) },
106
  embeddings: { provider: String(form.embeddings.provider || '').trim() },
webui/src/locales/en.json CHANGED
@@ -171,6 +171,22 @@
171
  "proxyNone": "Direct connection",
172
  "proxyBadge": "Proxy: {name}",
173
  "proxyUpdateSuccess": "Account proxy updated.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  "envModeRiskTitle": "Environment-variable config mode detected (persistence risk)",
175
  "envModeRiskDesc": "Detected DS2API_CONFIG_JSON. If DS2API_ENV_WRITEBACK is not enabled, Admin UI edits are in-memory only and may be lost after restart.",
176
  "envModeWritebackPendingTitle": "Env mode + auto-persistence enabled (pending file handoff)",
@@ -403,6 +419,7 @@
403
  "accountMaxQueue": "Account max queue size",
404
  "globalMaxInflight": "Global max inflight",
405
  "tokenRefreshIntervalHours": "Managed token refresh interval (hours)",
 
406
  "behaviorTitle": "Behavior",
407
  "responsesTTL": "Responses store TTL (seconds)",
408
  "embeddingsProvider": "Embeddings provider",
 
171
  "proxyNone": "Direct connection",
172
  "proxyBadge": "Proxy: {name}",
173
  "proxyUpdateSuccess": "Account proxy updated.",
174
+ "accountTypeLabel": "Account type",
175
+ "roleNormal": "Normal",
176
+ "roleNormalDesc": "Participates in request rotation",
177
+ "roleStandby": "Standby",
178
+ "roleStandbyDesc": "Activated when normal accounts are banned",
179
+ "standbyStatus": "Standby",
180
+ "bannedStatus": "Banned",
181
+ "bannedBadge": "Banned",
182
+ "switchToNormal": "Switch to normal",
183
+ "switchToStandby": "Switch to standby",
184
+ "switchToNormalSuccess": "Switched to normal account",
185
+ "switchToStandbySuccess": "Switched to standby account",
186
+ "banAccount": "Ban account",
187
+ "unbanAccount": "Unban account",
188
+ "banSuccess": "Account banned",
189
+ "unbanSuccess": "Account unbanned",
190
  "envModeRiskTitle": "Environment-variable config mode detected (persistence risk)",
191
  "envModeRiskDesc": "Detected DS2API_CONFIG_JSON. If DS2API_ENV_WRITEBACK is not enabled, Admin UI edits are in-memory only and may be lost after restart.",
192
  "envModeWritebackPendingTitle": "Env mode + auto-persistence enabled (pending file handoff)",
 
419
  "accountMaxQueue": "Account max queue size",
420
  "globalMaxInflight": "Global max inflight",
421
  "tokenRefreshIntervalHours": "Managed token refresh interval (hours)",
422
+ "backupReleaseCount": "Standby accounts released per batch",
423
  "behaviorTitle": "Behavior",
424
  "responsesTTL": "Responses store TTL (seconds)",
425
  "embeddingsProvider": "Embeddings provider",
webui/src/locales/zh.json CHANGED
@@ -171,6 +171,22 @@
171
  "proxyNone": "不走代理",
172
  "proxyBadge": "代理: {name}",
173
  "proxyUpdateSuccess": "账号代理已更新",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  "envModeRiskTitle": "当前为环境变量配置模式(有持久化风险)",
175
  "envModeRiskDesc": "检测到 DS2API_CONFIG_JSON。若未开启 DS2API_ENV_WRITEBACK,管理台改动仅在内存生效,重启可能丢失。",
176
  "envModeWritebackPendingTitle": "环境变量模式 + 自动持久化已开启(等待落盘)",
@@ -403,6 +419,7 @@
403
  "accountMaxQueue": "账号等待队列上限",
404
  "globalMaxInflight": "全局并发上限",
405
  "tokenRefreshIntervalHours": "托管账号 Token 刷新间隔(小时)",
 
406
  "behaviorTitle": "行为设置",
407
  "responsesTTL": "Responses 缓存 TTL(秒)",
408
  "embeddingsProvider": "Embeddings Provider",
 
171
  "proxyNone": "不走代理",
172
  "proxyBadge": "代理: {name}",
173
  "proxyUpdateSuccess": "账号代理已更新",
174
+ "accountTypeLabel": "账号类型",
175
+ "roleNormal": "正常账号",
176
+ "roleNormalDesc": "参与请求轮询",
177
+ "roleStandby": "后备账号",
178
+ "roleStandbyDesc": "正常账号被封时启用",
179
+ "standbyStatus": "后备待命",
180
+ "bannedStatus": "已被封禁",
181
+ "bannedBadge": "封禁",
182
+ "switchToNormal": "切换为正常账号",
183
+ "switchToStandby": "切换为后备账号",
184
+ "switchToNormalSuccess": "已切换为正常账号",
185
+ "switchToStandbySuccess": "已切换为后备账号",
186
+ "banAccount": "封禁账号",
187
+ "unbanAccount": "解封账号",
188
+ "banSuccess": "账号已封禁",
189
+ "unbanSuccess": "账号已解封",
190
  "envModeRiskTitle": "当前为环境变量配置模式(有持久化风险)",
191
  "envModeRiskDesc": "检测到 DS2API_CONFIG_JSON。若未开启 DS2API_ENV_WRITEBACK,管理台改动仅在内存生效,重启可能丢失。",
192
  "envModeWritebackPendingTitle": "环境变量模式 + 自动持久化已开启(等待落盘)",
 
419
  "accountMaxQueue": "账号等待队列上限",
420
  "globalMaxInflight": "全局并发上限",
421
  "tokenRefreshIntervalHours": "托管账号 Token 刷新间隔(小时)",
422
+ "backupReleaseCount": "后备账号每次释放个数",
423
  "behaviorTitle": "行为设置",
424
  "responsesTTL": "Responses 缓存 TTL(秒)",
425
  "embeddingsProvider": "Embeddings Provider",