完美像素结果上传前增加只读预检
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 2m46s
Project CI / Native shell tests (pull_request) Successful in 14m17s

拆分最终 PNG 的纯 prepare 与 OSS 执行阶段。

新增 SpacetimeDB 只读 preflight procedure,并在最终原子事务重复目录、布局、幂等和 revision 校验。

让 preflight、PUT、HEAD 与 persist 共享 60 秒绝对截止,并保持首个 PUT 前不标 unknown。

生成 Rust bindings,补充定向守卫与 TOCTOU 边界文档。
This commit is contained in:
2026-08-04 12:06:15 +00:00
parent aed9a0c4da
commit f29c7d5918
10 changed files with 708 additions and 159 deletions
@@ -6253,3 +6253,12 @@
- 决策:纯 generation-dialog 的右键删除在任何历史或选择副作用前委托给 `requestRemoveCanvasGenerationDialog`。未收口完美像素只激活原占位并显示继续对账/原样重试提示;普通 generating 进入现有确认弹窗;终态占位才执行真实删除。层命令保留低层回调给快捷键和混合选择的既有可删除目标,不扩大本次改动为删除系统重构。
- 验证:层命令定向测试构造 `pending-confirmation + perfectPixelOperation` 右键目标,断言请求保护入口只调用一次、历史为零、选择保持、低层删除未调用且菜单收口。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
## 2026-08-04 完美像素最终 PNG 在 PUT 前执行只读 preflight
- 缺陷:最终 PNG object key 虽已稳定,目录、画布 completion 和布局大小门禁仍只在 OSS PUT / HEAD 之后的原子 procedure 内判定。可预知的自定义目录缺失、目录越权、重复 dialog 或 2 MiB / 512 KiB 布局拒绝会先产生无引用 OSS object,再返回确定失败。
- 决策:上传 helper 拆成纯 prepare 与 execute。prepare 只生成精确 object key / request,不访问 OSS;handler 用同一 object key 构造候选 project resource,调用受 runtime service identity 保护的只读 `preflight_editor_pixel_art_result_and_return`。preflight 允许尚未创建的默认目录,要求自定义目录存在且属于 owner,复用 `plan_editor_pixel_art_canvas_completion`,并对 legacy / structured 结果布局执行 2 MiB 总量与 512 KiB 单项门禁。通过后才执行 PUT / HEAD,再调用既有原子 persist。
- 预算与 unknown 边界:preflight、PUT / HEAD 和最终 persist 共用既有 60 秒绝对 deadline。preflight 失败或超时发生在第一次 PUT 之前,不带 `resultPersistenceStarted`;从第一次 PUT 发出开始继续沿用 unknown 标记和项目 GET 对账。
- 权威性与剩余风险:preflight 不创建锁、reservation 或新表记录;最终 `persist_editor_pixel_art_result_and_return` 仍在同一事务内重复目录、布局、幂等 identity 和 revision 校验。preflight 通过后若目录或画布并发漂移,最终事务仍可能在 PUT 后拒绝并留下无引用 OSS object;彻底消除该 TOCTOU 需要 durable reservation / journal 或事务协调,不在本 PR 的最小修复边界内。
- 契约影响:只新增 SpacetimeDB procedure ABI 与生成 bindings;没有表字段、index、migration、HTTP DTO、路由、状态码、OpenAPI 或 shared-contracts 变化。
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
@@ -52,7 +52,7 @@
- 前端提交前先创建关闭 composer 的右侧生成占位,再解析或上传源图以取得稳定引用,随后把版本化 `perfectPixelOperation` 请求快照写入该占位并 flush 当前项目布局,最后才发送 POST。`canvasCompletion.dialogId` 同时作为 operation identity、稳定 task identity 的输入和本地源图上传 ID;同一 operation 的上传路径与后续 POST 请求都不得随机漂移。`sourceImageSrc` 优先由当前图层已有的 `objectKey / resourceId / sourceAssetId` 解析;尚未登记的浏览器本地图片只执行 `ticket → OSS PUT → confirm → objectKey`,不为这条持久化输入换取 signed URL。一个 `AbortSignal` 必须贯穿源文件 fetch / 图片解析边界、ticket、PUT、confirm,完整上传 helper 的可选换签也必须透传同一 signal。正式请求不得包含 `data:` / `blob:`、signed URL 或普通外链。后端在读取源图前必须把该字段解析为当前 owner 已登记的私有 OSS object key,并核对 project / resource / asset 归属。
- 源准备与 operation journal 使用两段绝对预算:`ticket → PUT → confirm` 连同源解析共用 90 秒;confirm 成功并形成稳定 `perfectPixelOperation` 后,strict layout save 另有 60 秒,覆盖等待既有保存、PATCH、冲突 GET 和重试退避。strict waiter 到期必须协作取消当前请求并释放本地保存队列;远端已经收到的迟到 PATCH 无法撤销,但不得再触发后续完美像素 POST。strict revision ACK 前 POST 和结果对账 GET 均为零。此阶段失败持久化为 `failed + perfectPixelOperation`,保留同一 `sourceImageSrc / dialogId / taskId / request`,普通按钮不得创建第二个 operation;用户只能从原占位重试,重试请求必须与 journal 中的 POST JSON byte-for-byte 一致且不得重新上传。confirm 成功后浏览器在 operation 首次 PATCH 落库前立即崩溃仍可能留下 object-only 记录;完全消除该窗口需要服务端 durable upload journal,不属于当前前端修复。
- 该已有图片入口使用 strict 语义:只接受静态 PNG / JPEG / WebP,GIF、APNG、动画 WebP、图片序列及其它非静态媒体必须在处理前拒绝。strict 与生成风格复用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码;仅当横纵两轴都未检测到步长、legacy 即将使用 `min(width,height)/64` 统一网格兜底时拒绝。任一轴已检测到步长时,两条路径行为和输出必须一致。源图读取、解码、尺寸校验、排队、像素规整或 PNG 编码任一步失败 / 超时 / 不适用时,请求失败,不保留原图副本冒充成功,不执行最终 OSS PUT,也不创建 project resource、账号素材或结果图层。成功时只对最终 PNG 执行一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`,再按 `canvasCompletion` 写回一个派生图层;不得保存逻辑低分辨率图、诊断图或前后对比图。
- strict 的本次结果事实零写入边界截至首个最终 PNG PUT:所有可预判的引用、归属、类型、静态编码、元数据、网格适用性和 CPU 处理错误必须在此前失败;前置 owner-scoped 项目 / 素材读取仍可能按既有语义懒建默认 canvas / folder,这些基础记录不属于本次完美像素结果。最终 PNG 的 OSS PUT / HEAD 位于数据库事务外;验证上传结果后,asset object、project resource、账号素材与可选 canvas completion 由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中原子提交。operation 以 `owner + project + canvasCompletion.dialogId` 为作用域,task / object / resource / asset ID 稳定派生,object key 携带规范请求与输入 / 输出摘要形成的 fingerprint;同内容重放只返回原结果,输入漂移或部分既有事实失败关闭。HTTP timeout/drop 不能撤销已发往远端的 procedure,客户端仍须按稳定 `taskId / objectKey / resourceId` 对账,不能把未收到回包等同于未提交。
- strict 的本次结果事实零写入边界截至首个最终 PNG PUT:所有可预判的引用、归属、类型、静态编码、元数据、网格适用性和 CPU 处理错误必须在此前失败;前置 owner-scoped 项目 / 素材读取仍可能按既有语义懒建默认 canvas / folder,这些基础记录不属于本次完美像素结果。后端先纯计算精确 object key 和候选 project resource,再调用只读 SpacetimeDB preflight 校验自定义素材目录归属、复用权威 completion planner,并执行 legacy / structured 的 2 MiB 总量与 512 KiB 单项门禁;默认目录尚未创建时允许通过,preflight 不写库。preflight 与 PUT / HEAD / 原子 persist 共用 60 秒绝对 deadline;preflight 失败或超时不得 PUT,也不得带 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 位于数据库事务外;验证上传结果后,asset object、project resource、账号素材与可选 canvas completion 由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中原子提交,并重新校验目录、布局、幂等身份与 revision。preflight 不加锁或 reservation,所以通过后若目录或画布并发漂移,最终事务仍可能在 PUT 后拒绝并留下 OSS 孤儿对象;这是本次最小修复明确保留的 TOCTOU 边界。operation 以 `owner + project + canvasCompletion.dialogId` 为作用域,task / object / resource / asset ID 稳定派生,object key 携带规范请求与输入 / 输出摘要形成的 fingerprint;同内容重放只返回原结果,输入漂移或部分既有事实失败关闭。HTTP timeout/drop 不能撤销已发往远端的 procedure,客户端仍须按稳定 `taskId / objectKey / resourceId` 对账,不能把未收到回包等同于未提交。
- `POST /api/editor/images/pixel-art-snaps` 是有副作用的 unsafe POST。客户端不得为它配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前以 `401` 拒绝、刷新 token 后的既有认证恢复不属于业务副作用重放,保持通用行为。POST 回包中的 `project / resource / asset` 不是结果 verdict;首次成功回包、未知异常、人工 exact replay 和刷新恢复都只读取项目 GET。`perfectPixelOperation.submittedAt / reconcileUntil` 从稳定请求快照写入时建立统一 75 秒绝对窗口,POST 回包不能续期;读取必须立即执行一次,随后退避间隔不超过 5 秒,窗口已过期时仍执行一次即时 GET。每次项目读取使用 `requestJson.deadlineAt` 覆盖缺 token 补票、业务 fetch、401 refresh、重试退避与响应体读取;窗口内单次最多 10 秒且不得越过 `reconcileUntil`,过期后的唯一即时读取最多额外 10 秒。固定判据为:匹配 task 的唯一 resource 加已收口 dialog / 关联图层才是画布成功;dialog 不存在但存在匹配 task resource 才是 asset-only 成功;dialog 仍 generating、dialog 不存在且无匹配 resource、项目始终不可读或窗口耗尽均保持 unknown。素材库刷新只在项目终态后 fire-and-forget,同步抛错、异步拒绝或永久挂起都不得阻塞 verdict、项目快照应用和执行锁释放。
- unknown 状态持久化为原 generation dialog 上的 `pending-confirmation + perfectPixelOperation`,普通删除和随源图层清理不得移除该 operation;用户只能继续 GET 对账或显式按原 identity 重放。人工重试只刷新观察窗口,POST JSON 必须与持久请求 byte-for-byte 一致,不得按当前画布、目录、类型或标题重建,也不得创建第二个 dialog / task / object / resource / asset。hydrate 后只做 GET,不自动 POST、上传或重建请求。处理成功但事务内权威 dialog 已删除时,后端保留 object / resource / asset 并返回 asset-only 事实,canvas / revision 不变;前端只有在项目 GET 看见匹配 task resource 后才能提示“已保存到素材库”。现有布局 CAS 没有 deletion tombstone,completion 与其它已持久化布局编辑冲突时继续按权威 revision 守卫收口;尚未防抖落库的本地编辑合并不在本批范围。
- 删除 generation dialog 的按钮、快捷键和右键菜单必须在写画布历史、清选择或执行低层移除前经过同一请求保护入口。未收口完美像素 operation 只激活原占位并提示继续对账/原样重试,不写 `delete-generation-result` 伪历史、不清选择也不移除 identity;普通 generating dialog 继续进入既有删除确认,只有可立即删除的终态占位才真正写历史并清理。
File diff suppressed because one or more lines are too long
+230 -96
View File
@@ -4,7 +4,7 @@ use std::{
io::Cursor,
sync::{
Arc, LazyLock,
atomic::{AtomicUsize, Ordering},
atomic::{AtomicBool, AtomicUsize, Ordering},
},
time::{Duration, Instant},
};
@@ -39,8 +39,8 @@ use shared_contracts::assets::{
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::editor_project::{
EditorPixelArtCanvasCompletionRecordInput, EditorPixelArtCanvasPlaceholderRecordInput,
EditorPixelArtResultPersistRecordInput, EditorSpritesheetSliceBatchPersistRecordInput,
EditorSpritesheetSlicePersistItemRecordInput,
EditorPixelArtResultPersistRecordInput, EditorPixelArtResultPreflightRecordInput,
EditorSpritesheetSliceBatchPersistRecordInput, EditorSpritesheetSlicePersistItemRecordInput,
};
use spacetime_client::{
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
@@ -81,7 +81,10 @@ use crate::{
},
generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
adapter::{
GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput,
GeneratedImageAssetPreparedPut,
},
decode_generated_image_asset_data_url, normalize_generated_image_asset_mime,
},
http_error::AppError,
@@ -5189,6 +5192,45 @@ pub async fn snap_editor_image_to_pixel_art(
original_height: payload.canvas_completion.placeholder.original_height,
},
};
let fingerprint_file_stem = format!(
"perfect-pixel-{}",
persistence_identity.operation_fingerprint
);
let prepared_upload = prepare_editor_generated_image_object_data(
owner_user_id.as_str(),
persistence_identity.task_id.as_str(),
GeneratedImageAssetDataUrl {
format: normalize_generated_image_asset_mime(snapped_image.mime_type.as_str()),
bytes: snapped_image.bytes,
},
EDITOR_PIXEL_ART_SNAP_ASSET_KIND,
"pixel-art-snaps",
fingerprint_file_stem.as_str(),
"result",
"genarrative",
)?;
let prepared_object_key = prepared_upload.storage_paths.object_key.clone();
let image_src = editor_media_src_from_object_key(prepared_object_key.as_str());
let mut project_resource = EditorProjectResourceCreateRecordInput {
resource_id: persistence_identity.resource_id.clone(),
project_id: project_id.clone(),
owner_user_id: owner_user_id.clone(),
asset_object_id: Some(persistence_identity.asset_object_id.clone()),
image_src: image_src.clone(),
object_key: Some(prepared_object_key),
width,
height,
source_type: "generated".to_string(),
prompt: Some("完美像素".to_string()),
actual_prompt: None,
model: Some(EDITOR_PIXEL_ART_SNAP_MODEL.to_string()),
provider: Some(EDITOR_PIXEL_ART_SNAP_PROVIDER.to_string()),
task_id: Some(persistence_identity.task_id.clone()),
source_resource_id: source_resource_id.clone(),
asset_kind: asset_kind.clone(),
generation_inputs_json: generation_inputs_json.clone(),
updated_at_micros: current_utc_micros(),
};
// 中文注释:持久化阶段此前完全无界,只受 OSS 客户端每请求 120 秒约束,而 PUT 与 HEAD
// 各自独立计时,加上多次无超时 SpacetimeDB 调用,服务端最坏合法时长可达 270 秒以上,
// 远超客户端 120 秒——客户端会在服务端仍在合法工作时先放弃,对账因此采样到一个仍在
@@ -5201,36 +5243,72 @@ pub async fn snap_editor_image_to_pixel_art(
let persistence_deadline = persistence_started_at
.checked_add(EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION)
.unwrap_or(persistence_started_at);
let persistence_task_id = response_task_id.clone();
let persisted = tokio::time::timeout_at(
// 中文注释:preflight 与 PUT/HEAD/原子 persist 共用同一个绝对 deadline。preflight
// 是只读 procedure,失败或超时证明第一次 PUT 尚未发出,因此不得附加 unknown 标记。
tokio::time::timeout_at(
tokio::time::Instant::from_std(persistence_deadline),
async move {
let fingerprint_file_stem = format!(
"perfect-pixel-{}",
persistence_identity.operation_fingerprint
state.spacetime_client().preflight_editor_pixel_art_result(
EditorPixelArtResultPreflightRecordInput {
owner_user_id: owner_user_id.clone(),
project_id: project_id.clone(),
asset_folder_id: asset_folder_id.clone(),
project_resource: project_resource.clone(),
canvas_completion: canvas_completion.clone(),
},
),
)
.await
.map_err(|_| {
editor_pixel_art_snap_failure(
StatusCode::GATEWAY_TIMEOUT,
"完美像素结果持久化预检超出处理预算。",
)
})?
.map_err(map_editor_project_error)?;
// 中文注释:Tokio Timeout 会先 poll 内层 future;preflight 与截止同时 ready 时,
// timeout_at 仍可能返回成功。进入任何上传 future 前必须再检查一次绝对截止,避免
// 第二个 timeout_at 首次 poll 内层并发出已经超预算的 PUT。
if Instant::now() >= persistence_deadline {
return Err(editor_pixel_art_snap_failure(
StatusCode::GATEWAY_TIMEOUT,
"完美像素结果持久化预检超出处理预算。",
));
}
let persistence_task_id = response_task_id.clone();
let result_persistence_started = Arc::new(AtomicBool::new(false));
let upload_persistence_started = Arc::clone(&result_persistence_started);
let persisted = tokio::select! {
// 中文注释:截止与上传同时 ready 时先选 timer,确保尚未开始的 PUT 不会被首轮 poll。
biased;
_ = tokio::time::sleep_until(tokio::time::Instant::from_std(persistence_deadline)) => {
let error = editor_pixel_art_snap_failure(
StatusCode::GATEWAY_TIMEOUT,
"完美像素结果持久化超出处理预算。",
);
let mut uploaded = upload_editor_generated_image_object_data(
Err(if result_persistence_started.load(Ordering::Acquire) {
error.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
} else {
error
})
}
result = async move {
let mut uploaded = upload_editor_generated_image_object_prepared(
&state,
owner_user_id.as_str(),
persistence_identity.task_id.as_str(),
GeneratedImageAssetDataUrl {
format: normalize_generated_image_asset_mime(snapped_image.mime_type.as_str()),
bytes: snapped_image.bytes,
},
prepared_upload,
"完美像素",
EDITOR_PIXEL_ART_SNAP_ASSET_KIND,
"pixel-art-snaps",
fingerprint_file_stem.as_str(),
"result",
"genarrative",
Some(persistence_identity.asset_object_id.clone()),
Some(upload_persistence_started.as_ref()),
)
.await?;
let completed_at_micros = current_utc_micros();
uploaded.asset_object.content_type = Some("image/png".to_string());
uploaded.asset_object.content_hash = Some(output_image_sha256);
uploaded.asset_object.updated_at_micros = completed_at_micros;
let image_src = editor_media_src_from_object_key(uploaded.object_key.as_str());
project_resource.updated_at_micros = completed_at_micros;
state
.spacetime_client()
.persist_editor_pixel_art_result(EditorPixelArtResultPersistRecordInput {
@@ -5239,26 +5317,7 @@ pub async fn snap_editor_image_to_pixel_art(
operation_id: persistence_identity.operation_id,
operation_fingerprint: persistence_identity.operation_fingerprint,
asset_object: uploaded.asset_object,
project_resource: EditorProjectResourceCreateRecordInput {
resource_id: persistence_identity.resource_id.clone(),
project_id: project_id.clone(),
owner_user_id: owner_user_id.clone(),
asset_object_id: Some(persistence_identity.asset_object_id.clone()),
image_src: image_src.clone(),
object_key: Some(uploaded.object_key.clone()),
width,
height,
source_type: "generated".to_string(),
prompt: Some("完美像素".to_string()),
actual_prompt: None,
model: Some(EDITOR_PIXEL_ART_SNAP_MODEL.to_string()),
provider: Some(EDITOR_PIXEL_ART_SNAP_PROVIDER.to_string()),
task_id: Some(persistence_identity.task_id.clone()),
source_resource_id: source_resource_id.clone(),
asset_kind: asset_kind.clone(),
generation_inputs_json: generation_inputs_json.clone(),
updated_at_micros: completed_at_micros,
},
project_resource,
asset: EditorAssetCreateRecordInput {
asset_id: persistence_identity.asset_id,
owner_user_id: owner_user_id.clone(),
@@ -5298,18 +5357,8 @@ pub async fn snap_editor_image_to_pixel_art(
map_editor_project_error(error)
.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
})
},
)
.await
// 中文注释:超时发生在 PUT 已经发出之后,对象可能已经落盘,也可能 procedure 已在远端
// 原子提交但响应尚未回来。两者都属于未知结果,必须保留标记让客户端进入权威对账。
.map_err(|_| {
editor_pixel_art_snap_failure(
StatusCode::GATEWAY_TIMEOUT,
"完美像素结果持久化超出处理预算。",
)
.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
})??;
} => result,
}?;
let image_src = editor_media_src_from_object_key(persisted.asset_object.object_key.as_str());
let resource = editor_project_resource_payload_from_record(persisted.project_resource);
@@ -9694,6 +9743,46 @@ struct UploadedEditorGeneratedImageObject {
asset_object: AssetObjectUpsertInput,
}
#[allow(clippy::too_many_arguments)]
fn prepare_editor_generated_image_object_data(
owner_user_id: &str,
task_id: &str,
image: GeneratedImageAssetDataUrl,
asset_kind: &str,
path_kind: &str,
file_stem: &str,
slot: &str,
provider: &str,
) -> Result<GeneratedImageAssetPreparedPut, AppError> {
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
prefix: LegacyAssetPrefix::CharacterDrafts,
path_segments: vec![
"editor".to_string(),
sanitize_editor_storage_segment(path_kind, "generated-images"),
sanitize_editor_storage_segment(task_id, "task"),
],
file_stem: sanitize_editor_storage_segment(file_stem, "image"),
image,
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some(asset_kind.to_string()),
owner_user_id: Some(owner_user_id.to_string()),
entity_kind: Some(EDITOR_CHARACTER_IMAGE_ENTITY_KIND.to_string()),
entity_id: Some(task_id.to_string()),
slot: Some(slot.to_string()),
provider: Some(provider.to_string()),
task_id: Some(task_id.to_string()),
},
extra_metadata: BTreeMap::from([("source".to_string(), "image-canvas-editor".to_string())]),
})
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "generated-image-assets",
"message": format!("准备画板生成图片 OSS 上传请求失败:{error:?}"),
}))
})
}
struct PersistEditorProviderSourceResourceInput {
project_id: Option<String>,
owner_user_id: String,
@@ -9835,6 +9924,39 @@ async fn upload_editor_generated_image_object_data(
slot: &str,
provider: &str,
asset_object_id: Option<String>,
) -> Result<UploadedEditorGeneratedImageObject, AppError> {
let prepared = prepare_editor_generated_image_object_data(
owner_user_id,
task_id,
image,
asset_kind,
path_kind,
file_stem,
slot,
provider,
)?;
upload_editor_generated_image_object_prepared(
state,
owner_user_id,
task_id,
prepared,
prompt,
asset_kind,
asset_object_id,
None,
)
.await
}
async fn upload_editor_generated_image_object_prepared(
state: &AppState,
owner_user_id: &str,
task_id: &str,
prepared: GeneratedImageAssetPreparedPut,
prompt: &str,
asset_kind: &str,
asset_object_id: Option<String>,
result_persistence_started: Option<&AtomicBool>,
) -> Result<UploadedEditorGeneratedImageObject, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
@@ -9842,37 +9964,6 @@ async fn upload_editor_generated_image_object_data(
"reason": "OSS 未完成环境变量配置",
}))
})?;
let prepared =
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
prefix: LegacyAssetPrefix::CharacterDrafts,
path_segments: vec![
"editor".to_string(),
sanitize_editor_storage_segment(path_kind, "generated-images"),
sanitize_editor_storage_segment(task_id, "task"),
],
file_stem: sanitize_editor_storage_segment(file_stem, "image"),
image,
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some(asset_kind.to_string()),
owner_user_id: Some(owner_user_id.to_string()),
entity_kind: Some(EDITOR_CHARACTER_IMAGE_ENTITY_KIND.to_string()),
entity_id: Some(task_id.to_string()),
slot: Some(slot.to_string()),
provider: Some(provider.to_string()),
task_id: Some(task_id.to_string()),
},
extra_metadata: BTreeMap::from([(
"source".to_string(),
"image-canvas-editor".to_string(),
)]),
})
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "generated-image-assets",
"message": format!("准备画板生成图片 OSS 上传请求失败:{error:?}"),
}))
})?;
let persisted_mime_type = prepared.format.mime_type.clone();
// 中文注释:写路径此前也是每次新建 client,PUT 与 HEAD 都没有超时——请求可以在
// 上传阶段无限期挂住,而这一段发生在 CPU 处理之后,任何按处理预算派生的 deadline
@@ -9883,6 +9974,9 @@ async fn upload_editor_generated_image_object_data(
// 第一次 PUT 之前,标了会让客户端对着什么都没落库的失败去核对素材库,是反向谎报;从
// PUT 开始(含 PUT 自身——响应丢失时字节可能已经落盘)到本函数返回,一律标记。
// 调用方拿到的是同一个 AppError,无法自行区分内部走到了哪一步,所以只能在这里标。
if let Some(result_persistence_started) = result_persistence_started {
result_persistence_started.store(true, Ordering::Release);
}
let put_result = oss_client
.put_object(http_client, prepared.request)
.await
@@ -13125,19 +13219,41 @@ mod tests {
"validate_editor_pixel_art_static_raster",
"snap_editor_pixel_art_strict",
"Some(processing_deadline)",
// 中文注释:持久化整段必须先被第二个 timeout_at 包住再开始写。此前这一段
// 完全无界,只受 OSS 客户端每请求 120 秒约束,服务端最坏合法时长超过客户端
// 超时,客户端会先 abort 而服务端还在合法工作。顺序断言把「预算在前、写入在后」
// 钉死:任何把 persist 挪到 timeout_at 之前的改动都会失败。
// 中文注释:prepare 只计算精确 object key;只读 preflight 与后续
// PUT/HEAD/原子 persist 共用第二份 60 秒绝对 deadline。preflight 必须发生
// 在第一次外部写之前,避免已知的目录/布局拒绝留下 OSS 孤儿对象。
"prepare_editor_generated_image_object_data(",
"EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION",
"tokio::time::timeout_at(",
"upload_editor_generated_image_object_data",
".preflight_editor_pixel_art_result(",
"if Instant::now() >= persistence_deadline",
"tokio::select!",
"biased;",
"upload_editor_generated_image_object_prepared(",
".persist_editor_pixel_art_result(",
// 中文注释:持久化超时同样发生在 PUT 之后,属于未知结果,必须带标记。
"完美像素结果持久化超出处理预算。",
"EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL",
],
);
assert_function_contains_in_order(
source,
"pub async fn snap_editor_image_to_pixel_art(",
"async fn validate_editor_background_removal_source",
&[
// 中文注释:timer 分支不能因为 preflight 已成功就谎报已 PUT;只有执行
// helper 在第一次 PUT 前置位后,持久化超时才附加 unknown 标记。
"AtomicBool::new(false)",
"tokio::select!",
"result_persistence_started.load(Ordering::Acquire)",
"EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL",
"upload_editor_generated_image_object_prepared(",
],
);
assert_function_occurrence_count(
source,
"pub async fn snap_editor_image_to_pixel_art(",
"async fn validate_editor_background_removal_source",
".preflight_editor_pixel_art_result(",
1,
);
assert_function_occurrence_count(
source,
"pub async fn snap_editor_image_to_pixel_art(",
@@ -13191,7 +13307,8 @@ mod tests {
&[
// 中文注释:标记只能出现在第一次 PUT 之后。出现在 persist 之前说明有纯
// 校验失败被误标成「可能已落库」,会让常见的 400 也触发多余的对账读取。
"upload_editor_generated_image_object_data(",
".preflight_editor_pixel_art_result(",
"upload_editor_generated_image_object_prepared(",
"EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL",
],
);
@@ -16993,14 +17110,31 @@ mod tests {
"EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL",
3,
);
// 中文注释:标记必须在第一次 PUT 之后才出现。prepare_put_object 与 OSS 未配置这两处
// 失败都在 PUT 之前,标了是反向谎报——会让客户端对着什么都没落库的失败去核对素材库。
// 中文注释:prepare 只能计算 request/object key,不得访问 OSS 或谎报已经开始持久化。
assert_function_contains(
source,
"fn prepare_editor_generated_image_object_data",
"struct PersistEditorProviderSourceResourceInput",
&["prepare_put_object"],
);
assert_function_not_contains(
source,
"fn prepare_editor_generated_image_object_data",
"struct PersistEditorProviderSourceResourceInput",
&[
"EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL",
".put_object(",
".head_object(",
],
);
// 中文注释:执行 helper 的标记必须在第一次 PUT 之后才出现。OSS 未配置仍发生在 PUT
// 之前,标了是反向谎报——会让客户端对着什么都没落库的失败去核对素材库。
assert_function_contains_in_order(
source,
"async fn persist_editor_generated_image_data",
"async fn upload_editor_generated_image_object_prepared",
"async fn persist_editor_provider_source_image",
&[
"prepare_put_object",
"result_persistence_started.store(true, Ordering::Release)",
".put_object(http_client, prepared.request)",
"EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL",
],
@@ -17,6 +17,15 @@ pub struct EditorPixelArtCanvasCompletionRecordInput {
pub placeholder: EditorPixelArtCanvasPlaceholderRecordInput,
}
#[derive(Clone, Debug, PartialEq)]
pub struct EditorPixelArtResultPreflightRecordInput {
pub owner_user_id: String,
pub project_id: String,
pub asset_folder_id: String,
pub project_resource: EditorProjectResourceCreateRecordInput,
pub canvas_completion: EditorPixelArtCanvasCompletionRecordInput,
}
#[derive(Clone, Debug, PartialEq)]
pub struct EditorPixelArtResultPersistRecordInput {
pub owner_user_id: String,
@@ -65,6 +74,20 @@ impl From<EditorPixelArtCanvasCompletionRecordInput>
}
}
impl From<EditorPixelArtResultPreflightRecordInput>
for crate::module_bindings::EditorPixelArtResultPreflightInput
{
fn from(input: EditorPixelArtResultPreflightRecordInput) -> Self {
Self {
owner_user_id: input.owner_user_id,
project_id: input.project_id,
asset_folder_id: input.asset_folder_id,
project_resource: input.project_resource.into(),
canvas_completion: input.canvas_completion.into(),
}
}
}
impl From<EditorPixelArtResultPersistRecordInput>
for crate::module_bindings::EditorPixelArtResultPersistInput
{
@@ -140,6 +163,31 @@ impl From<EditorSpritesheetSliceBatchPersistRecordInput>
}
impl SpacetimeClient {
pub async fn preflight_editor_pixel_art_result(
&self,
input: EditorPixelArtResultPreflightRecordInput,
) -> Result<(), SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(
"preflight_editor_pixel_art_result_and_return",
move |connection, sender| {
connection
.procedures()
.preflight_editor_pixel_art_result_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_pixel_art_result_preflight_result);
send_once(&sender, mapped);
},
);
},
)
.await
}
pub async fn persist_editor_pixel_art_result(
&self,
input: EditorPixelArtResultPersistRecordInput,
@@ -1089,6 +1137,15 @@ impl SpacetimeClient {
}
}
fn map_editor_pixel_art_result_preflight_result(
result: crate::module_bindings::EditorPixelArtResultPreflightResult,
) -> Result<(), SpacetimeClientError> {
if result.ok {
return Ok(());
}
Err(SpacetimeClientError::procedure_failed(result.error_message))
}
fn map_editor_pixel_art_result_persist_result(
result: crate::module_bindings::EditorPixelArtResultPersistResult,
) -> Result<EditorPixelArtResultPersistRecord, SpacetimeClientError> {
@@ -302,6 +302,8 @@ pub mod editor_pixel_art_canvas_placeholder_input_type;
pub mod editor_pixel_art_result_persist_input_type;
pub mod editor_pixel_art_result_persist_result_type;
pub mod editor_pixel_art_result_persist_status_type;
pub mod editor_pixel_art_result_preflight_input_type;
pub mod editor_pixel_art_result_preflight_result_type;
pub mod editor_project_create_input_type;
pub mod editor_project_delete_input_type;
pub mod editor_project_delete_procedure_result_type;
@@ -485,6 +487,7 @@ pub mod persist_editor_spritesheet_slice_batch_and_return_procedure;
pub mod player_progression_grant_source_type;
pub mod player_progression_table;
pub mod player_progression_type;
pub mod preflight_editor_pixel_art_result_and_return_procedure;
pub mod prepare_profile_recharge_refund_hold_and_return_procedure;
pub mod preview_profile_recharge_refund_hold_and_return_procedure;
pub mod profile_code_operation_table;
@@ -1131,6 +1134,8 @@ pub use editor_pixel_art_canvas_placeholder_input_type::EditorPixelArtCanvasPlac
pub use editor_pixel_art_result_persist_input_type::EditorPixelArtResultPersistInput;
pub use editor_pixel_art_result_persist_result_type::EditorPixelArtResultPersistResult;
pub use editor_pixel_art_result_persist_status_type::EditorPixelArtResultPersistStatus;
pub use editor_pixel_art_result_preflight_input_type::EditorPixelArtResultPreflightInput;
pub use editor_pixel_art_result_preflight_result_type::EditorPixelArtResultPreflightResult;
pub use editor_project_create_input_type::EditorProjectCreateInput;
pub use editor_project_delete_input_type::EditorProjectDeleteInput;
pub use editor_project_delete_procedure_result_type::EditorProjectDeleteProcedureResult;
@@ -1314,6 +1319,7 @@ pub use persist_editor_spritesheet_slice_batch_and_return_procedure::persist_edi
pub use player_progression_grant_source_type::PlayerProgressionGrantSource;
pub use player_progression_table::*;
pub use player_progression_type::PlayerProgression;
pub use preflight_editor_pixel_art_result_and_return_procedure::preflight_editor_pixel_art_result_and_return;
pub use prepare_profile_recharge_refund_hold_and_return_procedure::prepare_profile_recharge_refund_hold_and_return;
pub use preview_profile_recharge_refund_hold_and_return_procedure::preview_profile_recharge_refund_hold_and_return;
pub use profile_code_operation_table::*;
@@ -5073,19 +5079,19 @@ impl __sdk::SubscriptionHandle for SubscriptionHandle {
/// either a [`DbConnection`] or an [`EventContext`] and operate on either.
pub trait RemoteDbContext:
__sdk::DbContext<
DbView = RemoteTables,
Reducers = RemoteReducers,
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
>
DbView = RemoteTables,
Reducers = RemoteReducers,
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
>
{
}
impl<
Ctx: __sdk::DbContext<
Ctx: __sdk::DbContext<
DbView = RemoteTables,
Reducers = RemoteReducers,
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
>,
> RemoteDbContext for Ctx
> RemoteDbContext for Ctx
{
}
@@ -0,0 +1,22 @@
// 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_pixel_art_canvas_completion_input_type::EditorPixelArtCanvasCompletionInput;
use super::editor_project_resource_create_input_type::EditorProjectResourceCreateInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct EditorPixelArtResultPreflightInput {
pub owner_user_id: String,
pub project_id: String,
pub asset_folder_id: String,
pub project_resource: EditorProjectResourceCreateInput,
pub canvas_completion: EditorPixelArtCanvasCompletionInput,
}
impl __sdk::InModule for EditorPixelArtResultPreflightInput {
type Module = super::RemoteModule;
}
@@ -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 EditorPixelArtResultPreflightResult {
pub ok: bool,
pub error_message: Option<String>,
}
impl __sdk::InModule for EditorPixelArtResultPreflightResult {
type Module = super::RemoteModule;
}
@@ -0,0 +1,62 @@
// 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_pixel_art_result_preflight_input_type::EditorPixelArtResultPreflightInput;
use super::editor_pixel_art_result_preflight_result_type::EditorPixelArtResultPreflightResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct PreflightEditorPixelArtResultAndReturnArgs {
pub input: EditorPixelArtResultPreflightInput,
}
impl __sdk::InModule for PreflightEditorPixelArtResultAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `preflight_editor_pixel_art_result_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait preflight_editor_pixel_art_result_and_return {
fn preflight_editor_pixel_art_result_and_return(
&self,
input: EditorPixelArtResultPreflightInput,
) {
self.preflight_editor_pixel_art_result_and_return_then(input, |_, _| {});
}
fn preflight_editor_pixel_art_result_and_return_then(
&self,
input: EditorPixelArtResultPreflightInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorPixelArtResultPreflightResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl preflight_editor_pixel_art_result_and_return for super::RemoteProcedures {
fn preflight_editor_pixel_art_result_and_return_then(
&self,
input: EditorPixelArtResultPreflightInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorPixelArtResultPreflightResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, EditorPixelArtResultPreflightResult>(
"preflight_editor_pixel_art_result_and_return",
PreflightEditorPixelArtResultAndReturnArgs { input },
__callback,
);
}
}
@@ -899,6 +899,21 @@ pub struct EditorPixelArtCanvasCompletionInput {
pub placeholder: EditorPixelArtCanvasPlaceholderInput,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct EditorPixelArtResultPreflightInput {
pub owner_user_id: String,
pub project_id: String,
pub asset_folder_id: String,
pub project_resource: EditorProjectResourceCreateInput,
pub canvas_completion: EditorPixelArtCanvasCompletionInput,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorPixelArtResultPreflightResult {
pub ok: bool,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct EditorPixelArtResultPersistInput {
pub owner_user_id: String,
@@ -1576,6 +1591,24 @@ pub fn persist_editor_spritesheet_slice_batch_and_return(
}
}
#[spacetimedb::procedure]
pub fn preflight_editor_pixel_art_result_and_return(
ctx: &mut ProcedureContext,
input: EditorPixelArtResultPreflightInput,
) -> EditorPixelArtResultPreflightResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| preflight_editor_pixel_art_result(tx, caller, input.clone())) {
Ok(()) => EditorPixelArtResultPreflightResult {
ok: true,
error_message: None,
},
Err(message) => EditorPixelArtResultPreflightResult {
ok: false,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn persist_editor_pixel_art_result_and_return(
ctx: &mut ProcedureContext,
@@ -2183,6 +2216,43 @@ fn create_editor_project_resource(
ctx: &ReducerContext,
input: EditorProjectResourceCreateInput,
) -> Result<EditorProjectResourceSnapshot, String> {
let resource = prepare_editor_project_resource(ctx, input)?;
if let Some(existing_resource) = find_reusable_project_resource_for_input(
ctx,
resource.project_id.as_str(),
resource.owner_user_id.as_str(),
resource.source_resource_id.as_ref(),
resource.asset_kind.as_deref(),
resource.asset_object_id.as_ref(),
resource.object_key.as_ref(),
resource.image_src.as_str(),
) {
return Ok(resource_snapshot_from_row(existing_resource));
}
if ctx
.db
.editor_project_resource()
.resource_id()
.find(&resource.resource_id)
.is_some()
{
return Err("画布资源已存在".to_string());
}
let resource_id = resource.resource_id.clone();
ctx.db.editor_project_resource().insert(resource);
ctx.db
.editor_project_resource()
.resource_id()
.find(&resource_id)
.map(resource_snapshot_from_row)
.ok_or_else(|| "画布资源创建失败".to_string())
}
fn prepare_editor_project_resource(
ctx: &ReducerContext,
input: EditorProjectResourceCreateInput,
) -> Result<EditorProjectResource, String> {
let resource_id =
normalize_required(&input.resource_id, "editor_project_resource.resource_id")?;
let project_id = normalize_required(&input.project_id, "editor_project_resource.project_id")?;
@@ -2210,61 +2280,29 @@ fn create_editor_project_resource(
let task_id = normalize_optional(input.task_id);
let asset_kind = normalize_optional(input.asset_kind);
let generation_inputs_json = normalize_optional(input.generation_inputs_json);
if let Some(existing_resource) = find_reusable_project_resource_for_input(
ctx,
project_id.as_str(),
owner_user_id.as_str(),
source_resource_id.as_ref(),
asset_kind.as_deref(),
asset_object_id.as_ref(),
object_key.as_ref(),
image_src.as_str(),
) {
return Ok(resource_snapshot_from_row(existing_resource));
}
if ctx
.db
.editor_project_resource()
.resource_id()
.find(&resource_id)
.is_some()
{
return Err("画布资源已存在".to_string());
}
let public_showcase_enabled = false;
let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
ctx.db
.editor_project_resource()
.insert(EditorProjectResource {
resource_id: resource_id.clone(),
project_id,
owner_user_id,
asset_object_id,
image_src,
object_key,
width: input.width,
height: input.height,
source_type,
prompt,
actual_prompt,
model,
provider,
task_id,
source_resource_id,
created_at: now,
updated_at: now,
asset_kind,
generation_inputs_json,
public_showcase_enabled,
});
ctx.db
.editor_project_resource()
.resource_id()
.find(&resource_id)
.map(resource_snapshot_from_row)
.ok_or_else(|| "画布资源创建失败".to_string())
Ok(EditorProjectResource {
resource_id,
project_id,
owner_user_id,
asset_object_id,
image_src,
object_key,
width: input.width,
height: input.height,
source_type,
prompt,
actual_prompt,
model,
provider,
task_id,
source_resource_id,
created_at: now,
updated_at: now,
asset_kind,
generation_inputs_json,
public_showcase_enabled: false,
})
}
fn repair_editor_project_resource_media(
@@ -2622,6 +2660,83 @@ enum EditorPixelArtCanvasLayoutPlan {
AlreadyApplied,
}
fn preflight_editor_pixel_art_result(
ctx: &ReducerContext,
caller: Identity,
input: EditorPixelArtResultPreflightInput,
) -> Result<(), String> {
require_editor_generation_runtime_service_identity(ctx, caller)?;
let owner_user_id = normalize_required(&input.owner_user_id, "owner_user_id")?;
let project_id = normalize_required(&input.project_id, "project_id")?;
let asset_folder_id = normalize_required(&input.asset_folder_id, "asset_folder_id")?;
require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?;
validate_editor_pixel_art_preflight_asset_folder(
ctx,
asset_folder_id.as_str(),
owner_user_id.as_str(),
)?;
let candidate_resource = prepare_editor_project_resource(ctx, input.project_resource)?;
if candidate_resource.project_id != project_id
|| candidate_resource.owner_user_id != owner_user_id
{
return Err("完美像素 preflight 项目资源与 owner 或项目不一致".to_string());
}
if let Some(source_resource_id) = candidate_resource.source_resource_id.as_deref() {
let source_resource =
require_owned_project_resource(ctx, source_resource_id, owner_user_id.as_str())?;
if source_resource.project_id != project_id
|| source_resource.resource_id == candidate_resource.resource_id
{
return Err("完美像素来源资源不属于同一 owner 与项目".to_string());
}
}
let candidate_snapshot = resource_snapshot_from_row(candidate_resource.clone());
match plan_editor_pixel_art_canvas_completion(
ctx,
project_id.as_str(),
owner_user_id.as_str(),
&input.canvas_completion,
&candidate_snapshot,
)? {
EditorPixelArtCanvasCompletionPlan::Apply {
canvas,
layers_json,
} => validate_editor_pixel_art_planned_canvas_layout(
ctx,
&canvas,
project_id.as_str(),
owner_user_id.as_str(),
layers_json,
&candidate_resource,
),
EditorPixelArtCanvasCompletionPlan::DialogMissing
| EditorPixelArtCanvasCompletionPlan::AlreadyApplied => Ok(()),
}
}
fn validate_editor_pixel_art_preflight_asset_folder(
ctx: &ReducerContext,
folder_id: &str,
owner_user_id: &str,
) -> Result<(), String> {
let default_folder_id = default_asset_folder_id(owner_user_id);
if folder_id == EDITOR_ASSET_DEFAULT_FOLDER_ID || folder_id == default_folder_id {
if let Some(folder) = ctx
.db
.editor_asset_folder()
.folder_id()
.find(&default_folder_id)
&& folder.owner_user_id != owner_user_id
{
return Err("默认素材文件夹不属于当前 owner".to_string());
}
return Ok(());
}
require_owned_asset_folder(ctx, folder_id, owner_user_id).map(|_| ())
}
fn persist_editor_pixel_art_result(
ctx: &ReducerContext,
caller: Identity,
@@ -2711,6 +2826,23 @@ fn persist_editor_pixel_art_result(
canvas,
layers_json,
} => {
let persisted_resource = ctx
.db
.editor_project_resource()
.resource_id()
.find(&project_resource.resource_id)
.ok_or_else(|| "完美像素项目资源不存在,拒绝完成画布".to_string())?;
// 中文注释:preflight 不持有锁或 reservation。PUT 之后进入最终原子事务时,
// 必须对当下的画布、迁移记录和 2 MiB / 512 KiB 布局门禁完整重验,不能把
// PUT 前的只读结论当成提交凭证。
validate_editor_pixel_art_planned_canvas_layout(
ctx,
&canvas,
project_id.as_str(),
owner_user_id.as_str(),
layers_json.clone(),
&persisted_resource,
)?;
persist_editor_project_layout_v2(
ctx,
EditorProjectLayoutSaveV2Input {
@@ -8373,6 +8505,23 @@ fn validate_repaired_editor_canvas_layout_resources(
owner_user_id: &str,
planned_resources: &[EditorProjectResource],
) -> Result<(), String> {
normalize_editor_canvas_layout_with_planned_resources(
ctx,
layers_json,
project_id,
owner_user_id,
planned_resources,
)
.map(|_| ())
}
fn normalize_editor_canvas_layout_with_planned_resources(
ctx: &ReducerContext,
layers_json: &str,
project_id: &str,
owner_user_id: &str,
planned_resources: &[EditorProjectResource],
) -> Result<String, String> {
let mut layout = parse_structured_canvas_layout(layers_json)?;
for layer in &mut layout.layers {
let stored = ctx
@@ -8403,7 +8552,49 @@ fn validate_repaired_editor_canvas_layout_resources(
}
}
}
Ok(())
normalize_layout_json(
serialize_structured_canvas_layout(&layout)
.map_err(|_| "图片画布布局无法序列化".to_string())?,
)
}
fn validate_editor_pixel_art_planned_canvas_layout(
ctx: &ReducerContext,
canvas: &EditorCanvas,
project_id: &str,
owner_user_id: &str,
layers_json: String,
candidate_resource: &EditorProjectResource,
) -> Result<(), String> {
if canvas.layout_storage_version == EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED {
let active_migration = ctx
.db
.editor_canvas_layout_migration()
.canvas_id()
.find(&canvas.canvas_id)
.filter(|migration| migration.status == EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE)
.ok_or_else(|| "结构化图片画布缺少 active 迁移记录,拒绝保存".to_string())?;
let current_structured_json = build_structured_canvas_layout_json(ctx, canvas)?;
let current_structured_hash = canonical_layout_sha256(current_structured_json.as_str())?;
let current_structured_integrity =
canvas_layout_integrity(current_structured_json.as_str())?;
verify_migration_layout(
&active_migration,
canvas.revision,
current_structured_hash.as_str(),
&current_structured_integrity,
)?;
}
normalize_layout_json(layers_json).and_then(|layers_json| {
normalize_editor_canvas_layout_with_planned_resources(
ctx,
layers_json.as_str(),
project_id,
owner_user_id,
std::slice::from_ref(candidate_resource),
)
.map(|_| ())
})
}
fn backfill_editor_canvas_layout(
@@ -9391,6 +9582,58 @@ mod tests {
);
}
#[test]
fn pixel_art_preflight_is_read_only_and_final_transaction_revalidates() {
let source = include_str!("editor_project_storage.rs");
let preflight_start = source
.find("fn preflight_editor_pixel_art_result(\n")
.expect("preflight implementation");
let preflight_end = source[preflight_start..]
.find("fn validate_editor_pixel_art_preflight_asset_folder(")
.map(|offset| preflight_start + offset)
.expect("preflight folder validation boundary");
let preflight = &source[preflight_start..preflight_end];
for required in [
"require_editor_generation_runtime_service_identity",
"validate_editor_pixel_art_preflight_asset_folder",
"prepare_editor_project_resource",
"plan_editor_pixel_art_canvas_completion",
"validate_editor_pixel_art_planned_canvas_layout",
] {
assert!(
preflight.contains(required),
"preflight must retain {required}"
);
}
for forbidden in [".insert(", ".update(", ".delete("] {
assert!(
!preflight.contains(forbidden),
"preflight must remain read-only: {forbidden}"
);
}
let persist_start = source
.find("fn persist_editor_pixel_art_result(\n")
.expect("atomic persist implementation");
let persist_end = source[persist_start..]
.find("fn validate_editor_pixel_art_result_input(")
.map(|offset| persist_start + offset)
.expect("atomic persist validation boundary");
let persist = &source[persist_start..persist_end];
for required in [
"validate_editor_pixel_art_result_input",
"load_editor_pixel_art_existing_records",
"plan_editor_pixel_art_canvas_completion",
"validate_editor_pixel_art_planned_canvas_layout",
"persist_editor_project_layout_v2",
] {
assert!(
persist.contains(required),
"final transaction must revalidate {required}"
);
}
}
#[test]
fn pixel_art_canvas_completion_applies_once_and_replays_without_mutation() {
let layout = json!([