Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "aitokenpool"
version = "0.3.1"
version = "0.3.2"
edition = "2021"
description = "AI Token 共享池 — 企业 key 池 + 公共共享市场"
license = "MIT"
Expand Down
14 changes: 12 additions & 2 deletions src/dao.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,11 @@ pub fn create_api_key(conn: &Connection, user_id: i64, name: &str) -> Result<Str
Ok(key)
}

/// 列出用户的 API Key(key 值脱敏)
/// 列出用户的 API Key(key 值脱敏;P2-B 起只列 active,revoked 不再显示
pub fn list_api_keys(conn: &Connection, user_id: i64) -> Result<Vec<serde_json::Value>> {
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)?;
Expand All @@ -69,6 +70,15 @@ pub fn list_api_keys(conn: &Connection, user_id: i64) -> Result<Vec<serde_json::
Ok(out)
}

/// 软删 API Key(P2-B:status → 'revoked');仅属主可删;返回是否删除成功
pub fn revoke_api_key(conn: &Connection, user_id: i64, key_id: i64) -> Result<bool> {
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(
Expand Down
24 changes: 20 additions & 4 deletions src/routes/api_keys.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<AppState>,
auth: AuthUser,
Path(id): Path<i64>,
) -> Result<Json<serde_json::Value>, 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" })))
}
50 changes: 50 additions & 0 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ pub fn router() -> Router<AppState> {
.route("/api/auth/login", post(login))
.route("/api/me", get(me))
.route("/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("/api/models", get(gateway::models))
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(), "/api/api-keys", "{}", Some(&demo_bearer)).await;
assert!(serde_json::from_str::<serde_json::Value>(&body).unwrap()["api_key"].is_string());
let (_, body) = get(st.clone(), "/api/api-keys", Some(&demo_bearer)).await;
let arr: Vec<serde_json::Value> = 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(), "/api/api-keys", Some(&demo_bearer)).await;
let arr: Vec<serde_json::Value> = 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(
Expand Down
2 changes: 2 additions & 0 deletions ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ <h3>按成员用量 <span class="en">By member</span></h3>

<div class="admin-pane hidden" data-admin-pane="org">
<div class="stat-grid" id="dept-stats"></div>
<div id="dept-demo-note"></div>
<div class="toolbar">
<span class="search-box">
<input id="od-search" class="input" placeholder="搜索部门…">
Expand Down Expand Up @@ -462,6 +463,7 @@ <h3 id="dept-form-title">添加部门 <span class="en">Add department</span></h3

<div class="admin-pane hidden" data-admin-pane="ops">
<div class="stat-grid" id="ops-stats"></div>
<div id="ops-demo-note"></div>
<div class="card">
<h3>用户充值 <span class="en">Top up a user</span></h3>
<p class="muted">运营者 = 宿主本人(职责最小化:仅运行概览 + 充值)。按用户名 / 邮箱定位用户 → 输入点数金额 → 确认后余额增加<strong>永久有效点数</strong>并产生一条交易记录。</p>
Expand Down
1 change: 1 addition & 0 deletions ui/js/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ const api = (() => {
get: (path) => request("GET", path),
post: (path, body) => request("POST", path, body),
patch: (path, body) => request("PATCH", path, body),
del: (path) => request("DELETE", path),
saveToken,
getToken,
clearToken,
Expand Down
Loading
Loading