HouYunFei Codex commited on
Commit
f394c2c
·
1 Parent(s): 6b994b3

fix: report invalid token refresh failures

Browse files

Co-Authored-By: Codex <noreply@openai.com>

services/account_service.py CHANGED
@@ -1,37 +1,21 @@
1
  from __future__ import annotations
2
 
3
  from concurrent.futures import ThreadPoolExecutor, as_completed
4
- import base64
5
- import hashlib
6
- import json
7
  from threading import Lock
8
  from typing import Any
9
  from datetime import datetime
10
 
11
- from curl_cffi.requests import Session
12
-
13
  from services.config import config
14
  from services.log_service import (
15
  LOG_TYPE_ACCOUNT,
16
  log_service,
17
  )
18
- from services.proxy_service import proxy_settings
19
  from services.storage.base import StorageBackend
20
  from utils.helper import anonymize_token
21
 
22
 
23
  class AccountService:
24
- ACCOUNT_TYPE_MAP = {
25
- "free": "Free",
26
- "plus": "Plus",
27
- "prolite": "ProLite",
28
- "pro_lite": "ProLite",
29
- "team": "Team",
30
- "pro": "Pro",
31
- "personal": "Plus",
32
- "business": "Team",
33
- "enterprise": "Team",
34
- }
35
 
36
  def __init__(self, storage_backend: StorageBackend):
37
  self.storage = storage_backend
@@ -39,198 +23,61 @@ class AccountService:
39
  self._index = 0
40
  self._accounts = self._load_accounts()
41
 
42
- @staticmethod
43
- def _clean_token(value: Any) -> str:
44
- return str(value or "").strip()
45
-
46
- def _clean_tokens(self, tokens: list[str]) -> list[str]:
47
- cleaned: list[str] = []
48
- seen = set()
49
- for token in tokens:
50
- value = self._clean_token(token)
51
- if value and value not in seen:
52
- seen.add(value)
53
- cleaned.append(value)
54
- return cleaned
55
-
56
- def _find_account_index(self, access_token: str) -> int:
57
- for index, item in enumerate(self._accounts):
58
- if self._clean_token(item.get("access_token")) == access_token:
59
- return index
60
- return -1
61
 
62
  @staticmethod
63
  def _is_image_account_available(account: dict) -> bool:
64
  if not isinstance(account, dict):
65
  return False
66
- status = str(account.get("status") or "").strip()
67
- if status in {"禁用", "限流", "异常"}:
68
  return False
69
  if bool(account.get("image_quota_unknown")):
70
  return True
71
  return int(account.get("quota") or 0) > 0
72
 
73
- def _decode_access_token_payload(self, access_token: str) -> dict[str, Any]:
74
- parts = self._clean_token(access_token).split(".")
75
- if len(parts) < 2:
76
- return {}
77
- payload = parts[1]
78
- payload += "=" * (-len(payload) % 4)
79
- try:
80
- decoded = base64.urlsafe_b64decode(payload.encode("utf-8"))
81
- data = json.loads(decoded.decode("utf-8"))
82
- except Exception:
83
- return {}
84
- return data if isinstance(data, dict) else {}
85
-
86
- def _normalize_account_type(self, value: Any) -> str | None:
87
- return self.ACCOUNT_TYPE_MAP.get(self._clean_token(value).lower())
88
-
89
- def _search_account_type(self, value: Any) -> str | None:
90
- if isinstance(value, dict):
91
- for key, item in value.items():
92
- key_text = self._clean_token(key).lower()
93
- if any(flag in key_text for flag in ("plan", "type", "subscription", "workspace", "tier")):
94
- matched = self._normalize_account_type(item)
95
- if matched:
96
- return matched
97
- matched = self._search_account_type(item)
98
- if matched:
99
- return matched
100
- return None
101
- if isinstance(value, list):
102
- for item in value:
103
- matched = self._search_account_type(item)
104
- if matched:
105
- return matched
106
- return None
107
- return None
108
-
109
- def _detect_account_type(self, access_token: str, me_payload: Any, init_payload: Any) -> str:
110
- token_payload = self._decode_access_token_payload(access_token)
111
-
112
- auth_payload = token_payload.get("https://api.openai.com/auth")
113
- print("检测账户类型响应", auth_payload)
114
- if isinstance(auth_payload, dict):
115
- matched = self._normalize_account_type(auth_payload.get("chatgpt_plan_type"))
116
- if matched:
117
- return matched
118
-
119
- for payload in (me_payload, init_payload, token_payload):
120
- matched = self._search_account_type(payload)
121
- if matched:
122
- return matched
123
-
124
- return "Free"
125
-
126
  def _normalize_account(self, item: dict) -> dict | None:
127
  if not isinstance(item, dict):
128
  return None
129
- access_token = self._clean_token(item.get("access_token"))
130
  if not access_token:
131
  return None
132
  normalized = dict(item)
133
  normalized["access_token"] = access_token
134
- normalized["type"] = self._clean_token(normalized.get("type")) or "Free"
135
- normalized["status"] = self._clean_token(normalized.get("status")) or "正常"
136
- normalized["quota"] = int(normalized.get("quota") if normalized.get("quota") is not None else 0)
137
- if normalized["quota"] < 0:
138
- normalized["quota"] = 0
139
  normalized["image_quota_unknown"] = bool(normalized.get("image_quota_unknown"))
140
- normalized["email"] = self._clean_token(normalized.get("email")) or None
141
- normalized["user_id"] = self._clean_token(normalized.get("user_id")) or None
142
  limits_progress = normalized.get("limits_progress")
143
  normalized["limits_progress"] = limits_progress if isinstance(limits_progress, list) else []
144
- normalized["default_model_slug"] = self._clean_token(normalized.get("default_model_slug")) or None
145
- normalized["restore_at"] = self._clean_token(normalized.get("restore_at")) or None
146
  normalized["success"] = int(normalized.get("success") or 0)
147
  normalized["fail"] = int(normalized.get("fail") or 0)
148
  normalized["last_used_at"] = normalized.get("last_used_at")
149
  return normalized
150
 
151
- @staticmethod
152
- def _extract_quota_and_restore_at(limits_progress: list[Any]) -> tuple[int, str | None, bool]:
153
- quota = 0
154
- restore_at = None
155
- for item in limits_progress:
156
- if not isinstance(item, dict) or item.get("feature_name") != "image_gen":
157
- continue
158
- quota = int(item.get("remaining") or 0)
159
- restore_at = str(item.get("reset_after") or "").strip() or None
160
- return quota, restore_at, False
161
- return quota, restore_at, True
162
-
163
- def _load_accounts(self) -> list[dict]:
164
- accounts = self.storage.load_accounts()
165
- return [normalized for item in accounts if (normalized := self._normalize_account(item)) is not None]
166
-
167
- def _save_accounts(self) -> None:
168
- self.storage.save_accounts(self._accounts)
169
-
170
- def _build_remote_headers(self, access_token: str) -> tuple[dict[str, str], str]:
171
- account = self.get_account(access_token) or {}
172
- user_agent = self._clean_token(account.get("user-agent") or account.get("user_agent"))
173
- impersonate = self._clean_token(account.get("impersonate")) or "edge101"
174
- headers = {
175
- "authorization": f"Bearer {access_token}",
176
- "accept": "*/*",
177
- "accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
178
- "content-type": "application/json",
179
- "oai-language": "zh-CN",
180
- "origin": "https://chatgpt.com",
181
- "referer": "https://chatgpt.com/",
182
- "sec-fetch-dest": "empty",
183
- "sec-fetch-mode": "cors",
184
- "sec-fetch-site": "same-origin",
185
- "user-agent": user_agent
186
- or "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
187
- "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
188
- "sec-ch-ua": self._clean_token(account.get("sec-ch-ua"))
189
- or '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
190
- "sec-ch-ua-mobile": self._clean_token(account.get("sec-ch-ua-mobile")) or "?0",
191
- "sec-ch-ua-platform": self._clean_token(account.get("sec-ch-ua-platform")) or '"Windows"',
192
- }
193
- device_id = self._clean_token(account.get("oai-device-id") or account.get("oai_device_id"))
194
- session_id = self._clean_token(account.get("oai-session-id") or account.get("oai_session_id"))
195
- if device_id:
196
- headers["oai-device-id"] = device_id
197
- if session_id:
198
- headers["oai-session-id"] = session_id
199
- return headers, impersonate
200
-
201
- def _public_items(self, accounts: list[dict]) -> list[dict]:
202
- return [
203
- {
204
- "id": hashlib.sha1(access_token.encode("utf-8")).hexdigest()[:16],
205
- "access_token": access_token,
206
- "type": account.get("type") or "Free",
207
- "status": account.get("status") or "正常",
208
- "quota": account.get("quota") if account.get("quota") is not None else 0,
209
- "imageQuotaUnknown": bool(account.get("image_quota_unknown")),
210
- "email": account.get("email"),
211
- "user_id": account.get("user_id"),
212
- "limits_progress": account.get("limits_progress") or [],
213
- "default_model_slug": account.get("default_model_slug"),
214
- "restoreAt": account.get("restore_at"),
215
- "success": int(account.get("success") or 0),
216
- "fail": int(account.get("fail") or 0),
217
- "lastUsedAt": account.get("last_used_at"),
218
- }
219
- for account in accounts
220
- if (access_token := self._clean_token(account.get("access_token")))
221
- ]
222
-
223
  def list_tokens(self) -> list[str]:
