#[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, } #[spacetimedb::table( accessor = asset_entity_binding, index(accessor = by_entity_slot, btree(columns = [entity_kind, entity_id, slot])), index(accessor = by_asset_object_id, btree(columns = [asset_object_id])) )] pub struct AssetEntityBinding { #[primary_key] binding_id: String, asset_object_id: String, entity_kind: String, entity_id: String, slot: String, asset_kind: String, owner_user_id: Option, profile_id: Option, 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), }, } } // reducer 负责把已确认对象绑定到实体槽位,强业务资产表稳定前先用通用绑定表承接关系。 #[spacetimedb::reducer] pub fn bind_asset_object_to_entity( ctx: &ReducerContext, input: AssetEntityBindingInput, ) -> Result<(), String> { upsert_asset_entity_binding(ctx, input).map(|_| ()) } // procedure 面向 Axum 同步绑定接口,返回最终绑定快照,避免 HTTP 层读取 private table。 #[spacetimedb::procedure] pub fn bind_asset_object_to_entity_and_return( ctx: &mut ProcedureContext, input: AssetEntityBindingInput, ) -> AssetEntityBindingProcedureResult { match ctx.try_with_tx(|tx| upsert_asset_entity_binding(tx, input.clone())) { Ok(record) => AssetEntityBindingProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => AssetEntityBindingProcedureResult { ok: false, record: None, error_message: Some(message), }, } } 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 updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); // 这里先保持最小可发布实现:查重语义已经冻结,后续再把实现优化回组合索引扫描。 let current = ctx .db .asset_object() .iter() .find(|row| row.bucket == input.bucket && row.object_key == input.object_key); let snapshot = match current { Some(existing) => { ctx.db .asset_object() .asset_object_id() .delete(&existing.asset_object_id); let row = AssetObject { 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: existing.created_at, updated_at, }; ctx.db.asset_object().insert(row); AssetObjectUpsertSnapshot { asset_object_id: existing.asset_object_id, bucket: input.bucket, object_key: input.object_key, access_policy: input.access_policy, content_type: input.content_type, content_length: input.content_length, content_hash: input.content_hash, version: input.version, source_job_id: input.source_job_id, owner_user_id: input.owner_user_id, profile_id: input.profile_id, entity_id: input.entity_id, asset_kind: input.asset_kind, created_at_micros: existing.created_at.to_micros_since_unix_epoch(), updated_at_micros: input.updated_at_micros, } } None => { let created_at = updated_at; let row = AssetObject { 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, updated_at, }; ctx.db.asset_object().insert(row); AssetObjectUpsertSnapshot { asset_object_id: input.asset_object_id, bucket: input.bucket, object_key: input.object_key, access_policy: input.access_policy, content_type: input.content_type, content_length: input.content_length, content_hash: input.content_hash, version: input.version, source_job_id: input.source_job_id, owner_user_id: input.owner_user_id, profile_id: input.profile_id, entity_id: input.entity_id, asset_kind: input.asset_kind, created_at_micros: input.updated_at_micros, updated_at_micros: input.updated_at_micros, } } }; Ok(snapshot) } fn upsert_asset_entity_binding( ctx: &ReducerContext, input: AssetEntityBindingInput, ) -> Result { validate_asset_entity_binding_fields( &input.binding_id, &input.asset_object_id, &input.entity_kind, &input.entity_id, &input.slot, &input.asset_kind, ) .map_err(|error| error.to_string())?; if ctx .db .asset_object() .asset_object_id() .find(&input.asset_object_id) .is_none() { return Err("asset_entity_binding.asset_object_id 对应的 asset_object 不存在".to_string()); } let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); // 首版绑定按 entity_kind + entity_id + slot 幂等定位,后续访问量明确后再改为组合索引扫描。 let current = ctx.db.asset_entity_binding().iter().find(|row| { row.entity_kind == input.entity_kind && row.entity_id == input.entity_id && row.slot == input.slot }); let snapshot = match current { Some(existing) => { ctx.db .asset_entity_binding() .binding_id() .delete(&existing.binding_id); let row = AssetEntityBinding { binding_id: existing.binding_id.clone(), asset_object_id: input.asset_object_id.clone(), entity_kind: input.entity_kind.clone(), entity_id: input.entity_id.clone(), slot: input.slot.clone(), asset_kind: input.asset_kind.clone(), owner_user_id: input.owner_user_id.clone(), profile_id: input.profile_id.clone(), created_at: existing.created_at, updated_at, }; ctx.db.asset_entity_binding().insert(row); AssetEntityBindingSnapshot { binding_id: existing.binding_id, asset_object_id: input.asset_object_id, entity_kind: input.entity_kind, entity_id: input.entity_id, slot: input.slot, asset_kind: input.asset_kind, owner_user_id: input.owner_user_id, profile_id: input.profile_id, created_at_micros: existing.created_at.to_micros_since_unix_epoch(), updated_at_micros: input.updated_at_micros, } } None => { let created_at = updated_at; let row = AssetEntityBinding { binding_id: input.binding_id.clone(), asset_object_id: input.asset_object_id.clone(), entity_kind: input.entity_kind.clone(), entity_id: input.entity_id.clone(), slot: input.slot.clone(), asset_kind: input.asset_kind.clone(), owner_user_id: input.owner_user_id.clone(), profile_id: input.profile_id.clone(), created_at, updated_at, }; ctx.db.asset_entity_binding().insert(row); AssetEntityBindingSnapshot { binding_id: input.binding_id, asset_object_id: input.asset_object_id, entity_kind: input.entity_kind, entity_id: input.entity_id, slot: input.slot, asset_kind: input.asset_kind, owner_user_id: input.owner_user_id, profile_id: input.profile_id, created_at_micros: input.updated_at_micros, updated_at_micros: input.updated_at_micros, } } }; Ok(snapshot) }