修复 AGC 生成准备依赖全量素材库的问题 (#592)
Project CI / AI game creator shell Rust crates (push) Successful in 1m47s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 5m1s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 5m18s
Project CI / Frontend tests (push) Successful in 2m27s
Project CI / Backend tests (push) Successful in 5m49s
Project CI / Native shell tests (push) Successful in 6m21s
Project CI / Repository checks (push) Successful in 3m36s
Project CI / AI game creator shell web tests (push) Successful in 2m54s

修复已有项目和素材目录绑定时,AGC 生成准备仍读取完整账户素材库、导致无关素材读取失败阻断生成的问题。

- 新增站内与 External v1 的单目录 GET,按主键读取并校验账号归属;复用目录 DTO,不读取素材列表或创建默认目录。
- 已有 binding 和 partial 恢复改为确认目标目录;首次建绑直接沿用稳定幂等创建,不再读取全库。
- 目录缺失、越权、超时、响应身份不符或会话切换时停止,保留恢复证据,不自动重建、不换键重发生成;已有生成账本继续沿用冻结快照恢复。
- 既有素材库与目录创建、修改、删除 API 合同保持不变,持久化表结构不变。同步 OpenAPI、生成绑定及技术文档。

验证:
- AGC Rust:目录绑定 7 项、生成 94 项、资源编辑 60 项、抠图 5 项通过(目录绑定用例包含在生成组中)。
- api-server:External Editor 30 项、受保护路由矩阵 3 项通过。
- 真实隔离 SpacetimeDB:目录读取成功、空目录、缺失、越权、删除后缺失、只读不变,以及既有幂等/并发/孤儿检查通过。
- smoke 脚本测试 4 项通过;module 编译、生成绑定、87 张表 schema guard、DDD、编码、文档索引、定向 Rust 格式与 diff 检查通过。
- 已比较 OpenAPI:除新增单目录 GET 外,原有定义逐项保持一致。

验证限制:未完成独立 API `/healthz` 联调,本地隔离数据库与授权配置未就绪;启动尝试已停止并清理。未运行全量测试或真实付费生成。

发布顺序:先发布后端模块与 API,再发布新客户端;不提供全库回退。#574 的素材分页、筛选及按素材 ID 读取不在本 PR 范围内。

Closes #550

Reviewed-on: #592
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
This commit was merged in pull request #592.
This commit is contained in:
2026-10-03 14:48:54 +08:00
committed by 孔令弘
parent b62e32946b
commit ae2d1a3a21
21 changed files with 852 additions and 189 deletions
+96 -1
View File
@@ -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(