feat: add admin api key settings

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-28 15:42:01 +08:00
co-authored by Codex
parent b6dc72d8f6
commit 93ba705636
19 changed files with 606 additions and 38 deletions
Generated
+1
View File
@@ -340,6 +340,7 @@ dependencies = [
"serde_yml",
"sha2 0.11.0",
"sqlx",
"subtle",
"tempfile",
"thiserror 2.0.18",
"tokio",
+1
View File
@@ -30,6 +30,7 @@ serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
sha2 = "0.11.0"
sqlx = { version = "0.9.0", features = ["runtime-tokio", "tls-rustls", "sqlite", "chrono", "uuid"] }
subtle = "2.6.1"
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "signal", "fs", "time", "sync", "net", "test-util"] }
tokio-tungstenite = { version = "0.28.0", features = ["proxy", "rustls-tls-native-roots"] }
+13 -13
View File
@@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
admin::auth::session::require_admin_session,
admin::auth::session::require_admin_auth,
admin::response::{
AdminEnvelope, AdminError, AdminResponse, CursorPageMeta, NumberedPageMeta, PageMeta,
},
@@ -419,7 +419,7 @@ pub(crate) async fn accounts(
headers: HeaderMap,
Query(params): Query<AccountsQuery>,
) -> Result<impl IntoResponse, AdminError> {
require_admin_session(&state, &headers).await?;
require_admin_auth(&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;
@@ -482,7 +482,7 @@ pub(crate) async fn create_account(
headers: HeaderMap,
Json(payload): Json<CreateAccountRequest>,
) -> Result<impl IntoResponse, AdminError> {
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
match state
.services
.admin_accounts
@@ -503,7 +503,7 @@ pub(crate) async fn export_accounts(
headers: HeaderMap,
Query(query): Query<AccountExportQuery>,
) -> Result<impl IntoResponse, AdminError> {
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
match state
.services
.admin_accounts
@@ -525,7 +525,7 @@ pub(crate) async fn refresh_account(
Json(payload): Json<AccountActionRequest>,
) -> Result<impl IntoResponse, AdminError> {
let account_id = payload.id;
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
match state
.services
.admin_accounts
@@ -547,7 +547,7 @@ pub(crate) async fn account_quota(
Query(query): Query<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError> {
let account_id = query.id;
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
match state
.services
.admin_accounts
@@ -579,7 +579,7 @@ pub(crate) async fn import_accounts(
headers: HeaderMap,
Json(payload): Json<Value>,
) -> Result<impl IntoResponse, AdminError> {
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
match state.services.admin_accounts.import(payload).await {
Ok(result) => Ok(AdminResponse::new(
StatusCode::OK,
@@ -598,7 +598,7 @@ pub(crate) async fn oauth_authorize_account(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
match state.services.admin_accounts.oauth_authorize().await {
Ok(result) => Ok(AdminResponse::new(
StatusCode::OK,
@@ -619,7 +619,7 @@ pub(crate) async fn oauth_exchange_account(
headers: HeaderMap,
Json(payload): Json<AccountOAuthExchangeRequest>,
) -> Result<impl IntoResponse, AdminError> {
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
match state
.services
.admin_accounts
@@ -652,7 +652,7 @@ pub(crate) async fn test_account_connection(
) -> Result<Response, AdminError> {
let account_id = query.id;
let payload = parse_account_test_request(&body)?;
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
let model = payload
.model_id
@@ -693,7 +693,7 @@ pub(crate) async fn account_models(
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
let account_id = query.id;
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
match state
.services
.admin_accounts
@@ -722,7 +722,7 @@ pub(crate) async fn batch_delete_accounts(
headers: HeaderMap,
Json(payload): Json<BatchDeleteAccountsRequest>,
) -> Result<impl IntoResponse, AdminError> {
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
match state
.services
.admin_accounts
@@ -746,7 +746,7 @@ pub(crate) async fn update_account(
headers: HeaderMap,
Json(payload): Json<Value>,
) -> Result<impl IntoResponse, AdminError> {
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
let ParsedAccountUpdate { id, update } = parse_account_update(&payload)?;
+29
View File
@@ -165,6 +165,30 @@ pub(crate) async fn require_admin_session(
}
}
/// 要求请求携带有效管理员凭证:会话 Cookie 或管理员 API Key。
pub(crate) async fn require_admin_auth(
state: &AppState,
headers: &HeaderMap,
) -> Result<(), AdminError> {
if let Some(api_key) = admin_api_key_header(headers) {
return match state.services.settings.verify_admin_api_key(&api_key).await {
Ok(true) => Ok(()),
Ok(false) => Err(AdminError::new(
StatusCode::UNAUTHORIZED,
40103,
"Invalid admin API key",
)),
Err(_) => Err(AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50001,
"Failed to validate admin API key",
)),
};
}
require_admin_session(state, headers).await
}
fn admin_session_cookie(headers: &HeaderMap) -> Option<String> {
let cookie = headers.get("cookie")?.to_str().ok()?;
cookie.split(';').find_map(|part| {
@@ -172,3 +196,8 @@ fn admin_session_cookie(headers: &HeaderMap) -> Option<String> {
(name == ADMIN_SESSION_COOKIE).then(|| value.to_string())
})
}
fn admin_api_key_header(headers: &HeaderMap) -> Option<String> {
let value = headers.get("x-api-key")?.to_str().ok()?.trim();
(!value.is_empty()).then(|| value.to_string())
}
+5 -5
View File
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
admin::auth::session::require_admin_session,
admin::auth::session::require_admin_auth,
admin::keys::service::{
AdminClientKeyError, AdminCreatedClientApiKey, AdminStoredClientApiKey,
},
@@ -213,7 +213,7 @@ pub(crate) async fn api_keys(
headers: HeaderMap,
Query(query): Query<ApiKeysQuery>,
) -> Result<impl IntoResponse, AdminError> {
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
let limit = clamp_limit(query.limit.unwrap_or(50));
match state
.services
@@ -241,7 +241,7 @@ pub(crate) async fn create_api_key(
headers: HeaderMap,
Json(payload): Json<CreateApiKeyRequest>,
) -> Result<impl IntoResponse, AdminError> {
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
match state.services.admin_client_keys.create(&payload.name).await {
Ok(created) => Ok(AdminResponse::new(
StatusCode::OK,
@@ -257,7 +257,7 @@ pub(crate) async fn update_api_key(
headers: HeaderMap,
Json(payload): Json<Value>,
) -> Result<impl IntoResponse, AdminError> {
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
let update = parse_client_api_key_update(&payload)?;
if let Some(label) = update.label {
@@ -301,7 +301,7 @@ pub(crate) async fn batch_delete_api_keys(
headers: HeaderMap,
Json(payload): Json<BatchDeleteClientApiKeysRequest>,
) -> Result<impl IntoResponse, AdminError> {
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
match state
.services
.admin_client_keys
+3 -3
View File
@@ -13,7 +13,7 @@ use serde_json::Value;
use crate::{
admin::{
auth::session::require_admin_session,
auth::session::require_admin_auth,
monitoring::{
billing,
diagnostics::{
@@ -236,7 +236,7 @@ pub(crate) async fn dashboard_summary(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
let accounts = state
.services
@@ -296,7 +296,7 @@ pub(crate) async fn dashboard_trend(
headers: HeaderMap,
Query(query): Query<DashboardTrendQuery>,
) -> Result<impl IntoResponse, AdminError> {
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
let time_buckets = dashboard_time_buckets(&state, Utc::now()).await;
Ok(AdminResponse::new(
+4 -4
View File
@@ -13,7 +13,7 @@ use crate::{
events::{EventLevel, EventLog},
},
admin::{
auth::session::require_admin_session,
auth::session::require_admin_auth,
response::{AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse},
},
infra::{
@@ -72,7 +72,7 @@ pub(crate) async fn logs(
headers: HeaderMap,
Query(query): Query<LogsQuery>,
) -> Result<impl IntoResponse, AdminError> {
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
let limit = clamp_limit(query.page_size.or(query.limit).unwrap_or(50));
let page = query.page;
let use_numbered_page = page.is_some() || query.page_size.is_some();
@@ -123,7 +123,7 @@ pub(crate) async fn log_detail(
headers: HeaderMap,
Query(query): Query<LogDetailQuery>,
) -> Result<impl IntoResponse, AdminError> {
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
match state.services.logs.get(&query.id).await {
Ok(Some(log)) => Ok(AdminResponse::new(
StatusCode::OK,
@@ -143,7 +143,7 @@ pub(crate) async fn clear_logs(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
match state.services.logs.clear().await {
Ok(cleared) => Ok(AdminResponse::new(
StatusCode::OK,
+12 -1
View File
@@ -19,7 +19,10 @@ use super::{
dashboard::{dashboard_summary, dashboard_trend},
logs::{clear_logs, log_detail, logs},
},
settings::routes::{settings, update_settings},
settings::routes::{
admin_api_key_status, delete_admin_api_key, regenerate_admin_api_key, settings,
update_settings,
},
};
/// 构造管理端路由。
@@ -29,6 +32,14 @@ pub fn router() -> Router<AppState> {
.route("/api/admin/auth/status", get(session_status))
.route("/api/admin/logout", post(logout))
.route("/api/admin/settings", get(settings).post(update_settings))
.route(
"/api/admin/settings/admin-api-key",
get(admin_api_key_status).delete(delete_admin_api_key),
)
.route(
"/api/admin/settings/admin-api-key/regenerate",
post(regenerate_admin_api_key),
)
.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))
+60 -3
View File
@@ -11,7 +11,7 @@ use serde_json::Value;
use std::collections::BTreeMap;
use crate::{
admin::auth::session::require_admin_session,
admin::auth::session::require_admin_auth,
admin::response::{AdminEnvelope, AdminError, AdminResponse},
config::{
settings::{AdminSettingsPatch, RuntimeSettingsError, SettingsServiceError},
@@ -61,7 +61,7 @@ pub(crate) async fn settings(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
let config = state.services.settings.current();
Ok(AdminResponse::new(
StatusCode::OK,
@@ -75,7 +75,7 @@ pub(crate) async fn update_settings(
headers: HeaderMap,
body: Bytes,
) -> Result<impl IntoResponse, AdminError> {
require_admin_session(&state, &headers).await?;
require_admin_auth(&state, &headers).await?;
let patch = parse_settings_patch(&body)?;
let config = state
@@ -91,6 +91,63 @@ pub(crate) async fn update_settings(
))
}
/// `GET /api/admin/settings/admin-api-key`
pub(crate) async fn admin_api_key_status(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
require_admin_auth(&state, &headers).await?;
let status = state
.services
.settings
.admin_api_key_status()
.await
.map_err(settings_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(status),
))
}
/// `POST /api/admin/settings/admin-api-key/regenerate`
pub(crate) async fn regenerate_admin_api_key(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
require_admin_auth(&state, &headers).await?;
let key = state
.services
.settings
.regenerate_admin_api_key()
.await
.map_err(settings_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({ "key": key })),
))
}
/// `DELETE /api/admin/settings/admin-api-key`
pub(crate) async fn delete_admin_api_key(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
require_admin_auth(&state, &headers).await?;
state
.services
.settings
.delete_admin_api_key()
.await
.map_err(settings_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({ "message": "Admin API key deleted" })),
))
}
fn parse_settings_patch(body: &[u8]) -> Result<AdminSettingsPatch, AdminError> {
let value = serde_json::from_slice::<Value>(body)
.map_err(|_| AdminError::new(StatusCode::BAD_REQUEST, 40000, "Malformed JSON body"))?;
+98
View File
@@ -8,9 +8,11 @@ use std::{
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sqlx::{sqlite::SqliteRow, Row, SqlitePool};
use subtle::ConstantTimeEq;
use thiserror::Error;
use crate::config::types::AppConfig;
use crate::infra::identity::generate_admin_api_key;
use crate::upstream::accounts::pool::{
AccountPoolOptions, RotationStrategy, RuntimeAccountPoolService,
};
@@ -228,6 +230,16 @@ fn invalid_field(field: &str, message: impl Into<String>) -> SettingsServiceErro
}
}
/// 管理员 API Key 状态。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminApiKeyStatus {
/// 是否已经生成。
pub exists: bool,
/// 脱敏后的密钥。
pub masked_key: Option<String>,
}
/// 运行时设置服务。
#[derive(Clone)]
pub struct RuntimeSettingsService {
@@ -295,6 +307,60 @@ impl RuntimeSettingsService {
.unwrap_or_else(std::sync::PoisonError::into_inner) = next.clone();
Ok(next)
}
/// 返回管理员 API Key 状态。
pub async fn admin_api_key_status(&self) -> Result<AdminApiKeyStatus, RuntimeSettingsError> {
admin_api_key_status(&self.pool).await
}
/// 重新生成管理员 API Key,完整值只在本次返回。
pub async fn regenerate_admin_api_key(&self) -> Result<String, RuntimeSettingsError> {
let settings = admin_settings_from_config(&self.current());
insert_runtime_settings_if_missing(&self.pool, &settings).await?;
let key = generate_admin_api_key();
sqlx::query(
r"
update runtime_settings
set admin_api_key = ?, updated_at = ?
where id = ?",
)
.bind(&key)
.bind(Utc::now().to_rfc3339())
.bind(RUNTIME_SETTINGS_ID)
.execute(&self.pool)
.await?;
Ok(key)
}
/// 删除管理员 API Key。
pub async fn delete_admin_api_key(&self) -> Result<(), RuntimeSettingsError> {
sqlx::query(
r"
update runtime_settings
set admin_api_key = null, updated_at = ?
where id = ?",
)
.bind(Utc::now().to_rfc3339())
.bind(RUNTIME_SETTINGS_ID)
.execute(&self.pool)
.await?;
Ok(())
}
/// 校验管理员 API Key。
pub async fn verify_admin_api_key(&self, key: &str) -> Result<bool, RuntimeSettingsError> {
if key.is_empty() {
return Ok(false);
}
let stored = load_admin_api_key(&self.pool).await?;
Ok(stored
.as_deref()
.filter(|stored_key| !stored_key.is_empty())
.is_some_and(|stored_key| key.as_bytes().ct_eq(stored_key.as_bytes()).into()))
}
}
/// 运行时设置错误。
@@ -458,6 +524,38 @@ where id = ?",
Ok(settings)
}
async fn admin_api_key_status(
pool: &SqlitePool,
) -> Result<AdminApiKeyStatus, RuntimeSettingsError> {
let key = load_admin_api_key(pool).await?;
let masked_key = key
.as_deref()
.filter(|key| !key.is_empty())
.map(mask_api_key);
Ok(AdminApiKeyStatus {
exists: masked_key.is_some(),
masked_key,
})
}
async fn load_admin_api_key(pool: &SqlitePool) -> Result<Option<String>, RuntimeSettingsError> {
Ok(sqlx::query_scalar::<_, Option<String>>(
"select admin_api_key from runtime_settings where id = ?",
)
.bind(RUNTIME_SETTINGS_ID)
.fetch_optional(pool)
.await?
.flatten())
}
fn mask_api_key(key: &str) -> String {
if key.len() > 14 {
format!("{}...{}", &key[..10], &key[key.len() - 4..])
} else {
key.to_string()
}
}
fn runtime_settings_from_row(row: &SqliteRow) -> Result<AdminSettings, RuntimeSettingsError> {
let model_aliases_json: String = row.get("model_aliases_json");
Ok(AdminSettings {
+8 -1
View File
@@ -1,4 +1,4 @@
//! 身份认证相关原语:管理员密码哈希与客户端 API Key 生成。
//! 身份认证相关原语:管理员密码哈希与 API Key 生成。
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
@@ -66,3 +66,10 @@ pub fn generate_client_api_key() -> GeneratedClientApiKey {
let prefix = key.chars().take(12).collect::<String>();
GeneratedClientApiKey { key, prefix }
}
/// 生成新的管理员 API Key。
pub fn generate_admin_api_key() -> String {
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
format!("admin-{}", hex::encode(bytes))
}
+1
View File
@@ -50,6 +50,7 @@ create table if not exists runtime_settings (
max_concurrent_per_account integer not null check (max_concurrent_per_account > 0),
request_interval_ms integer not null check (request_interval_ms >= 0),
rotation_strategy text not null check (rotation_strategy in ('least_used', 'round_robin', 'sticky')),
admin_api_key text,
updated_at text not null
);
+169
View File
@@ -66,6 +66,175 @@ async fn admin_settings_should_return_runtime_fields() {
assert_eq!(body["data"]["rotationStrategy"], "least_used");
}
#[tokio::test]
async fn admin_settings_admin_api_key_status_should_be_empty_by_default() {
let (app, _dir) = admin_settings_test_app("admin-settings-admin-key-empty.sqlite").await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/settings/admin-api-key")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_settings_admin_key_empty")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["data"]["exists"], false);
assert!(body["data"]["maskedKey"].is_null());
}
#[tokio::test]
async fn admin_settings_admin_api_key_should_regenerate_and_return_masked_status() {
let (app, _dir) = admin_settings_test_app("admin-settings-admin-key-regenerate.sqlite").await;
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/settings/admin-api-key/regenerate")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_settings_admin_key_regenerate")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
let key = body["data"]["key"].as_str().unwrap();
assert!(key.starts_with("admin-"));
assert_eq!(key.len(), 70);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/settings/admin-api-key")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_settings_admin_key_status")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["data"]["exists"], true);
assert_eq!(
body["data"]["maskedKey"],
format!("{}...{}", &key[..10], &key[key.len() - 4..])
);
}
#[tokio::test]
async fn admin_settings_should_accept_valid_admin_api_key_header() {
let (app, _dir) = admin_settings_test_app("admin-settings-admin-key-auth.sqlite").await;
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/settings/admin-api-key/regenerate")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_settings_admin_key_auth_create")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response_json(response).await;
let key = body["data"]["key"].as_str().unwrap();
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/settings")
.header("x-api-key", key)
.header("x-request-id", "req_settings_admin_key_auth")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["data"]["refreshMarginSeconds"], 240);
}
#[tokio::test]
async fn admin_settings_should_reject_invalid_admin_api_key_header() {
let (app, _dir) = admin_settings_test_app("admin-settings-admin-key-invalid.sqlite").await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/settings")
.header("x-api-key", "admin-invalid")
.header("x-request-id", "req_settings_admin_key_invalid")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(response_json(response).await["code"], 40103);
}
#[tokio::test]
async fn admin_settings_admin_api_key_should_delete_and_revoke_access() {
let (app, _dir) = admin_settings_test_app("admin-settings-admin-key-delete.sqlite").await;
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/settings/admin-api-key/regenerate")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_settings_admin_key_delete_create")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response_json(response).await;
let key = body["data"]["key"].as_str().unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/admin/settings/admin-api-key")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_settings_admin_key_delete")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/settings")
.header("x-api-key", key)
.header("x-request-id", "req_settings_admin_key_revoked")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(response_json(response).await["code"], 40103);
}
#[tokio::test]
async fn admin_settings_update_should_require_admin_session_cookie() {
let (app, _dir) = admin_settings_test_app("admin-settings-update-auth.sqlite").await;
+2 -1
View File
@@ -44,7 +44,7 @@ async fn sqlite_schema_should_persist_runtime_settings_columns() {
let pool = connect_sqlite(&url).await.unwrap();
let rows: Vec<(String,)> = sqlx::query_as(
"select name from pragma_table_info('runtime_settings') where name in ('id', 'model_aliases_json', 'refresh_margin_seconds', 'refresh_concurrency', 'max_concurrent_per_account', 'request_interval_ms', 'rotation_strategy', 'updated_at') order by name",
"select name from pragma_table_info('runtime_settings') where name in ('id', 'model_aliases_json', 'refresh_margin_seconds', 'refresh_concurrency', 'max_concurrent_per_account', 'request_interval_ms', 'rotation_strategy', 'admin_api_key', 'updated_at') order by name",
)
.fetch_all(&pool)
.await
@@ -53,6 +53,7 @@ async fn sqlite_schema_should_persist_runtime_settings_columns() {
assert_eq!(
rows.into_iter().map(|row| row.0).collect::<Vec<_>>(),
[
"admin_api_key",
"id",
"max_concurrent_per_account",
"model_aliases_json",
+21
View File
@@ -14,3 +14,24 @@ export function updateSettings(data: any) {
data,
})
}
export function getAdminApiKeyStatus() {
return request({
url: '/api/admin/settings/admin-api-key',
method: 'GET',
})
}
export function regenerateAdminApiKey() {
return request({
url: '/api/admin/settings/admin-api-key/regenerate',
method: 'POST',
})
}
export function deleteAdminApiKey() {
return request({
url: '/api/admin/settings/admin-api-key',
method: 'DELETE',
})
}
+4 -2
View File
@@ -28,8 +28,10 @@ watch(
<div class="flex h-screen overflow-hidden bg-(--cp-bg-page)">
<AppSidebar :collapsed="sidebarCollapsed" @toggle="toggleSidebar" />
<main class="h-screen min-w-0 flex-1 overflow-hidden">
<BaseScrollbar ref="pageScrollbarRef" view-class="flex h-full min-w-0 flex-col p-6">
<RouterView />
<BaseScrollbar ref="pageScrollbarRef" view-class="flex min-h-full min-w-0 flex-col p-6">
<RouterView v-slot="{ Component }">
<component :is="Component" class="min-h-0 flex-1" />
</RouterView>
</BaseScrollbar>
</main>
</div>
@@ -139,7 +139,7 @@ const quotaToneClasses: Record<string, string> = {
class="grid px-4 pt-5 pb-6 lg:px-7 lg:pt-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.28fr)_minmax(280px,0.9fr)] xl:gap-7 xl:pb-0"
>
<section
class="min-w-0 w-full pb-6 shadow-[inset_0_-1px_0_var(--cp-default-border)] xl:h-100.5 xl:pb-0 xl:shadow-none"
class="min-w-0 w-full shadow-[inset_0_-1px_0_var(--cp-default-border)] xl:h-100.5 xl:pb-0 xl:shadow-none"
>
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">账号调度</h2>
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-semibold text-(--cp-text-secondary)">
+1 -1
View File
@@ -31,7 +31,7 @@ const {
</script>
<template>
<div class="w-full pb-6">
<div class="w-full">
<header class="flex min-h-17 items-start justify-between gap-4">
<div>
<h1 class="mt-0 text-[34px] leading-[1.15] font-extrabold mb-0 text-(--cp-text-primary)">
+173 -3
View File
@@ -1,11 +1,19 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useClipboard } from '@vueuse/core'
import { sortBy } from 'es-toolkit'
import { Gauge, GitBranch, Plus, Save, Timer, Trash2, Zap } from '@lucide/vue'
import { Copy, Gauge, GitBranch, KeyRound, Plus, Save, Timer, Trash2, Zap } from '@lucide/vue'
import { getSettings, updateSettings } from '@/api'
import {
deleteAdminApiKey,
getAdminApiKeyStatus,
getSettings,
regenerateAdminApiKey,
updateSettings,
} from '@/api'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
import BaseForm from '@/components/base/BaseForm/index.vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseInput from '@/components/base/BaseInput.vue'
@@ -30,6 +38,12 @@ interface SettingsForm {
const loading = ref(true)
const saving = ref(false)
const aliasError = ref('')
const adminKeyLoading = ref(true)
const adminKeyRegenerating = ref(false)
const adminKeyDeleting = ref(false)
const showDeleteAdminKeyModal = ref(false)
const generatedAdminApiKey = ref('')
const { copy } = useClipboard()
const form = reactive<SettingsForm>({
modelAliases: {},
@@ -41,6 +55,10 @@ const form = reactive<SettingsForm>({
})
const aliasRows = ref<AliasRow[]>([])
const adminApiKeyStatus = reactive({
exists: false,
maskedKey: null as string | null,
})
const rotationOptions: Array<{
label: string
@@ -123,6 +141,15 @@ function applySettings(data: any) {
aliasError.value = ''
}
function applyAdminApiKeyStatus(data: any) {
adminApiKeyStatus.exists = Boolean(data?.exists)
adminApiKeyStatus.maskedKey = data?.maskedKey || null
}
function maskAdminApiKey(key: string) {
return key.length > 14 ? `${key.slice(0, 10)}...${key.slice(-4)}` : key
}
function addAliasRow() {
aliasRows.value = [...aliasRows.value, { alias: '', target: '' }]
aliasError.value = ''
@@ -143,11 +170,15 @@ function removeAliasRow(index: number) {
async function loadSettings() {
try {
loading.value = true
applySettings(await getSettings())
adminKeyLoading.value = true
const [settings, adminKeyStatus] = await Promise.all([getSettings(), getAdminApiKeyStatus()])
applySettings(settings)
applyAdminApiKeyStatus(adminKeyStatus)
} catch (error: any) {
toast.error(error.message || '加载失败')
} finally {
loading.value = false
adminKeyLoading.value = false
}
}
@@ -179,6 +210,54 @@ async function handleSave() {
}
}
async function handleRegenerateAdminApiKey() {
if (adminKeyRegenerating.value || adminKeyDeleting.value) return
try {
adminKeyRegenerating.value = true
const wasEnabled = adminApiKeyStatus.exists
const data = await regenerateAdminApiKey()
generatedAdminApiKey.value = data.key
applyAdminApiKeyStatus({
exists: true,
maskedKey: maskAdminApiKey(data.key),
})
toast.success(wasEnabled ? '管理员 API Key 已更新' : '管理员 API Key 已生成')
} catch (error: any) {
toast.error(error.message || '生成失败')
} finally {
adminKeyRegenerating.value = false
}
}
async function handleDeleteAdminApiKey() {
if (adminKeyDeleting.value || adminKeyRegenerating.value) return
try {
adminKeyDeleting.value = true
await deleteAdminApiKey()
applyAdminApiKeyStatus({ exists: false, maskedKey: null })
generatedAdminApiKey.value = ''
showDeleteAdminKeyModal.value = false
toast.success('管理员 API Key 已删除')
} catch (error: any) {
toast.error(error.message || '删除失败')
} finally {
adminKeyDeleting.value = false
}
}
async function copyAdminApiKey() {
if (!generatedAdminApiKey.value) return
try {
await copy(generatedAdminApiKey.value)
toast.success('已复制')
} catch (error: any) {
toast.error(error.message || '复制失败')
}
}
onMounted(loadSettings)
</script>
@@ -205,6 +284,84 @@ onMounted(loadSettings)
</header>
<div class="mt-5 grid max-w-6xl gap-5">
<BaseCard
:padded="false"
title="管理员 API Key"
description="用于外部系统集成的全局 API Key,拥有完整管理员权限。"
header-class="px-5 pt-4"
body-class="px-5 py-5"
>
<template #actions>
<div class="flex flex-wrap items-center gap-2">
<BaseButton
variant="default"
:loading="adminKeyRegenerating"
:disabled="adminKeyLoading || adminKeyDeleting"
@click="handleRegenerateAdminApiKey"
>
<template #icon>
<KeyRound class="size-4" />
</template>
{{ adminApiKeyStatus.exists ? '重新生成' : '生成' }}
</BaseButton>
<BaseButton
variant="danger"
:disabled="adminKeyLoading || adminKeyRegenerating || !adminApiKeyStatus.exists"
@click="showDeleteAdminKeyModal = true"
>
<template #icon>
<Trash2 class="size-4" />
</template>
删除
</BaseButton>
</div>
</template>
<div class="grid gap-4">
<div
class="flex min-h-16 items-center justify-between gap-4 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-4 py-3"
>
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-9 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-bg-surface) text-(--cp-normal) shadow-(--cp-shadow-control)"
>
<KeyRound class="size-4" />
</span>
<div class="min-w-0">
<p class="m-0 text-[13px] leading-[1.15] font-[720] text-(--cp-text-primary)">
{{ adminApiKeyStatus.exists ? '已启用' : '未生成' }}
</p>
<p
class="mt-1.5 mb-0 truncate font-mono text-[12px] leading-[1.15] font-[650] text-(--cp-text-secondary)"
>
{{
adminKeyLoading
? '加载中...'
: adminApiKeyStatus.maskedKey || '外部系统暂时无法通过 API Key 调用管理接口'
}}
</p>
</div>
</div>
</div>
<div v-if="generatedAdminApiKey" class="grid gap-2">
<p class="m-0 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)">
完整 Key 仅显示一次,请立即保存。
</p>
<div class="flex min-w-0 items-center gap-2">
<code
class="min-w-0 flex-1 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2.5 font-mono text-[12px] leading-normal font-[650] break-all text-(--cp-text-primary)"
>
{{ generatedAdminApiKey }}
</code>
<BaseButton icon-only size="md" title="复制" @click="copyAdminApiKey">
<Copy class="size-4" />
</BaseButton>
</div>
</div>
</div>
</BaseCard>
<BaseCard
:padded="false"
title="运行参数"
@@ -362,6 +519,19 @@ onMounted(loadSettings)
</button>
</div>
</BaseCard>
<BaseConfirmModal
v-model="showDeleteAdminKeyModal"
title="删除管理员 API Key"
description="删除后外部系统将无法继续使用该 Key 调用管理接口。"
variant="danger"
confirm-text="确认删除"
:loading="adminKeyDeleting"
width="480px"
@confirm="handleDeleteAdminApiKey"
>
<p class="m-0">确定要删除当前管理员 API Key 吗?此操作会立即生效。</p>
</BaseConfirmModal>
</div>
</div>
</template>