dsh-plugin-image-workbench 0.0.0-stage → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +256 -0
  3. package/README.md +256 -2
  4. package/client.js +241 -0
  5. package/cordis.patch.yml +14 -0
  6. package/host.js +641 -0
  7. package/install.mjs +387 -0
  8. package/package.json +57 -4
  9. package/scripts/vendor-snapshot.py +154 -0
  10. package/server/access.py +26 -0
  11. package/server/app.py +1329 -0
  12. package/server/catalog.json +1 -0
  13. package/server/catalog.py +147 -0
  14. package/server/composer.py +391 -0
  15. package/server/drafts.py +158 -0
  16. package/server/history.py +575 -0
  17. package/server/imaging.py +172 -0
  18. package/server/pool.py +1335 -0
  19. package/server/pool_admin.py +921 -0
  20. package/server/pool_service.py +1073 -0
  21. package/server/requirements.txt +6 -0
  22. package/server/smoke_check.py +402 -0
  23. package/server/static/access.html +202 -0
  24. package/server/static/index.html +4725 -0
  25. package/vendor/chatgpt2api/README.md +13 -0
  26. package/vendor/chatgpt2api/SOURCE.json +11 -0
  27. package/vendor/chatgpt2api/runtime/uv.exe +0 -0
  28. package/vendor/chatgpt2api/source/.env.example +82 -0
  29. package/vendor/chatgpt2api/source/.python-version +1 -0
  30. package/vendor/chatgpt2api/source/LICENSE +21 -0
  31. package/vendor/chatgpt2api/source/README.md +391 -0
  32. package/vendor/chatgpt2api/source/VERSION +1 -0
  33. package/vendor/chatgpt2api/source/api/__init__.py +2 -0
  34. package/vendor/chatgpt2api/source/api/accounts.py +537 -0
  35. package/vendor/chatgpt2api/source/api/ai.py +217 -0
  36. package/vendor/chatgpt2api/source/api/app.py +62 -0
  37. package/vendor/chatgpt2api/source/api/errors.py +46 -0
  38. package/vendor/chatgpt2api/source/api/image_inputs.py +309 -0
  39. package/vendor/chatgpt2api/source/api/image_tasks.py +120 -0
  40. package/vendor/chatgpt2api/source/api/support.py +134 -0
  41. package/vendor/chatgpt2api/source/api/system.py +365 -0
  42. package/vendor/chatgpt2api/source/assets/atlascloud.svg +1 -0
  43. package/vendor/chatgpt2api/source/assets/ipwo.png +0 -0
  44. package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -0
  45. package/vendor/chatgpt2api/source/config.default.json +108 -0
  46. package/vendor/chatgpt2api/source/main.py +9 -0
  47. package/vendor/chatgpt2api/source/pyproject.toml +27 -0
  48. package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -0
  49. package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -0
  50. package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -0
  51. package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -0
  52. package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -0
  53. package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -0
  54. package/vendor/chatgpt2api/source/services/__init__.py +0 -0
  55. package/vendor/chatgpt2api/source/services/account_service.py +1757 -0
  56. package/vendor/chatgpt2api/source/services/auth_service.py +253 -0
  57. package/vendor/chatgpt2api/source/services/backup_service.py +671 -0
  58. package/vendor/chatgpt2api/source/services/config.py +667 -0
  59. package/vendor/chatgpt2api/source/services/content_filter.py +242 -0
  60. package/vendor/chatgpt2api/source/services/cpa_service.py +315 -0
  61. package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -0
  62. package/vendor/chatgpt2api/source/services/image_service.py +376 -0
  63. package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -0
  64. package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -0
  65. package/vendor/chatgpt2api/source/services/image_task_service.py +550 -0
  66. package/vendor/chatgpt2api/source/services/log_service.py +337 -0
  67. package/vendor/chatgpt2api/source/services/model_service.py +182 -0
  68. package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -0
  69. package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -0
  70. package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -0
  71. package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -0
  72. package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -0
  73. package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -0
  74. package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -0
  75. package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -0
  76. package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -0
  77. package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -0
  78. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -0
  79. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -0
  80. package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -0
  81. package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -0
  82. package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -0
  83. package/vendor/chatgpt2api/source/services/proxy_service.py +648 -0
  84. package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -0
  85. package/vendor/chatgpt2api/source/services/storage/base.py +38 -0
  86. package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -0
  87. package/vendor/chatgpt2api/source/services/storage/factory.py +104 -0
  88. package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -0
  89. package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -0
  90. package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -0
  91. package/vendor/chatgpt2api/source/utils/__init__.py +0 -0
  92. package/vendor/chatgpt2api/source/utils/helper.py +469 -0
  93. package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -0
  94. package/vendor/chatgpt2api/source/utils/log.py +110 -0
  95. package/vendor/chatgpt2api/source/utils/pkce.py +19 -0
  96. package/vendor/chatgpt2api/source/utils/pow.py +207 -0
  97. package/vendor/chatgpt2api/source/utils/sentinel.py +159 -0
  98. package/vendor/chatgpt2api/source/utils/turnstile.py +184 -0
  99. package/vendor/chatgpt2api/source/uv.lock +904 -0
  100. package/vendor/chatgpt2api/source/web/bun.lock +1501 -0
  101. package/vendor/chatgpt2api/source/web/components.json +24 -0
  102. package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -0
  103. package/vendor/chatgpt2api/source/web/next.config.ts +40 -0
  104. package/vendor/chatgpt2api/source/web/package.json +58 -0
  105. package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -0
  106. package/vendor/chatgpt2api/source/web/public/github.svg +1 -0
  107. package/vendor/chatgpt2api/source/web/public/next.svg +1 -0
  108. package/vendor/chatgpt2api/source/web/public/openai.svg +1 -0
  109. package/vendor/chatgpt2api/source/web/public/vercel.svg +1 -0
  110. package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -0
  111. package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -0
  112. package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -0
  113. package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -0
  114. package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -0
  115. package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -0
  116. package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -0
  117. package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -0
  118. package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -0
  119. package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -0
  120. package/vendor/chatgpt2api/source/web/src/app/favicon.ico +0 -0
  121. package/vendor/chatgpt2api/source/web/src/app/globals.css +231 -0
  122. package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -0
  123. package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -0
  124. package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -0
  125. package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -0
  126. package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -0
  127. package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -0
  128. package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -0
  129. package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -0
  130. package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -0
  131. package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -0
  132. package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -0
  133. package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -0
  134. package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -0
  135. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -0
  136. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -0
  137. package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -0
  138. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -0
  139. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -0
  140. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -0
  141. package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -0
  142. package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -0
  143. package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -0
  144. package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -0
  145. package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -0
  146. package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -0
  147. package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -0
  148. package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -0
  149. package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -0
  150. package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -0
  151. package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -0
  152. package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -0
  153. package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -0
  154. package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -0
  155. package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -0
  156. package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -0
  157. package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -0
  158. package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -0
  159. package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -0
  160. package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -0
  161. package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -0
  162. package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -0
  163. package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -0
  164. package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -0
  165. package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -0
  166. package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -0
  167. package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -0
  168. package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -0
  169. package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -0
  170. package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -0
  171. package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -0
  172. package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -0
  173. package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -0
  174. package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -0
  175. package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -0
  176. package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -0
  177. package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -0
  178. package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -0
  179. package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -0
  180. package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -0
  181. package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -0
  182. package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -0
  183. package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -0
  184. package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -0
  185. package/vendor/chatgpt2api/source/web/tsconfig.json +42 -0