224
  with self._lock:
225
- return [token for item in self._accounts if (token := self._clean_token(item.get("access_token")))]
226
 
227
  def _list_available_candidate_tokens(self, excluded_tokens: set[str] | None = None) -> list[str]:
228
- excluded = {self._clean_token(token) for token in (excluded_tokens or set()) if self._clean_token(token)}
229
  return [
230
  token
231
- for item in self._accounts
232
  if self._is_image_account_available(item)
233
- and (token := self._clean_token(item.get("access_token")))
234
  and token not in excluded
235
  ]
236
 
@@ -243,49 +90,26 @@ class AccountService:
243
  self._index += 1
244
  return access_token
245
 
246
- def refresh_account_state(self, access_token: str) -> dict | None:
247
- token_ref = anonymize_token(access_token)
248
- try:
249
- remote_info = self.fetch_remote_info(access_token)
250
- except Exception as exc:
251
- message = str(exc)
252
- print(f"[account-available] refresh token={token_ref} fail {message}")
253
- if "/backend-api/me failed: HTTP 401" in message:
254
- if self.remove_invalid_token(access_token, "refresh_account_state"):
255
- return None
256
- return self.update_account(
257
- access_token,
258
- {
259
- "status": "异常",
260
- "quota": 0,
261
- },
262
- )
263
- return None
264
- return self.update_account(access_token, remote_info)
265
-
266
  def get_available_access_token(self) -> str:
267
  attempted_tokens: set[str] = set()
268
  while True:
269
  access_token = self._pick_next_candidate_token(excluded_tokens=attempted_tokens)
270
  attempted_tokens.add(access_token)
271
- token_ref = anonymize_token(access_token)
272
- account = self.refresh_account_state(access_token)
 
 
273
  if self._is_image_account_available(account or {}):
274
  return access_token
275
- print(
276
- f"[account-available] skip token={token_ref} "
277
- f"quota={account.get('quota') if account else 'unknown'} "
278
- f"status={account.get('status') if account else 'unknown'}"
279
- )
280
 
281
  def get_text_access_token(self, excluded_tokens: set[str] | None = None) -> str:
282
- excluded = {self._clean_token(token) for token in (excluded_tokens or set()) if self._clean_token(token)}
283
  with self._lock:
284
  candidates = [
285
  token
286
- for account in self._accounts
287
- if self._clean_token(account.get("status")) not in {"禁用", "异常"}
288
- and (token := self._clean_token(account.get("access_token")))
289
  and token not in excluded
290
  ]
291
  if not candidates:
@@ -295,70 +119,62 @@ class AccountService:
295
  return access_token
296
 
297
  def mark_text_used(self, access_token: str) -> None:
298
- access_token = self._clean_token(access_token)
299
  if not access_token:
300
  return
301
  with self._lock:
302
- index = self._find_account_index(access_token)
303
- if index < 0:
304
  return
305
- next_item = dict(self._accounts[index])
306
  next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
307
  account = self._normalize_account(next_item)
308
  if account is None:
309
  return
310
- self._accounts[index] = account
311
  self._save_accounts()
312
 
313
  def remove_invalid_token(self, access_token: str, event: str) -> bool:
314
  if not config.auto_remove_invalid_accounts:
 
315
  return False
316
- removed = self.remove_token(access_token)
317
  if removed:
318
- log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号", {"source": event, "token": anonymize_token(access_token)})
 
 
 
319
  return removed
320
 
321
- def next_token(self) -> str:
322
- return self.get_available_access_token()
323
-
324
- def has_available_account(self) -> bool:
325
- with self._lock:
326
- return any(self._is_image_account_available(item) for item in self._accounts)
327
-
328
  def get_account(self, access_token: str) -> dict | None:
329
- access_token = self._clean_token(access_token)
330
  if not access_token:
331
  return None
332
  with self._lock:
333
- index = self._find_account_index(access_token)
334
- if index >= 0:
335
- return dict(self._accounts[index])
336
- return None
337
 
338
  def list_accounts(self) -> list[dict]:
339
  with self._lock:
340
- return self._public_items(self._accounts)
341
 
342
  def list_limited_tokens(self) -> list[str]:
343
  with self._lock:
344
  return [
345
  token
346
- for item in self._accounts
347
  if item.get("status") == "限流"
348
- and (token := self._clean_token(item.get("access_token")))
349
  ]
350
 
351
  def add_accounts(self, tokens: list[str]) -> dict:
352
- cleaned_tokens = self._clean_tokens(tokens)
353
- if not cleaned_tokens:
354
  return {"added": 0, "skipped": 0, "items": self.list_accounts()}
355
 
356
  with self._lock:
357
- indexed = {self._clean_token(item.get("access_token")): dict(item) for item in self._accounts}
358
  added = 0
359
  skipped = 0
360
- for access_token in cleaned_tokens:
361
- current = indexed.get(access_token)
362
  if current is None:
363
  added += 1
364
  current = {}
@@ -368,70 +184,63 @@ class AccountService:
368
  {
369
  **current,
370
  "access_token": access_token,
371
- "type": str(current.get("type") or "Free"),
372
  }
373
  )
374
  if account is not None:
375
- indexed[access_token] = account
376
- self._accounts = list(indexed.values())
377
  self._save_accounts()
378
- items = self._public_items(self._accounts)
379
- log_service.add(LOG_TYPE_ACCOUNT, f"新增 {added} 个账号,跳过 {skipped} 个", {"added": added, "skipped": skipped})
 
380
  return {"added": added, "skipped": skipped, "items": items}
381
 
382
  def delete_accounts(self, tokens: list[str]) -> dict:
383
- target_set = set(self._clean_tokens(tokens))
384
  if not target_set:
385
  return {"removed": 0, "items": self.list_accounts()}
386
  with self._lock:
387
- before = len(self._accounts)
388
- self._accounts = [item for item in self._accounts if
389
- self._clean_token(item.get("access_token")) not in target_set]
390
- removed = before - len(self._accounts)
391
- if self._accounts:
392
- self._index %= len(self._accounts)
393
- else:
394
- self._index = 0
395
  if removed:
 
 
 
 
396
  self._save_accounts()
397
  log_service.add(LOG_TYPE_ACCOUNT, f"删除 {removed} 个账号", {"removed": removed})
398
- items = self._public_items(self._accounts)
399
  return {"removed": removed, "items": items}
400
 
401
- def remove_token(self, access_token: str) -> bool:
402
- return bool(self.delete_accounts([access_token])["removed"])
403
-
404
  def update_account(self, access_token: str, updates: dict) -> dict | None:
405
- access_token = self._clean_token(access_token)
406
  if not access_token:
407
  return None
408
  with self._lock:
409
- index = self._find_account_index(access_token)
410
- if index < 0:
411
  return None
412
- account = self._normalize_account({**self._accounts[index], **updates, "access_token": access_token})
413
  if account is None:
414
  return None
415
  if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts:
416
- del self._accounts[index]
417
  self._save_accounts()
418
  log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)})
419
  return None
420
- self._accounts[index] = account
421
  self._save_accounts()
