From b200899883ee0b4666b9ae439a9a1de46e4a3264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 14:03:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E5=BC=95=E7=94=A8=E7=AA=84=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分别按项目资源 ID 与素材 ID 主键查询并在双表冲突时失败关闭 校验资源归属、对象存储位置与 asset object 所有权 补齐 Spacetime 客户端映射、facade、生成 bindings 与回归测试 --- .../spacetime-client/src/active/mapper.rs | 7 +- .../src/active/mapper/editor_project.rs | 44 ++++ .../spacetime-client/src/editor_project.rs | 21 ++ server-rs/crates/spacetime-client/src/lib.rs | 3 +- .../crates/spacetime-client/src/mapper.rs | 6 +- .../src/mapper/editor_project.rs | 43 ++++ .../spacetime-client/src/module_bindings.rs | 6 + .../editor_reference_procedure_result_type.rs | 21 ++ .../editor_reference_resolve_input_type.rs | 17 ++ ...e_editor_reference_and_return_procedure.rs | 59 +++++ .../src/editor_project_storage.rs | 212 ++++++++++++++++++ 11 files changed, 433 insertions(+), 6 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_reference_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_reference_resolve_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/resolve_editor_reference_and_return_procedure.rs diff --git a/server-rs/crates/spacetime-client/src/active/mapper.rs b/server-rs/crates/spacetime-client/src/active/mapper.rs index 3f3478293..3deaa5bb4 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper.rs @@ -57,7 +57,8 @@ pub use self::editor_project::{ EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, EditorProjectResourcePublicShowcaseListRecordInput, EditorProjectResourceRecord, - EditorProjectResourceShowcaseUpdateRecordInput, EditorShowcaseAssetAdminListRecordInput, + EditorProjectResourceShowcaseUpdateRecordInput, EditorReferenceRecord, + EditorReferenceResolveRecordInput, EditorShowcaseAssetAdminListRecordInput, EditorShowcaseAssetAdminReviewRecordInput, EditorShowcaseAssetDisplayUpdateRecordInput, EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput, EditorShowcaseAssetRecord, EditorShowcaseAssetRefundMarkRecordInput, @@ -108,8 +109,8 @@ pub(crate) use self::editor_project::{ map_editor_project_layout_save_v2_procedure_result, map_editor_project_list_procedure_result, map_editor_project_optional_procedure_result, map_editor_project_required_procedure_result, map_editor_project_resource_list_procedure_result, - map_editor_project_resource_procedure_result, map_editor_showcase_asset_list_procedure_result, - map_editor_showcase_asset_procedure_result, + map_editor_project_resource_procedure_result, map_editor_reference_procedure_result, + map_editor_showcase_asset_list_procedure_result, map_editor_showcase_asset_procedure_result, map_editor_showcase_campaign_config_procedure_result, map_optional_editor_asset_procedure_result, }; diff --git a/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs index 640a5946a..168df6b62 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs @@ -1,5 +1,30 @@ use super::*; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorReferenceResolveRecordInput { + pub owner_user_id: String, + pub bucket: String, + pub reference_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EditorReferenceRecord { + ProjectResource(EditorProjectResourceRecord), + Asset(EditorAssetRecord), +} + +impl From + for crate::module_bindings::EditorReferenceResolveInput +{ + fn from(input: EditorReferenceResolveRecordInput) -> Self { + Self { + owner_user_id: input.owner_user_id, + bucket: input.bucket, + reference_id: input.reference_id, + } + } +} + #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct EditorCanvasViewportRecord { pub x: f64, @@ -1061,6 +1086,25 @@ pub(crate) fn map_editor_project_resource_procedure_result( .ok_or_else(|| SpacetimeClientError::missing_snapshot("图片画布资源快照")) } +pub(crate) fn map_editor_reference_procedure_result( + result: EditorReferenceProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + match (result.resource, result.asset) { + (Some(resource), None) => map_editor_project_resource_snapshot(resource) + .map(EditorReferenceRecord::ProjectResource), + (None, Some(asset)) => map_editor_asset_snapshot(asset).map(EditorReferenceRecord::Asset), + (None, None) => Err(SpacetimeClientError::missing_snapshot( + "编辑器引用资源或素材快照", + )), + (Some(_), Some(_)) => Err(SpacetimeClientError::procedure_failed(Some( + "SpacetimeDB procedure 同时返回了编辑器资源与素材快照".to_string(), + ))), + } +} + pub(crate) fn map_editor_project_resource_list_procedure_result( result: EditorProjectResourceListProcedureResult, ) -> Result, SpacetimeClientError> { diff --git a/server-rs/crates/spacetime-client/src/editor_project.rs b/server-rs/crates/spacetime-client/src/editor_project.rs index 2cb17c09a..779075d93 100644 --- a/server-rs/crates/spacetime-client/src/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/editor_project.rs @@ -163,6 +163,27 @@ impl From } impl SpacetimeClient { + pub async fn resolve_editor_reference( + &self, + input: EditorReferenceResolveRecordInput, + ) -> Result { + let procedure_input = input.into(); + self.call_after_connect( + "resolve_editor_reference_and_return", + move |connection, sender| { + connection + .procedures() + .resolve_editor_reference_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_reference_procedure_result); + send_once(&sender, mapped); + }); + }, + ) + .await + } + pub async fn preflight_editor_pixel_art_result( &self, input: EditorPixelArtResultPreflightRecordInput, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 16f931444..9278c4b33 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -50,7 +50,8 @@ pub use mapper::{ EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, EditorProjectResourcePublicShowcaseListRecordInput, EditorProjectResourceRecord, - EditorProjectResourceShowcaseUpdateRecordInput, EditorShowcaseAssetAdminListRecordInput, + EditorProjectResourceShowcaseUpdateRecordInput, EditorReferenceRecord, + EditorReferenceResolveRecordInput, EditorShowcaseAssetAdminListRecordInput, EditorShowcaseAssetAdminReviewRecordInput, EditorShowcaseAssetDisplayUpdateRecordInput, EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput, EditorShowcaseAssetRecord, EditorShowcaseAssetRefundMarkRecordInput, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index da3ac68b9..22518763e 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -108,7 +108,8 @@ pub use self::editor_project::{ EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput, EditorShowcaseAssetRecord, EditorShowcaseAssetRefundMarkRecordInput, EditorShowcaseAssetSubmitRecordInput, EditorShowcaseCampaignConfigGetRecordInput, - EditorShowcaseCampaignConfigRecord, EditorShowcaseCampaignConfigUpsertRecordInput, + EditorReferenceRecord, EditorReferenceResolveRecordInput, EditorShowcaseCampaignConfigRecord, + EditorShowcaseCampaignConfigUpsertRecordInput, }; pub use self::external_api_key::{ ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord, @@ -260,7 +261,8 @@ pub(crate) use self::editor_project::{ map_editor_project_layout_save_v2_procedure_result, map_editor_project_list_procedure_result, map_editor_project_optional_procedure_result, map_editor_project_required_procedure_result, map_editor_project_resource_list_procedure_result, - map_editor_project_resource_procedure_result, map_editor_showcase_asset_list_procedure_result, + map_editor_project_resource_procedure_result, map_editor_reference_procedure_result, + map_editor_showcase_asset_list_procedure_result, map_editor_showcase_asset_procedure_result, map_editor_showcase_campaign_config_procedure_result, map_optional_editor_asset_procedure_result, diff --git a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs index 640a5946a..336c44049 100644 --- a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs @@ -1,5 +1,29 @@ use super::*; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorReferenceResolveRecordInput { + pub owner_user_id: String, + pub bucket: String, + pub reference_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EditorReferenceRecord { + ProjectResource(EditorProjectResourceRecord), + Asset(EditorAssetRecord), +} + +impl From for crate::module_bindings::EditorReferenceResolveInput +{ + fn from(input: EditorReferenceResolveRecordInput) -> Self { + Self { + owner_user_id: input.owner_user_id, + bucket: input.bucket, + reference_id: input.reference_id, + } + } +} + #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct EditorCanvasViewportRecord { pub x: f64, @@ -1061,6 +1085,25 @@ pub(crate) fn map_editor_project_resource_procedure_result( .ok_or_else(|| SpacetimeClientError::missing_snapshot("图片画布资源快照")) } +pub(crate) fn map_editor_reference_procedure_result( + result: EditorReferenceProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + match (result.resource, result.asset) { + (Some(resource), None) => map_editor_project_resource_snapshot(resource) + .map(EditorReferenceRecord::ProjectResource), + (None, Some(asset)) => map_editor_asset_snapshot(asset).map(EditorReferenceRecord::Asset), + (None, None) => Err(SpacetimeClientError::missing_snapshot( + "编辑器引用资源或素材快照", + )), + (Some(_), Some(_)) => Err(SpacetimeClientError::procedure_failed(Some( + "SpacetimeDB procedure 同时返回了编辑器资源与素材快照".to_string(), + ))), + } +} + pub(crate) fn map_editor_project_resource_list_procedure_result( result: EditorProjectResourceListProcedureResult, ) -> Result, SpacetimeClientError> { diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index a511b0ce5..062841cac 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -334,6 +334,8 @@ pub mod editor_project_snapshot_type; pub mod editor_project_table; pub mod editor_project_type; pub mod editor_project_viewport_snapshot_type; +pub mod editor_reference_procedure_result_type; +pub mod editor_reference_resolve_input_type; pub mod editor_showcase_asset_admin_list_input_type; pub mod editor_showcase_asset_admin_review_input_type; pub mod editor_showcase_asset_display_update_input_type; @@ -620,6 +622,7 @@ pub mod renew_external_generation_job_lease_and_return_procedure; pub mod repair_editor_asset_media_and_return_procedure; pub mod repair_editor_canvas_resources_and_return_procedure; pub mod repair_editor_project_resource_media_and_return_procedure; +pub mod resolve_editor_reference_and_return_procedure; pub mod resolve_profile_recharge_refund_manual_review_and_return_procedure; pub mod revoke_database_migration_operator_procedure; pub mod revoke_external_api_key_and_return_procedure; @@ -1169,6 +1172,8 @@ pub use editor_project_snapshot_type::EditorProjectSnapshot; pub use editor_project_table::*; pub use editor_project_type::EditorProject; pub use editor_project_viewport_snapshot_type::EditorProjectViewportSnapshot; +pub use editor_reference_procedure_result_type::EditorReferenceProcedureResult; +pub use editor_reference_resolve_input_type::EditorReferenceResolveInput; pub use editor_showcase_asset_admin_list_input_type::EditorShowcaseAssetAdminListInput; pub use editor_showcase_asset_admin_review_input_type::EditorShowcaseAssetAdminReviewInput; pub use editor_showcase_asset_display_update_input_type::EditorShowcaseAssetDisplayUpdateInput; @@ -1455,6 +1460,7 @@ pub use renew_external_generation_job_lease_and_return_procedure::renew_external pub use repair_editor_asset_media_and_return_procedure::repair_editor_asset_media_and_return; pub use repair_editor_canvas_resources_and_return_procedure::repair_editor_canvas_resources_and_return; pub use repair_editor_project_resource_media_and_return_procedure::repair_editor_project_resource_media_and_return; +pub use resolve_editor_reference_and_return_procedure::resolve_editor_reference_and_return; pub use resolve_profile_recharge_refund_manual_review_and_return_procedure::resolve_profile_recharge_refund_manual_review_and_return; pub use revoke_database_migration_operator_procedure::revoke_database_migration_operator; pub use revoke_external_api_key_and_return_procedure::revoke_external_api_key_and_return; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_reference_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_reference_procedure_result_type.rs new file mode 100644 index 000000000..c630e9ab0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_reference_procedure_result_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_asset_snapshot_type::EditorAssetSnapshot; +use super::editor_project_resource_snapshot_type::EditorProjectResourceSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorReferenceProcedureResult { + pub ok: bool, + pub resource: Option, + pub asset: Option, + pub error_message: Option, +} + +impl __sdk::InModule for EditorReferenceProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_reference_resolve_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_reference_resolve_input_type.rs new file mode 100644 index 000000000..67bfc6ccc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_reference_resolve_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorReferenceResolveInput { + pub owner_user_id: String, + pub bucket: String, + pub reference_id: String, +} + +impl __sdk::InModule for EditorReferenceResolveInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/resolve_editor_reference_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/resolve_editor_reference_and_return_procedure.rs new file mode 100644 index 000000000..e57f5cfb9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/resolve_editor_reference_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_reference_procedure_result_type::EditorReferenceProcedureResult; +use super::editor_reference_resolve_input_type::EditorReferenceResolveInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ResolveEditorReferenceAndReturnArgs { + pub input: EditorReferenceResolveInput, +} + +impl __sdk::InModule for ResolveEditorReferenceAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `resolve_editor_reference_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait resolve_editor_reference_and_return { + fn resolve_editor_reference_and_return(&self, input: EditorReferenceResolveInput) { + self.resolve_editor_reference_and_return_then(input, |_, _| {}); + } + + fn resolve_editor_reference_and_return_then( + &self, + input: EditorReferenceResolveInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl resolve_editor_reference_and_return for super::RemoteProcedures { + fn resolve_editor_reference_and_return_then( + &self, + input: EditorReferenceResolveInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorReferenceProcedureResult>( + "resolve_editor_reference_and_return", + ResolveEditorReferenceAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 0058006b0..a061a04f9 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -663,6 +663,13 @@ pub struct EditorProjectResourceMediaRepairInput { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorReferenceResolveInput { + pub owner_user_id: String, + pub bucket: String, + pub reference_id: String, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct EditorProjectResourceShowcaseUpdateInput { pub resource_id: String, @@ -1226,6 +1233,14 @@ pub struct EditorProjectResourceProcedureResult { pub error_message: Option, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorReferenceProcedureResult { + pub ok: bool, + pub resource: Option, + pub asset: Option, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct EditorProjectResourceListProcedureResult { pub ok: bool, @@ -1527,6 +1542,37 @@ pub fn create_editor_project_resource_and_return( } } +#[spacetimedb::procedure] +pub fn resolve_editor_reference_and_return( + ctx: &mut ProcedureContext, + input: EditorReferenceResolveInput, +) -> EditorReferenceProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller)?; + resolve_editor_reference(tx, &input) + }) { + Ok(ResolvedEditorReference::ProjectResource(resource)) => EditorReferenceProcedureResult { + ok: true, + resource: Some(resource), + asset: None, + error_message: None, + }, + Ok(ResolvedEditorReference::Asset(asset)) => EditorReferenceProcedureResult { + ok: true, + resource: None, + asset: Some(asset), + error_message: None, + }, + Err(message) => EditorReferenceProcedureResult { + ok: false, + resource: None, + asset: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn repair_editor_project_resource_media_and_return( ctx: &mut ProcedureContext, @@ -6264,6 +6310,117 @@ fn require_editor_generation_pricing_bootstrap( .map_err(|_| "当前 identity 无权初始化模型定价服务身份".to_string()) } +enum ResolvedEditorReference { + ProjectResource(EditorProjectResourceSnapshot), + Asset(EditorAssetSnapshot), +} + +fn resolve_editor_reference( + ctx: &ReducerContext, + input: &EditorReferenceResolveInput, +) -> Result { + let owner_user_id = input.owner_user_id.trim(); + let bucket = input.bucket.trim(); + if owner_user_id.is_empty() || bucket.is_empty() { + return Err("编辑器引用的 owner_user_id 与 bucket 不能为空".to_string()); + } + let reference_id = input.reference_id.trim(); + if reference_id.is_empty() { + return Err("编辑器引用的 reference_id 不能为空".to_string()); + } + let resource = ctx + .db + .editor_project_resource() + .resource_id() + .find(&reference_id.to_string()); + let asset = ctx + .db + .editor_asset() + .asset_id() + .find(&reference_id.to_string()); + match (resource, asset) { + (Some(_), Some(_)) => Err("编辑器引用 ID 同时命中项目资源与素材,无法唯一解析".to_string()), + (Some(resource), None) => { + resolve_editor_reference_resource(ctx, bucket, owner_user_id, &resource) + } + (None, Some(asset)) => resolve_editor_reference_asset(ctx, bucket, owner_user_id, asset), + (None, None) => Err("编辑器引用不存在".to_string()), + } +} + +fn resolve_editor_reference_resource( + ctx: &ReducerContext, + bucket: &str, + owner_user_id: &str, + resource: &EditorProjectResource, +) -> Result { + let object_key = validate_editor_reference_record_fields( + "资源", + owner_user_id, + resource.owner_user_id.as_str(), + resource.object_key.as_deref(), + )?; + verify_editor_reference_asset_object(ctx, bucket, owner_user_id, object_key.as_str())?; + let mut snapshot = resource_snapshot_from_row(resource.clone()); + snapshot.object_key = Some(object_key); + Ok(ResolvedEditorReference::ProjectResource(snapshot)) +} + +fn resolve_editor_reference_asset( + ctx: &ReducerContext, + bucket: &str, + owner_user_id: &str, + asset: EditorAsset, +) -> Result { + let object_key = validate_editor_reference_record_fields( + "素材", + owner_user_id, + asset.owner_user_id.as_str(), + asset.object_key.as_deref(), + )?; + verify_editor_reference_asset_object(ctx, bucket, owner_user_id, object_key.as_str())?; + let mut snapshot = asset_snapshot_from_row(ctx, asset); + snapshot.object_key = Some(object_key); + Ok(ResolvedEditorReference::Asset(snapshot)) +} + +fn validate_editor_reference_record_fields( + record_label: &str, + expected_owner_user_id: &str, + actual_owner_user_id: &str, + object_key: Option<&str>, +) -> Result { + if actual_owner_user_id.trim() != expected_owner_user_id { + return Err(format!("编辑器{record_label}引用不属于当前用户")); + } + object_key + .map(str::trim) + .map(|value| value.trim_start_matches('/')) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| format!("编辑器{record_label}引用缺少已登记 object_key")) +} + +fn verify_editor_reference_asset_object( + ctx: &ReducerContext, + bucket: &str, + owner_user_id: &str, + object_key: &str, +) -> Result<(), String> { + let asset_object = crate::find_asset_object_by_location( + ctx, + &AssetObjectLocationInput { + bucket: bucket.to_string(), + object_key: object_key.to_string(), + }, + )? + .ok_or_else(|| "编辑器引用对应的 asset_object 不存在".to_string())?; + if asset_object.owner_user_id.as_deref().map(str::trim) != Some(owner_user_id) { + return Err("编辑器引用对应的 asset_object 不属于当前用户".to_string()); + } + Ok(()) +} + pub(crate) fn is_editor_generation_runtime_service_identity( ctx: &ReducerContext, candidate: Identity, @@ -16389,4 +16546,59 @@ mod tests { Some("character-animation") ); } + + #[test] + fn editor_reference_record_validation_rejects_owner_and_object_key_failures() { + assert!( + validate_editor_reference_record_fields( + "资源", + "owner-1", + "owner-2", + Some("generated/icon-spec.png"), + ) + .unwrap_err() + .contains("不属于当前用户") + ); + assert!( + validate_editor_reference_record_fields("素材", "owner-1", "owner-1", None) + .unwrap_err() + .contains("object_key") + ); + assert_eq!( + validate_editor_reference_record_fields( + "素材", + "owner-1", + "owner-1", + Some("/generated/icon-spec.png"), + ) + .unwrap(), + "generated/icon-spec.png" + ); + } + + #[test] + fn editor_reference_resolution_uses_both_primary_keys_and_fails_on_collision() { + let source = include_str!("editor_project_storage.rs"); + let start = source + .find("fn resolve_editor_reference(\n") + .expect("reference resolver"); + let end = source[start..] + .find("fn resolve_editor_reference_resource(\n") + .map(|offset| start + offset) + .expect("resource resolver boundary"); + let resolver = &source[start..end]; + for required in [ + ".editor_project_resource()\n .resource_id()\n .find(", + ".editor_asset()\n .asset_id()\n .find(", + "(Some(_), Some(_))", + "同时命中项目资源与素材", + ] { + assert!( + resolver.contains(required), + "resolver must retain {required}" + ); + } + assert!(!resolver.contains(".iter()")); + assert!(!resolver.contains(".filter(")); + } }