@@ -0,0 +1,1757 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import secrets
6
+ import time
7
+ import uuid
8
+ from concurrent.futures import ThreadPoolExecutor, as_completed
9
+ from datetime import datetime, timedelta, timezone
10
+ from pathlib import Path
11
+ from threading import Condition, Lock, Thread
12
+ from typing import Any
13
+ from urllib.parse import urlencode
14
+
15
+ from services.config import config
16
+ from services.log_service import (
17
+ LOG_TYPE_ACCOUNT,
18
+ log_service,
19
+ )
20
+ from services.storage.base import StorageBackend
21
+ from utils.helper import anonymize_token
22
+
23
+
24
+ class AccountService:
25
+ """账号池服务,使用 token -> account 的 dict 保存账号。"""
26
+
27
+ _NEW_ACCOUNT_INVALID_GRACE_SECONDS = 10 * 60
28
+ _INVALID_CONFIRM_SECONDS = 30
29
+ _ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 24 * 60 * 60
30
+ _REFRESH_TOKEN_KEEPALIVE_SECONDS = 3 * 24 * 60 * 60
31
+ _REFRESH_TOKEN_KEEPALIVE_ERROR_BACKOFF_SECONDS = 6 * 60 * 60
32
+ _REFRESH_TOKEN_KEEPALIVE_BATCH_SIZE = 3
33
+ _TOKEN_REFRESH_ERROR_BACKOFF_SECONDS = 5 * 60
34
+ _OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
35
+ _OAUTH_CLIENT_ID = "app_2SKx67EdpoN0G6j64rFvigXD"
36
+ _OAUTH_USER_AGENT = (
37
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
38
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
39
+ "Chrome/145.0.0.0 Safari/537.36"
40
+ )
41
+
42
+ # 刷新进度追踪
43
+ _refresh_progress: dict[str, dict] = {}
44
+ _refresh_progress_lock = Lock()
45
+ # 重新登录进度追踪
46
+ _relogin_progress: dict[str, dict] = {}
47
+ _relogin_progress_lock = Lock()
48
+
49
+ def __init__(self, storage_backend: StorageBackend):
50
+ self.storage = storage_backend
51
+ self._lock = Lock()
52
+ self._token_refresh_lock = Lock()
53
+ self._image_slot_condition = Condition(self._lock)
54
+ self._index = 0
55
+ # [dsh-patch:rr-persist] 图片/文本各一个游标,持久化到 data/.rr_index.json
56
+ _rr_state = self._load_rr_state()
57
+ self._image_index = int(_rr_state.get("image") or 0)
58
+ self._text_index = int(_rr_state.get("text") or 0)
59
+ self._accounts = self._load_accounts()
60
+ self._image_inflight: dict[str, int] = {}
61
+ self._token_aliases: dict[str, str] = {}
62
+ self._cumulative_total = self._load_cumulative_total()
63
+
64
+ # [dsh-patch:rr-persist] begin 轮转游标读写
65
+ def _get_rr_state_file(self) -> Path:
66
+ from services.config import DATA_DIR
67
+ return DATA_DIR / ".rr_index.json"
68
+
69
+ def _load_rr_state(self) -> dict:
70
+ try:
71
+ f = self._get_rr_state_file()
72
+ if f.exists():
73
+ data = json.loads(f.read_text(encoding="utf-8") or "{}")
74
+ if isinstance(data, dict):
75
+ return {
76
+ "image": int(data.get("image") or 0),
77
+ "text": int(data.get("text") or 0),
78
+ }
79
+ except Exception:
80
+ pass
81
+ return {"image": 0, "text": 0}
82
+
83
+ def _save_rr_state(self) -> None:
84
+ try:
85
+ f = self._get_rr_state_file()
86
+ f.parent.mkdir(parents=True, exist_ok=True)
87
+ tmp = f.with_suffix(".json.tmp")
88
+ tmp.write_text(json.dumps({
89
+ "image": int(getattr(self, "_image_index", 0)),
90
+ "text": int(getattr(self, "_text_index", 0)),
91
+ }), encoding="utf-8")
92
+ tmp.replace(f)
93
+ except Exception:
94
+ pass
95
+ # [dsh-patch:rr-persist] end
96
+
97
+ def _get_cumulative_file(self) -> Path:
98
+ from services.config import DATA_DIR
99
+ return DATA_DIR / ".cumulative_total"
100
+
101
+ def _load_cumulative_total(self) -> int:
102
+ try:
103
+ f = self._get_cumulative_file()
104
+ if f.exists():
105
+ return int(f.read_text().strip())
106
+ except Exception:
107
+ pass
108
+ return len(self._accounts)
109
+
110
+ def _save_cumulative_total(self) -> None:
111
+ try:
112
+ self._get_cumulative_file().write_text(str(self._cumulative_total))
113
+ except Exception:
114
+ pass
115
+
116
+ @staticmethod
117
+ def _now() -> str:
118
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
119
+
120
+ @staticmethod
121
+ def _decode_jwt_payload(token: str) -> dict:
122
+ try:
123
+ payload = str(token or "").split(".")[1]
124
+ payload += "=" * ((4 - len(payload) % 4) % 4)
125
+ import base64
126
+ import json
127
+ data = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
128
+ return data if isinstance(data, dict) else {}
129
+ except Exception:
130
+ return {}
131
+
132
+ @staticmethod
133
+ def _parse_time(value: object) -> datetime | None:
134
+ raw = str(value or "").strip()
135
+ if not raw:
136
+ return None
137
+ try:
138
+ parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
139
+ except Exception:
140
+ try:
141
+ parsed = datetime.strptime(raw, "%Y-%m-%d %H:%M:%S")
142
+ except Exception:
143
+ return None
144
+ if parsed.tzinfo is None:
145
+ parsed = parsed.replace(tzinfo=timezone.utc)
146
+ return parsed.astimezone(timezone.utc)
147
+
148
+ @staticmethod
149
+ def _timestamp_to_iso(value: object) -> str:
150
+ try:
151
+ ts = int(value)
152
+ except (TypeError, ValueError):
153
+ return ""
154
+ tz = timezone(timedelta(hours=8))
155
+ return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone(tz).isoformat()
156
+
157
+ def _load_accounts(self) -> dict[str, dict]:
158
+ accounts = self.storage.load_accounts()
159
+ return {
160
+ normalized["access_token"]: normalized
161
+ for item in accounts
162
+ if (normalized := self._normalize_account(item)) is not None
163
+ }
164
+
165
+ def _save_accounts(self) -> None:
166
+ self.storage.save_accounts(list(self._accounts.values()))
167
+
168
+ @staticmethod
169
+ def _is_image_account_available(account: dict) -> bool:
170
+ if not isinstance(account, dict):
171
+ return False
172
+ if account.get("status") in {"禁用", "限流", "异常"}:
173
+ return False
174
+ return int(account.get("quota") or 0) > 0
175
+
176
+ @classmethod
177
+ def _account_matches_plan_type(cls, account: dict, plan_type: str | None = None) -> bool:
178
+ if not plan_type:
179
+ return True
180
+ normalized_plan = cls._normalize_account_type(plan_type)
181
+ normalized_account = cls._normalize_account_type(account.get("type"))
182
+ if not normalized_plan or not normalized_account:
183
+ return False
184
+ return normalized_plan.lower() == normalized_account.lower()
185
+
186
+ @classmethod
187
+ def _account_matches_source_type(cls, account: dict, source_type: str | None = None) -> bool:
188
+ if not source_type:
189
+ return True
190
+ return cls._normalize_source_type(account.get("source_type")) == cls._normalize_source_type(source_type)
191
+
192
+ @classmethod
193
+ def _account_matches_any_plan_type(cls, account: dict, plan_types: set[str] | tuple[str, ...] | None = None) -> bool:
194
+ if not plan_types:
195
+ return True
196
+ normalized_account = cls._normalize_account_type(account.get("type"))
197
+ normalized_plans = {
198
+ normalized
199
+ for plan_type in plan_types
200
+ if (normalized := cls._normalize_account_type(plan_type))
201
+ }
202
+ return bool(normalized_account and normalized_account in normalized_plans)
203
+
204
+ @staticmethod
205
+ def _normalize_source_type(value: object) -> str:
206
+ return str(value or "web").strip().lower() or "web"
207
+
208
+ @staticmethod
209
+ def _normalize_account_type(value: object) -> str | None:
210
+ raw = str(value or "").strip()
211
+ if not raw:
212
+ return None
213
+ key = raw.lower().replace("-", "_").replace(" ", "_")
214
+ compact = key.replace("_", "")
215
+ aliases = {
216
+ "free": "free",
217
+ "plus": "Plus",
218
+ "pro": "Pro",
219
+ "prolite": "ProLite",
220
+ "team": "Team",
221
+ "business": "Team",
222
+ "enterprise": "Enterprise",
223
+ }
224
+ return aliases.get(compact) or aliases.get(key) or raw
225
+
226
+ def _search_account_type(self, payload: object) -> str | None:
227
+ if isinstance(payload, dict):
228
+ for key in ("plan_type", "account_plan", "account_type", "subscription_type", "type"):
229
+ plan = self._normalize_account_type(payload.get(key))
230
+ if plan:
231
+ return plan
232
+ for value in payload.values():
233
+ plan = self._search_account_type(value)
234
+ if plan:
235
+ return plan
236
+ elif isinstance(payload, list):
237
+ for value in payload:
238
+ plan = self._search_account_type(value)
239
+ if plan:
240
+ return plan
241
+ return None
242
+
243
+ def _normalize_account(self, item: dict) -> dict | None:
244
+ if not isinstance(item, dict):
245
+ return None
246
+ access_token = item.get("access_token") or item.get("accessToken") or ""
247
+ if not access_token:
248
+ return None
249
+ normalized = dict(item)
250
+ normalized.pop("accessToken", None)
251
+ normalized["access_token"] = access_token
252
+ if str(normalized.get("type") or "").strip().lower() == "codex":
253
+ normalized["export_type"] = "codex"
254
+ normalized.pop("type", None)
255
+ normalized["type"] = normalized.get("type") or "free"
256
+ normalized["status"] = normalized.get("status") or "正常"
257
+ normalized["quota"] = max(0, int(normalized.get("quota") if normalized.get("quota") is not None else 0))
258
+ normalized["email"] = normalized.get("email") or None
259
+ normalized["user_id"] = normalized.get("user_id") or None
260
+ normalized["proxy"] = str(normalized.get("proxy") or "").strip()
261
+ source_type = normalized.get("source_type")
262
+ if not source_type and str(normalized.get("export_type") or "").strip().lower() == "codex":
263
+ source_type = "codex"
264
+ normalized["source_type"] = self._normalize_source_type(source_type)
265
+ limits_progress = normalized.get("limits_progress")
266
+ normalized["limits_progress"] = limits_progress if isinstance(limits_progress, list) else []
267
+ normalized["default_model_slug"] = normalized.get("default_model_slug") or None
268
+ normalized["restore_at"] = normalized.get("restore_at") or None
269
+ normalized["success"] = int(normalized.get("success") or 0)
270
+ normalized["fail"] = int(normalized.get("fail") or 0)
271
+ normalized["invalid_count"] = int(normalized.get("invalid_count") or 0)
272
+ normalized["last_used_at"] = normalized.get("last_used_at")
273
+ normalized["last_invalid_at"] = normalized.get("last_invalid_at") or None
274
+ normalized["last_refresh_error"] = normalized.get("last_refresh_error") or None
275
+ normalized["last_refresh_error_at"] = normalized.get("last_refresh_error_at") or None
276
+ normalized["last_token_refresh_at"] = normalized.get("last_token_refresh_at") or None
277
+ normalized["last_token_refresh_error"] = normalized.get("last_token_refresh_error") or None
278
+ normalized["last_token_refresh_error_at"] = normalized.get("last_token_refresh_error_at") or None
279
+ normalized["created_at"] = normalized.get("created_at") or AccountService._now()
280
+ return normalized
281
+
282
+ @staticmethod
283
+ def _jwt_exp(access_token: str) -> int:
284
+ try:
285
+ return int(AccountService._decode_jwt_payload(access_token).get("exp") or 0)
286
+ except (TypeError, ValueError):
287
+ return 0
288
+
289
+ @classmethod
290
+ def _token_expires_in(cls, access_token: str) -> int | None:
291
+ exp = cls._jwt_exp(access_token)
292
+ if exp <= 0:
293
+ return None
294
+ return exp - int(time.time())
295
+
296
+ @classmethod
297
+ def _token_needs_refresh(cls, access_token: str, *, force: bool = False) -> bool:
298
+ if force:
299
+ return True
300
+ remaining = cls._token_expires_in(access_token)
301
+ return remaining is not None and remaining <= cls._ACCESS_TOKEN_REFRESH_SKEW_SECONDS
302
+
303
+ @classmethod
304
+ def _token_issued_at(cls, access_token: str) -> datetime | None:
305
+ try:
306
+ iat = int(cls._decode_jwt_payload(access_token).get("iat") or 0)
307
+ except (TypeError, ValueError):
308
+ return None
309
+ if iat <= 0:
310
+ return None
311
+ return datetime.fromtimestamp(iat, tz=timezone.utc)
312
+
313
+ @staticmethod
314
+ def _safe_response_text(response: object, limit: int = 300) -> str:
315
+ try:
316
+ return str(getattr(response, "text", "") or "")[:limit]
317
+ except Exception:
318
+ return ""
319
+
320
+ def _resolve_access_token_locked(self, access_token: str) -> str:
321
+ token = str(access_token or "").strip()
322
+ seen: set[str] = set()
323
+ while token and token not in self._accounts and token in self._token_aliases and token not in seen:
324
+ seen.add(token)
325
+ token = self._token_aliases.get(token, token)
326
+ return token
327
+
328
+ def resolve_access_token(self, access_token: str) -> str:
329
+ if not access_token:
330
+ return ""
331
+ with self._lock:
332
+ return self._resolve_access_token_locked(access_token)
333
+
334
+ def _get_account_for_token(self, access_token: str) -> tuple[str, dict | None]:
335
+ with self._lock:
336
+ resolved = self._resolve_access_token_locked(access_token)
337
+ account = self._accounts.get(resolved)
338
+ return resolved, dict(account) if account else None
339
+
340
+ def _record_token_refresh_error(self, access_token: str, event: str, error: str) -> None:
341
+ now = datetime.now(timezone.utc).isoformat()
342
+ with self._lock:
343
+ resolved = self._resolve_access_token_locked(access_token)
344
+ current = self._accounts.get(resolved)
345
+ if current is None:
346
+ return
347
+ next_item = dict(current)
348
+ next_item["last_token_refresh_error"] = str(error or "refresh token failed")
349
+ next_item["last_token_refresh_error_at"] = now
350
+ account = self._normalize_account(next_item)
351
+ if account is not None:
352
+ self._accounts[resolved] = account
353
+ self._save_accounts()
354
+ log_service.add(
355
+ LOG_TYPE_ACCOUNT,
356
+ "refresh_token 刷新 access_token 失败",
357
+ {"source": event, "token": anonymize_token(access_token), "error": str(error or "")},
358
+ )
359
+
360
+ def _recent_token_refresh_error(self, account: dict) -> bool:
361
+ last_error_at = self._parse_time(account.get("last_token_refresh_error_at"))
362
+ if last_error_at is None:
363
+ return False
364
+ return (datetime.now(timezone.utc) - last_error_at).total_seconds() < self._TOKEN_REFRESH_ERROR_BACKOFF_SECONDS
365
+
366
+ def _recent_refresh_token_keepalive_error(self, account: dict, now: datetime) -> bool:
367
+ last_error_at = self._parse_time(account.get("last_token_refresh_error_at"))
368
+ if last_error_at is None:
369
+ return False
370
+ return (now - last_error_at).total_seconds() < self._REFRESH_TOKEN_KEEPALIVE_ERROR_BACKOFF_SECONDS
371
+
372
+ def _refresh_token_keepalive_anchor(self, account: dict) -> datetime | None:
373
+ return (
374
+ self._parse_time(account.get("last_token_refresh_at"))
375
+ or self._token_issued_at(str(account.get("access_token") or ""))
376
+ or self._parse_time(account.get("created_at"))
377
+ )
378
+
379
+ def _refresh_token_keepalive_due_at(self, account: dict, now: datetime) -> datetime | None:
380
+ if not str(account.get("refresh_token") or "").strip():
381
+ return None
382
+ if account.get("status") == "禁用":
383
+ return None
384
+ if self._recent_refresh_token_keepalive_error(account, now):
385
+ return None
386
+ anchor = self._refresh_token_keepalive_anchor(account)
387
+ if anchor is None:
388
+ return now
389
+ due_at = anchor + timedelta(seconds=self._REFRESH_TOKEN_KEEPALIVE_SECONDS)
390
+ return due_at if due_at <= now else None
391
+
392
+ def _request_access_token_refresh(self, refresh_token: str, account: dict | None = None) -> dict[str, str]:
393
+ from curl_cffi import requests
394
+ from services.proxy_service import proxy_settings
395
+
396
+ session = requests.Session(**proxy_settings.build_session_kwargs(account=account, impersonate="chrome110", verify=True))
397
+ try:
398
+ response = session.post(
399
+ self._OAUTH_TOKEN_URL,
400
+ headers={
401
+ "Accept": "application/json",
402
+ "Content-Type": "application/x-www-form-urlencoded",
403
+ "User-Agent": self._OAUTH_USER_AGENT,
404
+ },
405
+ data={
406
+ "grant_type": "refresh_token",
407
+ "refresh_token": refresh_token,
408
+ "client_id": self._OAUTH_CLIENT_ID,
409
+ },
410
+ timeout=60,
411
+ )
412
+ data = response.json() if response.text else {}
413
+ if response.status_code != 200 or not isinstance(data, dict) or not data.get("access_token"):
414
+ detail = ""
415
+ if isinstance(data, dict):
416
+ detail = str(data.get("error_description") or data.get("error") or data.get("message") or "")
417
+ detail = detail or self._safe_response_text(response)
418
+ raise RuntimeError(f"oauth_refresh_http_{response.status_code}{': ' + detail if detail else ''}")
419
+ return {
420
+ "access_token": str(data.get("access_token") or "").strip(),
421
+ "refresh_token": str(data.get("refresh_token") or refresh_token).strip(),
422
+ "id_token": str(data.get("id_token") or "").strip(),
423
+ }
424
+ finally:
425
+ session.close()
426
+
427
+ def _apply_refreshed_tokens(self, old_access_token: str, token_data: dict, event: str) -> str:
428
+ now = datetime.now(timezone.utc).isoformat()
429
+ with self._image_slot_condition:
430
+ old_token = self._resolve_access_token_locked(old_access_token)
431
+ current = self._accounts.get(old_token)
432
+ if current is None:
433
+ return old_token
434
+ new_token = str(token_data.get("access_token") or old_token).strip()
435
+ if not new_token:
436
+ return old_token
437
+
438
+ next_item = dict(current)
439
+ next_item["access_token"] = new_token
440
+ if token_data.get("refresh_token"):
441
+ next_item["refresh_token"] = str(token_data.get("refresh_token") or "").strip()
442
+ if token_data.get("id_token"):
443
+ next_item["id_token"] = str(token_data.get("id_token") or "").strip()
444
+ next_item["last_token_refresh_at"] = now
445
+ next_item["last_token_refresh_error"] = None
446
+ next_item["last_token_refresh_error_at"] = None
447
+ next_item["invalid_count"] = 0
448
+ next_item["last_invalid_at"] = None
449
+ next_item["last_refresh_error"] = None
450
+ next_item["last_refresh_error_at"] = None
451
+
452
+ account = self._normalize_account(next_item)
453
+ if account is None:
454
+ return old_token
455
+
456
+ rotated = new_token != old_token
457
+ if rotated:
458
+ self._accounts.pop(old_token, None)
459
+ self._token_aliases[old_token] = new_token
460
+ old_inflight = int(self._image_inflight.pop(old_token, 0))
461
+ if old_inflight:
462
+ self._image_inflight[new_token] = int(self._image_inflight.get(new_token, 0)) + old_inflight
463
+ self._accounts[new_token] = account
464
+ self._save_accounts()
465
+ self._image_slot_condition.notify_all()
466
+
467
+ log_service.add(
468
+ LOG_TYPE_ACCOUNT,
469
+ "refresh_token 已刷新 access_token",
470
+ {"source": event, "token": anonymize_token(new_token), "rotated": rotated},
471
+ )
472
+ return new_token
473
+
474
+ def refresh_access_token(self, access_token: str, *, force: bool = False, event: str = "refresh_access_token") -> str:
475
+ if not access_token:
476
+ return ""
477
+ with self._token_refresh_lock:
478
+ resolved_token, account = self._get_account_for_token(access_token)
479
+ if not account:
480
+ return access_token
481
+ active_token = str(account.get("access_token") or resolved_token or access_token)
482
+ if not self._token_needs_refresh(active_token, force=force):
483
+ return active_token
484
+ refresh_token = str(account.get("refresh_token") or "").strip()
485
+ if not refresh_token:
486
+ return active_token
487
+ if not force and self._recent_token_refresh_error(account):
488
+ return active_token
489
+ try:
490
+ token_data = self._request_access_token_refresh(refresh_token, account)
491
+ except Exception as exc:
492
+ error_str = str(exc or "")
493
+ self._record_token_refresh_error(active_token, event, error_str)
494
+ # 如果是 app_session_terminated 错误,尝试密码重新登录
495
+ if "app_session_terminated" in error_str.lower():
496
+ # 获取账号信息(email, password)
497
+ email = str(account.get("email") or "").strip()
498
+ password = str(account.get("password") or "").strip()
499
+ if email and password:
500
+ # 创建新线程执行密码重新登录
501
+ t = Thread(
502
+ target=self._password_re_login_thread,
503
+ args=(active_token, email, password, event),
504
+ daemon=True,
505
+ )
506
+ t.start()
507
+ return active_token
508
+ return self._apply_refreshed_tokens(active_token, token_data, event)
509
+
510
+ def _password_re_login_thread(self, access_token: str, email: str, password: str, event: str, progress_id: str | None = None) -> None:
511
+ """密码重新登录线程入口"""
512
+ try:
513
+ result = self._login_with_password(email, password)
514
+ if result.get("ok"):
515
+ # 登录成功,更新账号
516
+ new_access_token = result.get("access_token", "")
517
+ new_refresh_token = result.get("refresh_token", "")
518
+ new_id_token = result.get("id_token", "")
519
+ new_expires_at = result.get("expires_at")
520
+
521
+ # 构建 token_data 供 _apply_refreshed_tokens 使用
522
+ token_data = {
523
+ "access_token": new_access_token,
524
+ "refresh_token": new_refresh_token,
525
+ "id_token": new_id_token,
526
+ }
527
+
528
+ # 使用 _apply_refreshed_tokens 更新账号(处理 token 别名)
529
+ new_token = self._apply_refreshed_tokens(access_token, token_data, f"{event}:password_relogin")
530
+
531
+ # 额外更新 source_type 和 status(静默,避免重复日志)
532
+ self.update_account(new_token, {
533
+ "source_type": result.get("source_type", "password"),
534
+ "status": "正常",
535
+ }, quiet=True)
536
+
537
+ log_service.add(
538
+ LOG_TYPE_ACCOUNT,
539
+ "更新账号",
540
+ {
541
+ "source": event,
542
+ "old_token": anonymize_token(access_token),
543
+ "new_token": anonymize_token(new_access_token),
544
+ "email": email,
545
+ "status": "成功",
546
+ },
547
+ )
548
+ if progress_id:
549
+ self.update_relogin_progress(progress_id, access_token, "成功")
550
+ else:
551
+ # 登录失败
552
+ error_type = result.get("error", "")
553
+ if error_type == "password_verify_failed_403" and isinstance(result.get("detail"), dict):
554
+ log_service.add(
555
+ LOG_TYPE_ACCOUNT,
556
+ "更新账号",
557
+ {
558
+ "source": event,
559
+ "token": anonymize_token(access_token),
560
+ "email": email,
561
+ "status": "失败",
562
+ "error": error_type,
563
+ "detail": result.get("detail", {}),
564
+ },
565
+ )
566
+ detail_error = result["detail"].get("error", {})
567
+ if isinstance(detail_error, dict) and detail_error.get("code") == "account_deactivated":
568
+ # 账号已删除/停用 → 标记为禁用
569
+ self.update_account(access_token, {"status": "禁用", "quota": 0}, quiet=True)
570
+ account = self.get_account(access_token) or {}
571
+ log_service.add(
572
+ LOG_TYPE_ACCOUNT,
573
+ "账号已停用-标记禁用",
574
+ {
575
+ "source": event,
576
+ "token": anonymize_token(access_token),
577
+ "email": email,
578
+ "detail": result.get("detail", {}),
579
+ },
580
+ )
581
+ if progress_id:
582
+ self.update_relogin_progress(progress_id, access_token, "禁用")
583
+ else:
584
+ # 永久故障:将账号标记为异常(或自动移除)
585
+ self.remove_invalid_token(access_token, f"{event}:password_relogin_failed", quiet=True)
586
+ if progress_id:
587
+ self.update_relogin_progress(progress_id, access_token, "异常", error_type)
588
+ else:
589
+ log_service.add(
590
+ LOG_TYPE_ACCOUNT,
591
+ "更新账号",
592
+ {
593
+ "source": event,
594
+ "token": anonymize_token(access_token),
595
+ "email": email,
596
+ "status": "失败",
597
+ "error": error_type,
598
+ "detail": result.get("detail", {}),
599
+ },
600
+ )
601
+ # 永久故障:将账号标记为异常(或自动移除)
602
+ self.remove_invalid_token(access_token, f"{event}:password_relogin_failed", quiet=True)
603
+ if progress_id:
604
+ self.update_relogin_progress(progress_id, access_token, "异常", error_type)
605
+ except Exception as exc:
606
+ log_service.add(
607
+ LOG_TYPE_ACCOUNT,
608
+ "更新账号",
609
+ {
610
+ "source": event,
611
+ "token": anonymize_token(access_token),
612
+ "email": email,
613
+ "status": "异常",
614
+ "error": str(exc),
615
+ },
616
+ )
617
+ # 将账号标记为异常(或自动移除)
618
+ self.remove_invalid_token(access_token, f"{event}:password_relogin_exception", quiet=True)
619
+ if progress_id:
620
+ self.update_relogin_progress(progress_id, access_token, "异常", str(exc))
621
+
622
+ def _login_with_password(self, email: str, password: str) -> dict:
623
+ """通过邮箱+密码登录,返回 {access_token, refresh_token, id_token, ...}"""
624
+ from curl_cffi import requests
625
+
626
+ # 常量
627
+ auth_base = "https://auth.openai.com"
628
+ platform_oauth_audience = "https://api.openai.com/v1"
629
+ platform_auth0_client = "eyJuYW1lIjoiYXV0aDAtc3BhLWpzIiwidmVyc2lvbiI6IjEuMjEuMCJ9"
630
+ platform_oauth_client_id = self._OAUTH_CLIENT_ID
631
+ platform_oauth_redirect_uri = "https://platform.openai.com/auth/callback"
632
+ user_agent = self._OAUTH_USER_AGENT
633
+
634
+ # 创建 session
635
+ session_kwargs = {"impersonate": "chrome110", "verify": False}
636
+ proxy = config.get_proxy_settings()
637
+ if proxy:
638
+ session_kwargs["proxy"] = proxy
639
+ session = requests.Session(**session_kwargs)
640
+
641
+ try:
642
+ device_id = str(uuid.uuid4())
643
+
644
+ # ─── 方式2: OAuth authorize 流程 ──────────────────────────
645
+ # 使用 Platform Client + PKCE
646
+
647
+ from utils.pkce import generate_pkce
648
+ code_verifier, code_challenge = generate_pkce()
649
+
650
+ # ② 发起 OAuth authorize 请求 (使用 Platform Client + PKCE)
651
+ session.cookies.set("oai-did", device_id, domain=".auth.openai.com")
652
+ session.cookies.set("oai-did", device_id, domain="auth.openai.com")
653
+ params = {
654
+ "issuer": auth_base,
655
+ "client_id": platform_oauth_client_id,
656
+ "audience": platform_oauth_audience,
657
+ "redirect_uri": platform_oauth_redirect_uri,
658
+ "device_id": device_id,
659
+ "screen_hint": "login_or_signup",
660
+ "max_age": "0",
661
+ "login_hint": email,
662
+ "scope": "openid profile email offline_access",
663
+ "response_type": "code",
664
+ "response_mode": "query",
665
+ "state": secrets.token_urlsafe(32),
666
+ "nonce": secrets.token_urlsafe(32),
667
+ "code_challenge": code_challenge,
668
+ "code_challenge_method": "S256",
669
+ "auth0Client": platform_auth0_client,
670
+ }
671
+ authorize_url = f"{auth_base}/api/accounts/authorize?{urlencode(params)}"
672
+ resp = session.get(
673
+ authorize_url,
674
+ headers={
675
+ "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
676
+ "accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
677
+ "user-agent": user_agent,
678
+ "sec-ch-ua": '"Chromium";v="145", "Google Chrome";v="145", "Not/A)Brand";v="99"',
679
+ "sec-ch-ua-mobile": "?0",
680
+ "sec-ch-ua-platform": '"Windows"',
681
+ "sec-fetch-dest": "document",
682
+ "sec-fetch-mode": "navigate",
683
+ "sec-fetch-site": "cross-site",
684
+ "sec-fetch-user": "?1",
685
+ "upgrade-insecure-requests": "1",
686
+ "referer": "https://platform.openai.com/",
687
+ },
688
+ allow_redirects=True,
689
+ timeout=30,
690
+ )
691
+
692
+ if resp.status_code not in (200, 302):
693
+ return {"ok": False, "error": f"authorize_failed_{resp.status_code}", "detail": {"url": resp.url, "text": resp.text[:500]}}
694
+
695
+ # 检测最终 URL 是否指向错误页面
696
+ final_url = str(resp.url)
697
+ if "/error" in final_url and "payload=" in final_url:
698
+ from urllib.parse import parse_qs, urlparse
699
+ try:
700
+ parsed_query = parse_qs(urlparse(final_url).query)
701
+ error_payload_b64 = parsed_query.get("payload", [""])[0]
702
+ error_payload_b64 += "=" * ((4 - len(error_payload_b64) % 4) % 4)
703
+ error_payload = json.loads(base64.b64decode(error_payload_b64))
704
+ error_code = error_payload.get("errorCode", "")
705
+ if error_code == "rate_limit_exceeded":
706
+ return {"ok": False, "error": "rate_limit_exceeded", "detail": error_payload}
707
+ else:
708
+ return {"ok": False, "error": f"authorize_error_{error_code}", "detail": error_payload}
709
+ except Exception as e:
710
+ return {"ok": False, "error": "authorize_redirect_error", "detail": {"url": final_url, "parse_error": str(e)}}
711
+
712
+ # ③ 提交密码验证
713
+ login_headers = {
714
+ "accept": "application/json",
715
+ "accept-language": "zh-CN,zh;q=0.9",
716
+ "content-type": "application/json",
717
+ "origin": auth_base,
718
+ "priority": "u=1, i",
719
+ "user-agent": user_agent,
720
+ "sec-ch-ua": '"Chromium";v="145", "Google Chrome";v="145", "Not/A)Brand";v="99"',
721
+ "sec-ch-ua-mobile": "?0",
722
+ "sec-ch-ua-platform": '"Windows"',
723
+ "sec-fetch-dest": "empty",
724
+ "sec-fetch-mode": "cors",
725
+ "sec-fetch-site": "same-origin",
726
+ "referer": f"{auth_base}/email-verification",
727
+ "oai-device-id": device_id,
728
+ }
729
+
730
+ # 添加 sentinel token
731
+ try:
732
+ from utils.sentinel import build_sentinel_token
733
+ sentinel_val, oai_sc_val = build_sentinel_token(session, device_id, "password_verify")
734
+ login_headers["openai-sentinel-token"] = sentinel_val
735
+ if oai_sc_val:
736
+ session.cookies.set("oai-sc", oai_sc_val, domain=".openai.com")
737
+ except Exception:
738
+ pass
739
+
740
+ login_resp = session.post(
741
+ f"{auth_base}/api/accounts/password/verify",
742
+ headers=login_headers,
743
+ json={"password": password},
744
+ timeout=30,
745
+ )
746
+
747
+ login_data = {}
748
+ try:
749
+ login_data = login_resp.json() if login_resp.text else {}
750
+ except Exception:
751
+ pass
752
+
753
+ if login_resp.status_code != 200:
754
+ error_code = login_data.get("error", {}).get("code", "")
755
+ error_msg = login_data.get("error", {}).get("message", "")
756
+ if error_code == "unsupported_country_region_territory":
757
+ return {"ok": False, "error": "unsupported_country_region_territory", "detail": login_data}
758
+ elif error_code == "invalid_state":
759
+ return {"ok": False, "error": "invalid_state", "detail": login_data}
760
+ elif "Invalid credentials" in error_msg or "wrong password" in error_msg.lower():
761
+ return {"ok": False, "error": "invalid_password", "detail": login_data}
762
+ return {"ok": False, "error": f"password_verify_failed_{login_resp.status_code}", "detail": login_data}
763
+
764
+ # 获取 authorization code
765
+ continue_url = str(login_data.get("continue_url") or "").strip()
766
+ auth_code = ""
767
+ if continue_url:
768
+ from urllib.parse import parse_qs, urlparse
769
+ parsed_params = parse_qs(urlparse(continue_url).query)
770
+ auth_code = str((parsed_params.get("code") or [""])[0]).strip()
771
+
772
+ # ─── 处理邮箱 OTP 验证 ──────────────────────────
773
+ if not auth_code:
774
+ page_type = ""
775
+ page_info = login_data.get("page")
776
+ if isinstance(page_info, dict):
777
+ page_type = str(page_info.get("type") or "")
778
+
779
+ if page_type == "email_otp_verification":
780
+ # 需要验证码才能登录,直接标记为账号异常
781
+ return {"ok": False, "error": "need_verification_code", "detail": login_data}
782
+ else:
783
+ return {"ok": False, "error": "no_auth_code", "detail": login_data}
784
+
785
+ # ④ 用 code 换 token (使用 Platform Client + code_verifier)
786
+ platform_base = "https://platform.openai.com"
787
+ token_resp = session.post(
788
+ f"{auth_base}/api/accounts/oauth/token",
789
+ headers={
790
+ "accept": "*/*",
791
+ "accept-language": "zh-CN,zh;q=0.9",
792
+ "auth0-client": platform_auth0_client,
793
+ "cache-control": "no-cache",
794
+ "content-type": "application/json",
795
+ "origin": platform_base,
796
+ "pragma": "no-cache",
797
+ "priority": "u=1, i",
798
+ "referer": f"{platform_base}/",
799
+ "sec-ch-ua": '"Chromium";v="145", "Google Chrome";v="145", "Not/A)Brand";v="99"',
800
+ "sec-ch-ua-mobile": "?0",
801
+ "sec-ch-ua-platform": '"Windows"',
802
+ "sec-fetch-dest": "empty",
803
+ "sec-fetch-mode": "cors",
804
+ "sec-fetch-site": "same-site",
805
+ "user-agent": user_agent,
806
+ },
807
+ json={
808
+ "client_id": platform_oauth_client_id,
809
+ "code_verifier": code_verifier,
810
+ "grant_type": "authorization_code",
811
+ "code": auth_code,
812
+ "redirect_uri": platform_oauth_redirect_uri,
813
+ },
814
+ verify=False,
815
+ timeout=60,
816
+ )
817
+
818
+ token_data = {}
819
+ try:
820
+ token_data = token_resp.json() if token_resp.text else {}
821
+ except Exception:
822
+ pass
823
+
824
+ if token_resp.status_code != 200 or not token_data.get("access_token"):
825
+ return {"ok": False, "error": "token_exchange_failed", "detail": token_data}
826
+
827
+ access_token = str(token_data.get("access_token") or "").strip()
828
+ refresh_token = str(token_data.get("refresh_token") or "").strip()
829
+ id_token = str(token_data.get("id_token") or "").strip()
830
+
831
+ # ⑤ 用 access_token 获取用户信息
832
+ user_info = {}
833
+ try:
834
+ me_resp = session.get(
835
+ "https://chatgpt.com/backend-api/me",
836
+ headers={
837
+ "accept": "application/json",
838
+ "authorization": f"Bearer {access_token}",
839
+ "user-agent": user_agent,
840
+ },
841
+ timeout=30,
842
+ )
843
+ if me_resp.status_code == 200:
844
+ user_info = me_resp.json() if me_resp.text else {}
845
+ except Exception:
846
+ pass
847
+
848
+ # 解析 JWT payload
849
+ jwt_payload = self._decode_jwt_payload(access_token)
850
+
851
+ email_from_jwt = str(jwt_payload.get("https://api.openai.com/profile", {}).get("email") or "").strip()
852
+ account_id_from_jwt = str(
853
+ jwt_payload.get("https://api.openai.com/auth", {}).get("chatgpt_account_id") or ""
854
+ ).strip()
855
+
856
+ account_info = user_info.get("account") if isinstance(user_info.get("account"), dict) else {}
857
+ result = {
858
+ "ok": True,
859
+ "email": email_from_jwt or email,
860
+ "account_id": account_id_from_jwt or account_info.get("account_id", ""),
861
+ "access_token": access_token,
862
+ "refresh_token": refresh_token,
863
+ "id_token": id_token,
864
+ "expires_at": jwt_payload.get("exp"),
865
+ "source_type": "password",
866
+ }
867
+
868
+ return result
869
+
870
+ finally:
871
+ session.close()
872
+
873
+ def list_expiring_access_tokens(self) -> list[str]:
874
+ with self._lock:
875
+ return [
876
+ token
877
+ for account in self._accounts.values()
878
+ if str(account.get("refresh_token") or "").strip()
879
+ and (token := str(account.get("access_token") or "").strip())
880
+ and self._token_needs_refresh(token)
881
+ ]
882
+
883
+ def list_refresh_token_keepalive_tokens(self) -> list[str]:
884
+ now = datetime.now(timezone.utc)
885
+ due_items: list[tuple[datetime, str]] = []
886
+ with self._lock:
887
+ for account in self._accounts.values():
888
+ due_at = self._refresh_token_keepalive_due_at(account, now)
889
+ token = str(account.get("access_token") or "").strip()
890
+ if due_at is not None and token:
891
+ due_items.append((due_at, token))
892
+ due_items.sort(key=lambda item: item[0])
893
+ return [token for _, token in due_items[: self._REFRESH_TOKEN_KEEPALIVE_BATCH_SIZE]]
894
+
895
+ def keepalive_refresh_tokens(self, access_tokens: list[str]) -> dict[str, Any]:
896
+ access_tokens = list(dict.fromkeys(token for token in access_tokens if token))
897
+ if not access_tokens:
898
+ return {"refreshed": 0, "errors": [], "items": self.list_accounts()}
899
+
900
+ refreshed = 0
901
+ errors = []
902
+ for access_token in access_tokens:
903
+ before = self.resolve_access_token(access_token)
904
+ after = self.refresh_access_token(before, force=True, event="refresh_token_keepalive")
905
+ account = self.get_account(after)
906
+ if account and str(account.get("last_token_refresh_error") or "").strip():
907
+ errors.append({
908
+ "token": anonymize_token(before),
909
+ "error": str(account.get("last_token_refresh_error") or "refresh token failed"),
910
+ })
911
+ continue
912
+ if account:
913
+ refreshed += 1
914
+
915
+ return {
916
+ "refreshed": refreshed,
917
+ "errors": errors,
918
+ "items": self.list_accounts(),
919
+ "relogined": 0,
920
+ }
921
+
922
+ def list_tokens(self) -> list[str]:
923
+ with self._lock:
924
+ return list(self._accounts)
925
+
926
+ def _list_ready_candidate_tokens(
927
+ self,
928
+ excluded_tokens: set[str] | None = None,
929
+ plan_type: str | None = None,
930
+ source_type: str | None = None,
931
+ plan_types: set[str] | tuple[str, ...] | None = None,
932
+ ) -> list[str]:
933
+ excluded = set(excluded_tokens or set())
934
+ return [
935
+ token
936
+ for item in self._accounts.values()
937
+ if self._is_image_account_available(item)
938
+ and self._account_matches_plan_type(item, plan_type)
939
+ and self._account_matches_any_plan_type(item, plan_types)
940
+ and self._account_matches_source_type(item, source_type)
941
+ and (token := item.get("access_token") or "")
942
+ and token not in excluded
943
+ ]
944
+
945
+ def _list_available_candidate_tokens(
946
+ self,
947
+ excluded_tokens: set[str] | None = None,
948
+ plan_type: str | None = None,
949
+ source_type: str | None = None,
950
+ plan_types: set[str] | tuple[str, ...] | None = None,
951
+ ) -> list[str]:
952
+ max_concurrency = max(1, int(config.image_account_concurrency or 1))
953
+ return [
954
+ token
955
+ for token in self._list_ready_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types)
956
+ if int(self._image_inflight.get(token, 0)) < max_concurrency
957
+ ]
958
+
959
+ def _acquire_next_candidate_token(
960
+ self,
961
+ excluded_tokens: set[str] | None = None,
962
+ plan_type: str | None = None,
963
+ source_type: str | None = None,
964
+ plan_types: set[str] | tuple[str, ...] | None = None,
965
+ ) -> str:
966
+ with self._image_slot_condition:
967
+ while True:
968
+ if not self._list_ready_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types):
969
+ raise RuntimeError(
970
+ f"no available {plan_type or source_type or ''} image quota".replace(" ", " ").strip()
971
+ if plan_type or source_type else "no available image quota"
972
+ )
973
+ tokens = self._list_available_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types)
974
+ if tokens:
975
+ access_token = tokens[self._image_index % len(tokens)]
976
+ self._image_index += 1
977
+ self._save_rr_state()
978
+ self._image_inflight[access_token] = int(self._image_inflight.get(access_token, 0)) + 1
979
+ return access_token
980
+ self._image_slot_condition.wait(timeout=1.0)
981
+
982
+ def release_image_slot(self, access_token: str) -> None:
983
+ if not access_token:
984
+ return
985
+ with self._image_slot_condition:
986
+ access_token = self._resolve_access_token_locked(access_token)
987
+ current_inflight = int(self._image_inflight.get(access_token, 0))
988
+ if current_inflight <= 1:
989
+ self._image_inflight.pop(access_token, None)
990
+ else:
991
+ self._image_inflight[access_token] = current_inflight - 1
992
+ self._image_slot_condition.notify_all()
993
+
994
+ def get_available_access_token(
995
+ self,
996
+ plan_type: str | None = None,
997
+ source_type: str | None = None,
998
+ plan_types: set[str] | tuple[str, ...] | None = None,
999
+ ) -> str:
1000
+ """从候选池中获取一个可用的图片生图 token。
1001
+
1002
+ 基于本地缓存做初筛,然后通过 fetch_remote_info 做远程验证(token 有效性、配额等)。
1003
+ 限制最大尝试次数防止 token rotation 导致无限循环。
1004
+ """
1005
+ max_attempts = 20 # 防止无限循环
1006
+ attempted_tokens: set[str] = set()
1007
+ for _attempt in range(max_attempts):
1008
+ access_token = self._acquire_next_candidate_token(
1009
+ excluded_tokens=attempted_tokens,
1010
+ plan_type=plan_type,
1011
+ source_type=source_type,
1012
+ plan_types=plan_types,
1013
+ )
1014
+ attempted_tokens.add(access_token)
1015
+ try:
1016
+ account = self.fetch_remote_info(access_token, "get_available_access_token")
1017
+ except Exception:
1018
+ self.release_image_slot(access_token)
1019
+ continue
1020
+ # fetch_remote_info 内部可能因 token rotation 导致 access_token 变化,
1021
+ # 把新 token 也加入排除列表,防止重复尝试
1022
+ resolved = str((account or {}).get("access_token") or "")
1023
+ if resolved and resolved != access_token:
1024
+ attempted_tokens.add(resolved)
1025
+ if (
1026
+ self._is_image_account_available(account or {})
1027
+ and self._account_matches_plan_type(account or {}, plan_type)
1028
+ and self._account_matches_any_plan_type(account or {}, plan_types)
1029
+ and self._account_matches_source_type(account or {}, source_type)
1030
+ ):
1031
+ return str((account or {}).get("access_token") or access_token)
1032
+ self.release_image_slot(access_token)
1033
+ raise RuntimeError(
1034
+ f"no available {plan_type or source_type or ''} image quota (tried {len(attempted_tokens)} tokens)".replace(" ", " ").strip()
1035
+ if plan_type or source_type else f"no available image quota (tried {len(attempted_tokens)} tokens)"
1036
+ )
1037
+
1038
+ def get_text_access_token(
1039
+ self,
1040
+ excluded_tokens: set[str] | None = None,
1041
+ model: str = "auto",
1042
+ ) -> str:
1043
+ excluded = set(excluded_tokens or set())
1044
+ requested_model = str(model or "auto").strip() or "auto"
1045
+ route = None
1046
+ if requested_model != "auto":
1047
+ from services.model_service import model_catalog_service
1048
+
1049
+ route = model_catalog_service.route_for_model(requested_model)
1050
+ with self._lock:
1051
+ candidates = [
1052
+ token
1053
+ for account in self._accounts.values()
1054
+ if account.get("status") not in {"禁用", "限流", "异常"}
1055
+ and (
1056
+ route is None
1057
+ or self._normalize_account_type(account.get("type")) in route.account_types
1058
+ )
1059
+ and (token := account.get("access_token") or "")
1060
+ and token not in excluded
1061
+ ]
1062
+ if not candidates:
1063
+ if route is None or route.allow_anonymous:
1064
+ return ""
1065
+ from services.model_service import ModelUnavailableError
1066
+
1067
+ raise ModelUnavailableError(
1068
+ f"model {requested_model!r} is not available to any active account"
1069
+ )
1070
+ access_token = candidates[self._text_index % len(candidates)]
1071
+ self._text_index += 1
1072
+ self._save_rr_state()
1073
+ return self.refresh_access_token(access_token, event="get_text_access_token") or access_token
1074
+
1075
+ def mark_text_used(self, access_token: str) -> None:
1076
+ if not access_token:
1077
+ return
1078
+ with self._lock:
1079
+ access_token = self._resolve_access_token_locked(access_token)
1080
+ current = self._accounts.get(access_token)
1081
+ if current is None:
1082
+ return
1083
+ next_item = dict(current)
1084
+ next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
1085
+ account = self._normalize_account(next_item)
1086
+ if account is None:
1087
+ return
1088
+ self._accounts[access_token] = account
1089
+ self._save_accounts()
1090
+
1091
+ def remove_invalid_token(self, access_token: str, event: str, quiet: bool = False) -> bool:
1092
+ if not config.auto_remove_invalid_accounts:
1093
+ self.update_account(access_token, {"status": "异常", "quota": 0}, quiet=quiet)
1094
+ return False
1095
+ removed = bool(self.delete_accounts([access_token])["removed"])
1096
+ if removed:
1097
+ log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号",
1098
+ {"source": event, "token": anonymize_token(access_token)})
1099
+ elif access_token:
1100
+ self.update_account(access_token, {"status": "异常", "quota": 0}, quiet=quiet)
1101
+ return removed
1102
+
1103
+ def get_account(self, access_token: str) -> dict | None:
1104
+ if not access_token:
1105
+ return None
1106
+ with self._lock:
1107
+ access_token = self._resolve_access_token_locked(access_token)
1108
+ account = self._accounts.get(access_token)
1109
+ return dict(account) if account else None
1110
+
1111
+ def list_accounts(self) -> list[dict]:
1112
+ """返回所有账号的副本,并为每个账号附加当前图片在途数 image_inflight。
1113
+
1114
+ image_inflight 为内存态并发计数(账号正在生成、尚未结束的图片数)。号池空闲时
1115
+ 若某账号该值持续 > 0,说明其并发槽位泄漏、已被静默排除出调度,可借此在 UI 上诊断。
1116
+ """
1117
+ with self._lock:
1118
+ result = []
1119
+ for item in self._accounts.values():
1120
+ account = dict(item)
1121
+ token = account.get("access_token") or ""
1122
+ account["image_inflight"] = int(self._image_inflight.get(token, 0))
1123
+ result.append(account)
1124
+ return result
1125
+
1126
+ def list_limited_tokens(self) -> list[str]:
1127
+ with self._lock:
1128
+ return [
1129
+ token
1130
+ for item in self._accounts.values()
1131
+ if item.get("status") == "限流"
1132
+ and (token := item.get("access_token") or "")
1133
+ ]
1134
+
1135
+ def list_normal_tokens(self) -> list[str]:
1136
+ with self._lock:
1137
+ return [
1138
+ token
1139
+ for item in self._accounts.values()
1140
+ if item.get("status") == "正常"
1141
+ and (token := item.get("access_token") or "")
1142
+ ]
1143
+
1144
+ @staticmethod
1145
+ def _account_payload_token(item: dict) -> str:
1146
+ return str(item.get("access_token") or item.get("accessToken") or "").strip()
1147
+
1148
+ @staticmethod
1149
+ def _prepare_account_payload(item: dict) -> dict | None:
1150
+ if not isinstance(item, dict):
1151
+ return None
1152
+ access_token = AccountService._account_payload_token(item)
1153
+ if not access_token:
1154
+ return None
1155
+ payload = dict(item)
1156
+ payload.pop("accessToken", None)
1157
+ payload["access_token"] = access_token
1158
+ # CPA/Codex 导出文件里的 `type=codex` 是导出格式,不是号池套餐类型。
1159
+ if str(payload.get("type") or "").strip().lower() == "codex":
1160
+ payload["export_type"] = "codex"
1161
+ payload["source_type"] = "codex"
1162
+ payload.pop("type", None)
1163
+ if str(payload.get("export_type") or "").strip().lower() == "codex":
1164
+ payload["source_type"] = "codex"
1165
+ if payload.get("plan_type") and not payload.get("type"):
1166
+ payload["type"] = str(payload.get("plan_type") or "").strip()
1167
+ return payload
1168
+
1169
+ def add_account_items(self, items: list[dict]) -> dict:
1170
+ payloads = [
1171
+ payload
1172
+ for item in items
1173
+ if (payload := self._prepare_account_payload(item)) is not None
1174
+ ]
1175
+ return self._add_account_payloads(payloads)
1176
+
1177
+ def add_accounts(self, tokens: list[str], source_type: str = "web") -> dict:
1178
+ tokens = list(dict.fromkeys(token for token in tokens if token))
1179
+ if not tokens:
1180
+ return {"added": 0, "skipped": 0, "items": self.list_accounts()}
1181
+ return self._add_account_payloads([
1182
+ {"access_token": token, "source_type": self._normalize_source_type(source_type)}
1183
+ for token in tokens
1184
+ ])
1185
+
1186
+ def _add_account_payloads(self, payloads: list[dict]) -> dict:
1187
+ deduped: dict[str, dict] = {}
1188
+ for payload in payloads:
1189
+ if not isinstance(payload, dict):
1190
+ continue
1191
+ access_token = self._account_payload_token(payload)
1192
+ if not access_token:
1193
+ continue
1194
+ current = deduped.get(access_token, {})
1195
+ deduped[access_token] = {**current, **payload, "access_token": access_token}
1196
+
1197
+ if not deduped:
1198
+ return {"added": 0, "skipped": 0, "items": self.list_accounts()}
1199
+
1200
+ with self._lock:
1201
+ added = 0
1202
+ skipped = 0
1203
+ for access_token, payload in deduped.items():
1204
+ current = self._accounts.get(access_token)
1205
+ if current is None:
1206
+ added += 1
1207
+ self._cumulative_total += 1
1208
+ self._save_cumulative_total()
1209
+ current = {"created_at": self._now()}
1210
+ else:
1211
+ skipped += 1
1212
+ incoming = dict(payload)
1213
+ if not incoming.get("created_at"):
1214
+ incoming.pop("created_at", None)
1215
+ account = self._normalize_account(
1216
+ {
1217
+ **current,
1218
+ **incoming,
1219
+ "access_token": access_token,
1220
+ "type": str(incoming.get("type") or current.get("type") or "free"),
1221
+ }
1222
+ )
1223
+ if account is not None:
1224
+ self._accounts[access_token] = account
1225
+ self._save_accounts()
1226
+ items = [dict(item) for item in self._accounts.values()]
1227
+ log_service.add(LOG_TYPE_ACCOUNT, f"新增 {added} 个账号,跳过 {skipped} 个",
1228
+ {"added": added, "skipped": skipped})
1229
+ return {"added": added, "skipped": skipped, "items": items}
1230
+
1231
+ def delete_accounts(self, tokens: list[str]) -> dict:
1232
+ target_set = set(token for token in tokens if token)
1233
+ if not target_set:
1234
+ return {"removed": 0, "items": self.list_accounts()}
1235
+ with self._lock:
1236
+ target_set = {self._resolve_access_token_locked(token) for token in target_set if token}
1237
+ removed = sum(self._accounts.pop(token, None) is not None for token in target_set)
1238
+ for token in target_set:
1239
+ self._image_inflight.pop(token, None)
1240
+ self._token_aliases = {
1241
+ old: new
1242
+ for old, new in self._token_aliases.items()
1243
+ if old not in target_set and new not in target_set
1244
+ }
1245
+ if removed:
1246
+ if self._accounts:
1247
+ self._index %= len(self._accounts)
1248
+ else:
1249
+ self._index = 0
1250
+ self._save_accounts()
1251
+ log_service.add(LOG_TYPE_ACCOUNT, f"删除 {removed} 个账号", {"removed": removed})
1252
+ items = [dict(item) for item in self._accounts.values()]
1253
+ return {"removed": removed, "items": items}
1254
+
1255
+ def update_account(self, access_token: str, updates: dict, quiet: bool = False) -> dict | None:
1256
+ if not access_token:
1257
+ return None
1258
+ with self._lock:
1259
+ access_token = self._resolve_access_token_locked(access_token)
1260
+ current = self._accounts.get(access_token)
1261
+ if current is None:
1262
+ return None
1263
+ account = self._normalize_account({**current, **updates, "access_token": access_token})
1264
+ if account is None:
1265
+ return None
1266
+ if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts:
1267
+ self._accounts.pop(access_token, None)
1268
+ self._save_accounts()
1269
+ log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)})
1270
+ return None
1271
+ self._accounts[access_token] = account
1272
+ self._save_accounts()
1273
+ if not quiet:
1274
+ log_service.add(LOG_TYPE_ACCOUNT, "更新账号",
1275
+ {"token": anonymize_token(access_token), "status": account.get("status")})
1276
+ return dict(account)
1277
+ return None
1278
+
1279
+ def _record_refresh_success(self, access_token: str) -> None:
1280
+ with self._lock:
1281
+ access_token = self._resolve_access_token_locked(access_token)
1282
+ current = self._accounts.get(access_token)
1283
+ if current is None:
1284
+ return
1285
+ next_item = dict(current)
1286
+ next_item["invalid_count"] = 0
1287
+ next_item["last_invalid_at"] = None
1288
+ next_item["last_refresh_error"] = None
1289
+ next_item["last_refresh_error_at"] = None
1290
+ account = self._normalize_account(next_item)
1291
+ if account is not None:
1292
+ self._accounts[access_token] = account
1293
+
1294
+ def _should_defer_invalid_token(self, account: dict | None, now: datetime) -> bool:
1295
+ if not isinstance(account, dict):
1296
+ return False
1297
+ created_at = self._parse_time(account.get("created_at"))
1298
+ if created_at is not None and (now - created_at).total_seconds() < self._NEW_ACCOUNT_INVALID_GRACE_SECONDS:
1299
+ return True
1300
+ last_invalid_at = self._parse_time(account.get("last_invalid_at"))
1301
+ invalid_count = int(account.get("invalid_count") or 0)
1302
+ if invalid_count <= 1:
1303
+ return True
1304
+ if last_invalid_at is not None and (now - last_invalid_at).total_seconds() < self._INVALID_CONFIRM_SECONDS:
1305
+ return True
1306
+ return False
1307
+
1308
+ def _record_invalid_token_seen(
1309
+ self,
1310
+ access_token: str,
1311
+ event: str,
1312
+ error: str,
1313
+ defer_invalid_removal: bool = True,
1314
+ ) -> bool:
1315
+ now = datetime.now(timezone.utc)
1316
+ with self._lock:
1317
+ access_token = self._resolve_access_token_locked(access_token)
1318
+ current = self._accounts.get(access_token)
1319
+ if current is None:
1320
+ return True
1321
+ should_defer = defer_invalid_removal and self._should_defer_invalid_token(current, now)
1322
+ next_item = dict(current)
1323
+ next_item["invalid_count"] = int(next_item.get("invalid_count") or 0) + 1
1324
+ next_item["last_invalid_at"] = now.isoformat()
1325
+ next_item["last_refresh_error"] = str(error or "invalid access token")
1326
+ next_item["last_refresh_error_at"] = now.isoformat()
1327
+ account = self._normalize_account(next_item)
1328
+ if account is not None:
1329
+ self._accounts[access_token] = account
1330
+ self._save_accounts()
1331
+ if should_defer:
1332
+ log_service.add(
1333
+ LOG_TYPE_ACCOUNT,
1334
+ "暂缓标记异常账号",
1335
+ {"source": event, "token": anonymize_token(access_token), "error": str(error or "")},
1336
+ )
1337
+ return False
1338
+ return True
1339
+
1340
+ def mark_image_result(self, access_token: str, success: bool) -> dict | None:
1341
+ if not access_token:
1342
+ return None
1343
+ self.release_image_slot(access_token)
1344
+ with self._lock:
1345
+ access_token = self._resolve_access_token_locked(access_token)
1346
+ current = self._accounts.get(access_token)
1347
+ if current is None:
1348
+ return None
1349
+ next_item = dict(current)
1350
+ next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
1351
+ if success:
1352
+ next_item["success"] = int(next_item.get("success") or 0) + 1
1353
+ next_item["quota"] = max(0, int(next_item.get("quota") or 0) - 1)
1354
+ if next_item["quota"] == 0:
1355
+ next_item["status"] = "限流"
1356
+ next_item["restore_at"] = next_item.get("restore_at") or None
1357
+ elif next_item.get("status") == "限流":
1358
+ next_item["status"] = "正常"
1359
+ else:
1360
+ next_item["fail"] = int(next_item.get("fail") or 0) + 1
1361
+ account = self._normalize_account(next_item)
1362
+ if account is None:
1363
+ return None
1364
+ if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts:
1365
+ self._accounts.pop(access_token, None)
1366
+ self._save_accounts()
1367
+ log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)})
1368
+ return None
1369
+ self._accounts[access_token] = account
1370
+ self._save_accounts()
1371
+ return dict(account)
1372
+ return None
1373
+
1374
+ def fetch_remote_info(
1375
+ self,
1376
+ access_token: str,
1377
+ event: str = "fetch_remote_info",
1378
+ defer_invalid_removal: bool = True,
1379
+ ) -> dict[str, Any] | None:
1380
+ if not access_token:
1381
+ raise ValueError("access_token is required")
1382
+
1383
+ active_token = self.refresh_access_token(access_token, event=f"{event}:preflight") or access_token
1384
+ try:
1385
+ from services.openai_backend_api import InvalidAccessTokenError, OpenAIBackendAPI
1386
+ backend = OpenAIBackendAPI(active_token)
1387
+ try:
1388
+ result = backend.get_user_info()
1389
+ finally:
1390
+ backend.close()
1391
+ except InvalidAccessTokenError as exc:
1392
+ refreshed_token = self.refresh_access_token(active_token, force=True, event=f"{event}:invalid_access_token")
1393
+ if refreshed_token and refreshed_token != active_token:
1394
+ try:
1395
+ backend = OpenAIBackendAPI(refreshed_token)
1396
+ try:
1397
+ result = backend.get_user_info()
1398
+ finally:
1399
+ backend.close()
1400
+ except InvalidAccessTokenError as retry_exc:
1401
+ if self._record_invalid_token_seen(
1402
+ refreshed_token,
1403
+ event,
1404
+ str(retry_exc),
1405
+ defer_invalid_removal=defer_invalid_removal,
1406
+ ):
1407
+ self.remove_invalid_token(refreshed_token, event)
1408
+ raise
1409
+ active_token = refreshed_token
1410
+ else:
1411
+ if self._record_invalid_token_seen(
1412
+ active_token,
1413
+ event,
1414
+ str(exc),
1415
+ defer_invalid_removal=defer_invalid_removal,
1416
+ ):
1417
+ self.remove_invalid_token(active_token, event)
1418
+ raise
1419
+ self._record_refresh_success(active_token)
1420
+ return self.update_account(active_token, result)
1421
+
1422
+ # ---- 刷新进度追踪 ----
1423
+
1424
+ def init_refresh_progress(self, progress_id: str, total: int) -> None:
1425
+ """初始化刷新进度记录。"""
1426
+ with self._refresh_progress_lock:
1427
+ self._refresh_progress[progress_id] = {
1428
+ "total": total,
1429
+ "processed": 0,
1430
+ "done": False,
1431
+ "error": None,
1432
+ "status_counts": {"正常": 0, "限流": 0, "异常": 0, "禁用": 0},
1433
+ "total_quota": 0,
1434
+ }
1435
+
1436
+ def update_refresh_progress(self, progress_id: str, token: str) -> None:
1437
+ """刷新单个账号后,更新进度计数。"""
1438
+ account = self.get_account(token)
1439
+ status = str(account.get("status") or "正常").strip() if account else "正常"
1440
+ quota = max(0, int(account.get("quota") or 0)) if account else 0
1441
+
1442
+ with self._refresh_progress_lock:
1443
+ progress = self._refresh_progress.get(progress_id)
1444
+ if progress is None:
1445
+ return
1446
+ progress["processed"] += 1
1447
+ progress["status_counts"][status] = progress["status_counts"].get(status, 0) + 1
1448
+ progress["total_quota"] += quota
1449
+
1450
+ def finish_refresh_progress(self, progress_id: str, result: dict | None = None, error: str | None = None) -> None:
1451
+ """标记刷新完成。"""
1452
+ with self._refresh_progress_lock:
1453
+ progress = self._refresh_progress.get(progress_id)
1454
+ if progress is None:
1455
+ return
1456
+ progress["done"] = True
1457
+ progress["result"] = result
1458
+ if error:
1459
+ progress["error"] = error
1460
+
1461
+ def get_refresh_progress(self, progress_id: str) -> dict | None:
1462
+ """查询刷新进度。"""
1463
+ with self._refresh_progress_lock:
1464
+ progress = self._refresh_progress.get(progress_id)
1465
+ return dict(progress) if progress else None
1466
+
1467
+ def clean_refresh_progress(self, progress_id: str) -> None:
1468
+ """清理过期进度记录。"""
1469
+ with self._refresh_progress_lock:
1470
+ self._refresh_progress.pop(progress_id, None)
1471
+
1472
+ # ---- 重新登录进度追踪 ----
1473
+
1474
+ def init_relogin_progress(self, progress_id: str, total: int) -> None:
1475
+ """初始化重新登录进度记录。"""
1476
+ with self._relogin_progress_lock:
1477
+ self._relogin_progress[progress_id] = {
1478
+ "total": total,
1479
+ "processed": 0,
1480
+ "done": False,
1481
+ "error": None,
1482
+ "results": [],
1483
+ }
1484
+
1485
+ def update_relogin_progress(self, progress_id: str, token: str, status: str, error: str | None = None) -> None:
1486
+ """更新单个重新登录进度。当所有账号处理完毕时自动标记完成。"""
1487
+ with self._relogin_progress_lock:
1488
+ progress = self._relogin_progress.get(progress_id)
1489
+ if progress is None:
1490
+ return
1491
+ progress["processed"] += 1
1492
+ progress["results"].append({
1493
+ "token": anonymize_token(token),
1494
+ "status": status,
1495
+ "error": error,
1496
+ })
1497
+ if progress["processed"] >= progress["total"]:
1498
+ progress["done"] = True
1499
+
1500
+ def finish_relogin_progress(self, progress_id: str, result: dict | None = None, error: str | None = None) -> None:
1501
+ """标记重新登录完成。"""
1502
+ with self._relogin_progress_lock:
1503
+ progress = self._relogin_progress.get(progress_id)
1504
+ if progress is None:
1505
+ return
1506
+ progress["done"] = True
1507
+ progress["result"] = result
1508
+ if error:
1509
+ progress["error"] = error
1510
+
1511
+ def get_relogin_progress(self, progress_id: str) -> dict | None:
1512
+ """查询重新登录进度。"""
1513
+ with self._relogin_progress_lock:
1514
+ progress = self._relogin_progress.get(progress_id)
1515
+ return dict(progress) if progress else None
1516
+
1517
+ def clean_relogin_progress(self, progress_id: str) -> None:
1518
+ """清理过期进度记录。"""
1519
+ with self._relogin_progress_lock:
1520
+ self._relogin_progress.pop(progress_id, None)
1521
+
1522
+ def refresh_accounts(
1523
+ self,
1524
+ access_tokens: list[str],
1525
+ progress_id: str | None = None,
1526
+ defer_invalid_removal: bool = True,
1527
+ ) -> dict[str, Any]:
1528
+ access_tokens = list(dict.fromkeys(token for token in access_tokens if token))
1529
+ if not access_tokens:
1530
+ items = self.list_accounts()
1531
+ result = {"refreshed": 0, "errors": [], "items": items, "relogined": 0}
1532
+ if progress_id:
1533
+ self.finish_refresh_progress(progress_id, result)
1534
+ return result
1535
+
1536
+ refreshed = 0
1537
+ errors = []
1538
+ max_workers = min(10, len(access_tokens))
1539
+
1540
+ if progress_id:
1541
+ self.init_refresh_progress(progress_id, len(access_tokens))
1542
+
1543
+ executor = ThreadPoolExecutor(max_workers=max_workers)
1544
+ try:
1545
+ futures = {
1546
+ executor.submit(self.fetch_remote_info, token, "refresh_accounts", defer_invalid_removal): token
1547
+ for token in access_tokens
1548
+ }
1549
+ for future in as_completed(futures):
1550
+ token = futures[future]
1551
+ try:
1552
+ account = future.result()
1553
+ except (KeyboardInterrupt, SystemExit):
1554
+ executor.shutdown(wait=False, cancel_futures=True)
1555
+ raise
1556
+ except Exception as exc:
1557
+ error_str = str(exc)
1558
+ # TLS/代理连接错误是网络问题,不计入账号失败
1559
+ from services.protocol.conversation import is_tls_connection_error
1560
+ if not is_tls_connection_error(error_str):
1561
+ errors.append({"token": anonymize_token(token), "error": error_str})
1562
+ else:
1563
+ if account is not None:
1564
+ refreshed += 1
1565
+
1566
+ if progress_id:
1567
+ self.update_refresh_progress(progress_id, token)
1568
+ except (KeyboardInterrupt, SystemExit):
1569
+ if progress_id:
1570
+ self.finish_refresh_progress(progress_id, error="cancelled")
1571
+ executor.shutdown(wait=False, cancel_futures=True)
1572
+ raise
1573
+ else:
1574
+ executor.shutdown(wait=True, cancel_futures=True)
1575
+
1576
+ # 自动重新登录异常账号(仅当配置开启时)
1577
+ relogined = 0
1578
+ if config.auto_relogin_after_refresh:
1579
+ for token in access_tokens:
1580
+ account = self.get_account(token)
1581
+ if not account:
1582
+ continue
1583
+ status = str(account.get("status") or "").strip()
1584
+ if status != "异常":
1585
+ continue
1586
+ email = str(account.get("email") or "").strip()
1587
+ password = str(account.get("password") or "").strip()
1588
+ if not email or not password:
1589
+ continue
1590
+ t = Thread(
1591
+ target=self._password_re_login_thread,
1592
+ args=(token, email, password, "auto_relogin_after_refresh"),
1593
+ daemon=True,
1594
+ )
1595
+ t.start()
1596
+ relogined += 1
1597
+
1598
+ result = {
1599
+ "refreshed": refreshed,
1600
+ "errors": errors,
1601
+ "items": self.list_accounts(),
1602
+ "relogined": relogined,
1603
+ }
1604
+
1605
+ if progress_id:
1606
+ self.finish_refresh_progress(progress_id, result)
1607
+
1608
+ return result
1609
+
1610
+ def re_login_accounts(self, access_tokens: list[str], progress_id: str | None = None) -> dict[str, Any]:
1611
+ """对选中账号执行密码重新登录流程。
1612
+
1613
+ 仅对包含 email + password 的账号有效。
1614
+ 登录成功后自动将状态设为"正常"。
1615
+ """
1616
+ access_tokens = list(dict.fromkeys(token for token in access_tokens if token))
1617
+ if not access_tokens:
1618
+ result = {"relogined": 0, "skipped": 0, "errors": [], "items": self.list_accounts()}
1619
+ if progress_id:
1620
+ self.finish_relogin_progress(progress_id, result)
1621
+ return result
1622
+
1623
+ if progress_id:
1624
+ self.init_relogin_progress(progress_id, len(access_tokens))
1625
+
1626
+ relogined = 0
1627
+ skipped = 0
1628
+ errors = []
1629
+
1630
+ for token in access_tokens:
1631
+ account = self.get_account(token)
1632
+ if not account:
1633
+ errors.append({"token": anonymize_token(token), "error": "账号不存在"})
1634
+ if progress_id:
1635
+ self.update_relogin_progress(progress_id, token, "跳过", "账号不存在")
1636
+ continue
1637
+
1638
+ email = str(account.get("email") or "").strip()
1639
+ password = str(account.get("password") or "").strip()
1640
+ if not email or not password:
1641
+ skipped += 1
1642
+ if progress_id:
1643
+ self.update_relogin_progress(progress_id, token, "跳过", "无邮箱密码")
1644
+ continue
1645
+
1646
+ # 在新线程中执行密码重新登录
1647
+ t = Thread(
1648
+ target=self._password_re_login_thread,
1649
+ args=(token, email, password, "manual_relogin", progress_id),
1650
+ daemon=True,
1651
+ )
1652
+ t.start()
1653
+ relogined += 1
1654
+
1655
+ result = {
1656
+ "relogined": relogined,
1657
+ "skipped": skipped,
1658
+ "errors": errors,
1659
+ "items": self.list_accounts(),
1660
+ }
1661
+ if progress_id:
1662
+ # 如果所有账号都已同步处理完毕(没有启动线程),直接标记完成
1663
+ if relogined == 0:
1664
+ self.finish_relogin_progress(progress_id, result)
1665
+ else:
1666
+ # 有线程在运行,等线程结束后再完成
1667
+ pass
1668
+ return result
1669
+
1670
+ def build_export_items(self, access_tokens: list[str] | None = None) -> list[dict[str, str]]:
1671
+ target_tokens = set(token for token in (access_tokens or []) if token)
1672
+ with self._lock:
1673
+ accounts = [
1674
+ dict(item)
1675
+ for item in self._accounts.values()
1676
+ if not target_tokens or str(item.get("access_token") or "") in target_tokens
1677
+ ]
1678
+
1679
+ items: list[dict[str, str]] = []
1680
+ for account in accounts:
1681
+ access_token = str(account.get("access_token") or "").strip()
1682
+ refresh_token = str(account.get("refresh_token") or "").strip()
1683
+ id_token = str(account.get("id_token") or "").strip()
1684
+ if not access_token or not refresh_token or not id_token:
1685
+ continue
1686
+
1687
+ access_payload = self._decode_jwt_payload(access_token)
1688
+ id_payload = self._decode_jwt_payload(id_token)
1689
+ auth_claim = access_payload.get("https://api.openai.com/auth")
1690
+ auth_claim = auth_claim if isinstance(auth_claim, dict) else {}
1691
+ profile_claim = access_payload.get("https://api.openai.com/profile")
1692
+ profile_claim = profile_claim if isinstance(profile_claim, dict) else {}
1693
+
1694
+ email = (
1695
+ str(account.get("email") or "").strip()
1696
+ or str(profile_claim.get("email") or "").strip()
1697
+ or str(id_payload.get("email") or "").strip()
1698
+ )
1699
+ account_id = (
1700
+ str(account.get("account_id") or "").strip()
1701
+ or str(auth_claim.get("chatgpt_account_id") or "").strip()
1702
+ or str(account.get("user_id") or "").strip()
1703
+ )
1704
+ item = {
1705
+ "type": str(account.get("export_type") or "codex"),
1706
+ "email": email,
1707
+ "account_id": account_id,
1708
+ "access_token": access_token,
1709
+ "refresh_token": refresh_token,
1710
+ "id_token": id_token,
1711
+ "expired": self._timestamp_to_iso(access_payload.get("exp")),
1712
+ "last_refresh": self._timestamp_to_iso(access_payload.get("iat")),
1713
+ }
1714
+ password = str(account.get("password") or "").strip()
1715
+ if password:
1716
+ item["password"] = password
1717
+ items.append(item)
1718
+ return items
1719
+
1720
+ def get_stats(self) -> dict:
1721
+ with self._lock:
1722
+ items = list(self._accounts.values())
1723
+ total = len(items)
1724
+ active = sum(1 for a in items if a.get("status") == "正常")
1725
+ limited = sum(1 for a in items if a.get("status") == "限流")
1726
+ abnormal = sum(1 for a in items if a.get("status") == "异常")
1727
+ disabled = sum(1 for a in items if a.get("status") == "禁用")
1728
+ total_quota = sum(max(0, int(a.get("quota") or 0)) for a in items if a.get("status") == "正常")
1729
+ total_success = sum(int(a.get("success") or 0) for a in items)
1730
+ total_fail = sum(int(a.get("fail") or 0) for a in items)
1731
+ by_type = {}
1732
+ for a in items:
1733
+ t = a.get("type", "unknown")
1734
+ by_type[t] = by_type.get(t, 0) + 1
1735
+ return {
1736
+ "total": total,
1737
+ "cumulative_total": self._cumulative_total,
1738
+ "active": active,
1739
+ "limited": limited,
1740
+ "abnormal": abnormal,
1741
+ "disabled": disabled,
1742
+ "total_quota": total_quota,
1743
+ "total_success": total_success,
1744
+ "total_fail": total_fail,
1745
+ "by_type": by_type,
1746
+ }
1747
+
1748
+ def account_health(self) -> dict:
1749
+ stats = self.get_stats()
1750
+ return {
1751
+ "healthy": stats["active"] > 0,
1752
+ "status": "ok" if stats["active"] > 0 else "degraded",
1753
+ **stats,
1754
+ }
1755
+
1756
+
1757
+ account_service = AccountService(config.get_storage_backend())