合并 master 到跳一跳分支
合入 master 最新平台公共组件与后端更新 保留跳一跳长按蓄力与视觉顶面判定规则 解决跳一跳运行态导入和决策日志冲突
This commit is contained in:
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`,密码入口可以直接注册未知手机号账号;生产默认仍关闭该开关。
|
||||
|
||||
@@ -208,6 +208,14 @@
|
||||
- 验证:`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`。
|
||||
|
||||
## 玩法入口分类字段缺失要前端兜底
|
||||
|
||||
- 现象:平台创作入口初始化时,`platformEntryCreationTypes.ts` 直接对 `creationTypes[].categoryId` / `categoryLabel` 调 `trim()`,一旦后端旧数据、局部 mock 或异常返回里缺字段,整个创作页会在 `derivePlatformCreationTypes(...)` 里直接炸掉。
|
||||
@@ -1157,6 +1165,7 @@
|
||||
- 追加处理:generated 私有图片换签 `/api/assets/read-url` 也属于展示层后台请求;推荐页拼图运行态挂载后会立即解析封面图,若换签 401 触发全局鉴权事件,也会表现成“进入拼图作品后瞬间未登录”。资源换签失败只应让当前图片为空,不应清 token、广播 auth 事件或主动 refresh。
|
||||
- 追加处理:从推荐页点进公开拼图作品并启动完整运行态后,`startPuzzleRun`、通关自动 `submitPuzzleLeaderboard`、下一关 `advancePuzzleNextLevel` 和重开同样属于当前玩法局部同步;这些请求失败时只应留在拼图错误态,不应清 token 或广播 auth 事件。
|
||||
- 追加处理:通关后 `refreshSaveArchives()`、首屏 bootstrap 的个人看板/作品架/浏览历史读写也只是平台投影刷新,失败应显示局部错误,不能充当全局登录态判定。
|
||||
- 追加处理:未登录推荐页启动任一公开正式玩法时,`/api/runtime/*` 局内路由必须使用 `RuntimePrincipal`,前端通过 `PlatformEntryFlowShellImpl` 的统一 request options helper 给 start / checkpoint / finish / input / drop / click / restart / time-up / leaderboard / next-level 等动作透传 runtime guest token;公开 runtime detail 读取如跳一跳、敲木鱼必须显式 `skipAuth/skipRefresh`,匿名推荐流不能补读受保护创作详情,否则会在真正开局前打出 `/api/auth/refresh 401`。
|
||||
- 验证:`npm run test -- src/services/apiClient.test.ts src/services/assetReadUrlService.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation starts embedded puzzle"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle runtime uses frontend move merge logic and backend leaderboard"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle similar work keeps current run level progression"`。
|
||||
- 关联:`src/services/apiClient.ts`、`src/services/assetReadUrlService.ts`、`src/services/puzzle-runtime/puzzleRuntimeClient.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md`。
|
||||
|
||||
@@ -1287,8 +1296,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 / 一体化脚本
|
||||
@@ -2221,3 +2230,11 @@
|
||||
- 待处理:将跳一跳生成改为后端任务化 / 可轮询真实阶段进度,按背景、返回按钮、图集、切片、持久化、写草稿分阶段落库;统一后端全局生成 deadline、VectorEngine 重试预算、前端等待窗口和失败态回写。超时后再次进入同一 session 应优先恢复正在运行或已完成的任务,不应重复生图。
|
||||
- 验证:模拟首张 image2 超长耗时或超时重试时,生成页应显示真实阶段和可恢复状态;前端请求超时不应把最终成功草稿标记为失败;刷新 `/creation/jump-hop/generating?sessionId=<id>` 后应能恢复到后端真实状态;同一 session 重试不得重复生成已完成阶段。
|
||||
- 关联:`src/services/jump-hop/jumpHopClient.ts`、`src/services/miniGameDraftGenerationProgress.ts`、`server-rs/crates/api-server/src/jump_hop.rs`、`server-rs/crates/platform-image/src/vector_engine/client.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## SpacetimeDB 连接池租约必须有 Drop 兜底,acquire 不允许无界自旋
|
||||
|
||||
- 现象:release 上 api-server 周期性出现全量 `spacetime_stage="pool_acquire" elapsed_ms=45000` 业务超时,`/readyz` 503(`reason=spacetime_unhealthy, stage=pool_acquire`),`/healthz` 仍 200,只有重启能恢复,过若干小时复发。
|
||||
- 原因:旧 `PooledConnectionLease` 只能显式 `release_connection` 归还;HTTP 请求方在等待 StDB 回包期间断开时 handler future 被取消,permit 自动归还但槽位 `in_use` 永不复位。后续 acquire 在拿到 permit 后进入无界 `loop + yield_now` 扫描空闲槽位,泄漏积累到 pool_size 后整池挂死。
|
||||
- 处理:租约持有 `Arc<SpacetimeConnectionPool>` 并实现 `Drop` 统一复位槽位/归还连接;槽位改 `AtomicBool` CAS 抢占,删除自旋循环(持有 permit 必然命中空闲槽位)。任何新的"显式归还"资源在 async 取消语义下都要先想 Drop 兜底。
|
||||
- 验证:`cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`(`dropped_lease_releases_slot_and_permit`、`acquire_times_out_at_pool_acquire_when_pool_is_busy`)。
|
||||
- 关联:`server-rs/crates/spacetime-client/src/lib.rs`、`docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md`。
|
||||
|
||||
@@ -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,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` 输出和现有部署文档为准。
|
||||
@@ -0,0 +1,40 @@
|
||||
# SpacetimeDB 连接池租约 Drop 兜底与取消安全
|
||||
|
||||
- 日期:2026-06-11
|
||||
- 关联故障:release 环境 api-server 周期性全量 `spacetime_stage="pool_acquire" elapsed_ms=45000` 超时,`/readyz` 503(`reason=spacetime_unhealthy, stage=pool_acquire`),重启后临时恢复。
|
||||
- 涉及代码:`server-rs/crates/spacetime-client/src/lib.rs`
|
||||
|
||||
## 故障根因
|
||||
|
||||
修复前的连接池存在两个叠加缺陷:
|
||||
|
||||
1. **租约没有 Drop 兜底**。`PooledConnectionLease` 只能通过显式 `release_connection` 归还。当 HTTP 请求方在等待 StDB 回包期间断开(前端超时、用户刷新、Nginx 截断),axum/hyper 会直接丢弃 handler future,租约被 Drop:permit 因 `OwnedSemaphorePermit` 自动归还,但槽位的 `in_use` 标记永远不会复位。
|
||||
2. **acquire 在槽位泄漏后永久空转**。后续请求拿到 permit 后进入 `loop { 扫描槽位; yield_now }`,找不到空闲槽位就无限自旋,且这段自旋不受 `procedure_timeout` 约束,自旋期间 permit 不归还。
|
||||
|
||||
叠加效果:StDB 一旦变慢(请求占用连接接近 45 秒),客户端取消请求的概率大增,每次取消泄漏一个槽位并连带吞掉一个 permit;泄漏数量达到 `pool_size`(release 为 8)后,所有业务请求与健康检查全部在 `pool_acquire` 阶段 45 秒超时,服务表现为"连不上 StDB",只有重启能恢复。
|
||||
|
||||
## 本地复现
|
||||
|
||||
不需要真实 SpacetimeDB,单元测试即可复现机制(位于 `spacetime-client` tests 模块):
|
||||
|
||||
- 修复前:将一个槽位置为 `in_use=true` 后调用 `acquire_connection_with_timeout(200ms)`,acquire 在 5 秒守护窗口内不返回(永久自旋),测试红。
|
||||
- `dropped_lease_releases_slot_and_permit`:模拟"请求被取消、租约未经 release 直接 Drop",断言槽位与 permit 都被复位归还。
|
||||
- `acquire_times_out_at_pool_acquire_when_pool_is_busy`:池内 permit 全部被占用时,acquire 必须在超时窗口内返回 `PoolAcquire + Timeout`,不允许无限等待。
|
||||
|
||||
## 修复方案
|
||||
|
||||
1. `PooledConnectionSlot` 改为 `in_use: AtomicBool + connection: Mutex<Option<PooledConnection>>`,槽位占用标记不再依赖异步锁。
|
||||
2. `PooledConnectionLease` 持有 `Arc<SpacetimeConnectionPool>` 并实现 `Drop`:无论显式归还还是 future 被取消,统一在 Drop 中复位槽位、按 broken 状态决定连接是否回池,permit 随后自动归还。Drop 体先复位 `in_use` 再释放 permit(字段在 Drop 体之后析构),保证新请求拿到 permit 时必有空闲槽位。
|
||||
3. acquire 改为 CAS 抢占槽位:持有 permit 即保证并发持有者不超过 `pool_size`,扫描一轮必然命中空闲槽位,彻底删除自旋循环;建连失败直接返回错误,槽位由租约 Drop 复位。
|
||||
4. `release_connection` 退化为 `drop(lease)`,显式与隐式归还共用同一条兜底路径。
|
||||
|
||||
## 验收
|
||||
|
||||
- `cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`(35 通过,含上述新测试)
|
||||
- `cargo test -p api-server --manifest-path server-rs/Cargo.toml readyz`(2 通过)
|
||||
- `cargo check -p api-server --manifest-path server-rs/Cargo.toml`
|
||||
|
||||
## 运维提示
|
||||
|
||||
- 此修复解决的是"取消导致的永久泄漏"。StDB 真慢时仍会出现成批 45 秒超时(连接被在途请求合法占用),那是容量/上游问题,应结合 `GENARRATIVE_SPACETIME_POOL_SIZE` 与 StDB 负载排查,不要再怀疑池泄漏。
|
||||
- 健康检查 `/readyz` 在池被在途请求占满时仍可能短暂 503(stage=pool_acquire),恢复后自动转好,无需重启。
|
||||
@@ -125,11 +125,14 @@ npm run check:server-rs-ddd
|
||||
|
||||
`/api/runtime/puzzle/runs*` 当前接受 `RuntimePrincipal`,可同时识别登录用户 Bearer 和 runtime guest token。推荐页嵌入运行态的正式开局、交换、拖拽、下一关、暂停、道具与排行榜请求,应由前端在登录态下继续携带账号 access token;匿名游客仅在确认为未登录时走 runtime guest token。不要再把拼图 runtime 当成只认普通 Bearer 的纯账号接口。
|
||||
|
||||
公开正式 runtime 的启动与局内同步动作统一接受 `RuntimePrincipal`,包括拼图、拼消消、跳一跳、敲木鱼、抓大鹅 Match3D、方洞挑战、视觉小说、大鱼吃小鱼和汪汪声浪。登录用户仍使用账号 Bearer;未登录推荐页或公开运行态使用 Runtime Guest Token,后端以 `principal.subject()` 作为本局 owner / player subject,并用 `WorkPlayTrackingDraft::runtime_principal(...)` 记录游玩。创作、个人作品、删除、发布、Remix、点赞等账号或所有权动作不得改成 runtime guest 鉴权。
|
||||
|
||||
抓大鹅 Match3D `api-server` 内部拆分:
|
||||
|
||||
- `server-rs/crates/api-server/src/modules/match3d.rs` 继续负责路由装配和 body limit;对外 handler 名称保持不变。
|
||||
- `server-rs/crates/api-server/src/match3d.rs` 只作为聚合入口,保留共享 import / 常量 / 内部类型、模块声明和 handler re-export。
|
||||
- `server-rs/crates/api-server/src/match3d/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP 响应。
|
||||
- `/api/runtime/match3d/works/{profile_id}/runs`、`/api/runtime/match3d/runs/{run_id}`、`/click`、`/stop`、`/restart` 与 `/time-up` 属于正式运行态局部请求,必须接受 `RuntimePrincipal`;登录用户使用账号 Bearer,推荐页匿名游客使用 runtime guest token,后端以 principal subject 作为本局 owner,不得退回只认普通 Bearer 的路由。
|
||||
- `server-rs/crates/api-server/src/match3d/draft.rs` 承接 Agent session、草稿编译、题材 / 难度 / 物品计划和草稿持久化编排。
|
||||
- `server-rs/crates/api-server/src/match3d/works.rs` 承接作品 CRUD、封面 / 背景 / 容器资产生成入口、发布 / Remix / 点赞 / 游玩记录和作品级 helper。
|
||||
- `server-rs/crates/api-server/src/match3d/item_assets.rs` 承接物品生成批次编排、append / replace / delete / sort / merge、计费外层和草稿素材映射;sheet prompt、绿幕 / 近白底透明化、切图和切片持久化复用 `generated_asset_sheets` 通用模块。
|
||||
@@ -187,6 +190,10 @@ npm run check:server-rs-ddd
|
||||
1. `creation_entry_type_config.unified_creation_spec_json` 内的 `mudPointCost` 是玩法新建草稿初始生成的泥点成本真相源,同时供入口卡展示和前端余额前置校验使用;旧契约缺失时允许按代码默认成本兜底。
|
||||
2. `api-server` 执行拼图首图生成、抓大鹅完整草稿生成和汪汪声浪初始三图生成时,必须通过 `GET /api/creation-entry/config` 同源配置解析对应玩法成本后再调用钱包扣费 procedure,不得继续使用前端或后端硬编码常量作为实际扣费真相。
|
||||
3. 结果页单图重生成、发布、道具使用和其它独立资产操作仍按各自业务操作成本执行;不要把初始草稿成本误套到这些单次操作上。
|
||||
4. 资产操作的预扣费必须 fail-closed:钱包或 SpacetimeDB 预扣费不可达、超时或返回业务错误时,`api-server` 直接返回错误,不允许继续调用图片、音频、GLB 等外部生成 provider。
|
||||
5. 需要支持 HTTP retry 的计费 ledger id 必须包含当前请求的 `request_id`;前端 `fetchWithApiAuth` 同一次业务请求的静默刷新重试复用同一个 `x-request-id`,后端不得再使用 prompt 指纹或随机 asset id 作为扣费幂等键。
|
||||
6. 外部生成已预扣费但后续失败时必须先同步调用钱包退款;若 SpacetimeDB 暂不可用,退款请求写入 `wallet-refund-outbox` 本地文件并由后台 worker 重放。默认启用,配置项为 `GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED`、`GENARRATIVE_WALLET_REFUND_OUTBOX_DIR`、`GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE`、`GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS` 和 `GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES`。outbox 文件按 refund ledger id 幂等落盘;成功重放后删除,坏文件隔离为 `corrupt-*`。
|
||||
7. 拼图首图后台生成的跨实例互斥锁必须落在 SpacetimeDB `puzzle_background_compile_task` 表,claim id 由 `task_id + request_id` 构成,释放时必须校验 claim id,避免旧后台任务释放新请求抢到的租约。
|
||||
|
||||
## 外部服务与资产
|
||||
|
||||
@@ -637,6 +644,12 @@ npm run check:server-rs-ddd
|
||||
- Rust 结构体:`PuzzleAgentSessionRow`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
||||
|
||||
### `puzzle_background_compile_task`
|
||||
|
||||
- Rust 结构体:`PuzzleBackgroundCompileTaskRow`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
||||
- 说明:拼图首图后台生成的跨 api-server 实例互斥 claim 表,只保存活动任务租约,不表达最终生成结果;`task_id` 为主键,`claim_id` 用于释放时防止误删新租约,租约超时时间为 30 分钟。
|
||||
|
||||
### `puzzle_event`
|
||||
|
||||
- Rust 结构体:`PuzzleEvent`
|
||||
|
||||
@@ -146,12 +146,14 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
|
||||
- 拼图试玩和正式运行态刷新恢复不复用创作私有 query。进入 `/runtime/puzzle` 时必须写入 `runtimeProfileId`、草稿 `runtimeSessionId`、可选 `runtimeLevelId`、公开作品 `work` 和 `mode=draft|published`;进入运行态的导航顺序必须先切到 `/runtime/puzzle`,再写这些 runtime query,避免被阶段导航清掉后刷新停在“正在进入拼图关卡”。
|
||||
- 结果页生成关卡图时若关卡名为空,前端必须传 `shouldAutoNameLevel=true`,后端复用首关命名契约先按画面描述生成关卡名,再在图片生成后用视觉命名结果精修,并把生成名和 UI 背景提示词随本次关卡快照写回。
|
||||
- 拼图运行态背景优先读取当前关卡 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,旧数据才兼容 `uiBackgroundImageSrc/uiBackgroundImageObjectKey`;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺关卡背景时必须继承同作品首个可用关卡背景,仍缺失时才沿用当前运行态快照背景或默认 UI。运行态按钮视觉优先读取当前关卡 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,先按透明 alpha 自动边界检测识别 spritesheet 中的独立按钮展示矩形,再按原图位置从左到右、从上到下映射到返回、设置、下一关、提示、原图、冻结;同一组件还要按较高 alpha 阈值派生紧致点击热区,透明留白和柔边低 alpha 区域尽量不响应点击。检测失败时回退旧固定六格裁切,缺失时才用现有图标按钮兜底。有 spritesheet 时,返回、设置和下一关的点击容器只提供透明点击区,不再叠加默认白色圆形底、胶囊主按钮底或额外文字;下一关按钮在通关弹窗和底部入口中都直接使用 spritesheet 裁切出的 next 素材作为按钮本体。底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。
|
||||
- 推荐页本身不是登录门禁入口,未登录用户点击底部或侧边栏的推荐 Tab 应直接进入嵌入运行态,不主动打开登录弹窗。推荐页嵌入运行态必须按真实身份分流:已登录用户或本地已有 access token 时,启动拼图和后续排行榜 / 下一关等正式请求继续走账号 Bearer;只有确认为匿名访客时才申请并透传 runtime guest token。`/api/runtime/puzzle/runs*` 后端统一接受 `RuntimePrincipal`,可识别账号用户和匿名 runtime guest;推荐卡片的后台读写请求仍使用 local auth impact,避免单卡 401 清空整站登录态。创作、个人作品、删除、发布、Remix 等账号或所有权动作仍保持普通用户鉴权。
|
||||
- 推荐页本身不是登录门禁入口,未登录用户点击底部或侧边栏的推荐 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 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
|
||||
- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。
|
||||
- 拼图运行态壳层自身要补齐 `platform-ui-shell` / `platform-theme` / `platform-theme--light|dark`,不能依赖外层平台壳来提供主题变量;`/puzzle` 直达页和平台内嵌页都必须渲染同一套主题语义类。
|
||||
- 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo.png` 卡通形象;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。
|
||||
- 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo-runtime-hud.webp` 卡通形象小图;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。
|
||||
- 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。
|
||||
- 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜、下一关按钮和相似作品卡。
|
||||
- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品。
|
||||
@@ -306,7 +308,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
|
||||
- 难度只决定本局加载的物品种类数量:轻松 3、标准 9、进阶 15、硬核 20。硬核仍保留 21 次消除和 63 件总物品,运行态按 20 种素材循环复用,不要求生成第 21 种素材。
|
||||
- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景、UI spritesheet 和物品 spritesheet;首次生成自动试玩、结果页手动试玩、推荐流和公开详情启动都必须传入提升后的 profile。卡片摘要缺图集字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和图集字段传给 `Match3DRuntimeShell`。
|
||||
- 背景图作为运行态全屏背景,图内已经保留容器;旧 `containerImage*` 只作为历史透明容器兼容字段。若 `containerImage*` 与 `uiSpritesheetImage*` 同源,运行态不得把 UI spritesheet 当中心容器图叠到棋盘上。
|
||||
- 抓大鹅运行态 HUD 需贴近拼图顶部信息条的视觉口径:左上只保留透明返回按钮;右上不再暴露设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板和同造型,并在牌面左侧挂上 `media/logo.png` 产品 logo;下方备选栏和道具图标只保留内容与交互边界,不再显示灰白半透底板;中央容器图层视觉可隐藏,但棋盘命中边界仍保留。
|
||||
- 抓大鹅运行态 HUD 需贴近拼图顶部信息条的视觉口径:左上只保留透明返回按钮;右上不再暴露设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板和同造型,并在牌面左侧挂上 `media/logo-runtime-hud.webp` 产品 logo 小图;下方备选栏和道具图标只保留内容与交互边界,不再显示灰白半透底板;中央容器图层视觉可隐藏,但棋盘命中边界仍保留。
|
||||
- generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL,只有资源源列表变化或换签失败后才允许进入兜底视觉。
|
||||
- `itemSize` 只缩放生成 2D 图片本体:`大`、`中`、`小` 均按相对尺寸缩放,其中 `大` 也比原始图片略小,`中` 和 `小` 进一步缩小;不改变后端下发的布局半径、点击半径或三消规则。
|
||||
- 物品进入底部物品栏时按同类型插入:如果物品栏已有同类物品,新物品插到该类型最后一个物品后面,后续物品整体后移;没有同类时追加到当前末尾。达到三件同类时,在飞入物品栏动画结束后,左侧和右侧同类物品向中间合成,三件一起消失,播放合成音效,不展示星星图标,后面的物品再向前补位。该动效只是前端表现层,后端和本地试玩仍负责权威插入、指定点击类型清除与补位后的槽位快照。
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "九宫切图"
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
const GRID_SIZE = 3;
|
||||
const TILE_COUNT = GRID_SIZE * GRID_SIZE;
|
||||
|
||||
function normalizeQueryValue(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function sanitizeFileNamePart(value) {
|
||||
const normalized = normalizeQueryValue(value)
|
||||
.replace(/[\\/:*?"<>|]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.slice(0, 32);
|
||||
return normalized || 'taonier';
|
||||
}
|
||||
|
||||
function buildShareGridTileFileName(params, tileIndex) {
|
||||
const safeTitle = sanitizeFileNamePart(params.title || params.publicWorkCode);
|
||||
const safeCode = sanitizeFileNamePart(params.publicWorkCode || 'share');
|
||||
const order = String(tileIndex + 1).padStart(2, '0');
|
||||
return `${safeTitle}-${safeCode}-${order}.png`;
|
||||
}
|
||||
|
||||
function normalizeShareGridQuery(query) {
|
||||
return {
|
||||
imageUrl: normalizeQueryValue(query && query.imageUrl),
|
||||
title: normalizeQueryValue(query && query.title) || '我的作品',
|
||||
publicWorkCode: normalizeQueryValue(query && query.publicWorkCode),
|
||||
};
|
||||
}
|
||||
|
||||
function buildShareGridTilePlan(imageWidth, imageHeight) {
|
||||
const tileWidth = Math.floor(imageWidth / GRID_SIZE);
|
||||
const tileHeight = Math.floor(imageHeight / GRID_SIZE);
|
||||
const plan = [];
|
||||
|
||||
for (let row = 0; row < GRID_SIZE; row += 1) {
|
||||
for (let col = 0; col < GRID_SIZE; col += 1) {
|
||||
const index = row * GRID_SIZE + col;
|
||||
const sourceX = col * tileWidth;
|
||||
const sourceY = row * tileHeight;
|
||||
plan.push({
|
||||
index,
|
||||
row,
|
||||
col,
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourceWidth: col === GRID_SIZE - 1 ? imageWidth - sourceX : tileWidth,
|
||||
sourceHeight: row === GRID_SIZE - 1 ? imageHeight - sourceY : tileHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GRID_SIZE,
|
||||
TILE_COUNT,
|
||||
buildShareGridTileFileName,
|
||||
buildShareGridTilePlan,
|
||||
normalizeShareGridQuery,
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import shareGridBridge from './index.shared.js';
|
||||
|
||||
const {
|
||||
buildShareGridTileFileName,
|
||||
buildShareGridTilePlan,
|
||||
normalizeShareGridQuery,
|
||||
} = shareGridBridge;
|
||||
|
||||
describe('share-grid mini program bridge', () => {
|
||||
test('normalizes query values and keeps a fallback title', () => {
|
||||
expect(
|
||||
normalizeShareGridQuery({
|
||||
imageUrl: ' https://web.test/cover.png ',
|
||||
publicWorkCode: ' PZ-0001 ',
|
||||
}),
|
||||
).toEqual({
|
||||
imageUrl: 'https://web.test/cover.png',
|
||||
title: '我的作品',
|
||||
publicWorkCode: 'PZ-0001',
|
||||
});
|
||||
});
|
||||
|
||||
test('names tiles by title, public code and left-to-right order', () => {
|
||||
const params = {
|
||||
title: '星港:拼图',
|
||||
publicWorkCode: 'PZ-0001',
|
||||
};
|
||||
|
||||
expect(buildShareGridTileFileName(params, 0)).toBe(
|
||||
'星港拼图-PZ-0001-01.png',
|
||||
);
|
||||
expect(buildShareGridTileFileName(params, 8)).toBe(
|
||||
'星港拼图-PZ-0001-09.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('builds a 3x3 crop plan in reading order', () => {
|
||||
const plan = buildShareGridTilePlan(900, 600);
|
||||
|
||||
expect(plan).toHaveLength(9);
|
||||
expect(plan[0]).toMatchObject({
|
||||
index: 0,
|
||||
row: 0,
|
||||
col: 0,
|
||||
sourceX: 0,
|
||||
sourceY: 0,
|
||||
sourceWidth: 300,
|
||||
sourceHeight: 200,
|
||||
});
|
||||
expect(plan[4]).toMatchObject({
|
||||
index: 4,
|
||||
row: 1,
|
||||
col: 1,
|
||||
sourceX: 300,
|
||||
sourceY: 200,
|
||||
});
|
||||
expect(plan[8]).toMatchObject({
|
||||
index: 8,
|
||||
row: 2,
|
||||
col: 2,
|
||||
sourceX: 600,
|
||||
sourceY: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
<view class="share-grid-page">
|
||||
<view class="share-grid-card">
|
||||
<view class="share-grid-title">{{title}}</view>
|
||||
<view wx:if="{{loading}}" class="share-grid-text">
|
||||
正在保存 {{savedCount}}/9
|
||||
</view>
|
||||
<view wx:elif="{{errorMessage}}" class="share-grid-text share-grid-text--danger">
|
||||
{{errorMessage}}
|
||||
</view>
|
||||
<view wx:else class="share-grid-text">已保存 9/9</view>
|
||||
<button class="share-grid-button" bindtap="handleBack">
|
||||
返回
|
||||
</button>
|
||||
</view>
|
||||
<canvas
|
||||
id="share-grid-canvas"
|
||||
type="2d"
|
||||
class="share-grid-canvas"
|
||||
></canvas>
|
||||
</view>
|
||||
@@ -0,0 +1,60 @@
|
||||
page {
|
||||
background: #fffdf9;
|
||||
}
|
||||
|
||||
.share-grid-page {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48rpx;
|
||||
background: #fffdf9;
|
||||
}
|
||||
|
||||
.share-grid-card {
|
||||
width: 100%;
|
||||
max-width: 560rpx;
|
||||
box-sizing: border-box;
|
||||
border: 1rpx solid rgba(127, 85, 57, 0.18);
|
||||
border-radius: 16rpx;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
padding: 36rpx;
|
||||
box-shadow: 0 24rpx 68rpx rgba(127, 85, 57, 0.12);
|
||||
}
|
||||
|
||||
.share-grid-title {
|
||||
color: #332820;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.share-grid-text {
|
||||
margin-top: 18rpx;
|
||||
color: rgba(51, 40, 32, 0.68);
|
||||
font-size: 26rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.share-grid-text--danger {
|
||||
color: #b84a3d;
|
||||
}
|
||||
|
||||
.share-grid-button {
|
||||
margin-top: 28rpx;
|
||||
width: 100%;
|
||||
border-radius: 8rpx;
|
||||
background: #7f5539;
|
||||
color: #fffdf9;
|
||||
font-size: 28rpx;
|
||||
line-height: 2.6;
|
||||
}
|
||||
|
||||
.share-grid-canvas {
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
@@ -10,6 +10,13 @@ const {
|
||||
WEB_VIEW_ENTRY_URL,
|
||||
WEB_VIEW_SOURCE_QUERY,
|
||||
} = require('../../config');
|
||||
const {
|
||||
appendHashParams,
|
||||
buildWebViewSharePath,
|
||||
buildWebViewShareTimelineQuery,
|
||||
resolveShareTargetFromWebViewMessage,
|
||||
resolveWebViewUrlFromRuntimeConfig,
|
||||
} = require('./index.shared');
|
||||
|
||||
const MINI_PROGRAM_CLIENT_TYPE = 'mini_program';
|
||||
const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program';
|
||||
@@ -19,7 +26,6 @@ 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') {
|
||||
@@ -32,17 +38,25 @@ function showWebViewShareMenu() {
|
||||
});
|
||||
}
|
||||
|
||||
function buildWebViewShareAppMessage() {
|
||||
function resolveNativeShareQuery(page) {
|
||||
return (
|
||||
(page && page._currentShareTarget) ||
|
||||
(page && page._lastLaunchQuery) ||
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
function buildWebViewShareAppMessage(query = {}) {
|
||||
return {
|
||||
title: WEB_VIEW_SHARE_TITLE,
|
||||
path: WEB_VIEW_SHARE_PATH,
|
||||
path: buildWebViewSharePath(query),
|
||||
};
|
||||
}
|
||||
|
||||
function buildWebViewShareTimeline() {
|
||||
function buildWebViewShareTimeline(query = {}) {
|
||||
return {
|
||||
title: WEB_VIEW_SHARE_TITLE,
|
||||
query: '',
|
||||
query: buildWebViewShareTimelineQuery(query),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,50 +73,6 @@ function isConfiguredApiBaseUrl(value) {
|
||||
return /^https:\/\/[^/]+/i.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
function appendQuery(url, query) {
|
||||
const pairs = Object.keys(query)
|
||||
.filter((key) => query[key])
|
||||
.map(
|
||||
(key) =>
|
||||
`${encodeURIComponent(key)}=${encodeURIComponent(String(query[key]))}`,
|
||||
);
|
||||
|
||||
if (pairs.length === 0) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return `${url}${url.includes('?') ? '&' : '?'}${pairs.join('&')}`;
|
||||
}
|
||||
|
||||
function appendHashParams(url, params) {
|
||||
const nextKeys = new Set(Object.keys(params).filter((key) => params[key]));
|
||||
const pairs = Object.keys(params)
|
||||
.filter((key) => params[key])
|
||||
.map(
|
||||
(key) =>
|
||||
`${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`,
|
||||
);
|
||||
if (pairs.length === 0) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const hashIndex = url.indexOf('#');
|
||||
const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
|
||||
const rawHash = hashIndex >= 0 ? url.slice(hashIndex + 1) : '';
|
||||
const keptHashParts = rawHash.split('&').filter((part) => {
|
||||
if (!part) {
|
||||
return false;
|
||||
}
|
||||
const [rawKey = ''] = part.split('=');
|
||||
try {
|
||||
return !nextKeys.has(decodeURIComponent(rawKey));
|
||||
} catch (_error) {
|
||||
return !nextKeys.has(rawKey);
|
||||
}
|
||||
});
|
||||
return `${baseUrl}#${keptHashParts.concat(pairs).join('&')}`;
|
||||
}
|
||||
|
||||
function parseBooleanQueryFlag(value) {
|
||||
return value === true || value === '1' || value === 'true' || value === 'yes';
|
||||
}
|
||||
@@ -233,22 +203,16 @@ function shouldReturnToPreviousPage(query) {
|
||||
return String((query && query.returnTo) || '').trim() === 'previous';
|
||||
}
|
||||
|
||||
function resolveWebViewUrl(authResult) {
|
||||
function resolveWebViewUrl(authResult, launchQuery = {}) {
|
||||
const runtimeConfig = resolveMiniProgramRuntimeConfig();
|
||||
const entryUrl = String(runtimeConfig.webViewEntryUrl || '').trim();
|
||||
if (!isConfiguredEntryUrl(entryUrl)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const sourcedUrl = appendQuery(entryUrl, runtimeConfig.sourceQuery);
|
||||
if (!authResult || !authResult.token) {
|
||||
return sourcedUrl;
|
||||
}
|
||||
|
||||
return appendHashParams(sourcedUrl, {
|
||||
auth_provider: 'wechat',
|
||||
auth_token: authResult.token,
|
||||
auth_binding_status: authResult.bindingStatus,
|
||||
return resolveWebViewUrlFromRuntimeConfig(authResult, launchQuery, {
|
||||
...runtimeConfig,
|
||||
webViewEntryUrl: String(runtimeConfig.webViewEntryUrl || '').trim(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -467,7 +431,7 @@ Page({
|
||||
loading: false,
|
||||
phoneBindingRequired: false,
|
||||
returnToPreviousPage: false,
|
||||
webViewUrl: resolveWebViewUrl(null),
|
||||
webViewUrl: resolveWebViewUrl(null, query),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -572,7 +536,7 @@ Page({
|
||||
nicknameRequired: false,
|
||||
phoneBindingRequired: false,
|
||||
returnToPreviousPage,
|
||||
webViewUrl: resolveWebViewUrl(authResult),
|
||||
webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}),
|
||||
});
|
||||
} catch (error) {
|
||||
this.setData({
|
||||
@@ -600,7 +564,7 @@ Page({
|
||||
loading: false,
|
||||
nicknameRequired: false,
|
||||
phoneBindingRequired: false,
|
||||
webViewUrl: resolveWebViewUrl(authResult),
|
||||
webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -674,7 +638,10 @@ Page({
|
||||
loading: false,
|
||||
nicknameRequired: false,
|
||||
phoneBindingRequired: false,
|
||||
webViewUrl: resolveWebViewUrl(nextAuthResult),
|
||||
webViewUrl: resolveWebViewUrl(
|
||||
nextAuthResult,
|
||||
this._lastLaunchQuery || {},
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
this.setData({
|
||||
@@ -712,15 +679,19 @@ Page({
|
||||
},
|
||||
|
||||
handleWebViewMessage(event) {
|
||||
const shareTarget = resolveShareTargetFromWebViewMessage(event.detail);
|
||||
if (shareTarget) {
|
||||
this._currentShareTarget = shareTarget;
|
||||
}
|
||||
// 中文注释:支付和订阅消息都由独立 native 页面承接,web-view 消息只保留调试输出。
|
||||
console.info('[web-view] message', event.detail);
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
return buildWebViewShareAppMessage();
|
||||
return buildWebViewShareAppMessage(resolveNativeShareQuery(this));
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
return buildWebViewShareTimeline();
|
||||
return buildWebViewShareTimeline(resolveNativeShareQuery(this));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
const ALLOWED_TARGET_PATHS = new Set(['/works/detail']);
|
||||
const SHARE_TARGET_MESSAGE_TYPE = 'genarrative:share-target';
|
||||
const WEB_VIEW_SHARE_PATH = '/pages/web-view/index';
|
||||
|
||||
function trimTrailingSlash(value) {
|
||||
return String(value || '').trim().replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
function appendQuery(url, query) {
|
||||
const rawUrl = String(url || '');
|
||||
const pairs = Object.keys(query)
|
||||
.filter((key) => query[key])
|
||||
.map(
|
||||
(key) =>
|
||||
`${encodeURIComponent(key)}=${encodeURIComponent(String(query[key]))}`,
|
||||
);
|
||||
|
||||
if (pairs.length === 0) {
|
||||
return rawUrl;
|
||||
}
|
||||
|
||||
const hashIndex = rawUrl.indexOf('#');
|
||||
const baseUrl = hashIndex >= 0 ? rawUrl.slice(0, hashIndex) : rawUrl;
|
||||
const hash = hashIndex >= 0 ? rawUrl.slice(hashIndex) : '';
|
||||
return `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}${pairs.join('&')}${hash}`;
|
||||
}
|
||||
|
||||
function appendHashParams(url, params) {
|
||||
const nextKeys = new Set(Object.keys(params).filter((key) => params[key]));
|
||||
const pairs = Object.keys(params)
|
||||
.filter((key) => params[key])
|
||||
.map(
|
||||
(key) =>
|
||||
`${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`,
|
||||
);
|
||||
if (pairs.length === 0) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const hashIndex = url.indexOf('#');
|
||||
const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
|
||||
const rawHash = hashIndex >= 0 ? url.slice(hashIndex + 1) : '';
|
||||
const keptHashParts = rawHash.split('&').filter((part) => {
|
||||
if (!part) {
|
||||
return false;
|
||||
}
|
||||
const [rawKey = ''] = part.split('=');
|
||||
try {
|
||||
return !nextKeys.has(decodeURIComponent(rawKey));
|
||||
} catch (_error) {
|
||||
return !nextKeys.has(rawKey);
|
||||
}
|
||||
});
|
||||
return `${baseUrl}#${keptHashParts.concat(pairs).join('&')}`;
|
||||
}
|
||||
|
||||
function normalizeTargetPath(value) {
|
||||
const trimmed = String(value || '').trim();
|
||||
if (!trimmed.startsWith('/')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const normalized = trimmed.replace(/\/+$/u, '') || '/';
|
||||
return ALLOWED_TARGET_PATHS.has(normalized) ? normalized : '';
|
||||
}
|
||||
|
||||
function resolveLaunchTargetQuery(query) {
|
||||
const targetPath = normalizeTargetPath(query && query.targetPath);
|
||||
const work = String((query && query.work) || '').trim();
|
||||
if (!targetPath || !work) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
targetPath,
|
||||
work,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWebViewSharePath(query = {}, basePath = WEB_VIEW_SHARE_PATH) {
|
||||
const launchTarget = resolveLaunchTargetQuery(query);
|
||||
if (!launchTarget.targetPath) {
|
||||
return basePath;
|
||||
}
|
||||
|
||||
return appendQuery(basePath, {
|
||||
targetPath: launchTarget.targetPath,
|
||||
work: launchTarget.work,
|
||||
});
|
||||
}
|
||||
|
||||
function buildWebViewShareTimelineQuery(query = {}) {
|
||||
const launchTarget = resolveLaunchTargetQuery(query);
|
||||
if (!launchTarget.targetPath) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new URLSearchParams({
|
||||
targetPath: launchTarget.targetPath,
|
||||
work: launchTarget.work,
|
||||
}).toString();
|
||||
}
|
||||
|
||||
function normalizeShareTargetMessageData(value) {
|
||||
const message = value && value.data ? value.data : value;
|
||||
if (!message || message.type !== SHARE_TARGET_MESSAGE_TYPE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = message.payload || {};
|
||||
const launchTarget = resolveLaunchTargetQuery(payload);
|
||||
if (!launchTarget.targetPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...launchTarget,
|
||||
title: String(payload.title || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveShareTargetFromWebViewMessage(detail) {
|
||||
const dataList = detail && Array.isArray(detail.data) ? detail.data : [];
|
||||
for (let index = dataList.length - 1; index >= 0; index -= 1) {
|
||||
const target = normalizeShareTargetMessageData(dataList[index]);
|
||||
if (target) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeShareTargetMessageData(detail);
|
||||
}
|
||||
|
||||
function appendLaunchTargetToEntryUrl(entryUrl, query) {
|
||||
const launchTarget = resolveLaunchTargetQuery(query);
|
||||
if (!launchTarget.targetPath) {
|
||||
return entryUrl;
|
||||
}
|
||||
|
||||
const rawEntryUrl = String(entryUrl || '').trim();
|
||||
const hashIndex = rawEntryUrl.indexOf('#');
|
||||
const entryWithoutHash =
|
||||
hashIndex >= 0 ? rawEntryUrl.slice(0, hashIndex) : rawEntryUrl;
|
||||
const hash = hashIndex >= 0 ? rawEntryUrl.slice(hashIndex) : '';
|
||||
const queryIndex = entryWithoutHash.indexOf('?');
|
||||
const entryBase =
|
||||
queryIndex >= 0 ? entryWithoutHash.slice(0, queryIndex) : entryWithoutHash;
|
||||
const entrySearch =
|
||||
queryIndex >= 0 ? entryWithoutHash.slice(queryIndex) : '';
|
||||
const targetUrl = `${trimTrailingSlash(entryBase)}${launchTarget.targetPath}${entrySearch}${hash}`;
|
||||
|
||||
return appendQuery(targetUrl, {
|
||||
work: launchTarget.work,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveWebViewUrlFromRuntimeConfig(
|
||||
authResult,
|
||||
launchQuery = {},
|
||||
runtimeConfig = {},
|
||||
) {
|
||||
const entryUrl = appendLaunchTargetToEntryUrl(
|
||||
String(runtimeConfig.webViewEntryUrl || '').trim(),
|
||||
launchQuery,
|
||||
);
|
||||
const sourcedUrl = appendQuery(entryUrl, runtimeConfig.sourceQuery || {});
|
||||
if (!authResult || !authResult.token) {
|
||||
return sourcedUrl;
|
||||
}
|
||||
|
||||
return appendHashParams(sourcedUrl, {
|
||||
auth_provider: 'wechat',
|
||||
auth_token: authResult.token,
|
||||
auth_binding_status: authResult.bindingStatus,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
appendHashParams,
|
||||
appendLaunchTargetToEntryUrl,
|
||||
appendQuery,
|
||||
buildWebViewSharePath,
|
||||
buildWebViewShareTimelineQuery,
|
||||
normalizeTargetPath,
|
||||
resolveShareTargetFromWebViewMessage,
|
||||
resolveLaunchTargetQuery,
|
||||
resolveWebViewUrlFromRuntimeConfig,
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import webViewBridge from './index.shared.js';
|
||||
|
||||
const {
|
||||
appendLaunchTargetToEntryUrl,
|
||||
buildWebViewSharePath,
|
||||
buildWebViewShareTimelineQuery,
|
||||
resolveShareTargetFromWebViewMessage,
|
||||
resolveWebViewUrlFromRuntimeConfig,
|
||||
} = webViewBridge;
|
||||
|
||||
const runtimeConfig = {
|
||||
sourceQuery: {
|
||||
clientType: 'mini_program',
|
||||
clientRuntime: 'wechat_mini_program',
|
||||
},
|
||||
webViewEntryUrl: 'https://www.genarrative.world',
|
||||
};
|
||||
|
||||
describe('mini program web-view launch target', () => {
|
||||
test('opens the H5 public work detail when launch query carries work params', () => {
|
||||
expect(
|
||||
appendLaunchTargetToEntryUrl('https://www.genarrative.world?foo=bar', {
|
||||
targetPath: '/works/detail',
|
||||
work: 'BB-12345678',
|
||||
}),
|
||||
).toBe(
|
||||
'https://www.genarrative.world/works/detail?foo=bar&work=BB-12345678',
|
||||
);
|
||||
|
||||
const webViewUrl = resolveWebViewUrlFromRuntimeConfig(
|
||||
null,
|
||||
{
|
||||
targetPath: '/works/detail',
|
||||
work: 'BB-12345678',
|
||||
},
|
||||
runtimeConfig,
|
||||
);
|
||||
const url = new URL(webViewUrl);
|
||||
expect(url.pathname).toBe('/works/detail');
|
||||
expect(url.searchParams.get('work')).toBe('BB-12345678');
|
||||
expect(url.searchParams.get('clientRuntime')).toBe('wechat_mini_program');
|
||||
});
|
||||
|
||||
test('ignores unsupported launch target paths', () => {
|
||||
const webViewUrl = resolveWebViewUrlFromRuntimeConfig(
|
||||
null,
|
||||
{
|
||||
targetPath: '/admin',
|
||||
work: 'BB-12345678',
|
||||
},
|
||||
runtimeConfig,
|
||||
);
|
||||
const url = new URL(webViewUrl);
|
||||
expect(url.pathname).toBe('/');
|
||||
expect(url.searchParams.get('work')).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps public work params in native mini program share paths', () => {
|
||||
const sharePath = buildWebViewSharePath({
|
||||
targetPath: '/works/detail',
|
||||
work: 'BB-12345678',
|
||||
});
|
||||
const url = new URL(sharePath, 'https://mini.test');
|
||||
|
||||
expect(url.pathname).toBe('/pages/web-view/index');
|
||||
expect(url.searchParams.get('targetPath')).toBe('/works/detail');
|
||||
expect(url.searchParams.get('work')).toBe('BB-12345678');
|
||||
expect(
|
||||
buildWebViewShareTimelineQuery({
|
||||
targetPath: '/works/detail',
|
||||
work: 'BB-12345678',
|
||||
}),
|
||||
).toBe('targetPath=%2Fworks%2Fdetail&work=BB-12345678');
|
||||
});
|
||||
|
||||
test('reads the latest H5 recommended work share target from web-view messages', () => {
|
||||
expect(
|
||||
resolveShareTargetFromWebViewMessage({
|
||||
data: [
|
||||
{
|
||||
data: {
|
||||
type: 'genarrative:share-target',
|
||||
payload: {
|
||||
targetPath: '/works/detail',
|
||||
work: 'PZ-0001',
|
||||
title: '旧作品',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
data: {
|
||||
type: 'genarrative:share-target',
|
||||
payload: {
|
||||
targetPath: '/works/detail',
|
||||
work: 'BB-12345678',
|
||||
title: '汪汪声浪',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
targetPath: '/works/detail',
|
||||
work: 'BB-12345678',
|
||||
title: '汪汪声浪',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@
|
||||
"dev:api-server": "node scripts/dev.mjs api-server",
|
||||
"dev:web": "node scripts/dev.mjs web",
|
||||
"dev:admin-web": "node scripts/dev.mjs admin-web",
|
||||
"server-manager:panel": "cargo run -p server-manager-panel --manifest-path server-rs/Cargo.toml",
|
||||
"dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
|
||||
"otel:debug": "node scripts/run-otelcol.mjs debug",
|
||||
"otel:rider": "node scripts/run-otelcol.mjs rider",
|
||||
|
||||
+41
-6
@@ -37,6 +37,7 @@ const manifestPath = resolve(serverRsDir, 'Cargo.toml');
|
||||
const modulePath = resolve(serverRsDir, 'crates/spacetime-module');
|
||||
const viteCliPath = resolve(repoRoot, 'scripts/vite-cli.mjs');
|
||||
const adminWebDir = resolve(repoRoot, 'apps/admin-web');
|
||||
const LOCAL_DEV_RUSTC_WRAPPER_BYPASS = process.platform === 'win32' ? 'rustc' : '/usr/bin/env';
|
||||
|
||||
const SERVICE_NAMES = ['spacetime', 'api-server', 'web', 'admin-web'];
|
||||
const SERVICE_ALIASES = new Map([
|
||||
@@ -399,6 +400,39 @@ function requireCommand(command) {
|
||||
}
|
||||
}
|
||||
|
||||
function isSccacheRustcWrapper(value) {
|
||||
const wrapper = String(value ?? '').trim();
|
||||
if (!wrapper) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const command = wrapper.split(/[\\/]/).pop()?.toLowerCase();
|
||||
return command === 'sccache' || command === 'sccache.exe';
|
||||
}
|
||||
|
||||
function buildLocalRustProcessEnv(env, options = {}) {
|
||||
const mergedEnv = {...env};
|
||||
const wrappers = [
|
||||
String(mergedEnv.RUSTC_WRAPPER ?? '').trim(),
|
||||
String(mergedEnv.CARGO_BUILD_RUSTC_WRAPPER ?? '').trim(),
|
||||
].filter(Boolean);
|
||||
const customWrapper = wrappers.find((wrapper) => !isSccacheRustcWrapper(wrapper));
|
||||
if (customWrapper) {
|
||||
mergedEnv.RUSTC_WRAPPER = customWrapper;
|
||||
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = customWrapper;
|
||||
return mergedEnv;
|
||||
}
|
||||
|
||||
mergedEnv.RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS;
|
||||
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS;
|
||||
if (options.log !== false) {
|
||||
console.warn(
|
||||
'[dev:rust] 本地 dev 构建绕过项目 sccache wrapper,避免缓存进程异常阻断启动。',
|
||||
);
|
||||
}
|
||||
return mergedEnv;
|
||||
}
|
||||
|
||||
function readWorkspaceSpacetimeVersion() {
|
||||
const manifestText = readFileSync(manifestPath, 'utf8');
|
||||
const match = /^spacetimedb\s*=\s*(?:"([^"]+)"|\{[^}]*version\s*=\s*"([^"]+)")/mu.exec(
|
||||
@@ -776,7 +810,7 @@ class DevRunner {
|
||||
this.writeDevStackState();
|
||||
}
|
||||
|
||||
async prepareLinuxPortRange(command) {
|
||||
async prepareLinuxPortRange() {
|
||||
if (process.platform !== 'linux') {
|
||||
return;
|
||||
}
|
||||
@@ -1228,7 +1262,7 @@ class DevRunner {
|
||||
}
|
||||
|
||||
async publishSpacetimeModule() {
|
||||
const env = {...this.baseEnv};
|
||||
const env = buildLocalRustProcessEnv(this.baseEnv);
|
||||
this.prepareMigrationBootstrapSecret(env);
|
||||
|
||||
const args = buildSpacetimePublishArgs({
|
||||
@@ -1291,7 +1325,7 @@ class DevRunner {
|
||||
await this.ensureApiServerSpacetimeToken();
|
||||
|
||||
const mergedEnv = buildApiServerProcessEnv({
|
||||
baseEnv: this.baseEnv,
|
||||
baseEnv: buildLocalRustProcessEnv(this.baseEnv),
|
||||
options: this.options,
|
||||
state: this.state,
|
||||
});
|
||||
@@ -2124,19 +2158,20 @@ function buildApiServerProcessEnv({baseEnv, options, state}) {
|
||||
}
|
||||
|
||||
export {
|
||||
DevRunner,
|
||||
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
||||
assertSpacetimeToolVersionMatchesWorkspace,
|
||||
buildApiServerProcessEnv,
|
||||
buildDevStackSnapshot,
|
||||
buildLocalRustProcessEnv,
|
||||
buildSpacetimePublishArgs,
|
||||
createDevServerSpawnOptions,
|
||||
createWatchConfigs,
|
||||
isSpacetimePublishPermissionError,
|
||||
DevRunner,
|
||||
isDirectModuleExecution,
|
||||
isSpacetimePublishPermissionError,
|
||||
normalizeCargoVersionRequirement,
|
||||
parseSpacetimeToolVersion,
|
||||
parseArgs,
|
||||
parseSpacetimeToolVersion,
|
||||
resolveDevStackStatePath,
|
||||
shouldAcceptWatchEvent,
|
||||
};
|
||||
|
||||
+32
-2
@@ -5,19 +5,20 @@ import {join} from 'node:path';
|
||||
import {afterEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
import {
|
||||
DevRunner,
|
||||
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
||||
assertSpacetimeToolVersionMatchesWorkspace,
|
||||
buildApiServerProcessEnv,
|
||||
buildDevStackSnapshot,
|
||||
buildLocalRustProcessEnv,
|
||||
buildSpacetimePublishArgs,
|
||||
createDevServerSpawnOptions,
|
||||
createWatchConfigs,
|
||||
DevRunner,
|
||||
isDirectModuleExecution,
|
||||
isSpacetimePublishPermissionError,
|
||||
normalizeCargoVersionRequirement,
|
||||
parseSpacetimeToolVersion,
|
||||
parseArgs,
|
||||
parseSpacetimeToolVersion,
|
||||
resolveDevStackStatePath,
|
||||
shouldAcceptWatchEvent,
|
||||
} from './dev.mjs';
|
||||
@@ -185,6 +186,35 @@ describe('dev scheduler api-server env', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev scheduler Rust build env', () => {
|
||||
test('local dev Rust env bypasses project sccache wrapper', () => {
|
||||
const env = buildLocalRustProcessEnv(
|
||||
{
|
||||
RUSTC_WRAPPER: '/usr/bin/sccache',
|
||||
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
|
||||
},
|
||||
{log: false},
|
||||
);
|
||||
|
||||
expect(env.RUSTC_WRAPPER).not.toBe('/usr/bin/sccache');
|
||||
expect(env.RUSTC_WRAPPER).not.toBe('sccache');
|
||||
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(env.RUSTC_WRAPPER);
|
||||
});
|
||||
|
||||
test('local dev Rust env keeps healthy custom wrapper untouched', () => {
|
||||
const env = buildLocalRustProcessEnv(
|
||||
{
|
||||
RUSTC_WRAPPER: 'custom-wrapper',
|
||||
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
|
||||
},
|
||||
{log: false},
|
||||
);
|
||||
|
||||
expect(env.RUSTC_WRAPPER).toBe('custom-wrapper');
|
||||
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('custom-wrapper');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev scheduler stack state file', () => {
|
||||
test('状态文件路径固定在根目录 .app/dev-stack.json', () => {
|
||||
expect(resolveDevStackStatePath('C:\\repo\\Genarrative')).toBe(
|
||||
|
||||
Generated
+1760
-19
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@ members = [
|
||||
"crates/platform-wechat",
|
||||
"crates/platform-speech",
|
||||
"crates/platform-agent",
|
||||
"crates/server-manager-panel",
|
||||
"crates/shared-contracts",
|
||||
"crates/shared-kernel",
|
||||
"crates/shared-logging",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user