按后台配置扣除创作泥点
前端创作表单泥点预校验改为读取入口契约配置 拼图和抓大鹅初始生成后端扣费改为解析后台配置 汪汪声浪初始三图生成按入口总成本拆分扣费 创作工作台按钮和确认弹窗展示后台配置泥点成本 补充泥点扣费回归测试并同步文档与共享记忆
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user