diff --git a/api/accounts.py b/api/accounts.py index 93acc85..1c4c5fe 100644 --- a/api/accounts.py +++ b/api/accounts.py @@ -81,11 +81,14 @@ class AccountExportRequest(BaseModel): class AccountUpdateRequest(BaseModel): access_token: str = "" + account_id: str | None = None + row_id: str | None = None type: str | None = None provider: str | None = None target_provider: Literal["gpt", "grok", "gemini"] | None = None status: str | None = None quota: int | None = None + proxy: str | None = None class CPAPoolCreateRequest(BaseModel): @@ -241,7 +244,7 @@ def _validate_gemini_import_payloads(tokens: list[str], payloads: list[dict[str, if not payloads: raise HTTPException(status_code=400, detail={"error": "Gemini 目前只支持 Cookie 双字段导入"}) top_level_gemini = normalize_provider(provider) == GEMINI_PROVIDER - allowed_keys = {"provider", "__Secure-1PSID", "__Secure-1PSIDTS"} + allowed_keys = {"provider", "__Secure-1PSID", "__Secure-1PSIDTS", "proxy"} for item in payloads: item_provider = str(item.get("provider") or "").strip() if top_level_gemini and item_provider and normalize_account_provider(item_provider) != GEMINI_PROVIDER: @@ -285,13 +288,20 @@ def _grok_import_requested(provider: str | None, payloads: list[dict[str, Any]]) def _validate_grok_import_payloads(tokens: list[str], payloads: list[dict[str, Any]], provider: str | None) -> list[str]: if not _grok_import_requested(provider, payloads): return tokens - if payloads: - raise HTTPException(status_code=400, detail={"error": "Grok 导入只接受裸 SSO 值,或每行一个 sso=<值>;不支持 sso-rw、完整 Cookie header、JSON、CPA、cookies 或 accounts 账号 payload"}) - if not tokens: + if not tokens and not payloads: raise HTTPException(status_code=400, detail={"error": "Grok 导入只接受裸 SSO 值,或每行一个 sso=<值>"}) + allowed_keys = {"provider", "sso", "proxy"} + for item in payloads: + item_provider = str(item.get("provider") or provider or "").strip() + if item_provider and normalize_account_provider(item_provider) != GROK_PROVIDER: + raise HTTPException(status_code=400, detail={"error": "Grok 导入不能混用其他供应商账号"}) + extra_keys = {key for key, value in item.items() if value is not None} - allowed_keys + sso = str(item.get("sso") or "").strip() + if extra_keys or not _normalize_grok_sso_import_token(sso): + raise HTTPException(status_code=400, detail={"error": "Grok 导入只接受裸 SSO 值,或每行一个 sso=<值>;不支持 sso-rw、完整 Cookie header、其他 Cookie 名称、JSON、CPA 或 cookies"}) normalized_tokens = [_normalize_grok_sso_import_token(token) for token in tokens] if not all(normalized_tokens): - raise HTTPException(status_code=400, detail={"error": "Grok 导入只接受裸 SSO 值,或每行一个 sso=<值>;不支持 sso-rw、完整 Cookie header、其他 Cookie 名称、JSON、CPA、cookies 或 accounts 账号 payload"}) + raise HTTPException(status_code=400, detail={"error": "Grok 导入只接受裸 SSO 值,或每行一个 sso=<值>;不支持 sso-rw、完整 Cookie header、其他 Cookie 名称、JSON、CPA 或 cookies"}) return normalized_tokens @@ -480,8 +490,9 @@ async def export_accounts(body: AccountExportRequest, authorization: str | None async def update_account(body: AccountUpdateRequest, authorization: str | None = Header(default=None)): require_admin(authorization) access_token = str(body.access_token or "").strip() - if not access_token: - raise HTTPException(status_code=400, detail={"error": "access_token is required"}) + identifier = _delete_identifiers([{"account_id": body.account_id, "row_id": body.row_id}]) + if not access_token and not identifier: + raise HTTPException(status_code=400, detail={"error": "access_token or identifier is required"}) updates = { key: value for key, value in { @@ -489,12 +500,16 @@ async def update_account(body: AccountUpdateRequest, authorization: str | None = "provider": body.provider, "status": body.status, "quota": body.quota, + "proxy": body.proxy, }.items() if value is not None } if not updates: raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"}) - account = account_service.update_account(access_token, updates, provider=body.target_provider) + if access_token: + account = account_service.update_account(access_token, updates, provider=body.target_provider) + else: + account = account_service.update_account_by_identifier(identifier[0], updates, provider=body.target_provider) if account is None: raise HTTPException(status_code=404, detail={"error": "account not found"}) return {"item": sanitize_account(account), "items": sanitize_accounts(account_service.list_accounts(provider=body.target_provider))} diff --git a/services/account_service.py b/services/account_service.py index 9a24905..022e821 100644 --- a/services/account_service.py +++ b/services/account_service.py @@ -387,6 +387,7 @@ def _normalize_account(self, item: dict) -> dict | None: normalized["success"] = int(normalized.get("success") or 0) normalized["fail"] = int(normalized.get("fail") or 0) normalized["last_used_at"] = normalized.get("last_used_at") + normalized["proxy"] = _clean_string(normalized.get("proxy")) return normalized def list_tokens(self, provider: str | None = None) -> list[str]: @@ -824,27 +825,40 @@ def delete_limited_accounts(self, provider: str | None = None) -> dict: items = self._list_account_items_locked(provider) return {"removed": removed, "items": items} + def _update_account_locked(self, access_token: str, updates: dict, provider: str | None = None) -> dict | None: + current = self._get_account_locked(access_token, provider) + if current is None: + return None + account = self._normalize_account({**current, **updates, "access_token": access_token}) + if account is None: + return None + if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts: + self._pop_account_locked(access_token, provider) + self._save_accounts() + log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)}) + return None + self._set_account_locked(account) + self._save_accounts() + log_service.add(LOG_TYPE_ACCOUNT, "更新账号", {"token": anonymize_token(access_token), "status": account.get("status")}) + return dict(account) + def update_account(self, access_token: str, updates: dict, provider: str | None = None) -> dict | None: if not access_token: return None with self._lock: - current = self._get_account_locked(access_token, provider) - if current is None: - return None - account = self._normalize_account({**current, **updates, "access_token": access_token}) - if account is None: - return None - if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts: - self._pop_account_locked(access_token, provider) - self._save_accounts() - log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)}) + return self._update_account_locked(access_token, updates, provider) + + def update_account_by_identifier(self, identifier: dict[str, str], updates: dict, provider: str | None = None) -> dict | None: + target_provider = self._provider_filter(provider) + if target_provider is None: + return None + with self._lock: + provider_accounts = self._accounts.get(target_provider, {}) + matched_tokens = _matched_account_tokens_by_identifiers([identifier], provider_accounts, target_provider) + if len(matched_tokens) != 1: return None - self._set_account_locked(account) - self._save_accounts() - log_service.add(LOG_TYPE_ACCOUNT, "更新账号", - {"token": anonymize_token(access_token), "status": account.get("status")}) - return dict(account) - return None + access_token = next(iter(matched_tokens)) + return self._update_account_locked(access_token, updates, target_provider) def mark_image_result(self, access_token: str, success: bool) -> dict | None: if not access_token: diff --git a/services/network/client.py b/services/network/client.py index 1364453..1c09d34 100644 --- a/services/network/client.py +++ b/services/network/client.py @@ -2,16 +2,17 @@ from typing import Any -def build_session_kwargs(*, impersonate: str | None = None, verify: bool = True, **session_kwargs: Any) -> dict[str, object]: + +def build_session_kwargs(*, impersonate: str | None = None, verify: bool = True, account: dict | None = None, **session_kwargs: Any) -> dict[str, object]: if impersonate: session_kwargs["impersonate"] = impersonate session_kwargs["verify"] = verify from services.proxy_service import proxy_settings - return proxy_settings.build_session_kwargs(**session_kwargs) + return proxy_settings.build_session_kwargs(account=account, **session_kwargs) -def create_session(*, impersonate: str | None = None, verify: bool = True, **session_kwargs: Any): +def create_session(*, impersonate: str | None = None, verify: bool = True, account: dict | None = None, **session_kwargs: Any): from curl_cffi import requests - return requests.Session(**build_session_kwargs(impersonate=impersonate, verify=verify, **session_kwargs)) + return requests.Session(**build_session_kwargs(impersonate=impersonate, verify=verify, account=account, **session_kwargs)) diff --git a/services/openai_backend_api.py b/services/openai_backend_api.py index 72f2413..141dc9c 100644 --- a/services/openai_backend_api.py +++ b/services/openai_backend_api.py @@ -79,6 +79,7 @@ def __init__(self, access_token: str = "") -> None: self.client_version = DEFAULT_CLIENT_VERSION self.client_build_number = DEFAULT_CLIENT_BUILD_NUMBER self.access_token = access_token + self.account = self._load_account() self.network_profile = self._build_network_profile() self.fp = self.network_profile.as_fingerprint() self.user_agent = self.fp["user-agent"] @@ -86,7 +87,7 @@ def __init__(self, access_token: str = "") -> None: self.session_id = self.fp["oai-session-id"] self.pow_script_sources: list[str] = [] self.pow_data_build = "" - self.session = create_session(impersonate=self.network_profile.impersonate, verify=self.network_profile.verify) + self.session = create_session(impersonate=self.network_profile.impersonate, verify=self.network_profile.verify, account=self.account) self.session.headers.update(build_chatgpt_web_headers( self.network_profile, base_url=self.base_url, @@ -105,9 +106,12 @@ def __enter__(self) -> "OpenAIBackendAPI": def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: self.close() - def _build_network_profile(self): + def _load_account(self) -> dict: account = account_service.get_account(self.access_token) if self.access_token else {} - account = account if isinstance(account, dict) else {} + return account if isinstance(account, dict) else {} + + def _build_network_profile(self): + account = self.account global_fp = config.data.get("chatgpt_fingerprint") global_fp = global_fp if isinstance(global_fp, dict) else {} return build_chatgpt_web_profile(account, global_fp) diff --git a/services/providers/gemini/accounts.py b/services/providers/gemini/accounts.py index 947d756..cd97ecf 100644 --- a/services/providers/gemini/accounts.py +++ b/services/providers/gemini/accounts.py @@ -289,7 +289,7 @@ def validate_remote_info(access_token: str, account: dict[str, Any] | None = Non if access_token: source.setdefault("access_token", access_token) cookie_header_value = account_cookie_header(source) - with GeminiWebClient(cookie_header_value, source.get("user_agent")) as client: + with GeminiWebClient(cookie_header_value, source.get("user_agent"), account=source) as client: client.rotate_psidts() session_token = client.bootstrap_session_token() return gemini_session_writeback(source, client.cookie_header, session_token) diff --git a/services/providers/gemini/client.py b/services/providers/gemini/client.py index 7144d4b..3ada1d0 100644 --- a/services/providers/gemini/client.py +++ b/services/providers/gemini/client.py @@ -180,6 +180,23 @@ def raise_unsupported_image_input() -> None: raise HTTPException(status_code=400, detail={"error": GEMINI_WEB_IMAGE_UNSUPPORTED_DETAIL}) +def contains_image_content(value: object) -> bool: + if isinstance(value, dict): + block_type = str(value.get("type") or "").strip() + if block_type in GEMINI_IMAGE_PART_TYPES: + return True + if any(key in value for key in GEMINI_IMAGE_PAYLOAD_KEYS): + return True + return any(contains_image_content(item) for item in value.values()) + if isinstance(value, list): + return any(contains_image_content(item) for item in value) + return False + + +def raise_unsupported_image_input() -> None: + raise HTTPException(status_code=400, detail={"error": GEMINI_WEB_IMAGE_UNSUPPORTED_DETAIL}) + + def message_text(content: object) -> str: if isinstance(content, str): return content @@ -459,11 +476,12 @@ def parse_web_response_text(raw_text: str) -> object: class GeminiWebClient: - def __init__(self, cookie_header: str, user_agent: str | None = None) -> None: + def __init__(self, cookie_header: str, user_agent: str | None = None, account: dict[str, Any] | None = None) -> None: self.cookie_header = cookie_header self.user_agent = user_agent or GEMINI_BROWSER_USER_AGENT self.session_token = "" - self.session = create_session() + self.account = account if isinstance(account, dict) else None + self.session = create_session(account=self.account) def __enter__(self) -> "GeminiWebClient": return self @@ -587,7 +605,7 @@ def fetch_authenticated_init_body() -> str: return "" account = account_service.get_account(access_token) or {"access_token": access_token, "provider": "gemini"} cookie_header = account_cookie_header(account) - with GeminiWebClient(cookie_header, account.get("user_agent")) as client: + with GeminiWebClient(cookie_header, account.get("user_agent"), account=account) as client: init_body = client.fetch_init_body() persist_gemini_session(account_service, access_token, account, client.cookie_header) return init_body @@ -614,7 +632,7 @@ def chat_completion(body: dict[str, Any], spec: ModelSpec, messages: list[dict[s if session_token: payload["session_token"] = session_token try: - with GeminiWebClient(cookie_header, account.get("user_agent")) as client: + with GeminiWebClient(cookie_header, account.get("user_agent"), account=account) as client: response_payload = client.generate(payload) persist_gemini_session(account_service, access_token, account, client.cookie_header, client.session_token) except GeminiWebError as exc: diff --git a/services/providers/grok/client.py b/services/providers/grok/client.py index 07b5f9c..f311155 100644 --- a/services/providers/grok/client.py +++ b/services/providers/grok/client.py @@ -668,7 +668,11 @@ class GrokConsoleClient: def __init__(self, access_token: str) -> None: self.access_token = access_token self.network_profile = _grok_console_profile() - self.session = create_session(impersonate=self.network_profile.impersonate, verify=self.network_profile.verify) + from services.account_service import account_service + + account = account_service.get_account(access_token, provider=GROK_PROVIDER) + account = account if isinstance(account, dict) else None + self.session = create_session(impersonate=self.network_profile.impersonate, verify=self.network_profile.verify, account=account) def close(self) -> None: self.session.close() @@ -1840,7 +1844,7 @@ def __init__(self, access_token: str, account: dict[str, Any] | None = None) -> self.account = account if isinstance(account, dict) else None self.network_profile = _grok_app_chat_profile() impersonate = _app_chat_impersonate(self.network_profile, self.account) - self.session = create_session(impersonate=impersonate, verify=self.network_profile.verify) + self.session = create_session(impersonate=impersonate, verify=self.network_profile.verify, account=self.account) def close(self) -> None: self.session.close() diff --git a/services/proxy_service.py b/services/proxy_service.py index c3587af..abf67b6 100644 --- a/services/proxy_service.py +++ b/services/proxy_service.py @@ -11,8 +11,15 @@ class ProxySettingsStore: - def build_session_kwargs(self, **session_kwargs) -> dict[str, object]: - proxy = config.get_proxy_settings() + def resolve_proxy(self, account: dict | None = None) -> str: + if isinstance(account, dict): + proxy = _clean(account.get("proxy")) + if proxy: + return proxy + return config.get_proxy_settings() + + def build_session_kwargs(self, *, account: dict | None = None, **session_kwargs) -> dict[str, object]: + proxy = self.resolve_proxy(account) if proxy: session_kwargs["proxy"] = proxy return session_kwargs diff --git a/web/src/app/accounts/components/account-import-dialog.tsx b/web/src/app/accounts/components/account-import-dialog.tsx index 12172cc..43ba79d 100644 --- a/web/src/app/accounts/components/account-import-dialog.tsx +++ b/web/src/app/accounts/components/account-import-dialog.tsx @@ -259,6 +259,7 @@ export function AccountImportDialog({ disabled, onImported }: AccountImportDialo const [tokenInput, setTokenInput] = useState(""); const [importProvider, setImportProvider] = useState("gpt"); const [sessionInput, setSessionInput] = useState(""); + const [importProxy, setImportProxy] = useState(""); const [geminiSecure1Psid, setGeminiSecure1Psid] = useState(""); const [geminiSecure1Psidts, setGeminiSecure1Psidts] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); @@ -276,6 +277,7 @@ export function AccountImportDialog({ disabled, onImported }: AccountImportDialo setTokenInput(""); setImportProvider("gpt"); setSessionInput(""); + setImportProxy(""); setGeminiSecure1Psid(""); setGeminiSecure1Psidts(""); setPendingCpaImport(null); @@ -321,12 +323,18 @@ export function AccountImportDialog({ disabled, onImported }: AccountImportDialo } }; - const buildTokenPayloads = (tokens: string[]): AccountImportPayload[] => { - if (importProvider === "grok") { - return []; - } + const withImportProxy = (payload: AccountImportPayload): AccountImportPayload => { + const proxy = importProxy.trim(); + return proxy ? { ...payload, proxy } : payload; + }; - return tokens.map((token) => ({ access_token: token, provider: importProvider })); + const buildTokenPayloads = (tokens: string[]): AccountImportPayload[] => { + return tokens.map((token) => { + const payload: AccountImportPayload = importProvider === "grok" + ? { sso: token, provider: importProvider } + : { access_token: token, provider: importProvider }; + return withImportProxy(payload); + }); }; const normalizeImportTokens = (tokens: string[]) => { @@ -344,12 +352,25 @@ export function AccountImportDialog({ disabled, onImported }: AccountImportDialo toast.error("Grok 每行仅支持裸 SSO 值或单个 sso=完整值;不支持 sso-rw、完整 Cookie header 或其他 name=value。"); }; + const renderImportProxyField = () => ( +
+ + setImportProxy(event.target.value)} + placeholder="留空使用全局代理,例如 http://127.0.0.1:7890" + className="h-11 rounded-xl border-stone-200 bg-white" + /> +

本次导入的账号会保存该代理;留空则使用系统全局代理。

+
+ ); + const buildGeminiSessionPayload = (secure1Psid: string, secure1Psidts: string): AccountImportPayload => { - return { + return withImportProxy({ provider: "gemini", "__Secure-1PSID": secure1Psid, "__Secure-1PSIDTS": secure1Psidts, - }; + }); }; const handleImportTokenText = async () => { @@ -469,7 +490,7 @@ export function AccountImportDialog({ disabled, onImported }: AccountImportDialo }), ); - const accounts = results.flatMap((item) => item.accounts); + const accounts = results.flatMap((item) => item.accounts).map(withImportProxy); const tokens = accounts.map((item) => item.access_token).filter((token): token is string => Boolean(token)); const parsedFileCount = results.filter((item) => item.accounts.length > 0).length; const errorCount = results.length - parsedFileCount; @@ -589,6 +610,7 @@ export function AccountImportDialog({ disabled, onImported }: AccountImportDialo className="min-h-48 resize-none rounded-xl border-stone-200" /> + {renderImportProxyField()}
@@ -637,6 +659,7 @@ export function AccountImportDialog({ disabled, onImported }: AccountImportDialo
风险提示
不要使用自己的大号,尽量使用不常用的小号进行导入,避免出现封号风险。本项目不承担任何封号风险责任。
+ {renderImportProxyField()}
{importProvider === "gemini" ? ( <> @@ -688,6 +711,7 @@ export function AccountImportDialog({ disabled, onImported }: AccountImportDialo 返回导入方式 + {renderImportProxyField()}
多选本地 JSON 文件
diff --git a/web/src/app/accounts/page.tsx b/web/src/app/accounts/page.tsx index cc30d6d..3dc3905 100644 --- a/web/src/app/accounts/page.tsx +++ b/web/src/app/accounts/page.tsx @@ -730,6 +730,7 @@ function AccountsPageContent() { const [pageSize] = useState("10"); const [editingAccount, setEditingAccount] = useState(null); const [editStatus, setEditStatus] = useState("正常"); + const [editProxy, setEditProxy] = useState(""); const [isLoading, setIsLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); const [isValidating, setIsValidating] = useState(false); @@ -940,6 +941,7 @@ function AccountsPageContent() { const openEditDialog = (account: Account) => { setEditingAccount(account); setEditStatus(account.status); + setEditProxy(String(account.proxy ?? "")); }; const handleUpdateAccount = async () => { @@ -949,17 +951,29 @@ function AccountsPageContent() { const provider = accountProviderId(editingAccount); const token = accountToken(editingAccount); - if (!token) { + const proxy = editProxy.trim(); + const statusChanged = editStatus !== editingAccount.status; + const proxyChanged = proxy !== String(editingAccount.proxy ?? "").trim(); + if (!token && statusChanged) { toast.error("脱敏账号不能在列表中直接编辑,请重新导入或通过后端管理接口处理"); return; } + if (!token && !proxyChanged) { + toast.error("还没有检测到改动,请修改后再保存"); + return; + } setIsUpdating(true); try { - const data = await updateAccount(token, { status: editStatus }, provider); + const data = await updateAccount(token, { + ...(token ? { status: editStatus } : {}), + proxy, + account_id: editingAccount.account_id, + row_id: editingAccount.row_id, + }, provider); handleProviderMutationResult(provider, data.items); setEditingAccount(null); - toast.success("账号状态已更新"); + toast.success("账号设置已更新"); } catch (error) { const message = error instanceof Error ? error.message : "更新账号失败"; toast.error(message); @@ -1035,7 +1049,7 @@ function AccountsPageContent() { (!open ? setEditingAccount(null) : null)}> - 编辑账户状态 + 编辑账户设置 当前账号归属 {editingAccount ? getAccountProviderLabel(editingAccount.provider) : ""};更新请求会按该服务商定位账号。 @@ -1057,6 +1071,16 @@ function AccountsPageContent() {
+
+ + setEditProxy(event.target.value)} + placeholder="留空使用全局代理,例如 http://127.0.0.1:7890" + className="h-11 rounded-xl border-stone-200 bg-white" + /> +

设置后该账号请求优先使用此代理;留空则使用系统全局代理。

+