196 Commits

Author SHA1 Message Date
f69affec95 Merge remote-tracking branch 'origin/master' into codex/feat/jump-hop-internal-hole-detect
Current branch is outdates
2026-06-08 21:48:15 +08:00
1dcba515b2 add internal holes detection in jump-hop image processing 2026-06-08 21:32:44 +08:00
kdletters
088470a315 收口微信领域能力
将 api-server 微信 HTTP/BFF 适配统一迁移到 wechat 目录。

将微信支付和虚拟支付消息协议细节下沉到 platform-wechat。

拆分 platform-wechat 的订阅消息与支付模块并补齐依赖。

修正微信相关测试的用户 ID 夹具并同步后端架构文档。
2026-06-08 21:05:37 +08:00
kdletters
11c5e3edf4 补齐创作生成订阅消息通知
订阅消息任务名称改为玩法模板名。

拼图、敲木鱼、抓大鹅、跳一跳、方洞、视觉小说在草稿生成成功或失败终态发送通知。

订阅消息泥点字段按本次生成结算后的实际扣除展示,失败退款后显示0。

更新微信订阅消息运维和支付方案文档口径。
2026-06-08 19:21:27 +08:00
a4ee6ff698 修复小游戏生成页恢复生成态
按生成状态判定小游戏生成页是否仍在生成中

补充敲木鱼生成中草稿恢复回归测试

记录小游戏生成页恢复 busy 判定踩坑
2026-06-08 19:15:04 +08:00
6f242a290a 合并架构调整分支
合入 codex/architecture-adjustment 的架构调整提交
保留 master 上推荐页资源等待和微信订阅时间修复

# Conflicts:
#	docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
#	src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
#	src/components/rpg-entry/RpgEntryHomeView.tsx
2026-06-08 18:01:43 +08:00
52c6f4282f 等待推荐页运行态全部资源
推荐页 ready 持续观察运行态图片、背景、音视频和资源 pending 标记
资源换签与玩法图集解析中通过隐藏标记阻止遮罩提前消失
补齐拼图、跳一跳、抓大鹅和敲木鱼运行态资源等待接入
补充推荐页资源等待回归测试和团队文档
2026-06-08 17:50:45 +08:00
f4eee2d585 再次合并 master
合入 origin/master 最新订阅消息与计费相关更新

保留作品架 actions 收口并接入统一分享弹窗

修复创作生成泥点预检与本地余额扣减回归
2026-06-08 17:18:38 +08:00
kdletters
ccb5023197 修复微信订阅消息时间字段格式
生成结果订阅消息 time4 改为北京时间 YYYY-MM-DD HH:mm

补充成功和失败生成结果模板时间格式测试

记录 dev 订阅消息 time4 被微信拒收的排障口径
2026-06-08 16:19:56 +08:00
5ea9f0a120 按后台配置扣除创作泥点
前端创作表单泥点预校验改为读取入口契约配置

拼图和抓大鹅初始生成后端扣费改为解析后台配置

汪汪声浪初始三图生成按入口总成本拆分扣费

创作工作台按钮和确认弹窗展示后台配置泥点成本

补充泥点扣费回归测试并同步文档与共享记忆
2026-06-08 15:47:48 +08:00
kdletters
3ca5a460f1 修复订阅授权返回后生成中断
生成动作先进入拼图进度页并立即发起生成

订阅授权改为非阻塞尝试,避免闪回卡住提交

移除订阅结果回写 web-view URL 导致回首页的逻辑

更新小程序订阅消息授权与发送边界文档
2026-06-08 15:01:52 +08:00
8f991a4ac2 隐藏历史图片名称
历史图片选择弹窗只展示缩略图和生成时间

历史素材兜底文案统一为历史素材

更新拼图历史图片展示口径文档

同步调整拼图历史图片选择流程测试
2026-06-08 14:53:07 +08:00
kdletters
59b5bd1f83 修复小程序生成前订阅授权体验
生成前订阅授权页自动弹出微信授权框

授权返回或跳过后继续执行拼图生成提交

避免订阅页改写上一页 web-view URL 导致回首页

更新订阅消息生成前授权与终态发送文档口径
2026-06-08 13:48:49 +08:00
kdletters
3a918687c5 修复拼图生成前订阅授权
新增小程序原生订阅消息授权页,在用户点击后请求生成结果通知授权。

拼图 compile_puzzle_draft 前等待授权页返回或跳过后再发起生成 action。

移除 web-view message 订阅授权路径,改用 storage/hash 回写订阅结果。

补充订阅授权测试、文档和团队踩坑记录。
2026-06-08 13:06:07 +08:00
kdletters
38d9c292ae feat: send puzzle result subscribe messages 2026-06-08 11:49:11 +08:00
49e4d085b3 修复作品架已发布卡片分享入口
为作品架分享按钮补充可见底色与回归测试
将已发布作品卡分享动作接入统一发布分享弹窗
同步玩法链路文档中的作品架分享口径
2026-06-08 01:54:11 +08:00
ff2ed5a59d 升级 SpacetimeDB 到 2.4.1
更新 server-rs SpacetimeDB crate 锁定版本与 Cargo.lock。

刷新 spacetime-client 生成绑定到 2.4.1。

同步 dev 脚本、容器、Jenkins 和运维文档中的 STDB 版本。

补充 dev 脚本版本校验测试,兼容 Cargo 精确版本要求。
2026-06-08 00:47:24 +08:00
498f7c9a3d 再次合并 master
合入 origin/master 最新创作入口契约与后台编辑调整。

保留本枝平台入口架构收口约束并合并玩法链路文档。

通过 typecheck、编码检查、冲突扫描与相关创作入口测试。
2026-06-08 00:00:36 +08:00
17662916cd 收口创作入口契约后台表单
将统一创作契约泥点消耗改为数字字段并由前端格式化展示
将后台契约编辑从 JSON 文本改为结构化卡片与弹窗表单
隐藏玩法阶段等内部标识并按玩法默认映射自动带出
更新创作入口文档、团队记忆和回归测试
2026-06-07 23:53:31 +08:00
e4b13d73b5 Merge remote-tracking branch 'origin/master' into codex/architecture-adjustment 2026-06-07 23:38:54 +08:00
2a6da01307 修复生成图片签名地址重复换签
将 refreshKey 调整为 signed URL 缓存版本号,同一路径同版本复用未过期签名地址。

让完整阿里云 OSS generated 地址在 hook 中先归一并走 read-url 换签。

补充前端回归测试,覆盖相同 refreshKey 不重复换签和完整 OSS 地址不裸写入图片。

更新运维文档与 Hermes 记忆,明确 refreshKey 不是每次绕过签名缓存。
2026-06-07 23:20:24 +08:00
7719c7e5a8 再次合并 master
合入 origin/master 最新后端、OSS 与认证链路调整。

保留本枝架构收口修改并合并 Hermes 决策记录。

通过 typecheck、编码检查、Spacetime schema guard 与 api-server cargo check。
2026-06-07 22:52:45 +08:00
d3a3238028 修复生成图片OSS签名缓存链路
前端将完整阿里云OSS generated 地址归一为 legacy path 后走 read-url 换签。

platform-oss 为 generated 私有对象 PostObject 和 PutObject 写入 immutable Cache-Control。

补齐 shared-contracts 与 api-server 直传票据字段映射。

更新后端、运维文档和 Hermes 团队记忆,明确不使用服务端磁盘缓存兜底。
2026-06-07 22:46:48 +08:00
decded991e 清理后端编译警告
删除后端未使用的历史 helper、mapper、handler 和 re-export

将仅测试使用的导入、常量和辅助函数收口到 cfg(test)

补齐 Jump Hop 测试构造体字段并对齐 Match3D 当前素材表测试契约

验证后端 workspace cargo check 与 Match3D、Puzzle 相关测试
2026-06-07 22:20:58 +08:00
48c7cce1ba 合并 master 并修复架构分支回归
合入 master 最新的认证、玩法契约与推荐页改动。

修复拼图草稿生成、推荐页下一关和公开详情访客试玩回归。

修复抓大鹅草稿试玩鉴权与首屏推荐详情测试入口。

补齐相关测试夹具、文档与团队记忆更新。
2026-06-07 21:35:47 +08:00
cc84656a1f 修复多端登录互相顶号
单设备退出只撤销当前 refresh session,不再提升账号级 token_version

认证中间件和 refresh 接口在本进程未命中会话时按需刷新 SpacetimeDB 认证工作集

补充多端登录与跨进程会话补水回归测试

同步项目文档和 Hermes 共享决策记录
2026-06-07 20:54:35 +08:00
a5143fa0cb 修复推荐页切作品误报无法进入
区分推荐运行态启动 pending 与真实失败

切作品时保持封面遮罩等待自动重试

补充推荐页 pending 切换回归测试

更新玩法链路文档的失败态判断口径
2026-06-07 19:15:32 +08:00
665f09f047 修复移动端软键盘页面弹跳黑底
移除 H5 软键盘打开时平台壳全局 transform 位移,避免浏览器原生避让后再次弹跳。

保留键盘打开状态、底部 dock 隐藏和浅色根背景兜底,避免短表单露出黑色宿主底色。

补充小程序 web-view 原生 page 浅色背景和对应样式测试。

更新统一创作页与平台键盘适配文档,沉淀不再全局上移平台壳的约束。
2026-06-07 18:17:23 +08:00
56a9075582 修复推荐页封面遮罩与登录态刷新
推荐页运行态封面增加加载条并隔离层级,避免 runtime 内容穿透封面

登录态从未登录到已登录或退出后刷新当前页面,退出等待 token 清理完成后再刷新

补充推荐页封面、认证刷新与样式回归测试

同步平台链路、项目基线和 Hermes 决策文档
2026-06-07 17:59:11 +08:00
ea4706daa6 修正 Jenkins 发布源码校验
API 发布流水线保留上游构建 commit

Jenkins 二次 checkout 改为浅拉优先并按需逐步加深

同步生产运维文档和团队排障记忆
2026-06-07 17:34:17 +08:00
78791af424 修正跳一跳排行榜展示名
新增排行榜 displayName 契约并在 api-server 出口补齐展示名

调整跳一跳结果页和运行态排行榜只显示 displayName

补充禁止展示 user_id 的前后端回归测试

更新跳一跳 PRD、后端契约文档和 Hermes 决策记录
2026-06-07 16:27:14 +08:00
8dca8a6443 fix: 稳定推荐页运行态封面遮罩 2026-06-07 16:04:34 +08:00
c810e255a5 修复拼图文字直创过早完成
修正拼图文字直创 compile 回包未出图时继续保持生成中

补充文字直创无正式图的回归测试

更新玩法链路文档和 Hermes 踩坑记录
2026-06-07 15:55:24 +08:00
3965f34b02 修复统一创作页软键盘黑底
让统一创作页根容器保留平台背景并限制滚动穿透

让统一创作页内容区占满剩余高度,避免短表单露出宿主黑底

补充统一创作页回归测试、玩法链路文档和 Hermes 踩坑记录
2026-06-07 15:40:07 +08:00
63444d047f 补齐拼消消作品可见性管理
接入后台作品可见性列表中的拼消消源类型

支持后台切换拼消消已发布作品 visible 状态

补充后台前端玩法标签与公开 ReadModel 文档说明
2026-06-07 15:08:32 +08:00
8131894bb5 fix: tighten public work type routing 2026-06-07 14:53:03 +08:00
8f460feb41 fix: route recommend puzzle next through feed 2026-06-07 14:15:45 +08:00
e56a25243c 新增提交信息格式约束
更新 AGENTS.md 中的提交标题中文化和逐行变更说明要求
同步 Hermes 团队协作规范中的提交信息格式
2026-06-07 13:59:14 +08:00
9e1549151d feat: add recommendation feed scoring 2026-06-07 13:58:24 +08:00
c344daba19 Merge pull request 'codex/tiaoyitiao' (#58) from codex/tiaoyitiao into master
Reviewed-on: #58
Reviewed-by: kdletters <kdletters@qq.com>
2026-06-07 00:58:02 +08:00
f5cefc8d5f Merge branch 'master' into codex/tiaoyitiao 2026-06-07 00:57:38 +08:00
48ef19d518 fix: reconcile architecture adjustment merge 2026-06-07 00:57:23 +08:00
kdletters
e3ecac85f3 fix: refine mini program nickname collection flow 2026-06-07 00:42:05 +08:00
ce930ee5c3 Merge codex/sse-stream-architecture into architecture adjustment 2026-06-07 00:23:42 +08:00
79d0d7a305 Merge branch 'codex/wechat-nickname' 2026-06-06 23:59:30 +08:00
b74440373f feat(auth): 小程序登录采集微信昵称 2026-06-06 23:59:15 +08:00
d0be3f36aa fix: 按契约显示统一创作表头 2026-06-06 23:51:53 +08:00
kdletters
ff7a2f6284 fix: delay wooden fish audio upload 2026-06-06 22:56:53 +08:00
d5b51a4242 Merge branch 'codex/platform-creation-flow-polish'
# Conflicts:
#	docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
#	src/components/rpg-entry/RpgEntryHomeView.tsx
2026-06-06 22:49:48 +08:00
18908609fc feat: 统一创作页表头跟随后台入口配置 2026-06-06 22:49:38 +08:00
50e335ba47 fix: polish platform creation flow interactions 2026-06-06 21:36:38 +08:00
95f17cd920 test: align jump hop summary fixture 2026-06-06 21:06:37 +08:00
dfa59aaf31 Merge remote-tracking branch 'origin/master' into codex/tiaoyitiao
# Conflicts:
#	server-rs/crates/api-server/src/jump_hop.rs
#	server-rs/crates/api-server/src/modules/jump_hop.rs
2026-06-06 21:04:46 +08:00
683a9115b3 fix: refine jump hop draft detail flow 2026-06-06 21:02:11 +08:00
caa65bf15f Merge pull request '修复拼消消素材生成与切图质量问题' (#56) from codex/puzzle-clear-template-runtime-fixes into master
Reviewed-on: #56
2026-06-06 20:10:47 +08:00
kdletters
b2543ba8a2 Merge branch 'master' into codex/puzzle-clear-template-runtime-fixes 2026-06-06 20:01:52 +08:00
ceb1e4b505 Merge pull request 'Update spacetime-client bindings and frontend' (#57) from dev-jenken into master
Reviewed-on: #57
2026-06-06 14:21:33 +08:00
2db3a6e185 Merge remote-tracking branch 'origin/master' into dev-jenken
# Conflicts:
#	.hermes/shared-memory/pitfalls.md
#	server-rs/crates/api-server/src/modules/jump_hop.rs
#	src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
#	src/services/jump-hop/jumpHopClient.test.ts
2026-06-05 23:59:40 +08:00
7e6ed91149 Merge pull request 'feat(jump-hop): 优化跳一跳素材生成与背景底图' (#55) from codex/tiaoyitiao into master
Reviewed-on: #55
2026-06-05 23:47:31 +08:00
kdletters
f4a8cc80c2 Merge remote-tracking branch 'origin/master' into codex/tiaoyitiao 2026-06-05 23:45:17 +08:00
fb3eede781 Merge branch 'master' into dev-jenken 2026-06-05 23:44:57 +08:00
601c6772b7 Merge branch 'dev-jenken' of https://git.genarrative.world/GenarrativeAI/Genarrative into dev-jenken 2026-06-05 23:44:36 +08:00
7140ac72b5 1 2026-06-05 23:41:24 +08:00
kdletters
36969726b4 fix(jump-hop): preserve themed runtime metadata 2026-06-05 23:40:12 +08:00
3849c3ccbe Merge remote-tracking branch 'origin/master' into codex/pr57-p0-p2
# Conflicts:
#	docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
#	docs/【项目基线】当前产品与工程约束-2026-05-15.md
2026-06-05 23:25:15 +08:00
kdletters
8e1a62d130 fix: decouple puzzle draft generation lifecycle 2026-06-05 23:22:30 +08:00
cd8088d1fd feat: polish jump hop themed runtime assets 2026-06-05 22:55:40 +08:00
60709395d0 Merge branch 'codex/fix-draft-retry-reuses-session' 2026-06-05 22:53:14 +08:00
dcbf02bbda fix: 失败草稿重试复用原会话 2026-06-05 22:49:07 +08:00
a215852381 Merge remote-tracking branch 'origin/master' into codex/tiaoyitiao 2026-06-05 22:46:57 +08:00
c98c3de96d 123 2026-06-05 22:11:12 +08:00
kdletters
ed6a59e641 fix: send VectorEngine edit images as file parts 2026-06-05 21:19:46 +08:00
6a03575d68 fix: 保留小程序登录上下文 2026-06-05 20:59:36 +08:00
kdletters
5a6d69bebe fix: provision OpenSSL 3.2 runtime 2026-06-05 19:45:23 +08:00
adfc421c9e Merge branch 'master' into codex/sse-stream-architecture
# Conflicts:
#	.hermes/shared-memory/decision-log.md
#	docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
2026-06-05 17:21:24 +08:00
kdletters
5150925947 fix: send VectorEngine images with libcurl 2026-06-05 17:03:49 +08:00
d489488ca2 fix: 稳定推荐页拼图下一关体验 2026-06-05 16:19:35 +08:00
524ad430ab fix(ci): simplify server provision pipeline 2026-06-05 16:14:30 +08:00
89f596ea64 fix(jump-hop): extend creation timeout 2026-06-05 14:51:22 +08:00
0edcb1b9f1 fix(jump-hop): clean magenta cutout fringes 2026-06-05 13:23:58 +08:00
e5592304a5 feat: add graceful api shutdown readiness 2026-06-05 11:44:27 +08:00
kdletters
9ab353926e fix: retry VectorEngine image send timeouts 2026-06-05 11:38:02 +08:00
0b07161034 chore: add structured OSS logs 2026-06-05 11:10:50 +08:00
kdletters
cb08c9ad20 fix(jump-hop): isolate draft runs from public leaderboard 2026-06-05 01:19:49 +08:00
kdletters
fdafe8d747 fix: allow masked phone author display 2026-06-05 00:51:43 +08:00
kdletters
2a271876ac fix: 发布拼消消后进入作品详情 2026-06-05 00:45:40 +08:00
1b39c0c5d7 Merge branch 'master' into codex/tiaoyitiao 2026-06-05 00:01:50 +08:00
kdletters
b19a3e4231 fix: switch miniprogram channel by env version 2026-06-04 23:52:59 +08:00
kdletters
dd5861d5f5 fix: align miniprogram web-view auth and share 2026-06-04 23:23:07 +08:00
27b30f974b Update spacetime-client bindings and frontend
Large update across server and web clients: regenerated/added many spacetime-client module bindings and input types (including new delete/work_delete input types and numerous procedure/reducer files), updates to server-rs API modules (bark_battle, jump_hop, wooden_fish, auth, module-runtime and shared contracts), and fixes in module-runtime behavior and domain logic. Frontend changes include new/updated components and tests (creative audio helpers, bark-battle/jump-hop/wooden-fish clients and views, unified generation pages, RPG entry views, and runtime shells), plus CSS and service updates. Documentation and operational notes updated (.hermes pitfalls and multiple PRD/docs) to cover daily-task refresh, banner asset fallback, recommend-key bug, and other platform behaviors. Tests and verification steps added/updated alongside these changes.
2026-06-04 22:44:19 +08:00
0041b95f72 feat(jump-hop): optimize generated assets and runtime background 2026-06-04 22:34:19 +08:00
0c7fc0b26f fix: 提升拼消消素材生成质量门禁 2026-06-04 22:32:46 +08:00
b60382a752 fix: 修正入口熔断与跳一跳草稿判定 2026-06-04 17:43:56 +08:00
c442c3c3f0 Merge remote-tracking branch 'origin/codex/tiaoyitiao' into codex/tiaoyitiao
# Conflicts:
#	docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md
#	server-rs/crates/spacetime-client/src/jump_hop.rs
#	server-rs/crates/spacetime-client/src/mapper/runtime.rs
#	server-rs/crates/spacetime-client/src/wooden_fish.rs
#	src/components/jump-hop-result/JumpHopResultView.test.tsx
#	src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
#	src/components/unified-creation/workspaces/JumpHopCreationWorkspace.tsx
#	src/components/unified-creation/workspaces/JumpHopWorkspace.test.tsx
2026-06-04 11:34:31 +08:00
b9de2f2a43 Merge branch 'master' into codex/puzzle-clear-template-runtime-fixes
# Conflicts:
#	.hermes/shared-memory/decision-log.md
#	.hermes/shared-memory/project-overview.md
#	docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
#	scripts/dev.test.ts
#	server-rs/crates/api-server/src/creation_entry_config.rs
#	server-rs/crates/api-server/src/wooden_fish.rs
#	server-rs/crates/module-auth/src/lib.rs
#	server-rs/crates/spacetime-client/src/wooden_fish.rs
#	server-rs/crates/spacetime-module/src/auth/procedures.rs
#	src/components/custom-world-home/creationWorkShelf.ts
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/rpgEntryWorldPresentation.ts
#	src/services/miniGameDraftGenerationProgress.test.ts
#	src/services/miniGameDraftGenerationProgress.ts
2026-06-04 11:24:14 +08:00
bbb9269bab refactor: 补齐草稿与SSE收口 2026-06-04 06:26:09 +08:00
c93b8fb570 refactor: 收口剩余草稿打开 intent 2026-06-04 06:12:26 +08:00
e570d50e9f refactor: 收口公开码搜索映射 2026-06-04 06:04:55 +08:00
217cc881e6 refactor: 收口草稿打开状态规则 2026-06-04 05:51:09 +08:00
b037ce1e32 refactor: 收口小玩法生成 action payload 2026-06-04 05:41:44 +08:00
991efb2eed refactor: 收口作品架更新回填规则 2026-06-04 05:31:40 +08:00
4069fd5859 refactor: 收口拼图 runtime 状态合并 2026-06-04 05:24:16 +08:00
d44560f330 refactor: 收口拼图作品更新 payload 2026-06-04 05:19:23 +08:00
9c96535073 refactor: 收口 Bark Battle 草稿恢复映射 2026-06-04 05:14:23 +08:00
20a21ee78b refactor: 收口视觉小说详情 session 映射 2026-06-04 05:09:49 +08:00
0dc326b79e refactor: 收口方洞 session profile 映射 2026-06-04 05:03:15 +08:00
df5e20d550 refactor: 收口 Match3D 生成资产进度 2026-06-04 04:56:21 +08:00
05713e1d3b refactor: 收口推荐 runtime 自动启动 2026-06-04 04:44:22 +08:00
4e23995347 refactor: 收口推荐 runtime 鉴权计划 2026-06-04 04:38:01 +08:00
f9f22e5663 refactor: 收口 Bark Battle work cache 规则 2026-06-04 04:30:36 +08:00
df17f51edf refactor: 收口公开作品流聚合 2026-06-04 04:18:16 +08:00
c31676a0e1 refactor: 收口拼图表单草稿判定 2026-06-04 04:03:11 +08:00
bced46ad92 refactor: 收口平台钱包余额 delta 2026-06-04 03:57:51 +08:00
4b4af11dbc fix: 收紧拼图发布资产门槛 2026-06-04 03:50:09 +08:00
46a36222cb fix: 收紧拼图草稿恢复判定 2026-06-04 03:34:06 +08:00
cd959b4095 refactor: 收口小游戏草稿 payload 2026-06-04 03:25:28 +08:00
5114a230ae refactor: 收口小游戏生成状态模型 2026-06-04 03:16:32 +08:00
23314e62aa refactor: 收口 RPG 结果预览门禁 2026-06-04 03:09:13 +08:00
671f5da86a refactor: 收口小游戏会话映射 2026-06-04 02:59:42 +08:00
5dd73186b0 refactor: 收口生成进度 tick 判定 2026-06-04 02:49:23 +08:00
80dab35646 refactor: 收口玩过作品打开意图 2026-06-04 02:42:43 +08:00
4e8cac3856 refactor: 收口公开码搜索计划 2026-06-04 02:32:08 +08:00
83ae363670 refactor: 收口创作入口启动意图 2026-06-04 02:20:48 +08:00
5ba5ca6bf8 refactor: 收口缺失创作状态回退 2026-06-04 02:12:52 +08:00
e6e0f93102 refactor: 收口创作恢复身份匹配 2026-06-04 02:04:53 +08:00
a504da1e32 refactor: 收口创作直达恢复目标 2026-06-04 01:57:13 +08:00
dbc00be2cc refactor: 收口平台阶段失权判定 2026-06-04 01:49:12 +08:00
0d2d391cb2 refactor: 收口创作直达恢复判定 2026-06-04 01:43:31 +08:00
75593b8860 refactor: 收口作品架删除确认模型 2026-06-04 01:35:11 +08:00
kdletters
2678954627 fix: log VectorEngine image edit request params 2026-06-04 01:22:28 +08:00
7301043afb refactor: 收口推荐运行态就绪判定 2026-06-04 01:19:23 +08:00
8d3e14020f refactor: 收口推荐运行态启动意图 2026-06-04 01:11:27 +08:00
7349c6df4f refactor: 收口公开详情编辑意图 2026-06-04 01:00:09 +08:00
e1134cc9ec refactor: 收口公开详情启动意图 2026-06-04 00:46:58 +08:00
37a35daddb refactor: 收口公开详情改造意图 2026-06-04 00:32:10 +08:00
872d741fdc refactor: 收口公开详情点赞意图 2026-06-04 00:27:34 +08:00
8c54d40b9c refactor: 收口公开详情封面解锁规则 2026-06-04 00:21:57 +08:00
39522f3b96 refactor: 收口公开作品详情映射 2026-06-04 00:17:31 +08:00
dd52848e9c refactor: 深化公开作品详情状态策略 2026-06-03 22:38:26 +08:00
00820e6571 refactor: 收口公开作品详情策略 2026-06-03 22:22:13 +08:00
7d2d67a3f5 feat(jump-hop): redesign sling platform gameplay 2026-06-03 22:21:00 +08:00
1b5e098225 feat: add puzzle clear template runtime 2026-06-03 22:11:46 +08:00
caac418e0e refactor: 收口平台弹窗状态模型 2026-06-03 22:00:36 +08:00
3efbb6882c refactor: 收口创作作品架Hub接口 2026-06-03 21:36:06 +08:00
ef236fc3a7 fix: 屏蔽创作入口关闭弹窗 2026-06-03 21:24:58 +08:00
c238ef9b40 refactor: 收口推荐滑动卡模型 2026-06-03 21:11:13 +08:00
30ead590e2 refactor: 收口创作恢复URL模型 2026-06-03 20:46:39 +08:00
deadce9cf1 fix: 收窄创作入口关闭熔断范围 2026-06-03 20:46:08 +08:00
01a68bcf3d fix: route creation wallet chip to top-up entry 2026-06-03 20:46:08 +08:00
fe2f8a66e6 refactor: 收口草稿生成作品架模型 2026-06-03 20:11:25 +08:00
69167da8d0 refactor: 收口抓大鹅运行资料模型 2026-06-03 19:21:07 +08:00
0b71b79e7a refactor: 收口公开作者展示模型 2026-06-03 19:05:00 +08:00
kdletters
84e5740e6d chore: switch miniprogram config to production 2026-06-03 19:01:17 +08:00
a0efb14e84 refactor: 收口分类配置模型 2026-06-03 18:50:32 +08:00
685560ec07 refactor: 收口排行配置模型 2026-06-03 18:37:34 +08:00
5fecceef4f refactor: 收口公开作品展示格式 2026-06-03 18:18:01 +08:00
f67f57b415 refactor: 收口个人资金展示模型 2026-06-03 18:06:31 +08:00
d67abecc9e refactor: 收口推荐流展示模型 2026-06-03 17:48:47 +08:00
a178942033 refactor: 收口个人数据展示模型 2026-06-03 17:30:28 +08:00
4f59a0e791 refactor: 迁移视觉小说与木鱼 runtime 请求骨架 2026-06-03 17:08:38 +08:00
06fabd3eab refactor: 收口每日任务 ViewModel 2026-06-03 16:49:54 +08:00
3783f0d2af refactor: 迁移拼图与跳跃 runtime 请求骨架 2026-06-03 16:42:18 +08:00
ab49c32e33 refactor: 迁移大鱼与汪汪 runtime 请求骨架 2026-06-03 16:35:01 +08:00
e9534baace refactor: 收口公开作品 ViewModel 2026-06-03 16:23:11 +08:00
4a185ac8c2 refactor: 收口 runtime client 请求骨架 2026-06-03 15:58:59 +08:00
5783bfeea6 refactor: 收口作品架 Source Adapter registry 2026-06-03 15:47:26 +08:00
cf0840d9e9 refactor: 深化前端入口作品流与作品架模块 2026-06-03 15:34:52 +08:00
1eeb14c50f refactor: 收口前端 SSE 传输层 2026-06-03 14:57:02 +08:00
545ffa4b2c fix: hide public card account identifiers 2026-06-03 12:09:03 +08:00
3f742fbaca feat: unify creation entry templates 2026-06-03 10:29:44 +08:00
b0865cfa19 Merge branch 'codex/feature-1'
# Conflicts:
#	docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
#	src/services/miniGameDraftGenerationProgress.ts
2026-06-03 03:56:25 +08:00
70ff18ad90 feat: 支持创作入口公告配置 2026-06-03 03:31:45 +08:00
08577b66c5 fix: stabilize puzzle vector engine asset generation 2026-06-03 02:40:07 +08:00
67ba40c678 Refine play type integration flow and docs 2026-06-03 01:25:53 +08:00
kdletters
dbe4c902b4 fix: normalize web release asset permissions 2026-06-02 11:51:37 +08:00
kdletters
2fdeb34567 fix: lock recharge flow until virtual payment settles 2026-06-02 01:47:54 +08:00
1cb11bc1dd fix: refine profile shortcuts and puzzle next button 2026-06-01 16:45:46 +00:00
kdletters
fae4db6a09 fix: handle wechat message url verification 2026-06-01 22:52:51 +08:00
kdletters
fc4b04a812 fix: allow wechat message aes key trailing bits 2026-06-01 22:20:29 +08:00
5354268529 fix web deploy artifact source 2026-06-01 13:13:35 +00:00
Codex
4796cff56f fix deploy labels 2026-06-01 12:16:35 +00:00
8713a5119b fix: isolate Jenkins sccache by job 2026-06-01 09:43:52 +00:00
kdletters
3ffa9e0bb1 Merge remote-tracking branch 'origin/codex/unified-creation-flow-phase1'
# Conflicts:
#	server-rs/crates/api-server/src/wooden_fish.rs
2026-06-01 15:22:58 +08:00
751c742b0f chore: move jenkins caches under home 2026-06-01 06:30:49 +00:00
6cd554473b chore: unify production pipelines on linux 2026-06-01 06:05:19 +00:00
kdletters
42a9f3cce1 修改小程序配置 2026-05-31 23:08:32 +08:00
kdletters
dd2f301771 merge origin/master into codex/wechat-mini-program-virtual-payment 2026-05-31 23:00:43 +08:00
kdletters
3db956ec81 feat: add wechat mini program virtual payment 2026-05-31 22:44:22 +08:00
kdletters
78448d2a7b merge master into codex/wechat-mini-program-virtual-payment 2026-05-30 16:46:11 +08:00
kdletters
aaaba77c3a fix wechat virtual payment coin flow 2026-05-30 16:42:25 +08:00
kdletters
e941ac4539 fix wechat virtual payment sandbox app key 2026-05-30 15:43:07 +08:00
kdletters
c02dcd8aaf fix wechat virtual payment signature 2026-05-30 15:15:53 +08:00
kdletters
0edfc21a46 Merge remote-tracking branch 'origin/master' into codex/wechat-mini-program-virtual-payment
# Conflicts:
#	.hermes/shared-memory/decision-log.md
2026-05-28 00:43:00 +08:00
kdletters
9c6fa10301 fix wechat mini program virtual payment flow 2026-05-28 00:41:06 +08:00
kdletters
b43c3cd823 Merge remote-tracking branch 'origin/master' into codex/wechat-mini-program-virtual-payment
# Conflicts:
#	.hermes/shared-memory/decision-log.md
2026-05-27 20:35:32 +08:00
kdletters
f36b90ebdb feat: switch mini program recharge to virtual payment 2026-05-26 22:32:16 +08:00
569 changed files with 77815 additions and 14753 deletions

View File

@@ -4,7 +4,7 @@ name = "Genarrative"
[setup]
script = '''
cp "$CODEX_SOURCE_TREE_PATH\.env.secrets.local" "$CODEX_WORKTREE_PATH\.env.secrets.local"
cp "$CODEX_SOURCE_TREE_PATH/.env.secrets.local" "$CODEX_WORKTREE_PATH/.env.secrets.local"
npm install
npm run codegraph:init
npm run codegraph:index

View File

@@ -109,6 +109,11 @@ WECHAT_MOCK_USER_ID="wx-mock-user"
WECHAT_MOCK_UNION_ID="wx-mock-union"
WECHAT_MOCK_DISPLAY_NAME="微信旅人"
WECHAT_MOCK_AVATAR_URL=""
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=""
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=""
WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED="true"
WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID="m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU"
WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE="formal"
# Model name for chat completions.
VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715"

View File

@@ -16,6 +16,290 @@
---
## 2026-06-08 微信能力按领域收口
- 背景:微信登录、订阅消息、普通微信支付和小程序虚拟支付能力曾分散在 `api-server` 根模块、`platform-auth``platform-wechat`,支付协议细节和业务 handler 边界不够清晰。
- 决策:`api-server` 内微信相关 HTTP/BFF 适配统一收在 `server-rs/crates/api-server/src/wechat.rs``wechat/*``platform-wechat` 负责微信订阅消息、微信支付 V3、虚拟支付消息推送的协议 client、header、签名、验签、解密、mock 和 payload 解析;`api-server::wechat` 只负责 AppConfig 映射、Axum handler、用户 / 订单 / 钱包 / SSE / 错误 envelope 编排。微信 OAuth / 小程序登录 provider 暂继续在 `platform-auth`,通过 `api-server::wechat::provider` 作为组合根 adapter 接入。
- 影响范围:`server-rs/crates/api-server/src/wechat.rs``server-rs/crates/api-server/src/wechat/*``server-rs/crates/platform-wechat/src/*`、微信支付 / 订阅消息 / 小程序消息推送文档。
- 验证方式:执行 `cargo check --manifest-path server-rs/Cargo.toml -p platform-wechat``cargo check --manifest-path server-rs/Cargo.toml -p api-server`、微信相关定向测试和编码检查;新增微信协议细节优先落到 `platform-wechat`
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【技术方案】微信虚拟支付接入-2026-05-26.md`
## 2026-06-07 推荐页运行态先封面预载再 ready 渐隐
- 背景:移动端推荐页上下切换公开作品时,如果运行态和封面资源没有明确准备边界,用户会看到未加载完成的 runtime、黑底闪动或切卡后反向回弹。
- 决策:推荐页拿到推荐作品列表后预加载每个作品的卡片封面、主封面和玩法兜底封面;嵌入 runtime 的启动遮罩必须复用带玩法标签和标题的作品卡面视觉不能再切到一层单独的纯封面图。作品切换后遮罩接手当前卡面时必须瞬时显示不允许从旧预览卡面再淡入到同一张卡面runtime 统一通过 ready 门控等待 run / profile、lazy 组件和 runtime DOM 内图片资源准备完成ready 返回 true 后再由外层露出游戏画面并只让卡面遮罩渐隐。遮罩层级必须隔离下层 runtime防止高 z-index HUD、canvas 或子运行态穿透到封面上ready 前保留无说明文案的加载条 / 动效,不展示“加载中”文案。推荐 rail 切换完成后归零不能走反向过渡动画。
- 影响范围:`src/components/rpg-entry/RpgEntryHomeView.tsx`、推荐页 runtime 生命周期、平台玩法链路文档。
- 验证方式:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-07 登录态身份边界变更后刷新当前页
- 背景:推荐页运行态、作品架、个人数据和私有 query 都可能在页面内缓存当前身份;如果登录或退出只改 React 上下文,当前页可能继续拿旧身份的局部状态渲染。
- 决策H5 登录态从未登录变为已登录,或从已登录变为未登录后,前端必须刷新当前页面一次,让平台壳和运行态按新身份重新初始化。普通 access token refresh、账号资料更新、主题或音量设置变化不触发整页刷新。
- 影响范围:`src/components/auth/AuthGate.tsx`、平台入口身份初始化、项目基线文档。
- 验证方式:`npm run test -- src/components/auth/AuthGate.test.tsx`
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`
## 2026-06-07 多端登录以 refresh session 为粒度互不顶号
- 背景:同一账号在多端登录后,若单设备退出或请求被打到尚未见过该 session 的 api-server 进程,旧设备会被误判为登录态失效。
- 决策:普通登录只新增当前设备 refresh session不撤销其它 active session`POST /api/auth/logout` 只撤销当前 refresh session不再提升账号级 `token_version``POST /api/auth/logout-all`、改密和重置密码继续吊销全端 session 并提升 `token_version`。api-server 鉴权和 refresh cookie 轮换在本进程工作集未命中 session 时,先从 SpacetimeDB 正式认证表按需刷新一次工作集再复查,支持多实例和滚动重启下的新会话被所有进程识别。
- 影响范围:`module-auth` refresh session 语义、`api-server` Bearer 鉴权和 `/api/auth/refresh`、账号安全页多端会话。
- 验证方式:`cargo test -p module-auth logout_current_session --manifest-path server-rs/Cargo.toml``cargo test -p module-auth refresh_from_snapshot_json_merges_session_created_by_another_process --manifest-path server-rs/Cargo.toml``cargo test -p api-server logout_current_device_keeps_other_device_session_alive --manifest-path server-rs/Cargo.toml`
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-06-07 跳一跳排行榜展示名禁止泄露内部身份键
- 背景:跳一跳排行榜曾在结果页和运行态失败弹窗里直接展示 `playerId` / `user_id`,用户可见内容暴露了内部身份键。
- 决策:`jump_hop_leaderboard_entry.player_id` 只作为 SpacetimeDB read model 的去重和 `viewerBest` 匹配字段HTTP 契约新增并强制使用 `displayName` 作为排行榜展示字段。api-server 出口按账号 `displayName` 补齐展示名;匿名 runtime guest 固定展示“游客玩家”;账号失效或不可解析时展示“失效玩家”;前端排行榜 UI 禁止兜底展示 `playerId` / `user_id`
- 影响范围:`packages/shared/src/contracts/jumpHop.ts``server-rs/crates/shared-contracts/src/jump_hop.rs``server-rs/crates/api-server/src/jump_hop.rs`、跳一跳结果页和运行态排行榜组件、跳一跳 PRD 与后端契约文档。
- 验证方式:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx -t "排行榜"``npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx -t "排行榜"``cargo test -p api-server jump_hop_leaderboard_display_name_never_falls_back_to_player_id --manifest-path server-rs/Cargo.toml`
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-06-07 generated 图片读取坚持 OSS 源站与签名缓存链路
- 背景:生成图片如果以完整 OSS 私有 bucket URL 进入前端,浏览器会裸连 OSS 并遇到 403 或绕过现有 `/api/assets/read-url` 签名缓存;同时旧对象缺少 `Cache-Control` 时只能走 `ETag` / `Last-Modified` 协商缓存,容易被误解为需要 api-server 本地磁盘缓存。
- 决策OSS 继续作为 generated 私有资产源站api-server 只签发短期读 URL不做本地磁盘静态资源兜底。前端收到同 bucket 的 `https://*.oss-*.aliyuncs.com/generated-*` 地址时,必须先归一为 legacy public path再复用 `/api/assets/read-url` 和本地 signed URL 缓存。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`,缓存职责交给 OSS 对象头、浏览器 / WebView HTTP 缓存和后续 CDN。
- 影响范围:`src/services/assetReadUrlService.ts``server-rs/crates/platform-oss``shared-contracts` direct upload form fields、`api-server` assets DTO 映射、后端契约文档和开发运维排障口径。
- 验证方式:完整 OSS generated URL 应触发 `/api/assets/read-url?legacyPublicPath=...`,同一路径、同一 `refreshKey` 版本且未临近过期时复用本地 signed URL`platform-oss``PostObject` policy / form fields 和 `PutObject` 请求头都应包含 immutable `Cache-Control`,且 `PutObject` V4 签名的 `AdditionalHeaders` 包含该普通请求头。
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md``server-rs/crates/platform-oss/README.md`
## 2026-06-06 小程序微信绑定展示使用原生昵称组件
- 背景:账号信息面板需要显示“绑定的是哪个微信号”。微信小程序登录 `jscode2session` 不返回昵称或个人微信号,但小程序提供 `input type="nickname"` 原生昵称填写 / 选择能力,可在登录前收集微信昵称用于展示。
- 决策:小程序登录页先展示原生 `input type="nickname"`,将昵称作为 `displayName``/api/auth/wechat/miniprogram-login` 提交;若还需要绑定手机号,再随 `/api/auth/wechat/bind-phone` 一并提交。`wechatDisplayName` 只能来自微信平台 profile、历史已保存的微信身份资料或小程序原生昵称组件不能用系统账号显示名或“微信旅人”兜底。小程序侧拿不到昵称时前端使用后端下发的 `wechatAccount`openid / provider_uid尾号展示避免只显示裸“已绑定”。
- 影响范围:`platform-auth` 小程序登录 profile、`module-auth` 微信身份持久化、`api-server` 小程序登录 / 绑定响应、账号信息面板、项目基线和后端契约文档。
- 验证方式:`npm run test -- src/components/auth/AccountModal.test.tsx``cargo test -p platform-auth --manifest-path server-rs/Cargo.toml``cargo test -p module-auth --manifest-path server-rs/Cargo.toml``cargo test -p api-server --manifest-path server-rs/Cargo.toml wechat_miniprogram``npm run typecheck``npm run check:encoding`
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-06-03 拼消消收敛为单关 6x6 与 4-sheet 素材策略
- 背景:最初 4 关 / 135 次消除 / 单张大 atlas 方案生图数量和空间一致性成本过高,真实 image2 结果容易被布局提示词诱导成带文字、边框或编号的说明图,不适合运行态 1x1 切片。
- 决策:拼消消运行态收敛为单关 `6x6 / 35 次消除 / 600 秒`,直接解锁 `1x2``1x3``2x2``2x3`;素材生成改为 4 张 `1024x1536` 竖版 sheet每张按 `4x6`、每格 `256x256` 切片,再由服务端合成 `10x10 / 2560x2560` 最终 atlas。形状配比固定为 `1x2=23``1x3=5``2x2=4``2x3=3`,总计 35 个复合图案组和 95 个 1x1 卡牌切片。
- 影响范围:`module-puzzle-clear` 关卡与图案组规划、api-server 拼消消素材生成编排、前端草稿试玩本地 runtime、结果页 atlas 预览、拼消消 PRD / 技术方案 / 平台链路文档。
- 验证方式:`cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml``cargo test -p api-server puzzle_clear --manifest-path server-rs/Cargo.toml -- --nocapture``npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts``npm run test -- src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`
- 关联文档:`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md``docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-05-30 拼消消按独立玩法公开闭环接入
- 背景:拼消消以拼图交换手感为基础,但核心规则从“拼完整单图过关”变为“拼成多个复合图案组后逐个消除”,同时需要顶部补牌、防死局、半锁定局部拼接组和正式统计,不能继续复用拼图运行态规则本体。
- 决策:`puzzle-clear` 作为独立玩法域接入,公开作品码前缀固定为 `PC-`;创作链路采用表单 / 图片输入工作台 -> 独立生成页 -> 结果页 -> 试玩 -> 发布 -> 统一作品详情 -> 正式 runtime。领域规则落在 `module-puzzle-clear`SpacetimeDB 新增 `puzzle_clear_*` 表 / procedure / view并接入统一 `public_work_gallery_entry` / `public_work_detail_entry`;前端只表现后端 snapshot/action 结果,不把胜负、补牌或消除裁决做成前端事实源。
- 补充约束:草稿编译和发布都必须拒绝缺失或 `placeholder` atlas / card assets不允许后端 facade 或 SpacetimeDB 合成临时素材;当前单关正式 runtime 终态事件使用 `run-finished``level-failed`,并写入包含 `status``level``clears``clearDelta``elapsedMs` 的结果 JSON。
- 补充约束:拼消消结果页草稿试玩使用前端本地 `runtimeMode=draft` snapshot不调用 `/api/runtime/puzzle-clear/runs`,不写正式 run 统计;公开详情和推荐流正式运行继续走后端 `/api/runtime/puzzle-clear/*`,客户端需要区分创作详情 `/api/creation/puzzle-clear/works/{profileId}` 与公开运行态详情 `/api/runtime/puzzle-clear/works/{profileId}`
- 影响范围:`CONTEXT.md`、拼消消 PRD / 技术方案、平台玩法链路文档、`shared-contracts` / `packages/shared``api-server``spacetime-module``spacetime-client`、作品架 / 广场 / 统一作品详情 / runtime 前端分流。
- 验证方式PRD 和技术方案必须覆盖资产槽位、素材工作表风险、切片验证、恢复语义、API 命名空间和验证命令;实现侧至少运行 `npm run spacetime:generate``npm run check:spacetime-schema``npm run check:spacetime-runtime-access``npm run check:server-rs-ddd``npm run typecheck``npm run check:encoding`、相关前端测试和 `cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml`
- 关联文档:`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md``docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-06-05 Server-Provision 全程在目标部署 agent 执行且不安装构建链
- 背景:`Genarrative-Server-Provision``DEPLOY_TARGET=development` 语义是部署到 dev 服务器,不是构建机 dry-run。旧流水线把 development 映射到 `linux && genarrative-build`,还先在 build 节点准备 `provision-tools/` 再 stash 给后续阶段,导致真实 dev 初始化可能跑到 Jenkins controller / build 节点;脚本还安装 clang / lld / pkg-config / OpenSSL headers / sccache 等构建链依赖,超出了服务器初始化职责。
- 决策Server-Provision 只做服务器初始化,全程运行在目标部署 agentdevelopment 使用 `linux && genarrative-dev-deploy`release 使用 `linux && genarrative-release-deploy``Prepare Provision Tools``Provision Server` 在同一个目标 agent workspace 顺序执行,不再切到 `linux && genarrative-build`,不再 `stash/unstash` 工具包。`scripts/jenkins-server-provision.sh` 不再安装 clang / lld / pkg-config / libssl-dev / sccache非 dry-run 仍要求目标 dev / release agent 具备 root 权限,因为 provision 会写 systemd、Nginx、`/etc` 和系统用户。Job 的 `Pipeline script from SCM` 与 Jenkinsfile 参数 `SOURCE_GIT_REMOTE_URL` 都必须使用本机路径或目标 agent 可访问的内网 Git 源,不允许公网 Git fallback。
- 影响范围:`jenkins/Jenkinsfile.production-server-provision``scripts/jenkins-server-provision.sh`、生产运维文档、Server-Provision 排障口径。
- 验证方式Jenkins 日志中 Server-Provision 的 `Prepare``Checkout Provision Files``Prepare Provision Tools``Provision Server` 都在目标 dev / release agent 上执行;日志不出现 `Running on Jenkins``linux && genarrative-build``stash 'server-provision-tools'``Git 主地址拉取失败...改用备用地址``https://git.genarrative.world/GenarrativeAI/Genarrative.git` 或构建依赖 / sccache 安装步骤;`bash -n scripts/jenkins-server-provision.sh` 和编码检查通过。
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-06-05 api-server 重启先摘流再排空并持久化 outbox
- 背景:生产部署重启 api-server 时,如果只用 `/healthz` 判断存活并直接停止进程,运行中的 HTTP 请求和本地 tracking outbox active 文件都可能被中断,容易造成用户请求失败或内存/本地缓冲数据延迟丢失。
- 决策:`/healthz` 只表示进程存活,发布和生产接流检查统一使用 `/readyz`。api-server 收到 `SIGINT` / `SIGTERM` 后先把 readiness 标记为不可用,再交给 Axum graceful shutdown 排空已有 HTTP 请求;退出前在 `GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS` 窗口内封存 active tracking outbox 并尽力 flush sealed 文件失败或超时则保留本地文件给下次启动重试。systemd 停机窗口统一放到 `TimeoutStopSec=90`
- 影响范围:`server-rs/crates/api-server``deploy/systemd/genarrative-api.service`、生产 API deploy 脚本、Jenkins API deploy 参数、Nginx 公网健康检查暴露策略、开发运维文档。
- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml readyz_reports_readiness_and_draining_state``cargo test -p api-server --manifest-path server-rs/Cargo.toml shutdown_flush_seals_active_file_for_later_retry``cargo check -p api-server --manifest-path server-rs/Cargo.toml`、部署脚本 `bash -n``/readyz` 本机 smoke。
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-06-05 OSS 平台适配器输出结构化日志
- 背景AI 生成资产、浏览器直传签名、私有读签名和对象确认都依赖 OSS如果 OSS 侧只有错误字符串,排查资产写入 / 确认失败时很难按操作、对象、状态码和耗时下钻。
- 决策:`server-rs/crates/platform-oss` 统一为 `sign_post_object``sign_get_object_url``head_object``put_object` 输出结构化日志。日志固定携带 `provider=aliyun-oss``operation``bucket``endpoint``object_key` / `key_prefix``access``content_type``content_length``status``status_class``error_kind``elapsed_ms` 等排障字段;禁止输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。
- 影响范围:`server-rs/crates/platform-oss``api-server` 资产签名 / 上传 / 确认链路、OTLP logs、本地 `logs/api-server/` 与运维排障文档。
- 验证方式:`cargo test -p platform-oss --manifest-path server-rs/Cargo.toml`;真实联调时按 `provider=aliyun-oss``operation` 过滤日志,确认只出现对象定位和状态字段,不出现签名材料。
- 关联文档:`server-rs/crates/platform-oss/README.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-06-05 跳一跳返回按钮改为独立主题资产
- 背景:跳一跳运行态曾把左上角返回按钮视觉锚点写进背景 image2 prompt导致返回按钮像静态背景元素不能替代真实可点击按钮。
- 决策:跳一跳背景 prompt 禁止生成任何 UI 或左上角图标;返回按钮由 `backButtonAsset` 单独生成 1:1 纯绿 key 图,后端去绿后作为透明 PNG 持久化到作品 profile运行态左上角真实按钮优先渲染该资产。顶部得分 HUD 复用拼图模板结构,包含陶泥儿 IP logo、标题牌和下挂数字卡。
- 影响范围:`packages/shared/src/contracts/jumpHop.ts``shared-contracts``spacetime-module` / `spacetime-client` bindings、`api-server` 跳一跳生成链路、`JumpHopRuntimeShell`、玩法链路文档和后端数据契约文档。
- 验证方式:`npm run spacetime:generate``cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml``npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx``npm run check:spacetime-schema`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-06-03 创作入口关闭不下架已发布作品
- 背景:`creation_entry_disabled` 曾由 api-server 按 runtime 路由前缀统一熔断,导致用户进入平台首页或启动已发布作品时也可能看到“创作入口已关闭”错误。
- 决策:入口配置的 `open=false` 只表示关闭新建创作入口,不表示下架已有草稿、私有作品或公开作品。后端熔断只拦新建创作、新建草稿、首次生成入口和 Remix 成草稿等会产生新创作的请求;公开广场、公开详情、点赞、已发布作品启动、运行态过程请求、存档 / 浏览记录和已有作品回读不因创作入口关闭而失败。前端平台首页遇到旧服务端返回的 `creation_entry_disabled` 只降级,不弹平台级错误弹窗;关闭态模板卡必须明显禁用并展示 `暂未开放`,不得继续显示泥点消耗。
- 影响范围:`server-rs/crates/api-server/src/creation_entry_config.rs``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、创作入口相关测试与玩法链路文档。
- 验证方式:关闭任一创作入口后,新建创作请求返回 `creation_entry_disabled`;公开作品列表 / 详情 / 启动 / 运行态动作不返回该错误进入平台首页不弹“平台首页creation_entry_disabled”关闭态入口卡显示锁定状态且不显示 `10-20泥点数`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-04 Draft Generation Shelf 剩余草稿打开 intent 收口
- 背景:拼图 / 抓大鹅草稿打开 intent 已归入 `platformDraftGenerationShelfModel.ts`,但方洞挑战、大鱼吃小鱼和视觉小说仍在平台壳层内联判断已发布详情、缺 session、active generating、当前结果页和普通草稿恢复。
- 决策:继续扩展 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,新增 `resolveSquareHoleDraftOpenIntent(...)``resolveBigFishDraftOpenIntent(...)``resolveVisualNovelDraftOpenIntent(...)`;平台壳只按 intent 执行 notice seen、详情打开、恢复 session、读取 work detail、清生成态和切 stage 副作用。
- 追加决策:跳一跳与敲木鱼草稿打开也归入同一 Draft Generation Shelf Model新增 `resolveJumpHopDraftOpenIntent(...)``resolveWoodenFishDraftOpenIntent(...)`;壳层只按 intent 执行已发布详情、失败生成页恢复、持久化 generating 恢复、读取 detail 和敲木鱼失败 fallback stage 副作用。
- 影响范围:创作中心作品架打开方洞挑战 / 大鱼吃小鱼 / 视觉小说 / 跳一跳 / 敲木鱼草稿、创作 URL 恢复时强制打开草稿、生成中回到生成页和视觉小说结果页恢复。
- 验证方式:`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对 Draft Shelf Module 与平台壳执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md`
## 2026-06-04 Platform Public Code Search matcher / DTO 收口
- 背景:`resolvePlatformPublicCodeSearchPlan(...)` 已收口公开搜索顺序,但 `PlatformEntryFlowShellImpl.tsx` 仍内联 RPG by-code DTO 构造,以及拼图、大鱼吃小鱼、跳一跳、敲木鱼、宝贝识物、抓大鹅、方洞挑战、视觉小说和汪汪声浪的 `isSame*PublicWorkCode` 匹配、公开可见性过滤与详情卡映射。
- 决策:扩展 `src/components/platform-entry/platformPublicCodeSearchModel.ts`,以 `mapRpgPublicCodeSearchDetailToGalleryCard(...)` 和各 `resolve*PublicCodeSearchMatch(...)` 收口 per-play 公开码匹配与 DTO 映射;壳层只保留 gallery 刷新、详情打开、Bark Battle runtime 特例、用户查询和错误归航副作用。`M3D-*` 旧抓大鹅前缀在 `isSameMatch3DPublicWorkCode(...)` 中继续匹配。
- 影响范围:平台首页搜索框、初始 `publicWorkCode` 恢复、各玩法公开作品号命中、RPG 公开作品 by-code 详情映射、Bark Battle runtime 内搜索启动。
- 验证方式:`npm run test -- src/components/platform-entry/platformPublicCodeSearchModel.test.ts src/services/publicWorkCode.test.ts`、针对搜索 Module / 壳层 / publicWorkCode 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-04 Draft Generation Shelf 草稿打开 intent 收口
- 背景:`openPuzzleDraft` / `openMatch3DDraft` 在平台壳内重复判断已发布作品、缺 session、ready 未读、失败 notice、active / background 生成中、持久化 generating 和普通草稿恢复,导致壳层继续理解拼图稳定 ID、抓大鹅 notice key 与生成状态优先级。
- 决策:扩展 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,以 `resolvePuzzleDraftOpenIntent(...)``resolveMatch3DDraftOpenIntent(...)` 返回纯打开计划和 notice keys壳层只按 intent 执行网络读取、生成态 rebase、试玩启动、错误写入、路由 / stage 和 notice seen 副作用。
- 影响范围:创作中心作品架打开拼图 / 抓大鹅草稿、公开码搜索强制打开抓大鹅草稿、生成完成后 ready 未读试玩、失败草稿恢复和后续 pending / persisted generating 判定。
- 验证方式:`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对 Draft Shelf Module 与平台壳执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md`
## 2026-06-04 Bark Battle Work Cache 草稿状态收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 仍内联维护 Bark Battle 草稿三图完整性、生成状态归一、作品架摘要恢复草稿配置,以及草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 字段映射,导致结果页试玩、作品架启动、草稿恢复和公开详情启动都要理解同一份资产字段清单。
- 决策:扩展 `src/components/platform-entry/barkBattleWorkCache.ts`,以 `hasBarkBattleDraftRequiredImages``resolveBarkBattleDraftGenerationStatus``buildBarkBattleDraftConfigFromWorkSummary``buildBarkBattlePublishedConfigFromDraft``buildBarkBattlePublishedConfigFromWork``buildBarkBattlePublishSnapshot``mergeBarkBattlePublishedConfigAssets` 收口 Bark Battle 纯规则。平台壳只保留 API、缓存刷新、React state、URL 和 stage 副作用。
- 影响范围Bark Battle 草稿生成完成、结果页保存、作品架摘要恢复草稿、草稿试玩、作品架 / 公开详情启动正式 runtime以及后续 Bark Battle 资产字段或 ruleset 默认值调整。
- 验证方式:`npm run test -- src/components/platform-entry/barkBattleWorkCache.test.ts`、针对 Bark Battle Work Cache Module 与平台壳执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md`
## 2026-06-04 Platform Recommend Runtime Auth Model 收口
- 背景:平台推荐 runtime 的 embedded 启动需要在匿名 Runtime Guest Token、已登录 background auth 和非 embedded 默认鉴权之间分流,拼图还额外维护 `isolated` / `default` runtime auth mode旧规则散在顶层 helper 与多个启动 callback。
- 决策:新增 `src/components/platform-entry/platformRecommendRuntimeAuthModel.ts`,以 `resolvePlatformRecommendRuntimeAuthPlan(input)``shouldUsePlatformRecommendRuntimeGuestAuth(input)` 收口纯鉴权计划。壳层仍负责读取 `getStoredAccessToken()`、申请 `ensureRuntimeGuestToken()`、拼装 request options 和写入拼图 runtime auth mode。
- 影响范围:推荐 Tab 内嵌 runtime 启动、拼图公开详情 isolated 入口、推荐运行态后续 action 的局部鉴权口径,以及后续新增可嵌入推荐 runtime 的玩法。
- 验证方式:`npm run test -- src/components/platform-entry/platformRecommendRuntimeAuthModel.test.ts`、针对新 Module 与平台壳执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md`
## 2026-06-04 Platform Recommend Runtime Auto Start 收口
- 背景:推荐 runtime 自动启动 effect 同时判断桌面断点、stage、Tab、loading、推荐列表、active entry、ready 状态和启动中状态,导致壳层 effect 依赖过长且混合推荐流状态机知识。
- 决策:扩展 `src/components/platform-entry/platformPublicGalleryFlow.ts`,新增 `resolvePlatformRecommendRuntimeAutoStartDecision(input)`,只返回 `noop``clear``start(entry)`。平台壳只执行清空 active runtime state 或调用 `selectRecommendRuntimeEntry(entry)`
- 影响范围:移动端首页推荐 runtime 自动启动、推荐列表为空时清空状态、active entry ready 判定,以及后续新增推荐 runtime 玩法的启动时机。
- 验证方式:`npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts`、针对 Flow Module 与平台壳执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformRecommendRuntimeAutoStart收口计划-2026-06-04.md`
## 2026-06-04 Platform Creation Launch Model 收口
- 背景:平台创作入口点击回调曾在 `PlatformEntryFlowShellImpl.tsx` 内联判断 `airp` 占位、隐藏的 `baby-object-match`、未知入口和各玩法工作台启动目标,壳层同时承接入口 ID 规则、启动前准备顺序和副作用。
- 决策:新增 `src/components/platform-entry/platformCreationLaunchModel.ts`,以 `resolvePlatformCreationLaunchIntent({ type, isBabyObjectMatchVisible })` 收口创作入口启动意图。`airp` 返回 `noop` 且不触发 `prepareCreationLaunch()`;隐藏 `baby-object-match` 返回 blocked intent 且仍在 prepare 后显示 `EDUTAINMENT_HIDDEN_MESSAGE`;未知入口保持旧语义,先 prepare 后 no-op已知入口返回稳定 launch target。壳层只执行 prepare、错误提示和 `runProtectedAction(...)`
- 影响范围:底部加号创作入口模板卡点击、入口可见性拦截、后续新增可启动模板的 launch target 接入。
- 验证方式:`npm run test -- src/components/platform-entry/platformCreationLaunchModel.test.ts`、针对新 Module 与壳层执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md`
## 2026-06-04 Platform Selection Stage Model 收口
- 背景:平台入口在受保护数据失效后会清空当前用户私有作品、草稿、运行态和生成状态,但哪些 `SelectionStage` 可保留、哪些必须回首页曾以内联长否定串散在 `PlatformEntryFlowShellImpl.tsx`
- 决策:新增 `src/components/platform-entry/platformSelectionStageModel.ts`,以 `resolveSelectionStageAfterProtectedDataLoss(stage)` 收口受保护数据失效后的 stage 去留判定。模型内部使用 `satisfies Record<SelectionStage, boolean>` 全量分类,新增 stage 时必须明确保留或回首页。壳层仍负责检测权限变化、清 state 和调用 `setSelectionStage`
- 追加决策:缺失草稿 / 作品 / run 时的阶段回退也归入 `platformSelectionStageModel.ts`,由 `resolveSelectionStageAfterMissingCreationState(params)` 统一判断 big-fish、match3d、square-hole、visual-novel 和 baby-object-match 的 result / runtime / gallery-detail 是否还能被当前状态支撑。壳层只汇总布尔事实并按输出 stage 跳转big-fish、match3d、square-hole 的草稿事实固定来自 `Boolean(session?.draft)`visual-novel 的 session draft 与 work draft 可独立支撑结果页baby-object-match runtime 缺 draft 时直接回首页。
- 影响范围:退出登录、鉴权上下文收回、平台入口公开页 / 工作台 / 结果页 / 生成页 / 运行态的阶段恢复规则,以及后续新增 `SelectionStage`
- 验证方式:`npm run test -- src/components/platform-entry/platformSelectionStageModel.test.ts`、针对新 Module 与壳层执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md`
## 2026-06-04 Creation Work Delete Flow 收口
- 背景:平台入口作品架删除入口在 RPG、拼图、抓大鹅、方洞挑战、大鱼吃小鱼、视觉小说和宝贝识物 handler 内重复计算确认标题、删除说明、草稿 notice key 与拼图派生稳定 ID导致删除确认规则散在巨型壳层。
- 决策:新增 `src/components/platform-entry/platformCreationWorkDeleteFlow.ts`,以 `resolvePlatformCreationWorkDeleteConfirmationModel(input)` 收口作品架删除确认纯模型;输出 `id/title/detail/noticeKeys``PlatformEntryFlowShellImpl.tsx` 仍作为副作用 Adapter保留删除 API、刷新作品架 / 公开广场、错误状态、`markDraftNoticeSeen` 和页面跳转。
- 影响范围:创作中心作品架删除确认弹窗、删除后生成 notice 清理、拼图稳定 result ID 清理、宝贝识物已发布删除说明,以及后续新增玩法作品架删除接入。
- 验证方式:`npm run test -- src/components/platform-entry/platformCreationWorkDeleteFlow.test.ts``npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对新 Module 与平台壳执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md`
## 2026-06-03 平台入口公开作品详情 Strategy 收口
- 背景:平台壳层直接判断公开作品详情入口的玩法类型、是否需要补读完整详情,以及自有作品按钮显示“编辑”还是“改造”,导致统一作品详情的纯决策散落在巨型 Implementation 内。
- 决策:新增 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`,以 `getPlatformPublicWorkDetailKind``resolvePlatformPublicWorkDetailOpenStrategy``resolvePlatformPublicWorkActionMode``resolvePlatformPublicWorkDetailOpenDecision``resolveActivePlatformPublicWorkAuthorEntry` 收口公开作品详情 Strategy。`PlatformEntryFlowShellImpl.tsx` 只按 Strategy 调用现有详情读取 / 直接展示 Adapter并保留作者请求竞态控制启动、点赞、remix 和编辑副作用不搬入 Module。
- 追加决策:公开详情 entry 映射与公开详情反推玩法 work 摘要也归入 `platformPublicWorkDetailFlow.ts`,包括 RPG、拼图、大鱼吃小鱼、方洞挑战、视觉小说、跳一跳、敲木鱼和汪汪声浪的通用映射。抓大鹅 `mapMatch3DWorkToPublicWorkDetail` 归入 `platformMatch3DRuntimeProfile.ts`,继续委托 `normalizeMatch3DWorkForRuntimeUi` 做素材归一和背景资产提升,避免把 Match3D 运行态规则复制到公开详情 Flow Module。
- 追加决策:拼图公开详情封面解锁数由 `resolveVisiblePuzzleDetailCoverCount(entry, run)` 收口;非拼图、无当前 run 或 run 不匹配当前公开详情时只展示首图,匹配当前公开详情时按 `clearedLevelCount + 1` 解锁且至少为 1。`PlatformWorkDetailView` 只接收 `visibleCoverCount` 展示,不读取 run。
- 追加决策:公开详情点赞能力矩阵由 `resolvePlatformPublicWorkLikeIntent(entry)` 收口Module 只返回大鱼吃小鱼、拼图、旧 RPG gallery fallback 或不可用文案壳层仍执行鉴权、API 调用、缓存同步、错误展示和 busy 状态。
- 追加决策:公开详情改造能力矩阵由 `resolvePlatformPublicWorkRemixIntent(entry)` 收口Module 只返回大鱼吃小鱼、拼图、旧 RPG gallery fallback 或不可用文案壳层仍执行鉴权、remix API、session / 缓存写入、stage 切换、错误展示和 busy 状态。
- 追加决策:公开详情启动分流由 `resolvePlatformPublicWorkStartIntent(entry, deps)` 收口Module 只返回大鱼吃小鱼、拼图、跳一跳、敲木鱼、抓大鹅、方洞挑战、视觉小说、汪汪声浪、宝贝识物或旧 RPG gallery 记录游玩的 intent。壳层仍执行登录保护、运行态启动、RPG 游玩记录、详情更新、busy 状态和错误展示;抓大鹅 public detail -> work mapper 作为 Adapter 注入,继续由 Match3D Runtime Profile Module 维护素材归一与背景资产提升。
- 追加决策:自有公开作品编辑分流由 `resolvePlatformPublicWorkEditIntent(entry, deps)` 收口Module 只返回可编辑草稿目标、需解析宝贝识物本地草稿 intent、旧 RPG gallery 编辑 intent 或原阻断文案。壳层仍执行登录保护、草稿恢复、宝贝识物异步草稿解析、RPG 编辑导航和错误展示;抓大鹅 public detail -> work mapper 仍作为 Adapter 注入,不复制 Match3D 素材归一规则。
- 影响范围:统一作品详情入口、公开详情打开策略、自有公开作品编辑 / 改造动作模式,以及后续新增玩法公开详情接入。
- 验证方式:`npm run test -- src/components/platform-entry/platformPublicWorkDetailFlow.test.ts``npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts`、公开详情壳层交互回归、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md`
## 2026-06-03 平台入口弹窗状态规则收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 曾同时持有平台级错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key、后台生成 still-running 识别和任务完成文案,导致壳层 Interface 偏浅,测试面不稳定。
- 决策:新增 `src/components/platform-entry/platformDialogStateModel.ts` 作为 Platform Dialog State Module统一导出 `normalizePlatformDialogMessage``formatPlatformDialogSource``resolvePlatformErrorDialog`、dismiss key builder、`resolveActivePlatformDialog``isBackgroundGenerationStillRunningMessage``PLATFORM_TASK_COMPLETION_MESSAGE`。平台壳只汇总候选、持有 React state并在关闭弹窗时作为 Adapter 清理对应副作用 setter。
- 影响范围:平台入口错误弹窗、任务完成弹窗、后台生成仍在处理识别、草稿生成完成 / 失败通知。
- 验证方式:`npm run test -- src/components/platform-entry/platformDialogStateModel.test.ts``npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx`、相关壳层交互测试、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md`
## 2026-06-03 前端 SSE 客户端传输层统一收口
- 背景:创作 Agent、创意互动 Agent、视觉小说运行态和微信充值订单状态等多个前端 client 曾各自手写 SSE 边界扫描、`TextDecoder` 解码、JSON 解析和流结束 flush导致 CRLF / LF、UTF-8 尾部、多行 `data:` 和提前停止释放 reader 的处理容易漂移。
- 决策:前端 SSE 传输层统一使用 `src/services/sseStream.ts``readSseStream` 负责事件边界、解码 flush、多行 data 和提前停止取消 reader`readSseJsonStream` 负责 JSON object 事件解析与异常 JSON 静默跳过。业务 client 只保留领域事件归一化、结果聚合和中文错误文案OpenAI 兼容文本流通过 `readSseStream` 处理 `[DONE]` 哨兵,后续不得复制 `findSseEventBoundary``parseSseEventBlock` 或手写 reader 循环。
- 影响范围:`src/services/sseStream.ts``src/services/aiService.ts``src/services/llmClient.ts``src/services/creation-agent/creationAgentSse.ts``src/services/creative-agent/creativeAgentSse.ts``src/services/visual-novel-runtime/visualNovelRuntimeSse.ts``src/services/rpg-entry/rpgProfileClient.ts`、前端 SSE 相关测试与架构文档。
- 验证方式:`npm run test -- src/services/sseStream.test.ts src/services/llmClient.test.ts src/services/creation-agent/creationAgentSse.test.ts src/services/creative-agent/creativeAgentSse.test.ts src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts src/services/rpg-entry/rpgProfileClient.test.ts src/services/ai.test.ts``npm run typecheck``npm run check:encoding`、相关文件 `npx eslint ... --max-warnings 0` 通过。
- 关联文档:`docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md`
## 2026-06-03 平台入口公开作品流身份规则收口
- 背景:平台入口公开作品推荐流需要同时处理 RPG、拼图、抓大鹅、跳一跳、敲木鱼、视觉小说、Bark Battle、宝贝识物等卡片公开作品身份、跨玩法去重、排序和推荐运行态 kind 判定曾放在 `PlatformEntryFlowShellImpl.tsx` 巨型实现里。
- 决策:公开作品身份、排序规则、公开作品流聚合矩阵、推荐 runtime 启动意图和 ready 判定统一收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`;入口壳层只调用该 Module 的 `getPlatformPublicGalleryEntryKey``getPlatformRecommendRuntimeKind``buildPlatformPublicGalleryFeeds``resolvePlatformRecommendRuntimeStartIntent``isPlatformRecommendRuntimeReadyForEntry``isSamePlatformPublicGalleryEntry``mergePlatformPublicGalleryEntries``edutainment` key 必须带 `templateId`RPG 卡片回退为 `rpg`。公开作品流聚合负责 featured / latest、玩法可见性 gate、汪汪声浪 works fallback 和首屏 `slice(0, 6)`;推荐 runtime 启动 intent 只返回启动目标、`embedded` / `returnStage` 参数、阻断文案和错误落点ready 判定只接布尔值与拼图 profile id避免把各玩法 run snapshot 类型拖入 Module。壳层仍执行 request key、运行态 API、错误 setter 与 UI 状态。
- 影响范围:平台入口推荐流、最新公开作品流、公开作品详情、推荐 runtime 启动、跨玩法公开作品合并,以及后续新增玩法的入口接入。
- 验证方式:`npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts``npm run typecheck``npm run check:encoding`、相关文件 ESLint 通过。
- 关联文档:`docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md`
## 2026-06-03 Work Shelf 打开动作交由 item Adapter
- 背景:`creationWorkShelf.ts` 已经为每个 `CreationWorkShelfItem` 生成 `actions.open`,但 `CustomWorldCreationHub.tsx` 点击卡片后仍按 `item.source.kind` 重复分发 RPG、拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、Bark Battle 和宝贝识物的打开逻辑。
- 决策:`CreationWorkShelfItem.actions.open` 作为作品架打开动作的正式 InterfaceHub 只保留 `onOpenShelfItem` 通知和 `item.actions.open()` 调用,不再读取玩法 kind 做打开分支。`buildCreationWorkShelfItemsFromSources``CreationWorkShelfSourceAdapter` 作为 source registry Interface统一执行 flatten、运行态覆盖、持久化生成态兜底和更新时间排序`buildCreationWorkShelfItems` 保留兼容,但内部改为组装 source adapters。
- 影响范围:创作中心作品架卡片点击、作品架动作 Adapter、source registry、后续新增玩法作品架接入。
- 验证方式:`npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx``npm run typecheck``npm run check:encoding`、相关文件 ESLint 通过。
- 关联文档:`docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md`
## 2026-06-03 Runtime Client Family 请求骨架收口
- 背景Match3D、SquareHole、Puzzle、Jump Hop 等 runtime client 重复手写 path segment 编码、JSON header / body、runtime guest token、auth options 和 retry options新增玩法容易遗漏同一请求骨架。
- 决策:新增 `src/services/runtimeRequest.ts`,以 `buildRuntimeApiPath` 统一 runtime path 编码,以 `requestRuntimeJson` 统一 JSON 请求、runtime guest auth 和 retry 合并。Match3D 与 SquareHole runtime client 已先迁移,保留原导出函数名、错误文案、返回契约和重试常量。
- 追加决策Big Fish 与 Bark Battle runtime client 也迁入 `runtimeRequest.ts`;玩法专属 payload 归一化(如 Bark Battle start / finish 自动补 `workId``runId`)仍留在各玩法 client通用 Module 只承接请求骨架。
- 追加决策Puzzle 的 start / get / swap / drag / next-level / leaderboard 与 Jump Hop 的 start / jump / restart 也迁入 `runtimeRequest.ts`Puzzle `pause``props` 仍保留原账号态 auth options不直接接入 runtime guest auth。
- 追加决策Wooden Fish 的 start / checkpoint / finish 与 Visual Novel 的 gallery / run / history / regenerate JSON 请求也迁入 `runtimeRequest.ts`Wooden Fish 的 `clientEventId` 生成仍留在木鱼 clientVisual Novel start 因 `timeoutMs`、SSE 因流式 `fetchWithApiAuth` 仍暂留原实现。
- 影响范围:`src/services/runtimeRequest.ts`、Match3D / SquareHole / Big Fish / Bark Battle / Puzzle / Jump Hop / Wooden Fish / Visual Novel runtime client。
- 验证方式:`npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts``npm run typecheck``npm run check:encoding`、相关文件 ESLint 通过。
- 关联文档:`docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md`
## 2026-06-03 Public Gallery ViewModel 收口
- 背景:`RpgEntryHomeView.tsx` 巨型页面内混合了公开作品分类、跨来源去重、搜索归一化、作品号匹配、时间戳解析和排序规则,新增玩法时页面与 ViewModel 规则容易纠缠。
- 决策:新增 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,把 `buildPublicGalleryCardKey``buildPublicCategoryGroups``getPlatformPublicEntries``getAllPlatformPublicEntries``getPlatformSearchableWorkIds``filterPlatformWorkSearchResults``isExactPublicWorkCodeSearch``filterTodayPublishedEntries`、公开卡片指标 getter、`buildPlatformRankingEntries``getPlatformRankingMetricValue``getPlatformCategoryKindFilter``matchesPlatformCategoryKindFilter``sortPlatformCategoryEntries``getPlatformCategoryPrimaryMetric``parsePlatformEntryTimestamp``getPlatformWorldTimestamp` 收口为公开作品 ViewModel Interface。公开作品 key 复用平台入口身份规则,补齐 jump-hop / wooden-fish 等玩法区分。
- 影响范围RPG 首页公开作品发现、分类、搜索、排行数据准备,以及后续新增玩法公开卡片接入。
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts``npm run typecheck``npm run check:encoding`、相关文件 ESLint 通过。
- 关联文档:`docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md`
## 2026-06-03 Profile Task ViewModel 收口
- 背景:`RpgEntryHomeView.tsx` 同时持有每日任务卡片和任务中心弹窗的任务选择、进度 clamp、奖励兜底、状态标签和按钮文案导致任务展示规则和 JSX 缠在一起。
- 决策:新增 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,把 `selectProfileTaskCenterTasks``selectProfileTaskCardTask``buildProfileTaskCardSummary``buildProfileTaskProgressLabel``getProfileTaskStatusLabel``getProfileTaskClaimButtonLabel` 收口为每日任务 ViewModel Interface。任务中心仍只展示一条 claimable / incomplete 优先任务任务卡按可操作、claimed、非 disabled 的顺序兜底。
- 影响范围RPG 首页“每日任务”卡片、任务中心弹窗、后续任务状态和任务展示文案调整。
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts``npm run typecheck``npm run check:encoding`、相关文件 ESLint 通过。
- 关联文档:`docs/technical/【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md`
## 2026-06-03 最近创作只复用创作模板入口
- 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。
- 决策:“最近创作”仍只由真实后端作品架摘要决定是否展示,但只纳入 `updatedAt` 在最近 7 天内的摘要,且摘要只用于推导最近使用过的模板 ID实际列表必须从后端入口配置的 `creationTypes` 中筛出对应模板,复用其它页签的模板卡结构、文案和 `onCreateType` 点击行为,不展示具体作品名称、作品摘要或草稿 / 生成状态也不新增独立最近创作组件。最近创作页签激活时页面必须显示“仅显示最近7天内使用过的模板”。
- 影响范围:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx``src/components/custom-world-home/CustomWorldCreationHub.tsx``src/components/platform-entry`、创作入口相关测试与玩法链路文档。
- 验证方式:`CustomWorldCreationHub` 测试应断言最近创作页签包含 `creation-template-card`、模板标题 / 副标题,并且不出现旧 `creation-recent-work-grid`、作品标题、作品摘要或“打开最近创作”按钮文案RPG 入口交互测试应断言最近创作默认页签展示“文字冒险”模板卡。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-02 底部加号创作入口页 banner 与最近创作口径
- 背景:创作入口页 banner 曾固定为前端两张主题赛卡,且模板分类兜底会产生 `recent` / `最近创作` 页签,和后台配置及真实作品数据口径冲突。
- 决策:点击底部加号进入的创作入口页 banner 改由后端 `eventBanners` 数组配置,多条自动轮播;旧 `eventBanner` 只保留单条兼容。后台公告配置使用表单维护标题与 HTML 内容,保存时序列化为后端 `eventBannersJson` 传输字段HTML 只允许经空权限 iframe 展示,不执行 JSX 或直接 DOM 注入。`最近创作` 不再作为模板分类,只由真实草稿 / 作品架后端数据决定是否展示,生成失败草稿也必须进入;模板分类缺失或历史 `recent` 统一归一到 `recommended` / `热门推荐`。移动端草稿页作品卡禁止长按选择文字,但输入框和可编辑区域保留选择能力。
- 影响范围:`server-rs/crates/module-runtime``server-rs/crates/spacetime-module``server-rs/crates/spacetime-client``server-rs/crates/api-server``shared-contracts``src/components/custom-world-home``src/components/platform-entry``apps/admin-web``src/index.css`
- 验证方式:`npm run spacetime:generate``npm run check:spacetime-schema`、相关 Rust / Vitest 入口配置测试和浏览器点击底部加号截图。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-05-26 微信小程序充值全面接入虚拟支付
- 背景:泥点和会员都属于小程序内由 Genarrative 控制的虚拟资产/权益,继续走普通小程序支付不符合微信虚拟支付接入口径。
- 决策:小程序 WebView 内充值商品全部使用渠道 `wechat_mp_virtual` 并由 `miniprogram/pages/wechat-pay` 调用 `wx.requestVirtualPayment`泥点属于代币coin使用 `short_series_coin``buyQuantity` 必须取当前充值中心商品快照里的 `points_amount`;会员和后台新增道具类商品使用 `short_series_goods``signData` 必须带 `productId``goodsPrice`。后端保存微信小程序 `session_key`,仅用于生成 `signature`,不下发客户端。客户端 success 只作为支付页返回信号,最终到账仍由后端微信通知或查询确认后写订单。
- 影响范围:`src/services/payment/paymentPlatform.ts``src/components/rpg-entry/RpgEntryHomeView.tsx``miniprogram/pages/wechat-pay/``server-rs/crates/api-server/src/runtime_profile.rs``server-rs/crates/shared-contracts/src/runtime.rs``packages/shared/src/contracts/runtime.ts`、微信登录态存储。
- 验证方式:泥点和会员商品在小程序运行态都请求 `wechat_mp_virtual`;小程序页能按 payload 调用 `wx.requestVirtualPayment` / `wx.requestPayment``cargo check -p api-server --manifest-path server-rs/Cargo.toml` 与支付相关前端测试通过。
- 关联文档:`docs/【技术方案】微信虚拟支付接入-2026-05-26.md`
## 2026-05-30 Linux 本地 dev 端口段按系统级注册表分配
- 背景:同一台 Linux 开发机上有多个用户同时跑 `npm run dev` 时,单纯靠各自 `GENARRATIVE_DEV_PORT_RANGE` 容易撞段,且同一用户并发起两个 dev 会话时也会把相同端口段重复拿走。
@@ -97,6 +381,7 @@
- 影响范围:`src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``docs/【项目基线】当前产品与工程约束-2026-05-15.md`
- 验证方式:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应断言任务卡显示 `1 / 1`、领取后显示已完成,且新用户账号也没有 `次级入口` / `填邀请码` 常驻按钮;`npm run typecheck``npm run check:encoding` 通过。
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`
## 2026-05-26 生成页总进度圆弧逆时针回调 5 度
- 背景:创作生成页的总进度圆弧在 `160deg` 位置仍需轻微向左微调,用户要求向左逆时针回调 `5deg`
@@ -155,10 +440,10 @@
- 验证方式:点击推荐页拼图“下一关”后,在 `advancePuzzleNextLevel` 未返回前,页面仍应保留 `puzzle-board`,且不出现 `加载中...` 占位;返回相似作品后,当前推荐卡的 `作品信息` 应显示新作品标题。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-05-24 创作 Tab banner 轮播只展示主题赛
## 2026-05-24 创作入口页 banner 曾固定主题赛
- 背景:创作 Tab banner 曾经把后端入口配置里的默认活动横幅和两个主题赛一起轮播,导致首屏出现 58000 奖池活动卡,和当只强调拼图 / 抓大鹅主题赛的产品口径不一致。
- 决策:创作 Tab 首屏 banner 轮播只展示 `拼图主题创作赛``抓大鹅主题创作赛` 两张主题卡;后端返回的 `eventBanner` 仅作为开始时间、结束时间等公共字段来源,不再直接作为一张轮播卡渲染。banner 底部顺序固定为开始 / 结束时间条在上、分页点在下,且二者都在封面内容底部。
- 背景:点击底部加号进入的创作入口页 banner 曾经把后端入口配置里的默认活动横幅和两个主题赛一起轮播,导致出现 58000 奖池活动卡,和当只强调拼图 / 抓大鹅主题赛的产品口径不一致。
- 决策:当时固定只展示 `拼图主题创作赛``抓大鹅主题创作赛` 两张主题卡;该口径已被 2026-06-02 的后台 `eventBanners` 配置决策替代。banner 底部顺序固定为开始 / 结束时间条在上、分页点在下,且二者都在封面内容底部。
- 影响范围:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx``src/components/custom-world-home/CustomWorldCreationHub.test.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
- 验证方式:`CustomWorldCreationHub.test.tsx` 应断言默认活动标题不出现在 start-only 创作页,且 `creation-event-banner__timebar` 位于 `creation-event-banner__pager` 前。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
@@ -182,7 +467,7 @@
## 2026-05-24 创作 Tab 模板卡点击直达已有玩法入口表单
- 背景:创作 Tab 首屏需要对齐参考图,展示赛事 banner、玩法模板分类和两列模板卡点击模板卡时空白入口页会让用户多走一层占位感也会让人误以为功能未接好。
- 决策:`/creation/<play>` 直达对应玩法已有的入口创作表单 stage不再保留空白创作入口页。RPG、拼图、抓大鹅、汪汪声浪、敲木鱼、视觉小说、宝贝识物等都直接进入既有工作台继续承接草稿恢复和后续编排。创作 Tab 首屏 banner 按参考图拆成右上泥点胶囊、主体宣传封面图文、底部开始/结束时间条和分页点;玩法模板卡使用独立 `creation-template-card` 白底信息区,不复用暗图蒙版 `platform-creation-reference-card`,确保标题、描述和“预计消耗 10-20 泥点”可见。
- 决策:`/creation/<play>` 直达对应玩法已有的入口创作表单 stage不再保留空白创作入口页。RPG、拼图、抓大鹅、汪汪声浪、敲木鱼、视觉小说、宝贝识物等都直接进入既有工作台继续承接草稿恢复和后续编排。点击底部加号进入的创作入口页 banner 按参考图拆成右上泥点胶囊、主体宣传封面图文、底部开始/结束时间条和分页点;玩法模板卡使用独立 `creation-template-card` 白底信息区,不复用暗图蒙版 `platform-creation-reference-card`,确保标题、描述和“预计消耗 10-20 泥点”可见。
- 影响范围:`src/components/platform-entry/platformEntryTypes.ts``src/routing/appPageRoutes.ts``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、创作大厅交互测试与平台入口文档。
- 验证方式:`npm test -- src/routing/appPageRoutes.test.ts``npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t \"create tab opens match3d entry form from the template card|create tab opens puzzle entry form from the template card|create tab opens bark battle entry form from the template card\"``npm run typecheck``npm run check:encoding` 通过;创作卡片点击后应进入对应工作台,不再出现空白入口页。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
@@ -206,11 +491,19 @@
## 2026-05-23 拼图生成页按后端真实进度推进阶段
- 背景:拼图生成页原先会按本地耗时自动推进步骤,容易在后端真实生成尚未完成时跳到后续阶段,导致页面状态和会话进度脱节。
- 决策:拼图生成页的跨步骤推进只认后端会话 `progressPercent` 的真实里程碑,当前步骤内部再用本地耗时假进度平滑展示;总进度初始必须为 `0%`,之后按 `0-88``88-94``94-96``96-98` 的真实里程碑区间平滑推进。只要当前步骤生成内容未完成,就必须停留在当前步骤。页面只展示当前步骤标题和进度,不展示步骤详细描述。`生成拼图首图` 单独按 4 分钟估算,完整 AI 重绘路径约 448 秒;上传图且关闭 AI 重绘路径跳过首图生成,仍约 208 秒。
- 决策:拼图生成页的跨步骤推进只认后端会话 `progressPercent` 的真实里程碑,当前步骤内部再用本地耗时假进度平滑展示;`88/94/96` 只切换当前步骤,不直接作为总进度地板。总进度按已完成步骤权重加当前步骤内假进度推导,非完成态最多停在 `98%`。恢复持久化生成中草稿时,展示态 `startedAtMs` 使用后端 session `updatedAt` 或作品摘要 `updatedAt`,保证已耗时不因重新进入页面清零。只要当前步骤生成内容未完成,就必须停留在当前步骤。页面只展示当前步骤标题和进度,不展示步骤详细描述。`生成拼图首图` 单独按 4 分钟估算,完整 AI 重绘路径约 448 秒;上传图且关闭 AI 重绘路径跳过首图生成,仍约 208 秒。
- 影响范围:`src/services/miniGameDraftGenerationProgress.ts``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/CustomWorldGenerationView.tsx`、拼图生成页相关测试与玩法链路文档。
- 验证方式:拼图生成页恢复、轮询和测试都应以 `puzzleProgressPercent` 驱动阶段推进;`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts src/components/CustomWorldGenerationView.test.tsx``npm run typecheck``npm run check:encoding` 通过。
- 关联文档:`docs/【玩法创作】拼图生成页进度口径-2026-05-23.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-02 生成失败草稿必须留在作品架并覆盖生成中摘要
- 背景:生成页收到失败回包后会进入重试态,但返回草稿 Tab 时,后端作品摘要可能仍短暂保持 `generationStatus=generating`,导致用户看到“生成中”;连续触发多个拼图生成时,失败后如果清掉 pending 条目,还会少显示新增草稿。后台失败如果只写局部生成页错误,用户离开生成页后也收不到通知。
- 决策:平台壳在生成失败时必须同时标记草稿 notice 和 pending 作品架条目为 `failed`,不得删除 pending 条目。失败 notice 要保存错误消息并在用户离开生成页后触发带来源的 `PlatformErrorDialog`;作品架本地失败 notice 要覆盖持久化生成中摘要,失败草稿仍显示为草稿卡但不显示“生成中”。点击失败草稿必须优先恢复失败 / 重试页,不能按持久化 `generating` 重新启动生成;拼图契约已允许 `generationStatus=failed`pending 拼图和后端失败回写都按 session 独立落失败态,跳一跳 / 木鱼 / 抓大鹅等也直接映射为 `failed` 或对应失败态。
- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/custom-world-home/creationWorkShelf.ts``src/components/custom-world-home/CustomWorldCreationHub.tsx`、玩法链路文档和失败态交互测试。
- 验证方式:`node node_modules/vitest/vitest.mjs run src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed parallel puzzle|background match3d"`;失败后返回草稿 Tab 应看到对应新增草稿,且没有“生成中”标记;后台失败应弹出错误来源,点击失败草稿应进入失败 / 重试页。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``.hermes/shared-memory/pitfalls.md`
## 2026-05-23 所有玩法生成页统一圆环主视觉
- 背景:多个玩法生成页分别展示横向总进度条、步骤列表或三槽位列表,和最新参考图里的陶泥儿圆环等待态不一致,也让移动端信息密度偏高。
@@ -326,7 +619,6 @@
- 验证方式:执行 `cargo test -p api-server match3d --manifest-path server-rs\Cargo.toml``npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/services/match3dSpritesheetParser.test.ts src/services/match3dGeneratedModelCache.test.ts``npm run typecheck``npm run check:encoding`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-05-18 Rust 手写模块入口统一不用 mod.rs
- 背景Rust 目录模块同时存在 `mod.rs` 与同名 `.rs` 两种入口形式,前次拆分已让 `spacetime-client/src/mapper.rs` 采用同名入口;继续新增 `mod.rs` 会让文件定位和评审口径不一致。
@@ -357,6 +649,7 @@
## 2026-05-18 Windows Jenkins PowerShell 统一改为显式 powershell.exe 启动
- 后续更新:该决策仅适用于历史 Windows Jenkins 节点;当前 `Genarrative-Stdb-Module-Build` 已改为 Linux agent实际执行路径不再依赖该口径。
- 背景:`Genarrative-Stdb-Module-Build` 在 Windows Jenkins 本地环境里调用裸 `powershell` step 时触发 `CreateProcess error=5, 拒绝访问`,而 `powershell.exe` 本体与 workspace ACL 都正常。
- 决策Windows Jenkins 上凡是需要执行 PowerShell 逻辑的流水线,优先通过 `bat` 显式调用 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ...`,不要再依赖 Jenkins `powershell` step 的隐式启动器。
- 追加决策:`Genarrative-Stdb-Module-Build` 的 Checkout 逻辑应复用 Jenkins GitSCM 已完成的工作区状态。`COMMIT_HASH` 为空或已与当前 `HEAD` 一致时,不再额外执行 `git clean` / `git checkout`;只有需要切到指定且不同的 commit 时才补 fetch、校验和切换避免在 Windows workspace 里二次清理触发权限拒绝。
@@ -401,6 +694,7 @@
## 2026-05-19 生产 provision 改为 Windows 下载包后由目标机本地安装
- 后续更新:该口径已被 `2026-06-01 生产 Jenkins 流水线统一改为 Linux 优先并先查 localhost` 取代;当前 `Genarrative-Server-Provision` 不再走 Windows 下载阶段,而是在 Linux build 节点直接准备 `provision-tools/`
- 背景:当前 `development` provision 目标实际就是 Linux agent `genarrative-build-01`,之前把 `Prepare Provision Tools` 放在 `linux && genarrative-build` 会让目标机自己连 GitHub 和 `install.spacetimedb.com`违背“Windows 本机先下载再传到目标机”的运维要求。
- 决策:`Genarrative-Server-Provision` 拆成 Windows 下载阶段和 Linux 目标机安装阶段。Windows 节点的 `Download Provision Tool Archives` 只下载 `spacetime-x86_64-unknown-linux-gnu.tar.gz``otelcol-contrib_0.151.0_linux_amd64.tar.gz`,通过 `stash/unstash` 传到目标 Linux 节点;目标机执行 `scripts/prepare-server-provision-tools.sh` 时设置 `PROVISION_REQUIRE_LOCAL_DOWNLOADS=true`,只消费已下载件生成 `provision-tools/`,缺包直接失败,不回退外网下载。
- 追加决策Server-Provision 的 Windows helper 不再对 Jenkins `writeFile` 刚写出的 `.ps1` 做原地 UTF-8 BOM 重写,而是由显式 `powershell.exe` 按 UTF-8 读入脚本文本,并用 `ScriptBlock::Create(...)` 在内存中执行;这样既保留中文脚本内容,又避免同一个 workspace 脚本被立即重写时触发 `拒绝访问`
@@ -563,7 +857,7 @@
- 验证方式:草稿页作品卡与分类页列表视觉口径保持一致;`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx``npm run typecheck``npm run check:encoding`
- 关联文档:`docs/design/MOBILE_CREATION_WORK_LIST_TWO_COLUMN_LAYOUT_2026-04-29.md``docs/experience/MOBILE_UI_DEV_EXPERIENCE.md`
2026-05-14 补充:草稿页作品卡不再用“草稿 / 已发布”文字标识状态,改为图标化 UI 状态点;作品封面直接铺到卡片右半区并从右向左渐隐;已发布作品右上角常驻分享图标;草稿长按弹出删除面板,已发布长按弹出分享和删除面板。
2026-05-14 补充:草稿页作品卡不再用“草稿 / 已发布”文字标识状态,改为图标化 UI 状态点;作品封面直接铺到卡片右半区并从右向左渐隐;已发布作品右上角常驻分享图标;草稿长按弹出删除面板,已发布长按弹出分享和删除面板。2026-06-02 追加:作品卡片右上角不再放删除按钮;删除只通过左滑、键盘展开或长按 / 右键展开的右侧操作区出现,避免与卡片主点击和分享入口抢占标题区。
## 2026-05-13 认证运行期同步直接导入正式认证表
@@ -599,7 +893,7 @@
## 2026-05-13 refresh_session 会话组后端聚合与远端踢下线
- 背景:账号安全页中同设备同 IP 的多条 active `refresh_session` 会重复展示;退出登录没有稳定撤销当前 refresh session前端“踢下线”只做本地状态变化未真正让远端设备失效。
- 决策:`GET /api/auth/sessions` 由后端按“同设备 + 同 IP”聚合 active refresh sessions响应保留代表 `sessionId` 并新增 `sessionIds/sessionCount`;组内包含当前 refresh hash 或 Bearer `sid` 时整组视为当前设备组,前端不展示踢下线。新增 `POST /api/auth/sessions/{session_id}/revoke`,只允许撤销当前用户自己的非当前会话,不递增 `token_version`,但认证中间件会校验 access token `sid` 对应 active refresh session使被踢设备立即失效。`/api/auth/logout` 在 refresh cookie 缺失时回退用 Bearer `sid` 撤销当前 session,并继续递增 `token_version`
- 决策:`GET /api/auth/sessions` 由后端按“同设备 + 同 IP”聚合 active refresh sessions响应保留代表 `sessionId` 并新增 `sessionIds/sessionCount`;组内包含当前 refresh hash 或 Bearer `sid` 时整组视为当前设备组,前端不展示踢下线。新增 `POST /api/auth/sessions/{session_id}/revoke`,只允许撤销当前用户自己的非当前会话,不递增 `token_version`,但认证中间件会校验 access token `sid` 对应 active refresh session使被踢设备立即失效。`/api/auth/logout` 在 refresh cookie 缺失时回退用 Bearer `sid` 撤销当前 session;自 2026-06-07 起单设备退出也不再递增 `token_version`,避免误伤其它设备,只有退出全部设备和改密类安全动作提升账号级版本
- 影响范围:`module-auth` refresh session service、`api-server` auth middleware/logout/sessions route、`shared-contracts`/TS auth contract、`AuthGate``AccountModal`、认证会话技术文档和路由/埋点索引。
- 验证方式:执行 `cargo test -p module-auth --manifest-path server-rs/Cargo.toml refresh_session``cargo test -p api-server --manifest-path server-rs/Cargo.toml auth_sessions -- --nocapture``cargo test -p api-server --manifest-path server-rs/Cargo.toml revoke_auth_session -- --nocapture``cargo test -p api-server --manifest-path server-rs/Cargo.toml logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid -- --nocapture``npm run test -- AuthGate.test.tsx AccountModal.test.tsx authService.test.ts``npm run check:encoding``git diff --check`,并用 `npm run dev:api-server` 检查 `/healthz`
- 关联文档:`docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md``docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md``docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`
@@ -750,6 +1044,7 @@
- 验证方式:执行 `npx vitest run src/services/useMocapInput.test.ts src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/components/child-motion-demo/childMotionWarmupModel.test.ts src/services/child-motion-demo/childMotionDebugInput.test.ts src/routing/appRoutes.test.ts``npx eslint ...``npm run typecheck``npm run check:encoding`,并确认 `http://127.0.0.1:8876/stream` WebSocket 可握手、`http://127.0.0.1:3000/child-motion-demo` 可访问。
## 2026-05-18 寓教于乐频道补充热身关入口
- 背景:用户希望在发现页的寓教于乐板块里直接看到热身关入口,而不是只依赖独立直达路由。
- 决策:`child-motion-demo` 作为寓教于乐频道的独立卡片展示,点击后直接进入 `/child-motion-demo`;该入口与 `宝贝爱画` 并列,仍复用现有独立热身关路由,不新增新的创作模板或运行态壳层。
- 影响范围:`src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
@@ -1048,12 +1343,29 @@
## 2026-05-19 server provision 下载件固定由 Windows 节点断点续传
- 后续更新:该口径已被 `2026-06-01 生产 Jenkins 流水线统一改为 Linux 优先并先查 localhost` 取代;当前不再维护 Windows 下载阶段和 `.download` 断点续传 helper。
- 背景:`SpacetimeDB``otelcol-contrib` release 资产在 Linux 目标机直接下载很慢;改到 Windows Jenkins 节点下载后GitHub 大文件仍可能出现 `curl: (18)` 响应体截断。
- 决策:`Genarrative-Server-Provision``Download Provision Tool Archives` 阶段继续只在 Windows 节点下载,再通过 `stash/unstash` 交给目标 Linux agent下载前查 GitHub release asset `digest`,本地最终文件 SHA256 命中即跳过,`.download` 临时文件用于 `curl -C -` 断点续传,完整返回但 digest 不匹配才清理重下。
- 影响范围:`jenkins/Jenkinsfile.production-server-provision`、目标机 `scripts/prepare-server-provision-tools.sh` 的本地下载件消费路径、生产 provision 运维排障。
- 验证方式Windows 下载日志应出现 digest 查询、已存在校验跳过或 `curl 断点续传`Linux 目标机阶段只使用 `provision-tool-downloads/` 中的 tarball不访问 GitHub 下载地址。
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-06-01 生产 Jenkins 流水线统一改为 Linux 优先并先查 localhost
- 背景:生产流水线长期混用 Windows、Linux 和公网 Git 入口,导致构建 / 发布 / provision 的 checkout 口径分叉;同时 `Genarrative-Server-Provision` 还残留过 Windows 下载 helper和当前 Linux 构建 / 发布部署路径不一致。
- 决策:生产 Jenkins 流水线统一把执行节点收口到 Linux label`Pipeline script from SCM` 仍保留公网域名,但所有生产流水线首次 `GitSCM checkout` 先尝试 `http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后再回退到 `https://git.genarrative.world/GenarrativeAI/Genarrative.git``Genarrative-Stdb-Module-Build``Genarrative-Server-Provision``Genarrative-Notify-Email` 也都切到 Linux 节点。`Genarrative-Server-Provision` 的工具准备不再依赖 Windows helper而是在 Linux build 节点直接生成 `provision-tools/` 后交给后续 Linux 发布阶段。
- 影响范围:`jenkins/Jenkinsfile.production-*``scripts/jenkins-checkout-source.sh``scripts/prepare-server-provision-tools.sh`、生产运维文档。
- 验证方式:扫描 Jenkinsfile 时应看到 `linux && genarrative-*` 节点和 localhost-first checkout 口径;`Genarrative-Server-Provision` 日志不再出现 Windows 相关 helper 输出,工具准备阶段应直接生成 `provision-tools/`
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-06-01 Web Deploy 只从 Jenkins 构建归档取发布包
- 背景:`Genarrative-Web-Deploy` 曾在发布阶段读取构建机本地缓存目录release 目标还可能通过 `rsync` 回构建机拉取 `web.tar.gz`,导致发布依赖机器拓扑和本地路径。
- 决策:`Genarrative-Web-Build` 直接归档 `build/<version>/web.tar.gz``web.tar.gz.sha256``release-manifest.json``Genarrative-Web-Deploy` 只使用 Jenkins `copyArtifacts` 从指定上游构建复制完整 Web 发布包,不再维护 `WEB_ARTIFACT_ROOT``WEB_ARTIFACT_SYNC_HOST``web-artifact-pointer.txt`
- 影响范围:`jenkins/Jenkinsfile.production-web-build``jenkins/Jenkinsfile.production-web-deploy`、Web 发布排障流程。
- 验证方式deploy 工作区直接存在 `build/<version>/web.tar.gz``web.tar.gz.sha256``release-manifest.json`,随后由 `scripts/deploy/production-web-deploy.sh` 校验 checksum 并解压发布。
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 个人任务与埋点首版边界冻结
- 背景“我的”Tab、任务、奖励、钱包和埋点涉及用户、运营、分析多条链路需要避免范围泛化。
@@ -1078,13 +1390,38 @@
- 验证方式:从平台推荐或公开详情进入跳一跳作品时,路由 source type 为 `jump-hop`、public code 为 `JH-*`,运行态启动消费后端返回的完整 profile / run 数据;后端 smoke 统一使用 `npm run dev:api-server` 启动并检查 `/healthz`
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-05-26 跳一跳地块图集改为专用 2x3 六格切分
## 2026-05-28 跳一跳重设计为 5x5 地块图集与弹弓拖拽
- 背景:跳一跳创作在地块生图阶段误用了通用系列素材图集 helper`item_names.len() > grid_size` 的校验会让 6 个地块类型在 `grid_size = 3` 时直接失败;即使绕过校验,通用 helper 仍以“每物品多视图”语义切图,不符合跳一跳地块的一次性六格资产模型
- 决策:跳一跳地块图集固定采用专用 `2行*3列` 六格布局,按 `start / normal / target / finish / bonus / accent` 顺序切分并分别持久化为独立 PNG 资产;图集 prompt 不再调用通用系列素材 `build_generated_asset_sheet_prompt`
- 影响范围:`server-rs/crates/api-server/src/jump_hop.rs``docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
- 验证方式:`cargo test -p api-server jump_hop_tile_atlas -- --nocapture` 通过;六张切片都应有独立 OSS 对象与 `JumpHopTileAsset` 记录,不再只有 atlas 预览路径
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`
- 背景:跳一跳模板仍保留角色生图、有限路径、score/combo 和 `2x3` 地块图集口径,和当前“俯视角平台跳跃 + 主题生成地块池 + 无限路径”的产品需求不一致
- 决策:`jump-hop` v1 创作端只保留主题输入image2 生成一张 `5x5`、共 25 个 2D 地块图标的图集,后端按均匀网格切出 25 个 `JumpHopTileAsset`。角色不再单独生图,运行态使用陶泥儿 logo 透明 PNG 角色;运行态输入为按住后拉蓄力、松手反向弹出,前端提交 `chargeMs + dragVectorX + dragVectorY`,后端裁决落点。草稿试玩必须使用 `runtimeMode=draft`,正式作品使用 `runtimeMode=published`;排行榜按作品维度每玩家只保留 1 条最佳记录,排序为成功跳跃次数降序、游戏时长升序、更新时间升序
- 决策补充:跳一跳创作入口的事实源仍是 SpacetimeDB `creation_entry_type_config`。默认种子和旧默认行都必须同步迁移到 `subtitle=主题驱动平台跳跃``image_src=/creation-type-references/jump-hop.webp`;后端只在系统默认旧值命中时自动纠偏,避免覆盖后台手动配置
- 影响范围:`jump-hop` PRD、`api-server` 生成编排、`module-jump-hop` 领域规则、`spacetime-module` / `spacetime-client` 跳一跳契约、前端工作台 / 结果页 / runtime / 平台壳调用链
- 验证方式:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml``cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:spacetime-schema`、跳一跳工作台和 runtime 定向前端测试
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-06-01 跳一跳运行态地块视觉尺寸和命中半径统一放大一倍
- 背景:当前跳一跳运行态里地块视觉尺寸偏小,玩家反馈“很难跳上去”,但仅放大前端展示会造成画面和后端裁决脱节。
- 决策:`jump-hop` 运行态的地块视觉尺寸、`width/height` 玩法世界尺寸以及 `landingRadius/perfectRadius` 同步乘以 2前端平台渲染抽成统一尺寸 helper保证单测可以直接校验放大结果。
- 影响范围:`server-rs/crates/module-jump-hop/src/application.rs``src/services/jump-hop/jumpHopRuntimeModel.ts``src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、对应定向测试。
- 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx``cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-02 跳一跳起跳距离减半并加入飞行动画缓冲
- 背景:用户反馈当前跳跃到目标位置需要拖得太远,且松手后缺少角色翻腾到目标地块的过渡动画,导致跳跃手感偏硬。
- 决策:`jump-hop``chargeToDistanceRatio` 统一从 `0.004` 提升到 `0.008`,让同等跳跃距离所需拖动距离减半;前端 runtime 把“后端真实 run”和“当前屏幕显示态”拆开松手瞬间先生成 `visualJump`,用当前角色位置作为起点、前端预测落点作为终点,播放约 `560ms` 的飞行动画;该路径不得等待后端新 run。角色弹到预测落点后若新 run 尚未返回,必须停在预测落点等待,再进入约 `1440ms` 的相机层推进过渡。推进期间地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块自然离开视野,新预览地块从上方露出,避免 p1/p2 单独 top/left 过渡导致角色和地块不同步。相机推进必须同时使用 X/Y 偏移,从旧目标地块位置斜向滑到新当前地块聚焦位置,不能先横向瞬切居中再纵向推进。地块保留当前 / 目标 / 预览的深度尺寸差异,但该差异通过固定基准宽高上的 CSS transform scale 表达,并在相机推进期间同样使用 `1440ms` 缓动;当前态不再额外叠 CSS scale。
- 影响范围:`server-rs/crates/module-jump-hop/src/application.rs``src/services/jump-hop/jumpHopRuntimeModel.ts``src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、跳一跳运行态定向测试。
- 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx``cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture``npm run check:encoding`
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-03 跳一跳角色形象改为陶泥儿 logo 透明 PNG
- 背景:跳一跳运行态此前仍使用旧内置 / CSS 角色形象,和用户要求的陶泥儿 logo 角色不一致,也容易和 DOM 地块层出现遮挡层级问题。
- 决策:`jump-hop` v1 不再渲染内置 3D 角色几何体;运行态和结果页统一使用 `public/branding/jump-hop-taonier-character.png`,该文件由陶泥儿 logo 处理为透明 PNG 后接入。蓄力时角色沿拖拽方向明显拉长,落地后向反方向回弹两次。`characterAsset` 继续仅作为历史兼容描述字段,不能重新打开角色生图槽或把角色图片作为创作者可配置输入。
- 影响范围:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx``src/components/jump-hop-result/JumpHopResultView.tsx`、跳一跳 PRD 和平台链路文档。
- 验证方式:跳一跳运行态 / 结果页测试需要断言角色图片 src 为 `/branding/jump-hop-taonier-character.png`,并确认旧默认角色 fallback 不再出现。
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
# 2026-05-20 陶泥儿主视觉配色回收为暖白/陶土橙
@@ -1130,6 +1467,186 @@
- 验证方式:工作台首屏不再出现标题 / 简介 / 标签输入;结果页修改后点试玩或发布会先写回当前作品信息。
- 关联文档:`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-03 Profile Dashboard Presentation 收口
- 背景:`RpgEntryHomeView.tsx` 同时承载个人数据卡、钱包 chip 与“玩过”弹窗,计数压缩、累计时长、单作品时长、玩法标签和作品号兜底散在页面 Implementation 内,修改展示口径时缺少稳定测试面。
- 决策:新增 `src/components/rpg-entry/rpgEntryProfileDashboardPresentation.ts` 作为个人数据展示 ModuleInterface 收口为 `buildProfileDashboardPresentation`、计数 / 时长格式化和“玩过”列表标签 / 作品号格式化函数;页面只消费结果并保留 UI 编排与点击处理。
- 影响范围RPG 首页“我的数据”卡片、移动端 / 桌面端钱包 chip、个人数据弹窗与“玩过”列表。
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryProfileDashboardPresentation.test.ts`、针对变更文件执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md`
## 2026-06-03 Recommend Feed ViewModel 收口
- 背景:推荐 feed 与正式 runtime 的上一条 / 下一条选择分别在 `RpgEntryHomeView.tsx``PlatformEntryFlowShellImpl.tsx` 手写公开作品去重、隐藏内容过滤、active key 兜底和相邻回环,存在推荐预览与 runtime 口径漂移风险。
- 决策:在 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 追加推荐 feed Module Interface`dedupePlatformPublicGalleryEntries``buildPlatformRecommendFeedEntries``selectPlatformRecommendFeedWindow``selectAdjacentPlatformRecommendEntry`;首页与 FlowShell 均消费该 Interface。
- 影响范围:移动端首页推荐 swipe、发现页推荐频道、桌面推荐格、推荐 runtime 队列与上一条 / 下一条跳转。
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts``npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "logged out home recommendation next starts the next puzzle work"`、针对变更文件执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md`
## 2026-06-03 Recommend Swipe Deck Model 收口
- 背景:移动端推荐首页 swipe deck 的拖拽阈值、offset clamp、commit 方向、rail class 和分享文案仍留在 `RpgEntryHomeView.tsx` 页面 Implementation 内,页面同时承载 DOM pointer 副作用和纯规则。
- 决策:新增 `src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts` 作为 Recommend Swipe Deck ModuleInterface 收口 `hasRecommendDragStarted``clampRecommendDragOffset``resolveRecommendDragCommitDirection``resolveRecommendCommitOffset``buildRecommendSwipeRailClassName``shouldAnimateRecommendSwipe``buildRecommendShareText`;页面仅保留 pointer capture、DOM 高度读取、动画 timer、clipboard 与 like/remix/open 副作用 Adapter。
- 影响范围:移动端推荐首页 swipe 手势、上一条 / 下一条动画、推荐分享文案与未登录时的直接切换行为。
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts``npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"``npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts -t "recommend"`、针对新 Module 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md`
## 2026-06-03 Ranking ViewModel 收口
- 背景:排行 tab 的文案、metric label 与空态文案在 `RpgEntryHomeView.tsx`,排序和 metric value 在 `rpgEntryPublicGalleryViewModel.ts`,同一 `PlatformRankingTab` 的 Interface 分散且页面需要类型断言取 active config。
- 决策:在 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 收口 `DEFAULT_PLATFORM_RANKING_TAB``PLATFORM_RANKING_TABS``getPlatformRankingTabConfig``getPlatformRankingMetric`;页面仅保留 active tab 状态和渲染。
- 影响范围:发现页排行频道 tab 顺序、tab 文案、空态文案、排行项指标 label/value。
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts``npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "bottom category tab becomes ranking and switches ranking metrics|ranking"`、针对变更文件执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】RankingViewModel收口计划-2026-06-03.md`
## 2026-06-03 Category Option ViewModel 收口
- 背景分类频道的筛选选项、排序选项、默认值、active label fallback 和排序循环仍留在 `RpgEntryHomeView.tsx` 页面 Implementation 内,而玩法过滤、排序和主指标已经在 `rpgEntryPublicGalleryViewModel.ts`,同一分类 Interface 被拆成两处。
- 决策:在 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 收口 `DEFAULT_PLATFORM_CATEGORY_KIND_FILTER``DEFAULT_PLATFORM_CATEGORY_SORT_MODE``PLATFORM_CATEGORY_KIND_FILTERS``PLATFORM_CATEGORY_SORT_OPTIONS``getPlatformCategoryKindFilterOption``getPlatformCategorySortOption``getNextPlatformCategorySortMode`;页面仅保留当前筛选 / 排序状态和渲染。
- 影响范围:发现页分类频道筛选弹窗、筛选按钮 label、排序按钮 label 与排序循环。
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts``npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "category"`、针对变更文件执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md`
## 2026-06-03 Match3D Runtime Profile 收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 内仍直接承载抓大鹅公开详情转 work、session draft 转 profile、生成背景资产提升、runtime active profile 选择和 run / profile / public detail 素材优先级,平台壳需要理解抓大鹅生成素材内部结构。
- 决策:新增 `src/components/platform-entry/platformMatch3DRuntimeProfile.ts` 作为抓大鹅 runtime profile ModuleInterface 收口 `mapPublicWorkDetailToMatch3DWork``buildMatch3DProfileFromSession``normalizeMatch3DWorkForRuntimeUi``mapMatch3DWorksForRuntimeUi``promoteMatch3DGeneratedBackgroundAsset``hasMatch3DRuntimeAsset``hasMatch3DRuntimeBackgroundAsset``resolveActiveMatch3DRuntimeProfile` 与 runtime item/background/backgroundImage 解析函数;平台壳只保留启动 run、预加载、路由、错误和 state 编排。
- 影响范围:抓大鹅作品架、公开详情试玩、推荐 runtime、正式 runtime 与草稿结果页试玩前素材规范化。
- 验证方式:`npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "match3d|抓大鹅"`、针对新 Module 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md`
## 2026-06-03 Draft Generation Shelf Model 收口
- 背景:平台壳内散落创作生成 notice key、pending 作品架占位、作品详情更新回填、失败文案覆盖、拼图稳定 ID、持久化 generating/failed 判断与草稿 Tab 未读点,新增或调整玩法时需要在多处理解 `workId` / `profileId` / `sourceSessionId` / `draftId` 形状。
- 决策:新增 `src/components/platform-entry/platformDraftGenerationShelfModel.ts` 作为 Draft Generation Shelf ModuleInterface 收口 `collectDraftNoticeKeys``getGenerationNoticeShelfKeys``createPendingDraftShelfState`、各玩法 `buildPending*Works``buildCreationWorkShelfRuntimeState``collectVisibleDraftNoticeKeys``hasUnreadDraftGenerationUpdates``mergePuzzleWorkSummary``mergeBigFishWorkSummary`、拼图稳定 ID 与持久化状态判断;`PlatformEntryFlowShellImpl.tsx` 仅作为 React state、网络刷新、路由和弹窗副作用 Adapter。
- 影响范围:创作中心草稿 Tab 未读点、作品架生成中遮罩、作品详情更新回填、失败草稿摘要、pending 草稿占位、拼图 / 抓大鹅生成恢复和各玩法生成完成通知。
- 验证方式:`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts``npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts -t "generation state|failure notice|failed puzzle"``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft|persisted generating match3d draft|completed baby object match draft"`、针对新 Module 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md`
## 2026-06-03 Creation Hub Shelf Items Interface 收口
- 背景:`creationWorkShelf.ts` 已把各玩法作品映射为 `CreationWorkShelfItem.actions`,但 `CustomWorldCreationHub.tsx` 的生产 Interface 仍接收 raw items 与 open/delete/claim 回调列阵,新增玩法时 Hub props 继续膨胀。
- 决策:`CustomWorldCreationHub.tsx` 生产 Interface 收敛为 `shelfItems: CreationWorkShelfItem[]` 与少量 UI 状态;`PlatformEntryFlowShellImpl.tsx` 在外层作为 Adapter 调用 `buildCreationWorkShelfItems` 注入完整 actionsHub 测试改经 `CustomWorldCreationHub.testAdapter.tsx` 把旧 fixture 转成 shelf items不让测试继续依赖旧浅 Interface。
- 影响范围:创作 Tab / 草稿 Tab 作品架、RPG / 拼图 / 抓大鹅 / 方洞 / 跳一跳 / 敲木鱼 / 视觉小说 / Bark Battle / 宝贝识物作品打开、删除、生成态与拼图奖励领取。
- 验证方式:`npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts``npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx``npm run test -- src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、相关 FlowShell creation hub 交互片段、针对变更文件执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md`
## 2026-06-03 Creation URL State Model 收口
- 背景:平台壳内散落各玩法创作恢复 URL 的 `sessionId` / `profileId` / `draftId` / `workId` 组装、空值归一化、拼图 runtime query key 与拼图稳定身份互推,导致刷新恢复规则缺少稳定测试面。
- 决策:新增 `src/components/platform-entry/platformCreationUrlStateModel.ts` 作为 Creation URL State ModuleInterface 收口各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState`、URL state 非空判断和 runtime state key新增 `src/components/platform-entry/platformPuzzleIdentityModel.ts` 作为拼图稳定身份 Module`platformDraftGenerationShelfModel.ts` 仅 re-export 旧入口以保持兼容。`PlatformEntryFlowShellImpl.tsx` 只保留路由、URL 写入和网络副作用 Adapter。
- 追加决策:初始创作 URL 恢复的已处理、非创作路径、无私有 query、平台配置加载中、受保护数据暂不可读与可恢复判定也收口到 `resolveInitialCreationUrlRestoreDecision`;壳层只按 `skip``mark-handled``wait``restore` 执行 ref 标记或进入原恢复副作用。
- 追加决策:创作直达恢复目标解析收口到 `resolveCreationUrlRestoreTarget(pathname, state)`Module 统一识别 big-fish、match3d、square-hole、puzzle、visual-novel、bark-battle、baby-object-match、jump-hop、wooden-fish 的 path、私有 query 归一化、生成路径标记和 big-fish workId 到 sessionId 兜底。壳层仍执行作品列表读取、草稿恢复、错误处理、stage 切换和 URL 写回;`/creation/rpg` 继续保持无具体恢复目标,后续要接入需先补规则与测试。
- 追加决策:创作 URL 恢复的作品 / 草稿身份匹配谓词、以及跳一跳 / 敲木鱼恢复后的阶段落点也归入 `platformCreationUrlStateModel.ts`。身份匹配只允许非空目标值命中,避免 query 缺失时用空值误开草稿壳层只把已读取的列表项、session 或 work 交给 Module 判定,然后执行对应打开 / restore 副作用。
- 影响范围:创作流程刷新恢复、拼图草稿 / 发布 runtime 深链、作品架打开试玩、跳一跳 / 敲木鱼 work-backed 恢复、Bark Battle / 宝贝识物本地草稿恢复。
- 验证方式:`npm run test -- src/components/platform-entry/platformCreationUrlStateModel.test.ts src/components/platform-entry/platformPuzzleIdentityModel.test.ts``npm run test -- src/services/creationUrlState.test.ts``npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对新 Module 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md`
## 2026-06-04 Platform Public Code Search Model 收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 的公开搜索回调内联判断内部用户 ID、陶泥号、RPG 作品号、各玩法公开作品号前缀和 fallback 顺序,壳层同时承担纯搜索计划与网络 / 打开副作用。
- 决策:新增 `src/components/platform-entry/platformPublicCodeSearchModel.ts`,以 `resolvePlatformPublicCodeSearchPlan(keyword)` 返回 `normalizedKeyword``steps``user_` / `user-` 只查用户 ID玩法前缀直达对应作品`CW` / 纯数字先查 RPG 作品再查陶泥号;普通关键词和 `SY` 保持既有用户号、RPG 作品、汪汪声浪、用户号兜底顺序。壳层只按 step 执行既有查找、详情打开、Bark Battle runtime 特例和 missing work 归航。
- 影响范围:发现页 / 推荐页公开搜索、作品详情深链初始搜索、陶泥号命中面板、各玩法公开作品号直达。
- 验证方式:`npm run test -- src/components/platform-entry/platformPublicCodeSearchModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-04 Platform Played Work Open Model 收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 的个人“玩过作品”点击回调内联判断 `worldType``worldKey` 前缀、玩法别名、目标 ID、RPG fallback 详情和大鱼吃小鱼 fallback work壳层同时承担打开意图与异步副作用。
- 决策:新增 `src/components/platform-entry/platformPlayedWorkOpenModel.ts`,以 `resolvePlatformPlayedWorkOpenIntent(work)` 返回 `noop`、各玩法公开详情打开意图、`open-big-fish``open-rpg`。Module 负责玩法别名、`worldKey` 前缀兜底、big-fish gallery miss `fallbackWork` 和 RPG `CustomWorldGalleryCard` payload壳层继续负责关闭面板、刷新 gallery、命中真实作品、打开详情和错误提示。
- 影响范围:个人“玩过作品”面板点击打开、拼图 / 抓大鹅 / 方洞 / 跳一跳 / 敲木鱼 / 大鱼吃小鱼 / RPG 公开详情入口。
- 验证方式:`npm run test -- src/components/platform-entry/platformPlayedWorkOpenModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、相关 profile 面板交互片段、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-04 Platform Generation Progress Tick Model 收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 的生成页进度 tick effect 内联维护 stage 到小游戏生成状态的三元链,并额外手写视觉小说 `startedAtMs` / `phase` 特例,壳层同时承担纯判定与 interval 副作用。
- 决策:新增 `src/components/platform-entry/platformGenerationProgressTickModel.ts`,以 `resolvePlatformGenerationProgressTickDecision(input)` 返回 `{ activeKind, shouldTick }`。Module 负责 stage 到 kind 映射、小游戏状态缺失 / 终态判定、视觉小说轻量生成判定;壳层继续负责 `Date.now()``window.setInterval`、progress now state 写入和 cleanup。
- 影响范围:拼图、抓大鹅、大鱼吃小鱼、方洞挑战、跳一跳、敲木鱼、宝贝识物和视觉小说生成页进度 tick。
- 验证方式:`npm run test -- src/components/platform-entry/platformGenerationProgressTickModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-04 Platform Mini Game Session Mapping Model 收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 顶部仍保留拼图 runtime 恢复、方洞 session draft 转 profile、视觉小说 work detail 转 Agent session、跳一跳 pending session、敲木鱼 detail 恢复、敲木鱼生成中作品摘要和敲木鱼 pending session 等纯 DTO 映射,壳层需要理解 sessionId 优先级、拼图稳定 ID、方洞草稿 profile 默认值、视觉小说 work/session fallback、敲木鱼生成中摘要和 pending draft 默认值。
- 决策:新增 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,收口 `buildPuzzleRuntimeWorkFromSession``buildSquareHoleProfileFromSession``buildVisualNovelSessionFromWorkDetail``buildJumpHopPendingSession``buildWoodenFishSessionFromWorkDetail``buildWoodenFishGeneratingWorkSummary``buildWoodenFishPendingSession`。Module 复用 `normalizeCreationUrlValue``platformPuzzleIdentityModel`壳层只保留网络读取、React state、URL 写入和 stage 切换副作用。
- 影响范围:拼图 runtime URL 恢复、方洞挑战草稿 profile 构造、视觉小说草稿作品架恢复、跳一跳生成中作品架打开、敲木鱼生成中作品架摘要 / 作品架打开和敲木鱼草稿 detail 恢复。
- 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-04 Platform RPG Agent Result Preview Model 收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护 RPG Agent 结果页发布门禁展示修正和 result preview source label 映射,壳层需要理解 `CustomWorldProfile` 顶层字段、`creatorIntent``anchorContent`、章节蓝图和首幕 acts。
- 决策:新增 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,收口 `buildPlatformRpgAgentResultPublishGateView``resolvePlatformRpgAgentResultPreviewSourceLabel`。Module 只做展示层纯判定;壳层继续负责 session/profile 编排、发布副作用和结果页 props 传递。
- 影响范围RPG Agent 结果页发布按钮门禁 blockers、publishReady 展示修正和预览来源 label。
- 验证方式:`npm run test -- src/components/platform-entry/platformRpgAgentResultPreviewModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-04 Platform Mini Game Draft Generation State Model 收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护小游戏生成状态恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并和 ready / generating 判定,壳层同时承担 API / background task 副作用和 `MiniGameDraftGenerationState` 生命周期细节。
- 决策:新增 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts`,收口恢复态、失败态、完成态、展示 rebase、拼图 progress phase 阈值和进度 metadata 合并。壳层继续负责 API、后台任务、React state 写入、作品架刷新、URL 和 stage 切换。
- 追加决策:抓大鹅轮询作品素材时的旁路进度合并也归入该 Module`mergeMatch3DGeneratedAssetsIntoGenerationState(state, assets)` 统一统计可用图片素材、至少 5 个总素材计数、`match3d-generate-views` phase 推进和首个素材错误传播;壳层只负责轮询 session / work detail 与写入 state。
- 影响范围:拼图 / 抓大鹅 / 大鱼吃小鱼 / 方洞 / 跳一跳 / 敲木鱼 / 宝贝识物生成状态恢复、完成失败收尾、生成页返回展示和拼图轮询进度合并。
- 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-04 Platform Mini Game Draft Payload Model 收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护拼图 / 抓大鹅表单 payload、拼图作品更新 payload、拼图编译 action、跳一跳 / 敲木鱼生成 action、作品摘要回填 payload 和 pending 草稿 metadata壳层需要理解描述字段优先级、formDraft 回退、结果页 draft 到作品更新字段的映射、跳一跳 / 敲木鱼 payload 与 draft 优先级、Match3D config / draft / anchorPack 优先级和数字解析。
- 决策:新增 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,收口 `buildPuzzleFormPayloadFromWork``buildPuzzleFormPayloadFromSession``buildPuzzleFormPayloadFromAction``buildPuzzleCompileActionFromFormPayload``buildPuzzleWorkUpdatePayloadFromDraft``buildJumpHopDraftActionPayload``buildWoodenFishDraftActionPayload``buildPendingPuzzleDraftMetadata``isPuzzleFormOnlyDraft``isEmptyPuzzleFormOnlyDraft``buildMatch3DFormPayloadFromSession``buildMatch3DFormPayloadFromWork``buildPendingMatch3DDraftMetadata``parseOptionalFiniteNumber` 留在 Module 内部。
- 影响范围:拼图 action 完成 / 执行前 / 失败恢复、拼图结果页试玩前作品更新、跳一跳 / 敲木鱼生成与重生成 action、拼图表单直生草稿、拼图 form-only 草稿恢复 / 分流 / 结果页渲染、拼图草稿架恢复、抓大鹅表单直生草稿与失败恢复。
- 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-04 Platform Puzzle Draft Recovery Model 收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 的拼图恢复链路只要 cover 或候选图存在就会把恢复 session 抬为 ready可能让缺关卡画面、UI spritesheet 或关卡背景的半成品直接进入结果页完成态。
- 决策:新增 `src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts`,收口 `normalizeRecoveredPuzzleDraftSession``hasRecoverableGeneratedPuzzleDraft`。恢复完成态必须同时具备首图、`levelSceneImage*``uiSpritesheetImage*``levelBackgroundImage*`;只有完整资产包成立时才把 draft 与首关 `generationStatus` 抬为 `ready`
- 影响范围:拼图生成完成后刷新恢复、拼图 background compile task 完成态写入和结果页自动打开。
- 验证方式:`npm run test -- src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft"``npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-04 Platform Puzzle Runtime State Model 收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 在拼图排行榜提交回包后内联合并服务端 run 快照,壳层需要理解 `PuzzleRunSnapshot` 中哪些字段由前端即时裁决、哪些字段只由服务端补齐。
- 决策:新增 `src/components/platform-entry/platformPuzzleRuntimeStateModel.ts`,以 `mergePuzzleServiceRuntimeState(currentRun, serviceRun)` 收口服务端 run 合并规则。Module 保留当前前端关卡状态、棋盘和计时,只合并服务端 run 身份、`clearedLevelCount` 上限、排行榜与下一关 handoff任一 run 缺 `currentLevel` 时直接返回当前 run。
- 影响范围:拼图排行榜提交、推荐 runtime isolated / default 运行态回包合并、下一关同作品 / 相似作品 handoff以及后续 Puzzle runtime 快照字段调整。
- 验证方式:`npm run test -- src/components/platform-entry/platformPuzzleRuntimeStateModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformPuzzleRuntimeStateModel收口计划-2026-06-04.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-04 Puzzle Publish Asset Gate 收紧
- 背景:后端拼图待发布门槛与前端历史恢复逻辑一样偏弱,只要求标题、描述、标签、关卡名和 cover导致缺关卡画面、UI spritesheet 或关卡背景的半成品可能被标为 `publishReady` / `ready_to_publish`
- 决策:`module-puzzle::validate_publish_requirements` 新增三类资产 blocker要求每关具备 `level_scene_image_*``ui_spritesheet_image_*``level_background_image_*``api-server::puzzle::tags::is_puzzle_session_snapshot_publish_ready` 同步使用完整资产包判定。
- 影响范围:拼图 result preview blockers、publishReady、标签生成后 session stage、从 action payload 构造 fallback session 的 ready 判定。
- 验证方式:`cargo test -p module-puzzle --manifest-path server-rs/Cargo.toml validate_publish_requirements``cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_builds_fallback_session_from_levels_snapshot``cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_fallback_session_ready_when_asset_pack_complete``npm run check:encoding`
- 关联文档:`docs/technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-04 Platform Profile Wallet Delta Model 收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护钱包余额归一、本地 delta 乐观更新和服务端 dashboard 刷新后的 delta 抵消,壳层需要理解余额非负、整数截断、借贷方向和服务端快照对账。
- 决策:新增 `src/components/platform-entry/platformProfileWalletDeltaModel.ts`,收口 `resolveProfileWalletBalance``adjustProfileDashboardWalletBalance``reconcileProfileWalletLocalDeltaWithServerDashboard`。壳层只保留 API 请求、React ref、state 写入和刷新触发副作用。
- 影响范围:创作入口泥点展示、生成前泥点校验、扣点 / 返还后的个人 dashboard 乐观更新、后台刷新 dashboard 时的本地 delta 对账。
- 验证方式:`npm run test -- src/components/platform-entry/platformProfileWalletDeltaModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-03 Public Work Presentation 收口
- 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。
- 决策:在 `src/components/rpg-entry/rpgEntryWorldPresentation.ts` 追加单作品展示 Interface`describePlatformPublicWorkKind``formatPlatformCompactCount``resolvePlatformPublicWorkAuthorLookup``formatPlatformPublicAuthorAvatarLabel`;页面删除本地玩法类型、紧凑计数、公开作者 lookup 和头像首字实现。集合筛选、排序和指标选择继续留在 `rpgEntryPublicGalleryViewModel.ts`
- 影响范围:公开作品卡片 aria label、推荐点赞 / 改造文案、排行数值、分类主指标、搜索结果、桌面 hero 玩法 label、公开作者摘要缓存 key 与无头像首字兜底。
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryWorldPresentation.test.ts``npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|ranking|category"`、针对变更文件执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PublicWorkPresentation收口计划-2026-06-03.md`
## 2026-06-03 Profile Funds ViewModel 收口
- 背景:个人资金展示规则散在 `RpgEntryHomeView.tsx`,且账单来源 label 表漏掉后端契约已有的 `puzzle_author_incentive_claim`,会把原始枚举值直接外显。
- 决策:新增 `src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts` 作为个人资金展示 ModuleInterface 收口账单来源文案、金额正负号、余额兜底、充值价格、商品主值与会员摘要;页面保留弹窗布局、支付流程、微信渠道和订单轮询副作用。
- 影响范围:泥点账单弹窗、充值商品卡片、账户充值弹窗会员摘要。
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts``npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger|profile recharge modal shows native qr code"`、针对变更文件执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】ProfileFundsViewModel收口计划-2026-06-03.md`
## 2026-05-26 前端不外露图片模型名
- 背景:拼图与相关结果页、生成进度和错误提示里直接显示 `gpt-image-2``gemini-3.1-flash-image-preview``image-2` 等名称,会把内部模型路由暴露给普通用户。
@@ -1145,13 +1662,14 @@
- 影响范围:`module-auth``api-server` 作品作者解析、`AppState` 启动初始化、历史孤儿作品离线回填脚本与相关文档。
- 验证方式:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml``cargo test -p api-server --manifest-path server-rs/Cargo.toml work_author``npm run test -- scripts/rebind-orphan-work-owners.test.ts`
- 关联文档:`server-rs/crates/module-auth/src/domain.rs``server-rs/crates/module-auth/src/lib.rs``server-rs/crates/api-server/src/work_author.rs``scripts/rebind-orphan-work-owners.mjs`
## 2026-05-26 敲木鱼发布后作品架与推荐流刷新口径
- 背景:敲木鱼已具备公开广场投影,但草稿 Tab 的作品架没有当前用户作品列表接口,导致已发布作品在发布后不能立即出现在“已发布”筛选和推荐流里。
- 决策:新增 `GET /api/creation/wooden-fish/works` 作为当前用户木鱼作品架事实源,返回 `WoodenFishWorksResponse.items` 摘要;平台壳在发布成功后必须同时刷新作品架和公开广场列表。
- 影响范围:`server-rs/crates/api-server/src/wooden_fish.rs``server-rs/crates/api-server/src/modules/wooden_fish.rs``src/services/wooden-fish/woodenFishClient.ts``src/components/custom-world-home/creationWorkShelf.ts``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
- 验证方式:发布一个木鱼作品后,草稿 Tab 的已发布筛选应立刻出现 `WF-*` 作品卡,推荐 / 最新流也应立即刷新出公开卡片。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`
## 2026-05-27 认证快照完全去文件化并仅保留行级备查
@@ -1160,3 +1678,28 @@
- 影响范围:`server-rs/crates/api-server/src/state.rs``server-rs/crates/module-auth/src/lib.rs``server-rs/crates/spacetime-module/src/auth/procedures.rs``server-rs/crates/spacetime-client/src/auth.rs`、对应生成 bindings。
- 验证方式:`cargo check -p module-auth --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``cargo test -p module-auth password --manifest-path server-rs/Cargo.toml -- --nocapture``npm run check:spacetime-schema``npm run check:encoding``cargo test -p api-server spacetime_unavailable_router_returns_service_unavailable_for_requests --manifest-path server-rs/Cargo.toml -- --nocapture`
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-06-07 创作入口泥点消耗改由统一契约驱动
- 背景:创作入口玩法卡封面右下角长期固定显示 `10-20泥点数`,无法在后台按玩法调整,也容易和真实钱包余额或活动奖池混淆。
- 决策:`creationTypes[].unifiedCreationSpec.mudPointCost` 作为入口卡泥点消耗数量字段,旧契约缺失时后端和前端都兜底为 `10`;入口卡由前端格式化为 `X泥点数` 展示,后端和后台不保存单位文案。该字段同时作为玩法新建草稿初始生成的扣费真相源,前端余额前置校验、拼图首图生成、抓大鹅完整草稿生成和汪汪声浪初始三图生成必须读取同一份后台入口配置;结果页单图重生成、发布、道具使用和其它独立资产操作继续使用各自业务成本。
- 决策补充:后台创作入口开关页不再直接暴露统一创作契约 JSON textarea页面按契约结构展示为卡片和字段列表点击“修改契约”后通过弹窗表单编辑 `title``mudPointCost` 和 fields再组装回统一契约 payload 保存。`workspaceStage``generationStage``resultStage` 属于内部阶段标识,后台不展示也不允许编辑;保存时沿用已有契约值,新增契约时按 `playId` 的固定阶段映射自动带出。
- 影响范围:`shared-contracts``UnifiedCreationSpecResponse``/api/creation-entry/config` 响应、前端入口卡派生、后台入口开关页、玩法链路文档和创作入口回归测试。
- 验证方式:后台修改 `mudPointCost` 后保存,`GET /api/creation-entry/config` 返回同名数字字段;底部加号创作入口卡显示前端格式化后的泥点消耗;创作表单泥点不足提示和后端实际钱包扣费都使用该数字;关闭态卡片仍只显示 `暂未开放`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-05-31 拼消消底图 prompt 与 atlas 切片提示词收口
- 背景:拼消消生成资产检查时,用户需要区分主题词、场地底图主题词和复合图 atlas prompt 的职责;若小图案显式画出切分线或边框,运行态 1x1 切片会显得像错误素材。
- 决策:`boardBackgroundPrompt` 成为中央场地底图的优先 prompt 来源,只有该字段为空时才回退读取 `themePrompt`;用户上传底图时只执行平台资产持久化和换签,不用主题词重写上传资产。复合图 atlas prompt 只描述“可被服务端按等大 1x1 方格切分”,禁止模型在图案上绘制切分线、边框、网格线或裁切参考线。
- 影响范围:拼消消工作台 payload、`shared-contracts` / `packages/shared` 契约、api-server 生成编排、SpacetimeDB session/work snapshot、文档与生成进度展示。
- 验证方式:`npm run spacetime:generate``npm run check:encoding``npm run check:server-rs-ddd``cargo test -p module-puzzle-clear``cargo test -p spacetime-client puzzle_clear -- --nocapture``npm run test -- src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/services/miniGameDraftGenerationProgress.test.ts src/routing/appPageRoutes.test.ts src/services/publicWorkCode.test.ts`
- 关联文档:`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md``docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-06 统一创作页表头按契约 title 原样显示
- 背景:统一创作页长期使用固定表头 `想做个什么玩法?`,导致跳一跳等玩法希望按自身语义展示标题时只能改前端或默认契约。
- 决策:`creationTypes[].unifiedCreationSpec.title` 继续作为统一创作页表头传输字段,但读取和保存时都按契约内容原样显示和持久化,不再用入口 `title` 自动覆盖。默认 spec 可以给出玩法中文名;旧库中已经持久化为 `想做个什么玩法?` 的契约也保持原样,若需要改表头应在后台契约结构卡片中点击修改并编辑 `title` 字段。
- 影响范围:`shared-contracts` 默认 spec、`module-runtime` 入口配置响应、`spacetime-module` 后台保存校验、后台入口开关页摘要和前端 fallback spec。
- 验证方式:`GET /api/creation-entry/config` 中各玩法 `unifiedCreationSpec.title` 等于已保存契约内容;后台只修改入口名称时不应隐式改写已保存的统一创作页表头。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`

View File

@@ -205,7 +205,7 @@ npm run check:server-rs-ddd
- 使用 `npm run dev:api-server` 重新拉起后端。
- 禁止使用 `npm run api-server:maincloud``npm.cmd run api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径;这些只属于历史残留。
- 检查 `/healthz`
- 本地 smoke 检查 `/healthz`;发布后或确认实例可接生产流量时检查 `/readyz`
- 执行对应自动测试。
- 涉及 SpacetimeDB 表、reducer、procedure、row shape 或绑定变化时,同步更新 `migration.rs`、表目录和生成绑定。
- SpacetimeDB 已有表新增字段必须放在 Rust 表结构体最后,并设置明确默认值;需要修改字段名时,先询问用户并确认迁移计划,再同步更新 `server-rs/crates/spacetime-module/src/migration.rs`、表目录和生成绑定。
@@ -224,7 +224,7 @@ npm run check:server-rs-ddd
## 生产压测与观测默认口径
- 作品列表 50 HTTP req/s 压测使用 `scripts/loadtest/README.md` 中的 K6 命令;当前脚本一次 iteration 请求两个公开列表接口,因此目标 50 HTTP req/s 对应 `PEAK_RPS=25`
- 生产 `api-server` 默认 backlog、worker threads、HTTP 并发背压、systemd 限制、Nginx upstream timing log 和 OTLP 开关以 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` 为准。
- 生产 `api-server` 默认 backlog、worker threads、HTTP 并发背压、`/readyz` 接流检查、systemd 优雅停机窗口、Nginx upstream timing log 和 OTLP 开关以 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` 为准。
- OpenTelemetry 现阶段可选发送 traces / metrics / logs但不会取代本地 `journalctl -u genarrative-api.service``logs/api-server/``/var/log/nginx/genarrative.*.log`
- 指标 label 不写 raw URI、userId、profileId 或 request_idrequest_id 只用于 trace/log 串联。
@@ -243,7 +243,7 @@ npm run check:server-rs-ddd
- 移动端优先,再兼容网页端。
- 页面只展示后端返回的状态,不自行计算结论型业务状态。
- 创作中心入口配置事实源在 SpacetimeDB通过 `GET /api/creation-entry/config` 下发;前端只在 `platformEntryCreationTypes.ts` 做展示派生api-server 路由熔断也使用同一份配置,禁止恢复前端硬编码入口配置文件。
- 创作中心入口配置事实源在 SpacetimeDB通过 `GET /api/creation-entry/config` 下发;前端只在 `platformEntryCreationTypes.ts` 做展示派生api-server 路由熔断也使用同一份配置,禁止恢复前端硬编码入口配置文件。底部加号创作入口页公告位也跟随后端 `eventBanners` 配置,前端只做展示和轮播;后台公告用表单维护标题与 HTML 内容,保存时再序列化为后端 `eventBannersJson` 传输字段。`最近创作` 不属于模板分类,不能作为分类缺失兜底;生成中和生成失败的真实草稿摘要都应进入最近创作。
- 一期统一创作页字段 spec 同样跟随 `GET /api/creation-entry/config`,由 `creationTypes[].unifiedCreationSpec` 下发;拼图、抓大鹅、敲木鱼之外的模板不接入该扩展位,前端只保留旧后端缺字段时的兜底默认。
- 优先复用现有面板、抽屉、弹窗,不新建独立大系统。
- 不在 UI 中默认写功能说明类文本。

View File

@@ -13,6 +13,7 @@
| 创作入口、草稿架和玩法链路 | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` |
| 创作流程统一阶段计划 | `docs/planning/【玩法创作】创作流程统一总计划-2026-05-30.md` |
| 本地启动、验证、部署、埋点和运营查询 | `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` |
| 微信小程序虚拟支付 | `docs/【技术方案】微信虚拟支付接入-2026-05-26.md` |
| UI 像素资产与 9-slice 规范 | `UI_CODING_STANDARD.md` |
## 阅读顺序

View File

@@ -1,4 +1,4 @@
# 踩坑与排障记录
# 踩坑与排障记录
> 用途:记录已验证、未来很可能再次遇到的问题。每条都应包含现象、原因、处理方式和验证方式。
@@ -15,6 +15,30 @@
- 关联:相关文件、文档、提交或 Issue
```
## 新建草稿扣费不能和入口卡泥点配置分离
- 现象:后台修改创作入口的 `mudPointCost` 后,入口卡和前置余额提示可能显示新数值,但用户真实钱包流水仍按代码常量扣除。
- 原因:早期约定把 `creationTypes[].unifiedCreationSpec.mudPointCost` 只当展示字段,拼图、抓大鹅和汪汪声浪初始生成各自保留了 `2``10`、三次单图 `1` 的硬编码扣费路径。
- 处理:新建草稿初始生成成本必须统一从 `GET /api/creation-entry/config``unifiedCreationSpec.mudPointCost` 解析;前端预校验、拼图首图生成、抓大鹅完整草稿生成和汪汪声浪初始三图生成同源。汪汪声浪结果页单图重新生成仍按单图资产操作成本,不套初始草稿总成本。
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "mud points"``npm run test -- src/services/bark-battle-creation/barkBattleCreationClient.test.ts``cargo test -p api-server --manifest-path server-rs/Cargo.toml resolves_mud_point_cost initial_generation_slot_cost_splits_creation_entry_total_cost -- --nocapture`
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``server-rs/crates/api-server/src/creation_entry_config.rs``server-rs/crates/api-server/src/puzzle/handlers.rs``server-rs/crates/api-server/src/match3d/draft.rs``server-rs/crates/api-server/src/bark_battle.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## generated 图片重复下载不要改成服务端本地磁盘缓存
- 现象:同一张 OSS generated 图片每次展示都重新从 OSS 拉取,或者完整 OSS 私有 URL 裸请求返回 403。
- 原因:前端输入如果是 `https://*.oss-*.aliyuncs.com/generated-*`,会被当普通绝对 URL 直连,绕过 `/api/assets/read-url` 和 signed URL 本地缓存;旧 OSS 对象如果缺少 `Cache-Control`,浏览器只能依赖 `ETag` / `Last-Modified` 做 304 协商缓存,不会长期强缓存。
- 处理:完整 OSS generated URL 先归一成 `/generated-*` legacy public path再走 `/api/assets/read-url` 换签;`refreshKey` 是 signed URL 缓存版本号,同一路径、同一版本且未临近过期时必须复用,不要每次渲染都强制重新换签。新上传 generated 私有对象由 `platform-oss``PostObject` form fields / policy 和服务端 `PutObject` 请求头中写入 `Cache-Control: public, max-age=31536000, immutable`。不要把 api-server 变成图片静态代理,也不要把 OSS 内容 fallback 到服务器磁盘。
- 验证:前端测试应看到完整 OSS generated URL 调用 `/api/assets/read-url?legacyPublicPath=...`,且相同 `refreshKey` 不重复换签;`cargo test -p platform-oss --manifest-path server-rs/Cargo.toml` 应覆盖 `Cache-Control` policy、form field、PutObject headers 和 V4 `AdditionalHeaders`;线上旧对象可用 `curl -I` 观察是否只有 `ETag` / `Last-Modified` 或已经补齐 `Cache-Control`
- 关联:`src/services/assetReadUrlService.ts``server-rs/crates/platform-oss/src/lib.rs``server-rs/crates/platform-oss/README.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 小程序 H5 导航不能清掉宿主 query
- 现象:微信小程序首次进入 H5 后,点击需要登录的入口没有返回小程序原生授权页,而是弹出 Web 端登录窗口;充值渠道也可能被误判为普通网页环境。
- 原因:小程序 `web-view` 入口通过 `clientType=mini_program``clientRuntime=wechat_mini_program``miniProgramEnv` 标记宿主环境,但 H5 内部 `pushAppHistoryPath(...)` 阶段导航会默认清空 query首点时微信 JS bridge 也可能尚未就绪,导致 `isWechatMiniProgramWebViewRuntime()` 和充值平台判断读不到小程序上下文。
- 处理:路由层统一把 `clientType``clientRuntime``miniProgramEnv` 当作 app runtime context在普通路径归一、显式 query 路由和同一创作流跳转时都跨导航保留;小程序环境识别同时用 `MicroMessenger + miniProgram` User-Agent 兜底首点 bridge 未就绪场景;创作恢复参数仍只在同玩法创作流内保留,离开创作流时继续清理。
- 验证:`npm exec vitest run src/routing/appPageRoutes.test.ts src/components/auth/AuthGate.test.tsx src/services/authService.test.ts src/services/payment/paymentPlatform.test.ts`
- 关联:`src/routing/appPageRoutes.ts``src/services/authService.ts``src/services/payment/paymentPlatform.ts``docs/【项目基线】当前产品与工程约束-2026-05-15.md`
## 平台异步错误必须带来源弹窗,不要只显示裸错误
- 现象:用户先后触发多个拼图或草稿生成时,旧请求失败后会在当前页面显示“图片生成失败”等裸错误,容易误判为当前正在看的拼图失败;错误文本也不便复制给开发排查。
@@ -23,20 +47,45 @@
- 验证:触发任一平台级异步失败时,页面应出现包含“错误来源”和“错误内容”的弹窗;复制内容应包含来源和错误正文;旧页面内错误 banner 不再重复出现。
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/platform-entry/PlatformErrorDialog.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 自定义世界旧公开作品不要用 published_at 判断是否存在
- 现象RPG / 自定义世界作品详情能打开,但点赞时报 `custom_world 已发布作品不存在,无法点赞`,错误来源是 `作品详情 CW-*` 或其它自定义世界历史公开号。
- 原因:部分历史 `custom_world_profile` 已是 `publication_status=Published`,但 `published_at` 为空;统一公开详情会用 `updated_at` 兜底展示,旧点赞 / 游玩 / Remix 判断却额外要求 `published_at.is_some()`
- 处理:公开互动存在性统一按 `Published + deleted_at=None + visible=true` 判断;`custom_world_gallery_entry` 同步和公开展示时间在 `published_at` 缺失时回退 `updated_at`
- 验证:`cargo test -p spacetime-module custom_world_public_interactions_accept_legacy_missing_published_at --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/spacetime-module/src/custom_world.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`
## 推荐页 WF 点赞不要落到 RPG / custom-world
- 现象:推荐页里给 `WF-*` 敲木鱼作品点赞时,平台错误弹窗显示 `custom_world 已发布作品不存在,无法点赞`
- 原因:推荐页点赞统一走 `likePublicWork`,但敲木鱼尚未接入点赞后端;缺少 `wooden-fish` 分支时会落入默认 RPG / custom-world 点赞路径,把敲木鱼的 owner/profile 传给 custom-world reducer。
- 处理:所有公开作品互动必须先按 `packages/shared/src/contracts/playTypes.ts` 中的全局 `sourceType` 分流;暂未接入点赞的玩法直接报“该作品类型暂不支持点赞”,禁止显示开放兜底文案,也禁止用默认 RPG / custom-world 分支兜底。
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation wooden fish like does not call RPG gallery like"`
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`
## 暗色创作进度卡不要被 platform-remap-surface 改成深色文字
- 现象:统一创作页里的暗色进度卡背景是深绿 / 深蓝,但“创作进度”、百分比和进度提示显示成深色,移动端几乎看不清。
- 原因:`platform-remap-surface` 在浅色主题下会把后代 `[class*='text-white']` 强制重映射成 `var(--platform-text-strong)`,并且使用 `!important`;暗色 hero 卡片如果只写通用 `text-white*`,刷新后仍会被全局 remap 覆盖成深色。早期还混用了 `text-white/72``text-white/88``border-white/14``bg-white/12` 等不稳透明度档位,进一步放大了问题。
- 处理:给暗色 hero 加组件专属 class例如 `creation-agent-hero__progress-label``creation-agent-hero__progress-value``creation-agent-hero__progress-hint`,并在 `src/index.css` 的 remap 规则之后用更具体选择器和 `!important` 固定白色透明度、边框和进度条底色。
- 验证:`CreationAgentWorkspace` 测试应断言进度标题、百分比和提示文本带专属 class`src/index.test.ts` 应断言这些 class 在 remap surface 内有白色覆盖规则;移动端截图中暗色卡片文字应保持可读。
- 关联:`src/components/creation-agent/CreationAgentWorkspace.tsx``src/components/creation-agent/CreationAgentWorkspace.test.tsx``src/index.css``src/index.test.ts``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## VectorEngine 图片生成 SendRequest 超时要按传输失败排查
- 现象:`external_api_call_failure` 里看到 `failureStage=request_send``timeout=true``statusCode=null``errorSource=client error (SendRequest)`,前端只知道图片生成失败。
- 现象:`external_api_call_failure` 里看到 `failureStage=request_send``timeout=true``statusCode=null``errorSource` 可能是 `client error (SendRequest)` 或更完整的 reqwest 底层错误链,前端只知道图片生成失败。
- 原因:`timeout=true` 来自 `reqwest::Error::is_timeout()`,不是业务代码固定写死;`SendRequest` 是 Hyper 发送请求阶段的错误来源标签,只说明请求未拿到可归类的 HTTP 响应,不会包含上游 JSON 错误体。
- 处理:先按 `provider/failureStage/statusClass` 聚合,再用 `user_id` / `profile_id``metadata_json.userId/profileId` 定位触发者草稿 / 作品;`request_send + timeout=true` 优先查请求体大小、参考图数量、出口网络、代理/Nginx、VectorEngine 当时可用性和同一 request_id 日志。若记录有 `502``429 moderation_blocked`,按上游网关或审核失败另行处理,不要归到传输超时。
- 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触发者 `user_id` 和可用的 `profile_id`
- 关联`server-rs/crates/api-server/src/external_api_audit.rs``server-rs/crates/api-server/src/openai_image_generation.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
- 处理:先按 `provider/failureStage/statusClass` 聚合,再用 `user_id` / `profile_id``metadata_json.userId/profileId/requestId` 定位触发者草稿 / 作品和同一次 HTTP 请求`request_send + timeout=true` 优先查 provider 日志的 `source_chain`请求体大小、参考图数量、出口网络、代理/Nginx、VectorEngine 当时可用性和同一 request_id 日志。当前 `platform-image``request_send``timeout` / `connect` 错误最多重试 3 次multipart `/v1/images/edits` 每次重试都必须重建 form看到 `VectorEngine 图片请求发送失败,准备重试` 只是单次 attempt 失败,最终 `external_api_call_failure` 才代表该用户请求整体失败。若记录有 `502``429 moderation_blocked`,按上游网关或审核失败另行处理,不要归到传输超时。
- 拼图关卡资产生成按 `level_scene -> ui_spritesheet -> level_background` 顺序执行,每个资产会输出 `slot``asset_kind``elapsed_ms`;排查拼图草稿失败时优先看同一 request_id 下最后一个失败 slot
- 验证`cargo test -p platform-image --manifest-path server-rs/Cargo.toml vector_engine_image_edit_retries_send_timeout_once_and_succeeds``cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触发者 `user_id` 和可用的 `profile_id`
- 关联:`server-rs/crates/platform-image/src/vector_engine/client.rs``server-rs/crates/api-server/src/external_api_audit.rs``server-rs/crates/api-server/src/openai_image_generation.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## “我的”页每日任务卡不要硬编码进度
## “我的”页每日任务卡不要硬编码进度,也不要跨日保留旧状态
- 现象:用户完成或领取每日任务后,任务中心弹窗里的任务状态已经变化,但“我的”页卡片仍显示 `0 / 1` 和“去完成”。
- 原因:卡片首版只写了静态展示文案,没有读取 `/api/profile/tasks` 返回的 `ProfileTaskCenterResponse`,领取接口返回的新 `center` 也只用于弹窗。
- 处理:进入“我的”页时读取任务中心,卡片用当前可操作任务或已领取任务派生奖励、进度条和操作状态;`claimRpgProfileTaskReward(...)` 成功后用响应里的 `center` 覆盖本地任务中心。
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应覆盖卡片从后端任务摘要显示 `1 / 1`领取后显示已完成。
- 原因:卡片首版只写了静态展示文案,没有读取 `/api/profile/tasks` 返回的 `ProfileTaskCenterResponse`,领取接口返回的新 `center` 也只用于弹窗;后来虽然后端按北京时间 0 点切换业务日,但前端停留在“我的”页时不会跨日刷新,可能继续展示上一日已领取状态
- 处理:进入“我的”页时读取任务中心,卡片用当前可操作任务或已领取任务派生奖励、进度条和操作状态;`claimRpgProfileTaskReward(...)` 成功后用响应里的 `center` 覆盖本地任务中心;停留在“我的”页跨过北京时间 0 点时,先非阻断 refresh 登录态写入新业务日 `daily_login`,再重拉任务中心
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应覆盖卡片从后端任务摘要显示 `1 / 1`领取后显示已完成,以及北京时间 0 点自动 refresh 后重拉任务中心
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``docs/【项目基线】当前产品与工程约束-2026-05-15.md`
## “我的”页不要恢复旧的填邀请码次级按钮
@@ -79,6 +128,14 @@
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft"`,并确认恢复生成中草稿后 `getPuzzleAgentSession` 不会因为进度刷新继续连发。
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/platform-entry/usePlatformCreationAgentFlowController.ts``src/components/platform-entry/usePlatformCreationAgentFlowController.test.tsx`
## 小游戏恢复生成页不要只用请求 busy 判定是否生成中
- 现象:敲木鱼作品架里的生成中草稿点击进入生成页后,页面会显示“重新生成草稿”按钮,而不是继续显示素材生成中的等待态。
- 原因:平台壳恢复 `generationStatus=generating` 草稿时会把 `isBusy` 置回 false只保留 `MiniGameDraftGenerationState` 作为生成事实;生成页如果只把请求 busy 传给 `isGenerating`,共用生成页会误判为空闲态并展示重试按钮。
- 处理:小游戏生成页的 `isGenerating` 必须由 `isBusy || isMiniGameDraftGenerating(generationState)` 推导;跳一跳、拼消消、敲木鱼等从作品架恢复的生成页都要使用同一口径。
- 验证:`npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts` 应覆盖 `busy=false` 但敲木鱼 generation state 仍在生成中时继续隐藏重试入口。
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/unified-creation/UnifiedGenerationPage.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 拼图试玩恢复 query 必须先切到运行态路径再写
- 现象:拼图试玩或正式运行态打开后,刷新会停在“正在进入拼图关卡”,或地址栏只有 `runtimeProfileId`,缺少草稿 `runtimeSessionId`
@@ -87,6 +144,30 @@
- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t \"puzzle draft generation auto starts trial and runtime back opens draft result\"`,确认 `window.location.pathname === '/runtime/puzzle'``window.location.search` 同时包含 `runtimeProfileId``runtimeSessionId`
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/services/puzzleRuntimeUrlState.ts``src/routing/appPageRoutes.ts``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 拼消消草稿试玩不能只测 swap 回调
- 现象:拼消消结果页和 runtime shell 的单测都能通过,但真实页面里卡片只是交换,完全不会消除,顶部准备区还会因为已知的卡背占位路径显示坏图。
- 原因:草稿试玩走的是前端本地 runtime早期测试只覆盖了 `onSwapCards` 回调和局部状态,没有验证完整的消除、重力补牌、关卡完成和资源兜底链路;同时顶部卡背对 `puzzle-clear-card-back.webp` 这类已知缺失资源没有前置回退。
- 处理:草稿试玩的回归测试必须覆盖“交换 -> 完整图案消除 -> 补牌 -> 关卡完成”闭环,并在组件测试里验证真实点击/拖拽序列;顶部准备区卡背遇到已知占位路径时直接回退到 `puzzle.webp` 这类可用参考图,不等图片加载失败后再兜底。
- 验证:`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx` 通过,浏览器 smoke 页实测可完成一次消除并弹出“本关完成”。
- 关联:`src/services/puzzle-clear/puzzleClearLocalRuntime.ts``src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts``src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx``src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`
## 拼消消消除过渡不能隐藏已有卡片的最终下沉格
- 现象:消除补牌过程中偶尔看起来下方有空位,但同列上方卡片没有落下来。
- 原因:后端和本地 runtime 的重力补牌已经把已有卡片压到底;真正的问题在前端过渡层。消除动画曾按旧消除坐标隐藏棋盘格,掉落动画也曾隐藏所有 drop 目标格。当某个旧卡下沉到刚被消除的格子时,最终 snapshot 里的真实卡片会被隐藏,视觉上像补牌没有落下。
- 处理:消除 / 掉落覆盖层只负责动画表现,不再隐藏已有场上卡片的最终格;只有从顶部准备区新补入、前一帧棋盘不存在的卡片,才允许临时隐藏底层目标格来配合下落动画。
- 验证:`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx -t "已有卡片因重力下沉时目标格不被过渡状态隐藏成空位"`,并保留领域侧 `cargo test -p module-puzzle-clear refill --manifest-path server-rs/Cargo.toml`
- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx``src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx``server-rs/crates/module-puzzle-clear/src/application.rs``docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`
## 拼消消完整消除反馈不要让补牌抢帧
- 现象:玩家正确拼完整组后,卡片几乎瞬间消失,顶部补牌马上出现或下落,导致“拼对了”的确认反馈很弱。
- 原因:前端一收到新 snapshot 就同时播放消除和掉落叠层,旧消除动画时长较短;新补入卡牌的下落延迟接近 0ms视觉上会抢在消除反馈之前开始。
- 处理:局部正确拼合但未消除时只给锁定组做一次高光;完整消除时让旧卡片在消除叠层中短暂放大展示再淡出;新补入卡牌的下落延迟到淡出尾段,并继续只隐藏新补入目标格,不隐藏已有场上卡片下沉后的最终格。
- 验证:`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`,浏览器里确认局部拼合会闪、完整消除会放大淡出、补牌在淡出后段才开始掉落。
- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx``src/index.css``src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`
## 首页推荐分流参数不能条件性调用 hook
- 现象:桌面首页或移动首页在 HMR、断点切换或重新渲染后直接报 React hook 顺序错误,页面停在“正在加载内容”。
@@ -106,10 +187,33 @@
## 玩法入口分类字段缺失要前端兜底
- 现象:平台创作入口初始化时,`platformEntryCreationTypes.ts` 直接对 `creationTypes[].categoryId` / `categoryLabel``trim()`,一旦后端旧数据、局部 mock 或异常返回里缺字段,整个创作页会在 `derivePlatformCreationTypes(...)` 里直接炸掉。
- 处理:`normalizeCategoryId(...)``normalizeCategoryLabel(...)` 必须接收可空值,并分别回退到 `recent` / `最近创作`。前端这里是展示派生层,不能要求所有历史配置都先补齐字段
- 处理:`normalizeCategoryId(...)``normalizeCategoryLabel(...)` 必须接收可空值,并分别回退到 `recommended` / `热门推荐`;历史 `recent` / `最近创作` 也要归一到推荐分类。`最近创作` 不属于模板分类页签,只能由真实草稿 / 作品架后端数据决定是否展示
- 验证:`npm test -- src/components/platform-entry/platformEntryCreationTypes.test.ts`,再打开本地创作页确认能正常进入创作 Tab。
- 关联:`src/components/platform-entry/platformEntryCreationTypes.ts``src/components/platform-entry/platformEntryCreationTypes.test.ts``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 创作入口公告不要恢复前端固定两卡
- 现象:点击底部加号进入的创作入口页只展示固定的拼图 / 抓大鹅主题卡,后台改公告表单后前台没有变化。
- 原因:前端重新硬编码 banner 列表,绕过了 `GET /api/creation-entry/config``eventBanners` 配置。
- 处理:创作入口页公告位优先读取后端 `eventBanners` 数组,多条自动轮播;旧 `eventBanner` 只做单条兼容兜底。后台主格式是标题与 HTML 内容表单,保存时序列化为后端 `eventBannersJson` 传输字段,只允许受控 HTML 片段经空权限 iframe 展示,不执行 JSX 或直接 DOM 注入。
- 验证:后台保存两条以上公告后,点击底部加号进入创作入口页应自动轮播这些后台配置项;`CustomWorldCreationHub` 相关测试应断言标题来自后端配置。
- 关联:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx``server-rs/crates/module-runtime/src/application.rs``apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx`
## 创作入口 banner 默认图片路径必须真实存在
- 现象:创作页顶部 banner 返回旧结构化 `eventBanner` 时,前端 `<img>` 请求 `/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png`,但 `public/` 下没有该文件,导致 banner 背景图加载失败。
- 原因:旧库 `event_banners_json=None` 时,读取层把旧单条结构化 banner 当成 `eventBanners` 优先数组下发;同时旧结构化默认 `coverImageSrc` 指向已经不存在的品牌素材路径。
- 处理:`module-runtime``event_banners_json` 缺失或不可解析时回到默认公告数组;默认 HTML 公告和旧结构化默认 `coverImageSrc` 都引用 `public/` 下真实存在的 `/creation-type-references/puzzle.webp`
- 验证:`cargo test -p module-runtime creation_entry_event_banners_none_returns_default_announcements --manifest-path server-rs/Cargo.toml`;重启本地 `api-server``GET /api/creation-entry/config``eventBanners[0]` 不再指向缺失的 `/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png`
- 关联:`server-rs/crates/module-runtime/src/application.rs``server-rs/crates/module-runtime/src/domain.rs``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 移动端草稿卡不要长按选中文字
- 现象:移动端草稿页长按作品卡标题或摘要时触发系统文字选区,容易误触并打断作品架操作。
- 处理:移动端只对 `#platform-tab-panel-saves .creation-work-card` 禁止 `user-select``-webkit-touch-callout`;输入框、文本域和 `[contenteditable='true']` 保留文本选择能力,避免破坏真实编辑场景。
- 验证:移动端草稿页长按普通作品卡文字不出现系统选区;`src/index.test.ts` 应覆盖 CSS 选择器和可编辑控件例外。
- 关联:`src/index.css``src/index.test.ts``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 草稿页未读点不要继续用红色 literal
- 现象:草稿页底部 Tab 和作品架的未读点视觉上仍像红点,或 glow 仍带红色阴影,和平台暖棕体系不一致。
@@ -123,7 +227,7 @@
- 现象:创作 Tab 两列玩法卡上图能看到,但标题、描述或预计消耗泥点在白底信息区里看不见,或只剩泥点小图标。
- 原因:旧 `platform-creation-reference-card` 是给暗图蒙版卡用的全局样式,会把卡片及全部子元素强制成白色文字;参考图要求的是“上图 + 下方白底信息区”,继续复用旧类会让白底上的文字消失。
- 处理:创作 Tab 首屏模板卡使用独立 `creation-template-card``creation-template-card__body``creation-template-card__title``creation-template-card__subtitle``creation-template-card__cost` 结构,不挂 `platform-creation-reference-card`;旧弹层如果仍是暗图蒙版卡,可以继续保留旧类。
- 验证:浏览器创作 Tab 中每张卡都应显示标题、描述和“预计消耗 10-20 泥点”`npm test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx -t "creation start card renders reference-aligned banner and template metadata"` 应通过。
- 验证:浏览器创作 Tab 中每张开放态卡都应显示标题、描述和后台契约 `mudPointCost` 数量经前端格式化后的泥点消耗文案;旧契约缺字段时兜底显示 `10泥点数``npm test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx -t "creation start card renders reference-aligned banner and template metadata"` 应通过。
- 关联:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx``src/index.css``src/components/custom-world-home/CustomWorldCreationHub.test.tsx`
## 创作首屏开放态卡片不要再显示左上状态标签
@@ -158,7 +262,6 @@
- 验证:`PlatformEntryFlowShellImpl.tsx` 中不应再出现四个旧工作台的入口渲染分支,创作 Tab 与 `/creation/<play>` 仍能正常进入对应工作台。
- 关联:`src/components/unified-creation/UnifiedCreationWorkspace.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## Jenkinsfile 开头不能带 UTF-8 BOM
- 现象:`Genarrative-Stdb-Module-Publish``Pipeline script from SCM` 读取 `jenkins/Jenkinsfile.production-stdb-module-publish` 后,流水线还未进入任何 stage 就失败,报 `java.lang.NoSuchMethodError: No such DSL method 'pipeline'`,堆栈位置是 `WorkflowScript.run(WorkflowScript:1)`
@@ -323,6 +426,7 @@
## Windows provision 下载截断要断点续传而不是回退目标机下载
- 当前状态已废弃。2026-06-01 起生产 Jenkins 流水线统一切到 Linux agent`Genarrative-Server-Provision` 不再维护 Windows 下载阶段。
- 现象:`Genarrative-Server-Provision``Download Provision Tool Archives` 阶段出现 `curl: (18) end of response ... bytes missing`,常见于 `otelcol-contrib_0.151.0_linux_amd64.tar.gz` 等 GitHub release 大文件。
- 原因:这是 Windows Jenkins 节点到 GitHub 的响应体被截断;若每轮都删除 `.download` 临时文件,就会丢掉已下载部分,下一次又从头开始。
- 处理Windows 下载函数保留 `${Output}.download``curl` 失败时下一轮使用 `-C -` 断点续传;最终只以 GitHub release asset 的 SHA256 `digest` 作为放行条件,完整返回但 digest 不匹配才删除临时文件重新下载。不要把 SpacetimeDB 或 `otelcol-contrib` 下载挪回 Linux 目标机。
@@ -361,12 +465,11 @@
- 验证:`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`
## 外部 API 失败没法追溯先查 external_api_call_failure
- 现象VectorEngine 图片生成 / 编辑接口对前端只表现为 `502` / `504` 或“上游服务请求失败”,但难以区分是请求发送失败、上游 429/5xx、响应解析失败、未返回图片还是下载图片失败。
- 原因:外部 API 失败如果只靠普通日志,不一定能和 OTLP 指标、trace 与 SpacetimeDB 历史查询稳定关联;重启后也容易丢失上下文。
- 处理:先查 OTLP 指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,再查 `tracking_event``event_key = 'external_api_call_failure'``metadata_json`。当前通用 VectorEngine `gpt-image-2-all` 适配器会记录 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModelrawExcerpt。
- 处理:先查 OTLP 指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,再查 `tracking_event``event_key = 'external_api_call_failure'``metadata_json`。当前通用 VectorEngine `gpt-image-2-all` 适配器会记录 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、errorSource、latencyMs、promptChars、referenceImageCount、imageModelrawExcerpt 和 requestId
- 验证:`SELECT event_id, scope_id AS provider, metadata_json, occurred_at FROM tracking_event WHERE event_key = 'external_api_call_failure' ORDER BY occurred_at DESC LIMIT 50;`;如果查不到同时看 tracking outbox 目录权限和 sealed 文件是否堆积。
- 关联:`server-rs/crates/api-server/src/external_api_audit.rs``server-rs/crates/api-server/src/openai_image_generation.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
@@ -561,10 +664,18 @@
- 现象:用户通过“忘记密码”重设密码后,接口返回成功或页面进入登录态,但再次使用新密码登录仍提示“手机号或密码错误”;重启后还可能出现 `Bearer JWT 版本已失效`,日志里的 token version 与本地快照不一致。
- 原因:重置/修改密码会更新 `password_hash``password_login_enabled``token_version`,如果 API 层只更新本地 `InMemoryAuthStore`,没有调用 `sync_auth_store_snapshot_to_spacetime()``api-server` 重启时可能从旧的 SpacetimeDB 表或旧快照恢复账号状态。
- 处理:`POST /api/auth/password/change``POST /api/auth/password/reset` 成功后必须同步认证快照。2026-05-27 起,启动恢复只允许从 SpacetimeDB 正式认证表恢复;`auth_store_snapshot` 只保留行级记录,不再写 `default` 聚合单行,也不再把本地文件 `auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 当作恢复源。若启动时连不上 SpacetimeDB`api-server` 等待启动恢复超时后进入依赖不可用模式,所有请求返回 `503 SERVICE_UNAVAILABLE``details.reason = "spacetime_startup_unavailable"`
- 处理:`POST /api/auth/password/change``POST /api/auth/password/reset` 成功后必须同步认证快照。2026-05-27 起,启动恢复只允许从 SpacetimeDB 正式认证表恢复;`auth_store_snapshot` 只保留行级记录,不再写 `default` 聚合单行,也不再把本地文件 `auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 当作恢复源。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前成功同步 SpacetimeDB同步失败时接口返回错误不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后避免认证没落库时先写出钱包或邀请关系。若启动时连不上 SpacetimeDB`api-server` 等待启动恢复超时后进入依赖不可用模式,所有请求返回 `503 SERVICE_UNAVAILABLE``details.reason = "spacetime_startup_unavailable"`
- 验证:执行 `cargo test -p module-auth password --manifest-path server-rs/Cargo.toml``cargo test -p api-server password --manifest-path server-rs/Cargo.toml`;手测时重设密码后旧密码应失败,新密码应成功,重启后仍应保持。
- 关联:`server-rs/crates/api-server/src/password_management.rs``server-rs/crates/api-server/src/state.rs``docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md`
## 密码登录失败且短信登录提示手机号已存在先查孤儿手机号索引
- 现象:老账号用密码登录提示“手机号或密码错误”,改用短信验证码登录又提示“手机号已存在 / 已注册”,用户卡在既不能登录也不能重新创建的状态。
- 原因:历史版本或停服务时认证同步不完整,可能在 SpacetimeDB `auth_identity(provider=phone)``module-auth` 快照里留下 `phone_to_user_id` 映射,但对应 `user_account` / `users_by_username` 用户行已经不存在。密码登录按手机号索引找不到真实用户,短信登录尝试创建新用户时又被孤儿手机号索引挡住。
- 处理:`export_auth_store_snapshot_from_tables` 导出时必须过滤没有 `user_account` 的 phone / wechat identity、union 索引和 refresh session`module-auth` 从 JSON 快照恢复时也必须二次丢弃指向不存在用户的索引。运行时创建手机号用户前若发现手机号映射指向不存在的用户,应删除孤儿映射后继续创建,避免死锁态继续扩散。
- 验证:`cargo test -p module-auth snapshot_json_drops_orphan_phone_index_before_phone_login --manifest-path server-rs/Cargo.toml``cargo test -p module-auth phone --manifest-path server-rs/Cargo.toml``cargo test -p spacetime-module auth --manifest-path server-rs/Cargo.toml``cargo test -p api-server phone_login_reuses_existing_user_for_same_phone_number --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/module-auth/src/lib.rs``server-rs/crates/spacetime-module/src/auth/procedures.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 认证本地文件快照已废弃,旧 procedure 也已删
- 现象:有些旧代码和生成 bindings 里还会残留 `get_auth_store_snapshot``upsert_auth_store_snapshot``import_auth_store_snapshot`,或者把 `auth-store.json` 误当成认证恢复源。
@@ -771,8 +882,8 @@
- 现象RPG 结果页点击开局 CG 后,`POST /api/runtime/custom-world/opening-cg` 在较长等待后返回“开局 CG 故事板生成失败创建图片生成任务失败error sending request for url (https://api.vectorengine.ai/v1/images/generations)”。
- 原因:该故事板会把角色图和首幕背景图作为参考图一起传给 VectorEngine `gpt-image-2-all`,请求体和上游生成耗时都比普通单图更大;若运行中的 `api-server` 仍沿用旧 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`,或者参考图过大,会在请求发送/等待阶段被 reqwest 截断。日志里 `timeout=false connect=false request=true body=false source=client error (SendRequest)` 表示还没拿到上游 HTTP 响应,通常优先怀疑大 JSON 请求体、上游网关中断或 HTTP 协议兼容,而不是业务响应解析失败。直接请求 VectorEngine 若无效 token 可快速返回 401不能据此判断真实生图不会超时。
- 处理:开局 CG 参考图入参先压到单边 768 的 JPEG`/v1/images/generations` 保持 reqwest 默认 HTTP 协商,只有 multipart `/v1/images/edits` 单独强制 HTTP/1.1。后端图片 helper 将 `request_body_bytes`、每张参考图 Data URL 长度、`timeout/connect/body/source/rootSource/sourceChain/endpoint` 分类写入日志和 `error.details`前端优先展示 `details.reason`。修改 `.env.secrets.local` 后必须重启 `api-server``npm run dev` 终端用 `rs api-server`,否则旧进程仍按旧超时运行。
- 验证:分别运行 `cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml``cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml`;真实联调重启后再触发开局 CG若仍失败看返回的 `details.reason/source/rootSource/sourceChain/timeout/connect/body/endpoint``logs/api-server/` 同一 request_id。
- 处理:开局 CG 参考图入参先压到单边 768 的 JPEG`/v1/images/generations` 保持 reqwest 默认 HTTP 协商,只有 multipart `/v1/images/edits` 单独强制 HTTP/1.1。后端图片 helper 将 `timeout/connect/body/source/source_chain/source_chain_depth/endpoint` 分类写入日志和 `error.details`失败审计通过 `metadata_json.errorSource/requestId` 保留底层错误链和请求标识。修改 `.env.secrets.local` 后必须重启 `api-server``npm run dev` 终端用 `rs api-server`,否则旧进程仍按旧超时运行。
- 验证:分别运行 `cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml``cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml`;真实联调重启后再触发开局 CG若仍失败看返回的 `details.errorSource/source/timeout/connect/body/endpoint``tracking_event.metadata_json.errorSource/requestId``logs/api-server/` 同一 request_id。
- 关联:`server-rs/crates/api-server/src/custom_world_ai.rs``server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs``server-rs/crates/api-server/src/openai_image_generation.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 开局 CG 成功后又变空白要保留 profile.openingCg
@@ -937,8 +1048,8 @@
## 拼图生成完成后图片只显示破图或 alt 文案
- 现象:拼图结果页生成完成后,“画面图”区域出现破图图标和作品名,图片无法正常预览;但打开历史拼图素材时同一张图可能可以正常预览。
- 原因:拼图正式图保存为 `/generated-puzzle-assets/*` 兼容标识,旧 `/generated-*` 直读代理已删除;如果前端没有通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签,或收到无前导斜杠的 `generated-puzzle-assets/*` object key 后未识别为 generated 私有资源,浏览器会直接请求裸路径并失败。生成完成后的结果图还会传入 `refreshKey`,它只能用于重新请求 `/api/assets/read-url`,不能给 OSS V4 签名 URL 追加 `_v`OSS 会把 query 纳入签名,额外参数会让签名失效,历史素材常因未传 `refreshKey` 而表现正常
- 处理:拼图结果页、发布预览、运行态和历史素材预览都走 `ResolvedAssetImage``useResolvedAssetReadUrl``isGeneratedLegacyPath(...)` 必须同时识别 `/generated-*``generated-*``refreshKey` 只绕过前端签名缓存并重新换签,不修改已返回的 OSS 签名 URL禁止恢复 `/generated-puzzle-assets` 直读代理。
- 原因:拼图正式图保存为 `/generated-puzzle-assets/*` 兼容标识,旧 `/generated-*` 直读代理已删除;如果前端没有通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签,或收到无前导斜杠的 `generated-puzzle-assets/*` object key 后未识别为 generated 私有资源,浏览器会直接请求裸路径并失败。生成完成后的结果图还会传入 `refreshKey`,它只能作为 signed URL 缓存版本号,不能给 OSS V4 签名 URL 追加 `_v`OSS 会把 query 纳入签名,额外参数会让签名失效。
- 处理:拼图结果页、发布预览、运行态和历史素材预览都走 `ResolvedAssetImage``useResolvedAssetReadUrl`generated 私有资源识别必须同时覆盖 `/generated-*``generated-*``https://*.oss-*.aliyuncs.com/generated-*``refreshKey` 变化时重新换签,同一路径同一 `refreshKey` 且签名未临近过期时复用已返回的 OSS 签名 URL禁止恢复 `/generated-puzzle-assets` 直读代理。
- 验证:运行 `npm run test -- src\services\assetReadUrlService.test.ts src\hooks\useResolvedAssetReadUrl.test.tsx src\components\puzzle-result\PuzzleResultView.test.tsx`,再触发一次真实生成确认 Network 中先请求 `/api/assets/read-url`,图片 `src` 为未追加 `_v` 的签名 URL。
- 关联:`src/services/assetReadUrlService.ts``src/components/ResolvedAssetImage.tsx``docs/technical/PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md`
@@ -979,6 +1090,14 @@
- 验证:`cargo test -p api-server phone_auth_sms_provider_errors_keep_upstream_http_semantics --manifest-path server-rs/Cargo.toml`,真实 provider 频控时接口不再返回 `500`
- 关联:`server-rs/crates/module-auth/src/errors.rs``server-rs/crates/api-server/src/phone_auth.rs``docs/technical/PHONE_SMS_PROVIDER_ERROR_HTTP_MAPPING_FIX_2026-05-08.md`
## 本地短信 smoke 先确认 SMS provider
- 现象:浏览器里短信验证码发送成功,但提交 `123456` 仍然报验证码错误,或者短信登录后又回到未登录态。
- 原因:当前运行中的 `api-server` 如果读取到 `.env.local` 里的 `SMS_AUTH_PROVIDER=aliyun`,就会走真实短信 provider 口径;这时 mock 验证码 `123456` 不会被接受。之前本地调试时常见的误判是把 `.env.local` 改成 mock 了,但没有重启 `npm run dev`,或者旧的 `scripts/dev.mjs` 进程还在沿用旧环境。
- 处理:本地只做 UI / 账号链路 smoke 时,把 `.env.local` 显式设为 `SMS_AUTH_PROVIDER=mock` 且配置 `SMS_AUTH_MOCK_VERIFY_CODE=123456`,然后重启 `npm run dev``npm run dev:api-server`。要做真实短信联调时,再切回 `SMS_AUTH_PROVIDER=aliyun` 并重启。
- 验证:`POST /api/auth/phone/send-code` 应返回 `providerRequestId=mock-request-id``POST /api/auth/phone/login``123456` 应返回 `200``user.loginMethod=phone`。浏览器侧短信登录成功后,会先进入邀请码弹窗或我的页面,不应再提示“验证码错误”。
- 关联:`scripts/dev-utils.mjs``scripts/dev-utils.test.ts``scripts/dev.mjs``server-rs/crates/api-server/src/config.rs`
## 手机验证码登录成功后又瞬间回到未登录
- 现象:手机号验证码登录先成功,随后 UI 又闪回“未登录”,登录弹窗可能重新出现。
@@ -1146,20 +1265,21 @@
- 验证:发布链路使用当前 `deploy/systemd``deploy/nginx``scripts/deploy``jenkins/Jenkinsfile.production-*`
- 关联:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
## Release Web 产物通过内网 rsync 拉取
## Web Deploy 只从 Jenkins 构建归档取包
- 现象:`Genarrative-Web-Deploy` 发布到 `release` 目标时,release agent 本地没有 `/var/cache/genarrative-build/web-artifacts/<job>/<build>/<version>/web.tar.gz`,但 Jenkins controller 又只归档轻量元数据,导致发布阶段找不到 Web 大包
- 原因Web 大包为了避免从 Linux 构建机拉回 Jenkins controller默认留在构建机稳定缓存目录development 目标与构建机同机可直接读取release 目标是独立机器,需要内网同步
- 处理:release 服务器的 Jenkins 运行用户配置 SSH Host `genarrative-build-internal` 指向构建机内网地址,`Genarrative-Web-Deploy` `DEPLOY_TARGET=release` 且本地缺少大包时默认执行 `rsync` 拉取同一路径内容;真实内网 IP、用户和私钥路径只放在服务器本机 SSH config不写入 Jenkinsfile
- 验证:在 release 服务器上先手工跑通 `rsync -av --progress "genarrative-build-internal:${SRC}/" "${DST}/"`,再运行 Web Deploy流水线会继续执行 `web.tar.gz.sha256` 校验
- 关联:`jenkins/Jenkinsfile.production-web-deploy``docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
- 现象:`Genarrative-Web-Deploy` 需要发布 Web 时,不应再在构建机或 release agent 本地缓存目录查找 `web.tar.gz`
- 原因Web 发布包已经由 `Genarrative-Web-Build` 归档到 Jenkins 构建产物deploy 阶段继续读本地缓存或通过 `rsync` 回构建机拉包会让 release agent 依赖机器拓扑和本地路径
- 处理:`Genarrative-Web-Build` 直接归档 `build/<version>/web.tar.gz``web.tar.gz.sha256``release-manifest.json``Genarrative-Web-Deploy` 使用 `copyArtifacts` 从指定 `BUILD_JOB_NAME` / `BUILD_NUMBER_TO_DEPLOY` 复制完整产物,不保留 `WEB_ARTIFACT_ROOT``WEB_ARTIFACT_SYNC_HOST``web-artifact-pointer.txt` 口径
- 验证:deploy 工作区应直接出现 `build/<version>/web.tar.gz``web.tar.gz.sha256`;后续仍由 `scripts/deploy/production-web-deploy.sh` 执行 checksum 校验和解压 smoke
- 关联:`jenkins/Jenkinsfile.production-web-deploy``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## Jenkins 生产流水线拉 Git 先本机再域名备用
- 后续更新:该条仍适用于常规构建 / 发布流水线;`Genarrative-Server-Provision` 已在 2026-06-05 改为服务器初始化专用口径,不允许公网 Git fallbackJob 的 `Pipeline script from SCM` 和 Jenkinsfile 内部 checkout 都必须使用本机路径或目标 agent 可访问的内网 Git 源。
- 现象:生产发布、数据库导入导出、服务器配置、构建或 `Genarrative-Full-Build-And-Deploy` 流水线执行 `GitSCM checkout` 时,如果 Jenkins 生成的 fetch 是 `+refs/heads/*:refs/remotes/origin/*`,公网 Git 链路可能在收包阶段以 `git-remote-https died of signal 15``curl 56 GnuTLS recv error (-9)``early EOF``invalid index-pack output` 失败;发布类流水线还可能先遇到 `http://127.0.0.1:3000/GenarrativeAI/Genarrative.git` 不可达。
- 原因:`127.0.0.1` 只代表当前执行阶段的 agent 自身;当 release agent 与 Git 服务不在同一台机器,或本机 Git/Web 服务临时不可用时,固定写死 localhost 会阻断 Jenkinsfile 内部源码/脚本 checkout。即使只使用域名 Git如果 `GitSCM` 没有显式 refspec 并开启 `CloneOption honorRefspec=true`Jenkins Git 插件也会拉取所有分支。
- 处理Jenkins Job 的 `Pipeline script from SCM` 由 Windows controller 执行SCM URL 使用公网域名 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。运行于 `linux && genarrative-build``Genarrative-Full-Build-And-Deploy` 源码解析阶段、`Genarrative-Web-Build` checkout 阶段,以及部署/发布类 Linux agent 的 Jenkinsfile 首次 `checkout([$class: 'GitSCM', ...])` 层先尝试 `GIT_REMOTE_URL=http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后直接尝试 `GIT_REMOTE_FALLBACK_URL=https://git.genarrative.world/GenarrativeAI/Genarrative.git`,不再配置内网 IP fallback这些首次 checkout 都必须使用目标分支 refspec、`CloneOption shallow=true depth=1 noTags=true honorRefspec=true`。后续统一走 `scripts/jenkins-checkout-source.sh`,该脚本也按主地址、域名备用地址顺序重新 fetch 并把 `origin` 切到实际可用地址;`COMMIT_HASH` 为空时继续 `--depth=1 --no-tags`只有指定 commit 时才允许加深历史做分支归属校验
- 验证:扫描本地 Jenkins live job `config.xml`,确认 SCM `<url>` 都是 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`;扫描所有生产 Jenkinsfile 的首次 `GitSCM checkout`,确认 `userRemoteConfigs``+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}``CloneOption``honorRefspec: true`;运行 `bash -n scripts/jenkins-checkout-source.sh`
- 处理Jenkins Job 的 `Pipeline script from SCM` 由 Windows controller 执行SCM URL 使用公网域名 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。运行于 `linux && genarrative-build``Genarrative-Full-Build-And-Deploy` 源码解析阶段、`Genarrative-Web-Build` checkout 阶段,以及部署/发布类 Linux agent 的 Jenkinsfile 首次 `checkout([$class: 'GitSCM', ...])` 层先尝试 `GIT_REMOTE_URL=http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后直接尝试 `GIT_REMOTE_FALLBACK_URL=https://git.genarrative.world/GenarrativeAI/Genarrative.git`,不再配置内网 IP fallback这些首次 checkout 都必须使用目标分支 refspec、`CloneOption shallow=true depth=1 noTags=true honorRefspec=true`。后续统一走 `scripts/jenkins-checkout-source.sh`,该脚本也按主地址、域名备用地址顺序重新 fetch 并把 `origin` 切到实际可用地址;`COMMIT_HASH` 为空时继续 `--depth=1 --no-tags`,指定 commit 时也先保持 `depth=1` 校验,浅历史无法证明归属时才按 `GENARRATIVE_JENKINS_CHECKOUT_DEEPEN_STEPS` 逐步加深,最后才展开完整历史。发布流水线不得为了缩短 checkout 时间清空上游构建传入的 `COMMIT_HASH`
- 验证:扫描本地 Jenkins live job `config.xml`,确认 SCM `<url>` 都是 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`;扫描所有生产 Jenkinsfile 的首次 `GitSCM checkout`,确认 `userRemoteConfigs``+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}``CloneOption``honorRefspec: true`扫描发布流水线确认传给 `scripts/jenkins-checkout-source.sh``COMMIT_HASH` 未被硬编码为空;运行 `bash -n scripts/jenkins-checkout-source.sh`
- 关联:`jenkins/Jenkinsfile.production-full-build-and-deploy``jenkins/Jenkinsfile.production-web-build``jenkins/Jenkinsfile.production-api-build``jenkins/Jenkinsfile.production-stdb-module-build``jenkins/Jenkinsfile.production-web-deploy``jenkins/Jenkinsfile.production-api-deploy``jenkins/Jenkinsfile.production-stdb-module-publish``jenkins/Jenkinsfile.production-server-provision``jenkins/Jenkinsfile.production-database-export``jenkins/Jenkinsfile.production-database-import``scripts/jenkins-checkout-source.sh``docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
## Jenkins 可选参数在 set -u 下不能裸读
@@ -1178,12 +1298,12 @@
- 验证:运行 `git ls-files --stage scripts/prepare-server-provision-tools.sh`,确认 mode 为 `100755`;重新跑 `Genarrative-Server-Provision` 时应进入工具下载/打包日志,而不是停在 `Permission denied`
- 关联:`jenkins/Jenkinsfile.production-server-provision``scripts/prepare-server-provision-tools.sh``scripts/jenkins-checkout-source.sh``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## Server-Provision 下载阶段不要放回 genarrative-build-01
## Server-Provision 工具准备只在目标部署 agent 内做一次
- 现象:`Genarrative-Server-Provision` 日志里 `Prepare Provision Tools` 显示 `Running on genarrative-build-01 in /root/...`,随后在该节点上下载 GitHub release 或 `install.spacetimedb.com` 失败
- 原因:`genarrative-build-01` 在当前 provision 流程里是 Linux 目标发布机/目标 agent不是用户本地 Windows 下载环境;把下载阶段放在 `linux && genarrative-build` 等于让目标机自己外连
- 处理:下载必须发生在 Jenkins `windows` 节点的 `Download Provision Tool Archives` 阶段,先下载 SpacetimeDB Linux release tarball 和 `otelcol-contrib` Linux amd64 包,再 `stash/unstash` 到目标 Linux 节点。目标机执行 `scripts/prepare-server-provision-tools.sh` 时设置 `PROVISION_REQUIRE_LOCAL_DOWNLOADS=true`,缺少下载件直接失败,不回退联网下载
- 验证Jenkins 日志应先出现 `Running on ... windows``[prepare-provision-downloads] 下载 ...`,目标节点只出现 `[prepare-provision-tools] 使用已下载的 ...`;如果目标节点出现 `下载 otelcol-contrib:``下载 SpacetimeDB 官方安装器脚本:`,说明又回退到错误路径
- 现象:`Genarrative-Server-Provision` 选择 `DEPLOY_TARGET=development` 时如果阶段跑在 `Running on Jenkins``linux && genarrative-build`,真实 provision 会落到构建机 / controller而不是 dev 服务器
- 原因:Server-Provision 是服务器初始化流水线dev / release 都是目标服务器,不应把 development 当成 build 节点预览目标,也不应通过 build 节点 stash 工具包再切回目标机;同时公网 Git fallback 会让目标 agent 内网源不可达时悄悄改从公网拉源码,掩盖服务器路由问题
- 处理:Server-Provision 全程运行在目标部署 agentdevelopment 使用 `linux && genarrative-dev-deploy`release 使用 `linux && genarrative-release-deploy``Prepare Provision Tools``Provision Server` 在同一个目标 agent workspace 内顺序执行,不再使用 `linux && genarrative-build`,也不再 `stash/unstash` 工具包。Job 的 `Pipeline script from SCM` 与参数 `SOURCE_GIT_REMOTE_URL` 都必须指向本机路径或内网 Git 源,不允许 `https://git.genarrative.world/...` 公网地址
- 验证Jenkins 日志`Provision Target` 下的 `Prepare``Checkout Provision Files``Prepare Provision Tools``Provision Server` 都应运行在目标 dev / release agent日志不应出现 `stash 'server-provision-tools'`、目标阶段 `unstash``Git 主地址拉取失败...改用备用地址``https://git.genarrative.world/GenarrativeAI/Genarrative.git`
- 关联:`jenkins/Jenkinsfile.production-server-provision``scripts/prepare-server-provision-tools.sh``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 个人任务 scope 不得扩成 work/site/module
@@ -1213,10 +1333,10 @@
## 拼图会过早进入待发布态,结果页可能空图但仍显示可发布
- 现象:拼图创作有时刚结束就跳到“待发布”结果页,但结果页里的正式图还是空的,发布检查随后又会拦住,用户会感觉“已经完成了却又不能发布”。
- 原因:拼图的待发布判定太弱,`build_result_preview` / `validate_publish_requirements``is_puzzle_session_snapshot_publish_ready` 只检查了作品名、简介、标签、关卡名和 cover 图,没有要求 `level_scene_image_src``ui_spritesheet_image_src``level_background_image_src` 等完整资产都齐;前端恢复链路里的 `hasRecoverableGeneratedPuzzleDraft` / `normalizeRecoveredPuzzleDraftSession` 也只要有 cover 或候选图就会把草稿当成已完成。
- 处理:待修复时要把“待发布”门槛收紧到整套拼图资产包完整,再让恢复逻辑只在完整草稿下抬高为完成态,避免半成品直接进入结果页
- 验证:当某个拼图草稿只补齐首图、但关卡背景或 UI spritesheet 仍缺失时,不应进入 `ready_to_publish`;结果页也不应把这类草稿误判为已完成
- 关联:`server-rs/crates/module-puzzle/src/application.rs``server-rs/crates/api-server/src/puzzle/tags.rs``server-rs/crates/api-server/src/puzzle/draft.rs``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/puzzle-result/PuzzleResultView.tsx`
- 原因:拼图的待发布判定太弱,`build_result_preview` / `validate_publish_requirements``is_puzzle_session_snapshot_publish_ready` 只检查了作品名、简介、标签、关卡名和 cover 图,没有要求 `level_scene_image_src``ui_spritesheet_image_src``level_background_image_src` 等完整资产都齐;历史前端恢复链路里的 `hasRecoverableGeneratedPuzzleDraft` / `normalizeRecoveredPuzzleDraftSession` 也只要有 cover 或候选图就会把草稿当成已完成。
- 处理:前端恢复链路已收口到 `platformPuzzleDraftRecoveryModel.ts`只有首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才把恢复草稿抬为完成态;后端 `build_result_preview` / `validate_publish_requirements` / `is_puzzle_session_snapshot_publish_ready` 也已收紧到同一完整资产包门槛
- 验证:当某个拼图草稿只补齐首图、但关卡背景或 UI spritesheet 仍缺失时,前端恢复链路不应把它误判为已完成,后端也不应进入 `ready_to_publish` 或返回 `publishReady=true`
- 关联:`server-rs/crates/module-puzzle/src/application.rs``server-rs/crates/api-server/src/puzzle/tags.rs``server-rs/crates/api-server/src/puzzle/draft.rs``src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts``src/components/puzzle-result/PuzzleResultView.tsx`
## WebGL 画布在高 DPR 移动端放大溢出
@@ -1452,7 +1572,7 @@
## 推荐页嵌入拼图通关结算不要放在运行态内部 absolute 层
- 现象:推荐页里玩拼图通关后,结算面板只显示上半部分,排行榜下一关按钮或相似作品卡被截断。
- 现象:推荐页里玩拼图通关后,结算面板只显示上半部分,排行榜下一关按钮被截断。
- 原因:推荐页把运行态放在滑动作品卡的视觉区内,`platform-recommend-swipe-page``platform-recommend-swipe-card__visual``platform-recommend-runtime-viewport` 都是 `overflow: hidden`;拼图通关结算如果仍是运行态内部 `absolute inset-0` 弹层,就只能在半屏卡片区域里显示。
- 处理:`PuzzleRuntimeShell``embedded` 模式下把通关结算层通过 portal 挂到 `document.body`,使用 `puzzle-runtime-modal-overlay--fixed` 页面级 fixed 浮层;非嵌入态继续使用运行态内部覆盖层。
- 验证:运行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx -t "推荐页嵌入拼图通关结算使用页面级浮层避免卡片裁剪"`,确认弹层不再位于 `.platform-recommend-runtime-viewport` 内。
@@ -1502,6 +1622,7 @@
## Windows Jenkins `powershell` step 在 Stdb module 构建里曾触发 CreateProcess error=5
- 当前状态:已废弃。`Genarrative-Stdb-Module-Build` 已切到 Linux agent不再执行 Windows PowerShell 流程。
- 现象:`Genarrative-Stdb-Module-Build` 在 Windows Jenkins 节点上报 `java.io.IOException: Cannot run program "powershell" (in directory "C:\\Users\\DSK\\.jenkins-local\\workspace\\Genarrative-Stdb-Module-Build"): CreateProcess error=5, 拒绝访问。`;日志里能看到 `durable-task` 已写出 `powershellWrapper.ps1`,但在真正启动裸 `powershell` 子进程时失败。
- 原因Jenkins durable-task 的 `powershell` step 依赖一个隐式命令解析/启动路径,在这台 Windows 本地 Jenkins 环境里会被拒绝。`powershell.exe` 本体和 workspace ACL 都是正常的,问题出在 Jenkins step 的启动方式,而不是 PowerShell 脚本内容。修复后若日志能打印 `[jenkins-powershell] exe:`,但随后仅报 `拒绝访问` / `script returned exit code 5`,通常已经不是 PowerShell 启动失败,而是 Checkout 脚本内部命令在 Windows workspace 里触发权限拒绝。若 `.jenkins-*.ps1` 里中文 `throw '[stdb-build] ...'``MissingArrayIndexExpression`,则是 Windows PowerShell 5.1 用 `-File` 解析无 BOM UTF-8 脚本时按本地 ANSI 误解码。
- 处理:把 `jenkins/Jenkinsfile.production-stdb-module-build``Checkout``Build Stdb Module` 两处 `powershell` step 收口成 `runWindowsPowerShell(...)` helper先用 `writeFile` 写出临时 `.ps1`,再用显式 `powershell.exe` 把脚本重写成 UTF-8 with BOM最后通过 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ...` 执行。这个 helper 写在 Groovy GString 里时PowerShell 的 `$path` / `$text` / `$true` 必须写成 `\$path` / `\$text` / `\$true`,否则 Jenkinsfile 会在 Groovy 编译阶段报 `unexpected token: true`。Checkout 阶段优先复用 Jenkins GitSCM 已完成的工作区结果;`COMMIT_HASH` 为空或已经等于当前 `HEAD` 时不再重复 `git fetch` / `git checkout` / `git clean`,只有确实要切到另一个指定 commit 时才补 fetch、归属校验和 checkout。
@@ -1540,14 +1661,38 @@
- 验证:`npm run test -- src/components/rpg-entry/rpgEntryWorldPresentation.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``npm run typecheck``npm run check:encoding`
- 关联:`src/index.css``src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/rpg-entry/rpgEntryWorldPresentation.ts``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 生成中草稿刷新后不要复用旧 updatedAt 当展示起点
## 公开作品卡作者行不要拼手机号或陶泥号
- 现象:拼图或抓大鹅草稿生成中刷新网页后,作品架卡片能显示等待遮罩,但进入生成页时总进度首帧直接跳到 80%+,看起来像已经跑了一大半
- 原因:前端只把持久化 `generationStatus=generating` 当作恢复生成页的条件,但恢复展示时仍沿用了作品摘要 `updatedAt` 作为伪 `startedAtMs`;同时拼图总进度又把后端 `progressPercent` 直接当作 floor导致 `86%` 之类未到首个里程碑的会话一进页就抬到 80%+
- 处理:恢复生成中的草稿时,展示起点改用“进入生成页的当前时间”;`updatedAt` 只保留给作品架排序和摘要,不再参与生成页假进度起算。拼图总进度还要忽略 `88` 以下的后端进度 floor拼图保留后端里程碑推进抓大鹅等非拼图玩法则从 `0%` 平滑起步,避免刚进页就看到 4% / 88% / 80%+
- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"``npm run test -- src/services/miniGameDraftGenerationProgress.test.ts -t "match3d draft generation starts total progress from zero"`
- 现象:发现页 / 推荐页公开作品卡作者行显示 `158****3533 · SY-00000003` 这类手机号掩码和陶泥号组合,列表卡片看起来像暴露账号标识
- 原因:`resolvePlatformWorkAuthorDisplayName(...)` 曾把公开昵称和 `publicUserCode` 拼接为 `昵称 · SY-*`,并在无法解析公开昵称时直接回退后端卡片里的 `authorDisplayName`;当后端或旧投影把手机号掩码写进展示名时,卡片会原样外露
- 处理:公开卡片作者名只取可读公开昵称;识别手机号掩码、单独 `SY-*``手机号掩码 · SY-*` 时回退为 `玩家`。作品号复制、陶泥号搜索和完整身份展示只放在详情页、搜索或明确复制入口,不塞进卡片作者行
- 验证:`npm run test -- src/components/rpg-entry/rpgEntryWorldPresentation.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/platform-entry/PlatformWorkDetailView.test.tsx`
- 关联:`src/components/rpg-entry/rpgEntryWorldPresentation.ts``src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/platform-entry/PlatformWorkDetailView.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 生成中草稿恢复要按后端时间戳计时
- 现象:拼图或抓大鹅草稿生成中刷新网页后,进入生成页的“已耗时”从 `0 秒` 重新开始;另一类旧问题是后端 `progressPercent=88` 时总进度首帧直接跳到 `88%`
- 原因:生成页恢复曾把展示态 `startedAtMs` 重置为进入页面的当前时间,导致计时不跟随后端真实生成时刻;拼图总进度也曾把后端里程碑当作百分比地板,导致步骤刚切换就抬高总进度。
- 处理:恢复生成中的草稿时,展示起点使用后端 session `updatedAt` 或作品摘要 `updatedAt``88/94/96` 只切换当前步骤,不直接作为总进度地板。总进度按已完成步骤权重加当前步骤内假进度推导,非完成态最多停在 `98%`
- 验证:`node node_modules/vitest/vitest.mjs run src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"``node node_modules/vitest/vitest.mjs run src/services/miniGameDraftGenerationProgress.test.ts`
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``src/services/miniGameDraftGenerationProgress.ts``docs/【玩法创作】拼图生成页进度口径-2026-05-23.md`
## 生成失败草稿回到作品架不能继续显示生成中
- 现象:拼图生成页已经收到 VectorEngine 图片编辑失败并进入重试态,但用户返回草稿 Tab 后同一草稿仍显示“生成中”连续触发多个拼图生成时失败后还可能只剩一条新增草稿或者只看到标题为“第1关”的半成品空壳抓大鹅后台失败时也可能没有任何通知点击草稿又像重新开始生成。
- 原因:前端失败 notice 只更新生成页局部状态pending 作品架条目在失败时被清掉或被非 `generating` 状态误映射为 `ready`;后端作品摘要也可能短暂仍是 `generationStatus=generating`。如果失败消息没有写入 notice用户离开生成页后不会弹出 `PlatformErrorDialog`;如果打开草稿只看持久化 `generating`,就会绕过失败态恢复。
- 处理:失败时按 session 保留 pending 作品架条目并标记 `failed`,失败 notice 保存错误消息并触发带来源的 `PlatformErrorDialog`;拼图契约没有 `failed` 枚举pending 拼图映射为 `idle`,同时用本地失败 notice 覆盖持久化生成中状态和旧的“正在生成”摘要。点击失败草稿应优先用 notice / 后端 session / fallback payload 组装失败生成页,不能重新从 0 秒启动新进度;失败页点击重新生成必须优先复用当前 `sessionId` 执行编译 action不得因存在表单缓存 payload 就调用 create-session。拼图失败半成品没有有效 `workTitle` 时,作品架标题回退为“拼图草稿”。
- 验证:`node node_modules/vitest/vitest.mjs run src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed parallel puzzle|background match3d"`
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/custom-world-home/creationWorkShelf.ts``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 生成失败重试不要走新建草稿
- 现象:拼图或抓大鹅生成失败后,在失败页点击“重新生成”,作品架里多出一份新的草稿,原失败草稿仍留在列表里。
- 原因:重试 handler 曾优先读取缓存的表单 payload 并调用 create-session 路径;失败草稿按 session 留在作品架是正确行为,于是重试动作额外创建了第二份草稿。
- 处理:只要当前失败页还能恢复到原 `sessionId`,重试就走该 session 的 compile action只有没有可恢复 session 时,才允许用表单 payload 重新创建草稿。
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed .* draft retry reuses current session"`
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 汪汪声浪草稿试玩不要写正式 run
- 现象:如果草稿结果页试玩和发布后 runtime 共用同一写成绩路径,未发布或未确认资源的草稿试玩会污染正式单局、排行榜和作品统计。
@@ -1595,14 +1740,53 @@
- 验证:`npm run typecheck`,并跑 `npm test -- src/routing/appPageRoutes.test.ts` 覆盖 JumpHop 阶段路径。
- 关联:`src/components/platform-entry/platformEntryTypes.ts``src/routing/appPageRoutes.ts``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryHomeView.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 跳一跳地块图集不要套通用系列素材 n 行模型
## 跳一跳地块图集固定走 5x5 地块池
- 现象:跳一跳初始草稿生成时报 `系列素材图集的物品行数不能超过 n。`,或者即使绕过报错也只生成了 atlas 预览路径,地块切片没有真正落盘。
- 原因:跳一跳地块只有 6 个固定 tileType但旧实现把它塞进通用系列素材 helper并使用 `grid_size = 3` / `item_names = 6` 的语义冲突模型;随后又只保留 atlas 资产与模拟路径,没把六个切片逐一上传并确认到 `JumpHopTileAsset`
- 处理:跳一跳地块改用专用 `2行*3列` 图集 prompt`start / normal / target / finish / bonus / accent` 顺序切 6 张 PNG并对每张切片各自走 OSS 上传、asset_object 确认和 entity bind
- 验证:`cargo test -p api-server jump_hop_tile_atlas -- --nocapture` 通过后,再看 `jump_hop.rs` 不应再调用 `build_generated_asset_sheet_prompt` 处理地块图集;公开结果里应能拿到 6 个独立 `JumpHopTileAsset`
- 现象:跳一跳初始草稿生成时报 `系列素材图集的物品行数不能超过 n。`,或者生成完成后只有 atlas 预览路径,地块切片没有真正落盘。
- 原因:旧模板先后尝试过通用系列素材 helper 和 `2x3` 六格固定 tileType但当前跳一跳已经重设计为“主题 -> 5x5 地块图集 -> 25 个等权地块池 -> 无限路径”,旧的物品行数 / 固定类型模型都会把创作链路带偏
- 处理:跳一跳地块固定生成一张 `5x5` 主题图集,后端按均匀网格切出 25 张 PNG并对每张切片各自走 OSS 上传、asset_object 确认和 entity bind不要再恢复 `2行*3列``start / normal / target / finish / bonus / accent` 六格口径
- 验证:`jump_hop.rs` 不应再调用通用物品行数模型处理地块图集;公开结果里应能拿到 25 个独立 `JumpHopTileAsset`,运行态无限路径从地块池随机取材
- 关联:`server-rs/crates/api-server/src/jump_hop.rs``docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 跳一跳宝可梦主题地块图集 safety rejection 只做专项改写
- 现象:跳一跳草稿使用“宝可梦 / Pokemon / 皮卡丘 / 精灵球”等主题时,背景底图和返回按钮可能已生成成功,但地块图集的 VectorEngine 请求返回 `Your request was rejected by the safety system`,日志里 `failure_context="跳一跳地块图集生成失败"``status=429``code="invalid_prompt"`
- 原因25 个落点图集 prompt 会把这些词放进“主题物体图集”语境,容易被上游理解为要求生成具体宝可梦角色或标志道具,触发安全拦截;这不是普通平台造型词、抠图或超时问题。
- 处理:仅在跳一跳图片生成 prompt 文本命中宝可梦相关词时做生成侧替换,把 `宝可梦 / 神奇宝贝 / 口袋妖怪 / Pokemon` 改为“原创幻想萌宠冒险道具”,把 `精灵球` 改为“彩色冒险能量球”,把 `皮卡丘 / Pikachu` 改为“黄色闪电萌宠符号”;不要把所有主题都加全局 IP 禁止约束,用户草稿标题和主题展示也不改。
- 验证:`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml` 应覆盖宝可梦词专项替换;真实联调时同一草稿重试后,地块图集请求的 prompt 不再包含宝可梦相关词。
- 关联:`server-rs/crates/api-server/src/jump_hop.rs``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 跳一跳地块切片不要按 tileType 复用资产槽位
- 现象:跳一跳生成完成后,运行态看起来仍像在显示默认几何地块,或者地块图片在加载时频闪;结果页地块池也可能只看到少量重复素材。
- 原因:`tileType` 只是路径平台的玩法类型标签25 个 atlas 切片里会重复出现 `normal / target / bonus / accent` 等类型。若后端持久化时用 `tileType` 生成 slot/path同类型切片会写入同一个 `/generated-jump-hop-assets/<profile>/<slot>/image.png`,后上传的切片覆盖先上传的切片,前端换签缓存也会读到重复或旧对象。
- 处理:后端切图后必须按 atlas 单元格写入 `tile-01``tile-25` 的唯一 slot/path前端结果页和运行态展示生成图时用 `assetObjectId` 作为 `refreshKey`,避免重生成后复用旧签名或旧图片缓存。
- 验证:`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` 应包含 `jump_hop_tile_asset_slots_are_unique_for_twenty_five_slices`;前端运行态测试应断言地块换签带 `assetObjectId` 刷新键。
- 关联:`server-rs/crates/api-server/src/jump_hop.rs``src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx``src/components/jump-hop-result/JumpHopResultView.tsx`
## 跳一跳落点辅助标识不要再用舞台高度常量拍脑袋投影
- 现象:拖拽时落点辅助标识虽然会动,但看起来像静态点位漂移,和真实可落地的位置对不上。
- 原因:辅助标识如果只按 `stageSize.height` 和一个固定比例估算投影距离,再去跟拖拽向量合成,就会和当前地块到目标地块的真实屏幕跨度脱节;三维场景层级过高时还会把辅助点直接盖住。
- 处理:辅助标识必须使用当前地块与目标地块之间的真实屏幕距离和后端 `chargeToDistanceRatio` 做投影,再映射到屏幕坐标;同时把辅助层 z-index 放到三维角色层之上,避免被场景层遮挡。
- 验证:拖拽半程时辅助点应落在当前地块和目标地块之间,完整拖拽时应逼近目标地块中心;运行态截图里辅助点必须始终压在地块与角色之上。
- 关联:`src/services/jump-hop/jumpHopRuntimeModel.ts``src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`
## 跳一跳落点辅助和后端裁决必须统一坐标换算
- 现象:落点辅助标识已经压在目标地块图片上,松手后后端仍判定失败,玩家看到的是“明明瞄准了却没落上去”。
- 原因:前端辅助标识使用屏幕像素坐标绘制,而后端裁决使用世界坐标。屏幕 y 轴向下为正、世界 y 轴向上为正;同时屏幕 x/y 每个世界单位对应的像素比例不同。若前端直接把屏幕像素拖拽向量发给后端,辅助点和后端落点方向会不一致。
- 处理:前端运行态保留原始屏幕拖拽向量用于画弹弓和辅助点,但提交后端前必须按当前地块到目标地块的屏幕跨度 / 世界跨度把 x、y 分别换算成世界尺度一致的向量;后端继续只负责反向弹射和落点裁决。
- 验证:前端回归测试要同时覆盖辅助点完整拖拽到目标地块,以及提交给后端的向量已完成世界尺度换算;后端领域测试覆盖屏幕向后下拉时应向世界 y 正方向跳出并命中。
- 关联:`src/services/jump-hop/jumpHopRuntimeModel.ts``src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx``server-rs/crates/module-jump-hop/src/application.rs`
## 跳一跳创作入口旧文案先查 SpacetimeDB 配置
- 现象:`JumpHopWorkspace` 已只剩主题输入,但创作 Tab 的跳一跳模板卡仍显示旧的“俯视角跳跃闯关”或拼图参考图。
- 原因:创作入口卡片事实源是 SpacetimeDB `creation_entry_type_config``/api/creation-entry/config`前端只做展示派生如果只改工作台、PRD 或前端组件,已有库里的旧入口行不会自动变化。当前 `api-server` 读取入口配置时优先订阅缓存,缓存命中后不会再走 procedure 播种,所以只把迁移写在 `get_creation_entry_config` 里不够。
- 处理:同步更新 `module-runtime` 默认入口种子,并在 `spacetime-module/src/runtime/creation_entry_config.rs` 加只命中旧系统默认值的迁移;同时在 `spacetime-client` 的入口配置读模型里做同一条旧系统默认行的读路径纠偏。跳一跳当前默认值为 `subtitle=主题驱动平台跳跃``image_src=/creation-type-references/jump-hop.webp`
- 验证:本地 `GET /api/creation-entry/config``jump-hop` 项应返回新 subtitle 和新 imageSrc若仍旧检查本地 SpacetimeDB 是否已发布当前 `spacetime-module`,以及后台是否手动覆盖过入口配置。若缓存路径和 procedure 路径返回不一致,优先怀疑读模型映射没做纠偏,而不是前端展示层。
## image2 dry-run 带参考图时不要直接打印 data URL
- 现象:使用 VectorEngine `gpt-image-2-all` 生成带参考图的概念图时,如果 dry-run 直接打印完整请求体,参考图会被转成超长 `data:image/png;base64,...`,终端日志会被数百万字符淹没。
@@ -1681,6 +1865,26 @@
- 验证:定向测试 `cargo test -p api-server generated_asset_sheet_two_items_per_row --manifest-path server-rs/Cargo.toml -- --nocapture` 应通过,且错位透明样本应按连通域切出完整视图。
- 关联:`server-rs/crates/api-server/src/generated_asset_sheets.rs``server-rs/crates/api-server/src/match3d/item_assets.rs`
## 腾讯云 release 上 VectorEngine `SendRequest` 超时先查出口链路与重试
- 现象release 机器调用 VectorEngine `gpt-image-2``/v1/images/generations``/v1/images/edits` 偶发 `client error (SendRequest) -> connection error -> Connection timed out (os error 110)`,应用层表现为 504本地通常正常。
- 原因:本地 DNS 可能走代理 / 加速出口,而腾讯云 release 直接解析到 VectorEngine 真实边缘节点。实测同一张约 2.37MB PNG、同一 edits 请求,`curl` 5/5 成功,但 `reqwest/hyper` 会间歇性超时;固定 `40.160.33.47` 也只能改善,不能根治。
- 处理:不要优先关闭 multipart也不要直接把 `SendRequest` 解释成上游业务拒绝。VectorEngine 图片 `generations` / `edits` 上游 POST 单独使用 `libcurl`;参考图下载和响应图片 URL 下载仍用 `reqwest`。send 阶段 timeout / connect error 在 `platform-image` 内最多重试 5 次,使用指数退避和短抖动;日志字段 `attempt``max_attempts``retry_delay_ms``reference_image_bytes_total``request_params` 是定位依据。
### api-server libcurl / OpenSSL 3.2 runtime
- 症状release 部署新 `api-server` 后服务反复 `exit-code``LD_TRACE_LOADED_OBJECTS=1 /opt/genarrative/current/api-server``ldd``/lib/x86_64-linux-gnu/libssl.so.3: version 'OPENSSL_3.2.0' not found`
- 根因:`platform-image` 使用 `libcurl`Linux release 构建产物可能直接要求 `OPENSSL_3.2.0` 符号Ubuntu 24.04 apt 默认 OpenSSL 仍是 `3.0.13`,不能满足该符号版本。
- 处理:`Genarrative-Server-Provision` 独立安装 OpenSSL `3.2.0``/opt/genarrative/openssl-3.2.0`,并只通过 `genarrative-api.service``LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib` 给 api-server 使用,避免替换系统 OpenSSL。
### VectorEngine edits multipart image part
- 症状:拼图参考图链路请求 `/v1/images/edits` 返回 `500 image is required`,但应用日志里 `reference_image_count=1``reference_image_bytes_total>0``request_params.referenceImages[0]` 也有 `field=image`、文件名、MIME 和 bytes。
- 根因Rust `curl::easy::Form``contents(...).filename(...)` 不等价于文件上传 partVectorEngine 转码层会认为没有收到图片。release 上用 curl CLI `-F image=@file` 可成功,证明字段名和上游接口本身没变。
- 处理multipart 参考图必须用 `Form::buffer(file_name, bytes)` 并设置 `content_type(...)`,让 libcurl 生成真正的 `name="image"; filename="..."` 文件 part。
- 验证release 上先看 `journalctl -u genarrative-api.service``VectorEngine 图片请求发送失败,准备重试` 与最终 `HTTP 返回`;若仍失败,再用同一图片分别跑 curl 与最小 reqwest 探针对照。
- 关联:`server-rs/crates/platform-image/src/vector_engine/client.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 个人中心不再保留直达“存档”按钮入口
- 现象2026-05-25 起,移动端“我的”页顶部改为品牌行 + 扫码 / 设置按钮,设置区和次级入口不再提供独立的 `存档` 按钮;用户仍可在“玩过”弹窗里查看可继续存档。
@@ -1689,6 +1893,22 @@
- 验证:`npm test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile profile page matches the reference layout sections|profile scan action opens camera scanner instead of recharge panel"`
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx``docs/【项目基线】当前产品与工程约束-2026-05-15.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 旧创作入口先确认是不是旧 worktree 在响应
- 现象:浏览器里明明还看到跳一跳旧入口,比如 `俯视角跳跃闯关``puzzle.webp`,但当前 worktree 里已经改成了 `主题驱动平台跳跃``jump-hop.webp`
- 原因:本机常同时存在两个开发栈,旧 worktree 可能还在占用 `3000/8082/3101/3102`,而当前 worktree 可能跑在另一组端口。只看页面文案就下结论,容易把旧进程误认成当前改动没生效。
- 处理:先用 `Get-NetTCPConnection` / `Get-CimInstance Win32_Process` 确认端口对应的可执行文件和命令行,再分别请求 `/api/creation-entry/config` 比对旧端口与当前 worktree 端口。必要时以当前 worktree 的实际端口为准重新打开页面。
- 验证:旧端口返回旧跳一跳入口,当前 worktree 端口返回新跳一跳入口;两边的 `api-server` / `vite-cli` 命令行应指向不同仓库路径。
- 关联:`scripts/dev.mjs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 3001 无法访问先查旧 worktree 占端口和 SpacetimeDB 版本
- 现象:`http://127.0.0.1:3001/` 打不开,但 `3000 / 3101 / 8082` 仍有进程;`npm run dev` 直接退出,没有把新栈拉起来。
- 原因:旧 worktree 的 `api-server``spacetime-standalone` 和 Vite 还活着,或者当前 worktree 的本机 SpacetimeDB CLI 默认版本低于仓库锁定版本,`scripts/dev.mjs` 会先校验版本再启动并直接报错退出。
- 处理:先停掉占用端口的旧进程,再执行 `spacetime version list``spacetime version use 2.4.1`,确认本机 CLI/standalone 与仓库一致后重新启动 `npm run dev -- --no-interactive --web-port 3001 --api-port 8083 --spacetime-port 3103 --admin-web-port 3104`
- 验证:`http://127.0.0.1:3001/``http://127.0.0.1:8083/healthz``http://127.0.0.1:3103/v1/ping` 都返回 200且进程命令行指向当前 worktree 路径而不是别的仓库。
- 关联:`scripts/dev.mjs``.hermes/shared-memory/pitfalls.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 微信历史孤儿作品不要让新注册账号顶替
- 现象:清空用户数据或迁移历史数据后,旧作品的 `owner_user_id` 为空或失效,新注册用户会因为顺序号复用或旧 ID 残留顶替作品归属,导致刚注册就看到别人的草稿或已发布作品。
@@ -1704,3 +1924,217 @@
- 处理:推荐页拖拽只校验当前是否有作品、多作品可切换以及是否正在提交动画,不再要求登录;登录态相关操作仍由点赞、改造等按钮自身权限控制。
- 验证:`npx vitest run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 覆盖访客态纵向滑动不弹登录且触发下一条推荐。
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`
## Windows junction worktree 下 Vitest 定向路径失败先切真实路径
- 现象:在 `C:\Users\...\ .codex\worktrees\...` 这类 junction 工作区运行 `npm run test -- src/...`Vitest 可能报 `Failed to load url C:/Users/... (resolved id: F:/DevWorktrees/...)`,同一测试文件明明存在却被判定找不到。
- 原因Vite / Vitest 在 Windows 下会把测试入口 realpath 到真实 worktree 路径;如果命令从 junction 路径传入相对文件参数,入口路径和 resolved id 可能跨盘符不一致。
- 处理:前端定向测试优先从 `Get-Item <worktree> | Format-List Target` 显示的真实路径运行,例如 `F:\DevWorktrees\codex\worktrees\f584\Genarrative`;不要把这类文件加载失败误判成组件或路由断言失败。
- 验证:同一命令从真实路径执行应正常收集并运行测试,例如 `npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`
- 关联:`src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx``src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx``src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx``src/routing/appPageRoutes.test.ts`
## 拼消消草稿试玩要和正式 runtime 分流
- 现象:拼消消结果页点击“试玩”后如果仍然调用 `/api/runtime/puzzle-clear/runs`,草稿试玩会被正式 run 规则和统计约束卡住,公开作品又可能和草稿恢复串台。
- 原因:拼消消既有草稿生成 / 结果页 / 发布闭环,也有正式公开 runtime如果把结果页试玩和公开运行态复用同一个后端 startRun 入口,`work detail` 读取路径和统计口径都会混在一起。
- 处理:结果页试玩改走前端本地 `runtimeMode=draft` snapshot只用于草稿试玩和关卡切换不写正式 run公开详情和推荐流进入正式 runtime 时才走后端 `/api/runtime/puzzle-clear/*`。客户端读取作品详情时也要区分创作详情 `/api/creation/puzzle-clear/works/{profileId}` 与公开运行态详情 `/api/runtime/puzzle-clear/works/{profileId}`
- 验证:点击拼消消结果页的试玩按钮,不应再请求 `/api/runtime/puzzle-clear/runs`;公开详情入口仍应能读取后端运行态详情。
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/services/puzzle-clear/puzzleClearClient.ts``src/services/puzzle-clear/puzzleClearLocalRuntime.ts``docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md`
## 拼消消 runtime 必须继承拼图模板的原生交互基线
- 现象:拼消消卡片在浏览器里会出现原生图片拖拽 / 下载手柄,或窗口拉伸后棋盘和卡片被拉成矩形。
- 原因:拼消消 runtime 早期只继承了“交换 / 消除”的业务逻辑,没有完整继承拼图模板在基础交互上的防护:`touch-none``select-none``aspect-square``draggable={false}``onDragStart(event.preventDefault())``-webkit-user-drag: none`
- 处理:棋盘容器必须保持正方形约束,卡片按钮和内层 `<img>` 都要显式禁用浏览器原生拖拽,样式层也要补 `user-select: none``-webkit-user-drag: none`,不能只靠业务指针逻辑。
- 验证:浏览器中检查棋盘 `getBoundingClientRect().width === height`,卡片图片 `draggable="false"``-webkit-user-drag``none`;真实拖拽只应进入交换逻辑,不应触发原生图片拖拽。
- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx``src/index.css``src/components/puzzle-runtime/PuzzleRuntimeShell.tsx``src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`
## 拼消消拖拽浮层要挂到页面级 portal
- 现象:拼消消拖拽时图片看起来没有贴在鼠标或手指上,尤其是平台壳层本身带有 transform 时更明显。
- 原因:拖拽 ghost 用了 `position: fixed`,但如果还挂在会被 transform 的局部容器里,浏览器会把 fixed 当成相对该祖先定位;`clientX/clientY` 读到的是视口坐标,两个坐标系一混就会出现肉眼可见的偏移。
- 处理:拖拽浮层必须通过 portal 挂到 `document.body` 这一层,再继续使用 `clientX/clientY - pointerOffset` 计算 left/top不要把 ghost 留在平台壳或任何会参与 transform 的容器里。
- 验证:`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx` 应断言拖拽浮层父节点是 `document.body`,且 left/top 与按下点偏移一致。
- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx``src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`
## 拼消消要继承拼图模板的动作语言,不只是规则
- 现象:拼消消如果只实现“交换后裁决”,但没有开局翻牌、按下留空位、被替换卡快速飞回、以及局部拼接块整体拖动,玩家会直觉上觉得比原拼图更笨重。
- 原因:早期实现容易把“规则独立”误读成“动作语言也要重写”,结果只保留了交换逻辑,没有沿用拼图模板里已经验证过的拖拽反馈、空位让位和合并块连续感。
- 处理:拼消消运行态要继承拼图模板的基础手感:只在开局保留入场翻牌,拖起时源位立即呈空,放下时被替换卡要有明确飞向空位的位移感,连通块要作为整体拖动和整体呈现。
- 验证:浏览器拖拽时能看到跟手 ghost、源位空槽、落点飞入和整组拼接层`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx` 应覆盖这些行为。
- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx``src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx``src/index.css`
## 拼消消空格位必须允许落位,不能当成不可交互死格
- 现象:运行到某一关后,棋盘里出现空格位,用户能看见空洞但拖不进去,也点不动。
- 原因:空格位被前端交互或后端裁决误当成“无效目标”,只保留了交换逻辑,没有把“源卡落入空位、源位清空”当成合法移动。
- 处理:空格位必须保留 button 交互态和落点命中逻辑;前端拖拽 / 点击落到空格时直接提交移动,后端和本地 runtime 都要把源卡移动到目标格并清空源格,不再走失败交换。
- 验证:`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx``npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts``cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml player_move_can_drop_card_into_empty_target_cell -- --nocapture`
- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx``src/services/puzzle-clear/puzzleClearLocalRuntime.ts``server-rs/crates/module-puzzle-clear/src/application.rs`
## 拼消消空位落卡后必须立即补位,不能把空洞留成真空格
- 现象:卡牌成功落进空格后,源位仍然留空,玩家会误以为那个格子坏掉了。
- 原因:移动逻辑只处理了“落到空位”,没有在未消除时同步走一遍重力补位,所以源列会短暂或永久留下空洞。
- 处理:只要移动后棋盘存在空位,就立即走补位和可解性修复;这样源位会从顶部准备区补卡,不会留下不可交互空洞。
- 验证:`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts``cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml player_move_can_drop_card_into_empty_target_cell -- --nocapture`
- 关联:`src/services/puzzle-clear/puzzleClearLocalRuntime.ts``server-rs/crates/module-puzzle-clear/src/application.rs`
## 拼消消素材错位先查 sheet 质量门禁
- 现象:一张卡牌切片里同时出现两个或多个错位图案,或空白格、相邻编号区域里混入其他图案碎片。
- 原因provider 生成的 `1024x1536 / 4x6` 工作表可能违反视觉契约;旧流程只校验布局元数据和切片数量,无法发现图像内容已经主体缺失或污染空白格。边界贴边检测容易把正常铺满主体误判成跨格污染,不能作为高可靠硬门禁。
- 处理:先强化 atlas prompt要求每个 `256x256` 单元独立查看时只能包含一个主体或同一主体单一局部;服务端在 sheet 切片前做像素级质量门禁,硬拦截非空格前景占比过低和空白格污染,严重多边非同组边界贴边只记录 warning 供排查,不直接让创作失败。硬门禁失败的 sheet 最多尝试 4 次,仍失败则拒绝持久化脏 atlas。
- 追加处理:照片式微场景素材必须把每个 `256x256` 单元收束为一张完整的单场景照片裁片;同编号连续格表示同一视觉家族,不是随机独立小图,要求共享同一场景锚点、主色和道具语言。禁止单格内部出现两张照片、两个不同场景、拼接线、内部竖切、内部横切或左右 / 上下两块不同背景;质量门禁只在单格内部强色差直线贯穿大部分高度或宽度,且两侧都像低纹理人工平铺色块时,按“单格内部疑似拼接线”硬失败并重试 sheet避免把窗框、桌沿、地平线等自然场景强边缘误杀。
- 追加处理sheet 生成时如果 VectorEngine 返回 `retryable=true``502``504``429` 或请求超时,例如 nginx HTML `502 Bad Gateway`,不要立刻把草稿置为 failed应消耗同一 sheet 的下一次 attempt仍失败再回写失败状态。
- 追加处理:`sheet-03` 原本唯一空白格容易被模型画入主题主体,导致第 6 行第 4 列反复报“空白格有主体”并消耗多次 image2 请求。该格改为 `FILL` 补位格允许生成主题小图但服务端切片、atlas 合成和运行态全部丢弃;前端拼消消 action 等待窗口同步提高到 40 分钟,避免上游单图慢返回时用户侧 20 分钟超时。
- 验证:`cargo test -p api-server puzzle_clear --manifest-path server-rs/Cargo.toml -- --nocapture``cargo check -p api-server --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/api-server/src/puzzle_clear.rs``docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`
## 拼消消锁定组覆盖层必须锚定在棋盘本身
- 现象:消除或补牌过程中,局部完成的组图偶尔会看起来从格子里“飘出去”,并且大小会随着窗口和外层面板变化而异常拉伸。
- 原因:锁定组视觉层用了 `absolute inset-0`,但棋盘容器本身不是 `position: relative`,于是覆盖层实际锚到了更外层的运行态面板,`gridColumn` / `gridRow` 只能在错误坐标系里排版。
- 处理:棋盘容器必须显式 `relative`,让锁定组覆盖层、拖拽鬼影和格子坐标都在同一正方形棋盘坐标系内排版;不要把这类覆盖层锚到外层 `section` 或整页容器。
- 验证:浏览器里棋盘 `getBoundingClientRect()` 和锁定组覆盖层应共享同一块正方形区域,窗口缩放后组图不应再出现越界或被拉伸的现象;`PuzzleClearRuntimeShell.test.tsx` 需要断言棋盘 class 包含 `relative`
- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx``src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`
## 拼消消中央场地底图必须挂在棋盘内部
- 现象:创作阶段选择了中央场地底图,但运行态消除卡片后只看到浅色格子或空点,看不到底图。
- 原因:底图被渲染成整页氛围背景,并被页面渐变、棋盘面板和格子 `bg-white/78` 遮住;棋盘内部没有静态底图层,空格仍保留不透明卡片底色。
- 处理:`boardBackgroundAsset.imageSrc` 必须作为 `puzzle-clear-board` 内部的 `absolute inset-0` 静态底图渲染;空格、消除空位和拖拽源位必须透明或近透明,不能继续使用实体卡片白底。
- 验证:`PuzzleClearRuntimeShell.test.tsx` 断言 `puzzle-clear-board-background` 在棋盘内,`/board-bg.png` 只出现一次,空格 class 包含 `bg-transparent` 且不包含 `bg-white/78`
- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx``src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 创作入口突然消失先查前后端是否串到不同 worktree
- 现象:`http://127.0.0.1:3000/` 可访问,但创作 Tab 里新增玩法入口消失;例如 `puzzle-clear` 已在代码默认种子中存在,浏览器仍看不到“拼消消”。
- 原因Vite 可能来自当前 worktree但代理目标的 `api-server` 仍是另一个 worktree 的旧进程,或者 `api-server` 连到旧 SpacetimeDB 模块;此时 `/api/creation-entry/config` 会返回旧入口配置。
- 处理:先用 `Get-NetTCPConnection -State Listen -LocalPort 3000,8083,3103` 结合 `Get-CimInstance Win32_Process` 确认端口进程路径;停止串线的旧 `api-server`,再用当前 worktree 的 `npm run dev:spacetime -- --spacetime-port <port> --database <database>``npm run dev:api-server -- --api-port <port> --spacetime-port <port> --database <database>` 拉起同一套服务。
- 验证:`GET /api/creation-entry/config` 应包含目标入口,且监听端口的命令行都指向同一个 worktree浏览器创作 Tab 对应分类应显示入口卡。
- 关联:`scripts/dev.mjs``.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## Windows junction 工作区下 dev.mjs 直接执行入口要用 realpath 判断
- 现象:在 `C:\Users\...\ .codex\worktrees\...` 这类 junction 路径里运行 `npm run dev:web`,进程会秒退,`3000` 不监听,但同一脚本从真实 worktree 路径能正常启动。
- 原因:`scripts/dev.mjs` 的入口判断只比对 `process.argv[1]``import.meta.url` 的字面路径junction 路径和 realpath 路径不一致时会误判成“不是直接执行”,于是主流程根本不进入。
- 处理:入口判断改成基于 `realpathSync(...)``isDirectModuleExecution(...)`,让 junction 路径和真实 worktree 路径指向同一个模块;同时补回归测试覆盖该场景。
- 验证:`npm run test -- scripts/dev.test.ts scripts/dev-stack-port-utils.test.ts` 通过后,`npm run dev:web -- --web-port 3000 --api-port 8083 --no-interactive` 应能稳定把 `0.0.0.0:3000` 监听起来。
- 关联:`scripts/dev.mjs``scripts/dev.test.ts`
## Vitest 定向测试在 Windows junction 工作区要切真实路径
- 现象:在 `C:\Users\...\ .codex\worktrees\...` 这类 junction 路径里跑 `npm run test -- src/...`Vitest 会报 `Failed to load url ... (resolved id: F:/DevWorktrees/...)`,看起来像文件不存在。
- 原因Vite / Vitest 会把入口 realpath 到真实 worktree 路径;如果命令从 junction 路径传入相对文件参数,入口路径和 resolved id 可能跨盘符不一致。
- 处理:前端定向测试优先从真实路径 `F:\DevWorktrees\codex\worktrees\f584\Genarrative` 运行,不要把这类文件加载失败误判成组件或路由断言失败。
- 验证:同一命令从真实路径执行应正常收集并运行测试。
- 关联:`src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx``src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx``src/routing/appPageRoutes.test.ts`
- 现象:新增或扩展 `*-generating` 页面后,生成卡只渲染首帧,`已耗时` / `预计等待` 停在进入页那一刻不动。
- 原因:平台壳层的共享 `miniGameGenerationProgressNowMs` 时钟没有把新生成阶段纳入 tick 条件,或者该阶段的 `buildMiniGameDraftGenerationProgress(..., nowMs)` 没有接入同一时钟。
- 处理:任何共享生成页都要通过平台壳层统一的时钟判断和 `nowMs` 传递刷新,新增生成阶段时要同时补 `selectionStage` 判定、`useEffect` 依赖和进度调用点。
- 验证:浏览器里进入对应生成页后,`已耗时` / `预计等待` 应持续变化,不应停在首帧。
## 拼消消要用真实可消除判断,不要把“已相邻”当成可解
- 现象:拼消消开局或补牌后会直接出现已完成的图案组,或者 `1x2` 被当成半锁定局部留在场上。
- 原因:早期把可解性写成“场上已经有同组相邻卡”或“只要有一对相邻同组卡就算可解”,这会把已完成盘面误当成合法盘面;同时半锁定规则没有排除 `1x2`
- 处理:开局和补牌后的重排必须先排除现成消除,再用真实交换 / 落位模拟判断是否会产生新消除;`1x2` 永远不进入半锁定组,半锁定只允许 `1x3``2x2``2x3`
- 验证:`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx``cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml -- --nocapture` 通过后,开局盘面不应直接出现 completed group。
- 关联:`src/services/puzzle-clear/puzzleClearLocalRuntime.ts``server-rs/crates/module-puzzle-clear/src/application.rs`
## 推荐页作品 key 漏玩法会导致运行内容和标题作者错位
- 现象:移动端推荐页进入跳一跳或敲木鱼等作品时,游戏运行内容已经切到当前作品,但下方标题、作者和头像仍显示第一条拼图或其它推荐作品。
- 原因:平台壳层用 `getPlatformPublicGalleryEntryKey(...)` 写入 `activeRecommendEntryKey`,而 `RpgEntryHomeView` 内部的 `buildPublicGalleryCardKey(...)` 漏掉新玩法 `sourceType` 分支,导致当前 key 查不到条目后回退到推荐列表第一条。
- 处理:推荐页和平台壳层的公开作品 key 规则必须复用 `buildPlatformPublicGalleryCardKey(...)`,覆盖同一批 `sourceType`,至少包括 `big-fish``puzzle``jump-hop``wooden-fish``match3d``square-hole``visual-novel``bark-battle``edutainment:<templateId>`;新增玩法公开推荐流时先补这个共享 helper。
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile recommend meta matches active"` 应覆盖跳一跳和敲木鱼的当前运行内容、标题和作者一致。
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 跳一跳飞行动画不要直接用最新 run 重绘地块窗口
- 现象:跳一跳松手后如果后端很快返回下一帧 run地块窗口会立刻前移角色翻腾动画看起来像没播放若同时刷新图片资产还可能被误认为地块频闪。
- 原因:后端 run 是规则真相,前端 runtime 又需要低延迟表现。如果 DOM 平台层直接用最新 `run.currentPlatformIndex` 渲染,后端回包会抢在动画前完成视觉切换。
- 处理:前端保留独立 `displayRun`,松手后先进入 `isJumpAnimating=true`,角色在当前窗口内插值飞向目标地块;约 `300ms` 后再把 `displayRun` 切到最新后端 run并进入约 `1440ms``platformAdvancing` 表现态。推进期间地块 DOM 层和 Three.js 角色层必须统一包在同一个 camera layer 下移动,旧当前地块用相机偏移自然离开视野,新预览地块从上方露出;不要再让 p1/p2 各自 top/left 过渡。相机层必须同时设置 `--jump-hop-camera-shift-x``--jump-hop-camera-shift-y`,从旧目标地块位置斜向滑到新当前地块聚焦位置,避免先横向瞬切居中再纵向推进。地块保留当前 / 目标 / 预览的深度尺寸差异,但深度差异必须用固定宽高 + CSS transform scale 缓动实现,不能直接改宽高瞬切;当前态不要额外叠 CSS scale。相机推进期间角色自身也不能保留 `left/top` transition否则 `displayRun` 切换造成的角色局部坐标变更会和父级 camera layer 位移叠加,视觉上像落地后又从屏幕外飞回;角色推进期只允许 transform / opacity transition。正式胜负、成功跳跃次数、时长和排行榜仍以后端 run 为准,前端只延迟显示态。
- 验证:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖动画期间平台仍停在旧窗口,动画结束后进入 `data-platform-advancing=true`Three 角色层与地块层同在 `jump-hop-camera-layer` 内,通过 `--jump-hop-camera-shift-x``--jump-hop-camera-shift-y` 完成相机斜向推进,并校验可见地块按深度保留不同视觉尺寸、运行态平台宽高使用固定基准值、推进态 transform transition 为 `1440ms`、推进态角色 transition 不包含 `left/top`
- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx``src/services/jump-hop/jumpHopRuntimeModel.ts``server-rs/crates/module-jump-hop/src/application.rs`
## 跳一跳相机推进不要让地块图片回退到原型方块
- 现象:角色落到下一块后,相机推进时旧地块图片突然消失,或新预览地块先露出浅色原型方块,随后真实 image2 切片才出现。
- 原因:旧地块进入 exiting 状态时如果 React key 从 `platformId` 变成 `platformId-exiting`,图片组件会重新挂载并丢失已加载状态;同时 `JumpHopTileImage` 曾在真实图片 URL 已存在但 `onLoad` 尚未触发时显示 fallback 原型地块。
- 处理exiting 地块继续使用稳定 `platformId` key让旧图片组件在推进期复用有真实 `resolvedUrl` 且未错误时直接保留真实 `<img>`,只在无 URL 或加载失败时显示 fallback当前 3 块之外的后续地块通过隐藏预加载图片提前解析签名 URL 和浏览器缓存。
- 验证:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/services/jump-hop/jumpHopRuntimeModel.test.ts` 应覆盖真实 tile URL 不露出 `.jump-hop-runtime__fallback-tile`,并存在 `jump-hop-tile-preload-image`
- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx``src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`
## 跳一跳地块抠图不要用绿幕或近白底识别
- 现象:跳一跳生成草地、花、雪地、白石或云朵地块时,透明化会把绿色 / 白色主体局部扣掉,运行态看到平台缺口、变薄或主体消失。
- 原因:通用图集默认按绿幕和近白底做透明化,适合 UI / 普通物品,但跳一跳地块天然高频包含绿色和白色;如果继续用 `#00FF00` 绿幕或近白背景识别,素材本体会落入背景分数。旧逻辑还会清理非边缘连通的高置信 key 色块,遇到主体内部撞色时也可能误伤。
- 处理:跳一跳地块图集 prompt 固定要求单一纯洋红 `#FF00FF` key 背景;切片前后透明化调用 `GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen()`,只扣洋红 key关闭近白扣除并且不清理非边缘连通 key 色像素。通用绿幕函数保持默认绿幕 / 近白兼容,避免影响拼图、抓大鹅和敲木鱼。
- 验证:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml generated_asset_sheet -- --nocapture` 覆盖洋红 key 保留绿色、白色和非边缘连通 key 色主体;`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` 覆盖跳一跳洋红 prompt 与绿 / 白地块切片。
- 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs``server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs``server-rs/crates/api-server/src/jump_hop.rs`
## 含中文 image2 live 验证不要用 PowerShell 管道喂 Node 源码
- 现象:本地用 `@'...'@ | node -` 跑 VectorEngine / gpt-image-2 live 验证时,`request.json` 里的中文 prompt 可能全部变成 `????`,生成图会变成完全不相关的 UI、建筑海报或其它随机内容容易误判为模型不服从提示词。
- 原因Windows PowerShell 管道到 Node stdin 时可能按本机非 UTF-8 编码传输脚本文本JS 源码里的中文字符串在进入 Node 前已经损坏Rust 后端真实请求不会走这条编码路径。
- 处理:含中文提示词的 live 验证优先写成 UTF-8 `.mjs` 文件再执行,或使用能确认 UTF-8 的运行入口;执行后先检查本次 `request.json` 是否保留真实中文,再判断生图质量。不要基于 `????` prompt 生成的图片调整项目提示词。
- 验证:生成前后检查 `request.json`,其中 `prompt` 字段应显示中文而不是问号;同一提示词在 UTF-8 文件脚本下应能得到符合主题的图。
- 关联:`.codex/skills/gpt-image-2-apimart/SKILL.md``server-rs/crates/api-server/src/jump_hop.rs`
## 自动试玩退出不要回到生成页
- 现象:拼图草稿生成完成后自动进入试玩,用户从试玩退出或使用系统返回时落回生成进度页,页面还暴露“重新生成”按钮。
- 原因:自动试玩前如果没有先把 `/creation/puzzle/result` 写成 `/runtime/puzzle` 的浏览器历史前一站,系统返回会命中旧的生成页历史项;仅靠运行态内部 `returnStage='puzzle-result'` 只能覆盖运行态按钮返回,不能覆盖浏览器 / WebView 系统返回。
- 处理:所有“生成完成后自动进入草稿试玩”的分支在 `openPuzzleRuntimeStage(...)` 前都必须调用结果页历史写入 helper`/creation/puzzle/result` 与当前 `sessionId/profileId/workId` 写入历史;运行态按钮返回到 `puzzle-result` 时也同步写回创作恢复 query。
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle draft generation auto starts trial and runtime back opens draft result"`
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 推荐页 ready 不能只等主图或首次 DOM 图片
- 现象:移动端推荐页卡面遮罩在作品主图加载后就渐隐,但游戏内 UI 图集、背景、道具图或换签中的 generated 图片还没有准备好,用户会看到运行态半成品或资源闪入。
- 原因:推荐页 ready probe 如果只扫描首次挂载时已有的 `<img>`,就会漏掉 React effect、`/api/assets/read-url` 换签、spritesheet 解析或后续 state 更新才新增的资源。
- 处理:推荐页 runtime 遮罩必须持续观察运行态 DOM 内新增图片、内联 `background-image``data-runtime-resource-pending` 隐藏标记;各玩法对换签中、解析中的资源源头要暴露 pending 标记,失败后释放标记并交给玩法兜底,避免遮罩永久卡住。
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile recommend cover waits for async runtime resources beyond the main image|mobile recommend cover waits until runtime images are ready"`
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/common/RuntimeResourcePendingMarker.tsx``src/components/ResolvedAssetImage.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 拼图文字直创的 compile 回包不等于生成完成
- 现象:只输入文字点击生成拼图时,页面刚进入生成页就弹出“生成任务已完成,可以继续查看草稿。”,随后又提示“请先选择一张正式拼图图片。”,结果页关卡里也没有图。
- 原因:统一创作表单路径把 `compile_puzzle_draft` 的同步回包无条件当成 ready但后端在 AI 重绘路径会先返回 `stage=image_refining``progressPercent=88` 的会话,只表示首关草稿已编译且后台首图 / UI 资产任务已启动,还没有正式封面或候选图。
- 处理:前端必须继续用 `isPuzzleCompileActionReady(...)` 判断回包 session没有 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时保持生成中,不弹完成、不把作品架 pending 标 ready、不自动试玩。生成页轮询合并 session 进度时,未进入编译态或进度无变化就返回原 state避免轮询制造重复 render。
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle text-only form stays generating|puzzle draft generation auto starts trial|running puzzle draft opens generation progress"`
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## CreativeImageInputPanel 主图点击默认预览
- 现象:复用 `CreativeImageInputPanel` 的结果页 / 编辑页已有主图时用户点击图片却触发上传无法直接查看大图不同玩法若各自手写上传按钮会让主图、历史图、AI 重绘和参考图行为再次分叉。
- 原因:旧主图卡整卡是上传 label缺少主图预览模式和上传 / 历史入口的显式控制参数。
- 处理:通用面板已有主图时默认点击主图打开全屏预览,上传 / 更换收口到右下角 `ImagePlus` 图标按钮;无图时仍允许点击空图卡上传。调用方用 `canUploadMainImage``canUseImageHistory` 分别控制上传与历史按钮,不要复制面板或用样式遮挡按钮。
- 验证:`npm run test -- src/components/common/CreativeImageInputPanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
- 关联:`src/components/common/CreativeImageInputPanel.tsx``src/components/puzzle-result/PuzzleResultView.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 统一创作页短表单软键盘打开不要露出黑底
- 现象:小程序 / H5 移动端点击拼图或敲木鱼创作输入框后输入框和键盘之间出现一大片黑色区域H5 还会明显弹一下。跳一跳因为按钮区用 `mt-auto` 撑开页面,看起来没有同样问题。
- 原因:旧移动键盘处理会用 `--platform-keyboard-focus-offset``.platform-viewport-shell` 整体上移;但 H5 浏览器和小程序 `web-view` 已会自行处理输入框可见性,二次整体上移会造成页面弹跳并露出 `body` 或原生 `page` 的黑色宿主底色。统一创作短表单若内容区按短内容收缩,也会放大这个黑底暴露。
- 处理:`UnifiedCreationPage` 根容器必须保留 `bg-[image:var(--platform-body-fill)]``overscroll-contain`,内容区必须用 `flex-1 min-h-0` 占满统一页剩余高度;移动端键盘打开时只记录 `data-mobile-keyboard-open`、隐藏底部 dock、设置键盘 inset 和浅色 `--platform-keyboard-exposed-fill`,不要再对 `.platform-viewport-shell` 做全局 `transform`;小程序 `pages/web-view``page` 和 web-view class 也要用浅色背景。不要只给某个玩法工作台单独加高度补丁。
- 验证:`npm run test -- src/components/unified-creation/UnifiedCreationPage.test.tsx src/components/unified-creation/UnifiedCreationWorkspace.test.tsx src/mobileViewportKeyboardFocus.test.ts src/index.test.ts miniprogram/pages/web-view/index.style.test.js`;移动端点击拼图、敲木鱼、跳一跳输入框时,页面不应整体弹起,键盘上方应持续显示平台浅色背景。
- 关联:`src/components/unified-creation/UnifiedCreationPage.tsx``src/mobileViewportKeyboardFocus.ts``src/index.css``miniprogram/pages/web-view/index.wxml``miniprogram/pages/web-view/index.wxss``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 小程序订阅消息授权不要依赖 web-view bindmessage
- 现象拼图点击生成后H5 以为已经请求了生成结果订阅授权,但小程序没有弹出 `wx.requestSubscribeMessage` 授权框。
- 原因:`web-view bindmessage` / `wx.miniProgram.postMessage` 不适合承接“当前用户点击后立刻请求授权”的时序,消息可能等到 web-view 后退、分享或销毁时才派发,导致授权请求没有发生在 `compile_puzzle_draft` 前。
- 处理:不要在原生页 `onLoad` 自动触发 `wx.requestSubscribeMessage`真机会闪页返回且不弹授权框。H5 在 `compile_puzzle_draft` 前应先进入生成进度态并立即发起生成 action再通过微信 JS SDK `miniProgram.navigateTo` 非阻塞跳转到小程序原生订阅页尝试请求授权;用户接受、拒绝或返回都不能阻塞生成。原生页不要改写上一页 `webViewUrl`,否则 web-view 可能重新加载首页并丢失进度页状态。后端发送订阅消息仍只允许在拼图资产成功或失败终态后执行。
- 验证:`npm run test -- src/services/wechatMiniProgramSubscribe.test.ts miniprogram/pages/subscribe-message/index.test.js`
- 关联:`src/services/wechatMiniProgramSubscribe.ts``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``miniprogram/pages/subscribe-message/index.shared.js``miniprogram/pages/web-view/index.js`
## 微信订阅消息 time 字段不能用内部时间戳
- 现象dev 服务器拼图资产生成终态后已经调用订阅消息发送,但日志出现 `微信订阅消息发送失败argument invalid! data.time4.value invalid`,用户收不到生成结果通知。
- 原因:微信模板 `time` 字段不接受内部微秒时间戳、秒级时间戳或带 `Z` / 时区后缀的字符串;发送 `1713686401.234567Z` 或类似 `2026-06-08 08:09:18Z` 会被微信拒绝。
- 处理:`api-server` 构造生成结果订阅消息时,`time4` 固定格式化为北京时间 `YYYY-MM-DD HH:mm`;不要复用 `shared_kernel::format_timestamp_micros`
- 验证:`cargo test --manifest-path server-rs\Cargo.toml -p api-server generation_result_template -- --nocapture`dev 日志中不应再出现 `data.time4.value invalid`
- 关联:`server-rs/crates/api-server/src/wechat_subscribe_message.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`

View File

@@ -1,6 +1,6 @@
# Genarrative 项目共享概览
更新时间:`2026-05-29`
更新时间:`2026-06-03`
## 一句话定位
@@ -10,6 +10,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台,把 A
- RPG / 自定义世界创作与运行时。
- 拼图玩法创作、草稿、发布、运行态和排行榜。
- 拼消消玩法创作、素材图集生成、结果页、发布、统一作品详情、正式运行态和基础统计。
- 敲木鱼玩法创作、草稿、发布、运行态、公开详情和分享码。
- 抓大鹅 Match3D 创作、2D 多视角素材生成、发布和运行态。
- 大鱼吃小鱼、方洞挑战、视觉小说、汪汪声浪和儿童向寓教于乐玩法。
@@ -33,7 +34,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台,把 A
server-rs + Axum + SpacetimeDB
```
当前 SpacetimeDB crate、SDK、CLI / standalone、生成 bindings 和容器压测镜像统一按 `2.3.0` 对齐。
当前 SpacetimeDB crate、SDK、CLI / standalone、生成 bindings 和容器压测镜像统一按 `2.4.1` 对齐。
职责边界:

View File

@@ -56,7 +56,7 @@
3. 新增或沉淀 Markdown 文档时,确认文件名已使用 `【标签名】` 前缀。
4. 若产生长期有效知识,更新 `.hermes/shared-memory/`
5. 若形成可复用流程,考虑沉淀到 `.hermes/skills/`
6. 提交信息中区分代码变更与文档/记忆变更
6. 提交代码时,提交标题使用中文;标题后逐行写明本次提交修改了什么,每条变更单独一行
## 文档阅读顺序

View File

@@ -43,6 +43,7 @@ Single-context layout: read root `CONTEXT.md` when present. Current architecture
- UI面板中不要默认写一些规则描述文案清爽一些按照游戏UI设计规范设计即可。
- UI设计需要兼顾网页端、移动端双端的使用体验确保在不同设备上都能正常显示和操作移动端优先考虑。
- 不要在gitignore中添加.env.local文件。
- 提交代码时,提交标题必须使用中文;标题后必须逐行写明本次提交修改了什么,每条变更单独一行。
- 严格遵循简洁的代码风格
- 请默认保持系统的简洁性,能复用、修改、扩展现有系统、页面就不新建新系统新页面。
- 禁止将功能说明描述类的文本默认写入UI界面中。

View File

@@ -18,6 +18,32 @@ _Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成
## Language
### Puzzle Clear
**拼消消**:
基于拼图交换 / 拖拽手感的新玩法模板,玩家移动 1x1 卡牌碎片,把同一复合图案组拼成完整矩形后消除,并由顶部对应纵列补牌继续游玩。
_Avoid_: 拼图整图过关、三消槽位玩法、前端本地裁决
**复合图案组**:
拼消消中可被消除的一幅小图,由 `1x2``1x3``2x2``2x3` 的 1x1 卡牌碎片组成;只有组内碎片按正确相对位置拼成完整矩形后才消除。
_Avoid_: 单张卡牌、整关大图、任意相邻同色块
**1x1 卡牌碎片**:
复合图案组被服务端切成的最小可移动单位,带有所属组、形状、组内坐标和图片资产。
_Avoid_: 前端临时裁图、无所属图案的普通方块
**半锁定拼接组**:
非 2 格复合图案组中已经局部完成的拼接状态,可作为整体拖动;玩家用外部单格撞入组内某格时只交换该格,其余部分保留并退回半完成状态。
_Avoid_: 永久锁死、补牌打散、完整消除
**顶部卡牌准备区**:
拼消消棋盘上方按纵列排列的背面卡牌队列;某列产生空位时,准备区对应列的卡牌从顶部下落补齐。
_Avoid_: 全局随机发牌槽、底部三消槽
**防死局发牌**:
拼消消开局和每次补牌后由后端保证至少存在一步可拼接;补牌时至少有一张新掉落卡能与场上剩余某张卡对应。
_Avoid_: 前端提示代替可解性、完全随机补牌
### Wooden Fish
**敲木鱼**:

View File

@@ -1,4 +1,5 @@
import type {
AdminUpsertCreationEntryEventBannersRequest,
AdminUpsertCreationEntryTypeConfigRequest,
AdminCreationEntryConfigResponse,
AdminDebugHttpRequest,
@@ -197,6 +198,21 @@ export function upsertAdminCreationEntryConfig(
);
}
/** 保存创作入口公告表单序列化后的后端传输字段。 */
export function upsertAdminCreationEntryBanners(
token: string,
payload: AdminUpsertCreationEntryEventBannersRequest,
) {
return request<AdminCreationEntryConfigResponse>(
'/admin/api/creation-entry/config/banners',
{
method: 'POST',
token,
body: payload,
},
);
}
export function listAdminWorkVisibility(token: string) {
return request<AdminWorkVisibilityListResponse>(
'/admin/api/works/visibility',

View File

@@ -144,10 +144,25 @@ export interface AdminTrackingEventListQuery {
}
/** 后台创作入口配置响应,同时包含模板入口和独立公告配置。 */
export interface AdminCreationEntryConfigResponse {
entries: AdminCreationEntryTypeConfigPayload[];
eventBanners: AdminCreationEntryEventBannerPayload[];
}
/** 后台创作入口公告位配置项;旧结构化 banner 字段仅保留兼容。 */
export interface AdminCreationEntryEventBannerPayload {
title: string;
description: string;
coverImageSrc: string;
prizePoolMudPoints: number;
startsAtText: string;
endsAtText: string;
renderMode: 'structured' | 'html';
htmlCode?: string | null;
}
/** 后台单个创作模板入口配置,公告不再绑定在某一个入口上。 */
export interface AdminCreationEntryTypeConfigPayload {
id: string;
title: string;
@@ -164,6 +179,7 @@ export interface AdminCreationEntryTypeConfigPayload {
unifiedCreationSpec?: UnifiedCreationSpecPayload | null;
}
/** 后台保存创作模板入口开关与统一创作契约的请求体。 */
export interface AdminUpsertCreationEntryTypeConfigRequest {
id: string;
title: string;
@@ -179,15 +195,24 @@ export interface AdminUpsertCreationEntryTypeConfigRequest {
unifiedCreationSpec?: UnifiedCreationSpecPayload | null;
}
/** 后台保存创作入口公告表单序列化结果的请求体。 */
export interface AdminUpsertCreationEntryEventBannersRequest {
/** 传输字段沿用后端契约,内容由后台表单生成。 */
eventBannersJson: string;
}
/** 后台统一创作工作台契约表单的传输结构。 */
export interface UnifiedCreationSpecPayload {
playId: string;
title: string;
mudPointCost: number;
workspaceStage: string;
generationStage: string;
resultStage: string;
fields: UnifiedCreationFieldPayload[];
}
/** 后台统一创作字段契约,保存前会校验字段类型和必填标记。 */
export interface UnifiedCreationFieldPayload {
id: string;
kind: 'text' | 'select' | 'image' | 'audio';

View File

@@ -200,6 +200,13 @@ export function AdminApp() {
onResultChange={setInviteResult}
/>
) : null}
{routeId === 'creation-announcement' ? (
<AdminCreationEntrySwitchPage
mode="announcements"
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'creation-entry' ? (
<AdminCreationEntrySwitchPage
token={token}

View File

@@ -3,6 +3,7 @@ import {
BadgeDollarSign,
LayoutDashboard,
LogOut,
Megaphone,
Eye,
ShieldCheck,
ListChecks,
@@ -35,6 +36,7 @@ const routeIcons = {
invite: TicketCheck,
tasks: ListChecks,
'recharge-products': BadgeDollarSign,
'creation-announcement': Megaphone,
'creation-entry': SlidersHorizontal,
'work-visibility': Eye,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;

View File

@@ -0,0 +1,16 @@
import {expect, test} from 'vitest';
import {adminRoutes, resolveAdminRoute, routeHash} from './adminRoutes';
// 中文注释:后台入口公告必须作为独立导航存在,避免公告表单被误藏在入口开关页。
test('后台入口公告路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'creation-announcement',
label: '入口公告',
hash: '#creation-announcement',
});
expect(resolveAdminRoute('#creation-announcement')).toBe(
'creation-announcement',
);
expect(routeHash('creation-announcement')).toBe('#creation-announcement');
});

View File

@@ -1,3 +1,4 @@
/** 后台单页应用可导航的路由标识,入口公告独立于入口开关维护。 */
export type AdminRouteId =
| 'overview'
| 'tables'
@@ -7,9 +8,11 @@ export type AdminRouteId =
| 'invite'
| 'tasks'
| 'recharge-products'
| 'creation-announcement'
| 'creation-entry'
| 'work-visibility';
/** 后台导航项定义hash 是浏览器地址栏和移动底栏共用入口。 */
export interface AdminRouteDefinition {
id: AdminRouteId;
label: string;
@@ -25,10 +28,12 @@ export const adminRoutes: AdminRouteDefinition[] = [
{id: 'invite', label: '邀请码', hash: '#invite'},
{id: 'tasks', label: '任务配置', hash: '#tasks'},
{id: 'recharge-products', label: '充值商品', hash: '#recharge-products'},
{id: 'creation-announcement', label: '入口公告', hash: '#creation-announcement'},
{id: 'creation-entry', label: '入口开关', hash: '#creation-entry'},
{id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'},
];
/** 根据地址栏 hash 解析后台路由,未知 hash 回落到总览页。 */
export function resolveAdminRoute(hash: string): AdminRouteId {
const normalizedHash = hash.trim().toLowerCase().split('?')[0] ?? '';
return (
@@ -37,6 +42,7 @@ export function resolveAdminRoute(hash: string): AdminRouteId {
);
}
/** 根据后台路由标识反查 hash供导航点击时同步地址栏。 */
export function routeHash(routeId: AdminRouteId) {
return (
adminRoutes.find((route) => route.id === routeId)?.hash ??

View File

@@ -1,11 +1,12 @@
/* @vitest-environment jsdom */
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
import {fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {beforeEach, expect, test, vi} from 'vitest';
import {
getAdminCreationEntryConfig,
upsertAdminCreationEntryBanners,
upsertAdminCreationEntryConfig,
} from '../api/adminApiClient';
import type {
@@ -20,12 +21,14 @@ vi.mock('../api/adminApiClient', () => ({
),
getAdminCreationEntryConfig: vi.fn(),
isAdminApiError: vi.fn(() => false),
upsertAdminCreationEntryBanners: vi.fn(),
upsertAdminCreationEntryConfig: vi.fn(),
}));
const puzzleSpec: UnifiedCreationSpecPayload = {
playId: 'puzzle',
title: '想做个什么玩法?',
title: '拼图',
mudPointCost: 10,
workspaceStage: 'puzzle-agent-workspace',
generationStage: 'puzzle-generating',
resultStage: 'puzzle-result',
@@ -40,6 +43,18 @@ const puzzleSpec: UnifiedCreationSpecPayload = {
};
const configResponse: AdminCreationEntryConfigResponse = {
eventBanners: [
{
title: '创作公告',
description: '',
coverImageSrc: '',
prizePoolMudPoints: 0,
startsAtText: '',
endsAtText: '',
renderMode: 'html',
htmlCode: '<section>后台公告</section>',
},
],
entries: [
{
id: 'puzzle',
@@ -50,9 +65,9 @@ const configResponse: AdminCreationEntryConfigResponse = {
visible: true,
open: true,
sortOrder: 30,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
unifiedCreationSpec: puzzleSpec,
},
@@ -62,6 +77,7 @@ const configResponse: AdminCreationEntryConfigResponse = {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getAdminCreationEntryConfig).mockResolvedValue(configResponse);
vi.mocked(upsertAdminCreationEntryBanners).mockResolvedValue(configResponse);
vi.mocked(upsertAdminCreationEntryConfig).mockResolvedValue(configResponse);
});
@@ -73,8 +89,27 @@ test('创作入口后台展示并保存统一创作契约', async () => {
await screen.findByText('pictureDescription');
expect(container.querySelector('.admin-subsection .admin-info-list')).not.toBeNull();
expect(
container.querySelector('.admin-subsection .admin-info-list')?.textContent,
).toContain('拼图');
expect(container.querySelector('.admin-panel .admin-panel')).toBeNull();
expect(container.querySelector('.admin-muted')).toBeNull();
expect(screen.queryByLabelText('契约 JSON')).toBeNull();
expect(screen.queryByText('puzzle-generating')).toBeNull();
await user.click(screen.getByRole('button', {name: '修改契约'}));
const dialog = screen.getByRole('dialog', {name: '统一创作契约'});
expect(within(dialog).queryByLabelText('玩法 ID')).toBeNull();
expect(within(dialog).queryByLabelText('工作台阶段')).toBeNull();
expect(within(dialog).queryByLabelText('生成阶段')).toBeNull();
expect(within(dialog).queryByLabelText('结果阶段')).toBeNull();
fireEvent.change(within(dialog).getByLabelText('泥点消耗'), {
target: {value: '12'},
});
await user.click(within(dialog).getByRole('button', {name: '应用修改'}));
expect(screen.queryByRole('dialog', {name: '统一创作契约'})).toBeNull();
expect(screen.getByText('12泥点数')).toBeTruthy();
await user.click(screen.getByRole('button', {name: '保存入库'}));
await user.click(screen.getByRole('button', {name: '确认'}));
@@ -84,7 +119,10 @@ test('创作入口后台展示并保存统一创作契约', async () => {
'admin-token',
expect.objectContaining({
id: 'puzzle',
unifiedCreationSpec: puzzleSpec,
unifiedCreationSpec: {
...puzzleSpec,
mudPointCost: 12,
},
}),
);
});
@@ -92,21 +130,134 @@ test('创作入口后台展示并保存统一创作契约', async () => {
test('创作入口后台拒绝 playId 不一致的统一创作契约', async () => {
const user = userEvent.setup();
vi.mocked(getAdminCreationEntryConfig).mockResolvedValueOnce({
...configResponse,
entries: [
{
...configResponse.entries[0]!,
unifiedCreationSpec: {
...puzzleSpec,
playId: 'match3d',
},
},
],
});
render(
<AdminCreationEntrySwitchPage token="admin-token" onUnauthorized={vi.fn()} />,
<AdminCreationEntrySwitchPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
const textarea = await screen.findByLabelText('契约 JSON');
fireEvent.change(textarea, {
target: {
value: JSON.stringify({
...puzzleSpec,
playId: 'match3d',
}),
},
});
await screen.findByText('pictureDescription');
await user.click(screen.getByRole('button', {name: '保存入库'}));
expect(await screen.findByText('统一创作契约 playId 必须与入口 ID 一致')).toBeTruthy();
expect(upsertAdminCreationEntryConfig).not.toHaveBeenCalled();
});
test('创作入口后台用表单保存公告配置', async () => {
const user = userEvent.setup();
render(
<AdminCreationEntrySwitchPage
mode="announcements"
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
expect(await screen.findAllByRole('heading', {name: '创作入口公告'})).toHaveLength(2);
expect(screen.queryByLabelText('公告代码 JSON')).toBeNull();
fireEvent.change(await screen.findByLabelText('公告 1 标题'), {
target: {value: '周末创作赛'},
});
fireEvent.change(screen.getByLabelText('公告 1 HTML'), {
target: {value: '<section>新的入口公告</section>'},
});
await user.click(screen.getByRole('button', {name: '新增公告'}));
fireEvent.change(screen.getByLabelText('公告 2 标题'), {
target: {value: '第二条公告'},
});
fireEvent.change(screen.getByLabelText('公告 2 HTML'), {
target: {value: '<section>轮播第二条</section>'},
});
await user.click(screen.getByRole('button', {name: '保存公告'}));
await user.click(screen.getByRole('button', {name: '确认'}));
await waitFor(() => {
expect(upsertAdminCreationEntryBanners).toHaveBeenCalled();
});
const [, payload] = vi.mocked(upsertAdminCreationEntryBanners).mock.calls[0]!;
expect(JSON.parse(payload.eventBannersJson)).toEqual([
{
title: '周末创作赛',
htmlCode: '<section>新的入口公告</section>',
},
{
title: '第二条公告',
htmlCode: '<section>轮播第二条</section>',
},
]);
expect(JSON.parse(payload.eventBannersJson)[0]).not.toHaveProperty(
'description',
);
expect(JSON.parse(payload.eventBannersJson)[0]).not.toHaveProperty(
'coverImageSrc',
);
});
test('创作入口后台把旧结构化公告回显成 HTML 表单', async () => {
vi.mocked(getAdminCreationEntryConfig).mockResolvedValueOnce({
...configResponse,
eventBanners: [
{
title: '旧公告 <标题>',
description: '旧描述 & 需要转义',
coverImageSrc: '/legacy.png',
prizePoolMudPoints: 120,
startsAtText: '2026-06-01',
endsAtText: '2026-06-30',
renderMode: 'structured',
},
],
});
render(
<AdminCreationEntrySwitchPage
mode="announcements"
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
expect(await screen.findByLabelText('公告 1 标题')).toHaveProperty(
'value',
'旧公告 <标题>',
);
expect(screen.getByLabelText('公告 1 HTML')).toHaveProperty(
'value',
'<section><h1>旧公告 &lt;标题&gt;</h1><p>旧描述 &amp; 需要转义</p></section>',
);
});
test('创作入口后台拒绝空公告表单', async () => {
const user = userEvent.setup();
render(
<AdminCreationEntrySwitchPage
mode="announcements"
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
fireEvent.change(await screen.findByLabelText('公告 1 标题'), {
target: {value: ''},
});
fireEvent.change(screen.getByLabelText('公告 1 HTML'), {
target: {value: ''},
});
await user.click(screen.getByRole('button', {name: '保存公告'}));
expect(await screen.findByText('公告 1 标题和 HTML 都不能为空')).toBeTruthy();
expect(upsertAdminCreationEntryBanners).not.toHaveBeenCalled();
});

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@ interface AdminWorkVisibilityPageProps {
const sourceLabels: Record<string, string> = {
puzzle: '拼图',
'puzzle-clear': '拼消消',
'custom-world': '自定义世界',
'jump-hop': '跳一跳',
'wooden-fish': '敲木鱼',

View File

@@ -791,6 +791,67 @@ button:disabled {
overflow-wrap: anywhere;
}
.admin-contract-card {
display: grid;
gap: 12px;
border: 1px solid #eaded2;
border-radius: 8px;
background: #fffdf9;
padding: 12px;
}
.admin-contract-field-list,
.admin-contract-field-editor-list {
display: grid;
gap: 10px;
}
.admin-contract-field-list {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.admin-contract-field-card,
.admin-contract-field-editor {
display: grid;
gap: 6px;
border: 1px solid #eaded2;
border-radius: 8px;
background: #fff8f1;
padding: 10px;
}
.admin-contract-field-card strong,
.admin-contract-field-card span {
min-width: 0;
overflow-wrap: anywhere;
}
.admin-contract-field-card strong {
color: #3d1f10;
font-size: 13px;
}
.admin-contract-field-card span {
color: #8f7868;
font-size: 12px;
font-weight: 650;
}
.admin-contract-dialog {
width: min(100%, 860px);
}
.admin-contract-field-editor-grid {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(120px, 0.6fr) minmax(0, 1fr) auto;
gap: 10px;
align-items: end;
}
.admin-contract-required-toggle {
min-height: 42px;
}
.admin-status {
display: inline-flex;
max-width: 460px;
@@ -948,7 +1009,8 @@ button:disabled {
.admin-two-column-wide,
.admin-form-row,
.admin-filter-grid,
.admin-table-query-grid {
.admin-table-query-grid,
.admin-contract-field-editor-grid {
grid-template-columns: 1fr;
}

View File

@@ -55,7 +55,7 @@ Linux Docker Engine 若要从宿主机 CLI 连到容器内服务,直接用 `ht
## 构建工具链
`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.3.0` 依赖链要求 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`。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。
## 启动与验证

View File

@@ -39,3 +39,5 @@ GENARRATIVE_LLM_PROVIDER=openai-compatible
GENARRATIVE_LLM_BASE_URL=
GENARRATIVE_LLM_API_KEY=
GENARRATIVE_LLM_MODEL=
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=

View File

@@ -2,7 +2,7 @@ name: genarrative-container-loadtest
services:
spacetimedb:
image: clockworklabs/spacetime:v2.3.0
image: clockworklabs/spacetime:v2.4.1
user: root
command:
[

View File

@@ -190,7 +190,7 @@ http {
proxy_set_header X-Request-Id $request_id;
}
location ~ ^/(generated-|healthz) {
location ~ ^/(generated-|healthz|readyz) {
return 404;
}

View File

@@ -11,6 +11,7 @@ GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512
GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320
GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64
GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS=16
GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS=5000
GENARRATIVE_TRACKING_OUTBOX_ENABLED=true
GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox
GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE=500
@@ -109,6 +110,8 @@ WECHAT_AUTHORIZE_ENDPOINT=https://open.weixin.qq.com/connect/qrconnect
WECHAT_ACCESS_TOKEN_ENDPOINT=https://api.weixin.qq.com/sns/oauth2/access_token
WECHAT_USER_INFO_ENDPOINT=https://api.weixin.qq.com/sns/userinfo
WECHAT_STATE_TTL_MINUTES=15
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=
ALIYUN_OSS_BUCKET=
ALIYUN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com

View File

@@ -215,7 +215,7 @@ server {
}
# 开发服仍不恢复旧生成资源代理和健康检查公网入口。
location ~ ^/(generated-|healthz) {
location ~ ^/(generated-|healthz|readyz) {
return 404;
}

View File

@@ -235,7 +235,7 @@ server {
}
# 生产公网不再暴露旧生成资源代理和健康检查入口。
location ~ ^/(generated-|healthz) {
location ~ ^/(generated-|healthz|readyz) {
return 404;
}

View File

@@ -10,11 +10,12 @@ User=genarrative
Group=genarrative
WorkingDirectory=/opt/genarrative/current
EnvironmentFile=/etc/genarrative/api-server.env
Environment="LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib"
ExecStart=/opt/genarrative/current/api-server
Restart=always
RestartSec=5
KillSignal=SIGINT
TimeoutStopSec=30
TimeoutStopSec=90
LimitNOFILE=65535
TasksMax=2048

View File

@@ -19,6 +19,8 @@
从文字需求生成高一致性美术素材流程抽象出的发明专利交底稿见 [【专利交底】一种极低成本快速生成高质量2D小游戏高一致性美术素材的解决方案-2026-05-25.md](./%E3%80%90%E4%B8%93%E5%88%A9%E4%BA%A4%E5%BA%95%E3%80%91%E4%B8%80%E7%A7%8D%E6%9E%81%E4%BD%8E%E6%88%90%E6%9C%AC%E5%BF%AB%E9%80%9F%E7%94%9F%E6%88%90%E9%AB%98%E8%B4%A8%E9%87%8F2D%E5%B0%8F%E6%B8%B8%E6%88%8F%E9%AB%98%E4%B8%80%E8%87%B4%E6%80%A7%E7%BE%8E%E6%9C%AF%E7%B4%A0%E6%9D%90%E7%9A%84%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88-2026-05-25.md)。
微信小程序虚拟支付接入、`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)。
生产部署切换到 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)。
@@ -35,6 +37,74 @@ SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段
AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md](./prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md) 为最新口径:只吸收 MOKU / 幕间类 AI 文游的剧本游乐场、自由行动、AI GM、记忆和模拟器强反馈经验禁止迁入外部社区、支付、榜单、私有存档或回放。
前端 Server-Sent Events 客户端传输层收口到 `src/services/sseStream.ts`事件边界、UTF-8 flush、JSON 解析跳过和提前取消约定见 [【前端架构】SSE客户端传输层收口约定-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91SSE%E5%AE%A2%E6%88%B7%E7%AB%AF%E4%BC%A0%E8%BE%93%E5%B1%82%E6%94%B6%E5%8F%A3%E7%BA%A6%E5%AE%9A-2026-06-03.md)。
平台入口公开作品身份、跨玩法去重、公开作品流聚合、推荐运行态 kind 判定、推荐 runtime 启动意图、ready 判定和最新排序收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%B9%B3%E5%8F%B0%E5%85%A5%E5%8F%A3PublicGalleryFlowModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
统一作品详情页的玩法 kind、详情打开策略、自有作品动作模式、编辑 / 点赞 / 改造 / 启动意图和公开详情映射收口到 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`;抓大鹅公开详情映射与启动 / 编辑 Adapter 的素材归一仍归 `platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md](./technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md)。
创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,生产 Hub 只接收 `CreationWorkShelfItem[]` 与 UI 状态,不再接收各玩法 raw items 和回调列阵,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
作品架删除确认的标题、删除说明、草稿 notice key 和拼图派生稳定 ID 收口到 `src/components/platform-entry/platformCreationWorkDeleteFlow.ts`,平台壳只保留删除 API、刷新、错误和页面跳转副作用规则见 [【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md](./technical/【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md)。
创作入口点击的占位、隐藏模板拦截、未知入口 no-op 与工作台启动目标收口到 `src/components/platform-entry/platformCreationLaunchModel.ts`,壳层只执行启动前准备、错误提示和受保护动作,规则见 [【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md)。
平台入口公开码搜索的用户 ID、陶泥号、RPG 作品号、各玩法作品号前缀、per-play 公开码匹配、详情卡 DTO 映射和失败回退顺序收口到 `src/components/platform-entry/platformPublicCodeSearchModel.ts`,壳层只按计划执行网络读取、详情打开和错误归航副作用,规则见 [【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md)。
个人“玩过作品”面板的玩法别名、`worldKey` 前缀兜底、RPG 公开详情 payload 和大鱼吃小鱼 gallery miss fallback 收口到 `src/components/platform-entry/platformPlayedWorkOpenModel.ts`壳层只执行面板关闭、gallery 读取、详情打开和错误提示副作用,规则见 [【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md)。
平台入口生成页进度 tick 的 stage 到生成状态映射、终态判定和视觉小说轻量生成特例收口到 `src/components/platform-entry/platformGenerationProgressTickModel.ts`,壳层只保留 `Date.now()``setInterval` 和 cleanup 副作用,规则见 [【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md)。
平台壳的拼图 runtime 恢复 work、方洞 session draft 转 profile、视觉小说 work detail 转 Agent session、跳一跳 pending session、敲木鱼 detail 恢复 session、敲木鱼生成中作品摘要和敲木鱼 pending session DTO 映射收口到 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`壳层只保留网络、状态、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md)。
平台小游戏生成状态的恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并、抓大鹅生成资产旁路进度合并和 ready / generating 判定收口到 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts`,壳层只保留 API、后台任务、React state、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md)。
平台小游戏草稿恢复和提交所需的拼图 / 抓大鹅表单 payload、拼图作品更新 payload、拼图编译 action、跳一跳 / 敲木鱼生成 action、pending metadata 与拼图 form-only 草稿判定收口到 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,壳层只保留 API、Action 执行、background task 与状态副作用,规则见 [【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md)。
平台拼图生成完成后刷新恢复的草稿归一化与可恢复完成态判定收口到 `src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts`恢复链路只有在首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才抬为 ready规则见 [【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md)。
拼图排行榜提交回包后的服务端 run 快照合并收口到 `src/components/platform-entry/platformPuzzleRuntimeStateModel.ts`只合并排行榜、run 身份、通关数上限和下一关 handoff保留前端即时裁决的关卡状态与棋盘规则见 [【前端架构】PlatformPuzzleRuntimeStateModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPuzzleRuntimeStateModel收口计划-2026-06-04.md)。
后端拼图发布 / 待发布门槛收紧到首图、关卡画面、UI spritesheet 与关卡背景资产包完整,`module-puzzle` 的 preview blockers 与 `api-server` 的 session stage 判定保持同一规则,方案见 [【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md](./technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md)。
平台入口个人钱包本地 delta、dashboard 乐观更新与服务端快照对账规则收口到 `src/components/platform-entry/platformProfileWalletDeltaModel.ts`,平台壳只保留 API、ref 与 state 副作用,规则见 [【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md)。
Bark Battle 草稿三图完整性、生成状态归一、作品架摘要恢复草稿配置、发布快照 / 发布回包资产兜底和草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 映射收口到 `src/components/platform-entry/barkBattleWorkCache.ts`,规则见 [【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md](./technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md)。
平台首页推荐 runtime 的匿名 Runtime Guest Token、已登录 background auth、非 embedded no-op 和拼图 isolated/default auth mode 计划收口到 `src/components/platform-entry/platformRecommendRuntimeAuthModel.ts`,规则见 [【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md)。
平台首页推荐 runtime 自动启动的桌面 / Tab / stage / loading gate、active entry 查找、ready 判定和 clear/start/noop 决策收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】PlatformRecommendRuntimeAutoStart收口计划-2026-06-04.md](./technical/【前端架构】PlatformRecommendRuntimeAutoStart收口计划-2026-06-04.md)。
RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,壳层只保留 session/profile 编排和结果页 props 传递,规则见 [【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md)。
平台入口创作生成通知、pending 作品架占位、作品详情更新回填、失败覆盖、跨玩法草稿打开优先级、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
平台入口创作恢复 URL 私有 query、初始恢复判定、创作直达恢复目标解析、恢复目标身份匹配、跳一跳 / 敲木鱼恢复阶段落点、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts``src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。
平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.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)。
抓大鹅 runtime profile 的公开详情转 work、session draft 转 profile、生成背景资产提升和 run/profile/public detail 素材优先级收口到 `src/components/platform-entry/platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91Match3DRuntimeProfile%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
公开作品分类选项、搜索、跨来源去重、今日筛选、排行排序和时间戳解析收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicGalleryViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
公开作品的玩法类型 label、公开作者 lookup 与游玩 / 改造 / 点赞等紧凑计数格式收口到 `src/components/rpg-entry/rpgEntryWorldPresentation.ts`,规则见 [【前端架构】PublicWorkPresentation收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicWorkPresentation%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
推荐 feed 的公开作品去重、普通内容过滤、active 窗口与上一条 / 下一条回环选择也收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RecommendFeedViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
移动端推荐首页 swipe deck 的拖拽阈值、offset clamp、commit 方向、rail class 和分享文案收口到 `src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts`,规则见 [【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md](./technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md)。
排行频道的默认 tab、tab 文案、空态文案、排序字段与指标 label/value 收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】RankingViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RankingViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
每日任务卡片与任务中心弹窗的任务选择、进度、状态标签和按钮文案收口到 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,规则见 [【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileTaskViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
个人数据卡、钱包 chip 与“玩过”弹窗的计数、时长、作品类型和作品号展示收口到 `src/components/rpg-entry/rpgEntryProfileDashboardPresentation.ts`,规则见 [【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileDashboardPresentation%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
个人资金展示的账单来源、金额正负号、余额兜底、充值价格、商品主值和会员摘要收口到 `src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts`,规则见 [【前端架构】ProfileFundsViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileFundsViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
## 推荐阅读顺序
1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。

View File

@@ -0,0 +1,77 @@
# 拼消消玩法模板 PRD
日期:`2026-05-30`
## 目标
新增玩法模板 **拼消消**,工程域与 `playId` 均为 `puzzle-clear`,公开作品码前缀为 `PC-`。拼消消以拼图的交换 / 拖拽手感为原型,但运行态规则独立:玩家移动 1x1 卡牌碎片,把同一复合图案组拼成完整矩形后消除;消除产生空位后,由顶部对应纵列的卡牌准备区下落补位。
首版必须完成公开闭环:
```text
创作入口 -> 轻表单工作台 -> 独立生成页 -> 结果页 -> 试玩 -> 发布 -> 统一作品详情 -> 正式 runtime -> 基础统计 / 作品架 / 广场
```
## 创作工具平台接入声明
- 工作台模式:表单 / 图片输入创作工作台。
- 创作链路:入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态。
- 单图资产槽位:
- `board-background` / `ui-background` / `中央场地底图` / `boardBackgroundPrompt` 优先、空值时回退 `themePrompt`,并支持用户上传图 / 写回 `draft.boardBackgroundAsset``draft.boardBackgroundPrompt``work.boardBackgroundAsset``work.boardBackgroundPrompt` / 允许历史图 / 允许 AI 重绘。
- 中央场地底图的字段名沿用平台表面口径实际作用是玩家逐步消除清空中央棋盘后慢慢看到的主题目标图AI 生成尺寸必须与中央棋盘一致,使用 1:1 正方形画面。prompt 必须强绑定主题、画面精致、强表现力并一眼体现主题,带来探索、揭开全貌和追求目标完成的感受;不得继续要求“画面干净”或“适合作为卡牌棋盘底图”。
- 系列素材槽位:
- `batchId=puzzle-clear-pattern-atlas-v1`
- `sheetSpec`4 张素材工作表,每张 `1024x1536` 竖版,后台按 `4 列 x 6 行` 裁切,每个 1x1 单元为 `256x256`;服务端再把切片合成一张 `10x10 / 2560x2560` 最终 atlas。复合图案组总数为 `35`,形状配比 `1x2=23``1x3=5``2x2=4``2x3=3`,总计 `95` 个 1x1 卡牌切片。
- `slotSpecs`:每个复合图案组一个 `patternGroup`,服务端预排 `groupId``shape`、atlas 坐标和 1x1 切片坐标。
- 切图规则:生图 prompt 只要求复合图案组能按 4x6 素材工作表均等切成 1x1 方形小份,不允许模型在图上绘制切分线、边框、网格线或裁切参考线;服务端按 sheet 布局直接裁出 1x1 卡牌碎片,校验每个编号占格数与领域图案组面积一致,再合成最终 atlas写入 `patternGroups[]``cardAssets[]`
- 透明化规则:首版保留完整方形卡面,不强制透明化;若 provider 输出带边框、切分线、网格、裁切参考线或文字,生成任务失败并回写审计。
- 失败回写:生成页写回 `generationStatus=failed` 与失败阶段;结果页保留重试入口。
- 局部重生成v1 允许整批 4 张素材工作表重试,不做单组局部重生。
- API 命名空间:`/api/creation/puzzle-clear/...``/api/runtime/puzzle-clear/...`
- 业务真相草稿、发布、runtime snapshot、胜负、补牌、防死局、统计均由后端裁决前端只做动画和交互表现。
- 创作工具模式例外:无。
- 验证命令:`npm run check:encoding``npm run typecheck``npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts``npm run test -- src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx``cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml``cargo test -p api-server puzzle_clear --manifest-path server-rs/Cargo.toml -- --nocapture`;涉及 SpacetimeDB schema 后运行 `npm run spacetime:generate``npm run check:spacetime-runtime-access``npm run check:spacetime-schema``npm run check:server-rs-ddd`
## 工作台字段
| 字段 | 契约字段 | 默认值 | 校验 | 落库 |
| --- | --- | --- | --- | --- |
| 作品标题 | `workTitle` | 空 | 必填1-30 字 | session draft / work profile |
| 简介 | `workDescription` | 空 | 0-120 字 | session draft / work profile |
| 主题词 | `themePrompt` | 空 | 必填1-80 字 | 生成 prompt 与草稿 |
| 场地底图主题词 | `boardBackgroundPrompt` | 空 | 0-80 字;为空时底图生成回退 `themePrompt` | session draft / work profile / 主题目标图生成 prompt |
| 中央场地底图 | `boardBackgroundAsset` | 空 | 上传或 AI 生成至少一种 | 单图资产槽位 |
| AI 生成底图 | `generateBoardBackground` | `true` | boolean | 生成编排参数 |
规则参数不开放创作者编辑:棋盘尺寸、倒计时、消除次数、形状解锁、防死局发牌和半锁定规则固定。
## 运行规则
| 关卡 | 棋盘 | 目标消除 | 倒计时 | 解锁形状 |
| --- | --- | --- | --- | --- |
| 1 | 6x6 | 35 | 10 分钟 | 1x2、1x3、2x2、2x3 |
- 开局每个小格子从背面翻向正面。
- 可消除图由横向或纵向复合图案组组成,最小消除单位为两张图拼接。
- 完成一个复合图案组后,该组所有 1x1 卡牌碎片消除。
- 消除后空位按列由顶部卡牌准备区下落补齐。
- 每次补牌至少保证掉落卡中有一张可以与场上剩余某张卡拼接,防止死局。
- 非 2 格消除时,若场上已有局部完成的半锁定拼接组,补牌不得破坏它。
- 半锁定拼接组可整体拖动;玩家用外部单格撞入组内某格时,只交换该格,组其余部分保留,组状态退回半完成。
- 超时只判当前关失败,可重试当前关;完成 35 次目标并清空当前棋盘后整局完成。
## 结果页
结果页展示:素材 atlas、中央场地底图、发布状态、试玩入口和失败重试。结果页不写功能说明类文案不开放规则编辑器不新增排行榜配置。
## 统计
首版只记录正式 `published` run
- 开局。
- 全局完成。
- 当前关失败。
- 耗时。
- 消除统计。
草稿试玩不写正式统计不进入排行榜v1 不做排行榜。

View File

@@ -205,10 +205,11 @@ WF-*
1. 若 payload 已包含上传/录音音频资产,`compile-draft` 跳过音效生成,直接持久化该资产;
2. 若 payload 已上传或录制音频,则直接写回 `hitSoundAsset`
3. 若两者都没有,后端写回默认木鱼音 `/wooden-fish/default-hit-sound.mp3`
4. 音效资产必须包含可播放地址、对象键、asset object id、来源和可选时长
5. 通用创作音频接口当前对 `wooden_fish``hit_sound` 目标返回 `410 Gone`,不得在创作流程中按提示词生成音效
6. `spacetime-client` 不得自行合成 `/generated-wooden-fish-assets/...` 音效占位路径;缺少真实 `hitSoundAsset` 时应使用默认木鱼音兜底展示与播放。
3. 麦克风录制音频在保存前由前端自动裁掉开头连续静音段;上传音频不做裁剪,裁剪失败时保留原始录音继续保存
4. 若两者都没有,后端写回默认木鱼音 `/wooden-fish/default-hit-sound.mp3`
5. 音效资产必须包含可播放地址、对象键、asset object id、来源和可选时长
6. 通用创作音频接口当前对 `wooden_fish` `hit_sound` 目标返回 `410 Gone`,不得在创作流程中按提示词生成音效;
7. `spacetime-client` 不得自行合成 `/generated-wooden-fish-assets/...` 音效占位路径;缺少真实 `hitSoundAsset` 时应使用默认木鱼音兜底展示与播放。
### 6.3 封面
@@ -371,7 +372,7 @@ finish
音频播放:
1. 前端使用小复音池;
1. 前端使用 10 路小复音池;
2. 设置最小播放间隔,避免极端连点导致浏览器抖动;
3. 点击计数不能因为音频节流而丢失;
4. 签名 URL 未就绪时先静音表现,不请求裸 generated 私有路径。

View File

@@ -2,491 +2,195 @@
## 1. 目标
新增一个可创作、可试玩、可发布的玩法模板:
`jump-hop` 重定义为竖屏俯视角平台跳跃游戏。创作者只输入主题,系统生成一张该主题的 `5x5` 地块资源图集,切成 25 个 2D 地块素材;运行态使用抠除白底后的陶泥儿 logo 透明 PNG 作为玩家角色,并和这些 2D 地块资产组成无限平台流。
```text
跳一跳
```
首版目标:
本模板参考拼图模板的创作闭环,沿用“创作入口 -> 生成过程页 -> 结果页 -> 试玩 -> 发布”的平台链路,但玩法本体改为俯视角 / 等距视角的跳跃闯关。
首版要求:
1. 初始草稿生成时,角色形象单独调用一次生图
2. 初始草稿生成时,地块只调用一次生图,输出 3D 视图的 2D 图片图集
3. 运行态不接真实 3D 网格,不生成 GLB / glTF
4. 作品可以直接进入试玩和发布
1. 创作输入只保留主题,标题、简介、标签和提示词由系统派生;
2. image2 只生成一张 `5x5` 地块图集,后端均匀切成 25 张 PNG
3. 角色不再单独生图v1 使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG
4. 运行态每屏只展示 3 个地块:当前地块、目标地块、下一预览地块;
5. 操作方式为按住屏幕向后拖动蓄力,松手后角色向拖拽反方向弹出
6. 只要落点未命中下一个地块,本局立即失败并冻结计时
7. 成绩记录成功跳跃次数和游戏时长
8. 排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长排序为成功跳跃次数降序、游戏时长升序、更新时间升序
## 2. 模板定位
模板 ID
- 模板 ID`jump-hop`
- 展示名:`跳一跳`
- 工程域:`jump-hop`
- 创作入口卡:`subtitle = 主题驱动平台跳跃``imageSrc = /creation-type-references/jump-hop.webp`
- 运行态:`DOM 平台 / DOM 角色 + Three.js 透明扩展层 + DOM HUD`
- 画面比例:移动端竖屏优先,桌面端居中承载 `9:16`
- 素材策略2D 地块图集 + 陶泥儿 logo 透明角色
- 渲染分层:生成地块切片必须由 DOM 平台层直接渲染为图片;角色必须由 DOM 透明 PNG 层渲染并保持最高层级Three.js 透明画布只作为后续扩展层,不能把地块图片或角色回退为 WebGL 占位材质
本玩法不是横版平台跳跃,也不是关卡制闯关。平台从屏幕下方向上无限延展,目标地块在当前地块上方不同 x 轴位置随机出现。
## 3. 创作工具平台接入声明
- 工作台模式:表单输入创作工作台
- 创作链路:入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态
- 单图资产槽位无独立角色图槽位v1 固定使用陶泥儿 logo 透明 PNG 角色
- 系列素材槽位:
- `batchId = jump-hop-tile-atlas`
- `sheetSpec = 5x5 / 1:1 / PNG / 纯绿色绿幕背景 / 后端切图透明化`
- `slotSpecs = tile-01 ... tile-25`,每个 slot 必须对应唯一 OSS path / `assetObjectId`
- 切图规则:按原图宽高均分为 5 行 5 列,从上到下、从左到右切出 25 张 PNG每格透明化后只保留最大的 alpha 连通主体,再裁边并补透明安全边,避免相邻格越界碎片或方形杂边进入 tile
- 透明化规则:生成时要求绿幕背景,后端上传 OSS 前抠成透明 PNG并清理与主体分离的小型残片
- 失败回写:生成失败时 session 保持 failed可从生成页重试
- 局部重生成:结果页允许重生成地块图集,仍只调用一次 image2前端展示生成图时以 `assetObjectId` 作为刷新键,避免同一路径重写后的旧签名或旧缓存
- API 命名空间:`/api/creation/jump-hop/*``/api/runtime/jump-hop/*`
- 业务真相:后端裁决落点、失败、成功跳跃次数、冻结时长和排行榜
- 创作工具模式例外:无
- 验证命令:`npm run check:encoding``npm run typecheck``cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml`
## 4. 创作输入
主题是唯一必填项。工作台不展示角色提示词、地块提示词、风格卡、难度卡、终点氛围或规则说明。
提交后系统自动派生:
1. 作品标题:主题为空白修剪后的短标题,默认前缀不外露;
2. 作品简介:基于主题生成一句短简介;
3. 标签:`跳一跳``休闲` 和主题关键词;
4. 地块提示词:围绕主题生成 25 个风格一致的俯视角清爽游戏化 2D 平台素材,每一块都是符合主题的单独可跳跃平台;实际 image2 prompt 使用“独立可落脚平台素材 / 平台裸素材 / 完整平台”措辞,不再把正向主体描述成图标集或游戏界面资源;
5. 初始平台流参数:固定 v1 标准参数,不让创作者手工调规则。
## 5. 地块图集
image2 只生成一张 `1:1` 图片,画面为 `5x5` 均匀分布平台裸素材;实际提示词必须先约束“画面只包含 25 个独立跳一跳可落脚平台素材”,并明确不是游戏界面、棋盘、背包、装备栏或图标集页面。
图集要求:
1. 每格只放一个完整地块资源;
2. 资源为纯 2D 平面素材,但要表现为符合主题且有设计感的俯视角清爽游戏化立体感平台,有顶面、主体内部明暗和清晰轮廓;主题元素必须直接成为平台主体,例如“水果”应生成苹果切片、橙子切片、西瓜块、草莓、菠萝、香蕉等水果造型平台;
3. 25 个地块来自同一主题、同一光向和同一材质体系;
4. 背景为纯绿色绿幕,方便后端透明化;
5. 不包含角色、文字、水印、UI、游戏面板、棋盘、背包、装备栏、按钮、标题、外层边框、网格线、场景背景、落地投影、接触阴影、方形阴影、方形底板、白底、灰底或黑底
6. 地块不能跨格、贴边或进入相邻格,主体必须居中并保留至少 18% 纯绿色安全留白;每个平台之间只能是纯绿色空白,不画容器框或棋盘格。
切片顺序固定为:
```text
jump-hop
tile-01 tile-02 tile-03 tile-04 tile-05
tile-06 tile-07 tile-08 tile-09 tile-10
tile-11 tile-12 tile-13 tile-14 tile-15
tile-16 tile-17 tile-18 tile-19 tile-20
tile-21 tile-22 tile-23 tile-24 tile-25
```
用户展示名:
运行态随机使用这 25 个地块作为后续平台外观。起点地块可复用第一个切片,其余平台从完整池中随机选择。
## 6. 运行态规则
### 6.1 平台流
运行态从底部初始地块开始,后续地块持续向屏幕上方生成。每次相机窗口只保留 3 个地块可见:
1. 当前地块;
2. 目标地块;
3. 下一预览地块。
服务端保存当前 run 的路径缓冲,并在每次成功落地后按同一 seed 补齐后续地块。前端只展示服务端快照,不自行生成正式路径。
### 6.2 操作
1. 用户按住当前地块或画面;
2. 向后拖动形成蓄力向量;
3. 松手后角色沿拖拽反方向弹出;
4. 拖拽距离决定力度,拖拽方向决定落点方向;
5. 力度和方向都由前端提交给后端裁决。
手感参数固定由后端 `module-jump-hop` 提供:`chargeToDistanceRatio = 0.008`。该值表示同等世界跳跃距离只需要旧版 `0.004` 配置的一半屏幕拖动距离;旧作品运行时若仍携带 `0.004`,开局归一化为 `0.008`
松手后前端必须立即生成 `visualJump`,用当前角色位置作为起点、前端预测落点作为终点,播放约 `560ms` 的角色飞行动画;角色从当前地块弹向预测落点,蓄力阶段角色应沿拖拽方向明显拉长,落地后再向反方向回弹两次。动画期间 DOM 地块窗口保持在本次起跳前的 3 块布局,动画路径不得等待后端新 run。若后端新 run 晚于飞行动画返回,角色必须停在预测落点等待,直到新 run 到达后再把显示态切到后端返回的最新 run并进入约 `1440ms` 的相机推进过渡。推进过渡中,地块 DOM 层和 DOM 角色层必须放在同一个相机层里统一位移,不允许 p1/p2 单独改 `top/left` 做过渡;旧当前地块随相机推进自然离开视野,新预览地块从上方自然露出,避免角色和地块不同步或闪现。相机推进必须同时携带 X/Y 偏移,从旧目标地块位置斜向滑到新当前地块聚焦位置,不允许先横向瞬切居中后再只做纵向滑动。地块可以保留当前 / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 CSS `transform: scale(...)` 表达,并在相机推进期间用同一 `1440ms` 缓动过渡;不得通过直接改宽高造成瞬切变大。当前地块高亮不得额外通过 CSS `scale` 放大。该动画只属于表现层,命中、失败、成功跳跃次数和冻结时长仍以后端裁决为准。
### 6.3 判定
1. 目标永远是当前地块后的下一个地块;
2. 落点进入下一个地块落地半径,则成功;
3. 落点未进入下一个地块落地半径,则失败;
4. 失败后状态改为 `failed`,计时冻结;
5. v1 没有通关状态、combo、perfect 或生命数。
### 6.4 计分与时间
- 成功跳跃次数:每成功落到下一个地块后 `+1`
- 游戏时长:`startedAtMs``finishedAtMs`,失败时冻结;
- 运行中时长由前端根据服务端 `startedAtMs` 展示;
- 失败后只展示冻结时长。
## 7. 排行榜
排行榜按作品维度生成。每位玩家只保留 1 条最佳记录。
排序规则固定为:
```text
跳一跳
successfulJumpCount desc -> durationMs asc -> updatedAt asc
```
体验关键词
1. 俯视角
2. 等距感地块
3. 单局闯关
4. 长按蓄力,松手起跳
5. 轻量休闲
首版采用竖屏优先的移动端体验,桌面端保持居中展示,画面比例以 `9:16` 为主。参考图的核心视觉要点是:
1. 大面积留白或浅色渐变背景;
2. 角色站在单个地块上;
3. 地块有明显顶面、侧面和投影;
4. 整体是俯视角 / 等距视角,而不是横版平台跳跃;
5. UI 克制,只保留必要控制,不堆说明文案。
## 3. 与拼图模板的复用边界
可以复用:
1. 创作入口和模板分流
2. 生成过程页
3. 结果页的草稿保存、返回编辑、试玩、发布、分享链路;
4. 作品架展示和草稿恢复口径;
5. 平台统一的发布与公开展示流程
不复用:
1. 拼图关卡切片逻辑;
2. 拼图拖拽拼块逻辑;
3. 拼图 UI 背景和多关卡编辑结构
4. 任何方格拼合语义。
## 4. 工程接入范围
首版需要做到完整玩法闭环,不只做入口占位。
新增前端阶段:
```text
jump-hop-workspace
jump-hop-generating
jump-hop-result
jump-hop-runtime
jump-hop-gallery-detail
```
新增前端组件建议:
1. `src/components/unified-creation/workspaces/JumpHopCreationWorkspace.tsx`
2. `src/components/jump-hop-result/JumpHopResultView.tsx`
3. `src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`
4. `src/services/jump-hop/jumpHopClient.ts`
新增共享契约建议:
1. `packages/shared/src/contracts/jumpHop.ts`
2. `server-rs/crates/shared-contracts/src/jump_hop.rs`
新增后端模块建议:
1. `server-rs/crates/module-jump-hop`:纯领域规则,包含路径生成、蓄力换算、落点判定、通关 / 失败状态机;
2. `server-rs/crates/api-server/src/jump_hop.rs``src/jump_hop/` 子模块HTTP handler、生成编排、资产保存和 DTO 映射;
3. `server-rs/crates/spacetime-module/src/jump_hop.rs`session、work profile、runtime run、公开 view 和 reducer / procedure
4. `server-rs/crates/spacetime-client/src/jump_hop.rs`api-server 访问 SpacetimeDB 的 facade
5. `server-rs/crates/api-server/src/modules/jump_hop.rs`:路由挂载。
入口配置事实源必须走 SpacetimeDB `creation_entry_type_config` 默认种子和后台配置接口,不新增前端硬编码入口配置。
## 5. 创作输入
创作者需要填写以下内容:
1. 作品主题描述,必填;
2. 角色形象描述,必填;
3. 地块风格卡,必选;
4. 难度,必选;
5. 可选的终点氛围或节奏偏好。
推荐的最小输入形态是:
1. 一句话主题;
2. 角色一句话描述;
3. 风格卡;
4. 难度卡。
不在首版开放手工拖拽平台编辑器。平台路径、地块间距和终点位置由系统自动生成,创作者只负责风格与难度选择。
### 5.1 地块风格卡
建议提供以下风格:
1. 极简积木;
2. 纸模玩具;
3. 霓虹玻璃;
4. 森林石块;
5. 未来金属;
6. 自定义。
### 5.2 难度
建议提供以下离散档位:
1. 轻松;
2. 标准;
3. 进阶;
4. 挑战。
难度主要影响:
1. 平台路径长度;
2. 平台间距;
3. 可落点容差;
4. 完美落点窗口;
5. 终点前的节奏变化。
## 6. 生成规则
本模板必须把生图责任拆成两条独立链路:
### 6.1 角色形象只生一次
角色形象必须只调用一次生图,输出一张可直接进入运行态的主角色图。
角色图要求:
1. 单人主角;
2. 全身可见;
3. 透明背景;
4. 角色站姿或轻微前倾姿态;
5. 镜头和透视必须匹配俯视角场景;
6. 不要求多视角,不要求多帧动画图集。
角色图生成后作为作品级锚点资产使用,结果页、封面合成、试玩和发布都复用同一张图。后续如果只修改标题、标签、难度或路径,不应默认重新生角色。只有用户在结果页明确点击“重生成角色”时,才允许再调用一次角色生图。
### 6.2 地块只生一次图集
地块必须只调用一次生图,输出一张 3D 视图的 2D 图片图集,再由后端切成运行态可用的地块资产。该图集使用跳一跳专用 `2行*3列` 六格布局,不套用通用“每个物品一行、每行 n 个不同视图”的系列素材模型。
地块图集要求:
1. 统一使用等距 / 俯视角;
2. 必须表现出顶面、侧面和投影;
3. 必须与角色图保持同一光向;
4. 必须有清晰的立体层次,但仍然是 2D 图片;
5. 六格必须按固定顺序包含以下地块类型:
- 起点地块;
- 普通地块;
- 目标地块;
- 终点地块;
- 奖励地块;
- 视觉强调地块。
固定格位为:
| 格位 | tileType | 语义 |
| --- | --- | --- |
| 第 1 行第 1 列 | `start` | 起点地块 |
| 第 1 行第 2 列 | `normal` | 普通地块 |
| 第 1 行第 3 列 | `target` | 目标地块 |
| 第 2 行第 1 列 | `finish` | 终点地块 |
| 第 2 行第 2 列 | `bonus` | 奖励地块 |
| 第 2 行第 3 列 | `accent` | 视觉强调地块 |
图集生成后按地块类型切分并去掉背景,运行态直接消费切好的 PNG不在前端做复杂拼接。只有用户在结果页明确点击“重生成地块”时才允许再调用一次地块图集生图。
### 6.3 不新增第三次生成
首版不把封面、分享海报、路径预览再拆成第三次图像生成。封面和分享图必须由角色图 + 地块图集在本地或后端轻量合成,不额外增加新的角色生图次数。
### 6.4 路径元数据
除图片资产外,系统还必须生成跳跃路径元数据:
1. 平台序列;
2. 平台中心点;
3. 平台宽度;
4. 平台间距;
5. 终点索引;
6. 评分和容差参数。
路径由领域规则自动生成,创作者不直接编辑坐标。路径元数据不依赖 LLM 或图片生成。
### 6.5 推荐的难度区间
| 难度 | 平台数量 | 平台间距 | 节奏 |
| --- | ---: | --- | --- |
| 轻松 | 12 - 14 | 短 | 宽容 |
| 标准 | 16 - 18 | 中 | 稳定 |
| 进阶 | 20 - 24 | 中长 | 紧凑 |
| 挑战 | 26 - 32 | 长 | 高压 |
平台宽度和容差由系统按难度自动缩放,不要求创作者手工填写。
## 7. 契约草案
### 7.1 草稿结构
`JumpHopDraft` 至少包含:
1. `templateId = "jump-hop"`
2. `templateName = "跳一跳"`
3. `profileId`
4. `workTitle`
5. `workDescription`
6. `themeTags`
7. `difficulty`
8. `stylePreset`
9. `characterPrompt`
10. `tilePrompt`
11. `characterAsset`
12. `tileAtlasAsset`
13. `tileAssets[]`
14. `path`
15. `coverComposite`
16. `generationStatus`
### 7.2 资产结构
`JumpHopCharacterAsset` 至少包含:
1. `assetId`
2. `imageSrc`
3. `imageObjectKey`
4. `assetObjectId`
5. `generationProvider`
6. `prompt`
7. `width`
8. `height`
`JumpHopTileAsset` 至少包含:
1. `tileType`
2. `imageSrc`
3. `imageObjectKey`
4. `assetObjectId`
5. `sourceAtlasCell`
6. `visualWidth`
7. `visualHeight`
8. `topSurfaceRadius`
9. `landingRadius`
`tileType` 首版限定:
```text
start | normal | target | finish | bonus | accent
```
### 7.3 路径结构
`JumpHopPath` 至少包含:
1. `seed`
2. `difficulty`
3. `platforms[]`
4. `finishIndex`
5. `cameraPreset`
6. `scoring`
`JumpHopPlatform` 至少包含:
1. `platformId`
2. `tileType`
3. `x`
4. `y`
5. `width`
6. `height`
7. `landingRadius`
8. `perfectRadius`
9. `scoreValue`
### 7.4 运行态快照
`JumpHopRunSnapshot` 至少包含:
1. `runId`
2. `profileId`
3. `status = playing | failed | cleared`
4. `currentPlatformIndex`
5. `score`
6. `combo`
7. `lastJump`
8. `startedAtMs`
9. `finishedAtMs`
`lastJump` 至少包含:
1. `chargeMs`
2. `jumpDistance`
3. `targetPlatformIndex`
4. `landedX`
5. `landedY`
6. `result = miss | hit | perfect | finish`
## 8. API 草案
HTTP 路由建议:
```text
POST /api/creation/jump-hop/sessions
GET /api/creation/jump-hop/sessions/{sessionId}
POST /api/creation/jump-hop/sessions/{sessionId}/actions
POST /api/creation/jump-hop/works/{profileId}/publish
GET /api/runtime/jump-hop/works/{profileId}
POST /api/runtime/jump-hop/runs
POST /api/runtime/jump-hop/runs/{runId}/jump
POST /api/runtime/jump-hop/runs/{runId}/restart
GET /api/runtime/jump-hop/gallery
GET /api/runtime/jump-hop/gallery/{publicWorkCode}
```
动作类型建议:
```text
compile-draft
regenerate-character
regenerate-tiles
update-work-meta
update-difficulty
```
`compile-draft` 是长耗时动作。前端进入生成页后必须持久化 `generationStatus=generating`,刷新后能从作品架恢复生成页。失败前需要复读 session如果后端已经完成草稿并写回资产前端按成功收尾。
## 9. SpacetimeDB 表和 view
建议新增表:
1. `jump_hop_agent_session`
2. `jump_hop_work_profile`
3. `jump_hop_runtime_run`
4. `jump_hop_event`
5. `jump_hop_leaderboard_entry`,首版可暂不对外展示;
6. `jump_hop_gallery_view`
7. `jump_hop_gallery_card_view`
表结构新增字段必须按 SpacetimeDB 迁移规则放在结构体末尾并设置明确默认值。新增或调整表、reducer、procedure、view 后必须同步 `migration.rs`、表目录、生成 bindings并执行 `npm run check:spacetime-schema`
公开列表主路径应优先订阅 `jump_hop_gallery_card_view` 后在 `api-server` 本地 cache 构造列表响应,不要让每个 HTTP 请求都调用 SpacetimeDB procedure 组装全量列表。
## 10. 结果页能力
结果页必须展示:
1. 作品标题;
2. 作品简介;
3. 角色形象;
4. 地块图集;
5. 路径预览;
6. 标签;
7. 试玩;
8. 发布;
9. 返回编辑。
结果页还必须支持:
1. 单独重生成角色;
2. 单独重生成地块图集;
3. 单独修改标题和简介;
4. 单独调整标签和难度。
结果页不应强制再走一次封面生图。封面只做合成,不新增图像生成调用。
## 11. 运行态规则
运行态采用 2D 表现,但画面视觉上必须保留参考图那种俯视角 / 等距感。
### 11.1 核心玩法
1. 玩家长按蓄力;
2. 松手后角色按蓄力长度起跳;
3. 跳跃距离决定是否落到下一个地块;
4. 落在目标区域内判定成功;
5. 落在地块外或越界判定失败;
6. 到达终点地块判定通关。
### 11.2 判定规则
1. 只做一个当前局面的起跳判定;
2. 不做复杂连招动作树;
3. 不新增生命数、体力、回合数;
4. 不新增计时赛作为首版核心规则;
5. 不把前端动画结果当成最终真相,通关与失败必须能回写运行态状态。
### 11.3 角色动画
角色不需要多帧生图,运行态只通过位移、缩放、轻微旋转和投影变化表达:
1. 蓄力时轻微压缩;
2. 起跳时向上抬升;
3. 空中保持可读轮廓;
4. 落地时轻微弹性回弹;
5. 失败时从地块边缘跌落。
### 11.4 摄像机与构图
1. 相机以当前角色和下一地块为中心;
2. 至少保证下一个落点一直可见;
3. 画面要留出顶部和底部的 UI 安全区;
4. 不要把地块做得太满,保留参考图那种疏朗感。
### 11.5 UI
运行态 UI 只保留必要元素:
1. 分数;
2. 暂停;
3. 重新开始;
4. 分享;
5. 结算按钮。
不默认展示大段规则说明。首进如果需要引导,只能用一次轻量提示,不允许常驻一屏的说明文案。
## 12. 视觉规范
本模板的视觉目标是“像 3D但仍是 2D 图片”。
必须遵守:
1. 平台有明确厚度;
2. 侧面可见分层或材质变化;
3. 投影统一且方向一致;
4. 背景干净,颜色克制;
5. 角色尺寸在小屏上依然可读;
6. 地块不能出现过多文字、按钮或装饰信息;
7. 不能把运行态做成重 UI 面板。
建议的背景策略:
1. 以静态浅色渐变或纯色背景为主;
2. 不把背景也做成每次都生成的重资产;
3. 让地块和角色成为画面的第一视觉焦点。
## 13. 发布后体验
发布后的作品必须支持:
1. 进入作品架和公开展示;
2. 分享;
3. 试玩;
4. 重新进入结果页编辑。
发布后的卡片封面应优先由角色图和地块图合成,不要求单独再生成封面图。
首版不新增排行榜、回放和对局对抗。后续如要扩展排行,可另起版本,不要塞进首版模板范围。
## 14. 验收
1. 创作入口能看到 `跳一跳` 模板;
2. 创作者可以填写主题、角色描述、风格和难度;
3. 提交后只生成一次角色图和一次地块图集;
4. 结果页能看到角色图、地块图集和路径预览;
5. 结果页可单独重生成角色或地块;
6. 试玩进入跳一跳运行态;
7. 长按蓄力、松手起跳、落点判定、失败和通关都可用;
8. 作品可以保存、发布和分享;
9. 前端不直接读取或暴露生图密钥;
10. 发布后的封面不依赖第三次额外生图。
11. `npm run check:spacetime-schema` 在 schema 变更后通过;
12. `npm run check:encoding` 通过。
展示字段
1. rank
2. displayName
3. successfulJumpCount
4. durationMs
5. updatedAt
排行榜 UI 禁止展示 `user_id` / `playerId` 这类内部身份键。后端可以继续用 `playerId` 做作品维度最佳成绩去重和 `viewerBest` 匹配,但 HTTP 响应必须补齐 `displayName`;已登录用户读取账号 `displayName`,匿名游客展示为“游客玩家”,账号失效或无法解析时展示为“失效玩家”。
草稿试玩可以展示本地结果,但正式排行榜只消费后端 run 记录。匿名 runtime guest 也按 guest subject 作为 playerId 参与当次作品维度排行。
## 8. 结果页
结果页展示:
1. 陶泥儿 logo 透明角色预览;
2. 25 个地块资源池预览;
3. 首屏 3 块平台预览;
4. 试玩;
5. 发布
6. 返回编辑
7. 重生成地块。
结果页不再展示角色图片生成槽位,也不提供独立角色重生成
## 9. 契约要点
公开语义保留:
1. `themeText`
2. `tileAtlasAsset`
3. `tileAssets[]`
4. `defaultCharacter`
5. `path.platforms[]` 作为服务端路径缓冲;
6. `currentPlatformIndex`
7. `successfulJumpCount`
8. `startedAtMs` / `finishedAtMs` / `durationMs`
9. `leaderboard`
旧语义处理:
1. `characterAsset` 仅作为角色描述兼容字段,不再表示生成图片;前端固定使用陶泥儿 logo 透明 PNG
2. `score` 兼容映射为成功跳跃次数;
3. `combo` 固定为 0不作为公开玩法语义
4. `cleared` 状态不再由 v1 产生;
5. 旧 finite path 只作为服务端路径缓冲兼容形态。
## 10. 验收
1. 创作页只显示主题输入
2. 生成链路只调用一次地块图集 image2不再调用角色生图
3. 地块图集为 `5x5`,后端切出 25 个地块 PNG
4. 结果页不依赖旧角色图片槽;
5. 运行态为竖屏俯视角,首屏保持 3 个地块可见;
6. 拖拽方向和力度会影响落点;
7. 未落到下一个地块立即失败
8. 成功跳跃次数累加,失败后计时冻结;
9. 排行榜按成功跳跃次数优先排序;
10. 作品可保存、发布、分享并从公开入口启动。
11. 运行态地块必须显示 `tileAssets[]` 中的生成切片图片;拖拽蓄力、计时刷新和角色位置更新不得销毁重建透明画布、平台图片层或 DOM 角色层。
12. 同等跳跃距离的拖动距离必须比旧 `0.004` 系数缩短一半,松手后必须先看到角色飞行动画,再看到地块窗口前移。

View File

@@ -0,0 +1,46 @@
# 【前端架构】Bark Battle Work Cache 草稿状态收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 仍内联维护 Bark Battle 草稿三图完整性、生成状态归一、作品架摘要恢复草稿配置,以及草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 映射。壳层因此需要同时理解三图资产字段、`partial_failed``pending_assets` 的差异、`publishedAt` 兜底、作品摘要字段和草稿试玩配置默认值。
这些规则属于 Bark Battle 作品摘要与草稿缓存的纯模型。若留在平台壳层,后续发布、作品架刷新、公开详情启动或草稿试玩都容易重复一份字段清单。
## 决策
扩展 `src/components/platform-entry/barkBattleWorkCache.ts`,作为 Bark Battle Work Cache **Module** 继续承接作品摘要缓存和草稿 runtime 配置规则。新增公开 **Interface**
- `hasBarkBattleDraftRequiredImages(draft)`:判断草稿是否已具备玩家形象、对手形象和竞技背景三图。
- `resolveBarkBattleDraftGenerationStatus(draft, partialFailed)`:三图齐备返回 `ready`,否则按是否部分失败返回 `partial_failed``pending_assets`
- `buildBarkBattleDraftConfigFromWorkSummary(work)`:把作品架摘要恢复成可编辑 / 可试玩的 `BarkBattleDraftConfig`
- `buildBarkBattlePublishedConfigFromDraft(draft)`:把草稿结果页试玩所需配置映射为 `BarkBattlePublishedConfig`
- `buildBarkBattlePublishedConfigFromWork(work)`:把作品架 / 公开详情启动正式 runtime 所需配置映射为 `BarkBattlePublishedConfig`
- `buildBarkBattlePublishSnapshot(draft)`:拼装发布接口所需的最终草稿快照。
- `mergeBarkBattlePublishedConfigAssets(published, draft)`:发布回包缺少三图字段时沿用结果页草稿图。
`PlatformEntryFlowShellImpl.tsx` 继续作为 **Adapter**:它只负责 API 请求、React state、URL、运行态 stage 切换和错误提示,不再持有 Bark Battle 三图完整性与 runtime config 字段清单。
## Interface 约束
- 草稿三图必须同时具备 `playerCharacterImageSrc``opponentCharacterImageSrc``uiBackgroundImageSrc` 的非空值,才视为 `ready`
- 未齐三图且 `partialFailed=true` 时返回 `partial_failed`,否则返回 `pending_assets`
- 作品摘要恢复草稿时,`draftId` 缺失回退 `workId``description` 来自 summary三图 null 归一为 `undefined``configVersion=1``rulesetVersion='bark-battle-ruleset-v1'`
- 草稿试玩配置的 `workId` 优先使用草稿稳定 `workId`,缺失时回退 `draftId`
- 草稿试玩配置的 `configVersion``rulesetVersion` 使用草稿值,缺失时回退 `1``bark-battle-ruleset-v1`
- 已发布作品配置的 `publishedAt` 缺失时回退 `updatedAt`,保持旧 runtime 启动语义。
- 发布快照只携带草稿已有的三图字段,不凭空补空字符串。
- 发布接口回包缺少三图字段时,结果页草稿图继续作为 runtime 和作品摘要的兜底。
## Depth / Leverage / Locality
- **Depth**:壳层传入草稿或作品摘要,即可得到生成状态、草稿配置或 runtime 配置;字段归一、默认值和三图完整性藏入 Module Implementation。
- **Leverage**:作品架草稿恢复、结果页试玩、作品架启动、公开详情启动和缓存刷新可复用同一组 Bark Battle 规则。
- **Locality**Bark Battle 资产完整性与配置映射集中到纯测试面,后续变更三图字段或规则集默认值时无需搜索巨型平台壳。
## 验收
- `npm run test -- src/components/platform-entry/barkBattleWorkCache.test.ts`
- `npx eslint --max-warnings 0 src/components/platform-entry/barkBattleWorkCache.ts src/components/platform-entry/barkBattleWorkCache.test.ts`
- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,41 @@
# CreationUrlStateModel 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 曾直接承载多玩法创作恢复 URL 的拼装规则:`sessionId``profileId``draftId``workId` 的优先级、拼图草稿 runtime query、以及空值归一化散在壳层 Implementation 内。平台壳因此需要理解各玩法快照结构,新增玩法或修复刷新恢复时缺少稳定测试面。
## 决策
- 新增 `src/components/platform-entry/platformCreationUrlStateModel.ts` 作为 Creation URL State Module。
- 该 Module 的 Interface 收口为各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState``normalizeCreationUrlValue``hasCreationUrlStateValue``hasPuzzleRuntimeUrlStateValue``buildPuzzleRuntimeUrlStateKey`、初始创作 URL 恢复判定 `resolveInitialCreationUrlRestoreDecision`、创作直达恢复目标解析 `resolveCreationUrlRestoreTarget`、恢复目标身份匹配谓词,以及跳一跳 / 敲木鱼恢复后的阶段落点判定。
- 新增 `src/components/platform-entry/platformPuzzleIdentityModel.ts` 作为拼图稳定身份 Module统一 `puzzle-session-*``puzzle-profile-*``puzzle-work-*` 的互推规则。
- `PlatformEntryFlowShellImpl.tsx` 保留 React state、路由、登录门禁、网络请求和 URL 写入副作用 Adapter不再在壳层内定义各玩法 URL 状态构造函数,也不直接内联初始恢复的已处理 / 等待 / 可恢复判定。
## Interface 约束
- 创作恢复私有 query 只使用 `sessionId``profileId``draftId``workId`;不得新增说明性 query 字段。
- 空字符串、全空白字符串统一视为 `null`,避免刷新恢复时写入无效私有参数。
- work-backed 玩法优先使用后端 work summary 的公开 `workId` / `profileId`;仅缺失时才回退 session draft。
- 拼图 runtime query 独立使用 `mode``runtimeSessionId``runtimeProfileId``runtimeLevelId``publicWorkCode`,不与创作恢复 query 混写。
- 拼图 draft runtime 若没有 `sourceSessionId`,只允许从 `puzzle-profile-*` 反推出 `puzzle-session-*`
- 初始创作 URL 恢复只在未处理、当前路径属于创作恢复路径、私有 query 有值、平台配置加载完成且受保护数据可读时执行;非创作路径或无私有 query 时标记已处理,加载中或暂不可读时等待。
- 创作直达恢复目标由 `resolveCreationUrlRestoreTarget(pathname, state)` 统一识别;它只返回玩法 kind、归一化后的四个私有 query、生成路径标记和大鱼吃小鱼 session 兜底不执行网络请求、草稿打开、stage 切换或 URL 写回。
- 作品 / 草稿身份匹配只允许非空目标值命中,避免 query 缺失时用 `null` / 空值误匹配到无效草稿。匹配谓词仍只判断身份,不触发列表读取或打开动作。
- 跳一跳和敲木鱼的恢复阶段落点由 `resolveJumpHopCreationUrlRestoreStage``resolveWoodenFishCreationUrlRestoreStage` 决定;生成路径优先进入生成页,否则按是否恢复到 draft / work 落到结果页或工作台。
- `/creation/rpg` 当前仍不归入具体恢复目标;若后续要恢复 RPG 直达,需要先补明确恢复规则和测试,不得让壳层重新内联路径判定。
## Depth / Leverage / Locality
- **Depth**:调用方只传玩法快照或作品摘要,即可得到规范化 URL state各玩法字段优先级藏在 Module Implementation 内。
- **Leverage**:新增或调整玩法恢复规则、恢复目标或恢复等待条件时,优先补 Module Interface 测试,再接壳层 Adapter。
- **Locality**:恢复 query、拼图 runtime query 和拼图稳定身份规则集中在两个小 Module避免散落在页面壳、作品架和 runtime 打开逻辑中。
## 验收
- `npm run test -- src/components/platform-entry/platformCreationUrlStateModel.test.ts src/components/platform-entry/platformPuzzleIdentityModel.test.ts`
- `npm run test -- src/services/creationUrlState.test.ts`
- `npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`
- `npx eslint src/components/platform-entry/platformCreationUrlStateModel.ts src/components/platform-entry/platformCreationUrlStateModel.test.ts src/components/platform-entry/platformPuzzleIdentityModel.ts src/components/platform-entry/platformPuzzleIdentityModel.test.ts --max-warnings 0`
- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx src/components/platform-entry/platformDraftGenerationShelfModel.ts --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,33 @@
# 【前端架构】Creation Work Delete Flow 收口计划
## 背景
平台入口作品架的删除入口覆盖 RPG、拼图、抓大鹅、方洞挑战、大鱼吃小鱼、视觉小说和宝贝识物。此前 `PlatformEntryFlowShellImpl.tsx` 在每个删除 handler 内重复计算确认框标题、删除说明、草稿 notice key 和拼图派生稳定 ID。壳层既要理解每种玩法的作品身份又要承接异步删除、刷新列表、错误状态和页面跳转导致删除确认规则缺少稳定测试面。
**Interface** 过浅:页面只想展示“删除哪个作品、会从哪里移除、删除成功后清哪些生成 notice”却必须知道 `workId` / `profileId` / `sourceSessionId` / `draftId``status` / `publicationStatus` / `publishStatus` 和宝贝识物特殊公开去向。
## 决策
新增 `src/components/platform-entry/platformCreationWorkDeleteFlow.ts` 作为 Creation Work Delete Flow **Module**。其唯一公开 **Interface**`resolvePlatformCreationWorkDeleteConfirmationModel(input)`,输入为带 `kind` 的 union输出
- `id`:确认框和删除 busy 使用的稳定作品 ID。
- `title`:确认框标题,含拼图、视觉小说和宝贝识物标题兜底。
- `detail`:草稿 / 已发布删除说明,宝贝识物已发布使用“寓教于乐板块”文案。
- `noticeKeys`:删除成功后应标记已读的草稿生成 notice keys拼图包含 `buildPuzzleResultWorkId` / `buildPuzzleResultProfileId` 派生 key。
`PlatformEntryFlowShellImpl.tsx` 仍作为副作用 **Adapter**:负责鉴权保护、确认框 state、调用各玩法删除 API、清错误、刷新作品架 / 公开广场、`markDraftNoticeSeen` 和必要的页面跳转。`run` 不进入纯 **Module**,避免把网络副作用和 React state 写入藏入模型层。
## 约定
- 新玩法接入作品架删除时,先补齐后端删除链路、作品架 action 和本 **Module** 的确认模型,再开放删除按钮。
- Jump Hop、Wooden Fish 和 Bark Battle 当前仅有作品架 action 预留,平台壳不传删除 handler不得因本 Module 存在而默认开放删除。
- 删除确认文案不得散回平台壳;若公开去向不是公开广场,应在本 **Module** 明确分支。
- 草稿 notice key 的身份扩展必须复用 `collectDraftNoticeKeys`,保持 trim、去空和去重语义一致。
## 验证
- `npm run test -- src/components/platform-entry/platformCreationWorkDeleteFlow.test.ts`
- `npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`
- `npx eslint src/components/platform-entry/platformCreationWorkDeleteFlow.ts src/components/platform-entry/platformCreationWorkDeleteFlow.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,40 @@
# 【前端架构】Draft Generation Shelf Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 同时承载创作生成状态、草稿 Tab 未读点、pending 作品架占位、失败文案覆盖、作品详情更新回填和跨玩法 notice key。拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、汪汪声浪、大鱼吃小鱼和宝贝识物各有不同的 `workId` / `profileId` / `sourceSessionId` / `draftId`,这些规则散在平台壳 **Implementation** 内,导致调用方必须理解每种玩法的草稿身份形状。
**Interface** 过浅:页面看似只关心“生成中 / 已完成未读 / 失败”,却要知道多 ID 去重、pending 草稿去重、失败摘要、拼图空标题兜底和持久化 generating 覆盖规则。
## 决策
新增 `src/components/platform-entry/platformDraftGenerationShelfModel.ts` 作为 Draft Generation Shelf **Module**。其 **Interface** 收口为:
- `collectDraftNoticeKeys(kind, ids)` / `getGenerationNoticeShelfKeys(item)`:统一把玩法草稿身份映射为 notice key。
- `createPendingDraftShelfState(...)``buildPending*Works(...)`:统一把本地 pending 生成状态映射成作品架占位,并避免与后端已有草稿重复。
- `buildCreationWorkShelfRuntimeState({ item, notices, pendingShelfItems })`:统一输出 `CreationWorkShelfRuntimeState`,处理失败覆盖、拼图空标题 `拼图草稿` 兜底、summary 占位覆盖、生成中遮罩和 ready 未读点。
- `collectVisibleDraftNoticeKeys(...)` / `hasUnreadDraftGenerationUpdates(...)`:统一草稿 Tab 顶部未读点规则。
- `mergePuzzleWorkSummary(current, updated)``mergeBigFishWorkSummary(current, updated)`:统一作品详情更新后回填作品架和当前详情的身份匹配规则。
- `resolvePuzzleDraftOpenIntent(...)``resolveMatch3DDraftOpenIntent(...)``resolveSquareHoleDraftOpenIntent(...)``resolveBigFishDraftOpenIntent(...)``resolveVisualNovelDraftOpenIntent(...)``resolveJumpHopDraftOpenIntent(...)``resolveWoodenFishDraftOpenIntent(...)`:统一拼图、抓大鹅、方洞挑战、大鱼吃小鱼、视觉小说、跳一跳和敲木鱼草稿打开时的已发布详情、缺 session、ready 未读试玩、失败 / active / background 生成页、当前结果页、持久化 generating 恢复、失败 fallback stage 和普通草稿恢复优先级。
- `buildPuzzleResultWorkId(...)` / `buildPuzzleResultProfileId(...)``isPersistedDraftGenerating(...)` / `isPersistedDraftFailed(...)`:把拼图稳定 ID 与持久化状态判断收在同一 **Seam**
`PlatformEntryFlowShellImpl.tsx` 仍作为 React state 与副作用 **Adapter**:负责写入 `draftGenerationNotices` / `pendingDraftShelfItems`、读取生成 session、启动 ready 草稿试玩、刷新后端列表、打开结果页和弹窗;它不再内联 pending shelf row shape、notice key 汇总、作品架 runtime state 和上述玩法草稿打开优先级。
## 约定
- 新玩法若需进入草稿生成通知,必须在此 **Module** 补 notice key、pending 占位和 visible key 映射,避免在平台壳里新增散落 switch。
- pending 作品只用于本地生成任务尚未被后端作品架返回时的临时展示;一旦后端已有同一 `sourceSessionId` / `profileId` / `workId`pending 占位必须让位。
- 拼图作品详情更新只以 `profileId` 匹配回填;大鱼吃小鱼作品详情更新只以 `sourceSessionId` 匹配回填。
- 失败 notice 优先级高于持久化 generating且可通过 pending metadata 提供更具体 summary否则回退玩法默认失败摘要。
- 已有封面的拼图草稿即使局部关卡仍在后台生成,也不得被整卡遮罩为不可打开的生成中状态。
- 草稿打开 intent 只返回纯计划、notice keys 与必要稳定 ID不创建失败生成态、不请求详情、不写 stage这些仍由壳层 Adapter 执行。
-**Module** 不做网络请求、路由切换、弹窗副作用或 React state 写入,只保留纯 **Implementation**,以提高 **Depth**、**Leverage** 与 **Locality**
## 验证
- `npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`
- `npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts -t "generation state|failure notice|failed puzzle"`
- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft|persisted generating match3d draft|completed baby object match draft"`
- 针对新 **Module** 与测试执行 ESLint`PlatformEntryFlowShellImpl.tsx` 保留既有 hook dependency warnings不在本切片扩大处理。
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,34 @@
# 【前端架构】Match3D Runtime Profile 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 同时编排抓大鹅创作、作品详情、推荐 runtime 和正式 runtime。运行态启动前的 profile 规范化、公开详情转 work、生成背景资产提升、run / profile / public detail 优先级和 runtime 素材选择原本都在平台壳 **Implementation** 内,导致平台壳必须理解抓大鹅生成素材的内部结构。
## 决策
新增 `src/components/platform-entry/platformMatch3DRuntimeProfile.ts`,作为抓大鹅 runtime profile **Module**。该 **Module****Interface** 收口为:
- `mapPublicWorkDetailToMatch3DWork(entry)`:把公开作品详情映射为可启动 runtime 的 Match3D work并补齐生成背景资产。
- `buildMatch3DProfileFromSession(session)`:从创作 session draft 生成 runtime profile。
- `normalizeMatch3DWorkForRuntimeUi(profile)` / `mapMatch3DWorksForRuntimeUi(profiles)`:统一作品列表进入 UI / runtime 前的素材规范化。
- `promoteMatch3DGeneratedBackgroundAsset(profile)`:从 `generatedBackgroundAsset``generatedItemAssets[].backgroundAsset` 提升背景图、对象 key 与 prompt。
- `hasMatch3DRuntimeAsset(profile.generatedItemAssets)` / `hasMatch3DRuntimeBackgroundAsset(profile)`:统一判断 runtime 是否具备物品与背景素材。
- `resolveActiveMatch3DRuntimeProfile(run, runtimeProfile, profile)`:按 run 的 `profileId` 选择当前 profile避免切屏时误用旧草稿。
- `resolveMatch3DRuntimeGeneratedItemAssets(...)``resolveMatch3DRuntimeGeneratedBackgroundAsset(...)``resolveMatch3DRuntimeBackgroundImageSrc(...)`:统一 run / profile / public detail 的素材优先级。
`PlatformEntryFlowShellImpl.tsx` 只保留启动 run、预加载、路由、错误和 state 编排;抓大鹅素材规则集中到该 **Module**,提升 **Locality** 与测试 **Leverage**
## 约定
- 公开详情补 runtime 素材时,只有 `profileId` 与 run 匹配才优先使用公开详情;错配时不得污染当前 run。
- 当前启动时拿到的 `runtimeProfile` 优先于旧草稿 profile若 run 指向旧草稿 profile才使用草稿 profile。
- 背景资产提升不得覆盖已有显式 `backgroundImageSrc` / `backgroundImageObjectKey` / `generatedBackgroundAsset`,只补缺。
-**Module** 只放纯 profile / asset 规则,不引入启动 run、预加载、URL、状态机或 UI 副作用。
## 验证
- `npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts`
- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "match3d|抓大鹅"`
- `npm run typecheck`
- `npm run check:encoding`
- 针对新 Module 与测试执行 ESLint`PlatformEntryFlowShellImpl.tsx` 保留既有 hook dependency warnings不在本切片扩大处理。

View File

@@ -0,0 +1,29 @@
# 【前端架构】Platform Creation Launch Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 的创作入口点击回调曾直接以内联 `if` 链判断 `airp` 占位、隐藏的 `baby-object-match`、RPG 与各小游戏工作台启动目标。壳层因此同时理解入口 ID、是否需要执行启动前准备、隐藏模板错误文案和具体工作台分流。
这类规则属于创作入口启动意图。壳层应只执行准备、错误提示和受保护动作,不应持有入口 ID 到工作台目标的长链判定。
## 决策
新增 `src/components/platform-entry/platformCreationLaunchModel.ts` 作为 Platform Creation Launch **Module**。其公开 **Interface** 为:
- `resolvePlatformCreationLaunchIntent({ type, isBabyObjectMatchVisible })`:输入后端入口配置下发的模板 ID 与幼教入口可见性,输出 `noop``blocked``launch` 意图。
`PlatformEntryFlowShellImpl.tsx` 仍作为副作用 **Adapter**:根据 intent 决定是否调用 `prepareCreationLaunch()`,对 blocked intent 写入 `sessionController.setCreationTypeError(...)`,对 launch intent 进入 `runProtectedAction(...)` 并调用具体工作台打开函数。
## 约定
- `airp` 是占位入口,必须在 `prepareCreationLaunch()` 之前返回 `noop`,避免触发新游戏初始化、返回目标复位或错误清理。
- 隐藏的 `baby-object-match` 必须在 `prepareCreationLaunch()` 之后返回 blocked intent错误文案仍使用 `EDUTAINMENT_HIDDEN_MESSAGE`
- 未知入口 ID 保持旧语义:先允许壳层执行启动前准备,再作为 `noop` 结束,避免改变未来后端配置异常时的准备流程。
- 新增可启动模板时,先在本 **Module** 的 launch target union、目标集合和测试中列明再在壳层 Adapter 中补具体启动函数。
## 验收
- `npm run test -- src/components/platform-entry/platformCreationLaunchModel.test.ts`
- `npx eslint src/components/platform-entry/platformCreationLaunchModel.ts src/components/platform-entry/platformCreationLaunchModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,46 @@
# PlatformDialogStateModel 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 曾直接承载平台级错误 / 完成弹窗的纯状态规则:错误文案 trim、来源 label 与 id 拼接、后台生成仍在处理的识别、错误候选优先级、dismiss key 与生成完成文案都散在壳层 Implementation 内。壳层因此既要管理 React state 与副作用清理,又要记住弹窗判定细则;新增玩法错误或调整弹窗展示时缺少稳定测试面。
## 决策
- 新增 `src/components/platform-entry/platformDialogStateModel.ts` 作为 Platform Dialog State Module。
- Module Interface 收口:
- `normalizePlatformDialogMessage`
- `formatPlatformDialogSource`
- `isBackgroundGenerationStillRunningMessage`
- `resolvePlatformErrorDialog`
- `buildPlatformErrorDialogDismissKey`
- `buildPlatformTaskCompletionDialogDismissKey`
- `resolveActivePlatformDialog`
- `PLATFORM_TASK_COMPLETION_MESSAGE`
- `PlatformErrorDialogState``PlatformTaskFailureDialogState``PlatformTaskCompletionDialogState`
- `PlatformEntryFlowShellImpl.tsx` 继续作为 Adapter汇总各玩法候选、持有 React state、关闭弹窗时清理对应 setter。副作用清理不下沉到 Module避免把大量壳层 setter 变成浅 Interface。
## Interface 约束
- 错误与完成弹窗文案先 trim空字符串或全空白字符串统一视为 `null`
- 来源格式固定为 `label + 空格 + trimmed id`;缺 id 时只返回 label。
- 平台错误候选按数组顺序取第一个有效文案;候选本身只描述 `key/source/message`
- 错误 dismiss key 固定为 `key:source:message`;完成 dismiss key 固定为 `key:source:message:completedAtMs`,缺完成时间时补 `0`
- `resolveActivePlatformDialog` 只根据当前弹窗 dismiss key 与已记录 dismiss key 决定是否隐藏,不修改底层错误或完成状态。
- 任务完成弹窗文案统一使用 `PLATFORM_TASK_COMPLETION_MESSAGE`,不得在壳层重复写同一中文 literal。
- `closePlatformErrorDialog` 保持在壳层 Adapter它负责按错误来源清理 `creationEntryConfigError`、玩法 error、作品详情 error 等副作用状态,不属于纯状态 Module。
## Depth / Leverage / Locality
- **Depth**:壳层传入候选和 dismiss 记录,即可得到当前平台弹窗状态;文案归一、来源格式和 dismiss 规则藏在 Module Implementation 内。
- **Leverage**:新增玩法错误来源时只需补候选;调整弹窗纯规则时优先改 Module 与单测。
- **Locality**:平台错误弹窗、任务完成弹窗和后台生成 still-running 识别集中在一个小 Module避免继续散落在大型平台壳 Implementation 内。
## 验收
- `npm run test -- src/components/platform-entry/platformDialogStateModel.test.ts`
- `npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx`
- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "background match3d draft failure notifies and reopens failed retry page|completed match3d draft notice first opens trial then reopens result|puzzle compile timeout shows failure dialog when reread session is still generating"`
- `npx eslint src/components/platform-entry/platformDialogStateModel.ts src/components/platform-entry/platformDialogStateModel.test.ts --max-warnings 0`
- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,37 @@
# 【前端架构】Platform Generation Progress Tick Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 的生成页进度 tick effect 曾以内联三元链按 `selectionStage` 选择拼图、抓大鹅、大鱼吃小鱼、方洞挑战、跳一跳、敲木鱼和宝贝识物的生成状态,并额外手写视觉小说的 `startedAtMs` / `phase` 判定。壳层因此既要维护 `setInterval` 副作用,又要记住每个生成页 stage 对应哪份进度状态。
生成进度是否需要 tick 是纯判定;`Date.now()``window.setInterval` 和进度时间 state 写入仍属于 React 壳层副作用。
## 决策
新增 `src/components/platform-entry/platformGenerationProgressTickModel.ts` 作为 Platform Generation Progress Tick **Module**。其公开 **Interface** 为:
- `resolvePlatformGenerationProgressTickDecision(input)`:输入当前 `selectionStage`、各小游戏 `MiniGameDraftGenerationState` 和视觉小说轻量生成状态,输出 `{ activeKind, shouldTick }`
- `PlatformGenerationProgressTickKind`:枚举可 tick 的生成类型,包含已有小游戏生成 kind 与 `visual-novel`
`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它把当前 state 组装给 Module`shouldTick=false` 则不启动 interval若为真仍按旧逻辑立即写一次 `Date.now()`,再每 `500ms` 更新并在 effect cleanup 中清理 timer。
## Interface 约束
- 小游戏生成 stage 只读取匹配 kind 的 `MiniGameDraftGenerationState`stage 与 state 不匹配时不 tick。
- 小游戏状态缺失、`phase='ready'``phase='failed'` 时不 tick其它 phase 按进行中处理。
- `visual-novel-generating` 不强行转成 `MiniGameDraftGenerationState`,只在 `startedAtMs != null` 且 phase 非 `ready` / `failed` 时 tick。
- 非生成 stage 即使传入可运行 state 也不 tick。
- 本 Module 不计算进度、不重建 view state、不处理拼图 / 抓大鹅 background task 覆盖;这些仍按既有生成页和作品架模型处理。
## Depth / Leverage / Locality
- **Depth**:壳层只消费 `shouldTick`stage 到 state 的映射和终态判定藏入 Module Implementation。
- **Leverage**:新增生成页玩法时,先扩展 stage-to-kind 映射和单测,再让壳层 Adapter 传入对应 state。
- **Locality**:生成进度 tick 规则集中到一个纯测试面interval 副作用继续局部留在 React effect避免把 timer 控制做成浅 Interface。
## 验收
- `npm run test -- src/components/platform-entry/platformGenerationProgressTickModel.test.ts`
- `npx eslint src/components/platform-entry/platformGenerationProgressTickModel.ts src/components/platform-entry/platformGenerationProgressTickModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,47 @@
# 【前端架构】Platform Mini Game Draft Generation State Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 曾内联维护小游戏生成状态的恢复、失败/完成收尾、展示 rebase、拼图后端进度合并、抓大鹅生成资产旁路进度合并和生成中 / ready 判定。壳层因此既要处理 API 回包、React state、后台任务、URL 和 stage又要记住 `MiniGameDraftGenerationState` 的生命周期细节。
这些状态变换不读取 DOM不请求网络也不写 React state它们属于平台层小游戏草稿生成状态 **Module**。壳层只应决定何时调用、把返回值写入对应 state。
## 决策
新增 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts` 作为 Platform Mini Game Draft Generation State **Module**。其公开 **Interface** 为:
- `createMiniGameDraftGenerationStateForRestoredDraft(kind, metadata?, startedAtMs?)`:为恢复的草稿重建生成态,并保留后端开始时间作为进度事实源。
- `createFailedMiniGameDraftGenerationStateForRestoredDraft(kind, updatedAt, error, metadata?)`:恢复失败草稿时按后端 `updatedAt` 建立失败态。
- `rebaseMiniGameDraftGenerationStateForDisplay(state)``rebaseMiniGameDraftBackgroundCompileTaskForDisplay(task)`:清理展示用 `finishedAtMs`,避免返回生成页后沿用结束态计时。
- `createPuzzleDraftGenerationStateFromPayload(payload, session?)``resolvePuzzlePhaseFromSessionProgress(state, session)``mergePuzzleSessionProgressIntoGenerationState(state, session)`:集中处理拼图生成的 aiRedraw、后端进度百分比和 phase 推进。
- `mergeMatch3DGeneratedAssetsIntoGenerationState(state, assets)`:抓大鹅轮询到作品素材后,按可用图片数量推进生成页资产计数,并把首个素材错误传播到生成态。
- `resolveFinishedMiniGameDraftGenerationState(state, phase, options?)`:统一完成 / 失败收尾的 `finishedAtMs`、错误与资产计数合并。
- `isMiniGameDraftReady(state)``isMiniGameDraftGenerating(state)`:统一生成态轻量判定。
`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它继续负责 API、background task、React state 写入、作品架刷新、URL 与 stage 切换。
## Interface 约束
- 恢复草稿状态必须允许调用方传入 `startedAtMs`;未传时使用当前时间,与旧逻辑一致。
- 恢复失败状态必须通过 `resolveMiniGameDraftGenerationStartedAtMs(updatedAt)` 解析后端时间,并保留传入 metadata。
- `resolveFinishedMiniGameDraftGenerationState` 只覆盖显式传入的 `error``completedAssetCount``totalAssetCount`;未传时沿用原 state。
- 拼图 session 只有在 `draft` 存在且不是 `formDraft` 时才视为后端编译生成中 session才写入 `puzzleProgressPercent` 并推进 phase。
- 拼图进度阈值保持旧值:`>=96``puzzle-select-image``>=94``puzzle-ui-assets``>=88` 时按 `puzzleAiRedraw=false` 进入 `puzzle-level-scene`,否则进入 `puzzle-cover-image`
- phase 变化时 `puzzleActiveStepStartedAtMs` 使用 session `updatedAt` 解析值phase 不变时保留旧值。
- 抓大鹅资产旁路进度不得覆盖 `ready``failed` 终态;非终态下只统计有 `imageViews[].imageObjectKey` / `imageViews[].imageSrc`、顶层 `imageObjectKey` 或顶层 `imageSrc` 的素材。
- 抓大鹅资产旁路进度的 `totalAssetCount` 至少为 `5`,保留当前五物品首批生成节奏;已有素材数量超过 `5` 时按真实素材数量展示。
- 抓大鹅已有可用素材时 phase 推进到 `match3d-generate-views`;无可用素材时保留原 phase首个素材错误写入 `error`,无素材错误时保留原错误。
- 展示 rebase 只清理 `finishedAtMs`,不得修改 phase、error、资产计数或 metadata。
## Depth / Leverage / Locality
- **Depth**:壳层以状态变换函数表达意图;生成态字段、拼图阈值、抓大鹅素材计数、时间解析与计数合并藏入 Module Implementation。
- **Leverage**:后续新增小游戏生成恢复、调整拼图后端进度阈值或改变抓大鹅素材批次展示时,先改 Module 与单测,再让壳层 Adapter 保持调用点不变。
- **Locality**:小游戏生成状态规则集中到一个纯测试面,避免在大型壳层的 API callback、background task 和恢复流程中重复推理 `MiniGameDraftGenerationState`
## 验收
- `npm run test -- src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts`
- `npx eslint src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,52 @@
# 【前端架构】Platform Mini Game Draft Payload Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 曾内联维护拼图和抓大鹅草稿恢复所需的表单 payload、拼图编译 action payload、拼图作品更新 payload、跳一跳 / 敲木鱼生成 action payload、作品摘要回填 payload 和 pending 草稿 metadata。壳层因此需要理解拼图描述字段优先级、formDraft 回退、结果页 draft 到作品更新字段的映射、Match3D config / draft / anchorPack 优先级、跳一跳 / 敲木鱼 payload 与 session draft 优先级,以及 pending 作品架标题摘要如何从 payload 派生。后续还残留拼图 form-only 草稿判定,影响 action 分流、草稿恢复阶段和结果页渲染。
这些逻辑都是 DTO 变换;不读取 React state不请求网络也不写 URL。壳层只应决定何时恢复、何时提交 action、何时写入生成状态。
## 决策
新增 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts` 作为 Platform Mini Game Draft Payload **Module**。其公开 **Interface** 为:
- `buildPuzzleFormPayloadFromWork(item)`:从拼图作品摘要恢复创作表单 payload。
- `buildPuzzleFormPayloadFromSession(session)`:从拼图 session 恢复创作表单 payload。
- `buildPuzzleFormPayloadFromAction(payload)`:从拼图 action 还原表单 payload仅接受 `compile_puzzle_draft``save_puzzle_form_draft`
- `buildPuzzleCompileActionFromFormPayload(payload)`:从表单 payload 构造拼图编译 action。
- `buildPuzzleWorkUpdatePayloadFromDraft(draft)`:从拼图结果 draft 构造 `updatePuzzleWork(...)` 所需 payload。
- `buildJumpHopDraftActionPayload(actionType, { payload, draft })`:从跳一跳表单 payload / session draft 构造生成或重生成 action。
- `buildWoodenFishDraftActionPayload(actionType, { payload, draft })`:从敲木鱼表单 payload / session draft 构造生成或重生成 action。
- `buildPendingPuzzleDraftMetadata(payload)`:从拼图 payload 派生 pending 作品架 metadata。
- `isPuzzleFormOnlyDraft(session)``isEmptyPuzzleFormOnlyDraft(session)`:判断拼图 session 是否仍只是表单草稿,以及表单草稿是否没有任何可提交内容。
- `buildMatch3DFormPayloadFromSession(session)``buildMatch3DFormPayloadFromWork(item)`:从抓大鹅 session / work 恢复表单 payload。
- `buildPendingMatch3DDraftMetadata(payload)`:从抓大鹅 payload 派生 pending metadata。
`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它继续负责 API、Action 执行、background task、生成状态、错误提示、作品架和阶段切换。
## Interface 约束
- 拼图 work payload 的 `pictureDescription` 优先级固定为 `workDescription > summary > first level pictureDescription > levelName > workTitle > ''`
- 拼图 session payload 的 `pictureDescription` 优先级固定为 `formDraft.pictureDescription > first level pictureDescription > anchorPack.visualSubject.value > seedText > ''`
- 拼图编译 action 的 `promptText` 来自 `pictureDescription || seedText``workDescription` 缺省回退到图片描述;`candidateCount` 固定为 `1`
- 拼图 action 还原只接受 `compile_puzzle_draft``save_puzzle_form_draft`;其它 action 返回 `null`
- 拼图作品更新 payload 必须直接映射 `workTitle``workDescription``levelName``summary``themeTags``coverImageSrc``coverAssetId``levels` 缺失时回退空数组。
- 跳一跳和敲木鱼生成 action payload 的字段优先级固定为表单 payload 优先,其次 session draft重生成 action 只传 session draft 字段。
- 拼图 form-only 草稿只在 `session.stage === 'collecting_anchors'` 且存在 `draft.formDraft` 时成立。
- 空 form-only 草稿必须同时缺少 `seedText``formDraft.workTitle``formDraft.workDescription``formDraft.pictureDescription`
- 抓大鹅 session payload 优先读取 `config`,其次 `draft`,最后 `anchorPack``anchorPack.clearCount``anchorPack.difficulty` 只接受有限数字字符串或数字。
- 抓大鹅 work payload 的 `themeText` 优先 `themeText`,缺失回退 `gameName`
- pending metadata 只收非空 trim 后标题和摘要;抓大鹅 metadata 用 `themeText || seedText` 同时作为 title 和 summary。
## Depth / Leverage / Locality
- **Depth**:壳层以一组表意函数取得 payload / metadata字段优先级、结果页 draft 更新字段、跳一跳 / 敲木鱼 action 字段、默认空资产和数字解析藏入 Module Implementation。
- **Leverage**:后续调整拼图或抓大鹅草稿恢复表单、拼图作品更新字段、跳一跳 / 敲木鱼生成 action 字段时,先改 Module 与单测,再保持壳层 API / state 副作用不变。
- **Locality**:表单恢复、作品更新与 action payload 规则集中到一个纯测试面,避免在大型平台壳的生成、重试和恢复流程里重复散落 DTO 拼装。
## 验收
- `npm run test -- src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts`
- `npx eslint src/components/platform-entry/platformMiniGameDraftPayloadModel.ts src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,47 @@
# 【前端架构】Platform Mini Game Session Mapping Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 顶部曾保留拼图 runtime 恢复、方洞挑战 session draft 转 profile、视觉小说 work detail 转 Agent session、跳一跳 pending session、敲木鱼 work detail 恢复、敲木鱼生成中作品摘要和敲木鱼 pending session 多段纯 DTO 映射。它们没有 React state、网络请求、路由、弹窗或计时副作用却住在大型平台壳内新增或修正生成中草稿恢复时需要在壳层里理解 sessionId 优先级、拼图稳定 ID、方洞 profile 默认值、视觉小说 work/session fallback、pending draft 默认值和木鱼 fallback 规则。
这些规则属于平台壳 session / work 恢复映射,应成为可测试的 **Module**。壳层只负责调用网络、写 React state、写 URL 和切换 stage。
## 决策
新增 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts` 作为 Platform Mini Game Session Mapping **Module**。其公开 **Interface** 为:
- `buildPuzzleRuntimeWorkFromSession(session, owner)`:从拼图 Agent session 构造可进入 runtime 的 draft `PuzzleWorkSummary`,缺草稿、缺 profile 或缺封面时返回 `null`
- `buildSquareHoleProfileFromSession(session)`:从方洞挑战 Agent session draft 构造草稿 `SquareHoleWorkProfile`,缺 session、缺 draft 或缺 profileId 时返回 `null`
- `buildVisualNovelSessionFromWorkDetail(work)`:从视觉小说 work detail 恢复 `VisualNovelAgentSessionSnapshot`,供草稿作品架回到结果页继续编辑。
- `buildJumpHopPendingSession(item)`:从跳一跳作品架 summary 构造生成中 pending session。
- `buildWoodenFishSessionFromWorkDetail(work, fallbackItem?)`:从敲木鱼 work detail 恢复 session并按 summary / fallback / profileId 决定 sessionId。
- `buildWoodenFishGeneratingWorkSummary(session, payload?)`:从敲木鱼生成 session 和可选表单 payload 构造作品架生成中摘要。
- `buildWoodenFishPendingSession(item)`:从敲木鱼作品架 summary 构造生成中 pending session。
`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:调用这些映射后继续负责 `set*Session``set*Work``set*Run``createMiniGameDraftGenerationState(...)``writeCreationUrlState(...)``enterCreateTab()``setSelectionStage(...)`
## Interface 约束
- 拼图 runtime work 必须保留 `draft.coverImageSrc` 非空门槛,避免启动缺封面的草稿运行态。
- 拼图 profileId 优先 `publishedProfileId`,否则用 `buildPuzzleResultProfileId(sessionId)`workId 使用 `buildPuzzleResultWorkId(sessionId)`,缺失时回退 profileId。
- 拼图 owner 缺省为 `current-user` / `玩家``publishReady` 来自 `session.resultPreview?.publishReady`
- 方洞 profile 的 `workId``profileId` 都来自 draft `profileId`owner 固定为 `current-user``sourceSessionId` 来自 sessionId。
- 方洞 profile 的 `updatedAt` 优先 session `updatedAt`,缺失时使用当前时间;`publicationStatus='draft'``playCount=0``publishedAt=null``publishReady` 来自 draft。
- 视觉小说恢复 session 的 `sessionId` 优先归一化后的 `sourceSessionId`,为空时回退 `workId``status='ready'``messages=[]``pendingAction=null``sourceMode` 来自 draft`updatedAt` 来自 summary。
- 跳一跳 pending sessionId 优先 `sourceSessionId`,缺失时用 `profileId`;素材、路径和 prompt 维持空值兜底。
- 敲木鱼 detail sessionId 优先级固定为 `work.summary.sourceSessionId > fallbackItem.sourceSessionId > profileId`
- 敲木鱼生成中摘要的 `workId/profileId/sourceSessionId` 都来自 sessionId标题、描述和标签优先表单 payload其次 session draft最后回退 `敲木鱼` / 空描述 / `['敲木鱼']`
- 敲木鱼 pending session 保持 `floatingWords=['功德 +1']`、素材 / 音效 / 背景为空的旧默认。
## Depth / Leverage / Locality
- **Depth**:壳层以少量函数取得恢复用 DTOID 优先级、方洞 profile 默认值、视觉小说 session fallback、敲木鱼生成中摘要和 pending draft 字段藏入 Module Implementation。
- **Leverage**:后续新增生成中作品恢复或修改 sessionId 规则时,先改 Module 与单测,再保持壳层 Adapter 副作用不变。
- **Locality**:拼图、方洞、视觉小说、跳一跳和敲木鱼的恢复 / 生成中映射集中在一个纯测试面,避免在大型壳层顶部继续堆积 DTO 构造。
## 验收
- `npm run test -- src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts`
- `npx eslint src/components/platform-entry/platformMiniGameSessionMappingModel.ts src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,39 @@
# 【前端架构】Platform Played Work Open Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 的个人“玩过作品”点击回调曾在壳层内直接判断 `worldType``worldKey` 前缀、玩法别名、目标 ID 兜底、RPG 公开详情 payload 和大鱼吃小鱼 gallery miss fallback。壳层因此同时承载纯打开意图与异步副作用后续新增玩法或修正玩过作品身份时缺少稳定测试面。
个人“玩过作品”的点击规则属于打开意图。壳层应只关闭面板、读取 gallery、打开详情和写错误玩法别名、目标 ID、fallback payload 应收口到纯 **Module**
## 决策
新增 `src/components/platform-entry/platformPlayedWorkOpenModel.ts` 作为 Platform Played Work Open **Module**。其公开 **Interface** 为:
- `resolvePlatformPlayedWorkOpenIntent(work)`:输入 `ProfilePlayedWorkSummary`,输出 `noop`、各玩法公开详情打开意图、`open-big-fish``open-rpg`
- `PlatformPlayedWorkOpenIntent`:描述壳层可执行的打开动作;大鱼吃小鱼意图包含 `sessionId` 和 gallery miss 时使用的 `fallbackWork`RPG 意图包含 `CustomWorldGalleryCard` 详情 payload。
`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它保留 `setIsProfilePlayStatsOpen(false)`、各玩法 `open*PublicWorkDetail``refreshBigFishGallery()`、大鱼 gallery 命中优先逻辑、`mapBigFishWorkToPublicWorkDetail(...)` 与错误 setter。
## Interface 约束
- `worldType` 只做小写归一,不 trim`worldKey` 前缀匹配保持大小写敏感,延续旧行为。
- `profileId` 使用 nullish 优先级:只在 `profileId``null` / `undefined` 时从 `worldKey` 前缀兜底;空字符串仍视为缺目标并返回 `noop`
- `puzzle` 打开时固定携带 `{ tab: 'profile' }`
- `match3d` / `match_3d``square-hole` / `square_hole``jump-hop` / `jump_hop``wooden-fish` / `wooden_fish``big-fish` / `big_fish` 均保持既有别名。
- `big-fish` 缺 gallery 命中时使用 Module 生成的 `fallbackWork`,默认 `ownerUserId` 为空串、`authorDisplayName``worldSubtitle || '玩家'`、关卡和素材 ready 计数为 `0` / `false`
- 未识别的 `worldType` 仍按 RPG 公开详情打开;缺 `ownerUserId` 或缺 profile 目标时返回 `noop`
## Depth / Leverage / Locality
- **Depth**:调用方只消费一个打开 intent玩法别名、目标 ID 兜底和 fallback payload 藏入 Module Implementation。
- **Leverage**:新增“玩过作品”玩法时,先在 intent union、resolver 与单测中定义,再让壳层 Adapter 绑定对应打开副作用。
- **Locality**RPG fallback payload 与大鱼 fallback work 不再散落在大型壳层里,维护者可在纯测试中锁定字段契约。
## 验收
- `npm run test -- src/components/platform-entry/platformPlayedWorkOpenModel.test.ts`
- `npx eslint src/components/platform-entry/platformPlayedWorkOpenModel.ts src/components/platform-entry/platformPlayedWorkOpenModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "authenticated users can open save archives from the profile played panel"`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,32 @@
# 【前端架构】Platform Profile Wallet Delta Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 仍内联维护个人钱包余额的本地 delta 规则:余额归一化、本地扣点 / 返还后的 dashboard 乐观更新,以及刷新服务端 dashboard 时如何抵消已经被服务端反映的本地 delta。
这些规则是纯展示状态计算,但留在平台壳层会让壳层同时理解钱包余额边界、整数截断、负数保护和服务端快照对账。
## 决策
新增 `platformProfileWalletDeltaModel.ts`,收口钱包余额本地 delta 的纯规则:
- `resolveProfileWalletBalance(...)` 负责把 dashboard 余额归一为非负整数。
- `adjustProfileDashboardWalletBalance(...)` 负责把本地 delta 应用到 dashboard并刷新 `updatedAt`
- `reconcileProfileWalletLocalDeltaWithServerDashboard(...)` 负责在拿到新服务端 dashboard 后扣除已被服务端反映的本地借贷变化。
`PlatformEntryFlowShellImpl.tsx` 继续保留 API 请求、React ref、state 写入和刷新触发副作用。
## 接口约束
- 非数字、无穷值或空 dashboard 的余额按 `0` 处理。
- 本地 delta 必须先 `Math.trunc`,余额不得低于 `0`
- 当服务端最新余额已经反映本地扣点时,剩余负 delta 应减少;已经全部反映时归零。
- 当服务端最新余额已经反映本地返还 / 奖励时,剩余正 delta 应减少;已经全部反映时归零。
- 服务端余额变化方向与本地 delta 相反时,不得错误抵消。
## 验收
- `npm run test -- src/components/platform-entry/platformProfileWalletDeltaModel.test.ts`
- 针对新 Module 与 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint。
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,40 @@
# 【前端架构】Platform Public Code Search Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 的公开搜索回调曾直接在壳层内判断 `user_` / `user-``PZ``BF``JH``WF``BO``M3``SH``VN``BB``CW`、纯数字和普通关键词的优先级。壳层因此既要持有搜索输入到查找顺序的纯规则,又要执行各玩法公开详情读取、用户读取、运行态启动和错误归航副作用。
公开搜索的“先查什么、失败后回退什么”是稳定的分流规则,应有独立测试面;壳层只应作为副作用 Adapter按计划执行网络读取与打开动作。
## 决策
新增 `src/components/platform-entry/platformPublicCodeSearchModel.ts` 作为 Platform Public Code Search **Module**。其公开 **Interface** 为:
- `resolvePlatformPublicCodeSearchPlan(keyword)`:输入用户搜索词,输出 `{ normalizedKeyword, steps }`;空输入返回 `null`
- `PlatformPublicCodeSearchStep`:枚举壳层可执行的查找步骤,包括 `user-id``public-user-code``rpg-work`、各玩法公开作品步骤与 `bark-battle-work`
- `mapRpgPublicCodeSearchDetailToGalleryCard(entry)`:把 RPG by-code 详情响应映射为公开作品卡,收口 `playCount` / `remixCount` / `likeCount``0` 兜底。
- `resolve*PublicCodeSearchMatch(entries, keyword)`:统一各玩法公开作品列表的公开码匹配、公开可见性过滤和详情卡 DTO 映射;拼图、大鱼吃小鱼、跳一跳、敲木鱼、宝贝识物、抓大鹅、方洞挑战、视觉小说和汪汪声浪都走此接口。
`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它保留 `getPublicAuthUserByCode`、各玩法 gallery 刷新 / 详情打开、Bark Battle runtime 特例和 missing work 归航副作用,只按 `steps` 顺序执行,前一步失败才尝试下一步;壳层不再重复维护 per-play `isSame*PublicWorkCode` 匹配和 DTO 映射。
## Interface 约束
- 空白搜索词返回 `null`,壳层不得进入搜索 loading。
- `user_` / `user-` 开头的内部用户 ID 只执行 `user-id`,不回退作品号。
- `PZ``BF``JH``WF``BO``M3``SH``VN``BB` 前缀只进入对应玩法公开作品查找;`M3D-*` 继续归入并匹配 `M3` / 抓大鹅。
- `CW``1-8` 位纯数字先查 RPG 公开作品,再回退陶泥号。
- 普通关键词与 `SY` 陶泥号保持既有顺序:先查陶泥号,再查 RPG 公开作品,再查汪汪声浪作品,最后再以陶泥号兜底。
## Depth / Leverage / Locality
- **Depth**:壳层只消费短小的 `steps` 与 match result Interface搜索前缀、优先级、回退顺序、per-play 匹配和 DTO 映射藏入 Module Implementation。
- **Leverage**:新增公开作品前缀时,先扩展 Module 的 step union、前缀表、matcher 和单测,再在壳层 Adapter 绑定对应网络读取与打开动作。
- **Locality**:搜索计划与作品命中规则集中在一个纯 ModuleUI、网络、详情打开与 runtime 启动副作用继续留在壳层,避免把副作用 setter 变成浅 Interface。
## 验收
- `npm run test -- src/components/platform-entry/platformPublicCodeSearchModel.test.ts`
- `npm run test -- src/services/publicWorkCode.test.ts`
- `npx eslint src/components/platform-entry/platformPublicCodeSearchModel.ts src/components/platform-entry/platformPublicCodeSearchModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,68 @@
# PlatformPublicWorkDetailFlow 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 已把公开作品身份、去重和推荐 runtime kind 收口到 `platformPublicGalleryFlow.ts`,但统一作品详情入口仍在壳层 Implementation 内直接判断 RPG、拼图、跳一跳、敲木鱼、视觉小说和其它玩法。壳层既要知道哪些公开详情可直接使用当前 entry又要知道哪些玩法必须先补读完整详情还要按当前用户判断详情按钮是“编辑”还是“改造”。这些是纯决策规则继续留在巨型壳层会削弱 Locality。
## 决策
- 新增 `src/components/platform-entry/platformPublicWorkDetailFlow.ts` 作为 Platform Public Work Detail Flow Module。
- Module Interface 收口:
- `getPlatformPublicWorkDetailKind(entry)`
- `resolvePlatformPublicWorkDetailOpenStrategy(entry)`
- `resolvePlatformPublicWorkActionMode(entry, viewerUserId)`
- `resolvePlatformPublicWorkEditIntent(entry, deps)`
- `resolvePlatformPublicWorkLikeIntent(entry)`
- `resolvePlatformPublicWorkRemixIntent(entry)`
- `resolvePlatformPublicWorkStartIntent(entry, deps)`
- `resolvePlatformPublicWorkDetailOpenDecision(entry, deps)`
- `resolveActivePlatformPublicWorkAuthorEntry(args)`
- `map*WorkToPublicWorkDetail(...)`
- `mapPublicWorkDetailToPuzzleWork(entry)`
- `mapPublicWorkDetailToBigFishWork(entry)`
- `mapPublicWorkDetailToSquareHoleWork(entry)`
- `mapBarkBattlePublicDetailToWorkSummary(entry)`
- `resolveVisiblePuzzleDetailCoverCount(entry, run)`
- `PlatformEntryFlowShellImpl.tsx` 继续作为 Adapter根据 open strategy 调用 `openPublicWorkDetail``openPuzzlePublicWorkDetail``openJumpHopPublicWorkDetail``openWoodenFishPublicWorkDetail``openVisualNovelPublicWorkDetail``openRpgPublicWorkDetail`
- 公开详情 entry 映射与公开详情反推玩法 work 摘要也收口到 Module。壳层只在运行态启动、编辑、改造、推荐缓存和详情展示时调用映射 Interface不再在壳层顶部持有每个玩法的 DTO 拼装 Implementation。
- `mapMatch3DWorkToPublicWorkDetail` 归入 `platformMatch3DRuntimeProfile.ts`,继续委托 `normalizeMatch3DWorkForRuntimeUi` 处理素材归一和背景资产提升;`platformPublicWorkDetailFlow.ts` 不复制 Match3D 运行态素材规则。
- 公开详情启动、编辑、点赞和改造只抽“意图” Interface不把整个 callback 搬进 Module。壳层继续作为 Adapter 执行鉴权、API 调用、运行态启动、草稿恢复、busy 状态、缓存同步、stage 切换和错误 setter避免形成伪 Seam。
## Interface 约束
- `getPlatformPublicWorkDetailKind` 只根据 `PlatformPublicGalleryCard` 的玩法判定 helper 归一 kind没有 `sourceType` 的公开 RPG 作品回退为 `rpg`
- `resolvePlatformPublicWorkDetailOpenStrategy` 只表达“如何打开详情”,不执行网络请求或 state setter。
- 拼图、跳一跳、敲木鱼、视觉小说需要按 `profileId` 补读完整详情;返回对应 `load-*` strategy。
- 大鱼吃小鱼、抓大鹅、方洞挑战、汪汪声浪、宝贝识物和其它可直接展示的公开 entry 返回 `use-entry` strategy。
- RPG 返回 `load-rpg-detail` strategy由壳层 Adapter 继续调用 RPG 详情读取流程。
- `resolvePlatformPublicWorkActionMode` 只比较 `entry.ownerUserId` 与当前 viewer user id当前用户拥有该公开作品时返回 `edit`,否则返回 `remix`
- `resolvePlatformPublicWorkEditIntent` 只表达自有公开作品编辑意图:大鱼吃小鱼、拼图、抓大鹅、方洞挑战、视觉小说和汪汪声浪在能定位原草稿时返回对应 draft open 目标;跳一跳、敲木鱼和缺草稿作品返回原阻断文案;宝贝识物只返回需解析本地草稿的 intent旧 RPG gallery fallback 只在完整 RPG 详情已补读且 profile 匹配时返回编辑 intent。壳层仍执行草稿恢复、宝贝识物异步草稿解析、RPG 编辑导航和错误展示。
- `resolvePlatformPublicWorkEditIntent``deps` 只接编辑决策所需的当前拼图详情、当前 RPG 详情、视觉小说作品缓存、汪汪声浪作品缓存,以及抓大鹅 public detail -> work 的 Adapter。抓大鹅 Adapter 必须来自 Match3D Runtime Profile Module以保留 `generatedItemAssets` 归一化与背景资产提升的 Locality。
- `resolvePlatformPublicWorkLikeIntent` 只表达公开作品点赞意图:大鱼吃小鱼、拼图和旧 RPG gallery fallback 返回可执行 intent宝贝识物、汪汪声浪、方洞挑战和视觉小说返回不可用文案。壳层只按 intent 调用 API、写缓存和展示错误不再持有这组能力矩阵。
- `resolvePlatformPublicWorkRemixIntent` 只表达公开作品改造意图:大鱼吃小鱼和拼图返回可执行 intent 与成功后目标 stage旧 RPG gallery fallback 返回可执行 intent其它玩法返回原未开放文案。壳层只按 intent 调用 remix API、写 session / 缓存、切 stage 和展示错误。
- `resolvePlatformPublicWorkStartIntent` 只表达公开作品“开始游玩”意图:大鱼吃小鱼、拼图、跳一跳、敲木鱼、抓大鹅、方洞挑战、视觉小说、汪汪声浪和宝贝识物返回对应启动目标;旧 RPG gallery fallback 只在完整 RPG 详情已补读且 profile 匹配时返回记录游玩 intent否则返回原阻断文案。壳层仍执行登录保护、运行态启动、RPG 游玩记录、详情更新、busy 状态和错误展示。
- `resolvePlatformPublicWorkStartIntent``deps` 只接启动决策所需的当前拼图详情、当前 RPG 详情、汪汪声浪作品缓存,以及抓大鹅 public detail -> work 的 Adapter。抓大鹅 Adapter 必须来自 Match3D Runtime Profile Module以保留 `generatedItemAssets` 归一化与背景资产提升的 Locality。
- `resolvePlatformPublicWorkDetailOpenDecision` 只表达直接展示公开详情的打开 / 阻断结果、错误文案、目标 stage 与可写入历史的路径;真正执行 setter、push history 的副作用仍由壳层 Adapter 执行。
- `resolveActivePlatformPublicWorkAuthorEntry` 只在 `work-detail` 阶段选择统一公开详情 entry在 RPG `detail` 阶段只选择非 draft 的 RPG 详情 entry作者请求、竞态 request key 和缓存仍留壳层。
- `map*WorkToPublicWorkDetail` 只把各玩法已存在的 work / gallery summary 映射为统一详情 entry公开码、封面、统计与标题字段继续复用 `rpgEntryWorldPresentation.ts` 的平台公开卡片映射。
- `mapPublicWorkDetailToPuzzleWork``mapPublicWorkDetailToBigFishWork``mapPublicWorkDetailToSquareHoleWork``mapBarkBattlePublicDetailToWorkSummary` 只用于公开详情 CTA、推荐缓存或运行态启动前的兼容 work 摘要拼装;缺省值必须留在 Module 测试中固定,壳层不得重复推导。
- `resolveVisiblePuzzleDetailCoverCount` 只表达拼图公开详情封面解锁规则:非拼图、无当前 run 或 run 不属于当前公开详情时只展示首图;当前 run 属于该公开详情时按 `clearedLevelCount + 1` 解锁,但至少为 1。`PlatformWorkDetailView` 只接收 `visibleCoverCount` 展示,不读取 run。
- Match3D 的公开详情与 work 摘要互转仍属于 Match3D Runtime Profile Module因为它依赖 `generatedItemAssets` 归一化与背景资产提升。公开详情 Flow 只接统一详情策略,不复制该运行态规则。
## Depth / Leverage / Locality
- **Depth**:壳层传入公开作品 entry、玩法 work summary、当前用户 id、当前拼图 run 或少量启动 / 编辑 deps即可得到详情打开策略、动作模式、编辑 / 点赞 / 改造 / 启动意图、统一详情映射和封面可见数;玩法判定、能力矩阵与 DTO 默认值藏在 Module Implementation 内。
- **Leverage**:新增玩法公开详情时先补 Strategy / Mapping 单测,再接壳层 Adapter不必在多个 JSX / callback 位置重复 sourceType 判断或 DTO 回填。
- **Locality**:公开作品详情入口的纯策略与通用映射集中到一个小 ModuleMatch3D 素材归一仍在 Match3D Module启动运行态、点赞、改造、编辑等副作用仍留在壳层避免伪 Seam。
## 验收
- `npm run test -- src/components/platform-entry/platformPublicWorkDetailFlow.test.ts`
- `npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts`
- `npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts`
- `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx`
- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "public detail|owned public puzzle detail|direct missing public work detail"`
- `npx eslint src/components/platform-entry/platformPublicWorkDetailFlow.ts src/components/platform-entry/platformPublicWorkDetailFlow.test.ts --max-warnings 0`
- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,44 @@
# 【前端架构】Platform Puzzle Draft Recovery Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 曾内联维护拼图生成完成后刷新恢复的两个纯函数:`normalizeRecoveredPuzzleDraftSession``hasRecoverableGeneratedPuzzleDraft`。旧逻辑只要草稿有 `coverImageSrc`、首关 cover 或候选图,就会把恢复会话的 draft 和首关 `generationStatus` 抬成 `ready`,再进入结果页。
`.hermes/shared-memory/pitfalls.md` 已记录拼图待发布判定偏弱时只有首图但缺关卡画面、UI spritesheet 或关卡背景的半成品会被误当完成,用户进入结果页后仍可能空图或无法发布。
本切片先修前端恢复链路:只有完整首关资产包存在时,恢复流程才视为可完成。后端 `build_result_preview` / `validate_publish_requirements` / `is_puzzle_session_snapshot_publish_ready` 的发布门槛收紧另作后续切片,不混入本次前端模型收口。
## 决策
新增 `src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts` 作为 Platform Puzzle Draft Recovery **Module**。其公开 **Interface** 为:
- `normalizeRecoveredPuzzleDraftSession(session)`:从恢复会话里补齐首图 cover、assetId 和 selectedCandidateId只有完整资产包满足时才把 draft 与首关 `generationStatus` 改为 `ready`
- `hasRecoverableGeneratedPuzzleDraft(session)`:判断恢复会话是否拥有完整首关资产包。
`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它继续负责拉取 session、写 background task、写 React state、打开结果页和切换 stage。
## Interface 约束
- 无 draft 时保持原 session并判定不可恢复完成态。
- 首图可来自 `draft.coverImageSrc`、首关 `coverImageSrc` 或选中 / 首个候选图。
- 完整首关资产包必须同时具备:
- 首图 cover
- `levelSceneImageSrc``levelSceneImageObjectKey`
- `uiSpritesheetImageSrc``uiSpritesheetImageObjectKey`
- `levelBackgroundImageSrc``levelBackgroundImageObjectKey`
- cover / assetId / selectedCandidateId 可按旧优先级从 draft、首关、候选图回填但若完整资产包不满足不得把 `generationStatus` 抬为 `ready`
- 只修复前端恢复判定,不改变拼图发布接口、后端 session stage 或后端 preview compiler。
## Depth / Leverage / Locality
- **Depth**:壳层以两个函数表达“恢复会话归一化”和“是否可作为生成完成态恢复”;完整资产门槛和候选图 fallback 藏入 Module Implementation。
- **Leverage**:后续后端补齐发布门槛时,可用同一资产语言对齐前端恢复模型,避免壳层再散落条件判断。
- **Locality**:拼图恢复判定集中到纯测试面,避免在异步恢复 callback 中把半成品 ready 规则继续隐身。
## 验收
- `npm run test -- src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts`
- `npx eslint src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft"`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,36 @@
# 【前端架构】Platform Puzzle Runtime State Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 曾内联 `mergePuzzleServiceRuntimeState(...)`,在拼图排行榜提交回包后,把服务端 run 快照合并回当前前端 run。此逻辑没有 React state、网络、URL 或弹窗副作用,却需要理解 `PuzzleRunSnapshot` 的局部真相分工拼块布局、当前关卡状态和计时结果由前端即时裁决服务端回包只补排行榜、run 身份、通关数上限和下一关 handoff。
若该合并规则继续留在平台壳,后续调整排行榜来源、相似作品下一关或本地 / 服务端 run 混合策略时,维护者必须翻大型壳层并同时避开大量副作用代码。
## 决策
新增 `src/components/platform-entry/platformPuzzleRuntimeStateModel.ts` 作为 Platform Puzzle Runtime State **Module**。公开 **Interface**
- `mergePuzzleServiceRuntimeState(currentRun, serviceRun)`:当双方都有 `currentLevel` 时,保留当前前端关卡状态与棋盘,只合并服务端 run 身份、`clearedLevelCount` 上限、排行榜与下一关 handoff任一方缺 `currentLevel` 时返回当前 run。
`PlatformEntryFlowShellImpl.tsx` 继续作为 **Adapter**:它负责提交排行榜、读取回包、写 React state、刷新 archive 和错误提示,不再持有拼图 run 快照合并字段清单。
## Interface 约束
- 缺少 `currentRun.currentLevel``serviceRun.currentLevel` 时不得合并,直接返回当前 run。
- `clearedLevelCount` 取当前 run 与服务端 run 的最大值,避免服务端较旧回包降低本地通关数。
- 排行榜优先取 `serviceRun.currentLevel.leaderboardEntries`;为空时取 `serviceRun.leaderboardEntries`;两者皆空时保留当前关卡榜单。
- `currentLevel` 的棋盘、状态、计时和关卡字段来自当前 run不被服务端回包覆盖。
- `runId``entryProfileId``recommendedNextProfileId``nextLevelMode``nextLevelProfileId``nextLevelId``recommendedNextWorks` 来自服务端 run。
## Depth / Leverage / Locality
- **Depth**:壳层传入当前 run 与服务端 run即取得合并后的稳定快照排行榜来源、下一关 handoff 和前端局部真相保留规则藏入 Module Implementation。
- **Leverage**:排行榜提交、后续相似作品推荐或服务端 run 字段变化时,先改纯 Module 与单测,壳层提交副作用不变。
- **Locality**:拼图 runtime 快照合并规则集中到一个纯测试面,避免在平台壳中继续散落 `PuzzleRunSnapshot` 字段判断。
## 验收
- `npm run test -- src/components/platform-entry/platformPuzzleRuntimeStateModel.test.ts`
- `npx eslint src/components/platform-entry/platformPuzzleRuntimeStateModel.ts src/components/platform-entry/platformPuzzleRuntimeStateModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,36 @@
# 【前端架构】Platform Recommend Runtime Auth Model 收口计划
## 背景
平台首页推荐流会以 embedded runtime 方式启动跳一跳、抓大鹅、方洞挑战、拼图、敲木鱼、视觉小说、大鱼吃小鱼和汪汪声浪等玩法。旧规则散在 `PlatformEntryFlowShellImpl.tsx` 顶层 helper 与多个启动 callback匿名访客应申请 Runtime Guest Token已登录或已有 access token 时应走 background auth非 embedded 正常启动则不改普通鉴权。拼图还额外维护 `isolated` / `default` runtime auth mode容易与通用推荐流口径漂移。
## 决策
新增 `src/components/platform-entry/platformRecommendRuntimeAuthModel.ts`,以纯 **Module** 收口推荐 runtime 鉴权计划:
- `resolvePlatformRecommendRuntimeAuthPlan(input)`:返回 `requestKind``none``background``runtime-guest`,并给出拼图 runtime 应落到 `default` 还是 `isolated`
- `shouldUsePlatformRecommendRuntimeGuestAuth(input)`:只判断当前用户状态和是否允许 guest auth不读取本地 token。
`PlatformEntryFlowShellImpl.tsx` 继续作为 **Adapter**:它读取 `getStoredAccessToken()`、调用 `ensureRuntimeGuestToken()`、拼装具体 request options并在启动拼图时写入 `setPuzzleRuntimeAuthMode(...)`
## Interface 约束
- 非 embedded 且未显式允许 runtime guest auth 时,计划为 `none`
- embedded 推荐 runtime 若无登录用户且无本地 access token计划为 `runtime-guest`
- embedded 推荐 runtime 若已有登录用户或本地 access token计划为 `background`
- 拼图公开详情要求 `authMode='isolated'` 时,匿名状态应返回 `runtime-guest``puzzleRuntimeAuthMode='isolated'`
- 拼图公开详情要求 `authMode='isolated'` 但已登录或已有 access token 时,应回到 `default`,避免把账号态伪装成匿名 isolated guest。
## Depth / Leverage / Locality
- **Depth**:壳层传入 embedded、是否允许 guest、用户 ID 与本地 token 布尔值,即得 request 计划和拼图 runtime auth mode。
- **Leverage**:所有推荐 runtime 启动复用同一鉴权矩阵;新增玩法只需消费计划,不再重写匿名 / 已登录分支。
- **Locality**guest token 选择规则集中在纯测试面,具体 token 获取和 request options 仍留在壳层副作用 Adapter。
## 验收
- `npm run test -- src/components/platform-entry/platformRecommendRuntimeAuthModel.test.ts`
- `npx eslint --max-warnings 0 src/components/platform-entry/platformRecommendRuntimeAuthModel.ts src/components/platform-entry/platformRecommendRuntimeAuthModel.test.ts`
- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,40 @@
# 【前端架构】Platform Recommend Runtime Auto Start 收口计划
## 背景
平台推荐页的 embedded runtime 会在移动端首页自动选择当前推荐作品并启动对应玩法。旧 `useEffect` 同时判断桌面断点、当前 stage、当前 Tab、平台 loading、推荐列表是否为空、active entry 是否仍存在、对应 runtime 是否 ready、是否已有启动请求以及下一条 entry 应选谁。
这组判断是纯推荐流自动启动决策,但留在 `PlatformEntryFlowShellImpl.tsx` 会让 effect 依赖很长,也让后续新增玩法时容易把 ready 判定和启动时机混在副作用里。
## 决策
扩展 `src/components/platform-entry/platformPublicGalleryFlow.ts`,新增 `resolvePlatformRecommendRuntimeAutoStartDecision(input)`
- `noop`:当前不需要改变推荐 runtime。
- `clear`:推荐列表为空,壳层应清空 active entry、runtime kind 和错误。
- `start`:壳层应调用既有 `selectRecommendRuntimeEntry(entry)` 启动指定作品。
`PlatformEntryFlowShellImpl.tsx` 继续作为 **Adapter**:它负责收集 React state、清空 state、调用 `selectRecommendRuntimeEntry(...)` 和执行各玩法 runtime 副作用。
## Interface 约束
- 桌面端、非 `platform` stage、非 `home` Tab 或平台仍在 loading 时返回 `noop`
- 推荐列表为空时返回 `clear`
- active entry 存在且对应 runtime 已 ready 时返回 `noop`
- 当前已有启动请求时返回 `noop`
- active entry 存在但未 ready 时返回 `start(activeEntry)`
- active key 缺失或已不在列表中时返回 `start(firstEntry)`
## Depth / Leverage / Locality
- **Depth**壳层只消费三态决策列表查找、ready 判定和自动启动门禁藏入 Flow Module Implementation。
- **Leverage**:后续推荐流新增玩法或改 ready 判定,只需补 `platformPublicGalleryFlow.ts` 的模型测试。
- **Locality**effect 只保留副作用动作,不再承载推荐流状态机知识。
## 验收
- `npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts`
- `npx eslint --max-warnings 0 src/components/platform-entry/platformPublicGalleryFlow.ts src/components/platform-entry/platformPublicGalleryFlow.test.ts`
- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,38 @@
# 【前端架构】Platform RPG Agent Result Preview Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 曾内联维护 RPG Agent 结果页的发布门禁展示规则:从 `CustomWorldProfile` 顶层字段、`creatorIntent``anchorContent`、章节蓝图和场景章节中反证服务端返回的 legacy blocker 是否已经被当前结果页 profile 补齐,并同时在壳层内把 result preview source 映射成展示标签。
这些逻辑不读取 React state不请求网络不写 URL也不操作弹窗它们属于 RPG Agent 结果预览展示的纯判定。壳层继续负责 session、profile、发布动作和结果页 props 编排。
## 决策
新增 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts` 作为 Platform RPG Agent Result Preview **Module**。其公开 **Interface** 为:
- `buildPlatformRpgAgentResultPublishGateView(profile, fallbackBlockers, fallbackPublishReady)`:无 profile 时沿用服务端 fallback有 profile 时过滤已经被当前 profile 结构字段满足的发布 blocker并按剩余 blocker 重算展示态 `publishReady`
- `resolvePlatformRpgAgentResultPreviewSourceLabel(source)`:把 `published_profile``session_preview` 和未知 future source 映射成结果页预览来源标签。
`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它只把 `agentResultPreview``generatedCustomWorldProfile` 交给 Module并将返回的 blocker / label 传入结果页组件。
## Interface 约束
- 无 profile 时不得自行修正 blocker必须保留 fallback blocker message 与 fallback `publishReady`
- 有 profile 时只过滤已知结构 blocker`publish_missing_world_hook``publish_missing_player_premise``publish_missing_core_conflict``publish_missing_main_chapter``publish_missing_first_act`
- 世界钩子兼容读取 `worldHook``creatorIntent.worldHook``anchorContent.worldPromise``anchorContent.worldPromise.hook``settingText`
- 玩家前提兼容读取 `playerPremise``creatorIntent.playerPremise``anchorContent.playerEntryPoint.openingIdentity``openingProblem``entryMotivation`
- 主章节兼容读取 `chapters``sceneChapterBlueprints``sceneChapters`;首幕读取 `sceneChapterBlueprints` / `sceneChapters` 下的 `acts`
- 未知 blocker code 不得被前端过滤;未知 source 保留“服务端预览”兜底,不做穷尽删除。
## Depth / Leverage / Locality
- **Depth**:壳层以两个函数取得发布门禁展示和 source labelprofile 兼容字段路径、legacy blocker code 与兜底规则藏入 Module Implementation。
- **Leverage**:后续后端调整 RPG result preview blocker 或新增 source 时,先改 Module 与单测,再让壳层 Adapter 保持结果页 props 编排不变。
- **Locality**RPG Agent 结果预览展示规则集中到一个纯测试面,避免在大型平台壳中继续混杂 profile 结构探测。
## 验收
- `npm run test -- src/components/platform-entry/platformRpgAgentResultPreviewModel.test.ts`
- `npx eslint src/components/platform-entry/platformRpgAgentResultPreviewModel.ts src/components/platform-entry/platformRpgAgentResultPreviewModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,32 @@
# 【前端架构】Platform Selection Stage Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 在受保护数据失效后会清空当前用户的私有作品、运行态、草稿 notice 和生成状态。清理完成后,壳层还要判断当前 `SelectionStage` 是否还能继续展示:公开首页、公开详情、工作台入口等阶段可保留;结果页、生成页、运行态、个人反馈等依赖私有数据或运行态快照的阶段必须回到首页。
此外,平台壳还曾在多个 `useEffect` 中分别判断 big-fish、match3d、square-hole、visual-novel、baby-object-match 缺少草稿、作品或 run 时应回工作台、结果页还是首页。这类“当前 stage 已不能被现有状态支撑”的规则同样属于 stage 纯判定,不应散在壳层。
此前这些规则以内联长否定串或多段相似 effect 维护在壳层 **Implementation** 内。新增玩法 stage 或调整登录态行为时,维护者必须在巨型壳层中查找白名单和状态缺失回退,缺少独立测试面。
## 决策
新增 `src/components/platform-entry/platformSelectionStageModel.ts` 作为 Platform Selection Stage **Module**。其公开 **Interface** 为:
- `resolveSelectionStageAfterProtectedDataLoss(stage)`:输入当前 `SelectionStage`,输出受保护数据失效后应停留的 stage可保留则原样返回否则返回 `platform`
- `resolveSelectionStageAfterMissingCreationState(params)`:输入当前 `SelectionStage` 与各玩法“是否有 session / draft / run / work / formPayload”等可渲染事实输出状态缺失后应停留的 stage仍可展示则原样返回。
`PlatformEntryFlowShellImpl.tsx` 仍作为副作用 **Adapter**:负责检测受保护数据从可读变为不可读、清空各玩法缓存、重置生成和错误状态,或把当前 React state 汇总为布尔事实,并只在模型输出与当前 stage 不一致时调用 `setSelectionStage(nextStage)`
## 约定
- 新增 `SelectionStage` 时,必须判断它在退出登录或鉴权上下文收回后是否仍可展示,并在本 **Module** 的全量 `Record<SelectionStage, boolean>` 与测试中列明。
- 公开列表、公开详情和创作工作台入口可保留;依赖当前用户私有数据、生成 session、运行态 run 或个人资料的 stage 默认回 `platform`
- 缺失状态回退只读取壳层传入的布尔事实,不直接读取玩法 session / work / run 对象。big-fish、match3d、square-hole 的草稿事实必须来自 `Boolean(session?.draft)`visual-novel 的 session draft 与 work draft 可独立支撑结果页baby-object-match runtime 缺 draft 时不看 formPayload直接回 `platform`
-**Module** 不清理 state、不调用路由、不触发登录弹窗只表达纯 stage 决策。
## 验收
- `npm run test -- src/components/platform-entry/platformSelectionStageModel.test.ts`
- `npx eslint src/components/platform-entry/platformSelectionStageModel.ts src/components/platform-entry/platformSelectionStageModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,30 @@
# 【前端架构】Profile Dashboard Presentation 收口计划
## 背景
`RpgEntryHomeView.tsx` 的“我的数据”、钱包 chip 和“玩过”弹窗共用一批展示规则:泥点数量压缩、累计时长固定小时展示、单作品游玩时长压缩、作品类型标签和作品 ID 兜底。原先这些规则散在页面 **Implementation** 内,导致格式口径只能靠 UI 集成测试间接保护。
## 决策
新增 `src/components/rpg-entry/rpgEntryProfileDashboardPresentation.ts`,作为个人数据展示 **Module**。该 **Module****Interface** 收口为:
- `buildProfileDashboardPresentation(dashboard)`:统一生成钱包余额、钱包文案、累计时长文案和已玩数量文案。
- `formatDashboardCount(value)`:统一泥点和计数压缩规则。
- `formatTotalPlayTimeHours(playTimeMs)`:统一“累计游戏时长”固定小时口径。
- `formatCompactPlayTime(playTimeMs)`:统一“玩过”单作品紧凑时长。
- `formatPlayedWorkType(value)``formatPlayedWorkId(work)`:统一“玩过”列表里的玩法标签和作品号兜底。
`RpgEntryHomeView.tsx` 只消费这些 presentation 函数,保留卡片、弹窗和点击处理。个人数据展示规则的 **Locality** 转移到该 **Module** 与纯测试,后续修改计数、时长或作品类型标签不再穿透页面 JSX。
## 约定
- `formatDashboardCount` 与公开作品卡片的 `formatCompactCount` 不合并,二者展示口径不同。
- “累计游戏时长”固定以小时展示,避免个人数据卡在分钟 / 天之间跳动。
- “玩过”列表当前仍按历史契约用 `profileId || worldKey` 展示作品号;若后端未来下发 `publicWorkCode`,应在此 **Module** 改口径。
## 验证
- `npm run test -- src/components/rpg-entry/rpgEntryProfileDashboardPresentation.test.ts`
- `npm run typecheck`
- `npm run check:encoding`
- 针对变更文件执行 ESLint

View File

@@ -0,0 +1,31 @@
# 【前端架构】Profile Funds ViewModel 收口计划
## 背景
`RpgEntryHomeView.tsx` 原先直接维护钱包账单来源文案、金额正负号、账单余额兜底、充值价格、充值商品主值和会员摘要文案。这些规则散在页面 **Implementation** 内,且已与 `ProfileWalletLedgerEntry.sourceType` 契约产生漂移:后端可返回 `puzzle_author_incentive_claim`,页面没有对应中文 label会把原始枚举值外显给用户。
## 决策
新增 `src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts` 作为个人资金展示 **Module**。该 **Module****Interface** 收口为:
- `getWalletLedgerSourceLabel(sourceType)`:统一账单来源中文文案,补齐 `puzzle_author_incentive_claim`
- `formatWalletLedgerAmount(amountDelta)`:统一账单金额正负号。
- `buildWalletLedgerPresentation(ledger, fallbackBalance)`:统一余额兜底与账单行 presentation。
- `formatRechargePrice(priceCents)``buildRechargeProductValueLabel(product)`:统一充值商品价格与主值文案。
- `buildMembershipLabel(membership, formatTime)`:统一会员摘要文案,并保留页面现有时间格式 Adapter。
`RpgEntryHomeView.tsx` 只消费该 **Module** 输出,保留弹窗布局、支付流程、微信渠道和轮询副作用。资金展示规则的 **Locality** 收口到纯函数测试,后续新增账单来源或调整价格 / 会员文案时不再穿透页面 JSX。
## 约定
- 未知账单来源仍保留原始 sourceType 兜底,避免新后端枚举被空白吞掉。
- 账单余额继续沿用既有口径:有账单时取第一条 `balanceAfter`,无账单时使用外部 fallback balance。
- 本次只收展示 **Interface**,不迁移支付确认、微信跳转、订单轮询或弹窗状态。
## 验证
- `npm run test -- src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger|profile recharge modal shows native qr code"`
- 针对变更文件执行 ESLint
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,29 @@
# 【前端架构】Profile Task ViewModel 收口计划
## 背景
`RpgEntryHomeView.tsx` 的“每日任务”卡片与任务弹窗共用同一批展示规则:任务优先级、可领取 / 未完成选择、进度 clamp、奖励兜底、状态标签和按钮文案。原先这些规则散在巨型页面 **Implementation**UI JSX 既要渲染,又要知道任务状态排序和兜底口径。
## 决策
新增 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,作为每日任务展示模型 **Module**。该 **Module****Interface** 收口为:
- `selectProfileTaskCenterTasks(tasks)`:统一任务中心只展示一条可操作任务,按 claimable / incomplete 优先级并保持原始顺序。
- `selectProfileTaskCardTask(tasks)`:统一任务卡兜底顺序,先可操作,再 claimed再非 disabled。
- `buildProfileTaskCardSummary(center)`:统一任务卡的奖励、阈值、进度百分比与动作文案。
- `buildProfileTaskProgressLabel(task)``getProfileTaskStatusLabel(status)``getProfileTaskClaimButtonLabel(task, isClaiming)`:统一任务弹窗中的进度、状态和按钮文案。
`RpgEntryHomeView.tsx` 只消费这些 ViewModel 函数,保留弹窗、按钮和点击处理。每日任务展示规则的 **Locality** 转移到 ViewModel **Module** 与纯测试,后续新增任务状态或修改展示优先级不再穿透 UI。
## 约定
- 任务中心只露出当前最需要用户处理的一条任务。
- 任务进度必须按 `0..threshold` clamp避免异常后端进度撑破卡片进度条。
- `pause` / `claim` 等副作用仍留在页面和后端 clientViewModel 只做展示派生。
## 验证
- `npm run test -- src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts`
- `npm run typecheck`
- `npm run check:encoding`
- 针对变更文件执行 ESLint

View File

@@ -0,0 +1,40 @@
# 【前端架构】Public Gallery ViewModel 收口计划
## 背景
`RpgEntryHomeView.tsx` 同时承担首页、发现、分类、排行、搜索和公开作品卡片渲染。公开作品的 category 分组、跨来源去重、搜索归一化、作品号匹配、时间戳解析和列表排序原本都放在页面巨型 **Implementation** 中,导致公开作品规则与 JSX 交错,新增玩法时难以判断该改页面、卡片还是平台入口规则。
## 决策
新增 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,作为公开作品 ViewModel **Module**。该 **Module****Interface** 收口为:
- `buildPublicGalleryCardKey(entry)`:复用平台公开作品身份规则,补齐 jump-hop / wooden-fish 等玩法 key。
- `buildPublicCategoryGroups(featuredEntries, latestEntries)`:统一去重、标签兜底和分类排序。
- `getPlatformPublicEntries(featuredEntries, latestEntries)` / `getAllPlatformPublicEntries(featuredEntries, latestEntries)`:统一公开作品合并规则。
- `getPlatformSearchableWorkIds(entry)``filterPlatformWorkSearchResults(entries, keyword)``isExactPublicWorkCodeSearch(entries, keyword)`统一搜索归一化、compact code 匹配和排序。
- `parsePlatformEntryTimestamp(value)` / `getPlatformWorldTimestamp(entry)`:统一兼容 ISO 与后端 seconds.microsZ 时间戳。
- `filterTodayPublishedEntries(entries)`:统一“今日游戏”本地自然日筛选。
- `getPlatformWorldLikeCount(entry)` / `getPlatformWorldPlayCount(entry)` / `getPlatformWorldRemixCount(entry)``buildPlatformRankingEntries(entries, tab)``getPlatformRankingMetricValue(entry, tab)`:统一公开卡片指标读取、排行 Tab 排序与取值。
- `DEFAULT_PLATFORM_CATEGORY_KIND_FILTER``DEFAULT_PLATFORM_CATEGORY_SORT_MODE``PLATFORM_CATEGORY_KIND_FILTERS``PLATFORM_CATEGORY_SORT_OPTIONS``getPlatformCategoryKindFilterOption(kindFilter)``getPlatformCategorySortOption(sortMode)``getNextPlatformCategorySortMode(sortMode)`:统一分类频道的筛选 / 排序选项、默认值、label 兜底和排序循环。
- `getPlatformCategoryKindFilter(entry)``matchesPlatformCategoryKindFilter(entry, kindFilter)``sortPlatformCategoryEntries(entries, sortMode)``getPlatformCategoryPrimaryMetric(entry)`:统一分类频道的玩法过滤、排序和主指标展示。
`RpgEntryHomeView.tsx` 只消费这些 ViewModel 函数,保留渲染、事件处理和账号状态。公开作品规则的 **Locality** 转移到 ViewModel **Module** 与其测试,页面不再持有这批纯规则。
## 约定
- 公开作品身份 key 与平台入口推荐流保持一致,优先复用 `platformPublicGalleryFlow`
- 搜索应同时匹配作品号、`profileId``workId`、标题、作者、摘要和副标题。
- 搜索排序先看标题前缀,再看作品号 compact 前缀,最后按发布时间 / 更新时间倒序。
- 时间解析必须保留后端 `seconds.microsZ` 兼容。
- 分类筛选与排序的选项顺序、默认值、中文 label 和“综合 -> 最新 -> 游玩 -> 点赞 -> 综合”循环属于 ViewModel **Interface**;页面只能消费该 **Interface**,不得在 `RpgEntryHomeView.tsx` 复写数组或 fallback 文案。
## 后续深化
下一步可把移动 / 桌面 discover feed 的数据准备继续迁入 ViewModel但卡片 JSX 与交互状态仍留页面内。
## 验证
- `npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`
- `npm run typecheck`
- `npm run check:encoding`
- 针对变更文件执行 ESLint

View File

@@ -0,0 +1,31 @@
# 【前端架构】Public Work Presentation 收口计划
## 背景
`RpgEntryHomeView.tsx` 的作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用公开作品玩法类型 label 与紧凑计数格式。原先 `describePublicGalleryCardKind``formatCompactCount` 放在页面 **Implementation** 内,导致新增玩法或调整数字展示时需要穿过多段 JSX。公开作者 lookup key 与头像首字也曾由页面手写,页面既要知道公开作品作者来源优先级,又要知道 `code:` / `id:` 前缀约定。
## 决策
`src/components/rpg-entry/rpgEntryWorldPresentation.ts` 追加单作品展示 **Interface**
- `describePlatformPublicWorkKind(entry)`:统一公开作品玩法类型 label并继续复用 `formatPlatformWorkDisplayTag` 的 4 字截断口径。
- `formatPlatformCompactCount(value)`:统一游玩、改造、点赞、排行和分类指标的紧凑数字展示。
- `resolvePlatformPublicWorkAuthorLookup(entry)`:统一公开作者查询 lookup优先使用 `authorPublicUserCode`,否则回退 `ownerUserId`,并用结构化 `{ key, source, value }` 避免页面复写前缀规则。
- `formatPlatformPublicAuthorAvatarLabel(authorDisplayName)`:统一公开作者头像无图时的首字兜底。
`RpgEntryHomeView.tsx` 删除本地类型 label、紧凑计数、公开作者 lookup 与头像首字 **Implementation**,仅消费 `rpgEntryWorldPresentation.ts`。认证请求、缓存和失败兜底仍留页面侧 Adapter集合筛选、排序和指标选择仍留在 `rpgEntryPublicGalleryViewModel.ts`,避免单作品展示 **Module** 与集合 **Module** 混杂。
## 约定
- 紧凑计数保留既有口径:`10000` 显示 `1.0万``100000000` 显示 `1.0亿`,一万以下不加千分位。
- 玩法类型 label 继续遵循 4 字展示限制,例如“大鱼吃小鱼”外显为“大鱼吃小”。
- 公开作者 lookup 的 `key` 只用于缓存索引;真正调用公开用户 Adapter 时以 `source``value` 分发,页面不得解析 `code:` / `id:` 前缀。
- 本次不迁移排行 metric label / value 配对;该规则属于集合排序 **Module** 的后续切片。
## 验证
- `npm run test -- src/components/rpg-entry/rpgEntryWorldPresentation.test.ts`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|ranking|category"`
- 针对变更文件执行 ESLint
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,32 @@
# 【前端架构】Ranking ViewModel 收口计划
## 背景
平台发现页排行频道以 `PlatformRankingTab` 决定 tab 文案、空态文案、排序字段和指标展示。原先排序与指标取值在 `rpgEntryPublicGalleryViewModel.ts`,而 tab label、metric label 与 empty text 留在 `RpgEntryHomeView.tsx`,页面还用类型断言寻找 active config导致同一个排行语义的 **Interface** 分散。
## 决策
`src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 收口排行 **Interface**
- `DEFAULT_PLATFORM_RANKING_TAB``PLATFORM_RANKING_TABS`:统一 tab 顺序、tab label、metric label 与空态文案。
- `getPlatformRankingTabConfig(tab)`:统一 active tab 配置兜底。
- `getPlatformRankingMetric(entry, tab)`:统一 metric label 与 value避免 label/value 漂移。
- `buildPlatformRankingEntries(entries, tab)` 继续承载排序规则。
`RpgEntryHomeView.tsx` 只保留 active tab 状态、点击与渲染,不再理解“热门榜=游玩值”“新品榜=近 7 日值”等映射。排行规则的 **Locality** 收口到 PublicGallery ViewModel。
## 约定
- 默认排行 tab 保持 `hot`
- tab 顺序保持“热门榜 / 改造榜 / 新品榜 / 点赞榜”。
- 排序口径保持:`hot=playCount``remix=remixCount``new=recentPlayCount7d``like=likeCount`
- “新品榜”仍按近 7 日游玩数排序,不改为发布时间排序。
- 页面层继续保留最多显示 30 条的展示限制。
## 验证
- `npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "bottom category tab becomes ranking and switches ranking metrics|ranking"`
- 针对变更文件执行 ESLint
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,31 @@
# 【前端架构】Recommend Feed ViewModel 收口计划
## 背景
平台首页推荐 feed、发现页推荐频道、桌面推荐格和正式 runtime 的上一条 / 下一条选择共用一批展示规则公开作品跨来源去重、过滤寓教于乐隐藏内容、按精选优先再最新兜底、active key 失效时回到首项、前后相邻条目回环且单条目不自循环。原先这些规则分别散在 `RpgEntryHomeView.tsx``PlatformEntryFlowShellImpl.tsx`**Implementation** 内,导致推荐预览与正式 runtime 之间存在口径漂移风险。
## 决策
`src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 追加推荐 feed **Interface**
- `dedupePlatformPublicGalleryEntries(entries)`:统一公开作品按 `buildPublicGalleryCardKey` 去重,后出现来源覆盖旧值。
- `buildPlatformRecommendFeedEntries(featuredEntries, latestEntries)`:统一推荐 feed 的精选 + 最新合并、隐藏寓教于乐内容与去重顺序。
- `selectPlatformRecommendFeedWindow(entries, activeEntryKey)`:统一推荐页当前项、上一项、下一项和 active key 失效兜底。
- `selectAdjacentPlatformRecommendEntry(entries, direction, baseEntryKey)`:统一正式 runtime 上一条 / 下一条回环选择,并避免单作品自循环。
`RpgEntryHomeView.tsx` 不再自建 `Map` 或手写取模;`PlatformEntryFlowShellImpl.tsx` 的 runtime 推荐条目也改用同一 **Module**。推荐 feed 的 **Locality** 回到 PublicGallery ViewModel页面与 runtime 只保留 UI、动画和启动副作用。
## 约定
- 推荐 feed 仍只展示普通公开作品;寓教于乐内容由独立频道控制,不进入推荐 runtime 队列。
- 去重保留既有“后出现来源覆盖旧值、插入位置不变”的行为。
- active key 缺失或失效时,展示窗口回到首个推荐作品;单个作品没有上一条 / 下一条预览。
## 验证
- `npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"`
- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "logged out home recommendation next starts the next puzzle work"`
- 针对变更文件执行 ESLint
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,42 @@
# RecommendSwipeDeckModel 收口计划
## 背景
移动端推荐首页的纵向 swipe deck 曾把拖拽阈值、offset clamp、commit 方向、rail class 和分享文案直接放在 `RpgEntryHomeView.tsx` Implementation 内。页面因此同时理解 DOM pointer 副作用、动画副作用与推荐卡纯规则,后续调整手势阈值或分享文案时缺少稳定测试面。
## 决策
- 新增 `src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts` 作为 Recommend Swipe Deck Module。
- Module Interface 收口:
- `hasRecommendDragStarted`
- `clampRecommendDragOffset`
- `resolveRecommendDragCommitDirection`
- `resolveRecommendCommitOffset`
- `buildRecommendSwipeRailClassName`
- `shouldAnimateRecommendSwipe`
- `buildRecommendShareText`
- `RpgEntryHomeView.tsx` 保留 pointer capture、DOM 高度读取、`setTimeout`、clipboard、like/remix/open 等副作用 Adapter推荐卡纯规则不再散落在页面 Implementation 内。
## Interface 约束
- swipe 阈值、commit 动画时长和 drag fallback limit 只从 Module 导出,不在页面重复定义。
- `deltaY < 0` 表示上滑进入下一条,返回方向 `1``deltaY > 0` 表示下滑进入上一条,返回方向 `-1`
- 未达到 commit 阈值时必须返回 `null`,页面 Adapter 只负责把 offset 归零。
- rail class 仅由 `offsetY``commitDirection` 决定CSS class 名保持现有命名。
- 分享文案只使用公开作品名、作品号和详情 URL公开作品码解析与复制副作用仍在页面 Adapter。
## Depth / Leverage / Locality
- **Depth**:页面传入少量数值或公开作品身份,即可得到拖拽状态、提交方向、动画 class 和分享文案。
- **Leverage**:调整推荐 swipe 体验时只需改 Module 与单测,交互测试仍护页面 Adapter。
- **Locality**pointer 事件生命周期与纯规则分离,推荐卡手势和分享规则集中到一个小 Module。
## 验收
- `npm run test -- src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"`
- `npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts -t "recommend"`
- `npx eslint src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts --max-warnings 0`
- `npx eslint src/components/rpg-entry/RpgEntryHomeView.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,32 @@
# 【前端架构】Runtime Client Family 收口计划
## 背景
多个小游戏 runtime client 都重复实现路径编码、JSON header / body、runtime guest token、认证影响策略和重试参数。重复逻辑分散在各玩法文件后新增玩法容易遗漏 guest auth 或 retry 语义,也让测试必须逐玩法检查同一请求骨架。
## 决策
新增 `src/services/runtimeRequest.ts`,作为 Runtime Client Family 的请求 **Module**。其 **Interface** 包含:
- `buildRuntimeApiPath(basePath, ...segments)`:统一对 runtime path segment 执行 `encodeURIComponent`
- `requestRuntimeJson(params)`:统一设置 method、JSON body、`Content-Type`、runtime guest `Authorization`、auth options 和 retry options。
`match3dRuntimeClient.ts``squareHoleRuntimeClient.ts``bigFishRuntimeClient.ts``barkBattleRuntimeClient.ts``puzzleRuntimeClient.ts` 的公开 / 推荐运行态请求、`jumpHopClient.ts``woodenFishClient.ts` 的正式 run 请求,以及 `visualNovelRuntimeClient.ts` 的公开列表、run 读取、history 读取和 regenerate JSON 请求已迁入此 **Module**,并保留原有导出函数名、错误文案、返回契约和重试常量。点击 / 投入 / 成绩提交等玩法专属 payload 归一化仍留在各自 client 内,避免把领域规则塞进通用请求 **Implementation**
## 约定
- Runtime client 不再手写 `encodeURIComponent` 拼 path应优先使用 `buildRuntimeApiPath`
- Runtime JSON 请求不再手写 `Content-Type`、guest `Authorization``buildRuntimeGuestAuthOptions` 合并;应优先使用 `requestRuntimeJson`
- 玩法专属 payload 归一化、返回值适配和中文错误文案仍属于各玩法 client。
- 每迁移一个 client必须保留原导出函数名与原调用方契约。
## 后续深化
下一批可评估是否扩展 `requestRuntimeJson` 支持 `timeoutMs` / `signal`,再迁移 Visual Novel start 请求Visual Novel SSE、平台存档、平台 checkpoint以及 Puzzle `pause` / `props` 继续保留各自现有 auth / stream 语义,暂不纳入通用 JSON helper。
## 验证
- `npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts`
- `npm run typecheck`
- `npm run check:encoding`
- 针对变更文件执行 ESLint

View File

@@ -0,0 +1,36 @@
# SSE 客户端传输层收口约定
更新时间:`2026-06-03`
## 背景
前端多个服务 client 需要读取 Server-Sent Events包括创作 Agent、创意互动 Agent、视觉小说运行态和微信充值订单状态。旧实现分别在各自文件里手写事件边界查找、`TextDecoder` 解码、JSON 解析和流结束 flush容易出现 CRLF / LF 边界不一致、UTF-8 多字节字符尾部丢失、错误事件处理漂移,以及长连接达到最终状态后没有及时释放的问题。
## 决策
前端 SSE 的传输层统一收口到 `src/services/sseStream.ts`
- `readSseStream` 负责读取 `Response.body`、识别 `\n\n``\r\n\r\n` 事件边界、合并多行 `data:`、flush `TextDecoder` 尾部缓冲,并支持事件处理函数返回 `false` 后取消 reader。
- `readSseJsonStream` 只在传输事件基础上解析 JSON object空 data 与异常 JSON 继续按旧口径静默跳过。
- 各业务 client 只保留领域事件归一化、最终结果聚合和中文错误文案,不再重复实现 SSE 边界扫描、reader 循环或 UTF-8 flush。
- OpenAI 兼容流、`[DONE]` 哨兵或其它非 JSON SSE 可直接使用 `readSseStream`;业务 JSON 事件优先使用 `readSseJsonStream`
## 落地范围
本次先收口以下客户端:
- `src/services/aiService.ts`
- `src/services/creation-agent/creationAgentSse.ts`
- `src/services/creative-agent/creativeAgentSse.ts`
- `src/services/visual-novel-runtime/visualNovelRuntimeSse.ts`
- `src/services/rpg-entry/rpgProfileClient.ts`
- `src/services/llmClient.ts`
后续新增 SSE client 时不得复制 `findSseEventBoundary``parseSseEventBlock` 或手写 reader 循环;若确实需要特殊 framing应先扩展 `sseStream.ts` 的传输能力,再在业务 client 中处理领域语义。
## 验收
- `src/services/sseStream.test.ts` 覆盖 CRLF / LF 边界、UTF-8 尾部 flush、异常 JSON 跳过和提前停止取消 reader。
- `src/services/llmClient.test.ts` 覆盖 OpenAI 兼容文本流、异常 JSON 跳过和 `[DONE]` 后提前停止。
- 已有 OpenAI 兼容文本流、NPC 聊天流、创作 Agent、创意互动 Agent、视觉小说运行态和充值订单状态测试继续通过。
- `npm run typecheck` 不产生新的类型错误。

View File

@@ -0,0 +1,34 @@
# 【前端架构】Work Shelf Module 收口计划
## 背景
创作中心作品架需要同时展示 RPG、拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、Bark Battle 和宝贝识物等作品。`creationWorkShelf.ts` 已经统一了卡片标题、摘要、封面、发布码、分享路径、指标、生成态和动作 Adapter。后续深化前`CustomWorldCreationHub.tsx` 虽已不再按玩法 `kind` 分发点击,但生产调用仍向 Hub 传入多玩法 raw items 与 open/delete/claim 回调列阵Hub Interface 仍偏 shallow。
## 决策
`CreationWorkShelfItem.actions.open` 是打开作品的正式 **Interface**`CustomWorldCreationHub.tsx` 只负责卡片点击与 `onOpenShelfItem` 通知,然后调用 `item.actions.open()`,不再根据 `item.source.kind` 分发玩法。
`buildCreationWorkShelfItemsFromSources` 是作品架 source registry 的正式 **Interface**。每个玩法提供一个 `CreationWorkShelfSourceAdapter`Adapter 负责把玩法数据、删除权限、打开动作和特殊动作映射为 `CreationWorkShelfItem[]`。registry 统一执行 flatten、运行态覆盖、持久化生成态兜底和更新时间排序。
`CustomWorldCreationHub.tsx` 的生产 **Interface** 收敛为 `shelfItems: CreationWorkShelfItem[]``loading/error/onRetry/mode/recentWorkItems/onOpenShelfItem/deletingWorkId/claimingPuzzleProfileId` 等 UI 状态。平台壳 `PlatformEntryFlowShellImpl.tsx` 在外层作为 Adapter 调用 `buildCreationWorkShelfItems` 注入完整 open/delete/claim actions 后再传给 HubHub 不再接触各玩法 raw items、删除权限布尔值或玩法专属打开回调。
测试文件通过 `CustomWorldCreationHub.testAdapter.tsx` 把旧 fixture 转成 `shelfItems`,避免测试继续强化生产 Hub 的旧浅 Interface。
此决策让 `creationWorkShelf.ts`**Module** 更 deep
- **Implementation**:玩法差异、草稿 / 已发布分支、profileId 进入方式和回调绑定都留在 Work Shelf Adapter 内。
- **Interface**Hub 只需要 `CreationWorkShelfItem[]`;后续调用方也可只传 `CreationWorkShelfSourceAdapter[]`,不需要知道每种玩法的打开规则、状态覆盖和排序规则。
- **Leverage**:新增玩法时只补 shelf item 映射与 AdapterHub 不再新增 switch 分支。
- **Locality**作品架点击行为、source flatten、运行态覆盖和排序错误集中在 `creationWorkShelf.ts` 与其测试里定位。
## 后续深化
`buildCreationWorkShelfItems` 仍保留旧长参数兼容入口,但其 **Implementation** 已改为组装 `CreationWorkShelfSourceAdapter[]` 后复用 `buildCreationWorkShelfItemsFromSources`。下一步可让平台壳直接传入 source adapters从而继续减少按玩法平铺的参数数量。`deletingWorkId``claimingPuzzleProfileId` 仍是 Hub UI 状态,可后续下沉到 shelf item/action busy state。
## 验证
- `npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`
- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "creation hub published work can open detail view before deleting from detail page|creation hub published work enters existing detail view|creation hub published work card reveals delete action after card action reveal"`
- `npm run typecheck`
- `npm run check:encoding`
- 针对变更文件执行 ESLint

View File

@@ -0,0 +1,56 @@
# 【前端架构】平台入口 Public Gallery Flow Module 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 同时承载平台入口、推荐流、公开作品详情、运行态启动和作品架刷新。公开作品列表中的身份识别、跨玩法去重、时间排序和推荐运行态类型判定原本散落在入口巨型实现中,后续每新增一种玩法都需要在巨型文件内追加判断,影响前端架构的复用、统一和扩展。
## 决策
新增 `src/components/platform-entry/platformPublicGalleryFlow.ts`,作为平台入口公开作品流的 **Module**。该 Module 的 **Interface** 固定收口为:
- `getPlatformPublicGalleryEntryKey(entry)`:按玩法类型、作者和 `profileId` 生成公开作品身份。
- `getPlatformRecommendRuntimeKind(entry)`:把公开作品卡映射为推荐运行态 kind。
- `resolvePlatformRecommendRuntimeStartIntent(entry, deps)`:把公开作品卡映射为推荐 runtime 启动意图、错误落点和 embedded / returnStage 参数。
- `isPlatformRecommendRuntimeReadyForEntry(entry, state)`:用标量 ready state 判定当前推荐 runtime 是否已能承接该公开作品。
- `isSamePlatformPublicGalleryEntry(left, right)`:按公开作品身份比较。
- `mergePlatformPublicGalleryEntries(rpgEntries, puzzleEntries)`:统一完成 RPG 与各玩法公开作品去重、覆盖和倒序排序。
- `buildPlatformPublicGalleryFeeds(input)`:统一构造 `featuredEntries``latestEntries`,收口各玩法可见性 gate、mapper 矩阵、汪汪声浪 works fallback 和推荐首屏 `slice(0, 6)`
入口壳层只调用这些函数,不再在 `PlatformEntryFlowShellImpl.tsx` 内手写公开作品身份、排序规则、公开作品流聚合矩阵、推荐 runtime 启动能力矩阵和 ready 判定。ready 判定只接布尔值与拼图 profile id不把各玩法 run snapshot 类型拖入 Module。
## 玩法身份规则
- `big-fish``puzzle``jump-hop``wooden-fish``match3d``square-hole``visual-novel``bark-battle` 使用自身 `sourceType` 作为 key kind。
- `edutainment` 使用 `edutainment:${templateId}` 作为 key kind避免后续幼教类模板共用 `sourceType` 时互相覆盖。
- 没有 `sourceType` 的 RPG 公开作品回退为 `rpg`
- 最终 key 格式为 `${kind}:${ownerUserId}:${profileId}`
- 合并时后进入的相同 key 会覆盖先进入的卡片,然后按 `publishedAt ?? updatedAt` 新到旧排序;非法时间按 `0` 处理。
- 公开作品流聚合时,大鱼吃小鱼、宝贝识物和视觉小说必须受各自可见性 gate 控制;汪汪声浪优先用 gallery entriesgallery 为空时才从 works 中筛 `status === 'published'` 作为 fallback。
## 推荐 runtime 启动意图
- `resolvePlatformRecommendRuntimeStartIntent` 只表达推荐 runtime 的启动目标,不执行鉴权、运行态 API、错误 setter、缓存、request key 或 UI 状态更新。
- 大鱼吃小鱼、拼图、跳一跳、敲木鱼、抓大鹅、方洞挑战、视觉小说、汪汪声浪和宝贝识物返回对应启动 intentRPG 维持当前无嵌入 runtime 的 `mark-ready` 行为。
- 大鱼吃小鱼、拼图、抓大鹅、方洞挑战和汪汪声浪在公开卡无法拼出启动 work 时返回 `blocked`,同时给出 `errorTarget`,由壳层 Adapter 分发到对应玩法错误 setter。
- 拼图优先使用同 `profileId``selectedPuzzleDetail`,否则从公开卡映射兼容 work 摘要。
- 抓大鹅 public detail -> work mapper 必须作为 Adapter 注入,继续由 Match3D Runtime Profile Module 维护 `generatedItemAssets` 归一化与背景资产提升。推荐 runtime 固定沿用旧参数 `returnStage = 'work-detail'``embedded = true`
- 汪汪声浪优先使用推荐流已持有的 `barkBattleGalleryEntries`,再回退公开卡映射;不额外读取作品架列表。
## 推荐 runtime ready 判定
- `isPlatformRecommendRuntimeReadyForEntry` 先要求 `state.activeKind` 与当前公开作品的 `getPlatformRecommendRuntimeKind(entry)` 相同,否则返回 `false`
- 大鱼吃小鱼、跳一跳、敲木鱼、抓大鹅、方洞挑战和视觉小说只看对应 `has*Run` 布尔值,保持旧行为,不在本 Module 内解析 run snapshot。
- 拼图只看 `puzzleRunEntryProfileId``puzzleRunCurrentLevelProfileId` 是否等于当前公开作品 `profileId`
- 汪汪声浪和 RPG 在 kind 匹配时沿用旧 `ready = true` 行为;宝贝识物只看 `hasBabyObjectMatchDraft`
- 若未来要修正同玩法旧 run 误判或 RPG 无嵌入 runtime 的旧行为,应另立行为变更任务;本 Module 先只收口现有规则。
## 后续深化
下一步可继续把平台入口的作品架刷新、删除确认和直达恢复逻辑收口成更深的 Work Shelf **Module**。当前 `platformPublicGalleryFlow` 先提供一个稳定 seam使公开作品 identity、runtime kind、推荐 runtime 启动意图与 ready 判定的修改集中在一处。
## 验证
- `npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts`
- `npm run typecheck`
- `npm run check:encoding`
- 针对变更文件执行 ESLint

View File

@@ -0,0 +1,38 @@
# 【后端架构】Puzzle Publish Asset Gate 收紧计划
## 背景
拼图前端恢复链路已由 `platformPuzzleDraftRecoveryModel.ts` 收紧只有首图、关卡画面、UI spritesheet 与关卡背景资产包完整时,才把恢复草稿抬为完成态。但后端仍有两处待发布门槛偏弱:
- `module-puzzle::validate_publish_requirements(...)` 只校验作品名、描述、标签、关卡名与 cover。
- `api-server::puzzle::tags::is_puzzle_session_snapshot_publish_ready(...)` 也只校验同一组轻字段,并据此把 session stage 置为 `ready_to_publish`
这会让只有首图但缺关卡正式画面、UI spritesheet 或关卡背景的半成品显示为可发布或进入待发布 stage。
## 决策
后端拼图待发布门槛统一收紧到完整首关资产包:
- `module-puzzle``validate_publish_requirements` 中新增资产 blocker业务规则继续留在领域模块。
- `api-server` 的 session snapshot ready 判定复用同一资产语言,避免标签生成后把半成品 session stage 改成 `ready_to_publish`
- 本切片不改 SpacetimeDB schema、不改 DTO 字段、不改路由、不改计费和发布动作副作用。
## 接口约束
- 仍保留既有作品名、描述、标签数量、关卡名、cover 校验。
- 每个关卡必须具备:
- `cover_image_src`
- `level_scene_image_src``level_scene_image_object_key`
- `ui_spritesheet_image_src``ui_spritesheet_image_object_key`
- `level_background_image_src``level_background_image_object_key`
- 缺正式关卡画面、UI spritesheet、关卡背景时各自输出明确 blocker。
- `build_result_preview(...).publish_ready``is_puzzle_session_snapshot_publish_ready(...)` 必须在同一类缺资产草稿上返回 false。
- `api-server` 从 action payload 构造 fallback session 时,缺资产 levels snapshot 应停留 `image_refining`,不得进入 `ready_to_publish`
## 验收
- `cargo test -p module-puzzle --manifest-path server-rs/Cargo.toml validate_publish_requirements`
- `cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation`
- `npm run check:encoding`
- `git diff --check`
- 修改后按仓规尝试 `npm run api-server` 拉起后端并确认 `/healthz`

View File

@@ -50,7 +50,7 @@
- `GET /admin/api/works/visibility`
- `POST /admin/api/works/visibility`
后台操作 key 使用统一的 `sourceType + profileId` 组合。`profileId` 在大多数玩法中对应作品 profile特殊玩法维持既有源表身份`big-fish` 对应 `session_id``bark-battle` 对应 `work_id``custom-world` 更新源表时必须同步 `custom_world_gallery_entry.visible`,避免兼容 gallery 缓存与统一公开 read model 出现可见性漂移。
后台操作 key 使用统一的 `sourceType + profileId` 组合。当前后端统一可见性管理覆盖 `puzzle``puzzle-clear``custom-world``jump-hop``wooden-fish``match3d``square-hole``visual-novel``big-fish``bark-battle``edutainment` 当前没有后端统一作品源表,暂不接入该后台能力。`profileId` 在大多数玩法中对应作品 profile特殊玩法维持既有源表身份`big-fish` 对应 `session_id``bark-battle` 对应 `work_id``custom-world` 更新源表时必须同步 `custom_world_gallery_entry.visible`,避免兼容 gallery 缓存与统一公开 read model 出现可见性漂移。
该后台能力只修改源表 / source view 过滤事实,不把 `visible` 暴露到公开列表或公开详情契约。隐藏作品后,统一 `public_work_gallery_entry``public_work_detail_entry` 不再返回该作品;恢复显示后重新进入公开 read model。
@@ -77,6 +77,7 @@
- 旧 view 退到底层 source / 兼容职责。
-`public_work_*` view 是 `api-server` 公开列表 / 详情的统一主读模型。
- 各玩法 source view 只暴露 `visible=true` 的已发布作品;旧数据迁移默认补 `visible=true`,避免历史作品被误隐藏。
- RPG / 自定义世界旧数据可能缺少 `published_at`。统一公开详情可以用 `updated_at` 作为展示和排序兜底点赞、游玩、Remix 等写入路径也必须按 `publication_status=Published + visible=true + 未删除` 判断作品存在,不能额外要求 `published_at` 非空。
- 临时运行约束SpacetimeDB 2.2 下抓大鹅 `match_3_d_gallery_view``publication_status` 索引过滤在源表更新触发统一 view 刷新时可能初始化 panic为避免后台隐藏作品打爆 module instance统一 `public_work_*` view 暂不级联抓大鹅 source view抓大鹅公开入口先保留玩法专用路径。后续应以 source projection 表替代索引 view 后再重新并入统一 read model。
-`/api/runtime/<play>/gallery` 响应 shape 保持兼容,由 BFF mapper 把统一 cache 再映射回当前 DTO。
- 旧详情 / runtime / 点赞 / 游玩 / Remix 仍走玩法专用路径。

View File

@@ -0,0 +1,123 @@
# 拼消消玩法模板技术方案
日期:`2026-05-30`
## 总体边界
拼消消使用独立工程域 `puzzle-clear`,不复用拼图运行态规则本体。实现按 DDD 分层:
- `module-puzzle-clear`:纯领域规则,覆盖图案组规划、棋盘、交换、半锁定、消除、补牌、防死局、关卡状态。
- `shared-contracts` / `packages/shared`工作台输入、生成素材、结果页、作品摘要、runtime snapshot 与 action DTO。
- `spacetime-module`session、work profile、runtime run、事件 / 统计、公开 source view。
- `spacetime-client`typed facade 与 row mapper。
- `api-server`Axum 路由、鉴权、入口熔断、生成编排、资产持久化、BFF。
- `platform-image` / OSS / asset object图片生成、切图、上传、换签和失败审计。
- 前端:轻表单、生成页、结果页与 runtime 动画,不承接正式业务真相。
## 资产生成方案
素材目标从“单张超大 atlas 生图”收敛为 4 张素材工作表,再由服务端合成最终 atlas
- image2 调用4 次,每次生成 1 张 `1024x1536` 竖版素材工作表。
- sheet 裁切:每张按 `4 列 x 6 行` 裁切,每个 1x1 单元为 `256x256`
- 最终 atlas服务端把 95 个切片按领域坐标合成 `10x10 / 2560x2560` PNG空单元保留浅色背景。
- 运行态素材:最终写回 `35` 个复合图案组和 `95` 个 1x1 卡牌切片;`sheet-03` 的第 6 行第 4 列为 `FILL` 补位格,只为填满 4x6 工作表,生成后会被服务端丢弃,不进入最终 atlas 或运行态卡牌。
服务端固定布局如下:
| 形状 | 数量 | 单组单元数 | 解锁 |
| --- | ---: | ---: | --- |
| 1x2 | 23 | 2 | 第 1 关 |
| 1x3 | 5 | 3 | 第 1 关 |
| 2x2 | 4 | 4 | 第 1 关 |
| 2x3 | 3 | 6 | 第 1 关 |
流程:
```text
主题词 / 场地底图主题词 / 用户底图 -> 4 张 sheet 坐标规划 -> gpt-image-2 生成素材工作表 -> 按 4x6 裁切 1x1 -> 合成最终 atlas -> atlas 与卡牌切片持久化 -> OSS / asset_object / bind -> session draft 回写
```
中央场地底图的 prompt 来源固定为:若用户填写 `boardBackgroundPrompt`AI 生成底图只读取该字段;若该字段为空,才回退读取 `themePrompt`。用户直接上传底图资产时不再用主题词重写该资产,只执行平台资产持久化与换签。中央场地底图在运行态不是普通棋盘衬底,而是玩家逐渐消除卡牌后露出的主题目标图;生成请求使用与中央棋盘一致的 1:1 正方形尺寸prompt 必须强调探索、揭开全貌、追求完成目标、精致主题主视觉和强主题表现,不写“画面干净”或“适合作为卡牌棋盘底图”。
### 素材工作表风险与切片验证
风险4x6 工作表 prompt 仍需要告诉 provider 编号布局;如果模型把布局理解成 UI 海报、说明图或卡牌模板,可能画出文字、编号、边框、切分线、贴纸外框或重复主体。若 provider 无法严格按布局输出,切片后可能出现跨格、主体贴边、重复图案、文字或图案错位。
验证策略:
- 生图 prompt 明确要求照片式构图 / 绘本式渲染的主题微场景拼图卡,每个 256x256 单元本身就是一张完整的单场景照片裁片,单元内部只能有一个连续画面,禁止出现两张照片、两个不同场景、拼接线、分割线、内部竖切、内部横切、左右 / 上下两块不同背景,场景变化只能发生在 256 单元边界上。
- 同编号连续格表示同一视觉家族,不是随机独立小图;同组格子要共享同一场景锚点、主色和道具语言,像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族。
- 同一张 sheet 内不同编号必须发散成不同视觉概念;以水果为例,应扩展为果园、集市摊位、野餐布、果汁杯、厨房案板、甜品盘、篮筐、玻璃罐、窗边餐桌、花园背景等微场景,禁止同品种主体换角度、换大小或换姿势后重复出现。
- 每个 256x256 小卡切片独立查看时也要有可辨识的背景纹理、桌面、草地、天空、建筑、布料、器皿、叶片、阴影或装饰元素,避免“孤立主体 + 纯色背景”导致运行态难区分。
- 生图 prompt 明确禁止文字、水印、UI、边框标签、切分线、网格线、裁切参考线、纯色背景、白底商品图、孤立主体、同品种重复和同一物体多角度。
- 复合图案组本身不画任何可见分割辅助线,但 prompt 必须说明每个 `1x2``1x3``2x2``2x3` 图案都能被服务端按等大的 1x1 方形单元切分;纵向 `1x2` 按横向切线分成两个 1x1横向 `1x2` 按纵向切线分成两个 1x1其他形状同理。图案组可以在语义上成组但不能把一张大图的照片边界或拼贴边界落在单个 1x1 单元内部。
- 服务端保留 `PuzzleClearPatternGroup` 坐标清单,切片前校验每个 sheet 正式编号出现次数等于领域图案组 `width * height`,并要求同编号区域是完整连续矩形;`FILL` 补位格不参与校验、切片、atlas 合成和运行态。
- 每张 sheet 生成后、正式切片前执行像素级质量门禁:非空格必须达到最低前景占比,空白格前景占比不得超阈值,单格内部明显人工拼贴式分割需要硬失败;内部强边缘检测必须同时满足“贯穿大部分高度或宽度”和“两侧近似低纹理平铺色块”,避免把照片式微场景里的窗框、桌沿、地平线等自然结构误杀。非同组边界前景贴边仅记录为质量提示,不作为硬失败,避免把模型正常铺满主体的图集误杀。
- 每张 sheet 生成最多尝试 4 次除质量门禁失败外VectorEngine 返回 `retryable=true``502``504``429` 或请求超时也应消耗下一次 sheet attempt避免上游 nginx 偶发 502 或单次拼贴式坏图直接把草稿置为 failed。
- 前端拼消消创作 action 的请求等待窗口为 40 分钟,用于覆盖 VectorEngine 单张图偶发 10 分钟以上的慢返回;这只是本地验收稳定性兜底,后续若继续优化体验,应把素材生成迁到后台任务 / 轮询进度链路。
- sheet 多次生成仍未通过硬质量门禁时,生成任务进入 `failed` 并写入错误原因;不得把明显空白格污染或主体缺失的工作表切成正式卡牌资产。
- 首版若当前 provider 无法稳定产出可切 atlas生成任务进入 `failed`,错误写入审计;不得退回前端假素材或绕过平台资产底座。
- 草稿编译和作品发布都必须拒绝缺失 atlas、缺失卡牌切片、空 `assetObjectId` / `imageObjectKey``placeholder` 占位资产;`spacetime-client` 不再为编译请求合成默认 atlas / card assets。
- 技术回退需要用户确认后才能改成更多 sheet、降低切片规格或改为逐图生成当前需求固定为 4 张 `1024x1536` sheet 与最终 `2560x2560` atlas。
## 领域规则
`module-puzzle-clear` 已固定以下规则:
- 关卡配置:单关 `6x6/35`600 秒。
- 图案组配比:`1x2=23``1x3=5``2x2=4``2x3=3`
- 开局随机铺满并保证至少一步可解。
- 补牌按列重力下落;补牌后仍保证至少一步可解。
- 完整图案组消除并清空对应格。
- 半锁定拼接组只由玩家主动交换 / 撞入打散,补牌不破坏。
- 超时失败只作用于当前单关,可重试;完成 35 次消除目标并清空棋盘后整局完成。
## API 命名空间
- `POST /api/creation/puzzle-clear/sessions`
- `GET /api/creation/puzzle-clear/sessions/{sessionId}`
- `POST /api/creation/puzzle-clear/sessions/{sessionId}/actions`
- `GET /api/creation/puzzle-clear/works`
- `GET /api/creation/puzzle-clear/works/{profileId}`
- `POST /api/creation/puzzle-clear/works/{profileId}/publish`
- `GET /api/runtime/puzzle-clear/works/{profileId}`
- `POST /api/runtime/puzzle-clear/runs`
- `POST /api/runtime/puzzle-clear/runs/{runId}/swap`
- `POST /api/runtime/puzzle-clear/runs/{runId}/retry-level`
- `POST /api/runtime/puzzle-clear/runs/{runId}/next-level`
- `POST /api/runtime/puzzle-clear/runs/{runId}/time-up`
api-server 路由熔断使用 SpacetimeDB 创作入口配置 `puzzle-clear`,不得新增前端硬编码事实源。
## Runtime 事件与统计载荷
正式 `published` run 记录开局、全局完成、当前关失败、耗时和消除统计。runtime action 返回的终态事件包括:
- `run-finished`:第 1 关完成并结束整局,结果 JSON 至少包含 `status``level``clears``clearDelta``elapsedMs`
- `level-failed`:当前关超时失败,结果 JSON 至少包含 `status``level``clears``clearDelta``elapsedMs`
草稿试玩只消费同一份 snapshot/action 结果做表现,不写正式统计。
## 前端阶段
新增阶段:
- `puzzle-clear-workspace` -> `/creation/puzzle-clear`
- `puzzle-clear-generating` -> `/creation/puzzle-clear/generating`
- `puzzle-clear-result` -> `/creation/puzzle-clear/result`
- `puzzle-clear-runtime` -> `/runtime/puzzle-clear`
runtime 移动端优先,首屏结构为顶部倒计时 / 单关铭牌、顶部列准备区、棋盘、失败 / 完成弹层。棋盘主网格、半锁定组覆盖层和消除 / 掉落覆盖层统一使用 1.5px 格间距。动画包括开场翻转、局部正确拼合高光、完整消除放大淡出和列补牌延迟下落,不再有下一关切换。消除和补牌动画只能作为当前后端 snapshot 的表现层覆盖;已有场上卡片因重力下沉后的最终格不得被旧消除坐标或掉落覆盖层隐藏,避免出现“下方空位但上方卡片未下落”的视觉假象;新补入卡牌应等完整消除淡出进入尾段后再播放下落反馈。
## 验证计划
- `cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml`
- `cargo test -p api-server puzzle_clear --manifest-path server-rs/Cargo.toml -- --nocapture`
- `cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml puzzle_clear_compile_requires_real_atlas_assets_from_api_server`
- `npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts`
- `npm run test -- src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`
- `npm run test -- src/routing/appPageRoutes.test.ts src/services/publicWorkCode.test.ts`
- `npm run check:encoding`
- `npm run typecheck`
- 接入 SpacetimeDB schema 后:`npm run spacetime:generate``npm run check:spacetime-runtime-access``npm run check:spacetime-schema``npm run check:server-rs-ddd`

View File

@@ -0,0 +1,81 @@
# 敲木鱼音频延迟上传与本地标准化测试用例
## 覆盖目标
- 选择上传或录音结束后只在浏览器本地处理,不请求 OSS 上传凭证。
- 用户点击 `生成` 时才上传处理后的音频 Blob/File并把确认后的 `WoodenFishAudioAsset` 放入创建 session payload。
- 上传和录音统一执行前后声音过小片段裁切、最长 1 秒限制、近似 `-15 LKFS` 响度平衡和峰值保护。
- 音频面板明确显示 `最长 1 秒`,并正确处理上传、录音、重置、禁用和错误状态。
- OSS 上传 client 只接收 Blob/File不接受 Data URL并覆盖上传凭证、OSS POST、资产确认和错误分支。
## 音频处理 helper
- 空文件:`size=0`,报 `音频文件为空,请重新选择。`
- 非音频 MIME`text/plain`,报 `请选择音频文件。`
- 浏览器没有 `AudioContext`:报 `当前浏览器不支持音频处理。`
- `decodeAudioData` 失败:报 `音频解码失败,请重新选择。`
- 全静音或声音全低于阈值:报 `音频声音过小,请重新录制或上传。`
- 前后静音裁切:低于阈值的头尾帧被裁掉,`startFrame``frameCount` 正确。
- 裁切后刚好 `1000ms`:允许通过。
- 裁切后超过 `1000ms`:报 `音频最长 1 秒。`
- 上传来源 `uploaded` 与录音来源 `recorded`:返回 pending asset 保留对应 source。
- 原文件名有扩展名:输出 `.wav` 文件名;无扩展名补 `.wav`;空白文件名输出 `creative-audio.wav`
- `URL.createObjectURL` 存在:`audioSrc` / `previewUrl` 为 blob URL不存在时返回空字符串且不阻断处理。
- 近似响度平衡:低 RMS 样本被拉向 `-15 LKFS` 目标。
- 峰值保护:高峰值样本增益后不超过 `peakCeiling`
- 零能量 section归一化阶段报声音过小。
- WAV 编码:写入 RIFF/WAVE/data header、PCM16 数据长度和采样值。
## 音频输入面板
- 传入 `limitLabel` 时显示 `最长 1 秒`;未传入时不显示限制标签。
- 无资产时显示默认音效文案。
- 有资产且 `audioSrc` 存在时渲染 `<audio controls>`
- 有资产但无 `audioSrc` 时显示 `音效已选择`
- 点击重置调用 `onAssetChange(null)`
- 上传取消选择时不读取音频、不写入资产。
- 上传成功后调用 `readFileAsAsset(file, 'uploaded')`,清空错误并写入资产。
- 上传失败时展示错误,不写入资产。
- 浏览器不支持录音时提示 `当前浏览器不支持录音。`
- 麦克风启动失败时透传启动错误。
- 录音停止后把 Blob 包成 File并以 `recorded` 来源读取。
- 录音保存失败时展示错误。
- disabled 状态不启动录音,文件输入禁用。
## 木鱼工作台链路
- 音频面板显示 `最长 1 秒`,并只保留上传和录音入口。
- 选择上传音频后只调用本地处理 helper不调用 `uploadWoodenFishHitSoundAsset`
- 点击 `生成` 且有 pending 音频时,先上传处理后的 WAV再调用 `woodenFishClient.createSession`
- 上传给 OSS 的文件是处理后的 WAV文件名和 MIME 为 `.wav` / `audio/wav`
- 提交 payload 使用 OSS confirmed asset不包含 `data:audio`
- 未选择音频时不上传 OSSpayload 使用默认木鱼音。
- 处理阶段报超过 1 秒时展示错误,不写入用户音频;继续生成时走默认木鱼音。
- OSS 上传失败时停留工作台,展示错误,不创建 session。
- `createSession` 失败时停留工作台,展示错误。
- 提交中重复点击 `生成` 不重复上传、不重复创建 session。
- 替换本地音频时回收旧 `previewUrl`
## 木鱼音频上传 client
- 空 Blob/File、超过 20MB、非音频 MIME 均在本地拒绝,不创建上传凭证。
- File 上传默认使用 `file.name``file.type`
- Blob 上传支持通过显式文件名扩展推断 `audio/wav` 等音频 MIME。
- Blob 缺少 MIME 且扩展未知时拒绝上传。
- direct upload ticket 请求包含 `legacyPrefix`、path segments、fileName、contentType、access、maxSizeBytes 和木鱼音频 metadata。
- OSS POST 成功后调用 `/api/assets/objects/confirm`
- OSS POST 非 2xx 时提示 `上传敲击音效失败。`,不确认资产对象。
- confirm 失败时透传确认错误。
- confirm 请求包含 bucket、objectKey、contentType、contentLength、assetKind、accessPolicy 和 entityId。
- 成功返回的 `WoodenFishAudioAsset` 包含 assetObjectId、audioObjectKey、audioSrc、source 和 prompt。
## 验证命令
```bash
npm run test -- src/components/common/creativeAudioProcessing.test.ts
npm run test -- src/components/common/CreativeAudioInputPanel.test.tsx
npm run test -- src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx
npm run test -- src/services/wooden-fish/woodenFishAssetClient.test.ts
npm run typecheck
npm run check:encoding
```

View File

@@ -16,13 +16,13 @@ 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.3.0`;本地 `spacetime` CLI / standalone、生成的 `spacetime-client` bindings 和容器压测镜像也必须与 `2.3.0` 对齐,避免 BSATN / procedure result 反序列化错配。
SpacetimeDB 版本口径:当前 Rust crate `spacetimedb``spacetimedb-sdk``spacetimedb-lib` 统一锁定 `2.4.1`;本地 `spacetime` CLI / standalone、生成的 `spacetime-client` bindings 和容器压测镜像也必须与 `2.4.1` 对齐,避免 BSATN / procedure result 反序列化错配。
当前主要 crate
- HTTP 服务:`api-server`
- 领域模块:`module-ai``module-assets``module-auth``module-bark-battle``module-big-fish``module-combat``module-creative-agent``module-custom-world``module-inventory``module-match3d``module-npc``module-progression``module-puzzle``module-quest``module-runtime``module-runtime-item``module-runtime-story``module-square-hole``module-story``module-visual-novel`
- 平台副作用:`platform-agent``platform-auth``platform-image``platform-llm``platform-oss``platform-speech`
- 平台副作用:`platform-agent``platform-auth``platform-image``platform-llm``platform-oss``platform-wechat``platform-speech`
- 共享层:`shared-contracts``shared-kernel``shared-logging`
- SpacetimeDB`spacetime-client``spacetime-module`
- 测试支撑:`tests-support`
@@ -35,6 +35,7 @@ SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`
4. 后端访问 SpacetimeDB 必须经 `spacetime-client` facade。
5. HTTP 鉴权、BFF 聚合、SSE、外部模型编排、OSS 上传和第三方回调在 `api-server`
6. 前端共享 DTO 通过 `shared-contracts``packages/shared` 对齐,不在页面内重新发明旧接口。
7. 微信能力按两层收口:`server-rs/crates/platform-wechat` 承载微信协议 client、订阅消息 `stable_token` / `subscribeMessage.send`、微信支付 V3 / 虚拟支付消息推送的 HTTP header、签名、验签、解密、mock 响应和协议 payload 解析;`server-rs/crates/api-server/src/wechat.rs``wechat/*` 承载 Axum handler、AppConfig 到平台配置的映射、Genarrative 用户 / 订单 / 钱包 / SSE / 错误 envelope 编排。`platform-auth` 当前仍承载微信 OAuth / 小程序登录 provider 协议,`api-server::wechat::provider` 只作为组合根 adapter不在业务 handler 内散落 provider 构造。
验证:
@@ -58,7 +59,7 @@ npm run check:server-rs-ddd
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请、兑换、存档、历史浏览和游玩统计。
- LLM 与语音:`/api/llm/*``/api/speech/volcengine/*`
- 资产:`/api/assets/*`包括直传票据、STS、对象确认、实体绑定、读签名、读 bytes、历史资产、角色图像/动画和 Hyper3D 代理。
- 创作入口配置:`/api/creation-entry/config`后台 `/admin/api/creation-entry/config`
- 创作入口配置:`/api/creation-entry/config`后台 `/admin/api/creation-entry/config``/admin/api/creation-entry/config/banners`
- 自定义世界 / RPG`/api/runtime/custom-world*``/api/story/*``/api/runtime/chat/*`
- 拼图:`/api/runtime/puzzle/*`
- 抓大鹅 Match3D`/api/creation/match3d/*``/api/runtime/match3d/*`
@@ -66,6 +67,7 @@ npm run check:server-rs-ddd
- 方洞挑战:`/api/creation/square-hole/*``/api/runtime/square-hole/*`
- 视觉小说:`/api/creation/visual-novel/*``/api/runtime/visual-novel/*`
- 大鱼吃小鱼:`/api/runtime/big-fish/*`
- 跳一跳:`/api/creation/jump-hop/*``/api/runtime/jump-hop/*`
- 汪汪声浪:`/api/runtime/bark-battle/*`
- 儿童向创作:`/api/creation/edutainment/*`
- AI task`/api/ai/tasks*`
@@ -74,9 +76,10 @@ npm run check:server-rs-ddd
### 认证态用户与会话摘要下发口径
- `AuthUserPayload` / `AuthUser` 只保留前端当前会用到的身份与绑定展示字段:`id``publicUserCode``displayName``avatarUrl``phoneNumberMasked``loginMethod``bindingStatus``wechatBound`
- `AuthUserPayload` / `AuthUser` 只保留前端当前会用到的身份与绑定展示字段:`id``publicUserCode``displayName``avatarUrl``phoneNumber``phoneNumberMasked``loginMethod``bindingStatus``wechatBound``wechatDisplayName``wechatAccount`。账号信息面板展示微信绑定时优先使用 `wechatDisplayName`;该字段只能来自微信平台 profile、历史已保存的微信身份资料或小程序原生 `input type="nickname"` 提交的 `displayName`,不得用系统账号显示名或“微信旅人”这类假昵称兜底。小程序 `/api/auth/wechat/miniprogram-login``/api/auth/wechat/bind-phone` 可接收 `displayName``/api/auth/wechat/miniprogram-login` 额外返回 `created`,供小程序壳在快捷登录后判断是否需要补采集微信昵称。`jscode2session` 无法直接返回微信昵称或个人微信号,只能稳定拿到小程序维度 `openid`,后端以 `wechatAccount` 下发可区分的绑定账号标识,前端在缺少真实昵称时展示账号尾号
- `AuthSessionSummaryPayload` / `AuthSessionSummary` 只保留设备卡片与撤销需要的摘要字段:`sessionId``sessionIds``sessionCount``clientLabel``ipMasked``isCurrent``createdAt``lastSeenAt``expiresAt`
- 设备诊断信息(例如原始 `clientType` / `clientRuntime` / `clientPlatform` / `userAgent` / `miniProgramAppId` / `miniProgramEnv` / `deviceDisplayName`)不再默认下发到前端;若未来确需展示,优先单独加窄 DTO而不是把账号 / 会话快照恢复为全量对象。
- 多端登录语义以 `refresh_session` 为粒度:同一账号可保留多个 active session普通登录不会撤销旧设备`POST /api/auth/logout` 只撤销当前 refresh session不提升 `token_version``POST /api/auth/logout-all`、改密、重置密码才吊销全端 session 并提升 `token_version`。鉴权中间件仍校验 Bearer `sid` 对应的 refresh session 是否 active单独踢下线或当前设备退出可以让目标设备立即失效而不误伤其它设备。
## api-server 模块化演进规则
@@ -104,6 +107,8 @@ npm run check:server-rs-ddd
- `server-rs/crates/api-server/src/puzzle/mappers.rs` 承接 SpacetimeDB record 到 shared-contracts DTO 的映射。
- `server-rs/crates/api-server/src/puzzle/tags.rs` 保留拼图标签生成、拼图通用错误映射和 SSE helper。
拼图发布 / 待发布门槛必须同时要求首图、关卡画面、UI spritesheet 与关卡背景资产包完整;`module-puzzle::validate_publish_requirements``api-server::puzzle::tags::is_puzzle_session_snapshot_publish_ready` 使用同一资产语言,不得只凭 cover、标题、描述和标签把半成品标为 `publishReady``ready_to_publish`
该拆分只改变 `api-server` 文件组织,不改变 `/api/runtime/puzzle/*` route、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义或计费语义;后续继续细分时也必须先保持行为不变,再单独讨论领域规则下沉。
`/api/runtime/puzzle/runs*` 当前接受 `RuntimePrincipal`,可同时识别登录用户 Bearer 和 runtime guest token。推荐页嵌入运行态的正式开局、交换、拖拽、下一关、暂停、道具与排行榜请求应由前端在登录态下继续携带账号 access token匿名游客仅在确认为未登录时走 runtime guest token。不要再把拼图 runtime 当成只认普通 Bearer 的纯账号接口。
@@ -128,9 +133,10 @@ npm run check:server-rs-ddd
3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。
4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。
5. 图片 provider 协议不再放在玩法模块里实现。VectorEngine `gpt-image-2` 创建 / 编辑协议、URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image/src/vector_engine/`;其中 `client.rs` 只保留 provider 调用编排,`transport.rs` 负责 HTTP client 与 reqwest 错误归一,`request.rs` 负责请求体和路径,`payload.rs` 负责响应 JSON 字段提取,`response.rs` 负责响应状态分流和图片结果归一。`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。
6. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口
7. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取
8. 系列素材图集实现真相源在 `server-rs/crates/platform-image/src/generated_asset_sheets/`:调用方必须传入 `grid_size` 作为 `n*n``n`,可选传入物品名称 prompt 模板和特殊设定 prompt模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。`server-rs/crates/api-server/src/generated_asset_sheets.rs` 只保留 `AppState` / `AppError` 适配和兼容导出。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段
6. OSS 平台适配日志统一在 `server-rs/crates/platform-oss` 输出,覆盖 `sign_post_object``sign_get_object_url``head_object``put_object`。日志字段固定使用 `provider``operation``bucket``endpoint``object_key` / `key_prefix``access``content_type``content_length``status``status_class``error_kind``elapsed_ms`,只记录对象定位和排障信息;不得输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。generated 私有对象上传时必须由 OSS 对象头承载浏览器 / CDN 缓存策略,默认写入 `Cache-Control: public, max-age=31536000, immutable`,不得改成 api-server 本地磁盘静态资源兜底
7. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口
8. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取
9. 系列素材图集实现真相源在 `server-rs/crates/platform-image/src/generated_asset_sheets/`:调用方必须传入 `grid_size` 作为 `n*n``n`,可选传入物品名称 prompt 模板和特殊设定 prompt模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。`server-rs/crates/api-server/src/generated_asset_sheets.rs` 只保留 `AppState` / `AppError` 适配和兼容导出。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。
## SpacetimeDB schema 变更规则
@@ -164,17 +170,23 @@ npm run check:server-rs-ddd
7. access JWT 只携带最小设备快照 `device.client_type``device.client_runtime``device.client_platform`。充值下单按该快照拦截渠道:小程序只允许 `wechat_mp`,手机微信内网页只允许 `wechat_h5`,桌面微信内网页只允许 `wechat_native`
8. 所有微信真实渠道都以微信支付通知或服务端查单确认 `SUCCESS` 为到账事实小程序、H5 跳转和 Native 二维码返回都不能直接发放泥点或会员。
## 创作入口泥点扣费契约
1. `creation_entry_type_config.unified_creation_spec_json` 内的 `mudPointCost` 是玩法新建草稿初始生成的泥点成本真相源,同时供入口卡展示和前端余额前置校验使用;旧契约缺失时允许按代码默认成本兜底。
2. `api-server` 执行拼图首图生成、抓大鹅完整草稿生成和汪汪声浪初始三图生成时,必须通过 `GET /api/creation-entry/config` 同源配置解析对应玩法成本后再调用钱包扣费 procedure不得继续使用前端或后端硬编码常量作为实际扣费真相。
3. 结果页单图重生成、发布、道具使用和其它独立资产操作仍按各自业务操作成本执行;不要把初始草稿成本误套到这些单次操作上。
## 外部服务与资产
- LLM`GENARRATIVE_LLM_*`,创意 Agent 另用 `APIMART_BASE_URL` / `APIMART_API_KEY`
- 图片生成VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event``event_key = external_generation_run`metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。APIMart 只保留给创意 Agent `gpt-5` Responses 文本 / 多模态链路DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。
- 图片生成VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event``event_key = external_generation_run`metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。APIMart 只保留给创意 Agent `gpt-5` Responses 文本 / 多模态链路DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations``/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required``request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt``max_attempts``retry_delay_ms``reference_image_bytes_total``request_params`,不要把 `SendRequest` 当成上游业务错误。
- Match3D 物品 sheet关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2``2K 1:1` 输出 `10*10` spritesheet物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。
- Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG背景图必须合成为全画幅不透明 PNG。
- Match3D 1:1 容器 UIVectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!``api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。
- 敲木鱼敲击物和背景环境图VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`。敲击物支持 multipart 多参考图第一张固定为后端内嵌默认木鱼图用户上传图只作为新主题参考prompt 必须要求 `1:1` 真实透明 alpha PNG 并禁止黑底、白底、棋盘格和任何实底背景。当前敲击物上传 OSS 前不做服务端抠图后处理避免误伤玉米等主体像素。背景环境图只使用第一步抠图完成后的透明敲击物图作为参考prompt 必须要求中央主体预留区保持干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围,且必须显式声明不继承任何绿色底色、绿幕底色或纯绿色画布。
- Hyper3D / Rodin只保留后端安全代理和旧数据兼容Rodin 提交、状态、下载和响应解析归属 `platform-hyper3d``api-server/src/hyper3d_generation.rs` 只做路由、配置和错误 envelope 映射;新 Match3D 草稿和批量新增不再生成 GLB。
- 音频视觉小说专用音频路由保留VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio``api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`
- OSS私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`
- 音频视觉小说专用音频路由保留VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio``api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;前端选择或录音阶段只在浏览器本地处理待提交音频,统一限制裁切后最长 1 秒、裁掉前后声音过小片段,并用浏览器端近似响度算法平衡到 `-15 LKFS` 后做峰值保护。点击生成时才直传 OSS 并确认 `asset_object`,创作 JSON 只提交轻量 `WoodenFishAudioAsset`,不得继续上传 Data URL 音频;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`
- OSS私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`前端如果收到同一 OSS bucket 的完整 `https://*.oss-*.aliyuncs.com/generated-*` 地址,也必须先归一为 legacy path 后走同一换签链路,避免裸连私有 bucket 403 或绕过签名缓存。OSS 签名、读签名、HEAD 和 PUT 的结构化日志由 `platform-oss` 输出,排查资产写入 / 确认失败时优先按 `operation``object_key` / `key_prefix``status_class``error_kind``elapsed_ms` 下钻。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`;旧对象若缺该头,只能依赖 `ETag` / `Last-Modified` 协商缓存,应通过 OSS 元数据刷新或 CDN 配置补齐,不要恢复 api-server 静态代理。
- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send``response_body``upstream_status``response_parse``missing_image``image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure``scope_kind = module``scope_id = provider``module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt以及在调用方可获得上下文时补充的 `userId`(触发者)和 `profileId`(草稿 / 作品 / 场景作用域)。图片生成入口应优先把 owner user id 和 profile id 透传到失败审计,不要只保留 provider 级聚合,否则很难按“谁触发、哪个作品触发”定位问题。入库优先复用 tracking outboxoutbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。
- 外部生成运行记录:所有外部生成编排的完成态统一写入 `tracking_event``event_key = external_generation_run``scope_kind = module``scope_id = provider``module_key = external-generation`。metadata 固定包含 `runId``provider``operation``requestLabel``requestPayload``status``success``failureReason``providerRequestId``resultPayload``startedAtMicros``completedAtMicros``durationMs`。这类记录只用于运行审计和排障,不再走 `ai_task` 旧表。
@@ -242,10 +254,12 @@ npm run check:server-rs-ddd
- Rust 结构体:`AuthStoreSnapshot`
- 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs`
认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)投影恢复进程内认证工作集;`auth_store_snapshot` 只保留行级快照备查,不再作为启动兜底来源。`module-auth` 只保留内存工作集和 JSON 导入 / 导出能力,不再写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源,也不得在启动时回写覆盖 `auth_identity` / `user_account`。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 进入依赖不可用模式并对请求返回 `503 SERVICE_UNAVAILABLE`,直到运维恢复 SpacetimeDB 并重启服务。
认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)投影恢复进程内认证工作集;运行中若 Bearer `sid` 或 refresh cookie 在本进程工作集内未命中,会先从 SpacetimeDB 正式认证表按需刷新一次认证工作集再复查,避免多实例或滚动重启时新登录设备只被签发它的进程认识。`auth_store_snapshot` 只保留行级快照备查,不再作为启动兜底来源。`module-auth` 只保留内存工作集和 JSON 导入 / 导出能力,不再写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源,也不得在启动时回写覆盖 `auth_identity` / `user_account`认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 进入依赖不可用模式并对请求返回 `503 SERVICE_UNAVAILABLE`,直到运维恢复 SpacetimeDB 并重启服务。
`auth_store_snapshot` 禁止再写单行 `snapshot_id = "default"` 聚合 JSON。认证同步入口收到 `module-auth` 整份快照后必须拆成行级记录写入同一张表,当前行键前缀包括:`meta/next_user_id``user/<user_id>``phone/<phone+user>``session/<session_id>``session_hash/<hash+session>``wechat/<provider_uid+user>``union/<union+user>`。SpacetimeDB 模块只保留 `import_auth_store_snapshot_json``export_auth_store_snapshot_from_tables` 两个认证快照过程;旧 `get_auth_store_snapshot``upsert_auth_store_snapshot``import_auth_store_snapshot` 兼容入口已删除。导入正式表时只按主键 upsert 本次快照包含的用户、身份和会话,避免过期快照把其他用户整表删除。
导出认证快照时,`auth_identity``refresh_session` 只能引用仍存在于 `user_account` 的用户;孤儿手机号 identity、微信 identity、union 索引或 refresh session 必须被过滤,不能恢复成 `module-auth` 内存态里的 `phone_to_user_id` 死索引。`module-auth` 从 JSON 快照恢复时也要二次清理这些孤儿索引,避免历史坏快照导致密码登录提示错误、短信登录又提示手机号已存在。
### `bark_battle_draft_config`
- Rust 结构体:`BarkBattleDraftConfigRow`
@@ -330,15 +344,15 @@ npm run check:server-rs-ddd
- Rust 结构体:`CreationEntryConfig`
- 源码:`server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`
- 字段:`config_id``start_title``start_description``start_idle_badge``start_busy_badge``modal_title``modal_description``updated_at``event_title``event_description``event_cover_image_src``event_prize_pool_mud_points``event_starts_at_text``event_ends_at_text`
- 迁移兼容:旧迁移包缺少活动横幅字段时,由 `migration.rs` 写入 `None` / `58000` 默认值;运行态读取层再按 `module-runtime` 默认横幅归一,不覆盖后台已保存配置。
- 字段:`config_id``start_title``start_description``start_idle_badge``start_busy_badge``modal_title``modal_description``updated_at``event_title``event_description``event_cover_image_src``event_prize_pool_mud_points``event_starts_at_text``event_ends_at_text``event_banners_json`
- 迁移兼容:旧迁移包缺少活动横幅字段时,由 `migration.rs` 写入 `None` / `58000` 默认值;旧库缺少 `event_banners_json` 时写入 `None`运行态读取层再按 `module-runtime` 默认公告数组归一,不覆盖后台已保存配置,也不把旧结构化 `eventBanner` 升格为前端优先数组。HTTP 响应同时返回 `eventBanners` 数组和旧 `eventBanner` 单条兼容字段,前端优先消费数组;后台新配置主格式为 HTML 公告字符串数组或 `{title, htmlCode}` 对象数组,旧结构化 banner 字段仅保留兼容。默认公告背景和旧结构化默认 `coverImageSrc` 必须引用 `public/` 下真实存在的静态资源,当前为 `/creation-type-references/puzzle.webp`
### `creation_entry_type_config`
- Rust 结构体:`CreationEntryTypeConfig`
- 源码:`server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`
- 字段:`id``title``subtitle``badge``image_src``visible``open``sort_order``updated_at``category_id``category_label``category_sort_order``unified_creation_spec_json`
- 迁移兼容:旧迁移包缺少入口分类字段或统一创作契约字段时,由 `migration.rs` 写入 `None` / `0` / `None` 默认值;入口分组展示由 `module-runtime` 和前端展示派生消费,统一创作契约由 `module-runtime` 解析为 `creationTypes[].unifiedCreationSpec`,为空时只回退首批 `puzzle``match3d``wooden-fish` 默认 spec
- 迁移兼容:旧迁移包缺少入口分类字段或统一创作契约字段时,由 `migration.rs` 写入 `None` / `0` / `None` 默认值;入口分组展示由 `module-runtime` 和前端展示派生消费,统一创作契约由 `module-runtime` 解析为 `creationTypes[].unifiedCreationSpec`,为空时`shared-contracts` 中当前支持的统一创作默认 spec 回退。`unifiedCreationSpec.title` 是统一创作页表头契约内容,读取和保存时不按入口 `title` 自动覆盖
### `custom_world_agent_message`
@@ -373,6 +387,7 @@ npm run check:server-rs-ddd
- Rust 结构体:`CustomWorldProfile`
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。
- 兼容约束:历史公开 RPG / 自定义世界 profile 可能存在 `publication_status=Published``published_at=None`。公开详情、点赞、游玩、Remix 和 `custom_world_gallery_entry` 同步都以 `Published + deleted_at=None + visible=true` 判断作品可公开互动;展示和 gallery 同步时间在 `published_at` 缺失时回退 `updated_at`,不得仅因 `published_at` 为空返回“已发布作品不存在”。
### `custom_world_session`
@@ -404,15 +419,24 @@ npm run check:server-rs-ddd
- Rust 结构体:`JumpHopEventRow`
- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs`
### `jump_hop_leaderboard_entry`
- Rust 结构体:`JumpHopLeaderboardEntryRow`
- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs`
- 说明:跳一跳作品维度排行榜 read model每个 `profile_id + player_id` 只保留 1 条最佳记录;排序口径为成功跳跃次数降序、游戏时长升序、更新时间升序,草稿试玩不作为公开排行榜语义。
- 展示契约:`player_id` 只作为后端去重和 `viewerBest` 匹配身份键,不得直接进入 HTTP/UI 展示字段;`/api/runtime/jump-hop/works/{profile_id}/leaderboard` 必须补齐 `displayName`,已登录玩家读取账号显示名,匿名游客展示“游客玩家”,失效账号展示“失效玩家”。
### `jump_hop_runtime_run`
- Rust 结构体:`JumpHopRuntimeRunRow`
- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs`
- 说明:运行记录持久化 `runtime_mode`,取值为 `draft` / `published`;草稿试玩只允许作品所有者启动,不累计公开游玩次数,也不写入公开排行榜。
### `jump_hop_work_profile`
- Rust 结构体:`JumpHopWorkProfileRow`
- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs`
- 说明:作品投影持久化独立 `theme_text`,用于生成主题和公开卡片主题展示;历史行为空时按 `work_title` 兜底。`back_button_asset_json` 保存 image2 单独生成并去绿后的 1:1 左上角返回按钮资产快照;旧迁移数据按 `None` 兼容,运行态缺失该字段时使用同尺寸 CSS 主题按钮兜底。
### SpacetimeDB view`jump_hop_gallery_card_view`
@@ -621,6 +645,45 @@ npm run check:server-rs-ddd
- Rust 结构体:`PuzzleWorkProfileRow`
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
### `puzzle_clear_agent_session`
- Rust 结构体:`PuzzleClearAgentSessionRow`
- 源码:`server-rs/crates/spacetime-module/src/puzzle_clear/tables.rs`
- 说明:拼消消创作会话表,保存轻表单草稿、生成状态、已发布 profile 关联和更新时间;只由拼消消 procedure 读写。
### `puzzle_clear_work_profile`
- Rust 结构体:`PuzzleClearWorkProfileRow`
- 源码:`server-rs/crates/spacetime-module/src/puzzle_clear/tables.rs`
- 说明:拼消消作品 profile 表保存中央底图资产、4 张素材工作表切片后合成的最终 atlas、35 个复合图案组、95 个 1x1 卡牌切片、卡背占位图、发布状态、可见性和基础 play count公开列表 / 详情只通过 read model 消费,不让前端直接订阅源表。
- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。
### `puzzle_clear_runtime_run`
- Rust 结构体:`PuzzleClearRuntimeRunRow`
- 源码:`server-rs/crates/spacetime-module/src/puzzle_clear/tables.rs`
- 说明:拼消消正式 runtime run 表,保存当前关卡、已消除次数、棋盘 snapshot、开始 / 完成时间和 run 状态;正式胜负、重试、完成、超时和交换结果以后端 procedure 裁决为准。
### `puzzle_clear_event`
- Rust 结构体:`PuzzleClearEventRow`
- 源码:`server-rs/crates/spacetime-module/src/puzzle_clear/tables.rs`
- 说明:拼消消基础 runtime 事件表,记录 published run 的开局、关卡完成、全局完成、失败、超时和消除统计来源;首版不做排行榜。
### SpacetimeDB view`puzzle_clear_gallery_view`
- Rust view`puzzle_clear_gallery_view`
- 返回类型:`Vec<PuzzleClearGalleryViewRow>`
- 源码:`server-rs/crates/spacetime-module/src/puzzle_clear.rs`
- 说明:拼消消公开详情 source 投影,只暴露 `publication_status = published``visible = true` 的作品,包含 atlas、底图、图案组和卡牌切片等详情级字段统一公开详情主路径通过 `public_work_detail_entry` 消费该 view只保留平台详情页展示摘要。
### SpacetimeDB view`puzzle_clear_gallery_card_view`
- Rust view`puzzle_clear_gallery_card_view`
- 返回类型:`Vec<PuzzleClearGalleryCardViewRow>`
- 源码:`server-rs/crates/spacetime-module/src/puzzle_clear.rs`
- 说明:拼消消公开列表 source 投影,只暴露平台卡片需要的公开字段;统一公开列表主路径通过 `public_work_gallery_entry` 消费该 view`/api/runtime/puzzle-clear/gallery` 保留玩法专属 HTTP shape。
### SpacetimeDB view`puzzle_gallery_view`
- Rust view`puzzle_gallery_view`
@@ -650,6 +713,7 @@ npm run check:server-rs-ddd
- `SELECT * FROM public_work_detail_entry`
- `SELECT * FROM bark_battle_gallery_view`
- `SELECT * FROM puzzle_gallery_card_view`
- `SELECT * FROM puzzle_clear_gallery_card_view`
- `SELECT * FROM jump_hop_gallery_card_view`
- `SELECT * FROM wooden_fish_gallery_card_view`
- `SELECT * FROM custom_world_gallery_entry`
@@ -666,6 +730,7 @@ npm run check:server-rs-ddd
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'`
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'`
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'bark-battle'`
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle-clear'`
- `SELECT * FROM creation_entry_config`
- `SELECT * FROM creation_entry_type_config`
- `SELECT * FROM asset_object`
@@ -673,7 +738,7 @@ npm run check:server-rs-ddd
跨玩法公开作品列表 / 详情主读模型是 `public_work_gallery_entry``public_work_detail_entry`。拼图、自定义世界等旧玩法公开列表 HTTP 路由保留原响应 shape由 BFF mapper 从统一 public cache 映射回当前 DTO`*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 继续作为 source view 和兼容缓存。各玩法的个人作品列表、详情、发布、点赞、游玩记录、Remix 和其它需要鉴权或写入副作用的路径继续走 procedure / reducer不要为了公开列表性能把这些 owner-specific 或 mutation 语义混进 public view。
`GET /api/creation-entry/config` 和入口熔断优先从订阅 cache 读取创作入口配置cache 缺失时使用最近一次成功读取的内存快照,再兜底调用 `get_creation_entry_config` procedure 完成空库种子或旧库兼容。
入口配置快照包含 start card、类型弹窗、活动横幅和入口类型列表;入口类型列表新增 `category_id``category_label``category_sort_order` 后,后台 upsert、`shared-contracts``module-runtime``spacetime-client` binding 必须同步,旧迁移 JSON 通过 `migration.rs` 默认值兼容。
入口配置快照包含 start card、类型弹窗、公告位兼容字段和入口类型列表;入口类型列表新增 `category_id``category_label``category_sort_order` 后,后台 upsert、`shared-contracts``module-runtime``spacetime-client` binding 必须同步,旧迁移 JSON 通过 `migration.rs` 默认值兼容。
RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true``open=true`;历史 `custom-world` 路由仍是 RPG 的工程域与运行态源类型。入口熔断把 `/api/runtime/custom-world*``/api/story/*``/api/runtime/chat/*` 统一映射到 `rpg`,不要新增平行 `airp` 路由或用 `airp` 接管当前文字冒险链路。

View File

@@ -1,6 +1,6 @@
# 本地开发验证与生产运维
# 本地开发验证与生产运维
更新时间:`2026-05-15`
更新时间:`2026-06-05`
## 标准开发流程
@@ -47,10 +47,16 @@ npm run dev:api-server
Linux 本机多用户并发开发时,`npm run dev``npm run dev:*` 单模块命令会先在系统级端口段注册表里给当前用户分配一个端口段,再把该段映射为 `web = start``api = start + 1``spacetime = start + 2``admin-web = start + 3`。默认注册表目录是 `/var/tmp/genarrative-dev-port-ranges/`,其中 `registry.json` 记录各用户的活跃段,`registry.lock` 负责串行化分配;可以用 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。系统自动分配时从 `10000-10099` 开始,每次占用 100 个端口块,后续块按 `10100-10199``10200-10299` 递增;`GENARRATIVE_DEV_PORT_RANGE``--port-range` 只在 Linux 上生效Windows 仍按原来的 3000 / 8082 / 3101 / 3102 端口探测与漂移逻辑运行,不读这个系统级注册表。
后端日志默认写入 `logs/api-server/`。后端 API smoke 使用 `npm run dev:api-server` 并检查 `/healthz`;不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。
后端日志默认写入 `logs/api-server/`。后端 API smoke 使用 `npm run dev:api-server` 并检查 `/healthz`需要确认实例可接生产流量时检查 `/readyz`不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。
开发态 `npm run dev``npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。
本地只做账号/UI smoke 且需要短信登录时,`SMS_AUTH_PROVIDER` 应显式设为 `mock`,并把 `SMS_AUTH_MOCK_VERIFY_CODE` 设为固定值(当前常用 `123456`),再重启 `npm run dev``npm run dev:api-server`。如果 `.env.local` 还保留 `SMS_AUTH_PROVIDER=aliyun``POST /api/auth/phone/login` 用 mock 验证码会稳定报“验证码错误”,不是前端表单问题。真实短信联调再切回 `aliyun` 并重启。
微信小程序虚拟支付使用 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID``WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY``WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY``WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV` 配置。小程序充值统一走 `wechat_mp_virtual` / `wx.requestVirtualPayment`:泥点属于代币(`coin``buyQuantity` 按当前充值商品快照里的 `points_amount` 传;会员和后台新增道具类商品走 `short_series_goods``productId` 对应微信后台道具 ID。旧登录快照若缺 `session_key`,需要用户在小程序内重新登录后再支付;客户端成功回调不是最终到账,仍以后端通知或查询确认订单为准。详细口径见 `docs/【技术方案】微信虚拟支付接入-2026-05-26.md`
微信小程序订阅消息生成结果通知使用 `WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED``WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID``WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 配置。当前模板为 `AI创作生成结果通知`H5 在生成动作发起前先进入生成进度态并立即继续生成动作,同时非阻塞跳转到小程序原生订阅授权页尝试请求授权,用户接受、拒绝或返回都不能阻塞生成,且原生页不改写上一页 `webViewUrl`,避免返回后丢失 H5 当前进度页状态。后端只在玩法草稿生成成功或失败终态后用微信登录保存的 openid 调用 `subscribeMessage.send`,发送失败只打 warning不影响生成主链路。模板 `thing1` 字段发送玩法模板名,例如 `拼图``敲木鱼``抓大鹅``number6` 字段发送本次生成结算后的实际泥点扣除,失败退款后固定为 `0`。模板 `time4` 字段固定发送北京时间 `YYYY-MM-DD HH:mm`,不要使用内部微秒时间戳、秒级时间戳或带时区后缀的 RFC3339 字符串,否则微信会返回 `argument invalid! data.time4.value invalid`。当前已接入拼图、敲木鱼、抓大鹅、跳一跳、方洞、视觉小说的草稿生成终态;分槽素材生成或发布动作不得直接复用生成结果通知,避免一次作品生成产生多条订阅消息。
如果本地 `GET /api/creation-entry/config` 返回 `No such procedure`,或 `api-server` 日志出现 `no such table: puzzle_gallery_card_view` / `no such table: wooden_fish_gallery_card_view` 这类公开 view 缺失,通常是 `.env.local` 指向的 SpacetimeDB 库还没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库。debug 构建的 `api-server` 会临时使用后端默认入口配置兜底,避免创作作品架整块消失;正式修复仍应切换到拥有目标库权限的 SpacetimeDB 身份后重新运行 `npm run dev` 完成发布,或用 gitignored 的 `spacetime.local.json` 指向可发布的本地库。
本地排查 schema 漂移时,先用当前 dev server 显式查询目标库,例如:
@@ -63,9 +69,13 @@ spacetime sql <database> "SELECT * FROM puzzle_gallery_card_view LIMIT 1" --serv
本地 `npm run dev:spacetime` 发布模块时必须显式忽略仓库根目录的 `spacetime.json`,由脚本固定追加 `--no-config` 并使用命令参数里传入的数据库名和 `--server http://127.0.0.1:3101`。否则 CLI 可能把发布目标改写到配置文件里的其他数据库,导致 `dev:spacetime` 启动后又因发布失败自动退出,浏览器随后会在 `ws://127.0.0.1:3101/v1/database/.../subscribe` 看到连接拒绝。
本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.3.0`。若版本错配procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`api-server 最终表现为敲木鱼等创作动作的 `SpacetimeDB procedure 调用超时`。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml``spacetimedb = "..."`;需要切版本时执行 `spacetime version install <version> && spacetime version use <version>`,然后重新启动 `npm run dev:spacetime`。当前 `scripts/dev.mjs` 会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免把旧 standalone 继续带进新一轮创作。
本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.4.1`。若版本错配procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`api-server 最终表现为敲木鱼等创作动作的 `SpacetimeDB procedure 调用超时`。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml``spacetimedb = "..."`;需要切版本时执行 `spacetime version install <version> && spacetime version use <version>`,然后重新启动 `npm run dev:spacetime`。当前 `scripts/dev.mjs` 会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免把旧 standalone 继续带进新一轮创作。
本地 `.env``.env.local``.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY``VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image``api-server` 只做配置、玩法编排、OSS / asset 持久化、计费和失败审计落库。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若开局 CG 故事板`send()` 阶段失败且日志显示 `SendRequest`,先看同一 request_id 的 `request_body_bytes``reference_data_url_bytes``sourceChain``rootSource`;当前开局 CG 会把角色图与首幕背景图压到单边 768 的 JPEG 后再作为 generations `image` 数组发送,`/v1/images/generations` 使用默认 HTTP 协商,只有 multipart `/v1/images/edits` 单独强制 HTTP/1.1
本地 `.env``.env.local``.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY``VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image``api-server` 只做配置、玩法编排、OSS / asset 持久化、计费和失败审计落库。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若 VectorEngine `send()` 阶段失败且日志显示 `SendRequest`,先看同一 `request_id`provider 日志字段 `source``source_chain``source_chain_depth`,再查 `external_api_call_failure.metadata_json.errorSource`;当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。拼图关卡资产按 `level_scene -> ui_spritesheet -> level_background` 顺序生成,日志会带 `slot``asset_kind``elapsed_ms`
VectorEngine 图片生成 / 编辑在 `request_send` 阶段出现 `timeout``connect` 错误时,`platform-image` 会对同一请求最多发送 3 次multipart 图片编辑每次重试都会重新构造 form避免复用已消费的 body。日志中 `VectorEngine 图片请求发送失败,准备重试` 表示本次失败已进入下一次尝试;最终仍失败时才会写入 `external_api_call_failure` 并返回 504。排查生产失败时应同时统计 retry 前的尝试日志和最终 audit避免把一次用户请求内的多次发送误判成多个用户请求。
拼图入口直创的 `compile_puzzle_draft` 是长耗时链路:后端会先快速编译草稿并返回 `image_refining` / `generating` 快照,然后在 api-server 后台任务中完成首图、UI 资产、OSS 持久化、作品投影、计费退款和失败态回写。生产排查小程序 `Failed to fetch` 时,若 Nginx access log 里 action POST 是 `499``upstream_status=-`,说明客户端或 WebView 先断开;此时不应再把长 POST 是否返回作为生成成败依据,而应继续按实际 `session_id` 查后台任务日志、VectorEngine provider 日志、`external_api_call_failure` 和后续 GET 轮询结果。同一用户可能先轮询旧的 `puzzle-session-*`,随后 POST 新建实际生成 session必须用 action POST 的 `request_id``/api/runtime/puzzle/agent/sessions/<session_id>/actions` 路径对齐真实失败请求,避免被前端显示的“来源草稿”误导。
查看本地 Rust / SpacetimeDB 日志:
@@ -236,27 +246,33 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
`Genarrative-Web-Build` 的主站构建失败若出现 Rollup 报错 `"xxx" is not exported by "src/services/publicWorkCode.ts"`,优先按前端公开作品号工具缺失处理,而不是排查 Jenkins 节点环境。修复时要让 `publicWorkCode.ts``build<Play>PublicWorkCode``isSame<Play>PublicWorkCode` 成对导出,并补 `src/services/publicWorkCode.test.ts` 覆盖对应玩法前缀;随后用 `npm run build:production-release -- --component web --name <临时名>` 复现 Jenkins web 构建路径。
生产 Jenkins 的 `Pipeline script from SCM` 由 Windows controller 读取 JenkinsfileSCM URL 继续使用 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。运行在 `linux && genarrative-build` 构建机上的 `Genarrative-Full-Build-And-Deploy` 源码解析阶段和 `Genarrative-Web-Build` checkout 阶段,优先使用 `http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后回退到 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`;两层 checkout 都必须保留单分支 refspec、`shallow=true``depth=1``noTags=true``honorRefspec=true`,后续二次源码确认继续走 `scripts/jenkins-checkout-source.sh`
`Genarrative-Web-Build` 会把 `build/<version>/web.tar.gz``web.tar.gz.sha256``release-manifest.json` 直接归档为 Jenkins 构建产物;`Genarrative-Web-Deploy` 只通过 `copyArtifacts` 从指定上游构建复制这些产物,再执行 `scripts/deploy/production-web-deploy.sh`。Web 发布不再读取构建机本地缓存目录,也不再通过 release agent `rsync` 回构建机拉取大包;如果 deploy 找不到 `web.tar.gz`,应先检查上游 Web Build 是否按同一 `BUILD_VERSION` 成功归档产物
`Genarrative-Web-Build` 打包 `web.tar.gz` 前、`Genarrative-Web-Deploy` 解包后都会把 Web 静态目录规范为目录 `755`、文件 `644`。如果前端页面能打开但 public 图片、字体或音频返回 `403 Forbidden`,优先检查当前 `/srv/genarrative/web` 指向的 release 中对应文件权限是否被异常归档为 `600`,临时恢复可对该 release 的 `web` 目录执行目录 `755`、文件 `644` 的权限修正。
生产 Jenkins 的 `Pipeline script from SCM` 由 Jenkins controller 读取 Jenkinsfile。`Genarrative-Server-Provision` 是服务器初始化流水线Job 配置里的 SCM URL 必须使用 controller 本机可访问的仓库路径或内网 Gitea 地址,不能使用 `https://git.genarrative.world/...`;否则日志一开始的 `Checking out git ... to read jenkins/Jenkinsfile.production-server-provision` 就会先从公网拉 Jenkinsfile。其它构建 / 发布流水线仍按各自 Jenkinsfile 的 checkout 口径执行;所有 `GitSCM checkout` 都必须保留单分支 refspec、`shallow=true``depth=1``noTags=true``honorRefspec=true`
`scripts/jenkins-checkout-source.sh` 是生产 Jenkinsfile 内部二次确认源码的统一入口。构建和发布流水线传入 `COMMIT_HASH` 时,脚本必须先保持 `depth=1` 浅拉,若上游 commit 已在浅历史内则直接校验并 checkout只有浅历史无法证明 commit 属于目标分支时,才按 `GENARRATIVE_JENKINS_CHECKOUT_DEEPEN_STEPS`(默认 `50 200 1000 5000`)逐步加深,最后才尝试展开完整历史。`Genarrative-Api-Deploy``Genarrative-Web-Deploy``Genarrative-Stdb-Module-Publish` 都必须保留上游构建传入的 `COMMIT_HASH`,不得为了缩短 checkout 时间改为空值或改用目标分支最新提交。
`Genarrative-Stdb-Module-Publish``Pipeline script from SCM` 阶段如果一开始就报 `No such DSL method 'pipeline'`,优先检查 `jenkins/Jenkinsfile.production-stdb-module-publish` 是否带 UTF-8 BOM。Jenkins Declarative Pipeline 的首个 token 必须是纯 `pipeline`;仓库中的 Jenkinsfile 应保存为 UTF-8 without BOM只有临时写给 Windows PowerShell 5.1 `-File` 执行的 `.ps1` 才需要按对应 helper 转成带 BOM。验证时可检查文件前三字节不再是 `EF BB BF`,并运行 `validateDeclarativePipeline` 或重放该流水线。
`Genarrative-Stdb-Module-Build` 或 SpacetimeDB module 构建失败若出现 Rust `E0425 cannot find function migrate_*`,优先排查 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` 等同文件内默认种子迁移 helper 是否在分支合并时只保留了调用、漏掉了函数定义。修复时不要直接删除迁移调用;应恢复只纠偏历史默认种子且不覆盖后台手动配置的 helper并用 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 复现 Jenkins module 编译路径。
`Genarrative-Stdb-Module-Build` 或 SpacetimeDB module 构建失败若出现 Rust `E0425 cannot find function migrate_*`,优先排查 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` 等同文件内默认种子迁移 helper 是否在分支合并时只保留了调用、漏掉了函数定义。`Genarrative-Stdb-Module-Build` 现在运行在 `linux && genarrative-build` 节点上Checkout 与 Build 都走 bash + cargo + sccache不再依赖 Windows PowerShell 或 Git Bash。修复时不要直接删除迁移调用;应恢复只纠偏历史默认种子且不覆盖后台手动配置的 helper并用 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 复现 Jenkins module 编译路径。
Windows Stdb module 构建流水线运行在 Jenkins `windows` 节点上。该流水线需要执行 PowerShell 逻辑时,统一通过 `bat` 显式调`%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe`,不要直接使用 Jenkins `powershell` step本地 Jenkins durable-task 曾在 `Genarrative-Stdb-Module-Build` workspace 中启动裸 `powershell` 时触发 `CreateProcess error=5, 拒绝访问`。临时 `.ps1` 由 Jenkins `writeFile` 写出后要先转成 UTF-8 with BOM 再交给 Windows PowerShell 5.1 `-File` 解析,避免中文错误消息在无 BOM UTF-8 下被当成本地 ANSI 误解码。Checkout 阶段要优先复用 Jenkins GitSCM 已经完成的结果:`COMMIT_HASH` 为空或与当前 `HEAD` 一致时,不要再额外 `git clean` / `git checkout`,只在确实需要切到别的指定 commit 时才补 fetch、校验和切换。排查时先看对应 build log、`@tmp/durable-*` 下的 `powershellWrapper.ps1`,以及日志中的 `[jenkins-powershell] user/exe`
`Genarrative-Server-Provision` 只做服务器初始化,不再承担构建职责。流水线全程运行在目标服务器 agent`DEPLOY_TARGET=development` 使`linux && genarrative-dev-deploy``DEPLOY_TARGET=release` 使用 `linux && genarrative-release-deploy``Prepare Provision Tools` 也在同一个目标 agent 工作区内准备 SpacetimeDB 与 `otelcol-contrib` 交付件,不再切到 `linux && genarrative-build`,也不再 stash 给后续阶段。`SOURCE_GIT_REMOTE_URL` 必须显式填写为目标 agent 可访问的本机路径、`file:///` 地址、localhost / 127.0.0.1、RFC1918 内网 HTTP Git 地址、单标签内网主机名或 `.local` / `.lan` / `.internal` 地址;这条流水线不配置公网 Git 备用地址,目标 agent 拉不到内网源就应直接失败。真实初始化会写入 `/etc` / systemd / Nginx、创建系统用户并修改服务目标 dev / release agent 非 dry-run 时都必须具备 root 权限
生产环境变量模板:`deploy/env/api-server.env.example`。真实密钥只放服务器,不提交 Git不写入文档示例。
`Genarrative-Server-Provision` 会安装基础构建依赖、systemd 模板和 Nginx 站点模板。Ubuntu / apt 目标机会额外安装 `libnginx-mod-http-brotli-filter``libnginx-mod-http-brotli-static`,随后由 `scripts/jenkins-server-provision.sh` 通过临时 `nginx -t` 配置探测 Brotli 指令是否可用;该临时配置必须先 `include /etc/nginx/modules-enabled/*.conf`,因为 apt 安装的 Brotli 是动态模块,不会出现在普通 `nginx -V` 编译参数里。探测成功才在渲染后的 `deploy/nginx/genarrative.conf` / `genarrative-dev-http.conf` 中启用 Brotli避免未安装模块的机器直接写入无效配置。Provision 写入 Genarrative Nginx 站点时会把 `/etc/nginx/sites-enabled/default*` 移到 `/etc/nginx/sites-disabled/`,避免 Debian / Certbot 默认站点继续占用 `genarrative.world` / `www.genarrative.world` 并在 `nginx -T` 中出现 `conflicting server name ... ignored`。如果 `nginx -t` 失败,脚本会恢复写入前的 Genarrative 配置和被移动的默认站点。
`Genarrative-Server-Provision` 会安装 systemd 模板和 Nginx 站点模板,不再安装 clang / lld / pkg-config / OpenSSL headers / sccache 等通用构建链依赖。因 VectorEngine 图片上游 POST 已改用 `libcurl`,当前 Linux release 构建出的 `api-server` 运行时需要 `OPENSSL_3.2.0` 符号Ubuntu 24.04 apt 默认只提供 OpenSSL 3.0.x不能直接满足该符号版本。Provision 会把 OpenSSL `3.2.0` 独立安装到 `/opt/genarrative/openssl-3.2.0`,校验官方 tarball SHA256并只通过 `genarrative-api.service``LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib` 让 api-server 使用,避免替换系统 OpenSSL 或影响 ssh / nginx / apt。Ubuntu / apt 目标机会额外安装 `libnginx-mod-http-brotli-filter``libnginx-mod-http-brotli-static`,随后由 `scripts/jenkins-server-provision.sh` 通过临时 `nginx -t` 配置探测 Brotli 指令是否可用;该临时配置必须先 `include /etc/nginx/modules-enabled/*.conf`,因为 apt 安装的 Brotli 是动态模块,不会出现在普通 `nginx -V` 编译参数里。探测成功才在渲染后的 `deploy/nginx/genarrative.conf` / `genarrative-dev-http.conf` 中启用 Brotli避免未安装模块的机器直接写入无效配置。Provision 写入 Genarrative Nginx 站点时会把 `/etc/nginx/sites-enabled/default*` 移到 `/etc/nginx/sites-disabled/`,避免 Debian / Certbot 默认站点继续占用 `genarrative.world` / `www.genarrative.world` 并在 `nginx -T` 中出现 `conflicting server name ... ignored`。如果 `nginx -t` 失败,脚本会恢复写入前的 Genarrative 配置和被移动的默认站点。
50 HTTP req/s 首版压测优化口径:
- `api-server` 生产模板默认 `GENARRATIVE_API_LISTEN_BACKLOG=1024``GENARRATIVE_API_WORKER_THREADS=4`;本地未设置 worker threads 时继续使用 Tokio 默认值。
- `GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512` 开启应用内 HTTP 并发背压;`GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320``GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64``GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS=16` 分别限制公开列表、公开详情和后台 API 热路径。超过许可时直接返回 `429 Too Many Requests``Retry-After: 1``/healthz` 不受该限制。这些值不是 RPS 限速;如果压测中 429 上升但内存和 p95 收敛,说明背压正在保护进程。直连 `api-server` 的极高 RPS 压测若出现 `connection refused`,通常已经打到 TCP 监听 / accept 层,应同时检查 backlog、Nginx upstream keepalive 和前置限流。
- `genarrative-api.service` 设置 `LimitNOFILE=65535``TasksMax=2048`;上线后用 `systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax``cat /proc/$(pidof api-server)/limits` 核对
- Server provision 不在目标机联网下载 SpacetimeDB 或 `otelcol-contrib``Genarrative-Server-Provision` 先在 Windows Jenkins 节点执行 `Download Provision Tool Archives`,把 `spacetime-x86_64-unknown-linux-gnu.tar.gz``otelcol-contrib_0.151.0_linux_amd64.tar.gz` 先下载到工作区,再通过 `stash/unstash` 带到 `genarrative-build-01`Windows 下载前会先查 GitHub release asset 的 `digest` 字段做 SHA256 校验,已有本地文件且 digest 一致就直接复用,不再重复下载。目标 Linux 节点上的 `scripts/prepare-server-provision-tools.sh` 只消费这些本地下载件生成 `provision-tools/`,再交给 `scripts/jenkins-server-provision.sh` 安装 `/stdb/spacetime``/stdb/bin/current/*``/usr/local/bin/otelcol-contrib`。Windows 侧的 `runWindowsPowerShell(...)` 现在会先 `writeFile` 生成 UTF-8 `.ps1`,再直接把脚本文本读入内存并通过 `ScriptBlock::Create(...)` 执行,避免对同一个 workspace 脚本做原地 BOM 重写。排查时除了看下载日志,还要看 `[jenkins-powershell] workspace:``[jenkins-powershell] script:``[jenkins-powershell] loaded bytes:`。注意 `scripts/jenkins-checkout-source.sh` 会执行 `git reset --hard` / `git clean`,因此被直接执行的新增脚本必须以 Git `100755` 模式提交,或在二次 checkout 之后再补 `chmod +x`
- Windows 下载阶段如果出现 `curl: (18)` 或响应体截断,流水线会保留同名 `.download` 临时文件并用 `curl -C -` 断点续传;只有完整返回但 SHA256 digest 仍不匹配时才删除临时文件后重新下载。目标 Linux 节点仍只接收 `stash/unstash` 带过去的本地下载件,不回退外网下载
- Windows 下载阶段如果走代理,在 `Genarrative-Server-Provision` 参数 `PROVISION_DOWNLOAD_PROXY` 填写 Windows Jenkins 节点可访问的 HTTP 代理,例如 `http://127.0.0.1:7890`;不要填写目标 release 机器视角的 `127.0.0.1`,除非代理确实运行在该 Windows 节点本机。Linux 目标机阶段会强制要求使用本地下载件,缺少文件直接失败,不再回退到外网下载
- `GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512` 开启应用内 HTTP 并发背压;`GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320``GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64``GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS=16` 分别限制公开列表、公开详情和后台 API 热路径。超过许可时直接返回 `429 Too Many Requests``Retry-After: 1``/healthz``/readyz` 不受该限制。这些值不是 RPS 限速;如果压测中 429 上升但内存和 p95 收敛,说明背压正在保护进程。直连 `api-server` 的极高 RPS 压测若出现 `connection refused`,通常已经打到 TCP 监听 / accept 层,应同时检查 backlog、Nginx upstream keepalive 和前置限流。
- `api-server` 正常运行时 `/healthz` 返回进程存活状态,`/readyz` 返回是否仍接收新流量;收到 `SIGINT` / `SIGTERM` 后会先把 readiness 标记为不可用,再让 Axum 停止接新连接并等待已有 HTTP 请求排空。systemd 仍以 `KillSignal=SIGINT` 停服务,`TimeoutStopSec=90` 作为长请求排空上限
- `genarrative-api.service` 设置 `LimitNOFILE=65535``TasksMax=2048`;上线后用 `systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax -p TimeoutStopUSec``cat /proc/$(pidof api-server)/limits` 核对
- Server provision 不再通过 Windows helper 下载,也不再通过 Linux build 节点中转工具包。`Prepare Provision Tools` 在目标 dev / release agent 工作区内准备 SpacetimeDB `2.4.1``spacetime-x86_64-unknown-linux-gnu.tar.gz``otelcol-contrib_0.151.0_linux_amd64.tar.gz` 并生成 `provision-tools/`;如果目标服务器下载需要代理,在 `PROVISION_DOWNLOAD_PROXY` 配置目标机可访问的 HTTP 代理
- `Genarrative-Server-Provision` 外,`Genarrative-Stdb-Module-Build``Genarrative-Web-Build``Genarrative-Api-Build``Genarrative-*Deploy``Genarrative-Database-Import/Export``Genarrative-Full-Build-And-Deploy``Genarrative-Notify-Email` 的生产流水线现都以 Linux agent 为主,仍按各自 Jenkinsfile 的 checkout 口径执行。Server provision 不使用公网备用 Git 源
- `otelcol-contrib.service` 作为可选系统服务加入 provision默认监听 `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制,服务 unit 见 `deploy/systemd/otelcol-contrib.service`
- Nginx `/api/``/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`upstream keepalive 为 64`limit_conn` 负责连接 / 并发保护,`limit_req` 负责入口 RPS 快拒绝。当前模板把公开 gallery list 单独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s``burst=4096``limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,后台 API 放到 `genarrative_admin_rps`。通用 `/api` location 设置 `client_max_body_size 64m` 是反代兜底,防止拼图入口页 / 新增关卡本地参考图 Data URL 或旧兼容请求在到达 `api-server` 前被默认 1 MiB 上限拦截;拼图本地参考图前后端统一限制 6MB历史图片仍提交 `referenceImageAssetObjectId(s)`。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000``upstream_status=-`,说明请求在 Nginx 层被拦截,先用 `nginx -T | grep client_max_body_size` 检查 release 模板是否已渲染并 reload同时检查前端是否超出 6MB 或错误提交了未压缩大图。`limit_conn_status 429``limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效;若线上压测看到 `limiting connections by zone "genarrative_api_conn"` 却返回 503优先检查 `nginx -T` 里 HTTPS server 是否缺少这些状态码,以及 `/api/runtime/puzzle/gallery` 是否误落到通用 `location ~ ^/api``limit_conn=64`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time``upstream_connect_time``upstream_header_time``upstream_response_time``upstream_status``request_id`
- 作品列表 K6 脚本一次 iteration 默认请求两个公开接口,因此约 50 HTTP req/s 的目标命令使用 `SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works`
@@ -274,7 +290,7 @@ npm run container:k6
npm run container:down
```
容器方案默认暴露 `http://127.0.0.1:18080``api-server` 在容器内监听 `0.0.0.0:8082`Nginx 通过 `api-server:8082` upstream 反代 `/api/``/admin/api/`。SpacetimeDB 也纳入 compose容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧则通过 Windows Jenkins 下载件在目标 Linux 节点生成 `provision-tools/otelcol-contrib`安装本机 `otelcol-contrib.service`真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。完整拓扑、端口、k6 参数和 OTLP debug exporter 使用方法见 `deploy/container/README.md`
容器方案默认暴露 `http://127.0.0.1:18080``api-server` 在容器内监听 `0.0.0.0:8082`Nginx 通过 `api-server:8082` upstream 反代 `/api/``/admin/api/`。SpacetimeDB 也纳入 compose容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧现在由目标 dev / release agent 自己准备 `provision-tools/otelcol-contrib`安装本机 `otelcol-contrib.service`真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。完整拓扑、端口、k6 参数和 OTLP debug exporter 使用方法见 `deploy/container/README.md`
`npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`
OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs但本地日志与 Nginx 文件日志仍保留:
@@ -287,7 +303,8 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs但本地日
- debug exporter / Rider 转发都会同时接收 traces、metrics 和 logs。
- api-server 会随 metrics 发送进程级指标:`process.memory.usage``process.memory.virtual``process.cpu.time``genarrative.process.cpu.usage_percent``process.thread.count``genarrative.process.memory.private`Windows 额外发送 `process.windows.handle.count`Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。
- HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight``genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。
- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出低基数字段结构化日志,字段包括 provider、endpoint、failure_stage、status、status_class、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model 和 raw_excerpt`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event``event_key = external_api_call_failure``module_key = external-api``scope_kind = module``scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id``metadata_json.userId` / `metadata_json.profileId` 中记录触发者草稿 / 作品作用域。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId最后结合 request 日志和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。
- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出结构化日志字段,字段包括 provider、endpoint、failure_stage、status、source、source_chain、source_chain_depth、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model、request_params 和 raw_excerpt图片编辑请求参数日志还会带 reference_image_bytes_total并在 request_params.referenceImages 中记录每个 multipart `image` part 的 fileName、mimeType 和 bytes不记录 API key 或原始图片 bytes`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event``event_key = external_api_call_failure``module_key = external-api``scope_kind = module``scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id``metadata_json.userId` / `metadata_json.profileId` / `metadata_json.requestId` / `metadata_json.errorSource` 中记录触发者草稿 / 作品作用域、请求标识和传输错误链。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId最后结合 request 日志、errorSource 和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。
- OSS 平台适配器也输出结构化日志,覆盖 `sign_post_object``sign_get_object_url``head_object``put_object`。排查资产签名、上传或确认失败时,先按 `provider=aliyun-oss``operation` 过滤,再看 `object_key` / `key_prefix``status``status_class``error_kind``content_length``content_type``elapsed_ms`;日志不得包含 AccessKey、policy、signature、Authorization header 或完整 signed URL。排查 generated 图片重复下载时,先确认前端输入是否为 `/generated-*` legacy path 或可归一化的 `https://*.oss-*.aliyuncs.com/generated-*`;正确链路应先调 `/api/assets/read-url`,再由浏览器请求 signed URL且同一路径、同一 `refreshKey` 版本和未临近过期的 signed URL 应复用。新上传 generated 私有对象应带 `Cache-Control: public, max-age=31536000, immutable`;旧对象若只有 `ETag` / `Last-Modified`,浏览器会走 304 协商缓存而不是长期强缓存,可通过刷新 OSS 元数据或 CDN 配置补齐。
- SpacetimeDB 观测分为两类procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*``read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。
- 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。
- Rider 的 Logs 面板只展示 log event 自身字段,不会自动展开父 span 的全部 attributes请求完成日志会直接带 `request_id``http.request.method``http.route``url.scheme``url.path``http.response.status_code``status_class``latency_ms``slow_request`,完整链路继续到 Traces 面板按 trace/span 查看。
@@ -344,9 +361,9 @@ cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms
- `profile_task_reward_claim`
- `profile_wallet_ledger`
个人任务首版 scope 仅支持 `user`。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`
个人任务首版 scope 仅支持 `user`每日登录任务按北京时间自然日 0 点重置;用户已登录并停留在“我的”页跨日时,前端需要先非阻断调用 refresh session 以写入新业务日 `daily_login`,再请求 `/api/profile/tasks` 刷新任务中心。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`
外部 API 失败审计复用 `tracking_event`,不新增表。失败事件优先写入本机 tracking outbox再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,会回退同步直写 SpacetimeDB。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt、userIdprofileId其中 `userId` 是触发生成的用户,`profileId` 是调用方传入的草稿 / 作品 / 场景作用域,入口拿不到上下文时允许为空。常用查询:
外部 API 失败审计复用 `tracking_event`,不新增表。失败事件优先写入本机 tracking outbox再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,会回退同步直写 SpacetimeDB。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、errorSource、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt、userIdprofileId 和 requestId;其中 `userId` 是触发生成的用户,`profileId` 是调用方传入的草稿 / 作品 / 场景作用域,`requestId` 用于回查同一次 HTTP 请求日志,入口拿不到上下文时允许为空。常用查询:
```sql
SELECT event_id, scope_id AS provider, metadata_json, occurred_at
@@ -373,7 +390,7 @@ ORDER BY failures DESC, last_seen DESC
LIMIT 100;
```
VectorEngine `request_send``timeout = true` 的记录表示 `reqwest::Error::is_timeout()` 判定为超时,常见于连接、发送请求体、等待上游首包或上游长时间无响应;`errorSource = client error (SendRequest)` Hyper 发送请求阶段的错误来源标签,不等于最终根因。若 `statusCode` 为空,应优先查同一时间窗口`api-server` request 日志、Nginx / 出口网络、VectorEngine 可用性和请求体大小;若已有 `502``429 moderation_blocked` 等状态码,则按上游网关或内容审核失败单独处理,不要和传输超时混为一类。
VectorEngine `request_send``timeout = true` 的记录表示 `reqwest::Error::is_timeout()` 判定为超时,常见于连接、发送请求体、等待上游首包或上游长时间无响应;`errorSource` 会保存 reqwest 底层错误链,若只看到 `client error (SendRequest)`,表示 Hyper 只暴露到发送请求阶段,不等于最终根因。若 `statusCode` 为空,应优先查同一 `requestId` `api-server` request 日志、provider 日志 `source_chain`、request_params、reference_image_bytes_total、Nginx / 出口网络、VectorEngine 可用性和请求体大小;若已有 `502``429 moderation_blocked` 等状态码,则按上游网关或内容审核失败单独处理,不要和传输超时混为一类。
tracking outbox 默认配置:
@@ -383,9 +400,10 @@ GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox
GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE=500
GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS=1000
GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES=268435456
GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS=5000
```
outbox 采用 NDJSON 文件保存原始事件。达到 `BATCH_SIZE` 时会立刻把当前 active 文件原子封存为 sealed 文件,并马上切到新的 active 继续写入;后台 worker 异步 flush sealed 文件HTTP 请求线程不等待 SpacetimeDB。`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件。SpacetimeDB 批量 procedure 返回成功后删除 sealed 文件,失败则保留文件并重试。`MAX_BYTES` 是磁盘保护阈值,不是 flush 阈值;超过后低价值 route tracking 可以被丢弃并记录日志 / 指标关键同步事件不进入该丢弃路径。sealed 文件若出现无法解析的坏行,会重命名为 `corrupt-*` 隔离并记录 `genarrative.tracking_outbox.files.corrupt` 指标,避免一个坏文件阻塞后续批量入库。该机制提供至少一次投递语义,依赖 `tracking_event.event_id` 幂等跳过重复事件。
outbox 采用 NDJSON 文件保存原始事件。达到 `BATCH_SIZE` 时会立刻把当前 active 文件原子封存为 sealed 文件,并马上切到新的 active 继续写入;后台 worker 异步 flush sealed 文件HTTP 请求线程不等待 SpacetimeDB。`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件。SpacetimeDB 批量 procedure 返回成功后删除 sealed 文件,失败则保留文件并重试。`MAX_BYTES` 是磁盘保护阈值,不是 flush 阈值;超过后低价值 route tracking 可以被丢弃并记录日志 / 指标关键同步事件不进入该丢弃路径。sealed 文件若出现无法解析的坏行,会重命名为 `corrupt-*` 隔离并记录 `genarrative.tracking_outbox.files.corrupt` 指标,避免一个坏文件阻塞后续批量入库。api-server 收到退出信号后会在 `GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS` 窗口内封存 active 文件并尽力 flush sealed 文件,超时或 SpacetimeDB 暂不可用时保留本地文件给下次启动继续投递。该机制提供至少一次投递语义,依赖 `tracking_event.event_id` 幂等跳过重复事件。
release 机器如果日志每秒刷 `tracking outbox ... Permission denied (os error 13)`,先检查 `/etc/genarrative/api-server.env` 是否缺少 `GENARRATIVE_TRACKING_OUTBOX_DIR`。缺少时 `api-server` 会回退到本地开发默认相对路径 `server-rs/.data/tracking-outbox`,而 systemd 的工作目录是只读发布目录 `/opt/genarrative/releases/<version>``genarrative` 用户无法在其中创建 `server-rs`。修复顺序:

View File

@@ -0,0 +1,78 @@
# 微信虚拟支付接入
更新时间:`2026-05-26`
## 接入口径
- 泥点充值在微信小程序 WebView 内走 `wechat_mp_virtual`,由小程序页调用 `wx.requestVirtualPayment``short_series_coin` 模式。
- 会员商品在微信小程序 WebView 内同样走 `wechat_mp_virtual`,由小程序页调用 `wx.requestVirtualPayment``short_series_goods` 模式,并在 `signData` 内带 `productId``goodsPrice`
- H5 与桌面微信环境仍分别走 `wechat_h5` / `wechat_native`,不进入虚拟支付链路。
- `session_key` 只保存在后端认证仓储内,用于计算虚拟支付用户态签名,不下发给前端。
- 客户端支付成功回调只代表已拉起支付并返回成功;最终到账仍以后端虚拟支付消息推送写入订单为准,普通微信支付订单则继续走微信支付 V3 notify / query。虚拟支付订单的确认接口只读取本地订单真相不再用普通微信支付 V3 查单。
- 小程序 WebView 普通进入不预登录H5 触发受保护入口或支付前必须保留 `clientRuntime=wechat_mini_program` 等宿主上下文,并用 `MicroMessenger + miniProgram` User-Agent 兜底识别首点 bridge 未就绪场景,再跳转小程序原生授权态,确保后端拿到带 `session_key` 的微信登录态。
## 关键文件
- 前端渠道选择:`src/services/payment/paymentPlatform.ts`
- 充值入口:`src/components/rpg-entry/RpgEntryHomeView.tsx`
- 小程序支付承接页:`miniprogram/pages/wechat-pay/index.shared.js`
- API 契约:`packages/shared/src/contracts/runtime.ts``server-rs/crates/shared-contracts/src/runtime.rs`
- 后端下单与订单编排:`server-rs/crates/api-server/src/runtime_profile.rs``server-rs/crates/api-server/src/wechat/pay.rs`
- 微信支付 / 虚拟支付协议适配:`server-rs/crates/platform-wechat/src/pay.rs`
- 微信订阅消息协议适配:`server-rs/crates/platform-wechat/src/subscribe_message.rs`
- WebView 回流确认:`GET /api/profile/recharge/orders/{orderId}/wechat/events``POST /api/profile/recharge/orders/{orderId}/wechat/confirm`
- 微信登录态保存:`server-rs/crates/platform-auth/src/lib.rs``server-rs/crates/module-auth/src/lib.rs`
## 后端配置
生产接入虚拟支付至少需要:
```bash
WECHAT_PAY_ENABLED=true
WECHAT_PAY_PROVIDER=real
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID=<微信虚拟支付 offerId>
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY=<现网 AppKey>
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY=<沙箱 AppKey可选>
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=<微信消息推送 Token>
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=<微信消息推送 EncodingAESKey>
WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED=true
WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID=m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU
WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE=formal
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0
```
`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0` 表示现网,`1` 表示沙箱。后端会按 env 选择 AppKey并生成
- `signData`:传给 `wx.requestVirtualPayment` 的订单数据。
- `paySig``HMAC-SHA256(appKey, "requestVirtualPayment&" + signData)` 的小写 hex。
- `signature``HMAC-SHA256(session_key, signData)` 的小写 hex。
- 泥点属于微信虚拟支付代币coin`short_series_coin``buyQuantity` 必须使用当前泥点商品的 `points_amount`;例如 60 泥点商品应传 `buyQuantity: 60`
- 会员直购 `signData` 额外包含 `productId``goodsPrice``goodsPrice` 使用后端商品配置价,和微信后台道具价格校验保持一致。
- 微信小程序“开发者服务器接收消息推送”必须配置为安全模式,数据格式选 JSONURL 统一指向 `/api/profile/recharge/wechat/virtual-notify`
- `WECHAT_MINIPROGRAM_MESSAGE_TOKEN``WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY` 由环境变量注入GET URL 验证按官方规则用 `Token/timestamp/nonce` 校验 `signature` 并原样返回 `echostr`POST 安全模式推送再校验 `msg_signature`、用 `EncodingAESKey` 解密 `Encrypt`,然后按虚拟支付事件入账。
- 安全模式下POST 推送会先解密再解析 `xpay_goods_deliver_notify` / `xpay_coin_pay_notify`;不要把 GET URL 验证里的 `echostr` 当密文解密。
## 验收命令
```bash
npm exec vitest run miniprogram/pages/wechat-pay/index.test.js src/services/payment/paymentPlatform.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
cargo check -p api-server --manifest-path server-rs/Cargo.toml
cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml create_profile_recharge_order_response_serializes_virtual_wechat_payloads
npm run typecheck
npm run check:encoding
```
## 注意事项
- 旧微信登录快照可能没有 `session_key`;普通进入小程序 WebView 仍允许匿名打开虚拟支付会由后端拦截并提示用户在小程序内重新登录。H5 内部导航不得清理 `clientType``clientRuntime``miniProgramEnv`,且首点登录要用小程序 User-Agent 兜底识别,否则登录和支付会误判为普通网页环境。
- 小程序充值商品全部映射到虚拟支付;泥点使用 `short_series_coin`,会员使用 `short_series_goods`
- `short_series_coin` 只用于代币购买,后端从本次下单返回的充值中心商品快照读取 `points_amount` 并写入 `buyQuantity`;不要把 coin 商品当成道具,也不要把 `buyQuantity` 固定为 1。
- 后台新增的会员类充值商品会直接把商品 `productId` 作为微信 `short_series_goods` 的道具 ID例如微信后台道具 ID 为 `item01` 时,后台会员商品 `productId` 也应配置为 `item01`,且商品价格需要与微信后台道具价格一致。
- 小程序页必须保留普通支付与虚拟支付双分支,按 pay params 字段判断调用 `wx.requestPayment``wx.requestVirtualPayment`
- 小程序支付承接页回传 `wx_pay_result` 时必须携带 `requestId:status:orderId[:error]`,并同时写入上一页 hash 与本地 storageWebView `onShow` 会立即检查一次、延迟二次检查一次,且同名 hash 参数必须替换,避免支付状态停留在处理中或重复处理。
- 微信虚拟支付消息推送使用独立后端入口 `/api/profile/recharge/wechat/virtual-notify`,按 `xpay_goods_deliver_notify``xpay_coin_pay_notify` 推进充值订单入账;回包需按入站格式返回 `ErrCode=0` / `ErrMsg=success`JSON 入站回 JSONXML 入站回 XML错误时带具体 `ErrMsg` 便于微信侧重试与排障。
- 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。
- Web 侧在拉起虚拟支付后会短时轮询 `wx_pay_result`,即使小程序 `web-view` 回写 hash 没触发浏览器 `hashchange`,也必须展示回写的微信错误内容。
- WebView 返回但没有拿到 `wx_pay_result` 时,前端必须主动调用订单确认接口,并接入 `/api/profile/recharge/orders/{orderId}/wechat/events` 的 SSE 事件流作为服务端推送兜底后端收到虚拟支付消息推送并入账后会发布订单更新SSE 先推当前订单快照,再在订单结束时推 `done`
- 小程序订阅消息用于 AI 创作生成结果通知H5 在生成动作发起前先把页面切到生成进度态并立即调用生成 action同时非阻塞跳转到小程序原生订阅授权页尝试请求授权授权接受、拒绝或页面返回都不得阻塞或取消生成。原生页不得改写上一页 `webViewUrl`,避免返回后丢失 H5 当前进度页状态。通知发送只允许发生在玩法草稿生成成功或失败终态之后api-server 使用当前用户微信登录保存的 openid 调用微信 `subscribeMessage.send`。发送失败只记录 warning不阻断作品生成。模板 `thing1` 发送玩法模板名,`number6` 发送本次生成结算后的实际泥点扣除,失败退款后固定为 `0`;模板 `time4` 字段必须是北京时间 `YYYY-MM-DD HH:mm``WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 支持 `formal` / `trial` / `developer`,应与当前发布环境一致。
- WebView 返回后,在订单状态拉取或 SSE 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。

View File

@@ -1,26 +1,50 @@
# 平台入口与玩法链路
更新时间:`2026-05-15`
更新时间:`2026-06-04`
## 平台创作入口
创作入口配置事实源在 SpacetimeDB通过 `GET /api/creation-entry/config` 下发;后台通过 `/admin/api/creation-entry/config` 管理。前端只在展示层派生可见卡片和入口状态,`api-server` 路由熔断也使用同一份配置。不要恢复前端硬编码入口配置文件。
当前创作 Tab 只承载赛事 banner、玩法模板分类和两列模板卡点击模板卡后直接进入对应玩法已有的入口创作表单 stage不再经过空白占位页也不把旧表单嵌进创作 Tab 首屏。移动端创作 Tab 顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把活动奖池当作账号余额展示。首屏 banner 结构按参考图拆成横向可滑动赛事卡、主体宣传图文区、奖池胶囊、开始 / 结束时间条和卡片内分页点;轮播只保留 `拼图主题创作赛``抓大鹅主题创作赛`,两个主题赛事奖池均为 `1000` 泥点数。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作 Tab 根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作首屏字号需要对齐平台普通 UI 档位:顶栏泥点组件、banner 正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px``14px`,不使用 `text-lg``text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace``big-fish-agent-workspace``match3d-agent-workspace``square-hole-agent-workspace``jump-hop-workspace``wooden-fish-workspace``puzzle-agent-workspace``bark-battle-workspace``visual-novel-agent-workspace``baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作 Tab 首屏内容。
当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage不再经过空白占位页也不把旧表单嵌进创作入口页模板点击的占位 no-op、隐藏模板拦截、未知入口 no-op 和工作台启动目标统一由 `platformCreationLaunchModel.ts` 判定,壳层只执行启动前准备、错误提示和受保护动作。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config``eventBanners` 数组,多条配置时前端自动轮播;旧 `eventBanner` 只保留字段回显与旧客户端兼容,不再作为前端公告数组的兜底来源。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下显示 `creationTypes[].unifiedCreationSpec.mudPointCost` 经前端格式化后的泥点消耗、下方白底标题/描述”结构展示,旧契约缺少该字段时兜底 `10` 并由前端显示为 `10泥点数`,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px``14px`,不使用 `text-lg``text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID页面必须展示“仅显示最近7天内使用过的模板”提示列表内容必须复用其它页签里的模板卡样式、文案和点击行为不展示具体作品名称、摘要或生成状态也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace``big-fish-agent-workspace``match3d-agent-workspace``square-hole-agent-workspace``jump-hop-workspace``wooden-fish-workspace``puzzle-agent-workspace``bark-battle-workspace``visual-novel-agent-workspace``baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。
创作恢复参数只保留 `sessionId``profileId``draftId``workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相
旧库或旧迁移包没有 `event_banners_json` 时,后端读取层必须把 `eventBanners` 归一到 `module-runtime` 默认公告数组,不能把旧结构化 `eventBanner` 当成前端优先数组下发。默认公告引用的背景图必须指向 `public/` 下真实存在的站内静态资源,当前默认使用 `/creation-type-references/puzzle.webp`,避免创作入口顶部 banner 出现失效图片
一期创作流程统一化覆盖拼图、抓大鹅、跳一跳和敲木鱼。四者在前端统一经过 `UnifiedCreationWorkspace``UnifiedGenerationPage``UnifiedCreationWorkspace` 作为平台壳唯一依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace``Match3DCreationWorkspace``JumpHopCreationWorkspace``WoodenFishCreationWorkspace`;创作页字段清单由后端在 `GET /api/creation-entry/config``creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec首期字段类型只保留 `text``select``image``audio``UnifiedCreationPage` 提供统一标题栏、统一返回入口、页面级纵向滚动、内容区和隐藏字段契约,不在 UI 中额外展示字段说明 chip竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交但返回按钮只保留在统一页头工作台内部不再重复渲染。拼图、抓大鹅、跳一跳和敲木鱼的工作台实现都已收口到统一目录只保留各自输入逻辑、素材选择和提交校验不再由平台壳直接依赖旧工作台文件。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。视觉小说、`airp``component`、汪汪声浪、方洞、大鱼和宝贝识物不进入一期接线范围,已有链路保持现状
创作页和草稿页顶栏右上角的泥点余额胶囊是补足泥点入口:如果当前运行环境开启充值入口,点击后直接打开账户充值弹窗;否则直接打开运营兑换码弹窗。该入口不再跳到账户面板或泥点账单,头像 / 设置等账号入口继续保留各自语义
创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点
创作恢复参数只保留 `sessionId``profileId``draftId``workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。平台入口刷新直达时,路径到玩法恢复目标、四个 query 归一化、生成页标记、大鱼吃小鱼 workId 兜底、作品 / 草稿身份匹配和跳一跳 / 敲木鱼恢复阶段落点统一由 `platformCreationUrlStateModel.ts` 解析,壳层只执行读取作品、恢复草稿和切换阶段等副作用。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相
平台入口、生成页、结果页、作品详情、作品架和运行态的跨流程错误统一收口到 `PlatformErrorDialog`。弹窗必须带明确错误来源,例如某个草稿、某次生成、作品详情或某个游玩实例,并提供复制按钮复制“错误来源 + 错误内容”。页面内不再重复渲染裸错误 banner表单校验、发布确认弹窗里的局部业务错误可以保留在原弹窗内
生成页进度 tick 是否启动统一由 `platformGenerationProgressTickModel.ts` 判定:各小游戏生成页只在当前 stage 与对应生成状态匹配、状态存在且 phase 非 `ready` / `failed` 时 tick视觉小说继续使用 `startedAtMs` 与轻量 phase 判定,不强行转成小游戏生成状态。平台壳只保留 `Date.now()``setInterval` 和 cleanup 副作用,不在壳层重复维护 stage 到 state 的三元链
拼图 runtime 刷新恢复、跳一跳生成中草稿打开和敲木鱼生成中 / detail 草稿恢复所需的 session / work DTO 映射统一由 `platformMiniGameSessionMappingModel.ts` 构造。平台壳只负责读取后端、写入本地 state、写 URL 和切换 stage不得在壳层重新手写 sessionId 优先级、pending draft 空素材默认值或拼图稳定 ID 映射。
平台小游戏生成状态的恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并和 ready / generating 判定统一由 `platformMiniGameDraftGenerationStateModel.ts` 处理。平台壳只决定何时调用并写入对应 React state不得在壳层重新维护 `MiniGameDraftGenerationState` 的 phase 阈值、`finishedAtMs` 清理或拼图进度 metadata 合并规则。
拼图 / 抓大鹅草稿恢复和提交所需的表单 payload、拼图编译 action、pending metadata 与拼图 form-only 草稿判定统一由 `platformMiniGameDraftPayloadModel.ts` 构造。平台壳不得重新手写拼图描述字段优先级、formDraft 回退、form-only 空草稿判定、Match3D config / draft / anchorPack 优先级、数字解析或 pending 标题摘要派生规则。
拼图生成完成后刷新恢复的草稿归一化与可恢复完成态判定统一由 `platformPuzzleDraftRecoveryModel.ts` 处理。恢复链路只有在首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才可把 draft 和首关状态抬为 `ready`;只有 cover 或候选图的半成品不得直接进入结果页完成态。
后端拼图发布 / 待发布门槛同样必须要求首图、关卡画面、UI spritesheet 与关卡背景资产包完整:`module-puzzle` preview blockers 与 `api-server` session stage 判定不得只凭 cover、标题、描述和标签把半成品标为 `publishReady``ready_to_publish`
平台入口个人钱包本地 delta 由 `platformProfileWalletDeltaModel.ts` 判定:余额归一、本地扣点 / 返还后的 dashboard 乐观更新,以及服务端 dashboard 刷新后的 delta 对账不得散落在平台壳层;壳层只负责 API、React ref 和 state 写入。
RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 判定:平台壳不得重新手写 `CustomWorldProfile` 顶层、`creatorIntent``anchorContent`、章节蓝图与首幕 acts 的结构探测,也不得在壳层内联 result preview source label 映射;壳层只负责 session/profile 编排和结果页 props 传递。
统一创作入口覆盖当前可进入创作链路的已有模板:`rpg``big-fish``puzzle``match3d``jump-hop``wooden-fish``square-hole``bark-battle``visual-novel``baby-object-match``creative-agent``airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace``UnifiedGenerationPage``UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace``Match3DCreationWorkspace``JumpHopCreationWorkspace``WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单、表头和入口卡泥点消耗数量由后端在 `GET /api/creation-entry/config``creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec字段类型只保留 `text``select``image``audio`。统一创作页表头按 `unifiedCreationSpec.title` 契约内容原样显示,入口卡泥点消耗按 `unifiedCreationSpec.mudPointCost` 由前端格式化为 `X泥点数`,读取和保存时不再用入口名称或前端固定文案自动覆盖;需要改表头或入口卡消耗数量时应在后台契约结构卡片点击修改,并通过弹窗表单编辑 `title``mudPointCost` 字段,不再要求直接编辑 JSON。`workspaceStage``generationStage``resultStage` 属于内部阶段标识,后台弹窗不展示也不允许编辑;保存时沿用已有契约值,新增契约时按 `playId` 的前端固定阶段映射自动带出。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。统一创作页根容器必须保留平台浅色背景并让内容区占满剩余高度,移动端软键盘打开或视口被小程序宿主压缩时,短表单也不得露出浏览器 / 宿主黑底H5 根节点在 `data-mobile-keyboard-open=true` 时必须把 `html` / `body` / `#root` 背景切到当前平台浅色底,但不得再用 `.platform-viewport-shell` 全局 `transform` 二次上推页面;小程序 `web-view` 页面原生宿主也必须使用浅色背景,不能沿用全局黑色 page 背景。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap确保白字、浅色边框和进度条底色不会被全局规则改成深色不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。
创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;校验成本必须读取同一份 `creationTypes[].unifiedCreationSpec.mudPointCost`,不能回到前端常量。余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。
平台入口、生成页、结果页、作品详情、作品架和运行态的跨流程错误统一收口到 `PlatformErrorDialog`。弹窗必须带明确错误来源,例如某个草稿、某次生成、作品详情或某个游玩实例,并提供复制按钮复制“错误来源 + 错误内容”。页面内不再重复渲染裸错误 banner表单校验、发布确认弹窗里的局部业务错误可以保留在原弹窗内。生成任务在用户离开生成页后异步失败时也必须通过同一弹窗通知用户并把失败消息写入该 session 的草稿 notice供草稿页和失败重试页恢复使用。
生成任务在用户离开生成页后异步完成时,平台壳层必须弹出 `PlatformTaskCompletionDialog`。完成弹窗同样要带来源,例如某个草稿或生成会话,并提供复制按钮复制“来源 + 状态”;如果用户仍停留在生成页并被自动带入结果页或试玩页,生成页 / 结果页本身即为完成反馈,不再额外叠加完成弹窗。
入口配置中的 `open=false` 表示关闭新建创作入口不表示下架已有草稿、私有作品或公开作品。api-server 的入口熔断只允许拦截新建创作、新建草稿、首次生成入口和 Remix 成草稿等会产生新创作的请求;公开广场列表、公开详情、点赞、已发布作品启动、运行态过程请求、存档 / 浏览记录和已有作品回读不能因为创作入口关闭而返回 `creation_entry_disabled`。平台首页如果遇到旧服务端返回的 `creation_entry_disabled`,只能降级为空列表或隐藏入口,不弹平台级错误弹窗。
创作入口页的关闭态卡片必须有明显差异:卡片禁用点击,展示后台配置的关闭态 badge 或 `暂未开放`,不再显示泥点消耗这类可创建成本提示;开放态卡片仍不显示普通 `可创建 / 可创作` badge。
`PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`
`platformEntryCreationTypes.ts` 只做前端展示派生,分组时必须把后端 `creationTypes` 里的 `categoryId` / `categoryLabel` 当作可缺失字段处理,空值统一回退到 `recent` / `最近创作`,避免旧数据、局部 mock 或异常返回把创作入口初始化直接打崩
`platformEntryCreationTypes.ts` 只做前端展示派生,分组时必须把后端 `creationTypes` 里的 `categoryId` / `categoryLabel` 当作可缺失字段处理,空值统一回退到 `recommended` / `热门推荐`,并把历史 `recent` / `最近创作` 归一到推荐分类。`最近创作` 不属于模板分类页签,只能由 7 天内的真实草稿 / 作品架后端数据决定是否展示;展示内容仍然从后端入口配置的模板卡中筛选,不读取或渲染作品标题、作品摘要、草稿阶段文案
移动端底部一级导航是全局平台样式,不按单一玩法分叉。当前视觉统一为米白浮动胶囊底座、浅棕分隔线、棕色线性图标、橘色选中态和底部短下划线;中间 `创作` 入口保持凸起圆形主按钮,但凸起位移只能作用在按钮内容层,不能移动承载分隔线的 Tab 按钮容器确保创作左右分隔线与其他分隔线垂直位置一致。Tab 名称和可见性仍由现有 `PlatformHomeTab` / 登录态规则决定,样式调整不得改写 Tab 文案或导航状态。
@@ -34,25 +58,34 @@
默认工作台只提交结构化表单、图片槽位和配置 payload不默认增加聊天输入区、流式消息区或轻输入 Agent。确需偏离该模式时必须先在 PRD 和本文档写明例外原因、影响范围和回退方式,再进入编码。
单图资产编辑统一通过 `CreativeImageInputPanel` 承载上传、AI 重绘、参考图、历史图和删除确认;新玩法页面不得重复手写这些交互。系列素材图集生成统一走“批量规划 -> sheet 生图 -> 后端切图 -> 透明化 -> OSS 持久化 -> 状态回写 -> 局部重生成”流程,玩法只提供 `sheetSpec``slotSpecs`、提示词和字段映射,不把任一玩法专属素材 DTO 当作平台通用模型。
单图资产编辑统一通过 `CreativeImageInputPanel` 承载上传、AI 重绘、参考图、历史图、主图预览和删除确认;新玩法页面不得重复手写这些交互。主图已有图片时,默认点击图片打开全屏预览,上传 / 更换收口到右下角 `ImagePlus` 图标按钮;无图时仍允许点击空图卡上传。调用方只能通过 `canUploadMainImage``canUseImageHistory` 等受控参数开关上传和历史入口,不得用复制组件或样式遮挡改行为。系列素材图集生成统一走“批量规划 -> sheet 生图 -> 后端切图 -> 透明化 -> OSS 持久化 -> 状态回写 -> 局部重生成”流程,玩法只提供 `sheetSpec``slotSpecs`、提示词和字段映射,不把任一玩法专属素材 DTO 当作平台通用模型。
通用系列素材图集能力的实现真相源在 `platform-image::generated_asset_sheets``n` 是必选参数,模块负责组装 `n*n` sheet prompt、按 `n*n` 切片、绿幕 / 近白底透明化、导出 PNG 和 OSS 持久化请求。`api-server::generated_asset_sheets` 只保留 `AppError` / `AppState` 适配,不再承载图像处理和 OSS 请求构造细节。物品名称 prompt 和特殊设定 prompt 是可选输入;调用方可传入类似“每个物品生成五个不同视图”的视角约束,通用模块会把 sheet prompt、物品行 prompt、特殊设定 prompt 编码写入 OSS 元数据。玩法仍负责计费、物品规划、slot 映射、失败回写和把通用切片结果映射回自己的草稿 / profile / runtime 字段。
通用系列素材图集能力的实现真相源在 `platform-image::generated_asset_sheets``n` 是必选参数,模块负责组装 `n*n` sheet prompt、按 `n*n` 切片、默认绿幕 / 近白底透明化、导出 PNG 和 OSS 持久化请求;高风险撞色玩法可显式使用专用 key 色、关闭近白扣除并限制为边缘连通背景扣除`api-server::generated_asset_sheets` 只保留 `AppError` / `AppState` 适配,不再承载图像处理和 OSS 请求构造细节。物品名称 prompt 和特殊设定 prompt 是可选输入;调用方可传入类似“每个物品生成五个不同视图”的视角约束,通用模块会把 sheet prompt、物品行 prompt、特殊设定 prompt 编码写入 OSS 元数据。玩法仍负责计费、物品规划、slot 映射、失败回写和把通用切片结果映射回自己的草稿 / profile / runtime 字段。
当前所有玩法生成页 UI 统一收敛为圆环主视觉:`media/create_bg_video.mp4` 作为生成页固定全屏背景层循环静音播放,主进度圆环居中覆盖在背景之上,围绕陶泥儿视觉展示;页面只保留当前步骤名称和当前步骤进度,不再渲染步骤列表块。视频层需要显式触发播放,不能只依赖 `autoPlay/loop/muted` 属性。圆环内部保持 `400x400` SVG 坐标系,外层显示宽度以 `400px` 为上限,窄屏按视口宽度收缩,预计等待 / 已耗时信息卡在窄屏下落到圆环下方,避免右侧裁切。共用生成页 `CustomWorldGenerationView` 和汪汪声浪生成页都必须遵循这一口径。
当前所有玩法生成页 UI 统一收敛为圆环主视觉:`media/create_bg_video.mp4` 作为生成页固定全屏背景层循环静音播放,主进度圆环居中覆盖在背景之上,围绕陶泥儿视觉展示;页面只保留当前步骤名称和当前步骤进度,不再渲染步骤列表块,也不再展示“当前拼图信息”“当前敲木鱼信息”“当前世界信息”等玩法设定信息模块。视频层需要显式触发播放,不能只依赖 `autoPlay/loop/muted` 属性。圆环内部保持 `400x400` SVG 坐标系,外层显示宽度以 `400px` 为上限,窄屏按视口宽度收缩,预计等待 / 已耗时信息卡在窄屏下落到圆环下方,和当前步骤卡保持更大的垂直间距;预计等待左边缘、已耗时右边缘必须分别与当前步骤卡左右边缘对齐,避免右侧裁切或横向漂移。生成页顶部返回栏和状态标识不参与内容滚动,滚动只发生在进度内容区。共用生成页 `CustomWorldGenerationView` 和汪汪声浪生成页都必须遵循这一口径。
## 草稿与作品架
1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。
2. 草稿页顶部 `全部 / 草稿 / 已发布` 筛选与发现页 `推荐 / 今日 / 分类 / 排行` 频道标签复用同一选中 / 未选中视觉,即 `platform-mobile-home-channel``platform-mobile-home-channel--active`,不再使用旧 `platform-tab` 胶囊样式。
3. 草稿页与底部导航的未读提示点统一使用平台暖棕色点和暖棕光晕,不再使用红点或红色 glow草稿 Tab 作品架卡片无论草稿 / 已发布都不外露作者信息;已发布作品卡右上角直接显示无边框分享 icon。删除等破坏性动作在作品卡上也要直接开放独立删除入口,左滑长按仅作为辅助操作层
3. 草稿页与底部导航的未读提示点统一使用平台暖棕色点和暖棕光晕,不再使用红点或红色 glow草稿 Tab 作品架卡片无论草稿 / 已发布都不外露作者信息;已发布作品卡右上角直接显示带底色的分享 icon,并统一唤起发布分享弹窗 `PublishShareModal`,不在卡片内部单独复制分享文案。删除等破坏性动作在作品卡上也要直接开放统一 `actions.delete` 入口,左滑长按和键盘左箭头仅作为打开同一操作层的辅助交互;所有玩法草稿和已发布列表项都必须通过该统一接口接入删除确认、删除中状态和列表刷新,不允许只给拼图保留专属滑动删除分支
4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。
5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。
6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用进入生成页的当前时间,作品摘要 `updatedAt` 只用于排序和摘要展示,不参与假进度起算
7. 从草稿 Tab 作品架打开草稿工作区、生成页或结果页时,返回按钮必须回到草稿 Tab 的同一作品架语境;从创作 Tab 新建或直接进入创作链路时才回到创作 Tab。平台壳层需要显式记录本次创作流的返回来源不能让结果页返回动作固定跳到创作入口
8. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取
9. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表
6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 优先使用后端 session 的 `updatedAt`,没有 session 时再使用作品摘要 `updatedAt`,不得因重新进入页面从 0 秒重新计时
7. 生成失败必须按 session 独立记录,不能用一个失败打断或覆盖同玩法的其它生成任务。失败 notice 需要保存错误消息并覆盖作品架本地状态:即使后端摘要暂时仍是 `generationStatus=generating` 或只写出半成品投影,草稿卡也不得继续显示“生成中”,点击后必须进入失败 / 重试生成页,不能重新创建一轮生成。失败页点击重新生成时必须优先复用当前可恢复 `sessionId` 执行编译 action只有没有可恢复 session 时才允许回退到新建草稿。拼图这类失败半成品若没有有效 `workTitle`作品架标题回退为“拼图草稿”不暴露“第1关”空壳
8. 从草稿 Tab 作品架打开草稿工作区、生成页或结果页时,返回按钮必须回到草稿 Tab 的同一作品架语境;从创作 Tab 新建或直接进入创作链路时才回到创作 Tab。平台壳层需要显式记录本次创作流的返回来源不能让结果页返回动作固定跳到创作入口
9. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取
10. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。
11. 移动端草稿页整体禁止长按选择文字,避免误触系统选区;输入框、文本域和可编辑区域仍必须保留文本选择能力。
12. 作品架删除确认的纯规则统一由 `platformCreationWorkDeleteFlow.ts` 解析,输出确认框 `id/title/detail` 与删除成功后清理的草稿 notice keys平台壳只接回该模型执行删除 API、刷新列表、清错误和跳转。Jump Hop、Wooden Fish、Bark Battle 虽在作品架 action 层有预留删除入口,但未补齐删除 API 前不得传入删除 handler 或开放按钮。
发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡
发现页 / 推荐页公开作品卡的作者行只显示可读公开昵称;不得把手机号掩码、账号生成的脱敏手机号、`SY-*` 陶泥号或作品号拼接进卡片作者名。陶泥号搜索、作品号复制和完整作品身份只在搜索、详情页或明确的复制入口展示,避免卡片列表暴露账号标识。推荐页运行态、标题和作者信息必须使用同一套公开作品 key 选中当前条目;新增或补齐公开玩法类型时复用 `buildPlatformPublicGalleryCardKey(...)`,避免运行内容已切换但标题 / 作者仍退回第一条作品
平台公开搜索的分流顺序、per-play 公开码匹配、公开可见性过滤和详情卡 DTO 映射统一由 `platformPublicCodeSearchModel.ts` 判定:`user_` / `user-` 内部用户 ID 只查用户 ID`PZ``BF``JH``WF``BO``M3``SH``VN``BB` 前缀分别直达对应玩法公开作品;`M3D-*` 作为抓大鹅旧前缀继续匹配;`CW` 与 1-8 位纯数字先查 RPG 公开作品再回退陶泥号;普通关键词和 `SY` 陶泥号保持先查陶泥号、再查 RPG 作品、再查汪汪声浪作品、最后陶泥号兜底的既有顺序。平台壳只按计划执行网络读取、详情打开、Bark Battle runtime 特例和缺失作品归航,不在壳层重复维护前缀布尔链、`isSame*PublicWorkCode` 或 DTO 映射。
个人“玩过作品”面板点击作品时,玩法别名、`worldKey` 前缀兜底、RPG 公开详情 payload 和大鱼吃小鱼缺 gallery 命中时的 fallback work 统一由 `platformPlayedWorkOpenModel.ts` 判定。平台壳只负责关闭面板、调用对应公开详情打开函数、刷新大鱼 gallery、优先使用真实 gallery 命中项和写入错误提示;不要在壳层重新维护 `worldType` / `worldKey` 分支链。
发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;常用功能当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。
## RPG / 自定义世界
@@ -94,10 +127,11 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
- 图像输入复用 `CreativeImageInputPanel`
- 结果页每关画面编辑复用 `CreativeImageInputPanel`;入口页和关卡画面只共享受控 UI 模块不共享数据源、状态、action 或存储位置:入口页继续写 `formDraft` 与草稿编译 payload关卡画面写 `levels[].pictureReference/pictureDescription` 并触发 `generate_puzzle_images`。结果页删除独立“素材配置”Tab不再提供单独 UI 背景生成入口。通用图片面板的展示图和 AI 重绘参考图能力必须分开控制:结果页正式关卡图只作为预览图,不因存在正式图自动暴露 AI 重绘开关;只有本地上传、历史选择或已保存 `pictureReference` 可作为重绘参考图时,才显示 AI 重绘开关并把状态带入 `generate_puzzle_images`。用户在本次编辑中上传或选择历史图后,该图优先占据主图卡片,可删除、切换 AI 重绘,也可关闭 AI 重绘直用;仅有正式图预览时,画面描述框仍可上传多张参考图。关卡详情弹窗应使用加宽面板,关卡名称、画面图和画面描述合并在同一个纵向列表中,名称输入和画面编辑模块外层不再包独立 `platform-subpanel`;画面图卡仍必须保留稳定最小高度,避免弹窗内 `flex-1` 布局坍缩后只剩标题、描述输入和操作按钮。
- 历史图片选择弹窗只展示缩略图与生成时间,不展示从对象路径或文件名解析出的图片名称;选中历史图后内部兜底文案统一使用“历史素材”。
- 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;主链要求浏览器先经 `/api/assets/direct-upload-tickets` 直传 OSS 并确认 `asset_object`,创作 action 只提交 `referenceImageAssetObjectId(s)`,由后端校验 owner / bucket / kind / MIME / size 后签发 OSS 只读 URL 并下载为 VectorEngine `/v1/images/edits` 的 multipart `image` part。本地上传 Data URL 与历史 `/generated-*` 图片路径仅保留为旧草稿、旧入口或未迁移客户端的兼容输入;关闭 AI 重绘时,后端统一解析为首关或当前关卡正式图后再持久化,不调用第一段拼图首图生成。
- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要生成完成并回写关卡拼图画面、关卡画面参考图、UI spritesheet 和关卡背景图后再变为 `ready`;当前不自动生成背景音乐。生成页步骤推进必须跟随后端 session `progressPercent` 的真实里程碑:`88` 表示草稿编译完成并进入出图步骤,`94` 表示生成图已保存并进入 UI / 背景步骤,`96` 表示正式图与 UI 背景已确认并进入写入步骤,最终 action 成功或发布才进入完成态;每个步骤内部可以按实际等待时间使用假进度平滑推进,总进度按 `0-88``88-94``94-96``96-98` 的真实里程碑区间平滑推进。任一同步 action 回包到达时立即以真实完成/失败结果冻结进度。
- 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 UI 背景重生成和追加关卡生图都必须作为结果页局部生成态处理,不能阻止打开草稿结果页。
- 拼图草稿编译是长耗时 action前端 action 请求默认等待 `1_800_000ms`30 分钟)且不自动重试。每次图片生成调用的预期用时按 90 秒计算,但 `生成拼图首图` 单独按 4 分钟展示;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 4 分钟、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 448 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面``生成UI与背景`,合计约 208 秒。生成页恢复时必须使用进入生成页的当前时间作为原始 `startedAtMs`;失败/完成态用 `finishedAtMs` 冻结耗时。未收到对应后端里程碑前,后续步骤保持待处理;即使当前步骤预计时长耗尽,也只能让当前步骤内部进度停在 `98%` 内,不能自动完成当前步骤或跳到后续步骤。生成页每个步骤只展示标题和进度,不展示步骤详细描述。
- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要生成完成并回写关卡拼图画面、关卡画面参考图、UI spritesheet 和关卡背景图后再变为 `ready`;当前不自动生成背景音乐。生成页步骤推进必须跟随后端 session `progressPercent` 的真实里程碑:`88` 表示草稿编译完成并进入出图步骤,`94` 表示生成图已保存并进入 UI / 背景步骤,`96` 表示正式图与 UI 背景已确认并进入写入步骤,最终 action 成功或发布才进入完成态;每个步骤内部可以按实际等待时间使用假进度平滑推进`88/94/96` 只负责切换当前步骤,不作为总进度地板;总进度按已完成步骤权重加当前步骤内假进度推导,非完成态最多停在 `98%`。任一同步 action 回包到达时立即以真实完成/失败结果冻结进度。
- 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 UI 背景重生成和追加关卡生图都必须作为结果页局部生成态处理,不能阻止打开草稿结果页。生成失败后,同一浏览器会话内的失败 notice 必须覆盖后端可能仍短暂返回的 `generationStatus=generating` 摘要,作品架保留对应草稿卡但不再显示“生成中”,点击后回到失败 / 重试状态。
- 拼图草稿编译是长耗时 action前端 action 请求默认等待 `1_800_000ms`30 分钟)且不自动重试。每次图片生成调用的预期用时按 90 秒计算,但 `生成拼图首图` 单独按 4 分钟展示;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 4 分钟、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 448 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面``生成UI与背景`,合计约 208 秒。生成页恢复时必须使用后端 session `updatedAt` 或作品摘要 `updatedAt` 作为原始 `startedAtMs`;失败/完成态用 `finishedAtMs` 冻结耗时。生成完成后若自动进入草稿试玩,进入 `/runtime/puzzle` 前必须先把 `/creation/puzzle/result` 和当前 `sessionId/profileId/workId` 写成浏览器历史前一站;运行态返回按钮和系统返回都应回到结果页,不得退回生成进度页或暴露重新生成入口。未收到对应后端里程碑前,后续步骤保持待处理;即使当前步骤预计时长耗尽,也只能让当前步骤内部进度停在 `98%` 内,不能自动完成当前步骤或跳到后续步骤。生成页每个步骤只展示标题和进度,不展示步骤详细描述。
- 前端创作、结果页、生成页和错误提示不展示 GPT / Gemini 等具体模型名称如需在内部保留模型路由UI 只使用“标准模式”“创意模式”等产品化名称。
- 若浏览器锁屏、息屏或网络切换导致 compile 请求失败,前端在标记失败前必须先复读 `getPuzzleAgentSession(sessionId)`;只有最新 session 仍缺 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时才展示失败,复读到已生成草稿时按成功收尾、刷新作品架并继续自动试玩/结果页链路。
- 拼图参考图 AI 重绘走 VectorEngine `/v1/images/edits`;无参考图时走 `/v1/images/generations`。两者模型都使用 `gpt-image-2`,参考图由后端作为 multipart `image` part 传入编辑接口。
@@ -107,7 +141,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
- 结果页单关测试只能把完整草稿持久化,并通过 `levelId` 指定运行态起始关卡;不得把单关快照作为整份草稿调用 `updatePuzzleWork`,否则 source session 和作品 profile 的 `levels` 会被覆盖成单关,退出重进后其它关卡会丢失。
- 拼图试玩和正式运行态刷新恢复不复用创作私有 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 时,返回设置按钮的点击容器只提供透明点击区,不再叠加默认白色圆形底底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。
- 拼图运行态背景优先读取当前关卡 `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 等账号或所有权动作仍保持普通用户鉴权。
- 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。
- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
@@ -124,29 +158,39 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
对外名称:`跳一跳`。工程域:`jump-hop`。PRD 见 `docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`
首版定位为俯视角 / 等距视角 2D 休闲跳跃模板,链路对齐拼图的创作闭环:
当前定位为竖屏俯视角 2D 平台跳跃模板,链路对齐平台创作闭环:
```text
创作入口 -> 模板输入 -> 生成过程页 -> 结果页 -> 试玩 -> 发布 -> 运行态
创作入口 -> 主题输入 -> 生成过程页 -> 结果页 -> 试玩 -> 发布 -> 运行态
```
创作入口配置事实源仍是 SpacetimeDB `creation_entry_type_config`:默认 `visible=true``open=true``badge=可创建``subtitle=主题驱动平台跳跃``image_src=/creation-type-references/jump-hop.webp`。旧库中仍停留在 `subtitle=俯视角跳跃闯关``image_src=/creation-type-references/puzzle.webp` 的系统默认行会在入口配置播种流程中自动迁移;同时 `spacetime-client` 的入口配置读模型也会对同一条旧系统默认行做纠偏,避免订阅缓存长期回放老口径。后台手动改过的跳一跳入口配置不被覆盖。
素材生成规则固定为:
1. 初始草稿生成时,角色形象单独调用一次生图
2. 初始草稿生成时,地块单独调用一次生图,输出 3D 视图的 2D 图片图集
3. 跳一跳地块图集使用专用 `2行*3列` 六格布局,后端按 `start / normal / target / finish / bonus / accent` 顺序切分为透明 PNG
4. 封面和分享图由角色图与地块图轻量合成,不再额外调用第三次生图
5. 显式重生成角色或地块时,只重生成对应资产槽位
1. 创作端只保留主题输入,作品标题、简介、标签和地块提示词由系统派生
2. v1 不再单独生成角色图片,运行态固定使用抠除白底后的陶泥儿 logo 透明 PNG 作为玩家角色
3. 地块只调用一次 image2输出一张 `5行*5列``1:1`、单一纯洋红 `#FF00FF` key 背景的主题地块图集;跳一跳地块常包含草地、花、雪、白石和云朵,后端透明化必须使用跳一跳专用洋红 key不启用近白底扣除也不清理非边缘连通的 key 色像素,避免把绿色或白色主体误扣;后处理必须对边缘连通 key 色做容差清理、去彩边 defringe 和底部残影清理,主体图不得自带洋红阴影、紫色底边、粉色脏边、彩色光晕或发光底边,运行态阴影统一由 DOM 绘制地块造型提示词要求以主题物体本身外轮廓为准允许苹果近似圆形、香蕉近似长条或长方形、西瓜近似扇形等自然差异只统一单格规格、安全留白、正面30度视角和 2D/2.5D 手绘风格包装所有地块素材必须保持统一正面30度视角相机位于物体正前方略高位置、镜头向下约30度必须看到清晰正面、侧壁、下沿、明显自身厚度和少量上表面主体正面或侧壁可见面积必须接近或大于顶面面积顶面只能作为辅助可见面水果主题需要明确要求橙瓣看到橙皮正面外侧和果肉厚度、椰子看到壳的正面侧壁和切口厚度、浆果不能只是从上往下看的圆形球顶避免生成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标主题物体本身必须是唯一可落脚体只能用自身切面、边缘厚度、花瓣层或果皮边表现承重禁止在主题物体下方额外垫石台、土墩、木板、圆台、托盘、岛屿底座或通用地板前端和后端默认 `tilePrompt` 都必须使用“正面30度视角主题物体图集物体本身作为跳跃落点”的口径不再提交“平台素材 / 跳台 / 地块 / 地砖”等会把模型拉回通用平台造型的词,后端生成前也会清洗旧草稿遗留的这些词;当主题或地块提示词命中宝可梦 / 神奇宝贝 / 口袋妖怪 / Pokemon / Pikachu / 精灵球等宝可梦相关词时,仅生图请求侧改写为“原创幻想萌宠冒险道具 / 彩色冒险能量球 / 黄色闪电萌宠符号”,用户草稿标题和主题展示不改
4. 背景底图同样由 image2 生成,复用现有 `coverComposite` / `coverImageSrc` 作为运行态背景读写字段OSS 槽位固定为 `background/image.png`;提示词必须严格以用户主题关键词为背景主题,结构以左右两侧氛围为主,中央纵轴 1/2 区域保持少元素、简洁、可读且有纵深感两侧允许更强立体层次和行进感背景只作为底图禁止生成跳板、地块、落脚物、角色、UI、返回按钮、文字、路径箭头或海报排版左上角返回按钮不允许画进背景而是单独生成 `backButtonAsset` 透明 PNGOSS 槽位固定为 `back-button/image.png`,提示词要求标准圆形、主题色材质包装、居中左箭头、纯绿色 key 背景,后端去绿后写入作品 profile
5. 后端按从上到下、从左到右均匀切分为 `tile-01``tile-25` 的透明 PNG每个切片必须使用唯一 slot/path 持久化,不能按重复的 `tileType` 复用槽位
6. 结果页只展示陶泥儿 logo 透明角色预览、地块池预览和首屏 3 地块预览;不再提供旧角色图生成槽;
7. 前端跳一跳创作 client 的创建会话与执行生成动作请求都必须使用 20 分钟等待窗口,避免背景底图、地块图集、切片、抠图和 OSS 写入仍在后端执行时被共创会话默认 15 秒超时中断。
运行态规则真相必须沉到 `module-jump-hop`,前端只做蓄力表现、角色位移、投影和落地反馈。通关、失败、分数、combo、运行态快照和发布作品状态以后端为准。公开列表应走 `jump_hop_gallery_card_view` 订阅缓存,不要每次 HTTP 请求调用 procedure 组装全量列表。
运行态规则真相必须沉到 `module-jump-hop`,前端只做拖拽蓄力、角色位移、投影和落地反馈。失败、成功跳跃次数、游戏时长冻结、运行态快照和发布作品状态以后端为准。v1 不保留公开 combo / perfect / 通关语义,旧 `score` 兼容映射为成功跳跃次数。公开列表应走 `jump_hop_gallery_card_view` 订阅缓存,不要每次 HTTP 请求调用 procedure 组装全量列表。
平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'``JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带角色图、地块图集和路径配置,必须先补读完整 work profile 再传入运行态。平台壳层必须同步注册 `jump-hop-workspace``jump-hop-generating``jump-hop-result``jump-hop-runtime``jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace``/creation/jump-hop/generating``/creation/jump-hop/result``/gallery/jump-hop/detail``/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页
每屏只展示 3 个地块:当前地块、目标地块和下一预览地块。平台流按同一 seed 无限生成,前端不得自行生成正式路径。运行态 HUD 顶部只保留返回按钮和成功跳跃次数不展示计时器或右上角重开按钮生成背景和游戏舞台必须覆盖整个运行态视口HUD 直接绝对定位压在背景上,不再用外层白底、居中窄栏、卡片边框或游戏区域圆角裁切背景。返回按钮固定在左上角安全区,交互热区固定为移动端 `56px`、桌面约 `62px`,不显示“返回”文字,并通过顶部锚点微调与得分标题牌保持协调;运行态优先使用独立 `backButtonAsset` 透明 PNG 作为真实可点击按钮图,旧作品缺失该字段时才使用同尺寸 CSS 主题色圆形按钮兜底。上方成功跳跃次数 UI 复用拼图模板顶部 HUD 结构:`puzzle-runtime-header-card` 内包含陶泥儿 IP logo、居中的“得分”标题牌以及下挂 `puzzle-runtime-timer-card / puzzle-runtime-timer` 居中数字卡;数字卡展示成功跳跃次数而不是倒计时。游玩中不显示左下角“进行中”状态,也不在屏幕底部常驻排行榜。排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长每位玩家只保留 1 条最佳记录,排序固定为 `成功跳跃次数 desc -> 游戏时长 asc -> 更新时间 asc`,并只在失败结算弹窗内展示,弹窗保留重开和返回动作
运行态渲染分层固定为:舞台底层 `.jump-hop-runtime__scene-backdrop` 优先使用 `coverComposite` / `coverImageSrc` 中的 image2 背景底图图片读取继续走平台资产换签没有背景时才回退到内置渐变DOM 平台层直接使用 `tileAssets[]` 的生成切片图片显示地块,图片读取继续走平台资产换签,并以 `assetObjectId` 作为刷新键避免重生成后沿用旧签名或旧图片缓存;每个地块下方的统一软椭圆阴影来自运行态 DOM 的 `.jump-hop-runtime__platform-shadow`,不是 image2 地块切片的必需内容,调整阴影优先改运行态 CSS有真实地块图片 URL 时不得在加载空档显示 fallback 原型地块下一屏预览地块必须在进入相机视野前隐藏预加载DOM 角色层固定使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG 并保持最高层级Three.js 透明画布仅作为后续扩展层。拖拽蓄力、计时刷新和角色位置变化只能更新 refs 或 DOM 状态,不得销毁重建透明画布、背景或平台图片层,否则会造成背景、地块和角色层频闪。
跳一跳当前拖拽手感统一采用 `chargeToDistanceRatio=0.008`,用于把同等跳跃距离所需拖拽距离缩短到旧 `0.004` 的一半;如果历史路径仍保存旧系数,`start_run` 会在开局归一化到新系数。拖拽中只显示弹弓拉线,不显示落点辅助点、投影圈或其它命中提示。松手后运行态必须立即生成 `visualJump`,用当前角色位置作为起点、前端预测落点作为终点,播放约 `560ms` 的角色飞行动画:蓄力时角色沿拖拽方向明显拉长,角色弹向预测落点,落地后向反方向回弹两次;动画路径不得等待后端新 run。若后端新 run 晚于飞行动画返回,角色必须停在预测落点等待,直到新 run 到达后再把显示态切到后端最新 run并用约 `1440ms` 的相机层推进过渡承接新窗口。推进时地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块自然离开视野,新预览地块从上方露出,禁止用 p1/p2 各自 `top/left` 过渡造成角色和地块不同步。相机层推进必须同时使用 X/Y 偏移,从旧目标地块位置斜向滑到新当前地块聚焦位置,不得先横向瞬切到居中再纵向滑动。地块允许保留当前 / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 `transform: scale(...)` 缓动呈现,并与相机推进使用同一 `1440ms` 节奏;不要直接修改宽高造成瞬切,也不要再给当前态额外叠 CSS scale。相机推进期间角色自身必须禁用 `left/top` transition只允许父级 camera layer 负责位移,否则角色局部坐标切换和相机推进会叠加,表现为落地后又从屏幕外闪回。
平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'``JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带地块图集和路径配置,必须先补读完整 work profile 再传入运行态。平台壳层必须同步注册 `jump-hop-workspace``jump-hop-generating``jump-hop-result``jump-hop-runtime``jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace``/creation/jump-hop/generating``/creation/jump-hop/result``/gallery/jump-hop/detail``/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。
跳一跳作品架走创作中心的统一作品列表:前端通过 `/api/creation/jump-hop/works` 拉取作品摘要,草稿态会与 pending notice 合并后显示在作品架里,已发布作品点击后会先按 profileId 读取完整详情再进入详情或运行态。生成中作品仍以后端摘要里的 `generationStatus` 为准,刷新后应能恢复等待遮罩,不能只依赖内存 notice。
删除等破坏性动作当前未接入 jump-hop 删除 API如果后续要在作品架提供删除入口必须先补齐后端/SpacetimeDB/前端整条删除链路,再开放按钮
跳一跳作品架删除入口必须走 `/api/creation/jump-hop/works/{profile_id}`,并通过 SpacetimeDB 同步删除 work profile、源 session、运行态 run 与事件,再刷新作品架和公开广场;不得只做前端本地隐藏
推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏``推荐``作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts``usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer但请求选项必须是 local auth impact避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token并只把它作为局部请求头传给运行态客户端不写入全局登录态、不触发 refresh也不把匿名流量伪装成普通用户。当前覆盖矩阵为跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求都必须继续按该身份分流公开读取入口仍可匿名读取创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。
推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏``推荐``作品分类` 等桌面内容。推荐页候选顺序由前端轻量推荐算法 `platformRecommendation.ts` 统一生成:先按公开作品 key 去重,再使用公开读模型已有的精选来源、近 7 日游玩、点赞、改造、总游玩、发布时间新鲜度、封面和标签完整度做确定性评分,最后优先交错不同玩法类型;只要还有其它玩法候选,就不要连续推荐同一玩法,只有候选池已没有其它玩法时才允许同玩法相邻。该算法不得新增前端业务真相或绕过公开作品 read model。断点事实统一走 `platformEntryResponsive.ts``usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页拿到推荐作品列表后必须预加载每个作品的卡片封面、主封面和玩法兜底封面;启动或切换作品时先展示当前带玩法标签和标题的作品卡面遮罩,嵌入 runtime 在卡面下层加载,不得再从卡面闪切到另一层单独纯封面图。作品切换提交后,当前 runtime 遮罩接手已在屏幕上的卡面时必须瞬时贴合,不允许再执行“卡面到同一卡面”的淡入或重绘过渡;推荐页 runtime 必须通过统一 `ready` 门控等待对应运行态 run / profile、lazy runtime 组件和 runtime DOM 内图片资源都准备好,且必须持续观察后续新增图片、内联 `background-image` 和换签中的资源标记,不能只在首次挂载时扫描主图或封面;`ready` 返回 `true` 后才由外层放开游戏画面并只让卡面遮罩渐隐。遮罩层级必须高于并隔离下层 runtime防止运行态 HUD、canvas 或高 `z-index` 子层穿透到封面上ready 前不展示“加载中”文案,但封面内必须保留无文案加载动效或进度条,避免用户误以为卡片损坏,也不得把未准备好的运行态直接暴露给用户。切换推荐作品时,如果上一条作品的启动请求、退出收口或目标玩法 busy 状态尚未结束,应继续显示当前作品卡面遮罩并等待下一轮自动启动;只有目标作品启动明确失败时,才显示“作品暂时无法进入,请稍后再试。”这类失败态。推荐页内拼图通关后的同 run 相似作品推进不视为推荐作品切换,不能重新显示启动封面;如果需要跨公开作品进入下一关,则必须走推荐页统一切卡入口,不能复用拼图 runtime 的跨作品 handoff也不能直接把当前 run 改写到另一个作品,`activeRecommendEntryKey` 只能由推荐页统一选择下一作品后更新。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer但请求选项必须是 local auth impact避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token并只把它作为局部请求头传给运行态客户端不写入全局登录态、不触发 refresh也不把匿名流量伪装成普通用户。当前覆盖矩阵为跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求都必须继续按该身份分流公开读取入口仍可匿名读取创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。推荐 runtime 的 `none` / `background` / `runtime-guest` 请求计划和拼图 `default` / `isolated` runtime auth mode 由 `platformRecommendRuntimeAuthModel.ts` 统一判定,平台壳只负责读取 token、申请 Runtime Guest Token 和传递 request options。推荐 runtime 自动启动只由 `platformPublicGalleryFlow.ts` 输出 `noop` / `clear` / `start(entry)` 决策,平台壳只执行清空 state 或启动指定作品。
## 敲木鱼
@@ -161,7 +205,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
创作输入固定为:
1. `敲什么`:敲击物单图资产槽位。默认模板使用内置透明 PNG `/wooden-fish/default-hit-object.png` 作为 `bundled-default` 敲击物资产,避免默认关键词被重新语义化改形;用户输入自定义关键词或上传参考图时,后端必须以默认木鱼图作为基础结构和画风参考,使用 image2 生成最终敲击物图案,上传图只作为新主题参考,不直接进入运行态。自定义 `compile-draft` / `regenerate-hit-object` 必须完成 image2 -> OSS 私有对象 -> asset object 登记和绑定后,再由 `api-server` 注入真实 `hitObjectAsset.imageSrc`,不能只写 `/generated-wooden-fish-assets/...` 占位路径,也不能接受前端请求自带的 `hitObjectAsset` 短路生成。
2. `敲击音效`:音频资产槽位,当前创作阶段只支持用户上传或麦克风录制;未提供音频时统一写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。提示词生成音效入口临时关闭,通用 `/api/creation/audio/sound-effect` 对木鱼 `hit_sound` 目标也返回 `410 Gone``hitSoundPrompt` 只作为历史兼容字段保留,不参与当前创作流程,也不得由 `spacetime-client` 合成假音频路径。
2. `敲击音效`:音频资产槽位,当前创作阶段只支持用户上传或麦克风录制;音频面板必须在前端明确显示 `最长 1 秒`。选择文件或录音结束后,前端只在浏览器本地解码并生成待提交音频对象,不在选择阶段请求 `/api/assets/direct-upload-tickets`。上传和录音统一裁掉前后声音过小片段,裁切后仍超过 1 秒时提示错误且不写入表单状态;有效音频按浏览器端近似算法做响度平衡,目标为 GY/T 377-2023 口径下的 `-15 LKFS`,并做峰值保护后重新编码为可上传 Blob。用户点击 `生成` 时才把处理后的音频直传 OSS、确认 `asset_object`,创作 session/action 只提交 `hitSoundAsset.assetObjectId``audioSrc` 和对象 key 等轻量字段;未提供音频时统一写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。提示词生成音效入口临时关闭,通用 `/api/creation/audio/sound-effect` 对木鱼 `hit_sound` 目标也返回 `410 Gone``hitSoundPrompt` 只作为历史兼容字段保留,不参与当前创作流程,也不得由 `spacetime-client` 合成假音频路径。后端对敲木鱼创作 JSON 的放宽 body limit 仅用于兼容旧小程序 Data URL 请求,不作为新链路输入方式。
3. `功德有什么`:最多 8 条飘字,创作态首屏只保留一个默认词条 `幸运`,其下提供加号格继续追加词条;创作态只保存词条名,运行态飘字展示时再追加 `+1`。运行态顶部总数卡采用品牌化徽标样式,子项计数器预置展示在可展开面板中,未出现词条初始值为 0。
4. `作品标题 / 作品简介 / 主题标签`:不再放在创作工作台首屏,改为生成草稿后的结果页补录区,提交试玩或发布前必须先写回当前作品信息。主题标签编辑样式对齐拼图结果页的胶囊标签编辑器。
@@ -173,6 +217,34 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='wooden-fish'``WF-*` 公开作品号识别敲木鱼作品;公开列表应走 `wooden_fish_gallery_card_view` 订阅缓存,公开详情或运行态启动时卡片摘要不足则补读完整 work profile。
## 拼消消
对外名称:拼消消。工程域与 `playId``puzzle-clear`。公开作品码前缀:`PC-`。当前按新增玩法 SOP 接入完整公开闭环,不复用拼图运行态规则本体。
链路为:
```text
创作入口 -> 轻表单工作台 -> 生成过程页 -> 结果页 -> 试玩 -> 发布 -> 统一作品详情 -> 正式运行态
```
工作台字段固定为作品标题、简介、主题词、场地底图主题词 `boardBackgroundPrompt`、中央场地底图槽位、是否 AI 生成底图。中央场地底图必须复用 `CreativeImageInputPanel`,支持上传、历史图和 AI 重绘;若用户填写 `boardBackgroundPrompt`AI 生成底图只读取该字段,字段为空时才回退读取 `themePrompt`;用户上传底图时不再用主题词重写该资产。中央场地底图的字段名保留平台口径,但实际语义是玩家逐步消除清空棋盘后露出的主题目标图,生成尺寸必须与中央棋盘一致,按 1:1 正方形出图prompt 必须强绑定主题、画面精致、强表现力并一眼体现主题,不再要求“画面干净”或“适合作为卡牌棋盘底图”。运行态必须把中央场地底图作为棋盘内部静态底图使用,不能降级成整页氛围背景;卡牌消除后产生的空位和拖拽源位应露出该棋盘底图。卡面背面背景 v1 使用默认占位图,不作为创作者配置项。规则参数不开放编辑:单关 `6x6`、每局 10 分钟、35 次目标消除、形状解锁、防死局发牌和半锁定规则均由后端规则集固定。
素材生成使用拼消消专用编排,但必须复用 `platform-image`、VectorEngine `gpt-image-2`、OSS、`asset_object`、换签和失败审计。素材目标是 4 张 `1024x1536` 竖版工作表,每张后台按 `4 列 x 6 行` 裁切,每格 `256x256`;服务端从工作表切出总计 95 个 1x1 卡牌碎片,再合成一张 `10x10 / 2560x2560` 最终 atlas。复合图案组总数固定为 35形状配比固定为 `1x2=23``1x3=5``2x2=4``2x3=3`。服务端先预排每个复合图案组的 sheet 布局、最终 atlas 坐标和形状,再按坐标切成 1x1 卡牌碎片作为运行态素材sheet 生图 prompt 只能要求复合图案组可按后台 4x6 均等切成 1x1 方形小份,不能让模型在小图案上绘制切分线、边框、网格线、编号或裁切参考线。当前只有单关,同关内复合图案不重复。草稿编译和发布都必须使用 api-server 已持久化的真实 atlas / card assets拒绝缺失、空对象键或 `placeholder` 占位素材,不允许 `spacetime-client` 或 SpacetimeDB 侧合成临时素材绕过平台图片底座。
运行态规则:
1. 单关固定为 `6x6 / 35次消除`
2. 每局固定 10 分钟;超时只判当前关失败,可重试当前关。
3. 当前关直接出现 `1x2``1x3``2x2``2x3`
4. 开局棋盘随机铺满并保证至少一步可解;补牌后也必须由后端保证至少一步可解。
5. 顶部卡牌准备区按纵列补位,某列有空格时该列卡牌从顶部下落。
6. 非 2 格消除时,补牌不得破坏已完成局部;只有玩家主动交换或撞入才允许打散半锁定拼接组。
7. 正式 runtime 只消费后端 snapshot 与 action 结果;前端负责开局翻转、拖拽、掉落、消除和弹层动画。
拖拽手感必须对齐拼图模板:开局小卡片只翻转一次,交换落位不得重新翻牌;按住后可见卡片立即跟随鼠标或手指,源位置即时留出空槽;放下时被替换卡片要快速飞向对应空位;已完成局部拼接组要以连续整体呈现并可作为整组拖起。拖拽浮层必须挂到页面级 `document.body` portal避免平台壳层 transform 让 `position: fixed``clientX/clientY` 坐标系错位。
8. 正式 `published` run 的终态事件使用 `run-finished``level-failed`,事件结果 JSON 至少包含 `status``level``clears``clearDelta``elapsedMs`,供基础统计与排障回读。
新增阶段为 `puzzle-clear-workspace``puzzle-clear-generating``puzzle-clear-result``puzzle-clear-runtime`;路由为 `/creation/puzzle-clear``/creation/puzzle-clear/generating``/creation/puzzle-clear/result``/runtime/puzzle-clear`。API 命名空间为 `/api/creation/puzzle-clear/*``/api/runtime/puzzle-clear/*`。验证命令见 `docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md``docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`
## 抓大鹅 Match3D
对外名称:`抓大鹅`。工程域:`match3d`
@@ -195,7 +267,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
当前素材生成流水线:
1. 点击生成前弹出泥点确认,草稿生成固定消耗 `10` 泥点
1. 点击生成前弹出泥点确认,草稿初始生成成本来自后台入口契约 `creationTypes[].unifiedCreationSpec.mudPointCost`;抓大鹅完整草稿生成按该值一次性预扣,汪汪声浪初始三张图按该值分摊到三次素材请求,结果页单图重新生成仍按单图资产操作计费
2. 先写入可恢复草稿 profile再执行文本计划、关卡整图生成、三张派生图生成、OSS 上传和素材解析作品摘要在背景、UI spritesheet 或物品 spritesheet 未完整时下发 `generationStatus=generating`,完整后下发 `ready`,草稿完成条件不包含 `backgroundMusic`
3. 首次调用 VectorEngine `gpt-image-2`,无参考图,竖屏 `9:16`,生成完整抓大鹅关卡画面并持久化到 `generatedBackgroundAsset.levelSceneImageSrc/levelSceneImageObjectKey`。提示词必须包含用户主题描述、顶部返回 / 标题倒计时 / 设置按钮、中间与主题匹配且贴横向边缘的容器,以及底部“移出 / 凑齐 / 打乱”三个道具按钮。
4. 关卡整图完成后并发发起三次 `gpt-image-2` 编辑请求,三者都以关卡整图作为参考图:`1K``1:1` 的 UI spritesheet 写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey``1K``9:16` 的背景图写入 `imageSrc/imageObjectKey``2K``1:1` 的物品 spritesheet 写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`
@@ -281,8 +353,8 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
- 结果页:围绕三图槽位展示错误态与已生成结果,只保留单槽重试、重新生成和上传,不再提供一次生成按钮、音频配置入口或排名配置;生成回写 `partial_failed` 时作品架不再显示整卡“生成中”遮罩,由结果页槽位错误承接失败。
- 手动上传:结果页通过平台资产直传 `/api/assets/direct-upload-tickets``/api/assets/objects/confirm` 写入私有资产,再把返回的历史 generated 路径写回草稿配置。
- 发布:结果页确认后必须携带草稿返回的同一个 `workId` 和结果页最终 `publishedSnapshot` 调用 `POST /api/creation/bark-battle/works/publish`SpacetimeDB 发布态的 `config_json` 必须使用该最终快照works summary 若拿到 `publishedSnapshotJson` 也优先使用最终快照映射封面三图。发布成功后先进入统一作品详情页,再由详情页进入正式 runtime缺少 `workId` 的旧草稿状态需要重新生成草稿。
- 作品架Bark Battle 草稿 / 已发布列表优先读取后端 `/works`,但创建、生成完成、保存或发布后的本地摘要必须在后端 read model 尚未回读到同 `workId` 前继续保留;创作中心作品架同时接入 pending shelf 兜底,避免 ready 且三图齐全的草稿在刷新窗口期从“我的草稿 / 已发布”中消失。
- 试玩与正式 runtime草稿试玩使用 `runtimeMode=draft` 和 mock 输入,不写正式 run正式 runtime 使用 `runtimeMode=published`,进入运行态后直接申请真实麦克风权限,授权成功后立刻进入倒计时,启动对局时调用 `POST /api/runtime/bark-battle/works/{workId}/runs` 登记 start run并以返回的 `runtimeConfig` 作为本局前端规则参数;结算时调用 `POST /api/runtime/bark-battle/runs/{runId}/finish` 写入基础统计派生指标;对局会在能量条推到任一侧边界时提前结算并弹出独立结算弹窗,运行态内固定提供返回按钮。
- 作品架Bark Battle 草稿 / 已发布列表优先读取后端 `/works`,但创建、生成完成、保存或发布后的本地摘要必须在后端 read model 尚未回读到同 `workId` 前继续保留;创作中心作品架同时接入 pending shelf 兜底,避免 ready 且三图齐全的草稿在刷新窗口期从“我的草稿 / 已发布”中消失。草稿三图完整性、`pending_assets` / `partial_failed` / `ready` 生成状态归一和作品摘要合并规则统一由 `barkBattleWorkCache.ts` 承接,平台壳只执行读取、刷新与 React state 副作用。
- 试玩与正式 runtime草稿试玩使用 `runtimeMode=draft` 和 mock 输入,不写正式 run正式 runtime 使用 `runtimeMode=published`,进入运行态后直接申请真实麦克风权限,授权成功后立刻进入倒计时,启动对局时调用 `POST /api/runtime/bark-battle/works/{workId}/runs` 登记 start run并以返回的 `runtimeConfig` 作为本局前端规则参数;结算时调用 `POST /api/runtime/bark-battle/runs/{runId}/finish` 写入基础统计派生指标;对局会在能量条推到任一侧边界时提前结算并弹出独立结算弹窗,运行态内固定提供返回按钮。发布快照拼装、发布回包缺图时沿用草稿图,以及草稿 / 已发布作品进入前端 runtime 前的 `BarkBattlePublishedConfig` 映射也统一由 `barkBattleWorkCache.ts` 提供,缺失 `publishedAt` 时仍按 `updatedAt` 兜底。
支持的创作者可替换内容:

View File

@@ -1,6 +1,6 @@
# 拼图生成页进度口径
更新时间:`2026-05-24`
更新时间:`2026-06-02`
## 目标
@@ -8,14 +8,14 @@
## 落地口径
- 总进度和当前步骤内百分比可以按已耗时平滑增长,但进入生成页的初始帧必须`0%` 开始,非完成态最多停在 `98%`
- 未收到首个真实里程碑前,页面仍停留在当前步骤,总进度在 `0-88` 区间内平滑推进;收到 `88/94/96` 里程碑后,分别在 `88-94``94-96``96-98` 区间内推进,避免步骤不跳时总进度也停死
- 后端 `progressPercent` 低于 `88` 只作为当前会话状态记录,不得把生成页阶段推到首个图片里程碑;低于首个里程碑时页面仍按当前视图进入时间从 `0%` 平滑展示
- 总进度和当前步骤内百分比可以按已耗时平滑增长;新发起的生成初始帧从 `0%` 开始,恢复持久化生成中草稿时必须按后端 session / 作品摘要时间戳推导已耗时,不能每次重新从 `0 秒` 起算。非完成态最多停在 `98%`
- 后端 `progressPercent``88/94/96` 只用于切换当前真实步骤,不得直接作为总进度地板;总进度应按已完成步骤权重加当前步骤内假进度推导,避免恢复或轮询后瞬间跳到 `88%`
- 后端 `progressPercent` 低于 `88` 只作为当前会话状态记录,不得把生成页阶段推到首个图片里程碑,也不得抬高首帧总进度
- 步骤状态以真实阶段为准:`phase` / 后端会话进度 / 最终完成或失败回包才允许跨步。
- 拼图生成页恢复持久化 `generationStatus=generating` 草稿时,展示进度使用“进入生成页的当前时间”作为 `startedAtMs`;不得再用作品摘要 `updatedAt` 推导展示起点,避免刷新后首帧直接跳到 `80%+`
- 拼图和抓大鹅等生成页从作品架 / 刷新恢复进入时,前端应把展示态生成状态重基准到进入页面的当前时间;后台 session 的 `progressPercent` 与历史里程碑只保留为状态事实,不得直接作为首帧总进度
- 拼图和抓大鹅等生成页从作品架 / 刷新恢复进入时,前端应优先使用后端 session `updatedAt` 或作品摘要 `updatedAt` 作为展示态 `startedAtMs`,保证已耗时与后端生成时间对齐;后台 session 的 `progressPercent` 只负责真实步骤推进,不直接决定总进度百分比
- 生成失败时,生成页冻结为失败 / 重试状态;同一浏览器会话内返回草稿 Tab 时,失败草稿必须继续出现在作品架,且本地失败 notice 要覆盖后端仍可能短暂返回的 `generationStatus=generating` 摘要,不能继续显示“生成中”
- 当前步骤未完成时,后续步骤保持待处理;即使预计时间耗尽,也只能让当前步骤内部进度接近或达到上限,不能自动完成后续步骤。
- 抓大鹅等非拼图小游戏的生成页也遵守初始帧 `0%`:没有后端资产计数时,当前步骤内假进度按玩法预计等待总时长`0` 平滑推进,不使用固定 `0.5` 这类常量起步。
- 抓大鹅等非拼图小游戏的生成页也遵守同一恢复口径:没有后端资产计数时,当前步骤内假进度按玩法预计等待总时长平滑推进,不使用固定 `0.5` 这类常量起步,也不在未完成时显示 `100%`
- 步骤卡片只展示标题和进度,不展示详细描述。
- 生成拼图首图步骤按 4 分钟预估;完整 AI 重绘路径总预计时长为 448 秒,上传图且关闭 AI 重绘时跳过首图生成,仍为 208 秒。
@@ -25,5 +25,6 @@
- `src/services/miniGameDraftGenerationProgress.test.ts` 覆盖后端 `progressPercent < 88` 时不会抬高进入生成页的初始总进度。
- `src/services/miniGameDraftGenerationProgress.test.ts` 覆盖抓大鹅等非拼图生成页初始总进度为 `0%`
- `src/components/CustomWorldGenerationView.test.tsx` 覆盖步骤详情不在生成页渲染。
- `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"` 覆盖刷新后继续生成中拼图 / 抓大鹅草稿不会继承旧 `updatedAt` 导致总进度首帧过高
- `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"` 覆盖刷新后继续生成中拼图 / 抓大鹅草稿按后端时间戳恢复,且不会因后端里程碑直接跳到 `88%``100%`
- `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed parallel puzzle generations"` 覆盖失败后的 pending 拼图草稿仍留在作品架,并且不再显示“生成中”。
- 文档主图谱的拼图章节同步保留该口径。

View File

@@ -1,6 +1,6 @@
# 生成页圆环布局口径
更新时间:`2026-05-30`
更新时间:`2026-06-02`
## 目标
@@ -17,7 +17,7 @@
- 在窄屏下,预计等待 / 已耗时信息卡放到圆环下方两列排布;`sm` 及以上视口再回到圆环左右悬浮,避免左右悬浮卡和圆环共同超过视口宽度。
- 总进度标题和百分比数字必须显式高于 SVG 圆环层级渲染,避免被圆环边缘压住;圆环本身只做背景层,不抢文字层。
- 总进度标题和百分比数字要比圆环再上移一点,当前内容区上边距以 `pt-[2%]` 为准,桌面端可进一步微调到 `sm:pt-[1.5%]`,确保数字不与进度条弧线重合。
- 从作品架或刷新后的持久化生成中草稿进入生成页时,前端必须重置“展示态 startedAtMs”为进入生成页的当前时间;后端 `progressPercent` 只用于后续真实步骤推进,不得参与首帧总进度展示,避免恢复生成页首帧直接显示 `80%+`
- 从作品架或刷新后的持久化生成中草稿进入生成页时,前端必须按后端 session `updatedAt` 或作品摘要 `updatedAt` 恢复展示态 `startedAtMs`,保证“已耗时”不因重新进入页面而清零;后端 `progressPercent` 只用于真实步骤推进,不得直接作为总进度地板,避免恢复生成页首帧直接显示 `88%``100%`
- 生成页只展示半透明“当前步骤”单卡,卡片内只保留步骤名称、步骤状态、步骤进度条和轻量加载指示;“当前步骤”标签使用 `10px-11px`,步骤名称使用 `14px-15px`,状态使用 `11px-12px`,不再渲染步骤列表或步骤详情。
- 当前作品信息放在圆角信息卡中,标题固定使用 `13px`;有结构化字段时以两列信息块展示,例如“题材 / 素材数量”,无结构化字段时才展示纯文本设定。
- 汪汪声浪生成页 `BarkBattleGeneratingView` 也必须对齐同一垂直布局,不再继续展示三行槽位列表或左右分栏抢占主视觉。

View File

@@ -1,6 +1,6 @@
# 当前产品与工程约束
更新时间:`2026-05-15`
更新时间:`2026-06-05`
## 项目定位
@@ -45,7 +45,13 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
2. `login-options` 为空、失败、只返回 `phone` 或只返回 `password` 时,前端仍要同时展示验证码登录页签和密码登录页签;短信能力真实可用性由发送验证码接口返回结果表达。
3. 登录弹窗继续复用现有独立 modal 和页签结构,不在页面中新增功能说明类文案,也不把邀请码输入放回登录面板。
4. 微信小程序 `web-view` 外壳默认不预登录,首次进入直接打开 H5并保持与 Web 端一致的未登录状态;只有 H5 触发 `openLoginModal` / `requireAuth` 等受保护入口时,才跳转小程序原生授权态。
5. 小程序内需要登录时不展示 H5 登录弹窗,也不走手输手机号 / 短信验证码流程;统一通过原生 `button open-type="getPhoneNumber"` 获取微信手机号授权,再调用 `/api/auth/wechat/miniprogram-login` `/api/auth/wechat/bind-phone` 换取系统登录态。
5. 小程序内需要登录时不展示 H5 登录弹窗,也不走手输手机号 / 短信验证码流程;统一通过 `wx.login` 获取微信登录 code 并调用 `/api/auth/wechat/miniprogram-login` 完成快捷登录。若该接口返回 `created=true`,或返回用户昵称仍是手机号、公开陶泥号、“微信旅人”等默认展示值,才展示原生 `input type="nickname"` 补充微信昵称并再次调用 `/api/auth/wechat/miniprogram-login` 写入 `displayName`。若后端返回 `pending_bind_phone`,再通过原生 `button open-type="getPhoneNumber"` 获取微信手机号授权并调用 `/api/auth/wechat/bind-phone` 换取系统登录态。
6. 小程序外壳注入到 H5 URL 的 `clientType``clientRuntime``miniProgramEnv` 是宿主上下文H5 内部 `pushState` / 阶段导航必须跨页面保留,避免登录和充值误判为普通浏览器;首点时微信 JS bridge 可能尚未就绪,前端还需用 `MicroMessenger + miniProgram` User-Agent 作为小程序识别兜底。
7. 小程序 `web-view` 页必须启用好友分享与朋友圈分享,分享目标固定回到 `pages/web-view/index`,不把 H5 当前 URL 作为不受控启动参数传回小程序页。
8. 小程序 `web-view` 外壳运行时通过 `wx.getAccountInfoSync().miniProgram.envVersion` 自动识别版本:线上版 `release` 使用 `www.genarrative.world`,体验版 `trial` 与开发版 `develop` 使用 `dev.genarrative.world`;传给后端的 `x-mini-program-env` 分别为 `release``trial``dev`
9. 账号信息面板只展示 `账号信息` 标题;绑定手机号和绑定微信以紧凑模块展示当前绑定状态,已绑定手机号展示完整手机号,已绑定微信优先展示微信平台实际返回并由后端保存的 `wechatDisplayName`。小程序 `jscode2session` 不能直接返回微信昵称或个人微信号,只能稳定拿到当前小程序维度的 `openid`,并在满足微信开放平台条件时拿到 `unionid`;小程序昵称来自快捷登录后按需展示的原生 `input type="nickname"` 提交的 `displayName`。后端下发 `wechatAccount` 作为绑定账号标识,前端在没有真实昵称时展示微信账号尾号,不展示裸“已绑定”。换绑入口放在对应模块右上角,退出登录和退出全部设备固定放在面板内容最底部。
10. H5 登录态从未登录变为已登录,或从已登录变为未登录后,必须刷新当前页面一次,确保推荐运行态、作品架、个人缓存和私有 query 都按新身份重新初始化;普通 access token 续期、账号资料更新和同一登录态内的设置变化不得触发整页刷新。
11. 同一账号允许多端同时在线。新增登录和单设备退出只影响对应 refresh session不得提升账号级 `tokenVersion` 让其它设备的 access token 失效;只有“退出全部设备”、修改密码、重置密码等明确安全动作才吊销全端 refresh session 并提升 `tokenVersion`
## 账户与充值
@@ -91,13 +97,13 @@ server-rs + Axum + SpacetimeDB
3. 点击按钮弹出独立面板时,必须弹出 dialog / drawer / modal不要在当前面板下方展开内容。
4. 优先复用现有系统、页面、组件和弹层,不因一次需求新建平行系统。
5. 游戏式页面要防止文字、按钮、HUD、底部 dock、输入法和画布互相遮挡。
6. 平台根壳已处理移动端输入法聚焦:输入法弹出时保持画布稳定高度,用偏移聚焦输入框,业务组件不要重复注册全局键盘适配。
6. 平台根壳已处理移动端输入法聚焦:输入法弹出时保持画布稳定高度,只记录键盘状态、隐藏底部 dock 并补齐浅色暴露背景,不再全局上移平台壳;业务组件不要重复注册全局键盘适配。
7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。
8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。
9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal至少支持玩法类型过滤与排序切换筛选结果为空时显示空状态不把筛选内容展开在当前列表下方。
10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;页面不再提供独立存档按钮入口,也不在底部保留旧的填邀请码次级入口。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。
11. “我的”页每日任务卡必须展示后端 `/api/profile/tasks` 返回的当前任务摘要,包括奖励泥点数进度领取 / 去完成 / 已完成状态任务领取成功后,卡片摘要必须跟随返回的任务中心数据同步刷新,不能继续硬编码 `0 / 1` 或只更新弹窗内任务列表。
12. “我的”页泥点、游戏时长、已玩游戏数量三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px``14px` 的普通 UI 字号区间,避免展示级字号挤压内容。
10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、通用设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位。页面不再提供独立存档按钮入口,也不在底部保留旧的填邀请码次级入口;主题设置、账号与安全只作为通用设置弹窗下一级入口,不在“我的”页外层单独占行。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。
11. “我的”页每日任务卡必须展示后端 `/api/profile/tasks` 返回的当前任务摘要,包括奖励泥点数进度;外层任务卡不展示“去完成”等左右侧行动按钮,领取 / 去完成 / 已完成状态只在任务中心弹窗内表达。任务领取成功后,卡片摘要必须跟随返回的任务中心数据同步刷新,不能继续硬编码 `0 / 1` 或只更新弹窗内任务列表。用户停留在“我的”页跨过北京时间 0 点时,前端必须非阻断刷新登录态以补齐 `daily_login` 埋点,再重拉任务中心,避免继续展示上一自然日已领取状态。
12. “我的”页泥点余额、累计游玩、已玩游戏三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px``14px` 的普通 UI 字号区间,避免展示级字号挤压内容。
13. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、每日任务、五项常用功能和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。
14. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗。
15. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css``--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案。

View File

@@ -10,12 +10,11 @@ pipeline {
}
environment {
GIT_REMOTE_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
CARGO_HOME = '/home/dsk/.cache/genarrative-jenkins/api-server/cargo-home'
CARGO_TARGET_DIR = '/home/dsk/.cache/genarrative-jenkins/api-server/cargo-target/prod-release'
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
GENARRATIVE_API_CACHE_ROOT = 'caches/genarrative-jenkins/api-server'
CARGO_INCREMENTAL = '0'
RUSTC_WRAPPER = 'sccache'
SCCACHE_DIR = '/home/dsk/.cache/genarrative-jenkins/api-server/sccache'
SCCACHE_CACHE_SIZE = '30G'
}
@@ -33,23 +32,36 @@ pipeline {
stages {
stage('Checkout') {
steps {
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [
[$class: 'CleanBeforeCheckout'],
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
],
userRemoteConfigs: [[url: "${GIT_REMOTE_URL}", refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
])
script {
def checkoutFromRemote = { String remoteUrl ->
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [
[$class: 'CleanBeforeCheckout'],
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
],
userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
])
}
try {
checkoutFromRemote(env.GIT_REMOTE_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
} catch (error) {
echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
}
}
sh '''
bash -lc '
set -euo pipefail
chmod +x scripts/jenkins-checkout-source.sh
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
COMMIT_HASH="${COMMIT_HASH:-}" \
GIT_REMOTE_URL="${GIT_REMOTE_URL}" \
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
scripts/jenkins-checkout-source.sh
'
@@ -66,10 +78,17 @@ pipeline {
sh '''
bash -lc '
set -euo pipefail
api_cache_root="${GENARRATIVE_API_CACHE_ROOT:-caches/genarrative-jenkins/api-server}"
if [[ "${api_cache_root}" != /* ]]; then
api_cache_root="${HOME:?HOME 不能为空}/${api_cache_root}"
fi
export CARGO_HOME="${api_cache_root}/cargo-home"
export CARGO_TARGET_DIR="${api_cache_root}/cargo-target/prod-release"
export SCCACHE_DIR="${api_cache_root}/sccache"
chmod +x scripts/jenkins-prepare-cargo-env.sh
source scripts/jenkins-prepare-cargo-env.sh
if ! command -v clang >/dev/null 2>&1 || ! command -v lld >/dev/null 2>&1; then
echo "[api-build] 缺少 clang/lld。请先运行 Genarrative-Server-Provision 安装 Linux 构建依赖。" >&2
echo "[api-build] 缺少 clang/lld。请在 genarrative-build 节点预先安装 Linux 构建依赖。" >&2
exit 1
fi
if ! command -v sccache >/dev/null 2>&1; then

View File

@@ -24,7 +24,7 @@ pipeline {
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: '生产 release 根目录')
string(name: 'CURRENT_LINK', defaultValue: '/opt/genarrative/current', description: '当前版本软链接')
string(name: 'SERVICE_NAME', defaultValue: 'genarrative-api.service', description: 'systemd 服务名')
string(name: 'HEALTH_URL', defaultValue: 'http://127.0.0.1:8082/healthz', description: '本机健康检查地址')
string(name: 'HEALTH_URL', defaultValue: 'http://127.0.0.1:8082/readyz', description: '本机 readiness 检查地址')
string(name: 'API_ENV_FILE', defaultValue: '/etc/genarrative/api-server.env', description: 'api-server 环境文件')
string(name: 'DATABASE', defaultValue: 'genarrative-prod', description: 'api-server 连接的 SpacetimeDB database')
string(name: 'SPACETIME_SERVER_URL', defaultValue: 'http://127.0.0.1:3101', description: 'api-server 连接的 SpacetimeDB server URL')
@@ -64,7 +64,7 @@ pipeline {
stage('Checkout Deploy Scripts') {
agent {
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}"
}
steps {
script {
@@ -89,17 +89,12 @@ pipeline {
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
}
}
script {
if (params.COMMIT_HASH?.trim()) {
echo "API 发布脚本 checkout 将忽略上游构建 commit=${params.COMMIT_HASH},改用 ${params.SOURCE_BRANCH ?: 'master'} 最新提交,避免发布阶段回退到旧部署脚本。构建产物仍由 BUILD_NUMBER_TO_DEPLOY 决定。"
}
}
sh '''
bash -lc '
set -euo pipefail
chmod +x scripts/jenkins-checkout-source.sh
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
COMMIT_HASH="" \
COMMIT_HASH="${COMMIT_HASH:-}" \
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
@@ -111,7 +106,7 @@ pipeline {
stage('Fetch Artifact') {
agent {
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}"
}
steps {
copyArtifacts(
@@ -126,7 +121,7 @@ pipeline {
stage('Deploy Api') {
agent {
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}"
}
steps {
sh '''

View File

@@ -1,5 +1,7 @@
pipeline {
agent none
agent {
label 'linux && genarrative-build'
}
options {
disableConcurrentBuilds()

View File

@@ -1,27 +1,3 @@
def runWindowsPowerShell(String scriptName, String scriptBody) {
def scriptPath = ".jenkins-${scriptName}.ps1"
writeFile file: scriptPath, text: scriptBody, encoding: 'UTF-8'
bat label: "PowerShell ${scriptName}", script: """
@echo off
setlocal
set "GENARRATIVE_POWERSHELL=%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
if not exist "%GENARRATIVE_POWERSHELL%" (
echo [jenkins-powershell] powershell.exe not found: %GENARRATIVE_POWERSHELL%
exit /b 1
)
echo [jenkins-powershell] user:
whoami
echo [jenkins-powershell] workspace: %CD%
echo [jenkins-powershell] exe: %GENARRATIVE_POWERSHELL%
if not exist "%CD%\\${scriptPath}" (
echo [jenkins-powershell] script not found: %CD%\\${scriptPath}
exit /b 1
)
"%GENARRATIVE_POWERSHELL%" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "try { \$path = Join-Path (Get-Location).ProviderPath '${scriptPath}'; Write-Host '[jenkins-powershell] script:' \$path; \$text = [System.IO.File]::ReadAllText(\$path, [System.Text.Encoding]::UTF8); Write-Host '[jenkins-powershell] loaded bytes:' ([System.IO.File]::ReadAllBytes(\$path).Length); \$scriptBlock = [ScriptBlock]::Create(\$text); & \$scriptBlock; if (\$LASTEXITCODE -is [int] -and \$LASTEXITCODE -ne 0) { exit \$LASTEXITCODE } } catch { Write-Host '[jenkins-powershell] failed:' \$_.Exception.Message; if (\$_.ScriptStackTrace) { Write-Host \$_.ScriptStackTrace }; exit 1 }"
exit /b %ERRORLEVEL%
"""
}
pipeline {
agent none
@@ -31,26 +7,22 @@ pipeline {
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
}
environment {
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
}
parameters {
choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标development 使用当前 Linux 开发/构建/开发部署 agent')
choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标development 使用 dev 服务器部署 agentrelease 使用正式服务器部署 agent')
booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent')
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送')
booleanParam(name: 'CONFIRM_PROVISION', defaultValue: false, description: '确认执行服务器初始化;未勾选时只允许 dry-run')
booleanParam(name: 'DRY_RUN', defaultValue: true, description: '只打印将执行的服务器初始化命令,不写入系统配置')
string(name: 'SOURCE_GIT_REMOTE_URL', defaultValue: '', description: '部署脚本 Git 来源;必须是目标 agent 可访问的内网/本机 Gitea 地址,不配置公网备用')
string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支')
string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit')
string(name: 'SERVER_NAME', defaultValue: 'genarrative.example.com', description: '证书主域名;也作为 Nginx server_name 的第一个域名')
string(name: 'SERVER_ALIASES', defaultValue: '', description: '可选,额外 Nginx server_name多个用空格或逗号分隔例如 www.genarrative.world')
string(name: 'PROVISION_DOWNLOADS_DIR', defaultValue: 'provision-tool-downloads', description: 'Windows 下载阶段暂存 SpacetimeDB/otelcol 安装包的工作区相对目录')
string(name: 'PROVISION_DOWNLOADS_DIR', defaultValue: 'provision-tool-downloads', description: '目标服务器工作区内暂存 SpacetimeDB/otelcol 安装包的相对目录')
string(name: 'PROVISION_TOOLS_DIR', defaultValue: 'provision-tools', description: '目标机工作区内由已下载安装包生成的工具包目录')
string(name: 'PROVISION_DOWNLOAD_PROXY', defaultValue: '', description: '可选,Windows 下载 SpacetimeDB 和 otelcol-contrib 时使用的代理地址,例如 http://127.0.0.1:7890留空不设置代理')
string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/latest/download', description: 'Windows 下载 SpacetimeDB Linux release tarball 根地址;目标机不访问该地址')
string(name: 'SPACETIME_TARGET_HOST', defaultValue: 'x86_64-unknown-linux-gnu', description: '目标机 SpacetimeDB 预编译包 host tripledevelopment/release Linux amd64 使用默认值')
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_TARGET_HOST', defaultValue: 'x86_64-unknown-linux-gnu', description: 'SpacetimeDB 预编译包 host tripledevelopment/release Linux amd64 使用默认值')
string(name: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir')
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录')
string(name: 'CURRENT_LINK', defaultValue: '/opt/genarrative/current', description: '当前版本软链接')
@@ -64,409 +36,160 @@ pipeline {
}
stages {
stage('Prepare') {
stage('Provision Target') {
agent {
label 'windows'
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}"
}
steps {
script {
if (params.DEPLOY_TARGET == 'release' && !params.CONFIRM_RELEASE_DEPLOY_AGENT) {
error('release provision 需要先配置独立 release 部署 agent并勾选 CONFIRM_RELEASE_DEPLOY_AGENT。')
}
if (!params.DRY_RUN && !params.CONFIRM_PROVISION) {
error('执行服务器初始化前必须勾选 CONFIRM_PROVISION否则请保持 DRY_RUN=true。')
}
if (!params.SERVER_NAME?.trim()) {
error('SERVER_NAME 不能为空。')
}
if (!(params.SERVER_NAME.trim() ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) {
error("SERVER_NAME 只能填写单个域名或 IP不能包含空格、路径或协议: ${params.SERVER_NAME}")
}
def serverAliases = params.SERVER_ALIASES?.trim()
if (serverAliases) {
serverAliases.split(/[,\s]+/).findAll { it }.each { aliasName ->
if (!(aliasName ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) {
error("SERVER_ALIASES 只能填写域名或 IP多个用空格或逗号分隔: ${aliasName}")
stages {
stage('Prepare') {
steps {
script {
if (params.DEPLOY_TARGET == 'release' && !params.CONFIRM_RELEASE_DEPLOY_AGENT) {
error('release provision 需要先配置独立 release 部署 agent并勾选 CONFIRM_RELEASE_DEPLOY_AGENT。')
}
if (!params.DRY_RUN && !params.CONFIRM_PROVISION) {
error('执行服务器初始化前必须勾选 CONFIRM_PROVISION否则请保持 DRY_RUN=true。')
}
if (!params.SERVER_NAME?.trim()) {
error('SERVER_NAME 不能为空。')
}
def sourceGitRemoteUrl = params.SOURCE_GIT_REMOTE_URL?.trim()
if (!sourceGitRemoteUrl) {
error('SOURCE_GIT_REMOTE_URL 不能为空。')
}
def isLocalGitPath = sourceGitRemoteUrl ==~ /^\/[0-9A-Za-z._\/-]+$/
def isLocalGitFileUrl = sourceGitRemoteUrl ==~ /^file:\/\/\/\S+$/
def isPrivateHttpGitUrl = sourceGitRemoteUrl ==~ /^https?:\/\/(localhost|127(?:\.[0-9]{1,3}){3}|10(?:\.[0-9]{1,3}){3}|192\.168(?:\.[0-9]{1,3}){2}|172\.(?:1[6-9]|2[0-9]|3[0-1])(?:\.[0-9]{1,3}){2}|[A-Za-z0-9-]+|[A-Za-z0-9.-]+\.(?:local|lan|internal))(?::[0-9]+)?\/\S+$/
if (!isLocalGitPath && !isLocalGitFileUrl && !isPrivateHttpGitUrl) {
error('Genarrative-Server-Provision 不允许使用公网 Git 仓库SOURCE_GIT_REMOTE_URL 只能是目标 agent 可访问的本机路径、file:/// 地址、localhost/127.0.0.1、RFC1918 内网 HTTP 地址、单标签内网主机名或 .local/.lan/.internal 地址。')
}
env.EFFECTIVE_GIT_REMOTE_URL = sourceGitRemoteUrl
if (!(params.SERVER_NAME.trim() ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) {
error("SERVER_NAME 只能填写单个域名或 IP不能包含空格、路径或协议: ${params.SERVER_NAME}")
}
def serverAliases = params.SERVER_ALIASES?.trim()
if (serverAliases) {
serverAliases.split(/[,\s]+/).findAll { it }.each { aliasName ->
if (!(aliasName ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) {
error("SERVER_ALIASES 只能填写域名或 IP多个用空格或逗号分隔: ${aliasName}")
}
}
}
if (!params.PROVISION_TOOLS_DIR?.trim()) {
error('PROVISION_TOOLS_DIR 不能为空。')
}
if (!(params.PROVISION_TOOLS_DIR.trim() ==~ /^[0-9A-Za-z._\/-]+$/) || params.PROVISION_TOOLS_DIR.startsWith('/') || params.PROVISION_TOOLS_DIR.contains('..') || params.PROVISION_TOOLS_DIR.trim() == '.') {
error("PROVISION_TOOLS_DIR 只能是工作区内的相对目录,不能包含绝对路径或连续点号: ${params.PROVISION_TOOLS_DIR}")
}
if (!params.PROVISION_DOWNLOADS_DIR?.trim()) {
error('PROVISION_DOWNLOADS_DIR 不能为空。')
}
if (!(params.PROVISION_DOWNLOADS_DIR.trim() ==~ /^[0-9A-Za-z._\/-]+$/) || params.PROVISION_DOWNLOADS_DIR.startsWith('/') || params.PROVISION_DOWNLOADS_DIR.contains('..') || params.PROVISION_DOWNLOADS_DIR.trim() == '.') {
error("PROVISION_DOWNLOADS_DIR 只能是工作区内的相对目录,不能包含绝对路径或连续点号: ${params.PROVISION_DOWNLOADS_DIR}")
}
def provisionToolsDir = params.PROVISION_TOOLS_DIR.trim()
def provisionDownloadsDir = params.PROVISION_DOWNLOADS_DIR.trim()
if (provisionToolsDir == provisionDownloadsDir || provisionDownloadsDir.startsWith("${provisionToolsDir}/")) {
error("PROVISION_DOWNLOADS_DIR 不能等于或位于 PROVISION_TOOLS_DIR 内,否则目标机生成工具包时会删除下载缓存: ${provisionDownloadsDir}")
}
def provisionDownloadProxy = params.PROVISION_DOWNLOAD_PROXY?.trim()
if (provisionDownloadProxy && !(provisionDownloadProxy ==~ /^https?:\/\/\S+$/)) {
error("PROVISION_DOWNLOAD_PROXY 只能填写 http:// 或 https:// 开头的代理地址,当前值: ${params.PROVISION_DOWNLOAD_PROXY}")
}
if (!(params.OTELCOL_VERSION?.trim() ==~ /^[0-9]+\.[0-9]+\.[0-9]+$/)) {
error("OTELCOL_VERSION 格式应为 x.y.z: ${params.OTELCOL_VERSION}")
}
if (!(params.SPACETIME_DOWNLOAD_ROOT?.trim() ==~ /^https?:\/\/\S+$/)) {
error('SPACETIME_DOWNLOAD_ROOT 不能为空。')
}
if (!(params.SPACETIME_TARGET_HOST?.trim() ==~ /^[0-9A-Za-z._-]+$/)) {
error("SPACETIME_TARGET_HOST 只能包含字母、数字、点号、下划线和短横线: ${params.SPACETIME_TARGET_HOST}")
}
def nginxMode = params.NGINX_CONFIG_MODE?.trim()
if (!(nginxMode in ['none', 'production-https', 'development-http'])) {
error("NGINX_CONFIG_MODE 只能是 none、production-https 或 development-http当前值: ${params.NGINX_CONFIG_MODE}")
}
if (params.DEPLOY_TARGET == 'release' && nginxMode == 'development-http') {
error('release 目标禁止安装 development-http Nginx 配置;无证书初始化请使用 NGINX_CONFIG_MODE=none。')
}
if (!params.DRY_RUN && nginxMode == 'production-https' && params.SERVER_NAME?.trim() == 'genarrative.example.com') {
error('真实初始化安装 Nginx 配置时必须把 SERVER_NAME 改成真实域名,不能使用 genarrative.example.com 占位值。证书未准备好时请先保持 NGINX_CONFIG_MODE=none。')
}
}
}
if (!params.PROVISION_TOOLS_DIR?.trim()) {
error('PROVISION_TOOLS_DIR 不能为空。')
}
if (!(params.PROVISION_TOOLS_DIR.trim() ==~ /^[0-9A-Za-z._\/-]+$/) || params.PROVISION_TOOLS_DIR.startsWith('/') || params.PROVISION_TOOLS_DIR.contains('..') || params.PROVISION_TOOLS_DIR.trim() == '.') {
error("PROVISION_TOOLS_DIR 只能是工作区内的相对目录,不能包含绝对路径或连续点号: ${params.PROVISION_TOOLS_DIR}")
}
if (!params.PROVISION_DOWNLOADS_DIR?.trim()) {
error('PROVISION_DOWNLOADS_DIR 不能为空。')
}
if (!(params.PROVISION_DOWNLOADS_DIR.trim() ==~ /^[0-9A-Za-z._\/-]+$/) || params.PROVISION_DOWNLOADS_DIR.startsWith('/') || params.PROVISION_DOWNLOADS_DIR.contains('..') || params.PROVISION_DOWNLOADS_DIR.trim() == '.') {
error("PROVISION_DOWNLOADS_DIR 只能是工作区内的相对目录,不能包含绝对路径或连续点号: ${params.PROVISION_DOWNLOADS_DIR}")
}
def provisionToolsDir = params.PROVISION_TOOLS_DIR.trim()
def provisionDownloadsDir = params.PROVISION_DOWNLOADS_DIR.trim()
if (provisionToolsDir == provisionDownloadsDir || provisionDownloadsDir.startsWith("${provisionToolsDir}/")) {
error("PROVISION_DOWNLOADS_DIR 不能等于或位于 PROVISION_TOOLS_DIR 内,否则目标机生成工具包时会删除下载缓存: ${provisionDownloadsDir}")
}
def provisionDownloadProxy = params.PROVISION_DOWNLOAD_PROXY?.trim()
if (provisionDownloadProxy && !(provisionDownloadProxy ==~ /^https?:\/\/\S+$/)) {
error("PROVISION_DOWNLOAD_PROXY 只能填写 http:// 或 https:// 开头的代理地址,当前值: ${params.PROVISION_DOWNLOAD_PROXY}")
}
if (!(params.OTELCOL_VERSION?.trim() ==~ /^[0-9]+\.[0-9]+\.[0-9]+$/)) {
error("OTELCOL_VERSION 格式应为 x.y.z: ${params.OTELCOL_VERSION}")
}
if (!(params.SPACETIME_DOWNLOAD_ROOT?.trim() ==~ /^https?:\/\/\S+$/)) {
error('SPACETIME_DOWNLOAD_ROOT 不能为空。')
}
if (!(params.SPACETIME_TARGET_HOST?.trim() ==~ /^[0-9A-Za-z._-]+$/)) {
error("SPACETIME_TARGET_HOST 只能包含字母、数字、点号、下划线和短横线: ${params.SPACETIME_TARGET_HOST}")
}
def nginxMode = params.NGINX_CONFIG_MODE?.trim()
if (!(nginxMode in ['none', 'production-https', 'development-http'])) {
error("NGINX_CONFIG_MODE 只能是 none、production-https 或 development-http当前值: ${params.NGINX_CONFIG_MODE}")
}
if (params.DEPLOY_TARGET == 'release' && nginxMode == 'development-http') {
error('release 目标禁止安装 development-http Nginx 配置;无证书初始化请使用 NGINX_CONFIG_MODE=none。')
}
if (!params.DRY_RUN && nginxMode == 'production-https' && params.SERVER_NAME?.trim() == 'genarrative.example.com') {
error('真实初始化安装 Nginx 配置时必须把 SERVER_NAME 改成真实域名,不能使用 genarrative.example.com 占位值。证书未准备好时请先保持 NGINX_CONFIG_MODE=none。')
}
}
}
}
stage('Download Provision Tool Archives') {
agent {
label 'windows'
}
steps {
script {
runWindowsPowerShell('server-provision-tool-downloads', '''
$ErrorActionPreference = 'Stop'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$downloadsDir = if ($env:PROVISION_DOWNLOADS_DIR) { $env:PROVISION_DOWNLOADS_DIR } else { 'provision-tool-downloads' }
$otelVersion = if ($env:OTELCOL_VERSION) { $env:OTELCOL_VERSION } else { '0.151.0' }
$prepareOtel = if ($env:ENABLE_OTELCOL) { $env:ENABLE_OTELCOL } else { 'true' }
$otelRoot = if ($env:OTELCOL_DOWNLOAD_ROOT) { $env:OTELCOL_DOWNLOAD_ROOT.TrimEnd('/') } else { 'https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download' }
$spacetimeDownloadRoot = if ($env:SPACETIME_DOWNLOAD_ROOT) { $env:SPACETIME_DOWNLOAD_ROOT.TrimEnd('/') } else { 'https://github.com/clockworklabs/SpacetimeDB/releases/latest/download' }
$spacetimeTargetHost = if ($env:SPACETIME_TARGET_HOST) { $env:SPACETIME_TARGET_HOST } else { 'x86_64-unknown-linux-gnu' }
$downloadProxy = if ($env:PROVISION_DOWNLOAD_PROXY) { $env:PROVISION_DOWNLOAD_PROXY } else { '' }
$workspace = (Get-Location).ProviderPath
if ([System.IO.Path]::IsPathRooted($downloadsDir)) {
throw "[prepare-provision-downloads] PROVISION_DOWNLOADS_DIR 只能是工作区内相对路径: ${downloadsDir}"
stage('Checkout Provision Files') {
steps {
script {
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [
[$class: 'CleanBeforeCheckout'],
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
],
userRemoteConfigs: [[url: env.EFFECTIVE_GIT_REMOTE_URL, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
])
}
$downloadsDir = Join-Path $workspace $downloadsDir
Write-Host "[prepare-provision-downloads] Windows workspace: ${workspace}"
Write-Host "[prepare-provision-downloads] download dir: ${downloadsDir}"
if (Test-Path -LiteralPath $downloadsDir) {
Write-Host "[prepare-provision-downloads] 复用已有下载目录: ${downloadsDir}"
} else {
New-Item -ItemType Directory -Force -Path $downloadsDir | Out-Null
Write-Host "[prepare-provision-downloads] 已创建下载目录: ${downloadsDir}"
}
if ($downloadProxy) {
$env:HTTP_PROXY = $downloadProxy
$env:HTTPS_PROXY = $downloadProxy
$env:ALL_PROXY = $downloadProxy
Write-Host "[prepare-provision-downloads] 已配置 Windows 下载代理: $($downloadProxy -replace '://.*', '://***')"
}
function Get-GithubReleaseAssetDigest {
param(
[Parameter(Mandatory=$true)][string]$Repository,
[Parameter(Mandatory=$true)][string]$ReleaseSelector,
[Parameter(Mandatory=$true)][string]$AssetName
)
$request = @{
Uri = "https://api.github.com/repos/${Repository}/${ReleaseSelector}"
Headers = @{
Accept = 'application/vnd.github+json'
'User-Agent' = 'Genarrative-Server-Provision'
}
ErrorAction = 'Stop'
}
if ($downloadProxy) {
$request.Proxy = $downloadProxy
}
Write-Host "[prepare-provision-downloads] 查询 GitHub digest: repo=${Repository} release=${ReleaseSelector} asset=${AssetName}"
$release = Invoke-RestMethod @request
$asset = $release.assets | Where-Object { $_.name -eq $AssetName } | Select-Object -First 1
if (-not $asset) {
throw "[prepare-provision-downloads] GitHub release 未找到资产: ${Repository}/${AssetName}"
}
if (-not $asset.digest) {
throw "[prepare-provision-downloads] GitHub release 未返回 digest: ${Repository}/${AssetName}"
}
Write-Host "[prepare-provision-downloads] GitHub digest ${AssetName}: $($asset.digest)"
return $asset.digest
}
function Get-FileDigest {
param(
[Parameter(Mandatory=$true)][string]$Path,
[Parameter(Mandatory=$true)][string]$Algorithm
)
$fileHash = Get-FileHash -Algorithm $Algorithm -LiteralPath $Path
return $fileHash.Hash.ToLowerInvariant()
}
function Test-DownloadDigestMatch {
param(
[Parameter(Mandatory=$true)][string]$Path,
[Parameter(Mandatory=$true)][string]$ExpectedDigest
)
$parts = $ExpectedDigest.Split(':', 2)
if ($parts.Length -ne 2) {
throw "[prepare-provision-downloads] 无法解析 GitHub digest: ${ExpectedDigest}"
}
$algorithm = $parts[0].Trim().ToLowerInvariant()
$expectedHash = $parts[1].Trim().ToLowerInvariant()
if ($algorithm -ne 'sha256') {
throw "[prepare-provision-downloads] 暂不支持的 GitHub digest 算法: ${algorithm}"
}
$localHash = Get-FileDigest -Path $Path -Algorithm 'SHA256'
return $localHash -eq $expectedHash
}
function Invoke-ProvisionDownload {
param(
[Parameter(Mandatory=$true)][string]$Label,
[Parameter(Mandatory=$true)][string]$Url,
[Parameter(Mandatory=$true)][string]$Output,
[string]$ExpectedDigest = ''
)
if ($ExpectedDigest) {
if (Test-Path -LiteralPath $Output) {
if (Test-DownloadDigestMatch -Path $Output -ExpectedDigest $ExpectedDigest) {
$existingItem = Get-Item -LiteralPath $Output
Write-Host "[prepare-provision-downloads] 已存在且校验一致,跳过下载: ${Label} bytes=$($existingItem.Length) path=${Output}"
return
}
Write-Host "[prepare-provision-downloads] 已存在但校验不一致,重新下载: ${Label} path=${Output}"
}
}
Write-Host "[prepare-provision-downloads] 下载 ${Label}: ${Url}"
$tempOutput = "${Output}.download"
if (Test-Path -LiteralPath $tempOutput) {
$tempItem = Get-Item -LiteralPath $tempOutput
if ($ExpectedDigest -and $tempItem.Length -gt 0 -and (Test-DownloadDigestMatch -Path $tempOutput -ExpectedDigest $ExpectedDigest)) {
Move-Item -LiteralPath $tempOutput -Destination $Output -Force
$finalItem = Get-Item -LiteralPath $Output
Write-Host "[prepare-provision-downloads] 已复用校验通过的临时下载: ${Label} bytes=$($finalItem.Length) path=${Output}"
return
}
if ($tempItem.Length -gt 0) {
Write-Host "[prepare-provision-downloads] 发现未完成临时文件,后续尝试断点续传: ${Label} bytes=$($tempItem.Length) path=${tempOutput}"
} else {
Remove-Item -LiteralPath $tempOutput -Force
}
}
$curl = Get-Command curl.exe -ErrorAction SilentlyContinue
$maxAttempts = 8
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$resumeBytes = 0
if (Test-Path -LiteralPath $tempOutput) {
$resumeBytes = (Get-Item -LiteralPath $tempOutput).Length
}
try {
if ($curl) {
$arguments = @('-fL', '--retry', '3', '--retry-delay', '3', '--retry-all-errors', '--connect-timeout', '30', '--speed-time', '60', '--speed-limit', '1024')
if ($resumeBytes -gt 0) {
$arguments += @('-C', '-')
Write-Host "[prepare-provision-downloads] curl 断点续传 ${Label}: attempt=${attempt}/${maxAttempts} resumeBytes=${resumeBytes}"
} else {
Write-Host "[prepare-provision-downloads] curl 下载 ${Label}: attempt=${attempt}/${maxAttempts}"
}
$arguments += @('-o', $tempOutput)
if ($downloadProxy) {
$arguments += @('--proxy', $downloadProxy)
}
$arguments += $Url
& $curl.Source @arguments
$exitCode = $LASTEXITCODE
if ($exitCode -ne 0) {
$currentBytes = if (Test-Path -LiteralPath $tempOutput) { (Get-Item -LiteralPath $tempOutput).Length } else { 0 }
Write-Host "[prepare-provision-downloads] curl 下载未完成: ${Label}, attempt=${attempt}/${maxAttempts}, exit=${exitCode}, tempBytes=${currentBytes}"
if ($attempt -lt $maxAttempts) {
Start-Sleep -Seconds ([Math]::Min(30, 3 * $attempt))
continue
}
throw "[prepare-provision-downloads] curl 下载失败: ${Label}, exit=${exitCode}, temp=${tempOutput}"
}
} else {
Write-Host "[prepare-provision-downloads] Invoke-WebRequest 下载 ${Label}: attempt=${attempt}/${maxAttempts}"
if ($resumeBytes -gt 0) {
Write-Host "[prepare-provision-downloads] Invoke-WebRequest 不支持断点续传,删除临时文件后重新下载: ${Label}, bytes=${resumeBytes}"
Remove-Item -LiteralPath $tempOutput -Force
}
$parameters = @{
Uri = $Url
OutFile = $tempOutput
UseBasicParsing = $true
}
if ($downloadProxy) {
$parameters.Proxy = $downloadProxy
}
Invoke-WebRequest @parameters
}
} catch {
$currentBytes = if (Test-Path -LiteralPath $tempOutput) { (Get-Item -LiteralPath $tempOutput).Length } else { 0 }
Write-Host "[prepare-provision-downloads] 下载尝试失败: ${Label}, attempt=${attempt}/${maxAttempts}, tempBytes=${currentBytes}, error=$($_.Exception.Message)"
if ($attempt -lt $maxAttempts) {
Start-Sleep -Seconds ([Math]::Min(30, 3 * $attempt))
continue
}
throw
}
if (-not (Test-Path -LiteralPath $tempOutput)) {
throw "[prepare-provision-downloads] 下载未生成临时文件: ${tempOutput}"
}
$item = Get-Item -LiteralPath $tempOutput
if ($item.Length -le 0) {
if ($attempt -lt $maxAttempts) {
Write-Host "[prepare-provision-downloads] 下载结果为空,将重试: ${Label}"
Start-Sleep -Seconds ([Math]::Min(30, 3 * $attempt))
continue
}
throw "[prepare-provision-downloads] 下载结果为空: ${tempOutput}"
}
if ($ExpectedDigest) {
if (-not (Test-DownloadDigestMatch -Path $tempOutput -ExpectedDigest $ExpectedDigest)) {
Write-Host "[prepare-provision-downloads] 下载结果校验未通过,将继续重试: ${Label}, attempt=${attempt}/${maxAttempts}, tempBytes=$($item.Length)"
if ($attempt -lt $maxAttempts) {
Remove-Item -LiteralPath $tempOutput -Force
Start-Sleep -Seconds ([Math]::Min(30, 3 * $attempt))
continue
}
throw "[prepare-provision-downloads] 下载结果校验失败: ${Label}, temp=${tempOutput}"
}
}
Move-Item -LiteralPath $tempOutput -Destination $Output -Force
$finalItem = Get-Item -LiteralPath $Output
Write-Host "[prepare-provision-downloads] 已下载 ${Label}: bytes=$($finalItem.Length) path=${Output}"
return
}
throw "[prepare-provision-downloads] 下载重试耗尽: ${Label}"
}
$spacetimeArchiveName = "spacetime-${spacetimeTargetHost}.tar.gz"
$spacetimeArchiveUrl = "${spacetimeDownloadRoot}/${spacetimeArchiveName}"
$spacetimeArchiveDigest = Get-GithubReleaseAssetDigest -Repository 'clockworklabs/SpacetimeDB' -ReleaseSelector 'releases/latest' -AssetName $spacetimeArchiveName
Invoke-ProvisionDownload -Label "SpacetimeDB release tarball ${spacetimeTargetHost}" -Url $spacetimeArchiveUrl -Output (Join-Path $downloadsDir $spacetimeArchiveName) -ExpectedDigest $spacetimeArchiveDigest
if ($prepareOtel -eq 'true') {
$otelArchiveName = "otelcol-contrib_${otelVersion}_linux_amd64.tar.gz"
$otelUrl = "${otelRoot}/v${otelVersion}/${otelArchiveName}"
$otelDigest = Get-GithubReleaseAssetDigest -Repository 'open-telemetry/opentelemetry-collector-releases' -ReleaseSelector "releases/tags/v${otelVersion}" -AssetName $otelArchiveName
Invoke-ProvisionDownload -Label "otelcol-contrib ${otelVersion} linux amd64" -Url $otelUrl -Output (Join-Path $downloadsDir $otelArchiveName) -ExpectedDigest $otelDigest
} else {
Write-Host "[prepare-provision-downloads] ENABLE_OTELCOL=${prepareOtel},跳过 otelcol-contrib 下载。"
}
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
$manifest = @(
"spacetime release tarball ${spacetimeArchiveUrl}",
"spacetime target host ${spacetimeTargetHost}",
"otelcol-contrib ${otelVersion} prepare=${prepareOtel}"
)
[System.IO.File]::WriteAllLines((Join-Path $downloadsDir 'DOWNLOADS-MANIFEST.txt'), $manifest, $utf8NoBom)
Get-ChildItem -LiteralPath $downloadsDir | Sort-Object Name | ForEach-Object {
Write-Host "[prepare-provision-downloads] artifact $($_.Length) $($_.Name)"
}
''')
}
stash name: 'server-provision-tool-downloads', includes: "${params.PROVISION_DOWNLOADS_DIR}/**", useDefaultExcludes: false
}
}
stage('Checkout Provision Files') {
agent {
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
}
steps {
script {
def checkoutFromRemote = { String remoteUrl ->
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [
[$class: 'CleanBeforeCheckout'],
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
],
userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
])
}
try {
checkoutFromRemote(env.GIT_REMOTE_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
} catch (error) {
echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
}
}
sh '''
bash <<'BASH'
set -euo pipefail
chmod +x scripts/jenkins-checkout-source.sh
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
COMMIT_HASH="${COMMIT_HASH:-${SOURCE_COMMIT:-}}" \
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
scripts/jenkins-checkout-source.sh
sh '''
bash <<'BASH'
set -euo pipefail
chmod +x scripts/jenkins-checkout-source.sh
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
COMMIT_HASH="${COMMIT_HASH:-${SOURCE_COMMIT:-}}" \
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL}" \
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
scripts/jenkins-checkout-source.sh
BASH
'''
script {
env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim()
echo "Provision 源码 commit=${env.SOURCE_COMMIT}"
'''
script {
env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim()
echo "Provision 源码 commit=${env.SOURCE_COMMIT}"
}
}
}
}
}
stage('Provision Server') {
agent {
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
}
steps {
unstash 'server-provision-tool-downloads'
sh '''
bash <<'BASH'
set -euo pipefail
chmod +x scripts/prepare-server-provision-tools.sh
PROVISION_DOWNLOADS_DIR="${PROVISION_DOWNLOADS_DIR:-provision-tool-downloads}" \
PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}" \
OTELCOL_VERSION="${OTELCOL_VERSION:-0.151.0}" \
PREPARE_OTELCOL="${ENABLE_OTELCOL:-true}" \
PROVISION_REQUIRE_LOCAL_DOWNLOADS="true" \
SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/latest/download}" \
SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}" \
scripts/prepare-server-provision-tools.sh
stage('Prepare Provision Tools') {
steps {
sh '''
bash -lc '
set -euo pipefail
chmod +x scripts/prepare-server-provision-tools.sh
PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}" \
PROVISION_DOWNLOADS_DIR="${PROVISION_DOWNLOADS_DIR:-provision-tool-downloads}" \
OTELCOL_VERSION="${OTELCOL_VERSION:-0.151.0}" \
PREPARE_OTELCOL="${ENABLE_OTELCOL:-true}" \
PROVISION_DOWNLOAD_PROXY="${PROVISION_DOWNLOAD_PROXY:-}" \
SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.4.1}" \
SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}" \
scripts/prepare-server-provision-tools.sh
'
'''
}
}
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
chmod +x "${PROVISION_TOOLS_DIR:-provision-tools}/otelcol-contrib"
fi
chmod +x "${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/spacetime" \
"${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/bin/current/spacetimedb-cli" \
"${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/bin/current/spacetimedb-standalone"
chmod +x scripts/jenkins-server-provision.sh
PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}" \
SPACETIME_BIN_SOURCE="${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/spacetime" \
OTELCOL_BIN_SOURCE="${PROVISION_TOOLS_DIR:-provision-tools}/otelcol-contrib" \
scripts/jenkins-server-provision.sh
stage('Provision Server') {
steps {
sh '''
bash <<'BASH'
set -euo pipefail
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
chmod +x "${PROVISION_TOOLS_DIR:-provision-tools}/otelcol-contrib"
fi
chmod +x "${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/spacetime" \
"${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/bin/current/spacetimedb-cli" \
"${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/bin/current/spacetimedb-standalone"
chmod +x scripts/jenkins-server-provision.sh
PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}" \
SPACETIME_BIN_SOURCE="${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/spacetime" \
OTELCOL_BIN_SOURCE="${PROVISION_TOOLS_DIR:-provision-tools}/otelcol-contrib" \
scripts/jenkins-server-provision.sh
BASH
'''
'''
}
}
}
}
}
@@ -481,9 +204,7 @@ BASH
string(name: 'SOURCE_RESULT', value: currentBuild.currentResult ?: 'UNKNOWN'),
string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH ?: ''),
string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT ?: (params.COMMIT_HASH ?: '')),
string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION ?: (params.BUILD_VERSION ?: '')),
string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET ?: ''),
string(name: 'DATABASE', value: params.DATABASE ?: ''),
string(name: 'SUMMARY', value: '服务器初始化流水线结束'),
]
def notificationRecipients = params.NOTIFICATION_EMAILS?.trim()
@@ -504,4 +225,4 @@ BASH
echo "Server provision 完成: target=${params.DEPLOY_TARGET}, dryRun=${params.DRY_RUN}, nginxConfigMode=${params.NGINX_CONFIG_MODE}"
}
}
}
}

View File

@@ -1,27 +1,6 @@
def runWindowsPowerShell(String scriptName, String scriptBody) {
def scriptPath = ".jenkins-${scriptName}.ps1"
writeFile file: scriptPath, text: scriptBody, encoding: 'UTF-8'
bat label: "PowerShell ${scriptName}", script: """
@echo off
setlocal
set "GENARRATIVE_POWERSHELL=%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
if not exist "%GENARRATIVE_POWERSHELL%" (
echo [jenkins-powershell] powershell.exe not found: %GENARRATIVE_POWERSHELL%
exit /b 1
)
echo [jenkins-powershell] user:
whoami
echo [jenkins-powershell] exe: %GENARRATIVE_POWERSHELL%
"%GENARRATIVE_POWERSHELL%" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "\$path = '%CD%\\${scriptPath}'; \$text = [System.IO.File]::ReadAllText(\$path, [System.Text.Encoding]::UTF8); \$utf8Bom = New-Object System.Text.UTF8Encoding(\$true); [System.IO.File]::WriteAllText(\$path, \$text, \$utf8Bom)"
if errorlevel 1 exit /b %ERRORLEVEL%
"%GENARRATIVE_POWERSHELL%" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%CD%\\${scriptPath}"
exit /b %ERRORLEVEL%
"""
}
pipeline {
agent {
label 'windows'
label 'linux && genarrative-build'
}
options {
@@ -31,12 +10,10 @@ pipeline {
}
environment {
GIT_REMOTE_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
CARGO_HOME = '${env.WORKSPACE_TMP}/cargo-home'
CARGO_TARGET_DIR = '${env.WORKSPACE_TMP}/cargo-target/prod-release'
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
CARGO_INCREMENTAL = '0'
RUSTC_WRAPPER = 'sccache'
SCCACHE_DIR = '${env.USERPROFILE}\\.cache\\sccache-stdb-module'
SCCACHE_CACHE_SIZE = '30G'
}
@@ -56,106 +33,42 @@ pipeline {
stages {
stage('Checkout') {
steps {
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [
[$class: 'CleanBeforeCheckout'],
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
],
userRemoteConfigs: [[url: "${GIT_REMOTE_URL}", refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
])
script {
runWindowsPowerShell('stdb-checkout', '''
$ErrorActionPreference = 'Stop'
$sourceBranch = if ($env:SOURCE_BRANCH) { $env:SOURCE_BRANCH } else { 'master' }
$commitHash = if ($env:COMMIT_HASH) { $env:COMMIT_HASH } else { '' }
$gitRemoteUrl = if ($env:GIT_REMOTE_URL) { $env:GIT_REMOTE_URL } else { 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' }
function Invoke-GitCommand {
param(
[string]$Label,
[string[]]$Arguments
)
Write-Host "[stdb-checkout] $Label"
& git @Arguments
$exitCode = $LASTEXITCODE
if ($exitCode -ne 0) {
throw "[stdb-checkout] $Label failed with exit code $exitCode"
}
}
Write-Host "[stdb-checkout] sourceBranch: $sourceBranch"
Write-Host "[stdb-checkout] remote: $gitRemoteUrl"
$currentCommit = (git rev-parse HEAD).Trim()
if ($LASTEXITCODE -ne 0 -or -not $currentCommit) {
throw '[stdb-checkout] cannot resolve current HEAD'
}
Write-Host "[stdb-checkout] current HEAD: $currentCommit"
if ($commitHash) {
Write-Host "[stdb-checkout] requested commit: $commitHash"
$resolvedCommit = (git rev-parse --verify "${commitHash}^{commit}" 2>$null).Trim()
if ($LASTEXITCODE -eq 0 -and $resolvedCommit -eq $currentCommit) {
Write-Host '[stdb-checkout] requested commit already matches Jenkins GitSCM checkout'
} else {
Invoke-GitCommand -Label 'fetch source branch history' -Arguments @(
'fetch',
'--no-tags',
'--prune',
$gitRemoteUrl,
"+refs/heads/${sourceBranch}:refs/remotes/origin/${sourceBranch}"
)
$isShallowRepository = (git rev-parse --is-shallow-repository 2>$null).Trim()
if ($LASTEXITCODE -ne 0) {
throw '[stdb-checkout] cannot determine whether repository is shallow'
}
if ($isShallowRepository -eq 'true') {
Invoke-GitCommand -Label 'deepen source branch history' -Arguments @(
'fetch',
'--unshallow',
'--no-tags',
$gitRemoteUrl,
"+refs/heads/${sourceBranch}:refs/remotes/origin/${sourceBranch}"
)
}
Invoke-GitCommand -Label 'validate source branch ref' -Arguments @(
'cat-file',
'-e',
"refs/remotes/origin/${sourceBranch}^{commit}"
)
Invoke-GitCommand -Label 'validate requested commit' -Arguments @(
'cat-file',
'-e',
"${commitHash}^{commit}"
)
$resolvedCommit = (git rev-parse --verify "${commitHash}^{commit}").Trim()
if ($LASTEXITCODE -ne 0 -or -not $resolvedCommit) {
throw "[stdb-checkout] cannot resolve requested commit: $commitHash"
}
Invoke-GitCommand -Label 'validate requested commit belongs to branch' -Arguments @(
'merge-base',
'--is-ancestor',
$resolvedCommit,
"refs/remotes/origin/${sourceBranch}"
)
Invoke-GitCommand -Label "checkout commit $resolvedCommit" -Arguments @(
'checkout',
'--force',
$resolvedCommit
)
}
} else {
Write-Host "[stdb-checkout] COMMIT_HASH empty, reusing Jenkins GitSCM checkout result"
}
$resolvedCommit = (git rev-parse HEAD).Trim()
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText((Join-Path (Get-Location) '.jenkins-source-commit'), "$resolvedCommit`n", $utf8NoBom)
''')
env.SOURCE_COMMIT = readFile('.jenkins-source-commit').replace('\uFEFF', '').trim()
def checkoutFromRemote = { String remoteUrl ->
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [
[$class: 'CleanBeforeCheckout'],
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
],
userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
])
}
try {
checkoutFromRemote(env.GIT_REMOTE_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
} catch (error) {
echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
}
}
sh '''
bash -lc '
set -euo pipefail
chmod +x scripts/jenkins-checkout-source.sh
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
COMMIT_HASH="${COMMIT_HASH:-}" \
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
scripts/jenkins-checkout-source.sh
'
'''
script {
env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim()
env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER
}
}
@@ -165,45 +78,27 @@ pipeline {
steps {
script {
def buildStep = {
runWindowsPowerShell('stdb-build', '''
$ErrorActionPreference = 'Stop'
$workspaceTmp = if ($env:WORKSPACE_TMP) { $env:WORKSPACE_TMP } else { "$env:WORKSPACE@tmp" }
$env:CARGO_HOME = "$workspaceTmp/cargo-home"
$env:CARGO_TARGET_DIR = "$workspaceTmp/cargo-target/prod-release"
$env:SCCACHE_DIR = "$env:USERPROFILE/.cache/sccache-stdb-module"
$env:PATH = "$env:CARGO_HOME/bin;$env:PATH"
$gitBash = @(
$env:GENARRATIVE_BASH,
'C:/Program Files/Git/bin/bash.exe',
'C:/Program Files/Git/usr/bin/bash.exe',
'C:/msys64/usr/bin/bash.exe',
'bash'
) | Where-Object { $_ -and (($_ -eq 'bash') -or (Test-Path $_)) } | Select-Object -First 1
if (-not $gitBash) {
throw '[stdb-build] Windows 构建节点缺少 Git Bash无法执行仓库现有生产构建脚本。请先安装 Git for Windows并确保 bash 在 PATH 中。'
}
$env:GENARRATIVE_BASH = $gitBash
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
throw '[stdb-build] 缺少 cargo。请先在 Windows 构建节点安装 Rust 工具链,并确保 cargo 在 PATH 中。'
}
# sccache 只是可选缓存PATH 中存在但不可执行时必须回退到 rustc。
$sccacheCommand = Get-Command sccache -ErrorAction SilentlyContinue
$sccacheUsable = $false
if ($sccacheCommand) {
try {
& $sccacheCommand.Source --version | Out-Host
$sccacheUsable = $true
} catch {
Write-Host "[stdb-build] sccache 无法执行:$($_.Exception.Message)"
}
}
if (-not $sccacheUsable) {
Write-Host '[stdb-build] 未找到可用 sccache改用 rustc 直接构建。'
Remove-Item Env:RUSTC_WRAPPER -ErrorAction SilentlyContinue
}
npm run build:production-release -- --component spacetime-module --name "$env:EFFECTIVE_BUILD_VERSION"
sh '''
bash -lc '
set -euo pipefail
workspace_tmp="${WORKSPACE_TMP:-${WORKSPACE}@tmp}"
export CARGO_HOME="${workspace_tmp}/cargo-home"
export CARGO_TARGET_DIR="${workspace_tmp}/cargo-target/prod-release"
export CARGO_INCREMENTAL=0
export RUSTC_WRAPPER=sccache
export SCCACHE_DIR="${workspace_tmp}/sccache-stdb-module"
export SCCACHE_CACHE_SIZE=30G
mkdir -p "${CARGO_HOME}" "${CARGO_TARGET_DIR}" "${SCCACHE_DIR}"
chmod +x scripts/jenkins-prepare-cargo-env.sh
source scripts/jenkins-prepare-cargo-env.sh
if ! command -v sccache >/dev/null 2>&1; then
echo "[stdb-build] 未找到可用 sccache改用 rustc 直接构建。"
unset RUSTC_WRAPPER
fi
SOURCE_BRANCH="${SOURCE_BRANCH}" SOURCE_COMMIT="${SOURCE_COMMIT}" \
npm run build:production-release -- --component spacetime-module --name "${EFFECTIVE_BUILD_VERSION}"
'
'''
)
}
if (params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID?.trim()) {
withCredentials([

View File

@@ -77,7 +77,7 @@ pipeline {
stage('Checkout Publish Scripts') {
agent {
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}"
}
steps {
script {
@@ -119,7 +119,7 @@ pipeline {
stage('Fetch Artifact') {
agent {
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}"
}
steps {
copyArtifacts(
@@ -134,7 +134,7 @@ pipeline {
stage('Publish Stdb Module') {
agent {
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}"
}
steps {
script {

View File

@@ -12,7 +12,6 @@ pipeline {
environment {
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
WEB_ARTIFACT_ROOT = '/var/cache/genarrative-build/web-artifacts'
}
parameters {
@@ -90,29 +89,7 @@ pipeline {
stage('Archive') {
steps {
sh '''
bash -lc '
set -euo pipefail
artifact_dir="${WEB_ARTIFACT_ROOT}/${JOB_NAME}/${BUILD_NUMBER}/${EFFECTIVE_BUILD_VERSION}"
mkdir -p "${artifact_dir}"
rm -f "${artifact_dir}/web.tar.gz" "${artifact_dir}/web.tar.gz.sha256" "${artifact_dir}/release-manifest.json"
install -m 0644 "build/${EFFECTIVE_BUILD_VERSION}/web.tar.gz" "${artifact_dir}/web.tar.gz"
install -m 0644 "build/${EFFECTIVE_BUILD_VERSION}/web.tar.gz.sha256" "${artifact_dir}/web.tar.gz.sha256"
install -m 0644 "build/${EFFECTIVE_BUILD_VERSION}/release-manifest.json" "${artifact_dir}/release-manifest.json"
cat >"build/${EFFECTIVE_BUILD_VERSION}/web-artifact-pointer.txt" <<EOF
WEB_ARTIFACT_DIR=${artifact_dir}
WEB_ARTIFACT_JOB=${JOB_NAME}
WEB_ARTIFACT_BUILD_NUMBER=${BUILD_NUMBER}
WEB_ARTIFACT_VERSION=${EFFECTIVE_BUILD_VERSION}
EOF
echo "[web-build] Web 大包已保存在构建机本地目录: ${artifact_dir}"
find "${WEB_ARTIFACT_ROOT}/${JOB_NAME}" -mindepth 1 -maxdepth 1 -type d -mtime +14 -print -exec rm -rf {} +
'
'''
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/web.tar.gz.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,build/${env.EFFECTIVE_BUILD_VERSION}/web-artifact-pointer.txt", fingerprint: false
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/web.tar.gz,build/${env.EFFECTIVE_BUILD_VERSION}/web.tar.gz.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json", fingerprint: true
}
}

View File

@@ -10,7 +10,6 @@ pipeline {
environment {
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
WEB_ARTIFACT_ROOT = '/var/cache/genarrative-build/web-artifacts'
}
parameters {
@@ -25,9 +24,6 @@ pipeline {
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: '生产 release 根目录')
string(name: 'CURRENT_LINK', defaultValue: '/opt/genarrative/current', description: '当前版本软链接')
string(name: 'WEB_LINK', defaultValue: '/srv/genarrative/web', description: 'Nginx 静态站点软链接')
booleanParam(name: 'SYNC_WEB_ARTIFACT_FROM_BUILD_HOST', defaultValue: true, description: 'release 目标本地缺少 Web 大包时,是否通过 rsync 从构建机内网拉取')
string(name: 'WEB_ARTIFACT_SYNC_HOST', defaultValue: 'genarrative-build-internal', description: 'rsync 源 SSH Host通常来自 release 服务器上 Jenkins 运行用户的 ~/.ssh/config')
string(name: 'WEB_ARTIFACT_SYNC_SSH_CONFIG', defaultValue: '', description: '可选rsync 使用的 ssh config 绝对路径;留空使用当前用户默认 ~/.ssh/config')
}
stages {
@@ -55,7 +51,7 @@ pipeline {
stage('Checkout Deploy Scripts') {
agent {
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}"
}
steps {
script {
@@ -97,13 +93,13 @@ pipeline {
stage('Fetch Artifact') {
agent {
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}"
}
steps {
copyArtifacts(
projectName: params.BUILD_JOB_NAME,
selector: specific(params.BUILD_NUMBER_TO_DEPLOY),
filter: "build/${params.BUILD_VERSION}/web.tar.gz.sha256,build/${params.BUILD_VERSION}/release-manifest.json,build/${params.BUILD_VERSION}/web-artifact-pointer.txt",
filter: "build/${params.BUILD_VERSION}/web.tar.gz,build/${params.BUILD_VERSION}/web.tar.gz.sha256,build/${params.BUILD_VERSION}/release-manifest.json",
target: '.',
fingerprintArtifacts: true
)
@@ -111,49 +107,10 @@ pipeline {
bash -lc '
set -euo pipefail
artifact_dir="${WEB_ARTIFACT_ROOT}/${BUILD_JOB_NAME}/${BUILD_NUMBER_TO_DEPLOY}/${BUILD_VERSION}"
if [[ ! -f "${artifact_dir}/web.tar.gz" ]]; then
sync_enabled="${SYNC_WEB_ARTIFACT_FROM_BUILD_HOST:-true}"
sync_host="${WEB_ARTIFACT_SYNC_HOST:-genarrative-build-internal}"
sync_ssh_config="${WEB_ARTIFACT_SYNC_SSH_CONFIG:-}"
if [[ "${DEPLOY_TARGET:-development}" == "release" && "${sync_enabled}" == "true" ]]; then
if [[ -z "${sync_host}" ]]; then
echo "[web-deploy] release 目标需要同步 Web 大包,但 WEB_ARTIFACT_SYNC_HOST 为空。" >&2
exit 1
fi
echo "[web-deploy] release 目标本地缓存缺少 Web 大包,尝试从 ${sync_host} 同步: ${artifact_dir}"
if ! command -v rsync >/dev/null 2>&1; then
echo "[web-deploy] 当前 release agent 缺少 rsync请先安装 rsync 或预先挂载 Web 产物目录。" >&2
exit 1
fi
mkdir -p "${artifact_dir}"
rsync_args=(-av --progress)
if [[ -n "${sync_ssh_config}" ]]; then
rsync_args+=(-e "ssh -F ${sync_ssh_config}")
fi
rsync "${rsync_args[@]}" "${sync_host}:${artifact_dir}/" "${artifact_dir}/"
fi
fi
if [[ ! -f "${artifact_dir}/web.tar.gz" ]]; then
echo "[web-deploy] 未找到构建机本地 Web 大包: ${artifact_dir}/web.tar.gz" >&2
echo "[web-deploy] development 目标要求 Web 构建与发布共享同一 Linux 构建/开发部署机release 目标会默认通过 rsync 从 WEB_ARTIFACT_SYNC_HOST 拉取,也可预先同步或挂载 ${WEB_ARTIFACT_ROOT}。" >&2
exit 1
fi
mkdir -p "build/${BUILD_VERSION}"
cp -f "${artifact_dir}/web.tar.gz" "build/${BUILD_VERSION}/web.tar.gz"
if [[ -f "${artifact_dir}/web.tar.gz.sha256" ]]; then
cp -f "${artifact_dir}/web.tar.gz.sha256" "build/${BUILD_VERSION}/web.tar.gz.sha256"
fi
if [[ -f "${artifact_dir}/release-manifest.json" ]]; then
cp -f "${artifact_dir}/release-manifest.json" "build/${BUILD_VERSION}/release-manifest.json"
fi
echo "[web-deploy] 已从构建机本地目录获取 Web 大包: ${artifact_dir}"
test -f "build/${BUILD_VERSION}/web.tar.gz"
test -f "build/${BUILD_VERSION}/web.tar.gz.sha256"
test -f "build/${BUILD_VERSION}/release-manifest.json"
echo "[web-deploy] 已从 Jenkins 构建归档获取 Web 发布包: build/${BUILD_VERSION}/web.tar.gz"
'
'''
}
@@ -161,7 +118,7 @@ pipeline {
stage('Deploy Web') {
agent {
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}"
}
steps {
sh '''

View File

@@ -1,5 +1,9 @@
{
"pages": ["pages/web-view/index", "pages/wechat-pay/index"],
"pages": [
"pages/web-view/index",
"pages/wechat-pay/index",
"pages/subscribe-message/index"
],
"window": {
"navigationBarTitleText": "陶泥儿",
"navigationBarBackgroundColor": "#0b0f14",

View File

@@ -1,18 +1,24 @@
// 中文注释:这里填写已经在“小程序后台-开发-开发设置-业务域名”配置过的 H5 入口。
// 示例https://game.example.com/
// 注意:必须是 https 域名,不能是 localhost、IP 地址或未备案域名。
const WEB_VIEW_ENTRY_URL = 'https://www.genarrative.world/';
const WEB_VIEW_ENTRY_URL = 'https://www.genarrative.world';
const DEV_WEB_VIEW_ENTRY_URL = 'https://dev.genarrative.world';
// 中文注释:这里填写 Rust api-server 的公网 HTTPS 域名,必须在“小程序后台-开发设置-request 合法域名”中配置。
// 如果 H5 和 API 同域,可保持和 WEB_VIEW_ENTRY_URL 同一个域名;请求路径会固定走 /api/auth/wechat/miniprogram-login。
const API_BASE_URL = 'https://www.genarrative.world/';
const API_BASE_URL = 'https://www.genarrative.world';
const DEV_API_BASE_URL = 'https://dev.genarrative.world';
// 中文注释:这里填写微信小程序 AppID用于后端记录会话来源project.config.json 里的 appid 也要保持一致。
const MINI_PROGRAM_APP_ID = 'wx3da23ea14ca66b65';
// 中文注释:按当前上传版本填写 develop / trial / release后端会写入会话来源快照
// 中文注释:仅作为运行时环境识别失败时的兜底;正常情况下由 wx.getAccountInfoSync 自动判断
const MINI_PROGRAM_ENV = 'release';
// 中文注释AI 创作生成结果订阅消息模板,需与微信公众平台后台的模板 ID 保持一致。
const GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID =
'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU';
// 中文注释:给 H5 加一个来源标记,便于后续前端或后端识别这是微信小程序 web-view 宿主。
const WEB_VIEW_SOURCE_QUERY = {
clientType: 'mini_program',
@@ -21,6 +27,9 @@ const WEB_VIEW_SOURCE_QUERY = {
module.exports = {
API_BASE_URL,
DEV_API_BASE_URL,
DEV_WEB_VIEW_ENTRY_URL,
GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID,
MINI_PROGRAM_APP_ID,
MINI_PROGRAM_ENV,
WEB_VIEW_ENTRY_URL,

View File

@@ -0,0 +1,10 @@
/* global Page */
const { GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID } = require('../../config');
const { createSubscribeMessagePage } = require('./index.shared');
Page(
createSubscribeMessagePage(null, {
templateId: GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID,
}),
);

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "生成通知"
}

View File

@@ -0,0 +1,128 @@
/* global wx */
const SUBSCRIBE_RESULT_STORAGE_KEY = 'genarrative:wechat-subscribe-result';
function appendSubscribeResult(url, result) {
const hashIndex = String(url || '').indexOf('#');
const baseUrl =
hashIndex >= 0 ? String(url).slice(0, hashIndex) : String(url || '');
const rawHash = hashIndex >= 0 ? String(url).slice(hashIndex + 1) : '';
const nextHash = rawHash
.split('&')
.filter((part) => part && !part.startsWith('wx_subscribe_result='))
.concat(`wx_subscribe_result=${encodeURIComponent(result)}`)
.join('&');
return `${baseUrl}#${nextHash}`;
}
function buildSubscribeResultValue(requestId, status, reason) {
const segments = [requestId, status];
if (reason) {
segments.push(encodeURIComponent(reason));
}
return segments.join(':');
}
function notifyPreviousWebView(requestId, status, reason) {
const result = buildSubscribeResultValue(requestId, status, reason);
wx.setStorageSync(SUBSCRIBE_RESULT_STORAGE_KEY, result);
}
function resolveSubscribeStatus(result, templateId) {
return result && result[templateId] === 'accept'
? 'success'
: 'skip';
}
function createSubscribeMessagePage(pageContext, options = {}) {
const templateId = String(options.templateId || '').trim();
const notifyPageResult = (methodThis, status, reason) => {
const page = pageContext ?? methodThis;
const requestId = page.requestId || '';
if (!requestId || page.hasNotifiedSubscribeResult) {
return;
}
page.hasNotifiedSubscribeResult = true;
notifyPreviousWebView(requestId, status, reason);
};
return {
data: {
title: '接收生成结果通知',
errorMessage: '',
requesting: false,
},
onLoad(query) {
const page = pageContext ?? this;
page.requestId = String(query.requestId || '');
page.hasNotifiedSubscribeResult = false;
},
notifyResult(status, reason) {
notifyPageResult(this, status, reason);
},
requestSubscribe() {
const page = pageContext ?? this;
const requestId = page.requestId || '';
if (!requestId) {
page.setData({
errorMessage: '缺少订阅请求参数。',
});
return;
}
if (!templateId) {
notifyPageResult(this, 'skip', 'missing_template_id');
wx.navigateBack();
return;
}
if (typeof wx.requestSubscribeMessage !== 'function') {
notifyPageResult(this, 'skip', 'unsupported');
wx.navigateBack();
return;
}
page.setData({
requesting: true,
errorMessage: '',
});
wx.requestSubscribeMessage({
tmplIds: [templateId],
success(result) {
notifyPageResult(
page,
resolveSubscribeStatus(result, templateId),
'',
);
wx.navigateBack();
},
fail(error) {
notifyPageResult(
page,
'skip',
error && error.errMsg ? error.errMsg : 'failed',
);
wx.navigateBack();
},
});
},
handleSkip() {
notifyPageResult(this, 'skip', 'user_skip');
wx.navigateBack();
},
onUnload() {
notifyPageResult(this, 'skip', 'page_unload');
},
};
}
module.exports = {
SUBSCRIBE_RESULT_STORAGE_KEY,
appendSubscribeResult,
buildSubscribeResultValue,
createSubscribeMessagePage,
resolveSubscribeStatus,
};

View File

@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import subscribeMessageBridge from './index.shared.js';
const TEST_TEMPLATE_ID = 'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU';
const {
SUBSCRIBE_RESULT_STORAGE_KEY,
appendSubscribeResult,
buildSubscribeResultValue,
createSubscribeMessagePage,
} = subscribeMessageBridge;
describe('subscribe-message mini program bridge', () => {
beforeEach(() => {
globalThis.wx = {
requestSubscribeMessage: vi.fn(),
setStorageSync: vi.fn(),
navigateBack: vi.fn(),
};
globalThis.getCurrentPages = vi.fn(() => []);
});
test('requests subscribe message and stores result before returning', () => {
const previousPage = {
data: { webViewUrl: 'https://web.test/#tab=create' },
setData: vi.fn(),
};
globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]);
globalThis.wx.requestSubscribeMessage.mockImplementationOnce((options) => {
options.success?.({
m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU: 'accept',
});
});
const page = createSubscribeMessagePage(
{
setData: vi.fn(),
},
{ templateId: TEST_TEMPLATE_ID },
);
page.onLoad({ requestId: 'request-1' });
page.requestSubscribe();
expect(globalThis.wx.requestSubscribeMessage).toHaveBeenCalledWith({
tmplIds: [TEST_TEMPLATE_ID],
success: expect.any(Function),
fail: expect.any(Function),
});
expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith(
SUBSCRIBE_RESULT_STORAGE_KEY,
'request-1:success',
);
expect(previousPage.setData).not.toHaveBeenCalled();
expect(globalThis.wx.navigateBack).toHaveBeenCalled();
});
test('skip action notifies previous web-view', () => {
const previousPage = {
data: { webViewUrl: 'https://web.test/' },
setData: vi.fn(),
};
globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]);
const page = createSubscribeMessagePage(
{
setData: vi.fn(),
},
{ templateId: TEST_TEMPLATE_ID },
);
page.onLoad({ requestId: 'request-skip' });
page.handleSkip();
expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith(
SUBSCRIBE_RESULT_STORAGE_KEY,
'request-skip:skip:user_skip',
);
expect(previousPage.setData).not.toHaveBeenCalled();
expect(globalThis.wx.navigateBack).toHaveBeenCalled();
});
test('appendSubscribeResult replaces stale subscribe hash', () => {
expect(
appendSubscribeResult(
'https://web.test/#old=1&wx_subscribe_result=old',
'req:skip',
),
).toBe('https://web.test/#old=1&wx_subscribe_result=req%3Askip');
expect(buildSubscribeResultValue('req-1', 'skip', 'user_cancel')).toBe(
'req-1:skip:user_cancel',
);
});
});

View File

@@ -0,0 +1,19 @@
<view class="subscribe-screen">
<view class="subscribe-card">
<view class="subscribe-title">{{title}}</view>
<view wx:if="{{errorMessage}}" class="subscribe-text subscribe-text--danger">
{{errorMessage}}
</view>
<button
class="primary-button"
loading="{{requesting}}"
disabled="{{requesting}}"
bindtap="requestSubscribe"
>
继续并接收通知
</button>
<button class="ghost-button" disabled="{{requesting}}" bindtap="handleSkip">
仅继续生成
</button>
</view>
</view>

View File

@@ -0,0 +1,58 @@
.subscribe-screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 48rpx;
background: #0b0f14;
box-sizing: border-box;
}
.subscribe-card {
width: 100%;
max-width: 560rpx;
padding: 36rpx;
border: 1rpx solid rgba(255, 255, 255, 0.14);
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.06);
box-sizing: border-box;
}
.subscribe-title {
font-size: 34rpx;
font-weight: 600;
line-height: 1.35;
color: #f5f7fb;
}
.subscribe-text {
margin-top: 16rpx;
font-size: 26rpx;
line-height: 1.55;
color: rgba(245, 247, 251, 0.72);
}
.subscribe-text--danger {
color: #ffb4a9;
}
.primary-button,
.ghost-button {
margin-top: 28rpx;
width: 100%;
border-radius: 8rpx;
font-size: 26rpx;
line-height: 2.6;
}
.primary-button {
background: #f5f7fb;
color: #0b0f14;
}
.ghost-button {
margin-top: 20rpx;
border: 1rpx solid rgba(255, 255, 255, 0.24);
background: transparent;
color: rgba(245, 247, 251, 0.86);
}

View File

@@ -3,6 +3,8 @@
const {
API_BASE_URL,
DEV_API_BASE_URL,
DEV_WEB_VIEW_ENTRY_URL,
MINI_PROGRAM_APP_ID,
MINI_PROGRAM_ENV,
WEB_VIEW_ENTRY_URL,
@@ -15,6 +17,34 @@ const CLIENT_INSTANCE_STORAGE_KEY = 'genarrative:mini-program-client-instance-id
const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result';
const AUTH_RESULT_STORAGE_KEY = 'genarrative:mini-program-auth-result';
const AUTH_ACTION_LOGIN = 'login';
const PAY_RESULT_RECHECK_DELAY_MS = 120;
const WEB_VIEW_SHARE_TITLE = '陶泥儿';
const WEB_VIEW_SHARE_PATH = '/pages/web-view/index';
function showWebViewShareMenu() {
if (typeof wx.showShareMenu !== 'function') {
return;
}
wx.showShareMenu({
withShareTicket: true,
menus: ['shareAppMessage', 'shareTimeline'],
});
}
function buildWebViewShareAppMessage() {
return {
title: WEB_VIEW_SHARE_TITLE,
path: WEB_VIEW_SHARE_PATH,
};
}
function buildWebViewShareTimeline() {
return {
title: WEB_VIEW_SHARE_TITLE,
query: '',
};
}
function isConfiguredEntryUrl(value) {
const trimmed = String(value || '').trim();
@@ -45,6 +75,7 @@ function appendQuery(url, query) {
}
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(
@@ -58,14 +89,142 @@ function appendHashParams(url, params) {
const hashIndex = url.indexOf('#');
const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
const rawHash = hashIndex >= 0 ? url.slice(hashIndex + 1) : '';
const separator = rawHash ? '&' : '';
return `${baseUrl}#${rawHash}${separator}${pairs.join('&')}`;
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';
}
function normalizeNicknameInput(value) {
return String(value || '').trim();
}
function normalizeNicknameForMatch(value) {
return normalizeNicknameInput(value).replace(/\s+/gu, '').toLowerCase();
}
function isPhoneLikeDisplayName(value) {
const normalized = normalizeNicknameForMatch(value);
if (!normalized) {
return false;
}
const digits = normalized.replace(/\D/gu, '');
return (
/^(\+?86)?1\d{10}$/u.test(normalized) ||
/^1\d{2}\*{4}\d{4}$/u.test(normalized) ||
(/[*x]/iu.test(normalized) && digits.length >= 7) ||
digits.length >= 11
);
}
function isDefaultDisplayName(value, publicUserCode) {
const normalized = normalizeNicknameForMatch(value);
const normalizedPublicUserCode = normalizeNicknameForMatch(publicUserCode);
if (!normalized) {
return true;
}
return (
normalized === '微信旅人' ||
normalized === '玩家' ||
normalized === normalizedPublicUserCode ||
/^sy-\d{8}$/iu.test(normalized) ||
/^user[_-]/iu.test(normalized) ||
isPhoneLikeDisplayName(normalized)
);
}
function shouldRequestNicknameAfterLogin(authResult) {
const user = authResult && authResult.user ? authResult.user : {};
const wechatDisplayName = normalizeNicknameInput(user.wechatDisplayName);
if (wechatDisplayName && !isDefaultDisplayName(wechatDisplayName, user.publicUserCode)) {
return false;
}
return (
authResult &&
(authResult.created ||
isDefaultDisplayName(user.displayName, user.publicUserCode) ||
(wechatDisplayName &&
isDefaultDisplayName(wechatDisplayName, user.publicUserCode)))
);
}
function normalizeMiniProgramEnv(value) {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'release') {
return 'release';
}
if (normalized === 'trial') {
return 'trial';
}
if (
normalized === 'develop' ||
normalized === 'development' ||
normalized === 'dev'
) {
return 'dev';
}
return '';
}
function readMiniProgramEnvVersion() {
if (typeof wx.getAccountInfoSync !== 'function') {
return '';
}
try {
const accountInfo = wx.getAccountInfoSync();
return (
accountInfo &&
accountInfo.miniProgram &&
accountInfo.miniProgram.envVersion
);
} catch (error) {
console.warn('[web-view] read mini program env failed', error);
return '';
}
}
function resolveMiniProgramRuntimeConfig() {
const miniProgramEnv =
normalizeMiniProgramEnv(readMiniProgramEnvVersion()) ||
normalizeMiniProgramEnv(MINI_PROGRAM_ENV) ||
'release';
const useReleaseChannel = miniProgramEnv === 'release';
const webViewEntryUrl = useReleaseChannel
? WEB_VIEW_ENTRY_URL
: DEV_WEB_VIEW_ENTRY_URL || WEB_VIEW_ENTRY_URL;
const apiBaseUrl = useReleaseChannel
? API_BASE_URL
: DEV_API_BASE_URL || API_BASE_URL;
const sourceQuery = {
...WEB_VIEW_SOURCE_QUERY,
};
if (!useReleaseChannel) {
sourceQuery.miniProgramEnv = miniProgramEnv;
}
return {
apiBaseUrl,
miniProgramEnv,
sourceQuery,
webViewEntryUrl,
};
}
function shouldStartAuthFromQuery(query) {
return String((query && query.authAction) || '').trim() === AUTH_ACTION_LOGIN;
}
@@ -75,12 +234,13 @@ function shouldReturnToPreviousPage(query) {
}
function resolveWebViewUrl(authResult) {
const entryUrl = String(WEB_VIEW_ENTRY_URL || '').trim();
const runtimeConfig = resolveMiniProgramRuntimeConfig();
const entryUrl = String(runtimeConfig.webViewEntryUrl || '').trim();
if (!isConfiguredEntryUrl(entryUrl)) {
return '';
}
const sourcedUrl = appendQuery(entryUrl, WEB_VIEW_SOURCE_QUERY);
const sourcedUrl = appendQuery(entryUrl, runtimeConfig.sourceQuery);
if (!authResult || !authResult.token) {
return sourcedUrl;
}
@@ -164,9 +324,10 @@ function wxLogin() {
});
}
function requestMiniProgramLogin(code) {
function requestMiniProgramLogin(code, displayName) {
return new Promise((resolve, reject) => {
const apiBaseUrl = trimTrailingSlash(API_BASE_URL);
const runtimeConfig = resolveMiniProgramRuntimeConfig();
const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl);
if (!isConfiguredApiBaseUrl(apiBaseUrl)) {
reject(new Error('请先配置 API_BASE_URL'));
return;
@@ -175,7 +336,10 @@ function requestMiniProgramLogin(code) {
wx.request({
url: `${apiBaseUrl}/api/auth/wechat/miniprogram-login`,
method: 'POST',
data: { code },
data: {
code,
...(displayName ? { displayName } : {}),
},
header: {
'content-type': 'application/json',
'x-client-type': MINI_PROGRAM_CLIENT_TYPE,
@@ -183,7 +347,7 @@ function requestMiniProgramLogin(code) {
'x-client-platform': resolveClientPlatform(),
'x-client-instance-id': getClientInstanceId(),
'x-mini-program-app-id': MINI_PROGRAM_APP_ID,
'x-mini-program-env': MINI_PROGRAM_ENV,
'x-mini-program-env': runtimeConfig.miniProgramEnv,
},
success(response) {
if (response.statusCode >= 200 && response.statusCode < 300) {
@@ -205,9 +369,10 @@ function requestMiniProgramLogin(code) {
});
}
function requestMiniProgramBindPhone(authToken, wechatPhoneCode) {
function requestMiniProgramBindPhone(authToken, wechatPhoneCode, displayName) {
return new Promise((resolve, reject) => {
const apiBaseUrl = trimTrailingSlash(API_BASE_URL);
const runtimeConfig = resolveMiniProgramRuntimeConfig();
const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl);
if (!isConfiguredApiBaseUrl(apiBaseUrl)) {
reject(new Error('请先配置 API_BASE_URL'));
return;
@@ -216,7 +381,10 @@ function requestMiniProgramBindPhone(authToken, wechatPhoneCode) {
wx.request({
url: `${apiBaseUrl}/api/auth/wechat/bind-phone`,
method: 'POST',
data: { wechatPhoneCode },
data: {
wechatPhoneCode,
...(displayName ? { displayName } : {}),
},
header: {
authorization: `Bearer ${authToken}`,
'content-type': 'application/json',
@@ -225,7 +393,7 @@ function requestMiniProgramBindPhone(authToken, wechatPhoneCode) {
'x-client-platform': resolveClientPlatform(),
'x-client-instance-id': getClientInstanceId(),
'x-mini-program-app-id': MINI_PROGRAM_APP_ID,
'x-mini-program-env': MINI_PROGRAM_ENV,
'x-mini-program-env': runtimeConfig.miniProgramEnv,
},
success(response) {
if (response.statusCode >= 200 && response.statusCode < 300) {
@@ -247,15 +415,17 @@ function requestMiniProgramBindPhone(authToken, wechatPhoneCode) {
});
}
async function resolveAuthResult() {
async function resolveAuthResult(displayName) {
const code = await wxLogin();
const response = await requestMiniProgramLogin(code);
const response = await requestMiniProgramLogin(code, displayName);
if (!response || !response.token) {
throw new Error('服务器未返回登录态');
}
return {
token: response.token,
bindingStatus: response.bindingStatus || 'pending_bind_phone',
user: response.user || null,
created: response.created === true,
};
}
@@ -264,7 +434,10 @@ Page({
authResult: null,
bindingPhone: false,
errorMessage: '',
loggingIn: false,
loading: true,
nicknameInput: '',
nicknameRequired: false,
phoneBindingRequired: false,
returnToPreviousPage: false,
webViewUrl: '',
@@ -272,8 +445,10 @@ Page({
async onLoad(query = {}) {
this._lastLaunchQuery = query;
showWebViewShareMenu();
const runtimeConfig = resolveMiniProgramRuntimeConfig();
// 中文注释web-view 只能打开已配置业务域名;未配置时展示本地提示,避免空白页误判。
if (!isConfiguredEntryUrl(WEB_VIEW_ENTRY_URL)) {
if (!isConfiguredEntryUrl(runtimeConfig.webViewEntryUrl)) {
this.setData({
errorMessage: '请先在 miniprogram/config.js 填写 WEB_VIEW_ENTRY_URL。',
loading: false,
@@ -287,6 +462,7 @@ Page({
if (!shouldStartAuthFromQuery(query) && !forcedPhoneBinding) {
this.setData({
authResult: null,
bindingPhone: false,
errorMessage: '',
loading: false,
phoneBindingRequired: false,
@@ -296,7 +472,7 @@ Page({
return;
}
if (!isConfiguredApiBaseUrl(API_BASE_URL)) {
if (!isConfiguredApiBaseUrl(runtimeConfig.apiBaseUrl)) {
this.setData({
errorMessage: '请先在 miniprogram/config.js 填写 API_BASE_URL。',
loading: false,
@@ -306,20 +482,65 @@ Page({
}
this.setData({
authResult: null,
bindingPhone: false,
errorMessage: '',
loggingIn: true,
loading: true,
nicknameRequired: false,
phoneBindingRequired: false,
returnToPreviousPage,
errorMessage: '',
webViewUrl: '',
});
await this.startAuthFlow(returnToPreviousPage, '');
},
handleNicknameInput(event) {
this.setData({
nicknameInput: event.detail ? event.detail.value : '',
});
},
async handleStartLogin() {
const displayName = normalizeNicknameInput(this.data.nicknameInput);
if (!displayName) {
this.setData({
errorMessage: '请先选择或填写微信昵称。',
});
return;
}
this.setData({
errorMessage: '',
loggingIn: true,
});
await this.startAuthFlow(this.data.returnToPreviousPage, displayName);
},
async startAuthFlow(returnToPreviousPage, displayName) {
try {
const authResult = await resolveAuthResult();
const authResult = await resolveAuthResult(displayName);
if (!displayName && shouldRequestNicknameAfterLogin(authResult)) {
this.setData({
authResult,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: true,
phoneBindingRequired: false,
returnToPreviousPage,
webViewUrl: '',
});
return;
}
if (authResult.bindingStatus === 'pending_bind_phone') {
this.setData({
authResult,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: true,
returnToPreviousPage,
webViewUrl: '',
@@ -329,6 +550,16 @@ Page({
if (returnToPreviousPage) {
persistAuthResult(authResult);
this.setData({
authResult,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
returnToPreviousPage,
webViewUrl: '',
});
wx.navigateBack();
return;
}
@@ -336,7 +567,9 @@ Page({
this.setData({
authResult,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
returnToPreviousPage,
webViewUrl: resolveWebViewUrl(authResult),
@@ -346,7 +579,9 @@ Page({
authResult: null,
errorMessage:
error && error.message ? error.message : '微信登录失败,请稍后重试。',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
returnToPreviousPage,
webViewUrl: '',
@@ -358,10 +593,24 @@ Page({
const authResult = consumeAuthResult();
if (authResult) {
this.setData({
authResult,
bindingPhone: false,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
webViewUrl: resolveWebViewUrl(authResult),
});
}
this.consumePayResult();
setTimeout(() => {
this.consumePayResult();
}, PAY_RESULT_RECHECK_DELAY_MS);
},
consumePayResult() {
const result = wx.getStorageSync(PAY_RESULT_STORAGE_KEY);
if (result && this.data.webViewUrl) {
wx.removeStorageSync(PAY_RESULT_STORAGE_KEY);
@@ -395,6 +644,7 @@ Page({
const response = await requestMiniProgramBindPhone(
this.data.authResult.token,
detail.code,
normalizeNicknameInput(this.data.nicknameInput),
);
if (!response || !response.token) {
throw new Error('服务器未返回绑定后的登录态');
@@ -408,7 +658,9 @@ Page({
this.setData({
bindingPhone: false,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
});
wx.navigateBack();
@@ -418,7 +670,9 @@ Page({
authResult: nextAuthResult,
bindingPhone: false,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
webViewUrl: resolveWebViewUrl(nextAuthResult),
});
@@ -438,7 +692,10 @@ Page({
authResult: null,
bindingPhone: false,
errorMessage: '',
loggingIn: false,
loading: true,
nicknameInput: '',
nicknameRequired: false,
phoneBindingRequired: false,
returnToPreviousPage: false,
webViewUrl: '',
@@ -455,7 +712,15 @@ Page({
},
handleWebViewMessage(event) {
// 中文注释:支付由独立 native 页面承接web-view 消息只保留调试输出。
// 中文注释:支付和订阅消息都由独立 native 页面承接web-view 消息只保留调试输出。
console.info('[web-view] message', event.detail);
},
onShareAppMessage() {
return buildWebViewShareAppMessage();
},
onShareTimeline() {
return buildWebViewShareTimeline();
},
});

View File

@@ -0,0 +1,32 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, test } from 'vitest';
const PAGE_DIR = path.resolve(
process.cwd(),
'miniprogram/pages/web-view',
);
function readPageFile(fileName) {
return fs.readFileSync(path.join(PAGE_DIR, fileName), 'utf8');
}
describe('mini program web-view page background', () => {
test('keeps the native web-view host light when the mobile keyboard exposes it', () => {
const wxml = readPageFile('index.wxml');
const wxss = readPageFile('index.wxss');
expect(wxml).toContain('class="web-view-host"');
expect(wxml).not.toContain('class="web-view-page"');
expect(wxss).toContain('page');
expect(wxss).toContain('.web-view-host');
expect(wxss).toContain('background: #fffdf9;');
const webViewHostBlock = wxss.slice(
wxss.indexOf('.web-view-host'),
wxss.indexOf('.setup-screen'),
);
expect(webViewHostBlock).not.toContain('#0b0f14');
});
});

View File

@@ -1,6 +1,7 @@
<block wx:if="{{webViewUrl}}">
<web-view
id="genarrative-web-view"
class="web-view-host"
src="{{webViewUrl}}"
bindload="handleWebViewLoad"
binderror="handleWebViewError"
@@ -8,6 +9,32 @@
/>
</block>
<view wx:elif="{{nicknameRequired}}" class="setup-screen">
<view class="setup-card">
<view class="setup-title">完善昵称</view>
<view wx:if="{{errorMessage}}" class="setup-text setup-text--danger">
{{errorMessage}}
</view>
<input
class="nickname-input"
type="nickname"
value="{{nicknameInput}}"
placeholder="微信昵称"
disabled="{{loggingIn}}"
bindinput="handleNicknameInput"
bindblur="handleNicknameInput"
/>
<button
class="retry-button"
loading="{{loggingIn}}"
disabled="{{loggingIn}}"
bindtap="handleStartLogin"
>
{{loggingIn ? '正在提交' : '确认昵称'}}
</button>
</view>
</view>
<view wx:elif="{{loading}}" class="setup-screen">
<view class="setup-card">
<view class="setup-title">正在登录</view>

View File

@@ -1,3 +1,14 @@
page {
background: #fffdf9;
}
.web-view-host {
display: block;
width: 100%;
min-height: 100vh;
background: #fffdf9;
}
.setup-screen {
min-height: 100vh;
display: flex;
@@ -36,6 +47,19 @@
color: #ffb4a9;
}
.nickname-input {
margin-top: 28rpx;
width: 100%;
min-height: 88rpx;
padding: 0 24rpx;
border: 1rpx solid rgba(255, 255, 255, 0.22);
border-radius: 8rpx;
background: rgba(255, 255, 255, 0.1);
color: #f5f7fb;
font-size: 28rpx;
box-sizing: border-box;
}
.retry-button {
margin-top: 28rpx;
width: 100%;

View File

@@ -1,90 +1,3 @@
function parsePayParams(rawValue) {
try {
const params = JSON.parse(decodeURIComponent(String(rawValue || '')));
if (!params || typeof params !== 'object') {
return null;
}
return params;
} catch (error) {
console.error('[wechat-pay] parse params failed', error);
return null;
}
}
const { createWechatPayPage } = require('./index.shared');
function requestPayment(payParams) {
return new Promise((resolve) => {
wx.requestPayment({
timeStamp: String(payParams.timeStamp || ''),
nonceStr: String(payParams.nonceStr || ''),
package: String(payParams.package || ''),
signType: payParams.signType || 'RSA',
paySign: String(payParams.paySign || ''),
success() {
resolve('success');
},
fail(error) {
const errMsg = error && error.errMsg ? error.errMsg : '';
resolve(/cancel/i.test(errMsg) ? 'cancel' : 'fail');
},
});
});
}
const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result';
function appendPayResult(url, requestId, status) {
const value = `${requestId}:${status}`;
const hashIndex = String(url || '').indexOf('#');
const baseUrl =
hashIndex >= 0 ? String(url).slice(0, hashIndex) : String(url || '');
const rawHash = hashIndex >= 0 ? String(url).slice(hashIndex + 1) : '';
const nextHash = rawHash
.split('&')
.filter((part) => part && !part.startsWith('wx_pay_result='))
.concat(`wx_pay_result=${encodeURIComponent(value)}`)
.join('&');
return `${baseUrl}#${nextHash}`;
}
function notifyPreviousWebView(requestId, status) {
const result = `${requestId}:${status}`;
wx.setStorageSync(PAY_RESULT_STORAGE_KEY, result);
const pages = getCurrentPages();
const previousPage = pages.length >= 2 ? pages[pages.length - 2] : null;
if (previousPage && typeof previousPage.setData === 'function') {
previousPage.setData({
webViewUrl: appendPayResult(
previousPage.data.webViewUrl,
requestId,
status,
),
});
}
}
Page({
data: {
title: '正在拉起支付',
errorMessage: '',
},
async onLoad(query) {
const requestId = String(query.requestId || '');
const payParams = parsePayParams(query.payParams);
if (!requestId || !payParams) {
this.setData({
title: '支付失败',
errorMessage: '缺少支付参数。',
});
return;
}
const status = await requestPayment(payParams);
notifyPreviousWebView(requestId, status);
wx.navigateBack();
},
handleBack() {
wx.navigateBack();
},
});
Page(createWechatPayPage());

View File

@@ -0,0 +1,221 @@
/* global wx, getCurrentPages */
function parsePayParams(rawValue) {
try {
const params = JSON.parse(decodeURIComponent(String(rawValue || '')));
if (!params || typeof params !== 'object') {
return null;
}
return params;
} catch (error) {
console.error('[wechat-pay] parse params failed', error);
return null;
}
}
function isVirtualPaymentParams(payParams) {
return (
typeof payParams.mode === 'string' &&
typeof payParams.signData === 'string' &&
typeof payParams.paySig === 'string' &&
typeof payParams.signature === 'string'
);
}
function safeCompareVersion(left, right) {
const leftParts = String(left || '')
.split('.')
.map((part) => Number(part) || 0);
const rightParts = String(right || '')
.split('.')
.map((part) => Number(part) || 0);
const length = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < length; index += 1) {
const leftValue = leftParts[index] || 0;
const rightValue = rightParts[index] || 0;
if (leftValue > rightValue) {
return 1;
}
if (leftValue < rightValue) {
return -1;
}
}
return 0;
}
function canUseVirtualPayment() {
if (typeof wx === 'undefined') {
return false;
}
if (typeof wx.canIUse === 'function' && wx.canIUse('requestVirtualPayment')) {
return true;
}
const version =
typeof wx.getSystemInfoSync === 'function'
? wx.getSystemInfoSync()?.SDKVersion || ''
: '';
return safeCompareVersion(version, '2.19.2') >= 0;
}
function resolvePayStatus(error) {
const errMsg = error && error.errMsg ? error.errMsg : '';
const errCode = Number(error && error.errCode);
return errCode === -2 || /cancel/i.test(errMsg) ? 'cancel' : 'fail';
}
function normalizePayError(error) {
if (!error) {
return '';
}
if (typeof error === 'string') {
return error;
}
try {
return JSON.stringify({
errCode: error.errCode,
errMsg: error.errMsg,
});
} catch (_error) {
return String(error.errMsg || error);
}
}
function requestOrdinaryPayment(payParams) {
return new Promise((resolve) => {
wx.requestPayment({
timeStamp: String(payParams.timeStamp || ''),
nonceStr: String(payParams.nonceStr || ''),
package: String(payParams.package || ''),
signType: payParams.signType || 'RSA',
paySign: String(payParams.paySign || ''),
success() {
resolve({ status: 'success', errorMessage: '' });
},
fail(error) {
resolve({
status: resolvePayStatus(error),
errorMessage: normalizePayError(error),
});
},
});
});
}
function requestVirtualPayment(payParams) {
return new Promise((resolve) => {
if (!canUseVirtualPayment() || typeof wx.requestVirtualPayment !== 'function') {
console.error('[wechat-pay] requestVirtualPayment unavailable', {
canUseVirtualPayment: canUseVirtualPayment(),
hasRequestVirtualPayment: typeof wx.requestVirtualPayment === 'function',
});
resolve({
status: 'fail',
errorMessage: '当前微信基础库不支持 requestVirtualPayment',
});
return;
}
wx.requestVirtualPayment({
mode: String(payParams.mode || ''),
signData: String(payParams.signData || ''),
paySig: String(payParams.paySig || ''),
signature: String(payParams.signature || ''),
success() {
resolve({ status: 'success', errorMessage: '' });
},
fail(error) {
console.error('[wechat-pay] requestVirtualPayment failed', error);
resolve({
status: resolvePayStatus(error),
errorMessage: normalizePayError(error),
});
},
});
});
}
function requestWechatPayment(payParams) {
if (isVirtualPaymentParams(payParams)) {
return requestVirtualPayment(payParams);
}
return requestOrdinaryPayment(payParams);
}
const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result';
function appendPayResult(url, result) {
const hashIndex = String(url || '').indexOf('#');
const baseUrl =
hashIndex >= 0 ? String(url).slice(0, hashIndex) : String(url || '');
const rawHash = hashIndex >= 0 ? String(url).slice(hashIndex + 1) : '';
const nextHash = rawHash
.split('&')
.filter((part) => part && !part.startsWith('wx_pay_result='))
.concat(`wx_pay_result=${encodeURIComponent(result)}`)
.join('&');
return `${baseUrl}#${nextHash}`;
}
function buildPayResultValue(requestId, orderId, payResult) {
const segments = [requestId, payResult.status, orderId || ''];
if (payResult.errorMessage) {
segments.push(encodeURIComponent(payResult.errorMessage));
}
return segments.join(':');
}
function notifyPreviousWebView(requestId, orderId, payResult) {
const result = buildPayResultValue(requestId, orderId, payResult);
wx.setStorageSync(PAY_RESULT_STORAGE_KEY, result);
const pages = getCurrentPages();
const previousPage = pages.length >= 2 ? pages[pages.length - 2] : null;
if (previousPage && typeof previousPage.setData === 'function') {
previousPage.setData({
webViewUrl: appendPayResult(previousPage.data.webViewUrl, result),
});
}
}
function createWechatPayPage(pageContext) {
return {
data: {
title: '正在拉起支付',
errorMessage: '',
},
async onLoad(query) {
const requestId = String(query.requestId || '');
const orderId = String(query.orderId || '');
const payParams = parsePayParams(query.payParams);
if (!requestId || !payParams) {
const page = pageContext ?? this;
page.setData({
title: '支付失败',
errorMessage: '缺少支付参数。',
});
return;
}
const payResult = await requestWechatPayment(payParams);
notifyPreviousWebView(requestId, orderId, payResult);
wx.navigateBack();
},
handleBack() {
wx.navigateBack();
},
};
}
module.exports = {
canUseVirtualPayment,
PAY_RESULT_STORAGE_KEY,
appendPayResult,
buildPayResultValue,
createWechatPayPage,
normalizePayError,
parsePayParams,
safeCompareVersion,
requestWechatPayment,
requestVirtualPayment,
};

View File

@@ -0,0 +1,176 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import wechatPayBridge from './index.shared.js';
const {
appendPayResult,
createWechatPayPage,
parsePayParams,
requestWechatPayment,
} = wechatPayBridge;
describe('wechat-pay mini program payment bridge', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
globalThis.wx = {
requestPayment: vi.fn(),
requestVirtualPayment: vi.fn(),
getSystemInfoSync: vi.fn(() => ({ SDKVersion: '2.32.0' })),
setStorageSync: vi.fn(),
navigateBack: vi.fn(),
};
globalThis.getCurrentPages = vi.fn(() => []);
});
test('routes virtual payloads to wx.requestVirtualPayment', async () => {
globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => {
options.success?.({ errMsg: 'requestVirtualPayment:ok' });
});
const payParams = {
mode: 'short_series_coin',
signData:
'{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","outTradeNo":"order-virtual-1","attach":"mud_points_60"}',
paySig: 'pay-sig',
signature: 'user-sig',
};
const result = await requestWechatPayment(payParams);
expect(result).toEqual({ status: 'success', errorMessage: '' });
expect(globalThis.wx.requestVirtualPayment).toHaveBeenCalledWith({
mode: 'short_series_coin',
signData: payParams.signData,
paySig: 'pay-sig',
signature: 'user-sig',
success: expect.any(Function),
fail: expect.any(Function),
});
expect(globalThis.wx.requestPayment).not.toHaveBeenCalled();
});
test('routes goods virtual payloads to wx.requestVirtualPayment', async () => {
globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => {
options.success?.({ errMsg: 'requestVirtualPayment:ok' });
});
const payParams = {
mode: 'short_series_goods',
signData:
'{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","productId":"member_month","goodsPrice":2800,"outTradeNo":"order-goods-1","attach":"member_month"}',
paySig: 'pay-sig',
signature: 'user-sig',
};
const result = await requestWechatPayment(payParams);
expect(result).toEqual({ status: 'success', errorMessage: '' });
expect(globalThis.wx.requestVirtualPayment).toHaveBeenCalledWith({
mode: 'short_series_goods',
signData: payParams.signData,
paySig: 'pay-sig',
signature: 'user-sig',
success: expect.any(Function),
fail: expect.any(Function),
});
expect(globalThis.wx.requestPayment).not.toHaveBeenCalled();
});
test('keeps ordinary requestPayment payloads on wx.requestPayment', async () => {
globalThis.wx.requestPayment.mockImplementationOnce((options) => {
options.success?.();
});
const result = await requestWechatPayment({
timeStamp: '1777110165',
nonceStr: 'nonce',
package: 'prepay_id=wx-prepay',
signType: 'RSA',
paySign: 'signature',
});
expect(result).toEqual({ status: 'success', errorMessage: '' });
expect(globalThis.wx.requestPayment).toHaveBeenCalledWith({
timeStamp: '1777110165',
nonceStr: 'nonce',
package: 'prepay_id=wx-prepay',
signType: 'RSA',
paySign: 'signature',
success: expect.any(Function),
fail: expect.any(Function),
});
expect(globalThis.wx.requestVirtualPayment).not.toHaveBeenCalled();
});
test('maps virtual payment cancel errCode to cancel result', async () => {
const payError = {
errCode: -2,
errMsg: 'requestVirtualPayment:fail cancel',
};
globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => {
options.fail?.(payError);
});
await expect(
requestWechatPayment({
mode: 'short_series_coin',
signData: '{}',
paySig: 'pay-sig',
signature: 'user-sig',
}),
).resolves.toEqual({
status: 'cancel',
errorMessage: JSON.stringify({
errCode: -2,
errMsg: 'requestVirtualPayment:fail cancel',
}),
});
expect(console.error).toHaveBeenCalledWith(
'[wechat-pay] requestVirtualPayment failed',
payError,
);
});
test('page notifies previous web-view after virtual payment', async () => {
const previousPage = {
data: { webViewUrl: 'https://web.test/#tab=profile' },
setData: vi.fn(),
};
globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]);
globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => {
options.success?.({ errMsg: 'requestVirtualPayment:ok' });
});
const page = createWechatPayPage({
setData: vi.fn(),
});
await page.onLoad({
requestId: 'request-1',
orderId: 'order-1',
payParams: encodeURIComponent(
JSON.stringify({
mode: 'short_series_coin',
signData: '{}',
paySig: 'pay-sig',
signature: 'user-sig',
}),
),
});
expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith(
'genarrative:wechat-pay-result',
'request-1:success:order-1',
);
expect(previousPage.setData).toHaveBeenCalledWith({
webViewUrl: 'https://web.test/#tab=profile&wx_pay_result=request-1%3Asuccess%3Aorder-1',
});
expect(globalThis.wx.navigateBack).toHaveBeenCalled();
});
test('parsePayParams and appendPayResult keep existing behavior', () => {
expect(parsePayParams(encodeURIComponent('{"paySign":"sig"}'))).toEqual({
paySign: 'sig',
});
expect(appendPayResult('https://web.test/#old=1', 'req:fail:order-1')).toBe(
'https://web.test/#old=1&wx_pay_result=req%3Afail%3Aorder-1',
);
});
});

405
package-lock.json generated
View File

@@ -31,6 +31,7 @@
"@types/three": "^0.184.0",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitest/coverage-v8": "^0.34.6",
"autoprefixer": "^10.4.21",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
@@ -48,6 +49,20 @@
"vitest": "^0.34.6"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -73,7 +88,6 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -302,6 +316,13 @@
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true,
"license": "MIT"
},
"node_modules/@clack/core": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.1.tgz",
@@ -851,6 +872,16 @@
"deprecated": "Use @eslint/object-schema instead",
"dev": true
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@jest/schemas": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
@@ -1575,6 +1606,7 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"peer": true,
"engines": {
"node": ">=10"
},
@@ -1587,6 +1619,7 @@
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -1600,7 +1633,8 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true
"dev": true,
"peer": true
},
"node_modules/@testing-library/react": {
"version": "16.3.2",
@@ -1663,7 +1697,8 @@
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true
"dev": true,
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -1706,8 +1741,7 @@
"version": "4.3.20",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz",
"integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/@types/chai-subset": {
"version": "1.3.6",
@@ -1723,6 +1757,13 @@
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1753,7 +1794,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1763,7 +1803,6 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -1855,7 +1894,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@@ -2079,6 +2117,32 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "0.34.6",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-0.34.6.tgz",
"integrity": "sha512-fivy/OK2d/EsJFoEoxHFEnNGTg+MmdZBAVK9Ka4qhXR2K3J0DS08vcGVwzDtXSuUMabLv4KtPcpSKkcMXFDViw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.1",
"@bcoe/v8-coverage": "^0.2.3",
"istanbul-lib-coverage": "^3.2.0",
"istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^4.0.1",
"istanbul-reports": "^3.1.5",
"magic-string": "^0.30.1",
"picocolors": "^1.0.0",
"std-env": "^3.3.3",
"test-exclude": "^6.0.0",
"v8-to-istanbul": "^9.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": ">=0.32.0 <1"
}
},
"node_modules/@vitest/expect": {
"version": "0.34.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.6.tgz",
@@ -2186,7 +2250,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2277,6 +2340,7 @@
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"dequal": "^2.0.3"
}
@@ -2460,7 +2524,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2824,6 +2887,7 @@
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
@@ -2879,7 +2943,8 @@
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true
"dev": true,
"peer": true
},
"node_modules/domexception": {
"version": "4.0.0",
@@ -3077,7 +3142,6 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -3796,6 +3860,13 @@
"node": ">=12"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/http-proxy-agent": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
@@ -3964,6 +4035,60 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-source-maps": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz",
"integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"debug": "^4.1.1",
"istanbul-lib-coverage": "^3.0.0",
"source-map": "^0.6.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -3994,7 +4119,6 @@
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz",
"integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==",
"dev": true,
"peer": true,
"dependencies": {
"abab": "^2.0.6",
"cssstyle": "^3.0.0",
@@ -4401,6 +4525,7 @@
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -4413,6 +4538,35 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -4866,7 +5020,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -5069,7 +5222,6 @@
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5078,7 +5230,6 @@
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -5404,6 +5555,16 @@
"node": ">=8"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -5551,6 +5712,21 @@
"node": ">=6"
}
},
"node_modules/test-exclude": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
"integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^7.1.4",
"minimatch": "^3.0.4"
},
"engines": {
"node": ">=8"
}
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -5673,7 +5849,6 @@
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"devOptional": true,
"peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -5740,7 +5915,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5826,11 +6000,25 @@
"license": "MIT",
"optional": true
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
"integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
"dev": true,
"license": "ISC",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.12",
"@types/istanbul-lib-coverage": "^2.0.1",
"convert-source-map": "^2.0.0"
},
"engines": {
"node": ">=10.12.0"
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -7647,6 +7835,16 @@
}
},
"dependencies": {
"@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"requires": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -7666,7 +7864,6 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"peer": true,
"requires": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -7825,6 +8022,12 @@
"@babel/helper-validator-identifier": "^7.28.5"
}
},
"@bcoe/v8-coverage": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
"@clack/core": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.1.tgz",
@@ -8089,6 +8292,12 @@
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
"dev": true
},
"@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
"dev": true
},
"@jest/schemas": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
@@ -8516,13 +8725,15 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true
"dev": true,
"peer": true
},
"pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"peer": true,
"requires": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -8533,7 +8744,8 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true
"dev": true,
"peer": true
}
}
},
@@ -8569,7 +8781,8 @@
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true
"dev": true,
"peer": true
},
"@types/babel__core": {
"version": "7.20.5",
@@ -8612,8 +8825,7 @@
"version": "4.3.20",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz",
"integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==",
"dev": true,
"peer": true
"dev": true
},
"@types/chai-subset": {
"version": "1.3.6",
@@ -8627,6 +8839,12 @@
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
},
"@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
"dev": true
},
"@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -8656,7 +8874,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"peer": true,
"requires": {
"csstype": "^3.2.2"
}
@@ -8666,7 +8883,6 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"peer": true,
"requires": {}
},
"@types/semver": {
@@ -8733,7 +8949,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"peer": true,
"requires": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@@ -8864,6 +9079,25 @@
"react-refresh": "^0.18.0"
}
},
"@vitest/coverage-v8": {
"version": "0.34.6",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-0.34.6.tgz",
"integrity": "sha512-fivy/OK2d/EsJFoEoxHFEnNGTg+MmdZBAVK9Ka4qhXR2K3J0DS08vcGVwzDtXSuUMabLv4KtPcpSKkcMXFDViw==",
"dev": true,
"requires": {
"@ampproject/remapping": "^2.2.1",
"@bcoe/v8-coverage": "^0.2.3",
"istanbul-lib-coverage": "^3.2.0",
"istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^4.0.1",
"istanbul-reports": "^3.1.5",
"magic-string": "^0.30.1",
"picocolors": "^1.0.0",
"std-env": "^3.3.3",
"test-exclude": "^6.0.0",
"v8-to-istanbul": "^9.1.0"
}
},
"@vitest/expect": {
"version": "0.34.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.6.tgz",
@@ -8944,8 +9178,7 @@
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"peer": true
"dev": true
},
"acorn-jsx": {
"version": "5.3.2",
@@ -9008,6 +9241,7 @@
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"peer": true,
"requires": {
"dequal": "^2.0.3"
}
@@ -9117,7 +9351,6 @@
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"peer": true,
"requires": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -9368,7 +9601,8 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true
"dev": true,
"peer": true
},
"detect-libc": {
"version": "2.1.2",
@@ -9408,7 +9642,8 @@
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true
"dev": true,
"peer": true
},
"domexception": {
"version": "4.0.0",
@@ -9553,7 +9788,6 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"dev": true,
"peer": true,
"requires": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -10057,6 +10291,12 @@
"whatwg-encoding": "^2.0.0"
}
},
"html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true
},
"http-proxy-agent": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
@@ -10174,6 +10414,44 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
},
"istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true
},
"istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"requires": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
}
},
"istanbul-lib-source-maps": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz",
"integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==",
"dev": true,
"requires": {
"debug": "^4.1.1",
"istanbul-lib-coverage": "^3.0.0",
"source-map": "^0.6.1"
}
},
"istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"requires": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
}
},
"jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -10198,7 +10476,6 @@
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz",
"integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==",
"dev": true,
"peer": true,
"requires": {
"abab": "^2.0.6",
"cssstyle": "^3.0.0",
@@ -10411,7 +10688,8 @@
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true
"dev": true,
"peer": true
},
"magic-string": {
"version": "0.30.21",
@@ -10421,6 +10699,23 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"requires": {
"semver": "^7.5.3"
},
"dependencies": {
"semver": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"dev": true
}
}
},
"math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -10740,7 +11035,6 @@
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"peer": true,
"requires": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -10878,14 +11172,12 @@
"react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"peer": true
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="
},
"react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"peer": true,
"requires": {
"scheduler": "^0.27.0"
}
@@ -11094,6 +11386,12 @@
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
"dev": true
},
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true
},
"source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -11206,6 +11504,17 @@
"readable-stream": "^3.1.1"
}
},
"test-exclude": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
"integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
"dev": true,
"requires": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^7.1.4",
"minimatch": "^3.0.4"
}
},
"text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -11300,7 +11609,6 @@
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"devOptional": true,
"peer": true,
"requires": {
"esbuild": "~0.27.0",
"fsevents": "~2.3.3",
@@ -11342,8 +11650,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"peer": true
"dev": true
},
"ufo": {
"version": "1.6.3",
@@ -11398,11 +11705,21 @@
"dev": true,
"optional": true
},
"v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
"integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
"dev": true,
"requires": {
"@jridgewell/trace-mapping": "^0.3.12",
"@types/istanbul-lib-coverage": "^2.0.1",
"convert-source-map": "^2.0.0"
}
},
"vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"peer": true,
"requires": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",

Some files were not shown because too many files have changed in this diff Show More