diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md
index 68d8cd56..0ca4927b 100644
--- a/.hermes/shared-memory/decision-log.md
+++ b/.hermes/shared-memory/decision-log.md
@@ -33,6 +33,37 @@
- 影响范围:`CONTEXT.md`、拼消消 PRD / 技术方案、平台玩法链路文档、`shared-contracts` / `packages/shared`、`api-server`、`spacetime-module`、`spacetime-client`、作品架 / 广场 / 统一作品详情 / runtime 前端分流。
- 验证方式:PRD 和技术方案必须覆盖资产槽位、素材工作表风险、切片验证、恢复语义、API 命名空间和验证命令;实现侧至少运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`npm run check:server-rs-ddd`、`npm run typecheck`、`npm run check:encoding`、相关前端测试和 `cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml`。
- 关联文档:`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md`、`docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
+## 2026-06-05 Server-Provision 全程在目标部署 agent 执行且不安装构建链
+
+- 背景:`Genarrative-Server-Provision` 的 `DEPLOY_TARGET=development` 语义是部署到 dev 服务器,不是构建机 dry-run。旧流水线把 development 映射到 `linux && genarrative-build`,还先在 build 节点准备 `provision-tools/` 再 stash 给后续阶段,导致真实 dev 初始化可能跑到 Jenkins controller / build 节点;脚本还安装 clang / lld / pkg-config / OpenSSL headers / sccache 等构建链依赖,超出了服务器初始化职责。
+- 决策:Server-Provision 只做服务器初始化,全程运行在目标部署 agent:development 使用 `linux && genarrative-dev-deploy`,release 使用 `linux && genarrative-release-deploy`。`Prepare Provision Tools` 与 `Provision Server` 在同一个目标 agent workspace 顺序执行,不再切到 `linux && genarrative-build`,不再 `stash/unstash` 工具包。`scripts/jenkins-server-provision.sh` 不再安装 clang / lld / pkg-config / libssl-dev / sccache;非 dry-run 仍要求目标 dev / release agent 具备 root 权限,因为 provision 会写 systemd、Nginx、`/etc` 和系统用户。Job 的 `Pipeline script from SCM` 与 Jenkinsfile 参数 `SOURCE_GIT_REMOTE_URL` 都必须使用本机路径或目标 agent 可访问的内网 Git 源,不允许公网 Git fallback。
+- 影响范围:`jenkins/Jenkinsfile.production-server-provision`、`scripts/jenkins-server-provision.sh`、生产运维文档、Server-Provision 排障口径。
+- 验证方式:Jenkins 日志中 Server-Provision 的 `Prepare`、`Checkout Provision Files`、`Prepare Provision Tools` 和 `Provision Server` 都在目标 dev / release agent 上执行;日志不出现 `Running on Jenkins`、`linux && genarrative-build`、`stash 'server-provision-tools'`、`Git 主地址拉取失败...改用备用地址`、`https://git.genarrative.world/GenarrativeAI/Genarrative.git` 或构建依赖 / sccache 安装步骤;`bash -n scripts/jenkins-server-provision.sh` 和编码检查通过。
+- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
+
+## 2026-06-05 api-server 重启先摘流再排空并持久化 outbox
+
+- 背景:生产部署重启 api-server 时,如果只用 `/healthz` 判断存活并直接停止进程,运行中的 HTTP 请求和本地 tracking outbox active 文件都可能被中断,容易造成用户请求失败或内存/本地缓冲数据延迟丢失。
+- 决策:`/healthz` 只表示进程存活,发布和生产接流检查统一使用 `/readyz`。api-server 收到 `SIGINT` / `SIGTERM` 后先把 readiness 标记为不可用,再交给 Axum graceful shutdown 排空已有 HTTP 请求;退出前在 `GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS` 窗口内封存 active tracking outbox 并尽力 flush sealed 文件,失败或超时则保留本地文件给下次启动重试。systemd 停机窗口统一放到 `TimeoutStopSec=90`。
+- 影响范围:`server-rs/crates/api-server`、`deploy/systemd/genarrative-api.service`、生产 API deploy 脚本、Jenkins API deploy 参数、Nginx 公网健康检查暴露策略、开发运维文档。
+- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml readyz_reports_readiness_and_draining_state`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml shutdown_flush_seals_active_file_for_later_retry`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、部署脚本 `bash -n` 与 `/readyz` 本机 smoke。
+- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
+
+## 2026-06-05 OSS 平台适配器输出结构化日志
+
+- 背景:AI 生成资产、浏览器直传签名、私有读签名和对象确认都依赖 OSS;如果 OSS 侧只有错误字符串,排查资产写入 / 确认失败时很难按操作、对象、状态码和耗时下钻。
+- 决策:`server-rs/crates/platform-oss` 统一为 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object` 输出结构化日志。日志固定携带 `provider=aliyun-oss`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms` 等排障字段;禁止输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。
+- 影响范围:`server-rs/crates/platform-oss`、`api-server` 资产签名 / 上传 / 确认链路、OTLP logs、本地 `logs/api-server/` 与运维排障文档。
+- 验证方式:`cargo test -p platform-oss --manifest-path server-rs/Cargo.toml`;真实联调时按 `provider=aliyun-oss` 与 `operation` 过滤日志,确认只出现对象定位和状态字段,不出现签名材料。
+- 关联文档:`server-rs/crates/platform-oss/README.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
+
+## 2026-06-05 跳一跳返回按钮改为独立主题资产
+
+- 背景:跳一跳运行态曾把左上角返回按钮视觉锚点写进背景 image2 prompt,导致返回按钮像静态背景元素,不能替代真实可点击按钮。
+- 决策:跳一跳背景 prompt 禁止生成任何 UI 或左上角图标;返回按钮由 `backButtonAsset` 单独生成 1:1 纯绿 key 图,后端去绿后作为透明 PNG 持久化到作品 profile,运行态左上角真实按钮优先渲染该资产。顶部得分 HUD 复用拼图模板结构,包含陶泥儿 IP logo、标题牌和下挂数字卡。
+- 影响范围:`packages/shared/src/contracts/jumpHop.ts`、`shared-contracts`、`spacetime-module` / `spacetime-client` bindings、`api-server` 跳一跳生成链路、`JumpHopRuntimeShell`、玩法链路文档和后端数据契约文档。
+- 验证方式:`npm run spacetime:generate`、`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`、`npm run check:spacetime-schema`。
+- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
## 2026-06-03 创作入口关闭不下架已发布作品
@@ -201,7 +232,7 @@
## 2026-05-26 推荐页拼图下一关 pending 时保留当前运行态
- 背景:推荐页嵌入拼图在点击“下一关”时,`advancePuzzleNextLevel` 的服务端请求会短暂处于 pending。旧逻辑把推荐卡的 `isStartingRecommendEntry` 和拼图局部 busy 混在一起,导致外层直接切回“加载中...”,把当前 `PuzzleRuntimeShell` 一起卸载,视觉上像是切关闪回。
-- 决策:推荐页嵌入拼图切关 pending 期间必须保留当前运行态与棋盘,只让拼图壳内部 busy 表现承接同步;`isStartingRecommendEntry` 只表示推荐作品尚未真正启动出来,不再把已有嵌入拼图 run 的局部 busy 一并当成整卡加载态。若下一关落到相似作品,前端还必须把新作品写回推荐缓存并同步 `activeRecommendEntryKey`,避免运行态进入新作品但推荐卡元信息、分享 / 点赞 / 改造和后续“下一个”仍锚定旧作品。
+- 决策:推荐页嵌入拼图切关 pending 期间必须保留当前运行态与棋盘,只让拼图壳内部 busy 表现承接同步;`isStartingRecommendEntry` 只表示推荐作品尚未真正启动出来,不再把已有嵌入拼图 run 的局部 busy 一并当成整卡加载态。若下一关落到相似作品,前端还必须把新作品写回推荐缓存并同步 `activeRecommendEntryKey`,避免运行态进入新作品但推荐卡元信息、分享 / 点赞 / 改造和后续“下一个”仍锚定旧作品;但这个同步仍属于同一个 run 内部推进,不得触发推荐 rail 切卡动画、纵向位移或启动封面重置。
- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、推荐页拼图切关测试与平台链路文档。
- 验证方式:点击推荐页拼图“下一关”后,在 `advancePuzzleNextLevel` 未返回前,页面仍应保留 `puzzle-board`,且不出现 `加载中...` 占位;返回相似作品后,当前推荐卡的 `作品信息` 应显示新作品标题。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
@@ -461,7 +492,7 @@
## 2026-05-19 生产 provision 改为 Windows 下载包后由目标机本地安装
-- 后续更新:该口径已被 `2026-06-01 生产 Jenkins 流水线统一改为 Linux 优先并先查 localhost` 取代;当前 `Genarrative-Server-Provision` 不再走 Windows 下载阶段,而是在 Linux build 节点直接准备 `provision-tools/`。
+- 后续更新:该口径先被 2026-06-01 Linux 优先方案取代,又在 2026-06-05 被 Server-Provision 专用口径覆盖;当前 `Genarrative-Server-Provision` 不走 Windows 下载阶段,也不在 Linux build 节点中转工具包,而是在目标 dev / release agent 内准备 `provision-tools/`。
- 背景:当前 `development` provision 目标实际就是 Linux agent `genarrative-build-01`,之前把 `Prepare Provision Tools` 放在 `linux && genarrative-build` 会让目标机自己连 GitHub 和 `install.spacetimedb.com`,违背“Windows 本机先下载再传到目标机”的运维要求。
- 决策:`Genarrative-Server-Provision` 拆成 Windows 下载阶段和 Linux 目标机安装阶段。Windows 节点的 `Download Provision Tool Archives` 只下载 `spacetime-x86_64-unknown-linux-gnu.tar.gz` 和 `otelcol-contrib_0.151.0_linux_amd64.tar.gz`,通过 `stash/unstash` 传到目标 Linux 节点;目标机执行 `scripts/prepare-server-provision-tools.sh` 时设置 `PROVISION_REQUIRE_LOCAL_DOWNLOADS=true`,只消费已下载件生成 `provision-tools/`,缺包直接失败,不回退外网下载。
- 追加决策:Server-Provision 的 Windows helper 不再对 Jenkins `writeFile` 刚写出的 `.ps1` 做原地 UTF-8 BOM 重写,而是由显式 `powershell.exe` 按 UTF-8 读入脚本文本,并用 `ScriptBlock::Create(...)` 在内存中执行;这样既保留中文脚本内容,又避免同一个 workspace 脚本被立即重写时触发 `拒绝访问`。
@@ -1119,6 +1150,7 @@
## 2026-06-01 生产 Jenkins 流水线统一改为 Linux 优先并先查 localhost
+- 后续更新:该条仍适用于常规构建 / 发布流水线;`Genarrative-Server-Provision` 已在 2026-06-05 改为目标部署 agent 全程执行,并禁止公网 Git fallback 与 build 节点工具包中转。
- 背景:生产流水线长期混用 Windows、Linux 和公网 Git 入口,导致构建 / 发布 / provision 的 checkout 口径分叉;同时 `Genarrative-Server-Provision` 还残留过 Windows 下载 helper,和当前 Linux 构建 / 发布部署路径不一致。
- 决策:生产 Jenkins 流水线统一把执行节点收口到 Linux label,`Pipeline script from SCM` 仍保留公网域名,但所有生产流水线首次 `GitSCM checkout` 先尝试 `http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后再回退到 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`;`Genarrative-Stdb-Module-Build`、`Genarrative-Server-Provision`、`Genarrative-Notify-Email` 也都切到 Linux 节点。`Genarrative-Server-Provision` 的工具准备不再依赖 Windows helper,而是在 Linux build 节点直接生成 `provision-tools/` 后交给后续 Linux 发布阶段。
- 影响范围:`jenkins/Jenkinsfile.production-*`、`scripts/jenkins-checkout-source.sh`、`scripts/prepare-server-provision-tools.sh`、生产运维文档。
@@ -1157,13 +1189,38 @@
- 验证方式:从平台推荐或公开详情进入跳一跳作品时,路由 source type 为 `jump-hop`、public code 为 `JH-*`,运行态启动消费后端返回的完整 profile / run 数据;后端 smoke 统一使用 `npm run dev:api-server` 启动并检查 `/healthz`。
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
-## 2026-05-26 跳一跳地块图集改为专用 2x3 六格切分
+## 2026-05-28 跳一跳重设计为 5x5 地块图集与弹弓拖拽
-- 背景:跳一跳创作在地块生图阶段误用了通用系列素材图集 helper,`item_names.len() > grid_size` 的校验会让 6 个地块类型在 `grid_size = 3` 时直接失败;即使绕过校验,通用 helper 仍以“每物品多视图”语义切图,不符合跳一跳地块的一次性六格资产模型。
-- 决策:跳一跳地块图集固定采用专用 `2行*3列` 六格布局,按 `start / normal / target / finish / bonus / accent` 顺序切分并分别持久化为独立 PNG 资产;图集 prompt 不再调用通用系列素材 `build_generated_asset_sheet_prompt`。
-- 影响范围:`server-rs/crates/api-server/src/jump_hop.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
-- 验证方式:`cargo test -p api-server jump_hop_tile_atlas -- --nocapture` 通过;六张切片都应有独立 OSS 对象与 `JumpHopTileAsset` 记录,不再只有 atlas 预览路径。
-- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`。
+- 背景:旧跳一跳模板仍保留角色生图、有限路径、score/combo 和 `2x3` 地块图集口径,和当前“俯视角平台跳跃 + 主题生成地块池 + 无限路径”的产品需求不一致。
+- 决策:`jump-hop` v1 创作端只保留主题输入;image2 生成一张 `5x5`、共 25 个 2D 地块图标的图集,后端按均匀网格切出 25 个 `JumpHopTileAsset`。角色不再单独生图,运行态使用陶泥儿 logo 透明 PNG 角色;运行态输入为按住后拉蓄力、松手反向弹出,前端提交 `chargeMs + dragVectorX + dragVectorY`,后端裁决落点。草稿试玩必须使用 `runtimeMode=draft`,正式作品使用 `runtimeMode=published`;排行榜按作品维度每玩家只保留 1 条最佳记录,排序为成功跳跃次数降序、游戏时长升序、更新时间升序。
+- 决策补充:跳一跳创作入口的事实源仍是 SpacetimeDB `creation_entry_type_config`。默认种子和旧默认行都必须同步迁移到 `subtitle=主题驱动平台跳跃`、`image_src=/creation-type-references/jump-hop.webp`;后端只在系统默认旧值命中时自动纠偏,避免覆盖后台手动配置。
+- 影响范围:`jump-hop` PRD、`api-server` 生成编排、`module-jump-hop` 领域规则、`spacetime-module` / `spacetime-client` 跳一跳契约、前端工作台 / 结果页 / runtime / 平台壳调用链。
+- 验证方式:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`、`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`、跳一跳工作台和 runtime 定向前端测试。
+- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
+
+## 2026-06-01 跳一跳运行态地块视觉尺寸和命中半径统一放大一倍
+
+- 背景:当前跳一跳运行态里地块视觉尺寸偏小,玩家反馈“很难跳上去”,但仅放大前端展示会造成画面和后端裁决脱节。
+- 决策:`jump-hop` 运行态的地块视觉尺寸、`width/height` 玩法世界尺寸以及 `landingRadius/perfectRadius` 同步乘以 2;前端平台渲染抽成统一尺寸 helper,保证单测可以直接校验放大结果。
+- 影响范围:`server-rs/crates/module-jump-hop/src/application.rs`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、对应定向测试。
+- 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`、`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`。
+- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+
+## 2026-06-02 跳一跳起跳距离减半并加入飞行动画缓冲
+
+- 背景:用户反馈当前跳跃到目标位置需要拖得太远,且松手后缺少角色翻腾到目标地块的过渡动画,导致跳跃手感偏硬。
+- 决策:`jump-hop` 的 `chargeToDistanceRatio` 统一从 `0.004` 提升到 `0.008`,让同等跳跃距离所需拖动距离减半;前端 runtime 把“后端真实 run”和“当前屏幕显示态”拆开,松手瞬间先生成 `visualJump`,用当前角色位置作为起点、前端预测落点作为终点,播放约 `560ms` 的飞行动画;该路径不得等待后端新 run。角色弹到预测落点后若新 run 尚未返回,必须停在预测落点等待,再进入约 `1440ms` 的相机层推进过渡。推进期间地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块自然离开视野,新预览地块从上方露出,避免 p1/p2 单独 top/left 过渡导致角色和地块不同步。相机推进必须同时使用 X/Y 偏移,从旧目标地块位置斜向滑到新当前地块聚焦位置,不能先横向瞬切居中再纵向推进。地块保留当前 / 目标 / 预览的深度尺寸差异,但该差异通过固定基准宽高上的 CSS transform scale 表达,并在相机推进期间同样使用 `1440ms` 缓动;当前态不再额外叠 CSS scale。
+- 影响范围:`server-rs/crates/module-jump-hop/src/application.rs`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、跳一跳运行态定向测试。
+- 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`、`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run check:encoding`。
+- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+
+## 2026-06-03 跳一跳角色形象改为陶泥儿 logo 透明 PNG
+
+- 背景:跳一跳运行态此前仍使用旧内置 / CSS 角色形象,和用户要求的陶泥儿 logo 角色不一致,也容易和 DOM 地块层出现遮挡层级问题。
+- 决策:`jump-hop` v1 不再渲染内置 3D 角色几何体;运行态和结果页统一使用 `public/branding/jump-hop-taonier-character.png`,该文件由陶泥儿 logo 处理为透明 PNG 后接入。蓄力时角色沿拖拽方向明显拉长,落地后向反方向回弹两次。`characterAsset` 继续仅作为历史兼容描述字段,不能重新打开角色生图槽或把角色图片作为创作者可配置输入。
+- 影响范围:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/components/jump-hop-result/JumpHopResultView.tsx`、跳一跳 PRD 和平台链路文档。
+- 验证方式:跳一跳运行态 / 结果页测试需要断言角色图片 src 为 `/branding/jump-hop-taonier-character.png`,并确认旧默认角色 fallback 不再出现。
+- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
# 2026-05-20 陶泥儿主视觉配色回收为暖白/陶土橙
diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md
index 6cfb9325..9fad4640 100644
--- a/.hermes/shared-memory/development-workflow.md
+++ b/.hermes/shared-memory/development-workflow.md
@@ -205,7 +205,7 @@ npm run check:server-rs-ddd
- 使用 `npm run dev:api-server` 重新拉起后端。
- 禁止使用 `npm run api-server:maincloud`、`npm.cmd run api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径;这些只属于历史残留。
-- 检查 `/healthz`。
+- 本地 smoke 检查 `/healthz`;发布后或确认实例可接生产流量时检查 `/readyz`。
- 执行对应自动测试。
- 涉及 SpacetimeDB 表、reducer、procedure、row shape 或绑定变化时,同步更新 `migration.rs`、表目录和生成绑定。
- SpacetimeDB 已有表新增字段必须放在 Rust 表结构体最后,并设置明确默认值;需要修改字段名时,先询问用户并确认迁移计划,再同步更新 `server-rs/crates/spacetime-module/src/migration.rs`、表目录和生成绑定。
@@ -224,7 +224,7 @@ npm run check:server-rs-ddd
## 生产压测与观测默认口径
- 作品列表 50 HTTP req/s 压测使用 `scripts/loadtest/README.md` 中的 K6 命令;当前脚本一次 iteration 请求两个公开列表接口,因此目标 50 HTTP req/s 对应 `PEAK_RPS=25`。
-- 生产 `api-server` 默认 backlog、worker threads、HTTP 并发背压、systemd 限制、Nginx upstream timing log 和 OTLP 开关以 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` 为准。
+- 生产 `api-server` 默认 backlog、worker threads、HTTP 并发背压、`/readyz` 接流检查、systemd 优雅停机窗口、Nginx upstream timing log 和 OTLP 开关以 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` 为准。
- OpenTelemetry 现阶段可选发送 traces / metrics / logs,但不会取代本地 `journalctl -u genarrative-api.service`、`logs/api-server/` 与 `/var/log/nginx/genarrative.*.log`。
- 指标 label 不写 raw URI、userId、profileId 或 request_id;request_id 只用于 trace/log 串联。
diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md
index f5fd1ae3..ebdffc89 100644
--- a/.hermes/shared-memory/pitfalls.md
+++ b/.hermes/shared-memory/pitfalls.md
@@ -15,6 +15,14 @@
- 关联:相关文件、文档、提交或 Issue
```
+## 小程序 H5 导航不能清掉宿主 query
+
+- 现象:微信小程序首次进入 H5 后,点击需要登录的入口没有返回小程序原生授权页,而是弹出 Web 端登录窗口;充值渠道也可能被误判为普通网页环境。
+- 原因:小程序 `web-view` 入口通过 `clientType=mini_program`、`clientRuntime=wechat_mini_program`、`miniProgramEnv` 标记宿主环境,但 H5 内部 `pushAppHistoryPath(...)` 阶段导航会默认清空 query;首点时微信 JS bridge 也可能尚未就绪,导致 `isWechatMiniProgramWebViewRuntime()` 和充值平台判断读不到小程序上下文。
+- 处理:路由层统一把 `clientType`、`clientRuntime`、`miniProgramEnv` 当作 app runtime context,在普通路径归一、显式 query 路由和同一创作流跳转时都跨导航保留;小程序环境识别同时用 `MicroMessenger + miniProgram` User-Agent 兜底首点 bridge 未就绪场景;创作恢复参数仍只在同玩法创作流内保留,离开创作流时继续清理。
+- 验证:`npm exec vitest run src/routing/appPageRoutes.test.ts src/components/auth/AuthGate.test.tsx src/services/authService.test.ts src/services/payment/paymentPlatform.test.ts`。
+- 关联:`src/routing/appPageRoutes.ts`、`src/services/authService.ts`、`src/services/payment/paymentPlatform.ts`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
+
## 平台异步错误必须带来源弹窗,不要只显示裸错误
- 现象:用户先后触发多个拼图或草稿生成时,旧请求失败后会在当前页面显示“图片生成失败”等裸错误,容易误判为当前正在看的拼图失败;错误文本也不便复制给开发排查。
@@ -35,17 +43,17 @@
- 现象:`external_api_call_failure` 里看到 `failureStage=request_send`、`timeout=true`、`statusCode=null`,`errorSource` 可能是 `client error (SendRequest)` 或更完整的 reqwest 底层错误链,前端只知道图片生成失败。
- 原因:`timeout=true` 来自 `reqwest::Error::is_timeout()`,不是业务代码固定写死;`SendRequest` 是 Hyper 发送请求阶段的错误来源标签,只说明请求未拿到可归类的 HTTP 响应,不会包含上游 JSON 错误体。
-- 处理:先按 `provider/failureStage/statusClass` 聚合,再用 `user_id` / `profile_id` 和 `metadata_json.userId/profileId/requestId` 定位触发者、草稿 / 作品和同一次 HTTP 请求;`request_send + timeout=true` 优先查 provider 日志的 `source_chain`、请求体大小、参考图数量、出口网络、代理/Nginx、VectorEngine 当时可用性和同一 request_id 日志。若记录有 `502` 或 `429 moderation_blocked`,按上游网关或审核失败另行处理,不要归到传输超时。
+- 处理:先按 `provider/failureStage/statusClass` 聚合,再用 `user_id` / `profile_id` 和 `metadata_json.userId/profileId/requestId` 定位触发者、草稿 / 作品和同一次 HTTP 请求;`request_send + timeout=true` 优先查 provider 日志的 `source_chain`、请求体大小、参考图数量、出口网络、代理/Nginx、VectorEngine 当时可用性和同一 request_id 日志。当前 `platform-image` 对 `request_send` 的 `timeout` / `connect` 错误最多重试 3 次,multipart `/v1/images/edits` 每次重试都必须重建 form;看到 `VectorEngine 图片请求发送失败,准备重试` 只是单次 attempt 失败,最终 `external_api_call_failure` 才代表该用户请求整体失败。若记录有 `502` 或 `429 moderation_blocked`,按上游网关或审核失败另行处理,不要归到传输超时。
- 拼图关卡资产生成按 `level_scene -> ui_spritesheet -> level_background` 顺序执行,每个资产会输出 `slot`、`asset_kind`、`elapsed_ms`;排查拼图草稿失败时优先看同一 request_id 下最后一个失败 slot。
-- 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触发者 `user_id` 和可用的 `profile_id`。
-- 关联:`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
+- 验证:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml vector_engine_image_edit_retries_send_timeout_once_and_succeeds`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触发者 `user_id` 和可用的 `profile_id`。
+- 关联:`server-rs/crates/platform-image/src/vector_engine/client.rs`、`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
-## “我的”页每日任务卡不要硬编码进度
+## “我的”页每日任务卡不要硬编码进度,也不要跨日保留旧状态
- 现象:用户完成或领取每日任务后,任务中心弹窗里的任务状态已经变化,但“我的”页卡片仍显示 `0 / 1` 和“去完成”。
-- 原因:卡片首版只写了静态展示文案,没有读取 `/api/profile/tasks` 返回的 `ProfileTaskCenterResponse`,领取接口返回的新 `center` 也只用于弹窗。
-- 处理:进入“我的”页时读取任务中心,卡片用当前可操作任务或已领取任务派生奖励、进度条和操作状态;`claimRpgProfileTaskReward(...)` 成功后用响应里的 `center` 覆盖本地任务中心。
-- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应覆盖卡片从后端任务摘要显示 `1 / 1`,领取后显示已完成。
+- 原因:卡片首版只写了静态展示文案,没有读取 `/api/profile/tasks` 返回的 `ProfileTaskCenterResponse`,领取接口返回的新 `center` 也只用于弹窗;后来虽然后端按北京时间 0 点切换业务日,但前端停留在“我的”页时不会跨日刷新,可能继续展示上一日已领取状态。
+- 处理:进入“我的”页时读取任务中心,卡片用当前可操作任务或已领取任务派生奖励、进度条和操作状态;`claimRpgProfileTaskReward(...)` 成功后用响应里的 `center` 覆盖本地任务中心;停留在“我的”页跨过北京时间 0 点时,先非阻断 refresh 登录态写入新业务日 `daily_login`,再重拉任务中心。
+- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应覆盖卡片从后端任务摘要显示 `1 / 1`、领取后显示已完成,以及北京时间 0 点自动 refresh 后重拉任务中心。
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
## “我的”页不要恢复旧的填邀请码次级按钮
@@ -151,6 +159,14 @@
- 验证:后台保存两条以上公告后,点击底部加号进入创作入口页应自动轮播这些后台配置项;`CustomWorldCreationHub` 相关测试应断言标题来自后端配置。
- 关联:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、`server-rs/crates/module-runtime/src/application.rs`、`apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx`。
+## 创作入口 banner 默认图片路径必须真实存在
+
+- 现象:创作页顶部 banner 返回旧结构化 `eventBanner` 时,前端 `
` 请求 `/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png`,但 `public/` 下没有该文件,导致 banner 背景图加载失败。
+- 原因:旧库 `event_banners_json=None` 时,读取层把旧单条结构化 banner 当成 `eventBanners` 优先数组下发;同时旧结构化默认 `coverImageSrc` 指向已经不存在的品牌素材路径。
+- 处理:`module-runtime` 在 `event_banners_json` 缺失或不可解析时回到默认公告数组;默认 HTML 公告和旧结构化默认 `coverImageSrc` 都引用 `public/` 下真实存在的 `/creation-type-references/puzzle.webp`。
+- 验证:`cargo test -p module-runtime creation_entry_event_banners_none_returns_default_announcements --manifest-path server-rs/Cargo.toml`;重启本地 `api-server` 后 `GET /api/creation-entry/config` 的 `eventBanners[0]` 不再指向缺失的 `/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png`。
+- 关联:`server-rs/crates/module-runtime/src/application.rs`、`server-rs/crates/module-runtime/src/domain.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+
## 移动端草稿卡不要长按选中文字
- 现象:移动端草稿页长按作品卡标题或摘要时触发系统文字选区,容易误触并打断作品架操作。
@@ -1219,6 +1235,7 @@
## Jenkins 生产流水线拉 Git 先本机再域名备用
+- 后续更新:该条仍适用于常规构建 / 发布流水线;`Genarrative-Server-Provision` 已在 2026-06-05 改为服务器初始化专用口径,不允许公网 Git fallback,Job 的 `Pipeline script from SCM` 和 Jenkinsfile 内部 checkout 都必须使用本机路径或目标 agent 可访问的内网 Git 源。
- 现象:生产发布、数据库导入导出、服务器配置、构建或 `Genarrative-Full-Build-And-Deploy` 流水线执行 `GitSCM checkout` 时,如果 Jenkins 生成的 fetch 是 `+refs/heads/*:refs/remotes/origin/*`,公网 Git 链路可能在收包阶段以 `git-remote-https died of signal 15`、`curl 56 GnuTLS recv error (-9)`、`early EOF`、`invalid index-pack output` 失败;发布类流水线还可能先遇到 `http://127.0.0.1:3000/GenarrativeAI/Genarrative.git` 不可达。
- 原因:`127.0.0.1` 只代表当前执行阶段的 agent 自身;当 release agent 与 Git 服务不在同一台机器,或本机 Git/Web 服务临时不可用时,固定写死 localhost 会阻断 Jenkinsfile 内部源码/脚本 checkout。即使只使用域名 Git,如果 `GitSCM` 没有显式 refspec 并开启 `CloneOption honorRefspec=true`,Jenkins Git 插件也会拉取所有分支。
- 处理:Jenkins Job 的 `Pipeline script from SCM` 由 Windows controller 执行,SCM URL 使用公网域名 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。运行于 `linux && genarrative-build` 的 `Genarrative-Full-Build-And-Deploy` 源码解析阶段、`Genarrative-Web-Build` checkout 阶段,以及部署/发布类 Linux agent 的 Jenkinsfile 首次 `checkout([$class: 'GitSCM', ...])` 层先尝试 `GIT_REMOTE_URL=http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后直接尝试 `GIT_REMOTE_FALLBACK_URL=https://git.genarrative.world/GenarrativeAI/Genarrative.git`,不再配置内网 IP fallback;这些首次 checkout 都必须使用目标分支 refspec、`CloneOption shallow=true depth=1 noTags=true honorRefspec=true`。后续统一走 `scripts/jenkins-checkout-source.sh`,该脚本也按主地址、域名备用地址顺序重新 fetch 并把 `origin` 切到实际可用地址;`COMMIT_HASH` 为空时继续 `--depth=1 --no-tags`,只有指定 commit 时才允许加深历史做分支归属校验。
@@ -1241,12 +1258,12 @@
- 验证:运行 `git ls-files --stage scripts/prepare-server-provision-tools.sh`,确认 mode 为 `100755`;重新跑 `Genarrative-Server-Provision` 时应进入工具下载/打包日志,而不是停在 `Permission denied`。
- 关联:`jenkins/Jenkinsfile.production-server-provision`、`scripts/prepare-server-provision-tools.sh`、`scripts/jenkins-checkout-source.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
-## Server-Provision 工具准备只在 Linux build 节点做一次
+## Server-Provision 工具准备只在目标部署 agent 内做一次
-- 现象:`Genarrative-Server-Provision` 在后续目标发布节点重复执行 `scripts/prepare-server-provision-tools.sh`,或日志里出现目标节点继续访问 GitHub release / `install.spacetimedb.com`。
-- 原因:当前流水线已经改成 Linux build 节点一次性准备 `provision-tools/` 并 stash 给目标发布阶段;如果目标发布阶段又重新准备工具包,就会重复下载并把目标节点暴露到外网依赖。
-- 处理:只允许 `Prepare Provision Tools` 阶段在 `linux && genarrative-build` 节点生成 `provision-tools/`;后续 `Provision Server` 阶段只 `unstash 'server-provision-tools'` 并安装其中的 `spacetime` 与 `otelcol-contrib`,不要再运行 `prepare-server-provision-tools.sh`。
-- 验证:Jenkins 日志应先在 Linux build 节点出现 `[prepare-provision-tools] 工具包已准备`,后续目标发布节点只出现安装 / systemd / Nginx provision 日志;目标节点不应出现 `下载 otelcol-contrib:` 或 `下载 SpacetimeDB 官方安装器脚本:`。
+- 现象:`Genarrative-Server-Provision` 选择 `DEPLOY_TARGET=development` 时如果阶段跑在 `Running on Jenkins` 或 `linux && genarrative-build`,真实 provision 会落到构建机 / controller,而不是 dev 服务器。
+- 原因:Server-Provision 是服务器初始化流水线,dev / release 都是目标服务器,不应把 development 当成 build 节点预览目标,也不应通过 build 节点 stash 工具包再切回目标机;同时公网 Git fallback 会让目标 agent 内网源不可达时悄悄改从公网拉源码,掩盖服务器路由问题。
+- 处理:Server-Provision 全程运行在目标部署 agent:development 使用 `linux && genarrative-dev-deploy`,release 使用 `linux && genarrative-release-deploy`。`Prepare Provision Tools` 和 `Provision Server` 在同一个目标 agent workspace 内顺序执行,不再使用 `linux && genarrative-build`,也不再 `stash/unstash` 工具包。Job 的 `Pipeline script from SCM` 与参数 `SOURCE_GIT_REMOTE_URL` 都必须指向本机路径或内网 Git 源,不允许 `https://git.genarrative.world/...` 公网地址。
+- 验证:Jenkins 日志中 `Provision Target` 下的 `Prepare`、`Checkout Provision Files`、`Prepare Provision Tools` 和 `Provision Server` 都应运行在目标 dev / release agent;日志不应出现 `stash 'server-provision-tools'`、目标阶段 `unstash`、`Git 主地址拉取失败...改用备用地址` 或 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。
- 关联:`jenkins/Jenkinsfile.production-server-provision`、`scripts/prepare-server-provision-tools.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
## 个人任务 scope 不得扩成 work/site/module
@@ -1624,10 +1641,18 @@
- 现象:拼图生成页已经收到 VectorEngine 图片编辑失败并进入重试态,但用户返回草稿 Tab 后,同一草稿仍显示“生成中”;连续触发多个拼图生成时,失败后还可能只剩一条新增草稿,或者只看到标题为“第1关”的半成品空壳;抓大鹅后台失败时也可能没有任何通知,点击草稿又像重新开始生成。
- 原因:前端失败 notice 只更新生成页局部状态,pending 作品架条目在失败时被清掉或被非 `generating` 状态误映射为 `ready`;后端作品摘要也可能短暂仍是 `generationStatus=generating`。如果失败消息没有写入 notice,用户离开生成页后不会弹出 `PlatformErrorDialog`;如果打开草稿只看持久化 `generating`,就会绕过失败态恢复。
-- 处理:失败时按 session 保留 pending 作品架条目并标记 `failed`,失败 notice 保存错误消息并触发带来源的 `PlatformErrorDialog`;拼图契约没有 `failed` 枚举,pending 拼图映射为 `idle`,同时用本地失败 notice 覆盖持久化生成中状态和旧的“正在生成”摘要。点击失败草稿应优先用 notice / 后端 session / fallback payload 组装失败生成页,不能重新从 0 秒启动新进度;拼图失败半成品没有有效 `workTitle` 时,作品架标题回退为“拼图草稿”。
+- 处理:失败时按 session 保留 pending 作品架条目并标记 `failed`,失败 notice 保存错误消息并触发带来源的 `PlatformErrorDialog`;拼图契约没有 `failed` 枚举,pending 拼图映射为 `idle`,同时用本地失败 notice 覆盖持久化生成中状态和旧的“正在生成”摘要。点击失败草稿应优先用 notice / 后端 session / fallback payload 组装失败生成页,不能重新从 0 秒启动新进度;失败页点击重新生成必须优先复用当前 `sessionId` 执行编译 action,不得因存在表单缓存 payload 就调用 create-session。拼图失败半成品没有有效 `workTitle` 时,作品架标题回退为“拼图草稿”。
- 验证:`node node_modules/vitest/vitest.mjs run src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed parallel puzzle|background match3d"`。
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/custom-world-home/creationWorkShelf.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+## 生成失败重试不要走新建草稿
+
+- 现象:拼图或抓大鹅生成失败后,在失败页点击“重新生成”,作品架里多出一份新的草稿,原失败草稿仍留在列表里。
+- 原因:重试 handler 曾优先读取缓存的表单 payload 并调用 create-session 路径;失败草稿按 session 留在作品架是正确行为,于是重试动作额外创建了第二份草稿。
+- 处理:只要当前失败页还能恢复到原 `sessionId`,重试就走该 session 的 compile action;只有没有可恢复 session 时,才允许用表单 payload 重新创建草稿。
+- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed .* draft retry reuses current session"`。
+- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+
## 汪汪声浪草稿试玩不要写正式 run
- 现象:如果草稿结果页试玩和发布后 runtime 共用同一写成绩路径,未发布或未确认资源的草稿试玩会污染正式单局、排行榜和作品统计。
@@ -1675,14 +1700,53 @@
- 验证:`npm run typecheck`,并跑 `npm test -- src/routing/appPageRoutes.test.ts` 覆盖 JumpHop 阶段路径。
- 关联:`src/components/platform-entry/platformEntryTypes.ts`、`src/routing/appPageRoutes.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
-## 跳一跳地块图集不要套通用系列素材 n 行模型
+## 跳一跳地块图集固定走 5x5 地块池
-- 现象:跳一跳初始草稿生成时报 `系列素材图集的物品行数不能超过 n。`,或者即使绕过报错也只生成了 atlas 预览路径,地块切片没有真正落盘。
-- 原因:跳一跳地块只有 6 个固定 tileType,但旧实现把它塞进通用系列素材 helper,并使用 `grid_size = 3` / `item_names = 6` 的语义冲突模型;随后又只保留 atlas 资产与模拟路径,没把六个切片逐一上传并确认到 `JumpHopTileAsset`。
-- 处理:跳一跳地块改用专用 `2行*3列` 图集 prompt,按 `start / normal / target / finish / bonus / accent` 顺序切 6 张 PNG,并对每张切片各自走 OSS 上传、asset_object 确认和 entity bind。
-- 验证:`cargo test -p api-server jump_hop_tile_atlas -- --nocapture` 通过后,再看 `jump_hop.rs` 不应再调用 `build_generated_asset_sheet_prompt` 处理地块图集;公开结果里应能拿到 6 个独立 `JumpHopTileAsset`。
+- 现象:跳一跳初始草稿生成时报 `系列素材图集的物品行数不能超过 n。`,或者生成完成后只有 atlas 预览路径,地块切片没有真正落盘。
+- 原因:旧模板先后尝试过通用系列素材 helper 和 `2x3` 六格固定 tileType,但当前跳一跳已经重设计为“主题 -> 5x5 地块图集 -> 25 个等权地块池 -> 无限路径”,旧的物品行数 / 固定类型模型都会把创作链路带偏。
+- 处理:跳一跳地块固定生成一张 `5x5` 主题图集,后端按均匀网格切出 25 张 PNG,并对每张切片各自走 OSS 上传、asset_object 确认和 entity bind;不要再恢复 `2行*3列`、`start / normal / target / finish / bonus / accent` 六格口径。
+- 验证:`jump_hop.rs` 不应再调用通用物品行数模型处理地块图集;公开结果里应能拿到 25 个独立 `JumpHopTileAsset`,运行态无限路径从地块池随机取材。
- 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+## 跳一跳宝可梦主题地块图集 safety rejection 只做专项改写
+
+- 现象:跳一跳草稿使用“宝可梦 / Pokemon / 皮卡丘 / 精灵球”等主题时,背景底图和返回按钮可能已生成成功,但地块图集的 VectorEngine 请求返回 `Your request was rejected by the safety system`,日志里 `failure_context="跳一跳地块图集生成失败"`、`status=429`、`code="invalid_prompt"`。
+- 原因:25 个落点图集 prompt 会把这些词放进“主题物体图集”语境,容易被上游理解为要求生成具体宝可梦角色或标志道具,触发安全拦截;这不是普通平台造型词、抠图或超时问题。
+- 处理:仅在跳一跳图片生成 prompt 文本命中宝可梦相关词时做生成侧替换,把 `宝可梦 / 神奇宝贝 / 口袋妖怪 / Pokemon` 改为“原创幻想萌宠冒险道具”,把 `精灵球` 改为“彩色冒险能量球”,把 `皮卡丘 / Pikachu` 改为“黄色闪电萌宠符号”;不要把所有主题都加全局 IP 禁止约束,用户草稿标题和主题展示也不改。
+- 验证:`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml` 应覆盖宝可梦词专项替换;真实联调时同一草稿重试后,地块图集请求的 prompt 不再包含宝可梦相关词。
+- 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+
+## 跳一跳地块切片不要按 tileType 复用资产槽位
+
+- 现象:跳一跳生成完成后,运行态看起来仍像在显示默认几何地块,或者地块图片在加载时频闪;结果页地块池也可能只看到少量重复素材。
+- 原因:`tileType` 只是路径平台的玩法类型标签,25 个 atlas 切片里会重复出现 `normal / target / bonus / accent` 等类型。若后端持久化时用 `tileType` 生成 slot/path,同类型切片会写入同一个 `/generated-jump-hop-assets///image.png`,后上传的切片覆盖先上传的切片,前端换签缓存也会读到重复或旧对象。
+- 处理:后端切图后必须按 atlas 单元格写入 `tile-01` 到 `tile-25` 的唯一 slot/path;前端结果页和运行态展示生成图时用 `assetObjectId` 作为 `refreshKey`,避免重生成后复用旧签名或旧图片缓存。
+- 验证:`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` 应包含 `jump_hop_tile_asset_slots_are_unique_for_twenty_five_slices`;前端运行态测试应断言地块换签带 `assetObjectId` 刷新键。
+- 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/components/jump-hop-result/JumpHopResultView.tsx`。
+
+## 跳一跳落点辅助标识不要再用舞台高度常量拍脑袋投影
+
+- 现象:拖拽时落点辅助标识虽然会动,但看起来像静态点位漂移,和真实可落地的位置对不上。
+- 原因:辅助标识如果只按 `stageSize.height` 和一个固定比例估算投影距离,再去跟拖拽向量合成,就会和当前地块到目标地块的真实屏幕跨度脱节;三维场景层级过高时还会把辅助点直接盖住。
+- 处理:辅助标识必须使用当前地块与目标地块之间的真实屏幕距离和后端 `chargeToDistanceRatio` 做投影,再映射到屏幕坐标;同时把辅助层 z-index 放到三维角色层之上,避免被场景层遮挡。
+- 验证:拖拽半程时辅助点应落在当前地块和目标地块之间,完整拖拽时应逼近目标地块中心;运行态截图里辅助点必须始终压在地块与角色之上。
+- 关联:`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`。
+
+## 跳一跳落点辅助和后端裁决必须统一坐标换算
+
+- 现象:落点辅助标识已经压在目标地块图片上,松手后后端仍判定失败,玩家看到的是“明明瞄准了却没落上去”。
+- 原因:前端辅助标识使用屏幕像素坐标绘制,而后端裁决使用世界坐标。屏幕 y 轴向下为正、世界 y 轴向上为正;同时屏幕 x/y 每个世界单位对应的像素比例不同。若前端直接把屏幕像素拖拽向量发给后端,辅助点和后端落点方向会不一致。
+- 处理:前端运行态保留原始屏幕拖拽向量用于画弹弓和辅助点,但提交后端前必须按当前地块到目标地块的屏幕跨度 / 世界跨度把 x、y 分别换算成世界尺度一致的向量;后端继续只负责反向弹射和落点裁决。
+- 验证:前端回归测试要同时覆盖辅助点完整拖拽到目标地块,以及提交给后端的向量已完成世界尺度换算;后端领域测试覆盖屏幕向后下拉时应向世界 y 正方向跳出并命中。
+- 关联:`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`server-rs/crates/module-jump-hop/src/application.rs`。
+
+## 跳一跳创作入口旧文案先查 SpacetimeDB 配置
+
+- 现象:`JumpHopWorkspace` 已只剩主题输入,但创作 Tab 的跳一跳模板卡仍显示旧的“俯视角跳跃闯关”或拼图参考图。
+- 原因:创作入口卡片事实源是 SpacetimeDB `creation_entry_type_config` 和 `/api/creation-entry/config`,前端只做展示派生;如果只改工作台、PRD 或前端组件,已有库里的旧入口行不会自动变化。当前 `api-server` 读取入口配置时优先订阅缓存,缓存命中后不会再走 procedure 播种,所以只把迁移写在 `get_creation_entry_config` 里不够。
+- 处理:同步更新 `module-runtime` 默认入口种子,并在 `spacetime-module/src/runtime/creation_entry_config.rs` 加只命中旧系统默认值的迁移;同时在 `spacetime-client` 的入口配置读模型里做同一条旧系统默认行的读路径纠偏。跳一跳当前默认值为 `subtitle=主题驱动平台跳跃`、`image_src=/creation-type-references/jump-hop.webp`。
+- 验证:本地 `GET /api/creation-entry/config` 的 `jump-hop` 项应返回新 subtitle 和新 imageSrc;若仍旧,检查本地 SpacetimeDB 是否已发布当前 `spacetime-module`,以及后台是否手动覆盖过入口配置。若缓存路径和 procedure 路径返回不一致,优先怀疑读模型映射没做纠偏,而不是前端展示层。
+
## image2 dry-run 带参考图时不要直接打印 data URL
- 现象:使用 VectorEngine `gpt-image-2-all` 生成带参考图的概念图时,如果 dry-run 直接打印完整请求体,参考图会被转成超长 `data:image/png;base64,...`,终端日志会被数百万字符淹没。
@@ -1761,6 +1825,26 @@
- 验证:定向测试 `cargo test -p api-server generated_asset_sheet_two_items_per_row --manifest-path server-rs/Cargo.toml -- --nocapture` 应通过,且错位透明样本应按连通域切出完整视图。
- 关联:`server-rs/crates/api-server/src/generated_asset_sheets.rs`、`server-rs/crates/api-server/src/match3d/item_assets.rs`。
+## 腾讯云 release 上 VectorEngine `SendRequest` 超时先查出口链路与重试
+
+- 现象:release 机器调用 VectorEngine `gpt-image-2` 的 `/v1/images/generations` 或 `/v1/images/edits` 偶发 `client error (SendRequest) -> connection error -> Connection timed out (os error 110)`,应用层表现为 504;本地通常正常。
+- 原因:本地 DNS 可能走代理 / 加速出口,而腾讯云 release 直接解析到 VectorEngine 真实边缘节点。实测同一张约 2.37MB PNG、同一 edits 请求,`curl` 5/5 成功,但 `reqwest/hyper` 会间歇性超时;固定 `40.160.33.47` 也只能改善,不能根治。
+- 处理:不要优先关闭 multipart,也不要直接把 `SendRequest` 解释成上游业务拒绝。VectorEngine 图片 `generations` / `edits` 上游 POST 单独使用 `libcurl`;参考图下载和响应图片 URL 下载仍用 `reqwest`。send 阶段 timeout / connect error 在 `platform-image` 内最多重试 5 次,使用指数退避和短抖动;日志字段 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total`、`request_params` 是定位依据。
+
+### api-server libcurl / OpenSSL 3.2 runtime
+
+- 症状:release 部署新 `api-server` 后服务反复 `exit-code`,`LD_TRACE_LOADED_OBJECTS=1 /opt/genarrative/current/api-server` 或 `ldd` 报 `/lib/x86_64-linux-gnu/libssl.so.3: version 'OPENSSL_3.2.0' not found`。
+- 根因:`platform-image` 使用 `libcurl` 后,Linux release 构建产物可能直接要求 `OPENSSL_3.2.0` 符号;Ubuntu 24.04 apt 默认 OpenSSL 仍是 `3.0.13`,不能满足该符号版本。
+- 处理:`Genarrative-Server-Provision` 独立安装 OpenSSL `3.2.0` 到 `/opt/genarrative/openssl-3.2.0`,并只通过 `genarrative-api.service` 的 `LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib` 给 api-server 使用,避免替换系统 OpenSSL。
+
+### VectorEngine edits multipart image part
+
+- 症状:拼图参考图链路请求 `/v1/images/edits` 返回 `500 image is required`,但应用日志里 `reference_image_count=1`、`reference_image_bytes_total>0`,`request_params.referenceImages[0]` 也有 `field=image`、文件名、MIME 和 bytes。
+- 根因:Rust `curl::easy::Form` 中 `contents(...).filename(...)` 不等价于文件上传 part;VectorEngine 转码层会认为没有收到图片。release 上用 curl CLI `-F image=@file` 可成功,证明字段名和上游接口本身没变。
+- 处理:multipart 参考图必须用 `Form::buffer(file_name, bytes)` 并设置 `content_type(...)`,让 libcurl 生成真正的 `name="image"; filename="..."` 文件 part。
+- 验证:release 上先看 `journalctl -u genarrative-api.service` 中 `VectorEngine 图片请求发送失败,准备重试` 与最终 `HTTP 返回`;若仍失败,再用同一图片分别跑 curl 与最小 reqwest 探针对照。
+- 关联:`server-rs/crates/platform-image/src/vector_engine/client.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
+
## 个人中心不再保留直达“存档”按钮入口
- 现象:2026-05-25 起,移动端“我的”页顶部改为品牌行 + 扫码 / 设置按钮,设置区和次级入口不再提供独立的 `存档` 按钮;用户仍可在“玩过”弹窗里查看可继续存档。
@@ -1769,6 +1853,22 @@
- 验证:`npm test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile profile page matches the reference layout sections|profile scan action opens camera scanner instead of recharge panel"`。
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+## 旧创作入口先确认是不是旧 worktree 在响应
+
+- 现象:浏览器里明明还看到跳一跳旧入口,比如 `俯视角跳跃闯关` 和 `puzzle.webp`,但当前 worktree 里已经改成了 `主题驱动平台跳跃` 和 `jump-hop.webp`。
+- 原因:本机常同时存在两个开发栈,旧 worktree 可能还在占用 `3000/8082/3101/3102`,而当前 worktree 可能跑在另一组端口。只看页面文案就下结论,容易把旧进程误认成当前改动没生效。
+- 处理:先用 `Get-NetTCPConnection` / `Get-CimInstance Win32_Process` 确认端口对应的可执行文件和命令行,再分别请求 `/api/creation-entry/config` 比对旧端口与当前 worktree 端口。必要时以当前 worktree 的实际端口为准重新打开页面。
+- 验证:旧端口返回旧跳一跳入口,当前 worktree 端口返回新跳一跳入口;两边的 `api-server` / `vite-cli` 命令行应指向不同仓库路径。
+- 关联:`scripts/dev.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+
+## 3001 无法访问先查旧 worktree 占端口和 SpacetimeDB 版本
+
+- 现象:`http://127.0.0.1:3001/` 打不开,但 `3000 / 3101 / 8082` 仍有进程;`npm run dev` 直接退出,没有把新栈拉起来。
+- 原因:旧 worktree 的 `api-server`、`spacetime-standalone` 和 Vite 还活着,或者当前 worktree 的本机 SpacetimeDB CLI 默认版本低于仓库锁定版本,`scripts/dev.mjs` 会先校验版本再启动并直接报错退出。
+- 处理:先停掉占用端口的旧进程,再执行 `spacetime version list` 和 `spacetime version use 2.3.0`,确认本机 CLI/standalone 与仓库一致后重新启动 `npm run dev -- --no-interactive --web-port 3001 --api-port 8083 --spacetime-port 3103 --admin-web-port 3104`。
+- 验证:`http://127.0.0.1:3001/`、`http://127.0.0.1:8083/healthz`、`http://127.0.0.1:3103/v1/ping` 都返回 200,且进程命令行指向当前 worktree 路径而不是别的仓库。
+- 关联:`scripts/dev.mjs`、`.hermes/shared-memory/pitfalls.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
+
## 微信历史孤儿作品不要让新注册账号顶替
- 现象:清空用户数据或迁移历史数据后,旧作品的 `owner_user_id` 为空或失效,新注册用户会因为顺序号复用或旧 ID 残留顶替作品归属,导致刚注册就看到别人的草稿或已发布作品。
@@ -1903,3 +2003,42 @@
- 处理:开局和补牌后的重排必须先排除现成消除,再用真实交换 / 落位模拟判断是否会产生新消除;`1x2` 永远不进入半锁定组,半锁定只允许 `1x3`、`2x2`、`2x3`。
- 验证:`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx` 与 `cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml -- --nocapture` 通过后,开局盘面不应直接出现 completed group。
- 关联:`src/services/puzzle-clear/puzzleClearLocalRuntime.ts`、`server-rs/crates/module-puzzle-clear/src/application.rs`。
+## 推荐页作品 key 漏玩法会导致运行内容和标题作者错位
+
+- 现象:移动端推荐页进入跳一跳或敲木鱼等作品时,游戏运行内容已经切到当前作品,但下方标题、作者和头像仍显示第一条拼图或其它推荐作品。
+- 原因:平台壳层用 `getPlatformPublicGalleryEntryKey(...)` 写入 `activeRecommendEntryKey`,而 `RpgEntryHomeView` 内部的 `buildPublicGalleryCardKey(...)` 漏掉新玩法 `sourceType` 分支,导致当前 key 查不到条目后回退到推荐列表第一条。
+- 处理:推荐页和平台壳层的公开作品 key 规则必须复用 `buildPlatformPublicGalleryCardKey(...)`,覆盖同一批 `sourceType`,至少包括 `big-fish`、`puzzle`、`jump-hop`、`wooden-fish`、`match3d`、`square-hole`、`visual-novel`、`bark-battle` 和 `edutainment:`;新增玩法公开推荐流时先补这个共享 helper。
+- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile recommend meta matches active"` 应覆盖跳一跳和敲木鱼的当前运行内容、标题和作者一致。
+- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+
+## 跳一跳飞行动画不要直接用最新 run 重绘地块窗口
+
+- 现象:跳一跳松手后如果后端很快返回下一帧 run,地块窗口会立刻前移,角色翻腾动画看起来像没播放;若同时刷新图片资产,还可能被误认为地块频闪。
+- 原因:后端 run 是规则真相,前端 runtime 又需要低延迟表现。如果 DOM 平台层直接用最新 `run.currentPlatformIndex` 渲染,后端回包会抢在动画前完成视觉切换。
+- 处理:前端保留独立 `displayRun`,松手后先进入 `isJumpAnimating=true`,角色在当前窗口内插值飞向目标地块;约 `300ms` 后再把 `displayRun` 切到最新后端 run,并进入约 `1440ms` 的 `platformAdvancing` 表现态。推进期间地块 DOM 层和 Three.js 角色层必须统一包在同一个 camera layer 下移动,旧当前地块用相机偏移自然离开视野,新预览地块从上方露出;不要再让 p1/p2 各自 top/left 过渡。相机层必须同时设置 `--jump-hop-camera-shift-x` 与 `--jump-hop-camera-shift-y`,从旧目标地块位置斜向滑到新当前地块聚焦位置,避免先横向瞬切居中再纵向推进。地块保留当前 / 目标 / 预览的深度尺寸差异,但深度差异必须用固定宽高 + CSS transform scale 缓动实现,不能直接改宽高瞬切;当前态不要额外叠 CSS scale。相机推进期间角色自身也不能保留 `left/top` transition,否则 `displayRun` 切换造成的角色局部坐标变更会和父级 camera layer 位移叠加,视觉上像落地后又从屏幕外飞回;角色推进期只允许 transform / opacity transition。正式胜负、成功跳跃次数、时长和排行榜仍以后端 run 为准,前端只延迟显示态。
+- 验证:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖动画期间平台仍停在旧窗口,动画结束后进入 `data-platform-advancing=true`,Three 角色层与地块层同在 `jump-hop-camera-layer` 内,通过 `--jump-hop-camera-shift-x` 和 `--jump-hop-camera-shift-y` 完成相机斜向推进,并校验可见地块按深度保留不同视觉尺寸、运行态平台宽高使用固定基准值、推进态 transform transition 为 `1440ms`、推进态角色 transition 不包含 `left/top`。
+- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、`server-rs/crates/module-jump-hop/src/application.rs`。
+
+## 跳一跳相机推进不要让地块图片回退到原型方块
+
+- 现象:角色落到下一块后,相机推进时旧地块图片突然消失,或新预览地块先露出浅色原型方块,随后真实 image2 切片才出现。
+- 原因:旧地块进入 exiting 状态时如果 React key 从 `platformId` 变成 `platformId-exiting`,图片组件会重新挂载并丢失已加载状态;同时 `JumpHopTileImage` 曾在真实图片 URL 已存在但 `onLoad` 尚未触发时显示 fallback 原型地块。
+- 处理:exiting 地块继续使用稳定 `platformId` key,让旧图片组件在推进期复用;有真实 `resolvedUrl` 且未错误时直接保留真实 `
`,只在无 URL 或加载失败时显示 fallback;当前 3 块之外的后续地块通过隐藏预加载图片提前解析签名 URL 和浏览器缓存。
+- 验证:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/services/jump-hop/jumpHopRuntimeModel.test.ts` 应覆盖真实 tile URL 不露出 `.jump-hop-runtime__fallback-tile`,并存在 `jump-hop-tile-preload-image`。
+- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`。
+
+## 跳一跳地块抠图不要用绿幕或近白底识别
+
+- 现象:跳一跳生成草地、花、雪地、白石或云朵地块时,透明化会把绿色 / 白色主体局部扣掉,运行态看到平台缺口、变薄或主体消失。
+- 原因:通用图集默认按绿幕和近白底做透明化,适合 UI / 普通物品,但跳一跳地块天然高频包含绿色和白色;如果继续用 `#00FF00` 绿幕或近白背景识别,素材本体会落入背景分数。旧逻辑还会清理非边缘连通的高置信 key 色块,遇到主体内部撞色时也可能误伤。
+- 处理:跳一跳地块图集 prompt 固定要求单一纯洋红 `#FF00FF` key 背景;切片前后透明化调用 `GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen()`,只扣洋红 key,关闭近白扣除,并且不清理非边缘连通 key 色像素。通用绿幕函数保持默认绿幕 / 近白兼容,避免影响拼图、抓大鹅和敲木鱼。
+- 验证:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml generated_asset_sheet -- --nocapture` 覆盖洋红 key 保留绿色、白色和非边缘连通 key 色主体;`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` 覆盖跳一跳洋红 prompt 与绿 / 白地块切片。
+- 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs`、`server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs`、`server-rs/crates/api-server/src/jump_hop.rs`。
+
+## 含中文 image2 live 验证不要用 PowerShell 管道喂 Node 源码
+
+- 现象:本地用 `@'...'@ | node -` 跑 VectorEngine / gpt-image-2 live 验证时,`request.json` 里的中文 prompt 可能全部变成 `????`,生成图会变成完全不相关的 UI、建筑海报或其它随机内容,容易误判为模型不服从提示词。
+- 原因:Windows PowerShell 管道到 Node stdin 时可能按本机非 UTF-8 编码传输脚本文本,JS 源码里的中文字符串在进入 Node 前已经损坏;Rust 后端真实请求不会走这条编码路径。
+- 处理:含中文提示词的 live 验证优先写成 UTF-8 `.mjs` 文件再执行,或使用能确认 UTF-8 的运行入口;执行后先检查本次 `request.json` 是否保留真实中文,再判断生图质量。不要基于 `????` prompt 生成的图片调整项目提示词。
+- 验证:生成前后检查 `request.json`,其中 `prompt` 字段应显示中文而不是问号;同一提示词在 UTF-8 文件脚本下应能得到符合主题的图。
+- 关联:`.codex/skills/gpt-image-2-apimart/SKILL.md`、`server-rs/crates/api-server/src/jump_hop.rs`。
diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf
index 239b5c4c..be9dd0eb 100644
--- a/deploy/container/nginx.conf
+++ b/deploy/container/nginx.conf
@@ -190,7 +190,7 @@ http {
proxy_set_header X-Request-Id $request_id;
}
- location ~ ^/(generated-|healthz) {
+ location ~ ^/(generated-|healthz|readyz) {
return 404;
}
diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example
index 1434d8be..90b2378b 100644
--- a/deploy/env/api-server.env.example
+++ b/deploy/env/api-server.env.example
@@ -11,6 +11,7 @@ GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512
GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320
GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64
GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS=16
+GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS=5000
GENARRATIVE_TRACKING_OUTBOX_ENABLED=true
GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox
GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE=500
diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf
index b7c0cdaa..62e87f14 100644
--- a/deploy/nginx/genarrative-dev-http.conf
+++ b/deploy/nginx/genarrative-dev-http.conf
@@ -215,7 +215,7 @@ server {
}
# 开发服仍不恢复旧生成资源代理和健康检查公网入口。
- location ~ ^/(generated-|healthz) {
+ location ~ ^/(generated-|healthz|readyz) {
return 404;
}
diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf
index c26e9bbb..fa1a111b 100644
--- a/deploy/nginx/genarrative.conf
+++ b/deploy/nginx/genarrative.conf
@@ -235,7 +235,7 @@ server {
}
# 生产公网不再暴露旧生成资源代理和健康检查入口。
- location ~ ^/(generated-|healthz) {
+ location ~ ^/(generated-|healthz|readyz) {
return 404;
}
diff --git a/deploy/systemd/genarrative-api.service b/deploy/systemd/genarrative-api.service
index bba53a79..c061d024 100644
--- a/deploy/systemd/genarrative-api.service
+++ b/deploy/systemd/genarrative-api.service
@@ -10,11 +10,12 @@ User=genarrative
Group=genarrative
WorkingDirectory=/opt/genarrative/current
EnvironmentFile=/etc/genarrative/api-server.env
+Environment="LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib"
ExecStart=/opt/genarrative/current/api-server
Restart=always
RestartSec=5
KillSignal=SIGINT
-TimeoutStopSec=30
+TimeoutStopSec=90
LimitNOFILE=65535
TasksMax=2048
diff --git a/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md b/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md
index 6bf23f8e..218b02f0 100644
--- a/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md
+++ b/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md
@@ -205,10 +205,11 @@ WF-*
1. 若 payload 已包含上传/录音音频资产,`compile-draft` 跳过音效生成,直接持久化该资产;
2. 若 payload 已上传或录制音频,则直接写回 `hitSoundAsset`;
-3. 若两者都没有,后端写回默认木鱼音 `/wooden-fish/default-hit-sound.mp3`;
-4. 音效资产必须包含可播放地址、对象键、asset object id、来源和可选时长;
-5. 通用创作音频接口当前对 `wooden_fish` 的 `hit_sound` 目标返回 `410 Gone`,不得在创作流程中按提示词生成音效;
-6. `spacetime-client` 不得自行合成 `/generated-wooden-fish-assets/...` 音效占位路径;缺少真实 `hitSoundAsset` 时应使用默认木鱼音兜底展示与播放。
+3. 麦克风录制音频在保存前由前端自动裁掉开头连续静音段;上传音频不做裁剪,裁剪失败时保留原始录音继续保存;
+4. 若两者都没有,后端写回默认木鱼音 `/wooden-fish/default-hit-sound.mp3`;
+5. 音效资产必须包含可播放地址、对象键、asset object id、来源和可选时长;
+6. 通用创作音频接口当前对 `wooden_fish` 的 `hit_sound` 目标返回 `410 Gone`,不得在创作流程中按提示词生成音效;
+7. `spacetime-client` 不得自行合成 `/generated-wooden-fish-assets/...` 音效占位路径;缺少真实 `hitSoundAsset` 时应使用默认木鱼音兜底展示与播放。
### 6.3 封面
@@ -371,7 +372,7 @@ finish
音频播放:
-1. 前端使用小复音池;
+1. 前端使用 10 路小复音池;
2. 设置最小播放间隔,避免极端连点导致浏览器抖动;
3. 点击计数不能因为音频节流而丢失;
4. 签名 URL 未就绪时先静音表现,不请求裸 generated 私有路径。
diff --git a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md
index 33dea4b7..6bbc45af 100644
--- a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md
+++ b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md
@@ -2,491 +2,193 @@
## 1. 目标
-新增一个可创作、可试玩、可发布的玩法模板:
+`jump-hop` 重定义为竖屏俯视角平台跳跃游戏。创作者只输入主题,系统生成一张该主题的 `5x5` 地块资源图集,切成 25 个 2D 地块素材;运行态使用抠除白底后的陶泥儿 logo 透明 PNG 作为玩家角色,并和这些 2D 地块资产组成无限平台流。
-```text
-跳一跳
-```
+首版目标:
-本模板参考拼图模板的创作闭环,沿用“创作入口 -> 生成过程页 -> 结果页 -> 试玩 -> 发布”的平台链路,但玩法本体改为俯视角 / 等距视角的跳跃闯关。
-
-首版要求:
-
-1. 初始草稿生成时,角色形象单独调用一次生图;
-2. 初始草稿生成时,地块只调用一次生图,输出 3D 视图的 2D 图片图集;
-3. 运行态不接真实 3D 网格,不生成 GLB / glTF;
-4. 作品可以直接进入试玩和发布。
+1. 创作输入只保留主题,标题、简介、标签和提示词由系统派生;
+2. image2 只生成一张 `5x5` 地块图集,后端均匀切成 25 张 PNG;
+3. 角色不再单独生图,v1 使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG;
+4. 运行态每屏只展示 3 个地块:当前地块、目标地块、下一预览地块;
+5. 操作方式为按住屏幕向后拖动蓄力,松手后角色向拖拽反方向弹出;
+6. 只要落点未命中下一个地块,本局立即失败并冻结计时;
+7. 成绩记录成功跳跃次数和游戏时长;
+8. 排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长,排序为成功跳跃次数降序、游戏时长升序、更新时间升序。
## 2. 模板定位
-模板 ID:
+- 模板 ID:`jump-hop`
+- 展示名:`跳一跳`
+- 工程域:`jump-hop`
+- 创作入口卡:`subtitle = 主题驱动平台跳跃`,`imageSrc = /creation-type-references/jump-hop.webp`
+- 运行态:`DOM 平台 / DOM 角色 + Three.js 透明扩展层 + DOM HUD`
+- 画面比例:移动端竖屏优先,桌面端居中承载 `9:16`
+- 素材策略:2D 地块图集 + 陶泥儿 logo 透明角色
+- 渲染分层:生成地块切片必须由 DOM 平台层直接渲染为图片;角色必须由 DOM 透明 PNG 层渲染并保持最高层级,Three.js 透明画布只作为后续扩展层,不能把地块图片或角色回退为 WebGL 占位材质
+
+本玩法不是横版平台跳跃,也不是关卡制闯关。平台从屏幕下方向上无限延展,目标地块在当前地块上方不同 x 轴位置随机出现。
+
+## 3. 创作工具平台接入声明
+
+- 工作台模式:表单输入创作工作台
+- 创作链路:入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态
+- 单图资产槽位:无独立角色图槽位;v1 固定使用陶泥儿 logo 透明 PNG 角色
+- 系列素材槽位:
+ - `batchId = jump-hop-tile-atlas`
+ - `sheetSpec = 5x5 / 1:1 / PNG / 纯绿色绿幕背景 / 后端切图透明化`
+ - `slotSpecs = tile-01 ... tile-25`,每个 slot 必须对应唯一 OSS path / `assetObjectId`
+ - 切图规则:按原图宽高均分为 5 行 5 列,从上到下、从左到右切出 25 张 PNG;每格透明化后只保留最大的 alpha 连通主体,再裁边并补透明安全边,避免相邻格越界碎片或方形杂边进入 tile
+ - 透明化规则:生成时要求绿幕背景,后端上传 OSS 前抠成透明 PNG,并清理与主体分离的小型残片
+ - 失败回写:生成失败时 session 保持 failed,可从生成页重试
+ - 局部重生成:结果页允许重生成地块图集,仍只调用一次 image2;前端展示生成图时以 `assetObjectId` 作为刷新键,避免同一路径重写后的旧签名或旧缓存
+- API 命名空间:`/api/creation/jump-hop/*`、`/api/runtime/jump-hop/*`
+- 业务真相:后端裁决落点、失败、成功跳跃次数、冻结时长和排行榜
+- 创作工具模式例外:无
+- 验证命令:`npm run check:encoding`、`npm run typecheck`、`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml`
+
+## 4. 创作输入
+
+主题是唯一必填项。工作台不展示角色提示词、地块提示词、风格卡、难度卡、终点氛围或规则说明。
+
+提交后系统自动派生:
+
+1. 作品标题:主题为空白修剪后的短标题,默认前缀不外露;
+2. 作品简介:基于主题生成一句短简介;
+3. 标签:`跳一跳`、`休闲` 和主题关键词;
+4. 地块提示词:围绕主题生成 25 个风格一致的俯视角清爽游戏化 2D 平台素材,每一块都是符合主题的单独可跳跃平台;实际 image2 prompt 使用“独立可落脚平台素材 / 平台裸素材 / 完整平台”措辞,不再把正向主体描述成图标集或游戏界面资源;
+5. 初始平台流参数:固定 v1 标准参数,不让创作者手工调规则。
+
+## 5. 地块图集
+
+image2 只生成一张 `1:1` 图片,画面为 `5x5` 均匀分布平台裸素材;实际提示词必须先约束“画面只包含 25 个独立跳一跳可落脚平台素材”,并明确不是游戏界面、棋盘、背包、装备栏或图标集页面。
+
+图集要求:
+
+1. 每格只放一个完整地块资源;
+2. 资源为纯 2D 平面素材,但要表现为符合主题且有设计感的俯视角清爽游戏化立体感平台,有顶面、主体内部明暗和清晰轮廓;主题元素必须直接成为平台主体,例如“水果”应生成苹果切片、橙子切片、西瓜块、草莓、菠萝、香蕉等水果造型平台;
+3. 25 个地块来自同一主题、同一光向和同一材质体系;
+4. 背景为纯绿色绿幕,方便后端透明化;
+5. 不包含角色、文字、水印、UI、游戏面板、棋盘、背包、装备栏、按钮、标题、外层边框、网格线、场景背景、落地投影、接触阴影、方形阴影、方形底板、白底、灰底或黑底;
+6. 地块不能跨格、贴边或进入相邻格,主体必须居中并保留至少 18% 纯绿色安全留白;每个平台之间只能是纯绿色空白,不画容器框或棋盘格。
+
+切片顺序固定为:
```text
-jump-hop
+tile-01 tile-02 tile-03 tile-04 tile-05
+tile-06 tile-07 tile-08 tile-09 tile-10
+tile-11 tile-12 tile-13 tile-14 tile-15
+tile-16 tile-17 tile-18 tile-19 tile-20
+tile-21 tile-22 tile-23 tile-24 tile-25
```
-用户展示名:
+运行态随机使用这 25 个地块作为后续平台外观。起点地块可复用第一个切片,其余平台从完整池中随机选择。
+
+## 6. 运行态规则
+
+### 6.1 平台流
+
+运行态从底部初始地块开始,后续地块持续向屏幕上方生成。每次相机窗口只保留 3 个地块可见:
+
+1. 当前地块;
+2. 目标地块;
+3. 下一预览地块。
+
+服务端保存当前 run 的路径缓冲,并在每次成功落地后按同一 seed 补齐后续地块。前端只展示服务端快照,不自行生成正式路径。
+
+### 6.2 操作
+
+1. 用户按住当前地块或画面;
+2. 向后拖动形成蓄力向量;
+3. 松手后角色沿拖拽反方向弹出;
+4. 拖拽距离决定力度,拖拽方向决定落点方向;
+5. 力度和方向都由前端提交给后端裁决。
+
+手感参数固定由后端 `module-jump-hop` 提供:`chargeToDistanceRatio = 0.008`。该值表示同等世界跳跃距离只需要旧版 `0.004` 配置的一半屏幕拖动距离;旧作品运行时若仍携带 `0.004`,开局归一化为 `0.008`。
+
+松手后前端必须立即生成 `visualJump`,用当前角色位置作为起点、前端预测落点作为终点,播放约 `560ms` 的角色飞行动画;角色从当前地块弹向预测落点,蓄力阶段角色应沿拖拽方向明显拉长,落地后再向反方向回弹两次。动画期间 DOM 地块窗口保持在本次起跳前的 3 块布局,动画路径不得等待后端新 run。若后端新 run 晚于飞行动画返回,角色必须停在预测落点等待,直到新 run 到达后再把显示态切到后端返回的最新 run,并进入约 `1440ms` 的相机推进过渡。推进过渡中,地块 DOM 层和 DOM 角色层必须放在同一个相机层里统一位移,不允许 p1/p2 单独改 `top/left` 做过渡;旧当前地块随相机推进自然离开视野,新预览地块从上方自然露出,避免角色和地块不同步或闪现。相机推进必须同时携带 X/Y 偏移,从旧目标地块位置斜向滑到新当前地块聚焦位置,不允许先横向瞬切居中后再只做纵向滑动。地块可以保留当前 / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 CSS `transform: scale(...)` 表达,并在相机推进期间用同一 `1440ms` 缓动过渡;不得通过直接改宽高造成瞬切变大。当前地块高亮不得额外通过 CSS `scale` 放大。该动画只属于表现层,命中、失败、成功跳跃次数和冻结时长仍以后端裁决为准。
+
+### 6.3 判定
+
+1. 目标永远是当前地块后的下一个地块;
+2. 落点进入下一个地块落地半径,则成功;
+3. 落点未进入下一个地块落地半径,则失败;
+4. 失败后状态改为 `failed`,计时冻结;
+5. v1 没有通关状态、combo、perfect 或生命数。
+
+### 6.4 计分与时间
+
+- 成功跳跃次数:每成功落到下一个地块后 `+1`;
+- 游戏时长:`startedAtMs` 到 `finishedAtMs`,失败时冻结;
+- 运行中时长由前端根据服务端 `startedAtMs` 展示;
+- 失败后只展示冻结时长。
+
+## 7. 排行榜
+
+排行榜按作品维度生成。每位玩家只保留 1 条最佳记录。
+
+排序规则固定为:
```text
-跳一跳
+successfulJumpCount desc -> durationMs asc -> updatedAt asc
```
-体验关键词:
-
-1. 俯视角;
-2. 等距感地块;
-3. 单局闯关;
-4. 长按蓄力,松手起跳;
-5. 轻量休闲。
-
-首版采用竖屏优先的移动端体验,桌面端保持居中展示,画面比例以 `9:16` 为主。参考图的核心视觉要点是:
-
-1. 大面积留白或浅色渐变背景;
-2. 角色站在单个地块上;
-3. 地块有明显顶面、侧面和投影;
-4. 整体是俯视角 / 等距视角,而不是横版平台跳跃;
-5. UI 克制,只保留必要控制,不堆说明文案。
-
-## 3. 与拼图模板的复用边界
-
-可以复用:
-
-1. 创作入口和模板分流;
-2. 生成过程页;
-3. 结果页的草稿保存、返回编辑、试玩、发布、分享链路;
-4. 作品架展示和草稿恢复口径;
-5. 平台统一的发布与公开展示流程。
-
-不复用:
-
-1. 拼图关卡切片逻辑;
-2. 拼图拖拽拼块逻辑;
-3. 拼图 UI 背景和多关卡编辑结构;
-4. 任何方格拼合语义。
-
-## 4. 工程接入范围
-
-首版需要做到完整玩法闭环,不只做入口占位。
-
-新增前端阶段:
-
-```text
-jump-hop-workspace
-jump-hop-generating
-jump-hop-result
-jump-hop-runtime
-jump-hop-gallery-detail
-```
-
-新增前端组件建议:
-
-1. `src/components/unified-creation/workspaces/JumpHopCreationWorkspace.tsx`;
-2. `src/components/jump-hop-result/JumpHopResultView.tsx`;
-3. `src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`;
-4. `src/services/jump-hop/jumpHopClient.ts`。
-
-新增共享契约建议:
-
-1. `packages/shared/src/contracts/jumpHop.ts`;
-2. `server-rs/crates/shared-contracts/src/jump_hop.rs`。
-
-新增后端模块建议:
-
-1. `server-rs/crates/module-jump-hop`:纯领域规则,包含路径生成、蓄力换算、落点判定、通关 / 失败状态机;
-2. `server-rs/crates/api-server/src/jump_hop.rs` 和 `src/jump_hop/` 子模块:HTTP handler、生成编排、资产保存和 DTO 映射;
-3. `server-rs/crates/spacetime-module/src/jump_hop.rs`:session、work profile、runtime run、公开 view 和 reducer / procedure;
-4. `server-rs/crates/spacetime-client/src/jump_hop.rs`:api-server 访问 SpacetimeDB 的 facade;
-5. `server-rs/crates/api-server/src/modules/jump_hop.rs`:路由挂载。
-
-入口配置事实源必须走 SpacetimeDB `creation_entry_type_config` 默认种子和后台配置接口,不新增前端硬编码入口配置。
-
-## 5. 创作输入
-
-创作者需要填写以下内容:
-
-1. 作品主题描述,必填;
-2. 角色形象描述,必填;
-3. 地块风格卡,必选;
-4. 难度,必选;
-5. 可选的终点氛围或节奏偏好。
-
-推荐的最小输入形态是:
-
-1. 一句话主题;
-2. 角色一句话描述;
-3. 风格卡;
-4. 难度卡。
-
-不在首版开放手工拖拽平台编辑器。平台路径、地块间距和终点位置由系统自动生成,创作者只负责风格与难度选择。
-
-### 5.1 地块风格卡
-
-建议提供以下风格:
-
-1. 极简积木;
-2. 纸模玩具;
-3. 霓虹玻璃;
-4. 森林石块;
-5. 未来金属;
-6. 自定义。
-
-### 5.2 难度
-
-建议提供以下离散档位:
-
-1. 轻松;
-2. 标准;
-3. 进阶;
-4. 挑战。
-
-难度主要影响:
-
-1. 平台路径长度;
-2. 平台间距;
-3. 可落点容差;
-4. 完美落点窗口;
-5. 终点前的节奏变化。
-
-## 6. 生成规则
-
-本模板必须把生图责任拆成两条独立链路:
-
-### 6.1 角色形象只生一次
-
-角色形象必须只调用一次生图,输出一张可直接进入运行态的主角色图。
-
-角色图要求:
-
-1. 单人主角;
-2. 全身可见;
-3. 透明背景;
-4. 角色站姿或轻微前倾姿态;
-5. 镜头和透视必须匹配俯视角场景;
-6. 不要求多视角,不要求多帧动画图集。
-
-角色图生成后作为作品级锚点资产使用,结果页、封面合成、试玩和发布都复用同一张图。后续如果只修改标题、标签、难度或路径,不应默认重新生角色。只有用户在结果页明确点击“重生成角色”时,才允许再调用一次角色生图。
-
-### 6.2 地块只生一次图集
-
-地块必须只调用一次生图,输出一张 3D 视图的 2D 图片图集,再由后端切成运行态可用的地块资产。该图集使用跳一跳专用 `2行*3列` 六格布局,不套用通用“每个物品一行、每行 n 个不同视图”的系列素材模型。
-
-地块图集要求:
-
-1. 统一使用等距 / 俯视角;
-2. 必须表现出顶面、侧面和投影;
-3. 必须与角色图保持同一光向;
-4. 必须有清晰的立体层次,但仍然是 2D 图片;
-5. 六格必须按固定顺序包含以下地块类型:
- - 起点地块;
- - 普通地块;
- - 目标地块;
- - 终点地块;
- - 奖励地块;
- - 视觉强调地块。
-
-固定格位为:
-
-| 格位 | tileType | 语义 |
-| --- | --- | --- |
-| 第 1 行第 1 列 | `start` | 起点地块 |
-| 第 1 行第 2 列 | `normal` | 普通地块 |
-| 第 1 行第 3 列 | `target` | 目标地块 |
-| 第 2 行第 1 列 | `finish` | 终点地块 |
-| 第 2 行第 2 列 | `bonus` | 奖励地块 |
-| 第 2 行第 3 列 | `accent` | 视觉强调地块 |
-
-图集生成后按地块类型切分并去掉背景,运行态直接消费切好的 PNG,不在前端做复杂拼接。只有用户在结果页明确点击“重生成地块”时,才允许再调用一次地块图集生图。
-
-### 6.3 不新增第三次生成
-
-首版不把封面、分享海报、路径预览再拆成第三次图像生成。封面和分享图必须由角色图 + 地块图集在本地或后端轻量合成,不额外增加新的角色生图次数。
-
-### 6.4 路径元数据
-
-除图片资产外,系统还必须生成跳跃路径元数据:
-
-1. 平台序列;
-2. 平台中心点;
-3. 平台宽度;
-4. 平台间距;
-5. 终点索引;
-6. 评分和容差参数。
-
-路径由领域规则自动生成,创作者不直接编辑坐标。路径元数据不依赖 LLM 或图片生成。
-
-### 6.5 推荐的难度区间
-
-| 难度 | 平台数量 | 平台间距 | 节奏 |
-| --- | ---: | --- | --- |
-| 轻松 | 12 - 14 | 短 | 宽容 |
-| 标准 | 16 - 18 | 中 | 稳定 |
-| 进阶 | 20 - 24 | 中长 | 紧凑 |
-| 挑战 | 26 - 32 | 长 | 高压 |
-
-平台宽度和容差由系统按难度自动缩放,不要求创作者手工填写。
-
-## 7. 契约草案
-
-### 7.1 草稿结构
-
-`JumpHopDraft` 至少包含:
-
-1. `templateId = "jump-hop"`;
-2. `templateName = "跳一跳"`;
-3. `profileId`;
-4. `workTitle`;
-5. `workDescription`;
-6. `themeTags`;
-7. `difficulty`;
-8. `stylePreset`;
-9. `characterPrompt`;
-10. `tilePrompt`;
-11. `characterAsset`;
-12. `tileAtlasAsset`;
-13. `tileAssets[]`;
-14. `path`;
-15. `coverComposite`;
-16. `generationStatus`。
-
-### 7.2 资产结构
-
-`JumpHopCharacterAsset` 至少包含:
-
-1. `assetId`;
-2. `imageSrc`;
-3. `imageObjectKey`;
-4. `assetObjectId`;
-5. `generationProvider`;
-6. `prompt`;
-7. `width`;
-8. `height`。
-
-`JumpHopTileAsset` 至少包含:
-
-1. `tileType`;
-2. `imageSrc`;
-3. `imageObjectKey`;
-4. `assetObjectId`;
-5. `sourceAtlasCell`;
-6. `visualWidth`;
-7. `visualHeight`;
-8. `topSurfaceRadius`;
-9. `landingRadius`。
-
-`tileType` 首版限定:
-
-```text
-start | normal | target | finish | bonus | accent
-```
-
-### 7.3 路径结构
-
-`JumpHopPath` 至少包含:
-
-1. `seed`;
-2. `difficulty`;
-3. `platforms[]`;
-4. `finishIndex`;
-5. `cameraPreset`;
-6. `scoring`。
-
-`JumpHopPlatform` 至少包含:
-
-1. `platformId`;
-2. `tileType`;
-3. `x`;
-4. `y`;
-5. `width`;
-6. `height`;
-7. `landingRadius`;
-8. `perfectRadius`;
-9. `scoreValue`。
-
-### 7.4 运行态快照
-
-`JumpHopRunSnapshot` 至少包含:
-
-1. `runId`;
-2. `profileId`;
-3. `status = playing | failed | cleared`;
-4. `currentPlatformIndex`;
-5. `score`;
-6. `combo`;
-7. `lastJump`;
-8. `startedAtMs`;
-9. `finishedAtMs`。
-
-`lastJump` 至少包含:
-
-1. `chargeMs`;
-2. `jumpDistance`;
-3. `targetPlatformIndex`;
-4. `landedX`;
-5. `landedY`;
-6. `result = miss | hit | perfect | finish`。
-
-## 8. API 草案
-
-HTTP 路由建议:
-
-```text
-POST /api/creation/jump-hop/sessions
-GET /api/creation/jump-hop/sessions/{sessionId}
-POST /api/creation/jump-hop/sessions/{sessionId}/actions
-POST /api/creation/jump-hop/works/{profileId}/publish
-GET /api/runtime/jump-hop/works/{profileId}
-POST /api/runtime/jump-hop/runs
-POST /api/runtime/jump-hop/runs/{runId}/jump
-POST /api/runtime/jump-hop/runs/{runId}/restart
-GET /api/runtime/jump-hop/gallery
-GET /api/runtime/jump-hop/gallery/{publicWorkCode}
-```
-
-动作类型建议:
-
-```text
-compile-draft
-regenerate-character
-regenerate-tiles
-update-work-meta
-update-difficulty
-```
-
-`compile-draft` 是长耗时动作。前端进入生成页后必须持久化 `generationStatus=generating`,刷新后能从作品架恢复生成页。失败前需要复读 session;如果后端已经完成草稿并写回资产,前端按成功收尾。
-
-## 9. SpacetimeDB 表和 view
-
-建议新增表:
-
-1. `jump_hop_agent_session`;
-2. `jump_hop_work_profile`;
-3. `jump_hop_runtime_run`;
-4. `jump_hop_event`;
-5. `jump_hop_leaderboard_entry`,首版可暂不对外展示;
-6. `jump_hop_gallery_view`;
-7. `jump_hop_gallery_card_view`。
-
-表结构新增字段必须按 SpacetimeDB 迁移规则放在结构体末尾并设置明确默认值。新增或调整表、reducer、procedure、view 后必须同步 `migration.rs`、表目录、生成 bindings,并执行 `npm run check:spacetime-schema`。
-
-公开列表主路径应优先订阅 `jump_hop_gallery_card_view` 后在 `api-server` 本地 cache 构造列表响应,不要让每个 HTTP 请求都调用 SpacetimeDB procedure 组装全量列表。
-
-## 10. 结果页能力
-
-结果页必须展示:
-
-1. 作品标题;
-2. 作品简介;
-3. 角色形象;
-4. 地块图集;
-5. 路径预览;
-6. 标签;
-7. 试玩;
-8. 发布;
-9. 返回编辑。
-
-结果页还必须支持:
-
-1. 单独重生成角色;
-2. 单独重生成地块图集;
-3. 单独修改标题和简介;
-4. 单独调整标签和难度。
-
-结果页不应强制再走一次封面生图。封面只做合成,不新增图像生成调用。
-
-## 11. 运行态规则
-
-运行态采用 2D 表现,但画面视觉上必须保留参考图那种俯视角 / 等距感。
-
-### 11.1 核心玩法
-
-1. 玩家长按蓄力;
-2. 松手后角色按蓄力长度起跳;
-3. 跳跃距离决定是否落到下一个地块;
-4. 落在目标区域内判定成功;
-5. 落在地块外或越界判定失败;
-6. 到达终点地块判定通关。
-
-### 11.2 判定规则
-
-1. 只做一个当前局面的起跳判定;
-2. 不做复杂连招动作树;
-3. 不新增生命数、体力、回合数;
-4. 不新增计时赛作为首版核心规则;
-5. 不把前端动画结果当成最终真相,通关与失败必须能回写运行态状态。
-
-### 11.3 角色动画
-
-角色不需要多帧生图,运行态只通过位移、缩放、轻微旋转和投影变化表达:
-
-1. 蓄力时轻微压缩;
-2. 起跳时向上抬升;
-3. 空中保持可读轮廓;
-4. 落地时轻微弹性回弹;
-5. 失败时从地块边缘跌落。
-
-### 11.4 摄像机与构图
-
-1. 相机以当前角色和下一地块为中心;
-2. 至少保证下一个落点一直可见;
-3. 画面要留出顶部和底部的 UI 安全区;
-4. 不要把地块做得太满,保留参考图那种疏朗感。
-
-### 11.5 UI
-
-运行态 UI 只保留必要元素:
-
-1. 分数;
-2. 暂停;
-3. 重新开始;
-4. 分享;
-5. 结算按钮。
-
-不默认展示大段规则说明。首进如果需要引导,只能用一次轻量提示,不允许常驻一屏的说明文案。
-
-## 12. 视觉规范
-
-本模板的视觉目标是“像 3D,但仍是 2D 图片”。
-
-必须遵守:
-
-1. 平台有明确厚度;
-2. 侧面可见分层或材质变化;
-3. 投影统一且方向一致;
-4. 背景干净,颜色克制;
-5. 角色尺寸在小屏上依然可读;
-6. 地块不能出现过多文字、按钮或装饰信息;
-7. 不能把运行态做成重 UI 面板。
-
-建议的背景策略:
-
-1. 以静态浅色渐变或纯色背景为主;
-2. 不把背景也做成每次都生成的重资产;
-3. 让地块和角色成为画面的第一视觉焦点。
-
-## 13. 发布后体验
-
-发布后的作品必须支持:
-
-1. 进入作品架和公开展示;
-2. 分享;
-3. 试玩;
-4. 重新进入结果页编辑。
-
-发布后的卡片封面应优先由角色图和地块图合成,不要求单独再生成封面图。
-
-首版不新增排行榜、回放和对局对抗。后续如要扩展排行,可另起版本,不要塞进首版模板范围。
-
-## 14. 验收
-
-1. 创作入口能看到 `跳一跳` 模板;
-2. 创作者可以填写主题、角色描述、风格和难度;
-3. 提交后只生成一次角色图和一次地块图集;
-4. 结果页能看到角色图、地块图集和路径预览;
-5. 结果页可单独重生成角色或地块;
-6. 试玩进入跳一跳运行态;
-7. 长按蓄力、松手起跳、落点判定、失败和通关都可用;
-8. 作品可以保存、发布和分享;
-9. 前端不直接读取或暴露生图密钥;
-10. 发布后的封面不依赖第三次额外生图。
-11. `npm run check:spacetime-schema` 在 schema 变更后通过;
-12. `npm run check:encoding` 通过。
+展示字段:
+
+1. rank;
+2. playerId;
+3. successfulJumpCount;
+4. durationMs;
+5. updatedAt。
+
+草稿试玩可以展示本地结果,但正式排行榜只消费后端 run 记录。匿名 runtime guest 也按 guest subject 作为 playerId 参与当次作品维度排行。
+
+## 8. 结果页
+
+结果页展示:
+
+1. 陶泥儿 logo 透明角色预览;
+2. 25 个地块资源池预览;
+3. 首屏 3 块平台预览;
+4. 试玩;
+5. 发布;
+6. 返回编辑;
+7. 重生成地块。
+
+结果页不再展示角色图片生成槽位,也不提供独立角色重生成。
+
+## 9. 契约要点
+
+公开语义保留:
+
+1. `themeText`;
+2. `tileAtlasAsset`;
+3. `tileAssets[]`;
+4. `defaultCharacter`;
+5. `path.platforms[]` 作为服务端路径缓冲;
+6. `currentPlatformIndex`;
+7. `successfulJumpCount`;
+8. `startedAtMs` / `finishedAtMs` / `durationMs`;
+9. `leaderboard`。
+
+旧语义处理:
+
+1. `characterAsset` 仅作为角色描述兼容字段,不再表示生成图片;前端固定使用陶泥儿 logo 透明 PNG;
+2. `score` 兼容映射为成功跳跃次数;
+3. `combo` 固定为 0,不作为公开玩法语义;
+4. `cleared` 状态不再由 v1 产生;
+5. 旧 finite path 只作为服务端路径缓冲兼容形态。
+
+## 10. 验收
+
+1. 创作页只显示主题输入;
+2. 生成链路只调用一次地块图集 image2,不再调用角色生图;
+3. 地块图集为 `5x5`,后端切出 25 个地块 PNG;
+4. 结果页不依赖旧角色图片槽;
+5. 运行态为竖屏俯视角,首屏保持 3 个地块可见;
+6. 拖拽方向和力度会影响落点;
+7. 未落到下一个地块立即失败;
+8. 成功跳跃次数累加,失败后计时冻结;
+9. 排行榜按成功跳跃次数优先排序;
+10. 作品可保存、发布、分享并从公开入口启动。
+11. 运行态地块必须显示 `tileAssets[]` 中的生成切片图片;拖拽蓄力、计时刷新和角色位置更新不得销毁重建透明画布、平台图片层或 DOM 角色层。
+12. 同等跳跃距离的拖动距离必须比旧 `0.004` 系数缩短一半,松手后必须先看到角色飞行动画,再看到地块窗口前移。
diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
index de34ec90..883e6663 100644
--- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
+++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
@@ -128,9 +128,10 @@ npm run check:server-rs-ddd
3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。
4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。
5. 图片 provider 协议不再放在玩法模块里实现。VectorEngine `gpt-image-2` 创建 / 编辑协议、URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image/src/vector_engine/`;其中 `client.rs` 只保留 provider 调用编排,`transport.rs` 负责 HTTP client 与 reqwest 错误归一,`request.rs` 负责请求体和路径,`payload.rs` 负责响应 JSON 字段提取,`response.rs` 负责响应状态分流和图片结果归一。`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。
-6. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。
-7. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。
-8. 系列素材图集实现真相源在 `server-rs/crates/platform-image/src/generated_asset_sheets/`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。`server-rs/crates/api-server/src/generated_asset_sheets.rs` 只保留 `AppState` / `AppError` 适配和兼容导出。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。
+6. OSS 平台适配日志统一在 `server-rs/crates/platform-oss` 输出,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。日志字段固定使用 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms`,只记录对象定位和排障信息;不得输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。
+7. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。
+8. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。
+9. 系列素材图集实现真相源在 `server-rs/crates/platform-image/src/generated_asset_sheets/`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。`server-rs/crates/api-server/src/generated_asset_sheets.rs` 只保留 `AppState` / `AppError` 适配和兼容导出。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。
## SpacetimeDB schema 变更规则
@@ -167,14 +168,14 @@ npm run check:server-rs-ddd
## 外部服务与资产
- LLM:`GENARRATIVE_LLM_*`,创意 Agent 另用 `APIMART_BASE_URL` / `APIMART_API_KEY`。
-- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。APIMart 只保留给创意 Agent `gpt-5` Responses 文本 / 多模态链路;DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。
+- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。APIMart 只保留给创意 Agent `gpt-5` Responses 文本 / 多模态链路;DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。
- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。
- Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。
- Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。
- 敲木鱼敲击物和背景环境图:VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`。敲击物支持 multipart 多参考图,第一张固定为后端内嵌默认木鱼图,用户上传图只作为新主题参考;prompt 必须要求 `1:1` 真实透明 alpha PNG 并禁止黑底、白底、棋盘格和任何实底背景。当前敲击物上传 OSS 前不做服务端抠图后处理,避免误伤玉米等主体像素。背景环境图只使用第一步抠图完成后的透明敲击物图作为参考,prompt 必须要求中央主体预留区保持干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围,且必须显式声明不继承任何绿色底色、绿幕底色或纯绿色画布。
- Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;Rodin 提交、状态、下载和响应解析归属 `platform-hyper3d`,`api-server/src/hyper3d_generation.rs` 只做路由、配置和错误 envelope 映射;新 Match3D 草稿和批量新增不再生成 GLB。
- 音频:视觉小说专用音频路由保留;VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio`。`api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。
-- OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。
+- OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。OSS 签名、读签名、HEAD 和 PUT 的结构化日志由 `platform-oss` 输出,排查资产写入 / 确认失败时优先按 `operation`、`object_key` / `key_prefix`、`status_class`、`error_kind` 和 `elapsed_ms` 下钻。
- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt,以及在调用方可获得上下文时补充的 `userId`(触发者)和 `profileId`(草稿 / 作品 / 场景作用域)。图片生成入口应优先把 owner user id 和 profile id 透传到失败审计,不要只保留 provider 级聚合,否则很难按“谁触发、哪个作品触发”定位问题。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。
- 外部生成运行记录:所有外部生成编排的完成态统一写入 `tracking_event`,`event_key = external_generation_run`,`scope_kind = module`,`scope_id = provider`,`module_key = external-generation`。metadata 固定包含 `runId`、`provider`、`operation`、`requestLabel`、`requestPayload`、`status`、`success`、`failureReason`、`providerRequestId`、`resultPayload`、`startedAtMicros`、`completedAtMicros` 和 `durationMs`。这类记录只用于运行审计和排障,不再走 `ai_task` 旧表。
@@ -333,7 +334,7 @@ npm run check:server-rs-ddd
- Rust 结构体:`CreationEntryConfig`
- 源码:`server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`
- 字段:`config_id`、`start_title`、`start_description`、`start_idle_badge`、`start_busy_badge`、`modal_title`、`modal_description`、`updated_at`、`event_title`、`event_description`、`event_cover_image_src`、`event_prize_pool_mud_points`、`event_starts_at_text`、`event_ends_at_text`、`event_banners_json`。
-- 迁移兼容:旧迁移包缺少活动横幅字段时,由 `migration.rs` 写入 `None` / `58000` 默认值;旧库缺少 `event_banners_json` 时写入 `None`,运行态读取层再按 `module-runtime` 默认公告数组归一,不覆盖后台已保存配置。HTTP 响应同时返回 `eventBanners` 数组和旧 `eventBanner` 单条兼容字段,前端优先消费数组;后台新配置主格式为 HTML 公告字符串数组或 `{title, htmlCode}` 对象数组,旧结构化 banner 字段仅保留兼容。
+- 迁移兼容:旧迁移包缺少活动横幅字段时,由 `migration.rs` 写入 `None` / `58000` 默认值;旧库缺少 `event_banners_json` 时写入 `None`,运行态读取层再按 `module-runtime` 默认公告数组归一,不覆盖后台已保存配置,也不把旧结构化 `eventBanner` 升格为前端优先数组。HTTP 响应同时返回 `eventBanners` 数组和旧 `eventBanner` 单条兼容字段,前端优先消费数组;后台新配置主格式为 HTML 公告字符串数组或 `{title, htmlCode}` 对象数组,旧结构化 banner 字段仅保留兼容。默认公告背景和旧结构化默认 `coverImageSrc` 必须引用 `public/` 下真实存在的静态资源,当前为 `/creation-type-references/puzzle.webp`。
### `creation_entry_type_config`
@@ -406,15 +407,23 @@ npm run check:server-rs-ddd
- Rust 结构体:`JumpHopEventRow`
- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs`
+### `jump_hop_leaderboard_entry`
+
+- Rust 结构体:`JumpHopLeaderboardEntryRow`
+- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs`
+- 说明:跳一跳作品维度排行榜 read model,每个 `profile_id + player_id` 只保留 1 条最佳记录;排序口径为成功跳跃次数降序、游戏时长升序、更新时间升序,草稿试玩不作为公开排行榜语义。
+
### `jump_hop_runtime_run`
- Rust 结构体:`JumpHopRuntimeRunRow`
- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs`
+- 说明:运行记录持久化 `runtime_mode`,取值为 `draft` / `published`;草稿试玩只允许作品所有者启动,不累计公开游玩次数,也不写入公开排行榜。
### `jump_hop_work_profile`
- Rust 结构体:`JumpHopWorkProfileRow`
- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs`
+- 说明:作品投影持久化独立 `theme_text`,用于生成主题和公开卡片主题展示;历史行为空时按 `work_title` 兜底。`back_button_asset_json` 保存 image2 单独生成并去绿后的 1:1 左上角返回按钮资产快照;旧迁移数据按 `None` 兼容,运行态缺失该字段时使用同尺寸 CSS 主题按钮兜底。
### SpacetimeDB view:`jump_hop_gallery_card_view`
diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
index bbc19a0c..16e7939c 100644
--- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
+++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
@@ -1,6 +1,6 @@
# 本地开发验证与生产运维
-更新时间:`2026-05-15`
+更新时间:`2026-06-05`
## 标准开发流程
@@ -47,7 +47,7 @@ npm run dev:api-server
Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模块命令会先在系统级端口段注册表里给当前用户分配一个端口段,再把该段映射为 `web = start`、`api = start + 1`、`spacetime = start + 2`、`admin-web = start + 3`。默认注册表目录是 `/var/tmp/genarrative-dev-port-ranges/`,其中 `registry.json` 记录各用户的活跃段,`registry.lock` 负责串行化分配;可以用 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。系统自动分配时从 `10000-10099` 开始,每次占用 100 个端口块,后续块按 `10100-10199`、`10200-10299` 递增;`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` 只在 Linux 上生效,Windows 仍按原来的 3000 / 8082 / 3101 / 3102 端口探测与漂移逻辑运行,不读这个系统级注册表。
-后端日志默认写入 `logs/api-server/`。后端 API smoke 使用 `npm run dev:api-server` 并检查 `/healthz`;不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。
+后端日志默认写入 `logs/api-server/`。后端 API smoke 使用 `npm run dev:api-server` 并检查 `/healthz`;需要确认实例可接生产流量时检查 `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。
开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。
@@ -71,6 +71,10 @@ spacetime sql "SELECT * FROM puzzle_gallery_card_view LIMIT 1" --serv
本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`;`api-server` 只做配置、玩法编排、OSS / asset 持久化、计费和失败审计落库。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若 VectorEngine 在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 `request_id` 的 provider 日志字段 `source`、`source_chain`、`source_chain_depth`,再查 `external_api_call_failure.metadata_json.errorSource`;当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。拼图关卡资产按 `level_scene -> ui_spritesheet -> level_background` 顺序生成,日志会带 `slot`、`asset_kind` 和 `elapsed_ms`。
+VectorEngine 图片生成 / 编辑在 `request_send` 阶段出现 `timeout` 或 `connect` 错误时,`platform-image` 会对同一请求最多发送 3 次;multipart 图片编辑每次重试都会重新构造 form,避免复用已消费的 body。日志中 `VectorEngine 图片请求发送失败,准备重试` 表示本次失败已进入下一次尝试;最终仍失败时才会写入 `external_api_call_failure` 并返回 504。排查生产失败时应同时统计 retry 前的尝试日志和最终 audit,避免把一次用户请求内的多次发送误判成多个用户请求。
+
+拼图入口直创的 `compile_puzzle_draft` 是长耗时链路:后端会先快速编译草稿并返回 `image_refining` / `generating` 快照,然后在 api-server 后台任务中完成首图、UI 资产、OSS 持久化、作品投影、计费退款和失败态回写。生产排查小程序 `Failed to fetch` 时,若 Nginx access log 里 action POST 是 `499`、`upstream_status=-`,说明客户端或 WebView 先断开;此时不应再把长 POST 是否返回作为生成成败依据,而应继续按实际 `session_id` 查后台任务日志、VectorEngine provider 日志、`external_api_call_failure` 和后续 GET 轮询结果。同一用户可能先轮询旧的 `puzzle-session-*`,随后 POST 新建实际生成 session;必须用 action POST 的 `request_id` 和 `/api/runtime/puzzle/agent/sessions//actions` 路径对齐真实失败请求,避免被前端显示的“来源草稿”误导。
+
查看本地 Rust / SpacetimeDB 日志:
```bash
@@ -244,26 +248,27 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
`Genarrative-Web-Build` 打包 `web.tar.gz` 前、`Genarrative-Web-Deploy` 解包后都会把 Web 静态目录规范为目录 `755`、文件 `644`。如果前端页面能打开但 public 图片、字体或音频返回 `403 Forbidden`,优先检查当前 `/srv/genarrative/web` 指向的 release 中对应文件权限是否被异常归档为 `600`,临时恢复可对该 release 的 `web` 目录执行目录 `755`、文件 `644` 的权限修正。
-生产 Jenkins 的 `Pipeline script from SCM` 仍由 Jenkins controller 读取 Jenkinsfile,SCM URL 继续使用 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。现在所有生产流水线 job 的首次 checkout 都先走 `http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后回退到 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`;两层 checkout 都必须保留单分支 refspec、`shallow=true`、`depth=1`、`noTags=true` 与 `honorRefspec=true`,后续二次源码确认继续走 `scripts/jenkins-checkout-source.sh`。
+生产 Jenkins 的 `Pipeline script from SCM` 由 Jenkins controller 读取 Jenkinsfile。`Genarrative-Server-Provision` 是服务器初始化流水线,Job 配置里的 SCM URL 必须使用 controller 本机可访问的仓库路径或内网 Gitea 地址,不能使用 `https://git.genarrative.world/...`;否则日志一开始的 `Checking out git ... to read jenkins/Jenkinsfile.production-server-provision` 就会先从公网拉 Jenkinsfile。其它构建 / 发布流水线仍按各自 Jenkinsfile 的 checkout 口径执行;所有 `GitSCM checkout` 都必须保留单分支 refspec、`shallow=true`、`depth=1`、`noTags=true` 与 `honorRefspec=true`。
`Genarrative-Stdb-Module-Publish` 在 `Pipeline script from SCM` 阶段如果一开始就报 `No such DSL method 'pipeline'`,优先检查 `jenkins/Jenkinsfile.production-stdb-module-publish` 是否带 UTF-8 BOM。Jenkins Declarative Pipeline 的首个 token 必须是纯 `pipeline`;仓库中的 Jenkinsfile 应保存为 UTF-8 without BOM,只有临时写给 Windows PowerShell 5.1 `-File` 执行的 `.ps1` 才需要按对应 helper 转成带 BOM。验证时可检查文件前三字节不再是 `EF BB BF`,并运行 `validateDeclarativePipeline` 或重放该流水线。
`Genarrative-Stdb-Module-Build` 或 SpacetimeDB module 构建失败若出现 Rust `E0425 cannot find function migrate_*`,优先排查 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` 等同文件内默认种子迁移 helper 是否在分支合并时只保留了调用、漏掉了函数定义。`Genarrative-Stdb-Module-Build` 现在运行在 `linux && genarrative-build` 节点上,Checkout 与 Build 都走 bash + cargo + sccache,不再依赖 Windows PowerShell 或 Git Bash。修复时不要直接删除迁移调用;应恢复只纠偏历史默认种子且不覆盖后台手动配置的 helper,并用 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 复现 Jenkins module 编译路径。
-`Genarrative-Server-Provision` 现在也运行在 `linux && genarrative-build` / `linux && genarrative-release-deploy` 节点上,`Prepare Provision Tools` 会在 Linux build 节点直接准备 SpacetimeDB 与 `otelcol-contrib` 交付件,再 stash 给后续发布阶段;旧 Windows 下载 helper 已退役。`Genarrative-Stdb-Module-Build`、`Genarrative-Server-Provision` 和 `Genarrative-Notify-Email` 都不再需要单独的 Windows 节点口径。
+`Genarrative-Server-Provision` 只做服务器初始化,不再承担构建职责。流水线全程运行在目标服务器 agent:`DEPLOY_TARGET=development` 使用 `linux && genarrative-dev-deploy`,`DEPLOY_TARGET=release` 使用 `linux && genarrative-release-deploy`;`Prepare Provision Tools` 也在同一个目标 agent 工作区内准备 SpacetimeDB 与 `otelcol-contrib` 交付件,不再切到 `linux && genarrative-build`,也不再 stash 给后续阶段。`SOURCE_GIT_REMOTE_URL` 必须显式填写为目标 agent 可访问的本机路径、`file:///` 地址、localhost / 127.0.0.1、RFC1918 内网 HTTP Git 地址、单标签内网主机名或 `.local` / `.lan` / `.internal` 地址;这条流水线不配置公网 Git 备用地址,目标 agent 拉不到内网源就应直接失败。真实初始化会写入 `/etc` / systemd / Nginx、创建系统用户并修改服务,目标 dev / release agent 非 dry-run 时都必须具备 root 权限。
生产环境变量模板:`deploy/env/api-server.env.example`。真实密钥只放服务器,不提交 Git,不写入文档示例。
-`Genarrative-Server-Provision` 会安装基础构建依赖、systemd 模板和 Nginx 站点模板。Ubuntu / apt 目标机会额外安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`,随后由 `scripts/jenkins-server-provision.sh` 通过临时 `nginx -t` 配置探测 Brotli 指令是否可用;该临时配置必须先 `include /etc/nginx/modules-enabled/*.conf`,因为 apt 安装的 Brotli 是动态模块,不会出现在普通 `nginx -V` 编译参数里。探测成功才在渲染后的 `deploy/nginx/genarrative.conf` / `genarrative-dev-http.conf` 中启用 Brotli,避免未安装模块的机器直接写入无效配置。Provision 写入 Genarrative Nginx 站点时会把 `/etc/nginx/sites-enabled/default*` 移到 `/etc/nginx/sites-disabled/`,避免 Debian / Certbot 默认站点继续占用 `genarrative.world` / `www.genarrative.world` 并在 `nginx -T` 中出现 `conflicting server name ... ignored`。如果 `nginx -t` 失败,脚本会恢复写入前的 Genarrative 配置和被移动的默认站点。
+`Genarrative-Server-Provision` 会安装 systemd 模板和 Nginx 站点模板,不再安装 clang / lld / pkg-config / OpenSSL headers / sccache 等通用构建链依赖。因 VectorEngine 图片上游 POST 已改用 `libcurl`,当前 Linux release 构建出的 `api-server` 运行时需要 `OPENSSL_3.2.0` 符号;Ubuntu 24.04 apt 默认只提供 OpenSSL 3.0.x,不能直接满足该符号版本。Provision 会把 OpenSSL `3.2.0` 独立安装到 `/opt/genarrative/openssl-3.2.0`,校验官方 tarball SHA256,并只通过 `genarrative-api.service` 的 `LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib` 让 api-server 使用,避免替换系统 OpenSSL 或影响 ssh / nginx / apt。Ubuntu / apt 目标机会额外安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`,随后由 `scripts/jenkins-server-provision.sh` 通过临时 `nginx -t` 配置探测 Brotli 指令是否可用;该临时配置必须先 `include /etc/nginx/modules-enabled/*.conf`,因为 apt 安装的 Brotli 是动态模块,不会出现在普通 `nginx -V` 编译参数里。探测成功才在渲染后的 `deploy/nginx/genarrative.conf` / `genarrative-dev-http.conf` 中启用 Brotli,避免未安装模块的机器直接写入无效配置。Provision 写入 Genarrative Nginx 站点时会把 `/etc/nginx/sites-enabled/default*` 移到 `/etc/nginx/sites-disabled/`,避免 Debian / Certbot 默认站点继续占用 `genarrative.world` / `www.genarrative.world` 并在 `nginx -T` 中出现 `conflicting server name ... ignored`。如果 `nginx -t` 失败,脚本会恢复写入前的 Genarrative 配置和被移动的默认站点。
50 HTTP req/s 首版压测优化口径:
- `api-server` 生产模板默认 `GENARRATIVE_API_LISTEN_BACKLOG=1024`、`GENARRATIVE_API_WORKER_THREADS=4`;本地未设置 worker threads 时继续使用 Tokio 默认值。
-- `GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512` 开启应用内 HTTP 并发背压;`GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320`、`GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64`、`GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS=16` 分别限制公开列表、公开详情和后台 API 热路径。超过许可时直接返回 `429 Too Many Requests` 和 `Retry-After: 1`,`/healthz` 不受该限制。这些值不是 RPS 限速;如果压测中 429 上升但内存和 p95 收敛,说明背压正在保护进程。直连 `api-server` 的极高 RPS 压测若出现 `connection refused`,通常已经打到 TCP 监听 / accept 层,应同时检查 backlog、Nginx upstream keepalive 和前置限流。
-- `genarrative-api.service` 设置 `LimitNOFILE=65535`、`TasksMax=2048`;上线后用 `systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax` 和 `cat /proc/$(pidof api-server)/limits` 核对。
-- Server provision 不再通过 Windows helper 下载。`Genarrative-Server-Provision` 的 `Prepare Provision Tools` 在 Linux build 节点直接准备 `spacetime-x86_64-unknown-linux-gnu.tar.gz` 和 `otelcol-contrib_0.151.0_linux_amd64.tar.gz`,再 stash `provision-tools/` 给后续发布阶段;如果 build 节点需要代理,在 `PROVISION_DOWNLOAD_PROXY` 配置 Linux 侧可访问的 HTTP 代理。后续 Linux 目标节点只消费 `provision-tools/`,不再回退到外网下载。
-- `Genarrative-Stdb-Module-Build`、`Genarrative-Web-Build`、`Genarrative-Api-Build`、`Genarrative-*Deploy`、`Genarrative-Database-Import/Export`、`Genarrative-Full-Build-And-Deploy` 和 `Genarrative-Notify-Email` 的生产流水线现都以 Linux agent 为主,统一走 `http://127.0.0.1:3000/GenarrativeAI/Genarrative.git` 优先、`https://git.genarrative.world/GenarrativeAI/Genarrative.git` 备用的 checkout 口径。
+- `GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512` 开启应用内 HTTP 并发背压;`GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320`、`GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64`、`GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS=16` 分别限制公开列表、公开详情和后台 API 热路径。超过许可时直接返回 `429 Too Many Requests` 和 `Retry-After: 1`,`/healthz` 与 `/readyz` 不受该限制。这些值不是 RPS 限速;如果压测中 429 上升但内存和 p95 收敛,说明背压正在保护进程。直连 `api-server` 的极高 RPS 压测若出现 `connection refused`,通常已经打到 TCP 监听 / accept 层,应同时检查 backlog、Nginx upstream keepalive 和前置限流。
+- `api-server` 正常运行时 `/healthz` 返回进程存活状态,`/readyz` 返回是否仍接收新流量;收到 `SIGINT` / `SIGTERM` 后会先把 readiness 标记为不可用,再让 Axum 停止接新连接并等待已有 HTTP 请求排空。systemd 仍以 `KillSignal=SIGINT` 停服务,`TimeoutStopSec=90` 作为长请求排空上限。
+- `genarrative-api.service` 设置 `LimitNOFILE=65535`、`TasksMax=2048`;上线后用 `systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax -p TimeoutStopUSec` 和 `cat /proc/$(pidof api-server)/limits` 核对。
+- Server provision 不再通过 Windows helper 下载,也不再通过 Linux build 节点中转工具包。`Prepare Provision Tools` 在目标 dev / release agent 工作区内准备 `spacetime-x86_64-unknown-linux-gnu.tar.gz` 和 `otelcol-contrib_0.151.0_linux_amd64.tar.gz` 并生成 `provision-tools/`;如果目标服务器下载需要代理,在 `PROVISION_DOWNLOAD_PROXY` 配置目标机可访问的 HTTP 代理。
+- 除 `Genarrative-Server-Provision` 外,`Genarrative-Stdb-Module-Build`、`Genarrative-Web-Build`、`Genarrative-Api-Build`、`Genarrative-*Deploy`、`Genarrative-Database-Import/Export`、`Genarrative-Full-Build-And-Deploy` 和 `Genarrative-Notify-Email` 的生产流水线现都以 Linux agent 为主,仍按各自 Jenkinsfile 的 checkout 口径执行。Server provision 不使用公网备用 Git 源。
- `otelcol-contrib.service` 作为可选系统服务加入 provision,默认监听 `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制,服务 unit 见 `deploy/systemd/otelcol-contrib.service`。
- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;`limit_conn` 负责连接 / 并发保护,`limit_req` 负责入口 RPS 快拒绝。当前模板把公开 gallery list 单独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s`、`burst=4096`、`limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,后台 API 放到 `genarrative_admin_rps`。通用 `/api` location 设置 `client_max_body_size 64m` 是反代兜底,防止拼图入口页 / 新增关卡本地参考图 Data URL 或旧兼容请求在到达 `api-server` 前被默认 1 MiB 上限拦截;拼图本地参考图前后端统一限制 6MB,历史图片仍提交 `referenceImageAssetObjectId(s)`。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先用 `nginx -T | grep client_max_body_size` 检查 release 模板是否已渲染并 reload,同时检查前端是否超出 6MB 或错误提交了未压缩大图。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效;若线上压测看到 `limiting connections by zone "genarrative_api_conn"` 却返回 503,优先检查 `nginx -T` 里 HTTPS server 是否缺少这些状态码,以及 `/api/runtime/puzzle/gallery` 是否误落到通用 `location ~ ^/api` 的 `limit_conn=64`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`、`upstream_connect_time`、`upstream_header_time`、`upstream_response_time`、`upstream_status`、`request_id`。
- 作品列表 K6 脚本一次 iteration 默认请求两个公开接口,因此约 50 HTTP req/s 的目标命令使用 `SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works`。
@@ -281,7 +286,7 @@ npm run container:k6
npm run container:down
```
-容器方案默认暴露 `http://127.0.0.1:18080`,`api-server` 在容器内监听 `0.0.0.0:8082`,Nginx 通过 `api-server:8082` upstream 反代 `/api/` 和 `/admin/api/`。SpacetimeDB 也纳入 compose,容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布;Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧现在由 Linux build 节点直接准备 `provision-tools/otelcol-contrib`,再交给后续 Linux 发布阶段安装本机 `otelcol-contrib.service`,真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。完整拓扑、端口、k6 参数和 OTLP debug exporter 使用方法见 `deploy/container/README.md`。
+容器方案默认暴露 `http://127.0.0.1:18080`,`api-server` 在容器内监听 `0.0.0.0:8082`,Nginx 通过 `api-server:8082` upstream 反代 `/api/` 和 `/admin/api/`。SpacetimeDB 也纳入 compose,容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布;Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧现在由目标 dev / release agent 自己准备 `provision-tools/otelcol-contrib`,并安装本机 `otelcol-contrib.service`,真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。完整拓扑、端口、k6 参数和 OTLP debug exporter 使用方法见 `deploy/container/README.md`。
`npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`。
OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日志与 Nginx 文件日志仍保留:
@@ -294,7 +299,8 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日
- debug exporter / Rider 转发都会同时接收 traces、metrics 和 logs。
- api-server 会随 metrics 发送进程级指标:`process.memory.usage`、`process.memory.virtual`、`process.cpu.time`、`genarrative.process.cpu.usage_percent`、`process.thread.count`、`genarrative.process.memory.private`;Windows 额外发送 `process.windows.handle.count`,Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。
- HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight` 与 `genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。
-- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出低基数字段结构化日志,字段包括 provider、endpoint、failure_stage、status、source、source_chain、source_chain_depth、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model 和 raw_excerpt;`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id` 和 `metadata_json.userId` / `metadata_json.profileId` / `metadata_json.requestId` / `metadata_json.errorSource` 中记录触发者、草稿 / 作品作用域、请求标识和传输错误链。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId,最后结合 request 日志、errorSource 和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。
+- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出结构化日志字段,字段包括 provider、endpoint、failure_stage、status、source、source_chain、source_chain_depth、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model、request_params 和 raw_excerpt;图片编辑请求参数日志还会带 reference_image_bytes_total,并在 request_params.referenceImages 中记录每个 multipart `image` part 的 fileName、mimeType 和 bytes,不记录 API key 或原始图片 bytes;`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id` 和 `metadata_json.userId` / `metadata_json.profileId` / `metadata_json.requestId` / `metadata_json.errorSource` 中记录触发者、草稿 / 作品作用域、请求标识和传输错误链。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId,最后结合 request 日志、errorSource 和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。
+- OSS 平台适配器也输出结构化日志,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。排查资产签名、上传或确认失败时,先按 `provider=aliyun-oss` 与 `operation` 过滤,再看 `object_key` / `key_prefix`、`status`、`status_class`、`error_kind`、`content_length`、`content_type` 和 `elapsed_ms`;日志不得包含 AccessKey、policy、signature、Authorization header 或完整 signed URL。
- SpacetimeDB 观测分为两类:procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*`。`read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。
- 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。
- Rider 的 Logs 面板只展示 log event 自身字段,不会自动展开父 span 的全部 attributes;请求完成日志会直接带 `request_id`、`http.request.method`、`http.route`、`url.scheme`、`url.path`、`http.response.status_code`、`status_class`、`latency_ms` 和 `slow_request`,完整链路继续到 Traces 面板按 trace/span 查看。
@@ -351,7 +357,7 @@ cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms
- `profile_task_reward_claim`
- `profile_wallet_ledger`
-个人任务首版 scope 仅支持 `user`。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`。
+个人任务首版 scope 仅支持 `user`。每日登录任务按北京时间自然日 0 点重置;用户已登录并停留在“我的”页跨日时,前端需要先非阻断调用 refresh session 以写入新业务日 `daily_login`,再请求 `/api/profile/tasks` 刷新任务中心。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`。
外部 API 失败审计复用 `tracking_event`,不新增表。失败事件优先写入本机 tracking outbox,再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,会回退同步直写 SpacetimeDB。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、errorSource、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt、userId、profileId 和 requestId;其中 `userId` 是触发生成的用户,`profileId` 是调用方传入的草稿 / 作品 / 场景作用域,`requestId` 用于回查同一次 HTTP 请求日志,入口拿不到上下文时允许为空。常用查询:
@@ -380,7 +386,7 @@ ORDER BY failures DESC, last_seen DESC
LIMIT 100;
```
-VectorEngine `request_send` 且 `timeout = true` 的记录表示 `reqwest::Error::is_timeout()` 判定为超时,常见于连接、发送请求体、等待上游首包或上游长时间无响应;`errorSource` 会保存 reqwest 底层错误链,若只看到 `client error (SendRequest)`,表示 Hyper 只暴露到发送请求阶段,仍不等于最终根因。若 `statusCode` 为空,应优先查同一 `requestId` 的 `api-server` request 日志、provider 日志 `source_chain`、Nginx / 出口网络、VectorEngine 可用性和请求体大小;若已有 `502`、`429 moderation_blocked` 等状态码,则按上游网关或内容审核失败单独处理,不要和传输超时混为一类。
+VectorEngine `request_send` 且 `timeout = true` 的记录表示 `reqwest::Error::is_timeout()` 判定为超时,常见于连接、发送请求体、等待上游首包或上游长时间无响应;`errorSource` 会保存 reqwest 底层错误链,若只看到 `client error (SendRequest)`,表示 Hyper 只暴露到发送请求阶段,仍不等于最终根因。若 `statusCode` 为空,应优先查同一 `requestId` 的 `api-server` request 日志、provider 日志 `source_chain`、request_params、reference_image_bytes_total、Nginx / 出口网络、VectorEngine 可用性和请求体大小;若已有 `502`、`429 moderation_blocked` 等状态码,则按上游网关或内容审核失败单独处理,不要和传输超时混为一类。
tracking outbox 默认配置:
@@ -390,9 +396,10 @@ GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox
GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE=500
GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS=1000
GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES=268435456
+GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS=5000
```
-outbox 采用 NDJSON 文件保存原始事件。达到 `BATCH_SIZE` 时会立刻把当前 active 文件原子封存为 sealed 文件,并马上切到新的 active 继续写入;后台 worker 异步 flush sealed 文件,HTTP 请求线程不等待 SpacetimeDB。`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件。SpacetimeDB 批量 procedure 返回成功后删除 sealed 文件,失败则保留文件并重试。`MAX_BYTES` 是磁盘保护阈值,不是 flush 阈值;超过后低价值 route tracking 可以被丢弃并记录日志 / 指标,关键同步事件不进入该丢弃路径。sealed 文件若出现无法解析的坏行,会重命名为 `corrupt-*` 隔离并记录 `genarrative.tracking_outbox.files.corrupt` 指标,避免一个坏文件阻塞后续批量入库。该机制提供至少一次投递语义,依赖 `tracking_event.event_id` 幂等跳过重复事件。
+outbox 采用 NDJSON 文件保存原始事件。达到 `BATCH_SIZE` 时会立刻把当前 active 文件原子封存为 sealed 文件,并马上切到新的 active 继续写入;后台 worker 异步 flush sealed 文件,HTTP 请求线程不等待 SpacetimeDB。`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件。SpacetimeDB 批量 procedure 返回成功后删除 sealed 文件,失败则保留文件并重试。`MAX_BYTES` 是磁盘保护阈值,不是 flush 阈值;超过后低价值 route tracking 可以被丢弃并记录日志 / 指标,关键同步事件不进入该丢弃路径。sealed 文件若出现无法解析的坏行,会重命名为 `corrupt-*` 隔离并记录 `genarrative.tracking_outbox.files.corrupt` 指标,避免一个坏文件阻塞后续批量入库。api-server 收到退出信号后会在 `GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS` 窗口内封存 active 文件并尽力 flush sealed 文件,超时或 SpacetimeDB 暂不可用时保留本地文件给下次启动继续投递。该机制提供至少一次投递语义,依赖 `tracking_event.event_id` 幂等跳过重复事件。
release 机器如果日志每秒刷 `tracking outbox ... Permission denied (os error 13)`,先检查 `/etc/genarrative/api-server.env` 是否缺少 `GENARRATIVE_TRACKING_OUTBOX_DIR`。缺少时 `api-server` 会回退到本地开发默认相对路径 `server-rs/.data/tracking-outbox`,而 systemd 的工作目录是只读发布目录 `/opt/genarrative/releases/`,`genarrative` 用户无法在其中创建 `server-rs`。修复顺序:
diff --git a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md
index 27d98f26..296fd2ed 100644
--- a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md
+++ b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md
@@ -9,7 +9,7 @@
- H5 与桌面微信环境仍分别走 `wechat_h5` / `wechat_native`,不进入虚拟支付链路。
- `session_key` 只保存在后端认证仓储内,用于计算虚拟支付用户态签名,不下发给前端。
- 客户端支付成功回调只代表已拉起支付并返回成功;最终到账仍以后端虚拟支付消息推送写入订单为准,普通微信支付订单则继续走微信支付 V3 notify / query。虚拟支付订单的确认接口只读取本地订单真相,不再用普通微信支付 V3 查单。
-- 小程序 WebView 默认进入时会静默调用 `wx.login` 刷新后端微信登录态,避免历史登录用户只有前端 JWT、后端缺少 `session_key` 时无法生成虚拟支付签名。
+- 小程序 WebView 普通进入不预登录;H5 触发受保护入口或支付前必须保留 `clientRuntime=wechat_mini_program` 等宿主上下文,并用 `MicroMessenger + miniProgram` User-Agent 兜底识别首点 bridge 未就绪场景,再跳转小程序原生授权态,确保后端拿到带 `session_key` 的微信登录态。
## 关键文件
@@ -59,7 +59,7 @@ npm run check:encoding
## 注意事项
-- 旧微信登录快照可能没有 `session_key`;小程序 WebView 会在普通进入时静默刷新一次微信登录态,刷新失败时仍允许匿名打开 WebView,但虚拟支付会继续由后端拦截并提示重新登录。
+- 旧微信登录快照可能没有 `session_key`;普通进入小程序 WebView 仍允许匿名打开,虚拟支付会由后端拦截并提示用户在小程序内重新登录。H5 内部导航不得清理 `clientType`、`clientRuntime`、`miniProgramEnv`,且首点登录要用小程序 User-Agent 兜底识别,否则登录和支付会误判为普通网页环境。
- 小程序充值商品全部映射到虚拟支付;泥点使用 `short_series_coin`,会员使用 `short_series_goods`。
- `short_series_coin` 只用于代币购买,后端从本次下单返回的充值中心商品快照读取 `points_amount` 并写入 `buyQuantity`;不要把 coin 商品当成道具,也不要把 `buyQuantity` 固定为 1。
- 后台新增的会员类充值商品会直接把商品 `productId` 作为微信 `short_series_goods` 的道具 ID;例如微信后台道具 ID 为 `item01` 时,后台会员商品 `productId` 也应配置为 `item01`,且商品价格需要与微信后台道具价格一致。
diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
index 3a6fb116..6c2a3bdc 100644
--- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
+++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
@@ -1,12 +1,14 @@
# 平台入口与玩法链路
-更新时间:`2026-06-03`
+更新时间:`2026-06-04`
## 平台创作入口
创作入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;后台通过 `/admin/api/creation-entry/config` 管理。前端只在展示层派生可见卡片和入口状态,`api-server` 路由熔断也使用同一份配置。不要恢复前端硬编码入口配置文件。
-当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作入口页。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config` 的 `eventBanners` 数组,多条配置时前端自动轮播,旧 `eventBanner` 仅作为单条兼容兜底。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID,页面必须展示“仅显示最近7天内使用过的模板”提示,列表内容必须复用其它页签里的模板卡样式、文案和点击行为,不展示具体作品名称、摘要或生成状态,也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。
+当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作入口页。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config` 的 `eventBanners` 数组,多条配置时前端自动轮播;旧 `eventBanner` 只保留字段回显与旧客户端兼容,不再作为前端公告数组的兜底来源。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID,页面必须展示“仅显示最近7天内使用过的模板”提示,列表内容必须复用其它页签里的模板卡样式、文案和点击行为,不展示具体作品名称、摘要或生成状态,也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。
+
+旧库或旧迁移包没有 `event_banners_json` 时,后端读取层必须把 `eventBanners` 归一到 `module-runtime` 默认公告数组,不能把旧结构化 `eventBanner` 当成前端优先数组下发。默认公告引用的背景图必须指向 `public/` 下真实存在的站内静态资源,当前默认使用 `/creation-type-references/puzzle.webp`,避免创作入口顶部 banner 出现失效图片。
创作页和草稿页顶栏右上角的泥点余额胶囊是补足泥点入口:如果当前运行环境开启充值入口,点击后直接打开账户充值弹窗;否则直接打开运营兑换码弹窗。该入口不再跳到账户面板或泥点账单,头像 / 设置等账号入口继续保留各自语义。
@@ -42,27 +44,27 @@
单图资产编辑统一通过 `CreativeImageInputPanel` 承载上传、AI 重绘、参考图、历史图和删除确认;新玩法页面不得重复手写这些交互。系列素材图集生成统一走“批量规划 -> sheet 生图 -> 后端切图 -> 透明化 -> OSS 持久化 -> 状态回写 -> 局部重生成”流程,玩法只提供 `sheetSpec`、`slotSpecs`、提示词和字段映射,不把任一玩法专属素材 DTO 当作平台通用模型。
-通用系列素材图集能力的实现真相源在 `platform-image::generated_asset_sheets`:`n` 是必选参数,模块负责组装 `n*n` sheet prompt、按 `n*n` 切片、绿幕 / 近白底透明化、导出 PNG 和 OSS 持久化请求。`api-server::generated_asset_sheets` 只保留 `AppError` / `AppState` 适配,不再承载图像处理和 OSS 请求构造细节。物品名称 prompt 和特殊设定 prompt 是可选输入;调用方可传入类似“每个物品生成五个不同视图”的视角约束,通用模块会把 sheet prompt、物品行 prompt、特殊设定 prompt 编码写入 OSS 元数据。玩法仍负责计费、物品规划、slot 映射、失败回写和把通用切片结果映射回自己的草稿 / profile / runtime 字段。
+通用系列素材图集能力的实现真相源在 `platform-image::generated_asset_sheets`:`n` 是必选参数,模块负责组装 `n*n` sheet prompt、按 `n*n` 切片、默认绿幕 / 近白底透明化、导出 PNG 和 OSS 持久化请求;高风险撞色玩法可显式使用专用 key 色、关闭近白扣除并限制为边缘连通背景扣除。`api-server::generated_asset_sheets` 只保留 `AppError` / `AppState` 适配,不再承载图像处理和 OSS 请求构造细节。物品名称 prompt 和特殊设定 prompt 是可选输入;调用方可传入类似“每个物品生成五个不同视图”的视角约束,通用模块会把 sheet prompt、物品行 prompt、特殊设定 prompt 编码写入 OSS 元数据。玩法仍负责计费、物品规划、slot 映射、失败回写和把通用切片结果映射回自己的草稿 / profile / runtime 字段。
-当前所有玩法生成页 UI 统一收敛为圆环主视觉:`media/create_bg_video.mp4` 作为生成页固定全屏背景层循环静音播放,主进度圆环居中覆盖在背景之上,围绕陶泥儿视觉展示;页面只保留当前步骤名称和当前步骤进度,不再渲染步骤列表块。视频层需要显式触发播放,不能只依赖 `autoPlay/loop/muted` 属性。圆环内部保持 `400x400` SVG 坐标系,外层显示宽度以 `400px` 为上限,窄屏按视口宽度收缩,预计等待 / 已耗时信息卡在窄屏下落到圆环下方,避免右侧裁切。共用生成页 `CustomWorldGenerationView` 和汪汪声浪生成页都必须遵循这一口径。
+当前所有玩法生成页 UI 统一收敛为圆环主视觉:`media/create_bg_video.mp4` 作为生成页固定全屏背景层循环静音播放,主进度圆环居中覆盖在背景之上,围绕陶泥儿视觉展示;页面只保留当前步骤名称和当前步骤进度,不再渲染步骤列表块,也不再展示“当前拼图信息”“当前敲木鱼信息”“当前世界信息”等玩法设定信息模块。视频层需要显式触发播放,不能只依赖 `autoPlay/loop/muted` 属性。圆环内部保持 `400x400` SVG 坐标系,外层显示宽度以 `400px` 为上限,窄屏按视口宽度收缩,预计等待 / 已耗时信息卡在窄屏下落到圆环下方,和当前步骤卡保持更大的垂直间距;预计等待左边缘、已耗时右边缘必须分别与当前步骤卡左右边缘对齐,避免右侧裁切或横向漂移。生成页顶部返回栏和状态标识不参与内容滚动,滚动只发生在进度内容区。共用生成页 `CustomWorldGenerationView` 和汪汪声浪生成页都必须遵循这一口径。
## 草稿与作品架
1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。
2. 草稿页顶部 `全部 / 草稿 / 已发布` 筛选与发现页 `推荐 / 今日 / 分类 / 排行` 频道标签复用同一选中 / 未选中视觉,即 `platform-mobile-home-channel` 与 `platform-mobile-home-channel--active`,不再使用旧 `platform-tab` 胶囊样式。
-3. 草稿页与底部导航的未读提示点统一使用平台暖棕色点和暖棕光晕,不再使用红点或红色 glow;草稿 Tab 作品架卡片无论草稿 / 已发布都不外露作者信息;已发布作品卡右上角直接显示无边框分享 icon。删除等破坏性动作在作品卡上也要直接开放独立删除入口,左滑或长按仅作为辅助操作层。
+3. 草稿页与底部导航的未读提示点统一使用平台暖棕色点和暖棕光晕,不再使用红点或红色 glow;草稿 Tab 作品架卡片无论草稿 / 已发布都不外露作者信息;已发布作品卡右上角直接显示无边框分享 icon。删除等破坏性动作在作品卡上也要直接开放统一 `actions.delete` 入口,左滑、长按和键盘左箭头仅作为打开同一操作层的辅助交互;所有玩法草稿和已发布列表项都必须通过该统一接口接入删除确认、删除中状态和列表刷新,不允许只给拼图保留专属滑动删除分支。
4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。
5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。
6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 优先使用后端 session 的 `updatedAt`,没有 session 时再使用作品摘要 `updatedAt`,不得因重新进入页面从 0 秒重新计时。
-7. 生成失败必须按 session 独立记录,不能用一个失败打断或覆盖同玩法的其它生成任务。失败 notice 需要保存错误消息并覆盖作品架本地状态:即使后端摘要暂时仍是 `generationStatus=generating` 或只写出半成品投影,草稿卡也不得继续显示“生成中”,点击后必须进入失败 / 重试生成页,不能重新创建一轮生成;拼图这类失败半成品若没有有效 `workTitle`,作品架标题回退为“拼图草稿”,不暴露“第1关”空壳。
+7. 生成失败必须按 session 独立记录,不能用一个失败打断或覆盖同玩法的其它生成任务。失败 notice 需要保存错误消息并覆盖作品架本地状态:即使后端摘要暂时仍是 `generationStatus=generating` 或只写出半成品投影,草稿卡也不得继续显示“生成中”,点击后必须进入失败 / 重试生成页,不能重新创建一轮生成。失败页点击重新生成时必须优先复用当前可恢复 `sessionId` 执行编译 action;只有没有可恢复 session 时才允许回退到新建草稿。拼图这类失败半成品若没有有效 `workTitle`,作品架标题回退为“拼图草稿”,不暴露“第1关”空壳。
8. 从草稿 Tab 作品架打开草稿工作区、生成页或结果页时,返回按钮必须回到草稿 Tab 的同一作品架语境;从创作 Tab 新建或直接进入创作链路时才回到创作 Tab。平台壳层需要显式记录本次创作流的返回来源,不能让结果页返回动作固定跳到创作入口。
9. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
10. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。
11. 移动端草稿页整体禁止长按选择文字,避免误触系统选区;输入框、文本域和可编辑区域仍必须保留文本选择能力。
-发现页 / 推荐页公开作品卡的作者行只显示可读公开昵称;不得把手机号掩码、`SY-*` 陶泥号或作品号拼接进卡片作者名。陶泥号搜索、作品号复制和完整作品身份只在搜索、详情页或明确的复制入口展示,避免卡片列表暴露账号标识。
+发现页 / 推荐页公开作品卡的作者行只显示可读公开昵称;不得把手机号掩码、账号生成的脱敏手机号、`SY-*` 陶泥号或作品号拼接进卡片作者名。陶泥号搜索、作品号复制和完整作品身份只在搜索、详情页或明确的复制入口展示,避免卡片列表暴露账号标识。推荐页运行态、标题和作者信息必须使用同一套公开作品 key 选中当前条目;新增或补齐公开玩法类型时复用 `buildPlatformPublicGalleryCardKey(...)`,避免运行内容已切换但标题 / 作者仍退回第一条作品。
-发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;常用功能当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。
+发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、通用设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;主题设置、账号与安全只放在通用设置弹窗下一级,不在外层单独占行;常用功能当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度,外层卡片不展示“去完成”等行动按钮。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。
## RPG / 自定义世界
@@ -126,7 +128,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
- 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo.png` 卡通形象;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。
- 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。
- 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜、下一关按钮和相似作品卡。
-- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品。
+- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品;但这仍属于同一个 runtime run 内部推进,不能触发推荐 rail 切卡动画、纵向位移或启动封面重置,已挂载且 ready 的运行态画面应保持稳定,只静默更新作品信息和操作基准。
- 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,再重新按推荐页自动启动逻辑进入作品,不能复用已经被清空的旧 `puzzleRun`。
- 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。
@@ -134,29 +136,39 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
对外名称:`跳一跳`。工程域:`jump-hop`。PRD 见 `docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`。
-首版定位为俯视角 / 等距视角 2D 休闲跳跃模板,链路对齐拼图的创作闭环:
+当前定位为竖屏俯视角 2D 平台跳跃模板,链路对齐平台创作闭环:
```text
-创作入口 -> 模板输入 -> 生成过程页 -> 结果页 -> 试玩 -> 发布 -> 运行态
+创作入口 -> 主题输入 -> 生成过程页 -> 结果页 -> 试玩 -> 发布 -> 运行态
```
+创作入口配置事实源仍是 SpacetimeDB `creation_entry_type_config`:默认 `visible=true`、`open=true`、`badge=可创建`、`subtitle=主题驱动平台跳跃`、`image_src=/creation-type-references/jump-hop.webp`。旧库中仍停留在 `subtitle=俯视角跳跃闯关` 且 `image_src=/creation-type-references/puzzle.webp` 的系统默认行会在入口配置播种流程中自动迁移;同时 `spacetime-client` 的入口配置读模型也会对同一条旧系统默认行做纠偏,避免订阅缓存长期回放老口径。后台手动改过的跳一跳入口配置不被覆盖。
+
素材生成规则固定为:
-1. 初始草稿生成时,角色形象单独调用一次生图;
-2. 初始草稿生成时,地块单独调用一次生图,输出 3D 视图的 2D 图片图集;
-3. 跳一跳地块图集使用专用 `2行*3列` 六格布局,后端按 `start / normal / target / finish / bonus / accent` 顺序切分为透明 PNG;
-4. 封面和分享图由角色图与地块图轻量合成,不再额外调用第三次生图;
-5. 显式重生成角色或地块时,只重生成对应资产槽位。
+1. 创作端只保留主题输入,作品标题、简介、标签和地块提示词由系统派生;
+2. v1 不再单独生成角色图片,运行态固定使用抠除白底后的陶泥儿 logo 透明 PNG 作为玩家角色;
+3. 地块只调用一次 image2,输出一张 `5行*5列`、`1:1`、单一纯洋红 `#FF00FF` key 背景的主题地块图集;跳一跳地块常包含草地、花、雪、白石和云朵,后端透明化必须使用跳一跳专用洋红 key,不启用近白底扣除,也不清理非边缘连通的 key 色像素,避免把绿色或白色主体误扣;后处理必须对边缘连通 key 色做容差清理、去彩边 defringe 和底部残影清理,主体图不得自带洋红阴影、紫色底边、粉色脏边、彩色光晕或发光底边,运行态阴影统一由 DOM 绘制;地块造型提示词要求以主题物体本身外轮廓为准,允许苹果近似圆形、香蕉近似长条或长方形、西瓜近似扇形等自然差异,只统一单格规格、安全留白、正面30度视角和 2D/2.5D 手绘风格包装;所有地块素材必须保持统一正面30度视角,相机位于物体正前方略高位置、镜头向下约30度,必须看到清晰正面、侧壁、下沿、明显自身厚度和少量上表面,主体正面或侧壁可见面积必须接近或大于顶面面积,顶面只能作为辅助可见面;水果主题需要明确要求橙瓣看到橙皮正面外侧和果肉厚度、椰子看到壳的正面侧壁和切口厚度、浆果不能只是从上往下看的圆形球顶;避免生成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标;主题物体本身必须是唯一可落脚体,只能用自身切面、边缘厚度、花瓣层或果皮边表现承重,禁止在主题物体下方额外垫石台、土墩、木板、圆台、托盘、岛屿底座或通用地板;前端和后端默认 `tilePrompt` 都必须使用“正面30度视角主题物体图集,物体本身作为跳跃落点”的口径,不再提交“平台素材 / 跳台 / 地块 / 地砖”等会把模型拉回通用平台造型的词,后端生成前也会清洗旧草稿遗留的这些词;当主题或地块提示词命中宝可梦 / 神奇宝贝 / 口袋妖怪 / Pokemon / Pikachu / 精灵球等宝可梦相关词时,仅生图请求侧改写为“原创幻想萌宠冒险道具 / 彩色冒险能量球 / 黄色闪电萌宠符号”,用户草稿标题和主题展示不改;
+4. 背景底图同样由 image2 生成,复用现有 `coverComposite` / `coverImageSrc` 作为运行态背景读写字段,OSS 槽位固定为 `background/image.png`;提示词必须严格以用户主题关键词为背景主题,结构以左右两侧氛围为主,中央纵轴 1/2 区域保持少元素、简洁、可读且有纵深感,两侧允许更强立体层次和行进感;背景只作为底图,禁止生成跳板、地块、落脚物、角色、UI、返回按钮、文字、路径箭头或海报排版;左上角返回按钮不允许画进背景,而是单独生成 `backButtonAsset` 透明 PNG,OSS 槽位固定为 `back-button/image.png`,提示词要求标准圆形、主题色材质包装、居中左箭头、纯绿色 key 背景,后端去绿后写入作品 profile;
+5. 后端按从上到下、从左到右均匀切分为 `tile-01` 到 `tile-25` 的透明 PNG,每个切片必须使用唯一 slot/path 持久化,不能按重复的 `tileType` 复用槽位;
+6. 结果页只展示陶泥儿 logo 透明角色预览、地块池预览和首屏 3 地块预览;不再提供旧角色图生成槽;
+7. 前端跳一跳创作 client 的创建会话与执行生成动作请求都必须使用 20 分钟等待窗口,避免背景底图、地块图集、切片、抠图和 OSS 写入仍在后端执行时被共创会话默认 15 秒超时中断。
-运行态规则真相必须沉到 `module-jump-hop`,前端只做蓄力表现、角色位移、投影和落地反馈。通关、失败、分数、combo、运行态快照和发布作品状态以后端为准。公开列表应走 `jump_hop_gallery_card_view` 订阅缓存,不要每次 HTTP 请求调用 procedure 组装全量列表。
+运行态规则真相必须沉到 `module-jump-hop`,前端只做拖拽蓄力、角色位移、投影和落地反馈。失败、成功跳跃次数、游戏时长冻结、运行态快照和发布作品状态以后端为准。v1 不保留公开 combo / perfect / 通关语义,旧 `score` 兼容映射为成功跳跃次数。公开列表应走 `jump_hop_gallery_card_view` 订阅缓存,不要每次 HTTP 请求调用 procedure 组装全量列表。
-平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带角色图、地块图集和路径配置,必须先补读完整 work profile 再传入运行态。平台壳层必须同步注册 `jump-hop-workspace`、`jump-hop-generating`、`jump-hop-result`、`jump-hop-runtime`、`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace`、`/creation/jump-hop/generating`、`/creation/jump-hop/result`、`/gallery/jump-hop/detail`、`/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。
+每屏只展示 3 个地块:当前地块、目标地块和下一预览地块。平台流按同一 seed 无限生成,前端不得自行生成正式路径。运行态 HUD 顶部只保留返回按钮和成功跳跃次数,不展示计时器或右上角重开按钮;生成背景和游戏舞台必须覆盖整个运行态视口,HUD 直接绝对定位压在背景上,不再用外层白底、居中窄栏、卡片边框或游戏区域圆角裁切背景。返回按钮固定在左上角安全区,交互热区固定为移动端 `56px`、桌面约 `62px`,不显示“返回”文字,并通过顶部锚点微调与得分标题牌保持协调;运行态优先使用独立 `backButtonAsset` 透明 PNG 作为真实可点击按钮图,旧作品缺失该字段时才使用同尺寸 CSS 主题色圆形按钮兜底。上方成功跳跃次数 UI 复用拼图模板顶部 HUD 结构:`puzzle-runtime-header-card` 内包含陶泥儿 IP logo、居中的“得分”标题牌,以及下挂 `puzzle-runtime-timer-card / puzzle-runtime-timer` 居中数字卡;数字卡展示成功跳跃次数而不是倒计时。游玩中不显示左下角“进行中”状态,也不在屏幕底部常驻排行榜。排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长;每位玩家只保留 1 条最佳记录,排序固定为 `成功跳跃次数 desc -> 游戏时长 asc -> 更新时间 asc`,并只在失败结算弹窗内展示,弹窗保留重开和返回动作。
+
+运行态渲染分层固定为:舞台底层 `.jump-hop-runtime__scene-backdrop` 优先使用 `coverComposite` / `coverImageSrc` 中的 image2 背景底图,图片读取继续走平台资产换签,没有背景时才回退到内置渐变;DOM 平台层直接使用 `tileAssets[]` 的生成切片图片显示地块,图片读取继续走平台资产换签,并以 `assetObjectId` 作为刷新键避免重生成后沿用旧签名或旧图片缓存;每个地块下方的统一软椭圆阴影来自运行态 DOM 的 `.jump-hop-runtime__platform-shadow`,不是 image2 地块切片的必需内容,调整阴影优先改运行态 CSS;有真实地块图片 URL 时不得在加载空档显示 fallback 原型地块,下一屏预览地块必须在进入相机视野前隐藏预加载;DOM 角色层固定使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG 并保持最高层级;Three.js 透明画布仅作为后续扩展层。拖拽蓄力、计时刷新和角色位置变化只能更新 refs 或 DOM 状态,不得销毁重建透明画布、背景或平台图片层,否则会造成背景、地块和角色层频闪。
+
+跳一跳当前拖拽手感统一采用 `chargeToDistanceRatio=0.008`,用于把同等跳跃距离所需拖拽距离缩短到旧 `0.004` 的一半;如果历史路径仍保存旧系数,`start_run` 会在开局归一化到新系数。拖拽中只显示弹弓拉线,不显示落点辅助点、投影圈或其它命中提示。松手后运行态必须立即生成 `visualJump`,用当前角色位置作为起点、前端预测落点作为终点,播放约 `560ms` 的角色飞行动画:蓄力时角色沿拖拽方向明显拉长,角色弹向预测落点,落地后向反方向回弹两次;动画路径不得等待后端新 run。若后端新 run 晚于飞行动画返回,角色必须停在预测落点等待,直到新 run 到达后再把显示态切到后端最新 run,并用约 `1440ms` 的相机层推进过渡承接新窗口。推进时地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块自然离开视野,新预览地块从上方露出,禁止用 p1/p2 各自 `top/left` 过渡造成角色和地块不同步。相机层推进必须同时使用 X/Y 偏移,从旧目标地块位置斜向滑到新当前地块聚焦位置,不得先横向瞬切到居中再纵向滑动。地块允许保留当前 / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 `transform: scale(...)` 缓动呈现,并与相机推进使用同一 `1440ms` 节奏;不要直接修改宽高造成瞬切,也不要再给当前态额外叠 CSS scale。相机推进期间角色自身必须禁用 `left/top` transition,只允许父级 camera layer 负责位移,否则角色局部坐标切换和相机推进会叠加,表现为落地后又从屏幕外闪回。
+
+平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带地块图集和路径配置,必须先补读完整 work profile 再传入运行态。平台壳层必须同步注册 `jump-hop-workspace`、`jump-hop-generating`、`jump-hop-result`、`jump-hop-runtime`、`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace`、`/creation/jump-hop/generating`、`/creation/jump-hop/result`、`/gallery/jump-hop/detail`、`/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。
跳一跳作品架走创作中心的统一作品列表:前端通过 `/api/creation/jump-hop/works` 拉取作品摘要,草稿态会与 pending notice 合并后显示在作品架里,已发布作品点击后会先按 profileId 读取完整详情再进入详情或运行态。生成中作品仍以后端摘要里的 `generationStatus` 为准,刷新后应能恢复等待遮罩,不能只依赖内存 notice。
-删除等破坏性动作当前未接入 jump-hop 删除 API;如果后续要在作品架提供删除入口,必须先补齐后端/SpacetimeDB/前端整条删除链路,再开放按钮。
+跳一跳作品架删除入口必须走 `/api/creation/jump-hop/works/{profile_id}`,并通过 SpacetimeDB 同步删除 work profile、源 session、运行态 run 与事件,再刷新作品架和公开广场;不得只做前端本地隐藏。
-推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。
+推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页启动或切换作品时先展示当前作品封面,嵌入 runtime 在封面下层加载;只有对应运行态 run / profile 已准备且 lazy runtime 组件完成挂载后,封面才渐隐,不在中途展示“加载中”文案。拼图下一关在同一个 run 内推进到相似作品时不视为推荐作品切换,不能重新显示启动封面。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。
## 敲木鱼
@@ -171,7 +183,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
创作输入固定为:
1. `敲什么`:敲击物单图资产槽位。默认模板使用内置透明 PNG `/wooden-fish/default-hit-object.png` 作为 `bundled-default` 敲击物资产,避免默认关键词被重新语义化改形;用户输入自定义关键词或上传参考图时,后端必须以默认木鱼图作为基础结构和画风参考,使用 image2 生成最终敲击物图案,上传图只作为新主题参考,不直接进入运行态。自定义 `compile-draft` / `regenerate-hit-object` 必须完成 image2 -> OSS 私有对象 -> asset object 登记和绑定后,再由 `api-server` 注入真实 `hitObjectAsset.imageSrc`,不能只写 `/generated-wooden-fish-assets/...` 占位路径,也不能接受前端请求自带的 `hitObjectAsset` 短路生成。
-2. `敲击音效`:音频资产槽位,当前创作阶段只支持用户上传或麦克风录制;未提供音频时统一写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。提示词生成音效入口临时关闭,通用 `/api/creation/audio/sound-effect` 对木鱼 `hit_sound` 目标也返回 `410 Gone`;`hitSoundPrompt` 只作为历史兼容字段保留,不参与当前创作流程,也不得由 `spacetime-client` 合成假音频路径。
+2. `敲击音效`:音频资产槽位,当前创作阶段只支持用户上传或麦克风录制;麦克风录制结束后,前端会自动裁掉音频开头连续静音段,再把裁剪后的录音作为 `recorded` 音频资产写入表单。上传音频不做裁剪;浏览器音频解码或裁剪失败时保留原始录音继续保存,不能让用户录音丢失。未提供音频时统一写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。提示词生成音效入口临时关闭,通用 `/api/creation/audio/sound-effect` 对木鱼 `hit_sound` 目标也返回 `410 Gone`;`hitSoundPrompt` 只作为历史兼容字段保留,不参与当前创作流程,也不得由 `spacetime-client` 合成假音频路径。
3. `功德有什么`:最多 8 条飘字,创作态首屏只保留一个默认词条 `幸运`,其下提供加号格继续追加词条;创作态只保存词条名,运行态飘字展示时再追加 `+1`。运行态顶部总数卡采用品牌化徽标样式,子项计数器预置展示在可展开面板中,未出现词条初始值为 0。
4. `作品标题 / 作品简介 / 主题标签`:不再放在创作工作台首屏,改为生成草稿后的结果页补录区,提交试玩或发布前必须先写回当前作品信息。主题标签编辑样式对齐拼图结果页的胶囊标签编辑器。
diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md
index 68c05a44..8638b222 100644
--- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md
+++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md
@@ -1,6 +1,6 @@
# 当前产品与工程约束
-更新时间:`2026-05-15`
+更新时间:`2026-06-05`
## 项目定位
@@ -46,6 +46,10 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
3. 登录弹窗继续复用现有独立 modal 和页签结构,不在页面中新增功能说明类文案,也不把邀请码输入放回登录面板。
4. 微信小程序 `web-view` 外壳默认不预登录,首次进入直接打开 H5,并保持与 Web 端一致的未登录状态;只有 H5 触发 `openLoginModal` / `requireAuth` 等受保护入口时,才跳转小程序原生授权态。
5. 小程序内需要登录时不展示 H5 登录弹窗,也不走手输手机号 / 短信验证码流程;统一通过原生 `button open-type="getPhoneNumber"` 获取微信手机号授权,再调用 `/api/auth/wechat/miniprogram-login` 与 `/api/auth/wechat/bind-phone` 换取系统登录态。
+6. 小程序外壳注入到 H5 URL 的 `clientType`、`clientRuntime`、`miniProgramEnv` 是宿主上下文,H5 内部 `pushState` / 阶段导航必须跨页面保留,避免登录和充值误判为普通浏览器;首点时微信 JS bridge 可能尚未就绪,前端还需用 `MicroMessenger + miniProgram` User-Agent 作为小程序识别兜底。
+7. 小程序 `web-view` 页必须启用好友分享与朋友圈分享,分享目标固定回到 `pages/web-view/index`,不把 H5 当前 URL 作为不受控启动参数传回小程序页。
+8. 小程序 `web-view` 外壳运行时通过 `wx.getAccountInfoSync().miniProgram.envVersion` 自动识别版本:线上版 `release` 使用 `www.genarrative.world`,体验版 `trial` 与开发版 `develop` 使用 `dev.genarrative.world`;传给后端的 `x-mini-program-env` 分别为 `release`、`trial`、`dev`。
+9. 账号信息面板只展示 `账号信息` 标题;绑定手机号和绑定微信以紧凑模块展示当前绑定状态,已绑定手机号展示完整手机号,已绑定微信展示微信昵称而不是微信账号标识,换绑入口放在对应模块右上角,退出登录和退出全部设备固定放在面板内容最底部。
## 账户与充值
@@ -95,9 +99,9 @@ server-rs + Axum + SpacetimeDB
7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。
8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。
9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal,至少支持玩法类型过滤与排序切换;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。
-10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位。页面不再提供独立存档按钮入口,也不在底部保留旧的填邀请码次级入口。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。
-11. “我的”页每日任务卡必须展示后端 `/api/profile/tasks` 返回的当前任务摘要,包括奖励泥点数、进度和领取 / 去完成 / 已完成状态;任务领取成功后,卡片摘要必须跟随返回的任务中心数据同步刷新,不能继续硬编码 `0 / 1` 或只更新弹窗内任务列表。
-12. “我的”页泥点、游戏时长、已玩游戏数量三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。
+10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、通用设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位。页面不再提供独立存档按钮入口,也不在底部保留旧的填邀请码次级入口;主题设置、账号与安全只作为通用设置弹窗下一级入口,不在“我的”页外层单独占行。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。
+11. “我的”页每日任务卡必须展示后端 `/api/profile/tasks` 返回的当前任务摘要,包括奖励泥点数和进度;外层任务卡不展示“去完成”等左右侧行动按钮,领取 / 去完成 / 已完成状态只在任务中心弹窗内表达。任务领取成功后,卡片摘要必须跟随返回的任务中心数据同步刷新,不能继续硬编码 `0 / 1` 或只更新弹窗内任务列表。用户停留在“我的”页跨过北京时间 0 点时,前端必须非阻断刷新登录态以补齐 `daily_login` 埋点,再重拉任务中心,避免继续展示上一自然日已领取状态。
+12. “我的”页泥点余额、累计游玩、已玩游戏三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。
13. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、每日任务、五项常用功能和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。
14. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗。
15. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css` 的 `--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案。
diff --git a/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build
index 9d925de6..609866ca 100644
--- a/jenkins/Jenkinsfile.production-api-build
+++ b/jenkins/Jenkinsfile.production-api-build
@@ -88,7 +88,7 @@ pipeline {
chmod +x scripts/jenkins-prepare-cargo-env.sh
source scripts/jenkins-prepare-cargo-env.sh
if ! command -v clang >/dev/null 2>&1 || ! command -v lld >/dev/null 2>&1; then
- echo "[api-build] 缺少 clang/lld。请先运行 Genarrative-Server-Provision 安装 Linux 构建依赖。" >&2
+ echo "[api-build] 缺少 clang/lld。请在 genarrative-build 节点预先安装 Linux 构建依赖。" >&2
exit 1
fi
if ! command -v sccache >/dev/null 2>&1; then
diff --git a/jenkins/Jenkinsfile.production-api-deploy b/jenkins/Jenkinsfile.production-api-deploy
index 95432266..8d17e08e 100644
--- a/jenkins/Jenkinsfile.production-api-deploy
+++ b/jenkins/Jenkinsfile.production-api-deploy
@@ -24,7 +24,7 @@ pipeline {
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: '生产 release 根目录')
string(name: 'CURRENT_LINK', defaultValue: '/opt/genarrative/current', description: '当前版本软链接')
string(name: 'SERVICE_NAME', defaultValue: 'genarrative-api.service', description: 'systemd 服务名')
- string(name: 'HEALTH_URL', defaultValue: 'http://127.0.0.1:8082/healthz', description: '本机健康检查地址')
+ string(name: 'HEALTH_URL', defaultValue: 'http://127.0.0.1:8082/readyz', description: '本机 readiness 检查地址')
string(name: 'API_ENV_FILE', defaultValue: '/etc/genarrative/api-server.env', description: 'api-server 环境文件')
string(name: 'DATABASE', defaultValue: 'genarrative-prod', description: 'api-server 连接的 SpacetimeDB database')
string(name: 'SPACETIME_SERVER_URL', defaultValue: 'http://127.0.0.1:3101', description: 'api-server 连接的 SpacetimeDB server URL')
diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision
index 25d7b229..2561aa3f 100644
--- a/jenkins/Jenkinsfile.production-server-provision
+++ b/jenkins/Jenkinsfile.production-server-provision
@@ -7,25 +7,21 @@ pipeline {
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
}
- environment {
- GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
- GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
- }
-
parameters {
- choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent')
+ choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用 dev 服务器部署 agent,release 使用正式服务器部署 agent')
booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent')
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送')
booleanParam(name: 'CONFIRM_PROVISION', defaultValue: false, description: '确认执行服务器初始化;未勾选时只允许 dry-run')
booleanParam(name: 'DRY_RUN', defaultValue: true, description: '只打印将执行的服务器初始化命令,不写入系统配置')
+ string(name: 'SOURCE_GIT_REMOTE_URL', defaultValue: '', description: '部署脚本 Git 来源;必须是目标 agent 可访问的内网/本机 Gitea 地址,不配置公网备用')
string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支')
string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit')
string(name: 'SERVER_NAME', defaultValue: 'genarrative.example.com', description: '证书主域名;也作为 Nginx server_name 的第一个域名')
string(name: 'SERVER_ALIASES', defaultValue: '', description: '可选,额外 Nginx server_name,多个用空格或逗号分隔,例如 www.genarrative.world')
- string(name: 'PROVISION_DOWNLOADS_DIR', defaultValue: 'provision-tool-downloads', description: 'Linux 预下载阶段暂存 SpacetimeDB/otelcol 安装包的工作区相对目录')
+ string(name: 'PROVISION_DOWNLOADS_DIR', defaultValue: 'provision-tool-downloads', description: '目标服务器工作区内暂存 SpacetimeDB/otelcol 安装包的相对目录')
string(name: 'PROVISION_TOOLS_DIR', defaultValue: 'provision-tools', description: '目标机工作区内由已下载安装包生成的工具包目录')
- string(name: 'PROVISION_DOWNLOAD_PROXY', defaultValue: '', description: '可选,Linux 预下载阶段下载 SpacetimeDB 和 otelcol-contrib 时使用的代理地址,例如 http://127.0.0.1:7890;留空不设置代理')
- string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/latest/download', description: 'Linux 预下载阶段使用的 SpacetimeDB Linux release tarball 根地址')
+ string(name: 'PROVISION_DOWNLOAD_PROXY', defaultValue: '', description: '可选,目标服务器下载 SpacetimeDB 和 otelcol-contrib 时使用的代理地址,例如 http://127.0.0.1:7890;留空不设置代理')
+ string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/latest/download', description: '目标服务器使用的 SpacetimeDB Linux release tarball 根地址')
string(name: 'SPACETIME_TARGET_HOST', defaultValue: 'x86_64-unknown-linux-gnu', description: 'SpacetimeDB 预编译包 host triple,development/release Linux amd64 使用默认值')
string(name: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir')
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录')
@@ -40,205 +36,160 @@ pipeline {
}
stages {
- stage('Prepare') {
+ stage('Provision Target') {
agent {
- label 'linux && genarrative-build'
+ label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}"
}
- steps {
- script {
- if (params.DEPLOY_TARGET == 'release' && !params.CONFIRM_RELEASE_DEPLOY_AGENT) {
- error('release provision 需要先配置独立 release 部署 agent,并勾选 CONFIRM_RELEASE_DEPLOY_AGENT。')
- }
- if (!params.DRY_RUN && !params.CONFIRM_PROVISION) {
- error('执行服务器初始化前必须勾选 CONFIRM_PROVISION;否则请保持 DRY_RUN=true。')
- }
- if (!params.SERVER_NAME?.trim()) {
- error('SERVER_NAME 不能为空。')
- }
- if (!(params.SERVER_NAME.trim() ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) {
- error("SERVER_NAME 只能填写单个域名或 IP,不能包含空格、路径或协议: ${params.SERVER_NAME}")
- }
- def serverAliases = params.SERVER_ALIASES?.trim()
- if (serverAliases) {
- serverAliases.split(/[,\s]+/).findAll { it }.each { aliasName ->
- if (!(aliasName ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) {
- error("SERVER_ALIASES 只能填写域名或 IP,多个用空格或逗号分隔: ${aliasName}")
+ stages {
+ stage('Prepare') {
+ steps {
+ script {
+ if (params.DEPLOY_TARGET == 'release' && !params.CONFIRM_RELEASE_DEPLOY_AGENT) {
+ error('release provision 需要先配置独立 release 部署 agent,并勾选 CONFIRM_RELEASE_DEPLOY_AGENT。')
+ }
+ if (!params.DRY_RUN && !params.CONFIRM_PROVISION) {
+ error('执行服务器初始化前必须勾选 CONFIRM_PROVISION;否则请保持 DRY_RUN=true。')
+ }
+ if (!params.SERVER_NAME?.trim()) {
+ error('SERVER_NAME 不能为空。')
+ }
+ def sourceGitRemoteUrl = params.SOURCE_GIT_REMOTE_URL?.trim()
+ if (!sourceGitRemoteUrl) {
+ error('SOURCE_GIT_REMOTE_URL 不能为空。')
+ }
+ def isLocalGitPath = sourceGitRemoteUrl ==~ /^\/[0-9A-Za-z._\/-]+$/
+ def isLocalGitFileUrl = sourceGitRemoteUrl ==~ /^file:\/\/\/\S+$/
+ def isPrivateHttpGitUrl = sourceGitRemoteUrl ==~ /^https?:\/\/(localhost|127(?:\.[0-9]{1,3}){3}|10(?:\.[0-9]{1,3}){3}|192\.168(?:\.[0-9]{1,3}){2}|172\.(?:1[6-9]|2[0-9]|3[0-1])(?:\.[0-9]{1,3}){2}|[A-Za-z0-9-]+|[A-Za-z0-9.-]+\.(?:local|lan|internal))(?::[0-9]+)?\/\S+$/
+ if (!isLocalGitPath && !isLocalGitFileUrl && !isPrivateHttpGitUrl) {
+ error('Genarrative-Server-Provision 不允许使用公网 Git 仓库;SOURCE_GIT_REMOTE_URL 只能是目标 agent 可访问的本机路径、file:/// 地址、localhost/127.0.0.1、RFC1918 内网 HTTP 地址、单标签内网主机名或 .local/.lan/.internal 地址。')
+ }
+ env.EFFECTIVE_GIT_REMOTE_URL = sourceGitRemoteUrl
+ if (!(params.SERVER_NAME.trim() ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) {
+ error("SERVER_NAME 只能填写单个域名或 IP,不能包含空格、路径或协议: ${params.SERVER_NAME}")
+ }
+ def serverAliases = params.SERVER_ALIASES?.trim()
+ if (serverAliases) {
+ serverAliases.split(/[,\s]+/).findAll { it }.each { aliasName ->
+ if (!(aliasName ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) {
+ error("SERVER_ALIASES 只能填写域名或 IP,多个用空格或逗号分隔: ${aliasName}")
+ }
+ }
+ }
+ if (!params.PROVISION_TOOLS_DIR?.trim()) {
+ error('PROVISION_TOOLS_DIR 不能为空。')
+ }
+ if (!(params.PROVISION_TOOLS_DIR.trim() ==~ /^[0-9A-Za-z._\/-]+$/) || params.PROVISION_TOOLS_DIR.startsWith('/') || params.PROVISION_TOOLS_DIR.contains('..') || params.PROVISION_TOOLS_DIR.trim() == '.') {
+ error("PROVISION_TOOLS_DIR 只能是工作区内的相对目录,不能包含绝对路径或连续点号: ${params.PROVISION_TOOLS_DIR}")
+ }
+ if (!params.PROVISION_DOWNLOADS_DIR?.trim()) {
+ error('PROVISION_DOWNLOADS_DIR 不能为空。')
+ }
+ if (!(params.PROVISION_DOWNLOADS_DIR.trim() ==~ /^[0-9A-Za-z._\/-]+$/) || params.PROVISION_DOWNLOADS_DIR.startsWith('/') || params.PROVISION_DOWNLOADS_DIR.contains('..') || params.PROVISION_DOWNLOADS_DIR.trim() == '.') {
+ error("PROVISION_DOWNLOADS_DIR 只能是工作区内的相对目录,不能包含绝对路径或连续点号: ${params.PROVISION_DOWNLOADS_DIR}")
+ }
+ def provisionToolsDir = params.PROVISION_TOOLS_DIR.trim()
+ def provisionDownloadsDir = params.PROVISION_DOWNLOADS_DIR.trim()
+ if (provisionToolsDir == provisionDownloadsDir || provisionDownloadsDir.startsWith("${provisionToolsDir}/")) {
+ error("PROVISION_DOWNLOADS_DIR 不能等于或位于 PROVISION_TOOLS_DIR 内,否则目标机生成工具包时会删除下载缓存: ${provisionDownloadsDir}")
+ }
+ def provisionDownloadProxy = params.PROVISION_DOWNLOAD_PROXY?.trim()
+ if (provisionDownloadProxy && !(provisionDownloadProxy ==~ /^https?:\/\/\S+$/)) {
+ error("PROVISION_DOWNLOAD_PROXY 只能填写 http:// 或 https:// 开头的代理地址,当前值: ${params.PROVISION_DOWNLOAD_PROXY}")
+ }
+ if (!(params.OTELCOL_VERSION?.trim() ==~ /^[0-9]+\.[0-9]+\.[0-9]+$/)) {
+ error("OTELCOL_VERSION 格式应为 x.y.z: ${params.OTELCOL_VERSION}")
+ }
+ if (!(params.SPACETIME_DOWNLOAD_ROOT?.trim() ==~ /^https?:\/\/\S+$/)) {
+ error('SPACETIME_DOWNLOAD_ROOT 不能为空。')
+ }
+ if (!(params.SPACETIME_TARGET_HOST?.trim() ==~ /^[0-9A-Za-z._-]+$/)) {
+ error("SPACETIME_TARGET_HOST 只能包含字母、数字、点号、下划线和短横线: ${params.SPACETIME_TARGET_HOST}")
+ }
+ def nginxMode = params.NGINX_CONFIG_MODE?.trim()
+ if (!(nginxMode in ['none', 'production-https', 'development-http'])) {
+ error("NGINX_CONFIG_MODE 只能是 none、production-https 或 development-http,当前值: ${params.NGINX_CONFIG_MODE}")
+ }
+ if (params.DEPLOY_TARGET == 'release' && nginxMode == 'development-http') {
+ error('release 目标禁止安装 development-http Nginx 配置;无证书初始化请使用 NGINX_CONFIG_MODE=none。')
+ }
+ if (!params.DRY_RUN && nginxMode == 'production-https' && params.SERVER_NAME?.trim() == 'genarrative.example.com') {
+ error('真实初始化安装 Nginx 配置时必须把 SERVER_NAME 改成真实域名,不能使用 genarrative.example.com 占位值。证书未准备好时请先保持 NGINX_CONFIG_MODE=none。')
}
}
}
- if (!params.PROVISION_TOOLS_DIR?.trim()) {
- error('PROVISION_TOOLS_DIR 不能为空。')
- }
- if (!(params.PROVISION_TOOLS_DIR.trim() ==~ /^[0-9A-Za-z._\/-]+$/) || params.PROVISION_TOOLS_DIR.startsWith('/') || params.PROVISION_TOOLS_DIR.contains('..') || params.PROVISION_TOOLS_DIR.trim() == '.') {
- error("PROVISION_TOOLS_DIR 只能是工作区内的相对目录,不能包含绝对路径或连续点号: ${params.PROVISION_TOOLS_DIR}")
- }
- if (!params.PROVISION_DOWNLOADS_DIR?.trim()) {
- error('PROVISION_DOWNLOADS_DIR 不能为空。')
- }
- if (!(params.PROVISION_DOWNLOADS_DIR.trim() ==~ /^[0-9A-Za-z._\/-]+$/) || params.PROVISION_DOWNLOADS_DIR.startsWith('/') || params.PROVISION_DOWNLOADS_DIR.contains('..') || params.PROVISION_DOWNLOADS_DIR.trim() == '.') {
- error("PROVISION_DOWNLOADS_DIR 只能是工作区内的相对目录,不能包含绝对路径或连续点号: ${params.PROVISION_DOWNLOADS_DIR}")
- }
- def provisionToolsDir = params.PROVISION_TOOLS_DIR.trim()
- def provisionDownloadsDir = params.PROVISION_DOWNLOADS_DIR.trim()
- if (provisionToolsDir == provisionDownloadsDir || provisionDownloadsDir.startsWith("${provisionToolsDir}/")) {
- error("PROVISION_DOWNLOADS_DIR 不能等于或位于 PROVISION_TOOLS_DIR 内,否则目标机生成工具包时会删除下载缓存: ${provisionDownloadsDir}")
- }
- def provisionDownloadProxy = params.PROVISION_DOWNLOAD_PROXY?.trim()
- if (provisionDownloadProxy && !(provisionDownloadProxy ==~ /^https?:\/\/\S+$/)) {
- error("PROVISION_DOWNLOAD_PROXY 只能填写 http:// 或 https:// 开头的代理地址,当前值: ${params.PROVISION_DOWNLOAD_PROXY}")
- }
- if (!(params.OTELCOL_VERSION?.trim() ==~ /^[0-9]+\.[0-9]+\.[0-9]+$/)) {
- error("OTELCOL_VERSION 格式应为 x.y.z: ${params.OTELCOL_VERSION}")
- }
- if (!(params.SPACETIME_DOWNLOAD_ROOT?.trim() ==~ /^https?:\/\/\S+$/)) {
- error('SPACETIME_DOWNLOAD_ROOT 不能为空。')
- }
- if (!(params.SPACETIME_TARGET_HOST?.trim() ==~ /^[0-9A-Za-z._-]+$/)) {
- error("SPACETIME_TARGET_HOST 只能包含字母、数字、点号、下划线和短横线: ${params.SPACETIME_TARGET_HOST}")
- }
- def nginxMode = params.NGINX_CONFIG_MODE?.trim()
- if (!(nginxMode in ['none', 'production-https', 'development-http'])) {
- error("NGINX_CONFIG_MODE 只能是 none、production-https 或 development-http,当前值: ${params.NGINX_CONFIG_MODE}")
- }
- if (params.DEPLOY_TARGET == 'release' && nginxMode == 'development-http') {
- error('release 目标禁止安装 development-http Nginx 配置;无证书初始化请使用 NGINX_CONFIG_MODE=none。')
- }
- if (!params.DRY_RUN && nginxMode == 'production-https' && params.SERVER_NAME?.trim() == 'genarrative.example.com') {
- error('真实初始化安装 Nginx 配置时必须把 SERVER_NAME 改成真实域名,不能使用 genarrative.example.com 占位值。证书未准备好时请先保持 NGINX_CONFIG_MODE=none。')
- }
}
- }
- }
- stage('Prepare Provision Tools') {
- agent {
- label 'linux && genarrative-build'
- }
- steps {
- script {
- def checkoutFromRemote = { String remoteUrl ->
- checkout([
- $class: 'GitSCM',
- branches: [[name: "*/${params.SOURCE_BRANCH}"]],
- doGenerateSubmoduleConfigurations: false,
- extensions: [
- [$class: 'CleanBeforeCheckout'],
- [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
- ],
- userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
- ])
- }
- try {
- checkoutFromRemote(env.GIT_REMOTE_URL)
- env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
- } catch (error) {
- echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
- checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
- env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
- }
- }
- sh '''
- bash <<'BASH'
- set -euo pipefail
- chmod +x scripts/jenkins-checkout-source.sh
- SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
- COMMIT_HASH="${COMMIT_HASH:-}" \
- GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
- GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
- SOURCE_COMMIT_FILE=".jenkins-source-commit" \
- scripts/jenkins-checkout-source.sh
+ stage('Checkout Provision Files') {
+ steps {
+ script {
+ checkout([
+ $class: 'GitSCM',
+ branches: [[name: "*/${params.SOURCE_BRANCH}"]],
+ doGenerateSubmoduleConfigurations: false,
+ extensions: [
+ [$class: 'CleanBeforeCheckout'],
+ [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
+ ],
+ userRemoteConfigs: [[url: env.EFFECTIVE_GIT_REMOTE_URL, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
+ ])
+ }
+ sh '''
+ bash <<'BASH'
+ set -euo pipefail
+ chmod +x scripts/jenkins-checkout-source.sh
+ SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
+ COMMIT_HASH="${COMMIT_HASH:-${SOURCE_COMMIT:-}}" \
+ GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL}" \
+ SOURCE_COMMIT_FILE=".jenkins-source-commit" \
+ scripts/jenkins-checkout-source.sh
BASH
- '''
- sh '''
- bash -lc '
- set -euo pipefail
- chmod +x scripts/prepare-server-provision-tools.sh
- PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}" \
- PROVISION_DOWNLOADS_DIR="${PROVISION_DOWNLOADS_DIR:-provision-tool-downloads}" \
- OTELCOL_VERSION="${OTELCOL_VERSION:-0.151.0}" \
- PREPARE_OTELCOL="${ENABLE_OTELCOL:-true}" \
- PROVISION_DOWNLOAD_PROXY="${PROVISION_DOWNLOAD_PROXY:-}" \
- SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/latest/download}" \
- SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}" \
- scripts/prepare-server-provision-tools.sh
- '
- '''
- script {
- env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim()
- echo "Provision 工具包源码 commit=${env.SOURCE_COMMIT}"
+ '''
+ script {
+ env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim()
+ echo "Provision 源码 commit=${env.SOURCE_COMMIT}"
+ }
+ }
}
- stash name: 'server-provision-tools', includes: "${params.PROVISION_TOOLS_DIR}/**", useDefaultExcludes: false
- }
- }
- stage('Checkout Provision Files') {
- agent {
- label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
- }
- steps {
- script {
- def checkoutFromRemote = { String remoteUrl ->
- checkout([
- $class: 'GitSCM',
- branches: [[name: "*/${params.SOURCE_BRANCH}"]],
- doGenerateSubmoduleConfigurations: false,
- extensions: [
- [$class: 'CleanBeforeCheckout'],
- [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
- ],
- userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
- ])
- }
- try {
- checkoutFromRemote(env.GIT_REMOTE_URL)
- env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
- } catch (error) {
- echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
- checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
- env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
+ stage('Prepare Provision Tools') {
+ steps {
+ sh '''
+ bash -lc '
+ set -euo pipefail
+ chmod +x scripts/prepare-server-provision-tools.sh
+ PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}" \
+ PROVISION_DOWNLOADS_DIR="${PROVISION_DOWNLOADS_DIR:-provision-tool-downloads}" \
+ OTELCOL_VERSION="${OTELCOL_VERSION:-0.151.0}" \
+ PREPARE_OTELCOL="${ENABLE_OTELCOL:-true}" \
+ PROVISION_DOWNLOAD_PROXY="${PROVISION_DOWNLOAD_PROXY:-}" \
+ SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/latest/download}" \
+ SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}" \
+ scripts/prepare-server-provision-tools.sh
+ '
+ '''
}
}
- sh '''
- bash <<'BASH'
- set -euo pipefail
- chmod +x scripts/jenkins-checkout-source.sh
- SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
- COMMIT_HASH="${COMMIT_HASH:-${SOURCE_COMMIT:-}}" \
- GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
- GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
- SOURCE_COMMIT_FILE=".jenkins-source-commit" \
- scripts/jenkins-checkout-source.sh
-BASH
- '''
- script {
- env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim()
- echo "Provision 源码 commit=${env.SOURCE_COMMIT}"
- }
- }
- }
- stage('Provision Server') {
- agent {
- label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
- }
- steps {
- unstash 'server-provision-tools'
- sh '''
- bash <<'BASH'
- set -euo pipefail
- if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
- chmod +x "${PROVISION_TOOLS_DIR:-provision-tools}/otelcol-contrib"
- fi
- chmod +x "${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/spacetime" \
- "${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/bin/current/spacetimedb-cli" \
- "${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/bin/current/spacetimedb-standalone"
- chmod +x scripts/jenkins-server-provision.sh
- PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}" \
- SPACETIME_BIN_SOURCE="${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/spacetime" \
- OTELCOL_BIN_SOURCE="${PROVISION_TOOLS_DIR:-provision-tools}/otelcol-contrib" \
- scripts/jenkins-server-provision.sh
+ stage('Provision Server') {
+ steps {
+ sh '''
+ bash <<'BASH'
+ set -euo pipefail
+ if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
+ chmod +x "${PROVISION_TOOLS_DIR:-provision-tools}/otelcol-contrib"
+ fi
+ chmod +x "${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/spacetime" \
+ "${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/bin/current/spacetimedb-cli" \
+ "${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/bin/current/spacetimedb-standalone"
+ chmod +x scripts/jenkins-server-provision.sh
+ PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}" \
+ SPACETIME_BIN_SOURCE="${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/spacetime" \
+ OTELCOL_BIN_SOURCE="${PROVISION_TOOLS_DIR:-provision-tools}/otelcol-contrib" \
+ scripts/jenkins-server-provision.sh
BASH
- '''
+ '''
+ }
+ }
}
}
}
@@ -253,9 +204,7 @@ BASH
string(name: 'SOURCE_RESULT', value: currentBuild.currentResult ?: 'UNKNOWN'),
string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH ?: ''),
string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT ?: (params.COMMIT_HASH ?: '')),
- string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION ?: (params.BUILD_VERSION ?: '')),
string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET ?: ''),
- string(name: 'DATABASE', value: params.DATABASE ?: ''),
string(name: 'SUMMARY', value: '服务器初始化流水线结束'),
]
def notificationRecipients = params.NOTIFICATION_EMAILS?.trim()
diff --git a/miniprogram/config.js b/miniprogram/config.js
index f9488282..c521817f 100644
--- a/miniprogram/config.js
+++ b/miniprogram/config.js
@@ -2,15 +2,17 @@
// 示例:https://game.example.com/
// 注意:必须是 https 域名,不能是 localhost、IP 地址或未备案域名。
const WEB_VIEW_ENTRY_URL = 'https://www.genarrative.world';
+const DEV_WEB_VIEW_ENTRY_URL = 'https://dev.genarrative.world';
// 中文注释:这里填写 Rust api-server 的公网 HTTPS 域名,必须在“小程序后台-开发设置-request 合法域名”中配置。
// 如果 H5 和 API 同域,可保持和 WEB_VIEW_ENTRY_URL 同一个域名;请求路径会固定走 /api/auth/wechat/miniprogram-login。
const API_BASE_URL = 'https://www.genarrative.world';
+const DEV_API_BASE_URL = 'https://dev.genarrative.world';
// 中文注释:这里填写微信小程序 AppID,用于后端记录会话来源;project.config.json 里的 appid 也要保持一致。
const MINI_PROGRAM_APP_ID = 'wx3da23ea14ca66b65';
-// 中文注释:按当前上传版本填写 develop / trial / release,后端会写入会话来源快照。
+// 中文注释:仅作为运行时环境识别失败时的兜底;正常情况下由 wx.getAccountInfoSync 自动判断。
const MINI_PROGRAM_ENV = 'release';
// 中文注释:给 H5 加一个来源标记,便于后续前端或后端识别这是微信小程序 web-view 宿主。
@@ -21,6 +23,8 @@ const WEB_VIEW_SOURCE_QUERY = {
module.exports = {
API_BASE_URL,
+ DEV_API_BASE_URL,
+ DEV_WEB_VIEW_ENTRY_URL,
MINI_PROGRAM_APP_ID,
MINI_PROGRAM_ENV,
WEB_VIEW_ENTRY_URL,
diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js
index 1f33e2cb..4b9e22c7 100644
--- a/miniprogram/pages/web-view/index.js
+++ b/miniprogram/pages/web-view/index.js
@@ -3,6 +3,8 @@
const {
API_BASE_URL,
+ DEV_API_BASE_URL,
+ DEV_WEB_VIEW_ENTRY_URL,
MINI_PROGRAM_APP_ID,
MINI_PROGRAM_ENV,
WEB_VIEW_ENTRY_URL,
@@ -16,6 +18,33 @@ const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result';
const AUTH_RESULT_STORAGE_KEY = 'genarrative:mini-program-auth-result';
const AUTH_ACTION_LOGIN = 'login';
const PAY_RESULT_RECHECK_DELAY_MS = 120;
+const WEB_VIEW_SHARE_TITLE = '陶泥儿';
+const WEB_VIEW_SHARE_PATH = '/pages/web-view/index';
+
+function showWebViewShareMenu() {
+ if (typeof wx.showShareMenu !== 'function') {
+ return;
+ }
+
+ wx.showShareMenu({
+ withShareTicket: true,
+ menus: ['shareAppMessage', 'shareTimeline'],
+ });
+}
+
+function buildWebViewShareAppMessage() {
+ return {
+ title: WEB_VIEW_SHARE_TITLE,
+ path: WEB_VIEW_SHARE_PATH,
+ };
+}
+
+function buildWebViewShareTimeline() {
+ return {
+ title: WEB_VIEW_SHARE_TITLE,
+ query: '',
+ };
+}
function isConfiguredEntryUrl(value) {
const trimmed = String(value || '').trim();
@@ -78,6 +107,68 @@ function parseBooleanQueryFlag(value) {
return value === true || value === '1' || value === 'true' || value === 'yes';
}
+function normalizeMiniProgramEnv(value) {
+ const normalized = String(value || '').trim().toLowerCase();
+ if (normalized === 'release') {
+ return 'release';
+ }
+ if (normalized === 'trial') {
+ return 'trial';
+ }
+ if (
+ normalized === 'develop' ||
+ normalized === 'development' ||
+ normalized === 'dev'
+ ) {
+ return 'dev';
+ }
+ return '';
+}
+
+function readMiniProgramEnvVersion() {
+ if (typeof wx.getAccountInfoSync !== 'function') {
+ return '';
+ }
+ try {
+ const accountInfo = wx.getAccountInfoSync();
+ return (
+ accountInfo &&
+ accountInfo.miniProgram &&
+ accountInfo.miniProgram.envVersion
+ );
+ } catch (error) {
+ console.warn('[web-view] read mini program env failed', error);
+ return '';
+ }
+}
+
+function resolveMiniProgramRuntimeConfig() {
+ const miniProgramEnv =
+ normalizeMiniProgramEnv(readMiniProgramEnvVersion()) ||
+ normalizeMiniProgramEnv(MINI_PROGRAM_ENV) ||
+ 'release';
+ const useReleaseChannel = miniProgramEnv === 'release';
+ const webViewEntryUrl = useReleaseChannel
+ ? WEB_VIEW_ENTRY_URL
+ : DEV_WEB_VIEW_ENTRY_URL || WEB_VIEW_ENTRY_URL;
+ const apiBaseUrl = useReleaseChannel
+ ? API_BASE_URL
+ : DEV_API_BASE_URL || API_BASE_URL;
+ const sourceQuery = {
+ ...WEB_VIEW_SOURCE_QUERY,
+ };
+ if (!useReleaseChannel) {
+ sourceQuery.miniProgramEnv = miniProgramEnv;
+ }
+
+ return {
+ apiBaseUrl,
+ miniProgramEnv,
+ sourceQuery,
+ webViewEntryUrl,
+ };
+}
+
function shouldStartAuthFromQuery(query) {
return String((query && query.authAction) || '').trim() === AUTH_ACTION_LOGIN;
}
@@ -87,12 +178,13 @@ function shouldReturnToPreviousPage(query) {
}
function resolveWebViewUrl(authResult) {
- const entryUrl = String(WEB_VIEW_ENTRY_URL || '').trim();
+ const runtimeConfig = resolveMiniProgramRuntimeConfig();
+ const entryUrl = String(runtimeConfig.webViewEntryUrl || '').trim();
if (!isConfiguredEntryUrl(entryUrl)) {
return '';
}
- const sourcedUrl = appendQuery(entryUrl, WEB_VIEW_SOURCE_QUERY);
+ const sourcedUrl = appendQuery(entryUrl, runtimeConfig.sourceQuery);
if (!authResult || !authResult.token) {
return sourcedUrl;
}
@@ -178,7 +270,8 @@ function wxLogin() {
function requestMiniProgramLogin(code) {
return new Promise((resolve, reject) => {
- const apiBaseUrl = trimTrailingSlash(API_BASE_URL);
+ const runtimeConfig = resolveMiniProgramRuntimeConfig();
+ const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl);
if (!isConfiguredApiBaseUrl(apiBaseUrl)) {
reject(new Error('请先配置 API_BASE_URL'));
return;
@@ -195,7 +288,7 @@ function requestMiniProgramLogin(code) {
'x-client-platform': resolveClientPlatform(),
'x-client-instance-id': getClientInstanceId(),
'x-mini-program-app-id': MINI_PROGRAM_APP_ID,
- 'x-mini-program-env': MINI_PROGRAM_ENV,
+ 'x-mini-program-env': runtimeConfig.miniProgramEnv,
},
success(response) {
if (response.statusCode >= 200 && response.statusCode < 300) {
@@ -219,7 +312,8 @@ function requestMiniProgramLogin(code) {
function requestMiniProgramBindPhone(authToken, wechatPhoneCode) {
return new Promise((resolve, reject) => {
- const apiBaseUrl = trimTrailingSlash(API_BASE_URL);
+ const runtimeConfig = resolveMiniProgramRuntimeConfig();
+ const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl);
if (!isConfiguredApiBaseUrl(apiBaseUrl)) {
reject(new Error('请先配置 API_BASE_URL'));
return;
@@ -237,7 +331,7 @@ function requestMiniProgramBindPhone(authToken, wechatPhoneCode) {
'x-client-platform': resolveClientPlatform(),
'x-client-instance-id': getClientInstanceId(),
'x-mini-program-app-id': MINI_PROGRAM_APP_ID,
- 'x-mini-program-env': MINI_PROGRAM_ENV,
+ 'x-mini-program-env': runtimeConfig.miniProgramEnv,
},
success(response) {
if (response.statusCode >= 200 && response.statusCode < 300) {
@@ -271,18 +365,6 @@ async function resolveAuthResult() {
};
}
-async function refreshMiniProgramSessionSilently() {
- if (!isConfiguredApiBaseUrl(API_BASE_URL)) {
- return null;
- }
- try {
- return await resolveAuthResult();
- } catch (error) {
- console.warn('[web-view] silent mini program login refresh failed', error);
- return null;
- }
-}
-
Page({
data: {
authResult: null,
@@ -296,8 +378,10 @@ Page({
async onLoad(query = {}) {
this._lastLaunchQuery = query;
+ showWebViewShareMenu();
+ const runtimeConfig = resolveMiniProgramRuntimeConfig();
// 中文注释:web-view 只能打开已配置业务域名;未配置时展示本地提示,避免空白页误判。
- if (!isConfiguredEntryUrl(WEB_VIEW_ENTRY_URL)) {
+ if (!isConfiguredEntryUrl(runtimeConfig.webViewEntryUrl)) {
this.setData({
errorMessage: '请先在 miniprogram/config.js 填写 WEB_VIEW_ENTRY_URL。',
loading: false,
@@ -309,19 +393,18 @@ Page({
const forcedPhoneBinding = parseBooleanQueryFlag(query.phoneBindingRequired);
const returnToPreviousPage = shouldReturnToPreviousPage(query);
if (!shouldStartAuthFromQuery(query) && !forcedPhoneBinding) {
- const authResult = await refreshMiniProgramSessionSilently();
this.setData({
- authResult,
+ authResult: null,
errorMessage: '',
loading: false,
phoneBindingRequired: false,
returnToPreviousPage: false,
- webViewUrl: resolveWebViewUrl(authResult),
+ webViewUrl: resolveWebViewUrl(null),
});
return;
}
- if (!isConfiguredApiBaseUrl(API_BASE_URL)) {
+ if (!isConfiguredApiBaseUrl(runtimeConfig.apiBaseUrl)) {
this.setData({
errorMessage: '请先在 miniprogram/config.js 填写 API_BASE_URL。',
loading: false,
@@ -490,4 +573,12 @@ Page({
// 中文注释:支付由独立 native 页面承接,web-view 消息只保留调试输出。
console.info('[web-view] message', event.detail);
},
+
+ onShareAppMessage() {
+ return buildWebViewShareAppMessage();
+ },
+
+ onShareTimeline() {
+ return buildWebViewShareTimeline();
+ },
});
diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts
index 4765cd9f..2a2aa6c6 100644
--- a/packages/shared/src/contracts/auth.ts
+++ b/packages/shared/src/contracts/auth.ts
@@ -6,10 +6,13 @@ export type AuthUser = {
publicUserCode: string;
displayName: string;
avatarUrl: string | null;
+ phoneNumber?: string | null;
phoneNumberMasked: string | null;
loginMethod: AuthLoginMethod;
bindingStatus: AuthBindingStatus;
wechatBound: boolean;
+ wechatDisplayName?: string | null;
+ wechatAccount?: string | null;
};
export type PublicUserSummary = {
diff --git a/packages/shared/src/contracts/jumpHop.ts b/packages/shared/src/contracts/jumpHop.ts
index 19fafe66..a5b6d9e9 100644
--- a/packages/shared/src/contracts/jumpHop.ts
+++ b/packages/shared/src/contracts/jumpHop.ts
@@ -24,7 +24,6 @@ export type JumpHopTileType =
export type JumpHopActionType =
| 'compile-draft'
- | 'regenerate-character'
| 'regenerate-tiles'
| 'update-work-meta'
| 'update-difficulty';
@@ -35,19 +34,21 @@ export type JumpHopJumpResult = 'miss' | 'hit' | 'perfect' | 'finish';
export interface JumpHopWorkspaceCreateRequest {
templateId: string;
- workTitle: string;
- workDescription: string;
- themeTags: string[];
- difficulty: JumpHopDifficulty;
- stylePreset: JumpHopStylePreset;
- characterPrompt: string;
- tilePrompt: string;
+ themeText: string;
+ workTitle?: string;
+ workDescription?: string;
+ themeTags?: string[];
+ difficulty?: JumpHopDifficulty;
+ stylePreset?: JumpHopStylePreset;
+ characterPrompt?: string;
+ tilePrompt?: string;
endMoodPrompt?: string | null;
}
export interface JumpHopActionRequest {
actionType: JumpHopActionType;
profileId?: string | null;
+ themeText?: string | null;
workTitle?: string | null;
workDescription?: string | null;
themeTags?: string[] | null;
@@ -60,6 +61,7 @@ export interface JumpHopActionRequest {
tileAtlasAsset?: JumpHopCharacterAsset | null;
tileAssets?: JumpHopTileAsset[] | null;
coverComposite?: string | null;
+ backButtonAsset?: JumpHopCharacterAsset | null;
}
export interface JumpHopCharacterAsset {
@@ -73,12 +75,23 @@ export interface JumpHopCharacterAsset {
height: number;
}
+export interface JumpHopDefaultCharacter {
+ characterId: string;
+ displayName: string;
+ modelKind: 'builtin-three';
+ bodyColor: string;
+ accentColor: string;
+}
+
export interface JumpHopTileAsset {
tileType: JumpHopTileType;
+ tileId?: string;
imageSrc: string;
imageObjectKey: string;
assetObjectId: string;
sourceAtlasCell: string;
+ atlasRow?: number;
+ atlasCol?: number;
visualWidth: number;
visualHeight: number;
topSurfaceRadius: number;
@@ -126,11 +139,13 @@ export interface JumpHopDraftResponse {
templateId: string;
templateName: string;
profileId: string | null;
+ themeText: string;
workTitle: string;
workDescription: string;
themeTags: string[];
difficulty: JumpHopDifficulty;
stylePreset: JumpHopStylePreset;
+ defaultCharacter?: JumpHopDefaultCharacter | null;
characterPrompt: string;
tilePrompt: string;
endMoodPrompt: string | null;
@@ -139,6 +154,7 @@ export interface JumpHopDraftResponse {
tileAssets: JumpHopTileAsset[];
path: JumpHopPath | null;
coverComposite: string | null;
+ backButtonAsset?: JumpHopCharacterAsset | null;
generationStatus: JumpHopGenerationStatus;
}
@@ -167,6 +183,7 @@ export interface JumpHopWorkSummaryResponse {
profileId: string;
ownerUserId: string;
sourceSessionId: string | null;
+ themeText: string;
workTitle: string;
workDescription: string;
themeTags: string[];
@@ -185,9 +202,11 @@ export interface JumpHopWorkProfileResponse {
summary: JumpHopWorkSummaryResponse;
draft: JumpHopDraftResponse;
path: JumpHopPath;
+ defaultCharacter?: JumpHopDefaultCharacter | null;
characterAsset: JumpHopCharacterAsset;
tileAtlasAsset: JumpHopCharacterAsset;
tileAssets: JumpHopTileAsset[];
+ backButtonAsset?: JumpHopCharacterAsset | null;
}
export interface JumpHopWorksResponse {
@@ -208,6 +227,7 @@ export interface JumpHopGalleryCardResponse {
profileId: string;
ownerUserId: string;
authorDisplayName: string;
+ themeText: string;
workTitle: string;
workDescription: string;
coverImageSrc: string | null;
@@ -237,6 +257,8 @@ export interface JumpHopRuntimeRunSnapshotResponse {
ownerUserId: string;
status: JumpHopRunStatus;
currentPlatformIndex: number;
+ successfulJumpCount: number;
+ durationMs: number;
score: number;
combo: number;
path: JumpHopPath;
@@ -251,10 +273,13 @@ export interface JumpHopRunResponse {
export interface JumpHopStartRunRequest {
profileId: string;
+ runtimeMode?: 'draft' | 'published';
}
export interface JumpHopJumpRequest {
- chargeMs: number;
+ dragDistance: number;
+ dragVectorX?: number;
+ dragVectorY?: number;
clientEventId: string;
}
@@ -265,3 +290,17 @@ export interface JumpHopRestartRunRequest {
export interface JumpHopJumpResponse {
run: JumpHopRuntimeRunSnapshotResponse;
}
+
+export interface JumpHopLeaderboardEntry {
+ rank: number;
+ playerId: string;
+ successfulJumpCount: number;
+ durationMs: number;
+ updatedAt: string;
+}
+
+export interface JumpHopLeaderboardResponse {
+ profileId: string;
+ items: JumpHopLeaderboardEntry[];
+ viewerBest?: JumpHopLeaderboardEntry | null;
+}
diff --git a/public/branding/jump-hop-taonier-character.png b/public/branding/jump-hop-taonier-character.png
new file mode 100644
index 00000000..0dcbaf41
Binary files /dev/null and b/public/branding/jump-hop-taonier-character.png differ
diff --git a/public/creation-type-references/jump-hop.webp b/public/creation-type-references/jump-hop.webp
new file mode 100644
index 00000000..b4e6c7b2
Binary files /dev/null and b/public/creation-type-references/jump-hop.webp differ
diff --git a/scripts/deploy/production-api-deploy.sh b/scripts/deploy/production-api-deploy.sh
index 9373b375..0f861923 100644
--- a/scripts/deploy/production-api-deploy.sh
+++ b/scripts/deploy/production-api-deploy.sh
@@ -5,10 +5,10 @@ set -euo pipefail
usage() {
cat <<'EOF'
用法:
- ./scripts/deploy/production-api-deploy.sh --source-dir build/ [--version ] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--health-url http://127.0.0.1:8082/healthz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101]
+ ./scripts/deploy/production-api-deploy.sh --source-dir build/ [--version ] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101]
说明:
- 进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 healthz 检查。
+ 进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 readiness 检查。
若传入 --database,会在重启前把 GENARRATIVE_SPACETIME_DATABASE 写入 api-server 环境文件,避免服务继续读取旧库。
失败时保留维护模式。
EOF
@@ -209,6 +209,7 @@ ensure_runtime_env_and_dirs() {
# 旧生产环境文件会被 server-provision 保留,不一定包含新增的运行态写入路径。
# 发布前只补缺省值,不覆盖线上已经定制过的目录或开关。
+ ensure_env_value "${api_env_file}" "GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS" "5000"
ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_ENABLED" "true"
ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_DIR" "/var/lib/genarrative/tracking-outbox"
ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE" "500"
@@ -228,7 +229,7 @@ VERSION=""
RELEASE_ROOT="/opt/genarrative/releases"
CURRENT_LINK="/opt/genarrative/current"
SERVICE_NAME="genarrative-api.service"
-HEALTH_URL="http://127.0.0.1:8082/healthz"
+HEALTH_URL="http://127.0.0.1:8082/readyz"
API_ENV_FILE="/etc/genarrative/api-server.env"
DATABASE=""
SPACETIME_SERVER_URL=""
@@ -362,7 +363,7 @@ ln -sfn "${RELEASE_DIR}" "${CURRENT_LINK}"
echo "[production-api-deploy] 重启服务: ${SERVICE_NAME}"
systemctl restart "${SERVICE_NAME}"
-echo "[production-api-deploy] 等待 healthz: ${HEALTH_URL}"
+echo "[production-api-deploy] 等待 readiness: ${HEALTH_URL}"
for _ in {1..30}; do
if curl -fsS "${HEALTH_URL}" >/dev/null; then
"${SCRIPT_DIR}/maintenance-off.sh"
@@ -373,5 +374,5 @@ for _ in {1..30}; do
sleep 2
done
-echo "[production-api-deploy] healthz 检查超时: ${HEALTH_URL}" >&2
+echo "[production-api-deploy] readiness 检查超时: ${HEALTH_URL}" >&2
exit 1
diff --git a/scripts/jenkins-prepare-cargo-env.sh b/scripts/jenkins-prepare-cargo-env.sh
index 3a21cbf9..1654c28f 100755
--- a/scripts/jenkins-prepare-cargo-env.sh
+++ b/scripts/jenkins-prepare-cargo-env.sh
@@ -28,7 +28,7 @@ if [[ -z "${RUSTUP_HOME:-}" && -n "${ORIGINAL_HOME}" && -d "${ORIGINAL_HOME}/.ru
fi
# HOME 会在下面切到组件级缓存目录,因此这里先把真实用户的 Rust 工具链目录补进 PATH。
-# Server-Provision 通过 cargo install 安装的 sccache 通常会落在 /root/.cargo/bin。
+# Jenkins 构建节点预装的 Rust 工具和 sccache 通常会落在 /root/.cargo/bin。
for tool_dir in "${ORIGINAL_HOME}/.cargo/bin" /root/.cargo/bin /usr/local/cargo/bin; do
if [[ -d "${tool_dir}" && ":${PATH}:" != *":${tool_dir}:"* ]]; then
export PATH="${tool_dir}:${PATH}"
diff --git a/scripts/jenkins-server-provision.sh b/scripts/jenkins-server-provision.sh
index e5d4e943..5d3535ed 100755
--- a/scripts/jenkins-server-provision.sh
+++ b/scripts/jenkins-server-provision.sh
@@ -4,6 +4,10 @@ set -euo pipefail
PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}"
SPACETIME_BIN_SOURCE="${SPACETIME_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/spacetime/spacetime}"
OTELCOL_BIN_SOURCE="${OTELCOL_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/otelcol-contrib}"
+GENARRATIVE_OPENSSL_VERSION="${GENARRATIVE_OPENSSL_VERSION:-3.2.0}"
+GENARRATIVE_OPENSSL_PREFIX="${GENARRATIVE_OPENSSL_PREFIX:-/opt/genarrative/openssl-3.2.0}"
+GENARRATIVE_OPENSSL_SOURCE_URL="${GENARRATIVE_OPENSSL_SOURCE_URL:-https://github.com/openssl/openssl/releases/download/openssl-${GENARRATIVE_OPENSSL_VERSION}/openssl-${GENARRATIVE_OPENSSL_VERSION}.tar.gz}"
+GENARRATIVE_OPENSSL_SOURCE_SHA256="${GENARRATIVE_OPENSSL_SOURCE_SHA256:-14c826f07c7e433706fb5c69fa9e25dab95684844b4c962a2cf1bf183eb4690e}"
require_non_root_relative_path() {
local label="$1"
@@ -27,6 +31,14 @@ require_path() {
fi
}
+require_cmd() {
+ local name="$1"
+ if ! command -v "${name}" >/dev/null 2>&1; then
+ echo "[server-provision] 缺少命令: ${name}" >&2
+ exit 1
+ fi
+}
+
normalize_server_aliases() {
printf "%s" "${SERVER_ALIASES:-}" | tr ',' ' ' | xargs
}
@@ -56,6 +68,18 @@ run_cmd() {
fi
}
+require_root_for_real_provision() {
+ if [[ "${DRY_RUN}" == "true" ]]; then
+ return
+ fi
+
+ if [[ "$(id -u)" != "0" ]]; then
+ echo "[server-provision] 非 dry-run 会安装系统包、写入 systemd/Nginx 和创建系统用户,必须在 root agent 上执行。" >&2
+ echo "[server-provision] 当前用户: $(id -un) uid=$(id -u)。请确认 DEPLOY_TARGET=${DEPLOY_TARGET:-} 对应的目标服务器 agent 以 root 运行,或保持 DRY_RUN=true。" >&2
+ exit 1
+ fi
+}
+
install_file() {
local source="$1"
local target="$2"
@@ -66,21 +90,6 @@ install_file() {
fi
}
-install_build_dependencies() {
- echo "[server-provision] 安装 Linux 构建依赖: clang, lld, pkg-config, OpenSSL headers"
- if command -v apt-get >/dev/null 2>&1; then
- run_cmd apt-get update
- run_cmd apt-get install -y clang lld pkg-config libssl-dev ca-certificates
- elif command -v dnf >/dev/null 2>&1; then
- run_cmd dnf install -y clang lld pkgconf-pkg-config openssl-devel ca-certificates
- elif command -v yum >/dev/null 2>&1; then
- run_cmd yum install -y clang lld pkgconf-pkg-config openssl-devel ca-certificates
- else
- echo "[server-provision] 未找到 apt-get/dnf/yum,无法自动安装 clang/lld。请手动安装后重跑构建。" >&2
- exit 1
- fi
-}
-
install_nginx_brotli_modules() {
echo "[server-provision] 安装 Nginx Brotli 动态模块依赖"
if command -v apt-get >/dev/null 2>&1; then
@@ -90,39 +99,111 @@ install_nginx_brotli_modules() {
fi
}
-install_sccache() {
- for tool_dir in "${HOME:-}/.cargo/bin" /root/.cargo/bin /usr/local/cargo/bin; do
- if [[ -d "${tool_dir}" && ":${PATH}:" != *":${tool_dir}:"* ]]; then
- export PATH="${tool_dir}:${PATH}"
+download_file() {
+ local url="$1"
+ local output="$2"
+
+ if command -v curl >/dev/null 2>&1; then
+ curl -fsSL --retry 3 --retry-delay 2 "${url}" -o "${output}"
+ elif command -v wget >/dev/null 2>&1; then
+ wget -O "${output}" "${url}"
+ else
+ echo "[server-provision] 需要 curl 或 wget 下载: ${url}" >&2
+ exit 1
+ fi
+}
+
+openssl_lib_dir_candidates() {
+ printf "%s\n" \
+ "${GENARRATIVE_OPENSSL_PREFIX}/lib64" \
+ "${GENARRATIVE_OPENSSL_PREFIX}/lib"
+}
+
+find_genarrative_openssl_lib_dir() {
+ local lib_dir
+ while IFS= read -r lib_dir; do
+ if [[ -f "${lib_dir}/libssl.so.3" && -f "${lib_dir}/libcrypto.so.3" ]]; then
+ printf "%s" "${lib_dir}"
+ return 0
fi
- done
+ done < <(openssl_lib_dir_candidates)
+ return 1
+}
- if command -v sccache >/dev/null 2>&1; then
- echo "[server-provision] sccache 已存在: $(command -v sccache)"
- return
+genarrative_openssl_has_required_symbol() {
+ local lib_dir
+ lib_dir="$(find_genarrative_openssl_lib_dir 2>/dev/null || true)"
+ if [[ -z "${lib_dir}" ]]; then
+ return 1
fi
+ grep -a -q "OPENSSL_${GENARRATIVE_OPENSSL_VERSION}" "${lib_dir}/libssl.so.3"
+}
- if [[ -x /root/.cargo/bin/sccache ]]; then
- echo "[server-provision] sccache 已存在: /root/.cargo/bin/sccache"
- return
+verify_genarrative_openssl_install() {
+ local lib_dir
+ lib_dir="$(find_genarrative_openssl_lib_dir 2>/dev/null || true)"
+ if [[ -z "${lib_dir}" ]]; then
+ echo "[server-provision] OpenSSL ${GENARRATIVE_OPENSSL_VERSION} 安装后缺少 libssl.so.3/libcrypto.so.3: ${GENARRATIVE_OPENSSL_PREFIX}" >&2
+ exit 1
fi
+ if ! grep -a -q "OPENSSL_${GENARRATIVE_OPENSSL_VERSION}" "${lib_dir}/libssl.so.3"; then
+ echo "[server-provision] OpenSSL 动态库缺少 OPENSSL_${GENARRATIVE_OPENSSL_VERSION} 符号: ${lib_dir}/libssl.so.3" >&2
+ exit 1
+ fi
+ if ! env "LD_LIBRARY_PATH=${lib_dir}" "${GENARRATIVE_OPENSSL_PREFIX}/bin/openssl" version | grep -q "OpenSSL ${GENARRATIVE_OPENSSL_VERSION}"; then
+ echo "[server-provision] OpenSSL ${GENARRATIVE_OPENSSL_VERSION} 安装后命令验证失败: ${GENARRATIVE_OPENSSL_PREFIX}/bin/openssl" >&2
+ exit 1
+ fi
+ echo "[server-provision] OpenSSL ${GENARRATIVE_OPENSSL_VERSION} 已就绪: ${lib_dir}"
+}
- echo "[server-provision] 未找到 sccache,准备通过 cargo install sccache 安装。"
+install_genarrative_openssl_runtime() {
+ local tmp_dir archive source_dir jobs lib_dir
+
+ echo "[server-provision] 检查 api-server/libcurl 运行时 OpenSSL ${GENARRATIVE_OPENSSL_VERSION}"
if [[ "${DRY_RUN}" == "true" ]]; then
- echo "+ cargo install sccache --locked"
+ echo "+ install OpenSSL ${GENARRATIVE_OPENSSL_VERSION} into ${GENARRATIVE_OPENSSL_PREFIX}"
+ echo "+ verify OPENSSL_${GENARRATIVE_OPENSSL_VERSION} symbol for api-server/libcurl"
return
fi
- if ! command -v cargo >/dev/null 2>&1; then
- echo "[server-provision] 未找到 cargo,无法自动安装 sccache。请先安装 Rust 工具链后重跑 Server-Provision。" >&2
- exit 1
+ if genarrative_openssl_has_required_symbol; then
+ verify_genarrative_openssl_install
+ return
fi
- cargo install sccache --locked
- if ! command -v sccache >/dev/null 2>&1 && [[ ! -x /root/.cargo/bin/sccache ]]; then
- echo "[server-provision] sccache 安装后仍不可用,请检查 cargo bin 目录是否在 PATH 中。" >&2
+ if command -v apt-get >/dev/null 2>&1; then
+ run_cmd apt-get install -y build-essential ca-certificates curl perl tar
+ else
+ echo "[server-provision] 当前系统未使用 apt,无法自动构建 OpenSSL ${GENARRATIVE_OPENSSL_VERSION};请手动安装到 ${GENARRATIVE_OPENSSL_PREFIX}。" >&2
exit 1
fi
+ require_cmd sha256sum
+ require_cmd tar
+
+ tmp_dir="$(mktemp -d)"
+ archive="${tmp_dir}/openssl-${GENARRATIVE_OPENSSL_VERSION}.tar.gz"
+ echo "[server-provision] 下载 OpenSSL ${GENARRATIVE_OPENSSL_VERSION}: ${GENARRATIVE_OPENSSL_SOURCE_URL}"
+ download_file "${GENARRATIVE_OPENSSL_SOURCE_URL}" "${archive}"
+ printf "%s %s\n" "${GENARRATIVE_OPENSSL_SOURCE_SHA256}" "${archive}" | sha256sum -c -
+
+ tar -xzf "${archive}" -C "${tmp_dir}"
+ source_dir="${tmp_dir}/openssl-${GENARRATIVE_OPENSSL_VERSION}"
+ jobs="$(nproc 2>/dev/null || echo 2)"
+ (
+ cd "${source_dir}"
+ ./config --prefix="${GENARRATIVE_OPENSSL_PREFIX}" --openssldir="${GENARRATIVE_OPENSSL_PREFIX}/ssl" shared
+ make -j "${jobs}"
+ make install_sw
+ )
+ rm -rf "${tmp_dir}"
+
+ lib_dir="$(find_genarrative_openssl_lib_dir 2>/dev/null || true)"
+ if [[ -n "${lib_dir}" ]]; then
+ chmod 0755 "${GENARRATIVE_OPENSSL_PREFIX}" "${lib_dir}" || true
+ chmod 0644 "${lib_dir}/libssl.so.3" "${lib_dir}/libcrypto.so.3" || true
+ fi
+ verify_genarrative_openssl_install
}
sync_otelcol_install() {
@@ -142,7 +223,7 @@ sync_otelcol_install() {
if [[ ! -x "${resolved_source}" ]]; then
echo "[server-provision] otelcol-contrib 不存在或不可执行: ${source_bin}" >&2
- echo "[server-provision] 请先在构建机准备好 otelcol-contrib ${version},再通过 provision-tools 上传到目标机。" >&2
+ echo "[server-provision] 请确认 Prepare Provision Tools 已在目标 agent 生成 otelcol-contrib ${version}: ${source_bin}" >&2
exit 1
fi
@@ -671,9 +752,8 @@ require_non_root_relative_path "PROVISION_TOOLS_DIR" "${PROVISION_TOOLS_DIR}"
echo "[server-provision] target=${DEPLOY_TARGET}, dry_run=${DRY_RUN}, nginx_config_mode=${NGINX_CONFIG_MODE}, source_commit=$(cat .jenkins-source-commit)"
run_cmd id
-install_build_dependencies
+require_root_for_real_provision
install_nginx_brotli_modules
-install_sccache
run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /var/lib/genarrative/maintenance /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox /var/lib/genarrative/database-backups
if ! id spacetimedb >/dev/null 2>&1; then
@@ -690,6 +770,7 @@ fi
run_cmd chown -R spacetimedb:spacetimedb "${SPACETIME_ROOT}"
run_cmd chown -R genarrative:genarrative /opt/genarrative /var/lib/genarrative /srv/genarrative
+install_genarrative_openssl_runtime
if [[ ! -x "${SPACETIME_BIN_SOURCE}" ]]; then
echo "[server-provision] spacetime CLI 不存在或不可执行: ${SPACETIME_BIN_SOURCE}" >&2
diff --git a/scripts/miniprogram-web-view-auth.test.ts b/scripts/miniprogram-web-view-auth.test.ts
index 388677e0..ccf24a85 100644
--- a/scripts/miniprogram-web-view-auth.test.ts
+++ b/scripts/miniprogram-web-view-auth.test.ts
@@ -17,18 +17,24 @@ type MiniProgramPage = {
data: Record;
setData: (patch: Record) => void;
onLoad: (query?: Record) => Promise;
+ onShareAppMessage: () => Record;
+ onShareTimeline: () => Record;
onShow: () => void;
consumePayResult: () => void;
};
function createWxMock() {
return {
+ getAccountInfoSync: vi.fn(() => ({
+ miniProgram: { envVersion: 'release' },
+ })),
getStorageSync: vi.fn(() => ''),
getSystemInfoSync: vi.fn(() => ({ platform: 'ios' })),
login: vi.fn(),
navigateBack: vi.fn(),
removeStorageSync: vi.fn(),
request: vi.fn(),
+ showShareMenu: vi.fn(),
setStorageSync: vi.fn(),
};
}
@@ -54,6 +60,8 @@ function loadWebViewPage(
if (requestPath === '../../config') {
return {
API_BASE_URL: 'https://www.genarrative.world/',
+ DEV_API_BASE_URL: 'https://dev.genarrative.world/',
+ DEV_WEB_VIEW_ENTRY_URL: 'https://dev.genarrative.world/',
MINI_PROGRAM_APP_ID: 'wx-test-app',
MINI_PROGRAM_ENV: 'release',
WEB_VIEW_ENTRY_URL: 'https://www.genarrative.world/',
@@ -91,7 +99,7 @@ describe('mini-program web-view auth page', () => {
vi.clearAllMocks();
});
- test('默认进入时刷新微信小程序登录态后打开 web-view', async () => {
+ test('默认进入时不预登录,直接打开未登录 web-view', async () => {
const wxMock = createWxMock();
wxMock.login.mockImplementation(({ success }) => {
success({ code: 'wx-login-code' });
@@ -109,19 +117,58 @@ describe('mini-program web-view auth page', () => {
await page.onLoad({});
- expect(wxMock.login).toHaveBeenCalledTimes(1);
- expect(wxMock.request).toHaveBeenCalledWith(
- expect.objectContaining({
- url: 'https://www.genarrative.world/api/auth/wechat/miniprogram-login',
- method: 'POST',
- data: { code: 'wx-login-code' },
- }),
- );
+ expect(wxMock.login).not.toHaveBeenCalled();
+ expect(wxMock.request).not.toHaveBeenCalled();
expect(page.data.loading).toBe(false);
expect(page.data.phoneBindingRequired).toBe(false);
expect(page.data.webViewUrl).toBe(
- 'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program#auth_provider=wechat&auth_token=jwt-active-wechat&auth_binding_status=active',
+ 'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program',
);
+ expect(wxMock.showShareMenu).toHaveBeenCalledWith({
+ withShareTicket: true,
+ menus: ['shareAppMessage', 'shareTimeline'],
+ });
+ });
+
+ test('默认进入时即便微信新身份待绑手机号,也不弹出绑定手机号页', async () => {
+ const wxMock = createWxMock();
+ wxMock.login.mockImplementation(({ success }) => {
+ success({ code: 'wx-login-code' });
+ });
+ wxMock.request.mockImplementation(({ success }) => {
+ success({
+ statusCode: 200,
+ data: {
+ token: 'jwt-pending-wechat',
+ bindingStatus: 'pending_bind_phone',
+ },
+ });
+ });
+ const page = loadWebViewPage(wxMock);
+
+ await page.onLoad({});
+
+ expect(wxMock.login).not.toHaveBeenCalled();
+ expect(wxMock.request).not.toHaveBeenCalled();
+ expect(page.data.loading).toBe(false);
+ expect(page.data.phoneBindingRequired).toBe(false);
+ expect(page.data.webViewUrl).toBe(
+ 'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program',
+ );
+ });
+
+ test('web-view 页面分享好友和朋友圈都回到小程序 web-view 入口', () => {
+ const wxMock = createWxMock();
+ const page = loadWebViewPage(wxMock);
+
+ expect(page.onShareAppMessage()).toEqual({
+ title: '陶泥儿',
+ path: '/pages/web-view/index',
+ });
+ expect(page.onShareTimeline()).toEqual({
+ title: '陶泥儿',
+ query: '',
+ });
});
test('默认匿名进入 web-view 仍不依赖 API_BASE_URL 配置', async () => {
@@ -140,6 +187,51 @@ describe('mini-program web-view auth page', () => {
);
});
+ test('体验版自动切到 dev 子域名并透传 trial 环境', async () => {
+ const wxMock = createWxMock();
+ wxMock.getAccountInfoSync.mockReturnValue({
+ miniProgram: { envVersion: 'trial' },
+ });
+ const page = loadWebViewPage(wxMock);
+
+ await page.onLoad({});
+
+ expect(page.data.webViewUrl).toBe(
+ 'https://dev.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program&miniProgramEnv=trial',
+ );
+ });
+
+ test('开发版自动切到 dev 子域名并把 develop 规整为 dev', async () => {
+ const wxMock = createWxMock();
+ wxMock.getAccountInfoSync.mockReturnValue({
+ miniProgram: { envVersion: 'develop' },
+ });
+ wxMock.login.mockImplementation(({ success }) => {
+ success({ code: 'wx-login-code' });
+ });
+ wxMock.request.mockImplementation(({ success }) => {
+ success({
+ statusCode: 200,
+ data: {
+ token: 'jwt-pending-wechat',
+ bindingStatus: 'pending_bind_phone',
+ },
+ });
+ });
+ const page = loadWebViewPage(wxMock);
+
+ await page.onLoad({ authAction: 'login', returnTo: 'previous' });
+
+ expect(wxMock.request).toHaveBeenCalledWith(
+ expect.objectContaining({
+ url: 'https://dev.genarrative.world/api/auth/wechat/miniprogram-login',
+ header: expect.objectContaining({
+ 'x-mini-program-env': 'dev',
+ }),
+ }),
+ );
+ });
+
test('onShow 二次检查支付结果并写回 web-view hash', () => {
const wxMock = createWxMock();
wxMock.getStorageSync.mockImplementation((key) =>
diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock
index 7e35514f..14a00146 100644
--- a/server-rs/Cargo.lock
+++ b/server-rs/Cargo.lock
@@ -637,6 +637,36 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "curl"
+version = "0.4.49"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79fc3b6dd0b87ba36e565715bf9a2ced221311db47bd18011676f24a6066edbc"
+dependencies = [
+ "curl-sys",
+ "libc",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "socket2 0.6.3",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "curl-sys"
+version = "0.4.88+curl-8.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "644816de6547255eff4e491a1dda1c19b7237f00b62a61e6e64859ce4f2906d0"
+dependencies = [
+ "cc",
+ "libc",
+ "libz-sys",
+ "openssl-sys",
+ "pkg-config",
+ "vcpkg",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "darling"
version = "0.23.0"
@@ -1311,7 +1341,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
- "socket2 0.5.10",
+ "socket2 0.6.3",
"tokio",
"tower-service",
"tracing",
@@ -1669,6 +1699,18 @@ dependencies = [
"glob",
]
+[[package]]
+name = "libz-sys"
+version = "1.1.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -2415,6 +2457,7 @@ name = "platform-image"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
+ "curl",
"image",
"platform-oss",
"reqwest 0.12.28",
@@ -2446,6 +2489,7 @@ dependencies = [
"sha2",
"time",
"tokio",
+ "tracing",
]
[[package]]
@@ -2615,7 +2659,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
- "socket2 0.5.10",
+ "socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -2652,7 +2696,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
- "socket2 0.5.10",
+ "socket2 0.6.3",
"tracing",
"windows-sys 0.52.0",
]
@@ -4582,7 +4626,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.48.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4662,6 +4706,15 @@ dependencies = [
"windows-targets 0.52.6",
]
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
[[package]]
name = "windows-sys"
version = "0.61.2"
diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml
index 5da52109..82abcab1 100644
--- a/server-rs/Cargo.toml
+++ b/server-rs/Cargo.toml
@@ -98,6 +98,7 @@ axum = "0.8"
base64 = "0.22"
cbc = { version = "0.1", features = ["alloc"] }
bytes = "1"
+curl = "0.4"
dotenvy = "0.15"
flate2 = "1"
futures-util = "0.3"
diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml
index bad600c3..0374defc 100644
--- a/server-rs/crates/api-server/Cargo.toml
+++ b/server-rs/crates/api-server/Cargo.toml
@@ -55,7 +55,7 @@ shared-kernel = { workspace = true }
shared-logging = { workspace = true }
socket2 = { workspace = true }
spacetime-client = { workspace = true }
-tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "fs", "io-util"] }
+tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "fs", "io-util", "signal"] }
tokio-stream = { workspace = true }
futures-util = { workspace = true }
time = { workspace = true, features = ["formatting"] }
diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs
index 3b5bfbdb..248c09d1 100644
--- a/server-rs/crates/api-server/src/app.rs
+++ b/server-rs/crates/api-server/src/app.rs
@@ -878,6 +878,46 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn readyz_reports_readiness_and_draining_state() {
+ let state = AppState::new(AppConfig::default()).expect("state should build");
+ let app = build_router(state.clone());
+
+ let ready_response = app
+ .clone()
+ .oneshot(
+ Request::builder()
+ .uri("/readyz")
+ .header("x-request-id", "req-ready")
+ .body(Body::empty())
+ .expect("readyz request should build"),
+ )
+ .await
+ .expect("readyz request should succeed");
+ assert_eq!(ready_response.status(), StatusCode::OK);
+ let ready_body = read_json_response(ready_response).await;
+ assert_eq!(ready_body["ok"], Value::Bool(true));
+ assert_eq!(ready_body["ready"], Value::Bool(true));
+
+ state.mark_not_ready();
+ let draining_response = app
+ .oneshot(
+ Request::builder()
+ .uri("/readyz")
+ .header("x-request-id", "req-draining")
+ .body(Body::empty())
+ .expect("readyz request should build"),
+ )
+ .await
+ .expect("readyz request should succeed");
+ assert_eq!(draining_response.status(), StatusCode::SERVICE_UNAVAILABLE);
+ let draining_body = read_json_response(draining_response).await;
+ assert_eq!(
+ draining_body["error"]["details"]["reason"],
+ "api_server_draining"
+ );
+ }
+
#[tokio::test]
async fn creative_agent_draft_edit_rejects_unconfirmed_template_session() {
let app = build_internal_creative_agent_app();
@@ -2658,6 +2698,18 @@ mod tests {
bind_payload["user"]["phoneNumberMasked"],
Value::String("138****8000".to_string())
);
+ assert_eq!(
+ bind_payload["user"]["phoneNumber"],
+ Value::String("+8613800138000".to_string())
+ );
+ assert_eq!(
+ bind_payload["user"]["wechatAccount"],
+ Value::String("wx-mini-code-bind-001".to_string())
+ );
+ assert_eq!(
+ bind_payload["user"]["wechatDisplayName"],
+ Value::String("微信旅人".to_string())
+ );
assert!(
bind_payload["token"]
.as_str()
@@ -3345,6 +3397,10 @@ mod tests {
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["user"]["id"], Value::String(seed_user.id));
+ assert_eq!(
+ payload["user"]["phoneNumber"],
+ Value::String("+8613800138016".to_string())
+ );
assert_eq!(
payload["availableLoginMethods"],
serde_json::json!(["phone", "password", "wechat"])
diff --git a/server-rs/crates/api-server/src/auth_payload.rs b/server-rs/crates/api-server/src/auth_payload.rs
index c4cc8673..4c2a6242 100644
--- a/server-rs/crates/api-server/src/auth_payload.rs
+++ b/server-rs/crates/api-server/src/auth_payload.rs
@@ -7,10 +7,13 @@ pub fn map_auth_user_payload(user: AuthUser) -> AuthUserPayload {
public_user_code: user.public_user_code,
display_name: user.display_name,
avatar_url: user.avatar_url,
+ phone_number: user.phone_number,
phone_number_masked: user.phone_number_masked,
login_method: user.login_method.as_str().to_string(),
binding_status: user.binding_status.as_str().to_string(),
wechat_bound: user.wechat_bound,
+ wechat_display_name: user.wechat_display_name,
+ wechat_account: user.wechat_account,
}
}
diff --git a/server-rs/crates/api-server/src/backpressure.rs b/server-rs/crates/api-server/src/backpressure.rs
index 3fc2b689..1f6baf7a 100644
--- a/server-rs/crates/api-server/src/backpressure.rs
+++ b/server-rs/crates/api-server/src/backpressure.rs
@@ -102,7 +102,7 @@ fn reject_overloaded_request(request: &Request) -> Response {
}
fn should_bypass_backpressure(request: &Request) -> bool {
- request.uri().path() == "/healthz"
+ matches!(request.uri().path(), "/healthz" | "/readyz")
}
fn classify_request_permit_pool(path: &str) -> HttpRequestPermitPoolKind {
@@ -200,6 +200,7 @@ mod tests {
.route("/held", get(held_request))
.route("/fast", get(fast_request))
.route("/healthz", get(fast_request))
+ .route("/readyz", get(fast_request))
.layer(middleware::from_fn_with_state(
backpressure_state,
limit_concurrent_requests,
@@ -297,6 +298,13 @@ mod tests {
.expect("healthz request should complete");
assert_eq!(health_response.status(), StatusCode::OK);
+ let ready_response = app
+ .clone()
+ .oneshot(test_request("/readyz"))
+ .await
+ .expect("readyz request should complete");
+ assert_eq!(ready_response.status(), StatusCode::OK);
+
gate.release.notify_one();
let completed_response = held_response
.await
diff --git a/server-rs/crates/api-server/src/bark_battle.rs b/server-rs/crates/api-server/src/bark_battle.rs
index f89affce..56cc47e7 100644
--- a/server-rs/crates/api-server/src/bark_battle.rs
+++ b/server-rs/crates/api-server/src/bark_battle.rs
@@ -30,7 +30,7 @@ use shared_kernel::{
use spacetime_client::{
BarkBattleDraftConfigUpsertRecordInput, BarkBattleDraftCreateRecordInput,
BarkBattleRunFinishRecordInput, BarkBattleRunRecord, BarkBattleRunStartRecordInput,
- BarkBattleWorkPublishRecordInput, SpacetimeClientError,
+ BarkBattleWorkDeleteRecordInput, BarkBattleWorkPublishRecordInput, SpacetimeClientError,
};
use time::{Duration as TimeDuration, OffsetDateTime};
@@ -406,6 +406,38 @@ pub async fn list_bark_battle_works(
))
}
+pub async fn delete_bark_battle_work(
+ State(state): State,
+ Path(work_id): Path,
+ Extension(request_context): Extension,
+ Extension(authenticated): Extension,
+) -> Result, Response> {
+ ensure_non_empty(&request_context, &work_id, "workId")?;
+ let items = state
+ .spacetime_client()
+ .delete_bark_battle_work(BarkBattleWorkDeleteRecordInput {
+ work_id,
+ owner_user_id: authenticated.claims().user_id().to_string(),
+ })
+ .await
+ .map_err(|error| {
+ bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
+ })?;
+ let items = items
+ .into_iter()
+ .map(|item| {
+ let author_display_name =
+ resolve_bark_battle_author_display_name_for_record(&state, &item);
+ map_work_summary_record(item, &request_context, author_display_name)
+ })
+ .collect::, _>>()?;
+
+ Ok(json_success_body(
+ Some(&request_context),
+ BarkBattleWorksResponse { items },
+ ))
+}
+
pub async fn list_bark_battle_gallery(
State(state): State,
Extension(request_context): Extension,
diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs
index 263c7556..3fe02061 100644
--- a/server-rs/crates/api-server/src/config.rs
+++ b/server-rs/crates/api-server/src/config.rs
@@ -25,6 +25,7 @@ pub struct AppConfig {
pub gallery_max_concurrent_requests: Option,
pub detail_max_concurrent_requests: Option,
pub admin_max_concurrent_requests: Option,
+ pub shutdown_outbox_flush_timeout: Duration,
pub tracking_outbox_enabled: bool,
pub tracking_outbox_dir: PathBuf,
pub tracking_outbox_batch_size: usize,
@@ -169,6 +170,7 @@ impl Default for AppConfig {
gallery_max_concurrent_requests: None,
detail_max_concurrent_requests: None,
admin_max_concurrent_requests: None,
+ shutdown_outbox_flush_timeout: Duration::from_millis(5_000),
tracking_outbox_enabled: true,
tracking_outbox_dir: PathBuf::from("server-rs/.data/tracking-outbox"),
tracking_outbox_batch_size: 500,
@@ -365,6 +367,11 @@ impl AppConfig {
{
config.admin_max_concurrent_requests = Some(max_concurrent_requests);
}
+ if let Some(timeout_ms) =
+ read_first_positive_u64_env(&["GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS"])
+ {
+ config.shutdown_outbox_flush_timeout = Duration::from_millis(timeout_ms);
+ }
if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_TRACKING_OUTBOX_ENABLED"]) {
config.tracking_outbox_enabled = enabled;
}
@@ -1324,6 +1331,7 @@ mod tests {
std::env::remove_var("GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS");
std::env::remove_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS");
std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS");
+ std::env::remove_var("GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS");
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED");
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR");
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE");
@@ -1336,6 +1344,7 @@ mod tests {
std::env::set_var("GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS", "64");
std::env::set_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS", "32");
std::env::set_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS", "16");
+ std::env::set_var("GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS", "3000");
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED", "false");
std::env::set_var(
"GENARRATIVE_TRACKING_OUTBOX_DIR",
@@ -1354,6 +1363,10 @@ mod tests {
assert_eq!(config.gallery_max_concurrent_requests, Some(64));
assert_eq!(config.detail_max_concurrent_requests, Some(32));
assert_eq!(config.admin_max_concurrent_requests, Some(16));
+ assert_eq!(
+ config.shutdown_outbox_flush_timeout,
+ std::time::Duration::from_millis(3_000)
+ );
assert!(!config.tracking_outbox_enabled);
assert_eq!(
config.tracking_outbox_dir,
@@ -1374,6 +1387,7 @@ mod tests {
std::env::remove_var("GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS");
std::env::remove_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS");
std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS");
+ std::env::remove_var("GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS");
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED");
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR");
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE");
diff --git a/server-rs/crates/api-server/src/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs
index eabc55e9..81f95e93 100644
--- a/server-rs/crates/api-server/src/creation_entry_config.rs
+++ b/server-rs/crates/api-server/src/creation_entry_config.rs
@@ -277,6 +277,29 @@ mod tests {
);
}
+ #[test]
+ fn test_creation_entry_config_response_updates_jump_hop_metadata() {
+ let config = test_creation_entry_config_response();
+ let jump_hop = config
+ .creation_types
+ .iter()
+ .find(|item| item.id == "jump-hop")
+ .expect("test creation entry config should include jump-hop");
+
+ assert_eq!(jump_hop.title, "\u{8df3}\u{4e00}\u{8df3}");
+ assert!(jump_hop.visible);
+ assert!(jump_hop.open);
+ assert_eq!(jump_hop.badge, "\u{53ef}\u{521b}\u{5efa}");
+ assert_eq!(
+ jump_hop.subtitle,
+ "\u{4e3b}\u{9898}\u{9a71}\u{52a8}\u{5e73}\u{53f0}\u{8df3}\u{8dc3}"
+ );
+ assert_eq!(
+ jump_hop.image_src,
+ "/creation-type-references/jump-hop.webp"
+ );
+ }
+
#[test]
fn test_creation_entry_config_response_keeps_baby_object_match_visible() {
let config = test_creation_entry_config_response();
diff --git a/server-rs/crates/api-server/src/generated_asset_sheets.rs b/server-rs/crates/api-server/src/generated_asset_sheets.rs
index 7fafb80b..b5df860e 100644
--- a/server-rs/crates/api-server/src/generated_asset_sheets.rs
+++ b/server-rs/crates/api-server/src/generated_asset_sheets.rs
@@ -1,4 +1,4 @@
-use axum::http::StatusCode;
+use axum::http::StatusCode;
use platform_image::generated_asset_sheets as generated_asset_sheets_impl;
use crate::{
@@ -8,9 +8,12 @@ use crate::{
#[allow(unused_imports)]
pub(crate) use generated_asset_sheets_impl::{
- GeneratedAssetSheetError, GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
+ GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetError, GeneratedAssetSheetKeyColor,
+ GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage, GeneratedAssetSheetUpload,
- apply_generated_asset_sheet_green_screen_alpha, crop_generated_asset_sheet_view_edge_matte,
+ apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha,
+ crop_generated_asset_sheet_view_edge_matte,
+ crop_generated_asset_sheet_view_edge_matte_with_options,
};
pub(crate) fn build_generated_asset_sheet_prompt(
diff --git a/server-rs/crates/api-server/src/health.rs b/server-rs/crates/api-server/src/health.rs
index ba043e02..ee83a012 100644
--- a/server-rs/crates/api-server/src/health.rs
+++ b/server-rs/crates/api-server/src/health.rs
@@ -1,7 +1,15 @@
-use axum::{Json, extract::Extension};
+use axum::{
+ Json,
+ extract::{Extension, State},
+ http::StatusCode,
+ response::{IntoResponse, Response},
+};
use serde_json::{Value, json};
-use crate::{api_response::json_success_body, request_context::RequestContext};
+use crate::{
+ api_response::json_success_body, http_error::AppError, request_context::RequestContext,
+ state::AppState,
+};
pub async fn health_check(Extension(request_context): Extension) -> Json {
json_success_body(
@@ -12,3 +20,28 @@ pub async fn health_check(Extension(request_context): Extension)
}),
)
}
+
+pub async fn readiness_check(
+ State(state): State,
+ Extension(request_context): Extension,
+) -> Response {
+ if state.is_ready() {
+ return json_success_body(
+ Some(&request_context),
+ json!({
+ "ok": true,
+ "ready": true,
+ "service": "genarrative-api-server",
+ }),
+ )
+ .into_response();
+ }
+
+ AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
+ .with_message("api-server 正在退出,不再接收新流量")
+ .with_details(json!({
+ "reason": "api_server_draining",
+ "ready": false,
+ }))
+ .into_response_with_context(Some(&request_context))
+}
diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs
index 9dd69508..5d905083 100644
--- a/server-rs/crates/api-server/src/jump_hop.rs
+++ b/server-rs/crates/api-server/src/jump_hop.rs
@@ -13,15 +13,15 @@ use serde_json::{Value, json};
use shared_contracts::jump_hop::{
JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse,
JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse,
- JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopSessionResponse,
- JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileType,
- JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorksResponse,
- JumpHopWorkspaceCreateRequest,
+ JumpHopLeaderboardResponse, JumpHopRestartRunRequest, JumpHopRunResponse,
+ JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest,
+ JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
+ JumpHopWorksResponse, JumpHopWorkspaceCreateRequest,
};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::SpacetimeClientError;
use std::{
- collections::BTreeMap,
+ collections::{BTreeMap, VecDeque},
time::{SystemTime, UNIX_EPOCH},
};
@@ -29,7 +29,8 @@ use crate::{
api_response::json_success_body,
auth::{AuthenticatedAccessToken, RuntimePrincipal},
generated_asset_sheets::{
- apply_generated_asset_sheet_green_screen_alpha, crop_generated_asset_sheet_view_edge_matte,
+ GeneratedAssetSheetAlphaOptions, apply_generated_asset_sheet_alpha_with_options,
+ crop_generated_asset_sheet_view_edge_matte_with_options,
},
generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
@@ -46,8 +47,7 @@ use crate::{
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
-const JUMP_HOP_TILE_ITEM_NAMES: [&str; 6] =
- ["start", "normal", "target", "finish", "bonus", "accent"];
+const JUMP_HOP_TILE_ITEM_COUNT: usize = 25;
const JUMP_HOP_PROVIDER: &str = "jump-hop";
const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation";
@@ -55,8 +55,15 @@ const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime";
const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop";
const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳";
const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs";
-const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 2;
-const JUMP_HOP_TILE_ATLAS_COLS: u32 = 3;
+const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 5;
+const JUMP_HOP_TILE_ATLAS_COLS: u32 = 5;
+const JUMP_HOP_TILE_ATLAS_KEY_HEX: &str = "#FF00FF";
+const JUMP_HOP_BACKGROUND_IMAGE_SIZE: &str = "1024*1536";
+const JUMP_HOP_BACKGROUND_IMAGE_WIDTH: u32 = 1024;
+const JUMP_HOP_BACKGROUND_IMAGE_HEIGHT: u32 = 1536;
+const JUMP_HOP_BACK_BUTTON_IMAGE_SIZE: &str = "1024*1024";
+const JUMP_HOP_BACK_BUTTON_IMAGE_WIDTH: u32 = 1024;
+const JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT: u32 = 1024;
#[derive(Clone, Debug, PartialEq, Eq)]
struct JumpHopTileAtlasSlice {
@@ -215,6 +222,33 @@ pub async fn list_jump_hop_works(
))
}
+pub async fn delete_jump_hop_work(
+ State(state): State,
+ Path(profile_id): Path,
+ Extension(request_context): Extension,
+ Extension(authenticated): Extension,
+) -> Result, Response> {
+ ensure_non_empty(&request_context, &profile_id, "profileId")?;
+ let works = state
+ .spacetime_client()
+ .delete_jump_hop_work(profile_id, authenticated.claims().user_id().to_string())
+ .await
+ .map_err(|error| {
+ jump_hop_error_response(
+ &request_context,
+ JUMP_HOP_CREATION_PROVIDER,
+ map_jump_hop_client_error(error),
+ )
+ })?;
+
+ Ok(json_success_body(
+ Some(&request_context),
+ JumpHopWorksResponse {
+ items: works.into_iter().map(|work| work.summary).collect(),
+ },
+ ))
+}
+
pub async fn get_jump_hop_runtime_work(
State(state): State,
Path(profile_id): Path,
@@ -239,6 +273,35 @@ pub async fn get_jump_hop_runtime_work(
))
}
+pub async fn get_jump_hop_leaderboard(
+ State(state): State,
+ Path(profile_id): Path,
+ Extension(request_context): Extension,
+ Extension(principal): Extension,
+) -> Result, Response> {
+ ensure_non_empty(&request_context, &profile_id, "profileId")?;
+ let leaderboard = state
+ .spacetime_client()
+ .get_jump_hop_leaderboard(profile_id, principal.subject().to_string())
+ .await
+ .map_err(|error| {
+ jump_hop_error_response(
+ &request_context,
+ JUMP_HOP_RUNTIME_PROVIDER,
+ map_jump_hop_client_error(error),
+ )
+ })?;
+
+ Ok(json_success_body(
+ Some(&request_context),
+ JumpHopLeaderboardResponse {
+ profile_id: leaderboard.profile_id,
+ items: leaderboard.items,
+ viewer_best: leaderboard.viewer_best,
+ },
+ ))
+}
+
pub async fn start_jump_hop_run(
State(state): State,
Extension(request_context): Extension,
@@ -247,6 +310,10 @@ pub async fn start_jump_hop_run(
) -> Result, Response> {
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
ensure_non_empty(&request_context, &payload.profile_id, "profileId")?;
+ let is_draft_runtime = payload
+ .runtime_mode
+ .as_deref()
+ .is_some_and(is_jump_hop_draft_runtime_mode);
let owner_user_id = principal.subject().to_string();
let principal_kind = principal.kind().as_str();
let run = state
@@ -261,23 +328,25 @@ pub async fn start_jump_hop_run(
)
})?;
- record_work_play_start_after_success(
- &state,
- &request_context,
- build_jump_hop_work_play_tracking_draft(
- &principal,
- run.profile_id.clone(),
- JUMP_HOP_RUNTIME_RUNS_ROUTE,
+ if !is_draft_runtime {
+ record_work_play_start_after_success(
+ &state,
+ &request_context,
+ build_jump_hop_work_play_tracking_draft(
+ &principal,
+ run.profile_id.clone(),
+ JUMP_HOP_RUNTIME_RUNS_ROUTE,
+ )
+ .owner_user_id(run.owner_user_id.clone())
+ .run_id(run.run_id.clone())
+ .profile_id(run.profile_id.clone())
+ .extra(json!({
+ "runStatus": run.status,
+ "principalKind": principal_kind,
+ })),
)
- .owner_user_id(run.owner_user_id.clone())
- .run_id(run.run_id.clone())
- .profile_id(run.profile_id.clone())
- .extra(json!({
- "runStatus": run.status,
- "principalKind": principal_kind,
- })),
- )
- .await;
+ .await;
+ }
Ok(json_success_body(
Some(&request_context),
@@ -391,16 +460,29 @@ async fn maybe_generate_jump_hop_assets(
owner_user_id: &str,
payload: &mut JumpHopActionRequest,
) -> Result<(), Response> {
- if !matches!(payload.action_type, JumpHopActionType::CompileDraft) {
+ if !matches!(
+ payload.action_type,
+ JumpHopActionType::CompileDraft | JumpHopActionType::RegenerateTiles
+ ) {
return Ok(());
}
- if payload.character_asset.is_some()
- && payload.tile_atlas_asset.is_some()
+ let has_complete_tile_assets = payload.tile_atlas_asset.is_some()
&& payload
.tile_assets
.as_ref()
- .is_some_and(|assets| !assets.is_empty())
- {
+ .is_some_and(|assets| assets.len() >= JUMP_HOP_TILE_ITEM_COUNT);
+ let has_real_background = payload
+ .cover_composite
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .is_some_and(|value| !is_jump_hop_legacy_cover_composite_placeholder(value));
+ let has_back_button_asset = payload
+ .back_button_asset
+ .as_ref()
+ .is_some_and(is_jump_hop_image_asset_usable);
+
+ if has_complete_tile_assets && has_real_background && has_back_button_asset {
return Ok(());
}
let profile_id = payload
@@ -427,138 +509,345 @@ async fn maybe_generate_jump_hop_assets(
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
})?;
- let character_prompt = payload
- .character_prompt
+ let theme_text = payload
+ .theme_text
.as_deref()
- .unwrap_or("俯视角可爱主角,透明背景");
- let tile_prompt = payload.tile_prompt.as_deref().unwrap_or("等距立体地块图集");
+ .or(payload.work_title.as_deref())
+ .unwrap_or("跳一跳")
+ .to_string();
+ let tile_prompt = payload
+ .tile_prompt
+ .clone()
+ .unwrap_or_else(|| theme_text.clone());
- let character_generated = create_openai_image_generation(
- &http_client,
- &settings,
- character_prompt,
- Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"),
- "1024*1024",
- 1,
- &[],
- "跳一跳角色资产生成失败",
- )
- .await
- .map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
- let character_image = character_generated
- .images
- .into_iter()
- .next()
- .ok_or_else(|| {
+ if !has_real_background {
+ let background_prompt = build_jump_hop_background_prompt(theme_text.as_str());
+ let background_generated = create_openai_image_generation(
+ &http_client,
+ &settings,
+ background_prompt.as_str(),
+ Some(build_jump_hop_background_negative_prompt()),
+ JUMP_HOP_BACKGROUND_IMAGE_SIZE,
+ 1,
+ &[],
+ "跳一跳背景底图生成失败",
+ )
+ .await
+ .map_err(|error| {
+ jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
+ })?;
+ let background_image = background_generated
+ .images
+ .into_iter()
+ .next()
+ .ok_or_else(|| {
+ jump_hop_error_response(
+ request_context,
+ JUMP_HOP_CREATION_PROVIDER,
+ AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
+ "provider": "vector-engine",
+ "message": "跳一跳背景底图生成成功但未返回图片。",
+ })),
+ )
+ })?;
+ let background_asset = persist_jump_hop_generated_image_asset(
+ state,
+ owner_user_id,
+ profile_id.as_str(),
+ "background",
+ background_prompt.as_str(),
+ background_image,
+ LegacyAssetPrefix::JumpHopAssets,
+ JUMP_HOP_BACKGROUND_IMAGE_WIDTH,
+ JUMP_HOP_BACKGROUND_IMAGE_HEIGHT,
+ request_context,
+ )
+ .await?;
+ payload.cover_composite = Some(background_asset.image_src);
+ }
+
+ if !has_back_button_asset {
+ let back_button_prompt = build_jump_hop_back_button_prompt(theme_text.as_str());
+ let back_button_generated = create_openai_image_generation(
+ &http_client,
+ &settings,
+ back_button_prompt.as_str(),
+ Some(build_jump_hop_back_button_negative_prompt()),
+ JUMP_HOP_BACK_BUTTON_IMAGE_SIZE,
+ 1,
+ &[],
+ "跳一跳返回按钮图生成失败",
+ )
+ .await
+ .map_err(|error| {
+ jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
+ })?;
+ let back_button_image =
+ back_button_generated
+ .images
+ .into_iter()
+ .next()
+ .ok_or_else(|| {
+ jump_hop_error_response(
+ request_context,
+ JUMP_HOP_CREATION_PROVIDER,
+ AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
+ "provider": "vector-engine",
+ "message": "跳一跳返回按钮图生成成功但未返回图片。",
+ })),
+ )
+ })?;
+ let back_button_image =
+ prepare_jump_hop_green_screen_image_for_persist(back_button_image, "跳一跳返回按钮图")
+ .map_err(|error| {
+ jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
+ })?;
+ let back_button_asset = persist_jump_hop_generated_image_asset(
+ state,
+ owner_user_id,
+ profile_id.as_str(),
+ "back-button",
+ back_button_prompt.as_str(),
+ back_button_image,
+ LegacyAssetPrefix::JumpHopAssets,
+ JUMP_HOP_BACK_BUTTON_IMAGE_WIDTH,
+ JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT,
+ request_context,
+ )
+ .await?;
+ payload.back_button_asset = Some(back_button_asset);
+ }
+
+ if !has_complete_tile_assets {
+ let sheet_prompt =
+ build_jump_hop_tile_atlas_prompt(theme_text.as_str(), tile_prompt.as_str());
+ let tile_generated = create_openai_image_generation(
+ &http_client,
+ &settings,
+ sheet_prompt.as_str(),
+ Some(build_jump_hop_tile_atlas_negative_prompt()),
+ "1024*1024",
+ 1,
+ &[],
+ "跳一跳地块图集生成失败",
+ )
+ .await
+ .map_err(|error| {
+ jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
+ })?;
+ let tile_image = tile_generated.images.into_iter().next().ok_or_else(|| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
- "message": "跳一跳角色资产生成成功但未返回图片。",
+ "message": "跳一跳地块图集生成成功但未返回图片。",
})),
)
})?;
- let character_asset = persist_jump_hop_generated_image_asset(
- state,
- owner_user_id,
- profile_id.as_str(),
- "character",
- character_prompt,
- character_image,
- LegacyAssetPrefix::JumpHopAssets,
- 768,
- 768,
- request_context,
- )
- .await?;
-
- let sheet_prompt = build_jump_hop_tile_atlas_prompt(tile_prompt);
- let tile_generated = create_openai_image_generation(
- &http_client,
- &settings,
- sheet_prompt.as_str(),
- Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"),
- "1024*1024",
- 1,
- &[],
- "跳一跳地块图集生成失败",
- )
- .await
- .map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
- let tile_image = tile_generated.images.into_iter().next().ok_or_else(|| {
- jump_hop_error_response(
+ let tile_slices = slice_jump_hop_tile_atlas(&tile_image).map_err(|error| {
+ jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
+ })?;
+ let tile_atlas_asset = persist_jump_hop_generated_image_asset(
+ state,
+ owner_user_id,
+ profile_id.as_str(),
+ "tile-atlas",
+ tile_prompt.as_str(),
+ tile_image,
+ LegacyAssetPrefix::JumpHopAssets,
+ 1024,
+ 1024,
request_context,
- JUMP_HOP_CREATION_PROVIDER,
- AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
- "provider": "vector-engine",
- "message": "跳一跳地块图集生成成功但未返回图片。",
- })),
)
- })?;
- let tile_slices = slice_jump_hop_tile_atlas(&tile_image).map_err(|error| {
- jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
- })?;
- let tile_atlas_asset = persist_jump_hop_generated_image_asset(
- state,
- owner_user_id,
- profile_id.as_str(),
- "tile-atlas",
- tile_prompt,
- tile_image,
- LegacyAssetPrefix::JumpHopAssets,
- 1024,
- 1024,
- request_context,
- )
- .await?;
- let mut tile_assets = Vec::with_capacity(tile_slices.len());
- for (index, tile_slice) in tile_slices.into_iter().enumerate() {
- tile_assets.push(
- persist_jump_hop_tile_asset(
- state,
- owner_user_id,
- profile_id.as_str(),
- index,
- tile_slice,
- request_context,
- )
- .await?,
- );
+ .await?;
+ let mut tile_assets = Vec::with_capacity(tile_slices.len());
+ for (index, tile_slice) in tile_slices.into_iter().enumerate() {
+ tile_assets.push(
+ persist_jump_hop_tile_asset(
+ state,
+ owner_user_id,
+ profile_id.as_str(),
+ index,
+ tile_slice,
+ request_context,
+ )
+ .await?,
+ );
+ }
+ payload.tile_atlas_asset = Some(tile_atlas_asset);
+ payload.tile_assets = Some(tile_assets);
+ }
+ if payload.character_asset.is_none() {
+ payload.character_asset = Some(build_jump_hop_default_character_asset(
+ profile_id.as_str(),
+ theme_text.as_str(),
+ ));
}
- payload.character_asset = Some(character_asset);
- payload.tile_atlas_asset = Some(tile_atlas_asset);
- payload.tile_assets = Some(tile_assets);
- payload.cover_composite = payload.cover_composite.clone().or_else(|| {
- Some(format!(
- "/generated-jump-hop-assets/{profile_id}/cover-composite.png"
- ))
- });
Ok(())
}
-fn build_jump_hop_tile_atlas_prompt(tile_prompt: &str) -> String {
- let subject_text = tile_prompt.trim();
- let subject_text = if subject_text.is_empty() {
- "等距立体地块图集"
- } else {
- subject_text
- };
- let cell_plan = [
- "第1行第1列:start 起点地块",
- "第1行第2列:normal 普通地块",
- "第1行第3列:target 目标地块",
- "第2行第1列:finish 终点地块",
- "第2行第2列:bonus 奖励地块",
- "第2行第3列:accent 视觉强调地块",
- ]
- .join(";");
+fn is_jump_hop_legacy_cover_composite_placeholder(value: &str) -> bool {
+ let value = value.trim();
+ value.starts_with("/generated-jump-hop-assets/")
+ && (value.ends_with("/cover-composite.png") || value.contains("/cover-composite-"))
+}
+
+fn is_jump_hop_image_asset_usable(asset: &JumpHopCharacterAsset) -> bool {
+ !asset.image_src.trim().is_empty()
+ && !asset.image_object_key.trim().is_empty()
+ && !asset.asset_object_id.trim().is_empty()
+ && !asset.generation_provider.trim().is_empty()
+}
+
+fn prepare_jump_hop_green_screen_image_for_persist(
+ image: crate::openai_image_generation::DownloadedOpenAiImage,
+ failure_label: &str,
+) -> Result {
+ let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
+ AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
+ "provider": JUMP_HOP_CREATION_PROVIDER,
+ "message": format!("{failure_label}解码失败:{error}"),
+ }))
+ })?;
+ let mut encoded = std::io::Cursor::new(Vec::new());
+ crate::generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha(source)
+ .write_to(&mut encoded, image::ImageFormat::Png)
+ .map_err(|error| {
+ AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
+ "provider": JUMP_HOP_CREATION_PROVIDER,
+ "message": format!("{failure_label}绿幕去背失败:{error}"),
+ }))
+ })?;
+
+ Ok(crate::openai_image_generation::DownloadedOpenAiImage {
+ bytes: encoded.into_inner(),
+ mime_type: "image/png".to_string(),
+ extension: "png".to_string(),
+ })
+}
+
+fn normalize_jump_hop_generation_theme_text(theme_text: &str) -> String {
+ let theme_text = theme_text.trim();
+ if theme_text.is_empty() {
+ return "跳一跳".to_string();
+ }
+
+ replace_jump_hop_pokemon_prompt_terms(theme_text)
+}
+
+fn replace_jump_hop_pokemon_prompt_terms(value: &str) -> String {
+ let mut value = value.trim().to_string();
+ if value.is_empty() {
+ return value;
+ }
+
+ // 中文注释:仅对宝可梦相关词做生成侧脱敏,避免地块图集触发上游安全拦截。
+ const POKEMON_REPLACEMENTS: [(&str, &str); 15] = [
+ ("宝可梦", "原创幻想萌宠冒险道具"),
+ ("神奇宝贝", "原创幻想萌宠冒险道具"),
+ ("口袋妖怪", "原创幻想萌宠冒险道具"),
+ ("精灵球", "彩色冒险能量球"),
+ ("皮卡丘", "黄色闪电萌宠符号"),
+ ("Pokémon", "原创幻想萌宠冒险道具"),
+ ("Pokemon", "原创幻想萌宠冒险道具"),
+ ("POKEMON", "原创幻想萌宠冒险道具"),
+ ("pokemon", "原创幻想萌宠冒险道具"),
+ ("Pikachu", "黄色闪电萌宠符号"),
+ ("PIKACHU", "黄色闪电萌宠符号"),
+ ("pikachu", "黄色闪电萌宠符号"),
+ ("Poké Ball", "彩色冒险能量球"),
+ ("Poke Ball", "彩色冒险能量球"),
+ ("pokeball", "彩色冒险能量球"),
+ ];
+
+ for (from, to) in POKEMON_REPLACEMENTS {
+ value = value.replace(from, to);
+ }
+
+ value
+}
+
+fn build_jump_hop_background_prompt(theme_text: &str) -> String {
+ let theme_text = normalize_jump_hop_generation_theme_text(theme_text);
format!(
- "生成一张1:1图片。固定生成2行*3列的跳一跳地块素材图集,画面是{subject_text}。严格按六个单元格排布:{cell_plan}。每个单元格只放一个完整等距/俯视角 2D 地块,必须表现顶面、侧面厚度和统一投影,光向一致,地块主体居中且四周保留留白。每格背景必须是统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无道具,方便后续抠成透明。素材本身不得使用与绿幕相同的纯绿色;若材质天然含绿色,必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。禁止主体跨格、贴边或越界,禁止任何内容进入相邻格子。不要出现文字、水印、UI、边框、网格线、标签、角色或场景。"
+ "生成一张9:16竖版跳一跳游戏背景底图,主题关键词严格只使用“{theme_text}”,不要额外改换主题;整体风格需要和同一主题的跳一跳游戏元素一致。\n画面结构必须以左右两侧氛围为主:左侧和右侧可以使用符合主题的环境元素、装饰层次、前中后景遮挡、透视节奏和行进感,让玩家感到从画面下方向上方前进。\n中央纵轴1/2区域必须是清爽的跳跃路径视觉走廊,从画面底部延伸到上方;该区域只能使用少量低对比度纹理、柔和光影、空气透视和纵深引导线,禁止堆放大型主体。\n中央纵轴1/2区域要有明显纵深感,但元素数量必须少,不能抢跳板、角色和交互层的视觉;两侧可以更有立体感、空间层次和主题氛围。\n背景只作为底图,不画任何跳板、地块、落脚物、角色、UI按钮、标题、文字、路径箭头、分数、边框、海报排版、Logo或水印;左上角也不要画返回按钮或任何固定图标,运行态会叠加独立可点击按钮资产。\n视角保持正面约30度的2D/2.5D休闲手游视角,相机位于场景正前方略高位置,画面有轻微向上行进的纵深,不要画成纯俯视地图、平铺俯拍、扁平壁纸或真实摄影。\n色彩清爽自然,哑光手绘质感,柔和光照,主体背景不油亮、不厚重CG、不暗黑;中央区域需要给运行态地块和陶泥儿角色留出干净可读空间。\nEnglish guardrail: vertical 9:16 mobile game background only, theme keywords strictly from \"{theme_text}\", left and right sides carry the atmosphere, the central vertical half-width corridor stays simple with sparse low-contrast details and clear depth, no platforms, no landing objects, no character, no UI, no top-left back button, no score UI, no other UI panels, consistent 2D/2.5D front-facing 30-degree game perspective."
)
}
+fn build_jump_hop_background_negative_prompt() -> &'static str {
+ "文字、Logo、水印、UI按钮、返回按钮、左上角图标、右上角按钮、底部按钮、UI面板、标题、说明文字、分数、边框、海报排版、角色、人物、跳板、地块、落脚物、平台、道路箭头、棋盘、格子、中心大型主体、中央堆满元素、中央遮挡、中央高对比装饰、中央复杂花纹、纯俯视地图、平铺俯拍、扁平壁纸、真实摄影、暗黑幻想风、厚重CG渲染、油亮高光、塑料质感"
+}
+
+fn build_jump_hop_back_button_prompt(theme_text: &str) -> String {
+ let theme_text = normalize_jump_hop_generation_theme_text(theme_text);
+
+ format!(
+ "生成跳一跳运行态左上角返回按钮的独立透明素材。主题关键词严格只使用“{theme_text}”,按钮的底色、材质、描边和轻微装饰跟随该主题,但必须仍然是清晰可识别的游戏 UI 返回按钮。\n按钮必须是单个标准圆形图标,圆心居中,主体视觉尺寸占画布约72%-82%,外沿有一圈干净描边,内部只有一个居中的向左箭头;不要写“返回”文字,不要数字、Logo、水印、按钮外标签或额外 UI 面板。\n允许在圆形底色里做很轻的主题材质包装,例如水果主题可用果皮色和果肉色、森林主题可用叶片色和木质描边、未来主题可用金属边和发光内环;但不要把按钮画成主题物体本身,不要继承复杂花纹、浮雕边、异形外框、贴纸堆叠或徽章装饰。\n尺寸1:1,输出绿色背景主体图,背景必须是单一纯绿色 #00FF00 且平整无纹理、无渐变、无阴影;按钮主体边缘干净,后续由服务端扣除绿色背景。按钮底色不要使用与绿幕接近的纯绿色,若主题天然包含绿色,请使用偏深、偏黄或偏蓝的主题绿色,并用高对比箭头颜色区分。\nEnglish guardrail: one standalone circular mobile game back button asset only, theme-styled colors/materials from \"{theme_text}\", centered left arrow only, no text, no logo, no extra UI, no complex badge, no object silhouette, solid #00FF00 green-screen background for later alpha removal."
+ )
+}
+
+fn build_jump_hop_back_button_negative_prompt() -> &'static str {
+ "文字、返回文字、Logo、水印、数字、多个按钮、UI面板、海报排版、复杂徽章、花盘、浮雕边、异形外框、主题物体主体、木槌、角色、跳板、地块、落脚物、平台、透明棋盘格、白底、黑底、灰底、真实摄影、厚重CG、暗黑幻想风、油亮塑料、纯绿色按钮主体、与绿幕混在一起"
+}
+
+fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> String {
+ let theme_text = normalize_jump_hop_generation_theme_text(theme_text);
+ let sanitized_tile_prompt = sanitize_jump_hop_tile_prompt(tile_prompt);
+ let subject_text = if sanitized_tile_prompt.is_empty() {
+ theme_text.as_str()
+ } else {
+ sanitized_tile_prompt.as_str()
+ };
+
+ format!(
+ "生成一张1:1图片,主题为“{theme_text}”。\n画面只包含25个独立的跳跃落点主题物体,按五行五列均匀摆放在纯洋红抠图画布上;不要画成游戏界面、棋盘、背包、装备栏或图标集页面。\n视觉方向为正面30度视角的跳跃游戏素材,画面内容是{subject_text}。所有落点素材都必须保持统一的正面30度视角:相机位于物体正前方略高位置,镜头向下约30度,能看到清晰正面、侧壁、下沿和少量上表面。\n构图验收标准:主体正面或侧壁可见面积必须接近或大于顶面面积,顶面只能作为辅助可见面;不要让顶面占据主要视觉,不要画成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标。\n水果主题尤其要避免俯拍:橙瓣必须看到橙皮正面外侧和果肉厚度,椰子必须看到壳的正面侧壁和切口厚度,浆果不能只是一个从上往下看的圆形球顶。\n每一个落点都必须直接使用主题物体或合理发散物体做主体造型,主题要一眼可见;例如主题为水果时,可以是苹果切片、橙瓣、西瓜块、草莓、菠萝块、香蕉、葡萄串等水果物体,苹果可近似圆,香蕉可近似长条或长方形,西瓜可近似扇形,造型以物体本身外轮廓为准。\n主题物体本身就是唯一可落脚体:雪花落点就是一枚带厚度的雪花,向日葵落点就是一朵带厚度的向日葵,水果落点就是水果切片或水果本体;不要在主题物体下面再垫任何石头、土块、木板、圆台、底盘、托盘、岛屿、花盆、地面块或通用承托物。\n只画主题物体裸素材,不画外层面板、棋盘底座、菜单、UI按钮、标题、文字、角标、装饰边框、工具栏、装备栏、图标卡、角色或游戏界面。\n整体风格为清爽自然的休闲手游主题物体素材,偏2D/2.5D手绘质感,哑光材质,干净色块,轻微主体内部明暗,避免写实摄影、油亮高光、塑料感、暗黑幻想风和厚重CG渲染。\n每个落点都是符合主题且有设计感的立体感物体,有清晰轮廓和明显自身厚度;不要把不同主题物体强行改造成统一地砖、统一按钮或统一抽象图标。\n造型规则完全由物体本身决定:允许圆形、长条、弧形、三角、扇形、块状、枝叶状、多件组合、轻微夸张和一定程度发散;只在同一2D/2.5D手绘风格、正面30度视角、材质包装、清晰轮廓、单格规格和安全留白上保持一致。\n25个落点应尽量选择不同主题物体或相关发散物体,差异主要来自物体种类和原生轮廓,不使用固定形状脚本;相邻格可以形状相似,只要物体不同且主题清楚。\n允许用主题物体自身的切面、边缘厚度、花瓣层、果皮边、雪花厚边或云朵体积表现可落脚感;禁止额外支撑层、承托底座、脚下地板、下方石台、下方土墩、下方圆盘、下方托盘或“物体摆在平台上”的画法。\n每个落点必须居中,视觉尺寸只占单格56%-64%,四周至少保留18%纯洋红安全留白;任何叶片、装饰、轮廓和光影都不得贴边、跨格或越界。\n每个落点只保留主体内部明暗、外轮廓和自身厚度,不绘制落地投影、接触阴影、方形阴影、洋红阴影、紫色底边、彩色光晕、发光底边、底板、白底、灰底、黑底或背景色块,运行态会统一添加阴影。\n25个落点同一材质体系、同一光向和同一正面30度视角,但物体类别、外轮廓和细节有变化;每个落点之间只能是纯洋红空白,不画分隔线、网格线、容器框或棋盘格。\n整张画布背景、格间空白和每格背景都必须是单一纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX},背景平整无纹理、无渐变、无阴影、无黑底;主体允许使用绿色、白色、雪地、云朵、草地和花朵,但主体自身不得使用接近 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 的洋红色,主体边缘不得出现洋红色描边、紫色描边、粉色脏边或彩色阴影。\n禁止跨格、贴边、越界、文字、水印、UI、边框、网格线、角色、场景、游戏面板、图标集页面、物体下方额外底座或物体摆在地板上。\nEnglish guardrail: isolated front-facing 30-degree camera-pitch theme-object assets only, camera slightly above the object and looking down about 30 degrees from the front; every object must show a clear front face, side wall, lower rim, object thickness, and only a small top surface; visible front/side area must be close to or larger than the top area; never produce top-down, overhead, bird's-eye, flat icon, round top-view disk assets; the theme object itself is the only landing object, each object's native silhouette decides the shape, no extra base under the object, no pedestal, no plinth, no floor slab, no colored shadow or magenta fringe around objects, consistent 2D/2.5D style wrapper, solid magenta chroma key background {JUMP_HOP_TILE_ATLAS_KEY_HEX}, no text, no poster, no UI screen, no inventory icons."
+ )
+}
+
+fn build_jump_hop_tile_atlas_negative_prompt() -> &'static str {
+ "文字、Logo、水印、UI按钮、UI 字、游戏界面、棋盘、背包、装备栏、图标集页面、外层面板、菜单、工具栏、低清晰度、畸形肢体、多余角色、裁切主体、写实摄影、油亮高光、塑料质感、暗黑幻想风、厚重CG渲染、海报、UI图标卡、标题、说明文字、装饰边框、纯俯视角、正上方视角、鸟瞰视角、平铺俯拍、顶面占主画面、只看顶面、圆形顶视图、扁平图标、落地投影、接触阴影、方形阴影、洋红阴影、紫色底边、粉色脏边、洋红色描边、彩色光晕、发光底边、方形底板、额外底座、承托底座、台座、石台、土墩、木板底座、圆台、底盘、托盘、岛屿底座、花盆底座、地面块、脚下地板、物体摆在平台上、物体下方垫地板、白底、灰底、黑底、暗色背景、背景色块、贴边、跨格、越界"
+}
+
+fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String {
+ let mut value = tile_prompt.trim().to_string();
+ if value.is_empty() {
+ return value;
+ }
+ value = replace_jump_hop_pokemon_prompt_terms(value.as_str());
+
+ const REPLACEMENTS: [(&str, &str); 18] = [
+ ("俯视角", "正面30度视角"),
+ ("正上方视角", "正面30度视角"),
+ ("鸟瞰视角", "正面30度视角"),
+ ("平铺俯拍", "正面30度视角"),
+ ("可落脚平台素材", "跳跃落点主题物体"),
+ ("清爽游戏化立体感平台素材", "清爽游戏化立体感主题物体"),
+ ("平台裸素材", "主题物体裸素材"),
+ ("每格一个完整平台", "每格一个完整主题物体"),
+ ("平台素材", "主题物体"),
+ ("可落脚平台", "跳跃落点"),
+ ("可落脚", "落点"),
+ ("平台", "主题物体"),
+ ("跳台", "落点"),
+ ("地块", "主题物体"),
+ ("地砖", "主题物体"),
+ ("底座", "承托物"),
+ ("底盘", "承托物"),
+ ("地板", "承托物"),
+ ];
+
+ for (from, to) in REPLACEMENTS {
+ value = value.replace(from, to);
+ }
+ while value.contains("正面30度视角正面30度视角") {
+ value = value.replace("正面30度视角正面30度视角", "正面30度视角");
+ }
+
+ value
+}
+
fn slice_jump_hop_tile_atlas(
image: &crate::openai_image_generation::DownloadedOpenAiImage,
) -> Result, AppError> {
@@ -568,7 +857,8 @@ fn slice_jump_hop_tile_atlas(
"message": format!("跳一跳地块图集解码失败:{error}"),
}))
})?;
- let source = apply_generated_asset_sheet_green_screen_alpha(source);
+ let alpha_options = GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen();
+ let source = apply_generated_asset_sheet_alpha_with_options(source, alpha_options);
let width = source.width();
let height = source.height();
let cell_width = width / JUMP_HOP_TILE_ATLAS_COLS;
@@ -582,8 +872,8 @@ fn slice_jump_hop_tile_atlas(
);
}
- let mut slices = Vec::with_capacity(JUMP_HOP_TILE_ITEM_NAMES.len());
- for index in 0..JUMP_HOP_TILE_ITEM_NAMES.len() {
+ let mut slices = Vec::with_capacity(JUMP_HOP_TILE_ITEM_COUNT);
+ for index in 0..JUMP_HOP_TILE_ITEM_COUNT {
let row = index as u32 / JUMP_HOP_TILE_ATLAS_COLS;
let col = index as u32 % JUMP_HOP_TILE_ATLAS_COLS;
let x0 = col.saturating_mul(width) / JUMP_HOP_TILE_ATLAS_COLS;
@@ -596,7 +886,12 @@ fn slice_jump_hop_tile_atlas(
x1.saturating_sub(x0).max(1),
y1.saturating_sub(y0).max(1),
);
- let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped);
+ let cleaned =
+ crop_generated_asset_sheet_view_edge_matte_with_options(cropped, alpha_options);
+ let cleaned = keep_jump_hop_largest_alpha_component(cleaned);
+ let cleaned =
+ crop_generated_asset_sheet_view_edge_matte_with_options(cleaned, alpha_options);
+ let cleaned = pad_jump_hop_tile_slice_image(cleaned);
let mut cursor = std::io::Cursor::new(Vec::new());
cleaned
.write_to(&mut cursor, image::ImageFormat::Png)
@@ -616,26 +911,116 @@ fn slice_jump_hop_tile_atlas(
Ok(slices)
}
+fn pad_jump_hop_tile_slice_image(image: image::DynamicImage) -> image::DynamicImage {
+ let source = image.to_rgba8();
+ let (width, height) = source.dimensions();
+ if width == 0 || height == 0 {
+ return image::DynamicImage::ImageRgba8(source);
+ }
+
+ // 中文注释:生图偶尔会让主体贴近单元格边缘;切片入库前补透明安全边,
+ // 避免运行态缩放或滤镜让主体看起来被裁掉。
+ let pad_x = (width / 12).clamp(8, 24);
+ let pad_y = (height / 12).clamp(8, 24);
+ let mut padded = image::RgbaImage::from_pixel(
+ width.saturating_add(pad_x.saturating_mul(2)),
+ height.saturating_add(pad_y.saturating_mul(2)),
+ image::Rgba([0, 0, 0, 0]),
+ );
+ image::imageops::overlay(&mut padded, &source, pad_x.into(), pad_y.into());
+ image::DynamicImage::ImageRgba8(padded)
+}
+
+fn keep_jump_hop_largest_alpha_component(image: image::DynamicImage) -> image::DynamicImage {
+ let mut source = image.to_rgba8();
+ let (width, height) = source.dimensions();
+ if width == 0 || height == 0 {
+ return image::DynamicImage::ImageRgba8(source);
+ }
+
+ // 中文注释:模型偶尔会让相邻格的叶片、果梗或阴影越界进当前格;
+ // 每格只保留最大的 alpha 连通主体,能去掉这些小碎片再入库。
+ let width_usize = width as usize;
+ let height_usize = height as usize;
+ let pixel_count = width_usize.saturating_mul(height_usize);
+ let mut visited = vec![false; pixel_count];
+ let mut best_component = Vec::::new();
+
+ for start in 0..pixel_count {
+ if visited[start] || source.as_raw()[start * 4 + 3] <= 16 {
+ visited[start] = true;
+ continue;
+ }
+
+ let mut queue = VecDeque::from([start]);
+ let mut component = Vec::::new();
+ visited[start] = true;
+
+ while let Some(index) = queue.pop_front() {
+ component.push(index);
+ let x = index % width_usize;
+ let y = index / width_usize;
+
+ for offset_y in -1i32..=1 {
+ for offset_x in -1i32..=1 {
+ if offset_x == 0 && offset_y == 0 {
+ continue;
+ }
+ let next_x = x as i32 + offset_x;
+ let next_y = y as i32 + offset_y;
+ if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32
+ {
+ continue;
+ }
+ let next = next_y as usize * width_usize + next_x as usize;
+ if visited[next] {
+ continue;
+ }
+ visited[next] = true;
+ if source.as_raw()[next * 4 + 3] > 16 {
+ queue.push_back(next);
+ }
+ }
+ }
+ }
+
+ if component.len() > best_component.len() {
+ best_component = component;
+ }
+ }
+
+ if best_component.is_empty() {
+ return image::DynamicImage::ImageRgba8(source);
+ }
+
+ let mut keep = vec![false; pixel_count];
+ for index in best_component {
+ keep[index] = true;
+ }
+ for index in 0..pixel_count {
+ if keep[index] {
+ continue;
+ }
+ let pixel =
+ source.get_pixel_mut((index % width_usize) as u32, (index / width_usize) as u32);
+ pixel.0[3] = 0;
+ }
+
+ image::DynamicImage::ImageRgba8(source)
+}
+
fn jump_hop_tile_type_by_index(index: usize) -> JumpHopTileType {
match index {
0 => JumpHopTileType::Start,
- 1 => JumpHopTileType::Normal,
- 2 => JumpHopTileType::Target,
- 3 => JumpHopTileType::Finish,
- 4 => JumpHopTileType::Bonus,
- _ => JumpHopTileType::Accent,
+ value if value % 11 == 0 => JumpHopTileType::Bonus,
+ value if value % 7 == 0 => JumpHopTileType::Accent,
+ value if value % 3 == 0 => JumpHopTileType::Target,
+ _ => JumpHopTileType::Normal,
}
}
-fn jump_hop_tile_asset_slot_name(tile_type: &JumpHopTileType) -> &'static str {
- match tile_type {
- JumpHopTileType::Start => "tile-start",
- JumpHopTileType::Normal => "tile-normal",
- JumpHopTileType::Target => "tile-target",
- JumpHopTileType::Finish => "tile-finish",
- JumpHopTileType::Bonus => "tile-bonus",
- JumpHopTileType::Accent => "tile-accent",
- }
+fn jump_hop_tile_asset_slot_name(tile_index: usize) -> String {
+ format!("tile-{:02}", tile_index + 1)
}
#[allow(clippy::too_many_arguments)]
@@ -647,7 +1032,7 @@ async fn persist_jump_hop_tile_asset(
tile_slice: JumpHopTileAtlasSlice,
request_context: &RequestContext,
) -> Result {
- let slot = jump_hop_tile_asset_slot_name(&tile_slice.tile_type);
+ let slot = jump_hop_tile_asset_slot_name(tile_index);
let image = crate::openai_image_generation::DownloadedOpenAiImage {
bytes: tile_slice.bytes,
mime_type: "image/png".to_string(),
@@ -657,7 +1042,7 @@ async fn persist_jump_hop_tile_asset(
state,
owner_user_id,
profile_id,
- slot,
+ slot.as_str(),
&format!(
"跳一跳地块切片 {}:{}",
tile_index + 1,
@@ -673,10 +1058,13 @@ async fn persist_jump_hop_tile_asset(
Ok(JumpHopTileAsset {
tile_type: tile_slice.tile_type,
+ tile_id: Some(slot),
image_src: persisted.image_src,
image_object_key: persisted.image_object_key,
asset_object_id: persisted.asset_object_id,
source_atlas_cell: tile_slice.source_atlas_cell,
+ atlas_row: Some((tile_index as u32 / JUMP_HOP_TILE_ATLAS_COLS) + 1),
+ atlas_col: Some((tile_index as u32 % JUMP_HOP_TILE_ATLAS_COLS) + 1),
visual_width: 256,
visual_height: 192,
top_surface_radius: 42.0,
@@ -684,6 +1072,22 @@ async fn persist_jump_hop_tile_asset(
})
}
+fn build_jump_hop_default_character_asset(
+ profile_id: &str,
+ theme_text: &str,
+) -> JumpHopCharacterAsset {
+ JumpHopCharacterAsset {
+ asset_id: format!("{profile_id}-builtin-character"),
+ image_src: "builtin://jump-hop/default-character".to_string(),
+ image_object_key: String::new(),
+ asset_object_id: format!("{profile_id}-builtin-character"),
+ generation_provider: "builtin-three".to_string(),
+ prompt: format!("内置默认 3D 角色:{}", theme_text.trim()),
+ width: 0,
+ height: 0,
+ }
+}
+
async fn persist_jump_hop_generated_image_asset(
state: &AppState,
owner_user_id: &str,
@@ -866,18 +1270,31 @@ fn build_jump_hop_work_play_tracking_draft(
WorkPlayTrackingDraft::runtime_principal("jump-hop", work_id, principal, source_route)
}
+fn is_jump_hop_draft_runtime_mode(runtime_mode: &str) -> bool {
+ runtime_mode.trim().eq_ignore_ascii_case("draft")
+}
+
fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse {
+ let theme_text = normalize_theme_text(&payload.theme_text, &payload.work_title);
JumpHopDraftResponse {
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
profile_id: None,
- work_title: payload.work_title.trim().to_string(),
- work_description: payload.work_description.trim().to_string(),
+ theme_text: theme_text.clone(),
+ work_title: clean_or_default(&payload.work_title, &theme_text),
+ work_description: clean_or_default(
+ &payload.work_description,
+ &format!("{theme_text}主题的俯视角跳跃作品"),
+ ),
theme_tags: normalize_tags(payload.theme_tags.clone()),
difficulty: payload.difficulty.clone(),
style_preset: payload.style_preset.clone(),
- character_prompt: payload.character_prompt.trim().to_string(),
- tile_prompt: payload.tile_prompt.trim().to_string(),
+ default_character: Some(default_jump_hop_character()),
+ character_prompt: clean_or_default(&payload.character_prompt, "内置默认 3D 角色"),
+ tile_prompt: clean_or_default(
+ &payload.tile_prompt,
+ &format!("{theme_text}主题的正面30度视角主题物体图集,物体本身作为跳跃落点"),
+ ),
end_mood_prompt: payload
.end_mood_prompt
.as_ref()
@@ -888,6 +1305,7 @@ fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraft
tile_assets: Vec::new(),
path: None,
cover_composite: None,
+ back_button_asset: None,
generation_status: JumpHopGenerationStatus::Draft,
}
}
@@ -896,13 +1314,7 @@ fn validate_workspace_request(
request_context: &RequestContext,
payload: &JumpHopWorkspaceCreateRequest,
) -> Result<(), Response> {
- ensure_non_empty(request_context, &payload.work_title, "workTitle")?;
- ensure_non_empty(
- request_context,
- &payload.character_prompt,
- "characterPrompt",
- )?;
- ensure_non_empty(request_context, &payload.tile_prompt, "tilePrompt")?;
+ ensure_non_empty(request_context, &payload.theme_text, "themeText")?;
if payload.template_id.trim() != JUMP_HOP_TEMPLATE_ID {
return Err(jump_hop_error_response(
request_context,
@@ -916,6 +1328,32 @@ fn validate_workspace_request(
Ok(())
}
+fn normalize_theme_text(theme_text: &str, fallback: &str) -> String {
+ clean_or_default(theme_text, fallback)
+ .chars()
+ .take(60)
+ .collect::()
+}
+
+fn clean_or_default(value: &str, fallback: &str) -> String {
+ let value = value.trim();
+ if value.is_empty() {
+ fallback.trim().to_string()
+ } else {
+ value.to_string()
+ }
+}
+
+fn default_jump_hop_character() -> shared_contracts::jump_hop::JumpHopDefaultCharacter {
+ shared_contracts::jump_hop::JumpHopDefaultCharacter {
+ character_id: "jump-hop-default-runner".to_string(),
+ display_name: "默认角色".to_string(),
+ model_kind: "builtin-three".to_string(),
+ body_color: "#f59e0b".to_string(),
+ accent_color: "#2563eb".to_string(),
+ }
+}
+
fn ensure_non_empty(
request_context: &RequestContext,
value: &str,
@@ -1019,32 +1457,306 @@ mod tests {
use super::*;
#[test]
- fn jump_hop_tile_atlas_prompt_uses_dedicated_two_by_three_layout() {
- let prompt = build_jump_hop_tile_atlas_prompt("森林石块风格等距地块");
+ fn jump_hop_draft_runtime_mode_detection_matches_client_normalization() {
+ assert!(is_jump_hop_draft_runtime_mode("draft"));
+ assert!(is_jump_hop_draft_runtime_mode(" DRAFT "));
+ assert!(!is_jump_hop_draft_runtime_mode("published"));
+ assert!(!is_jump_hop_draft_runtime_mode(""));
+ }
- assert!(prompt.contains("2行*3列"));
- assert!(prompt.contains("第1行第1列:start 起点地块"));
- assert!(prompt.contains("第2行第3列:accent 视觉强调地块"));
+ #[test]
+ fn jump_hop_tile_atlas_prompt_uses_dedicated_five_by_five_floor_layout() {
+ let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游戏化立体感平台");
+
+ assert!(prompt.contains("五行五列"));
+ assert!(prompt.contains("25个独立"));
+ assert!(prompt.contains("跳跃落点主题物体"));
+ assert!(prompt.contains("不要画成游戏界面"));
+ assert!(prompt.contains("视觉方向为正面30度视角"));
+ assert!(prompt.contains("所有落点素材都必须保持统一的正面30度视角"));
+ assert!(prompt.contains("相机位于物体正前方略高位置"));
+ assert!(prompt.contains("镜头向下约30度"));
+ assert!(prompt.contains("能看到清晰正面、侧壁、下沿和少量上表面"));
+ assert!(prompt.contains("主体正面或侧壁可见面积必须接近或大于顶面面积"));
+ assert!(prompt.contains("顶面只能作为辅助可见面"));
+ assert!(prompt.contains("不要让顶面占据主要视觉"));
+ assert!(prompt.contains("不要画成纯俯视、正上方俯拍、鸟瞰地图块"));
+ assert!(prompt.contains("水果主题尤其要避免俯拍"));
+ assert!(prompt.contains("橙瓣必须看到橙皮正面外侧和果肉厚度"));
+ assert!(prompt.contains("浆果不能只是一个从上往下看的圆形球顶"));
+ assert!(prompt.contains("主题要一眼可见"));
+ assert!(prompt.contains("每个落点都是符合主题且有设计感的立体感物体"));
+ assert!(prompt.contains("清爽自然的休闲手游主题物体素材"));
+ assert!(prompt.contains("符合主题且有设计感的立体感物体"));
+ assert!(prompt.contains("每一个落点都必须直接使用主题物体或合理发散物体"));
+ assert!(prompt.contains("苹果可近似圆"));
+ assert!(prompt.contains("香蕉可近似长条或长方形"));
+ assert!(prompt.contains("主题物体本身就是唯一可落脚体"));
+ assert!(prompt.contains("雪花落点就是一枚带厚度的雪花"));
+ assert!(prompt.contains("不要在主题物体下面再垫任何石头、土块、木板"));
+ assert!(prompt.contains("造型规则完全由物体本身决定"));
+ assert!(prompt.contains("允许圆形、长条、弧形、三角、扇形、块状"));
+ assert!(prompt.contains("只在同一2D/2.5D手绘风格"));
+ assert!(prompt.contains("同一正面30度视角"));
+ assert!(prompt.contains("不使用固定形状脚本"));
+ assert!(prompt.contains("允许用主题物体自身的切面、边缘厚度"));
+ assert!(prompt.contains("禁止额外支撑层、承托底座、脚下地板"));
+ assert!(prompt.contains("四周至少保留18%纯洋红安全留白"));
+ assert!(prompt.contains(JUMP_HOP_TILE_ATLAS_KEY_HEX));
+ assert!(prompt.contains("主体允许使用绿色、白色、雪地、云朵、草地和花朵"));
+ assert!(prompt.contains("不绘制落地投影"));
+ assert!(prompt.contains("不绘制落地投影、接触阴影、方形阴影、洋红阴影"));
+ assert!(prompt.contains("紫色底边、彩色光晕、发光底边"));
+ assert!(prompt.contains("不画分隔线、网格线、容器框或棋盘格"));
+ assert!(prompt.contains("主体边缘不得出现洋红色描边、紫色描边、粉色脏边或彩色阴影"));
+ assert!(prompt.contains("English guardrail"));
+ assert!(prompt.contains("front-facing 30-degree camera-pitch"));
+ assert!(prompt.contains("camera slightly above the object"));
+ assert!(
+ prompt.contains("visible front/side area must be close to or larger than the top area")
+ );
+ assert!(prompt.contains("never produce top-down"));
+ assert!(prompt.contains("each object's native silhouette decides the shape"));
+ assert!(prompt.contains("no extra base under the object"));
+ assert!(prompt.contains("no pedestal"));
+ assert!(prompt.contains("no floor slab"));
+ assert!(prompt.contains("no colored shadow or magenta fringe around objects"));
+ assert!(!prompt.contains("可落脚平台素材"));
+ assert!(!prompt.contains("平台裸素材"));
+ assert!(!prompt.contains("每格一个完整平台"));
+ assert!(!prompt.contains("25个平台"));
+ assert!(!prompt.contains("platform, each"));
+ assert!(!prompt.contains("only platform"));
+ assert!(!prompt.contains("基础轮廓优先做不规则主题剪影"));
+ assert!(!prompt.contains("25格造型要混排"));
+ assert!(!prompt.contains("no simple circles"));
+ assert!(!prompt.contains("no simple squares"));
+ assert!(!prompt.contains("纯绿色绿幕"));
+ assert!(!prompt.contains("#00FF00"));
+ assert!(!prompt.contains("isolated top-down"));
+ assert!(!prompt.contains("按5行*5列"));
+ assert!(!prompt.contains("2D地板图标"));
+ assert!(!prompt.contains("清爽自然的游戏图标"));
+ assert!(!prompt.contains("边缘厚度暗示"));
+ assert!(!prompt.contains("统一投影"));
assert!(!prompt.contains("每个物品生成"));
assert!(!prompt.contains("不同视图"));
}
#[test]
- fn jump_hop_tile_atlas_slices_one_png_per_tile_type() {
- let width = 300;
- let height = 200;
- let colors = [
- [220, 24, 24, 255],
- [240, 150, 32, 255],
- [248, 220, 72, 255],
- [52, 168, 84, 255],
- [38, 132, 255, 255],
- [156, 92, 220, 255],
- ];
+ fn jump_hop_background_prompt_keeps_center_corridor_and_side_atmosphere() {
+ let prompt = build_jump_hop_background_prompt("水果");
+
+ assert!(prompt.contains("9:16竖版跳一跳游戏背景底图"));
+ assert!(prompt.contains("主题关键词严格只使用“水果”"));
+ assert!(prompt.contains("整体风格需要和同一主题的跳一跳游戏元素一致"));
+ assert!(prompt.contains("左右两侧氛围为主"));
+ assert!(prompt.contains("中央纵轴1/2区域必须是清爽的跳跃路径视觉走廊"));
+ assert!(prompt.contains("该区域只能使用少量低对比度纹理"));
+ assert!(prompt.contains("中央纵轴1/2区域要有明显纵深感"));
+ assert!(prompt.contains("两侧可以更有立体感、空间层次和主题氛围"));
+ assert!(prompt.contains("不画任何跳板、地块、落脚物、角色、UI按钮"));
+ assert!(prompt.contains("左上角也不要画返回按钮或任何固定图标"));
+ assert!(prompt.contains("运行态会叠加独立可点击按钮资产"));
+ assert!(prompt.contains("视角保持正面约30度"));
+ assert!(prompt.contains("中央区域需要给运行态地块和陶泥儿角色留出干净可读空间"));
+ assert!(prompt.contains("English guardrail"));
+ assert!(prompt.contains("left and right sides carry the atmosphere"));
+ assert!(prompt.contains("central vertical half-width corridor stays simple"));
+ assert!(prompt.contains("no top-left back button"));
+ assert!(prompt.contains("no platforms"));
+ assert!(prompt.contains("no landing objects"));
+ assert!(prompt.contains("no other UI panels"));
+ }
+
+ #[test]
+ fn jump_hop_back_button_prompt_builds_standalone_green_screen_asset() {
+ let prompt = build_jump_hop_back_button_prompt("水果");
+
+ assert!(prompt.contains("独立透明素材"));
+ assert!(prompt.contains("主题关键词严格只使用“水果”"));
+ assert!(prompt.contains("单个标准圆形图标"));
+ assert!(prompt.contains("内部只有一个居中的向左箭头"));
+ assert!(prompt.contains("不要写“返回”文字"));
+ assert!(prompt.contains("背景必须是单一纯绿色 #00FF00"));
+ assert!(prompt.contains("后续由服务端扣除绿色背景"));
+ assert!(prompt.contains("one standalone circular mobile game back button asset only"));
+ assert!(prompt.contains("solid #00FF00 green-screen background"));
+ }
+
+ #[test]
+ fn jump_hop_background_negative_prompt_blocks_runtime_layer_conflicts() {
+ let negative_prompt = build_jump_hop_background_negative_prompt();
+
+ assert!(negative_prompt.contains("跳板"));
+ assert!(negative_prompt.contains("地块"));
+ assert!(negative_prompt.contains("落脚物"));
+ assert!(negative_prompt.contains("角色"));
+ assert!(negative_prompt.contains("UI按钮"));
+ assert!(negative_prompt.contains("返回按钮"));
+ assert!(negative_prompt.contains("左上角图标"));
+ assert!(negative_prompt.contains("右上角按钮"));
+ assert!(negative_prompt.contains("底部按钮"));
+ assert!(negative_prompt.contains("UI面板"));
+ assert!(negative_prompt.contains("中央堆满元素"));
+ assert!(negative_prompt.contains("中央遮挡"));
+ assert!(negative_prompt.contains("纯俯视地图"));
+ assert!(negative_prompt.contains("平铺俯拍"));
+ }
+
+ #[test]
+ fn jump_hop_legacy_cover_placeholder_is_not_treated_as_background() {
+ assert!(is_jump_hop_legacy_cover_composite_placeholder(
+ "/generated-jump-hop-assets/jump-hop-profile-test/cover-composite.png",
+ ));
+ assert!(is_jump_hop_legacy_cover_composite_placeholder(
+ "/generated-jump-hop-assets/jump-hop-profile-test/cover-composite-123.png",
+ ));
+ assert!(!is_jump_hop_legacy_cover_composite_placeholder(
+ "/generated-jump-hop-assets/jump-hop-profile-test/background/image.png",
+ ));
+ assert!(!is_jump_hop_legacy_cover_composite_placeholder(
+ "/uploads/custom-cover.png",
+ ));
+ }
+
+ #[test]
+ fn jump_hop_generation_prompt_only_rewrites_pokemon_terms() {
+ let background_prompt = build_jump_hop_background_prompt("宝可梦");
+ assert!(background_prompt.contains("主题关键词严格只使用“原创幻想萌宠冒险道具”"));
+ assert!(!background_prompt.contains("宝可梦"));
+
+ let back_button_prompt = build_jump_hop_back_button_prompt("Pokemon");
+ assert!(back_button_prompt.contains("主题关键词严格只使用“原创幻想萌宠冒险道具”"));
+ assert!(!back_button_prompt.contains("Pokemon"));
+
+ let tile_prompt = build_jump_hop_tile_atlas_prompt(
+ "宝可梦",
+ "宝可梦主题的正面30度视角主题物体图集,包含皮卡丘和精灵球装饰",
+ );
+ assert!(tile_prompt.contains("主题为“原创幻想萌宠冒险道具”"));
+ assert!(tile_prompt.contains("画面内容是原创幻想萌宠冒险道具主题"));
+ assert!(tile_prompt.contains("黄色闪电萌宠符号"));
+ assert!(tile_prompt.contains("彩色冒险能量球"));
+ assert!(!tile_prompt.contains("宝可梦"));
+ assert!(!tile_prompt.contains("皮卡丘"));
+ assert!(!tile_prompt.contains("精灵球"));
+
+ let normal_prompt =
+ build_jump_hop_tile_atlas_prompt("水果", "水果主题的正面30度视角主题物体图集");
+ assert!(normal_prompt.contains("主题为“水果”"));
+ assert!(normal_prompt.contains("画面内容是水果主题的正面30度视角主题物体图集"));
+ }
+
+ #[test]
+ fn jump_hop_tile_prompt_sanitizes_legacy_platform_words() {
+ let prompt = build_jump_hop_tile_atlas_prompt(
+ "科幻芯片",
+ "科幻芯片主题的俯视角清爽游戏化立体感平台素材",
+ );
+
+ assert!(prompt.contains("画面内容是科幻芯片主题的正面30度视角清爽游戏化立体感主题物体"));
+ assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角清爽游戏化立体感平台素材"));
+ assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角"));
+
+ let top_down_prompt =
+ build_jump_hop_tile_atlas_prompt("水果", "水果主题鸟瞰视角平铺俯拍圆形平台");
+
+ assert!(top_down_prompt.contains("画面内容是水果主题正面30度视角圆形主题物体"));
+ assert!(!top_down_prompt.contains("画面内容是水果主题鸟瞰视角"));
+ assert!(!top_down_prompt.contains("画面内容是水果主题平铺俯拍"));
+
+ let legacy_prompt = build_jump_hop_tile_atlas_prompt(
+ "雪花",
+ "雪花主题可落脚平台素材,每格一个完整平台,不要底座",
+ );
+
+ assert!(legacy_prompt.contains("雪花主题跳跃落点主题物体"));
+ assert!(legacy_prompt.contains("每格一个完整主题物体"));
+ assert!(legacy_prompt.contains("不要承托物"));
+ assert!(!legacy_prompt.contains("画面内容是雪花主题可落脚平台素材"));
+ assert!(!legacy_prompt.contains("画面内容是雪花主题可落脚"));
+ assert!(!legacy_prompt.contains("画面内容是雪花主题平台"));
+ assert!(!legacy_prompt.contains("画面内容是雪花主题地块"));
+ }
+
+ #[test]
+ fn jump_hop_tile_atlas_negative_prompt_blocks_oily_and_square_shadow_artifacts() {
+ let negative_prompt = build_jump_hop_tile_atlas_negative_prompt();
+
+ assert!(negative_prompt.contains("油亮高光"));
+ assert!(negative_prompt.contains("厚重CG渲染"));
+ assert!(negative_prompt.contains("游戏界面"));
+ assert!(negative_prompt.contains("图标集页面"));
+ assert!(negative_prompt.contains("纯俯视角"));
+ assert!(negative_prompt.contains("正上方视角"));
+ assert!(negative_prompt.contains("鸟瞰视角"));
+ assert!(negative_prompt.contains("顶面占主画面"));
+ assert!(negative_prompt.contains("只看顶面"));
+ assert!(negative_prompt.contains("圆形顶视图"));
+ assert!(negative_prompt.contains("扁平图标"));
+ assert!(negative_prompt.contains("方形阴影"));
+ assert!(negative_prompt.contains("洋红阴影"));
+ assert!(negative_prompt.contains("紫色底边"));
+ assert!(negative_prompt.contains("粉色脏边"));
+ assert!(negative_prompt.contains("洋红色描边"));
+ assert!(negative_prompt.contains("彩色光晕"));
+ assert!(negative_prompt.contains("发光底边"));
+ assert!(negative_prompt.contains("方形底板"));
+ assert!(negative_prompt.contains("额外底座"));
+ assert!(negative_prompt.contains("承托底座"));
+ assert!(negative_prompt.contains("台座"));
+ assert!(negative_prompt.contains("物体摆在平台上"));
+ assert!(negative_prompt.contains("物体下方垫地板"));
+ assert!(!negative_prompt.contains("规则圆盘"));
+ assert!(!negative_prompt.contains("正圆平台"));
+ assert!(!negative_prompt.contains("规则方块"));
+ assert!(!negative_prompt.contains("圆角矩形"));
+ assert!(!negative_prompt.contains("杯垫"));
+ assert!(!negative_prompt.contains("重复圆形"));
+ assert!(!negative_prompt.contains("建筑"));
+ assert!(!negative_prompt.contains("楼房"));
+ }
+
+ #[test]
+ fn jump_hop_tile_slice_keeps_largest_alpha_component() {
+ let mut image = image::RgbaImage::from_pixel(80, 80, image::Rgba([0, 0, 0, 0]));
+ for y in 12..52 {
+ for x in 12..52 {
+ image.put_pixel(x, y, image::Rgba([220, 70, 50, 255]));
+ }
+ }
+ for y in 68..74 {
+ for x in 36..42 {
+ image.put_pixel(x, y, image::Rgba([40, 190, 80, 255]));
+ }
+ }
+
+ let cleaned = keep_jump_hop_largest_alpha_component(image::DynamicImage::ImageRgba8(image))
+ .to_rgba8();
+
+ assert_eq!(cleaned.get_pixel(20, 20).0[3], 255);
+ assert_eq!(
+ cleaned.get_pixel(38, 70).0[3],
+ 0,
+ "相邻格侵入的小碎片不应扩大当前地块切片边界"
+ );
+ }
+
+ #[test]
+ fn jump_hop_tile_atlas_slices_twenty_five_png_tiles() {
+ let width = 500;
+ let height = 500;
let mut atlas = image::RgbaImage::new(width, height);
- for row in 0..2 {
- for col in 0..3 {
- let color = image::Rgba(colors[row * 3 + col]);
+ for row in 0..5 {
+ for col in 0..5 {
+ let index = row * 5 + col;
+ let color = image::Rgba([
+ 40 + index as u8 * 3,
+ 24 + index as u8 * 5,
+ 120 + index as u8 * 2,
+ 255,
+ ]);
for y in row as u32 * 100..(row as u32 + 1) * 100 {
for x in col as u32 * 100..(col as u32 + 1) * 100 {
atlas.put_pixel(x, y, color);
@@ -1064,20 +1776,104 @@ mod tests {
let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice");
- assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_NAMES.len());
+ assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_COUNT);
for (index, slice) in slices.iter().enumerate() {
assert_eq!(slice.tile_type, jump_hop_tile_type_by_index(index));
assert_eq!(
slice.source_atlas_cell,
- format!("row-{}-col-{}", index / 3 + 1, index % 3 + 1)
+ format!("row-{}-col-{}", index / 5 + 1, index % 5 + 1)
);
let decoded = image::load_from_memory(slice.bytes.as_slice())
.expect("tile slice should decode")
.to_rgba8();
+ assert_eq!(
+ decoded.dimensions(),
+ (116, 116),
+ "跳一跳地块切片应在 100x100 单元格外补透明安全边"
+ );
+ let color = [
+ 40 + index as u8 * 3,
+ 24 + index as u8 * 5,
+ 120 + index as u8 * 2,
+ 255,
+ ];
assert!(
- decoded.pixels().any(|pixel| pixel.0 == colors[index]),
+ decoded.pixels().any(|pixel| pixel.0 == color),
"第 {index} 个地块切片应保留对应格子的主体颜色"
);
}
}
+
+ #[test]
+ fn jump_hop_tile_atlas_slicing_preserves_green_and_white_tile_materials() {
+ let width = 500;
+ let height = 500;
+ let mut atlas =
+ image::RgbaImage::from_pixel(width, height, image::Rgba([255, 0, 255, 255]));
+ for row in 0..5 {
+ for col in 0..5 {
+ let color = if row == 0 && col == 0 {
+ image::Rgba([62, 188, 74, 255])
+ } else if row == 0 && col == 1 {
+ image::Rgba([246, 246, 238, 255])
+ } else {
+ image::Rgba([120, 96, 72, 255])
+ };
+ let center_x = col as u32 * 100 + 50;
+ let center_y = row as u32 * 100 + 50;
+ for y in center_y - 24..center_y + 24 {
+ for x in center_x - 28..center_x + 28 {
+ atlas.put_pixel(x, y, color);
+ }
+ }
+ }
+ }
+ let mut encoded = std::io::Cursor::new(Vec::new());
+ image::DynamicImage::ImageRgba8(atlas)
+ .write_to(&mut encoded, image::ImageFormat::Png)
+ .expect("atlas should encode");
+ let image = crate::openai_image_generation::DownloadedOpenAiImage {
+ bytes: encoded.into_inner(),
+ mime_type: "image/png".to_string(),
+ extension: "png".to_string(),
+ };
+
+ let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice");
+ let green_tile = image::load_from_memory(slices[0].bytes.as_slice())
+ .expect("green tile should decode")
+ .to_rgba8();
+ let white_tile = image::load_from_memory(slices[1].bytes.as_slice())
+ .expect("white tile should decode")
+ .to_rgba8();
+
+ assert!(
+ green_tile
+ .pixels()
+ .any(|pixel| pixel.0 == [62, 188, 74, 255])
+ );
+ assert!(
+ white_tile
+ .pixels()
+ .any(|pixel| pixel.0 == [246, 246, 238, 255])
+ );
+ assert_eq!(green_tile.get_pixel(0, 0).0[3], 0);
+ assert_eq!(white_tile.get_pixel(0, 0).0[3], 0);
+ }
+
+ #[test]
+ fn jump_hop_tile_asset_slots_are_unique_for_twenty_five_slices() {
+ let slots = (0..JUMP_HOP_TILE_ITEM_COUNT)
+ .map(jump_hop_tile_asset_slot_name)
+ .collect::>();
+ let unique_slots = slots
+ .iter()
+ .cloned()
+ .collect::>();
+
+ assert_eq!(
+ unique_slots.len(),
+ JUMP_HOP_TILE_ITEM_COUNT,
+ "25 个地块切片必须写入 25 个独立 slot/path,不能按重复的 tile_type 互相覆盖"
+ );
+ }
}
diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs
index c7400bf0..bb1098de 100644
--- a/server-rs/crates/api-server/src/main.rs
+++ b/server-rs/crates/api-server/src/main.rs
@@ -100,25 +100,35 @@ use shared_logging::{OtelConfig, init_tracing};
use socket2::{Domain, Protocol, Socket, Type};
use std::{
collections::HashSet,
- env, fs, io,
+ env, fs, future, io,
net::{SocketAddr, TcpListener as StdTcpListener},
- panic, thread,
+ panic,
+ sync::Arc,
+ thread,
time::Duration,
};
use tokio::net::TcpListener;
use tokio::runtime::Builder as TokioRuntimeBuilder;
use tokio::time::timeout;
-use tracing::{error, info};
+use tracing::{error, info, warn};
use crate::{
app::{build_router, build_spacetime_unavailable_router},
config::AppConfig,
state::{AppState, AppStateInitError},
+ tracking_outbox::TrackingOutbox,
};
const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024;
const AUTH_STORE_STARTUP_RESTORE_TIMEOUT: Duration = Duration::from_secs(8);
+#[derive(Clone)]
+struct ShutdownContext {
+ app_state: Option,
+ tracking_outbox: Option>,
+ outbox_flush_timeout: Duration,
+}
+
fn main() -> Result<(), io::Error> {
// Windows 本地调试下 Axum 路由树和启动恢复链较重,显式放大启动线程栈,避免 debug 构建在进入监听前栈溢出。
let server_thread = thread::Builder::new()
@@ -159,19 +169,33 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
let listen_backlog = config.listen_backlog;
let worker_threads = config.worker_threads;
let otel_enabled = config.otel_enabled;
+ let outbox_flush_timeout = config.shutdown_outbox_flush_timeout;
let listener = build_tcp_listener(bind_address, listen_backlog)?;
- let router = match restore_app_state_for_startup(config).await {
+ let (router, shutdown_context) = match restore_app_state_for_startup(config).await {
Ok(state) => {
state.puzzle_gallery_cache().spawn_cleanup_task();
- if let Some(outbox) = state.tracking_outbox() {
+ let tracking_outbox = state.tracking_outbox();
+ if let Some(outbox) = tracking_outbox.clone() {
outbox.spawn_worker();
}
- build_router(state)
- }
- Err(AppStateInitError::DependencyUnavailable(message)) => {
- build_spacetime_unavailable_router(message)
+ (
+ build_router(state.clone()),
+ ShutdownContext {
+ app_state: Some(state),
+ tracking_outbox,
+ outbox_flush_timeout,
+ },
+ )
}
+ Err(AppStateInitError::DependencyUnavailable(message)) => (
+ build_spacetime_unavailable_router(message),
+ ShutdownContext {
+ app_state: None,
+ tracking_outbox: None,
+ outbox_flush_timeout,
+ },
+ ),
Err(error) => {
return Err(std::io::Error::other(format!(
"初始化应用状态失败:{error}"
@@ -187,7 +211,98 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
"api-server 已完成 tracing 初始化并开始监听"
);
- axum::serve(listener, router).await
+ let result = axum::serve(listener, router)
+ .with_graceful_shutdown(shutdown_signal(shutdown_context.clone()))
+ .await;
+ finalize_shutdown(shutdown_context).await;
+ result
+}
+
+async fn shutdown_signal(context: ShutdownContext) {
+ let signal = wait_for_shutdown_signal().await;
+ if let Some(state) = context.app_state.as_ref() {
+ state.mark_not_ready();
+ }
+ info!(
+ signal,
+ "api-server 收到退出信号,已标记 readiness 不可用并开始排空 HTTP 请求"
+ );
+}
+
+async fn wait_for_shutdown_signal() -> &'static str {
+ #[cfg(unix)]
+ {
+ tokio::select! {
+ signal = wait_for_ctrl_c_signal() => signal,
+ signal = wait_for_sigterm_signal() => signal,
+ }
+ }
+
+ #[cfg(not(unix))]
+ {
+ wait_for_ctrl_c_signal().await
+ }
+}
+
+async fn wait_for_ctrl_c_signal() -> &'static str {
+ if let Err(error) = tokio::signal::ctrl_c().await {
+ error!(error = %error, "监听 SIGINT 失败,无法通过 Ctrl-C 触发优雅退出");
+ future::pending::<()>().await;
+ }
+ "sigint"
+}
+
+#[cfg(unix)]
+async fn wait_for_sigterm_signal() -> &'static str {
+ let mut signal = match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
+ {
+ Ok(signal) => signal,
+ Err(error) => {
+ error!(error = %error, "监听 SIGTERM 失败,无法通过 systemd terminate 触发优雅退出");
+ future::pending::<()>().await;
+ unreachable!("pending future never returns");
+ }
+ };
+ signal.recv().await;
+ "sigterm"
+}
+
+async fn finalize_shutdown(context: ShutdownContext) {
+ if let Some(state) = context.app_state.as_ref() {
+ state.mark_not_ready();
+ }
+
+ let Some(outbox) = context.tracking_outbox else {
+ return;
+ };
+
+ if context.outbox_flush_timeout.is_zero() {
+ warn!("api-server 退出时 tracking outbox flush timeout 为 0,跳过主动 flush");
+ return;
+ }
+
+ let timeout_ms = context
+ .outbox_flush_timeout
+ .as_millis()
+ .min(u128::from(u64::MAX)) as u64;
+ info!(timeout_ms, "api-server 退出前封存并 flush tracking outbox");
+ match timeout(context.outbox_flush_timeout, outbox.flush_for_shutdown()).await {
+ Ok(Ok(())) => {
+ info!("api-server 退出前 tracking outbox flush 完成");
+ }
+ Ok(Err(error)) => {
+ warn!(
+ error = %error,
+ "api-server 退出前 tracking outbox flush 未完成,已保留本地文件等待下次启动重试"
+ );
+ }
+ Err(_) => {
+ warn!(
+ timeout_ms,
+ "api-server 退出前 tracking outbox flush 超时,已保留本地文件等待下次启动重试"
+ );
+ }
+ }
}
fn build_tcp_listener(
diff --git a/server-rs/crates/api-server/src/modules/bark_battle.rs b/server-rs/crates/api-server/src/modules/bark_battle.rs
index 14dac1ae..6184150e 100644
--- a/server-rs/crates/api-server/src/modules/bark_battle.rs
+++ b/server-rs/crates/api-server/src/modules/bark_battle.rs
@@ -1,15 +1,15 @@
use axum::{
Router, middleware,
- routing::{get, post},
+ routing::{delete, get, post},
};
use crate::{
auth::require_bearer_auth,
bark_battle::{
- create_bark_battle_draft, finish_bark_battle_run, generate_bark_battle_image_asset,
- get_bark_battle_run, get_bark_battle_runtime_config, list_bark_battle_gallery,
- list_bark_battle_works, publish_bark_battle_work, start_bark_battle_run,
- update_bark_battle_draft_config,
+ create_bark_battle_draft, delete_bark_battle_work, finish_bark_battle_run,
+ generate_bark_battle_image_asset, get_bark_battle_run, get_bark_battle_runtime_config,
+ list_bark_battle_gallery, list_bark_battle_works, publish_bark_battle_work,
+ start_bark_battle_run, update_bark_battle_draft_config,
},
state::AppState,
};
@@ -51,6 +51,13 @@ pub fn router(state: AppState) -> Router {
require_bearer_auth,
)),
)
+ .route(
+ "/api/runtime/bark-battle/works/{work_id}",
+ delete(delete_bark_battle_work).route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_bearer_auth,
+ )),
+ )
.route(
"/api/runtime/bark-battle/gallery",
get(list_bark_battle_gallery),
diff --git a/server-rs/crates/api-server/src/modules/health.rs b/server-rs/crates/api-server/src/modules/health.rs
index 5e2f19ac..dd488807 100644
--- a/server-rs/crates/api-server/src/modules/health.rs
+++ b/server-rs/crates/api-server/src/modules/health.rs
@@ -1,7 +1,12 @@
use axum::{Router, routing::get};
-use crate::{health::health_check, state::AppState};
+use crate::{
+ health::{health_check, readiness_check},
+ state::AppState,
+};
pub fn router(_state: AppState) -> Router {
- Router::new().route("/healthz", get(health_check))
+ Router::new()
+ .route("/healthz", get(health_check))
+ .route("/readyz", get(readiness_check))
}
diff --git a/server-rs/crates/api-server/src/modules/jump_hop.rs b/server-rs/crates/api-server/src/modules/jump_hop.rs
index 48864e8d..1d69d4c3 100644
--- a/server-rs/crates/api-server/src/modules/jump_hop.rs
+++ b/server-rs/crates/api-server/src/modules/jump_hop.rs
@@ -1,14 +1,16 @@
use axum::{
- Router, middleware,
- routing::{get, post},
+ middleware,
+ routing::{delete, get, post},
+ Router,
};
use crate::{
auth::{require_bearer_auth, require_runtime_principal_auth},
jump_hop::{
- create_jump_hop_session, execute_jump_hop_action, get_jump_hop_gallery_detail,
- get_jump_hop_runtime_work, get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery,
- list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run,
+ create_jump_hop_session, delete_jump_hop_work, execute_jump_hop_action,
+ get_jump_hop_gallery_detail, get_jump_hop_leaderboard, get_jump_hop_runtime_work,
+ get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery, list_jump_hop_works,
+ publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run,
},
state::AppState,
};
@@ -43,6 +45,13 @@ pub fn router(state: AppState) -> Router {
require_bearer_auth,
)),
)
+ .route(
+ "/api/creation/jump-hop/works/{profile_id}",
+ delete(delete_jump_hop_work).route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_bearer_auth,
+ )),
+ )
.route(
"/api/creation/jump-hop/works/{profile_id}/publish",
post(publish_jump_hop_work).route_layer(middleware::from_fn_with_state(
@@ -54,6 +63,13 @@ pub fn router(state: AppState) -> Router {
"/api/runtime/jump-hop/works/{profile_id}",
get(get_jump_hop_runtime_work),
)
+ .route(
+ "/api/runtime/jump-hop/works/{profile_id}/leaderboard",
+ get(get_jump_hop_leaderboard).route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_runtime_principal_auth,
+ )),
+ )
.route(
"/api/runtime/jump-hop/runs",
post(start_jump_hop_run).route_layer(middleware::from_fn_with_state(
diff --git a/server-rs/crates/api-server/src/modules/wooden_fish.rs b/server-rs/crates/api-server/src/modules/wooden_fish.rs
index 556c31b0..b46c4750 100644
--- a/server-rs/crates/api-server/src/modules/wooden_fish.rs
+++ b/server-rs/crates/api-server/src/modules/wooden_fish.rs
@@ -1,16 +1,16 @@
use axum::{
Router, middleware,
- routing::{get, post},
+ routing::{delete, get, post},
};
use crate::{
auth::{require_bearer_auth, require_runtime_principal_auth},
state::AppState,
wooden_fish::{
- checkpoint_wooden_fish_run, create_wooden_fish_session, execute_wooden_fish_action,
- finish_wooden_fish_run, get_wooden_fish_gallery_detail, get_wooden_fish_runtime_work,
- get_wooden_fish_session, list_wooden_fish_gallery, list_wooden_fish_works,
- publish_wooden_fish_work, start_wooden_fish_run,
+ checkpoint_wooden_fish_run, create_wooden_fish_session, delete_wooden_fish_work,
+ execute_wooden_fish_action, finish_wooden_fish_run, get_wooden_fish_gallery_detail,
+ get_wooden_fish_runtime_work, get_wooden_fish_session, list_wooden_fish_gallery,
+ list_wooden_fish_works, publish_wooden_fish_work, start_wooden_fish_run,
},
};
@@ -44,6 +44,13 @@ pub fn router(state: AppState) -> Router {
require_bearer_auth,
)),
)
+ .route(
+ "/api/creation/wooden-fish/works/{profile_id}",
+ delete(delete_wooden_fish_work).route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_bearer_auth,
+ )),
+ )
.route(
"/api/creation/wooden-fish/works/{profile_id}/publish",
post(publish_wooden_fish_work).route_layer(middleware::from_fn_with_state(
diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs
index b4fe7b41..be4b8cb0 100644
--- a/server-rs/crates/api-server/src/puzzle.rs
+++ b/server-rs/crates/api-server/src/puzzle.rs
@@ -1,5 +1,6 @@
use std::{
- collections::BTreeMap,
+ collections::{BTreeMap, HashSet},
+ sync::{Mutex, OnceLock},
time::{Instant, SystemTime, UNIX_EPOCH},
};
@@ -130,6 +131,73 @@ const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024";
const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536";
+static PUZZLE_BACKGROUND_COMPILE_TASKS: OnceLock>> = OnceLock::new();
+
+fn puzzle_background_compile_tasks() -> &'static Mutex> {
+ PUZZLE_BACKGROUND_COMPILE_TASKS.get_or_init(|| Mutex::new(HashSet::new()))
+}
+
+fn try_register_puzzle_background_compile_task(session_id: &str) -> bool {
+ match puzzle_background_compile_tasks().lock() {
+ Ok(mut tasks) => tasks.insert(session_id.to_string()),
+ Err(error) => {
+ tracing::warn!(
+ provider = PUZZLE_AGENT_API_BASE_PROVIDER,
+ session_id,
+ error = %error,
+ "拼图后台生成任务注册表锁已损坏,允许本次任务继续"
+ );
+ true
+ }
+ }
+}
+
+fn unregister_puzzle_background_compile_task(session_id: &str) {
+ match puzzle_background_compile_tasks().lock() {
+ Ok(mut tasks) => {
+ tasks.remove(session_id);
+ }
+ Err(error) => {
+ tracing::warn!(
+ provider = PUZZLE_AGENT_API_BASE_PROVIDER,
+ session_id,
+ error = %error,
+ "拼图后台生成任务注册表解锁失败,忽略清理"
+ );
+ }
+ }
+}
+
+fn has_puzzle_cover_image_src(value: &Option) -> bool {
+ value
+ .as_deref()
+ .map(str::trim)
+ .is_some_and(|value| !value.is_empty())
+}
+
+fn mark_puzzle_initial_generation_started_snapshot(
+ mut session: PuzzleAgentSessionRecord,
+) -> PuzzleAgentSessionRecord {
+ session.stage = "image_refining".to_string();
+ session.progress_percent = session.progress_percent.max(88);
+ if let Some(draft) = session.draft.as_mut() {
+ let draft_needs_cover = !has_puzzle_cover_image_src(&draft.cover_image_src);
+ if let Some(primary_level) = draft.levels.first_mut() {
+ if !has_puzzle_cover_image_src(&primary_level.cover_image_src) {
+ primary_level.generation_status = "generating".to_string();
+ }
+ draft.generation_status = primary_level.generation_status.clone();
+ draft.candidates = primary_level.candidates.clone();
+ draft.selected_candidate_id = primary_level.selected_candidate_id.clone();
+ draft.cover_image_src = primary_level.cover_image_src.clone();
+ draft.cover_asset_id = primary_level.cover_asset_id.clone();
+ } else if draft_needs_cover {
+ draft.generation_status = "generating".to_string();
+ }
+ }
+ session
+}
+
pub(crate) fn format_puzzle_reference_image_upload_bytes(bytes: usize) -> String {
format!("{:.1}MB", bytes as f64 / 1024.0 / 1024.0)
}
diff --git a/server-rs/crates/api-server/src/puzzle/draft.rs b/server-rs/crates/api-server/src/puzzle/draft.rs
index 276a29f5..43bc146d 100644
--- a/server-rs/crates/api-server/src/puzzle/draft.rs
+++ b/server-rs/crates/api-server/src/puzzle/draft.rs
@@ -1177,21 +1177,16 @@ pub(crate) fn find_puzzle_level_for_initial_asset_check<'a>(
.or_else(|| levels.first())
}
-pub(crate) async fn compile_puzzle_draft_with_initial_cover(
+pub(crate) async fn generate_puzzle_initial_cover_from_compiled_session(
state: &PuzzleApiState,
request_context: &RequestContext,
- session_id: String,
+ compiled_session: PuzzleAgentSessionRecord,
owner_user_id: String,
prompt_text: Option<&str>,
reference_image_src: Option<&str>,
image_model: Option<&str>,
now: i64,
) -> Result {
- let compiled_session = state
- .spacetime_client()
- .compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now)
- .await
- .map_err(map_puzzle_compile_error)?;
let draft = compiled_session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
@@ -1419,7 +1414,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
match state
.spacetime_client()
.select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput {
- session_id,
+ session_id: compiled_session.session_id.clone(),
owner_user_id,
level_id: Some(target_level.level_id),
candidate_id: selected_candidate_id,
diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs
index afd6f3cf..873495f7 100644
--- a/server-rs/crates/api-server/src/puzzle/handlers.rs
+++ b/server-rs/crates/api-server/src/puzzle/handlers.rs
@@ -623,7 +623,7 @@ pub async fn execute_puzzle_agent_action(
session_id,
owner_user_id,
error_message,
- failed_at_micros: now,
+ failed_at_micros: current_utc_micros(),
})
.await;
if let Err(error) = result {
@@ -668,27 +668,128 @@ pub async fn execute_puzzle_agent_action(
Err(response) => return Err(response),
};
let session = if ai_redraw {
- execute_billable_asset_operation_with_cost(
- state.root_state(),
- &owner_user_id,
- "puzzle_initial_image",
- &billing_asset_id,
- PUZZLE_IMAGE_GENERATION_POINTS_COST,
- async {
- compile_puzzle_draft_with_initial_cover(
- &state,
- &request_context,
+ if !try_register_puzzle_background_compile_task(&compile_session_id) {
+ tracing::info!(
+ provider = PUZZLE_AGENT_API_BASE_PROVIDER,
+ session_id = %compile_session_id,
+ owner_user_id = %owner_user_id,
+ "拼图首图后台生成任务已存在,本次 action 直接返回生成中会话"
+ );
+ state
+ .spacetime_client()
+ .get_puzzle_agent_session(
+ compile_session_id.clone(),
+ owner_user_id.clone(),
+ )
+ .await
+ .map(mark_puzzle_initial_generation_started_snapshot)
+ .map_err(map_puzzle_client_error)
+ } else {
+ let compiled_session = state
+ .spacetime_client()
+ .compile_puzzle_agent_draft(
compile_session_id.clone(),
owner_user_id.clone(),
- prompt_text,
- primary_reference_image_src,
- payload.image_model.as_deref(),
now,
)
.await
- },
- )
- .await
+ .map_err(map_puzzle_compile_error);
+ match compiled_session {
+ Ok(compiled_session) => {
+ let response_session =
+ mark_puzzle_initial_generation_started_snapshot(
+ compiled_session.clone(),
+ );
+ let background_state = state.clone();
+ let background_request_context = request_context.clone();
+ let background_session_id = compile_session_id.clone();
+ let background_owner_user_id = owner_user_id.clone();
+ let background_prompt_text = prompt_text.map(str::to_string);
+ let background_reference_image_src =
+ primary_reference_image_src.map(str::to_string);
+ let background_image_model = payload.image_model.clone();
+ let background_billing_asset_id =
+ format!("{background_session_id}:compile_puzzle_draft");
+ tokio::spawn(async move {
+ let operation_owner_user_id =
+ background_owner_user_id.clone();
+ let background_root_state =
+ background_state.root_state().clone();
+ let operation_state = background_state.clone();
+ let result = execute_billable_asset_operation_with_cost(
+ &background_root_state,
+ &background_owner_user_id,
+ "puzzle_initial_image",
+ &background_billing_asset_id,
+ PUZZLE_IMAGE_GENERATION_POINTS_COST,
+ async move {
+ generate_puzzle_initial_cover_from_compiled_session(
+ &operation_state,
+ &background_request_context,
+ compiled_session,
+ operation_owner_user_id,
+ background_prompt_text.as_deref(),
+ background_reference_image_src.as_deref(),
+ background_image_model.as_deref(),
+ current_utc_micros(),
+ )
+ .await
+ },
+ )
+ .await;
+ match result {
+ Ok(session) => {
+ tracing::info!(
+ provider = PUZZLE_AGENT_API_BASE_PROVIDER,
+ session_id = %session.session_id,
+ owner_user_id = %background_owner_user_id,
+ "拼图首图后台生成任务完成"
+ );
+ }
+ Err(error) => {
+ let error_message = error.body_text();
+ let failure_result = background_state
+ .spacetime_client()
+ .mark_puzzle_draft_generation_failed(
+ PuzzleDraftCompileFailureRecordInput {
+ session_id: background_session_id.clone(),
+ owner_user_id: background_owner_user_id
+ .clone(),
+ error_message: error_message.clone(),
+ failed_at_micros: current_utc_micros(),
+ },
+ )
+ .await;
+ if let Err(mark_error) = failure_result {
+ tracing::warn!(
+ provider = PUZZLE_AGENT_API_BASE_PROVIDER,
+ session_id = %background_session_id,
+ owner_user_id = %background_owner_user_id,
+ message = %mark_error,
+ "拼图首图后台生成失败态回写失败"
+ );
+ }
+ tracing::warn!(
+ provider = PUZZLE_AGENT_API_BASE_PROVIDER,
+ session_id = %background_session_id,
+ owner_user_id = %background_owner_user_id,
+ message = %error_message,
+ "拼图首图后台生成任务失败"
+ );
+ }
+ }
+ unregister_puzzle_background_compile_task(
+ &background_session_id,
+ );
+ });
+ Ok(response_session)
+ }
+ Err(error) => {
+ unregister_puzzle_background_compile_task(&compile_session_id);
+ Err(error)
+ }
+ }
+ }
} else {
compile_puzzle_draft_with_uploaded_cover(
&state,
@@ -716,7 +817,7 @@ pub async fn execute_puzzle_agent_action(
"compile_puzzle_draft",
"首关拼图草稿",
if ai_redraw {
- "已编译首关草稿、并行生成首关画面和 UI 背景并写入正式草稿。"
+ "已编译首关草稿,并启动首关画面和 UI 资产后台生成。"
} else {
"已编译首关草稿,并直接应用上传图片、生成 UI 背景为第一关图片。"
},
diff --git a/server-rs/crates/api-server/src/puzzle/tests.rs b/server-rs/crates/api-server/src/puzzle/tests.rs
index 86512e7d..b5b902b9 100644
--- a/server-rs/crates/api-server/src/puzzle/tests.rs
+++ b/server-rs/crates/api-server/src/puzzle/tests.rs
@@ -980,6 +980,41 @@ fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
);
}
+#[test]
+fn puzzle_compile_started_snapshot_marks_primary_level_generating() {
+ let mut session = PuzzleAgentSessionRecord {
+ session_id: "puzzle-session-1".to_string(),
+ seed_text: "画面描述:一只猫在雨夜灯牌下回头。".to_string(),
+ current_turn: 1,
+ progress_percent: 88,
+ stage: "draft_ready".to_string(),
+ anchor_pack: test_puzzle_anchor_pack_record(),
+ draft: Some(test_puzzle_draft_record()),
+ messages: Vec::new(),
+ last_assistant_reply: None,
+ published_profile_id: None,
+ suggested_actions: Vec::new(),
+ result_preview: None,
+ updated_at: "2024-01-01T00:00:00Z".to_string(),
+ };
+ {
+ let draft = session.draft.as_mut().expect("draft");
+ draft.generation_status = "idle".to_string();
+ draft.levels[0].generation_status = "idle".to_string();
+ draft.levels[0].cover_image_src = None;
+ draft.levels[0].cover_asset_id = None;
+ }
+
+ let session = mark_puzzle_initial_generation_started_snapshot(session);
+ let draft = session.draft.expect("draft");
+
+ assert_eq!(session.stage, "image_refining");
+ assert_eq!(draft.generation_status, "generating");
+ assert_eq!(draft.levels[0].generation_status, "generating");
+ assert!(draft.cover_image_src.is_none());
+ assert!(draft.levels[0].cover_image_src.is_none());
+}
+
#[test]
fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() {
let prompt = build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景");
diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs
index 4d178687..d4bbb445 100644
--- a/server-rs/crates/api-server/src/state.rs
+++ b/server-rs/crates/api-server/src/state.rs
@@ -2,7 +2,10 @@ use std::{
collections::HashMap,
error::Error,
fmt,
- sync::{Arc, Mutex},
+ sync::{
+ Arc, Mutex,
+ atomic::{AtomicBool, Ordering},
+ },
};
use axum::extract::FromRef;
@@ -229,6 +232,7 @@ pub struct AppStateInner {
// 配置会在后续中间件、路由和平台适配接入时逐步消费。
#[allow(dead_code)]
pub config: AppConfig,
+ ready: AtomicBool,
http_request_permit_pools: HttpRequestPermitPools,
auth_jwt_config: JwtConfig,
admin_runtime: Option,
@@ -399,6 +403,7 @@ impl AppState {
Ok(Self(Arc::new(AppStateInner {
config,
+ ready: AtomicBool::new(true),
http_request_permit_pools,
auth_jwt_config,
admin_runtime,
@@ -447,6 +452,14 @@ impl AppState {
self.http_request_permit_pools.clone()
}
+ pub fn is_ready(&self) -> bool {
+ self.ready.load(Ordering::Acquire)
+ }
+
+ pub fn mark_not_ready(&self) {
+ self.ready.store(false, Ordering::Release);
+ }
+
pub async fn upsert_creation_entry_type_config(
&self,
input: module_runtime::CreationEntryTypeAdminUpsertInput,
diff --git a/server-rs/crates/api-server/src/tracking_outbox.rs b/server-rs/crates/api-server/src/tracking_outbox.rs
index cf2b4a97..eb04762b 100644
--- a/server-rs/crates/api-server/src/tracking_outbox.rs
+++ b/server-rs/crates/api-server/src/tracking_outbox.rs
@@ -159,6 +159,16 @@ impl TrackingOutbox {
});
}
+ pub async fn flush_for_shutdown(&self) -> Result<(), TrackingOutboxError> {
+ {
+ let mut inner = self.inner.lock().await;
+ self.ensure_initialized_locked(&mut inner).await?;
+ self.seal_active_locked(&mut inner, "shutdown").await?;
+ }
+
+ self.flush_sealed_files_once().await
+ }
+
async fn seal_active_if_due(&self) -> Result<(), TrackingOutboxError> {
let mut inner = self.inner.lock().await;
self.ensure_initialized_locked(&mut inner).await?;
@@ -176,7 +186,11 @@ impl TrackingOutbox {
crate::telemetry::update_tracking_outbox_pending_files(sealed_files.len());
for path in sealed_files {
let started_at = Instant::now();
- let metadata = fs::metadata(&path).await?;
+ let metadata = match fs::metadata(&path).await {
+ Ok(metadata) => metadata,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
+ Err(error) => return Err(error.into()),
+ };
let file_bytes = metadata.len();
let events = match read_outbox_events(&path).await {
Ok(events) => events,
@@ -203,7 +217,11 @@ impl TrackingOutbox {
match self.spacetime_client.record_tracking_events(events).await {
Ok(accepted_count) => {
- fs::remove_file(&path).await?;
+ match fs::remove_file(&path).await {
+ Ok(()) => {}
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
+ Err(error) => return Err(error.into()),
+ }
self.subtract_total_bytes(file_bytes).await;
crate::telemetry::record_tracking_outbox_flush(
started_at.elapsed(),
@@ -596,6 +614,34 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
+ #[tokio::test]
+ async fn shutdown_flush_seals_active_file_for_later_retry() {
+ let dir = test_dir("shutdown");
+ let outbox = test_outbox(dir.clone(), 500, 1024 * 1024);
+
+ outbox.enqueue(sample_event("event-1")).await.unwrap();
+ let result = outbox.flush_for_shutdown().await;
+
+ assert!(
+ matches!(result, Err(TrackingOutboxError::Spacetime(_))),
+ "missing test SpacetimeDB should keep sealed file for retry"
+ );
+ assert!(!dir.join(ACTIVE_FILE_NAME).exists());
+ let sealed_count = std::fs::read_dir(&dir)
+ .unwrap()
+ .filter_map(Result::ok)
+ .filter(|entry| {
+ entry
+ .file_name()
+ .to_str()
+ .is_some_and(|name| name.starts_with(SEALED_FILE_PREFIX))
+ })
+ .count();
+ assert_eq!(sealed_count, 1);
+
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
#[test]
fn directory_size_excludes_quarantined_corrupt_files() {
let dir = test_dir("directory-size");
diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs
index a0e60220..1a30d2cb 100644
--- a/server-rs/crates/api-server/src/wooden_fish.rs
+++ b/server-rs/crates/api-server/src/wooden_fish.rs
@@ -229,6 +229,33 @@ pub async fn list_wooden_fish_works(
))
}
+pub async fn delete_wooden_fish_work(
+ State(state): State,
+ Path(profile_id): Path,
+ Extension(request_context): Extension,
+ Extension(authenticated): Extension,
+) -> Result, Response> {
+ ensure_non_empty(&request_context, &profile_id, "profileId")?;
+ let works = state
+ .spacetime_client()
+ .delete_wooden_fish_work(profile_id, authenticated.claims().user_id().to_string())
+ .await
+ .map_err(|error| {
+ wooden_fish_error_response(
+ &request_context,
+ WOODEN_FISH_CREATION_PROVIDER,
+ map_wooden_fish_client_error(error),
+ )
+ })?;
+
+ Ok(json_success_body(
+ Some(&request_context),
+ WoodenFishWorksResponse {
+ items: works.into_iter().map(|work| work.summary).collect(),
+ },
+ ))
+}
+
pub async fn get_wooden_fish_runtime_work(
State(state): State,
Path(profile_id): Path,
diff --git a/server-rs/crates/module-auth/src/domain.rs b/server-rs/crates/module-auth/src/domain.rs
index ac63f925..19c8dae8 100644
--- a/server-rs/crates/module-auth/src/domain.rs
+++ b/server-rs/crates/module-auth/src/domain.rs
@@ -57,10 +57,16 @@ pub struct AuthUser {
pub display_name: String,
#[serde(default)]
pub avatar_url: Option,
+ #[serde(default)]
+ pub phone_number: Option,
pub phone_number_masked: Option,
pub login_method: AuthLoginMethod,
pub binding_status: AuthBindingStatus,
pub wechat_bound: bool,
+ #[serde(default)]
+ pub wechat_display_name: Option,
+ #[serde(default)]
+ pub wechat_account: Option,
pub token_version: u64,
#[serde(default)]
pub created_at: String,
diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs
index 711864bb..fd809932 100644
--- a/server-rs/crates/module-auth/src/lib.rs
+++ b/server-rs/crates/module-auth/src/lib.rs
@@ -97,6 +97,33 @@ struct StoredWechatIdentity {
session_key: Option,
}
+fn hydrate_private_auth_fields(
+ state: &InMemoryAuthStoreState,
+ stored_user: &StoredPasswordUser,
+) -> StoredPasswordUser {
+ let mut hydrated = stored_user.clone();
+ if hydrated.user.phone_number.is_none() {
+ hydrated.user.phone_number = hydrated.phone_number.clone();
+ }
+ let hydrated_wechat_identity = state
+ .wechat_identity_by_provider_uid
+ .values()
+ .find(|identity| identity.user_id == hydrated.user.id);
+ if hydrated.user.wechat_display_name.is_none() {
+ hydrated.user.wechat_display_name = hydrated_wechat_identity
+ .and_then(|identity| identity.display_name.clone())
+ .or_else(|| {
+ (hydrated.user.login_method == AuthLoginMethod::Wechat)
+ .then(|| hydrated.user.display_name.clone())
+ });
+ }
+ if hydrated.user.wechat_account.is_none() {
+ hydrated.user.wechat_account =
+ hydrated_wechat_identity.map(|identity| identity.provider_uid.clone());
+ }
+ hydrated
+}
+
#[derive(Clone, Debug)]
pub struct PasswordEntryService {
store: InMemoryAuthStore,
@@ -1067,7 +1094,7 @@ impl InMemoryAuthStore {
.users_by_username
.values()
.find(|stored_user| stored_user.user.id == user_id)
- .cloned())
+ .map(|stored_user| hydrate_private_auth_fields(&state, stored_user)))
}
fn ensure_orphan_work_owner_user(
@@ -1107,10 +1134,13 @@ impl InMemoryAuthStore {
username: username.clone(),
display_name,
avatar_url: None,
+ phone_number: None,
phone_number_masked: None,
login_method: AuthLoginMethod::Password,
binding_status: AuthBindingStatus::Active,
wechat_bound: false,
+ wechat_display_name: None,
+ wechat_account: None,
token_version: 1,
created_at,
};
@@ -1141,7 +1171,7 @@ impl InMemoryAuthStore {
.users_by_username
.values()
.find(|stored_user| stored_user.user.public_user_code == public_user_code)
- .cloned())
+ .map(|stored_user| hydrate_private_auth_fields(&state, stored_user)))
}
fn find_by_phone_number(
@@ -1152,7 +1182,8 @@ impl InMemoryAuthStore {
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?;
- Ok(Self::resolve_phone_user_locked(&mut state, phone_number))
+ Ok(Self::resolve_phone_user_locked(&mut state, phone_number)
+ .map(|stored_user| hydrate_private_auth_fields(&state, &stored_user)))
}
fn find_by_phone_number_for_password(
@@ -1163,7 +1194,8 @@ impl InMemoryAuthStore {
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
- Ok(Self::resolve_phone_user_locked(&mut state, phone_number))
+ Ok(Self::resolve_phone_user_locked(&mut state, phone_number)
+ .map(|stored_user| hydrate_private_auth_fields(&state, &stored_user)))
}
fn update_user_profile(
@@ -1226,10 +1258,13 @@ impl InMemoryAuthStore {
username: username.clone(),
display_name,
avatar_url: None,
+ phone_number: Some(phone_number.e164.clone()),
phone_number_masked: Some(phone_number.masked_national_number.clone()),
login_method: AuthLoginMethod::Phone,
binding_status: AuthBindingStatus::Active,
wechat_bound: false,
+ wechat_display_name: None,
+ wechat_account: None,
token_version: 1,
created_at,
};
@@ -1278,10 +1313,13 @@ impl InMemoryAuthStore {
username: username.clone(),
display_name,
avatar_url: None,
+ phone_number: Some(phone_number.e164.clone()),
phone_number_masked: Some(phone_number.masked_national_number.clone()),
login_method: AuthLoginMethod::Password,
binding_status: AuthBindingStatus::Active,
wechat_bound: false,
+ wechat_display_name: None,
+ wechat_account: None,
token_version: 1,
created_at,
};
@@ -1327,17 +1365,23 @@ impl InMemoryAuthStore {
.filter(|value| !value.is_empty())
.unwrap_or("微信旅人")
.to_string();
+ let wechat_display_name = normalize_optional_string(profile.display_name.clone())
+ .or_else(|| Some(display_name.clone()));
let username = build_wechat_username(&display_name, &profile.provider_uid);
+ let provider_uid = normalize_required_string(&profile.provider_uid).unwrap_or_default();
let user = AuthUser {
id: user_id.clone(),
public_user_code,
username: username.clone(),
display_name,
avatar_url: avatar_url.clone(),
+ phone_number: None,
phone_number_masked: None,
login_method: AuthLoginMethod::Wechat,
binding_status: AuthBindingStatus::PendingBindPhone,
wechat_bound: true,
+ wechat_display_name,
+ wechat_account: Some(provider_uid.clone()),
token_version: 1,
created_at,
};
@@ -1352,7 +1396,7 @@ impl InMemoryAuthStore {
);
let identity = StoredWechatIdentity {
user_id: user_id.clone(),
- provider_uid: normalize_required_string(&profile.provider_uid).unwrap_or_default(),
+ provider_uid,
provider_union_id: normalize_optional_string(profile.provider_union_id),
display_name: normalize_optional_string(profile.display_name),
avatar_url,
@@ -1390,7 +1434,7 @@ impl InMemoryAuthStore {
.values()
.find(|stored_user| stored_user.user.id == *user_id)
{
- return Ok(Some(stored.user.clone()));
+ return Ok(Some(hydrate_private_auth_fields(&state, stored).user));
}
let Some(identity) = state
@@ -1403,7 +1447,7 @@ impl InMemoryAuthStore {
.users_by_username
.values()
.find(|stored_user| stored_user.user.id == identity.user_id)
- .map(|stored| stored.user.clone()))
+ .map(|stored| hydrate_private_auth_fields(&state, stored).user))
}
fn get_wechat_identity_by_user_id(
@@ -1492,6 +1536,10 @@ impl InMemoryAuthStore {
{
stored_user.user.display_name = display_name.to_string();
}
+ stored_user.user.wechat_account = Some(next_provider_uid.clone());
+ if let Some(display_name) = next_display_name.clone() {
+ stored_user.user.wechat_display_name = Some(display_name);
+ }
stored_user.user.clone()
};
self.persist_wechat_state(&state)?;
@@ -1728,6 +1776,8 @@ impl InMemoryAuthStore {
.find(|identity| identity.user_id == pending_user_id)
.cloned()
.ok_or(PhoneAuthError::UserStateMismatch)?;
+ let pending_wechat_account = pending_wechat_identity.provider_uid.clone();
+ let pending_wechat_display_name = pending_wechat_identity.display_name.clone();
let pending_username = state
.users_by_username
@@ -1756,6 +1806,11 @@ impl InMemoryAuthStore {
.find(|stored| stored.user.id == target_user_id)
.ok_or(PhoneAuthError::UserNotFound)?;
target_user.user.wechat_bound = true;
+ target_user.user.wechat_account = Some(pending_wechat_account);
+ target_user.user.wechat_display_name = pending_wechat_display_name;
+ if target_user.user.phone_number.is_none() {
+ target_user.user.phone_number = target_user.phone_number.clone();
+ }
let next_user = target_user.user.clone();
self.persist_phone_state(&state)?;
@@ -1765,15 +1820,32 @@ impl InMemoryAuthStore {
state
.phone_to_user_id
.insert(phone_number.e164.clone(), pending_user_id.to_string());
+ let bound_wechat_account = state
+ .wechat_identity_by_provider_uid
+ .values()
+ .find(|identity| identity.user_id == pending_user_id)
+ .map(|identity| identity.provider_uid.clone());
+ let bound_wechat_display_name = state
+ .wechat_identity_by_provider_uid
+ .values()
+ .find(|identity| identity.user_id == pending_user_id)
+ .and_then(|identity| identity.display_name.clone());
let stored_user = state
.users_by_username
.values_mut()
.find(|stored| stored.user.id == pending_user_id)
.ok_or(PhoneAuthError::UserNotFound)?;
+ stored_user.user.phone_number = Some(phone_number.e164.clone());
stored_user.user.phone_number_masked = Some(phone_number.masked_national_number.clone());
stored_user.user.binding_status = AuthBindingStatus::Active;
stored_user.user.wechat_bound = true;
+ if stored_user.user.wechat_account.is_none() {
+ stored_user.user.wechat_account = bound_wechat_account;
+ }
+ if stored_user.user.wechat_display_name.is_none() {
+ stored_user.user.wechat_display_name = bound_wechat_display_name;
+ }
stored_user.phone_number = Some(phone_number.e164);
let next_user = stored_user.user.clone();
self.persist_phone_state(&state)?;
@@ -3412,6 +3484,10 @@ mod tests {
AuthBindingStatus::PendingBindPhone
);
assert_eq!(first_wechat.user.username, "微信旅人甲_wx-openid-first");
+ assert_eq!(
+ first_wechat.user.wechat_display_name.as_deref(),
+ Some("微信旅人甲")
+ );
assert!(first_wechat.user.id.starts_with("user_"));
assert!(!first_wechat.user.id.ends_with("00000001"));
@@ -3433,6 +3509,10 @@ mod tests {
assert_ne!(second_wechat.user.id, phone_user.id);
assert_eq!(second_wechat.user.login_method, AuthLoginMethod::Wechat);
assert_eq!(second_wechat.user.username, first_wechat.user.username);
+ assert_eq!(
+ second_wechat.user.wechat_display_name.as_deref(),
+ Some("微信旅人乙")
+ );
}
#[tokio::test]
@@ -3482,6 +3562,10 @@ mod tests {
wechat_user.binding_status,
AuthBindingStatus::PendingBindPhone
);
+ assert_eq!(
+ wechat_user.wechat_display_name.as_deref(),
+ Some("待绑定微信用户")
+ );
assert_ne!(wechat_user.id, phone_user.id);
phone_service
@@ -3509,6 +3593,10 @@ mod tests {
assert_eq!(merged.user.id, phone_user.id);
assert_eq!(merged.user.binding_status, AuthBindingStatus::Active);
assert!(merged.user.wechat_bound);
+ assert_eq!(
+ merged.user.wechat_display_name.as_deref(),
+ Some("待绑定微信用户")
+ );
let reused_wechat_user = wechat_service
.resolve_login(ResolveWechatLoginInput {
@@ -3526,5 +3614,9 @@ mod tests {
assert!(!reused_wechat_user.created);
assert_eq!(reused_wechat_user.user.id, phone_user.id);
assert!(reused_wechat_user.user.wechat_bound);
+ assert_eq!(
+ reused_wechat_user.user.wechat_display_name.as_deref(),
+ Some("已归并微信用户")
+ );
}
}
diff --git a/server-rs/crates/module-jump-hop/src/application.rs b/server-rs/crates/module-jump-hop/src/application.rs
index e7f835bf..71a990d5 100644
--- a/server-rs/crates/module-jump-hop/src/application.rs
+++ b/server-rs/crates/module-jump-hop/src/application.rs
@@ -5,61 +5,18 @@ use crate::{
JumpHopPlatform, JumpHopRunSnapshot, JumpHopRunStatus, JumpHopScoring, JumpHopTileType,
};
+const JUMP_HOP_PLATFORM_SIZE_MULTIPLIER: f32 = 2.0;
+const JUMP_HOP_CHARGE_TO_DISTANCE_RATIO: f32 = 0.008;
+
pub fn generate_jump_hop_path(seed: &str, difficulty: JumpHopDifficulty) -> JumpHopPath {
let config = difficulty_config(difficulty);
- let mut rng = DeterministicRng::new(seed, difficulty.as_str());
- let platform_count = rng.range_u32(config.min_platforms, config.max_platforms) as usize;
- let mut platforms = Vec::with_capacity(platform_count);
- let mut x = 0.0f32;
- let mut y = 0.0f32;
-
- for index in 0..platform_count {
- let tile_type = if index == 0 {
- JumpHopTileType::Start
- } else if index + 1 == platform_count {
- JumpHopTileType::Finish
- } else if index % 7 == 0 {
- JumpHopTileType::Bonus
- } else if index % 5 == 0 {
- JumpHopTileType::Target
- } else if index % 4 == 0 {
- JumpHopTileType::Accent
- } else {
- JumpHopTileType::Normal
- };
- let width = rng.range_f32(config.min_width, config.max_width);
- let height = width * rng.range_f32(0.86, 1.04);
- let landing_radius = width * config.landing_radius_factor;
- let perfect_radius = landing_radius * config.perfect_radius_factor;
-
- platforms.push(JumpHopPlatform {
- platform_id: format!("jump-hop-platform-{index:03}"),
- tile_type,
- x,
- y,
- width,
- height,
- landing_radius,
- perfect_radius,
- score_value: if tile_type == JumpHopTileType::Bonus {
- 180
- } else {
- 100
- },
- });
-
- if index + 1 < platform_count {
- let distance = rng.range_f32(config.min_gap, config.max_gap);
- let direction = if rng.next_u32() % 2 == 0 { 1.0 } else { -1.0 };
- x += distance * 0.62 * direction;
- y += distance;
- }
- }
+ let platform_count = 8usize;
+ let platforms = build_platforms_until(seed, difficulty, platform_count);
JumpHopPath {
seed: seed.trim().to_string(),
difficulty,
- finish_index: platform_count.saturating_sub(1) as u32,
+ finish_index: u32::MAX,
platforms,
camera_preset: "portrait-isometric-9x16".to_string(),
scoring: JumpHopScoring {
@@ -85,6 +42,7 @@ pub fn start_run(
if path.platforms.is_empty() {
return Err(JumpHopError::EmptyPath);
}
+ let path = normalize_jump_hop_path_platform_size(path);
Ok(JumpHopRunSnapshot {
run_id,
@@ -103,7 +61,9 @@ pub fn start_run(
pub fn apply_jump(
run: &JumpHopRunSnapshot,
- charge_ms: u32,
+ drag_distance: f32,
+ drag_vector_x: Option,
+ drag_vector_y: Option,
jumped_at_ms: u64,
) -> Result {
if run.status != JumpHopRunStatus::Playing {
@@ -111,46 +71,42 @@ pub fn apply_jump(
}
let current_index = run.current_platform_index as usize;
let next_index = current_index + 1;
+ let path = extend_jump_hop_path(run.path.clone(), next_index + 3);
let current = run
.path
.platforms
.get(current_index)
.ok_or(JumpHopError::EmptyPath)?;
- let target = run
- .path
+ let target = path
.platforms
.get(next_index)
.ok_or(JumpHopError::NoNextPlatform)?;
- let capped_charge = charge_ms.min(run.path.scoring.max_charge_ms);
- let jump_distance = capped_charge as f32 * run.path.scoring.charge_to_distance_ratio;
+ let capped_drag_distance = drag_distance.clamp(0.0, run.path.scoring.max_charge_ms as f32);
+ let jump_distance = capped_drag_distance * run.path.scoring.charge_to_distance_ratio;
let vector_x = target.x - current.x;
let vector_y = target.y - current.y;
let target_distance = vector_x.hypot(vector_y).max(0.0001);
- let unit_x = vector_x / target_distance;
- let unit_y = vector_y / target_distance;
+ let (unit_x, unit_y) = normalize_jump_direction(
+ drag_vector_x,
+ drag_vector_y,
+ vector_x / target_distance,
+ vector_y / target_distance,
+ );
let landed_x = current.x + unit_x * jump_distance;
let landed_y = current.y + unit_y * jump_distance;
let landing_error = (landed_x - target.x).hypot(landed_y - target.y);
+ let target_landing_radius = target.landing_radius;
let mut next = run.clone();
- let result = if landing_error <= target.perfect_radius {
- if next_index as u32 == run.path.finish_index {
- JumpHopJumpResultKind::Finish
- } else {
- JumpHopJumpResultKind::Perfect
- }
- } else if landing_error <= target.landing_radius {
- if next_index as u32 == run.path.finish_index {
- JumpHopJumpResultKind::Finish
- } else {
- JumpHopJumpResultKind::Hit
- }
+ next.path = path;
+ let result = if landing_error <= target_landing_radius {
+ JumpHopJumpResultKind::Hit
} else {
JumpHopJumpResultKind::Miss
};
next.last_jump = Some(JumpHopLastJump {
- charge_ms: capped_charge,
+ charge_ms: capped_drag_distance.round() as u32,
jump_distance,
target_platform_index: next_index as u32,
landed_x,
@@ -166,23 +122,8 @@ pub fn apply_jump(
}
next.current_platform_index = next_index as u32;
- next.combo = next.combo.saturating_add(1);
- next.score = next.score.saturating_add(target.score_value);
- if matches!(
- result,
- JumpHopJumpResultKind::Perfect | JumpHopJumpResultKind::Finish
- ) {
- next.score = next
- .score
- .saturating_add(run.path.scoring.perfect_bonus)
- .saturating_add(next.combo.saturating_mul(run.path.scoring.hit_bonus));
- } else {
- next.score = next.score.saturating_add(run.path.scoring.hit_bonus);
- }
- if result == JumpHopJumpResultKind::Finish {
- next.status = JumpHopRunStatus::Cleared;
- next.finished_at_ms = Some(jumped_at_ms);
- }
+ next.combo = 0;
+ next.score = next.current_platform_index;
Ok(next)
}
@@ -201,9 +142,31 @@ pub fn restart_run(
)
}
+fn normalize_jump_hop_path_platform_size(mut path: JumpHopPath) -> JumpHopPath {
+ let should_scale_legacy_path = path
+ .platforms
+ .iter()
+ .any(|platform| platform.width < 1.2 && platform.landing_radius < 0.75);
+ if !should_scale_legacy_path {
+ if (path.scoring.charge_to_distance_ratio - JUMP_HOP_CHARGE_TO_DISTANCE_RATIO).abs()
+ > f32::EPSILON
+ {
+ path.scoring.charge_to_distance_ratio = JUMP_HOP_CHARGE_TO_DISTANCE_RATIO;
+ }
+ return path;
+ }
+
+ for platform in &mut path.platforms {
+ platform.width *= JUMP_HOP_PLATFORM_SIZE_MULTIPLIER;
+ platform.height *= JUMP_HOP_PLATFORM_SIZE_MULTIPLIER;
+ platform.landing_radius *= JUMP_HOP_PLATFORM_SIZE_MULTIPLIER;
+ platform.perfect_radius *= JUMP_HOP_PLATFORM_SIZE_MULTIPLIER;
+ }
+ path.scoring.charge_to_distance_ratio = JUMP_HOP_CHARGE_TO_DISTANCE_RATIO;
+ path
+}
+
struct DifficultyConfig {
- min_platforms: u32,
- max_platforms: u32,
min_gap: f32,
max_gap: f32,
min_width: f32,
@@ -214,54 +177,143 @@ struct DifficultyConfig {
max_charge_ms: u32,
}
+fn build_platforms_until(
+ seed: &str,
+ difficulty: JumpHopDifficulty,
+ required_count: usize,
+) -> Vec {
+ let config = difficulty_config(difficulty);
+ let mut platforms = Vec::with_capacity(required_count);
+ let mut x = 0.0f32;
+ let mut y = 0.0f32;
+
+ for index in 0..required_count {
+ platforms.push(build_platform(seed, difficulty, index, x, y, &config));
+ if index + 1 < required_count {
+ let mut rng = DeterministicRng::new(seed, &format!("{}:{index}", difficulty.as_str()));
+ let distance = rng.range_f32(config.min_gap, config.max_gap);
+ let lane = rng.range_f32(0.42, 0.86);
+ let direction = if rng.next_u32() % 2 == 0 { 1.0 } else { -1.0 };
+ x += distance * lane * direction;
+ y += distance;
+ }
+ }
+
+ platforms
+}
+
+fn build_platform(
+ seed: &str,
+ difficulty: JumpHopDifficulty,
+ index: usize,
+ x: f32,
+ y: f32,
+ config: &DifficultyConfig,
+) -> JumpHopPlatform {
+ let mut rng = DeterministicRng::new(seed, &format!("platform:{}:{index}", difficulty.as_str()));
+ let tile_type = if index == 0 {
+ JumpHopTileType::Start
+ } else if index % 11 == 0 {
+ JumpHopTileType::Bonus
+ } else if index % 7 == 0 {
+ JumpHopTileType::Accent
+ } else if index % 3 == 0 {
+ JumpHopTileType::Target
+ } else {
+ JumpHopTileType::Normal
+ };
+ let width = rng.range_f32(config.min_width, config.max_width);
+ let height = width * rng.range_f32(0.88, 1.06);
+ let landing_radius = width * config.landing_radius_factor;
+
+ JumpHopPlatform {
+ platform_id: format!("jump-hop-platform-{index:05}"),
+ tile_type,
+ x,
+ y,
+ width: width * JUMP_HOP_PLATFORM_SIZE_MULTIPLIER,
+ height: height * JUMP_HOP_PLATFORM_SIZE_MULTIPLIER,
+ landing_radius: landing_radius * JUMP_HOP_PLATFORM_SIZE_MULTIPLIER,
+ perfect_radius: landing_radius
+ * config.perfect_radius_factor
+ * JUMP_HOP_PLATFORM_SIZE_MULTIPLIER,
+ score_value: 1,
+ }
+}
+
+fn extend_jump_hop_path(mut path: JumpHopPath, required_count: usize) -> JumpHopPath {
+ if path.platforms.len() >= required_count {
+ return path;
+ }
+ path.platforms = build_platforms_until(&path.seed, path.difficulty, required_count);
+ path.finish_index = u32::MAX;
+ path
+}
+
+fn normalize_jump_direction(
+ drag_vector_x: Option,
+ drag_vector_y: Option,
+ fallback_x: f32,
+ fallback_y: f32,
+) -> (f32, f32) {
+ let Some(drag_x) = drag_vector_x.filter(|value| value.is_finite()) else {
+ return (fallback_x, fallback_y);
+ };
+ let Some(drag_y) = drag_vector_y.filter(|value| value.is_finite()) else {
+ return (fallback_x, fallback_y);
+ };
+ // 前端提交的是屏幕拖拽向量:x 轴同向,y 轴向下为正。
+ // 真实起跳需要“反向弹出”,同时把屏幕 y 翻回世界坐标的向上为正。
+ let jump_x = -drag_x;
+ let jump_y = drag_y;
+ let length = jump_x.hypot(jump_y);
+ if length < 0.0001 {
+ (fallback_x, fallback_y)
+ } else {
+ (jump_x / length, jump_y / length)
+ }
+}
+
fn difficulty_config(difficulty: JumpHopDifficulty) -> DifficultyConfig {
match difficulty {
JumpHopDifficulty::Easy => DifficultyConfig {
- min_platforms: 12,
- max_platforms: 14,
min_gap: 1.0,
max_gap: 1.45,
min_width: 0.9,
max_width: 1.08,
landing_radius_factor: 0.62,
perfect_radius_factor: 0.32,
- charge_to_distance_ratio: 0.004,
+ charge_to_distance_ratio: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO,
max_charge_ms: 700,
},
JumpHopDifficulty::Standard => DifficultyConfig {
- min_platforms: 16,
- max_platforms: 18,
min_gap: 1.22,
max_gap: 1.78,
min_width: 0.82,
max_width: 1.0,
landing_radius_factor: 0.54,
perfect_radius_factor: 0.26,
- charge_to_distance_ratio: 0.004,
+ charge_to_distance_ratio: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO,
max_charge_ms: 780,
},
JumpHopDifficulty::Advanced => DifficultyConfig {
- min_platforms: 20,
- max_platforms: 24,
min_gap: 1.45,
max_gap: 2.05,
min_width: 0.72,
max_width: 0.94,
landing_radius_factor: 0.48,
perfect_radius_factor: 0.22,
- charge_to_distance_ratio: 0.004,
+ charge_to_distance_ratio: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO,
max_charge_ms: 860,
},
JumpHopDifficulty::Challenge => DifficultyConfig {
- min_platforms: 26,
- max_platforms: 32,
min_gap: 1.7,
max_gap: 2.35,
min_width: 0.66,
max_width: 0.88,
landing_radius_factor: 0.42,
perfect_radius_factor: 0.18,
- charge_to_distance_ratio: 0.004,
+ charge_to_distance_ratio: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO,
max_charge_ms: 950,
},
}
@@ -289,13 +341,6 @@ impl DeterministicRng {
(self.state >> 32) as u32
}
- fn range_u32(&mut self, min: u32, max: u32) -> u32 {
- if max <= min {
- return min;
- }
- min + self.next_u32() % (max - min + 1)
- }
-
fn range_f32(&mut self, min: f32, max: f32) -> f32 {
if max <= min {
return min;
@@ -319,14 +364,67 @@ mod tests {
let challenge = generate_jump_hop_path("seed-a", JumpHopDifficulty::Challenge);
assert_eq!(first, second);
- assert!((16..=18).contains(&first.platforms.len()));
- assert!((26..=32).contains(&challenge.platforms.len()));
+ assert_eq!(first.platforms.len(), 8);
+ assert_eq!(challenge.platforms.len(), 8);
assert_eq!(first.platforms.first().unwrap().tile_type.as_str(), "start");
- assert_eq!(first.platforms.last().unwrap().tile_type.as_str(), "finish");
+ assert_eq!(first.finish_index, u32::MAX);
}
#[test]
- fn jump_resolution_distinguishes_perfect_hit_and_miss() {
+ fn difficulty_charge_to_distance_ratio_is_doubled() {
+ let easy = generate_jump_hop_path("seed-ratio-easy", JumpHopDifficulty::Easy);
+ let standard = generate_jump_hop_path("seed-ratio-standard", JumpHopDifficulty::Standard);
+ let advanced = generate_jump_hop_path("seed-ratio-advanced", JumpHopDifficulty::Advanced);
+ let challenge = generate_jump_hop_path("seed-ratio-challenge", JumpHopDifficulty::Challenge);
+
+ assert_eq!(easy.scoring.charge_to_distance_ratio, 0.008);
+ assert_eq!(standard.scoring.charge_to_distance_ratio, 0.008);
+ assert_eq!(advanced.scoring.charge_to_distance_ratio, 0.008);
+ assert_eq!(challenge.scoring.charge_to_distance_ratio, 0.008);
+ }
+
+ #[test]
+ fn generated_platforms_use_double_size_and_landing_radius() {
+ let path = generate_jump_hop_path("seed-size", JumpHopDifficulty::Standard);
+ let first_platform = path.platforms.first().expect("platform should exist");
+
+ assert!(first_platform.width >= 1.64);
+ assert!(first_platform.width <= 2.0);
+ assert!(first_platform.height >= 1.44);
+ assert!(first_platform.height <= 2.12);
+ assert!(first_platform.landing_radius >= 0.88);
+ assert!(first_platform.landing_radius <= 1.08);
+ }
+
+ #[test]
+ fn start_run_normalizes_legacy_single_size_platforms() {
+ let mut path = generate_jump_hop_path("seed-legacy", JumpHopDifficulty::Standard);
+ for platform in &mut path.platforms {
+ platform.width /= 2.0;
+ platform.height /= 2.0;
+ platform.landing_radius /= 2.0;
+ platform.perfect_radius /= 2.0;
+ }
+ let legacy_width = path.platforms[0].width;
+ let legacy_landing_radius = path.platforms[0].landing_radius;
+
+ let run = start_run(
+ "run-legacy".to_string(),
+ "user-legacy".to_string(),
+ "profile-legacy".to_string(),
+ path,
+ 100,
+ )
+ .expect("run should start");
+
+ assert!((run.path.platforms[0].width - legacy_width * 2.0).abs() < 0.0001);
+ assert!(
+ (run.path.platforms[0].landing_radius - legacy_landing_radius * 2.0).abs() < 0.0001
+ );
+ }
+
+ #[test]
+ fn jump_resolution_distinguishes_hit_and_miss() {
let path = generate_jump_hop_path("seed-b", JumpHopDifficulty::Easy);
let run = start_run(
"run-1".to_string(),
@@ -338,25 +436,25 @@ mod tests {
.expect("run should start");
let target = &run.path.platforms[1];
let distance = target.x.hypot(target.y);
- let perfect_charge = (distance / run.path.scoring.charge_to_distance_ratio) as u32;
-
- let perfect = apply_jump(&run, perfect_charge, 200).expect("jump should resolve");
- assert_eq!(
- perfect.last_jump.as_ref().unwrap().result,
- JumpHopJumpResultKind::Perfect
- );
- assert_eq!(perfect.status, JumpHopRunStatus::Playing);
- assert_eq!(perfect.current_platform_index, 1);
+ let target_charge = (distance / run.path.scoring.charge_to_distance_ratio) as u32;
let hit =
- apply_jump(&run, perfect_charge.saturating_add(80), 200).expect("jump should resolve");
+ apply_jump(&run, target_charge as f32, None, None, 200).expect("jump should resolve");
assert_eq!(
hit.last_jump.as_ref().unwrap().result,
JumpHopJumpResultKind::Hit
);
+ assert_eq!(hit.status, JumpHopRunStatus::Playing);
+ assert_eq!(hit.current_platform_index, 1);
- let miss =
- apply_jump(&run, perfect_charge.saturating_add(900), 200).expect("jump should resolve");
+ let miss = apply_jump(
+ &run,
+ target_charge.saturating_add(900) as f32,
+ None,
+ None,
+ 200,
+ )
+ .expect("jump should resolve");
assert_eq!(miss.status, JumpHopRunStatus::Failed);
assert_eq!(
miss.last_jump.as_ref().unwrap().result,
@@ -364,6 +462,39 @@ mod tests {
);
}
+ #[test]
+ fn jump_resolution_uses_screen_drag_y_axis_for_forward_jump_direction() {
+ let path = generate_jump_hop_path("seed-screen-axis", JumpHopDifficulty::Easy);
+ let run = start_run(
+ "run-screen-axis".to_string(),
+ "user-screen-axis".to_string(),
+ "profile-screen-axis".to_string(),
+ path,
+ 100,
+ )
+ .expect("run should start");
+ let current = &run.path.platforms[0];
+ let target = &run.path.platforms[1];
+ let target_distance = (target.x - current.x).hypot(target.y - current.y);
+ let charge = (target_distance / run.path.scoring.charge_to_distance_ratio).round() as u32;
+
+ let result = apply_jump(
+ &run,
+ charge as f32,
+ Some(-(target.x - current.x)),
+ Some(target.y - current.y),
+ 200,
+ )
+ .expect("jump should resolve");
+
+ assert_eq!(result.status, JumpHopRunStatus::Playing);
+ assert_eq!(
+ result.last_jump.as_ref().unwrap().result,
+ JumpHopJumpResultKind::Hit
+ );
+ assert_eq!(result.current_platform_index, 1);
+ }
+
#[test]
fn restart_returns_to_first_platform_and_playing_state() {
let path = generate_jump_hop_path("seed-c", JumpHopDifficulty::Easy);
@@ -392,4 +523,32 @@ mod tests {
assert_eq!(restarted.started_at_ms, 300);
assert!(restarted.finished_at_ms.is_none());
}
+
+ #[test]
+ fn successful_jump_extends_infinite_path_buffer_and_counts_jumps() {
+ let path = generate_jump_hop_path("seed-d", JumpHopDifficulty::Easy);
+ let mut run = start_run(
+ "run-1".to_string(),
+ "user-1".to_string(),
+ "profile-1".to_string(),
+ path,
+ 100,
+ )
+ .expect("run should start");
+
+ for step in 0..9 {
+ let current = &run.path.platforms[run.current_platform_index as usize];
+ let target = &run.path.platforms[run.current_platform_index as usize + 1];
+ let distance = (target.x - current.x).hypot(target.y - current.y);
+ let charge = (distance / run.path.scoring.charge_to_distance_ratio).round() as u32;
+ run = apply_jump(&run, charge as f32, None, None, 200 + step)
+ .expect("jump should resolve");
+ }
+
+ assert_eq!(run.status, JumpHopRunStatus::Playing);
+ assert_eq!(run.current_platform_index, 9);
+ assert_eq!(run.score, 9);
+ assert!(run.path.platforms.len() >= 12);
+ assert!(run.finished_at_ms.is_none());
+ }
}
diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs
index e087675e..2c578dd9 100644
--- a/server-rs/crates/module-runtime/src/application.rs
+++ b/server-rs/crates/module-runtime/src/application.rs
@@ -80,7 +80,7 @@ pub fn default_creation_entry_event_banner_snapshots() -> Vec创作公告
这里可以在后台替换成你的公告 HTML。
"#
+ r#""#
.to_string(),
),
}]
@@ -233,11 +233,16 @@ pub fn resolve_creation_entry_event_banner_responses(
event_banners_json: Option<&str>,
fallback_banner: &CreationEntryEventBannerSnapshot,
) -> Vec {
- event_banners_json
+ let banners = event_banners_json
.and_then(|raw| decode_creation_entry_event_banner_snapshots(raw).ok())
.filter(|banners| !banners.is_empty())
- .unwrap_or_else(|| vec![fallback_banner.clone()])
- .into_iter()
+ .unwrap_or_else(default_creation_entry_event_banner_snapshots);
+ if banners.is_empty() {
+ vec![fallback_banner.clone()]
+ } else {
+ banners
+ }
+ .into_iter()
.map(build_creation_entry_event_banner_response)
.collect()
}
@@ -399,9 +404,9 @@ pub fn default_creation_entry_type_snapshots(
build_default_creation_entry_type_snapshot(
"jump-hop",
"跳一跳",
- "俯视角跳跃闯关",
+ "主题驱动平台跳跃",
"可创建",
- "/creation-type-references/puzzle.webp",
+ "/creation-type-references/jump-hop.webp",
true,
true,
45,
diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs
index d421ce60..32e6a1b7 100644
--- a/server-rs/crates/module-runtime/src/domain.rs
+++ b/server-rs/crates/module-runtime/src/domain.rs
@@ -57,7 +57,7 @@ pub const DEFAULT_CREATION_ENTRY_CATEGORY_LABEL: &str = "热门推荐";
pub const DEFAULT_CREATION_ENTRY_EVENT_TITLE: &str = "主题创作赛";
pub const DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION: &str = "用温暖的色彩,捏出秋天的故事。";
pub const DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC: &str =
- "/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png";
+ "/creation-type-references/puzzle.webp";
pub const DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS: u64 = 58_000;
pub const DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT: &str = "2024.10.20 10:00";
pub const DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT: &str = "2024.11.20 23:59";
diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs
index 03429ce2..3e16b6fe 100644
--- a/server-rs/crates/module-runtime/src/lib.rs
+++ b/server-rs/crates/module-runtime/src/lib.rs
@@ -319,6 +319,35 @@ mod tests {
assert_eq!(banners, default_creation_entry_event_banner_snapshots());
}
+ #[test]
+ fn creation_entry_event_banners_none_returns_default_announcements() {
+ let legacy_banner = CreationEntryEventBannerSnapshot {
+ title: "旧结构化横幅".to_string(),
+ description: "旧库单条字段".to_string(),
+ cover_image_src:
+ "/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png"
+ .to_string(),
+ prize_pool_mud_points: 58_000,
+ starts_at_text: "2024.10.20 10:00".to_string(),
+ ends_at_text: "2024.11.20 23:59".to_string(),
+ render_mode: "structured".to_string(),
+ html_code: None,
+ };
+
+ let banners = resolve_creation_entry_event_banner_responses(None, &legacy_banner);
+
+ assert_eq!(banners.len(), 1);
+ assert_eq!(banners[0].render_mode, "html");
+ assert_eq!(banners[0].title, "创作公告");
+ assert!(banners[0].html_code.as_deref().unwrap_or("").contains("创作公告"));
+ assert!(banners[0]
+ .html_code
+ .as_deref()
+ .unwrap_or("")
+ .contains("/creation-type-references/puzzle.webp"));
+ assert_ne!(banners[0].cover_image_src, legacy_banner.cover_image_src);
+ }
+
#[test]
fn creation_entry_event_banners_json_accepts_announcement_html_code() {
let normalized = normalize_creation_entry_event_banners_json(
@@ -433,6 +462,29 @@ mod tests {
assert_eq!(puzzle_clear.category_id, "recommended");
}
+ #[test]
+ fn default_creation_entry_types_include_jump_hop_theme_only_entry() {
+ let configs = default_creation_entry_type_snapshots(1);
+ let jump_hop = configs
+ .iter()
+ .find(|item| item.id == "jump-hop")
+ .expect("jump-hop creation entry should be seeded");
+
+ assert_eq!(jump_hop.title, "跳一跳");
+ assert_eq!(jump_hop.subtitle, "主题驱动平台跳跃");
+ assert!(jump_hop.visible);
+ assert!(jump_hop.open);
+ assert_eq!(jump_hop.badge, "可创建");
+ assert_eq!(jump_hop.sort_order, 45);
+ assert_eq!(
+ jump_hop.image_src,
+ "/creation-type-references/jump-hop.webp"
+ );
+ assert_eq!(jump_hop.category_id, "recommended");
+ assert_eq!(jump_hop.category_label, "热门推荐");
+ assert_eq!(jump_hop.category_sort_order, 20);
+ }
+
#[test]
fn normalized_clamps_music_volume_into_valid_range() {
let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light);
diff --git a/server-rs/crates/platform-image/Cargo.toml b/server-rs/crates/platform-image/Cargo.toml
index f71fe161..9da08834 100644
--- a/server-rs/crates/platform-image/Cargo.toml
+++ b/server-rs/crates/platform-image/Cargo.toml
@@ -6,9 +6,10 @@ license.workspace = true
[dependencies]
base64 = { workspace = true }
+curl = { workspace = true }
image = { workspace = true, features = ["jpeg", "png", "webp"] }
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
serde_json = { workspace = true }
-tokio = { workspace = true, features = ["time"] }
+tokio = { workspace = true, features = ["io-util", "macros", "net", "time"] }
tracing = { workspace = true }
platform-oss = { workspace = true }
diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs
index 92810d15..d95b4675 100644
--- a/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs
+++ b/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs
@@ -2,13 +2,80 @@ use super::color::{
GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE, GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE,
GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE, clamp_generated_asset_sheet_unit,
compute_generated_asset_sheet_green_screen_score,
+ compute_generated_asset_sheet_key_color_score,
compute_generated_asset_sheet_white_screen_score,
is_generated_asset_sheet_soft_green_matte_pixel, lerp_generated_asset_sheet_channel,
touches_generated_asset_sheet_background_mask,
};
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct GeneratedAssetSheetKeyColor {
+ pub red: u8,
+ pub green: u8,
+ pub blue: u8,
+}
+
+impl GeneratedAssetSheetKeyColor {
+ pub const GREEN_SCREEN: Self = Self {
+ red: 0,
+ green: 255,
+ blue: 0,
+ };
+
+ pub const MAGENTA_SCREEN: Self = Self {
+ red: 255,
+ green: 0,
+ blue: 255,
+ };
+
+ pub fn is_green_screen(self) -> bool {
+ self == Self::GREEN_SCREEN
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct GeneratedAssetSheetAlphaOptions {
+ pub key_color: GeneratedAssetSheetKeyColor,
+ pub remove_near_white_background: bool,
+ pub remove_disconnected_hard_key_background: bool,
+}
+
+impl GeneratedAssetSheetAlphaOptions {
+ pub const fn green_screen() -> Self {
+ Self {
+ key_color: GeneratedAssetSheetKeyColor::GREEN_SCREEN,
+ remove_near_white_background: true,
+ remove_disconnected_hard_key_background: true,
+ }
+ }
+
+ pub const fn jump_hop_magenta_screen() -> Self {
+ Self {
+ key_color: GeneratedAssetSheetKeyColor::MAGENTA_SCREEN,
+ remove_near_white_background: false,
+ remove_disconnected_hard_key_background: false,
+ }
+ }
+}
+
+impl Default for GeneratedAssetSheetAlphaOptions {
+ fn default() -> Self {
+ Self::green_screen()
+ }
+}
+
pub fn apply_generated_asset_sheet_green_screen_alpha(
source: image::DynamicImage,
+) -> image::DynamicImage {
+ apply_generated_asset_sheet_alpha_with_options(
+ source,
+ GeneratedAssetSheetAlphaOptions::default(),
+ )
+}
+
+pub fn apply_generated_asset_sheet_alpha_with_options(
+ source: image::DynamicImage,
+ options: GeneratedAssetSheetAlphaOptions,
) -> image::DynamicImage {
let mut image = source.to_rgba8();
let (width, height) = image.dimensions();
@@ -16,6 +83,7 @@ pub fn apply_generated_asset_sheet_green_screen_alpha(
image.as_mut(),
width as usize,
height as usize,
+ options,
);
image::DynamicImage::ImageRgba8(image)
}
@@ -24,13 +92,14 @@ fn remove_generated_asset_sheet_green_screen_background(
pixels: &mut [u8],
width: usize,
height: usize,
+ options: GeneratedAssetSheetAlphaOptions,
) -> bool {
let pixel_count = width.saturating_mul(height);
if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) {
return false;
}
- let mut green_scores = vec![0.0f32; pixel_count];
+ let mut key_scores = vec![0.0f32; pixel_count];
let mut white_scores = vec![0.0f32; pixel_count];
let mut background_hints = vec![0.0f32; pixel_count];
let mut background_mask = vec![0u8; pixel_count];
@@ -43,16 +112,19 @@ fn remove_generated_asset_sheet_green_screen_background(
let green = pixels[offset + 1];
let blue = pixels[offset + 2];
let alpha = pixels[offset + 3];
- let green_score =
- compute_generated_asset_sheet_green_screen_score([red, green, blue, alpha]);
- let white_score =
- compute_generated_asset_sheet_white_screen_score([red, green, blue, alpha]);
+ let key_score =
+ compute_generated_asset_sheet_key_score([red, green, blue, alpha], options.key_color);
+ let white_score = if options.remove_near_white_background {
+ compute_generated_asset_sheet_white_screen_score([red, green, blue, alpha])
+ } else {
+ 0.0
+ };
let transparency_hint =
clamp_generated_asset_sheet_unit((56.0 - alpha as f32) / 56.0) * 0.75;
- green_scores[pixel_index] = green_score;
+ key_scores[pixel_index] = key_score;
white_scores[pixel_index] = white_score;
- background_hints[pixel_index] = green_score.max(white_score).max(transparency_hint);
+ background_hints[pixel_index] = key_score.max(white_score).max(transparency_hint);
}
let seed_background_pixel =
@@ -62,10 +134,10 @@ fn remove_generated_asset_sheet_green_screen_background(
}
let alpha = pixels[pixel_index * 4 + 3];
let strong_candidate = alpha < 40
- || green_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
+ || key_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
|| (alpha < 224
- && green_scores[pixel_index] > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE)
- || white_scores[pixel_index] > 0.32;
+ && key_scores[pixel_index] > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE)
+ || (options.remove_near_white_background && white_scores[pixel_index] > 0.32);
if !strong_candidate {
return;
}
@@ -113,26 +185,34 @@ fn remove_generated_asset_sheet_green_screen_background(
}
let next_offset = next_pixel_index * 4;
let alpha = pixels[next_offset + 3];
- let green_score = green_scores[next_pixel_index];
+ let key_score = key_scores[next_pixel_index];
let white_score = white_scores[next_pixel_index];
let hint = background_hints[next_pixel_index];
let reachable_soft_edge = hint > 0.08
&& alpha < 224
- && (green_score > 0.04 || white_score > 0.08 || alpha < 180);
- let green_background = green_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
- || (alpha < 224 && green_score > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE);
- if alpha < 40 || green_background || white_score > 0.32 || reachable_soft_edge {
+ && (key_score > 0.04
+ || (options.remove_near_white_background && white_score > 0.08)
+ || alpha < 180);
+ let key_background = key_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
+ || (alpha < 224 && key_score > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE);
+ if alpha < 40
+ || key_background
+ || (options.remove_near_white_background && white_score > 0.32)
+ || reachable_soft_edge
+ {
background_mask[next_pixel_index] = 1;
queue.push(next_pixel_index);
}
}
}
- for pixel_index in 0..pixel_count {
- if background_mask[pixel_index] == 0
- && green_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
- {
- background_mask[pixel_index] = 1;
+ if options.remove_disconnected_hard_key_background {
+ for pixel_index in 0..pixel_count {
+ if background_mask[pixel_index] == 0
+ && key_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
+ {
+ background_mask[pixel_index] = 1;
+ }
}
}
@@ -153,10 +233,14 @@ fn remove_generated_asset_sheet_green_screen_background(
pixels[offset + 2],
pixels[offset + 3],
];
- let green_score = green_scores[pixel_index];
+ let key_score = key_scores[pixel_index];
let white_score = white_scores[pixel_index];
- if !is_generated_asset_sheet_soft_green_matte_pixel(pixel, green_score, white_score)
- {
+ if !is_generated_asset_sheet_soft_key_matte_pixel(
+ pixel,
+ key_score,
+ white_score,
+ options,
+ ) {
continue;
}
if !touches_generated_asset_sheet_background_mask(
@@ -188,12 +272,12 @@ fn remove_generated_asset_sheet_green_screen_background(
continue;
}
let alpha = pixels[pixel_index * 4 + 3];
- let green_score = green_scores[pixel_index];
+ let key_score = key_scores[pixel_index];
let white_score = white_scores[pixel_index];
let hint = background_hints[pixel_index];
let soft_matte_candidate = alpha < 224
- || white_score > 0.10
- || green_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE;
+ || (options.remove_near_white_background && white_score > 0.10)
+ || key_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE;
if hint < GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE || !soft_matte_candidate {
continue;
}
@@ -278,9 +362,9 @@ fn remove_generated_asset_sheet_green_screen_background(
continue;
}
- let green_score = green_scores[pixel_index];
+ let key_score = key_scores[pixel_index];
let white_score = white_scores[pixel_index];
- let contamination = green_score.max(white_score).max(if alpha < 220 {
+ let contamination = key_score.max(white_score).max(if alpha < 220 {
((220 - alpha) as f32 / 220.0) * 0.25
} else {
0.0
@@ -301,30 +385,47 @@ fn remove_generated_asset_sheet_green_screen_background(
let mut red = pixels[offset] as f32;
let mut green = pixels[offset + 1] as f32;
let mut blue = pixels[offset + 2] as f32;
- let blend = clamp_generated_asset_sheet_unit(contamination.max(0.22));
+ let blend = if options.key_color.is_green_screen() {
+ clamp_generated_asset_sheet_unit(contamination.max(0.22))
+ } else {
+ // 中文注释:洋红 / 青色等非绿幕 key 的残留更容易表现成彩边,
+ // 需要比绿幕更强地向主体邻近色收敛,避免 PNG 边缘继续带 key 色。
+ clamp_generated_asset_sheet_unit((key_score * 1.35).max(contamination).max(0.28))
+ };
if let Some((sample_red, sample_green, sample_blue)) = sample {
red = lerp_generated_asset_sheet_channel(red, sample_red as f32, blend);
green = lerp_generated_asset_sheet_channel(green, sample_green as f32, blend);
blue = lerp_generated_asset_sheet_channel(blue, sample_blue as f32, blend);
- if green_score > 0.04 {
+ if options.key_color.is_green_screen() && key_score > 0.04 {
green = green.min(sample_green as f32 + 18.0);
}
- if white_score > 0.1 {
+ if options.remove_near_white_background && white_score > 0.1 {
red = red.min(sample_red as f32 + 26.0);
green = green.min(sample_green as f32 + 26.0);
blue = blue.min(sample_blue as f32 + 26.0);
}
+ if !options.key_color.is_green_screen() && key_score > 0.04 {
+ let defringed = suppress_generated_asset_sheet_key_color_fringe(
+ [red, green, blue],
+ [sample_red as f32, sample_green as f32, sample_blue as f32],
+ key_score,
+ options.key_color,
+ );
+ red = defringed[0];
+ green = defringed[1];
+ blue = defringed[2];
+ }
} else {
- if green_score > 0.04 {
+ if options.key_color.is_green_screen() && key_score > 0.04 {
let toned_green = (green - (green - red.max(blue)) * 0.78)
.round()
.max(red.max(blue));
green = green.min(toned_green).min(red.max(blue) + 18.0);
}
- if white_score > 0.12 {
+ if options.remove_near_white_background && white_score > 0.12 {
let spread = red.max(green).max(blue) - red.min(green).min(blue);
if spread < 20.0 {
let toned_value = ((red + green + blue) / 3.0 * 0.88).round();
@@ -333,10 +434,26 @@ fn remove_generated_asset_sheet_green_screen_background(
blue = blue.min(toned_value);
}
}
+ if !options.key_color.is_green_screen() && key_score > 0.04 {
+ let neutral = (red + green + blue) / 3.0;
+ let defringed = suppress_generated_asset_sheet_key_color_fringe(
+ [red, green, blue],
+ [neutral, neutral, neutral],
+ key_score,
+ options.key_color,
+ );
+ red = defringed[0];
+ green = defringed[1];
+ blue = defringed[2];
+ }
}
let mut next_alpha = alpha;
- let edge_fade = (green_score * 0.35).max(white_score * 0.28);
+ let edge_fade = if options.key_color.is_green_screen() {
+ (key_score * 0.35).max(white_score * 0.28)
+ } else {
+ (key_score * 0.48).max(white_score * 0.28)
+ };
if edge_fade > 0.08 {
next_alpha = ((alpha as f32) * (1.0 - edge_fade)).round() as u8;
if next_alpha < 10 {
@@ -364,6 +481,66 @@ fn remove_generated_asset_sheet_green_screen_background(
changed
}
+pub(super) fn suppress_generated_asset_sheet_key_color_fringe(
+ color: [f32; 3],
+ target: [f32; 3],
+ key_score: f32,
+ key_color: GeneratedAssetSheetKeyColor,
+) -> [f32; 3] {
+ let strength = clamp_generated_asset_sheet_unit(key_score * 1.18);
+ let key_channels = [
+ key_color.red as f32 / 255.0,
+ key_color.green as f32 / 255.0,
+ key_color.blue as f32 / 255.0,
+ ];
+ let mut next = color;
+
+ for index in 0..3 {
+ if key_channels[index] >= 0.66 {
+ let cap = target[index] + 18.0 + (1.0 - strength) * 28.0;
+ next[index] = next[index].min(lerp_generated_asset_sheet_channel(
+ next[index],
+ cap,
+ strength,
+ ));
+ } else if key_channels[index] <= 0.34 {
+ next[index] =
+ lerp_generated_asset_sheet_channel(next[index], target[index], strength * 0.72);
+ }
+ }
+
+ next
+}
+
+fn compute_generated_asset_sheet_key_score(
+ pixel: [u8; 4],
+ key_color: GeneratedAssetSheetKeyColor,
+) -> f32 {
+ if key_color.is_green_screen() {
+ return compute_generated_asset_sheet_green_screen_score(pixel);
+ }
+
+ compute_generated_asset_sheet_key_color_score(
+ pixel,
+ [key_color.red, key_color.green, key_color.blue],
+ )
+}
+
+fn is_generated_asset_sheet_soft_key_matte_pixel(
+ pixel: [u8; 4],
+ key_score: f32,
+ white_score: f32,
+ options: GeneratedAssetSheetAlphaOptions,
+) -> bool {
+ if options.key_color.is_green_screen() {
+ return is_generated_asset_sheet_soft_green_matte_pixel(pixel, key_score, white_score);
+ }
+
+ pixel[3] != 0
+ && key_score >= GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE
+ && (!options.remove_near_white_background || white_score < 0.34)
+}
+
fn collect_generated_asset_sheet_foreground_neighbor_color(
pixels: &[u8],
width: usize,
diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/color.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/color.rs
index 833082ed..ecd5e2c8 100644
--- a/server-rs/crates/platform-image/src/generated_asset_sheets/color.rs
+++ b/server-rs/crates/platform-image/src/generated_asset_sheets/color.rs
@@ -139,6 +139,24 @@ pub(super) fn compute_generated_asset_sheet_green_screen_score(pixel: [u8; 4]) -
.clamp(0.0, 1.0)
}
+pub(super) fn compute_generated_asset_sheet_key_color_score(
+ pixel: [u8; 4],
+ key_color: [u8; 3],
+) -> f32 {
+ if pixel[3] == 0 {
+ return 1.0;
+ }
+
+ let color_distance = (pixel[0] as f32 - key_color[0] as f32).abs()
+ + (pixel[1] as f32 - key_color[1] as f32).abs()
+ + (pixel[2] as f32 - key_color[2] as f32).abs();
+ if color_distance >= 180.0 {
+ return 0.0;
+ }
+
+ clamp_generated_asset_sheet_unit(1.0 - color_distance / 180.0)
+}
+
pub(super) fn compute_generated_asset_sheet_white_screen_score(pixel: [u8; 4]) -> f32 {
if pixel[3] == 0 {
return 1.0;
diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs
index 1abfdff2..fa55105e 100644
--- a/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs
+++ b/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs
@@ -5,7 +5,10 @@ pub mod persist;
pub mod prompt;
pub mod sheet;
-pub use alpha::apply_generated_asset_sheet_green_screen_alpha;
+pub use alpha::{
+ GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetKeyColor,
+ apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha,
+};
pub use error::GeneratedAssetSheetError;
pub use persist::{
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetUpload,
@@ -14,5 +17,6 @@ pub use persist::{
pub use prompt::{GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt};
pub use sheet::{
GeneratedAssetSheetSliceImage, crop_generated_asset_sheet_view_edge_matte,
- slice_generated_asset_sheet, slice_generated_asset_sheet_two_items_per_row,
+ crop_generated_asset_sheet_view_edge_matte_with_options, slice_generated_asset_sheet,
+ slice_generated_asset_sheet_two_items_per_row,
};
diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs
index 8d2a6d6a..740f4f43 100644
--- a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs
+++ b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs
@@ -1,10 +1,14 @@
-use super::alpha::apply_generated_asset_sheet_green_screen_alpha;
+use super::alpha::{
+ GeneratedAssetSheetAlphaOptions, apply_generated_asset_sheet_green_screen_alpha,
+ suppress_generated_asset_sheet_key_color_fringe,
+};
use super::color::{
- is_generated_asset_sheet_foreground_pixel,
+ clamp_generated_asset_sheet_unit, compute_generated_asset_sheet_key_color_score,
+ compute_generated_asset_sheet_white_screen_score, is_generated_asset_sheet_foreground_pixel,
is_generated_asset_sheet_green_contaminated_edge_pixel,
is_generated_asset_sheet_soft_edge_pixel, is_generated_asset_sheet_strong_green_contamination,
is_generated_asset_sheet_view_background_pixel, is_generated_asset_sheet_visible_pixel,
- touches_generated_asset_sheet_background_mask,
+ lerp_generated_asset_sheet_channel, touches_generated_asset_sheet_background_mask,
};
use super::error::GeneratedAssetSheetError;
use image::{GenericImageView, ImageFormat};
@@ -130,10 +134,25 @@ pub fn slice_generated_asset_sheet_two_items_per_row(
pub fn crop_generated_asset_sheet_view_edge_matte(
image: image::DynamicImage,
+) -> image::DynamicImage {
+ crop_generated_asset_sheet_view_edge_matte_with_options(
+ image,
+ GeneratedAssetSheetAlphaOptions::default(),
+ )
+}
+
+pub fn crop_generated_asset_sheet_view_edge_matte_with_options(
+ image: image::DynamicImage,
+ options: GeneratedAssetSheetAlphaOptions,
) -> image::DynamicImage {
let mut image = image.to_rgba8();
let (width, height) = image.dimensions();
- remove_generated_asset_sheet_view_edge_matte(image.as_mut(), width as usize, height as usize);
+ remove_generated_asset_sheet_view_edge_matte(
+ image.as_mut(),
+ width as usize,
+ height as usize,
+ options,
+ );
let bounds = detect_generated_asset_sheet_visible_bounds(&image).unwrap_or_else(|| {
GeneratedAssetSheetCellBounds {
x0: 0,
@@ -359,6 +378,7 @@ fn remove_generated_asset_sheet_view_edge_matte(
pixels: &mut [u8],
width: usize,
height: usize,
+ options: GeneratedAssetSheetAlphaOptions,
) -> bool {
let pixel_count = width.saturating_mul(height);
if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) {
@@ -403,7 +423,7 @@ fn remove_generated_asset_sheet_view_edge_matte(
pixels[offset + 2],
pixels[offset + 3],
];
- if !is_generated_asset_sheet_view_background_pixel(pixel) {
+ if !is_generated_asset_sheet_view_background_pixel_with_options(pixel, options) {
continue;
}
background_mask[pixel_index] = 1;
@@ -434,7 +454,7 @@ fn remove_generated_asset_sheet_view_edge_matte(
pixels[offset + 2],
pixels[offset + 3],
];
- if !is_generated_asset_sheet_view_background_pixel(pixel) {
+ if !is_generated_asset_sheet_view_background_pixel_with_options(pixel, options) {
continue;
}
background_mask[next_pixel_index] = 1;
@@ -452,12 +472,15 @@ fn remove_generated_asset_sheet_view_edge_matte(
continue;
}
let offset = pixel_index * 4;
- if !is_generated_asset_sheet_view_background_pixel([
- pixels[offset],
- pixels[offset + 1],
- pixels[offset + 2],
- pixels[offset + 3],
- ]) {
+ if !is_generated_asset_sheet_view_background_pixel_with_options(
+ [
+ pixels[offset],
+ pixels[offset + 1],
+ pixels[offset + 2],
+ pixels[offset + 3],
+ ],
+ options,
+ ) {
continue;
}
@@ -526,7 +549,7 @@ fn remove_generated_asset_sheet_view_edge_matte(
pixels[offset + 2],
pixels[offset + 3],
];
- if !is_generated_asset_sheet_green_contaminated_edge_pixel(pixel) {
+ if !is_generated_asset_sheet_key_contaminated_edge_pixel(pixel, options) {
continue;
}
if !touches_generated_asset_sheet_background_mask(
@@ -539,7 +562,7 @@ fn remove_generated_asset_sheet_view_edge_matte(
continue;
}
- if is_generated_asset_sheet_strong_green_contamination(pixel) {
+ if is_generated_asset_sheet_strong_key_contamination(pixel, options) {
pixels[offset] = 0;
pixels[offset + 1] = 0;
pixels[offset + 2] = 0;
@@ -559,17 +582,61 @@ fn remove_generated_asset_sheet_view_edge_matte(
y,
&background_mask,
&visible_mask,
+ options,
)
.unwrap_or((
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
));
- let next_red = replacement.0.max(pixels[offset]);
- let next_blue = replacement.2.max(pixels[offset + 2]);
- let next_green = replacement
- .1
- .min(next_red.max(next_blue).saturating_add(12));
+ let (next_red, next_green, next_blue) = if options.key_color.is_green_screen() {
+ let next_red = replacement.0.max(pixels[offset]);
+ let next_blue = replacement.2.max(pixels[offset + 2]);
+ let next_green = replacement
+ .1
+ .min(next_red.max(next_blue).saturating_add(12));
+ (next_red, next_green, next_blue)
+ } else {
+ let key_score = compute_generated_asset_sheet_key_color_score(
+ pixel,
+ [
+ options.key_color.red,
+ options.key_color.green,
+ options.key_color.blue,
+ ],
+ );
+ let blend = clamp_generated_asset_sheet_unit((key_score * 1.25).max(0.36));
+ let red = lerp_generated_asset_sheet_channel(
+ pixels[offset] as f32,
+ replacement.0 as f32,
+ blend,
+ );
+ let green = lerp_generated_asset_sheet_channel(
+ pixels[offset + 1] as f32,
+ replacement.1 as f32,
+ blend,
+ );
+ let blue = lerp_generated_asset_sheet_channel(
+ pixels[offset + 2] as f32,
+ replacement.2 as f32,
+ blend,
+ );
+ let defringed = suppress_generated_asset_sheet_key_color_fringe(
+ [red, green, blue],
+ [
+ replacement.0 as f32,
+ replacement.1 as f32,
+ replacement.2 as f32,
+ ],
+ key_score,
+ options.key_color,
+ );
+ (
+ defringed[0].round().clamp(0.0, 255.0) as u8,
+ defringed[1].round().clamp(0.0, 255.0) as u8,
+ defringed[2].round().clamp(0.0, 255.0) as u8,
+ )
+ };
if next_red != pixels[offset]
|| next_green != pixels[offset + 1]
|| next_blue != pixels[offset + 2]
@@ -605,6 +672,7 @@ fn collect_generated_asset_sheet_visible_neighbor_color(
y: usize,
background_mask: &[u8],
visible_mask: &[u8],
+ options: GeneratedAssetSheetAlphaOptions,
) -> Option<(u8, u8, u8)> {
let mut total_weight = 0.0f32;
let mut total_red = 0.0f32;
@@ -638,8 +706,9 @@ fn collect_generated_asset_sheet_visible_neighbor_color(
pixels[next_offset + 2],
next_alpha,
];
- if is_generated_asset_sheet_green_contaminated_edge_pixel(pixel)
- || is_generated_asset_sheet_soft_edge_pixel(pixel)
+ if is_generated_asset_sheet_key_contaminated_edge_pixel(pixel, options)
+ || (options.key_color.is_green_screen()
+ && is_generated_asset_sheet_soft_edge_pixel(pixel))
{
continue;
}
@@ -670,3 +739,73 @@ fn collect_generated_asset_sheet_visible_neighbor_color(
(total_blue / total_weight).round() as u8,
))
}
+
+fn is_generated_asset_sheet_view_background_pixel_with_options(
+ pixel: [u8; 4],
+ options: GeneratedAssetSheetAlphaOptions,
+) -> bool {
+ if options.key_color.is_green_screen() && options.remove_near_white_background {
+ return is_generated_asset_sheet_view_background_pixel(pixel);
+ }
+
+ if pixel[3] < 16 {
+ return true;
+ }
+
+ if options.key_color.is_green_screen() && is_generated_asset_sheet_soft_edge_pixel(pixel) {
+ return true;
+ }
+
+ if !options.key_color.is_green_screen()
+ && compute_generated_asset_sheet_key_color_score(
+ pixel,
+ [
+ options.key_color.red,
+ options.key_color.green,
+ options.key_color.blue,
+ ],
+ ) > 0.18
+ {
+ return true;
+ }
+
+ options.remove_near_white_background
+ && compute_generated_asset_sheet_white_screen_score(pixel) > 0.18
+}
+
+fn is_generated_asset_sheet_key_contaminated_edge_pixel(
+ pixel: [u8; 4],
+ options: GeneratedAssetSheetAlphaOptions,
+) -> bool {
+ if options.key_color.is_green_screen() {
+ return is_generated_asset_sheet_green_contaminated_edge_pixel(pixel);
+ }
+
+ pixel[3] != 0
+ && compute_generated_asset_sheet_key_color_score(
+ pixel,
+ [
+ options.key_color.red,
+ options.key_color.green,
+ options.key_color.blue,
+ ],
+ ) > 0.18
+}
+
+fn is_generated_asset_sheet_strong_key_contamination(
+ pixel: [u8; 4],
+ options: GeneratedAssetSheetAlphaOptions,
+) -> bool {
+ if options.key_color.is_green_screen() {
+ return is_generated_asset_sheet_strong_green_contamination(pixel);
+ }
+
+ compute_generated_asset_sheet_key_color_score(
+ pixel,
+ [
+ options.key_color.red,
+ options.key_color.green,
+ options.key_color.blue,
+ ],
+ ) > 0.62
+}
diff --git a/server-rs/crates/platform-image/src/vector_engine/client.rs b/server-rs/crates/platform-image/src/vector_engine/client.rs
index b7a31084..6fcd23dd 100644
--- a/server-rs/crates/platform-image/src/vector_engine/client.rs
+++ b/server-rs/crates/platform-image/src/vector_engine/client.rs
@@ -1,15 +1,22 @@
-use reqwest::header;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+const VECTOR_ENGINE_SEND_MAX_ATTEMPTS: u32 = 5;
+const VECTOR_ENGINE_SEND_RETRY_BASE_DELAY_MS: u64 = 500;
+const VECTOR_ENGINE_SEND_RETRY_MAX_JITTER_MS: u64 = 999;
use super::{
constants::{GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER},
+ curl_transport::{
+ map_curl_error, send_vector_engine_json_request_with_curl,
+ send_vector_engine_multipart_edit_request_with_curl,
+ },
error::PlatformImageError,
image_source::resolve_reference_images,
request::{
- build_prompt_with_negative, build_vector_engine_image_request_body, normalize_image_size,
- vector_engine_images_edit_url, vector_engine_images_generation_url,
+ build_vector_engine_image_edit_request_log_params, build_vector_engine_image_request_body,
+ normalize_image_size, vector_engine_images_edit_url, vector_engine_images_generation_url,
},
response::handle_vector_engine_response,
- transport::map_reqwest_error,
types::{GeneratedImages, ReferenceImage, VectorEngineImageSettings},
};
@@ -49,61 +56,69 @@ pub async fn create_vector_engine_image_generation(
reference_images,
);
let started_at = std::time::Instant::now();
- let response = match http_client
- .post(request_url.as_str())
- .header(
- header::AUTHORIZATION,
- format!("Bearer {}", settings.api_key),
+ let mut attempt = 1;
+ let response = loop {
+ match send_vector_engine_json_request_with_curl(
+ request_url.as_str(),
+ settings.api_key.as_str(),
+ &request_body,
+ settings.request_timeout_ms,
)
- .header(header::ACCEPT, "application/json")
- .header(header::CONTENT_TYPE, "application/json")
- .json(&request_body)
- .send()
.await
- {
- Ok(response) => response,
- Err(error) => {
- return Err(map_reqwest_error(
- format!("{failure_context}:创建图片生成任务失败").as_str(),
- request_url.as_str(),
- "request_send",
- error,
- started_at.elapsed().as_millis() as u64,
- Some(prompt.chars().count()),
- Some(reference_images.len()),
- ));
+ {
+ Ok(response) => break response,
+ Err(error) => {
+ if should_retry_vector_engine_curl_send_error(&error, attempt) {
+ retry_vector_engine_send_after_delay(
+ "generation",
+ request_url.as_str(),
+ "request_send",
+ attempt,
+ error.is_timeout(),
+ error.is_connect(),
+ true,
+ false,
+ error.to_string().as_str(),
+ started_at.elapsed().as_millis() as u64,
+ Some(prompt.chars().count()),
+ Some(reference_images.len()),
+ Some(&request_body),
+ )
+ .await;
+ attempt += 1;
+ continue;
+ }
+ return Err(map_curl_error(
+ format!("{failure_context}:创建图片生成任务失败").as_str(),
+ request_url.as_str(),
+ "request_send",
+ error,
+ started_at.elapsed().as_millis() as u64,
+ Some(prompt.chars().count()),
+ Some(reference_images.len()),
+ Some(&request_body),
+ ));
+ }
}
};
- let response_status = response.status();
+ let response_status = response.status;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
endpoint = %request_url,
- status = response_status.as_u16(),
+ status = response_status,
prompt_chars = prompt.chars().count(),
size = %normalized_size,
reference_image_count = reference_images.len(),
+ attempt,
elapsed_ms = started_at.elapsed().as_millis() as u64,
failure_context,
"VectorEngine 图片生成 HTTP 返回"
);
- let response_text = match response.text().await {
- Ok(response_text) => response_text,
- Err(error) => {
- return Err(map_reqwest_error(
- format!("{failure_context}:读取图片生成响应失败").as_str(),
- request_url.as_str(),
- "response_body",
- error,
- started_at.elapsed().as_millis() as u64,
- Some(prompt.chars().count()),
- Some(reference_images.len()),
- ));
- }
- };
+ let response_text = response.body;
handle_vector_engine_response(
http_client,
request_url.as_str(),
- response_status.as_u16(),
+ response_status,
response_text.as_str(),
failure_context,
started_at.elapsed().as_millis() as u64,
@@ -156,83 +171,110 @@ pub async fn create_vector_engine_image_edit_with_references(
let request_url = vector_engine_images_edit_url(settings);
let normalized_size = normalize_image_size(size);
-
- let mut form = reqwest::multipart::Form::new()
- .text("model", GPT_IMAGE_2_MODEL.to_string())
- .text(
- "prompt",
- build_prompt_with_negative(prompt, negative_prompt),
- )
- .text("n", candidate_count.clamp(1, 4).to_string())
- .text("size", normalized_size.clone());
-
- for reference_image in reference_images.iter().take(5) {
- let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone())
- .file_name(reference_image.file_name.clone())
- .mime_str(reference_image.mime_type.as_str())
- .map_err(|error| PlatformImageError::InvalidRequest {
- provider: VECTOR_ENGINE_PROVIDER,
- message: format!("{failure_context}:构造参考图失败:{error}"),
- })?;
- form = form.part("image", image_part);
- }
+ let request_params = build_vector_engine_image_edit_request_log_params(
+ prompt,
+ negative_prompt,
+ normalized_size.as_str(),
+ candidate_count,
+ reference_images,
+ );
let reference_image_count = reference_images.iter().take(5).count();
+ let reference_image_bytes_total: usize = reference_images
+ .iter()
+ .take(5)
+ .map(|image| image.bytes.len())
+ .sum();
let started_at = std::time::Instant::now();
- let response = match http_client
- .post(request_url.as_str())
- .header(
- header::AUTHORIZATION,
- format!("Bearer {}", settings.api_key),
- )
- .header(header::ACCEPT, "application/json")
- .multipart(form)
- .send()
- .await
- {
- Ok(response) => response,
- Err(error) => {
- return Err(map_reqwest_error(
- format!("{failure_context}:创建图片编辑任务失败").as_str(),
- request_url.as_str(),
- "request_send",
- error,
- started_at.elapsed().as_millis() as u64,
- Some(prompt.chars().count()),
- Some(reference_image_count),
- ));
- }
- };
- let response_status = response.status();
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
endpoint = %request_url,
- status = response_status.as_u16(),
+ image_model = GPT_IMAGE_2_MODEL,
+ size = %normalized_size,
+ candidate_count = candidate_count.clamp(1, 4),
+ requested_candidate_count = candidate_count,
+ prompt_chars = prompt.trim().chars().count(),
+ negative_prompt_chars = negative_prompt
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(str::chars)
+ .map(Iterator::count)
+ .unwrap_or_default(),
+ reference_image_count,
+ reference_image_bytes_total,
+ request_params = %request_params,
+ failure_context,
+ "VectorEngine 图片编辑请求参数"
+ );
+ let mut attempt = 1;
+ let response = loop {
+ match send_vector_engine_multipart_edit_request_with_curl(
+ request_url.as_str(),
+ settings.api_key.as_str(),
+ prompt,
+ negative_prompt,
+ normalized_size.as_str(),
+ candidate_count,
+ reference_images,
+ settings.request_timeout_ms,
+ )
+ .await
+ {
+ Ok(response) => break response,
+ Err(error) => {
+ if should_retry_vector_engine_curl_send_error(&error, attempt) {
+ retry_vector_engine_send_after_delay(
+ "edit",
+ request_url.as_str(),
+ "request_send",
+ attempt,
+ error.is_timeout(),
+ error.is_connect(),
+ true,
+ false,
+ error.to_string().as_str(),
+ started_at.elapsed().as_millis() as u64,
+ Some(prompt.chars().count()),
+ Some(reference_image_count),
+ Some(&request_params),
+ )
+ .await;
+ attempt += 1;
+ continue;
+ }
+ return Err(map_curl_error(
+ format!("{failure_context}:创建图片编辑任务失败").as_str(),
+ request_url.as_str(),
+ "request_send",
+ error,
+ started_at.elapsed().as_millis() as u64,
+ Some(prompt.chars().count()),
+ Some(reference_image_count),
+ Some(&request_params),
+ ));
+ }
+ }
+ };
+ let response_status = response.status;
+ tracing::info!(
+ provider = VECTOR_ENGINE_PROVIDER,
+ endpoint = %request_url,
+ status = response_status,
prompt_chars = prompt.chars().count(),
size = %normalized_size,
reference_image_count,
+ reference_image_bytes_total,
+ request_params = %request_params,
+ attempt,
elapsed_ms = started_at.elapsed().as_millis() as u64,
failure_context,
"VectorEngine 图片编辑 HTTP 返回"
);
- let response_text = match response.text().await {
- Ok(response_text) => response_text,
- Err(error) => {
- return Err(map_reqwest_error(
- format!("{failure_context}:读取图片编辑响应失败").as_str(),
- request_url.as_str(),
- "response_body",
- error,
- started_at.elapsed().as_millis() as u64,
- Some(prompt.chars().count()),
- Some(reference_image_count),
- ));
- }
- };
+ let response_text = response.body;
handle_vector_engine_response(
http_client,
request_url.as_str(),
- response_status.as_u16(),
+ response_status,
response_text.as_str(),
failure_context,
started_at.elapsed().as_millis() as u64,
@@ -243,3 +285,84 @@ pub async fn create_vector_engine_image_edit_with_references(
)
.await
}
+
+fn should_retry_vector_engine_curl_send_error(
+ error: &super::curl_transport::VectorEngineCurlError,
+ attempt: u32,
+) -> bool {
+ attempt < VECTOR_ENGINE_SEND_MAX_ATTEMPTS && (error.is_timeout() || error.is_connect())
+}
+
+async fn retry_vector_engine_send_after_delay(
+ request_kind: &'static str,
+ request_url: &str,
+ failure_stage: &'static str,
+ attempt: u32,
+ timeout: bool,
+ connect: bool,
+ request: bool,
+ body: bool,
+ error: &str,
+ elapsed_ms: u64,
+ prompt_chars: Option,
+ reference_image_count: Option,
+ request_params: Option<&serde_json::Value>,
+) {
+ let delay_ms = vector_engine_send_retry_delay_ms(attempt, vector_engine_send_retry_jitter_ms());
+ tracing::warn!(
+ provider = VECTOR_ENGINE_PROVIDER,
+ endpoint = %request_url,
+ request_kind,
+ failure_stage,
+ attempt,
+ max_attempts = VECTOR_ENGINE_SEND_MAX_ATTEMPTS,
+ retry_delay_ms = delay_ms,
+ timeout,
+ connect,
+ request,
+ body,
+ status = 0,
+ error,
+ elapsed_ms,
+ prompt_chars,
+ reference_image_count,
+ request_params = %request_params
+ .map(|value| value.to_string())
+ .unwrap_or_default(),
+ "VectorEngine 图片请求发送失败,准备重试"
+ );
+ tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
+}
+
+fn vector_engine_send_retry_delay_ms(attempt: u32, jitter_ms: u64) -> u64 {
+ let exponential_factor = 1_u64 << attempt.saturating_sub(1).min(10);
+ let bounded_jitter_ms = jitter_ms.min(VECTOR_ENGINE_SEND_RETRY_MAX_JITTER_MS);
+ VECTOR_ENGINE_SEND_RETRY_BASE_DELAY_MS * exponential_factor + bounded_jitter_ms
+}
+
+fn vector_engine_send_retry_jitter_ms() -> u64 {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.subsec_nanos())
+ .unwrap_or_default();
+ u64::from(nanos) % (VECTOR_ENGINE_SEND_RETRY_MAX_JITTER_MS + 1)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn vector_engine_send_retry_policy_allows_four_retries_before_final_attempt() {
+ assert_eq!(VECTOR_ENGINE_SEND_MAX_ATTEMPTS, 5);
+ }
+
+ #[test]
+ fn vector_engine_send_retry_delay_uses_exponential_backoff_with_bounded_jitter() {
+ assert_eq!(vector_engine_send_retry_delay_ms(1, 0), 500);
+ assert_eq!(vector_engine_send_retry_delay_ms(2, 0), 1_000);
+ assert_eq!(vector_engine_send_retry_delay_ms(3, 0), 2_000);
+ assert_eq!(vector_engine_send_retry_delay_ms(4, 0), 4_000);
+ assert_eq!(vector_engine_send_retry_delay_ms(4, 999), 4_999);
+ }
+}
diff --git a/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs b/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs
new file mode 100644
index 00000000..1991bdda
--- /dev/null
+++ b/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs
@@ -0,0 +1,406 @@
+use std::{error::Error, fmt, time::Duration};
+
+use curl::{
+ FormError,
+ easy::{Easy, Form, List},
+};
+use serde_json::Value;
+
+use super::{
+ audit::build_failure_audit,
+ constants::{GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER},
+ error::PlatformImageError,
+ request::build_prompt_with_negative,
+ types::ReferenceImage,
+};
+
+#[derive(Debug)]
+pub(crate) struct VectorEngineCurlResponse {
+ pub(crate) status: u16,
+ pub(crate) body: String,
+}
+
+#[derive(Debug)]
+pub(crate) enum VectorEngineCurlError {
+ Curl(curl::Error),
+ Form(FormError),
+ WorkerJoin(tokio::task::JoinError),
+}
+
+impl VectorEngineCurlError {
+ pub(crate) fn is_timeout(&self) -> bool {
+ match self {
+ Self::Curl(error) => error.is_operation_timedout(),
+ Self::Form(_) | Self::WorkerJoin(_) => false,
+ }
+ }
+
+ pub(crate) fn is_connect(&self) -> bool {
+ match self {
+ Self::Curl(error) => {
+ error.is_couldnt_connect()
+ || error.is_couldnt_resolve_host()
+ || error.is_couldnt_resolve_proxy()
+ }
+ Self::Form(_) | Self::WorkerJoin(_) => false,
+ }
+ }
+}
+
+impl fmt::Display for VectorEngineCurlError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Curl(error) => write!(formatter, "{error}"),
+ Self::Form(error) => write!(formatter, "multipart form error: {error}"),
+ Self::WorkerJoin(error) => write!(formatter, "curl worker join failed: {error}"),
+ }
+ }
+}
+
+impl Error for VectorEngineCurlError {}
+
+impl From for VectorEngineCurlError {
+ fn from(error: curl::Error) -> Self {
+ Self::Curl(error)
+ }
+}
+
+impl From for VectorEngineCurlError {
+ fn from(error: FormError) -> Self {
+ Self::Form(error)
+ }
+}
+
+pub(crate) async fn send_vector_engine_json_request_with_curl(
+ request_url: &str,
+ api_key: &str,
+ request_body: &Value,
+ timeout_ms: u64,
+) -> Result {
+ let request_url = request_url.to_string();
+ let api_key = api_key.to_string();
+ let request_body = request_body.to_string();
+ tokio::task::spawn_blocking(move || {
+ send_json_request_with_curl_blocking(
+ request_url.as_str(),
+ api_key.as_str(),
+ request_body.as_str(),
+ timeout_ms,
+ )
+ })
+ .await
+ .map_err(VectorEngineCurlError::WorkerJoin)?
+}
+
+#[allow(clippy::too_many_arguments)]
+pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl(
+ request_url: &str,
+ api_key: &str,
+ prompt: &str,
+ negative_prompt: Option<&str>,
+ normalized_size: &str,
+ candidate_count: u32,
+ reference_images: &[ReferenceImage],
+ timeout_ms: u64,
+) -> Result {
+ let request_url = request_url.to_string();
+ let api_key = api_key.to_string();
+ let prompt = prompt.to_string();
+ let negative_prompt = negative_prompt.map(str::to_string);
+ let normalized_size = normalized_size.to_string();
+ let reference_images = reference_images.iter().take(5).cloned().collect::>();
+ tokio::task::spawn_blocking(move || {
+ send_multipart_edit_request_with_curl_blocking(
+ request_url.as_str(),
+ api_key.as_str(),
+ prompt.as_str(),
+ negative_prompt.as_deref(),
+ normalized_size.as_str(),
+ candidate_count,
+ reference_images.as_slice(),
+ timeout_ms,
+ )
+ })
+ .await
+ .map_err(VectorEngineCurlError::WorkerJoin)?
+}
+
+pub(crate) fn map_curl_error(
+ context: &str,
+ request_url: &str,
+ failure_stage: &'static str,
+ error: VectorEngineCurlError,
+ latency_ms: u64,
+ prompt_chars: Option,
+ reference_image_count: Option,
+ request_params: Option<&Value>,
+) -> PlatformImageError {
+ let is_timeout = error.is_timeout();
+ let is_connect = error.is_connect();
+ let source = error.to_string();
+ let message = format!("{context}:{source}");
+ let audit = build_failure_audit(
+ request_url,
+ context,
+ failure_stage,
+ None,
+ None,
+ is_timeout,
+ is_connect,
+ message.as_str(),
+ Some(source.clone()),
+ None,
+ Some(latency_ms),
+ prompt_chars,
+ reference_image_count,
+ );
+ tracing::warn!(
+ provider = VECTOR_ENGINE_PROVIDER,
+ endpoint = %request_url,
+ failure_stage,
+ timeout = is_timeout,
+ connect = is_connect,
+ request = true,
+ body = false,
+ status = 0,
+ source = %source,
+ source_chain = %source,
+ source_chain_depth = 1,
+ message = %message,
+ elapsed_ms = latency_ms,
+ prompt_chars,
+ reference_image_count,
+ request_params = %request_params
+ .map(|value| value.to_string())
+ .unwrap_or_default(),
+ "VectorEngine 图片 libcurl 请求失败"
+ );
+
+ PlatformImageError::Request {
+ provider: VECTOR_ENGINE_PROVIDER,
+ message,
+ endpoint: Some(request_url.to_string()),
+ timeout: is_timeout,
+ connect: is_connect,
+ request: true,
+ body: false,
+ status_code: None,
+ source: Some(source),
+ audit: Some(audit),
+ }
+}
+
+fn send_json_request_with_curl_blocking(
+ request_url: &str,
+ api_key: &str,
+ request_body: &str,
+ timeout_ms: u64,
+) -> Result {
+ let mut headers = vector_engine_curl_headers(api_key)?;
+ headers.append("Content-Type: application/json")?;
+ let mut easy = Easy::new();
+ easy.url(request_url)?;
+ easy.post(true)?;
+ easy.http_headers(headers)?;
+ easy.timeout(Duration::from_millis(timeout_ms.max(1)))?;
+ easy.post_fields_copy(request_body.as_bytes())?;
+ Ok(perform_curl_request(easy)?)
+}
+
+#[allow(clippy::too_many_arguments)]
+fn send_multipart_edit_request_with_curl_blocking(
+ request_url: &str,
+ api_key: &str,
+ prompt: &str,
+ negative_prompt: Option<&str>,
+ normalized_size: &str,
+ candidate_count: u32,
+ reference_images: &[ReferenceImage],
+ timeout_ms: u64,
+) -> Result {
+ let mut form = Form::new();
+ form.part("model")
+ .contents(GPT_IMAGE_2_MODEL.as_bytes())
+ .add()?;
+ form.part("prompt")
+ .contents(build_prompt_with_negative(prompt, negative_prompt).as_bytes())
+ .add()?;
+ form.part("n")
+ .contents(candidate_count.clamp(1, 4).to_string().as_bytes())
+ .add()?;
+ form.part("size")
+ .contents(normalized_size.as_bytes())
+ .add()?;
+
+ for reference_image in reference_images {
+ form.part("image")
+ .buffer(
+ reference_image.file_name.as_str(),
+ reference_image.bytes.clone(),
+ )
+ .content_type(reference_image.mime_type.as_str())
+ .add()?;
+ }
+
+ let headers = vector_engine_curl_headers(api_key)?;
+ let mut easy = Easy::new();
+ easy.url(request_url)?;
+ easy.httppost(form)?;
+ easy.http_headers(headers)?;
+ easy.timeout(Duration::from_millis(timeout_ms.max(1)))?;
+ Ok(perform_curl_request(easy)?)
+}
+
+fn vector_engine_curl_headers(api_key: &str) -> Result {
+ let mut headers = List::new();
+ headers.append(format!("Authorization: Bearer {api_key}").as_str())?;
+ headers.append("Accept: application/json")?;
+ Ok(headers)
+}
+
+fn perform_curl_request(mut easy: Easy) -> Result {
+ let mut body = Vec::new();
+ {
+ let mut transfer = easy.transfer();
+ transfer.write_function(|data| {
+ body.extend_from_slice(data);
+ Ok(data.len())
+ })?;
+ transfer.perform()?;
+ }
+ let status = easy.response_code()? as u16;
+ let body = String::from_utf8_lossy(body.as_slice()).into_owned();
+ Ok(VectorEngineCurlResponse { status, body })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::vector_engine::types::ReferenceImage;
+ use tokio::{
+ io::{AsyncReadExt, AsyncWriteExt},
+ net::TcpListener,
+ sync::oneshot,
+ };
+
+ #[tokio::test]
+ async fn vector_engine_curl_transport_posts_json_request() {
+ let (base_url, server, request_rx) = start_single_response_server().await;
+ let response = send_vector_engine_json_request_with_curl(
+ format!("{base_url}/v1/images/generations").as_str(),
+ "test-key",
+ &serde_json::json!({"model":"gpt-image-2","prompt":"测试"}),
+ 1_000,
+ )
+ .await
+ .expect("curl json request should succeed");
+
+ assert_eq!(response.status, 200);
+ assert_eq!(response.body, "{\"data\":[]}");
+ let request = request_rx
+ .await
+ .expect("mock server should capture request");
+ let request_text = String::from_utf8_lossy(request.as_slice());
+ assert!(request_text.contains("Content-Type: application/json"));
+ server.abort();
+ }
+
+ #[tokio::test]
+ async fn vector_engine_curl_transport_posts_multipart_request() {
+ let (base_url, server, request_rx) = start_single_response_server().await;
+ let response = send_vector_engine_multipart_edit_request_with_curl(
+ format!("{base_url}/v1/images/edits").as_str(),
+ "test-key",
+ "测试提示词",
+ None,
+ "1024x1024",
+ 1,
+ &[ReferenceImage {
+ bytes: b"reference".to_vec(),
+ mime_type: "image/png".to_string(),
+ file_name: "reference.png".to_string(),
+ }],
+ 1_000,
+ )
+ .await
+ .expect("curl multipart request should succeed");
+
+ assert_eq!(response.status, 200);
+ assert_eq!(response.body, "{\"data\":[]}");
+ let request = request_rx
+ .await
+ .expect("mock server should capture request");
+ let request_text = String::from_utf8_lossy(request.as_slice());
+ assert!(request_text.contains("name=\"image\"; filename=\"reference.png\""));
+ assert!(request_text.contains("Content-Type: image/png"));
+ assert!(request_text.contains("reference"));
+ server.abort();
+ }
+
+ async fn start_single_response_server() -> (
+ String,
+ tokio::task::JoinHandle<()>,
+ oneshot::Receiver>,
+ ) {
+ let listener = TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("mock server should bind");
+ let addr = listener
+ .local_addr()
+ .expect("mock server addr should be readable");
+ let (request_tx, request_rx) = oneshot::channel();
+ let server = tokio::spawn(async move {
+ let Ok((mut stream, _)) = listener.accept().await else {
+ return;
+ };
+ let mut request = Vec::new();
+ let mut buffer = [0_u8; 4096];
+ loop {
+ let Ok(read) = stream.read(&mut buffer).await else {
+ return;
+ };
+ if read == 0 {
+ return;
+ }
+ request.extend_from_slice(&buffer[..read]);
+ if request.windows(4).any(|window| window == b"\r\n\r\n") {
+ break;
+ }
+ }
+ let header_end = request
+ .windows(4)
+ .position(|window| window == b"\r\n\r\n")
+ .map(|index| index + 4)
+ .unwrap_or(request.len());
+ let headers = String::from_utf8_lossy(&request[..header_end]);
+ let content_length = headers
+ .lines()
+ .find_map(|line| {
+ line.strip_prefix("Content-Length:")
+ .or_else(|| line.strip_prefix("content-length:"))
+ })
+ .and_then(|value| value.trim().parse::().ok())
+ .unwrap_or_default();
+ let expected_len = header_end + content_length;
+ while request.len() < expected_len {
+ let Ok(read) = stream.read(&mut buffer).await else {
+ return;
+ };
+ if read == 0 {
+ break;
+ }
+ request.extend_from_slice(&buffer[..read]);
+ }
+ let _ = request_tx.send(request);
+ let body = "{\"data\":[]}";
+ let response = format!(
+ "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
+ body.len(),
+ body
+ );
+ let _ = stream.write_all(response.as_bytes()).await;
+ });
+
+ (format!("http://{addr}"), server, request_rx)
+ }
+}
diff --git a/server-rs/crates/platform-image/src/vector_engine/mod.rs b/server-rs/crates/platform-image/src/vector_engine/mod.rs
index 6cdcf543..4bd39b30 100644
--- a/server-rs/crates/platform-image/src/vector_engine/mod.rs
+++ b/server-rs/crates/platform-image/src/vector_engine/mod.rs
@@ -1,6 +1,7 @@
mod audit;
mod client;
mod constants;
+mod curl_transport;
mod error;
mod image_source;
mod payload;
diff --git a/server-rs/crates/platform-image/src/vector_engine/request.rs b/server-rs/crates/platform-image/src/vector_engine/request.rs
index 10a5c06b..656d07d7 100644
--- a/server-rs/crates/platform-image/src/vector_engine/request.rs
+++ b/server-rs/crates/platform-image/src/vector_engine/request.rs
@@ -1,6 +1,9 @@
use serde_json::{Map, Value, json};
-use super::{constants::GPT_IMAGE_2_MODEL, types::VectorEngineImageSettings};
+use super::{
+ constants::GPT_IMAGE_2_MODEL,
+ types::{ReferenceImage, VectorEngineImageSettings},
+};
pub fn build_vector_engine_image_request_body(
prompt: &str,
@@ -56,6 +59,52 @@ pub fn vector_engine_images_edit_url(settings: &VectorEngineImageSettings) -> St
}
}
+pub(crate) fn build_vector_engine_image_edit_request_log_params(
+ prompt: &str,
+ negative_prompt: Option<&str>,
+ size: &str,
+ candidate_count: u32,
+ reference_images: &[ReferenceImage],
+) -> Value {
+ let prompt = prompt.trim();
+ let negative_prompt = negative_prompt
+ .map(str::trim)
+ .filter(|value| !value.is_empty());
+ let references: Vec = reference_images
+ .iter()
+ .take(5)
+ .enumerate()
+ .map(|(index, image)| {
+ json!({
+ "index": index,
+ "field": "image",
+ "fileName": image.file_name.as_str(),
+ "mimeType": image.mime_type.as_str(),
+ "bytes": image.bytes.len(),
+ })
+ })
+ .collect();
+ let reference_image_bytes_total: usize = reference_images
+ .iter()
+ .take(5)
+ .map(|image| image.bytes.len())
+ .sum();
+
+ json!({
+ "model": GPT_IMAGE_2_MODEL,
+ "prompt": prompt,
+ "negativePrompt": negative_prompt.unwrap_or_default(),
+ "promptChars": prompt.chars().count(),
+ "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
+ "n": candidate_count.clamp(1, 4),
+ "requestedCandidateCount": candidate_count,
+ "size": size,
+ "referenceImageCount": references.len(),
+ "referenceImageBytesTotal": reference_image_bytes_total,
+ "referenceImages": references,
+ })
+}
+
pub(crate) fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<&str>) -> String {
let prompt = prompt.trim();
let Some(negative_prompt) = negative_prompt
@@ -67,3 +116,49 @@ pub(crate) fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<&
format!("{prompt}\n避免:{negative_prompt}")
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::vector_engine::types::ReferenceImage;
+
+ #[test]
+ fn edit_request_log_params_include_reference_image_sizes_without_secrets_or_bytes() {
+ let params = build_vector_engine_image_edit_request_log_params(
+ " 拼图参考图重绘 ",
+ Some(" 文字,水印 "),
+ "1024x1024",
+ 9,
+ &[
+ ReferenceImage {
+ bytes: vec![1, 2, 3, 4, 5],
+ mime_type: "image/png".to_string(),
+ file_name: "reference-a.png".to_string(),
+ },
+ ReferenceImage {
+ bytes: vec![8; 7],
+ mime_type: "image/jpeg".to_string(),
+ file_name: "reference-b.jpg".to_string(),
+ },
+ ],
+ );
+
+ assert_eq!(params["model"], GPT_IMAGE_2_MODEL);
+ assert_eq!(params["prompt"], "拼图参考图重绘");
+ assert_eq!(params["negativePrompt"], "文字,水印");
+ assert_eq!(params["n"], 4);
+ assert_eq!(params["requestedCandidateCount"], 9);
+ assert_eq!(params["size"], "1024x1024");
+ assert_eq!(params["referenceImageCount"], 2);
+ assert_eq!(params["referenceImageBytesTotal"], 12);
+ assert_eq!(params["referenceImages"][0]["field"], "image");
+ assert_eq!(params["referenceImages"][0]["fileName"], "reference-a.png");
+ assert_eq!(params["referenceImages"][0]["mimeType"], "image/png");
+ assert_eq!(params["referenceImages"][0]["bytes"], 5);
+
+ let serialized = params.to_string();
+ assert!(!serialized.contains("api_key"));
+ assert!(!serialized.contains("Bearer"));
+ assert!(!serialized.contains("[1,2,3,4,5]"));
+ }
+}
diff --git a/server-rs/crates/platform-image/src/vector_engine/transport.rs b/server-rs/crates/platform-image/src/vector_engine/transport.rs
index a40819da..6a63878b 100644
--- a/server-rs/crates/platform-image/src/vector_engine/transport.rs
+++ b/server-rs/crates/platform-image/src/vector_engine/transport.rs
@@ -1,8 +1,7 @@
-use std::{error::Error, time::Duration};
+use std::time::Duration;
use super::{
- audit::build_failure_audit, constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError,
- types::VectorEngineImageSettings,
+ constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError, types::VectorEngineImageSettings,
};
pub fn build_vector_engine_image_http_client(
@@ -18,126 +17,3 @@ pub fn build_vector_engine_image_http_client(
message: format!("构造 VectorEngine 图片生成 HTTP 客户端失败:{error}"),
})
}
-
-pub(super) fn map_reqwest_error(
- context: &str,
- request_url: &str,
- failure_stage: &'static str,
- error: reqwest::Error,
- latency_ms: u64,
- prompt_chars: Option,
- reference_image_count: Option,
-) -> PlatformImageError {
- let is_timeout = error.is_timeout();
- let is_connect = error.is_connect();
- let source_chain_parts = collect_error_source_chain(&error);
- let source = source_chain_parts.first().cloned();
- let source_chain_depth = source_chain_parts.len();
- let source_chain = if source_chain_parts.is_empty() {
- None
- } else {
- Some(source_chain_parts.join(" -> "))
- };
- let message = format!("{context}:{error}");
- let audit = build_failure_audit(
- request_url,
- context,
- failure_stage,
- error.status().map(|status| status.as_u16()),
- None,
- is_timeout,
- is_connect,
- message.as_str(),
- source_chain.clone().or_else(|| source.clone()),
- None,
- Some(latency_ms),
- prompt_chars,
- reference_image_count,
- );
- tracing::warn!(
- provider = VECTOR_ENGINE_PROVIDER,
- endpoint = %request_url,
- failure_stage,
- timeout = is_timeout,
- connect = is_connect,
- request = error.is_request(),
- body = error.is_body(),
- status = error.status().map(|status| status.as_u16()).unwrap_or_default(),
- source = %source.clone().unwrap_or_default(),
- source_chain = %source_chain.clone().unwrap_or_default(),
- source_chain_depth,
- message = %message,
- elapsed_ms = latency_ms,
- prompt_chars,
- reference_image_count,
- "VectorEngine 图片请求发送失败"
- );
-
- PlatformImageError::Request {
- provider: VECTOR_ENGINE_PROVIDER,
- message,
- endpoint: Some(request_url.to_string()),
- timeout: is_timeout,
- connect: is_connect,
- request: error.is_request(),
- body: error.is_body(),
- status_code: error.status().map(|status| status.as_u16()),
- source: source_chain.or(source),
- audit: Some(audit),
- }
-}
-
-fn collect_error_source_chain(error: &(dyn Error + 'static)) -> Vec {
- let mut chain = Vec::new();
- let mut next = error.source();
- while let Some(source) = next {
- chain.push(source.to_string());
- next = source.source();
- }
- chain
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use std::fmt;
-
- #[derive(Debug)]
- struct TestError {
- message: &'static str,
- source: Option>,
- }
-
- impl fmt::Display for TestError {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(self.message)
- }
- }
-
- impl Error for TestError {
- fn source(&self) -> Option<&(dyn Error + 'static)> {
- self.source
- .as_deref()
- .map(|source| source as &(dyn Error + 'static))
- }
- }
-
- #[test]
- fn collect_error_source_chain_keeps_nested_causes() {
- let error = TestError {
- message: "top",
- source: Some(Box::new(TestError {
- message: "middle",
- source: Some(Box::new(TestError {
- message: "bottom",
- source: None,
- })),
- })),
- };
-
- assert_eq!(
- collect_error_source_chain(&error),
- vec!["middle".to_string(), "bottom".to_string()]
- );
- }
-}
diff --git a/server-rs/crates/platform-image/tests/generated_asset_sheets.rs b/server-rs/crates/platform-image/tests/generated_asset_sheets.rs
index df530028..40a0c8f0 100644
--- a/server-rs/crates/platform-image/tests/generated_asset_sheets.rs
+++ b/server-rs/crates/platform-image/tests/generated_asset_sheets.rs
@@ -2,9 +2,11 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
use platform_image::DownloadedImage;
use platform_image::generated_asset_sheets::{
- GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
- GeneratedAssetSheetPromptInput, apply_generated_asset_sheet_green_screen_alpha,
+ GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetPersistInput,
+ GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetPromptInput,
+ apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha,
build_generated_asset_sheet_prompt, crop_generated_asset_sheet_view_edge_matte,
+ crop_generated_asset_sheet_view_edge_matte_with_options,
prepare_generated_asset_sheet_put_request, slice_generated_asset_sheet,
slice_generated_asset_sheet_two_items_per_row,
};
@@ -142,6 +144,140 @@ fn generated_asset_sheet_green_screen_alpha_removes_green_background() {
assert_eq!(cleaned.get_pixel(10, 10).0[3], 255);
}
+#[test]
+fn generated_asset_sheet_magenta_key_preserves_green_white_and_disconnected_key_subject() {
+ let mut sheet = RgbaImage::from_pixel(28, 28, Rgba([255, 0, 255, 255]));
+ for y in 6..22 {
+ for x in 6..14 {
+ sheet.put_pixel(x, y, Rgba([64, 188, 74, 255]));
+ }
+ }
+ for y in 6..22 {
+ for x in 14..22 {
+ sheet.put_pixel(x, y, Rgba([244, 244, 236, 255]));
+ }
+ }
+ for y in 12..16 {
+ for x in 12..16 {
+ sheet.put_pixel(x, y, Rgba([255, 0, 255, 255]));
+ }
+ }
+
+ let cleaned = apply_generated_asset_sheet_alpha_with_options(
+ DynamicImage::ImageRgba8(sheet),
+ GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(),
+ )
+ .to_rgba8();
+
+ assert_eq!(cleaned.get_pixel(0, 0).0[3], 0);
+ assert_eq!(cleaned.get_pixel(8, 8).0[3], 255);
+ assert_eq!(cleaned.get_pixel(18, 8).0[3], 255);
+ assert_eq!(
+ cleaned.get_pixel(13, 13).0[3],
+ 255,
+ "非边缘连通的 key 色像素不应被当成背景清掉"
+ );
+}
+
+#[test]
+fn generated_asset_sheet_magenta_edge_matte_does_not_remove_white_subject() {
+ let mut sheet = RgbaImage::from_pixel(24, 24, Rgba([0, 0, 0, 0]));
+ for y in 2..22 {
+ for x in 2..22 {
+ sheet.put_pixel(x, y, Rgba([246, 246, 240, 255]));
+ }
+ }
+ for y in 0..24 {
+ sheet.put_pixel(0, y, Rgba([255, 0, 255, 255]));
+ sheet.put_pixel(23, y, Rgba([255, 0, 255, 255]));
+ }
+
+ let cleaned = crop_generated_asset_sheet_view_edge_matte_with_options(
+ DynamicImage::ImageRgba8(sheet),
+ GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(),
+ )
+ .to_rgba8();
+
+ assert_eq!(cleaned.get_pixel(1, 1).0[3], 255);
+ assert!(
+ cleaned
+ .pixels()
+ .any(|pixel| pixel.0 == [246, 246, 240, 255])
+ );
+}
+
+#[test]
+fn generated_asset_sheet_magenta_alpha_defringes_pink_halo() {
+ let mut sheet = RgbaImage::from_pixel(24, 24, Rgba([255, 0, 255, 255]));
+ for y in 7..17 {
+ for x in 7..17 {
+ sheet.put_pixel(x, y, Rgba([198, 170, 120, 255]));
+ }
+ }
+ for y in 6..18 {
+ sheet.put_pixel(6, y, Rgba([226, 26, 218, 220]));
+ sheet.put_pixel(17, y, Rgba([226, 26, 218, 220]));
+ }
+ for x in 6..18 {
+ sheet.put_pixel(x, 6, Rgba([226, 26, 218, 220]));
+ sheet.put_pixel(x, 17, Rgba([226, 26, 218, 220]));
+ }
+
+ let cleaned = apply_generated_asset_sheet_alpha_with_options(
+ DynamicImage::ImageRgba8(sheet),
+ GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(),
+ )
+ .to_rgba8();
+ let edge = cleaned.get_pixel(6, 12).0;
+
+ assert_eq!(cleaned.get_pixel(0, 0).0[3], 0);
+ assert_eq!(cleaned.get_pixel(12, 12).0, [198, 170, 120, 255]);
+ if edge[3] > 0 {
+ assert!(
+ edge[0].saturating_sub(edge[1]) <= 76,
+ "红色 key 通道残留过强:{edge:?}"
+ );
+ assert!(
+ edge[2].saturating_sub(edge[1]) <= 76,
+ "蓝色 key 通道残留过强:{edge:?}"
+ );
+ }
+}
+
+#[test]
+fn generated_asset_sheet_magenta_edge_matte_defringes_bottom_shadow() {
+ let mut sheet = RgbaImage::from_pixel(32, 32, Rgba([0, 0, 0, 0]));
+ for y in 8..18 {
+ for x in 10..22 {
+ sheet.put_pixel(x, y, Rgba([202, 176, 126, 255]));
+ }
+ }
+ for y in 18..22 {
+ for x in 9..23 {
+ sheet.put_pixel(x, y, Rgba([224, 30, 220, 186]));
+ }
+ }
+
+ let cleaned = crop_generated_asset_sheet_view_edge_matte_with_options(
+ DynamicImage::ImageRgba8(sheet),
+ GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(),
+ )
+ .to_rgba8();
+
+ assert!(
+ cleaned
+ .pixels()
+ .any(|pixel| pixel.0 == [202, 176, 126, 255])
+ );
+ assert!(
+ !cleaned.pixels().any(|pixel| {
+ let [red, green, blue, alpha] = pixel.0;
+ alpha > 0 && red > 200 && blue > 200 && green < 96
+ }),
+ "底部洋红残影应被删除或去彩边"
+ );
+}
+
#[test]
fn generated_asset_sheet_view_edge_matte_trims_transparent_border() {
let mut sheet = RgbaImage::from_pixel(20, 20, Rgba([0, 0, 0, 0]));
diff --git a/server-rs/crates/platform-image/tests/vector_engine.rs b/server-rs/crates/platform-image/tests/vector_engine.rs
index e9bfb1e0..c53d63c2 100644
--- a/server-rs/crates/platform-image/tests/vector_engine.rs
+++ b/server-rs/crates/platform-image/tests/vector_engine.rs
@@ -1,8 +1,20 @@
use platform_image::vector_engine::{
- GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings,
- build_vector_engine_image_request_body, vector_engine_images_edit_url,
+ GPT_IMAGE_2_MODEL, ReferenceImage, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings,
+ build_vector_engine_image_http_client, build_vector_engine_image_request_body,
+ create_vector_engine_image_edit, vector_engine_images_edit_url,
vector_engine_images_generation_url,
};
+use std::{
+ sync::{
+ Arc,
+ atomic::{AtomicUsize, Ordering},
+ },
+ time::Duration,
+};
+use tokio::{
+ io::{AsyncReadExt, AsyncWriteExt},
+ net::TcpListener,
+};
#[test]
fn vector_engine_module_exposes_provider_protocol_helpers() {
@@ -30,3 +42,70 @@ fn vector_engine_module_exposes_provider_protocol_helpers() {
"https://vector.example/v1/images/edits"
);
}
+
+#[tokio::test]
+async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() {
+ let listener = TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("mock server should bind");
+ let server_addr = listener
+ .local_addr()
+ .expect("mock server address should be readable");
+ let request_count = Arc::new(AtomicUsize::new(0));
+ let request_count_for_server = Arc::clone(&request_count);
+
+ let server = tokio::spawn(async move {
+ loop {
+ let Ok((mut stream, _)) = listener.accept().await else {
+ break;
+ };
+ let request_index = request_count_for_server.fetch_add(1, Ordering::SeqCst);
+ tokio::spawn(async move {
+ let mut buffer = [0_u8; 4096];
+ let _ = stream.read(&mut buffer).await;
+ if request_index == 0 {
+ tokio::time::sleep(Duration::from_millis(120)).await;
+ return;
+ }
+
+ let body = r#"{"data":[{"b64_json":"iVBORw0KGgpyZXN0"}]}"#;
+ let response = format!(
+ "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
+ body.len(),
+ body
+ );
+ let _ = stream.write_all(response.as_bytes()).await;
+ });
+ }
+ });
+
+ let settings = VectorEngineImageSettings {
+ base_url: format!("http://{server_addr}/v1"),
+ api_key: "test-key".to_string(),
+ request_timeout_ms: 40,
+ };
+ let http_client =
+ build_vector_engine_image_http_client(&settings).expect("client should build");
+ let reference_image = ReferenceImage {
+ bytes: b"reference".to_vec(),
+ mime_type: "image/png".to_string(),
+ file_name: "reference.png".to_string(),
+ };
+
+ let generated = create_vector_engine_image_edit(
+ &http_client,
+ &settings,
+ "测试提示词",
+ None,
+ "1024x1024",
+ &reference_image,
+ "测试 VectorEngine 图片编辑失败",
+ )
+ .await
+ .expect("second attempt should return generated image");
+
+ assert_eq!(generated.images.len(), 1);
+ assert_eq!(generated.images[0].mime_type, "image/png");
+ assert_eq!(request_count.load(Ordering::SeqCst), 2);
+ server.abort();
+}
diff --git a/server-rs/crates/platform-oss/Cargo.toml b/server-rs/crates/platform-oss/Cargo.toml
index 216e5955..2b8a9b9e 100644
--- a/server-rs/crates/platform-oss/Cargo.toml
+++ b/server-rs/crates/platform-oss/Cargo.toml
@@ -12,6 +12,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
time = { workspace = true, features = ["formatting"] }
+tracing = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt"] }
diff --git a/server-rs/crates/platform-oss/README.md b/server-rs/crates/platform-oss/README.md
index 025481d3..84d38def 100644
--- a/server-rs/crates/platform-oss/README.md
+++ b/server-rs/crates/platform-oss/README.md
@@ -22,6 +22,7 @@
5. 服务端 `PutObject` 上传 helper
6. `x-oss-meta-*` 元数据归一化与大小限制校验
7. `content-type`、`content-length-range`、`success_action_status` policy 条件生成
+8. `PostObject` 签名、`GetObject` 读签名、`HEAD Object` 和 `PutObject` 的结构化日志
当前仍未落地的内容:
@@ -34,8 +35,9 @@
1. 当前产品口径为服务器上传 AI 生成资源、Web 端只负责读取。
2. 因此 `STS` 不作为默认上传主链,`api-server` 只暴露禁用式 contract,避免浏览器拿到 OSS 写权限。
3. 服务端生成资源应优先复用 `OssClient::put_object`,上传成功后再走对象确认链路写入 `asset_object`。
- 4. 读签名和 `HEAD Object` 的入参必须直接传 object_key,不要把 bucket 名拼进路径;例如 `generated-square-hole-assets/.../image.png` 才是正确入参,`xushi-dev/...` 这类前缀不属于 object_key。
- 5. OSS V4 `x-oss-date` 必须固定为 `yyyyMMdd'T'HHmmss'Z'`,不能依赖 `time::Time::to_string()`;后者在小时小于 10 时可能输出非补零时间,导致签名格式错误。
+4. 读签名和 `HEAD Object` 的入参必须直接传 object_key,不要把 bucket 名拼进路径;例如 `generated-square-hole-assets/.../image.png` 才是正确入参,`xushi-dev/...` 这类前缀不属于 object_key。
+5. OSS V4 `x-oss-date` 必须固定为 `yyyyMMdd'T'HHmmss'Z'`,不能依赖 `time::Time::to_string()`;后者在小时小于 10 时可能输出非补零时间,导致签名格式错误。
+6. 结构化日志只记录 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms` 等排障字段;禁止输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。
## 3. 边界约束
diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs
index c801af3b..31d48a1b 100644
--- a/server-rs/crates/platform-oss/src/lib.rs
+++ b/server-rs/crates/platform-oss/src/lib.rs
@@ -1,4 +1,4 @@
-use std::{collections::BTreeMap, error::Error, fmt};
+use std::{collections::BTreeMap, error::Error, fmt, time::Instant};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use hmac::{Hmac, Mac};
@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339};
+use tracing::{info, warn};
type HmacSha256 = Hmac;
@@ -19,6 +20,7 @@ const OSS_V4_ALGORITHM: &str = "OSS4-HMAC-SHA256";
const OSS_V4_REQUEST: &str = "aliyun_v4_request";
const OSS_V4_SERVICE: &str = "oss";
const OSS_UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
+const OSS_PROVIDER: &str = "aliyun-oss";
pub const LEGACY_PUBLIC_PREFIXES: [&str; 14] = [
"generated-character-drafts",
@@ -373,105 +375,154 @@ impl OssClient {
&self,
request: OssPostObjectRequest,
) -> Result {
- let max_size_bytes = request
- .max_size_bytes
- .unwrap_or(self.config.default_post_max_size_bytes);
- let expire_seconds = request
- .expire_seconds
- .unwrap_or(self.config.default_post_expire_seconds);
- let success_action_status = request
- .success_action_status
- .unwrap_or(self.config.default_success_action_status);
+ let started_at = Instant::now();
+ let requested_prefix = request.prefix.as_str();
+ let requested_content_type = request
+ .content_type
+ .as_deref()
+ .map(str::trim)
+ .unwrap_or("")
+ .to_string();
+ let requested_metadata_count = request.metadata.len();
- if max_size_bytes == 0 {
- return Err(OssError::InvalidRequest(
- "maxSizeBytes 必须大于 0".to_string(),
- ));
+ let result = (|| {
+ let max_size_bytes = request
+ .max_size_bytes
+ .unwrap_or(self.config.default_post_max_size_bytes);
+ let expire_seconds = request
+ .expire_seconds
+ .unwrap_or(self.config.default_post_expire_seconds);
+ let success_action_status = request
+ .success_action_status
+ .unwrap_or(self.config.default_success_action_status);
+
+ if max_size_bytes == 0 {
+ return Err(OssError::InvalidRequest(
+ "maxSizeBytes 必须大于 0".to_string(),
+ ));
+ }
+
+ if expire_seconds == 0 {
+ return Err(OssError::InvalidRequest(
+ "expireSeconds 必须大于 0".to_string(),
+ ));
+ }
+
+ if !(100..=999).contains(&success_action_status) {
+ return Err(OssError::InvalidRequest(
+ "successActionStatus 必须是三位 HTTP 状态码".to_string(),
+ ));
+ }
+
+ let sanitized_segments = request
+ .path_segments
+ .iter()
+ .map(|segment| sanitize_path_segment(segment))
+ .filter(|segment| !segment.is_empty())
+ .collect::>();
+ let file_name = sanitize_file_name(&request.file_name)?;
+ let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name);
+ let legacy_public_path = format!("/{}", object_key);
+ let content_type = normalize_optional_value(request.content_type);
+ let metadata = normalize_metadata(request.metadata)?;
+
+ let expires_at = OffsetDateTime::now_utc()
+ .checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err(
+ |_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()),
+ )?))
+ .ok_or_else(|| {
+ OssError::InvalidRequest("expireSeconds 计算结果溢出".to_string())
+ })?;
+ let expires_at = expires_at.format(&Rfc3339).map_err(|error| {
+ OssError::SerializePolicy(format!("格式化过期时间失败:{error}"))
+ })?;
+
+ let signed_at = OffsetDateTime::now_utc();
+ let signature_scope = build_v4_signature_scope(&self.config.endpoint, signed_at)?;
+ let signature_date = build_v4_signature_date(signed_at)?;
+ let credential = format!("{}/{}", self.config.access_key_id, signature_scope);
+ let policy_json = build_policy_json(
+ &self.config.bucket,
+ &object_key,
+ &expires_at,
+ max_size_bytes,
+ success_action_status,
+ content_type.as_deref(),
+ &metadata,
+ &credential,
+ &signature_date,
+ );
+ let policy = serde_json::to_string(&policy_json).map_err(|error| {
+ OssError::SerializePolicy(format!("序列化 policy 失败:{error}"))
+ })?;
+ let encoded_policy = BASE64_STANDARD.encode(policy.as_bytes());
+ let signature = sign_v4_content(
+ &self.config.access_key_secret,
+ &signature_scope,
+ &encoded_policy,
+ )?;
+
+ Ok(OssPostObjectResponse {
+ signature_version: "v4",
+ provider: OSS_PROVIDER,
+ bucket: self.config.bucket.clone(),
+ endpoint: self.config.endpoint.clone(),
+ host: self.config.upload_host(),
+ object_key: object_key.clone(),
+ legacy_public_path,
+ content_type: content_type.clone(),
+ access: request.access,
+ key_prefix: build_key_prefix(request.prefix, &sanitized_segments),
+ expires_at,
+ max_size_bytes,
+ success_action_status,
+ form_fields: OssPostObjectFormFields {
+ key: object_key,
+ policy: encoded_policy,
+ signature_version: OSS_V4_ALGORITHM.to_string(),
+ credential,
+ date: signature_date,
+ signature,
+ success_action_status: success_action_status.to_string(),
+ content_type,
+ metadata,
+ },
+ })
+ })();
+
+ match &result {
+ Ok(response) => info!(
+ provider = OSS_PROVIDER,
+ operation = "sign_post_object",
+ bucket = %response.bucket,
+ endpoint = %response.endpoint,
+ object_key = %response.object_key,
+ key_prefix = %response.key_prefix,
+ access = oss_access_label(response.access),
+ content_type = %response.content_type.as_deref().unwrap_or(""),
+ max_size_bytes = response.max_size_bytes,
+ success_action_status = response.success_action_status,
+ metadata_count = response.form_fields.metadata.len(),
+ expires_at = %response.expires_at,
+ elapsed_ms = elapsed_ms(started_at),
+ "OSS PostObject 签名完成"
+ ),
+ Err(error) => warn!(
+ provider = OSS_PROVIDER,
+ operation = "sign_post_object",
+ bucket = %self.config.bucket(),
+ endpoint = %self.config.endpoint(),
+ key_prefix = requested_prefix,
+ content_type = %requested_content_type,
+ metadata_count = requested_metadata_count,
+ error_kind = oss_error_kind_label(error),
+ message = %error,
+ elapsed_ms = elapsed_ms(started_at),
+ "OSS PostObject 签名失败"
+ ),
}
- if expire_seconds == 0 {
- return Err(OssError::InvalidRequest(
- "expireSeconds 必须大于 0".to_string(),
- ));
- }
-
- if !(100..=999).contains(&success_action_status) {
- return Err(OssError::InvalidRequest(
- "successActionStatus 必须是三位 HTTP 状态码".to_string(),
- ));
- }
-
- let sanitized_segments = request
- .path_segments
- .iter()
- .map(|segment| sanitize_path_segment(segment))
- .filter(|segment| !segment.is_empty())
- .collect::>();
- let file_name = sanitize_file_name(&request.file_name)?;
- let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name);
- let legacy_public_path = format!("/{}", object_key);
- let content_type = normalize_optional_value(request.content_type);
- let metadata = normalize_metadata(request.metadata)?;
-
- let expires_at = OffsetDateTime::now_utc()
- .checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err(
- |_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()),
- )?))
- .ok_or_else(|| OssError::InvalidRequest("expireSeconds 计算结果溢出".to_string()))?;
- let expires_at = expires_at
- .format(&Rfc3339)
- .map_err(|error| OssError::SerializePolicy(format!("格式化过期时间失败:{error}")))?;
-
- let signed_at = OffsetDateTime::now_utc();
- let signature_scope = build_v4_signature_scope(&self.config.endpoint, signed_at)?;
- let signature_date = build_v4_signature_date(signed_at)?;
- let credential = format!("{}/{}", self.config.access_key_id, signature_scope);
- let policy_json = build_policy_json(
- &self.config.bucket,
- &object_key,
- &expires_at,
- max_size_bytes,
- success_action_status,
- content_type.as_deref(),
- &metadata,
- &credential,
- &signature_date,
- );
- let policy = serde_json::to_string(&policy_json)
- .map_err(|error| OssError::SerializePolicy(format!("序列化 policy 失败:{error}")))?;
- let encoded_policy = BASE64_STANDARD.encode(policy.as_bytes());
- let signature = sign_v4_content(
- &self.config.access_key_secret,
- &signature_scope,
- &encoded_policy,
- )?;
-
- Ok(OssPostObjectResponse {
- signature_version: "v4",
- provider: "aliyun-oss",
- bucket: self.config.bucket.clone(),
- endpoint: self.config.endpoint.clone(),
- host: self.config.upload_host(),
- object_key: object_key.clone(),
- legacy_public_path,
- content_type: content_type.clone(),
- access: request.access,
- key_prefix: build_key_prefix(request.prefix, &sanitized_segments),
- expires_at,
- max_size_bytes,
- success_action_status,
- form_fields: OssPostObjectFormFields {
- key: object_key,
- policy: encoded_policy,
- signature_version: OSS_V4_ALGORITHM.to_string(),
- credential,
- date: signature_date,
- signature,
- success_action_status: success_action_status.to_string(),
- content_type,
- metadata,
- },
- })
+ result
}
// 私有 bucket 的对象读取统一走短期签名 URL,避免把长期主凭证下发给浏览器。
@@ -479,81 +530,119 @@ impl OssClient {
&self,
request: OssSignedGetObjectUrlRequest,
) -> Result {
- let expire_seconds = request
- .expire_seconds
- .unwrap_or(self.config.default_read_expire_seconds);
+ let started_at = Instant::now();
+ let requested_object_key = request
+ .object_key
+ .trim()
+ .trim_start_matches('/')
+ .trim()
+ .to_string();
- if expire_seconds == 0 {
- return Err(OssError::InvalidRequest(
- "expireSeconds 必须大于 0".to_string(),
- ));
+ let result = (|| {
+ let expire_seconds = request
+ .expire_seconds
+ .unwrap_or(self.config.default_read_expire_seconds);
+
+ if expire_seconds == 0 {
+ return Err(OssError::InvalidRequest(
+ "expireSeconds 必须大于 0".to_string(),
+ ));
+ }
+
+ let object_key = normalize_object_key(&request.object_key)?;
+ let expires_at = OffsetDateTime::now_utc()
+ .checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err(
+ |_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()),
+ )?))
+ .ok_or_else(|| {
+ OssError::InvalidRequest("expireSeconds 计算结果溢出".to_string())
+ })?;
+ let expires_at_text = expires_at
+ .format(&Rfc3339)
+ .map_err(|error| OssError::Sign(format!("格式化过期时间失败:{error}")))?;
+
+ let signed_at = OffsetDateTime::now_utc();
+ let signed_at_text = build_v4_signature_date(signed_at)?;
+ let signature_scope = build_v4_signature_scope(&self.config.endpoint, signed_at)?;
+ let credential = format!("{}/{}", self.config.access_key_id, signature_scope);
+ let mut query = BTreeMap::from([
+ ("x-oss-additional-headers".to_string(), "host".to_string()),
+ (
+ "x-oss-signature-version".to_string(),
+ OSS_V4_ALGORITHM.to_string(),
+ ),
+ ("x-oss-credential".to_string(), credential),
+ ("x-oss-date".to_string(), signed_at_text),
+ ("x-oss-expires".to_string(), expire_seconds.to_string()),
+ ]);
+ let canonical_uri = build_v4_canonical_uri(&self.config.bucket, Some(&object_key));
+ let object_url_path = format!("/{}", encode_url_path(&object_key));
+ let additional_headers = "host";
+ let canonical_headers =
+ format!("host:{}.{}\n", self.config.bucket(), self.config.endpoint());
+ let canonical_query = build_canonical_query_string(&query);
+ let canonical_request = build_v4_canonical_request(
+ Method::GET.as_str(),
+ &canonical_uri,
+ &canonical_query,
+ &canonical_headers,
+ additional_headers,
+ OSS_UNSIGNED_PAYLOAD,
+ );
+ let string_to_sign = build_v4_string_to_sign(
+ query["x-oss-date"].as_str(),
+ &signature_scope,
+ &canonical_request,
+ );
+ let signature = sign_v4_content(
+ &self.config.access_key_secret,
+ &signature_scope,
+ &string_to_sign,
+ )?;
+ query.insert("x-oss-signature".to_string(), signature);
+ let signed_url = format!(
+ "{}{}?{}",
+ self.config.upload_host(),
+ object_url_path,
+ build_canonical_query_string(&query)
+ );
+
+ Ok(OssSignedGetObjectUrlResponse {
+ provider: OSS_PROVIDER,
+ bucket: self.config.bucket.clone(),
+ endpoint: self.config.endpoint.clone(),
+ host: self.config.upload_host(),
+ object_key,
+ expires_at: expires_at_text,
+ signed_url,
+ })
+ })();
+
+ match &result {
+ Ok(response) => info!(
+ provider = OSS_PROVIDER,
+ operation = "sign_get_object_url",
+ bucket = %response.bucket,
+ endpoint = %response.endpoint,
+ object_key = %response.object_key,
+ expires_at = %response.expires_at,
+ elapsed_ms = elapsed_ms(started_at),
+ "OSS GetObject 读签名完成"
+ ),
+ Err(error) => warn!(
+ provider = OSS_PROVIDER,
+ operation = "sign_get_object_url",
+ bucket = %self.config.bucket(),
+ endpoint = %self.config.endpoint(),
+ object_key = %requested_object_key,
+ error_kind = oss_error_kind_label(error),
+ message = %error,
+ elapsed_ms = elapsed_ms(started_at),
+ "OSS GetObject 读签名失败"
+ ),
}
- let object_key = normalize_object_key(&request.object_key)?;
- let expires_at = OffsetDateTime::now_utc()
- .checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err(
- |_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()),
- )?))
- .ok_or_else(|| OssError::InvalidRequest("expireSeconds 计算结果溢出".to_string()))?;
- let expires_at_text = expires_at
- .format(&Rfc3339)
- .map_err(|error| OssError::Sign(format!("格式化过期时间失败:{error}")))?;
-
- let signed_at = OffsetDateTime::now_utc();
- let signed_at_text = build_v4_signature_date(signed_at)?;
- let signature_scope = build_v4_signature_scope(&self.config.endpoint, signed_at)?;
- let credential = format!("{}/{}", self.config.access_key_id, signature_scope);
- let mut query = BTreeMap::from([
- ("x-oss-additional-headers".to_string(), "host".to_string()),
- (
- "x-oss-signature-version".to_string(),
- OSS_V4_ALGORITHM.to_string(),
- ),
- ("x-oss-credential".to_string(), credential),
- ("x-oss-date".to_string(), signed_at_text),
- ("x-oss-expires".to_string(), expire_seconds.to_string()),
- ]);
- let canonical_uri = build_v4_canonical_uri(&self.config.bucket, Some(&object_key));
- let object_url_path = format!("/{}", encode_url_path(&object_key));
- let additional_headers = "host";
- let canonical_headers =
- format!("host:{}.{}\n", self.config.bucket(), self.config.endpoint());
- let canonical_query = build_canonical_query_string(&query);
- let canonical_request = build_v4_canonical_request(
- Method::GET.as_str(),
- &canonical_uri,
- &canonical_query,
- &canonical_headers,
- additional_headers,
- OSS_UNSIGNED_PAYLOAD,
- );
- let string_to_sign = build_v4_string_to_sign(
- query["x-oss-date"].as_str(),
- &signature_scope,
- &canonical_request,
- );
- let signature = sign_v4_content(
- &self.config.access_key_secret,
- &signature_scope,
- &string_to_sign,
- )?;
- query.insert("x-oss-signature".to_string(), signature);
- let signed_url = format!(
- "{}{}?{}",
- self.config.upload_host(),
- object_url_path,
- build_canonical_query_string(&query)
- );
-
- Ok(OssSignedGetObjectUrlResponse {
- provider: "aliyun-oss",
- bucket: self.config.bucket.clone(),
- endpoint: self.config.endpoint.clone(),
- host: self.config.upload_host(),
- object_key,
- expires_at: expires_at_text,
- signed_url,
- })
+ result
}
// 上传完成确认前,服务端必须自己探测一次对象,不能只相信客户端回传的 object_key。
@@ -562,59 +651,107 @@ impl OssClient {
client: &reqwest::Client,
request: OssHeadObjectRequest,
) -> Result {
- let object_key = normalize_object_key(&request.object_key)?;
- let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key)
- .map_err(|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")))?;
- let response = send_signed_request(
- client,
- &self.config,
- Method::HEAD,
- Some(&object_key),
- target_url,
- )
- .await?;
+ let started_at = Instant::now();
+ let requested_object_key = request
+ .object_key
+ .trim()
+ .trim_start_matches('/')
+ .trim()
+ .to_string();
+ let mut response_status = None;
- if response.status() == reqwest::StatusCode::NOT_FOUND {
- return Err(OssError::ObjectNotFound(format!(
- "OSS 对象不存在:{}",
- request.object_key
- )));
+ let result = async {
+ let object_key = normalize_object_key(&request.object_key)?;
+ let target_url =
+ build_object_url(&self.config.bucket, &self.config.endpoint, &object_key).map_err(
+ |error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")),
+ )?;
+ let response = send_signed_request(
+ client,
+ &self.config,
+ Method::HEAD,
+ Some(&object_key),
+ target_url,
+ )
+ .await?;
+ response_status = Some(response.status().as_u16());
+
+ if response.status() == reqwest::StatusCode::NOT_FOUND {
+ return Err(OssError::ObjectNotFound(format!(
+ "OSS 对象不存在:{}",
+ request.object_key
+ )));
+ }
+
+ if !response.status().is_success() {
+ return Err(OssError::Request(format!(
+ "OSS HEAD Object 失败,状态码:{}",
+ response.status()
+ )));
+ }
+
+ let headers = response.headers();
+ let content_length = headers
+ .get(reqwest::header::CONTENT_LENGTH)
+ .and_then(|value| value.to_str().ok())
+ .and_then(|value| value.parse::().ok())
+ .unwrap_or(0);
+ let content_type = headers
+ .get(reqwest::header::CONTENT_TYPE)
+ .and_then(|value| value.to_str().ok())
+ .map(|value| value.to_string());
+ let etag = headers
+ .get(reqwest::header::ETAG)
+ .and_then(|value| value.to_str().ok())
+ .map(|value| value.trim_matches('"').to_string());
+ let last_modified = headers
+ .get(reqwest::header::LAST_MODIFIED)
+ .and_then(|value| value.to_str().ok())
+ .map(|value| value.to_string());
+
+ Ok(OssHeadObjectResponse {
+ bucket: self.config.bucket.clone(),
+ object_key,
+ content_length,
+ content_type,
+ etag,
+ last_modified,
+ })
+ }
+ .await;
+
+ match &result {
+ Ok(response) => info!(
+ provider = OSS_PROVIDER,
+ operation = "head_object",
+ bucket = %response.bucket,
+ endpoint = %self.config.endpoint(),
+ object_key = %response.object_key,
+ status = response_status.unwrap_or(reqwest::StatusCode::OK.as_u16()),
+ status_class = http_status_class_from_option(response_status),
+ content_length = response.content_length,
+ content_type = %response.content_type.as_deref().unwrap_or(""),
+ etag_present = response.etag.is_some(),
+ last_modified_present = response.last_modified.is_some(),
+ elapsed_ms = elapsed_ms(started_at),
+ "OSS HEAD Object 完成"
+ ),
+ Err(error) => warn!(
+ provider = OSS_PROVIDER,
+ operation = "head_object",
+ bucket = %self.config.bucket(),
+ endpoint = %self.config.endpoint(),
+ object_key = %requested_object_key,
+ status = response_status.unwrap_or_default(),
+ status_class = http_status_class_from_option(response_status),
+ error_kind = oss_error_kind_label(error),
+ message = %error,
+ elapsed_ms = elapsed_ms(started_at),
+ "OSS HEAD Object 失败"
+ ),
}
- if !response.status().is_success() {
- return Err(OssError::Request(format!(
- "OSS HEAD Object 失败,状态码:{}",
- response.status()
- )));
- }
-
- let headers = response.headers();
- let content_length = headers
- .get(reqwest::header::CONTENT_LENGTH)
- .and_then(|value| value.to_str().ok())
- .and_then(|value| value.parse::().ok())
- .unwrap_or(0);
- let content_type = headers
- .get(reqwest::header::CONTENT_TYPE)
- .and_then(|value| value.to_str().ok())
- .map(|value| value.to_string());
- let etag = headers
- .get(reqwest::header::ETAG)
- .and_then(|value| value.to_str().ok())
- .map(|value| value.trim_matches('"').to_string());
- let last_modified = headers
- .get(reqwest::header::LAST_MODIFIED)
- .and_then(|value| value.to_str().ok())
- .map(|value| value.to_string());
-
- Ok(OssHeadObjectResponse {
- bucket: self.config.bucket.clone(),
- object_key,
- content_length,
- content_type,
- etag,
- last_modified,
- })
+ result
}
// AI 生成资源默认由服务端上传 OSS,Web 端只拿签名读地址,不直接持有写权限。
@@ -623,73 +760,128 @@ impl OssClient {
client: &reqwest::Client,
request: OssPutObjectRequest,
) -> Result {
- if request.body.is_empty() {
- return Err(OssError::InvalidRequest(
- "服务端上传对象内容不能为空".to_string(),
- ));
+ let started_at = Instant::now();
+ let requested_prefix = request.prefix.as_str();
+ let requested_content_type = request
+ .content_type
+ .as_deref()
+ .map(str::trim)
+ .unwrap_or("")
+ .to_string();
+ let requested_content_length = request.body.len();
+ let requested_metadata_count = request.metadata.len();
+ let mut response_status = None;
+
+ let result = async {
+ if request.body.is_empty() {
+ return Err(OssError::InvalidRequest(
+ "服务端上传对象内容不能为空".to_string(),
+ ));
+ }
+
+ let sanitized_segments = request
+ .path_segments
+ .iter()
+ .map(|segment| sanitize_path_segment(segment))
+ .filter(|segment| !segment.is_empty())
+ .collect::>();
+ let file_name = sanitize_file_name(&request.file_name)?;
+ let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name);
+ let content_type = normalize_optional_value(request.content_type);
+ let metadata = normalize_metadata(request.metadata)?;
+ let target_url =
+ build_object_url(&self.config.bucket, &self.config.endpoint, &object_key).map_err(
+ |error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")),
+ )?;
+ let content_length = u64::try_from(request.body.len())
+ .map_err(|_| OssError::InvalidRequest("上传对象大小超出可支持范围".to_string()))?;
+ let builder = signed_request_builder(
+ client,
+ &self.config,
+ Method::PUT,
+ Some(&object_key),
+ target_url,
+ content_type.as_deref(),
+ &metadata,
+ )?
+ .header(reqwest::header::CONTENT_LENGTH, content_length)
+ .body(request.body);
+
+ let response = builder
+ .send()
+ .await
+ .map_err(|error| OssError::Request(format!("请求 OSS 失败:{error}")))?;
+ response_status = Some(response.status().as_u16());
+
+ if !response.status().is_success() {
+ return Err(OssError::Request(format!(
+ "OSS PutObject 失败,状态码:{}",
+ response.status()
+ )));
+ }
+
+ let headers = response.headers();
+ let etag = headers
+ .get(reqwest::header::ETAG)
+ .and_then(|value| value.to_str().ok())
+ .map(|value| value.trim_matches('"').to_string());
+ let last_modified = headers
+ .get(reqwest::header::LAST_MODIFIED)
+ .and_then(|value| value.to_str().ok())
+ .map(|value| value.to_string());
+
+ Ok(OssPutObjectResponse {
+ provider: OSS_PROVIDER,
+ bucket: self.config.bucket.clone(),
+ endpoint: self.config.endpoint.clone(),
+ host: self.config.upload_host(),
+ legacy_public_path: format!("/{object_key}"),
+ object_key,
+ content_type,
+ content_length,
+ access: request.access,
+ etag,
+ last_modified,
+ })
+ }
+ .await;
+
+ match &result {
+ Ok(response) => info!(
+ provider = OSS_PROVIDER,
+ operation = "put_object",
+ bucket = %response.bucket,
+ endpoint = %response.endpoint,
+ object_key = %response.object_key,
+ access = oss_access_label(response.access),
+ status = response_status.unwrap_or(reqwest::StatusCode::OK.as_u16()),
+ status_class = http_status_class_from_option(response_status),
+ content_length = response.content_length,
+ content_type = %response.content_type.as_deref().unwrap_or(""),
+ etag_present = response.etag.is_some(),
+ last_modified_present = response.last_modified.is_some(),
+ elapsed_ms = elapsed_ms(started_at),
+ "OSS PutObject 上传完成"
+ ),
+ Err(error) => warn!(
+ provider = OSS_PROVIDER,
+ operation = "put_object",
+ bucket = %self.config.bucket(),
+ endpoint = %self.config.endpoint(),
+ key_prefix = requested_prefix,
+ content_length = requested_content_length,
+ content_type = %requested_content_type,
+ metadata_count = requested_metadata_count,
+ status = response_status.unwrap_or_default(),
+ status_class = http_status_class_from_option(response_status),
+ error_kind = oss_error_kind_label(error),
+ message = %error,
+ elapsed_ms = elapsed_ms(started_at),
+ "OSS PutObject 上传失败"
+ ),
}
- let sanitized_segments = request
- .path_segments
- .iter()
- .map(|segment| sanitize_path_segment(segment))
- .filter(|segment| !segment.is_empty())
- .collect::>();
- let file_name = sanitize_file_name(&request.file_name)?;
- let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name);
- let content_type = normalize_optional_value(request.content_type);
- let metadata = normalize_metadata(request.metadata)?;
- let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key)
- .map_err(|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")))?;
- let content_length = u64::try_from(request.body.len())
- .map_err(|_| OssError::InvalidRequest("上传对象大小超出可支持范围".to_string()))?;
- let builder = signed_request_builder(
- client,
- &self.config,
- Method::PUT,
- Some(&object_key),
- target_url,
- content_type.as_deref(),
- &metadata,
- )?
- .header(reqwest::header::CONTENT_LENGTH, content_length)
- .body(request.body);
-
- let response = builder
- .send()
- .await
- .map_err(|error| OssError::Request(format!("请求 OSS 失败:{error}")))?;
-
- if !response.status().is_success() {
- return Err(OssError::Request(format!(
- "OSS PutObject 失败,状态码:{}",
- response.status()
- )));
- }
-
- let headers = response.headers();
- let etag = headers
- .get(reqwest::header::ETAG)
- .and_then(|value| value.to_str().ok())
- .map(|value| value.trim_matches('"').to_string());
- let last_modified = headers
- .get(reqwest::header::LAST_MODIFIED)
- .and_then(|value| value.to_str().ok())
- .map(|value| value.to_string());
-
- Ok(OssPutObjectResponse {
- provider: "aliyun-oss",
- bucket: self.config.bucket.clone(),
- endpoint: self.config.endpoint.clone(),
- host: self.config.upload_host(),
- legacy_public_path: format!("/{object_key}"),
- object_key,
- content_type,
- content_length,
- access: request.access,
- etag,
- last_modified,
- })
+ result
}
}
@@ -721,6 +913,43 @@ impl OssError {
}
}
+fn elapsed_ms(started_at: Instant) -> u64 {
+ started_at.elapsed().as_millis().min(u64::MAX as u128) as u64
+}
+
+fn oss_access_label(access: OssObjectAccess) -> &'static str {
+ match access {
+ OssObjectAccess::Public => "public",
+ OssObjectAccess::Private => "private",
+ }
+}
+
+fn oss_error_kind_label(error: &OssError) -> &'static str {
+ match error.kind() {
+ OssErrorKind::InvalidConfig => "invalid_config",
+ OssErrorKind::InvalidRequest => "invalid_request",
+ OssErrorKind::ObjectNotFound => "object_not_found",
+ OssErrorKind::Request => "request",
+ OssErrorKind::SerializePolicy => "serialize_policy",
+ OssErrorKind::Sign => "sign",
+ }
+}
+
+fn http_status_class_from_option(status: Option) -> &'static str {
+ status.map(http_status_class).unwrap_or("unknown")
+}
+
+fn http_status_class(status: u16) -> &'static str {
+ match status {
+ 100..=199 => "1xx",
+ 200..=299 => "2xx",
+ 300..=399 => "3xx",
+ 400..=499 => "4xx",
+ 500..=599 => "5xx",
+ _ => "unknown",
+ }
+}
+
fn build_policy_json(
bucket: &str,
object_key: &str,
@@ -1299,6 +1528,18 @@ mod tests {
);
}
+ #[test]
+ fn structured_log_labels_are_stable() {
+ assert_eq!(
+ oss_error_kind_label(&OssError::InvalidRequest("bad input".to_string())),
+ "invalid_request"
+ );
+ assert_eq!(oss_access_label(OssObjectAccess::Private), "private");
+ assert_eq!(http_status_class(204), "2xx");
+ assert_eq!(http_status_class(404), "4xx");
+ assert_eq!(http_status_class_from_option(None), "unknown");
+ }
+
fn build_client() -> OssClient {
OssClient::new(
OssConfig::new(
diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs
index 94847336..0c0f5809 100644
--- a/server-rs/crates/shared-contracts/src/auth.rs
+++ b/server-rs/crates/shared-contracts/src/auth.rs
@@ -19,10 +19,13 @@ pub struct AuthUserPayload {
pub public_user_code: String,
pub display_name: String,
pub avatar_url: Option,
+ pub phone_number: Option,
pub phone_number_masked: Option,
pub login_method: String,
pub binding_status: String,
pub wechat_bound: bool,
+ pub wechat_display_name: Option,
+ pub wechat_account: Option,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
diff --git a/server-rs/crates/shared-contracts/src/jump_hop.rs b/server-rs/crates/shared-contracts/src/jump_hop.rs
index cd2c0a51..cbad6f68 100644
--- a/server-rs/crates/shared-contracts/src/jump_hop.rs
+++ b/server-rs/crates/shared-contracts/src/jump_hop.rs
@@ -44,7 +44,6 @@ pub enum JumpHopTileType {
#[serde(rename_all = "kebab-case")]
pub enum JumpHopActionType {
CompileDraft,
- RegenerateCharacter,
RegenerateTiles,
UpdateWorkMeta,
UpdateDifficulty,
@@ -71,12 +70,20 @@ pub enum JumpHopJumpResult {
#[serde(rename_all = "camelCase")]
pub struct JumpHopWorkspaceCreateRequest {
pub template_id: String,
+ pub theme_text: String,
+ #[serde(default)]
pub work_title: String,
+ #[serde(default)]
pub work_description: String,
+ #[serde(default)]
pub theme_tags: Vec,
+ #[serde(default = "default_jump_hop_difficulty")]
pub difficulty: JumpHopDifficulty,
+ #[serde(default = "default_jump_hop_style_preset")]
pub style_preset: JumpHopStylePreset,
+ #[serde(default)]
pub character_prompt: String,
+ #[serde(default)]
pub tile_prompt: String,
#[serde(default)]
pub end_mood_prompt: Option,
@@ -89,6 +96,8 @@ pub struct JumpHopActionRequest {
#[serde(default)]
pub profile_id: Option,
#[serde(default)]
+ pub theme_text: Option,
+ #[serde(default)]
pub work_title: Option,
#[serde(default)]
pub work_description: Option,
@@ -112,6 +121,8 @@ pub struct JumpHopActionRequest {
pub tile_assets: Option>,
#[serde(default)]
pub cover_composite: Option,
+ #[serde(default)]
+ pub back_button_asset: Option,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -127,14 +138,30 @@ pub struct JumpHopCharacterAsset {
pub height: u32,
}
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+pub struct JumpHopDefaultCharacter {
+ pub character_id: String,
+ pub display_name: String,
+ pub model_kind: String,
+ pub body_color: String,
+ pub accent_color: String,
+}
+
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct JumpHopTileAsset {
pub tile_type: JumpHopTileType,
+ #[serde(default)]
+ pub tile_id: Option,
pub image_src: String,
pub image_object_key: String,
pub asset_object_id: String,
pub source_atlas_cell: String,
+ #[serde(default)]
+ pub atlas_row: Option,
+ #[serde(default)]
+ pub atlas_col: Option,
pub visual_width: u32,
pub visual_height: u32,
pub top_surface_radius: f32,
@@ -193,11 +220,14 @@ pub struct JumpHopDraftResponse {
pub template_name: String,
#[serde(default)]
pub profile_id: Option,
+ pub theme_text: String,
pub work_title: String,
pub work_description: String,
pub theme_tags: Vec,
pub difficulty: JumpHopDifficulty,
pub style_preset: JumpHopStylePreset,
+ #[serde(default)]
+ pub default_character: Option,
pub character_prompt: String,
pub tile_prompt: String,
#[serde(default)]
@@ -212,6 +242,8 @@ pub struct JumpHopDraftResponse {
pub path: Option,
#[serde(default)]
pub cover_composite: Option,
+ #[serde(default)]
+ pub back_button_asset: Option,
pub generation_status: JumpHopGenerationStatus,
}
@@ -251,6 +283,7 @@ pub struct JumpHopWorkSummaryResponse {
pub owner_user_id: String,
#[serde(default)]
pub source_session_id: Option,
+ pub theme_text: String,
pub work_title: String,
pub work_description: String,
pub theme_tags: Vec,
@@ -274,9 +307,13 @@ pub struct JumpHopWorkProfileResponse {
pub summary: JumpHopWorkSummaryResponse,
pub draft: JumpHopDraftResponse,
pub path: JumpHopPath,
+ #[serde(default)]
+ pub default_character: Option,
pub character_asset: JumpHopCharacterAsset,
pub tile_atlas_asset: JumpHopCharacterAsset,
pub tile_assets: Vec,
+ #[serde(default)]
+ pub back_button_asset: Option,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -305,6 +342,7 @@ pub struct JumpHopGalleryCardResponse {
pub profile_id: String,
pub owner_user_id: String,
pub author_display_name: String,
+ pub theme_text: String,
pub work_title: String,
pub work_description: String,
#[serde(default)]
@@ -343,6 +381,8 @@ pub struct JumpHopRuntimeRunSnapshotResponse {
pub owner_user_id: String,
pub status: JumpHopRunStatus,
pub current_platform_index: u32,
+ pub successful_jump_count: u32,
+ pub duration_ms: u64,
pub score: u32,
pub combo: u32,
pub path: JumpHopPath,
@@ -363,15 +403,29 @@ pub struct JumpHopRunResponse {
#[serde(rename_all = "camelCase")]
pub struct JumpHopStartRunRequest {
pub profile_id: String,
+ #[serde(default)]
+ pub runtime_mode: Option,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct JumpHopJumpRequest {
- pub charge_ms: u32,
+ pub drag_distance: f32,
+ #[serde(default)]
+ pub drag_vector_x: Option,
+ #[serde(default)]
+ pub drag_vector_y: Option,
pub client_event_id: String,
}
+fn default_jump_hop_difficulty() -> JumpHopDifficulty {
+ JumpHopDifficulty::Standard
+}
+
+fn default_jump_hop_style_preset() -> JumpHopStylePreset {
+ JumpHopStylePreset::MinimalBlocks
+}
+
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct JumpHopRestartRunRequest {
@@ -384,6 +438,25 @@ pub struct JumpHopJumpResponse {
pub run: JumpHopRuntimeRunSnapshotResponse,
}
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct JumpHopLeaderboardEntry {
+ pub rank: u32,
+ pub player_id: String,
+ pub successful_jump_count: u32,
+ pub duration_ms: u64,
+ pub updated_at: String,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct JumpHopLeaderboardResponse {
+ pub profile_id: String,
+ pub items: Vec,
+ #[serde(default)]
+ pub viewer_best: Option,
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -393,6 +466,7 @@ mod tests {
fn jump_hop_workspace_request_uses_camel_case() {
let payload = serde_json::to_value(JumpHopWorkspaceCreateRequest {
template_id: "jump-hop".to_string(),
+ theme_text: "跳一跳".to_string(),
work_title: "跳一跳".to_string(),
work_description: "俯视角跳跃闯关".to_string(),
theme_tags: vec!["休闲".to_string()],
diff --git a/server-rs/crates/spacetime-client/src/bark_battle.rs b/server-rs/crates/spacetime-client/src/bark_battle.rs
index 1f47242c..99482684 100644
--- a/server-rs/crates/spacetime-client/src/bark_battle.rs
+++ b/server-rs/crates/spacetime-client/src/bark_battle.rs
@@ -3,6 +3,7 @@ use std::collections::HashMap;
pub type BarkBattleDraftCreateRecordInput = BarkBattleDraftCreateInput;
pub type BarkBattleDraftConfigUpsertRecordInput = BarkBattleDraftConfigUpsertInput;
+pub type BarkBattleWorkDeleteRecordInput = BarkBattleWorkDeleteInput;
pub type BarkBattleWorkPublishRecordInput = BarkBattleWorkPublishInput;
pub type BarkBattleRunStartRecordInput = BarkBattleRunStartInput;
pub type BarkBattleRunFinishRecordInput = BarkBattleRunFinishInput;
@@ -88,6 +89,34 @@ impl SpacetimeClient {
.await
}
+ pub async fn delete_bark_battle_work(
+ &self,
+ input: BarkBattleWorkDeleteRecordInput,
+ ) -> Result, SpacetimeClientError> {
+ let owner_user_id = input.owner_user_id.clone();
+ self.call_after_connect("delete_bark_battle_work", move |connection, sender| {
+ connection
+ .procedures()
+ .delete_bark_battle_work_then(input, move |_, result| {
+ let mapped = result
+ .map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
+ .and_then(|result| {
+ if result.ok {
+ Ok(())
+ } else {
+ Err(SpacetimeClientError::procedure_failed(
+ result.error_message,
+ ))
+ }
+ });
+ send_once(&sender, mapped);
+ });
+ })
+ .await?;
+
+ self.list_bark_battle_works(owner_user_id).await
+ }
+
pub async fn get_bark_battle_runtime_config(
&self,
work_id: String,
diff --git a/server-rs/crates/spacetime-client/src/jump_hop.rs b/server-rs/crates/spacetime-client/src/jump_hop.rs
index 16f2eea8..df224a68 100644
--- a/server-rs/crates/spacetime-client/src/jump_hop.rs
+++ b/server-rs/crates/spacetime-client/src/jump_hop.rs
@@ -1,15 +1,15 @@
use super::*;
use crate::mapper::{
map_jump_hop_agent_session_procedure_result, map_jump_hop_gallery_card_view_row,
- map_jump_hop_run_procedure_result, map_jump_hop_work_procedure_result,
- map_jump_hop_works_procedure_result,
+ map_jump_hop_leaderboard_procedure_result, map_jump_hop_run_procedure_result,
+ map_jump_hop_work_procedure_result, map_jump_hop_works_procedure_result,
};
use shared_contracts::jump_hop::{
JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset,
JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryResponse, JumpHopGenerationStatus,
- JumpHopJumpRequest, JumpHopRestartRunRequest, JumpHopRuntimeRunSnapshotResponse,
- JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset,
- JumpHopTileType, JumpHopWorkProfileResponse,
+ JumpHopJumpRequest, JumpHopLeaderboardResponse, JumpHopRestartRunRequest,
+ JumpHopRuntimeRunSnapshotResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest,
+ JumpHopStylePreset, JumpHopWorkProfileResponse,
};
use shared_kernel::build_prefixed_uuid_id;
@@ -222,6 +222,30 @@ impl SpacetimeClient {
.await
}
+ pub async fn delete_jump_hop_work(
+ &self,
+ profile_id: String,
+ owner_user_id: String,
+ ) -> Result, SpacetimeClientError> {
+ let procedure_input = JumpHopWorkDeleteInput {
+ profile_id,
+ owner_user_id,
+ };
+
+ self.call_after_connect("delete_jump_hop_work", move |connection, sender| {
+ connection.procedures().delete_jump_hop_work_then(
+ procedure_input,
+ move |_, result| {
+ let mapped = result
+ .map_err(SpacetimeClientError::from_sdk_error)
+ .and_then(map_jump_hop_works_procedure_result);
+ send_once(&sender, mapped);
+ },
+ );
+ })
+ .await
+ }
+
pub async fn get_jump_hop_runtime_work(
&self,
profile_id: String,
@@ -229,7 +253,7 @@ impl SpacetimeClient {
let work = self
.get_jump_hop_work_profile(profile_id, String::new())
.await?;
- validate_jump_hop_runtime_ready(&work)?;
+ validate_jump_hop_runtime_ready(&work, "published")?;
Ok(work)
}
@@ -238,17 +262,24 @@ impl SpacetimeClient {
payload: JumpHopStartRunRequest,
owner_user_id: String,
) -> Result {
+ let runtime_mode = normalize_jump_hop_runtime_mode(payload.runtime_mode.as_deref());
let profile_id = payload.profile_id;
+ let work_owner_user_id = if runtime_mode == "draft" {
+ owner_user_id.clone()
+ } else {
+ String::new()
+ };
let work = self
- .get_jump_hop_work_profile(profile_id.clone(), String::new())
+ .get_jump_hop_work_profile(profile_id.clone(), work_owner_user_id)
.await?;
- validate_jump_hop_runtime_ready(&work)?;
+ validate_jump_hop_runtime_ready(&work, runtime_mode)?;
let run_id = build_prefixed_uuid_id("jump-hop-run-");
let procedure_input = JumpHopRunStartInput {
client_event_id: format!("{run_id}:start"),
run_id,
owner_user_id,
profile_id,
+ runtime_mode: runtime_mode.to_string(),
started_at_ms: current_unix_micros().div_euclid(1000),
};
self.start_jump_hop_run_with_input(procedure_input).await
@@ -303,7 +334,9 @@ impl SpacetimeClient {
let procedure_input = JumpHopRunJumpInput {
run_id,
owner_user_id,
- charge_ms: payload.charge_ms,
+ drag_distance: payload.drag_distance,
+ drag_vector_x: payload.drag_vector_x,
+ drag_vector_y: payload.drag_vector_y,
client_event_id: payload.client_event_id,
jumped_at_ms: current_unix_micros().div_euclid(1000),
};
@@ -396,13 +429,39 @@ impl SpacetimeClient {
self.get_jump_hop_work_profile(card.profile_id, String::new())
.await
}
+
+ pub async fn get_jump_hop_leaderboard(
+ &self,
+ profile_id: String,
+ viewer_player_id: String,
+ ) -> Result {
+ let procedure_input = JumpHopLeaderboardGetInput {
+ profile_id,
+ viewer_player_id,
+ limit: 50,
+ };
+
+ self.call_after_connect("get_jump_hop_leaderboard", move |connection, sender| {
+ connection.procedures().get_jump_hop_leaderboard_then(
+ procedure_input,
+ move |_, result| {
+ let mapped = result
+ .map_err(SpacetimeClientError::from_sdk_error)
+ .and_then(map_jump_hop_leaderboard_procedure_result);
+ send_once(&sender, mapped);
+ },
+ );
+ })
+ .await
+ }
}
fn validate_jump_hop_runtime_ready(
work: &JumpHopWorkProfileResponse,
+ runtime_mode: &str,
) -> Result<(), SpacetimeClientError> {
let status = work.summary.publication_status.trim().to_ascii_lowercase();
- if status != "published" {
+ if runtime_mode == "published" && status != "published" {
return Err(SpacetimeClientError::validation_failed(
"jump-hop runtime 只能启动已发布作品",
));
@@ -412,11 +471,11 @@ fn validate_jump_hop_runtime_ready(
"jump-hop runtime 需要 ready 状态作品",
));
}
- validate_jump_hop_character_asset_ready(&work.character_asset, "character_asset")?;
- validate_jump_hop_character_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?;
- if work.tile_assets.is_empty() {
+ validate_jump_hop_default_character_ready(work)?;
+ validate_jump_hop_tile_atlas_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?;
+ if work.tile_assets.len() < 25 {
return Err(SpacetimeClientError::validation_failed(
- "jump-hop runtime 缺少地块资产",
+ "jump-hop runtime 需要 25 个地块资产",
));
}
for (index, asset) in work.tile_assets.iter().enumerate() {
@@ -437,7 +496,34 @@ fn validate_jump_hop_runtime_ready(
Ok(())
}
-fn validate_jump_hop_character_asset_ready(
+fn normalize_jump_hop_runtime_mode(value: Option<&str>) -> &'static str {
+ if value
+ .map(|value| value.trim().eq_ignore_ascii_case("draft"))
+ .unwrap_or(false)
+ {
+ "draft"
+ } else {
+ "published"
+ }
+}
+
+fn validate_jump_hop_default_character_ready(
+ work: &JumpHopWorkProfileResponse,
+) -> Result<(), SpacetimeClientError> {
+ let Some(default_character) = work.default_character.as_ref() else {
+ return Err(SpacetimeClientError::validation_failed(
+ "jump-hop runtime 缺少内置默认角色配置",
+ ));
+ };
+ if default_character.model_kind.trim() != "builtin-three" {
+ return Err(SpacetimeClientError::validation_failed(
+ "jump-hop runtime 默认角色必须使用 builtin-three",
+ ));
+ }
+ Ok(())
+}
+
+fn validate_jump_hop_tile_atlas_asset_ready(
asset: &JumpHopCharacterAsset,
field: &str,
) -> Result<(), SpacetimeClientError> {
@@ -475,7 +561,6 @@ enum JumpHopActionProcedure {
#[derive(Clone, Copy)]
enum JumpHopDraftMergeScope {
CompileDraft,
- RegenerateCharacter,
RegenerateTiles,
UpdateWorkMeta,
UpdateDifficulty,
@@ -484,7 +569,6 @@ enum JumpHopDraftMergeScope {
#[derive(Clone, Copy)]
enum JumpHopAssetRefresh {
Preserve,
- Character,
Tiles,
}
@@ -496,12 +580,18 @@ fn build_jump_hop_action_plan(
) -> Result<(JumpHopActionProcedure, JumpHopDraftResponse), SpacetimeClientError> {
let scope = match payload.action_type {
JumpHopActionType::CompileDraft => JumpHopDraftMergeScope::CompileDraft,
- JumpHopActionType::RegenerateCharacter => JumpHopDraftMergeScope::RegenerateCharacter,
JumpHopActionType::RegenerateTiles => JumpHopDraftMergeScope::RegenerateTiles,
JumpHopActionType::UpdateWorkMeta => JumpHopDraftMergeScope::UpdateWorkMeta,
JumpHopActionType::UpdateDifficulty => JumpHopDraftMergeScope::UpdateDifficulty,
};
- let mut draft = merge_action_into_draft(current.draft.clone(), payload, scope)?;
+ let mut base_draft = current.draft.clone();
+ if matches!(payload.action_type, JumpHopActionType::RegenerateTiles) {
+ if let Some(draft) = base_draft.as_mut() {
+ draft.tile_atlas_asset = None;
+ draft.tile_assets.clear();
+ }
+ }
+ let mut draft = merge_action_into_draft(base_draft, payload, scope)?;
let profile_id = resolve_jump_hop_profile_id(&draft, &payload.action_type)?;
draft.profile_id = Some(profile_id.clone());
@@ -514,16 +604,6 @@ fn build_jump_hop_action_plan(
JumpHopAssetRefresh::Preserve,
now_micros,
)?),
- JumpHopActionType::RegenerateCharacter => {
- JumpHopActionProcedure::Compile(build_compile_input(
- current,
- owner_user_id,
- &profile_id,
- &mut draft,
- JumpHopAssetRefresh::Character,
- now_micros,
- )?)
- }
JumpHopActionType::RegenerateTiles => JumpHopActionProcedure::Compile(build_compile_input(
current,
owner_user_id,
@@ -563,6 +643,13 @@ fn merge_action_into_draft(
{
draft.work_title = value.trim().to_string();
}
+ if let Some(value) = payload
+ .theme_text
+ .as_ref()
+ .filter(|value| !value.trim().is_empty())
+ {
+ draft.theme_text = value.trim().chars().take(60).collect();
+ }
if let Some(value) = payload.work_description.as_ref() {
draft.work_description = value.trim().to_string();
}
@@ -590,10 +677,7 @@ fn merge_action_into_draft(
.filter(|value| !value.is_empty());
}
}
- if matches!(
- scope,
- JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter
- ) {
+ if matches!(scope, JumpHopDraftMergeScope::CompileDraft) {
if let Some(value) = payload
.character_prompt
.as_ref()
@@ -622,10 +706,7 @@ fn merge_action_into_draft(
{
draft.profile_id = Some(profile_id.to_string());
}
- if matches!(
- scope,
- JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter
- ) {
+ if matches!(scope, JumpHopDraftMergeScope::CompileDraft) {
if let Some(asset) = payload.character_asset.clone() {
draft.character_asset = Some(asset);
}
@@ -649,6 +730,14 @@ fn merge_action_into_draft(
{
draft.cover_composite = Some(value.to_string());
}
+ if matches!(
+ scope,
+ JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles
+ ) {
+ if let Some(asset) = payload.back_button_asset.clone() {
+ draft.back_button_asset = Some(asset);
+ }
+ }
if draft.work_title.trim().is_empty() {
return Err(SpacetimeClientError::validation_failed(
"jump-hop work_title 不能为空",
@@ -665,28 +754,19 @@ fn build_compile_input(
refresh: JumpHopAssetRefresh,
now_micros: i64,
) -> Result {
- let force_character = matches!(refresh, JumpHopAssetRefresh::Character);
- let force_tiles = matches!(refresh, JumpHopAssetRefresh::Tiles);
- if force_character {
- draft.character_asset = None;
- }
- if force_tiles {
- draft.tile_atlas_asset = None;
- draft.tile_assets.clear();
- }
- let character_asset = draft.character_asset.clone().ok_or_else(|| {
- SpacetimeClientError::validation_failed(
- "jump-hop compile-draft 缺少真实角色资产,请先由 api-server 生成并持久化 asset_object",
- )
- })?;
+ let character_asset = draft.character_asset.clone().unwrap_or_else(|| {
+ build_jump_hop_default_character_asset(profile_id, draft.theme_text.as_str())
+ });
+ draft.character_asset = Some(character_asset.clone());
+ draft.default_character = Some(default_jump_hop_default_character());
let tile_atlas_asset = draft.tile_atlas_asset.clone().ok_or_else(|| {
SpacetimeClientError::validation_failed(
"jump-hop compile-draft 缺少真实地块图集资产,请先由 api-server 生成并持久化 asset_object",
)
})?;
- let tile_assets = if draft.tile_assets.is_empty() {
+ let tile_assets = if draft.tile_assets.len() < 25 {
return Err(SpacetimeClientError::validation_failed(
- "jump-hop compile-draft 缺少真实地块资产,请先由 api-server 生成并持久化 asset_object",
+ "jump-hop compile-draft 需要 25 个真实地块资产,请先由 api-server 生成并持久化 asset_object",
));
} else {
draft.tile_assets.clone()
@@ -705,7 +785,7 @@ fn build_compile_input(
work_title: draft.work_title.clone(),
work_description: draft.work_description.clone(),
theme_tags_json: Some(json_string(&draft.theme_tags)?),
- theme_text: Some(draft.work_title.clone()),
+ theme_text: Some(draft.theme_text.clone()),
difficulty: Some(difficulty_to_str(&draft.difficulty).to_string()),
style_preset: Some(style_to_str(&draft.style_preset).to_string()),
character_prompt: Some(draft.character_prompt.clone()),
@@ -715,6 +795,11 @@ fn build_compile_input(
tile_atlas_asset_json: Some(json_string(&tile_atlas_asset)?),
tile_assets_json: Some(json_string(&tile_assets)?),
cover_composite,
+ back_button_asset_json: draft
+ .back_button_asset
+ .as_ref()
+ .map(json_string)
+ .transpose()?,
generation_status: Some("ready".to_string()),
compiled_at_micros: now_micros,
})
@@ -785,26 +870,29 @@ fn default_draft() -> JumpHopDraftResponse {
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
profile_id: None,
+ theme_text: JUMP_HOP_TEMPLATE_NAME.to_string(),
work_title: JUMP_HOP_TEMPLATE_NAME.to_string(),
work_description: "俯视角跳跃闯关".to_string(),
theme_tags: vec!["跳一跳".to_string(), "休闲".to_string()],
difficulty: JumpHopDifficulty::Standard,
style_preset: JumpHopStylePreset::MinimalBlocks,
- character_prompt: "俯视角可爱主角,透明背景".to_string(),
- tile_prompt: "等距立体地块图集".to_string(),
+ default_character: Some(default_jump_hop_default_character()),
+ character_prompt: "内置默认 3D 角色".to_string(),
+ tile_prompt: "跳一跳主题的正面30度视角主题物体图集,物体本身作为跳跃落点".to_string(),
end_mood_prompt: None,
character_asset: None,
tile_atlas_asset: None,
tile_assets: Vec::new(),
path: None,
cover_composite: None,
+ back_button_asset: None,
generation_status: JumpHopGenerationStatus::Draft,
}
}
fn build_config_json(draft: &JumpHopDraftResponse) -> Result {
serde_json::to_string(&serde_json::json!({
- "themeText": draft.work_title,
+ "themeText": draft.theme_text,
"difficulty": difficulty_to_str(&draft.difficulty),
"stylePreset": style_to_str(&draft.style_preset),
"characterPrompt": draft.character_prompt,
@@ -814,94 +902,6 @@ fn build_config_json(draft: &JumpHopDraftResponse) -> Result,
- profile_id: &str,
- prompt: &str,
- force_new: bool,
- now_micros: i64,
-) -> JumpHopCharacterAsset {
- if !force_new {
- if let Some(asset) = existing {
- return asset;
- }
- }
- let revision = force_new.then_some(now_micros);
- let suffix = asset_revision_suffix(revision);
- JumpHopCharacterAsset {
- asset_id: format!("{profile_id}-character{suffix}"),
- image_src: format!("/generated-jump-hop-assets/{profile_id}/character{suffix}.png"),
- image_object_key: format!("generated-jump-hop-assets/{profile_id}/character{suffix}.png"),
- asset_object_id: format!("{profile_id}-character{suffix}-object"),
- generation_provider: "deterministic-placeholder".to_string(),
- prompt: prompt.to_string(),
- width: 768,
- height: 768,
- }
-}
-
-fn ensure_tile_atlas_asset(
- existing: Option,
- profile_id: &str,
- prompt: &str,
- force_new: bool,
- now_micros: i64,
-) -> JumpHopCharacterAsset {
- if !force_new {
- if let Some(asset) = existing {
- return asset;
- }
- }
- let revision = force_new.then_some(now_micros);
- let suffix = asset_revision_suffix(revision);
- JumpHopCharacterAsset {
- asset_id: format!("{profile_id}-tile-atlas{suffix}"),
- image_src: format!("/generated-jump-hop-assets/{profile_id}/tile-atlas{suffix}.png"),
- image_object_key: format!("generated-jump-hop-assets/{profile_id}/tile-atlas{suffix}.png"),
- asset_object_id: format!("{profile_id}-tile-atlas{suffix}-object"),
- generation_provider: "deterministic-placeholder".to_string(),
- prompt: prompt.to_string(),
- width: 1024,
- height: 1024,
- }
-}
-
-fn ensure_tile_assets(
- existing: Vec,
- profile_id: &str,
- force_new: bool,
- now_micros: i64,
-) -> Vec {
- if !force_new && !existing.is_empty() {
- return existing;
- }
- let suffix = asset_revision_suffix(force_new.then_some(now_micros));
- [
- JumpHopTileType::Start,
- JumpHopTileType::Normal,
- JumpHopTileType::Target,
- JumpHopTileType::Finish,
- JumpHopTileType::Bonus,
- JumpHopTileType::Accent,
- ]
- .into_iter()
- .enumerate()
- .map(|(index, tile_type)| JumpHopTileAsset {
- tile_type,
- image_src: format!("/generated-jump-hop-assets/{profile_id}/tiles/{index}{suffix}.png"),
- image_object_key: format!(
- "generated-jump-hop-assets/{profile_id}/tiles/{index}{suffix}.png"
- ),
- asset_object_id: format!("{profile_id}-tile-{index}{suffix}-object"),
- source_atlas_cell: format!("cell-{index}{suffix}"),
- visual_width: 256,
- visual_height: 192,
- top_surface_radius: 42.0,
- landing_radius: 34.0,
- })
- .collect()
-}
-
fn resolve_cover_composite(
draft: &JumpHopDraftResponse,
profile_id: &str,
@@ -926,6 +926,22 @@ fn resolve_cover_composite(
))
}
+fn build_jump_hop_default_character_asset(
+ profile_id: &str,
+ theme_text: &str,
+) -> JumpHopCharacterAsset {
+ JumpHopCharacterAsset {
+ asset_id: format!("{profile_id}-builtin-character"),
+ image_src: "builtin://jump-hop/default-character".to_string(),
+ image_object_key: String::new(),
+ asset_object_id: format!("{profile_id}-builtin-character"),
+ generation_provider: "builtin-three".to_string(),
+ prompt: format!("内置默认 3D 角色:{}", theme_text.trim()),
+ width: 0,
+ height: 0,
+ }
+}
+
fn asset_revision_suffix(revision: Option) -> String {
revision
.filter(|value| *value > 0)
@@ -957,6 +973,16 @@ fn style_to_str(value: &JumpHopStylePreset) -> &'static str {
}
}
+fn default_jump_hop_default_character() -> shared_contracts::jump_hop::JumpHopDefaultCharacter {
+ shared_contracts::jump_hop::JumpHopDefaultCharacter {
+ character_id: "jump-hop-default-runner".to_string(),
+ display_name: "默认角色".to_string(),
+ model_kind: "builtin-three".to_string(),
+ body_color: "#f59e0b".to_string(),
+ accent_color: "#2563eb".to_string(),
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -968,8 +994,9 @@ mod tests {
const NOW_MICROS: i64 = 1_763_456_789_000_000;
#[test]
- fn jump_hop_action_compile_draft_builds_compile_input_with_assets() {
- let session = session_with_draft(draft_without_assets());
+ fn jump_hop_action_compile_draft_builds_compile_input_with_25_tile_assets_and_builtin_character()
+ {
+ let session = session_with_draft(draft_without_character_asset());
let payload = action(JumpHopActionType::CompileDraft);
let (plan, draft) =
@@ -987,7 +1014,7 @@ mod tests {
.character_asset_json
.as_deref()
.unwrap_or("")
- .contains("-character")
+ .contains("builtin-three")
);
assert!(
input
@@ -1001,59 +1028,19 @@ mod tests {
.tile_assets_json
.as_deref()
.unwrap_or("")
- .contains("tile-0-object")
+ .contains("old-tile-25-object")
);
+ assert_eq!(draft.tile_assets.len(), 25);
assert_eq!(draft.generation_status, JumpHopGenerationStatus::Ready);
}
- #[test]
- fn jump_hop_action_regenerate_character_replaces_only_character_asset_input() {
- let session = session_with_draft(draft_with_assets());
- let mut payload = action(JumpHopActionType::RegenerateCharacter);
- payload.character_prompt = Some("新的主角提示词".to_string());
-
- let (plan, _draft) =
- build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
- .expect("regenerate-character should build plan");
-
- let JumpHopActionProcedure::Compile(input) = plan else {
- panic!("regenerate-character should call compile_jump_hop_draft");
- };
- assert!(
- !input
- .character_asset_json
- .as_deref()
- .unwrap_or("")
- .contains("old-character")
- );
- assert!(
- input
- .character_asset_json
- .as_deref()
- .unwrap_or("")
- .contains(&NOW_MICROS.to_string())
- );
- assert!(
- input
- .tile_atlas_asset_json
- .as_deref()
- .unwrap_or("")
- .contains("old-tile-atlas")
- );
- assert!(
- input
- .tile_assets_json
- .as_deref()
- .unwrap_or("")
- .contains("old-normal-tile")
- );
- }
-
#[test]
fn jump_hop_action_regenerate_tiles_replaces_only_tile_asset_input() {
let session = session_with_draft(draft_with_assets());
let mut payload = action(JumpHopActionType::RegenerateTiles);
payload.tile_prompt = Some("新的地块提示词".to_string());
+ payload.tile_atlas_asset = Some(tile_atlas_asset("new-tile-atlas", NOW_MICROS));
+ payload.tile_assets = Some(tile_assets("new", 25));
let (plan, _draft) =
build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
@@ -1067,7 +1054,7 @@ mod tests {
.character_asset_json
.as_deref()
.unwrap_or("")
- .contains("old-character")
+ .contains("builtin-three")
);
assert!(
!input
@@ -1081,24 +1068,43 @@ mod tests {
.tile_assets_json
.as_deref()
.unwrap_or("")
- .contains("old-normal-tile")
+ .contains("old-tile-01-object")
);
assert!(
input
.tile_atlas_asset_json
.as_deref()
.unwrap_or("")
- .contains(&NOW_MICROS.to_string())
+ .contains("new-tile-atlas")
);
assert!(
input
.tile_assets_json
.as_deref()
.unwrap_or("")
- .contains(&NOW_MICROS.to_string())
+ .contains("new-tile-25-object")
);
}
+ #[test]
+ fn jump_hop_action_compile_draft_persists_theme_text_separately_from_title() {
+ let session = session_with_draft(draft_without_character_asset());
+ let mut payload = action(JumpHopActionType::CompileDraft);
+ payload.theme_text = Some(" 森林蘑菇跳台 ".to_string());
+ payload.work_title = Some("自动标题".to_string());
+
+ let (plan, draft) =
+ build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
+ .expect("compile-draft should build plan");
+
+ let JumpHopActionProcedure::Compile(input) = plan else {
+ panic!("compile-draft should call compile_jump_hop_draft");
+ };
+ assert_eq!(draft.theme_text, "森林蘑菇跳台");
+ assert_eq!(input.theme_text.as_deref(), Some("森林蘑菇跳台"));
+ assert_eq!(input.work_title, "自动标题");
+ }
+
#[test]
fn jump_hop_action_update_work_meta_builds_update_input_without_asset_compile() {
let session = session_with_draft(draft_with_assets());
@@ -1143,22 +1149,22 @@ mod tests {
.character_asset
.as_ref()
.map(|asset| asset.asset_id.as_str()),
- Some("old-character")
+ Some("jump-hop-profile-test-builtin-character")
);
assert_eq!(
draft
.tile_assets
.first()
.map(|asset| asset.asset_object_id.as_str()),
- Some("old-normal-tile-object")
+ Some("old-tile-01-object")
);
}
- /// 构造不携带资产覆盖的 JumpHop action,单测按需再覆盖字段。
fn action(action_type: JumpHopActionType) -> JumpHopActionRequest {
JumpHopActionRequest {
action_type,
profile_id: None,
+ theme_text: None,
work_title: None,
work_description: None,
theme_tags: None,
@@ -1185,9 +1191,11 @@ mod tests {
}
}
- fn draft_without_assets() -> JumpHopDraftResponse {
+ fn draft_without_character_asset() -> JumpHopDraftResponse {
JumpHopDraftResponse {
profile_id: None,
+ tile_atlas_asset: Some(tile_atlas_asset("old-tile-atlas", 0)),
+ tile_assets: tile_assets("old", 25),
..base_draft()
}
}
@@ -1195,37 +1203,9 @@ mod tests {
fn draft_with_assets() -> JumpHopDraftResponse {
JumpHopDraftResponse {
profile_id: Some(PROFILE_ID.to_string()),
- character_asset: Some(JumpHopCharacterAsset {
- asset_id: "old-character".to_string(),
- image_src: "/generated-jump-hop-assets/old-character.png".to_string(),
- image_object_key: "generated-jump-hop-assets/old-character.png".to_string(),
- asset_object_id: "old-character-object".to_string(),
- generation_provider: "old-provider".to_string(),
- prompt: "旧角色提示词".to_string(),
- width: 768,
- height: 768,
- }),
- tile_atlas_asset: Some(JumpHopCharacterAsset {
- asset_id: "old-tile-atlas".to_string(),
- image_src: "/generated-jump-hop-assets/old-tile-atlas.png".to_string(),
- image_object_key: "generated-jump-hop-assets/old-tile-atlas.png".to_string(),
- asset_object_id: "old-tile-atlas-object".to_string(),
- generation_provider: "old-provider".to_string(),
- prompt: "旧地块提示词".to_string(),
- width: 1024,
- height: 1024,
- }),
- tile_assets: vec![JumpHopTileAsset {
- tile_type: JumpHopTileType::Normal,
- image_src: "/generated-jump-hop-assets/old-normal-tile.png".to_string(),
- image_object_key: "generated-jump-hop-assets/old-normal-tile.png".to_string(),
- asset_object_id: "old-normal-tile-object".to_string(),
- source_atlas_cell: "old-cell".to_string(),
- visual_width: 256,
- visual_height: 192,
- top_surface_radius: 42.0,
- landing_radius: 34.0,
- }],
+ character_asset: Some(build_jump_hop_default_character_asset(PROFILE_ID, "旧主题")),
+ tile_atlas_asset: Some(tile_atlas_asset("old-tile-atlas", 0)),
+ tile_assets: tile_assets("old", 25),
path: Some(sample_jump_hop_path()),
cover_composite: Some("/generated-jump-hop-assets/old-cover.png".to_string()),
generation_status: JumpHopGenerationStatus::Ready,
@@ -1233,16 +1213,58 @@ mod tests {
}
}
+ fn tile_atlas_asset(asset_id: &str, revision: i64) -> JumpHopCharacterAsset {
+ let suffix = asset_revision_suffix((revision > 0).then_some(revision));
+ JumpHopCharacterAsset {
+ asset_id: asset_id.to_string(),
+ image_src: format!("/generated-jump-hop-assets/{asset_id}{suffix}.png"),
+ image_object_key: format!("generated-jump-hop-assets/{asset_id}{suffix}.png"),
+ asset_object_id: format!("{asset_id}-object"),
+ generation_provider: "vector-engine-image2".to_string(),
+ prompt: "旧地块提示词".to_string(),
+ width: 1024,
+ height: 1024,
+ }
+ }
+
+ fn tile_assets(prefix: &str, count: usize) -> Vec {
+ (0..count)
+ .map(|index| JumpHopTileAsset {
+ tile_type: if index == 0 {
+ JumpHopTileType::Start
+ } else {
+ JumpHopTileType::Normal
+ },
+ tile_id: Some(format!("tile-{:02}", index + 1)),
+ image_src: format!("/generated-jump-hop-assets/{prefix}-tile-{}.png", index + 1),
+ image_object_key: format!(
+ "generated-jump-hop-assets/{prefix}-tile-{}.png",
+ index + 1
+ ),
+ asset_object_id: format!("{prefix}-tile-{:02}-object", index + 1),
+ source_atlas_cell: format!("row-{}-col-{}", index / 5 + 1, index % 5 + 1),
+ atlas_row: Some(index as u32 / 5 + 1),
+ atlas_col: Some(index as u32 % 5 + 1),
+ visual_width: 256,
+ visual_height: 192,
+ top_surface_radius: 42.0,
+ landing_radius: 34.0,
+ })
+ .collect()
+ }
+
fn base_draft() -> JumpHopDraftResponse {
JumpHopDraftResponse {
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
profile_id: None,
+ theme_text: "旧主题".to_string(),
work_title: "旧标题".to_string(),
work_description: "旧描述".to_string(),
theme_tags: vec!["旧标签".to_string()],
difficulty: JumpHopDifficulty::Standard,
style_preset: JumpHopStylePreset::MinimalBlocks,
+ default_character: Some(default_jump_hop_default_character()),
character_prompt: "旧角色提示词".to_string(),
tile_prompt: "旧地块提示词".to_string(),
end_mood_prompt: None,
diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs
index 59659f51..bb0c0a13 100644
--- a/server-rs/crates/spacetime-client/src/lib.rs
+++ b/server-rs/crates/spacetime-client/src/lib.rs
@@ -106,7 +106,7 @@ pub mod bark_battle;
pub use bark_battle::{
BarkBattleDraftConfigUpsertRecordInput, BarkBattleDraftCreateRecordInput,
BarkBattleRunFinishRecordInput, BarkBattleRunStartRecordInput,
- BarkBattleWorkPublishRecordInput,
+ BarkBattleWorkDeleteRecordInput, BarkBattleWorkPublishRecordInput,
};
pub mod big_fish;
pub mod combat;
diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs
index 3c836ff3..c1f4c069 100644
--- a/server-rs/crates/spacetime-client/src/mapper.rs
+++ b/server-rs/crates/spacetime-client/src/mapper.rs
@@ -183,8 +183,8 @@ pub(crate) use self::inventory::{
};
pub(crate) use self::jump_hop::{
map_jump_hop_agent_session_procedure_result, map_jump_hop_gallery_card_view_row,
- map_jump_hop_run_procedure_result, map_jump_hop_work_procedure_result,
- map_jump_hop_works_procedure_result,
+ map_jump_hop_leaderboard_procedure_result, map_jump_hop_run_procedure_result,
+ map_jump_hop_work_procedure_result, map_jump_hop_works_procedure_result,
};
pub(crate) use self::match3d::{
map_match3d_agent_session_procedure_result, map_match3d_click_item_procedure_result,
diff --git a/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs b/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs
index a2384840..eec6ba97 100644
--- a/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs
+++ b/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs
@@ -1,10 +1,11 @@
use super::*;
pub use shared_contracts::jump_hop::{
JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset,
- JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse,
+ JumpHopDefaultCharacter, JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse,
JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus,
- JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath,
- JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus,
+ JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump,
+ JumpHopLeaderboardEntry, JumpHopLeaderboardResponse, JumpHopPath, JumpHopPlatform,
+ JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus,
JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse,
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset,
JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
@@ -61,15 +62,40 @@ pub(crate) fn map_jump_hop_run_procedure_result(
Ok(map_jump_hop_run_snapshot(run))
}
+pub(crate) fn map_jump_hop_leaderboard_procedure_result(
+ result: JumpHopLeaderboardProcedureResult,
+) -> Result {
+ if !result.ok {
+ return Err(SpacetimeClientError::procedure_failed(result.error_message));
+ }
+ Ok(JumpHopLeaderboardResponse {
+ profile_id: result.profile_id,
+ items: result
+ .items
+ .into_iter()
+ .map(map_jump_hop_leaderboard_entry_snapshot)
+ .collect(),
+ viewer_best: result
+ .viewer_best
+ .map(map_jump_hop_leaderboard_entry_snapshot),
+ })
+}
+
pub(crate) fn map_jump_hop_gallery_card_view_row(
row: JumpHopGalleryCardViewRow,
) -> JumpHopGalleryCardResponse {
+ let theme_text = if row.theme_text.trim().is_empty() {
+ row.work_title.clone()
+ } else {
+ row.theme_text.clone()
+ };
JumpHopGalleryCardResponse {
public_work_code: row.public_work_code,
work_id: row.work_id,
profile_id: row.profile_id,
owner_user_id: row.owner_user_id,
author_display_name: row.author_display_name,
+ theme_text,
work_title: row.work_title,
work_description: row.work_description,
cover_image_src: empty_string_to_none(row.cover_image_src),
@@ -104,15 +130,22 @@ fn map_jump_hop_session_snapshot(
fn map_jump_hop_work_snapshot(
snapshot: JumpHopWorkSnapshot,
) -> Result {
+ let theme_text = if snapshot.theme_text.trim().is_empty() {
+ snapshot.work_title.clone()
+ } else {
+ snapshot.theme_text.clone()
+ };
let draft = JumpHopDraftResponse {
template_id: "jump-hop".to_string(),
template_name: "跳一跳".to_string(),
profile_id: Some(snapshot.profile_id.clone()),
+ theme_text: theme_text.clone(),
work_title: snapshot.work_title.clone(),
work_description: snapshot.work_description.clone(),
theme_tags: snapshot.theme_tags.clone(),
difficulty: parse_difficulty(&snapshot.difficulty),
style_preset: parse_style_preset(&snapshot.style_preset),
+ default_character: Some(default_jump_hop_character()),
character_prompt: snapshot.character_prompt.clone(),
tile_prompt: snapshot.tile_prompt.clone(),
end_mood_prompt: snapshot.end_mood_prompt.clone(),
@@ -126,6 +159,7 @@ fn map_jump_hop_work_snapshot(
.collect(),
path: Some(map_jump_hop_path(snapshot.path.clone())),
cover_composite: snapshot.cover_composite.clone(),
+ back_button_asset: snapshot.back_button_asset.clone().map(map_character_asset),
generation_status: parse_generation_status(&snapshot.generation_status),
};
let character_asset = draft
@@ -143,6 +177,7 @@ fn map_jump_hop_work_snapshot(
profile_id: snapshot.profile_id,
owner_user_id: snapshot.owner_user_id,
source_session_id: empty_string_to_none(snapshot.source_session_id),
+ theme_text,
work_title: snapshot.work_title,
work_description: snapshot.work_description,
theme_tags: snapshot.theme_tags,
@@ -159,6 +194,7 @@ fn map_jump_hop_work_snapshot(
},
draft,
path: map_jump_hop_path(snapshot.path),
+ default_character: Some(default_jump_hop_character()),
character_asset,
tile_atlas_asset,
tile_assets: snapshot
@@ -166,19 +202,27 @@ fn map_jump_hop_work_snapshot(
.into_iter()
.map(map_tile_asset)
.collect(),
+ back_button_asset: snapshot.back_button_asset.map(map_character_asset),
})
}
fn map_jump_hop_draft_snapshot(snapshot: JumpHopDraftSnapshot) -> JumpHopDraftResponse {
+ let theme_text = if snapshot.theme_text.trim().is_empty() {
+ snapshot.work_title.clone()
+ } else {
+ snapshot.theme_text.clone()
+ };
JumpHopDraftResponse {
template_id: snapshot.template_id,
template_name: snapshot.template_name,
profile_id: snapshot.profile_id,
+ theme_text,
work_title: snapshot.work_title,
work_description: snapshot.work_description,
theme_tags: snapshot.theme_tags,
difficulty: parse_difficulty(&snapshot.difficulty),
style_preset: parse_style_preset(&snapshot.style_preset),
+ default_character: Some(default_jump_hop_character()),
character_prompt: snapshot.character_prompt,
tile_prompt: snapshot.tile_prompt,
end_mood_prompt: snapshot.end_mood_prompt,
@@ -191,6 +235,7 @@ fn map_jump_hop_draft_snapshot(snapshot: JumpHopDraftSnapshot) -> JumpHopDraftRe
.collect(),
path: snapshot.path.map(map_jump_hop_path),
cover_composite: snapshot.cover_composite,
+ back_button_asset: snapshot.back_button_asset.map(map_character_asset),
generation_status: parse_generation_status(&snapshot.generation_status),
}
}
@@ -211,10 +256,13 @@ fn map_character_asset(snapshot: JumpHopCharacterAssetSnapshot) -> JumpHopCharac
fn map_tile_asset(snapshot: JumpHopTileAssetSnapshot) -> JumpHopTileAsset {
JumpHopTileAsset {
tile_type: parse_tile_type(&snapshot.tile_type),
+ tile_id: snapshot.tile_id,
image_src: snapshot.image_src,
image_object_key: snapshot.image_object_key,
asset_object_id: snapshot.asset_object_id,
source_atlas_cell: snapshot.source_atlas_cell,
+ atlas_row: snapshot.atlas_row,
+ atlas_col: snapshot.atlas_col,
visual_width: snapshot.visual_width,
visual_height: snapshot.visual_height,
top_surface_radius: snapshot.top_surface_radius,
@@ -263,6 +311,8 @@ fn map_jump_hop_run_snapshot(snapshot: JumpHopRunSnapshot) -> JumpHopRuntimeRunS
crate::module_bindings::JumpHopRunStatus::Playing => JumpHopRunStatus::Playing,
},
current_platform_index: snapshot.current_platform_index,
+ successful_jump_count: snapshot.current_platform_index,
+ duration_ms: jump_hop_duration_ms(snapshot.started_at_ms, snapshot.finished_at_ms),
score: snapshot.score,
combo: snapshot.combo,
path: map_jump_hop_path(snapshot.path),
@@ -286,6 +336,34 @@ fn map_jump_hop_run_snapshot(snapshot: JumpHopRunSnapshot) -> JumpHopRuntimeRunS
}
}
+fn map_jump_hop_leaderboard_entry_snapshot(
+ snapshot: JumpHopLeaderboardEntrySnapshot,
+) -> JumpHopLeaderboardEntry {
+ JumpHopLeaderboardEntry {
+ rank: snapshot.rank,
+ player_id: snapshot.player_id,
+ successful_jump_count: snapshot.successful_jump_count,
+ duration_ms: snapshot.duration_ms,
+ updated_at: format_timestamp_micros(snapshot.updated_at_micros),
+ }
+}
+
+fn default_jump_hop_character() -> JumpHopDefaultCharacter {
+ JumpHopDefaultCharacter {
+ character_id: "jump-hop-default-runner".to_string(),
+ display_name: "默认角色".to_string(),
+ model_kind: "builtin-three".to_string(),
+ body_color: "#f59e0b".to_string(),
+ accent_color: "#2563eb".to_string(),
+ }
+}
+
+fn jump_hop_duration_ms(started_at_ms: u64, finished_at_ms: Option) -> u64 {
+ finished_at_ms
+ .unwrap_or(started_at_ms)
+ .saturating_sub(started_at_ms)
+}
+
fn parse_difficulty(value: &str) -> JumpHopDifficulty {
match value {
"easy" => JumpHopDifficulty::Easy,
diff --git a/server-rs/crates/spacetime-client/src/mapper/runtime.rs b/server-rs/crates/spacetime-client/src/mapper/runtime.rs
index 5b0ef784..47efee78 100644
--- a/server-rs/crates/spacetime-client/src/mapper/runtime.rs
+++ b/server-rs/crates/spacetime-client/src/mapper/runtime.rs
@@ -296,26 +296,30 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
event_banners_json: header.event_banners_json,
creation_types: creation_types
.into_iter()
- .map(|item| module_runtime::CreationEntryTypeSnapshot {
- id: item.id,
- title: item.title,
- subtitle: item.subtitle,
- badge: item.badge,
- image_src: item.image_src,
- visible: item.visible,
- open: item.open,
- sort_order: item.sort_order,
- category_id: creation_entry_text_or_default(
- item.category_id,
- module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_ID,
- ),
- category_label: creation_entry_text_or_default(
- item.category_label,
- module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_LABEL,
- ),
- category_sort_order: item.category_sort_order,
- updated_at_micros: item.updated_at.to_micros_since_unix_epoch(),
- unified_creation_spec_json: item.unified_creation_spec_json,
+ .map(|item| {
+ normalize_creation_entry_type_snapshot(
+ module_runtime::CreationEntryTypeSnapshot {
+ id: item.id,
+ title: item.title,
+ subtitle: item.subtitle,
+ badge: item.badge,
+ image_src: item.image_src,
+ visible: item.visible,
+ open: item.open,
+ sort_order: item.sort_order,
+ category_id: creation_entry_text_or_default(
+ item.category_id,
+ module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_ID,
+ ),
+ category_label: creation_entry_text_or_default(
+ item.category_label,
+ module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_LABEL,
+ ),
+ category_sort_order: item.category_sort_order,
+ updated_at_micros: item.updated_at.to_micros_since_unix_epoch(),
+ unified_creation_spec_json: item.unified_creation_spec_json,
+ },
+ )
})
.collect(),
updated_at_micros: header.updated_at.to_micros_since_unix_epoch(),
@@ -353,20 +357,22 @@ fn map_creation_entry_config_snapshot(
creation_types: snapshot
.creation_types
.into_iter()
- .map(|item| module_runtime::CreationEntryTypeSnapshot {
- id: item.id,
- title: item.title,
- subtitle: item.subtitle,
- badge: item.badge,
- image_src: item.image_src,
- visible: item.visible,
- open: item.open,
- sort_order: item.sort_order,
- category_id: item.category_id,
- category_label: item.category_label,
- category_sort_order: item.category_sort_order,
- updated_at_micros: item.updated_at_micros,
- unified_creation_spec_json: item.unified_creation_spec_json,
+ .map(|item| {
+ normalize_creation_entry_type_snapshot(module_runtime::CreationEntryTypeSnapshot {
+ id: item.id,
+ title: item.title,
+ subtitle: item.subtitle,
+ badge: item.badge,
+ image_src: item.image_src,
+ visible: item.visible,
+ open: item.open,
+ sort_order: item.sort_order,
+ category_id: item.category_id,
+ category_label: item.category_label,
+ category_sort_order: item.category_sort_order,
+ updated_at_micros: item.updated_at_micros,
+ unified_creation_spec_json: item.unified_creation_spec_json,
+ })
})
.collect(),
updated_at_micros: snapshot.updated_at_micros,
@@ -380,6 +386,150 @@ fn creation_entry_text_or_default(value: Option, default_value: &str) ->
.unwrap_or_else(|| default_value.to_string())
}
+fn normalize_creation_entry_type_snapshot(
+ item: module_runtime::CreationEntryTypeSnapshot,
+) -> module_runtime::CreationEntryTypeSnapshot {
+ // 中文注释:旧库里残留的跳一跳系统默认入口行仍会从订阅缓存命中,这里统一做读模型纠偏,
+ // 这样无论走订阅缓存还是 procedure 回退,创作页都只会看到新的跳一跳入口口径。
+ if item.id == "jump-hop"
+ && item.title == "跳一跳"
+ && item.subtitle == "俯视角跳跃闯关"
+ && item.badge == "可创建"
+ && item.image_src == "/creation-type-references/puzzle.webp"
+ && item.visible
+ && item.open
+ && item.sort_order == 45
+ {
+ return module_runtime::CreationEntryTypeSnapshot {
+ subtitle: "主题驱动平台跳跃".to_string(),
+ image_src: "/creation-type-references/jump-hop.webp".to_string(),
+ ..item
+ };
+ }
+
+ item
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use spacetimedb_sdk::Timestamp;
+
+ fn build_creation_entry_header() -> CreationEntryConfig {
+ CreationEntryConfig {
+ config_id: "creation-entry-config".to_string(),
+ start_title: "新建作品".to_string(),
+ start_description: "选择模板后进入对应的创作表单。".to_string(),
+ start_idle_badge: "模板 Tab".to_string(),
+ start_busy_badge: "正在开启".to_string(),
+ modal_title: "选择创作类型".to_string(),
+ modal_description: "先选玩法类型,再进入对应创作工作台。".to_string(),
+ updated_at: Timestamp::from_micros_since_unix_epoch(1_000_000),
+ event_title: None,
+ event_description: None,
+ event_cover_image_src: None,
+ event_prize_pool_mud_points: 0,
+ event_starts_at_text: None,
+ event_ends_at_text: None,
+ event_banners_json: None,
+ }
+ }
+
+ fn build_old_jump_hop_row() -> CreationEntryTypeConfig {
+ CreationEntryTypeConfig {
+ id: "jump-hop".to_string(),
+ title: "跳一跳".to_string(),
+ subtitle: "俯视角跳跃闯关".to_string(),
+ badge: "可创建".to_string(),
+ image_src: "/creation-type-references/puzzle.webp".to_string(),
+ visible: true,
+ open: true,
+ sort_order: 45,
+ updated_at: Timestamp::from_micros_since_unix_epoch(2_000_000),
+ category_id: Some("recommended".to_string()),
+ category_label: Some("热门推荐".to_string()),
+ category_sort_order: 20,
+ unified_creation_spec_json: None,
+ }
+ }
+
+ #[test]
+ fn build_creation_entry_config_record_from_rows_normalizes_old_jump_hop_row() {
+ let record = build_creation_entry_config_record_from_rows(
+ build_creation_entry_header(),
+ vec![build_old_jump_hop_row()],
+ );
+
+ let jump_hop = record
+ .creation_types
+ .iter()
+ .find(|item| item.id == "jump-hop")
+ .expect("should contain jump-hop");
+
+ assert_eq!(jump_hop.subtitle, "主题驱动平台跳跃");
+ assert_eq!(
+ jump_hop.image_src,
+ "/creation-type-references/jump-hop.webp"
+ );
+ }
+
+ #[test]
+ fn map_creation_entry_config_snapshot_normalizes_old_jump_hop_snapshot() {
+ let record = map_creation_entry_config_snapshot(CreationEntryConfigSnapshot {
+ config_id: "creation-entry-config".to_string(),
+ start_card: CreationEntryStartCardSnapshot {
+ title: "新建作品".to_string(),
+ description: "选择模板后进入对应的创作表单。".to_string(),
+ idle_badge: "模板 Tab".to_string(),
+ busy_badge: "正在开启".to_string(),
+ },
+ type_modal: CreationEntryTypeModalSnapshot {
+ title: "选择创作类型".to_string(),
+ description: "先选玩法类型,再进入对应创作工作台。".to_string(),
+ },
+ event_banner: CreationEntryEventBannerSnapshot {
+ title: "主题创作赛".to_string(),
+ description: "用温暖的色彩,捏出秋天的故事。".to_string(),
+ cover_image_src: "/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png".to_string(),
+ prize_pool_mud_points: 58_000,
+ starts_at_text: "2024.10.20 10:00".to_string(),
+ ends_at_text: "2024.11.20 23:59".to_string(),
+ render_mode: "structured".to_string(),
+ html_code: None,
+ },
+ event_banners_json: None,
+ creation_types: vec![CreationEntryTypeSnapshot {
+ id: "jump-hop".to_string(),
+ title: "跳一跳".to_string(),
+ subtitle: "俯视角跳跃闯关".to_string(),
+ badge: "可创建".to_string(),
+ image_src: "/creation-type-references/puzzle.webp".to_string(),
+ visible: true,
+ open: true,
+ sort_order: 45,
+ category_id: "recommended".to_string(),
+ category_label: "热门推荐".to_string(),
+ category_sort_order: 20,
+ updated_at_micros: 2_000_000,
+ unified_creation_spec_json: None,
+ }],
+ updated_at_micros: 1_000_000,
+ });
+
+ let jump_hop = record
+ .creation_types
+ .iter()
+ .find(|item| item.id == "jump-hop")
+ .expect("should contain jump-hop");
+
+ assert_eq!(jump_hop.subtitle, "主题驱动平台跳跃");
+ assert_eq!(
+ jump_hop.image_src,
+ "/creation-type-references/jump-hop.webp"
+ );
+ }
+}
+
pub(crate) fn map_runtime_setting_procedure_result(
result: RuntimeSettingProcedureResult,
) -> Result {
diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs
index ef444fe6..b1edf48c 100644
--- a/server-rs/crates/spacetime-client/src/module_bindings.rs
+++ b/server-rs/crates/spacetime-client/src/module_bindings.rs
@@ -126,6 +126,7 @@ pub mod bark_battle_runtime_run_row_type;
pub mod bark_battle_runtime_run_table;
pub mod bark_battle_score_record_row_type;
pub mod bark_battle_score_record_table;
+pub mod bark_battle_work_delete_input_type;
pub mod bark_battle_work_publish_input_type;
pub mod bark_battle_work_stats_projection_row_type;
pub mod bark_battle_work_stats_projection_table;
@@ -328,14 +329,17 @@ pub mod database_migration_procedure_result_type;
pub mod database_migration_revoke_operator_input_type;
pub mod database_migration_table_stat_type;
pub mod database_migration_warning_type;
+pub mod delete_bark_battle_work_procedure;
pub mod delete_big_fish_work_procedure;
pub mod delete_custom_world_agent_session_procedure;
pub mod delete_custom_world_profile_and_return_procedure;
+pub mod delete_jump_hop_work_procedure;
pub mod delete_match_3_d_work_procedure;
pub mod delete_puzzle_work_procedure;
pub mod delete_runtime_snapshot_and_return_procedure;
pub mod delete_square_hole_work_procedure;
pub mod delete_visual_novel_work_procedure;
+pub mod delete_wooden_fish_work_procedure;
pub mod drag_puzzle_piece_or_group_procedure;
pub mod drop_square_hole_shape_procedure;
pub mod ensure_analytics_date_dimension_for_date_reducer;
@@ -369,6 +373,7 @@ pub mod get_custom_world_gallery_detail_by_code_procedure;
pub mod get_custom_world_gallery_detail_procedure;
pub mod get_custom_world_library_detail_procedure;
pub mod get_jump_hop_agent_session_procedure;
+pub mod get_jump_hop_leaderboard_procedure;
pub mod get_jump_hop_run_procedure;
pub mod get_jump_hop_work_profile_procedure;
pub mod get_match_3_d_agent_session_procedure;
@@ -440,6 +445,11 @@ pub mod jump_hop_gallery_view_table;
pub mod jump_hop_jump_procedure;
pub mod jump_hop_jump_result_kind_type;
pub mod jump_hop_last_jump_type;
+pub mod jump_hop_leaderboard_entry_row_type;
+pub mod jump_hop_leaderboard_entry_snapshot_type;
+pub mod jump_hop_leaderboard_entry_table;
+pub mod jump_hop_leaderboard_get_input_type;
+pub mod jump_hop_leaderboard_procedure_result_type;
pub mod jump_hop_path_type;
pub mod jump_hop_platform_type;
pub mod jump_hop_run_get_input_type;
@@ -454,6 +464,7 @@ pub mod jump_hop_runtime_run_table;
pub mod jump_hop_scoring_type;
pub mod jump_hop_tile_asset_snapshot_type;
pub mod jump_hop_tile_type_type;
+pub mod jump_hop_work_delete_input_type;
pub mod jump_hop_work_get_input_type;
pub mod jump_hop_work_procedure_result_type;
pub mod jump_hop_work_profile_row_type;
@@ -1088,6 +1099,7 @@ pub mod wooden_fish_run_status_type;
pub mod wooden_fish_runtime_run_row_type;
pub mod wooden_fish_runtime_run_table;
pub mod wooden_fish_word_counter_type;
+pub mod wooden_fish_work_delete_input_type;
pub mod wooden_fish_work_get_input_type;
pub mod wooden_fish_work_procedure_result_type;
pub mod wooden_fish_work_profile_row_type;
@@ -1218,6 +1230,7 @@ pub use bark_battle_runtime_run_row_type::BarkBattleRuntimeRunRow;
pub use bark_battle_runtime_run_table::*;
pub use bark_battle_score_record_row_type::BarkBattleScoreRecordRow;
pub use bark_battle_score_record_table::*;
+pub use bark_battle_work_delete_input_type::BarkBattleWorkDeleteInput;
pub use bark_battle_work_publish_input_type::BarkBattleWorkPublishInput;
pub use bark_battle_work_stats_projection_row_type::BarkBattleWorkStatsProjectionRow;
pub use bark_battle_work_stats_projection_table::*;
@@ -1420,14 +1433,17 @@ pub use database_migration_procedure_result_type::DatabaseMigrationProcedureResu
pub use database_migration_revoke_operator_input_type::DatabaseMigrationRevokeOperatorInput;
pub use database_migration_table_stat_type::DatabaseMigrationTableStat;
pub use database_migration_warning_type::DatabaseMigrationWarning;
+pub use delete_bark_battle_work_procedure::delete_bark_battle_work;
pub use delete_big_fish_work_procedure::delete_big_fish_work;
pub use delete_custom_world_agent_session_procedure::delete_custom_world_agent_session;
pub use delete_custom_world_profile_and_return_procedure::delete_custom_world_profile_and_return;
+pub use delete_jump_hop_work_procedure::delete_jump_hop_work;
pub use delete_match_3_d_work_procedure::delete_match_3_d_work;
pub use delete_puzzle_work_procedure::delete_puzzle_work;
pub use delete_runtime_snapshot_and_return_procedure::delete_runtime_snapshot_and_return;
pub use delete_square_hole_work_procedure::delete_square_hole_work;
pub use delete_visual_novel_work_procedure::delete_visual_novel_work;
+pub use delete_wooden_fish_work_procedure::delete_wooden_fish_work;
pub use drag_puzzle_piece_or_group_procedure::drag_puzzle_piece_or_group;
pub use drop_square_hole_shape_procedure::drop_square_hole_shape;
pub use ensure_analytics_date_dimension_for_date_reducer::ensure_analytics_date_dimension_for_date;
@@ -1461,6 +1477,7 @@ pub use get_custom_world_gallery_detail_by_code_procedure::get_custom_world_gall
pub use get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail;
pub use get_custom_world_library_detail_procedure::get_custom_world_library_detail;
pub use get_jump_hop_agent_session_procedure::get_jump_hop_agent_session;
+pub use get_jump_hop_leaderboard_procedure::get_jump_hop_leaderboard;
pub use get_jump_hop_run_procedure::get_jump_hop_run;
pub use get_jump_hop_work_profile_procedure::get_jump_hop_work_profile;
pub use get_match_3_d_agent_session_procedure::get_match_3_d_agent_session;
@@ -1532,6 +1549,11 @@ pub use jump_hop_gallery_view_table::*;
pub use jump_hop_jump_procedure::jump_hop_jump;
pub use jump_hop_jump_result_kind_type::JumpHopJumpResultKind;
pub use jump_hop_last_jump_type::JumpHopLastJump;
+pub use jump_hop_leaderboard_entry_row_type::JumpHopLeaderboardEntryRow;
+pub use jump_hop_leaderboard_entry_snapshot_type::JumpHopLeaderboardEntrySnapshot;
+pub use jump_hop_leaderboard_entry_table::*;
+pub use jump_hop_leaderboard_get_input_type::JumpHopLeaderboardGetInput;
+pub use jump_hop_leaderboard_procedure_result_type::JumpHopLeaderboardProcedureResult;
pub use jump_hop_path_type::JumpHopPath;
pub use jump_hop_platform_type::JumpHopPlatform;
pub use jump_hop_run_get_input_type::JumpHopRunGetInput;
@@ -1546,6 +1568,7 @@ pub use jump_hop_runtime_run_table::*;
pub use jump_hop_scoring_type::JumpHopScoring;
pub use jump_hop_tile_asset_snapshot_type::JumpHopTileAssetSnapshot;
pub use jump_hop_tile_type_type::JumpHopTileType;
+pub use jump_hop_work_delete_input_type::JumpHopWorkDeleteInput;
pub use jump_hop_work_get_input_type::JumpHopWorkGetInput;
pub use jump_hop_work_procedure_result_type::JumpHopWorkProcedureResult;
pub use jump_hop_work_profile_row_type::JumpHopWorkProfileRow;
@@ -2180,6 +2203,7 @@ pub use wooden_fish_run_status_type::WoodenFishRunStatus;
pub use wooden_fish_runtime_run_row_type::WoodenFishRuntimeRunRow;
pub use wooden_fish_runtime_run_table::*;
pub use wooden_fish_word_counter_type::WoodenFishWordCounter;
+pub use wooden_fish_work_delete_input_type::WoodenFishWorkDeleteInput;
pub use wooden_fish_work_get_input_type::WoodenFishWorkGetInput;
pub use wooden_fish_work_procedure_result_type::WoodenFishWorkProcedureResult;
pub use wooden_fish_work_profile_row_type::WoodenFishWorkProfileRow;
@@ -2506,6 +2530,7 @@ pub struct DbUpdate {
jump_hop_event: __sdk::TableUpdate,
jump_hop_gallery_card_view: __sdk::TableUpdate,
jump_hop_gallery_view: __sdk::TableUpdate,
+ jump_hop_leaderboard_entry: __sdk::TableUpdate,
jump_hop_runtime_run: __sdk::TableUpdate,
jump_hop_work_profile: __sdk::TableUpdate,
match_3_d_agent_message: __sdk::TableUpdate,
@@ -2726,6 +2751,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
"jump_hop_gallery_view" => db_update.jump_hop_gallery_view.append(
jump_hop_gallery_view_table::parse_table_update(table_update)?,
),
+ "jump_hop_leaderboard_entry" => db_update.jump_hop_leaderboard_entry.append(
+ jump_hop_leaderboard_entry_table::parse_table_update(table_update)?,
+ ),
"jump_hop_runtime_run" => db_update.jump_hop_runtime_run.append(
jump_hop_runtime_run_table::parse_table_update(table_update)?,
),
@@ -3175,6 +3203,12 @@ impl __sdk::DbUpdate for DbUpdate {
diff.jump_hop_event = cache
.apply_diff_to_table::("jump_hop_event", &self.jump_hop_event)
.with_updates_by_pk(|row| &row.event_id);
+ diff.jump_hop_leaderboard_entry = cache
+ .apply_diff_to_table::(
+ "jump_hop_leaderboard_entry",
+ &self.jump_hop_leaderboard_entry,
+ )
+ .with_updates_by_pk(|row| &row.entry_id);
diff.jump_hop_runtime_run = cache
.apply_diff_to_table::(
"jump_hop_runtime_run",
@@ -3693,6 +3727,9 @@ impl __sdk::DbUpdate for DbUpdate {
"jump_hop_gallery_view" => db_update
.jump_hop_gallery_view
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
+ "jump_hop_leaderboard_entry" => db_update
+ .jump_hop_leaderboard_entry
+ .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"jump_hop_runtime_run" => db_update
.jump_hop_runtime_run
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
@@ -4054,6 +4091,9 @@ impl __sdk::DbUpdate for DbUpdate {
"jump_hop_gallery_view" => db_update
.jump_hop_gallery_view
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
+ "jump_hop_leaderboard_entry" => db_update
+ .jump_hop_leaderboard_entry
+ .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"jump_hop_runtime_run" => db_update
.jump_hop_runtime_run
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
@@ -4331,6 +4371,7 @@ pub struct AppliedDiff<'r> {
jump_hop_event: __sdk::TableAppliedDiff<'r, JumpHopEventRow>,
jump_hop_gallery_card_view: __sdk::TableAppliedDiff<'r, JumpHopGalleryCardViewRow>,
jump_hop_gallery_view: __sdk::TableAppliedDiff<'r, JumpHopGalleryViewRow>,
+ jump_hop_leaderboard_entry: __sdk::TableAppliedDiff<'r, JumpHopLeaderboardEntryRow>,
jump_hop_runtime_run: __sdk::TableAppliedDiff<'r, JumpHopRuntimeRunRow>,
jump_hop_work_profile: __sdk::TableAppliedDiff<'r, JumpHopWorkProfileRow>,
match_3_d_agent_message: __sdk::TableAppliedDiff<'r, Match3DAgentMessageRow>,
@@ -4629,6 +4670,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
&self.jump_hop_gallery_view,
event,
);
+ callbacks.invoke_table_row_callbacks::(
+ "jump_hop_leaderboard_entry",
+ &self.jump_hop_leaderboard_entry,
+ event,
+ );
callbacks.invoke_table_row_callbacks::(
"jump_hop_runtime_run",
&self.jump_hop_runtime_run,
@@ -5232,19 +5278,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
{
}
@@ -5681,6 +5727,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
jump_hop_event_table::register_table(client_cache);
jump_hop_gallery_card_view_table::register_table(client_cache);
jump_hop_gallery_view_table::register_table(client_cache);
+ jump_hop_leaderboard_entry_table::register_table(client_cache);
jump_hop_runtime_run_table::register_table(client_cache);
jump_hop_work_profile_table::register_table(client_cache);
match_3_d_agent_message_table::register_table(client_cache);
@@ -5799,6 +5846,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
"jump_hop_event",
"jump_hop_gallery_card_view",
"jump_hop_gallery_view",
+ "jump_hop_leaderboard_entry",
"jump_hop_runtime_run",
"jump_hop_work_profile",
"match_3_d_agent_message",
diff --git a/server-rs/crates/spacetime-client/src/module_bindings/accept_quest_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/accept_quest_reducer.rs
index dfebf903..61e6b9c5 100644
--- a/server-rs/crates/spacetime-client/src/module_bindings/accept_quest_reducer.rs
+++ b/server-rs/crates/spacetime-client/src/module_bindings/accept_quest_reducer.rs
@@ -47,9 +47,11 @@ pub trait accept_quest {
&self,
input: QuestRecordInput,
- callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>)
- + Send
- + 'static,
+ callback: impl FnOnce(
+ &super::ReducerEventContext,
+ Result, __sdk::InternalError>,
+ ) + Send
+ + 'static,
) -> __sdk::Result<()>;
}
@@ -58,9 +60,11 @@ impl accept_quest for super::RemoteReducers {
&self,
input: QuestRecordInput,
- callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>)
- + Send
- + 'static,
+ callback: impl FnOnce(
+ &super::ReducerEventContext,
+ Result, __sdk::InternalError>,
+ ) + Send
+ + 'static,
) -> __sdk::Result<()> {
self.imp
.invoke_reducer_with_callback(AcceptQuestArgs { input }, callback)
diff --git a/server-rs/crates/spacetime-client/src/module_bindings/acknowledge_quest_completion_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/acknowledge_quest_completion_reducer.rs
index 6ae2fd10..b1419fb7 100644
--- a/server-rs/crates/spacetime-client/src/module_bindings/acknowledge_quest_completion_reducer.rs
+++ b/server-rs/crates/spacetime-client/src/module_bindings/acknowledge_quest_completion_reducer.rs
@@ -47,9 +47,11 @@ pub trait acknowledge_quest_completion {
&self,
input: QuestCompletionAckInput,
- callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>)
- + Send
- + 'static,
+ callback: impl FnOnce(
+ &super::ReducerEventContext,
+ Result, __sdk::InternalError>,
+ ) + Send
+ + 'static,
) -> __sdk::Result<()>;
}
@@ -58,9 +60,11 @@ impl acknowledge_quest_completion for super::RemoteReducers {
&self,
input: QuestCompletionAckInput,
- callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>)
- + Send
- + 'static,
+ callback: impl FnOnce(
+ &super::ReducerEventContext,
+ Result, __sdk::InternalError>,
+ ) + Send
+ + 'static,
) -> __sdk::Result<()> {
self.imp
.invoke_reducer_with_callback(AcknowledgeQuestCompletionArgs { input }, callback)
diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs
index bbdaab4f..9865ace5 100644
--- a/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs
+++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs
@@ -31,10 +31,10 @@ pub trait admin_disable_profile_redeem_code {
input: RuntimeProfileRedeemCodeAdminDisableInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
);
}
@@ -44,10 +44,10 @@ impl admin_disable_profile_redeem_code for super::RemoteProcedures {
input: RuntimeProfileRedeemCodeAdminDisableInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>(
diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_task_config_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_task_config_procedure.rs
index c968f950..0417bd2e 100644
--- a/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_task_config_procedure.rs
+++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_task_config_procedure.rs
@@ -31,10 +31,10 @@ pub trait admin_disable_profile_task_config {
input: RuntimeProfileTaskConfigAdminDisableInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
);
}
@@ -44,10 +44,10 @@ impl admin_disable_profile_task_config for super::RemoteProcedures {
input: RuntimeProfileTaskConfigAdminDisableInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminProcedureResult>(
diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_invite_codes_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_invite_codes_procedure.rs
index cdfa27d9..96d2350f 100644
--- a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_invite_codes_procedure.rs
+++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_invite_codes_procedure.rs
@@ -31,10 +31,10 @@ pub trait admin_list_profile_invite_codes {
input: RuntimeProfileInviteCodeAdminListInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
);
}
@@ -44,10 +44,10 @@ impl admin_list_profile_invite_codes for super::RemoteProcedures {
input: RuntimeProfileInviteCodeAdminListInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileInviteCodeAdminListProcedureResult>(
diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_recharge_products_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_recharge_products_procedure.rs
index e84d4ec6..a1deed88 100644
--- a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_recharge_products_procedure.rs
+++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_recharge_products_procedure.rs
@@ -34,10 +34,10 @@ pub trait admin_list_profile_recharge_products {
input: RuntimeProfileRechargeProductAdminListInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
);
}
@@ -47,10 +47,10 @@ impl admin_list_profile_recharge_products for super::RemoteProcedures {
input: RuntimeProfileRechargeProductAdminListInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
) {
self.imp.invoke_procedure_with_callback::<_, RuntimeProfileRechargeProductAdminListProcedureResult>(
"admin_list_profile_recharge_products",
diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_redeem_codes_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_redeem_codes_procedure.rs
index 2c9b9dd7..c7d6a78e 100644
--- a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_redeem_codes_procedure.rs
+++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_redeem_codes_procedure.rs
@@ -31,10 +31,10 @@ pub trait admin_list_profile_redeem_codes {
input: RuntimeProfileRedeemCodeAdminListInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
);
}
@@ -44,10 +44,10 @@ impl admin_list_profile_redeem_codes for super::RemoteProcedures {
input: RuntimeProfileRedeemCodeAdminListInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminListProcedureResult>(
diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_task_configs_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_task_configs_procedure.rs
index 88ca28d5..a152116d 100644
--- a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_task_configs_procedure.rs
+++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_task_configs_procedure.rs
@@ -31,10 +31,10 @@ pub trait admin_list_profile_task_configs {
input: RuntimeProfileTaskConfigAdminListInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
);
}
@@ -44,10 +44,10 @@ impl admin_list_profile_task_configs for super::RemoteProcedures {
input: RuntimeProfileTaskConfigAdminListInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminListProcedureResult>(
diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_work_visibility_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_work_visibility_procedure.rs
index 72028b5e..df222e41 100644
--- a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_work_visibility_procedure.rs
+++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_work_visibility_procedure.rs
@@ -31,10 +31,10 @@ pub trait admin_list_work_visibility {
input: AdminWorkVisibilityListInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
);
}
@@ -44,10 +44,10 @@ impl admin_list_work_visibility for super::RemoteProcedures {
input: AdminWorkVisibilityListInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, AdminWorkVisibilityListProcedureResult>(
diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_update_work_visibility_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_update_work_visibility_procedure.rs
index bbc85a89..4a88c084 100644
--- a/server-rs/crates/spacetime-client/src/module_bindings/admin_update_work_visibility_procedure.rs
+++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_update_work_visibility_procedure.rs
@@ -31,10 +31,10 @@ pub trait admin_update_work_visibility {
input: AdminWorkVisibilityUpdateInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
);
}
@@ -44,10 +44,10 @@ impl admin_update_work_visibility for super::RemoteProcedures {
input: AdminWorkVisibilityUpdateInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, AdminWorkVisibilityProcedureResult>(
diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs
index 3601be97..2411092d 100644
--- a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs
+++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs
@@ -31,10 +31,10 @@ pub trait admin_upsert_profile_invite_code {
input: RuntimeProfileInviteCodeAdminUpsertInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
);
}
@@ -44,10 +44,10 @@ impl admin_upsert_profile_invite_code for super::RemoteProcedures {
input: RuntimeProfileInviteCodeAdminUpsertInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileInviteCodeAdminProcedureResult>(
diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_recharge_product_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_recharge_product_procedure.rs
index e3f42278..83941b83 100644
--- a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_recharge_product_procedure.rs
+++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_recharge_product_procedure.rs
@@ -34,10 +34,10 @@ pub trait admin_upsert_profile_recharge_product {
input: RuntimeProfileRechargeProductAdminUpsertInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
);
}
@@ -47,10 +47,10 @@ impl admin_upsert_profile_recharge_product for super::RemoteProcedures {
input: RuntimeProfileRechargeProductAdminUpsertInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileRechargeProductAdminProcedureResult>(
diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs
index 7e918220..9c7ae92f 100644
--- a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs
+++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs
@@ -31,10 +31,10 @@ pub trait admin_upsert_profile_redeem_code {
input: RuntimeProfileRedeemCodeAdminUpsertInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
);
}
@@ -44,10 +44,10 @@ impl admin_upsert_profile_redeem_code for super::RemoteProcedures {
input: RuntimeProfileRedeemCodeAdminUpsertInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>(
diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_task_config_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_task_config_procedure.rs
index a3d3e11a..b441a808 100644
--- a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_task_config_procedure.rs
+++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_task_config_procedure.rs
@@ -31,10 +31,10 @@ pub trait admin_upsert_profile_task_config {
input: RuntimeProfileTaskConfigAdminUpsertInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
);
}
@@ -44,10 +44,10 @@ impl admin_upsert_profile_task_config for super::RemoteProcedures {
input: RuntimeProfileTaskConfigAdminUpsertInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminProcedureResult>(
diff --git a/server-rs/crates/spacetime-client/src/module_bindings/advance_puzzle_next_level_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/advance_puzzle_next_level_procedure.rs
index 6d3e9f79..7cb4f8f4 100644
--- a/server-rs/crates/spacetime-client/src/module_bindings/advance_puzzle_next_level_procedure.rs
+++ b/server-rs/crates/spacetime-client/src/module_bindings/advance_puzzle_next_level_procedure.rs
@@ -31,10 +31,10 @@ pub trait advance_puzzle_next_level {
input: PuzzleRunNextLevelInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
);
}
@@ -44,10 +44,10 @@ impl advance_puzzle_next_level for super::RemoteProcedures {
input: PuzzleRunNextLevelInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, PuzzleRunProcedureResult>(
diff --git a/server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs
index 11323392..191e2ea7 100644
--- a/server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs
+++ b/server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs
@@ -31,10 +31,10 @@ pub trait append_ai_text_chunk_and_return {
input: AiTextChunkAppendInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
);
}
@@ -44,10 +44,10 @@ impl append_ai_text_chunk_and_return for super::RemoteProcedures {
input: AiTextChunkAppendInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, AiTaskProcedureResult>(
diff --git a/server-rs/crates/spacetime-client/src/module_bindings/append_visual_novel_runtime_history_entry_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/append_visual_novel_runtime_history_entry_procedure.rs
index 4686ba5c..ad1099d0 100644
--- a/server-rs/crates/spacetime-client/src/module_bindings/append_visual_novel_runtime_history_entry_procedure.rs
+++ b/server-rs/crates/spacetime-client/src/module_bindings/append_visual_novel_runtime_history_entry_procedure.rs
@@ -34,10 +34,10 @@ pub trait append_visual_novel_runtime_history_entry {
input: VisualNovelRuntimeHistoryAppendInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
);
}
@@ -47,10 +47,10 @@ impl append_visual_novel_runtime_history_entry for super::RemoteProcedures {
input: VisualNovelRuntimeHistoryAppendInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result,
+ ) + Send
+ + 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, VisualNovelHistoryProcedureResult>(
diff --git a/server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_and_return_procedure.rs
index bba4c841..4a949906 100644
--- a/server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_and_return_procedure.rs
+++ b/server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_and_return_procedure.rs
@@ -34,10 +34,10 @@ pub trait apply_chapter_progression_ledger_entry_and_return {
input: ChapterProgressionLedgerInput,
__callback: impl FnOnce(
- &super::ProcedureEventContext,
- Result,
- ) + Send
- + 'static,
+ &super::ProcedureEventContext,
+ Result