Files
Genarrative/server-rs/crates/module-assets/src/domain.rs
T
kdletters 2ac57463c0 修复公开作品私有资产读取
新增公开可见作品的精确资产授权视图并覆盖十类玩法

改用受限 procedure 权威点查资产对象并在失败时关闭访问

移除资产对象全表订阅并统一 ACL 与编辑器引用查询

生成 SpacetimeDB 绑定并补齐回归测试、架构文档和项目记忆
2026-07-13 18:35:26 +08:00

310 lines
9.9 KiB
Rust

//! 资产领域模型。
//!
//! 本层只保留资产对象、实体绑定、访问策略、版本和业务归属等纯领域事实。
//! OSS 对象探测、HTTP DTO 映射和 SpacetimeDB row 写入都属于外层 adapter。
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,
}
impl AssetObjectAccessPolicy {
pub fn as_str(&self) -> &'static str {
match self {
Self::Private => "private",
Self::PublicRead => "public_read",
}
}
}
/// SpacetimeDB 写入前的资产对象快照。
#[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 AssetHistoryEntrySnapshot {
pub asset_object_id: String,
pub asset_kind: String,
pub image_src: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<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 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,
}
/// 面向 API 与前端展示的资产对象记录。
#[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 PublicAssetReadGrant {
pub owner_user_id: String,
pub asset_object_id: Option<String>,
pub object_key: Option<String>,
}
/// 为公开资产授权生成可由订阅缓存点查的 owner-scoped 主键。
pub fn public_asset_read_grant_id(grant: &PublicAssetReadGrant) -> Option<String> {
let owner_user_id = grant.owner_user_id.trim();
if owner_user_id.is_empty() {
return None;
}
let owner_prefix = format!("owner:{}:{owner_user_id}", owner_user_id.len());
if let Some(asset_object_id) = grant
.asset_object_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(format!("{owner_prefix}|asset-object-id:{asset_object_id}"));
}
grant
.object_key
.as_deref()
.map(str::trim)
.map(|value| value.trim_start_matches('/'))
.filter(|value| !value.is_empty())
.map(|object_key| format!("{owner_prefix}|object-key:{object_key}"))
}
/// 判断资产对象是否命中同 owner 公开作品派生出的精确授权。
pub fn asset_object_matches_public_read_grant(
asset_object: &AssetObjectRecord,
grant: &PublicAssetReadGrant,
) -> bool {
let Some(asset_owner_user_id) = asset_object
.owner_user_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return false;
};
let grant_owner_user_id = grant.owner_user_id.trim();
if grant_owner_user_id.is_empty() || asset_owner_user_id != grant_owner_user_id {
return false;
}
grant
.asset_object_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some_and(|value| value == asset_object.asset_object_id.trim())
|| grant
.object_key
.as_deref()
.map(str::trim)
.map(|value| value.trim_start_matches('/'))
.filter(|value| !value.is_empty())
.is_some_and(|value| value == asset_object.object_key.trim().trim_start_matches('/'))
}
/// 面向 API 与前端展示的资产历史记录。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetHistoryEntryRecord {
pub asset_object_id: String,
pub asset_kind: String,
pub image_src: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// 面向 API 与前端展示的实体绑定记录。
#[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,
}
#[cfg(test)]
mod tests {
use super::*;
fn asset_object(owner_user_id: Option<&str>) -> AssetObjectRecord {
AssetObjectRecord {
asset_object_id: "assetobj_public_reference".to_string(),
bucket: "genarrative-assets".to_string(),
object_key: "generated-puzzle-assets/public-reference.png".to_string(),
access_policy: AssetObjectAccessPolicy::Private,
content_type: Some("image/png".to_string()),
content_length: 1,
content_hash: None,
version: 1,
source_job_id: None,
owner_user_id: owner_user_id.map(ToOwned::to_owned),
profile_id: None,
entity_id: Some("puzzle-session-001".to_string()),
asset_kind: "puzzle_cover_image".to_string(),
created_at: "2026-07-13T00:00:00Z".to_string(),
updated_at: "2026-07-13T00:00:00Z".to_string(),
}
}
fn grant(
owner_user_id: &str,
asset_object_id: Option<&str>,
object_key: Option<&str>,
) -> PublicAssetReadGrant {
PublicAssetReadGrant {
owner_user_id: owner_user_id.to_string(),
asset_object_id: asset_object_id.map(ToOwned::to_owned),
object_key: object_key.map(ToOwned::to_owned),
}
}
#[test]
fn public_work_grant_authorizes_exact_historical_object_key() {
let asset = asset_object(Some("user-owner"));
assert!(asset_object_matches_public_read_grant(
&asset,
&grant(
"user-owner",
None,
Some("generated-puzzle-assets/public-reference.png")
)
));
}
#[test]
fn public_work_grant_authorizes_exact_asset_object_id() {
let asset = asset_object(Some("user-owner"));
assert!(asset_object_matches_public_read_grant(
&asset,
&grant("user-owner", Some("assetobj_public_reference"), None)
));
}
#[test]
fn public_work_grant_rejects_cross_owner_and_unrelated_assets() {
let asset = asset_object(Some("user-owner"));
assert!(!asset_object_matches_public_read_grant(
&asset,
&grant("user-other", Some("assetobj_public_reference"), None)
));
assert!(!asset_object_matches_public_read_grant(
&asset,
&grant("user-owner", Some("assetobj_other"), None)
));
assert!(!asset_object_matches_public_read_grant(
&asset_object(None),
&grant("user-owner", Some("assetobj_public_reference"), None)
));
}
#[test]
fn public_work_grant_id_is_owner_scoped_and_normalizes_object_key() {
assert_eq!(
public_asset_read_grant_id(&grant(
"user-owner",
Some("assetobj_public_reference"),
None
)),
Some("owner:10:user-owner|asset-object-id:assetobj_public_reference".to_string())
);
assert_eq!(
public_asset_read_grant_id(&grant(
"user-owner",
None,
Some("/generated-puzzle-assets/public-reference.png")
)),
Some(
"owner:10:user-owner|object-key:generated-puzzle-assets/public-reference.png"
.to_string()
)
);
assert_ne!(
public_asset_read_grant_id(&grant(
"user-owner",
Some("assetobj_public_reference"),
None
)),
public_asset_read_grant_id(&grant(
"user-other",
Some("assetobj_public_reference"),
None
))
);
}
}