Merge remote-tracking branch 'origin/master' into codex/editor-asset-library
# Conflicts: # server-rs/crates/spacetime-client/src/lib.rs # server-rs/crates/spacetime-client/src/mapper.rs # server-rs/crates/spacetime-client/src/module_bindings.rs # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
This commit is contained in:
@@ -22,6 +22,7 @@ tmp
|
||||
.env.secrets.*
|
||||
spacetime.local.json
|
||||
deploy/container/api-server.env
|
||||
deploy/container/worker-smoke
|
||||
|
||||
server-rs/target
|
||||
server-rs/target-*
|
||||
|
||||
@@ -42,6 +42,7 @@ temp*build*/
|
||||
.env.secrets.local
|
||||
spacetime.local.json
|
||||
deploy/container/api-server.env
|
||||
deploy/container/worker-smoke/
|
||||
|
||||
# Local load-test data extracted from private migration files
|
||||
scripts/loadtest/data/*.local.json
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -52,6 +52,8 @@ npm run dev
|
||||
|
||||
Linux 多用户共享同一台机器开发时,本地 dev 脚本会为当前 Linux 用户分配一个固定端口段并写入系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,自动分配从 `10000-10099` 开始,每段 100 个端口,四个 dev 服务依次使用 `start` 到 `start + 3`。可用 `GENARRATIVE_DEV_PORT_RANGE` 或 `npm run dev -- --port-range` 手动指定端口段用于特殊场景;注册表会阻止不同用户使用相同或重叠段,并让同一用户后续启动继续复用自己已占用的固定段。该机制只在 Linux 生效,Windows 仍沿用原有端口探测与漂移逻辑。
|
||||
|
||||
本地 `npm run dev`、`npm run dev:spacetime` 和 `npm run dev:api-server` 会在 Rust 子进程环境中绕过项目默认 `sccache` wrapper,避免损坏的本机 cache daemon 阻断 `spacetime publish` 或 `api-server` 启动;显式设置的非 sccache 自定义 wrapper 会被保留。生产 / Jenkins 构建仍按流水线自身的 sccache 策略执行。
|
||||
|
||||
该命令会启动:
|
||||
|
||||
- SpacetimeDB standalone
|
||||
@@ -95,6 +97,15 @@ npm run dev:web
|
||||
npm run dev:admin-web
|
||||
```
|
||||
|
||||
本地 SSH 服务器管理面板:
|
||||
|
||||
```bash
|
||||
npm run server-manager:panel
|
||||
```
|
||||
|
||||
该命令启动 `server-rs/crates/server-manager-panel` 的 egui 桌面工具,从本机 `~/.ssh/config` 读取可用 `Host` alias,支持多服务器健康巡检、可折叠侧边栏和受控 systemd 服务启停。服务操作通过远端 `sudo -n systemctl start|stop|restart <unit>` 执行,目标服务器需要提前配置对应 unit 的免交互 sudo 权限。
|
||||
面板启动时会自动注入本机中文字体;如开发机中文仍显示为方块,可设置 `GENARRATIVE_SERVER_PANEL_CJK_FONT=/path/to/font.ttc|index` 指向本机 CJK 字体。
|
||||
|
||||
`npm run dev:api-server` 会保留终端实时输出,并把同一份输出持久化到 `logs/api-server/api-server-<timestamp>.log`。完整联调入口 `npm run dev` 启动的 Rust `api-server` 使用同一套日志规则。如需改写路径,可设置 `GENARRATIVE_API_SERVER_LOG_FILE`;如只改目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR`。
|
||||
|
||||
开发态 `npm run dev` / `npm run dev:api-server` 默认打开 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,密码入口可以直接注册未知手机号账号;生产默认仍关闭该开关。
|
||||
@@ -109,6 +120,16 @@ npm run dev:admin-web
|
||||
npm run dev:spacetime:logs
|
||||
```
|
||||
|
||||
本机隔离验证外部生成 worker 队列、API-only 更新和 worker 动态扩缩容时,优先使用:
|
||||
|
||||
```bash
|
||||
npm run container:worker-smoke -- smoke
|
||||
```
|
||||
|
||||
该命令生成 `deploy/container/worker-smoke/` 下的 gitignored env 与端口 state,启动独立 compose project 和独立 SpacetimeDB,用 unsupported job 验证 worker claim / fail 回写;排查时用 `api-update` 确认 API 重建不触碰 worker,用 `scale <n>` 调整 worker 数量。
|
||||
`external_generation_job` 是 private table,worker-smoke 通过 worker 日志里的 job_id 和 unsupported 记录确认消费,不通过 CLI SQL 查询队列表。
|
||||
worker-smoke 默认把本机 `spacetime` CLI 打成轻量 SpacetimeDB 镜像,避免首次 smoke 依赖官方大镜像下载。若容器内 Cargo 下载依赖不稳定,追加 `--local-binary`,让容器内 Cargo 复用本机 Cargo 缓存构建当前 `api-server` 二进制,并把产物放进 Debian bookworm smoke runtime;可用 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE` 覆盖运行时基础镜像;隔离端口或库数据需要重建时追加 `--force`。
|
||||
|
||||
后台管理前端:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -15,6 +15,38 @@
|
||||
- 关联:相关文件、文档、提交或 Issue
|
||||
```
|
||||
|
||||
## 外部生成 worker 业务失败重试会撞上钱包扣退费幂等
|
||||
|
||||
- 现象:同一个外部生成 job 如果第一次业务失败后退款,再用同一个业务资源 ID 自动重试并成功,钱包 `consume` ledger 可能因为同 ID 已存在而跳过,最终出现“失败已退、成功不再扣”的余额漂移。
|
||||
- 原因:资产操作扣费和退款都用稳定 ledger id 做幂等;这能保护 lease 过期后的崩溃重领不重复扣费,但不适合“已明确失败且已退款”的自动业务重试。
|
||||
- 处理:拼图 `puzzle_compile_draft` 首期设置 `max_attempts=1`,业务失败直接 failed,只保留 running lease 过期后的崩溃重领。后续若要恢复自动 retry,必须先引入 attempt-aware billing 或可配对撤销的账本接口。
|
||||
- 验证:检查 `external_generation_job.max_attempts`、worker 失败回写和钱包 ledger;失败后草稿进入 failed,重试应由用户重新触发新任务,而不是旧 job 自动 pending。
|
||||
- 关联:`server-rs/crates/api-server/src/puzzle/handlers.rs`、`server-rs/crates/api-server/src/asset_billing.rs`、`server-rs/crates/spacetime-module/src/runtime/profile.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。
|
||||
|
||||
## 外部生成队列不再由 HTTP 进程兜底执行
|
||||
|
||||
- 现象:拼图首关生成接口返回 `queued`,但生成页长时间不完成,重启 `genarrative-api.service` 也没有推进任务。
|
||||
- 原因:HTTP 角色只入队,不再直接调用外部 provider;如果没有运行 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 或 `all` 的进程,`external_generation_job` 会停留在 `pending/running`,直到有 worker claim。
|
||||
- 处理:生产用 `systemctl enable --now genarrative-external-generation-worker@1.service genarrative-external-generation-controller.service` 启动保底 worker 和 controller;首次 API deploy 会在默认 worker pattern 下自动启用并启动 `@1`、等待 worker active,并重启验活 controller。扩容默认交给 controller 按队列统计启动 `@2.service` 等实例,手动扩缩容只作为兜底;worker 收到停机信号后会停止 claim 新任务并等待当前任务完成。本地 smoke 可临时用 `GENARRATIVE_PROCESS_ROLE=all npm run dev`;本地若只想同步排查可通过 `.env.local` 或本机环境设置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline`,但这不会创建 job,也不能验证 worker 扩缩容。
|
||||
- 验证:`systemctl status genarrative-external-generation-controller.service 'genarrative-external-generation-worker@*.service'` 能看到 controller 和 worker 实例;queue 模式下任务被 claim 后 `worker_id` 与 `lease_expires_at` 会更新,完成后 session 进入 ready 或 failed;inline 模式下不应产生新的 `external_generation_job`。
|
||||
- 关联:`deploy/systemd/genarrative-external-generation-worker@.service`、`deploy/systemd/genarrative-external-generation-controller.service`、`deploy/env/external-generation-controller.env.example`、`server-rs/crates/spacetime-module/src/external_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 外部生成 worker 业务写回必须同事务校验 lease guard
|
||||
|
||||
- 现象:worker `complete/fail` 已校验 `worker_id + lease_token`,但如果玩法 session / work profile 写回在此之前单独调用,过期 worker 仍可能先写入业务状态,随后才在 job complete/fail 阶段失败;带计费包装的旧 worker 还可能因为 stale guard 错误触发补偿退款。
|
||||
- 原因:队列状态栅栏只保护 `external_generation_job` 自身,不会自动保护玩法 procedure。业务写回必须自己带 claim 后的 `job_id / worker_id / lease_token`,并在同一个 SpacetimeDB transaction 内校验 job 仍为 `running`、lease 未过期、job kind、owner 和 source entity 匹配。
|
||||
- 处理:拼图首图 worker 的前置 `compile_puzzle_agent_draft`、`save_puzzle_generated_images`、`save_puzzle_ui_background`、`mark_puzzle_draft_generation_failed` 和 `mark_puzzle_level_generation_failed` 已接入 `external_generation_job` lease guard;api-server 的资产扣费包装遇到这类 stale worker lease guard 错误时不执行补偿退款,错误文本包含 `external_generation_job 当前不是 running 状态` 或 `external_generation_job 不存在` 时也按 stale guard 处理。inline 模式只允许 `job_id / worker_id / lease_token` 三项同时为空,半空 guard 仍拒绝。后续迁移其它玩法 worker 时必须复用该模式,不能只在 worker 进程内保存一份 token。
|
||||
- 验证:`cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server asset_operation_billing_does_not_refund_stale_worker_lease_errors --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联:`server-rs/crates/spacetime-module/src/external_generation.rs`、`server-rs/crates/spacetime-module/src/puzzle.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`server-rs/crates/api-server/src/asset_billing.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。
|
||||
|
||||
## 外部生成 worker 核心业务写回失败不能完成 job
|
||||
|
||||
- 现象:worker 已经生成图片并拿到本地合成 session 快照,但 SpacetimeDB 业务写回因连接、旧 wasm 或 lease guard 失败没有真实落库;如果此时仍把 `external_generation_job` 标成 `completed`,前端只会看到队列完成而 session 长时间不变化,后续也没有 worker 会重领修复。
|
||||
- 原因:同步 HTTP handler 的“外部 provider 已成功但 SpacetimeDB 短暂不可用时返回内存快照”降级语义,不能直接搬进异步 worker。worker 的完成状态必须代表核心业务事实已经持久化。
|
||||
- 处理:worker 路径的 `save_puzzle_generated_images` / `save_puzzle_ui_background` 等核心业务写回失败时直接返回错误;只有核心写回已经成功后的非关键投影回写才允许降级记录 warning。业务失败态也必须先写回 session / work profile,写回成功后才允许把队列 job 标为 failed;失败态未写回时保留租约,等待 lease 过期后重领。生产首装和首次 API deploy 都必须至少启用一个 worker 实例,例如 `systemctl enable --now genarrative-external-generation-worker@1.service`。
|
||||
- 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server asset_operation_billing_does_not_refund_stale_worker_lease_errors --manifest-path server-rs/Cargo.toml`,并在 smoke 时确认 queued 任务被 worker 消费后 session 真实更新。
|
||||
- 关联:`server-rs/crates/api-server/src/puzzle/draft.rs`、`server-rs/crates/api-server/src/puzzle/generation.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。
|
||||
|
||||
## 生产冷备份后 API 不能只依赖 SpacetimeDB 自恢复
|
||||
|
||||
- 现象:release 机器 `03:20` 冷备份后,`spacetimedb.service` 已恢复,但作品列表、创作入口配置或公开 gallery 继续超时 / 502 / 504,`genarrative-api.service` 保持 stopped。
|
||||
@@ -200,6 +232,22 @@
|
||||
- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle form checks mud points before creating a draft|match3d form checks mud points before creating a draft|bark battle form checks mud points before creating image assets"` 应断言弹窗出现、对应工作台仍在、玩法模板分类不再出现。
|
||||
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 内嵌泥点确认弹窗必须自带平台主题作用域
|
||||
|
||||
- 现象:拼图 / 抓大鹅统一创作页点击生成后,“确认消耗泥点”弹窗正文和按钮存在,但弹窗面板背景透明,只剩遮罩和文字。
|
||||
- 原因:`PlatformMudPointConfirmDialog` 作为二级确认常以 `portal={false}` 内嵌到工作台局部 DOM,局部节点不一定继承 `.platform-theme`;`platform-modal-shell` 依赖 `--platform-modal-fill` 等主题变量,变量缺失时面板底色解析为空。
|
||||
- 处理:共享泥点确认弹窗默认在 overlay 上带 `platform-theme platform-theme--<theme>`、`platform-modal-backdrop` 和实色遮罩,在 panel 上带 `platform-modal-shell platform-remap-surface`;单按钮状态弹窗也要有默认 light 主题,避免未来独立调用复现。
|
||||
- 验证:浏览器触发 `/creation/puzzle` 与 `/creation/match3d` 的泥点确认弹窗,检查 overlay 最近主题 class 存在、`--platform-modal-fill` 有值且面板为实底;聚焦测试覆盖默认 overlay / panel class。
|
||||
- 关联:`src/components/common/PlatformMudPointConfirmDialog.tsx`、`src/components/common/PlatformStatusDialog.tsx`、`src/components/unified-creation/workspaces/PuzzleCreationWorkspace.tsx`、`src/components/unified-creation/workspaces/Match3DCreationWorkspace.tsx`。
|
||||
|
||||
## 拼图结果页关卡图不要裁切,嵌套图片预览要高于详情弹窗
|
||||
|
||||
- 现象:拼图结果页“拼图关卡”列表里的关卡图底部被裁掉;进入关卡详情后点击画面图,看起来没有打开全屏预览。
|
||||
- 原因:关卡列表复用 `PlatformMediaFrame aspect="standard"` 默认 `object-cover`,方图或竖向生成图会在 4:3 框内被裁切;关卡详情弹窗自身层级高于 `CreativeImageInputPanel` 默认图片预览层级,预览实际打开但被压在详情弹窗后面。
|
||||
- 处理:结果页关卡缩略图显式传 `imageClassName="h-full w-full object-contain"` 保留完整画面;`CreativeImageInputPanel` 提供 `mainImagePreviewZIndexClassName`,嵌套在高层级弹窗内时由调用方传更高层级。
|
||||
- 验证:聚焦测试断言关卡缩略图使用 `object-contain` 且没有 `object-cover`,并断言关卡详情内主图预览 overlay 层级高于详情弹窗;浏览器里检查列表完整显示图片,详情内点击画面图能打开可见预览。
|
||||
- 关联:`src/components/puzzle-result/PuzzleResultView.tsx`、`src/components/common/CreativeImageInputPanel.tsx`、`src/components/puzzle-result/PuzzleResultView.test.tsx`。
|
||||
|
||||
## 玩法入口分类字段缺失要前端兜底
|
||||
|
||||
- 现象:平台创作入口初始化时,`platformEntryCreationTypes.ts` 直接对 `creationTypes[].categoryId` / `categoryLabel` 调 `trim()`,一旦后端旧数据、局部 mock 或异常返回里缺字段,整个创作页会在 `derivePlatformCreationTypes(...)` 里直接炸掉。
|
||||
@@ -370,7 +418,7 @@
|
||||
|
||||
- 现象:敲木鱼创作时点击“生成”,前端提示 `SpacetimeDB procedure 调用超时`,但服务端日志更早出现 `Failed to BSATN deserialize procedure return value` 或类似反序列化错误。
|
||||
- 原因:本机 `spacetime` CLI / standalone 版本与 `server-rs/Cargo.toml` 锁定的 `spacetimedb` 版本不一致时,procedure 返回值会在宿主侧反序列化失败,api-server 继续等待就表现成调用超时。若旧 standalone 进程还在复用,也会把这个错配继续带进新一轮创作。
|
||||
- 处理:先用 `spacetime --version` 确认 `spacetimedb tool version`,再和 `server-rs/Cargo.toml` 的 `spacetimedb = "..."` 对齐;必要时执行 `spacetime version install <version> && spacetime version use <version>`,然后重启 `npm run dev:spacetime`。当前 dev 脚本会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免继续复用旧宿主。
|
||||
- 处理:先用 `spacetime --version` 确认 `spacetimedb tool version`,再和 `server-rs/Cargo.toml` 的 `spacetimedb = "..."` 对齐;遇到版本不匹配时先直接执行 `spacetime version install <version> && spacetime version use <version>`,或在目标就是最新版本时执行 `spacetime version upgrade`,升级后重启 `npm run dev:spacetime` 再重试。当前 dev 脚本会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免继续复用旧宿主。
|
||||
- 验证:`spacetime --version` 输出与 `server-rs/Cargo.toml` 一致,`http://127.0.0.1:3101/v1/ping` 正常,`npm run test -- scripts/dev.test.ts` 通过,敲木鱼创作点击生成不再卡在 procedure timeout。
|
||||
- 关联:`scripts/dev.mjs`、`scripts/dev.test.ts`、`server-rs/Cargo.toml`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
@@ -489,6 +537,14 @@
|
||||
- 验证:`tr '\0' '\n' < /proc/$(systemctl show genarrative-api.service -p MainPID --value)/environ | grep GENARRATIVE_TRACKING_OUTBOX_DIR` 应指向 `/var/lib/genarrative/tracking-outbox`;重启后当前 PID 不再出现 `Permission denied (os error 13)`。
|
||||
- 关联:`scripts/deploy/production-api-deploy.sh`、`scripts/jenkins-server-provision.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## release otelcol 217/USER 和备份 timer inactive 分开处理
|
||||
|
||||
- 现象:release 巡检中 `otelcol-contrib.service` 持续 `activating (auto-restart)`,日志出现 `status=217/USER` / `Failed to determine user credentials`;同时 `genarrative-database-backup.timer` 显示 `enabled` 但 `inactive/dead`,`NEXT` / `Trigger` 为空。
|
||||
- 原因:otelcol 的 systemd unit 使用 `User=otelcol` / `Group=otelcol`,但目标机缺少该系统用户和 `/etc/otelcol/genarrative-debug.yaml`;备份 timer 在 missed window 后未处于 active waiting 状态,直接重启 Persistent timer 可能在白天立刻补跑冷备份并停止 SpacetimeDB。
|
||||
- 处理:先创建系统用户 / 组 `otelcol`,补齐 `/var/lib/otelcol`、`/etc/otelcol/genarrative-debug.yaml` 和 `/var/log/genarrative`,再重启 `otelcol-contrib.service`;修 timer 时先 `touch /var/lib/systemd/timers/stamp-genarrative-database-backup.timer`,再 `systemctl daemon-reload && systemctl start genarrative-database-backup.timer`,避免当前窗口立即补跑冷备份。
|
||||
- 验证:`otelcol-contrib.service` 为 `active (running)` 且监听 `127.0.0.1:4317/4318`;`systemctl list-timers genarrative-database-backup.timer --all` 显示下一次触发约为次日 `03:20`;`/healthz`、`/readyz`、`/v1/ping` 仍通过。
|
||||
- 关联:`scripts/jenkins-server-provision.sh`、`deploy/systemd/otelcol-contrib.service`、`deploy/otelcol/genarrative-debug.yaml`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 外部 API 失败没法追溯先查 external_api_call_failure
|
||||
|
||||
- 现象:VectorEngine 图片生成 / 编辑接口对前端只表现为 `502` / `504` 或“上游服务请求失败”,但难以区分是请求发送失败、上游 429/5xx、响应解析失败、未返回图片,还是下载图片失败。
|
||||
@@ -1280,8 +1336,8 @@
|
||||
|
||||
- 现象:Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)`、`sccache: error: Timed out waiting for server startup`,或 `sccache: caused by: Failed to send data to or receive data from server / Failed to read response header / failed to fill whole buffer`;真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。
|
||||
- 原因:环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper,但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。Windows 本机若配置了 `SCCACHE_OSS_*`,sccache daemon 冷启动会先经 OSS/本机代理完成缓存读写检查,再监听 `127.0.0.1:4226`;代理或 OSS 链路慢时,Cargo 的 `sccache rustc -vV` 可能先超时。
|
||||
- 处理:保留 `server-rs/.cargo/config.toml` 的 `rustc-wrapper = "sccache"`;Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro` 的 `sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。
|
||||
- 验证:`rustc -Vv` 能输出版本;冷启动后原始 `cargo check -p api-server` 和 `cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明仍在使用 sccache/OSS 缓存;Jenkins 日志出现“未找到可用 sccache,改用 rustc 直接构建”后仍继续真实构建。
|
||||
- 处理:保留 `server-rs/.cargo/config.toml` 的 `rustc-wrapper = "sccache"`;本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` 由 `scripts/dev.mjs` 给 Rust 子进程注入直通 wrapper,自动绕过项目默认 sccache,避免损坏的 daemon 阻断 `spacetime publish` 或 `api-server` 启动;显式设置的非 sccache 自定义 wrapper 会被保留。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro` 的 `sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。
|
||||
- 验证:`rustc -Vv` 能输出版本;本地 `npm run dev` 能完成 `spacetime publish`、`api-server` `/healthz`、主站 Vite 和后台 Vite 启动;冷启动后原始 `cargo check -p api-server` 和 `cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明原始 Cargo/Jenkins 路径仍可使用 sccache/OSS 缓存;Jenkins 日志出现“未找到可用 sccache,改用 rustc 直接构建”后仍继续真实构建。
|
||||
- 关联:`scripts/dev.mjs`、`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
|
||||
|
||||
## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本
|
||||
@@ -1940,7 +1996,7 @@
|
||||
|
||||
- 现象:`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.4.1`,确认本机 CLI/standalone 与仓库一致后重新启动 `npm run dev -- --no-interactive --web-port 3001 --api-port 8083 --spacetime-port 3103 --admin-web-port 3104`。
|
||||
- 处理:先停掉占用端口的旧进程,再执行 `spacetime version list`,确认本机 CLI/standalone 与 `server-rs/Cargo.toml` 锁定版本一致;不一致时先直接升级 / 切换到锁定版本,再重新启动 `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`。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Genarrative 项目共享概览
|
||||
|
||||
更新时间:`2026-06-03`
|
||||
更新时间:`2026-06-12`
|
||||
|
||||
## 一句话定位
|
||||
|
||||
@@ -34,7 +34,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台,把 A
|
||||
server-rs + Axum + SpacetimeDB
|
||||
```
|
||||
|
||||
当前 SpacetimeDB crate、SDK、CLI / standalone、生成 bindings 和容器压测镜像统一按 `2.4.1` 对齐。
|
||||
当前 SpacetimeDB crate、SDK、CLI / standalone、生成 bindings 和容器压测镜像统一按 `2.5.0` 对齐;遇到版本不匹配时先升级到 `server-rs/Cargo.toml` 锁定版本,升级后重启对应 SpacetimeDB 进程再重试。
|
||||
|
||||
职责边界:
|
||||
|
||||
|
||||
@@ -9,12 +9,14 @@ Docker Compose
|
||||
├─ spacetimedb :3101,独立数据卷,供 api-server 连接
|
||||
├─ nginx :80 -> api-server:8082,负责静态站点、/admin/、/api/ 反代、upstream timing log、连接限制
|
||||
├─ api-server :8082,Linux release 构建,连接 compose 内 SpacetimeDB
|
||||
├─ external-generation-worker,独立 worker 进程,消费 external_generation_job 队列
|
||||
├─ otelcol :4317/4318,debug exporter,接收 traces / metrics / logs
|
||||
└─ k6 profile=loadtest 时临时启动,在 compose 网络内压 nginx
|
||||
```
|
||||
|
||||
当前容器模拟参数按 `genarrative-release` 服务器采样值收口为 2 vCPU / 2 GiB RAM / 4096 soft nofile / 768 worker_connections,并已在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=896m`、`api-server cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=1.0 mem_limit=512m`。SpacetimeDB 同时设置 `--page_pool_max_size=402653184`,给 reducer、订阅与运行时保留更多非 page pool 内存。
|
||||
当前容器模拟参数按 `genarrative-release` 服务器采样值收口为 2 vCPU / 2 GiB RAM / 4096 soft nofile / 768 worker_connections,并已在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=896m`、`api-server cpus=2.0 mem_limit=1g`、`external-generation-worker cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=1.0 mem_limit=512m`。SpacetimeDB 同时设置 `--page_pool_max_size=402653184`,给 reducer、订阅与运行时保留更多非 page pool 内存。
|
||||
容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,用于让 Tokio 在 2 vCPU 配额内有更多 I/O 调度 worker;该值不会突破 compose 里的 `cpus=2.0` CPU 上限。
|
||||
容器默认 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,用于验证 `api-server -> external_generation_job -> external-generation-worker` 链路;如只想本地同步排查 provider/OSS/SpacetimeDB 写回,可在本机 env 临时改为 `inline`,但该模式不会覆盖 worker 动态扩缩容验证。
|
||||
Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。
|
||||
生产服务器若启用 Collector,则由 `deploy/systemd/otelcol-contrib.service` 和 `deploy/otelcol/genarrative-debug.yaml` 托管,不走容器镜像。
|
||||
|
||||
@@ -55,7 +57,7 @@ Linux Docker Engine 若要从宿主机 CLI 连到容器内服务,直接用 `ht
|
||||
|
||||
## 构建工具链
|
||||
|
||||
`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.4.1` 依赖链要求 Rust 1.93,因此 `deploy/container/api-server.Dockerfile` 的 Rust builder 固定为 `rust:1.93-bookworm`。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。
|
||||
`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.4.1` 依赖链要求 Rust 1.93,因此 `deploy/container/api-server.Dockerfile` 的 Rust builder 固定为 `rust:1.93-bookworm`。镜像构建阶段会同时复制 `public/`,用于满足 API 二进制里 `include_bytes!` 引用的内置素材;不要把 `public/generated-*` 放入镜像上下文。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。
|
||||
|
||||
## 启动与验证
|
||||
|
||||
@@ -74,6 +76,7 @@ curl -sS http://127.0.0.1:18080/api/runtime/puzzle/gallery
|
||||
```bash
|
||||
npm run container:logs -- nginx
|
||||
npm run container:logs -- api-server
|
||||
npm run container:logs -- external-generation-worker
|
||||
npm run container:logs -- otelcol
|
||||
```
|
||||
|
||||
@@ -85,6 +88,73 @@ npm run container:config -- --print
|
||||
|
||||
如果 `deploy/container/api-server.env` 已写入真实 token,不要把完整展开结果贴到公开渠道。
|
||||
|
||||
动态扩缩容外部生成 worker 时,只调整 `external-generation-worker` service:
|
||||
|
||||
```bash
|
||||
npm run container:up -- --scale external-generation-worker=3 external-generation-worker
|
||||
npm run container:up -- --scale external-generation-worker=1 external-generation-worker
|
||||
```
|
||||
|
||||
动态扩缩容验证必须保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`;`inline` 模式下生成请求由 `api-server` 同步执行,不会被这些 worker 实例消费。
|
||||
|
||||
### 外部生成 Worker 隔离 Smoke
|
||||
|
||||
如果只想在本机隔离验证 worker 模式,不复用 `deploy/container/api-server.env`,使用专用脚本:
|
||||
|
||||
```bash
|
||||
npm run container:worker-smoke -- smoke
|
||||
```
|
||||
|
||||
该脚本会生成 gitignored 的 `deploy/container/worker-smoke/api-server.env` 与端口 state,使用独立 compose project、独立 SpacetimeDB 数据卷和独立 host 端口,完成 `build -> up-spacetime -> publish -> up -> enqueue -> api-update -> enqueue`。测试 job 使用 `worker_smoke_unsupported` 类型,不访问真实 VectorEngine、LLM 或 OSS;预期结果是 worker 领取队列任务后按“不支持的任务类型”执行失败分支,从而验证队列 claim、lease、失败回写路径和 API / worker 进程隔离。`external_generation_job` 是 private table,脚本通过 worker 日志里的 job_id 和 unsupported 记录确认消费,不通过 CLI SQL 绕过权限。`smoke` 默认只启动 `api-server` 与 `external-generation-worker`,避免无关前端 / Nginx 镜像构建;需要同时验证 Nginx 时可分步执行 `up --with-nginx`。
|
||||
|
||||
分步排查时可执行:
|
||||
|
||||
```bash
|
||||
npm run container:worker-smoke -- init --force
|
||||
npm run container:worker-smoke -- build
|
||||
npm run container:worker-smoke -- up-spacetime
|
||||
npm run container:worker-smoke -- publish
|
||||
npm run container:worker-smoke -- up
|
||||
npm run container:worker-smoke -- enqueue before-update
|
||||
npm run container:worker-smoke -- api-update
|
||||
npm run container:worker-smoke -- enqueue after-update
|
||||
npm run container:worker-smoke -- status
|
||||
```
|
||||
|
||||
如果隔离端口或库数据需要重置:
|
||||
|
||||
```bash
|
||||
npm run container:worker-smoke -- smoke --force
|
||||
```
|
||||
|
||||
`container:worker-smoke` 默认会把本机 `spacetime` 2.4.1 CLI 打成轻量 SpacetimeDB 镜像,避免首次 smoke 必须拉取官方大镜像;普通 `npm run container:*` 压测仍默认使用 `clockworklabs/spacetime:v2.4.1`。如果 Docker build 阶段在容器内拉取 crates.io 依赖不稳定,可让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入临时 smoke 镜像。该模式默认使用 `rust:1.93-bookworm` 作为 builder、Debian bookworm smoke runtime 承载构建产物;需要换 builder 镜像时设置 `GENARRATIVE_WORKER_SMOKE_CARGO_IMAGE`,需要换运行时基础镜像时设置 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE`:
|
||||
|
||||
```bash
|
||||
npm run container:worker-smoke -- smoke --local-binary
|
||||
```
|
||||
|
||||
`api-update` 只会 `--force-recreate api-server`,并校验 `external-generation-worker` 容器 ID 不变;如要同时重建 API 镜像,使用:
|
||||
|
||||
```bash
|
||||
npm run container:worker-smoke -- api-update --build
|
||||
```
|
||||
|
||||
验证 worker 动态扩缩容:
|
||||
|
||||
```bash
|
||||
npm run container:worker-smoke -- scale 3
|
||||
npm run container:worker-smoke -- ps
|
||||
npm run container:worker-smoke -- enqueue scaled-workers
|
||||
npm run container:worker-smoke -- scale 1
|
||||
```
|
||||
|
||||
查看或清理隔离环境:
|
||||
|
||||
```bash
|
||||
npm run container:worker-smoke -- logs external-generation-worker
|
||||
npm run container:worker-smoke -- down -v
|
||||
```
|
||||
|
||||
停止:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -2,6 +2,7 @@ FROM rust:1.93-bookworm AS rust-builder
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY server-rs ./server-rs
|
||||
COPY public ./public
|
||||
RUN cargo build --release -p api-server --manifest-path server-rs/Cargo.toml && \
|
||||
cp server-rs/target/release/api-server /tmp/api-server
|
||||
|
||||
|
||||
@@ -8,6 +8,14 @@ GENARRATIVE_API_PORT=8082
|
||||
GENARRATIVE_API_LOG=info,tower_http=info
|
||||
GENARRATIVE_API_LISTEN_BACKLOG=1024
|
||||
GENARRATIVE_API_WORKER_THREADS=4
|
||||
# 容器 smoke 可临时设 all;压测或预发按 api / external-generation-worker 拆进程。
|
||||
GENARRATIVE_PROCESS_ROLE=api
|
||||
# 默认 queue 进入 external_generation_job;本地/小流量同步排查可显式设 inline。
|
||||
GENARRATIVE_EXTERNAL_GENERATION_MODE=queue
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=3600
|
||||
GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512
|
||||
GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320
|
||||
GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64
|
||||
|
||||
@@ -2,7 +2,7 @@ name: genarrative-container-loadtest
|
||||
|
||||
services:
|
||||
spacetimedb:
|
||||
image: clockworklabs/spacetime:v2.4.1
|
||||
image: ${GENARRATIVE_CONTAINER_SPACETIME_IMAGE:-clockworklabs/spacetime:v2.4.1}
|
||||
user: root
|
||||
command:
|
||||
[
|
||||
@@ -44,7 +44,7 @@ services:
|
||||
cpus: "2.0"
|
||||
mem_limit: 1g
|
||||
env_file:
|
||||
- ./api-server.env
|
||||
- ${GENARRATIVE_CONTAINER_API_ENV_FILE:-./api-server.env}
|
||||
environment:
|
||||
GENARRATIVE_API_HOST: 0.0.0.0
|
||||
GENARRATIVE_API_PORT: 8082
|
||||
@@ -69,6 +69,32 @@ services:
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
external-generation-worker:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: deploy/container/api-server.Dockerfile
|
||||
target: api-runtime
|
||||
cpus: "2.0"
|
||||
mem_limit: 1g
|
||||
env_file:
|
||||
- ${GENARRATIVE_CONTAINER_API_ENV_FILE:-./api-server.env}
|
||||
environment:
|
||||
GENARRATIVE_PROCESS_ROLE: external-generation-worker
|
||||
GENARRATIVE_TRACKING_OUTBOX_DIR: /var/lib/genarrative/tracking-outbox-worker
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4318
|
||||
OTEL_SERVICE_NAME: genarrative-external-generation-worker
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 4096
|
||||
hard: 4096
|
||||
depends_on:
|
||||
spacetimedb:
|
||||
condition: service_healthy
|
||||
otelcol:
|
||||
condition: service_started
|
||||
|
||||
nginx:
|
||||
build:
|
||||
context: ../..
|
||||
|
||||
Vendored
+8
@@ -7,6 +7,14 @@ GENARRATIVE_API_PORT=8082
|
||||
GENARRATIVE_API_LOG=info,tower_http=info
|
||||
GENARRATIVE_API_LISTEN_BACKLOG=1024
|
||||
GENARRATIVE_API_WORKER_THREADS=4
|
||||
# api 只监听 HTTP;外部生成 worker 用独立进程设置为 external-generation-worker 后横向扩缩。
|
||||
GENARRATIVE_PROCESS_ROLE=api
|
||||
# 默认 queue 进入 external_generation_job;本地/小流量同步排查可显式设 inline。
|
||||
GENARRATIVE_EXTERNAL_GENERATION_MODE=queue
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=3600
|
||||
GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512
|
||||
GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320
|
||||
GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# 复制到 /etc/genarrative/external-generation-controller.env 后按机器容量调整。
|
||||
# controller 只管理 systemd worker 实例;SpacetimeDB、外部 provider 密钥继续复用 api-server.env。
|
||||
# systemd unit 会强制设置 GENARRATIVE_PROCESS_ROLE=external-generation-controller。
|
||||
|
||||
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MIN_WORKERS=1
|
||||
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MAX_WORKERS=8
|
||||
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_TARGET_JOBS_PER_WORKER=2
|
||||
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_POLL_INTERVAL_MS=10000
|
||||
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SCALE_DOWN_IDLE_ROUNDS=6
|
||||
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SERVICE_TEMPLATE=genarrative-external-generation-worker@{}.service
|
||||
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_DRY_RUN=false
|
||||
GENARRATIVE_API_LOG=info,tower_http=info
|
||||
OTEL_SERVICE_NAME=genarrative-external-generation-controller
|
||||
@@ -0,0 +1,11 @@
|
||||
# 复制到 /etc/genarrative/external-generation-worker.env 后按机器容量调整。
|
||||
# 该文件只覆盖 worker 专属参数;SpacetimeDB、外部 provider 密钥继续复用 api-server.env。
|
||||
# systemd 模板会强制设置 GENARRATIVE_PROCESS_ROLE=external-generation-worker
|
||||
# 和 GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=%H-%i,避免多实例 ID 冲突。
|
||||
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000
|
||||
# 单次 lease 会由 worker 自动续租;该值覆盖心跳抖动窗口即可。
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=3600
|
||||
GENARRATIVE_API_LOG=info,tower_http=info
|
||||
OTEL_SERVICE_NAME=genarrative-external-generation-worker
|
||||
@@ -0,0 +1,28 @@
|
||||
[Unit]
|
||||
Description=Genarrative External Generation Worker Controller
|
||||
After=network-online.target spacetimedb.service
|
||||
Wants=network-online.target
|
||||
Requires=spacetimedb.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/genarrative/current
|
||||
EnvironmentFile=/etc/genarrative/api-server.env
|
||||
EnvironmentFile=-/etc/genarrative/external-generation-controller.env
|
||||
Environment="LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib"
|
||||
ExecStart=/usr/bin/env GENARRATIVE_PROCESS_ROLE=external-generation-controller GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox/controller OTEL_SERVICE_NAME=genarrative-external-generation-controller /opt/genarrative/current/api-server
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
KillSignal=SIGINT
|
||||
TimeoutStopSec=120
|
||||
LimitNOFILE=65535
|
||||
TasksMax=512
|
||||
|
||||
# controller 需要调用 systemctl 管理 worker@N 实例,因此不降为 genarrative 用户。
|
||||
# 它只复用 api-server 发布包和 SpacetimeDB 配置,不直接执行外部生成任务。
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ReadWritePaths=/opt/genarrative /var/lib/genarrative
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,30 @@
|
||||
[Unit]
|
||||
Description=Genarrative External Generation Worker %i
|
||||
After=network-online.target spacetimedb.service
|
||||
Wants=network-online.target
|
||||
Requires=spacetimedb.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=genarrative
|
||||
Group=genarrative
|
||||
WorkingDirectory=/opt/genarrative/current
|
||||
EnvironmentFile=/etc/genarrative/api-server.env
|
||||
EnvironmentFile=-/etc/genarrative/external-generation-worker.env
|
||||
Environment="LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib"
|
||||
ExecStart=/usr/bin/env GENARRATIVE_PROCESS_ROLE=external-generation-worker GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=%H-%i GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox/%H-%i OTEL_SERVICE_NAME=genarrative-external-generation-worker /opt/genarrative/current/api-server
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
KillSignal=SIGINT
|
||||
TimeoutStopSec=7200
|
||||
LimitNOFILE=65535
|
||||
TasksMax=2048
|
||||
|
||||
# worker 复用 api-server 发布目录;外部生成审计与临时运行态只写服务端私有目录。
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ReadWritePaths=/opt/genarrative /var/lib/genarrative
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -21,6 +21,8 @@
|
||||
|
||||
微信小程序虚拟支付接入、`wechat_mp_virtual` 渠道、`wx.requestVirtualPayment` 承接页和后端签名配置见 [【技术方案】微信虚拟支付接入-2026-05-26.md](./%E3%80%90%E6%8A%80%E6%9C%AF%E6%96%B9%E6%A1%88%E3%80%91%E5%BE%AE%E4%BF%A1%E8%99%9A%E6%8B%9F%E6%94%AF%E4%BB%98%E6%8E%A5%E5%85%A5-2026-05-26.md)。
|
||||
|
||||
本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。
|
||||
|
||||
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
|
||||
|
||||
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。
|
||||
@@ -83,6 +85,8 @@ RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/compon
|
||||
|
||||
平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md)。
|
||||
|
||||
平台 UI Kit 的提示 / 确认弹窗收口到 `src/components/common/UnifiedConfirmDialog.tsx`,复制反馈收口到 `src/components/common/useCopyFeedback.ts`、`src/components/common/CopyFeedbackButton.tsx`、`src/components/common/CopyCodeButton.tsx` 与 `src/components/common/CopyFeedbackMessage.tsx`,基础状态提示收口到 `src/components/common/PlatformStatusMessage.tsx`,运行态短错误 / 成功 / 反馈 toast 收口到 `src/components/common/PlatformRuntimeStatusToast.tsx`,平台空态 / 轻量加载态收口到 `src/components/common/PlatformEmptyState.tsx`,平台动作按钮收口到 `src/components/common/PlatformActionButton.tsx`,平台白底子面板 / 小型列表卡片收口到 `src/components/common/PlatformSubpanel.tsx`,平台输入框 / 文本域收口到 `src/components/common/PlatformTextField.tsx`,平台字段标题收口到 `src/components/common/PlatformFieldLabel.tsx`,平台媒体预览框收口到 `src/components/common/PlatformMediaFrame.tsx`,平台胶囊状态标签收口到 `src/components/common/PlatformPillBadge.tsx`,平台 / 个人中心弹窗关闭按钮收口到 `src/components/common/PlatformModalCloseButton.tsx`,底层继续复用 `UnifiedModal`;普通提示、确认 / 取消、危险确认、复制状态机、短代码复制 chip、复制按钮表现、白底 / 个人中心 / 认证入口 token 状态条、运行态状态 toast、无操作空态、主动作按钮、白底子面板、白底交互列表卡片、普通输入字段、字段标题、图片源 / fallback / 固定比例媒体预览、单个状态 / 标签 chip 和圆形关闭按钮优先使用公共 Module,规则见 [【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md](./technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md)。
|
||||
|
||||
平台入口受保护数据失效后的 stage 去留判定,以及缺失草稿 / 作品 / run 时的阶段回退,收口到 `src/components/platform-entry/platformSelectionStageModel.ts`,壳层只执行缓存清空、布尔事实汇总和必要跳转,规则见 [【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md)。
|
||||
|
||||
小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,204 @@
|
||||
# 外部生成 Worker 化方案
|
||||
|
||||
更新时间:`2026-06-12`
|
||||
|
||||
## 背景
|
||||
|
||||
当前 VectorEngine `gpt-image-2`、音频、LLM 等外部生成链路多数由 `api-server` 的 HTTP handler 直接等待上游、OSS 持久化和 SpacetimeDB 回写完成。前端虽然有生成页和会话轮询,但 HTTP 进程仍承担长耗时副作用,导致接入更多玩法或大图生成时只能放大 API 进程,而不能单独扩展外部生成吞吐。
|
||||
|
||||
## 目标
|
||||
|
||||
- 默认 `queue` 模式下,`api-server` 的 HTTP 角色只负责鉴权、入参校验、扣费前置/状态初始化、任务入队和返回 `queued` 操作结果。
|
||||
- 外部生成副作用由独立 `external-generation-worker` 角色执行。
|
||||
- 多个 worker 进程通过 SpacetimeDB 任务表抢占任务,依赖 lease 超时恢复,支持按进程数和单进程并发动态缩扩容。
|
||||
- 本地或小流量同步排查可显式启用 `inline` 模式,由 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。
|
||||
- SpacetimeDB reducer / procedure 只做任务状态流转,不做网络、文件系统或外部 provider I/O。
|
||||
- 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`;本轮扩展到跳一跳、拼消消和敲木鱼的外部图片生成动作。后续玩法继续复用同一队列 Module,不再为每个玩法发明独立队列。
|
||||
- 第一版外部生成队列粒度固定为“单个用户动作对应单个 job”。例如草稿编译、结果页单槽重生、图集重生都各自入一个 job;job 内部可以串行或并行调用 provider、OSS、SpacetimeDB 写回,但不再拆成“提示词 / 生图 / 切图 / 去背景 / 持久化 / 回写”等阶段 job。阶段进度只作为 `request_payload_json` / 业务 session 的展示状态,不作为队列调度单位。
|
||||
- 不调用外部图片 / 音频 / LLM provider 的动作继续 inline 执行,不为了统一排队而进入 `external_generation_job`。
|
||||
|
||||
## Module 与 Interface
|
||||
|
||||
新增深一点的 **外部生成任务 Module**,Interface 收敛为:
|
||||
|
||||
- `enqueue_external_generation_job_and_return`:按 `dedupe_key` 幂等创建或返回现有任务。
|
||||
- `claim_external_generation_jobs_and_return`:worker 按 `worker_id`、`limit` 和 lease 时长抢占 `pending` 或 lease 过期的 `running` 任务,返回本次 claim 的 `lease_token`。
|
||||
- `renew_external_generation_job_lease_and_return`:worker 长任务执行期间按 `worker_id + lease_token` 续租,防止外部生成超过单次 lease 后被重复领取。
|
||||
- `complete_external_generation_job_and_return`:worker 成功后按 `worker_id + lease_token` 写入 `result_payload_json`,任务进入 `completed`。
|
||||
- `fail_external_generation_job_and_return`:worker 失败后按 `worker_id + lease_token` 回写错误,并按 `max_attempts` 决定回到 `pending` 重试或进入 `failed`。
|
||||
- `get_external_generation_queue_stats_and_return`:controller 读取队列积压、运行中任务和过期 lease 数量,用于计算 worker 目标实例数;该 procedure 只读 `external_generation_job`,不直接操作 systemd。
|
||||
- `get_external_generation_job_and_return`:按 `job_id` 读取单个任务状态,给 BFF 和生成页展示使用;必须只返回调用者有权读取的任务,不能暴露其它用户的 payload、错误详情或 worker 内部字段。
|
||||
|
||||
这个 Module 的 **Seam** 在 SpacetimeDB procedure + `spacetime-client` facade;`api-server` HTTP role 和 worker role 都只依赖这个 Interface。外部 provider、OSS、计费补偿、玩法草稿回写仍留在 `api-server` worker implementation 内,不进入 SpacetimeDB reducer。
|
||||
|
||||
## BFF 状态接口
|
||||
|
||||
队列状态对前端只通过 `api-server` BFF 暴露,不允许前端直接查询 SpacetimeDB private table:
|
||||
|
||||
- `GET /api/runtime/external-generation/queue-overview`:队列概览,用于生成页、调试面板或后台观测当前用户可见的等待状态。返回 pending / running / completed / failed / cancelled 数量、最早等待时间、当前可见 job 摘要,以及是否存在过期 lease 需要等待 worker 重领。
|
||||
- `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `jobId`、`jobKind`、`sourceModule`、`sourceEntityId`、`status`、`attempt`、`maxAttempts`、`createdAt`、`startedAt`、`completedAt`、`updatedAt`、可展示的 `requestLabel`、可展示的 `lastErrorMessage`、以及业务侧下一次轮询所需的 source 标识。
|
||||
|
||||
BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍以 `external_generation_job` 为准,业务结果仍以玩法 session / work profile 为准。生成页展示“排队中 / 处理中 / 失败 / 完成”时,应优先用单 job 状态补充等待信息,再继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。
|
||||
|
||||
## 任务表
|
||||
|
||||
新增私有表 `external_generation_job`:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `job_id` | 主键,`extgen-` 前缀 UUID |
|
||||
| `dedupe_key` | 唯一键,建议为 `play/action/session/scope` |
|
||||
| `job_kind` | 执行类型,当前拼图为 `puzzle_compile_draft`、`puzzle_generate_images`、`puzzle_generate_ui_background` |
|
||||
| `owner_user_id` | 触发用户 |
|
||||
| `source_module` | 玩法或能力名,例如 `puzzle` |
|
||||
| `source_entity_id` | session/profile/work 等作用域 |
|
||||
| `request_label` | 排障标签 |
|
||||
| `request_payload_json` | worker 执行入参 JSON |
|
||||
| `status` | `pending/running/completed/failed/cancelled` |
|
||||
| `attempt` / `max_attempts` | 当前尝试次数与最大尝试次数 |
|
||||
| `last_error_message` | 最近失败原因 |
|
||||
| `worker_id` | 当前 lease owner |
|
||||
| `lease_expires_at` | lease 到期时间 |
|
||||
| `lease_token` | 本次 claim 的 fencing token,用于阻止过期 worker 回写 |
|
||||
| `available_at` | 下次可领取时间 |
|
||||
| `result_payload_json` | 完成摘要 |
|
||||
| `created_at/started_at/completed_at/updated_at` | 审计时间 |
|
||||
|
||||
索引:
|
||||
|
||||
- `by_external_generation_job_status_available(status, available_at)`
|
||||
- `by_external_generation_job_worker_id(worker_id)`
|
||||
- `by_external_generation_job_source(source_module, source_entity_id)`
|
||||
- `by_external_generation_job_owner_user_id(owner_user_id)`
|
||||
|
||||
## 状态机
|
||||
|
||||
```text
|
||||
pending -> running -> completed
|
||||
pending -> running -> pending (可重试失败)
|
||||
pending -> running -> failed (达到最大重试次数)
|
||||
pending/running -> cancelled (预留)
|
||||
```
|
||||
|
||||
`claim` 只领取 `pending` 且 `available_at <= now` 的任务,或 `running` 且 `lease_expires_at <= now` 的任务。领取时递增 `attempt`、写入 `worker_id`、`started_at`、新的 `lease_expires_at` 和 `lease_token`。SpacetimeDB procedure 使用 `ctx.timestamp` 作为状态流转时间,只从 worker 入参读取“时长差值”,不信任 worker 本机绝对时间。worker 每次执行只处理自己 claim 到的任务;续租、完成或失败时必须带同一个 `worker_id + lease_token`,且当前 lease 尚未过期,防止过期 worker 覆盖新 lease。
|
||||
|
||||
玩法业务写回也必须在 SpacetimeDB 同一事务里校验 lease fencing。拼图的 `compile_puzzle_agent_draft` worker 调用、`save_puzzle_generated_images`、`save_puzzle_ui_background`、`mark_puzzle_draft_generation_failed` 和 `mark_puzzle_level_generation_failed` 在 `queue` 模式下会带 `external_generation_job_id / worker_id / lease_token`,并校验 job 仍为 `running`、token 未过期、`job_kind`、`owner_user_id`、`source_module` 和 `source_entity_id` 均匹配后才写 session / work profile。`inline` 模式不创建 `external_generation_job`,因此这三个 guard 字段必须同时为空;transaction 只把三项全空识别为 api-server 受控同步写回,三项半空仍按非法请求拒绝。worker 路径的核心业务写回失败不能返回内存快照并把 job 标为 `completed`;失败态业务回写成功后才允许把队列 job 标为 `failed`,失败态仍未写回时保留当前租约并等待后续 lease 过期重领,避免队列状态和真实 session 脱节。api-server 的资产扣费包装遇到这类 stale worker lease guard 错误时不执行补偿退款,避免旧 worker 冲掉后续合法 worker 的同一账本扣费。
|
||||
|
||||
## 执行模式与进程角色
|
||||
|
||||
外部生成执行模式由 `GENARRATIVE_EXTERNAL_GENERATION_MODE` 控制:
|
||||
|
||||
- `queue`:默认值,HTTP handler 入队 `external_generation_job`,由 `external-generation-worker` 角色 claim lease 后执行;生产、预发和压测默认使用该模式。
|
||||
- `inline`:HTTP handler 直接调用同一个 worker executor,同步等待 provider、OSS 和 SpacetimeDB 写回完成后返回 `operation.status = completed`;只用于本地或低并发排查,不提供队列持久化、lease 重领和 worker 横向扩容。
|
||||
|
||||
同一个 Rust binary 通过 `GENARRATIVE_PROCESS_ROLE` 切换:
|
||||
|
||||
- `api`:只启动 HTTP server。
|
||||
- `external-generation-worker`:只启动外部生成 worker,不监听 HTTP。
|
||||
- `external-generation-controller`:只启动 worker controller,不监听 HTTP,也不直接执行外部生成任务。
|
||||
- `all`:本地开发可同时启动 HTTP 与 worker。
|
||||
|
||||
worker 配置:
|
||||
|
||||
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID`:实例 ID;未配置时用 hostname/pid 派生。
|
||||
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY`:单进程并发领取/执行数量。
|
||||
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS`:空队列轮询间隔。
|
||||
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS`:任务 lease 时长;worker 会按约三分之一 lease、最长 30 秒的间隔续租。该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。
|
||||
|
||||
controller 配置:
|
||||
|
||||
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MIN_WORKERS`:保底 worker 实例数,生产默认 `1`,controller 不会主动停止 `@1`。
|
||||
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MAX_WORKERS`:自动扩容上限,生产模板默认 `8`。
|
||||
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_TARGET_JOBS_PER_WORKER`:每个 worker 实例承担的目标未完成任务数,默认 `2`;目标实例数按 `claimable_pending + running_active + expired_running` 计算后夹在 min/max 之间,避免把已包含过期 running 的 `claimable_count` 重复计入。
|
||||
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_POLL_INTERVAL_MS`:controller 轮询队列统计的间隔,默认 `10000`。
|
||||
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SCALE_DOWN_IDLE_ROUNDS`:连续多少轮无可领取、无运行中、无过期 running 后才允许缩容,默认 `6`;缩容每轮只停止最高编号的一个实例。
|
||||
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SERVICE_TEMPLATE`:systemd worker 模板,默认 `genarrative-external-generation-worker@{}.service`。
|
||||
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_DRY_RUN`:只记录决策不执行 systemctl,默认 `false`。
|
||||
|
||||
动态缩扩容方式:生产默认由 `deploy/systemd/genarrative-external-generation-controller.service` 启动 `GENARRATIVE_PROCESS_ROLE=external-generation-controller`,controller 读取 `get_external_generation_queue_stats_and_return` 后对 `genarrative-external-generation-worker@N.service` 执行精确 `systemctl start/stop`;无需改变 HTTP 进程数。controller 只操作 `@1..@MAX` 中的缺口或最高编号多余实例,保留 `@1` 作为保底 worker。缩容或发布重启 worker 时,进程收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务会在 lease 过期后被其它 worker 重新领取。容器链路已有独立 `external-generation-worker` compose service;扩 worker 必须扩这个 worker service,不能只扩 `api-server` HTTP service。
|
||||
|
||||
## 已接入的拼图纵切
|
||||
|
||||
### 拼图
|
||||
|
||||
`compile_puzzle_draft`:
|
||||
|
||||
1. HTTP handler 保存拼图表单草稿;`queue` 模式下 `queued/running` 的持久事实源是 `external_generation_job`,不把 HTTP 进程变成外部生成执行者。
|
||||
2. `queue` 模式下 HTTP handler 入队 `puzzle_compile_draft`,返回 `operation.status = queued` 和当前 session。拼图 dedupe key 包含本次 `extgen-` job id,只保证同一任务行唯一,不把同一 session 后续重新生成吞掉。`inline` 模式下 HTTP handler 复用同一 executor 同步执行,成功后直接返回 `completed` 和最新 session。
|
||||
3. 前端保持 `puzzle-generating`,继续轮询 `getPuzzleAgentSession`;首期不把 `queued/running` 写回 `puzzle_agent_session`,因此刷新或跨设备恢复生成中状态仍是后续 read model 工作。
|
||||
4. worker claim 后执行原有 `compile_puzzle_draft_with_initial_cover` 或 `compile_puzzle_draft_with_uploaded_cover`;前置 `compile_puzzle_agent_draft` 也必须携带本次 `job_id / worker_id / lease_token`,防止过期 worker 先把草稿卡和 session 写到 ready。
|
||||
5. 成功后沿原有 SpacetimeDB 拼图会话/作品写回,前端轮询看到 `progressPercent >= 94/96/100` 和 ready 草稿。
|
||||
6. 失败后调用 `mark_puzzle_draft_generation_failed`,拼图首期业务失败直接进入 failed;只有失败态写回成功才把队列 job 标为 failed,失败态写回失败则保留租约等待重领。队列仍保留 lease 过期后的崩溃重领,避免 worker 退款后再次成功导致钱包账本漂移。前端通过现有失败草稿/弹窗机制展示来源错误。
|
||||
|
||||
`generate_puzzle_images`:
|
||||
|
||||
1. HTTP handler 校验本次 `levelsJson` 快照;`queue` 模式下入队 `puzzle_generate_images` 并返回 `operation.status = queued/running/completed/failed`,`inline` 模式下同步执行原 worker executor 并在成功后返回 `completed`。
|
||||
2. worker 执行原结果页关卡图链路:自动命名、VectorEngine / 上传图直用、关卡场景图、UI spritesheet、关卡背景资产包、OSS 持久化和 SpacetimeDB 回写。
|
||||
3. 成功后 `save_puzzle_generated_images` 写回目标关卡和草稿卡;失败后 `mark_puzzle_level_generation_failed` 只标记目标关卡 `failed`,不污染已 ready 的其它关卡。队列 job 只有在目标关卡失败态写回成功后才进入 failed。
|
||||
4. 前端结果页对 `queued/running` 操作继续轮询 `getPuzzleAgentSession`,目标关卡变为 ready 或 failed 后收敛。
|
||||
|
||||
`generate_puzzle_ui_background`:
|
||||
|
||||
1. HTTP handler 校验本次 `levelsJson` 快照;`queue` 模式下入队 `puzzle_generate_ui_background` 并返回 `operation.status = queued/running/completed/failed`,`inline` 模式下同步执行原 worker executor 并在成功后返回 `completed`。
|
||||
2. worker 执行原结果页 UI 背景链路:归一化提示词、VectorEngine 生成、OSS 持久化和 `save_puzzle_ui_background` 写回。
|
||||
3. 成功后目标关卡写入 `uiBackgroundPrompt/uiBackgroundImageSrc/uiBackgroundImageObjectKey`;失败后复用 `mark_puzzle_level_generation_failed` 标记目标关卡 `failed`,并在失败态写回成功后才终结队列 job,让前端轮询能收敛。
|
||||
|
||||
### 跳一跳、拼消消和敲木鱼扩展范围
|
||||
|
||||
以下动作按同一 worker 模式迁移。命名以现有玩法 action 为准,队列 `job_kind` 采用后端稳定 snake_case,不新增平行队列:
|
||||
|
||||
- 跳一跳 `jump-hop`
|
||||
- `compile-draft`:草稿编译阶段需要生成地块 / 视觉资产时入队,例如 `jump_hop_compile_draft`。
|
||||
- `regenerate-tiles`:结果页地块图集重生入队,例如 `jump_hop_regenerate_tiles`。
|
||||
- 拼消消 `puzzle-clear`
|
||||
- `compile-draft`:草稿编译阶段需要生成场地底图和卡片 atlas 时入队,例如 `puzzle_clear_compile_draft`。
|
||||
- `regenerate-atlas`:结果页素材 atlas 重生入队,例如 `puzzle_clear_regenerate_atlas`。
|
||||
- 敲木鱼 `wooden-fish`
|
||||
- `compile-draft`:草稿编译阶段需要生成背景、敲击物或其它图片资产时入队,例如 `wooden_fish_compile_draft`。
|
||||
- `regenerate-hit-object`:结果页敲击物图片重生入队,例如 `wooden_fish_regenerate_hit_object`。
|
||||
|
||||
这些动作首版都保持“单动作单 job”:一次 `compile-draft` 或一次 `regenerate-*` 请求只创建一个 job,worker 内部负责该动作所需的 provider 调用、素材处理、OSS 持久化、失败态写回和业务成功写回。非外部图片生成动作,例如纯元信息保存、标签编辑、发布、试玩启动、运行态动作、删除和公开 read model 读取,继续 inline 执行。
|
||||
|
||||
每个玩法迁移时必须同时接入业务写回 lease guard:worker 路径带 `external_generation_job_id / worker_id / lease_token`,inline 路径三项同时为空。过期 worker 不得写 session / work profile;业务失败态写回成功后才允许 job 进入 `failed`。
|
||||
|
||||
## 验收
|
||||
|
||||
基础检查:
|
||||
|
||||
```bash
|
||||
npm run spacetime:generate
|
||||
npm run check:spacetime-schema
|
||||
npm run check:server-rs-ddd
|
||||
cargo check -p api-server --manifest-path server-rs/Cargo.toml
|
||||
```
|
||||
|
||||
定向测试:
|
||||
|
||||
```bash
|
||||
cargo test -p spacetime-module external_generation --manifest-path server-rs/Cargo.toml
|
||||
cargo test -p spacetime-module level_generation_failure --manifest-path server-rs/Cargo.toml
|
||||
cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml
|
||||
npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx -t "keeps generation progress visible"
|
||||
npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "compile_puzzle_draft"
|
||||
```
|
||||
|
||||
本地 smoke:
|
||||
|
||||
```bash
|
||||
GENARRATIVE_PROCESS_ROLE=all npm run dev
|
||||
curl -f http://127.0.0.1:<api-port>/healthz
|
||||
```
|
||||
|
||||
本地 `npm run dev` 默认保持 `inline` 开发体验:未显式配置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,普通本地联调可以同步确认 provider、OSS 和 SpacetimeDB 写回链路本身是否可行。需要验证 worker 队列、BFF 队列状态、lease 重领或扩缩容时,必须显式使用 `queue`,并启动 worker 角色;可以用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server` 做临时单进程 smoke,也可以使用隔离容器 smoke。
|
||||
|
||||
生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认队列概览 BFF 可读、单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。
|
||||
|
||||
systemd 生产 controller 与手动兜底示例:
|
||||
|
||||
```bash
|
||||
systemctl enable --now genarrative-external-generation-worker@1.service
|
||||
systemctl enable --now genarrative-external-generation-controller.service
|
||||
systemctl start genarrative-external-generation-worker@2.service
|
||||
systemctl stop genarrative-external-generation-worker@2.service
|
||||
systemctl status genarrative-external-generation-controller.service 'genarrative-external-generation-worker@*.service'
|
||||
```
|
||||
@@ -0,0 +1,82 @@
|
||||
# 本地 SSH 服务器管理面板技术方案
|
||||
|
||||
日期:`2026-06-11`
|
||||
|
||||
## 背景
|
||||
|
||||
release / dev 等服务器的日常巡检已经有 `genarrative-health-patrol.timer`、`/readyz`、`/healthz`、SpacetimeDB `/v1/ping` 和 systemd 状态文件,但开发者本地仍需要在多个 SSH alias 之间切换命令。服务器管理面板用于把这些只读巡检和少量 systemd 服务操作收敛到一个本地桌面入口。
|
||||
|
||||
## 范围
|
||||
|
||||
- 使用 Rust `egui` / `eframe` 实现本地桌面面板,不接入线上 Web 后台,不暴露公网端口。
|
||||
- 从本机 `~/.ssh/config` 的 `Host` alias 发现服务器;只展示不含通配符的 alias。
|
||||
- 支持多个服务器,左侧服务器侧边栏可收起。
|
||||
- 主面板展示硬件状态、服务状态、HTTP 健康探测和生产健康巡检状态。
|
||||
- 支持对允许的 systemd unit 执行启动、关闭、重启。
|
||||
|
||||
## 命令入口
|
||||
|
||||
```bash
|
||||
npm run server-manager:panel
|
||||
```
|
||||
|
||||
等价于:
|
||||
|
||||
```bash
|
||||
cargo run -p server-manager-panel --manifest-path server-rs/Cargo.toml
|
||||
```
|
||||
|
||||
面板启动时会自动查找本机中文字体并注入 egui 字体集,优先使用 `Noto Sans CJK SC`,其次使用文泉驿 / Droid fallback。若某台开发机字体路径特殊,可用 `GENARRATIVE_SERVER_PANEL_CJK_FONT=/path/to/font.ttc|index` 指定;普通 `.ttf` 可省略 `|index`。
|
||||
|
||||
## SSH 约定
|
||||
|
||||
本地 `~/.ssh/config` 中需要存在类似:
|
||||
|
||||
```sshconfig
|
||||
Host dev
|
||||
HostName 10.2.0.10
|
||||
User genarrative
|
||||
|
||||
Host release
|
||||
HostName genarrative.world
|
||||
User genarrative
|
||||
```
|
||||
|
||||
面板通过 `ssh <alias> sh -s` 执行远端只读巡检脚本。服务操作使用:
|
||||
|
||||
```bash
|
||||
sudo -n systemctl <start|stop|restart> <unit>
|
||||
```
|
||||
|
||||
若 SSH 用户是 root,则直接执行 `systemctl`。非 root 用户需要提前配置只允许目标 unit 的无密码 sudo;否则面板会显示 sudo 权限错误,不会弹出交互密码输入。
|
||||
|
||||
## 健康检查内容
|
||||
|
||||
只读巡检覆盖:
|
||||
|
||||
- 主机名、内核、运行时长、CPU 核数 / 型号、load average。
|
||||
- 内存 / swap 使用情况。
|
||||
- `/`、`/var`、`/opt`、`/stdb`、`/data` 中存在路径的磁盘使用率。
|
||||
- `genarrative-api.service`、`spacetimedb.service`、`nginx.service`、`genarrative-health-patrol.timer`、`genarrative-database-backup.timer` 的 systemd 状态。
|
||||
- `http://127.0.0.1:8082/healthz`、`/readyz`、`http://127.0.0.1:3101/v1/ping` 和代表性公开接口。
|
||||
- `/var/lib/genarrative/health-patrol/status.json` 的最近巡检状态。
|
||||
- 若服务器安装了 `sensors`,附带温度 / 风扇等硬件传感器摘要。
|
||||
|
||||
## 服务操作安全边界
|
||||
|
||||
面板只允许 `start`、`stop`、`restart` 三种动作,并且 unit 名必须匹配安全字符集:
|
||||
|
||||
```text
|
||||
A-Z a-z 0-9 . _ - @ :
|
||||
```
|
||||
|
||||
服务操作会先出现确认弹窗,避免误点。第一版默认列出 Genarrative 生产相关 unit,并提供“其他 unit”输入框;该输入框仍只会执行 `systemctl` 的三种受控动作,不提供任意命令执行入口。
|
||||
|
||||
## 状态判定
|
||||
|
||||
- service / HTTP 探测失败:`CRITICAL`。
|
||||
- 磁盘使用率 `>= 95%`:`CRITICAL`,`>= 85%`:`WARNING`。
|
||||
- 内存使用率 `>= 95%`:`CRITICAL`,`>= 85%`:`WARNING`。
|
||||
- 生产健康巡检状态沿用 `OK / WARNING / CRITICAL`。
|
||||
|
||||
面板状态只是本地巡检视图,最终运维事实仍以服务器上的 systemd、journal、Nginx 日志、`production-health-patrol.mjs` 输出和现有部署文档为准。
|
||||
@@ -1,6 +1,6 @@
|
||||
# server-rs 与 SpacetimeDB 数据契约
|
||||
|
||||
更新时间:`2026-06-10`
|
||||
更新时间:`2026-06-12`
|
||||
|
||||
## 后端主线
|
||||
|
||||
@@ -16,7 +16,7 @@ server-rs + Axum + SpacetimeDB
|
||||
|
||||
`server-rs/Cargo.toml` 是 workspace 事实源。默认构建成员为 `crates/api-server`;第三方依赖版本和 workspace 内 crate path 统一放在 `[workspace.dependencies]`。
|
||||
|
||||
SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`、`spacetimedb-lib` 统一锁定 `2.4.1`;本地 `spacetime` CLI / standalone、生成的 `spacetime-client` bindings 和容器压测镜像也必须与 `2.4.1` 对齐,避免 BSATN / procedure result 反序列化错配。
|
||||
SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`、`spacetimedb-lib` 统一锁定 `2.5.0`;本地 `spacetime` CLI / standalone、生成的 `spacetime-client` bindings 和容器压测镜像也必须与 `server-rs/Cargo.toml` 锁定版本对齐,避免 BSATN / procedure result 反序列化错配。遇到版本不匹配时,不继续沿着业务超时排查,先把 CLI / standalone 直接升级到锁定版本并重启后再重试。
|
||||
|
||||
当前主要 crate:
|
||||
|
||||
@@ -113,7 +113,7 @@ npm run check:server-rs-ddd
|
||||
- `server-rs/crates/api-server/src/state.rs` 中的 `PuzzleApiState` 是拼图 HTTP/BFF 的 Feature State,集中暴露 `SpacetimeClient`、`PuzzleGalleryCache`、OSS client、作者查询所需认证服务、拼图 LLM client 和少量 VectorEngine / Agent 配置快照。拼图 handler 只提取 `State<PuzzleApiState>`,不得重新改回 `State<AppState>`。
|
||||
- `server-rs/crates/api-server/src/puzzle.rs` 只作为聚合入口,保留共享 import / 常量、内部模块声明和 handler re-export,不继续承载大段实现。
|
||||
- `server-rs/crates/api-server/src/puzzle/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP/SSE 响应。
|
||||
- `server-rs/crates/api-server/src/puzzle/draft.rs` 承接表单草稿保存、草稿编译、首关命名、UI 背景 prompt、降级 snapshot 和初始资产就绪校验。
|
||||
- `server-rs/crates/api-server/src/puzzle/draft.rs` 承接表单草稿保存、草稿编译、首关命名、UI 背景 prompt 和初始资产就绪校验。
|
||||
- `server-rs/crates/api-server/src/puzzle/generation.rs` 承接拼图图片与 UI 背景的生成编排、计费包裹和 reference image 路径选择。
|
||||
- `server-rs/crates/api-server/src/puzzle/vector_engine.rs` 承接 VectorEngine 请求体、HTTP 调用、下载 / base64 解码、OSS 写入、asset object / binding 持久化和上游错误归一。
|
||||
- `server-rs/crates/api-server/src/puzzle/mappers.rs` 承接 SpacetimeDB record 到 shared-contracts DTO 的映射。
|
||||
@@ -233,6 +233,12 @@ npm run check:server-rs-ddd
|
||||
- Rust 结构体:`AiTaskStage`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/ai/stages.rs`
|
||||
|
||||
### `external_generation_job`
|
||||
|
||||
- Rust 结构体:`ExternalGenerationJob`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/external_generation.rs`
|
||||
- 用途:外部生成 worker 的持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。
|
||||
|
||||
### `ai_text_chunk`
|
||||
|
||||
- Rust 结构体:`AiTextChunk`
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -148,6 +148,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
|
||||
- 拼图运行态背景优先读取当前关卡 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,旧数据才兼容 `uiBackgroundImageSrc/uiBackgroundImageObjectKey`;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺关卡背景时必须继承同作品首个可用关卡背景,仍缺失时才沿用当前运行态快照背景或默认 UI。运行态按钮视觉优先读取当前关卡 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,先按透明 alpha 自动边界检测识别 spritesheet 中的独立按钮展示矩形,再按原图位置从左到右、从上到下映射到返回、设置、下一关、提示、原图、冻结;同一组件还要按较高 alpha 阈值派生紧致点击热区,透明留白和柔边低 alpha 区域尽量不响应点击。检测失败时回退旧固定六格裁切,缺失时才用现有图标按钮兜底。有 spritesheet 时,返回、设置和下一关的点击容器只提供透明点击区,不再叠加默认白色圆形底、胶囊主按钮底或额外文字;下一关按钮在通关弹窗和底部入口中都直接使用 spritesheet 裁切出的 next 素材作为按钮本体。底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。
|
||||
- 推荐页本身不是登录门禁入口,未登录用户点击底部或侧边栏的推荐 Tab 应直接进入嵌入运行态,不主动打开登录弹窗。推荐页嵌入运行态必须按真实身份分流:已登录用户或本地已有 access token 时,正式 runtime 启动与后续局内动作继续走账号 Bearer;只有确认为匿名访客时才申请并透传 runtime guest token。平台壳统一通过 `buildRecommendRuntimeRequestOptions(...)` 为各玩法的 start / checkpoint / finish / input / drop / click / restart / time-up / leaderboard / next-level 等动作生成局部 request options,不允许每个玩法各写一套匿名分支。后端 `/api/runtime/*` 正式运行态写请求统一接受 `RuntimePrincipal`,可识别账号用户和匿名 runtime guest;推荐卡片的后台读写请求仍使用 local auth impact,避免单卡 401 清空整站登录态。创作、个人作品、删除、发布、Remix 等账号或所有权动作仍保持普通用户鉴权。
|
||||
- 推荐页作品队列只能通过 `buildPlatformRecommendFeedEntries(...)` 生成,首页卡片窗口、桌面推荐格、嵌入 runtime 自动启动和上一条 / 下一条切换都必须消费同一队列。不得在首页和 `PlatformEntryFlowShellImpl` 内分别按“最新列表顺序”和“评分推荐顺序”各算一套相邻作品,否则连续切换会出现视觉上跳过作品或回跳。
|
||||
- 推荐页作品信息区的分享按钮统一唤起发布分享弹窗 `PublishShareModal`,不在推荐卡内部单独拼接分享文案或只做剪贴板复制反馈;拼图推荐作品的 H5 分享链接继续沿用 `/gallery/puzzle/detail?work=...`,其它统一公开作品默认走 `/works/detail?work=...`。微信小程序 WebView 内复制动作必须改为小程序 `pages/web-view/index` 路径并补齐 `targetPath=/works/detail` 与 `work` 参数。推荐页当前 active 作品必须通过 `wx.miniProgram.postMessage` 同步给原生 `web-view` 页,让右上角系统“转发给朋友”和“分享到朋友圈”也使用当前作品参数生成小程序短链背后的 path。微信小程序 WebView 内的推荐页运行态需要启用分享快照安全区,把游戏画面等比缩放并保持在页面中部,避免用户直接点击小程序自带“分享到聊天”时只截到游戏画面局部。
|
||||
- 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。
|
||||
- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
|
||||
- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。
|
||||
|
||||
@@ -21,7 +21,7 @@ pipeline {
|
||||
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: '可选,目标服务器下载 SpacetimeDB 和 otelcol-contrib 时使用的代理地址,例如 http://127.0.0.1:7890;留空不设置代理')
|
||||
string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.4.1', description: '目标服务器使用的 SpacetimeDB Linux release tarball 根地址;默认固定到项目锁定版本')
|
||||
string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.5.0', 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 根目录')
|
||||
@@ -162,7 +162,7 @@ BASH
|
||||
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/download/v2.4.1}" \
|
||||
SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.5.0}" \
|
||||
SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}" \
|
||||
SPACETIME_ROOT="${SPACETIME_ROOT:-/stdb}" \
|
||||
scripts/prepare-server-provision-tools.sh
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages/web-view/index",
|
||||
"pages/share-grid/index",
|
||||
"pages/wechat-pay/index",
|
||||
"pages/subscribe-message/index"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/* global Page, wx */
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const {
|
||||
buildShareGridTileFileName,
|
||||
buildShareGridTilePlan,
|
||||
normalizeShareGridQuery,
|
||||
} = require('./index.shared');
|
||||
|
||||
function downloadImage(imageUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.downloadFile({
|
||||
url: imageUrl,
|
||||
success(response) {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
resolve(response.tempFilePath);
|
||||
return;
|
||||
}
|
||||
reject(new Error(`封面下载失败:${response.statusCode}`));
|
||||
},
|
||||
fail(error) {
|
||||
reject(new Error(error.errMsg || '封面下载失败'));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getImageInfo(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.getImageInfo({
|
||||
src,
|
||||
success: resolve,
|
||||
fail(error) {
|
||||
reject(new Error(error.errMsg || '读取封面失败'));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getCanvasNode(page) {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.createSelectorQuery()
|
||||
.in(page)
|
||||
.select('#share-grid-canvas')
|
||||
.fields({ node: true, size: true })
|
||||
.exec((results) => {
|
||||
const canvas = results && results[0] && results[0].node;
|
||||
if (canvas) {
|
||||
resolve(canvas);
|
||||
return;
|
||||
}
|
||||
reject(new Error('切图画布初始化失败'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function canvasToTempFilePath(canvas, width, height) {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.canvasToTempFilePath({
|
||||
canvas,
|
||||
width,
|
||||
height,
|
||||
destWidth: width,
|
||||
destHeight: height,
|
||||
fileType: 'png',
|
||||
success(response) {
|
||||
resolve(response.tempFilePath);
|
||||
},
|
||||
fail(error) {
|
||||
reject(new Error(error.errMsg || '导出切图失败'));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function saveImageToAlbum(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.saveImageToPhotosAlbum({
|
||||
filePath,
|
||||
success() {
|
||||
resolve();
|
||||
},
|
||||
fail(error) {
|
||||
reject(new Error(error.errMsg || '保存到相册失败'));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function copyTempFileWithName(tempFilePath, fileName) {
|
||||
const fileSystem = wx.getFileSystemManager && wx.getFileSystemManager();
|
||||
const userDataPath = wx.env && wx.env.USER_DATA_PATH;
|
||||
if (!fileSystem || !userDataPath || typeof fileSystem.copyFile !== 'function') {
|
||||
return Promise.resolve(tempFilePath);
|
||||
}
|
||||
|
||||
const targetPath = `${userDataPath}/${fileName}`;
|
||||
return new Promise((resolve) => {
|
||||
fileSystem.copyFile({
|
||||
srcPath: tempFilePath,
|
||||
destPath: targetPath,
|
||||
success() {
|
||||
resolve(targetPath);
|
||||
},
|
||||
fail() {
|
||||
resolve(tempFilePath);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function saveGridTiles(page, params, localImagePath, imageInfo) {
|
||||
const canvas = await getCanvasNode(page);
|
||||
const context = canvas.getContext('2d');
|
||||
const image = canvas.createImage();
|
||||
await new Promise((resolve, reject) => {
|
||||
image.onload = resolve;
|
||||
image.onerror = () => reject(new Error('封面绘制失败'));
|
||||
image.src = localImagePath;
|
||||
});
|
||||
|
||||
const plan = buildShareGridTilePlan(imageInfo.width, imageInfo.height);
|
||||
for (const tile of plan) {
|
||||
canvas.width = tile.sourceWidth;
|
||||
canvas.height = tile.sourceHeight;
|
||||
context.clearRect(0, 0, tile.sourceWidth, tile.sourceHeight);
|
||||
context.drawImage(
|
||||
image,
|
||||
tile.sourceX,
|
||||
tile.sourceY,
|
||||
tile.sourceWidth,
|
||||
tile.sourceHeight,
|
||||
0,
|
||||
0,
|
||||
tile.sourceWidth,
|
||||
tile.sourceHeight,
|
||||
);
|
||||
|
||||
const tempFilePath = await canvasToTempFilePath(
|
||||
canvas,
|
||||
tile.sourceWidth,
|
||||
tile.sourceHeight,
|
||||
);
|
||||
const namedFilePath = await copyTempFileWithName(
|
||||
tempFilePath,
|
||||
buildShareGridTileFileName(params, tile.index),
|
||||
);
|
||||
await saveImageToAlbum(namedFilePath);
|
||||
page.setData({
|
||||
savedCount: tile.index + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
errorMessage: '',
|
||||
loading: true,
|
||||
savedCount: 0,
|
||||
title: '九宫切图',
|
||||
},
|
||||
|
||||
async onLoad(query = {}) {
|
||||
const params = normalizeShareGridQuery(query);
|
||||
this._shareGridParams = params;
|
||||
this.setData({
|
||||
errorMessage: '',
|
||||
loading: true,
|
||||
savedCount: 0,
|
||||
title: params.title,
|
||||
});
|
||||
|
||||
if (!params.imageUrl) {
|
||||
this.setData({
|
||||
errorMessage: '缺少封面图。',
|
||||
loading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const localImagePath = await downloadImage(params.imageUrl);
|
||||
const imageInfo = await getImageInfo(localImagePath);
|
||||
await saveGridTiles(this, params, localImagePath, imageInfo);
|
||||
this.setData({
|
||||
loading: false,
|
||||
savedCount: 9,
|
||||
});
|
||||
wx.showToast({
|
||||
title: '已保存',
|
||||
icon: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[share-grid] save failed', error);
|
||||
this.setData({
|
||||
errorMessage:
|
||||
error && error.message ? error.message : '九宫切图保存失败。',
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
handleBack() {
|
||||
wx.navigateBack();
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user