fix: support user recharge record cancellation

This commit is contained in:
boos
2026-06-09 17:22:23 +08:00
parent 55b3d4d99a
commit 3d54c7d529
8 changed files with 388 additions and 33 deletions
+189 -9
View File
@@ -25,7 +25,15 @@ pub fn routes() -> Router<AppState> {
.route("/api/user/logs", get(get_user_logs))
.route("/api/user/summary", get(get_user_summary))
.route("/api/user/dashboard", get(get_user_dashboard))
.route("/api/user/recharge-records", get(list_recharge_records))
.route(
"/api/user/recharge-records",
get(list_recharge_records).delete(clear_recharge_records),
)
.route("/api/user/recharge-records/clear", post(clear_recharge_records))
.route(
"/api/user/recharge-records/{public_id}/cancel",
post(cancel_recharge_record),
)
.route("/api/user/recharge/checkout", post(create_recharge_checkout))
.route(
"/api/user/models/marketplace",
@@ -89,6 +97,7 @@ struct UserRechargeRecord {
}
const USER_RECHARGE_ARRIVAL_NOTE: &str = "已创建系统内充值记录。跳转 Comfy Checkout 完成付款后,记录会保持待确认;到账需要管理员按付款记录核对并为用户 API Key 增加额度。";
const USER_RECHARGE_CANCEL_NOTE: &str = "用户已取消该充值记录。";
const USER_RECHARGE_PROVIDER: &str = "comfy_checkout";
fn resolve_recharge_option(payload: &CreateRechargeCheckoutPayload) -> AppResult<RechargeCheckoutOption> {
@@ -216,6 +225,63 @@ async fn list_user_recharge_records(db: &SqlitePool, user_id: i64) -> AppResult<
Ok(records)
}
async fn fetch_user_recharge_record(
db: &SqlitePool,
user_id: i64,
public_id: &str,
) -> AppResult<UserRechargeRecord> {
ensure_user_recharge_schema(db).await?;
let record = sqlx::query_as::<_, UserRechargeRecord>(
"SELECT *
FROM user_recharge_records
WHERE user_id = ? AND public_id = ?
LIMIT 1",
)
.bind(user_id)
.bind(public_id)
.fetch_optional(db)
.await?
.ok_or_else(|| AppError::NotFound("Recharge record not found".into()))?;
Ok(record)
}
async fn cancel_user_recharge_record(
db: &SqlitePool,
user_id: i64,
public_id: &str,
) -> AppResult<UserRechargeRecord> {
ensure_user_recharge_schema(db).await?;
let result = sqlx::query(
"UPDATE user_recharge_records
SET status = 'cancelled', note = ?, updated_at = datetime('now')
WHERE user_id = ? AND public_id = ? AND status = 'pending'",
)
.bind(USER_RECHARGE_CANCEL_NOTE)
.bind(user_id)
.bind(public_id)
.execute(db)
.await?;
if result.rows_affected() == 0 {
let existing = fetch_user_recharge_record(db, user_id, public_id).await?;
return Err(AppError::BadRequest(format!(
"Recharge record cannot be cancelled from status {}",
existing.status
)));
}
fetch_user_recharge_record(db, user_id, public_id).await
}
async fn clear_user_recharge_records(db: &SqlitePool, user_id: i64) -> AppResult<u64> {
ensure_user_recharge_schema(db).await?;
let result = sqlx::query("DELETE FROM user_recharge_records WHERE user_id = ?")
.bind(user_id)
.execute(db)
.await?;
Ok(result.rows_affected())
}
async fn user_billing_summary(db: &SqlitePool, user_id: i64) -> AppResult<Value> {
let row = sqlx::query(
r#"
@@ -313,18 +379,31 @@ fn configured_recharge_options_from_env() -> Vec<RechargeCheckoutOption> {
.filter(|value| !value.is_empty());
let checkout_url = override_url.unwrap_or_else(|| DEFAULT_USER_RECHARGE_CHECKOUT_URL.to_string());
validate_checkout_url(&checkout_url)
.map(|checkout_url| RechargeCheckoutOption {
id: "usd-50".to_string(),
label: "$50".to_string(),
title: "团队高频".to_string(),
amount_usd: Some(50.0),
checkout_url,
recommended: true,
})
.map(|checkout_url| default_recharge_options(&checkout_url))
.into_iter()
.flatten()
.collect()
}
fn default_recharge_options(checkout_url: &str) -> Vec<RechargeCheckoutOption> {
[
("usd-5", "$5", "轻量试用", 5.0, false),
("usd-10", "$10", "日常调用", 10.0, false),
("usd-20", "$20", "创作加量", 20.0, false),
("usd-50", "$50", "团队高频", 50.0, true),
]
.into_iter()
.map(|(id, label, title, amount, recommended)| RechargeCheckoutOption {
id: id.to_string(),
label: label.to_string(),
title: title.to_string(),
amount_usd: Some(amount),
checkout_url: checkout_url.to_string(),
recommended,
})
.collect()
}
fn parse_recharge_options(raw: &str) -> Vec<RechargeCheckoutOption> {
let trimmed = raw.trim();
if trimmed.is_empty() {
@@ -1406,6 +1485,47 @@ async fn list_recharge_records(
})))
}
async fn cancel_recharge_record(
State(state): State<AppState>,
Path(public_id): Path<String>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let record = cancel_user_recharge_record(&state.db, user_id, public_id.trim()).await?;
let billing = user_billing_summary(&state.db, user_id).await?;
Ok(Json(json!({
"success": true,
"record": record,
"billing": billing,
"arrivalNote": USER_RECHARGE_ARRIVAL_NOTE,
"checkoutProvider": "Comfy Checkout",
"usagePath": "/user/usage",
"rechargePath": "/user/recharge",
})))
}
async fn clear_recharge_records(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let cleared = clear_user_recharge_records(&state.db, user_id).await?;
let billing = user_billing_summary(&state.db, user_id).await?;
Ok(Json(json!({
"success": true,
"cleared": cleared,
"records": [],
"items": [],
"billing": billing,
"arrivalNote": USER_RECHARGE_ARRIVAL_NOTE,
"checkoutProvider": "Comfy Checkout",
"usagePath": "/user/usage",
"rechargePath": "/user/recharge",
})))
}
async fn create_recharge_checkout(
State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
@@ -1651,6 +1771,37 @@ mod tests {
assert_eq!(billing["usagePath"], "/user/usage");
}
#[tokio::test]
async fn recharge_records_can_be_cancelled_and_cleared() {
let pool = recharge_test_pool().await;
let option = RechargeCheckoutOption {
id: "usd-10".to_string(),
label: "$10".to_string(),
title: "日常调用".to_string(),
amount_usd: Some(10.0),
checkout_url: DEFAULT_USER_RECHARGE_CHECKOUT_URL.to_string(),
recommended: false,
};
let record = create_user_recharge_record(&pool, 9, &option, "203.0.113.11", None)
.await
.unwrap();
let cancelled = cancel_user_recharge_record(&pool, 9, &record.public_id)
.await
.unwrap();
assert_eq!(cancelled.status, "cancelled");
assert_eq!(cancelled.note.as_deref(), Some(USER_RECHARGE_CANCEL_NOTE));
assert!(matches!(
cancel_user_recharge_record(&pool, 9, &record.public_id).await,
Err(AppError::BadRequest(_))
));
let cleared = clear_user_recharge_records(&pool, 9).await.unwrap();
assert_eq!(cleared, 1);
let records = list_user_recharge_records(&pool, 9).await.unwrap();
assert!(records.is_empty());
}
#[test]
fn recharge_checkout_rejects_invalid_or_unconfigured_amount() {
let bad_amount = CreateRechargeCheckoutPayload {
@@ -1677,6 +1828,35 @@ mod tests {
assert!(find_recharge_option(&options, &unconfigured_amount).is_none());
}
#[test]
fn default_recharge_options_share_comfy_checkout_but_keep_amounts() {
let options = default_recharge_options(DEFAULT_USER_RECHARGE_CHECKOUT_URL);
assert_eq!(options.len(), 4);
assert_eq!(
options.iter().map(|option| option.label.as_str()).collect::<Vec<_>>(),
vec!["$5", "$10", "$20", "$50"],
);
assert_eq!(
options.iter().map(|option| option.amount_usd).collect::<Vec<_>>(),
vec![Some(5.0), Some(10.0), Some(20.0), Some(50.0)],
);
assert!(options
.iter()
.all(|option| option.checkout_url == DEFAULT_USER_RECHARGE_CHECKOUT_URL));
assert_eq!(
find_recharge_option(
&options,
&CreateRechargeCheckoutPayload {
option_id: None,
amount_usd: Some(10.0),
},
)
.map(|option| option.id),
Some("usd-10".to_string()),
);
}
#[test]
fn user_log_filters_include_full_date_to_day() {
let item = json!({