diff --git a/Cargo.lock b/Cargo.lock
index 4cd1af15f..84715fb2f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -343,8 +343,6 @@ dependencies = [
"tempfile",
"thiserror 2.0.18",
"tokio",
- "tokio-rustls",
- "tokio-stream",
"tokio-tungstenite",
"tower",
"tower-http",
diff --git a/Cargo.toml b/Cargo.toml
index 5a1a2ac21..169ae0004 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -28,13 +28,10 @@ rustls-pki-types = "1.14.1"
secrecy = "0.10.3"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
-serde_yml = "0.0.13"
sha2 = "0.11.0"
sqlx = { version = "0.9.0", features = ["runtime-tokio", "tls-rustls", "sqlite", "chrono", "uuid"] }
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "signal", "fs", "time", "sync", "net", "test-util"] }
-tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring", "tls12"] }
-tokio-stream = "0.1.17"
tokio-tungstenite = { version = "0.28.0", features = ["proxy", "rustls-tls-native-roots"] }
tower-http = { version = "0.6.11", features = ["trace", "cors", "request-id", "timeout"] }
tracing = "0.1.44"
@@ -44,6 +41,7 @@ tungstenite = { version = "0.27.0", features = ["deflate", "proxy"] }
uuid = { version = "1.23.3", features = ["v4", "v7", "serde"] }
[dev-dependencies]
+serde_yml = "0.0.13"
tempfile = "3.27.0"
tower = { version = "0.5.3", features = ["util"] }
wiremock = "0.6.5"
diff --git a/README.md b/README.md
index 94d17057b..fa7737cd5 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@ Codex Proxy RS 关注这些细节:
**Codex 账号池**
-- 支持 ChatGPT/Codex OAuth、device code、refresh token 账号接入
+- 支持 ChatGPT/Codex OAuth、CPR JSON、Sub2API JSON 和 refresh token 账号接入
- 支持账号轮转、并发控制、请求间隔和会话亲和性
- 支持账号状态、配额、Cookie、模型目录和连接测试管理
@@ -52,7 +52,7 @@ Codex Proxy RS 关注这些细节:
**可观测与管理**
- 管理控制台内置账号、密钥、事件日志、用量统计和系统设置
-- SQLite 持久化,敏感密钥加密存储
+- SQLite 持久化,账号、Cookie 与访问密钥采用统一的本地存储策略
- 结构化事件日志,便于定位请求失败、上游状态和调度路径
## 快速开始
@@ -69,7 +69,7 @@ admin:
default_password: admin
```
-长期运行前请修改默认密码,并确认数据库、密钥和日志路径符合你的部署环境。
+长期运行前请修改默认密码,并确认数据库和日志路径符合你的部署环境。
运行时数据默认写入 `.runtime`:
@@ -142,7 +142,7 @@ flowchart LR
proxy --> codex[codex
Codex 协议、模型和上游传输]
http --> admin[admin
管理端 API]
admin --> runtime[runtime
启动装配、状态和后台任务]
- runtime --> infra[infra
SQLite、加密、路径和日志]
+ runtime --> infra[infra
SQLite、路径和日志]
proxy --> telemetry[telemetry
事件日志和用量记录]
admin --> telemetry
http --> web[web
管理控制台静态资源]
diff --git a/docs/deleted-code-feature-parity-audit.md b/docs/deleted-code-feature-parity-audit.md
new file mode 100644
index 000000000..40ab42221
--- /dev/null
+++ b/docs/deleted-code-feature-parity-audit.md
@@ -0,0 +1,127 @@
+# 删除代码功能等价审计
+
+审计日期: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 69684f2d5..6827dfe43 100644
--- a/src/admin/accounts/routes.rs
+++ b/src/admin/accounts/routes.rs
@@ -13,7 +13,6 @@ use axum::{
use std::collections::HashMap;
use chrono::{DateTime, Utc};
-use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -31,11 +30,11 @@ use crate::{
http::middleware::request_id::RequestId,
infra::{
json::{clamp_limit, clamp_page, total_pages, Page},
- time::{china_datetime, china_relative_time, china_rfc3339, china_rfc3339_str},
+ time::{china_datetime, china_relative_time, china_rfc3339},
},
runtime::state::AppState,
upstream::accounts::model::AccountStatus,
- upstream::accounts::store::{AccountModelUsageRecord, StoredAccount},
+ upstream::accounts::store::AccountModelUsageRecord,
};
const ACCOUNT_STATS_PAGE_LIMIT: u32 = 200;
@@ -43,10 +42,6 @@ const FIVE_HOUR_WINDOW_SECONDS: u64 = 18_000;
const WEEK_WINDOW_SECONDS: u64 = 604_800;
const MONTH_WINDOW_SECONDS: u64 = 2_592_000;
-// ============================================================================
-// Query / Request types
-// ============================================================================
-
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AccountsQuery {
@@ -70,13 +65,6 @@ pub(crate) struct BatchDeleteAccountsRequest {
ids: Vec,
}
-#[derive(Debug, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub(crate) struct SetAccountCookiesRequest {
- id: String,
- cookies: Value,
-}
-
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AccountActionRequest {
@@ -89,21 +77,6 @@ pub(crate) struct AccountIdQuery {
id: String,
}
-#[derive(Debug, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub(crate) struct AccountExportQuery {
- ids: Option,
- format: Option,
-}
-
-#[derive(Debug, Default, Deserialize)]
-#[serde(rename_all = "camelCase", deny_unknown_fields)]
-pub(crate) struct HealthCheckRequest {
- ids: Option>,
- stagger_ms: Option,
- concurrency: Option,
-}
-
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AccountTestRequest {
@@ -122,21 +95,17 @@ pub(crate) struct AccountOAuthExchangeRequest {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
-pub(crate) struct AccountTestModelsData {
- models: Vec,
+struct AccountModelsData {
+ models: Vec,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
-pub(crate) struct AccountTestModelData {
+struct AccountModelData {
id: String,
label: String,
}
-// ============================================================================
-// Response types
-// ============================================================================
-
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AdminAccountData {
@@ -415,74 +384,10 @@ struct BatchDeleteAccountsData {
not_found: Vec,
}
-#[derive(Debug, Clone, Serialize)]
-#[serde(rename_all = "camelCase")]
-struct BatchUpdateAccountStatusData {
- updated: u32,
- not_found: Vec,
-}
-
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
enum AccountUpdateData {
Account(Box),
- BatchStatus(BatchUpdateAccountStatusData),
-}
-
-#[derive(Debug, Clone, Serialize)]
-#[serde(rename_all = "camelCase")]
-struct ResetAccountUsageData {
- id: String,
- reset: bool,
-}
-
-#[derive(Debug, Clone, Serialize)]
-#[serde(rename_all = "camelCase")]
-struct AccountCookiesData {
- cookies: Option,
-}
-
-#[derive(Debug, Clone, Serialize)]
-#[serde(rename_all = "camelCase")]
-struct AdminAccountExportData {
- id: String,
- email: Option,
- account_id: Option,
- user_id: Option,
- label: Option,
- plan_type: Option,
- status: String,
- access_token_expires_at: Option,
- added_at: String,
- updated_at: String,
- token: String,
- refresh_token: Option,
-}
-
-impl From for AdminAccountExportData {
- fn from(a: StoredAccount) -> Self {
- Self {
- id: a.id,
- email: a.email,
- account_id: a.account_id,
- user_id: a.user_id,
- label: a.label,
- plan_type: a.plan_type,
- status: account_status_str(a.status).to_string(),
- access_token_expires_at: a.access_token_expires_at.map(|dt| china_rfc3339(&dt)),
- added_at: china_rfc3339_str(&a.added_at),
- updated_at: china_rfc3339_str(&a.updated_at),
- token: a.access_token.expose_secret().to_string(),
- refresh_token: a.refresh_token.map(|t| t.expose_secret().to_string()),
- }
- }
-}
-
-#[derive(Debug, Clone, Serialize)]
-#[serde(rename_all = "camelCase")]
-struct AccountExportData {
- source_format: &'static str,
- accounts: Vec,
}
#[derive(Debug, Clone, Serialize)]
@@ -502,35 +407,13 @@ struct AccountOAuthAuthorizeData {
expires_at_display: String,
}
-#[derive(Debug, Clone, Serialize)]
-#[serde(rename_all = "camelCase")]
-struct HealthCheckData {
- summary: HealthCheckSummary,
- results: Vec,
-}
-
-#[derive(Debug, Clone, Copy, Serialize)]
-#[serde(rename_all = "camelCase")]
-struct HealthCheckSummary {
- total: usize,
- alive: usize,
- dead: usize,
- skipped: usize,
-}
-
-// ============================================================================
-// Handlers
-// ============================================================================
-
/// `GET /api/admin/accounts`
pub(crate) async fn accounts(
State(state): State,
- Extension(request_id): Extension,
headers: HeaderMap,
Query(params): Query,
) -> Result {
- let request_id = request_id.as_str().to_string();
- require_admin_session(&state, &headers, &request_id).await?;
+ require_admin_session(&state, &headers).await?;
let limit = clamp_limit(params.page_size.or(params.limit).unwrap_or(50));
let use_numbered_page = params.page.is_some() || params.page_size.is_some();
let stats = account_list_stats(&state).await;
@@ -559,7 +442,7 @@ pub(crate) async fn accounts(
AdminAccountPageEnvelope::numbered(page, summary),
))
}
- Err(error) => Err(account_error(error, request_id)),
+ Err(error) => Err(account_error(&error)),
};
}
@@ -583,19 +466,17 @@ pub(crate) async fn accounts(
AdminAccountPageEnvelope::cursor(page, limit, summary),
))
}
- Err(error) => Err(account_error(error, request_id)),
+ Err(error) => Err(account_error(&error)),
}
}
/// `POST /api/admin/accounts`
pub(crate) async fn create_account(
State(state): State,
- Extension(request_id): Extension,
headers: HeaderMap,
Json(payload): Json,
) -> Result {
- let request_id = request_id.as_str().to_string();
- require_admin_session(&state, &headers, &request_id).await?;
+ require_admin_session(&state, &headers).await?;
match state
.services
.admin_accounts
@@ -604,22 +485,20 @@ pub(crate) async fn create_account(
{
Ok(account) => Ok(AdminResponse::new(
StatusCode::OK,
- AdminEnvelope::ok(AdminAccountData::from(account), request_id),
+ AdminEnvelope::ok(AdminAccountData::from(account)),
)),
- Err(error) => Err(account_error(error, request_id)),
+ Err(error) => Err(account_error(&error)),
}
}
/// `POST /api/admin/accounts/refresh`
pub(crate) async fn refresh_account(
State(state): State,
- Extension(request_id): Extension,
headers: HeaderMap,
Json(payload): Json,
) -> Result {
- let request_id = request_id.as_str().to_string();
let account_id = payload.id;
- require_admin_session(&state, &headers, &request_id).await?;
+ require_admin_session(&state, &headers).await?;
match state
.services
.admin_accounts
@@ -628,101 +507,28 @@ pub(crate) async fn refresh_account(
{
Ok(account) => Ok(AdminResponse::new(
StatusCode::OK,
- AdminEnvelope::ok(AdminAccountData::from(account), request_id),
+ AdminEnvelope::ok(AdminAccountData::from(account)),
)),
- Err(error) => Err(account_error(error, request_id)),
- }
-}
-
-/// `POST /api/admin/accounts/reset-usage`
-pub(crate) async fn reset_account_usage(
- State(state): State,
- Extension(request_id): Extension,
- headers: HeaderMap,
- Json(payload): Json,
-) -> Result {
- let request_id = request_id.as_str().to_string();
- let account_id = payload.id;
- require_admin_session(&state, &headers, &request_id).await?;
- match state.services.admin_accounts.reset_usage(&account_id).await {
- Ok(account) => Ok(AdminResponse::new(
- StatusCode::OK,
- AdminEnvelope::ok(
- ResetAccountUsageData {
- id: account.id,
- reset: true,
- },
- request_id,
- ),
- )),
- Err(error) => Err(account_error(error, request_id)),
- }
-}
-
-/// `GET /api/admin/accounts/cookies`
-pub(crate) async fn get_account_cookies(
- State(state): State,
- Extension(request_id): Extension,
- headers: HeaderMap,
- Query(query): Query,
-) -> Result {
- let request_id = request_id.as_str().to_string();
- let account_id = query.id;
- require_admin_session(&state, &headers, &request_id).await?;
- match state.services.admin_accounts.cookies(&account_id).await {
- Ok(cookies) => Ok(AdminResponse::new(
- StatusCode::OK,
- AdminEnvelope::ok(AccountCookiesData { cookies }, request_id),
- )),
- Err(error) => Err(account_error(error, request_id)),
- }
-}
-
-/// `POST /api/admin/accounts/cookies`
-pub(crate) async fn set_account_cookies(
- State(state): State,
- Extension(request_id): Extension,
- headers: HeaderMap,
- Json(payload): Json,
-) -> Result {
- let request_id = request_id.as_str().to_string();
- let account_id = payload.id;
- require_admin_session(&state, &headers, &request_id).await?;
- match state
- .services
- .admin_accounts
- .set_cookies(&account_id, payload.cookies)
- .await
- {
- Ok(cookies) => Ok(AdminResponse::new(
- StatusCode::OK,
- AdminEnvelope::ok(AccountCookiesData { cookies }, request_id),
- )),
- Err(error) => Err(account_error(error, request_id)),
+ Err(error) => Err(account_error(&error)),
}
}
/// `GET /api/admin/accounts/quota`
pub(crate) async fn account_quota(
State(state): State,
- Extension(request_id): Extension,
headers: HeaderMap,
Query(query): Query,
) -> Result {
- let request_id = request_id.as_str().to_string();
let account_id = query.id;
- require_admin_session(&state, &headers, &request_id).await?;
+ require_admin_session(&state, &headers).await?;
match state
.services
.admin_accounts
.account_quota(&account_id)
.await
{
- Ok(data) => Ok(AdminResponse::new(
- StatusCode::OK,
- AdminEnvelope::ok(data, request_id),
- )),
- Err(AdminAccountError::NotFound) => Err(account_not_found(request_id)),
+ Ok(data) => Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data))),
+ Err(AdminAccountError::NotFound) => Err(account_not_found()),
Err(AdminAccountError::Inactive(status)) => Err(AdminError::new(
StatusCode::CONFLICT,
40901,
@@ -730,180 +536,63 @@ pub(crate) async fn account_quota(
"Account is {}, cannot query quota",
account_status_str(status)
),
- request_id,
)),
Err(AdminAccountError::FetchQuota(msg)) => Err(AdminError::new(
StatusCode::BAD_GATEWAY,
50201,
format!("Failed to fetch quota from Codex API: {msg}"),
- request_id,
)),
- Err(e) => Err(account_error(e, request_id)),
- }
-}
-
-/// `GET /api/admin/accounts/quota-warnings`
-pub(crate) async fn quota_warnings(
- State(state): State,
- Extension(request_id): Extension,
- headers: HeaderMap,
-) -> Result {
- let request_id = request_id.as_str().to_string();
- require_admin_session(&state, &headers, &request_id).await?;
- match state.services.admin_accounts.quota_warnings().await {
- Ok(data) => Ok(AdminResponse::new(
- StatusCode::OK,
- AdminEnvelope::ok(data, request_id),
- )),
- Err(error) => Err(account_error(error, request_id)),
- }
-}
-
-/// `POST /api/admin/accounts/health-check`
-pub(crate) async fn health_check_accounts(
- State(state): State,
- Extension(request_id): Extension,
- headers: HeaderMap,
- body: Bytes,
-) -> Result {
- let request_id = request_id.as_str().to_string();
- let payload = parse_health_check_request(&body, &request_id)?;
- validate_health_check_request(&payload, &request_id)?;
- require_admin_session(&state, &headers, &request_id).await?;
-
- let mut req = serde_json::json!({});
- if let Some(ids) = &payload.ids {
- req["ids"] = serde_json::json!(ids);
- }
-
- match state
- .services
- .admin_accounts
- .health_check_accounts(req)
- .await
- {
- Ok(result) => {
- let summary = HealthCheckSummary {
- total: result
- .get("summary")
- .and_then(|s| s.get("total").and_then(Value::as_u64))
- .unwrap_or(0) as usize,
- alive: result
- .get("summary")
- .and_then(|s| s.get("alive").and_then(Value::as_u64))
- .unwrap_or(0) as usize,
- dead: result
- .get("summary")
- .and_then(|s| s.get("dead").and_then(Value::as_u64))
- .unwrap_or(0) as usize,
- skipped: result
- .get("summary")
- .and_then(|s| s.get("skipped").and_then(Value::as_u64))
- .unwrap_or(0) as usize,
- };
- let results = result
- .get("results")
- .and_then(Value::as_array)
- .cloned()
- .unwrap_or_default();
- Ok(AdminResponse::new(
- StatusCode::OK,
- AdminEnvelope::ok(HealthCheckData { summary, results }, request_id),
- ))
- }
- Err(error) => Err(account_error(error, request_id)),
- }
-}
-
-/// `GET /api/admin/accounts/export`
-pub(crate) async fn export_accounts(
- State(state): State,
- Extension(request_id): Extension,
- headers: HeaderMap,
- Query(query): Query,
-) -> Result {
- let request_id = request_id.as_str().to_string();
- require_admin_session(&state, &headers, &request_id).await?;
- validate_account_export_format(query.format.as_deref())
- .map_err(|msg| AdminError::new(StatusCode::BAD_REQUEST, 40001, msg, request_id.clone()))?;
- let ids = account_export_ids(query.ids.as_deref());
- match state.services.admin_accounts.export_with_tokens(ids).await {
- Ok(accounts) => Ok(AdminResponse::new(
- StatusCode::OK,
- AdminEnvelope::ok(
- AccountExportData {
- source_format: "native",
- accounts: accounts
- .into_iter()
- .map(AdminAccountExportData::from)
- .collect(),
- },
- request_id,
- ),
- )),
- Err(error) => Err(account_error(error, request_id)),
+ Err(e) => Err(account_error(&e)),
}
}
/// `POST /api/admin/accounts/import`
pub(crate) async fn import_accounts(
State(state): State,
- Extension(request_id): Extension,
headers: HeaderMap,
Json(payload): Json,
) -> Result {
- let request_id = request_id.as_str().to_string();
- require_admin_session(&state, &headers, &request_id).await?;
+ require_admin_session(&state, &headers).await?;
match state.services.admin_accounts.import(payload).await {
Ok(result) => Ok(AdminResponse::new(
StatusCode::OK,
- AdminEnvelope::ok(
- AccountImportData {
- imported: result.imported,
- skipped: result.skipped,
- source_format: result.source_format.to_string(),
- },
- request_id,
- ),
+ AdminEnvelope::ok(AccountImportData {
+ imported: result.imported,
+ skipped: result.skipped,
+ source_format: result.source_format.to_string(),
+ }),
)),
- Err(error) => Err(account_error(error, request_id)),
+ Err(error) => Err(account_error(&error)),
}
}
/// `POST /api/admin/accounts/oauth/authorize`
pub(crate) async fn oauth_authorize_account(
State(state): State,
- Extension(request_id): Extension,
headers: HeaderMap,
) -> Result {
- let request_id = request_id.as_str().to_string();
- require_admin_session(&state, &headers, &request_id).await?;
+ require_admin_session(&state, &headers).await?;
match state.services.admin_accounts.oauth_authorize().await {
Ok(result) => Ok(AdminResponse::new(
StatusCode::OK,
- AdminEnvelope::ok(
- AccountOAuthAuthorizeData {
- session_id: result.session_id,
- auth_url: result.auth_url,
- expires_at: china_rfc3339(&result.expires_at),
- expires_at_display: china_datetime(&result.expires_at),
- },
- request_id,
- ),
+ AdminEnvelope::ok(AccountOAuthAuthorizeData {
+ session_id: result.session_id,
+ auth_url: result.auth_url,
+ expires_at: china_rfc3339(&result.expires_at),
+ expires_at_display: china_datetime(&result.expires_at),
+ }),
)),
- Err(error) => Err(account_error(error, request_id)),
+ Err(error) => Err(account_error(&error)),
}
}
/// `POST /api/admin/accounts/oauth/exchange`
pub(crate) async fn oauth_exchange_account(
State(state): State,
- Extension(request_id): Extension,
headers: HeaderMap,
Json(payload): Json,
) -> Result {
- let request_id = request_id.as_str().to_string();
- require_admin_session(&state, &headers, &request_id).await?;
+ require_admin_session(&state, &headers).await?;
match state
.services
.admin_accounts
@@ -917,16 +606,13 @@ pub(crate) async fn oauth_exchange_account(
{
Ok(result) => Ok(AdminResponse::new(
StatusCode::OK,
- AdminEnvelope::ok(
- AccountImportData {
- imported: result.imported,
- skipped: result.skipped,
- source_format: result.source_format.to_string(),
- },
- request_id,
- ),
+ AdminEnvelope::ok(AccountImportData {
+ imported: result.imported,
+ skipped: result.skipped,
+ source_format: result.source_format.to_string(),
+ }),
)),
- Err(error) => Err(account_error(error, request_id)),
+ Err(error) => Err(account_error(&error)),
}
}
@@ -934,14 +620,12 @@ pub(crate) async fn oauth_exchange_account(
pub(crate) async fn test_account_connection(
State(state): State,
Query(query): Query,
- Extension(request_id): Extension,
headers: HeaderMap,
body: Bytes,
) -> Result {
- let request_id = request_id.as_str().to_string();
let account_id = query.id;
- let payload = parse_account_test_request(&body, &request_id)?;
- require_admin_session(&state, &headers, &request_id).await?;
+ let payload = parse_account_test_request(&body)?;
+ require_admin_session(&state, &headers).await?;
let model = payload
.model_id
@@ -949,20 +633,13 @@ pub(crate) async fn test_account_connection(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
- .ok_or_else(|| {
- AdminError::new(
- StatusCode::BAD_REQUEST,
- 40001,
- "Model is required",
- &request_id,
- )
- })?;
+ .ok_or_else(|| AdminError::new(StatusCode::BAD_REQUEST, 40001, "Model is required"))?;
let stream = state
.services
.admin_accounts
.test_connection_stream(&account_id, model)
.await
- .map_err(|error| account_error(error, request_id.clone()))?;
+ .map_err(|error| account_error(&error))?;
Response::builder()
.status(StatusCode::OK)
@@ -976,13 +653,12 @@ pub(crate) async fn test_account_connection(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to build account test stream",
- request_id,
)
})
}
/// `GET /api/admin/accounts/models?id=...`
-pub(crate) async fn account_test_models(
+pub(crate) async fn account_models(
State(state): State,
Query(query): Query,
Extension(request_id): Extension,
@@ -990,41 +666,36 @@ pub(crate) async fn account_test_models(
) -> Result {
let request_id = request_id.as_str().to_string();
let account_id = query.id;
- require_admin_session(&state, &headers, &request_id).await?;
+ require_admin_session(&state, &headers).await?;
match state
.services
.admin_accounts
- .test_connection_models(&account_id, &request_id)
+ .account_models(&account_id, &request_id)
.await
{
Ok(models) => Ok(AdminResponse::new(
StatusCode::OK,
- AdminEnvelope::ok(
- AccountTestModelsData {
- models: models
- .into_iter()
- .map(|model| AccountTestModelData {
- id: model.id,
- label: model.label,
- })
- .collect(),
- },
- request_id,
- ),
+ AdminEnvelope::ok(AccountModelsData {
+ models: models
+ .into_iter()
+ .map(|model| AccountModelData {
+ id: model.id,
+ label: model.label,
+ })
+ .collect(),
+ }),
)),
- Err(error) => Err(account_error(error, request_id)),
+ Err(error) => Err(account_error(&error)),
}
}
/// `POST /api/admin/accounts/delete`
pub(crate) async fn batch_delete_accounts(
State(state): State,
- Extension(request_id): Extension,
headers: HeaderMap,
Json(payload): Json,
) -> Result {
- let request_id = request_id.as_str().to_string();
- require_admin_session(&state, &headers, &request_id).await?;
+ require_admin_session(&state, &headers).await?;
match state
.services
.admin_accounts
@@ -1033,65 +704,37 @@ pub(crate) async fn batch_delete_accounts(
{
Ok(result) => Ok(AdminResponse::new(
StatusCode::OK,
- AdminEnvelope::ok(
- BatchDeleteAccountsData {
- deleted: result.deleted,
- not_found: result.not_found,
- },
- request_id,
- ),
+ AdminEnvelope::ok(BatchDeleteAccountsData {
+ deleted: result.deleted,
+ not_found: result.not_found,
+ }),
)),
- Err(error) => Err(account_error(error, request_id)),
+ Err(error) => Err(account_error(&error)),
}
}
/// `POST /api/admin/accounts/update`
pub(crate) async fn update_account(
State(state): State,
- Extension(request_id): Extension,
headers: HeaderMap,
Json(payload): Json,
) -> Result {
- let request_id = request_id.as_str().to_string();
- require_admin_session(&state, &headers, &request_id).await?;
+ require_admin_session(&state, &headers).await?;
- match parse_account_update(payload, &request_id)? {
- ParsedAccountUpdate::Single { id, update } => {
- match state
- .services
- .admin_accounts
- .update_metadata(&id, update)
- .await
- {
- Ok(Some(account)) => Ok(AdminResponse::new(
- StatusCode::OK,
- AdminEnvelope::ok(
- AccountUpdateData::Account(Box::new(account.into())),
- request_id,
- ),
- )),
- Ok(None) => Err(account_not_found(request_id)),
- Err(error) => Err(account_error(error, request_id)),
- }
- }
- ParsedAccountUpdate::BatchStatus { ids, status } => match state
- .services
- .admin_accounts
- .batch_update_status(ids, &status)
- .await
- {
- Ok(result) => Ok(AdminResponse::new(
- StatusCode::OK,
- AdminEnvelope::ok(
- AccountUpdateData::BatchStatus(BatchUpdateAccountStatusData {
- updated: result.updated,
- not_found: result.not_found,
- }),
- request_id,
- ),
- )),
- Err(error) => Err(account_error(error, request_id)),
- },
+ let ParsedAccountUpdate { id, update } = parse_account_update(&payload)?;
+
+ match state
+ .services
+ .admin_accounts
+ .update_metadata(&id, update)
+ .await
+ {
+ Ok(Some(account)) => Ok(AdminResponse::new(
+ StatusCode::OK,
+ AdminEnvelope::ok(AccountUpdateData::Account(Box::new(account.into()))),
+ )),
+ Ok(None) => Err(account_not_found()),
+ Err(error) => Err(account_error(&error)),
}
}
@@ -1345,13 +988,10 @@ fn push_monthly_quota_window(
window_seconds,
label_display: "月限额".to_string(),
used_percent,
- used_percent_display: used_percent
- .map(format_percent)
- .unwrap_or_else(|| "-".to_string()),
+ used_percent_display: used_percent.map_or_else(|| "-".to_string(), format_percent),
reset_at_display: reset_at
.as_ref()
- .map(china_datetime)
- .unwrap_or_else(|| "-".to_string()),
+ .map_or_else(|| "-".to_string(), china_datetime),
window_used_display: quota_window_used_display(reset_at, window_seconds),
});
true
@@ -1430,13 +1070,10 @@ fn push_quota_window(
window_seconds,
label_display,
used_percent,
- used_percent_display: used_percent
- .map(format_percent)
- .unwrap_or_else(|| "-".to_string()),
+ used_percent_display: used_percent.map_or_else(|| "-".to_string(), format_percent),
reset_at_display: reset_at
.as_ref()
- .map(china_datetime)
- .unwrap_or_else(|| "-".to_string()),
+ .map_or_else(|| "-".to_string(), china_datetime),
window_used_display: quota_window_used_display(reset_at, window_seconds),
});
}
@@ -1569,7 +1206,8 @@ fn quota_window_used_display(
let remaining = reset_at
.signed_duration_since(Utc::now())
.num_seconds()
- .max(0) as u64;
+ .max(0)
+ .cast_unsigned();
let used = window_seconds.saturating_sub(remaining);
format!(
"{} / {}",
@@ -1634,66 +1272,28 @@ fn format_cost(value: f64) -> String {
format!("${value:.2}")
}
-// ============================================================================
-// Error handling
-// ============================================================================
-
-enum ParsedAccountUpdate {
- Single {
- id: String,
- update: AdminAccountMetadataUpdate,
- },
- BatchStatus {
- ids: Vec,
- status: String,
- },
+struct ParsedAccountUpdate {
+ id: String,
+ update: AdminAccountMetadataUpdate,
}
-fn parse_account_update(
- payload: Value,
- request_id: &str,
-) -> Result {
+fn parse_account_update(payload: &Value) -> Result {
let object = payload.as_object().ok_or_else(|| {
AdminError::new(
StatusCode::BAD_REQUEST,
40001,
"Account update request must be an object",
- request_id,
)
})?;
- if object.contains_key("ids") {
- if object.contains_key("id") {
- return Err(AdminError::new(
- StatusCode::BAD_REQUEST,
- 40001,
- "Account batch update must not include id",
- request_id,
- ));
- }
- if ["label", "email", "accountId", "userId", "planType"]
- .iter()
- .any(|field| object.contains_key(*field))
- {
- return Err(AdminError::new(
- StatusCode::BAD_REQUEST,
- 40001,
- "Account batch update only supports status",
- request_id,
- ));
- }
- let ids = required_string_array_field(object, "ids", request_id)?;
- let status = required_string_field(object, "status", request_id)?;
- return Ok(ParsedAccountUpdate::BatchStatus { ids, status });
- }
- let id = required_string_field(object, "id", request_id)?;
- let label = optional_string_update_field(object, "label", request_id)?;
- let email = optional_string_update_field(object, "email", request_id)?;
- let account_id = optional_string_update_field(object, "accountId", request_id)?;
- let user_id = optional_string_update_field(object, "userId", request_id)?;
- let plan_type = optional_string_update_field(object, "planType", request_id)?;
+ let id = required_string_field(object, "id")?;
+ let label = optional_string_update_field(object, "label")?;
+ let email = optional_string_update_field(object, "email")?;
+ let account_id = optional_string_update_field(object, "accountId")?;
+ let user_id = optional_string_update_field(object, "userId")?;
+ let plan_type = optional_string_update_field(object, "planType")?;
let status = object
.get("status")
- .map(|value| required_string_value(value, "status", request_id))
+ .map(|value| required_string_value(value, "status"))
.transpose()?;
let update = AdminAccountMetadataUpdate {
email,
@@ -1708,33 +1308,26 @@ fn parse_account_update(
StatusCode::BAD_REQUEST,
40001,
"Account update request must include editable fields",
- request_id,
));
}
- Ok(ParsedAccountUpdate::Single { id, update })
+ Ok(ParsedAccountUpdate { id, update })
}
fn required_string_field(
object: &serde_json::Map,
field: &'static str,
- request_id: &str,
) -> Result {
let Some(value) = object.get(field) else {
return Err(AdminError::new(
StatusCode::BAD_REQUEST,
40001,
format!("{field} is required"),
- request_id,
));
};
- required_string_value(value, field, request_id)
+ required_string_value(value, field)
}
-fn required_string_value(
- value: &Value,
- field: &'static str,
- request_id: &str,
-) -> Result {
+fn required_string_value(value: &Value, field: &'static str) -> Result {
value
.as_str()
.map(str::trim)
@@ -1745,51 +1338,11 @@ fn required_string_value(
StatusCode::BAD_REQUEST,
40001,
format!("{field} must be a non-empty string"),
- request_id,
)
})
}
-fn required_string_array_field(
- object: &serde_json::Map,
- field: &'static str,
- request_id: &str,
-) -> Result, AdminError> {
- let Some(value) = object.get(field) else {
- return Err(AdminError::new(
- StatusCode::BAD_REQUEST,
- 40001,
- format!("{field} is required"),
- request_id,
- ));
- };
- let Some(values) = value.as_array() else {
- return Err(AdminError::new(
- StatusCode::BAD_REQUEST,
- 40001,
- format!("{field} must be an array of non-empty strings"),
- request_id,
- ));
- };
- if values.is_empty() {
- return Err(AdminError::new(
- StatusCode::BAD_REQUEST,
- 40001,
- format!("{field} must not be empty"),
- request_id,
- ));
- }
- values
- .iter()
- .map(|value| required_string_value(value, field, request_id))
- .collect()
-}
-
-fn optional_string_field(
- value: &Value,
- field: &'static str,
- request_id: &str,
-) -> Result