feat: add admin api key batch operations

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-06-12 14:23:57 +08:00
co-authored by Codex
parent fad67b9be1
commit ca0aefd1c8
5 changed files with 326 additions and 7 deletions
@@ -0,0 +1 @@
alter table client_api_keys add column label text;
+10 -5
View File
@@ -8,11 +8,11 @@ use tower_http::trace::TraceLayer;
use crate::{
http::{
admin::{
accounts, api_keys, batch_delete_accounts, batch_update_account_status, create_api_key,
delete_account, delete_account_cookies, delete_api_key, get_account_cookies,
import_accounts, login, logs, refresh_models, set_account_cookies, settings,
update_account_label, update_account_status, update_api_key_status, usage_stats,
usage_stats_summary,
accounts, api_keys, batch_delete_accounts, batch_delete_api_keys,
batch_update_account_status, create_api_key, delete_account, delete_account_cookies,
delete_api_key, get_account_cookies, import_accounts, login, logs, refresh_models,
set_account_cookies, settings, update_account_label, update_account_status,
update_api_key_label, update_api_key_status, usage_stats, usage_stats_summary,
},
health::health,
middleware::attach_request_id,
@@ -63,7 +63,12 @@ pub fn build_router(state: AppState) -> Router {
)
.route("/admin/accounts/import", post(import_accounts))
.route("/admin/api-keys", get(api_keys).post(create_api_key))
.route("/admin/api-keys/batch-delete", post(batch_delete_api_keys))
.route("/admin/api-keys/{key_id}", delete(delete_api_key))
.route(
"/admin/api-keys/{key_id}/label",
patch(update_api_key_label),
)
.route(
"/admin/api-keys/{key_id}/status",
patch(update_api_key_status),
+31 -2
View File
@@ -24,6 +24,7 @@ pub type ClientApiKeyRepositoryResult<T> = Result<T, ClientApiKeyRepositoryError
pub struct StoredClientApiKey {
pub id: String,
pub name: String,
pub label: Option<String>,
pub prefix: String,
pub enabled: bool,
pub created_at: String,
@@ -60,6 +61,7 @@ impl ClientApiKeyRepository {
Ok(StoredClientApiKey {
id,
name: name.to_string(),
label: None,
prefix: generated.prefix.clone(),
enabled: true,
created_at: now,
@@ -77,7 +79,7 @@ impl ClientApiKeyRepository {
let (created_at, id) =
decode_cursor(&cursor).ok_or(ClientApiKeyRepositoryError::InvalidCursor)?;
sqlx::query(
"select id, name, prefix, enabled, created_at, last_used_at from client_api_keys where created_at < ? or (created_at = ? and id < ?) order by created_at desc, id desc limit ?",
"select id, name, label, prefix, enabled, created_at, last_used_at from client_api_keys where created_at < ? or (created_at = ? and id < ?) order by created_at desc, id desc limit ?",
)
.bind(&created_at)
.bind(created_at)
@@ -87,7 +89,7 @@ impl ClientApiKeyRepository {
.await?
} else {
sqlx::query(
"select id, name, prefix, enabled, created_at, last_used_at from client_api_keys order by created_at desc, id desc limit ?",
"select id, name, label, prefix, enabled, created_at, last_used_at from client_api_keys order by created_at desc, id desc limit ?",
)
.bind(fetch_limit)
.fetch_all(&self.pool)
@@ -111,6 +113,16 @@ impl ClientApiKeyRepository {
Ok(Page { items, next_cursor })
}
pub async fn get(&self, id: &str) -> ClientApiKeyRepositoryResult<Option<StoredClientApiKey>> {
let row = sqlx::query(
"select id, name, label, prefix, enabled, created_at, last_used_at from client_api_keys where id = ?",
)
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|row| key_from_row(&row)))
}
pub async fn verify_and_touch(
&self,
plaintext: &str,
@@ -147,6 +159,22 @@ impl ClientApiKeyRepository {
Ok(result.rows_affected() > 0)
}
pub async fn set_label(
&self,
id: &str,
label: Option<String>,
) -> ClientApiKeyRepositoryResult<Option<StoredClientApiKey>> {
let result = sqlx::query("update client_api_keys set label = ? where id = ?")
.bind(label)
.bind(id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Ok(None);
}
self.get(id).await
}
pub async fn delete(&self, id: &str) -> ClientApiKeyRepositoryResult<bool> {
let result = sqlx::query("delete from client_api_keys where id = ?")
.bind(id)
@@ -160,6 +188,7 @@ fn key_from_row(row: &sqlx::sqlite::SqliteRow) -> StoredClientApiKey {
StoredClientApiKey {
id: row.get("id"),
name: row.get("name"),
label: row.get("label"),
prefix: row.get("prefix"),
enabled: row.get::<i64, _>("enabled") != 0,
created_at: row.get("created_at"),
+153
View File
@@ -325,6 +325,7 @@ pub struct CreateApiKeyRequest {
pub struct ClientApiKeyData {
pub id: String,
pub name: String,
pub label: Option<String>,
pub prefix: String,
pub enabled: bool,
pub created_at: String,
@@ -336,6 +337,7 @@ pub struct ClientApiKeyData {
pub struct CreatedClientApiKeyData {
pub id: String,
pub name: String,
pub label: Option<String>,
pub prefix: String,
pub enabled: bool,
pub created_at: String,
@@ -349,6 +351,12 @@ pub struct UpdateClientApiKeyStatusRequest {
pub status: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateClientApiKeyLabelRequest {
pub label: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateClientApiKeyStatusData {
@@ -356,6 +364,19 @@ pub struct UpdateClientApiKeyStatusData {
pub enabled: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchDeleteClientApiKeysRequest {
pub ids: Vec<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchDeleteClientApiKeysData {
pub deleted: u32,
pub not_found: Vec<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteClientApiKeyData {
@@ -367,6 +388,7 @@ impl From<StoredClientApiKey> for ClientApiKeyData {
Self {
id: key.id,
name: key.name,
label: key.label,
prefix: key.prefix,
enabled: key.enabled,
created_at: key.created_at,
@@ -380,6 +402,7 @@ impl CreatedClientApiKeyData {
Self {
id: key.id,
name: key.name,
label: key.label,
prefix: key.prefix,
enabled: key.enabled,
created_at: key.created_at,
@@ -1820,6 +1843,136 @@ pub async fn create_api_key(
}
}
pub async fn batch_delete_api_keys(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Json(payload): Json<BatchDeleteClientApiKeysRequest>,
) -> Response {
let request_id = request_id.as_str().to_string();
if payload.ids.is_empty() {
return AdminResponse::new(
StatusCode::BAD_REQUEST,
AdminEnvelope::new(40001, "API key ids are required", (), request_id),
)
.into_response();
}
let Some(pool) = state.db() else {
return AdminResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminEnvelope::new(50001, "Database is not initialized", (), request_id),
)
.into_response();
};
if let Err(response) = require_admin_session(pool, &headers, &request_id).await {
return response;
}
let Some(repo) = state.client_api_key_repository() else {
return AdminResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminEnvelope::new(
50001,
"API key repository is not initialized",
(),
request_id,
),
)
.into_response();
};
let mut deleted = 0u32;
let mut not_found = Vec::new();
for key_id in payload.ids {
match repo.delete(&key_id).await {
Ok(true) => deleted += 1,
Ok(false) => not_found.push(key_id),
Err(_) => {
return AdminResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminEnvelope::new(50001, "Failed to delete API key", (), request_id),
)
.into_response();
}
}
}
AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
BatchDeleteClientApiKeysData { deleted, not_found },
request_id,
),
)
.into_response()
}
pub async fn update_api_key_label(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Path(key_id): Path<String>,
Json(payload): Json<UpdateClientApiKeyLabelRequest>,
) -> Response {
let request_id = request_id.as_str().to_string();
if payload
.label
.as_ref()
.is_some_and(|label| label.chars().count() > 64)
{
return AdminResponse::new(
StatusCode::BAD_REQUEST,
AdminEnvelope::new(
40001,
"API key label must be 64 characters or fewer",
(),
request_id,
),
)
.into_response();
}
let Some(pool) = state.db() else {
return AdminResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminEnvelope::new(50001, "Database is not initialized", (), request_id),
)
.into_response();
};
if let Err(response) = require_admin_session(pool, &headers, &request_id).await {
return response;
}
let Some(repo) = state.client_api_key_repository() else {
return AdminResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminEnvelope::new(
50001,
"API key repository is not initialized",
(),
request_id,
),
)
.into_response();
};
// label 是后台显示备注,不能影响 cpr_ key 的 hash、prefix 或启用状态。
match repo.set_label(&key_id, payload.label).await {
Ok(Some(key)) => AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ClientApiKeyData::from(key), request_id),
)
.into_response(),
Ok(None) => AdminResponse::new(
StatusCode::NOT_FOUND,
AdminEnvelope::new(40401, "API key not found", (), request_id),
)
.into_response(),
Err(_) => AdminResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminEnvelope::new(50001, "Failed to update API key label", (), request_id),
)
.into_response(),
}
}
pub async fn update_api_key_status(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
+131
View File
@@ -305,6 +305,137 @@ async fn admin_api_key_delete_should_remove_client_key_authorization() {
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn admin_api_key_label_should_update_and_clear_local_client_key_name() {
let (app, _dir) = admin_api_keys_test_app("admin-api-key-label.sqlite").await;
let (key_id, _plaintext) = create_admin_api_key(&app, "session_1", "label-key").await;
let renamed = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(format!("/admin/api-keys/{key_id}/label"))
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(r#"{"label":"automation"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(renamed.status(), StatusCode::OK);
let body = response_json(renamed).await;
assert_eq!(body["data"]["name"], "label-key");
assert_eq!(body["data"]["label"], "automation");
let cleared = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(format!("/admin/api-keys/{key_id}/label"))
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(r#"{"label":null}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(cleared.status(), StatusCode::OK);
let body = response_json(cleared).await;
assert_eq!(body["data"]["name"], "label-key");
assert!(body["data"]["label"].is_null());
let too_long = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(format!("/admin/api-keys/{key_id}/label"))
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(json!({ "label": "x".repeat(65) }).to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(too_long.status(), StatusCode::BAD_REQUEST);
let missing = app
.oneshot(
Request::builder()
.method("PATCH")
.uri("/admin/api-keys/missing/label")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(r#"{"label":"automation"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn admin_api_keys_batch_delete_should_remove_found_keys_and_report_missing_ids() {
let (app, _dir) = admin_api_keys_test_app("admin-api-key-batch-delete.sqlite").await;
let (key_a, plaintext_a) = create_admin_api_key(&app, "session_1", "batch-a").await;
let (key_b, plaintext_b) = create_admin_api_key(&app, "session_1", "batch-b").await;
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api-keys/batch-delete")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(
json!({
"ids": [key_a, "ghost", key_b]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["data"]["deleted"], 2);
assert_eq!(body["data"]["notFound"], json!(["ghost"]));
for plaintext in [plaintext_a, plaintext_b] {
let rejected = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/v1/models")
.header("authorization", format!("Bearer {plaintext}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
}
let empty = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api-keys/batch-delete")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(r#"{"ids":[]}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(empty.status(), StatusCode::BAD_REQUEST);
}
async fn admin_api_keys_test_app(db_name: &str) -> (axum::Router, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join(db_name);