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.2"
version = "0.3.3"
edition = "2021"
description = "AI Token 共享池 — 企业 key 池 + 公共共享市场"
license = "MIT"
Expand Down
59 changes: 58 additions & 1 deletion src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Connection> {
Expand Down Expand Up @@ -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);
"#,
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -259,6 +295,27 @@ pub fn seed(conn: &Connection) -> Result<()> {
[id],
)?;
}

// 运营者账号(P2-C):ops@aitokenpool.local / ops1234,role=ops
let ops_id: Option<i64> = 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(())
}

Expand Down
171 changes: 151 additions & 20 deletions src/routes/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<i64>>(6)?,
"dept_name": r.get::<_, String>(7)?,
}))
})
.map_err(internal)?;
Expand All @@ -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<i64>,
}

pub async fn patch_user(
State(st): State<AppState>,
auth: AuthUser,
axum::extract::Path(id): axum::extract::Path<i64>,
Json(req): Json<UserPatch>,
) -> Result<Json<serde_json::Value>, 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<i64>) = 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<AppState>,
auth: AuthUser,
) -> Result<Json<Vec<serde_json::Value>>, ApiErr> {
) -> Result<Json<serde_json::Value>, 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<i64>>(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,
})))
}
Loading
Loading