422
- log_service.add(LOG_TYPE_ACCOUNT, "更新账号", {"token": anonymize_token(access_token), "status": account.get("status")})
 
423
  return dict(account)
424
  return None
425
 
426
  def mark_image_result(self, access_token: str, success: bool) -> dict | None:
427
- access_token = self._clean_token(access_token)
428
  if not access_token:
429
  return None
430
  with self._lock:
431
- index = self._find_account_index(access_token)
432
- if index < 0:
433
  return None
434
- next_item = dict(self._accounts[index])
435
  next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
436
  image_quota_unknown = bool(next_item.get("image_quota_unknown"))
437
  if success:
@@ -449,116 +258,50 @@ class AccountService:
449
  if account is None:
450
  return None
451
  if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts:
452
- del self._accounts[index]
453
  self._save_accounts()
454
  log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)})
455
  return None
456
- self._accounts[index] = account
457
  self._save_accounts()
458
  return dict(account)
459
  return None
460
 
461
- def fetch_remote_info(self, access_token: str) -> dict[str, Any]:
462
- access_token = self._clean_token(access_token)
463
  if not access_token:
464
  raise ValueError("access_token is required")
465
 
466
- headers, impersonate = self._build_remote_headers(access_token)
467
- token_ref = anonymize_token(access_token)
468
- print(f"[account-refresh] start {token_ref}")
469
- session = Session(**proxy_settings.build_session_kwargs(impersonate=impersonate, verify=True))
470
- session.headers.update(headers)
471
  try:
472
- with ThreadPoolExecutor(max_workers=2) as executor:
473
- me_future = executor.submit(
474
- session.get,
475
- "https://chatgpt.com/backend-api/me",
476
- headers={
477
- "x-openai-target-path": "/backend-api/me",
478
- "x-openai-target-route": "/backend-api/me",
479
- },
480
- timeout=20,
481
- )
482
- init_future = executor.submit(
483
- session.post,
484
- "https://chatgpt.com/backend-api/conversation/init",
485
- json={
486
- "gizmo_id": None,
487
- "requested_default_model": None,
488
- "conversation_id": None,
489
- "timezone_offset_min": -480,
490
- },
491
- timeout=20,
492
- )
493
-
494
- me_response = me_future.result()
495
- init_response = init_future.result()
496
-
497
- if me_response.status_code != 200:
498
- raise RuntimeError(f"/backend-api/me failed: HTTP {me_response.status_code}")
499
- me_payload = me_response.json()
500
-
501
- if init_response.status_code != 200:
502
- raise RuntimeError(f"/backend-api/conversation/init failed: HTTP {init_response.status_code}")
503
- init_payload = init_response.json()
504
-
505
- limits_progress = init_payload.get("limits_progress")
506
- if not isinstance(limits_progress, list):
507
- limits_progress = []
508
-
509
- account_type = self._detect_account_type(access_token, me_payload, init_payload)
510
- quota, restore_at, image_quota_unknown = self._extract_quota_and_restore_at(limits_progress)
511
- status = "正常" if image_quota_unknown and account_type != "Free" else ("限流" if quota == 0 else "正常")
512
-
513
- result = {
514
- "email": me_payload.get("email"),
515
- "user_id": me_payload.get("id"),
516
- "type": account_type,
517
- "quota": quota,
518
- "image_quota_unknown": image_quota_unknown,
519
- "limits_progress": limits_progress,
520
- "default_model_slug": init_payload.get("default_model_slug"),
521
- "restore_at": restore_at,
522
- "status": status,
523
- }
524
- print(
525
- "[account-refresh] ok",
526
- token_ref,
527
- f"quota={result.get('quota')}",
528
- f"restore_at={result.get('restore_at')}",
529
- )
530
- return result
531
- finally:
532
- session.close()
533
 
534
  def refresh_accounts(self, access_tokens: list[str]) -> dict[str, Any]:
535
- cleaned_tokens = self._clean_tokens(access_tokens)
536
- if not cleaned_tokens:
537
  return {"refreshed": 0, "errors": [], "items": self.list_accounts()}
538
 
539
  refreshed = 0
540
- errors: list[dict[str, str]] = []
541
- max_workers = min(10, len(cleaned_tokens))
542
 
543
  with ThreadPoolExecutor(max_workers=max_workers) as executor:
544
- future_map = {executor.submit(self.fetch_remote_info, access_token): access_token for access_token in
545
- cleaned_tokens}
546
- for future in as_completed(future_map):
547
- access_token = future_map[future]
 
548
  try:
549
- remote_info = future.result()
550
- if self.update_account(access_token, remote_info) is not None:
551
- refreshed += 1
552
  except Exception as exc:
553
- message = str(exc)
554
- print(f"[account-refresh] fail {anonymize_token(access_token)} {message}")
555
- if "/backend-api/me failed: HTTP 401" in message:
556
- if not self.remove_invalid_token(access_token, "refresh_accounts"):
557
- self.update_account(access_token, {"status": "异常", "quota": 0})
558
- message = "检测到封号"
559
- errors.append({"access_token": access_token, "error": message})
560
-
561
- print(f"[account-refresh] done refreshed={refreshed} errors={len(errors)} workers={max_workers}")
562
  return {
563
  "refreshed": refreshed,
564
  "errors": errors,
 
1
  from __future__ import annotations
2
 
3
  from concurrent.futures import ThreadPoolExecutor, as_completed
 
 
 
4
  from threading import Lock
5
  from typing import Any
6
  from datetime import datetime
7
 
 
 
8
  from services.config import config
9
  from services.log_service import (
10
  LOG_TYPE_ACCOUNT,
11
  log_service,
12
  )
 
13
  from services.storage.base import StorageBackend
14
  from utils.helper import anonymize_token
15
 
16
 
17
  class AccountService:
18
+ """账号池服务,使用 token -> account 的 dict 保存账号。"""
 
 
 
 
 
 
 
 
 
 
19
 
20
  def __init__(self, storage_backend: StorageBackend):
21
  self.storage = storage_backend
 
23
  self._index = 0
24
  self._accounts = self._load_accounts()
25
 
26
+ def _load_accounts(self) -> dict[str, dict]:
27
+ accounts = self.storage.load_accounts()
28
+ return {
29
+ normalized["access_token"]: normalized
30
+ for item in accounts
31
+ if (normalized := self._normalize_account(item)) is not None
32
+ }
33
+
34
+ def _save_accounts(self) -> None:
35
+ self.storage.save_accounts(list(self._accounts.values()))
 
 
 
 
 
 
 
 
 
36
 
37
  @staticmethod
38
  def _is_image_account_available(account: dict) -> bool:
39
  if not isinstance(account, dict):
40
  return False
41
+ if account.get("status") in {"禁用", "限流", "异常"}:
 
42
  return False
43
  if bool(account.get("image_quota_unknown")):
44
  return True
45
  return int(account.get("quota") or 0) > 0
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  def _normalize_account(self, item: dict) -> dict | None:
48
  if not isinstance(item, dict):
49
  return None
50
+ access_token = item.get("access_token") or ""
51
  if not access_token:
52
  return None
53
  normalized = dict(item)
54
  normalized["access_token"] = access_token
55
+ normalized["type"] = normalized.get("type") or "free"
56
+ normalized["status"] = normalized.get("status") or "正常"
57
+ normalized["quota"] = max(0, int(normalized.get("quota") if normalized.get("quota") is not None else 0))
 
 
58
  normalized["image_quota_unknown"] = bool(normalized.get("image_quota_unknown"))
59
+ normalized["email"] = normalized.get("email") or None
60
+ normalized["user_id"] = normalized.get("user_id") or None
61
  limits_progress = normalized.get("limits_progress")
62
  normalized["limits_progress"] = limits_progress if isinstance(limits_progress, list) else []
63
+ normalized["default_model_slug"] = normalized.get("default_model_slug") or None
64
+ normalized["restore_at"] = normalized.get("restore_at") or None
65
  normalized["success"] = int(normalized.get("success") or 0)
66
  normalized["fail"] = int(normalized.get("fail") or 0)
67
  normalized["last_used_at"] = normalized.get("last_used_at")
68
  return normalized
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  def list_tokens(self) -> list[str]:
71
  with self._lock:
72
+ return list(self._accounts)
73
 
74
  def _list_available_candidate_tokens(self, excluded_tokens: set[str] | None = None) -> list[str]:
75
+ excluded = set(excluded_tokens or set())
76
  return [
77
  token
78
+ for item in self._accounts.values()
79
  if self._is_image_account_available(item)
80
+ and (token := item.get("access_token") or "")
81
  and token not in excluded
82
  ]
83
 
 
90
  self._index += 1
91
  return access_token
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  def get_available_access_token(self) -> str:
94
  attempted_tokens: set[str] = set()
95
  while True:
96
  access_token = self._pick_next_candidate_token(excluded_tokens=attempted_tokens)
97
  attempted_tokens.add(access_token)
98
+ try:
99
+ account = self.fetch_remote_info(access_token, "get_available_access_token")
100
+ except Exception:
101
+ continue
102
  if self._is_image_account_available(account or {}):
103
  return access_token
 
 
 
 
 
104
 
105
  def get_text_access_token(self, excluded_tokens: set[str] | None = None) -> str:
106
+ excluded = set(excluded_tokens or set())
107
  with self._lock:
108
  candidates = [
109
  token
110
+ for account in self._accounts.values()
111
+ if account.get("status") not in {"禁用", "异常"}
112
+ and (token := account.get("access_token") or "")
113
  and token not in excluded
114
  ]
115
  if not candidates:
 
119
  return access_token
120
 
121
  def mark_text_used(self, access_token: str) -> None:
 
122
  if not access_token:
123
  return
124
  with self._lock:
125
+ current = self._accounts.get(access_token)
126
+ if current is None:
127
  return
128
+ next_item = dict(current)
129
  next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
130
  account = self._normalize_account(next_item)
131
  if account is None:
132
  return
133
+ self._accounts[access_token] = account
134
  self._save_accounts()
135
 
136
  def remove_invalid_token(self, access_token: str, event: str) -> bool:
137
  if not config.auto_remove_invalid_accounts:
138
+ self.update_account(access_token, {"status": "异常", "quota": 0})
139
  return False
140
+ removed = bool(self.delete_accounts([access_token])["removed"])
141
  if removed:
142
+ log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号",
143
+ {"source": event, "token": anonymize_token(access_token)})
144
+ elif access_token:
145
+ self.update_account(access_token, {"status": "异常", "quota": 0})
146
  return removed
