feat: add asset operation wallet ledger
Some checks failed
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-04-28 12:14:07 +08:00
parent 3cdbf36859
commit 04dfce57e6
16 changed files with 780 additions and 669 deletions

View File

@@ -67,7 +67,7 @@ use tokio::time::sleep;
use crate::{
ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter},
api_response::json_success_body,
asset_billing::{consume_asset_operation_points, refund_asset_operation_points},
asset_billing::execute_billable_asset_operation,
auth::AuthenticatedAccessToken,
http_error::AppError,
prompt::puzzle_image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
@@ -442,29 +442,29 @@ pub async fn execute_puzzle_agent_action(
let owner_user_id = authenticated.claims().user_id().to_string();
let now = current_utc_micros();
let action = payload.action.trim().to_string();
let billed_asset_kind = match action.as_str() {
"compile_puzzle_draft" => Some("puzzle_initial_image"),
"generate_puzzle_images" => Some("puzzle_generated_image"),
_ => None,
};
let billing_asset_id = format!("{session_id}:{now}");
if let Some(asset_kind) = billed_asset_kind {
consume_asset_operation_points(&state, &owner_user_id, asset_kind, &billing_asset_id)
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
"compile_puzzle_draft" => {
let session = execute_billable_asset_operation(
&state,
&owner_user_id,
"puzzle_initial_image",
&billing_asset_id,
async {
compile_puzzle_draft_with_initial_cover(
&state,
session_id.clone(),
owner_user_id.clone(),
now,
)
.await
.map_err(map_puzzle_client_error)
},
)
.await
.map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
})?;
}
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
"compile_puzzle_draft" => {
let session = compile_puzzle_draft_with_initial_cover(
&state,
session_id.clone(),
owner_user_id.clone(),
now,
)
.await;
});
(
"compile_puzzle_draft",
"完整拼图草稿",
@@ -473,75 +473,76 @@ pub async fn execute_puzzle_agent_action(
)
}
"generate_puzzle_images" => {
let session = state
.spacetime_client()
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
.await;
let session = match session {
Ok(session) => {
let session = execute_billable_asset_operation(
&state,
&owner_user_id,
"puzzle_generated_image",
&billing_asset_id,
async {
let session = state
.spacetime_client()
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
.await
.map_err(map_puzzle_client_error)?;
let draft = session.draft.clone().ok_or_else(|| {
SpacetimeClientError::Runtime("拼图结果页草稿尚未生成".to_string())
});
match draft {
Ok(draft) => {
let prompt = payload
.prompt_text
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| draft.summary.clone());
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
let candidate_count = 1;
let candidate_start_index = draft.candidates.len();
let candidates = generate_puzzle_image_candidates(
&state,
owner_user_id.as_str(),
&session.session_id,
&draft.level_name,
&prompt,
payload.reference_image_src.as_deref(),
candidate_count,
candidate_start_index,
)
.await
.map_err(SpacetimeClientError::Runtime);
match candidates {
Ok(candidates) => {
let candidates_json = serde_json::to_string(
&candidates
.iter()
.map(to_puzzle_generated_image_candidate)
.collect::<Vec<_>>(),
)
.map_err(|error| {
SpacetimeClientError::Runtime(format!(
"拼图候选图序列化失败:{error}"
))
});
match candidates_json {
Ok(candidates_json) => {
state
.spacetime_client()
.save_puzzle_generated_images(
PuzzleGeneratedImagesSaveRecordInput {
session_id: session.session_id,
owner_user_id: owner_user_id.clone(),
candidates_json,
saved_at_micros: now,
},
)
.await
}
Err(error) => Err(error),
}
}
Err(error) => Err(error),
}
}
Err(error) => Err(error),
}
}
Err(error) => Err(error),
};
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图结果页草稿尚未生成",
}))
})?;
let prompt = payload
.prompt_text
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| draft.summary.clone());
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
let candidate_count = 1;
let candidate_start_index = draft.candidates.len();
let candidates = generate_puzzle_image_candidates(
&state,
owner_user_id.as_str(),
&session.session_id,
&draft.level_name,
&prompt,
payload.reference_image_src.as_deref(),
candidate_count,
candidate_start_index,
)
.await
.map_err(|message| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": message,
}))
})?;
let candidates_json = serde_json::to_string(
&candidates
.iter()
.map(to_puzzle_generated_image_candidate)
.collect::<Vec<_>>(),
)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图候选图序列化失败:{error}"),
}))
})?;
state
.spacetime_client()
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
session_id: session.session_id,
owner_user_id: owner_user_id.clone(),
candidates_json,
saved_at_micros: now,
})
.await
.map_err(map_puzzle_client_error)
},
)
.await
.map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
});
(
"generate_puzzle_images",
"拼图图片生成",
@@ -569,7 +570,14 @@ pub async fn execute_puzzle_agent_action(
candidate_id,
selected_at_micros: now,
})
.await;
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)
});
(
"select_puzzle_image",
"正式图确认",
@@ -579,43 +587,35 @@ pub async fn execute_puzzle_agent_action(
}
"publish_puzzle_work" => {
let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id);
consume_asset_operation_points(&state, &owner_user_id, "puzzle_publish_work", &work_id)
.await
.map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
})?;
let profile_result = state
.spacetime_client()
.publish_puzzle_work(PuzzlePublishRecordInput {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
// 发布沿用 session 派生的稳定作品 ID避免草稿卡与已发布卡重复。
work_id: work_id.clone(),
profile_id,
author_display_name: resolve_author_display_name(&state, &authenticated),
level_name: payload.level_name.clone(),
summary: payload.summary.clone(),
theme_tags: payload.theme_tags.clone(),
published_at_micros: now,
})
.await;
let profile = match profile_result {
Ok(profile) => profile,
Err(error) => {
refund_asset_operation_points(
&state,
&owner_user_id,
"puzzle_publish_work",
&work_id,
)
.await;
return Err(puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
));
}
};
let author_display_name = resolve_author_display_name(&state, &authenticated);
let profile = execute_billable_asset_operation(
&state,
&owner_user_id,
"puzzle_publish_work",
&work_id,
async {
state
.spacetime_client()
.publish_puzzle_work(PuzzlePublishRecordInput {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
// 发布沿用 session 派生的稳定作品 ID避免草稿卡与已发布卡重复。
work_id: work_id.clone(),
profile_id,
author_display_name,
level_name: payload.level_name.clone(),
summary: payload.summary.clone(),
theme_tags: payload.theme_tags.clone(),
published_at_micros: now,
})
.await
.map_err(map_puzzle_client_error)
},
)
.await
.map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
})?;
let session = state
.spacetime_client()
@@ -654,29 +654,7 @@ pub async fn execute_puzzle_agent_action(
}
};
let session = session.map_err(|error| {
if let Some(asset_kind) = billed_asset_kind {
tokio::spawn({
let state = state.clone();
let owner_user_id = owner_user_id.clone();
let billing_asset_id = billing_asset_id.clone();
async move {
refund_asset_operation_points(
&state,
&owner_user_id,
asset_kind,
&billing_asset_id,
)
.await;
}
});
}
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)
})?;
let session = session?;
Ok(json_success_body(
Some(&request_context),