修复资产计费边界风险
资产生成预扣费改为 fail-closed,避免钱包异常时继续调用外部生成 新增钱包退款 outbox,退款失败时本地落盘并后台重放 拼图首图后台任务改用 SpacetimeDB claim 表实现跨实例互斥 计费 ledger id 统一绑定 request_id,并让前端重试复用 x-request-id 同步 SpacetimeDB bindings、后端架构文档和 Hermes 决策记录
This commit is contained in:
@@ -20,8 +20,8 @@ use crate::match3d::tables::{
|
||||
match_3_d_work_profile, match3d_agent_message, match3d_agent_session, match3d_runtime_run,
|
||||
};
|
||||
use crate::puzzle::{
|
||||
puzzle_agent_message, puzzle_agent_session, puzzle_event, puzzle_leaderboard_entry,
|
||||
puzzle_runtime_run, puzzle_work_profile,
|
||||
puzzle_agent_message, puzzle_agent_session, puzzle_background_compile_task, puzzle_event,
|
||||
puzzle_leaderboard_entry, puzzle_runtime_run, puzzle_work_profile,
|
||||
};
|
||||
use crate::puzzle_clear::tables::{
|
||||
puzzle_clear_agent_session, puzzle_clear_event, puzzle_clear_runtime_run,
|
||||
@@ -229,6 +229,7 @@ macro_rules! migration_tables {
|
||||
asset_entity_binding,
|
||||
asset_event,
|
||||
puzzle_agent_session,
|
||||
puzzle_background_compile_task,
|
||||
puzzle_agent_message,
|
||||
puzzle_work_profile,
|
||||
puzzle_event,
|
||||
|
||||
@@ -10,14 +10,16 @@ use module_puzzle::{
|
||||
PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind,
|
||||
PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput,
|
||||
PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot,
|
||||
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileFailureInput, PuzzleDraftCompileInput,
|
||||
PuzzleFormDraftSaveInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput,
|
||||
PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus,
|
||||
PuzzlePublishInput, PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput,
|
||||
PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult,
|
||||
PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput,
|
||||
PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput,
|
||||
PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput,
|
||||
PuzzleAgentStage, PuzzleAnchorPack, PuzzleBackgroundCompileTaskClaimInput,
|
||||
PuzzleBackgroundCompileTaskProcedureResult, PuzzleBackgroundCompileTaskReleaseInput,
|
||||
PuzzleDraftCompileFailureInput, PuzzleDraftCompileInput, PuzzleFormDraftSaveInput,
|
||||
PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry,
|
||||
PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus, PuzzlePublishInput,
|
||||
PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput,
|
||||
PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, PuzzleRunPropInput,
|
||||
PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus,
|
||||
PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput, PuzzleWorkDeleteInput,
|
||||
PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput,
|
||||
PuzzleWorkPointIncentiveClaimInput, PuzzleWorkProcedureResult, PuzzleWorkProfile,
|
||||
PuzzleWorkRemixInput, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult,
|
||||
apply_publish_overrides_to_draft, apply_selected_candidate, build_form_draft_from_seed,
|
||||
@@ -38,6 +40,7 @@ use spacetimedb::{
|
||||
use crate::auth::user_account;
|
||||
|
||||
const PUZZLE_POINT_INCENTIVE_DEFAULT_U64: u64 = 0;
|
||||
const PUZZLE_BACKGROUND_COMPILE_TASK_LEASE_MICROS: i64 = 30 * 60 * 1_000_000;
|
||||
const WORK_VISIBLE_DEFAULT: bool = true;
|
||||
|
||||
/// 拼图 Agent session 真相表。
|
||||
@@ -62,6 +65,22 @@ pub struct PuzzleAgentSessionRow {
|
||||
updated_at: Timestamp,
|
||||
}
|
||||
|
||||
/// 拼图首图后台编译活动任务表。
|
||||
/// 中文注释:该表只保存跨 api-server 实例互斥 claim,不表达最终生成结果。
|
||||
#[spacetimedb::table(
|
||||
accessor = puzzle_background_compile_task,
|
||||
index(accessor = by_puzzle_background_compile_task_session_id, btree(columns = [session_id]))
|
||||
)]
|
||||
pub struct PuzzleBackgroundCompileTaskRow {
|
||||
#[primary_key]
|
||||
task_id: String,
|
||||
claim_id: String,
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
created_at: Timestamp,
|
||||
updated_at: Timestamp,
|
||||
}
|
||||
|
||||
/// 拼图 Agent 消息真相表。
|
||||
#[spacetimedb::table(
|
||||
accessor = puzzle_agent_message,
|
||||
@@ -388,6 +407,44 @@ pub fn mark_puzzle_draft_generation_failed(
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn claim_puzzle_background_compile_task(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: PuzzleBackgroundCompileTaskClaimInput,
|
||||
) -> PuzzleBackgroundCompileTaskProcedureResult {
|
||||
match ctx.try_with_tx(|tx| claim_puzzle_background_compile_task_tx(tx, input.clone())) {
|
||||
Ok(claimed) => PuzzleBackgroundCompileTaskProcedureResult {
|
||||
ok: true,
|
||||
claimed,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => PuzzleBackgroundCompileTaskProcedureResult {
|
||||
ok: false,
|
||||
claimed: false,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn release_puzzle_background_compile_task(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: PuzzleBackgroundCompileTaskReleaseInput,
|
||||
) -> PuzzleBackgroundCompileTaskProcedureResult {
|
||||
match ctx.try_with_tx(|tx| release_puzzle_background_compile_task_tx(tx, input.clone())) {
|
||||
Ok(released) => PuzzleBackgroundCompileTaskProcedureResult {
|
||||
ok: true,
|
||||
claimed: released,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => PuzzleBackgroundCompileTaskProcedureResult {
|
||||
ok: false,
|
||||
claimed: false,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存拼图入口表单草稿。
|
||||
/// 中文注释:该 procedure 只更新 session 与创作中心草稿卡,不触发图片生成或发布校验。
|
||||
#[spacetimedb::procedure]
|
||||
@@ -1024,6 +1081,84 @@ fn compile_puzzle_agent_draft_tx(
|
||||
)
|
||||
}
|
||||
|
||||
fn claim_puzzle_background_compile_task_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleBackgroundCompileTaskClaimInput,
|
||||
) -> Result<bool, String> {
|
||||
let task_id = normalize_required_puzzle_task_field(&input.task_id, "拼图后台任务 ID")?;
|
||||
let claim_id = normalize_required_puzzle_task_field(&input.claim_id, "拼图后台任务 claim ID")?;
|
||||
let session_id = normalize_required_puzzle_task_field(&input.session_id, "拼图 session ID")?;
|
||||
let owner_user_id = normalize_required_puzzle_task_field(&input.owner_user_id, "拼图用户 ID")?;
|
||||
let claimed_at = Timestamp::from_micros_since_unix_epoch(input.claimed_at_micros);
|
||||
|
||||
get_owned_session_row(ctx, &session_id, &owner_user_id)?;
|
||||
if let Some(existing) = ctx
|
||||
.db
|
||||
.puzzle_background_compile_task()
|
||||
.task_id()
|
||||
.find(&task_id)
|
||||
{
|
||||
if !is_stale_puzzle_background_compile_task(&existing, input.claimed_at_micros) {
|
||||
return Ok(false);
|
||||
}
|
||||
ctx.db
|
||||
.puzzle_background_compile_task()
|
||||
.task_id()
|
||||
.delete(&task_id);
|
||||
}
|
||||
|
||||
ctx.db
|
||||
.puzzle_background_compile_task()
|
||||
.insert(PuzzleBackgroundCompileTaskRow {
|
||||
task_id,
|
||||
claim_id,
|
||||
session_id,
|
||||
owner_user_id,
|
||||
created_at: claimed_at,
|
||||
updated_at: claimed_at,
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn release_puzzle_background_compile_task_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleBackgroundCompileTaskReleaseInput,
|
||||
) -> Result<bool, String> {
|
||||
let task_id = normalize_required_puzzle_task_field(&input.task_id, "拼图后台任务 ID")?;
|
||||
let claim_id = normalize_required_puzzle_task_field(&input.claim_id, "拼图后台任务 claim ID")?;
|
||||
let session_id = normalize_required_puzzle_task_field(&input.session_id, "拼图 session ID")?;
|
||||
let owner_user_id = normalize_required_puzzle_task_field(&input.owner_user_id, "拼图用户 ID")?;
|
||||
|
||||
let Some(row) = ctx
|
||||
.db
|
||||
.puzzle_background_compile_task()
|
||||
.task_id()
|
||||
.find(&task_id)
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
if row.session_id != session_id || row.owner_user_id != owner_user_id {
|
||||
return Err("无权释放该拼图后台任务".to_string());
|
||||
}
|
||||
if row.claim_id != claim_id {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
ctx.db
|
||||
.puzzle_background_compile_task()
|
||||
.task_id()
|
||||
.delete(&task_id);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn is_stale_puzzle_background_compile_task(
|
||||
row: &PuzzleBackgroundCompileTaskRow,
|
||||
now_micros: i64,
|
||||
) -> bool {
|
||||
now_micros.saturating_sub(row.updated_at.to_micros_since_unix_epoch())
|
||||
>= PUZZLE_BACKGROUND_COMPILE_TASK_LEASE_MICROS
|
||||
}
|
||||
|
||||
fn mark_puzzle_draft_generation_failed_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleDraftCompileFailureInput,
|
||||
@@ -2950,6 +3085,14 @@ fn get_owned_session_row(
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
fn normalize_required_puzzle_task_field(value: &str, field_name: &str) -> Result<String, String> {
|
||||
let normalized = value.trim();
|
||||
if normalized.is_empty() {
|
||||
return Err(format!("{field_name} 不能为空"));
|
||||
}
|
||||
Ok(normalized.to_string())
|
||||
}
|
||||
|
||||
fn get_owned_run_row(
|
||||
ctx: &TxContext,
|
||||
run_id: &str,
|
||||
|
||||
Reference in New Issue
Block a user