From 3180d18df0c51748b44bc0ae303f4463cbc7649f Mon Sep 17 00:00:00 2001 From: argszero Date: Tue, 18 Aug 2026 14:12:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20P2-C=20=E9=83=A8=E9=97=A8/?= =?UTF-8?q?=E6=88=90=E5=91=98=E7=AE=A1=E7=90=86=20+=20=E5=8A=A0=E9=A2=9D?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=20+=20=E7=94=A8=E9=87=8F=E6=8A=A5=E8=A1=A8?= =?UTF-8?q?=20+=20=E8=BF=90=E8=90=A5=E8=80=85=E8=A7=86=E5=9B=BE=EF=BC=88v0?= =?UTF-8?q?.3.3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端(schema v4,幂等迁移): - departments 表 + users.dept_id;raise_requests 表 - GET/POST /api/admin/departments、PATCH/DELETE /api/admin/departments/:id - PATCH /api/admin/users/:id {dept_id} 成员改部门/移除 - POST /api/raise-requests + GET(用户自己/admin 全部,role 区分) - POST /api/admin/raise-requests/:id/approve|reject(批准加永久点数 + topup 交易) - GET /api/ops/runtime、POST /api/ops/credits(counterpart=运营者)、GET /api/ops/users - GET /api/admin/usage 扩展为 {users, models, departments} 三组聚合 - seed 运营者账号 ops@aitokenpool.local / ops1234(role=ops) 前端(管理视图全真实,运营视图独立): - 侧边栏按 role 显隐:admin→管理视图、ops→运营视图(原型两者都显示改为按角色) - 组织管理 tab 部门 CRUD + 成员改部门(真实 API) - 成员 tab 部门列/加额申请审批(真实 API) - 用量报表 tab 模型/成员/部门三组柱状条(真实数据) - 钱包页「申请加额」真实提交;运营视图:运行概览 stat 卡 + 成员充值 测试 64→70(dept CRUD/重名409/删非空409/成员改部门、加额申请/重复409/批准+交易/驳回、 ops 403/runtime/credits、usage 三组聚合);fmt/clippy 干净;live smoke 全过 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/db.rs | 59 ++++++- src/routes/admin.rs | 171 ++++++++++++++++--- src/routes/mod.rs | 394 +++++++++++++++++++++++++++++++++++++++++++- src/routes/ops.rs | 173 +++++++++++++++++++ src/routes/org.rs | 224 +++++++++++++++++++++++++ src/routes/raise.rs | 232 ++++++++++++++++++++++++++ ui/index.html | 21 ++- ui/js/app.js | 301 +++++++++++++++++++++++++-------- 10 files changed, 1477 insertions(+), 102 deletions(-) create mode 100644 src/routes/ops.rs create mode 100644 src/routes/org.rs create mode 100644 src/routes/raise.rs diff --git a/Cargo.lock b/Cargo.lock index 16f549d..fd6a206 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,7 +60,7 @@ dependencies = [ [[package]] name = "aitokenpool" -version = "0.3.2" +version = "0.3.3" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index c9fc9e6..8d9ce1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aitokenpool" -version = "0.3.2" +version = "0.3.3" edition = "2021" description = "AI Token 共享池 — 企业 key 池 + 公共共享市场" license = "MIT" diff --git a/src/db.rs b/src/db.rs index 1be471c..6076fdc 100644 --- a/src/db.rs +++ b/src/db.rs @@ -10,7 +10,7 @@ use anyhow::{Context, Result}; use rusqlite::Connection; -pub const SCHEMA_VERSION: i64 = 3; +pub const SCHEMA_VERSION: i64 = 4; /// 打开(或创建)数据库并执行幂等迁移 + dev 种子 pub fn open(path: &str) -> Result { @@ -112,6 +112,22 @@ pub fn migrate(conn: &Connection) -> Result<()> { cost REAL NOT NULL DEFAULT 0, time TEXT NOT NULL DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS departments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + quota REAL NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS raise_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + amount REAL NOT NULL DEFAULT 0, + reason TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + reviewed_by INTEGER, + reviewed_at TEXT + ); CREATE UNIQUE INDEX IF NOT EXISTS idx_models_provider_model ON models(provider, model); "#, @@ -153,6 +169,26 @@ pub fn migrate(conn: &Connection) -> Result<()> { status TEXT NOT NULL DEFAULT 'active' );", )?; + // v4(P2-C):部门/加额审批——users.dept_id + departments / raise_requests 表 + ensure_column(conn, "users", "dept_id", "dept_id INTEGER")?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS departments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + quota REAL NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS raise_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + amount REAL NOT NULL DEFAULT 0, + reason TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + reviewed_by INTEGER, + reviewed_at TEXT + );", + )?; // schema_version:INSERT OR REPLACE 保证幂等 let v: i64 = conn .query_row("SELECT version FROM schema_version", [], |r| r.get(0)) @@ -259,6 +295,27 @@ pub fn seed(conn: &Connection) -> Result<()> { [id], )?; } + + // 运营者账号(P2-C):ops@aitokenpool.local / ops1234,role=ops + let ops_id: Option = conn + .query_row( + "SELECT id FROM users WHERE email = ?1", + ["ops@aitokenpool.local"], + |r| r.get(0), + ) + .ok(); + if ops_id.is_none() { + let hash = hash_password("ops1234")?; + conn.execute( + "INSERT INTO users (email, password_hash, name, role) VALUES (?1, ?2, '运营者', 'ops')", + rusqlite::params!["ops@aitokenpool.local", hash], + )?; + let id = conn.last_insert_rowid(); + conn.execute( + "INSERT OR IGNORE INTO quotas (user_id, balance) VALUES (?1, 0)", + [id], + )?; + } Ok(()) } diff --git a/src/routes/admin.rs b/src/routes/admin.rs index 1902790..744d0b8 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -102,8 +102,10 @@ pub async fn users( let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; let mut stmt = conn .prepare( - "SELECT u.id, u.email, u.name, u.role, COALESCE(q.balance, 0), COALESCE(q.gift_balance, 0) \ - FROM users u LEFT JOIN quotas q ON q.user_id = u.id ORDER BY u.id", + "SELECT u.id, u.email, u.name, u.role, COALESCE(q.balance, 0), COALESCE(q.gift_balance, 0), \ + u.dept_id, COALESCE(d.name, '') \ + FROM users u LEFT JOIN quotas q ON q.user_id = u.id \ + LEFT JOIN departments d ON d.id = u.dept_id ORDER BY u.id", ) .map_err(internal)?; let rows = stmt @@ -115,6 +117,8 @@ pub async fn users( "role": r.get::<_, String>(3)?, "balance": r.get::<_, f64>(4)?, "gift_balance": r.get::<_, f64>(5)?, + "dept_id": r.get::<_, Option>(6)?, + "dept_name": r.get::<_, String>(7)?, })) }) .map_err(internal)?; @@ -125,38 +129,165 @@ pub async fn users( Ok(Json(out)) } -/// GET /api/admin/usage:用量报表(每用户本月 tokens/点数/调用次数) +/// PATCH /api/admin/users/:id:成员改部门 / 移除(P2-C;{dept_id: null} = 移出部门) +#[derive(Debug, Deserialize)] +pub struct UserPatch { + pub dept_id: Option, +} + +pub async fn patch_user( + State(st): State, + auth: AuthUser, + axum::extract::Path(id): axum::extract::Path, + Json(req): Json, +) -> Result, ApiErr> { + crate::routes::org::require_admin(&auth)?; + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + let exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1)", + [id], + |r| r.get(0), + ) + .unwrap_or(false); + if !exists { + return Err(( + axum::http::StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "用户不存在" })), + )); + } + if let Some(did) = req.dept_id { + let dept_exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM departments WHERE id = ?1)", + [did], + |r| r.get(0), + ) + .unwrap_or(false); + if !dept_exists { + return Err(( + axum::http::StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "部门不存在" })), + )); + } + conn.execute( + "UPDATE users SET dept_id = ?1 WHERE id = ?2", + params![did, id], + ) + .map_err(internal)?; + } else { + conn.execute("UPDATE users SET dept_id = NULL WHERE id = ?1", [id]) + .map_err(internal)?; + } + let (email, dept_id): (String, Option) = conn + .query_row( + "SELECT email, dept_id FROM users WHERE id = ?1", + [id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .map_err(internal)?; + Ok(Json(serde_json::json!({ + "id": id, + "email": email, + "dept_id": dept_id, + }))) +} + +/// GET /api/admin/usage:用量报表(P2-C 扩展为三组聚合) +/// 返回 { users: [{id,email,name,dept_id,dept_name,month_tokens,month_cost,month_calls}], +/// models: [{model,tokens,cost,calls}], +/// departments: [{id,name,tokens,cost,calls}] } pub async fn usage( State(st): State, auth: AuthUser, -) -> Result>, ApiErr> { +) -> Result, ApiErr> { require_admin(&auth)?; let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + // 按成员 let mut stmt = conn .prepare( - "SELECT u.id, u.email, u.name, \ + "SELECT u.id, u.email, u.name, u.dept_id, COALESCE(d.name, ''), \ COALESCE(SUM(ur.tokens), 0), COALESCE(SUM(ur.cost), 0), COUNT(ur.id) \ FROM users u \ LEFT JOIN usage_records ur ON ur.user_id = u.id \ AND strftime('%Y-%m', ur.time) = strftime('%Y-%m', 'now') \ + LEFT JOIN departments d ON d.id = u.dept_id \ GROUP BY u.id ORDER BY u.id", ) .map_err(internal)?; - let rows = stmt - .query_map([], |r| { - Ok(serde_json::json!({ - "id": r.get::<_, i64>(0)?, - "email": r.get::<_, String>(1)?, - "name": r.get::<_, String>(2)?, - "month_tokens": r.get::<_, f64>(3)?, - "month_cost": r.get::<_, f64>(4)?, - "month_calls": r.get::<_, i64>(5)?, - })) - }) + let mut users = Vec::new(); + { + let rows = stmt + .query_map([], |r| { + Ok(serde_json::json!({ + "id": r.get::<_, i64>(0)?, + "email": r.get::<_, String>(1)?, + "name": r.get::<_, String>(2)?, + "dept_id": r.get::<_, Option>(3)?, + "dept_name": r.get::<_, String>(4)?, + "month_tokens": r.get::<_, f64>(5)?, + "month_cost": r.get::<_, f64>(6)?, + "month_calls": r.get::<_, i64>(7)?, + })) + }) + .map_err(internal)?; + for r in rows { + users.push(r.map_err(internal)?); + } + } + // 按模型 + let mut stmt = conn + .prepare( + "SELECT COALESCE(model, ''), COALESCE(SUM(tokens), 0), COALESCE(SUM(cost), 0), COUNT(*) \ + FROM usage_records WHERE strftime('%Y-%m', time) = strftime('%Y-%m', 'now') \ + GROUP BY model ORDER BY SUM(cost) DESC", + ) .map_err(internal)?; - let mut out = Vec::new(); - for r in rows { - out.push(r.map_err(internal)?); + let mut models = Vec::new(); + { + let rows = stmt + .query_map([], |r| { + Ok(serde_json::json!({ + "model": r.get::<_, String>(0)?, + "tokens": r.get::<_, f64>(1)?, + "cost": r.get::<_, f64>(2)?, + "calls": r.get::<_, i64>(3)?, + })) + }) + .map_err(internal)?; + for r in rows { + models.push(r.map_err(internal)?); + } } - Ok(Json(out)) + // 按部门 + let mut stmt = conn + .prepare( + "SELECT COALESCE(d.name, '(未分配)'), COALESCE(SUM(ur.tokens), 0), COALESCE(SUM(ur.cost), 0), COUNT(ur.id) \ + FROM usage_records ur JOIN users u ON u.id = ur.user_id \ + LEFT JOIN departments d ON d.id = u.dept_id \ + WHERE strftime('%Y-%m', ur.time) = strftime('%Y-%m', 'now') \ + GROUP BY d.id ORDER BY SUM(ur.cost) DESC", + ) + .map_err(internal)?; + let mut departments = Vec::new(); + { + let rows = stmt + .query_map([], |r| { + Ok(serde_json::json!({ + "name": r.get::<_, String>(0)?, + "tokens": r.get::<_, f64>(1)?, + "cost": r.get::<_, f64>(2)?, + "calls": r.get::<_, i64>(3)?, + })) + }) + .map_err(internal)?; + for r in rows { + departments.push(r.map_err(internal)?); + } + } + Ok(Json(serde_json::json!({ + "users": users, + "models": models, + "departments": departments, + }))) } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 9761feb..353d5c1 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -11,6 +11,9 @@ pub mod admin; pub mod api_keys; +pub mod ops; +pub mod org; +pub mod raise; pub mod sharing; pub mod wallet; @@ -180,7 +183,26 @@ pub fn router() -> Router { // P1:管理员(充值 / 成员列表 / 用量报表) .route("/api/admin/credits", post(admin::credits)) .route("/api/admin/users", get(admin::users)) + .route( + "/api/admin/users/:id", + axum::routing::patch(admin::patch_user), + ) .route("/api/admin/usage", get(admin::usage)) + // P2-C:部门管理 / 加额审批 / 运营者 + .route("/api/admin/departments", get(org::list).post(org::create)) + .route( + "/api/admin/departments/:id", + axum::routing::patch(org::patch).delete(org::remove), + ) + .route("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/api/raise-requests", post(raise::create).get(raise::list)) + .route( + "/api/admin/raise-requests/:id/approve", + post(raise::approve), + ) + .route("/api/admin/raise-requests/:id/reject", post(raise::reject)) + .route("/api/ops/runtime", get(ops::runtime)) + .route("/api/ops/credits", post(ops::credits)) + .route("/api/ops/users", get(ops::users)) // P2-A:静态托管(ui/ 目录;API 路由优先,未命中回退到文件服务) .fallback_service(tower_http::services::ServeDir::new("ui")) } @@ -262,6 +284,31 @@ mod tests { (status, String::from_utf8(bytes.to_vec()).unwrap()) } + async fn patch( + state: AppState, + uri: &str, + body: &str, + bearer: Option<&str>, + ) -> (StatusCode, String) { + let mut b = Request::builder() + .method("PATCH") + .uri(uri) + .header("content-type", "application/json"); + if let Some(k) = bearer { + b = b.header("authorization", format!("Bearer {k}")); + } + let resp = router() + .with_state(state) + .oneshot(b.body(Body::from(body.to_string())).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; @@ -463,25 +510,29 @@ mod tests { async fn admin_users_and_usage_lists() { let st = test_state("adminlist"); let admin_bearer = login_bearer(&st, "admin@aitokenpool.local", "admin1234").await; - // users:demo + admin 都在列表 + // users:demo + admin + ops 都在列表 let (s, body) = get(st.clone(), "/api/admin/users", Some(&admin_bearer)).await; assert_eq!(s, StatusCode::OK, "users 应 200: {body}"); let arr: Vec = serde_json::from_str(&body).unwrap(); - assert_eq!(arr.len(), 2, "demo + admin: {body}"); + assert_eq!(arr.len(), 3, "demo + admin + ops: {body}"); assert!(arr.iter().any(|u| u["role"] == "admin"), "admin 在列表中"); + assert!(arr.iter().any(|u| u["role"] == "ops"), "ops 在列表中"); assert!( arr.iter().any(|u| u["email"] == "demo@aitokenpool.local"), "demo 在列表中" ); - // usage:每用户本月聚合 + // usage:对象 {users, models, departments} 三组聚合 let (s, body) = get(st.clone(), "/api/admin/usage", Some(&admin_bearer)).await; assert_eq!(s, StatusCode::OK, "usage 应 200: {body}"); - let arr: Vec = serde_json::from_str(&body).unwrap(); - assert_eq!(arr.len(), 2); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + let users = v["users"].as_array().expect("users 为数组"); + assert_eq!(users.len(), 3, "每用户一行: {body}"); assert!( - arr.iter().all(|u| u["month_tokens"] == 0.0), + users.iter().all(|u| u["month_tokens"] == 0.0), "无调用时 tokens 为 0: {body}" ); + assert!(v["models"].as_array().is_some(), "models 组存在"); + assert!(v["departments"].as_array().is_some(), "departments 组存在"); // 非 admin 访问 users → 403 let demo_bearer = login_bearer(&st, "demo@aitokenpool.local", "demo1234").await; let (s, _) = get(st.clone(), "/api/admin/users", Some(&demo_bearer)).await; @@ -536,4 +587,335 @@ mod tests { let v: serde_json::Value = serde_json::from_str(&body).unwrap(); assert_eq!(v["role"], "admin"); } + + /* ---- P2-C:部门管理 / 加额审批 / 运营者 / 用量三组聚合 ---- */ + + #[tokio::test] + async fn dept_crud_duplicate_and_delete_nonempty() { + let st = test_state("dept"); + let admin = login_bearer(&st, "admin@aitokenpool.local", "admin1234").await; + // 建部门 + let (s, body) = post( + st.clone(), + "/api/admin/departments", + r#"{"name":"研发","quota":80000}"#, + Some(&admin), + ) + .await; + assert_eq!(s, StatusCode::OK, "建部门应 200: {body}"); + let dept_id = serde_json::from_str::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + // 重名 → 409 + let (s, body) = post( + st.clone(), + "/api/admin/departments", + r#"{"name":"研发","quota":100}"#, + Some(&admin), + ) + .await; + assert_eq!(s, StatusCode::CONFLICT, "重名应 409: {body}"); + // 列表含新部门 + let (s, body) = get(st.clone(), "/api/admin/departments", Some(&admin)).await; + assert_eq!(s, StatusCode::OK, "列表应 200: {body}"); + let arr: Vec = serde_json::from_str(&body).unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["name"], "研发"); + assert_eq!(arr[0]["member_count"], 0); + // PATCH 改名 + 配额 + let (s, body) = patch( + st.clone(), + &format!("/api/admin/departments/{dept_id}"), + r#"{"name":"研发中心","quota":90000}"#, + Some(&admin), + ) + .await; + assert_eq!(s, StatusCode::OK, "PATCH 应 200: {body}"); + // 分配成员(demo → 部门) + let (s, body) = patch( + st.clone(), + "/api/admin/users/1", + &format!(r#"{{"dept_id":{dept_id}}}"#), + Some(&admin), + ) + .await; + assert_eq!(s, StatusCode::OK, "成员改部门应 200: {body}"); + // 删除非空部门 → 409 + let (s, body) = del( + st.clone(), + &format!("/api/admin/departments/{dept_id}"), + Some(&admin), + ) + .await; + assert_eq!(s, StatusCode::CONFLICT, "非空部门应 409: {body}"); + // 移除成员 → 可删 + let (s, body) = patch( + st.clone(), + "/api/admin/users/1", + r#"{"dept_id":null}"#, + Some(&admin), + ) + .await; + assert_eq!(s, StatusCode::OK, "移除成员应 200: {body}"); + let (s, body) = del( + st, + &format!("/api/admin/departments/{dept_id}"), + Some(&admin), + ) + .await; + assert_eq!(s, StatusCode::OK, "空部门应可删: {body}"); + } + + #[tokio::test] + async fn dept_requires_admin_and_valid_dept() { + let st = test_state("dept403"); + // 非 admin 建部门 → 403 + let demo = login_bearer(&st, "demo@aitokenpool.local", "demo1234").await; + let (s, _) = post( + st.clone(), + "/api/admin/departments", + r#"{"name":"研发","quota":100}"#, + Some(&demo), + ) + .await; + assert_eq!(s, StatusCode::FORBIDDEN); + // admin 给不存在的部门分配成员 → 404 + let admin = login_bearer(&st, "admin@aitokenpool.local", "admin1234").await; + let (s, _) = patch(st, "/api/admin/users/1", r#"{"dept_id":999}"#, Some(&admin)).await; + assert_eq!(s, StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn raise_request_apply_dup_approve_reject() { + let st = test_state("raise"); + let demo = login_bearer(&st, "demo@aitokenpool.local", "demo1234").await; + let admin = login_bearer(&st, "admin@aitokenpool.local", "admin1234").await; + // demo 申请 500 点 + let (s, body) = post( + st.clone(), + "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/api/raise-requests", + r#"{"amount":500,"reason":"任务增加"}"#, + Some(&demo), + ) + .await; + assert_eq!(s, StatusCode::OK, "申请应 200: {body}"); + let req_id = serde_json::from_str::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + // 重复 pending → 409 + let (s, body) = post( + st.clone(), + "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/api/raise-requests", + r#"{"amount":100,"reason":"再来"}"#, + Some(&demo), + ) + .await; + assert_eq!(s, StatusCode::CONFLICT, "重复申请应 409: {body}"); + // 非法 amount → 400 + let (s, _) = post( + st.clone(), + "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/api/raise-requests", + r#"{"amount":-1,"reason":"x"}"#, + Some(&demo), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); + // 用户看自己的(1 条) + let (s, body) = get(st.clone(), "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/api/raise-requests", Some(&demo)).await; + assert_eq!(s, StatusCode::OK); + let arr: Vec = serde_json::from_str(&body).unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["amount"], 500.0); + // admin 看全部(1 条,含用户信息) + let (s, body) = get(st.clone(), "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/api/raise-requests", Some(&admin)).await; + assert_eq!(s, StatusCode::OK); + let arr: Vec = serde_json::from_str(&body).unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["email"], "demo@aitokenpool.local"); + // 批准 → balance += 500 + 交易记录 + let (s, body) = post( + st.clone(), + &format!("/api/admin/raise-requests/{req_id}/approve"), + "{}", + Some(&admin), + ) + .await; + assert_eq!(s, StatusCode::OK, "批准应 200: {body}"); + let (_, body) = get(st.clone(), "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/api/wallet", Some(&demo)).await; + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["balance"], 12471.0 + 500.0, "批准后永久余额增加: {body}"); + let (s, body) = get(st.clone(), "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/api/transactions?type=topup", Some(&demo)).await; + assert_eq!(s, StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["items"].as_array().unwrap().len(), 1); + assert_eq!(v["items"][0]["counterpart"], "加额审批"); + // 重复批准 → 409 + let (s, _) = post( + st.clone(), + &format!("/api/admin/raise-requests/{req_id}/approve"), + "{}", + Some(&admin), + ) + .await; + assert_eq!(s, StatusCode::CONFLICT); + // 再申请一条 → 驳回 + let (s, body) = post( + st.clone(), + "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/api/raise-requests", + r#"{"amount":50,"reason":"再试"}"#, + Some(&demo), + ) + .await; + assert_eq!(s, StatusCode::OK); + let req2 = serde_json::from_str::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + let (s, body) = post( + st, + &format!("/api/admin/raise-requests/{req2}/reject"), + "{}", + Some(&admin), + ) + .await; + assert_eq!(s, StatusCode::OK, "驳回应 200: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["status"], "rejected"); + } + + #[tokio::test] + async fn raise_requires_admin_review() { + let st = test_state("raise403"); + let demo = login_bearer(&st, "demo@aitokenpool.local", "demo1234").await; + let (s, body) = post( + st.clone(), + "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/api/raise-requests", + r#"{"amount":100,"reason":"x"}"#, + Some(&demo), + ) + .await; + assert_eq!(s, StatusCode::OK); + let req_id = serde_json::from_str::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + // 非 admin 批准 → 403 + let (s, _) = post( + st, + &format!("/api/admin/raise-requests/{req_id}/approve"), + "{}", + Some(&demo), + ) + .await; + assert_eq!(s, StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn ops_runtime_credits_users() { + let st = test_state("ops"); + let ops_bearer = login_bearer(&st, "ops@aitokenpool.local", "ops1234").await; + // 普通用户访问 ops → 403 + let demo = login_bearer(&st, "demo@aitokenpool.local", "demo1234").await; + let (s, _) = get(st.clone(), "/api/ops/runtime", Some(&demo)).await; + assert_eq!(s, StatusCode::FORBIDDEN, "普通用户应 403"); + // runtime 聚合 + let (s, body) = get(st.clone(), "/api/ops/runtime", Some(&ops_bearer)).await; + assert_eq!(s, StatusCode::OK, "ops runtime 应 200: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["users"], 3, "demo+admin+ops: {body}"); + assert_eq!(v["month_calls"], 0); + // users 列表(含余额) + let (s, body) = get(st.clone(), "/api/ops/users", Some(&ops_bearer)).await; + assert_eq!(s, StatusCode::OK); + let arr: Vec = serde_json::from_str(&body).unwrap(); + assert_eq!(arr.len(), 3); + assert!(arr.iter().any(|u| u["email"] == "demo@aitokenpool.local")); + // credits 给 demo 充 200 + let (s, body) = post( + st.clone(), + "/api/ops/credits", + r#"{"user_id":1,"amount":200}"#, + Some(&ops_bearer), + ) + .await; + assert_eq!(s, StatusCode::OK, "ops 充值应 200: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["balance"], 12471.0 + 200.0); + // 交易记录 counterpart=运营者(demo 视角) + let (s, body) = get(st.clone(), "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/api/transactions?type=topup", Some(&demo)).await; + assert_eq!(s, StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["items"][0]["counterpart"], "运营者"); + // 负数金额 → 400 + let (s, _) = post( + st, + "/api/ops/credits", + r#"{"user_id":1,"amount":-5}"#, + Some(&ops_bearer), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn usage_three_group_aggregation() { + let st = test_state("usage3"); + let admin = login_bearer(&st, "admin@aitokenpool.local", "admin1234").await; + // 造部门 + 分配 demo + 插一条 usage_records + let (_, body) = post( + st.clone(), + "/api/admin/departments", + r#"{"name":"研发","quota":100000}"#, + Some(&admin), + ) + .await; + let dept_id = serde_json::from_str::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + let (_, _) = patch( + st.clone(), + "/api/admin/users/1", + &format!(r#"{{"dept_id":{dept_id}}}"#), + Some(&admin), + ) + .await; + { + let conn = st.db.lock().unwrap(); + conn.execute( + "INSERT INTO usage_records (user_id, model, tokens, cost) VALUES (1, 'gpt-test', 1000, 2.5)", + [], + ) + .unwrap(); + } + let (s, body) = get(st.clone(), "/api/admin/usage", Some(&admin)).await; + assert_eq!(s, StatusCode::OK, "usage 应 200: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + // users 组 + let demo_u = v["users"] + .as_array() + .unwrap() + .iter() + .find(|u| u["id"] == 1) + .unwrap(); + assert_eq!(demo_u["month_tokens"], 1000.0); + assert_eq!(demo_u["month_calls"], 1); + assert_eq!(demo_u["dept_name"], "研发"); + // models 组 + let m = v["models"] + .as_array() + .unwrap() + .iter() + .find(|x| x["model"] == "gpt-test") + .unwrap(); + assert_eq!(m["tokens"], 1000.0); + assert_eq!(m["cost"], 2.5); + assert_eq!(m["calls"], 1); + // departments 组 + let d = v["departments"] + .as_array() + .unwrap() + .iter() + .find(|x| x["name"] == "研发") + .unwrap(); + assert_eq!(d["tokens"], 1000.0); + assert_eq!(d["cost"], 2.5); + } } diff --git a/src/routes/ops.rs b/src/routes/ops.rs new file mode 100644 index 0000000..b03f75f --- /dev/null +++ b/src/routes/ops.rs @@ -0,0 +1,173 @@ +//! 运营者 API(P2-C,rant 2026-08-18T14:03:51) +//! +//! 运营者(role=ops)= 平台运维角色,职责最小化:运行概览 + 给任意用户充值。 +//! - GET /api/ops/runtime:平台运行概览(用户数 / 上架 key 数 / 本月调用 / 本月点数流水,全库聚合) +//! - POST /api/ops/credits {user_id, amount}:给任意用户充值(写 transactions counterpart='运营者') +//! - GET /api/ops/users:全平台用户列表(含余额) +//! - 权限:require_role(&["admin","ops"])——普通 user 403 + +use axum::extract::State; +use axum::Json; +use rusqlite::params; +use serde::Deserialize; + +use crate::routes::{internal, ApiErr, AppState, AuthUser}; + +/// 权限判定:admin 或 ops 均可访问运营端点 +fn require_role(auth: &AuthUser) -> Result<(), ApiErr> { + if auth.role == "admin" || auth.role == "ops" { + Ok(()) + } else { + Err(( + axum::http::StatusCode::FORBIDDEN, + Json(serde_json::json!({ "error": "需要运营者权限" })), + )) + } +} + +/// 充值请求 +#[derive(Debug, Deserialize)] +pub struct OpsCreditReq { + pub user_id: i64, + pub amount: f64, +} + +/// GET /api/ops/runtime:平台运行概览(全库聚合) +pub async fn runtime( + State(st): State, + auth: AuthUser, +) -> Result, ApiErr> { + require_role(&auth)?; + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + let users: i64 = conn + .query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0)) + .unwrap_or(0); + let active_keys: i64 = conn + .query_row("SELECT COUNT(*) FROM keys WHERE status = 'on'", [], |r| { + r.get(0) + }) + .unwrap_or(0); + let month_calls: i64 = conn + .query_row( + "SELECT COUNT(*) FROM usage_records WHERE strftime('%Y-%m', time) = strftime('%Y-%m', 'now')", + [], + |r| r.get(0), + ) + .unwrap_or(0); + let month_in: f64 = conn + .query_row( + "SELECT COALESCE(SUM(pts), 0) FROM transactions \ + WHERE pts > 0 AND strftime('%Y-%m', time) = strftime('%Y-%m', 'now')", + [], + |r| r.get(0), + ) + .unwrap_or(0.0); + let month_out: f64 = conn + .query_row( + "SELECT COALESCE(SUM(ABS(pts)), 0) FROM transactions \ + WHERE pts < 0 AND strftime('%Y-%m', time) = strftime('%Y-%m', 'now')", + [], + |r| r.get(0), + ) + .unwrap_or(0.0); + let total_txs: i64 = conn + .query_row("SELECT COUNT(*) FROM transactions", [], |r| r.get(0)) + .unwrap_or(0); + Ok(Json(serde_json::json!({ + "users": users, + "active_keys": active_keys, + "month_calls": month_calls, + "month_in": month_in, + "month_out": month_out, + "total_txs": total_txs, + }))) +} + +/// POST /api/ops/credits:给任意用户充值永久点数(counterpart='运营者') +pub async fn credits( + State(st): State, + auth: AuthUser, + Json(req): Json, +) -> Result, ApiErr> { + require_role(&auth)?; + if req.amount <= 0.0 { + return Err(( + axum::http::StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "amount 必须大于 0" })), + )); + } + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + let exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1)", + [req.user_id], + |r| r.get(0), + ) + .unwrap_or(false); + if !exists { + return Err(( + axum::http::StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "用户不存在" })), + )); + } + conn.execute( + "INSERT OR IGNORE INTO quotas (user_id, balance) VALUES (?1, 0)", + [req.user_id], + ) + .map_err(internal)?; + conn.execute( + "UPDATE quotas SET balance = balance + ?1, updated_at = datetime('now') WHERE user_id = ?2", + params![req.amount, req.user_id], + ) + .map_err(internal)?; + conn.execute( + "INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, pts, type, status) \ + VALUES (?1, '运营者', NULL, 'ops', 0, ?2, 'topup', '成功')", + params![req.user_id, req.amount], + ) + .map_err(internal)?; + let balance: f64 = conn + .query_row( + "SELECT COALESCE(balance, 0) FROM quotas WHERE user_id = ?1", + [req.user_id], + |r| r.get(0), + ) + .unwrap_or(0.0); + Ok(Json(serde_json::json!({ + "user_id": req.user_id, + "amount": req.amount, + "balance": balance, + }))) +} + +/// GET /api/ops/users:全平台用户列表(含余额) +pub async fn users( + State(st): State, + auth: AuthUser, +) -> Result>, ApiErr> { + require_role(&auth)?; + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + let mut stmt = conn + .prepare( + "SELECT u.id, u.email, u.name, u.role, COALESCE(q.balance, 0), COALESCE(q.gift_balance, 0) \ + FROM users u LEFT JOIN quotas q ON q.user_id = u.id ORDER BY u.id", + ) + .map_err(internal)?; + let rows = stmt + .query_map([], |r| { + Ok(serde_json::json!({ + "id": r.get::<_, i64>(0)?, + "email": r.get::<_, String>(1)?, + "name": r.get::<_, String>(2)?, + "role": r.get::<_, String>(3)?, + "balance": r.get::<_, f64>(4)?, + "gift_balance": r.get::<_, f64>(5)?, + })) + }) + .map_err(internal)?; + let mut out = Vec::new(); + for r in rows { + out.push(r.map_err(internal)?); + } + Ok(Json(out)) +} diff --git a/src/routes/org.rs b/src/routes/org.rs new file mode 100644 index 0000000..6bb85a0 --- /dev/null +++ b/src/routes/org.rs @@ -0,0 +1,224 @@ +//! 部门管理 API(P2-C,rant 2026-08-18T14:03:51) +//! +//! - GET /api/admin/departments:部门列表(成员数 / 本月已用 / 剩余,join users + usage_records) +//! - POST /api/admin/departments {name, quota}:新增部门(重名 409) +//! - PATCH /api/admin/departments/:id {name?, quota?}:改部门名 / 月分配(重名 409) +//! - DELETE /api/admin/departments/:id:删除部门(有成员 409) +//! - PATCH /api/admin/users/:id {dept_id}:成员改部门 / 移除(见 admin.rs patch_user) + +use axum::extract::{Path, State}; +use axum::Json; +use rusqlite::params; +use serde::Deserialize; + +use crate::routes::{internal, ApiErr, AppState, AuthUser}; + +/// 权限判定:仅 admin(管理端点统一) +pub(crate) fn require_admin(auth: &AuthUser) -> Result<(), ApiErr> { + if auth.role == "admin" { + Ok(()) + } else { + Err(( + axum::http::StatusCode::FORBIDDEN, + Json(serde_json::json!({ "error": "需要管理员权限" })), + )) + } +} + +/// 新增部门请求 +#[derive(Debug, Deserialize)] +pub struct DeptReq { + pub name: String, + pub quota: f64, +} + +/// PATCH 部门请求(字段可选) +#[derive(Debug, Default, Deserialize)] +pub struct DeptPatch { + #[serde(default)] + pub name: String, + #[serde(default)] + pub quota: Option, +} + +/// GET /api/admin/departments:部门 + 成员数 + 本月已用(usage_records 经 users.dept_id 聚合)+ 剩余 +pub async fn list( + State(st): State, + auth: AuthUser, +) -> Result>, ApiErr> { + require_admin(&auth)?; + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + let mut stmt = conn + .prepare( + "SELECT d.id, d.name, d.quota, d.created_at, \ + (SELECT COUNT(*) FROM users u WHERE u.dept_id = d.id), \ + (SELECT COALESCE(SUM(ur.cost), 0) FROM usage_records ur \ + JOIN users u ON u.id = ur.user_id WHERE u.dept_id = d.id \ + AND strftime('%Y-%m', ur.time) = strftime('%Y-%m', 'now')) \ + FROM departments d ORDER BY d.id", + ) + .map_err(internal)?; + let rows = stmt + .query_map([], |r| { + Ok(serde_json::json!({ + "id": r.get::<_, i64>(0)?, + "name": r.get::<_, String>(1)?, + "quota": r.get::<_, f64>(2)?, + "created_at": r.get::<_, String>(3)?, + "member_count": r.get::<_, i64>(4)?, + "month_cost": r.get::<_, f64>(5)?, + "remaining": r.get::<_, f64>(2)? - r.get::<_, f64>(5)?, + })) + }) + .map_err(internal)?; + let mut out = Vec::new(); + for r in rows { + out.push(r.map_err(internal)?); + } + Ok(Json(out)) +} + +/// POST /api/admin/departments:新增部门(重名 → 409) +pub async fn create( + State(st): State, + auth: AuthUser, + Json(req): Json, +) -> Result, ApiErr> { + require_admin(&auth)?; + let name = req.name.trim().to_string(); + if name.is_empty() || req.quota <= 0.0 { + return Err(( + axum::http::StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "name 不能为空且 quota 必须大于 0" })), + )); + } + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + let dup: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM departments WHERE name = ?1)", + [&name], + |r| r.get(0), + ) + .unwrap_or(false); + if dup { + return Err(( + axum::http::StatusCode::CONFLICT, + Json(serde_json::json!({ "error": format!("部门「{name}」已存在") })), + )); + } + conn.execute( + "INSERT INTO departments (name, quota) VALUES (?1, ?2)", + params![name, req.quota], + ) + .map_err(internal)?; + let id = conn.last_insert_rowid(); + Ok(Json( + serde_json::json!({ "id": id, "name": name, "quota": req.quota }), + )) +} + +/// PATCH /api/admin/departments/:id:改名 / 改月分配(重名 → 409) +pub async fn patch( + State(st): State, + auth: AuthUser, + Path(id): Path, + Json(req): Json, +) -> Result, ApiErr> { + require_admin(&auth)?; + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + let exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM departments WHERE id = ?1)", + [id], + |r| r.get(0), + ) + .unwrap_or(false); + if !exists { + return Err(( + axum::http::StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "部门不存在" })), + )); + } + if !req.name.is_empty() { + let dup: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM departments WHERE name = ?1 AND id != ?2)", + params![req.name.trim(), id], + |r| r.get(0), + ) + .unwrap_or(false); + if dup { + return Err(( + axum::http::StatusCode::CONFLICT, + Json(serde_json::json!({ "error": format!("部门「{}」已存在", req.name.trim()) })), + )); + } + conn.execute( + "UPDATE departments SET name = ?1 WHERE id = ?2", + params![req.name.trim(), id], + ) + .map_err(internal)?; + } + if let Some(q) = req.quota { + if q <= 0.0 { + return Err(( + axum::http::StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "quota 必须大于 0" })), + )); + } + conn.execute( + "UPDATE departments SET quota = ?1 WHERE id = ?2", + params![q, id], + ) + .map_err(internal)?; + } + let (name, quota): (String, f64) = conn + .query_row( + "SELECT name, quota FROM departments WHERE id = ?1", + [id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .map_err(internal)?; + Ok(Json( + serde_json::json!({ "id": id, "name": name, "quota": quota }), + )) +} + +/// DELETE /api/admin/departments/:id:删除(有成员 → 409) +pub async fn remove( + State(st): State, + auth: AuthUser, + Path(id): Path, +) -> Result, ApiErr> { + require_admin(&auth)?; + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + let exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM departments WHERE id = ?1)", + [id], + |r| r.get(0), + ) + .unwrap_or(false); + if !exists { + return Err(( + axum::http::StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "部门不存在" })), + )); + } + let members: i64 = conn + .query_row("SELECT COUNT(*) FROM users WHERE dept_id = ?1", [id], |r| { + r.get(0) + }) + .unwrap_or(0); + if members > 0 { + return Err(( + axum::http::StatusCode::CONFLICT, + Json( + serde_json::json!({ "error": format!("部门下还有 {members} 名成员,请先调整成员部门") }), + ), + )); + } + conn.execute("DELETE FROM departments WHERE id = ?1", [id]) + .map_err(internal)?; + Ok(Json(serde_json::json!({ "deleted": id }))) +} diff --git a/src/routes/raise.rs b/src/routes/raise.rs new file mode 100644 index 0000000..1dcd664 --- /dev/null +++ b/src/routes/raise.rs @@ -0,0 +1,232 @@ +//! 加额审批 API(P2-C,rant 2026-08-18T14:03:51,US-20) +//! +//! - POST /api/raise-requests {amount, reason}:用户自助申请(正整数;pending 重复 409) +//! - GET /api/raise-requests?status=:用户看自己的;admin 看全部(role 区分) +//! - POST /api/admin/raise-requests/:id/approve:批准 → 永久 balance += amount + transactions(type='topup', counterpart='加额审批') +//! - POST /api/admin/raise-requests/:id/reject:驳回 + +use axum::extract::{Query, State}; +use axum::Json; +use rusqlite::params; +use serde::Deserialize; + +use crate::routes::{internal, ApiErr, AppState, AuthUser}; + +/// 申请请求 +#[derive(Debug, Deserialize)] +pub struct RaiseReq { + pub amount: f64, + pub reason: String, +} + +/// 列表查询参数 +#[derive(Debug, Default, Deserialize)] +pub struct ListQuery { + #[serde(default)] + pub status: String, +} + +/// POST /api/raise-requests:成员自助申请加额 +pub async fn create( + State(st): State, + auth: AuthUser, + Json(req): Json, +) -> Result, ApiErr> { + let reason = req.reason.trim().to_string(); + if !req.amount.is_finite() || req.amount <= 0.0 || req.amount.fract() != 0.0 { + return Err(( + axum::http::StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "amount 必须为正整数" })), + )); + } + if reason.is_empty() { + return Err(( + axum::http::StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "reason 不能为空" })), + )); + } + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + // 已有 pending 申请 → 409(防重复提交) + let pending: i64 = conn + .query_row( + "SELECT COUNT(*) FROM raise_requests WHERE user_id = ?1 AND status = 'pending'", + [auth.user_id], + |r| r.get(0), + ) + .unwrap_or(0); + if pending > 0 { + return Err(( + axum::http::StatusCode::CONFLICT, + Json(serde_json::json!({ "error": "已有待审批的加额申请,请等待管理员处理" })), + )); + } + conn.execute( + "INSERT INTO raise_requests (user_id, amount, reason, status) VALUES (?1, ?2, ?3, 'pending')", + params![auth.user_id, req.amount, reason], + ) + .map_err(internal)?; + let id = conn.last_insert_rowid(); + Ok(Json(serde_json::json!({ + "id": id, + "amount": req.amount, + "reason": reason, + "status": "pending", + }))) +} + +/// GET /api/raise-requests:用户看自己的;admin 看全部;?status= 过滤 +pub async fn list( + State(st): State, + auth: AuthUser, + Query(q): Query, +) -> Result>, ApiErr> { + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + let status = q.status.trim().to_string(); + let is_admin = auth.role == "admin"; + let (base, param): (&str, bool) = if is_admin { + ( + "SELECT r.id, r.user_id, u.email, u.name, r.amount, r.reason, r.status, r.created_at FROM raise_requests r JOIN users u ON u.id = r.user_id", + false, + ) + } else { + ( + "SELECT r.id, r.user_id, u.email, u.name, r.amount, r.reason, r.status, r.created_at FROM raise_requests r JOIN users u ON u.id = r.user_id WHERE r.user_id = ?1", + true, + ) + }; + let sql = if status.is_empty() { + format!("{base} ORDER BY r.id DESC") + } else if is_admin { + format!("{base} WHERE r.status = ?1 ORDER BY r.id DESC") + } else { + format!("{base} AND r.status = ?2 ORDER BY r.id DESC") + }; + let mapper = |r: &rusqlite::Row| { + Ok(serde_json::json!({ + "id": r.get::<_, i64>(0)?, + "user_id": r.get::<_, i64>(1)?, + "email": r.get::<_, String>(2)?, + "name": r.get::<_, String>(3)?, + "amount": r.get::<_, f64>(4)?, + "reason": r.get::<_, String>(5)?, + "status": r.get::<_, String>(6)?, + "created_at": r.get::<_, String>(7)?, + })) + }; + let mut stmt = conn.prepare(&sql).map_err(internal)?; + let rows = if param && status.is_empty() { + stmt.query_map([auth.user_id], mapper) + .map_err(internal)? + .collect::>>() + .map_err(internal)? + } else if !param && !status.is_empty() { + stmt.query_map([&status], mapper) + .map_err(internal)? + .collect::>>() + .map_err(internal)? + } else if param && !status.is_empty() { + stmt.query_map(params![auth.user_id, &status], mapper) + .map_err(internal)? + .collect::>>() + .map_err(internal)? + } else { + stmt.query_map([], mapper) + .map_err(internal)? + .collect::>>() + .map_err(internal)? + }; + Ok(Json(rows)) +} + +/// POST /api/admin/raise-requests/:id/approve:批准 → 永久点数 + 交易记录 +pub async fn approve( + State(st): State, + auth: AuthUser, + axum::extract::Path(id): axum::extract::Path, +) -> Result, ApiErr> { + crate::routes::org::require_admin(&auth)?; + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + let row: Option<(i64, f64, String)> = conn + .query_row( + "SELECT user_id, amount, status FROM raise_requests WHERE id = ?1", + [id], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .ok(); + let Some((user_id, amount, status)) = row else { + return Err(( + axum::http::StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "申请不存在" })), + )); + }; + if status != "pending" { + return Err(( + axum::http::StatusCode::CONFLICT, + Json(serde_json::json!({ "error": "该申请已处理" })), + )); + } + // 永久余额 += amount + conn.execute( + "INSERT OR IGNORE INTO quotas (user_id, balance) VALUES (?1, 0)", + [user_id], + ) + .map_err(internal)?; + conn.execute( + "UPDATE quotas SET balance = balance + ?1, updated_at = datetime('now') WHERE user_id = ?2", + params![amount, user_id], + ) + .map_err(internal)?; + // 交易记录(type=topup, counterpart='加额审批') + conn.execute( + "INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, pts, type, status) \ + VALUES (?1, '加额审批', NULL, 'raise', 0, ?2, 'topup', '成功')", + params![user_id, amount], + ) + .map_err(internal)?; + conn.execute( + "UPDATE raise_requests SET status = 'approved', reviewed_by = ?1, reviewed_at = datetime('now') WHERE id = ?2", + params![auth.user_id, id], + ) + .map_err(internal)?; + Ok(Json(serde_json::json!({ + "id": id, + "status": "approved", + "user_id": user_id, + "amount": amount, + }))) +} + +/// POST /api/admin/raise-requests/:id/reject:驳回 +pub async fn reject( + State(st): State, + auth: AuthUser, + axum::extract::Path(id): axum::extract::Path, +) -> Result, ApiErr> { + crate::routes::org::require_admin(&auth)?; + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + let status: Option = conn + .query_row( + "SELECT status FROM raise_requests WHERE id = ?1", + [id], + |r| r.get(0), + ) + .ok(); + let Some(status) = status else { + return Err(( + axum::http::StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "申请不存在" })), + )); + }; + if status != "pending" { + return Err(( + axum::http::StatusCode::CONFLICT, + Json(serde_json::json!({ "error": "该申请已处理" })), + )); + } + conn.execute( + "UPDATE raise_requests SET status = 'rejected', reviewed_by = ?1, reviewed_at = datetime('now') WHERE id = ?2", + params![auth.user_id, id], + ) + .map_err(internal)?; + Ok(Json(serde_json::json!({ "id": id, "status": "rejected" }))) +} diff --git a/ui/index.html b/ui/index.html index 319beb8..4e87081 100644 --- a/ui/index.html +++ b/ui/index.html @@ -390,7 +390,6 @@

管理视图 Admin

-
@@ -419,6 +418,10 @@

按模型用量 By model

按成员用量 By member

+
+

按部门用量 By department

+
+
@@ -461,9 +464,23 @@

添加部门 Add department

-