456cc42dd3
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>
1097 lines
38 KiB
Rust
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>(¤t_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()"));
|
|
}
|
|
}
|