mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
feat: add admin api key settings
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Generated
+1
@@ -340,6 +340,7 @@ dependencies = [
|
||||
"serde_yml",
|
||||
"sha2 0.11.0",
|
||||
"sqlx",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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"))?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user