修复 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
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:
File diff suppressed because it is too large
Load Diff
@@ -6203,15 +6203,17 @@ mod tests {
|
||||
"projectId": "source-binding-project"
|
||||
}]}}),
|
||||
);
|
||||
} else if request_line.starts_with("GET /api/external/v1/editor/assets/library ")
|
||||
|| request_line.starts_with("GET /api/editor/assets/library ")
|
||||
} else if request_line.starts_with(
|
||||
"GET /api/external/v1/editor/assets/folders/source-binding-folder ",
|
||||
) || request_line
|
||||
.starts_with("GET /api/editor/assets/folders/source-binding-folder ")
|
||||
{
|
||||
write_json(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
serde_json::json!({"data": {"library": {"folders": [{
|
||||
serde_json::json!({"data": {"folder": {
|
||||
"folderId": "source-binding-folder"
|
||||
}]}}}),
|
||||
}}}),
|
||||
);
|
||||
} else if request_line
|
||||
.starts_with("POST /api/external/v1/assets/direct-upload-tickets ")
|
||||
@@ -6900,7 +6902,7 @@ mod tests {
|
||||
"generated/owner-b-source.{}",
|
||||
if is_image { "png" } else { "mp4" }
|
||||
);
|
||||
let request_count = if is_image { 6 } else { 7 };
|
||||
let request_count = 6;
|
||||
let server_upload_url = upload_url.clone();
|
||||
let server_object_key = owner_b_object_key.clone();
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
@@ -6935,18 +6937,12 @@ mod tests {
|
||||
serde_json::json!({"data": {"project": {"projectId": "owner-b-project"}}}),
|
||||
);
|
||||
} else if request_line
|
||||
.starts_with("GET /api/external/v1/editor/assets/library ")
|
||||
.starts_with("GET /api/external/v1/editor/assets/folders/owner-b-folder ")
|
||||
{
|
||||
write_json(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
if is_image {
|
||||
serde_json::json!({"data": {"library": {"folders": [{
|
||||
"folderId": "owner-b-folder"
|
||||
}]}}})
|
||||
} else {
|
||||
serde_json::json!({"data": {"library": {"folders": []}}})
|
||||
},
|
||||
serde_json::json!({"data": {"folder": {"folderId": "owner-b-folder"}}}),
|
||||
);
|
||||
} else if request_line
|
||||
.starts_with("POST /api/external/v1/editor/assets/folders ")
|
||||
@@ -7417,7 +7413,9 @@ mod tests {
|
||||
assert!(requests.iter().all(|request| {
|
||||
let line = request.lines().next().unwrap_or_default();
|
||||
line.starts_with("GET /api/external/v1/editor/projects?view=summary ")
|
||||
|| line.starts_with("GET /api/external/v1/editor/assets/library ")
|
||||
|| line.starts_with(
|
||||
"GET /api/external/v1/editor/assets/folders/source-binding-folder ",
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -9582,7 +9580,7 @@ mod tests {
|
||||
let server_generated_video = generated_video.clone();
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let server = std::thread::spawn(move || {
|
||||
for request_index in 0..11 {
|
||||
for request_index in 0..10 {
|
||||
let mut stream = accept_resource_editor_fixture_connection(
|
||||
&listener,
|
||||
"External video fixture",
|
||||
@@ -9608,12 +9606,6 @@ mod tests {
|
||||
"projectId": "external-video-project"
|
||||
}}}),
|
||||
);
|
||||
} else if request_line.starts_with("GET /api/external/v1/editor/assets/library ") {
|
||||
write_json(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
serde_json::json!({"data": {"library": {"folders": []}}}),
|
||||
);
|
||||
} else if request_line.starts_with("POST /api/external/v1/editor/assets/folders ") {
|
||||
write_json(
|
||||
&mut stream,
|
||||
@@ -9731,7 +9723,7 @@ mod tests {
|
||||
.expect("derive External video");
|
||||
server.join().expect("join External video server");
|
||||
let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::<Vec<_>>();
|
||||
assert_eq!(requests.len(), 11);
|
||||
assert_eq!(requests.len(), 10);
|
||||
assert!(requests.iter().all(|request| {
|
||||
!request.starts_with("POST /api/assets/")
|
||||
&& !request.starts_with("GET /api/runtime/external-generation/")
|
||||
|
||||
+6
-4
@@ -400,13 +400,15 @@ fn respond_canvas_context_request(stream: &mut TcpStream, request: &str) -> bool
|
||||
serde_json::json!({"data": {"projects": [{"projectId": REMOTE_PROJECT_ID}]}}),
|
||||
);
|
||||
true
|
||||
} else if line.starts_with("GET /api/external/v1/editor/assets/library ")
|
||||
|| line.starts_with("GET /api/editor/assets/library ")
|
||||
{
|
||||
} else if line.starts_with(&format!(
|
||||
"GET /api/external/v1/editor/assets/folders/{REMOTE_FOLDER_ID} "
|
||||
)) || line.starts_with(&format!(
|
||||
"GET /api/editor/assets/folders/{REMOTE_FOLDER_ID} "
|
||||
)) {
|
||||
write_json(
|
||||
stream,
|
||||
"200 OK",
|
||||
serde_json::json!({"data": {"library": {"folders": [{"folderId": REMOTE_FOLDER_ID}]}}}),
|
||||
serde_json::json!({"data": {"folder": {"folderId": REMOTE_FOLDER_ID}}}),
|
||||
);
|
||||
true
|
||||
} else {
|
||||
|
||||
@@ -786,6 +786,46 @@
|
||||
}
|
||||
},
|
||||
"/api/external/v1/editor/assets/folders/{folderId}": {
|
||||
"get": {
|
||||
"tags": ["Editor Assets"],
|
||||
"operationId": "getEditorAssetFolder",
|
||||
"summary": "按 ID 读取当前账号素材文件夹",
|
||||
"description": "要求 editor:asset scope。仅按 folderId 读取目录并校验当前账号归属,不读取素材列表或创建默认目录;空目录也可读取。目录不存在返回 404,无权访问返回 403。",
|
||||
"security": [
|
||||
{
|
||||
"ExternalApiKey": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/FolderId"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "素材文件夹",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ExternalEditorAssetFolderResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": ["Editor Assets"],
|
||||
"operationId": "updateEditorAssetFolder",
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
|
||||
## 验证路由
|
||||
|
||||
AGC 生成准备只通过单目录 GET 确认当前账号绑定,不能重新依赖完整素材库,也不能把目录查询失败当成首次无绑定而创建目录。目录删除时保留证据并停止;`prepared / accepted / legacy-completed` 生成账本继续用冻结快照恢复。该查询同时覆盖站内会话与 External v1,后端新增接口先发布,旧 API 保持兼容;详见 AGC 实施计划和后端数据契约。
|
||||
|
||||
Windows 下的移动壳 smoke 通过 Node 启动从当前 workspace 包解析出的 Expo/EAS CLI,不直接 `spawnSync('npm.cmd')`;保留原配置与导出断言。具体入口和警告清理边界见本地开发运维文档。
|
||||
|
||||
AGC 的纯桌面/生产接入按模块排除测试编译,共享实现与受测入口保持可测试;拆分后同时检查普通目标和测试目标,并保留原有行为断言。命令注册与退出接线的源码检查跟随真实模块位置更新,不能因移动文件而漏检;不通过全局允许死代码或虚假调用消除告警。具体边界见开发运维文档“编译告警的保留边界与待优化项”。
|
||||
|
||||
@@ -946,6 +946,10 @@ Agent Runtime 负责:
|
||||
- 2026-07-10 补充:后台任务工具箱已加入 `preview.start`。Agent 可在 loop 中自行请求启动当前项目的本地 HTTP 预览;Runtime 会复用 `preview.start` 策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑,并把 `agent.runtime.preview.start` 写入 `.agent/agent.db`。该 observation 只向 LLM 返回 localhost URL 与端口,不返回用户项目绝对路径。
|
||||
- External Editor 的远端项目和资源属于服务 principal,不属于本地项目的跨账号身份。`.agent/manifest.json` 中 `source.canvasProjectId / resourceId / assetObjectId` 只记录生成来源,不得作为当前登录账号的 active editable binding。客户端在 `.agent/runtime/external-editor-bindings/` 私有保存账号级投影:项目键至少包含服务 origin、principal 和本地 `projectId`,资源键再包含远端项目、本地 asset ID、源文件 SHA-256、媒体类型与 canonical asset kind;值保存该 principal 下的远端 project/folder/resource/object 身份。Token 轮换继续复用同一 principal binding,账号、服务、源字节或 canonical kind 变化必须重新建立;同名本地项目和项目改名不得参与权威匹配。
|
||||
- binding 的 lookup key 与稳定 payload 内容指纹分开校验;远端 project/folder/resource/object ID、object key 或尺寸被单独改写时必须失败关闭。同一本地项目和 principal 的首次项目 binding 建立必须串行化覆盖“复读 binding → 必要的远端创建 → 原子安装 binding”,不得因并发任务创建孤儿画布。External v1 项目、素材目录和项目资源创建请求携带由 binding key 派生的稳定 `Idempotency-Key`;服务端在 owner 与接口命名空间内生成稳定 ID,并允许同键同正文安全重试,使远端响应后、本地 sidecar 落盘前崩溃也不会重复创建。
|
||||
- 素材生成准备的目录确认采用单目录读取:站内 `GET /api/editor/assets/folders/{folderId}` 与开发者 `GET /api/external/v1/editor/assets/folders/{folderId}` 返回现有 `{folder: ...}` 数据结构,后端按目录主键读取并校验 owner,不读取账户素材、不创建默认目录、不执行媒体修复。External 入口要求 `editor:asset` scope;缺失目录返回 404,越权返回 403,未认证返回 401。原素材库快照及目录创建、修改、删除接口的合同不变;不修改持久化表结构。先部署新增后端接口,再发布依赖该接口的客户端;新客户端不回退完整素材库。
|
||||
- 已有当前账号项目 binding 或恢复 partial 的目录身份时,在新的生成准备中按该 ID 确认目录并核对响应 ID;合法空目录也可继续。首次没有可复用目录身份时直接沿用稳定幂等创建和冻结正文,不先列举素材库,不按同名认领目录。partial 继续只承载中间恢复进度,成功写入完整 binding 后删除;既有生成账本的 `prepared / accepted / legacy-completed` 恢复仍使用冻结快照,不重新查询或创建目录。
|
||||
- 单目录查询超时、断网、服务异常、鉴权失效、越权或会话切换时停止准备并保留现有 binding / 恢复证据,不解释为目录缺失、不自动 POST 新目录。明确 404 时提示绑定目录不存在并停止,不用原键复活目录、不换键重建或重发生成;自动重新绑定与素材分页、关键词筛选、按素材 ID 导入不属于该行为。验证覆盖全库不可用而单目录正常、空目录、首次建绑、缺失与越权、查询失败及切号,并保留并发单次创建、创建响应丢失、冻结正文及生成账本恢复回归。
|
||||
- 单目录合同的定向验证使用 AGC Rust `external_canvas_context`、`canvas_generation_tests`、`project::resource_editor::tests` 与 `background_removal_tests`,后端使用 `external_editor` 和 `protected_route_matrix_keeps_auth_methods_and_head_behavior`;`npm run check:editor-idempotency-procedures` 在独立 SpacetimeDB 中验证真实目录读取、归属、删除与只读行为,host 单测不能代替该运行时证据。
|
||||
- 当前 binding wire schema 为 v2,payload fingerprint 必填。已落盘的 v1 project/resource JSON 只能经独立严格 wire 结构读取,在 lookup key、principal、本地 source、远端字段、对象键、时间和尺寸等不变量全部通过后重算指纹并原子回写 v2。不得用 `serde(default)` 把未知或篡改文档混入迁移。
|
||||
- 已完成的本地正式资源以本地文件和 manifest asset 身份为权威。切换账号后,当前账号没有资源 binding 时,必须从本地字节上传、confirm 并登记到当前账号的远端项目后再编辑或派生;任何请求都不得把旧账号的 `canvasProjectId / resourceId / objectKey` 与新账号 Token 组合。新绑定不得覆盖历史来源,也不得迁移或删除旧账号远端对象。已经 `prepared / accepted / running` 的生成或编辑 operation 仍冻结原 principal、原正文和原 operation;切换账号只能停止恢复并保留对账证据,不能改绑、重放或重复扣费。
|
||||
- 远端提交返回 `operationId` 后先持久化 accepted 身份,再在每次 poll、download 和本地 commit 前复验冻结会话;同步本地 commit 持有冻结会话租约,使切号只能发生在完整安装前或安装后。Tauri 手工生成同样使用稳定的 durable operation slot,不得使用进程内临时幂等键。历史资源编辑账本从 manifest 远端 resource ID 迁移到本地 asset ID 时,只允许在本地 asset、路径和内容摘要仍一致时兼容旧指纹,新请求不得恢复信任 manifest 远端 ID。
|
||||
|
||||
@@ -617,6 +617,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
|
||||
- Rust 结构体:`EditorAssetFolder`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
|
||||
- 说明:图片画布账号级素材文件夹表,归属于用户账号而不是 project;首次读取素材库时自动创建系统默认“项目素材”文件夹。文件夹支持重命名、折叠和删除,系统默认文件夹不能删除。
|
||||
- 单目录读取:`GET /api/editor/assets/folders/{folderId}` 与 `GET /api/external/v1/editor/assets/folders/{folderId}` 经同一 `spacetime-client` facade / 只读 procedure 按目录主键和 owner 校验,返回现有目录 DTO;不读取素材、不创建默认目录或修复媒体。External 要求 `editor:asset` scope;未认证 401、越权 403、缺失 404。既有全库、目录创建/修改/删除 API 合同和持久表结构保持不变;先部署后端再发布依赖新接口的 AGC 客户端,不提供全库回退。
|
||||
- 索引:`by_editor_asset_folder_owner_user_id`。
|
||||
|
||||
### `editor_asset`
|
||||
|
||||
@@ -384,6 +384,28 @@ function assertDeletedConflict(result, recordLabel) {
|
||||
}
|
||||
|
||||
async function runSmoke(configPath, serverUrl) {
|
||||
const readFolder = (folderId, ownerUserId = 'receipt-owner') =>
|
||||
callProcedure(configPath, serverUrl, 'get_editor_asset_folder_and_return', {
|
||||
folder_id: folderId,
|
||||
owner_user_id: ownerUserId,
|
||||
});
|
||||
const missingBeforeCreate = await readFolder('missing-folder', 'empty-owner');
|
||||
assert(
|
||||
missingBeforeCreate.ok === false &&
|
||||
missingBeforeCreate.errorMessage === '素材文件夹不存在',
|
||||
'Missing folder read did not return the missing-folder error.',
|
||||
);
|
||||
assert(
|
||||
(
|
||||
await sqlRows(
|
||||
configPath,
|
||||
serverUrl,
|
||||
'SELECT folder_id FROM editor_asset_folder',
|
||||
'folders after missing read',
|
||||
)
|
||||
).length === 0,
|
||||
'Missing folder read created a default folder.',
|
||||
);
|
||||
const projectA = projectInput('project-a', 'First');
|
||||
assertOk(
|
||||
await callProcedure(
|
||||
@@ -471,6 +493,73 @@ async function runSmoke(configPath, serverUrl) {
|
||||
'asset folder drift replay',
|
||||
);
|
||||
|
||||
const folderRowsBeforeRead = await sqlRows(
|
||||
configPath,
|
||||
serverUrl,
|
||||
'SELECT * FROM editor_asset_folder',
|
||||
'folders before read',
|
||||
);
|
||||
const receiptsBeforeRead = await sqlRows(
|
||||
configPath,
|
||||
serverUrl,
|
||||
'SELECT * FROM editor_idempotent_create_receipt',
|
||||
'receipts before read',
|
||||
);
|
||||
assert(
|
||||
(
|
||||
await sqlRows(
|
||||
configPath,
|
||||
serverUrl,
|
||||
'SELECT asset_id FROM editor_asset',
|
||||
'assets before folder read',
|
||||
)
|
||||
).length === 0,
|
||||
'Folder read fixture must have no assets.',
|
||||
);
|
||||
const emptyFolder = await readFolder('folder-a');
|
||||
assertOk(emptyFolder, 'empty asset folder read');
|
||||
assert(
|
||||
emptyFolder.record[0] === 'folder-a' &&
|
||||
emptyFolder.record[1] === 'Folder A',
|
||||
'Folder read returned a different folder.',
|
||||
);
|
||||
const foreignFolder = await readFolder('folder-a', 'other-owner');
|
||||
assert(
|
||||
foreignFolder.ok === false &&
|
||||
foreignFolder.record === null &&
|
||||
foreignFolder.errorMessage === '无权访问该素材文件夹',
|
||||
'Foreign-owner folder read did not reject access.',
|
||||
);
|
||||
const missingFolder = await readFolder('missing-folder');
|
||||
assert(
|
||||
missingFolder.ok === false &&
|
||||
missingFolder.record === null &&
|
||||
missingFolder.errorMessage === '素材文件夹不存在',
|
||||
'Missing folder read did not fail closed.',
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(
|
||||
await sqlRows(
|
||||
configPath,
|
||||
serverUrl,
|
||||
'SELECT * FROM editor_asset_folder',
|
||||
'folders after read',
|
||||
),
|
||||
) === JSON.stringify(folderRowsBeforeRead),
|
||||
'Folder reads changed folder rows or timestamps.',
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(
|
||||
await sqlRows(
|
||||
configPath,
|
||||
serverUrl,
|
||||
'SELECT * FROM editor_idempotent_create_receipt',
|
||||
'receipts after read',
|
||||
),
|
||||
) === JSON.stringify(receiptsBeforeRead),
|
||||
'Folder reads changed durable receipts.',
|
||||
);
|
||||
|
||||
const sameRaceInput = projectInput('project-same-race', 'Same Race');
|
||||
const sameRace = await Promise.all([
|
||||
callProcedure(
|
||||
@@ -641,6 +730,12 @@ async function runSmoke(configPath, serverUrl) {
|
||||
},
|
||||
);
|
||||
assertOk(deleteFolderResult, 'asset folder delete');
|
||||
const deletedFolder = await readFolder('folder-a');
|
||||
assert(
|
||||
deletedFolder.ok === false &&
|
||||
deletedFolder.errorMessage === '素材文件夹不存在',
|
||||
'Deleted folder read did not return the missing-folder error.',
|
||||
);
|
||||
assertDeletedConflict(
|
||||
await callProcedure(
|
||||
configPath,
|
||||
@@ -757,7 +852,7 @@ export async function main() {
|
||||
|
||||
await runSmoke(configPath, serverUrl);
|
||||
console.log(
|
||||
'[editor-idempotency-smoke] Passed exact replay, body conflicts, deletion fail-close, concurrent calls, and orphan checks.',
|
||||
'[editor-idempotency-smoke] Passed folder reads (empty, missing, foreign owner, no writes), exact replay, body conflicts, deletion fail-close, concurrent calls, and orphan checks.',
|
||||
);
|
||||
} catch (error) {
|
||||
const standaloneOutput = sanitizeDiagnostic(
|
||||
|
||||
@@ -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;
|
||||
|
||||
+16
@@ -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;
|
||||
}
|
||||
+59
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user