重写
This commit is contained in:
16
server-rs/crates/module-assets/Cargo.toml
Normal file
16
server-rs/crates/module-assets/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "module-assets"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["server-service"]
|
||||
server-service = ["dep:platform-oss", "dep:reqwest"]
|
||||
spacetime-types = ["dep:spacetimedb"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"], optional = true }
|
||||
spacetimedb = { workspace = true, optional = true }
|
||||
platform-oss = { path = "../platform-oss", optional = true }
|
||||
@@ -14,10 +14,29 @@
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前提交尚未进入完整资产状态建模,但已完成与本模块直接相关的前置基础设施:
|
||||
当前提交尚未进入完整资产状态建模,但已完成与本模块直接相关的前置基础设施与首版 schema 骨架:
|
||||
|
||||
1. `api-server` 已具备 `POST /api/assets/direct-upload-tickets`
|
||||
2. `platform-oss` 已具备旧 `/generated-*` 前缀兼容的 `PostObject` 签名能力
|
||||
3. 资产对象引用口径已冻结为 `bucket + object_key` 双列
|
||||
4. `module-assets` 已落地:
|
||||
- `AssetObjectAccessPolicy`
|
||||
- `asset_object` 字段校验 helper
|
||||
- `assetobj_` ID 前缀与初始版本常量
|
||||
- `asset_entity_binding` 输入、快照、返回记录与字段校验 helper
|
||||
- `assetbind_` ID 前缀
|
||||
|
||||
当前 `asset_object` 表的字段、索引与可编码约束见:
|
||||
|
||||
1. [../../../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md)
|
||||
2. [../../../docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](../../../docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md)
|
||||
3. [../../../docs/technical/ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](../../../docs/technical/ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md)
|
||||
|
||||
当前还已补齐:
|
||||
|
||||
1. `AssetObjectService`
|
||||
2. 私有 bucket `HEAD Object` 后的对象确认写入
|
||||
3. 当前阶段的进程内 `asset_object` 去重存储
|
||||
|
||||
后续与本 package 直接相关的任务包括:
|
||||
|
||||
@@ -31,3 +50,4 @@
|
||||
1. `module-assets` 负责资产任务状态、对象引用关系与模块级编排,不把二进制对象本身放回本地持久化目录真相中。
|
||||
2. OSS 上传、签名、对象读写等副作用通过平台适配完成,状态最终回写到 `apps/spacetime-module` 聚合的状态模型中。
|
||||
3. 前端兼容接口由 `apps/api-server` 暴露,但资产任务状态与对象绑定关系不能再次散落到本地文件判断逻辑里。
|
||||
4. 后续 `SpacetimeDB` 中的对象引用统一按 `bucket + object_key` 两列建模,不存完整 URL 作为真相字段。
|
||||
|
||||
515
server-rs/crates/module-assets/src/asset_object_core.rs
Normal file
515
server-rs/crates/module-assets/src/asset_object_core.rs
Normal file
@@ -0,0 +1,515 @@
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
pub const ASSET_OBJECT_ID_PREFIX: &str = "assetobj_";
|
||||
pub const ASSET_BINDING_ID_PREFIX: &str = "assetbind_";
|
||||
pub const INITIAL_ASSET_OBJECT_VERSION: u32 = 1;
|
||||
|
||||
// 资产对象访问策略先冻结为枚举,避免后续在 reducer、HTTP DTO 和脚本里散落字符串字面量。
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AssetObjectAccessPolicy {
|
||||
Private,
|
||||
PublicRead,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AssetObjectFieldError {
|
||||
MissingBucket,
|
||||
MissingObjectKey,
|
||||
MissingAssetKind,
|
||||
MissingAssetObjectId,
|
||||
MissingBindingId,
|
||||
MissingEntityKind,
|
||||
MissingEntityId,
|
||||
MissingSlot,
|
||||
InvalidVersion,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ConfirmAssetObjectInput {
|
||||
pub bucket: Option<String>,
|
||||
pub object_key: String,
|
||||
pub content_type: Option<String>,
|
||||
pub content_length: Option<u64>,
|
||||
pub content_hash: Option<String>,
|
||||
pub asset_kind: String,
|
||||
pub access_policy: Option<AssetObjectAccessPolicy>,
|
||||
pub source_job_id: Option<String>,
|
||||
pub owner_user_id: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub entity_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AssetObjectProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<AssetObjectUpsertSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AssetEntityBindingProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<AssetEntityBindingSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AssetObjectUpsertInput {
|
||||
pub asset_object_id: String,
|
||||
pub bucket: String,
|
||||
pub object_key: String,
|
||||
pub access_policy: AssetObjectAccessPolicy,
|
||||
pub content_type: Option<String>,
|
||||
pub content_length: u64,
|
||||
pub content_hash: Option<String>,
|
||||
pub version: u32,
|
||||
pub source_job_id: Option<String>,
|
||||
pub owner_user_id: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub entity_id: Option<String>,
|
||||
pub asset_kind: String,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AssetObjectUpsertSnapshot {
|
||||
pub asset_object_id: String,
|
||||
pub bucket: String,
|
||||
pub object_key: String,
|
||||
pub access_policy: AssetObjectAccessPolicy,
|
||||
pub content_type: Option<String>,
|
||||
pub content_length: u64,
|
||||
pub content_hash: Option<String>,
|
||||
pub version: u32,
|
||||
pub source_job_id: Option<String>,
|
||||
pub owner_user_id: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub entity_id: Option<String>,
|
||||
pub asset_kind: String,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AssetEntityBindingInput {
|
||||
pub binding_id: String,
|
||||
pub asset_object_id: String,
|
||||
pub entity_kind: String,
|
||||
pub entity_id: String,
|
||||
pub slot: String,
|
||||
pub asset_kind: String,
|
||||
pub owner_user_id: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AssetEntityBindingSnapshot {
|
||||
pub binding_id: String,
|
||||
pub asset_object_id: String,
|
||||
pub entity_kind: String,
|
||||
pub entity_id: String,
|
||||
pub slot: String,
|
||||
pub asset_kind: String,
|
||||
pub owner_user_id: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AssetObjectRecord {
|
||||
pub asset_object_id: String,
|
||||
pub bucket: String,
|
||||
pub object_key: String,
|
||||
pub access_policy: AssetObjectAccessPolicy,
|
||||
pub content_type: Option<String>,
|
||||
pub content_length: u64,
|
||||
pub content_hash: Option<String>,
|
||||
pub version: u32,
|
||||
pub source_job_id: Option<String>,
|
||||
pub owner_user_id: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub entity_id: Option<String>,
|
||||
pub asset_kind: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ConfirmAssetObjectResult {
|
||||
pub record: AssetObjectRecord,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AssetEntityBindingRecord {
|
||||
pub binding_id: String,
|
||||
pub asset_object_id: String,
|
||||
pub entity_kind: String,
|
||||
pub entity_id: String,
|
||||
pub slot: String,
|
||||
pub asset_kind: String,
|
||||
pub owner_user_id: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl AssetObjectAccessPolicy {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Private => "private",
|
||||
Self::PublicRead => "public_read",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bucket 与 object_key 是正式真相字段,因此这里只做字段校验,不回退成单字符串路径字段。
|
||||
pub fn validate_asset_object_fields(
|
||||
bucket: &str,
|
||||
object_key: &str,
|
||||
asset_kind: &str,
|
||||
version: u32,
|
||||
) -> Result<(), AssetObjectFieldError> {
|
||||
if bucket.trim().is_empty() {
|
||||
return Err(AssetObjectFieldError::MissingBucket);
|
||||
}
|
||||
|
||||
if object_key.trim().trim_start_matches('/').is_empty() {
|
||||
return Err(AssetObjectFieldError::MissingObjectKey);
|
||||
}
|
||||
|
||||
if asset_kind.trim().is_empty() {
|
||||
return Err(AssetObjectFieldError::MissingAssetKind);
|
||||
}
|
||||
|
||||
if version == 0 {
|
||||
return Err(AssetObjectFieldError::InvalidVersion);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 业务绑定首版只校验稳定定位字段;授权关系后续由 SpacetimeDB 身份透传接入后再收紧。
|
||||
pub fn validate_asset_entity_binding_fields(
|
||||
binding_id: &str,
|
||||
asset_object_id: &str,
|
||||
entity_kind: &str,
|
||||
entity_id: &str,
|
||||
slot: &str,
|
||||
asset_kind: &str,
|
||||
) -> Result<(), AssetObjectFieldError> {
|
||||
if binding_id.trim().is_empty() {
|
||||
return Err(AssetObjectFieldError::MissingBindingId);
|
||||
}
|
||||
|
||||
if asset_object_id.trim().is_empty() {
|
||||
return Err(AssetObjectFieldError::MissingAssetObjectId);
|
||||
}
|
||||
|
||||
if entity_kind.trim().is_empty() {
|
||||
return Err(AssetObjectFieldError::MissingEntityKind);
|
||||
}
|
||||
|
||||
if entity_id.trim().is_empty() {
|
||||
return Err(AssetObjectFieldError::MissingEntityId);
|
||||
}
|
||||
|
||||
if slot.trim().is_empty() {
|
||||
return Err(AssetObjectFieldError::MissingSlot);
|
||||
}
|
||||
|
||||
if asset_kind.trim().is_empty() {
|
||||
return Err(AssetObjectFieldError::MissingAssetKind);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_asset_object_upsert_input(
|
||||
asset_object_id: String,
|
||||
bucket: String,
|
||||
object_key: String,
|
||||
access_policy: AssetObjectAccessPolicy,
|
||||
content_type: Option<String>,
|
||||
content_length: u64,
|
||||
content_hash: Option<String>,
|
||||
asset_kind: String,
|
||||
source_job_id: Option<String>,
|
||||
owner_user_id: Option<String>,
|
||||
profile_id: Option<String>,
|
||||
entity_id: Option<String>,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<AssetObjectUpsertInput, AssetObjectFieldError> {
|
||||
if asset_object_id.trim().is_empty() {
|
||||
return Err(AssetObjectFieldError::MissingAssetObjectId);
|
||||
}
|
||||
|
||||
validate_asset_object_fields(
|
||||
&bucket,
|
||||
&object_key,
|
||||
&asset_kind,
|
||||
INITIAL_ASSET_OBJECT_VERSION,
|
||||
)?;
|
||||
|
||||
Ok(AssetObjectUpsertInput {
|
||||
asset_object_id: asset_object_id.trim().to_string(),
|
||||
bucket: bucket.trim().to_string(),
|
||||
object_key: object_key.trim().trim_start_matches('/').to_string(),
|
||||
access_policy,
|
||||
content_type: normalize_optional_value(content_type),
|
||||
content_length,
|
||||
content_hash: normalize_optional_value(content_hash),
|
||||
version: INITIAL_ASSET_OBJECT_VERSION,
|
||||
source_job_id: normalize_optional_value(source_job_id),
|
||||
owner_user_id: normalize_optional_value(owner_user_id),
|
||||
profile_id: normalize_optional_value(profile_id),
|
||||
entity_id: normalize_optional_value(entity_id),
|
||||
asset_kind: asset_kind.trim().to_string(),
|
||||
updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_asset_object_record(snapshot: AssetObjectUpsertSnapshot) -> AssetObjectRecord {
|
||||
AssetObjectRecord {
|
||||
asset_object_id: snapshot.asset_object_id,
|
||||
bucket: snapshot.bucket,
|
||||
object_key: snapshot.object_key,
|
||||
access_policy: snapshot.access_policy,
|
||||
content_type: snapshot.content_type,
|
||||
content_length: snapshot.content_length,
|
||||
content_hash: snapshot.content_hash,
|
||||
version: snapshot.version,
|
||||
source_job_id: snapshot.source_job_id,
|
||||
owner_user_id: snapshot.owner_user_id,
|
||||
profile_id: snapshot.profile_id,
|
||||
entity_id: snapshot.entity_id,
|
||||
asset_kind: snapshot.asset_kind,
|
||||
created_at: format_timestamp_micros(snapshot.created_at_micros),
|
||||
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_asset_entity_binding_input(
|
||||
binding_id: String,
|
||||
asset_object_id: String,
|
||||
entity_kind: String,
|
||||
entity_id: String,
|
||||
slot: String,
|
||||
asset_kind: String,
|
||||
owner_user_id: Option<String>,
|
||||
profile_id: Option<String>,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<AssetEntityBindingInput, AssetObjectFieldError> {
|
||||
validate_asset_entity_binding_fields(
|
||||
&binding_id,
|
||||
&asset_object_id,
|
||||
&entity_kind,
|
||||
&entity_id,
|
||||
&slot,
|
||||
&asset_kind,
|
||||
)?;
|
||||
|
||||
Ok(AssetEntityBindingInput {
|
||||
binding_id: binding_id.trim().to_string(),
|
||||
asset_object_id: asset_object_id.trim().to_string(),
|
||||
entity_kind: entity_kind.trim().to_string(),
|
||||
entity_id: entity_id.trim().to_string(),
|
||||
slot: slot.trim().to_string(),
|
||||
asset_kind: asset_kind.trim().to_string(),
|
||||
owner_user_id: normalize_optional_value(owner_user_id),
|
||||
profile_id: normalize_optional_value(profile_id),
|
||||
updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_asset_entity_binding_record(
|
||||
snapshot: AssetEntityBindingSnapshot,
|
||||
) -> AssetEntityBindingRecord {
|
||||
AssetEntityBindingRecord {
|
||||
binding_id: snapshot.binding_id,
|
||||
asset_object_id: snapshot.asset_object_id,
|
||||
entity_kind: snapshot.entity_kind,
|
||||
entity_id: snapshot.entity_id,
|
||||
slot: snapshot.slot,
|
||||
asset_kind: snapshot.asset_kind,
|
||||
owner_user_id: snapshot.owner_user_id,
|
||||
profile_id: snapshot.profile_id,
|
||||
created_at: format_timestamp_micros(snapshot.created_at_micros),
|
||||
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_asset_object_id(seed_micros: i64) -> String {
|
||||
format!("{}{:x}", ASSET_OBJECT_ID_PREFIX, seed_micros)
|
||||
}
|
||||
|
||||
pub fn generate_asset_binding_id(seed_micros: i64) -> String {
|
||||
format!("{}{:x}", ASSET_BINDING_ID_PREFIX, seed_micros)
|
||||
}
|
||||
|
||||
pub fn normalize_optional_value(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty() { None } else { Some(value) }
|
||||
})
|
||||
}
|
||||
|
||||
fn format_timestamp_micros(micros: i64) -> String {
|
||||
let seconds = micros.div_euclid(1_000_000);
|
||||
let subsec_micros = micros.rem_euclid(1_000_000);
|
||||
format!("{seconds}.{subsec_micros:06}Z")
|
||||
}
|
||||
|
||||
impl fmt::Display for AssetObjectFieldError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingBucket => f.write_str("asset_object.bucket 不能为空"),
|
||||
Self::MissingObjectKey => f.write_str("asset_object.object_key 不能为空"),
|
||||
Self::MissingAssetKind => f.write_str("asset_object.asset_kind 不能为空"),
|
||||
Self::MissingAssetObjectId => f.write_str("asset_object.asset_object_id 不能为空"),
|
||||
Self::MissingBindingId => f.write_str("asset_entity_binding.binding_id 不能为空"),
|
||||
Self::MissingEntityKind => f.write_str("asset_entity_binding.entity_kind 不能为空"),
|
||||
Self::MissingEntityId => f.write_str("asset_entity_binding.entity_id 不能为空"),
|
||||
Self::MissingSlot => f.write_str("asset_entity_binding.slot 不能为空"),
|
||||
Self::InvalidVersion => f.write_str("asset_object.version 必须大于 0"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for AssetObjectFieldError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validate_asset_object_fields_accepts_minimal_private_object_contract() {
|
||||
let result = validate_asset_object_fields(
|
||||
"xushi-dev",
|
||||
"generated-characters/hero_001/visual/master.png",
|
||||
"character_visual",
|
||||
INITIAL_ASSET_OBJECT_VERSION,
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_asset_object_fields_rejects_missing_storage_truth() {
|
||||
let error = validate_asset_object_fields("", " ", "character_visual", 0)
|
||||
.expect_err("missing bucket/object_key/version should fail");
|
||||
|
||||
assert_eq!(error, AssetObjectFieldError::MissingBucket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_policy_string_matches_private_bucket_first_contract() {
|
||||
assert_eq!(AssetObjectAccessPolicy::Private.as_str(), "private");
|
||||
assert_eq!(AssetObjectAccessPolicy::PublicRead.as_str(), "public_read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_asset_object_upsert_input_normalizes_optional_fields() {
|
||||
let input = build_asset_object_upsert_input(
|
||||
"assetobj_001".to_string(),
|
||||
"xushi-dev".to_string(),
|
||||
"/generated-characters/hero/master.png".to_string(),
|
||||
AssetObjectAccessPolicy::Private,
|
||||
Some(" image/png ".to_string()),
|
||||
128,
|
||||
Some(" ".to_string()),
|
||||
" character_visual ".to_string(),
|
||||
Some(" job-001 ".to_string()),
|
||||
None,
|
||||
Some(" profile_001 ".to_string()),
|
||||
None,
|
||||
1_713_686_400_000_000,
|
||||
)
|
||||
.expect("input should build");
|
||||
|
||||
assert_eq!(input.object_key, "generated-characters/hero/master.png");
|
||||
assert_eq!(input.content_type.as_deref(), Some("image/png"));
|
||||
assert_eq!(input.content_hash, None);
|
||||
assert_eq!(input.asset_kind, "character_visual");
|
||||
assert_eq!(input.source_job_id.as_deref(), Some("job-001"));
|
||||
assert_eq!(input.profile_id.as_deref(), Some("profile_001"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_asset_object_record_formats_timestamp_micros_stably() {
|
||||
let record = build_asset_object_record(AssetObjectUpsertSnapshot {
|
||||
asset_object_id: "assetobj_001".to_string(),
|
||||
bucket: "xushi-dev".to_string(),
|
||||
object_key: "generated-characters/hero/master.png".to_string(),
|
||||
access_policy: AssetObjectAccessPolicy::Private,
|
||||
content_type: Some("image/png".to_string()),
|
||||
content_length: 128,
|
||||
content_hash: None,
|
||||
version: INITIAL_ASSET_OBJECT_VERSION,
|
||||
source_job_id: None,
|
||||
owner_user_id: None,
|
||||
profile_id: None,
|
||||
entity_id: None,
|
||||
asset_kind: "character_visual".to_string(),
|
||||
created_at_micros: 1_713_686_400_000_000,
|
||||
updated_at_micros: 1_713_686_401_234_567,
|
||||
});
|
||||
|
||||
assert_eq!(record.created_at, "1713686400.000000Z");
|
||||
assert_eq!(record.updated_at, "1713686401.234567Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_asset_entity_binding_input_normalizes_binding_fields() {
|
||||
let input = build_asset_entity_binding_input(
|
||||
" assetbind_001 ".to_string(),
|
||||
" assetobj_001 ".to_string(),
|
||||
" character ".to_string(),
|
||||
" hero_001 ".to_string(),
|
||||
" primary_visual ".to_string(),
|
||||
" character_visual ".to_string(),
|
||||
Some(" user_001 ".to_string()),
|
||||
Some(" ".to_string()),
|
||||
1_713_686_400_000_000,
|
||||
)
|
||||
.expect("binding input should build");
|
||||
|
||||
assert_eq!(input.binding_id, "assetbind_001");
|
||||
assert_eq!(input.asset_object_id, "assetobj_001");
|
||||
assert_eq!(input.entity_kind, "character");
|
||||
assert_eq!(input.entity_id, "hero_001");
|
||||
assert_eq!(input.slot, "primary_visual");
|
||||
assert_eq!(input.asset_kind, "character_visual");
|
||||
assert_eq!(input.owner_user_id.as_deref(), Some("user_001"));
|
||||
assert_eq!(input.profile_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_asset_entity_binding_fields_rejects_missing_slot() {
|
||||
let error = validate_asset_entity_binding_fields(
|
||||
"assetbind_001",
|
||||
"assetobj_001",
|
||||
"character",
|
||||
"hero_001",
|
||||
" ",
|
||||
"character_visual",
|
||||
)
|
||||
.expect_err("missing slot should fail");
|
||||
|
||||
assert_eq!(error, AssetObjectFieldError::MissingSlot);
|
||||
}
|
||||
}
|
||||
254
server-rs/crates/module-assets/src/asset_object_service.rs
Normal file
254
server-rs/crates/module-assets/src/asset_object_service.rs
Normal file
@@ -0,0 +1,254 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
error::Error,
|
||||
fmt,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use platform_oss::{OssClient, OssError, OssHeadObjectRequest};
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::{
|
||||
AssetObjectAccessPolicy, AssetObjectFieldError, AssetObjectRecord, AssetObjectUpsertSnapshot,
|
||||
ConfirmAssetObjectInput, ConfirmAssetObjectResult, INITIAL_ASSET_OBJECT_VERSION,
|
||||
build_asset_object_record, build_asset_object_upsert_input, generate_asset_object_id,
|
||||
normalize_optional_value, validate_asset_object_fields,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InMemoryAssetObjectStore {
|
||||
inner: Arc<Mutex<HashMap<(String, String), AssetObjectUpsertSnapshot>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AssetObjectService {
|
||||
store: InMemoryAssetObjectStore,
|
||||
http_client: Client,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum ConfirmAssetObjectError {
|
||||
BucketMismatch,
|
||||
ContentLengthMismatch,
|
||||
Field(AssetObjectFieldError),
|
||||
Oss(OssError),
|
||||
Store(String),
|
||||
}
|
||||
|
||||
impl Default for InMemoryAssetObjectStore {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InMemoryAssetObjectStore {
|
||||
fn upsert_by_location(
|
||||
&self,
|
||||
record: AssetObjectUpsertSnapshot,
|
||||
) -> Result<AssetObjectRecord, ConfirmAssetObjectError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| ConfirmAssetObjectError::Store("资产对象仓储锁已中毒".to_string()))?;
|
||||
|
||||
let key = (record.bucket.clone(), record.object_key.clone());
|
||||
let next_record = match state.get(&key) {
|
||||
Some(existing) => AssetObjectUpsertSnapshot {
|
||||
asset_object_id: existing.asset_object_id.clone(),
|
||||
created_at_micros: existing.created_at_micros,
|
||||
..record
|
||||
},
|
||||
None => record,
|
||||
};
|
||||
state.insert(key, next_record.clone());
|
||||
|
||||
Ok(build_asset_object_record(next_record))
|
||||
}
|
||||
}
|
||||
|
||||
impl AssetObjectService {
|
||||
pub fn new(store: InMemoryAssetObjectStore) -> Self {
|
||||
Self {
|
||||
store,
|
||||
http_client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn confirm_object(
|
||||
&self,
|
||||
oss_client: &OssClient,
|
||||
input: ConfirmAssetObjectInput,
|
||||
) -> Result<ConfirmAssetObjectResult, ConfirmAssetObjectError> {
|
||||
let configured_bucket = oss_client.config_bucket().to_string();
|
||||
let resolved_bucket = input
|
||||
.bucket
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(configured_bucket.as_str())
|
||||
.to_string();
|
||||
|
||||
if resolved_bucket != configured_bucket {
|
||||
return Err(ConfirmAssetObjectError::BucketMismatch);
|
||||
}
|
||||
|
||||
validate_asset_object_fields(
|
||||
&resolved_bucket,
|
||||
&input.object_key,
|
||||
&input.asset_kind,
|
||||
INITIAL_ASSET_OBJECT_VERSION,
|
||||
)
|
||||
.map_err(ConfirmAssetObjectError::Field)?;
|
||||
|
||||
let head = oss_client
|
||||
.head_object(
|
||||
&self.http_client,
|
||||
OssHeadObjectRequest {
|
||||
object_key: input.object_key.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ConfirmAssetObjectError::Oss)?;
|
||||
|
||||
if let Some(expected_length) = input.content_length
|
||||
&& expected_length != head.content_length
|
||||
{
|
||||
return Err(ConfirmAssetObjectError::ContentLengthMismatch);
|
||||
}
|
||||
|
||||
// 进程内 store 仅保留给无 SpacetimeDB 配置场景的最小 fallback,因此这里继续使用稳定微秒值表达时间。
|
||||
let now_micros = chrono_like_utc_now_micros();
|
||||
let upsert_input = build_asset_object_upsert_input(
|
||||
generate_asset_object_id(now_micros),
|
||||
resolved_bucket,
|
||||
head.object_key,
|
||||
input
|
||||
.access_policy
|
||||
.unwrap_or(AssetObjectAccessPolicy::Private),
|
||||
head.content_type
|
||||
.or_else(|| normalize_optional_value(input.content_type)),
|
||||
head.content_length,
|
||||
normalize_optional_value(input.content_hash),
|
||||
input.asset_kind,
|
||||
input.source_job_id,
|
||||
input.owner_user_id,
|
||||
input.profile_id,
|
||||
input.entity_id,
|
||||
now_micros,
|
||||
)
|
||||
.map_err(ConfirmAssetObjectError::Field)?;
|
||||
|
||||
let record = self.store.upsert_by_location(AssetObjectUpsertSnapshot {
|
||||
asset_object_id: upsert_input.asset_object_id,
|
||||
bucket: upsert_input.bucket,
|
||||
object_key: upsert_input.object_key,
|
||||
access_policy: upsert_input.access_policy,
|
||||
content_type: upsert_input.content_type,
|
||||
content_length: upsert_input.content_length,
|
||||
content_hash: upsert_input.content_hash,
|
||||
version: upsert_input.version,
|
||||
source_job_id: upsert_input.source_job_id,
|
||||
owner_user_id: upsert_input.owner_user_id,
|
||||
profile_id: upsert_input.profile_id,
|
||||
entity_id: upsert_input.entity_id,
|
||||
asset_kind: upsert_input.asset_kind,
|
||||
created_at_micros: now_micros,
|
||||
updated_at_micros: now_micros,
|
||||
})?;
|
||||
|
||||
Ok(ConfirmAssetObjectResult { record })
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ConfirmAssetObjectError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::BucketMismatch => f.write_str("bucket 与当前服务端 OSS bucket 不一致"),
|
||||
Self::ContentLengthMismatch => {
|
||||
f.write_str("客户端声明的 contentLength 与 OSS 实际对象大小不一致")
|
||||
}
|
||||
Self::Field(error) => write!(f, "{error}"),
|
||||
Self::Oss(error) => write!(f, "{error}"),
|
||||
Self::Store(message) => f.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for ConfirmAssetObjectError {}
|
||||
|
||||
impl From<AssetObjectFieldError> for ConfirmAssetObjectError {
|
||||
fn from(value: AssetObjectFieldError) -> Self {
|
||||
Self::Field(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OssError> for ConfirmAssetObjectError {
|
||||
fn from(value: OssError) -> Self {
|
||||
Self::Oss(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn chrono_like_utc_now_micros() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should be after unix epoch");
|
||||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn in_memory_store_upsert_keeps_same_primary_id_for_same_bucket_and_object_key() {
|
||||
let store = InMemoryAssetObjectStore::default();
|
||||
let first = store
|
||||
.upsert_by_location(AssetObjectUpsertSnapshot {
|
||||
asset_object_id: "assetobj_first".to_string(),
|
||||
bucket: "xushi-dev".to_string(),
|
||||
object_key: "generated-characters/hero/master.png".to_string(),
|
||||
access_policy: AssetObjectAccessPolicy::Private,
|
||||
content_type: Some("image/png".to_string()),
|
||||
content_length: 100,
|
||||
content_hash: None,
|
||||
version: INITIAL_ASSET_OBJECT_VERSION,
|
||||
source_job_id: None,
|
||||
owner_user_id: None,
|
||||
profile_id: None,
|
||||
entity_id: None,
|
||||
asset_kind: "character_visual".to_string(),
|
||||
created_at_micros: 1_000_000,
|
||||
updated_at_micros: 1_000_000,
|
||||
})
|
||||
.expect("first upsert should succeed");
|
||||
|
||||
let second = store
|
||||
.upsert_by_location(AssetObjectUpsertSnapshot {
|
||||
asset_object_id: "assetobj_second".to_string(),
|
||||
bucket: "xushi-dev".to_string(),
|
||||
object_key: "generated-characters/hero/master.png".to_string(),
|
||||
access_policy: AssetObjectAccessPolicy::Private,
|
||||
content_type: Some("image/png".to_string()),
|
||||
content_length: 100,
|
||||
content_hash: None,
|
||||
version: INITIAL_ASSET_OBJECT_VERSION,
|
||||
source_job_id: None,
|
||||
owner_user_id: None,
|
||||
profile_id: None,
|
||||
entity_id: None,
|
||||
asset_kind: "character_visual".to_string(),
|
||||
created_at_micros: 2_000_000,
|
||||
updated_at_micros: 2_000_000,
|
||||
})
|
||||
.expect("second upsert should succeed");
|
||||
|
||||
assert_eq!(first.asset_object_id, "assetobj_first");
|
||||
assert_eq!(second.asset_object_id, "assetobj_first");
|
||||
assert_eq!(second.created_at, "1.000000Z");
|
||||
assert_eq!(second.updated_at, "2.000000Z");
|
||||
}
|
||||
}
|
||||
18
server-rs/crates/module-assets/src/lib.rs
Normal file
18
server-rs/crates/module-assets/src/lib.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
mod asset_object_core;
|
||||
#[cfg(feature = "server-service")]
|
||||
mod asset_object_service;
|
||||
|
||||
pub use asset_object_core::{
|
||||
ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingInput,
|
||||
AssetEntityBindingProcedureResult, AssetEntityBindingRecord, AssetEntityBindingSnapshot,
|
||||
AssetObjectAccessPolicy, AssetObjectFieldError, AssetObjectProcedureResult, AssetObjectRecord,
|
||||
AssetObjectUpsertInput, AssetObjectUpsertSnapshot, ConfirmAssetObjectInput,
|
||||
ConfirmAssetObjectResult, INITIAL_ASSET_OBJECT_VERSION, build_asset_entity_binding_input,
|
||||
build_asset_entity_binding_record, build_asset_object_record, build_asset_object_upsert_input,
|
||||
generate_asset_binding_id, generate_asset_object_id, normalize_optional_value,
|
||||
validate_asset_entity_binding_fields, validate_asset_object_fields,
|
||||
};
|
||||
#[cfg(feature = "server-service")]
|
||||
pub use asset_object_service::{
|
||||
AssetObjectService, ConfirmAssetObjectError, InMemoryAssetObjectStore,
|
||||
};
|
||||
Reference in New Issue
Block a user