147
 
 
 
 
 
 
 
 
148
  def get_account(self, access_token: str) -> dict | None:
 
149
  if not access_token:
150
  return None
151
  with self._lock:
152
+ account = self._accounts.get(access_token)
153
+ return dict(account) if account else None
 
 
154
 
155
  def list_accounts(self) -> list[dict]:
156
  with self._lock:
157
+ return [dict(item) for item in self._accounts.values()]
158
 
159
  def list_limited_tokens(self) -> list[str]:
160
  with self._lock:
161
  return [
162
  token
163
+ for item in self._accounts.values()
164
  if item.get("status") == "限流"
165
+ and (token := item.get("access_token") or "")
166
  ]
167
 
168
  def add_accounts(self, tokens: list[str]) -> dict:
169
+ tokens = list(dict.fromkeys(token for token in tokens if token))
170
+ if not tokens:
171
  return {"added": 0, "skipped": 0, "items": self.list_accounts()}
172
 
173
  with self._lock:
 
174
  added = 0
175
  skipped = 0
176
+ for access_token in tokens:
177
+ current = self._accounts.get(access_token)
178
  if current is None:
179
  added += 1
180
  current = {}
 
184
  {
185
  **current,
186
  "access_token": access_token,
187
+ "type": str(current.get("type") or "free"),
188
  }
189
  )
190
  if account is not None:
191
+ self._accounts[access_token] = account
 
192
  self._save_accounts()
193
+ items = [dict(item) for item in self._accounts.values()]
194
+ log_service.add(LOG_TYPE_ACCOUNT, f"新增 {added} 个账号,跳过 {skipped} 个",
195
+ {"added": added, "skipped": skipped})
196
  return {"added": added, "skipped": skipped, "items": items}
197
 
198
  def delete_accounts(self, tokens: list[str]) -> dict:
199
+ target_set = set(token for token in tokens if token)
200
  if not target_set:
201
  return {"removed": 0, "items": self.list_accounts()}
202
  with self._lock:
203
+ removed = sum(self._accounts.pop(token, None) is not None for token in target_set)
 
 
 
 
 
 
 
204
  if removed:
205
+ if self._accounts:
206
+ self._index %= len(self._accounts)
207
+ else:
208
+ self._index = 0
209
  self._save_accounts()
210
  log_service.add(LOG_TYPE_ACCOUNT, f"删除 {removed} 个账号", {"removed": removed})
211
+ items = [dict(item) for item in self._accounts.values()]
212
  return {"removed": removed, "items": items}
213
 
 
 
 
214
  def update_account(self, access_token: str, updates: dict) -> dict | None:
 
215
  if not access_token:
216
  return None
217
  with self._lock:
218
+ current = self._accounts.get(access_token)
219
+ if current is None:
220
  return None
221
+ account = self._normalize_account({**current, **updates, "access_token": access_token})
222
  if account is None:
223
  return None
224
  if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts:
225
+ self._accounts.pop(access_token, None)
226
  self._save_accounts()
227
  log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)})
228
  return None
229
+ self._accounts[access_token] = account
230
  self._save_accounts()
231
+ log_service.add(LOG_TYPE_ACCOUNT, "更新账号",
232
+ {"token": anonymize_token(access_token), "status": account.get("status")})
233
  return dict(account)
234
  return None
235
 
236
  def mark_image_result(self, access_token: str, success: bool) -> dict | None:
 
237
  if not access_token:
238
  return None
239
  with self._lock:
240
+ current = self._accounts.get(access_token)
241
+ if current is None:
242
  return None
243
+ next_item = dict(current)
244
  next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
245
  image_quota_unknown = bool(next_item.get("image_quota_unknown"))
246
  if success:
 
258
  if account is None:
259
  return None
260
  if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts:
261
+ self._accounts.pop(access_token, None)
262
  self._save_accounts()
263
  log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)})
264
  return None
265
+ self._accounts[access_token] = account
266
  self._save_accounts()
267
  return dict(account)
268
  return None
269
 
270
+ def fetch_remote_info(self, access_token: str, event: str = "fetch_remote_info") -> dict[str, Any] | None:
 
271
  if not access_token:
272
  raise ValueError("access_token is required")
273
 
 
 
 
 
 
274
  try:
275
+ from services.openai_backend_api import InvalidAccessTokenError, OpenAIBackendAPI
276
+ result = OpenAIBackendAPI(access_token).get_user_info()
277
+ except InvalidAccessTokenError:
278
+ self.remove_invalid_token(access_token, event)
279
+ raise
280
+ return self.update_account(access_token, result)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
 
282
  def refresh_accounts(self, access_tokens: list[str]) -> dict[str, Any]:
283
+ access_tokens = list(dict.fromkeys(token for token in access_tokens if token))
284
+ if not access_tokens:
285
  return {"refreshed": 0, "errors": [], "items": self.list_accounts()}
286
 
287
  refreshed = 0
288
+ errors = []
289
+ max_workers = min(10, len(access_tokens))
290
 
291
  with ThreadPoolExecutor(max_workers=max_workers) as executor:
292
+ futures = {
293
+ executor.submit(self.fetch_remote_info, token, "refresh_accounts"): token
294
+ for token in access_tokens
295
+ }
296
+ for future in as_completed(futures):
297
  try:
298
+ account = future.result()
 
 
299
  except Exception as exc:
