完美像素结果上传前增加只读预检
拆分最终 PNG 的纯 prepare 与 OSS 执行阶段。 新增 SpacetimeDB 只读 preflight procedure,并在最终原子事务重复目录、布局、幂等和 revision 校验。 让 preflight、PUT、HEAD 与 persist 共享 60 秒绝对截止,并保持首个 PUT 前不标 unknown。 生成 Rust bindings,补充定向守卫与 TOCTOU 边界文档。
This commit is contained in:
@@ -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
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
+22
@@ -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;
|
||||
}
|
||||
+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 EditorPixelArtResultPreflightResult {
|
||||
pub ok: bool,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for EditorPixelArtResultPreflightResult {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
+62
@@ -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(),
|
||||
¤t_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!([
|
||||
|
||||
Reference in New Issue
Block a user