Skip to content

Commit d49fff2

Browse files
committed
fix(upload): backfill chatgpt account fields for codex json
1 parent aeefb92 commit d49fff2

4 files changed

Lines changed: 259 additions & 9 deletions

File tree

src/core/register.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import uuid
1313
from typing import Optional, Dict, Any, Tuple, Callable, List
1414
from dataclasses import dataclass
15-
from datetime import datetime
15+
from datetime import datetime, timezone
1616

1717
from curl_cffi import requests as cffi_requests
1818

@@ -1858,6 +1858,52 @@ def _extract_account_id_from_access_token(self, access_token: str) -> str:
18581858
except Exception:
18591859
return ""
18601860

1861+
def _extract_token_timestamps(self, token: str) -> Tuple[Optional[datetime], Optional[datetime]]:
1862+
"""
1863+
从 JWT(access_token / id_token)payload 提取 iat / exp,返回 naive UTC datetime。
1864+
"""
1865+
try:
1866+
raw = str(token or "").strip()
1867+
if raw.count(".") < 2:
1868+
return None, None
1869+
payload = raw.split(".")[1]
1870+
import base64
1871+
pad = "=" * ((4 - (len(payload) % 4)) % 4)
1872+
decoded = base64.urlsafe_b64decode((payload + pad).encode("ascii"))
1873+
claims = json.loads(decoded.decode("utf-8"))
1874+
if not isinstance(claims, dict):
1875+
return None, None
1876+
1877+
iat = claims.get("iat")
1878+
exp = claims.get("exp")
1879+
1880+
issued_at = (
1881+
datetime.fromtimestamp(int(iat), tz=timezone.utc).replace(tzinfo=None)
1882+
if iat not in (None, "")
1883+
else None
1884+
)
1885+
expires_at = (
1886+
datetime.fromtimestamp(int(exp), tz=timezone.utc).replace(tzinfo=None)
1887+
if exp not in (None, "")
1888+
else None
1889+
)
1890+
return issued_at, expires_at
1891+
except Exception:
1892+
return None, None
1893+
1894+
@staticmethod
1895+
def _parse_iso_datetime_to_naive_utc(value: str) -> Optional[datetime]:
1896+
try:
1897+
text = str(value or "").strip()
1898+
if not text:
1899+
return None
1900+
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
1901+
if parsed.tzinfo is None:
1902+
return parsed
1903+
return parsed.astimezone(timezone.utc).replace(tzinfo=None)
1904+
except Exception:
1905+
return None
1906+
18611907
def _ensure_native_required_tokens(self, result: RegistrationResult) -> bool:
18621908
"""
18631909
原生注册入口要求拿齐:
@@ -2995,6 +3041,17 @@ def save_to_database(
29953041
try:
29963042
# 获取默认 client_id
29973043
settings = get_settings()
3044+
token_issued_at, token_expires_at = self._extract_token_timestamps(
3045+
result.access_token or result.id_token
3046+
)
3047+
metadata_expires_at = None
3048+
if isinstance(result.metadata, dict):
3049+
metadata_expires_at = self._parse_iso_datetime_to_naive_utc(
3050+
str(result.metadata.get("expires") or "")
3051+
)
3052+
final_expires_at = token_expires_at or metadata_expires_at
3053+
now_utc_naive = datetime.now(timezone.utc).replace(tzinfo=None)
3054+
final_last_refresh = token_issued_at or (now_utc_naive if result.access_token else None)
29983055

29993056
with get_db() as db:
30003057
# 保存账户信息
@@ -3013,6 +3070,8 @@ def save_to_database(
30133070
refresh_token=result.refresh_token,
30143071
id_token=result.id_token,
30153072
proxy_used=self.proxy_url,
3073+
last_refresh=final_last_refresh,
3074+
expires_at=final_expires_at,
30163075
extra_data=result.metadata,
30173076
source=result.source,
30183077
account_label=account_label,

src/core/upload/cpa_upload.py

Lines changed: 108 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import json
66
import logging
7+
import base64
78
from typing import List, Dict, Any, Tuple, Optional
89
from datetime import datetime
910
from urllib.parse import quote
@@ -14,11 +15,58 @@
1415
from ...database.session import get_db
1516
from ...database.models import Account
1617
from ...config.settings import get_settings
17-
from ..timezone_utils import utcnow_naive
18+
from ..timezone_utils import UTC, SHANGHAI_TZ, utcnow_naive
1819

1920
logger = logging.getLogger(__name__)
2021

2122

23+
def _decode_jwt_payload_unverified(token: str) -> Dict[str, Any]:
24+
text = str(token or "").strip()
25+
if not text or text.count(".") < 2:
26+
return {}
27+
try:
28+
payload_b64 = text.split(".")[1]
29+
padding = "=" * (-len(payload_b64) % 4)
30+
raw = base64.urlsafe_b64decode((payload_b64 + padding).encode("utf-8"))
31+
data = json.loads(raw.decode("utf-8"))
32+
return data if isinstance(data, dict) else {}
33+
except Exception:
34+
return {}
35+
36+
37+
def _parse_unix_ts_to_naive_utc(value: Any) -> Optional[datetime]:
38+
try:
39+
if value in (None, ""):
40+
return None
41+
ts = int(value)
42+
return datetime.fromtimestamp(ts, tz=UTC).replace(tzinfo=None)
43+
except Exception:
44+
return None
45+
46+
47+
def _parse_iso_to_naive_utc(value: Any) -> Optional[datetime]:
48+
try:
49+
text = str(value or "").strip()
50+
if not text:
51+
return None
52+
dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
53+
if dt.tzinfo is None:
54+
return dt
55+
return dt.astimezone(UTC).replace(tzinfo=None)
56+
except Exception:
57+
return None
58+
59+
60+
def _format_cpa_datetime(dt: Optional[datetime]) -> str:
61+
if dt is None:
62+
return ""
63+
if dt.tzinfo is None:
64+
dt = dt.replace(tzinfo=UTC)
65+
else:
66+
dt = dt.astimezone(UTC)
67+
return dt.astimezone(SHANGHAI_TZ).strftime("%Y-%m-%dT%H:%M:%S+08:00")
68+
69+
2270
def _normalize_cpa_auth_files_url(api_url: str) -> str:
2371
"""将用户填写的 CPA 地址规范化为 auth-files 接口地址。"""
2472
normalized = (api_url or "").strip().rstrip("/")
@@ -100,15 +148,67 @@ def generate_token_json(account: Account) -> dict:
100148
Returns:
101149
CPA 格式的 Token 字典
102150
"""
151+
access_token = str(account.access_token or "").strip()
152+
id_token_raw = str(account.id_token or "").strip()
153+
# 兼容历史下游:部分工具只从 id_token 提取 chatgpt_account_id。
154+
# 当注册链路仅拿到 access_token 时,使用 access_token 作为兜底 id_token 供解析。
155+
id_token = id_token_raw or access_token
156+
refresh_token = str(account.refresh_token or "").strip()
157+
158+
claims = _decode_jwt_payload_unverified(access_token) or _decode_jwt_payload_unverified(id_token)
159+
auth_claims = claims.get("https://api.openai.com/auth") if isinstance(claims, dict) else {}
160+
auth_claims = auth_claims if isinstance(auth_claims, dict) else {}
161+
profile_claims = claims.get("https://api.openai.com/profile") if isinstance(claims, dict) else {}
162+
profile_claims = profile_claims if isinstance(profile_claims, dict) else {}
163+
164+
account_id = str(
165+
account.account_id
166+
or auth_claims.get("chatgpt_account_id")
167+
or claims.get("chatgpt_account_id")
168+
or ""
169+
).strip()
170+
workspace_id = str(account.workspace_id or account_id).strip()
171+
chatgpt_user_id = str(
172+
auth_claims.get("chatgpt_user_id")
173+
or auth_claims.get("user_id")
174+
or claims.get("chatgpt_user_id")
175+
or claims.get("user_id")
176+
or ""
177+
).strip()
178+
179+
email = str(
180+
account.email
181+
or profile_claims.get("email")
182+
or ""
183+
).strip()
184+
185+
token_issued_at = _parse_unix_ts_to_naive_utc(claims.get("iat"))
186+
token_expires_at = _parse_unix_ts_to_naive_utc(claims.get("exp"))
187+
extra_data = account.extra_data if isinstance(account.extra_data, dict) else {}
188+
metadata_expires_at = _parse_iso_to_naive_utc(extra_data.get("expires"))
189+
190+
last_refresh = account.last_refresh or token_issued_at or account.registered_at
191+
expires_at = account.expires_at or token_expires_at or metadata_expires_at
192+
193+
client_id = str(
194+
claims.get("client_id")
195+
or account.client_id
196+
or ""
197+
).strip()
198+
103199
return {
104200
"type": "codex",
105-
"email": account.email,
106-
"expired": account.expires_at.strftime("%Y-%m-%dT%H:%M:%S+08:00") if account.expires_at else "",
107-
"id_token": account.id_token or "",
108-
"account_id": account.account_id or "",
109-
"access_token": account.access_token or "",
110-
"last_refresh": account.last_refresh.strftime("%Y-%m-%dT%H:%M:%S+08:00") if account.last_refresh else "",
111-
"refresh_token": account.refresh_token or "",
201+
"email": email,
202+
"expired": _format_cpa_datetime(expires_at),
203+
"id_token": id_token,
204+
"account_id": account_id,
205+
"workspace_id": workspace_id,
206+
"chatgpt_account_id": account_id,
207+
"chatgpt_user_id": chatgpt_user_id,
208+
"client_id": client_id,
209+
"access_token": access_token,
210+
"last_refresh": _format_cpa_datetime(last_refresh),
211+
"refresh_token": refresh_token,
112212
}
113213