300
+ errors.append({"token": anonymize_token(futures[future]), "error": str(exc)})
301
+ continue
302
+ if account is not None:
303
+ refreshed += 1
304
+
 
 
 
 
305
  return {
306
  "refreshed": refreshed,
307
  "errors": errors,
services/openai_backend_api.py CHANGED
@@ -2,6 +2,7 @@ import base64
2
  import os
3
  import re
4
  import time
 
5
  from dataclasses import dataclass
6
  from io import BytesIO
7
  from pathlib import Path
@@ -19,6 +20,10 @@ from utils.pow import build_legacy_requirements_token, build_proof_token, parse_
19
  from utils.turnstile import solve_turnstile_token
20
 
21
 
 
 
 
 
22
  @dataclass
23
  class ChatRequirements:
24
  """保存一次对话请求所需的 sentinel token。"""
@@ -135,6 +140,93 @@ class OpenAIBackendAPI:
135
  headers.update(extra)
136
  return headers
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  def _bootstrap_headers(self) -> Dict[str, str]:
139
  """构造首页预热请求头。"""
140
  return {
@@ -562,14 +654,18 @@ class OpenAIBackendAPI:
562
  for sediment_id in record["sediment_ids"]:
563
  if sediment_id not in sediment_ids:
564
  sediment_ids.append(sediment_id)
565
- logger.debug({"event": "image_poll_check", "conversation_id": conversation_id, "attempt": attempt, "file_ids": file_ids, "sediment_ids": sediment_ids})
 
566
  if file_ids:
567
- logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": file_ids, "sediment_ids": sediment_ids})
 
568
  return file_ids, sediment_ids
569
  if sediment_ids:
570
- logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": [], "sediment_ids": sediment_ids})
 
571
  return [], sediment_ids
572
- logger.debug({"event": "image_poll_wait", "conversation_id": conversation_id, "elapsed_secs": round(time.time() - start, 1)})
 
573
  time.sleep(4)
574
  logger.info({"event": "image_poll_timeout", "conversation_id": conversation_id, "timeout_secs": timeout_secs})
575
  return [], []
@@ -675,7 +771,8 @@ class OpenAIBackendAPI:
675
  sediment_ids = list(sediment_ids)
676
  if poll and conversation_id and not file_ids and not sediment_ids:
677
  logger.info({"event": "image_resolve_poll_needed", "conversation_id": conversation_id})
678
- polled_file_ids, polled_sediment_ids = self._poll_image_results(conversation_id, config.image_poll_timeout_secs)
 
679
  file_ids.extend(item for item in polled_file_ids if item and item not in file_ids)
680
  sediment_ids.extend(item for item in polled_sediment_ids if item and item not in sediment_ids)
681
  return self._resolve_image_urls(conversation_id, file_ids, sediment_ids)
 
2
  import os
3
  import re
4
  import time
5
+ from concurrent.futures import ThreadPoolExecutor
6
  from dataclasses import dataclass
7
  from io import BytesIO
8
  from pathlib import Path
 
20
  from utils.turnstile import solve_turnstile_token
21
 
22
 
23
+ class InvalidAccessTokenError(RuntimeError):
24
+ pass
25
+
26
+
27
  @dataclass
28
  class ChatRequirements:
29
  """保存一次对话请求所需的 sentinel token。"""
 
140
  headers.update(extra)
141
  return headers
142
 
143
+ @staticmethod
144
+ def _extract_quota_and_restore_at(limits_progress: list[Any]) -> tuple[int, str | None, bool]:
145
+ for item in limits_progress:
146
+ if isinstance(item, dict) and item.get("feature_name") == "image_gen":
147
+ return int(item.get("remaining") or 0), str(item.get("reset_after") or "") or None, False
148
+ return 0, None, True
149
+
150
+ def _get_me(self) -> Dict[str, Any]:
151
+ path = "/backend-api/me"
152
+ response = self.session.get(self.base_url + path, headers=self._headers(path), timeout=20)
153
+ if response.status_code != 200:
154
+ if response.status_code == 401:
155
+ raise InvalidAccessTokenError(f"{path} failed: HTTP {response.status_code}")
156
+ raise RuntimeError(f"{path} failed: HTTP {response.status_code}")
157
+ return response.json()
158
+
159
+ def _get_conversation_init(self) -> Dict[str, Any]:
160
+ path = "/backend-api/conversation/init"
161
+ response = self.session.post(
162
+ self.base_url + path,
163
+ headers=self._headers(path, {"Content-Type": "application/json"}),
164
+ json={
165
+ "gizmo_id": None,
166
+ "requested_default_model": None,
167
+ "conversation_id": None,
168
+ "timezone_offset_min": -480,
169
+ },
170
+ timeout=20,
171
+ )
172
+ if response.status_code != 200:
173
+ if response.status_code == 401:
174
+ raise InvalidAccessTokenError(f"{path} failed: HTTP {response.status_code}")
175
+ raise RuntimeError(f"{path} failed: HTTP {response.status_code}")
176
+ return response.json()
177
+
178
+ def _get_default_account(self) -> Dict[str, Any]:
179
+ route = "/backend-api/accounts/check/v4-2023-04-27"
180
+ response = self.session.get(self.base_url + route + "?timezone_offset_min=-480", headers=self._headers(route),
181
+ timeout=20)
182
+ if response.status_code != 200:
183
+ if response.status_code == 401:
184
+ raise InvalidAccessTokenError(f"{route} failed: HTTP {response.status_code}")
185
+ raise RuntimeError(f"/backend-api/accounts/check failed: HTTP {response.status_code}")
186
+ payload = response.json()
187
+ logger.debug({"event": "backend_user_info_account_payload", "account_payload": payload})
188
+ return ((payload.get("accounts") or {}).get("default") or {}).get("account") or {}
189
+
190
+ def get_user_info(self) -> Dict[str, Any]:
191
+ """获取当前 token 的账号信息。"""
192
+ if not self.access_token:
193
+ raise RuntimeError("access_token is required")
194
+ logger.debug({"event": "backend_user_info_start"})
195
+ with ThreadPoolExecutor(max_workers=3) as executor:
196
+ me_future = executor.submit(self._get_me)
197
+ init_future = executor.submit(self._get_conversation_init)
198
+ account_future = executor.submit(self._get_default_account)
199
+ me_payload, init_payload, default_account = me_future.result(), init_future.result(), account_future.result()
200
+
201
+ plan_type = str(default_account.get("plan_type") or "free")
202
+
203
+ limits_progress = init_payload.get("limits_progress")
204
+ limits_progress = limits_progress if isinstance(limits_progress, list) else []
205
+ quota, restore_at, image_quota_unknown = self._extract_quota_and_restore_at(limits_progress)
206
+ result = {
207
+ "email": me_payload.get("email"),
208
+ "user_id": me_payload.get("id"),
209
+ "type": plan_type,
210
+ "quota": quota,
211
+ "image_quota_unknown": image_quota_unknown,
212
+ "limits_progress": limits_progress,
213
+ "default_model_slug": init_payload.get("default_model_slug"),
214
+ "restore_at": restore_at,
215
+ "status": "正常" if image_quota_unknown and plan_type.lower() != "free" else ("限流" if quota == 0 else "正常"),
216
+ }
217
+ logger.debug({
218
+ "event": "backend_user_info_result",
219
+ "email": result.get("email"),
220
+ "user_id": result.get("user_id"),
221
+ "type": result.get("type"),
222
+ "quota": result.get("quota"),
223
+ "image_quota_unknown": result.get("image_quota_unknown"),
224
+ "default_model_slug": result.get("default_model_slug"),
225
+ "restore_at": result.get("restore_at"),
226
+ "status": result.get("status"),
227
+ })
228
+ return result
229
+
230
  def _bootstrap_headers(self) -> Dict[str, str]:
231
  """构造首页预热请求头。"""
232
  return {
 
654
  for sediment_id in record["sediment_ids"]:
655
  if sediment_id not in sediment_ids:
656
  sediment_ids.append(sediment_id)
657
+ logger.debug({"event": "image_poll_check", "conversation_id": conversation_id, "attempt": attempt,
658
+ "file_ids": file_ids, "sediment_ids": sediment_ids})
659
  if file_ids:
660
+ logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": file_ids,
661
+ "sediment_ids": sediment_ids})
662
  return file_ids, sediment_ids
663
  if sediment_ids:
664
+ logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": [],
665
+ "sediment_ids": sediment_ids})
666
  return [], sediment_ids
667
+ logger.debug({"event": "image_poll_wait", "conversation_id": conversation_id,
668
+ "elapsed_secs": round(time.time() - start, 1)})
669
  time.sleep(4)
670
  logger.info({"event": "image_poll_timeout", "conversation_id": conversation_id, "timeout_secs": timeout_secs})
671
  return [], []
 
771
  sediment_ids = list(sediment_ids)
772
  if poll and conversation_id and not file_ids and not sediment_ids:
