use crate::*; const ASSET_HISTORY_MAX_LIMIT: usize = 120; const ASSET_HISTORY_CHARACTER_VISUAL_KIND: &str = "character_visual"; const ASSET_HISTORY_SCENE_IMAGE_KIND: &str = "scene_image"; const ASSET_HISTORY_PUZZLE_COVER_IMAGE_KIND: &str = "puzzle_cover_image"; const ASSET_HISTORY_SQUARE_HOLE_COVER_IMAGE_KIND: &str = "square_hole_cover_image"; const ASSET_HISTORY_SQUARE_HOLE_BACKGROUND_IMAGE_KIND: &str = "square_hole_background_image"; const ASSET_HISTORY_SQUARE_HOLE_SHAPE_IMAGE_KIND: &str = "square_hole_shape_image"; const ASSET_HISTORY_SQUARE_HOLE_HOLE_IMAGE_KIND: &str = "square_hole_hole_image"; /// 资产事件类型。 /// /// 事件表只承接订阅端和审计所需的轻量事实,正式资产状态仍以 /// `asset_object` 和 `asset_entity_binding` 为准。 #[derive(Clone, Copy, Debug, PartialEq, Eq, SpacetimeType)] pub enum AssetEventKind { ObjectConfirmed, EntityBindingChanged, } #[spacetimedb::table( accessor = asset_event, public, event, index(accessor = by_asset_event_asset_object_id, btree(columns = [asset_object_id])), index(accessor = by_asset_event_owner_user_id, btree(columns = [owner_user_id])), index(accessor = by_asset_event_profile_id, btree(columns = [profile_id])) )] pub struct AssetEvent { #[primary_key] pub(crate) event_id: String, pub(crate) asset_object_id: String, pub(crate) binding_id: Option, pub(crate) event_kind: AssetEventKind, pub(crate) asset_kind: String, pub(crate) owner_user_id: Option, pub(crate) profile_id: Option, pub(crate) entity_kind: Option, pub(crate) entity_id: Option, pub(crate) slot: Option, pub(crate) occurred_at: Timestamp, } #[spacetimedb::table( accessor = asset_object, index(accessor = by_bucket_object_key, btree(columns = [bucket, object_key])) )] pub struct AssetObject { #[primary_key] asset_object_id: String, // 正式对象定位固定拆成 bucket + object_key 两列,避免后续再从单字符串路径做 schema 拆分。 bucket: String, object_key: String, access_policy: AssetObjectAccessPolicy, content_type: Option, content_length: u64, content_hash: Option, version: u32, source_job_id: Option, owner_user_id: Option, profile_id: Option, entity_id: Option, #[index(btree)] asset_kind: String, created_at: Timestamp, updated_at: Timestamp, } // reducer 负责固定资产对象的正式写规则,供后续内部模块逻辑复用。 #[spacetimedb::reducer] pub fn confirm_asset_object( ctx: &ReducerContext, input: AssetObjectUpsertInput, ) -> Result<(), String> { upsert_asset_object(ctx, input).map(|_| ()) } // procedure 面向 Axum 同步确认接口,返回最终持久化后的对象记录,避免 HTTP 层再额外查询 private table。 #[spacetimedb::procedure] pub fn confirm_asset_object_and_return( ctx: &mut ProcedureContext, input: AssetObjectUpsertInput, ) -> AssetObjectProcedureResult { match ctx.try_with_tx(|tx| upsert_asset_object(tx, input.clone())) { Ok(record) => AssetObjectProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => AssetObjectProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // 历史素材只返回编辑器复用所需的脱敏字段,asset_object 本表继续保持 private。 #[spacetimedb::procedure] pub fn list_asset_history_and_return( ctx: &mut ProcedureContext, input: AssetHistoryListInput, ) -> AssetHistoryListResult { match ctx.try_with_tx(|tx| list_asset_history(tx, input.clone())) { Ok(entries) => AssetHistoryListResult { ok: true, entries, error_message: None, }, Err(message) => AssetHistoryListResult { ok: false, entries: Vec::new(), error_message: Some(message), }, } } pub(crate) fn upsert_asset_object( ctx: &ReducerContext, input: AssetObjectUpsertInput, ) -> Result { validate_asset_object_fields( &input.bucket, &input.object_key, &input.asset_kind, input.version, ) .map_err(|error| error.to_string())?; let current = ctx .db .asset_object() .by_bucket_object_key() .filter((input.bucket.as_str(), input.object_key.as_str())) .next(); let snapshot = match current { Some(existing) => { ctx.db .asset_object() .asset_object_id() .delete(&existing.asset_object_id); let snapshot = AssetObjectUpsertSnapshot { asset_object_id: existing.asset_object_id.clone(), bucket: input.bucket.clone(), object_key: input.object_key.clone(), access_policy: input.access_policy, content_type: input.content_type.clone(), content_length: input.content_length, content_hash: input.content_hash.clone(), version: input.version, source_job_id: input.source_job_id.clone(), owner_user_id: input.owner_user_id.clone(), profile_id: input.profile_id.clone(), entity_id: input.entity_id.clone(), asset_kind: input.asset_kind.clone(), created_at_micros: existing.created_at.to_micros_since_unix_epoch(), updated_at_micros: input.updated_at_micros, }; ctx.db .asset_object() .insert(build_asset_object_row(&snapshot)); snapshot } None => { let snapshot = AssetObjectUpsertSnapshot { asset_object_id: input.asset_object_id.clone(), bucket: input.bucket.clone(), object_key: input.object_key.clone(), access_policy: input.access_policy, content_type: input.content_type.clone(), content_length: input.content_length, content_hash: input.content_hash.clone(), version: input.version, source_job_id: input.source_job_id.clone(), owner_user_id: input.owner_user_id.clone(), profile_id: input.profile_id.clone(), entity_id: input.entity_id.clone(), asset_kind: input.asset_kind.clone(), created_at_micros: input.updated_at_micros, updated_at_micros: input.updated_at_micros, }; ctx.db .asset_object() .insert(build_asset_object_row(&snapshot)); snapshot } }; emit_asset_object_confirmed_event(ctx, &snapshot); Ok(snapshot) } pub(crate) fn has_asset_object(ctx: &ReducerContext, asset_object_id: &str) -> bool { ctx.db .asset_object() .asset_object_id() .find(&asset_object_id.to_string()) .is_some() } fn list_asset_history( ctx: &ReducerContext, input: AssetHistoryListInput, ) -> Result, String> { let asset_kind = input.asset_kind.trim(); if asset_kind != ASSET_HISTORY_CHARACTER_VISUAL_KIND && asset_kind != ASSET_HISTORY_SCENE_IMAGE_KIND && asset_kind != ASSET_HISTORY_PUZZLE_COVER_IMAGE_KIND && asset_kind != ASSET_HISTORY_SQUARE_HOLE_COVER_IMAGE_KIND && asset_kind != ASSET_HISTORY_SQUARE_HOLE_BACKGROUND_IMAGE_KIND && asset_kind != ASSET_HISTORY_SQUARE_HOLE_SHAPE_IMAGE_KIND && asset_kind != ASSET_HISTORY_SQUARE_HOLE_HOLE_IMAGE_KIND { return Err( "历史素材类型只支持 character_visual、scene_image、puzzle_cover_image、square_hole_cover_image、square_hole_background_image、square_hole_shape_image 或 square_hole_hole_image".to_string(), ); } let limit = usize::try_from(input.limit) .unwrap_or(ASSET_HISTORY_MAX_LIMIT) .clamp(1, ASSET_HISTORY_MAX_LIMIT); let mut entries = ctx .db .asset_object() .asset_kind() .filter(&asset_kind.to_string()) .map(|row| AssetHistoryEntrySnapshot { asset_object_id: row.asset_object_id, asset_kind: row.asset_kind, image_src: object_key_to_legacy_image_src(row.object_key.as_str()), owner_user_id: row.owner_user_id, profile_id: row.profile_id, entity_id: row.entity_id, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), }) .collect::>(); entries.sort_by(|left, right| { right .created_at_micros .cmp(&left.created_at_micros) .then_with(|| right.asset_object_id.cmp(&left.asset_object_id)) }); entries.truncate(limit); Ok(entries) } fn object_key_to_legacy_image_src(object_key: &str) -> String { let normalized = object_key.trim().trim_start_matches('/'); if normalized.is_empty() { return String::new(); } format!("/{normalized}") } fn build_asset_object_row(snapshot: &AssetObjectUpsertSnapshot) -> AssetObject { AssetObject { asset_object_id: snapshot.asset_object_id.clone(), bucket: snapshot.bucket.clone(), object_key: snapshot.object_key.clone(), access_policy: snapshot.access_policy, content_type: snapshot.content_type.clone(), content_length: snapshot.content_length, content_hash: snapshot.content_hash.clone(), version: snapshot.version, source_job_id: snapshot.source_job_id.clone(), owner_user_id: snapshot.owner_user_id.clone(), profile_id: snapshot.profile_id.clone(), entity_id: snapshot.entity_id.clone(), asset_kind: snapshot.asset_kind.clone(), created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), } } pub(crate) fn emit_asset_object_confirmed_event( ctx: &ReducerContext, snapshot: &AssetObjectUpsertSnapshot, ) { let event = AssetObjectConfirmedEvent { asset_object_id: snapshot.asset_object_id.clone(), asset_kind: snapshot.asset_kind.clone(), owner_user_id: snapshot.owner_user_id.clone(), profile_id: snapshot.profile_id.clone(), entity_id: snapshot.entity_id.clone(), occurred_at_micros: snapshot.updated_at_micros, }; ctx.db.asset_event().insert(AssetEvent { event_id: format!( "assetevt_{}_{}_confirmed", event.asset_object_id, event.occurred_at_micros ), asset_object_id: event.asset_object_id, binding_id: None, event_kind: AssetEventKind::ObjectConfirmed, asset_kind: event.asset_kind, owner_user_id: event.owner_user_id, profile_id: event.profile_id, entity_kind: None, entity_id: event.entity_id, slot: None, occurred_at: Timestamp::from_micros_since_unix_epoch(event.occurred_at_micros), }); }