修复 AGC 生成准备依赖全量素材库的问题
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m33s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 4m41s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 3m41s
Project CI / Backend tests (pull_request) Failing after 3m38s
Project CI / Frontend tests (pull_request) Successful in 2m27s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m56s
Project CI / Repository checks (pull_request) Successful in 3m1s
Project CI / Native shell tests (pull_request) Successful in 6m12s

新增站内与 External v1 单目录读取接口并校验账号归属,保持既有 API 兼容
将已有绑定与 partial 恢复改为按目录 ID 确认,首次建绑不再读取全库
目录缺失或查询失败时停止并保留恢复证据,保持生成幂等与账号隔离
同步 OpenAPI、生成绑定、技术文档及目录读取和生成恢复回归测试
This commit is contained in:
2026-10-02 22:25:17 +08:00
parent db0332ea09
commit 0afd0b0b10
19 changed files with 842 additions and 184 deletions
@@ -48,7 +48,7 @@ use spacetime_client::editor_project::{
};
use spacetime_client::{
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord,
EditorAssetFolderDeleteRecordInput, EditorAssetFolderGetRecordInput, EditorAssetFolderRecord,
EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord,
EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput,
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput,
@@ -2386,6 +2386,29 @@ pub async fn get_editor_asset_library(
))
}
pub async fn get_editor_asset_folder(
State(state): State<AppState>,
Path(folder_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, AppError> {
let folder = state
.spacetime_client()
.get_editor_asset_folder(EditorAssetFolderGetRecordInput {
folder_id,
owner_user_id: current_owner_user_id(&authenticated),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorAssetFolderResponse {
folder: editor_asset_folder_payload_from_record(folder),
},
))
}
pub async fn create_editor_asset_folder(
State(state): State<AppState>,
headers: HeaderMap,
@@ -17,11 +17,11 @@ use shared_contracts::external_generation::{
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
EditorAssetFolderDeleteRecordInput, EditorAssetFolderUpdateRecordInput,
EditorAssetUpdateRecordInput, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput,
EditorProjectGetRecordInput, EditorProjectRenameRecordInput,
EditorProjectResourceCreateRecordInput, ExternalGenerationJobGetRecordInput,
ExternalGenerationJobRecord, SpacetimeClientError,
EditorAssetFolderDeleteRecordInput, EditorAssetFolderGetRecordInput,
EditorAssetFolderUpdateRecordInput, EditorAssetUpdateRecordInput,
EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput,
ExternalGenerationJobGetRecordInput, ExternalGenerationJobRecord, SpacetimeClientError,
};
use crate::editor_project_icon::{
@@ -468,6 +468,30 @@ pub async fn get_external_editor_asset_library(
))
}
pub async fn get_external_editor_asset_folder(
State(state): State<AppState>,
Path(folder_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
let folder = state
.spacetime_client()
.get_editor_asset_folder(EditorAssetFolderGetRecordInput {
folder_id,
owner_user_id: principal.owner_user_id().to_string(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalEditorAssetFolderResponse {
folder: editor_asset_folder_payload_from_record(folder),
},
))
}
pub async fn create_external_editor_asset_folder(
State(state): State<AppState>,
headers: HeaderMap,
@@ -1194,7 +1218,11 @@ fn serialize_external_editor_image_sequence_frames(
mod tests {
use super::*;
use crate::editor_project::PROJECT_COVER_SNAPSHOT_ASSET_KIND;
use axum::{Router, body::Body, routing::post};
use axum::{
Router,
body::Body,
routing::{get, post},
};
use spacetime_client::{
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectRecord,
EditorProjectResourceRecord,
@@ -2317,6 +2345,72 @@ mod tests {
);
}
#[tokio::test]
async fn external_editor_asset_folder_read_requires_asset_scope_before_store_access() {
let state = AppState::new(crate::config::AppConfig::default()).expect("state should build");
let app = Router::new()
.route(
"/api/external/v1/editor/assets/folders/{folder_id}",
get(get_external_editor_asset_folder),
)
.layer(Extension(request_context(false)))
.layer(Extension(ExternalApiPrincipal::for_test("user-1", &[])))
.with_state(state);
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/api/external/v1/editor/assets/folders/folder-1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn external_editor_asset_folder_read_maps_missing_and_foreign_owner_errors() {
for (message, status) in [
("素材文件夹不存在", StatusCode::NOT_FOUND),
("无权访问该素材文件夹", StatusCode::FORBIDDEN),
] {
assert_eq!(
map_editor_project_error(SpacetimeClientError::Procedure(message.to_string()))
.status_code(),
status
);
}
}
#[test]
fn external_editor_asset_folder_read_openapi_matches_route_contract() {
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
let operation =
&parsed["paths"]["/api/external/v1/editor/assets/folders/{folderId}"]["get"];
assert_eq!(operation["operationId"], "getEditorAssetFolder");
assert_eq!(
operation["parameters"][0]["$ref"],
"#/components/parameters/FolderId"
);
assert_eq!(operation["security"], json!([{ "ExternalApiKey": [] }]));
assert!(
operation["description"]
.as_str()
.unwrap()
.contains("editor:asset")
);
assert_eq!(
operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"],
"#/components/schemas/ExternalEditorAssetFolderResponse"
);
for status in ["200", "400", "401", "403", "404"] {
assert!(
operation["responses"].get(status).is_some(),
"missing {status}"
);
}
}
#[test]
fn exported_openapi_json_contains_external_editor_routes_and_security() {
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
@@ -22,10 +22,10 @@ use crate::{
create_editor_project, create_editor_project_resource, delete_editor_asset,
delete_editor_asset_folder, delete_editor_project, edit_editor_image,
extract_editor_ui_design_assets, generate_editor_image, generate_editor_scene,
get_editor_asset_library, get_editor_generation_pricing, get_editor_project,
list_editor_projects, list_public_editor_project_resources, load_recent_editor_project,
remove_editor_image_background, rename_editor_project, save_editor_project_layout,
snap_editor_image_to_pixel_art, submit_editor_asset_showcase,
get_editor_asset_folder, get_editor_asset_library, get_editor_generation_pricing,
get_editor_project, list_editor_projects, list_public_editor_project_resources,
load_recent_editor_project, remove_editor_image_background, rename_editor_project,
save_editor_project_layout, snap_editor_image_to_pixel_art, submit_editor_asset_showcase,
toggle_editor_showcase_asset_like, update_editor_asset, update_editor_asset_folder,
update_editor_project_resource_showcase,
},
@@ -110,7 +110,9 @@ pub fn router(state: AppState) -> Router<AppState> {
),
(
"/api/editor/assets/folders/{folder_id}",
patch(update_editor_asset_folder).delete(delete_editor_asset_folder),
get(get_editor_asset_folder)
.patch(update_editor_asset_folder)
.delete(delete_editor_asset_folder),
),
("/api/editor/assets", post(create_editor_asset)),
(
@@ -291,7 +293,7 @@ mod route_contract_tests {
("/api/editor/assets/folders", &["POST"]),
(
"/api/editor/assets/folders/{folder_id}",
&["PATCH", "DELETE"],
&["GET", "PATCH", "DELETE"],
),
("/api/editor/assets", &["POST"]),
("/api/editor/assets/{asset_id}", &["PATCH", "DELETE"]),
@@ -21,10 +21,11 @@ use crate::{
generate_external_editor_character_animation, generate_external_editor_icon_spritesheet,
generate_external_editor_image, generate_external_editor_scene,
generate_external_editor_sound_effect, generate_external_editor_video,
get_external_editor_asset_library, get_external_editor_generation_job,
get_external_editor_project, list_external_editor_projects,
load_recent_external_editor_project, openapi_json, remove_external_editor_image_background,
rename_external_editor_project, save_external_editor_canvas, update_external_editor_asset,
get_external_editor_asset_folder, get_external_editor_asset_library,
get_external_editor_generation_job, get_external_editor_project,
list_external_editor_projects, load_recent_external_editor_project, openapi_json,
remove_external_editor_image_background, rename_external_editor_project,
save_external_editor_canvas, update_external_editor_asset,
update_external_editor_asset_folder,
},
external_mcp,
@@ -93,7 +94,9 @@ pub fn router(state: AppState) -> Router<AppState> {
),
(
"/api/external/v1/editor/assets/folders/{folder_id}",
patch(update_external_editor_asset_folder).delete(delete_external_editor_asset_folder),
get(get_external_editor_asset_folder)
.patch(update_external_editor_asset_folder)
.delete(delete_external_editor_asset_folder),
),
(
"/api/external/v1/editor/assets",
@@ -252,7 +255,7 @@ mod route_contract_tests {
("/api/external/v1/editor/assets/folders", &["POST"]),
(
"/api/external/v1/editor/assets/folders/{folder_id}",
&["PATCH", "DELETE"],
&["GET", "PATCH", "DELETE"],
),
("/api/external/v1/editor/assets", &["POST"]),
(
@@ -52,7 +52,7 @@ pub use self::editor_agent::{
pub use self::editor_project::{
AdminEditorAssetListRecordInput, AdminEditorAssetRecord, EditorAssetCreateRecordInput,
EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord,
EditorAssetFolderDeleteRecordInput, EditorAssetFolderGetRecordInput, EditorAssetFolderRecord,
EditorAssetFolderUpdateRecordInput, EditorAssetGroupCohortCompleteRecordInput,
EditorAssetGroupSourceLookupRecordInput, EditorAssetLibraryRecord,
EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput,
@@ -362,6 +362,12 @@ pub struct EditorProjectResourcePublicShowcaseListRecordInput {
pub cursor: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditorAssetFolderGetRecordInput {
pub folder_id: String,
pub owner_user_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditorAssetFolderCreateRecordInput {
pub folder_id: String,
@@ -711,6 +717,15 @@ impl From<EditorProjectResourcePublicShowcaseListRecordInput>
}
}
impl From<EditorAssetFolderGetRecordInput> for crate::module_bindings::EditorAssetFolderGetInput {
fn from(input: EditorAssetFolderGetRecordInput) -> Self {
Self {
folder_id: input.folder_id,
owner_user_id: input.owner_user_id,
}
}
}
impl From<EditorAssetFolderCreateRecordInput>
for crate::module_bindings::EditorAssetFolderCreateInput
{
@@ -819,6 +819,28 @@ impl SpacetimeClient {
.await
}
pub async fn get_editor_asset_folder(
&self,
input: EditorAssetFolderGetRecordInput,
) -> Result<EditorAssetFolderRecord, SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(
"get_editor_asset_folder_and_return",
move |connection, sender| {
connection
.procedures()
.get_editor_asset_folder_and_return_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_asset_folder_procedure_result);
send_once(&sender, mapped);
});
},
)
.await
}
pub async fn create_editor_asset_folder(
&self,
input: EditorAssetFolderCreateRecordInput,
@@ -208,6 +208,7 @@ pub mod editor_asset_create_input_type;
pub mod editor_asset_delete_input_type;
pub mod editor_asset_folder_create_input_type;
pub mod editor_asset_folder_delete_input_type;
pub mod editor_asset_folder_get_input_type;
pub mod editor_asset_folder_procedure_result_type;
pub mod editor_asset_folder_snapshot_type;
pub mod editor_asset_folder_table;
@@ -460,6 +461,7 @@ pub mod get_asset_object_by_id_and_return_procedure;
pub mod get_asset_object_by_location_and_return_procedure;
pub mod get_asset_read_access_by_location_and_return_procedure;
pub mod get_editor_agent_conversation_and_return_procedure;
pub mod get_editor_asset_folder_and_return_procedure;
pub mod get_editor_asset_library_and_return_procedure;
pub mod get_editor_generation_pricing_config_and_return_procedure;
pub mod get_editor_project_and_return_procedure;
@@ -1009,6 +1011,7 @@ pub use editor_asset_create_input_type::EditorAssetCreateInput;
pub use editor_asset_delete_input_type::EditorAssetDeleteInput;
pub use editor_asset_folder_create_input_type::EditorAssetFolderCreateInput;
pub use editor_asset_folder_delete_input_type::EditorAssetFolderDeleteInput;
pub use editor_asset_folder_get_input_type::EditorAssetFolderGetInput;
pub use editor_asset_folder_procedure_result_type::EditorAssetFolderProcedureResult;
pub use editor_asset_folder_snapshot_type::EditorAssetFolderSnapshot;
pub use editor_asset_folder_table::*;
@@ -1261,6 +1264,7 @@ pub use get_asset_object_by_id_and_return_procedure::get_asset_object_by_id_and_
pub use get_asset_object_by_location_and_return_procedure::get_asset_object_by_location_and_return;
pub use get_asset_read_access_by_location_and_return_procedure::get_asset_read_access_by_location_and_return;
pub use get_editor_agent_conversation_and_return_procedure::get_editor_agent_conversation_and_return;
pub use get_editor_asset_folder_and_return_procedure::get_editor_asset_folder_and_return;
pub use get_editor_asset_library_and_return_procedure::get_editor_asset_library_and_return;
pub use get_editor_generation_pricing_config_and_return_procedure::get_editor_generation_pricing_config_and_return;
pub use get_editor_project_and_return_procedure::get_editor_project_and_return;
@@ -0,0 +1,16 @@
// 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 EditorAssetFolderGetInput {
pub folder_id: String,
pub owner_user_id: String,
}
impl __sdk::InModule for EditorAssetFolderGetInput {
type Module = super::RemoteModule;
}
@@ -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_asset_folder_get_input_type::EditorAssetFolderGetInput;
use super::editor_asset_folder_procedure_result_type::EditorAssetFolderProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct GetEditorAssetFolderAndReturnArgs {
pub input: EditorAssetFolderGetInput,
}
impl __sdk::InModule for GetEditorAssetFolderAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `get_editor_asset_folder_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait get_editor_asset_folder_and_return {
fn get_editor_asset_folder_and_return(&self, input: EditorAssetFolderGetInput) {
self.get_editor_asset_folder_and_return_then(input, |_, _| {});
}
fn get_editor_asset_folder_and_return_then(
&self,
input: EditorAssetFolderGetInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorAssetFolderProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl get_editor_asset_folder_and_return for super::RemoteProcedures {
fn get_editor_asset_folder_and_return_then(
&self,
input: EditorAssetFolderGetInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorAssetFolderProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, EditorAssetFolderProcedureResult>(
"get_editor_asset_folder_and_return",
GetEditorAssetFolderAndReturnArgs { input },
__callback,
);
}
}
@@ -972,6 +972,12 @@ pub struct EditorAssetLibraryGetInput {
pub now_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorAssetFolderGetInput {
pub folder_id: String,
pub owner_user_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorAssetFolderCreateInput {
pub folder_id: String,
@@ -1872,6 +1878,17 @@ pub fn find_editor_asset_group_source_and_return(
}
}
#[spacetimedb::procedure]
pub fn get_editor_asset_folder_and_return(
ctx: &mut ProcedureContext,
input: EditorAssetFolderGetInput,
) -> EditorAssetFolderProcedureResult {
match ctx.try_with_tx(|tx| get_editor_asset_folder(tx, input.clone())) {
Ok(folder) => editor_asset_folder_ok(Some(folder), None),
Err(message) => editor_asset_folder_error(message),
}
}
#[spacetimedb::procedure]
pub fn create_editor_asset_folder_and_return(
ctx: &mut ProcedureContext,
@@ -5777,6 +5794,17 @@ fn create_editor_asset_folder(
.ok_or_else(|| "素材文件夹创建失败".to_string())
}
fn get_editor_asset_folder(
ctx: &ReducerContext,
input: EditorAssetFolderGetInput,
) -> Result<EditorAssetFolderSnapshot, String> {
let folder_id = normalize_required(&input.folder_id, "editor_asset_folder.folder_id")?;
let owner_user_id =
normalize_required(&input.owner_user_id, "editor_asset_folder.owner_user_id")?;
require_owned_asset_folder(ctx, folder_id.as_str(), owner_user_id.as_str())
.map(asset_folder_snapshot_from_row)
}
fn update_editor_asset_folder(
ctx: &ReducerContext,
input: EditorAssetFolderUpdateInput,