按后台配置扣除创作泥点

前端创作表单泥点预校验改为读取入口契约配置

拼图和抓大鹅初始生成后端扣费改为解析后台配置

汪汪声浪初始三图生成按入口总成本拆分扣费

创作工作台按钮和确认弹窗展示后台配置泥点成本

补充泥点扣费回归测试并同步文档与共享记忆
This commit is contained in:
2026-06-08 15:47:48 +08:00
parent 3ca5a460f1
commit 5ea9f0a120
21 changed files with 425 additions and 45 deletions

View File

@@ -71,6 +71,10 @@ async fn consume_asset_operation_points(
asset_id: &str,
points_cost: u64,
) -> Result<bool, AppError> {
if points_cost == 0 {
return Ok(false);
}
let ledger_id = format!(
"asset_operation_consume:{}:{}:{}",
owner_user_id, asset_kind, asset_id

View File

@@ -36,7 +36,7 @@ use time::{Duration as TimeDuration, OffsetDateTime};
use crate::{
api_response::json_success_body,
asset_billing::execute_billable_asset_operation,
asset_billing::execute_billable_asset_operation_with_cost,
auth::AuthenticatedAccessToken,
generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
@@ -62,6 +62,8 @@ const BARK_BATTLE_RUN_ID_PREFIX: &str = "bark-battle-run-";
const BARK_BATTLE_RUN_TOKEN_PREFIX: &str = "bark-battle-token-";
const BARK_BATTLE_IMAGE_ID_PREFIX: &str = "bark-battle-image-";
const BARK_BATTLE_PLAY_TYPE_ID: &str = "bark-battle";
const BARK_BATTLE_INITIAL_DRAFT_GENERATION_BILLING_PURPOSE: &str = "initial_draft_generation";
const BARK_BATTLE_INITIAL_DRAFT_GENERATION_SLOT_COUNT: u64 = 3;
const BARK_BATTLE_RUN_TTL_SECONDS: i64 = 10 * 60;
const BARK_BATTLE_CHARACTER_IMAGE_SIZE: &str = "1024*1024";
const BARK_BATTLE_BACKGROUND_IMAGE_SIZE: &str = "1024*1792";
@@ -303,11 +305,13 @@ pub async fn generate_bark_battle_image_asset(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
let result = execute_billable_asset_operation(
let points_cost = resolve_bark_battle_image_asset_points_cost(&state, &payload).await;
let result = execute_billable_asset_operation_with_cost(
&state,
&owner_user_id,
bark_battle_slot_asset_kind(&slot),
asset_id.as_str(),
points_cost,
async {
generate_and_persist_bark_battle_image_asset(
&state,
@@ -328,6 +332,40 @@ pub async fn generate_bark_battle_image_asset(
Ok(json_success_body(Some(&request_context), result))
}
async fn resolve_bark_battle_image_asset_points_cost(
state: &AppState,
payload: &BarkBattleImageAssetGenerateRequest,
) -> u64 {
if payload.billing_purpose.as_deref()
!= Some(BARK_BATTLE_INITIAL_DRAFT_GENERATION_BILLING_PURPOSE)
{
return crate::asset_billing::ASSET_OPERATION_POINTS_COST;
}
let total_cost = crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
state,
BARK_BATTLE_PLAY_TYPE_ID,
BARK_BATTLE_INITIAL_DRAFT_GENERATION_SLOT_COUNT
* crate::asset_billing::ASSET_OPERATION_POINTS_COST,
)
.await;
resolve_bark_battle_initial_generation_slot_points_cost(&payload.slot, total_cost)
}
fn resolve_bark_battle_initial_generation_slot_points_cost(
slot: &BarkBattleAssetSlot,
total_cost: u64,
) -> u64 {
let base_cost = total_cost / BARK_BATTLE_INITIAL_DRAFT_GENERATION_SLOT_COUNT;
let remainder = total_cost % BARK_BATTLE_INITIAL_DRAFT_GENERATION_SLOT_COUNT;
let slot_index = match slot {
BarkBattleAssetSlot::PlayerCharacter => 0,
BarkBattleAssetSlot::OpponentCharacter => 1,
BarkBattleAssetSlot::UiBackground => 2,
};
base_cost + u64::from(slot_index < remainder)
}
pub async fn publish_bark_battle_work(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -1661,6 +1699,94 @@ mod tests {
);
}
#[test]
fn initial_generation_slot_cost_splits_creation_entry_total_cost() {
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::PlayerCharacter,
1,
),
1,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::OpponentCharacter,
1,
),
0,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::UiBackground,
1,
),
0,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::PlayerCharacter,
2,
),
1,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::OpponentCharacter,
2,
),
1,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::UiBackground,
2,
),
0,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::PlayerCharacter,
6,
),
2,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::OpponentCharacter,
6,
),
2,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::UiBackground,
6,
),
2,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::PlayerCharacter,
8,
),
3,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::OpponentCharacter,
8,
),
3,
);
assert_eq!(
resolve_bark_battle_initial_generation_slot_points_cost(
&BarkBattleAssetSlot::UiBackground,
8,
),
2,
);
}
#[test]
fn draft_config_mapping_includes_stable_work_identity() {
let request_context = RequestContext::new(

View File

@@ -126,6 +126,44 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
None
}
pub(crate) fn resolve_creation_entry_mud_point_cost_from_config(
config: &CreationEntryConfigResponse,
creation_type_id: &str,
fallback_cost: u64,
) -> u64 {
config
.creation_types
.iter()
.find(|item| item.id == creation_type_id)
.and_then(|item| item.unified_creation_spec.as_ref())
.map(|spec| u64::from(spec.mud_point_cost))
.filter(|cost| *cost > 0)
.unwrap_or(fallback_cost)
}
pub(crate) async fn resolve_creation_entry_mud_point_cost(
state: &AppState,
creation_type_id: &str,
fallback_cost: u64,
) -> u64 {
match state.get_creation_entry_config().await {
Ok(config) => resolve_creation_entry_mud_point_cost_from_config(
&config,
creation_type_id,
fallback_cost,
),
Err(error) => {
tracing::warn!(
creation_type_id,
fallback_cost,
error = %error,
"读取创作入口泥点成本失败,回退到代码默认值"
);
fallback_cost
}
}
}
fn creation_entry_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
@@ -170,6 +208,7 @@ pub(crate) fn test_creation_entry_config_response() -> CreationEntryConfigRespon
#[cfg(test)]
mod tests {
use super::*;
use shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST;
#[test]
fn resolves_new_creation_paths_to_creation_type_ids() {
@@ -258,6 +297,50 @@ mod tests {
assert_eq!(resolve_creation_entry_route_id("/healthz"), None);
}
#[test]
fn resolves_mud_point_cost_from_unified_creation_spec() {
let mut config = test_creation_entry_config_response();
let puzzle = config
.creation_types
.iter_mut()
.find(|item| item.id == "puzzle")
.expect("puzzle config should exist");
let spec = puzzle
.unified_creation_spec
.as_mut()
.expect("puzzle unified spec should exist");
spec.mud_point_cost = 8;
assert_eq!(
resolve_creation_entry_mud_point_cost_from_config(&config, "puzzle", 2),
8,
);
}
#[test]
fn resolves_mud_point_cost_with_fallback_for_legacy_config() {
let mut config = test_creation_entry_config_response();
let puzzle = config
.creation_types
.iter_mut()
.find(|item| item.id == "puzzle")
.expect("puzzle config should exist");
puzzle.unified_creation_spec = None;
assert_eq!(
resolve_creation_entry_mud_point_cost_from_config(&config, "puzzle", 2),
2,
);
assert_eq!(
resolve_creation_entry_mud_point_cost_from_config(
&config,
"missing-play",
u64::from(DEFAULT_UNIFIED_CREATION_MUD_POINT_COST),
),
u64::from(DEFAULT_UNIFIED_CREATION_MUD_POINT_COST),
);
}
#[test]
fn test_creation_entry_config_response_opens_bark_battle() {
let config = test_creation_entry_config_response();

View File

@@ -163,6 +163,12 @@ pub(super) async fn compile_match3d_draft_for_session(
.clone()
.unwrap_or_else(|| fallback_work_metadata.tags.clone());
let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros());
let points_cost = crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
state,
"match3d",
MATCH3D_DRAFT_GENERATION_POINTS_COST,
)
.await;
let compile_session_id = session_id.clone();
let compile_owner_user_id = owner_user_id.clone();
let compile_profile_id = profile_id.clone();
@@ -175,6 +181,7 @@ pub(super) async fn compile_match3d_draft_for_session(
request_context,
owner_user_id.as_str(),
billing_asset_id.as_str(),
points_cost,
async {
let mut session = upsert_match3d_draft_snapshot(
state,
@@ -418,12 +425,13 @@ fn match3d_response_failure_message(response: &Response) -> String {
.unwrap_or_else(|| format!("抓大鹅草稿生成失败HTTP {}", response.status()))
}
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 泥点
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按后台入口配置的泥点成本幂等预扣
async fn execute_billable_match3d_draft_generation<T, Fut>(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
billing_asset_id: &str,
points_cost: u64,
operation: Fut,
) -> Result<T, Response>
where
@@ -434,6 +442,7 @@ where
request_context,
owner_user_id,
billing_asset_id,
points_cost,
)
.await?;
@@ -441,8 +450,13 @@ where
Ok(value) => Ok(value),
Err(response) => {
if points_consumed {
refund_match3d_draft_generation_points(state, owner_user_id, billing_asset_id)
.await;
refund_match3d_draft_generation_points(
state,
owner_user_id,
billing_asset_id,
points_cost,
)
.await;
}
Err(response)
}
@@ -454,6 +468,7 @@ async fn consume_match3d_draft_generation_points(
request_context: &RequestContext,
owner_user_id: &str,
billing_asset_id: &str,
points_cost: u64,
) -> Result<bool, Response> {
let ledger_id = format!(
"asset_operation_consume:{}:match3d_draft_generation:{}",
@@ -463,7 +478,7 @@ async fn consume_match3d_draft_generation_points(
.spacetime_client()
.consume_profile_wallet_points(
owner_user_id.to_string(),
MATCH3D_DRAFT_GENERATION_POINTS_COST,
points_cost,
ledger_id,
current_utc_micros(),
)
@@ -491,6 +506,7 @@ async fn refund_match3d_draft_generation_points(
state: &AppState,
owner_user_id: &str,
billing_asset_id: &str,
points_cost: u64,
) {
let ledger_id = format!(
"asset_operation_refund:{}:match3d_draft_generation:{}",
@@ -500,7 +516,7 @@ async fn refund_match3d_draft_generation_points(
.spacetime_client()
.refund_profile_wallet_points(
owner_user_id.to_string(),
MATCH3D_DRAFT_GENERATION_POINTS_COST,
points_cost,
ledger_id,
current_utc_micros(),
)

View File

@@ -589,6 +589,7 @@ pub async fn execute_puzzle_agent_action(
let now = current_utc_micros();
let action = payload.action.trim().to_string();
let billing_asset_id = format!("{session_id}:{now}");
let mut operation_consumed_points = 0;
tracing::info!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %session_id,
@@ -655,6 +656,17 @@ pub async fn execute_puzzle_agent_action(
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
"compile_puzzle_draft" => {
let ai_redraw = payload.ai_redraw.unwrap_or(true);
let puzzle_draft_generation_points_cost = if ai_redraw {
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
state.root_state(),
"puzzle",
PUZZLE_IMAGE_GENERATION_POINTS_COST,
)
.await
} else {
0
};
operation_consumed_points = puzzle_draft_generation_points_cost;
let reference_image_sources = collect_puzzle_reference_image_sources(
payload.reference_image_src.as_deref(),
payload.reference_image_srcs.as_slice(),
@@ -718,6 +730,7 @@ pub async fn execute_puzzle_agent_action(
let background_reference_image_src =
primary_reference_image_src.map(str::to_string);
let background_image_model = payload.image_model.clone();
let background_points_cost = puzzle_draft_generation_points_cost;
let background_work_name = compiled_session
.draft
.as_ref()
@@ -733,7 +746,7 @@ pub async fn execute_puzzle_agent_action(
&background_owner_user_id,
"puzzle_initial_image",
&background_billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
background_points_cost,
async move {
generate_puzzle_initial_cover_from_compiled_session(
&operation_state,
@@ -761,8 +774,7 @@ pub async fn execute_puzzle_agent_action(
.map(|draft| draft.work_title.clone()),
status:
GenerationResultSubscribeMessageStatus::Succeeded,
consumed_points:
PUZZLE_IMAGE_GENERATION_POINTS_COST,
consumed_points: background_points_cost,
completed_at_micros: current_utc_micros(),
page: Some("/pages/web-view/index".to_string()),
},
@@ -1481,11 +1493,7 @@ pub async fn execute_puzzle_agent_action(
owner_user_id: owner_user_id.clone(),
work_name: session.draft.as_ref().map(|draft| draft.work_title.clone()),
status: GenerationResultSubscribeMessageStatus::Succeeded,
consumed_points: if payload.ai_redraw.unwrap_or(true) {
PUZZLE_IMAGE_GENERATION_POINTS_COST
} else {
0
},
consumed_points: operation_consumed_points,
completed_at_micros: current_utc_micros(),
page: Some("/pages/web-view/index".to_string()),
},