114214

src/database/crud.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def create_account(
5252
id_token: Optional[str] = None,
5353
cookies: Optional[str] = None,
5454
proxy_used: Optional[str] = None,
55+
last_refresh: Optional['datetime'] = None,
5556
expires_at: Optional['datetime'] = None,
5657
extra_data: Optional[Dict[str, Any]] = None,
5758
status: Optional[str] = None,
@@ -88,6 +89,7 @@ def create_account(
8889
id_token=id_token,
8990
cookies=cookies,
9091
proxy_used=proxy_used,
92+
last_refresh=last_refresh,
9193
expires_at=expires_at,
9294
extra_data=extra_data or {},
9395
status=status or 'active',
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import base64
2+
import json
3+
from datetime import datetime
4+
5+
from src.core.upload.cpa_upload import generate_token_json
6+
from src.database.models import Account
7+
8+
9+
def _jwt_with_payload(payload: dict) -> str:
10+
header = {"alg": "none", "typ": "JWT"}
11+
12+
def _b64(obj: dict) -> str:
13+
raw = json.dumps(obj, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
14+
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
15+
16+
return f"{_b64(header)}.{_b64(payload)}."
17+
18+
19+
def test_generate_token_json_backfills_time_and_account_id_from_access_token():
20+
payload = {
21+
"iat": 1775019821, # 2026-04-01T05:03:41Z
22+
"exp": 1775883821, # 2026-04-11T05:03:41Z
23+
"https://api.openai.com/auth": {
24+
"chatgpt_account_id": "acct_from_jwt",
25+
},
26+
"https://api.openai.com/profile": {
27+
"email": "jwt@example.com",
28+
},
29+
}
30+
31+
account = Account(
32+
email="ericvillegas9964@outlook.com",
33+
email_service="luckmail",
34+
access_token=_jwt_with_payload(payload),
35+
account_id="",
36+
last_refresh=None,
37+
expires_at=None,
38+
)
39+
40+
token_data = generate_token_json(account)
41+
42+
assert token_data["account_id"] == "acct_from_jwt"
43+
assert token_data["email"] == "ericvillegas9964@outlook.com"
44+
assert token_data["last_refresh"] == "2026-04-01T13:03:41+08:00"
45+
assert token_data["expired"] == "2026-04-11T13:03:41+08:00"
46+
assert token_data["id_token"] == account.access_token
47+
assert token_data["workspace_id"] == "acct_from_jwt"
48+
assert token_data["chatgpt_account_id"] == "acct_from_jwt"
49+
50+
51+
def test_generate_token_json_prefers_db_timestamps_over_jwt_claims():
52+
payload = {
53+
"iat": 1775019821,
54+
"exp": 1775883821,
55+
"https://api.openai.com/auth": {"chatgpt_account_id": "acct_from_jwt"},
56+
}
57+
58+
account = Account(
59+
email="db@example.com",
60+
email_service="luckmail",
61+
access_token=_jwt_with_payload(payload),
62+
account_id="acct_from_db",
63+
last_refresh=datetime(2026, 4, 2, 0, 0, 0), # naive UTC
64+
expires_at=datetime(2026, 4, 3, 0, 0, 0), # naive UTC
65+
)
66+
67+
token_data = generate_token_json(account)
68+
69+
assert token_data["account_id"] == "acct_from_db"
70+
assert token_data["last_refresh"] == "2026-04-02T08:00:00+08:00"
71+
assert token_data["expired"] == "2026-04-03T08:00:00+08:00"
72+
assert token_data["chatgpt_account_id"] == "acct_from_db"
73+
74+
75+
def test_generate_token_json_uses_metadata_expires_when_db_and_jwt_missing():
76+
account = Account(
77+
email="meta@example.com",
78+
email_service="luckmail",
79+
access_token="",
80+
account_id="acct_meta",
81+
last_refresh=None,
82+
expires_at=None,
83+
extra_data={"expires": "2026-06-30T05:03:46.241Z"},
84+
)
85+
86+
token_data = generate_token_json(account)
87+
88+
assert token_data["expired"] == "2026-06-30T13:03:46+08:00"
89+
assert token_data["id_token"] == ""

0 commit comments

Comments
 (0)