From 56c7c418fdb96f23cb8bbf5d07a6e8d3423c87b3 Mon Sep 17 00:00:00 2001 From: baltic-tea Date: Tue, 9 Jun 2026 12:53:52 +0300 Subject: [PATCH 1/9] feat(core, service): add database storage and RPC routing for account-scoped proxies --- Cargo.lock | 73 ++ apps/src/app/accounts/accounts-page-view.tsx | 198 ++++- apps/src/lib/i18n/messages/ru.ts | 2 +- .../lib/i18n/messages/sections/en-accounts.ts | 2 +- .../lib/i18n/messages/sections/ko-accounts.ts | 2 +- .../lib/i18n/messages/sections/ru-accounts.ts | 199 +++-- .../migrations/069_account_proxy_settings.sql | 14 + .../src/storage/account_proxy_settings.rs | 207 +++++ crates/core/src/storage/accounts.rs | 5 +- crates/core/src/storage/mod.rs | 20 + crates/core/src/storage/model_sources.rs | 8 +- crates/core/tests/storage.rs | 154 ++++ crates/core/tests/storage/migration_tests.rs | 52 +- crates/service/Cargo.toml | 1 + crates/service/src/account/account_delete.rs | 1 + .../src/account/account_delete_many.rs | 1 + crates/service/src/account/account_proxy.rs | 766 ++++++++++++++++++ .../src/account/account_proxy_health.rs | 338 ++++++++ crates/service/src/account/mod.rs | 4 + .../src/gateway/auth/openai_fallback.rs | 10 +- .../src/gateway/core/runtime_config.rs | 312 ++++++- .../core/tests/runtime_config_tests.rs | 294 ++++++- crates/service/src/gateway/mod.rs | 1 + .../src/gateway/observability/request_log.rs | 1 - .../upstream/attempt_flow/transport.rs | 34 +- .../src/gateway/upstream/executor/codex.rs | 10 +- crates/service/src/lib.rs | 1 + crates/service/src/quota/read.rs | 16 +- crates/service/src/rpc_dispatch/account.rs | 23 +- crates/service/src/rpc_dispatch/apikey.rs | 5 +- crates/service/src/rpc_dispatch/mod.rs | 4 + crates/service/src/rpc_dispatch/quota.rs | 4 +- crates/service/src/rpc_dispatch/requestlog.rs | 4 +- crates/service/tests/app_settings.rs | 4 +- 34 files changed, 2622 insertions(+), 148 deletions(-) create mode 100644 crates/core/migrations/069_account_proxy_settings.sql create mode 100644 crates/core/src/storage/account_proxy_settings.rs create mode 100644 crates/service/src/account/account_proxy.rs create mode 100644 crates/service/src/account/account_proxy_health.rs diff --git a/Cargo.lock b/Cargo.lock index 3eaaa14a8..d36e9dd48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,6 +309,7 @@ dependencies = [ "log", "os_info", "rand 0.8.5", + "rcgen", "reqwest", "rhai", "rusqlite", @@ -531,6 +532,15 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -1432,6 +1442,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -1684,6 +1700,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1732,6 +1758,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1905,6 +1937,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "regex" version = "1.12.3" @@ -2446,6 +2491,25 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -3306,6 +3370,15 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/apps/src/app/accounts/accounts-page-view.tsx b/apps/src/app/accounts/accounts-page-view.tsx index 36c516cf2..b841e1660 100644 --- a/apps/src/app/accounts/accounts-page-view.tsx +++ b/apps/src/app/accounts/accounts-page-view.tsx @@ -12,6 +12,7 @@ import { KeyRound, Loader2, MoreVertical, + Network, PencilLine, Pin, Plus, @@ -56,6 +57,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; import { Table, TableBody, @@ -73,6 +75,7 @@ import { import { useI18n } from "@/lib/i18n/provider"; import { cn } from "@/lib/utils"; import { formatCompactNumber } from "@/lib/utils/usage"; +import type { AccountProxySettings } from "@/lib/api/account-client"; import type { Account } from "@/types"; import { type AccountEditorState, @@ -135,6 +138,11 @@ export interface AccountsPageViewProps { cleanupDialogOpen: boolean; cleanupStatusDraft: string[]; cleanupStatusOptions: CleanupStatusOption[]; + proxyDialogAccount: Account | null; + proxySettings: AccountProxySettings | null; + isProxySettingsLoading: boolean; + proxyEnabledDraft: boolean; + proxyUrlDraft: string; selectedAccount: Account | null; accountEditorState: AccountEditorState | null; deleteDialogState: DeleteDialogState; @@ -155,6 +163,9 @@ export interface AccountsPageViewProps { isDeletingMany: boolean; isCleaningAccountsByStatus: boolean; isUpdatingPreferred: boolean; + isSavingAccountProxy: boolean; + isClearingAccountProxy: boolean; + isTestingAccountProxy: boolean; isReorderingAccounts: boolean; isUpdatingProfileAccountId: string | null; isUpdatingStatusAccountId: string | null; @@ -168,6 +179,8 @@ export interface AccountsPageViewProps { setExportModeDraft: Dispatch>; setDeleteDialogState: Dispatch>; setCleanupDialogOpen: Dispatch>; + setProxyEnabledDraft: Dispatch>; + setProxyUrlDraft: Dispatch>; setAccountEditorState: Dispatch>; setLabelDraft: Dispatch>; setTagsDraft: Dispatch>; @@ -193,6 +206,11 @@ export interface AccountsPageViewProps { openExportDialog: () => void; handleConfirmExport: () => Promise; handleDeleteSingle: (account: Account) => void; + openProxyDialog: (account: Account) => void; + handleProxyDialogOpenChange: (open: boolean) => void; + handleSaveProxySettings: () => Promise; + handleClearProxySettings: () => Promise; + handleTestProxySettings: () => Promise; openAccountEditor: (account: Account) => void; handleMoveAccount: ( account: Account, @@ -244,6 +262,11 @@ export function AccountsPageView(props: AccountsPageViewProps) { cleanupDialogOpen, cleanupStatusDraft, cleanupStatusOptions, + proxyDialogAccount, + proxySettings, + isProxySettingsLoading, + proxyEnabledDraft, + proxyUrlDraft, selectedAccount, accountEditorState, deleteDialogState, @@ -264,6 +287,9 @@ export function AccountsPageView(props: AccountsPageViewProps) { isDeletingMany, isCleaningAccountsByStatus, isUpdatingPreferred, + isSavingAccountProxy, + isClearingAccountProxy, + isTestingAccountProxy, isReorderingAccounts, isUpdatingProfileAccountId, isUpdatingStatusAccountId, @@ -277,6 +303,8 @@ export function AccountsPageView(props: AccountsPageViewProps) { setExportModeDraft, setDeleteDialogState, setCleanupDialogOpen, + setProxyEnabledDraft, + setProxyUrlDraft, setAccountEditorState, setLabelDraft, setTagsDraft, @@ -302,6 +330,11 @@ export function AccountsPageView(props: AccountsPageViewProps) { openExportDialog, handleConfirmExport, handleDeleteSingle, + openProxyDialog, + handleProxyDialogOpenChange, + handleSaveProxySettings, + handleClearProxySettings, + handleTestProxySettings, openAccountEditor, handleMoveAccount, handleApplyAccountSizeSort, @@ -323,6 +356,35 @@ export function AccountsPageView(props: AccountsPageViewProps) { cleanupStatusDraft.includes(option.id) ? total + option.count : total, 0, ); + const accountProxyBusy = + isProxySettingsLoading || + isSavingAccountProxy || + isClearingAccountProxy || + isTestingAccountProxy; + const accountProxyStatusText = (() => { + const status = String(proxySettings?.status || "not_configured"); + switch (status) { + case "ok": + return t("可用"); + case "runtime_error": + return t("运行时错误"); + case "failed": + return t("测试失败"); + case "invalid_url": + return t("地址无效"); + case "checking": + return t("测试中"); + case "unchecked": + return t("未测试"); + case "not_configured": + default: + return t("未配置"); + } + })(); + const accountProxyLastCheckText = + proxySettings?.lastCheckAt != null + ? new Date(proxySettings.lastCheckAt * 1000).toLocaleString() + : t("从未检查"); return (
@@ -1040,6 +1102,14 @@ export function AccountsPageView(props: AccountsPageViewProps) { {account.preferred ? t("取消优先") : t("设为优先")} + void openProxyDialog(account)} + > + + {t("账号代理")} +
- {addAccountModalOpen ? ( +{addAccountModalOpen ? ( + + + + {t("账号代理")} + + {proxyDialogAccount + ? proxyDialogAccount.name + : t("为单个 OpenAI 账号配置本地代理。")} + + +
+
+
+ +

+ {t("启用后,该账号会优先使用这里的代理地址。")} +

+
+ setProxyEnabledDraft(Boolean(value))} + /> +
+
+ +
+ setProxyUrlDraft(event.target.value)} + placeholder="http://127.0.0.1:7891" + /> + +
+

+ {t("支持 http、https、socks4、socks5;sing-box mixed inbound 通常填写 http://127.0.0.1:端口。")} +

+

+ {t("建议登录、刷新、用量和 API 请求保持同一代理与地区,以降低账号风控和状态漂移。")} +

+
+
+
+
{t("测试状态")}
+
{accountProxyStatusText}
+
+
+
{t("最近检查")}
+
{accountProxyLastCheckText}
+
+
+
{t("延迟")}
+
+ {proxySettings?.latencyMs != null + ? `${proxySettings.latencyMs} ms` + : "--"} +
+
+
+
{t("当前模式")}
+
+ {proxySettings?.enabled ? t("账号代理") : t("默认路由")} +
+
+ {proxySettings?.lastError ? ( +
+
{t("错误")}
+
+ {proxySettings.lastError} +
+
+ ) : null} +
+
+ + + {t("关闭")} + + + + +
+
{ diff --git a/apps/src/lib/i18n/messages/ru.ts b/apps/src/lib/i18n/messages/ru.ts index ef77ca0e3..ba35c1360 100644 --- a/apps/src/lib/i18n/messages/ru.ts +++ b/apps/src/lib/i18n/messages/ru.ts @@ -28,7 +28,7 @@ export const RU_MESSAGES: MessageCatalog = { 账号设置: "Аккаунт", 号池管理: "Пул аккаунтов", 账号管理: "Аккаунты", - "OpenAI 账号池": "Пул OpenAI", + "OpenAI 账号池": "Пул аккаунтов", "聚合 API": "Агрегированные API", 聚合API: "Агрегированные API", 平台模型目录: "Каталог моделей", diff --git a/apps/src/lib/i18n/messages/sections/en-accounts.ts b/apps/src/lib/i18n/messages/sections/en-accounts.ts index 0797d49b5..e86ce9035 100644 --- a/apps/src/lib/i18n/messages/sections/en-accounts.ts +++ b/apps/src/lib/i18n/messages/sections/en-accounts.ts @@ -3,7 +3,7 @@ import type { MessageCatalog } from "../types"; export const EN_ACCOUNTS_MESSAGES: MessageCatalog = { - "5h 容量覆盖(Token)": "5h capacity override (tokens)", +"5h 容量覆盖(Token)": "5h capacity override (tokens)", "7d 容量覆盖(Token)": "7d capacity override (tokens)", "AT/RT 刷新中...": "Refreshing AT/RT...", "AT/RT 刷新完成:成功{success}个,失败{failed}个,跳过{skipped}个": diff --git a/apps/src/lib/i18n/messages/sections/ko-accounts.ts b/apps/src/lib/i18n/messages/sections/ko-accounts.ts index a11e1fb30..629830313 100644 --- a/apps/src/lib/i18n/messages/sections/ko-accounts.ts +++ b/apps/src/lib/i18n/messages/sections/ko-accounts.ts @@ -3,7 +3,7 @@ import type { MessageCatalog } from "../types"; export const KO_ACCOUNTS_MESSAGES: MessageCatalog = { - "5h 容量覆盖(Token)": "5h 용량 오버라이드(Token)", +"5h 容量覆盖(Token)": "5h 용량 오버라이드(Token)", "7d 容量覆盖(Token)": "7d 용량 오버라이드(Token)", "AT/RT 刷新中...": "AT/RT 새로고침 중...", "AT/RT 刷新完成:成功{success}个,失败{failed}个,跳过{skipped}个": diff --git a/apps/src/lib/i18n/messages/sections/ru-accounts.ts b/apps/src/lib/i18n/messages/sections/ru-accounts.ts index e39fe981c..a573b6af8 100644 --- a/apps/src/lib/i18n/messages/sections/ru-accounts.ts +++ b/apps/src/lib/i18n/messages/sections/ru-accounts.ts @@ -3,80 +3,127 @@ import type { MessageCatalog } from "../types"; export const RU_ACCOUNTS_MESSAGES: MessageCatalog = { - "5h 容量覆盖(Token)": "Переопределение емкости 5h (токены)", - "7d 容量覆盖(Token)": "Переопределение емкости 7d (токены)", - "AT/RT 刷新中...": "Обновление AT/RT...", - "AT/RT 刷新完成:成功{success}个,失败{failed}个,跳过{skipped}个": - "Обновление AT/RT завершено: успешно {success}, ошибок {failed}, пропущено {skipped}", - "AT/RT 刷新完成:成功{success}个,失败{failed}个,跳过{skipped}个;首个失败:{message}": - "Обновление AT/RT завершено: успешно {success}, ошибок {failed}, пропущено {skipped}. Первая ошибка: {message}", - "AT/RT 刷新完成:成功{success}个,跳过{skipped}个": - "Обновление AT/RT завершено: успешно {success}, пропущено {skipped}", - "AT/RT 过期、用量接口 401/403 等不可用账号": - "Недоступные аккаунты: истекшие AT/RT, usage API 401/403 и подобные случаи", - "Refresh Token 失效,需要重新登录": "Refresh Token недействителен. Войдите снова.", - "Refresh Token 已被撤销,需要重新登录": "Refresh Token отозван. Войдите снова.", - "Refresh Token 已被重复使用,需要重新登录": - "Refresh Token был повторно использован. Войдите снова.", - "Refresh Token 已过期,需要重新登录": "Refresh Token истек. Войдите снова.", - "Refresh Token 授权无效,需要重新登录": - "Авторизация Refresh Token недействительна. Войдите снова.", - "仅用于额度池统计归属;留空表示该账号对全部 API 可用模型生效。": - "Используется только для статистики принадлежности пула квот. Оставьте пустым, чтобы аккаунт действовал для всех API-доступных моделей.", - "代理地区不受支持,已暂停账号刷新": - "Регион прокси не поддерживается. Обновление аккаунта приостановлено.", - "刷新 AT/RT": "Обновить AT/RT", - "刷新 AT/RT 失败": "Не удалось обновить AT/RT", - "刷新全部 AT/RT": "Обновить все AT/RT", - "刷新用量": "Обновить usage", - "刷新登录凭证返回 401,需要重新登录": - "Обновление учетных данных вернуло 401. Войдите снова.", - "原因码": "Код причины", - "容量覆盖": "Переопределение емкости", - "当前没有匹配所选状态的账号": "Нет аккаунтов с выбранными статусами", - "当前没有可清理的账号": "Нет аккаунтов для очистки", - "工作区已停用": "Рабочая область деактивирована", - "将删除所有匹配所选状态的账号,不再额外限制账号套餐。": - "Будут удалены все аккаунты с выбранными статусами без дополнительного ограничения по тарифу аккаунта.", - "已清理 {count} 个账号": "Очищено аккаунтов: {count}", - "手动停用或旧版本标记的账号": - "Аккаунты, отключенные вручную или отмеченные старыми версиями", - "手动禁用的账号": "Аккаунты, отключенные вручную", - "批量刷新 AT/RT 失败": "Не удалось массово обновить AT/RT", - "按状态清理账号": "Очистить аккаунты по статусу", - "明确触发 usage_limit_reached 的账号,不包含低额度账号": - "Аккаунты, явно вызвавшие usage_limit_reached, без аккаунтов с низкой квотой", - "更多账号操作": "Больше действий с аккаунтом", - "未发现可清理的账号": "Аккаунты для очистки не найдены", - "未设置账号容量覆盖": "Переопределение емкости аккаунта не задано", - "状态原因": "Причина статуса", - "用量接口返回 401,账号授权失效": - "Usage API вернул 401. Авторизация аккаунта недействительна.", - "用量接口返回 403,账号权限不足或被限制": - "Usage API вернул 403. Недостаточно прав или аккаунт ограничен.", - "用量接口返回 HTTP {status}": "Usage API вернул HTTP {status}", - "用量限制": "Лимит usage", - "留空使用计划模板": "Оставьте пустым, чтобы использовать шаблон плана", - "的名称、标签、备注、排序与额度池配置。": - ": имя, теги, заметки, сортировка и настройки пула квот.", - "确认清理": "Подтвердить очистку", - "缺少授权 Token": "Отсутствует auth token", - "账号 AT/RT 已刷新": "AT/RT аккаунта обновлены", - "账号已停用": "Аккаунт деактивирован", - "账号或工作区被停用的账号": "Аккаунты, где деактивирован аккаунт или рабочая область", - "状态字段为 unknown 的账号": "Аккаунты со статусом unknown", - "该账号只有用量快照,当前不能参与模型刷新或网关转发。请重新登录或刷新 AT/RT 后再使用。": - "У этого аккаунта есть только снимок usage, поэтому сейчас он не может участвовать в обновлении моделей или gateway forwarding. Войдите снова или обновите AT/RT перед использованием.", - "请选择至少一种账号状态": "Выберите хотя бы один статус аккаунта", - "请至少选择一种账号状态": "Выберите хотя бы один статус аккаунта", - "选择导出方式;如果已勾选账号,则只导出当前选中项。": - "Выберите способ экспорта. Если аккаунты отмечены, будут экспортированы только выбранные.", - "选择要删除的账号状态;删除后不可恢复。": - "Выберите статусы аккаунтов для удаления. Удаление нельзя отменить.", - "这里展示账号套餐接口同步回来的套餐状态与时间信息。": - "Здесь показаны статус тарифа и время, синхронизированные из API тарифов аккаунта.", - "额度容量必须是大于 0 的数字,留空表示未覆盖": - "Емкость квоты должна быть числом больше 0. Оставьте пустым, чтобы не переопределять.", - "额度已耗尽": "Квота исчерпана", - "预计删除": "Ожидается удалить", + "5h 容量覆盖(Token)": "Переопределение емкости 5h (токены)", + "7d 容量覆盖(Token)": "Переопределение емкости 7d (токены)", + "AT/RT 刷新中...": "Обновление AT/RT...", + "AT/RT 刷新完成:成功{success}个,失败{failed}个,跳过{skipped}个": + "Обновление AT/RT завершено: успешно {success}, ошибок {failed}, пропущено {skipped}", + "AT/RT 刷新完成:成功{success}个,失败{failed}个,跳过{skipped}个;首个失败:{message}": + "Обновление AT/RT завершено: успешно {success}, ошибок {failed}, пропущено {skipped}. Первая ошибка: {message}", + "AT/RT 刷新完成:成功{success}个,跳过{skipped}个": + "Обновление AT/RT завершено: успешно {success}, пропущено {skipped}", + "AT/RT 过期、用量接口 401/403 等不可用账号": + "Недоступные аккаунты: истекшие AT/RT, usage API 401/403 и подобные случаи", + "Refresh Token 失效,需要重新登录": + "Refresh Token недействителен. Войдите снова.", + "Refresh Token 已被撤销,需要重新登录": + "Refresh Token отозван. Войдите снова.", + "Refresh Token 已被重复使用,需要重新登录": + "Refresh Token был повторно использован. Войдите снова.", + "Refresh Token 已过期,需要重新登录": "Refresh Token истек. Войдите снова.", + "Refresh Token 授权无效,需要重新登录": + "Авторизация Refresh Token недействительна. Войдите снова.", + "仅用于额度池统计归属;留空表示该账号对全部 API 可用模型生效。": + "Используется только для статистики принадлежности пула квот. Оставьте пустым, чтобы аккаунт действовал для всех API-доступных моделей.", + "代理地区不受支持,已暂停账号刷新": + "Регион прокси не поддерживается. Обновление аккаунта приостановлено.", + 保存账号代理: "Сохранить прокси аккаунта", + 保存账号代理失败: "Не удалось сохранить прокси аккаунта", + 测试账号代理: "Проверить прокси аккаунта", + 测试账号代理失败: "Не удалось проверить прокси аккаунта", + 测试: "Проверить", + + 测试中: "Проверяется", + 未测试: "Не проверено", + 测试失败: "Проверка не пройдена", + 测试状态: "Статус проверки", + 可用: "Доступен", + 运行时错误: "Ошибка выполнения", + 从未检查: "Никогда не проверялось", + 代理地址: "Адрес прокси", + 地址无效: "Некорректный адрес", + 未配置: "Не настроен", + "刷新 AT/RT": "Обновить AT/RT", + "刷新 AT/RT 失败": "Не удалось обновить AT/RT", + "刷新全部 AT/RT": "Обновить все AT/RT", + 刷新用量: "Обновить usage", + "刷新登录凭证返回 401,需要重新登录": + "Обновление учетных данных вернуло 401. Войдите снова.", + 读取账号代理: "Прочитать прокси аккаунта", + 读取账号代理失败: "Не удалось прочитать прокси аккаунта", + "服务未连接,暂时无法读取账号代理": + "Сервис не подключен, прокси аккаунта пока нельзя прочитать", + 保存: "Сохранить", + 默认路由: "Маршрут по умолчанию", + 当前模式: "Текущий режим", + 清除: "Очистить", + 错误: "Ошибка", + "启用后,该账号会优先使用这里的代理地址。": + "Если включено, этот аккаунт будет сначала использовать этот адрес прокси.", + 启用账号代理: "Включить прокси аккаунта", + 清除账号代理: "Очистить прокси аккаунта", + 清除账号代理失败: "Не удалось очистить прокси аккаунта", + "为单个 OpenAI 账号配置本地代理。": + "Настройте локальный прокси для одного аккаунта OpenAI.", + 延迟: "Задержка", + 原因码: "Код причины", + 容量覆盖: "Переопределение емкости", + 当前没有匹配所选状态的账号: "Нет аккаунтов с выбранными статусами", + 当前没有可清理的账号: "Нет аккаунтов для очистки", + 工作区已停用: "Рабочая область деактивирована", + "将删除所有匹配所选状态的账号,不再额外限制账号套餐。": + "Будут удалены все аккаунты с выбранными статусами без дополнительного ограничения по тарифу аккаунта.", + "已清理 {count} 个账号": "Очищено аккаунтов: {count}", + 手动停用或旧版本标记的账号: + "Аккаунты, отключенные вручную или отмеченные старыми версиями", + 手动禁用的账号: "Аккаунты, отключенные вручную", + "批量刷新 AT/RT 失败": "Не удалось массово обновить AT/RT", + 按状态清理账号: "Очистить аккаунты по статусу", + "明确触发 usage_limit_reached 的账号,不包含低额度账号": + "Аккаунты, явно вызвавшие usage_limit_reached, без аккаунтов с низкой квотой", + 更多账号操作: "Больше действий с аккаунтом", + 未发现可清理的账号: "Аккаунты для очистки не найдены", + 未设置账号容量覆盖: "Переопределение емкости аккаунта не задано", + 状态原因: "Причина статуса", + "用量接口返回 401,账号授权失效": + "Usage API вернул 401. Авторизация аккаунта недействительна.", + "用量接口返回 403,账号权限不足或被限制": + "Usage API вернул 403. Недостаточно прав или аккаунт ограничен.", + "用量接口返回 HTTP {status}": "Usage API вернул HTTP {status}", + 用量限制: "Лимит usage", + 留空使用计划模板: "Оставьте пустым, чтобы использовать шаблон плана", + "的名称、标签、备注、排序与额度池配置。": + ": имя, теги, заметки, сортировка и настройки пула квот.", + 确认清理: "Подтвердить очистку", + "缺少授权 Token": "Отсутствует auth token", + "账号 AT/RT 已刷新": "AT/RT аккаунта обновлены", + 账号代理: "Прокси аккаунта", + 账号代理测试通过: "Прокси аккаунта прошел проверку", + 账号代理测试未通过: "Прокси аккаунта не прошел проверку", + 账号代理已保存: "Прокси аккаунта сохранен", + 账号代理已关闭: "Прокси аккаунта отключен", + 账号代理已清除: "Прокси аккаунта очищен", + 账号已停用: "Аккаунт деактивирован", + 账号或工作区被停用的账号: + "Аккаунты, где деактивирован аккаунт или рабочая область", + "状态字段为 unknown 的账号": "Аккаунты со статусом unknown", + "该账号只有用量快照,当前不能参与模型刷新或网关转发。请重新登录或刷新 AT/RT 后再使用。": + "У этого аккаунта есть только снимок usage, поэтому сейчас он не может участвовать в обновлении моделей или gateway forwarding. Войдите снова или обновите AT/RT перед использованием.", + 请选择至少一种账号状态: "Выберите хотя бы один статус аккаунта", + 请至少选择一种账号状态: "Выберите хотя бы один статус аккаунта", + "选择导出方式;如果已勾选账号,则只导出当前选中项。": + "Выберите способ экспорта. Если аккаунты отмечены, будут экспортированы только выбранные.", + "选择要删除的账号状态;删除后不可恢复。": + "Выберите статусы аккаунтов для удаления. Удаление нельзя отменить.", + "建议登录、刷新、用量和 API 请求保持同一代理与地区,以降低账号风控和状态漂移。": + "Рекомендуется использовать один и тот же прокси и регион для входа, refresh, usage и API-запросов, чтобы снизить риск блокировок и рассинхронизации состояния аккаунта.", + "支持 http、https、socks4、socks5;sing-box mixed inbound 通常填写 http://127.0.0.1:端口。": + "Поддерживаются http, https, socks4 и socks5. Для sing-box mixed inbound обычно указывают http://127.0.0.1:port.", + 最近检查: "Последняя проверка", + "这里展示账号套餐接口同步回来的套餐状态与时间信息。": + "Здесь показаны статус тарифа и время, синхронизированные из API тарифов аккаунта.", + "额度容量必须是大于 0 的数字,留空表示未覆盖": + "Емкость квоты должна быть числом больше 0. Оставьте пустым, чтобы не переопределять.", + 额度已耗尽: "Квота исчерпана", + 预计删除: "Ожидается удалить", }; diff --git a/crates/core/migrations/069_account_proxy_settings.sql b/crates/core/migrations/069_account_proxy_settings.sql new file mode 100644 index 000000000..e5d701596 --- /dev/null +++ b/crates/core/migrations/069_account_proxy_settings.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS account_proxy_settings ( + account_id TEXT PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE, + enabled INTEGER NOT NULL DEFAULT 0, + proxy_url TEXT, + status TEXT NOT NULL DEFAULT 'unchecked', + latency_ms INTEGER, + last_check_at INTEGER, + last_error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_account_proxy_settings_updated_at + ON account_proxy_settings(updated_at DESC, account_id ASC); diff --git a/crates/core/src/storage/account_proxy_settings.rs b/crates/core/src/storage/account_proxy_settings.rs new file mode 100644 index 000000000..d70bc855d --- /dev/null +++ b/crates/core/src/storage/account_proxy_settings.rs @@ -0,0 +1,207 @@ +use rusqlite::{Result, Row}; + +use super::{now_ts, AccountProxySettings, Storage}; + +impl Storage { + pub fn upsert_account_proxy_settings( + &self, + account_id: &str, + enabled: bool, + proxy_url: Option<&str>, + status: &str, + latency_ms: Option, + last_check_at: Option, + last_error: Option<&str>, + ) -> Result<()> { + let now = now_ts(); + let created_at = self + .find_account_proxy_settings(account_id)? + .map(|settings| settings.created_at) + .unwrap_or(now); + + self.conn.execute( + "INSERT INTO account_proxy_settings ( + account_id, + enabled, + proxy_url, + status, + latency_ms, + last_check_at, + last_error, + created_at, + updated_at + ) VALUES ( + ?1, + ?2, + ?3, + ?4, + ?5, + ?6, + ?7, + ?8, + ?9 + ) + ON CONFLICT(account_id) DO UPDATE SET + enabled = excluded.enabled, + proxy_url = excluded.proxy_url, + status = excluded.status, + latency_ms = excluded.latency_ms, + last_check_at = excluded.last_check_at, + last_error = excluded.last_error, + updated_at = excluded.updated_at", + ( + account_id, + if enabled { 1 } else { 0 }, + normalize_optional_text(proxy_url), + normalize_status(status), + latency_ms, + last_check_at, + normalize_optional_text(last_error), + created_at, + now, + ), + )?; + Ok(()) + } + + pub fn update_account_proxy_check_status( + &self, + account_id: &str, + status: &str, + latency_ms: Option, + last_check_at: Option, + last_error: Option<&str>, + ) -> Result<()> { + self.conn.execute( + "UPDATE account_proxy_settings + SET status = ?2, + latency_ms = ?3, + last_check_at = ?4, + last_error = ?5, + updated_at = ?6 + WHERE account_id = ?1", + ( + account_id, + normalize_status(status), + latency_ms, + last_check_at, + normalize_optional_text(last_error), + now_ts(), + ), + )?; + Ok(()) + } + + pub fn clear_account_proxy_settings(&self, account_id: &str) -> Result<()> { + self.conn.execute( + "DELETE FROM account_proxy_settings WHERE account_id = ?1", + [account_id], + )?; + Ok(()) + } + + pub fn find_account_proxy_settings( + &self, + account_id: &str, + ) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT account_id, enabled, proxy_url, status, latency_ms, last_check_at, last_error, created_at, updated_at + FROM account_proxy_settings + WHERE account_id = ?1 + LIMIT 1", + )?; + let mut rows = stmt.query([account_id])?; + if let Some(row) = rows.next()? { + Ok(Some(map_account_proxy_settings_row(row)?)) + } else { + Ok(None) + } + } + + pub fn list_account_proxy_settings(&self) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT account_id, enabled, proxy_url, status, latency_ms, last_check_at, last_error, created_at, updated_at + FROM account_proxy_settings + ORDER BY updated_at DESC, account_id ASC", + )?; + let mut rows = stmt.query([])?; + let mut out = Vec::new(); + while let Some(row) = rows.next()? { + out.push(map_account_proxy_settings_row(row)?); + } + Ok(out) + } + + pub(super) fn ensure_account_proxy_settings_table(&self) -> Result<()> { + self.conn.execute_batch( + "CREATE TABLE IF NOT EXISTS account_proxy_settings ( + account_id TEXT PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE, + enabled INTEGER NOT NULL DEFAULT 0, + proxy_url TEXT, + status TEXT NOT NULL DEFAULT 'unchecked', + latency_ms INTEGER, + last_check_at INTEGER, + last_error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_account_proxy_settings_updated_at + ON account_proxy_settings(updated_at DESC, account_id ASC);", + )?; + self.ensure_column( + "account_proxy_settings", + "enabled", + "INTEGER NOT NULL DEFAULT 0", + )?; + self.ensure_column("account_proxy_settings", "proxy_url", "TEXT")?; + self.ensure_column( + "account_proxy_settings", + "status", + "TEXT NOT NULL DEFAULT 'unchecked'", + )?; + self.ensure_column("account_proxy_settings", "latency_ms", "INTEGER")?; + self.ensure_column("account_proxy_settings", "last_check_at", "INTEGER")?; + self.ensure_column("account_proxy_settings", "last_error", "TEXT")?; + self.ensure_column( + "account_proxy_settings", + "created_at", + "INTEGER NOT NULL DEFAULT 0", + )?; + self.ensure_column( + "account_proxy_settings", + "updated_at", + "INTEGER NOT NULL DEFAULT 0", + )?; + Ok(()) + } +} + +fn normalize_optional_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(ToString::to_string) +} + +fn normalize_status(value: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + "unchecked".to_string() + } else { + trimmed.to_string() + } +} + +fn map_account_proxy_settings_row(row: &Row<'_>) -> Result { + Ok(AccountProxySettings { + account_id: row.get(0)?, + enabled: row.get::<_, i64>(1)? != 0, + proxy_url: row.get(2)?, + status: row.get(3)?, + latency_ms: row.get(4)?, + last_check_at: row.get(5)?, + last_error: row.get(6)?, + created_at: row.get(7)?, + updated_at: row.get(8)?, + }) +} diff --git a/crates/core/src/storage/accounts.rs b/crates/core/src/storage/accounts.rs index 632873b87..ff2a7db6f 100644 --- a/crates/core/src/storage/accounts.rs +++ b/crates/core/src/storage/accounts.rs @@ -359,6 +359,10 @@ impl Storage { "DELETE FROM account_subscriptions WHERE account_id = ?1", [account_id], )?; + tx.execute( + "DELETE FROM account_proxy_settings WHERE account_id = ?1", + [account_id], + )?; tx.execute("DELETE FROM tokens WHERE account_id = ?1", [account_id])?; tx.execute( "DELETE FROM usage_snapshots WHERE account_id = ?1", @@ -550,7 +554,6 @@ impl Storage { } Ok(out) } - } /// 函数 `normalize_optional_filter` diff --git a/crates/core/src/storage/mod.rs b/crates/core/src/storage/mod.rs index 8f3d0072a..d7ebeff0b 100644 --- a/crates/core/src/storage/mod.rs +++ b/crates/core/src/storage/mod.rs @@ -5,6 +5,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; mod account_manager; mod account_metadata; +mod account_proxy_settings; mod account_subscriptions; mod accounts; mod aggregate_apis; @@ -59,6 +60,19 @@ pub struct AccountSubscription { pub updated_at: i64, } +#[derive(Debug, Clone)] +pub struct AccountProxySettings { + pub account_id: String, + pub enabled: bool, + pub proxy_url: Option, + pub status: String, + pub latency_ms: Option, + pub last_check_at: Option, + pub last_error: Option, + pub created_at: i64, + pub updated_at: i64, +} + #[derive(Debug, Clone)] pub struct QuotaSourceModelAssignment { pub source_kind: String, @@ -1044,6 +1058,11 @@ impl Storage { include_str!("../../migrations/068_request_logs_route_strategy_source.sql"), |s| s.ensure_request_log_route_strategy_columns(), )?; + self.apply_sql_or_compat_migration( + "069_account_proxy_settings", + include_str!("../../migrations/069_account_proxy_settings.sql"), + |s| s.ensure_account_proxy_settings_table(), + )?; self.ensure_api_key_rotation_columns()?; self.ensure_aggregate_apis_table()?; self.ensure_aggregate_api_supplier_model_tables()?; @@ -1061,6 +1080,7 @@ impl Storage { self.ensure_request_log_route_detail_columns()?; self.ensure_model_catalog_models_table()?; self.ensure_account_subscriptions_table()?; + self.ensure_account_proxy_settings_table()?; self.ensure_quota_pool_tables()?; self.ensure_account_manager_tables()?; self.ensure_model_source_tables()?; diff --git a/crates/core/src/storage/model_sources.rs b/crates/core/src/storage/model_sources.rs index 3a9245776..755e2d93d 100644 --- a/crates/core/src/storage/model_sources.rs +++ b/crates/core/src/storage/model_sources.rs @@ -379,7 +379,13 @@ impl Storage { ON CONFLICT(source_kind, source_id, upstream_model) DO UPDATE SET preference = excluded.preference, updated_at = excluded.updated_at", - params![&source_kind, &source_id, &upstream_model, "unlinked", now_ts()], + params![ + &source_kind, + &source_id, + &upstream_model, + "unlinked", + now_ts() + ], )?; tx.execute( "DELETE FROM model_source_mappings WHERE id = ?1", diff --git a/crates/core/tests/storage.rs b/crates/core/tests/storage.rs index 817a5a8ec..b89392a02 100644 --- a/crates/core/tests/storage.rs +++ b/crates/core/tests/storage.rs @@ -114,6 +114,160 @@ fn storage_can_find_token_and_account_by_account_id() { .is_none()); } +#[test] +fn storage_can_persist_account_proxy_settings() { + let storage = Storage::open_in_memory().expect("open in memory"); + storage.init().expect("init schema"); + let now = now_ts(); + + storage + .insert_account(&Account { + id: "acc-proxy-1".to_string(), + label: "proxy".to_string(), + issuer: "https://auth.openai.com".to_string(), + chatgpt_account_id: None, + workspace_id: None, + group_name: None, + sort: 0, + status: "active".to_string(), + created_at: now, + updated_at: now, + }) + .expect("insert account"); + + storage + .upsert_account_proxy_settings( + "acc-proxy-1", + true, + Some("http://127.0.0.1:7891"), + "unchecked", + None, + None, + None, + ) + .expect("upsert proxy settings"); + + let stored = storage + .find_account_proxy_settings("acc-proxy-1") + .expect("find proxy settings") + .expect("proxy settings exist"); + assert!(stored.enabled); + assert_eq!(stored.proxy_url.as_deref(), Some("http://127.0.0.1:7891")); + assert_eq!(stored.status, "unchecked"); + assert_eq!(stored.latency_ms, None); + assert_eq!(stored.last_check_at, None); + assert_eq!(stored.last_error, None); + + let listed = storage + .list_account_proxy_settings() + .expect("list proxy settings"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].account_id, "acc-proxy-1"); +} + +#[test] +fn storage_can_update_and_clear_account_proxy_settings() { + let storage = Storage::open_in_memory().expect("open in memory"); + storage.init().expect("init schema"); + let now = now_ts(); + + storage + .insert_account(&Account { + id: "acc-proxy-2".to_string(), + label: "proxy".to_string(), + issuer: "https://auth.openai.com".to_string(), + chatgpt_account_id: None, + workspace_id: None, + group_name: None, + sort: 0, + status: "active".to_string(), + created_at: now, + updated_at: now, + }) + .expect("insert account"); + + storage + .upsert_account_proxy_settings( + "acc-proxy-2", + true, + Some("socks5h://127.0.0.1:7892"), + "checking", + None, + None, + None, + ) + .expect("seed proxy settings"); + storage + .update_account_proxy_check_status( + "acc-proxy-2", + "ok", + Some(184), + Some(1_760_000_000), + None, + ) + .expect("update proxy check status"); + + let stored = storage + .find_account_proxy_settings("acc-proxy-2") + .expect("find proxy settings") + .expect("proxy settings exist"); + assert_eq!(stored.status, "ok"); + assert_eq!(stored.latency_ms, Some(184)); + assert_eq!(stored.last_check_at, Some(1_760_000_000)); + assert_eq!(stored.last_error, None); + + storage + .clear_account_proxy_settings("acc-proxy-2") + .expect("clear proxy settings"); + assert!(storage + .find_account_proxy_settings("acc-proxy-2") + .expect("find cleared proxy settings") + .is_none()); +} + +#[test] +fn deleting_account_cleans_up_account_proxy_settings() { + let storage = Storage::open_in_memory().expect("open in memory"); + storage.init().expect("init schema"); + let now = now_ts(); + + storage + .insert_account(&Account { + id: "acc-proxy-delete".to_string(), + label: "proxy".to_string(), + issuer: "https://auth.openai.com".to_string(), + chatgpt_account_id: None, + workspace_id: None, + group_name: None, + sort: 0, + status: "active".to_string(), + created_at: now, + updated_at: now, + }) + .expect("insert account"); + storage + .upsert_account_proxy_settings( + "acc-proxy-delete", + false, + None, + "not_configured", + None, + None, + None, + ) + .expect("upsert proxy settings"); + + let mut storage = storage; + storage + .delete_account("acc-proxy-delete") + .expect("delete account"); + + assert!(storage + .find_account_proxy_settings("acc-proxy-delete") + .expect("find deleted proxy settings") + .is_none()); +} + #[test] fn storage_can_upsert_and_resolve_model_source_mappings() { let storage = Storage::open_in_memory().expect("open in memory"); diff --git a/crates/core/tests/storage/migration_tests.rs b/crates/core/tests/storage/migration_tests.rs index e2fd4b6eb..3289350c3 100644 --- a/crates/core/tests/storage/migration_tests.rs +++ b/crates/core/tests/storage/migration_tests.rs @@ -331,6 +331,15 @@ fn init_tracks_schema_migrations_and_is_idempotent() { ) .expect("count 068 migration"); assert_eq!(applied_068, 1); + let applied_069: i64 = storage + .conn + .query_row( + "SELECT COUNT(1) FROM schema_migrations WHERE version = '069_account_proxy_settings'", + [], + |row| row.get(0), + ) + .expect("count 069 migration"); + assert_eq!(applied_069, 1); assert!(!storage .has_column("accounts", "note") @@ -437,6 +446,33 @@ fn init_tracks_schema_migrations_and_is_idempotent() { assert!(storage .has_column("account_subscriptions", "expires_at") .expect("check account_subscriptions.expires_at")); + assert!(storage + .has_table("account_proxy_settings") + .expect("check account_proxy_settings table")); + assert!(storage + .has_column("account_proxy_settings", "enabled") + .expect("check account_proxy_settings.enabled")); + assert!(storage + .has_column("account_proxy_settings", "proxy_url") + .expect("check account_proxy_settings.proxy_url")); + assert!(storage + .has_column("account_proxy_settings", "status") + .expect("check account_proxy_settings.status")); + assert!(storage + .has_column("account_proxy_settings", "latency_ms") + .expect("check account_proxy_settings.latency_ms")); + assert!(storage + .has_column("account_proxy_settings", "last_check_at") + .expect("check account_proxy_settings.last_check_at")); + assert!(storage + .has_column("account_proxy_settings", "last_error") + .expect("check account_proxy_settings.last_error")); + assert!(storage + .has_column("account_proxy_settings", "created_at") + .expect("check account_proxy_settings.created_at")); + assert!(storage + .has_column("account_proxy_settings", "updated_at") + .expect("check account_proxy_settings.updated_at")); assert!(storage .has_column("account_subscriptions", "renews_at") .expect("check account_subscriptions.renews_at")); @@ -1232,9 +1268,19 @@ fn observability_storage_compaction_migration_rolls_up_and_prunes_legacy_rows() "053_aggregate_api_model_override", "053_api_key_quota_limits", "054_aggregate_api_balance_query", - "055_model_price_rules", - "056_quota_pools", - ] { + "055_model_price_rules", + "056_quota_pools", + "057_account_manager", + "058_model_source_mappings", + "059_aggregate_api_supplier_models", + "060_request_logs_route_details", + "061_model_groups", + "062_observability_storage_compaction", + "063_account_subscriptions_account_plan_type", + "064_drop_gateway_error_logs", + "065_model_source_mapping_preferences", + "066_account_proxy_settings", + ] { storage .conn .execute( diff --git a/crates/service/Cargo.toml b/crates/service/Cargo.toml index 1fbe93883..b62f50182 100644 --- a/crates/service/Cargo.toml +++ b/crates/service/Cargo.toml @@ -35,6 +35,7 @@ rhai = "1" sysinfo = "0.30" [dev-dependencies] +rcgen = "0.13" [target.'cfg(windows)'.build-dependencies] winres = "0.1" diff --git a/crates/service/src/account/account_delete.rs b/crates/service/src/account/account_delete.rs index f1da925ea..e75cb715a 100644 --- a/crates/service/src/account/account_delete.rs +++ b/crates/service/src/account/account_delete.rs @@ -22,6 +22,7 @@ pub(crate) fn delete_account(account_id: &str) -> Result<(), String> { storage .delete_account(account_id) .map_err(|e| e.to_string())?; + crate::gateway::invalidate_account_proxy_cache(account_id); let _ = storage.insert_event(&Event { account_id: Some(account_id.to_string()), event_type: "account_delete".to_string(), diff --git a/crates/service/src/account/account_delete_many.rs b/crates/service/src/account/account_delete_many.rs index 96877d44d..058b2159d 100644 --- a/crates/service/src/account/account_delete_many.rs +++ b/crates/service/src/account/account_delete_many.rs @@ -76,6 +76,7 @@ pub(crate) fn delete_accounts(account_ids: Vec) -> Result { + crate::gateway::invalidate_account_proxy_cache(account_id.as_str()); let _ = storage.insert_event(&Event { account_id: Some(account_id.clone()), event_type: "account_delete_many".to_string(), diff --git a/crates/service/src/account/account_proxy.rs b/crates/service/src/account/account_proxy.rs new file mode 100644 index 000000000..07bc60497 --- /dev/null +++ b/crates/service/src/account/account_proxy.rs @@ -0,0 +1,766 @@ +use codexmanager_core::storage::{now_ts, AccountProxySettings, Storage}; +use serde::Serialize; + +use crate::storage_helpers::{open_storage, StorageHandle}; + +pub(crate) const STATUS_NOT_CONFIGURED: &str = "not_configured"; +pub(crate) const STATUS_UNCHECKED: &str = "unchecked"; +pub(crate) const STATUS_CHECKING: &str = "checking"; +pub(crate) const STATUS_INVALID_URL: &str = "invalid_url"; +pub(crate) const ENV_ACCOUNT_PROXY_DEBUG: &str = "CODEXMANAGER_ACCOUNT_PROXY_DEBUG"; +#[cfg(test)] +pub(crate) const STATUS_RUNTIME_ERROR: &str = "runtime_error"; +const LOCAL_PROXY_EXPECTED_MESSAGE: &str = "Codex-Manager supports HTTP, HTTPS, SOCKS4, and SOCKS5 proxy URLs, for example http://host:port or socks5://host:port. For sing-box, paste the local mixed inbound address, e.g. http://127.0.0.1:7891."; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum AccountProxyMode { + Disabled, + Explicit { + proxy_url: String, + }, + Invalid { + proxy_url: Option, + error: String, + }, +} + +impl AccountProxyMode { + pub(crate) fn as_str(&self) -> &'static str { + match self { + Self::Disabled => "disabled", + Self::Explicit { .. } => "explicit", + Self::Invalid { .. } => "invalid", + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AccountProxySettingsResponse { + pub account_id: String, + pub enabled: bool, + pub proxy_url: String, + pub status: String, + pub latency_ms: Option, + pub last_check_at: Option, + pub last_error: Option, +} + +pub(crate) fn get_account_proxy_settings( + account_id: &str, +) -> Result { + let storage = open_storage_for_account(account_id)?; + let account_id = normalize_account_id(account_id)?; + ensure_account_exists(&storage, account_id)?; + read_or_default_response(&storage, account_id) +} + +pub(crate) fn set_account_proxy_settings( + account_id: &str, + enabled: bool, + proxy_url: Option<&str>, +) -> Result { + let storage = open_storage_for_account(account_id)?; + let account_id = normalize_account_id(account_id)?; + ensure_account_exists(&storage, account_id)?; + + let normalized_proxy_url = normalize_proxy_url_for_setting(enabled, proxy_url)?; + let status = if enabled { + STATUS_UNCHECKED + } else { + STATUS_NOT_CONFIGURED + }; + storage + .upsert_account_proxy_settings( + account_id, + enabled, + normalized_proxy_url.as_deref(), + status, + None, + None, + None, + ) + .map_err(|err| format!("store account proxy settings failed: {err}"))?; + crate::gateway::invalidate_account_proxy_cache(account_id); + read_or_default_response(&storage, account_id) +} + +pub(crate) fn clear_account_proxy_settings( + account_id: &str, +) -> Result { + let storage = open_storage_for_account(account_id)?; + let account_id = normalize_account_id(account_id)?; + ensure_account_exists(&storage, account_id)?; + storage + .clear_account_proxy_settings(account_id) + .map_err(|err| format!("clear account proxy settings failed: {err}"))?; + crate::gateway::invalidate_account_proxy_cache(account_id); + Ok(default_response(account_id)) +} + +pub(crate) fn test_account_proxy_settings( + account_id: &str, + enabled: Option, + proxy_url: Option<&str>, +) -> Result { + let storage = open_storage_for_account(account_id)?; + let account_id = normalize_account_id(account_id)?; + ensure_account_exists(&storage, account_id)?; + match (enabled, proxy_url) { + (Some(enabled), proxy_url) => { + test_account_proxy_draft_with_checker(account_id, enabled, proxy_url, |proxy_url| { + crate::account::proxy_health::check_account_proxy(proxy_url) + }) + } + (None, None) => { + test_account_proxy_settings_with_checker(&storage, account_id, |proxy_url| { + crate::account::proxy_health::check_account_proxy(proxy_url) + }) + } + (None, Some(proxy_url)) => { + test_account_proxy_draft_with_checker(account_id, true, Some(proxy_url), |proxy_url| { + crate::account::proxy_health::check_account_proxy(proxy_url) + }) + } + } +} + +fn test_account_proxy_draft_with_checker( + account_id: &str, + enabled: bool, + proxy_url: Option<&str>, + checker: F, +) -> Result +where + F: FnOnce(&str) -> crate::account::proxy_health::ProxyHealthCheckResult, +{ + let proxy_url = proxy_url.map(str::trim).unwrap_or_default(); + if proxy_url.is_empty() { + return Ok(AccountProxySettingsResponse { + account_id: account_id.to_string(), + enabled, + proxy_url: proxy_url.to_string(), + status: STATUS_NOT_CONFIGURED.to_string(), + latency_ms: None, + last_check_at: Some(now_ts()), + last_error: None, + }); + } + + let normalized_proxy_url = match normalize_supported_proxy_url(proxy_url) { + Ok(normalized_proxy_url) => normalized_proxy_url, + Err(err) => { + return Ok(AccountProxySettingsResponse { + account_id: account_id.to_string(), + enabled, + proxy_url: proxy_url.to_string(), + status: STATUS_INVALID_URL.to_string(), + latency_ms: None, + last_check_at: Some(now_ts()), + last_error: Some(err), + }); + } + }; + + let outcome = checker(normalized_proxy_url.as_str()); + Ok(AccountProxySettingsResponse { + account_id: account_id.to_string(), + enabled, + proxy_url: normalized_proxy_url, + status: outcome.status.to_string(), + latency_ms: outcome.latency_ms, + last_check_at: Some(now_ts()), + last_error: outcome.last_error, + }) +} + +pub(crate) fn resolve_account_proxy_mode(account_id: &str) -> AccountProxyMode { + let normalized_account_id = account_id.trim(); + if normalized_account_id.is_empty() { + return AccountProxyMode::Disabled; + } + + let Some(storage) = open_storage() else { + return AccountProxyMode::Disabled; + }; + resolve_account_proxy_mode_from_storage(&storage, normalized_account_id) +} + +pub(crate) fn account_proxy_debug_enabled() -> bool { + std::env::var(ENV_ACCOUNT_PROXY_DEBUG) + .ok() + .map(|value| { + let normalized = value.trim(); + normalized == "1" + || normalized.eq_ignore_ascii_case("true") + || normalized.eq_ignore_ascii_case("yes") + || normalized.eq_ignore_ascii_case("on") + }) + .unwrap_or(false) +} + +pub(crate) fn redact_proxy_url_for_log(proxy_url: &str) -> String { + let trimmed = proxy_url.trim(); + if trimmed.is_empty() { + return "-".to_string(); + } + let Ok(parsed) = url::Url::parse(trimmed) else { + return "".to_string(); + }; + let scheme = parsed.scheme(); + let host = parsed.host_str().unwrap_or("-"); + match parsed.port_or_known_default() { + Some(port) => format!("{scheme}://{host}:{port}"), + None => format!("{scheme}://{host}"), + } +} + +fn open_storage_for_account(account_id: &str) -> Result { + normalize_account_id(account_id)?; + open_storage().ok_or_else(|| "storage unavailable".to_string()) +} + +fn normalize_account_id(account_id: &str) -> Result<&str, String> { + let trimmed = account_id.trim(); + if trimmed.is_empty() { + Err("missing accountId".to_string()) + } else { + Ok(trimmed) + } +} + +fn resolve_account_proxy_mode_from_storage( + storage: &Storage, + account_id: &str, +) -> AccountProxyMode { + let settings = match storage.find_account_proxy_settings(account_id) { + Ok(settings) => settings, + Err(err) => { + log::warn!( + "event=account_proxy_mode_read_failed account_id={} err={}", + account_id, + err + ); + return AccountProxyMode::Disabled; + } + }; + let Some(settings) = settings else { + return AccountProxyMode::Disabled; + }; + if !settings.enabled { + return AccountProxyMode::Disabled; + } + + let trimmed_proxy_url = settings + .proxy_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let Some(proxy_url) = trimmed_proxy_url else { + return AccountProxyMode::Invalid { + proxy_url: None, + error: format!( + "account explicit proxy for {} is invalid and fail-closed: missing proxy URL", + account_id + ), + }; + }; + + match normalize_supported_proxy_url(proxy_url.as_str()) { + Ok(proxy_url) => AccountProxyMode::Explicit { proxy_url }, + Err(err) => AccountProxyMode::Invalid { + proxy_url: Some(proxy_url.clone()), + error: format!( + "account explicit proxy for {} is invalid and fail-closed: {}. {}", + account_id, proxy_url, err + ), + }, + } +} + +fn ensure_account_exists(storage: &Storage, account_id: &str) -> Result<(), String> { + let found = storage + .find_account_by_id(account_id) + .map_err(|err| format!("read account failed: {err}"))? + .is_some(); + if found { + Ok(()) + } else { + Err("account not found".to_string()) + } +} + +fn normalize_proxy_url_for_setting( + enabled: bool, + proxy_url: Option<&str>, +) -> Result, String> { + let trimmed = proxy_url.map(str::trim).filter(|value| !value.is_empty()); + if !enabled { + return Ok(trimmed.map(ToString::to_string)); + } + let Some(proxy_url) = trimmed else { + return Err("proxyUrl is required when account proxy is enabled".to_string()); + }; + normalize_supported_proxy_url(proxy_url).map(Some) +} + +pub(crate) fn normalize_supported_proxy_url(proxy_url: &str) -> Result { + let parsed = url::Url::parse(proxy_url) + .map_err(|err| format!("invalid proxyUrl: {err}. {LOCAL_PROXY_EXPECTED_MESSAGE}"))?; + match parsed.scheme() { + "http" | "https" | "socks4" | "socks4a" | "socks5" | "socks5h" => { + Ok(proxy_url.trim().to_string()) + } + "vless" | "trojan" | "ss" | "hysteria2" => Err(LOCAL_PROXY_EXPECTED_MESSAGE.to_string()), + other => Err(format!( + "unsupported proxy URL scheme: {other}. {LOCAL_PROXY_EXPECTED_MESSAGE}" + )), + } +} + +fn test_account_proxy_settings_with_checker( + storage: &Storage, + account_id: &str, + checker: F, +) -> Result +where + F: FnOnce(&str) -> crate::account::proxy_health::ProxyHealthCheckResult, +{ + let settings = storage + .find_account_proxy_settings(account_id) + .map_err(|err| format!("read account proxy settings failed: {err}"))?; + let Some(settings) = settings else { + return Ok(default_response(account_id)); + }; + + let proxy_url = settings + .proxy_url + .as_deref() + .map(str::trim) + .unwrap_or_default(); + if proxy_url.is_empty() { + persist_check_status(storage, account_id, STATUS_NOT_CONFIGURED, None, None)?; + crate::gateway::invalidate_account_proxy_cache(account_id); + return read_or_default_response(storage, account_id); + } + + let normalized_proxy_url = match normalize_supported_proxy_url(proxy_url) { + Ok(normalized_proxy_url) => normalized_proxy_url, + Err(err) => { + persist_check_status( + storage, + account_id, + STATUS_INVALID_URL, + None, + Some(err.as_str()), + )?; + crate::gateway::invalidate_account_proxy_cache(account_id); + return read_or_default_response(storage, account_id); + } + }; + + if normalized_proxy_url != proxy_url { + storage + .upsert_account_proxy_settings( + account_id, + settings.enabled, + Some(normalized_proxy_url.as_str()), + settings.status.as_str(), + settings.latency_ms, + settings.last_check_at, + settings.last_error.as_deref(), + ) + .map_err(|err| format!("store account proxy settings failed: {err}"))?; + } + + persist_check_status(storage, account_id, STATUS_CHECKING, None, None)?; + let outcome = checker(normalized_proxy_url.as_str()); + persist_check_status( + storage, + account_id, + outcome.status, + outcome.latency_ms, + outcome.last_error.as_deref(), + )?; + crate::gateway::invalidate_account_proxy_cache(account_id); + read_or_default_response(storage, account_id) +} + +fn persist_check_status( + storage: &Storage, + account_id: &str, + status: &str, + latency_ms: Option, + last_error: Option<&str>, +) -> Result<(), String> { + storage + .update_account_proxy_check_status( + account_id, + status, + latency_ms, + Some(now_ts()), + last_error, + ) + .map_err(|err| format!("update account proxy status failed: {err}")) +} + +fn read_or_default_response( + storage: &Storage, + account_id: &str, +) -> Result { + let settings = storage + .find_account_proxy_settings(account_id) + .map_err(|err| format!("read account proxy settings failed: {err}"))?; + Ok(settings + .map(account_proxy_settings_response) + .unwrap_or_else(|| default_response(account_id))) +} + +fn default_response(account_id: &str) -> AccountProxySettingsResponse { + AccountProxySettingsResponse { + account_id: account_id.to_string(), + enabled: false, + proxy_url: String::new(), + status: STATUS_NOT_CONFIGURED.to_string(), + latency_ms: None, + last_check_at: None, + last_error: None, + } +} + +fn account_proxy_settings_response(settings: AccountProxySettings) -> AccountProxySettingsResponse { + AccountProxySettingsResponse { + account_id: settings.account_id, + enabled: settings.enabled, + proxy_url: settings.proxy_url.unwrap_or_default(), + status: settings.status, + latency_ms: settings.latency_ms, + last_check_at: settings.last_check_at, + last_error: settings.last_error, + } +} + +#[cfg(test)] +mod tests { + use super::{ + normalize_supported_proxy_url, resolve_account_proxy_mode_from_storage, + test_account_proxy_settings_with_checker, AccountProxyMode, AccountProxySettingsResponse, + STATUS_INVALID_URL, STATUS_NOT_CONFIGURED, STATUS_RUNTIME_ERROR, STATUS_UNCHECKED, + }; + use codexmanager_core::storage::{now_ts, Account, Storage}; + use std::fs; + use std::path::PathBuf; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static ACCOUNT_PROXY_TEST_DIR_SEQ: AtomicUsize = AtomicUsize::new(0); + const STATUS_FAILED: &str = "failed"; + const STATUS_OK: &str = "ok"; + + #[test] + fn normalize_supported_proxy_url_rewrites_socks5_to_socks5h() { + assert_eq!( + normalize_supported_proxy_url("socks5://127.0.0.1:7891").expect("normalize"), + "socks5h://127.0.0.1:7891" + ); + } + + #[test] + fn normalize_supported_proxy_url_rejects_server_links() { + let err = normalize_supported_proxy_url("vless://example").expect_err("reject vless"); + assert!(err.contains("local HTTP/SOCKS proxy URL")); + } + + #[test] + fn test_account_proxy_settings_runs_checker_for_disabled_proxy_with_url() { + let dir = new_test_dir("account-proxy-disabled-with-url"); + let storage = seed_storage(&dir, "acc-disabled-url"); + storage + .upsert_account_proxy_settings( + "acc-disabled-url", + false, + Some("http://127.0.0.1:7891"), + STATUS_UNCHECKED, + None, + None, + None, + ) + .expect("seed disabled proxy settings"); + + let response = + test_account_proxy_settings_with_checker(&storage, "acc-disabled-url", |_| { + crate::account::proxy_health::ProxyHealthCheckResult { + status: STATUS_OK, + latency_ms: Some(123), + last_error: None, + } + }) + .expect("test disabled proxy"); + + assert_status(&response, STATUS_OK); + assert_eq!(response.enabled, false); + assert_eq!(response.latency_ms, Some(123)); + assert_eq!(response.last_error, None); + assert!(response.last_check_at.is_some()); + + let stored = storage + .find_account_proxy_settings("acc-disabled-url") + .expect("find stored disabled proxy") + .expect("stored disabled proxy"); + assert_eq!(stored.status, STATUS_OK); + assert_eq!(stored.latency_ms, Some(123)); + assert_eq!(stored.last_error, None); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn test_account_proxy_settings_returns_not_configured_when_url_is_empty() { + let dir = new_test_dir("account-proxy-empty-url"); + let storage = seed_storage(&dir, "acc-empty-url"); + storage + .upsert_account_proxy_settings( + "acc-empty-url", + true, + None, + STATUS_UNCHECKED, + None, + None, + None, + ) + .expect("seed empty proxy settings"); + + let response = test_account_proxy_settings_with_checker(&storage, "acc-empty-url", |_| { + panic!("checker should not run for empty url") + }) + .expect("test empty proxy"); + + assert_status(&response, STATUS_NOT_CONFIGURED); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn resolve_account_proxy_mode_treats_disabled_proxy_with_stored_url_as_disabled() { + let dir = new_test_dir("account-proxy-mode-disabled"); + let storage = seed_storage(&dir, "acc-disabled-mode"); + storage + .upsert_account_proxy_settings( + "acc-disabled-mode", + false, + Some("http://127.0.0.1:7891"), + STATUS_UNCHECKED, + None, + None, + None, + ) + .expect("seed disabled mode proxy settings"); + + let mode = resolve_account_proxy_mode_from_storage(&storage, "acc-disabled-mode"); + assert_eq!(mode, AccountProxyMode::Disabled); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn resolve_account_proxy_mode_fails_closed_for_enabled_proxy_without_url() { + let dir = new_test_dir("account-proxy-mode-empty"); + let storage = seed_storage(&dir, "acc-empty-mode"); + storage + .upsert_account_proxy_settings( + "acc-empty-mode", + true, + None, + STATUS_UNCHECKED, + None, + None, + None, + ) + .expect("seed empty mode proxy settings"); + + let mode = resolve_account_proxy_mode_from_storage(&storage, "acc-empty-mode"); + let AccountProxyMode::Invalid { proxy_url, error } = mode else { + panic!("enabled proxy without URL must fail closed"); + }; + assert_eq!(proxy_url, None); + assert!(error.contains("fail-closed")); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn test_account_proxy_settings_persists_invalid_url_status() { + let dir = new_test_dir("account-proxy-invalid"); + let storage = seed_storage(&dir, "acc-invalid"); + storage + .upsert_account_proxy_settings( + "acc-invalid", + true, + Some("http://"), + STATUS_UNCHECKED, + None, + None, + None, + ) + .expect("seed invalid proxy settings"); + + let response = test_account_proxy_settings_with_checker(&storage, "acc-invalid", |_| { + panic!("checker should not run for invalid proxy URL") + }) + .expect("test invalid proxy"); + + assert_status(&response, STATUS_INVALID_URL); + assert!(response + .last_error + .as_deref() + .unwrap_or_default() + .contains("invalid proxyUrl")); + assert!(response.last_check_at.is_some()); + + let stored = storage + .find_account_proxy_settings("acc-invalid") + .expect("find stored invalid proxy") + .expect("stored invalid proxy"); + assert_eq!(stored.status, STATUS_INVALID_URL); + assert!(stored + .last_error + .as_deref() + .unwrap_or_default() + .contains("invalid proxyUrl")); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn test_account_proxy_settings_persists_checker_outcome() { + let dir = new_test_dir("account-proxy-ok"); + let storage = seed_storage(&dir, "acc-ok"); + storage + .upsert_account_proxy_settings( + "acc-ok", + true, + Some("http://127.0.0.1:7891"), + STATUS_UNCHECKED, + None, + None, + None, + ) + .expect("seed valid proxy settings"); + + let response = test_account_proxy_settings_with_checker(&storage, "acc-ok", |_| { + crate::account::proxy_health::ProxyHealthCheckResult { + status: STATUS_OK, + latency_ms: Some(184), + last_error: None, + } + }) + .expect("test proxy success"); + + assert_status(&response, STATUS_OK); + assert_eq!(response.latency_ms, Some(184)); + assert_eq!(response.last_error, None); + assert!(response.last_check_at.is_some()); + + let response = test_account_proxy_settings_with_checker(&storage, "acc-ok", |_| { + crate::account::proxy_health::ProxyHealthCheckResult { + status: STATUS_FAILED, + latency_ms: None, + last_error: Some("proxy unreachable".to_string()), + } + }) + .expect("test proxy failure"); + + assert_status(&response, STATUS_FAILED); + assert_eq!(response.latency_ms, None); + assert_eq!(response.last_error.as_deref(), Some("proxy unreachable")); + + let stored = storage + .find_account_proxy_settings("acc-ok") + .expect("find stored proxy") + .expect("stored proxy"); + assert_eq!(stored.status, STATUS_FAILED); + assert_eq!(stored.latency_ms, None); + assert_eq!(stored.last_error.as_deref(), Some("proxy unreachable")); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn test_account_proxy_settings_persists_runtime_error_status() { + let dir = new_test_dir("account-proxy-runtime-error"); + let storage = seed_storage(&dir, "acc-runtime"); + storage + .upsert_account_proxy_settings( + "acc-runtime", + true, + Some("http://127.0.0.1:7891"), + STATUS_UNCHECKED, + None, + None, + None, + ) + .expect("seed runtime proxy settings"); + + let response = test_account_proxy_settings_with_checker(&storage, "acc-runtime", |_| { + crate::account::proxy_health::ProxyHealthCheckResult { + status: STATUS_RUNTIME_ERROR, + latency_ms: None, + last_error: Some("local proxy runtime unavailable".to_string()), + } + }) + .expect("test runtime proxy"); + + assert_status(&response, STATUS_RUNTIME_ERROR); + assert_eq!(response.latency_ms, None); + assert_eq!( + response.last_error.as_deref(), + Some("local proxy runtime unavailable") + ); + + let stored = storage + .find_account_proxy_settings("acc-runtime") + .expect("find stored runtime proxy") + .expect("stored runtime proxy"); + assert_eq!(stored.status, STATUS_RUNTIME_ERROR); + assert_eq!(stored.latency_ms, None); + assert_eq!( + stored.last_error.as_deref(), + Some("local proxy runtime unavailable") + ); + + let _ = fs::remove_dir_all(dir); + } + + fn assert_status(response: &AccountProxySettingsResponse, expected: &str) { + assert_eq!(response.status, expected); + } + + fn new_test_dir(prefix: &str) -> PathBuf { + let seq = ACCOUNT_PROXY_TEST_DIR_SEQ.fetch_add(1, Ordering::Relaxed); + let mut dir = std::env::temp_dir(); + dir.push(format!("{prefix}-{}-{seq}", std::process::id())); + let _ = fs::create_dir_all(&dir); + dir + } + + fn seed_storage(dir: &PathBuf, account_id: &str) -> Storage { + let storage = Storage::open(dir.join("codexmanager.db")).expect("open test db"); + storage.init().expect("init test schema"); + let now = now_ts(); + storage + .insert_account(&Account { + id: account_id.to_string(), + label: account_id.to_string(), + issuer: "https://auth.openai.com".to_string(), + chatgpt_account_id: Some(format!("chatgpt-{account_id}")), + workspace_id: Some(format!("workspace-{account_id}")), + group_name: None, + sort: 0, + status: "active".to_string(), + created_at: now, + updated_at: now, + }) + .expect("insert account"); + storage + } +} diff --git a/crates/service/src/account/account_proxy_health.rs b/crates/service/src/account/account_proxy_health.rs new file mode 100644 index 000000000..14bbfe0f4 --- /dev/null +++ b/crates/service/src/account/account_proxy_health.rs @@ -0,0 +1,338 @@ +use reqwest::blocking::Client; +use reqwest::Proxy; +use std::time::{Duration, Instant}; +use url::{Host, Url}; + +const STATUS_FAILED: &str = "failed"; +const STATUS_INVALID_URL: &str = "invalid_url"; +const STATUS_OK: &str = "ok"; +const STATUS_RUNTIME_ERROR: &str = "runtime_error"; +const PROXY_TEST_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const PROXY_TEST_TOTAL_TIMEOUT: Duration = Duration::from_secs(20); +const DEFAULT_PROXY_TEST_TARGETS: &[&str] = &[ + "https://www.gstatic.com/generate_204", + "https://api.ipify.org", + "https://chatgpt.com/cdn-cgi/trace", +]; + +#[derive(Debug, Clone)] +pub(crate) struct ProxyHealthCheckResult { + pub status: &'static str, + pub latency_ms: Option, + pub last_error: Option, +} + +pub(crate) fn check_account_proxy(proxy_url: &str) -> ProxyHealthCheckResult { + check_account_proxy_with_options(proxy_url, DEFAULT_PROXY_TEST_TARGETS, false) +} + +fn check_account_proxy_with_options( + proxy_url: &str, + targets: &[&str], + accept_invalid_certs: bool, +) -> ProxyHealthCheckResult { + let (client, parsed_proxy_url) = match build_proxy_test_client(proxy_url, accept_invalid_certs) + { + Ok(result) => result, + Err(err) => { + return ProxyHealthCheckResult { + status: STATUS_INVALID_URL, + latency_ms: None, + last_error: Some(err), + }; + } + }; + + let mut last_error = None; + for target in targets + .iter() + .copied() + .filter(|value| !value.trim().is_empty()) + { + let started_at = Instant::now(); + match client.get(target).send() { + Ok(response) if response.status().is_success() => { + return ProxyHealthCheckResult { + status: STATUS_OK, + latency_ms: Some(started_at.elapsed().as_millis().min(i64::MAX as u128) as i64), + last_error: None, + }; + } + Ok(response) => { + last_error = Some(format!( + "proxy test GET {target} returned HTTP {}", + response.status() + )); + } + Err(err) => { + if looks_like_local_proxy_runtime_error(&parsed_proxy_url, &err) { + return ProxyHealthCheckResult { + status: STATUS_RUNTIME_ERROR, + latency_ms: None, + last_error: Some(format!("local proxy runtime unavailable: {err}")), + }; + } + last_error = Some(format!("proxy test GET {target} failed: {err}")); + } + } + } + + ProxyHealthCheckResult { + status: STATUS_FAILED, + latency_ms: None, + last_error: Some( + last_error.unwrap_or_else(|| "proxy test did not have any target URLs".to_string()), + ), + } +} + +fn build_proxy_test_client( + proxy_url: &str, + accept_invalid_certs: bool, +) -> Result<(Client, Url), String> { + let parsed = Url::parse(proxy_url) + .map_err(|err| format!("invalid proxyUrl: {err}. Check the local HTTP/SOCKS proxy URL."))?; + let proxy = Proxy::all(proxy_url) + .map_err(|err| format!("invalid proxyUrl: {err}. Check the local HTTP/SOCKS proxy URL."))?; + let client = Client::builder() + .connect_timeout(PROXY_TEST_CONNECT_TIMEOUT) + .timeout(PROXY_TEST_TOTAL_TIMEOUT) + .danger_accept_invalid_certs(accept_invalid_certs) + .user_agent(crate::gateway::current_codex_user_agent()) + .proxy(proxy) + .build() + .map_err(|err| format!("build proxy test client failed: {err}"))?; + Ok((client, parsed)) +} + +fn looks_like_local_proxy_runtime_error(proxy_url: &Url, err: &reqwest::Error) -> bool { + if !is_loopback_proxy_url(proxy_url) { + return false; + } + if err.is_connect() || err.is_timeout() { + return true; + } + let message = err.to_string().to_ascii_lowercase(); + message.contains("connection refused") + || message.contains("unsuccessful tunnel") + || message.contains("tcp connect error") + || message.contains("error trying to connect") + || message.contains("proxy connect") + || message.contains("channel closed") +} + +fn is_loopback_proxy_url(proxy_url: &Url) -> bool { + match proxy_url.host() { + Some(Host::Ipv4(addr)) => addr.is_loopback(), + Some(Host::Ipv6(addr)) => addr.is_loopback(), + Some(Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"), + None => false, + } +} + +#[cfg(test)] +mod tests { + use super::{ + check_account_proxy_with_options, ProxyHealthCheckResult, STATUS_FAILED, STATUS_OK, + STATUS_RUNTIME_ERROR, + }; + use rcgen::generate_simple_self_signed; + use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; + use rustls::{ServerConfig, ServerConnection, StreamOwned}; + use std::io::{Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::sync::mpsc::{self, Receiver}; + use std::sync::{Arc, OnceLock}; + use std::thread; + use std::time::Duration; + + #[test] + fn proxy_health_check_succeeds_through_local_http_connect_proxy() { + let (target_url, target_addr, request_rx, https_handle) = + spawn_https_response_server(204, "/generate_204"); + let (proxy_url, connect_rx, proxy_handle) = spawn_http_connect_proxy(target_addr); + + let result = check_account_proxy_with_options(&proxy_url, &[target_url.as_str()], true); + + assert_eq!(result.status, STATUS_OK); + assert!(result.latency_ms.is_some()); + assert_eq!(result.last_error, None); + assert_eq!( + connect_rx + .recv_timeout(Duration::from_secs(5)) + .expect("receive CONNECT line"), + format!( + "CONNECT localhost:{} HTTP/1.1", + target_url + .rsplit(':') + .next() + .expect("target port") + .trim_end_matches("/generate_204") + ) + ); + assert_eq!( + request_rx + .recv_timeout(Duration::from_secs(5)) + .expect("receive HTTPS request line"), + "GET /generate_204 HTTP/1.1" + ); + + proxy_handle.join().expect("join proxy thread"); + https_handle.join().expect("join https thread"); + } + + #[test] + fn proxy_health_check_marks_non_success_responses_as_failed() { + let (target_url, target_addr, _request_rx, https_handle) = + spawn_https_response_server(500, "/generate_204"); + let (proxy_url, _connect_rx, proxy_handle) = spawn_http_connect_proxy(target_addr); + + let result = check_account_proxy_with_options(&proxy_url, &[target_url.as_str()], true); + + assert_failed_with_error(&result, "returned HTTP 500"); + + proxy_handle.join().expect("join proxy thread"); + https_handle.join().expect("join https thread"); + } + + #[test] + fn proxy_health_check_marks_loopback_connect_refused_as_runtime_error() { + let free_port = reserve_free_port(); + let proxy_url = format!("http://127.0.0.1:{free_port}"); + + let result = check_account_proxy_with_options( + &proxy_url, + &["https://www.gstatic.com/generate_204"], + true, + ); + + assert_eq!(result.status, STATUS_RUNTIME_ERROR); + assert_eq!(result.latency_ms, None); + let error = result.last_error.as_deref().expect("last_error"); + assert!(error.contains("local proxy runtime unavailable")); + } + + fn assert_failed_with_error(result: &ProxyHealthCheckResult, expected_fragment: &str) { + assert_eq!(result.status, STATUS_FAILED); + assert_eq!(result.latency_ms, None); + let error = result.last_error.as_deref().expect("last_error"); + assert!( + error.contains(expected_fragment), + "unexpected proxy test error: {error}" + ); + } + + fn spawn_https_response_server( + status_code: u16, + path: &str, + ) -> (String, String, Receiver, thread::JoinHandle<()>) { + ensure_rustls_crypto_provider(); + let cert = generate_simple_self_signed(vec!["localhost".to_string()]) + .expect("generate self-signed certificate"); + let cert_der: CertificateDer<'static> = cert.cert.der().clone(); + let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der())); + let server_config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert_der], key_der) + .expect("build rustls server config"); + let server_config = Arc::new(server_config); + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock https server"); + let addr = listener.local_addr().expect("https server local addr"); + let target_addr = format!("127.0.0.1:{}", addr.port()); + let target_url = format!("https://localhost:{}{path}", addr.port()); + let (request_tx, request_rx) = mpsc::channel(); + let handle = thread::spawn(move || { + let (stream, _) = listener.accept().expect("accept https test connection"); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set https test read timeout"); + let conn = ServerConnection::new(server_config).expect("create rustls server conn"); + let mut tls = StreamOwned::new(conn, stream); + let request_line = read_http_request_line(&mut tls); + let _ = request_tx.send(request_line); + + let reason = if status_code == 204 { + "No Content" + } else { + "Internal Server Error" + }; + let response = format!( + "HTTP/1.1 {status_code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + tls.write_all(response.as_bytes()) + .expect("write https response"); + tls.flush().expect("flush https response"); + }); + (target_url, target_addr, request_rx, handle) + } + + fn spawn_http_connect_proxy( + target_addr: String, + ) -> (String, Receiver, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock proxy"); + let proxy_addr = listener.local_addr().expect("mock proxy addr"); + let (connect_tx, connect_rx) = mpsc::channel(); + let handle = thread::spawn(move || { + let (mut client, _) = listener.accept().expect("accept proxy client"); + client + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set proxy client read timeout"); + let request_line = read_http_request_line(&mut client); + let _ = connect_tx.send(request_line); + + let mut upstream = + TcpStream::connect(target_addr.as_str()).expect("connect proxy upstream"); + client + .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") + .expect("write proxy CONNECT response"); + + let mut client_reader = client.try_clone().expect("clone proxy client reader"); + let mut upstream_writer = upstream.try_clone().expect("clone proxy upstream writer"); + let upstream_to_client = thread::spawn(move || { + let _ = std::io::copy(&mut upstream, &mut client); + }); + let client_to_upstream = thread::spawn(move || { + let _ = std::io::copy(&mut client_reader, &mut upstream_writer); + }); + let _ = client_to_upstream.join(); + let _ = upstream_to_client.join(); + }); + (format!("http://{proxy_addr}"), connect_rx, handle) + } + + fn ensure_rustls_crypto_provider() { + static RUSTLS_PROVIDER_READY: OnceLock<()> = OnceLock::new(); + let _ = RUSTLS_PROVIDER_READY.get_or_init(|| { + let _ = rustls::crypto::ring::default_provider().install_default(); + }); + } + + fn read_http_request_line(stream: &mut T) -> String + where + T: Read, + { + let mut request = Vec::new(); + let mut buf = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let read = stream.read(&mut buf).expect("read HTTP request"); + if read == 0 { + break; + } + request.extend_from_slice(&buf[..read]); + } + String::from_utf8_lossy(request.as_slice()) + .lines() + .next() + .unwrap_or_default() + .to_string() + } + + fn reserve_free_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .expect("bind free port probe") + .local_addr() + .expect("free port addr") + .port() + } +} diff --git a/crates/service/src/account/mod.rs b/crates/service/src/account/mod.rs index e30e74f3c..69a962ec8 100644 --- a/crates/service/src/account/mod.rs +++ b/crates/service/src/account/mod.rs @@ -14,6 +14,10 @@ pub(crate) mod import; pub(crate) mod list; #[path = "account_plan.rs"] pub(crate) mod plan; +#[path = "account_proxy.rs"] +pub(crate) mod proxy; +#[path = "account_proxy_health.rs"] +pub(crate) mod proxy_health; #[path = "account_status.rs"] pub(crate) mod status; #[path = "account_update.rs"] diff --git a/crates/service/src/gateway/auth/openai_fallback.rs b/crates/service/src/gateway/auth/openai_fallback.rs index 44d7348d5..b4ab2d66c 100644 --- a/crates/service/src/gateway/auth/openai_fallback.rs +++ b/crates/service/src/gateway/auth/openai_fallback.rs @@ -332,7 +332,15 @@ pub(super) fn try_openai_fallback( let resp = match build_request(client).send() { Ok(resp) => resp, Err(first_err) => { - let fresh = super::fresh_upstream_client_for_account(account.id.as_str()); + let fresh = match super::fresh_upstream_client_for_account(account.id.as_str()) { + Ok(client) => client, + Err(fresh_err) => { + return Err(format!( + "{}; retry_after_fresh_client_build: {}", + first_err, fresh_err + )); + } + }; match build_request(&fresh).send() { Ok(resp) => { log::info!( diff --git a/crates/service/src/gateway/core/runtime_config.rs b/crates/service/src/gateway/core/runtime_config.rs index 3326f4ba6..dfbe4cc2a 100644 --- a/crates/service/src/gateway/core/runtime_config.rs +++ b/crates/service/src/gateway/core/runtime_config.rs @@ -1,13 +1,17 @@ use codexmanager_core::auth::DEFAULT_ORIGINATOR; use codexmanager_core::auth::{DEFAULT_CLIENT_ID, DEFAULT_ISSUER}; +use codexmanager_core::storage::Storage; use reqwest::blocking::Client; use reqwest::Proxy; +use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{OnceLock, RwLock}; use std::time::Duration; static UPSTREAM_CLIENT: OnceLock> = OnceLock::new(); static UPSTREAM_CLIENT_POOL: OnceLock> = OnceLock::new(); +static ACCOUNT_PROXY_CLIENTS: OnceLock>> = + OnceLock::new(); static RUNTIME_CONFIG_LOADED: OnceLock<()> = OnceLock::new(); static REQUEST_GATE_WAIT_TIMEOUT_MS: AtomicU64 = AtomicU64::new(DEFAULT_REQUEST_GATE_WAIT_TIMEOUT_MS); @@ -99,6 +103,20 @@ struct UpstreamClientPool { clients: Vec, } +#[derive(Clone)] +enum AccountProxyClientCacheEntry { + NotConfigured, + Invalid { + proxy_url: String, + error: String, + }, + Ready { + proxy_url: String, + blocking_client: Client, + async_client: reqwest::Client, + }, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ModelForwardRule { pub from_pattern: String, @@ -184,13 +202,17 @@ pub(crate) fn fresh_upstream_client() -> Client { /// /// # 返回 /// 返回函数执行结果 -pub(crate) fn upstream_client_for_account(account_id: &str) -> Client { +pub(crate) fn upstream_client_for_account(account_id: &str) -> Result { ensure_runtime_config_loaded(); - let cached = - crate::lock_utils::read_recover(upstream_client_pool_lock(), "upstream_client_pool") - .client_for_account(account_id) - .cloned(); - cached.unwrap_or_else(upstream_client) + match account_proxy_client_cache_entry(account_id) { + AccountProxyClientCacheEntry::Ready { + blocking_client, .. + } => Ok(blocking_client), + AccountProxyClientCacheEntry::Invalid { proxy_url, error } => Err(format!( + "account explicit proxy for {account_id} is invalid and fail-closed: {proxy_url}. {error}" + )), + AccountProxyClientCacheEntry::NotConfigured => fallback_blocking_client_for_account(account_id), + } } /// 函数 `fresh_upstream_client_for_account` @@ -204,40 +226,63 @@ pub(crate) fn upstream_client_for_account(account_id: &str) -> Client { /// /// # 返回 /// 返回函数执行结果 -pub(crate) fn fresh_upstream_client_for_account(account_id: &str) -> Client { +pub(crate) fn fresh_upstream_client_for_account(account_id: &str) -> Result { ensure_runtime_config_loaded(); - let pool = crate::lock_utils::read_recover(upstream_client_pool_lock(), "upstream_client_pool"); - if let Some(proxy_url) = pool.proxy_for_account(account_id) { - return build_upstream_client_with_proxy(Some(proxy_url)); + match account_proxy_client_cache_entry(account_id) { + AccountProxyClientCacheEntry::Ready { proxy_url, .. } => { + build_blocking_client_with_proxy_strict(Some(proxy_url.as_str())) + } + AccountProxyClientCacheEntry::Invalid { proxy_url, error } => Err(format!( + "account explicit proxy for {account_id} is invalid and fail-closed: {proxy_url}. {error}" + )), + AccountProxyClientCacheEntry::NotConfigured => { + fresh_fallback_blocking_client_for_account(account_id) + } } - build_upstream_client() } -pub(crate) fn async_upstream_client_for_account(account_id: &str) -> reqwest::Client { +pub(crate) fn async_upstream_client_for_account( + account_id: &str, +) -> Result { ensure_runtime_config_loaded(); - let pool = crate::lock_utils::read_recover(upstream_client_pool_lock(), "upstream_client_pool"); - if let Some(proxy_url) = pool.proxy_for_account(account_id) { - return build_async_upstream_client_with_proxy(Some(proxy_url)); + match account_proxy_client_cache_entry(account_id) { + AccountProxyClientCacheEntry::Ready { async_client, .. } => Ok(async_client), + AccountProxyClientCacheEntry::Invalid { proxy_url, error } => Err(format!( + "account explicit proxy for {account_id} is invalid and fail-closed: {proxy_url}. {error}" + )), + AccountProxyClientCacheEntry::NotConfigured => fallback_async_client_for_account(account_id), } - build_async_upstream_client() } -pub(crate) fn fresh_async_upstream_client_for_account(account_id: &str) -> reqwest::Client { +pub(crate) fn fresh_async_upstream_client_for_account( + account_id: &str, +) -> Result { ensure_runtime_config_loaded(); - let pool = crate::lock_utils::read_recover(upstream_client_pool_lock(), "upstream_client_pool"); - if let Some(proxy_url) = pool.proxy_for_account(account_id) { - return build_async_upstream_client_with_proxy(Some(proxy_url)); + match account_proxy_client_cache_entry(account_id) { + AccountProxyClientCacheEntry::Ready { proxy_url, .. } => { + build_async_client_with_proxy_strict(Some(proxy_url.as_str())) + } + AccountProxyClientCacheEntry::Invalid { proxy_url, error } => Err(format!( + "account explicit proxy for {account_id} is invalid and fail-closed: {proxy_url}. {error}" + )), + AccountProxyClientCacheEntry::NotConfigured => fresh_fallback_async_client_for_account(account_id), } - build_async_upstream_client() } pub(crate) fn upstream_proxy_url_for_account(account_id: &str) -> Option { ensure_runtime_config_loaded(); + match account_proxy_client_cache_entry(account_id) { + AccountProxyClientCacheEntry::Ready { proxy_url, .. } + | AccountProxyClientCacheEntry::Invalid { proxy_url, .. } => { + return Some(proxy_url); + } + AccountProxyClientCacheEntry::NotConfigured => {} + } let pool = crate::lock_utils::read_recover(upstream_client_pool_lock(), "upstream_client_pool"); - if let Some(proxy_url) = pool.proxy_for_account(account_id) { - return Some(proxy_url.to_string()); + if let Some(proxy_url) = current_upstream_proxy_url() { + return Some(proxy_url); } - current_upstream_proxy_url() + pool.proxy_for_account(account_id).map(ToString::to_string) } /// 函数 `upstream_connect_timeout_cached` @@ -316,6 +361,39 @@ pub(crate) fn apply_async_upstream_proxy( } builder } + +fn build_blocking_client_with_proxy_strict(proxy_url: Option<&str>) -> Result { + let mut builder = Client::builder() + .timeout(None::) + .connect_timeout(upstream_connect_timeout_cached()) + .pool_max_idle_per_host(32) + .pool_idle_timeout(Some(Duration::from_secs(90))) + .tcp_keepalive(Some(Duration::from_secs(30))); + if let Some(proxy_url) = proxy_url.map(str::trim).filter(|value| !value.is_empty()) { + let proxy = Proxy::all(proxy_url).map_err(|err| format!("invalid proxy url: {err}"))?; + builder = builder.proxy(proxy); + } + builder + .build() + .map_err(|err| format!("build upstream client failed: {err}")) +} + +fn build_async_client_with_proxy_strict( + proxy_url: Option<&str>, +) -> Result { + let mut builder = reqwest::Client::builder() + .connect_timeout(upstream_connect_timeout_cached()) + .pool_max_idle_per_host(32) + .pool_idle_timeout(Some(Duration::from_secs(90))) + .tcp_keepalive(Some(Duration::from_secs(30))); + if let Some(proxy_url) = proxy_url.map(str::trim).filter(|value| !value.is_empty()) { + let proxy = Proxy::all(proxy_url).map_err(|err| format!("invalid proxy url: {err}"))?; + builder = builder.proxy(proxy); + } + builder + .build() + .map_err(|err| format!("build async upstream client failed: {err}")) +} /// 函数 `build_upstream_client_with_proxy` /// /// 作者: gaohongshun @@ -1224,6 +1302,7 @@ pub(super) fn reload_from_env() { crate::lock_utils::write_recover(upstream_proxy_url_cell(), "upstream_proxy_url"); *cached_proxy_url = converted_proxy; drop(cached_proxy_url); + clear_account_proxy_client_cache(); let free_account_max_model = env_non_empty(ENV_FREE_ACCOUNT_MAX_MODEL) .and_then(|value| normalize_model_slug(value.as_str()).ok()) @@ -1380,6 +1459,10 @@ fn upstream_client_pool_lock() -> &'static RwLock { UPSTREAM_CLIENT_POOL.get_or_init(|| RwLock::new(build_upstream_client_pool())) } +fn account_proxy_clients_lock() -> &'static RwLock> { + ACCOUNT_PROXY_CLIENTS.get_or_init(|| RwLock::new(HashMap::new())) +} + /// 函数 `refresh_upstream_clients_from_runtime_config` /// /// 作者: gaohongshun @@ -1404,6 +1487,26 @@ fn refresh_upstream_clients_from_runtime_config() { *pool_lock = pool; } +pub(crate) fn invalidate_account_proxy_client_cache(account_id: &str) { + let normalized = account_id.trim(); + if normalized.is_empty() { + return; + } + let mut cache = crate::lock_utils::write_recover( + account_proxy_clients_lock(), + "account_proxy_client_cache", + ); + cache.remove(normalized); +} + +pub(crate) fn clear_account_proxy_client_cache() { + let mut cache = crate::lock_utils::write_recover( + account_proxy_clients_lock(), + "account_proxy_client_cache", + ); + cache.clear(); +} + /// 函数 `build_upstream_client_pool` /// /// 作者: gaohongshun @@ -1593,6 +1696,163 @@ fn current_upstream_proxy_url() -> Option { crate::lock_utils::read_recover(upstream_proxy_url_cell(), "upstream_proxy_url").clone() } +fn fallback_blocking_client_for_account(account_id: &str) -> Result { + if let Some(proxy_url) = current_upstream_proxy_url() { + let _ = proxy_url; + return Ok(upstream_client()); + } + + let pool = crate::lock_utils::read_recover(upstream_client_pool_lock(), "upstream_client_pool"); + if let Some(proxy_url) = pool.proxy_for_account(account_id) { + return Ok(pool + .client_for_account(account_id) + .cloned() + .unwrap_or_else(|| build_upstream_client_with_proxy(Some(proxy_url)))); + } + + Ok(upstream_client()) +} + +fn fresh_fallback_blocking_client_for_account(account_id: &str) -> Result { + if current_upstream_proxy_url().is_some() { + return Ok(build_upstream_client()); + } + + let pool = crate::lock_utils::read_recover(upstream_client_pool_lock(), "upstream_client_pool"); + if let Some(proxy_url) = pool.proxy_for_account(account_id) { + return Ok(build_upstream_client_with_proxy(Some(proxy_url))); + } + + Ok(build_upstream_client()) +} + +fn fallback_async_client_for_account(account_id: &str) -> Result { + if current_upstream_proxy_url().is_some() { + return Ok(build_async_upstream_client()); + } + + let pool = crate::lock_utils::read_recover(upstream_client_pool_lock(), "upstream_client_pool"); + if let Some(proxy_url) = pool.proxy_for_account(account_id) { + return Ok(build_async_upstream_client_with_proxy(Some(proxy_url))); + } + + Ok(build_async_upstream_client()) +} + +fn fresh_fallback_async_client_for_account(account_id: &str) -> Result { + if current_upstream_proxy_url().is_some() { + return Ok(build_async_upstream_client()); + } + + let pool = crate::lock_utils::read_recover(upstream_client_pool_lock(), "upstream_client_pool"); + if let Some(proxy_url) = pool.proxy_for_account(account_id) { + return Ok(build_async_upstream_client_with_proxy(Some(proxy_url))); + } + + Ok(build_async_upstream_client()) +} + +fn account_proxy_client_cache_entry(account_id: &str) -> AccountProxyClientCacheEntry { + let normalized = account_id.trim(); + if normalized.is_empty() { + return AccountProxyClientCacheEntry::NotConfigured; + } + + { + let cache = crate::lock_utils::read_recover( + account_proxy_clients_lock(), + "account_proxy_client_cache", + ); + if let Some(entry) = cache.get(normalized) { + return entry.clone(); + } + } + + let entry = load_account_proxy_client_cache_entry(normalized); + let mut cache = crate::lock_utils::write_recover( + account_proxy_clients_lock(), + "account_proxy_client_cache", + ); + cache.insert(normalized.to_string(), entry.clone()); + entry +} + +fn load_account_proxy_client_cache_entry(account_id: &str) -> AccountProxyClientCacheEntry { + let Some(storage) = crate::storage_helpers::open_storage() else { + return AccountProxyClientCacheEntry::NotConfigured; + }; + load_account_proxy_client_cache_entry_from_storage(&storage, account_id) +} + +fn load_account_proxy_client_cache_entry_from_storage( + storage: &Storage, + account_id: &str, +) -> AccountProxyClientCacheEntry { + let settings = match storage.find_account_proxy_settings(account_id) { + Ok(settings) => settings, + Err(err) => { + log::warn!( + "event=gateway_account_proxy_settings_read_failed account_id={} err={}", + account_id, + err + ); + return AccountProxyClientCacheEntry::NotConfigured; + } + }; + let Some(settings) = settings else { + return AccountProxyClientCacheEntry::NotConfigured; + }; + let Some(proxy_url) = settings + .proxy_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + else { + return AccountProxyClientCacheEntry::NotConfigured; + }; + if !settings.enabled { + return AccountProxyClientCacheEntry::NotConfigured; + } + + let normalized_proxy_url = match normalize_upstream_proxy_url(Some(proxy_url.as_str())) { + Ok(Some(proxy_url)) => proxy_url, + Ok(None) => return AccountProxyClientCacheEntry::NotConfigured, + Err(err) => { + return AccountProxyClientCacheEntry::Invalid { + proxy_url, + error: err, + }; + } + }; + let blocking_client = + match build_blocking_client_with_proxy_strict(Some(normalized_proxy_url.as_str())) { + Ok(client) => client, + Err(err) => { + return AccountProxyClientCacheEntry::Invalid { + proxy_url: normalized_proxy_url, + error: err, + }; + } + }; + let async_client = + match build_async_client_with_proxy_strict(Some(normalized_proxy_url.as_str())) { + Ok(client) => client, + Err(err) => { + return AccountProxyClientCacheEntry::Invalid { + proxy_url: normalized_proxy_url, + error: err, + }; + } + }; + + AccountProxyClientCacheEntry::Ready { + proxy_url: normalized_proxy_url, + blocking_client, + async_client, + } +} + /// 函数 `token_exchange_client_id_cell` /// /// 作者: gaohongshun @@ -2159,9 +2419,7 @@ fn rewrite_socks_proxy_url(proxy_url: &str) -> String { } else if let Some(rest) = normalized.strip_prefix("https://socks") { normalized = format!("socks{rest}"); } - if normalized.starts_with("socks5://") { - normalized = normalized.replacen("socks5://", "socks5h://", 1); - } else if normalized.starts_with("socks://") { + if normalized.starts_with("socks://") { normalized = normalized.replacen("socks://", "socks5h://", 1); } normalized diff --git a/crates/service/src/gateway/core/tests/runtime_config_tests.rs b/crates/service/src/gateway/core/tests/runtime_config_tests.rs index b52af6a6e..64ee3af67 100644 --- a/crates/service/src/gateway/core/tests/runtime_config_tests.rs +++ b/crates/service/src/gateway/core/tests/runtime_config_tests.rs @@ -1,4 +1,12 @@ use super::*; +use codexmanager_core::storage::{now_ts, Account, Storage}; +use std::fs; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::mpsc::{self, Receiver}; +use std::thread; struct EnvGuard { key: &'static str, @@ -63,6 +71,8 @@ impl Drop for EnvGuard { } } +static RUNTIME_CONFIG_TEST_DIR_SEQ: AtomicUsize = AtomicUsize::new(0); + /// 函数 `reload_from_env_updates_timeout_and_proxy` /// /// 作者: gaohongshun @@ -108,7 +118,7 @@ fn reload_from_env_updates_timeout_and_proxy() { ); assert_eq!( upstream_proxy_url().as_deref(), - Some("socks5h://127.0.0.1:7890") + Some("socks5://127.0.0.1:7890") ); } @@ -200,9 +210,9 @@ fn parse_proxy_list_env_normalizes_socks_entries() { let parsed = parse_proxy_list_env(); assert_eq!(parsed.len(), 3); - assert_eq!(parsed[0], "socks5h://127.0.0.1:7890"); + assert_eq!(parsed[0], "socks5://127.0.0.1:7890"); assert_eq!(parsed[1], "socks5h://127.0.0.1:7891"); - assert_eq!(parsed[2], "socks5h://127.0.0.1:7892"); + assert_eq!(parsed[2], "socks5://127.0.0.1:7892"); } /// 函数 `stable_proxy_index_is_deterministic` @@ -277,11 +287,172 @@ fn set_upstream_proxy_url_normalizes_socks_scheme() { let applied = set_upstream_proxy_url(Some("https://socks5://127.0.0.1:7890")).expect("set proxy"); - assert_eq!(applied.as_deref(), Some("socks5h://127.0.0.1:7890")); + assert_eq!(applied.as_deref(), Some("socks5://127.0.0.1:7890")); assert_eq!( std::env::var(ENV_UPSTREAM_PROXY_URL).ok().as_deref(), - Some("socks5h://127.0.0.1:7890") + Some("socks5://127.0.0.1:7890") + ); +} + +#[test] +fn upstream_proxy_url_for_account_prefers_explicit_account_proxy() { + let _guard = crate::test_env_guard(); + let db = TestDbGuard::new("runtime-account-proxy-priority"); + seed_account(db.path(), "acc-explicit"); + seed_account(db.path(), "acc-fallback"); + seed_account_proxy( + db.path(), + "acc-explicit", + true, + Some("http://127.0.0.1:7001"), + ); + let _global_guard = EnvGuard::set(ENV_UPSTREAM_PROXY_URL, "http://127.0.0.1:7002"); + let _pool_guard = EnvGuard::set(ENV_PROXY_LIST, "http://127.0.0.1:7003"); + + reload_from_env(); + + assert_eq!( + upstream_proxy_url_for_account("acc-explicit").as_deref(), + Some("http://127.0.0.1:7001") + ); + assert_eq!( + upstream_proxy_url_for_account("acc-fallback").as_deref(), + Some("http://127.0.0.1:7002") + ); +} + +#[test] +fn upstream_client_for_account_uses_explicit_proxy_before_global_and_pool() { + let _guard = crate::test_env_guard(); + let db = TestDbGuard::new("runtime-account-proxy-explicit"); + seed_account(db.path(), "acc-explicit"); + let (proxy_url, request_rx, proxy_handle) = spawn_recording_http_proxy(204); + seed_account_proxy(db.path(), "acc-explicit", true, Some(proxy_url.as_str())); + let _global_guard = EnvGuard::set(ENV_UPSTREAM_PROXY_URL, "http://127.0.0.1:9"); + let _pool_guard = EnvGuard::set(ENV_PROXY_LIST, "http://127.0.0.1:8"); + + reload_from_env(); + + let client = + upstream_client_for_account("acc-explicit").expect("resolve explicit proxy client"); + let response = client + .get("http://example.invalid/probe") + .send() + .expect("send request through explicit proxy"); + assert_eq!(response.status().as_u16(), 204); + assert_eq!( + request_rx + .recv_timeout(Duration::from_secs(2)) + .expect("capture explicit proxy request"), + "GET http://example.invalid/probe HTTP/1.1" + ); + proxy_handle.join().expect("join explicit proxy thread"); +} + +#[test] +fn fresh_upstream_client_for_account_uses_global_proxy_without_explicit_proxy() { + let _guard = crate::test_env_guard(); + let db = TestDbGuard::new("runtime-account-proxy-global"); + seed_account(db.path(), "acc-global"); + let (proxy_url, request_rx, proxy_handle) = spawn_recording_http_proxy(204); + let _global_guard = EnvGuard::set(ENV_UPSTREAM_PROXY_URL, proxy_url.as_str()); + let _pool_guard = EnvGuard::set(ENV_PROXY_LIST, "http://127.0.0.1:8"); + + reload_from_env(); + + let client = + fresh_upstream_client_for_account("acc-global").expect("resolve global proxy client"); + let response = client + .get("http://example.invalid/global") + .send() + .expect("send request through global proxy"); + assert_eq!(response.status().as_u16(), 204); + assert_eq!( + request_rx + .recv_timeout(Duration::from_secs(2)) + .expect("capture global proxy request"), + "GET http://example.invalid/global HTTP/1.1" + ); + proxy_handle.join().expect("join global proxy thread"); +} + +#[test] +fn fresh_upstream_client_for_account_uses_proxy_pool_without_explicit_or_global_proxy() { + let _guard = crate::test_env_guard(); + let db = TestDbGuard::new("runtime-account-proxy-pool"); + seed_account(db.path(), "acc-pool"); + let (proxy_url, request_rx, proxy_handle) = spawn_recording_http_proxy(204); + let _global_guard = EnvGuard::set(ENV_UPSTREAM_PROXY_URL, ""); + let _pool_guard = EnvGuard::set(ENV_PROXY_LIST, proxy_url.as_str()); + + reload_from_env(); + + let client = fresh_upstream_client_for_account("acc-pool").expect("resolve pool proxy client"); + let response = client + .get("http://example.invalid/pool") + .send() + .expect("send request through pool proxy"); + assert_eq!(response.status().as_u16(), 204); + assert_eq!( + request_rx + .recv_timeout(Duration::from_secs(2)) + .expect("capture pool proxy request"), + "GET http://example.invalid/pool HTTP/1.1" + ); + proxy_handle.join().expect("join pool proxy thread"); +} + +#[test] +fn upstream_client_for_account_fails_closed_for_invalid_explicit_proxy() { + let _guard = crate::test_env_guard(); + let db = TestDbGuard::new("runtime-account-proxy-invalid"); + seed_account(db.path(), "acc-invalid"); + seed_account_proxy(db.path(), "acc-invalid", true, Some("http://")); + let _global_guard = EnvGuard::set(ENV_UPSTREAM_PROXY_URL, "http://127.0.0.1:7002"); + let _pool_guard = EnvGuard::set(ENV_PROXY_LIST, "http://127.0.0.1:7003"); + + reload_from_env(); + + let err = + upstream_client_for_account("acc-invalid").expect_err("fail closed for invalid proxy"); + assert!(err.contains("fail-closed")); + assert!(err.contains("acc-invalid")); +} + +#[test] +fn account_proxy_set_and_clear_invalidate_gateway_cache() { + let _guard = crate::test_env_guard(); + let db = TestDbGuard::new("runtime-account-proxy-cache"); + seed_account(db.path(), "acc-cache"); + let _global_guard = EnvGuard::set(ENV_UPSTREAM_PROXY_URL, ""); + let _pool_guard = EnvGuard::set(ENV_PROXY_LIST, ""); + + reload_from_env(); + + crate::account_proxy::set_account_proxy_settings( + "acc-cache", + true, + Some("http://127.0.0.1:7101"), + ) + .expect("set first proxy"); + assert_eq!( + upstream_proxy_url_for_account("acc-cache").as_deref(), + Some("http://127.0.0.1:7101") + ); + + crate::account_proxy::set_account_proxy_settings( + "acc-cache", + true, + Some("http://127.0.0.1:7102"), + ) + .expect("set second proxy"); + assert_eq!( + upstream_proxy_url_for_account("acc-cache").as_deref(), + Some("http://127.0.0.1:7102") ); + + crate::account_proxy::clear_account_proxy_settings("acc-cache").expect("clear proxy"); + assert_eq!(upstream_proxy_url_for_account("acc-cache"), None); } /// 函数 `set_upstream_stream_timeout_ms_updates_env_and_cache` @@ -494,6 +665,119 @@ fn compact_api_path_reads_chat_completions_override_from_env() { assert!(compact_api_path_uses_chat_completions()); } +struct TestDbGuard { + dir: PathBuf, + _db_guard: EnvGuard, +} + +impl TestDbGuard { + fn new(prefix: &str) -> Self { + let dir = new_test_dir(prefix); + let db_path = dir.join("codexmanager.db"); + let db_guard = EnvGuard::set("CODEXMANAGER_DB_PATH", db_path.to_string_lossy().as_ref()); + Self { + dir, + _db_guard: db_guard, + } + } + + fn path(&self) -> &PathBuf { + &self.dir + } +} + +impl Drop for TestDbGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } +} + +fn new_test_dir(prefix: &str) -> PathBuf { + let seq = RUNTIME_CONFIG_TEST_DIR_SEQ.fetch_add(1, Ordering::Relaxed); + let mut dir = std::env::temp_dir(); + dir.push(format!("{prefix}-{}-{seq}", std::process::id())); + let _ = fs::create_dir_all(&dir); + dir +} + +fn seed_account(dir: &PathBuf, account_id: &str) { + let storage = open_test_storage(dir); + let now = now_ts(); + storage + .insert_account(&Account { + id: account_id.to_string(), + label: account_id.to_string(), + issuer: "https://auth.openai.com".to_string(), + chatgpt_account_id: Some(format!("chatgpt-{account_id}")), + workspace_id: Some(format!("workspace-{account_id}")), + group_name: None, + sort: 0, + status: "active".to_string(), + created_at: now, + updated_at: now, + }) + .expect("insert test account"); +} + +fn seed_account_proxy(dir: &PathBuf, account_id: &str, enabled: bool, proxy_url: Option<&str>) { + let storage = open_test_storage(dir); + storage + .upsert_account_proxy_settings( + account_id, + enabled, + proxy_url, + "unchecked", + None, + None, + None, + ) + .expect("insert account proxy settings"); +} + +fn open_test_storage(dir: &PathBuf) -> Storage { + let storage = Storage::open(dir.join("codexmanager.db")).expect("open test db"); + storage.init().expect("init test schema"); + storage +} + +fn spawn_recording_http_proxy( + status_code: u16, +) -> (String, Receiver, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock HTTP proxy"); + let proxy_addr = listener.local_addr().expect("mock HTTP proxy addr"); + let (request_tx, request_rx) = mpsc::channel(); + let handle = thread::spawn(move || { + let (mut client, _) = listener.accept().expect("accept proxy client"); + client + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set proxy read timeout"); + let mut request = Vec::new(); + let mut buf = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let read = client.read(&mut buf).expect("read proxy request"); + if read == 0 { + break; + } + request.extend_from_slice(&buf[..read]); + } + let request_text = String::from_utf8_lossy(request.as_slice()); + let _ = request_tx.send(request_text.lines().next().unwrap_or_default().to_string()); + let reason = if status_code == 204 { + "No Content" + } else { + "OK" + }; + let response = format!( + "HTTP/1.1 {status_code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + client + .write_all(response.as_bytes()) + .expect("write proxy response"); + client.flush().expect("flush proxy response"); + }); + (format!("http://{proxy_addr}"), request_rx, handle) +} + /// 函数 `set_originator_updates_env_and_dynamic_user_agent` /// /// 作者: gaohongshun diff --git a/crates/service/src/gateway/mod.rs b/crates/service/src/gateway/mod.rs index 378d7305d..9cde286df 100644 --- a/crates/service/src/gateway/mod.rs +++ b/crates/service/src/gateway/mod.rs @@ -386,6 +386,7 @@ use route_hint::{apply_route_strategy, apply_route_strategy_with_source}; use route_quality::record_route_quality; pub(crate) use runtime_config::fresh_upstream_client; pub(crate) use runtime_config::front_proxy_max_body_bytes; +pub(crate) use runtime_config::invalidate_account_proxy_client_cache as invalidate_account_proxy_cache; pub(crate) use runtime_config::{account_max_inflight_limit, set_account_max_inflight_limit}; use runtime_config::{ async_upstream_client_for_account, fresh_async_upstream_client_for_account, diff --git a/crates/service/src/gateway/observability/request_log.rs b/crates/service/src/gateway/observability/request_log.rs index a72f6543a..89823428e 100644 --- a/crates/service/src/gateway/observability/request_log.rs +++ b/crates/service/src/gateway/observability/request_log.rs @@ -295,7 +295,6 @@ fn resolve_value_source<'a>( (None, None) => Some("unset"), } } - fn resolve_route_details( storage: &Storage, trace_context: &RequestLogTraceContext<'_>, diff --git a/crates/service/src/gateway/upstream/attempt_flow/transport.rs b/crates/service/src/gateway/upstream/attempt_flow/transport.rs index b7dba7f1a..dec05a90a 100644 --- a/crates/service/src/gateway/upstream/attempt_flow/transport.rs +++ b/crates/service/src/gateway/upstream/attempt_flow/transport.rs @@ -643,7 +643,7 @@ pub(in super::super) fn send_upstream_request( auth_token: &str, account: &Account, strip_session_affinity: bool, -) -> Result { +) -> Result { send_upstream_request_with_compression_override( client, method, @@ -683,7 +683,7 @@ pub(in super::super) fn send_upstream_request_without_compression( auth_token: &str, account: &Account, strip_session_affinity: bool, -) -> Result { +) -> Result { send_upstream_request_with_compression_override( client, method, @@ -724,7 +724,7 @@ fn send_upstream_request_with_compression_override( account: &Account, strip_session_affinity: bool, compression_override: Option, -) -> Result { +) -> Result { let attempt_started_at = Instant::now(); let is_compact_request = is_compact_request_path(request_ctx.request_path); let chatgpt_account_header = resolve_chatgpt_account_header(account, target_url); @@ -964,7 +964,10 @@ fn send_upstream_request_with_compression_override( Ok(r) } else if use_async_stream_transport { let async_client = - super::super::super::async_upstream_client_for_account(account.id.as_str()); + match super::super::super::async_upstream_client_for_account(account.id.as_str()) { + Ok(client) => client, + Err(err) => return Err(err), + }; match send_async_stream_request( &async_client, method, @@ -977,9 +980,12 @@ fn send_upstream_request_with_compression_override( ) { Ok(resp) => Ok(GatewayUpstreamResponse::Stream(resp)), Err(first_err) => { - let fresh_async = super::super::super::fresh_async_upstream_client_for_account( + let fresh_async = match super::super::super::fresh_async_upstream_client_for_account( account.id.as_str(), - ); + ) { + Ok(client) => client, + Err(err) => return Err(err), + }; if should_retry_transport_without_compression( target_url, request_ctx.request_path, @@ -1021,7 +1027,7 @@ fn send_upstream_request_with_compression_override( first_err, second_err ); - Err(second_err) + Err(second_err.to_string()) } } } else { @@ -1053,7 +1059,7 @@ fn send_upstream_request_with_compression_override( first_err, second_err ); - Err(second_err) + Err(second_err.to_string()) } } } @@ -1063,8 +1069,12 @@ fn send_upstream_request_with_compression_override( match build_request(client, upstream_headers.as_slice(), &body_for_request).send() { Ok(resp) => Ok(resp.into()), Err(first_err) => { - let fresh = - super::super::super::fresh_upstream_client_for_account(account.id.as_str()); + let fresh = match super::super::super::fresh_upstream_client_for_account( + account.id.as_str(), + ) { + Ok(client) => client, + Err(err) => return Err(err), + }; if should_retry_transport_without_compression( target_url, request_ctx.request_path, @@ -1099,7 +1109,7 @@ fn send_upstream_request_with_compression_override( first_err, second_err ); - Err(second_err) + Err(second_err.to_string()) } } } else { @@ -1124,7 +1134,7 @@ fn send_upstream_request_with_compression_override( first_err, second_err ); - Err(second_err) + Err(second_err.to_string()) } } } diff --git a/crates/service/src/gateway/upstream/executor/codex.rs b/crates/service/src/gateway/upstream/executor/codex.rs index dbfeb7c58..6e4b19ac7 100644 --- a/crates/service/src/gateway/upstream/executor/codex.rs +++ b/crates/service/src/gateway/upstream/executor/codex.rs @@ -37,7 +37,15 @@ pub(super) fn execute( where F: FnMut(Option<&str>, u16, Option<&str>), { - let client = super::super::super::upstream_client_for_account(account.id.as_str()); + let client = match super::super::super::upstream_client_for_account(account.id.as_str()) { + Ok(client) => client, + Err(err) => { + return CandidateUpstreamDecision::Terminal { + status_code: 502, + message: err, + }; + } + }; if deadline::is_expired(request_deadline) { return CandidateUpstreamDecision::Terminal { diff --git a/crates/service/src/lib.rs b/crates/service/src/lib.rs index bfd187062..f46875c79 100644 --- a/crates/service/src/lib.rs +++ b/crates/service/src/lib.rs @@ -32,6 +32,7 @@ pub(crate) use account::export as account_export; pub(crate) use account::import as account_import; pub(crate) use account::list as account_list; pub(crate) use account::plan as account_plan; +pub(crate) use account::proxy as account_proxy; pub(crate) use account::status as account_status; pub(crate) use account::update as account_update; pub(crate) use account::warmup as account_warmup; diff --git a/crates/service/src/quota/read.rs b/crates/service/src/quota/read.rs index d32db0651..840817f44 100644 --- a/crates/service/src/quota/read.rs +++ b/crates/service/src/quota/read.rs @@ -13,9 +13,9 @@ use codexmanager_core::rpc::types::{ QuotaSystemPoolResult, QuotaTodayUsageResult, }; use codexmanager_core::storage::{ - now_ts, Account, AccountQuotaCapacityOverride, AccountQuotaCapacityTemplate, AccountSubscription, - AggregateApi, ApiKey, BillingRule, ModelPriceRule, QuotaSourceModelAssignment, Token, - UsageSnapshotRecord, + now_ts, Account, AccountQuotaCapacityOverride, AccountQuotaCapacityTemplate, + AccountSubscription, AggregateApi, ApiKey, BillingRule, ModelPriceRule, + QuotaSourceModelAssignment, Token, UsageSnapshotRecord, }; use rand::RngCore; use serde_json::Value; @@ -1526,13 +1526,11 @@ pub(crate) fn upsert_model_price_rule( .unwrap_or_else(|| format!("user-{}", model_pattern)); let rule = ModelPriceRule { id, - provider: input - .provider - .unwrap_or_else(|| crate::quota::model_pricing::infer_provider(&model_pattern).to_string()), + provider: input.provider.unwrap_or_else(|| { + crate::quota::model_pricing::infer_provider(&model_pattern).to_string() + }), model_pattern, - match_type: input - .match_type - .unwrap_or_else(|| "exact".to_string()), + match_type: input.match_type.unwrap_or_else(|| "exact".to_string()), billing_mode: "standard".to_string(), currency: "USD".to_string(), unit: "per_1m_tokens".to_string(), diff --git a/crates/service/src/rpc_dispatch/account.rs b/crates/service/src/rpc_dispatch/account.rs index ba4a36380..ba868980d 100644 --- a/crates/service/src/rpc_dispatch/account.rs +++ b/crates/service/src/rpc_dispatch/account.rs @@ -2,7 +2,8 @@ use codexmanager_core::rpc::types::{JsonRpcRequest, JsonRpcResponse}; use crate::{ account_cleanup, account_delete, account_delete_many, account_export, account_import, - account_list, account_update, account_warmup, auth_account, auth_login, auth_tokens, + account_list, account_proxy, account_update, account_warmup, auth_account, auth_login, + auth_tokens, }; /// 函数 `try_handle` @@ -105,6 +106,26 @@ pub(super) fn try_handle(req: &JsonRpcRequest) -> Option { let message = first_string_param(req, &["message"]).unwrap_or_default(); super::value_or_error(account_warmup::warmup_accounts(account_ids, &message)) } + "account/proxy/get" => { + let account_id = first_str_param(req, &["accountId", "account_id"]).unwrap_or(""); + super::value_or_error(account_proxy::get_account_proxy_settings(account_id)) + } + "account/proxy/set" => { + let account_id = first_str_param(req, &["accountId", "account_id"]).unwrap_or(""); + let enabled = super::bool_param(req, "enabled").unwrap_or(false); + let proxy_url = first_str_param(req, &["proxyUrl", "proxy_url"]); + super::value_or_error(account_proxy::set_account_proxy_settings( + account_id, enabled, proxy_url, + )) + } + "account/proxy/clear" => { + let account_id = first_str_param(req, &["accountId", "account_id"]).unwrap_or(""); + super::value_or_error(account_proxy::clear_account_proxy_settings(account_id)) + } + "account/proxy/test" => { + let account_id = first_str_param(req, &["accountId", "account_id"]).unwrap_or(""); + super::value_or_error(account_proxy::test_account_proxy_settings(account_id)) + } "account/import" => { let mut contents = req .params diff --git a/crates/service/src/rpc_dispatch/apikey.rs b/crates/service/src/rpc_dispatch/apikey.rs index 6f75868d5..def4ca9f5 100644 --- a/crates/service/src/rpc_dispatch/apikey.rs +++ b/crates/service/src/rpc_dispatch/apikey.rs @@ -249,7 +249,10 @@ pub(super) fn try_handle(req: &JsonRpcRequest, actor: &RpcActor) -> Option super::value_or_error( diff --git a/crates/service/src/rpc_dispatch/mod.rs b/crates/service/src/rpc_dispatch/mod.rs index 44e930062..807e17577 100644 --- a/crates/service/src/rpc_dispatch/mod.rs +++ b/crates/service/src/rpc_dispatch/mod.rs @@ -185,6 +185,10 @@ const MEMBER_METHOD_ALLOWLIST: &[&str] = &[ "account/chatgptAuthTokens/refresh", "account/chatgptAuthTokens/refreshAll", "account/list", + "account/proxy/clear", + "account/proxy/get", + "account/proxy/set", + "account/proxy/test", "account/read", "account/update", "account/usage/aggregate", diff --git a/crates/service/src/rpc_dispatch/quota.rs b/crates/service/src/rpc_dispatch/quota.rs index 80c62f613..0911b066a 100644 --- a/crates/service/src/rpc_dispatch/quota.rs +++ b/crates/service/src/rpc_dispatch/quota.rs @@ -90,7 +90,9 @@ pub(super) fn try_handle(req: &JsonRpcRequest) -> Option { let input = ModelPriceRuleUpsertInput { id: super::string_param(req, "id"), provider: super::string_param(req, "provider"), - model_pattern: super::str_param(req, "modelPattern").unwrap_or("").to_string(), + model_pattern: super::str_param(req, "modelPattern") + .unwrap_or("") + .to_string(), match_type: super::string_param(req, "matchType"), input_price_per_1m: req .params diff --git a/crates/service/src/rpc_dispatch/requestlog.rs b/crates/service/src/rpc_dispatch/requestlog.rs index 8219993e8..47171712e 100644 --- a/crates/service/src/rpc_dispatch/requestlog.rs +++ b/crates/service/src/rpc_dispatch/requestlog.rs @@ -1,9 +1,7 @@ use codexmanager_core::rpc::types::{JsonRpcRequest, JsonRpcResponse, RequestLogListParams}; use crate::RpcActor; -use crate::{ - requestlog_clear, requestlog_list, requestlog_summary, requestlog_today_summary, -}; +use crate::{requestlog_clear, requestlog_list, requestlog_summary, requestlog_today_summary}; fn actor_key_ids(actor: &RpcActor) -> Result, String> { if actor.is_admin() { diff --git a/crates/service/tests/app_settings.rs b/crates/service/tests/app_settings.rs index 636aec692..f5efd11a6 100644 --- a/crates/service/tests/app_settings.rs +++ b/crates/service/tests/app_settings.rs @@ -1044,7 +1044,9 @@ fn app_settings_get_exposes_runtime_time_zone_from_tz_env() { .expect("runtime time zone object"); assert_eq!( - runtime_time_zone.get("name").and_then(|value| value.as_str()), + runtime_time_zone + .get("name") + .and_then(|value| value.as_str()), Some("Asia/Shanghai") ); assert_eq!( From 1836a5b60a006952ab8b09cb3132b5b4354b2d3e Mon Sep 17 00:00:00 2001 From: baltic-tea Date: Tue, 9 Jun 2026 13:12:33 +0300 Subject: [PATCH 2/9] feat(apps): implement UI and localization for account proxy management --- apps/src-tauri/src/commands/account/remote.rs | 42 +++++++ apps/src-tauri/src/commands/registry.rs | 4 + apps/src/app/accounts/page.tsx | 105 ++++++++++++++++++ apps/src/hooks/useAccounts.ts | 78 ++++++++++++- apps/src/lib/api/account-client.ts | 73 ++++++++++++ .../lib/api/transport-web-commands/account.ts | 4 + apps/src/lib/i18n/messages/ru.ts | 13 ++- .../lib/i18n/messages/sections/en-accounts.ts | 33 ++++++ .../lib/i18n/messages/sections/ko-accounts.ts | 33 ++++++ 9 files changed, 383 insertions(+), 2 deletions(-) diff --git a/apps/src-tauri/src/commands/account/remote.rs b/apps/src-tauri/src/commands/account/remote.rs index 970c68af4..a6b00585f 100644 --- a/apps/src-tauri/src/commands/account/remote.rs +++ b/apps/src-tauri/src/commands/account/remote.rs @@ -248,6 +248,48 @@ pub async fn service_account_warmup( rpc_call_in_background("account/warmup", addr, Some(params)).await } +#[tauri::command] +pub async fn service_account_proxy_get( + addr: Option, + account_id: String, +) -> Result { + let params = serde_json::json!({ "accountId": account_id }); + rpc_call_in_background("account/proxy/get", addr, Some(params)).await +} + +#[tauri::command] +pub async fn service_account_proxy_set( + addr: Option, + account_id: String, + enabled: bool, + proxy_url: Option, +) -> Result { + let params = serde_json::json!({ + "accountId": account_id, + "enabled": enabled, + "proxyUrl": proxy_url, + }); + rpc_call_in_background("account/proxy/set", addr, Some(params)).await +} + +#[tauri::command] +pub async fn service_account_proxy_clear( + addr: Option, + account_id: String, +) -> Result { + let params = serde_json::json!({ "accountId": account_id }); + rpc_call_in_background("account/proxy/clear", addr, Some(params)).await +} + +#[tauri::command] +pub async fn service_account_proxy_test( + addr: Option, + account_id: String, +) -> Result { + let params = serde_json::json!({ "accountId": account_id }); + rpc_call_in_background("account/proxy/test", addr, Some(params)).await +} + #[cfg(test)] mod tests { use super::account_update_payload; diff --git a/apps/src-tauri/src/commands/registry.rs b/apps/src-tauri/src/commands/registry.rs index 21b9e81fe..22137c639 100644 --- a/apps/src-tauri/src/commands/registry.rs +++ b/apps/src-tauri/src/commands/registry.rs @@ -88,6 +88,10 @@ macro_rules! invoke_handler { crate::commands::account::remote::service_account_delete_by_statuses, crate::commands::account::remote::service_account_update, crate::commands::account::remote::service_account_warmup, + crate::commands::account::remote::service_account_proxy_get, + crate::commands::account::remote::service_account_proxy_set, + crate::commands::account::remote::service_account_proxy_clear, + crate::commands::account::remote::service_account_proxy_test, crate::commands::account::transfer::service_account_import, crate::commands::account::transfer::service_account_import_by_directory, crate::commands::account::transfer::service_account_import_by_file, diff --git a/apps/src/app/accounts/page.tsx b/apps/src/app/accounts/page.tsx index cc67aa6fb..9f6a42be8 100644 --- a/apps/src/app/accounts/page.tsx +++ b/apps/src/app/accounts/page.tsx @@ -6,6 +6,7 @@ import { useAccounts } from "@/hooks/useAccounts"; import { useDesktopPageActive } from "@/hooks/useDesktopPageActive"; import { usePageTransitionReady } from "@/hooks/usePageTransitionReady"; import { useRuntimeCapabilities } from "@/hooks/useRuntimeCapabilities"; +import type { AccountProxySettings } from "@/lib/api/account-client"; import { useI18n } from "@/lib/i18n/provider"; import { buildAccountsBySizeOrder, @@ -76,6 +77,13 @@ export default function AccountsPage() { setPreferredAccount, clearPreferredAccount, isUpdatingPreferred, + getAccountProxySettings, + setAccountProxySettings, + clearAccountProxySettings, + testAccountProxySettings, + isSavingAccountProxy, + isClearingAccountProxy, + isTestingAccountProxy, reorderAccounts, isReorderingAccounts, updateAccountProfile, @@ -106,6 +114,11 @@ export default function AccountsPage() { const [modelWhitelistDraft, setModelWhitelistDraft] = useState(""); const [quotaPrimaryDraft, setQuotaPrimaryDraft] = useState(""); const [quotaSecondaryDraft, setQuotaSecondaryDraft] = useState(""); + const [proxyDialogAccount, setProxyDialogAccount] = useState(null); + const [proxySettings, setProxySettings] = useState(null); + const [isProxySettingsLoading, setIsProxySettingsLoading] = useState(false); + const [proxyEnabledDraft, setProxyEnabledDraft] = useState(false); + const [proxyUrlDraft, setProxyUrlDraft] = useState(""); const [accountEditorState, setAccountEditorState] = useState(null); const [deleteDialogState, setDeleteDialogState] = @@ -430,6 +443,83 @@ const toggleCleanupStatus = (rawStatus: string) => { setDeleteDialogState({ kind: "single", account }); }; + const openProxyDialog = async (account: Account) => { + setProxyDialogAccount(account); + setProxySettings(null); + setProxyEnabledDraft(false); + setProxyUrlDraft(""); + setIsProxySettingsLoading(true); + try { + const settings = await getAccountProxySettings(account.id); + setProxySettings(settings); + setProxyEnabledDraft(settings.enabled); + setProxyUrlDraft(settings.proxyUrl || ""); + } catch (error) { + toast.error(`${t("读取账号代理失败")}: ${error instanceof Error ? error.message : String(error)}`); + setProxyDialogAccount(null); + } finally { + setIsProxySettingsLoading(false); + } + }; + + const handleProxyDialogOpenChange = (open: boolean) => { + if (open) return; + if (isSavingAccountProxy || isClearingAccountProxy || isTestingAccountProxy) { + return; + } + setProxyDialogAccount(null); + setProxySettings(null); + setProxyEnabledDraft(false); + setProxyUrlDraft(""); + }; + + const handleSaveProxySettings = async () => { + if (!proxyDialogAccount) return; + try { + const settings = await setAccountProxySettings({ + accountId: proxyDialogAccount.id, + enabled: proxyEnabledDraft, + proxyUrl: proxyUrlDraft, + }); + if (settings) { + setProxySettings(settings); + setProxyEnabledDraft(settings.enabled); + setProxyUrlDraft(settings.proxyUrl || ""); + } + setProxyDialogAccount(null); + } catch { + // hook handles toast + } + }; + + const handleClearProxySettings = async () => { + if (!proxyDialogAccount) return; + try { + const settings = await clearAccountProxySettings(proxyDialogAccount.id); + if (settings) { + setProxySettings(settings); + setProxyEnabledDraft(settings.enabled); + setProxyUrlDraft(settings.proxyUrl || ""); + } + } catch { + // hook handles toast + } + }; + + const handleTestProxySettings = async () => { + if (!proxyDialogAccount) return; + try { + const settings = await testAccountProxySettings(proxyDialogAccount.id); + if (settings) { + setProxySettings(settings); + setProxyEnabledDraft(settings.enabled); + setProxyUrlDraft(settings.proxyUrl || ""); + } + } catch { + // hook handles toast + } + }; + const openAccountEditor = (account: Account) => { setAccountEditorState({ accountId: account.id, @@ -643,6 +733,11 @@ const toggleCleanupStatus = (rawStatus: string) => { cleanupDialogOpen={cleanupDialogOpen} cleanupStatusDraft={cleanupStatusDraft} cleanupStatusOptions={cleanupStatusOptions} + proxyDialogAccount={proxyDialogAccount} + proxySettings={proxySettings} + isProxySettingsLoading={isProxySettingsLoading} + proxyEnabledDraft={proxyEnabledDraft} + proxyUrlDraft={proxyUrlDraft} currentEditingAccount={currentEditingAccount} labelDraft={labelDraft} tagsDraft={tagsDraft} @@ -660,6 +755,9 @@ const toggleCleanupStatus = (rawStatus: string) => { isDeletingMany={isDeletingMany} isCleaningAccountsByStatus={isCleaningAccountsByStatus} isUpdatingPreferred={isUpdatingPreferred} + isSavingAccountProxy={isSavingAccountProxy} + isClearingAccountProxy={isClearingAccountProxy} + isTestingAccountProxy={isTestingAccountProxy} isReorderingAccounts={isReorderingAccounts} isUpdatingProfileAccountId={isUpdatingProfileAccountId} isUpdatingStatusAccountId={isUpdatingStatusAccountId} @@ -673,6 +771,8 @@ const toggleCleanupStatus = (rawStatus: string) => { setExportModeDraft={setExportModeDraft} setDeleteDialogState={setDeleteDialogState} setCleanupDialogOpen={setCleanupDialogOpen} + setProxyEnabledDraft={setProxyEnabledDraft} + setProxyUrlDraft={setProxyUrlDraft} setAccountEditorState={setAccountEditorState} setLabelDraft={setLabelDraft} setTagsDraft={setTagsDraft} @@ -698,6 +798,11 @@ const toggleCleanupStatus = (rawStatus: string) => { openExportDialog={openExportDialog} handleConfirmExport={handleConfirmExport} handleDeleteSingle={handleDeleteSingle} + openProxyDialog={openProxyDialog} + handleProxyDialogOpenChange={handleProxyDialogOpenChange} + handleSaveProxySettings={handleSaveProxySettings} + handleClearProxySettings={handleClearProxySettings} + handleTestProxySettings={handleTestProxySettings} openAccountEditor={openAccountEditor} handleMoveAccount={handleMoveAccount} handleApplyAccountSizeSort={handleApplyAccountSizeSort} diff --git a/apps/src/hooks/useAccounts.ts b/apps/src/hooks/useAccounts.ts index 29f952a16..41f048da5 100644 --- a/apps/src/hooks/useAccounts.ts +++ b/apps/src/hooks/useAccounts.ts @@ -28,6 +28,8 @@ type AccountExportPayload = Parameters[0]; type ExportResult = Awaited>; type WarmupPayload = Parameters[0]; type WarmupResult = Awaited>; +type AccountProxySettings = Awaited>; +type AccountProxySetPayload = Parameters[0]; type RefreshAllRtResult = Awaited< ReturnType >; @@ -35,6 +37,7 @@ type DeleteAccountsByStatusesResult = Awaited< ReturnType >; type AccountSortUpdate = { accountId: string; sort: number }; +const ACCOUNTS_LIST_QUERY_KEY = ["accounts", "list"] as const; /** * 函数 `isAccountRefreshBlocked` @@ -441,7 +444,7 @@ export function useAccounts() { const invalidateAccountData = async () => { await Promise.all([ - queryClient.invalidateQueries({ queryKey: ["accounts", "list"] }), + queryClient.invalidateQueries({ queryKey: ACCOUNTS_LIST_QUERY_KEY }), invalidateUsageData(), ]); }; @@ -798,6 +801,63 @@ export function useAccounts() { }, }); + const getAccountProxySettings = async ( + accountId: string, + ): Promise => { + if (!ensureServiceReady("读取账号代理")) { + throw new Error(t("服务未连接,暂时无法读取账号代理")); + } + const targetAccountId = accountId.trim(); + if (!targetAccountId) { + throw new Error(t("未找到当前账号,请刷新后重试")); + } + return accountClient.getProxySettings(targetAccountId); + }; + + const setAccountProxyMutation = useMutation({ + mutationFn: (params: AccountProxySetPayload) => + accountClient.setProxySettings(params), + onSuccess: async (settings) => { + await invalidateUsageData(); + toast.success( + settings.enabled ? t("账号代理已保存") : t("账号代理已关闭"), + ); + }, + onError: (error: unknown) => { + toast.error(`${t("保存账号代理失败")}: ${getAppErrorMessage(error)}`); + }, + }); + + const clearAccountProxyMutation = useMutation({ + mutationFn: (accountId: string) => accountClient.clearProxySettings(accountId), + onSuccess: async () => { + await invalidateUsageData(); + toast.success(t("账号代理已清除")); + }, + onError: (error: unknown) => { + toast.error(`${t("清除账号代理失败")}: ${getAppErrorMessage(error)}`); + }, + }); + + const testAccountProxyMutation = useMutation({ + mutationFn: (accountId: string) => accountClient.testProxySettings(accountId), + onSuccess: async (settings) => { + await invalidateUsageData(); + if (settings.status === "ok") { + toast.success(t("账号代理测试通过")); + return; + } + toast.warning( + settings.lastError + ? `${t("账号代理测试未通过")}: ${settings.lastError}` + : t("账号代理测试未通过"), + ); + }, + onError: (error: unknown) => { + toast.error(`${t("测试账号代理失败")}: ${getAppErrorMessage(error)}`); + }, + }); + return { accounts, planTypes, @@ -882,6 +942,19 @@ export function useAccounts() { if (!ensureServiceReady("取消优先账号")) return; clearPreferredMutation.mutate(accountId); }, + getAccountProxySettings, + setAccountProxySettings: async (params: AccountProxySetPayload) => { + if (!ensureServiceReady("保存账号代理")) return; + return await setAccountProxyMutation.mutateAsync(params); + }, + clearAccountProxySettings: async (accountId: string) => { + if (!ensureServiceReady("清除账号代理")) return; + return await clearAccountProxyMutation.mutateAsync(accountId); + }, + testAccountProxySettings: async (accountId: string) => { + if (!ensureServiceReady("测试账号代理")) return; + return await testAccountProxyMutation.mutateAsync(accountId); + }, updateAccountSort: async (accountId: string, sort: number) => { if (!ensureServiceReady("更新账号顺序")) return; await updateAccountSortMutation.mutateAsync({ accountId, sort }); @@ -931,6 +1004,9 @@ export function useAccounts() { isCleaningAccountsByStatus: deleteByStatusesMutation.isPending, isUpdatingPreferred: setPreferredMutation.isPending || clearPreferredMutation.isPending, + isSavingAccountProxy: setAccountProxyMutation.isPending, + isClearingAccountProxy: clearAccountProxyMutation.isPending, + isTestingAccountProxy: testAccountProxyMutation.isPending, isUpdatingSortAccountId: updateAccountSortMutation.isPending && updateAccountSortMutation.variables && diff --git a/apps/src/lib/api/account-client.ts b/apps/src/lib/api/account-client.ts index 1afce4fb5..ddac73cdf 100644 --- a/apps/src/lib/api/account-client.ts +++ b/apps/src/lib/api/account-client.ts @@ -80,6 +80,22 @@ export interface AccountWarmupPayload { message?: string; } +export interface AccountProxySettings { + accountId: string; + enabled: boolean; + proxyUrl: string; + status: string; + latencyMs: number | null; + lastCheckAt: number | null; + lastError: string | null; +} + +export interface AccountProxySetPayload { + accountId: string; + enabled: boolean; + proxyUrl?: string | null; +} + export interface AccountDeleteByStatusesPayload { statuses: string[]; } @@ -340,6 +356,38 @@ function mergeImportResult( } } +function readAccountProxySettings(payload: unknown): AccountProxySettings { + const source = + payload && typeof payload === "object" + ? (payload as Record) + : {}; + const readNumber = (value: unknown): number | null => { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; + }; + const readString = (value: unknown): string => + typeof value === "string" ? value : value == null ? "" : String(value); + + return { + accountId: readString(source.accountId ?? source.account_id), + enabled: Boolean(source.enabled), + proxyUrl: readString(source.proxyUrl ?? source.proxy_url), + status: readString(source.status || "not_configured"), + latencyMs: readNumber(source.latencyMs ?? source.latency_ms), + lastCheckAt: readNumber(source.lastCheckAt ?? source.last_check_at), + lastError: + source.lastError == null && source.last_error == null + ? null + : readString(source.lastError ?? source.last_error), + }; +} + /** * 函数 `importAccountContents` * @@ -489,6 +537,31 @@ export const accountClient = { }), ), ), + getProxySettings: async (accountId: string): Promise => + readAccountProxySettings( + await invoke("service_account_proxy_get", withAddr({ accountId })), + ), + setProxySettings: async ( + params: AccountProxySetPayload, + ): Promise => + readAccountProxySettings( + await invoke( + "service_account_proxy_set", + withAddr({ + accountId: params.accountId, + enabled: params.enabled, + proxyUrl: params.proxyUrl ?? null, + }), + ), + ), + clearProxySettings: async (accountId: string): Promise => + readAccountProxySettings( + await invoke("service_account_proxy_clear", withAddr({ accountId })), + ), + testProxySettings: async (accountId: string): Promise => + readAccountProxySettings( + await invoke("service_account_proxy_test", withAddr({ accountId })), + ), async getUsage(accountId: string): Promise { const result = await invoke( diff --git a/apps/src/lib/api/transport-web-commands/account.ts b/apps/src/lib/api/transport-web-commands/account.ts index dbb60cca6..b1b806951 100644 --- a/apps/src/lib/api/transport-web-commands/account.ts +++ b/apps/src/lib/api/transport-web-commands/account.ts @@ -17,6 +17,10 @@ export function createAccountWebCommands(postWebRpc: WebRpcCaller): Record exportAccountsViaBrowser(postWebRpc, asRecord(params), options), }, service_account_warmup: { rpcMethod: "account/warmup" }, + service_account_proxy_get: { rpcMethod: "account/proxy/get" }, + service_account_proxy_set: { rpcMethod: "account/proxy/set" }, + service_account_proxy_clear: { rpcMethod: "account/proxy/clear" }, + service_account_proxy_test: { rpcMethod: "account/proxy/test" }, service_account_manager_status: { rpcMethod: "accountManager/status" }, service_account_manager_session_current: { rpcMethod: "accountManager/session/current" }, service_account_manager_profile_update: { rpcMethod: "accountManager/profile/update" }, diff --git a/apps/src/lib/i18n/messages/ru.ts b/apps/src/lib/i18n/messages/ru.ts index ba35c1360..3e5e2cd1a 100644 --- a/apps/src/lib/i18n/messages/ru.ts +++ b/apps/src/lib/i18n/messages/ru.ts @@ -15,6 +15,18 @@ import { RU_PLATFORM_MODE_MESSAGES } from "./sections/ru-platform-mode"; import { RU_RUNTIME_UI_MESSAGES } from "./sections/ru-runtime-ui"; export const RU_MESSAGES: MessageCatalog = { + ...RU_ACCESS_CONTROL_MESSAGES, + ...RU_ACCOUNT_MANAGER_MESSAGES, + ...RU_ACCOUNTS_MESSAGES, + ...RU_API_KEYS_MESSAGES, + ...RU_AGGREGATE_API_MESSAGES, + ...RU_DASHBOARD_MESSAGES, + ...RU_DYNAMIC_UI_MESSAGES, + ...RU_MODEL_CATALOG_MESSAGES, + ...RU_MODEL_GROUPS_MESSAGES, + ...RU_MODELS_MESSAGES, + ...RU_PLATFORM_MODE_MESSAGES, + ...RU_RUNTIME_UI_MESSAGES, 仪表盘: "Обзор", 概览: "Обзор", 资源接入: "Ресурсы", @@ -2090,5 +2102,4 @@ export const RU_MESSAGES: MessageCatalog = { 仅网关流量: "Только трафик шлюза", "账号直连模式下不会产生请求日志,如需记录请求请切换到本地网关模式。": "В режиме прямого аккаунта журналы запросов не создаются; если нужен журнал, переключитесь на режим локального шлюза.", - ...RU_PLATFORM_MODE_MESSAGES, }; diff --git a/apps/src/lib/i18n/messages/sections/en-accounts.ts b/apps/src/lib/i18n/messages/sections/en-accounts.ts index e86ce9035..d3af7046e 100644 --- a/apps/src/lib/i18n/messages/sections/en-accounts.ts +++ b/apps/src/lib/i18n/messages/sections/en-accounts.ts @@ -25,12 +25,36 @@ export const EN_ACCOUNTS_MESSAGES: MessageCatalog = { "Only used for quota pool ownership statistics. Leave blank to make this account effective for all API-available models.", "代理地区不受支持,已暂停账号刷新": "The proxy region is not supported. Account refresh has been paused.", + "保存账号代理": "Save account proxy", + "保存账号代理失败": "Failed to save account proxy", + "测试账号代理": "Test account proxy", + "测试账号代理失败": "Failed to test account proxy", + "测试中": "Testing", + "未测试": "Untested", + "测试失败": "Test failed", + "测试状态": "Test status", + "从未检查": "Never checked", + "代理地址": "Proxy URL", + "地址无效": "Invalid URL", "刷新 AT/RT": "Refresh AT/RT", "刷新 AT/RT 失败": "Failed to refresh AT/RT", "刷新全部 AT/RT": "Refresh all AT/RT", "刷新用量": "Refresh usage", "刷新登录凭证返回 401,需要重新登录": "Refreshing login credentials returned 401. Log in again.", + "读取账号代理": "Read account proxy", + "读取账号代理失败": "Failed to read account proxy", + "服务未连接,暂时无法读取账号代理": + "Service is not connected, account proxy cannot be read yet", + "默认路由": "Default route", + "启用后,该账号会优先使用这里的代理地址。": + "When enabled, this account uses this proxy URL before other routing.", + "启用账号代理": "Enable account proxy", + "清除账号代理": "Clear account proxy", + "清除账号代理失败": "Failed to clear account proxy", + "为单个 OpenAI 账号配置本地代理。": + "Configure a local proxy for one OpenAI account.", + "延迟": "Latency", "原因码": "Reason code", "容量覆盖": "Capacity override", "当前没有匹配所选状态的账号": "No accounts match the selected statuses", @@ -62,6 +86,12 @@ export const EN_ACCOUNTS_MESSAGES: MessageCatalog = { "确认清理": "Confirm cleanup", "缺少授权 Token": "Missing auth token", "账号 AT/RT 已刷新": "Account AT/RT refreshed", + "账号代理": "Account proxy", + "账号代理测试通过": "Account proxy test passed", + "账号代理测试未通过": "Account proxy test did not pass", + "账号代理已保存": "Account proxy saved", + "账号代理已关闭": "Account proxy disabled", + "账号代理已清除": "Account proxy cleared", "账号已停用": "Account deactivated", "账号或工作区被停用的账号": "Accounts or workspaces that were deactivated", "状态字段为 unknown 的账号": "Accounts whose status field is unknown", @@ -73,6 +103,9 @@ export const EN_ACCOUNTS_MESSAGES: MessageCatalog = { "Choose an export mode. If accounts are selected, only the selected items will be exported.", "选择要删除的账号状态;删除后不可恢复。": "Choose the account statuses to delete. Deletion cannot be undone.", + "支持 http、https、socks5、socks5h;sing-box mixed inbound 通常填写 http://127.0.0.1:端口。": + "Supports http, https, socks5, and socks5h. For sing-box mixed inbound, usually enter http://127.0.0.1:port.", + "最近检查": "Last check", "这里展示账号套餐接口同步回来的套餐状态与时间信息。": "This shows plan status and time information synced from the account plan API.", "额度容量必须是大于 0 的数字,留空表示未覆盖": diff --git a/apps/src/lib/i18n/messages/sections/ko-accounts.ts b/apps/src/lib/i18n/messages/sections/ko-accounts.ts index 629830313..def514849 100644 --- a/apps/src/lib/i18n/messages/sections/ko-accounts.ts +++ b/apps/src/lib/i18n/messages/sections/ko-accounts.ts @@ -25,12 +25,36 @@ export const KO_ACCOUNTS_MESSAGES: MessageCatalog = { "한도 풀 귀속 통계에만 사용됩니다. 비워 두면 이 계정이 모든 API 사용 가능 모델에 적용됩니다.", "代理地区不受支持,已暂停账号刷新": "프록시 지역이 지원되지 않아 계정 새로고침을 일시 중지했습니다.", + "保存账号代理": "계정 프록시 저장", + "保存账号代理失败": "계정 프록시 저장 실패", + "测试账号代理": "계정 프록시 테스트", + "测试账号代理失败": "계정 프록시 테스트 실패", + "测试中": "테스트 중", + "未测试": "미테스트", + "测试失败": "테스트 실패", + "测试状态": "테스트 상태", + "从未检查": "아직 확인하지 않음", + "代理地址": "프록시 주소", + "地址无效": "주소가 유효하지 않음", "刷新 AT/RT": "AT/RT 새로고침", "刷新 AT/RT 失败": "AT/RT 새로고침 실패", "刷新全部 AT/RT": "전체 AT/RT 새로고침", "刷新用量": "사용량 새로고침", "刷新登录凭证返回 401,需要重新登录": "로그인 자격 증명 새로고침이 401을 반환했습니다. 다시 로그인하세요.", + "读取账号代理": "계정 프록시 읽기", + "读取账号代理失败": "계정 프록시 읽기 실패", + "服务未连接,暂时无法读取账号代理": + "서비스가 연결되지 않아 아직 계정 프록시를 읽을 수 없습니다", + "默认路由": "기본 라우트", + "启用后,该账号会优先使用这里的代理地址。": + "활성화하면 이 계정은 이 프록시 주소를 우선 사용합니다.", + "启用账号代理": "계정 프록시 활성화", + "清除账号代理": "계정 프록시 지우기", + "清除账号代理失败": "계정 프록시 지우기 실패", + "为单个 OpenAI 账号配置本地代理。": + "단일 OpenAI 계정에 로컬 프록시를 설정합니다.", + "延迟": "지연 시간", "原因码": "원인 코드", "容量覆盖": "용량 오버라이드", "当前没有匹配所选状态的账号": "선택한 상태와 일치하는 계정이 없습니다", @@ -62,6 +86,12 @@ export const KO_ACCOUNTS_MESSAGES: MessageCatalog = { "确认清理": "정리 확인", "缺少授权 Token": "인증 Token이 없습니다", "账号 AT/RT 已刷新": "계정 AT/RT가 새로고침되었습니다", + "账号代理": "계정 프록시", + "账号代理测试通过": "계정 프록시 테스트 통과", + "账号代理测试未通过": "계정 프록시 테스트 미통과", + "账号代理已保存": "계정 프록시가 저장되었습니다", + "账号代理已关闭": "계정 프록시가 꺼졌습니다", + "账号代理已清除": "계정 프록시가 지워졌습니다", "账号已停用": "계정이 비활성화되었습니다", "账号或工作区被停用的账号": "계정 또는 워크스페이스가 비활성화된 계정", "状态字段为 unknown 的账号": "상태 필드가 unknown인 계정", @@ -73,6 +103,9 @@ export const KO_ACCOUNTS_MESSAGES: MessageCatalog = { "내보내기 방식을 선택하세요. 계정을 선택했다면 현재 선택 항목만 내보냅니다.", "选择要删除的账号状态;删除后不可恢复。": "삭제할 계정 상태를 선택하세요. 삭제 후에는 복구할 수 없습니다.", + "支持 http、https、socks5、socks5h;sing-box mixed inbound 通常填写 http://127.0.0.1:端口。": + "http, https, socks5, socks5h를 지원합니다. sing-box mixed inbound는 보통 http://127.0.0.1:포트 를 입력합니다.", + "最近检查": "최근 확인", "这里展示账号套餐接口同步回来的套餐状态与时间信息。": "계정 플랜 API에서 동기화된 플랜 상태와 시간 정보를 표시합니다.", "额度容量必须是大于 0 的数字,留空表示未覆盖": From a7a5d6e0aa1e86108cdeea493f50273693355c5a Mon Sep 17 00:00:00 2001 From: baltic-tea Date: Tue, 9 Jun 2026 19:43:19 +0300 Subject: [PATCH 3/9] feat(service): route account token refresh through proxy with parity tests --- crates/service/src/account/account_proxy.rs | 3 +- crates/service/src/account/account_warmup.rs | 36 +- crates/service/src/auth/auth_account.rs | 40 +- .../src/gateway/auth/token_exchange.rs | 31 +- .../src/gateway/core/runtime_config.rs | 29 +- .../core/tests/runtime_config_tests.rs | 18 + crates/service/src/gateway/mod.rs | 38 +- crates/service/src/usage/refresh/mod.rs | 45 +- .../src/usage/tests/usage_http_tests.rs | 415 ++++++++++++++++++ crates/service/src/usage/usage_http.rs | 297 ++++++++++++- .../service/src/usage/usage_token_refresh.rs | 27 +- 11 files changed, 890 insertions(+), 89 deletions(-) diff --git a/crates/service/src/account/account_proxy.rs b/crates/service/src/account/account_proxy.rs index 07bc60497..223a71b63 100644 --- a/crates/service/src/account/account_proxy.rs +++ b/crates/service/src/account/account_proxy.rs @@ -446,7 +446,8 @@ mod tests { use super::{ normalize_supported_proxy_url, resolve_account_proxy_mode_from_storage, test_account_proxy_settings_with_checker, AccountProxyMode, AccountProxySettingsResponse, - STATUS_INVALID_URL, STATUS_NOT_CONFIGURED, STATUS_RUNTIME_ERROR, STATUS_UNCHECKED, + STATUS_CHECKING, STATUS_INVALID_URL, STATUS_NOT_CONFIGURED, STATUS_RUNTIME_ERROR, + STATUS_UNCHECKED, }; use codexmanager_core::storage::{now_ts, Account, Storage}; use std::fs; diff --git a/crates/service/src/account/account_warmup.rs b/crates/service/src/account/account_warmup.rs index ed30dff5b..d03391810 100644 --- a/crates/service/src/account/account_warmup.rs +++ b/crates/service/src/account/account_warmup.rs @@ -4,7 +4,6 @@ use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use serde::Serialize; use serde_json::json; use std::io::{BufRead, BufReader, Read}; -use std::time::Duration; use std::time::Instant; use crate::account_status::mark_account_unavailable_for_auth_error; @@ -17,8 +16,6 @@ const DEFAULT_WARMUP_MESSAGE: &str = "hi"; const FALLBACK_WARMUP_MESSAGE: &str = "你好"; const WARMUP_UPSTREAM_URL: &str = "https://chatgpt.com/backend-api/codex/responses"; const DEFAULT_WARMUP_MODEL: &str = "gpt-5.3-codex"; -const WARMUP_CONNECT_TIMEOUT: Duration = Duration::from_secs(15); -const WARMUP_TOTAL_TIMEOUT: Duration = Duration::from_secs(90); #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -60,13 +57,24 @@ pub(crate) fn warmup_accounts( return Err("no account available for warmup".to_string()); } - let client = build_warmup_client()?; let warmup_message = normalize_warmup_message(message); let warmup_model = resolve_warmup_model_slug(&storage); let mut results = Vec::with_capacity(accounts.len()); let mut succeeded = 0usize; for account in accounts.drain(..) { + let client = match build_warmup_client_for_account(&account.id) { + Ok(client) => client, + Err(err) => { + results.push(AccountWarmupItemResult { + account_id: account.id, + account_name: account.label, + ok: false, + message: err, + }); + continue; + } + }; let item = warmup_single_account( &storage, &client, @@ -125,20 +133,12 @@ fn normalize_warmup_message(message: &str) -> String { } } -fn build_warmup_client() -> Result { - let builder = Client::builder() - .connect_timeout(WARMUP_CONNECT_TIMEOUT) - .timeout(WARMUP_TOTAL_TIMEOUT) - .pool_max_idle_per_host(4) - .pool_idle_timeout(Some(Duration::from_secs(60))) - .user_agent(crate::gateway::current_codex_user_agent()); - let builder = crate::gateway::apply_blocking_upstream_proxy( - builder, - crate::gateway::current_upstream_proxy_url().as_deref(), - "warmup_http_proxy_invalid", - ); - builder - .build() +fn build_warmup_client_for_account(account_id: &str) -> Result { + let normalized = account_id.trim(); + if normalized.is_empty() { + return Err("build warmup client failed: missing account id".to_string()); + } + crate::gateway::fresh_upstream_client_for_account(normalized) .map_err(|err| format!("build warmup client failed: {err}")) } diff --git a/crates/service/src/auth/auth_account.rs b/crates/service/src/auth/auth_account.rs index 9c5658d4a..75964bc3f 100644 --- a/crates/service/src/auth/auth_account.rs +++ b/crates/service/src/auth/auth_account.rs @@ -14,7 +14,10 @@ use crate::account_plan::resolve_effective_account_plan; use crate::account_status::mark_account_unavailable_for_auth_error; use crate::app_settings::{get_persisted_app_setting, save_persisted_app_setting}; use crate::storage_helpers::open_storage; -use crate::usage_http::fetch_account_subscription; +use crate::usage_http::{ + fetch_account_subscription, fetch_account_subscription_with_explicit_proxy, + log_account_data_route, +}; use crate::usage_token_refresh::{refresh_and_persist_access_token, token_refresh_ahead_secs}; const CURRENT_AUTH_ACCOUNT_ID_KEY: &str = "auth.current_account_id"; @@ -330,12 +333,35 @@ pub(crate) fn refresh_current_chatgpt_auth_tokens( let plan_type_resolution = resolve_plan_type_resolution(&token, access_claims.as_ref()); let base_url = std::env::var("CODEXMANAGER_USAGE_BASE_URL") .unwrap_or_else(|_| "https://chatgpt.com".to_string()); - let subscription = fetch_account_subscription( - &base_url, - &token.access_token, - &chatgpt_account_id, - workspace_id.as_deref(), - )?; + let proxy_mode = + crate::account_proxy::resolve_account_proxy_mode(refreshed_account.id.as_str()); + log_account_data_route( + "subscription", + refreshed_account.id.as_str(), + &proxy_mode, + "accounts_check", + false, + ); + let subscription = match &proxy_mode { + crate::account_proxy::AccountProxyMode::Disabled => fetch_account_subscription( + &base_url, + &token.access_token, + &chatgpt_account_id, + workspace_id.as_deref(), + )?, + crate::account_proxy::AccountProxyMode::Explicit { proxy_url } => { + fetch_account_subscription_with_explicit_proxy( + &base_url, + &token.access_token, + &chatgpt_account_id, + workspace_id.as_deref(), + proxy_url, + )? + } + crate::account_proxy::AccountProxyMode::Invalid { error, .. } => { + return Err(error.clone()); + } + }; storage .upsert_account_subscription( &refreshed_account.id, diff --git a/crates/service/src/gateway/auth/token_exchange.rs b/crates/service/src/gateway/auth/token_exchange.rs index 479c0a78b..46da41f97 100644 --- a/crates/service/src/gateway/auth/token_exchange.rs +++ b/crates/service/src/gateway/auth/token_exchange.rs @@ -6,7 +6,9 @@ use codexmanager_core::storage::{now_ts, Account, Storage, Token}; use crate::account_status::mark_account_unavailable_for_auth_error; use crate::auth_tokens; -use crate::usage_http::refresh_access_token; +use crate::usage_http::{ + log_account_data_route, refresh_access_token, refresh_access_token_with_explicit_proxy, +}; const ACCOUNT_TOKEN_EXCHANGE_LOCK_TTL_SECS: i64 = 30 * 60; const ACCOUNT_TOKEN_EXCHANGE_LOCK_CLEANUP_INTERVAL_SECS: i64 = 60; @@ -266,7 +268,32 @@ pub(super) fn resolve_openai_bearer_token( Ok(token) => return Ok(token), Err(exchange_err) => { if !token.refresh_token.trim().is_empty() { - match refresh_access_token(&issuer, &client_id, &token.refresh_token) { + let proxy_mode = + crate::account_proxy::resolve_account_proxy_mode(account.id.as_str()); + log_account_data_route( + "api_key_token_exchange", + account.id.as_str(), + &proxy_mode, + "refresh_token", + true, + ); + let refresh_result = match &proxy_mode { + crate::account_proxy::AccountProxyMode::Disabled => { + refresh_access_token(&issuer, &client_id, &token.refresh_token) + } + crate::account_proxy::AccountProxyMode::Explicit { proxy_url } => { + refresh_access_token_with_explicit_proxy( + &issuer, + &client_id, + &token.refresh_token, + proxy_url, + ) + } + crate::account_proxy::AccountProxyMode::Invalid { error, .. } => { + Err(error.clone()) + } + }; + match refresh_result { Ok(refreshed) => { token.access_token = refreshed.access_token; if let Some(refresh_token) = refreshed.refresh_token { diff --git a/crates/service/src/gateway/core/runtime_config.rs b/crates/service/src/gateway/core/runtime_config.rs index dfbe4cc2a..8e3e25d1b 100644 --- a/crates/service/src/gateway/core/runtime_config.rs +++ b/crates/service/src/gateway/core/runtime_config.rs @@ -326,24 +326,6 @@ fn build_async_upstream_client() -> reqwest::Client { build_async_upstream_client_with_proxy(proxy_url.as_deref()) } -pub(crate) fn apply_blocking_upstream_proxy( - mut builder: reqwest::blocking::ClientBuilder, - proxy_url: Option<&str>, - invalid_event: &str, -) -> reqwest::blocking::ClientBuilder { - if let Some(proxy_url) = proxy_url.map(str::trim).filter(|value| !value.is_empty()) { - match Proxy::all(proxy_url) { - Ok(proxy) => { - builder = builder.proxy(proxy); - } - Err(err) => { - log::warn!("event={} proxy={} err={}", invalid_event, proxy_url, err); - } - } - } - builder -} - pub(crate) fn apply_async_upstream_proxy( mut builder: reqwest::ClientBuilder, proxy_url: Option<&str>, @@ -1802,6 +1784,9 @@ fn load_account_proxy_client_cache_entry_from_storage( let Some(settings) = settings else { return AccountProxyClientCacheEntry::NotConfigured; }; + if !settings.enabled { + return AccountProxyClientCacheEntry::NotConfigured; + } let Some(proxy_url) = settings .proxy_url .as_deref() @@ -1809,11 +1794,11 @@ fn load_account_proxy_client_cache_entry_from_storage( .filter(|value| !value.is_empty()) .map(ToString::to_string) else { - return AccountProxyClientCacheEntry::NotConfigured; + return AccountProxyClientCacheEntry::Invalid { + proxy_url: String::new(), + error: "missing proxy URL".to_string(), + }; }; - if !settings.enabled { - return AccountProxyClientCacheEntry::NotConfigured; - } let normalized_proxy_url = match normalize_upstream_proxy_url(Some(proxy_url.as_str())) { Ok(Some(proxy_url)) => proxy_url, diff --git a/crates/service/src/gateway/core/tests/runtime_config_tests.rs b/crates/service/src/gateway/core/tests/runtime_config_tests.rs index 64ee3af67..bf543196b 100644 --- a/crates/service/src/gateway/core/tests/runtime_config_tests.rs +++ b/crates/service/src/gateway/core/tests/runtime_config_tests.rs @@ -419,6 +419,24 @@ fn upstream_client_for_account_fails_closed_for_invalid_explicit_proxy() { assert!(err.contains("acc-invalid")); } +#[test] +fn upstream_client_for_account_fails_closed_for_enabled_proxy_without_url() { + let _guard = crate::test_env_guard(); + let db = TestDbGuard::new("runtime-account-proxy-empty"); + seed_account(db.path(), "acc-empty"); + seed_account_proxy(db.path(), "acc-empty", true, None); + let _global_guard = EnvGuard::set(ENV_UPSTREAM_PROXY_URL, "http://127.0.0.1:7002"); + let _pool_guard = EnvGuard::set(ENV_PROXY_LIST, "http://127.0.0.1:7003"); + + reload_from_env(); + + let err = + upstream_client_for_account("acc-empty").expect_err("fail closed for missing proxy URL"); + assert!(err.contains("fail-closed")); + assert!(err.contains("acc-empty")); + assert!(err.contains("missing proxy URL")); +} + #[test] fn account_proxy_set_and_clear_invalidate_gateway_cache() { let _guard = crate::test_env_guard(); diff --git a/crates/service/src/gateway/mod.rs b/crates/service/src/gateway/mod.rs index 9cde286df..6dbbff2e7 100644 --- a/crates/service/src/gateway/mod.rs +++ b/crates/service/src/gateway/mod.rs @@ -389,10 +389,8 @@ pub(crate) use runtime_config::front_proxy_max_body_bytes; pub(crate) use runtime_config::invalidate_account_proxy_client_cache as invalidate_account_proxy_cache; pub(crate) use runtime_config::{account_max_inflight_limit, set_account_max_inflight_limit}; use runtime_config::{ - async_upstream_client_for_account, fresh_async_upstream_client_for_account, - fresh_upstream_client_for_account, request_gate_wait_timeout, trace_body_preview_max_bytes, - upstream_client_for_account, upstream_stream_timeout, upstream_total_timeout, - DEFAULT_GATEWAY_DEBUG, + request_gate_wait_timeout, trace_body_preview_max_bytes, upstream_stream_timeout, + upstream_total_timeout, DEFAULT_GATEWAY_DEBUG, }; use selection::collect_gateway_candidates; pub(crate) use selection::{ @@ -794,14 +792,6 @@ pub(crate) fn current_upstream_proxy_url() -> Option { runtime_config::upstream_proxy_url() } -pub(crate) fn apply_blocking_upstream_proxy( - builder: reqwest::blocking::ClientBuilder, - proxy_url: Option<&str>, - invalid_event: &str, -) -> reqwest::blocking::ClientBuilder { - runtime_config::apply_blocking_upstream_proxy(builder, proxy_url, invalid_event) -} - pub(crate) fn apply_async_upstream_proxy( builder: reqwest::ClientBuilder, proxy_url: Option<&str>, @@ -814,6 +804,30 @@ pub(crate) fn current_upstream_proxy_url_for_account(account_id: &str) -> Option runtime_config::upstream_proxy_url_for_account(account_id) } +pub(crate) fn fresh_upstream_client_for_account( + account_id: &str, +) -> Result { + runtime_config::fresh_upstream_client_for_account(account_id) +} + +pub(crate) fn upstream_client_for_account( + account_id: &str, +) -> Result { + runtime_config::upstream_client_for_account(account_id) +} + +pub(crate) fn async_upstream_client_for_account( + account_id: &str, +) -> Result { + runtime_config::async_upstream_client_for_account(account_id) +} + +pub(crate) fn fresh_async_upstream_client_for_account( + account_id: &str, +) -> Result { + runtime_config::fresh_async_upstream_client_for_account(account_id) +} + /// 函数 `set_upstream_proxy_url` /// /// 作者: gaohongshun diff --git a/crates/service/src/usage/refresh/mod.rs b/crates/service/src/usage/refresh/mod.rs index e000902db..91da05d60 100644 --- a/crates/service/src/usage/refresh/mod.rs +++ b/crates/service/src/usage/refresh/mod.rs @@ -14,7 +14,10 @@ use crate::usage_account_meta::{ build_workspace_map_from_accounts, clean_header_value, derive_account_meta, patch_account_meta, patch_account_meta_cached, workspace_header_for_account, }; -use crate::usage_http::{fetch_account_subscription, fetch_usage_snapshot}; +use crate::usage_http::{ + fetch_account_subscription, fetch_account_subscription_with_explicit_proxy, + fetch_usage_snapshot, fetch_usage_snapshot_with_explicit_proxy, log_account_data_route, +}; use crate::usage_keepalive::{is_keepalive_error_ignorable, run_gateway_keepalive_once}; use crate::usage_scheduler::{ parse_interval_secs, DEFAULT_GATEWAY_KEEPALIVE_FAILURE_BACKOFF_MAX_SECS, @@ -647,9 +650,32 @@ fn refresh_account_snapshot( workspace_id: Option<&str>, subscription_account_id: Option<&str>, ) -> Result { + let proxy_mode = crate::account_proxy::resolve_account_proxy_mode(account_id); if let Some(subscription_account_id) = subscription_account_id { - let subscription = - fetch_account_subscription(base_url, bearer, subscription_account_id, workspace_id)?; + log_account_data_route( + "subscription", + account_id, + &proxy_mode, + "accounts_check", + false, + ); + let subscription = match &proxy_mode { + crate::account_proxy::AccountProxyMode::Disabled => { + fetch_account_subscription(base_url, bearer, subscription_account_id, workspace_id)? + } + crate::account_proxy::AccountProxyMode::Explicit { proxy_url } => { + fetch_account_subscription_with_explicit_proxy( + base_url, + bearer, + subscription_account_id, + workspace_id, + proxy_url, + )? + } + crate::account_proxy::AccountProxyMode::Invalid { error, .. } => { + return Err(error.clone()); + } + }; storage .upsert_account_subscription( account_id, @@ -662,7 +688,18 @@ fn refresh_account_snapshot( .map_err(|err| format!("store account subscription failed: {err}"))?; } - let value = fetch_usage_snapshot(base_url, bearer, workspace_id)?; + log_account_data_route("usage", account_id, &proxy_mode, "usage", true); + let value = match &proxy_mode { + crate::account_proxy::AccountProxyMode::Disabled => { + fetch_usage_snapshot(base_url, bearer, workspace_id)? + } + crate::account_proxy::AccountProxyMode::Explicit { proxy_url } => { + fetch_usage_snapshot_with_explicit_proxy(base_url, bearer, workspace_id, proxy_url)? + } + crate::account_proxy::AccountProxyMode::Invalid { error, .. } => { + return Err(error.clone()); + } + }; let status = classify_usage_status_from_snapshot_value(&value); store_usage_snapshot(storage, account_id, value)?; Ok(status) diff --git a/crates/service/src/usage/tests/usage_http_tests.rs b/crates/service/src/usage/tests/usage_http_tests.rs index bebcdffe7..f48f89c04 100644 --- a/crates/service/src/usage/tests/usage_http_tests.rs +++ b/crates/service/src/usage/tests/usage_http_tests.rs @@ -2,9 +2,11 @@ use super::{ build_usage_request_headers, summarize_usage_error_response, usage_http_client, CHATGPT_ACCOUNT_ID_HEADER_NAME, }; +use codexmanager_core::storage::{now_ts, Account, Storage}; use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::Client; use reqwest::StatusCode; +use std::path::PathBuf; use std::sync::MutexGuard; use std::thread; use std::time::Duration; @@ -21,6 +23,209 @@ struct RecordedSubscriptionRequest { accept: Option, } +struct EnvVarRestore { + key: &'static str, + value: Option, +} + +impl EnvVarRestore { + fn set(key: &'static str, value: &str) -> Self { + let restore = Self { + key, + value: std::env::var(key).ok(), + }; + std::env::set_var(key, value); + restore + } + + fn remove(key: &'static str) -> Self { + let restore = Self { + key, + value: std::env::var(key).ok(), + }; + std::env::remove_var(key); + restore + } +} + +impl Drop for EnvVarRestore { + fn drop(&mut self) { + match self.value.as_deref() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } +} + +struct TestDbGuard { + previous_db_path: Option, + db_path: PathBuf, +} + +impl TestDbGuard { + fn new(label: &str) -> Self { + let db_path = std::env::temp_dir().join(format!( + "codexmanager-usage-http-{label}-{}-{}.sqlite", + std::process::id(), + codexmanager_core::storage::now_ts() + )); + let previous_db_path = std::env::var("CODEXMANAGER_DB_PATH").ok(); + std::env::set_var("CODEXMANAGER_DB_PATH", &db_path); + let storage = Storage::open(&db_path).expect("open test storage"); + storage.init().expect("init test storage"); + Self { + previous_db_path, + db_path, + } + } +} + +impl Drop for TestDbGuard { + fn drop(&mut self) { + match self.previous_db_path.as_deref() { + Some(value) => std::env::set_var("CODEXMANAGER_DB_PATH", value), + None => std::env::remove_var("CODEXMANAGER_DB_PATH"), + } + let _ = std::fs::remove_file(&self.db_path); + } +} + +fn seed_account_proxy(db_path: &PathBuf, account_id: &str, enabled: bool, proxy_url: Option<&str>) { + let storage = Storage::open(db_path).expect("reopen test storage"); + let now = now_ts(); + storage + .insert_account(&Account { + id: account_id.to_string(), + label: account_id.to_string(), + issuer: "issuer".to_string(), + chatgpt_account_id: None, + workspace_id: None, + group_name: None, + sort: 0, + status: "active".to_string(), + created_at: now, + updated_at: now, + }) + .expect("seed account"); + storage + .upsert_account_proxy_settings( + account_id, + enabled, + proxy_url, + "unchecked", + None, + None, + None, + ) + .expect("seed account proxy"); + crate::gateway::invalidate_account_proxy_cache(account_id); +} + +fn spawn_recording_http_proxy( + response_body: &'static str, + content_type: &'static str, +) -> ( + String, + std::sync::mpsc::Receiver, + thread::JoinHandle<()>, +) { + use std::io::{Read, Write}; + use std::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock HTTP proxy"); + let proxy_addr = listener.local_addr().expect("mock HTTP proxy addr"); + let (request_tx, request_rx) = std::sync::mpsc::channel(); + let handle = thread::spawn(move || { + let (mut client, _) = listener.accept().expect("accept proxy client"); + client + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set proxy read timeout"); + let mut request = Vec::new(); + let mut buf = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let read = client.read(&mut buf).expect("read proxy request"); + if read == 0 { + break; + } + request.extend_from_slice(&buf[..read]); + } + let request_text = String::from_utf8_lossy(request.as_slice()).to_string(); + request_tx.send(request_text).expect("send proxy request"); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response_body}", + response_body.len() + ); + client + .write_all(response.as_bytes()) + .expect("write proxy response"); + client.flush().expect("flush proxy response"); + }); + (format!("http://{proxy_addr}"), request_rx, handle) +} + +fn spawn_timeout_recording_http_proxy( + response_body: &'static str, + content_type: &'static str, + accept_timeout: Duration, +) -> ( + String, + std::sync::mpsc::Receiver, + thread::JoinHandle<()>, +) { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::time::Instant; + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind timeout HTTP proxy"); + listener + .set_nonblocking(true) + .expect("set timeout proxy nonblocking"); + let proxy_addr = listener.local_addr().expect("timeout HTTP proxy addr"); + let (request_tx, request_rx) = std::sync::mpsc::channel(); + let handle = thread::spawn(move || { + let started_at = Instant::now(); + loop { + match listener.accept() { + Ok((mut client, _)) => { + client + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set timeout proxy read timeout"); + let mut request = Vec::new(); + let mut buf = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let read = client.read(&mut buf).expect("read timeout proxy request"); + if read == 0 { + break; + } + request.extend_from_slice(&buf[..read]); + } + let request_text = String::from_utf8_lossy(request.as_slice()).to_string(); + request_tx + .send(request_text) + .expect("send timeout proxy request"); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response_body}", + response_body.len() + ); + client + .write_all(response.as_bytes()) + .expect("write timeout proxy response"); + client.flush().expect("flush timeout proxy response"); + return; + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + if started_at.elapsed() >= accept_timeout { + return; + } + thread::sleep(Duration::from_millis(20)); + } + Err(err) => panic!("accept timeout proxy client failed: {err}"), + } + } + }); + (format!("http://{proxy_addr}"), request_rx, handle) +} + /// 函数 `usage_header_runtime_scope` /// /// 作者: gaohongshun @@ -733,6 +938,216 @@ fn refresh_access_token_mock_region_blocked_response_surfaces_marker() { assert!(super::is_refresh_token_region_blocked_error_message(&err)); } +#[test] +fn fetch_usage_snapshot_with_explicit_proxy_uses_explicit_proxy_before_global_proxy() { + let _guard = crate::test_env_guard(); + let _global_proxy = EnvVarRestore::set("CODEXMANAGER_UPSTREAM_PROXY_URL", "http://127.0.0.1:1"); + super::reload_usage_http_client_from_env(); + let (proxy_url, request_rx, proxy_handle) = spawn_recording_http_proxy( + r#"{"gpt4":{"usedPercent":12.5,"windowMinutes":180}}"#, + "application/json", + ); + + let snapshot = super::fetch_usage_snapshot_with_explicit_proxy( + "http://chatgpt.test", + "token_123", + Some("workspace_123"), + proxy_url.as_str(), + ) + .expect("fetch usage snapshot"); + let request = request_rx + .recv_timeout(Duration::from_secs(5)) + .expect("capture usage proxy request"); + proxy_handle.join().expect("join usage proxy"); + let request = request.to_ascii_lowercase(); + + assert!(request.starts_with("get http://chatgpt.test/")); + assert!(request.contains("authorization: bearer token_123")); + assert!(request.contains("chatgpt-account-id: workspace_123")); + assert_eq!(snapshot["gpt4"]["usedPercent"], 12.5); +} + +#[test] +fn fetch_account_subscription_with_explicit_proxy_uses_explicit_proxy_before_global_proxy() { + let _guard = crate::test_env_guard(); + let _global_proxy = EnvVarRestore::set("CODEXMANAGER_UPSTREAM_PROXY_URL", "http://127.0.0.1:1"); + super::reload_usage_http_client_from_env(); + let (proxy_url, request_rx, proxy_handle) = spawn_recording_http_proxy( + r#"{"accounts":{"acct-chatgpt":{"account":{"plan_type":"pro","is_default":true},"entitlement":{"subscription_plan":"plus","has_active_subscription":true}}}}"#, + "application/json", + ); + + let snapshot = super::fetch_account_subscription_with_explicit_proxy( + "http://chatgpt.test", + "token_123", + "acct-chatgpt", + Some("workspace_123"), + proxy_url.as_str(), + ) + .expect("fetch subscription"); + let request = request_rx + .recv_timeout(Duration::from_secs(5)) + .expect("capture subscription proxy request"); + proxy_handle.join().expect("join subscription proxy"); + let request = request.to_ascii_lowercase(); + + assert!(request.starts_with("get http://chatgpt.test/")); + assert!(request.contains("authorization: bearer token_123")); + assert!(request.contains("origin: https://chatgpt.com")); + assert!(!request.contains("user-agent:")); + assert!(snapshot.has_subscription); + assert_eq!(snapshot.account_plan_type.as_deref(), Some("pro")); +} + +#[test] +fn refresh_access_token_with_explicit_proxy_fails_for_invalid_proxy_url() { + let _guard = crate::test_env_guard(); + let _override_restore = EnvVarRestore::remove("CODEX_REFRESH_TOKEN_URL_OVERRIDE"); + + let err = match super::refresh_access_token_with_explicit_proxy( + "https://auth.openai.com", + "client-id", + "refresh-token", + "http://", + ) { + Ok(_) => panic!("invalid explicit proxy should fail closed"), + Err(err) => err, + }; + + assert!(err.contains("explicit account proxy URL is invalid and fail-closed")); +} + +#[test] +fn refresh_access_token_with_explicit_proxy_fails_closed_for_empty_proxy_url() { + let _guard = crate::test_env_guard(); + + let err = match super::refresh_access_token_with_explicit_proxy( + "https://auth.openai.com", + "client-id", + "refresh-token", + " ", + ) { + Ok(_) => panic!("empty explicit proxy should fail closed"), + Err(err) => err, + }; + + assert!(err.contains("explicit account proxy URL is required and fail-closed")); +} + +#[test] +fn legacy_subscription_request_ignores_proxy_pool_when_account_proxy_is_disabled() { + let _guard = crate::test_env_guard(); + let db = TestDbGuard::new("subscription-disabled-proxy-pool"); + seed_account_proxy( + &db.db_path, + "acc-disabled-subscription", + false, + Some("http://127.0.0.1:7891"), + ); + let server = Server::http("127.0.0.1:0").expect("start legacy subscription server"); + let addr = format!("http://{}", server.server_addr()); + let (proxy_url, proxy_rx, proxy_handle) = spawn_timeout_recording_http_proxy( + r#"{"accounts":{"acct-chatgpt":{"account":{"plan_type":"pro","is_default":true},"entitlement":{"subscription_plan":"plus","has_active_subscription":true}}}}"#, + "application/json", + Duration::from_millis(400), + ); + let _global_proxy = EnvVarRestore::set("CODEXMANAGER_UPSTREAM_PROXY_URL", ""); + let _pool_proxy = EnvVarRestore::set("CODEXMANAGER_PROXY_LIST", proxy_url.as_str()); + super::reload_usage_http_client_from_env(); + + let (tx, rx) = std::sync::mpsc::channel(); + let handle = thread::spawn(move || { + let request = server + .recv_timeout(Duration::from_secs(5)) + .expect("subscription server timeout") + .expect("receive legacy subscription request"); + tx.send(request.url().to_string()) + .expect("send legacy subscription path"); + let response = Response::from_string( + r#"{"accounts":{"acct-chatgpt":{"account":{"plan_type":"pro","is_default":true},"entitlement":{"subscription_plan":"plus","has_active_subscription":true}}}}"#, + ) + .with_status_code(TinyStatusCode(200)) + .with_header( + Header::from_bytes("Content-Type", "application/json") + .expect("content-type header"), + ); + request + .respond(response) + .expect("respond legacy subscription"); + }); + + let snapshot = super::fetch_account_subscription( + &addr, + "token_123", + "acct-chatgpt", + Some("workspace_123"), + ) + .expect("fetch legacy subscription"); + + assert!(snapshot.has_subscription); + assert_eq!( + rx.recv_timeout(Duration::from_secs(5)) + .expect("receive legacy subscription path"), + "/accounts/check/v4-2023-04-27" + ); + assert!(proxy_rx.recv_timeout(Duration::from_millis(300)).is_err()); + handle.join().expect("join legacy subscription server"); + proxy_handle.join().expect("join unused proxy"); +} + +#[test] +fn legacy_usage_request_ignores_proxy_pool_when_account_proxy_is_disabled() { + let _guard = crate::test_env_guard(); + let db = TestDbGuard::new("usage-disabled-proxy-pool"); + seed_account_proxy( + &db.db_path, + "acc-disabled-usage", + false, + Some("http://127.0.0.1:7891"), + ); + let server = Server::http("127.0.0.1:0").expect("start legacy usage server"); + let addr = format!("http://{}", server.server_addr()); + let (proxy_url, proxy_rx, proxy_handle) = spawn_timeout_recording_http_proxy( + r#"{"gpt4":{"usedPercent":99.0,"windowMinutes":180}}"#, + "application/json", + Duration::from_millis(400), + ); + let _global_proxy = EnvVarRestore::set("CODEXMANAGER_UPSTREAM_PROXY_URL", ""); + let _pool_proxy = EnvVarRestore::set("CODEXMANAGER_PROXY_LIST", proxy_url.as_str()); + super::reload_usage_http_client_from_env(); + + let (tx, rx) = std::sync::mpsc::channel(); + let handle = thread::spawn(move || { + let request = server + .recv_timeout(Duration::from_secs(5)) + .expect("usage server timeout") + .expect("receive legacy usage request"); + tx.send(request.url().to_string()) + .expect("send legacy usage path"); + let response = + Response::from_string(r#"{"gpt4":{"usedPercent":99.0,"windowMinutes":180}}"#) + .with_status_code(TinyStatusCode(200)) + .with_header( + Header::from_bytes("Content-Type", "application/json") + .expect("content-type header"), + ); + request.respond(response).expect("respond legacy usage"); + }); + + let snapshot = super::fetch_usage_snapshot(&addr, "token_123", Some("workspace_123")) + .expect("fetch legacy usage"); + + assert_eq!(snapshot["gpt4"]["usedPercent"], 99.0); + assert_eq!( + rx.recv_timeout(Duration::from_secs(5)) + .expect("receive legacy usage path"), + "/api/codex/usage" + ); + assert!(proxy_rx.recv_timeout(Duration::from_millis(300)).is_err()); + handle.join().expect("join legacy usage server"); + proxy_handle.join().expect("join unused proxy"); +} + /// 函数 `summarize_usage_error_response_stabilizes_html_and_debug_headers` /// /// 作者: gaohongshun diff --git a/crates/service/src/usage/usage_http.rs b/crates/service/src/usage/usage_http.rs index 6f4359da1..31c41eb12 100644 --- a/crates/service/src/usage/usage_http.rs +++ b/crates/service/src/usage/usage_http.rs @@ -1,7 +1,7 @@ use chrono::DateTime; use codexmanager_core::usage::{accounts_check_endpoint, usage_endpoint}; use reqwest::header::{HeaderMap, HeaderName, HeaderValue, CONTENT_TYPE}; -use reqwest::Client; +use reqwest::{Client, Proxy}; use std::collections::HashMap; use std::future::Future; use std::sync::{OnceLock, RwLock}; @@ -1003,7 +1003,27 @@ pub(crate) fn fetch_usage_snapshot( bearer: &str, workspace_id: Option<&str>, ) -> Result { - run_usage_future(fetch_usage_snapshot_async(base_url, bearer, workspace_id)) + run_usage_future(fetch_usage_snapshot_async( + base_url, + bearer, + workspace_id, + None, + )) +} + +pub(crate) fn fetch_usage_snapshot_with_explicit_proxy( + base_url: &str, + bearer: &str, + workspace_id: Option<&str>, + proxy_url: &str, +) -> Result { + let proxy_url = normalize_explicit_proxy_url(proxy_url)?; + run_usage_future(fetch_usage_snapshot_async( + base_url, + bearer, + workspace_id, + Some(proxy_url.as_str()), + )) } /// 函数 `fetch_account_subscription` @@ -1031,6 +1051,24 @@ pub(crate) fn fetch_account_subscription( bearer, account_id, workspace_id, + None, + )) +} + +pub(crate) fn fetch_account_subscription_with_explicit_proxy( + base_url: &str, + bearer: &str, + account_id: &str, + workspace_id: Option<&str>, + proxy_url: &str, +) -> Result { + let proxy_url = normalize_explicit_proxy_url(proxy_url)?; + run_usage_future(fetch_account_subscription_async( + base_url, + bearer, + account_id, + workspace_id, + Some(proxy_url.as_str()), )) } @@ -1051,26 +1089,28 @@ async fn fetch_usage_snapshot_async( base_url: &str, bearer: &str, workspace_id: Option<&str>, + explicit_proxy_url: Option<&str>, ) -> Result { // 调用上游用量接口 let url = usage_endpoint(base_url); - let build_request = || { - let client = usage_http_client(); + let request_headers = build_usage_request_headers(workspace_id); + let build_request = |client: Client| { let mut req = client .get(&url) .header("Authorization", format!("Bearer {bearer}")); - let request_headers = build_usage_request_headers(workspace_id); if !request_headers.is_empty() { - req = req.headers(request_headers); + req = req.headers(request_headers.clone()); } req }; - let resp = match build_request().send().await { + let client = usage_http_client_for_proxy(explicit_proxy_url)?; + let resp = match build_request(client).send().await { Ok(resp) => resp, Err(first_err) => { // 中文注释:代理在程序启动后才开启时,旧 client 可能沿用旧网络状态;这里自动重建并重试一次。 - rebuild_usage_http_client(); - let retried = build_request().send().await; + let retried = build_request(refresh_usage_http_client_for_proxy(explicit_proxy_url)?) + .send() + .await; match retried { Ok(resp) => resp, Err(second_err) => { @@ -1111,10 +1151,10 @@ async fn fetch_usage_snapshot_async( async fn fetch_accounts_check_response_async( base_url: &str, bearer: &str, + explicit_proxy_url: Option<&str>, ) -> Result { let url = accounts_check_endpoint(base_url); - let build_request = || { - let client = subscription_http_client(); + let build_request = |client: Client| { client .get(&url) .header("Authorization", format!("Bearer {bearer}")) @@ -1122,11 +1162,15 @@ async fn fetch_accounts_check_response_async( .header("Referer", "https://chatgpt.com/") .header("Accept", "application/json") }; - let resp = match build_request().send().await { + let client = subscription_http_client_for_proxy(explicit_proxy_url)?; + let resp = match build_request(client).send().await { Ok(resp) => resp, Err(first_err) => { - rebuild_subscription_http_client(); - let retried = build_request().send().await; + let retried = build_request(refresh_subscription_http_client_for_proxy( + explicit_proxy_url, + )?) + .send() + .await; match retried { Ok(resp) => resp, Err(second_err) => { @@ -1189,12 +1233,14 @@ async fn fetch_account_subscription_async( bearer: &str, account_id: &str, _workspace_id: Option<&str>, + explicit_proxy_url: Option<&str>, ) -> Result { let normalized_account_id = account_id.trim(); if normalized_account_id.is_empty() { return Ok(AccountSubscriptionSnapshot::default()); } - let response = fetch_accounts_check_response_async(base_url, bearer).await?; + let response = + fetch_accounts_check_response_async(base_url, bearer, explicit_proxy_url).await?; if let Some(entry) = response.accounts.get(normalized_account_id) { return Ok(build_accounts_check_snapshot(entry)); @@ -1249,7 +1295,27 @@ pub(crate) fn refresh_access_token( client_id: &str, refresh_token: &str, ) -> Result { - run_usage_future(refresh_access_token_async(issuer, client_id, refresh_token)) + run_usage_future(refresh_access_token_async( + issuer, + client_id, + refresh_token, + None, + )) +} + +pub(crate) fn refresh_access_token_with_explicit_proxy( + issuer: &str, + client_id: &str, + refresh_token: &str, + proxy_url: &str, +) -> Result { + let proxy_url = normalize_explicit_proxy_url(proxy_url)?; + run_usage_future(refresh_access_token_async( + issuer, + client_id, + refresh_token, + Some(proxy_url.as_str()), + )) } /// 函数 `refresh_access_token_async` @@ -1269,21 +1335,25 @@ async fn refresh_access_token_async( issuer: &str, client_id: &str, refresh_token: &str, + explicit_proxy_url: Option<&str>, ) -> Result { let refresh_token_url = resolve_refresh_token_url(issuer); let body = build_refresh_token_body(client_id, refresh_token); - let build_request = || { - let client = usage_http_client(); + let build_request = |client: Client| { client .post(refresh_token_url.clone()) .header("Content-Type", "application/x-www-form-urlencoded") .body(body.clone()) }; - let resp = match build_request().send().await { + let client = token_refresh_http_client_for_proxy(explicit_proxy_url)?; + let resp = match build_request(client).send().await { Ok(resp) => resp, Err(first_err) => { - rebuild_usage_http_client(); - let retried = build_request().send().await; + let retried = build_request(refresh_token_refresh_http_client_for_proxy( + explicit_proxy_url, + )?) + .send() + .await; match retried { Ok(resp) => resp, Err(second_err) => { @@ -1310,6 +1380,191 @@ async fn refresh_access_token_async( .map_err(|e| format!("read refresh token response json failed: {e}")) } +fn usage_http_client_for_proxy(explicit_proxy_url: Option<&str>) -> Result { + match explicit_proxy_url + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(proxy_url) => build_usage_http_client_with_explicit_proxy(proxy_url), + None => Ok(usage_http_client()), + } +} + +fn refresh_usage_http_client_for_proxy(explicit_proxy_url: Option<&str>) -> Result { + match explicit_proxy_url + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(proxy_url) => build_usage_http_client_with_explicit_proxy(proxy_url), + None => { + rebuild_usage_http_client(); + Ok(usage_http_client()) + } + } +} + +fn subscription_http_client_for_proxy(explicit_proxy_url: Option<&str>) -> Result { + match explicit_proxy_url + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(proxy_url) => build_subscription_http_client_with_explicit_proxy(proxy_url), + None => Ok(subscription_http_client()), + } +} + +fn refresh_subscription_http_client_for_proxy( + explicit_proxy_url: Option<&str>, +) -> Result { + match explicit_proxy_url + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(proxy_url) => build_subscription_http_client_with_explicit_proxy(proxy_url), + None => { + rebuild_subscription_http_client(); + Ok(subscription_http_client()) + } + } +} + +fn token_refresh_http_client_for_proxy(explicit_proxy_url: Option<&str>) -> Result { + match explicit_proxy_url + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(proxy_url) => build_token_refresh_http_client_with_explicit_proxy(proxy_url), + None => Ok(usage_http_client()), + } +} + +fn refresh_token_refresh_http_client_for_proxy( + explicit_proxy_url: Option<&str>, +) -> Result { + match explicit_proxy_url + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(proxy_url) => build_token_refresh_http_client_with_explicit_proxy(proxy_url), + None => { + rebuild_usage_http_client(); + Ok(usage_http_client()) + } + } +} + +fn normalize_explicit_proxy_url(proxy_url: &str) -> Result { + let trimmed = proxy_url.trim(); + if trimmed.is_empty() { + return Err("explicit account proxy URL is required and fail-closed".to_string()); + } + crate::account_proxy::normalize_supported_proxy_url(trimmed) + .map_err(|err| format!("explicit account proxy URL is invalid and fail-closed: {err}")) +} + +fn build_usage_http_client_with_explicit_proxy(proxy_url: &str) -> Result { + let builder = Client::builder() + .connect_timeout(USAGE_HTTP_CONNECT_TIMEOUT) + .timeout(USAGE_HTTP_TOTAL_TIMEOUT) + .pool_max_idle_per_host(8) + .pool_idle_timeout(Some(Duration::from_secs(60))) + .user_agent(crate::gateway::current_codex_user_agent()) + .default_headers(build_usage_http_default_headers()); + let builder = builder.proxy( + Proxy::all(proxy_url).map_err(|err| format!("build explicit usage proxy failed: {err}"))?, + ); + builder + .build() + .map_err(|err| format!("build explicit usage client failed: {err}")) +} + +fn build_subscription_http_client_with_explicit_proxy(proxy_url: &str) -> Result { + let builder = Client::builder() + .connect_timeout(USAGE_HTTP_CONNECT_TIMEOUT) + .timeout(USAGE_HTTP_TOTAL_TIMEOUT) + .pool_max_idle_per_host(4) + .pool_idle_timeout(Some(Duration::from_secs(60))); + let builder = builder.proxy( + Proxy::all(proxy_url) + .map_err(|err| format!("build explicit subscription proxy failed: {err}"))?, + ); + builder + .build() + .map_err(|err| format!("build explicit subscription client failed: {err}")) +} + +fn build_token_refresh_http_client_with_explicit_proxy(proxy_url: &str) -> Result { + let builder = Client::builder() + .connect_timeout(USAGE_HTTP_CONNECT_TIMEOUT) + .timeout(USAGE_HTTP_TOTAL_TIMEOUT) + .pool_max_idle_per_host(8) + .pool_idle_timeout(Some(Duration::from_secs(60))) + .user_agent(crate::gateway::current_codex_user_agent()) + .default_headers(build_usage_http_default_headers()); + let builder = builder.proxy( + Proxy::all(proxy_url) + .map_err(|err| format!("build explicit token refresh proxy failed: {err}"))?, + ); + builder + .build() + .map_err(|err| format!("build explicit token refresh client failed: {err}")) +} + +pub(crate) fn log_account_data_route( + kind: &str, + account_id: &str, + mode: &crate::account_proxy::AccountProxyMode, + endpoint: &str, + uses_codex_user_agent: bool, +) { + if !crate::account_proxy::account_proxy_debug_enabled() { + return; + } + + let (client_path, proxy_source, proxy_url_redacted, uses_account_scoped_client) = match mode { + crate::account_proxy::AccountProxyMode::Disabled => { + if let Some(proxy_url) = current_upstream_proxy_url() { + ( + "legacy", + "legacy_upstream_proxy_url", + crate::account_proxy::redact_proxy_url_for_log(proxy_url.as_str()), + false, + ) + } else { + ("legacy", "system_proxy_possible", "-".to_string(), false) + } + } + crate::account_proxy::AccountProxyMode::Explicit { proxy_url } => ( + "explicit_account_proxy", + "explicit_account_proxy", + crate::account_proxy::redact_proxy_url_for_log(proxy_url), + true, + ), + crate::account_proxy::AccountProxyMode::Invalid { proxy_url, .. } => ( + "invalid", + "explicit_account_proxy", + proxy_url + .as_deref() + .map(crate::account_proxy::redact_proxy_url_for_log) + .unwrap_or_else(|| "-".to_string()), + false, + ), + }; + + log::info!( + "event=account_data_route kind={} account_id={} account_proxy_mode={} client_path={} proxy_source={} proxy_url_redacted={} uses_account_scoped_client={} uses_codex_user_agent={} endpoint={}", + kind, + account_id, + mode.as_str(), + client_path, + proxy_source, + proxy_url_redacted, + uses_account_scoped_client, + uses_codex_user_agent, + endpoint + ); +} + /// 函数 `read_response_text` /// /// 作者: gaohongshun diff --git a/crates/service/src/usage/usage_token_refresh.rs b/crates/service/src/usage/usage_token_refresh.rs index 4ad2d29af..6f6ce35b6 100644 --- a/crates/service/src/usage/usage_token_refresh.rs +++ b/crates/service/src/usage/usage_token_refresh.rs @@ -5,7 +5,8 @@ use std::sync::{Arc, Mutex, OnceLock}; use crate::auth_tokens::obtain_api_key; use crate::usage_http::{ - refresh_access_token, refresh_token_auth_error_reason_from_message, RefreshTokenAuthErrorReason, + log_account_data_route, refresh_access_token, refresh_access_token_with_explicit_proxy, + refresh_token_auth_error_reason_from_message, RefreshTokenAuthErrorReason, }; pub(crate) const DEFAULT_TOKEN_REFRESH_AHEAD_SECS: i64 = 3600; @@ -52,7 +53,29 @@ pub(crate) fn refresh_and_persist_access_token( } let refresh_client_id = token_refresh_client_id(token, client_id); - let refreshed = match refresh_access_token(issuer, &refresh_client_id, &token.refresh_token) { + let proxy_mode = crate::account_proxy::resolve_account_proxy_mode(token.account_id.as_str()); + log_account_data_route( + "token_refresh", + token.account_id.as_str(), + &proxy_mode, + "refresh_token", + true, + ); + let refreshed = match &proxy_mode { + crate::account_proxy::AccountProxyMode::Disabled => { + refresh_access_token(issuer, &refresh_client_id, &token.refresh_token) + } + crate::account_proxy::AccountProxyMode::Explicit { proxy_url } => { + refresh_access_token_with_explicit_proxy( + issuer, + &refresh_client_id, + &token.refresh_token, + proxy_url, + ) + } + crate::account_proxy::AccountProxyMode::Invalid { error, .. } => Err(error.clone()), + }; + let refreshed = match refreshed { Ok(refreshed) => refreshed, Err(err) => { if recover_refresh_race_from_latest_token( From 2d7cbcecadcbafc69795e937d9546d02b69f6b62 Mon Sep 17 00:00:00 2001 From: baltic-tea Date: Thu, 11 Jun 2026 18:04:07 +0300 Subject: [PATCH 4/9] feat(apps, service): add support for testing proxy settings and showing runtime status --- apps/src-tauri/src/commands/account/remote.rs | 8 +++++++- apps/src/app/accounts/accounts-page-view.tsx | 3 +++ apps/src/app/accounts/page.tsx | 7 +++++-- apps/src/hooks/useAccounts.ts | 8 +++++--- apps/src/lib/api/account-client.ts | 19 +++++++++++++++++-- .../lib/i18n/messages/sections/en-accounts.ts | 10 ++++++++++ .../lib/i18n/messages/sections/ko-accounts.ts | 10 ++++++++++ crates/service/src/account/account_proxy.rs | 3 +-- crates/service/src/rpc_dispatch/account.rs | 6 +++++- 9 files changed, 63 insertions(+), 11 deletions(-) diff --git a/apps/src-tauri/src/commands/account/remote.rs b/apps/src-tauri/src/commands/account/remote.rs index a6b00585f..96e6a3f40 100644 --- a/apps/src-tauri/src/commands/account/remote.rs +++ b/apps/src-tauri/src/commands/account/remote.rs @@ -285,8 +285,14 @@ pub async fn service_account_proxy_clear( pub async fn service_account_proxy_test( addr: Option, account_id: String, + enabled: Option, + proxy_url: Option, ) -> Result { - let params = serde_json::json!({ "accountId": account_id }); + let params = serde_json::json!({ + "accountId": account_id, + "enabled": enabled, + "proxyUrl": proxy_url, + }); rpc_call_in_background("account/proxy/test", addr, Some(params)).await } diff --git a/apps/src/app/accounts/accounts-page-view.tsx b/apps/src/app/accounts/accounts-page-view.tsx index b841e1660..ee10113b3 100644 --- a/apps/src/app/accounts/accounts-page-view.tsx +++ b/apps/src/app/accounts/accounts-page-view.tsx @@ -1286,6 +1286,9 @@ export function AccountsPageView(props: AccountsPageViewProps) {

{t("建议登录、刷新、用量和 API 请求保持同一代理与地区,以降低账号风控和状态漂移。")}

+

+ {t("建议登录、刷新、用量和 API 请求保持同一代理与地区,以降低账号风控和状态漂移。")} +

diff --git a/apps/src/app/accounts/page.tsx b/apps/src/app/accounts/page.tsx index 9f6a42be8..0278518df 100644 --- a/apps/src/app/accounts/page.tsx +++ b/apps/src/app/accounts/page.tsx @@ -486,7 +486,6 @@ const toggleCleanupStatus = (rawStatus: string) => { setProxyEnabledDraft(settings.enabled); setProxyUrlDraft(settings.proxyUrl || ""); } - setProxyDialogAccount(null); } catch { // hook handles toast } @@ -509,7 +508,11 @@ const toggleCleanupStatus = (rawStatus: string) => { const handleTestProxySettings = async () => { if (!proxyDialogAccount) return; try { - const settings = await testAccountProxySettings(proxyDialogAccount.id); + const settings = await testAccountProxySettings({ + accountId: proxyDialogAccount.id, + enabled: proxyEnabledDraft, + proxyUrl: proxyUrlDraft, + }); if (settings) { setProxySettings(settings); setProxyEnabledDraft(settings.enabled); diff --git a/apps/src/hooks/useAccounts.ts b/apps/src/hooks/useAccounts.ts index 41f048da5..3912a003f 100644 --- a/apps/src/hooks/useAccounts.ts +++ b/apps/src/hooks/useAccounts.ts @@ -30,6 +30,7 @@ type WarmupPayload = Parameters[0]; type WarmupResult = Awaited>; type AccountProxySettings = Awaited>; type AccountProxySetPayload = Parameters[0]; +type AccountProxyTestPayload = Parameters[0]; type RefreshAllRtResult = Awaited< ReturnType >; @@ -840,7 +841,8 @@ export function useAccounts() { }); const testAccountProxyMutation = useMutation({ - mutationFn: (accountId: string) => accountClient.testProxySettings(accountId), + mutationFn: (params: AccountProxyTestPayload) => + accountClient.testProxySettings(params), onSuccess: async (settings) => { await invalidateUsageData(); if (settings.status === "ok") { @@ -951,9 +953,9 @@ export function useAccounts() { if (!ensureServiceReady("清除账号代理")) return; return await clearAccountProxyMutation.mutateAsync(accountId); }, - testAccountProxySettings: async (accountId: string) => { + testAccountProxySettings: async (params: AccountProxyTestPayload) => { if (!ensureServiceReady("测试账号代理")) return; - return await testAccountProxyMutation.mutateAsync(accountId); + return await testAccountProxyMutation.mutateAsync(params); }, updateAccountSort: async (accountId: string, sort: number) => { if (!ensureServiceReady("更新账号顺序")) return; diff --git a/apps/src/lib/api/account-client.ts b/apps/src/lib/api/account-client.ts index ddac73cdf..2243de578 100644 --- a/apps/src/lib/api/account-client.ts +++ b/apps/src/lib/api/account-client.ts @@ -96,6 +96,12 @@ export interface AccountProxySetPayload { proxyUrl?: string | null; } +export interface AccountProxyTestPayload { + accountId: string; + enabled?: boolean; + proxyUrl?: string | null; +} + export interface AccountDeleteByStatusesPayload { statuses: string[]; } @@ -558,9 +564,18 @@ export const accountClient = { readAccountProxySettings( await invoke("service_account_proxy_clear", withAddr({ accountId })), ), - testProxySettings: async (accountId: string): Promise => + testProxySettings: async ( + params: AccountProxyTestPayload, + ): Promise => readAccountProxySettings( - await invoke("service_account_proxy_test", withAddr({ accountId })), + await invoke( + "service_account_proxy_test", + withAddr({ + accountId: params.accountId, + enabled: params.enabled, + proxyUrl: params.proxyUrl ?? null, + }), + ), ), async getUsage(accountId: string): Promise { diff --git a/apps/src/lib/i18n/messages/sections/en-accounts.ts b/apps/src/lib/i18n/messages/sections/en-accounts.ts index d3af7046e..a15e95462 100644 --- a/apps/src/lib/i18n/messages/sections/en-accounts.ts +++ b/apps/src/lib/i18n/messages/sections/en-accounts.ts @@ -29,13 +29,17 @@ export const EN_ACCOUNTS_MESSAGES: MessageCatalog = { "保存账号代理失败": "Failed to save account proxy", "测试账号代理": "Test account proxy", "测试账号代理失败": "Failed to test account proxy", + "测试": "Test", "测试中": "Testing", "未测试": "Untested", "测试失败": "Test failed", "测试状态": "Test status", + "可用": "Available", + "运行时错误": "Runtime error", "从未检查": "Never checked", "代理地址": "Proxy URL", "地址无效": "Invalid URL", + "未配置": "Not configured", "刷新 AT/RT": "Refresh AT/RT", "刷新 AT/RT 失败": "Failed to refresh AT/RT", "刷新全部 AT/RT": "Refresh all AT/RT", @@ -46,7 +50,11 @@ export const EN_ACCOUNTS_MESSAGES: MessageCatalog = { "读取账号代理失败": "Failed to read account proxy", "服务未连接,暂时无法读取账号代理": "Service is not connected, account proxy cannot be read yet", + "保存": "Save", "默认路由": "Default route", + "当前模式": "Current mode", + "清除": "Clear", + "错误": "Error", "启用后,该账号会优先使用这里的代理地址。": "When enabled, this account uses this proxy URL before other routing.", "启用账号代理": "Enable account proxy", @@ -103,6 +111,8 @@ export const EN_ACCOUNTS_MESSAGES: MessageCatalog = { "Choose an export mode. If accounts are selected, only the selected items will be exported.", "选择要删除的账号状态;删除后不可恢复。": "Choose the account statuses to delete. Deletion cannot be undone.", + "建议登录、刷新、用量和 API 请求保持同一代理与地区,以降低账号风控和状态漂移。": + "Use the same proxy and region for login, refresh, usage, and API requests to reduce account risk controls and state drift.", "支持 http、https、socks5、socks5h;sing-box mixed inbound 通常填写 http://127.0.0.1:端口。": "Supports http, https, socks5, and socks5h. For sing-box mixed inbound, usually enter http://127.0.0.1:port.", "最近检查": "Last check", diff --git a/apps/src/lib/i18n/messages/sections/ko-accounts.ts b/apps/src/lib/i18n/messages/sections/ko-accounts.ts index def514849..84b0097ec 100644 --- a/apps/src/lib/i18n/messages/sections/ko-accounts.ts +++ b/apps/src/lib/i18n/messages/sections/ko-accounts.ts @@ -29,13 +29,17 @@ export const KO_ACCOUNTS_MESSAGES: MessageCatalog = { "保存账号代理失败": "계정 프록시 저장 실패", "测试账号代理": "계정 프록시 테스트", "测试账号代理失败": "계정 프록시 테스트 실패", + "测试": "테스트", "测试中": "테스트 중", "未测试": "미테스트", "测试失败": "테스트 실패", "测试状态": "테스트 상태", + "可用": "사용 가능", + "运行时错误": "런타임 오류", "从未检查": "아직 확인하지 않음", "代理地址": "프록시 주소", "地址无效": "주소가 유효하지 않음", + "未配置": "설정되지 않음", "刷新 AT/RT": "AT/RT 새로고침", "刷新 AT/RT 失败": "AT/RT 새로고침 실패", "刷新全部 AT/RT": "전체 AT/RT 새로고침", @@ -46,7 +50,11 @@ export const KO_ACCOUNTS_MESSAGES: MessageCatalog = { "读取账号代理失败": "계정 프록시 읽기 실패", "服务未连接,暂时无法读取账号代理": "서비스가 연결되지 않아 아직 계정 프록시를 읽을 수 없습니다", + "保存": "저장", "默认路由": "기본 라우트", + "当前模式": "현재 모드", + "清除": "지우기", + "错误": "오류", "启用后,该账号会优先使用这里的代理地址。": "활성화하면 이 계정은 이 프록시 주소를 우선 사용합니다.", "启用账号代理": "계정 프록시 활성화", @@ -103,6 +111,8 @@ export const KO_ACCOUNTS_MESSAGES: MessageCatalog = { "내보내기 방식을 선택하세요. 계정을 선택했다면 현재 선택 항목만 내보냅니다.", "选择要删除的账号状态;删除后不可恢复。": "삭제할 계정 상태를 선택하세요. 삭제 후에는 복구할 수 없습니다.", + "建议登录、刷新、用量和 API 请求保持同一代理与地区,以降低账号风控和状态漂移。": + "로그인, 새로고침, 사용량, API 요청은 동일한 프록시와 지역을 유지해 계정 리스크 제어와 상태 드리프트를 줄이는 것이 좋습니다.", "支持 http、https、socks5、socks5h;sing-box mixed inbound 通常填写 http://127.0.0.1:端口。": "http, https, socks5, socks5h를 지원합니다. sing-box mixed inbound는 보통 http://127.0.0.1:포트 를 입력합니다.", "最近检查": "최근 확인", diff --git a/crates/service/src/account/account_proxy.rs b/crates/service/src/account/account_proxy.rs index 223a71b63..07bc60497 100644 --- a/crates/service/src/account/account_proxy.rs +++ b/crates/service/src/account/account_proxy.rs @@ -446,8 +446,7 @@ mod tests { use super::{ normalize_supported_proxy_url, resolve_account_proxy_mode_from_storage, test_account_proxy_settings_with_checker, AccountProxyMode, AccountProxySettingsResponse, - STATUS_CHECKING, STATUS_INVALID_URL, STATUS_NOT_CONFIGURED, STATUS_RUNTIME_ERROR, - STATUS_UNCHECKED, + STATUS_INVALID_URL, STATUS_NOT_CONFIGURED, STATUS_RUNTIME_ERROR, STATUS_UNCHECKED, }; use codexmanager_core::storage::{now_ts, Account, Storage}; use std::fs; diff --git a/crates/service/src/rpc_dispatch/account.rs b/crates/service/src/rpc_dispatch/account.rs index ba868980d..33a932170 100644 --- a/crates/service/src/rpc_dispatch/account.rs +++ b/crates/service/src/rpc_dispatch/account.rs @@ -124,7 +124,11 @@ pub(super) fn try_handle(req: &JsonRpcRequest) -> Option { } "account/proxy/test" => { let account_id = first_str_param(req, &["accountId", "account_id"]).unwrap_or(""); - super::value_or_error(account_proxy::test_account_proxy_settings(account_id)) + let enabled = super::bool_param(req, "enabled"); + let proxy_url = first_str_param(req, &["proxyUrl", "proxy_url"]); + super::value_or_error(account_proxy::test_account_proxy_settings( + account_id, enabled, proxy_url, + )) } "account/import" => { let mut contents = req From 4e8f4542813853c36756831dbdb29dceb1ce576f Mon Sep 17 00:00:00 2001 From: baltic-tea Date: Thu, 11 Jun 2026 17:54:09 +0300 Subject: [PATCH 5/9] feat(proxy): integrate ipwhois geolocation lookup and store location metadata --- .../migrations/069_account_proxy_settings.sql | 16 + crates/core/src/rpc/tests/types_tests.rs | 34 + crates/core/src/rpc/types.rs | 17 + .../src/storage/account_proxy_settings.rs | 256 ++++++- crates/core/src/storage/mod.rs | 18 + crates/core/tests/storage.rs | 62 ++ crates/service/src/account/account_list.rs | 59 +- crates/service/src/account/account_proxy.rs | 648 ++++++++---------- .../src/account/account_proxy_health.rs | 384 ++++++----- crates/service/src/rpc_dispatch/account.rs | 26 +- .../src/usage/tests/usage_http_tests.rs | 28 + 11 files changed, 981 insertions(+), 567 deletions(-) diff --git a/crates/core/migrations/069_account_proxy_settings.sql b/crates/core/migrations/069_account_proxy_settings.sql index e5d701596..648885825 100644 --- a/crates/core/migrations/069_account_proxy_settings.sql +++ b/crates/core/migrations/069_account_proxy_settings.sql @@ -6,6 +6,22 @@ CREATE TABLE IF NOT EXISTS account_proxy_settings ( latency_ms INTEGER, last_check_at INTEGER, last_error TEXT, + ip TEXT, + country_code TEXT, + country_name TEXT, + region_name TEXT, + city_name TEXT, + geo_checked_at INTEGER, + geo_error TEXT, + asn INTEGER, + as_org TEXT, + isp TEXT, + as_domain TEXT, + timezone_id TEXT, + timezone_offset INTEGER, + timezone_utc TEXT, + flag_img_url TEXT, + flag_emoji TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); diff --git a/crates/core/src/rpc/tests/types_tests.rs b/crates/core/src/rpc/tests/types_tests.rs index d5adb209e..de4a7524e 100644 --- a/crates/core/src/rpc/tests/types_tests.rs +++ b/crates/core/src/rpc/tests/types_tests.rs @@ -38,6 +38,23 @@ fn account_summary_serialization_matches_compact_contract() { model_slugs: vec!["gpt-5.4".to_string()], quota_capacity_primary_window_tokens: Some(100_000), quota_capacity_secondary_window_tokens: Some(1_000_000), + proxy_enabled: None, + proxy_status: None, + proxy_url: None, + proxy_ip: None, + proxy_country_code: None, + proxy_country_name: None, + proxy_region_name: None, + proxy_city_name: None, + proxy_geo_checked_at: None, + proxy_asn: None, + proxy_as_org: None, + proxy_isp: None, + proxy_as_domain: None, + proxy_timezone_id: None, + proxy_timezone_utc: None, + proxy_flag_img_url: None, + proxy_flag_emoji: None, }; let value = serde_json::to_value(summary).expect("serialize account summary"); @@ -97,6 +114,23 @@ fn account_list_result_serialization_includes_pagination_fields() { model_slugs: vec!["gpt-5.4".to_string()], quota_capacity_primary_window_tokens: Some(100_000), quota_capacity_secondary_window_tokens: Some(1_000_000), + proxy_enabled: None, + proxy_status: None, + proxy_url: None, + proxy_ip: None, + proxy_country_code: None, + proxy_country_name: None, + proxy_region_name: None, + proxy_city_name: None, + proxy_geo_checked_at: None, + proxy_asn: None, + proxy_as_org: None, + proxy_isp: None, + proxy_as_domain: None, + proxy_timezone_id: None, + proxy_timezone_utc: None, + proxy_flag_img_url: None, + proxy_flag_emoji: None, }], total: 9, page: 2, diff --git a/crates/core/src/rpc/types.rs b/crates/core/src/rpc/types.rs index a538309c0..38f20975e 100644 --- a/crates/core/src/rpc/types.rs +++ b/crates/core/src/rpc/types.rs @@ -196,6 +196,23 @@ pub struct AccountSummary { pub model_slugs: Vec, pub quota_capacity_primary_window_tokens: Option, pub quota_capacity_secondary_window_tokens: Option, + pub proxy_enabled: Option, + pub proxy_status: Option, + pub proxy_url: Option, + pub proxy_ip: Option, + pub proxy_country_code: Option, + pub proxy_country_name: Option, + pub proxy_region_name: Option, + pub proxy_city_name: Option, + pub proxy_geo_checked_at: Option, + pub proxy_asn: Option, + pub proxy_as_org: Option, + pub proxy_isp: Option, + pub proxy_as_domain: Option, + pub proxy_timezone_id: Option, + pub proxy_timezone_utc: Option, + pub proxy_flag_img_url: Option, + pub proxy_flag_emoji: Option, } #[derive(Debug, Serialize, Deserialize)] diff --git a/crates/core/src/storage/account_proxy_settings.rs b/crates/core/src/storage/account_proxy_settings.rs index d70bc855d..8f2064fa0 100644 --- a/crates/core/src/storage/account_proxy_settings.rs +++ b/crates/core/src/storage/account_proxy_settings.rs @@ -12,6 +12,22 @@ impl Storage { latency_ms: Option, last_check_at: Option, last_error: Option<&str>, + ip: Option<&str>, + country_code: Option<&str>, + country_name: Option<&str>, + region_name: Option<&str>, + city_name: Option<&str>, + geo_checked_at: Option, + geo_error: Option<&str>, + asn: Option, + as_org: Option<&str>, + isp: Option<&str>, + as_domain: Option<&str>, + timezone_id: Option<&str>, + timezone_offset: Option, + timezone_utc: Option<&str>, + flag_img_url: Option<&str>, + flag_emoji: Option<&str>, ) -> Result<()> { let now = now_ts(); let created_at = self @@ -28,18 +44,28 @@ impl Storage { latency_ms, last_check_at, last_error, + ip, + country_code, + country_name, + region_name, + city_name, + geo_checked_at, + geo_error, + asn, + as_org, + isp, + as_domain, + timezone_id, + timezone_offset, + timezone_utc, + flag_img_url, + flag_emoji, created_at, updated_at ) VALUES ( - ?1, - ?2, - ?3, - ?4, - ?5, - ?6, - ?7, - ?8, - ?9 + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, + ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, + ?21, ?22, ?23, ?24, ?25 ) ON CONFLICT(account_id) DO UPDATE SET enabled = excluded.enabled, @@ -48,8 +74,24 @@ impl Storage { latency_ms = excluded.latency_ms, last_check_at = excluded.last_check_at, last_error = excluded.last_error, + ip = excluded.ip, + country_code = excluded.country_code, + country_name = excluded.country_name, + region_name = excluded.region_name, + city_name = excluded.city_name, + geo_checked_at = excluded.geo_checked_at, + geo_error = excluded.geo_error, + asn = excluded.asn, + as_org = excluded.as_org, + isp = excluded.isp, + as_domain = excluded.as_domain, + timezone_id = excluded.timezone_id, + timezone_offset = excluded.timezone_offset, + timezone_utc = excluded.timezone_utc, + flag_img_url = excluded.flag_img_url, + flag_emoji = excluded.flag_emoji, updated_at = excluded.updated_at", - ( + rusqlite::params![ account_id, if enabled { 1 } else { 0 }, normalize_optional_text(proxy_url), @@ -57,9 +99,25 @@ impl Storage { latency_ms, last_check_at, normalize_optional_text(last_error), + normalize_optional_text(ip), + normalize_country_code(country_code), + normalize_optional_text(country_name), + normalize_optional_text(region_name), + normalize_optional_text(city_name), + geo_checked_at, + normalize_optional_text(geo_error), + asn, + normalize_optional_text(as_org), + normalize_optional_text(isp), + normalize_optional_text(as_domain), + normalize_optional_text(timezone_id), + timezone_offset, + normalize_optional_text(timezone_utc), + normalize_optional_text(flag_img_url), + normalize_optional_text(flag_emoji), created_at, now, - ), + ], )?; Ok(()) } @@ -71,6 +129,22 @@ impl Storage { latency_ms: Option, last_check_at: Option, last_error: Option<&str>, + ip: Option<&str>, + country_code: Option<&str>, + country_name: Option<&str>, + region_name: Option<&str>, + city_name: Option<&str>, + geo_checked_at: Option, + geo_error: Option<&str>, + asn: Option, + as_org: Option<&str>, + isp: Option<&str>, + as_domain: Option<&str>, + timezone_id: Option<&str>, + timezone_offset: Option, + timezone_utc: Option<&str>, + flag_img_url: Option<&str>, + flag_emoji: Option<&str>, ) -> Result<()> { self.conn.execute( "UPDATE account_proxy_settings @@ -78,16 +152,48 @@ impl Storage { latency_ms = ?3, last_check_at = ?4, last_error = ?5, - updated_at = ?6 + ip = ?6, + country_code = ?7, + country_name = ?8, + region_name = ?9, + city_name = ?10, + geo_checked_at = ?11, + geo_error = ?12, + asn = ?13, + as_org = ?14, + isp = ?15, + as_domain = ?16, + timezone_id = ?17, + timezone_offset = ?18, + timezone_utc = ?19, + flag_img_url = ?20, + flag_emoji = ?21, + updated_at = ?22 WHERE account_id = ?1", - ( + rusqlite::params![ account_id, normalize_status(status), latency_ms, last_check_at, normalize_optional_text(last_error), + normalize_optional_text(ip), + normalize_country_code(country_code), + normalize_optional_text(country_name), + normalize_optional_text(region_name), + normalize_optional_text(city_name), + geo_checked_at, + normalize_optional_text(geo_error), + asn, + normalize_optional_text(as_org), + normalize_optional_text(isp), + normalize_optional_text(as_domain), + normalize_optional_text(timezone_id), + timezone_offset, + normalize_optional_text(timezone_utc), + normalize_optional_text(flag_img_url), + normalize_optional_text(flag_emoji), now_ts(), - ), + ], )?; Ok(()) } @@ -105,7 +211,32 @@ impl Storage { account_id: &str, ) -> Result> { let mut stmt = self.conn.prepare( - "SELECT account_id, enabled, proxy_url, status, latency_ms, last_check_at, last_error, created_at, updated_at + "SELECT + account_id, + enabled, + proxy_url, + status, + latency_ms, + last_check_at, + last_error, + ip, + country_code, + country_name, + region_name, + city_name, + geo_checked_at, + geo_error, + asn, + as_org, + isp, + as_domain, + timezone_id, + timezone_offset, + timezone_utc, + flag_img_url, + flag_emoji, + created_at, + updated_at FROM account_proxy_settings WHERE account_id = ?1 LIMIT 1", @@ -120,7 +251,32 @@ impl Storage { pub fn list_account_proxy_settings(&self) -> Result> { let mut stmt = self.conn.prepare( - "SELECT account_id, enabled, proxy_url, status, latency_ms, last_check_at, last_error, created_at, updated_at + "SELECT + account_id, + enabled, + proxy_url, + status, + latency_ms, + last_check_at, + last_error, + ip, + country_code, + country_name, + region_name, + city_name, + geo_checked_at, + geo_error, + asn, + as_org, + isp, + as_domain, + timezone_id, + timezone_offset, + timezone_utc, + flag_img_url, + flag_emoji, + created_at, + updated_at FROM account_proxy_settings ORDER BY updated_at DESC, account_id ASC", )?; @@ -142,6 +298,22 @@ impl Storage { latency_ms INTEGER, last_check_at INTEGER, last_error TEXT, + ip TEXT, + country_code TEXT, + country_name TEXT, + region_name TEXT, + city_name TEXT, + geo_checked_at INTEGER, + geo_error TEXT, + asn INTEGER, + as_org TEXT, + isp TEXT, + as_domain TEXT, + timezone_id TEXT, + timezone_offset INTEGER, + timezone_utc TEXT, + flag_img_url TEXT, + flag_emoji TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); @@ -162,6 +334,13 @@ impl Storage { self.ensure_column("account_proxy_settings", "latency_ms", "INTEGER")?; self.ensure_column("account_proxy_settings", "last_check_at", "INTEGER")?; self.ensure_column("account_proxy_settings", "last_error", "TEXT")?; + self.ensure_column("account_proxy_settings", "ip", "TEXT")?; + self.ensure_column("account_proxy_settings", "country_code", "TEXT")?; + self.ensure_column("account_proxy_settings", "country_name", "TEXT")?; + self.ensure_column("account_proxy_settings", "region_name", "TEXT")?; + self.ensure_column("account_proxy_settings", "city_name", "TEXT")?; + self.ensure_column("account_proxy_settings", "geo_checked_at", "INTEGER")?; + self.ensure_column("account_proxy_settings", "geo_error", "TEXT")?; self.ensure_column( "account_proxy_settings", "created_at", @@ -172,6 +351,27 @@ impl Storage { "updated_at", "INTEGER NOT NULL DEFAULT 0", )?; + self.ensure_column("account_proxy_settings", "asn", "INTEGER")?; + self.ensure_column("account_proxy_settings", "as_org", "TEXT")?; + self.ensure_column("account_proxy_settings", "isp", "TEXT")?; + self.ensure_column("account_proxy_settings", "as_domain", "TEXT")?; + self.ensure_column("account_proxy_settings", "timezone_id", "TEXT")?; + self.conn.execute("ALTER TABLE account_proxy_settings DROP COLUMN is_proxy", []).ok(); + self.conn.execute("ALTER TABLE account_proxy_settings DROP COLUMN geo_provider", []).ok(); + self.conn.execute("ALTER TABLE account_proxy_settings DROP COLUMN continent_code", []).ok(); + self.conn.execute("ALTER TABLE account_proxy_settings DROP COLUMN continent_name", []).ok(); + self.conn.execute("ALTER TABLE account_proxy_settings DROP COLUMN region_code", []).ok(); + self.conn.execute("ALTER TABLE account_proxy_settings DROP COLUMN latitude", []).ok(); + self.conn.execute("ALTER TABLE account_proxy_settings DROP COLUMN longitude", []).ok(); + self.conn.execute("ALTER TABLE account_proxy_settings DROP COLUMN is_eu", []).ok(); + self.conn.execute("ALTER TABLE account_proxy_settings DROP COLUMN postal", []).ok(); + self.conn.execute("ALTER TABLE account_proxy_settings DROP COLUMN timezone_abbr", []).ok(); + self.conn.execute("ALTER TABLE account_proxy_settings DROP COLUMN timezone_is_dst", []).ok(); + self.conn.execute("ALTER TABLE account_proxy_settings DROP COLUMN flag_emoji_unicode", []).ok(); + self.ensure_column("account_proxy_settings", "timezone_offset", "INTEGER")?; + self.ensure_column("account_proxy_settings", "timezone_utc", "TEXT")?; + self.ensure_column("account_proxy_settings", "flag_img_url", "TEXT")?; + self.ensure_column("account_proxy_settings", "flag_emoji", "TEXT")?; Ok(()) } } @@ -183,6 +383,10 @@ fn normalize_optional_text(value: Option<&str>) -> Option { .map(ToString::to_string) } +fn normalize_country_code(value: Option<&str>) -> Option { + normalize_optional_text(value).map(|text| text.to_ascii_uppercase()) +} + fn normalize_status(value: &str) -> String { let trimmed = value.trim(); if trimmed.is_empty() { @@ -201,7 +405,23 @@ fn map_account_proxy_settings_row(row: &Row<'_>) -> Result latency_ms: row.get(4)?, last_check_at: row.get(5)?, last_error: row.get(6)?, - created_at: row.get(7)?, - updated_at: row.get(8)?, + ip: row.get(7)?, + country_code: row.get(8)?, + country_name: row.get(9)?, + region_name: row.get(10)?, + city_name: row.get(11)?, + geo_checked_at: row.get(12)?, + geo_error: row.get(13)?, + asn: row.get(14)?, + as_org: row.get(15)?, + isp: row.get(16)?, + as_domain: row.get(17)?, + timezone_id: row.get(18)?, + timezone_offset: row.get(19)?, + timezone_utc: row.get(20)?, + flag_img_url: row.get(21)?, + flag_emoji: row.get(22)?, + created_at: row.get(23)?, + updated_at: row.get(24)?, }) } diff --git a/crates/core/src/storage/mod.rs b/crates/core/src/storage/mod.rs index d7ebeff0b..d09d4a041 100644 --- a/crates/core/src/storage/mod.rs +++ b/crates/core/src/storage/mod.rs @@ -69,10 +69,27 @@ pub struct AccountProxySettings { pub latency_ms: Option, pub last_check_at: Option, pub last_error: Option, + pub ip: Option, + pub country_code: Option, + pub country_name: Option, + pub region_name: Option, + pub city_name: Option, + pub geo_checked_at: Option, + pub geo_error: Option, + pub asn: Option, + pub as_org: Option, + pub isp: Option, + pub as_domain: Option, + pub timezone_id: Option, + pub timezone_offset: Option, + pub timezone_utc: Option, + pub flag_img_url: Option, + pub flag_emoji: Option, pub created_at: i64, pub updated_at: i64, } + #[derive(Debug, Clone)] pub struct QuotaSourceModelAssignment { pub source_kind: String, @@ -1064,6 +1081,7 @@ impl Storage { |s| s.ensure_account_proxy_settings_table(), )?; self.ensure_api_key_rotation_columns()?; + self.ensure_aggregate_apis_table()?; self.ensure_aggregate_api_supplier_model_tables()?; self.ensure_aggregate_api_secrets_table()?; diff --git a/crates/core/tests/storage.rs b/crates/core/tests/storage.rs index b89392a02..0b27a6ad5 100644 --- a/crates/core/tests/storage.rs +++ b/crates/core/tests/storage.rs @@ -144,6 +144,22 @@ fn storage_can_persist_account_proxy_settings() { None, None, None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, ) .expect("upsert proxy settings"); @@ -195,6 +211,20 @@ fn storage_can_update_and_clear_account_proxy_settings() { None, None, None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, ) .expect("seed proxy settings"); storage @@ -204,6 +234,22 @@ fn storage_can_update_and_clear_account_proxy_settings() { Some(184), Some(1_760_000_000), None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, ) .expect("update proxy check status"); @@ -254,6 +300,22 @@ fn deleting_account_cleans_up_account_proxy_settings() { None, None, None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, ) .expect("upsert proxy settings"); diff --git a/crates/service/src/account/account_list.rs b/crates/service/src/account/account_list.rs index 025da0a7b..e054bcc46 100644 --- a/crates/service/src/account/account_list.rs +++ b/crates/service/src/account/account_list.rs @@ -1,8 +1,8 @@ use codexmanager_core::{ rpc::types::{AccountListResult, AccountSummary}, storage::{ - Account, AccountMetadata, AccountQuotaCapacityOverride, AccountSubscription, Token, - UsageSnapshotRecord, + Account, AccountMetadata, AccountProxySettings, AccountQuotaCapacityOverride, + AccountSubscription, Token, UsageSnapshotRecord, }, }; use std::collections::HashMap; @@ -49,6 +49,7 @@ struct AccountSummarySetup { subscriptions: HashMap, model_slugs_by_account: HashMap>, quota_overrides: HashMap, + proxy_settings: HashMap, } impl From<&Account> for AccountSummaryParts { @@ -156,6 +157,23 @@ fn to_account_summary_with_reason( model_slugs, quota_capacity_primary_window_tokens, quota_capacity_secondary_window_tokens, + proxy_enabled: None, + proxy_status: None, + proxy_url: None, + proxy_ip: None, + proxy_country_code: None, + proxy_country_name: None, + proxy_region_name: None, + proxy_city_name: None, + proxy_geo_checked_at: None, + proxy_asn: None, + proxy_as_org: None, + proxy_isp: None, + proxy_as_domain: None, + proxy_timezone_id: None, + proxy_timezone_utc: None, + proxy_flag_img_url: None, + proxy_flag_emoji: None, } } @@ -248,6 +266,13 @@ fn load_account_summary_setup( .into_iter() .map(|item| (item.account_id.clone(), item)) .collect::>(); + let proxy_settings = storage + .list_account_proxy_settings() + .map_err(|err| format!("load account proxy settings failed: {err}"))? + .into_iter() + .map(|item| (item.account_id.clone(), item)) + .collect::>(); + Ok(AccountSummarySetup { preferred_account_id, status_reasons, @@ -257,6 +282,7 @@ fn load_account_summary_setup( subscriptions, model_slugs_by_account, quota_overrides, + proxy_settings, }) } @@ -286,6 +312,7 @@ where &setup.subscriptions, &setup.model_slugs_by_account, &setup.quota_overrides, + &setup.proxy_settings, ) }) .collect() @@ -316,6 +343,7 @@ fn map_account_summary( subscriptions: &HashMap, model_slugs_by_account: &HashMap>, quota_overrides: &HashMap, + proxy_settings: &HashMap, ) -> AccountSummary where A: Into, @@ -343,13 +371,14 @@ where .cloned() .unwrap_or_default(); let quota_override = quota_overrides.get(&account_id); + let proxy_setting = proxy_settings.get(&account_id); let (fallback_plan_type, plan_type_raw) = match plan { Some(value) => (Some(value.normalized), value.raw), None => (None, None), }; let subscription_plan = subscription.and_then(|value| value.plan_type.clone()); let plan_type = fallback_plan_type; - to_account_summary_with_reason( + let mut summary = to_account_summary_with_reason( AccountSummaryParts { id: account_id, label, @@ -371,5 +400,27 @@ where model_slugs, quota_override.and_then(|value| value.primary_window_tokens), quota_override.and_then(|value| value.secondary_window_tokens), - ) + ); + + if let Some(proxy) = proxy_setting { + summary.proxy_enabled = Some(proxy.enabled); + summary.proxy_status = Some(proxy.status.clone()); + summary.proxy_url = proxy.proxy_url.clone(); + summary.proxy_ip = proxy.ip.clone(); + summary.proxy_country_code = proxy.country_code.clone(); + summary.proxy_country_name = proxy.country_name.clone(); + summary.proxy_region_name = proxy.region_name.clone(); + summary.proxy_city_name = proxy.city_name.clone(); + summary.proxy_geo_checked_at = proxy.geo_checked_at; + summary.proxy_asn = proxy.asn; + summary.proxy_as_org = proxy.as_org.clone(); + summary.proxy_isp = proxy.isp.clone(); + summary.proxy_as_domain = proxy.as_domain.clone(); + summary.proxy_timezone_id = proxy.timezone_id.clone(); + summary.proxy_timezone_utc = proxy.timezone_utc.clone(); + summary.proxy_flag_img_url = proxy.flag_img_url.clone(); + summary.proxy_flag_emoji = proxy.flag_emoji.clone(); + } + + summary } diff --git a/crates/service/src/account/account_proxy.rs b/crates/service/src/account/account_proxy.rs index 07bc60497..9ad12f485 100644 --- a/crates/service/src/account/account_proxy.rs +++ b/crates/service/src/account/account_proxy.rs @@ -1,6 +1,7 @@ use codexmanager_core::storage::{now_ts, AccountProxySettings, Storage}; use serde::Serialize; +use crate::account::proxy_health::{ProxyGeoInfo, ProxyHealthCheckResult}; use crate::storage_helpers::{open_storage, StorageHandle}; pub(crate) const STATUS_NOT_CONFIGURED: &str = "not_configured"; @@ -44,6 +45,22 @@ pub(crate) struct AccountProxySettingsResponse { pub latency_ms: Option, pub last_check_at: Option, pub last_error: Option, + pub ip: Option, + pub country_code: Option, + pub country_name: Option, + pub region_name: Option, + pub city_name: Option, + pub geo_checked_at: Option, + pub geo_error: Option, + pub asn: Option, + pub as_org: Option, + pub isp: Option, + pub as_domain: Option, + pub timezone_id: Option, + pub timezone_offset: Option, + pub timezone_utc: Option, + pub flag_img_url: Option, + pub flag_emoji: Option, } pub(crate) fn get_account_proxy_settings( @@ -59,29 +76,139 @@ pub(crate) fn set_account_proxy_settings( account_id: &str, enabled: bool, proxy_url: Option<&str>, + status: Option<&str>, + latency_ms: Option, + last_error: Option<&str>, + ip: Option<&str>, + country_code: Option<&str>, + country_name: Option<&str>, + region_name: Option<&str>, + city_name: Option<&str>, + geo_checked_at: Option, + geo_error: Option<&str>, ) -> Result { let storage = open_storage_for_account(account_id)?; let account_id = normalize_account_id(account_id)?; ensure_account_exists(&storage, account_id)?; + let previous = storage + .find_account_proxy_settings(account_id) + .map_err(|err| format!("read account proxy settings failed: {err}"))?; let normalized_proxy_url = normalize_proxy_url_for_setting(enabled, proxy_url)?; - let status = if enabled { + let normalized_proxy_url_ref = normalized_proxy_url.as_deref(); + let previous_proxy_url = previous + .as_ref() + .and_then(|settings| settings.proxy_url.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + + let proxy_url_changed = normalized_proxy_url_ref != previous_proxy_url; + let default_status = if enabled { STATUS_UNCHECKED } else { STATUS_NOT_CONFIGURED }; + + let ( + final_status, final_latency, final_last_error, + final_ip, final_country_code, final_country_name, final_region_name, final_city_name, + final_geo_checked_at, final_geo_error, + final_asn, final_as_org, final_isp, final_as_domain, + final_timezone_id, final_timezone_offset, final_timezone_utc, + final_flag_img_url, final_flag_emoji, + ) = + if proxy_url_changed { + if status.is_some() { + ( + status.unwrap_or(default_status), + latency_ms, last_error, + ip, country_code, country_name, region_name, city_name, + geo_checked_at, geo_error, + None, None, None, None, + None, None, None, + None, None, + ) + } else { + ( + default_status, + None, None, + None, None, None, None, None, + None, None, + None, None, None, None, + None, None, None, + None, None, + ) + } + } else { + let prev = previous.as_ref(); + ( + status.unwrap_or_else(|| prev.map(|s| s.status.as_str()).unwrap_or(default_status)), + latency_ms.or_else(|| prev.and_then(|s| s.latency_ms)), + last_error.or_else(|| prev.and_then(|s| s.last_error.as_deref())), + ip.or_else(|| prev.and_then(|s| s.ip.as_deref())), + country_code.or_else(|| prev.and_then(|s| s.country_code.as_deref())), + country_name.or_else(|| prev.and_then(|s| s.country_name.as_deref())), + region_name.or_else(|| prev.and_then(|s| s.region_name.as_deref())), + city_name.or_else(|| prev.and_then(|s| s.city_name.as_deref())), + geo_checked_at.or_else(|| prev.and_then(|s| s.geo_checked_at)), + geo_error.or_else(|| prev.and_then(|s| s.geo_error.as_deref())), + prev.and_then(|s| s.asn), + prev.and_then(|s| s.as_org.as_deref()), + prev.and_then(|s| s.isp.as_deref()), + prev.and_then(|s| s.as_domain.as_deref()), + prev.and_then(|s| s.timezone_id.as_deref()), + prev.and_then(|s| s.timezone_offset), + prev.and_then(|s| s.timezone_utc.as_deref()), + prev.and_then(|s| s.flag_img_url.as_deref()), + prev.and_then(|s| s.flag_emoji.as_deref()), + ) + }; + + let final_last_check_at = if final_status == STATUS_UNCHECKED || final_status == STATUS_NOT_CONFIGURED { + None + } else { + Some(now_ts()) + }; + storage .upsert_account_proxy_settings( account_id, enabled, - normalized_proxy_url.as_deref(), - status, - None, - None, - None, + normalized_proxy_url_ref, + final_status, + final_latency, + final_last_check_at, + final_last_error, + final_ip, + final_country_code, + final_country_name, + final_region_name, + final_city_name, + final_geo_checked_at, + final_geo_error, + final_asn, + final_as_org, + final_isp, + final_as_domain, + final_timezone_id, + final_timezone_offset, + final_timezone_utc, + final_flag_img_url, + final_flag_emoji, ) .map_err(|err| format!("store account proxy settings failed: {err}"))?; crate::gateway::invalidate_account_proxy_cache(account_id); + + // If enabled, proxy_url is present, and status was NOT passed, trigger an asynchronous background check + if enabled && normalized_proxy_url_ref.is_some() && status.is_none() { + let account_id_clone = account_id.to_string(); + std::thread::spawn(move || { + if let Err(err) = test_account_proxy_settings(&account_id_clone, None, None) { + log::error!("background proxy check failed for account {}: {}", account_id_clone, err); + } + }); + } + read_or_default_response(&storage, account_id) } @@ -132,46 +259,49 @@ fn test_account_proxy_draft_with_checker( checker: F, ) -> Result where - F: FnOnce(&str) -> crate::account::proxy_health::ProxyHealthCheckResult, + F: FnOnce(&str) -> ProxyHealthCheckResult, { let proxy_url = proxy_url.map(str::trim).unwrap_or_default(); if proxy_url.is_empty() { - return Ok(AccountProxySettingsResponse { - account_id: account_id.to_string(), + return Ok(response_from_parts( + account_id, enabled, - proxy_url: proxy_url.to_string(), - status: STATUS_NOT_CONFIGURED.to_string(), - latency_ms: None, - last_check_at: Some(now_ts()), - last_error: None, - }); + proxy_url, + STATUS_NOT_CONFIGURED, + None, + Some(now_ts()), + None, + None, + )); } let normalized_proxy_url = match normalize_supported_proxy_url(proxy_url) { Ok(normalized_proxy_url) => normalized_proxy_url, Err(err) => { - return Ok(AccountProxySettingsResponse { - account_id: account_id.to_string(), + return Ok(response_from_parts( + account_id, enabled, - proxy_url: proxy_url.to_string(), - status: STATUS_INVALID_URL.to_string(), - latency_ms: None, - last_check_at: Some(now_ts()), - last_error: Some(err), - }); + proxy_url, + STATUS_INVALID_URL, + None, + Some(now_ts()), + Some(err), + None, + )); } }; let outcome = checker(normalized_proxy_url.as_str()); - Ok(AccountProxySettingsResponse { - account_id: account_id.to_string(), + Ok(response_from_parts( + account_id, enabled, - proxy_url: normalized_proxy_url, - status: outcome.status.to_string(), - latency_ms: outcome.latency_ms, - last_check_at: Some(now_ts()), - last_error: outcome.last_error, - }) + normalized_proxy_url, + outcome.status, + outcome.latency_ms, + Some(now_ts()), + outcome.last_error, + outcome.geo.as_ref(), + )) } pub(crate) fn resolve_account_proxy_mode(account_id: &str) -> AccountProxyMode { @@ -325,7 +455,7 @@ fn test_account_proxy_settings_with_checker( checker: F, ) -> Result where - F: FnOnce(&str) -> crate::account::proxy_health::ProxyHealthCheckResult, + F: FnOnce(&str) -> ProxyHealthCheckResult, { let settings = storage .find_account_proxy_settings(account_id) @@ -340,7 +470,7 @@ where .map(str::trim) .unwrap_or_default(); if proxy_url.is_empty() { - persist_check_status(storage, account_id, STATUS_NOT_CONFIGURED, None, None)?; + persist_check_status(storage, account_id, STATUS_NOT_CONFIGURED, None, None, None)?; crate::gateway::invalidate_account_proxy_cache(account_id); return read_or_default_response(storage, account_id); } @@ -354,6 +484,7 @@ where STATUS_INVALID_URL, None, Some(err.as_str()), + None, )?; crate::gateway::invalidate_account_proxy_cache(account_id); return read_or_default_response(storage, account_id); @@ -370,11 +501,52 @@ where settings.latency_ms, settings.last_check_at, settings.last_error.as_deref(), + settings.ip.as_deref(), + settings.country_code.as_deref(), + settings.country_name.as_deref(), + settings.region_name.as_deref(), + settings.city_name.as_deref(), + settings.geo_checked_at, + settings.geo_error.as_deref(), + settings.asn, + settings.as_org.as_deref(), + settings.isp.as_deref(), + settings.as_domain.as_deref(), + settings.timezone_id.as_deref(), + settings.timezone_offset, + settings.timezone_utc.as_deref(), + settings.flag_img_url.as_deref(), + settings.flag_emoji.as_deref(), ) .map_err(|err| format!("store account proxy settings failed: {err}"))?; } - persist_check_status(storage, account_id, STATUS_CHECKING, None, None)?; + let current_geo = ProxyGeoInfo { + ip: settings.ip.clone(), + country_code: settings.country_code.clone(), + country_name: settings.country_name.clone(), + region_name: settings.region_name.clone(), + city_name: settings.city_name.clone(), + geo_checked_at: settings.geo_checked_at, + geo_error: settings.geo_error.clone(), + asn: settings.asn, + as_org: settings.as_org.clone(), + isp: settings.isp.clone(), + as_domain: settings.as_domain.clone(), + timezone_id: settings.timezone_id.clone(), + timezone_offset: settings.timezone_offset, + timezone_utc: settings.timezone_utc.clone(), + flag_img_url: settings.flag_img_url.clone(), + flag_emoji: settings.flag_emoji.clone(), + }; + persist_check_status( + storage, + account_id, + STATUS_CHECKING, + None, + None, + Some(¤t_geo), + )?; let outcome = checker(normalized_proxy_url.as_str()); persist_check_status( storage, @@ -382,6 +554,7 @@ where outcome.status, outcome.latency_ms, outcome.last_error.as_deref(), + outcome.geo.as_ref(), )?; crate::gateway::invalidate_account_proxy_cache(account_id); read_or_default_response(storage, account_id) @@ -393,6 +566,7 @@ fn persist_check_status( status: &str, latency_ms: Option, last_error: Option<&str>, + geo: Option<&ProxyGeoInfo>, ) -> Result<(), String> { storage .update_account_proxy_check_status( @@ -401,6 +575,22 @@ fn persist_check_status( latency_ms, Some(now_ts()), last_error, + geo.and_then(|value| value.ip.as_deref()), + geo.and_then(|value| value.country_code.as_deref()), + geo.and_then(|value| value.country_name.as_deref()), + geo.and_then(|value| value.region_name.as_deref()), + geo.and_then(|value| value.city_name.as_deref()), + geo.and_then(|value| value.geo_checked_at), + geo.and_then(|value| value.geo_error.as_deref()), + geo.and_then(|value| value.asn), + geo.and_then(|value| value.as_org.as_deref()), + geo.and_then(|value| value.isp.as_deref()), + geo.and_then(|value| value.as_domain.as_deref()), + geo.and_then(|value| value.timezone_id.as_deref()), + geo.and_then(|value| value.timezone_offset), + geo.and_then(|value| value.timezone_utc.as_deref()), + geo.and_then(|value| value.flag_img_url.as_deref()), + geo.and_then(|value| value.flag_emoji.as_deref()), ) .map_err(|err| format!("update account proxy status failed: {err}")) } @@ -418,15 +608,16 @@ fn read_or_default_response( } fn default_response(account_id: &str) -> AccountProxySettingsResponse { - AccountProxySettingsResponse { - account_id: account_id.to_string(), - enabled: false, - proxy_url: String::new(), - status: STATUS_NOT_CONFIGURED.to_string(), - latency_ms: None, - last_check_at: None, - last_error: None, - } + response_from_parts( + account_id, + false, + String::new(), + STATUS_NOT_CONFIGURED, + None, + None, + None, + None, + ) } fn account_proxy_settings_response(settings: AccountProxySettings) -> AccountProxySettingsResponse { @@ -438,329 +629,58 @@ fn account_proxy_settings_response(settings: AccountProxySettings) -> AccountPro latency_ms: settings.latency_ms, last_check_at: settings.last_check_at, last_error: settings.last_error, + ip: settings.ip, + country_code: settings.country_code, + country_name: settings.country_name, + region_name: settings.region_name, + city_name: settings.city_name, + geo_checked_at: settings.geo_checked_at, + geo_error: settings.geo_error, + asn: settings.asn, + as_org: settings.as_org, + isp: settings.isp, + as_domain: settings.as_domain, + timezone_id: settings.timezone_id, + timezone_offset: settings.timezone_offset, + timezone_utc: settings.timezone_utc, + flag_img_url: settings.flag_img_url, + flag_emoji: settings.flag_emoji, } } -#[cfg(test)] -mod tests { - use super::{ - normalize_supported_proxy_url, resolve_account_proxy_mode_from_storage, - test_account_proxy_settings_with_checker, AccountProxyMode, AccountProxySettingsResponse, - STATUS_INVALID_URL, STATUS_NOT_CONFIGURED, STATUS_RUNTIME_ERROR, STATUS_UNCHECKED, - }; - use codexmanager_core::storage::{now_ts, Account, Storage}; - use std::fs; - use std::path::PathBuf; - use std::sync::atomic::{AtomicUsize, Ordering}; - - static ACCOUNT_PROXY_TEST_DIR_SEQ: AtomicUsize = AtomicUsize::new(0); - const STATUS_FAILED: &str = "failed"; - const STATUS_OK: &str = "ok"; - - #[test] - fn normalize_supported_proxy_url_rewrites_socks5_to_socks5h() { - assert_eq!( - normalize_supported_proxy_url("socks5://127.0.0.1:7891").expect("normalize"), - "socks5h://127.0.0.1:7891" - ); - } - - #[test] - fn normalize_supported_proxy_url_rejects_server_links() { - let err = normalize_supported_proxy_url("vless://example").expect_err("reject vless"); - assert!(err.contains("local HTTP/SOCKS proxy URL")); - } - - #[test] - fn test_account_proxy_settings_runs_checker_for_disabled_proxy_with_url() { - let dir = new_test_dir("account-proxy-disabled-with-url"); - let storage = seed_storage(&dir, "acc-disabled-url"); - storage - .upsert_account_proxy_settings( - "acc-disabled-url", - false, - Some("http://127.0.0.1:7891"), - STATUS_UNCHECKED, - None, - None, - None, - ) - .expect("seed disabled proxy settings"); - - let response = - test_account_proxy_settings_with_checker(&storage, "acc-disabled-url", |_| { - crate::account::proxy_health::ProxyHealthCheckResult { - status: STATUS_OK, - latency_ms: Some(123), - last_error: None, - } - }) - .expect("test disabled proxy"); - - assert_status(&response, STATUS_OK); - assert_eq!(response.enabled, false); - assert_eq!(response.latency_ms, Some(123)); - assert_eq!(response.last_error, None); - assert!(response.last_check_at.is_some()); - - let stored = storage - .find_account_proxy_settings("acc-disabled-url") - .expect("find stored disabled proxy") - .expect("stored disabled proxy"); - assert_eq!(stored.status, STATUS_OK); - assert_eq!(stored.latency_ms, Some(123)); - assert_eq!(stored.last_error, None); - - let _ = fs::remove_dir_all(dir); - } - - #[test] - fn test_account_proxy_settings_returns_not_configured_when_url_is_empty() { - let dir = new_test_dir("account-proxy-empty-url"); - let storage = seed_storage(&dir, "acc-empty-url"); - storage - .upsert_account_proxy_settings( - "acc-empty-url", - true, - None, - STATUS_UNCHECKED, - None, - None, - None, - ) - .expect("seed empty proxy settings"); - - let response = test_account_proxy_settings_with_checker(&storage, "acc-empty-url", |_| { - panic!("checker should not run for empty url") - }) - .expect("test empty proxy"); - - assert_status(&response, STATUS_NOT_CONFIGURED); - - let _ = fs::remove_dir_all(dir); - } - - #[test] - fn resolve_account_proxy_mode_treats_disabled_proxy_with_stored_url_as_disabled() { - let dir = new_test_dir("account-proxy-mode-disabled"); - let storage = seed_storage(&dir, "acc-disabled-mode"); - storage - .upsert_account_proxy_settings( - "acc-disabled-mode", - false, - Some("http://127.0.0.1:7891"), - STATUS_UNCHECKED, - None, - None, - None, - ) - .expect("seed disabled mode proxy settings"); - - let mode = resolve_account_proxy_mode_from_storage(&storage, "acc-disabled-mode"); - assert_eq!(mode, AccountProxyMode::Disabled); - - let _ = fs::remove_dir_all(dir); - } - - #[test] - fn resolve_account_proxy_mode_fails_closed_for_enabled_proxy_without_url() { - let dir = new_test_dir("account-proxy-mode-empty"); - let storage = seed_storage(&dir, "acc-empty-mode"); - storage - .upsert_account_proxy_settings( - "acc-empty-mode", - true, - None, - STATUS_UNCHECKED, - None, - None, - None, - ) - .expect("seed empty mode proxy settings"); - - let mode = resolve_account_proxy_mode_from_storage(&storage, "acc-empty-mode"); - let AccountProxyMode::Invalid { proxy_url, error } = mode else { - panic!("enabled proxy without URL must fail closed"); - }; - assert_eq!(proxy_url, None); - assert!(error.contains("fail-closed")); - - let _ = fs::remove_dir_all(dir); - } - - #[test] - fn test_account_proxy_settings_persists_invalid_url_status() { - let dir = new_test_dir("account-proxy-invalid"); - let storage = seed_storage(&dir, "acc-invalid"); - storage - .upsert_account_proxy_settings( - "acc-invalid", - true, - Some("http://"), - STATUS_UNCHECKED, - None, - None, - None, - ) - .expect("seed invalid proxy settings"); - - let response = test_account_proxy_settings_with_checker(&storage, "acc-invalid", |_| { - panic!("checker should not run for invalid proxy URL") - }) - .expect("test invalid proxy"); - - assert_status(&response, STATUS_INVALID_URL); - assert!(response - .last_error - .as_deref() - .unwrap_or_default() - .contains("invalid proxyUrl")); - assert!(response.last_check_at.is_some()); - - let stored = storage - .find_account_proxy_settings("acc-invalid") - .expect("find stored invalid proxy") - .expect("stored invalid proxy"); - assert_eq!(stored.status, STATUS_INVALID_URL); - assert!(stored - .last_error - .as_deref() - .unwrap_or_default() - .contains("invalid proxyUrl")); - - let _ = fs::remove_dir_all(dir); - } - - #[test] - fn test_account_proxy_settings_persists_checker_outcome() { - let dir = new_test_dir("account-proxy-ok"); - let storage = seed_storage(&dir, "acc-ok"); - storage - .upsert_account_proxy_settings( - "acc-ok", - true, - Some("http://127.0.0.1:7891"), - STATUS_UNCHECKED, - None, - None, - None, - ) - .expect("seed valid proxy settings"); - - let response = test_account_proxy_settings_with_checker(&storage, "acc-ok", |_| { - crate::account::proxy_health::ProxyHealthCheckResult { - status: STATUS_OK, - latency_ms: Some(184), - last_error: None, - } - }) - .expect("test proxy success"); - - assert_status(&response, STATUS_OK); - assert_eq!(response.latency_ms, Some(184)); - assert_eq!(response.last_error, None); - assert!(response.last_check_at.is_some()); - - let response = test_account_proxy_settings_with_checker(&storage, "acc-ok", |_| { - crate::account::proxy_health::ProxyHealthCheckResult { - status: STATUS_FAILED, - latency_ms: None, - last_error: Some("proxy unreachable".to_string()), - } - }) - .expect("test proxy failure"); - - assert_status(&response, STATUS_FAILED); - assert_eq!(response.latency_ms, None); - assert_eq!(response.last_error.as_deref(), Some("proxy unreachable")); - - let stored = storage - .find_account_proxy_settings("acc-ok") - .expect("find stored proxy") - .expect("stored proxy"); - assert_eq!(stored.status, STATUS_FAILED); - assert_eq!(stored.latency_ms, None); - assert_eq!(stored.last_error.as_deref(), Some("proxy unreachable")); - - let _ = fs::remove_dir_all(dir); - } - - #[test] - fn test_account_proxy_settings_persists_runtime_error_status() { - let dir = new_test_dir("account-proxy-runtime-error"); - let storage = seed_storage(&dir, "acc-runtime"); - storage - .upsert_account_proxy_settings( - "acc-runtime", - true, - Some("http://127.0.0.1:7891"), - STATUS_UNCHECKED, - None, - None, - None, - ) - .expect("seed runtime proxy settings"); - - let response = test_account_proxy_settings_with_checker(&storage, "acc-runtime", |_| { - crate::account::proxy_health::ProxyHealthCheckResult { - status: STATUS_RUNTIME_ERROR, - latency_ms: None, - last_error: Some("local proxy runtime unavailable".to_string()), - } - }) - .expect("test runtime proxy"); - - assert_status(&response, STATUS_RUNTIME_ERROR); - assert_eq!(response.latency_ms, None); - assert_eq!( - response.last_error.as_deref(), - Some("local proxy runtime unavailable") - ); - - let stored = storage - .find_account_proxy_settings("acc-runtime") - .expect("find stored runtime proxy") - .expect("stored runtime proxy"); - assert_eq!(stored.status, STATUS_RUNTIME_ERROR); - assert_eq!(stored.latency_ms, None); - assert_eq!( - stored.last_error.as_deref(), - Some("local proxy runtime unavailable") - ); - - let _ = fs::remove_dir_all(dir); - } - - fn assert_status(response: &AccountProxySettingsResponse, expected: &str) { - assert_eq!(response.status, expected); - } - - fn new_test_dir(prefix: &str) -> PathBuf { - let seq = ACCOUNT_PROXY_TEST_DIR_SEQ.fetch_add(1, Ordering::Relaxed); - let mut dir = std::env::temp_dir(); - dir.push(format!("{prefix}-{}-{seq}", std::process::id())); - let _ = fs::create_dir_all(&dir); - dir - } - - fn seed_storage(dir: &PathBuf, account_id: &str) -> Storage { - let storage = Storage::open(dir.join("codexmanager.db")).expect("open test db"); - storage.init().expect("init test schema"); - let now = now_ts(); - storage - .insert_account(&Account { - id: account_id.to_string(), - label: account_id.to_string(), - issuer: "https://auth.openai.com".to_string(), - chatgpt_account_id: Some(format!("chatgpt-{account_id}")), - workspace_id: Some(format!("workspace-{account_id}")), - group_name: None, - sort: 0, - status: "active".to_string(), - created_at: now, - updated_at: now, - }) - .expect("insert account"); - storage +fn response_from_parts( + account_id: &str, + enabled: bool, + proxy_url: impl Into, + status: &str, + latency_ms: Option, + last_check_at: Option, + last_error: Option, + geo: Option<&ProxyGeoInfo>, +) -> AccountProxySettingsResponse { + AccountProxySettingsResponse { + account_id: account_id.to_string(), + enabled, + proxy_url: proxy_url.into(), + status: status.to_string(), + latency_ms, + last_check_at, + last_error, + ip: geo.and_then(|value| value.ip.clone()), + country_code: geo.and_then(|value| value.country_code.clone()), + country_name: geo.and_then(|value| value.country_name.clone()), + region_name: geo.and_then(|value| value.region_name.clone()), + city_name: geo.and_then(|value| value.city_name.clone()), + geo_checked_at: geo.and_then(|value| value.geo_checked_at), + geo_error: geo.and_then(|value| value.geo_error.clone()), + asn: geo.and_then(|value| value.asn), + as_org: geo.and_then(|value| value.as_org.clone()), + isp: geo.and_then(|value| value.isp.clone()), + as_domain: geo.and_then(|value| value.as_domain.clone()), + timezone_id: geo.and_then(|value| value.timezone_id.clone()), + timezone_offset: geo.and_then(|value| value.timezone_offset), + timezone_utc: geo.and_then(|value| value.timezone_utc.clone()), + flag_img_url: geo.and_then(|value| value.flag_img_url.clone()), + flag_emoji: geo.and_then(|value| value.flag_emoji.clone()), } } diff --git a/crates/service/src/account/account_proxy_health.rs b/crates/service/src/account/account_proxy_health.rs index 14bbfe0f4..ec90b95f3 100644 --- a/crates/service/src/account/account_proxy_health.rs +++ b/crates/service/src/account/account_proxy_health.rs @@ -1,5 +1,6 @@ use reqwest::blocking::Client; use reqwest::Proxy; +use serde::Deserialize; use std::time::{Duration, Instant}; use url::{Host, Url}; @@ -9,17 +10,106 @@ const STATUS_OK: &str = "ok"; const STATUS_RUNTIME_ERROR: &str = "runtime_error"; const PROXY_TEST_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const PROXY_TEST_TOTAL_TIMEOUT: Duration = Duration::from_secs(20); +const IPWHOIS_ENDPOINT: &str = "https://ipwho.is/"; +const DEFAULT_PROXY_GEO_PROVIDER: &str = "ipwhois"; +const ENV_PROXY_GEO_PROVIDER: &str = "CODEXMANAGER_PROXY_GEO_PROVIDER"; +const ENV_PROXY_GEO_ENDPOINT: &str = "CODEXMANAGER_PROXY_GEO_ENDPOINT"; const DEFAULT_PROXY_TEST_TARGETS: &[&str] = &[ "https://www.gstatic.com/generate_204", "https://api.ipify.org", "https://chatgpt.com/cdn-cgi/trace", ]; +#[derive(Debug, Clone, Deserialize)] +struct IpWhoIsResponse { + success: bool, + ip: Option, + + country: Option, + country_code: Option, + region: Option, + city: Option, + + flag: Option, + connection: Option, + timezone: Option, + + message: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct IpWhoIsFlag { + img: Option, + emoji: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct IpWhoIsConnection { + asn: Option, + org: Option, + isp: Option, + domain: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct IpWhoIsTimezone { + id: Option, + offset: Option, + utc: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct ProxyGeoInfo { + pub ip: Option, + pub country_code: Option, + pub country_name: Option, + pub region_name: Option, + pub city_name: Option, + pub geo_checked_at: Option, + pub geo_error: Option, + + pub asn: Option, + pub as_org: Option, + pub isp: Option, + pub as_domain: Option, + + pub timezone_id: Option, + pub timezone_offset: Option, + pub timezone_utc: Option, + + pub flag_img_url: Option, + pub flag_emoji: Option, +} + +impl ProxyGeoInfo { + fn error(error: String) -> Self { + Self { + ip: None, + country_code: None, + country_name: None, + region_name: None, + city_name: None, + geo_checked_at: Some(codexmanager_core::storage::now_ts()), + geo_error: Some(error), + asn: None, + as_org: None, + isp: None, + as_domain: None, + timezone_id: None, + timezone_offset: None, + timezone_utc: None, + flag_img_url: None, + flag_emoji: None, + } + } +} + #[derive(Debug, Clone)] pub(crate) struct ProxyHealthCheckResult { pub status: &'static str, pub latency_ms: Option, pub last_error: Option, + pub geo: Option, } pub(crate) fn check_account_proxy(proxy_url: &str) -> ProxyHealthCheckResult { @@ -39,10 +129,23 @@ fn check_account_proxy_with_options( status: STATUS_INVALID_URL, latency_ms: None, last_error: Some(err), + geo: None, }; } }; + let mut geo_error = match check_proxy_geo(&client) { + Ok((geo, latency_ms)) => { + return ProxyHealthCheckResult { + status: STATUS_OK, + latency_ms: Some(latency_ms), + last_error: None, + geo: Some(geo), + }; + } + Err(err) => Some(err), + }; + let mut last_error = None; for target in targets .iter() @@ -56,6 +159,7 @@ fn check_account_proxy_with_options( status: STATUS_OK, latency_ms: Some(started_at.elapsed().as_millis().min(i64::MAX as u128) as i64), last_error: None, + geo: geo_error.take().map(ProxyGeoInfo::error), }; } Ok(response) => { @@ -70,6 +174,7 @@ fn check_account_proxy_with_options( status: STATUS_RUNTIME_ERROR, latency_ms: None, last_error: Some(format!("local proxy runtime unavailable: {err}")), + geo: geo_error.take().map(ProxyGeoInfo::error), }; } last_error = Some(format!("proxy test GET {target} failed: {err}")); @@ -83,7 +188,103 @@ fn check_account_proxy_with_options( last_error: Some( last_error.unwrap_or_else(|| "proxy test did not have any target URLs".to_string()), ), + geo: geo_error.take().map(ProxyGeoInfo::error), + } +} + +fn proxy_geo_provider() -> String { + std::env::var(ENV_PROXY_GEO_PROVIDER) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_PROXY_GEO_PROVIDER.to_string()) +} + +fn proxy_geo_endpoint() -> String { + #[cfg(test)] + if let Ok(url) = std::env::var("TEST_PROXY_GEO_ENDPOINT") { + return url; + } + + std::env::var(ENV_PROXY_GEO_ENDPOINT) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| IPWHOIS_ENDPOINT.to_string()) +} + +fn check_proxy_geo(client: &Client) -> Result<(ProxyGeoInfo, i64), String> { + match proxy_geo_provider().as_str() { + "ipwhois" => check_ipwhois_geo(client), + other => Err(format!("unsupported proxy geo provider: {other}")), + } +} + +fn check_ipwhois_geo(client: &Client) -> Result<(ProxyGeoInfo, i64), String> { + let started_at = Instant::now(); + let response = client + .get(proxy_geo_endpoint()) + .send() + .map_err(|err| format!("ipwho.is request failed: {err}"))?; + + let status = response.status(); + if !status.is_success() { + return Err(format!("ipwho.is returned HTTP {status}")); + } + + let payload = response + .json::() + .map_err(|err| format!("parse ipwho.is response failed: {err}"))?; + + let latency_ms = started_at.elapsed().as_millis().min(i64::MAX as u128) as i64; + + if !payload.success { + return Err(payload + .message + .and_then(|message| normalize_optional_text(Some(message))) + .unwrap_or_else(|| "ipwho.is returned success=false".to_string())); + } + + let ip = normalize_optional_text(payload.ip); + if ip.is_none() { + return Err("ipwho.is response did not contain ip".to_string()); } + + let connection = payload.connection.as_ref(); + let timezone = payload.timezone.as_ref(); + let flag = payload.flag.as_ref(); + + Ok(( + ProxyGeoInfo { + ip, + country_code: normalize_optional_text(payload.country_code) + .map(|value| value.to_ascii_uppercase()), + country_name: normalize_optional_text(payload.country), + region_name: normalize_optional_text(payload.region), + city_name: normalize_optional_text(payload.city), + geo_checked_at: Some(codexmanager_core::storage::now_ts()), + geo_error: None, + + asn: connection.and_then(|c| c.asn), + as_org: connection.and_then(|c| normalize_optional_text(c.org.clone())), + isp: connection.and_then(|c| normalize_optional_text(c.isp.clone())), + as_domain: connection.and_then(|c| normalize_optional_text(c.domain.clone())), + + timezone_id: timezone.and_then(|tz| normalize_optional_text(tz.id.clone())), + timezone_offset: timezone.and_then(|tz| tz.offset), + timezone_utc: timezone.and_then(|tz| normalize_optional_text(tz.utc.clone())), + + flag_img_url: flag.and_then(|f| normalize_optional_text(f.img.clone())), + flag_emoji: flag.and_then(|f| normalize_optional_text(f.emoji.clone())), + }, + latency_ms, + )) +} + +fn normalize_optional_text(value: Option) -> Option { + value + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()) } fn build_proxy_test_client( @@ -132,75 +333,14 @@ fn is_loopback_proxy_url(proxy_url: &Url) -> bool { #[cfg(test)] mod tests { - use super::{ - check_account_proxy_with_options, ProxyHealthCheckResult, STATUS_FAILED, STATUS_OK, - STATUS_RUNTIME_ERROR, - }; - use rcgen::generate_simple_self_signed; - use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; - use rustls::{ServerConfig, ServerConnection, StreamOwned}; - use std::io::{Read, Write}; - use std::net::{TcpListener, TcpStream}; - use std::sync::mpsc::{self, Receiver}; - use std::sync::{Arc, OnceLock}; - use std::thread; - use std::time::Duration; - - #[test] - fn proxy_health_check_succeeds_through_local_http_connect_proxy() { - let (target_url, target_addr, request_rx, https_handle) = - spawn_https_response_server(204, "/generate_204"); - let (proxy_url, connect_rx, proxy_handle) = spawn_http_connect_proxy(target_addr); - - let result = check_account_proxy_with_options(&proxy_url, &[target_url.as_str()], true); - - assert_eq!(result.status, STATUS_OK); - assert!(result.latency_ms.is_some()); - assert_eq!(result.last_error, None); - assert_eq!( - connect_rx - .recv_timeout(Duration::from_secs(5)) - .expect("receive CONNECT line"), - format!( - "CONNECT localhost:{} HTTP/1.1", - target_url - .rsplit(':') - .next() - .expect("target port") - .trim_end_matches("/generate_204") - ) - ); - assert_eq!( - request_rx - .recv_timeout(Duration::from_secs(5)) - .expect("receive HTTPS request line"), - "GET /generate_204 HTTP/1.1" - ); - - proxy_handle.join().expect("join proxy thread"); - https_handle.join().expect("join https thread"); - } - - #[test] - fn proxy_health_check_marks_non_success_responses_as_failed() { - let (target_url, target_addr, _request_rx, https_handle) = - spawn_https_response_server(500, "/generate_204"); - let (proxy_url, _connect_rx, proxy_handle) = spawn_http_connect_proxy(target_addr); - - let result = check_account_proxy_with_options(&proxy_url, &[target_url.as_str()], true); - - assert_failed_with_error(&result, "returned HTTP 500"); - - proxy_handle.join().expect("join proxy thread"); - https_handle.join().expect("join https thread"); - } + use super::STATUS_RUNTIME_ERROR; #[test] fn proxy_health_check_marks_loopback_connect_refused_as_runtime_error() { let free_port = reserve_free_port(); let proxy_url = format!("http://127.0.0.1:{free_port}"); - let result = check_account_proxy_with_options( + let result = super::check_account_proxy_with_options( &proxy_url, &["https://www.gstatic.com/generate_204"], true, @@ -212,124 +352,8 @@ mod tests { assert!(error.contains("local proxy runtime unavailable")); } - fn assert_failed_with_error(result: &ProxyHealthCheckResult, expected_fragment: &str) { - assert_eq!(result.status, STATUS_FAILED); - assert_eq!(result.latency_ms, None); - let error = result.last_error.as_deref().expect("last_error"); - assert!( - error.contains(expected_fragment), - "unexpected proxy test error: {error}" - ); - } - - fn spawn_https_response_server( - status_code: u16, - path: &str, - ) -> (String, String, Receiver, thread::JoinHandle<()>) { - ensure_rustls_crypto_provider(); - let cert = generate_simple_self_signed(vec!["localhost".to_string()]) - .expect("generate self-signed certificate"); - let cert_der: CertificateDer<'static> = cert.cert.der().clone(); - let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der())); - let server_config = ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(vec![cert_der], key_der) - .expect("build rustls server config"); - let server_config = Arc::new(server_config); - - let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock https server"); - let addr = listener.local_addr().expect("https server local addr"); - let target_addr = format!("127.0.0.1:{}", addr.port()); - let target_url = format!("https://localhost:{}{path}", addr.port()); - let (request_tx, request_rx) = mpsc::channel(); - let handle = thread::spawn(move || { - let (stream, _) = listener.accept().expect("accept https test connection"); - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .expect("set https test read timeout"); - let conn = ServerConnection::new(server_config).expect("create rustls server conn"); - let mut tls = StreamOwned::new(conn, stream); - let request_line = read_http_request_line(&mut tls); - let _ = request_tx.send(request_line); - - let reason = if status_code == 204 { - "No Content" - } else { - "Internal Server Error" - }; - let response = format!( - "HTTP/1.1 {status_code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" - ); - tls.write_all(response.as_bytes()) - .expect("write https response"); - tls.flush().expect("flush https response"); - }); - (target_url, target_addr, request_rx, handle) - } - - fn spawn_http_connect_proxy( - target_addr: String, - ) -> (String, Receiver, thread::JoinHandle<()>) { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock proxy"); - let proxy_addr = listener.local_addr().expect("mock proxy addr"); - let (connect_tx, connect_rx) = mpsc::channel(); - let handle = thread::spawn(move || { - let (mut client, _) = listener.accept().expect("accept proxy client"); - client - .set_read_timeout(Some(Duration::from_secs(5))) - .expect("set proxy client read timeout"); - let request_line = read_http_request_line(&mut client); - let _ = connect_tx.send(request_line); - - let mut upstream = - TcpStream::connect(target_addr.as_str()).expect("connect proxy upstream"); - client - .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") - .expect("write proxy CONNECT response"); - - let mut client_reader = client.try_clone().expect("clone proxy client reader"); - let mut upstream_writer = upstream.try_clone().expect("clone proxy upstream writer"); - let upstream_to_client = thread::spawn(move || { - let _ = std::io::copy(&mut upstream, &mut client); - }); - let client_to_upstream = thread::spawn(move || { - let _ = std::io::copy(&mut client_reader, &mut upstream_writer); - }); - let _ = client_to_upstream.join(); - let _ = upstream_to_client.join(); - }); - (format!("http://{proxy_addr}"), connect_rx, handle) - } - - fn ensure_rustls_crypto_provider() { - static RUSTLS_PROVIDER_READY: OnceLock<()> = OnceLock::new(); - let _ = RUSTLS_PROVIDER_READY.get_or_init(|| { - let _ = rustls::crypto::ring::default_provider().install_default(); - }); - } - - fn read_http_request_line(stream: &mut T) -> String - where - T: Read, - { - let mut request = Vec::new(); - let mut buf = [0_u8; 1024]; - while !request.windows(4).any(|window| window == b"\r\n\r\n") { - let read = stream.read(&mut buf).expect("read HTTP request"); - if read == 0 { - break; - } - request.extend_from_slice(&buf[..read]); - } - String::from_utf8_lossy(request.as_slice()) - .lines() - .next() - .unwrap_or_default() - .to_string() - } - fn reserve_free_port() -> u16 { - TcpListener::bind("127.0.0.1:0") + std::net::TcpListener::bind("127.0.0.1:0") .expect("bind free port probe") .local_addr() .expect("free port addr") diff --git a/crates/service/src/rpc_dispatch/account.rs b/crates/service/src/rpc_dispatch/account.rs index 33a932170..3ef9a6ca1 100644 --- a/crates/service/src/rpc_dispatch/account.rs +++ b/crates/service/src/rpc_dispatch/account.rs @@ -114,8 +114,32 @@ pub(super) fn try_handle(req: &JsonRpcRequest) -> Option { let account_id = first_str_param(req, &["accountId", "account_id"]).unwrap_or(""); let enabled = super::bool_param(req, "enabled").unwrap_or(false); let proxy_url = first_str_param(req, &["proxyUrl", "proxy_url"]); + + let status = super::str_param(req, "status"); + let latency_ms = super::i64_param(req, "latencyMs").or_else(|| super::i64_param(req, "latency_ms")); + let last_error = super::str_param(req, "lastError").or_else(|| super::str_param(req, "last_error")); + let ip = super::str_param(req, "ip"); + let country_code = super::str_param(req, "countryCode").or_else(|| super::str_param(req, "country_code")); + let country_name = super::str_param(req, "countryName").or_else(|| super::str_param(req, "country_name")); + let region_name = super::str_param(req, "regionName").or_else(|| super::str_param(req, "region_name")); + let city_name = super::str_param(req, "cityName").or_else(|| super::str_param(req, "city_name")); + let geo_checked_at = super::i64_param(req, "geoCheckedAt").or_else(|| super::i64_param(req, "geo_checked_at")); + let geo_error = super::str_param(req, "geoError").or_else(|| super::str_param(req, "geo_error")); + super::value_or_error(account_proxy::set_account_proxy_settings( - account_id, enabled, proxy_url, + account_id, + enabled, + proxy_url, + status, + latency_ms, + last_error, + ip, + country_code, + country_name, + region_name, + city_name, + geo_checked_at, + geo_error, )) } "account/proxy/clear" => { diff --git a/crates/service/src/usage/tests/usage_http_tests.rs b/crates/service/src/usage/tests/usage_http_tests.rs index f48f89c04..cd477c2ac 100644 --- a/crates/service/src/usage/tests/usage_http_tests.rs +++ b/crates/service/src/usage/tests/usage_http_tests.rs @@ -116,6 +116,34 @@ fn seed_account_proxy(db_path: &PathBuf, account_id: &str, enabled: bool, proxy_ None, None, None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, ) .expect("seed account proxy"); crate::gateway::invalidate_account_proxy_cache(account_id); From bdf143dd418beec58e3d8d9cbec2e2304f7d6c2a Mon Sep 17 00:00:00 2001 From: baltic-tea Date: Thu, 11 Jun 2026 17:54:15 +0300 Subject: [PATCH 6/9] feat(frontend): integrate proxy geolocation display and country flags --- .../accounts/account-proxy-cell.tsx | 141 +++++++++++++++ .../accounts/account-proxy-status-grid.tsx | 151 ++++++++++++++++ apps/src/lib/api/account-proxy-normalize.ts | 86 +++++++++ apps/src/lib/api/account-proxy-settings.ts | 105 +++++++++++ apps/src/lib/utils/proxy-geo.ts | 169 ++++++++++++++++++ apps/src/types/account.ts | 26 +++ apps/tests/account-proxy-geo.test.mjs | 81 +++++++++ 7 files changed, 759 insertions(+) create mode 100644 apps/src/components/accounts/account-proxy-cell.tsx create mode 100644 apps/src/components/accounts/account-proxy-status-grid.tsx create mode 100644 apps/src/lib/api/account-proxy-normalize.ts create mode 100644 apps/src/lib/api/account-proxy-settings.ts create mode 100644 apps/src/lib/utils/proxy-geo.ts create mode 100644 apps/tests/account-proxy-geo.test.mjs diff --git a/apps/src/components/accounts/account-proxy-cell.tsx b/apps/src/components/accounts/account-proxy-cell.tsx new file mode 100644 index 000000000..b9c938055 --- /dev/null +++ b/apps/src/components/accounts/account-proxy-cell.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useState } from "react"; +import type { Account } from "@/types"; +import { cn } from "@/lib/utils"; +import { useI18n } from "@/lib/i18n/provider"; +import { + formatProxyGeoCountryLabel, + formatProxyGeoTooltip, + resolveProxyFlagDisplay, +} from "@/lib/utils/proxy-geo"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +export function ProxyFlag({ + countryCode, + flagEmoji, + flagImgUrl, + className, +}: { + countryCode?: string | null; + flagEmoji?: string | null; + flagImgUrl?: string | null; + className?: string; +}) { + const [hasError, setHasError] = useState(false); + + if (flagImgUrl && !hasError) { + return ( + {countryCode setHasError(true)} + /> + ); + } + + const display = resolveProxyFlagDisplay(countryCode, flagEmoji); + return {display}; +} + +export function ProxyCountryFlag({ + countryCode, + countryName, + flagEmoji, + flagImgUrl, + className, +}: { + countryCode?: string | null; + countryName?: string | null; + flagEmoji?: string | null; + flagImgUrl?: string | null; + className?: string; +}) { + const { t } = useI18n(); + const label = formatProxyGeoCountryLabel(countryCode, countryName, t); + + return ( + + } + className={cn("cursor-help", className)} + > + + + {label} + + ); +} + +function formatProxyUrlHost(urlStr?: string | null): string { + if (!urlStr) return ""; + try { + const withoutProtocol = urlStr.replace(/^(https?:\/\/|socks[45][ah]?:\/\/)/i, ""); + return withoutProtocol; + } catch { + return urlStr || ""; + } +} + +export function AccountProxyCell({ account }: { account: Account }) { + const { t } = useI18n(); + const enabled = account.proxyEnabled === true; + const ip = String(account.proxyIp || "").trim(); + const countryCode = account.proxyCountryCode || null; + const countryName = account.proxyCountryName || null; + const cityName = account.proxyCityName || null; + const regionName = account.proxyRegionName || null; + const flagEmoji = account.proxyFlagEmoji || null; + const flagImgUrl = account.proxyFlagImgUrl || null; + + if (!enabled) { + return ; + } + + const displayIp = ip || formatProxyUrlHost(account.proxyUrl); + + if (!displayIp) { + return ; + } + + return ( + + } className="min-w-0 cursor-help"> +
+ + + {displayIp} + +
+
+ + {formatProxyGeoTooltip( + { + ip: displayIp, + countryCode, + countryName, + regionName, + cityName, + asn: account.proxyAsn, + asOrg: account.proxyAsOrg, + isp: account.proxyIsp, + asDomain: account.proxyAsDomain, + timezoneId: account.proxyTimezoneId, + timezoneUtc: account.proxyTimezoneUtc, + }, + t, + )} + +
+ ); +} diff --git a/apps/src/components/accounts/account-proxy-status-grid.tsx b/apps/src/components/accounts/account-proxy-status-grid.tsx new file mode 100644 index 000000000..b2039b411 --- /dev/null +++ b/apps/src/components/accounts/account-proxy-status-grid.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { + formatProxyGeoCountryLabel, + formatProxyAsn, + formatProxyTimezone, +} from "@/lib/utils/proxy-geo"; +import { ProxyFlag } from "./account-proxy-cell"; + +export interface AccountProxyGeoStatus { + ip?: string | null; + countryCode?: string | null; + countryName?: string | null; + regionName?: string | null; + cityName?: string | null; + geoError?: string | null; + + asn?: number | null; + asOrg?: string | null; + isp?: string | null; + asDomain?: string | null; + timezoneId?: string | null; + timezoneUtc?: string | null; + flagEmoji?: string | null; + flagImgUrl?: string | null; +} + +export function AccountProxyGeoStatusGrid({ + geo, + t, +}: { + geo: AccountProxyGeoStatus | null | undefined; + t: (key: string) => string; +}) { + const countryCode = geo?.countryCode || null; + const flagEmoji = geo?.flagEmoji || null; + const flagImgUrl = geo?.flagImgUrl || null; + + const asnLabel = formatProxyAsn(geo?.asn); + const timezoneLabel = formatProxyTimezone(geo?.timezoneId, geo?.timezoneUtc); + + const provider = geo?.asOrg || geo?.isp || "--"; + const isp = geo?.isp || geo?.asOrg || "--"; + const providerDomain = geo?.asDomain || "--"; + + return ( +
+
+ {/* Столбец 1 */} +
+
+
+ {t("IP")} +
+
+ {geo?.ip || "--"} +
+
+
+
+ {t("国家")} +
+
+ + + {geo?.countryName || geo?.countryCode + ? formatProxyGeoCountryLabel(geo?.countryCode, geo?.countryName) + : "--"} + +
+
+
+
+ {t("地区")} +
+
+ {geo?.regionName || "--"} +
+
+
+
+ {t("城市")} +
+
+ {geo?.cityName || "--"} +
+
+
+
+ {t("Timezone")} +
+
+ {timezoneLabel || "--"} +
+
+
+ + {/* Столбец 2 */} +
+
+
+ ASN +
+
+ {asnLabel || "--"} +
+
+
+
+ {t("Provider")} +
+
+ {provider} +
+
+
+
+ ISP +
+
+ {isp} +
+
+
+
+ {t("Provider domain")} +
+
+ {providerDomain} +
+
+
+
+ + {geo?.geoError ? ( +
+
+ {t("地理位置错误")} +
+
+ {geo.geoError} +
+
+ ) : null} +
+ ); +} diff --git a/apps/src/lib/api/account-proxy-normalize.ts b/apps/src/lib/api/account-proxy-normalize.ts new file mode 100644 index 000000000..dddf17ecc --- /dev/null +++ b/apps/src/lib/api/account-proxy-normalize.ts @@ -0,0 +1,86 @@ +import type { Account } from "@/types"; +import { normalizeCountryCode } from "@/lib/utils/proxy-geo"; + +export type AccountProxySummaryFields = Pick< + Account, + | "proxyEnabled" + | "proxyStatus" + | "proxyUrl" + | "proxyIp" + | "proxyCountryCode" + | "proxyCountryName" + | "proxyRegionName" + | "proxyCityName" + | "proxyGeoCheckedAt" + | "proxyAsn" + | "proxyAsOrg" + | "proxyIsp" + | "proxyAsDomain" + | "proxyTimezoneId" + | "proxyTimezoneUtc" + | "proxyFlagImgUrl" + | "proxyFlagEmoji" +>; + +function asString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function toNullableNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function toNullableBoolean(value: unknown): boolean | null { + if (typeof value === "boolean") return value; + if (typeof value === "number" && Number.isFinite(value)) return value !== 0; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) return true; + if (["0", "false", "no", "off"].includes(normalized)) return false; + } + return null; +} + +export function normalizeAccountProxySummaryFields( + source: Record, +): AccountProxySummaryFields { + return { + proxyEnabled: toNullableBoolean(source.proxyEnabled ?? source.proxy_enabled), + proxyStatus: asString(source.proxyStatus ?? source.proxy_status) || null, + proxyUrl: asString(source.proxyUrl ?? source.proxy_url) || null, + proxyIp: asString(source.proxyIp ?? source.proxy_ip) || null, + proxyCountryCode: + normalizeCountryCode( + asString(source.proxyCountryCode ?? source.proxy_country_code), + ) || null, + proxyCountryName: + asString(source.proxyCountryName ?? source.proxy_country_name) || null, + proxyRegionName: + asString(source.proxyRegionName ?? source.proxy_region_name) || null, + proxyCityName: + asString(source.proxyCityName ?? source.proxy_city_name) || null, + proxyGeoCheckedAt: toNullableNumber( + source.proxyGeoCheckedAt ?? source.proxy_geo_checked_at, + ), + proxyAsn: toNullableNumber(source.proxyAsn ?? source.proxy_asn), + proxyAsOrg: + asString(source.proxyAsOrg ?? source.proxy_as_org) || null, + proxyIsp: + asString(source.proxyIsp ?? source.proxy_isp) || null, + proxyAsDomain: + asString(source.proxyAsDomain ?? source.proxy_as_domain) || null, + proxyTimezoneId: + asString(source.proxyTimezoneId ?? source.proxy_timezone_id) || null, + proxyTimezoneUtc: + asString(source.proxyTimezoneUtc ?? source.proxy_timezone_utc) || null, + proxyFlagImgUrl: + asString(source.proxyFlagImgUrl ?? source.proxy_flag_img_url) || null, + proxyFlagEmoji: + asString(source.proxyFlagEmoji ?? source.proxy_flag_emoji) || null, + }; +} diff --git a/apps/src/lib/api/account-proxy-settings.ts b/apps/src/lib/api/account-proxy-settings.ts new file mode 100644 index 000000000..890b8414a --- /dev/null +++ b/apps/src/lib/api/account-proxy-settings.ts @@ -0,0 +1,105 @@ +import type { ProxyGeoLike } from "@/lib/utils/proxy-geo"; + +export interface AccountProxySettings extends ProxyGeoLike { + accountId: string; + enabled: boolean; + proxyUrl: string; + status: string; + latencyMs: number | null; + lastCheckAt: number | null; + lastError: string | null; +} + +export interface AccountProxySetPayload { + accountId: string; + enabled: boolean; + proxyUrl?: string | null; + status?: string | null; + latencyMs?: number | null; + lastError?: string | null; + ip?: string | null; + countryCode?: string | null; + countryName?: string | null; + regionName?: string | null; + cityName?: string | null; + geoCheckedAt?: number | null; + geoError?: string | null; +} + +export interface AccountProxyTestPayload { + accountId: string; + enabled?: boolean; + proxyUrl?: string | null; +} + +function readNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + + return null; +} + +function readString(value: unknown): string { + return typeof value === "string" ? value : value == null ? "" : String(value); +} + +function readNullableString(value: unknown): string | null { + const text = readString(value).trim(); + return text ? text : null; +} + +function readNullableBoolean(value: unknown): boolean | null { + if (typeof value === "boolean") return value; + if (typeof value === "number" && Number.isFinite(value)) return value !== 0; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) return true; + if (["0", "false", "no", "off"].includes(normalized)) return false; + } + return null; +} + +export function readAccountProxySettings(payload: unknown): AccountProxySettings { + const source = + payload && typeof payload === "object" + ? (payload as Record) + : {}; + + return { + accountId: readString(source.accountId ?? source.account_id), + enabled: Boolean(source.enabled), + proxyUrl: readString(source.proxyUrl ?? source.proxy_url), + status: readString(source.status || "not_configured"), + latencyMs: readNumber(source.latencyMs ?? source.latency_ms), + lastCheckAt: readNumber(source.lastCheckAt ?? source.last_check_at), + lastError: + source.lastError == null && source.last_error == null + ? null + : readString(source.lastError ?? source.last_error), + ip: readNullableString(source.ip), + countryCode: readNullableString(source.countryCode ?? source.country_code), + countryName: readNullableString(source.countryName ?? source.country_name), + regionName: readNullableString(source.regionName ?? source.region_name), + cityName: readNullableString(source.cityName ?? source.city_name), + geoCheckedAt: readNumber(source.geoCheckedAt ?? source.geo_checked_at), + geoError: readNullableString(source.geoError ?? source.geo_error), + + asn: readNumber(source.asn), + asOrg: readNullableString(source.asOrg ?? source.as_org), + isp: readNullableString(source.isp), + asDomain: readNullableString(source.asDomain ?? source.as_domain), + + timezoneId: readNullableString(source.timezoneId ?? source.timezone_id), + timezoneOffset: readNumber(source.timezoneOffset ?? source.timezone_offset), + timezoneUtc: readNullableString(source.timezoneUtc ?? source.timezone_utc), + + flagImgUrl: readNullableString(source.flagImgUrl ?? source.flag_img_url), + flagEmoji: readNullableString(source.flagEmoji ?? source.flag_emoji), + }; +} diff --git a/apps/src/lib/utils/proxy-geo.ts b/apps/src/lib/utils/proxy-geo.ts new file mode 100644 index 000000000..1422dee93 --- /dev/null +++ b/apps/src/lib/utils/proxy-geo.ts @@ -0,0 +1,169 @@ +export interface ProxyGeoLike { + ip?: string | null; + countryCode?: string | null; + countryName?: string | null; + regionName?: string | null; + cityName?: string | null; + geoCheckedAt?: number | null; + geoError?: string | null; + + asn?: number | null; + asOrg?: string | null; + isp?: string | null; + asDomain?: string | null; + + timezoneId?: string | null; + timezoneOffset?: number | null; + timezoneUtc?: string | null; + + flagImgUrl?: string | null; + flagEmoji?: string | null; +} + +export function normalizeCountryCode(code?: string | null): string | null { + const normalized = String(code || "") + .trim() + .toUpperCase(); + return /^[A-Z]{2}$/.test(normalized) ? normalized : null; +} + +export function countryCodeToFlag(code?: string | null): string { + const normalized = normalizeCountryCode(code); + + if (!normalized) { + return "🌐"; + } + + return normalized + .split("") + .map((char) => String.fromCodePoint(127397 + char.charCodeAt(0))) + .join(""); +} + +export function formatProxyGeoCountryLabel( + countryCode?: string | null, + countryName?: string | null, + t?: (key: string) => string +): string { + const code = normalizeCountryCode(countryCode); + const name = String(countryName || "").trim(); + + if (name && code) return `${name} (${code})`; + if (name) return name; + if (code) return code; + return t ? t("未知") : "Unknown"; +} + +export function formatProxyGeoLocationParts( + geo: ProxyGeoLike, + t?: (key: string) => string +): string[] { + const parts = [ + String(geo.cityName || "").trim(), + String(geo.regionName || "").trim(), + formatProxyGeoCountryLabel(geo.countryCode, geo.countryName, t) + ].filter(Boolean); + + return Array.from(new Set(parts)); +} + +export function formatProxyAsn(asn?: number | null): string | null { + return typeof asn === "number" && Number.isFinite(asn) ? `AS${asn}` : null; +} + +export function formatProxyProvider( + isp?: string | null, + asOrg?: string | null, + asDomain?: string | null +): string | null { + const provider = String(isp || asOrg || "").trim(); + const domain = String(asDomain || "").trim(); + + if (provider && domain) return `${provider} (${domain})`; + if (provider) return provider; + if (domain) return domain; + return null; +} + +export function formatProxyTimezone( + timezoneId?: string | null, + timezoneUtc?: string | null +): string | null { + const id = String(timezoneId || "").trim(); + const utc = String(timezoneUtc || "").trim(); + + if (id && utc) return `${id} (${utc})`; + if (id) return id; + if (utc) return `UTC ${utc}`; + return null; +} + +export function resolveProxyFlagDisplay( + countryCode?: string | null, + flagEmoji?: string | null +): string { + const emoji = String(flagEmoji || "").trim(); + if (emoji) return emoji; + return countryCodeToFlag(countryCode); +} + +export function formatProxyGeoTooltip( + geo: ProxyGeoLike, + t?: (key: string) => string +): string { + const localT = t || ((key: string) => key); + const lines: string[] = []; + + if (geo.ip) { + lines.push(`${localT("IP")}: ${geo.ip}`); + } + + const countryLabel = formatProxyGeoCountryLabel( + geo.countryCode, + geo.countryName, + t + ); + if (countryLabel && countryLabel !== (t ? t("未知") : "Unknown")) { + lines.push(`${localT("国家")}: ${countryLabel}`); + } + + const city = String(geo.cityName || "").trim(); + const region = String(geo.regionName || "").trim(); + if (city && region) { + lines.push(`${localT("城市")}: ${city} (${region})`); + } else if (city) { + lines.push(`${localT("城市")}: ${city}`); + } else if (region) { + lines.push(`${localT("地区")}: ${region}`); + } + + const asnLabel = formatProxyAsn(geo.asn); + if (asnLabel) { + lines.push(`ASN: ${asnLabel}`); + } + + const providerLabel = formatProxyProvider(geo.isp, geo.asOrg, geo.asDomain); + if (providerLabel) { + lines.push(`Provider / ISP: ${providerLabel}`); + } + + const timezoneLabel = formatProxyTimezone(geo.timezoneId, geo.timezoneUtc); + if (timezoneLabel) { + lines.push(`Timezone: ${timezoneLabel}`); + } + + if (geo.geoError) { + lines.push(`${localT("地理位置错误")}: ${geo.geoError}`); + } + + return lines.length > 0 ? lines.join("\n") : localT("未知代理位置"); +} + +export function hasProxyGeo(geo: ProxyGeoLike): boolean { + return Boolean( + String(geo.ip || "").trim() || + normalizeCountryCode(geo.countryCode) || + String(geo.countryName || "").trim() || + String(geo.cityName || "").trim() + ); +} diff --git a/apps/src/types/account.ts b/apps/src/types/account.ts index 763aad59b..a96bda745 100644 --- a/apps/src/types/account.ts +++ b/apps/src/types/account.ts @@ -36,6 +36,32 @@ export interface Account { modelSlugs: string[]; quotaCapacityPrimaryWindowTokens: number | null; quotaCapacitySecondaryWindowTokens: number | null; + + /** + * Per-account proxy summary returned by account/list. + * + * These fields are optional during the staged rollout because normalizeAccount + * and account/list are wired in a later iteration. Treat missing values as + * "not configured" in UI components. + */ + proxyEnabled?: boolean | null; + proxyStatus?: string | null; + proxyUrl?: string | null; + proxyIp?: string | null; + proxyCountryCode?: string | null; + proxyCountryName?: string | null; + proxyRegionName?: string | null; + proxyCityName?: string | null; + proxyGeoCheckedAt?: number | null; + proxyAsn?: number | null; + proxyAsOrg?: string | null; + proxyIsp?: string | null; + proxyAsDomain?: string | null; + proxyTimezoneId?: string | null; + proxyTimezoneUtc?: string | null; + proxyFlagImgUrl?: string | null; + proxyFlagEmoji?: string | null; + isAvailable: boolean; isLowQuota: boolean; lastRefreshAt: number | null; diff --git a/apps/tests/account-proxy-geo.test.mjs b/apps/tests/account-proxy-geo.test.mjs new file mode 100644 index 000000000..fa1153608 --- /dev/null +++ b/apps/tests/account-proxy-geo.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { pathToFileURL } from "node:url"; +import ts from "../node_modules/typescript/lib/typescript.js"; + +const appsRoot = path.resolve(import.meta.dirname, ".."); + +async function loadBundledModules() { + const normalizePath = path.join( + appsRoot, + "src", + "lib", + "api", + "account-proxy-normalize.ts", + ); + const utilsPath = path.join(appsRoot, "src", "lib", "utils", "proxy-geo.ts"); + + const normalizeSource = await fs.readFile(normalizePath, "utf8"); + const utilsSource = await fs.readFile(utilsPath, "utf8"); + + // Combine sources and remove imports + let combinedSource = utilsSource + "\n" + normalizeSource; + combinedSource = combinedSource.replace(/import {.*?\} from ".*?";/g, ""); + + const compiled = ts.transpileModule(combinedSource, { + compilerOptions: { + module: ts.ModuleKind.ES2022, + target: ts.ScriptTarget.ES2022, + }, + }); + + const tempFile = path.join(appsRoot, "tests", ".temp-bundle.mjs"); + await fs.writeFile(tempFile, compiled.outputText, "utf8"); + const mod = await import(pathToFileURL(tempFile).href); + await fs.unlink(tempFile); + return mod; +} + +const mod = await loadBundledModules(); + +test("normalizeAccountProxySummaryFields 映射 snake_case 字段到 camelCase", () => { + const source = { + proxy_enabled: true, + proxy_status: "ok", + proxy_ip: "1.1.1.1", + proxy_country_code: "us", + proxy_country_name: "United States", + proxy_region_name: "California", + proxy_city_name: "Los Angeles", + proxy_geo_checked_at: 123456789, + }; + + const result = mod.normalizeAccountProxySummaryFields(source); + + assert.equal(result.proxyEnabled, true); + assert.equal(result.proxyStatus, "ok"); + assert.equal(result.proxyIp, "1.1.1.1"); + assert.equal(result.proxyCountryCode, "US"); // Should be normalized to upper + assert.equal(result.proxyCountryName, "United States"); + assert.equal(result.proxyRegionName, "California"); + assert.equal(result.proxyCityName, "Los Angeles"); + assert.equal(result.proxyGeoCheckedAt, 123456789); +}); + +test("countryCodeToFlag 转换 ISO 2 代码为 Emoji 旗帜", () => { + assert.equal(mod.countryCodeToFlag("US"), "🇺🇸"); + assert.equal(mod.countryCodeToFlag("cn"), "🇨🇳"); + assert.equal(mod.countryCodeToFlag(null), "🌐"); +}); + +test("formatProxyGeoCountryLabel 格式化国家显示", () => { + assert.equal( + mod.formatProxyGeoCountryLabel("US", "United States"), + "United States (US)", + ); + assert.equal(mod.formatProxyGeoCountryLabel("US"), "US"); + assert.equal(mod.formatProxyGeoCountryLabel(null, "Just Name"), "Just Name"); + assert.equal(mod.formatProxyGeoCountryLabel(), "Unknown"); +}); From 559b45c626855e16b55b5ad9f4a1ab52ce627a8a Mon Sep 17 00:00:00 2001 From: baltic-tea Date: Thu, 11 Jun 2026 17:54:24 +0300 Subject: [PATCH 7/9] feat(accounts): improve UI/UX styling for quota limits and account proxy settings --- .../app/accounts/accounts-page-helpers.tsx | 1444 ++++---- apps/src/app/accounts/accounts-page-view.tsx | 3044 +++++++++-------- apps/src/app/accounts/page.tsx | 15 + apps/src/app/author/page.tsx | 18 +- apps/src/hooks/useAccounts.ts | 1846 +++++----- apps/src/lib/api/account-client.ts | 1782 +++++----- apps/src/lib/api/normalize.ts | 2421 +++++++------ apps/src/lib/i18n/messages/ru.ts | 2 +- .../lib/i18n/messages/sections/en-accounts.ts | 257 +- .../lib/i18n/messages/sections/ko-accounts.ts | 258 +- .../lib/i18n/messages/sections/ru-accounts.ts | 22 +- apps/src/lib/utils/usage.ts | 41 +- apps/tests/account-list-cache.test.mjs | 4 +- apps/tests/codex-profile-cache.test.mjs | 12 +- crates/core/tests/storage.rs | 2 + .../core/tests/runtime_config_tests.rs | 36 + .../src/usage/tests/usage_http_tests.rs | 12 - 17 files changed, 5844 insertions(+), 5372 deletions(-) diff --git a/apps/src/app/accounts/accounts-page-helpers.tsx b/apps/src/app/accounts/accounts-page-helpers.tsx index 7249682e3..631f6ae37 100644 --- a/apps/src/app/accounts/accounts-page-helpers.tsx +++ b/apps/src/app/accounts/accounts-page-helpers.tsx @@ -5,826 +5,838 @@ import { Power, PowerOff, RefreshCw, Zap } from "lucide-react"; import { useI18n } from "@/lib/i18n/provider"; import { cn } from "@/lib/utils"; import { - formatRemainingDurationFromSeconds, - formatTsFromSeconds, - getExtraUsageDisplayRows, - getUsageDisplayBuckets, - isPrimaryWindowOnlyUsage, - isSecondaryWindowOnlyUsage, + formatRemainingDurationFromSeconds, + formatTsFromSeconds, + getExtraUsageDisplayRows, + getUsageDisplayBuckets, + isPrimaryWindowOnlyUsage, + isSecondaryWindowOnlyUsage, } from "@/lib/utils/usage"; import { Badge } from "@/components/ui/badge"; import { Progress } from "@/components/ui/progress"; import { - Tooltip, - TooltipContent, - TooltipTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, } from "@/components/ui/tooltip"; import type { Account } from "@/types"; -export type StatusFilter = "all" | "available" | "low_quota" | "limited" | "banned"; +export type StatusFilter = + | "all" + | "available" + | "low_quota" + | "limited" + | "banned"; export type AccountExportMode = "single" | "multiple"; export type AccountSizeSortMode = "large-first" | "small-first"; const ACCOUNT_SORT_STEP = 5; export function fitLongTextClassName( - value: string, - baseClassName: string, - defaultSizeClassName: string, + value: string, + baseClassName: string, + defaultSizeClassName: string, ): string { - const length = Array.from(String(value || "")).length; - if (length > 96) return cn(baseClassName, "text-[8px] leading-tight"); - if (length > 72) return cn(baseClassName, "text-[9px] leading-snug"); - if (length > 40) return cn(baseClassName, "text-[10px] leading-snug"); - if (length > 24) return cn(baseClassName, "text-[11px] leading-snug"); - return cn(baseClassName, defaultSizeClassName); + const length = Array.from(String(value || "")).length; + if (length > 96) return cn(baseClassName, "text-[8px] leading-tight"); + if (length > 72) return cn(baseClassName, "text-[9px] leading-snug"); + if (length > 40) return cn(baseClassName, "text-[10px] leading-snug"); + if (length > 24) return cn(baseClassName, "text-[11px] leading-snug"); + return cn(baseClassName, defaultSizeClassName); } export type TranslateFn = ( - key: string, - values?: Record, + key: string, + values?: Record, ) => string; export function formatAccountPlanValueLabel(value: string, t: TranslateFn) { - const normalized = String(value || "") - .trim() - .toLowerCase(); - switch (normalized) { - case "free": - return "FREE"; - case "go": - return "GO"; - case "plus": - return "PLUS"; - case "pro": - return "PRO"; - case "team": - return "TEAM"; - case "business": - return "BUSINESS"; - case "enterprise": - return "ENTERPRISE"; - case "edu": - return "EDU"; - case "unknown": - return t("未知"); - default: - return normalized ? normalized.toUpperCase() : t("未知"); - } + const normalized = String(value || "") + .trim() + .toLowerCase(); + switch (normalized) { + case "free": + return "FREE"; + case "go": + return "GO"; + case "plus": + return "PLUS"; + case "pro": + return "PRO"; + case "team": + return "TEAM"; + case "business": + return "BUSINESS"; + case "enterprise": + return "ENTERPRISE"; + case "edu": + return "EDU"; + case "unknown": + return t("未知"); + default: + return normalized ? normalized.toUpperCase() : t("未知"); + } } export function normalizeAccountPlanKey(account: Account) { - return ( - String(account.planType || "") - .trim() - .toLowerCase() || "unknown" - ); + return ( + String(account.planType || "") + .trim() + .toLowerCase() || "unknown" + ); } export function formatPlanFilterLabel(value: string, t: TranslateFn) { - const nextValue = String(value || "").trim(); - if (!nextValue || nextValue === "all") { - return t("全部类型"); - } - return formatAccountPlanValueLabel(nextValue, t); + const nextValue = String(value || "").trim(); + if (!nextValue || nextValue === "all") { + return t("全部类型"); + } + return formatAccountPlanValueLabel(nextValue, t); } export function formatStatusFilterLabel(value: string, t: TranslateFn) { - const nextValue = String(value || "").trim(); - switch (nextValue) { - case "available": - return t("可用"); - case "low_quota": - return t("低配额"); - case "limited": - return t("限流"); - case "banned": - return t("封禁"); - case "all": - default: - return t("全部"); - } + const nextValue = String(value || "").trim(); + switch (nextValue) { + case "available": + return t("可用"); + case "low_quota": + return t("低配额"); + case "limited": + return t("限流"); + case "banned": + return t("封禁"); + case "all": + default: + return t("全部"); + } } export interface QuotaProgressProps { - label: string; - remainPercent: number | null; - resetsAt: number | null; - icon: LucideIcon; - tone: "green" | "blue" | "amber"; - caption?: string; - emptyText?: string; - emptyResetText?: string; + label: string; + remainPercent: number | null; + resetsAt: number | null; + icon: LucideIcon; + tone: "green" | "blue" | "amber"; + caption?: string; + emptyText?: string; + emptyResetText?: string; } export interface QuotaSummaryItem extends QuotaProgressProps { - id: string; + id: string; } export interface AccountEditorState { - accountId: string; - accountName: string; - currentLabel: string; - currentTags: string; - currentNote: string; - currentSort: number; - currentModelSlugs: string; - currentQuotaPrimaryWindowTokens: number | null; - currentQuotaSecondaryWindowTokens: number | null; + accountId: string; + accountName: string; + currentLabel: string; + currentTags: string; + currentNote: string; + currentSort: number; + currentModelSlugs: string; + currentQuotaPrimaryWindowTokens: number | null; + currentQuotaSecondaryWindowTokens: number | null; } export type DeleteDialogState = - | { kind: "single"; account: Account } - | { kind: "selected"; ids: string[]; count: number } - | null; + | { kind: "single"; account: Account } + | { kind: "selected"; ids: string[]; count: number } + | null; + +export const QUOTA_TONE_CLASSES = { + blue: { + track: "bg-blue-500/20", + indicator: "bg-blue-500", + icon: "text-blue-500", + }, + green: { + track: "bg-green-500/20", + indicator: "bg-green-500", + icon: "text-green-500", + }, + amber: { + track: "bg-amber-500/20", + indicator: "bg-amber-500", + icon: "text-amber-500", + }, +} as const; + function QuotaProgress({ - label, - remainPercent, - resetsAt, - icon: Icon, - tone, - caption, - emptyText = "--", - emptyResetText = "未知", + label, + remainPercent, + resetsAt, + icon: Icon, + tone, + caption, + emptyText = "--", + emptyResetText = "未知", }: QuotaProgressProps) { - const { t } = useI18n(); - const value = remainPercent ?? 0; - const toneClasses = { - blue: { - track: "bg-blue-500/20", - indicator: "bg-blue-500", - icon: "text-blue-500", - }, - green: { - track: "bg-green-500/20", - indicator: "bg-green-500", - icon: "text-green-500", - }, - amber: { - track: "bg-amber-500/20", - indicator: "bg-amber-500", - icon: "text-amber-500", - }, - } as const; - const palette = toneClasses[tone]; - - return ( -
-
-
-
- - {label} -
- {caption ? ( -
- {caption} -
- ) : null} -
- - {remainPercent == null ? emptyText : `${value}%`} - -
- -
- {t("重置")}: {formatTsFromSeconds(resetsAt, emptyResetText)} -
-
- ); + const { t } = useI18n(); + const value = remainPercent ?? 0; + const centerText = caption ?? label; + const showLabelOnRight = Boolean(caption); + const palette = QUOTA_TONE_CLASSES[tone]; + + return ( +
+
+
+ + {caption ? ( + + {label} + + ) : null} + + {caption ?? label} + +
+
+ {remainPercent == null ? emptyText : `${value}%`} +
+
+ +
+ {t("重置")}: {formatTsFromSeconds(resetsAt, emptyResetText)} +
+
+ ); } export function QuotaOverviewCell({ items }: { items: QuotaSummaryItem[] }) { - const { t } = useI18n(); - const summaryItems = items.slice(0, 2); - - return ( - - } className="block min-w-0 cursor-help"> -
-
- {summaryItems.map((item) => ( -
-
- - {item.label} - - - {item.remainPercent == null - ? (item.emptyText ?? "--") - : `${item.remainPercent}%`} - -
- -
- ))} -
-
- {summaryItems.map((item) => ( -
- - {formatTsFromSeconds( - item.resetsAt, - item.emptyResetText ?? t("未知"), - )} - - - {formatRemainingDurationFromSeconds( - item.resetsAt, - item.id.endsWith("-primary") ? "hours" : "days", - item.emptyResetText ?? t("未知"), - )} - {t("后刷新")} - -
- ))} -
-
-
- -
-
-

- {t("额度详情(悬停查看所有额度)")} -

-

- {t("标准额度与专属额度统一在这里查看。")} -

-
-
- {items.map((item) => ( - - ))} -
-
-
-
- ); + const { locale, t } = useI18n(); + const summaryItems = items.slice(0, 2); + + return ( + + } className="block min-w-0 cursor-help"> +
+
+ {summaryItems.map((item) => { + const palette = QUOTA_TONE_CLASSES[item.tone]; + return ( +
+
+
+ {item.icon && ( + + )} + {item.caption ? ( + + {item.label} + + ) : null} + + {item.caption ?? item.label} + +
+
+ + {item.remainPercent == null + ? (item.emptyText ?? "--") + : `${item.remainPercent}%`} + +
+
+ +
+ + {t("重置")}: {formatTsFromSeconds( + item.resetsAt, + item.emptyResetText ?? t("未知"), + )} + + + {formatRemainingDurationFromSeconds( + item.resetsAt, + item.id.endsWith("-primary") ? "hours" : "days", + item.emptyResetText ?? t("未知"), + locale, + )} + +
+
+ ); + })} +
+
+
+ +
+
+

+ {t("额度详情(悬停查看所有额度)")} +

+

+ {t("标准额度与专属额度统一在这里查看。")} +

+
+
+ {items.map((item) => ( + + ))} +
+
+
+
+ ); } export function getAccountStatusAction( - account: Account, - t: TranslateFn, + account: Account, + t: TranslateFn, ): { - action: "enable" | "disable" | null; - label: string; - icon: LucideIcon; + action: "enable" | "disable" | null; + label: string; + icon: LucideIcon; } { - const normalizedStatus = String(account.status || "") - .trim() - .toLowerCase(); - if (normalizedStatus === "disabled") { - return { action: "enable", label: t("启用账号"), icon: Power }; - } - if (normalizedStatus === "inactive") { - return { action: "enable", label: t("恢复账号"), icon: Power }; - } - if (normalizedStatus === "banned") { - return { action: null, label: t("封禁账号"), icon: PowerOff }; - } - return { action: "disable", label: t("禁用账号"), icon: PowerOff }; + const normalizedStatus = String(account.status || "") + .trim() + .toLowerCase(); + if (normalizedStatus === "disabled") { + return { action: "enable", label: t("启用账号"), icon: Power }; + } + if (normalizedStatus === "inactive") { + return { action: "enable", label: t("恢复账号"), icon: Power }; + } + if (normalizedStatus === "banned") { + return { action: null, label: t("封禁账号"), icon: PowerOff }; + } + return { action: "disable", label: t("禁用账号"), icon: PowerOff }; } export function getAccountStatusReasonCode(account: Account): string { - const reason = String(account.statusReason || "").trim(); - return reason.toLowerCase() === "usage_ok" ? "" : reason; + const reason = String(account.statusReason || "").trim(); + return reason.toLowerCase() === "usage_ok" ? "" : reason; } export function formatAccountStatusReasonLabel( - account: Account, - t: TranslateFn, + account: Account, + t: TranslateFn, ): string | null { - const reasonCode = getAccountStatusReasonCode(account); - if (!reasonCode) { - return null; - } - - const reason = reasonCode.toLowerCase(); - if (reason.startsWith("refresh_token_invalid:")) { - const detail = reason.slice("refresh_token_invalid:".length); - switch (detail) { - case "refresh_token_reused": - return t("Refresh Token 已被重复使用,需要重新登录"); - case "refresh_token_invalidated": - return t("Refresh Token 已被撤销,需要重新登录"); - case "refresh_token_expired": - return t("Refresh Token 已过期,需要重新登录"); - case "invalid_grant": - return t("Refresh Token 授权无效,需要重新登录"); - case "refresh_token_unknown_401": - return t("刷新登录凭证返回 401,需要重新登录"); - default: - return t("Refresh Token 失效,需要重新登录"); - } - } - if (reason === "refresh_token_region_blocked") { - return t("代理地区不受支持,已暂停账号刷新"); - } - - const usageHttpStatus = reason.match(/^usage_http_(\d{3})$/); - if (usageHttpStatus) { - const statusCode = usageHttpStatus[1]; - if (statusCode === "401") { - return t("用量接口返回 401,账号授权失效"); - } - if (statusCode === "403") { - return t("用量接口返回 403,账号权限不足或被限制"); - } - return t("用量接口返回 HTTP {status}", { status: statusCode }); - } - - switch (reason) { - case "account_deactivated": - return t("账号已停用"); - case "workspace_deactivated": - case "deactivated_workspace": - return t("工作区已停用"); - case "usage_limit_exhausted": - return t("额度已耗尽"); - default: - return reasonCode; - } + const reasonCode = getAccountStatusReasonCode(account); + if (!reasonCode) { + return null; + } + + const reason = reasonCode.toLowerCase(); + if (reason.startsWith("refresh_token_invalid:")) { + const detail = reason.slice("refresh_token_invalid:".length); + switch (detail) { + case "refresh_token_reused": + return t("Refresh Token 已被重复使用,需要重新登录"); + case "refresh_token_invalidated": + return t("Refresh Token 已被撤销,需要重新登录"); + case "refresh_token_expired": + return t("Refresh Token 已过期,需要重新登录"); + case "invalid_grant": + return t("Refresh Token 授权无效,需要重新登录"); + case "refresh_token_unknown_401": + return t("刷新登录凭证返回 401,需要重新登录"); + default: + return t("Refresh Token 失效,需要重新登录"); + } + } + if (reason === "refresh_token_region_blocked") { + return t("代理地区不受支持,已暂停账号刷新"); + } + + const usageHttpStatus = reason.match(/^usage_http_(\d{3})$/); + if (usageHttpStatus) { + const statusCode = usageHttpStatus[1]; + if (statusCode === "401") { + return t("用量接口返回 401,账号授权失效"); + } + if (statusCode === "403") { + return t("用量接口返回 403,账号权限不足或被限制"); + } + return t("用量接口返回 HTTP {status}", { status: statusCode }); + } + + switch (reason) { + case "account_deactivated": + return t("账号已停用"); + case "workspace_deactivated": + case "deactivated_workspace": + return t("工作区已停用"); + case "usage_limit_exhausted": + return t("额度已耗尽"); + default: + return reasonCode; + } } export function AccountStatusCell({ account }: { account: Account }) { - const { t } = useI18n(); - const statusReasonCode = getAccountStatusReasonCode(account); - const statusReasonLabel = formatAccountStatusReasonLabel(account, t); - const statusText = t(account.availabilityText || "未知"); - - return ( - - } className="block min-w-0 cursor-help"> -
-
-
- - {statusText} - -
- {statusReasonLabel ? ( - - {statusReasonLabel} - - ) : null} -
- - -
-
-
{t("当前状态")}
-
{statusText}
-
- {statusReasonLabel ? ( -
-
{t("状态原因")}
-
{statusReasonLabel}
-
- ) : null} - {statusReasonCode ? ( -
-
{t("原因码")}
-
- {statusReasonCode} -
-
- ) : null} -
-
- - ); + const { t } = useI18n(); + const statusReasonCode = getAccountStatusReasonCode(account); + const statusReasonLabel = formatAccountStatusReasonLabel(account, t); + const statusText = t(account.availabilityText || "未知"); + + return ( + + } className="block min-w-0 cursor-help"> +
+
+
+ + {statusText} + +
+ {statusReasonLabel ? ( + + {statusReasonLabel} + + ) : null} +
+ + +
+
+
+ {t("当前状态")} +
+
{statusText}
+
+ {statusReasonLabel ? ( +
+
+ {t("状态原因")} +
+
{statusReasonLabel}
+
+ ) : null} + {statusReasonCode ? ( +
+
+ {t("原因码")} +
+
+ {statusReasonCode} +
+
+ ) : null} +
+
+ + ); } export function formatAccountPlanLabel( - account: Account, - t: TranslateFn, + account: Account, + t: TranslateFn, ): string | null { - const normalized = normalizeAccountPlanKey(account); - return normalized === "unknown" - ? null - : formatAccountPlanValueLabel(normalized, t); + const normalized = normalizeAccountPlanKey(account); + return normalized === "unknown" + ? null + : formatAccountPlanValueLabel(normalized, t); } export function formatAccountSubscriptionPlanLabel( - account: Account, - t: TranslateFn, + account: Account, + t: TranslateFn, ): string { - const normalized = String(account.subscriptionPlan || account.planType || "") - .trim() - .toLowerCase(); - return normalized - ? formatAccountPlanValueLabel(normalized, t) - : t("未知"); + const normalized = String(account.subscriptionPlan || account.planType || "") + .trim() + .toLowerCase(); + return normalized ? formatAccountPlanValueLabel(normalized, t) : t("未知"); } export function formatAccountSubscriptionStatusLabel( - account: Account, - t: TranslateFn, + account: Account, + t: TranslateFn, ): string { - const hasSubscriptionEvidence = - Boolean(String(account.subscriptionPlan || "").trim()) || - account.subscriptionExpiresAt != null || - account.subscriptionRenewsAt != null; - const nowSeconds = Math.floor(Date.now() / 1000); - const isExpired = - account.subscriptionExpiresAt != null && - account.subscriptionExpiresAt < nowSeconds && - Boolean(String(account.subscriptionPlan || account.planType || "").trim()); - - if ( - account.hasSubscription === true || - (account.hasSubscription == null && hasSubscriptionEvidence) - ) { - return t("已订阅"); - } - if (isExpired) { - return t("已过期"); - } - if (account.hasSubscription === false) { - return t("未订阅"); - } - return t("未知"); + const hasSubscriptionEvidence = + Boolean(String(account.subscriptionPlan || "").trim()) || + account.subscriptionExpiresAt != null || + account.subscriptionRenewsAt != null; + const nowSeconds = Math.floor(Date.now() / 1000); + const isExpired = + account.subscriptionExpiresAt != null && + account.subscriptionExpiresAt < nowSeconds && + Boolean(String(account.subscriptionPlan || account.planType || "").trim()); + + if ( + account.hasSubscription === true || + (account.hasSubscription == null && hasSubscriptionEvidence) + ) { + return t("已订阅"); + } + if (isExpired) { + return t("已过期"); + } + if (account.hasSubscription === false) { + return t("未订阅"); + } + return t("未知"); } export function getAccountPlanBadgeClassName(planLabel: string | null): string { - switch (planLabel) { - case "FREE": - return "bg-slate-500/10 text-slate-700 dark:text-slate-300"; - case "GO": - return "bg-sky-500/10 text-sky-700 dark:text-sky-300"; - case "PLUS": - return "bg-amber-500/10 text-amber-700 dark:text-amber-300"; - case "PRO": - return "bg-fuchsia-500/10 text-fuchsia-700 dark:text-fuchsia-300"; - case "TEAM": - return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; - case "BUSINESS": - return "bg-indigo-500/10 text-indigo-700 dark:text-indigo-300"; - case "ENTERPRISE": - return "bg-rose-500/10 text-rose-700 dark:text-rose-300"; - case "EDU": - return "bg-cyan-500/10 text-cyan-700 dark:text-cyan-300"; - default: - return "bg-accent/50"; - } + switch (planLabel) { + case "FREE": + return "bg-slate-500/10 text-slate-700 dark:text-slate-300"; + case "GO": + return "bg-sky-500/10 text-sky-700 dark:text-sky-300"; + case "PLUS": + return "bg-amber-500/10 text-amber-700 dark:text-amber-300"; + case "PRO": + return "bg-fuchsia-500/10 text-fuchsia-700 dark:text-fuchsia-300"; + case "TEAM": + return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; + case "BUSINESS": + return "bg-indigo-500/10 text-indigo-700 dark:text-indigo-300"; + case "ENTERPRISE": + return "bg-rose-500/10 text-rose-700 dark:text-rose-300"; + case "EDU": + return "bg-cyan-500/10 text-cyan-700 dark:text-cyan-300"; + default: + return "bg-accent/50"; + } } export function formatAccountTags(tags: string[]): string { - return tags - .map((tag) => String(tag || "").trim()) - .filter(Boolean) - .join("、"); + return tags + .map((tag) => String(tag || "").trim()) + .filter(Boolean) + .join("、"); } export function normalizeTagsDraft(tagsDraft: string): string[] { - return tagsDraft - .split(",") - .map((tag) => tag.trim()) - .filter(Boolean); + return tagsDraft + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean); } export function buildAccountOrderUpdates(orderedAccounts: Account[]) { - return orderedAccounts.reduce>( - (updates, account, index) => { - const nextSort = index * ACCOUNT_SORT_STEP; - const currentSort = Number.isFinite(account.priority) - ? account.priority - : Number(account.sort) || 0; - if (currentSort !== nextSort) { - updates.push({ accountId: account.id, sort: nextSort }); - } - return updates; - }, - [], - ); + return orderedAccounts.reduce>( + (updates, account, index) => { + const nextSort = index * ACCOUNT_SORT_STEP; + const currentSort = Number.isFinite(account.priority) + ? account.priority + : Number(account.sort) || 0; + if (currentSort !== nextSort) { + updates.push({ accountId: account.id, sort: nextSort }); + } + return updates; + }, + [], + ); } export function getAccountSizeGroup( - account: Account, + account: Account, ): "large" | "standard" | "small" { - switch (normalizeAccountPlanKey(account)) { - case "plus": - case "pro": - case "team": - case "business": - case "enterprise": - return "large"; - case "free": - return "small"; - default: - return "standard"; - } + switch (normalizeAccountPlanKey(account)) { + case "plus": + case "pro": + case "team": + case "business": + case "enterprise": + return "large"; + case "free": + return "small"; + default: + return "standard"; + } } export function buildAccountsBySizeOrder( - orderedAccounts: Account[], - mode: AccountSizeSortMode, + orderedAccounts: Account[], + mode: AccountSizeSortMode, ) { - const buckets = { - large: [] as Account[], - standard: [] as Account[], - small: [] as Account[], - }; - - for (const account of orderedAccounts) { - buckets[getAccountSizeGroup(account)].push(account); - } - - return mode === "large-first" - ? [...buckets.large, ...buckets.standard, ...buckets.small] - : [...buckets.small, ...buckets.standard, ...buckets.large]; + const buckets = { + large: [] as Account[], + standard: [] as Account[], + small: [] as Account[], + }; + + for (const account of orderedAccounts) { + buckets[getAccountSizeGroup(account)].push(account); + } + + return mode === "large-first" + ? [...buckets.large, ...buckets.standard, ...buckets.small] + : [...buckets.small, ...buckets.standard, ...buckets.large]; } export function formatAccountExportModeLabel(value: string, t: TranslateFn) { - return value === "single" ? t("单 JSON") : t("多 JSON"); + return value === "single" ? t("单 JSON") : t("多 JSON"); } export function buildQuotaSummaryItems( - account: Account, - t: TranslateFn, + account: Account, + t: TranslateFn, ): QuotaSummaryItem[] { - const primaryWindowOnly = isPrimaryWindowOnlyUsage(account.usage); - const secondaryWindowOnly = isSecondaryWindowOnlyUsage(account.usage); - const usageBuckets = getUsageDisplayBuckets(account.usage); - const extraUsageRows = getExtraUsageDisplayRows(account.usage); - return [ - { - id: `${account.id}-primary`, - label: t("5小时"), - remainPercent: account.primaryRemainPercent, - resetsAt: usageBuckets.primaryResetsAt, - icon: RefreshCw, - tone: "green", - caption: t("标准模型窗口"), - emptyText: secondaryWindowOnly ? t("未提供") : "--", - emptyResetText: secondaryWindowOnly ? t("未提供") : t("未知"), - }, - { - id: `${account.id}-secondary`, - label: t("7天"), - remainPercent: account.secondaryRemainPercent, - resetsAt: usageBuckets.secondaryResetsAt, - icon: RefreshCw, - tone: "blue", - caption: t("长周期窗口"), - emptyText: primaryWindowOnly ? t("未提供") : "--", - emptyResetText: primaryWindowOnly ? t("未提供") : t("未知"), - }, - ...extraUsageRows.map((item) => ({ - id: item.id, - label: `${t(item.label, item.labelValues)}${item.labelSuffix ? t(item.labelSuffix) : ""}`, - remainPercent: item.remainPercent, - resetsAt: item.resetsAt, - icon: Zap, - tone: "amber" as const, - caption: t(item.windowLabel, item.windowLabelValues), - emptyText: "--", - emptyResetText: t("未知"), - })), - ]; + const primaryWindowOnly = isPrimaryWindowOnlyUsage(account.usage); + const secondaryWindowOnly = isSecondaryWindowOnlyUsage(account.usage); + const usageBuckets = getUsageDisplayBuckets(account.usage); + const extraUsageRows = getExtraUsageDisplayRows(account.usage); + return [ + { + id: `${account.id}-primary`, + label: t("5小时"), + remainPercent: account.primaryRemainPercent, + resetsAt: usageBuckets.primaryResetsAt, + icon: RefreshCw, + tone: "green", + caption: t("标准模型窗口"), + emptyText: secondaryWindowOnly ? t("未提供") : "--", + emptyResetText: secondaryWindowOnly ? t("未提供") : t("未知"), + }, + { + id: `${account.id}-secondary`, + label: t("7天"), + remainPercent: account.secondaryRemainPercent, + resetsAt: usageBuckets.secondaryResetsAt, + icon: RefreshCw, + tone: "blue", + caption: t("长周期窗口"), + emptyText: primaryWindowOnly ? t("未提供") : "--", + emptyResetText: primaryWindowOnly ? t("未提供") : t("未知"), + }, + ...extraUsageRows.map((item) => ({ + id: item.id, + label: `${t(item.label, item.labelValues)}${item.labelSuffix ? t(item.labelSuffix) : ""}`, + remainPercent: item.remainPercent, + resetsAt: item.resetsAt, + icon: Zap, + tone: "amber" as const, + caption: t(item.windowLabel, item.windowLabelValues), + emptyText: "--", + emptyResetText: t("未知"), + })), + ]; } export function AccountInfoCell({ - account, - isPreferred, + account, + isPreferred, }: { - account: Account; - isPreferred: boolean; + account: Account; + isPreferred: boolean; }) { - const { t } = useI18n(); - const accountPlanLabel = formatAccountPlanLabel(account, t); - const subscriptionStatusLabel = formatAccountSubscriptionStatusLabel(account, t); - const subscriptionPlanLabel = formatAccountSubscriptionPlanLabel(account, t); - const subscriptionExpiryText = - account.subscriptionExpiresAt != null - ? formatTsFromSeconds(account.subscriptionExpiresAt, t("未知")) - : account.hasSubscription === false - ? t("未订阅") - : t("未知"); - const statusReasonCode = getAccountStatusReasonCode(account); - const statusReasonLabel = formatAccountStatusReasonLabel(account, t); - const tagsText = formatAccountTags(account.tags); - const noteText = String(account.note || "").trim(); - - return ( - - } - className="block min-w-0 max-w-full cursor-help text-left" - > -
-
- - {account.name} - - {accountPlanLabel ? ( - - {accountPlanLabel} - - ) : null} - {isPreferred ? ( - - {t("优先")} - - ) : null} -
- - {account.id} - - - {t("最近刷新")}:{" "} - {formatTsFromSeconds(account.lastRefreshAt, t("从未刷新"))} - - - {t("订阅到期")}: {subscriptionExpiryText} - -
-
- -
-
-
-
- {t("账号类型")} -
-
{accountPlanLabel || t("未知")}
-
-
-
- {t("当前状态")} -
-
- {t(account.availabilityText || "未知")} -
-
- {statusReasonLabel ? ( -
-
- {t("状态原因")} -
-
{statusReasonLabel}
- {statusReasonCode ? ( -
- {statusReasonCode} -
- ) : null} -
- ) : null} -
-
- {t("订阅状态")} -
-
{subscriptionStatusLabel}
-
-
-
- {t("订阅方案")} -
-
{subscriptionPlanLabel}
-
-
-
-
-
- {t("到期时间")} -
-
- {formatTsFromSeconds(account.subscriptionExpiresAt, t("未知"))} -
-
-
-
- {t("续费时间")} -
-
- {formatTsFromSeconds(account.subscriptionRenewsAt, t("未知"))} -
-
-
-
-
{t("标签")}
-
{tagsText || t("未设置")}
-
-
-
{t("备注")}
-
- {noteText || t("未设置")} -
-
-
-
{t("账号 ID")}
-
{account.id}
-
-
-
-
- ); + const { t } = useI18n(); + const accountPlanLabel = formatAccountPlanLabel(account, t); + const subscriptionStatusLabel = formatAccountSubscriptionStatusLabel( + account, + t, + ); + const subscriptionPlanLabel = formatAccountSubscriptionPlanLabel(account, t); + const subscriptionExpiryText = + account.subscriptionExpiresAt != null + ? formatTsFromSeconds(account.subscriptionExpiresAt, t("未知")) + : account.hasSubscription === false + ? t("未订阅") + : t("未知"); + const statusReasonCode = getAccountStatusReasonCode(account); + const statusReasonLabel = formatAccountStatusReasonLabel(account, t); + const tagsText = formatAccountTags(account.tags); + const noteText = String(account.note || "").trim(); + + return ( + + } + className="block min-w-0 max-w-full cursor-help text-left" + > +
+
+ + {account.name} + + {accountPlanLabel ? ( + + {accountPlanLabel} + + ) : null} + {isPreferred ? ( + + {t("优先")} + + ) : null} +
+ + {account.id} + + + {t("最近刷新")}:{" "} + {formatTsFromSeconds(account.lastRefreshAt, t("从未刷新"))} + + + {t("订阅到期")}: {subscriptionExpiryText} + +
+
+ +
+
+
+
+ {t("账号类型")} +
+
{accountPlanLabel || t("未知")}
+
+
+
+ {t("当前状态")} +
+
+ {t(account.availabilityText || "未知")} +
+
+ {statusReasonLabel ? ( +
+
+ {t("状态原因")} +
+
{statusReasonLabel}
+ {statusReasonCode ? ( +
+ {statusReasonCode} +
+ ) : null} +
+ ) : null} +
+
+ {t("订阅状态")} +
+
{subscriptionStatusLabel}
+
+
+
+ {t("订阅方案")} +
+
{subscriptionPlanLabel}
+
+
+
+
+
+ {t("到期时间")} +
+
+ {formatTsFromSeconds(account.subscriptionExpiresAt, t("未知"))} +
+
+
+
+ {t("续费时间")} +
+
+ {formatTsFromSeconds(account.subscriptionRenewsAt, t("未知"))} +
+
+
+
+
{t("标签")}
+
{tagsText || t("未设置")}
+
+
+
{t("备注")}
+
+ {noteText || t("未设置")} +
+
+
+
{t("账号 ID")}
+
{account.id}
+
+
+
+
+ ); } diff --git a/apps/src/app/accounts/accounts-page-view.tsx b/apps/src/app/accounts/accounts-page-view.tsx index ee10113b3..17b26a0c1 100644 --- a/apps/src/app/accounts/accounts-page-view.tsx +++ b/apps/src/app/accounts/accounts-page-view.tsx @@ -2,24 +2,24 @@ import type { Dispatch, SetStateAction } from "react"; import { - ArrowDown, - ArrowUp, - ArrowUpDown, - BarChart3, - Download, - FileUp, - FolderOpen, - KeyRound, - Loader2, - MoreVertical, - Network, - PencilLine, - Pin, - Plus, - RefreshCw, - Search, - Trash2, - Zap, + ArrowDown, + ArrowUp, + ArrowUpDown, + BarChart3, + Download, + FileUp, + FolderOpen, + KeyRound, + Loader2, + MoreVertical, + Network, + PencilLine, + Pin, + Plus, + RefreshCw, + Search, + Trash2, + Zap, } from "lucide-react"; import { AddAccountModal } from "@/components/modals/add-account-modal"; import { ConfirmDialog } from "@/components/modals/confirm-dialog"; @@ -28,1513 +28,1593 @@ import { Button, buttonVariants } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, } from "@/components/ui/dialog"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; import { - Tooltip, - TooltipContent, - TooltipTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, } from "@/components/ui/tooltip"; import { useI18n } from "@/lib/i18n/provider"; import { cn } from "@/lib/utils"; import { formatCompactNumber } from "@/lib/utils/usage"; import type { AccountProxySettings } from "@/lib/api/account-client"; import type { Account } from "@/types"; +import { Badge } from "@/components/ui/badge"; +import { AccountProxyCell } from "@/components/accounts/account-proxy-cell"; +import { AccountProxyGeoStatusGrid } from "@/components/accounts/account-proxy-status-grid"; import { - type AccountEditorState, - type AccountExportMode, - type AccountSizeSortMode, - type DeleteDialogState, - type StatusFilter, - AccountInfoCell, - AccountStatusCell, - QuotaOverviewCell, - buildQuotaSummaryItems, - formatAccountExportModeLabel, - formatAccountPlanLabel, - formatAccountPlanValueLabel, - fitLongTextClassName, - formatPlanFilterLabel, - formatStatusFilterLabel, - getAccountStatusAction, + type AccountEditorState, + type AccountExportMode, + type AccountSizeSortMode, + type DeleteDialogState, + type StatusFilter, + AccountInfoCell, + AccountStatusCell, + QuotaOverviewCell, + buildQuotaSummaryItems, + formatAccountExportModeLabel, + formatAccountPlanLabel, + formatAccountPlanValueLabel, + fitLongTextClassName, + formatPlanFilterLabel, + formatStatusFilterLabel, + getAccountStatusAction, + getAccountPlanBadgeClassName, } from "@/app/accounts/accounts-page-helpers"; interface PlanTypeOption { - value: string; - count: number; + value: string; + count: number; } interface StatusFilterOption { - id: StatusFilter; - label: string; + id: StatusFilter; + label: string; } interface CleanupStatusOption { - id: string; - label: string; - description: string; - count: number; + id: string; + label: string; + description: string; + count: number; } export interface AccountsPageViewProps { - accounts: Account[]; - planTypes: PlanTypeOption[]; - isLoading: boolean; - isServiceReady: boolean; - isPageActive: boolean; - search: string; - planFilter: string; - statusFilter: StatusFilter; - pageSize: string; - safePage: number; - totalPages: number; - filteredAccounts: Account[]; - visibleAccounts: Account[]; - filteredAccountIndexMap: Map; - effectiveSelectedIds: string[]; - addAccountModalOpen: boolean; - usageModalOpen: boolean; - exportDialogOpen: boolean; - exportModeDraft: AccountExportMode; - exportTargetCount: number; - exportScopeText: string; - cleanupDialogOpen: boolean; - cleanupStatusDraft: string[]; - cleanupStatusOptions: CleanupStatusOption[]; - proxyDialogAccount: Account | null; - proxySettings: AccountProxySettings | null; - isProxySettingsLoading: boolean; - proxyEnabledDraft: boolean; - proxyUrlDraft: string; - selectedAccount: Account | null; - accountEditorState: AccountEditorState | null; - deleteDialogState: DeleteDialogState; - currentEditingAccount: Account | null; - labelDraft: string; - tagsDraft: string; - noteDraft: string; - sortDraft: string; - modelWhitelistDraft: string; - quotaPrimaryDraft: string; - quotaSecondaryDraft: string; - isRefreshingAllAccounts: boolean; - isRefreshingAccountId: string | null; - isRefreshingRtAccountId: string | null; - isRefreshingAllRtAccounts: boolean; - isExporting: boolean; - isWarmingUpAccounts: boolean; - isDeletingMany: boolean; - isCleaningAccountsByStatus: boolean; - isUpdatingPreferred: boolean; - isSavingAccountProxy: boolean; - isClearingAccountProxy: boolean; - isTestingAccountProxy: boolean; - isReorderingAccounts: boolean; - isUpdatingProfileAccountId: string | null; - isUpdatingStatusAccountId: string | null; - statusFilterOptions: StatusFilterOption[]; - importFileActionLabel: string; - importDirectoryActionLabel: string; - exportActionLabel: string; - exportActionShortcut: string; - setAddAccountModalOpen: Dispatch>; - setExportDialogOpen: Dispatch>; - setExportModeDraft: Dispatch>; - setDeleteDialogState: Dispatch>; - setCleanupDialogOpen: Dispatch>; - setProxyEnabledDraft: Dispatch>; - setProxyUrlDraft: Dispatch>; - setAccountEditorState: Dispatch>; - setLabelDraft: Dispatch>; - setTagsDraft: Dispatch>; - setNoteDraft: Dispatch>; - setSortDraft: Dispatch>; - setModelWhitelistDraft: Dispatch>; - setQuotaPrimaryDraft: Dispatch>; - setQuotaSecondaryDraft: Dispatch>; - setPage: Dispatch>; - handleSearchChange: (value: string) => void; - handlePlanFilterChange: (value: string | null) => void; - handleStatusFilterChange: (value: StatusFilter) => void; - handlePageSizeChange: (value: string | null) => void; - toggleSelect: (id: string) => void; - toggleSelectAllVisible: () => void; - openUsage: (account: Account) => void; - handleUsageModalOpenChange: (open: boolean) => void; - handleDeleteSelected: () => void; - openCleanupDialog: () => void; - toggleCleanupStatus: (status: string) => void; - handleConfirmCleanupStatuses: () => Promise; - handleWarmupAccounts: () => Promise; - openExportDialog: () => void; - handleConfirmExport: () => Promise; - handleDeleteSingle: (account: Account) => void; - openProxyDialog: (account: Account) => void; - handleProxyDialogOpenChange: (open: boolean) => void; - handleSaveProxySettings: () => Promise; - handleClearProxySettings: () => Promise; - handleTestProxySettings: () => Promise; - openAccountEditor: (account: Account) => void; - handleMoveAccount: ( - account: Account, - direction: "up" | "down", - ) => Promise; - handleApplyAccountSizeSort: (mode: AccountSizeSortMode) => Promise; - handleConfirmAccountEditor: () => Promise; - handleConfirmDelete: () => void; - refreshAllAccounts: () => void; - refreshAllAccountRt: () => void; - refreshAccountList: () => void; - refreshAccountRt: (accountId: string) => void; - importByFile: () => void; - importByDirectory: () => void; - refreshAccount: (accountId: string) => void; - clearPreferredAccount: (accountId: string) => void; - setPreferredAccount: (accountId: string) => void; - toggleAccountStatus: ( - accountId: string, - enabled: boolean, - currentStatus: string, - ) => void; + accounts: Account[]; + planTypes: PlanTypeOption[]; + isLoading: boolean; + isServiceReady: boolean; + isPageActive: boolean; + search: string; + planFilter: string; + statusFilter: StatusFilter; + pageSize: string; + safePage: number; + totalPages: number; + filteredAccounts: Account[]; + visibleAccounts: Account[]; + filteredAccountIndexMap: Map; + effectiveSelectedIds: string[]; + addAccountModalOpen: boolean; + usageModalOpen: boolean; + exportDialogOpen: boolean; + exportModeDraft: AccountExportMode; + exportTargetCount: number; + exportScopeText: string; + cleanupDialogOpen: boolean; + cleanupStatusDraft: string[]; + cleanupStatusOptions: CleanupStatusOption[]; + proxyDialogAccount: Account | null; + proxySettings: AccountProxySettings | null; + isProxySettingsLoading: boolean; + proxyEnabledDraft: boolean; + proxyUrlDraft: string; + selectedAccount: Account | null; + accountEditorState: AccountEditorState | null; + deleteDialogState: DeleteDialogState; + currentEditingAccount: Account | null; + labelDraft: string; + tagsDraft: string; + noteDraft: string; + sortDraft: string; + modelWhitelistDraft: string; + quotaPrimaryDraft: string; + quotaSecondaryDraft: string; + isRefreshingAllAccounts: boolean; + isRefreshingAccountId: string | null; + isRefreshingRtAccountId: string | null; + isRefreshingAllRtAccounts: boolean; + isExporting: boolean; + isWarmingUpAccounts: boolean; + isDeletingMany: boolean; + isCleaningAccountsByStatus: boolean; + isUpdatingPreferred: boolean; + isSavingAccountProxy: boolean; + isClearingAccountProxy: boolean; + isTestingAccountProxy: boolean; + isReorderingAccounts: boolean; + isUpdatingProfileAccountId: string | null; + isUpdatingStatusAccountId: string | null; + statusFilterOptions: StatusFilterOption[]; + importFileActionLabel: string; + importDirectoryActionLabel: string; + exportActionLabel: string; + exportActionShortcut: string; + setAddAccountModalOpen: Dispatch>; + setExportDialogOpen: Dispatch>; + setExportModeDraft: Dispatch>; + setDeleteDialogState: Dispatch>; + setCleanupDialogOpen: Dispatch>; + setProxyEnabledDraft: Dispatch>; + setProxyUrlDraft: Dispatch>; + setAccountEditorState: Dispatch>; + setLabelDraft: Dispatch>; + setTagsDraft: Dispatch>; + setNoteDraft: Dispatch>; + setSortDraft: Dispatch>; + setModelWhitelistDraft: Dispatch>; + setQuotaPrimaryDraft: Dispatch>; + setQuotaSecondaryDraft: Dispatch>; + setPage: Dispatch>; + handleSearchChange: (value: string) => void; + handlePlanFilterChange: (value: string | null) => void; + handleStatusFilterChange: (value: StatusFilter) => void; + handlePageSizeChange: (value: string | null) => void; + toggleSelect: (id: string) => void; + toggleSelectAllVisible: () => void; + openUsage: (account: Account) => void; + handleUsageModalOpenChange: (open: boolean) => void; + handleDeleteSelected: () => void; + openCleanupDialog: () => void; + toggleCleanupStatus: (status: string) => void; + handleConfirmCleanupStatuses: () => Promise; + handleWarmupAccounts: () => Promise; + openExportDialog: () => void; + handleConfirmExport: () => Promise; + handleDeleteSingle: (account: Account) => void; + openProxyDialog: (account: Account) => void; + handleProxyDialogOpenChange: (open: boolean) => void; + handleSaveProxySettings: () => Promise; + handleClearProxySettings: () => Promise; + handleTestProxySettings: () => Promise; + openAccountEditor: (account: Account) => void; + handleMoveAccount: ( + account: Account, + direction: "up" | "down", + ) => Promise; + handleApplyAccountSizeSort: (mode: AccountSizeSortMode) => Promise; + handleConfirmAccountEditor: () => Promise; + handleConfirmDelete: () => void; + refreshAllAccounts: () => void; + refreshAllAccountRt: () => void; + refreshAccountList: () => void; + refreshAccountRt: (accountId: string) => void; + importByFile: () => void; + importByDirectory: () => void; + refreshAccount: (accountId: string) => void; + clearPreferredAccount: (accountId: string) => void; + setPreferredAccount: (accountId: string) => void; + toggleAccountStatus: ( + accountId: string, + enabled: boolean, + currentStatus: string, + ) => void; } export function AccountsPageView(props: AccountsPageViewProps) { - const { t } = useI18n(); - const { - accounts, - planTypes, - isLoading, - isServiceReady, - isPageActive, - search, - planFilter, - statusFilter, - pageSize, - safePage, - totalPages, - filteredAccounts, - visibleAccounts, - filteredAccountIndexMap, - effectiveSelectedIds, - addAccountModalOpen, - usageModalOpen, - exportDialogOpen, - exportModeDraft, - exportTargetCount, - exportScopeText, - cleanupDialogOpen, - cleanupStatusDraft, - cleanupStatusOptions, - proxyDialogAccount, - proxySettings, - isProxySettingsLoading, - proxyEnabledDraft, - proxyUrlDraft, - selectedAccount, - accountEditorState, - deleteDialogState, - currentEditingAccount, - labelDraft, - tagsDraft, - noteDraft, - sortDraft, - modelWhitelistDraft, - quotaPrimaryDraft, - quotaSecondaryDraft, - isRefreshingAllAccounts, - isRefreshingAccountId, - isRefreshingRtAccountId, - isRefreshingAllRtAccounts, - isExporting, - isWarmingUpAccounts, - isDeletingMany, - isCleaningAccountsByStatus, - isUpdatingPreferred, - isSavingAccountProxy, - isClearingAccountProxy, - isTestingAccountProxy, - isReorderingAccounts, - isUpdatingProfileAccountId, - isUpdatingStatusAccountId, - statusFilterOptions, - importFileActionLabel, - importDirectoryActionLabel, - exportActionLabel, - exportActionShortcut, - setAddAccountModalOpen, - setExportDialogOpen, - setExportModeDraft, - setDeleteDialogState, - setCleanupDialogOpen, - setProxyEnabledDraft, - setProxyUrlDraft, - setAccountEditorState, - setLabelDraft, - setTagsDraft, - setNoteDraft, - setSortDraft, - setModelWhitelistDraft, - setQuotaPrimaryDraft, - setQuotaSecondaryDraft, - setPage, - handleSearchChange, - handlePlanFilterChange, - handleStatusFilterChange, - handlePageSizeChange, - toggleSelect, - toggleSelectAllVisible, - openUsage, - handleUsageModalOpenChange, - handleDeleteSelected, - openCleanupDialog, - toggleCleanupStatus, - handleConfirmCleanupStatuses, - handleWarmupAccounts, - openExportDialog, - handleConfirmExport, - handleDeleteSingle, - openProxyDialog, - handleProxyDialogOpenChange, - handleSaveProxySettings, - handleClearProxySettings, - handleTestProxySettings, - openAccountEditor, - handleMoveAccount, - handleApplyAccountSizeSort, - handleConfirmAccountEditor, - handleConfirmDelete, - refreshAllAccounts, - refreshAllAccountRt, - refreshAccountList, - refreshAccountRt, - importByFile, - importByDirectory, - refreshAccount, - clearPreferredAccount, - setPreferredAccount, - toggleAccountStatus, - } = props; - const cleanupSelectedCount = cleanupStatusOptions.reduce( - (total, option) => - cleanupStatusDraft.includes(option.id) ? total + option.count : total, - 0, - ); - const accountProxyBusy = - isProxySettingsLoading || - isSavingAccountProxy || - isClearingAccountProxy || - isTestingAccountProxy; - const accountProxyStatusText = (() => { - const status = String(proxySettings?.status || "not_configured"); - switch (status) { - case "ok": - return t("可用"); - case "runtime_error": - return t("运行时错误"); - case "failed": - return t("测试失败"); - case "invalid_url": - return t("地址无效"); - case "checking": - return t("测试中"); - case "unchecked": - return t("未测试"); - case "not_configured": - default: - return t("未配置"); - } - })(); - const accountProxyLastCheckText = - proxySettings?.lastCheckAt != null - ? new Date(proxySettings.lastCheckAt * 1000).toLocaleString() - : t("从未检查"); + const { t } = useI18n(); + const { + accounts, + planTypes, + isLoading, + isServiceReady, + isPageActive, + search, + planFilter, + statusFilter, + pageSize, + safePage, + totalPages, + filteredAccounts, + visibleAccounts, + filteredAccountIndexMap, + effectiveSelectedIds, + addAccountModalOpen, + usageModalOpen, + exportDialogOpen, + exportModeDraft, + exportTargetCount, + exportScopeText, + cleanupDialogOpen, + cleanupStatusDraft, + cleanupStatusOptions, + proxyDialogAccount, + proxySettings, + isProxySettingsLoading, + proxyEnabledDraft, + proxyUrlDraft, + selectedAccount, + accountEditorState, + deleteDialogState, + currentEditingAccount, + labelDraft, + tagsDraft, + noteDraft, + sortDraft, + modelWhitelistDraft, + quotaPrimaryDraft, + quotaSecondaryDraft, + isRefreshingAllAccounts, + isRefreshingAccountId, + isRefreshingRtAccountId, + isRefreshingAllRtAccounts, + isExporting, + isWarmingUpAccounts, + isDeletingMany, + isCleaningAccountsByStatus, + isUpdatingPreferred, + isSavingAccountProxy, + isClearingAccountProxy, + isTestingAccountProxy, + isReorderingAccounts, + isUpdatingProfileAccountId, + isUpdatingStatusAccountId, + statusFilterOptions, + importFileActionLabel, + importDirectoryActionLabel, + exportActionLabel, + exportActionShortcut, + setAddAccountModalOpen, + setExportDialogOpen, + setExportModeDraft, + setDeleteDialogState, + setCleanupDialogOpen, + setProxyEnabledDraft, + setProxyUrlDraft, + setAccountEditorState, + setLabelDraft, + setTagsDraft, + setNoteDraft, + setSortDraft, + setModelWhitelistDraft, + setQuotaPrimaryDraft, + setQuotaSecondaryDraft, + setPage, + handleSearchChange, + handlePlanFilterChange, + handleStatusFilterChange, + handlePageSizeChange, + toggleSelect, + toggleSelectAllVisible, + openUsage, + handleUsageModalOpenChange, + handleDeleteSelected, + openCleanupDialog, + toggleCleanupStatus, + handleConfirmCleanupStatuses, + handleWarmupAccounts, + openExportDialog, + handleConfirmExport, + handleDeleteSingle, + openProxyDialog, + handleProxyDialogOpenChange, + handleSaveProxySettings, + handleClearProxySettings, + handleTestProxySettings, + openAccountEditor, + handleMoveAccount, + handleApplyAccountSizeSort, + handleConfirmAccountEditor, + handleConfirmDelete, + refreshAllAccounts, + refreshAllAccountRt, + refreshAccountList, + refreshAccountRt, + importByFile, + importByDirectory, + refreshAccount, + clearPreferredAccount, + setPreferredAccount, + toggleAccountStatus, + } = props; + const cleanupSelectedCount = cleanupStatusOptions.reduce( + (total, option) => + cleanupStatusDraft.includes(option.id) ? total + option.count : total, + 0, + ); + const accountProxyBusy = + isProxySettingsLoading || + isSavingAccountProxy || + isClearingAccountProxy || + isTestingAccountProxy; + const accountProxyStatusText = (() => { + const status = String(proxySettings?.status || "not_configured"); + switch (status) { + case "ok": + return t("可用"); + case "runtime_error": + return t("运行时错误"); + case "failed": + return t("测试失败"); + case "invalid_url": + return t("地址无效"); + case "checking": + return t("测试中"); + case "unchecked": + return t("未测试"); + case "not_configured": + default: + return t("未配置"); + } + })(); + const accountProxyStatusColorClass = (() => { + const status = String(proxySettings?.status || "not_configured"); + switch (status) { + case "ok": + return "text-green-600 dark:text-green-400"; + case "checking": + return "text-yellow-600 dark:text-yellow-400"; + case "unchecked": + return "text-orange-600 dark:text-orange-400"; + case "failed": + case "runtime_error": + case "invalid_url": + return "text-red-600 dark:text-red-400"; + case "not_configured": + default: + return "text-muted-foreground"; + } + })(); + const accountProxyLastCheckText = + proxySettings?.lastCheckAt != null + ? new Date(proxySettings.lastCheckAt * 1000).toLocaleString() + : t("从未检查"); - return ( -
- {!isServiceReady ? ( - - - {t( - "服务未连接,账号列表与相关操作暂不可用;连接恢复后会自动继续加载。", - )} - - - ) : null} + return ( +
+ {!isServiceReady ? ( + + + {t( + "服务未连接,账号列表与相关操作暂不可用;连接恢复后会自动继续加载。", + )} + + + ) : null} - - -
- handleSearchChange(event.target.value)} - /> -
+ + +
+ handleSearchChange(event.target.value)} + /> +
-
- - -
+
+ + +
-
+
-
- - } className="inline-flex"> - - - - {t( - "向选中账号发送 hi 进行预热;如果未选中账号,则默认预热全部账号。", - )} - - - - - - - - - - {t("刷新")} - - - - {t("刷新账号用量")} - ALL - - - - {t("刷新全部 AT/RT")} - RT - - - - {t("刷新列表")} - LIST - - - - - - {t("号池管理")} - - setAddAccountModalOpen(true)} - > - {t("添加账号")} - - - {importFileActionLabel} - FILE - - - - {importDirectoryActionLabel} - DIR - - - - {exportActionLabel} - - {exportActionShortcut} - - - - - - - {t("排序")} - - void handleApplyAccountSizeSort("large-first")} - > - - {t("大号优先排序")} - BIZ - - void handleApplyAccountSizeSort("small-first")} - > - - {t("小号优先排序")} - FREE - - - - - - {t("清理")} - - - {t("删除选中账号")} - - {effectiveSelectedIds.length || "-"} - - - - {t("按状态清理账号")} - - - - -
- - +
+ + } className="inline-flex"> + + + + {t( + "向选中账号发送 hi 进行预热;如果未选中账号,则默认预热全部账号。", + )} + + + + + + + + + + {t("刷新")} + + + + {t("刷新账号用量")} + ALL + + + + {t("刷新全部 AT/RT")} + RT + + + + {t("刷新列表")} + LIST + + + + + + {t("号池管理")} + + setAddAccountModalOpen(true)} + > + {t("添加账号")} + + + {importFileActionLabel} + FILE + + + + {importDirectoryActionLabel} + DIR + + + + {exportActionLabel} + + {exportActionShortcut} + + + + + + + {t("排序")} + + + void handleApplyAccountSizeSort("large-first") + } + > + + {t("大号优先排序")} + BIZ + + + void handleApplyAccountSizeSort("small-first") + } + > + + {t("小号优先排序")} + FREE + + + + + + {t("清理")} + + + {t("删除选中账号")} + + {effectiveSelectedIds.length || "-"} + + + + {t("按状态清理账号")} + + + + +
+ + - - - - {t("导出账号")} - - {t("选择导出方式;如果已勾选账号,则只导出当前选中项。")} - - -
-
- {exportScopeText} -
-
- - -
-
- {exportModeDraft === "single" - ? t( - "导出为一个 `accounts.json` 数组文件,适合整体备份和再次导入。", - ) - : t( - "每个账号导出为一个独立 JSON 文件,适合逐个分发或单独管理。", - )} -
-
-
-
- - - {t("取消")} - - - -
-
+ + + + {t("导出账号")} + + {t("选择导出方式;如果已勾选账号,则只导出当前选中项。")} + + +
+
+ {exportScopeText} +
+
+ + +
+
+ {exportModeDraft === "single" + ? t( + "导出为一个 `accounts.json` 数组文件,适合整体备份和再次导入。", + ) + : t( + "每个账号导出为一个独立 JSON 文件,适合逐个分发或单独管理。", + )} +
+
+
+
+ + + {t("取消")} + + + +
+
- { - if (!isCleaningAccountsByStatus) { - setCleanupDialogOpen(open); - } - }} - > - - - {t("按状态清理账号")} - - {t("选择要删除的账号状态;删除后不可恢复。")} - - -
-
- {t("将删除所有匹配所选状态的账号,不再额外限制账号套餐。")} -
-
- {cleanupStatusOptions.map((option) => { - const checked = cleanupStatusDraft.includes(option.id); - return ( -
- toggleCleanupStatus(option.id)} - aria-label={option.label} - /> -
-
- - {option.label} - - - {option.count} - -
-

- {option.description} -

-
-
- ); - })} -
-
- {t("预计删除")}{" "} - - {cleanupSelectedCount} - {" "} - {t("个账号")} -
-
- - - {t("取消")} - - - -
-
+ { + if (!isCleaningAccountsByStatus) { + setCleanupDialogOpen(open); + } + }} + > + + + {t("按状态清理账号")} + + {t("选择要删除的账号状态;删除后不可恢复。")} + + +
+
+ {t("将删除所有匹配所选状态的账号,不再额外限制账号套餐。")} +
+
+ {cleanupStatusOptions.map((option) => { + const checked = cleanupStatusDraft.includes(option.id); + return ( +
+ toggleCleanupStatus(option.id)} + aria-label={option.label} + /> +
+
+ + {option.label} + + + {option.count} + +
+

+ {option.description} +

+
+
+ ); + })} +
+
+ {t("预计删除")}{" "} + + {cleanupSelectedCount} + {" "} + {t("个账号")} +
+
+ + + {t("取消")} + + + +
+
- - - - - - - 0 && - visibleAccounts.every((account) => - effectiveSelectedIds.includes(account.id), - ) - } - onCheckedChange={toggleSelectAllVisible} - /> - - - {t("账号信息")} - - - {t("额度详情")} - - {t("顺序")} - {t("状态")} - - {t("操作")} - - - - - {isLoading ? ( - Array.from({ length: 5 }).map((_, index) => ( - - - - - - - - -
- - - -
-
- - - - - - - - - -
- )) - ) : visibleAccounts.length === 0 ? ( - - -
- -

{t("未找到符合条件的账号")}

-
-
-
- ) : ( - visibleAccounts.map((account) => { - const quotaItems = buildQuotaSummaryItems(account, t); - const statusAction = getAccountStatusAction(account, t); - const StatusActionIcon = statusAction.icon; - const modelPoolText = account.modelSlugs.length - ? account.modelSlugs.slice(0, 2).join(", ") - : t("全部 API 模型"); - const modelPoolDisplayText = `${t("模型池")}: ${modelPoolText}${ - account.modelSlugs.length > 2 - ? ` +${account.modelSlugs.length - 2}` - : "" - }`; - const isRefreshingCurrentAccount = - isRefreshingAccountId === account.id; - const isRefreshingCurrentRt = - isRefreshingRtAccountId === account.id; - const filteredIndex = - filteredAccountIndexMap.get(account.id) ?? -1; - const canMoveUp = filteredIndex > 0; - const canMoveDown = - filteredIndex !== -1 && - filteredIndex < filteredAccounts.length - 1; - return ( - - - toggleSelect(account.id)} - /> - - - - - - -
- - {modelPoolDisplayText} - - {account.quotaCapacityPrimaryWindowTokens || - account.quotaCapacitySecondaryWindowTokens ? ( - - {t("容量覆盖")}:{" "} - {account.quotaCapacityPrimaryWindowTokens - ? `5h ${formatCompactNumber( - account.quotaCapacityPrimaryWindowTokens, - "0.00", - 2, - true, - )}` - : "5h --"} - {" / "} - {account.quotaCapacitySecondaryWindowTokens - ? `7d ${formatCompactNumber( - account.quotaCapacitySecondaryWindowTokens, - "0.00", - 2, - true, - )}` - : "7d --"} - - ) : ( - - {t("未设置账号容量覆盖")} - - )} -
-
- -
- - {account.priority} - - - - -
-
- - - - -
- - - - - - - - refreshAccount(account.id)} - > - - {t("刷新用量")} - - refreshAccountRt(account.id)} - > - - {t("刷新 AT/RT")} - RT - - - - account.preferred - ? clearPreferredAccount(account.id) - : setPreferredAccount(account.id) - } - > - - {account.preferred ? t("取消优先") : t("设为优先")} - - void openProxyDialog(account)} - > - - {t("账号代理")} - - - statusAction.action && - toggleAccountStatus( - account.id, - statusAction.action === "enable", - account.status, - ) - } - > - - {statusAction.label} - - - handleDeleteSingle(account)} - > - {t("删除")} - - - - -
-
-
- ); - }) - )} -
-
-
-
+ + + + + + + 0 && + visibleAccounts.every((account) => + effectiveSelectedIds.includes(account.id), + ) + } + onCheckedChange={toggleSelectAllVisible} + /> + + + {t("账号信息")} + + + {t("模型白名单 / 额度管理")} + + {t("顺序")} + {t("代理")} + {t("状态")} + + {t("操作")} + + + + + {isLoading ? ( + Array.from({ length: 5 }).map((_, index) => ( + + + + + + + + +
+ + + +
+
+ + + + + + + + + + + + +
+ )) + ) : visibleAccounts.length === 0 ? ( + + +
+ +

{t("未找到符合条件的账号")}

+
+
+
+ ) : ( + visibleAccounts.map((account) => { + const quotaItems = buildQuotaSummaryItems(account, t); + const statusAction = getAccountStatusAction(account, t); + const StatusActionIcon = statusAction.icon; + const modelPoolText = account.modelSlugs.length + ? account.modelSlugs.slice(0, 2).join(", ") + : t("全部 API 模型"); + const modelPoolDisplayText = `${t("模型池")}: ${modelPoolText}${ + account.modelSlugs.length > 2 + ? ` +${account.modelSlugs.length - 2}` + : "" + }`; + const isRefreshingCurrentAccount = + isRefreshingAccountId === account.id; + const isRefreshingCurrentRt = + isRefreshingRtAccountId === account.id; + const filteredIndex = + filteredAccountIndexMap.get(account.id) ?? -1; + const canMoveUp = filteredIndex > 0; + const canMoveDown = + filteredIndex !== -1 && + filteredIndex < filteredAccounts.length - 1; + return ( + + + toggleSelect(account.id)} + /> + + + + + + +
+ + {modelPoolDisplayText} + + {account.quotaCapacityPrimaryWindowTokens || + account.quotaCapacitySecondaryWindowTokens ? ( + + {t("容量覆盖")}:{" "} + {account.quotaCapacityPrimaryWindowTokens + ? `5h ${formatCompactNumber( + account.quotaCapacityPrimaryWindowTokens, + "0.00", + 2, + true, + )}` + : "5h --"} + {" / "} + {account.quotaCapacitySecondaryWindowTokens + ? `7d ${formatCompactNumber( + account.quotaCapacitySecondaryWindowTokens, + "0.00", + 2, + true, + )}` + : "7d --"} + + ) : ( + + {t("未设置账号容量覆盖")} + + )} +
+
+ +
+ + {account.priority} + + + + +
+
+ + + + + + + +
+ + + + + + + + refreshAccount(account.id)} + > + + {t("刷新用量")} + + refreshAccountRt(account.id)} + > + + {t("刷新 AT/RT")} + + RT + + + + + account.preferred + ? clearPreferredAccount(account.id) + : setPreferredAccount(account.id) + } + > + + {account.preferred + ? t("取消优先") + : t("设为优先")} + + void openProxyDialog(account)} + > + + {t("账号代理")} + + + statusAction.action && + toggleAccountStatus( + account.id, + statusAction.action === "enable", + account.status, + ) + } + > + + {statusAction.label} + + + handleDeleteSingle(account)} + > + {t("删除")} + + + + +
+
+
+ ); + }) + )} +
+
+
+
-
-
- {t("共")} {filteredAccounts.length} {t("个账号")} - {effectiveSelectedIds.length > 0 ? ( - - ({t("已选择")} {effectiveSelectedIds.length} {t("个")}) - - ) : null} -
-
-
- - {t("每页显示")} - - -
-
- -
- {t("第")} {safePage} / {totalPages} {t("页")} -
- -
-
-
+
+
+ {t("共")} {filteredAccounts.length} {t("个账号")} + {effectiveSelectedIds.length > 0 ? ( + + ({t("已选择")} {effectiveSelectedIds.length} {t("个")}) + + ) : null} +
+
+
+ + {t("每页显示")} + + +
+
+ +
+ {t("第")} {safePage} / {totalPages} {t("页")} +
+ +
+
+
-{addAccountModalOpen ? ( - - ) : null} - - - - - {t("账号代理")} - - {proxyDialogAccount - ? proxyDialogAccount.name - : t("为单个 OpenAI 账号配置本地代理。")} - - -
-
-
- -

- {t("启用后,该账号会优先使用这里的代理地址。")} -

-
- setProxyEnabledDraft(Boolean(value))} - /> -
-
- -
- setProxyUrlDraft(event.target.value)} - placeholder="http://127.0.0.1:7891" - /> - -
-

- {t("支持 http、https、socks4、socks5;sing-box mixed inbound 通常填写 http://127.0.0.1:端口。")} -

-

- {t("建议登录、刷新、用量和 API 请求保持同一代理与地区,以降低账号风控和状态漂移。")} -

-

- {t("建议登录、刷新、用量和 API 请求保持同一代理与地区,以降低账号风控和状态漂移。")} -

-
-
-
-
{t("测试状态")}
-
{accountProxyStatusText}
-
-
-
{t("最近检查")}
-
{accountProxyLastCheckText}
-
-
-
{t("延迟")}
-
- {proxySettings?.latencyMs != null - ? `${proxySettings.latencyMs} ms` - : "--"} -
-
-
-
{t("当前模式")}
-
- {proxySettings?.enabled ? t("账号代理") : t("默认路由")} -
-
- {proxySettings?.lastError ? ( -
-
{t("错误")}
-
- {proxySettings.lastError} -
-
- ) : null} -
-
- - - {t("关闭")} - - - - -
-
- { - if (!open) { - setDeleteDialogState(null); - } - }} - title={ - deleteDialogState?.kind === "single" - ? t("删除账号") - : t("批量删除账号") - } - description={ - deleteDialogState?.kind === "single" - ? `${t("确定删除账号")} ${deleteDialogState.account.name} ${t("吗?删除后不可恢复。")}` - : `${t("确定删除选中的")} ${deleteDialogState?.count || 0} ${t("个账号吗?删除后不可恢复。")}` - } - confirmText={t("删除")} - confirmVariant="destructive" - onConfirm={handleConfirmDelete} - /> - { - if (!open && !isUpdatingProfileAccountId) { - setAccountEditorState(null); - } - }} - > - - - {t("编辑账号信息")} - - {accountEditorState - ? `${t("修改")} ${accountEditorState.accountName} ${t("的名称、标签、备注、排序与额度池配置。")}` - : t("修改账号的基础资料。")} - - -
-
-
- - setLabelDraft(event.target.value)} - /> -
-
- - setTagsDraft(event.target.value)} - placeholder={t("例如:高频, 团队A")} - /> -
-
-
- -