feat(billing): comfy recharge to points + admin credit adjust

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
boos
2026-06-16 21:11:44 +08:00
parent ca9e45c59c
commit 5d42671172
4 changed files with 364 additions and 11 deletions
+49 -7
View File
@@ -1,4 +1,5 @@
use crate::{
auth::jwt::validate_jwt,
db::models::setting::Setting,
error::{AppError, AppResult},
AppState,
@@ -24,7 +25,8 @@ mod sites;
mod tokens;
mod users;
/// Middleware to require admin authentication via the configured admin API token.
/// Middleware to require admin authentication via the configured admin API token
/// or a JWT belonging to a user with role='admin'.
async fn require_admin_auth(
State(state): State<AppState>,
headers: HeaderMap,
@@ -37,8 +39,16 @@ async fn require_admin_auth(
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
if auth_header != state.config.auth.auth_token {
return Err(AppError::Unauthorized("Invalid admin token".into()));
if auth_header == state.config.auth.auth_token {
return Ok(next.run(request).await);
}
let claims = validate_jwt(&state.config.auth.jwt_secret, auth_header)
.map_err(|_| AppError::Unauthorized("Invalid admin token".into()))?;
if claims.role != "admin" {
return Err(AppError::Forbidden(
"Admin account is required for this route".into(),
));
}
Ok(next.run(request).await)
@@ -177,6 +187,18 @@ pub fn routes(state: AppState) -> Router<AppState> {
.delete(users::delete_user),
)
.route("/api/admin/users/{id}/usage", get(users::get_user_usage))
.route(
"/api/admin/user-balances",
get(users::list_user_balances),
)
.route(
"/api/admin/users/{id}/balance/adjust",
post(users::adjust_user_balance),
)
.route(
"/api/admin/users/{id}/credits/adjust",
post(users::adjust_user_credits),
)
.route(
"/api/admin/users/{id}/keys",
get(users::list_user_keys).post(users::create_user_key),
@@ -354,6 +376,8 @@ mod tests {
template_proxy_url: None,
},
comfyui: Default::default(),
pixelle_video: Default::default(),
payment: Default::default(),
},
pool,
BuiltinKeys {
@@ -419,7 +443,7 @@ mod tests {
}
#[tokio::test]
async fn admin_routes_accept_config_token_and_reject_admin_jwt() {
async fn admin_routes_accept_config_token_and_admin_jwt_reject_user_jwt() {
let state = settings_state().await;
let app = routes(state.clone()).with_state(state.clone());
@@ -436,8 +460,10 @@ mod tests {
.expect("token request");
assert_eq!(token_response.status(), StatusCode::OK);
let admin_jwt = generate_jwt(&state.config.auth.jwt_secret, "1", "admin", 1)
.expect("admin jwt");
// Admin-role JWT is accepted: admins signed in at /user/login (which now
// mints an admin JWT) can reach admin routes without the global token.
let admin_jwt =
generate_jwt(&state.config.auth.jwt_secret, "1", "admin", 1).expect("admin jwt");
let jwt_response = app
.clone()
.oneshot(
@@ -449,7 +475,23 @@ mod tests {
)
.await
.expect("jwt request");
assert_eq!(jwt_response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(jwt_response.status(), StatusCode::OK);
// Non-admin (user-role) JWT is rejected with 403.
let user_jwt =
generate_jwt(&state.config.auth.jwt_secret, "2", "user", 1).expect("user jwt");
let user_jwt_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/settings/auth/info")
.header("Authorization", format!("Bearer {user_jwt}"))
.body(Body::empty())
.unwrap(),
)
.await
.expect("user jwt request");
assert_eq!(user_jwt_response.status(), StatusCode::FORBIDDEN);
let missing_response = app
.oneshot(
+78
View File
@@ -898,6 +898,43 @@ pub async fn adjust_user_balance(
})))
}
/// POST /api/admin/users/{id}/credits/adjust — add or remove workflow points
/// on the user's points pool (`user_credits`), writing a points/admin_adjust
/// ledger entry. Mirrors `adjust_user_balance` but for the points track.
pub async fn adjust_user_credits(
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 new_bal = crate::services::billing::ledger::apply_points_delta(
&state.db,
id,
delta,
"admin_adjust",
None,
Some("admin"),
Some("manual"),
None,
)
.await?;
info!(user_id = id, delta, new_bal, "admin adjusted user credits");
Ok(Json(json!({
"success": true,
"data": {
"userId": id,
"pointsBalance": new_bal,
}
})))
}
#[cfg(test)]
mod tests {
@@ -1089,4 +1126,45 @@ mod tests {
.expect("ledger row");
assert_eq!(row, ("usd".into(), "admin_adjust".into(), 5.0));
}
#[tokio::test]
async fn adjust_user_credits_writes_points_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("Pointer")
.bind("pointer@example.test")
.bind("hash")
.execute(&*state.db)
.await
.expect("insert user")
.last_insert_rowid();
let response = adjust_user_credits(
State(state.clone()),
Path(user_id),
Json(AdjustBalanceRequest { delta: 50.0 }),
)
.await
.expect("adjust credits");
assert_eq!(response.0["success"], true);
assert_eq!(response.0["data"]["pointsBalance"], 50.0);
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, ("points".into(), "admin_adjust".into(), 50.0));
}
}