247 lines
8.8 KiB
Rust
247 lines
8.8 KiB
Rust
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";
|
||
|
||
#[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<String>,
|
||
content_length: u64,
|
||
content_hash: Option<String>,
|
||
version: u32,
|
||
source_job_id: Option<String>,
|
||
owner_user_id: Option<String>,
|
||
profile_id: Option<String>,
|
||
entity_id: Option<String>,
|
||
#[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<AssetObjectUpsertSnapshot, String> {
|
||
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)
|
||
}
|
||
|
||
pub(crate) fn has_asset_object(ctx: &ReducerContext, asset_object_id: &str) -> bool {
|
||
ctx.db
|
||
.asset_object()
|
||
.iter()
|
||
.any(|row| row.asset_object_id == asset_object_id)
|
||
}
|
||
|
||
fn list_asset_history(
|
||
ctx: &ReducerContext,
|
||
input: AssetHistoryListInput,
|
||
) -> Result<Vec<AssetHistoryEntrySnapshot>, 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
|
||
{
|
||
return Err(
|
||
"历史素材类型只支持 character_visual、scene_image 或 puzzle_cover_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()
|
||
.iter()
|
||
.filter(|row| row.asset_kind == asset_kind)
|
||
.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::<Vec<_>>();
|
||
|
||
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}")
|
||
}
|