feat(billing): USD admin adjust writes credit_ledger
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+244
-2
@@ -4,6 +4,7 @@ use crate::{
|
|||||||
auth::password::hash_password,
|
auth::password::hash_password,
|
||||||
db::models::{downstream_api_key::DownstreamApiKey, user::User},
|
db::models::{downstream_api_key::DownstreamApiKey, user::User},
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
|
routes::auth::DEFAULT_NEW_USER_MAX_COST_USD,
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -238,17 +239,18 @@ pub async fn create_user(
|
|||||||
|
|
||||||
let user_id = result.last_insert_rowid();
|
let user_id = result.last_insert_rowid();
|
||||||
|
|
||||||
// Auto-generate downstream API key
|
// Auto-generate downstream API key with the default starter credit.
|
||||||
let key = format!("sk-user-{}", uuid::Uuid::new_v4());
|
let key = format!("sk-user-{}", uuid::Uuid::new_v4());
|
||||||
let key_name = format!("{} 的默认密钥", payload.username.trim());
|
let key_name = format!("{} 的默认密钥", payload.username.trim());
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO downstream_api_keys (name, key, user_id, enabled) VALUES (?, ?, ?, ?)",
|
"INSERT INTO downstream_api_keys (name, key, user_id, enabled, max_cost) VALUES (?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&key_name)
|
.bind(&key_name)
|
||||||
.bind(&key)
|
.bind(&key)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(true)
|
.bind(true)
|
||||||
|
.bind(DEFAULT_NEW_USER_MAX_COST_USD)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -708,6 +710,195 @@ pub async fn update_user_key(
|
|||||||
Ok(Json(json!({ "data": updated })))
|
Ok(Json(json!({ "data": updated })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── User balance management (admin) ────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListBalancesQuery {
|
||||||
|
pub page: Option<i64>,
|
||||||
|
pub page_size: Option<i64>,
|
||||||
|
pub q: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/admin/user-balances — list users with their credit pool
|
||||||
|
/// (summed max_cost / used_cost across their downstream keys).
|
||||||
|
pub async fn list_user_balances(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<ListBalancesQuery>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let page = query.page.unwrap_or(1).max(1);
|
||||||
|
let page_size = query.page_size.unwrap_or(50).clamp(1, 200);
|
||||||
|
let offset = (page - 1) * page_size;
|
||||||
|
let q = query.q.unwrap_or_default().trim().to_string();
|
||||||
|
let q_like = format!("%{}%", q);
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
u.id AS user_id,
|
||||||
|
u.username AS username,
|
||||||
|
u.email AS email,
|
||||||
|
u.role AS role,
|
||||||
|
u.status AS status,
|
||||||
|
COALESCE(SUM(COALESCE(k.used_cost, 0.0)), 0.0) AS used_cost,
|
||||||
|
COALESCE(SUM(CASE WHEN k.max_cost IS NULL THEN 0.0 ELSE k.max_cost END), 0.0) AS limited_max_cost,
|
||||||
|
SUM(CASE WHEN k.id IS NULL THEN 0 WHEN k.max_cost IS NULL THEN 1 ELSE 0 END) AS unlimited_key_count,
|
||||||
|
COUNT(k.id) AS key_count
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN downstream_api_keys k ON k.user_id = u.id
|
||||||
|
WHERE (? = '' OR u.username LIKE ? OR u.email LIKE ? OR CAST(u.id AS TEXT) = ?)
|
||||||
|
GROUP BY u.id
|
||||||
|
ORDER BY u.id DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&q)
|
||||||
|
.bind(&q_like)
|
||||||
|
.bind(&q_like)
|
||||||
|
.bind(&q)
|
||||||
|
.bind(page_size)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let total: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM users
|
||||||
|
WHERE (? = '' OR username LIKE ? OR email LIKE ? OR CAST(id AS TEXT) = ?)",
|
||||||
|
)
|
||||||
|
.bind(&q)
|
||||||
|
.bind(&q_like)
|
||||||
|
.bind(&q_like)
|
||||||
|
.bind(&q)
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let items: Vec<Value> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
use sqlx::Row;
|
||||||
|
let used: f64 = row.try_get("used_cost").unwrap_or(0.0);
|
||||||
|
let limited_max: f64 = row.try_get("limited_max_cost").unwrap_or(0.0);
|
||||||
|
let unlimited: i64 = row.try_get("unlimited_key_count").unwrap_or(0);
|
||||||
|
let remaining = if unlimited > 0 {
|
||||||
|
Value::Null
|
||||||
|
} else {
|
||||||
|
Value::from((limited_max - used).max(0.0))
|
||||||
|
};
|
||||||
|
json!({
|
||||||
|
"userId": row.try_get::<i64, _>("user_id").unwrap_or(0),
|
||||||
|
"username": row.try_get::<Option<String>, _>("username").ok().flatten(),
|
||||||
|
"email": row.try_get::<Option<String>, _>("email").ok().flatten(),
|
||||||
|
"role": row.try_get::<Option<String>, _>("role").ok().flatten(),
|
||||||
|
"status": row.try_get::<Option<String>, _>("status").ok().flatten(),
|
||||||
|
"keyCount": row.try_get::<i64, _>("key_count").unwrap_or(0),
|
||||||
|
"usedCost": used,
|
||||||
|
"maxCost": if unlimited > 0 { Value::Null } else { Value::from(limited_max) },
|
||||||
|
"remainingCost": remaining,
|
||||||
|
"unlimitedKeyCount": unlimited,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"data": items,
|
||||||
|
"meta": {
|
||||||
|
"page": page,
|
||||||
|
"pageSize": page_size,
|
||||||
|
"total": total,
|
||||||
|
"totalPages": ((total + page_size - 1) / page_size).max(1),
|
||||||
|
},
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AdjustBalanceRequest {
|
||||||
|
/// Amount to add (positive) or remove (negative) from the user's credit pool.
|
||||||
|
pub delta: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/admin/users/{id}/balance/adjust — add or remove credit on the
|
||||||
|
/// user's primary (lowest-id) downstream key by bumping its max_cost.
|
||||||
|
pub async fn adjust_user_balance(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Json(payload): Json<AdjustBalanceRequest>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let delta = payload.delta;
|
||||||
|
if !delta.is_finite() || delta == 0.0 {
|
||||||
|
return Err(AppError::BadRequest("delta must be a non-zero finite number".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::services::billing::ledger::ensure_ledger_schema(&state.db).await?;
|
||||||
|
|
||||||
|
let key: Option<(i64, Option<f64>, Option<f64>)> = sqlx::query_as(
|
||||||
|
"SELECT id, max_cost, used_cost
|
||||||
|
FROM downstream_api_keys
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some((key_id, max_cost, used_cost)) = key else {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"该用户没有可调整额度的 API Key(请先为其创建 Key)".into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let used = used_cost.unwrap_or(0.0).max(0.0);
|
||||||
|
let current = max_cost.unwrap_or(used).max(used);
|
||||||
|
// Never let the limit drop below what's already been spent.
|
||||||
|
let next_max = (current + delta).max(used);
|
||||||
|
let remaining = (next_max - used).max(0.0);
|
||||||
|
|
||||||
|
let mut tx = state.db.begin().await?;
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE downstream_api_keys
|
||||||
|
SET max_cost = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(next_max)
|
||||||
|
.bind(key_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
crate::services::billing::ledger::record_ledger_entry(
|
||||||
|
&mut tx,
|
||||||
|
crate::services::billing::ledger::LedgerEntry {
|
||||||
|
user_id: id,
|
||||||
|
track: "usd",
|
||||||
|
entry_type: "admin_adjust",
|
||||||
|
amount: delta,
|
||||||
|
balance_after: remaining,
|
||||||
|
ref_type: Some("manual"),
|
||||||
|
ref_id: None,
|
||||||
|
model: None,
|
||||||
|
operator: Some("admin"),
|
||||||
|
note: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
user_id = id,
|
||||||
|
key_id, delta, next_max, "admin adjusted user balance"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"userId": id,
|
||||||
|
"keyId": key_id,
|
||||||
|
"maxCost": next_max,
|
||||||
|
"usedCost": used,
|
||||||
|
"remainingCost": remaining,
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -800,6 +991,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
|
payment: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
@@ -847,4 +1040,53 @@ mod tests {
|
|||||||
.expect("read role");
|
.expect("read role");
|
||||||
assert_eq!(role, "admin");
|
assert_eq!(role, "admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn adjust_user_balance_writes_admin_adjust_ledger() {
|
||||||
|
let state = test_state().await;
|
||||||
|
crate::services::billing::ledger::ensure_ledger_schema(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("ledger schema");
|
||||||
|
|
||||||
|
let user_id = sqlx::query(
|
||||||
|
"INSERT INTO users (username, email, password_hash, role, status)
|
||||||
|
VALUES (?, ?, ?, 'user', 'active')",
|
||||||
|
)
|
||||||
|
.bind("Spender")
|
||||||
|
.bind("spender@example.test")
|
||||||
|
.bind("hash")
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert user")
|
||||||
|
.last_insert_rowid();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO downstream_api_keys (name, key, user_id, max_cost, used_cost)
|
||||||
|
VALUES (?, ?, ?, 10.0, 0.0)",
|
||||||
|
)
|
||||||
|
.bind("primary")
|
||||||
|
.bind("sk-spender")
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert key");
|
||||||
|
|
||||||
|
adjust_user_balance(
|
||||||
|
State(state.clone()),
|
||||||
|
Path(user_id),
|
||||||
|
Json(AdjustBalanceRequest { delta: 5.0 }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("adjust balance");
|
||||||
|
|
||||||
|
let row: (String, String, f64) = sqlx::query_as(
|
||||||
|
"SELECT track, entry_type, amount FROM credit_ledger
|
||||||
|
WHERE user_id = ? ORDER BY id DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("ledger row");
|
||||||
|
assert_eq!(row, ("usd".into(), "admin_adjust".into(), 5.0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user