773
  logger.info({"event": "image_resolve_poll_needed", "conversation_id": conversation_id})
774
+ polled_file_ids, polled_sediment_ids = self._poll_image_results(conversation_id,
775
+ config.image_poll_timeout_secs)
776
  file_ids.extend(item for item in polled_file_ids if item and item not in file_ids)
777
  sediment_ids.extend(item for item in polled_sediment_ids if item and item not in sediment_ids)
778
  return self._resolve_image_urls(conversation_id, file_ids, sediment_ids)
services/register_service.py CHANGED
@@ -118,7 +118,7 @@ class RegisterService:
118
  items = account_service.list_accounts()
119
  normal = [item for item in items if item.get("status") == "正常"]
120
  return {
121
- "current_quota": sum(int(item.get("quota") or 0) for item in normal if not item.get("imageQuotaUnknown")),
122
  "current_available": len(normal),
123
  }
124
 
 
118
  items = account_service.list_accounts()
119
  normal = [item for item in items if item.get("status") == "正常"]
120
  return {
121
+ "current_quota": sum(int(item.get("quota") or 0) for item in normal if not item.get("image_quota_unknown")),
122
  "current_available": len(normal),
123
  }
124
 
utils/log.py CHANGED
@@ -1,5 +1,6 @@
1
  import base64
2
  import binascii
 
3
  import logging
4
  import re
5
  from typing import Any
@@ -83,21 +84,27 @@ class Logger:
83
  return self._sanitize_string(value)
84
  return value
85
 
 
 
 
 
 
 
86
  def debug(self, message: Any) -> None:
87
  if self._enabled("debug"):
88
- self._logger.debug(self._sanitize(message))
89
 
90
  def info(self, message: Any) -> None:
91
  if self._enabled("info"):
92
- self._logger.info(self._sanitize(message))
93
 
94
  def warning(self, message: Any) -> None:
95
  if self._enabled("warning"):
96
- self._logger.warning(self._sanitize(message))
97
 
98
  def error(self, message: Any) -> None:
99
  if self._enabled("error"):
100
- self._logger.error(self._sanitize(message))
101
 
102
 
103
  logger = Logger()
 
1
  import base64
2
  import binascii
3
+ import json
4
  import logging
5
  import re
6
  from typing import Any
 
84
  return self._sanitize_string(value)
85
  return value
86
 
87
+ def _message(self, value: Any) -> str:
88
+ sanitized = self._sanitize(value)
89
+ if isinstance(sanitized, str):
90
+ return sanitized
91
+ return json.dumps(sanitized, ensure_ascii=False, default=str)
92
+
93
  def debug(self, message: Any) -> None:
94
  if self._enabled("debug"):
95
+ self._logger.debug(self._message(message))
96
 
97
  def info(self, message: Any) -> None:
98
  if self._enabled("info"):
99
+ self._logger.info(self._message(message))
100
 
101
  def warning(self, message: Any) -> None:
102
  if self._enabled("warning"):
103
+ self._logger.warning(self._message(message))
104
 
105
  def error(self, message: Any) -> None:
106
  if self._enabled("error"):
107
+ self._logger.error(self._message(message))
108
 
109
 
110
  logger = Logger()
web/src/app/accounts/page.tsx CHANGED
@@ -47,22 +47,12 @@ import {
47
  updateAccount,
48
  type Account,
49
  type AccountStatus,
50
- type AccountType,
51
  } from "@/lib/api";
52
  import { useAuthGuard } from "@/lib/use-auth-guard";
53
  import { cn } from "@/lib/utils";
54
 
55
  import { AccountImportDialog } from "./components/account-import-dialog";
56
 
