Files
Genarrative/server-rs/crates/api-server/src/asset_billing.rs
T
suzmii 456cc42dd3
Project CI / Repository checks (push) Successful in 1m59s
Project CI / Frontend tests (push) Successful in 3m7s
Project CI / Backend tests (push) Successful in 6m13s
Project CI / Native shell tests (push) Successful in 19m24s
优化泥点流水的素材消耗原因展示 (#302)
Close #296

## 问题

钱包流水目前只把素材类扣费暴露为 `asset_operation_consume`,共享账单组件统一显示“资产操作消耗”,无法说明本次泥点实际用于哪类素材生成。

## 落地方案

- 在既有钱包流水 metadata 中记录服务端确定的 `assetKind`,不修改 SpacetimeDB schema。
- `api-server` 只将内部素材类型白名单映射为用户可见 `reason`,不暴露原始 metadata、资源 ID 或任务 ID。
- 共享钱包流水契约与组件优先展示具体原因;历史、未知或空 metadata 继续回退为“资产操作消耗”。
- 主站、图片画布与 AGC 继续复用同一共享账单组件。

## 当前进度

- [x] 补充权威后端数据契约
- [x] 写入素材操作类型 metadata
- [x] 扩展钱包流水公开 DTO 与安全映射
- [x] 更新共享账单展示与回归测试
- [x] 完成定向验证与边界检查

## 用户可见映射

- 图片、图标图集、美术规范、UI 设计、发布素材:生成美术素材
- 图片修改:编辑美术素材
- UI 素材提取:提取美术素材
- 角色动画:生成角色动画
- 视频:生成视频素材
- 音效:生成音效素材
- 背景音乐:生成背景音乐
- 历史、未知、空 metadata:资产操作消耗

## 验证

- `cargo fmt --all -- --check`
- `cargo test -p api-server profile_wallet_ledger_reason_only_exposes_known_asset_operations`
- `cargo test -p api-server worker_billing_context_freezes_charge_and_preserves_job_metadata`
- `cargo test -p shared-contracts profile_wallet_ledger_response_uses_camel_case_fields`
- `npx vitest run packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx`
- `npm run typecheck`
- `npx eslint packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx packages/shared/src/contracts/runtime.ts`
- `npm run check:encoding`
- `npm run check:spacetime-schema`
- `git diff --check`

以上均通过;Rust 仅输出仓库已有 dead-code warnings。

## 未验证边界

- 未执行真实登录、生成并读取新钱包流水的端到端联调。本机 `8082/3101` 已由另一套本地栈占用,为避免当前分支挂接同一数据库并带起 worker 处理现有队列,本轮未启动 `npm run dev:api-server`。
- 四项远端 CI 已全部通过,PR 已转为 Ready for review。

## 不变项

- 不修改历史流水数据。
- 不调整泥点价格、扣费顺序、幂等 ledger、失败退款或余额结算。
- 不修改 SpacetimeDB schema。

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/302
Co-authored-by: Suzumiya <suzmii@qq.com>
Co-committed-by: Suzumiya <suzmii@qq.com>
2026-09-08 14:56:16 +08:00

1097 lines
38 KiB
Rust

use std::{
cell::{Cell, RefCell},
future::Future,
};
use axum::http::StatusCode;
use serde_json::json;
use spacetime_client::SpacetimeClientError;
use crate::{
http_error::AppError,
state::AppState,
wallet_refund_outbox::{WalletRefundOutboxEnqueueOutcome, WalletRefundOutboxRecord},
};
pub(crate) const ASSET_OPERATION_POINTS_COST: u64 = 1;
#[derive(Clone, Debug)]
struct ExternalGenerationBillingContext {
job_id: String,
claim_attempt: u32,
price_mud_points: u32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct AssetOperationLedgerPair {
consume_ledger_id: String,
refund_ledger_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct AssetOperationBillingPlan {
current: AssetOperationLedgerPair,
previous_attempts: Vec<AssetOperationLedgerPair>,
external_generation_job_id: Option<String>,
external_generation_claim_attempt: Option<u32>,
}
tokio::task_local! {
static CURRENT_EXTERNAL_GENERATION_BILLING_CONTEXT: ExternalGenerationBillingContext;
static DEFERRED_ASSET_OPERATION_REFUNDS: RefCell<Vec<AssetOperationRefundOnDrop>>;
static EDITOR_GENERATION_COMMIT_DISPATCHED: Cell<bool>;
static EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN: Cell<bool>;
}
pub(crate) async fn with_editor_generation_durable_billing_boundary<T, E, Fut>(
future: Fut,
) -> Result<T, E>
where
Fut: Future<Output = Result<T, E>>,
{
EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN
.scope(Cell::new(false), async {
EDITOR_GENERATION_COMMIT_DISPATCHED
.scope(Cell::new(false), async {
DEFERRED_ASSET_OPERATION_REFUNDS
.scope(RefCell::new(Vec::new()), async {
let result = future.await;
let outcome_unknown =
EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN.with(Cell::get);
let refunds = DEFERRED_ASSET_OPERATION_REFUNDS.with(RefCell::take);
match &result {
Ok(_) => {
for mut refund in refunds {
refund.disarm();
}
}
Err(_) if outcome_unknown => {
// 远端提交可能已经成功;不能把正式结果与扣费同时保留后再退款。
for mut refund in refunds {
refund.disarm();
}
}
Err(_) => {
for mut refund in refunds {
refund.refund_now().await;
}
}
}
result
})
.await
})
.await
})
.await
}
pub(crate) fn begin_editor_generation_commit_attempt() {
let _ = EDITOR_GENERATION_COMMIT_DISPATCHED.try_with(|dispatched| dispatched.set(false));
}
pub(crate) fn mark_editor_generation_commit_dispatched() {
let _ = EDITOR_GENERATION_COMMIT_DISPATCHED.try_with(|dispatched| dispatched.set(true));
let _ = EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN.try_with(|unknown| unknown.set(true));
}
pub(crate) fn editor_generation_commit_attempt_was_dispatched() -> bool {
EDITOR_GENERATION_COMMIT_DISPATCHED
.try_with(Cell::get)
.unwrap_or(false)
}
pub(crate) fn mark_editor_generation_commit_outcome_confirmed() {
let _ = EDITOR_GENERATION_COMMIT_DISPATCHED.try_with(|dispatched| dispatched.set(false));
let _ = EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN.try_with(|unknown| unknown.set(false));
}
fn editor_generation_commit_outcome_is_unknown() -> bool {
EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN
.try_with(Cell::get)
.unwrap_or(false)
}
#[cfg(test)]
pub(crate) async fn with_external_generation_billing_context<T, Fut>(
job_id: String,
price_mud_points: u32,
future: Fut,
) -> T
where
Fut: Future<Output = T>,
{
with_external_generation_billing_attempt_context(job_id, 1, price_mud_points, future).await
}
pub(crate) async fn with_external_generation_billing_attempt_context<T, Fut>(
job_id: String,
claim_attempt: u32,
price_mud_points: u32,
future: Fut,
) -> T
where
Fut: Future<Output = T>,
{
CURRENT_EXTERNAL_GENERATION_BILLING_CONTEXT
.scope(
ExternalGenerationBillingContext {
job_id: job_id.trim().to_string(),
claim_attempt,
price_mud_points,
},
future,
)
.await
}
/// 资产操作统一执行入口:业务层只声明操作类型与资源 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>>,
{
execute_billable_asset_operation_with_cost(
state,
owner_user_id,
asset_kind,
asset_id,
ASSET_OPERATION_POINTS_COST,
operation,
)
.await
}
/// 生图等特殊操作可声明独立泥点成本,避免修改全局资产操作默认价格。
pub(crate) async fn execute_billable_asset_operation_with_cost<T, Fut>(
state: &AppState,
owner_user_id: &str,
asset_kind: &str,
asset_id: &str,
points_cost: u64,
operation: Fut,
) -> Result<T, AppError>
where
Fut: Future<Output = Result<T, AppError>>,
{
let billing_context = current_external_generation_billing_context();
let points_cost = resolve_asset_operation_points_cost(points_cost);
let billing_plan = build_asset_operation_billing_plan(
owner_user_id,
asset_kind,
asset_id,
billing_context.as_ref(),
);
settle_previous_external_generation_attempts(
state,
owner_user_id,
asset_kind,
asset_id,
points_cost,
&billing_plan,
)
.await?;
let points_consumed = consume_asset_operation_points(
state,
owner_user_id,
points_cost,
&billing_plan.current.consume_ledger_id,
wallet_metadata_json(
asset_kind,
billing_plan.external_generation_job_id.as_deref(),
billing_plan.external_generation_claim_attempt,
),
)
.await?;
let mut refund_on_drop = AssetOperationRefundOnDrop::new(
state.clone(),
owner_user_id,
asset_kind,
asset_id,
points_cost,
points_consumed,
billing_plan.current.refund_ledger_id,
billing_plan.external_generation_job_id,
billing_plan.external_generation_claim_attempt,
);
match operation.await {
Ok(value) => {
let mut deferred_refund = Some(refund_on_drop);
let deferred = DEFERRED_ASSET_OPERATION_REFUNDS
.try_with(|refunds| {
refunds
.borrow_mut()
.push(deferred_refund.take().expect("deferred refund guard"));
})
.is_ok();
if !deferred {
deferred_refund
.as_mut()
.expect("immediate refund guard")
.disarm();
}
Ok(value)
}
Err(error) => {
if points_consumed && should_refund_asset_operation_error(&error) {
refund_on_drop.refund_now().await;
} else {
refund_on_drop.disarm();
}
Err(error)
}
}
}
struct AssetOperationRefundOnDrop {
state: AppState,
owner_user_id: String,
asset_kind: String,
asset_id: String,
points_cost: u64,
refund_ledger_id: String,
external_generation_job_id: Option<String>,
external_generation_claim_attempt: Option<u32>,
active: bool,
}
impl AssetOperationRefundOnDrop {
fn new(
state: AppState,
owner_user_id: &str,
asset_kind: &str,
asset_id: &str,
points_cost: u64,
points_consumed: bool,
refund_ledger_id: String,
external_generation_job_id: Option<String>,
external_generation_claim_attempt: Option<u32>,
) -> Self {
Self {
state,
owner_user_id: owner_user_id.to_string(),
asset_kind: asset_kind.to_string(),
asset_id: asset_id.to_string(),
points_cost,
refund_ledger_id,
external_generation_job_id,
external_generation_claim_attempt,
active: points_consumed,
}
}
fn disarm(&mut self) {
self.active = false;
}
async fn refund_now(&mut self) {
if !self.active {
return;
}
refund_asset_operation_points_with_job_id(
self.state.clone(),
self.owner_user_id.clone(),
self.asset_kind.clone(),
self.asset_id.clone(),
self.points_cost,
self.refund_ledger_id.clone(),
self.external_generation_job_id.clone(),
self.external_generation_claim_attempt,
)
.await
.ok();
self.active = false;
}
}
impl Drop for AssetOperationRefundOnDrop {
fn drop(&mut self) {
if !self.active {
return;
}
if editor_generation_commit_outcome_is_unknown() {
tracing::warn!(
owner_user_id = self.owner_user_id,
asset_kind = self.asset_kind,
asset_id = self.asset_id,
"编辑器生成提交结果未知,取消请求时保留扣费并等待 durable receipt 对账"
);
self.active = false;
return;
}
let state = self.state.clone();
let owner_user_id = self.owner_user_id.clone();
let asset_kind = self.asset_kind.clone();
let asset_id = self.asset_id.clone();
let points_cost = self.points_cost;
let refund_ledger_id = self.refund_ledger_id.clone();
let external_generation_job_id = self.external_generation_job_id.clone();
let external_generation_claim_attempt = self.external_generation_claim_attempt;
let Ok(handle) = tokio::runtime::Handle::try_current() else {
tracing::error!(
owner_user_id,
asset_kind,
asset_id,
external_generation_job_id,
external_generation_claim_attempt,
points_cost,
"资产操作 future 被取消,但当前没有 Tokio runtime,无法异步补偿退款"
);
return;
};
handle.spawn(async move {
tracing::warn!(
owner_user_id,
asset_kind,
asset_id,
external_generation_job_id,
external_generation_claim_attempt,
points_cost,
"资产操作 future 被取消,异步补偿退款"
);
refund_asset_operation_points_with_job_id(
state,
owner_user_id,
asset_kind,
asset_id,
points_cost,
refund_ledger_id,
external_generation_job_id,
external_generation_claim_attempt,
)
.await
.ok();
});
}
}
pub(crate) fn should_refund_asset_operation_error(_error: &AppError) -> bool {
// 队列扣退费账本按 claim attempt 隔离,旧 worker 退款不会再冲掉新 attempt 的扣款。
true
}
fn build_asset_operation_billing_plan(
owner_user_id: &str,
asset_kind: &str,
asset_id: &str,
billing_context: Option<&ExternalGenerationBillingContext>,
) -> AssetOperationBillingPlan {
let Some(billing_context) = billing_context else {
return AssetOperationBillingPlan {
current: AssetOperationLedgerPair {
consume_ledger_id: format!(
"asset_operation_consume:{}:{}:{}",
owner_user_id, asset_kind, asset_id
),
refund_ledger_id: format!(
"asset_operation_refund:{}:{}:{}",
owner_user_id, asset_kind, asset_id
),
},
previous_attempts: Vec::new(),
external_generation_job_id: None,
external_generation_claim_attempt: None,
};
};
let current = external_generation_attempt_ledger_pair(
&billing_context.job_id,
billing_context.claim_attempt,
);
let previous_attempts = (1..billing_context.claim_attempt)
.map(|attempt| external_generation_attempt_ledger_pair(&billing_context.job_id, attempt))
.collect();
AssetOperationBillingPlan {
current,
previous_attempts,
external_generation_job_id: Some(billing_context.job_id.clone()),
external_generation_claim_attempt: Some(billing_context.claim_attempt),
}
}
fn external_generation_attempt_ledger_pair(
job_id: &str,
claim_attempt: u32,
) -> AssetOperationLedgerPair {
let suffix = format!(
"external_generation_job:{}:attempt:{}",
job_id.trim(),
claim_attempt
);
AssetOperationLedgerPair {
consume_ledger_id: format!("asset_operation_consume:{suffix}"),
refund_ledger_id: format!("asset_operation_refund:{suffix}"),
}
}
async fn settle_previous_external_generation_attempts(
state: &AppState,
owner_user_id: &str,
asset_kind: &str,
asset_id: &str,
points_cost: u64,
billing_plan: &AssetOperationBillingPlan,
) -> Result<(), AppError> {
let Some(job_id) = billing_plan.external_generation_job_id.as_deref() else {
return Ok(());
};
if points_cost == 0 {
return Ok(());
}
for (index, previous_attempt) in billing_plan.previous_attempts.iter().enumerate() {
let claim_attempt = u32::try_from(index).unwrap_or(u32::MAX).saturating_add(1);
refund_asset_operation_points_with_job_id(
state.clone(),
owner_user_id.to_string(),
asset_kind.to_string(),
asset_id.to_string(),
points_cost,
previous_attempt.refund_ledger_id.clone(),
Some(job_id.to_string()),
Some(claim_attempt),
)
.await?;
}
Ok(())
}
/// 资产操作统一预扣泥点;普通调用按资源 ID 幂等,队列调用按 job + claim attempt 幂等。
async fn consume_asset_operation_points(
state: &AppState,
owner_user_id: &str,
points_cost: u64,
ledger_id: &str,
metadata_json: String,
) -> Result<bool, AppError> {
if points_cost == 0 {
return Ok(false);
}
match state
.spacetime_client()
.consume_profile_wallet_points_with_metadata(
owner_user_id.to_string(),
points_cost,
ledger_id.to_string(),
current_utc_micros(),
metadata_json,
)
.await
{
Ok(_) => Ok(true),
Err(error) => Err(map_asset_operation_wallet_error(error)),
}
}
async fn refund_asset_operation_points_with_job_id(
state: AppState,
owner_user_id: String,
asset_kind: String,
asset_id: String,
points_cost: u64,
ledger_id: String,
external_generation_job_id: Option<String>,
external_generation_claim_attempt: Option<u32>,
) -> Result<(), AppError> {
let created_at_micros = current_utc_micros();
let current_attempt_is_owned_by_failure_transaction =
external_generation_job_id.as_deref().is_some_and(|job_id| {
current_external_generation_billing_context().is_some_and(|context| {
context.job_id == job_id
&& Some(context.claim_attempt) == external_generation_claim_attempt
})
});
if current_attempt_is_owned_by_failure_transaction {
// 队列当前 attempt 的 refund 由 fail_external_generation_job transaction 原子写入
// SpacetimeDB outbox;这里不能先写另一笔独立退款,避免任务成功写回后被误退。
return Ok(());
}
let settlement_reason = if external_generation_job_id.is_some() {
"stale_attempt_recovery"
} else {
"asset_operation_failed"
};
let enqueue_input = module_runtime::build_runtime_profile_wallet_refund_outbox_enqueue_input(
owner_user_id.clone(),
points_cost,
ledger_id.clone(),
created_at_micros,
asset_kind.clone(),
asset_id.clone(),
settlement_reason.to_string(),
external_generation_job_id.clone(),
external_generation_claim_attempt,
)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "profile-wallet-refund-outbox",
"message": error.to_string(),
}))
})?;
let enqueue_result = state
.spacetime_client()
.enqueue_profile_wallet_refund_outbox(enqueue_input)
.await;
match enqueue_result {
Ok(_) => {
tracing::info!(
owner_user_id,
asset_kind,
asset_id,
external_generation_job_id,
external_generation_claim_attempt,
ledger_id,
"资产操作失败后的泥点退款已写入 SpacetimeDB refund outbox"
);
Ok(())
}
Err(error) if should_use_wallet_refund_emergency_spool(&error) => {
let refund_error = error.to_string();
let app_error = map_asset_operation_wallet_error(error);
if let Some(outbox) = state.wallet_refund_outbox() {
match outbox
.enqueue(WalletRefundOutboxRecord {
owner_user_id: owner_user_id.clone(),
amount: points_cost,
ledger_id: ledger_id.clone(),
created_at_micros,
asset_kind: asset_kind.clone(),
asset_id: asset_id.clone(),
settlement_reason: settlement_reason.to_string(),
external_generation_job_id: external_generation_job_id.clone(),
external_generation_claim_attempt,
})
.await
{
Ok(WalletRefundOutboxEnqueueOutcome::Enqueued) => {
tracing::warn!(
owner_user_id,
asset_kind,
asset_id,
external_generation_job_id,
ledger_id,
error = %refund_error,
"SpacetimeDB refund outbox 不可达,已写入本机 emergency spool"
);
}
Ok(WalletRefundOutboxEnqueueOutcome::OverflowEnqueued { reason }) => {
tracing::error!(
owner_user_id,
asset_kind,
asset_id,
external_generation_job_id,
ledger_id,
reason,
error = %refund_error,
"SpacetimeDB refund outbox 不可达,退款已写入本机 emergency spool overflow 文件;需监控并尽快恢复库内队列"
);
}
Err(outbox_error) => {
tracing::error!(
owner_user_id,
asset_kind,
asset_id,
external_generation_job_id,
ledger_id,
refund_error = %refund_error,
outbox_error = %outbox_error,
"SpacetimeDB refund outbox 不可达,且写入本机 emergency spool 失败"
);
}
}
} else {
tracing::error!(
owner_user_id,
asset_kind,
asset_id,
external_generation_job_id,
external_generation_claim_attempt,
ledger_id,
error = %refund_error,
"SpacetimeDB refund outbox 不可达,且本机 emergency spool 未启用"
);
}
Err(app_error)
}
Err(error) => Err(map_asset_operation_wallet_error(error)),
}
}
fn current_external_generation_billing_context() -> Option<ExternalGenerationBillingContext> {
CURRENT_EXTERNAL_GENERATION_BILLING_CONTEXT
.try_with(Clone::clone)
.ok()
.filter(|context| !context.job_id.trim().is_empty())
}
pub(crate) fn current_external_generation_billing_price_mud_points() -> Option<u32> {
CURRENT_EXTERNAL_GENERATION_BILLING_CONTEXT
.try_with(|context| context.price_mud_points)
.ok()
}
fn resolve_asset_operation_points_cost(configured_points_cost: u64) -> u64 {
current_external_generation_billing_price_mud_points()
.map(u64::from)
.unwrap_or(configured_points_cost)
}
#[cfg(test)]
fn current_wallet_metadata_json(asset_kind: &str) -> String {
let billing_context = current_external_generation_billing_context();
wallet_metadata_json(
asset_kind,
billing_context
.as_ref()
.map(|context| context.job_id.as_str()),
billing_context
.as_ref()
.map(|context| context.claim_attempt),
)
}
fn wallet_metadata_json(
asset_kind: &str,
external_generation_job_id: Option<&str>,
external_generation_claim_attempt: Option<u32>,
) -> String {
let mut metadata = json!({
"assetKind": asset_kind.trim(),
});
if let Some(external_generation_job_id) = external_generation_job_id
.map(str::trim)
.filter(|value| !value.is_empty())
{
metadata["externalGenerationJobId"] = json!(external_generation_job_id);
metadata["externalGenerationClaimAttempt"] = json!(external_generation_claim_attempt);
}
metadata.to_string()
}
pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> AppError {
let message = error.to_string();
tracing::warn!(
provider = "profile-wallet",
error = %message,
"资产操作泥点预扣失败"
);
let is_insufficient_balance = matches!(
&error,
SpacetimeClientError::Procedure(message)
if message.contains("泥点余额不足") || message.contains("可消费泥点不足:")
);
let status = if is_insufficient_balance {
StatusCode::CONFLICT
} else {
StatusCode::BAD_GATEWAY
};
let public_message = is_insufficient_balance.then_some("泥点余额不足");
let public_detail_message = public_message.unwrap_or(message.as_str());
let app_error = AppError::from_status(status).with_details(json!({
"provider": "profile-wallet",
"message": public_detail_message,
}));
if let Some(public_message) = public_message {
app_error.with_message(public_message)
} else {
app_error
}
}
pub(crate) fn should_skip_asset_operation_billing_for_connectivity(
error: &SpacetimeClientError,
) -> bool {
match error {
SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout(_) => true,
SpacetimeClientError::Build(message)
| SpacetimeClientError::Procedure(message)
| SpacetimeClientError::Runtime(message) => {
message.contains("503")
|| message.contains("Service Unavailable")
|| message.contains("Failed to connect")
|| message.contains("WebSocket")
|| message.contains("No such procedure")
|| message.contains("连接已断开")
|| message.contains("连接在返回结果前已断开")
}
}
}
fn should_use_wallet_refund_emergency_spool(error: &SpacetimeClientError) -> bool {
match error {
SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout(_) => true,
SpacetimeClientError::Build(message)
| SpacetimeClientError::Procedure(message)
| SpacetimeClientError::Runtime(message) => {
message.contains("503")
|| message.contains("Service Unavailable")
|| message.contains("Failed to connect")
|| message.contains("WebSocket")
|| message.contains("连接已断开")
|| message.contains("连接在返回结果前已断开")
}
}
}
fn current_utc_micros() -> i64 {
time::OffsetDateTime::now_utc().unix_timestamp_nanos() as i64 / 1_000
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use super::*;
#[derive(Default)]
struct TestWalletLedger {
balance_delta: i64,
ledger_ids: HashSet<String>,
}
impl TestWalletLedger {
fn consume(&mut self, ledger: &AssetOperationLedgerPair, amount: u64) {
if self.ledger_ids.insert(ledger.consume_ledger_id.clone()) {
self.balance_delta -= amount as i64;
}
}
fn refund(&mut self, ledger: &AssetOperationLedgerPair, amount: u64) {
if self.ledger_ids.contains(&ledger.consume_ledger_id)
&& self.ledger_ids.insert(ledger.refund_ledger_id.clone())
{
self.balance_delta += amount as i64;
}
}
}
fn worker_billing_context(attempt: u32) -> ExternalGenerationBillingContext {
ExternalGenerationBillingContext {
job_id: "extgen-billing-test".to_string(),
claim_attempt: attempt,
price_mud_points: 37,
}
}
#[test]
fn crashed_worker_attempt_is_settled_before_reclaimed_attempt_charge() {
let first_attempt = build_asset_operation_billing_plan(
"user-1",
"editor-image",
"asset-1",
Some(&worker_billing_context(1)),
);
let second_attempt = build_asset_operation_billing_plan(
"user-1",
"editor-image",
"asset-1",
Some(&worker_billing_context(2)),
);
let mut wallet = TestWalletLedger::default();
wallet.consume(&first_attempt.current, 37);
for previous in &second_attempt.previous_attempts {
wallet.refund(previous, 37);
}
wallet.consume(&second_attempt.current, 37);
assert_eq!(wallet.balance_delta, -37);
assert!(wallet.ledger_ids.contains(
"asset_operation_refund:external_generation_job:extgen-billing-test:attempt:1"
));
assert!(wallet.ledger_ids.contains(
"asset_operation_consume:external_generation_job:extgen-billing-test:attempt:2"
));
}
#[test]
fn repeated_previous_attempt_settlement_is_idempotent() {
let first_attempt = build_asset_operation_billing_plan(
"user-1",
"editor-image",
"asset-1",
Some(&worker_billing_context(1)),
);
let second_attempt = build_asset_operation_billing_plan(
"user-1",
"editor-image",
"asset-1",
Some(&worker_billing_context(2)),
);
let mut wallet = TestWalletLedger::default();
wallet.consume(&first_attempt.current, 37);
for _ in 0..2 {
for previous in &second_attempt.previous_attempts {
wallet.refund(previous, 37);
}
}
assert_eq!(wallet.balance_delta, 0);
}
#[test]
fn ordinary_asset_failure_keeps_legacy_ledger_and_refunds_charge() {
let plan = build_asset_operation_billing_plan("user-1", "editor-image", "asset-1", None);
let mut wallet = TestWalletLedger::default();
wallet.consume(&plan.current, 1);
wallet.refund(&plan.current, 1);
assert!(plan.previous_attempts.is_empty());
assert_eq!(
plan.current.consume_ledger_id,
"asset_operation_consume:user-1:editor-image:asset-1"
);
assert_eq!(
plan.current.refund_ledger_id,
"asset_operation_refund:user-1:editor-image:asset-1"
);
assert_eq!(wallet.balance_delta, 0);
}
#[test]
fn successful_worker_attempt_is_charged_once() {
let plan = build_asset_operation_billing_plan(
"user-1",
"editor-image",
"asset-1",
Some(&worker_billing_context(1)),
);
let mut wallet = TestWalletLedger::default();
wallet.consume(&plan.current, 37);
wallet.consume(&plan.current, 37);
assert!(plan.previous_attempts.is_empty());
assert_eq!(wallet.balance_delta, -37);
}
#[test]
fn asset_operation_connectivity_errors_are_classified_for_non_billing_fallbacks() {
assert_eq!(ASSET_OPERATION_POINTS_COST, 1);
assert!(should_skip_asset_operation_billing_for_connectivity(
&SpacetimeClientError::ConnectDropped
));
assert!(should_skip_asset_operation_billing_for_connectivity(
&SpacetimeClientError::Runtime(
"Failed to connect: HTTP error: 503 Service Unavailable".to_string(),
),
));
assert!(should_skip_asset_operation_billing_for_connectivity(
&SpacetimeClientError::Procedure(
"No such procedure: consume_profile_wallet_points_and_return".to_string(),
),
));
assert!(!should_skip_asset_operation_billing_for_connectivity(
&SpacetimeClientError::Procedure("泥点余额不足".to_string()),
));
}
#[test]
fn wallet_refund_emergency_spool_requires_database_unavailability() {
assert!(should_use_wallet_refund_emergency_spool(
&SpacetimeClientError::ConnectDropped
));
assert!(should_use_wallet_refund_emergency_spool(
&SpacetimeClientError::Runtime("503 Service Unavailable".to_string())
));
assert!(!should_use_wallet_refund_emergency_spool(
&SpacetimeClientError::Procedure(
"No such procedure: enqueue_profile_wallet_refund_outbox_and_return".to_string(),
)
));
assert!(!should_use_wallet_refund_emergency_spool(
&SpacetimeClientError::Procedure("泥点余额不足".to_string())
));
}
#[test]
fn asset_operation_wallet_insufficient_balance_is_public_message() {
for domain_message in [
"泥点余额不足",
"可消费泥点不足:需要 10,扣除退款占用后可用 2",
] {
let error = map_asset_operation_wallet_error(SpacetimeClientError::Procedure(
domain_message.to_string(),
));
assert_eq!(error.status_code(), StatusCode::CONFLICT);
assert_eq!(error.code(), "CONFLICT");
assert_eq!(error.message(), "泥点余额不足");
assert_eq!(
error
.details()
.and_then(|details| details.get("message"))
.and_then(serde_json::Value::as_str),
Some("泥点余额不足"),
);
}
}
#[test]
fn asset_operation_billing_refunds_stale_worker_attempt_errors() {
let stale_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": "external_generation_job lease 已过期",
}));
let completed_job_error =
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": "external_generation_job 当前不是 running 状态",
}));
let missing_job_error =
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": "external_generation_job 不存在",
}));
let ordinary_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "图片生成失败",
}));
assert!(should_refund_asset_operation_error(&stale_error));
assert!(should_refund_asset_operation_error(&completed_job_error));
assert!(should_refund_asset_operation_error(&missing_job_error));
assert!(should_refund_asset_operation_error(&ordinary_error));
}
#[tokio::test]
async fn worker_billing_context_freezes_charge_and_preserves_job_metadata() {
let (points_cost, metadata_json) = with_external_generation_billing_attempt_context(
" extgen-billing-test ".to_string(),
2,
37,
async {
(
resolve_asset_operation_points_cost(99),
current_wallet_metadata_json("editor_generated_image"),
)
},
)
.await;
assert_eq!(points_cost, 37);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&metadata_json)
.expect("worker metadata should be valid JSON")
.get("assetKind")
.and_then(serde_json::Value::as_str),
Some("editor_generated_image")
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&metadata_json)
.expect("worker metadata should be valid JSON")
.get("externalGenerationJobId")
.and_then(serde_json::Value::as_str),
Some("extgen-billing-test")
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&metadata_json)
.expect("worker metadata should be valid JSON")
.get("externalGenerationClaimAttempt")
.and_then(serde_json::Value::as_u64),
Some(2)
);
assert_eq!(resolve_asset_operation_points_cost(99), 99);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&current_wallet_metadata_json(
"editor_image_edit"
))
.expect("ordinary metadata should be valid JSON"),
json!({"assetKind": "editor_image_edit"})
);
}
#[test]
fn every_provider_inline_route_defers_billing_until_durable_completion() {
for (source, expected_calls) in [
(include_str!("editor_project.rs"), 4),
(include_str!("editor_project_icon.rs"), 2),
(include_str!("character_animation_assets.rs"), 2),
(
include_str!("vector_engine_audio_generation/generation.rs"),
2,
),
] {
let production_source = source
.rsplit_once("\n#[cfg(test)]\nmod tests")
.map(|(production_source, _)| production_source)
.unwrap_or(source);
assert_eq!(
production_source
.matches("with_editor_generation_durable_billing_boundary(")
.count(),
expected_calls,
);
}
let editor_source = include_str!("editor_project.rs");
let scene_route = editor_source
.split_once("pub async fn generate_editor_scene(")
.and_then(|(_, tail)| {
tail.split_once("pub async fn generate_editor_image(")
.map(|(body, _)| body)
})
.expect("scene route");
let inline_operation = scene_route
.find(".with_inline_operation(")
.expect("scene inline operation");
let billing_boundary = scene_route
.find("with_editor_generation_durable_billing_boundary(")
.expect("scene durable billing boundary");
let provider_execution = scene_route
.rfind("generate_editor_image_for_owner(")
.expect("scene provider execution");
assert!(inline_operation < billing_boundary);
assert!(billing_boundary < provider_execution);
}
#[test]
fn atomic_commit_dispatch_marks_unknown_before_the_cancellable_result_wait() {
let editor_source = include_str!("editor_project.rs");
let editor_body = editor_source
.split_once("async fn persist_editor_generation_prepared_commit(")
.and_then(|(_, tail)| {
tail.split_once("async fn call_with_editor_generation_unknown_result_replay")
.map(|(body, _)| body)
})
.expect("prepared commit helper");
assert!(editor_body.contains("persist_editor_generation_result_with_dispatch"));
assert!(editor_body.contains("mark_editor_generation_commit_dispatched"));
let client_source = include_str!("../../spacetime-client/src/editor_project.rs");
let client_body = client_source
.split_once("pub async fn persist_editor_generation_result_with_dispatch")
.and_then(|(_, tail)| {
tail.split_once("pub async fn create_editor_project")
.map(|(body, _)| body)
})
.expect("dispatch-aware client procedure");
let mark = client_body.find("on_dispatch();").expect("dispatch marker");
let procedure = client_body
.find(".persist_editor_generation_result_and_return_then(")
.expect("procedure dispatch");
assert!(mark < procedure);
let billing_source = include_str!("asset_billing.rs");
let drop_body = billing_source
.split_once("impl Drop for AssetOperationRefundOnDrop")
.and_then(|(_, tail)| {
tail.split_once("pub(crate) fn should_refund_asset_operation_error")
.map(|(body, _)| body)
})
.expect("refund guard drop");
assert!(drop_body.contains("editor_generation_commit_outcome_is_unknown()"));
}
}