From 7d554208fa23544f89fe4803b61ccd9ba3de7180 Mon Sep 17 00:00:00 2001 From: argszero Date: Tue, 18 Aug 2026 12:11:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20P2-B=20=E5=90=84=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E6=95=B0=E6=8D=AE=E5=AF=B9=E6=8E=A5=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=20API=EF=BC=88=E5=B8=82=E5=9C=BA/=E5=85=B1=E4=BA=AB/=E4=BA=A4?= =?UTF-8?q?=E6=98=93/=E4=BB=AA=E8=A1=A8=E7=9B=98/API=20Key/=E7=AE=A1?= =?UTF-8?q?=E7=90=86=EF=BC=8Cv0.3.2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - DELETE /api/api-keys/:id 软删(status→revoked,仅属主;list 只列 active) - 测试 63→64(api_key_delete_revokes_own_only) 前端(ui/js/app.js + api.js): - api.js 补 del() - Live 数据层:models/sharings/transactions/wallet/dashboard/apiKeys/adminUsers/adminUsage 缓存 + loaders(登录拉取,游客/失败降级 mock + 重试) - 市场页:/api/models(可用性 available_keys>0 绿/繁忙黄);「使用/消费」→ POST /v1/chat/completions(最小占位,成功 toast + 刷新钱包,失败显示后端错误 402/503);游客静态列表 + 登录提示 - 共享页:/api/sharings 列表 + POST 上架 + PATCH 暂停/恢复/删除(软删 off) - 交易页:/api/transactions?type= 分页 + tab 联动(汇总/导出同源) - 仪表盘:/api/wallet month_use/month_earn + /api/dashboard month/series(sparkline) - 设置页:/api/api-keys 列表 + POST 生成(完整 key 仅生成时展示一次)+ DELETE 撤销 - 管理视图:/api/admin/users + 充值(/api/admin/credits)+ /api/admin/usage;部门/加额/ops 保留 mock 并标注「演示数据(P2-C 补齐)」 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/dao.rs | 14 +- src/routes/api_keys.rs | 24 +- src/routes/mod.rs | 50 ++++ ui/index.html | 2 + ui/js/api.js | 1 + ui/js/app.js | 540 ++++++++++++++++++++++++++++++++++++----- 8 files changed, 566 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6c07f24..16f549d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,7 +60,7 @@ dependencies = [ [[package]] name = "aitokenpool" -version = "0.3.1" +version = "0.3.2" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 97cb602..c9fc9e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aitokenpool" -version = "0.3.1" +version = "0.3.2" edition = "2021" description = "AI Token 共享池 — 企业 key 池 + 公共共享市场" license = "MIT" diff --git a/src/dao.rs b/src/dao.rs index 2a589ff..6486761 100644 --- a/src/dao.rs +++ b/src/dao.rs @@ -47,10 +47,11 @@ pub fn create_api_key(conn: &Connection, user_id: i64, name: &str) -> Result Result> { let mut stmt = conn.prepare( - "SELECT id, key_value, name, status, created_at FROM api_keys WHERE user_id = ?1 ORDER BY id DESC", + "SELECT id, key_value, name, status, created_at FROM api_keys \ + WHERE user_id = ?1 AND status = 'active' ORDER BY id DESC", )?; let rows = stmt.query_map([user_id], |r| { let raw: String = r.get(1)?; @@ -69,6 +70,15 @@ pub fn list_api_keys(conn: &Connection, user_id: i64) -> Result Result { + let n = conn.execute( + "UPDATE api_keys SET status = 'revoked' WHERE id = ?1 AND user_id = ?2 AND status = 'active'", + rusqlite::params![key_id, user_id], + )?; + Ok(n == 1) +} + /// Bearer 认证:按 key 查归属用户 + api_key id + 角色 → Some((user_id, api_key_id, role)) pub fn find_api_key_user_and_id(conn: &Connection, key: &str) -> Option<(i64, i64, String)> { conn.query_row( diff --git a/src/routes/api_keys.rs b/src/routes/api_keys.rs index 3e64739..1870fe3 100644 --- a/src/routes/api_keys.rs +++ b/src/routes/api_keys.rs @@ -1,10 +1,9 @@ //! API Key 管理端点(Bearer 认证) //! -//! P0-A(rant 2026-08-17T22:21:52): -//! - POST /api/api-keys:生成(atk_live_ + 24 hex,与 UI 原型一致) -//! - GET /api/api-keys:列表(key 脱敏 atk_live_****xxxx) +//! - P0-A(rant 2026-08-17T22:21:52):POST 生成(atk_live_ + 24 hex);GET 列表(脱敏) +//! - P2-B(rant 2026-08-18T12:02:40):DELETE /api/api-keys/:id 软删(status → 'revoked'),仅属主 -use axum::extract::State; +use axum::extract::{Path, State}; use axum::Json; use crate::auth; @@ -32,3 +31,20 @@ pub async fn list( let keys = crate::dao::list_api_keys(&conn, auth.user_id).map_err(internal)?; Ok(Json(keys)) } + +/// DELETE /api/api-keys/:id(软删;非属主 / 不存在 → 404) +pub async fn remove( + State(st): State, + auth: AuthUser, + Path(id): Path, +) -> Result, ApiErr> { + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + let ok = crate::dao::revoke_api_key(&conn, auth.user_id, id).map_err(internal)?; + if !ok { + return Err(( + axum::http::StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "API Key 不存在或已撤销" })), + )); + } + Ok(Json(serde_json::json!({ "id": id, "status": "revoked" }))) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index a126810..9761feb 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -167,6 +167,7 @@ pub fn router() -> Router { .route("/api/auth/login", post(login)) .route("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/api/me", get(me)) .route("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/api/api-keys", post(api_keys::create).get(api_keys::list)) + .route("/api/api-keys/:id", axum::routing::delete(api_keys::remove)) .route("/v1/chat/completions", post(gateway::chat_completions)) .route("/anthropic/v1/messages", post(gateway::anthropic_messages)) .route("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/api/models", get(gateway::models)) @@ -244,6 +245,23 @@ mod tests { (status, String::from_utf8(bytes.to_vec()).unwrap()) } + async fn del(state: AppState, uri: &str, bearer: Option<&str>) -> (StatusCode, String) { + let mut b = Request::builder().method("DELETE").uri(uri); + if let Some(k) = bearer { + b = b.header("authorization", format!("Bearer {k}")); + } + let resp = router() + .with_state(state) + .oneshot(b.body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024) + .await + .unwrap(); + (status, String::from_utf8(bytes.to_vec()).unwrap()) + } + #[tokio::test] async fn healthz_ok() { let (s, body) = get(test_state("healthz"), "/healthz", None).await; @@ -336,6 +354,38 @@ mod tests { assert_eq!(s, StatusCode::UNAUTHORIZED); } + #[tokio::test] + async fn api_key_delete_revokes_own_only() { + let st = test_state("keydel"); + let demo_bearer = login_bearer(&st, "demo@aitokenpool.local", "demo1234").await; + // 生成一个 key → 拿到 id(从列表) + let (_, body) = post(st.clone(), "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/api/api-keys", "{}", Some(&demo_bearer)).await; + assert!(serde_json::from_str::(&body).unwrap()["api_key"].is_string()); + let (_, body) = get(st.clone(), "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/api/api-keys", Some(&demo_bearer)).await; + let arr: Vec = serde_json::from_str(&body).unwrap(); + let new_id = arr[0]["id"].as_i64().expect("有 id"); + // 删除 + let (s, body) = del( + st.clone(), + &format!("/api/api-keys/{new_id}"), + Some(&demo_bearer), + ) + .await; + assert_eq!(s, StatusCode::OK, "删除应 200: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["status"], "revoked"); + // 列表不再显示(revoked 过滤) + let (_, body) = get(st.clone(), "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/api/api-keys", Some(&demo_bearer)).await; + let arr: Vec = serde_json::from_str(&body).unwrap(); + assert!( + !arr.iter().any(|k| k["id"] == new_id), + "撤销后不再出现在列表" + ); + // 再删 → 404(已撤销) + let (s, _) = del(st, &format!("/api/api-keys/{new_id}"), Some(&demo_bearer)).await; + assert_eq!(s, StatusCode::NOT_FOUND); + } + /// 登录并返回 Bearer async fn login_bearer(st: &AppState, email: &str, password: &str) -> String { let (s, body) = post( diff --git a/ui/index.html b/ui/index.html index 24e19c7..319beb8 100644 --- a/ui/index.html +++ b/ui/index.html @@ -424,6 +424,7 @@

按成员用量 By member