57
- const accountTypeOptions: { label: string; value: AccountType | "all" }[] = [
58
- { label: "全部类型", value: "all" },
59
- { label: "Free", value: "Free" },
60
- { label: "Plus", value: "Plus" },
61
- { label: "ProLite", value: "ProLite" },
62
- { label: "Team", value: "Team" },
63
- { label: "Pro", value: "Pro" },
64
- ];
65
-
66
  const accountStatusOptions: { label: string; value: AccountStatus | "all" }[] = [
67
  { label: "全部状态", value: "all" },
68
  { label: "正常", value: "正常" },
@@ -94,7 +84,11 @@ const metricCards = [
94
  ] as const;
95
 
96
  function isUnlimitedImageQuotaAccount(account: Account) {
97
- return account.type === "Pro" || account.type === "ProLite";
 
 
 
 
98
  }
99
 
100
  function formatCompact(value: number) {
@@ -108,7 +102,7 @@ function formatQuota(account: Account) {
108
  if (isUnlimitedImageQuotaAccount(account)) {
109
  return "∞";
110
  }
111
- if (account.imageQuotaUnknown) {
112
  return "未知";
113
  }
114
  return String(Math.max(0, account.quota));
@@ -143,7 +137,7 @@ function formatQuotaSummary(accounts: Account[]) {
143
  if (availableAccounts.some(isUnlimitedImageQuotaAccount)) {
144
  return "∞";
145
  }
146
- if (availableAccounts.some((account) => account.imageQuotaUnknown)) {
147
  return "未知";
148
  }
149
  return formatCompact(availableAccounts.reduce((sum, account) => sum + Math.max(0, account.quota), 0));
@@ -166,18 +160,8 @@ function downloadTokens(accounts: Account[]) {
166
  URL.revokeObjectURL(url);
167
  }
168
 
169
- function normalizeAccounts(items: Account[]): Account[] {
170
- return items.map((item) => ({
171
- ...item,
172
- type:
173
- item.type === "Plus" ||
174
- item.type === "ProLite" ||
175
- item.type === "Team" ||
176
- item.type === "Pro" ||
177
- item.type === "Free"
178
- ? item.type
179
- : "Free",
180
- }));
181
  }
182
 
183
  function AccountsPageContent() {
@@ -185,14 +169,12 @@ function AccountsPageContent() {
185
  const [accounts, setAccounts] = useState<Account[]>([]);
186
  const [selectedIds, setSelectedIds] = useState<string[]>([]);
187
  const [query, setQuery] = useState("");
188
- const [typeFilter, setTypeFilter] = useState<AccountType | "all">("all");
189
  const [statusFilter, setStatusFilter] = useState<AccountStatus | "all">("all");
190
  const [page, setPage] = useState(1);
191
  const [pageSize, setPageSize] = useState("10");
192
  const [editingAccount, setEditingAccount] = useState<Account | null>(null);
193
- const [editType, setEditType] = useState<AccountType>("Free");
194
  const [editStatus, setEditStatus] = useState<AccountStatus>("正常");
195
- const [editQuota, setEditQuota] = useState("0");
196
  const [isLoading, setIsLoading] = useState(true);
197
  const [isRefreshing, setIsRefreshing] = useState(false);
198
  const [isDeleting, setIsDeleting] = useState(false);
@@ -204,8 +186,8 @@ function AccountsPageContent() {
204
  }
205
  try {
206
  const data = await fetchAccounts();
207
- setAccounts(normalizeAccounts(data.items));
208
- setSelectedIds((prev) => prev.filter((id) => data.items.some((item) => item.id === id)));
209
  } catch (error) {
210
  const message = error instanceof Error ? error.message : "加载账户失败";
211
  toast.error(message);
@@ -229,7 +211,7 @@ function AccountsPageContent() {
229
  return accounts.filter((account) => {
230
  const searchMatched =
231
  normalizedQuery.length === 0 || (account.email ?? "").toLowerCase().includes(normalizedQuery);
232
- const typeMatched = typeFilter === "all" || account.type === typeFilter;
233
  const statusMatched = statusFilter === "all" || account.status === statusFilter;
234
  return searchMatched && typeMatched && statusMatched;
235
  });
@@ -240,7 +222,7 @@ function AccountsPageContent() {
240
  const startIndex = (safePage - 1) * Number(pageSize);
241
  const currentRows = filteredAccounts.slice(startIndex, startIndex + Number(pageSize));
242
  const allCurrentSelected =
243
- currentRows.length > 0 && currentRows.every((row) => selectedIds.includes(row.id));
244
 
245
  const summary = useMemo(() => {
246
  const total = accounts.length;
@@ -253,9 +235,17 @@ function AccountsPageContent() {
253
  return { total, active, limited, abnormal, disabled, quota };
254
  }, [accounts]);
255
 
 
 
 
 
 
 
 
 
256
  const selectedTokens = useMemo(() => {
257
  const selectedSet = new Set(selectedIds);
258
- return accounts.filter((item) => selectedSet.has(item.id)).map((item) => item.access_token);
259
  }, [accounts, selectedIds]);
260
 
261
  const abnormalTokens = useMemo(() => {
@@ -285,8 +275,8 @@ function AccountsPageContent() {
285
  setIsDeleting(true);
286
  try {
287
  const data = await deleteAccounts(tokens);
288
- setAccounts(normalizeAccounts(data.items));
289
- setSelectedIds((prev) => prev.filter((id) => data.items.some((item) => item.id === id)));
290
  toast.success(`删除 ${data.removed ?? 0} 个账户`);
291
  } catch (error) {
292
  const message = error instanceof Error ? error.message : "删除账户失败";
@@ -305,8 +295,8 @@ function AccountsPageContent() {
305
  setIsRefreshing(true);
306
  try {
307
  const data = await refreshAccounts(accessTokens);
308
- setAccounts(normalizeAccounts(data.items));
309
- setSelectedIds((prev) => prev.filter((id) => data.items.some((item) => item.id === id)));
310
  if (data.errors.length > 0) {
311
  const firstError = data.errors[0]?.error;
312
  toast.error(
@@ -325,9 +315,7 @@ function AccountsPageContent() {
325
 
326
  const openEditDialog = (account: Account) => {
327
  setEditingAccount(account);
328
- setEditType(account.type);
329
  setEditStatus(account.status);
330
- setEditQuota(String(account.quota));
331
  };
332
 
333
  const handleUpdateAccount = async () => {
@@ -338,12 +326,10 @@ function AccountsPageContent() {
338
  setIsUpdating(true);
339
  try {
340
  const data = await updateAccount(editingAccount.access_token, {
341
- type: editType,
342
  status: editStatus,
343
- quota: Number(editQuota || 0),
344
  });
345
- setAccounts(normalizeAccounts(data.items));
346
- setSelectedIds((prev) => prev.filter((id) => data.items.some((item) => item.id === id)));
347
  setEditingAccount(null);
348
  toast.success("账号信息已更新");
349
  } catch (error) {
@@ -356,10 +342,10 @@ function AccountsPageContent() {
356
 
357
  const toggleSelectAll = (checked: boolean) => {
358
  if (checked) {
359
- setSelectedIds((prev) => Array.from(new Set([...prev, ...currentRows.map((item) => item.id)])));
360
  return;
361
  }
362
- setSelectedIds((prev) => prev.filter((id) => !currentRows.some((row) => row.id === id)));
363
  };
364
 
365
  return (
@@ -394,7 +380,7 @@ function AccountsPageContent() {
394
  <AccountImportDialog
395
  disabled={isLoading || isRefreshing || isDeleting}
396
  onImported={(items) => {
397
- setAccounts(normalizeAccounts(items));
398
  setSelectedIds([]);
399
  setPage(1);
400
  }}
@@ -416,7 +402,7 @@ function AccountsPageContent() {
416
  <DialogHeader className="gap-2">
417
  <DialogTitle>编辑账户</DialogTitle>
418
  <DialogDescription className="text-sm leading-6">
419
- 手动修改账号状态、类型和额度。
420
  </DialogDescription>
421
  </DialogHeader>
422
  <div className="space-y-4">
@@ -437,31 +423,6 @@ function AccountsPageContent() {
437
  </SelectContent>
438
  </Select>
439
  </div>
440
- <div className="space-y-2">
441
- <label className="text-sm font-medium text-stone-700">类型</label>
442
- <Select value={editType} onValueChange={(value) => setEditType(value as AccountType)}>
443
- <SelectTrigger className="h-11 rounded-xl border-stone-200 bg-white">
444
- <SelectValue />
445
- </SelectTrigger>
446
- <SelectContent>
447
- {accountTypeOptions
448
- .filter((option) => option.value !== "all")
449
- .map((option) => (
450
- <SelectItem key={option.value} value={option.value}>
451
- {option.label}
452
- </SelectItem>
453
- ))}
454
- </SelectContent>
455
- </Select>
456
- </div>
457
- <div className="space-y-2">
458
- <label className="text-sm font-medium text-stone-700">额度</label>
459
- <Input
460
- value={editQuota}
461
- onChange={(event) => setEditQuota(event.target.value)}
462
- className="h-11 rounded-xl border-stone-200 bg-white"
463
- />
464
- </div>
465
  </div>
466
  <DialogFooter className="pt-2">
467
  <Button
@@ -533,7 +494,7 @@ function AccountsPageContent() {
533
  <Select
534
  value={typeFilter}
535
  onValueChange={(value) => {
536
- setTypeFilter(value as AccountType | "all");
537
  setPage(1);
538
  }}
539
  >
@@ -655,17 +616,17 @@ function AccountsPageContent() {
655
 
656
  return (
657
  <tr
658
- key={account.id}
659
  className="border-b border-stone-100/80 text-sm text-stone-600 transition-colors hover:bg-stone-50/70"
660
  >
661
  <td className="px-4 py-3">
662
  <Checkbox
663
- checked={selectedIds.includes(account.id)}
664
  onCheckedChange={(checked) => {
665
  setSelectedIds((prev) =>
666
  checked
667
- ? Array.from(new Set([...prev, account.id]))
668
- : prev.filter((item) => item !== account.id),
669
  );
670
  }}
671
  />
@@ -689,7 +650,7 @@ function AccountsPageContent() {
689
  </td>
690
  <td className="px-4 py-3">
691
  <Badge variant="secondary" className="rounded-md bg-stone-100 text-stone-700">
692
- {account.type}
693
  </Badge>
694
  </td>
695
  <td className="px-4 py-3">
@@ -711,7 +672,7 @@ function AccountsPageContent() {
711
  </td>
712
  <td className="px-4 py-3 text-xs leading-5 text-stone-500">
713
  {(() => {
714
- const restore = formatRestoreAt(account.restoreAt);
715
  return (
716
  <div className="space-y-0.5">
717
  {restore.relative ? <div className="font-medium text-stone-700">{restore.relative}</div> : null}
 
47
  updateAccount,
48
  type Account,
49
  type AccountStatus,
 
50
  } from "@/lib/api";
51
  import { useAuthGuard } from "@/lib/use-auth-guard";
52
  import { cn } from "@/lib/utils";
53
 
54
  import { AccountImportDialog } from "./components/account-import-dialog";
55
 
 
 
 
 
 
 
 
 
 
56
  const accountStatusOptions: { label: string; value: AccountStatus | "all" }[] = [
57
  { label: "全部状态", value: "all" },
58
  { label: "正常", value: "正常" },
 
84
  ] as const;
85
 
86
  function isUnlimitedImageQuotaAccount(account: Account) {
87
+ return account.type === "pro" || account.type === "prolite";
88
+ }
89
+
90
+ function imageQuotaUnknown(account: Account) {
91
+ return Boolean(account.image_quota_unknown);
92
  }
93
 
94
  function formatCompact(value: number) {
 
102
  if (isUnlimitedImageQuotaAccount(account)) {
103
  return "∞";
104
  }
105
+ if (imageQuotaUnknown(account)) {
106
  return "未知";
107
  }
108
  return String(Math.max(0, account.quota));
 
137
  if (availableAccounts.some(isUnlimitedImageQuotaAccount)) {
138
  return "∞";
139
  }
140
+ if (availableAccounts.some(imageQuotaUnknown)) {
141
  return "未知";
142
  }
143
  return formatCompact(availableAccounts.reduce((sum, account) => sum + Math.max(0, account.quota), 0));
 
160
  URL.revokeObjectURL(url);
161
  }
162
 
163
+ function displayAccountType(account: Account) {
164
+ return account.type || "Free";
 
 
 
 
 
 
 
 
 
 
165
  }
166
 
167
  function AccountsPageContent() {
 
169
  const [accounts, setAccounts] = useState<Account[]>([]);
170
  const [selectedIds, setSelectedIds] = useState<string[]>([]);
171
  const [query, setQuery] = useState("");
172
+ const [typeFilter, setTypeFilter] = useState("all");
173
  const [statusFilter, setStatusFilter] = useState<AccountStatus | "all">("all");
174
  const [page, setPage] = useState(1);
175
  const [pageSize, setPageSize] = useState("10");
176
  const [editingAccount, setEditingAccount] = useState<Account | null>(null);
 
177
  const [editStatus, setEditStatus] = useState<AccountStatus>("正常");
 
178
  const [isLoading, setIsLoading] = useState(true);
179
  const [isRefreshing, setIsRefreshing] = useState(false);
180
  const [isDeleting, setIsDeleting] = useState(false);
 
186
  }
187
  try {
188
  const data = await fetchAccounts();
189
+ setAccounts(data.items);
190
+ setSelectedIds((prev) => prev.filter((id) => data.items.some((item) => item.access_token === id)));
191
  } catch (error) {
192
  const message = error instanceof Error ? error.message : "加载账户失败";
193
  toast.error(message);
 
211
  return accounts.filter((account) => {
212
  const searchMatched =
213
  normalizedQuery.length === 0 || (account.email ?? "").toLowerCase().includes(normalizedQuery);
214
+ const typeMatched = typeFilter === "all" || displayAccountType(account) === typeFilter;
215
  const statusMatched = statusFilter === "all" || account.status === statusFilter;
216
  return searchMatched && typeMatched && statusMatched;
217
  });
 
222
  const startIndex = (safePage - 1) * Number(pageSize);
223
  const currentRows = filteredAccounts.slice(startIndex, startIndex + Number(pageSize));
224
  const allCurrentSelected =
225
+ currentRows.length > 0 && currentRows.every((row) => selectedIds.includes(row.access_token));
226
 
227
  const summary = useMemo(() => {
228
  const total = accounts.length;
 
235
  return { total, active, limited, abnormal, disabled, quota };
236
  }, [accounts]);
237
 
238
+ const accountTypeOptions = useMemo(
239
+ () => [
240
+ { label: "全部类型", value: "all" },
241
+ ...Array.from(new Set(accounts.map(displayAccountType))).map((type) => ({ label: type, value: type })),
242
+ ],
243
+ [accounts],
244
+ );
245
+
246
  const selectedTokens = useMemo(() => {
247
  const selectedSet = new Set(selectedIds);
248
+ return accounts.filter((item) => selectedSet.has(item.access_token)).map((item) => item.access_token);
249
  }, [accounts, selectedIds]);
250
 
251
  const abnormalTokens = useMemo(() => {
 
275
  setIsDeleting(true);
276
  try {
277
  const data = await deleteAccounts(tokens);
278
+ setAccounts(data.items);
279
+ setSelectedIds((prev) => prev.filter((id) => data.items.some((item) => item.access_token === id)));
280
  toast.success(`删除 ${data.removed ?? 0} 个账户`);
281
  } catch (error) {
282
  const message = error instanceof Error ? error.message : "删除账户失败";
 
295
  setIsRefreshing(true);
296
  try {
297
  const data = await refreshAccounts(accessTokens);
298
+ setAccounts(data.items);
299
+ setSelectedIds((prev) => prev.filter((id) => data.items.some((item) => item.access_token === id)));
300
  if (data.errors.length > 0) {
301
  const firstError = data.errors[0]?.error;
302
  toast.error(
 
315
 
316
  const openEditDialog = (account: Account) => {
317
  setEditingAccount(account);
 
318
  setEditStatus(account.status);
 
319
  };
320
 
321
  const handleUpdateAccount = async () => {
 
326
  setIsUpdating(true);
327
  try {
328
  const data = await updateAccount(editingAccount.access_token, {
 
329
  status: editStatus,
 
330
  });
331
+ setAccounts(data.items);
332
+ setSelectedIds((prev) => prev.filter((id) => data.items.some((item) => item.access_token === id)));
333
  setEditingAccount(null);
334
  toast.success("账号信息已更新");
335
  } catch (error) {
 
342
 
343
  const toggleSelectAll = (checked: boolean) => {
344
  if (checked) {
345
+ setSelectedIds((prev) => Array.from(new Set([...prev, ...currentRows.map((item) => item.access_token)])));
346
  return;
347
  }
348
+ setSelectedIds((prev) => prev.filter((id) => !currentRows.some((row) => row.access_token === id)));
349
  };
350
 
351
  return (
 
380
  <AccountImportDialog
381
  disabled={isLoading || isRefreshing || isDeleting}
382
  onImported={(items) => {
383
+ setAccounts(items);
384
  setSelectedIds([]);
385
  setPage(1);
386
  }}
 
402
  <DialogHeader className="gap-2">
403
  <DialogTitle>编辑账户</DialogTitle>
404
  <DialogDescription className="text-sm leading-6">
405
+ 手动修改账号状态。
406
  </DialogDescription>
407
  </DialogHeader>
408
  <div className="space-y-4">
 
423
  </SelectContent>
424
  </Select>
425
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  </div>
427
  <DialogFooter className="pt-2">
428
  <Button
 
494
  <Select
495
  value={typeFilter}
496
  onValueChange={(value) => {
497
+ setTypeFilter(value);
498
  setPage(1);
499
  }}
500
  >
 
616
 
617
  return (
618
  <tr
619
+ key={account.access_token}
620
  className="border-b border-stone-100/80 text-sm text-stone-600 transition-colors hover:bg-stone-50/70"
621
  >
622
  <td className="px-4 py-3">
623
  <Checkbox
624
+ checked={selectedIds.includes(account.access_token)}
625
  onCheckedChange={(checked) => {
626
  setSelectedIds((prev) =>
627
  checked
628
+ ? Array.from(new Set([...prev, account.access_token]))
629
+ : prev.filter((item) => item !== account.access_token),
630
  );
631
  }}
632
  />
 
650
  </td>
651
  <td className="px-4 py-3">
652
  <Badge variant="secondary" className="rounded-md bg-stone-100 text-stone-700">
653
+ {displayAccountType(account)}
654
  </Badge>
655
  </td>
656
  <td className="px-4 py-3">
 
672
  </td>
673
  <td className="px-4 py-3 text-xs leading-5 text-stone-500">
674
  {(() => {
675
+ const restore = formatRestoreAt(account.restore_at);
676
  return (
677
  <div className="space-y-0.5">
678
  {restore.relative ? <div className="font-medium text-stone-700">{restore.relative}</div> : null}
web/src/lib/api.ts CHANGED
@@ -1,17 +1,16 @@
1
  import { httpRequest } from "@/lib/request";
2
 
3
- export type AccountType = "Free" | "Plus" | "ProLite" | "Pro" | "Team";
4
  export type AccountStatus = "正常" | "限流" | "异常" | "禁用";
5
  export type ImageModel = "gpt-image-2" | "codex-gpt-image-2";
6
  export type AuthRole = "admin" | "user";
7
 
8
  export type Account = {
9
- id: string;
10
  access_token: string;
11
  type: AccountType;
12
  status: AccountStatus;
13
  quota: number;
14
- imageQuotaUnknown?: boolean;
15
  email?: string | null;
16
  user_id?: string | null;
17
  limits_progress?: Array<{
@@ -20,10 +19,10 @@ export type Account = {
20
  reset_after?: string;
21
  }>;
22
  default_model_slug?: string | null;
23
- restoreAt?: string | null;
24
  success: number;
25
  fail: number;
26
- lastUsedAt: string | null;
27
  };
28
 
29
  type AccountListResponse = {
 
1
  import { httpRequest } from "@/lib/request";
2
 
3
+ export type AccountType = string;
4
  export type AccountStatus = "正常" | "限流" | "异常" | "禁用";
5
  export type ImageModel = "gpt-image-2" | "codex-gpt-image-2";
6
  export type AuthRole = "admin" | "user";
7
 
8
  export type Account = {
 
9
  access_token: string;
10
  type: AccountType;
11
  status: AccountStatus;
12
  quota: number;
13
+ image_quota_unknown?: boolean;
14
  email?: string | null;
15
  user_id?: string | null;
16
  limits_progress?: Array<{
 
19
  reset_after?: string;
20
  }>;
21
  default_model_slug?: string | null;
22
+ restore_at?: string | null;
23
  success: number;
24
  fail: number;
25
+ last_used_at?: string | null;
26
  };
27
 
28
  type AccountListResponse = {