From f3236d041098bfa49698a95d3dc4b63174563181 Mon Sep 17 00:00:00 2001 From: Zyy Date: Sun, 20 Sep 2026 16:27:55 +0800 Subject: [PATCH] feat(key-usage): add about dialog with build information Reuse the shared About dialog from a toolbar icon and expose version and commit identifiers through an authenticated key-only endpoint. Align the About and refresh button backgrounds with the configuration button in dark mode. Validate the response field allowlist and session boundaries, and document the new endpoint. Co-authored-by: Codex --- backend/crates/gateway-admin/src/lib.rs | 4 +- .../gateway-admin/src/use_case/key_usage.rs | 15 +++++++- .../crates/gateway-api/src/key_usage/mod.rs | 24 +++++++++++- .../gateway-api/src/key_usage/presenter.rs | 16 ++++++++ .../crates/gateway-api/src/key_usage/query.rs | 2 +- .../crates/gateway-api/tests/key_usage/mod.rs | 38 +++++++++++++++++-- docs/api.md | 3 ++ frontend/src/api/modules/key-usage.ts | 13 +++++++ .../{layout => }/components/AppAboutModal.vue | 12 +++--- frontend/src/layout/index.vue | 6 +-- .../key-usage/components/KeyUsageHeader.vue | 9 +++-- frontend/src/views/key-usage/index.vue | 27 ++++++++++++- 12 files changed, 149 insertions(+), 20 deletions(-) rename frontend/src/{layout => }/components/AppAboutModal.vue (92%) diff --git a/backend/crates/gateway-admin/src/lib.rs b/backend/crates/gateway-admin/src/lib.rs index 26f3969b6..5e03cba68 100644 --- a/backend/crates/gateway-admin/src/lib.rs +++ b/backend/crates/gateway-admin/src/lib.rs @@ -370,10 +370,12 @@ pub async fn initialize( backup_ports.dump(), backup_ports.object_store(), ); + let system = Arc::new(DefaultSystemService::new(system)); let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new( auth.clone(), store.client_keys(), store.observability(), + system.clone(), )); let openai = Arc::new(DefaultOpenAiService::new( openai, @@ -423,7 +425,7 @@ pub async fn initialize( registry, pricing_source, )), - system: Arc::new(DefaultSystemService::new(system)), + system, openai, xai, import_tasks, diff --git a/backend/crates/gateway-admin/src/use_case/key_usage.rs b/backend/crates/gateway-admin/src/use_case/key_usage.rs index 1c1800ede..74b6c12e1 100644 --- a/backend/crates/gateway-admin/src/use_case/key_usage.rs +++ b/backend/crates/gateway-admin/src/use_case/key_usage.rs @@ -6,7 +6,7 @@ use async_trait::async_trait; use chrono::Utc; use crate::{ - AuthService, + AuthService, SystemService, model::{ AdminError, AdminErrorKind, auth::SessionSubject, @@ -18,6 +18,7 @@ use crate::{ observability::{ OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery, china_day_start, }, + system::SystemVersion, }, ports::store::{ClientKeyStore, ObservabilityStore}, }; @@ -27,6 +28,8 @@ use super::{map_store_error, observability::health_timeline_at}; #[async_trait] pub trait KeyUsageService: Send + Sync { + async fn version(&self, session_id: Option<&str>) -> Result, AdminError>; + async fn config(&self, session_id: Option<&str>) -> Result, AdminError>; @@ -47,6 +50,7 @@ pub(crate) struct DefaultKeyUsageService { auth: Arc, keys: Arc, observations: Arc, + system: Arc, } impl DefaultKeyUsageService { @@ -54,11 +58,13 @@ impl DefaultKeyUsageService { auth: Arc, keys: Arc, observations: Arc, + system: Arc, ) -> Self { Self { auth, keys, observations, + system, } } @@ -89,6 +95,13 @@ fn usage_filter(id: &ClientApiKeyId, model: Option) -> UsageFilter { #[async_trait] impl KeyUsageService for DefaultKeyUsageService { + async fn version(&self, session_id: Option<&str>) -> Result, AdminError> { + if self.key_id(session_id).await?.is_none() { + return Ok(None); + } + self.system.version().await.map(Some) + } + async fn config( &self, session_id: Option<&str>, diff --git a/backend/crates/gateway-api/src/key_usage/mod.rs b/backend/crates/gateway-api/src/key_usage/mod.rs index f89c0d83e..387e6b263 100644 --- a/backend/crates/gateway-api/src/key_usage/mod.rs +++ b/backend/crates/gateway-api/src/key_usage/mod.rs @@ -23,6 +23,7 @@ where S: SessionState + Clone + Send + Sync + 'static, { Router::new() + .route("/api/key-usage/version", get(version::)) .route("/api/key-usage/config", get(config::)) .route("/api/key-usage/overview", get(overview::)) .route("/api/key-usage/records", get(records::)) @@ -32,10 +33,31 @@ where .layer(middleware::map_response(no_store)) } +async fn version( + State(state): State, + headers: HeaderMap, + AdminQuery(_): AdminQuery, +) -> Result +where + S: SessionState + Send + Sync, +{ + let version = state + .admin_services() + .key_usage() + .version(session_cookie::value(&headers).as_deref()) + .await + .map_err(map_admin_service_error)? + .ok_or_else(AdminError::session_required)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(presenter::version(version)), + )) +} + async fn config( State(state): State, headers: HeaderMap, - AdminQuery(_): AdminQuery, + AdminQuery(_): AdminQuery, ) -> Result where S: SessionState + Send + Sync, diff --git a/backend/crates/gateway-api/src/key_usage/presenter.rs b/backend/crates/gateway-api/src/key_usage/presenter.rs index f53866e2c..11d118080 100644 --- a/backend/crates/gateway-api/src/key_usage/presenter.rs +++ b/backend/crates/gateway-api/src/key_usage/presenter.rs @@ -7,6 +7,7 @@ use gateway_admin::model::{ observability::{ CostCoverage, CurrencyCost, Granularity, OpsError, RequestMetrics, UsageListRecord, }, + system::SystemVersion, }; use serde::Serialize; @@ -15,6 +16,21 @@ use crate::admin::observability::{ health_timeline_view, usage_list_token_details, }; +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct VersionView { + version: String, + git_sha: String, +} + +pub(super) fn version(version: SystemVersion) -> VersionView { + // 密钥用户仅查看构建标识,不暴露部署环境、更新状态或内部诊断。 + VersionView { + version: version.version, + git_sha: version.git_sha, + } +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub(super) struct ConfigView { diff --git a/backend/crates/gateway-api/src/key_usage/query.rs b/backend/crates/gateway-api/src/key_usage/query.rs index 798139446..fdb41262d 100644 --- a/backend/crates/gateway-api/src/key_usage/query.rs +++ b/backend/crates/gateway-api/src/key_usage/query.rs @@ -13,7 +13,7 @@ use crate::admin::AdminError; #[derive(Deserialize)] #[serde(deny_unknown_fields)] -pub(super) struct ConfigQuery {} +pub(super) struct EmptyQuery {} #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/backend/crates/gateway-api/tests/key_usage/mod.rs b/backend/crates/gateway-api/tests/key_usage/mod.rs index df4592932..43a6051e9 100644 --- a/backend/crates/gateway-api/tests/key_usage/mod.rs +++ b/backend/crates/gateway-api/tests/key_usage/mod.rs @@ -40,8 +40,8 @@ async fn login(app: &Router, mode: &str) -> String { } async fn get(app: &Router, resource: &str, suffix: &str, cookie: &str) -> Response { - let path = if resource == "config" { - format!("/api/key-usage/config{suffix}") + let path = if matches!(resource, "config" | "version") { + format!("/api/key-usage/{resource}{suffix}") } else { format!("/api/key-usage/{resource}?{RANGE}{suffix}") }; @@ -54,6 +54,33 @@ async fn get(app: &Router, resource: &str, suffix: &str, cookie: &str) -> Respon response } +#[tokio::test] +async fn version_exposes_only_build_identifiers_for_key_sessions() { + let fixture = fixtures::fixture().await; + let app = crate::openai::api_router_with_admin(fixture.services.clone()); + let cookie = login(&app, "key").await; + let response = get(&app, "version", "", &cookie).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response_json(response).await["data"], + json!({"version": "3.7.0", "gitSha": "internal-revision"}) + ); + assert_eq!( + get(&app, "version", "?keyId=other", &cookie).await.status(), + StatusCode::BAD_REQUEST + ); + let response = app + .clone() + .oneshot(cookie_request( + Method::GET, + "/api/admin/system/version", + &cookie, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + #[tokio::test] async fn config_reveals_only_the_session_keys_name_and_plaintext() { let fixture = fixtures::fixture().await; @@ -288,7 +315,7 @@ async fn missing_admin_and_revoked_sessions_cannot_read_key_usage() { let app = crate::openai::api_router_with_admin(fixture.services.clone()); let admin = login(&app, "admin").await; let key = login(&app, "key").await; - for resource in ["overview", "records", "config"] { + for resource in ["overview", "records", "config", "version"] { assert_eq!( get(&app, resource, "", "").await.status(), StatusCode::UNAUTHORIZED @@ -427,6 +454,11 @@ async fn namespace_errors_remain_json_and_uncacheable() { "/api/key-usage/config", StatusCode::METHOD_NOT_ALLOWED, ), + ( + Method::POST, + "/api/key-usage/version", + StatusCode::METHOD_NOT_ALLOWED, + ), ] { let response = app .clone() diff --git a/docs/api.md b/docs/api.md index 09d99502e..f04ac9082 100644 --- a/docs/api.md +++ b/docs/api.md @@ -348,6 +348,7 @@ OpenAI 选号阶段确认本次可选账号全部额度耗尽时,HTTP 返回 ` | `GET` | `/api/key-usage/overview` | `startTime`、`endTime`、`model?` | 用量汇总、趋势、当前额度和北京时间今日健康时间线 | | `GET` | `/api/key-usage/records` | 同上,另含 `kind?`、`currentPage?`、`pageSize?` | 当前 Key 的成功请求或错误记录 | | `GET` | `/api/key-usage/config` | 无 | 当前 Key 的客户端配置凭据 | +| `GET` | `/api/key-usage/version` | 无 | “关于”弹窗使用的当前版本号和提交号 | 用量查询的起止时间使用 RFC3339,开始必须早于结束,一次最多 31 天。模型按完整名称匹配; 不接受 Key ID、账号、Provider 等范围参数或其他未知字段。页码默认 1,每页默认 20,允许 1–100 条; @@ -369,6 +370,8 @@ Token 明细、费用明细、用时/首字与状态。Token 和费用复用现 成功记录的 `status` 为 `success`,不伪造未保存的 HTTP 状态;错误记录为 `error`,只返回客户端状态码, 缺失的 Token/费用明细为 null。不返回账号资料、Key ID、上游模型或请求标识、原始错误正文或诊断内容。 +version 返回 `{ version, gitSha }`,不接受查询参数,不包含部署模式、更新状态或内部诊断。 + config 返回 `{ name, plaintextKey }`,仅读取服务端会话绑定的当前 Key,不接受任何查询参数。 使用统计页在打开“密钥配置”弹窗时读取,用于复制 Codex 配置文件或导入 CCSwitch; 明文不进入用量轮询响应或浏览器持久化存储,关闭弹窗后清除页面中的配置状态。 diff --git a/frontend/src/api/modules/key-usage.ts b/frontend/src/api/modules/key-usage.ts index aa3e03097..7e742f4d1 100644 --- a/frontend/src/api/modules/key-usage.ts +++ b/frontend/src/api/modules/key-usage.ts @@ -8,6 +8,11 @@ export interface KeyUsageConfig { plaintextKey: string } +export interface KeyUsageVersion { + version: string + gitSha: string +} + export interface KeyUsageMetrics { requests: number inputTokens: number @@ -91,6 +96,14 @@ export function getKeyUsageOverview(params: KeyUsageQuery, options: RequestOptio }) } +export function getKeyUsageVersion(options: RequestOptions = {}) { + return request({ + url: '/api/key-usage/version', + method: 'GET', + ...options, + }) +} + export function getKeyUsageConfig(options: RequestOptions = {}) { return request({ url: '/api/key-usage/config', diff --git a/frontend/src/layout/components/AppAboutModal.vue b/frontend/src/components/AppAboutModal.vue similarity index 92% rename from frontend/src/layout/components/AppAboutModal.vue rename to frontend/src/components/AppAboutModal.vue index 38c66376c..64b139223 100644 --- a/frontend/src/layout/components/AppAboutModal.vue +++ b/frontend/src/components/AppAboutModal.vue @@ -1,16 +1,16 @@