From f29c7d591806f552341a5b61e234d6a501c3614d Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 4 Aug 2026 12:06:15 +0000 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E7=BE=8E=E5=83=8F=E7=B4=A0=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E4=B8=8A=E4=BC=A0=E5=89=8D=E5=A2=9E=E5=8A=A0=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E9=A2=84=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拆分最终 PNG 的纯 prepare 与 OSS 执行阶段。 新增 SpacetimeDB 只读 preflight procedure,并在最终原子事务重复目录、布局、幂等和 revision 校验。 让 preflight、PUT、HEAD 与 persist 共享 60 秒绝对截止,并保持首个 PUT 前不标 unknown。 生成 Rust bindings,补充定向守卫与 TOCTOU 边界文档。 --- .../shared-memory/decision-log.md | 9 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- .../crates/api-server/src/editor_project.rs | 326 +++++++++++----- .../spacetime-client/src/editor_project.rs | 57 +++ .../spacetime-client/src/module_bindings.rs | 18 +- ...r_pixel_art_result_preflight_input_type.rs | 22 ++ ..._pixel_art_result_preflight_result_type.rs | 16 + ...r_pixel_art_result_and_return_procedure.rs | 62 +++ .../src/editor_project_storage.rs | 353 +++++++++++++++--- 10 files changed, 708 insertions(+), 159 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_pixel_art_result_and_return_procedure.rs diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8c03f785c..045823f69 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 87d8964c8..4ca65d48c 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/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 继续进入既有删除确认,只有可立即删除的终态占位才真正写历史并清理。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index b2bdca2a4..bc3976e7f 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -249,7 +249,7 @@ npm run check:server-rs-ddd ## 外部服务与资产 - 已有图片完美像素化:登录态 `POST /api/editor/images/pixel-art-snaps` 使用 `sourceImageSrc` 承载 `objectKey / resourceId / assetId` 候选稳定引用,要求 `projectId / canvasCompletion` 且 `canvasCompletion.dialogId` 必须非空,并可携带 `sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel`;BFF 必须在下载前将候选解析为当前 owner 已登记的私有 OSS object key,并校验 project / resource / asset 归属,拒绝 `data:` / `blob:`、signed URL、普通外链和音频、视频、图片序列等非静态栅格输入。归属校验有两条等价路径:带 `sourceResourceId` 且 `sourceImageSrc` 能免查确认指向同一张图(本身即该 objectKey 或就是该 resourceId)时,来源资源已随 owner-scoped 项目读取完成鉴权,直接断言 `resource.ownerUserId` 与 `resource.projectId` 后取用其 objectKey,不再按注册 ID 做全账号项目与素材库扫描;两个字段指向不同图片必须直接拒绝而不是退回扫描。其余情况仍走完整解析。跨记录的 asset_kind 扫描随扫描一并省略,按 `(bucket, objectKey)` 的存储类型点查两条路径都保留,动图仍由下载后的静态编码门禁按实际字节拒绝。编码门禁只接受静态 PNG / JPEG / WebP,明确拒绝 GIF、带 `acTL` 的 APNG 及带动画标志 / `ANIM` / `ANMF` chunk 的 WebP。处理复用 `platform-image` 纯内存 snapper、单边 `10000` 与总像素 `8294400` 上限,并发控制分两层:端点级并发闸最大 `4`、等待队列上限 `2048`,在首次 IO 之前取得,队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`;内层是与生成风格共享的进程级 CPU 并发 `2`。30 秒总预算从 handler 入口起算,覆盖归属校验读取、OSS 下载、两层排队与规整全过程。OSS 读写共用带 `connect 10s / total 120s` 的进程级 HTTP 客户端。strict 与生成风格使用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码,唯一差异是横纵两轴都未检测到步长时,不执行 `min(width,height)/64` 统一网格兜底而返回不适用。任一轴已检测到步长时,两条路径行为和输出必须一致。读取、解码、校验、排队、规整、PNG 编码任一步失败 / 超时 / 不适用时,在最终持久化前返回错误,OSS PUT、asset object、project resource、账号素材和画布 layer 增量都必须为零。成功结果保留源图,只对最终 PNG 做一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`;源图已有正式 project resource 时,结果资源以 `source_resource_id` 关联该资源,再按 `canvasCompletion` 尝试写入一个右侧派生 layer。completion 读取的权威 dialog 已删除时沿用现有语义跳过画布写入,不得用请求中的旧 placeholder 复活图层;已经成功落库的 resource / asset 可以保留。客户端回包时若本地 dialog 已删除,不应用完成快照;现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。客户端不得为该 unsafe POST 配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发送后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。结果未知时先 GET 权威项目 / 素材快照。 -- 完美像素持久化边界:所有可判定的稳定引用、owner、项目、来源资源、素材类型、静态编码、元数据、网格适用性、排队、CPU、解码、规整和编码校验都必须在首个最终 PNG PUT 前完成。最终 PNG 的 OSS PUT / HEAD 仍位于数据库事务外;确认上传结果后,`asset_object + editor_project_resource + editor_asset + optional canvas completion` 必须由 `persist_editor_pixel_art_result_and_return` 在一次 `try_with_tx` 中原子提交,handler 不得先调用 `confirm_asset_object` 或三个旧分段 helper。该原子保证只覆盖本次结果事实;前置 owner-scoped 项目 / 素材读取仍可沿用既有默认 canvas / folder 懒建语义,不把整个 preflight 声明为数据库只读。operation 以规范化 `canvasCompletion.dialogId` 表示并由 owner / project 限定作用域;task ID 可由前端直接推导,object / resource / asset ID 按同一 operation 稳定派生,object key 必须包含覆盖规范输入、来源 / 输出摘要与算法版本的 64 位 fingerprint。完整同内容既有记录只读返回 `AlreadyApplied`,不得再次执行 layout CAS 或推进 revision;同 operation 输入漂移、稳定 ID / object location 冲突或 object/resource/asset 只有部分存在时必须整笔失败关闭并映射 `409`,不得补写或覆盖第一次事实。权威 dialog 已删除时 object/resource/asset 仍在同一事务提交,canvas / revision 不变并返回 `DialogMissing`。HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此首个 PUT 后仍设置 `resultPersistenceStarted=true` 并按稳定身份对账;该标记不再表示数据库可能部分提交。事务失败后可能留下无引用 OSS object,当前不做破坏性删除补偿或历史孤儿清理。 +- 完美像素持久化边界:所有可判定的稳定引用、owner、项目、来源资源、素材类型、静态编码、元数据、网格适用性、排队、CPU、解码、规整和编码校验都必须在首个最终 PNG PUT 前完成。handler 先用纯 prepare 生成精确 object key 和候选 project resource,再调用只读 `preflight_editor_pixel_art_result_and_return`;preflight 校验自定义素材目录归属(尚未创建的默认目录允许通过)、复用权威 canvas completion planner,并对 legacy / structured 候选布局执行 2 MiB 总量和 512 KiB 单项门禁。preflight 与后续 PUT / HEAD / 原子 persist 共用同一份 60 秒绝对 deadline;preflight 失败或超时不得发送 PUT,也不得附加 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 仍位于数据库事务外;确认上传结果后,`asset_object + editor_project_resource + editor_asset + optional canvas completion` 必须由 `persist_editor_pixel_art_result_and_return` 在一次 `try_with_tx` 中原子提交,handler 不得先调用 `confirm_asset_object` 或三个旧分段 helper。最终 procedure 必须重新校验目录、布局、幂等身份和 revision,不能把 preflight 结果当成提交凭证。preflight 不创建锁或 reservation,因此通过后若目录或画布被并发修改,最终事务仍可能在 PUT 后拒绝并留下无引用 OSS object;当前不做破坏性删除补偿或历史孤儿清理。该原子保证只覆盖本次结果事实;前置 owner-scoped 项目 / 素材读取仍可沿用既有默认 canvas / folder 懒建语义,不把整个请求声明为数据库只读。operation 以规范化 `canvasCompletion.dialogId` 表示并由 owner / project 限定作用域;task ID 可由前端直接推导,object / resource / asset ID 按同一 operation 稳定派生,object key 必须包含覆盖规范输入、来源 / 输出摘要与算法版本的 64 位 fingerprint。完整同内容既有记录只读返回 `AlreadyApplied`,不得再次执行 layout CAS 或推进 revision;同 operation 输入漂移、稳定 ID / object location 冲突或 object/resource/asset 只有部分存在时必须整笔失败关闭并映射 `409`,不得补写或覆盖第一次事实。权威 dialog 已删除时 object/resource/asset 仍在同一事务提交,canvas / revision 不变并返回 `DialogMissing`。HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此首个 PUT 后仍设置 `resultPersistenceStarted=true` 并按稳定身份对账;该标记不再表示数据库可能部分提交。 - 完美像素 unknown 与并发闸测试边界:上一条末句“结果未知时先 GET 权威项目 / 素材快照”的旧表述已撤回,项目 GET 才是唯一结果 verdict;素材刷新只允许在项目终态后 best-effort 触发,不能参与成功判断。无 dialog 只有同时存在匹配稳定 task 的唯一 resource 时才是 asset-only 成功,否则保持 unknown。过期预算用例只断言返回 `504`,不得读取进程级 `EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH` 的 before/after;queue guard 的 Drop 归还由独立用例覆盖。不得用相对断言、`--test-threads=1` 或全局串行锁掩盖并行竞态。 - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;`platform-llm` 文本请求默认走 Responses,旧 `/api/llm/chat/completions` 代理和少数旧运行态聊天显式保留 Chat Completions 兼容协议;创意 Agent `gpt-5` Responses / Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/responses`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 726e1b8ae..22725526e 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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 { + 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, owner_user_id: String, @@ -9835,6 +9924,39 @@ async fn upload_editor_generated_image_object_data( slot: &str, provider: &str, asset_object_id: Option, +) -> Result { + 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, + result_persistence_started: Option<&AtomicBool>, ) -> Result { 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", ], diff --git a/server-rs/crates/spacetime-client/src/editor_project.rs b/server-rs/crates/spacetime-client/src/editor_project.rs index 6da9a4179..bcb1c3237 100644 --- a/server-rs/crates/spacetime-client/src/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/editor_project.rs @@ -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 } } +impl From + 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 for crate::module_bindings::EditorPixelArtResultPersistInput { @@ -140,6 +163,31 @@ impl From } 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 { diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 1a46a742a..9259c2b63 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -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, -> + DbView = RemoteTables, + Reducers = RemoteReducers, + SubscriptionBuilder = __sdk::SubscriptionBuilder, + > { } impl< - Ctx: __sdk::DbContext< + Ctx: __sdk::DbContext< DbView = RemoteTables, Reducers = RemoteReducers, SubscriptionBuilder = __sdk::SubscriptionBuilder, >, - > RemoteDbContext for Ctx +> RemoteDbContext for Ctx { } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_input_type.rs new file mode 100644 index 000000000..0699166f6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_result_type.rs new file mode 100644 index 000000000..5b089e7ff --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_result_type.rs @@ -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, +} + +impl __sdk::InModule for EditorPixelArtResultPreflightResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_pixel_art_result_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_pixel_art_result_and_return_procedure.rs new file mode 100644 index 000000000..f38144b63 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_pixel_art_result_and_return_procedure.rs @@ -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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorPixelArtResultPreflightResult>( + "preflight_editor_pixel_art_result_and_return", + PreflightEditorPixelArtResultAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index b1ef33fcc..70ae8c469 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -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, +} + #[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 { + 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 { 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 { 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!([