Files
Genarrative/server-rs/crates/api-server/src/asset_billing.rs
T
kdletters 04dfce57e6
CI / verify (pull_request) Has been cancelled
feat: add asset operation wallet ledger
2026-04-28 12:14:07 +08:00

105 lines
3.0 KiB
Rust

use std::future::Future;
use axum::http::StatusCode;
use serde_json::json;
use spacetime_client::SpacetimeClientError;
use crate::{http_error::AppError, state::AppState};
pub(crate) const ASSET_OPERATION_POINTS_COST: u64 = 1;
/// 资产操作统一执行入口:业务层只声明操作类型与资源 ID,钱包扣退费由服务层收口。
pub(crate) async fn execute_billable_asset_operation<T, Fut>(
state: &AppState,
owner_user_id: &str,
asset_kind: &str,
asset_id: &str,
operation: Fut,
) -> Result<T, AppError>
where
Fut: Future<Output = Result<T, AppError>>,
{
consume_asset_operation_points(state, owner_user_id, asset_kind, asset_id).await?;
match operation.await {
Ok(value) => Ok(value),
Err(error) => {
refund_asset_operation_points(state, owner_user_id, asset_kind, asset_id).await;
Err(error)
}
}
}
/// 资产操作统一预扣叙世币;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。
async fn consume_asset_operation_points(
state: &AppState,
owner_user_id: &str,
asset_kind: &str,
asset_id: &str,
) -> Result<(), AppError> {
let ledger_id = format!(
"asset_operation_consume:{}:{}:{}",
owner_user_id, asset_kind, asset_id
);
state
.spacetime_client()
.consume_profile_wallet_points(
owner_user_id.to_string(),
ASSET_OPERATION_POINTS_COST,
ledger_id,
current_utc_micros(),
)
.await
.map(|_| ())
.map_err(map_asset_operation_wallet_error)
}
/// 外部生成或发布 mutation 失败后补偿退款;退款失败只记日志,避免覆盖原始业务错误。
async fn refund_asset_operation_points(
state: &AppState,
owner_user_id: &str,
asset_kind: &str,
asset_id: &str,
) {
let ledger_id = format!(
"asset_operation_refund:{}:{}:{}",
owner_user_id, asset_kind, asset_id
);
if let Err(error) = state
.spacetime_client()
.refund_profile_wallet_points(
owner_user_id.to_string(),
ASSET_OPERATION_POINTS_COST,
ledger_id,
current_utc_micros(),
)
.await
{
tracing::error!(
owner_user_id,
asset_kind,
asset_id,
error = %error,
"资产操作失败后的叙世币退款失败"
);
}
}
pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> AppError {
let status = match &error {
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
SpacetimeClientError::Procedure(message) if message.contains("叙世币余额不足") => {
StatusCode::CONFLICT
}
_ => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "profile-wallet",
"message": error.to_string(),
}))
}
fn current_utc_micros() -> i64 {
time::OffsetDateTime::now_utc().unix_timestamp_nanos() as i64 / 1_000
}