2ac57463c0
新增公开可见作品的精确资产授权视图并覆盖十类玩法 改用受限 procedure 权威点查资产对象并在失败时关闭访问 移除资产对象全表订阅并统一 ACL 与编辑器引用查询 生成 SpacetimeDB 绑定并补齐回归测试、架构文档和项目记忆
310 lines
9.9 KiB
Rust
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
|
|
))
|
|
);
|
|
}
|
|
}
|