From 877e9265caaaa72b91b68f9ca62577e8e13e8a3e Mon Sep 17 00:00:00 2001 From: zyycn Date: Sun, 28 Jun 2026 13:27:15 +0800 Subject: [PATCH] feat: add account export workflow Co-authored-by: Codex --- docs/deleted-code-feature-parity-audit.md | 127 --------- src/admin/accounts/routes.rs | 37 +++ src/admin/accounts/service/exporting.rs | 97 +++++++ src/admin/accounts/service/mod.rs | 4 +- src/admin/accounts/service/types.rs | 27 ++ src/admin/router.rs | 5 +- tests/admin/accounts/exporting.rs | 251 ++++++++++++++++++ tests/admin/accounts/mod.rs | 1 + web/index.html | 25 +- web/package.json | 2 + web/pnpm-lock.yaml | 42 +++ web/src/api/modules/accounts.ts | 8 + web/src/components/base/BasePopover.vue | 52 ++-- web/src/components/base/BaseScrollbar.vue | 56 ++-- web/src/components/base/BaseSegmented.vue | 7 +- web/src/components/base/BaseSelect.vue | 38 +-- web/src/components/base/BaseTable/columns.ts | 25 +- web/src/components/base/BaseTable/index.vue | 52 ++-- .../components/base/BaseTable/pagination.ts | 8 +- web/src/components/charts/BaseChart.vue | 49 ++-- web/src/composables/useJsonDownload.ts | 28 ++ web/src/layout/components/AppSidebar.vue | 4 +- web/src/stores/modules/ui.ts | 41 ++- web/src/utils/async.ts | 4 +- web/src/utils/clipboard.ts | 39 --- .../components/AccountCreateModal.vue | 40 ++- .../components/AccountIdentityCell.vue | 3 +- .../accounts/components/AccountQuotaPanel.vue | 3 +- .../components/AccountQuotaSummaryCell.vue | 3 +- .../composables/useAccountConnectionTest.ts | 7 +- .../accounts/composables/useAccountFilters.ts | 25 +- .../composables/useAccountMutations.ts | 37 ++- web/src/views/accounts/index.vue | 13 +- .../api-keys/composables/useApiKeyFilters.ts | 7 +- .../composables/useApiKeyMutations.ts | 15 +- .../components/AccountOverviewCard.vue | 3 +- .../dashboard/composables/useDashboard.ts | 34 +-- .../views/logs/composables/useLogFilters.ts | 25 +- .../views/logs/composables/useLogsTable.ts | 7 +- web/src/views/settings/index.vue | 8 +- web/vite.config.ts | 12 + 41 files changed, 826 insertions(+), 445 deletions(-) delete mode 100644 docs/deleted-code-feature-parity-audit.md create mode 100644 src/admin/accounts/service/exporting.rs create mode 100644 tests/admin/accounts/exporting.rs create mode 100644 web/src/composables/useJsonDownload.ts delete mode 100644 web/src/utils/clipboard.ts diff --git a/docs/deleted-code-feature-parity-audit.md b/docs/deleted-code-feature-parity-audit.md deleted file mode 100644 index 40ab42221..000000000 --- a/docs/deleted-code-feature-parity-audit.md +++ /dev/null @@ -1,127 +0,0 @@ -# 删除代码功能等价审计 - -审计日期:2026-06-28 - -对照基准: - -- 当前 Rust 工程:`/home/zyy/Codes/codex-proxy-rs` -- TS 参考工程:`/home/zyy/桌面/Codes/codex-proxy` - -本文档只审计近期删除或收敛的功能是否造成产品能力缺失。结论不以文件名为准,而以当前路由、服务实现和 TS 版已存在能力为准。 - -## 总体结论 - -当前删除并不全是问题。部分删除符合当前产品方向:后台设置减少人工配置、日志策略固定、接口去掉路径 id、账号导入增强为 CPR/Sub2API 双格式并支持重复账号更新,这些属于合理收敛。 - -但有几类能力被删后没有等价替代,会影响管理闭环: - -1. 账号导出缺失,导入和备份迁移不闭环。 -2. 批量账号状态更新缺失,账号池批量维护效率下降。 -3. 批量健康检查缺失,目前只剩单账号模型测试。 -4. 手动 Cookie 维护缺失,Cloudflare 人工旁路能力不完整。 -5. 独立用量统计接口缺失,当前只被 Dashboard 和账号列表部分吸收。 -6. 手动模型刷新缺失,后台只剩启动后和周期任务刷新。 -7. 客户端 API Key 导出缺失,密钥备份/迁移能力被削弱。 - -## 当前 Rust 管理端路由事实 - -当前 `src/admin/router.rs` 保留的管理端接口: - -- 登录会话:`/api/admin/login`、`/api/admin/auth/status`、`/api/admin/logout` -- 设置:`/api/admin/settings` -- 概览:`/api/admin/dashboard/summary`、`/api/admin/dashboard/trend` -- 账号:`/api/admin/accounts`、`/api/admin/accounts/import`、`/api/admin/accounts/oauth/authorize`、`/api/admin/accounts/oauth/exchange`、`/api/admin/accounts/test`、`/api/admin/accounts/models`、`/api/admin/accounts/delete`、`/api/admin/accounts/update`、`/api/admin/accounts/refresh`、`/api/admin/accounts/quota` -- 日志:`/api/admin/logs`、`/api/admin/logs/delete`、`/api/admin/logs/detail` -- 客户端 API Key:`/api/admin/keys`、`/api/admin/keys/delete`、`/api/admin/keys/update` - -已从路由层移除的旧 Rust 管理端接口: - -- `/api/admin/models/refresh` -- `/api/admin/usage` -- `/api/admin/usage/summary` -- `/api/admin/accounts/export` -- `/api/admin/accounts/quota-warnings` -- `/api/admin/accounts/health-check` -- `/api/admin/accounts/reset-usage` -- `/api/admin/accounts/cookies` -- `/api/admin/logs/state` -- `/api/admin/keys/export` - -## 逐项审计 - -| 能力 | Rust 当前状态 | TS 参考状态 | 结论 | 建议 | -| --- | --- | --- | --- | --- | -| 账号导入 | 已保留并增强。`src/admin/accounts/service/importing.rs` 支持 CPR/Sub2API,重复账号会按 ChatGPT identity 更新,缺少字段时会补充 account id、user id、email、quota。 | `src/routes/accounts.ts` 支持 `/auth/accounts/import`。 | 不缺失。当前 Rust 实现比旧导入更贴近当前产品。 | 保留当前实现。 | -| 账号导出 | 当前路由已删除 `/api/admin/accounts/export`,旧 `export_accounts` 和 `export_with_tokens` 不再暴露。 | `src/routes/accounts.ts` 有 `/auth/accounts/export`,支持按 ids 和 format 导出。 | 功能缺失。导入存在但导出消失,备份、迁移、批量排障不闭环。 | P1 恢复,格式只保留 CPR,避免旧 native 命名和兼容分支。 | -| 批量删除账号 | 当前保留 `/api/admin/accounts/delete`。 | TS 有 `/auth/accounts/batch-delete`。 | 不缺失。 | 保留。 | -| 批量状态更新 | 当前 `/api/admin/accounts/update` 只解析单账号 `id` 更新,旧 `batch_update_status` 已删除。 | TS 有 `/auth/accounts/batch-status`。 | 功能缺失。批量禁用、恢复账号池时需要逐个操作。 | P1 恢复为 `/api/admin/accounts/update` 的批量 data 形式,保持无路径 id。 | -| 单账号刷新/额度查询 | 当前保留 `/api/admin/accounts/refresh` 和 `/api/admin/accounts/quota`,id 放在 body 或 query。 | TS 有 `/auth/accounts/:id/refresh`、`/auth/accounts/:id/quota`。 | 不缺失,且当前 Rust 路由形态更符合项目规范。 | 保留当前形态。 | -| 重置账号用量 | 当前删除 `/api/admin/accounts/reset-usage`,未发现等价管理端入口。 | TS 有 `/auth/accounts/:id/reset-usage`。 | 功能缺失但不是核心链路。主要影响调试、演示和清理异常统计。 | P3 低优先级恢复,或明确产品不提供手动重置。 | -| 批量健康检查 | 当前删除 `/api/admin/accounts/health-check`,只保留 `/api/admin/accounts/test?id=...` 单账号 SSE 模型测试。 | TS 有 `/auth/accounts/health-check`,支持 ids、stagger、concurrency。 | 功能缺失。单账号测试不能替代账号池健康巡检。 | P1 恢复批量健康检查,复用现有单账号探测和 loading 流程。 | -| 测试模型列表 | 当前保留 `/api/admin/accounts/models?id=...`,从上游返回模型。 | TS 模型列表能力来自模型 catalog 和上游刷新。 | 不缺失。 | 保留当前接口名 `models`。 | -| OAuth 添加账号 | 当前新增 `/api/admin/accounts/oauth/authorize`、`/api/admin/accounts/oauth/exchange`。 | TS 有 `/auth/accounts/login` 和 OAuth 链路。 | 不缺失。Rust 当前已按后台弹窗流程实现。 | 保留。 | -| 手动 Cookie 维护 | 当前删除 `/api/admin/accounts/cookies`。Cookie 存储仍存在,Cloudflare 逻辑会删除账号 cookie,但没有管理端查看/写入入口。 | TS 有 `/auth/accounts/:id/cookies` GET/POST/DELETE,并在 quota 失败时提示写入 cf cookie。 | 功能缺失。遇到 Cloudflare 挑战时缺少人工修复入口。 | P2 恢复,接口保持无路径 id,例如 `GET/POST /api/admin/accounts/cookies?id=...`,删除可并入 POST action 或独立 delete。 | -| 配额告警列表 | 当前删除 `/api/admin/accounts/quota-warnings`。账号列表 summary 仍有 high_usage/attention,但没有独立 warning 列表。 | TS 有 `/auth/quota/warnings`。 | 部分缺失。列表页摘要不能替代可点击、可定位、可清理的告警集合。 | P2 视 UI 是否需要恢复;如果不做告警中心,应在产品上明确删除。 | -| 独立账号用量分页 | 当前删除 `/api/admin/usage`,账号列表会内嵌账号用量,Dashboard 会读取 usage summary 和 buckets。 | TS 有 `/admin/usage-stats/summary` 与 `/admin/usage-stats/history`。 | 部分缺失。概览和账号列表替代了展示,但没有独立用量页/API。 | P2 如果后续有用量分析页,应恢复为 `/api/admin/usage` 和 `/api/admin/usage/summary`;否则可接受删除。 | -| 用量历史 | 当前 Dashboard 趋势读 `usage_time_buckets`,但没有通用 history API。 | TS 有 `/admin/usage-stats/history`,支持 granularity 和 hours。 | 部分缺失。当前只服务 Dashboard,不适合复用到独立分析页面。 | P2 如要开放分析视图,新增 `/api/admin/usage/history`,直接读聚合事实表。 | -| 手动模型刷新 | 当前删除 `/api/admin/models/refresh`,但 `ModelRefreshTask` 会启动后和每小时刷新,并有 `refresh_once()`。 | TS 有 `/admin/refresh-models` 手动触发。 | 功能缺失但已有后台替代。问题在于管理端无法立即刷新模型目录。 | P2 恢复轻量接口,直接调用现有 `refresh_once()` 或后台任务句柄,避免再引入 `admin_models` 旧模块。 | -| OpenAI 模型列表 | 当前 `/v1/models`、`/v1/models/catalog`、`/v1/models/{model}`、`/v1/models/{model}/info` 仍存在。 | TS 有同类模型路由。 | 不缺失。 | 保留。 | -| 日志列表/详情/清空 | 当前保留 `/api/admin/logs`、`/api/admin/logs/detail`、`/api/admin/logs/delete`。 | TS 有 `/admin/logs`、`/admin/logs/:id`、`/admin/logs/clear`。 | 不缺失。Rust 当前筛选字段更丰富。 | 保留当前形态。 | -| 日志开关状态 | 当前删除 `/api/admin/logs/state`。 | TS 有 `/admin/logs/state` GET/POST。 | 不算缺失。此前产品已决定日志采集和容量走默认策略,不在系统设置暴露。 | 不恢复。 | -| 设置项大而全 | 当前 `/api/admin/settings` 只保留 modelAliases、modelAccountRoutes、refreshMarginSeconds、refreshConcurrency、maxConcurrentPerAccount、requestIntervalMs、rotationStrategy。 | TS `general-settings`/`quota-settings` 暴露端口、TLS、默认模型、日志容量、更新策略等大量字段。 | 不算缺失。当前产品已收敛为只保留需要人工决策的运行配置。 | 不恢复 TS 大而全设置页。 | -| 客户端 API Key 列表/创建/更新/删除 | 当前保留 `/api/admin/keys`、`/api/admin/keys/update`、`/api/admin/keys/delete`,并保存完整 `sk_` key 供复制。 | TS 的 `/auth/api-keys` 是第三方 provider key pool,领域不同。 | 当前核心能力不缺失。 | 保留当前“客户端访问密钥”定位,不混入 provider key pool。 | -| 客户端 API Key 导出 | 当前删除 `/api/admin/keys/export`。旧 Rust 实现曾导出 `rustLocalClientApiKeys` 并标记 `rotation_required`。 | TS provider key pool 有 `/auth/api-keys/export`。 | 功能缺失但需谨慎。当前项目已允许保存完整 key,导出可以服务备份;但也会扩大密钥泄露面。 | P3 如恢复,只做管理员显式导出,并使用 CPR 格式;不恢复旧 `rustLocalClientApiKeys` 命名。 | - -## 不建议恢复的删除项 - -以下删除符合当前项目方向,不建议回滚: - -1. `/api/admin/logs/state`:日志策略已固定,系统设置不再暴露日志采集和容量。 -2. TS 版 `general-settings` 中的默认模型、默认推理强度、默认 service tier:这些应由客户端请求决定,网关不兜底。 -3. TS 版模型 aliases 的旧配置形态:当前 Rust 已拆成 `modelAliases` 和 `modelAccountRoutes`,边界更清楚。 -4. 路径中携带 id 的管理端接口形态:当前 Rust 统一使用 query/body 传 id,更符合近期接口规范。 -5. 第三方 provider API Key pool:当前 Rust 的 API Key 是客户端访问密钥,不应混入 TS 版 provider key pool 概念。 - -## 建议恢复优先级 - -P1:应该优先补齐,直接影响管理闭环。 - -- 账号导出:`GET /api/admin/accounts/export` -- 批量账号状态更新:复用 `/api/admin/accounts/update` -- 批量健康检查:`POST /api/admin/accounts/health-check` - -P2:影响运维体验和可诊断性,建议排第二批。 - -- 手动 Cookie 维护:`GET/POST /api/admin/accounts/cookies` -- 配额告警列表:`GET /api/admin/accounts/quota-warnings` -- 独立用量统计和历史:`GET /api/admin/usage`、`GET /api/admin/usage/summary`、可选 `GET /api/admin/usage/history` -- 手动模型刷新:`POST /api/admin/models/refresh` - -P3:低频能力,可先产品确认。 - -- 重置账号用量:`POST /api/admin/accounts/reset-usage` -- 客户端 API Key 导出:`GET /api/admin/keys/export` - -## 恢复原则 - -如果恢复上述能力,不应该把旧代码原样搬回来。需要按当前项目规范重写: - -1. 不在路径中放 id,id 统一放 query 或 body。 -2. 不恢复兼容命名,例如 `native`、`rustLocalClientApiKeys`。 -3. 不恢复大而全的 settings/config 写回逻辑。 -4. 不为了兼容旧数据增加迁移分支;已有开发库需要重建。 -5. 账号导出格式只使用 CPR,和当前导入格式闭环。 -6. 批量操作必须返回 `updated/deleted/notFound` 这类可被 UI 准确呈现的数据。 -7. 涉及真实网络请求的操作必须有后端测试覆盖,包括成功、部分失败、空 ids、未登录。 - -## 需要补的测试 - -当前被删除的测试里包含 `tests/admin/models/*`、`tests/admin/keys/import_export.rs`、旧账号 import/export 测试等。若恢复能力,至少补回以下测试面: - -- 账号导出:全部导出、按 ids 导出、无效格式、未登录。 -- 批量状态:部分 id 不存在、空 ids、非法 status、状态写入后账号列表可见。 -- 批量健康检查:成功、失败、跳过、并发参数校验。 -- Cookie 维护:写入、读取、清空、账号不存在。 -- 用量接口:分页、汇总、空数据库。 -- 模型刷新:无账号、部分计划失败、成功刷新并写入模型快照。 -- API Key 导出:未登录、空列表、导出字段包含完整 key。 diff --git a/src/admin/accounts/routes.rs b/src/admin/accounts/routes.rs index 6827dfe43..8b76e9e2e 100644 --- a/src/admin/accounts/routes.rs +++ b/src/admin/accounts/routes.rs @@ -77,6 +77,12 @@ pub(crate) struct AccountIdQuery { id: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AccountExportQuery { + ids: Option, +} + #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct AccountTestRequest { @@ -491,6 +497,27 @@ pub(crate) async fn create_account( } } +/// `GET /api/admin/accounts/export` +pub(crate) async fn export_accounts( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result { + require_admin_session(&state, &headers).await?; + match state + .services + .admin_accounts + .export(account_export_ids(query.ids.as_deref())) + .await + { + Ok(result) => Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(result), + )), + Err(error) => Err(account_error(&error)), + } +} + /// `POST /api/admin/accounts/refresh` pub(crate) async fn refresh_account( State(state): State, @@ -1401,6 +1428,16 @@ fn account_not_found() -> AdminError { AdminError::new(StatusCode::NOT_FOUND, 40401, "Account not found") } +fn account_export_ids(value: Option<&str>) -> Vec { + value + .into_iter() + .flat_map(|ids| ids.split(',')) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(ToString::to_string) + .collect() +} + fn account_status_str(status: crate::upstream::accounts::model::AccountStatus) -> &'static str { match status { crate::upstream::accounts::model::AccountStatus::Active => "active", diff --git a/src/admin/accounts/service/exporting.rs b/src/admin/accounts/service/exporting.rs new file mode 100644 index 000000000..4d1e4fa8f --- /dev/null +++ b/src/admin/accounts/service/exporting.rs @@ -0,0 +1,97 @@ +use secrecy::ExposeSecret; + +use super::{ + types::{ExportedAccount, ExportedAccounts}, + AdminAccountError, AdminAccountService, +}; +use crate::{ + infra::time::china_rfc3339_str, + upstream::accounts::{model::AccountStatus, store::StoredAccount}, +}; + +const EXPORT_PAGE_LIMIT: u32 = 200; + +impl AdminAccountService { + pub async fn export(&self, ids: Vec) -> Result { + let accounts = if ids.is_empty() { + self.export_all_accounts().await? + } else { + self.export_accounts_by_id(ids).await? + }; + + Ok(ExportedAccounts { + source_format: "cpr", + accounts: accounts.into_iter().map(ExportedAccount::from).collect(), + }) + } + + async fn export_all_accounts(&self) -> Result, AdminAccountError> { + let mut accounts = Vec::new(); + let mut cursor = None; + loop { + let page = self + .store + .list(cursor, EXPORT_PAGE_LIMIT) + .await + .map_err(|_| AdminAccountError::Export)?; + accounts.extend(page.items); + let Some(next_cursor) = page.next_cursor else { + return Ok(accounts); + }; + cursor = Some(next_cursor); + } + } + + async fn export_accounts_by_id( + &self, + ids: Vec, + ) -> Result, AdminAccountError> { + let mut accounts = Vec::with_capacity(ids.len()); + for id in ids { + let Some(account) = self + .store + .get(&id) + .await + .map_err(|_| AdminAccountError::Export)? + else { + return Err(AdminAccountError::NotFound); + }; + accounts.push(account); + } + Ok(accounts) + } +} + +impl From for ExportedAccount { + fn from(account: StoredAccount) -> Self { + Self { + id: account.id, + email: account.email, + account_id: account.account_id, + user_id: account.user_id, + label: account.label, + plan_type: account.plan_type, + token: account.access_token.expose_secret().to_string(), + refresh_token: account + .refresh_token + .map(|token| token.expose_secret().to_string()), + access_token_expires_at: account + .access_token_expires_at + .map(|value| value.to_rfc3339()), + status: export_status_str(account.status).to_string(), + added_at: china_rfc3339_str(&account.added_at), + updated_at: china_rfc3339_str(&account.updated_at), + } + } +} + +fn export_status_str(status: AccountStatus) -> &'static str { + match status { + AccountStatus::Active => "active", + AccountStatus::Expired => "expired", + AccountStatus::QuotaExhausted => "quota_exhausted", + AccountStatus::Refreshing => "refreshing", + AccountStatus::Disabled => "disabled", + AccountStatus::Banned => "banned", + } +} diff --git a/src/admin/accounts/service/mod.rs b/src/admin/accounts/service/mod.rs index d1d170753..11ad8fa34 100644 --- a/src/admin/accounts/service/mod.rs +++ b/src/admin/accounts/service/mod.rs @@ -1,6 +1,7 @@ //! 管理端账号业务服务。 mod cookies; +mod exporting; mod importing; mod lifecycle; pub(crate) mod oauth; @@ -22,7 +23,8 @@ use crate::{ pub use types::{ AdminAccountError, AdminAccountMetadata, AdminAccountMetadataUpdate, BatchDeleteAccounts, - ImportedAccounts, OAuthAuthorizeResult, OAuthExchangeInput, UpdatedAccountStatus, + ExportedAccounts, ImportedAccounts, OAuthAuthorizeResult, OAuthExchangeInput, + UpdatedAccountStatus, }; #[derive(Clone)] diff --git a/src/admin/accounts/service/types.rs b/src/admin/accounts/service/types.rs index 7806f59a9..3e18401cb 100644 --- a/src/admin/accounts/service/types.rs +++ b/src/admin/accounts/service/types.rs @@ -1,6 +1,7 @@ //! 管理端账号 DTO、错误与内部转换辅助。 use chrono::{DateTime, Utc}; +use serde::Serialize; use thiserror::Error; use crate::{ @@ -56,6 +57,8 @@ pub enum AdminAccountError { List, #[error("failed to import accounts")] Import, + #[error("failed to export accounts")] + Export, #[error("failed to inspect account")] Inspect, #[error("account not found")] @@ -150,6 +153,30 @@ pub struct ImportedAccounts { pub source_format: &'static str, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportedAccount { + pub id: String, + pub email: Option, + pub account_id: Option, + pub user_id: Option, + pub label: Option, + pub plan_type: Option, + pub token: String, + pub refresh_token: Option, + pub access_token_expires_at: Option, + pub status: String, + pub added_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportedAccounts { + pub source_format: &'static str, + pub accounts: Vec, +} + #[derive(Debug, Clone)] pub struct OAuthAuthorizeResult { pub session_id: String, diff --git a/src/admin/router.rs b/src/admin/router.rs index ac0dcb653..d69516a4c 100644 --- a/src/admin/router.rs +++ b/src/admin/router.rs @@ -10,8 +10,8 @@ use crate::runtime::state::AppState; use super::{ accounts::routes::{ account_models, account_quota, accounts, batch_delete_accounts, create_account, - import_accounts, oauth_authorize_account, oauth_exchange_account, refresh_account, - test_account_connection, update_account, + export_accounts, import_accounts, oauth_authorize_account, oauth_exchange_account, + refresh_account, test_account_connection, update_account, }, auth::session::{login, logout, session_status}, keys::routes::{api_keys, batch_delete_api_keys, create_api_key, update_api_key}, @@ -32,6 +32,7 @@ pub fn router() -> Router { .route("/api/admin/dashboard/summary", get(dashboard_summary)) .route("/api/admin/dashboard/trend", get(dashboard_trend)) .route("/api/admin/accounts", get(accounts).post(create_account)) + .route("/api/admin/accounts/export", get(export_accounts)) .route("/api/admin/accounts/import", post(import_accounts)) .route( "/api/admin/accounts/oauth/authorize", diff --git a/tests/admin/accounts/exporting.rs b/tests/admin/accounts/exporting.rs new file mode 100644 index 000000000..779e4c3bc --- /dev/null +++ b/tests/admin/accounts/exporting.rs @@ -0,0 +1,251 @@ +use super::*; + +#[tokio::test] +async fn admin_accounts_export_should_return_cpr_payload_for_all_accounts() { + let (app, _state, pool, _dir) = + admin_accounts_test_app("admin-accounts-export-all.sqlite", 131).await; + seed_account( + &pool, + NewAccount { + id: "acct_export_a".into(), + email: Some("export-a@example.com".into()), + account_id: Some("chatgpt_export_a".into()), + user_id: Some("user_export_a".into()), + label: Some("primary".into()), + plan_type: Some("team".into()), + access_token: SecretString::new( + test_jwt( + "chatgpt_export_a", + Some("user_export_a"), + Some("export-a@example.com"), + Some("team"), + ) + .into(), + ), + refresh_token: Some(SecretString::new("refresh-export-a".into())), + access_token_expires_at: None, + status: AccountStatus::Active, + added_at: None, + }, + ) + .await; + seed_account( + &pool, + NewAccount { + id: "acct_export_b".into(), + email: Some("export-b@example.com".into()), + account_id: Some("chatgpt_export_b".into()), + user_id: Some("user_export_b".into()), + label: Some("backup".into()), + plan_type: Some("plus".into()), + access_token: SecretString::new( + test_jwt( + "chatgpt_export_b", + Some("user_export_b"), + Some("export-b@example.com"), + Some("plus"), + ) + .into(), + ), + refresh_token: Some(SecretString::new("refresh-export-b".into())), + access_token_expires_at: None, + status: AccountStatus::Disabled, + added_at: None, + }, + ) + .await; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts/export") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let accounts = body["data"]["accounts"].as_array().unwrap(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["data"]["sourceFormat"], "cpr"); + assert_eq!(accounts.len(), 2); + assert!(accounts.iter().any(|account| { + account["id"] == "acct_export_a" + && account["token"] + .as_str() + .is_some_and(|token| !token.is_empty()) + && account["refreshToken"] == "refresh-export-a" + })); +} + +#[tokio::test] +async fn admin_accounts_export_should_filter_by_ids() { + let (app, _state, pool, _dir) = + admin_accounts_test_app("admin-accounts-export-filter.sqlite", 132).await; + seed_account( + &pool, + NewAccount { + id: "acct_export_selected".into(), + email: Some("selected@example.com".into()), + account_id: Some("chatgpt_export_selected".into()), + user_id: Some("user_export_selected".into()), + label: None, + plan_type: Some("pro".into()), + access_token: SecretString::new( + test_jwt( + "chatgpt_export_selected", + Some("user_export_selected"), + Some("selected@example.com"), + Some("pro"), + ) + .into(), + ), + refresh_token: Some(SecretString::new("refresh-selected".into())), + access_token_expires_at: None, + status: AccountStatus::Active, + added_at: None, + }, + ) + .await; + seed_account( + &pool, + NewAccount { + id: "acct_export_ignored".into(), + email: Some("ignored@example.com".into()), + account_id: Some("chatgpt_export_ignored".into()), + user_id: Some("user_export_ignored".into()), + label: None, + plan_type: Some("free".into()), + access_token: SecretString::new( + test_jwt( + "chatgpt_export_ignored", + Some("user_export_ignored"), + Some("ignored@example.com"), + Some("free"), + ) + .into(), + ), + refresh_token: Some(SecretString::new("refresh-ignored".into())), + access_token_expires_at: None, + status: AccountStatus::Active, + added_at: None, + }, + ) + .await; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts/export?ids=acct_export_selected") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json(response).await; + let accounts = body["data"]["accounts"].as_array().unwrap(); + + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0]["id"], "acct_export_selected"); +} + +#[tokio::test] +async fn admin_accounts_export_should_fail_when_selected_id_is_missing() { + let (app, _state, _pool, _dir) = + admin_accounts_test_app("admin-accounts-export-missing.sqlite", 133).await; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts/export?ids=missing_account") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn admin_accounts_export_payload_should_import_directly() { + let (source_app, _source_state, source_pool, _source_dir) = + admin_accounts_test_app("admin-accounts-export-source.sqlite", 134).await; + seed_account( + &source_pool, + NewAccount { + id: "acct_export_roundtrip".into(), + email: Some("roundtrip@example.com".into()), + account_id: Some("chatgpt_export_roundtrip".into()), + user_id: Some("user_export_roundtrip".into()), + label: Some("roundtrip".into()), + plan_type: Some("team".into()), + access_token: SecretString::new( + test_jwt( + "chatgpt_export_roundtrip", + Some("user_export_roundtrip"), + Some("roundtrip@example.com"), + Some("team"), + ) + .into(), + ), + refresh_token: Some(SecretString::new("refresh-roundtrip".into())), + access_token_expires_at: None, + status: AccountStatus::Active, + added_at: None, + }, + ) + .await; + let exported = response_json( + source_app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts/export?ids=acct_export_roundtrip") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(), + ) + .await; + let payload = exported["data"].clone(); + + let (target_app, _target_state, target_pool, _target_dir) = + admin_accounts_test_app("admin-accounts-export-target.sqlite", 135).await; + let response = target_app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/import") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from(payload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let stored = SqliteAccountStore::new(target_pool) + .get("acct_export_roundtrip") + .await + .unwrap() + .unwrap(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["data"]["imported"], 1); + assert_eq!( + stored.refresh_token.unwrap().expose_secret(), + "refresh-roundtrip" + ); +} diff --git a/tests/admin/accounts/mod.rs b/tests/admin/accounts/mod.rs index 924a92cde..b9df27ac2 100644 --- a/tests/admin/accounts/mod.rs +++ b/tests/admin/accounts/mod.rs @@ -28,6 +28,7 @@ use tower::util::ServiceExt; use crate::support::jwt::unsigned_jwt; use crate::support::{admin::seed_admin_session, config::test_config, http::response_json}; +mod exporting; mod importing; mod lifecycle; mod list; diff --git a/web/index.html b/web/index.html index a3c87d88c..2076e3544 100644 --- a/web/index.html +++ b/web/index.html @@ -7,12 +7,25 @@ Codex Proxy RS diff --git a/web/package.json b/web/package.json index d206345f5..5d2a026b0 100644 --- a/web/package.json +++ b/web/package.json @@ -15,9 +15,11 @@ "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@lucide/vue": "^1.21.0", + "@vueuse/core": "^14.3.0", "axios": "^1.18.1", "dayjs": "^1.11.21", "echarts": "^6.1.0", + "es-toolkit": "^1.49.0", "gsap": "^3.15.0", "pinia": "^3.0.4", "pinia-plugin-persistedstate": "^4.7.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 88a1a49cd..7cc68388c 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@lucide/vue': specifier: ^1.21.0 version: 1.21.0(vue@3.5.39(typescript@6.0.3)) + '@vueuse/core': + specifier: ^14.3.0 + version: 14.3.0(vue@3.5.39(typescript@6.0.3)) axios: specifier: ^1.18.1 version: 1.18.1 @@ -26,6 +29,9 @@ importers: echarts: specifier: ^6.1.0 version: 6.1.0 + es-toolkit: + specifier: ^1.49.0 + version: 1.49.0 gsap: specifier: ^3.15.0 version: 3.15.0 @@ -356,6 +362,9 @@ packages: '@types/node@26.0.1': resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@vitejs/plugin-vue@6.0.7': resolution: {integrity: sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -442,6 +451,19 @@ packages: vue: optional: true + '@vueuse/core@14.3.0': + resolution: {integrity: sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/metadata@14.3.0': + resolution: {integrity: sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==} + + '@vueuse/shared@14.3.0': + resolution: {integrity: sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==} + peerDependencies: + vue: ^3.5.0 + acorn@8.17.0: resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} engines: {node: '>=0.4.0'} @@ -550,6 +572,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.49.0: + resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -1219,6 +1244,8 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/web-bluetooth@0.0.21': {} + '@vitejs/plugin-vue@6.0.7(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -1347,6 +1374,19 @@ snapshots: typescript: 6.0.3 vue: 3.5.39(typescript@6.0.3) + '@vueuse/core@14.3.0(vue@3.5.39(typescript@6.0.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.3.0 + '@vueuse/shared': 14.3.0(vue@3.5.39(typescript@6.0.3)) + vue: 3.5.39(typescript@6.0.3) + + '@vueuse/metadata@14.3.0': {} + + '@vueuse/shared@14.3.0(vue@3.5.39(typescript@6.0.3))': + dependencies: + vue: 3.5.39(typescript@6.0.3) + acorn@8.17.0: {} agent-base@6.0.2: @@ -1450,6 +1490,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 + es-toolkit@1.49.0: {} + estree-walker@2.0.2: {} exsolve@1.1.0: {} diff --git a/web/src/api/modules/accounts.ts b/web/src/api/modules/accounts.ts index a9579fb36..be36cb230 100644 --- a/web/src/api/modules/accounts.ts +++ b/web/src/api/modules/accounts.ts @@ -16,6 +16,14 @@ export function importAccounts(data: any) { }) } +export function exportAccounts(params?: any) { + return request({ + url: '/api/admin/accounts/export', + method: 'GET', + params, + }) +} + export function authorizeAccountOAuth(data?: any) { return request({ url: '/api/admin/accounts/oauth/authorize', diff --git a/web/src/components/base/BasePopover.vue b/web/src/components/base/BasePopover.vue index 83b96abe1..d93b5c61d 100644 --- a/web/src/components/base/BasePopover.vue +++ b/web/src/components/base/BasePopover.vue @@ -1,5 +1,13 @@