dsh-plugin-image-workbench 1.1.5 → 1.1.7
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.
- package/LICENSE +21 -21
- package/README.en.md +5 -3
- package/README.md +7 -3
- package/client.js +241 -241
- package/cordis.patch.yml +14 -14
- package/host.js +51 -0
- package/install.mjs +333 -387
- package/package.json +3 -2
- package/scripts/fetch-style-images.mjs +104 -0
- package/scripts/vendor-snapshot.py +154 -154
- package/server/access.py +26 -26
- package/server/app.py +1371 -1329
- package/server/catalog.py +147 -147
- package/server/composer.py +391 -391
- package/server/drafts.py +158 -158
- package/server/history.py +575 -575
- package/server/imaging.py +172 -172
- package/server/pool.py +1335 -1335
- package/server/pool_admin.py +921 -921
- package/server/pool_service.py +1073 -1073
- package/server/requirements.txt +6 -6
- package/server/smoke_check.py +402 -402
- package/server/static/access.html +202 -202
- package/server/static/index.html +15 -0
- package/vendor/chatgpt2api/source/.env.example +82 -82
- package/vendor/chatgpt2api/source/.python-version +1 -1
- package/vendor/chatgpt2api/source/LICENSE +21 -21
- package/vendor/chatgpt2api/source/README.md +391 -391
- package/vendor/chatgpt2api/source/VERSION +1 -1
- package/vendor/chatgpt2api/source/api/__init__.py +2 -2
- package/vendor/chatgpt2api/source/api/accounts.py +537 -537
- package/vendor/chatgpt2api/source/api/ai.py +217 -217
- package/vendor/chatgpt2api/source/api/app.py +62 -62
- package/vendor/chatgpt2api/source/api/errors.py +46 -46
- package/vendor/chatgpt2api/source/api/image_inputs.py +309 -309
- package/vendor/chatgpt2api/source/api/image_tasks.py +120 -120
- package/vendor/chatgpt2api/source/api/support.py +134 -134
- package/vendor/chatgpt2api/source/api/system.py +365 -365
- package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -50
- package/vendor/chatgpt2api/source/config.default.json +108 -108
- package/vendor/chatgpt2api/source/main.py +9 -9
- package/vendor/chatgpt2api/source/pyproject.toml +27 -27
- package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -186
- package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -163
- package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -13
- package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -385
- package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -129
- package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -92
- package/vendor/chatgpt2api/source/services/account_service.py +1757 -1757
- package/vendor/chatgpt2api/source/services/auth_service.py +253 -253
- package/vendor/chatgpt2api/source/services/backup_service.py +671 -671
- package/vendor/chatgpt2api/source/services/config.py +667 -667
- package/vendor/chatgpt2api/source/services/content_filter.py +242 -242
- package/vendor/chatgpt2api/source/services/cpa_service.py +315 -315
- package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -260
- package/vendor/chatgpt2api/source/services/image_service.py +376 -376
- package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -405
- package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -76
- package/vendor/chatgpt2api/source/services/image_task_service.py +550 -550
- package/vendor/chatgpt2api/source/services/log_service.py +337 -337
- package/vendor/chatgpt2api/source/services/model_service.py +182 -182
- package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -267
- package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -2763
- package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -32
- package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -2
- package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -307
- package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -238
- package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -1644
- package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -124
- package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -19
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -316
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -86
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -42
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -53
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -453
- package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -160
- package/vendor/chatgpt2api/source/services/proxy_service.py +648 -648
- package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -5
- package/vendor/chatgpt2api/source/services/storage/base.py +38 -38
- package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -194
- package/vendor/chatgpt2api/source/services/storage/factory.py +104 -104
- package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -183
- package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -95
- package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -531
- package/vendor/chatgpt2api/source/utils/helper.py +469 -469
- package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -346
- package/vendor/chatgpt2api/source/utils/log.py +110 -110
- package/vendor/chatgpt2api/source/utils/pkce.py +19 -19
- package/vendor/chatgpt2api/source/utils/pow.py +207 -207
- package/vendor/chatgpt2api/source/utils/sentinel.py +159 -159
- package/vendor/chatgpt2api/source/utils/turnstile.py +184 -184
- package/vendor/chatgpt2api/source/uv.lock +904 -904
- package/vendor/chatgpt2api/source/web/bun.lock +1501 -1501
- package/vendor/chatgpt2api/source/web/components.json +24 -24
- package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -17
- package/vendor/chatgpt2api/source/web/next.config.ts +40 -40
- package/vendor/chatgpt2api/source/web/package.json +58 -58
- package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -5
- package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -925
- package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -1307
- package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -224
- package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -444
- package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -9
- package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -9
- package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -172
- package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -169
- package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -39
- package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -59
- package/vendor/chatgpt2api/source/web/src/app/globals.css +230 -230
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -542
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -554
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -176
- package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -1799
- package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -732
- package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -50
- package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -101
- package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -356
- package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -30
- package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -341
- package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -447
- package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -71
- package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -491
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -108
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -169
- package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -185
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -379
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -127
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -200
- package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -12
- package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -886
- package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -81
- package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -422
- package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -135
- package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -1069
- package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -49
- package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -24
- package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -434
- package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -46
- package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -18
- package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -14
- package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -263
- package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -256
- package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -41
- package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -59
- package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -46
- package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -61
- package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -27
- package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -267
- package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -132
- package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -13
- package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -19
- package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -24
- package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -149
- package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -38
- package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -175
- package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -143
- package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -29
- package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -91
- package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -269
- package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -18
- package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -127
- package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -6
- package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -85
- package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -926
- package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -26
- package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -25
- package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -93
- package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -89
- package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -6
- package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -112
- package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -33
- package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -318
- package/vendor/chatgpt2api/source/web/tsconfig.json +42 -42
|
@@ -1,1757 +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())
|
|
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())
|