Files
Genarrative/docs/project-memory/shared-memory/pitfalls.md
T
kdletters fe35fcf264
Project CI / Backend tests (push) Successful in 6m48s
Project CI / Repository checks (push) Successful in 2m46s
Project CI / Frontend tests (push) Successful in 3m16s
Project CI / Native shell tests (push) Successful in 15m39s
修复 AGC Windows NSIS 打包工具缓存
启用 Tauri 项目级 NSIS 工具缓存,避开 Jenkins systemprofile AppData。

补充 Windows 配置门禁,防止 useLocalToolsDir 回退。

增强 Jenkins 预检与失败诊断,记录实际用户和 makensis 路径。

同步 AGC 发布文档与 NSIS 排障记忆。
2026-09-02 15:24:07 +08:00

4979 lines
1005 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 踩坑与排障记录
> 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 schema 以代码与 `docs/README.md` 为准。
## 2026-08-27 Provider 成功 handoff 失败时需要保留本地私有原始响应
- **现象**Provider 已返回响应,但 tool-plan handoff 因绝对路径或其它内容安全校验失败,Runtime 只留下 `failureKind`、哈希和被压平的 JSON pointer;排障时无法确认实际工具名和完整 arguments。
- **处理**:项目 `.agent`、Agent DB 和公共 event 继续只写安全摘要;额外在应用私有配置目录的 `diagnostics/provider-reconciliation/<projectHash>/<requestHash>.json` 保存本次响应、tool calls 和校验错误,供本机人工排障。该文件不参与恢复/重试、不复制到项目、不进入 Git,单文件限制 1 MiB,写入失败不改变 reconciliation 语义。
- **排查顺序**:先读 Runtime 状态里的 `localDiagnostic` 相对引用,再在应用私有目录读取诊断,核对 requestId、requestSlot、tool name 和失败 pointer;不要为了取得原文而放宽 handoff 的安全门。
## 2026-08-27 阶段判定不能在持锁的 Provider builder 中再次获取项目锁
- **现象**:GDD 修订取证阶段新增后,重新启动策划时前两步表面成功,但父 Supervisor 在收到 `project-planning` 回执、生成下一轮工具计划时失败:`项目正在被其他写操作占用:$PROJECT_ROOT\\.agent\\project.lock`
- **原因**`provider_tool_plan` 在构建请求前已持有 `.agent/project.lock``plan_root_supervisor_stage_at` 又调用会自行取锁的 Acceptance Evidence 包装入口。同一进程的文件锁不可重入,持锁调用被误判为外部竞争,等待约 10 秒后失败。问题与 Provider、代理端口或 GDD 内容无关。
- **处理**:所有需要一致快照的状态读取保留在项目锁内;阶段判定提供明确的 `*_locked` 内部入口,外层入口仅供未持锁调用方取得一次锁。Provider builder 显式接收并校验当前锁后调用 locked 阶段判定,不引入可重入锁,也不移除 Acceptance Evidence 门禁。
- **排查顺序**:先看失败 Run 的事件顺序是否为 `delegate receipt ready → 生成工具计划 → 阶段判定项目锁失败`,再检查调用方是否已持有 Provider plan project lock;不要因为错误文案包含“其他写操作”就先扩大锁等待或放宽 Provider usage。
- **验证**`cargo check``cargo fmt --check`、planning submit 68 passed、Provider request builder 17 passed;阶段测试同时覆盖未持锁包装入口和持锁 locked 入口。
## 2026-08-26 GDD 新版本提交后不能沿用“已有委派”工具面
- **现象**`plan_root_supervisor_stage_at` 只按是否存在 delivery 判定 `Delegated`。用户修订产生的新 GDD 仍未完成当前根 Run 的 `file.read → agent.acceptance_update` 取证时,模型会看到 `agent.delegate`,可能重复派发同一条策划链。
- **原因**:自然语言 playbook 已规定“证据不足先取证、用户修改后才返工”,但阶段工具白名单没有把这条 durable 状态固化。
- **处理**:阶段判定复用现有 acceptance gate 的 GDD/session/delivery/graph identity 检查,增加无副作用的 `AwaitingAcceptanceEvidence` 阶段;`PLAN_PROVIDER_USAGE_DEFERRED` 保持 fail-closed,不通过放宽 Provider 使用量门禁解决。
- **排查顺序**:先看最新 `gdd.vN.json``session.latestSubmittedRef`、delivery 是否 `ClaimedByParent`,再看 Acceptance Graph 是否 `NeedsEvidence`;若仍可见 `agent.delegate`,优先检查 plan root 阶段快照,而不是修改 acceptance gate 或 Provider 门禁。
## 2026-08-15 把校验往链路前面挪,改的不是严格程度而是作用域
- 现象:CI 全量 5 条失败,看上去毫不相干(两条 Goal 续跑停在 `needs-reconciliation`、一条交接用例断言错误文案、一条恢复用例把不可读 state 的错误抛了出来、一条 Linux-only 用例错误码对不上),实际只有 3 个根因,且三者是**同一个形状**:新增或既有的检查被放在了链路更靠前的位置,于是它的语义作用域被悄悄放大或提前,而不是「变严」。
- 形状一(判据上提 → 作用域从同 loop 变成跨 loop):`tool_plan_handoff/identity_order_validation.rs``same_tool_plan_repair_chain` 含 steer cursor、goal revision/快照与 planning session binding 这些**本轮量**,只在同 loop 的 repair 之间才必须逐位相等。`M1B-2``27c3eb847`)把它提到 `loop_iteration` 分支之外后,`loop-N → loop-N+1` 的正常续跑必然被判成「身份冲突」,整个 run 进 `needs-reconciliation`。**同一次上提还把分支内那句同名检查变成了死代码**——编译器不报,测试拿到的是外层文案,于是表现成「断言的错误文案不对」这种看起来无关的症状。模块内本来就有反例可对照:`ledger.rs``is_later_repair_identity` 一直把这条判据显式限定在 `current_loop == candidate_loop`
- 形状二(探测器的前置条件变成整条链路的 gate):`recovery_scan.rs``missing_plan_submit_anchor_candidate_at``?` 强读 runtime state,而它被挂在每个 Agent resume 循环的**最前面**。真正负责处置「state 不可读」的是它下游的 `resume_game_creator_agent_finalization_at`——那里会从 task record 重建并 fail-closed 到 `needs-reconciliation`。前面这一 `?` 把整轮 resume 打断,**恰好绕过了专门为这种情况写的兜底**。这个探测器甚至不适用于出事的 Agent(它只认 planning agent + `agent-delegate`),读失败纯属前置成本。
- 形状三(通用解析器排在专用校验之前,错误码被压平):`planning_storage.rs``resolve_local_project_path` 的失败整体映射成 `PLAN_INVALID_PATH`,但该解析器会先于 planning 自己的校验逐组件拒绝符号链接。于是「不可信路径」被报成「非法路径」,与同模块 `ensure_planning_parent``verify_regular_planning_file` 的分类自相矛盾。这条自 `M1B-1``f453c2ca2`)写下就没绿过——用例是 `#[cfg(unix)]`Windows 上编译都不参与(`0 tests`),只有 Linux CI 能看见。
- 处理:形状一改回 loop 分支内,并补一条正向回归(跨 loop 且 steer cursor / goal revision 已前进必须被接受),把边界钉住而不是只钉拒绝;跨 loop 身份由 `same_durable_tool_plan_run` 守,binding 漂移另有 `provider_retry.rs` 的 per-request 判据兜底,去掉上提不留缺口。形状二把强读改成「读不到就 `Ok(None)`」,让处置权回到下游兜底。形状三新增 `resolve_planning_path`,先用模块自己的 `planning_metadata_is_link_or_reparse` 判链接/重解析点并返回 `PLAN_UNTRUSTED_PATH`,再交给通用解析器;模块内 13 处解析全部改走它,分类统一。
- 定位手法(比二分快得多):失败用例的临时项目目录在 panic 后不会被清理(`fs::remove_dir_all` 写在用例末尾),直接读里面的 `.agent/agent.db``.agent/runtime/agents/*.json`。本次两条 Goal 失败在 db 里留下 `failureKind=tool-plan-integrity``errorChars=55``errorSha256=0750f609…`,把候选错误文案逐条算 SHA-256 一比即命中,`requestSlot``loop-1-repair-0``loop-2-repair-0` 直接指出是跨 loop 那一跳。**错误只留指纹不留原文时,指纹就是可检索的**。
- 验证:4 条可在 Windows 复现的用例全部转绿(含新增回归);Linux-only 用例在 WSL 的独立 Linux clone 中验证。Windows worktree 的 `.git` 文件可能记录 Windows 路径,WSL 内应从可访问的主仓库 clone 或真实路径 fetch,不要直接复用不可解析的 worktree 元数据。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/identity_order_validation.rs``.../agent/runtime_driver/recovery_scan.rs``.../agent/runtime_protocol/planning_storage.rs`decision-log 同日条。
## 2026-08-15 `#[cfg(windows)]` 里的代码不参与 Linux CI 编译,CI 绿不代表能构建
- 现象:把 master`9f5c84ee7`)合进 `feat/five_min_design` 后,`cargo check --all-targets` 在 Windows 上直接 `error[E0658]: use of unstable library feature 'windows_by_handle'`,位置是 `apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs``metadata.number_of_links()`。该文件与 `origin/master` **逐字节相同**,即 master 自身在 Windows 上就构建不过。
- 原因:`std::os::windows::fs::MetadataExt::number_of_links` 至今未稳定(rust-lang#63010),而 `rust-toolchain.toml` 锁的是 stable `1.96.0`。引入它的提交是 `578f8019f`(优化 AGC 项目入口并识别 Godot 工作区),其中 unix 分支用 `MetadataExt::nlink()`(已稳定)、windows 分支用了未稳定的对应物。**Linux CI 上 `#[cfg(windows)]` 整块不参与编译,所以 CI 全绿。**
- 更普遍的形状:只要一段代码只在某个 `#[cfg(target_os)]` 下编译,它就完全绕过了其它平台的 CI——不只是 unstable feature,还包括类型错误、借用错误、缺失 import。跨平台分支是「双写」,两侧都得有人真的编译过。
- 处理:本仓库对「文件是不是无硬链接普通文件」统一自行声明 `ByHandleFileInformation` 并调用 `GetFileInformationByHandle`,见 `runner/endpoint.rs``tool_plan_handoff/storage_windows.rs``project/agent_db.rs``git_inspect.rs``image_inspect.rs``agent/generation/canvas_generation.rs``manifest.rs` 当前已采用同一实现,并保留 fail-closed 语义:无法取得句柄信息或确认存在硬链接时均拒绝,同时拒绝 directory / reparse point。
- 验证:改后 `cargo check --offline --all-targets` 通过、`cargo fmt --check` 通过、`project::manifest` 与 godot 相关定向测试 65 passed / 0 failed。判断「是不是本次合并引入」的通用手法:`git diff origin/master -- <file>` 为空即说明该文件就是 master 原样,问题不在合并。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs``rust-toolchain.toml`master 提交 `578f8019f`
## 2026-08-14 planning sidecar 必须区分“可读”与“可写”,canonical bytes 也不等于 typed 指纹
- 现象:如果为了保护 Runtime-owned 事实,直接把 `.agent/planning/**` 加进现有 private-control **读**门,planning 子 Agent 的 `file.read` / `file.list` 会一起失败;反过来若只依赖工具面约束,通用 `file.write``file.patch``file.delete``project.patchset` 或 checkpoint restore 仍可能覆盖 GDD/session。另一个常见误判是把“能反序列化且语义相同”的 JSON 当成已提交文件,导致尾换行、字段重排或重复键绕过不可变事实的字节身份。
- 原因:`.agent/planning/**` 是 Runtime 专用 durable sidecar,但 planning Agent 需要只读观察;`game/fast_gdd.md` 又是 Runtime renderer 的人读投影,二者都不能复用“读写一体”的旧 private path 判据。存储 bytes 与 typed fingerprint 是两个门:前者约束磁盘 canonical serializationRust struct 字段顺序、compact UTF-8、无 BOM/尾空白、无重复键),后者约束 domain-separated 业务 payload 的完整性;只过其中一门都不能视为 authoritative。
- 处理:保持现有 private-control **读**门不含 planning,新增只挡写 predicate;所有通用 mutation 与 restore 路径在推进 revision 前先拒绝 `.agent/planning/**` / `game/fast_gdd.md`,专用 writer 再校验 `project-planning + agent-delegate + standard + project-supervisor` 身份。GDD/index 等不可变文件走项目锁、同目录临时文件、`sync_all`、回读与 no-replace 发布;相同 canonical bytes 才是 replay,任何其它内容都是 identity conflict。session 只保留一个 `.session.json.previous`primary 损坏时 fail closed,不得拿 previous 猜测新旧。
- 验证:先用 planning Agent 的只读 action 验证 sidecar 可列出/读取,再逐项证明 `file.write``file.patch``file.delete``project.patchset` 与 checkpoint restore 均拒绝;storage 测试应覆盖 duplicate key、BOM/尾空白、字段顺序、symlink/目录/硬链接、create-only replay/conflict、session 缺 primary 提升、primary 损坏和合法 successor。第 9.1 节 golden vector 当前为 3857 bytes / `sha256-serde-json-v2:a59856de7ef134cf2f49c4dedd2ba10ae4ab2340a9634d402eb792b6ee5458f0`
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs``apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs``apps/ai-game-creator-shell/src-tauri/src/patchset.rs``docs/technical/【技术方案】立项策划AgentFast GDD-2026-08-10.md` 第 8.310.2 节。
## 2026-08-12 在 autonomous-game-build 下试图向用户提问,会让整条工作流永久瘫痪
- 现象:给自主构建链路加「问用户一句」的需求时,最自然的两个想法——让 DAG 节点自己问、或让父 Supervisor 代问——**都不成立**,而且第二个的失败方式是灾难性的。
- 拦截一(节点自己问):`apps/ai-game-creator-shell/src-tauri/src/user_input.rs:367-394``validate_user_input_action_owner` 三路 OR 拒绝任何带 `parent_agent_id / parent_run_id / delegation_id` 的 run。autonomous 的 ready-task 调度器在 `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs:905-907` **显式**给每个 DAG 子节点写 parent,因此必然命中。
- 拦截二(父代问):`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs:198-215` 只要 `run_profile == autonomous-game-build` 就把 `user.input_request``auto_tools` / `confirm_tools` 移除并推进 `denied_tools``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs:2604-2616` 在执行层再拒一次。**两处判据都只看 profile、不看 `agent_id`,所以 root Supervisor 自己也在禁令内**——「父能问、子不能问」这个中转赖以成立的不对称,在 autonomous 下根本不存在。
- 真正的坑(后果放大):硬闯不是「这次失败」,而是**整条流水线停摆**。执行层拒绝会走 `mark_game_creator_agent_runtime_needs_reconciliation_at``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs:1157``runtime.status` 直接写成 `failed`;此后每次调度 ready task 必经的 `autonomous_game_build_root_task_is_active``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs:685-693`)只认 `pending | running | waiting-for-confirmation | waiting-for-user-input``failed` 不在其中,于是报「父 Run 已不再活跃」,**剩余节点一个都起不来,须人工核对才能恢复**。同一类故障本仓库已踩过一次,见下方「自主模式不能保留任何 RequiresConfirmation 漏口」条。
- 也别想着换 profile 绕开:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs:264-266` 明文「子 Run 不能切换父 Run 的 Run Profile」,且 profile 是 run 绑定时 CAS 锁死的终身属性。
- 处理:需要与用户往返的链路必须整体跑在 `standard` profile 下。`standard` 的 ready-task 调度器(`task_start.rs:566-580`,走 `..._with_source_at` 而非 `..._with_link_at`)不写 parent,节点是自己的 root run,上述拦截一并不适用。这是 2026-08-12 立项策划 D9 改用「Supervisor + standard 下游工作流节点」的直接原因,见 decision-log 同日条。
- 附带结论:`autonomous_owner_artifact_validation_available_for_run_at``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs:416-441`)同样四重绑死 owner 白名单、profile、source,且**要求 `parent_agent_id` 为 Project Supervisor**——standard 节点无 parent,第 436 行即不通过。想给 standard 路径加 owner 产物验证的人不要试图扩展它,必须另建。
## 2026-08-12 往 trusted supervisor source 里加新 source,等于同时授予 Goal Contract 参与者身份
- 现象:`agent_runtime_supervisor_source_is_trusted``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs:113`)读起来像一个「谁能启动 Project Supervisor」的入口白名单,实际早已是多条互不相干的授权判据的共同开关。2026-08-11 合入动态目标验收图后,它的非测试消费者从 2 个文件涨到 10 个文件 18 处调用:run 启动(`commands.rs:698`)、steer`commands.rs:876``steering.rs:763`)、Goal Contract 创建权限(`goal_contract.rs:496`)、根控制面工具是否被剥离(`provider_request_builders.rs:134``root_control_authority`)、验收图完成门(`acceptance_graph.rs:595`)、run configuration、lifecycle_control、task_start、project_gates、autonomous_completion。
- 陷阱:这些判据**全都不看 Run Profile**。`goal_contract_acceptance_completion_blocker_at_locked``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs:568-611`)只要求「`agent_id``project-supervisor` + binding 是无 parent 的 root + source 可信」,未冻结 Goal Contract 就返回 `blocked`,并被 `main_loop.rs:213``main_loop.rs:1803``finalization.rs:398` 消费。因此给一个**用途完全不同**的新 source(例如立项策划的 plan chat)加进白名单,会让它的根 Run 立刻背上「必须先调 `agent.goal_contract`」的义务;如果该 source 的工具面按 exact allowlist 设计、不含这个工具,根 Run 就永远无法完成——而且症状是 run 卡在完成门,不是启动失败,排查方向容易跑偏。
- 更坏的一半:把新 source 排除出白名单**并不能**脱身。同一协议还有一道入口门 `validate_root_goal_contract_control_plan_at``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs:171`),由 `provider_tool_plan.rs:434` 在通用 tool-plan 解析路径上无条件调用,判据只有「`agent_id``project-supervisor` + binding 的 root 是自己 + 存在 run profile binding」——**连 source 都不看**。合同不存在时它强制本轮恰好一个 `agent.goal_contract` 动作且 `plan_update`/legacy plan/`response` 全为空,于是「第一轮先问用户一个问题」或「第一轮先回复」的 Agent 会被直接判协议错误。三处判据里只有 `agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID` 是共同项,改 `agent_id` 是唯一能一次性解耦的做法。
- 处理:新增 trusted source 前,先逐个确认这些调用对新 source 的语义是否成立,尤其是 Goal Contract 创建、验收图完成门与 steer 三处,再单独确认不看 source 的入口门;需要区分时,应当拆出「可信入口」与「Goal Contract 参与者」两条判据,而不是继续复用同一个函数。立项策划已按第 23.1 节裁决进 matcher 并参与 Goal Contractsteer 用独立于 matcher 的显式否决(`reject_supervisor_plan_root_steer`),不得用「不进 matcher」实现。复核结论见 decision-log 2026-08-13 `M1A-1` 条。
- 双向提问:调用点**既是判据又是构造器**时,只问「会不会误得不该有的语义」不够,还要问「落到通用兜底会不会丢掉该有的语义」。`resolve_game_creator_agent_runtime_retry_configuration_at` 因此在 `M1A-1` 漏出,由 `M1A-3` 补强判据与保源;拒绝继续用弱判据,授予必须用强判据。
- 相关:`requiredEvidence` 只接受 `tool:<Runtime 工具名>` 且必须命中 `agent_runtime_acceptance_evidence_tools()``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs:74-96`,当前 18 项)。确定性收束的任务和 Runtime 内部产物验证都不产生 Provider 回执,因此无法为验收节点提供证据——不要指望「让 Runtime 自己验一下」能满足验收图。
## 2026-08-12 给 manifest 加"新鲜度门控"或身份字段的两个陷阱
- 陷阱二(补身份字段只堵一条路):改写 `task.status` 的写入路径有两条互相独立的。除 `update_manifest_task_status_at``apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs:590-608`)外,还有毫无秩序守卫的 `set_task_status`(同文件 `580-588`,直接 `task.status = status`),其调用方 `record_draft_task_progress`(同文件 `387-413`)把 `code-prototype` 列进批量置 `Completed` 的清单,由用户可随时触发的 `game.generate_draft` 命令调用。只给前者补 `statusRunId` / `statusSource`,后者会原样保留上一次写入的旧身份印记,于是污染写入反而通过校验,比不校验更危险。对照组是 `apps/ai-game-creator-shell/src-tauri/src/agent/generation/trace.rs:613-637``set_task_status_if_current`,它有 `should_replace_task_status` 秩序判定——两者的不对称本身也是一个待处理项。
- 处理:本阶段不加机制,manifest 明确降级为 lineage 判定通过后的补充信号(见 decision-log 2026-08-12 条)。将来要做,必须同时覆盖两条写入路径,并统一走毫秒换算 helper。
- 验证:`apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts` 的「keeps an unbound manifest out of the verdict until the current main is terminal」钉住了现有边界与残余风险;该用例最后一条断言即为已记录的残余风险,改动它就意味着重新裁决,必须同步更新决策记录。
- 现象:前端 Runtime map 以 Agent ID 保存当前记录。新一轮仍会复用 `code-prototype``art-director``art-asset-plan` 这些 Agent ID;若旧 root 已终态但 main/美术 child 或 manifest 仍在收口,直接从 live map 归档会在新 root 接管后丢失旧后代,或把新轮证据误接到旧阶段记录。
- 修复:根进入终态时按完整 `agent/session/run` 身份保存 root-scoped Runtime 快照,后续只合并同一稳定身份的更新;manifest 快照只在该 root 仍为当前 root 时捕获。归档前重新执行严格 lineage、全终态、单 main/单 active art 与 reconciliation 门禁,并用 root run 派生稳定 message ID。
> 用途:记录已验证、未来很可能再次遇到的问题。每条都应包含现象、原因、处理方式和验证方式。
## 记录格式
```md
## 问题标题
- 现象:看到什么错误或异常行为
- 原因:确认后的根因
- 处理:具体修复步骤
- 验证:如何确认修复有效
- 关联:相关文件、文档、提交或 Issue
```
## 严格 delegated 授权失败不能把 retry 回落为普通美术权限
- 原因:代码混用了“是否声称动态美术 lineage”和“是否已证明当前首次委派授权”两个事实。retry 使用新 run 和新 delegationId,但没有与之绑定的 durable delivery,结构上无法满足现行 exact lineage;授权失败应表示不可信候选,而不是普通 Agent。
- 验证:覆盖 failed/cancelled child 重试零 successor run、遗留 retry 的 file/patchset/Canvas/command/preview/再委派拒绝、失败回执认领后同 run 同 target 重委派拒绝、下一轮 main 重新审计后同 target 新委派放行且 delivery 全链一致并恢复 `assets/**`,同时对完整 DAG 与非美术 retry 做非回归。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs``docs/project-memory/shared-memory/decision-log.md`
## 不能用可玩游戏 smoke 验证 code-prototype 上游的固定文档产物
- 现象:真实新项目的 `design-foundation``balance-seed``art-asset-plan``audio-asset-plan` 已写完自己的固定文件,却始终无法收束;`project.verify` 因项目没有 `package.json` 不可用,`game.static_smoke` 又报告缺少活动 `<canvas>`。测试若先调用 `fake_llm_game_draft()`,同一路径却会“通过”。
- 原因:初始化 `game/index.html` 只是无 `<canvas>` 的占位页,真正游戏要到下游 `code-prototype` 才生成。把所有 mutation verification 都等同于可玩游戏 smoke,会让上游 artifact-only owner 在依赖顺序上自锁;预写 fake game 的夹具提前完成了下游职责,掩盖了真实新项目路径。
- 处理:四个 pre-code 固定 owner 在最终收束门由 Runtime 内部验证 canonical 产物:普通文件有界读取、非空,JSON 可解析,无 incomplete marker,且相对根完成合同 baseline 已变化。该能力不进入 Provider 工具目录,不新增 commandId;凭证类型为 `runtime.owner_artifacts_validate`,只写普通 `verifiedRevision`,不得写 `staticSmokeVerifiedRevision` 或制造 smoke / preview trace。文件路径门与验证必须复用同一 canonical owner 映射。`code-prototype``preview-readiness` 继续执行真实 `game.static_smoke``preview-playtest` 继续独立浏览器验收;`publish-package` 不借本修复扩入内部验证。
- 身份与恢复:只允许完整 GUI / CLI 16 任务 DAG 的 `agent-ready-task-scheduler` 确定性直接 child、当前活跃根和正确 parent/binding;错误 source、delegated run、历史/终态根、非当前 root、跨 Agent/run 一律失败关闭。owner 再次 mutation 必须令旧凭证失效;相同身份恢复时可按当前磁盘事实确定性重验。
- 并发恢复补充:自主根任务 journal 写入后建立或重建 completion contract 时,初始 manifest reset 与 continuation reconciliation reset 不能重新使用 fail-fast 项目锁。异步 child finalization 可以合法插入两次取锁之间,使已入 journal 的新根被误记为 `completion-contract-failed`。这两条 reset 必须使用现有有界等待项目锁,超时仍失败关闭;只验证 scheduler 合同的测试应预占 child Runtime lane,不能真实启动后台 worker 后再手工改 manifest。确定性回归要显式持锁,分别证明初始合同与 continuation 合同等待释放后成功落盘。
- 验证:夹具必须从 `init_local_game_project_at` 开始,先断言无 `package.json` 且占位入口 smoke 失败,再证明产物不齐阻断、齐全后内部验证通过、无 smoke trace、再次 mutation 失效;另覆盖四个 owner 路径矩阵、错误身份、恢复、跨 run 凭证、`art-director` 有/无 Key、动态美术借凭证拒绝、`code-prototype` project.verify-only 阻断和试玩 executor 身份。
- 关联:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md``docs/technical/【技术方案】立项策划AgentFast GDD-2026-08-10.md`
## UI 设计 State 的 strict JSON round-trip 不能混用两种浮点序列化表示(2026-08-19)
- 现象:为节点拖拽/缩放生成非整数 Transform 后,保存报“UI 设计 State 安装后回读与待写内容不一致”;由于读取主文件失败关闭后恢复 `.previous`,后续回读表现为刚导入的 spirit/sprite 资产丢失。
- 原因:写入使用 `serde_json::to_vec_pretty`,它对 `f32` 输出短十进制(如 `348.5318`);读取端却以 `serde_json::to_value` 重建 canonical JSON,重新扩展为精确二进制值(如 `348.53179931640625`)。两者作为 `serde_json::Value` 不相等,合法的新主文件被误判为不受支持,然后错误回退到旧恢复副本。
- 处理:严格 envelope 检查必须使用与磁盘写入相同的 `serialize_ui_design_document` 再解析为 `Value`,仍拒绝未知字段/值,却允许合法 `f32` 的稳定文件表示;不可再把 `to_vec_pretty``to_value` 的数字文本直接比较。
- 验证:持久化回归使用带 `spirit` sprite、`Image.target_graphic` 引用及拖拽式非整数 Transform 的完整 State,断言保存返回和后续 load 均完整相等;同时保留旧空对象/未知 envelope 字段拒绝、CAS 和 `.previous` 恢复测试。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs`
## 资源管理第二轮修复后不能继续用第一轮文档和弱测试作为验收合同
- 现象:代码已经改成分区内 SVG plane 和卡内媒体,文档仍要求单全局 Overlay 或中央大图;CSS 正则和浅层 AppSurface 测试保持绿色,但真实 Tauri WebView 仍会默认缩放、主动预览请求饥饿、过滤后媒体继续播放或超深布局反复提交非法坐标。
- 原因:第一轮编码时同步编写的 PRD / 技术方案被后续代码修复绕过,第二轮只改实现和局部测试,没有把新验证结论回写正式合同。React 合成 wheel 事件、单例滚动 ref、只按数量限制的 base64 缓存、跨 scope 共用的活动读取计数、无优先级有界队列和前后端不同坐标边界又分别跨越浏览器、会话状态与 IPC 边界,浅层文本断言无法证明运行时行为。
- 处理:每轮验证后按“当前代码 + 最新决策 + 真实运行证据”同步修订 PRD、技术方案、决策记录和回归测试。原生可取消事件要直接断言 `defaultPrevented`;队列验证主动请求替换预取;预览 data URL 只作临时传输并转为可撤销 Blob URL,LRU 同时限制项目数和总字节;项目 / mode 切换推进 epoch,旧 `finally` 不得扣减新 scope;媒体状态同时核对可见集合;滚动分别按内外 scope 保存;坐标合同由共享 TS 与 Rust 同边界维护;暂时 / 永久错误在模型中显式分类。
- 验证:运行定向 hook / AppSurface / 纯布局 / Rust 边界测试,再执行类型检查、编码检查和 `git diff --check`
- 关联:`apps/ai-game-creator-shell/src/view/project-development/index.tsx``apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts``apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts``apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs``docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`
## 预览读取不能像放弃旧 CAS 回调一样直接重置并发槽
- 现象:A 项目的 3 个预览 IPC 仍挂起时切到 B,前端把活动计数归零并立即再发 3 个;界面不会被 A 的迟到结果污染,但原生同时保留 6 个读取。连续项目 / mode / Hook / 窗口切换会继续叠加整文件缓冲、base64 临时字符串和 WebView IPC 载荷,绕过 `64 MiB` 终态缓存预算。
- 原因:布局 CAS 已经发出后只能依靠 revision / 系统锁仲裁,前端放弃回调是正确语义;只读预览却有明确的物理内存和文件读取成本,可以协作取消。把 scope epoch 的逻辑隔离误当成底层取消,又让每个 scope 自行拥有 3 个物理槽,实际并发就不再全局有界。
- 处理:前端继续用 epoch、优先级队列、LRU 和完整资源身份隔离展示状态,但每次挂载 / scope 变化生成不复用的 `scopeId`,每次 IPC 生成唯一 `requestId`。三个安全读取命令共用 Tauri 进程级 3 permit 管理器;切换和卸载调用窄 scope 取消命令,等待 permit 与固定块读取都检查取消。permit 和 request/scope 清理 guard 必须移入真正的 `spawn_blocking` 读取闭包;WebView 卸载或调用方 abort 只会丢弃外层等待,不能让仍在运行的 blocking 读取提前释放物理槽或从取消 registry 消失。取消任务在 base64 前退出,所有终态清理活动 request / scope registry。seen request tombstone 最多保留 `8192` 项;非活动 cancelled scope tombstone 的预算为 `1024` 项,活动取消 scope 为防复活必须临时钉住并在结束后重新收敛。不得为了追求整个 registry 字面清零而删除防重放 / 防复活记录,也不得让已经结束的 scope 长期占用预算外记录。不得放宽原有项目边界、登记、权限、链接、签名、大小、漂移或安全 SVG 门禁。
- 验证:先让旧 scope 占满 3 个 permit,再连续执行 A → B → A、mode、Hook 和多窗口切换;断言原生活动峰值始终 `<= 3`,旧等待任务不打开文件,旧在途任务在最近检查点释放,新 scope 随后启动,取消任务不编码 data URL / 不创建 Blob URL,最终活动 request / scope registry 为零。阻塞读取进入后主动 abort 外层 future,必须证明旧 blocking 任务仍占 permit、仍可按 scope 取消且新请求不能提前启动。另分别证明 seen request `8192` 项的硬上限、cancelled scope 超预算时不淘汰活动记录,以及任一活动 scope 结束后非活动 tombstone 立即收敛到 `1024` 项预算。前端再断言旧 `then / catch / finally` 和取消 ACK 均不写新 scope;内部取消类别只允许精确匹配,不能因真实错误正文恰好包含该标识而静默吞错。
- 关联:`apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts``apps/ai-game-creator-shell/src-tauri/src/resource_preview_scheduler.rs``apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs``apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs`
## 依赖线与资源卡不在同一 transform 层时会在滚动和缩放中分离
- 现象:静止时依赖线似乎对齐,触摸板缩放或连续滚动后线段会追赶、漂移或忽隐忽现;某一资源分区的长线还可能出现在相邻分区。
- 原因:资源卡位于各自可滚动、可缩放的 section plane,全局 SVG 却是外层兄弟节点;通过 `getBoundingClientRect`、RAF 和 React state 重建屏幕端点无法与浏览器合成层 transform 原子同步。把四个 viewport 做成一个 clipPath 并集也不具备“每条线属于哪个分区”的所有权语义。
- 处理:让每个固定分区在自己的 `.game-resource-plane` 中拥有独立 SVG,卡片与路径都直接使用布局逻辑坐标并共享父级 CSS scale / 原生 scrollviewport 原生 overflow 负责本区裁剪。DOM 测量只换算本区逻辑 viewport,用于完整路径、incoming / outgoing 继续线和两端离屏隐藏,不参与端点身份或主路径坐标。每区 observer 和 RAF 各至多一个,卸载时清理。
- 验证:同时挂载至少两个分区和各自同类型关系,断言每条边只存在于对应分区 SVG;只滚动其中一分区,另一分区的逻辑 viewport 与 path 不变。另覆盖缩放后 SVG / 卡片仍在同一 plane、双向离屏继续线、两端离屏隐藏、marker、自环以及 mode / 项目切换清理。
- 关联:`apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx``apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts``docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`
## 正式保存不能等待 React state 才取得草稿 CAS 的新 revision
- 现象:用户刚完成编辑就点击“保存到项目”,自动草稿保存已经成功,但正式提交仍携带旧 `expectedDraftRevision`,于是单窗口也得到 draft revision conflict;快速连续保存时还可能使用不同 commitId 重复 staging。
- 原因:`setDraft(result.value)` 的 React state 提交晚于当前 Promise 链,正式保存若从闭包或下一次 render 读取 revision,会把 UI 调度时序误当成持久化顺序。相同问题也会出现在选择变化未标脏、父组件每次 render 新建 scope 对象而重复恢复、项目切换后旧 generation 回调继续写 notice。
- 处理:草稿保存队列在 CAS 成功后同步更新 `draftRef.current` 并直接返回权威 draft;正式提交继续使用该返回值的 revision。scope 按 project/draft/intent/source 原始字段稳定化,所有导入、保存、生成和事件回调捕获当前 epoch,选择变化属于草稿合同并必须标脏。首次正式保存冻结 commitId/idempotencyKey,未知结果只重放原请求。
- 验证:用 deferred Promise 证明草稿 CAS 完成后正式 commit 使用新 revision;相同 scope 值重渲染不重复 recover/load;锁定图层选择进入草稿更新;项目切换后迟到 generation/commit/event 均不改变新会话。
- 关联:`apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx``apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx`
## 共享画布不能用全局 DOM 查询或宿主整包 CSS 作为隐式依赖
- 现象:页面挂两个画布时,第二个画布点击小地图会移动第一个画布;反复挂载后 wheel 触发多次。Tauri 单独引入网站 `index.css` 时还会带入账号、项目页和历史业务样式,或因仓库外源码解析到第二份 React 而出现 Hook 错误。
- 原因:`document.querySelector`、body 级 portal、未清理的 listener/observer/animation frame 和不受作用域约束的 CSS 都把组件实例与网站宿主当成全局单例;Tauri Vite 默认根目录又不等于仓库根,React 解析路径可能分叉。
- 处理:共享小地图先从当前 viewport 查询,只有文档中唯一候选时才兼容旧单实例形态;wheel、ResizeObserver 和 animation frame 在 effect cleanup 中逐项释放。portal 支持实例 root,默认 body 只作兼容。共享 CSS 全部限定在 `.genarrative-image-canvas`,两宿主 alias 同一 `packages/` 源码并 dedupe React/ReactDOMTauri `fs.allow` 覆盖 repo root,禁止导入主站完整 `index.css`
- 验证:共享 React 测试重复 mount/unmount 后 wheel add/remove 数量相等、ResizeObserver 精确 disconnect,并挂两个含各自小地图的 viewport,确认只更新目标实例;网站与 Tauri 壳分别 typecheck/build。
- 关联:`packages/image-canvas-react/src/useImageCanvasViewportControls.ts``packages/image-canvas-react/src/CanvasPortal.tsx`、根目录与 `apps/ai-game-creator-shell` 的 Vite 配置。
## 桌面工作台不要让 Supervisor 内容高度挤掉输入区和 Agent Dock
- 现象:`1280×800` 或更矮窗口中,Supervisor 的消息、Runtime 状态和错误正文共同按内容高度增长,聊天输入被推到栏外;底部 Dock 使用固定宽度卡片时还会在中等宽度造成页面级横向溢出。中央画布的生成卡和状态栏若同时固定高度并隐藏 overflow,进度、失败、重试、保存或取消动作会被裁掉。
- 原因:四区工作台没有把“主区内部滚动”和“页面级滚动”分开;Supervisor 的长状态没有独立上限,Dock 卡片不能收缩,宿主又用全局按钮/固定行高样式覆盖共享 chrome。
- 处理:桌面工作台使用 `100dvh` 两行网格,第一行 `minmax(0, 1fr)` 承载中央区与 Supervisor,第二行承载 Dock;消息和 Runtime 分别内部滚动,composer 保持最后一行并设置明确层级。Dock 卡片使用可收缩 flex 与文本省略。画布 dialog、进度/失败卡和状态栏设置 `min-height: 0`、受限最大高度与内部滚动;宿主不再覆盖所有按钮,只为主要动作和布局提供 token 化薄样式。
- 验证:AppSurface CSS 合同检查 `100dvh`、Supervisor composer、Runtime 内滚动、Dock 常驻/可收缩;素材画布测试检查生成 dialog、operation card、状态栏和窄屏保存动作不会被裁剪。真实入口在 `1280×800` 测量 document/body client 与 scroll 一致;登录门禁不可为视觉测试绕过。
- 关联:`apps/ai-game-creator-shell/src/styles.css``apps/ai-game-creator-shell/src/features/asset-canvas/assetCanvasSurface.css``apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts``apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx`
## 素材保存区不要把机器 subtype 当普通文本框(2026-08-06
- 现象:保存区同时显示“画布素材”和裸 `asset` 文本框,普通用户无法判断两者用途;自由修改 kind 会形成无法稳定参与类型布局、Agent 合同和替换兼容性的 subtype。工具栏与保存设置挤在同一行时,主要保存按钮还会被压缩或裁切。
- 原因:把 Host Port 的 `name / assetKind / mediaType` DTO 直接映射成同层输入控件,没有区分用户命名、机器分类和编码格式,也没有为中央区域的真实容器宽度保留主操作列。
- 处理:名称保留编辑;create kind 使用 Runtime 权威四项目录和中文标签,refine 从源 manifest 继承并锁定,未知历史值只透传;格式继续使用有限枚举。工具动作和保存设置显式上下分行,保存列使用 `max-content + nowrap`,窄容器时按钮独占整行。普通工作区状态只显示项目名称,不把绝对路径作为默认辅助文案。
- 验证:Surface 测试断言四项用途、默认值、精修未知 kind 锁定、最终 commit 参数和保存按钮 CSSAppSurface 断言普通界面找不到绝对路径,内部 Tauri 调用仍使用原完整路径。
- 关联:`apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx``apps/ai-game-creator-shell/src/features/asset-canvas/assetCanvasSurface.css``apps/ai-game-creator-shell/src/features/project-workspace/`
## 正式素材提交不能把多文件写入或 Tauri 事件误当成一次原子动作
- 现象:图片已经落到 `assets/` 但 manifest 没有资产,或 manifest 已追加而 project revision/草稿仍是旧值;进程在 emit 前后退出后,用户重试又得到第二份图片、第二个 asset 或重复选中。
- 原因:文件系统只保证单文件原子替换,不能让最终图片、manifest、`.agent/runtime/project-revision.json`、commit ledger 和草稿跨文件物理原子;Tauri event 也没有跨崩溃 exactly-once。若先写副作用再临时生成幂等身份,或只凭目标文件存在推断成功,就无法区分未提交、已提交未回包和部分提交。
- 处理:第一次保存前冻结 `commitId + idempotencyKey + eventId + requestFingerprint`,在项目 write lock 内先写 prepared journal 和 before/after 摘要,再按最终图片、manifest/revision 逻辑原子更新、回读、ledger/草稿提交推进,释放锁后最后 emit。恢复只按 journal stage、精确字节摘要和 ledger 前向完成/安全回滚;矛盾状态进入 reconciliation-required。事件采用至少一次,监听方按 eventId 和 project revision 去重。
- 验证:分别在 prepared、图片安装、manifest 安装、revision 安装、ledger 提交、emit 和投递标记后强杀;确认只有唯一 `canvas-<commitId>`、revision 最多推进一次、源资产与血缘正确,响应丢失后返回 already-committed,矛盾 fixture 不自动重试。
- 关联:`docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`
## 素材保存成功不等于迟到结果仍有权抢占当前焦点
- 现象:用户等待生成/保存时切到另一个项目、run、另一份素材草稿,或主动选择其它资源、修改搜索条件;旧请求完成后界面却切回旧画布、清空筛选并自动选中新资源。
- 原因:异步回调只检查“请求成功”或捕获的旧 `isMounted/projectId`,没有绑定中央状态 session、draft/intent、selection epoch 和 query epochmanifest 投影这一数据事实又被错误地与“当前应自动聚焦”的用户意图合并处理。
- 处理:保存开始捕获 `projectPath + projectId + centerKind + sessionId + draftId + intent + selectionEpoch + queryEpoch`,响应时从当前 ref/store 完整复核。manifest 可以按精确项目身份更新当前上下文或后台缓存,但自动切状态、选择、滚动和聚焦必须等当前 mode 布局 ready 且全部焦点守卫仍相等。新资源被搜索/筛选隐藏时保留条件与选择,提示“新资源已保存,当前筛选条件下不可见”,只提供显式清除/定位动作。
- 验证:使用 deferred commit/layout Promise,依次在请求后切项目、切 run/overview、新开 session、改选择和改筛选;断言 manifest 只更新对应项目,新资源仍进入投影/布局,但所有失效守卫都不切中央状态、不改选择、不清查询。条件未变化且资源可见时才自动定位。
- 关联:`docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md``docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`
## 派生 Debug 会让完整配置经应用状态递归进入日志
- 现象:配置和状态当前没有直接日志调用,但新增一行 `debug!(?state, ...)``format!("{config:?}")` 就能把 JWT、后台口令、支付私钥、OSS / provider key 与 SpacetimeDB token 一次性写入日志及 OTel 留存面。
- 原因:`AppConfig``AppState``AppStateInner` 曾使用派生 `Debug`;状态继续递归格式化多个含配置的 client。即使顶层状态停止下钻,`SpacetimeClientConfig``SpacetimeClient` 的独立手写路径仍会绕过顶层防线。
- 处理:配置和聚合状态只实现封闭的手写安全摘要,不格式化任一自由字符串或含凭据的嵌套 client;`SpacetimeClientConfig` 独立脱敏,`SpacetimeClient` 只复用该安全摘要。不要以默认 `info` 级别或当前零调用点代替代码约束。
- 验证:同一唯一哨兵同时填入全部凭据字段、可能带凭据的 SpacetimeDB URL 和数据库名,逐一格式化 `AppConfig``AppStateInner``AppState``SpacetimeClientConfig``SpacetimeClient`,断言哨兵零出现且安全运行摘要仍存在。
- 关联:`server-rs/crates/api-server/src/config.rs``server-rs/crates/api-server/src/state.rs``server-rs/crates/spacetime-client/src/active.rs`、Issue #148
## Chat 生成预算字段不能按模型名猜测或失败后自动重放
- 现象:同一个 OpenAI-compatible Chat endpoint 调用 reasoning 模型时返回 `Unsupported parameter: max_tokens`;直接把全局请求字段改成 `max_completion_tokens` 后,旧兼容网关又可能拒绝新字段。
- 原因:内部生成预算语义与上游 wire dialect 被混在一起。Chat 当前字段是 `max_completion_tokens`,旧兼容层仍只接受 `max_tokens`Responses 和 Anthropic 又分别使用自己的字段。模型名、base URL 和 `OpenAiCompatible` 标签都不能证明 endpoint 能力,收到 `400` 后重发还可能重复计费。
- 处理:在 `LlmConfig` 上显式声明 Chat token budget field capability;通用兼容配置默认 legacy,已验证的 VectorEngine 专用 client opt-in `max_completion_tokens`,每次只发送一个字段。内部 `max_output_tokens` 与 AGC 持久指纹键 `maxOutputTokens` 保持不变。
- 验证:序列化测试分别断言 modern / legacy Chat 只出现选定字段,请求级 model override 不改变字段;Responses 继续只发 `max_output_tokens`Anthropic 继续只发 `max_tokens`AppState 测试断言 VectorEngine client 已显式启用 modern capability。
- 关联:`server-rs/crates/platform-llm/src/lib.rs``server-rs/crates/api-server/src/state.rs``scripts/test-ve-llm.mjs`、Issue #143
## Runtime 状态写失败不能发生在公开失败消息之前
- 现象:用户提交长任务后只看到运行失败或任务直接消失,聊天里一条有用消息都没有;另一些失败又同时出现 Runtime event 和 conversation 两条近似提示。
- 原因:启动确认依赖实际 `turn.started`,任务只入队或 Runner 在 start transition 前失败时没有公开回执;终态失败先写 task/event/state,最后才由各 main-loop 分支尽力追加 assistant。坏掉的若正是状态文件,流程会在公开消息前返回;散落的 `let _` 又无法提供幂等身份。
- 恢复补充:不能把“accepted 还没写完”等同于“用户从未投递”。用户消息已持久时,真实 resume 必须补写 accepted 后才入队;用户消息或 accepted conversation 已存在时,后续审计失败不得留下“正在启动”但永不执行的假状态,但同 message ID 的 role/content 冲突必须把 task 明确收束为 `conversation-write-failed`。根终态首次公开写入的瞬时失败必须在终态投影后用相同 message ID 重试。带 parent 的 Supervisor continuation 不得同时产生 Session 终态和 Runtime 事件两条公开消息;秒级时间戳下必须以 task/status message ID 共享的 run 关联摘要排序,不能用不同消息类别的计数猜测顺序。
- 验证:任务 journal 必须显示 `preparing -> pending`,仅有 accepted 时恢复才可提升;破坏项目 conversation 时断言任务为 `public-status-write-failed` 且无可运行 pending;破坏 Runtime state 路径时断言公开失败已经存在;重复写同一 run/status 只有一条 message ID;渲染实际 prompt 断言不包含 Runtime 公开状态;AppSurface 证明根启动/失败事件不重复,专业 Agent 启动仍可见。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs``agent/runtime_driver/task_start.rs``src/features/agent-runtime/model.ts``src/features/project-workspace/SupervisorChatOnlyView.tsx`
## manifest 被旧快照写回 Pending 时不能让父 Run 抛下真实运行中的 child
- 现象:`code-prototype` 已有确定性 child run、running journal 和工具事件,父 Supervisor 却在几秒后以 fixed graph stalled 失败;child 随后完成代码与静态检查,但 completion gate 持续报告 `task=code-prototype status=pending`,最后 `loop-budget-exhausted`
- 原因:并发 hydration 或其它旧 manifest 快照把 scheduler 已写的 running 覆盖为 pending;父 Run 把“当前不宜调度新 child”错误等同于“没有 child 需要等待”,并只以 manifest 状态判断 DAG 活性。父先终态后,真实 child 也失去正常投影窗口。
- 处理:调度与等待分离。新调度可以被派生视觉修复等门禁阻止,但当前最新活跃根 Run 下,只要 durable child 具有确定性 runId、scheduler source、正确父链接且 journal 仍处于 queued/running,父 Run 就继续等待;同一 child 的完成门可容忍 manifest pending,但仍执行正式产物、revision、静态检查与试玩证据门禁。更新根 Run、GUI/CLI、终态/确认/reconciliation child 或绑定冲突全部失败关闭。
- 验证:人工把当前 child 的 manifest 状态回写 pending,断言父 DAG 仍 in progress、父上下文可持久化为 `waiting-for-manifest-tasks`、child 可投影 completed;随后创建更新根 Run,断言旧 child 不再保持 DAG 活性且 completion blocker 恢复 `status=pending`。不要靠增加 loop 次数或伪造 completed 掩盖竞态。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`
## 固定关键词提示不能代替 Supervisor 选择条件任务图
- 现象:用户只说“现在没有用到任何美术资源”,Graph 就在 Supervisor 输出任何计划前自动打开美术节点;或者用户想复用现有素材,Runtime 直接按关键词预完成节点。Supervisor 无固定计划时随即 `fixed-task-graph-stalled`,看起来像模型不理解意图,实际上模型根本没有获得决策机会。
- 原因:同一套关键词函数同时承担 prompt hint、Graph reset、baseline 豁免和历史试玩类型继承,启发式信号越过 Supervisor 成为了控制面真相;main loop 又在 Provider 请求前优先调度 ready task。
- 验证:直接使用用户原句,断言 hint 命中但 manifest 全部保持 pending、决策前零 child、Provider request 包含路由工具;决策后只启动 code-prototype,它未完成 asset.list 时不得委派美术;再分别覆盖完整复用、真实缺口和显式重做。
## 既有正式产物不能同时被快车道视为已完成、被本轮 baseline 门视为未变化
- 现象:增量任务已有完整美术图集,`art-asset-plan` 每轮都返回零 action 和“已验证交付”,但 completion gate 每轮都报告 `assets/manifest.art.json(unchanged-from-run-baseline)`;最终 child `loop-budget-exhausted`,随后 Graph 和父 Run 失败。日志中没有本轮 Provider request、tool plan 或 action receipt。
- 原因:Graph reset 无差别重新打开稳定的美术 owner 节点;快车道按“当前产物有效”判断完成,owner 完成合同则按“本轮必须修改 baseline 产物”判断完成,两套语义互相冲突。增加 loop 预算、伪造版本号或机械改写 manifest 都不能消除冲突,还会引入 verification loop、字段丢失或错误复用旧主题。
- 处理:关键词和资产探测只作为 Supervisor 的 advisory context,不能直接修改 Graph。根 Run 先以 `audit-existing-first` 持久化用户 intent;即使用户提出整体视觉重做,这也不授权强制重生成。决策后只启动 `code-prototype`,由它在成功 `asset.list` 后提交或建立权威覆盖/缺口 delivery。Runtime 验证合同后才允许已有资产复用,或只打开精确缺口 owner;根完成门继续要求主 Agent 认领回执并完成接入、Canvas、私有回执、切片、可见使用和试玩验收。
- 验证:先断言 Supervisor 决策前零 child、固定关键词不会预完成节点,再覆盖完整复用、仅缺图集和明确重做。还要直接经过父完成门,证明合法持久 route/delivery 不再出现 art baseline gap,并证明删除切片后覆盖合同拒绝复用;旧 root、错误 fingerprint、虚构或遗漏缺口、重复委派以及 child 写入 `game/**` 都应失败关闭。
## 单主 Graph 升级不能只迁移 sidecar,必须同时处理活跃旧 Run
- 现象:v1 decision/route 能迁移,但升级前正在运行的 `code-prototype` 因 prompt 文本变化被 scheduler 判为身份冲突;同时旧 fixed-graph 的 `art-director / art-asset-plan` 仍持有 scheduler binding,可以脱离新主 Agent 继续生图。
- 原因:迁移测试只手工构造了非确定性主 Run,未经过真实 ready scheduler;资源 route 的迁移也没有自动让已启动的旧责任链失效。硬截止处理若仍只接受根的直接 child,还会把新的嵌套美术 child 留在 running,而丢失 reconciliation 投影。
- 处理:确定性主 Run 只白名单兼容已知 canonical task 文本版本,所有其它身份字段继续精确校验;旧 fixed-graph 美术 child 及沿 isolated instance 父链可证的历史后代在计划和所有非只读工具入口失败关闭,只允许当前 `code-prototype``asset.list` 后重新委派。新美术 child 同样使用显式只读白名单,写工具只允许可证明落在 `assets/**` 的文件/patchset 与 `canvas.asset_generate`,不能借 `memory.write``task.create/update` 修改 memory 和 manifest;会认领 delivery 并写 observed 状态的 `agent.run_status` 也不是只读。绝对硬截止显式验证根、主 Agent、delegated art child 的完整 task/binding/delegation 链。
- 验证:用真实 scheduler 恢复确定性 v1 主 Run;把旧 scheduler 美术 child 置为 running,断言 `canvas.asset_generate``memory.write``task.create``task.update``agent.run_status` 均被拒绝;再持久化其历史 `game/**` writeScope isolated 后代,断言恢复执行写操作仍失败且项目未变。对当前合法美术 child 同样验证 memory/manifest 零写入,再让它带在途外部生成命中硬截止,断言状态进入 `needs-reconciliation` 且 pending/batch/外部生成账本原样保留。
## 委派幂等与 child 写入测试不能和真实后台 worker 抢状态
- 现象:测试刚建立 static delivery,自动 parent-wake 就抢先恢复并终结父 Run,使随后同 action 重放被“当前 durable task 仍为 running”拒绝;受限美术 child 测试也可能在后台 worker 抢先终态后,让本应允许的 `assets/**` 写入误报 verification failure。
- 原因:测试 fixture 同时手工推进 journal/manifest,又允许真实后台 future 执行同一父子 Run;单测运行时序决定谁最后写入。若为让测试通过而把 active durable task 门禁整体移动到 existing-child 分支之后,该分支仍可能补建 delivery 或投影 Ready,反而允许终态/过期父 Run 发生修复性写入。
- 处理:保留生产门禁顺序,父 Run 终态后的迟到 delivery 继续只允许 suppressed。需要断言回执、claim 与同 action 重放时,测试持有父 Agent execution lane,断言结束后释放再执行 wake;直接测试 child 写工具时持有目标 Agent lane,再把 child 持久推进到 `running`。所有 lane 均由 RAII 释放,不能依赖后台 future 的调度时机。
- 验证:覆盖父 Run active 时 claimed delivery 的同 action 重放返回 existing、不同 action 的重复缺口仍被拒绝、父终态后的新委派/迟到 delivery 继续失败关闭或 suppressed、合法运行中 child 只可写 `assets/**`。macOS 直接拼接 `std::env::temp_dir()` 的仓库安全测试还应先规范化临时根,避免 `/var -> /private/var` 被误当成项目内符号链接。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs``apps/ai-game-creator-shell/src-tauri/src/repository_context.rs`
## 执行锁移交给未确认启动的异步 future 会制造永久 queued
- 现象:父 Supervisor 与 Runner 一直显示运行中、heartbeat 正常,专业 Agent 已有 `background_task.queued``autonomous_ready_task.scheduled`,对应执行锁也被 Runner 持有,但该 child 永远没有 running journal、`turn.started` 或后续 Runtime event;其它同批 Agent 可能已经完成。
- 原因:ready scheduler 把 per-Agent 执行锁直接 move 进 fire-and-forget Tokio future,并在 future 首次 poll 前返回成功。锁移交不是启动确认;future 未进入 start transition 时,常规 wake/recovery 又拿不到同一把锁,queued task 因而没有任何接管者。预先占用 child locks 的测试会绕过真实 spawn 路径,无法发现该缺口。
- 处理:实际 Runner 必须在项目写锁外同步完成 pending -> running 与 started journal,成功且 execution worker 已开始轮询后才移交执行锁;启动/接管失败必须持锁完成 child failed 与 manifest Graph failed 投影,再释放锁并让 parent 收到错误。scheduled 诊断审计失败不能阻断 child 启动,external client 只负责 wake Runner。UI 另以 durable `startedAt` 和父/子最大活动时间显示运行态时长及疑似停滞;task-record fallback 不得把最新 record 时间写成 startedAt,父 Run terminal 后也不得被 child 晚到事件继续增加时长。
- 验证:使用真实空闲 child lanes 一次调度 `design-director / art-director / code-director`,在有界时间内逐一断言 running/`turn.started`,并验证幂等重调度不新增逻辑 Run;禁止只断言 scheduled 记录、锁文件或 Runner heartbeat。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs``apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx`
## Canvas 视觉门不能在没有资产词法候选时运行整套 JavaScript 语义分析
- 现象:Supervisor 已写入 `turn.started`,但第一条 planning 进度长期不出现;Runner 无 Provider 连接,单个 Tokio worker 持续占满一核。对项目现场复现时,15 KiB 的经典脚本在检查一个根本未被引用的切片文件时,超过 60 秒仍未返回。
- 原因:视觉门先为每个候选切片无条件执行模块依赖分析和函数可达性分析,最后才判断图片 `.src` 或已绑定 DOM 元素是否能指向目标资产。没有 `import` 关键字、没有目标文件名且没有已绑定图片元素时,这些全程序分析不可能产生有效视觉证据,属于纯浪费;复杂闭包与 alias 图会把浪费放大成看似 Runtime 停滞。
- 处理:仅做单向安全短路:JavaScript 原文同时没有大小写精确的 `import``export` 字节序列时,跳过模块依赖语义分析;纯 `export ... from` / `export * from` 仍是模块图依赖,不能误跳过。当前脚本不含目标文件名或任一已绑定 DOM 图片元素 ID 时,先低成本解码 `\\xNN``\\uNNNN``\\u{...}`、简单转义和续行;解码后仍无候选才直接判定没有绘制证据,解码不确定则保守进入 Oxc。这样必须保留 computed `s\\x72c`、转义资产 URL 与转义 `getElementById/querySelector`,同时不能因 HTML 中存在某个绑定元素让所有无关 JavaScript 单元进入重分析。
- 验证:无候选现场的同一切片检查必须有界返回且保持 `missing-visible-art-slice-use` 结论;同时覆盖相对路径、转义 URL、computed/转义属性与 DOM 方法、绑定 DOM 图片元素、含无关正则转义的脚本单元、路径大小写、纯重导出模块图、动态 import namespace 写入、未调用函数、恒假分支和真实可达 `drawImage`,证明短路只拒绝不可能命中的输入,不扩大验收权限。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`
## ready-task 对账取消后不能让 successor 永久继承 failed Graph
- 原因:取消原 reconciliation Run 只负责安全释放 Agent 队列屏障,并不等于 manifest 任务完成;continuation 完成合同保留既有 Graph 进度,却没有区分“普通失败”和“已经人工核对、保留 cancel tombstone 的 reconciliation 取消”。
- 处理:旧 action 继续禁止重放或伪造 observation;旧 child 与父 Run 先真实终态。新 Supervisor continuation 仅扫描同 Session、同 source、同有效任务合同的历史根 Run,并要求对应 ready-task 同时存在 `failed / needs-reconciliation` 记录、最终 `cancelled` 记录和 durable cancel tombstone,才把当前 manifest 的同一 failed 节点恢复为 pending,让 scheduler 创建新 child Run。manifest 的读取、筛选、child 证据重验和写回放在同一项目写锁内;每个 task journal 只读取一次并按 parent Run 建索引。较新的无 child Run 默认阻断旧凭证,只有其 root journal 精确证明为旧 failed Graph 在进入 scheduler 前即失败时才允许向前查找;scheduler 自身失败不得被当成该兼容场景。
- 验证:构造 reconciliation child、人工 cancel tombstone、failed manifest 和终态父 Run,证明同源 continuation 只重排该节点;并列普通 failed 节点保持 failed,完成合同继续继承原任务 SHA 与项目 baseline,旧 pending action 不恢复。追加覆盖“旧 failed Graph 未调度”的中间 Run 可以跨过,而较新的 scheduler failure 即使没有 child journal 也会阻断更老 tombstone。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs`
## `timeout_at` 不能替代显式的预算耗尽预检
- 现象:给完美像素加端点级并发闸后,预算已经耗尽的请求仍然能拿到许可,白占一个名额继续去打几轮全账号 SpacetimeDB 扫描,直到下载那步才失败。
- 原因:`tokio::time::timeout_at` 会先 poll 一次内层 future 再判超时。信号量有空闲许可时 `acquire_owned()` 首次 poll 就绪,于是即使 deadline 早已过去,返回的仍是 `Ok(Ok(permit))` 而不是超时。既有 `acquire_editor_pixel_art_cpu_permit` 里那句 `if Instant::now() >= processing_deadline` 正是为此存在,新写的许可函数漏掉后被单测抓出。
- 处理:所有「先判预算、再等资源」的获取函数都必须在 `timeout_at` 之前显式判一次 `Instant::now() >= deadline` 并直接返回超时错误;这句不是冗余防御。同理,进入排队计数之前也要先做这个预检,避免为注定失败的请求占用队列名额。
- 验证:在有空闲许可时用已过期的 deadline 调用获取函数,只断言返回 `504` 而不是许可;`504` 已足以证明显式预检没有被 `timeout_at` 的首次 poll 绕过。禁止在该用例里读取进程级队列 Atomic 的 before/after;相对断言同样会被并行测试插入。仅靠「信号量占满时超时」的用例发现不了这个问题。
- 关联:`server-rs/crates/api-server/src/editor_project.rs``acquire_editor_pixel_art_snap_permit``acquire_editor_pixel_art_cpu_permit`)。
## 有界等待队列的计数递减必须写在 Drop 里
- 现象:给同步端点加「最多 N 个等待者」的保险丝时,若把计数递减写在正常返回路径上,客户端断连或超时触发会让等待中的 future 被丢弃而跳过递减;计数只增不减,最终队列永久判定为满,接口对所有人返回 `503` 且不会自愈。
- 原因:Rust 的 async future 可以在任意 await 点被取消,取消时只保证 `Drop` 会跑,不保证后续代码会执行。有界队列的入场与离场天然不对称。
- 处理:把递增封进一个 guard 结构体,递减放在它的 `Drop` 实现里;递增本身用 `fetch_update` 的 CAS,不能用「先读后加」——两个线程同时读到 `max - 1` 各自加一就会越界。拿到资源后立即 `drop(guard)` 让出队列名额,不要让它跟着许可一起活到请求结束。
- 验证:单测覆盖 CAS 边界(满了返回失败且计数不越界、上限为 0 时任何进入都失败),并由独立用例覆盖 guard 离开作用域后的计数归还。预算耗尽路径只断言 `504`,不得通过另一个测试也会修改的进程级 static before/after 来推断“未入队”,也不得用串行锁或 `--test-threads=1` 掩盖隔离问题。
- 关联:`server-rs/crates/api-server/src/editor_project.rs``try_enter_bounded_queue``EditorPixelArtSnapQueueGuard`)。
## dependency 不能复用 type 的紧凑间距或让窄层始终顶部对齐
- 现象:相邻卡片间的橙色引用只剩一个箭头,看起来像长度异常;同一个菱形 / 分叉关系中,上半组线很短而下半组线绕很远。
- 原因:type 模式的 `16px` 紧凑行列间距不足以同时容纳 marker 安全距离和可辨认线身;分层布局若只按每层 index 从簇顶向下排,单节点层无法与多节点层的垂直中心对齐。
- 处理:dependency 自动坐标使用独立 `48px` 列间距和 `40px` 行间距,type 继续使用 `16px`。相关簇记录最大层行数,每层起始 y 增加 `(maxRows - layerRows) * dependencySlotHeight / 2` 的确定性偏移;平局仍用稳定资源 ID,手动坐标仍原样占位。不能通过裁短长线、偏移真实端点或压缩 Rust depth 伪造一致长度。
- 验证:纯模型锁定 type / dependency 间距隔离、四节点菱形的首尾单节点层居中、同层稳定顺序、历史手动坐标和 4096 项性能;Overlay 使用相邻 dependency 槽位证明箭头前保留可辨认线身。
- 关联:`apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts``ResourceDependencyOverlay.tsx``resourceCanvasLayoutModel.test.ts`
## 依赖聚类不能把聚合 task-flow 展开为资源两两边
- 现象:为了让 task-flow 的两端资源靠近,若对每个 source × target 构造边,资源多的任务流会迅速放大内存、排序工作和虚假关系;同一图输入还可能随着成员枚举顺序出现不稳定排列。
- 原因:task-flow 的业务语义是任务对的聚合流,不是资源间的完整笛卡尔依赖;dependency depth 也已经由 Rust SCC read model 权威计算,前端不能用布局边重建业务方向。
- 处理:布局分组把每条 flow 作为一个临时流节点,仅与其 source / target 成员相连;中位数扫描读取流另一端成员现有 rank 的中位值。遍历保持迭代式,扫描轮数固定,所有初始序和最终平局都以稳定资源 ID 收口。用于自动重派生的拓扑签名先按固定分类过滤并规范化稳定 ID,再生成固定大小摘要,不能把显示名、卡片大小或浏览器几何加入签名。跨分类 reference 与跨分类-only task-flow 不进入前端布局;所有 task-flow 都不进入 SVG 或画布关系说明。`producerMappingTruncated` 时继续只消费现有同类型精确引用,不重建 task-flow。
- 验证:纯模型覆盖多入、多出、聚合 task-flow、环、4096 链和重复输入坐标一致;Hook 覆盖仅改邻接、深度不变仍重派生自动坐标。不得把搜索后的可见集传入聚类。
- 关联:`apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts``useProjectResourceCanvasLayout.ts``resourceDependencyGraphModel.ts`
## 四分区 SVG 不能把跨类型业务关系当成可绘制几何
- 现象:跨分类资源位于彼此独立滚动和裁剪的 viewport;若仍绘制一条全局 SVG 路径,只会在两个分区中留下没有完整上下文的断线,滚动时还会看似随机出现或消失。同一卡片多边若都锚在中心点,也会让合法的同类型线叠成一束。
- 原因:Rust read model 的业务关系范围大于资源管理画布的展示合同;四分区视图没有跨标题栏的合法连线走廊。几何层直接遍历全部 reference edge 等于把业务真相误当成全部可视关系;单中心端口又忽略了边的稳定身份与对端顺序。
- 处理:保留 Rust 图与权威深度;布局拓扑按资源分类过滤 reference 和 task-flow 超边切片,关系说明与 SVG 则只消费同类型精确引用。相同分区内按对端坐标、稳定边 ID 为同侧精确边分配有界端口。不要通过改变端点、隐藏同类型合法精确边或生成资源笛卡尔积来换取整洁。
- 验证:同时覆盖跨分类精确引用与跨分类-only flow 不聚类 / 不绘制、全部 task-flow 零 SVG / 零画布关系说明、同侧多边端口不重合且重复输入路径一致、同类环 / 自环和 4096 项回归。
- 关联:`apps/ai-game-creator-shell/src/view/project-development/index.tsx``ResourceDependencyOverlay.tsx``resourceCanvasLayoutModel.ts`
## Linux 生产脚本门禁不能假设本地也是 GNU userland
- 现象:macOS 本地运行维护页、生产 API 部署和 Rust 产物门禁时,依次出现 `mv: illegal option -- T``mapfile: command not found``/usr/bin/cp` / `/usr/bin/chmod` 不存在,以及 `.rlib` 明明含有 `.o` 却报告“没有可扫描成员”;安全修复计划还会把 `/var/folders``/private/var/folders` 的系统别名误判为用户符号链接。
- 原因:生产机是 Linux/GNU,而本地门禁运行在 BSD userland、Bash 3.2 和 BSD ar;测试桩硬编码 Linux 二进制路径与参数,归档解析器没有去掉 BSD 扩展成员名的尾随 NUL,路径校验也直接比较了未规范化字符串。
- 处理:维护 marker 使用同目录临时文件加 POSIX `mv -f`,并在替换前拒绝所有符号链接和目录目标,避免 `mv -f` 跟随目录链接把临时文件移入链接目标;生产部署测试桩在 macOS 忠实模拟 GNU `mv/ln -T` 的“目标不是目录”语义,并按平台选择系统工具;脚本收集服务使用 Bash 3.2 可用的 `while read`;rlib 解析清理 BSD 成员名 NUL;计划文件只规范化系统临时目录别名,仍拒绝其下用户创建的符号链接组件。
- 验证:运行 `npm run check:maintenance-page``npm run check:production-api-deploy``npm run check:server-rs-ddd``npm run test -- scripts/spacetime-repair-editor-canvas-resources.test.ts`,并在 Linux CI 保留同一生产脚本语义。
- 关联:`scripts/deploy/maintenance-on.sh``scripts/check-maintenance-page.mjs``scripts/check-production-api-deploy.mjs``scripts/deploy/production-api-deploy.sh``scripts/check-module-runtime-artifact.mjs``scripts/spacetime-repair-editor-canvas-resources.mjs`
## 分区内部滚动不能只重测分区原点
- 现象:若滚动时只重测分区原点,线会停在旧位置或穿过标题栏;若进一步把“卡片完整位于 viewport”当作关系挂载条件,同一合法关系会在卡片刚触边时突然消失、滚回又出现,箭头也可能恰好落在 clip 外而只剩一截线。
- 原因:全局 SVG 与 section plane 不共享 transform / scroll`getBoundingClientRect + RAF + state` 重建屏幕端点只能异步追赶浏览器合成层;卡片可见性又是显示裁剪状态,不是关系身份。SVG marker 贴卡或贴裁剪边界时还可能只剩主 path。
- 处理:每个 section plane 自己持有 SVG,让路径和卡片直接使用同一逻辑坐标与父级 scale / scroll;每区只以一个 Observer 和 RAF 维护逻辑 viewport。精确引用源端或目标端单独离屏时分别绘制 outgoing / incoming 边界继续线,两端离屏才隐藏;目标锚点预留固定箭头间隙,marker 使用 `userSpaceOnUse` 且允许 overflow;自环整体外移避免箭头压卡。搜索隐藏端点仍属于业务可见性过滤,不能与 viewport 裁剪混用。
- 验证:覆盖同帧多次 scroll 只调度一次 RAF、部分离屏时 outgoing / incoming 正确切换、两端离屏隐藏、缩放后路径与卡片仍处于同一 plane、箭头可见、分区互不串线,以及每区单 observer 与卸载清理。搜索隐藏任一精确端点时整条橙线隐藏;task-flow 始终不渲染。
- 关联:`apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx``apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`
## External Editor taskId 不能当作本地 manifest taskId
- 现象:Rust read model、资源详情或 dependency 聚类中缺少本应存在的 task-flow;测试用 `design-foundation` 之类字符串时正常,真实生成返回 `task-1` 后失败。画布不显示灰色 task-flow 是当前产品决定,不能再用是否出现虚线判断 producer 映射是否正确。
- 原因:`GameCreationAppAssetSource.taskId` 保存的是 External Editor 生成任务身份,命名空间与本地 `.agent/manifest.json` 的 Agent/task 身份不同;前端用 `taskById.get(source.taskId)` 会让真实画布资产全部失去 producer。
- 处理:资源依赖图的 Tauri Rust read model 从有界 `.agent/agent.db` 读取 `agent.runtime.canvas.asset_generate`,以 `assetId -> agentId` 映射 producer,并要求 `agentId` 存在于当前 manifest。记录缺失、多个不同有效 Agent 冲突或读取已截断时失败关闭 producer assignment、task flow 与对应 `cyclicTaskIds`,不回退 `source.taskId`。精确 `asset-reference` 仍只依赖 manifest 中外部 resourceId 的唯一匹配;Rust 独立返回的 `dependencyDepths` 继续作为 manifest / reference read model 权威结果,前端只过滤未知资源、负数、非整数和非安全整数,不得因 producer 截断把它整体清空。
- 验证:Rust fixture 把 `source.taskId` 固定为 `task-1 / task-2`,只有审计提供 `art-director / design-foundation` 后才生成 task-flow read model;移除或截断审计后该 flow 消失但橙色引用保留,合法深度仍为 `asset:spec=0 / asset:ui=1`。前端始终断言 task-flow 零 SVGAppSurface 使用截断生产数据形状证明深度 `0 / 1 / 2` 真实到达卡片布局,并且不会把已有自动坐标持久化成扁平布局。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs``apps/ai-game-creator-shell/src/view/project-development/resourceDependencyGraphModel.ts``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 依赖图未就绪时不能先初始化资源布局
- 现象:首次打开 dependency 画布时所有资源短暂按深度 0 排列;Rust 图返回后连线正确,但卡片仍停留在同一列,错误自动坐标还可能已经写入 sidecar。
- 原因:资源图和布局读取独立异步启动,布局 Hook 在图未返回时使用空图资源创建 fallback;后续 reconcile 按旧合同保留全部已有坐标,真实 producer 与 dependency depth 无法纠正首次自动位置。
- 处理:dependency 模式增加按项目与资源输入隔离的图加载屏障,`ready / failed` 前不启动布局 Hook 的 fallback、读取、协调或保存。Rust read model 返回确定性依赖深度;已有布局只永久保留手动位置,自动位置按最终图重新派生。type 模式不受图加载影响。
- 验证:用 deferred graph Promise 断言终态前 Tauri layout read/update 调用均为 0;图就绪后首次坐标直接按最终深度生成,旧 scope 迟到结果无效,手动坐标不变且相同自动布局不增加 revision。
- 关联:`apps/ai-game-creator-shell/src/view/project-development/index.tsx``apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts``apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs`
## 等价 Runtime 投影刷新不能清空资源依赖图(2026-08-10)
- 现象:专业 Agent 运行期间,资源画布中的卡片按轮询节奏整批消失并立即恢复;停止产生新的 Runtime 时间戳后闪烁减弱或消失。
- 原因:专业 Agent 轮询会重建结果数组;资源内容虽然相同,前端图读取 effect 仍因数组引用变化重新执行,并先把图和布局置空。布局位置暂时缺失时,所有资源卡都会返回 `null`
- 处理:用包含项目与资源输入的语义 scope key 稳定图请求参数,等价输入不重复读取;同一项目的资源集合确实变化时,异步刷新期间保留上一个已解析图和布局,只有首次加载或切换项目才启用空图屏障。刷新失败继续展示旧快照,不能用瞬态失败清空画布。
- 验证:AppSurface 先用全新但内容相同的 Agent 结果数组 rerender,断言图读取仍只有一次且原卡片 DOM 保持连接;再增加真实资源并延迟第二次图响应,断言旧卡片在刷新窗口持续挂载,新图返回后新增卡片正常出现。
- 关联:`apps/ai-game-creator-shell/src/view/project-development/index.tsx``apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`
## Supervisor steer 不能只有内部排队事件(2026-08-10)
- 现象:自主制作期间继续向项目总控发消息,用户消息已进入同一 Run,当前 Provider 也被中断并重新规划,但普通工作台短暂的提交状态消失后一直没有回复,直到整轮制作最终收束。
- 原因:steer 只持久化用户消息与内部 `steer.queued` 事件;公开事件投影又明确排除 `steer.*`。普通工作台提交后会用后端 conversation 覆盖本地消息,因此仅追加临时前端气泡也无法稳定跨刷新显示。
- 处理:根 Project Supervisor 的 steer 进入 durable `queued` 后,先写“正在判断、当前任务继续”的公开确认,再由独立 `steer-decision` LLM turn 返回自然语言回复和 `interruptCurrentProvider`。状态询问、解释和不冲突补充默认不中断;明确停止、改向或会使在途方案过期时才允许请求中断。判定和回复按 `run + steer` 持久幂等,刷新后仍可见;判定失败时继续当前任务,并在下一安全边界应用 steer。
- 并发边界:steer 入队、Runner `runtime.steer` 通知都不得直接触发 Provider interrupt。Codex app-server 的判定使用独立节点,不能等待主节点 turn 锁;判定为 true 后也只能中断 `appliedSteerCursor < steer.sequence` 的旧 Provider 请求,已经消费该 steer 后启动的新请求不可被误杀。已经开始的工具和外部动作不强杀,完成 observation 后再消费 steer。
- 验证:真实 mock LLM 回归必须覆盖状态询问回复且 `interruptCurrentProvider=false`;持久重放只保留一条语义回复;steer 入队后旧 Provider 继续运行,判定为 true 后才中断;新规划 Provider 的 cursor 已包含该 steer 时即使旧判定为 true 也不能中断。前端同秒多条消息保持“用户补充 → 判断提示/语义回复”的关联顺序。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs``apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs``apps/ai-game-creator-shell/src/features/agent-runtime/model.ts`
## Jenkins 异步备份不能用 nohup 脱离作业
- 现象:Stdb Publish 成功,上传日志只留下“已获取进程锁 / 上传已有备份 / 目标对象”,没有成功或可捕获错误;本地 tar.gz 和 `uploadStatus=deferred` manifest 每次发布后继续增长。
- 原因:`nohup` 只忽略终端 HUP,不会移除 Jenkins/Hudson 进程 CookieJob 收尾可清理后台 uploader。原链路只上传当次归档,旧 deferred manifest 没有扫描重试,而 `files-history` timer 只处理 `/stdb` 历史文件。
- 处理:发布退出时用独立 `systemd-run --collect --service-type=exec` transient unit 执行 `--upload-deferred-dir`,串行处理同库 deferred/pending 归档。启动前拒绝符号链接和非绝对路径;unit 启动失败必须保留 status、archive 和 manifest。补偿扫描不删除上传未验真的文件,也不扫描目录外路径。
- 验证:门禁必须禁止 `nohup`,要求命名 transient unit、`--collect``Type=exec` 与失败后保留 status;备份测试覆盖稳定顺序、同库过滤、已上传但未清理的归档收敛、归档缺失报告与路径逃逸拒绝。现场最终核对 backup lock、manifest、transient unit/result、根盘、SpacetimeDB/API/worker/controller/Nginx 和公开端点。
- 关联:`scripts/deploy/production-stdb-publish.sh``scripts/database-backup-to-oss.mjs``scripts/check-production-ops-guardrails.mjs``scripts/check-database-backup-to-oss.mjs`
## 图集切片上限必须早于合并、裁剪和编码
- 现象:透明图集含大量独立碎块或噪点时,接口长时间占用 async worker;最终即使报“超过 64 个切片”,此前仍已完成全量两两合并、裁剪和 PNG 编码。
- 原因:原始连通域无上限,辅助部件合并全量扫描所有 pair,输出限制只在 platform slicer 返回后由 api-server 检查;UI 提取还绕过了该 wrapper。
- 处理:platform slicer 对全部 flood-fill 连通域设置 `4096` 硬上限,用空间网格只查 `48px` 邻域候选;单网格最多 `256` 个组件、单 source 最多 `512` 个候选,避免拥挤网格重新退化为全量 pair。`maxOutputSlices` 与 padding crop 总像素预算在首片 PNG 编码前拒绝。图标自动、手动和 UI 三入口统一在 2 路 CPU semaphore 与 30 秒 / 请求 deadline 保护下 prepare 出共享 RGBA + bounds 计划,不再一次返回最多 64 份 PNG。api-server 只按需编码并用容量 2 的有界管线上传,OSS 连接 / 单请求超时固定为 `10s / 60s`;手动入口在下载最大 32 MiB 来源对象前取得独立内存 admission,同一 admission 覆盖下载、计划与上传生命周期,并在最后一次 HEAD 完成后、数据库调用前释放,排队请求、慢 OSS 或慢数据库都不能绕过内存边界。全部 `PUT + HEAD` 成功后,单个 SpacetimeDB procedure 在一个事务中批量确认对象、创建项目资源 / 账号素材并完成 cohortresource / asset ID 按 owner + task + 序号稳定派生,重放只复用内容一致的素材,来源资源必须存在且同 owner / project;不在上传失败后留下部分数据库批次,也不在不确定结果重放后复制整批素材。
- 验证:覆盖大量独立 `4×4` 块、超过上限的单像素噪点、65 个有效输出和既有高光 / 阴影合并样本;手动超限必须发生在首次持久化前,自动超限不得产生切片 PUT、资源或画布切片。
- 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs``server-rs/crates/api-server/src/editor_project.rs`
## Alpha 恢复失败后不能继续持久化原始后处理图
- 现象:BgFilter 返回比例漂移、损坏或低分辨率图片,provider 原图修复性回读又失败时,图标 / UI 仍可能落库透明图与切片,尺寸元数据甚至回退为 `512×512`
- 原因:Alpha helper 会同时返回原后处理字节和错误;角色调用方会 source-only 早退,图标 / UI 却只写日志后继续。相同尺寸快路径还只读图片 header,没有完整解码。
- 处理:角色、图标、UI 共用 provider 原图 source-only helper;比例漂移超过 `5%`、原图回读、Alpha 回贴或透明图完整解码任一失败都立即返回原图、通用 warning、空切片和空 `sliceWarning`,禁止透明图 PUT、派生资源、拆分和透明 / 切片画布层。provider 原图尺寸必须完整解码取得,不得伪造兜底值。
- 验证:覆盖错比例 Alpha、缺失 provider 原图、合法 PNG header 但截断正文;结构断言 source-only helper 不含任何透明持久化、切片或多图层完成调用。
- 关联:`server-rs/crates/api-server/src/editor_project.rs``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 工具 JSON Schema 的条件约束必须覆盖运行时默认值
- 现象:LLM 按工具 schema 生成的参数可以通过结构约束,但参数补默认值后被运行时校验拒绝,白白消耗一次工具修复轮次。例如固定 `gpt-image-2` 的 UI 工具仍暴露 `0.5K`,或视频调用省略 `model` 时 schema 允许 `1080p`,运行时却默认成 `seedance2.0-fast` 后拒绝。
- 原因:通用枚举 schema 被固定模型工具直接复用;JSON Schema 的 `if` 又用 `required: ["model"]` 排除了字段缺失场景,而 Serde 默认值只在 schema 校验之后生效。description 只能提示 LLM,不能替代 `enum` / `if` / `then` 的结构约束。
- 处理:固定模型工具使用与该模型能力一致的专用枚举;可切换模型的图片工具在对象层复用共享 `model + image_size` 条件约束。条件字段有运行时默认值时,省略字段必须落入默认模型对应的 schema 分支:默认 nanobanana2 的图片工具只在显式选择 `gpt-image-2` 时收紧尺寸,所以条件保留 `required: ["model"]`;默认 fast 的视频工具则利用字段缺失时 `properties.model.const` 条件成立的语义,不额外要求 `model` 存在。运行时校验仍保留为最终防线。
- 验证:锁定 `generate-ui-design.image_size = ["1K", "2K"]`,三个可切换图片模型的工具都接入共享 `gpt-image-2 -> image_size = ["1K", "2K"]` 条件,以及视频 fast 条件没有内层 `required`、其 `then.resolution = ["480p", "720p"]`;同时保留运行时拒绝 `gpt-image-2 + 0.5K``seedance2.0-fast + 1080p` 的测试。
- 关联:`server-rs/crates/platform-editor-agent/src/agent/tools/image_generation_options.rs``server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs``server-rs/crates/platform-editor-agent/src/agent/tools/generate_video.rs``docs/【编辑器】画布Agent对话面板-2026-07-03.md`
## 重复成功的 agent.message 不能被当成新的 Runtime 进展
- 现象:专业 Agent 已把一条定向消息写入目标 Session,却在后续 planning 中反复发送相同正文;目标会话看起来没有重复消息,但 Provider 请求持续增长,run 可能长期不返回自身终态回执。
- 原因:conversation 层的 messageId 幂等只能阻止重复落盘。若每个新 Runtime action 的 `status=ok` 都进入上下文进展指纹,相同 durable no-op 会不断刷新 6 轮停滞窗口;只检查目标会话条数无法证明 action loop 已有界收束。
- 处理:消息语义键必须包含来源 Agent/run、目标 Agent/已解析 Session 和清洗截断后正文 SHA-256conversation message、`conversation.message``agent.runtime.agent.message` 各自 exactly-once。重复调用继续完整记录自己的 action/observation/receipt,但私有 observation 固定返回 `messageAppended=false`ContextWindowTracker 只忽略这一精确 no-op,不能忽略不同正文的新消息。专业 Agent prompt 同时明确中途消息不能替代自身 final response。
- 验证:`background_agent_runtime_bounds_duplicate_agent_message_livelock` 必须真实驱动 6 个相同指纹、不同 actionId 的消息动作,证明 action/observation/receipt 各 6 条,目标消息和两类消息审计各 1 条,后 5 次不算进展,第 6 轮保留 `in_progress` 计划并进入 `budget-exhausted`,没有第 7 次 Provider 请求、context compaction 或 completed。另保留 `agent_runtime_context_window_counts_distinct_agent_message_bodies`,防止把真正不同的新消息误压成 no-op。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent.rs``apps/ai-game-creator-shell/src-tauri/src/project.rs``apps/ai-game-creator-shell/src-tauri/src/tests.rs``docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`
## Swarm E2E 的隔离 AppData 不能建在正式 AppData 里面
- 现象:真实 suite 自称使用隔离配置,但一次性 AppData 出现在正式 AppData 子目录;源目录 watcher、配置副本计数和清理归属变得含糊,Runner 还可能把临时 endpoint 或运行态写进正式目录树。
- 原因:把 `mkdtemp` 前缀拼在 source config dir 内,只隔离了文件名,没有隔离目录所有权;source-dir guard 无法区分 suite 自己的合法子目录写入与污染,失败清理也可能触碰正式目录边界。
- 处理:需要保护正式配置的 suite 一律在 `dirname(realConfigDir)` 下创建 sentinel 管理的 sibling AppData,并要求 realpath 后与源目录同父、互不包含。配置只使用私有副本或受控 hardlink/overlay,启动 CLI/Runner 全部指向 sibling;清理前核对 sentinel、源配置 inode/hash/link count、source-dir 前缀事件、正式 endpoint 身份和正式 CLI 调用计数,随后只删除拥有明确 token 的临时目录。
- 验证:真实报告必须同时满足 `isolatedAppDataUsed=true``sourceAppDataDirectoryUntouched=true``sourceRunnerEndpointUnchanged=true``formalConfigCliCallCount=0`、配置副本校验和 `AppDataCleanupPerformed=true`;项目选择 `--keep-project` 时也不能改变 AppData 自动清理。
- 关联:`apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs``docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`
## 异步 Runtime 测试不能把 child idle 当成终态结果已发布
- 现象:isolated child 已显示 idle,单次 all-join reconcile 却偶发返回空列表;或者 Runtime 已显示 completed / failedGoal、conversation、Agent DB 审计和 per-Agent lock 仍未完成,完整 Rust suite 里出现低概率失败,单独重跑通常通过。
- 原因:Runtime state、Goal sidecar、终态 result、conversation、审计记录、handoff 清理和执行 lane 释放不是同一个原子观测点;测试只等待 idle / failed 会在同一后台 drain 的 durable 收尾前抢先断言。
- 处理:产品协议仍以 durable terminal result 和 join readiness 为准。测试在有界时限内等待业务目标终态;需要断言同一 drain 的后续副作用时,同时以 per-Agent runtime task lock 释放为 fence,命中后重新读取投影。join 场景继续重复调用幂等 reconcile,直到取得唯一 join 或超时;不得靠固定长 sleep,也不能因为第一次为空就把协议改成吞掉未完成 child。
- 验证:`isolated_agents_with_same_template_run_independently_and_join_once` 最多执行 100 次、每次间隔 20ms 的 reconcile,并继续断言只有一个 all-join 和一次父唤醒;Goal、loop-budget、finalization 与 Supervisor reconciliation 测试必须在目标 status / phase 与 Agent lane 同时收束后再读取最终副作用。`background_agent_runtime_marks_response_plan_step_failed_when_final_reply_fails``background_agent_runtime_tasks_can_run_in_parallel_and_persist_replies` 同样必须经过该 fence 后再断言 `turn.failed``agent.runtime.completed` 审计。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/tests.rs``apps/ai-game-creator-shell/src-tauri/src/agent.rs`
## CI root 环境不能用文件只读权限注入写失败
- 现象:本地测试把 conversation 文件设为 readonly 后能稳定得到写入失败,Gitea Actions 中同一断言却发现写入成功并继续执行任务。
- 原因:隔离 job 内测试进程可能以 root 运行;root 不受普通 owner write bit 的同等限制,`set_readonly(true)` 不是跨 runner 身份的确定性故障注入。
- 处理:需要覆盖写失败恢复时使用仅在 `cfg(test)` 生效、一次性消费并限定写入阶段的 marker;生产路径仍走真实持久化函数。测试同时断言 marker 已消费、失败前数据未落盘和恢复后 exactly-once,不依赖 chmod、固定 sleep 或 runner 用户身份。
- 验证:在普通本地用户和 root 容器中分别运行用户消息、assistant 最终回复持久化失败测试,均应进入相同 durable phase 并通过恢复断言。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project.rs``apps/ai-game-creator-shell/src-tauri/src/agent.rs``apps/ai-game-creator-shell/src-tauri/src/tests.rs`
## Ubuntu 容器不能把 chromium-browser 的 Snap 占位包当成 CI 浏览器
- 现象:AI 游戏创作壳的 1132 条 Rust 测试全部通过,尾部 `agent-run:smoke` 却以 `spawn google-chrome ENOENT` 失败;直接给 Ubuntu 24.04 job 安装 `chromium-browser` 仍拿不到可执行浏览器。
- 原因:Ubuntu 24.04 仓库里的 `chromium-browser` 是 Snap 过渡包,普通 Docker job 没有 snapd 宿主能力;固定 job image 也不预装 Google Chrome。脚本回退到命令名 `google-chrome` 后只能在本机通过,在干净 Runner 中必然 ENOENT。
- 处理:Native job 通过 Google 官方签名 APT 源安装 `google-chrome-stable`,安装后先执行 `google-chrome --version`smoke 继续真实启动 headless 浏览器验证 DOM / canvas,不允许因 CI 缺浏览器而跳过或降级为静态 HTTP 检查。
- 验证:固定 Ubuntu 24.04 job image 内先确认 `apt-cache policy chromium-browser` 仅为 Snap 占位,再安装官方签名包并运行 `google-chrome --version`Gitea Native job 最终必须在 1132 passed / 5 ignored 后继续通过 `agent-run:smoke`
- 关联:`.gitea/workflows/project-ci.yml``apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs`
## PTY 测试不能假设输入回显与后续输出必然分行
- 现象:PTY 环境隔离用例偶发得到 `你好BRIDGE_ENV:`,而不是独立的 `你好``BRIDGE_ENV:` 两行;真实私有环境变量并未泄漏,但整行相等断言失败。
- 原因:canonical PTY 的输入回显和目标进程后续输出存在合法调度竞争,读取边界不等于逻辑行边界,回显可能与紧随其后的固定标记合并。
- 处理:对不含秘密的固定标记按语义边界断言,例如要求某行以标记结尾;敏感值仍必须在完整 transcript 和公共持久面执行严格零命中扫描,不能借此放宽泄漏门禁。
- 验证:`process_session_pty_uses_private_environment_and_redacts_public_records``BRIDGE_ENV:` 使用行尾匹配,并保留真实私有环境变量、stdin 正文与公共记录泄漏扫描。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/process_session.rs``apps/ai-game-creator-shell/src-tauri/src/command_output.rs`
## 自主 Swarm 验收不能把父 project.verify 当成意外确认动作
- 现象:两个专业 Agent 已完成初始交付,Supervisor 在语义 repair 前合法执行项目宿主验证,但 E2E harness 把所有父 run pending action 一律拒绝,导致真实协作链在业务逻辑正常时提前失败。
- 原因:验收器把“repair 前不允许父 Agent 绕过专业工作”错误实现成“父 run 不能出现任何确认动作”,混淆了 Supervisor 自己的 `project.verify` 与会改变专业交付/文件的意外动作。
- 处理:确认过滤器必须按 owning run 和 tool 精确判断。repair 前允许当前父 run 的 `project.verify`,仍拒绝其它未列入场景合同的父 pending action;专业 Agent 的修改和验证继续按各自 run、policy 和预期确认集合处理。允许确认不等于通过验收,最终仍由 host oracle、最新 revision verification、delivery/claim/repair 和唯一回复共同裁决。
- 验证:自主 suite 必须出现有效 `hostVerificationPassed=true`,同时保持恰好 2 个初始 + 1 个 repair delivery、父计划完成、意外 pending 为 0、Runner 强杀恢复和唯一 Supervisor assistant;若放宽后出现额外父写动作,场景必须失败而不是吞掉。
- 关联:`apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs``docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`
## Provider 全成功的真实报告不能证明显式重试可用
- 现象:真实 Swarm 报告显示全部 Provider lifecycle completedE2E 的 retry validator 也没有报错,于是文档把“支持瞬态重试”一并写成已真实验收。
- 原因:validator 只在实际出现 failed lifecycle 时校验 retry audit`failed=0 / retry=0` 会自然通过。随机等待外部网络故障既不可重复,也无法在故障和重试之间证明副作用仍为 0。
- 处理:为重试单独建立 fail-first loopback proxy。在正式 AppData 同级创建 sentinel 管理的一次性目录,只覆盖其中一个目标 Agent 的 base URL 和重试配置;首个 POST 在正文进入 upstream 前断线,第二个请求由 forwarding gate 暂停。gate 内交叉检查 failed lifecycle、retry audit、request slot/identity、action、pending、receipt、delivery、claim、assistant、project revision 和目标产物,再显式放行真实 Provider。代理不能记录 URL、headers 或正文,不能跟随 redirect,必须可幂等清理;启动 CLI/Runner 时同时设置合并后的 `NO_PROXY / no_proxy` 并显式加入 loopback,不能假设开发机已正确配置代理绕过;source-dir guard 禁止本 suite 前缀进入源目录,配置和 endpoint 身份保持只读并逐字复核。不要用源目录 mtime/ctime 归因,正式 Runner heartbeat 会并发改变它。完整链在 checkpoint 后失败时,partial report 也要保留已取得的 identity、slot 和零副作用证据,不能退回模板默认值。
- 验证:`npm run test -- apps/ai-game-creator-shell/tests/llmTransientFaultProxy.test.ts` 覆盖故障、暂停、base path、流式转发、fallback、隐私和清理;`npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-transient-retry-real-e2e -- --config-dir <AppData>` 必须得到恰好 1 failed/1 retry、重试前副作用全 0,并继续通过完整 Swarm/Runner 恢复和零泄漏门禁。
- 关联:`apps/ai-game-creator-shell/scripts/llm-transient-fault-proxy.mjs``apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs``docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`
## Agent 真实验收的阶段等待必须同步观察 Runtime 终态
- 现象:真实 Provider 已因 transport、格式修复或其它不可恢复错误把 task/Runtime 写成 failed,专项验收仍在等待某个 pending action、observation 或 receipt,直到 30 分钟总超时才返回。
- 原因:阶段等待只轮询“想看到的成功证据”,没有同时读取 owning Agent/run 的最新 task 与 Runtime phase;外部错误发生在该证据之前时,目标条件永远不会出现。
- 处理:所有分钟级阶段等待都要在每轮先检查 owning task 的 failed/cancelled/budget-exhausted,以及 Runtime 的 needs-reconciliation;命中后立即抛出带阶段前缀的结构化错误。正常 pause 必须保留为可恢复状态,不能被 fail-fast 当失败;Runner 强杀后的 paused 稳定窗口继续按签名零推进单独验证。
- 验证:用正式 `goal-runtime` 观察 Provider repair transport failure,确认部分报告立即保留成功/repair 协议计数、生命周期闭合和零泄漏证据;随后完整复跑仍能通过 Goal edit/pause/Runner kill/resume/finalization,证明 fail-fast 未破坏正常恢复路径。
- 关联:`apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs``docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`
## 图片生成的 K 档不能靠回图后缩放实现
- 现象:用户选择 2K 时占位框看起来是 2K,最终资源元数据也显示为 2K,但模型请求实际仍是固定 1K 或竖版回落尺寸;画面只是后端放大后的低分辨率结果。
- 原因:前端占位尺寸、api-server 的模型尺寸映射和 VectorEngine provider 合法尺寸各自维护;同时通用交付恢复与角色去背景恢复会直接缩放整张回图,掩盖了上游请求尺寸错误。
- 处理:`model + imageSize + aspectRatio` 必须先解析为 provider 可直接生成的真实尺寸,前端占位和后端请求使用同一矩阵。带新尺寸字段的用户生成不执行回图后交付放大;角色、图标和 UI 的去背景服务若降采样,只缩放 alpha 蒙版并应用回模型原始 K 档 RGB。
- 验证:覆盖 nanobanana2 / gpt-image-2 的比例与 K 档尺寸矩阵、VectorEngine 最终请求体、普通图片 / 角色 / 图标 / UI 占位,以及低分辨率去背景结果只贡献 alpha、不贡献被放大的 RGB。
- 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts``server-rs/crates/api-server/src/editor_project.rs``server-rs/crates/platform-image/src/vector_engine/request.rs`
## 多产物任务的中间原图不能只落资源库
- 现象:角色形象、图标 spritesheet 或 UI 素材提取正常成功后,画布只有透明结果,用户无法直接对照和复用 provider 原图。
- 原因:后端虽然为追溯和后处理失败恢复持久化了 provider 原图,但完成画布只写入透明主结果和拆分素材,把“资源已保存”错误等同为“用户已拿到原图”。
- 处理:provider 原图始终写入 OSS、项目资源和账号素材库;三类任务正常成功时都同时落透明主结果与右侧 provider 原图,生成器 `generatedLayerId` 仍指向透明主结果,图标 / UI 拆分素材从原图右侧继续排列。仅当透明处理最终失败时,才用 provider 原图作为唯一主图完成占位。
- 验证:覆盖三类任务正常成功都落透明结果与原图、原图位于透明图右侧、`generatedLayerId` 指向透明图、图标 / UI 拆分素材位于原图右侧,以及透明处理失败仍由原图单独完成占位。
- 关联:`server-rs/crates/api-server/src/editor_project.rs``docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`
## Vite 源码 CSS 清理插件必须早于 Tailwind 执行
- 现象:生产构建通过,但本地 dev 打开主站后全页白屏,`/src/index.css` 返回 500Vite 报 `Unknown word updateStyle`
- 原因:自定义 CSS 插件使用 `enforce: 'post'`,在 Tailwind/Vite 已把 CSS 转为包含 `updateStyle` import 的 JavaScript 模块后,仍调用 `postcss.parse`
- 处理:需要改写原始 CSS 的 transform 固定使用 `enforce: 'pre'`;最终构建产物清理继续放在 `generateBundle`,不要混用两个阶段的输入格式。
- 验证:真实启动 `npm run dev` 后请求 `/src/index.css` 必须返回 200,并在浏览器确认 `#root` 已挂载且控制台无 CSS transform 错误。
- 关联:`vite.config.ts``scripts/vite-retired-css-plugin.test.ts`
## phase 上报的业务拒绝与传输失败不能共用字符串错误
- 现象:provider 已经返回并保存原图,worker 上报 `processing` 时一次断连或超时就直接把任务判为失败;或者为了规避误杀而重试所有错误,导致 stale lease 的旧 worker 继续执行后处理。
- 原因:phase procedure 的 lease / fencing 业务拒绝与 SDK 建连、断连、超时错误被压成同一种字符串错误,调用方无法可靠决定是否重试;按中文或 SDK 文案匹配会在错误文本变化后失效。
- 处理:procedure 返回结构化 `LeaseFencingRejected` / `OtherRejected`typed client 再把模块拒绝与 RPC 错误分开。`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试;只有 `Build` / `ConnectDropped` / `Timeout` 在同一 job attempt 内重试一次。编辑器 job 固定 `max_attempts=1`,第二次传输失败后进入 `failed`,不回 `pending`、不重新调用 provider。不得让 phase 上报错误落入“后处理失败保留原图”的降级分支。
- 验证:分别覆盖 lease / fencing 拒绝、其它拒绝、建连、断连、超时和第二次失败,确认最多调用两次;同时断言角色、图标和 UI 的原图降级只包住透明背景处理,不包住 phase 上报。
- 关联:`server-rs/crates/spacetime-module/src/external_generation.rs``server-rs/crates/spacetime-client/src/external_generation.rs``server-rs/crates/api-server/src/editor_project.rs``docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`
## 禁止 Data URL 持久化时不要漏掉异步任务 JSON
- 现象:工程、素材、图层和元数据都已禁止 Data URL 后,服务器仍在生成高峰出现 SpacetimeDB / api-server 内存急剧膨胀甚至 OOM;读取少量正式生成任务也会造成远大于响应体的瞬时内存增长。
- 原因:同步接口 worker 化时把原请求整体序列化到 `external_generation_job.request_payload_json`,而前端又把已有 `objectKey` 下载成 Data URL 提交。任务表也是正式持久化边界;列表 procedure 若先收集完整任务行再截断,还会把 request/result 大字段在 SpacetimeDB、SDK mapper 和 BFF 多次持有。
- 处理:先在事故涉及的编辑器持久任务 JSON 上由 api-server 与 SpacetimeDB 两层递归拒绝 `data:` / `blob:` 并限制字节数;已有媒体传 `objectKey` / `resourceId` / `assetId`,本地派生图先用强唯一 key 上传。其它玩法若仍以 Data URL 作为正式请求契约,必须先资源化,不能直接扩大门禁造成玩法回归。列表、详情和 acknowledge 只走无 payload 的摘要投影,ack 不能为了同步旧字段重写大任务行;摘要错误文本也必须清除内联媒体并设硬上限,列表只能维护有界 top-N,不能先收集 owner 全量历史再截断。历史只通过迁移操作员的 dry-run + B-tree cursor 分批 procedure 压缩 `editor-canvas` 终态任务,cursor 选择读取量必须受 limit 约束,绝不全表扫描、绝不处理 pending / runningdry-run 后 apply 同一批时保持输入 cursor 不变,最后一批即使 `has_more=false` 只要仍有命中也必须 apply,只有 apply 成功后才推进到返回 cursor。SpacetimeDB CLI 2.5 的 `Option<T>` 非空参数必须使用 SATS sum 编码;维护脚本要统一编码 `cursor_job_id``owner_user_id``completed_before_micros`,否则首批空 cursor 可运行,但第二批或带截止时间的调用会在写入前被拒绝。
- 发布门禁:生产发布入口必须固定 `--delete-data=never` 与 scoped `--yes=migrate,break-clients`,普通 Jenkins 参数不得暴露清库开关;需要删数据的 schema 冲突必须直接阻断并重新检查 artifact/schema,不能靠裸 `--yes` 放行。
- 验证:构造嵌套 Data URL、Blob URL 和超限 JSON 确认入队失败;检查正式 UI procedure / client record 不含 request/result payload;用 dry-run 和 apply 测试确认活动任务不变、终态普通提示词保留且内联媒体被替换;至少带一次非空 `--cursor-job-id``--completed-before-micros` 验证 CLI Option 编码,而不是只测首批空 cursor。
- 关联:`server-rs/crates/api-server/src/editor_generation_queue.rs``server-rs/crates/spacetime-module/src/external_generation.rs``server-rs/crates/api-server/src/external_generation.rs``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## React 测试因内部状态或实现细节正常重构就碎
- 现象:修改组件结构、按钮排序、图标库 class、提示文案或 hook 内部状态名后,React 测试大量失败,但真实用户流程和对外契约没有变化。
- 原因:测试把 `data-testid` 仪表盘、`textContent` 拼接状态、完整对象 / 数组顺序、图标 class 或长文案当成契约;这些断言绑定的是实现形状,不是用户行为或稳定边界。
- 处理:按 `React 组件测试准则` 重写到更稳定的层级。用户流程测试断言 role / label / URL / 弹窗 / callbackhook 逻辑用 `renderHook` 直接验证公开返回契约;DTO / payload 使用关键字段或 `expect.objectContaining(...)`。只有产品明确要求的可访问语义、固定顺序或渲染边界才保留精确断言。
- 验证:运行触达文件的定向 `vitest`,必要时追加 `npm run typecheck``npm run check:encoding``git diff --check`
- 关联:`docs/technical/【前端测试】React组件测试准则-2026-06-26.md``src/components/image-editor/useCanvasGenerationDialogs.test.tsx``src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx`
## Rust 并行测试不要在 await 跨度内修改进程环境变量
- 现象:单独运行的异步测试稳定通过,默认并行运行整个 crate 时却看到临时目录多出其它测试的文件、文件对被拆散,或目录清理与并发写入互相竞争;Gitea Backend CI 可能表现为日志数量断言偶发增加。
- 原因:`std::env::set_var` / `remove_var` 修改整个测试进程,不属于当前 async task。测试在 `await` 前设置目录、结束后恢复时,同一 test binary 的其它用例会在中间窗口读取该值;只锁修改环境变量的测试也无效,除非所有间接读取方都参与同一把锁。
- 处理:文件、队列、缓存等副作用目录进入实例配置,在构造时一次性解析环境默认值,并允许测试显式注入唯一临时目录。不要靠 `--test-threads=1`、固定 sleep 或只过滤自己的文件名掩盖错误路由;纯环境解析测试只有在全部相关读写都封闭于同一 `OnceLock<Mutex<()>>` 时才使用全局锁。
- 验证:先精确运行目标用例,再以默认并行度重复运行完整 crate;失败类测试同时执行时,各实例目录只能包含自己的输入 / 输出日志,测试结束后临时目录必须清理。
- 关联:`server-rs/crates/platform-llm/src/lib.rs``server-rs/crates/api-server/src/creation_agent_llm_turn.rs``server-rs/crates/api-server/src/custom_world_foundation_draft.rs`
## 带 objectKey 的画布图片测试要等待换签后可见
- 现象:测试点击“添加素材”后,图层状态已经写入,但立即用 `getByAltText('画布图片:...')` 偶发或稳定找不到图片;前一张图可能通过,紧接着添加的第二张失败。
- 原因:带 `objectKey` 的画布图片通过 `useResolvedAssetReadUrl` 异步获取签名 URL`resolvedUrl` 就绪前不会渲染带 `alt``<img>``user.click` 只等待点击交互完成,不等待 effect 内的换签 Promise;前一张图在后续操作期间出现只是调度时机,不是同步契约。
- 处理:每次点击添加后分别用 `await screen.findByAltText(...)` 等待对应图片可见,再执行依赖该图层的下一步操作;不要用固定 sleep,也不要只等待最后一张图而让前面的断言依赖偶然调度。完整前端回归并行负载较高时,可只对明确跨越换签 Promise 的目标查询设置局部、有界的 `5_000ms` 超时,不要放宽 Testing Library 全局超时。
- 验证:先精确运行目标用例并连续重复,再运行所在测试文件和完整前端测试;删除场景仍要保留 A/B 都消失、两个删除调用和撤销不恢复已删除素材的断言。
- 关联:`src/hooks/useResolvedAssetReadUrl.ts``src/components/image-editor/ImageCanvasWorldView.tsx``src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`
## 图片画布素材库删除要匹配 sourceResourceId
- 现象:素材库中删除了已经生成并进入素材库的资源,但画布上对应图层仍然存在,刷新后还可能从已保存布局里恢复。
- 原因:生成素材进入账号级素材库时可能通过 `editor_asset.sourceResourceId` 指向原项目资源;如果前端素材库映射和级联删除只比较 `sourceAssetId``assetObjectId``objectKey``src`,就会漏掉只靠项目资源 ID 关联的历史 / 后端生成图层。
- 处理:`EditorAsset` 必须保留 `sourceResourceId`;从素材库添加到画布时继续写入图层;删除素材时同时比较 `layer.resourceId` / `layer.sourceResourceId``asset.sourceResourceId`
- 验证:`ImageCanvasEditorModel.test.ts` 覆盖素材库 source resource 保留,`useImageCanvasAssetCanvasBridge.test.tsx` 覆盖资源 ID 级联清理,`ImageCanvasEditorAssetsIntegration.test.tsx` 覆盖删除后保存的新 layout 不再包含被删图层。
- 关联:`src/components/image-editor/ImageCanvasEditorModel.ts``src/components/image-editor/useImageCanvasAssetCanvasBridge.ts``src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`
## 图片画布素材选择有效性不要绑定搜索与折叠可见性
- 现象:批量选择多个素材后,搜索、折叠文件夹或展开文件夹会让已选数量下降、Shift 范围锚点丢失,后续批量下载或删除遗漏此前已选素材。
- 原因:搜索结果和文件夹展开状态只描述当前 UI 可见范围,不描述素材是否仍然有效;用 `visibleAssetIds` reconcile 全局选择会把暂时隐藏误判为素材失效。
- 处理:由唯一 `useImageCanvasAssetSelection` 持有选择集合、范围锚点、框选和全部选择 mutation;全局选择只按全部 `selectableAssetIds` 清理真正删除、上传未完成、上传失败或媒体地址无效的 ID。`visibleAssetIds` 只作为单项切换、Shift 可见区间和当前结果全选 / 取消全选的动作入参,批量下载与删除消费 hook 输出的完整 `selectedAssets`;删除中包含当前未显示选择时,必须明确展示全部数量和未显示数量并二次确认。
- 验证:模型测试覆盖隐藏选择保留、可见范围增量和真正失效 ID 清理;图片画布素材集成测试覆盖搜索、折叠 / 展开后选中数量稳定及当前可见全选不影响隐藏选择。
- 关联:`src/components/image-editor/useImageCanvasAssetSelection.ts``src/components/image-editor/useImageCanvasAssetLibrary.ts``src/components/image-editor/ImageCanvasSidebarView.tsx``src/components/image-editor/ImageCanvasEditorView.tsx`
## 后台素材查询不要用 SQL 直查 editor_asset
- 后台审核与素材查询的图片预览若要显示像素化原图,应由后台 read model 在 `sourceResourceId` 关联的项目资源上预先透传原图媒体引用,再复用管理员换签;不要让 admin-web 直接查询私有 `editor_project_resource`
- 现象:后台“素材查询”报 `HTTP 400no such table: editor_asset. If the table exists, it may be marked private.`
- 原因:`editor_asset` 是私有 SpacetimeDB 表,后台 SQL / schema HTTP 查询面看不到私有表;即使 api-server 有后台身份,也不能把私有表当 Dashboard SQL 表直接查。
- 处理:后台素材查询走 `spacetime-module` 内的 `admin_list_editor_assets_and_return` procedure,由 `spacetime-client` typed facade 调用后再在 `api-server` 映射作者展示名和陶泥号。新增类似后台只读能力时,优先补窄 procedure / read model,不要复用 `fetch_admin_dashboard_rows` 直查私有源表。
- 验证:`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`
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs``server-rs/crates/spacetime-client/src/editor_project.rs``server-rs/crates/api-server/src/admin.rs`
## 后台素材查询要在分页前归一用户、游标和派生任务
- 现象:输入 `SY-*` 陶泥号查不到已有素材;无筛选时只显示 80 个任务且没有“读取更多”;手动重拆图集后,`素材 N` 被单独当成零成本父任务,原图集又显示成另一组。
- 原因:`editor_asset.owner_user_id` 保存内部 `user_id`,不保存公开陶泥号;作者陶泥号在 procedure 返回后才映射,不能直接参与素材表过滤。`spacetime-client` 的统一时间文本是 `seconds.microsZ`,若 cursor 只按 RFC3339 解析,第 80 个任务无法生成 `nextCursor`。手动图集拆分使用独立 `editor-atlas-split-*` 任务号,但切片项目资源通过 `source_resource_id` 指回原图集资源,只按当前 `task_id` 分组会割裂同一条素材生产链。
- 处理:`api-server` 在调用素材 procedure 前把“用户 ID / 陶泥号”字段或精确 `SY-*` keyword 解析成内部 `user_id`;游标解析同时接受整数微秒、统一 `seconds.microsZ` 和 RFC3339,编码失败必须返回服务错误,不能伪装成末页,客户端传入的非法 cursor 必须返回 `400`,不能静默回到第一页。手动拆分素材保留真实 `task_id`,通过 `editor_asset_group_source_provenance` 按原 resource、asset object 或 Object Key 查找服务端生成账号素材的可信来源任务,再把来源任务写入 `editor_asset.group_task_id`;跨项目复用时同时携带当前和原始 source resource,首次历史回溯命中后补写 provenance,后续不再扫描账号全量素材。没有可信来源的新批次显式以自己的拆分任务作为 `group_task_id`,不得落回可读取用户资源元数据的 legacy 分支。每片保存 `group_task_expected_asset_count`,全部切片落库后再写不可逆的 `editor_asset_group_cohort` 完成事实;read model 依据完成事实判断批次资格,不用当前剩余行数猜测初始是否完整,因此用户后来删除切片不会让批次脱组。部分失败批次没有完成事实,始终保留为独立拆分任务。历史行兼容沿资源链回溯,删除项目时只固化直接引用待删资源且尚未固化的历史切片;固化始终保存真实来源任务,有界展示分组不得反写覆盖来源。read model 只让同根任务的一个完整拆分批次并入原任务,重复批次按真实拆分任务独立分页。带 owner 条件时先走 `by_editor_asset_owner_user_id`,不要为单用户查询扫描全站素材。后台筛选输入使用防抖并取消旧 transport;手动刷新同一查询失败时保留已有结果和游标,筛选已变化时不展示旧查询结果。
- 验证:API 测试覆盖陶泥号组合条件、未知陶泥号、可信来源归组、`seconds.microsZ` / 极值游标和真实 / 归组 Task IDSpacetimeDB 测试覆盖资源删除后稳定归组、部分失败批次不抢占根任务、重复拆分有界无丢失和 owner 索引分支;后台页面测试覆盖逐字输入防抖、请求取消、刷新失败保留结果、筛选请求乱序、旧分页响应失效和“用户 ID / 陶泥号”请求。再运行 `cargo test -p api-server admin_editor_asset --manifest-path server-rs/Cargo.toml``cargo test -p spacetime-module admin_editor_asset --manifest-path server-rs/Cargo.toml``npm run test -- apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx`
- 关联:`apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx``server-rs/crates/api-server/src/admin.rs``server-rs/crates/spacetime-module/src/editor_project_storage.rs`
## 后台素材查询与精选审核缩略图不要在首次挂载时全量换签
- 现象:后台“素材查询”或“精选审核”首批缩略图正常,继续向下滚动、读取更多或一次加载较多审核项后长期显示占位图;api-server journald 中已到达的 `/admin/api/assets/read-url` 可能全部是 `200`
- 原因:列表一次挂载 80 条私有素材时,每个缩略图同时换签,会在同秒突发请求。production Nginx 的 `genarrative_admin_rps``30r/s burst=16`,超出部分在进入 api-server 前已返回 `429`,因此仅查 api-server 日志会漏掉失败请求。
- 处理:素材查询与精选审核共用缩略图和预览组件;缩略图使用 `IntersectionObserver` 在进入视口附近时再调用管理端换签;对 `429` 使用有上限的退避重试,并在条目卸载后停止更新状态和安排重试。无 `objectKey` 的绝对 OSS generated 地址先提取 legacy path 再换签。不得为单页突发放大 Nginx 通用管理端限流,也不得在单次限流失败后永久保留无图占位。
- 验证:前端定向测试覆盖两页共用换签组件、首屏外的后续行进入可见区后才换签、“读取更多”追加行可继续显示缩略图、`429` 后有限重试恢复、卸载后不再重试、绝对 OSS 地址换签和点击缩略图打开媒体预览;真实浏览器滚动验收时同时核对 Nginx access/error log、api-server journald 和 Network 面板,不以单一日志面判定成功。
- 关联:`apps/admin-web/src/components/AdminEditorAssetMedia.tsx``apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx``apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx` 及对应测试。
## 陶泥儿精选重复先查同源同媒体画布副本
- 现象:每次从项目素材中把同一个生成素材拖到画布上,`陶泥儿精选` 都多出一张看起来相同的素材。
- 原因:素材拖入画布会为图层实例准备 `editor_project_resource`;如果该素材本来带 `sourceResourceId` 指向原始生成资源,而新资源仍按普通 generated 资源公开,精选就会把原件和每次拖拽产生的同源同媒体副本都展示出来。
- 处理:创建项目资源时保留 `source_resource_id`,并在同项目已有同源同媒体资源时复用已有 resource;确需创建同源同媒体副本时默认 `public_showcase_enabled = false`。公开精选读取和前端精选模型都跳过 `sourceResourceId` 指回同一媒体原件的副本,但不要按图片地址全局去重,避免不同生成步骤共享占位图时被误合并。
- 验证:`creationShowcaseModel.test.ts` 覆盖同源同媒体副本只展示原件;`ImageCanvasEditorAssetsIntegration.test.tsx` 覆盖拖拽生成素材到画布时继续提交 `sourceResourceId``cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 确认后端资源复用 / 精选过滤逻辑可编译。
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs``src/components/creation-home/creationShowcaseModel.ts``src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`
## 陶泥儿精选作者丢失先查公开作者展示字段
- 现象:`/creation``陶泥儿精选` 卡片和预览弹窗中,素材下方作者名消失、只显示占位,或错误显示内部用户 ID。
- 原因:精选数据源来自公开 `editor_project_resource` 快照;如果 SpacetimeDB read model、`spacetime-client` mapper 或 `api-server` payload 任一层漏传 `authorDisplayName` / `display_name``authorPublicUserCode` / 陶泥号,前端没有可展示的公开作者字段。`owner_user_id` / `ownerUserId` / `user_id` 是内部归属字段,不是公开作者名兜底。
- 处理:`EditorProjectResourceSnapshot``EditorProjectResourceRecord``EditorProjectResourcePayload` 需要一路保留公开作者展示字段;前端 `creationShowcaseModel` 优先显示 `authorDisplayName` / `display_name`,没有展示名时显示 `authorPublicUserCode` / 陶泥号,绝不能兜底到内部 `ownerUserId` / `user_id`
- 验证:`creationShowcaseModel.test.ts` 覆盖展示名优先、陶泥号兜底和内部 owner/user id 不展示;`editorProjectClient.test.ts` 覆盖公开精选接口客户端保留公开作者字段;若改动 SpacetimeDB read model,再运行 `npm run spacetime:generate``cargo check -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml``npm run check:spacetime-schema`
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs``server-rs/crates/spacetime-client/src/mapper/editor_project.rs``server-rs/crates/api-server/src/editor_project.rs``src/components/creation-home/creationShowcaseModel.ts`
## 陶泥儿精选顺序分列不要用 multi-column 或共享 Grid 行高
- 现象:`/creation` 桌面端主内容区明明能放下三张卡,首行却只出现一到两张,后续素材提前回到左侧下一段;活动卡与普通素材高度差较大时尤其明显。
- 原因:`column-count` 按纵向文章列流入并平衡,三张卡可排成 `2 + 1 + 0` 列;标准 CSS Grid 虽会横向先填三张,但整行共用最高卡行轨,短卡下会留下高度差。`align-items: start` 只是不拉伸短卡,不能消除行轨空白;`grid-auto-flow: dense` 也不能填单个网格项内的剩余高度。
- 处理:保留平面 DOM 和 `.creation-landing__asset-waterfall` 旧类名,按当前列数将第 `index` 张显式放入 `index % columns` 列;三列时第 1/2/3 张分别进入三列,第 4/5/6 张再分别接到三列下方。列数以容器实际宽度和 288px 首选最小列宽计算,不以 viewport 硬切;先设目标卡宽再测真实卡高,列内用 computed gap 紧凑堆叠。
- 动态边界:只有当容器宽度、卡数和所有卡高有效时才进入 absolute Masonry ready;否则保留 Grid fallback。`ResizeObserver + requestAnimationFrame` 在容器变宽、图片/字体/文本改变高度、筛选重排和 cursor 追加后全量重算;cleanup 必须兼容 StrictMode,避免分页 sentinel 因容器短暂零高提前触发。
- 验证:纯函数测试锁定容器宽度临界值、`index % columns` 和最高列容器高;样式契约同时锁定 Grid fallback 与 Masonry ready。Playwright 需在同一 viewport 内改容器宽度验证 3/2/1 列,检查每列相邻卡间距等于 gap、容器高等于最高列底、DOM 顺序不变且无重叠/横向溢出。
- 关联:`src/index.css``src/index.test.ts``src/components/creation-home/CreationLandingView.tsx``src/components/creation-home/showcaseMasonryLayout.ts`
## 画板外部生成排队超时不是失败
- 现象:画板发起付费图片生成后,前端弹出 `生成任务仍在队列中,请稍后刷新画布查看结果`,但后端任务仍在队列或执行中,后续可能正常完成。
- 原因:画板生成已经接入后端外部生成任务队列,`queued` / `running` 是正式任务状态;旧前端轮询等待窗口到期时直接抛错,导致正常排队被提交流程 catch 成失败 UI。
- 处理:`waitForEditorGenerationQueue` 等待超时只返回“仍在后端继续执行”,调用方停止本次前端等待并保留生成中状态;只有后端任务终态为 `failed` 才展示失败。
- 验证:画板生成 workflow 测试覆盖 queueState 持续 `running` 到前端等待窗口结束时,不进入 failed、不显示该排队文案、不添加本地临时结果层。
- 关联:`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx`
## 场景队列终态不保证首次项目快照已经收口生成占位
- 现象:游戏场景任务已经显示完成,但画布仍保留 `generating` 占位;场景链路又禁止用本地结果补层,因此当前会话可能一直停在生成中。
- 原因:外部生成任务终态与项目画布投影不是同一个原子观测点。队列轮询先看到 `completed` 后,紧接着的首次项目 GET 仍可能读到同一 `dialogId` 的未收口占位;若调用统一回读函数时没有传 completion dialog ID,函数无法识别该快照仍未完成,也不会执行已有的有界延迟重读。
- 处理:游戏场景队列调用要把本次占位 `dialogId` 传给 `applyQueuedEditorGenerationProject`。首个快照中该 ID 仍为 unresolved 时,只按既有间隔补读一次项目;不追加本地图层,也不把任务终态直接等同于画布投影终态。其他生成类型若要补同类保护,必须分别复现其权威回填时序后再改,不能用本条场景结论替代验证。
- 验证:场景 workflow 用两个连续快照复现时序:第一个保留 `scene / generating`,第二个包含场景结果并把同一占位置为 `idle`。修复前只读一次并超时,修复后依次应用两个权威快照。
- 关联:`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx``docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md`
## 图片画布历史不能回退当前权威状态或复活后端已删素材
- 现象:生成占位框移动后开始生成,撤销移动会把仍在运行的生成对象恢复成待生成状态;切换到 2K 或改变比例后撤销位置,旧占位框还可能把当前尺寸回退。上传图层落库后,普通移动撤销可能被提示“可能会使图片消失”并永久卡在栈顶;即使安全检查已放行,直接恢复旧图层快照也会丢失刚回填的资源关联。素材库后端删除关联素材后,更早的移动快照还可能把已删图层重新加入并自动保存;修改素材类型虽然界面提示撤销成功,刷新后却可能从仍指向新类型的 resource 回弹。无稳定 ID 的“修改图片”草稿也可能被 target-null 快照直接关闭。
- 原因:内容消失安全检查和历史快照合并是两道独立边界。图层内容签名若严格比较 `sourceAssetId``sourceResourceId` 等延迟回填的内部关联 ID,会把同一媒体误判为替换;放行后若仍用目标快照整体覆盖同 ID 图层或占位框,又会回退当前权威关联、内容或尺寸。相同 dialog ID 直接恢复整个旧对话框快照还会覆盖当前 `generating` / 完成态;没有 ID 的 edit 草稿则根本不会进入存在性检查。外部素材删除不写画布历史,若不主动剪除包含关联图层的旧目标快照,target-only 图层会被当作正常撤销删除完整恢复。`assetKind` 的正式事实保存在项目 resource,历史只改内存字段而保留当前 `resourceId` 时无法跨刷新成立。
- 处理:图层内容身份按对象存储 key、对象标识和媒体地址的稳定优先级比较,内部关联 ID 的补齐不参与内容消失判断。同 ID 图层以 current 为权威,只从历史覆盖 `x``y``zIndex``groupId``assetKind``hidden``locked``flipX``flipY`;current 的资源关联、内容、媒体、生成元数据、尺寸和标题全部保留。同 ID generation dialog 从 target 恢复 placeholder 的 `x` / `y` 和 active / inactive 槽位对应的 `composerOpen`current 的 `width` / `height` / `originalWidth` / `originalHeight`、当前参数、任务生命周期、提示词、参考图和结果保持一致;edit 草稿使用基于来源图层的稳定 ID。current 中不存在对应 ID 时属于撤销完整删除,可从 target 全量恢复对象。素材库删除必须用 `isLayerLinkedToAsset` matcher 同步过滤 undo / redo 中所有包含关联图层的 entry,即使图层只存在于历史中也要过滤;普通画布删除不调用该接口。assetKind undo / redo 每次重新创建匹配恢复类型的正式 resource,并按 layer 请求版本只接受最新响应。即时生成结果在追加图层前捕获生成历史,自动适合视图不再压入另一条历史。
- 验证:覆盖 idle 生成框移动后进入 generating 再撤销、上传图层异步回填 `resourceId` / `sourceAssetId` / `sourceResourceId` 后撤销移动仍保留当前关联值、切换到 2K 或改变比例后撤销位置仍保留当前占位尺寸、非活动生成框被激活并拖动后撤销可恢复原 active / inactive 打开状态、撤销完整删除可以全量恢复对象、即时生成后第一次撤销直接命中生成保护、阈值内指针抖动既不移动也不产生历史、素材库删除后旧 undo / redo 无法复活关联 layer、edit 草稿不会被 target-null 快照吞掉,以及 assetKind 撤销与乱序 resource 响应后刷新仍保持最终类型。
- 关联:`src/components/image-editor/ImageCanvasHistoryModel.ts``src/components/image-editor/useCanvasHistory.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/components/image-editor/useImageCanvasStageInteractions.ts``docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md`
## 画板参考图 objectKey 必须先做归属校验
- 现象:画板生成、快速编辑、图标素材或 UI 素材提取如果允许直接提交 generated objectKey,用户只要知道其他账号的私有 objectKey,就可能让 api-server 签名读取并送给外部生成供应商。
- 原因:Data URL/Blob URL 只允许停留在浏览器临时态,正式编辑器引用必须先上传并经统一 resolver 校验归属。
- 处理:普通图片、重绘、图标图集附加参考图和 UI 提取等允许 objectKey 的私有对象引用,在读取字节或签发 URL 前统一走 `resolve_editor_reference_object_key_for_owner(state, owner_user_id, source)`,先在当前账号的项目资源、素材库资产或 `asset_object` 中匹配 owner / bucket / key。只有确实需要图片字节的入口再走 `parse_editor_reference_image`(内部仍先 resolve,再下载 OSS 字节);手动去背景等只签发短期 URL 的入口不要 `parse` 整图。图标规范生成的可选参考图与图标图集的主规范引用是更窄的业务契约:只接受正式 `referenceId`(项目资源 ID / 素材 ID),不得回退 objectKey、URL 或临时 key;图标图集的额外 `referenceImageSrcs` 才继续沿用通用 owned objectKey 规则。手动去背景还要恢复源模型时,object key 解析、所有权校验和源模型回溯必须复用同一轮账号项目 / 素材快照,项目与素材快照各最多读取一次;不得先走通用 resolver 全量读取,再为模型回溯重复拉取完整画布和素材库。图标素材等额外参考图必须真实传到 provider,不只写 metadata;图片快速编辑当前不开放额外参考图,若后续重开入口也必须沿用同一归属校验。
- 验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_reference`,并用前端 workflow 测试覆盖 `referenceImageSrcs` 进入图标生成请求;若快速编辑重开额外参考图,再补对应请求覆盖。
- 关联:`server-rs/crates/api-server/src/editor_project.rs``server-rs/crates/spacetime-client/src/assets.rs``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`
## 资产换签不能把 generated 前缀当成 objectKey 授权
- 现象:主站或 External API 只要拿到另一个账号的 generated `objectKey` 就能换签,已登记的私有对象因为 key 同时命中 legacy 前缀而被匿名读取,或者 `/api/assets/read-url` 已拒绝但 `/api/assets/read-bytes` 仍能读出原始字节;后台资源预览为解决跨账号读取又误把主站入口整体放开。
- 原因:`legacyPublicPath``objectKey` 代表两种不同信任边界。前者仅用于未登记历史公开作品兼容,后者是正式对象引用;只检查 generated 前缀、或在查询 `asset_object` metadata 前直接接受 legacy 白名单,都不能证明对象公开或属于调用方。签名 URL 和 bytes proxy 如果各写一套判断也容易漂移。
- 处理:`read-url``read-bytes` 必须共用 `authorize_asset_read_target`,先按配置 bucket / 精确 key 查询 `asset_object`metadata 一旦存在,即使 key 命中 legacy 前缀,也严格执行 `PublicRead` / owner ACL。只有 metadata 不存在且显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` 时才允许匿名兼容;普通 `objectKey` 必须登记。唯一窄例外是历史精选活动卡:`global` 配置已启用、请求 key 位于 `generated-character-drafts/editor/showcase-campaign/` 且与当前 `image_object_key` 精确匹配时,可在 metadata 缺失期间派生公开读取,禁用或换图后旧 key 立即失效;新上传活动卡仍必须 confirm。External read-url 使用 API Key owner。主站和 External 的 object confirm owner 必须来自认证主体,同 bucket / key 已登记后不能改变 owner,不能让请求体 owner 接管对象。后台跨 owner 换签只用于管理员资源预览,成功后以 `admin_asset_read_url` 持久化管理员 subject、请求对象和有效期,且不得记录 signed URL;不能为此把 admin 能力下沉到主站入口。无权访问统一返回不存在,避免泄露对象是否存在。
- 验证:覆盖未登记 curated legacy public path、命中 legacy 前缀但已有私有 metadata、未登记普通 objectKey、活动卡当前/禁用/替换/越目录 exact key、公开对象、本人私有对象、跨 owner、匿名私有、External owner、confirm owner 不可变和 admin-only endpoint;对 `read-url``read-bytes` 使用同一组授权矩阵,并断言 Admin 成功换签会生成不含 signed URL 的管理员主体审计事件。
- 关联:`server-rs/crates/api-server/src/assets.rs``server-rs/crates/api-server/src/external_assets_api.rs``server-rs/crates/api-server/src/admin.rs``server-rs/crates/api-server/src/modules/admin.rs`
## 精选活动卡上传成功但网站空图先查对象确认和 exact grant
- 现象:后台精选活动卡能保存标题、作者、尺寸和图片地址,`GET /api/editor/showcase/resources` 也返回已启用 campaign,但网站卡片只有占位区域,没有 `<img>`Network 中活动卡的 `/api/assets/read-url?objectKey=...` 返回 `404 资源不存在或无权访问`
- 原因:活动卡旧上传链路只完成 signed POST 并保存 `imageSrc + imageObjectKey`,没有调用 object confirm;同时公开授权只扫描普通 `editor_showcase_asset`,没有识别当前活动卡。前端见到 `imageObjectKey` 后会优先走正式 objectKey 换签,失败时按安全规则保持空 src,不回退裸 private 路径。
- 处理:后台上传必须按 ticket -> OSS POST -> `/admin/api/editor-showcase/campaign/image-upload-confirm` -> 写回表单执行,confirm 复用统一 OSS HEAD、bucket/长度校验和 `asset_object` upsert,并由管理员会话绑定 owner、强制 private/固定 asset kind/活动卡专用目录。读取 procedure 在同一事务中对当前 enabled global campaign 的专用目录 exact key 派生授权,使历史未登记当前卡无需重新上传即可恢复;api-server 仅接受 procedure 明确返回的这一 grant,其他未登记 objectKey 继续 404。
- 验证:SpacetimeDB 测试覆盖 current key、disabled、missing key、replaced old key、越目录 key 和普通精选 grantapi-server 测试覆盖 metadata 缺失时 exact grant 可读、无 grant 仍 404、confirm 路径/MIME/大小/固定 private 约束;admin-web 测试锁定 ticket -> OSS -> confirm 顺序。真实浏览器应看到活动卡图片,Network 中 objectKey 换签返回 200,禁用或换图后旧 key 返回 404。
- 关联:`apps/admin-web/src/api/adminApiClient.ts``server-rs/crates/api-server/src/admin.rs``server-rs/crates/api-server/src/assets.rs``server-rs/crates/spacetime-module/src/asset_metadata/objects.rs``server-rs/crates/spacetime-module/src/editor_project_storage.rs`
## 编辑器生成按钮显示泥点后仍要查真实钱包预扣
- 现象:画板生成按钮显示 `N泥点`,后端也能按模型配置计算出价格,但用户点击后钱包余额不变。
- 原因:前端展示价和后端价格计算只证明价格能被展示 / 解析;如果 handler 没有包进 `execute_billable_asset_operation_with_cost`,或异步音频发布目标没有携带本次模型价格,外部 provider 仍会被调用但不会真实扣费。
- 处理:新增或改造编辑器外部生成入口时,确认前端请求不携带 `priceMudPoints`,后端按运行时模型定价重新计算价格,并用该价格进入资产扣费 wrapper。音频提交 / 发布分离时,把后端计算出的价格写入 `AudioAssetBindingTarget.billing_points_cost`
- 验证:结构性测试覆盖对应 handler 包含 `execute_billable_asset_operation_with_cost` 和价格变量;音频测试覆盖 `resolve_creation_audio_points_cost` 优先读取 editor target 的 `billing_points_cost`
- 关联:`server-rs/crates/api-server/src/editor_project.rs``server-rs/crates/api-server/src/character_animation_assets.rs``server-rs/crates/api-server/src/vector_engine_audio_generation/``src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts`
## 本地 dev 启动日志先看成功锚点,不要把非阻断 warning 当失败
- 现象:`npm run dev` 启动 SpacetimeDB 时可能先打印 `static max level is off``Skipping tokio metrics`,或 SpacetimeDB CLI 提示存在新版本 / 当前版本较旧,看起来像启动异常。
- 原因:这些是 tracing、metrics 或 CLI 更新提示,不代表本地 dev 栈失败;同一段日志后续仍可能已经完成 `SpacetimeDB listening on 127.0.0.1:3101`、模块 publish、`api-server` `/healthz` 200、主站 Vite `3000` 和后台 Vite `3102` ready。
- 处理:排查本地 dev 栈时先确认成功锚点:`[dev:spacetime] actual``Updated database``api-server 已完成 tracing 初始化并开始监听``/healthz` 200、两个 Vite `ready`。只有缺少这些锚点或进程退出时,再继续查 CLI 权限、端口占用、publish 或 API 编译问题。
- 验证:`http://127.0.0.1:3101/v1/ping` 可访问、`http://127.0.0.1:8082/healthz` 返回 200、`http://127.0.0.1:3000/``http://127.0.0.1:3102/admin/` 可打开。
- 关联:`scripts/dev.mjs``.app/dev-stack.json``docs/project-memory/shared-memory/development-workflow.md`
## 私有兑换码不适用先查同手机号重复账号
- 现象:后台把私有兑换码配给某个陶泥号或手机号后,用户用同一手机号登录兑换仍提示 `该兑换码不适用于当前账号`
- 原因:认证表里可能存在同一手机号的多条 `user_account`。如果认证工作集重建 `phone_to_user_id` 时让 `user_account.phone_number_e164` 后写覆盖前写,当前登录态会漂到没有 `auth_identity` 的重复账号,而兑换码白名单仍指向另一个内部 `user_id`
- 处理:重建认证工作集时以 typed `AuthStoreProjectionView``user_account` / `auth_identity` / `refresh_session` 恢复;手机号索引以 `auth_identity(provider="phone")` 指向的账号为权威,`user_account.phone_number_e164` 只补没有 identity 的手机号;`auth_store_snapshot` 表和旧 JSON procedure 已删除,Bearer / refresh session 本进程未命中时不要再从 SpacetimeDB 导出整包状态刷新内存。线上止血先核对失败请求附近的 current session `user_id` 与兑换码 `allowed_user_ids`,不要只看手机号展示值。
- 约束:`auth_identity` 只保存登录入口身份键;手机号、昵称和头像的正式资料真相在 `user_account.phone_number_e164` / `display_name` / `avatar_url`。旧 `auth_identity.phone_e164` / `display_name` / `avatar_url` 只能作为历史回填来源,不能继续让新写入依赖这些列。
- 验证:`cargo test -p spacetime-module auth_export -- --nocapture` 应覆盖同手机号重复账号时手机号索引优先指向有 phone identity 的账号;`api-server` 中不应再存在运行期 `refresh_auth_store_from_spacetime` 调用。
- 关联:`server-rs/crates/spacetime-module/src/auth/procedures.rs``server-rs/crates/spacetime-module/src/auth/tables.rs``server-rs/crates/module-auth/src/lib.rs`
## API Build / Deploy 归档清单不能漏掉随包 Pingora 脚本
- 现象:`Genarrative-Api-Deploy` 在发布阶段报 `发布产物缺少 Pingora TLS 证书同步脚本: build/<version>/scripts/deploy/pingora-tls-cert-sync.mjs`
- 原因:`scripts/build-production-release.sh` 已经把脚本复制进 `build/<version>/scripts/deploy/`,但 Jenkins API Build 的 `archiveArtifacts` 和 API Deploy 的 `copyArtifacts` 过滤清单仍可能漏掉新增随包脚本,导致 Deploy 工作区拿到的是残缺发布包。
- 处理:新增随包部署脚本时,必须同时更新 `jenkins/Jenkinsfile.production-api-build` 的归档清单、`jenkins/Jenkinsfile.production-api-deploy` 的复制清单和 `scripts/check-production-ops-guardrails.mjs` 的字符串门禁;不要在 Deploy Job 里从工作区根目录或源码 checkout 兜底补脚本。
- 验证:运行 `npm run check:production-ops``npm run check:production-api-release``npm run check:production-api-deploy`,确认构建包、Jenkins 归档链路和 deploy fail-fast 检查口径一致。
- 关联:`jenkins/Jenkinsfile.production-api-build``jenkins/Jenkinsfile.production-api-deploy``scripts/deploy/production-api-deploy.sh``scripts/check-production-ops-guardrails.mjs`
## 图片画布角色动作结果主类型是序列帧
- 现象:产品要求画板 `生成角色动作` 返回后按透明序列帧播放和下载,但旧实现或旧测试可能继续把结果当作预览视频处理。
- 原因:后端仍需要先生成 `previewVideoPath` 再抽帧、绿幕去背和落 OSS;如果前端把预览视频当主媒体,就会绕过已经扣绿幕的 PNG 帧,也无法按序列帧打包下载。
- 处理:角色动作结果图层主 `src` 使用 `frames[0].imageSrc``mediaType` 固定为 `image-sequence``assetKind` 固定为 `character-animation`,完整帧列表写入 `imageSequenceFrames``previewVideoPath` 只作为来源信息保留。生成端确认每帧对象后必须把该帧 `objectKey``assetObjectId` 一起写入正式 payload 和 `generation_inputs_json.characterAnimation.frames`。单图层下载必须生成序列帧 ZIP;画布素材 ZIP 中角色动作写入 `sequences/<编号-标题>/frames/`。不得移除后端原有视频生成、抽帧、绿幕去背和帧落盘流程。
- 验证:`ImageCanvasGenerationLayerModel` 应断言动作结果 `src` 为首帧且 `mediaType="image-sequence"`;画布集成测试应出现 `画布序列帧:角色动作` 图片播放器,不应出现角色动作 `<video>`;导出测试应断言角色动作下载和画布素材导出都包含序列帧 ZIP / frames 目录;生成测试还应断言首帧与非首帧的稳定引用都被保留。
- 关联:`src/components/image-editor/ImageCanvasGenerationLayerModel.ts``src/components/image-editor/ImageCanvasWorldView.tsx``src/components/image-editor/ImageCanvasExportModel.ts``server-rs/crates/api-server/src/character_animation_assets.rs``docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`
## OSS 导出兜底必须覆盖响应体读取阶段
- 现象:浏览器控制台显示 OSS `206 Partial Content` 后紧跟 `net::ERR_FAILED`,画布预览或素材导出失败,但没有出现预期的 `/api/assets/read-bytes` 兜底请求。
- 原因:`fetch(signedUrl)` 可能先返回一个 `ok``Response`,网络、浏览器 Range 缓存或传输错误随后才在 `arrayBuffer()` / `blob()` 消费响应体时暴露;如果直连保护边界只包住 `fetch` 和状态码,响应体失败会绕过 fallback。
- 处理:私有素材字节读取必须在直连 OSS 分支内完整消费响应体并重新构造可重复读取的 `Response`;换签、请求、非成功状态或响应体读取任一阶段失败时统一回退同源 `/api/assets/read-bytes`。Abort 仍应直接上抛,不能转化为额外服务器读取。
- 验证:前端服务测试模拟 OSS 返回 `206/ok`、但 `blob()` reject,断言随后请求 `/api/assets/read-bytes` 并返回 fallback 完整字节;同时保留直连成功、局部分片、直连非成功、请求 reject 和 Abort 边界。
- 关联:`src/services/assetReadUrlService.ts``src/services/assetReadUrlService.test.ts``src/components/image-editor/ImageCanvasExportModel.ts`
## 图片画布序列帧播放不要复用普通图片淡入样式
- 现象:角色动作序列帧播放时看起来像每帧之间在渐变或闪烁。
- 原因:序列帧播放器每帧切换可低至 40ms,默认约 125ms 一帧;如果帧 `<img>` 复用普通图片的 `image-canvas-editor__layer-image--loading/--loaded`,其中 `opacity 180ms ease` 会跨过下一帧切换,形成类似交叉淡入淡出的视觉。
- 处理:`ImageCanvasImageSequenceFrame` 只使用序列帧专属 class,帧显隐用同步 `opacity` 硬切,并显式 `transition: none`;保留“下一帧未加载时继续显示上一帧”的 readiness gate。
- 验证:`ImageCanvasWorldView.test.tsx` 应断言序列帧 `<img>` 不带普通图片 loading/loaded class,且 style 中 `transition``none`
- 关联:`src/components/image-editor/ImageCanvasWorldView.tsx``src/index.css``src/components/image-editor/ImageCanvasWorldView.test.tsx`
## Vidu 文生音频线上网关可能要求 sound 字段
- 现象:画板点击 `生成游戏音效` 后,请求返回 `Failed to deserialize the JSON body into the target type: missing field sound`
- 原因:VectorEngine Apifox `创建文生音频任务` 文档仍写 `/ent/v2/text2audio` 使用 `model + prompt + duration`,但线上 Vidu 网关曾按 `sound` 字段反序列化;只发送 `prompt` 会被上游拦截在 JSON 解析阶段。
- 处理:前端和 BFF 对内继续使用用户语义更清晰的 `prompt``platform-audio` 转发到 VectorEngine Vidu 时同时发送 `prompt``sound`,两者值保持一致。不要把 UI 改回 Suno `task: "sound"``type``tempo` 或 BPM。
- 验证:`cargo test -p platform-audio --manifest-path server-rs/Cargo.toml --test vector_engine_audio` 中音效请求体测试必须同时断言 `prompt``sound`;必要时用线上生成音效 smoke 确认不再出现 `missing field sound`
- 关联:`server-rs/crates/platform-audio/src/request.rs``server-rs/crates/platform-audio/tests/vector_engine_audio.rs``docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`
## Suno 任务完成或返回 audiopipe 不代表已经拿到稳定下载地址
- 现象:画板生成背景音乐时,前端可能报 `音频生成尚未返回可下载地址(requestId:...)``获取 Suno 音效 wav 失败(requestId:...)``读取生成音频内容失败:error decoding response body`。上游任务可能已经完成并返回 `https://audiopipe.suno.ai/?item_id=...`,但该地址仍可能以 `200 + chunked` 开始响应后不返回完整正文。
- 原因:VectorEngine Suno `/suno/fetch/{task_id}` 可能先在 `data` 中返回歌曲 / 音效 clip id,或返回只携带 `item_id` 的 audiopipe 流式中转地址,而不是稳定 `.wav` / `.mp3` 文件 URL;需要再调用 `/suno/act/wav/{clipId}` 获取实际文件地址。如果只在“完全没有 URL”时回退 wav,会误把 audiopipe 当最终文件并让 worker 在正文读取阶段卡满请求超时。
- 处理:`platform-audio` 查询 Suno 结果时保留普通直接音频 URL;遇到 audiopipe 时不直接下载,而是从查询结果的 `id` / `clip_id` / `audioId` / `songId` 或 audiopipe `item_id` 提取 clip id,逐个调用 `/suno/act/wav/{clipId}`。已拿到 clip id 但 wav 地址仍未就绪,或 wav 子请求暂时返回上游错误时,都保持 `processing` 让上层继续轮询,不能直接判定为缺少可下载地址或 wav 获取失败。
- 验证:`cargo test -p platform-audio --manifest-path server-rs/Cargo.toml``cargo test -p api-server vector_engine_audio_generation --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/platform-audio/src/client.rs``server-rs/crates/platform-audio/src/response.rs``docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`
## 图片画布音频卡播放条 0:00 要优先查签名 URL 和嵌套交互
- 现象:画板音效或背景音乐已经生成成功,但卡片里的播放条显示 `0:00`,点击无法预览。
- 原因:generated 音频资源通常是私有 OSS 路径,直接把 `/generated-*` 或 generated OSS 地址交给 `<audio>` 会无鉴权读取失败;如果音频控件嵌在 `<button>` 图层里,浏览器还可能因嵌套交互元素阻断 controls 行为。
- 处理:音频图层使用非嵌套交互容器承接画布选择语义,内部 `<audio controls preload="metadata">` 单独阻止 pointer / click 冒泡;generated 音频播放前统一通过 `useResolvedAssetReadUrl` / `/api/assets/read-url` 换签。卡片和角标展示 `时长`,后端没返回时长时可用 `loadedmetadata.duration` 兜底。
- 验证:`npx vitest run src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts --reporter verbose`,并在浏览器确认 generated 音频控件可播放。
- 关联:`src/components/image-editor/ImageCanvasWorldView.tsx``src/components/image-editor/ImageCanvasMediaModel.ts``docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`
## 图片编辑器底部生成按钮不要复用单一画布生成状态
- 现象:图片画布里先新建一个“生成规范”占位,再点击“生成角色形象”或其它底部生成入口,前一个规范占位和面板状态被销毁。
- 原因:底部普通生成、规范、角色和图标素材曾共用单个 `generateDialog` 状态;后一次点击直接覆盖该状态,等同把前一个画布生成对象卸载。
- 处理:底部生成类入口每次点击都创建独立 generation dialog id;当前 active 对象只负责显示编辑面板,旧对象归档为 inactive 后仍保留占位和生成逻辑状态。生成完成 / 失败回写、生成中拖拽和删除都必须按 dialog id 读取 active + inactive 中的最新对象,不能回退到提交瞬间的旧占位快照。
- 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "keeps existing generation placeholders"` 应断言规范占位和角色占位可同时存在;`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "keeps archived generation logic"` 应断言旧对象归档后拖动,占位完成回写仍落在最新位置。
- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx``src/components/image-editor/ImageCanvasEditorView.test.tsx``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 图片编辑器生成中设定面板不要和预览框绑成同一可见性
- 现象:图片编辑器里点击生成后,有时设定面板没收起,有时连画布上的占位预览一起消失,看起来像“生成中界面掉了”。
- 原因:生成中状态只收了 composer 可见性,或把占位框和设定面板共用了同一段条件渲染;面板隐藏后把 placeholder 也一起卸掉,就会丢掉 Lovart 式生成中预览。
- 处理:进入 `generating` 后只隐藏设定面板,保留占位框和生成中状态胶囊;面板外观、预览框和结果图层分开控制,不共用同一个 `composerOpen` 条件。
- 验证:对应测试应断言生成按钮点击后 `dialog` 消失但 `image-canvas-editor__generation-frame--generating` 仍然存在。
- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx``src/components/image-editor/ImageCanvasEditorView.test.tsx`
## 图片画布生成器全体点不开先查卡住的临时交互状态
- 现象:特定操作后,画布中已有生成器点击不再显示设定对话框,而且不是单个生成器坏掉;新建生成器或刷新页面后恢复。
- 原因:旧生成器激活依赖全局交互状态;如果 `Shift` / 空格按住态因为窗口失焦漏掉 `keyup`,或“从画布选择参考图”等临时 picking / 菜单状态没有在激活旧生成器时清理,后续点击会被当成多选或选参考图而短路。
- 处理:窗口 `blur` / 页面隐藏时释放 `Shift` 和空格按住态;激活已有 generation dialog 时同步清理参考图 picking、规格 / 参考菜单和右键菜单;active / inactive 生成器状态的 ref 与 React state 必须同事件周期同步。
- 验证:`npm run test -- src/components/image-editor/useCanvasGenerationDialogs.test.tsx src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx -- --runInBand`,并跑 `ImageCanvasEditorView.test.tsx` 确认真实组件链路仍能激活生成器。
- 关联:`src/components/image-editor/useCanvasGenerationDialogs.ts``src/components/image-editor/useImageCanvasKeyboardShortcuts.ts``src/components/image-editor/ImageCanvasEditorView.tsx`
## 图片画布素材多时拖拽卡顿先查等距吸附候选规模
- 现象:画布素材数量增加后,拖拽单个图层或生成占位框时 pointermove 明显卡顿,关闭或绕开吸附后体感恢复。
- 原因:边缘 / 中心线吸附是线性扫描,但等距吸附如果对所有可吸附素材做两两配对,会在素材数量上来后进入 O(n²) 热路径。
- 处理:保留边缘 / 中心线全量线性扫描;等距吸附先过滤跨轴相交素材,再只检查轴向邻近候选,不要为远处或不相交素材生成配对候选。
- 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/ImageCanvasInteractionModel.test.ts`,并在多素材画布拖拽时确认参考线仍能命中邻近图层且 pointermove 不再明显掉帧。
- 关联:`src/components/image-editor/ImageCanvasEditorModel.ts``src/components/image-editor/ImageCanvasInteractionModel.ts``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 图片画布拖动卡顿先查 Stage 合帧和交互期自动保存
- 现象:素材多或序列帧多时,拖动图层、生成占位、小地图视口框或手型平移明显卡顿,像是接口慢或 CSS 动画掉帧,但网络请求不一定异常。
- 原因:高刷新率输入设备会在单个屏幕帧内发出多次 `pointermove`;每次直接 `setLayers` / `setViewport` 都会触发画布重渲染、吸附或小地图模型重算和工程持久化 effect。即使 `moveLayersFromDrag` 保留未移动图层的对象引用,若 WorldView 仍在每帧重建全部图层子树,所有真实位图的 URL hook、加载态、标签和 SVG 操作也会重复执行。持久化链路还会同步 `serializeCanvasLayout``JSON.stringify` 并写 sessionStorage,远端 PATCH 有防抖也挡不住本地同步缓存写入。
- 处理:Stage 的图层、生成占位、框选和手型平移统一用单一在途 `requestAnimationFrame` 合并同帧输入,只应用最新坐标;结束拖拽时 flush 最后一帧,主动清理和卸载时 cancel。图层、生成占位、平移和小地图拖动一旦越过拖动阈值就标记为临时交互,拖动中不触发项目保存、session cache 写入或封面快照上传,`pointerup` / `pointercancel` 后保存最终布局。WorldView 的完整单图层节点必须按稳定 layer 对象浅比较 memo,父回调通过 latest ref 的稳定门面转发,避免未移动图层重渲染或读取陈旧闭包。小地图继续只在 viewport controls 内合帧,不要重复套 rAF。
- 验证:`npm run test -- src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx --reporter verbose` 应覆盖只重渲染移动图层、稳定节点调用最新回调、同帧只保留最新坐标、结束前 flush、卸载 cancel、小地图无双重合帧、图层 / 生成占位 / 平移 / 小地图交互边界,以及拖动期间不写 sessionStorage / 不调用 `saveEditorProjectLayout`。浏览器验收必须使用多个独立 raster URL,并区分 rAF 心跳与目标实际位置变化帧;共享 data URI SVG 和包含空闲尾帧的自由 rAF 不能作为拖动流畅证据。
- 关联:`src/components/image-editor/useImageCanvasViewportControls.ts``src/components/image-editor/useImageCanvasStageInteractions.ts``src/components/image-editor/useImageCanvasProjectPersistence.ts``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 图片编辑器宣发素材生成器刷新后不要丢快照
- 现象:图片画布刷新后,宣发素材生成卡片消失,或卡片仍在但游戏名、分类、描述和参考图丢失。
- 原因:画布布局把生成器保存为 `itemType: "generation-dialog"`,但恢复白名单漏掉 `publication` 模式和 `publicationWorkflowId` / `publicationGameInfo` / `publicationReferences` 字段,导致整条生成器快照被当成无效布局项丢弃。
- 处理:`hydrateCanvasGenerationDialog` 必须把 `publication` 视为正式画布生成器模式,并显式恢复宣发素材专属字段;组件层应断言刷新回读项目快照后仍显示卡片类型、字段和参考图。
- 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx --reporter verbose`
- 关联:`src/components/image-editor/ImageCanvasEditorModel.ts``src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx``src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`
## 图片画布生成请求不要直接提交临时媒体源
- 现象:图片画布快速编辑、参考生成、去背景或角色动画提交 Data URL / Blob URL 时,前端或后端返回“必须先上传 OSS”。
- 原因:Data URL / Blob URL 体积大且不能作为持久引用;如果写入外部生成队列,请求体会膨胀,worker 也无法稳定复用浏览器临时资源。
- 处理:提交前优先复用图层已有 `objectKey`、项目资源 ID 或素材 ID;未登记的本地图片和普通 public 图片路径先通过 `resolveEditorGenerationMediaReference(...)` 读取并上传 OSS,再把稳定引用交给生成接口。Data URL 只允许用于浏览器内压缩、标注等临时处理,不能进入 API 请求、队列载荷或项目持久化。
- 验证:`npm run test -- src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx``cargo test -p api-server inline_data_url --manifest-path server-rs/Cargo.toml`
- 关联:`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/services/image-editor/editorProjectClient.ts``server-rs/crates/api-server/src/editor_generation_queue.rs``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 专用生成契约不能被通用生成接口和任务摘要绕过
- 现象:专用场景接口要求结构化 `sceneContent + stylePreset`,但调用方仍可向通用图片接口传 `kind = scene``assetKind = scene`,用任意完整 Prompt 生成并持久化正式场景;合法场景入队后,任务侧栏还可能显示后端完整规则文本和通用“生成图片”标题,空白素材名则可能回退成完整 Prompt。
- 原因:专用 handler 内部复用了通用图片 payload、队列和 Worker,但公开通用 HTTP handler 没有限制专用身份;任务摘要又无条件优先提取 payload 顶层 `prompt`,素材名默认值只处理了字段省略,没有处理空白字符串。
- 处理:公开通用 handler 拒绝专用 `kind / assetKind`,专用 handler 仍可直接调用内部共享执行函数;队列投影按 `kind = scene` 从权威 `generationInputs.fields[画面内容]` 派生标题和摘要,缺字段时失败关闭而不是回退内部 Prompt,并重新计算历史缓存;专用素材名统一把省略和空白收口为产品默认值。
- 验证:路由测试先证明旁路会越过 HTTP 边界,再断言两种旁路均返回 `400` 且指向专用端点;摘要测试覆盖新任务、历史错误缓存和缺少画面内容三种情况;标签测试覆盖省略、空白、自定义和 80 字上限。
- 关联:`server-rs/crates/api-server/src/editor_project.rs``server-rs/crates/spacetime-module/src/external_generation.rs``docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md`
## 图片编辑器角色动画必须提交稳定图片引用
- 现象:图片编辑器里对尚未上传的角色图点击 `生成动画` 后,前端或后端返回 `sourceImageSrc 必须先上传 OSS`
- 原因:角色动画会进入外部生成队列,浏览器 Data URL / Blob URL 既不适合持久任务,也会放大 JSON 请求体。
- 处理:前端统一通过 `resolveEditorGenerationMediaReference(...)` 取得 `objectKey` 或画板资源引用;本地临时角色图必须先上传 OSS。后端在入队前同步拒绝内联媒体,不再通过放宽 body limit 兼容 Data URL。
- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx``cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml`
- 关联:`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``server-rs/crates/api-server/src/character_animation_assets.rs``server-rs/crates/api-server/src/app.rs``docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`
## 图片编辑器角色动画抽帧不要采到视频尾点或逐帧重启 FFmpeg
- 现象:画板角色图点击 `生成动画` 后,Ark 视频已生成并上传 OSS,但后端返回 `ffmpeg 已执行但未产出动作帧文件(requestId:...)`
- 原因:FFmpeg 在采样时间落到视频尾点附近时可能退出码仍为 `0`,但实际输出 `0` 帧;如果后端按 `duration - 0.001` 抽最后一帧,低帧率或短视频很容易踩到不可解码尾点。旧实现还会为 `32 / 40 / 48` 个采样点分别启动 FFmpeg、重复解码同一视频,在低配 worker 上形成不必要的多秒 CPU 尖刺。
- 处理:角色动画先按目标帧数计算全部安全采样时刻,例如 `32帧·4秒` 最后一帧采 `3.875s`,不要采 `3.999s`;随后使用单个 `setpts + split + select` filter graph 批量输出全部帧,不改用粗粒度 `fps` 抽帧。命令返回后逐一检查输出,缺帧时用户主文案保持简短,details 保留首个缺帧的 `targetSeconds / outputPath`、整批 `missingFrames` 和 stdout/stderr。
- 验证:`cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml``editor_character_animation_batch_extracts_all_samples_from_short_video` 必须用一次 FFmpeg 产出整批短视频帧,尾帧测试继续锁定 `3.875s`
- 关联:`server-rs/crates/api-server/src/character_animation_assets.rs``docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`
## Windows 本地角色动画抽帧找不到 ffmpeg 先查 dev 子进程环境
- 现象:画板角色动画抽帧报 `抽取动作视频帧失败:无法启动进程 ffmpegprogram not found(requestId:...)`,但新开的 PowerShell 里 `ffmpeg -version` 正常。
- 原因:长期运行的 `api-server` 可能是在安装 FFmpeg 或更新用户 Path 之前启动的,子进程不会自动继承后续写入的用户环境变量。
- 处理:Windows 本地默认把 FFmpeg 安装到 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin`,并确保用户 Path 包含该目录;`npm run dev` / `npm run dev:api-server` 会在启动 `api-server` 时自动注入该目录和 `CHARACTER_ANIMATION_FFMPEG_PATH` / `CHARACTER_ANIMATION_FFPROBE_PATH` 绝对路径。修复后需要重启 `api-server`,不能只刷新浏览器。
- 验证:`where ffmpeg``where ffprobe` 能找到本地安装;`npm run test -- scripts/dev.test.ts -t "FFmpeg"`;重启 `npm run dev:api-server` 后访问 `/healthz`
- 关联:`scripts/dev.mjs``server-rs/crates/api-server/src/config.rs``server-rs/crates/api-server/src/character_animation_assets.rs`
## 图片编辑器生成长请求完成态必须由后端写入画布
- 现象:画板角色形象等生成请求已经在服务端返回 `200`OSS 中也已有 `generated-character-drafts/.../image.png`,但用户刷新或页面重载后仍看到旧生成卡片停在“生成中”。
- 原因:生成是一次长 HTTP 请求,浏览器在请求完成前刷新或重新挂载时会丢失原页面的成功回调;如果完成态只靠前端回调把结果图层写回 `editor_canvas.layers_json`,服务端虽然已经创建 `editor_project_resource` / `editor_asset`,但布局里的 `generation-dialog` 仍可能停在 `status="generating"` 且没有 `generatedLayerId`。如果之后从素材库把同一私有素材加回画布,前端再次创建项目资源时若提交 signed URL / Data URL,还会触发 `413`,进一步阻断资源行绑定。
- 处理:图片生成提交必须在有项目上下文时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框);`api-server` 生成成功并创建资源后,直接读取当前项目布局,只有当前布局仍存在对应生成器时才插入轻量结果图层、把生成器改回 `idle` 并写入 `generatedLayerId`,再沿用后端当前 viewport 保存 layout 并返回最新项目快照。前端只应用该快照刷新显示,不在加载时根据资源行推断完成态;有项目上下文但后端没有返回快照时也不得本地补结果图层。为已有 `objectKey` 的图层创建项目资源时,`imageSrc` 只提交 `/<objectKey>`,不要提交 signed URL / Data URL。
- 验证:`cargo test -p api-server editor_canvas_generation_completion --manifest-path server-rs/Cargo.toml` 覆盖后端完成态写 layout`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand` 覆盖前端提交 `canvasCompletion`、应用后端快照、项目加载不推断完成态和 `objectKey` 资源创建不提交大 URL。
- 关联:`server-rs/crates/api-server/src/editor_project.rs``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/components/image-editor/useImageCanvasProjectPersistence.ts``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 图片编辑器项目和素材 payload 不能持久化内联媒体
- 现象:`/api/editor/projects*`、素材库、项目资源或 layout payload 里出现数 MB 的 `data:image/*``data:video/*``data:audio/*`,刷新恢复变慢,发布入口可能 OOM / 413,素材库缩略图还可能只显示文件名。
- 原因:生成、规范图、角色图、图标 / UI spritesheet、音视频或动画帧如果直接把 Data URL / signed URL 写入 `editor_project_resource``editor_asset``editor_canvas.layers_json`,就把媒体本体塞进了项目快照;signed URL 还会过期,素材库也无法稳定换签。
- 处理:登录态媒体必须先上传 OSS / asset object,持久化只写 `imageSrc: "/<objectKey>"``objectKey``assetObjectId`;素材库和图层缩略图都通过 `PlatformMediaFrame -> ResolvedAssetImage``objectKey` 并调用 `/api/assets/read-url`。layout 序列化和后端保存要递归拒绝 `data:*` / `blob:`;旧行有 `objectKey` 时读出归一成 `/<objectKey>`,没有 `objectKey` 的旧 Data URL 必须走修复上传后回写轻量引用。刷新恢复可先用 session 轻量缓存显示,但缓存不得含内联媒体,必须按用户隔离,而且不能在后端快照回来前自动保存。认证状态变化重跑加载 effect 时,要同步用 ref 关闭写门禁并清除 revision、pending save 和 timer;不能只等 `isProjectReady=false` 的下一次 render,否则旧 effect 会先消费 skip 标记,再把公司浏览器的旧缓存无版本 PATCH 到服务端,覆盖另一台设备的新画布布局。现役 Web 与 External layout PATCH 的 `expectedRevision` 都必填,三层门禁分别放在 autosave effect、queue 和真正发送前;session cache 即使带 revision 也只有显示权。异步 project resource 创建必须把未发请求队列按用户 / 项目隔离,并记录发起时已接受的权威快照序号;若资源响应前发生认证重载、409 恢复或生成完成快照替换,只把新资源对应图层合并进当前权威布局,禁止用历史 `snapshotLayers` 整体覆盖。生成扣费、失败退款或 queue 终态后,右上角泥点余额通过 `/profile/dashboard` 回读,不做本地乐观扣减。
- 验证:Network 中 `/api/editor/projects*``PATCH /api/editor/projects/{id}`、素材库接口不应出现 `data:image` / `data:video` / `data:audio`;素材库和图层面板缩略图都能换签显示;`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/common/PlatformMediaFrame.test.tsx src/services/assetReadUrlService.test.ts src/services/image-editor/editorProjectClient.test.ts`,后端跑 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/api-server/src/editor_project.rs``src/components/image-editor/ImageCanvasEditorModel.ts``src/components/image-editor/useImageCanvasProjectPersistence.ts``src/components/common/PlatformMediaFrame.tsx``src/services/assetReadUrlService.ts`
## 图片画布裁扩后刷新或去背景丢图先查项目资源化
- 现象:从规范图裁切 / 裁扩出新图层后立即执行去除背景,去背景完成时原裁扩图从画布消失;刷新后裁扩图仍不在,但去背景占位可能变成结果图。
- 原因:裁扩结果由浏览器 canvas 本地渲染为 `data:image/png`。如果项目态先把 `local-resource-*` 图层加入画布,`serializeLayer` 不会保存 `src``resolveProjectResourceCreateImageSrc` 又会跳过内联 Data URL,后续应用后端 project snapshot 或刷新 hydrate 时找不到对应 `editor_project_resource`,该源图层就会被过滤。去背景带 `canvasCompletion` 时后端只负责把结果写入生成占位,不会恢复这个未资源化的裁扩源层。
- 处理:项目上下文中的裁扩结果必须在加入画布前先上传 OSS / asset object,再创建 `editor_project_resource`,并用服务端返回的 `resourceId/objectKey/assetObjectId` 创建裁扩图层;随后去背景的 `sourceResourceId` 和图片读取都指向正式资源。queue 模式下去背景完成后,如果首次读取的项目快照中对应 `generation-dialog` 仍是 `generating` 或缺少 `generatedLayerId`,前端短暂等待后再读取一次项目快照。
- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand` 应覆盖裁扩先上传再创建项目资源,以及去背景队列完成后对未完成占位进行二次项目读取。
- 关联:`src/components/image-editor/useImageCanvasGenerationWorkflow.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 图片画布不能用 `local-*` 前缀代替资源登记状态
- 现象:刚加入画布、资源登记仍在途的图层被复制后,权威项目快照刷新与资源创建响应交错,副本可能刷新后消失;反过来,历史自包含角色动作序列虽然也使用 `local-*`,却会被永久禁用并持续提示“素材仍在保存”。
- 原因:`local-*` 同时覆盖两种不同状态:新素材的临时 ID,以及没有项目资源行、但已凭完整持久化帧成为终态的历史兼容序列。ID 前缀不是状态机;把所有本地 ID 当 pending,或把多个布局调用压进一个临时 ID single-flight Promise,都无法表达每次请求的真实生命周期与恢复上下文。
- 处理:新素材是否 pending 必须读资源登记在途集合;存在明确在途请求时,复制、剪切、创建副本和内部粘贴整体拒绝,正式 `resourceId` 回填后开放。历史序列按结构化持久化的严格自包含谓词识别,不能显示保存中;暂不支持复制时用准确原因失败关闭。既非 pending 又不满足历史谓词的 unresolved local 图层也拒绝,但提示“资源尚未登记”而非“仍在保存”。不要把普通 layout PATCH pending 当资源登记状态,也不要阻止系统剪贴板图片导入。资源创建不再按临时 ID single-flight 合并;布局保存的串行 latest-wins 队列仍保留。
- 验证:`ImageCanvasLayerCommandModel.test.ts``useImageCanvasLayerCommands.test.tsx``ImageCanvasContextMenusView.test.tsx` 分别覆盖登记在途整体拒绝、正式 ID 回填后恢复、历史 self-contained local sequence 不误报保存中,以及 unsupported / unresolved 的准确提示;`useImageCanvasProjectPersistence.test.tsx` 继续覆盖单个资源响应与权威快照交错恢复。
- 关联:`src/components/image-editor/ImageCanvasLayerCommandModel.ts``src/components/image-editor/useImageCanvasLayerCommands.ts``src/components/image-editor/useImageCanvasProjectPersistence.ts``docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md`
## 图片画布修改图层标签不能通过创建资源和换绑实现
- 现象:用户只修改一个图层的素材类型,项目资源数量却增加且该图层的 `resourceId` 改变;如果修改请求完成前复制,原图层会换绑到新资源,副本仍引用旧资源,刷新后副本类型回退。
- 原因:把资源 `assetKind` 同时当作共享默认值和布局实例标签,只能通过按类型查找 / 创建资源来模拟局部修改。异步响应只知道原 `layerId`,无法自动追踪期间复制出的新布局实例;layout 又没有独立覆盖字段,最终形成资源身份漂移和类型错位。
- 处理:固定双层模型:`editor_project_resource.asset_kind` 是资源默认类型,`editor_canvas_layer.asset_kind_override` 是可空布局覆盖,effective 值为 `override ?? resource default`。图层标签动作只写 / 清除 override,保持资源行数量和 `resourceId` 不变;复制复用 `resourceId` 并复制 override。`asset_kind_override` 必须是追加在表末尾、默认 `None` 的 typed 字段,不能塞入 `item_json`schema 同步 migration、表目录、bindings、DTO 和 canonical hash。
- 验证:覆盖“修改标签不新增资源且不换 ID”“同资源两个图层可有不同 override”“复制保留 override 后可独立修改”“清除 override 恢复资源默认值”“刷新与 structured round-trip 不丢覆盖”,并运行 `npm run spacetime:generate``npm run check:spacetime-schema`、定向 Rust / API / 前端测试。
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs``server-rs/crates/spacetime-module/src/migration.rs``src/components/image-editor/useImageCanvasProjectPersistence.ts``src/services/image-editor/editorProjectClient.ts`
## 图片画布项目封面上传失败要有本地展示兜底
- 现象:画布项目已反复打开、保存或操作,但 `/project` 列表卡片仍只显示“项目”占位,没有封面图。
- 原因:项目封面快照需要先在浏览器生成 Blob,再上传 OSS 并创建 `assetKind: "project-cover-snapshot"` 项目资源;本地 dev 或 OSS CORS 异常时,Blob 生成成功但上传失败,服务端不会产生正式封面资源。
- 处理:服务端 `project-cover-snapshot` 仍是跨设备正式封面;前端在生成封面 Blob 后立即把 Blob 以项目 ID 写入 IndexedDB,仅作为当前浏览器展示兜底。项目列表读取时优先使用服务端封面资源,其次使用本地 IndexedDB 封面,最后才退回可见画布图层或占位。IndexedDB 兜底不得写入项目快照、不得进入 `editor_project_resource`,也不得替代 OSS / asset object 正式持久化。
- 封面是展示派生物,不是 layout 真相。常规编辑只在项目加载和原有 layout 保存触发点采样当前 `canvasSize`,不监听 ResizeObserver 尺寸变化单独增加保存频率;但用户主动返回项目页时必须先 flush 最新权威 layout,并等待同一视口封面写入 IndexedDB 和正式项目资源后再导航。为避免移动端、窄窗口或首次尺寸尚未稳定时取景过小,以当前视口中心为锚点把取景宽高至少扩大到 `1280x960`;实际值更大时保留更大值。画布存在 drawable 图层但当前取景全部离屏时,要保存纯背景封面,不能因相交列表为空而保留旧缩略图。
- 封面生成不要为同一 OSS 对象发起另一套换签缓存维度:图片、序列帧和 poster 分别复用主画布预览的 refresh key,保证封面取得相同 signed URL,由浏览器合并 in-flight 请求或命中 HTTP 缓存。通用素材上传里的 `bypassCache: true` 只用于上传后立即预览;项目封面不消费该 `src`,应在 confirm 后直接使用 object-only 结果创建项目资源。
- 验证:`npm run test -- src/components/project/ProjectCanvasCover.test.ts src/components/project/ProjectGalleryView.test.tsx src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx` 覆盖服务端封面优先、本地缓存兜底、上传失败仍保留本地封面缓存、小视口居中扩大到 `1280x960`以及大视口不缩小;浏览器 smoke 可在 `/project` 对没有服务端封面的项目写入 `genarrative-editor-project-covers` IndexedDB 记录,刷新后应显示 `blob:` 封面图。
- 关联:`src/services/image-editor/editorProjectCoverCache.ts``src/components/project/ProjectGalleryView.tsx``src/components/project/ProjectCanvasCover.tsx``src/components/image-editor/useImageCanvasProjectPersistence.ts`
## 图片画布框选预览要复用源图换签缓存
- 现象:UI 设计素材提取或快速编辑框选时,画布上的红色框选还在,但底部“框选区域预览”卡片变成空白。
- 原因:预览图从原生 `img` 改成 `ResolvedAssetImage` 后,如果没有传入源图同一套 `objectKey` / `refreshKey`,它会另起一条 `/api/assets/read-url` 缓存维度;画布主图已经显示时,预览仍可能处于空签名或失败缓存状态。
- 处理:框选预览继续用 `ResolvedAssetImage` 承接私有资源换签,但必须传源图 `objectKey`,并使用 `taskId ?? resourceId` 作为 `refreshKey`,和主画布图片保持同一签名缓存版本。只允许对 `data:``blob:` 或已带签名参数的 URL 设置 `fallbackSrc`;不要把裸 `/generated...` 私有路径作为 fallback 写进 `img`
- 验证:`npm run test -- src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx --reporter=dot` 应断言私有框选预览带 `objectKey``refreshKey`,且裸 generated 路径没有 fallback。
- 关联:`src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx``src/components/ResolvedAssetImage.tsx``src/hooks/useResolvedAssetReadUrl.ts``src/services/assetReadUrlService.ts`
## 图片画布发布入口 429 先查自动保存 PATCH 并发
- 现象:发布域名访问画板时出现短时间密集 `429`Nginx access log 中 `PATCH /api/editor/projects/<projectId>`、生成接口和资料接口混杂,429 行常见 `request_time=0.000``upstream_status=-`error log 写 `limiting connections by zone "genarrative_api_conn"`
- 原因:这类 429 是入口 Nginx `limit_conn` 在转发前拒绝,不是 api-server、SpacetimeDB、worker 或 VectorEngine 的业务 429。画布自动保存如果只有防抖、没有 in-flight 串行保护,慢 `PATCH /api/editor/projects/{projectId}` 未完成时,拖拽生成器、资源回填和后续状态变化会继续发起新的保存请求,同一客户端连接数被长请求撑满后触发入口连接限流。
- 处理:不要先放大 Nginx 限流或把错误归给生成 provider;先看 access log 的 `upstream_status` / `request_time` 和 error log 的 `limit_conn` zone,再查前端保存路径。`useImageCanvasProjectPersistence` 中自动保存和资源创建后的布局保存必须共用串行队列:同一时刻只允许一个 `saveEditorProjectLayout` in-flight,期间新快照覆盖旧待保存快照,当前保存结束后只发送最新一次。
- 验证:`npm run test -- src/components/image-editor/useImageCanvasProjectPersistence.test.tsx -t "serializes project layout saves" --reporter verbose` 应覆盖慢保存期间不启动第二个 PATCH,首个保存完成后只发送最新待保存快照;排查发布现场时 429 行应从 `upstream_status=-` / Nginx `limit_conn` 收敛。
- 关联:`src/components/image-editor/useImageCanvasProjectPersistence.ts``src/components/image-editor/useImageCanvasProjectPersistence.test.tsx``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 图片画布发布入口 429 也要查 read-url 换签爆发
- 现象:发布域名刚上线或刷新画板后出现短时间 `429`Nginx access log 中集中为同一 IP / 同一 `editor/canvas?projectid=...` referrer 的 `GET /api/assets/read-url?objectKey=generated-character-drafts/editor/ui-design-assets/.../asset-001.png` 到几十上百个 UI 设计切片;429 行常见 `request_time=0.000``upstream_status=-`error log 写 `limiting requests ... zone "genarrative_api_rps"`
- 原因:这类 429 是入口 Nginx `limit_req` 在转发前按 RPS burst 快拒,不是 api-server、SpacetimeDB、worker 或 VectorEngine 的业务 429。UI 设计提取、角色动画帧或大量私有素材恢复会让多个 `ResolvedAssetImage` 同时挂载;如果 `/api/assets/read-url` 只有同 key pending 去重和缓存,没有跨 objectKey 节流,一个页面能在同一秒内发出数百个不同 objectKey 换签请求并打满 `genarrative_api_rps` burst。
- 处理:不要先放大 Nginx 通用 API 限流;先按 access log 聚合 `read-url` 数量、状态和 referrer,确认是否同一画板页面触发。`assetReadUrlService` 必须统一承接私有 generated 资源换签,并在真实请求前做跨组件轻量节流;画板、素材库、运行态和结果页不得直接绕过该服务调用 `/api/assets/read-url`
- 验证:`npm run test -- src/services/assetReadUrlService.test.ts --reporter verbose` 应覆盖大量不同 objectKey 同时换签时首批限量放行、后续按间隔派发;发布现场同类页面刷新时,Nginx `GET /api/assets/read-url` 429 应从 `upstream_status=-` / `genarrative_api_rps` 收敛。
- 关联:`src/services/assetReadUrlService.ts``src/hooks/useResolvedAssetReadUrl.ts``src/components/ResolvedAssetImage.tsx``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 图片编辑器 Seedance 2.0 参考媒体只提交稳定引用
- 现象:画板生成视频选择 Seedance 2.0 并上传参考视频后,请求体暴涨、可能返回 `413` 或上游拒绝 `video_url.url`;文档示例或测试如果写 `data:video/mp4;base64,...`,后续实现很容易照抄。
- 原因:画板生成会进入持久队列,Base64 / Data URL / Blob URL 会放大请求和任务 JSON;直接签名客户端给出的 objectKey 又会绕过跨账号素材归属校验。项目资源 ID、素材 ID 和 objectKey 必须先解析到当前 owner 的正式对象,公网 URL 与 `asset://` 才能按供应商契约直接透传。
- 处理:本地参考图片 / 视频 / 音频先走 `/api/assets/direct-upload-tickets` 直传 OSS,再 `/api/assets/objects/confirm` 确认;前端保留 signed URL 做预览,提交生成时使用 `objectKey`、项目资源 ID 或素材 ID。后端归一化拒绝全部 `data:*` / `blob:*`,签名 OSS URL 前统一校验 owner;稳定引用字段总长度限制为 `256KB`,非 Seedance 模型携带参考字段也必须拒绝;Ark body 显式带 `generate_audio:false`
- 验证:`npx vitest run src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/services/image-editor/editorReferenceUploadClient.test.ts --reporter verbose``cargo test -p api-server editor_video --manifest-path server-rs/Cargo.toml``cargo test -p shared-contracts editor_video_request_supports_seedance_multimodal_references --manifest-path server-rs/Cargo.toml`
- 关联:`src/services/image-editor/editorReferenceUploadClient.ts``src/components/image-editor/useImageCanvasUploadWorkflow.ts``src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts``server-rs/crates/api-server/src/character_animation_assets.rs``docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`
## 图片编辑器生成类菜单要挂到页面级 portal
- 现象:底部 `生成规范` 菜单、角色面板里的 `角色规范` 来源菜单点击后像没有弹出来,实际被按钮所在的局部滚动容器挡住了。
- 原因:菜单仍然渲染在底部工具栏或参考图横向滚动行内部,父容器带 `overflow`,弹层无法越出边界;即便挂到 portal,如果菜单根节点的 `pointerdown` 继续冒泡到画布视口,也会先触发画布失焦并卸载面板,导致菜单项 `click` 前消失。
- 处理:这类轻量菜单统一用页面级 fixed portal 挂到 `document.body`,位置根据触发按钮的 `getBoundingClientRect()` 计算;`PlatformFloatingMenu` 根节点必须阻止 `pointerdown` 冒泡,避免画布清空当前生成面板;底部 AI 工具栏在生成面板打开时仍保持可见,不要整栏隐藏。
- 验证:测试断言菜单不包含在底部工具栏 / 参考图行里,并且生成面板打开时底部 `AI画布工具栏` 仍存在;规范参考图来源菜单应能通过 portal 点击“从画布中选择 / 上传图片”并写回规范参考图。
- 关联:`src/components/common/PlatformFloatingMenu.tsx``src/components/image-editor/ImageCanvasEditorView.tsx``src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`
## 图片编辑器 portal 菜单必须显式继承画板主题 token
- 现象:生成视频参数面板里点击“静音”后,开关轨道和白色滑块一起消失;如果直接把轨道改成 `#00ff00`,虽然重新可见,却变成与画板主题不一致的荧光绿。相同比例、清晰度、slider、时长文字和模型选中勾选也可能丢失选中态主题。
- 原因:`renderEditorPortal(...)``.image-canvas-editor__portal-menu` 挂到 `document.body`,它不再是 `.image-canvas-editor` 的后代,无法继承只定义在编辑器根节点上的 `--image-canvas-brand-*` 自定义属性。浏览器会把依赖缺失变量且没有 fallback 的声明按无效值处理,轨道背景最终为透明。
- 处理:portal 继续挂到 `document.body` 以避免局部 `overflow` 裁切,但外层必须通过 `.image-canvas-editor__portal-theme` 同步当前 `platform-theme--light / platform-theme--dark`;画板品牌 token 由 `.image-canvas-editor`、主题桥接层与 `.image-canvas-editor__portal-menu` 共用同一组声明。控件继续消费主题变量,不使用单点硬编码颜色,也不要只给静音轨道补 fallback 而遗漏同一 portal 内其它 token 消费者。
- 验证:`scripts/image-canvas-portal-theme.test.ts` 应锁定编辑器根节点、portal 主题桥接层与 portal 菜单共享完整品牌 token,静音 pressed 轨道仍使用 `var(--image-canvas-brand-accent)` 且不出现 `#00ff00``useImageCanvasGenerationSurface.test.tsx` 应覆盖暗色主题 class 被桥接到 `document.body` 下的 portal。真实浏览器从 `生成视频 -> 视频参数 -> 静音` 点击后,轨道 computed background 应为非透明当前主题色,portal 内 `--image-canvas-brand-accent``--image-canvas-brand-border-strong``--image-canvas-brand-soft` 均应有值。
- 关联:`src/index.css``scripts/image-canvas-portal-theme.test.ts``src/components/image-editor/ImageCanvasEditorPortal.tsx``src/components/image-editor/useImageCanvasGenerationSurface.tsx``src/components/image-editor/ImageCanvasGenerationComposerView.tsx`
## 图片编辑器规范图片面板不要脱离统一生成 shell
- 现象:生成 UI 设计图或新建图标规范时,面板参考图、输入区和底部生成按钮相对生成图片 / 生成角色 / 生成视频错位;图标规范甚至可能缺少首行参考图入口。
- 原因:规范、UI 设计图等面板虽然都属于生成类入口,但 JSX 和 CSS 曾各自维护 `spec-footer`、局部 field wrapper 或缺省参考区,导致后续改造只覆盖普通图片 / 角色 / 视频,规范图片类面板结构漂移。
- 处理:生成规范下的角色规范、图标规范、自定义规范,以及生成 UI 设计图,都必须复用 `image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image` 外层 shell;首行统一 `image-canvas-editor__generation-ref`,底部统一 `image-canvas-editor__generation-composer-footer` + `image-canvas-editor__generation-submit`。多字段内容只在中央字段区保持紧凑,不单独发明 footer 或省略参考区。
- 验证:`npm test -- src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx -t "生成UI设计图|生成规范|visible titles|图标规范|character spec"`
- 关联:`src/components/image-editor/ImageCanvasGenerationComposerView.tsx``src/index.css``docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`
## 图片编辑器生成占位图在生成中也要使用最新拖拽位置
- 现象:用户在图片编辑器里提交生成后继续拖动画布占位图,预览框可以移动,但生成完成后的真实图片仍落回提交瞬间的旧位置。
- 原因:生成提交函数闭包里保存了旧的 `dialog.placeholder` 快照;如果完成回包仍用这个快照创建图层,就会丢失生成中期间的拖拽坐标。若 `handleGenerationFramePointerDown` 又按 `status === 'generating'` 拦截,则生成中占位图完全不能拖动。
- 处理:生成占位图的 pointer down 不因 `generating` 禁止;普通图片、规范图、角色图和图标素材回包创建图层时,都从当前 `generateDialogRef.current.placeholder` 读取最新占位位置,失败后保留的占位图也继续走同一拖拽链路。
- 验证:`npm test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "keeps the generation placeholder draggable while the image is generating"`
- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx``src/components/image-editor/ImageCanvasEditorView.test.tsx``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 图片画布 Lovart 新生成占位必须避让已有图层和占位
- 现象:用户在画布中心已有图片时继续点击“生成图片 / 生成视频 / 生成规范”等入口,新建的待生成占位压在已有图片或其它待生成占位上;生成完成后看起来像图片被覆盖或丢失。
- 原因:入口直接把 placeholder 放在当前视口中心,没有把已有图层、隐藏状态和 inactive generation dialog 的占位统一纳入避让计算,也没有在落点确定后把 viewport 平移到新占位中心。
- 处理:所有会创建 generation dialog 的入口都必须走 `ImageCanvasGenerationPlacementModel`,避让所有 `hidden !== true` 的图层和 active / inactive placeholder;按 32px 世界坐标间距外扩阻挡矩形,在候选点中选择距离当前屏幕中心对应画板位置最近且不重叠的位置,再调用 `centerViewportOnPlacement(...)` 保持缩放只平移。
- 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`
- 关联:`src/components/image-editor/ImageCanvasGenerationPlacementModel.ts``src/components/image-editor/useImageCanvasGenerationWorkflow.ts``docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`
## 图片画布生成类 composer 打开后必须自动进入可见安全区
- 现象:生成器、快速编辑、裁扩或角色动作面板打开后,面板可能在当前画布视口外,或被底部工具栏 / 左下 dock 盖住,用户只看到一部分甚至完全看不到输入框。
- 原因:placement 只负责选择画布世界坐标里的占位落点,面板实际 DOM 宽高、`translateX(-50%)`、移动端 fixed 样式和工具栏覆盖区域没有反向修正 viewport。
- 处理:所有画布内 composer / 面板渲染后统一走 `resolveViewportForOverlayVisibility(...)`,用真实 DOM 矩形和工具栏安全边界只平移 viewport;新增入口不要在各自按钮 handler 里写独立偏移。
- 验证:`npm run test -- src/components/image-editor/ImageCanvasOverlayModel.test.ts src/components/image-editor/useImageCanvasGenerationSurface.test.tsx`
- 关联:`src/components/image-editor/ImageCanvasOverlayModel.ts``src/components/image-editor/useImageCanvasGenerationSurface.tsx``docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`
## 图片画布改造按原 action 生成新产物,快速编辑不要新建生成器
- 现象:用户点击图片素材的“快速编辑”后,画布上额外出现 `Quick Edit Generator` 占位,像是新建了一个生成器;但用户预期是在原图下方框选区域、填写一个提示词和模型,然后直接修改当前图。
- 原因:快速编辑入口和提交链路误用了 `createQuickEditGenerationDialogDraft(...)` / `CanvasGenerationDialogState`,把“覆盖源图”的快速编辑伪装成会产出新图层的生成器占位。
- 处理:图片快速编辑必须走 `QuickEditPanelState`,打开时归档当前 active generation dialog 但不创建新的 `mode="quick-edit"` dialog;提交时调用 `/api/editor/images/edits`,主来源始终使用当前图片已登记的 `resourceId``sourceAssetId`。带编号标注的图片上传后只作为辅助 `referenceImageSrcs`,不能替换主来源身份;成功后覆盖源图,失败时保留快速编辑面板。快速编辑任务进入 `generating` 后必须移除框选工具和覆盖层,禁止继续新增框选;失败恢复面板后可继续调整框选再重试。`image.edit` 结果不允许改造;其它可改造产物按原 `action` 恢复 generation dialog 并生成新产物,去背景等异步入口继续使用各自现役 dialog / placement 链路,不恢复独立 quick-edit 改造面板。
- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx -- --runInBand`,以及按需运行 `npm run test -- src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "快速编辑|quick edit" -- --runInBand`
- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx``src/components/image-editor/useImageCanvasGenerationWorkflow.ts``src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/services/image-editor/editorImageReference.ts`
## 图片画布快速编辑完成必须按目标图层回写
- 现象:图片快速编辑任务成功后,刷新页面素材库能看到新图,但画布上的源图没有替换。
- 原因:`/api/editor/images/edits` 只保存生成图、项目资源和素材;没有 `canvasCompletion` 时不会写 `editor_canvas.layers_json``sourceResourceId` 只能表示溯源,同一资源可出现在多个图层,不能用它来决定替换哪一层。
- 处理:图片快速编辑请求必须传 `targetLayerId`;后端在没有 `canvasCompletion` 的快速编辑完成分支里,用目标 layer id 和生成资源写回项目 layout。
- 验证:`npm run test -- src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand`;后端验证至少覆盖 `editor_image_edit_request_omits_price_mud_points``editor_image_edit_can_complete_by_replacing_target_layer`
- 关联:`src/services/image-editor/editorProjectClient.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``server-rs/crates/api-server/src/editor_project.rs`
## 图片画布快速编辑尺寸要区分用户目标和 provider 对齐尺寸
- 现象:原图经过快速编辑后 Resolution 变成近似比例的 1K / 2K 预设;原图或框选标记图宽高不是 16 的倍数时,VectorEngine edits 直接拒绝请求。
- 原因:前端已有源图精确 `originalWidth/originalHeight`,提交时却按最近常用比例和 K 档重新计算 `size`;后端又把非 16 倍数的目标尺寸和原始参考图字节直接放进 multipart,并以 provider 回图宽高落库和覆盖画布图层。
- 处理:画布快速编辑展示与常规图片生成一致的模型、比例和尺寸参数,默认继承来源生成器参数;缺少来源生成器时使用图层模型,并按真实分辨率推导比例和尺寸。用户当前选定的比例和尺寸共同决定业务目标分辨率,允许覆盖源图旧分辨率;api-server 只在 provider 边界向右、向下复制边缘像素,把每张参考图和目标尺寸临时补齐到 16 的倍数,收到回图后裁回业务目标尺寸再持久化。若上游异常返回其他尺寸,先按目标比例裁切缩放;临时对齐尺寸不能进入 OSS 元数据、`editor_project_resource``editor_asset` 或画布 Resolution。结果覆盖目标图层时更新原始分辨率,并保持图层中心位置不跳动。
- 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx` 覆盖来源参数继承、模型参数切换、目标尺寸提交和图层回填;`cargo test -p api-server editor_image_edit --manifest-path server-rs/Cargo.toml` 覆盖图标类拒绝、provider 尺寸对齐和回图恢复。
- 关联:`src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/components/image-editor/ImageCanvasGenerationLayerModel.ts``server-rs/crates/api-server/src/editor_project.rs`
## 图片画布生成中占位必须同步业务目标尺寸
- 现象:用户选择 `2K` 生成或从 2K 普通图、角色图再次改造时,最终成品仍是 2K,但待生成 / 生成中的灰色框保持 1K 大小,完成后突然放大;UI 素材提取也可能始终显示 512 方框。
- 原因:同来源改造先按默认 1K 创建 draft,再只恢复 `imageModel / aspectRatio / imageSize`,没有重新计算 placeholder;UI 提取和旧修改入口则分别写死图标展示尺寸与 `1024x1024`
- 处理:所有共享图片参数恢复和面板比例 / 清晰度切换都经过 `resizeGenerationPlaceholderToImageSelection(...)`,保持占位中心不变并同步 `width/height/originalWidth/originalHeight`UI 提取按 `resolveUiAssetExtractionGenerationPlan(...)` 的 1K / 2K 计划计算占位,旧修改入口使用源图真实 Resolution。快速编辑覆盖源图,不另建生成占位,仍按目标尺寸更新原图层。
- 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx --reporter=dot`
- 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts``src/components/image-editor/ImageCanvasGenerationDialogModel.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 图片画布快速编辑模型必须在后端选择正确的 provider 协议
- 现象:快速编辑继承或选择 `nanobanana2` 后,上游返回 `not supported model for image generation`;图集开放快速编辑后尤其容易触发。
- 原因:前端把 `gemini-3.1-flash-image-preview` 正常提交到 `/api/editor/images/edits`,但后端无条件使用只支持 `gpt-image-2` 的 VectorEngine `/v1/images/edits` multipart 协议。
- 处理:快速编辑请求同时提交 `model / aspectRatio / imageSize`。api-server 归一模型后分流:`nanobanana2` 使用 `/v1beta/models/{model}:generateContent`,把原图和参考图放入 `inline_data`,并传递 `generationConfig.imageConfig``gpt-image-2` 继续使用 `/v1/images/edits` multipart 和 16 像素 provider 边界对齐。nanobanana2 保留 provider 输出几何尺寸,不套用 GPT edits 的像素恢复。
- 验证:`npm run test -- src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx` 覆盖前端参数提交;`cargo test -p api-server editor_image_edit --manifest-path server-rs/Cargo.toml` 覆盖模型分流、尺寸档位计费与 GPT 对齐恢复。
- 关联:`src/services/image-editor/editorProjectClient.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``server-rs/crates/api-server/src/editor_project.rs``server-rs/crates/api-server/src/openai_image_generation.rs`
## 图片画布纯尺寸变换必须先在内存决策再单次上传
- 现象:`nanobanana2` 已返回并成功解码图片,任务随后报“尺寸无效”;普通生图若把尺寸恢复作为硬失败,provider 已成功回图后仍可能没有素材进入素材库和画布。
- 原因:通用尺寸恢复把 `nanobanana2` 的标量清晰度档位 `512 / 1024 / 2K` 当成 `WIDTHxHEIGHT` 解析;普通图片与快速编辑还把 OSS 持久化放在尺寸恢复之后。同步 `generateContent` 返回的是内联 base64,本地兜底 task id 不能用于向 provider 回查原图。
- 处理:`nanobanana2` 保留 provider 输出几何尺寸;其它模型仍按显式像素目标尝试恢复。普通生图和快速编辑先把 provider 回图留在内存,尺寸恢复成功后只上传变换结果,恢复失败则降级为只上传 provider 原图;每个主结果只执行一次 OSS 持久化并只创建一个素材。角色、图标图集、UI 提取和角色动作的 provider 原始输出按多产物语义单独保留并承载任务模型成本,后续抠图、逐帧处理和切片阶段成本为 0;中间产物沿用 `character``icon-spritesheet``character-animation` 等真实类型,不新增“原图类型”。扣费确认仍以 provider 成功为界,不延长到 OSS、后处理或画布回填;后台按任务显示最终产物父行,并把每个中间产物作为独立子行展开,分别展示阶段生成器和阶段成本。
- 验证:`cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml` 覆盖 nanobanana 标量尺寸、变换失败回落 provider 原图、变换先于单次持久化;`cargo test -p api-server character_animation_assets --manifest-path server-rs/Cargo.toml` 覆盖角色动作原始预览的真实类型和成本归因;`npm run test -- apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx` 覆盖中间产物逐行展开和成本文案。
- 关联:`server-rs/crates/api-server/src/editor_project.rs``apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx`
## 图片画布快速编辑元数据必须记录原图引用
- 现象:快速编辑生成的新图可以替换画布,但打开图片信息时“生成输入”里看不到被修改的原图。
- 原因:信息面板直接渲染 `generationInputs.references`;快速编辑虽然以 `sourceReferenceId` 指定原图,但如果 `buildQuickEditGenerationInputs(...)` 不把该业务 ID 写成引用,后端资源和画布层都没有可展示的原图引用。
- 处理:快速编辑的 `generationInputs.references` 必须始终包含 `原图`,再追加用户额外参考图;关闭额外参考图入口时也不能删除这条源图引用。
- 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx -- --runInBand`
- 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts``src/components/image-editor/ImageCanvasMetadataModalView.tsx``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`
## 图片编辑主来源不能接受 objectKey 或请求类型
- 现象:调用方可把 objectKey、URL 或 Data URL 当作主来源,再用请求 `assetKind` 或另一个允许编辑的目标图层为禁止类型“借壳”;无目标图层时,后端还会扫描账号全部项目和素材库。
- 原因:HTTP DTO 同时承担外部请求与队列载荷,来源身份、存储定位和类型真相混在 `sourceImageSrc/sourceResourceId/assetKind` 中;worker 没有按业务 ID 复核入队后的身份漂移。
- 处理:站内与 External v1 API 调用方只提交必填 `sourceReferenceId`,且只接受当前账号项目资源 ID 或素材 ID;上传对象必须先登记。后端按两张表主键分别窄查,双表同 ID 时失败关闭,objectKey 仅作为服务端解析结果。目标绑定优先比较双方 `assetObjectId`,缺失才比较 canonical `(bucket, objectKey)`,并校验双方默认类型一致。队列保存版本化解析快照,worker 执行前再次定点解析;旧任务只把既有资源 ID 或旧来源字符串本身作为业务 ID 尝试迁移,禁止 objectKey 反查和旧 `assetKind` 真相回退。
- 验证:覆盖资源 ID、素材 ID、双表冲突、跨账号、raw objectKey/URL/Data URL/Blob URL、旧字段、禁止类型、目标对象与类型冲突、快照漂移、旧任务迁移、红框图辅助引用,以及 Canvas Agent 缺少 `reference_id`
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs``server-rs/crates/api-server/src/editor_project.rs``server-rs/crates/api-server/src/external_generation_worker.rs``src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts``docs/openapi/genarrative-external-v1.openapi.json`
## 图片画布生成完成应用项目快照后也要刷新素材库
- 现象:部分素材生成成功后画布上已经出现结果,但左侧素材库没有立刻出现新素材,刷新页面后才显示。
- 原因:生成接口带 `project` 快照时,前端只调用 `applyProjectSnapshot(...)` 刷新画布布局;左侧素材库状态仍停留在首次 `loadEditorAssetLibrary()` 的结果。只有少数图片分支手动 `upsertGeneratedAsset`,图标素材图集、视频、音频、排队完成后重新 `loadEditorProject` 等分支不会统一更新素材库。
- 处理:素材库 hook 必须提供显式 `refreshAssetLibrary()`;传给生成工作流的项目快照应用函数应包装为“先应用项目快照,再刷新素材库”。新增生成分支不要在各自分支散落刷新逻辑,除非是无项目快照的本地图层回填,才继续使用 `generatedAssetSnapshot` / `upsertGeneratedAsset`
- 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "refreshes the asset library after an icon generation project snapshot is applied" -- --runInBand``npm run test -- src/components/image-editor/useImageCanvasAssetLibrary.test.tsx -- --runInBand`
- 关联:`src/components/image-editor/useImageCanvasAssetLibrary.ts``src/components/image-editor/ImageCanvasEditorView.tsx``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`
## Windows 本地 dev 不要把 RUSTC_WRAPPER 绕过写成 rustc
- 现象:Windows 上执行 `npm run dev:api-server` 时,api-server 在 Cargo 启动阶段失败,日志出现 `error: multiple input filenames provided (first two filenames are ... rustc.exe and -)``/healthz` 无法访问。
- 原因:`server-rs/.cargo/config.toml` 默认配置 `rustc-wrapper = "sccache"`;本地 dev 脚本为了绕过损坏的 sccache 需要覆盖 wrapper。Windows 下如果把 `RUSTC_WRAPPER` 设置为 `rustc`Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,真实 rustc 把 wrapper 传入的 rustc 路径和 stdin `-` 都当输入文件。
- 处理:Windows 本地 dev 脚本应把 `RUSTC_WRAPPER``CARGO_BUILD_RUSTC_WRAPPER` 显式设为空字符串,让 Cargo 覆盖项目配置并直连真实 rustc;Linux 保持 `/usr/bin/env` 绕过 sccache。
- 验证:`npm run test -- scripts/dev.test.ts -t "Windows 下本地 dev Rust env 用空 wrapper 覆盖项目 sccache"`,并用 `npm run dev:api-server` 拉起后访问实际 api 端口的 `/healthz` 返回 200。
- 关联:`scripts/dev.mjs``scripts/dev.test.ts``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## Pingora 直连 80/443 不能只改 env
- 现象:`/etc/genarrative/pingora-gateway.env` 已把 `GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN` / `HTTP_REDIRECT_LISTEN` 改到 `0.0.0.0:443` / `0.0.0.0:80`,但 `genarrative-pingora-gateway.service` 启动失败,日志出现低端口绑定权限错误。
- 原因:默认 service 用非 root `genarrative` 用户运行,并且主模板为了保持 shadow 安全边界不带 `CAP_NET_BIND_SERVICE`。低端口直连必须通过显式 systemd drop-in 单独授予 capability;同时 Certbot 私钥默认未必允许 `genarrative` 读取,Nginx 也可能仍占用 `80/443`。另一个常见误区是 API release 只带 `pingora-direct-enable.sh` / rollback 壳脚本,却漏带 `pingora-current-release-audit.mjs``pingora-direct-rehearsal-status.mjs``check-pingora-direct-preflight.mjs``check-pingora-direct-live.mjs``deploy/systemd/``deploy/env/``deploy/pingora/`,导致从 `/opt/genarrative/current` 启用时依赖 Jenkins 工作区、源码 checkout 或 `/etc` 里某份参考模板;或者 release 已经包含新版 `pingora-gateway`,但已运行的 shadow / canary / direct service 没有随 `current` 链接切换重启,仍在跑旧二进制。Jenkins API Build、API Deploy 和 Full Build-And-Deploy 默认要求 Pingora 产物,并用 `--require-pingora-gateway` 在部署阶段硬校验;手工本地 API 包仍需显式 `--include-pingora-gateway` 才会把二进制、checksum 和 manifest artifact 写入发布包。Server-Provision 安装到 `/etc/genarrative/pingora/genarrative-pingora-gateway-direct-entry.conf` 的 drop-in 只用于人工审阅和显式覆盖;直连启用脚本默认必须读取 current release 随包 `deploy/systemd/genarrative-pingora-gateway-direct-entry.conf`,否则旧 `/etc` 模板会掩盖发布包缺失。API deploy 脚本本身也不能继续用部署工作区根部的 `scripts/deploy/production-api-deploy.sh`,否则 Jenkins workspace 里的脚本会掩盖 `build/<version>` 发布包缺少 deploy / maintenance 同目录脚本的问题;备份脚本、健康巡检脚本和 env 示例目录同样不能从部署工作区兜底,切换命令证据脚本也不能从部署工作区兜底,否则 current release 会和上游构建归档漂移。Pingora 直连依赖、备份脚本、巡检脚本、env 示例目录和 API deploy 执行入口都必须来自上游发布产物;随包 `api-server.sha256` 和可选 `pingora-gateway.sha256` 也必须复制进 current release,供随包 current release 自审校验二进制;随包 `deploy/pingora/pingora-gateway.env.example` 也不能只检查存在,还要保持 gzip-only、不信任 XFF、前置代理确认关闭、接流保护开启和空 probe token 这些生产安全默认值;`production-api-deploy.sh` 发现缺失时应在 current 切换前 fail-fast、清理 staging 并退出本次打开的维护模式,不应从部署工作区兜底补齐;所有 API 发布包都必须携带 `release-manifest.json` 且登记 `api-server` artifact,发布包包含 Pingora 时还必须登记 `pingora-gateway` artifact,否则 deploy 应在切换 current 前失败;deploy 必须要求 release root、current link 和 api env file 都是绝对路径,release version 以数字或字母开头并拒绝点目录,再先写 staging release,全部复制完成后用非合并语义提升为正式 release,失败时清理 staging 且不留下正式 release,同版本 release 已存在、提升前竞态出现或 current 路径不是符号链接时拒绝覆盖 / 合并,避免旧文件混入 current;发布包包含 Pingora 时,deploy 必须先确认 systemd 最终配置没有 direct-entry `CAP_NET_BIND_SERVICE`、env 仍是 `127.0.0.1:18081` shadow 且未配置 `TLS_LISTEN` / `HTTP_REDIRECT_LISTEN`,再提升 release、切换 current 并 `restart` Pingora shadow;配置不安全时必须在切换 current 前失败并退出本次打开的维护模式,current 切换后的 readiness / 服务重启失败仍保留维护模式。
- 踩坑补充:Bash 的进程替换 `< <(...)` 不会自动把生产者子进程的失败状态传给消费循环。Pingora systemd 检查若在子进程发现 `CAP_NET_BIND_SERVICE` 后直接退出,父函数仍可能继续用空列表输出“缺少 EnvironmentFile”,外层命令替换又继续用空 env 输出“LISTEN 为空”,形成三条互相矛盾的错误。部署前检查必须先捕获并显式检查配置提取命令的退出状态,再解析 EnvironmentFile;首错失败后立即返回。回归用 `npm run check:production-api-deploy` 的 direct-entry fixture 同时断言后两条误报不存在。
- 踩坑补充:旧 release 可能没有 `pingora-gateway` 二进制,但 systemd 仍残留历史 `direct-entry.conf`,同时 Nginx 已正常接回 `80/443`、Pingora inactive、env 已是 shadow。此时不要直接执行当前 `pingora-direct-rollback.sh --apply`:脚本删除 drop-in 后会固定重启 Pingora,因 current 二进制不存在而中止,后续 Nginx reload/smoke 不会执行。先确认 current 确实无可执行网关、Pingora inactive、env 已完整恢复 shadow、Nginx 配置与公网 smoke 正常,再以单次 fail-fast 运维命令删除 stale drop-in、`daemon-reload`、复核 capability/DropInPaths 已清空,随后 reload(若 inactive 则 startNginx,并复核 Nginx/API/SpacetimeDB、正式 vhost smoke 和 health patrol nginx 模式;不要伪造 `--require-pingora-shadow` 验收。下一次包含 Pingora artifact 的 API Deploy 会在切换 current 后启动新 shadow 网关。
- 处理:确认真实 TLS 证书和 redirect env 已写入 `/etc/genarrative/pingora-gateway.env`、service 模板和 `systemctl cat` 最终配置读取的 `EnvironmentFile=` 都包含这份 env、当前执行用户和 `genarrative-pingora-gateway.service``User=` 服务用户都能读取证书链 / 私钥、current release 的 `pingora-gateway` 已存在且可执行、Nginx 或其它进程已释放 `80/443` 后,先用 `npm run plan:pingora-direct-cutover -- --require-direct ...` 生成只读 JSON runbook,并逐条审阅 Host 与回退巡检入口确认、current release 自包含自审、current release preflight、启用前基础 readiness、direct enable dry-run、direct enable apply、启用后 `--require-direct` 复核、rollback dry-run、rollback apply、回退后 health patrol 切回 Nginx 并恢复切换前 public base URL / Host、回退后 health patrol env 复核;runbook 只用于审阅,不修改系统。正式 runbook 中 `--direct-redirect-host``--rollback-nginx-smoke-host``--direct-host` 必须使用同一 hostname,只允许端口不同,避免 redirect 或回退 smoke 各自验证到不同入口;同时必须提供 `--rollback-health-patrol-public-base-url <切换前Nginx巡检入口>`,若切换前 Nginx 巡检需要 Host 覆盖,再追加 `--rollback-health-patrol-public-host <切换前Host>`,确认步骤会展示回退后要恢复的 public base URL / Host,避免回退 runbook 把现场巡检入口覆盖成仓库默认值;如需把回退后 Pingora shadow 探针复核纳入 runbook,追加 `--rollback-pingora-shadow-probe-url` / `--rollback-pingora-shadow-probe-token`JSON 输出会隐藏 token 原文。随后先执行 `/opt/genarrative/current/scripts/ops/pingora-current-release-audit.mjs --release-root /opt/genarrative/current --require-pingora-gateway --systemd-show`,再 dry-run `/opt/genarrative/current/scripts/deploy/pingora-direct-enable.sh --no-status`,最后执行 `/opt/genarrative/current/scripts/deploy/pingora-direct-enable.sh --apply --preflight-env-file /etc/genarrative/pingora-gateway.env --preflight-check-cert-readable --preflight-check-service-env-file --preflight-check-service-user-cert-readable --preflight-check-service-binary-executable --preflight-check-ports-free --direct-https-base-url https://127.0.0.1 --direct-http-base-url http://127.0.0.1 --direct-host <域名> --direct-redirect-host <域名或host:port> --direct-spacetime-database <库名> --direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log`,由脚本先跑 direct preflight,再安装 drop-in、reload systemd、重启 Pingora,并用 `systemctl cat` 核验 capability 和 `EnvironmentFile=/etc/genarrative/pingora-gateway.env` 已生效、用 `systemctl show ... ExecStart` 核验最终 service 仍指向随包主 service 模板里的 current release `pingora-gateway`、用 `systemctl is-active` 确认服务 active,再以 JSON 模式执行 direct live smoke,验证 HTTPS / HTTP redirect / ACME / WSS 101 和 Pingora access log request_id 落盘,并要求 `direct-access-log` 结构化结果 `matchedCount == checked``missingCount=0``mismatchCount=0`;如果 direct live 退出 0 但缺少该结构化证据,也必须视为启用失败。直连启用后同步调整 `/etc/genarrative/health-patrol.env`:设置 `GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE=pingora-direct`,本机打 `127.0.0.1` 时设置 `GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST=<域名>`,否则巡检会继续按 Nginx 模式误报。验证失败时执行 `/opt/genarrative/current/scripts/deploy/pingora-direct-rollback.sh --apply --reload-nginx --nginx-smoke-url https://<域名>/ --nginx-smoke-expect-body '<!doctype html>'``npm run deploy:pingora-direct-rollback -- --apply --reload-nginx --nginx-smoke-url https://<域名>/ --nginx-smoke-expect-body '<!doctype html>'`;回退脚本先跑 `nginx -t`,通过后才移除 drop-in、reload systemd、重启 Pingora,并用 `systemctl cat` 核验 capability 已移除、用 `systemctl show ... ExecStart` 核验最终 service 仍指向随包主 service 模板里的 current release `pingora-gateway`,随后 reload Nginx、确认 Nginx service 仍为 active,并用 curl smoke URL 证明公网入口已回到 Nginx;回退 smoke URL/body 必须来自切换前真实 Nginx 入口,不要继续用固定 `http://127.0.0.1/healthz``"ok":true`;回退脚本 `--apply` 不允许省略 `--reload-nginx``--nginx-smoke-url`,当 smoke URL 指向本机地址时必须同时提供 `--nginx-smoke-host <域名>`,且 host 值不能包含 URL、路径或查询;回退后把 health patrol gateway mode 改回 `nginx`,恢复切换前 public base URL / Host,并用 `node -- /opt/genarrative/current/scripts/check-production-health-patrol-env.mjs --env-file /etc/genarrative/health-patrol.env --expected-gateway-mode nginx --expected-public-base-url <切换前Nginx巡检入口> --require-empty-public-host` 复核;若切换前 Nginx 巡检需要 Host 覆盖,则把 `--require-empty-public-host` 换成 `--expected-public-host <切换前Host>`。若 env 已在回退命令前切回 Nginx,也可给 rollback 脚本追加 `--health-patrol-env-file /etc/genarrative/health-patrol.env --health-patrol-expected-public-base-url <切换前Nginx巡检入口> --health-patrol-require-empty-public-host` 让它在 Nginx smoke 后自动复核;切换前 Nginx 巡检需要 Host 覆盖时把最后一项换成 `--health-patrol-expected-public-host <切换前Host>`。若要同时证明 Pingora shadow 高端口仍活着,追加 `--pingora-shadow-probe-url http://127.0.0.1:18081/__genarrative_pingora/healthz --pingora-shadow-probe-token <token>`,脚本会隐藏 token 并要求响应为 `gateway=pingora-shadow`
- 处理补充:不要直接 chmod `/etc/letsencrypt/live``archive` 来让 Pingora 读取证书;Certbot live 路径通常是 symlink,即使 `stat -L` 看起来是普通文件,父目录权限也会让非 root `genarrative` 用户不可达。先用随包 `node -- /opt/genarrative/current/scripts/deploy/pingora-tls-cert-sync.mjs --apply --source-cert-file /etc/letsencrypt/live/<域名>/fullchain.pem --source-key-file /etc/letsencrypt/live/<域名>/privkey.pem --target-dir /etc/genarrative/pingora-tls/<域名>` 把证书同步到 Pingora 私有目录,再让 `GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE` / `TLS_KEY_FILE` 指向 `/etc/genarrative/pingora-tls/<域名>/fullchain.pem``privkey.pem`。脚本默认 dry-run`--apply` 才写入,目标目录默认 `root:genarrative 0750`,文件默认 `root:genarrative 0640`,并拒绝符号链接目标目录或目标文件。
- 处理补充:不要在切换窗口手工编辑 `/etc/genarrative/health-patrol.env` 的三项网关变量;使用 `node -- /opt/genarrative/current/scripts/deploy/pingora-health-patrol-env-switch.mjs --apply --env-file /etc/genarrative/health-patrol.env --gateway-mode pingora-direct --public-base-url <直连HTTPS入口> --public-host <域名>` 切到直连,回退前用同一脚本传 `--gateway-mode nginx --public-base-url <切换前Nginx巡检入口>` 并按切换前记录选择 `--clear-public-host``--public-host <切换前Host>`。脚本只改 gateway mode / public base URL / public Host,并立即复用随包 env 复核脚本,减少空 Host 和旧值残留;生产巡检、env 复核和 env 切换脚本读取的布尔 env 都必须是明确布尔值,非法值直接失败,不能把拼写错误当成 false;env 复核脚本的 `--env-file` 与 env 切换脚本的 `--env-file` / `--check-script` 必须是绝对路径且不能是文件系统根目录,也不能包含换行或 NUL;env 切换脚本写入的 public base URL / Host 同样不能包含换行或 NUL。Node 22 已内置 `--env-file` 启动参数,直接用 `node script.mjs --env-file ...` 或 shebang 执行 `.mjs --env-file ...` 都可能让 Node 抢走业务参数;所有这类命令都必须写成 `node -- script.mjs --env-file ...`,或通过已内置 `node --` 的 npm script 执行。
- 踩坑补充:health patrol env 切换脚本必须先复核权限固定为 `0600` 的临时目标 env 再写真实文件,真实 env 原子替换时保持原文件权限和 owner/group;如果随包 env 复核脚本失败,`--apply` 应失败且真实 env 保持原样,避免“切换脚本失败但巡检配置已半改”的状态。`--apply``--env-file` 必须直接指向真实普通文件,不能传符号链接;如果 `/etc/genarrative/health-patrol.env` 是链接,先确认真实目标路径后再传给脚本,避免替换链接本身或写入非预期目标。
- 踩坑补充:直连启用脚本的 `--preflight-script``--direct-live-script``--current-release-audit-script``--template-path``--service-unit-path``--dropin-path` 和 env 文件参数都必须使用绝对路径;不要在切换窗口传相对脚本路径,否则会把 current release、Jenkins 工作区或现场 cwd 混在一起。`--apply` 会在安装 direct-entry drop-in 前确认 current release 自审、direct preflight 和 direct live smoke 脚本存在,缺脚本时应先修发布包或复制链路,不要手工改成工作区相对路径绕过。启用脚本还会在任何自审、preflight、drop-in 写入或 systemctl 前拒绝 service、路径、URL、Host、probe token、access log、数据库名、tail 行数和 timeout 参数中的换行或 NUL 字符;遇到这类失败先修 runbook 参数来源或现场 env,不要手工绕过脚本。启用脚本还会拒绝符号链接形式的 drop-in 目录或 drop-in 目标文件,以及已存在但不是普通文件的目标;如果现场 systemd 目录被软链改写,应先修正真实路径,不要让脚本把低端口 capability 写入非预期位置。回退脚本 `--apply` 同样会在 `nginx -t` 和删除 drop-in 前拒绝 service、路径、Nginx smoke URL / Host / 响应片段、health patrol 复核参数、shadow probe URL / token 和二进制 override 中的换行或 NUL 字符,并拒绝符号链接 drop-in 目录 / 目标以及非普通 drop-in 目标;如果现场路径或参数异常,应先修正 systemd 路径、runbook 参数或现场 env,不要手工删 drop-in、绕过 `nginx -t` 或把删除 symlink 当成已回退真实低端口能力。回退脚本覆盖 `--nginx-binary``--curl-binary` 时也不要传 `./nginx``tools/curl` 这类相对路径;裸命令名可以走 `PATH`,路径形式必须使用绝对路径。`--nginx-smoke-url` 必须带 `http://``https://`,不要只写 host/path,否则脚本会在移除 drop-in 前失败。
- 踩坑补充:回退到 Nginx 后不要只把 `curl --fail` / HTTP 200 当作 Nginx 已接回的证据;正式 runbook 必须给 rollback dry-run / apply 显式传切换前真实 Nginx smoke URL 和响应体片段,例如 `--nginx-smoke-url https://<域名>/ --nginx-smoke-expect-body '<!doctype html>'`。不要继续用固定 `/healthz``"ok":true`,否则要么误卡回退,要么只验证到了错误入口。
- 踩坑修正:上述 `/healthz``"ok":true` 只能算旧示例,不再是正式 runbook 默认。dev 真实直连 `80/443` 测试确认回退 smoke 必须从切换前真实 Nginx 入口取样,例如 `https://dev.genarrative.world/``<!doctype html>`;固定 `http://127.0.0.1/healthz` 可能返回 301/404 或命中错误 vhost。回退前还必须把 `/etc/genarrative/pingora-gateway.env` 从 direct 低端口配置恢复为 shadow 高端口配置,否则回退脚本移除 capability 后重启 Pingora 可能继续按 `80/443` 配置失败;恢复 shadow 时不能只清 `GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN` / `HTTP_REDIRECT_LISTEN`,也必须清空 `GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE` / `TLS_KEY_FILE`,避免留下证书路径但无 TLS listener 的半直连 env。
- 踩坑补充:直连彩排状态脚本不是修复动作。`node -- /opt/genarrative/current/scripts/ops/pingora-direct-rehearsal-status.mjs --release-root /opt/genarrative/current --expect-public-gateway nginx --require-pingora-shadow --require-realpath-canary --require-current-release-gateway --fail-on-critical` 只读检查公网端口归属、health patrol 模式、Pingora shadow、realpath canary、systemd 和 current release 自审;如果它报 `CRITICAL`,应先修发布包、端口归属、canary 配置、health patrol env 或 systemd 指向,不要把它当成会自动启用 canary、停止 Nginx 或修复 current release 的脚本。
- 踩坑补充:Pingora current release 自审和切换证据链都不是修复动作,`npm run check:pingora-current-release-audit` / `scripts/ops/pingora-current-release-audit.mjs` 只负责只读确认发布包自包含、`api-server.sha256` / `pingora-gateway.sha256` 匹配、release manifest 登记了当前要接流的 Pingora 产物、`pingora-gateway` 可执行和 systemd `ExecStart` 指向;`npm run check:pingora-cutover-status-snapshot` / `scripts/ops/pingora-cutover-status-snapshot.mjs` 只负责输出 `pre-cutover``post-enable``post-rollback` 三阶段只读 JSON evidence,并在直连 runbook 中通过 `--require-pingora-gateway` 把上述自审结果收录到 `checks.current-release-audit.details`;快照还必须确认 `systemctl cat genarrative-pingora-gateway.service``EnvironmentFile=` 精确包含本次 `--pingora-env-file`,否则 `systemd.pingoraUnit.environmentFileMatchesPingoraEnvFile=false` 且标记 `CRITICAL`,避免证据包读到一份 env、真实服务读另一份 env。正式切换窗口用 `scripts/ops/pingora-cutover-evidence-bundle.mjs` 把快照 JSON、stdout、stderr、命令记录和 manifest 写入 `--output-root` 下的新证据目录,证据包 manifest 必须记录已生成 snapshot、direct live、stdout / stderr、命令记录和 parse-error 文件的 `path``sizeBytes``sha256`,便于归档后复核;证据目录生成、复制或归档后必须用随包 `scripts/ops/pingora-cutover-evidence-verify.mjs --bundle-dir <bundleDir>` 做只读验真,确认 `manifest.files` 登记的文件未缺失、大小未漂移、sha256 未漂移,且证据目录不是符号链接或非目录;三阶段证据分别验真后,还必须用随包 `scripts/ops/pingora-cutover-evidence-audit.mjs --evidence-root <证据根目录> --require-phase pre-cutover --require-phase post-enable --require-phase post-rollback` 做只读总审计,自动选择每个阶段最新 bundle 并复用 verifier,缺阶段、最新证据损坏、坏 manifest 或符号链接条目都应失败。direct enable apply / rollback apply 必须通过 `scripts/ops/pingora-cutover-command-evidence.mjs` 包装真实脚本,单独保存命令 stdout、stderr、退出码、脱敏命令记录和 manifest,runbook 必须显式传绝对路径 `--output-root`,且该路径不能是文件系统根目录、符号链接或包含换行 / NUL 字符;命令记录必须同时保留脱敏后的可读命令和结构化 `executable` / `args[]`,命令证据 manifest 也必须记录 `command.stdout.txt``command.stderr.txt``command-record.json``sizeBytes``sha256`;命令证据生成后也要立即把 stdout 中的 `bundleDir` 填入随包 verifier 的 `<enable-apply-bundle-dir>``<rollback-apply-bundle-dir>` 占位符做只读验真,不能只等最终根目录总审计才发现 command-record 或 stdout/stderr 归档漂移。启用后证据包必须额外运行随包 direct live smoke,并写入 `direct-live.json``direct-live.stdout.txt``direct-live.stderr.txt``direct-live-command.json` 和 manifest summary 的 `directLiveStatus`,让 Pingora access log `request_id` 反查结果可复盘;`direct-live.json``direct-access-log` 结果必须保留扫描行数、匹配数量、缺失明细以及 method/path/status 漂移明细,不要只保留 count 或依赖 stderr。证据包 manifest summary 还必须包含 `directLiveAccessLog` 摘要;如果 direct live JSON 缺少 `direct-access-log` 结构化结果,整包应记为 `CRITICAL`。若 snapshot 或 direct live stdout 解析失败,必须保留 `snapshot-parse-error.txt``direct-live-parse-error.txt` 并在 manifest / 最终 stdout 中给出路径;不要只截图或复制 `pingora-direct-enable.sh` / release readiness 的终端输出当作直连证据。证据阶段名只能使用 ASCII 字母、数字、点、下划线和短横线,非法 `--phase` 会直接失败,不会被清洗后继续落盘;自审、状态快照和证据包的 `--release-root` 都不能是文件系统根目录,状态快照的 `--health-patrol-env-file` / `--pingora-env-file` 以及证据包所有显式路径参数也不能是文件系统根目录,状态快照自身还必须拒绝带换行或 NUL 的 release/env 路径,并在执行 systemctl、current release 自审、health patrol env 复核或生产巡检子命令前复核子命令参数,证据包执行状态快照或 direct live 子命令前也必须拒绝任何带换行或 NUL 字符的子命令参数,避免污染后的结构化 `args[]` 先进入正式证据再等总审计兜底,`--output-root` 及其已存在上级路径不能是符号链接,已存在的 `--output-root` 必须是真实目录,路径异常时会在执行状态快照前失败,避免把证据写入非预期软链目标;current release 自审、状态快照和证据包的显式 `--timeout-ms` 及对应 env 必须是正整数,生产健康巡检的 `--timeout-ms``--slow-ms``GENARRATIVE_HEALTH_PATROL_TIMEOUT_MS``GENARRATIVE_HEALTH_PATROL_SLOW_MS`canary / direct live smoke 的 `--timeout-ms` 及对应 envcanary access log 对账的 `--since-lines` 及对应 env 也必须是正整数,直连 live / release readiness 的布尔 env 也必须是明确布尔值,非法值都会失败,不再静默回退默认值或 false。自审、快照、证据包、证据验真和证据总审计脚本都不修改 `/etc`、systemd、Nginx 或 Pingora;命令证据脚本只执行 `--` 后面的真实命令并归档输出,不自行理解 systemd / Nginx;证据包和命令证据目录必须是 `0750`,证据文件必须是 `0640`,且不能覆盖既有文件;probe token 和其他 env 敏感值只能记录是否存在或显示 `<redacted>`,不能把 env 原文写入终端执行日志、gateway smoke / direct live / direct rollback shadow probe 命令日志、stdout、snapshot、manifest、命令记录或子检查 stdout / stderr;如果自审、快照、direct live 或总审计证据里出现 `CRITICAL`,应先修发布包、Jenkins 归档过滤、deploy 复制、env、systemd capability、直连入口、巡检状态或证据归档,再继续下一阶段,不要把自审、快照或证据包当成可自动修复的烟测。
- 踩坑补充:直连 Pingora 后不要让静态缓存头继续依赖框架默认值。HTML、目录 index 和 SPA fallback 必须保持 `Cache-Control: no-cache`,否则旧入口页可能长期引用已经切换的 chunk;带 Vite 指纹的 `/assets/*``/admin/assets/*` 才能使用 `public, max-age=31536000, immutable`;普通非指纹静态和 ACME challenge 继续保守 `no-cache`。如果需要临时覆盖 `GENARRATIVE_PINGORA_GATEWAY_*_CACHE_CONTROL`,值不能包含换行或 NUL,修改后必须跑 `npm run check:pingora-gateway-smoke` 确认 HTML、普通静态和指纹资源三类响应头没有漂移。
- 踩坑补充:直连 Pingora 后也不能只验证整文件静态读取。浏览器、媒体探测和线上签名 URL 排障都可能使用 `Range: bytes=`;Pingora 静态文件必须支持单段 range 的 `206 + Content-Range` 和越界 range 的 `416 + Content-Range: bytes */<len>`,同时给静态响应写入 `Accept-Ranges: bytes``If-Range` 不能被忽略:日期匹配才继续给局部内容,旧日期或弱 ETag 校验器应回完整 `200`,避免客户端拿旧校验器拼接错误文件片段。`206``304``416` 不应被 gzip 压缩,否则 `Content-Range` 指向的字节区间会和实际响应体不一致。多段 range 暂按完整文件处理,不要在切换窗口临时拼 multipart 响应。
- 踩坑补充:直连 Pingora 后不要让静态路由接受非读取方法。`POST /assets/app.js``POST /some/deep/link` 这类请求不应返回静态内容;命中静态候选时返回 `405 + Allow: GET, HEAD`,缺失文件仍返回 `404`。修改静态路由后跑 `npm run check:pingora-gateway-smoke`,确认 405 没有被压缩或误写成 JSON 代理错误。
- 踩坑补充:静态响应不是代理路径,也必须有 access log 证据。修改静态协商缓存、方法限制或 Range 行为后,smoke 要用固定 `X-Request-Id` 反查 Pingora access log 中同一行的 `path``status``proxy_target=Local`,至少覆盖 `304``405``206``416`;否则直连切换证据包可能只能证明 API / WSS 代理路径,排查浏览器缓存或媒体 Range 问题时缺少本地响应状态证据。
- 踩坑补充:直连 live smoke 不能只证明根 HTML 返回 `200`。正式发布包的首页通常会引用 `/assets/``/admin/assets/` 构建产物,direct live 应自动发现静态资源,验证静态缓存 / 校验头、`HEAD` 头响应、`If-None-Match` / `If-Modified-Since` 304 和 `Range: bytes=0-0`,并纳入 access log method/path/status 对账;如果首页存在 Vite 指纹资源,还必须额外证明 `Cache-Control: public, max-age=31536000, immutable` 以及指纹资源 GET / HEAD / 304 / Range 的 access log method/path/status 证据,避免只验证普通 `/assets/app.js` 却漏掉旧 tab chunk 长缓存口径。如果该项显示 skipped,要确认是维护模式、非 HTML,还是发布包首页确实没有资产引用,不要把 skipped 当作已经验证前端静态资源可读。
- 踩坑补充:不要把 `direct-live.json` 当成只有状态码的摘要。静态 GET / HEAD / 304 / Range 检查必须保留白名单 `headers`,至少能复盘 `cache-control``etag``last-modified``accept-ranges``content-range``content-length``content-encoding`;证据包自测要确认普通静态和 Vite 指纹资源的 `Cache-Control``Content-Range` 都被归档。API / WSS 检查不要落原始响应头,避免把认证、Cookie 或上游细节带进切换证据。
- 踩坑补充:切流证据包不能只把静态头部藏在 `direct-live.json``manifest.summary.directLiveStaticHeaders` 必须提升普通静态和 Vite 指纹静态的缓存头、校验头、Range `Content-Range` 和 304 状态摘要,方便切换窗口先扫 manifest 判断证据是否完整;如果 direct live 已输出静态资产结果但摘要缺少缓存头、校验头、Range `206 + Content-Range`、ETag 304 或 Last-Modified 304 证据,证据包会直接记为 `CRITICAL`。遇到摘要缺失或 diagnostics 非空时,应重新生成启用后证据包或修复 direct live / 静态响应头,不要手工改 manifest。
- 踩坑补充:最终证据根目录总审计必须带 `--require-phase-direct-live-access-log post-enable --require-phase-direct-live-static-headers post-enable --require-phase-pingora-env-shadow post-rollback`,正式 runbook 已默认生成这些参数。`post-enable` 证据包还必须传 `--expected-pingora-env-mode direct``post-rollback` 证据包必须传 `--expected-pingora-env-mode shadow`,否则 health patrol 模式正确也不能证明 active Pingora env 姿态正确。若旧 `post-enable` bundle 虽然 `manifest.summary.status=OK` 但没有 `directLiveAccessLog``directLiveStaticHeaders`,或旧 `post-rollback` bundle 没有 `manifest.summary.pingoraEnvShadow`、没有 `mode=shadow` / `shadowReady=true`、仍残留 `tlsCertFile` / `tlsKeyFile`,总审计也应失败;处理方式是用新版 current release 重新生成对应阶段证据包,不要把旧包混进正式归档。
- 踩坑补充:最终证据根目录总审计失败时先看 JSON 顶层 `summary`,不要直接在长 `phases[]` / `commands[]` 里翻。`summary.failedItems[]` 会聚合失败阶段、命令、根目录或时间线诊断,`summary.directLiveEvidence[]` 会直接给出 `post-enable``accessLog.ok/reason``staticHeaders.ok/reason`;reason 指向缺摘要或字段不完整时,应重新生成启用后证据包,而不是手工补 manifest。
- 踩坑补充:最终证据根目录总审计命令示例也必须包含五条 `--require-command-executable`,分别绑定 current release 随包 `pingora-direct-enable.sh``pingora-health-patrol-env-switch.mjs``pingora-gateway-env-shadow-switch.mjs``pingora-health-patrol-env-switch.mjs``pingora-direct-rollback.sh`。不要只写 `--require-command``--require-command-arg --apply`,否则只能证明有命令证据和参数,不能证明真实执行的是本次 current release 脚本。发布包级 `npm run check:production-api-release` 会同时检查生成 README 和随包 readiness dry-run cutover 输出,若这里失败,先修发布包构建脚本或随包 readiness 脚本,不要只改源码文档。
- 踩坑补充:直连 Pingora 后也不要只看前端页面和 access log 成功。API 上游必须继续收到 Nginx 口径代理头:`Host``X-Forwarded-Host`、配置化 `X-Forwarded-Proto`、TCP 对端 IP 的 `X-Real-IP`,以及追加 TCP 对端 IP 的 `X-Forwarded-For``TRUST_X_FORWARDED_FOR` 只用于接流保护 client key;如果误以为它会改变上游 `X-Real-IP` 或覆盖上游 `X-Forwarded-For`,容易造成回调 URL、鉴权来源或日志归因排查漂移。修改代理头逻辑后先跑 `npm run check:pingora-gateway-smoke`,让 mock 上游回显这些头。
- 踩坑补充:current release 自审开启 `--systemd-show` 时,带换行或 NUL 的 `--release-root` / `--systemd-service` 必须在执行 `systemctl show` 前失败,不能把污染参数写进子命令或后续证据链。遇到这类失败先修 runbook 参数来源,不要用 `--warn-only` 继续采集。
- 踩坑补充:canary live 的 `--base-url``--prefix``--host``--path``--timeout-ms` 和对应 env 如果包含换行或 NUL,必须在发起 canary 请求前失败,不能让污染参数进入 URL、Host header 或 JSON 输出。遇到这类失败先修目标机 env、runbook 参数来源或手工命令,不要用只看 `X-Genarrative-Nginx-Handoff` 的 curl 替代完整 `npm run check:pingora-canary-live`
- 踩坑补充:真实路径 canary 不能为了“更像生产”而 include 到生产 `443` server 里写 `/api``/v1``/assets` location;那会覆盖当前 Nginx 正式路由。只能把 `genarrative-pingora-realpath-canary.conf` 作为独立 loopback `server` include 到 `http` 上下文,使用 `127.0.0.1:18083` 和独立 `genarrative-pingora-realpath-canary.access.log` 验证,再用 release readiness 的 `--require-realpath-live` 纳入门禁。
- 踩坑补充:direct preflight 的 `--env-file``--systemd-service`、服务用户和 env 中的 listen / cert / key 值如果包含换行或 NUL,必须在执行 `systemctl cat``sudo -u ... test -r ...`、证书可读检查或端口监听检查前失败。遇到这类失败先修目标机 env 或 runbook 参数来源,不要临时改成手工 systemctl / sudo 命令绕过。
- 踩坑补充:direct live 的 `--https-base-url``--http-base-url``--host``--redirect-host``--probe-token``--path``--spacetime-database``--pingora-access-log``--timeout-ms` 和对应 env 如果包含换行或 NUL,必须在发起 HTTPS / HTTP / WSS 请求前失败,不能让污染参数进入请求头、URL、access log 对账或 direct live JSON。遇到这类失败先修 runbook 参数来源或目标机 env,不要临时删掉 direct live smoke、改用 curl 截图或只看 `systemctl is-active`
- 踩坑补充:证据包 `--run-direct-live` 透传的 direct URL、Host、probe token、数据库名、Pingora access log 路径和 access log tail 行数也要在证据包配置层先拒绝换行或 NUL,`--direct-pingora-access-log` 还必须是绝对路径且不能是 `/`。遇到这类失败先修 runbook 参数或现场 env,不要把参数污染留给 direct live 子命令兜底,也不要手工改 `direct-live-command.json` 或跳过启用后证据包。
- 踩坑补充:不要在证据目录或证据根目录里手工塞 `README`、截图、压缩包、临时目录、无 manifest 子目录或软链来“辅助说明”。证据 verifier 把 `manifest.files` 视为闭集,未登记普通文件、目录和符号链接都会默认失败;证据总审计也默认要求根目录只包含带 `manifest.json` 的证据目录。证据 verifier / 总审计的入口路径、verifier 脚本路径和 manifest 登记文件名都不能包含换行或 NUL 字符,避免污染 JSON 证据、终端输出或归档复盘。需要保留人工说明时,应放到证据根目录外部,或重新生成能把该文件纳入 manifest 元数据的正式证据,而不是在正式切换归档上使用 `--allow-extra-files``--allow-extra-root-entries`
- 踩坑补充:不要把即时证据验真理解成只验 hash。正式 runbook 中 `pre-cutover``enable-apply``post-enable``rollback-apply``post-rollback` 五个即时 verifier 步骤都必须带 `--require-summary-ok`,同时要求 `manifest.schemaVersion=1``manifest.files` 未漂移且 `manifest.summary.status=OK`;如果证据包已经记录 `CRITICAL`、缺少 schemaVersion 或缺少 summary,应先修复现场状态、发布包、env、systemd、health patrol 或 direct live 证据并重新归档,不能继续推进到最终总审计。
- 踩坑补充:最终证据根目录总审计复用 verifier 时也必须启用 `--require-summary-ok`。如果总审计输出里 `verify.requireSummaryOk` 不是 `true`,说明脚本或随包 verifier 已经退化成宽松模式,应先修发布包脚本而不是继续切换。
- 踩坑补充:不要用带 `manifest.commandName` 的命令证据目录满足 `--require-phase`。阶段证据必须来自状态快照证据包,命令证据必须通过 `--require-command` 单独要求;否则总审计可能把“真实执行过命令”和“某阶段状态已归档”混成一件事。
- 踩坑补充:证据根目录总审计不能只要求三阶段状态快照,也不能只做 sha256 验真。正式 runbook 必须在 `--require-phase pre-cutover --require-phase post-enable --require-phase post-rollback` 之外,再传 `--require-command enable-apply:pingora-direct-enable-apply --require-command post-enable:pingora-health-patrol-direct-env-switch --require-command rollback-prep:pingora-gateway-shadow-env-switch --require-command rollback-prep:pingora-health-patrol-nginx-env-switch --require-command rollback-apply:pingora-direct-rollback-apply`,用 `manifest.phase + manifest.commandName` 锁定五条真实切换命令证据;缺命令证据、最新命令证据损坏、阶段或命令 `manifest.summary.status``OK`、命令 `manifest.summary.exitCode``0`、命令名不安全,或顶层 `manifest.commandName` / 内嵌 `manifest.command.name` 任一为空、非法、互不一致时都必须失败。
- 踩坑补充:`commandName` 只能说明证据分类,不能证明真的跑了 enable / rollback 脚本。正式 runbook 的命令证据必须传 `--expected-executable` 绑定 current release 随包脚本绝对路径,并传 `--require-arg --apply` 在执行前确认真实命令参数包含 `--apply`;如果真实命令与预期脚本不一致或缺少 `--apply`,命令证据脚本应在创建正式命令证据前失败,避免把错误命令归档成正式切换证据。最终证据根目录总审计还必须传五条 `--require-command-executable` 和七条 `--require-command-arg`,覆盖 enable apply、health patrol direct env switch、Pingora gateway shadow env switch、health patrol nginx env switch 和 rollback apply,其中包含 `enable-apply:pingora-direct-enable-apply:/opt/genarrative/current/scripts/deploy/pingora-direct-enable.sh``post-enable:pingora-health-patrol-direct-env-switch:/opt/genarrative/current/scripts/deploy/pingora-health-patrol-env-switch.mjs``rollback-prep:pingora-gateway-shadow-env-switch:/opt/genarrative/current/scripts/deploy/pingora-gateway-env-shadow-switch.mjs``rollback-prep:pingora-health-patrol-nginx-env-switch:/opt/genarrative/current/scripts/deploy/pingora-health-patrol-env-switch.mjs``rollback-apply:pingora-direct-rollback-apply:/opt/genarrative/current/scripts/deploy/pingora-direct-rollback.sh` 以及对应 `--apply``pingora-direct``nginx` 参数要求,复核 `manifest.expectedExecutable``manifest.command.executable` 与独立 `command-record.json` 的 executable,并要求 `manifest.commandName``manifest.command.name` 只要存在就各自是安全非空命令名、两者同时存在时一致、`manifest.command.args` 与独立 `command-record.json.args` 都包含 `--apply`,且每个 args 字符串都不含换行或 NUL 字符。`--require-command-executable` 的 executable 段必须是安全绝对路径,不能是文件系统根目录,也不能包含换行或 NUL 字符;总审计 JSON 会记录 `requiredCommandExecutables`,便于复盘本次绑定的真实 current release 随包脚本。总审计还会要求 `manifest.command``command-record.json` 关键字段一致;其中两份命令记录的 `schemaVersion` 都必须是 `1``stdoutPath` / `stderrPath` 必须同时与 `manifest.files.stdout.path` / `manifest.files.stderr.path` 对齐,不能把重新计算过 hash 的 command-record 指向另一份输出文件;`args` / `command` 也必须一致,不能只保证脚本路径正确却把 `--apply` 证据改成 `--dry-run` 或其它参数;命令记录时间必须满足 `finishedAt >= startedAt``durationMs == finishedAt - startedAt`,且 `manifest.generatedAt` 不能早于命令 `finishedAt`;旧证据缺少 schemaVersion、缺少 expectedExecutable、缺少必需 `--apply` 参数、人工同名证据 executable 漂移、manifest 与 command-record 语义漂移、命令 stdout / stderr 引用漂移、命令参数漂移、命令参数控制字符污染、命令时间线漂移或同一命令重复绑定不同脚本路径都必须失败。
- 踩坑补充:命令证据里 `expectedExecutable` 字段不能用空字符串或相对路径表示“未知”。`manifest.expectedExecutable``manifest.command.expectedExecutable` 只要存在就必须是安全绝对路径;否则总审计应把 manifest 判坏,避免坏顶层字段被内嵌字段兜底,或坏内嵌字段被 command-record 里的路径掩盖。生成端 `--expected-executable` 也不能填 `/` 或带换行 / NUL 的路径,脚本会在执行真实命令前失败,不能用坏 expected path 先生成证据再交给总审计兜底。
- 踩坑补充:不能只检查 `manifest.command.executable``command-record.json.executable` 两边一致;如果命令证据已经声明 `expectedExecutable`,真实 `executable` 必须同时等于该预期路径,并且必须是绝对路径。否则人工修改两份命令记录为同一个错误脚本或相对路径,也可能伪造成一致证据。
- 踩坑补充:命令证据的 `command` 字符串是给人读的,不是结构化身份事实。`manifest.command.executable``command-record.json.executable` 都必须存在且是绝对路径;缺少结构化 executable 时,即使 `command` 字符串看起来包含正确脚本,也不能作为正式切换证据。
- 踩坑补充:生成命令证据时不要写 `-- node script.mjs ...``-- bash script.sh ...``-- pingora-direct-enable.sh ...``pingora-cutover-command-evidence.mjs` 现在要求 `-- <command>` 本身就是绝对路径,且不能是文件系统根目录;真实命令和每个真实命令参数都不能包含换行或 NUL 字符。正式 runbook 应直接执行 current release 随包脚本的绝对路径,让 manifest 与 command-record 的 `executable` / `args[]` 字段从源头就是可审计事实。
- 踩坑补充:不要把 Pingora 日志、env、drop-in、脚本或 release root 路径填成 `/` 来“先跑通参数”。release readiness 的 `--live-nginx-access-log` / `--live-pingora-access-log` / `--direct-pingora-access-log` / `--direct-health-patrol-env-file` / `--direct-preflight-env-file`canary 对账脚本的 `--nginx-log-file` / `--pingora-log-file`direct live 的 `--pingora-access-log`direct preflight 的 `--env-file`,以及 `pingora-direct-enable.sh` / `pingora-direct-rollback.sh` 的显式路径参数都必须是绝对文件路径且不能是文件系统根目录;如果现场不确定真实日志、env、drop-in 或脚本文件,先查 `systemctl cat`、logrotate、发布包 manifest 或服务 env,而不是用 `/` 占位。
- 踩坑补充:证据根目录总审计选择“每类最新证据”后,还必须证明这些证据来自同一次切换时间线。最新证据选择和标准八段时间线证明只接受 `schemaVersion=1` 且带合法、规范 UTC 毫秒格式 `manifest.generatedAt` 的 manifest,命令记录 `startedAt` / `finishedAt` 也必须是 `new Date().toISOString()` 形式;缺失、非法、省略毫秒、本地时区或其它宽松可解析格式都会直接失败,不能用目录 mtime 兜底;证据目录被复制、归档或恢复后,也必须以 manifest 时间为准。同一阶段或同一命令如果出现多个候选共享最新 `manifest.generatedAt`,总审计会以 `AMBIGUOUS_LATEST` 失败并列出重复目录,不能按目录名排序打平;应重新归档该阶段 / 命令证据,或把旧证据移出正式证据根目录后再审计。标准八段证据都被要求时,每段审计状态都必须是 `OK``manifest.generatedAt` 必须满足 `pre-cutover -> enable-apply -> post-enable:pingora-health-patrol-direct-env-switch -> post-enable -> rollback-prep:pingora-gateway-shadow-env-switch -> rollback-prep:pingora-health-patrol-nginx-env-switch -> rollback-apply -> post-rollback`,且默认八段跨度不能超过 24 小时;非 OK、倒序或跨度过大都代表可能混入不同切换窗口遗留证据或现场状态未达标,必须失败后重新归档或清理证据根目录。任何证据 manifest 只要显式写入 `cutoverRunId` 字段,就必须是安全非空 ID,不能用空字符串伪装成缺省字段。确需跨更长维护窗口时,只能在生成 runbook 时显式传 `--cutover-evidence-timeline-max-span-ms <ms>`,让最终总审计 JSON 记录本次放宽后的 `timeline.maxSpanMs` 与实际 `timeline.spanMs`
- 踩坑补充:标准八段时间线失败时不要只看顶层 `ok=false``diagnostics` 文本。`timeline.failedCount` 会按具体失败项累计,`timeline.failureBreakdown` 会把非 OK 证据、缺少时间、cutoverRunId 混入、时间倒序和跨度超限拆开计数;同一次审计可能同时暴露多个证据问题,应逐项修复后重新归档。
- 踩坑补充:同一天多次演练或切换时,只靠“最新证据”和 24 小时窗口仍可能把两轮证据拼在一起。正式 runbook 会生成或接受 `--cutover-run-id <id>`,并把同一 `manifest.cutoverRunId` 写入三阶段证据包、五条真实切换命令证据和最终总审计;最终审计必须带 `--require-cutover-run-id <本次cutoverRunId>`,缺少该字段或 ID 不一致时必须失败。即使人工临时总审计忘记带 `--require-cutover-run-id`,标准八段时间线里只要任一证据声明了 `manifest.cutoverRunId`,八段也必须全部声明同一个值,否则总审计失败。
- 验证:先运行 `npm run check:pingora-direct-preflight -- --env-file /etc/genarrative/pingora-gateway.env --require-live-env --systemd-cat --check-cert-readable --check-service-env-file --check-service-user-cert-readable --check-service-binary-executable --check-ports-free`,确认 env、drop-in、service EnvironmentFile 一致性、当前用户证书权限、服务用户证书权限、service 二进制可执行性和 80/443 已释放;`systemctl cat genarrative-pingora-gateway.service` 必须显示 `AmbientCapabilities=CAP_NET_BIND_SERVICE``CapabilityBoundingSet=CAP_NET_BIND_SERVICE``EnvironmentFile=/etc/genarrative/pingora-gateway.env`;启用脚本 apply 必须先通过 current release 自审,失败时不安装 direct-entry drop-in;还必须带 direct HTTPS / HTTP / Host / redirect host / SpacetimeDB database / Pingora access log 参数,并在重启后直接完成 direct live smoke 和 direct-access-log JSON 证据校验;也可用 release readiness `--require-direct --direct-https-base-url https://127.0.0.1 --direct-http-base-url http://127.0.0.1 --direct-host <域名> --direct-redirect-host <域名或host:port> --direct-spacetime-database <库名> --direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log --direct-health-patrol-env-file /etc/genarrative/health-patrol.env --direct-preflight-env-file /etc/genarrative/pingora-gateway.env --direct-preflight-systemd --direct-preflight-check-cert-readable --direct-preflight-check-service-env-file --direct-preflight-check-service-user-cert-readable --direct-preflight-check-service-binary-executable` 把 HTTPS、HTTP redirect / ACME、正式域名 Host/SNI、redirect Location host、Pingora access log request_id 落盘、env 预检、systemd drop-in、service EnvironmentFile 一致性、当前用户和服务用户证书可读、service 二进制可执行、显式目标库和 WSS 101 一起纳入硬门禁,并拒绝 `--direct-skip-wss`,避免 TLS 证书只按 `127.0.0.1` 误测、HTTP redirect Location 指错域名、service 实际读取另一份 env、root / deploy 用户可读但 systemd 服务用户不可读、current release 缺少可执行 `pingora-gateway`,或 WSS subscribe 隐式打到默认 SpacetimeDB 库。`check-pingora-release-readiness.mjs --help` 的正式直连和只生成 runbook 示例也必须带 `--direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log`,不要让值班人员复制示例后才被 `--require-direct` 拦截。current release 自审、状态快照和证据包的布尔 env 必须是明确布尔值,非法值会失败,不得把拼错的 run / require / fail 开关当成 false。`npm run plan:pingora-direct-cutover -- --require-direct ...` 输出必须包含 Host 与回退巡检入口确认、current release preflight、启用前不带 `--require-direct` 的基础 readiness、direct enable dry-run/apply、启用后带 `--require-direct` 的复核、rollback dry-run/apply、回退后 health patrol 切回 Nginx 并恢复切换前 public base URL / Host、回退后 health patrol env 复核;缺少 `--require-direct`、缺少 `--rollback-health-patrol-public-base-url`、缺少 `--direct-pingora-access-log`、redirect Host 漂移或 rollback smoke Host 漂移时必须失败,避免生成缺少正式直连硬门禁或验证不同入口的切换计划。Host 与回退巡检入口确认步骤必须展示回退后要恢复的 health patrol public base URL / Host。直连后 `genarrative-health-patrol.service` 应使用 `GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE=pingora-direct`,状态 JSON 中 `gatewayMode` 应为 `pingora-direct`,并检查 `genarrative-pingora-gateway.service` 而不是 `nginx.service`public probe 走 `127.0.0.1` 时应带 `GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST=<域名>`。回退后 `nginx -t` 必须先通过,`systemctl cat genarrative-pingora-gateway.service` 不应再显示这两条 capability`systemctl show genarrative-pingora-gateway.service --property=ExecStart --value --no-pager` 必须仍指向 current release 的 `pingora-gateway``systemctl is-active nginx.service` 应为 `active``curl --fail --max-time 5` 访问 `--nginx-smoke-url` 应成功;若 smoke URL 为本机地址必须带 `--nginx-smoke-host <域名>`,证明正式 vhost 已回到 Nginx;随后用 `node -- /opt/genarrative/current/scripts/check-production-health-patrol-env.mjs ...` 复核 health patrol env,必须显示 `GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE=nginx` 且 public base URL / Host 与切换前记录一致,shadow probe 可选复核必须返回 `gateway=pingora-shadow`。本机提交前还要运行 `npm run check:pingora-direct-enable``npm run check:pingora-direct-rollback``npm run check:production-health-patrol``npm run check:production-api-release``npm run check:pingora-production-release-build``npm run check:production-api-deploy`,确保脚本默认 dry-run 不会安装或删除 drop-in、current release 自审失败时启用脚本不会安装 drop-in、direct live 退出 0 但缺少 `direct-access-log` 结构化证据时启用失败,API release 布局自包含,真实 Pingora release 二进制能构建并进入发布包,API deploy 从发布产物内执行后 current release 自包含;缺少数据库备份脚本、健康巡检脚本、健康巡检 env 复核脚本、切换命令证据脚本、env 示例目录或 direct live smoke 脚本的发布包都必须在 current 切换前部署失败并退出本次打开的维护模式。正式直连 readiness 必须带 `--direct-health-patrol-env-file /etc/genarrative/health-patrol.env`,并用 `scripts/check-production-health-patrol-env.mjs` 阻断 health patrol 仍停在 Nginx 模式或本机 direct probe 缺少正式 Host;发布包包含 `pingora-gateway` 时,`npm run check:production-api-deploy` 必须覆盖服务 active / inactive 都会在 shadow 配置安全时执行 `systemctl restart genarrative-pingora-gateway.service` 并复核 active,同时覆盖 direct-entry capability 或公网监听 env 下不会提升 release、不会切 current、不会自动 restart。
- 顺序补充:正式 runbook 必须先通过 health patrol env 切换脚本预置回 Nginx 和切换前 public base URL / Host,再执行 `rollback apply`;回退脚本内置 env 复核和独立 env 复核都会阻断 public base URL / Host 漂移。
- 顺序补充:正式 runbook 还必须在 `rollback apply` 前预置 Pingora shadow env。启用前和 `--dry-run-cutover` 要求 `80/443` 空闲;启用后 `--require-direct` 复核不再要求端口空闲,因为端口应由 Pingora 占用。回退时要先用 current release 随包 `node -- /opt/genarrative/current/scripts/deploy/pingora-gateway-env-shadow-switch.mjs --apply --env-file /etc/genarrative/pingora-gateway.env` 恢复 `GENARRATIVE_PINGORA_GATEWAY_LISTEN=127.0.0.1:18081` 并清空 TLS / HTTP redirect 低端口监听,再移除 direct-entry drop-in 和重启 Pingora。
- 关联:`deploy/systemd/genarrative-pingora-gateway-direct-entry.conf``deploy/env/health-patrol.env.example``deploy/env/pingora-direct-live.env.example``deploy/env/pingora-canary-live.env.example``scripts/deploy/pingora-direct-enable.sh``scripts/deploy/pingora-direct-rollback.sh``scripts/deploy/pingora-tls-cert-sync.mjs``scripts/check-pingora-direct-preflight.mjs``scripts/check-pingora-direct-live.mjs``scripts/ops/pingora-cutover-command-evidence.mjs``scripts/ops/pingora-cutover-evidence-verify.mjs``scripts/ops/pingora-cutover-evidence-audit.mjs``scripts/jenkins-server-provision.sh``scripts/build-production-release.sh``scripts/deploy/production-api-deploy.sh``docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md`
## 外部生成 worker 重领必须按 claim attempt 隔离并持久结算
- 现象:同一个外部生成 job 在 worker 崩溃或 lease 过期后重领,可能出现旧 attempt 和新 attempt 都扣费,或者旧 attempt 已退款后新 attempt 因稳定 ledger 被当成幂等而免费执行。
- 原因:只按业务资源 ID 或 job ID 生成稳定 ledger 无法区分 claim;仅在新 attempt 开始时“先查旧 consume、存在则退款”仍有竞态,旧 consume RPC 可能在检查之后才提交。
- 处理:扣退费 ledger 固定包含 `job_id + claim_attempt`,每次重领先结算所有旧 attempt,再扣当前 attempt。结算必须在 SpacetimeDB `asset_operation_wallet_settlement` 持久化:旧 consume 已存在时原子退款;尚不存在时写取消 intent。任何迟到 consume 在同一事务内看到 intent 后失败关闭。重复 ledger 必须核对用户、金额和来源,不能只按 ID 存在就返回成功。claim 处理 lease 已过期的 `running` job 时还必须先比较 `attempt``max_attempts`:未耗尽才递增并返回 worker;最终 attempt 已耗尽时在同一事务内把 job 置为 `failed`、清空 lease、写完成时间和失败事件,并按当前 attempt 退款或写取消 intent,绝不能再次返回 provider executor。
- 验证:`cargo test -p spacetime-module asset_operation` 覆盖缺 consume 时写 intent、冲突结算拒绝和退款配对;`cargo test -p spacetime-module external_generation::tests::` 覆盖未耗尽 lease 可重领、最终 attempt 只终态收口且不再递增;`cargo test -p spacetime-module wallet_idempotent_replay` 覆盖冲突重放;`cargo test -p api-server asset_billing` 覆盖崩溃重领、重复结算和当前 attempt 扣费。
- 关联:`server-rs/crates/api-server/src/asset_billing.rs``server-rs/crates/spacetime-module/src/runtime/profile.rs``docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`
## 外部生成队列不再由 HTTP 进程兜底执行
- 现象:拼图首关生成接口返回 `queued`,但生成页长时间不完成,重启 `genarrative-api.service` 也没有推进任务。
- 原因:HTTP 角色只入队,不再直接调用外部 provider;如果没有运行 `GENARRATIVE_PROCESS_ROLE=external-generation-worker``all` 的进程,`external_generation_job` 会停留在 `pending/running`,直到有 worker claim。
- 处理:生产用 `systemctl enable --now genarrative-external-generation-worker@1.service genarrative-external-generation-controller.service` 启动保底 worker 和 controller`genarrative-api.service` 对 controller 使用 systemd `Wants` 弱依赖,启动 API 时会尝试一并拉起 controller,但不会让 HTTP 进程自己执行 `systemctl`。首次 API deploy 会在默认 worker pattern 下自动启用并启动 `@1`、等待 worker active,并重启验活 controller。扩容默认交给 controller 按队列统计启动 `@2.service` 等实例,手动扩缩容只作为兜底;worker 收到停机信号后会停止 claim 新任务并等待当前任务完成。本地 smoke 可临时用 `GENARRATIVE_PROCESS_ROLE=all npm run dev`;本地若只想同步排查可通过 `.env.local` 或本机环境设置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline`,但这不会创建 job,也不能验证 worker 扩缩容。
- 验证:`systemctl status genarrative-external-generation-controller.service 'genarrative-external-generation-worker@*.service'` 能看到 controller 和 worker 实例;queue 模式下任务被 claim 后 `worker_id``lease_expires_at` 会更新,完成后 session 进入 ready 或 failedinline 模式下不应产生新的 `external_generation_job`
- 关联:`deploy/systemd/genarrative-external-generation-worker@.service``deploy/systemd/genarrative-external-generation-controller.service``deploy/env/external-generation-controller.env.example``server-rs/crates/spacetime-module/src/external_generation.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 外部生成 worker 不应等待 HTTP 认证投影恢复
- 现象:`genarrative-external-generation-worker@1.service` 在 systemd 中显示 active,但 `external_generation_job` 长时间保持 `pending`;worker 日志每 5 秒出现认证投影或公开 read model 订阅失败。
- 原因:独立 worker / controller 是非 HTTP 角色,不承接用户登录态恢复;如果启动路径复用 HTTP `api-server` 的认证投影恢复,SpacetimeDB 认证投影或公开 read model 漂移会把 worker claim 循环挡在启动前。
- 处理:`GENARRATIVE_PROCESS_ROLE=external-generation-worker``external-generation-controller` 启动时只构建空 auth store 的 `AppState`,不调用 SpacetimeDB 认证投影导出;只有 `api` / `all` 这类 HTTP 角色需要在启动时恢复认证投影并在依赖不可用时重试或进入 503 降级。
- 验证:重启 worker 后日志应先出现“非 HTTP 进程跳过 SpacetimeDB 认证投影恢复”,随后出现 `external generation worker 已启动`;同一时间窗口不应再因为认证投影恢复失败而阻止 job claim。HTTP `api-server` 的认证恢复日志和 503 降级语义保持不变。
- 关联:`server-rs/crates/api-server/src/main.rs``server-rs/crates/api-server/src/external_generation_worker.rs``server-rs/crates/api-server/src/external_generation_worker_controller.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 本地旧 external-generation-worker 会抢队列并暴露成 procedure 超时
- 现象:角色 / 画布生成的外部 provider 与 OSS 上传已成功,但 worker 写回 `editor_project_resource` 等业务资源时报 `SpacetimeDB procedure 调用超时`,日志里可能还能看到旧 worker 二进制对 procedure 返回值做 BSATN 反序列化失败。
- 原因:本地 `npm run dev` / `npm run dev:api-server` 默认 `GENARRATIVE_PROCESS_ROLE=all`,会自己消费队列;如果之前手动启动的同仓库、同 database `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程没有退出,旧二进制会继续 claim 新 jobschema / binding 已更新的当前进程反而没有拿到这次任务。
- 处理:Linux 本地默认 `all` 角色启动前,`scripts/dev.mjs` 会扫描同仓库、同 SpacetimeDB server / database、同 `server-rs/target/debug/api-server` 的遗留 `external-generation-worker` 并停止;显式 `GENARRATIVE_PROCESS_ROLE=api` 做生产式拆分验证时不清理独立 worker。
- 验证:`ps -eo pid,ppid,lstart,cmd | rg 'server-rs/target/debug/api-server'` 只应看到当前 `all` 或显式拆分下预期的进程;`/healthz``/readyz` 成功后,生成 job 应由当前进程消费并把业务资源写回。
- 关联:`scripts/dev.mjs``scripts/dev.test.ts``server-rs/crates/api-server/src/external_generation_worker.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 外部生成 worker 业务写回必须同事务校验 lease guard
- 现象:worker `complete/fail` 已校验 `worker_id + lease_token`,但如果玩法 session / work profile 写回在此之前单独调用,过期 worker 仍可能先写入业务状态,随后才在 job complete/fail 阶段失败;带计费包装的旧 worker 还可能因为 stale guard 错误触发补偿退款。
- 原因:队列状态栅栏只保护 `external_generation_job` 自身,不会自动保护玩法 procedure。业务写回必须自己带 claim 后的 `job_id / worker_id / lease_token`,并在同一个 SpacetimeDB transaction 内校验 job 仍为 `running`、lease 未过期、job kind、owner 和 source entity 匹配。
- 处理:拼图首图 worker 的前置 `compile_puzzle_agent_draft``save_puzzle_generated_images``save_puzzle_ui_background``mark_puzzle_draft_generation_failed``mark_puzzle_level_generation_failed` 已接入 `external_generation_job` lease guardapi-server 的资产扣费包装遇到这类 stale worker lease guard 错误时不执行补偿退款,错误文本包含 `external_generation_job 当前不是 running 状态``external_generation_job 不存在` 时也按 stale guard 处理。inline 模式只允许 `job_id / worker_id / lease_token` 三项同时为空,半空 guard 仍拒绝。后续迁移其它玩法 worker 时必须复用该模式,不能只在 worker 进程内保存一份 token。
- 验证:`cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml``cargo test -p api-server asset_operation_billing_does_not_refund_stale_worker_lease_errors --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/spacetime-module/src/external_generation.rs``server-rs/crates/spacetime-module/src/puzzle.rs``server-rs/crates/api-server/src/external_generation_worker.rs``server-rs/crates/api-server/src/asset_billing.rs``docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`
## 外部生成 worker 核心业务写回失败不能完成 job
- 现象:worker 已经生成图片并拿到本地合成 session 快照,但 SpacetimeDB 业务写回因连接、旧 wasm 或 lease guard 失败没有真实落库;如果此时仍把 `external_generation_job` 标成 `completed`,前端只会看到队列完成而 session 长时间不变化,后续也没有 worker 会重领修复。
- 原因:同步 HTTP handler 的“外部 provider 已成功但 SpacetimeDB 短暂不可用时返回内存快照”降级语义,不能直接搬进异步 worker。worker 的完成状态必须代表核心业务事实已经持久化。
- 处理:worker 路径的 `save_puzzle_generated_images` / `save_puzzle_ui_background` 等核心业务写回失败时直接返回错误;只有核心写回已经成功后的非关键投影回写才允许降级记录 warning。业务失败态也必须先写回 session / work profile,写回成功后才允许把队列 job 标为 failed;失败态未写回时保留租约,等待 lease 过期后重领。生产首装和首次 API deploy 都必须至少启用一个 worker 实例,例如 `systemctl enable --now genarrative-external-generation-worker@1.service`
- 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml``cargo test -p api-server asset_operation_billing_does_not_refund_stale_worker_lease_errors --manifest-path server-rs/Cargo.toml`,并在 smoke 时确认 queued 任务被 worker 消费后 session 真实更新。
- 关联:`server-rs/crates/api-server/src/puzzle/draft.rs``server-rs/crates/api-server/src/puzzle/generation.rs``server-rs/crates/api-server/src/external_generation_worker.rs``docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`
## 生产冷备份后 API 和外部生成 worker 不能只依赖 SpacetimeDB 自恢复
- 现象:release 机器 `03:20` 冷备份后,`spacetimedb.service` 已恢复,但作品列表、创作入口配置或公开 gallery 继续超时 / 502 / 504`genarrative-api.service` 保持 stopped;或图片画布生成请求返回队列态后长期显示排队,`external_generation_job` 有 claimable pending,但 `genarrative-external-generation-worker@1.service` / controller 是 inactive;也可能先看到 `/var/lib/genarrative/database-backups` 把根分区写满,`gzip: stdout: No space left on device`
- 原因:`genarrative-api.service``genarrative-external-generation-worker@*.service``genarrative-external-generation-controller.service` 都配置了 `Requires=spacetimedb.service`,冷备份停止 `spacetimedb.service` 时这些服务会被 systemd 依赖关系一并停止;如果备份脚本只在打包成功后重启依赖服务,那么 tar/gzip 因空间不足失败时就只会恢复数据库,外部生成队列和 API 仍无人接管。
- 处理:生产冷备份 unit 和发布脚本必须带 `--restart-service-after genarrative-api.service``--restart-service-after genarrative-external-generation-worker@1.service``--restart-service-after genarrative-external-generation-controller.service`;备份脚本必须在停止 SpacetimeDB 前做工作目录剩余空间预检,并且一旦已经停过 SpacetimeDB,就算打包失败也要先恢复 SpacetimeDB 与这些依赖服务,再返回原始备份错误。`genarrative-api.service` 也保留对 controller 的 `Wants` 弱依赖,覆盖“只恢复 API”的现场兜底。仓库用 `npm run check:production-ops``npm run check:database-backup` 检查 systemd 模板、脚本失败路径、API build/deploy 归档和健康巡检链路。现场修复后执行 `systemctl daemon-reload`,但不要为了验证而手动触发冷备份。
- 验证:`systemctl cat genarrative-database-backup.service` 应包含这些参数;`systemctl is-active spacetimedb.service genarrative-api.service genarrative-external-generation-worker@1.service genarrative-external-generation-controller.service nginx.service` 全为 `active``curl -fsS http://127.0.0.1:3101/v1/ping``/healthz``/readyz` 和代表性 `/api/editor/showcase/resources` 均成功;`npm run check:database-backup` 覆盖空间不足不触碰 systemctl、tar 失败仍恢复依赖服务;`get_external_generation_queue_stats_and_return` 不应长期出现 claimable pending。
- 关联:`deploy/systemd/genarrative-database-backup.service``scripts/database-backup-to-oss.mjs``scripts/ops/production-health-patrol.mjs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## Pingora Brotli 不能只看 Content-Encoding
- 现象:在 `pingora-gateway` 中把 `GENARRATIVE_PINGORA_GATEWAY_COMPRESSION_ALGORITHMS` 试验性改成 `gzip,br` 后,`Accept-Encoding: br, gzip` 的响应会带 `Content-Encoding: br`,但 Node `brotliDecompressSync(...)``unexpected end of file`
- 原因:Pingora 0.8.1 的 Brotli compressor 路径虽然存在,但端到端输出不能被 Node 按完整 Brotli 流解压;只断言响应头会误判为可用。
- 处理:当前 Pingora shadow 只允许 `GENARRATIVE_PINGORA_GATEWAY_COMPRESSION_ALGORITHMS=gzip`,在进入 Pingora compression 模块前把下游 `Accept-Encoding` 收敛为 gzip。Brotli 继续由 Nginx / 前置代理承担,直到补齐可解压的端到端门禁后再评估迁移。
- 验证:`npm run check:pingora-gateway-smoke` 必须覆盖小响应不压缩、图片资源不压缩、大响应 `Accept-Encoding: gzip``Accept-Encoding: br, gzip` 都返回可解压的 gzip`GENARRATIVE_PINGORA_GATEWAY_COMPRESSION_ALGORITHMS=br` 必须启动失败,`GENARRATIVE_PINGORA_GATEWAY_GZIP_MIN_LENGTH_BYTES=0` 也必须启动失败。
- 关联:`server-rs/crates/pingora-gateway/src/main.rs``scripts/check-pingora-gateway-smoke.mjs``docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md``deploy/nginx/README.md`
## Pingora 公网直连不能信任 X-Forwarded-For
- 现象:公网直连 Pingora 后接流保护、access log 或 `client_ip` 似乎按用户传入的 `X-Forwarded-For` 分散,限流 key 可被客户端伪造。
- 原因:`GENARRATIVE_PINGORA_GATEWAY_TRUST_X_FORWARDED_FOR=true` 只适合 Pingora 前方还有受控 Nginx / LB 且该前置层会清洗 `X-Forwarded-For` 的场景;Pingora 自己监听公网 `0.0.0.0:80/443` 时,下游请求头就是用户可控输入,不能拿来作为接流保护 client key。
- 处理:公网直连 env 必须保持 `GENARRATIVE_PINGORA_GATEWAY_TRUST_X_FORWARDED_FOR=false`。只有 loopback / 受控前置入口才允许配合 `GENARRATIVE_PINGORA_GATEWAY_TRUSTED_FRONT_PROXY_CONFIRMED=true` 使用 XFF。目标机 direct preflight 会在公网监听加 `TRUST_X_FORWARDED_FOR=true` 时失败。
- 验证:`npm run check:pingora-direct-enable` 覆盖公网监听误信任 XFF 负例;切换窗口运行 `npm run check:pingora-direct-preflight -- --env-file /etc/genarrative/pingora-gateway.env --require-live-env ...`,看到该错误时先改 env,再重启 Pingora。该 npm script 内部必须保持 `node -- scripts/check-pingora-direct-preflight.mjs`,避免 Node 22 抢占业务 `--env-file`
- 关联:`scripts/check-pingora-direct-preflight.mjs``deploy/pingora/pingora-gateway.env.example``docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md`
## Pingora canary 不能只看 handoff 响应头
- 现象:目标 Nginx 前缀 canary 的 `/__genarrative_pingora_canary/healthz` 和代表性 API 都返回成功,响应也带 `X-Genarrative-Nginx-Handoff: pingora-canary`,但仍无法证明 Nginx 与 Pingora 对同一请求的 method/status/path 完全一致。
- 原因:响应头只能证明请求经过了 canary snippet,不能证明同一 `request_id` 已在 Pingora access log 落盘,也不能发现 healthz exact location 映射、前缀 rewrite 后路径或状态码漂移。
- 处理:本机 / CI 的 `check-pingora-canary-docker` 也必须写临时 Nginx access log,并在 live smoke 后复用 `scripts/check-pingora-canary-access-log-parity.mjs` 对账 Docker Nginx 与 Pingora access log。目标机 `--require-live` 必须在 live smoke 后继续执行同一脚本,默认读取 `/var/log/nginx/genarrative.access.log``/var/log/genarrative/pingora-gateway.access.log`,按 `request_id` 对照 `/__genarrative_pingora_canary/healthz``/__genarrative_pingora_canary/api/creation-entry/config`。Nginx canary exact `/healthz` 映射到 Pingora shadow `/__genarrative_pingora/healthz`,其它 canary 前缀路径按 rewrite 后路径比对。对账脚本的日志路径、prefix、必需路径和 `--since-lines` / `GENARRATIVE_PINGORA_CANARY_ACCESS_LOG_SINCE_LINES` 不能包含换行或 NUL;日志行里解析出的 URI / path 含控制字符时也必须失败,避免污染值进入 JSON 对账输出。
- 验证:本机或 CI 执行 `node scripts/check-pingora-canary-docker.mjs --require-docker --pull` 时应同时完成临时 Nginx / Pingora access log 对账。目标机执行 `node scripts/check-pingora-release-readiness.mjs --require-docker --pull-docker --require-nginx --require-live --live-base-url http://127.0.0.1 --live-host <域名> --live-nginx-access-log /var/log/nginx/genarrative.access.log --live-pingora-access-log /var/log/genarrative/pingora-gateway.access.log`;本机执行 `npm run check:pingora-release-readiness-plan``npm run check:production-ops`,确认 live 门禁计划包含真实 access log 对账。
- 关联:`scripts/check-pingora-release-readiness.mjs``scripts/check-pingora-canary-access-log-parity.mjs``deploy/env/pingora-canary-live.env.example``docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md`
## Pingora realpath canary include 要晚于 log_format
- 现象:目标机把 `genarrative-pingora-realpath-canary.conf` 放进 `/etc/nginx/conf.d/` 后,`nginx -t` 失败并报 `unknown log format "genarrative_upstream"`
- 原因:真实路径 canary 是独立 `server` 片段,并使用 `access_log /var/log/nginx/genarrative-pingora-realpath-canary.access.log genarrative_upstream;`。Nginx 会按文件名顺序加载 `conf.d`;如果 canary 文件名早于定义 `log_format genarrative_upstream` 的主站配置,access log 行会先被解析而找不到格式。
- 处理:真实路径 canary 启停统一用 current release 随包脚本,不再手工写 `/etc/nginx/conf.d/`。启用执行 `/opt/genarrative/current/scripts/deploy/pingora-realpath-canary-enable.sh --apply --probe-token <token> --host <域名> --base-url http://127.0.0.1:18083`,脚本固定写入晚于主站配置加载的 `/etc/nginx/conf.d/zz-genarrative-pingora-realpath-canary.conf`,并在 `nginx -t`、reload 或 live smoke 失败时恢复写入前配置。关闭执行 `/opt/genarrative/current/scripts/deploy/pingora-realpath-canary-disable.sh --apply`,脚本在 `nginx -t` 或 reload 失败时恢复删除前配置。另一种长期做法是把 `log_format` 放到所有 `conf.d` server 之前的全局 Nginx 配置。检查配置时不要把 probe token 原文写入记录。
- 验证:提交前运行 `npm run check:pingora-realpath-canary-toggle` 或默认聚合门禁 `npm run check:pingora-release-readiness`,确认启停脚本的 dry-run、apply、失败回滚和 disable 恢复逻辑仍被覆盖。启用脚本通过后,再运行 `node -- /opt/genarrative/current/scripts/check-pingora-canary-live.mjs --realpath --base-url http://127.0.0.1:18083 --host <域名>``node -- /opt/genarrative/current/scripts/check-pingora-canary-access-log-parity.mjs --realpath --nginx-log-file /var/log/nginx/genarrative-pingora-realpath-canary.access.log --pingora-log-file /var/log/genarrative/pingora-gateway.access.log ...``node -- /opt/genarrative/current/scripts/check-pingora-release-readiness.mjs --release-runtime-only --require-realpath-live ...`。若只启用了真实路径 canary,不要同时传 `--require-live`,否则前缀 canary 未启用时会按正式 Nginx HTTP 入口返回 301。
- 关联:`deploy/nginx/snippets/genarrative-pingora-realpath-canary.conf``deploy/nginx/README.md``docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md``scripts/check-pingora-release-readiness.mjs`
## Pingora release readiness 脚本不能只存在于源码 checkout
- 现象:本机 runbook 能生成,但目标机切换窗口执行启用前或启用后的 release readiness 复核时,可能命中 Jenkins workspace 或源码 checkout 的 `scripts/check-pingora-release-readiness.mjs`,而不是当前发布包里的脚本。
- 原因:API release、Jenkins Build 归档、Jenkins Deploy 复制清单都是显式文件列表;只在仓库中新增脚本或只改 runbook 相对路径,不能保证目标机 current release 自包含。另一个误区是在 `/opt/genarrative/current` 上运行默认源码全量 readiness,导致包内脚本依赖 Cargo、npm、Docker 或 Nginx 构建环境。
- 处理:`scripts/build-production-release.sh``jenkins/Jenkinsfile.production-api-build``jenkins/Jenkinsfile.production-api-deploy``scripts/deploy/production-api-deploy.sh` 必须同时携带 `scripts/check-pingora-release-readiness.mjs``scripts/check-pingora-canary-live.mjs`、canary access log 对账脚本以及 direct preflight / live 子脚本;正式 cutover runbook 的启用前基础门禁和启用后 `--require-direct` 复核必须调用 `/opt/genarrative/current/scripts/check-pingora-release-readiness.mjs --release-runtime-only`。默认不带 `--release-runtime-only` 的全量 readiness 只在源码 checkout / CI / 构建环境运行。
- 验证:运行 `npm run check:production-api-release``npm run check:production-api-deploy``npm run check:pingora-current-release-audit``npm run check:pingora-release-readiness-plan``npm run check:production-ops`,确认发布包、current release、runbook 与 guardrails 都覆盖聚合脚本和 `check-pingora-canary-live.mjs`,且 runbook readiness 参数包含 `--release-runtime-only`
- 关联:`scripts/check-pingora-release-readiness.mjs``scripts/check-pingora-canary-live.mjs``scripts/build-production-release.sh``scripts/deploy/production-api-deploy.sh``jenkins/Jenkinsfile.production-api-build``jenkins/Jenkinsfile.production-api-deploy`
## SpacetimeDB 45 秒超时要看 api-server 记录的阶段
- 现象:release 上 Nginx 能立刻连到 `api-server`,但 `/api/runtime/*/gallery``/api/creation-entry/config` 等请求在约 `GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS` 后返回 `502` / `504`
- 原因:旧日志只能看到 HTTP 总耗时和最终状态,无法区分卡在连接池、SDK 建连、等待 `on_connect`、订阅 read model、等待 procedure / reducer 回调还是本地订阅 cache 读取。
- 处理:`spacetime-client` 内置阶段化健康检查和失败日志;`/readyz``GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS` 短窗口检查 SpacetimeDB 连接租约,业务失败日志包含 `operation_kind``operation_name``spacetime_stage``elapsed_ms`
- 验证:`/readyz` 失败时看 `details.spacetime.stage`;业务请求超时时查 `journalctl -u genarrative-api.service` 中同一时间窗口的 `SpacetimeDB client operation failed`,优先按 `pool_acquire``connect_build``connect_handshake``read_model_subscribe``procedure_result``reducer_result``read_cache` 分阶段处理。
- 关联:`server-rs/crates/spacetime-client/src/lib.rs``server-rs/crates/api-server/src/health.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 新建草稿扣费不能和入口卡泥点配置分离
- 现象:后台修改创作入口的 `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`
## 平台异步错误必须带来源弹窗,不要只显示裸错误
- 现象:用户先后触发多个拼图或草稿生成时,旧请求失败后会在当前页面显示“图片生成失败”等裸错误,容易误判为当前正在看的拼图失败;错误文本也不便复制给开发排查。
- 原因:不同入口、生成页、结果页、作品详情和运行态各自渲染局部错误,没有统一携带草稿、生成会话、作品或游玩来源。
- 处理:跨流程错误统一由 `PlatformEntryFlowShellImpl` 汇总为 `PlatformErrorDialog`,来源使用玩法、草稿 / session / work / run 标识组成;弹窗提供复制按钮。关闭弹窗时只清理可安全清理的错误状态;恢复类错误用 dismiss key 防止反复弹出但不擅自改底层状态。
- 验证:触发任一平台级异步失败时,页面应出现包含“错误来源”和“错误内容”的弹窗;复制内容应包含来源和错误正文;旧页面内错误 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`
## 拼图公开推荐不要只按 Published 判断
- 现象:后台把拼图作品隐藏后,作品不在公开列表里显示,但玩家通关其它拼图后的推荐下一作品仍可能出现这条隐藏作品。
- 原因:拼图隐藏只把 `puzzle_work_profile.visible` 置为 `false`,不会把 `publication_status``Published` 改走;通关推荐候选曾只通过 `by_puzzle_work_publication_status().filter(Published)` 取数,漏掉可见性判断。
- 处理:拼图公开消费路径统一使用 `Published + visible=true`,范围包括 `puzzle_gallery_view``puzzle_gallery_card_view`、兼容 gallery/detail procedure、公开点赞 / Remix、正式公开 runtime 启动和通关后的 `recommended_next_works` 候选。
- 验证:`cargo test -p spacetime-module hidden_published_puzzle_work_is_not_public_visible_candidate --manifest-path server-rs/Cargo.toml`,并在需要时用后台隐藏一个已发布拼图后重试通关推荐。
- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.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 图片生成 request_send 传输错误要按可重试网络抖动排查
- 现象:`external_api_call_failure` 里看到 `failureStage=request_send``statusCode=null``errorSource` 可能是 `client error (SendRequest)``[35] SSL connect error (Recv failure: Connection reset by peer)``[56] Failure when receiving data from the peer (... unexpected eof while reading ...)`;也可能看到 `failureStage=upstream_status``statusCode=502`、错误体是 Nginx HTML `502 Bad Gateway`。前端只知道图片生成失败。
- 原因:`request_send` 表示请求未拿到可归类的 HTTP 响应,不会包含上游 JSON 错误体;`upstream_status=502/5xx/429/408` 表示拿到了上游错误响应但仍属于可重试的过载 / 网关抖动。`timeout=true` 来自超时判定,`connect=true` 会同时覆盖 DNS / connect 失败以及 libcurl 35 SSL 握手、libcurl 56 收包提前 EOF、connection reset 这类临时传输错误。
- 处理:先按 `provider/failureStage/statusClass` 聚合,再用 `user_id` / `profile_id``metadata_json.userId/profileId/requestId` 定位触发者、草稿 / 作品和同一次 HTTP 请求;`request_send + timeout/connect=true``upstream_status + statusCode=408/429/5xx` 优先查 provider 日志的 `source_chain`、请求体大小、参考图数量、出口网络、代理/Nginx、VectorEngine 当时可用性和同一 request_id 日志。当前 `platform-image` 对 request_send 的 timeout / connect / SSL connect reset / recv error / unexpected eof / send error,以及 upstream_status 的 408 / 429 / 5xx 最多发送 5 次,multipart `/v1/images/edits` 每次重试都会重新构造 form;看到 `VectorEngine 图片请求发送失败,准备重试``VectorEngine 图片上游状态可重试,准备重试` 只是单次 attempt 失败,最终 `external_api_call_failure` 才代表该用户请求整体失败。若记录有 `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_send_retry_policy -- --nocapture``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`
## 跳一跳 Three.js 地块 UV 顶面要映射到 Z 轴
- 现象:跳一跳地块使用六面 UV 贴图后,看起来像贴图位置贴歪,顶面显示侧面纹理,或者旧单张地块图被拉到立方体多个面上。
- 原因:运行态以 `z` 作为立方体竖直高度和相机下压方向,但 Three.js `BoxGeometry` / `RoundedBoxGeometry` 的默认材质 group 顺序把 `+Y` 当 top;如果直接按 `right / left / top / bottom / front / back` 写材质,玩法逻辑的 `top` 会贴到侧面。旧作品没有完整 `faceAssets` 时,把单张旧贴图强行作为 3D 六面 fallback 也会被误认为 UV 贴歪。
- 处理:Three 平台层只在 `tileAssets[].faceAssets` 六面完整时启用;材质数组按 Three group 顺序写入 `right / left / back / front / top / bottom`,把逻辑 `top` 映射到 `+Z` 顶面,并按每面 UV 方向做翻转校正;旧单图作品继续走 DOM 图片 / 原型兜底层。
- 验证:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖材质顺序、UV 翻转和旧单图不启用 Three 贴面;`cargo test -p api-server jump_hop_tile_atlas_slicing --manifest-path server-rs/Cargo.toml -- --nocapture` 应覆盖 UV 安全边裁切。
- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx``server-rs/crates/api-server/src/jump_hop.rs``docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## “我的”页每日任务卡不要硬编码进度,也不要跨日保留旧状态
- 现象:用户完成或领取每日任务后,任务中心弹窗里的任务状态已经变化,但“我的”页卡片仍显示 `0 / 1` 和“去完成”。
- 原因:卡片首版只写了静态展示文案,没有读取 `/api/profile/tasks` 返回的 `ProfileTaskCenterResponse`,领取接口返回的新 `center` 也只用于弹窗;后来虽然后端按北京时间 0 点切换业务日,但前端停留在“我的”页时不会跨日刷新,可能继续展示上一日已领取状态。若认证成功后把 `daily_login` 当普通埋点写入,或历史 `profile_task_config` 仍保留旧 `profile.login.daily` 事件键,新业务日也可能写了登录事件却查不到任务进度。
- 处理:进入“我的”页时读取任务中心,卡片用当前可操作任务或已领取任务派生奖励、进度条和操作状态;`claimRpgProfileTaskReward(...)` 成功后用响应里的 `center` 覆盖本地任务中心;停留在“我的”页跨过北京时间 0 点时,先非阻断 refresh 登录态写入新业务日 `daily_login`,再重拉任务中心。后端认证成功统一走 `SpacetimeClient::record_daily_login_tracking_event(...)` 与 SpacetimeDB 专用 `record_daily_login_tracking_event_and_return`,默认每日登录任务读取时会把结算字段自愈到 canonical `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`
## “我的”页不要恢复旧的填邀请码次级按钮
- 现象:移动端“我的”页在五项常用功能和设置入口下方又出现一个“填邀请码”按钮,看起来像旧入口残留。
- 原因:邀请码流程迁移后仍按新用户窗口保留 `canShowReferralRedeemShortcut` 次级入口;但当前页面口径已经固定为五项常用功能宫格,邀请码填写应由邀请链接 query 或明确引导打开弹窗。
- 处理:移除常驻 `次级入口` / `填邀请码` 渲染,不删除 `ProfileReferralModal``redeem` 面板,也不破坏 `?inviteCode=` / `?invite_code=` 自动打开填写弹窗。
- 验证:新用户账号打开“我的”页时没有 `次级入口``填邀请码` 按钮;带 `?inviteCode=spring-2026` 的登录用户仍自动打开邀请码弹窗并预填 `SPRING2026`
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx``.codex/skills/genarrative-profile-invite-flow/SKILL.md`
## 创作卡片点击要直达已有入口表单,别再保留空白入口页
- 现象:创作 Tab 模板卡点击后如果仍然停留在创作大厅,或者先进入“X 创作入口”这种空白页,就会让用户多走一层,还可能被错误的 stage 白名单拉回平台。
- 原因:`/creation/<play>` 一度被接成空白创作入口页,导致 `SelectionStage``appPageRoutes` 和卡片点击分流被旧占位 stage 污染。
- 处理:把 `/creation/<play>` 重新指向已有入口表单 stage,例如 `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`;平台壳层和测试同步清理空白入口页相关 helper。
- 验证:点拼图 / 抓大鹅 / 汪汪声浪卡片后,应看到各自既有工作台内容,例如测试中的 `拼图工作区:missing-session``抓大鹅工作区:missing-session``汪汪声浪配置表单`,并且不再出现“X 创作入口”空白页。
- 关联:`src/components/platform-entry/platformEntryTypes.ts``src/routing/appPageRoutes.ts``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`
## 创作流程刷新恢复必须写私有 query
- 现象:创作生成页或结果页刷新后回到空白工作区、平台首页,或者从作品详情返回时错误复用了别的玩法草稿。
- 原因:部分创作流程只把 `sessionId` / `profileId` / `draftId` / `workId` 放在前端内存里,没有写进 URL;也曾把写 URL 放在 stage 切换前,`writeCreationUrlState` 因为还停在非创作路径而直接跳过。若跨玩法或公开详情继续保留私有 query,还会污染 `/works/detail?work=...`
- 处理:创作页只使用私有 query `sessionId``profileId``draftId``workId` 做刷新恢复,不复用公开 `work` 参数;`pushAppHistoryPath` 只在同一创作流内保留这些 query,离开创作流或切到另一个玩法必须清掉;手动 draft 打开、生成完成和保存回调要在路由已经切到 `/creation/<play>` 后再调用 `writeCreationUrlState`
- 验证:`npm run test -- src/services/creationUrlState.test.ts src/routing/appPageRoutes.test.ts src/components/platform-entry/usePlatformCreationAgentFlowController.test.tsx`;手测生成页 / 结果页刷新仍恢复同一草稿,打开公开作品详情 URL 不带私有恢复参数。
- 关联:`src/services/creationUrlState.ts``src/routing/appPageRoutes.ts``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 草稿作品架打开结果页返回必须回草稿 Tab
- 现象:从草稿 Tab 作品架点击已有草稿进入结果页后,点结果页返回会跳回创作 Tab 模板入口,用户需要重新切回草稿页才能继续找原草稿。
- 原因:平台壳层只按结果页类型硬编码返回创作入口,没有记录本次创作流是从草稿作品架打开;如果来源标记没有在新建入口时重置,还可能污染下一条创作链路。
- 处理:从作品架打开任一玩法草稿时标记返回目标为 `draft-shelf`;从创作 Tab 新建、打开模板或退出非草稿来源工作区时重置为 `create`;结果页返回和工作区退出统一消费这个返回目标,并在消费后复位。
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle draft result back button returns to draft hub when opened from shelf|agent draft result back button returns to draft hub without syncing result profile"`
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 拼图生成页轮询不要绑展示 phase 或不稳定 setter
- 现象:拼图创作进入生成中页后,`/api/runtime/puzzle/agent/sessions/{sessionId}` 会在 0.3 到 0.5 秒内被反复 GET,看起来像轮询风暴,而不是 3 秒一次的正常刷新。
- 原因:轮询 `useEffect` 同时依赖了拼图展示 phase 和会随父组件渲染变化的 `setSession` 函数,导致 `puzzleGenerationState` 的进度合并或页面重渲染就会重挂 effect;effect 里又会立即先请求一次 session,于是请求被放大成密集循环。
- 处理:拼图轮询只绑定 `selectionStage``activePuzzleGenerationSessionId` 和“是否仍在生成中”这个布尔条件;`setSession` 通过 ref 保持稳定,不让父组件重新渲染改变轮询器身份。进度 phase 变化只更新展示,不重建轮询。
- 验证:`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`
- 原因:`writePuzzleRuntimeUrlState` 只会在当前路径已经是 `/runtime/puzzle` 时写入;如果先触发阶段切换再写 query,或者草稿作品摘要缺少 `sourceSessionId`,就会把恢复参数写丢。`App.tsx` 的 stage 同步也会改 pathname,所以顺序不对时容易只留下部分 query。
- 处理:进入拼图 runtime 时先 `pushAppHistoryPath('/runtime/puzzle')`,再 `setSelectionStage('puzzle-runtime')`,最后写 `runtimeProfileId``runtimeSessionId``runtimeLevelId``work``mode`;草稿 runtime URL state 允许从 `profileId` 反推 `puzzle-session-*`,作为 `sourceSessionId` 的兜底。
- 验证:`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 顺序错误,页面停在“正在加载内容”。
- 原因:`RpgEntryHomeView` 曾经写成 `const isDesktopLayout = isDesktopLayoutProp ?? usePlatformDesktopLayout();`,当 `isDesktopLayoutProp` 存在时会跳过 hook 调用,导致 hook 顺序在不同渲染之间变化。
- 处理:先无条件调用 `usePlatformDesktopLayout()`,再用 `isDesktopLayoutProp ?? detectedDesktopLayout` 合并;不要把 hook 调用藏在条件表达式里。
- 验证:桌面与窄屏各刷新一次首页,控制台不再出现 hook 顺序错误;`npm run typecheck` 和首页推荐相关测试通过。
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/platform-entry/platformEntryResponsive.ts`
## 泥点不足提示不要把用户退回创作入口
- 现象:拼图 / 抓大鹅 / 汪汪声浪等创作表单点击生成时,如果泥点不足,页面直接回到创作 Tab 玩法模板列表,刚填的表单内容随工作台卸载全部丢失。
- 原因:`PlatformEntryFlowShellImpl.tsx``ensureEnoughDraftGenerationPointsFromServer(...)` 曾在余额不足或余额读取失败时调用 `enterCreateTab()``setSelectionStage('platform')`,把前置校验失败当作离开工作台处理。
- 处理:泥点前置校验失败只更新独立 `UnifiedModal` 提示,不切换 stage,不清表单;余额读取失败也走同一弹窗口径。需要提示玩法内错误时可以保留局部错误位,但不得因此退出工作台。
- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle form checks mud points before creating a draft|match3d form checks mud points before creating a draft|bark battle form checks mud points before creating image assets"` 应断言弹窗出现、对应工作台仍在、玩法模板分类不再出现。
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 内嵌泥点确认弹窗必须自带平台主题作用域
- 现象:拼图 / 抓大鹅统一创作页点击生成后,“确认消耗泥点”弹窗正文和按钮存在,但弹窗面板背景透明,只剩遮罩和文字。
- 原因:`PlatformMudPointConfirmDialog` 作为二级确认常以 `portal={false}` 内嵌到工作台局部 DOM,局部节点不一定继承 `.platform-theme``platform-modal-shell` 依赖 `--platform-modal-fill` 等主题变量,变量缺失时面板底色解析为空。
- 处理:共享泥点确认弹窗默认在 overlay 上带 `platform-theme platform-theme--<theme>``platform-modal-backdrop` 和实色遮罩,在 panel 上带 `platform-modal-shell platform-remap-surface`;单按钮状态弹窗也要有默认 light 主题,避免未来独立调用复现。
- 验证:浏览器触发 `/creation/puzzle``/creation/match3d` 的泥点确认弹窗,检查 overlay 最近主题 class 存在、`--platform-modal-fill` 有值且面板为实底;聚焦测试覆盖默认 overlay / panel class。
- 关联:`src/components/common/PlatformMudPointConfirmDialog.tsx``src/components/common/PlatformStatusDialog.tsx``src/components/unified-creation/workspaces/PuzzleCreationWorkspace.tsx``src/components/unified-creation/workspaces/Match3DCreationWorkspace.tsx`
## 拼图结果页关卡图不要裁切,嵌套图片预览要高于详情弹窗
- 现象:拼图结果页“拼图关卡”列表里的关卡图底部被裁掉;进入关卡详情后点击画面图,看起来没有打开全屏预览。
- 原因:关卡列表复用 `PlatformMediaFrame aspect="standard"` 默认 `object-cover`,方图或竖向生成图会在 4:3 框内被裁切;关卡详情弹窗自身层级高于 `CreativeImageInputPanel` 默认图片预览层级,预览实际打开但被压在详情弹窗后面。
- 处理:结果页关卡缩略图显式传 `imageClassName="h-full w-full object-contain"` 保留完整画面;`CreativeImageInputPanel` 提供 `mainImagePreviewZIndexClassName`,嵌套在高层级弹窗内时由调用方传更高层级。
- 验证:聚焦测试断言关卡缩略图使用 `object-contain` 且没有 `object-cover`,并断言关卡详情内主图预览 overlay 层级高于详情弹窗;浏览器里检查列表完整显示图片,详情内点击画面图能打开可见预览。
- 关联:`src/components/puzzle-result/PuzzleResultView.tsx``src/components/common/CreativeImageInputPanel.tsx``src/components/puzzle-result/PuzzleResultView.test.tsx`
## 图片大图预览不要复用白底工具弹窗
- 现象:点击图像输入面板里的参考图或主图预览后,页面只出现白底非全屏弹窗,背后原页面透出,不能缩放或拖拽查看细节。
- 原因:图片查看和工具弹窗共用了 `UnifiedModal` 白底壳层;该壳层适合编辑 / 选择工具,不适合沉浸式看图,也没有图片边界拖拽状态。
- 处理:纯图片预览统一走 `PlatformImagePreviewModal`,全屏黑底展示,初始 contain 保证完整图片可见,缩放夹在 `1x-4x`,拖拽位移按缩放后的图片边界夹取,避免把图片拖到露出背景。
- 验证:`npm run test -- src/components/common/PlatformImagePreviewModal.test.tsx src/components/common/CreativeImageInputPanel.test.tsx` 应覆盖黑底全屏、缩放上限、拖拽边界和关闭按钮。
- 关联:`src/components/common/PlatformImagePreviewModal.tsx``src/components/common/CreativeImageInputPanel.tsx`
## 玩法入口分类字段缺失要前端兜底
- 现象:平台创作入口初始化时,`platformEntryCreationTypes.ts` 直接对 `creationTypes[].categoryId` / `categoryLabel``trim()`,一旦后端旧数据、局部 mock 或异常返回里缺字段,整个创作页会在 `derivePlatformCreationTypes(...)` 里直接炸掉。
- 处理:`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 仍带红色阴影,和平台暖棕体系不一致。
- 原因:`platform-nav-unread-dot``creation-work-card__unread-dot` 直接写了 `#b64a35``rgba(239, 68, 68, ...)`,没有收口到统一 token。
- 处理:未读点颜色统一走 `--platform-unread-dot-fill` / `--platform-unread-dot-glow`,桌面/移动端共用同一口径;不要把红色 literal 再写回样式。
- 验证:`src/index.test.ts` 断言两个 unread dot block 都只引用未读点 token,不再出现红色 literal 或红色 glow。
- 关联:`src/index.css``src/index.test.ts``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 创作 Tab 模板卡不要复用暗图蒙版参考卡样式
- 现象:创作 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 中每张开放态卡都应显示标题、描述和后台契约 `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`
## 创作首屏开放态卡片不要再显示左上状态标签
- 现象:创作 Tab 的开放态玩法卡左上角会重复显示“可创建”或“可创作”,视觉上比其它状态更吵,还会和封面图抢注意力。
- 原因:卡片渲染层默认把 `badge` 当成所有状态都要展示的左上角标签,没有区分开放态与非开放态。
- 处理:开放态卡片不渲染左上标签,仅保留标题、描述和右下角消耗信息;`敬请期待``即将开放` 等非开放态标签继续保留。
- 验证:创作首屏 HTML 中不应包含 `可创建` / `可创作`,但仍应包含 `即将开放` 等非开放态状态。
- 关联:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 发现 / 创作 / 草稿页不要把根内容区再包成全局卡片壳
- 现象:发现页、创作页或草稿页根区一旦套回 `platform-page-stage`,页面边缘会立刻变得更厚,频道标签、列表和模板卡的横向空间都被挤窄,看起来像回到了旧全局卡片壳。
- 原因:`platform-page-stage` 本身是全局内容卡片壳,适合推荐页、我的页和其它页面,但这三页已经有自己的视觉结构;草稿页顶部筛选若继续用旧 `platform-tab`,还会和发现页频道标签不一致。
- 处理:这三页的根内容区只保留 `platform-remap-surface`,不要再加 `platform-page-stage`;草稿页顶部筛选复用发现页的 `platform-mobile-home-channel``platform-mobile-home-channel--active`
- 验证:浏览器里这三页的根区应仍保留 `platform-remap-surface`,但不再出现 `platform-page-stage`;草稿页顶部筛选样式应和发现页频道标签一致。
- 关联:`src/components/custom-world-home/CustomWorldCreationHub.tsx``src/components/custom-world-home/CustomWorldWorkTabs.tsx``src/components/rpg-entry/RpgEntryHomeView.tsx``src/index.css`
## 统一创作壳现在自己负责页面滚动和四条入口外壳
- 现象:统一创作页最初只包住拼图、抓大鹅和敲木鱼的工作台内容,跳一跳仍然保留独立工作台壳,页面级滚动职责也散落在平台入口 motion wrapper 里,导致移动端不同入口的可见外壳不一致。
- 原因:`UnifiedCreationPage` 只做了标题和隐藏契约,入口壳还在各自工作台里保留 `platform-remap-surface` / `overflow-y-auto``jump-hop` 也没进入统一 spec。
- 处理:把 `jump-hop` 纳入 `unifiedCreationSpec`,让 `UnifiedCreationPage` 自己承担页面级滚动与统一标题栏;`JumpHopCreationWorkspace``WoodenFishCreationWorkspace``unifiedChrome` / `showBackButton`,平台壳不再给这几条统一入口套额外滚动壳。
- 验证:`npm run test -- src/components/unified-creation/unifiedCreationSpecs.test.ts src/components/unified-creation/UnifiedCreationPage.test.tsx src/components/unified-creation/UnifiedGenerationPage.test.tsx src/components/unified-creation/workspaces/JumpHopCreationWorkspace.test.tsx src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx` 通过后,`/creation/puzzle``/creation/match3d``/creation/jump-hop``/creation/wooden-fish` 都应由同一套统一创作页外壳承载。
- 关联:`src/components/unified-creation/UnifiedCreationPage.tsx``src/components/unified-creation/unifiedCreationSpecs.ts``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
## 统一创作编排层不要再让平台壳直挂旧工作台
- 现象:平台入口壳已经切到统一创作外壳,但源码里仍直接 lazy import 并渲染四个旧工作台分支,看起来还是四套入口编排。
- 原因:统一创作页只收口了可见外壳,入口层没有再抽一层统一创作编排组件,导致平台壳依旧要认识各玩法旧工作台。
- 处理:新增 `UnifiedCreationWorkspace`,由它内部按 `playId` 选择真实工作台;平台壳只依赖这一层,不再直接挂旧工作台分支。旧工作台已迁入 `src/components/unified-creation/workspaces/`,不再是入口编排事实源。
- 验证:`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)`
- 原因:该 Jenkinsfile 文件前三字节是 UTF-8 BOM `EF BB BF`Jenkins/Groovy 把它拼进首个标识符,导致实际调用的是 `\ufeffpipeline` 而不是 Declarative Pipeline 的 `pipeline` 全局。
- 处理:仓库内 `jenkins/Jenkinsfile.production-*` 保存为 UTF-8 without BOM;不要为了解决 Windows PowerShell 5.1 `.ps1` 中文解析问题而给 Jenkinsfile 本身加 BOM。只有 Jenkins helper 临时写出的 `.ps1` 才按需要转成 UTF-8 with BOM。
- 验证:检查 `jenkins/Jenkinsfile.production-stdb-module-publish` 文件开头字节不再是 `EF BB BF`,并用 Jenkins `validateDeclarativePipeline` 或重放 `Genarrative-Stdb-Module-Publish`,不应再停在 `No such DSL method 'pipeline'`
- 关联:`jenkins/Jenkinsfile.production-stdb-module-publish``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## Full Build 的维护退出节点不得 checkout Git
- 现象:Full Build 的 Stdb、API 和 Web 都已发布成功,`Exit Maintenance` 进入目标部署 agent 后却先执行 `checkout scm`,用 `ssh://git@127.0.0.1:2222/...` 拉仓库并报 `Connection refused`,导致已部署的维护退出脚本根本没有执行。
- 原因:`127.0.0.1:2222` 只是 Jenkins controller 上的 Gitea SSH 端口,在部署 agent 上代表部署机自身。该次流水线在 Jenkins 重启后恢复,Declarative 的阶段 `agent` 路径未继续遵守顶层 `skipDefaultCheckout(true)`,在 `steps` 前注入了不必要的 SCM checkout。
- 处理:`Exit Maintenance` 保持 `agent none`,在 `steps` 内根据 `DEPLOY_TARGET` 用显式 `node(deployLabel)` 分配目标机,只从绝对路径执行 current release 已携带的 `/opt/genarrative/current/scripts/deploy/maintenance-off.sh`。不要在这个节点添加 GitSCM、Git SSH 凭据或 Jenkins workspace 相对路径。
- 验证:运行 `npm run check:production-ops`;重放流水线时,`Exit Maintenance``Running on <deploy-agent>` 之后应直接进入 `sh`,不应出现 `checkout``GitSCM` 或 Git 凭据日志。
- 关联:`jenkins/Jenkinsfile.production-full-build-and-deploy``scripts/check-production-ops-guardrails.mjs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## Linux 多用户 dev 端口冲突先查系统级端口段注册表
- 现象:同一台 Linux 机器上多个用户同时开发时,`npm run dev` 报端口段已被其他用户占用、同一用户已有活跃端口段,或 SpacetimeDB 复用记录指向当前用户端口段之外的地址;未手动指定时自动分配应从 `10000-10099` 起步。
- 原因:Linux dev 脚本会通过 `/var/tmp/genarrative-dev-port-ranges/registry.json` 做系统级端口段分配,避免两个用户配置相同或重叠端口段;同一用户后续启动会继续复用自己已经占用的固定端口段。注册表会保留该用户的段记录,不会因为多开而要求重新分配。
- 处理:先确认当前用户已经占用的端口段,再让后续 `npm run dev` / `dev:*` 继续沿用这段;如确实要切换段,手动释放或清掉对应 registry 记录后再重启。需要临时隔离测试时用 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR=<tmp-dir>` 覆盖注册表目录。不要在 Windows 上按这个注册表排查,Windows 仍走原有端口探测与漂移逻辑。未指定端口段时,系统会从 `10000-10099` 开始顺序分配。
- 验证:重新启动后终端应打印 `[dev] port-range: <start-end> (<user>)``[dev] port-range-registry: .../registry.json``node node_modules/vitest/vitest.mjs run scripts/dev-stack-port-utils.test.ts scripts/dev.test.ts` 应通过 Linux registry、自动分配 `10000-10099` 与 Windows bypass 用例。
- 关联:`scripts/dev-stack-port-utils.mjs``scripts/dev.mjs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## SpacetimeDB 入口迁移 helper 合并时不要只保留调用
- 现象:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 或 Jenkins `Genarrative-Stdb-Module-Build``E0425 cannot find function migrate_rpg_entry_from_old_hidden_default in this scope`,位置在 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` 的默认入口配置播种流程。
- 原因:分支合并时保留了 `seed_creation_entry_config_if_missing(...)` 中的迁移调用,但漏掉了同文件内的 helper 定义;该 helper 负责把历史默认隐藏的 RPG 入口纠偏为当前开放默认值。
- 处理:恢复缺失的迁移 helper,不要直接删除调用。helper 只能匹配历史默认种子(标题、副标题、badge、图片、visible/open、排序都一致)后再更新,避免覆盖后台入口开关的人工配置。
- 验证:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 抓大鹅新 UI spritesheet 不要回退成中心容器图
- 现象:新素材流程生成后,运行态棋盘中心可能叠出一整张 UI spritesheet,导致按钮素材、方格和空白图集覆盖容器区域。
- 原因:为了兼容旧 DTO,后端可能把 `uiSpritesheetImage*` 同步写入历史 `containerImage*` 字段;旧前端只看 `containerImage*`,会误把 UI 图集当透明中心容器。
- 处理:读取中心容器图时先比较归一化后的 `containerImage*``uiSpritesheetImage*`。两者同源时忽略 `containerImage*`,只把它作为旧数据兼容字段;新流程背景图本身已经保留容器,运行态只需加载背景和解析 UI / 物品 spritesheet。
- 验证:`npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx` 应覆盖“运行态不把兼容写入的UI spritesheet当中心容器图”。
- 关联:`src/components/match3d-runtime/Match3DRuntimeShell.tsx``server-rs/crates/api-server/src/match3d/mappers.rs``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 通用系列素材图集先看 platform-image,不要先翻 api-server 大文件
- 现象:排查跳一跳、抓大鹅或其它玩法的系列素材图集切片 / 去绿 / 持久化时,最容易先打开 `api-server/src/generated_asset_sheets.rs`,结果在一个 60KB+ 大文件里找实现、测试和辅助函数,定位很慢。
- 原因:这条通用图片 seam 已经下沉到 `server-rs/crates/platform-image/src/generated_asset_sheets/``api-server` 只剩薄包装和调用方兼容;继续把 `api-server` 当真值源会把理解路径拉回旧位置。
- 处理:先看 `server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs``prompt.rs``sheet.rs``alpha.rs``persist.rs``error.rs`,再看 `api-server/src/generated_asset_sheets.rs` 的 AppError / AppState 适配和玩法调用点。
- 验证:`cargo test -p platform-image --test generated_asset_sheets --manifest-path server-rs/Cargo.toml` 通过,且 `cargo check -p api-server --manifest-path server-rs/Cargo.toml` 保持绿灯。
- 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/``server-rs/crates/api-server/src/generated_asset_sheets.rs``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 图片画布 UI 提取素材切片不要把断开的高光阴影当独立图标
- 现象:图片画布提取 UI 素材后,右侧素材库出现很小的废图;主体图标的阴影、反光、高光或小装饰不完整。
- 原因:图标 spritesheet 切片按 alpha 连通域识别素材,模型常把软阴影、高光、小星星等画成与主体断开的透明块;如果直接逐连通域出图,小碎片会抢占图标顺序,主体也会缺边缘装饰。
- 处理:在 `platform-image``sheet.rs` 里先合并靠近主体的辅助连通域,再过滤孤立小碎片,最后给裁剪框保留安全 padding。不要在前端素材卡或画布层里修已经切坏的 PNG。`生成图标素材` 入口只回填扣绿后的整张图集,不再拆分独立图标。
- 验证:`cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml` 覆盖断开的高光合并和孤立小碎片过滤;调用方补跑 `cargo test -p api-server editor_icon --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs``server-rs/crates/api-server/src/editor_project.rs`
## UI spritesheet 不要依赖模型直接生成透明背景
- 现象:拼图或抓大鹅运行态解析 UI spritesheet 时,把整张背景图、棋盘格、叶子或装饰图也当作 UI 素材区域,按钮映射错乱;截图里常表现为底部按钮区只剩透明棋盘格或素材碎片。
- 原因:前端解析依赖 alpha 连通域检测,透明背景是前提;但生图模型收到“透明背景 spritesheet”提示后仍可能输出带实景背景或伪透明棋盘格的普通不透明 PNG,OSS 中保存的图没有真实 alpha。
- 处理:UI spritesheet 提示词应要求统一单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,而不是让模型直接产透明背景;后端在上传 OSS 前复用 `generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha(...)` 把绿幕扣成真实透明 PNG,再把透明图写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`
- 验证:`cargo test -p api-server puzzle_ui_spritesheet_postprocess_turns_green_screen_transparent --manifest-path server-rs\Cargo.toml``cargo test -p api-server puzzle_level_scene_spritesheet_and_background_requests_use_references --manifest-path server-rs\Cargo.toml``cargo test -p api-server match3d_derived_asset_prompts_match_three_sheet_pipeline --manifest-path server-rs\Cargo.toml`
- 关联:`server-rs/crates/api-server/src/puzzle/generation.rs``server-rs/crates/api-server/src/match3d/works.rs``server-rs/crates/api-server/src/generated_asset_sheets.rs``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 敲木鱼 hit object 不要只相信透明底 prompt
- 现象:苹果等主题试玩时,中央敲击物图带明显黑底;背景图中央还可能出现苹果主体,或背景环境图偶发变成纯绿色底,和“中央只叠加 hitObjectAsset”的运行态设定冲突。
- 原因:gpt-image-2 对“透明底”和“背景只做外围氛围”的遵循不稳定。若 hit object 直接入库,黑底会被当成真实像素展示;若背景 prompt 只有软描述,模型会把主题主体画进中央。第一步为了去背刻意要求绿幕图时,如果第二步参考图或 prompt 没有切断绿幕语义,背景图也可能继承纯绿色画布。
- 处理:敲木鱼 hit object prompt 固定要求先输出 `1:1` 单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景主体图,再由 `api-server` 只对绿幕背景做去绿透明化;不要回到黑底 / 白底 / 透明底 prompt 后再做泛抠图。背景生成必须使用第一步抠图完成后的透明图作为参考图,并在 prompt 中显式禁止继承绿色底色、绿幕底色或纯绿色画布;背景 prompt 还要固定要求中央 40% 主体预留区干净,禁止主题主体、局部特写、轮廓影子、重复元素和主题碎片,只允许外围氛围。不要在背景 prompt 写“木鱼预设在屏幕中央位置”或类似中心主体正向描述,运行态敲击物只能由前端叠放。
- 验证:`cargo test -p api-server wooden_fish --manifest-path server-rs\Cargo.toml`,并用花朵 / 苹果 / 玉米主题跑试玩图确认绿幕被去除、主体未被抠除、背景中央不出现主题主体,背景环境图不再出现纯绿色底。
- 关联:`server-rs/crates/api-server/src/wooden_fish.rs``docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 敲木鱼返回按钮不要让模型自由发挥外圈花纹
- 现象:返回按钮试玩图有时会被画成徽章、花盘、浮雕圆牌,甚至出现复杂外圈和装饰花纹,左箭头反而不够突出。
- 原因:prompt 只说“主题化返回按钮”时,image2 会把参考图里的装饰语言一起学进去;如果没有把形状收束到“标准圆形 + 单个居中左箭头”,模型会优先补造型而不是补图标。
- 处理:返回按钮生成 prompt 必须只允许参考图约束圆形底色与箭头配色,明确禁止复杂造型、花纹、浮雕边、异形外框和装饰图案,按钮本体固定为标准圆形,视觉尺寸比当前模板再放大约 50%,圆形外沿需要一圈与主题色搭配的干净外描边。
- 验证:`cargo test -p api-server wooden_fish --manifest-path server-rs\Cargo.toml`,并重新试玩确认返回按钮只剩圆形底色和中央左箭头。
- 关联:`server-rs/crates/api-server/src/wooden_fish.rs``docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`.
## 敲木鱼历史已发布作品缺返回按钮要补齐,不要靠推荐过滤
- 现象:推荐页或公开列表中的历史敲木鱼作品点击运行态时报 `敲木鱼运行态需要完整作品配置`,但这类作品的敲击物、背景、音效和飘字都已完整,只是 `backButtonAsset` 为空。
- 原因:早期已发布作品缺少统一的默认返回按钮快照;运行态启动时如果仍直接按完整配置校验,就会把可玩的历史作品拒掉。这个问题不应通过推荐流或公开列表过滤解决。
- 处理:`spacetime-module``start_wooden_fish_run_tx` 和 work snapshot 构建时,若作品已发布且 `generationStatus=ready`,但仅缺 `backButtonAsset`,就补写内置默认返回按钮 `/UI/11_left_arrow.png`,再继续进入运行态。默认返回按钮以 `bundled-default` 资产快照写回 work profile,字段保持 `assetId=wooden-fish-default-back-button``imageObjectKey=public/UI/11_left_arrow.png`
- 验证:历史木鱼作品点击运行态不再报完整作品配置缺失;第一次进入后,work profile 里应补出 `backButtonAsset`
- 关联:`server-rs/crates/spacetime-module/src/wooden_fish.rs``docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 敲木鱼创作生成不要沿用 15 秒会话超时
- 现象:敲木鱼工作台点击“生成”后,前端直接提示 `请求超时:15000ms`,但后端和 VectorEngine 未必已经失败。
- 原因:`createCreationAgentClient``createSessionTimeoutMs` 默认是 15 秒;敲木鱼创作链路会继续进入生成页并执行多次 image2 edits、去绿背景处理和 OSS 写入,单次请求窗口如果继承共享默认值,会早于业务生成完成被前端中断。
- 处理:敲木鱼 client 必须单独配置长等待窗口,同时覆盖 `createSessionTimeoutMs``executeActionTimeoutMs`;不要修改共享默认值影响其它轻量创作 Agent。
- 验证:`npm run test -- src/services/wooden-fish/woodenFishClient.test.ts`,并在本地触发一次木鱼创作确认不再出现 15 秒前端超时。
- 关联:`src/services/wooden-fish/woodenFishClient.ts``src/services/creation-agent/creationAgentClientFactory.ts``docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`
## 敲木鱼创作“卡住”先查 2xx 慢请求
- 现象:敲木鱼工作台点击生成后长时间停留在生成页,看起来像卡住;`api-server` 日志可能出现 `/api/creation/wooden-fish/sessions/{sessionId}/actions``2xx` 慢请求,耗时可达数分钟,例如 `latency_ms=525473`
- 原因:当前 `compile-draft` 是同步 action,会串行等待敲击物、背景环境图、返回按钮图三次 image2 edits、去绿处理、OSS 写入和 SpacetimeDB 草稿写回;提示词生成音效已关闭,不应作为生成阶段。
- 处理:先确认日志中该 action 是不是最终 200;若是 200 慢请求,不要优先排查 WebSocket 或 SpacetimeDB procedure。前端生成页进度必须按“整理草稿 -> 生成敲击物 -> 生成背景环境图 -> 生成返回按钮图 -> 写入正式草稿”展示,并在未收到 action 回包前保持等待态,不宣称完成。
- 验证:`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts -t "wooden fish"`,并观察木鱼生成页在 5 分钟以上等待时仍停留在合理阶段。
- 关联:`src/services/miniGameDraftGenerationProgress.ts``docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 本地 SpacetimeDB procedure 超时或缺失先查版本错配
- 现象:敲木鱼创作时点击“生成”提示 `SpacetimeDB procedure 调用超时`,或后台 Dashboard 的指标与柱状图同时消失;服务端日志更早出现 `Failed to BSATN deserialize procedure return value``No such procedure`Dashboard 请求返回 `502`
- 原因:本机 `spacetime` CLI / standalone 版本与 `server-rs/Cargo.toml` 锁定的 `spacetimedb` 版本不一致时,procedure 返回值会在宿主侧反序列化失败,api-server 继续等待就表现成调用超时。若旧 worktree 已删除但其 orphan standalone 仍监听原端口,API 还可能连到旧 wasm:健康检查正常,新 bindings 对应的 procedure 却尚未发布。
- 处理:先用 `spacetime --version` 和监听端口对应的 `/proc/<pid>/exe --version` 分别核对 CLI 与真实宿主,再和 `server-rs/Cargo.toml` 的锁定版本对齐;不能把新版本模块硬发布到旧宿主。旧实例仍有需要保留的本地数据时,先用迁移 procedure 导出,在独立端口启动匹配版本、发布当前模块并增量导入,逐表对账后再把本次 API 切到新实例;旧实例在对账前不停止。当前 dev 脚本会对带版本记录的本地实例校验 `dev-spacetime-tool-version`,但显式连接历史端口时仍要核对真实进程和 module schema。
- 验证:CLI、standalone 与 Cargo 锁定版本一致,`/v1/ping` 正常,`spacetime describe` 可找到调用中的 procedureDashboard 接口返回 `200` 且包含 4 张图,敲木鱼生成不再卡在 procedure timeout。另执行 `npm run test -- scripts/dev.test.ts` 验证本地调度门禁。
- 关联:`scripts/dev.mjs``scripts/dev.test.ts``server-rs/Cargo.toml``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 拼图 UI spritesheet 运行态不要二次包圆底或拉伸比例
- 现象:拼图运行态左上返回和右上设置按钮外面出现白色圆圈;底部“提示 / 原图 / 冻结”三枚素材被压扁、拉宽或拉成正圆,和图集原始按钮比例不一致。
- 原因:UI spritesheet 已经包含按钮视觉本体,但运行态仍给顶部按钮套默认圆形 icon 容器;底部三枚素材用 `h-full w-full rounded-full` 铺满按钮格,覆盖了自动检测矩形的真实宽高比。
- 处理:有 `uiSpritesheetImage*` 时,顶部返回 / 设置按钮容器只保留透明点击区和 focus 状态,不再叠加默认圆形底;`buildPuzzleUiSpriteBackgroundStyle(...)` 对检测到的矩形写入 `aspectRatio`,底部三枚素材按原始宽高比和最大尺寸渲染,不强制 `w-full`
- 验证:`npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx``npm run test -- src/services/puzzle-runtime/puzzleUiSpritesheetParser.test.ts`
- 关联:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx``src/services/puzzle-runtime/puzzleUiSpritesheetParser.ts``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
2026-05-22 补充:展示矩形和点击热区要分开处理。`puzzleUiSpritesheetParser``regions` 保留完整视觉裁切矩形,`hitRegions` 用较高 alpha 阈值只包住实心按钮主体;运行态底部 spritesheet 道具按钮启用 `puzzle-runtime-sprite-tool-button--precise-hit`,父按钮不吃整块透明留白,内部 `puzzle-runtime-ui-sprite-hit-zone` 才接收指针事件,避免透明区域成为点击热区。
## 图像输入组件不要把业务状态藏在页面内联实现里
- 现象:拼图页把参考图上传、缩略图、主图删除确认和 AI 重绘开关内联实现后,后续想复用到其它创作页时,页面级状态和通用 UI 状态混在一起,容易出现多套上传卡和参考图展示口径。
- 原因:通用图像输入是受控输入面板,不是只服务单页的临时实现;图片、提示词、参考图数组、重绘开关等业务真相应由外层页面持有,组件最多持有参考图预览、删除确认这类短生命周期 UI 状态。
- 处理:抽 `CreativeImageInputPanel` 时,保留上传卡、参考图入口、缩略图、预览弹层、删除确认和提交按钮的统一壳,但把主图文件读取、裁剪、历史素材、计费确认和具体提交动作留给外层页面;后续页面接入时只传业务回调和文案。
- 验证:拼图入口测试仍可通过,且新组件可通过不同页面复用而不需要复制上传卡实现。
- 关联:`src/components/common/CreativeImageInputPanel.tsx``src/components/unified-creation/workspaces/PuzzleCreationWorkspace.tsx`
## RPG 发布不能只依赖 agent session seed_text
- 现象:RPG 结果页 `publish_world` 返回 `UPSTREAM_ERROR`details 为 `custom_world.setting_text 不能为空`;同一 session 的 `result-view` 日志显示 `publish_ready=true`
- 原因:前端发布动作只提交 `{ action: 'publish_world' }`,旧 agent 会话的 `seed_text` 可能为空;如果后端只从 action payload 或 `seed_text``setting_text`,就会在最终 compile / publish 校验阶段失败。
- 处理:`module-custom-world::resolve_custom_world_publish_setting_text(...)` 以当前 `draft_profile_json` 为草稿真相,优先读取 `settingText``creatorIntent.rawSettingText``creatorIntent.worldHook``worldHook``anchorContent.worldPromise(.hook)``summary``name/title`,最后才回退 `seed_text`
- 验证:`cargo test -p module-custom-world publish_setting_text --manifest-path server-rs\Cargo.toml``cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml`
- 关联:`server-rs/crates/module-custom-world/src/application.rs``server-rs/crates/spacetime-module/src/custom_world.rs``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## RPG 已发布结果页进入世界不能重复 publish_world
- 现象:RPG 草稿发布成功后,按钮文案已变为“进入世界”,但点击仍请求 `POST /api/runtime/custom-world/agent/sessions/{sessionId}/actions` 且 payload 为 `{"action":"publish_world"}`,后端返回 `publish_world is only available during object_refining, visual_refining, long_tail_review or ready_to_publish`
- 原因:按钮文案依据 agent session `stage === 'published'` 切换,但点击处理仍走发布协调路径;如果前端只依赖草稿同步回包判断是否已发布,回包为空或缺少可进入状态时就会继续重复发送 `publish_world`
- 处理:进入世界协调器接收当前 agent session stage;当 stage 已为 `published` 时,只调用 `result-view` 回读已发布 profile 并启动运行态,不再调用 `sync_result_profile``publish_world`
- 验证:`npm run test -- src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx`;确认已发布场景下 `syncAgentDraftResultProfile``executePublishWorld` 均未被调用。
- 关联:`src/components/rpg-entry/useRpgCreationEnterWorld.ts``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## RPG 点击启动黑屏 / 默认 profile 先查 profile 归一化和摘要覆盖
- 现象:作品详情点击“启动”后页面切到 RPG runtime,但用户只看到黑屏、空白,或进入默认角色 / 默认 profile;从作品详情点“作品编辑”后开局 CG、封面、角色图、技能动作预览、初始物品图标或场景背景图丢失;DevTools 里可能同时看到旧自动存档 `/api/runtime/save/snapshot` 被主动 cancel。
- 原因:`/custom-world-library` / `/custom-world-gallery` 详情接口可能返回历史或摘要式 `profile`,缺少 `playableNpcs``storyNpcs``landmarks``attributeSchema` 等运行态字段;前端 client 若直接把该对象传给 runtime,角色选择首屏会在 `buildCustomWorldPlayableCharacters(profile)` 或后续属性解析处抛错。另一类常见原因是详情接口已回读完整 profile 后,`savedCustomWorldEntries` 里的列表摘要又把 `selectedDetailEntry` 覆盖回空 profile,导致启动或编辑时只剩卡片摘要。发布 / 回读 result-view 若返回字段更少的旧视图,也可能把当前结果页已编辑资产降级掉。`save/snapshot (canceled)` 通常是切 runtime 或卸载时 `AbortController` 取消旧自动存档,不是黑屏根因。
- 处理:RPG 入口作品库 client 在所有返回 `CustomWorldLibraryEntry<CustomWorldProfile>` 的接口边界统一调用 `normalizeCustomWorldProfileRecord`,并用 `profileId/worldName/subtitle/summaryText` 补齐旧数据缺字段;详情页已拿到运行态字段或资产槽位更多的完整 profile 时,不允许列表摘要覆盖当前详情;同一 `profile.id` 下,正式进入世界发布 / 回读不得用字段更少的后端旧视图降级当前结果页 profile。`normalizeCustomWorldProfileRecord` 必须近似无损保留 `cover``openingCg``camp.narrativeResidues``landmark.visualDescription/narrativeResidues``skills[].actionPreviewConfig``initialItems[].iconSrc``attributeSchema`、角色 `attributeProfile``sceneChapterBlueprints[].acts[]` 的背景与结构字段;只有背景资产的 act 也不能被过滤。角色选择页对角色生成异常或空数组回退默认角色,并保留返回按钮/轻量空态;顶层 runtime 懒加载 fallback 不使用纯 `null`
- 验证:运行对应入口交互、结果 profile 归一化、创建恢复和类型检查用例,确认列表摘要不会覆盖已加载的完整作品资料。
- 关联:当前作品资料读取、结果回读和运行态入口模块;现行链路见 `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## RPG 战后一轮战斗后卡在观察/试探/调息先查 post-battle finalization
- 现象:RPG 一轮战斗胜利后,运行态只显示默认 `观察周围迹象 / 主动出声试探 / 原地调息`,这些按钮只有文字反馈;点“继续冒险”后又回到同样选项,点探索只播退场/进场动画,场景和剧情不推进。
- 原因:终局战斗 action 如果只走通用 `resolve_story_runtime_action` fallback,而没有在后端调用 `finalize_post_battle_resolution(...)`,就不会持久写入 `story_continue_adventure``deferredOptions` 和下一幕 `currentSceneActState`。另外旧 bootstrap 快照可能只有 `connectedSceneIds` / `forwardSceneId`、没有 `connections`,战后选项生成若只读 `connections` 也会退回 `idle_explore_forward` 循环。
- 处理:`module-runtime-story` 在 story action 投影后统一调用 post-battle finalization`idle_explore_forward` 清理战斗态并生成下一段遭遇预览;`idle_travel_next_scene` / `camp_travel_home_scene` 由后端写入新 `currentScenePreset`、场景 act 状态、遭遇预览和 `runtimeStats.scenesTraveled`。前端只负责播放继续、探索和切场景动画,不承接正式剧情推进真相。
- 验证:`cargo test -p module-runtime-story --manifest-path server-rs\Cargo.toml battle_tests -- --nocapture` 应覆盖战斗终局持久化 `story_continue_adventure``deferredOptions`、下一幕 act,以及 `idle_travel_next_scene` 真正切换场景。
- 关联:`server-rs/crates/module-runtime-story/src/session_action.rs``server-rs/crates/module-runtime-story/src/post_battle.rs``server-rs/crates/module-runtime-story/src/battle_tests.rs``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## RPG 战斗飘字不要只靠低对比红绿文字
- 现象:暗色或棕黑噪声背景下,战斗伤害飘字看起来像背景纹理,尤其是远端敌人头顶的小号红字几乎不可读。
- 原因:旧 `CombatFloatingNumber` 主要依赖 `text-rose-200` / `text-emerald-200` 和 8px 同色 glow;在暗红、棕黑、像素噪声背景上,颜色与背景混在一起,1px 深色描边也不足以形成轮廓。
- 处理:飘字本体使用高亮近白文字、小面积半透明深色底、明显深色描边和多层黑色阴影;只增强瞬时反馈,不新增说明面板,不遮挡主要战斗画面。
- 验证:`npm run test -- src/components/game-canvas/GameCanvasEntityLayer.test.tsx` 覆盖伤害/治疗飘字样式策略;运行态截图中敌方头顶伤害数字应能在暗场景上辨认。
- 关联:`src/components/game-canvas/GameCanvasEntityLayer.tsx``docs/【项目基线】当前产品与工程约束-2026-05-15.md`
## 弹窗里复用 CreativeImageInputPanel 要保留画面卡高度
- 现象:拼图草稿结果页的关卡详情弹窗中仍能看到“画面图”标题、画面描述和生成按钮,但实际画面图卡片视觉上消失。
- 原因:`CreativeImageInputPanel` 内部依赖 `flex-1``h-full``max-h-full` 撑开正方形画面卡;放进弹窗里的普通 `section` 后,父级没有可计算高度,卡片会被压到不可见。
- 处理:通用画面卡 `puzzle-image-upload-card` 保持 `aspect-square` 的同时设置稳定 `min-height`,让入口页和关卡详情弹窗都能显示主图/上传区。
- 验证:`npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx -t "opens an independent level detail dialog"` 应断言关卡详情中的 `.puzzle-image-upload-card` 具备最小高度类;`npm run test -- src/components/common/CreativeImageInputPanel.test.tsx` 应继续通过。
- 关联:`src/components/common/CreativeImageInputPanel.tsx``src/components/puzzle-result/PuzzleResultView.tsx``src/components/puzzle-result/PuzzleResultView.test.tsx`
## 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 目标机。
- 验证:日志应显示 `curl 断点续传 ... resumeBytes=...`,最终出现 `已下载 ... bytes=...`;目标 Linux 阶段只消费 `stash/unstash` 带过去的下载件。
- 关联:`jenkins/Jenkinsfile.production-server-provision``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## OTLP 端点只填 Collector HTTP base endpoint
- 现象:生产或容器 env 里把 `OTEL_EXPORTER_OTLP_ENDPOINT` 填成 `4317`、Rider 端口或别的非 HTTP base endpoint 后,api-server 发不出 OTLP,或者链路被错误转发。
- 原因:api-server 当前走 OTLP HTTP,不是 gRPCCollector 才是接收和转发边界。
- 处理:生产模板用 `http://127.0.0.1:4318`,容器模板用 `http://otelcol:4318`;需要关闭时显式设 `GENARRATIVE_OTEL_ENABLED=false`,不要通过改 endpoint 绕开 Collector 语义。
- 验证:检查 env 模板和运行态配置都指向 Collector HTTP base endpoint,日志仍通过 `journalctl` / 文件日志保留。
- 关联:`deploy/env/api-server.env.example``deploy/container/api-server.env.example``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## tracking outbox 到批量阈值后先封存再异步 flush
- 现象:route tracking 高峰时如果主请求线程要等 SpacetimeDB 批量入库,接口延迟会被 outbox 写入链路拖长。
- 原因:outbox 的职责是把普通 HTTP route tracking 从请求线程切走,不能把 flush 结果回写成同步阻塞。
- 处理:达到 `BATCH_SIZE` 立即封存 active 文件并切新 active`FLUSH_INTERVAL_MS` 只做兜底封存,后台 worker 异步 flush sealed 文件;成功删文件,失败保留重试,坏文件隔离为 `corrupt-*``MAX_BYTES` 只做磁盘保护。
- 验证:普通 route 请求在 SpacetimeDB 不可用时仍能返回,恢复后 sealed 文件会继续被清理。
- 关联:`server-rs/crates/api-server/src/tracking_outbox.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 跳一跳推荐页匿名直玩要同步放行 runtime 路由和埋点
- 现象:推荐页能看到跳一跳公开卡片,但未登录点击后会被登录门禁拦住,或者进入运行态后没有 `work_play_start` 记录。
- 原因:前端只改了展示层登录门禁,后端 runtime 路由仍要求 bearer auth,或 tracking helper 仍把匿名请求当成无效输入直接丢弃。
- 处理:`/api/runtime/jump-hop/runs``/jump``/restart` 改为可选鉴权;未登录时直接允许启动、跳跃和重开,同时让 `work_play_tracking` 接受 `Option` 用户身份并在 metadata 中标记匿名语义,不要伪造 userId。
- 验证:未登录推荐页可以直接进入跳一跳运行态,且 `work_play_start` 事件仍会落库或出现在 outbox 中,metadata 含匿名标记。
- 关联:`server-rs/crates/api-server/src/jump_hop.rs``server-rs/crates/api-server/src/auth.rs``server-rs/crates/api-server/src/work_play_tracking.rs``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`
## 跳一跳直接打开空 runtime 路由不能停在加载态
- 现象:直接访问 `/runtime/jump-hop` 时页面看起来一直停在“正在载入游戏 / 正在加载内容”,DOM 内部只有空的跳一跳运行态,没有平台、地块或 run 数据。
- 原因:`appPageRoutes` 会把该路径解析为 `jump-hop-runtime`,但裸路径没有 `work=JH-*` 公开作品码,也没有从详情页启动后写入的 `jumpHopRun`,平台壳仍挂载 `JumpHopRuntimeShell`
- 处理:平台壳在 `jump-hop-runtime` 且缺少 run 时先看 `work` 参数;有 `JH-*` 则通过公开 gallery detail 回读 profile 并启动 published run,没有则回到平台首页。全局作品码恢复 effect 在跳一跳 runtime 阶段要跳过,避免和运行态恢复互相抢路由。
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct jump hop runtime route"`;浏览器 smoke 分别打开 `/``/runtime/jump-hop``/runtime/jump-hop?work=JH-*`
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/routing/appPageRoutes.ts``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`
## release tracking outbox 权限错误先查 env 缺失
- 现象:release 机器 `journalctl -u genarrative-api.service` 每秒刷 `tracking outbox 定时封存 active 文件失败 error=Permission denied (os error 13)``tracking outbox 批量写入 SpacetimeDB 失败`
- 原因:旧 `/etc/genarrative/api-server.env` 没有 `GENARRATIVE_TRACKING_OUTBOX_DIR` 时,api-server 会回退到本地开发默认相对路径 `server-rs/.data/tracking-outbox`;systemd 工作目录是只读发布目录 `/opt/genarrative/releases/<version>``genarrative` 用户不能在其中创建 `server-rs`
- 处理:补齐 `GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox` 及 batch/flush/max 配置,创建并授权 `/var/lib/genarrative/tracking-outbox``genarrative:genarrative`,再重启 `genarrative-api.service`。Server-Provision 与 API-Deploy 会保留旧 env 但自动补缺这些运行态路径。
- 验证:`tr '\0' '\n' < /proc/$(systemctl show genarrative-api.service -p MainPID --value)/environ | grep GENARRATIVE_TRACKING_OUTBOX_DIR` 应指向 `/var/lib/genarrative/tracking-outbox`;重启后当前 PID 不再出现 `Permission denied (os error 13)`
- 关联:`scripts/deploy/production-api-deploy.sh``scripts/jenkins-server-provision.sh``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## release otelcol 217/USER 和备份 timer inactive 分开处理
- 现象:release 巡检中 `otelcol-contrib.service` 持续 `activating (auto-restart)`,日志出现 `status=217/USER` / `Failed to determine user credentials`;同时 `genarrative-database-backup.timer` 显示 `enabled``inactive/dead``NEXT` / `Trigger` 为空。
- 原因:otelcol 的 systemd unit 使用 `User=otelcol` / `Group=otelcol`,但目标机缺少该系统用户和 `/etc/otelcol/genarrative-debug.yaml`;备份 timer 在 missed window 后未处于 active waiting 状态,直接重启 Persistent timer 可能在白天立刻补跑冷备份并停止 SpacetimeDB。
- 处理:先创建系统用户 / 组 `otelcol`,补齐 `/var/lib/otelcol``/etc/otelcol/genarrative-debug.yaml``/var/log/genarrative`,再重启 `otelcol-contrib.service`;修 timer 时先 `touch /var/lib/systemd/timers/stamp-genarrative-database-backup.timer`,再 `systemctl daemon-reload && systemctl start genarrative-database-backup.timer`,避免当前窗口立即补跑冷备份。
- 验证:`otelcol-contrib.service``active (running)` 且监听 `127.0.0.1:4317/4318``systemctl list-timers genarrative-database-backup.timer --all` 显示下一次触发约为次日 `03:20``/healthz``/readyz``/v1/ping` 仍通过。
- 关联:`scripts/jenkins-server-provision.sh``deploy/systemd/otelcol-contrib.service``deploy/otelcol/genarrative-debug.yaml``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 外部 API 失败没法追溯先查 external_api_call_failure
- 现象:VectorEngine 图片生成 / 编辑接口对前端只表现为 `502` / `504` 或“上游服务请求失败”,但难以区分是请求发送失败、上游 429/5xx、响应解析失败、未返回图片,还是下载图片失败。
- 原因:外部 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、errorSource、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt 和 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`
## VectorEngine 图片协议先看 platform-image,不要先翻 puzzle.rs
- 现象:排查拼图或其它玩法的生图失败时,如果直接在 `api-server` 的大文件里找 `images/generations``images/edits`、base64 解码或下载逻辑,会看到很多历史 helper 和测试桥,看起来像每个玩法都自带一份 provider 实现。
- 原因:旧实现把 VectorEngine 图片 provider 协议、响应解析、下载和日志混在 `api-server` 里,后来虽然迁出到 `platform-image`,但兼容层和测试 helper 仍会让人误判真相源位置。
- 处理:先看 `server-rs/crates/platform-image/src/vector_engine/``request.rs` 查路径和请求体,`client.rs` 查生成 / 编辑编排,`transport.rs` 查 HTTP client 与 reqwest 错误归一,`payload.rs` 查响应字段提取,`response.rs` 查上游状态、解析、缺图和下载分流,`image_source.rs` 查参考图和远端图片下载。再看 `server-rs/crates/api-server/src/openai_image_generation.rs` 的兼容桥和 `external_api_audit.rs` 的落库映射;`puzzle/vector_engine.rs` 只保留玩法编排,不再作为 provider 协议真相源。
- 验证:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml``cargo test -p platform-image --test vector_engine --manifest-path server-rs/Cargo.toml``cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml -- --nocapture` 通过时,排障先按 `platform-image` 的日志字段查 provider / endpoint / failure_stage。
- 关联:`server-rs/crates/platform-image/src/vector_engine/``server-rs/crates/api-server/src/openai_image_generation.rs``server-rs/crates/api-server/src/external_api_audit.rs``server-rs/crates/api-server/src/puzzle/vector_engine.rs`
## 音频 provider 协议先看 platform-audio,不要先翻 api-server 大文件
- 现象:排查 Visual Novel 或通用创作音频生成失败时,如果直接打开 `api-server/src/vector_engine_audio_generation.rs`,会同时看到路由、计费、asset binding、下载、解析和 provider 协议,定位时很容易在同一个文件里来回跳。
- 原因:音频 provider 已经迁到 `server-rs/crates/platform-audio/`,但 `api-server` 仍保留薄 wrapper;如果把 wrapper 当真值源,就会误判边界。
- 处理:先看 `server-rs/crates/platform-audio/src/client.rs``request.rs``response.rs``download.rs``persist.rs``error.rs`,再看 `api-server/src/vector_engine_audio_generation.rs` 的路由、配置、计费、asset object confirm 和 entity binding 包裹。
- 验证:`cargo test -p platform-audio --manifest-path server-rs/Cargo.toml` 通过,且 `cargo check -p api-server --manifest-path server-rs/Cargo.toml` 保持绿灯。
- 关联:`server-rs/crates/platform-audio/``server-rs/crates/api-server/src/vector_engine_audio_generation.rs`
## Hyper3D 现在只剩后端薄代理,不要再把协议解析写回 api-server
- 现象:排查 Hyper3D/Rodin 时,如果继续在 `api-server/src/hyper3d_generation.rs` 里扩协议解析、请求体构造或下载列表处理,文件会重新变厚。
- 原因:`platform-hyper3d` 已经承接 Rodin 的提交、状态和下载协议解析;`api-server` 只是薄 wrapper 和错误 envelope 映射。
- 处理:新增或修改 Hyper3D 协议时优先放到 `server-rs/crates/platform-hyper3d/``client.rs``request.rs``response.rs``transport.rs` 和子模块,`api-server` 只保留鉴权、配置校验和错误映射。
- 验证:`cargo test -p platform-hyper3d --manifest-path server-rs/Cargo.toml` 通过后再看 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/platform-hyper3d/``server-rs/crates/api-server/src/hyper3d_generation.rs`
## release 创作接口 413 先查是否还在提交 Data URL
- 现象:release 上 `POST /api/runtime/puzzle/agent/sessions/{session_id}/actions` 携带参考图 Data URL 时返回 `413 Request Entity Too Large`access log 显示 `request_time=0.000``upstream_status=-`
- 原因:Nginx 默认 `client_max_body_size` 只有 1 MiB,请求在反代层被拒绝,根本没有到达 `api-server`;即使模板放宽到 `64m`,把图片 base64 放进创作 JSON body 仍会放大请求体并把上限问题推给下一层。
- 处理:长期修复不是继续调大 Nginx,而是让浏览器先走 `/api/assets/direct-upload-tickets` 直传 OSS,再 `/api/assets/objects/confirm` 确认 `asset_object`,拼图 action 只提交 `referenceImageAssetObjectId(s)`;后端校验 owner / bucket / kind / MIME / size 后签只读 URL 给 VectorEngine。Nginx `client_max_body_size 64m` 只保留为旧客户端和兼容输入兜底,发布后仍需 `nginx -t && nginx -s reload`
- 验证:前端 action payload 不应再出现大段 `data:image/...;base64``nginx -T 2>/dev/null | grep client_max_body_size` 可确认反代兜底;再次提交参考图时 access log 应有正常 `upstream_status`,后端测试 `puzzle_reference_image_sources_prefer_asset_object_ids` / `puzzle_asset_object_reference_requires_matching_owner` 应通过。
- 关联:`src/services/puzzle-works/puzzleAssetClient.ts``server-rs/crates/api-server/src/puzzle/vector_engine.rs``deploy/nginx/genarrative.conf``deploy/nginx/genarrative-dev-http.conf``deploy/container/nginx.conf``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 汪汪声浪入口不要再回到独立配置阶段
- 现象:汪汪声浪入口如果继续切换到独立配置阶段,会和拼图、抓大鹅的创作页内嵌结构不一致,用户会感觉入口跳页。
- 原因:旧实现把 `bark-battle` 单独挂到 `bark-battle-config` selectionStage,而不是复用创作 Tab 里的模板区。
- 处理:入口点击只设置 `activeCreationFormType = 'bark-battle'` 并回到创作 Tab`BarkBattleConfigEditor` 作为内嵌表单使用,默认隐藏返回按钮和页面标题;runtime `onExit` 重新回到创作 Tab 的汪汪声浪模板。
- 验证:点击汪汪声浪后直接看到创作页内嵌表单,不再出现独立配置页;测试应覆盖内嵌表单与 runtime 返回路径。
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/bark-battle-creation/BarkBattleConfigEditor.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`
## 汪汪声浪发布态不要丢失结果页最终素材
- 现象:结果页上传或批量生成玩家形象、对手形象、UI 背景后,发布进入正式 runtime 仍可能显示初始草稿素材或兜底视觉。
- 原因:`publish_bark_battle_work` 如果只把结果页最终状态保存到 `published_snapshot_json`,但正式 runtime 读取的 `config_json` 仍来自草稿行旧值,就会丢失结果页局部替换。
- 处理:发布时把最终 `publishedSnapshot` 解析为 `BarkBattleEditorConfigSnapshot`、规范化后同时写入 `bark_battle_published_config.config_json``published_snapshot_json`;首轮自动生成只由 `bark-battle-generating` 负责,结果页仅覆盖已接入的玩家形象、对手形象和竞技背景图片槽位,不再提供音频配置入口。
- 验证:发布后 runtime config 应包含结果页最终 `playerCharacterImageSrc``opponentCharacterImageSrc``uiBackgroundImageSrc`
## 汪汪声浪 v1 生成页和正式运行态要分开
- 现象:如果把初始三图自动生成、结果页修补、公开发布和正式运行态混在一页,创作者容易误以为一次生成和正式运行是同一职责。
- 原因:`bark-battle-generating` 才应该承担玩家形象、对手形象和竞技背景的自动生成;结果页只做单槽修补,正式 runtime 又必须切到真实麦克风和正式统计。
- 处理:表单提交后先进入独立生成页,部分失败仍进结果页;结果页只保留单槽重试、重新生成和上传,不再保留一次生成按钮、音频配置入口、皮肤预设入口或排名配置。发布后先到统一作品详情页,再进正式 runtime;草稿试玩允许 mock,不写正式 run。
- 验证:生成页负责首轮自动产出三图;结果页不出现一次生成按钮、音频配置入口、皮肤预设入口或排名配置;正式 runtime 必须麦克风可用且会写正式 run,草稿试玩不写正式统计。
## 汪汪声浪生成页不要只停留在前端内存草稿
- 现象:点击“生成草稿”后生成页一直转圈,或刷新 / 回到草稿架后看不到三图素材。
- 原因:生成页只在前端内存里合并玩家形象、对手形象和竞技背景,没有把生成结果写回 `bark_battle_draft_config.config_json`;另外 BFF 若在刚创建草稿后先读 `spacetime-client` 订阅 cache 再保存,cache 可能短暂落后,导致保存失败或返回旧快照。
- 处理:生成页三图完成后调用 `POST /api/creation/bark-battle/drafts/{draftId}/config` 持久化;保存接口直接把请求快照交给 SpacetimeDB procedure,由模块事务校验 owner / work,并在 HTTP 回包用本次请求里的三图字段覆盖,避免订阅 cache 滞后;保存请求必须设置前端超时,保存失败也进入结果页并标记部分失败。
- 验证:`npm run test -- src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx src/services/bark-battle-creation/barkBattleCreationClient.test.ts src/components/bark-battle-creation/BarkBattleResultView.test.tsx packages/shared/src/contracts/barkBattle.test.ts``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "bark battle"``cargo check --manifest-path server-rs\Cargo.toml -p api-server`
- 关联:`src/components/bark-battle-creation/BarkBattleGeneratingView.tsx``src/services/bark-battle-creation/barkBattleCreationClient.ts``server-rs/crates/api-server/src/bark_battle.rs``server-rs/crates/spacetime-module/src/bark_battle.rs`
## 汪汪声浪三图不要复用 RPG 场景图链路
- 现象:玩家形象和对手形象看起来走了场景图片 prompt;生成页三个槽位同时转圈,但只有第一个真实生成,首图返回后三个槽位一起停止或只显示首图。
- 原因:前端曾复用 `/api/runtime/custom-world/scene-image`,三类素材都被当成 RPG landmark scene image;生成页又只用父级 draft 判断 ready,批量 Promise 结束后才一次性合并结果,缺少逐槽状态。
- 处理:Bark Battle 生图统一走 `POST /api/creation/bark-battle/images/generate`,请求体包含 `slot` 和 v1 配置;后端在 `api-server/src/bark_battle.rs``player-character``opponent-character``ui-background` 分别拼装正式 prompt,写入 `generated-bark-battle-assets`,并返回 `prompt/actualPrompt`。前端 `generateAllBarkBattleImageAssets` 保持三槽 `Promise.allSettled` 并通过 `onSlotComplete` 逐槽刷新生成页状态。
- 验证:`npm run test -- src/services/bark-battle-creation/barkBattleCreationClient.test.ts src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx packages/shared/src/contracts/barkBattle.test.ts``cargo test -p shared-contracts bark_battle --manifest-path server-rs\Cargo.toml``cargo check --manifest-path server-rs\Cargo.toml -p platform-oss -p api-server`
- 关联:`src/services/bark-battle-creation/barkBattleCreationClient.ts``src/components/bark-battle-creation/BarkBattleGeneratingView.tsx``server-rs/crates/api-server/src/bark_battle.rs``server-rs/crates/platform-oss/src/lib.rs`
## 抓大鹅批量重新生成物品不要新增 itemId
- 现象:结果页批量重新生成物品后,试玩或正式运行态的物品类型和图片对应关系漂移,或者用户输入一个不存在名称后被当作新物品追加。
- 原因:重新生成和批量新增共用 `item-assets` 接口,如果前端不传 `mode = "replace"`,或后端替换时重新分配 `itemId` / 追加未匹配名称,就会破坏 `generatedItemAssets` 顺序和运行态类型映射。
- 处理:批量重新生成只提交当前素材列表中能匹配到的名称,并传 `mode = "replace"`;后端只对同名已有素材生成新图片,合并时保留原 `itemId``itemName`、模型兼容字段、UI 背景和历史音频字段,未匹配名称直接忽略且不计费。
- 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx` 覆盖前端提交口径,`cargo test -p api-server match3d_item_asset --manifest-path server-rs\Cargo.toml``cargo test -p api-server match3d_regenerated_asset --manifest-path server-rs\Cargo.toml` 覆盖后端替换计划与身份保留。
- 关联:`src/components/match3d-result/Match3DResultView.tsx``server-rs/crates/api-server/src/match3d.rs``packages/shared/src/contracts/match3dWorks.ts``server-rs/crates/shared-contracts/src/match3d_works.rs``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅生成封面图不要覆盖物品素材或配置
- 现象:结果页生成封面图后,`素材配置 > 物品` 中已有物品素材被清空、回退旧快照,或难度 / 消除次数被改回旧值。
- 原因:封面生成属于定向图片槽位更新;若后端复用草稿编译写回,可能按 session config 重算作品行。即使后端已修正,前端若直接把封面接口返回的整份 `item` 当成最新 profile,也可能用旧回包里的空 `generatedItemAssets` 覆盖当前页面素材。
- 处理:`POST /api/creation/match3d/works/{profileId}/cover-image` 只保存 `coverImageSrc` / `coverAssetId` 等封面字段,保留当前 `generated_item_assets_json`、难度、消除次数、题材和描述;前端收到回包后只合并 `coverImageSrc`,继续保留当前可见 `generatedItemAssets``clearCount``difficulty`
- 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx` 覆盖旧回包不覆盖物品素材和配置;`cargo test -p api-server match3d_cover --manifest-path server-rs\Cargo.toml` 覆盖封面提示词与参考图链路。
- 关联:`src/components/match3d-result/Match3DResultView.tsx``server-rs/crates/api-server/src/match3d.rs``server-rs/crates/spacetime-module/src/match3d.rs``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## OSS V4 签名时间和 bucket/object_key 兼容
- 现象:OSS V4 私有读签名在部分时间点失败,可能出现 `OSS V4 签名时间格式化失败` 或服务端判定签名格式错误;排查用例中 bucket 为 `xushi-dev`object_key 为 `generated-square-hole-assets/.../image.png`
- 原因:旧逻辑依赖 `time::Time::to_string()` 再去掉冒号,小时小于 10 时输出不稳定补零;同时排查时容易把 bucket 名误当成 object_key 的一部分。
- 处理:OSS V4 `x-oss-date` 使用固定宽度 `yyyyMMdd'T'HHmmss'Z'` 格式化;调用读签名或 `HEAD Object` 时只传 object_key,不要传 `bucket/object_key` 拼接路径。
- 验证:运行 `cd server-rs && cargo test -p platform-oss -- --nocapture`,并用 bucket=`xushi-dev`、object_key=`generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png` 覆盖签名生成。
- 关联:`server-rs/crates/platform-oss/src/lib.rs``server-rs/crates/platform-oss/README.md`
## generated 音频路径进运行态前要先换签
- 现象:草稿页 audio 控件能播放背景音乐,但拼图或抓大鹅运行态开局后背景音乐不响,Network 可能出现裸 `/generated-*-assets/...mp3` 私有路径 403。
- 原因:生成音乐转存到 OSS 私有对象后,`audioSrc` 是 generated legacy path;浏览器 `<audio>` 不能像公开静态资源一样直接请求裸路径。另一个常见误判是浏览器拒绝自动播放,资源已经进入运行态但开局第一次 `audio.play()` 被拦截。
- 处理:结果页试听控件和运行态隐藏 `<audio>` 设置 `src` 前,都先通过 `useResolvedAssetReadUrl``resolveAssetReadUrl` 换签;签名未就绪时不要回退请求裸 generated 路径。运行态自动播放失败只静默兜底,但玩家首次按下拼图块或点击抓大鹅物品时要重试同一个背景音乐播放函数。拼图读取 `currentLevel.backgroundMusic.audioSrc`,抓大鹅读取 `generatedItemAssets[].backgroundMusic.audioSrc`
- 验证:结果页试听和运行态 `<audio loop>``src` 为签名 URL 或公开 URL;拼图/抓大鹅运行态首次局内交互后会再次尝试播放背景音乐;`npm run typecheck` 不报契约字段缺失,后端 run response 带 `backgroundMusic`
- 关联:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx``src/components/match3d-runtime/Match3DRuntimeShell.tsx``docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md`
## 抓大鹅背景音乐是作品级字段但暂存在首个物品素材
- 现象:抓大鹅草稿生成日志和 work detail 中已有背景音乐,但结果页 `素材配置 > 背景音乐` 显示“暂无音乐”,点击试玩后局内也不播放生成音乐。
- 原因:当前表结构没有作品级音频字段,背景音乐暂存在 `generatedItemAssets[]`。如果 action response 的 draft assets 缺音乐,前端又优先用它覆盖 work detail,或音乐落在非首个素材而结果页只读 `assetDrafts[0].backgroundMusic`,就会丢掉已生成音乐。
- 处理:前端统一使用 `normalizeMatch3DGeneratedItemAssetsForRuntime` / `mergeMatch3DGeneratedItemAssetsForRuntime`:把任意素材上的 `backgroundMusic` 与音乐元信息迁移到首个素材,清空其它素材上的作品级音乐字段;action draft assets 与 work detail assets 按 `itemId` 合并,保留详情里的音乐、UI 背景和点击音效。
- 验证:`npm run test -- src\services\match3dGeneratedModelCache.test.ts src\components\match3d-result\Match3DResultView.test.tsx src\components\match3d-runtime\Match3DRuntimeShell.test.tsx`;平台推荐流定向跑 `RpgEntryFlowShell.agent.interaction.test.tsx` 中的 Match3D runtime assets 用例;`npm run typecheck`
- 关联:`src/services/match3dGeneratedModelCache.ts``src/components/match3d-result/Match3DResultView.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 中文乱码与编码风险
- 现象:中文文案、注释、剧情或文档显示为乱码,或被改写成英文。
- 原因:Windows/PowerShell/终端编码不一致,或整文件重写导致编码变化。
- 处理:
- 不要直接沿用乱码文本。
- 不要用英文替换中文,除非用户明确要求翻译。
- 在 PowerShell 5.1 中显式使用 UTF-8。
- 优先用 Python/Node 或 `Get-Content -Encoding UTF8` 核对原文。
- 修改中文文件时优先局部补丁,避免无关内容重写。
- 验证:运行仓库已有编码检查;人工抽查修改文件中的中文内容。
- 关联:`AGENTS.md``npm run check:encoding`
## SpacetimeDB 运行态查询不要绕过已有索引或用 procedure JSON 回传
- 现象:运行态接口看起来只查当前用户、作品或任务,却在 `spacetime-module` 中使用 `ctx.db.<table>().iter().filter(...)` 整表遍历;或者 procedure result 返回 `items_json/run_json/work_json` 等 JSON 字符串,`spacetime-client` mapper 再反序列化成旧兼容结构。
- 原因:新增索引或 typed snapshot 后,没有同步清理旧 mapper / 测试兼容层,也没有用静态检查拦截回退写法。
- 处理:表上已有主键、unique 或 `#[index]` 覆盖查询前缀时,先用对应 accessor `.find(...)` / `.filter(...)`,只对索引无法覆盖的条件做内存残余过滤;procedure result 返回 typed snapshot / typed value,不再跨层传 `*_json: Option<String>` 作为 payload。
- 验证:执行 `npm run check:spacetime-runtime-access``npm run check:server-rs-ddd`,涉及绑定变化时先执行 `npm run spacetime:generate``npm run check:spacetime-schema`
- 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``scripts/check-spacetime-runtime-access.mjs``server-rs/crates/spacetime-module/src/*``server-rs/crates/spacetime-client/src/mapper.rs`
## 拼图广场列表不要每次 HTTP 请求调用 SpacetimeDB procedure
- 现象:`/api/runtime/puzzle/gallery` 每个请求都走 `spacetime-client.list_puzzle_gallery()` 调用 SpacetimeDB procedure,导致 SpacetimeDB WASM 侧重复组装全量列表,客户端再映射一遍;历史实现还出现过 procedure JSON 字符串往返。
- 原因:`api-server` 的服务器端 `spacetime-client` 没有订阅可公开读取的 gallery 投影,虽然 SDK 支持 client cache,但请求路径仍把列表读取当作 procedure 调用。
- 处理:`spacetime-module` 中用 public view `puzzle_gallery_card_view` 暴露已发布拼图作品的列表卡片字段,不携带 `levels` / `anchor_pack` 等详情级载荷;`spacetime-client` 建连接后订阅 `SELECT * FROM puzzle_gallery_card_view``SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'` 并等待 `on_applied`。HTTP gallery 通过 `PuzzleGalleryCache` 缓存最终 `PuzzleGalleryResponse` DTO`items` 返回前 10 个完整卡片,`previewRefs` 返回后 10 个作品号引用,cache miss / TTL 过期时单飞重建,后台 cleanup task 周期清理旧响应。旧 `list_puzzle_gallery` procedure 只作兼容,不再作为 HTTP gallery 主路径。
- 验证:搜索 `server-rs/crates/spacetime-client/src/puzzle.rs` 不应再出现 gallery 主路径调用 `list_puzzle_gallery_then`;搜索 `server-rs/crates/spacetime-client/src/lib.rs` 应订阅 `puzzle_gallery_card_view`;执行 `npm run spacetime:generate``cargo check --manifest-path server-rs/Cargo.toml -p spacetime-client``cargo check --manifest-path server-rs/Cargo.toml -p api-server` 和 schema/runtime access 检查。
- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs``server-rs/crates/spacetime-client/src/lib.rs``server-rs/crates/spacetime-client/src/puzzle.rs``server-rs/crates/api-server/src/puzzle_gallery_cache.rs``/api/runtime/puzzle/gallery`
## Windows 本地直连高 VU 压测不要误判成业务内存泄漏
- 现象:本地 Windows release `api-server` 直连 K6 压测时,250 RPS、`PREALLOCATED_VUS=300` 能把进程 private memory 瞬时推到约 7GB;同样配置打 `/healthz` 小响应也能复现,压测结束后回落到 100MB 级。
- 原因:高水位主要来自本机直连的 K6 VU / 长连接 / Hyper 发送链路和 Windows 连接缓冲,不是 SpacetimeDB procedure、拼图 JSON 缓存或 OTEL exporter。降低到接近真实并发的 VU 后,同样 250 RPS 拼图广场 p95 约 9ms,峰值约 600MB。
- 处理:本地容量判断时让 `PREALLOCATED_VUS` / `MAX_VUS` 接近真实并发,不要把过高 VU 预分配当作默认吞吐测试;同时观察 `process.memory.*``process.windows.handle.count``genarrative.http.server.response_bodies.in_flight``genarrative.http.server.request_permits.available``genarrative.puzzle_gallery.cache.*``genarrative.spacetime.read.*`。如果内存高但 body in-flight、背压 permit、cache rebuild 和 SpacetimeDB read 都不显示积压,优先按连接 / 发送链路高水位处理。
- 验证:对照打 `/api/runtime/puzzle/gallery``/healthz`;对比 `PREALLOCATED_VUS=300 MAX_VUS=800``PREALLOCATED_VUS=20 MAX_VUS=40`;压测结束后继续采样 10 秒确认 private memory 回落。
- 关联:`scripts/loadtest/README.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md``server-rs/crates/api-server/src/process_metrics.rs``server-rs/crates/api-server/src/telemetry.rs`
## 容器高 VU 下 `/healthz` RSS 尖峰先查 Axum state 深拷贝
- 现象:容器 Linux release `api-server``/healthz`500 HTTP req/s、`PREALLOCATED_VUS=100` 只跑 1 秒也能把 RSS 推到约 1 GiB;同样问题与作品列表、SpacetimeDB procedure、业务 cache 和请求日志等级无关。
- 原因:`AppState` 曾直接 `#[derive(Clone)]` 大结构体,里面包含配置、SpacetimeDB client、平台服务、认证服务和多组 cache。Axum/Hyper 会在 router/service/connection 路径频繁 clone state,高并发 keepalive 下会放大为状态深拷贝高水位。
- 处理:`server-rs/crates/api-server/src/state.rs``AppState` 必须保持 `Arc<AppStateInner>` 浅拷贝壳;新增共享状态字段时放入 `AppStateInner`,不要把外层改回大结构体 clone。
- 验证:用容器内 k6 直连 `api-server:8082/healthz`500 HTTP req/s、`PREALLOCATED_VUS=100`、30 秒压测后采样 `/proc/$pid/status``/proc/$pid/smaps_rollup` 和 cgroup `memory.current/memory.peak`。2026-05-18 修复后结果为 `15001` 请求、`http_req_failed=0``dropped_iterations=0`RSS 约 18 MiB -> 52 MiBcgroup peak 约 47 MiB。
- 关联:`server-rs/crates/api-server/src/state.rs``deploy/container/README.md``deploy/container/api-server.Dockerfile`
## Gallery 压测延迟升高先查入口过量放行和 TTL 边界刷新
- 现象:公开作品列表在 500-1000 HTTP req/s 附近可能吞吐没有明显提升,但 p95 变高、VU 上升,甚至出现排队和 dropped iterations。
- 原因:Nginx、Axum 和缓存刷新边界如果同时允许过多请求进入,压力会先堆在连接、service 和 cache rebuild 周围;这类延迟不等同于数据库连接池不足。
- 处理:Nginx 按 endpoint 使用 `limit_req` 快拒绝,api-server 按 `default/gallery/detail/admin` 分组 semaphore 快拒绝;拼图广场 TTL 过期时已有缓存先返回 stale 响应,只允许一个后台 refresh 任务重建,冷启动无缓存时才同步构建。
- 验证:OTLP 看 `genarrative.http.server.request_permits.available{pool=...}``genarrative.puzzle_gallery.cache.stale_hits``refreshes_started``refreshes_failed`Nginx access log 看 `request_time``upstream_response_time` 是否同步收敛;超过容量时应明确 429,而不是长时间排队或新增 502。
- 关联:`deploy/nginx/genarrative.conf``deploy/container/nginx.conf``server-rs/crates/api-server/src/backpressure.rs``server-rs/crates/api-server/src/puzzle_gallery_cache.rs`
## 多玩法公开广场列表优先订阅 public view / read model
- 现象:抓大鹅、方洞挑战、视觉小说、大鱼吃小鱼等公开列表如果沿用 `list_*_works` procedure,即使只读已发布作品,也会在每个 HTTP 请求里回到 SpacetimeDB WASM 侧扫描、反序列化配置并组装列表,50RPS 以上容易变成热点。
- 原因:个人作品列表和公开广场列表复用了同一套 procedure 输入,导致公开列表为了通过 owner 校验传固定占位 owner,并把可长期同步的公开读模型当成请求期查询。
- 处理:每个公开广场新增或复用专用 public view / public read model`match_3_d_gallery_view``square_hole_gallery_view``visual_novel_gallery_view``big_fish_gallery_view``spacetime-client` 建连接后订阅这些 view 和对应 `public_work_play_daily_stat` source_type 桶,HTTP gallery 只读本地 cache。个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍走原有 procedure / reducer。
- 验证:搜索 `server-rs/crates/spacetime-client/src/{match3d,square_hole,visual_novel,big_fish}.rs`,公开 gallery 主路径应读取 `connection.db().*_gallery_view()`,不应调用 `list_*_works_with_input`;执行 `npm run spacetime:generate``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`
- 关联:`server-rs/crates/spacetime-module/src/match3d.rs``server-rs/crates/spacetime-module/src/square_hole.rs``server-rs/crates/spacetime-module/src/visual_novel.rs``server-rs/crates/spacetime-module/src/big_fish/session.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 自定义世界广场和创作入口配置不要每次 HTTP 请求调用只读 procedure
- 现象:`/api/runtime/custom-world-gallery` 每次请求调用 `list_custom_world_gallery_entries` procedure;入口熔断中间件每个玩法请求调用 `get_creation_entry_config` procedure50RPS 以上会把 SpacetimeDB procedure 调用变成热点。
- 原因:`custom_world_gallery_entry``creation_entry_config``creation_entry_type_config` 已经是可订阅读模型或配置表,但 HTTP 路径仍按“请求到来再查 procedure”处理。
- 处理:`spacetime-client` 长连接订阅 `custom_world_gallery_entry``public_work_play_daily_stat``custom-world` 桶、`creation_entry_config``creation_entry_type_config`custom-world gallery 从本地 cache 排序并聚合 7 日播放数;入口配置优先读订阅 cache,cache 缺失时用最近一次成功内存快照,再兜底调用 `get_creation_entry_config` 完成旧库兼容。旧 `list_custom_world_gallery_entries` procedure 只允许作为旧库缺少 gallery 行时的一次性同步兜底。
- 验证:搜索 `server-rs/crates/spacetime-client/src/custom_world.rs`gallery 主路径应是 `read_after_connect` 读取 `custom_world_gallery_entry()`;搜索 `server-rs/crates/spacetime-client/src/runtime.rs``get_creation_entry_config` 应优先读取 `creation_entry_config()``creation_entry_type_config()`。执行 `cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/spacetime-client/src/lib.rs``server-rs/crates/spacetime-client/src/custom_world.rs``server-rs/crates/spacetime-client/src/runtime.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 陶泥儿 logo 生图慢请求先缩短 prompt 并单张串行
- 现象:使用 VectorEngine `gpt-image-2` 生成陶泥儿 logo 概念图时,部分 prompt 会超过 10 分钟仍无响应,或返回 `429` / `当前分组上游负载已饱和`;同一批次里后续图片会被前面的慢请求拖住。
- 原因:复杂抽象 logo prompt 同时包含品牌解释、禁用元素、中文结构和多重隐喻时,上游排队与生成时长不稳定;并发或批量运行会放大单条慢请求的影响。
- 处理:先 `--dry-run` 看请求体;真实生成时优先短 prompt、单一造型、单张串行或小批量。失败后不要反复重试同一长 prompt,先压缩到“一个主体 + 一个负形 + 颜色 + 禁用文字/播放键/聊天气泡”再跑。联系表中的中文标签不要通过 PowerShell 管道内联 Python 写入,容易因编码链路显示为问号,可改用英文标签或脚本文件方式。
- 验证:生成文件落在 `public/branding/taonier-logo-*/`,用 Pillow 检查图片尺寸和非空;执行 `node --check scripts/generate-taonier-logo-concepts.mjs``npm run check:encoding``git diff --check`
- 关联:`scripts/generate-taonier-logo-concepts.mjs``docs/design/TAONIER_BRAND_LOGO_CONCEPTS_2026-05-13.md`
## 忘记密码后仍提示手机号或密码错误先查认证投影同步
- 现象:用户通过“忘记密码”重设密码后,接口返回成功或页面进入登录态,但再次使用新密码登录仍提示“手机号或密码错误”;重启后还可能出现 `Bearer JWT 版本已失效`,日志里的 token version 与本地快照不一致。
- 原因:重置/修改密码会更新 `password_hash``password_login_enabled``token_version`,如果 API 层只更新本地 `InMemoryAuthStore`,没有调用 `sync_auth_store_tables_to_spacetime()``api-server` 重启时可能从旧的 SpacetimeDB 正式认证表恢复账号状态。
- 处理:`POST /api/auth/password/change``POST /api/auth/password/reset` 成功后必须同步正式认证表。2026-07-01 起,`auth_store_snapshot` 表和旧 JSON procedure 已删除;认证工作集只通过 typed projection 同步 `user_account` / `auth_identity` / `refresh_session`。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前成功同步 SpacetimeDB;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。
- 验证:执行 `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_projection_from_tables` 只导出正式认证表 projection`module-auth` 从 projection 恢复时必须丢弃指向不存在 `user_account` 的 identity、union 索引和 refresh session。运行时创建手机号用户前若发现手机号映射指向不存在的用户,应删除孤儿映射后继续创建,避免死锁态继续扩散。
- 验证:`cargo test -p module-auth projection --manifest-path server-rs/Cargo.toml``cargo test -p module-auth phone --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``import_auth_store_snapshot_json``export_auth_store_snapshot_from_tables`,或者把 `auth-store.json` 误当成认证恢复源。
- 原因:认证恢复已经彻底收口到 SpacetimeDB 正式表和 `module-auth` typed projection;本地文件持久化或 JSON 快照会和正式表投影打架,SpacetimeDB 不可用时还可能把旧快照回灌到用户表。
- 处理:先用 `npm run spacetime:generate` 刷新 bindings,确认 `server-rs/crates/spacetime-client/src/module_bindings.rs` 里已没有旧 snapshot table / procedure 导出;`module-auth` 只保留内存态和 projection view,不再写本地快照文件。
- 验证:`cargo check -p module-auth --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:spacetime-schema``npm run check:encoding`
## 抓大鹅生成页只显示服务暂不可用先查 reason 和外部服务配置
- 现象:点击生成抓大鹅草稿后,页面只提示“服务暂不可用”,或者本地 `npm run dev:api-server` 看似启动但生成接口不可用。
- 原因:配置缺失类错误通常在后端 `error.details.reason` 中给出具体缺项,前端如果只读 `details.message` 会吞掉原因;本地只配置 `ALIYUN_OSS_BUCKET` / `ALIYUN_OSS_ENDPOINT` 时,旧逻辑还会在启动期构造空 AccessKey 的 OSS 客户端并失败。抓大鹅新链路仍是 2D 生图切割,不需要也不应回退 Rodin/GLB。
- 处理:前端 API 错误展示优先读取 `details.reason`,再读取 `details.message`,避免底层 `error sending request` 覆盖真正可操作的配置或网络原因;`api-server` 只有在 OSS 四件套齐全时初始化 OSS 客户端,部分缺失只记 warning 并让具体 generated 上传/换签接口返回 `OSS 未完成环境变量配置`。抓大鹅素材、封面和背景生成在调用 VectorEngine 前先预检 OSS,并通过 `details.missingEnv` 列出缺项;真实生成需补齐 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 和完整 `ALIYUN_OSS_*` 四件套。抓大鹅 UI spritesheet 和物品 spritesheet 的提示词必须要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前统一扣成透明 PNG,避免运行态 alpha 连通域解析失败。
- 验证:`npm run test -- src/services/apiClient.test.ts` 覆盖 `details.reason``cargo test -p api-server state --manifest-path server-rs/Cargo.toml` 覆盖半配置 OSS 不阻断启动;`npm run dev:api-server` 后按实际 `GENARRATIVE_API_PORT` 请求 `/healthz`,不要默认打 `3100`
- 关联:`packages/shared/src/http.ts``server-rs/crates/api-server/src/state.rs``docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md``docs/technical/AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md`
2026-05-22 补充:抓大鹅“物品 spritesheet”不再按旧 Gemini `generateContent` / `5*5` sheet 路径排查;当前链路先用 `gpt-image-2` 无参考图生成 `9:16` 关卡整图,再以该关卡整图作为 multipart `image` 参考并发编辑生成 `1K 1:1` UI spritesheet、`1K 9:16` 背景图和 `2K 1:1` 物品 spritesheet。UI 与物品 spritesheet 都要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,上传 OSS 前通过后端透明化处理写入真实 alpha PNG。
## 抓大鹅发布按钮要先开发布面板,封面编辑收口到发布面板内
- 现象:抓大鹅结果页发布按钮看起来点不了,或者封面编辑仍然分散在作品信息 Tab 里,和拼图发布体验不一致。
- 原因:发布按钮被 `publishReady` 直接禁用,导致未满足门槛时无法进入发布检查面板;封面编辑仍挂在作品信息 Tab,不能和发布检查一起收口。
- 处理:发布按钮只受忙碌态控制,点击后始终打开独立发布面板;发布面板内先展示阻断项,再承载封面图上传 / AI 重绘 / 参考图编辑,满足条件后再点击 `发布到广场`
- 验证:`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx``npm run typecheck`
- 关联:`src/components/match3d-result/Match3DResultView.tsx``src/components/match3d-result/Match3DResultView.test.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## `.codex` 只放项目工具,不放个人 Codex 配置
- 现象:团队成员误把个人 Codex 配置、会话或密钥复制进仓库。
- 原因:仓库 `.codex/` 与个人 `~/.codex/` 名称相似。
- 处理:仓库 `.codex/` 只放可公开的 skills、插件资源、hooks 和配置模板;长期项目知识写入 `docs/project-memory/`,不提交 `.env``config.toml``sessions/``auth.json`
- 验证:提交前检查 `git diff -- .codex`,确认没有密钥、会话记录或个人路径敏感信息。
- 关联:`.codex/README.md`
## 儿童动作 Demo 卡在摄像头不可用或挥手不推进先查 mocap 消费链路
- 现象:`/child-motion-demo` 打开后即使 `http://127.0.0.1:8876/` 已启动,页面仍提示“摄像头暂不可用”,或到“打个招呼”、左右手挥动、站位步骤时真实硬件动作无法检测通过,只能用鼠标拖拽或键盘调试继续。
- 原因:浏览器摄像头视频流只是舞台背景;如果热身关把 `getUserMedia` 状态当成主动作数据源,或只在 gesture 阶段消费 `useMocapInput`,就会错过 mocap 的身体中心、动作名和手部坐标。
- 处理:确认 `src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 全热身流程启用 `useMocapInput`,页面主提示展示 mocap 动作数据源状态而不是浏览器摄像头状态;确认 `src/services/useMocapInput.ts` 能解析 `/stream` 包里的 `general.body.center_norm``actions/action/gesture/gestures/event/name/type``hands[]``leftHand/rightHand``left_hand/right_hand`、左右手标记和 `open_palm/grab` 状态。`/stream` 是 WebSocket,普通 HTTP 访问返回 404 不能当成服务不可用。
- 验证:运行 `npx vitest run src\services\useMocapInput.test.ts src\components\child-motion-demo\ChildMotionWarmupDemo.test.tsx`,并在本地硬件服务启动后进入 `/child-motion-demo` 实测站位、招手、左右手挥动和跳跃阶段。
- 关联:`src/services/useMocapInput.ts``src/components/child-motion-demo/ChildMotionWarmupDemo.tsx``docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`
## 儿童动作 Demo 左右手阶段误通过先查身体侧映射和手臂展开阈值
- 现象:热身关“挥动左手 / 挥动右手”阶段,用户只是手自然下垂、横向小幅抖动,或挥了相反侧手,也可能被判定通过。
- 原因:本地 mocap 的 handedness 当前按摄像头视角输出,不能直接当作用户身体左/右;同时左右手阶段的目标是确认现实空间安全,需要验证手臂向外打开和上下摆动角度,不能只看手部 `x` 轨迹范围。
- 处理:热身关中用户左手应消费 camera-right,用户右手应消费 camera-left;左右手阶段只在同侧肩肘腕外展、手腕非自然下垂、连续有效帧、横向范围、上下摆动范围、肩腕角度范围和上下方向变化全部达标时完成,并记录轨迹空间包络、角度范围和最大外展距离。
- 验证:运行 `npx vitest run src\components\child-motion-demo\ChildMotionWarmupDemo.test.tsx src\components\child-motion-demo\childMotionWarmupModel.test.ts`,确认相反侧手、自然下垂、单纯横向轨迹不会完成,真实展开上下摆动可以完成。
- 关联:`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx``src/components/child-motion-demo/childMotionWarmupModel.ts``docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`
## 儿童动作 Demo 角色轮廓抽搐先查 mocap 坐标防抖和渲染分层
- 现象:`/child-motion-demo` 中间半透明小人在真实硬件驱动下左右轻微来回摆,移动过程中看起来忽大忽小,用户很难稳定停在目标圆环内。
- 原因:`general.body.center_norm.x` 原始值逐包直接写入 `avatarX` 时,硬件坐标小噪声会直接驱动位置保持判定和 CSS 动画;如果角色外层同时承担横向定位和跳跃 `transform`,半透明 PNG 在移动时也更容易出现重采样抖动观感。
- 处理:mocap 身体中心进入角色位置前必须先 clamp,再经过小幅死区、低通阻尼和单包最大步长限制;键盘 A/D 调试输入仍保持即时。角色 DOM 外层只负责横向定位,内层 sprite 负责轮廓图和跳跃位移,避免同一层 `transform` 同时表达多种运动。
- 验证:运行 `npx vitest run src\components\child-motion-demo\ChildMotionWarmupDemo.test.tsx src\components\child-motion-demo\childMotionWarmupModel.test.ts src\services\useMocapInput.test.ts src\services\child-motion-demo\childMotionDebugInput.test.ts`,并用真实硬件进入站位阶段观察小幅身体晃动不会导致角色频繁左右跳动。
- 关联:`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx``src/index.css``docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`
## 宝贝识物选篮误触发先查多套判定和残余轨迹
- 现象:`宝贝识物` 运行态打开礼物盒或反馈结束后,当前物品被连续送入左侧或右侧篮子,或硬件动作名偶发命中导致未做明确横移动作也触发选篮。
- 原因:选篮如果同时消费 `wave_left_hand` / `wave_right_hand` / `wave` 动作名、连续横向轨迹和左右手固定篮子规则,或在 `correct` / `wrong` 反馈阶段继续累计手部状态,会把反馈期间残留移动或未知侧别手部误算成下一次选篮。
- 处理:宝贝识物当前选篮只允许“手先触碰中央物品 UI,物品绑定到该手,随后拖入左侧或右侧篮子区域”这一套路径;侧别为 `unknown` 的手部不参与抓取或选篮;反馈阶段清空持有状态,不在非 `active` 阶段累计输入。进入关卡和每次正确反馈结束后自动弹出物品,不再用 `open_palm -> grab` 抓握序列激活礼物盒。
- 补充:当前本地 mocap 的 handedness 是摄像头视角,宝贝识物仍需换算为用户身体视角以展示左右手:`rightHand` 坐标代表玩家左手,`leftHand` 坐标代表玩家右手。换算不再决定只能选择哪侧篮子;任意一只手都可以拖物品到任意篮子。键鼠调试保持鼠标左键=左手位置、右键=右手位置,也必须先触碰中央物品再拖入篮子。
- 验证:运行 `npm run test -- src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/services/useMocapInput.test.ts`,确认动作名负向测试、未知侧别负向测试、触碰前不能选篮和任意手拖入任意篮子用例通过。
- 关联:`src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx``docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`
## 宝贝爱画左右手反了先查 mocap 摄像头视角换算
- 现象:`宝贝爱画` 中真实硬件下左手指示器和右手画笔表现反向,用户抬右手却出现左手选色指示器,或抬左手却驱动画笔 / 橡皮。
- 原因:本地 mocap 的 handedness 当前按摄像头视角输出,不能直接当成用户身体左 / 右;宝贝爱画初版直接消费 `latestCommand.leftHand/rightHand`,漏做摄像头视角到用户身体视角的换算。
- 处理:宝贝爱画运行态消费 mocap 前先换算:`rightHand` 作为用户左手,用于颜色悬停和左手指示器;`leftHand` 作为用户右手,用于画笔 / 橡皮光标、绘制、擦除和工具切换。键鼠调试输入不做该换算,继续保持鼠标左键为左手、右键为右手。
- 验证:运行 `npm run test -- src/components/edutainment-runtime/BabyLoveDrawingRuntimeShell.test.tsx src/components/edutainment-runtime/babyLoveDrawingModel.test.ts`,确认 camera-left 驱动用户右手画笔、camera-right 渲染用户左手选色指示器。
- 关联:`src/components/edutainment-runtime/BabyLoveDrawingRuntimeShell.tsx``docs/technical/BABY_LOVE_DRAWING_RUNTIME_DEMO_IMPLEMENTATION_2026-05-13.md`
## 宝贝识物创作卡在准备结果页先查长耗时 image-2 请求
- 现象:`/creation/baby-object-match` 创作生成停在“准备结果页”,约 3 分钟后显示“生成失败 / 请求超时”;后端日志可能出现同一路由 `status=502 latency_ms=231291`,或前端已失败但后端稍后返回 200。
- 原因:宝贝识物创作属于长耗时 image-2 链路。旧前端只等待 180 秒并对长耗时 POST 自动重试,容易在 VectorEngine 仍在生成时先 abort,再重复发起第二次生成;上游某张图超过后端 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 或返回 5xx 时会表现为 502。2026-05-14 后,新链路已从“2 张物品图 + 5 张视觉包装图”收敛为“1 张 `2x2` 素材 sheet + 1 张场景背景图”,左右手位置指示器改为运行态默认静态素材,不再每次创作生成,但仍需要按长耗时链路排查。
- 处理:`babyObjectMatchClient``/api/creation/edutainment/baby-object-match/assets` 使用 10 分钟超时并取消自动重试;后端并发启动 `2x2` 素材 sheet 和场景背景生成,并把该路由的 VectorEngine 单图请求等待预算提升到至少 8 分钟,按资源类别输出开始、完成和耗时日志。`2x2` sheet 固定包含物品 A、物品 B、篮子和礼物盒,服务端按格切图并转透明 PNG;`ui-frame` / `smoke-puff` / `left-hand` / `right-hand` 不再作为新生成必需资源。
- 验证:运行 `npm run test -- src/services/edutainment-baby-object/babyObjectMatchClient.test.ts src/services/miniGameDraftGenerationProgress.test.ts``cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml` 和编码检查;真实联调时查看 `宝贝识物 image-2 2x2 素材 sheet 生成完成``宝贝识物 image-2 场景资源生成完成` 和整体 `宝贝识物 image-2 资源生成完成` 耗时是否小于前端超时,若仍 502 再看 `VectorEngine 图片生成上游错误``upstreamStatus/raw_excerpt`
- 关联:`src/services/edutainment-baby-object/babyObjectMatchClient.ts``src/services/miniGameDraftGenerationProgress.ts``server-rs/crates/api-server/src/edutainment_baby_object.rs``docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`
## 宝贝识物篮子手柄白底先查 sheet 切图后处理
- 现象:`宝贝识物` 新生成的主题篮子在左右手柄、篮口镂空或边缘处仍出现白底块或白色毛边,尤其是 2x2 sheet 背景被抠透明后,封闭镂空区域可能没有被通用边缘连通抠图清理掉。
- 原因:宝贝识物为了降低 image-2 成本,把物品 A、物品 B、篮子和礼物盒放在同一张 `2x2` sheet。通用背景透明处理主要从单格边缘连通背景开始,封闭在篮子手柄内部的近白区域不一定与边缘连通,因此会残留;如果把强力近白清理应用到物品格,又可能误伤白色物品主体。
- 处理:后端 `slice_baby_object_match_sheet` 只在 `BabyObjectMatchSheetSlot::Basket` 编码前执行近白、低饱和 matte 清理;物品格和礼物盒格继续只走通用背景透明处理。sheet prompt 同步要求篮子手柄和篮口镂空处不要留下白底描边或毛边。运行态左右篮子的物品图标和名称 UI 以篮子中心线对齐,避免素材放大后看起来偏移。
- 验证:运行 `cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml``npm run test -- src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx`;真实联调需要重新生成宝贝识物资源,旧草稿中已保存的 base64 篮子图不会自动被新后处理改写。
- 关联:`server-rs/crates/api-server/src/edutainment_baby_object.rs``src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx``src/index.css``docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`
## 宝贝识物物品框被长条素材拉伸先查固定槽位
- 现象:用户用手机、筷子等长条关键词生成素材后,中央物品 UI 或篮子上方物品图标看起来被拉成长框,圆形 UI 失去固定比例。
- 原因:运行态如果让图片固有宽高或外层自适应内容,就会把长条透明 PNG 的主体比例传导到 UI 容器。
- 处理:中央物品 UI 和篮子物品图标都必须使用固定正方形槽位,外层尺寸由 CSS 变量控制;生成素材图片只在槽位内 `object-fit: contain` 等比缩放,不改变外层圆形 UI 框尺寸。
- 验证:用长条物品草稿进入宝贝识物运行态,中央物品框和篮子图标框仍为正圆,长条主体在框内缩小显示。
- 关联:`src/index.css``src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx``docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`
## 寓教于乐作品和宝贝识物模板同时消失先查入口种子
- 现象:发现页“寓教于乐”分类下已发布的宝贝识物作品突然消失,同时创作界面模板选项中也看不到或无法正常展示 `宝贝识物`
- 原因:创作入口配置事实源已迁到 SpacetimeDB `creation_entry_type_config`;前端用 `baby-object-match` 入口可见性同时控制创作模板展示和发现页宝贝识物公开作品合入。若默认种子或后台配置缺少 `baby-object-match` 行,两条链路会一起被判定为不可见。
- 处理:确认 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` 默认种子包含 `id=baby-object-match``title=宝贝识物``visible=true``open=true``sort_order=90`api-server 测试降级配置也要同步包含该类型。入口图片路径需指向真实存在资源,避免卡片图片 404。
- 验证:运行 `cargo test -p module-runtime default_creation_entry_types_include_baby_object_match --manifest-path server-rs/Cargo.toml``cargo test -p api-server test_creation_entry_config_response_keeps_baby_object_match_visible --manifest-path server-rs/Cargo.toml``cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml``npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts`
- 关联:`server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs``server-rs/crates/api-server/src/creation_entry_config.rs``docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md`
## 儿童动作 Demo 绘本风资源未生成先查 VectorEngine 配置
- 现象:`/child-motion-demo` 已经呈现绘本草地风格,但 `public/child-motion-demo/picture-book-grass-stage.png``picture-book-grass-floor.png``picture-book-ground-ring.png``picture-book-character-outline.png``picture-book-ui-panel.png``picture-book-ui-button.png` 不存在,Network 里对应图片返回 404,或运行 `npm run assets:child-motion-demo -- --live` 返回缺少 VectorEngine 配置。
- 原因:儿童动作 Demo 的真实背景、地面、UI、地面指示环和角色轮廓资源都使用 VectorEngine `gpt-image-2` 生成,脚本只读取 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 和可选 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;仓库内不能提交真实 key,缺配置时页面只能使用 CSS 草地绘本兜底。
- 处理:在本地私密环境补齐 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai``VECTOR_ENGINE_API_KEY`,不要把 key 写入 Git;先运行 `npm run assets:child-motion-demo -- --dry-run` 核对 prompt,再运行 `npm run assets:child-motion-demo -- --live``npm run assets:child-motion-demo -- --live --only ui-panel` 等小批量命令生成资源。透明资源的品红底源图写入 `tmp/child-motion-demo-assets/`,不要把源图或预览图放入 `public/child-motion-demo/` 作为正式资产。
- 验证:生成后确认 `public/child-motion-demo/` 只保留页面引用的最终 PNG,重新打开 `/child-motion-demo` 可看到真实绘本草地背景、地面、圆环、角色轮廓和 UI 资源;`npm run check:encoding` 仍通过。
- 关联:`scripts/generate-child-motion-demo-assets.mjs``src/index.css``docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`
## 儿童动作 Demo 绘本资源变形先查用途拆分和透明后处理
- 现象:`/child-motion-demo` 背景风格正确,但底部草坪被拉成厚色块、顶部 HUD 或右下状态条像方形面板被横向拉伸,或旧 `picture-book-ui-panel.png` 与新资源叠在一起。
- 原因:早期资源中 `picture-book-ui-panel.png` 是接近方形画布,`picture-book-grass-floor.png` 也含大量透明边界;若 CSS 用 `background-size: 100% 100%` 把同一资源强行铺成 HUD、状态条、开始面板或底部地板,就会出现变形和层叠观感。
- 处理:使用用途专属资源:`picture-book-foreground-grass-v2.png``picture-book-ground-ring-v3.png``picture-book-character-outline-v4.png``picture-book-hud-strip-v2.png``picture-book-calibration-strip-v2.png``picture-book-start-panel-v2.png``picture-book-ui-button-v2.png`;CSS 按资源比例等比缩放,底部草坪只覆盖下沿,HUD / 状态条 / 开始托盘分别引用各自资源。角色指示器使用 v4 更细白色描边资源,内部透明且显示尺寸相对上一版放大 50%;若只需修透明裁切、品红边或纯描边后处理,运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>`,不重新请求 image-2。
- 验证:用横屏截图检查没有新旧资源叠加、没有方形面板拉成长条、角色和地面指示环不被前景草坪埋住;同时运行 `npm run check:encoding`
- 关联:`scripts/generate-child-motion-demo-assets.mjs``src/index.css``public/child-motion-demo/``docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`
## 儿童动作 Demo 猫咪挥手拆件错位先查动画父级和肩部挂点
- 现象:`/child-motion-demo` 打个招呼阶段的猫咪图和风格正确,但挥手时左右手臂像漂浮在身体旁边,视频里能看到肢体没有稳定接在肩膀上。
- 原因:猫咪身体和手臂如果分别做上下浮动,或手臂使用透明方形画布的默认中心/底部旋转轴,就会在摆动极值时放大肩点偏差;镜像左臂还需要把资源内部连接点换算到镜像后的坐标。
- 处理:`.child-motion-gesture-guide__wave-cat` 父级统一承接 bob 动画,身体层保持静态贴底且层级低于手臂;左右手臂作为同一父级下的兄弟层,只做旋转动画并显示在身体前方。身体使用去掉左右小圆点的 `picture-book-wave-cat-body-guide-v7.png`;手臂 v7 资源当前按身体外缘摆放,圆猫爪掌面朝向玩家;左右侧距为 `12%`,左臂使用原图层与 `60% 78%` 旋转轴,右臂使用镜像图层与 `40% 78%` 旋转轴,动画周期为 `0.47s`,左右手臂不设置错峰延迟;不要把 `scaleX(...)` 和 rotate 放在同一个手臂 wrapper 上。
- 验证:用用户录屏关键帧或离线合成预览检查摆动两端的手臂根部仍贴住肩点;再运行儿童动作 Demo 定向组件测试、ESLint 和 `npm run check:encoding`
- 关联:`src/index.css``public/child-motion-demo/picture-book-wave-cat-body-guide-v7.png``public/child-motion-demo/picture-book-wave-cat-arm-guide-v7.png``docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`
## GPT-image-2 不再读 APIMart 图片配置
- 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY` 后,RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls`
- 原因:2026-05-21 后 GPT-image-2 图片生成按 VectorEngine 创建/编辑接口分流;2026-07-05 后创意 Agent 文本链路也改为 VectorEngine Chat Completions `gpt-5.4-mini`,APIMart 不再作为当前创意 Agent 来源。
- 处理:为图片生成配置 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai``VECTOR_ENGINE_API_KEY``VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;排查请求体时确认无参考图路径为 `/v1/images/generations`、有参考图路径为 `/v1/images/edits`,业务 / 计费与 provider 首发模型均为 `gpt-image-2`,仅在符合条件的 provider 失败后切到兜底模型 `gpt-image-2-c`
- 验证:运行 `cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml` 和相关玩法图片生成测试;真实联调只在本地私密环境放置 VectorEngine key。
- 关联:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md``server-rs/crates/api-server/src/openai_image_generation.rs`
## 拼图参考图没有影响生成时先查 action payload 和阶段日志
- 现象:拼图上传参考图后生成出的画面明显不像参考图,或结果页重新生成没有按保存的参考图走图生图。
- 原因:首图生成只通过 `compile_puzzle_draft.referenceImageSrc` 临时传 Data URL,不持久化到 SpacetimeDB;结果页重新生成则要把当前上传图或关卡 `pictureReference` 作为 `generate_puzzle_images.referenceImageSrc` 继续传给后端。
- 处理:浏览器 Network 里确认 action payload 带 `referenceImageSrc`api-server 日志按同一 `session_id` 查看 `拼图参考图解析完成``拼图 VectorEngine 图片生成 HTTP 返回``拼图 VectorEngine 图片下载完成``拼图生成图片已写入 OSS 与资产索引`,可定位慢在参考图读取、VectorEngine、下载或 OSS。
- 验证:前端测试覆盖上传图 + AI 重绘、结果页保存的 `pictureReference` 重新生成;后端单测覆盖 VectorEngine 请求体 `image` 字段。
- 关联:`src/components/unified-creation/workspaces/PuzzleCreationWorkspace.tsx``src/components/puzzle-result/PuzzleResultView.tsx``server-rs/crates/api-server/src/puzzle.rs`
## 拼图首图生成后要把入口参考图写回 `pictureReference`
- 现象:入口页上传图后,首图看着像没吃到参考图;结果页重新生成时默认只沿用关卡旧图,没有继续带入口上传图。
- 原因:首图生成请求虽然已经把 `referenceImageSrc` 传给 VectorEngine,但如果后端只更新 `cover_image_src` / `selected_candidate_id` 而不回写首关 `pictureReference`,结果页后续重绘就会丢失参考图。
- 处理:在 `compile_puzzle_draft``generate_puzzle_images` 的成功与 SpacetimeDB 降级快照路径里,都把本次入口参考图写入首关 `pictureReference`
- 验证:后端单测覆盖 `build_puzzle_levels_with_primary_update``apply_generated_puzzle_candidates_to_session_snapshot`;结果页重新生成应在未重新上传时继续带入 `level.pictureReference`
- 关联:`server-rs/crates/api-server/src/puzzle.rs``src/components/puzzle-result/PuzzleResultView.tsx`
## 拼图参考图不像时先看 edits multipart image
- 现象:Network payload 已带 `referenceImageSrc`,但 VectorEngine 生成结果仍明显不像上传图。
- 原因:参考图只在 `aiRedraw = true` 时由后端解析并传给 `gpt-image-2` `/v1/images/edits` 的 multipart `image` part;若前端没传 `referenceImageSrc`、后端解析失败或 prompt 缺少参考图强约束,生成会退化为纯文生图。
- 处理:`referenceImageSrc` 存在且 `aiRedraw = true` 时走 edits multipartprompt 保留参考图强约束;入口页关闭 AI 重绘时直接应用上传图,不调用图片生成;前端把参考图压到单边 1024 内,后端解析后拒绝超过 8MB 的参考图字节。
- 验证:后端单测应覆盖 `/v1/images/edits` 路由、`b64_json` 响应解码和参考图强提示;真实联调看日志里是否命中 `拼图 VectorEngine 图片编辑 HTTP 返回`
- 关联:`server-rs/crates/api-server/src/puzzle.rs``src/services/puzzleReferenceImage.ts``docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`
## 拼图 edits 报 error sending request 先看网络分类
- 现象:拼图有参考图时返回 `拼图图片生成失败:创建拼图 VectorEngine 图片编辑任务失败:error sending request for url (https://api.vectorengine.ai/v1/images/edits)`,后端没有 `拼图 VectorEngine 图片编辑 HTTP 返回` 日志。
- 原因:这是 `reqwest``send()` 阶段失败,尚未收到 VectorEngine HTTP 响应;常见原因是服务器网络 / DNS / 防火墙 / 代理问题,或上游网关中断 multipart 连接。
- 处理:查看错误响应和 `拼图 VectorEngine 图片编辑` 相关日志;若请求发送阶段失败,先查网络出口、DNS、防火墙、代理、参考图大小和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`
- 验证:`curl --http1.1 -i -X POST https://api.vectorengine.ai/v1/images/edits -H "Authorization: Bearer invalid" -F "model=gpt-image-2" -F "prompt=test" -F "n=1" -F "size=1024x1024" -F "image=@public/match3d-background-references/pot-fused-reference.png;type=image/png"` 至少应返回 HTTP `401`,说明域名、TLS、路径和 multipart 上传可达;执行 `cargo test -p api-server puzzle_vector_engine --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/api-server/src/puzzle.rs``docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md``docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`
## 拼图 UI 背景缺失先区分生成失败和消费链路丢字段
- 现象:拼图草稿生成完成后,素材配置页没有展示生成的 UI 背景,或结果页能看到背景但自动试玩 / 结果页“试玩”进入局内仍只显示封面模糊背景。
- 原因:`compile_puzzle_draft` 设计上会在首图后生成 UI 背景,且缺 `uiBackgroundImageSrc/uiBackgroundImageObjectKey` 会让自动草稿失败;若草稿已成功,通常不是“没生成”,而是前端消费链路漏了 `levels[].uiBackgroundImageObjectKey` 回退,或本地 `startLocalPuzzleRun(...)` 只把 `coverImageSrc` 带入 `currentLevel`
- 处理:结果页预览、运行态和本地运行态统一用 `resolvePuzzleUiBackgroundSource`,优先 `uiBackgroundImageSrc`,为空时把 `uiBackgroundImageObjectKey` 规范成 `/generated-...` 路径并交给 `/api/assets/read-url` 换签;`startLocalPuzzleRun` 与本地下一关 handoff 都要从 `PuzzleWorkSummary.levels[]` 复制 `uiBackgroundImageSrc/uiBackgroundImageObjectKey/backgroundMusic``currentLevel`。结果页 `UI背景提示词` 输入框不得把本地兜底 prompt 直接显示成已保存提示词,避免误判为后端已生成。
- 验证:`npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx src/services/puzzle-runtime/puzzleLocalRuntime.test.ts src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx`,以及 `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle draft generation auto starts trial"`;后端用 `cargo test -p api-server puzzle_ui_background --manifest-path server-rs\Cargo.toml` 确认生成 / 序列化链路。
- 关联:`src/services/puzzle-runtime/puzzleUiBackgroundSource.ts``src/services/puzzle-runtime/puzzleLocalRuntime.ts``src/components/puzzle-runtime/PuzzleRuntimeShell.tsx``src/components/puzzle-result/PuzzleResultView.tsx``docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`
## 拼图草稿生成后音乐/UI 又变空先查结果页回包合并
- 现象:拼图草稿生成完成后,音乐面板曲名有值但音频槽仍显示“暂无音乐”,UI 仍展示默认预览;试玩进入局内也没有生成音乐或 UI 背景。
- 原因:结果页若已有本地 `generationStatus = generating` 编辑态,后端生成完成回包会走 `mergeDraftEditStateWithIncomingState(...)` 合并。该合并必须把生成候选图、正式图、`uiBackground*``backgroundMusic` 作为同一批生成资产处理;漏掉 `backgroundMusic` 时,随后自动保存会把空音乐写回 `levels_json`
- 处理:`PuzzleResultView` 合并生成完成回包时同步保留 `backgroundMusic`,并用回归测试覆盖 UI 预览、音乐试听和试玩 payload 都读取最新 `levels[]` 资产。
- 验证:`npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`,以及自动试玩入口测试 `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle draft generation auto starts trial"`
- 关联:`src/components/puzzle-result/PuzzleResultView.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md`
## 自动草稿成功但缺音乐或 UI 先查后端吞错
- 现象:拼图或抓大鹅生成页提示完成,但草稿页仍显示“暂无音乐”,拼图 UI 仍是默认预览,试玩局内也没有生成音乐或 UI 背景。
- 原因:自动草稿阶段如果把 VectorEngine / Suno / OSS / 资产绑定错误记录为 warning 后继续返回成功,前端只能拿到缺关键资产的成功 draft,随后保存和试玩都会消费这份空资产状态。
- 处理:自动草稿必须把必需生成资产当作后端完成条件:拼图首关需同时具备 `levels[0].backgroundMusic.audioSrc``levels[0].uiBackgroundImageSrc/uiBackgroundImageObjectKey`;抓大鹅需在 `generatedItemAssets[]` 中具备非空 `backgroundMusic.audioSrc`。缺失或上游失败时返回错误并停留在生成页,结果页手动重新生成只作为已有草稿补救入口。
- 验证:`cargo test -p api-server puzzle_initial_draft_assets_must_include_music_and_ui_background match3d_background_music_ready_requires_audio_src match3d_background_music_title_is_required_for_auto_draft --manifest-path server-rs\Cargo.toml`,并重启 `npm run dev:api-server` 后检查 `/healthz`
- 关联:`server-rs/crates/api-server/src/puzzle.rs``server-rs/crates/api-server/src/match3d.rs``docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 拼图草稿生成 180 秒后 502/504 先查 VectorEngine 超时与前端重试
- 现象:点击“生成拼图游戏草稿”后,`POST /api/runtime/puzzle/agent/sessions/{sessionId}/actions` 等待约 180 秒返回 `502 Bad Gateway``504 Gateway Timeout`;钱包流水里同一 session 可能出现连续两组 `puzzle_initial_image` 扣费后退款。
- 原因:首图生成走 VectorEngine `gpt-image-2`,默认 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1000000`;若上游在该窗口内未返回,后端退款并返回超时错误。旧前端 action 写请求会对 502/503/504 自动重试一次,导致同一次点击重复触发生图与扣退费。
- 处理:拼图/创作 Agent 的 `executeAction` 默认不做前端自动重试;后端将 VectorEngine / 图片请求超时映射为 `504 Gateway Timeout``error.details.provider=vector-engine``timeout=true`。真实排障按日志同一 `session_id``拼图 VectorEngine 图片生成 HTTP 返回` 是否缺失,以及钱包流水扣费到退款的时间差是否接近 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`
- 验证:运行 `npm run test -- src/services/creation-agent/creationAgentClientFactory.test.ts src/services/apiClient.test.ts``cargo test -p api-server puzzle_vector_engine --manifest-path server-rs/Cargo.toml`,真实联调重启 `npm run dev:api-server` 后检查 `/healthz`
- 关联:`src/services/creation-agent/creationAgentClientFactory.ts``server-rs/crates/api-server/src/puzzle.rs``docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`
## 开局 CG 故事板生图失败先查 VectorEngine 请求预算和旧进程
- 现象: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 将 `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
- 现象:RPG 结果页里的开局 CG 成功显示一瞬后,窗口又退回空白占位。
- 原因:`openingCg` 只存在于结果页 profile 槽位,如果父层在 `onProfileChange` 后重新同步了 profile,却经过 `normalizeCustomWorldProfileRecord` 或作品库写回时丢掉 `openingCg`,预览就会从视频 / 故事板回退为空白。
- 处理:`src/data/customWorldLibrary.ts` 的 profile 归一化必须透传 `openingCg`;结果页和父层后续同步都应把它当作受控资产槽位,而不是临时 UI 状态。
- 验证:`npm run test -- src/data/customWorldLibrary.test.ts src/components/CustomWorldResultView.test.tsx`,确认生成后即使父层做一次归一化回写,开局 CG 仍继续显示。
- 关联:`src/data/customWorldLibrary.ts``src/components/rpg-creation-result/RpgCreationResultViewImpl.tsx``src/components/CustomWorldEntityCatalog.tsx`
## RPG 发布报 legacy_result_profile_json 非法先查 null 兼容
- 现象:RPG 结果页发布动作返回 `UPSTREAM_ERROR`SpacetimeDB details 里是 `custom_world.compile.legacy_result_profile_json 不是合法 JSON object`
- 原因:`publish_world` 前端契约只要求 `{ action: 'publish_world' }``ExecuteCustomWorldAgentActionRequest.legacy_result_profile` 是可选字段,经 HTTP / serde / SpacetimeDB payload 传递时可能显式成为 JSON `null`。旧的编译器只接受 object 或缺省,把 `Some("null")` 当成非法 legacy JSON。
- 处理:`module-custom-world` 的 optional JSON object 解析要把 `null` 视为未提供,仍拒绝数组、字符串、数字和坏 JSON;正式发布继续以 session `draft_profile_json` 为草稿真相。
- 验证:`cargo test -p module-custom-world published_profile_compile --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/module-custom-world/src/application.rs``server-rs/crates/spacetime-module/src/custom_world.rs``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 本地脚本调 VectorEngine 生图卡住先区分 fetch 首部超时
- 现象:用 Node `fetch` 直接请求 `POST /v1/images/generations`,已经设置较长的 AbortController 超时,但仍在约 180 到 300 秒后抛 `AbortError``TypeError: fetch failed``UND_ERR_HEADERS_TIMEOUT`;同一 prompt 改用原生 `https.request` 可以在较短时间内成功返回图片。
- 原因:Node/Undici 的默认 headers timeout 可能早于业务脚本期望的长生图等待窗口触发,表现上容易被误判成 VectorEngine 上游本身超时。
- 处理:长期脚本优先复用后端 reqwest 或项目已有生成脚本;临时本地工具若必须用 Node,可改用原生 `http`/`https.request` 并显式设置 socket timeout,或为 Undici 单独配置 headers timeout。仍需隐藏 `VECTOR_ENGINE_API_KEY`,只报告配置是否存在。
- 验证:同一 `gpt-image-2` 请求体、同一环境变量下,原生 HTTP 请求能返回 `url` / `b64_json` 并落盘;失败时错误里能区分请求发送、首部等待、下载和解码阶段。
- 关联:`.codex/skills/gpt-image-2-apimart/SKILL.md``server-rs/crates/api-server/src/openai_image_generation.rs`
## 旧后端路线文档造成判断漂移
- 现象:开发时参考到 Express、Node、PostgreSQL 或 Go 方向旧文档,导致接口、数据真相或部署路径与当前主线不一致。
- 原因:项目历史文档较多,部分旧方案仍保留作迁移参考。
- 处理:涉及服务端、数据真相、SpacetimeDB、运行时状态时,先看 `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`,再看当前代码和具体技术方案。
- 验证:代码改动应落在 `server-rs + Axum + SpacetimeDB` 主线;旧路线只作为迁移参考,不作为兼容目标。
- 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``AGENTS.md`
## SpacetimeDB 表结构变更不能按 PostgreSQL 迁移直觉处理
- 现象:发布时 schema 冲突、自动迁移拒绝、旧客户端调用 reducer 失败、private 表数据迁移遗漏。
- 原因:SpacetimeDB 对字段删除、类型变化、索引/主键/RLS/reducer 变化有不同自动迁移边界。
- 处理:变更前阅读 `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`;已有表新增字段必须放在 Rust 表结构体最后并设置明确默认值;需要修改字段名时,先询问用户并确认迁移计划;涉及表变化时同步 `migration.rs`、当前表目录和 bindings;必要时走 JSON 导入导出与分片导入迁移流程。
- 验证:发布前运行 `npm run check:spacetime-schema`,完成 schema 检查、bindings 生成、表目录更新和相关 smoke。
- 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## SpacetimeDB 持久化 enum 新 variant 只能末尾追加
- 现象:生产发布时 schema 迁移失败,或旧数据中的 enum 判别序号被新代码解释成其它业务枚举值。
- 原因:SpacetimeDB schema 会保存 enum variant 顺序;在已有持久化 enum 中间插入新 variant,会让后续 variant 的判别序号整体移动。即使 Rust 代码能编译,发布到已有数据库也可能炸。
- 处理:给已发布并持久化的 enum 增加 variant 时,只能追加到 enum 末尾;同步运行 `npm run spacetime:generate` 刷新 bindings,不能手工把 generated bindings 改成另一套顺序。需要调整既有 variant 顺序、删除或重命名时,必须先确认数据迁移方案。
- 验证:`npm run check:spacetime-schema` 应通过;对照本次修改前后的 enum,所有旧 variant 顺序必须完全不变,新 variant 只出现在末尾。
- 关联:`server-rs/crates/module-runtime/src/domain.rs``server-rs/crates/spacetime-client/src/module_bindings/``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## SpacetimeDB publish 报 wasm-bindgen 时先查 shared-contracts feature
- 现象:发布 `spacetime-module` 时报 `wasm-bindgen detected`,提示 `wasm-bindgen is only for webassembly modules that target the web platform`
- 原因:SpacetimeDB module 的 wasm32 构建树被间接带入原生/网页依赖;已验证链路是 `reqwest -> platform-oss -> shared-contracts -> module-runtime -> spacetime-module`,由共享契约默认启用资产 OSS 契约触发。
- 处理:让 `shared-contracts` 的 OSS 资产契约走 `oss-contracts` featureworkspace 根依赖保持 `default-features = false``api-server` 这类原生后端需要资产 DTO 时在自身 `Cargo.toml` 显式启用 `features = ["oss-contracts"]`
- 验证:执行 `cargo tree -i wasm-bindgen --manifest-path server-rs\crates\spacetime-module\Cargo.toml --target wasm32-unknown-unknown` 应显示 nothing to print;再执行 `cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml --target wasm32-unknown-unknown`
- 关联:`server-rs/crates/shared-contracts/Cargo.toml``server-rs/crates/api-server/Cargo.toml``docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md`
## 本地 SpacetimeDB replica identity 不匹配
- 现象:本地 standalone 启动时报 `mismatched database identity`
- 原因:本地 SpacetimeDB 数据目录中的 replica 数据残留与当前数据库身份不一致。
- 处理:按本地 replica identity mismatch 文档进行备份、重建和脚本诊断。
- 验证:本地 SpacetimeDB 可正常启动并 publish / 访问。
- 关联:`docs/technical/SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md`
## 本地 SpacetimeDB publish 403 优先查 CLI 身份和目标库
- 现象:`spacetime publish``Pre-publish check` 阶段返回 `403 Forbidden`,提示当前 identity 无权对目标 database identity 执行 `update database`
- 原因:当前 CLI 登录态不是目标数据库的创建者或授权身份,或 `.env.local` / publish 命令指向了另一个数据库或 SpacetimeDB 服务。
- 处理:除 CI/CD 脚本内部受控用法外,不再使用 `spacetime --root-dir` 排障或发布。先执行 `spacetime login show``spacetime server list`,再用 `spacetime list --server http://127.0.0.1:3101` 或实际 `--server-url` 确认当前身份是否能看到目标库;本地开发发布优先使用 `npm run dev:spacetime` 或从 `server-rs` 目录执行显式 `--server``spacetime publish`。如果身份不对,重新登录正确身份、使用项目脚本重新生成本地库,或在 SpacetimeDB 侧补授权。
- 验证:`spacetime list --server http://127.0.0.1:3101` 能看到目标库;重新发布不再使用无权限 identity。
- 关联:`scripts/dev.mjs``docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md`
## `npm run dev` 本地 SpacetimeDB 401 / 403 可重置默认 local 身份
- 现象:`npm run dev` 启动本地开发栈时,SpacetimeDB 在登录、发布或预检查阶段返回 `401` / `403`,清理后仍像在使用旧 token 或旧本地库。
- 原因:本机 `spacetime` CLI 保存的旧 token、默认 server、正在运行的 standalone 进程或默认 local 数据库与当前发布身份不一致。
- 处理:确认只是本地测试库且数据可丢弃后,先查看并停止本地 `spacetimedb-standalone`,执行 `spacetime logout`,确认并设置 `spacetime server set-default local`,停 server 后用 `spacetime server clear -y` 清空默认本地库,再 `spacetime start`,另开终端执行 `spacetime login --server-issued-login local`,最后用 `spacetime publish --server local A` 或项目脚本重新发布。
- 验证:`spacetime server list` 默认目标为 local;重新登录后发布不再返回 `401` / `403``npm run dev` 可以完成 SpacetimeDB publish 并继续启动 `api-server`
- 关联:`docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md``scripts/dev.mjs`
## 本地 SpacetimeDB 联调可按阶段跳过宿主或发布
- 现象:本地 `npm run dev``3101` 已占用、重复发布 SpacetimeDB wasm 编译太慢,或只想检查 `spacetime-module` 语法而被完整联调链路拖慢。
- 原因:`npm run dev` 默认同时启动 SpacetimeDB standalone、发布 `server-rs/crates/spacetime-module`、启动 Rust `api-server`、主站 Vite 与后台 Vite;并非每个阶段都需要完整重启和重新发布。
- 处理:`npm run dev` 启动时解析实际 SpacetimeDB、api-server、主站 Vite 和后台 Vite 端口,并将同一组运行时地址传给 publish、后端环境变量和前端代理。是否复用既有宿主由启动参数和健康探测决定;修改 `spacetime-module` 时重新 publish,未修改时可使用 `--skip-publish`
- 验证:`--skip-spacetime` 后脚本复用现有 `http://127.0.0.1:3101`;日志中的 `[dev] spacetime:` 不应漂移到没有服务的 `3102``GET /api/creation-entry/config` 不应返回连接空端口导致的 `502``3101``8082` 被其他进程占用时,脚本使用最近可用端口;`--skip-publish` 后不再进入 publish 阶段;`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 能完成 Rust 语法和类型检查。端口漂移时控制台会打印 `[dev:ports] ... 不可用,改用 ...`,后续 `[dev] web/admin web/api-server/spacetime` 地址应与实际端口一致。`spacetime-module` 变更后只应看到重新发布日志,不应看到 standalone 重启日志。
- 关联:`docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md``scripts/dev.mjs`
## `npm run dev -- --watch` 前端无限重启先查外层 watcher
- 现象:开启 `npm run dev -- --watch` 后,后台 Vite 或主站 Vite 反复退出重启,即使没有手动修改源码。
- 原因:Vite 本身会监听源码并写入 `node_modules/.vite` 等缓存;外层调度器如果再递归监听前端目录并重启 dev server,就可能把 Vite 自己的缓存写入当成源码变化,形成循环重启。
- 处理:外层 watcher 只负责后端侧:`spacetime-module` 改动后重新 publish`api-server` 改动后重启 Rust 进程。主站 Vite 和后台 Vite 的源码变化交给 Vite HMR;需要进程级重启时在 `npm run dev` 终端手动输入 `rs web``rs admin-web`
- 验证:`npm run dev -- --watch` 下修改 `apps/admin-web/src/**` 应由 Vite HMR 处理,不应出现连续 `[dev] 重启 admin-web``scripts/dev.test.ts` 覆盖 web/admin-web 不注册外层 watch。
- 关联:`scripts/dev.mjs``docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`
## 根目录 `nohup.out` 持续写入会触发主站 Vite 刷新循环
- 现象:在仓库根目录用 `nohup npm run dev ... &` 启动完整 dev 栈后,即使没有修改前端源码,主站页面也会反复整页刷新;`nohup.out` 同时持续增长。
- 原因:未显式重定向 stdout / stderr 时,`nohup.out` 会收集 SpacetimeDB、api-server 和两套 Vite 的整套 dev 栈输出。主站 Vite 的 root 是仓库根目录,若 watcher 未忽略这个持续写入的文件,每次追加日志都会被当成文件变化;后台 Vite root 是 `apps/admin-web`,仓库根日志不在其监听根内。
- 处理:主站 `vite.config.ts``server.watch.ignored` 保持忽略 `**/nohup.out`Git 同时忽略 `nohup.out`。修改配置后重启主站 Vite。若显式重定向到其它仓库内日志文件,该文件不会自动受保护,应写到 Vite root 之外或补充精确忽略规则。
- 验证:在仓库根目录追加 `nohup.out` 时主站不再刷新,真实源码修改仍正常触发 HMR;`git check-ignore nohup.out` 能命中忽略规则,`git status` 不出现该日志。
- 关联:`vite.config.ts``.gitignore``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 本地 SpacetimeDB publish 401 可清本地库重发
- 现象:本地 `spacetime publish` 显示 `401` 无权限,或重新发布仍像是在更新旧库。
- 原因:本地开发数据目录中保留的数据库、控制库身份或发布身份与当前目标不一致。
- 处理:确认本地开发数据可以丢弃后,停止本地 SpacetimeDB,备份或删除 `server-rs/.spacetimedb/local/data`,再重新运行 `npm run dev` 或本地 publish;不要用 `--root-dir` 手工清库。
- 验证:重新发布日志应显示创建新的数据库,而不是更新旧数据库;若仍显示更新或继续 `401`,继续检查数据目录、库名和 CLI 身份。
- 关联:`docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md``docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md`
## SpacetimeDB 模块 publish 报 `wasm-bindgen detected`
- 现象:`spacetime publish` 已经完成 Rust 编译,但随后报 `wasm-bindgen detected`,提示依赖树里有面向 Web 平台的 wasm-bindgen。
- 原因:SpacetimeDB 模块是数据库内 WASM,不允许拉入 Web/HTTP client 链路;常见误因是 `spacetime-module -> module-* -> shared-contracts -> platform-* -> reqwest -> wasm-bindgen` 这类反向依赖。
- 处理:执行 `cargo tree -i wasm-bindgen --manifest-path server-rs/Cargo.toml -p spacetime-module --target wasm32-unknown-unknown` 找到链路;把平台实现类型从 `shared-contracts``module-*` 中移除,只保留公开 DTO,平台响应到 DTO 的转换放回 `api-server` 等 adapter 层。
- 验证:上述 `cargo tree` 输出 `warning: nothing to print``cargo check -p shared-contracts``cargo check -p api-server` 通过;重新 `spacetime publish ... --module-path server-rs/crates/spacetime-module` 不再报 wasm-bindgen。
- 关联:`docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md``server-rs/crates/shared-contracts/src/assets.rs``server-rs/crates/api-server/src/assets.rs`
## Vite SPA fallback 吞掉 API 请求
- 现象:本地请求 `/api/profile/*` 等接口时返回 HTML,被前端当 JSON 解析报错。
- 原因:Vite 代理缺少对应 `/api/*` 前缀,API 请求落到 SPA fallback。
- 处理:补齐 Vite 代理,让 API 请求转发到 Rust `api-server`
- 验证:请求返回 JSON,相关页面不再出现 HTML parse 错误。
- 关联:`docs/technical/PROFILE_MAIN_ROUTE_VITE_PROXY_FIX_2026-05-02.md`
## `npm run build` 因 Vite warning 被 build-gate 判失败
- 现象:主站或后台 Vite 已经输出 `built in ...`,但根命令最后仍失败并打印 `Build gate failed because warnings were emitted`
- 原因:`scripts/build-gate.mjs` 会收集 stdout / stderr 中的 warning 行并作为硬失败;常见触发是产物 chunk 超过 `vite.config.ts``apps/admin-web/vite.config.ts``chunkSizeWarningLimit`
- 处理:先看 warning 原文确认来源。若是合理的入口级 chunk 体积增长,调整对应 Vite 配置阈值或做真实拆包;不要把这类失败按 Rust / SpacetimeDB 编译错误排查。
- 验证:重新执行 `npm run build`,主站与后台均构建完成且没有 build-gate warning 汇总。
- 关联:`scripts/build-gate.mjs``vite.config.ts``apps/admin-web/vite.config.ts`
## 反馈页清空 file input 前必须先拷贝 FileList
- 现象:点击上传凭证会打开文件选择框,但选择图片后页面没有展示预览,提交时也没有携带图片凭证。
- 原因:浏览器传入的 `FileList` 可能跟 `<input type="file">` 保持 live 绑定;如果先执行 `input.value = ''`,再从参数里的 `FileList` 读取文件,列表可能已经为空。
- 处理:在清空 file input 前先执行 `const selectedFiles = files ? Array.from(files) : []`,后续图片类型、大小、Data URL 读取和预览都基于这个普通数组。
- 验证:`PlatformFeedbackView.test.tsx` 用 mock `FileReader` 断言选择图片后出现 `反馈凭证预览`,且提交 payload 带 `evidenceItems[].dataUrl`
- 关联:`src/components/platform-entry/PlatformFeedbackView.tsx``docs/technical/PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md`
## 拼图 VectorEngine 图片生成密钥不能复用 DashScope / ARK key
- 现象:拼图新手引导或拼图创作点击生成后返回 `VectorEngine 图片生成密钥未配置`
- 原因:拼图 `gpt-image-2` / 历史 `nanobanana2` 图片生成已统一走 VectorEngine;后端只读取 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY``VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`,不会用 `DASHSCOPE_API_KEY``LLM_API_KEY``ARK_API_KEY``APIMART_API_KEY` 兜底。
- 处理:在本机私密配置 `.env.secrets.local` 或进程环境中配置真实 `VECTOR_ENGINE_API_KEY`,不要提交到 Git;填入后必须重启 `api-server` / `npm run dev`,运行中的进程不会自动加载新 env。
- 验证:不打印密钥内容,只检查 `VECTOR_ENGINE_API_KEY` 非空;重启后触发拼图生成不再返回本地配置缺失的 503。
- 关联:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md``.codex/skills/gpt-image-2-apimart/SKILL.md`
## `npm run dev:api-server` 读取 env 的顺序必须让 `.env.secrets.local` 最后覆盖
- 现象:`POST /api/assets/hyper3d/text-to-model` 在本地返回 503,详情里提示 `HYPER3D_API_KEY 未配置`,但开发者明明已经在本地私密文件里写了 key。
- 原因:`scripts/dev-utils.mjs` 之前按 `.env.secrets.local → .env.local → .env` 合并,结果仓库里的 `.env` 空示例值会把前面已经设置好的私密 key 覆盖掉。
- 处理:`npm run dev:api-server` / `npm run dev:spacetime` / `npm run dev` 统一按“外层 shell 变量优先,其后 `.env``.env.local``.env.secrets.local` 逐层覆盖”的顺序加载;真实密钥优先放 `.env.secrets.local`。本地认证开关例外:`SMS_AUTH_ENABLED``SMS_AUTH_PROVIDER` 等以本地 env 文件为准,避免父进程继承的旧开关值长期压过 `.env.local`
- 验证:本地加入临时测试后,`HYPER3D_API_KEY` 应能被 `.env.secrets.local` 覆盖,真实密钥 shell 变量仍然最高优先级;`mergeApiServerEnv(..., { SMS_AUTH_ENABLED: "false" })``.env.local``SMS_AUTH_ENABLED=true` 时应返回 true。
- 关联:`scripts/dev-utils.mjs``server-rs/crates/api-server/src/hyper3d_generation.rs``docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md`
## OSS 密钥键名不要把字母 O 写成数字 0
- 现象:`.env.secrets.local` 看起来已经配置 OSS AccessKey Secret,但拼图或抓大鹅生成仍返回 `OSS 未完成环境变量配置`
- 原因:后端只读取 `ALIYUN_OSS_ACCESS_KEY_SECRET`。如果写成 `ALIYUN_0SS_ACCESS_KEY_SECRET`,中间是数字 `0`,配置合并检查会显示正确键缺失,`api-server` 不会初始化 OSS 客户端。另一个常见原因是外层 shell / IDE 预置了空的 `ALIYUN_OSS_*`,旧启动脚本会把空值当作最高优先级,导致 `.env.local``.env.secrets.local` 的真实值被跳过。
- 处理:只改键名为 `ALIYUN_OSS_ACCESS_KEY_SECRET`,保留原值;不要在日志、文档或对话里输出密钥内容。本地启动脚本应只保护非空外层环境变量,空字符串或全空白值不得遮蔽本地 env 文件。
- 验证:运行 `npm run check:api-server-env`,确认 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY``ALIYUN_OSS_BUCKET``ALIYUN_OSS_ENDPOINT``ALIYUN_OSS_ACCESS_KEY_ID``ALIYUN_OSS_ACCESS_KEY_SECRET` 都是 `present`,再重启 `npm run dev:api-server``npm run dev`
## 拼图图片生成 98% 后报 OSS V4 签名时间格式化失败
- 现象:拼图创作表单生成进度卡在 98%,`POST /api/runtime/puzzle/agent/sessions/{sessionId}/actions` 返回 `502 Bad Gateway`,前端提示 `拼图图片生成失败:OSS V4 签名时间格式化失败`
- 原因:`platform-oss` 曾用 `OffsetDateTime::time().to_string()` 拼接 `x-oss-date`,UTC 小时、分钟或秒为个位数时可能缺少前导零,导致 V4 签名时间不是固定 `YYYYMMDDTHHMMSSZ`
- 处理:OSS V4 签名日期统一显式补零格式化;签名 scope 用 `YYYYMMDD`,完整签名时间用 `YYYYMMDDTHHMMSSZ`,不要再依赖 `time().to_string()`
- 验证:运行 `cargo test -p platform-oss``cargo check -p api-server`;重启 `npm run dev:api-server` 后检查 `/healthz`,再重新触发拼图生成。
- 关联:`server-rs/crates/platform-oss/src/lib.rs``server-rs/crates/api-server/src/assets.rs``docs/technical/M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md`
## 拼图生成完成后图片只显示破图或 alt 文案
- 现象:拼图结果页生成完成后,“画面图”区域出现破图图标和作品名,图片无法正常预览;但打开历史拼图素材时同一张图可能可以正常预览。
- 原因:拼图正式图保存为 `/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`
## 拼图图片生成失败后不要停在 ImageRefining
- 现象:拼图图片生成失败后,会话仍停留在 `PuzzleAgentStage::ImageRefining`,用户从作品架或生成页恢复时容易被当成生成中/精修中状态,重试入口和失败承接不清晰。
- 原因:`mark_puzzle_draft_generation_failed_tx` 只把 `PuzzleResultDraft.generation_status` 标成 `failed`,但 session stage 仍沿用旧的 `row.stage`;如果失败前已进入 `ImageRefining`,失败回写不会把会话带回结果草稿态。
- 处理:失败回写后按失败草稿重新解析 session stage:已发布保持 `Published`,仍满足发布门禁则为 `ReadyToPublish`,否则回到 `DraftReady`;前端生成页文案用“拼图图片生成进度 / 重新生成图片”,避免把失败态误导成还在生成整份草稿。
- 验证:运行 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml``npm run check:encoding`,以及拼图生成页恢复相关 `RpgEntryFlowShell.agent.interaction.test.tsx` 定向用例。
- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
## 本地短信登录页签突然消失
- 现象:登录弹窗只剩密码登录,短信登录页签看起来像被删掉,但 `LoginScreen` 中手机号验证码表单仍存在。
- 原因:历史实现曾根据 `GET /api/auth/login-options` 返回的 `availableLoginMethods` 渲染页签;接口返回空、失败或只返回 `["password"]` 时,`AuthGate` 会降级成只显示密码。
- 本地启动脚本没有让 `.env.local` 覆盖 `.env``SMS_AUTH_ENABLED=true` 不生效,后端只返回 `["password"]`
- Rust API 直连已返回 `["phone","password"]`,但 Vite 代理目标指向未监听端口,导致 3000 域名下的 `login-options` 返回 `500``AuthGate` 降级成 `["password"]`
- 3000 端口被旧 `dev:web` 占用后,新的完整栈 Vite 自动漂移到 3001/3002;浏览器仍打开旧 3000 页面,旧页面继续代理到已经下线的端口。
- 生成页 UI 改动看起来“完全没变化”时,也要先确认当前浏览器打开的 Vite 进程正在返回最新源码;例如直接请求 `http://127.0.0.1:3000/src/components/CustomWorldGenerationView.tsx` 检查是否包含本次新增类名或关键字。
- 单独 `npm run dev:web` 启动瞬间另一个临时 API 端口可用,脚本若自动切过去,之后临时 API 停掉也会让 3000 继续代理到空端口。
- 处理:当前口径是登录弹窗永远展示 `短信登录``密码登录` 两个核心入口;`login-options` 只补充微信等环境相关入口,不能隐藏短信或密码页签。如果“获取验证码”点击后失败,再按短信 provider / API 代理问题排查:优先用 `npm run dev:api-server``npm run dev:spacetime``npm run dev` 启动,确认 `.env.local` 覆盖 `.env``RUST_SERVER_TARGET` 没有指向旧端口,并分别请求 3000 域名和 Rust API 目标。
- 验证:即使 `/api/auth/login-options` 返回空、失败或只返回 `["password"]`,登录弹窗也应同时显示 `短信登录``密码登录``验证码` 输入和“获取验证码”按钮;短信发送真实可用性再通过 `POST /api/auth/phone/send-code` 验证。
- 关联:`src/components/auth/AuthGate.tsx``src/components/auth/LoginScreen.tsx``src/components/auth/AuthGate.test.tsx``scripts/dev-utils.mjs``scripts/dev.mjs`
## 浏览器自动填充手机号带 `+86`
- 现象:登录弹窗的手机号被浏览器回填为 `+86 1xxxxxxxxxx`,点击获取验证码或登录后返回“手机号格式不正确”。
- 原因:`autocomplete="tel"` 允许浏览器回填含国家码的完整电话号码,`inputMode="numeric"` 只提示软键盘布局,不会过滤自动填充;如果把完整号码和纯号码混在一个 `phone` 字段中,微信 `purePhoneNumber` 又与 `countryCode` 分开传递,后端容易在国家码丢失后把境外号码误判为 `+86`
- 处理:手机号字段保留 `autocomplete="tel"``authService` 在请求前把 `+86 1xxxxxxxxxx``86 1xxxxxxxxxx` 拆为 `countryCode=86 + purePhoneNumber=1xxxxxxxxxx`。普通认证请求缺少 `countryCode` 时默认 `86`,但微信授权必须使用 provider 真实返回的 `countryCode + purePhoneNumber`,不能默认国家码。`module-auth` 先校验国家码,再用原纯手机号规则校验 `purePhoneNumber` 并生成 E.164;数据库仍只保存 E.164。
- 验证:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、定向 `api-server` 认证测试和 `npm run test -- src/services/authService.test.ts src/components/auth/AuthGate.test.tsx`,覆盖省略 / 显式 `86`、境外国家码、浏览器 `+86` 自动填充以及微信 provider 国家码路径。
- 关联:`server-rs/crates/module-auth/src/domain.rs``server-rs/crates/module-auth/src/errors.rs``server-rs/crates/api-server/src/phone_auth.rs``server-rs/crates/api-server/src/wechat/auth.rs``src/services/authService.ts``src/components/auth/LoginScreen.tsx`
## 本地短信收不到验证码先查 provider
- 现象:登录弹窗可以进入短信页签,但点击“获取验证码”后,手机没有收到短信。
- 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;真实阿里云链路已经改为普通短信 `SendSms`,验证码由当前 `api-server` 进程本地生成、哈希存储和校验,旧 `SendSmsVerifyCode` / `CheckSmsVerifyCode` 托管验证码参数不再参与真实校验。若接口直接返回“手机号登录暂未启用”,说明当前运行中的 `api-server` 进程内 `sms_auth_enabled=false`:常见原因是修改 `.env.local` 后没有重启后端,或外层 shell 已经设置了非空 `SMS_AUTH_ENABLED` 导致 dotenv 不覆盖。历史上 cmd 里 `set SMS_AUTH_ENABLED="true"` 会把引号也传进进程,Rust bool 解析失败后保持默认 false。
- 处理:真实短信联调时把 `.env.local``SMS_AUTH_ENABLED=true``SMS_AUTH_PROVIDER=aliyun` 显式打开,并确认 `ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com``ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技``ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486``ALIYUN_SMS_TEMPLATE_PARAM_KEY=code` 后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE`。Shell 临时覆盖时 PowerShell 用 `$env:SMS_AUTH_ENABLED="true"`cmd 用 `set SMS_AUTH_ENABLED=true`,不要把引号作为值的一部分。`api-server` 重启会清掉未校验的本地验证码。
- 验证:分别请求浏览器域名和 Rust API 直连的 `/api/auth/login-options`,都应返回 `["phone","password"]``api-server` 日志里 `provider=aliyun` 才说明真实短信链路已生效。需要直接确认平台层真实调用阿里云时,配置 `ALIYUN_SMS_ACCESS_KEY_ID``ALIYUN_SMS_ACCESS_KEY_SECRET``ALIYUN_SMS_REAL_TEST_PHONE_NUMBER` 后手动执行 `cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms_real_provider_sends_verify_code -- --ignored --nocapture`
- 关联:`server-rs/crates/api-server/src/config.rs``scripts/dev-utils.mjs``docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md``docs/technical/PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md`
## 手机验证码登录 500 先查短信 provider 语义
- 现象:登录弹窗手机号验证码登录失败,浏览器看到 `POST /api/auth/phone/login 500`,后端日志里同时出现阿里云短信 `UNKNOWN``biz.FREQUENCY``check frequency failed`
- 原因:真实短信 provider 的配置错误或上游失败曾被 `module-auth` 折叠成 `PhoneAuthError::Store`HTTP 层只能按内部错误返回 `500`,掩盖了 provider 失败。当前验证码校验已经改成本地哈希校验,登录阶段的验证码错误不会再调用阿里云校验接口;若登录前的发送阶段失败,应优先看 `SendSms` 返回的 `Code/Message`
- 处理:保留 provider 错误语义,配置错误映射 `503 Service Unavailable`,上游短信失败映射 `502 Bad Gateway`;本地只验证 UI/账号链路时可用 shell 临时覆盖 `SMS_AUTH_PROVIDER=mock` 后启动 `npm run dev:api-server`
- 验证:`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 又闪回“未登录”,登录弹窗可能重新出现。
- 原因:`AuthGate` 首次 hydrate 会异步轮换 refresh cookie 并请求 `/api/auth/me`。如果用户在 hydrate 完成前已经登录,晚到的旧 hydrate 仍可能把刚写入的 `user` 覆盖成 `null`
- 处理:给 `AuthGate` 的 hydrate 增加版本号保护;登录成功、退出登录和全局 auth 事件都会推进版本号,旧 hydrate 结果到达后直接丢弃。
- 验证:`npm run test -- src/components/auth/AuthGate.test.tsx`,新增用例应覆盖“旧 guest hydrate 不覆盖新登录态”。
- 关联:`src/components/auth/AuthGate.tsx``src/components/auth/AuthGate.test.tsx``docs/technical/AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md`
## 刷新网页后登录态失效
- 现象:刷新网页后,用户明明有本地 access token,却回到未登录状态。
- 原因:`AuthGate` hydrate 曾先强制调用 `refreshStoredAccessToken()`;当 refresh cookie 临时失效、代理错配或后端返回 `401` 时,该方法会先清空本地 access token,随后 `/api/auth/me` 只能恢复成未登录。
- 处理:`refreshStoredAccessToken()` 增加 `clearOnFailure` 选项;`AuthGate` 在已有本地 access token 时先用 `/api/auth/me` 确认用户,确认成功后再后台 refresh 续期与写每日登录埋点,后台 refresh 失败不清 token。
- 追加处理:`/api/auth/refresh` 只有明确返回 `401` / `403` 时才代表登录态权威失效,可以清本地 access token 并触发全局 auth 变化;服务器重启、Nginx 502/503/504、浏览器 `Failed to fetch` 或 refresh 响应契约异常都属于暂时不可用,不能把已有本地 token 清掉,否则重启窗口会把所有打开页面踢成未登录。
- 契约:`/api/auth/refresh` 成功响应按共享契约 `RefreshSessionResponse { token }` 解析;测试 mock 不要额外塞 `{ ok: true, token }` 遮住真实恢复路径。
- 验证:`npm run test -- src/services/apiClient.test.ts src/components/auth/AuthGate.test.tsx -t "explicit refresh opts out|auth gate keeps a valid local token login"`
- 关联:`src/services/apiClient.ts``src/components/auth/AuthGate.tsx``docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md`
## 登录后推荐页加载出作品又回到未登录
- 现象:前端登录成功后进入推荐页,推荐页自动加载出一个作品,随后瞬间回到未登录;停留在其他页面或推荐页没加载出作品时不复现。
- 原因:推荐页 embedded 运行态会自动发起受保护写请求。若这些卡片级后台请求遇到 `401` 或 refresh 失败,默认请求层曾清空 access token 并广播全局 auth 事件,导致 `AuthGate` 重新 hydrate 成未登录态。更隐蔽的是,`refreshAccessToken()` 自身曾在 refresh 失败时静默清 token,即便调用方关闭了 `clearAuthOnUnauthorized`,也可能让后续 hydrate 变成未登录。
- 处理:请求层统一使用 `authImpact: 'global' | 'local'` 区分账号权威请求与局部后台请求;推荐页自动运行态、图片换签、公开拼图运行态和平台 bootstrap 私有投影刷新统一使用 `BACKGROUND_AUTH_REQUEST_OPTIONS` / `RUNTIME_BACKGROUND_AUTH_OPTIONS`,并等 `canReadProtectedData` 为 true 后再启动;用户主动点击的账号动作仍保留默认全局鉴权失败处理。
- 追加处理:推荐页嵌入运行态要按真实身份分流,已登录或已有 access token 时继续走账号 Bearer + local auth impact,不能误带 runtime guest token;只有匿名访客才申请并透传 runtime guest token。
- 追加处理:generated 私有图片换签 `/api/assets/read-url` 也属于展示层后台请求;推荐页拼图运行态挂载后会立即解析封面图,若换签 401 触发全局鉴权事件,也会表现成“进入拼图作品后瞬间未登录”。资源换签失败只应让当前图片为空,不应清 token、广播 auth 事件或主动 refresh。
- 追加处理:从推荐页点进公开拼图作品并启动完整运行态后,`startPuzzleRun`、通关自动 `submitPuzzleLeaderboard`、下一关 `advancePuzzleNextLevel` 和重开同样属于当前玩法局部同步;这些请求失败时只应留在拼图错误态,不应清 token 或广播 auth 事件。
- 追加处理:通关后 `refreshSaveArchives()`、首屏 bootstrap 的个人看板/作品架/浏览历史读写也只是平台投影刷新,失败应显示局部错误,不能充当全局登录态判定。
- 追加处理:未登录推荐页启动任一公开正式玩法时,`/api/runtime/*` 局内路由必须使用 `RuntimePrincipal`,前端通过 `PlatformEntryFlowShellImpl` 的统一 request options helper 给 start / checkpoint / finish / input / drop / click / restart / time-up / leaderboard / next-level 等动作透传 runtime guest token;公开 runtime detail 读取如跳一跳、敲木鱼必须显式 `skipAuth/skipRefresh`,匿名推荐流不能补读受保护创作详情,否则会在真正开局前打出 `/api/auth/refresh 401`
- 验证:`npm run test -- src/services/apiClient.test.ts src/services/assetReadUrlService.test.ts``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation starts embedded puzzle"``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle runtime uses frontend move merge logic and backend leaderboard"``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle similar work keeps current run level progression"`
- 关联:`src/services/apiClient.ts``src/services/assetReadUrlService.ts``src/services/puzzle-runtime/puzzleRuntimeClient.ts``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md`
## 推荐页作品卡一直显示加载中
- 现象:推荐页有公开作品,但主视口一直停在“加载中...”,没有进入作品,也没有显示可操作错误。
- 原因:推荐页自动启动嵌入运行态时先设置 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,但失败或并发切换时外层缺少稳定错误态和请求版本保护,旧启动请求可能晚到覆盖新状态。
- 处理:`selectRecommendRuntimeEntry` 使用启动请求版本号丢弃旧请求;启动失败统一设置 `activeRecommendRuntimeError = "作品暂时无法进入,请稍后再试。"` 并关闭 `isStartingRecommendEntry`
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation surfaces start failure"`
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryHomeView.tsx``docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md`
## 推荐页未登录入口误打开公开详情
- 现象:新用户默认在发现页,但点击推荐页或推荐封面后,如果复用公开作品详情入口,可能绕过推荐页沉浸运行态,打开普通公开详情页。
- 原因:`RpgEntryHomeView` 曾只有 `onOpenGalleryDetail` 一个回调,同时服务发现页公开详情和推荐页作品入口;一旦为发现页保留公开浏览能力,推荐页也会跟着打开详情。
- 处理:公开详情与推荐页入口分离为 `onOpenGalleryDetail``onOpenRecommendGalleryDetail`。发现页、搜索和排行榜保留公开详情;推荐 Tab、推荐封面、推荐运行态错误重试和桌面推荐模块走推荐运行态入口,不再主动弹登录窗。登录门禁只保留给创作、个人作品、删除、发布、Remix 等账号或所有权动作。
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "logged out recommend"`
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md`
## Rust 冷编译导致 api-server 健康检查误超时
- 现象:旧 `npm run dev:rust` 在 Windows 冷编译/链接阶段误判 `/healthz` 等待超时并杀掉 `cargo run`;现入口为 `npm run dev``npm run dev:api-server`
- 原因:脚本把 SpacetimeDB 与 api-server 等待窗口混在一起,未考虑 Rust 冷编译耗时。
- 处理:按冷编译超时修复文档拆分等待窗口。
- 验证:冷启动时不再误杀仍在编译的 api-server。
- 关联:`docs/technical/API_SERVER_DEV_STACK_COLD_BUILD_TIMEOUT_FIX_2026-04-25.md`
## Windows debug api-server 主线程栈溢出
- 现象:`cargo check -p api-server``build_router` 测试通过,但 `npm run dev:api-server` 在 Windows debug 启动时 `thread 'main' has overflowed its stack`
- 原因:`api-server` Axum 路由树已经很深,debug 主线程默认栈偏小,初始化状态和构造路由时容易触顶。
- 处理:入口 `main` 用显式 16MB 栈线程启动 Tokio runtime,并把实际服务逻辑放入 `run_server()`;新增路由时优先用小 router `.merge()`,避免继续拉长主链。
- 验证:`npm run dev:api-server``/healthz` 返回 200,相关路由冒烟通过。
- 关联:`server-rs/crates/api-server/src/main.rs``server-rs/crates/api-server/src/app.rs`
## Windows debug api-server.exe 锁文件与强杀退出码容易混淆
- 现象:`cargo run -p api-server``npm run dev:api-server``failed to remove file ... target\debug\api-server.exe`;清理旧进程后,旧终端可能继续打印 `process didn't exit successfully: server-rs\target\debug\api-server.exe (exit code: 0xffffffff)`
- 原因:Windows 不能覆盖仍在运行的 exe;通常是上一条 `npm run dev:api-server` 链路仍在运行,进程树为 `npm run dev:api-server -> node scripts/dev.mjs api-server -> cargo run -> api-server.exe``0xffffffff` 常见于排障时用 `Stop-Process -Force` 强制结束旧 `api-server.exe` 后由 Cargo 回显,不一定代表新启动失败。
- 处理:先按目标路径确认并停止本仓库的旧 `api-server.exe` 及其父级 `cargo/node/cmd` 启动链路,再重新启动;不要同时开多个 `npm run dev:api-server`
- 验证:确认没有匹配 `C:\Genarrative\server-rs\target\debug\api-server.exe` 的进程后,`Remove-Item` 能删除旧 exe;随后 `npm run dev:api-server` 启动并访问 `/healthz` 返回 200。
- 关联:`scripts/dev.mjs``server-rs/crates/api-server/src/main.rs`
## dev scheduler 端口被旧进程占用时会误判健康检查
- 现象:旧本地 dev 链路可能输出 `Port 3000 is in use, trying another one...`,随后 `api-server.exe``AddrInUse` / `code: 10048`
- 原因:旧 `api-server` 仍监听默认 `8082` 时,脚本的 `/healthz` 探测会命中旧进程并误判新服务已就绪;旧 Vite 占住 `3000` 时,Vite 默认漂移到新端口,浏览器仍可能打开旧页面。
- 处理:`scripts/dev.mjs` 已在 publish / 编译前解析 SpacetimeDB、`api-server`、主站 Vite、后台 Vite 端口,并让 Vite 使用 `--strictPort`;遇到端口占用时会自动选择后续可用端口,也可显式传入 `--api-port` / `--web-port` / `--admin-web-port`
- 验证:默认端口被占用时,完整栈应打印 `[dev:ports] ... 不可用,改用 ...` 并把实际端口传给后续 publish、健康检查和 Vite 代理;清理端口后重新启动不再命中旧 `/healthz`
- 关联:`scripts/dev.mjs``docs/technical/DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md`
## Windows debug 长 SSE Future 触发 api-server 断连
- 现象:前端 Vite 代理请求 `/api/runtime/creative-agent/sessions/{sessionId}/messages/stream``read ECONNRESET`,随后 `api-server.exe``0xffffffff` 退出,`dev:spacetime` 回收 SpacetimeDB、Vite 和后台 Vite。
- 原因:单个 `async_stream::stream!` 中塞入 Agent 执行、外部模型请求、会话更新和大量 SSE 事件,会在 Windows debug 下生成很大的 Future;真实消费 SSE body 时容易触发 worker 线程栈压力或进程级中断,单元测试若只测函数和路由状态会漏掉。
- 处理:长 SSE 路由优先使用 `tokio::spawn` 跑业务流程,通过 `mpsc` + `UnboundedReceiverStream` 向 Axum 返回轻量 stream;失败时更新会话为 `failed` 并发送 SSE `error`,不要把大段执行逻辑内联到路由返回的 stream future 中。
- 验证:补充实际 `collect()` SSE body 的路由测试,确认首轮包含 `stage``puzzle_template_catalog``done`,且不会提前发送 `puzzle_template_selection` / `puzzle_cost_range`;再执行 `cargo check -p api-server``cargo test -p api-server creative_agent`,联调时用 `npm run dev:api-server` 检查 `/healthz`
- 关联:`server-rs/crates/api-server/src/creative_agent.rs``server-rs/crates/api-server/src/app.rs`
## creative-agent 过程项不要把历史事件渲染成运行中
- 现象:智能创作页过程中多个阶段从一开始同时转圈,生成结束或进入模板确认后仍有过程项保持转圈。
- 原因:前端把历史 `stage``tool_started``thought_summary_delta` 都按 active 渲染;后端工具开始/完成事件如果 `toolCallId` 不一致,也会导致开始事件无法收口。
- 处理:
- 只有最新且仍在执行的 stage 可为 active;等待确认、等待用户、target ready 和 failed 都是静态状态。
- 工具开始事件必须等同一 `toolCallId``tool_completed` 收口;兼容旧流时可按后续同名完成事件兜底。
- 思考摘要只展示用户可见摘要,且流结束或会话进入等待/完成/失败态后必须改成 done。
- 验证:前端测试断言完成后 `CreativeAgentProcessItem` 不再存在 `tone === 'active'`;后端测试确认工具开始/完成事件使用相同 `toolCallId`
- 关联:`src/components/creative-agent/creativeAgentViewModel.ts``server-rs/crates/api-server/src/creative_agent.rs``docs/prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md`
## creative-agent 会话切换要清理本地待确认模板
- 现象:用户在一个智能创作会话中点开模板确认面板后,立即切到另一条创作会话,可能看到上一会话的确认面板残留。
- 原因:模板确认面板的 `pendingSelection``CreativeAgentWorkspace` 本地 UI 状态,不属于后端 session 快照;组件复用时如果不监听 `sessionId` 清理,会跨会话泄漏。
- 处理:工作区以 `session?.sessionId` 为边界清空 `pendingSelection`;服务端仍以 `puzzleTemplateSelection` / `targetBinding` 作为正式业务状态。
- 验证:前端测试先点开模板确认面板,再 rerender 到另一 session,断言确认面板消失。
- 关联:`src/components/creative-agent/CreativeAgentWorkspace.tsx``src/components/creative-agent/CreativeAgentWorkspace.test.tsx`
## 创作 Tab 语义迁移后,旧“新建作品”测试要改看智能创作首页
- 现象:把 `create` 从旧创作中心切到 `CreativeAgentHome` 后,旧测试仍尝试在创作页找“新建作品”类型卡,导致用例失败或定位不到元素。
- 原因:产品语义已经变成“创作 = 智能创作首页,草稿 = 旧作品架”,但测试夹具和 helper 还沿用旧入口。
- 处理:把这类测试改成验证智能创作首页、快捷胶囊、抽屉与草稿 Tab;同时给 `useRpgEntryLibraryDetail` 这类恢复路径补上 `setPlatformTabToDraft`
- 验证:定向 `vitest``eslint``typecheck``check:encoding` 都通过。
- 关联:`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx``src/components/rpg-entry/useRpgEntryLibraryDetail.ts`
## server-rs 默认 cargo build 不能等同于构建 SpacetimeDB 模块
- 现象:在 `server-rs` 下无参数 `cargo build` 期望同时构建 `spacetime-module`,导致链接或构建范围误判。
- 原因:workspace default-members 当前只包含 `crates/api-server`SpacetimeDB module 有独立构建/发布方式。
- 处理:默认 Rust 构建只覆盖原生 `api-server`;本地模块发布继续走 `spacetime publish --module-path ... --build-options="--debug"` / bindings 生成流程。
- 验证:查看 `server-rs/Cargo.toml` default-members,并按相关 SpacetimeDB 文档执行模块构建。
- 关联:`server-rs/Cargo.toml``docs/technical/RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md`
## Windows 原生 `spacetime-module` 单测会链接缺失 SpacetimeDB 宿主符号
- 现象:在 Windows 上执行 `cargo test -p spacetime-module --manifest-path server-rs/Cargo.toml` 可能编译到链接阶段后失败,出现 `LNK2019` / `LNK1120`,缺失 `datastore_insert_bsatn``procedure_start_mut_tx``console_log` 等 SpacetimeDB 宿主符号。
- 原因:`spacetime-module` 依赖的 SpacetimeDB runtime API 面向 wasm 宿主环境,原生 test exe 链接不到这些宿主导出。
- 处理:日常语法和类型验证使用 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`;需要验证模块行为时走 SpacetimeDB publish/dev 或模块域纯 Rust crate 的单测,不把该原生链接错误当作业务测试失败。
- 验证:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 能通过;原生 `cargo test` 若仍报上述宿主符号缺失,按当前限制记录为未执行。
- 关联:`server-rs/crates/spacetime-module``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## Rust 构建不要让不可用的 sccache 阻断 rustc
- 现象:Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)``sccache: error: Timed out waiting for server startup`,或 `sccache: caused by: Failed to send data to or receive data from server / Failed to read response header / failed to fill whole buffer`;真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。
- 原因:环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper,但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。Windows 本机若配置了 `SCCACHE_OSS_*`sccache daemon 冷启动会先经 OSS/本机代理完成缓存读写检查,再监听 `127.0.0.1:4226`;代理或 OSS 链路慢时,Cargo 的 `sccache rustc -vV` 可能先超时。
- 处理:保留 `server-rs/.cargo/config.toml``rustc-wrapper = "sccache"`;本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server``scripts/dev.mjs` 给 Rust 子进程注入直通 wrapper,自动绕过项目默认 sccache,避免损坏的 daemon 阻断 `spacetime publish``api-server` 启动;显式设置的非 sccache 自定义 wrapper 会被保留。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro``sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`
- 验证:`rustc -Vv` 能输出版本;本地 `npm run dev` 能完成 `spacetime publish``api-server` `/healthz`、主站 Vite 和后台 Vite 启动;冷启动后原始 `cargo check -p api-server``cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明原始 Cargo/Jenkins 路径仍可使用 sccache/OSS 缓存;Jenkins 日志出现“未找到可用 sccache,改用 rustc 直接构建”后仍继续真实构建。
- 关联:`scripts/dev.mjs``jenkins/Jenkinsfile.production-stdb-module-build``docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md``docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本
- 现象:部署、回滚或 Jenkins Job 重建时参考旧发布文档,导致 systemd、Nginx、SpacetimeDB 自托管和生产包拆分不一致。
- 原因:旧 Jenkins / 旧本地远端部署脚本文档仍作为历史经验保留。
- 处理:生产相关操作先看 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`,再按需追溯旧文档。
- 验证:发布链路使用当前 `deploy/systemd``deploy/nginx``scripts/deploy``jenkins/Jenkinsfile.production-*`
- 关联:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
## Web Deploy 只从 Jenkins 构建归档取包
- 现象:`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`
## Copy Artifact Production 模式下来源 Job 必须显式授权
- 现象:Deploy / Publish / Import 在 `copyArtifacts` 立即报 `Unable to find project for artifact copy: <job>`,但 Jenkins 中的来源 Job、指定构建号和归档产物都存在。
- 原因:Copy Artifact 已启用推荐的 `Production` 模式,但产物生产者的 Jenkinsfile 没有 `copyArtifactPermission`;插件会把权限不足伪装成“找不到项目”。
- 处理:在产物生产者的 Declarative Pipeline `options` 内精确授权固定消费者:Stdb Build 授权 Stdb PublishAPI Build 授权 API DeployWeb Build 授权 Web DeployDatabase Export 授权 Database Import。不使用 `*`,不通过全局 `Job/Read` 扩权,不把插件退回 Migration 模式规避。
- 验证:运行 `npm run check:production-ops`;上线后先运行一次四个产物生产者中本次需要的 Job,确认 live `config.xml` 出现 `CopyArtifactPermissionProperty`,再重跑消费者。
- 关联:`jenkins/Jenkinsfile.production-stdb-module-build``jenkins/Jenkinsfile.production-api-build``jenkins/Jenkinsfile.production-web-build``jenkins/Jenkinsfile.production-database-export``scripts/check-production-ops-guardrails.mjs`
## Jenkins 生产流水线拉 Git 统一走本机 SSH
- 后续更新:2026-07-14 起所有生产 Job 的 `Pipeline script from SCM` 和 Jenkinsfile 内部 checkout 统一使用本机 SSH 地址 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git` 与凭据 `genarrative-local-gitea-ssh`,不再保留局域网 IP、HTTP 内网地址或公网 fallback。
- 现象:生产发布、数据库导入导出、服务器配置、构建或 `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` 失败;写死 `127.0.0.1:3000` 也会在当前执行 agent 不是 Gitea 所在机器时失败。
- 原因:`127.0.0.1` 只代表当前执行阶段的 agent 自身,因此 Git checkout 必须收口到同机运行 Gitea SSH、带 `linux && genarrative-build` 标签的 Jenkins Built-In Node;公网域名和局域网 IP 会引入额外网络、代理、TLS 与地址漂移。即使使用本机 Git,如果 `GitSCM` 没有显式 refspec 并开启 `CloneOption honorRefspec=true`Jenkins Git 插件仍会拉取所有分支。
- 处理:Full、Web、API、Stdb、Server-Provision 与数据库导入导出的源码准备统一在 Jenkins Built-In Node 使用 `GIT_REMOTE_URL=ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git``GIT_REMOTE_CREDENTIAL_ID=genarrative-local-gitea-ssh``GIT_REMOTE_FALLBACK_URL` 留空。数据库导入导出把经过 commit 校验的必要脚本 stash 给目标 agentrelease / dev 目标阶段只 unstash,不再 checkout Git 或挂载 Git SSH 凭据。首次 checkout 保留目标分支 refspec、`CloneOption shallow=true depth=1 noTags=true honorRefspec=true`,随后由 `scripts/jenkins-checkout-source.sh` 复用并在必要时逐步加深。
- 验证:扫描本地 Jenkins live Job `config.xml` 和所有生产 Jenkinsfile,确认 Git URL 均为 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`,凭据仍为 `genarrative-local-gitea-ssh` 且 fallback 为空;确认数据库导入导出在 Prepare 阶段 checkout / stash,目标阶段只 unstash;在 Jenkins 凭据环境运行 `git ls-remote ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git HEAD`,并运行 `npm run check:production-ops``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 下不能裸读
- 现象:数据库导入或导出流水线报 `INCLUDE_TABLES: unbound variable`,或其它可选参数在 Bash 中未定义即退出。
- 原因:Jenkins string/boolean 参数留空时不一定会导出同名环境变量,而生产数据库导入导出脚本块启用了 `set -u`
- 处理:进入 Bash 执行块后先使用 `${VAR:-}``${VAR:-默认值}` 收敛成本地变量;必填项使用 `${VAR:?中文错误}` 明确失败原因。
- 验证:扫描 `jenkins/Jenkinsfile.production-database-export``jenkins/Jenkinsfile.production-database-import`,确认 `INCLUDE_TABLES``CHUNK_SIZE``SERVER_BACKUP_DIRECTORY``SMOKE_HEALTH_URL` 等可选参数不再裸读。
- 关联:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md``jenkins/Jenkinsfile.production-database-export``jenkins/Jenkinsfile.production-database-import`
## Jenkins 二次 checkout 后脚本执行位会被 Git 还原
- 现象:`Genarrative-Server-Provision` 已在 shell 块前面对脚本执行 `chmod +x`,但进入 `Prepare Provision Tools` 后仍报 `scripts/prepare-server-provision-tools.sh: Permission denied` / `exit code 126`
- 原因:该阶段会先运行 `scripts/jenkins-checkout-source.sh`,脚本内部执行 `git reset --hard HEAD``git clean -fd`,会把前面临时 `chmod` 的执行位还原为 Git 记录的 mode;若被直接执行的脚本在仓库里是 `100644`,二次 checkout 后仍不可执行。
- 处理:需要直接以 `scripts/*.sh` 方式执行的 Jenkins 脚本应提交为 Git `100755`;如果只想临时授权,必须放在 `scripts/jenkins-checkout-source.sh` 完成之后。
- 验证:运行 `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 目标机只接收并执行 Jenkins 上传的脚本
- 现象:`Genarrative-Server-Provision` 选择 `DEPLOY_TARGET=development` / `release` 时,目标阶段仍要求填写 `SOURCE_GIT_REMOTE_URL`,或在目标 dev / release agent 上执行 Git checkout。
- 原因:旧流水线要求目标 agent 自己拉取 provision 脚本,导致服务器初始化依赖目标机到 Git remote 的网络可达性;公网 Git fallback 还会让目标 agent 内网源不可达时悄悄改从公网拉源码,掩盖路由问题。新口径改为 Jenkins 构建节点准备并上传脚本,目标机只接收和执行。
- 处理:`Prepare Provision Files``linux && genarrative-build` 上使用固定内网 SSH 源和 Jenkins 凭据 `genarrative-local-gitea-ssh` checkout / 校验 `SOURCE_BRANCH` / `COMMIT_HASH`,并把 provision 脚本、`scripts/deploy/**``deploy/**``.jenkins-source-commit` stash 给目标 agent。`Provision Target` 下的 `Receive Provision Files``Prepare Provision Tools``Provision Server` 必须运行在目标部署 agentdevelopment 使用 `linux && genarrative-dev-deploy`release 使用 `linux && genarrative-release-deploy`。目标 agent 不再需要 `SOURCE_GIT_REMOTE_URL`,也不再 checkout Git。
- 验证:Jenkins 日志中应先看到 `Prepare Provision Files``linux && genarrative-build` 上完成源码准备和 `stash 'server-provision-files'`,再看到 `Provision Target` 下的 `Receive Provision Files``Prepare Provision Tools``Provision Server` 在目标 dev / release agent 上运行;目标阶段日志不应出现 Git checkout、`SOURCE_GIT_REMOTE_URL``Git 主地址拉取失败...改用备用地址``https://git.genarrative.world/GenarrativeAI/Genarrative.git`
- 关联:`jenkins/Jenkinsfile.production-server-provision``scripts/prepare-server-provision-tools.sh``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## Server-Provision 不要无条件下载工具包
- 现象:目标 dev / release 机器已经安装正确版本的 SpacetimeDB 或 `otelcol-contrib`,但 `Prepare Provision Tools` 仍每次下载 release tarball,网络慢或 GitHub 不稳时会把服务器初始化卡在准备阶段。
- 原因:工具准备阶段如果只按“生成交付包”理解,会忽略它已经运行在目标部署 agent 上这一事实;此时目标机本地的 `/usr/local/bin/otelcol-contrib``${SPACETIME_ROOT}/bin/current` 就是可信状态源。
- 处理:`scripts/prepare-server-provision-tools.sh` 必须先检查目标机状态:`otelcol-contrib --version` 命中 `OTELCOL_VERSION` 时复制现有二进制;`spacetimedb-cli --version` 命中 `SPACETIME_EXPECTED_VERSION``SPACETIME_DOWNLOAD_ROOT` 推导出的版本且 standalone 同时存在时,复制 `${SPACETIME_ROOT}/bin` 并生成 wrapper。只有缺失、不可执行或版本不匹配时,才查 `PROVISION_DOWNLOADS_DIR` 或下载源。
- 验证:运行 `bash scripts/check-server-provision-tools.sh`;Jenkins 日志应先出现“检查目标机 ...”,已有版本命中时出现“复用目标机已有 ...”,且不出现“下载 ...”。
- 关联:`scripts/prepare-server-provision-tools.sh``jenkins/Jenkinsfile.production-server-provision``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 个人任务 scope 不得扩成 work/site/module
- 现象:个人任务配置为 `work` / `site` / `module` 后进度串桶或静默按 0 处理。
- 原因:首版个人任务只支持用户维度,非 user scope 会造成任务进度读取语义错误。
- 处理:Admin 任务配置页不展示范围选择,保存时固定 `scopeKind: 'user'`API 和领域构造层拒绝非 `User`
- 验证:非 `user` scope 返回错误;相关测试覆盖 `Site` / `Module` / `Work` 被拒绝。
- 关联:`docs/technical/RUNTIME_PROFILE_TASK_SCOPE_2026-05-04.md``docs/technical/ANALYTICS_DATE_DIMENSION_IMPLEMENTATION_2026-05-04.md`
## 拼图发布 409 不一定是接口故障
- 现象:拼图结果页点击发布后,控制台出现 `POST /api/runtime/puzzle/agent/sessions/{sessionId}/actions 409 (Conflict)`,用户只看到发布失败。
- 原因:`publish_puzzle_work` 是资产操作发布入口,发布前会预扣 `1` 枚泥点;余额不足时后端按业务冲突返回 `409 CONFLICT``details.message``泥点余额不足`
- 处理:前端发布弹窗在用户点击发布后必须保留并展示后端业务错误,不能只把错误写到弹窗背后的页面 banner。
- 验证:`PuzzleResultView` 单测覆盖发布弹窗内展示 `泥点余额不足`
- 关联:`src/components/puzzle-result/PuzzleResultView.tsx``docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md``docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md`
## 拼图发布检查阶段会在事件落库时炸 wasm
- 现象:拼图发布在“发布检查”环节直接报 `The module instance encountered a fatal error`wasm backtrace 指向 `spacetime_module::puzzle::publish_puzzle_work`,并停在 `procedure_commit_mut_tx` 的 commit 阶段。
- 原因:`publish_puzzle_work_tx` 会无条件调用 `emit_puzzle_work_published_event` 写入 `puzzle_event`;该表的 `event_id` 是主键,而事件 ID 由 `profile_id + published_at_micros` 组成。只要同一发布动作被重复执行、重放,或极端情况下发生时间戳碰撞,commit 时就会因主键冲突触发 fatal error。
- 处理:待修复。发布事件写入需要改成幂等,或在重复发布时显式跳过已存在的 `event_id`;发布动作本身也应补一层更明确的幂等键,避免把重复提交直接推到事务提交阶段。
- 验证:对同一 `session_id/profile_id/published_at_micros` 重复调用 `publish_puzzle_work` 时,不应再在 commit 阶段炸 wasm;正常发布仍应生成作品、更新 session,并可进入公开详情。
- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs``server-rs/crates/api-server/src/puzzle/handlers.rs``server-rs/crates/spacetime-client/src/module_bindings/puzzle_event_table.rs`
## 拼图会过早进入待发布态,结果页可能空图但仍显示可发布
- 现象:拼图创作有时刚结束就跳到“待发布”结果页,但结果页里的正式图还是空的,发布检查随后又会拦住,用户会感觉“已经完成了却又不能发布”。
- 原因:拼图的待发布判定太弱,`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 移动端放大溢出
- 现象:抓大鹅试玩入口进入后,3D 锅体和物体从中心圆形区域向右下溢出,顶部状态和底部备选栏也可能看起来被右侧裁切。
- 原因:`WebGLRenderer.setPixelRatio(...)` 会把绘图缓冲区乘上设备 DPR;如果没有给 `renderer.domElement` 单独设置 CSS `width/height: 100%` 和绝对铺满,浏览器可能把高 DPR 缓冲区尺寸当成页面显示尺寸。
- 处理:中心棋盘和托盘预览的 WebGL canvas 统一套用 `position:absolute; inset:0; width:100%; height:100%; display:block``renderer.setSize(..., false)` 只负责同步绘图缓冲区。
- 验证:强制移动端 `390x844`、DPR 2 截图,确认棋盘左右边界在视口内,canvas CSS 尺寸等于容器尺寸,内部 `width/height` 属性可大于 CSS 尺寸。
- 关联:`src/components/match3d-runtime/Match3DPhysicsBoard.tsx``docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md`
## Hyper3D subscriptionKey 不要按固定短文本限长
- 现象:抓大鹅生成草稿时,内联 Rodin 图生 3D 模型提交成功后,状态轮询报 `subscriptionKey 超过 256 字符`,导致 `/api/creation/match3d/sessions/{sessionId}/actions` 返回 400。
- 原因:`subscriptionKey` 是 Hyper3D 返回的 opaque token,长度由上游决定;后端状态查询曾复用普通文本校验,把它限制在 256 字符。
- 处理:`query_task_status``subscriptionKey` 只做 trim 和非空校验,不做固定长度限制;前端临时任务和 Match3D 草稿响应可继续展示该 token,但不要把它当作可编辑短文本。
- 验证:`cargo test -p api-server accepts_opaque_subscription_key_without_length_cap --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/api-server/src/hyper3d_generation.rs``docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md`
## 抓大鹅新草稿不要再接回 Rodin 或 GLB 生成
- 现象:修改抓大鹅素材时容易沿用旧 Rodin/GLB 方案,导致新草稿生成耗时变长、进度停在模型阶段,或运行态等待不存在的 GLB。
- 原因:仓库里保留了 Hyper3D 通用代理和历史模型字段,旧文档也曾要求草稿阶段同步生成 GLB。当前产品口径已经改为 2D 多视角素材。
- 处理:新 `match3d_compile_draft` 与批量新增只生成 2D 图片:每个物品 5 个形态,单张 `2K 1:1` 物品 spritesheet 固定 `10*10`,每行承载两种物品、每种五个形态,单张最多承载 20 种物品。素材图 prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,上传 OSS 前先把整张 spritesheet 绿幕处理为透明 alpha,再由运行态和编辑器按 alpha 连通域解析;`generatedItemAssets[].status` 使用 `image_ready`,发布校验看 `imageViews[]`、首图引用或可解析的物品 spritesheet。`generated-models` 仅用于历史外部模型链接转存,不能作为新生产链路。
- 验证:`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml``npm run test -- src\services\miniGameDraftGenerationProgress.test.ts src\components\match3d-result\Match3DResultView.test.tsx src\components\match3d-runtime\Match3DRuntimeShell.test.tsx`
- 关联:`server-rs/crates/api-server/src/match3d.rs``src/components/match3d-runtime/Match3DRuntimeShell.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅切图路径不能只用中文物品名
- 现象:草稿页 `素材配置 > 物品` 中多个素材名称不同,但预览图片完全一样。
- 原因:中文物品名经过 OSS 路径段清洗后都可能退化成 `item`,多张切割图片写到同一个 object key,后写入覆盖先写入。
- 处理:切割图上传路径必须带稳定唯一 `itemId` 前缀,例如 `items/match3d-item-1-item/views/view-01.png`;运行态读取 generated 私有图片时通过同源 `/api/assets/read-url` 换签,不直接请求裸 OSS 路径。
- 验证:后端单测覆盖中文名路径唯一,前端运行态测试覆盖 generated 图片源解析。
- 关联:`server-rs/crates/api-server/src/match3d.rs``src/components/match3d-result/Match3DResultView.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅生成素材不能只挂在 compile response
- 现象:抓大鹅草稿生成完成后停留在结果页能看到切割好的物品图片;退出后从草稿 Tab 重新进入同一草稿,素材列表变回默认占位或为空,已生成的物品名称和图片丢失。
- 原因:`generatedItemAssets` 如果只附加在 `match3d_compile_draft` 的 HTTP response draft 上,刷新或重进时 `getMatch3DWorkDetail` 只能读取 SpacetimeDB 中的 `match3d_work_profile`;旧 mapper 返回空数组,自然无法恢复素材。拼图链路已经通过 `save_puzzle_generated_images` 把候选图和 levels 写回 work profile,抓大鹅也必须同样写持久字段。
- 处理:compile 成功时把独立物品图片列表序列化写入 `match3d_work_profile.generated_item_assets_json``update_match3d_work` / `publish_match3d_work` 保留该字段;API work summary/detail 映射反序列化为 `generatedItemAssets`。前端保持“本次 draft 优先,重进 profile 兜底”的读取顺序。
- 验证:`cargo test -p spacetime-client match3d --manifest-path server-rs/Cargo.toml``cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml``npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`
- 关联:`server-rs/crates/spacetime-module/src/match3d/*``server-rs/crates/spacetime-client/src/mapper.rs``server-rs/crates/api-server/src/match3d.rs``src/components/match3d-result/Match3DResultView.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅试玩和正式运行态不要只读草稿页本地素材预览
- 现象:结果页能看到生成的物品图片,但点击试玩或从推荐 / 公开作品进入正式抓大鹅时,局内仍显示默认积木素材。
- 原因:结果页本地 `assetDrafts` 和作品 profile 的 `generatedItemAssets` 可能不同步;推荐流内嵌运行态若只读卡片摘要,卡片缺素材时会把已持久化 profile 素材丢掉;点击试玩时 React state 异步更新也可能让运行态第一帧读取旧 `match3dProfile`
- 处理:删除、批量新增、音效生成或封面引用物品素材后,都把当前 `generatedItemAssets` 写回作品 profile`Match3DResultView` 合并同 `itemId` 的 draft/profile 素材,用 profile 已有 `imageViews[]`、首图引用、`backgroundMusic``backgroundAsset` 补齐旧 draft;点击试玩前把试玩可用物品种类通过 `itemTypeCountOverride` 降到已生成 2D 素材数量;推荐流内嵌运行态启动前若卡片摘要没有物品图片素材,补读 `getMatch3DWorkDetail(profileId)` 并把详情资产传给 `Match3DRuntimeShell``PlatformEntryFlowShellImpl` 需要维护 `match3dRuntimeProfile`,在 `startMatch3DRunFromProfile` 创建 run 后立即锁定本次完整 profile,runtime 渲染时优先按 `run.profileId` 使用这份 profile,而不是等待普通 `match3dProfile` state 下一轮刷新。同 profile 下已有 `generatedItemAssets` 时不能因为图片完整性判断失败就覆盖为空数组。判断是否需要补读详情时只看 `imageViews[]``imageSrc/imageObjectKey`;背景、音乐、容器 UI 是附属运行态资产,不能单独证明物品素材已完整。
- 验证:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx``npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`,并检查历史草稿和公开 M3 作品的 Network 响应里 `generatedItemAssets[].imageViews/imageSrc/imageObjectKey`
- 关联:`src/components/match3d-result/Match3DResultView.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/match3d-runtime/Match3DPhysicsBoard.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅 UI 背景和容器只在顶层字段时也要传进运行态
- 现象:抓大鹅草稿 / 推荐卡片响应里已有 `generatedBackgroundAsset`,结果页 UI 预览能看到纯背景图和容器图,但进入试玩或正式局内仍显示默认渐变背景和默认圆形容器。
- 原因:部分链路把 UI 资产只放在作品顶层 `generatedBackgroundAsset` / `backgroundImageObjectKey`,没有同步放进首个 `generatedItemAssets[].backgroundAsset`;如果运行态入口只传 `generatedItemAssets``backgroundImageSrc``Match3DRuntimeShell` 就拿不到 `containerImageObjectKey`
- 处理:`PlatformMatch3DGalleryCard``mapPublicWorkDetailToMatch3DWork``resolveMatch3DRuntimeGeneratedBackgroundAsset``Match3DRuntimeShell` 都必须保留并传递顶层 `generatedBackgroundAsset`;运行态背景读取顺序为 `backgroundImageSrc` / 顶层 `generatedBackgroundAsset.image*` / `generatedItemAssets[].backgroundAsset.image*`,容器读取顺序为顶层 `generatedBackgroundAsset.containerImage*` / `generatedItemAssets[].backgroundAsset.containerImage*`
- 验证:执行 `npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "Match3D runtime"`;浏览器 Network 中背景和容器 generated path 应先请求 `/api/assets/read-url` 换签,局内出现 `match3d-background-image``match3d-container-image` 对应图片。
- 关联:`src/components/match3d-runtime/Match3DRuntimeShell.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/rpgEntryWorldPresentation.ts``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅容器参考图必须进入 edits multipart image 并接管棋盘外观
- 现象:抓大鹅结果页看似有容器生成入口,但真实生成出的局内容器不像 `pot-fused-reference.png`,或进入试玩后仍被默认圆形锅壳、金色边框和径向底色覆盖/裁切。
- 原因:容器参考图必须进入 `gpt-image-2` `/v1/images/edits` multipart `image` part,并配合强 prompt 锁定大尺寸轻俯视容器构图;即使生成了容器图,如果运行态继续保留默认 `rounded-full` 锅壳和 `overflow-hidden`,生成图也会被默认视觉覆盖或裁掉。
- 处理:抓大鹅 `1:1` 容器 UI 图统一调用 VectorEngine `POST /v1/images/edits`,参考 `public/match3d-background-references/pot-fused-reference.png` 的透明容器图由后端作为 `image` part 上传;该参考图属于后端生图协议输入,需通过 `include_bytes!` 编译进 `api-server`,不能在运行时按当前工作目录读取 `public/``Match3DRuntimeShell` 在容器图换签并成功加载后,把棋盘外壳切为透明和 `overflow-visible`,只在容器缺失或加载失败时使用默认圆形容器。
- 验证:执行 `cargo test -p api-server vector_engine --manifest-path server-rs/Cargo.toml``cargo test -p api-server match3d_background --manifest-path server-rs/Cargo.toml``npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`;真实联调看容器生成请求是否命中 `/v1/images/edits`,局内 `match3d-container-image` 是否渲染且 `match3d-board` 不再含默认 `rounded-full`
- 关联:`server-rs/crates/api-server/src/openai_image_generation.rs``server-rs/crates/api-server/src/match3d.rs``src/components/match3d-runtime/Match3DRuntimeShell.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅结果页音频试听也要先换签
- 现象:抓大鹅草稿生成完成后,背景音乐已写在 `generatedItemAssets[0].backgroundMusic.audioSrc`,但 `素材配置 > 背景音乐` 或物品详情音效 `<audio>` 不能播放,Network 可能请求裸 `/generated-match3d-assets/...mp3` 并返回 403。
- 原因:结果页试听控件和运行态一样运行在浏览器里,不能直接读取 generated 私有对象;只在运行态换签会造成“运行态可能有声,结果页不能预览”的割裂。
- 处理:结果页音频控件统一通过 `useResolvedAssetReadUrl` / `/api/assets/read-url` 取得签名 URL 后再传给 `<audio>`;换签失败时只显示“音频已绑定”,不要回退请求裸 generated path。
- 验证:`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx` 覆盖背景音乐和点击音效试听使用签名 URL。
- 关联:`src/components/match3d-result/Match3DResultView.tsx``src/services/assetReadUrlService.ts``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 法律文档弹窗通过 portal 挂载时要显式带平台主题
- 现象:登录弹窗内点击协议链接打开法律文档时,弹窗可能继承不到 `platform-theme--light/dark` 变量,或者层级低于登录遮罩导致不可见。
- 原因:`UnifiedModal` 默认通过 portal 挂到 `document.body`,不再处于原页面的主题容器内;登录弹窗自身又使用较高 z-index。
- 处理:法律文档弹窗组件应支持传入 `platformTheme`overlay 上显式挂 `platform-theme platform-theme--*`,并使用高于登录遮罩的层级。法律内容必须作为独立面板打开,不要在当前个人页或登录面板下方内联展开。
- 验证:登录页协议链接、个人页法律入口均能打开可滚动 `LegalDocumentModal`,亮色 / 暗色主题文本和按钮可读。
## 生成页完成回调不能只依赖异步 React state
- 现象:抓大鹅或拼图点击生成后,进度页已经显示 100% / 生成完成,但没有自动进入试玩或结果页。
- 原因:完成回调用 `selectionStageRef.current` 判断用户是否仍在生成页;如果执行 compile 前只调用 `setSelectionStage('*-generating')`action 很快返回时 ref 仍可能是旧 stage。
- 处理:进入各玩法生成页时同步写 `selectionStageRef.current = '*-generating'`,再调用 `setSelectionStage('*-generating')`。这不是为渲染服务,而是给同一异步链路里的完成回调提供即时事实。
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` 覆盖抓大鹅和拼图生成后自动试玩 / 返回结果页。
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 拼图最后一步到 100% 但不变绿优先看阶段映射
- 现象:拼图草稿生成跑完所有步骤后,总进度仍停在 98%,最后一步“写入正式草稿”显示 100% 但卡片不变绿,视觉上像还在进行中。
- 原因:进度条总进度刻意保留 98% 作为未收到 action 回包前的安全余量,但最后一步的绿色完成态只看步骤状态;如果时间轴已经跑到 `puzzle-select-image` 末尾却还没收到 `ready` 回包,最后一步会一直保持 active。
- 处理:`buildMiniGameDraftGenerationProgress` 需要在拼图最后一步时,把“预计写入时长已耗尽”单独判为 completed,避免出现“进行中 100%”。
- 验证:`npm test -- src/services/miniGameDraftGenerationProgress.test.ts`
- 关联:`src/services/miniGameDraftGenerationProgress.ts``src/services/miniGameDraftGenerationProgress.test.ts``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 微信支付回调验签不要用商户私钥
- 现象:微信小程序支付下单能返回 `prepay_id`,但真实支付通知验签失败,或者本地实现误把商户 API 私钥当作回调验签 key。
- 原因:商户私钥只用于商户请求微信支付和生成小程序 `paySign`;微信支付通知的 `Wechatpay-Signature` 需要使用微信支付平台公钥或平台证书公钥验签,并按通知头里的平台序列号匹配。
- 处理:api-server 真实微信支付配置同时需要商户私钥与微信平台公钥:`WECHAT_PAY_PRIVATE_KEY_*` 用于签名,`WECHAT_PAY_PLATFORM_PUBLIC_KEY_*``WECHAT_PAY_PLATFORM_SERIAL_NO` 用于通知验签,`WECHAT_PAY_API_V3_KEY` 只用于解密通知 resource。微信平台 `PUBLIC KEY` PEM 的 DER 内容是 SPKI `SubjectPublicKeyInfo`,初始化时必须解析并提取其中的 PKCS#1 `RSAPublicKey` DER 后再交给 `ring::RSA_PKCS1_2048_8192_SHA256`;不能把整段 SPKI DER 直接传给 `ring`。支付成功后只通过通知里的 `out_trade_no` 确认本地 pending 订单,并保存 `transaction_id``profile_recharge_order.provider_transaction_id`
- APIv3 通知成功应答使用 HTTP `204 No Content`,不要沿用 V2 XML 成功报文;失败仍返回 4XX/5XX 让微信重试。
- 验证:mock 通知测试只能覆盖本地回调推进;`platform-wechat` 必须用标准 SPKI `PUBLIC KEY` 和匹配私钥生成真实 RSA-SHA256 签名,覆盖 SPKI 到 PKCS#1 的解析与生产验签 helper。真实环境还需用微信支付平台公钥、真实通知头和 API v3 密钥验证签名与解密链路。
- 关联:`server-rs/crates/platform-wechat/src/pay.rs``server-rs/crates/api-server/src/wechat/pay.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 微信支付 JSAPI 下单必须显式带 User-Agent
- 现象:调用 `/v3/pay/transactions/jsapi` 失败,微信返回“Http头缺少Accept或User-Agent”。
- 原因:`reqwest` 请求即使已设置 `Accept: application/json`,也不会默认附带业务侧 `User-Agent`;微信支付网关会校验这两个头。
- 处理:`api-server` 的 JSAPI 下单请求统一通过 `with_wechat_pay_jsapi_headers(...)` 设置 `Accept: application/json``Content-Type: application/json``User-Agent: Genarrative-WechatPay/1.0`
- 验证:执行 `cargo test -p api-server jsapi_order_request_sets_wechat_required_http_headers --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/api-server/src/wechat_pay.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 容器公开列表压测不要靠继续抬并发吃满 CPU
- 现象:2C / 2G 容器压测公开 gallery list 时,`api-server` CPU 仍有余量,看起来像可以继续提高 `GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS` 或 Nginx `limit_conn`
- 原因:当前瓶颈不是 Tokio worker 线程数。`/api/runtime/puzzle/gallery``/api/runtime/custom-world-gallery` 成功响应后会走全局 route tracking,继续向 SpacetimeDB 写 `record_tracking_event_and_return`;入口并发从 320 抬到 336 / 352 时,SpacetimeDB 内存先逼近 `896m` 容器上限,200 请求 p95 变差,429 比例没有改善。
- 处理:2C / 2G 容器模拟里公开 gallery list 暂以 `limit_conn=320``GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320` 作为稳定上限。若要继续提升吞吐,优先减少高频公开 GET 的 tracking 写入、做采样或改成批量/异步聚合;不要单纯放大入口并发。
- 验证:宿主机 k6 打 `http://127.0.0.1:18080``PEAK_RPS=1000` 等价约 2000 HTTP req/s320 档无 dropped iterations、无 5xx、无 OOM200 请求 `request_time p95` 约 0.292s。336 / 352 档 p95 升到约 0.31s / 0.32sSpacetimeDB 内存尾部可到约 `880MiB / 896MiB`
- 关联:`deploy/container/nginx.conf``deploy/container/api-server.env.example``deploy/container/README.md``server-rs/crates/api-server/src/tracking.rs`
## tracking outbox 成功入库后删除 sealed 文件
- 现象:普通 route tracking 改为本机 outbox 后,容易误以为入库成功只需要清空文件内容。
- 原因:清空文件会扩大崩溃窗口,进程在 truncate 和确认之间异常退出时可能丢失未确认事件。
- 处理:当前 active NDJSON 达到数量或时间阈值后原子 rename 为 sealed 文件;后台批量 flush sealed 文件,SpacetimeDB 返回成功后直接删除该文件,失败则保留文件等待重试。sealed 文件如果出现无法解析的坏行,重命名为 `corrupt-*` 隔离并记录指标,避免阻塞后续批量入库。该路径是至少一次投递,重复事件由 `tracking_event.event_id` 幂等跳过。
- 验证:模拟 SpacetimeDB 不可用时 sealed 文件保留;恢复后批量 procedure 成功,sealed 文件消失,`tracking_event``tracking_daily_stat` 均更新。
- 关联:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md``server-rs/crates/api-server/src/tracking.rs``server-rs/crates/spacetime-module/src/runtime/profile.rs`
## 后台表查询展示 SpacetimeDB 枚举时不要套用 Option 解码
- 现象:后台“表查询”查看 `profile_recharge_order` 时,`kind``status` 显示为空数组 `[]`,例如充值订单原始行里 `points_60` 的类型和状态都不可读。
- 原因:SpacetimeDB HTTP SQL 对无载荷枚举会返回 SATS 形态 `[variant_index, []]`;后台通用 normalizer 曾把任何 `[0, value]` 都当作 `Option::Some(value)` 展开,导致 `[0, []]` 最终只剩 `[]`
- 处理:通用表查询解析应先按表名和列名识别已知业务枚举,再落回 Option / Timestamp 通用展开;例如 `profile_recharge_order.kind` 映射为 `points` / `membership``profile_recharge_order.status` 映射为 `pending` / `paid` / `failed` / `closed` / `refunded` / `expired`
- 验证:执行 `cargo test -p api-server admin_database -- --nocapture`,并确认后台详情弹层的 `raw` 与表格 `cells` 都显示业务字符串。
- 关联:`server-rs/crates/api-server/src/admin.rs``docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md`
## 后台通用表查询不能先按每页条数截断再筛选
- 现象:后台“表查询”填写关键词或 JSON 条件后查不到确定存在的记录;把“条数”从 100 调到 500 只能偶尔缓解,而且页面没有继续翻页的入口。
- 原因:旧实现先执行 `SELECT * FROM <table> LIMIT <limit>`,再对这批行做内存过滤;目标记录不在首批结果时永远无法命中,同时响应没有页码、匹配总数或扫描上限状态。
- 处理:用户输入继续不进入通用 SQL。API Server 通过单次 `SELECT * ... LIMIT 50001` 读取哨兵行,最多保留前 50,000 条候选,先过滤,再按后端接收的列名 / 方向对完整候选集稳定排序,最后分页;`totalMatched``scannedCount``scanLimitReached` 都从同一份 SQL 结果计算。请求页码超过实际总页数时钳制到末页,零结果固定为第 1 页。响应统一返回 `page``totalMatched``scannedCount``scanLimit``scanLimitReached`,后台翻页栏固定在视口底部;达到扫描上限时明确提示结果可能不完整。32 MiB 是候选 SQL 响应体硬上限,宽表即使每页条数很小也可能整次拒绝,不返回部分结果。实时写入可能改变相邻请求之间的候选快照,精确审计使用专用业务查询。
- 验证:执行 `cargo test -p api-server admin_database -- --nocapture`,覆盖第 101 行才命中、完整候选集排序后分页、哨兵截断、越界页码和响应体硬上限;前端测试覆盖下一页沿用已应用条件、后端排序参数 / 返回结果与扫描警告,并运行后台类型检查。
- 关联:`server-rs/crates/api-server/src/admin.rs``server-rs/crates/shared-contracts/src/admin.rs``apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx`
## 充值订单过期补偿不要放进外部生成 worker
- 现象:外部生成 worker/controller 扩容后,微信充值过期查单和关单流量也被同步放大;排查时还会误去外部生成 worker 日志里找支付过期任务。
- 原因:支付过期是账户资金链路,不是外部内容生成队列;旧实现把充值过期轮询 worker 挂在通用后台任务启动函数里,非 HTTP 角色也会启动。
- 处理:充值订单过期由 SpacetimeDB 原生 `profile_recharge_order_expiration_timer` 到点把 `pending` 改为 `expired`,只有 HTTP `api-server` 订阅活跃 timer 表的删除事件,按 `order_id` 重新读取订单并仅对 `expired` 查微信补偿;支付或主动关闭导致的删除信号会被状态判断忽略,断线窗口由未检查过期订单 catch-up 补齐。未支付终态本地保持 `expired`,不要再改写成 `closed`;微信成功支付通知或补偿查单仍可把 `Expired -> Paid` 入账。
- 验证:确认 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` / `external-generation-controller` 不启动充值过期监听;创建 pending 充值单后只由 scheduled reducer 产生 `expired`HTTP api-server listener 记录 `expiration_checked_at` 或补入账。
- 关联:`server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs``server-rs/crates/spacetime-module/src/runtime/profile.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 充值订单状态枚举不能用字符串 SQL 字面量订阅
- 现象:API 已 ready,但日志每 5 秒出现 `profile recharge expiration listener failed to subscribe`,并提示 `pending` 不能解析为 `profile_recharge_order.status` 的枚举类型;scheduled reducer 仍会把订单改成 `expired`,但微信查单补偿监听没有运行。
- 原因:SpacetimeDB 2.6 不会把订阅 SQL 中的 `'pending'` / `'expired'` 字符串自动转换为生成绑定的 sum-type enum;两个按状态过滤的订阅都在应用阶段失败。
- 处理:不要改成订阅完整 `profile_recharge_order` 历史表。后端订阅只保留活跃五分钟定时器的 `profile_recharge_order_expiration_timer`,监听 timer 删除后按 `order_id` 通过 procedure 读取订单,只处理当前状态为 `expired` 的记录;支付 / 关闭信号会被忽略,断线窗口继续由未检查过期订单 catch-up 补齐。这样既不依赖不受支持的枚举 SQL,也不会把充值历史常驻 API 客户端缓存。
- 验证:运行 `cargo test -p spacetime-client profile_recharge_expiration --manifest-path server-rs/Cargo.toml`,发布后确认 API 日志不再出现订阅解析错误,并用真实 pending 订单验证 scheduled reducer 过期后写入 `expiration_checked_at`
## 微信 Native 已入账但二维码弹窗不关闭
- 现象:微信支付回调已经返回 `204`,本地充值订单为 `paid` 且泥点已到账,但网页仍停留在“微信扫码支付”,必须点击“我已支付”才刷新。
- 原因:通用页面恢复确认逻辑在存在 `nativeWechatPayment` 时直接跳过,Native 分支创建二维码后也没有订阅订单 SSE,因此服务端回调发布的订单更新没有前端消费者。
- 处理:Native 二维码出现后立即调用 `watchWechatRpgProfileRechargeOrder` 订阅当前订单;收到终态后更新充值中心、关闭二维码、清理 pending ref、刷新全局余额并只展示一次结果。SSE 超时或暂时失败时在二维码过期前重连,手动确认与 SSE 并发时以 pending order ref 保证只有首个终态生效。
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 覆盖不点击“我已支付”也会在 SSE 返回 `paid` 后自动关闭;再运行根级 `npm run typecheck``npm run check:encoding``git diff --check`
- 关联:`src/components/platform-entry/usePlatformProfileCenterController.ts``src/services/rpg-entry/rpgProfileClient.ts``server-rs/crates/api-server/src/runtime_profile.rs`
## 商户平台退款登记不要混淆 refund_id 与 out_refund_no
- 现象:在“登记商户平台退款”里填写 `50000000000000000000000000000` 一类微信退款单号后提示找不到退款。
- 原因:该编号是微信侧 `refund_id`;V3 单笔退款查询路径只接受商户退款单号 `out_refund_no`。把 `refund_id` 放进路径不会自动转换,退款查单会返回 `RESOURCE_NOT_EXISTS`。不过微信没有承诺 `refund_id` 固定为 `50` 开头的 29 位数字,合法 `out_refund_no` 也可能是纯数字,因此形状判断不能代替真实查单。
- 处理:登记表单明确标注 `out_refund_no`,所有满足官方字符和长度约束的输入都交给服务端真实查询;只有查询确认不存在后,才把 `50` 开头的 29 位纯数字作为“疑似 refund*id”给出定向提示。只有 `refund_id` 时等待退款回调或 T+1 退款账单建立映射;不要调用异常退款申请接口冒充查询。`out_refund_no` 字符校验须覆盖官方允许的数字、大小写字母和 `* - | \* @`
- 验证:后台页面测试断言疑似编号仍交给服务端;平台适配器测试锁定 `RESOURCE_NOT_EXISTS` 映射和 `@` 字符,并确认真正的 `out_refund_no` 仍调用 `GET /v3/refund/domestic/refunds/{out_refund_no}`
- 关联:`apps/admin-web/src/pages/AdminRechargeOrderPage.tsx``server-rs/crates/api-server/src/admin_recharge.rs``server-rs/crates/platform-wechat/src/pay.rs`
## 微信支付查单的 REFUND 不等于已经全额退款
- 现象:一笔 6 元充值在商户平台成功退 3 元并登记 `out_refund_no` 后,本地显示累计已退 3 元、剩余可退 3 元,但后台再次预检仍显示“未核验 / REFUND”并禁止退款。
- 原因:微信支付订单查单的 `trade_state=REFUND` 只说明该支付订单发生过退款,不携带累计退款明细,也不表示已经全额退款。若后台把 `verified` 硬编码为 `trade_state == SUCCESS`,任何已成功部分退款的订单都会永久失去继续退款能力;反过来,仅看到 `REFUND` 就直接放行又可能漏掉未登记的商户平台退款。
- 处理:预检先查支付订单并校验商户订单号、支付单号和总金额,再主动刷新全部已知 `out_refund_no` 并重读本地退款 settlement。`SUCCESS` 可继续预检;`REFUND` 仅在本地累计成功退款大于 0 且小于订单总额,并且没有 `PROCESSING / ABNORMAL` 退款、活动 hold、退款欠账或人工冻结时,允许继续退本地剩余额度。没有本地成功退款能解释 `REFUND` 时使用独立原因码阻止并要求登记或对账,不能冒充“订单未支付”。
- 验证:后端策略测试覆盖 `SUCCESS + 0/600``REFUND + 0/600``REFUND + 300/600``REFUND + 600/600`;后台页面测试覆盖 `已核验 / REFUND` 时剩余额度可提交。真实联调核对累计退款、已追回泥点、活动占用和欠账均与退款明细一致。
- 关联:`server-rs/crates/api-server/src/admin_recharge.rs``server-rs/crates/spacetime-module/src/runtime/profile.rs``apps/admin-web/src/pages/AdminRechargeOrderPage.tsx`
## 抓大鹅历史草稿外部 Rodin GLB 链接必须转存后再试玩或发布
- 现象:草稿页预览模型失败并报 `GL_INVALID_ENUM: Invalid cap.`,或结果页能看到历史生成记录但试玩、发布和正式运行态仍显示默认积木。
- 原因:历史结果页手动 `重新生成` 会把 Hyper3D/Rodin 的外部 CDN 下载链接直接保存到 `generatedItemAssets[].modelSrc`,同时 `modelObjectKey` 为空。外部链接可能过期、跨域、返回 HTML 错误页或非 GLB 内容;前端预览和运行态不能把它当作稳定私有资产。
- 处理:该问题只适用于旧数据。结果页发现 `status = model_ready``modelSrc = https://...` 且无 `modelObjectKey` 时,可调用 `POST /api/creation/match3d/works/{profileId}/generated-models` 做一次性转存;新草稿和批量新增不得继续生成或依赖 GLB。若历史半修复数据同时保留外部 `modelSrc` 和平台 `modelObjectKey`,旧模型预览读取层优先用 `modelObjectKey`
- 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx``npm run test -- src\components\match3d-runtime\Match3DRuntimeShell.test.tsx``npm run test -- src\components\rpg-entry\RpgEntryFlowShell.agent.interaction.test.tsx``cargo test -p api-server match3d_model_download --manifest-path server-rs\Cargo.toml`,并检查修复后响应中的 `generatedItemAssets[].modelObjectKey` 不为空。
- 关联:`server-rs/crates/api-server/src/match3d.rs``src/components/match3d-result/Match3DResultView.tsx``src/components/match3d-result/Match3DModelPreview.tsx``src/components/match3d-runtime/Match3DPhysicsBoard.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅难度配置的物品种类和消除次数必须分离
- 现象:历史草稿选择标准 / 硬核难度后,系统可能把 `clearCount` 当成局内物品种类数量,导致标准需要 12 种、硬核需要 20/21 种;或者把第 11 到 20 个物品持久化为第 11 到 20 行,触发“系列素材图集持久化的行列索引必须落在 n\*n 范围内”。
- 原因:旧运行态把消除次数和类型数量绑在一起,结果页文案又同时展示“素材图片 / 局内类型”,导致前端、发布校验和 run start 口径不一致。
- 处理:生成和持久化固定使用 20 个物品素材;运行态物品种类口径为轻松 3、标准 9、进阶 15、硬核 20,历史 `clearCount=20` 且难度为硬核的运行态仍可升为 21 组三消,但类型池不超过 20。10*10 sheet 每行两种物品、每种五个形态,持久化行列为 `row = itemIndex / 2 + 1``col = itemIndex % 2 * 5 + viewIndex + 1`。发布前按 `image_ready`且有`imageViews[]``imageSrc/imageObjectKey`的生成素材数量阻断不足难度;试玩不阻断,但通过`itemTypeCountOverride` 自动降到已生成 2D 素材数量。重启从已有 run 快照反推实际物品种类,保持同一局重开不变。
- 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx``cargo test -p module-match3d --manifest-path server-rs\Cargo.toml`,涉及发布 reducer 时补跑 `cargo test -p spacetime-module match3d --manifest-path server-rs\Cargo.toml`
- 关联:`src/components/match3d-result/Match3DResultView.tsx``src/services/match3d-runtime/match3dRuntimeClient.ts``server-rs/crates/module-match3d/src/application.rs``server-rs/crates/spacetime-module/src/match3d.rs``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅标签清洗不要把 `3D素材` 当编号剥掉
- 现象:AI 或兜底生成的 `3D素材` 标签在后端规范化后变成 `D素材`
- 原因:标签清洗在去掉编号列表前缀后,又无条件剥离开头数字和标点,把合法标签中的 `3D` 当成列表编号处理。
- 处理:只移除明确的编号列表前缀,例如 `1. 标签``1、标签``1) 标签`;不要对普通标签开头数字做二次剥离。
- 验证:`cargo test -p api-server match3d_tag_normalization --manifest-path server-rs/Cargo.toml`,并保留 `normalize_match3d_tag("3D素材") == "3D素材"` 的单测。
- 关联:`server-rs/crates/api-server/src/match3d.rs`
## 抓大鹅物品切图白边或绿幕残留先查后端透明化
- 现象:抓大鹅生成的物品视角图裁剪后仍带白边,或者整块纯绿色绿幕背景没有被透明化,运行态看到绿色方块。
- 原因:素材 sheet 可能是“每格内部绿幕、整张图外圈近白底”,内部绿幕不一定连通到 sheet 外边缘;旧 flood fill 只从外边缘找背景会漏掉这种绿幕块。白底抗锯齿如果不纳入抠像和边缘去污染,也会随裁剪输出成一圈白边。即使顺序已是先整张 sheet 去绿再裁剪,较厚的半透明或混色软绿边仍可能低于高置信绿幕阈值,被当作前景带进独立 PNG。
- 处理:`api-server``slice_match3d_material_sheet` 必须先在整张 sheet 上做透明背景后处理:外边缘连通绿幕/近白底清 alpha,非连通但高置信纯绿块也清 alpha,沿整张 sheet 透明背景继续吃掉软绿边,边缘近白和绿幕抗锯齿做透明或去污染;同时保护不够纯的绿色主体像素。不要改成先裁剪单格再去绿。
- 验证:`cargo test -p api-server match3d_material_sheet_slicing --manifest-path server-rs\Cargo.toml` 覆盖非连通绿幕、白边、贴边主体保留和固定 `10*10` 切图;`cargo test -p api-server match3d_spritesheet_green_screen_postprocess_turns_background_transparent --manifest-path server-rs\Cargo.toml` 覆盖完整 spritesheet 上传前绿幕透明化。
- 关联:`server-rs/crates/api-server/src/match3d.rs``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅物品详情大方格只做单张大图查看
- 现象:结果页 `素材配置 > 物品` 打开详情后,上方大方格仍显示横向五图带、焦点内框或小缩略图边框,物品本体看起来偏小且像带着素材自带边框。
- 原因:旧预览把上方区域当作横向视角带,当前焦点只是带内缩略图的一张,视觉上不是“详细查看物品形象”的大图。
- 处理:上方方格只渲染当前选中的单张大图,使用 `object-contain` 和少量内边距放大查看;底部缩略图栏负责切换视角,缩略图可以保留选中态边框,但上方大图不渲染焦点内框或缩略图容器边框。
- 验证:`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx` 覆盖上方大图、底部缩略图和视角切换。
- 关联:`src/components/match3d-result/Match3DResultView.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 草稿页卡片有真实素材但仍显示黑卡先查摘要字段
- 现象:草稿页拼图卡片没有关卡图背景,抓大鹅卡片没有背景图或物品图背景,甚至兜底视觉也退回黑色面板。
- 原因:拼图列表摘要若不下发 `levels`,前端拿不到关卡 `coverImageSrc` / 候选图;抓大鹅列表摘要若只提供公开 URL、不保留 `generatedBackgroundAsset``generatedItemAssets` 中的 object key,前端无法换签读取私有生成图。卡片封面组件如果自带暗色默认背景,也会让兜底失败时看起来仍是黑卡。
- 处理:拼图 `map_puzzle_work_summary_response` 必须保留 `levels`;草稿页优先用关卡 `coverImageSrc`,再用候选图。抓大鹅货架封面解析必须读取 `backgroundImageObjectKey``generatedBackgroundAsset.imageObjectKey/containerImageObjectKey``generatedItemAssets[].imageObjectKey``imageViews[].imageObjectKey`。图片渲染统一交给 `ResolvedAssetImage` 换签,并给卡片传入玩法参考图与暖色底兜底。
- 验证:执行 `npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/hooks/useResolvedAssetReadUrl.test.tsx``cargo test -p api-server puzzle_work_summary_response_keeps_levels_for_shelf_cover --manifest-path server-rs\Cargo.toml``npm run typecheck`
- 关联:`src/components/custom-world-home/creationWorkShelf.ts``src/components/CustomWorldCoverArtwork.tsx``server-rs/crates/api-server/src/puzzle.rs``docs/technical/CREATION_WORK_SHELF_UNIFICATION_2026-04-25.md`
## 用户标签不要直接外显,SpacetimeDB Vec 字段不要写 default 宏
- 现象:给 `user_account.user_tags` 或邀请码独立标签列写 `#[default(Vec::<String>::new())]` 时,SpacetimeDB WASM 构建报 `destructor of Vec<String> cannot be evaluated at compile-time`
- 原因:SpacetimeDB 的 table default 宏会走编译期常量求值,不能直接使用有析构逻辑的堆分配类型默认值。
- 处理:`user_account.user_tags` 使用 `Option<Vec<String>>` + `#[default(None::<Vec<String>>)]` 表达数据库默认空,业务层统一把 `None` 归一化为空数组;邀请码授予标签复用 `metadata_json.userTags` 存储和解析,不再新增独立 Vec 列。用户标签原始值不得进入登录态、个人资料等通用响应,只能在明确业务白名单里投影,例如拼图排行榜 `visibleTags` 首版仅允许 `北科`
- 验证:`npm run spacetime:generate -- --rust-only` 能通过;`user_account` 旧迁移 JSON 缺字段时能导入,`profile_invite_code``metadata_json` 时按 `{}` 兼容。
- 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``server-rs/crates/spacetime-module/src/`
## 公开作品详情深链找不到作品不能停在空详情页
- 现象:直接访问 `/works/detail?work=PZ-...`,作品不存在或已下架时会弹出“作品不存在或已下架,将返回首页。”;关闭提示后仍可能停在大白屏。
- 原因:旧恢复逻辑只覆盖 `/runtime/...`,没有覆盖 `/works/detail`。同时 `selectionStage === 'work-detail'``selectedPublicWorkDetail === null` 时没有兜底渲染,详情数据为空就只剩空页面。
- 处理:公开详情失效统一走 `resolveWorkNotFoundRecoveryAction(...)`,覆盖 `/works/detail``/gallery/puzzle/detail``/gallery/visual-novel/detail`;搜索失败和拼图详情 404 分支清理详情/运行态临时状态并回首页;`work-detail` 空数据阶段显示轻量读取态,避免异步间隙白屏。
- 验证:`npm run test -- src/routing/runtimeNotFoundRecovery.test.ts``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct missing public work detail alert returns to platform home"`
- 关联:`docs/technical/PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md``src/routing/runtimeNotFoundRecovery.ts``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
## 拼图 UI 背景只有 objectKey 时不要回退默认 UI
- 现象:拼图草稿页、试玩和正式运行态都显示默认 UI,或者只在结果页看到生成图,进入试玩后又回到默认背景。
- 原因:`uiBackgroundImageSrc` 可能为空而真实生成结果只写了 `uiBackgroundImageObjectKey`;如果前端和运行态只读 `src`,或者本地试玩 / 正式 run 没把 `objectKey` 一起传递,就会丢掉已有背景。
- 处理:统一通过一个解析入口把 `uiBackgroundImageSrc || uiBackgroundImageObjectKey` 归一到可展示路径;本地试玩和正式运行态都要保留 `uiBackgroundImageObjectKey`,并在 `uiBackgroundImageSrc` 为空时换签读取。
- 验证:结果页 UI Tab、`startLocalPuzzleRun``PuzzleRuntimeShell` 都应在仅有 `objectKey` 时显示生成背景,不再回落默认 UI。
- 关联:`src/services/puzzle-runtime/puzzleUiBackgroundSource.ts``src/components/puzzle-result/PuzzleResultView.tsx``src/services/puzzle-runtime/puzzleLocalRuntime.ts``src/components/puzzle-runtime/PuzzleRuntimeShell.tsx``server-rs/crates/module-puzzle/src/application.rs`
## 拼图 UI 背景提示词或作品元信息异常先查首关命名契约
- 现象:拼图草稿生成完成后,第一关名称或作品名称变成 `levelNam` / `levelName` 这类字段名片段,或 `素材配置 > UI` 里显示的 `UI背景提示词` 像前端或后端模板拼接,而不是 AI 生成的视觉提示词。
- 原因:首关命名 LLM 旧契约只返回 `levelName`,自动 UI 背景阶段只能用作品名、作品描述、关卡描述和标签拼接确定性兜底提示词;如果模型返回截断 JSON,解析层还可能把 `levelNam` 这类字段名片段当作普通英文关卡名归一化通过。
- 处理:首关命名 LLM 契约必须同时返回 `{"levelName":"...","workDescription":"...","workTags":["..."],"uiBackgroundPrompt":"..."}`;解析层必须拒绝 `levelNam``levelName``workDescription``workTags``uiBackgroundPrompt` 等字段名片段作为关卡名。草稿自动 UI 背景生成优先使用该 AI 提示词,作品描述和 6 个作品标签默认填入草稿;视觉精修请求若返回新提示词或作品元信息则覆盖文本请求结果,否则保留文本请求结果。前端文本框只展示已保存的 `uiBackgroundPrompt` 或用户编辑值,字段为空时不展示本地兜底模板。
- 验证:执行 `cargo test -p api-server puzzle_level_naming_parser --manifest-path server-rs\Cargo.toml``cargo test -p api-server puzzle_first_level_name --manifest-path server-rs\Cargo.toml``cargo test -p api-server puzzle_initial --manifest-path server-rs\Cargo.toml``npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`
- 关联:`server-rs/crates/api-server/src/prompt/puzzle/level_name.rs``server-rs/crates/api-server/src/puzzle.rs``src/components/puzzle-result/PuzzleResultView.tsx``docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`
## 拼图 / 抓大鹅 UI 背景重生成报 No such procedure 先查 SpacetimeDB 版本漂移
- 现象:拼图或抓大鹅结果页点击 `重新生成` UI 背景时报 `No such procedure`,常见位置是泥点预扣、`save_puzzle_ui_background` 或 Match3D 草稿写回。
- 原因:`api-server``spacetime-client` 已按新 bindings 调用 procedure,但目标 SpacetimeDB 数据库仍运行旧 wasm,尚未导出钱包扣退费、拼图 UI 背景保存或 Match3D 写回相关 procedure。
- 处理:临时容错是把这类 `No such procedure` 当作后端版本漂移:泥点预扣阶段跳过扣费,图片已经生成但保存失败时返回本次内存快照 / 内存 profile,避免草稿页直接报错。长期修复仍是发布最新 `spacetime-module`、重新生成 bindings,并用 `spacetime describe` 或定向 smoke 确认 procedure 已导出。
- 验证:`cargo test -p api-server asset_operation_billing_skips_spacetime_connectivity_errors --manifest-path server-rs\Cargo.toml``cargo test -p api-server match3d_fallback_work_profile_keeps_generated_background_asset --manifest-path server-rs\Cargo.toml``npm run dev:api-server` 后检查 `/healthz`
- 关联:`server-rs/crates/api-server/src/asset_billing.rs``server-rs/crates/api-server/src/match3d.rs``docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 拼图合并块拖起后原位置出现红色块先查选中态泄漏
- 现象:拼图运行态中,多个拼图片合并后拖起整体块,原位置会露出一块粉红 / 红色底色。
- 原因:合并块拖拽的可见层来自 `mergedGroups` 绝对定位整体层,但 `pointerdown` 会同步写入 `selectedPieceId`;若棋盘格里的底层单块 DOM 先匹配选中态,再匹配合并态,整体层移开后就会露出单块选中填充色。
- 处理:合并格底层 DOM 只作为透明定位占位,`isSelected` 必须排除 `isMerged`;合并格样式优先级高于单块选中态。
- 验证:运行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx -t "拖拽合并大块时底层单格不显示选中色块"`,并确认合并块拖拽时底层 `[data-piece-id]` 仍为 `puzzle-runtime-piece--merged`
- 关联:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx``src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx``docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`
## 推荐页嵌入拼图通关结算不要放在运行态内部 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` 内。
- 关联:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx``src/index.css``src/components/rpg-entry/RpgEntryHomeView.tsx`
## 拼图历史图片列表不要把账号归属当图片名
- 现象:拼图创作页或结果页打开“选择历史图片”后,历史列表显示 `账号 user-1` 之类归属文案而不是图片名;`1713686400.000000Z` 这类时间显示为未知;选中后预览或生成参考图可能被怀疑不可用。
- 原因:`/api/assets/history?kind=puzzle_cover_image` 返回的 `ownerLabel` 是资产归属账号,不是图片标题;`createdAt` 可能是 SpacetimeDB / shared-kernel 秒级时间字符串,不能只用浏览器 `new Date(value)` 解析。历史图的 `imageSrc``/generated-*` 私有兼容路径,浏览器预览必须换签。
- 处理:前端标题和选中标签从 `imageSrc` 路径末尾推导,例如 `image.png`;时间解析兼容 ISO 与 `1713686400.000000Z`;创作页主图、历史列表图和结果页参考图继续用 `ResolvedAssetImage`,提交给后端时仍保留原始 `imageSrc`
- 验证:`npm run test -- src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`,并执行 `npm run check:encoding`
- 关联:`src/services/puzzle-works/puzzleHistoryAsset.ts``src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.tsx``docs/technical/ASSET_HISTORY_PUZZLE_COVER_KIND_FIX_2026-04-27.md`
## 拼图历史图关闭 AI 重绘不要强制 Data URL
- 现象:拼图创作页从历史生成图片中选择主图,再关闭 AI 重绘生成草稿时,后端报“上传图必须是图片 Data URL”。
- 原因:历史图 `imageSrc``/generated-puzzle-assets/...` 私有兼容路径;AI 重绘开启时后端参考图分支会解析该路径,但关闭 AI 重绘的“直用上传图”分支旧实现只调用 `parse_puzzle_image_data_url`
- 处理:关闭 AI 重绘时也复用拼图参考图解析入口,允许 Data URL 与 `/generated-*` 历史路径统一转成 `PuzzleDownloadedImage` 后持久化;前端不需要下载历史图再转 base64。
- 验证:`npm run test -- src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx``cargo test -p api-server puzzle_uploaded_cover_can_reuse_resolved_history_image --manifest-path server-rs\Cargo.toml``npm run dev:api-server` 后检查 `/healthz`
- 关联:`server-rs/crates/api-server/src/puzzle/draft.rs``server-rs/crates/api-server/src/puzzle/vector_engine.rs``src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx`
## 拼图结果页局部生图不要污染草稿生成态
- 现象:拼图草稿已经生成完成后,在结果页重新生成关卡图片或追加关卡生成图片,草稿页仍显示整卡“生成中”,点击草稿会回到生成过程页,无法查看已有结果;关卡图片生成中还会禁用“新增关卡”和其它关卡详情编辑。
- 原因:结果页局部 action 复用了全局 `isPuzzleBusy` / 持久化 `generationStatus=generating` 语义,作品架没有区分“初始草稿不可查看”和“已有结果上的局部关卡生成”。
- 处理:作品架只在拼图没有可用封面、首关候选图或任一可查看关卡时才把 `generationStatus=generating` 解释为初始草稿生成;结果页关卡图走 background action,不设置全局 busy,只标记对应关卡局部生成进度;SpacetimeDB/API mapper 读写时把已有图片但状态仍是 `generating` 的历史关卡归一为 `ready`
- 验证:`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx``cargo test -p api-server puzzle --manifest-path server-rs\Cargo.toml`
- 关联:`src/components/custom-world-home/creationWorkShelf.ts``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/puzzle-result/PuzzleResultView.tsx``server-rs/crates/api-server/src/puzzle/mappers.rs``server-rs/crates/spacetime-module/src/puzzle.rs`
2026-05-22 补充:结果页关卡详情的“关卡测试”不能把单关 `draft` 传给父级再调用 `updatePuzzleWork``updatePuzzleWork` 会同步 `puzzle_work_profile.levels_json` 和 source session 草稿,单关快照会把整份多关卡草稿覆盖成一个关卡,退出重进后只剩最后测试的关卡且序号表现为第一关。修复口径是 `PuzzleResultView` 始终传完整 `syncedDraft`,额外用 `{ levelId }` 指定起始关卡;父级持久化完整 levels 后调用 `startLocalPuzzleRun(item, levelId)`
2026-06-18 补充:结果页点击“新增关卡”只是在本地打开一个空白占位关卡,不应立刻进入自动保存。空白占位如果被写入 `/api/runtime/puzzle/works/{profile_id}`,在作品 profile 投影尚未稳定存在时会触发 `update_puzzle_work` 404,并且后续 session/draft 回读可能把当前详情弹窗关闭。修复口径是自动保存比较和 payload 过滤掉“后端基线中不存在且完全空白”的本地关卡;用户填写名称、描述、参考图或开始生成后再保存。`mergeDraftEditStateWithIncomingState(...)` 还要保留本地空白占位,避免 incoming draft 刷新时移除正在编辑的弹窗。
2026-06-18 补充:改造流的 `creative_agent` 草稿写回会用 `puzzle-session-*` 派生出的 `puzzle-profile-*` 调用 `update_puzzle_work`;如果前置 `create_puzzle_agent_session` 已写入 `puzzle_agent_session`,但派生的 `puzzle_work_profile` 草稿投影缺失,写回会报“拼图作品不存在”。首图生成或结果页保存也可能踩到同一缺口。修复口径是在 SpacetimeDB `update_puzzle_work_tx` 里只对稳定 `puzzle-profile-*` 反推同源 `puzzle-session-*`,确认 owner 匹配、session 未发布且有 draft 后恢复 draft profile,再继续更新;不要在前端重试或凭空创建任意 profile,也不要恢复已发布 session。
## 拼图上传图关闭 AI 重绘不要走首图生图
- 现象:用户在拼图入口页或结果页关卡详情上传图片并关闭 AI 重绘后,生成页仍显示“生成拼图首图”,或者后端仍调用 `generate_puzzle_image_candidates` 生成第一张 1:1 候选图。
- 原因:上传图直用路径应把 Data URL 或 `/generated-*` 历史图解析后持久化为 `sourceType=uploaded` 的正式候选,再继续生成 9:16 关卡画面、UI spritesheet 和纯背景;如果只把 `aiRedraw=false` 当作“不参考图片生成”,就会误走首图生成。
- 处理:入口页用 payload 的 `aiRedraw` 写入生成页 metadata`puzzleAiRedraw=false` 时进度跳过 `生成拼图首图`;后端 `compile_puzzle_draft` 和结果页 `generate_puzzle_images` 都在 `aiRedraw=false && referenceImageSrc 非空` 时走上传图直用候选。结果页关卡详情必须复用 `CreativeImageInputPanel`,不要把正式图当成可重绘参考图;本次上传或历史选择的图才显示 AI 重绘开关并可删除。
- 验证:`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts src/components/puzzle-result/PuzzleResultView.test.tsx``cargo test -p api-server puzzle_result_level_direct_upload_skips_cover_image_generation --manifest-path server-rs\Cargo.toml`
- 关联:`src/services/miniGameDraftGenerationProgress.ts``src/components/unified-creation/workspaces/PuzzleCreationWorkspace.tsx``src/components/puzzle-result/PuzzleResultView.tsx``server-rs/crates/api-server/src/puzzle/draft.rs``server-rs/crates/api-server/src/puzzle/generation.rs`
## Jenkins 数据库导入导出脚本先补 Node 工具链 PATH
- 现象:`Genarrative-Database-Import``Genarrative-Database-Export` 运行到迁移脚本时,`bash``node: command not found`,常见在日志里表现为某个 `sh` 块内第 61 行直接调用 `node` 失败。
- 原因:Jenkins 的非交互 shell 没有自动加载用户的 nvm/profile,数据库导入导出脚本又在 shell 里直接执行 `node scripts/spacetime-*.mjs`,因此只要 Jenkins agent 没把 Node 的 bin 目录放进 PATH,就会在迁移开始前失败。
- 处理:导入 / 导出流水线在调用迁移脚本前先 `source scripts/jenkins-prepare-toolchain-env.sh`;该脚本会把 `GENARRATIVE_JENKINS_TOOL_PATHS``/var/lib/jenkins/.nvm/versions/node/v22.22.2/bin``/var/lib/jenkins/.cargo/bin``/var/lib/jenkins/.local/bin` 和系统 PATH 前缀统一补齐,并在缺少 `node` 时尽早报错。
- 验证:重新跑 `Genarrative-Database-Import``Genarrative-Database-Export`,日志应先打印 `jenkins-toolchain``node=...` 解析结果,而不是在迁移中途报 `node: command not found`
- 关联:`scripts/jenkins-prepare-toolchain-env.sh``jenkins/Jenkinsfile.production-database-import``jenkins/Jenkinsfile.production-database-export``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## Runtime bootstrap secret 原文不能进入 WASM 或发布归档
- 现象:下载 Jenkins Stdb artifact 或检查 `spacetime_module.wasm` 能找到原始 bootstrap secretStdb Build / Publish 配了不同 credential,或 Publish 使用的 Secret File 摘要与 release manifest 不一致却仍继续发布;或者 module 已发布、`/var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt` 也已更新,但模型定价首次初始化、队列 claim 或钱包调用仍报 identity 未授权。
- 原因:把原文作为 Rust 编译环境变量会进入可下载 WASM;把 `migration-bootstrap-secret.txt` 归档会把构建凭据变成长生命周期 artifact。只把摘要编进 WASM、却不在 release manifest 绑定摘要并让 Publish 重算核对,仍可能把另一份 secret 配给已构建 module。另一方面,`AppConfig` 只在 api-server / worker 进程启动时读取直传值或 FILE,覆盖文件不会更新已运行进程;Full Build 又先发 Stdb、后发 API,不能等待后续 API deploy 才补旧 env。
- 处理:原始 bootstrap secret 固定为 64 位十六进制。WASM 编译只接受 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256`,模块对 procedure 入参原文重新计算 SHA-256 并做常量时间比较。生产 Jenkins Build / Publish 必须使用完全相同的 Secret File credential IDBuild 只计算摘要,WASM、artifact 和 `copyArtifacts` 不含原文,Stdb release manifest 记录 `migration_bootstrap_secret_sha256`Publish 重新读取同一 Secret File、校验 64 位十六进制并重算摘要,与 manifest 强制匹配后才把临时文件路径交给 `production-stdb-publish.sh`。module 发布后安装固定 runtime 文件为 `root:genarrative 0440`、目录 `root:genarrative 0750`,补齐 API / worker env,再在维护模式内重启发布前 active 的 API、controller 和 worker,并执行 API `/healthz` 门禁。人工构建自动生成的原文只放 gitignored `server-rs/.spacetimedb/build-secrets/<version>.txt`,目录 `0700`、文件 `0600`;本地 dev 的 API token 和按 server/database 作用域 secret 也分别持久化为 `0600` 文件。日志不得 `cat` 或插值打印明文。
- 身份轮换:bootstrap secret 只允许空表首次授权,不能重复接管既有 writer。migration operator 与 runtime writer 必须互斥:operator 不能成为 writer,当前 writer 不能被授权为 operator;已有任一 operator 后,bootstrap secret 不得新增或接管 operator。生产 token 确需轮换时,使用 `scripts/deploy/production-runtime-writer-identity-rotate.mjs`,由当前已授权 migration operator 登录态双录新 writer identity、填写操作人和原因;procedure 必须拒绝把新 writer 设为当前 writer 或任一 migration operator,并写 `editor_generation_runtime_identity_rotation` 审计。成功后先核对审计,再切换 token,不得靠重启 API 隐式改 writer。
- 验证:运行相关部署脚本 `bash -n``node --check scripts/dev.mjs scripts/check-production-ops-guardrails.mjs scripts/deploy/production-runtime-writer-identity-rotate.mjs``npm run check:production-ops`,扫描 artifact 清单和 diff,确认 Build / Publish credential ID 一致、manifest 摘要与 Publish Secret File 匹配,并确认不存在 `migration-bootstrap-secret.txt`、原文编译环境变量、`cat` 或生产 env 明文键,同时验证 64 位十六进制规则及手工 secret / 本地 token 文件权限。
- 关联:`server-rs/crates/spacetime-module/src/migration.rs``scripts/dev.mjs``scripts/build-production-release.sh``scripts/deploy/production-stdb-publish.sh``scripts/deploy/production-runtime-writer-identity-rotate.mjs``jenkins/Jenkinsfile.production-stdb-module-build``jenkins/Jenkinsfile.production-stdb-module-publish`
## 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。
- 验证:检查 Jenkins build log 中是否出现 `[jenkins-powershell] user:``[jenkins-powershell] exe:`,以及 `[stdb-checkout] current HEAD:`。上游 Full Build 传下来的 `COMMIT_HASH` 若已等于当前 GitSCM checkout,日志应显示 `requested commit already matches Jenkins GitSCM checkout` 并继续进入构建阶段;同时确认 `builds/<n>/log` 不再停在 `PipelineNodeTreeScanner... Cannot run program "powershell"` 或 Checkout 内部 exit code 5。
- 关联:`jenkins/Jenkinsfile.production-stdb-module-build``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## Server-Provision Windows 下载 helper 不要原地重写临时 ps1
- 现象:`Genarrative-Server-Provision` 的 Windows 下载阶段已经打印了 `[jenkins-powershell] user:``[jenkins-powershell] exe:`,但在 `.ps1` 原地 BOM 重写前后仍然返回 `exit code 5` / `拒绝访问`,且下载目录还没创建。
- 原因:Jenkins `writeFile` 生成的临时 `.ps1` 正被同一个 workspace 里的 PowerShell 进程马上重写成 BOM 文件,这个原地改写在本地 Windows Jenkins 环境里比直接脚本执行更容易碰到 workspace 占用或 ACL 拒绝。对这条流水线来说,BOM 不是必须的执行条件。
- 处理:`runWindowsPowerShell(...)` 改成先 `writeFile`,再由显式 `powershell.exe` 读取脚本文本并用 `ScriptBlock::Create(...)` 直接在内存中执行,不再对同一个 `.ps1` 做 BOM 重写。Windows 下载脚本里先把 `PROVISION_DOWNLOADS_DIR` 归一到 workspace 绝对路径,并补 `Windows workspace` / `download dir` / `已创建下载目录` 三段日志,方便区分是路径问题还是下载问题。
- 验证:Jenkins log 应先出现 `[jenkins-powershell] workspace:``[jenkins-powershell] loaded bytes:`,再出现 `[prepare-provision-downloads] Windows workspace:``[prepare-provision-downloads] 已创建下载目录:`;如果下载 URL 故意指到不可达地址,应该只在 `curl 下载失败` 处结束,而不是卡在 BOM 重写前。
- 关联:`jenkins/Jenkinsfile.production-server-provision``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## SpacetimeDB update installer 不要按带 host 后缀的下载文件名执行
- 现象:Server-Provision 目标机阶段已经显示“使用已下载的 SpacetimeDB Linux update installer”,随后报 `Error: unexpected argument '-y' found` 或前置 `unknown command name for spacetimedb-update multicall binary`
- 原因:`spacetimedb-update-*` 不是当前离线交付的最终形态,GitHub release 页面真正可比较的缓存对象是 `spacetime-x86_64-unknown-linux-gnu.tar.gz` 这种 release tarballGitHub release asset API 暴露的是 `digest` / SHA256,不是 MD5。
- 处理:Windows 下载阶段应直接缓存 release tarball 和 `otelcol-contrib_0.151.0_linux_amd64.tar.gz`,目标机 `scripts/prepare-server-provision-tools.sh` 只解压本地 tarball 生成 `bin/current/spacetimedb-cli``bin/current/spacetimedb-standalone`,不要再把 update installer 当成最终离线包执行。
- 验证:Jenkins 目标机日志不再出现 `unexpected argument '-y'``unknown command name for spacetimedb-update multicall binary`,后续应继续检查 `bin/current/spacetimedb-cli``bin/current/spacetimedb-standalone` 是否生成。
- 关联:`scripts/prepare-server-provision-tools.sh``jenkins/Jenkinsfile.production-server-provision`
## 清库重建后先查 schema 兼容再重启
- 现象:`npm run dev -- --clear-database --no-interactive` 之后,api-server 仍在 `GET /api/creation-entry/config` 或订阅恢复阶段报 `No such procedure` / schema guard 失败。
- 原因:本地重建只会重发当前 `spacetime-module`,不会自动修正旧迁移 JSON 的字段兼容;如果 `migration.rs` 没把新字段补成 `None` / 默认值,清库后重建仍会卡在 schema 同步。
- 处理:先让 `server-rs/crates/spacetime-module/src/migration.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` 和生成绑定对齐,再执行清库重建。
- 验证:`npm run check:spacetime-schema` 先通过,再重启 `npm run dev -- --clear-database --no-interactive`,最后检查 `/v1/ping``/healthz``GET /api/creation-entry/config`
- 关联:`server-rs/crates/spacetime-module/src/migration.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``scripts/dev.mjs`
## QQ 浏览器发现页推荐封面全不显示先查 aspect-ratio 兜底
- 现象:发现页的“推荐”子频道作品卡标题、作者和数据正常,但所有封面图不显示,常见于 QQ 浏览器 / X5 等旧移动内核。
- 原因:公开作品卡封面内部图片是绝对铺满,容器原本主要依赖 Tailwind `aspect-video` / CSS `aspect-ratio` 撑高;旧内核不支持或实现异常时封面容器高度会坍缩为 0。若封面还是 `/generated-*` 私有资源,换签失败后没有玩法参考图兜底时会进一步表现成黑卡。
- 处理:`.platform-public-work-card__cover::before` 使用 `padding-top: 56.25%` 保留 16:9 高度,沉浸式卡片单独覆盖比例;公开作品卡通过 `resolvePlatformWorldFallbackCoverImage(...)``ResolvedAssetImage` 传入玩法参考图兜底,签名失败或图片加载失败时仍有可见封面。
- 验证:`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`
## 公开作品卡作者行不要拼手机号或陶泥号
- 现象:发现页 / 推荐页公开作品卡作者行显示 `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 共用同一写成绩路径,未发布或未确认资源的草稿试玩会污染正式单局、排行榜和作品统计。
- 原因:`BarkBattleRuntimeShell` 同时承担草稿预览和发布后运行态,需要由调用方显式传入 `runtimeMode` 区分是否写正式 run。
- 处理:草稿结果页试玩保持 `runtimeMode=draft`,只做本地预览;发布成功后先进入 `/works/detail?work=BB-xxxxxxxx`,再从详情页以 `runtimeMode=published` 进入正式 runtime,并在开始/结算时分别调用 `startBarkBattleRun``finishBarkBattleRun`
- 验证:草稿试玩不触发 start / finish run;正式 runtime 必须先通过麦克风授权,再写 start run 和结算派生指标。
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx``src/services/bark-battle-runtime/barkBattleRuntimeClient.ts`
## 汪汪声浪移动端创作表单不要再套一层纵向滚动
- 现象:移动端创作 Tab 里进入汪汪声浪表单后,页面右侧出现不自然的内层滚动条,最后的形象描述输入框容易被“生成草稿”按钮、键盘或底部 TabBar 挤压 / 遮挡;顶部玩法卡首尾也可能贴边显得被裁。
- 原因:外层 `.platform-tab-panel` 已经是纵向滚动容器,创作页中间又有多层 `overflow-hidden`,旧的 `BarkBattleConfigEditor` 根节点再加 `overflow-y-auto`,形成外层 Tab 面板 + 内层表单的套滚动;底部按钮只预留 safe-area,不预留真实操作区距离;顶部玩法卡横向滚动条隐藏且首尾没有 scroll padding。
- 处理:移动端让 Bark Battle 表单跟随父级滚动,`lg` 以上才恢复表单内滚动;创作页容器移动端使用 `overflow-visible` 和 safe-area 底部 padding;顶部模板 tablist 加 `scroll-px-3` / 横向 padding,移动端卡片宽度收窄,避免首尾 ring 和圆角贴边裁切。
## 统一创作页不要把竖屏滚动锁进内部内容区
- 现象:竖屏打开拼图、抓大鹅或敲木鱼创作页时,浏览器页面本身无法滚动,生成按钮或右侧表单面板落到视口外;木鱼的敲击音效和功德词条看起来像被塞进单独滑动窗口。
- 原因:平台根壳固定一屏并隐藏溢出,`UnifiedCreationPage` 又使用 `h-full min-h-0 overflow-hidden` 和内容区 `overflow-y-auto`,导致滚动责任落到内部内容窗,而不是整个创作 stage。
- 处理:`UnifiedCreationPage` 统一负责标题、隐藏字段契约、内容包装和页面级纵向滚动;拼图、抓大鹅、跳一跳和敲木鱼的外层 `motion.div` 不再额外包 `overflow-y-auto`。各工作台在 `unifiedChrome` 下收起旧 `h-full overflow-hidden` 外壳,让表单主体跟随统一页面滚动。
- 验证:用竖屏浏览器视口打开 `/creation/wooden-fish``/creation/puzzle``/creation/match3d``/creation/jump-hop`,统一创作页应可滚动到生成按钮;`.unified-creation-page` 应包含页面级 `overflow-y-auto`,木鱼工作台内部也不应出现独立纵向滚动容器,拼图 / 抓大鹅可见标题不应重复。
- 验证:`npm run test -- src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "create tab shows template tabs"`、移动端视口检查最后一个输入框与“生成草稿”按钮不重叠。
- 关联:`src/components/bark-battle-creation/BarkBattleConfigEditor.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 汪汪声浪拟声词不要被默认狗主题锁死
- 现象:创作者把主题或形象改成机甲、猫、骑士等非狗主题后,局内仍播放 `轰汪!``汪爆!` 这类狗叫词,表现像系统强行把主题带回狗。
- 原因:拟声词 textarea 如果一开始就填入默认小狗词池,并且始终作为自定义 `onomatopoeia` 提交,runtime 会优先使用该字段,无法再根据新的 `themeDescription` / `playerImageDescription` / `opponentImageDescription` 走主题 fallback。
- 处理:`BarkBattleConfigEditor` 需要区分“系统默认词池”和“创作者已手动编辑”。未手动编辑时随主题 / 形象描述自动重算;手动编辑后才冻结为自定义词池。默认词池只在命中狗相关关键词时加入狗叫词,非狗主题使用科技、幻想或通用高能词。
- 验证:`npm run test -- src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/games/bark-battle/ui/__tests__/BarkBattleRuntimeShell.test.tsx`,并确认非狗主题的拟声词不含 `汪`
- 关联:`src/components/bark-battle-creation/BarkBattleConfigEditor.tsx``src/games/bark-battle/application/BarkBattleConfig.ts``src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx`
## Jenkins Web 构建公开作品号导出缺失优先补 publicWorkCode
- 现象:`Genarrative-Web-Build``npm run build:production-release -- --component web` 阶段失败,Rollup 报 `"buildJumpHopPublicWorkCode" is not exported by "src/services/publicWorkCode.ts"`,但导入方 `rpgEntryWorldPresentation.ts``PlatformEntryFlowShellImpl.tsx` 已经引用该玩法公开码函数。
- 原因:玩法分支合并时容易只带入新玩法的 `publicWorkCode.ts` 导出,覆盖或遗漏另一个玩法的公开码 builder / matcherVite 构建会在静态导出检查阶段直接失败。
- 处理:在 `src/services/publicWorkCode.ts` 中保持每个玩法的 `build<Play>PublicWorkCode``isSame<Play>PublicWorkCode` 成对导出;跳一跳使用 `JH-` 前缀和 profileId 后 8 位规范化后缀。补 `src/services/publicWorkCode.test.ts` 覆盖 builder 和 matcher,避免后续合并再次丢失导出。
- 验证:`npm test -- src/services/publicWorkCode.test.ts`,并用 `npm run build:production-release -- --component web --name <临时名>` 复现 Jenkins web 构建路径。若 `npm run typecheck` 仍报 JumpHop 阶段或状态变量缺口,那是远端当前 JumpHop 接线未收齐的独立问题,不等同于该 Rollup 导出失败。
- 关联:`src/services/publicWorkCode.ts``src/components/rpg-entry/rpgEntryWorldPresentation.ts``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 跳一跳前端壳层接线不要只合渲染分支
- 现象:`npm run typecheck` 大量报 `setJumpHopSession``jumpHopRun``jumpHopGalleryEntries``mapJumpHopWorkToPublicWorkDetail` 不存在,以及 `"jump-hop-runtime" is not assignable to SelectionStage`;即使 typecheck 过了,分享或刷新 `/runtime/jump-hop?work=...` 仍可能掉回首页。
- 原因:跳一跳工作台、生成页、结果页、runtime 和推荐流渲染分支已经合入 `PlatformEntryFlowShellImpl.tsx`,但平台壳层状态、public detail mapper、`SelectionStage` union 与 `appPageRoutes.ts` 阶段路由映射没有一并合入;发现页卡片分类也没有先判断 `isJumpHopGalleryEntry`,导致 fallback 访问 RPG `themeMode`
- 处理:`platformEntryTypes.ts` 必须注册 `jump-hop-workspace/generating/result/runtime/gallery-detail``appPageRoutes.ts` 必须补 `/creation/jump-hop/workspace``/creation/jump-hop/generating``/creation/jump-hop/result``/gallery/jump-hop/detail``/runtime/jump-hop``PlatformEntryFlowShellImpl.tsx` 必须持有 JumpHop session/work/run/gallery/runtimeReturnStage/generationState/error/busy,并提供 `mapJumpHopWorkToPublicWorkDetail``RpgEntryHomeView.tsx` 的公开卡片类型描述要给 JumpHop 单独返回 `跳一跳`
- 验证:`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`
## 跳一跳地块图集固定走 18 个 UV 大单元
- 现象:跳一跳初始草稿生成时报 `系列素材图集的物品行数不能超过 n。`,或者生成完成后只有 atlas 预览路径,地块切片没有真正落盘。
- 原因:旧模板先后尝试过通用系列素材 helper、`2x3` 六格固定 tileType 和 `5x5` 单贴图池,但当前跳一跳已经重设计为“主题 -> 一张 `1024x1536` 图集 -> 18 个 `3列*6行` UV 大单元 -> 每格 `4列*3行` 六面贴图 -> 无限路径”,旧的物品行数 / 固定类型模型都会把创作链路带偏。
- 处理:跳一跳地块固定只生成一张 `1024x1536` 主题 UV 展开图集,后端先切出 18 个大单元,再从每格固定 UV 网切出 top/front/right/back/left/bottom 六张 `256x256` 不透明 PNG,并对 108 张面贴图各自走 OSS 上传、asset_object 确认和 entity bind;不要再恢复 `2行*3列``5x5` 单贴图、`start / normal / target / finish / bonus / accent` 六格口径。
- 验证:`jump_hop.rs` 不应再调用通用物品行数模型处理地块图集;公开结果里应能拿到 18 个独立 `JumpHopTileAsset` 且每个新资产包含 `faceAssets` 六面贴图,运行态无限路径从地块池随机取材;旧资产没有 `faceAssets` 时仍能用 `imageSrc` 单贴图 fallback。
- 关联:`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"`
- 原因:18 个立方体主题物体 UV 展开图集 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` 只是路径平台的玩法类型标签,18 个 atlas 大单元里会重复出现 `normal / target / bonus / accent` 等类型。若后端持久化时用 `tileType` 生成 slot/path,同类型切片会写入同一个 `/generated-jump-hop-assets/<profile>/<slot>/image.png`,后上传的切片覆盖先上传的切片,前端换签缓存也会读到重复或旧对象。
- 处理:后端切图后必须按 atlas 单元格写入 `tile-01``tile-18` 的唯一 tile slot,并把六面贴图写入 `tile-XX-top/front/right/back/left/bottom` 唯一 face slot;前端结果页和运行态展示生成图时用 `assetObjectId` 作为 `refreshKey`,避免重生成后复用旧签名或旧图片缓存。
- 验证:`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` 应包含 `jump_hop_tile_asset_slots_are_unique_for_eighteen_slices`;前端运行态测试应断言地块换签带 `assetObjectId` 刷新键,并覆盖新 UV 资产会解析六张面贴图。
- 关联:`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`
## 跳一跳长按蓄力不能再消费拖拽方向
- 现象:跳一跳改成长按蓄力后,如果前端或后端仍消费 `dragVectorX/dragVectorY`,玩家手指轻微移动就会改变跳跃方向,和“始终朝下一块中心跳”的体验不一致。
- 原因:历史弹弓拖拽版本把屏幕拖拽方向作为正式裁决输入,契约字段仍为兼容旧客户端保留,容易被误认为仍是当前玩法规则。
- 处理:前端运行态只用长按时长提交 `dragDistance` 兼容字段,不再发送方向字段;落点预测按当前地块中心到下一块地块中心的方向投影。后端 `module-jump-hop` 即使收到旧客户端 `dragVectorX/dragVectorY` 也必须忽略,只按当前地块到下一块地块中心的单位向量裁决。
- 验证:前端回归测试覆盖手指移动不改变提交方向、预测落点忽略旧方向字段;后端领域测试覆盖旧客户端传错误方向时仍按下一块中心命中。
- 关联:`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,...`,终端日志会被数百万字符淹没。
- 原因:生成请求支持 `image` 数组传入 data URL 参考图;dry-run 如果复用 live 请求体输出,就会把参考图内容完整打印。
- 处理:dry-run 输出摘要,只保留 `imageReferenceCount`、尺寸、模型和 prompt,不输出完整 base64。live 请求仍按实际需要传 `image` 数组。
- 验证:执行 `node scripts/generate-edutainment-tv-map-concepts.mjs --dry-run`,输出应只显示 `imageReferenceCount: 1`,不出现完整 base64。
- 关联:`scripts/generate-edutainment-tv-map-concepts.mjs``docs/design/【前端体验】寓教于乐电视端乐园地图入口概念图-2026-05-18.md`
## 生成图资产不能只拼 generated legacy path
- 现象:结果页或运行态拿到 `/generated-*-assets/.../image.png` 后图片不显示;前端 `ResolvedAssetImage` 会先调用 `/api/assets/read-url?legacyPublicPath=...`,但换签后的 OSS URL 仍指向不存在对象。
- 原因:后端只写了看起来像生成图的 legacy path,没有真正调用 image2、上传 OSS、登记 `asset_object` 并绑定实体。`/api/assets/read-url` 只负责签名读取,不会凭空生成或补写对象。
- 处理:玩法生成链路必须在 `api-server` 完成外部副作用:调用 VectorEngine `gpt-image-2-all`,用 `GeneratedImageAssetAdapter` 准备 `PutObject`,上传 OSS 私有对象,调用 `confirm_asset_object``bind_asset_object_to_entity`,再把返回的 `legacyPublicPath` 写入玩法 profile。
- 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;契约测试应断言前端 JSON 自带的 `hitObjectAsset` 会被忽略,spacetime-client 定向测试应断言缺少服务端注入的真实 `hitObjectAsset` 时不能编译;浏览器 Network 中 generated 图片应先换签,签名 URL 指向已存在对象。
- 关联:`server-rs/crates/api-server/src/wooden_fish.rs``server-rs/crates/spacetime-client/src/wooden_fish.rs``src/components/ResolvedAssetImage.tsx``src/services/assetReadUrlService.ts`
## 生成页背景视频要固定全屏并显式触发播放
- 现象:生成页明明带了 `media/create_bg_video.mp4`,但移动端或某些内核里只看到静态首帧,或视频层跟着局部容器滚动,被白色面板压住后看起来像没加载。
- 原因:仅靠 `autoPlay/loop/muted/playsInline` 并不稳定;视频如果仍挂在局部容器里,还会被页面面板和遮罩吞掉。某些浏览器初始化后也会停在 `paused=true`
- 处理:背景视频必须放到 `fixed inset-0` 的全屏底层容器里,外层页面用 `isolate` / 透明底控制叠层;挂载后显式尝试 `play()`,并在 `loadeddata``canplay` 和页面聚焦时再次触发,避免只停首帧。
- 验证:移动端视口检查视频 `rect` 应覆盖整个视口,`paused` 应最终变为 `false``currentTime` 应持续前进。
- 关联:`src/components/GenerationProgressHero.tsx``docs/【玩法创作】生成页圆环布局口径-2026-05-23.md`
## 跳一跳结果页直达时不要把恢复面板当成空白页
- 现象:浏览器直接打开 `/creation/jump-hop/result`,如果没有 `sessionId``profileId``draftId``workId`,页面以前会看起来像空白,容易误判成结果页坏了。
- 原因:跳一跳结果页恢复原先只盯 `jumpHopSession.draft`,没有把“缺恢复信息”明确兜成可见恢复面板;直达结果页时也没有优先用 `profileId -> getWorkDetail` 补回完整作品。
- 处理:`PlatformEntryFlowShellImpl` 的跳一跳恢复逻辑改成先尝试 `profileId -> getWorkDetail`,再尝试 `sessionId -> getSession`;两者都没有时显示 `跳一跳草稿未恢复``返回创作`,不再留空白页。
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct jump hop result route"`,并手测 `/creation/jump-hop/result``/creation/jump-hop/result?profileId=<id>` 两种情况。
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
2026-05-24 补充:`GenerationPageBackdrop` 不要通过 portal 挂到 `document.body`。body 级 fixed 背景会逃离生成页自己的 stacking context,即使业务内容有局部 `z-10`,真实浏览器里也可能把整页 UI 压住。背景视频应作为生成页根容器子节点保留 `fixed inset-0 z-0`,生成页内容保持 `relative z-10`;相关测试应同时断言背景容器低层级、生成页根容器高层级,以及视频节点仍在生成页 DOM 内部。视觉调整时还要记住:空心圆环的中心块要抽掉,时间卡与总进度标题都应缩小,不要让生成页再回到“纯色底 + 大字号说明卡”的状态。顶部返回和右上状态也不能沿用 `text-lg` / `sm:text-2xl` 这类展示级字号;当前步骤名、步骤状态和底部玩法信息标题要维持普通 UI 字号档位,优先保持 `text-xs``text-sm` 区间。
2026-05-24 补充:生成页“预计等待 / 已耗时”卡片本身已经有标签,传给 `GenerationProgressHero` 的值只能是纯时间,例如 `4 分钟``1 分 15 秒`,不要再拼接“预计还需”或“已耗时”;两张时间卡也要和当前步骤卡一样保持半透明。拼图总进度初始帧必须允许显示 `0%`,不要再用 `Math.max(1, nextProgress)` 之类的保护把启动态抬到 `1%`
2026-05-27 补充:`generation-hero-progress-ring-fill` 里那个橘黄色小点不是背景噪点,而是 `strokeLinecap="round"` 在短弧段上的端点;当前圆环口径要求底部 `90deg` 开口居中对称,因此轨道和填充都应使用 `135deg` 起点。圆环本体现在固定为 `400x400`,排查时先看 `data-ring-start-degrees``data-ring-fill-start-degrees` 和容器尺寸,不要把尺寸伸缩误认成素材渲染问题。
## `dev:spacetime` 启动后 3101 又断开先查 publish 是否被 spacetime.json 干扰
- 现象:浏览器报 `Failed to initiate WebSocket connection`,目标为 `ws://127.0.0.1:3101/v1/database/<db>/subscribe`,端口检查发现 `3101` 没有长期监听;手动运行 `npm run dev:spacetime` 可看到 standalone 短暂启动后退出,发布阶段报 `No database target matches '<db>'`
- 原因:SpacetimeDB CLI 会读取仓库根目录 `spacetime.json`。如果本地发布命令没有显式 `--no-config`,CLI 可能按配置文件里的 target 解析数据库,覆盖脚本已传入的 `.env.local` 数据库名和 `--server`,导致 publish 失败;`dev.mjs` 捕获错误后会清理刚启动的 standalone,于是浏览器看到 3101 被拒绝连接。
- 处理:`scripts/dev.mjs` 的本地 publish 固定追加 `--no-config`,只使用脚本解析出的数据库名、module path 和实际 SpacetimeDB server。排查时前台运行 `npm run dev:spacetime -- --no-interactive`,若看到该错误,先确认脚本是否仍带 `--no-config`,再查 `.env.local` / `spacetime.local.json` 的数据库名。
- 验证:`npm run test -- scripts/dev.test.ts` 覆盖 publish 参数包含 `--no-config``npm run dev:spacetime -- --no-interactive``http://127.0.0.1:3101/v1/ping` 应保持 200。
- 关联:`scripts/dev.mjs``scripts/dev.test.ts``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 本地 api-server 启动订阅 401 先查 Web identity token 注入
- 现象:`npm run dev` 启动到 api-server 恢复认证投影时,日志出现 `Failed to initiate WebSocket connection ... /v1/database/<db>/subscribe?compression=Brotli: HTTP error: 401 Unauthorized`
- 原因:SpacetimeDB SDK 订阅需要 Web API identity token;本地 `.env.local` 常把 `GENARRATIVE_SPACETIME_TOKEN` 留空,只靠 CLI 登录态 publish 成功并不能让 api-server 的 WebSocket subscribe 获得权限。
- 处理:`scripts/dev.mjs` 在 SpacetimeDB 就绪后优先读取 `<spacetimeDataDir>/dev-api-identities/<serverSha256>.json`;缺失或不可用时才调用 `/v1/identity` 创建专用 Web API identity token,并以普通 `0600` 文件持久化。token 只注入 `api-server`,不写 `.env.local`、不传 Web / Vite、也不进日志。若仍报 401,先确认是否使用项目脚本启动、记录文件是否因 server 或权限不匹配被重建,以及 `GENARRATIVE_SPACETIME_SERVER_URL` / 数据库名是否指向本次启动的实例。
- 验证:`npm run test -- scripts/dev.test.ts`;重新运行 `npm run dev` 后 api-server 启动日志不再出现上述 subscribe 401`/healthz` 返回 200。
- 关联:`scripts/dev.mjs``scripts/dev.test.ts``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 创作作品架或公开列表异常先查本地 SpacetimeDB schema 漂移
- 现象:本地 `http://127.0.0.1:3000/` 启动后,`api-server` 日志反复出现 `Host returned error when processing subscription query: no such table: puzzle_gallery_card_view`;或创作中心草稿 / 已发布作品整块消失,`GET /api/creation-entry/config` 返回 `502` 且 details 为 `No such procedure`
- 原因:本地 `.env.local``spacetime.local.json` 指向的 SpacetimeDB 库没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库;例如旧 `xushi-p4wfr` 库缺 `get_creation_entry_config` / `puzzle_gallery_card_view`,但当前代码的 `spacetime-client` 启动时会长期订阅这些公开 read model。
- 处理:先用 `spacetime sql <database> "SELECT * FROM puzzle_gallery_card_view LIMIT 1" --server http://127.0.0.1:3101` 确认目标库是否有当前 view;若只是本地验证,可用 gitignored 的 `spacetime.local.json` 指向可发布且已包含当前 schema 的库,例如 `{"database":"genarrative-dev-codex"}`。该 JSON 必须无 UTF-8 BOM,否则 `scripts/dev.mjs` 会忽略它。修改后用 `npm run dev:api-server -- --database <database> --spacetime-port 3101 --api-port 8082 --no-interactive` 重启。
- 验证:`curl.exe -i http://127.0.0.1:8082/healthz` 返回 `200``curl.exe -i http://127.0.0.1:8082/api/runtime/puzzle/gallery` 返回 `200`;浏览器打开 `http://127.0.0.1:3000/``puzzle_gallery_card_view` 控制台或后端日志错误。
- 关联:`scripts/dev.mjs``server-rs/crates/spacetime-client/src/lib.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 创作作品架消失先查入口配置 procedure 与本地库权限
- 现象:寓教于乐或创作中心下草稿 / 已发布作品突然整块消失,`GET /api/creation-entry/config` 返回 `502`details 中为 `No such procedure`
- 原因:本地 `.env.local``spacetime.local.json` 指向的 SpacetimeDB 库没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库;例如旧 `xushi-p4wfr` 库缺 `get_creation_entry_config` 时,前端拿不到入口配置就不会渲染作品架。
- 处理:优先切换到拥有目标库权限的 SpacetimeDB 身份后重新运行 `npm run dev` 完成发布;若只是本地验证,可用 gitignored 的 `spacetime.local.json` 指向可发布的本地库。debug 构建的 `api-server` 对入口配置缺 procedure 会使用后端默认入口配置兜底,避免作品架因本地库漂移整块空白。
- 验证:`curl.exe -i http://127.0.0.1:8082/api/creation-entry/config` 返回 `200` 且包含 `baby-object-match`;前端草稿页作品架重新渲染。
- 关联:`server-rs/crates/api-server/src/state.rs``server-rs/crates/api-server/src/creation_entry_config.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 抓大鹅物品 spritesheet 偏移先查 alpha 连通域切片是否启用
- 现象:抓大鹅物品图集里大多数素材显示不全、被裁碎、位置整体偏移,甚至切出来像拼贴块。
- 原因:旧链路只按 `10x10` 固定格线裁切,遇到模型输出的透明图集稍有偏移、跨格或留白不均时就会把主体切坏。现在后端优先按透明 alpha 连通域识别真实素材矩形,再按原图从上到下、从左到右排序;只有识别数量不足时才回退旧网格切法。
- 处理:优先检查 `generated_asset_sheets.rs` 的 alpha 连通域切片是否生效,再查 `item_assets.rs` 是否还在透传旧的固定格线语义。不要只改前端显示比例。
- 验证:定向测试 `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 起,移动端“我的”页顶部改为品牌行 + 扫码 / 设置按钮,设置区和次级入口不再提供独立的 `存档` 按钮;用户仍可在“玩过”弹窗里查看可继续存档。
- 原因:产品布局收口后,个人中心只保留设置、扫码、常用功能和条件性次级入口,存档恢复继续以后端 `/api/profile/save-archives` 真相为准,但不再作为页面直达入口。
- 处理:后续如果需要重新暴露存档入口,优先评估是否应回到“玩过”或别的独立弹窗流程,不要默认把存档再塞回常用功能宫格或设置列表。
- 验证:`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`,确认本机 CLI/standalone 与 `server-rs/Cargo.toml` 锁定版本一致;不一致时先直接升级 / 切换到锁定版本,再重新启动 `npm run dev -- --no-interactive --web-port 3001 --api-port 8083 --spacetime-port 3103 --admin-web-port 3104`
- 验证:`http://127.0.0.1:3001/``http://127.0.0.1:8083/healthz``http://127.0.0.1:3103/v1/ping` 都返回 200,且进程命令行指向当前 worktree 路径而不是别的仓库。
- 关联:`scripts/dev.mjs``docs/project-memory/shared-memory/pitfalls.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 微信历史孤儿作品不要让新注册账号顶替
- 现象:清空用户数据或迁移历史数据后,旧作品的 `owner_user_id` 为空或失效,新注册用户会因为顺序号复用或旧 ID 残留顶替作品归属,导致刚注册就看到别人的草稿或已发布作品。
- 原因:作品作者解析曾经把缺失作者简单回退到普通登录用户,且微信新用户用户名 / 内部 ID 都太容易被误认或复用。
- 处理:作品作者找不到真实账号时统一回退到占位作者 `wx-openid-placeholder`,展示名固定为 `失效作者`;微信新用户用户名改为 `名字_openid`,内部 `user_id` 改成不可复用的 UUID 风格;离线回填时先识别真实有效用户,再把孤儿作品表写回占位账号。
- 验证:`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/api-server/src/work_author.rs``server-rs/crates/module-auth/src/domain.rs``scripts/rebind-orphan-work-owners.mjs`
## 访客推荐页上下滑不要绑定登录态
- 现象:访客模式进入移动端推荐页后,推荐内容可展示和点击底部“下一个”,但在作品信息区域上下滑不会切换推荐作品,表现为推荐页不能上下滑动。
- 原因:推荐页滑动切换逻辑 `beginRecommendDrag(...)` 误把 `isAuthenticated` 作为启用条件;访客态虽然允许浏览和通过底部按钮切换,却无法触发同一套拖拽切换。
- 处理:推荐页拖拽只校验当前是否有作品、多作品可切换以及是否正在提交动画,不再要求登录;登录态相关操作仍由点赞、改造等按钮自身权限控制。
- 验证:`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 定向路径失败先切真实路径
- 现象:在 Windows junction 或映射 worktree 中运行前端测试时,Vitest 可能把同一文件解析为另一盘符路径,误报文件不存在。
- 原因: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``.codex/skills/genarrative-dev-stack-port-routing/SKILL.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## Windows junction 工作区下 dev.mjs 直接执行入口要用 realpath 判断
- 现象:在 Windows junction 或映射 worktree 中运行 `npm run dev:web`,进程可能秒退且端口不监听;从真实 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 工作区要切真实路径
- 现象:同一类 junction 路径问题会让 Vitest 的错误路径和实际工作树不一致,看起来像文件不存在。
- 原因: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`,角色在当前显示窗口内飞向前端预测真实落点;视觉预测必须用当前显示窗口的 current/next 地块作为方向来源,不能拿已经提前返回的后端新 run 目标配旧窗口角色,否则下一跳会朝实际目标反方向飞。飞行动画完成后再把 `displayRun` 切到最新后端 run,并进入约 `1440ms``platformAdvancing` 表现态。成功后的角色显示必须使用 `lastJump.landedX/landedY` 映射出的真实偏移,不要吸附到目标地块中心。推进期间地块层和角色层必须统一包在同一个 camera layer 下移动,旧当前地块先跟随相机偏移离开主视野,之后只保留在屏幕后方;不要给旧地块加独立向上 / 向下飞走 keyframes,也不要因为旧地块还在保留列表里阻塞下一跳。玩家继续向前跳时,已完成旧地块继续被新的相机推进自然带离屏幕,超过离屏阈值后销毁。相机层必须同时设置 `--jump-hop-camera-shift-x``--jump-hop-camera-shift-y`,并以旧窗口真实落点和新窗口真实落点为锚点,避免先横向瞬切居中再纵向推进;运行态相机层当前为约 `1.3x` 近距缩放。地块保留当前 / 目标 / 预览的深度尺寸差异,但深度差异必须用固定宽高 + CSS transform scale 缓动实现,不能直接改宽高瞬切;当前态不要额外叠 CSS scale。Three.js Sprite 角色与平台共用同一套屏幕坐标投影,DOM 角色只作为 WebGL 或贴图加载失败 fallbackDOM fallback 在相机推进期间自身不能保留 `left/top` transition,否则 `displayRun` 切换造成的角色局部坐标变更会和父级 camera layer 位移叠加,视觉上像落地后又从屏幕外飞回。正式胜负、成功跳跃次数、时长和排行榜仍以后端 run 为准,前端只延迟显示态。
- 验证:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖动画期间平台仍停在旧窗口,成功落地保留真实落点偏移,动画结束后进入 `data-platform-advancing=true`,角色 Three 帧沿真实预测落点插值并保留飞行弧线,DOM fallback 角色与地块层同在 `jump-hop-camera-layer` 内,通过 `--jump-hop-camera-shift-x``--jump-hop-camera-shift-y` 完成相机斜向推进,并校验可见地块按深度保留不同视觉尺寸、运行态平台宽高使用固定基准值、推进态 transform transition 为 `1440ms`、推进态 DOM fallback 角色 transition 不包含 `left/top`、旧地块没有独立 `jump-hop-platform-exit-drift` keyframes 且下一跳不会被旧地块保留态阻塞。
- 关联:`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 原型地块。Three.js 平台层接入后,如果隐藏预加载只让浏览器缓存 `<img>`,但没有把未来 `platformId` 的纹理 URL 写入 `platformTextureUrlsByRenderKey`,相机推进时新预览地块会短暂缺 Three 贴图;若旧 blob 贴图在空 URL 回调时先被 revoke,再继续保留在 state 中,也会留下一个看似 ready、实际已失效的贴图地址。
- 处理:exiting 地块继续使用稳定 `platformId` key,让旧图片组件在推进期复用;有真实 `resolvedUrl` 且未错误时直接保留真实 `<img>`,只在无 URL 或加载失败时显示 fallback;当前 3 块之外的后续地块通过隐藏预加载图片提前解析签名 URL 和浏览器缓存,并同步按未来 `platformId` 发布 Three 纹理 URL。Three 平台层在当前 render items 全部有贴图 URL 后继续承接包含 exiting 地块在内的 3D 渲染;退出地块只随相机推进自然离屏,不播放独立飞走动画,避免退出期露出被放大的平面贴图或重复飞多次;贴图 URL 替换必须等新 URL 到达后再释放旧 parent-owned blob,空 URL 回调不得清空或 revoke 仍在活跃 / 预加载 key 上的旧贴图。
- 验证:`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`
## 跳一跳 Three.js 平台层不能左右镜像 DOM 坐标
- 现象:视觉上下一块地块在角色右侧,但蓄力引导和角色飞行动画朝左侧;后端回包后地块窗口又闪现摆回正确位置,像是先按反方向飞、再由快照刷新纠正。
- 原因:Three.js 平台层如果把相机 `up` 设置成反向,或在 Three 容器上做左右镜像,会让 WebGL 地块的屏幕 X 轴和角色 / 落点预测的屏幕 X 轴相反。规则层仍沿当前地块中心到下一块中心裁决,所以后端快照会把状态纠正回来,表现为跳后刷新。
- 处理:Three 相机保持 `up=(0, 1, 0)`,再用内部投影公式抵消 45° 下压导致的 Y 轴压缩;不要通过反向 `camera.up` 解决上下方向。Three.js Sprite 角色、DOM fallback 角色、蓄力引导、落点预测和 Three 平台层必须共用同向屏幕坐标。
- 验证:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/services/jump-hop/jumpHopRuntimeModel.test.ts` 应覆盖 `JUMP_HOP_THREE_CAMERA_UP_Y=1`,并断言 Three 投影与 DOM 屏幕坐标同向。
- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx``src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`
## 跳一跳 Three.js 角色不要被地块透明排序压住
- 现象:角色已经进 Three.js 场景后,看起来像落在地块内部或只露出头,角色没有站在方块顶面上。
- 原因:地块材质如果设置 `transparent=true` 会进入 Three.js 透明物体排序队列,可能在 Sprite 角色之后绘制;同时角色脚点如果仍用固定 Z 高度,遇到标准 `1x1x1` 方块放大后的当前块时会落到顶面后方或方块体内。
- 处理:地块贴图材质只使用 `alphaTest` 裁掉透明边,不放入透明材质队列;角色 Sprite 的 `renderOrder` 必须高于平台 mesh,脚点 Z 高度按最近方块半高加顶面偏移计算,确保角色站在当前方块顶面上方。
- 验证:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖平台材质不透明队列、角色 renderOrder 高于地块、角色脚点高度高于方块顶面。
- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx``docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`
## 跳一跳立方体贴图不要走透明主体切片
- 现象:水果等主题生成成功后,运行态地块看起来像薄的纯水果 PNG、果切贴纸、透明 cutout;或者反过来六个面都是同一张平铺果皮 / 果肉材质,无法组合成方块苹果 / 方块香蕉这类完整主题对象表达。
- 原因:跳一跳地板已经改为 Three.js 标准 `1x1x1` 等比极小倒角立方体复用几何体,运行态视角固定为近距相机和 45° 下压视角;image2 应生成 `1024x1536` 的 18 个 cube object UV unwrap,每个大单元内的 top/front/right/back/left/bottom 六面要共同包装同一个主题物体。只强调 full-bleed 容易让水果主题退化成果皮、果肉、叶脉等表面纹理;如果仍把一张图贴给六个面,模型也不需要理解正反和跨面连续特征。旧切图链路若把洋红 key 转 alpha、裁边、只保留最大 alpha 连通主体并补透明安全边,会把整格贴图重新抠成苹果 / 香蕉 / 果切等居中主体,贴到立方体上后四角和侧面都变透明。
- 处理:跳一跳地板图集 prompt 固定要求 `cube object UV unwrap atlas / 立方体主题物体六面展开图集`,一张图只生成 18 个大单元,每个大单元固定 `4列*3行` UV 网:第 1 行第 2 列 top,第 2 行 left/front/right/back,第 3 行第 2 列 bottom;水果主题要明确生成能一眼说出名称的方块苹果、方块香蕉、方块橙子、方块西瓜等可识别对象,并要求果柄叶片、剥皮条带、放射切面、红瓤黑籽等身份特征跨面连续。禁止自然圆形水果、自然长条香蕉、非方块化完整水果、果切小贴纸、居中小物体、透明背景和留白,同时也禁止“单纯平铺材质 / 抽象纹理 / 只铺主题颜色 / 纯果皮材质 / 纯果肉纹理 / 纯叶脉纹理”。后端先对图集做洋红去背,再以 `jump_hop_atlas_slicing.rs` 的自适应 blob+gradient 算法检测 3x6 大单元和单元内六面区域,输出 108 张 `256x256` 不透明面贴图;固定 3x6 / 4x3 切片只作为测试对照和必要 fallback 参考,不作为优先生图切图路径。洋红 `#FF00FF` 只作为图集安全缝 / UV 空位 / 外圈 key 色;绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题颜色必须完整保留。
- 验证:`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` 覆盖跳一跳 UV unwrap prompt、18 个大单元、108 张不透明面贴图、绿色 / 白色材质不被透明化、洋红 key 残留不作为透明洞;前端 `JumpHopRuntimeShell` 测试覆盖新 UV 资产会解析六张面贴图,旧单贴图资产仍可 fallback。
- 关联:`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`
## 跳一跳 UV 图集切片要防贴边矩形 u32 中间溢出
- 现象:跳一跳草稿在背景、返回按钮和地板图集 image2 都生成成功后,前端报“执行跳一跳共创操作失败”,Vite 代理日志出现 `socket hang up`,后端日志出现 `jump_hop_atlas_slicing.rs``attempt to subtract with overflow`
- 原因:blob gradient 切片的 histogram 最大不透明矩形在计算顶部坐标时写成 `by0 + ly - sh + 1`。当模型输出的 UV 面内容刚好贴到 cell 顶边,数学结果本应是 0,但 `u32` 会先执行中间步骤 `0 - 1` 并在 debug 运行时 panic。
- 处理:顶部坐标先在局部坐标内用 `ly.saturating_add(1).saturating_sub(sh)` 计算,再加 block 偏移;不要恢复成连写减法。补充贴顶两行不透明矩形回归测试,保证贴边 UV 面不会打崩共创接口。
- 验证:`RUSTC_WRAPPER= cargo test -p api-server --manifest-path server-rs/Cargo.toml jump_hop_atlas_slicing::tests::max_opaque_rect_handles_content_touching_top_edge`;整组再跑 `RUSTC_WRAPPER= cargo test -p api-server --manifest-path server-rs/Cargo.toml jump_hop`
- 关联:`server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs``server-rs/crates/api-server/src/jump_hop.rs`
## 跳一跳生图切图主路径不要绕过自适应图集切片
- 现象:拉取 `fix/jump-hop-image-gen` 后,如果又把生成链路切回旧固定坐标裁切,容易和该分支解决的 AI 图集偏移、间距不均、UV 面位置漂移问题互相抵消,导致新生图链路的实际收益无法验证。
- 原因:当前跳一跳 image2 prompt 仍要求 3x6 大单元和 4x3 UV 子网格,这是给模型和算法的结构约束;真实生产切图由自适应 `SeedRefinement + blob + gradient + max opaque rectangle` 链路消化 AI 输出偏差。固定网格切片只能验证理想图集,不适合覆盖新分支的主修复。
- 处理:生产生成链路优先调用 `slice_tile_atlas_adaptive(...)`;旧固定 `slice_jump_hop_tile_atlas(...)` 只保留为对照测试、实验和必要 fallback 参考。若自适应切图出现具体误切,应优先修正自适应模块的边界检测、主 blob、透明/安全色处理和回归测试,而不是直接全局切回固定坐标。
- 验证:新生成作品下载 `tile-01-top/front/right` 等面贴图时,单图应基本充满对应主题面内容,不应出现大块空背景、相邻面混入或纯色原型 cube;同时执行 `RUSTC_WRAPPER= cargo test -p api-server --manifest-path server-rs/Cargo.toml jump_hop_atlas_slicing -- --nocapture`
- 关联:`server-rs/crates/api-server/src/jump_hop.rs``server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs``docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`
## 含中文 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`
## Tauri devUrl 不会自动跟随 dev:web 端口漂移
- 现象:运行 `npm run desktop-shell:dev` 时终端显示主站 Vite 实际启动在 `10000+` 端口,但 Tauri 窗口仍加载 `http://127.0.0.1:3000/`,桌面壳表现为白屏、连接失败或加载到旧页面。
- 原因:Linux dev 端口段只把 CLI `--web-port` 视为显式端口;桌面壳 package script 里的 `WEB_PORT=3000` 会被端口段映射覆盖。Tauri `devUrl` 是静态配置,不会读取 `scripts/dev.mjs` 最终解析出的漂移端口。
- 处理:桌面壳 `beforeDevCommand` 必须使用 `npm --prefix ../.. run dev:web -- --web-port 3000 --strict-web-port`,让 Vite 实际监听端口和 Tauri `devUrl` 一致,并在 3000 被占用时直接失败。若 3000 被占用,先释放占用进程再启动桌面壳,不要依赖 Vite 漂移。
- 验证:`npm run test -- scripts/dev.test.ts -t "Linux 桌面壳显式指定 web-port"``npm run desktop-shell:typecheck`、实际启动时终端应显示 `[dev] web: http://127.0.0.1:3000`
- 关联:`apps/desktop-shell/src-tauri/tauri.conf.json``apps/desktop-shell/scripts/check-config.mjs``scripts/dev.mjs`
## Tauri 手动创建主窗口时 devUrl 不会自动套到 index.html
- 现象:`npm run desktop-shell:dev` 启动后窗口地址显示 `http://tauri.localhost/index.html``tauri://localhost/index.html`,即使 Vite 已经在 `http://127.0.0.1:3000/` 正常监听。
- 原因:桌面壳为了注册导航、下载、生命周期和托盘行为,把 Tauri 配置里的主窗口设为 `create=false`,再在 Rust `app.rs` 中用 `WebviewWindowBuilder::from_config(...)` 手动创建窗口。此时如果只读取 `app.windows[].url = index.html` 并补 HostBridge query,手动窗口会沿 release 入口走打包资源协议;Tauri CLI 的 `build.devUrl` 不会自动替换这份手动克隆后的窗口 URL。
- 处理:`app.rs` 在 dev build 下必须先把主窗口 URL 替换为 `config.build.dev_url`,再调用 `desktop_window_config_with_runtime_platform(...)` 补写宿主上下文;`shell/navigation.rs` 也必须允许 dev build 下的 `http://127.0.0.1:3000` 留在 WebView 内,不要把自己的 Vite 首页当外链交给系统浏览器。release build 保持 `index.html` 打包入口。
- 验证:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml desktop_main_window_config_uses_dev_url_in_dev_builds desktop_webview_navigation_stays_on_packaged_or_same_origin_pages`,实际启动时窗口应加载 `http://127.0.0.1:3000/...` 而不是 `tauri.localhost/index.html`
- 关联:`apps/desktop-shell/src-tauri/src/app.rs``apps/desktop-shell/src-tauri/src/shell/url.rs``apps/desktop-shell/src-tauri/src/shell/navigation.rs``apps/desktop-shell/src-tauri/tauri.conf.json`
## Tauri release 的 tauri.localhost 不要交给系统浏览器
- 现象:Windows / release 包启动桌面壳时,系统默认浏览器被打开到 `http://tauri.localhost/index.html`
- 原因:release 打包资源在 WebView 内可能表现为 `tauri://localhost/index.html``https://tauri.localhost/index.html``http://tauri.localhost/index.html`;如果导航白名单只允许 `tauri:``https://*.localhost``http://tauri.localhost` 会被误判成普通外链并交给 `opener.open_url`
- 处理:桌面壳导航策略必须把 `http` / `https``*.localhost` 都视为 Tauri 内部打包资源,只允许真正外部 `http` / `https``mailto``tel` 走系统浏览器。Windows release 入口还必须使用 `windows_subsystem = "windows"`,避免正式包额外弹出控制台窗口;dev build 保留控制台日志。
- 验证:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml desktop_webview_navigation_stays_on_packaged_or_same_origin_pages``npm run desktop-shell:typecheck`、Windows release 启动时不应打开系统浏览器或控制台窗口。
- 关联:`apps/desktop-shell/src-tauri/src/main.rs``apps/desktop-shell/src-tauri/src/shell/navigation.rs``apps/desktop-shell/scripts/check-config.mjs`
## 自动试玩退出不要回到生成页
- 现象:拼图草稿生成完成后自动进入试玩,用户从试玩退出或使用系统返回时落回生成进度页,页面还暴露“重新生成”按钮。
- 原因:自动试玩前如果没有先把 `/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`
## 项目画布跳转不要先写无参画布路由
- 现象:从 `/creation` 最近项目或 `/project` 项目卡进入画布时,浏览器先进入 `/editor/canvas`,随后再进入 `/editor/canvas?projectid=xxx`,导致返回来源页需要点两次。
- 原因:`App` 传给平台壳的 `setSelectionStage` 会按 stage 自动 `pushAppHistoryPath(resolvePathForSelectionStage(stage))`;如果项目入口先 `setSelectionStage('image-editor')` 再写项目 URL,就会把无参数画布路由塞入 history。
- 处理:项目入口必须先写入最终 `/editor/canvas?projectid=xxx`,再切 `image-editor` 阶段;`App` 的 stage setter 在当前位置已经解析为 `image-editor` 时不要再补写基础画布路由。
- 验证:`npm run test -- src/App.test.tsx`;浏览器中从最近项目或项目页打开项目后,后退一次应直接回到 `/creation``/project`
- 关联:`src/App.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.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
> 2026-07-18:本节及下一节只作为历史记录。生成结果订阅页、H5 service、HostBridge capability 和后端发送链路已随旧创作模板业务退役,不得按这些排障步骤恢复。
- 现象:拼图点击生成后,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 字段不能用内部时间戳
> 2026-07-18:该能力已退役,本节不再作为现役排障入口。
- 现象: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`
## 待解决:跳一跳生成超时后可能后台继续成功
- 风险程度:高。
- 现象:跳一跳生成页可能在 `98% 写入正式草稿` 后报“请求超时,请稍后重试”,但后端仍在继续生成,稍后才把同一 session 写成 `DraftCompiled=100`。2026-06-08 排查 `jump-hop-session-6db8fa7af57c4fa2a71e6430cc808412` 时,背景底图 image2 成功但耗时约 `18分25秒`,返回按钮约 `2分44秒`,地板图集约 `1分46秒`,总耗时超过前端 20 分钟等待窗口,最终在前端超时后约 3 分钟写草稿成功。
- 原因:跳一跳创作链路仍把背景、返回按钮、地板图集、切片和 OSS 写入串在一次 HTTP 请求里;VectorEngine image2 单步 timeout/connect 失败会在后端重试,单步耗时可能超过前端总等待窗口。中间资产和真实阶段没有落库,session 在完成前仍显示 `Collecting``progress_percent=0`,前端只能按时间显示假进度;超时后重试同一 session 时,后端还可能因为 session 没有中间素材而重新从背景开始生成。
- 待处理:将跳一跳生成改为后端任务化 / 可轮询真实阶段进度,按背景、返回按钮、图集、切片、持久化、写草稿分阶段落库;统一后端全局生成 deadline、VectorEngine 重试预算、前端等待窗口和失败态回写。超时后再次进入同一 session 应优先恢复正在运行或已完成的任务,不应重复生图。
- 验证:模拟首张 image2 超长耗时或超时重试时,生成页应显示真实阶段和可恢复状态;前端请求超时不应把最终成功草稿标记为失败;刷新 `/creation/jump-hop/generating?sessionId=<id>` 后应能恢复到后端真实状态;同一 session 重试不得重复生成已完成阶段。
- 关联:`src/services/jump-hop/jumpHopClient.ts``src/services/miniGameDraftGenerationProgress.ts``server-rs/crates/api-server/src/jump_hop.rs``server-rs/crates/platform-image/src/vector_engine/client.rs``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 画布生成完成态不能被旧 autosave 覆盖
- 现象:release 外部生成 worker 补跑完成后,生成图已进入素材库或项目资源,但画布生成器仍显示 `generating`;刷新后可能仍看到历史生成框卡住。
- 原因:画布前端在提交生成后会把 `generating` layout 放入 450ms 自动保存队列;worker 完成后后端会写入 `idle + generatedLayerId + 生成层`,但旧的 pending / in-flight layout save 可能晚到并覆盖完成态。另有历史 inline 请求在 api-server 重启时只留下前端已保存的 `generating` 框,没有终态任务或生成资源。
- 处理:前端 `applyProjectSnapshot` 必须取消 pending layout save,并跳过一次由后端快照恢复触发的 autosave;后端 `save_editor_project_layout` 要保护已完成的 generation dialog,如果传入旧 `generating` 且无 `generatedLayerId`,而当前 layout 已有同一 dialog 的完成态,则保留完成态和生成层。线上脏数据只在确认无任务 / 无资源时标成 `failed` 并保留原 prompt 供用户重试。
- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx``cargo test -p spacetime-module --manifest-path server-rs/Cargo.toml editor_project_storage --lib`release 排障用 `list_editor_projects_and_return` / `get_editor_project_and_return``generation-dialog` 状态,不要只看素材库。
- 关联:`src/components/image-editor/useImageCanvasProjectPersistence.ts``server-rs/crates/spacetime-module/src/editor_project_storage.rs``server-rs/crates/api-server/src/external_generation_worker.rs`
## Pingora 静态缓存不能只写 Cache-Control
- 现象:直连 Pingora 后,HTML 入口虽然是 `Cache-Control: no-cache`,但浏览器每次都重新下载完整入口页或普通静态文件;或者 Vite 指纹资源长期缓存正常,但旧标签页刷新时协商缓存行为和 Nginx 直连不同。
- 原因:`Cache-Control` 只决定缓存策略,不等于条件请求能力。Nginx 静态文件默认会按文件 metadata 提供 `ETag` / `Last-Modified`,浏览器随后可用 `If-None-Match` / `If-Modified-Since` 得到 `304`;Pingora 自实现静态读取时如果只写 body 和 `Cache-Control`,就会丢掉这层协商缓存。
- 处理:Pingora 静态响应读取文件 metadata,写入弱 `ETag``Last-Modified``GET` / `HEAD` 命中 `If-None-Match``If-Modified-Since` 时直接返回 `304`,不读取或发送 body。`HEAD` 静态响应只读 metadata,仍写正确 `Content-Length`
- 验证:`npm run check:pingora-gateway-smoke` 必须覆盖静态 `HEAD``If-None-Match` 304、`If-Modified-Since` 304,并用 access log method/path/status 对账证明本地静态边界进入日志证据链;`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml` 必须覆盖 ETag 构造和匹配 helper。
- 关联:`server-rs/crates/pingora-gateway/src/main.rs``scripts/check-pingora-gateway-smoke.mjs``docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md`
## Pingora 静态路径必须按 URL segment 解码
- 现象:dev 页面里部分像素图标加载失败,浏览器直接打开 `/Icons/Admurin%27s%20Pixel%20Items/.../499_Iron_Gear.png` 返回 `200 text/html`,响应体是主站 `index.html`,但服务器磁盘上真实 PNG 文件存在。
- 原因:浏览器请求中的空格和英文撇号会变成 `%20` / `%27`;Pingora 静态文件解析如果直接把编码后的 path 当磁盘路径查找,就会错过真实文件,并继续落到 SPA fallback,最终让图片解码看到 HTML。
- 处理:静态路径按 `/` 拆分 URL segment 后逐段 percent-decode;解码后拒绝 `/``\`、NUL、`..` 和非法 `%` 编码,既能读取带空格 / 撇号的真实文件,又不重新打开目录穿越边界。
- 验证:`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml` 必须覆盖编码空格 / 撇号、`%2e%2e``%2f` 和非法 `%GG``npm run check:pingora-gateway-smoke` 必须覆盖编码图标路径返回 `image/png`,并确认危险编码路径仍返回 `404`。dev 切换后用浏览器或 curl 直接验证对应图标 URL 的 `Content-Type` 和 PNG magic bytes。
- 关联:`server-rs/crates/pingora-gateway/src/main.rs``scripts/check-pingora-gateway-smoke.mjs``docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md`
## dev health patrol 不能缺少公网 HTTPS 入口配置
- 现象:dev 上 `genarrative-health-patrol.timer` 正常 active,但 `genarrative-health-patrol.service` 最近一次运行失败;Pingora 直连彩排状态脚本只因 `/etc/genarrative/health-patrol.env` 缺失或 public probe 命中 `http://127.0.0.1` 后被 Nginx 301 而报 `CRITICAL`
- 原因:health patrol systemd unit 的 `EnvironmentFile=-/etc/genarrative/health-patrol.env` 允许文件缺失,脚本会退回默认 public base URL `http://127.0.0.1`dev / release 的 Nginx 公开入口会把 HTTP 跳到 HTTPS,巡检按非 2xx 判失败。
- 处理:目标机应创建 `/etc/genarrative/health-patrol.env`,保持 `GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE=nginx`,把 `GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL` 指向真实 HTTPS 域名,例如 `https://dev.genarrative.world`Pingora shadow 巡检同时配置 `GENARRATIVE_HEALTH_PATROL_PINGORA_BASE_URL=http://127.0.0.1:18081` 和与 `/etc/genarrative/pingora-gateway.env` 一致的 probe token。不要为了让彩排状态变绿把缺 env 降级成 warning。
- 验证:先运行随包 `node -- /opt/genarrative/current/scripts/check-production-health-patrol-env.mjs --env-file /etc/genarrative/health-patrol.env --expected-gateway-mode nginx --expected-public-base-url https://dev.genarrative.world --require-empty-public-host`,再 `systemctl start genarrative-health-patrol.service`;最后运行 `node -- /opt/genarrative/current/scripts/ops/pingora-direct-rehearsal-status.mjs --release-root /opt/genarrative/current --expect-public-gateway nginx --require-pingora-shadow --require-realpath-canary --require-current-release-gateway --fail-on-critical`
- 关联:`deploy/env/health-patrol.env.example``scripts/ops/production-health-patrol.mjs``scripts/ops/pingora-direct-rehearsal-status.mjs``docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md`
## Pingora 高端口直连演练不要 source env 文件
- 现象:在 dev 上用临时 env 启动高端口 Pingora direct 演练时,shell 报 `/tmp/pingora-direct-highport-*.env: line ...: max-age=31536000,: command not found`,或者临时演练进程启动后没有按预期监听 `18443/18080`
- 原因:`pingora-gateway.env` 是 systemd EnvironmentFile 口径,允许 `GENARRATIVE_PINGORA_GATEWAY_ASSET_CACHE_CONTROL=public, max-age=31536000, immutable` 这类带空格的值;它不是可安全 `source` 的 shell 脚本。用 shell `source` 会把空格后的内容拆成命令或参数。另一个容易误判的点是 Pingora 默认优雅退出窗口较长,停止临时 systemd unit 后可能短暂停在 `stop-sigterm`,即使监听端口已经释放。
- 处理:高端口真实演练优先用临时 systemd unit 启动 current release 的 `/opt/genarrative/current/pingora-gateway`,通过 `systemd-run --property=EnvironmentFile=/tmp/<run>.env --property=User=genarrative --property=WorkingDirectory=/opt/genarrative/current ...` 让 systemd 解析 env;或使用显式安全 env 解析器,禁止直接 `source`。临时 env 要把正式 shadow 端口改到独立 loopback 端口,例如 `127.0.0.1:18084`HTTPS / HTTP redirect 用 `127.0.0.1:18443` / `127.0.0.1:18080`,access log 写独立文件。演练结束先 `systemctl stop <临时unit>`,再用 `ss -ltnp` 确认高端口已释放;若临时 unit 仍停在 `deactivating/stop-sigterm` 且只剩演练进程,可对该临时 unit 执行 `systemctl kill -s SIGKILL <临时unit>` 收尾,不要碰正式 `genarrative-pingora-gateway.service`
- 处理补充:正式 `plan:pingora-direct-cutover` / `check-pingora-release-readiness.mjs --dry-run-cutover --require-direct` 生成的 runbook 默认读取 active `/etc/genarrative/pingora-gateway.env`,不会自动使用 `/tmp` 候选 env。若只生成了候选 direct env,必须先在维护窗口内把候选 env 提升为 active env,并确认 Nginx 已释放 `80/443`,再执行 runbook 的 direct preflight、enable dry-run 和 enable apply;否则 runbook 第 5 步仍会按 shadow env 报缺 `TLS_LISTEN``HTTP_REDIRECT_LISTEN`、cert/key、`FORWARDED_PROTO=https` 以及 direct-entry capability。不要把候选 env 的 loopback / 高端口预检通过误解为 active env 已满足正式直连门禁。
- 验证:先跑 `check-pingora-direct-preflight.mjs --env-file <临时env> --require-live-env --check-cert-readable --check-service-user-cert-readable --check-ports-free --allow-loopback-only`;启动临时 unit 后跑 `check-pingora-direct-live.mjs --https-base-url https://127.0.0.1:18443 --http-base-url http://127.0.0.1:18080 --host <域名> --redirect-host <域名> --redirect-base-url https://<域名> --require-wss-upgrade --pingora-access-log <临时log> --insecure-tls --json`,要求 `OK``direct-access-log matchedCount == checked`。收尾后复核 `80/443` 仍由 Nginx 监听,正式 Pingora shadow 仍为 `127.0.0.1:18081`
- 关联:`scripts/check-pingora-direct-preflight.mjs``scripts/check-pingora-direct-live.mjs``deploy/pingora/pingora-gateway.env.example``docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md`
## Pingora 直连接管同 IP 多域名前先确认 Host 和证书覆盖
- 现象:dev 上准备让 Pingora 直接绑定 `0.0.0.0:80/443` 时,只按 `dev.genarrative.world` 配置证书和路由会让同 IP 的 `git.genarrative.world` 也进入主站 Pingora 路由,Gitea 可能不可访问;即使补了 Gitea Host 路由,如果仍使用只覆盖 `dev.genarrative.world` 的单域名证书,浏览器和 Git 客户端访问 `git.genarrative.world` 也会遇到证书域名不匹配。
- 原因:Nginx 原来通过多个 `server_name` vhost 承载主站和 Gitea;当前 Pingora direct listener 默认只有一组 TLS cert/key,且路径路由本身无法区分同一 IP 上的多个域名。
- 处理:direct env 必须配置 `GENARRATIVE_PINGORA_GATEWAY_GITEA_HOSTS=git.genarrative.world``GENARRATIVE_PINGORA_GATEWAY_GITEA_UPSTREAM=127.0.0.1:3000`TLS 证书必须同时覆盖 `dev.genarrative.world``git.genarrative.world`,并通过 `pingora-tls-cert-sync.mjs` 同步到 Pingora 私有目录后再指向 env。不要通过“临时释放 Gitea vhost”把 Gitea 从切换窗口里牺牲掉。
- 验证:本地 `npm run check:pingora-gateway-smoke` 必须覆盖 Gitea Host 整站转发、维护模式不拦截 Gitea Host 和 access log `proxy_target=Gitea`dev 切换后除 `https://dev.genarrative.world/` 外,还必须验证 `https://git.genarrative.world/` 返回 GiteaHTTP 到 HTTPS redirect 保留正确 HostPingora access log 中有 `host=git.genarrative.world` / `proxy_target=Gitea`
- 关联:`server-rs/crates/pingora-gateway/src/main.rs``deploy/pingora/pingora-gateway.env.example``docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md`
## SpacetimeDB 连接池租约必须有 Drop 兜底,acquire 不允许无界自旋
- 现象:release 上 api-server 周期性出现全量 `spacetime_stage="pool_acquire" elapsed_ms=45000` 业务超时,`/readyz` 503`reason=spacetime_unhealthy, stage=pool_acquire`),`/healthz` 仍 200,只有重启能恢复,过若干小时复发。
- 原因:旧 `PooledConnectionLease` 只能显式 `release_connection` 归还;HTTP 请求方在等待 StDB 回包期间断开时 handler future 被取消,permit 自动归还但槽位 `in_use` 永不复位。后续 acquire 在拿到 permit 后进入无界 `loop + yield_now` 扫描空闲槽位,泄漏积累到 pool_size 后整池挂死。
- 处理:租约持有 `Arc<SpacetimeConnectionPool>` 并实现 `Drop` 统一复位槽位/归还连接;槽位改 `AtomicBool` CAS 抢占,删除自旋循环(持有 permit 必然命中空闲槽位)。任何新的"显式归还"资源在 async 取消语义下都要先想 Drop 兜底。该保证只覆盖本地 lease / slot / permit 回收;RPC 已发出后,handler timeout/drop 不会取消或回滚远端 procedure,结果仍须按 unknown 读取权威事实。
- 验证:`cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib``dropped_lease_releases_slot_and_permit``acquire_times_out_at_pool_acquire_when_pool_is_busy`)。
- 关联:`server-rs/crates/spacetime-client/src/active.rs``docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md`
## 后台灰度配置不能从 SpacetimeDB 本地表缓存读取
- 现象:后台灰度页保存 `image-editor:agent-sidebar` 后当前响应能看到 gate,但刷新后台页列表变空;前台画布 Agent 入口仍显示,0% 灰度没有生效。
- 原因:`feature_gate_config` 是后台私有事实表,`spacetime-client` 如果优先读 SDK 本地订阅表缓存,可能得到空表并覆盖 procedure 返回后的正确缓存。灰度语义里“未配置 gate”表示不限制访问,所以空列表会让功能继续开放。
- 处理:灰度配置读取必须走 `get_feature_gate_config` procedure 的事务快照,成功后再更新进程缓存;缓存只作为 procedure 暂时失败后的兜底。不要订阅或读取 `feature_gate_config` 本地表来判断后台配置。
- 验证:`RUSTC_WRAPPER= cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml``RUSTC_WRAPPER= cargo test -p api-server --manifest-path server-rs/Cargo.toml frontend_runtime_config_denies_anonymous_agent_sidebar_when_gate_enabled``RUSTC_WRAPPER= cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent_api_returns_service_unavailable_when_sidebar_gate_denies_user`
- 关联:`server-rs/crates/spacetime-client/src/runtime.rs``server-rs/crates/spacetime-client/src/lib.rs``server-rs/crates/api-server/src/frontend_runtime_config.rs``server-rs/crates/api-server/src/editor_agent.rs`
## 后台灰度新 target 不能继承旧规则
- 现象:管理员先点开一条已有 gate,再从两段式下拉框选择一个尚不存在的新 target,保存后新 gate 可能带着上一条 gate 的启用状态、灰度比例和黑白名单。
- 原因:新 target 分支如果只更新 gate key,会复用当前 React 表单状态;这些字段对运营不可见地跨 target 泄漏。
- 处理:`applyGateTarget` 进入不存在的新 target 时必须重置为新建态:`enabled=false``rolloutPercent=0`、allow / deny 列表为空,并使用 target 默认描述。只有显式点已有 gate 才 `fillForm` 复制服务端规则。
- 验证:`npm run test -- apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx`
- 关联:`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx``apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx`
## 背景色决策喂 gpt-5-mini 的图不必按阿里云抠图那样归一化
- 现象:担心带图背景色决策把源角色图原样 base64 塞给 gpt-5-mini`resolve_media_source_as_data_url` 不做 resize / 字节上限),会像阿里云通用抠图那样因超尺寸 / 超体积被上游拒绝,于是想给决策链路也补一套图片归一化。
- 原因:两条链路的上游限制完全不同。阿里云 SegmentCommonImage 有硬限制(≤3MB、分辨率 <2000×2000、最长边 ≤1999),必须归一化;而 gpt-5-mini(经 VectorEngine `/v1/responses`Responses 协议 + `input_image`)对图片输入宽松得多,实测远超 App 真实源图范围仍全部 HTTP 200:纯色图到 5000×5000(隔离像素维度)正常识别;噪声图到 base64 请求体 34MB(隔离字节维度,PNG 25.8MB)仍成功返回。App 真实源角色图一般 ≤2048px、几 MB,稳落在安全区。
- 处理:**不要**给 `resolve_editor_screen_background_color` 的带图路径加图片归一化——那是阿里云抠图链路(`platform-matting`)专属需求,两者别混。真要加保护也应放在字节 / 像素远高于当前实测通过档(如 base64 >40MB 或长边 >6000px)才截断,避免无谓重编码开销与画质损失。
- 验证:探针脚本 `Myscripts/probe_gpt5mini_image_limits.py`(本地不入库,逐级放大纯色 / 噪声图打 `/v1/responses`,记录 HTTP 状态与响应)。2026-07-10 实测:solid 512²~5000² 全 200noise 900²(4.1MB)~2600²(34.4MB) 全 200,无拒绝阈值出现在实用范围内。
- 关联:`server-rs/crates/api-server/src/character_animation_assets.rs``resolve_media_source_as_data_url`)、`server-rs/crates/api-server/src/editor_screen_background_decision.rs``server-rs/crates/platform-matting/src/lib.rs`(对照:阿里云输入归一化)。
## 不要把 BgFilter segModel 暴露为外部可选参数
- 现象:看到 `EditorImageGenerationRequest``EditorIconSpritesheetGenerationRequest``EditorUiDesignAssetExtractionRequest` 能反序列化 `segModel`,容易认为外部 OpenAPI 也应公开该字段,或让用户在 `birefnet``anime-seg` 间自行选择。
- 原因:`segModel` 是 BgFilter 内部调用链的有效兼容字段,不等于稳定的外部产品契约。当前 BgFilter 服务受进程内存和并发容量约束,不同分割模型的资源消耗不能交给外部调用方控制;任意开放模型切换会让容量规划、超时和故障隔离失去确定性。
- 处理:产品 UI 不提供模型选择,应用内调用固定 `birefnet`;外部编辑器 OpenAPI 不声明 `segModel`,并保持相关请求 schema 的 `additionalProperties: false`,使外部请求携带该字段时被契约拒绝。只有维护 BgFilter 模型与容量的后端代码可使用该内部字段;若未来需要开放,先完成各模型的内存、并发和超时压测,再明确版本化外部契约。
- 验证:检查 `docs/openapi/genarrative-external-v1.openapi.json` 的图片生成、图标 spritesheet 和 UI 素材提取请求 schema 均未包含 `segModel`,且均保持 `additionalProperties: false`
- 关联:`server-rs/crates/api-server/src/editor_project.rs``src/services/image-editor/editorProjectClient.ts``docs/project-memory/shared-memory/decision-log.md`
## SPA 路由白名单不能只按一级目录放行
- 现象:`/not-exist` 已返回 404,但 `/creation/not-exist``/runtime/not-exist``/puzzle/not-exist` 仍返回 200 首页,搜索引擎继续判定为 soft 404。
- 原因:Nginx 或 Pingora 使用 `/creation/*``/runtime/*` 等宽前缀作为 SPA fallback,前端对未知路径又回到平台首页;只验收根级未知 URL 无法发现该问题。
- 处理:SPA fallback 必须精确匹配当前真实完整路径,同时允许前端已有的大小写归一和尾部斜杠;最终 catch-all 只提供真实静态文件。浏览器 HTML 导航失败时返回品牌 `404.html`,但状态码仍为 404;API、探针和非 HTML 请求保持原有 404 响应。路由增删同步三套 Nginx、Pingora、route parity matrix 和路由门禁。
- 验证:除全部真实 SPA 路径外,至少检查 `/not-exist``/creation/not-exist``/runtime/not-exist``/puzzle/not-exist` 均返回 404;带 `Accept: text/html` 的未知 Web 路径正文命中品牌页,不带 HTML Accept 的请求不得命中品牌页;维护模式仍保持页面 503 优先语义。
- 关联:`src/routing/appRoutes.tsx``src/routing/appPageRoutes.ts``deploy/nginx/``deploy/container/nginx.conf``server-rs/crates/pingora-gateway/src/main.rs`
## Jenkins Job UI 参数会被 SCM Jenkinsfile 覆盖
- 现象:在 Jenkins Job 页面给 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 配了默认值,下一次加载 Declarative Pipeline 后又变空或恢复旧描述;04:00 Full Job 还可能因默认选择 `pause-after-stdb` 且 approvers 为空而失败。
- 原因:这些 Job 使用 Pipeline script from SCM`parameters {}``triggers {}` 会作为 Job property 回写现场配置;只改 UI 不是持久修复。构建编排如果不显式关闭下游 `PUBLISH_AFTER_BUILD`,还会受下游默认值漂移影响。
- 处理:credential ID 和参数默认值写回三个 Jenkinsfile;仅供开发使用的 dev 定时 Full Job 默认 `STDB_API_ROLLOUT_MODE=normal`,三路 Build 调用显式传 `PUBLISH_AFTER_BUILD=false`,再由 Full Job 统一按 Stdb → API → Web 发布。Secret 原文只放 Jenkins Secret File,旧 Secret Text 保留给 Import / Export。
- 验证:推送后让 Full / Stdb Build 用不存在的源码分支在 checkout 阶段 fail-closed,让 Stdb Publish 用空构建版本在 Prepare 阶段 fail-closed,以安全刷新参数 schema;随后只读检查三个 live `config.xml` 的参数描述和默认值,确认 Full timer 仍为 `0 4 * * *`、rollout 默认值为 `normal`,并确认刷新运行未进入 publish / deploy stage。
- 关联:`jenkins/Jenkinsfile.production-full-build-and-deploy``jenkins/Jenkinsfile.production-stdb-module-build``jenkins/Jenkinsfile.production-stdb-module-publish``scripts/check-production-ops-guardrails.mjs`
## 维护模式内网全站放行不能信任 X-Forwarded-For
- 现象:维护期间希望让内网继续访问整站,如果直接按 `X-Forwarded-For: 192.168.x.x` 放行,公网请求可伪造该头绕过维护闸;如果仍按路径只放行后台,又会让内网主站和普通 API 继续返回 503。
- 原因:XFF 是客户端可提交的普通请求头,当前 Nginx 的 `$proxy_add_x_forwarded_for` 还会保留已有前缀;维护放行属于授权判断,必须建立在不可伪造的网络来源边界上,并在路由分类前按来源统一决定是否绕过维护闸。
- 处理:Nginx 按 TCP `$remote_addr` 判断内网;Pingora 按 TCP peer 判断,只有 peer 为 loopback 的同机 Nginx 时才接受 Nginx 强制覆盖的 `X-Real-IP`。可信内网来源绕过整站维护响应,公网应用主站、普通 API、后台和 SpacetimeDB 路由仍保持维护响应;绝不能用 `X-Forwarded-For` 做放行判断。
- 验证:Pingora smoke 同时覆盖公网主站、普通 API、后台为 503,以及内网对应路由为 200;Rust 单测覆盖 IPv4 / IPv6 内网、公网和空来源;Nginx 静态门禁反查两份模板的内网来源定义与全局维护变量清零逻辑。
- 限制:如果发布门禁已经停止 api-server,网关放行后普通 API 和后台 API 仍会失败;需要调用后端时应确保对应服务仍运行,不能把维护页绕过误当作服务可用性保证。
## Full 结束后保持维护不能只加一个 UI 参数
- 现象:Full Job 参数页没有“完整发布成功后是否退出维护”选项,或者补了选项后 API readiness 一通过仍自动撤掉维护。
- 原因:维护退出发生在随 API artifact 发布的 `production-api-deploy.sh` 内;Full、API Deploy Job 和脚本任一层没有透传,最终都会回到固定执行 `maintenance-off.sh`。Declarative Pipeline 参数还要等 live Job 加载新版 Jenkinsfile 后才会刷新。
- 处理:Full 使用 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 表达产品选择,Stdb Publish 和 API Deploy 全程固定保持维护,Web Deploy 成功后才进入独立最终退出阶段;API Deploy 的独立 `KEEP_MAINTENANCE_MODE` 再转换为脚本 `--keep-maintenance-mode`。API deploy 还必须把 `production-api-deploy.sh``maintenance-on.sh``maintenance-off.sh` 从同一 build artifact 复制进 current release,否则 Full 最终阶段即使有选项也找不到随包退出脚本。默认值仍在 Full 结束时退出维护,避免定时 dev 发布行为变化。
- 验证:API deploy fixture 必须覆盖成功发布并保留 marker,还要断言 current release 中三个部署 / 维护脚本存在;生产运维静态门禁同时反查 Full 参数、下游透传、API Deploy 参数和脚本 flag。推送后用 fail-closed 首阶段运行刷新 live Job 参数,再核对 `config.xml`,不能只看仓库文件。
## 临时维护公告不能提交进版本化默认页
- 现象:现场已恢复通用维护页,但后续 Web Deploy 或下一次进入维护后,又显示昨天的“今天晚上 HH:MM~HH:MM”公告。
- 原因:`public/maintenance.html` 会被 Vite 复制进 `web.tar.gz`Web Deploy 解包后把 `/srv/genarrative/web` 指向新制品;临时公告一旦进入该源码,就会成为每次发布都恢复的长期内容。旧维护 on / off 只控制 marker,浏览器缓存不是根因。
- 处理:版本化默认页只保留无日期通用文案;临时公告用 `maintenance-on.sh --page-file <公告HTML>` 安装到 `/var/lib/genarrative/maintenance/page.html`。Nginx / Pingora 优先读取运行态公告,退出维护时同步清理;不要再原地编辑 `/srv/genarrative/web/maintenance.html` 或提交临时公告到 `public/`
- 验证:`npm run check:maintenance-page` 必须拒绝相对日期、具体日期和具体时间,并覆盖公告安装、同窗口保留、退出清理与新窗口清残留;Pingora smoke 必须证明运行态公告优先且删除后回退默认页。
- 关联:`public/maintenance.html``scripts/deploy/maintenance-on.sh``scripts/deploy/maintenance-off.sh``deploy/nginx/snippets/genarrative-maintenance.conf``server-rs/crates/pingora-gateway/src/main.rs`
## 遮罩点击关闭必须校验完整指针序列
- 现象:在弹窗内容内按下鼠标,拖到弹窗外的遮罩上松开时,弹窗被误关闭。
- 原因:只在 `click` 阶段判断 `event.target === event.currentTarget` 不足以确认用户点击了遮罩;跨弹窗边界松开时,浏览器可能把合成点击的目标归到弹窗和遮罩的共同祖先。
- 处理:共享弹窗统一记录 `pointerdown``pointerup` 的目标,只有按下和松开都发生在遮罩自身时才允许关闭。新增弹窗优先复用 `UnifiedModal`,不要继续复制只判断最终 `click` 目标的手写遮罩逻辑。
- 验证:回归测试同时覆盖“弹窗内按下、遮罩松开不关闭”和“遮罩按下、遮罩松开正常关闭”。
- 关联:`src/components/common/UnifiedModal.tsx``src/components/common/UnifiedModal.test.tsx``src/components/auth/PlatformAuthModalShell.test.tsx`
## 公开作品资产不能用 generated 前缀或 PublicRead 批量放行
- 现象:资产 ACL 收紧后,公开页面读取其他作者作品资产集中返回 `404`;对象在 OSS 中真实存在,但已登记 `asset_object.access_policy = private`
- 原因:“作品公开”不等于“作者账号下所有 generated 对象永久公开”。只按 profile / session 关联也会误公开同会话的未选候选图、参考图或生成输入;批量改 `PublicRead` 则无法随作品隐藏、删除或取消发布自动撤销。
- 处理:已登记对象继续保持 `private`,通过 `public_work_asset_read_grant` 只派生 `Published + visible``custom-world` 还必须未删除)正式发布快照实际使用资产的匿名读授权。API 必须同时校验 grant owner 与资产 owner 一致,以及 `asset_object_id` 或精确 `object_key` 命中;明确排除参考图、未选候选图和 `generationInputs`。Custom World 只能扫描角色、地标、营地、章节和 opening CG 等正式根,不能遍历 legacy payload 的未知根。历史作品交给 view 现算补齐,不做永久 ACL 数据补丁。
- 权威查询边界:不能从 `asset_object``public_work_asset_read_grant` 的连接级订阅 cache 推断当前 ACL;池连接水位不一致会让刚撤销的 grant 继续签发 URL,也会让刚公开的作品短暂 404。资产定位和公开授权必须通过受 runtime service identity 限制的 procedure 在同一事务快照中计算,失败时拒绝读取;procedure 先按 asset owner 使用各玩法 owner 索引缩小到该作者作品,再匹配候选 `asset_object_id` / 精确 key,不能每张图都执行全站公开 view,也不要在每个池连接订阅复制全量 private 资产表。公开派生授权、`PublicRead` 和 legacy 兼容读取的签名 URL 最长 600 秒,owner / admin 不受该公开上限影响。
- Remix 边界:拼图、Custom World 和大鱼现有 Remix 会把源资产引用复制到新 owner,但没有持久化不可伪造的资产来源。不得因此放宽跨 owner grant;源作品隐藏后仍公开的 Remix 资产,需要后续通过 Remix 时复制资产或持久化 provenance 解决。
- 验证:资产 owner 本人仍可读;公开可见作品的正式资产可匿名读;跨 owner、只命中前缀、参考图、未选候选图和 `generationInputs` 仍返回不存在;作品隐藏、删除或取消发布后 grant 消失。
- 关联:`server-rs/crates/spacetime-module/src/public_asset_access.rs``server-rs/crates/spacetime-client/src/assets.rs``server-rs/crates/api-server/src/assets.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 持久进程恢复不能重放 start 或按 PID 重连
- 现象:Runner 强杀或重启后,同一个 run 又启动了一份开发服务器,或者新 Runner 根据旧 PID 把宿主上的同号进程误认成原 PTY 会话。
- 原因:把 durable process record 当成活 OS handle,或在 spawn 前没有同步写入 `launching`,导致恢复逻辑无法区分“尚未启动”和“已经尝试启动”;OS PID 会复用,也不包含项目、Agent、run、action 和 Runner boot 身份。
- 处理:`processId` create-once 绑定完整 Runtime 身份,`prepared / launching` 必须先于 OS spawn 持久化。旧 boot 下 prepared / launching / running / terminating 且缺少可信 terminal record 时只进入 `needs-reconciliation`;不重放 start / stdin / terminate,不探测或接管旧 PID / PTY。首版不自动推断旧 prepared 为安全重试。
- 验证:在 launch 前后、running、stdin 写入后和两阶段 terminate 中分别强杀 Runnerfixture 的 launch / stdin 计数保持 1,恢复后没有 PID reconnect、没有 final`runner.shutdown_if_idle` 仍报告 busy / reconciliation。
- 关联:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md``apps/ai-game-creator-shell/src-tauri/src/runner.rs``apps/ai-game-creator-shell/src-tauri/src/command_exec.rs`
## PTY 输出与 stdin 正文不能进入公共 Runtime 持久面
- 现象:模型能正常 poll 进程输出,但 task/event、Agent DB、receipt、动作历史、activity/output、UI snapshot 或验收报告里也出现了终端正文;或者 stdin challenge 被确认摘要、inputSummary、错误日志保存为明文。
- 原因:直接复用普通 tool observation / pending action 的通用序列化,或为了排障把 PTY chunk 和 stdin data 整段复制进审计。持久进程正文可能包含密钥、绝对路径、交互输入和第三方进程回显,不能只依赖事后清洗。
- 处理:PTY 原始字节经控制序列、UTF-8、凭据和绝对路径清洗后,必须显式恢复清洗器裁掉的逻辑换行,再只进入 owning Agent 的私有 transcript、observation 和 context。公共持久面只保留 cursor、字节数、SHA-256、截断、状态和退出元数据;stdin 正文只允许存在于执行所需的私有 pending action,终态后删除,审计仅保留 `bytesWritten / contentSha256 / stdinOpen / eof`,禁止前后缀、摘要和可逆编码。
- 验证:fixture 同时输出唯一 sentinel、绝对路径和诱饵密钥,并发送唯一 stdin challenge;私有 poll 能读取清洗后结果,所有公共文件和报告的正文命中数为 0,stdin 只命中字节数和 SHA-256。
- 关联:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md``apps/ai-game-creator-shell/src-tauri/src/command_output.rs``apps/ai-game-creator-shell/src-tauri/src/agent.rs`
## PTY 会话未终态时不能 final 或关闭 Runner
- 现象:Agent 回复“服务已在后台运行”后 run 被记成 completed,随后 `runner.shutdown_if_idle` 关闭 Runner;或 terminate 只发出一次信号就宣告成功,留下仍存活的 child / grandchild。
- 原因:完成门禁只检查 pending tool action,没有把 live process registry、输出泵、终止任务和 unresolved reconciliation 纳入 active work;或者 terminate 为取状态从 offset 0 偷读一个字符并返回新 cursor,诱导后续 poll 重读 / 跳过;同时把 PTY / process group 错当成完整 OS sandbox 和可靠进程树隔离。
- 处理:launching / running / terminating 与任意 status 上的 `needsReconciliation=true` 全部阻止 final reply、finalization journal、completed 和 idle shutdown。terminate 携带最后一次 poll cursor 并返回同一 cursor 的零消费元数据;Unix 完成完整 graceful wait 后只 force kill 同组残留,再 wait / reap / drain PTYWindows 首版使用 Job force terminate + wait / reap;任何 signal / Job / wait 阶段无法确认都保持 reconciliation。Linux wrapper 监测 owner PID 并在 Runner 强杀后 kill 当前前台进程组,Windows 使用 kill-on-close Job Object;取消 run 也走同一收束路径。主动 `setsid` / 外部 service 和 OS sandbox 仍不在承诺内。
- 验证:活会话下 finalization 和 `runner.shutdown_if_idle` 必须失败关闭;分别验证 graceful handler 尾部输出、宽限超时后的 force、忽略 SIGHUP 的 npm 孙进程和 Windows Job 路径,只有 child 已终态、同组残留已处理且 PTY 尾部排空才出现唯一 terminal record。另用允许程序证明代理和固定 cwd 不是文件系统 / 网络沙箱,不得把该现象误写成测试失败或安全能力。
- 关联:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md``apps/ai-game-creator-shell/src-tauri/src/runner.rs``apps/ai-game-creator-shell/src-tauri/src/agent.rs`
## 命令环境变量、代理和进程组不能冒充 OS 沙箱
- 现象:命令看似使用隔离 HOME / TMP、离线包管理器和不可达代理,仍能直接读取宿主用户文件、用原始 socket 联网,或由 `project.verify` 的平行 npm spawn 绕开 `command.exec` 限制。
- 原因:环境变量和 argv 白名单只约束主动配合的程序,进程组 / Job Object 主要解决生命周期;它们不建立 mount / network namespace,也不能保护 `.agent` Runtime 控制面。只包 `command.exec` 而漏掉 `command.start``project.verify` 同样属于 fail-open。
- 处理:Linux 三个入口统一使用受信任系统 bubblewrap;项目根 rw`.git / .agents / .codex` ro`.agent` 以 000 空 mount 隐藏,项目外普通用户路径不挂载,network namespace 默认隔离,嵌套 userns 禁用。全局 namespace canary 与项目 mount preflight 都必须在 revision / processId / 目标 program 前成功;任何失败都不回退宿主执行。Windows 在等价 restricted process / AppContainer 落地前继续标记为固定命令 legacy 边界。
- 验证:不能只断言 bwrap argv。必须运行真实目标和子进程,分别检查工作区写入、宿主 sentinel、四个控制目录、原始 socket、PTY stdin / graceful terminate、Runner SIGKILL 后宿主 `/proc` 无项目 cwd 进程,以及 unavailable 时 marker 为零。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs``command_exec.rs``process_session.rs``project.rs``docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`
## 工具链环境根不能把整个用户目录挂进命令沙箱
- 现象:Runner 以 `RUSTUP_HOME=$HOME` 启动,或 `.rustup` 符号链接最终 canonicalize 到 HOMEAgent 随后能在 bubblewrap 内读取 SSH、Cookie 或其它用户文件,并把正文带回命令输出。
- 原因:只拒绝字面 `/home / root / tmp`,没有拒绝 `/home/<user>`,也没有检查 canonicalize 后目录名是否仍与 `RUSTUP_HOME / JAVA_HOME / GOROOT / DOTNET_ROOT` 类型匹配。
- 处理:外部工具链环境根 canonicalize 后必须通过窄叶目录校验;用户 HOME、HOME 符号链接目标和类型不匹配目录全部在 mount preflight 阶段失败关闭。不要为了兼容任意自定义环境根放宽成“只读就安全”。
- 验证:直接 HOME、`.rustup -> HOME` 均返回错误且目标 program 零执行;真实 `.rustup` 叶目录仍可只读挂载,Cargo fixture build 继续通过。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs``command_exec.rs``docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`
## 沙箱内验收 fixture 不能依赖 Runtime 控制面或宿主 namespace 身份
- 现象:V1.10 process-session 真实验收在 V1.11 后报“缺少精确回显”,但确定性 PTY 和 sandbox 测试均通过;旧 fixture 在 readiness 前写 `.agent`,还启动 loopback server 并把进程内 PID / 端口交给宿主检查。
- 原因:V1.11 正确地用 0000 空 mount 隐藏 `.agent`,并隔离 pid / network namespace。沙箱内 PID、loopback 端口和 Runtime 控制目录不再是宿主可观察事实;fixture 在输出 readiness 前即可能失败,统一的 interaction-evidence 错误又掩盖了真实阶段。
- 处理:交互 fixture 只使用 PTY stdin/stdout/signal,不写 `.agent`、不监听 TCP、不持久化 PID/端口。唯一启动由 process record、start action/fingerprint、start audit 和唯一 readiness marker共同证明;Runner 强杀后的清理由 owner boot、reconciliation record 和宿主 `/proc/*/cwd` 项目进程归零证明。
- 验证:独立真实 Node smoke 必须完成 readiness、challenge 单行原样输入、精确 echo、SIGTERM stopped,并确认项目未创建 `.agent`E2E 分别报告 readiness / stdin hash / echo / stopped 缺失,严格检查 readiness poll -> stdin -> echo poll -> terminate -> terminal poll。Provider 在零工具计划阶段的 502/TLS 只记外部失败,不得归因到 fixture 或 Runtime。
- 关联:`apps/ai-game-creator-shell/scripts/process-session-real-e2e-fixture.mjs``agent-runtime-real-e2e.mjs``apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts``docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`
## pre-exec 固定 FD 映射不能逐项覆盖源描述符
- 现象:可信 launcher 的单项测试都通过,但并发 `project.verify` 偶发在第二次 launch 被记为 failed;失败项单独重跑又恢复正常。
- 原因:父进程创建 socket/file 后得到的源 FD 数值不固定。若逐项 `dup2(source, 0/4/5/6)`,前一次目标 FD 可能正是后一条映射尚未读取的源 FD,导致 status、block 或 trampoline source 被静默替换。测试并发度改变打开 FD 分布,因此表现为偶发。
- 处理:在父进程进入 spawn 前先用 `F_DUPFD_CLOEXEC` 把所有源复制到 64 以上互不重叠的 owned FD,并保持到 child-createdpre-exec 只把这些稳定高位 FD `dup2` 到固定 0/4/5/6。不能等到 pre-exec 才复制原始源,因为 Command 的 stdin/stdout/stderr 安装可能已覆盖原本占用 0/1/2 的源 FD。
- 验证:并发执行全部 project verification 测试;同时真实运行 bwrap staged marker 用例,确认 child-created、block、ready、commit、exec 和目标退出链均稳定,目标 argv/env/FD 不含 nonce 或控制 socket。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/command_sandbox_trampoline.rs``command_sandbox.rs``command_exec.rs``project.rs`
## bwrap 的命令分隔符不能从目标 argv 末尾反查
- 现象:普通命令握手正常,但 `cargo test -- --nocapture`、npm forwarded args 等包含独立 `--` 的合法目标参数可能在 sandbox preflight 或 staged launch 期间提前执行原目标。
- 原因:launcher 用 `rposition("--")` 查找 bwrap 自己插入的命令分隔符,误命中目标 argv 里的最后一个 `--`;截断后原 target executable 仍位于 bwrap COMMAND 位置,trampoline 被追加成目标参数而不是替代目标。
- 处理:构造器保证 bwrap options 与 COMMAND 之间只有第一个独立 `--` 是 launcher 分隔符;preflight 和 stage 都取第一个位置。不要从用户目标 argv 的末尾推断结构边界。
- 验证:真实 staged bwrap 用例必须让目标 argv 带独立 `--`,同时断言 sandbox-ready/commit 前 marker 为零,commit 后才执行成功。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs``command_exec.rs`
## PTY 私有控制 FD 不能假设会被 bubblewrap 透传
- 现象:process-session wrapper 在 portable-pty 之后成功创建 fd 3 控制 socket,但 trampoline 收不到 ready/commit 帧;如果直接改用 fd 0,真实 target 又失去交互 stdin。
- 原因:portable-pty 在 wrapper exec 前关闭全部 fd 3 以上描述符;wrapper 重新创建 fd 3 后,bubblewrap 仍只保留 stdio 和被 `--json-status-fd / --block-fd / --ro-bind-fd` 明确引用的描述符,未引用 fd 3 不是可靠 COMMAND 继承通道。
- 处理:Runner 与 wrapper 先用 abstract Unix socket bridge 传递私有 launch planwrapper 内部 gate 继续通过 bwrap 会保留的 fd 0进入 trampoline。process-session trampoline 不把控制 fd 0传给 target,而是确认 fd 1 是 PTY 后复制同一 slave作为 target stdin;一次性命令模式仍显式使用 `/dev/null`
- 验证:真实 PTY 测试必须完成 readiness、stdin echo 和 terminatetrampoline 测试同时断言一次性 stdin 为 null、process-session stdin 可读 PTYtarget fd 3/4/5/6 不存在,bridge endpoint/nonce/control frame 不出现在 transcript 和公共持久面。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs``process_session.rs``command_sandbox.rs``command_sandbox_trampoline.rs`
## graceful terminate 不能先杀承载 target 的 wrapper 进程组
- 现象:target 注册了 SIGTERM 清理逻辑,但 `command.terminate` 只偶尔出现 stopped marker;耗时 300-500ms 的清理经常被提前截断。
- 原因:如果先向 wrapper/bwrap/trampoline/target 共用的外层进程组发送 SIGTERMwrapper 会先退出,bwrap 的 die-with-parent 随即收走 namespace;名义上的 800ms 宽限并没有真正留给 target。
- 处理:process-session target 在 child pre-exec 内暂时屏蔽 SIGTTOU,完成 setpgid + PTY slave tcsetpgrp 并恢复信号掩码后才 exec;不能先 spawn 到后台组再由 parent 设前台,否则 target 可能已经因 immediate read 收到 SIGTTIN。Runtime 通过两级私有控制通道请求 trampoline 只向 target group 发 SIGTERM。direct leader 退出后 trampoline 继续检查同组后代,外层 wrapper/bwrap 在最多 800ms 宽限期保持存活,超时才强杀 containment group。reader 发现未换行输出超过上限时必须先原子投影 `output-limit-exceeded` 并唤醒 poll,再异步发送终止控制,不能让高负载下的 supervisor 调度延迟把已越界进程继续暴露为 `running`
- 验证:使用直接 bash target 启动同组后台子进程;leader 在输出 READY 后自然退出,仍存活的子进程收到 TERM 后由 trap 延迟 400ms 写 marker 并退出,terminate 返回前 marker 必须存在。正式 `command.exec` 测试夹具仍必须走允许的 `npm run` 等程序,不能为了构造 stdin race 绕过白名单直接解析 `bash -lc`。另跑 immediate stdin/EOF、Runner owner SIGKILL 和后代隔离用例,确认前台切组没有破坏交互或 fail-closed 回收;测试互斥锁在前序 panic 后应恢复 guard 继续报告后续独立结果,不能用 `PoisonError` 掩盖真实失败范围。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/process_session.rs``process_session_bridge.rs``command_sandbox_trampoline.rs`
## 启动记录必须封闭状态组合,child 不能自行猜 durable commit 超时
- 现象:慢磁盘让 sandbox-ready 后的 child 在父侧持久化完成前自行退出,父侧随后把零执行误记为 launch-unknown;损坏的 `failed + launch-unknown + needsReconciliation=false` record 又可能被 active/final/idle 扫描漏掉。
- 原因:child 与父侧各自维护短 timeout,没有统一 commit/abort 决策;record 校验只检查枚举和值存在,没有约束 status、sandbox、target、failure、reconciliation 与时间戳的合法组合。Windows 如果在 action 去重前调用 durable callback,还会在同 action replay 时重复推进 revision。
- 处理:sandbox-ready 后 child 阻塞等待父侧显式 commit 或 abort,父侧失败时发送 abort 并回收树。v3 读取使用封闭状态矩阵和 `started <= ready <= exec <= terminal <= updated` 的逐项可选时间校验;旧 boot prepared/launching 及同 action start replay转成 target unknown。非 Linux durable callback 只放在 existing action miss 分支,started/ready/exec 使用同一时间点。live registry 必须与 durable record、pending reservation 合并参与 capacity/final/idle,不能因 record 缺失失败开放。
- 验证:durable callback 延迟超过旧 3 秒时 target marker 在 callback 内必须仍不存在、commit 后才出现;构造 launch-unknown/start-audit/target-exec 的非法组合均拒绝读取,旧 boot launching 和 same-action replay必须变成可再次读取的 reconciliation,同 action Windows 测试只调用一次 callback;删除 live record 后 final/idle 仍被 registry 阻断。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/process_session.rs``process_session_bridge.rs``docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`
## loopback port 0 也会被临时端口池耗尽阻断
- 现象:旧 Runner 已停止、endpoint 连接拒绝,但新 Runner 在 `TcpListener::bind(127.0.0.1:0)` 直接返回 `Address already in use`,所有 Agent 写命令随后报“Runner 在就绪前退出”;同一主机上的本地预览和 Node HTTP 测试夹具也会以相同方式失败。
- 原因:port 0 仍需要内核从 `ip_local_port_range` 分配监听端口;本机 api-server 与 SpacetimeDB 的约 2.8 万双向连接占满 32768-60999 后,即使目标端口不是旧 endpoint 端口,自动分配也会失败。只看 `ss -ltn` 会漏掉占用本地端口的 established client socket。
- 处理:先保留 port 0 正常路径;Linux 只在 `AddrInUse` 后懒读取 `ip_local_port_range / ip_unprivileged_port_start / ip_local_reserved_ports`,把候选限制在 61000-65535 高位段并排除临时范围、实际特权范围和 reserved ranges,再按随机起点尝试。Runner 与本地预览复用同一 loopback binder;必须交给外部测试进程监听时,先用有同等回退能力的测试 listener 预留端口并有限重试交接。不要停止用户 dev 栈,不要扫描常见服务低端口,不要使用非 loopback fallback,也不要用固定公开端口或无 token 协议绕过。
- 验证:除纯 bind 回退、懒加载、非默认特权起点、reserved ranges、候选耗尽和范围解析单测外,还要在端口池真实耗尽的主机上启动 Runner 和本地预览,确认监听端口位于临时范围外、heartbeat 与预览内容可读,并完成真实 Provider 任务及 MCP HTTP fixture 全量回归。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/runner.rs``preview.rs``tests.rs``agent-runtime-real-e2e.mjs``docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`
## 旧 process record 惰性迁移不能替代 resume 主动投影
- 现象:owning Runner 被 SIGKILL 后,项目 cwd 进程已经清零,新 boot 和同 run / session 也恢复成功,但 process record 仍显示旧 boot 的 runningtask 长时间停在旧 planning,真实 Runner-kill 套件等不到 reconciliation。
- 原因:process record 的旧 boot 迁移只在 poll、active scan 等读取路径发生;独立 Runner `runtime.resume` 原先直接恢复 running task,没有先触发 active process scan,也没有把迁移后的 record 同步投影到 task / state。
- 处理:resume 先全局拒绝指向未知 Agent 的 reconciliation record,再在 Agent lane 锁内处理所属旧 active records;一旦 record 进入 reconciliation,立即把 owning run 的 task / state / queue / event / Agent DB 写成 `needs-reconciliation` 并停止恢复。这里不能用“task phase 已是 reconciliation”作为整体完成标记:task 追加、state / queue 重建、event 和 Agent DB 补齐必须分别幂等。JSONL 追加前先锁内修复截断尾行;event / Agent DB 用原 start action 身份去重,并对 Agent/task/session/run/process/owner boot 做冲突校验,不能只按 run/type 判断存在。
- 安全边界:写投影前逐条核对 process record 与 task 的 Agent、run、task、conversation session 身份;同一 Agent 出现多个不同 owning run 时失败关闭。同 run 多 record 也只能在全部身份一致时聚合。缺 task、记录损坏或身份冲突时不得改写原 task、继续规划、按 PID 重连或自动重启服务。
- 验证:确定性用例除重复 resume 外,还要在首次恢复后把 task、event 和 Agent DB 专用投影改成截断尾行并删除 state,再次 resume 必须修复半行、补齐四者且 reconciliation task 仍只有 1 条;Agent 错归属、task/session/process 同键冲突必须报错。真实套件在 readiness 后 SIGKILL Runner,分别从全量 task、event、Agent DB、runtime state 和 process record证明唯一 reconciliation与零 reconnect;可选文件或目录只容忍 `ENOENT`,其他读取错误不能吞掉。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent.rs``process_session.rs``tests.rs``agent-runtime-real-e2e.mjs`
## 私有 PTY 正文不能只靠 prompt 阻止最终回复复述
- 现象:Agent 正确完成唯一持久进程交互,但模型偶发在最终回复中复述一次性 challenge 或 readiness / echo / stopped 行,随后 conversation、event 和 Agent DB 公共投影一起泄漏私有进程正文。
- 原因:`command.poll` 正文需要进入 owning Agent 私有 observation 才能继续交互;system/task prompt 只能约束模型行为,不能作为持久化安全边界。
- 处理:在 finalization journal 写入前检查当前 run 的成功 `command.poll` observation;只要存在非空私有输出,就不再持久化模型原回复,而是写固定安全完成摘要,再计算 fingerprint、写 assistant 和公共终态投影。不能只替换长行或高熵 token,因为模型可能只复述 `1234` 等短子串;不要把原始行或 token 写入新的审计记录。
- 验证:定向用例让最终回复包含 challenge、完整 ready/echo/stopped 行和 `PIN=1234` 的短值局部回显,要求统一变为固定摘要;没有私有 poll 正文的普通回复保持原样。真实 Provider 继续扫描 task/event/Agent DB/receipt/conversation/activity/output/runtime state/report,所有正文泄漏必须为 0。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent.rs``tests.rs``agent-runtime-real-e2e.mjs`
## Project Supervisor 专业回执不能靠 Agent DB 或新 continuation 收束
- 现象:同一条用户目标在专业 Agent 完成后出现第二个 `delegate-receipt-*` run 和第二条面向用户的 assistant;或 Runner 重启后回执丢失、被不同 action 重复认领、父 run 永久等待,甚至错配 child 把真实 delivery 误标为 suppressed。另一类实测症状是首条 Supervisor 消息报“Agent Session Runtime 启动跨进程锁超时”,或父 Agent 在两个 child 运行时持续调用 `agent.run_status`,随后被 child 的黑板写入推进 project revision 而误判为旧动作。
- 原因:把 `.agent/agent.db` 诊断投影当成回执协议事实,或直接从 ready 跳到已消费,没有可恢复的 claim 阶段和 observation 门禁。多回执认领若不先按稳定顺序取齐所有 delivery 锁,会死锁或留下部分认领;parent-wake 若每次生成随机 Runner requestId、把“全局扫描成功”当成“目标已推进”或对结构性错误无界重试,会丢 wake、重复唤醒或掩盖损坏状态。持有 Session lane 同步通知 Runner 会让 Runner 反向启动同一 Agent 时自锁;把 claim 型 `agent.run_status` 当成项目写动作做 revision/fingerprint 门禁,会被合法的专业 Agent 写入误伤。
- 处理:正式主聊天只路由到 `project-supervisor` active Session,活跃期输入继续 same-run steer;同一父 run 最多同时保留 3 个 `dispatched / ready` 静态专业委派,已预留的同 action delivery 恢复复用原 target Session/run,不另占名额。同一工具计划完成委派后,Runtime 在下一次 Provider planning 前直接持久化 `waiting-for-delegate-receipts` 并释放 lane,不让模型轮询等待。delivery 单向推进 `dispatched -> ready -> claimed-by-parent / suppressed`claim 单向推进 `Prepared -> Committed -> Observed`;先持有 claim 锁,再对 delegationId 排序去重并按序取齐 delivery 锁,任一锁不可得时零状态推进。delivery / claim journal 与 pending observation 是事实源;Agent DB append 只能 best-effort,失败不得推翻已持久化结果。入队在 Session lane 内完成,Runner 通知在 lane 外发送;`agent.run_status` 保留 claim 身份校验但不绑定全局 project revision/fingerprint。
- 恢复门禁:只有 `project-supervisor` 的 executing `agent.delegate / agent.run_status` 可在项目锁内重验 durable pending、Session/run/action fingerprint、delivery/claim/child 身份和当前 policy 后补交;只有 delivery 预留且无 child 时,拒绝动作必须把该预留 CAS 为 suppressed。其他 executing 动作或副作用身份不明必须进入 `needs-reconciliation`。parent-wake 以 project/Agent/run 做 coalescing singleflight,新信号不能在已有 worker 退出窗口丢失;有界重试接受 lane 竞争、暂时连接、连接中止、broken pipe、unexpected EOF、资源暂不可用和超时类错误。损坏 journal、身份冲突及重启扫描中的损坏 barrier 直接投影 reconciliation。External Runner wake 用项目根、method、Agent、runId 和 loop iteration 派生稳定 requestId,目标未观察到、仍 waiting 或 lane 忙时返回不缓存的可重试错误。
- 身份与收束:子终态发布前同时核对 parent Agent/Session/run/action、delegationId 派生、target Agent/Session/run、child source 和 child 反向 parent/delegation 链接。错配 child 保持原 delivery 不变并记录冲突;父任务先进入 completed / failed / cancelled / budget-exhausted 时,终态写入路径 suppress 尚未认领的匹配 delivery,合法迟到 child 不能重新写 ready。父 run 在 waiting、ready-unclaimed 或 unobserved claim 任一非零时都不得 final;全部清零后仍由原 Supervisor Session/run 的 finalization journal 幂等写入唯一 assistant,不创建新 receipt run。
- 验证:Rust 定向回归使用 `project_supervisor_` 前缀,覆盖 delivery/claim 状态机、同 action 幂等、第 4 个新委派拒绝与已预留委派复用/拒绝 suppression、后续 delivery 锁忙时零部分认领、Agent DB 故障后回执仍可重放、未 Observed 阻断 final、Provider planning 前 durable 等待、parent-wake coalescing/结构性错误、重启损坏 barrier、错配和迟到 child、executing `run_status` 续接与 delegate policy 重验;本地 mock Provider 长套件应允许一次短间隔 connectivity 重试,并在断言前同时等待终态投影和 Agent lane 释放,避免端口瞬时波动或后台收尾窗口制造假失败。`agent_background_enqueue_notifies_only_after_session_lane_release` 覆盖入队锁序,Runner 内部测试覆盖定向 wake 与不缓存重试。真实 Provider 必须同时证明专业 Agent 时间区间重叠、父 run 仅一次 waiting、同一 Observed claim 认领全部回执、唯一 assistant、第二轮历史引用不新增委派和项目范围密钥扫描为 0。
- 关联:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md``apps/ai-game-creator-shell/src-tauri/src/delegation.rs``agent.rs``runner.rs``tests.rs`
## 父 run 协作策略不能在绑定后继续按全局 live policy 重验
- 现象:同一 Supervisor 父 run 已经持久化合法 collaboration batch,管理员随后修改或损坏 `.agent/collaboration-policy.json`,后续 spawn、claim、mutation、MCP 或 finalization 却突然改用新策略、进入 reconciliation;或者 snapshot 被删除后,Runtime 又按 live policy 把已有 run 当成未绑定 run。另一类症状是 contractless/v1 batch 被跳过、两个不安全 run ID 经字符替换落到同一 snapshot/锁 key,或旧 `Prepared / Committed` claim 因 snapshot/binding 不可读而不能重放 observation。
- 原因:把项目级 policy 当成每个动作的 live 执行事实,没有为父 run 设置明确线性化点、不可变策略快照和独立“曾绑定”记录;或者在 v2 batch 完整验真前就用 `contract.policy` 播种 snapshot。只对 run ID 做 lossy 规范化、让锁复用该路径片段,或用通用原子 replace 代替同一身份锁内 CAS,也会制造路径碰撞、并发覆盖和伪合同漂移。
- 处理:V1.38 固定顺序为 `v2 batch -> snapshot -> binding sidecar -> action side effects`。snapshot 位于 `.agent/runtime/collaboration-policy-snapshots/<agentKey>/<runKey>.json`,其完整字段必须统一为 `schemaVersion / projectId / parentAgentId / parentRunId / boundFrom / policy / policyFingerprint / snapshotFingerprint / boundAt`snapshot fingerprint 覆盖除 `snapshotFingerprint / boundAt` 外的全部稳定字段。独立 binding 位于 `.agent/runtime/collaboration-policy-snapshot-bindings/<agentKey>/<runKey>.json`,固定包含 `schemaVersion / projectId / parentAgentId / parentRunId / boundFrom / policyFingerprint / snapshotFingerprint / boundAt`,与 snapshot 逐字段交叉验证并持久证明“该 run 曾绑定”。同一 run 并发绑定时,无论 loser 是读取到不同快照还是在 winner 持锁期间耗尽有界等待,都必须返回稳定的“并发绑定冲突”错误分类。Unix 同进程首次并发初始化安全锁路径时,需要短暂串行化 `mkdirat/openat` 打开阶段,规避 macOS loser 在最终 `O_CREAT` 前观察到瞬时 `ENOENT`;返回后的 `flock` 仍承担跨线程、跨进程互斥。
- 路径与恢复:不安全或规范化后变化的 Agent/run ID 使用有界安全前缀加原始 ID 稳定 SHA-256,锁 key 对完整 `parentAgentId + NUL + parentRunId` 计算稳定 SHA-256,不能只做字符替换。恢复顺序为 existing valid snapshot > 完整验真的 v2 batch contract > 符合严格状态门禁的 legacy 当前有效 policysnapshot 缺 binding 可从 snapshot 补写,binding 存在但 snapshot 丢失只能按可信 v2 contract 和首次绑定身份恢复,无可信 v2 时禁止 live policy 重绑。contractless/v1 collaboration batch 必须先失败关闭。`legacy-current-project-policy` 只允许无 snapshot/binding、无可信 v2 contract,且不存在上述旧 batch,并由可信身份和状态明确证明属于 `pending / running / waiting-for-confirmation / waiting-for-user-input` 的旧父 runterminal、`needs-reconciliation` 或身份/状态未知 run 的状态读取不得新建 snapshot。
- 漂移与 Claim:绑定后 global policy 的 `matched / drifted / unreadable` 只报告状态,不能改变后续动作或完成门禁;新 policy 只用于后续新父 run。旧 durable claim、未观察 claim 和 legacy claimed delivery 先按原 action/group 身份恢复且不得取得新 delivery;新的 claim 必须先成功解析 effective snapshot 并核对 binding,再执行 V1.35-V1.37 的全锁、预算、完整 observation 和 group 数量门禁。
- 真实 E2E 现场:正在运行的正式客户端可能在验收期间启动或重启正式 Runner,导致 source endpoint 身份真实变化。不得关闭 `sourceRunnerEndpointUnchanged` 门禁,也不得杀掉不属于验收器的进程;应把同一配置内容复制到仓库外的大容量磁盘私有目录,目录/文件权限分别为 `0700/0600`,不复制 endpoint、锁、会话或数据库,验收后删除。功能完整但 source endpoint 被外部改变的报告与后续干净清理报告不得拼接。
- 验证:必须覆盖 snapshot 9 个完整字段、首次 `aborted` batch 无 snapshot/binding、matching binding 已存在时可用可信 `aborted` v2 contract 恢复缺失 snapshot、双故障窗口零副作用恢复、同内容并发 CAS、snapshot/binding 冲突或丢失、篡改 contract 不得播种、binding 已存在且无可信 v2 时禁止 live 重绑、contractless/v1 协作 batch 先失败关闭且非协作 v1 batch 不误伤、四种 legacy 非终态可迁移而 terminal/`needs-reconciliation`/身份状态未知读取不建 snapshot、危险 ID 路径/锁不碰撞、四类 global policy 状态,以及旧 claim 可恢复而新 claim 先过 effective snapshot。2026-07-19 上述确定性门禁、E2E self-test、52/52 collaboration 定向回归、终态 snapshot/binding 字节保留回归和 949 passed/4 ignored Rust 全量已完成;真实功能闭合轮受正式 endpoint 外部重启污染,私有配置源轮又连续耗尽 transient Provider retry,不能拼接为 PASS,故当前仍**不得声称 V1.38 真实 E2E 已 PASS**。
- 关联:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md``docs/project-memory/shared-memory/decision-log.md``apps/ai-game-creator-shell/src-tauri/src/collaboration.rs``agent.rs``tests.rs``scripts/agent-runtime-real-e2e.mjs`
## 单 Agent 持久计划不能靠工具下标或恢复猜进度
- 现象:工具 action 1 成功后第二个计划步骤被自动标成完成,模型仍有 pending / in_progress 步骤却写出最终回复;或 Runner 重启、刷新 UI、same-run steer 后 `planRevision` 回退、已完成步骤消失,legacy `plan` 又覆盖新计划。另一类错误是仅更新计划就触发项目 revision 漂移、verification 失效或权限确认。
- 原因:旧 `planSteps` 由短 `plan` 派生,并按 actions 数组下标驱动 `active / completed`,它无法表达跨窗口、恢复和 steer 后的真实任务进度。把 context bundle 当唯一计划事实源、把 v2 缺字段当空计划,或把 `planUpdate` 伪装成受策略工具,也会让 Runtime state、恢复快照和项目副作用门禁互相污染。
- 处理:V1.17 的 `planUpdate` 只接受 1 到 8 个唯一 `pending / in_progress / completed` 步骤,至多一个 `in_progress`native function 即使无变化也必须显式传 `planUpdate: null`,只有旧文本 JSON 可省略。当前 run 建立结构化计划后,legacy `plan` 和所有按 action 下标推进的 helper 都只能读不能写。`planRevision` 只在有效变化时单调增加;completed 与历史快照中已有的 failed 终态即使被下一版省略也必须合并保留,completed 回退或合并后超过 8 步时整次拒绝。外层 run 进入 `failed / budget-exhausted` 时必须原样保留最后可信进度,不把 pending / in_progress 机械标成 failed。任何非 completed 步骤都阻止成功 final,不能用 response 文本绕过。
- 恢复与 steercontext bundle v3 必须保存并复核 revision、说明、步骤和 active index;与 Runtime state 不一致时失败关闭,不能选“看起来更新”的一份。v2 只能在原身份、task、revision 和 verification gate 校验通过后从当前 state 补齐计划,v1 继续拒绝。same-run steer 只作废旧 Provider actions / 回复并要求重审未完成部分,不能清空终态步骤或重置 revision;Runner 重启同样不得自动勾选。finalization 另以 v2 journal 绑定最终完整计划快照:只有 assistant 已落盘时,state 丢失才可从该快照恢复终态;assistant 未落盘且 state 不可读时必须进入 reconciliation。
- 边界:计划更新是 `.agent/runtime` 私有元数据,不经过项目工具 policy,不推进 project revision 或 verification gate,不改变 pending action fingerprint。开发 UI/CLI 可展示最多 8 步完整计划;普通用户 Supervisor 只能显示完成数、当前步骤、等待、下一步和协作数量,不能把内部 explanation、完整步骤或 currentAction 搬到主聊天。
- 验证:运行 `structured_plan_``agent_runtime_context_bundle_migrates_v2_and_rejects_v3_plan_mismatch` Rust 定向用例,并用 `appSurface.test.ts` 覆盖刷新恢复和 Supervisor 紧凑摘要。真实 Provider 必须在无计划配方下多次更新计划,完成一步后接受 same-run steer,再经历 Runner 强杀恢复;最终证明 run/session 不变、revision 不回退、终态不丢、旧动作与副作用不重放、未完成时零 assistant、完成后唯一 assistant,计划更新前后 project revision / policy 不变。未运行该门禁时不得写 V1.17 PASS。
### Finalization 只绑定回复会在 Runtime state 丢失后丢计划
- 症状:assistant 已经按稳定 messageId 写入 conversation,进程却在 Runtime completed 投影前退出;重启后 state 文件缺失,系统从 task record 重建出默认或 legacy 计划,最终回复虽然没有重复,结构化计划 revision 和步骤却丢失。反向地,assistant 尚未写入时若也用 journal 单独猜计划,会把过期回复错误提交给用户。
- 原因:task record 不携带完整结构化计划,context bundle 也可能对应 finalization 前的其它 checkpoint;只给 journal 绑定回复和 verification gate,无法证明准备提交时的最终计划快照。
- 处理:`game-creator-runtime-finalization.v2` 在 prepared 时保存完整 `planRevision / planExplanation / plan / planSteps / activePlanStepIndex``planSnapshotFingerprint`,并将指纹纳入 finalizationId。读取时除校验格式和指纹外,还要再次要求全部结构化步骤 completed 且 active index 为空。assistant 已存在且 task 唯一时,允许从 v2 快照恢复原计划并补齐 Runtime completedassistant 不存在而 state 缺失或不可读时保留 journal、进入 `needs-reconciliation`,不能自动写回复。已有 state 与 journal 快照冲突时同样失败关闭或让 prepared 回复失效后在同一 run 重规划。
- 验证:`finalization_resume_recovers_persisted_assistant_without_runtime_state` 必须证明无 Provider 重放、assistant 唯一且恢复后的 revision/说明/步骤与 prepared 快照完全一致;`structured_plan_finalization_without_readable_runtime_state_needs_reconciliation` 必须证明 assistant 未落盘时 missing/corrupt state 都零回复、journal 保留且无 completed 审计;`finalization_resume_blocks_internally_consistent_incomplete_plan_snapshot` 必须证明重算合法指纹和 finalizationId 也不能提交未完成计划。
### CLI Runtime JSON 不能暴露项目绝对存储路径
- 症状:真实 E2E 的 task/event/Agent DB/report 均无项目绝对路径,子进程 transcript 扫描却稳定命中 6 次;入队和首次状态读取各返回 3 个路径。
- 原因:开发 CLI 直接序列化 `AgentRuntimeResult`,把仅供 Tauri/App 定位本地 sidecar 的 `sessionPath / eventPath / taskPath` 一并写进 `runtimeJson`。后续 confirm、steer 和 resume 复用同一结果结构,也会重复暴露。
- 处理:保持 Tauri 内部契约不变,只在 CLI JSON 输出视图递归删除三个存储路径;`state`、task queue、events、tasks、run/session/action 身份和 steer 状态继续保留,验收器仍能解析必要证据。不要靠 E2E 忽略 CLI stdout,也不要笼统删除所有 `path` 字段破坏安全相对产物证据。
- 验证:CLI serializer 单测覆盖顶层、嵌套和数组结果;真实 `--agent-runtime-status` 输出对 disposable 项目路径命中为 0,后续完整 `llm-runtime` 报告的 `projectPathTranscriptLeakCount / projectPathReportLeakCount` 必须同时为 0。
- 补充现象:`agent.message` 已把正文安全写入目标 Agent 的私有 tool 会话,但 `.agent/agent.db``agent.runtime.agent.message.path` 直接复用了 conversation 返回的绝对路径,导致真实 Supervisor swarm 的公共路径门禁失败。
- 补充处理:会话文件仍由项目内部 API 创建,写公共审计前必须再用项目根做 `strip_prefix`、路径分隔统一和相对路径规范化,只保存 `.agent/conversations/agents/<agentId>.jsonl`;不能通过 E2E 忽略该 record,也不能笼统删除所有相对 `path` 证据。
- 补充验证:`background_agent_runtime_can_write_blackboard_and_message_other_agent` 同时证明 tool 消息可读、`agent.runtime.agent.message` 唯一存在、path 等于规范项目相对路径且 `Path::is_absolute=false`;正式 `supervisor-swarm` 的 Agent DB 项目路径泄漏计数必须为 0。
### 把模型修复上下文写入公共审计会泄露正文
- 症状:Runtime event 或 `.agent/agent.db` 为了排障直接记录 thinking summary、legacy plan 标题、解析错误、malformed JSON 或 native function arguments;私有任务内容、项目路径或模型调用体因此进入公共审计和 UI 最近事件。
- 原因:格式修复确实需要把上一条输出与错误反馈给同一次 Provider 请求,但“Provider 私有修复上下文”和“持久公共诊断投影”被误当成同一份数据。
- 处理:`thinking_summary` event 只留正文 SHA-256 与字符数,legacy `plan` event 只留步骤数;结构化计划审计只留 explanation 哈希与字符数,以及 step 标题哈希、状态和计数。`agent.runtime.tool_plan.repair` 只留 attempt/maxAttempts、protocol,以及错误、输出或调用体预览、callId/functionName 的哈希与长度。经过过滤和限长的上一条输出与协议错误只可进入当前 planning 的私有 repair 请求,不得落到 event、task 或 Agent DB 正文字段。
- 验证:后台 loop 回归必须断言 thinking event 不含摘要正文、legacy plan event 不含标题;文本与 native repair 回归必须同时证明私有请求仍含足够修复上下文,而 Agent DB 不存在 `protocolError / responsePreview / function arguments / callId / functionName` 原文字段。
### 每次同步 context bundle 时刷新 planning fingerprint,导致同批旧动作越过仓库规范漂移
- 症状:同一 Provider planning 返回多个 actions;前一个验证动作修改了 `AGENTS.md` 或其它启动上下文来源,后一个写动作仍执行成功,下一轮只看到普通 `ok` observation,没有 `repositoryContextDrift=true`
- 原因:context bundle v3 在 action 激活和 observation 落盘后都会同步完整计划,同时重新扫描 repository startup context。若 drift gate 从最新 bundle 读取 fingerprint,前一个动作造成的漂移会被同步成新基线,后续动作不再与 Provider planning 真正看到的旧规范比较。
- 处理:Provider request builder 必须把实际渲染的 repository fingerprint 和工具计划一起返回;现行 `game-creator-pending-action.v5``plannedRepositoryContextFingerprint` 外,还绑定 steer cursor 与 `goalId / goalRevision / goalSnapshotFingerprint`。同批自动动作、待确认动作和恢复动作只复核 planning 时持久化的快照;旧 v1-v4 缺少现行完整身份,失败关闭,不能从最新 bundle 或当前 Goal 猜回。
- 验证:`runtime_v11_closure_repository_context_drift_replans_before_auto_mutations` 必须覆盖 file.write / file.patch / file.delete / project.patchset / project.restore 五种动作,证明前置验证导致规范漂移后旧动作零执行、同 run 收到稳定 drift observation`legacy_context_and_pending_records_fail_closed` 覆盖 v4 拒绝。
- 关联:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md``apps/ai-game-creator-shell/src-tauri/src/agent.rs``main.rs``tests.rs``apps/ai-game-creator-shell/src/App.tsx``tests/appSurface.test.ts`
## 持久 Goal 不能只靠 Runtime state 推断,也不能让旧动作跨 revision 执行
- 现象:Goal 编辑后,旧的自动动作或待确认动作仍按旧目标执行;暂停后重启 Runner 又先恢复 finalization/pending action 并继续调用 Provider;或 assistant 已经可见,但 Goal 先标成 completed、Runtime task/state 仍停在非终态。另一类恢复问题是 pause 已中断 Provider,却没有保存准确 continuation,或 resume 先把 sidecar 写成 `active`、后续 Runtime 恢复失败,重试却假成功并永久停在 paused。
- 原因:把每 Agent 的 latest Runtime state 当成 Goal 正文事实源,没有独立 Agent/Session Goal sidecarpending action 未绑定 Goal ID、revision 和快照;恢复扫描把 pause control 放在 finalization/pending action 之后;或 finalization 把 Goal completed 当成 Runtime completed 之前的提交点。只在内存里中断 Provider,也无法保证进程退出后仍有可恢复上下文;只看 sidecar 的 `active` 也不能证明 Runtime 投影和 Runner 唤醒已经提交。旧/半写 Runtime 缺少 `goalId` 时吞掉 sidecar 损坏错误,还会把有 Goal 的 run 错当成无 Goal run。
- 处理:Goal 正文只认 `.agent/runtime/goals/current/<agentHash>/<sessionHash>.json`,终态历史写入 `.agent/runtime/goals/history/<agentHash>/<goalHash>.json`Runtime state/task 仅作投影。context bundle v4 固定绑定 `goalId / goalRevision / goalStatus / goalSnapshotFingerprint`Provider 中断边界先保存按 `active` 恢复语义构造的 continuation,再把同一 run 收束为 paused。
- 动作与恢复:pending action v5 同时绑定 Goal ID、revision 和 snapshot fingerprint,旧 v1-v4 失败关闭。Goal edit 后,旧自动/确认动作写成稳定 `blocked` observation 并在同一 run 重规划;不能执行旧副作用,也不能转成 retry run。重启先处理 cancel / Goal control`pause-requested` 收束为 `paused` 后直接休眠;resume 只做 `paused -> active`,先删除同一 run 的旧 cancel tombstone,再唤醒原 run。若首次 resume 在 sidecar 提交后失败,重试必须继续补 Runtime/Runnersidecar 损坏或身份冲突时,无论 Runtime 是否已有 `goalId` 都进入 reconciliation。
- 完成顺序:`game-creator-runtime-finalization.v3` 绑定 Goal 快照。assistant 落盘后,先写 Runtime completed task/state,再写 Goal completed 并补齐携带 Goal 终态的 Runtime projection;全部成功后才删除 finalization journal。prepared 且 assistant 未落盘时发现 Goal revision 已更新,必须丢弃旧回复并 same-run 重规划。
- 验证:确定性测试至少覆盖旧自动/确认动作转 blocked、paused 重启零 Provider、同 run resume、旧 cancel tombstone 清理、Provider 中断边界 context、assistant 后 Runtime/Goal completed 顺序和 v1-v4 pending 拒绝。真实 Provider 还必须经历 Goal edit、pause、Runner 强杀和显式 resume,并证明 run/session 不变、旧动作零重放、唯一 assistant;未完成该链路时不得写 V1.18 PASS。
- 关联:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md``apps/ai-game-creator-shell/src-tauri/src/goal.rs``agent.rs``runner.rs``tests.rs``apps/ai-game-creator-shell/src/App.tsx``tests/appSurface.test.ts`
## iOS 退款问询的 result_code 不是 debug 状态
- 现象:为了先观察真实 iOS 退款通知,回调返回 `ErrCode=0 + IosRefundQueryResponse.result_code=1`,并把 evidence 写成“调试阶段不执行自动退款决策”,看起来像安全 ACK,实际已经向微信建议拒绝退款。
- 原因:`xpay_subscribe_ios_refund_query_notify` 只有 `result_code=0`(建议退款)和 `1`(建议拒绝)两种正式决策;`evidence` 必须是可审计的履约或消耗事实,不存在中立调试值。与此同时,解密后的完整 payload 含 OpenID、Apple 交易号、退款原因和票据,不能为了排障直接落日志。
- 处理:未接入真实履约决策时返回非零 `ErrCode` 让微信重试,不携带 `IosRefundQueryResponse`;所有事件只写脱敏结构化摘要,payload、未知事件/字段、标识符和字符串值使用消息 Token 加用途域派生的稳定 HMAC 引用,自由文本只写长度和 HMAC 引用。Android 订阅成功和普通 goods 通知可能同形,payload marker 只能快速分流;只有存在同号 `wechat_mp_virtual` 本地充值订单,并在 2.5 秒内通过 `/xpay/query_order` 校验订单号、金额、`order_type=0/7`、支付状态和权威 `paid_time`,才允许入账。Apple 通知缺少 `WeChatPayInfo.PaidTime` 时走查单,绝不能用本机时间补齐。
- 验证:`cargo test -p platform-wechat virtual_payment_debug_summary --manifest-path server-rs/Cargo.toml``cargo test -p api-server virtual_payment_debug_routing --manifest-path server-rs/Cargo.toml``cargo test -p api-server virtual_payment_ios_refund_query_has_no_fake_decision_response --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/platform-wechat/src/pay.rs``server-rs/crates/api-server/src/wechat/pay.rs``docs/【技术方案】微信虚拟支付接入-2026-05-26.md`
## 微信支付 V3 的支付 notify_url 不会自动接收退款结果或发现全部手工退款
- 现象:普通微信支付成功回调已经配置并可达,但在商户平台或代码里发起退款后,`/api/profile/recharge/wechat/notify` 收不到退款单状态变化;商户平台手工退款也可能没有请求本系统的退款回调入口。
- 原因:V3 支付成功通知与退款结果通知是不同契约;代码发起退款时,退款通知地址来自每次 `POST /v3/refund/domestic/refunds` 请求里的 `notify_url`,支付下单使用的 `WECHAT_PAY_NOTIFY_URL` 不会自动复用。商户平台手工退款不能假设会携带本系统按 API 请求传入的回调地址;退款接口返回成功也只表示受理,不能当成退款终态。
- 处理:代码退款显式传入公网 `https://<API 域名>/api/profile/recharge/wechat/refund-notify`,并用稳定 `out_refund_no` 串联申请、重复通知和主动查单。回调先用原始 body 验签、检查正负 5 分钟时间窗,再用 APIv3 密钥解密;校验事件、资源类型、商户号和退款状态后,将 callback observation 写入统一 SpacetimeDB 事务,持久化成功才返回 `204`。正式链路不再是“debug 只记日志”:部分 / 全额退款、泥点回收、欠款冻结和会员人工复核均由事务收口;未知事件、校验或持久化失败返回微信 `FAIL` 响应。另开启 `WECHAT_PAY_REFUND_RECONCILIATION_ENABLED=true`,对 `order_missing / order_not_paid` 继续等待晚到支付通知,候选退款按分钟轮转分页且错误日志不回显 provider URL;次日 10 点后按分片补扫微信 API 可查询的近 90 天 `bill_type=REFUND` 交易账单,并在落账前再主动查单。单行失败不能阻塞其他行或日期,也不能提前写完成 checkpoint;昨日 `NO_STATEMENT_EXIST` 至少延迟到次日 10 点后再确认;不要为联调开放未鉴权公网退款或补录接口。
- 验证:`cargo test -p platform-wechat v3_refund_notify --manifest-path server-rs/Cargo.toml``cargo test -p platform-wechat v3_transaction_notify --manifest-path server-rs/Cargo.toml``cargo test -p api-server v3_refund_notify_failure --manifest-path server-rs/Cargo.toml`;真实联调后只读核对 `profile_recharge_refund``profile_recharge_refund_observation``profile_recharge_order_refund_settlement``profile_recharge_refund_bill_checkpoint`
- 关联:`server-rs/crates/platform-wechat/src/pay.rs``server-rs/crates/api-server/src/wechat/pay.rs``server-rs/crates/api-server/src/app.rs``docs/【技术方案】微信虚拟支付接入-2026-05-26.md`
## 已 ACK 的历史退款通知不会因正式落账上线而自动重放
- 现象:微信侧退款已经是 `SUCCESS`,旧 debug 回调也曾返回 `204`,但部署正式退款表和权益回收事务后,本地充值订单仍为 `paid`,退款表没有记录。
- 原因:微信收到成功应答后会把该次通知视为已送达;服务升级不会让已经 ACK 的历史通知自动重放。主动 reconciliation 只能继续查询本地已经知道 `out_refund_no` 的非终态退款,不能凭空枚举所有历史退款。
- 处理:已知 `out_refund_no` 时,由持有真实商户凭据的受控服务端先调用单笔退款查询,验微信响应签名后写入统一 observation 事务;未知的商户平台退款等待 T+1 `REFUND` 交易账单发现,再查单落账。自动账单按分片补扫微信 API 可查询的近 90 天,超过窗口的数据需从商户平台导出候选后逐笔受控查单。禁止用 SQL 直接把订单改为 `refunded`,也禁止直接插入退款表或按账单 CSV 状态扣泥点,这些做法会绕过不可变字段冲突校验、累计部分退款和权益结算。
- 验证:核对退款 observation 的 `source``resolution_code` 与金额,再核对订单级 settlement 的累计退款、`recovery_status``unrecovered_points``wallet_frozen`;全额退款应保留原订单 `paid_at`,防止错误恢复首充资格。
- 关联:`server-rs/crates/api-server/src/profile_recharge_refund_reconciliation.rs``server-rs/crates/spacetime-module/src/runtime/profile.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 退款请求结果未知时不能立即释放钱包占用
- 现象:后台调用微信退款超时或连接中断,页面提示状态未知;如果服务端立即释放泥点占用,用户可以继续消费,而微信稍后仍可能完成退款,最终形成可避免的退款欠账。反过来,永久保留占用又会让一次明确未创建的退款长期冻结余额。
- 原因:HTTP 错误只能说明客户端没有拿到确定响应,不能证明微信没有受理;单次退款查单 `RESOURCE_NOT_EXISTS` 也可能处于短暂传播窗口。只有使用原 `out_refund_no` 主动查单才能继续判定。
- 处理:网络结果未知时保留活动 hold,页面把原 `requestId`、订单、金额、原因和已知 `out_refund_no` 保存到当前后台标签页、当前管理员会话隔离的 `sessionStorage`,有效期 2 小时;刷新后必须与后端 active hold 的金额、原因和退款号对账一致才允许直接复用原请求,服务端明确拒绝时清理上下文。没有原请求上下文、上下文过期或管理员会话已切换时只开放预填 `out_refund_no` 的安全查单登记,不生成新 ID 硬撞活动 hold。worker 在占用创建至少 10 分钟后查同一退款号,查到退款就将验签事实写入统一 observation,只有连续 3 次查单收到官方 `RESOURCE_NOT_EXISTS` 才释放。进程重启清空连续次数并重新观察;超时、签名、配置、解析等错误一律重置次数并继续占用。
- 补充:退款查单适配器必须保留微信 `RESOURCE_NOT_EXISTS` 业务码,并兼容同类 `ORDER_NOT_EXIST`,不能把所有非 2xx 都抹平成通用上游错误;签名有效的退款申请/查询响应仍须与本次 `out_refund_no`、订单号、交易号和金额做关联校验。已释放 hold 复用旧 `requestId` 时必须在调用微信前拒绝,并要求重新预检生成新的请求 ID。
- 关联:`server-rs/crates/api-server/src/admin_recharge.rs``server-rs/crates/api-server/src/profile_recharge_refund_reconciliation.rs``profile_recharge_refund_hold`
## 时段 UV 不能伪装成单日趋势
- 现象:访问人数卡显示整段时间有数百人,但趋势图只有终止日一根满柱,其余日期是同样高度的小短柱;横向滚动后还容易误以为后半月突然出现访问。
- 原因:把时段跨日去重 UV 通过单值 series 塞进 `anchor_date_key`,再对 0 值强制设置最小可见高度。时段 UV、每日 UV 和每日 UV 之和是三个不同指标,不能互相替代。
- 处理:趋势 bucket 按 `day_key + user_id` 每日去重,图头单独使用整个筛选范围跨日去重人数;0 值高度必须为 0。多张同轴图使用同一日期范围并同步横向滚动,快捷范围不生成未来日 bucket。
- 验证:构造同一用户跨两日访问与某日零访问的 fixture,断言每日 bucket、时段去重总数和零值柱分别正确;浏览器核对四图首尾日期窗口一致。
## 运营聚合不能用固定 LIMIT 的原始事实冒充精确结果
- 现象:Dashboard 出现“达到单次读取上限 50000 行”告警,但模块分布和累计值仍以无“不完整”标识的精确数字展示;数据增长后结果会随任意截断样本漂移。
- 原因:api-server 拉取 `SELECT ... LIMIT 50000` 原始事实再聚合,没有完整分页、稳定排序或数据库侧聚合。提高上限只会推迟错误,并增加响应体与内存压力。
- 处理:精确运营指标通过受 runtime service identity 限制的 SpacetimeDB procedure 在事务内聚合,只返回紧凑统计投影,再由 `spacetime-client` facade 交给 BFF。权威聚合失败时请求必须失败,不能用默认 0 代替未知值;现有索引无法覆盖跨 scope、跨日期统计时先监控事务扫描耗时,数据增长后补日期前缀索引或持久化日聚合事实,不能退回固定 `LIMIT`。若某查询只能采样,契约和 UI 必须明确标为采样,不能展示成精确值。
- 验证:聚合结果不随 HTTP SQL 行上限变化;超过 50,000 条事实时仍无截断告警,并用数据库事实抽样对账每日、时段、累计与分布结果。
## 后台详情列表的 grid 规则不要命中嵌套身份组件
- 现象:素材查询或精选素材详情弹窗中的作者陶泥号被逐字符竖排,用户详情按钮也被挤到编号旁边;窄屏下图片与详情列继续互相挤压。
- 原因:`.admin-info-list div` 会命中列表内所有后代 `div`,把字段值内部的 `.admin-inline-identity` 和昵称容器也覆盖成双列 grid;陶泥号又允许任意位置换行,最终只剩单字符宽度。素材详情布局若始终固定为 `220px + 信息列`,移动端也没有足够空间。
- 处理:信息列表的行布局只使用直接子选择器 `.admin-info-list > div`;作者昵称与陶泥号在身份组件内分行,陶泥号保持单行并在真正不足时省略。`560px` 以下的素材详情改为单列,缩略图居中;素材查询与精选审核共用该规则。
- 验证:在桌面、560px、390px 和 320px 浏览器宽度打开素材详情,确认 `.admin-inline-identity` 的 computed `display``flex`、陶泥号横向显示、详情字段不溢出页面。
## Provider 瞬态重试不能只依赖进程内 sleep,也不能让刷新时间进入请求指纹
- 现象:Provider 首次规划请求发生 timeout/connectivity/transport 后,Runner 在 backoff 期间退出会丢失 retry,或重启后清零 attempt、提前补发;另一种偶发现象是 Goal pause/resume 看似保留 sidecar,但只要等待跨过一秒,恢复请求就被判为 context drift,旧 `-transient-N` attempt 被删除并改成新 loop 请求。
- 原因:退避 attempt 和到期时间只存在于进程内;或虽然已有 sidecar,请求 prompt 却直接序列化 Runtime 工具策略快照,把每次刷新都会变化的 `updatedAt` 带进 request fingerprint。同一权限内容因此仅因时间变化产生不同请求身份。
- 处理:先闭合物理请求 lifecycle,再原子持久 retry sidecar/.previous,最后投影 `waiting-for-provider-retry` 并释放 lane;Runner 启动扫描并按绝对到期时间恢复。请求指纹只绑定实际 Provider 请求的稳定语义,UI/审计时间戳、剩余等待毫秒和等待态文案不得进入 prompt。Goal pause 保留 sidecarresume 必须校验同一 Goal/steer/request/config 身份后恢复原 attempt。
- 验证:测试必须故意让 pause/resume 跨秒,捕获失败请求与恢复请求的 HTTP body 并比较 SHA-256,同时断言 lifecycle 使用原 `-transient-N` slot而不是新 loop;另覆盖 `.previous` 扫描、Runner idle blocker、同 Agent FIFO、跨 Agent 并行、cancel/steer/耗尽清理和重启未到期零请求。真实网络门禁不能只在 `retryAt` 前留一个静默窗口后等待请求,代理还要用不含 URL/header/body 的毫秒 metadata 证明第二个请求 `acceptedAtMs >= retryAtMs`
- 真实验收陷阱:sidecar 按设计早于 task/state 等待投影落盘,验收器看到 sidecar 后必须继续等完整 `running / waiting-for-provider-retry`,不能把合法提交窗口误判为 torn projection。共享 Runner 强杀会同时中断其它 Agent 的 in-flight Provider 请求;要隔离验证单个持久 retry,应在子请求产生前对父 Agent 首次规划注入故障,恢复后再完成同一 run 的并行协作。首批同批双委派若只依赖自然语言提示会受模型波动影响,真实 suite 应使用正式 collaboration policy/preflight 固定两个指定 static Agent,并保留无正文的 batch 数量诊断。长链路还可能发生额外真实瞬态失败,不能用“全局 failed/retry 必须等于 1”把已正确恢复的网络抖动误判为注入失败;应按 request identity 锁定唯一受控链,额外 failure/retry 独立计数并继续执行全部 lifecycle、后继终态和零残留门禁。
- final-reply 恢复陷阱:不能把当前 Runtime `status / phase / currentAction` 投影或整份临时 tool-plan 放进要求跨进程稳定的请求指纹。前者在 `waiting -> planning -> response` 恢复过程中必然变化,后者的 `planUpdate/actions` 不会由 context bundle 原样保存;两者都会让合法 `-transient-N` 被误判为 drift。final-reply 必须在请求前同步 response 状态与 context bundle,并只使用可由 bundle 精确恢复的有界收束摘要;恢复 pass 先识别 `final-reply``final-reply-context-compaction` sidecar、恢复原 loop 并跳过新 planning。Agent DB lifecycle 的 requestKind 白名单也必须同步扩展,否则压缩请求会在网络调用前失败并被 planning fallback 掩盖。测试必须制造两种 sidecar-first 窗口、调用恢复扫描、按网络接收时间证明 `acceptedAtMs >= retryAtMs`,并比较失败/恢复 HTTP body 的 SHA-256 和字节一致性;失败输出不得打印正文片段。Provider 成功返回到压缩 sidecar 或 finalization journal `prepared` 之间仍不是 durable 提交点,进程退出可能重发 Providerfinalization journal 清理后才标记 stream committed 的窗口也可能留下 assistant 已落盘但 stream 仍为 ready。在新增成功响应 journal 与可恢复 stream commit 前不得宣称成功请求 exactly-once 或 stream 终态事务。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/provider_retry.rs``agent.rs``runner.rs``tests.rs``docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`
## Provider 成功不等于已交接,stream ready 也不等于 finalization 已完成
- 现象:Provider 已返回完整 final-replyRunner 在 finalization `prepared` 前退出后却再次请求;或 assistant/completed 已唯一落盘,response stream 长期停在 `ready / streaming`。更危险的修复是看到 handoff 与 retry 同时存在便择一删除,或因为另一个 Agent 已推进全局 project revision,就把当前固定 run 的 stream 当成不可见缓存并静默跳过提交。
- 原因:Provider 网络 future、成功 handoff、compaction/finalization journal 和 response stream 是连续但不同的 durable owner。只有内存中的成功响应、`started` lifecycle、流式半句或 `ready` 展示缓存都不能证明下一 owner 已接管;面向 UI 的 stream 可见性还会读取当前全局 revision,不适合作为 finalization 的提交判据。
- 正确顺序:成功响应先规范化并写入 `game-creator-provider-handoff.v1`,原子落盘并回读一致后,才用 handoff 保存的真实 requestId 补 `completed` lifecycle。恢复先修复该真实 requestId,再零网络回放。压缩结果先持久化并回读 compaction sidecar 后再清 handofffinal-reply 至少先进入 finalization `prepared`journal 持续负责唯一 assistant、Runtime/Goal completed 和 stream committed,直到 committed 写入后的身份、状态、正文回读全部成功才清理。
- 冲突处理:handoff/retry 只有完整 identity、attempt 和 slot 一致才可把 retry 当作已被成功结果覆盖;冲突时必须零网络进入 reconciliation,并保留两份 sidecar、`.previous` 和真实 requestId 证据。stream 身份或正文与 journal 冲突时也保留 journal;禁止覆盖冲突 stream、删除 journal、补造 requestId 或靠重复 Provider 调用“刷新”现场。Runner 对存在、备份或损坏 handoff 的 root 都必须报告 busy,不能为 idle shutdown 自动删证据。
- stream 恢复:finalization 使用 journal v4 固定的 Agent/task/Session/run/request slot/steer cursor/response revision 和正文直接读取提交面。缺失或 `streaming` 可由 journal 重建为规范 `ready` 再提交;已 committed 且正文一致可幂等清理。即使全局 project revision 已被其它 Agent 推进,也不能跳过这个既定 run 的 stream;写入、回读、固定身份或正文任一不一致,都保持 journal 和可恢复 finalization。
- 排障与验证:先核对 handoff 的 `providerRequestId / requestSlot / attempt` 与 Agent DB lifecycle,再看 retry/handoff/finalization/response-stream sidecar,最后才看 Runtime/UI 投影。用关闭 mock Provider 后恢复证明 handoff 回放零网络;分别覆盖 compaction 与 final-reply 消费窗口、handoff/retry 冲突、stream 缺失/streaming、commit 写失败、committed 后清理前退出、全局 revision 漂移和 Runner busy。日志与断言只公开指纹、字符数、状态和差异字段,不能打印 handoff 正文、请求体、凭据、URL 或绝对路径。
- 保留边界:Runner 若在 Provider 成功后、handoff 原子提交并回读前被硬杀,本地仍只有结果未知的 `started`,不能安全补发或宣称 exactly-once。handoff 只覆盖无 tool call 的 `context-compaction / final-reply-context-compaction / final-reply``tool-plan` 及其 function arguments 不在内,真实外部 Provider 的 final-reply Runner 强杀门禁也需单独完成。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/provider_handoff.rs``provider_retry.rs``agent.rs``project.rs``runner.rs``tests.rs``docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`
## final-reply 强杀门禁不能按请求正文选目标,也不能在协作证据未闭合时动手
- 现象:复用 fail-first 代理后,suite 仍只命中第一个 tool-plan;或者为了定位 final-reply,让代理 selector 读取 URL/header/body;又或者把父 run 的 `project.verify` 误当成注入许可,没有在目标请求 reset/forward 前运行可信宿主检查。最终即使出现 failed/retry 和唯一 assistant,也不能证明 Project Supervisor 收尾边界真实可恢复。
- 原因:网络 sequence 本身不表达请求种类,Provider payload 又包含正文、工具上下文和凭据,不能成为故障选择 API。final-reply 前的 `2` 初始 delivery、`1` repair delivery、`3` receipts 和零 assistant 用于证明“父 run 已完成协作、正要唯一收尾”;真正允许故障注入的项目正确性 oracle 是 selector 在 disposable project cwd 同步运行的可信宿主 `node verify-e2e.mjs` 成功 marker。父 `project.verify` audit/receipt/observation 可能不存在,只能作为诊断计数。
- 处理:fault proxy 只向异步 selector 暴露冻结的 `sequence / acceptedAtMs`harness 自己从 Agent DB、delivery/claim sidecar 和 conversation 中选择同一父 Session/run 的唯一 base final-reply,并先验证 `2+1` delivery 已 claim、两次 observed claim 覆盖 `3` receipts、assistant 为 `0`。selector 随后必须在 proxy reset/forward 目标请求前,以可信宿主 Node 同步运行固定的 `node verify-e2e.mjs`;只有 stdout 含 `real-e2e-command=passed` 且 stdout/stderr 无失败 marker 才返回允许注入。失败、超时、非零退出或 marker 无效时不得注入,捕获的 stdout/stderr 只能用于内存判定与敏感扫描,不得写入 state、checkpoint、report 或公共日志;父 `project.verify` 只记录诊断。候选重复或前提不完整必须失败,禁止回退到首请求或解析正文。
- 恢复门禁:等 base final-reply 形成 failed lifecycle、retry audit、持久 sidecar 和完整 `running / waiting-for-provider-retry` 后,才在 `30s` backoff 内 pidfd `SIGKILL` Runner。重启保持 Session/run/request fingerprint/attempt/slot/retryAt`retryAt` 前零请求,到期后只允许唯一 `-transient-1`;父 tool-plan 数不得增加。task/Runtime 进入终态后还必须显式等待 pending、retry、handoff、finalization、confirmation sidecar 全部清零,并设置 `10s` 硬超时;看到唯一回复后立即采样到残留 journal 只能判该轮 **FAIL**,不能与后续清理或其它轮次拼接。
- 写锁竞争:并行 Agent 的 `file.write` 可能与项目写锁竞争。若失败 observation 原样携带绝对锁路径,后续 pending 持久化会因安全门禁拒绝并进入 `needs-reconciliation`,把原本可恢复的锁竞争扩大为持久状态故障。`file.write / file.patch / file.delete` 应统一使用 Runtime 短等待项目写锁,`file.write` 错误在进入 observation/pending 前脱敏,并以 `2` 条 Rust 回归测试固定短等待和脱敏边界。
- 验证:新命令为 `npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-final-reply-transient-retry-real-e2e -- --config-dir <发布AppData绝对路径>`;旧 `supervisor-swarm-transient-retry` 只保留首次 tool-plan 证明。fault proxy `14/14`、E2E self-test **PASS**、前端 `308/308` 及 shell typecheck、`platform-llm 41/41``platform-agent 17/17``shared-contracts 7/7` 等确定性门禁已完成。真实外部 Provider suite 共执行六轮,前五轮均为 **FAIL** 且不得拼接:第一、二轮沿用既有失败记录,第三轮为终态后过早观察到 `1` 个 finalization journal,第四轮为 quality-review 普通 tool-plan 连续 transport/connectivity 失败耗尽重试且未进入目标故障,第五轮为上述写锁竞争与绝对锁路径问题;修复后第六轮在同一轮内完整 **PASS**
- 第六轮证据:`gpt-5.5 / openai_chat``2` 条初始加 `1` 条 repair delivery、`3` 条专业 Agent assistantProject Supervisor base final-reply 目标与可信宿主 verify marker 门禁均成立;受控 Provider `failed=1 / retry=1`、incidental `failure=0 / retry=0``30s` backoffpidfd `claim=2 / signal=2`Runner `resumed=true / identityStable=true`。父 tool-plan 故障前后均为 `13`parent final-reply 与最终 assistant 唯一,response stream `sequence=2 / committed`pending、retry、handoff、finalization、confirmation sidecar、全部重复计数及 API Key、私有正文、项目路径、正式配置路径和公共报告泄漏扫描命中均为 `0`。V1.41 handoff 原子提交前的 unknown-result 和 tool-plan handoff 未覆盖边界继续保留。
- 关联:`apps/ai-game-creator-shell/scripts/llm-transient-fault-proxy.mjs``apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs``apps/ai-game-creator-shell/tests/llmTransientFaultProxy.test.ts``docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`
## SpacetimeDB 历史归档不能按文件名小于 snapshot 就全部删除
- 现象:看到最新 `N.snapshot_dir` 后,把所有起始 offset 小于 `N``.stdb.log` 删除,或者只把旧日志上传 OSS 就宣称已有完整增量灾备。
- 原因:segment 文件名只表示该段最早事务;起始 offset 小于等于最新 snapshot 的最后一个 segment 可能跨越 snapshot 边界,重启仍需要它。历史归档也不会及时覆盖 control-db、program bytes、最新 snapshot 和 active segment。
- 处理:latest snapshot 必须是未锁定且存在同 offset `.snapshot_bsatn` 的完整目录,空目录或同名 `.lock` 存在时忽略。每个 replica 独立保留 `max(segment_start <= latest_snapshot)` 及全部后缀,只处理更早 segment 对;旧 snapshot 只保留最新一个。`--storage-format files` 必须先发布完整 full cataloghistory 对每个候选文件 CAS 对象、history catalog 和 full catalog 执行 HEAD 长度/SHA 验真,再复算边界与 stat fingerprint,最后发布并验真固定 `latest.json`pointer 失败时不得推进 state 或删除源文件。不要把在线逐文件 full 扫描当成跨文件一致备份,基线必须来自停库目录或已验证冻结副本。SSH 或工具超时后先检查 work-dir PID lock 与原进程,不要直接并发重跑;不要在 history 模式传 `--stop-service`。定时任务通过 Server-Provision 的显式 profile 和仓库 drop-in 管理,启用前 dry-run 验证 baseline,切回 archive 时同时移除托管与现场遗留 drop-in;不要在 `/etc/systemd/system` 长期保留手写覆盖,release 必须建立和验证自己的 full baseline 与 work-dir,不能直接复用 dev 的本地 state。files 本地 state 只能保存去重后的 catalog 引用并使用 gzip 原子落盘;本地只保留 latest full catalog 压缩缓存,history/旧 full catalog 和紧凑 result 不得再次复制完整清单。metadata 压缩或清理失败必须早于 `/stdb` history 源文件删除。
- 验证:dry-run 输出 replica 的 `latestSnapshot``boundarySegment` 和候选清单;从另一台机器仅凭 OSS `latest.json` 自动定位 full catalog,创建目录、下载文件并逐项校验长度/SHA,启动隔离 data-dir 验证 `/v1/ping`、snapshot restore、commitlog replay、module launch、代表性 SQL 与 reducer。备份门禁还必须覆盖 v1 JSON 到 v2 gzip 迁移、损坏 gzip 不回退、full 增量复用、本地 catalog SHA 校验、history catalog 清理与紧凑 result。
- 关联:`scripts/database-backup-to-oss.mjs``scripts/check-database-backup-to-oss.mjs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## Procedure 事务鉴权必须显式捕获调用者
- 现象:api-server 的 SpacetimeDB token identity 与 `editor_generation_pricing_config.writer_identity` 完全一致,服务启动门禁也通过,但手动拆分图集调用 `find_editor_asset_group_source_and_return` 仍返回“当前 identity 无权调用模型生成运行时服务”。
- 原因:当前 workspace 锁定 SpacetimeDB `2.7.0`,procedure 的事务闭包身份仍不应成为业务鉴权的隐式来源。把 runtime writer 鉴权写成 `require_*(tx, tx.sender())` 会让鉴权边界依赖 SDK 细节,升级后也容易回归。
- 处理:在调用 `try_with_tx` 前通过 `let caller = ctx.sender()` 捕获真实调用者,再把 `caller` 显式传入事务函数;迁移、后台账号、runtime profile、外部生成等现有 procedure 已采用这一模式。`npm run check:spacetime-runtime-access` 禁止编辑器 runtime writer 鉴权重新直接读取事务 `ctx.sender()`
- 验证:运行 `npm run check:spacetime-runtime-access``cargo test -p spacetime-module --manifest-path server-rs/Cargo.toml``npm run check:spacetime-schema`;使用当前 2.7.0 模块以 runtime writer identity 重试 `POST /api/editor/icon-spritesheets/slices`,确认来源查询、分片素材写入与 cohort 完成不再返回 identity 403。
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs``scripts/check-spacetime-runtime-access.mjs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## Portal 弹窗必须在遮罩根节点携带平台主题
- 现象:项目库点击“重命名”后,标题、输入框和按钮仍显示,但弹窗面板及遮罩背景变透明,看起来像“改名界面的背景没了”。
- 原因:`UnifiedModal` 默认 portal 到 `document.body`;若业务入口只在页面内层继承 `platform-theme`,portal 根节点不会继承该容器的 CSS 变量。此时 `.platform-modal-shell``background: var(--platform-modal-fill)``.platform-overlay` 的背景声明都会失效。
- 处理:`UnifiedModal``portal=true` 时默认把 `AuthUiContext.platformTheme` 注入 overlay,共享白底弹窗和直接调用都不应再手工拼接主题 class。完全自绘的黑底预览显式使用 `portalTheme="none"`;已明确固定主题的弹窗使用 `light` / `dark`;局部 CSS 仍固定白底且未完成暗色样式的弹窗,必须暂时显式固定 `light`,否则会出现白底白字或深浅样式混杂;`portal=false` 继续依赖原 DOM 主题作用域。裸 `createPortal` 若使用平台或画布 CSS 变量,必须改用相应的主题 portal 壳,不要用硬编码白底掩盖主题变量缺失。
- 验证:在真实 `AuthUiContext.platformTheme="dark"` Provider 下打开 portal 弹窗,断言 auto 弹窗的 overlay 携带暗色主题类,固定浅色弹窗只携带浅色主题类,panel 与遮罩的 computed background 均非透明;同时断言 `portalTheme="none"` 的黑底预览不被平台 remap。
- 关联:`src/components/project/ProjectGalleryView.tsx``src/components/common/PlatformToolModalShell.tsx``src/components/common/UnifiedModal.tsx`
## 自主试玩失败后的修复责任不能同时落给总控和专业 Agent
- 现象:专业 Agent 已交付新 revisionProject Supervisor 的固定试玩已通过全部业务交互断言,但双视口可见性等外围门禁仍失败;下一轮 Provider 被要求直接 `file.patch`,随后又被 `orchestratorOnlyAfterDelegation` 正确拦截,格式修复耗尽后父 run 失败且没有最终回复。
- 原因:试玩失败活性门只检查“必须出现项目 mutation”,没有区分父 run 是否已经存在 durable 协作事实;它与“进入协作后 Supervisor 只委派、读取、认领和验证”的策略形成互斥合同。
- 处理:同一失败 revision 上,尚无协作事实的兼容 run 可以保留总控直接修复;已有协作事实且总控只编排时,只允许创建新的 `code-prototype` 后续修复委派,明确继承最新 `preview.validate` 诊断和 `game/index.html` 产物要求,不把它伪装成已有 repair delivery 的二次返工。专业 Agent 推进 revision 后,总控先验证新 revision,再重新试玩。
- 验证:回归测试同时覆盖“无协作时仍可直接修复”“有协作时工具目录只剩 `agent.delegate`”“专业 Agent 推进新 revision 后总控只能先复验”,并用 `supervisor-autonomous-playable-lane-defense` 真实 E2E 检查静态烟雾、桌面/移动浏览器、全部固定试玩断言、唯一 Supervisor 回复和零残留。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent.rs``apps/ai-game-creator-shell/src-tauri/src/tests.rs``apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs`
## 旧 revision 试玩失败不能越过 ready delivery 触发重复委派
- 现象:父 Supervisor 在较早 revision 的试玩失败后已经收到专业 Agent 推进的新 revision,且 delivery 处于 ready;但下一轮仍按旧失败创建第四次 `agent.delegate`,随后持续命中 `activeDelegations=3`,即使最新项目已通过浏览器试玩也不能结束。
- 原因:试玩失败 liveness 先执行“协作后必须委派”,没有先检查同一父 run 的 ready 未认领回执和 active delivery 容量;生成合同又只要求提供固定 `data-playtest-id`,没有明确每个值必须唯一、可见和启用。
- 处理:旧失败仍存在但 ready 回执可认领或 active delivery 已满时,只允许 `agent.run_status` 原子认领/观察既有委派;认领后验证当前 revision,再由父 run 重跑固定试玩。只有当前 revision 自身的新失败且没有待收束交付时才创建后续专业修复。固定试玩控件必须唯一匹配、可见、启用且真实可点击。
- 验证:构造 `failed preview@旧 revision + ready delivery@新 revision`,断言顺序为 `agent.run_status -> verification@当前 revision -> preview.validate@当前 revision`,委派总数不超过 3;分别以缺失、重复、隐藏和 disabled 的固定控件验证浏览器失败关闭。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs``apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_game_build.rs`
## 隔离 AppData 的长 TMPDIR 会让 Chrome SingletonSocket 超限
- 现象:静态检查已通过,但 `preview.validate` 在 Chrome 启动阶段以 status `134` 失败;minidump 中是 `process_singleton_posix.cc``Socket path too long`,页面逻辑和截图探针均未开始。
- 原因:真实 E2E 为隔离 Runner 创建的 AppData 路径较长,Chrome 又在 `TMPDIR` 下拼接 SingletonSocket 名称,最终超过 Unix domain socket 路径上限。
- 处理:浏览器进程使用 `/tmp/ga-browser-*` 短临时根,同时把 Profile 放在该根目录并显式传入子进程 `TMPDIR`;项目证据目录不变。用真实长 `TMPDIR` 环境运行 Chrome smoke,不能只测临时目录字符串长度。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/browser.rs`
## image.inspect 的桌面和移动截图别名必须绑定当前 run
- 现象:真实桌面/移动试玩已经通过并生成截图,Supervisor 随后反复调用 `image.inspect``desktop.png / mobile.png`,但工具只接受完整项目相对路径,最终耗尽 planning 循环。
- 原因:Provider 能看到固定截图名,却看不到持久证据路径中的 Agent、run 和 revision;让模型猜完整内部路径既不稳定,也会扩大私有路径暴露面。
- 处理:只为精确 basename 提供受控别名,解析到当前 Agent、当前 run 下最新数字 revision;显式路径继续按原规则处理。严禁跨 run 搜索“最近截图”,否则旧轮次成功证据会污染当前验收。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs`
## Rust 大文件拆成嵌套模块后要同时核对路径、可见性和兼容重导出
- 现象:函数正文原样搬到 `foo/bar.rs` 后,兄弟模块或原父模块突然无法访问;新增 `runtime_protocol::provider_retry` 后,原本指向 crate 根 `provider_retry` 的相对路径又会被新子模块遮蔽。为尽快通过编译而把全部符号改成 `pub(crate)`,或因 `unused_imports` 告警删除 facade 上的兼容重导出,都会扩大内部 API 或破坏旧调用面。
- 原因:`pub(super)` 永远指当前模块的直接父级,源文件下沉一层后原可见范围会随层级缩小;Rust 名称解析又会优先命中更近的同名子模块。facade 重导出是否属于兼容合同,也不能只按当前文件内有没有直接使用来判断。
- 处理:拆分前记录公开符号、可见性和测试路径。仅供同一 facade 下兄弟子模块调用的 helper 使用 `pub(super)` 暴露给直接父级;确实需要供 `crate::agent` 兄弟模块调用的符号才最小化使用 `pub(in crate::agent)`,不得统一放宽为 `pub(crate)`。访问 crate 根同名模块时显式写 `crate::provider_retry`;入口 facade 保留原公开 API 和兼容重导出,确认为兼容出口但当前未直接消费时只在该重导出上局部添加 `#[allow(unused_imports)]`,不要全局 suppress 或机械删除。
- 验证:比对拆分前后的公开 API、测试名和测试路径,运行全 crate 编译与串行测试;同时搜索同名模块的相对路径、跨子模块 helper 和带局部 allow 的兼容重导出,防止后续整理再次回退。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs``apps/ai-game-creator-shell/src-tauri/src/runner.rs``apps/ai-game-creator-shell/src-tauri/src/process_session.rs`
## 共享工作树并行拆分期间不能启动全 crate 验收
- 现象:真实 E2E 在准备 CLI 阶段以 Rust exit `101` 退出,任务、run 和 Provider lifecycle 全为 0;同一代码在并行 Agent 停笔后可以正常编译。
- 原因:多个 Agent 虽然拥有互不重叠的写入文件,但全 crate 编译会同时读取所有模块。某个入口刚写入 `mod`、对应子文件尚未全部落盘时启动构建,会读到合法的中间态半成品。
- 处理:并行 Agent 只做各自 scoped 格式和测试;主 Agent 等所有写入方正式完成并关闭后,再在稳定共享树统一运行 crate fmt、全量测试和真实 E2E。编译前失败且 `stdin/provider/task=0` 的轮次只能算 harness 准备失败,不能归因给 Runtime 行为。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/runner.rs``apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/`
## 确定性最终回复不能掩盖外部 Provider 的真实失败
- 现象:自主构建已经推进到 revision 5,浏览器试玩 `37/37`、Supervisor 计划 `8/8`,但 `image.inspect` 视觉请求与最终回复命中同一 deserialize fingerprint`114` 个 Provider request identity 中仍有 `1` 个 final-reply failed,且没有 Supervisor assistant。只看项目已完成或后续确定性回复,容易把这轮误写成 PASS。
- 原因:项目 completion gates、用户是否收到收束回复和 Provider lifecycle 是否全成功是三个独立事实。确定性回复可以补齐已完成项目的用户出口,但不能反向证明失败的 Provider 请求成功,也不能覆盖失败 identity。
- 处理:兜底条件必须同时锁定 `autonomous-game-build``project-supervisor`、当前 revision completion gates 全通过和 final-reply 阶段;优先使用非空 `plan.response`,为空时才生成当前 revision 已完成生成并通过静态、桌面和移动试玩的固定回复。普通 Agent、未收敛、门禁未通过或 reconciliation 一律继续失败关闭,并原样保留 Provider failed lifecycle 证据。
- 验证:把已有外部轮次继续标记为 **FAIL**。修复后另起独立真实外部 E2E,在同一轮同时证明唯一 Supervisor assistant、完成门禁、Provider lifecycle、零残留、零重复和零泄漏;复验完成前不得宣称外部 Provider 全链路 PASS,也不得与旧失败轮拼接。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/``apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
- 最新复验:新 fallback 已真实命中,父 Supervisor 为 `idle / completed``turn.report``settled`,唯一 assistant 为 `44` 字符;pending、retry、handoff、finalization、reconciliation、重复、API Key 和路径泄漏均为 `0`。“完成后不回复”已解决,但该轮仍是 **FAIL**`105` 个 Provider identity 只有 `103 completed / 2 failed`,两个原始专业 Agent 失败虽均由 repair 恢复,最终仍因 `supervisor-swarm-private-body-public-event-leak` 未通过。
- 泄漏定位:两个专业 Agent 的 `149 / 123` 字符失败正文,分别对应 SHA-256 前缀 `494ce8 / 3089ad`,进入 `4``event.detail``2``agent.runtime.background_task.failed.error`。这些内容是 delivery result,不是 userTask、委派任务或对话正文;不要把该问题误归因到 final-reply fallback。
- 脱敏处理:私有 `state.error` 与私有 delivery 应保留完整诊断;公共 event / agentDb 只投影 `errorSha256 / errorChars /` 稳定 `failureKind`。禁止按正文黑名单打补丁,也禁止把失败状态伪装为成功来消除泄漏。
- 复验要求:公共投影修复后必须另起独立真实外部 E2E,重新核对同轮 Provider lifecycle、唯一回复、残留、重复和泄漏;当前仍未 PASS。
- 最终复验:修复后另起的独立真实外部轮次已取得 `status=PASS``evidence=complete``privacy scan=complete`。此前 `114` identity 和 `105` identity 两个 **FAIL** 仍是各自独立的历史失败,未与本轮拼接;最终 PASS 仅由这个单个新轮次的完整证据构成,当前状态现已 **PASS**
- Provider 与恢复证据:`84` 个 Provider identity 的 `started / terminal / completed` 均为 `84``failed / retry / open / duplicate` 均为 `0``1` 个原专业任务为 `budget-exhausted`,唯一 repair `completed``recovered`;最终 child 为 `2 completed + 1 historical failed`,全部任务均已终态。
- 收束与项目证据:父 Supervisor 为 `idle / completed``turn.report=settled`,唯一 assistant 为 `297` 字符,`completed audit=1`finalization stages 为 `4`。项目 revision `0 -> 4``game/index.html``7639` bytes 且已变化,static smoke passed`lane-defense-v1` 的 desktop / mobile 浏览器验证均通过并取得 `37/37`
- 零值与清理证据:pending / confirmation / user-input / provider batch / retry / handoff / tool-plan handoff / finalization 残留 / reconciliation / duplicate 全为 `0`Provider payload / private body / API Key / project path / config path / log / browser report leak 全为 `0`;人工 approve / answer / steer 全为 `0`。Runner 与 AppData 已清,项目因 `--keep-project` 暂留后由主线程清理。
## 第三轮并行拆分要分开处理测试作用域、兼容出口和稳定树验收
- 现象:把父文件中的测试整体下沉到 `tests.rs` 后,原来可直接使用的 helper、validator 或平台 trait 突然无法解析;本轮具体缺失的是 `response_fingerprint``validate_ledger` 与 Unix `AsRawFd`。与此同时,facade 兼容重导出会出现 `unused_imports`,并行写入期间启动全 crate 编译还可能读到其它 Agent 尚未完成的中间态。
- 原因:Rust 子模块不会继承父模块的私有 `use` 作用域;兼容重导出的价值是维持旧调用面,不能用当前 facade 是否直接消费来判断;多个 Agent 即使写入范围互不重叠,全 crate 编译仍会读取全部模块,因而无法避开正在落盘的半成品。
- 处理:测试下沉时显式补齐自身依赖的 import,不把生产可见性为测试统一放宽。已确认属于旧调用面的重导出必须保留,只在精确重导出位置添加局部 `#[allow(unused_imports)]`,不得按 warning 机械删除或全局 suppress。并行阶段禁止启动全 crate 编译、全量测试和真实 E2E;各 Agent 只执行自己边界内的检查,待所有写入方完成后由主线程在稳定共享树统一验收。
- 验证:稳定树统一运行 `cargo fmt --check``cargo check``cargo check --tests`、三个定向测试组、Linux 串行全量和确定性真实 Runner + Chrome E2E;同时核对原测试名、`turn.report` 字段/顺序、raw JavaScript 哈希和兼容重导出。Windows cross check 若因宿主缺少交叉链接器而未进入项目代码,必须明确记录为残余验证缺口,不能写成项目代码已通过。
## 未执行的并行 stale 动作不能进入 needs-reconciliation
- 现象:两个专业 Agent 在不同文件上并行工作,一个 Agent 先推进全局 project revision;另一个 Agent 的 pending 写动作尚未执行,却因 revision 与 planning 快照不同进入 `failed / needs-reconciliation`。Swarm CLI 随即提前结束,父 Supervisor 仍在等待回执,项目 revision 甚至可能尚未包含核心游戏文件。
- 原因:旧实现把“执行前发现计划过期”和“执行后无法证明副作用结果”合并成同一种 reconciliation。全局 revision 会被任何合法项目修改推进,因此它能证明旧计划已过期,却不能证明尚未开始的动作产生了未知副作用。
- 处理:在 pending 标记为 executing 之前检查 revision 漂移;漂移时写入可恢复的 blocked observation,明确旧动作未执行并要求同 run 重新规划。文件写入和 patch 在项目锁内再做一次同样检查,防止预检后的竞态。只有动作可能已落盘、账本身份冲突、审计失败或持久记录损坏时继续失败关闭到 reconciliation。
- 验证:必须覆盖确认后 stale 动作和锁内 stale 动作两条路径,证明目标文件未改变、旧 pending 被收束、下一次 Provider planning 使用同一 run、最终状态可完成,并用单输入真实 external-provider E2E 验证并行专业 Agent 最终生成可试玩项目。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs`
## 自主模式不能保留任何 RequiresConfirmation 漏口
- 现象:`user.input_request` 已被拒绝,但模型选择项目权限或 MCP catalog 标记为确认的工具后,整个自主批次仍进入 `waiting-for-confirmation`;重启还会忠实恢复这个等待态,形成永久阻塞。
- 原因:只在本地 command policy 中提升少量 auto-safe 工具,不能覆盖 MCP 动态 approval;只修改新请求策略,也不能处理旧版本已经持久化的 waiting batch。拒绝 observation 若仍带 `executionMode=auto`,通用续跑还可能把它错误投影为“已执行自动工具”。
- 处理:最终合并后的本地/MCP policy block 必须再次经过持久 Run Profile gate;自主 profile 的所有剩余确认统一转为 deny,混合批次整体零执行并同 run 重规划。恢复迁移以 aborted batch 为提交点、pending 为镜像,并把自动策略拒绝显式审计为 `runtime-policy-rejected`
- 验证:同时覆盖 auto-safe 白名单、显式 deny、动态确认失败关闭、标准 profile 不变、完整恢复扫描、前缀副作用未发生、Session/run/profile identity 不变和 replacement planning 已发出;最后必须重新运行确定性与外部 Provider 的单输入可玩 E2E。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs`
## 只读职责不能用无边界关键词子串判定
- 现象:真实 Provider 已连续返回首批 `code-prototype / quality-review` 委派,但四次格式修复都报“code-prototype 必须是非只读实现任务”;Provider lifecycle 全部正常完成,首批 batch 却始终无法建立,父 run 在 revision 0 失败。
- 原因:只读分类器用 `contains("只读")` 判断任务。模型按修复提示把程序任务写成“非只读实现任务”或“不要只读检查”,否定式文本仍命中“只读”子串,因此同一正确修复会被永久拒绝。
- 处理:只接受明确的“只读审查 / 只读检查 / 只读验收 / 不得修改项目”等正向合同;判定前剥离 `非只读 / 不要只读 / 不是只读 / non-read-only / not read-only` 等否定式标签。首批角色事实继续以 durable delivery 的 target 与 expectedArtifacts 为准,不能只依赖易漂移的任务文案。文本合同还必须落实到 Provider 计划边界:只读 delivery 只允许纯读取和状态观察,写文件、启动命令、推进 revision、修改任务/记忆/黑板/资产或继续委派必须在任何执行前拒绝,并只允许修复成 `respond_to_user`
- 恢复:给既有持久 batch 增加新职责时必须升级 schema;新 v3 按新合同失败关闭,旧 v2 collaboration batch 与 v1 contractless batch 继续按原 fingerprint 和创建时语义恢复,不能在反序列化时用新规则误杀升级中的无人干预轮次。
- 验证:正向只读与否定式可修改分别做定向回归,并额外让只读 quality Agent 尝试 `file.write`,证明目标文件和 revision 均不变且下一请求只广告 `respond_to_user`;同内容 v3 batch 必须失败关闭,v2 必须可恢复。真实外部 E2E 必须证明首批两份委派建立、程序 Agent 实际推进 `game/index.html` revision、质量 Agent mutation 为零、父 run 最终 settled,并保持人工输入和终局残留为零。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs`
## Gitea PR runner 不能把宿主 Docker socket 当普通 volume
- 现象:`act_runner``container.docker_host` 留空时,job inspect 会出现 `/var/run/docker.sock:/var/run/docker.sock`PR 脚本即使 `valid_volumes: []`,仍可直接调用 Docker API读取其它容器、挂宿主目录或取得 runner 配置。另一类迁移故障是 rootless daemon可以启动,但 bwrap 在 job 内报 `No permissions to create new namespace``Failed to make / slave``Mount too revealing`
- 原因:空 `docker_host` 会自动发现并把控制 socket 传播到 jobrootful Docker 的 seccomp、AppArmor 与 system-path masks 又不支持完整 nested bwrap。Runner 2.0.0 还有一处独立合并缺陷:`parseSystemPaths``systempaths=unconfined` 转成显式空 slice 后,`mergo.WithOverride` 不覆盖 empty value,真实 job 又恢复 Docker 默认 masks,表现为配置文件写了 unconfined、手工 `docker run` canary 也成功,但 Actions job 仍在 `--proc /proc` 返回 EPERM。直接使用 `--privileged`、外层 `CAP_SYS_ADMIN` 或 host executor 会把测试跑绿建立在破坏 PR 隔离的前提上。宿主启用 Clash fake-IP 时,简单按 DNS 的 `198.18.0.0/15` 判断公网还会误拒所有公共依赖。
- 处理:先把 Gitea 升到至少 `1.26.4`,再用固定 digest 的 Runner 2.0.0 rootless DinD;外层保持非 privileged、无 `CAP_SYS_ADMIN`,内部 Docker 仅 Unix socketrunner 固定 `docker_host: "-"`。若版本仍有上述 empty-slice 缺陷,只做 merge 后保留 `MaskedPaths=[]` / `ReadonlyPaths=[]` 的最小补丁并固定自有镜像 digest,不对整个 HostConfig 启用 overwrite-empty。job 使用独立 internal networkGitea 经 reverse gateway,公共 80/443 经使用公共 DoH 验证真实 IP、拒绝私网/保留地址/metadata 的 proxy;DoH 要缓存并合并同域并发,长下载 timeout 不能只有 60 秒。rootless job 内的 bwrap namespace/proc 选项不能复制到宿主 rootful runner。apt 步骤在 root 时直接执行,非 root 时用 `sudo -E`,否则 sudo `env_reset` 会让 apt 丢失 proxy。
- 运维陷阱:从容器内运行 Compose 时,宿主 `/opt/gitea-stack` 必须挂到容器同名绝对路径;挂成 `/stack` 会让相对 bind source 被 daemon解析为宿主 `/stack/...`,表现为 Gitea进入空安装页、gateway 脚本“缺失”。发现后不要迁移空库,立即用同路径 mount 重建并核对原数据大小、installed 日志、仓库数和 API。切换前保留冷数据 tar、pg_dumpall 和原 compose/env/runner 配置,备份与 token 不提交 Git。
- 验证:同时检查 Gitea 版本、runner declare、外层 `Privileged=false`/无 CapAdd/无宿主 socket、inner job `Binds=[]``MaskedPaths=[]``ReadonlyPaths=[]`、固定 image digest、`/var/run/docker.sock` 不存在、公共 proxy 可用、直连公网/Postgres/metadata 失败,以及完整 bwrap canary。AI 原生壳的共享 Agent Runtime 后台锁 suite 固定单线程执行;并行全量出现锁或异步终态失败、逐项单线程全部通过时,修正 suite 调度口径,不放宽断言。最后重跑四个 CI jobcheckout 成功但 apt/rustup/npm 同时失败时,先排 proxy/env,而不是改测试。
- 关联:`.gitea/workflows/project-ci.yml``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md``docs/project-memory/shared-memory/development-workflow.md`
## Unix 文件身份复核不能假定 Linux 的 `dev_t` 类型
- 现象:AI 游戏创作 Tauri 壳在 Linux CI 编译通过,但 macOS 上会在 Agent DB、External Runner owner 和 tool-plan handoff 的 `fstatat` 身份复核中报 `i32 == u64` 类型错误;Tauri 失败后配套后端收束,终端还可能短暂出现 SpacetimeDB 订阅连接失败的连锁日志。
- 原因:`libc::stat.st_dev` 跟随平台 `dev_t`macOS 为有符号整数,而 `std::os::unix::fs::MetadataExt::dev()` 统一返回 `u64`;直接比较会把 Linux 的类型偶合误当成 Unix 通用契约。
- 处理:与 Rust 标准库的 Unix `MetadataExt` 实现保持一致,先把 `st_dev / st_ino` 规范为 `u64`,再与 `metadata.dev() / metadata.ino()` 比较;设备号、inode 和文件类型三重检查均必须保留。
- macOS 测试夹具:`std::env::temp_dir()` 可能返回 `/var/folders/...`,而 `/var` 是系统兼容符号链接。需要真实项目根的 Runtime 测试应先 canonicalize 已存在的临时根目录,再创建唯一子目录;不得为了让夹具通过而放宽生产 Runtime 的项目根及祖先符号链接拒绝规则。
- 异步测试隔离:测试触发后台 continuation 后,必须等待对应 Agent lane 完整释放,再删除项目夹具或安装下一项全局 mock 配置;否则前一项后台任务可能抢占后一项的唯一 mock 响应,形成只在全量顺序执行时出现的跨测试污染。
- 验证:macOS 本机运行 `cargo check --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`,并复跑 Agent DB、project owner 和 tool-plan handoff 的 Unix 相对句柄替换检测;Linux CI 继续覆盖原有安全回归。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project.rs``runner.rs``tool_plan_handoff.rs`
## AI 游戏创作壳不能用全局或匿名身份发布本地模块
- 现象:`npm run agc` 在发布模块时先访问 `auth.spacetimedb.com` 并以 401 失败;改成 `--anonymous` 后首次可能成功,但再次启动会因匿名 identity 变化而 403。若把 403 当成可忽略警告继续启动,api-server 会连接旧 schema,随后持续输出 `external_generation_job``profile_recharge_order_expiration_timer` 等缺表订阅失败,Tauri 也可能在后端就绪前退出或迟迟不弹窗。
- 原因:本地 publish 默认继承开发者全局 SpacetimeDB 云端登录,离线时 standalone 无法校验 issuer`--anonymous` 不是可跨进程持久复用的 owner identity;AI 游戏创作壳若再复用主站历史数据目录,还会继承旧数据库归属和旧 schema。
- 处理:AI 游戏创作壳固定使用 gitignored 的独立数据目录;standalone 就绪后先从 `/v1/identity` 获取并按 data dir 而非监听端口持久化同一 API identity,再用数据目录内权限为 `0600` 的独立 `cli.toml` 执行 `spacetime login --token` 和 publish。旧端口作用域记录在同一 data dir 下身份唯一时迁移,存在多个不同身份时失败关闭,不能猜 owner。远程 server 继续使用正常登录配置;本地 publish 403 必须阻断 API/Vite,不得带旧 schema 降级启动。`.app/dev-stack.json` 记录规范化 data dir,独立壳复用后端时必须同时匹配数据库名、专用目录和健康状态;缺少目录字段的旧状态不得复用。POSIX 启动器在 `spawn` 后立即监听 `error / exit`、保存 detached leader 的 PGID、向外层登记句柄并用独立进程组收束 npm、Node、Cargo 和子进程;direct leader 先退出后仍向负 PGID 发信号清理后代,ready 前中断、超时或 ENOENT 也走统一清理,退出后确认 3080、8082、3101 均释放。
- macOS 日志:api-server 进程指标当前只实现 Windows API 和 Linux `/proc`macOS 必须跳过 observable callback 注册;不能每轮采集为每个指标重复打印“不支持平台”。Rust/Tauri 既有 `dead_code` warning 与一次性配置缺失提示不属于长驻重试日志。非 Linux `project.verify` 校验 `npm run` 参数时必须越过 `--silent``--ignore-scripts` 等前置选项定位真实脚本名,不能固定读取 `run` 后第一个参数,否则会在 macOS 将合法验证误报为“缺少脚本名”并引发 Runtime 测试级联失败。
- 验证:定向测试覆盖同一 data dir 跨端口复用 identity、不同 data dir 隔离、旧 state/data dir 不匹配拒绝复用、spawn ENOENT 受控失败、direct leader 以 42 退出后同组 descendant 仍收到 TERM,以及后端 ready 前句柄已登记且超时清理。连续运行两次 `npm run agc`,两次都必须真实完成 module publish、`/v1/ping``/healthz`、Vite 3080 和 Tauri `Running`;稳定观察期间不得出现缺表订阅失败或进程指标平台告警,Ctrl-C 后三个端口和主 Tauri 进程均应释放。
- 关联:`scripts/dev.mjs``apps/ai-game-creator-shell/scripts/start-dev-stack.mjs``server-rs/crates/api-server/src/process_metrics.rs`
## Swarm 人工测试不能复用正式客户端 Runner AppData
- 现象:`npm run agc:test:chat` 在进入聊天前报“Agent Runner 版本与当前客户端不一致,但旧 Runner 仍有任务,暂不能重启”;正式客户端仍能看到自己的待确认或委派任务,重复执行测试也持续失败。
- 原因:Runner 复用身份同时绑定协议版本和当前可执行文件 SHA-256。`cargo run` 重新编译后的 debug 二进制与正在运行的 release Runner 指纹不同,而旧入口只隔离测试项目、仍把正式 AppData 直接传给 CLI,于是测试会向正式 endpoint 发升级探测。正式 Runner 有 pending action、Provider sidecar、进程会话或非终态队列时拒绝退出是正确的安全门禁,不能通过强退或放宽 idle 判定让测试通过。
- 处理:正式 AppData 只作只读配置来源。每次人工测试在系统临时根创建 `0700` sentinel 隔离目录,只把主配置和可选 local overlay 私有复制为 `0600` 普通文件;不得复制 endpoint、lock、`.previous` 或其它状态。LLM 检查与 Swarm CLI 全部使用隔离目录。退出时通过内部 CLI 请求 `runner.shutdown_if_idle`,确认隔离 endpoint 消失后才删除配置;仍有任务或无法确认退出时同时保留测试项目和隔离配置并报告路径。正式 Runner 的 PID、bootId、端口和 executable fingerprint 必须保持不变。
- 验证:单元测试覆盖私有 inode、权限、local overlay、禁止复制 endpoint/lock/备份、符号链接拒绝、sentinel 清理和 endpoint 存在时拒绝删除;真实 smoke 使用隔离 AppData 启动并收束空闲 Runner,前后比较正式 endpoint 身份且确认正式 PID 存活,再检查本轮 `/tmp` 项目和隔离配置均已清理。
- 关联:`apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs``apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts``apps/ai-game-creator-shell/src-tauri/src/runner/client.rs``apps/ai-game-creator-shell/src-tauri/src/cli.rs`
## Swarm 队列 busy 不能直接当成 canonical run 可 steer
- 现象:继续已有项目时,Runtime state 仍指向旧的 `idle / completed``cancelled / cancelled` run A,但 task ledger 已有更新的 `pending / queued` run B;CLI 打印“已投递 B”后却立刻把 A 及其历史子 Agent 的 cancelled/budget-exhausted 报成 B 的失败。
- 原因:旧 `runtime_is_busy` 同时包含当前 state 和队列汇总,调用方看到 `task_queue.pending > 0` 后仍从 canonical state 反推 steer、失败扫描和 turn report 的 runId;取消 tombstone 还会让恢复扫描在处理 A 后无条件跳过 B。底层拒绝 terminal steer 和保留 A 的真实失败历史都是正确行为,不能通过放宽门禁或删除历史记录修复。
- 处理:保留 queue busy 用于 Runner 存活判断,另由 Runtime 协议层提供唯一 steerable 判定。start mutation 返回实际 `acceptedRunId`CLI 以它建立不可变 turn baseline;失败、reconciliation、用户交互、收束和报告只观察该 run。canonical 已推进到后续 run 时从 task journal 读取目标 run 的最终记录。旧 cancelled canonical 若仍有 pending 且无 running,恢复扫描跳过旧 run 的 pending action 恢复,直接启动队首 pending。若输入与已落盘 pending task 及最后一条 user 消息相同,则只观察原 run。Goal 路径也必须核对同一 Agent、Session、runId、Run Profile 和 steerable 状态。连续 run 的回复必须按确定性 finalization message ID 过滤;历史 specialist 失败必须以 `(agentId, runId)` 为键读取完整 journal,不能让滞后的非失败 state 删除 journal 已记录的失败;报告计数也不能退回 `recent_tasks` 的 12 条窗口。
- 验证:构造 cancelled run A、保留 A cancel tombstone、pending run B 和单份已落盘用户消息,证明恢复后 B 进入 running 并完成且 conversation 不重复。另覆盖观察 B 时忽略 A 及 A 子任务失败、观察 A 时仍正常失败、B 完成后 canonical 已推进到 C 仍可从 journal 收束 B、`turn.report.parentRunId` 始终为 baseline,以及 expected Goal runId 不一致时不选中目标。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs``apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs`
## 2026-07-25 autonomous-game-build 不能只检查 game/index.html 就宣称正式项目完成
- 现象:`agc:test:chat` 收束后可能只有可玩 `game/index.html`,配置画布 Key 时可能再有一张首版美术图,但 `memory/``exports/` 仍为空,设计、数值、美术清单、音频需求和发布包装文件缺失;界面或 CLI 却仍可能显示已完成。
- 原因:“可玩原型”和“正式项目”共用了同一完成信号。首批静态委派只覆盖程序、质量和可选美术,Supervisor 完成门与 `agc:test:chat` 收束检查又主要围绕 `game/index.html`、静态 smoke 和试玩回执;seed manifest 中其他任务的终态和正式文件集没有成为硬门禁。
- 处理:将自主构建改为正式产物 DAG,按“设计打底 -> 数值/美术/音频并行 -> 程序整合 -> 质量/静态/试玩 -> 发布包装”分波调度。基础正式路径必须包含 `memory/project.md``game/game_design.md``game/balance.json``assets/manifest.art.json``assets/manifest.audio.json``game/index.html``exports/README.md`;配置画布 Key 时再额外要求 `assets/ui-prototype.png``assets/art-spritesheet.png`,并校验非空、JSON 可解析、图片真实可读和 manifest 登记。
- 补充原因:历史 seed 状态未重置、普通 preview / smoke 自动投影和在 `agent.run_status` claim 尚未可靠观察时过早启动 DAG,都会让任务看似完成却没有真实执行;恢复扫描重新发布已经冻结或认领的 delivery,还会刷出重复 `agent.delegate.result_failed`
- 特殊边界:`assets/manifest.audio.json` 只是当前阶段的音频需求清单,不能冒充 BGM/SFX 已生成。无画布 API Key 时交付 7 项文本 / JSON / 代码 / 发布产物,并明确图片尚未生成;配置 Key 时额外要求两张受控画布图片,Key 无效或生成失败必须阻塞,不得用占位文件或伪造登记绕过。
- 收束:Supervisor 只有在当前轮所有必需 seed manifest tasks 都为 `completed`、正式产物齐全且可解析、当前最新 revision 的 `game.static_smoke``preview.validate` 通过后才能最终回复。严格 `agc:test:chat` 必须精确校验固定 16 个 manifest task 在同一父 Run 下各自唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection,并核对 current revision 静态凭证、桌面 / 移动浏览器 playtest、截图与报告;不能把 delivery 完成、历史 revision 成功、文件存在、非空、JSON 可解析或 PNG magic 命中当成正式完成。PNG 还必须通过 chunk CRC、zlib、scanline、PLTE 和未知 critical chunk 检查;当前脚本已同时绑定 current revision 的 static smoke 与浏览器证据。
- 处理:新自主根 run 重置全部 16 个 seed taskDAG 在 `run_status` claim 可靠观察后启动并等待项目写锁;普通 bookkeeping 不修改自主 DAG;已 `ready / claimed-by-parent` 的相同终态 delivery 重放按幂等成功处理,只有真实终态冲突才写失败。
- 验证:确定性 `npm run agc:test` 已通过,16 个 manifest task exactly-once,最终 revision 为 `11`,基础正式产物、两张画布 PNG、静态 smoke 和桌面 / 移动 `37/37` 试玩均通过,pending、reconciliation、Provider 失败、重复与泄漏均为 `0`。该证据不冒充独立外部 Provider 验收。
## 终端真实测试不能混用配置参数、stdin EOF 和持续预览
- 现象:开发者第一次运行 `agc:test:chat` 时必须先打开 GUI 才能配置 Provider;无 TTY 的脚本可能在 stdin 立即 EOF 后零任务成功退出,或者任务已经完成却继续等待 preview 的 `Ctrl+C`,导致自动化看似卡死。若为图省事增加 `--api-key`,密钥还会进入 shell history 和进程列表。
- 原因:把首次配置、手工多轮聊天、单轮真实测试和持续试玩当成同一个交互生命周期;同时让 GUI 与 CLI 使用不同配置入口,或把 EOF 既解释为“提交当前需求”又解释为“没有输入”,会让退出语义随调用环境漂移。
- 处理:GUI 与 `npm run agc:config` 共用系统 AppData `game-creator.config.json`,终端隐藏输入 API Key 并禁止 `--api-key`;更新时保留 `agentLlm``editorApi``mcpServers` 等其它配置,POSIX 权限维持目录 `0700` / 文件 `0600` 并原子替换。显式 `--config-dir` 必须以 `world.genarrative.ai-game-creator` 为独立叶目录,不能让向导对 `/tmp`、AppData 根或共享目录整体 chmod / 重建 DACL。隐藏输入调用 `stdin.resume()` 后必须记住原 pause 状态,在成功、取消、异常和 `SIGINT / SIGTERM / SIGHUP` 路径恢复 raw mode 并 `pause()`,信号恢复后重发;只移除 `data` listener 会让 `--configure-only`、配置检查失败或 Ctrl+C 保持活动 stdin。缺配置时仅 TTY 人工会话可询问进入向导,非 TTY 立即失败并提示配置命令。
- Windows 密钥复制:`mode: 0o600` 和 POSIX `chmod` 在 Windows 上不能代替 DACL。隔离 AppData 目录必须先设置仅当前用户、禁止继承的 DACL;目标配置文件先以空文件创建并收紧 DACL,之后才允许把 API Key 字节写入。先 `copyFile` 再依赖 Rust 只读检查或事后收紧会留下密钥暴露窗口,也可能因继承 ACL 不满足 Runtime 合同而在首次 `--llm-status` 失败。
- Windows PowerShell 参数:不要把 DACL 目标路径和目录标记直接追加在 `powershell.exe -Command <script>` 后;Windows Node `spawn` 会让 PowerShell 5.1 把这些值拼入命令文本,带空格的临时路径会被拆分并使 `GetFullPath($args[0])` 失败。当前实现只通过子进程私有环境变量传入路径和布尔值,并由真实 Windows `npm run agc:typecheck` 覆盖 DACL 回归。
- 跨平台临时路径比较:macOS 的 `os.tmpdir()` 可返回 `/var/folders/...`,而 `realpath` 会返回同一目录的 `/private/var/folders/...`;Windows 也可存在驱动器号大小写、junction 或链接解析差异。测试安全路径函数时,fixture 期望值必须基于平台原生 `realpath` 后的根目录构造,不能直接与 `mkdtemp` 的逻辑路径字符串严格比较。不能只依赖开发机自带的路径别名;CI 应创建真实父目录、指向它的符号链接(Windows 使用 junction)和不存在的叶目录,断言安全函数返回真实父目录下的叶路径。`realpath` 和全部断言都应放在 `try/finally` 内,确保失败也能清理 fixture。
- 超时与进程树:`setTimeout` 后只对直接 Cargo PID 调一次 `kill()` 不是硬超时;Cargo 启动的 CLI / Runner 仍可能持有 stdio,使 `close` 永远不返回,清理阶段也可能无界等待。POSIX 必须创建独立进程组并按负 PID 终止,Windows 必须使用 `taskkill /T`;宽限期后升级强杀,Runner shutdown 和清理另设短硬超时。重复 Ctrl+C 也必须升级,不能一直被自定义 signal handler 吞掉。
- 模式拆分:自动化使用默认的 `agc:test:chat`,由脚本投递固定植物塔防需求;正式产物、最新 revision 静态检查和 Runtime 浏览器验收通过后立即收束并清理,不启动持续 preview。`agc:test:chat:manual` 不注入 `--task`,用于多轮 stdin 手工聊天,并保留 preview 直到显式退出;不能用 EOF 或是否存在 `game/index.html` 猜测当前模式。
- 验证:覆盖 `--api-key` 拒绝、各平台 AppData 路径、Provider 预设、旧配置节点保留、URL 安全校验、原子写入、POSIX 权限、隐藏输入恢复 pause、Windows 写密钥前 DACL、整棵进程树超时终止、缺配置时 TTY / 非 TTY 分支,以及 `--task` 成功后不进入长期 preview、手工模式仍可持续试玩。测试和错误输出只验证“密钥已配置”状态,不读取或打印密钥本体;源码字符串断言和 Linux 上的 Windows mock 不能替代真实 Windows ACL / `taskkill` 复验。
- 关联:`apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs``apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs``apps/ai-game-creator-shell/scripts/check-config.mjs``apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts`
- 真实验收状态:外部 Provider 与画布 API 均可调用不等于全链路验收通过。2026-07-27 新起的独立轮次使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**:同一轮完成固定 `16` 个 manifest task exactly-once、七份基础产物、两张真实画布 PNG、当前 revision 静态检查、desktop / mobile `lane-defense-v1` playtest、唯一终态回复和安全清理;`turn.report` 的 busy / pending / running / confirmation / user-input / reconciliation 均为 `0`。此前失败轮、部分产物、单项接口成功和确定性结果仍不得与本轮拼接。
## 项目总控空态和持久 Runtime 不能依赖同一份 Session 索引
- 现象:新项目尚未发消息时右侧总控区域只剩整块空白;已有 `needs-reconciliation` Runtime 的项目重新打开后,也可能看不到失败状态卡。
- 原因:空 Runtime 直接返回 `null`,没有稳定空态;总控首轮水合又只在 active Session 索引存在时读取单 Agent Runtime。若 Provider 成功响应交接失败并留下 Runtime 文件、但 Session 索引未完成持久化,专业 Agent 列表能读到总控状态,专用总控面板却仍保持 `runtime=null`
- 处理:总控面板在无 Runtime 时显示“尚未开始”入口;项目级 Runtime 列表中的 `project-supervisor` 作为缺失 Session 索引时的恢复来源,并同步其 Session、响应流和 Runtime 状态。`needs-reconciliation` 显示为“待核对”,不伪装成执行中。
- Windows 根因补充:`tool-plan` 成功响应在相对目录句柄下原子安装账本时,不能把非空 `FILE_RENAME_INFO.RootDirectory` 传给 `SetFileInformationByHandle(FileRenameInfo)`;该组合会稳定返回 `ERROR_INVALID_PARAMETER (87)`,导致每轮首个 Provider 响应都进入 `needs-reconciliation`。应使用支持相对根目录句柄的 `NtSetInformationFile(FileRenameInformation)`,继续保留目录句柄锚定,不能退化成可受路径换绑影响的绝对路径 rename。“按句柄安装”失败应归类为 `tool-plan-storage`,不能落入 `tool-plan-unknown`
- Responses 协议补充:格式修复会把上一次 Provider 输出作为 `assistant` 消息追加到新请求。OpenAI Responses API 中 system / user 文本使用 `input_text`assistant 文本必须使用 `output_text`;不区分 role 会收到 `Invalid value: 'input_text'` 的 HTTP 400。assistant 图片不能继续序列化为 `input_image`,应在本地请求校验中失败关闭。
- Steer 后审计补充:等待持久 Provider retry 时用户 steer 会让同一 run、同一 loop 重新使用 `loop-N-repair-0`tool-plan protocol / repair 审计的幂等身份必须包含 `appliedSteerCursor`,否则新 cursor 的合法响应会与旧响应误报“内容冲突”。旧审计没有该字段时只按 cursor `0` 兼容;不能删库、忽略冲突或改用 response fingerprint 作逻辑槽唯一键。
- 单调用 Provider 协作修复补充:若 Provider 每轮只返回一个 function callProject Supervisor 的首批协作 repair 必须从触发首次协作缺口的响应开始,跨文本 JSON、OpenAI Chat tool call 与 OpenAI Responses function call 等格式修复轮次累积合法的 `agent.delegate / agent.spawn_isolated`。同一 `agentId` 后出现的 action 覆盖较早 action`agent.spawn_isolated` 是单批唯一槽位,修正版必须覆盖旧 action,不能因输入变化追加第二个 spawn。每轮再按累计结果计算缺失的静态 Agent,并把下一轮 `agentId` enum 收窄到明确缺失集合;`missingStaticAgents=none` 表示没有指定 ID 缺口,不得生成 `enum=["none"]`,避免已满足的委派被重复生成或首批协作永远无法成批提交。
- Provider action 安全持久化补充:pending / provider action 的泄密检测不能因裸自然语言短语 `api key` 直接拒绝,否则 `agent.delegate` 中“不要暴露 External Editor API Key”等安全约束会被误报并阻断首批协作。赋值形式只允许完整匹配受控的“未配置 / 不可用 / 禁止读取”等状态或固定无密钥降级说明,不能用 `starts_with` 放行 `none-but-secret``not configured; actual value ...` 等安全前缀后的凭据;`**API Key**:`、`` `API Key`: ``、`API Key(生产):` 等装饰或限定标签也必须识别为赋值。结构化字段标记 `apiKey / api_key`、`Authorization / Cookie`、`token / Bearer` 以及已知 secret token 形状仍必须检测并失败关闭。
- Windows retry 扫描补充:`Path::strip_prefix(root)` 在 Windows 上得到的相对 `Path` 转字符串后使用反斜杠,不能直接传给只接受 portable `/` 的 Runtime JSON sidecar 读取器;否则 Runner 重启或显式 `--agent-resume` 扫描已到期 retry 时会报“项目文件路径不能包含反斜杠”,任务持续停在 `waiting-for-provider-retry`。目录扫描应按路径组件重组成 `/` 分隔的 UTF-8 相对路径,不要放宽全局路径校验。
- 恢复交互:`needs-reconciliation` 即使没有 `pendingToolAction`,也必须提供显式“已核对,结束旧任务”;它只取消旧 run,不直接 retry。若取消后仍有 pending task,由 Runner 自动继续;只有队列为空且旧 run 已取消时,才允许创建新的 retry run,避免重复执行同一用户输入。自主构建 Supervisor 的 retry 不能改写为普通 `agent-background-task` source,必须从已验证的原 Run Profile 绑定恢复 `project-supervisor-gui / project-supervisor-cli` 可信来源;不得只信可追加的 task journal。
- 验证:前端回归同时覆盖零历史、无 Session 的初始空态、无 active Session 索引但存在持久 `needs-reconciliation` 总控 Runtime 的恢复展示,以及“先取消、队列为空后才重试”;真实 Windows 运行全部 tool-plan handoff 测试,确保相对句柄 rename、覆盖安装、回读和清理均通过。Responses 回归覆盖 system / user / assistant 文本分别序列化,并保留 user `input_text + input_image`;Runtime 回归覆盖“无效计划 → repair transport 等待 → steer → 新 cursor 再修复”,断言 cursor `0 / 1` 各有一条审计且不冲突。
## 固定画布产物返工不能变成任意覆盖,design-foundation 不能越权修程序
- 现象:视觉 Agent 发现候选图不合格后,可能先删除 `assets/ui-prototype.png``assets/art-spritesheet.png`,再用猜测的尺寸、比例或另一条路径重新生成;远端生成期间项目文件又可能被其它 Agent 更新,迟到结果覆盖较新的文件。`design-foundation` 为了让静态或浏览器检查通过,也可能顺手改写 `game/index.html` 或自行启动 preview。
- 原因:把“允许一次语义返工”误解成“视觉 Agent 可以任意覆盖”,且只在 prompt 中描述角色职责,没有在 replacement 授权、文件写入、工具策略和提交时 fingerprint 上强制执行。
- 处理:固定 UI 与 spritesheet 路径、比例、尺寸、kind 和 label;普通生成 `replaceExisting=false`。只有 Project Supervisor 对已认领原 delivery 建立的唯一静态 repair,且父 run、目标 Agent 与 `expectedArtifacts` 全部匹配时,才允许 `replaceExisting=true` 原位替换;不得先删除固定正式产物,也不得对 repair 再 repair。请求外部生成前记录原路径 SHA-256,取得写锁准备提交时复算;不一致即按 stale fingerprint 失败关闭并保留当前文件。
- 职责隔离(2026-08-11 补充):`design-foundation` 只写 `memory/project.md``game/game_design.md` 和可选固定 UI 原型。Runtime 必须同时在单文件写入、patchset、delete 与工具 policy 层拒绝其修改 `game/index.html`、其它实现文件、启动 preview / playtest、运行进程、执行 `project.verify``game.static_smoke` 或整项目恢复。它的两个固定文本产物由 Runtime 在收束门内验证;这不向模型开放验证工具,也不替代 `preview-readiness` 的最终静态 smoke 或 `preview-playtest` 的独立浏览器验收。
- 画布配置一致性:未配置 External Editor API Key 时,`design-foundation / art-asset-plan` 的委派合同与 manifest 终态投影必须一起降级为文本产物,不能仍把 `assets/ui-prototype.png / assets/art-spritesheet.png` 作为完成条件;配置 Key 时两张固定图片继续是严格必需产物。委派、完成合同和 manifest 投影必须读取同一配置事实,禁止一层降级、另一层仍要求图片。
- 验证与状态:当前回归已覆盖固定合同拒绝漂移、已登记 spritesheet 删除保护、静态 repair 授权、并发修改触发 stale fingerprint、`design-foundation` 的 write / patchset / delete 和 preview 工具拒绝。2026-07-27 的独立 75 分钟上限外部 E2E 已在同一轮完成两张真实画布图片、固定 `16` 任务、当前 revision 静态与双视口试玩并安全清理,当前状态为 **PASS**;后续改动仍须新轮复验。
## manifest 波次不能持项目锁启动 child,终态投影不能依赖静态委派屏障
- 现象:同波多个 ready task 被标为 `running` 后,后排专业 Agent 会在第 1 轮 Provider planning 前,或第 2 轮并行只读结果投影时,等待约 1 秒后直接报“项目正在被其他写操作占用”;另一些专业 Agent 的 Runtime 已 `completed`manifest 却继续停在 `running`,并留下 `autonomous_ready_task.projection_failed`
- 原因:scheduler 持有 `runtime.autonomous.schedule_ready` 项目锁时直接启动不同 Agent lane,child 立即申请同一锁构建首轮 Provider request;通用短等待耗尽后被误投影为 terminal failed。终态投影又复用了“开始下一波”校验,把仍在运行的独立静态委派屏障错误当成 child terminal projection 前置条件。
- 处理:scheduler 在项目锁内预占每个 candidate 的 Agent Runtime lane并落 durable journal,释放项目锁后才启动 drain;Runtime 通用项目写锁使用约 10 秒有界等待,统一覆盖 Provider tool-plan、并行只读结果投影及同 run 控制面写入。child terminal projection 只验证父 run 身份和活跃合同,静态屏障继续约束下一波调度与父 run 收束,但不阻止已完成 child 写回 manifest。
- 验证:定向回归至少覆盖 Provider planning和并行只读投影跨过大于 1 秒的 manifest 写锁、静态委派仍 running 时 child terminal 仍能投影、ready scheduler Profile / 幂等身份保持不变;真实验收必须新起父 run,证明同波 child 零锁竞争失败且 16 项均有唯一 terminal projection。
## 完成合同不能只绑定一个入口摘要,公开资源审计不能保存完整 prompt
- 现象:历史 completion contract 只绑定 `game/index.html`,可能在其它正式产物沿用旧文件时仍放行;画布生成成功后,`asset.register` 又把完整 `source.prompt` 复制进公开 Agent DB,触发 provider payload / private-body 表面泄漏。
- 原因:把“入口变了”误当成“本轮全部正式产物都新鲜”,并直接复用资源 manifest 的完整 source 对象写公开审计。manifest 的本地来源元数据与公开 event / agentDb 的最小身份字段不是同一个安全边界。
- 处理:完成合同升级为 `game-creator-autonomous-completion-contract.v2``baselineArtifacts` 必填并参与指纹;旧 v1、缺基线或提交前身份漂移失败关闭。资源 manifest 可以保留 prompt,但 `asset.register / asset.update` 写审计前必须清除 `source.prompt`,只保留资源身份和模型。
- 并发补验:revision 漂移、repository context drift 和项目锁竞争只能在同一 logical run 有界重试;只有明确 blocker / repair 或成功 `ok` observation 才可重放终态,失败 observation 不能当通过,只读 Agent 不能借补验获得命令权限,completion 统计仍只能增加一次。
## tool-plan handoff 不能把计划叙述和源码字段当成配置载荷扫描
- 现象:Provider 已返回 HTTP 200 并计费,tool-plan lifecycle 却只有 `started`handoff 账本停在上一 loopRuntime 进入 `needs-reconciliation`;重启 Runner 或 `/resume` 后仍原样被屏障阻断。
- 原因:在解析 function arguments 之前,对整段 `response.text` 和序列化 arguments 统一执行 `.env``game-creator.config` 等字面标记扫描。安全叙述如“无需读取 `.env`”,或 `oldText / newText / content / patch` 中的普通源码字面量,会在真实路径和内容字段尚未区分时被误判。原始响应未成功交接时不会留下正文,因此现场只能结合 loop 边界和最小复现定位,不能把高概率分支冒充已恢复的原响应证据。
- 处理:计划叙述与规范源码内容字段只检查真实密钥 token 形状、凭据头标记和不安全控制字符;结构化敏感 JSON key、非内容字段的配置痕迹和绝对路径、真实 token、容量、thinking、身份、顺序及账本完整性继续失败关闭。成功 handoff 失败时只在 Runtime event/state 和 Agent DB 保存受控 `failureKind`、脱敏错误 SHA-256、字符数与 requestId,禁止保存正文、arguments、密钥和绝对路径。
- 验证:必须同时覆盖 narrative 和 `oldText / newText / content / html / patch` 提及 `.env` / `game-creator.config` 可 round-trip`path=.env.local``sk-...` 真实 token 仍拒绝,全部 handoff 回归通过;诊断审计必须断言不存在 `error / response / arguments` 原文。修复后的外部 Provider 重试仍需新起独立轮次,不能与故障轮或确定性回归拼接为 PASS。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs`
## 自然语言“继续修复”不能在无活动 Runtime 时落入空 resume
- 现象:旧 run 已取消且 `/resume` 明确报告无可恢复任务,用户随后输入“继续”“继续之前干的事情”或“那就继续修复”,interaction 仍返回 `resume`,宿主反复扫描后不新建任务。
- 原因:interaction 模型能看到会话历史,却不知道宿主已经排除了 active、pending 和可 steer Runtime;宿主又把自然语言 `resume` 与显式 `/resume` 当成相同控制动作机械执行。
- 处理:活动 Runtime、排队任务和 Goal 仍在 interaction 前优先 steer/恢复;只有这些门禁全部为空后,自然语言 interaction 返回的 `resume` 才规范化为 `execute` 并创建新 run。显式 `/resume` 继续保持纯恢复控制,不因无任务而隐式执行。
- 验证:保留自然语言必须进入统一 interaction loop 与显式 `/resume` 命令测试,并新增无活动 Runtime 时 `Resume -> Execute`、普通 reply 不变的回归。
## tool-plan 中项目内绝对路径应在成功交接时规范化
- 现象:Provider 已成功返回原生工具调用,但 `path / paths / cwd / outputPath` 等结构化输入使用了当前项目根目录内的绝对路径;交接安全门禁以 `tool-plan-absolute-path` 失败,run 进入 `needs-reconciliation`,后续“继续”只能排队。
- 原因:Runtime 工具最终只接受项目相对路径,但 Provider 不一定始终遵守提示;交接层此前只能拒绝全部绝对路径,无法区分“当前项目内、可无损转换”的输入与项目外越界输入。
- 处理:成功响应写入 tool-plan handoff 前,只对内置 Runtime 原生函数和 legacy tool-plan wrapper 的合法、无重复 key JSON arguments 按工具 schema 的精确位置做规范化;仅改写完整字符串且位于当前项目根目录内的 `file.*.path``project.patchset.changes[*].path``project.git_commit.paths[*]``command.*.cwd``image.inspect.paths[*]``canvas.asset_generate.outputPath` 等无歧义真实路径字段。不要泛化改写 `command.*.args[*]`:同一个完整绝对路径字符串在 `rg` 中可能是搜索 pattern,在其他程序中也可能不是 path operand;无法由固定工具契约确认语义的位置继续拒绝。项目根通过符号链接别名传入时,只解析根身份并保留根内原始相对后缀,使后续 Runtime 仍能拒绝内部 symlink / reparse point;写入目标末端尚不存在可正常处理,越出项目根的 alias 继续失败关闭。源码/叙述字段、动态 MCP arguments、项目外绝对路径、`file://`、路径加行号、畸形或重复 key JSON、敏感 key 和真实凭据继续拒绝。
- 诊断:拒绝合法 JSON 中剩余的绝对路径时,只公开固定枚举/白名单约束的 `functionClass``jsonPointer``pathShape``relationToRoot``duplicateSafeJson``hitCount`;数组下标与 object key 分开生成,未知或纯数字 object key 不原样公开,root 关系只做词法分类,不对任意外部路径执行 canonicalize。不得记录 arguments、路径值、正文、前后缀或可逆编码。Provider prompt 同时明确 `command.exec` / `command.start` 的 argv 项目路径必须相对 `cwd`,禁止绝对路径、file URI、路径加行号或嵌入式绝对路径。规范化后的 handoff 同时作为当前进程执行值和重启 replay 值,避免 live/restart 语义漂移。
- 验证:覆盖项目内 `path`、项目根 `cwd``paths` 数组和符号链接根别名的相对化与落账重放,并锁定根 alias 之后的内部 symlink 仍以原相对后缀交给 Runtime 拒绝;覆盖 `command.exec` / `command.start` argv 不猜测 path 语义,嵌入式 argv、`file://`、扁平化 `path`、项目外 alias 继续拒绝且只生成安全字段定位;覆盖纯数字 object key、伪造/超长/非法枚举 diagnostic 不进入公开状态。源码 `content` 原文保持不变,动态 MCP 和项目外绝对路径仍拒绝,并运行全部 tool-plan handoff 与 Provider reconciliation 回归。
- 关联:`src/components/project/ProjectGalleryView.tsx``src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx``src/components/common/PlatformToolModalShell.tsx``src/components/common/UnifiedModal.tsx`
## 待用户确认的 Agent 工具不能依赖模型自行结束回合
- 现象:画布 Agent 已生成有效工具规划,却最终只保存 `ERROR max turns reached: 3`,助手文本和待确认工具卡都消失。
- 原因:八类画布工具的 `call()` 只返回待用户确认的规划结果,但 function-calling runner 在成功工具后仍继续请求 LLM,只靠 prompt 要求模型不再重试;模型连续返回工具调用直到上限后,错误结果又丢弃此前累积的输出。
- 处理:工具通过框架契约显式声明 `requires_user_confirmation`;当本批全部工具都成功且等待确认时,runner 在处理完整批次后立即返回已有助手文本和工具结果。未知工具、参数错误、hook skip、普通连续工具和不可解析响应仍继续受 `max_turns` 门禁保护。不要用单纯提高轮次上限掩盖终止条件缺失。
- 验证:runner 回归测试必须同时覆盖“待确认工具只调用一次 LLM 并成功结束”“普通连续工具仍会触发 max-turn 门禁”“多工具按数组顺序执行”“request 级 system prompt 真实进入请求”;公共 prompt 在无工具时仍必须包含 runner 所需的 JSON 响应格式,且不得宣称并发执行。
- 关联:`server-rs/crates/platform-agent-harness/src/run.rs``server-rs/crates/platform-agent-harness/src/tool.rs``server-rs/crates/platform-editor-agent/src/agent/tools/`
## 待确认工具的 prompt 不能使用全局禁止重发话术
- 现象:为防止用户在对话中说“确认 / 可以 / 取消”时重复生成待确认卡片,prompt 加入“不得重新发起相同工具调用”后,模型在用户随后明确提出新生成、修改或重做请求时也拒绝调用工具。
- 原因:LLM 容易把面向“当前确认 / 取消意图 + 特定 pending 卡片”的限制过度泛化为跨回合、跨意图的全局禁止;单看工具名或参数相似度不能区分“重复确认旧卡片”和“用户明确发起新任务”。
- 处理:prompt 只用正向条件句描述当前回合:确认或取消意图确实匹配某条现存 pending 卡片时,引导用户点击该卡片按钮,本条意图不生成新 tool call。不添加全局的“禁止重发相同工具”规则。cancelled 卡片不再处理;用户要求修改、重做或新任务时正常发起新调用,pending 卡片不阻塞无关请求。
- 验证:业务 prompt 契约测试要同时锁定“匹配 pending 时引导确认 / 取消按钮”“cancelled 后可发起新调用”和“pending 不阻塞无关新请求”;模型实测必须另外覆盖同工具名的后续新任务,确认不会因过度泛化而拒绝。
- 关联:`server-rs/crates/platform-editor-agent/src/agent/prompt.rs``docs/【编辑器】画布Agent对话面板-2026-07-03.md`
## Agent 终态失败不能吞掉已发生的工具事实
- 现象:同一轮 prompt 中前面工具已经成功生成待确认结果,但后续工具、hook、completion 或 `max_turns` 失败后,API 只保存最后一条 `ERROR `,已执行工具和用户本轮语义从会话历史中消失。
- 原因:runner 只返回单一 `PromptError`,或者直接向 committed memory 逐步写入,无法区分“尚未发生外部工具事实,整轮可回滚”与“已发生工具事实,只能提交并闭合错误”。工具失败若被压成字符串,调用方还会丢失 `kind``retryable``fatal` 和原始 `output`
- 处理:用 `PromptRunError { error, partial_outputs }` 保留失败前输出,并将本轮 memory 先写入 staged buffer。无工具活动失败时整体回滚 staged 增量;有成功或失败工具活动时提交已发生事实,并追加 terminal error closure。api-server 按 `partial_outputs` 顺序先持久化成功工具的 `not_completed` 待确认消息,再追加 `ERROR ` 终态消息;`ToolFailed` 保留给调用方做诊断和流程决策,不伪装成成功确认卡。
- 取消边界:不能在 prompt future 内对 `agent.memory.take()` 后跨 await 持有,也不能用统一 `VecMemory` staging 绕过自定义 memory 的限长、摘要或脱敏规则。`AgentMemory::begin_staged` 必须产生行为等价、写入隔离的 `StagedAgentMemory`,成功或已有工具活动时显式 `commit()`,直接 drop 才表示回滚。外部 drop 若发生在工具完成后,guard 必须提交结果与取消闭环;若工具仍在执行,至少提交“已启动、结果未知”事实,供后续 reconcile。正式总 deadline 应作为 runner 内部 future 终止 completion;工具开始前检查 deadline,一旦开始则不能中途 drop,必须等待结果后再携带 partial outputs 收口。外层 timeout 只适合作为进程级最后保险,不能承担业务收口。
- 验证:至少覆盖“无工具 completion 失败回滚 staged 用户消息”“非 fatal 工具失败对调用方暴露 `kind/retryable/fatal/output`”“成功工具后终态失败保留 partial tool output”“有工具活动时 committed memory 末尾存在 error closure”以及“API 增量中待确认工具位于 terminal `ERROR ` 之前”。
- 关联:`server-rs/crates/platform-agent-harness/src/run.rs``server-rs/crates/platform-agent-harness/src/tool.rs``server-rs/crates/platform-editor-agent/src/agent/prompt.rs``server-rs/crates/api-server/src/editor_agent/api.rs`
## 画布 Agent 的规划请求不能关闭瞬时失败重试
- 现象:美术 Agent 对话返回红色错误气泡 `completion error: LLM 请求超时,累计尝试 1 次`;HTTP 本身仍返回 200,前端 20 分钟 transport timeout 没有触发。
- 原因:规划请求虽然有 Agent 专用单次 timeout,但 `vector_engine_llm_client``max_retries` 硬编码为 0VectorEngine `gpt-5.4-mini` 的偶发长尾、连接超时或可重试上游状态会在第一次失败后直接持久化成 system error。framework 的英文 `completion error` 前缀也被原样暴露给用户。
- 处理:120 秒改为前端软提示阈值:POST 仍 pending 时显示不入库的“仍在处理中,请耐心等待”;provider 明确断开/失败才写正式错误。专用 provider 单 attempt 使用 8 分钟 hard timeout,请求发起阶段读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次且重试退避最多 60 秒。不要只计算单次 complete 的最坏时间:runner 还可因非法 JSON/工具校验失败进入后续轮次,必须从 handler 入口开始计算 18 分钟总 deadline,进入 `agent.prompt(...)` 时扣除会话锁/上下文准备已用时间,为持久化和前端 20 分钟 timeout 留出余量。响应头后的体读取/解析错误按明确失败收口,必须使用真实 attempt 计数;规划、配置和定价错误对用户统一为中文,原始诊断只记后端日志。重试发生在任何生成工具执行前,不会重复提交生成任务或扣费,不要通过提高前端 timeout 或 runner `max_turns` 掩盖 provider 重试缺失。
- 验证:`platform-editor-agent` 测试锁定 8 分钟 hard timeout 与中文错误;前端 fake timer 用例锁定 120 秒前只显示思考动画、到点后显示耐心等待、成功/失败后移除;`platform-llm` 回归用例锁定第二次 attempt 成功响应头后的 body timeout 仍报累计 2 次;`api-server` 测试锁定专用 client retry、18 分钟整体 deadline 与中文直达错误。运行态排障按同一 request id 对齐 `platform_llm` failure stage 与 `/messages` 总耗时,并确认仍 pending 的请求不再在 120 秒形成错误气泡。
- 关联:`server-rs/crates/platform-editor-agent/src/agent/agent.rs``server-rs/crates/platform-agent-harness/src/error.rs``server-rs/crates/api-server/src/state.rs``src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts``src/components/image-editor/EditorAgentConversation/MessageBubble.tsx``src/services/image-editor/editorAgentClient.ts`
## 前端退役目录不能只靠扫描和 ignore 隔离
- 现象:Tailwind `@source`、TypeScript 根 `include`、ESLint ignore 和 Vitest include 都排除了旧创作目录,但干净打开新版页面时,Vite 仍转换 `services/rpg-entry/index.ts`,构建产物也包含旧作品库和旧 profile 逻辑。
- 原因:现役模块的静态 import 会让 Vite、TypeScript 和打包器递归解析依赖;watch ignore 只停止监听,Tailwind source 只控制 class 扫描,tree-shaking 也发生在模块已经加载之后。经 barrel 只取一个公共函数尤其容易把同文件的旧导出一起带回图中。
- 处理:把仍在用的公共账号 / 钱包 / 设置能力迁到明确的现役 client 与 presentation modelVite `pre` transform 对退役模块真实路径直接失败,ESLint 在现役源上增加 restricted imports。每次恢复公共 UI 后用 `tsc --listFilesOnly` 和全新浏览器 context 复核,不能用已有 HMR 会话判绿。
- 关联:`vite.config.ts``.eslintrc.cjs``src/services/platform-entry/``docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`
## SpacetimeDB schema guard 的基线不能递归扫描保留源码
- 现象:旧业务按“数据壳保留、业务实现退役”落地后,`check:spacetime-schema` 报几十个 `legacy_schema` 与原路径 accessor 重复;同一提交对自身比较也失败,但 `cargo` 实际可以正常编译 module。
- 原因:当前工作树按 `Cargo.toml [lib].path` 的 crate root 可达模块扫描,基线提交却通过 `git ls-tree -r` 扫描整个 `spacetime-module/src`。原 `src/lib.rs` 和旧业务源码只供追溯、不进入 active crate,但基线全目录扫描仍会把它们与 `#[path]` 引入的历史数据壳同时解析。
- 处理:current 与 base 必须各自读取所在快照的 Cargo manifest,并沿各自 `mod` / `#[path]` 图扫描;base 文件存在性和内容从该 Git tree 读取,不能复用当前工作树。不要忽略 `legacy_schema`、删除历史源码或吞掉 base duplicate,因为历史数据壳正是正式 schema,真实可达重复仍须失败。
- 验证:回归测试同时覆盖“不可达旧源码同 accessor 不报错”和“两个可达模块同 accessor 仍失败”;再运行 `npm run check:spacetime-schema -- --base-ref HEAD`,确认 self-base 按当前 136 张表通过。
- 关联:`scripts/check-spacetime-schema-guard.mjs``scripts/check-spacetime-schema-guard.test.ts``server-rs/crates/spacetime-module/Cargo.toml``docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`
## VectorEngine 请求超时不能脱离 worker 绝对预算(2026-07-20
- 现象:VectorEngine 单次请求超时大于 worker job 执行预算时,worker 已停止续租,provider 才超时或开始重试;最终 lease 过期、任务失败并退款,上游却可能继续消耗资源或迟到成功。
- 原因:单 attempt timeout、重试退避、图片下载与 worker / lease 分别使用独立的相对计时,没有共享同一绝对 deadline;只抬高 worker timeout 或单独压低 provider timeout 都无法保证留出终态写回窗口。
- 处理:实际调用 VectorEngine 的四类图片 job 使用 `1800s` long 预算;从 job 开始的同一起点派生 provider deadline,常规提前 `60s`、短预算提前一半。每次 attempt、退避、下一次 attempt 和图片下载都必须在该 deadline 内;普通 HTTP / `inline` 不伪造 worker deadline。修复时不改动 lease fencing、迟到写回仲裁和原子退款语义。
## 同一 Rust 二进制的本地双进程不能各自并发 watch 重启(2026-07-21
- 现象:本地把 `api-server` 与独立 `bgfilter-worker` 都用 `cargo run -p api-server` 启动后,一次 Rust 源码变更触发两套 watcher 并发停止、编译和链接;Windows 常因另一个实例仍占用 `api-server.exe` 而链接失败,或出现 API 已恢复但内部 worker 尚未 ready 的半更新状态。
- 原因:两个进程角色共享同一 crate、target 和可执行文件,却被错误地当成两个互不相关的 dev service。更危险的是先启动 `GENARRATIVE_PROCESS_ROLE=all` 的 API:它会立即消费外部生成队列,可能在内部 BgFilter worker 尚未 ready 时领取任务。
- 处理:`npm run dev``npm run dev:api-server` 只创建一套 Rust watcher,并把两个进程作为组合重启单元:先停止 API 与 BgFilter worker,再只让 worker 的 `cargo run` 完成必要构建,等待 worker `/readyz`,最后启动并验活 API。交互 `rs api-server``rs bgfilter-worker` 在完整栈内也必须走同一组合重启。`ProcessRole::All` 永远不内嵌 BgFilter listener;父子进程共享解析后的内部 base URL / TokenLinux 第五端口固定为端口段 `start + 4`,Windows 把第五端口纳入统一探测和漂移。
- 验证:定向测试断言组合重启顺序为“stop API → stop worker → start/ready worker → start/ready API”,`dev:api-server` 自动带起同 runner worker,端口解析得到五个互不冲突的端口;再运行 `node --check scripts/dev.mjs`、dev-stack 定向测试和编码检查。
- 关联:`scripts/dev.mjs``scripts/dev-stack-port-utils.mjs``.codex/skills/genarrative-dev-stack-port-routing/SKILL.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 固定 digest 不等于每个 CI job 都要强制拉镜像
- 现象:四个 Gitea Actions job 在约 20 秒内同时失败,checkout 和测试都没开始;setup 日志显示 Docker 对已固定的 `docker.gitea.com/runner-images@sha256:...` 发起 manifest HEAD,随后以 `net/http: TLS handshake timeout` 结束。
- 原因:镜像 label 固定 digest 只防止内容漂移;`container.force_pull: true` 仍会让每个 job 调用 Docker image create/pull 并依赖 registry 即时可用,即使 rootless Docker 本地已有该精确 RepoDigest。并发四个 job 还会同时放大同一外网 TLS 故障。
- 处理:继续使用完整 digest,把 Runner 设为 `force_pull: false`;首次部署或变更 digest 时,在切换 label 前对精确 digest 执行有界重试拉取,并用内层 `docker image inspect` 核对 RepoDigest。保留上一份 runner 配置和已验证镜像备份,切换失败时回滚配置,不改用浮动 tag。
- 验证:重启 runner 后先等待内层 `docker info` 就绪,再 inspect 精确 digest 并确认 runner declare;重跑真实 PR 的四个 job,必须越过原先的启动失败窗口。运行中 job 仍要复核 `Privileged=false``Binds=[]``MaskedPaths=[]``ReadonlyPaths=[]` 和独立网络,防止稳定性修正意外放宽隔离。
- 关联:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md``docs/project-memory/shared-memory/development-workflow.md`
## 四个 Gitea CI job 不要重复现场安装固定工具链
- 现象:`Repository checks``Frontend tests``Backend tests``Native shell tests` 都从全新 job 容器开始,apt、setup-node、rustup 和原生系统库在不同 job 里重复安装;后端与原生壳的安装时间可达数分钟,并把软件源和代理瞬时失败放大为四份。
- 原因:Gitea Actions job 彼此隔离,上一个 job 在容器内安装的包不会自动进入下一个 job;把同一套不随 PR 变化的工具链写在 workflow step 中,必然每次重做。
- 处理:用 `deploy/container/gitea-ci-job.Dockerfile` 预装 Node 22、固定 npm、Rust 1.96、`rustfmt`、Chrome、`bwrap``rg``ffmpeg``clang/lld` 和 Tauri / 后端系统依赖,并按锁预热唯一根 npm workspace、server-rs、桌面壳与 AI 游戏创作壳 Cargo 四份下载缓存。四个 job 统一 `runs-on: genarrative-ci`,先用镜像内脚本直接从 Gitea checkout,再以 runtime 模式运行 `scripts/check-gitea-ci-job-image.sh`,同时检查四份缓存锁、工具链、完整 bwrap 与 Chrome headless。`RUSTUP_AUTO_INSTALL=0``rust-toolchain.toml` 变更时先重建镜像,不把下载 fallback 放回 job。
- 依赖边界:每个 job 仍必须各自执行 `npm ci`,让当前 lockfile 和 PR 依赖在干净环境中验证;区别是命中镜像 cache 时只做本地解包,锁新增依赖时才走受控网络。不要把 `node_modules` 或 Cargo `target` 烘进镜像,也不要向不受信任 PR 挂载跨 job 可写 cache。
- 锁漂移边界:runtime 校验输出任一 `*_cache_lock=partial` 说明镜像内 lock 与当前 checkout 不同,不代表新增依赖已经缓存;必须同时输出 Actions warning,提示可信分支落地后刷新镜像。必须在新镜像中对 server-rs、桌面壳和 AI 游戏创作壳当前 lock 执行真实 `cargo fetch --locked --offline``cargo metadata --no-deps` 不会证明依赖 archive 可用,不能作为替代。
- 构建网络边界:`CARGO_NET_RETRY` 只覆盖部分 crate 下载,registry `config.json` / index TLS 握手仍可能直接终止整次 fetch。Dockerfile 对每个 `cargo fetch --locked` 再做最多 5 次整命令级有界重试,最终仍执行断网 fetch,不能降低为无锁重试或省略离线闭合验证。
- 验证:workflow 不再出现 GitHub checkout action、apt、setup-node 或 rustup 安装 step;镜像能按四份当前 lock 完成缓存闭合,四个 job 的环境校验、经 3 次整命令级有界重试保护的单次根 `npm ci` 和原有测试门禁仍全部执行。
## Gitea Actions HTTPS CONNECT 隧道必须双向收束 socket2026-08-07
- 现象:CI 的 `npm ci` 高频出现 `ECONNRESET / network aborted`Cargo 则出现 crates.io TLS EOF、连接超时或下载失败;同一出口 gateway 容器看似健康,却累计自动重启数百次,日志反复出现 `Socket.ondata -> Writable.write -> write EPIPE -> Unhandled 'error' event`
- 原因:HTTPS CONNECT 建立后使用 `upstreamSocket.pipe(clientSocket)` 与反向 pipe,但只监听 upstream `error`;客户端在 DNS 等待、下载或 job 清理期间关闭连接时,pipe 继续向已断开的 client socket 写入,未处理的 EPIPE 会让 Node 进程退出。`unless-stopped` 自动拉起和浅层 healthcheck 会掩盖崩溃,所有并发 npm / Cargo 隧道同时被 reset。
- 处理:CONNECT 一开始就为 client socket 注册 `error / close`,解析完成后为 upstream socket注册同样的双向销毁处理;DNS 返回、写 200 和开始 pipe 前都检查 client 是否已销毁。任一端 error、close 或 timeout 都幂等 destroy 两端,不把普通客户端 reset 写成错误日志。不要用进程级 `uncaughtException` 吞掉问题,也不要只增加 npm/Cargo 重试掩盖 gateway 崩溃。
- 验证:在独立 canary 和正式 gateway 上分别并发制造至少 500 次“CONNECT 后立即断开”,随后确认容器仍运行、restart count 不增加、日志无 EPIPE;再通过同一 proxy 对 npm registry 与 crates index 建立完整 TLS 隧道。切换前仍须确认 Gitea 无活跃 run 且 Runner 内层无 job 容器。
## 统一 npm workspace lock 不能丢失可选 WASM 包的 bundled 依赖节点(2026-08-21
- 现象:根 `npm ci` 在安装前失败,报告统一 lock 缺少 `@emnapi/core` / `@emnapi/runtime`;错误版本可能是 registry 当前满足 `^1.11.1` 的最新版,而不是原 lock 中曾记录的版本。
- 原因:重写或解决根 workspace lock 冲突时,保留了 AGC 使用的 `@tailwindcss/oxide-wasm32-wasi` 对 bundled `@emnapi` 包的声明,却删掉了对应嵌套 package 节点。npm 会重新解析当前 registry 版本并判定 manifest 与 lock 不同步;这不是单一 npm 版本问题,也不表示应用应直接依赖两个 `@emnapi` 包。
- 处理:只在最新目标分支的仓库根执行固定 npm 的 `npm install --package-lock-only --ignore-scripts`,保留 npm 对全部 workspaces、bundled 节点及 `peer` / `optional` 标记的完整规范化结果;确认各 workspace manifest 没有意外变化,不要手工只补报错中的两个版本。
- 验证:至少用 Jenkins 对应固定 npm 和当前开发环境分别执行干净的根 `npm ci`,核对 bundled 节点后再运行 `npm run check:npm-workspaces`、AGC typecheck、编码检查和 `git diff --check`;禁止恢复独立 AGC lock 或子目录 `npm ci`
## Windows 专属 Tauri resource 不能写进通用配置(2026-08-21
- 现象:Linux CI 已完成根 workspace `npm ci`,却在 Tauri custom build command 中报 `resources/codex/win-x64/...exe doesn't exist`Windows 侧车的 Rust staging 受 `cfg(windows)` 保护,因此非 Windows 构建不会生成这些文件。
- 原因:Tauri 会在所有平台校验通用 `tauri.conf.json` 的 bundle resource 源路径;把 Windows x64 资源映射写进通用配置,等于要求 Linux / macOS 也预先拥有不属于其安装闭包的 Windows 可执行文件。
- 处理:通用配置只保留跨平台 bundle 项;Windows 原生侧车的完整白名单放入 Tauri 自动合并的 `tauri.windows.conf.json`。不要提交二进制占位文件,也不要让非 Windows build script 下载或伪造 Windows 资源。
- 验证:配置门禁断言通用配置没有 Windows resource、Windows 平台配置保留完整固定白名单;Linux 运行原生壳门禁必须越过 Tauri resource 校验,Windows release 仍由 build script 对 npm 原生包、SHA-256 清单和目标布局失败关闭。
## AGC Skill 指纹与相对路径校验必须跨平台一致(2026-08-21)
- 现象:内置 Skill 文件集合没有缺失,原生测试却统一报内容指纹不匹配;另一个测试在 Linux 上把 `C:\\temp\\SKILL.md` 判为安全相对路径,受控资源工具可能继续处理 Windows 盘符或反斜杠遍历形式。
- 原因:审核文件定稿后未按最终字节重新生成 manifest SHA-256;同时 `std::path::Path` 只按当前宿主语义解析路径,Linux 不会把 Windows 盘符和反斜杠视为绝对路径或分隔符。
- 处理:Skill 文件变化与 manifest 指纹更新必须同次提交,并提升审核包版本;资源引用只接受使用 `/` 的普通相对段,显式拒绝反斜杠、冒号盘符、UNC、绝对路径和父目录段,再查询审核清单。不要先把反斜杠替换成 `/` 后再做安全检查。
- 回归补充:即使 Skill 文件本轮没有变化,也不能从旧提交或旧构建结果复制清单指纹;必须对当前工作树按 UTF-8 读取、将 CRLF 规范为 LF 后现场重算,并在提交前运行原生 Skill Pack 校验。Git 的 `eol=lf` 不能阻止编辑器在干净工作树里留下少量混合 CRLF,而 Cargo `include_bytes!` 会读取这些原始字节;因此运行时计算与安装也必须使用同一规范化函数。运行时只报告排序后的首个不匹配项,不能据此假定其余 Skill 已通过。
- 验证:逐项按排序后的 `relativePath + NUL + canonical UTF-8 LF bytes + NUL` 重算并核对 manifestRust 单测同时覆盖 LF / CRLF 指纹等价、安装结果只含 LF、POSIX 绝对路径、`..``C:\\...``C:/...`、UNC 和反斜杠相对路径,受控 MCP 工具也必须把 Windows 绝对路径投影为 `isError=true`
## Gitea CI 预构建镜像不能只靠 tag 判断内容
- 现象:宿主已重建带日期修订 tag 的 `genarrative/gitea-project-ci` 镜像,但 `genarrative-ci` job 仍跑旧内容,或直接报 image not found;另一种危险操作是只改 runner label,没把对应镜像装入 rootless runner 的内层 Docker。
- 原因:宿主 Docker 和 runner 内层 Docker 是两个镜像库,同名 tag 可指向不同 Image ID。基础镜像 digest 和 Node tarball 哈希能锁定关键输入,但重建后仍必须把最终完整 Image ID 当作 runner 映射的事实源,不能从 tag 名推断二进制内容。
- 处理:使用 `scripts/gitea-ci-job-image.sh build/verify`,用 `export` 在仓库外保存镜像归档与便携 SHA-256 sidecar,再用 `load-runner` 将镜像导入内层、比对两侧 Image ID 并执行 bwrap / Chrome canary。确认无活跃 job 后,把当前 config 备份到仓库外受控位置,再将 `genarrative-ci` 映射到新的 `docker://sha256:...` 并执行 `docker restart --timeout 660 gitea-runner`。保持内层 Docker 持久化和 `force_pull: false`;精确 ID 缺失时失败关闭,不回退浮动 tag。
- 验证与回滚:重启后先跑真实 PR 的四个 job,再清理旧镜像。失败时先把 workflow `runs-on` 改回 `ubuntu-latest`,再恢复 runner config 备份并重启;不在 Git、共享文档或日志中记录 config 备份路径、注册信息或 token。
- 重启边界:`docker restart --timeout 660` 只设置容器停止宽限,不能替代 Runner drain。rootless DinD supervisor 可能与 runner 同时停止内层 dockerd,使仍在收尾的 job 因连接关闭被标记失败;切换前必须同时确认 Gitea 没有 `in_progress` run 且内层 `docker ps` 为空。误触发时只重跑受影响的失败 job,不重跑已成功项。
- 关联:`deploy/container/README.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md``docs/project-memory/shared-memory/development-workflow.md`
## 生产 API 发布重装 worker unit 不能丢失自定义路径(2026-07-23)
- 现象:Server-Provision 已按自定义 current link 和 env 路径安装 worker systemd unit,但下一次 API 发布后,worker 可能重新读取 `/opt/genarrative/current``/etc/genarrative/*.env`;默认路径仍有旧 release 时,服务 active 和部署成功都不能证明新二进制已运行。
- 原因:发布包中的 BgFilter、external-generation worker 和 controller unit 是带默认路径的模板;deploy 若直接 `install` 原文件,会覆盖 provision 已渲染的目标机 unit。external-generation 专属 env 还是可选加载,错误路径可能不会阻止服务进入 active。
- 处理:API deploy 安装三个 unit 前必须按本次 current、API env 和各角色 env 参数渲染临时文件,安装后保留 release 内原始模板不变;controller 自定义 env 由 `--controller-env-file` 显式传入。API Deploy 与 Full Job 必须同步暴露并透传 controller/BgFilter env,不能让流水线回退默认路径。自定义服务名表示沿用目标机自管 unit,不进入默认 unit 安装分支。
- 验证:部署 guard 使用临时自定义绝对路径,直接读取实际安装目录中的三个 unit,核对 `WorkingDirectory``ExecStart`、共享 API env 与角色 env,不能只用 fake `systemctl is-active` 判绿。
- 关联:`scripts/deploy/production-api-deploy.sh``scripts/check-production-api-deploy.mjs``scripts/jenkins-server-provision.sh``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 通用灰度后台页不能依赖已退役业务配置
- 现象:通用 `feature_gate_config``/admin/api/feature-gates` 和现役功能 gate 仍在,但后台“灰度发布”Tab 随旧创作模板入口一起消失;Rust 权限仍可授予 `gray-release`,前端却没有对应路由。
- 原因:灰度页同时请求通用 gate 与旧 `/admin/api/creation-entry/config`,并把 `creation-entry:*` 动态目标和现役固定目标混在同一页面;按页面清理旧入口时连带摘除了通用控制面。
- 处理:灰度页只能以 `/admin/api/feature-gates` 为数据源,固定目标列表只登记现役功能;新增或退役业务 target 只修改固定目标注册,不得让通用页面依赖业务列表接口。旧 `creation-entry:*` 目标、接口和页面保持退役。
- 验证:`adminRoutes` 必须包含 `gray-release`admin-web TypeScript/ESLint/Vitest 不得排除灰度页;页面测试必须断言只请求 feature-gates,并继续覆盖现役固定 target、直接 Gate Key 保存与新 target 状态重置。
- 关联:`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx``apps/admin-web/src/app/adminRoutes.ts``server-rs/crates/api-server/src/modules/admin.rs``docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`
## 角色动作不能靠素材主图或通用生成输入恢复
- 现象:角色动作在整画布导出时正常,但从素材库单项下载只得到第一帧 PNG,拖回画布也成为普通静态图片。
- 原因:`editor_asset.image_src` 只指向首帧;若 worker 把完整帧集塞进 `generation_inputs_json`,素材 DTO、用户输入清洗或画布布局任一层丢字段,就会退化成 PNG。再增加一个 `mediaType` 只能掩盖结果字段没有落到正式资源的问题。
- 处理:worker 只把完整帧集与图片序列毫秒时长写入 `editor_project_resource` / `editor_asset``image_sequence_frames_json``image_sequence_duration_ms`;数组位置是唯一帧序,不保存 `frameIndex`,帧数和 FPS 均按需派生。`assetKind=character-animation` 决定序列渲染。素材映射、单项下载和拖回画布只读取这两个正式字段,项目 resource 在保存 / 刷新后继续作为主真相;动作 layout 只保留资源引用和 placement,不再复制正式媒体结果。账号素材提交精选审核时,`editor_showcase_asset` 必须冻结复制相同字段,公开 read model 只返回正式字段。外部 helper 只调用一次动作生成接口并直接使用响应 `resource` / `asset`
- 画布回填:角色动作会形成“原角色资源 → 预览视频资源 → 最终序列资源”的血缘链。生成响应必须返回已经持久化的最终 resource,前端图层直接使用其 `resourceId`;不能继续构造 `local-resource-character-animation-*`,否则 `appendCanvasLayersWithResources` 会再次创建重复资源。新图层的 `sourceResourceId` 同时使用最终 resource 的直接来源(预览视频 resource),不能继续沿用请求中的原角色 resource;否则结构化保存会在已生成并计费后因血缘不一致而拒绝。修复时只替换资源关联与血缘字段,不要顺带把动作图层显示尺寸从生成占位尺寸改成原始帧分辨率。
- 历史处理:不要再在 read mapper 增加 `generationInputs` / layout fallback。使用 migration operator procedure 按 `asset → project-resource → showcase → canvas` 迁移;只有动作身份已由 `assetKind`、正式字段、嵌套 `characterAnimation` 或权威对象证明后,才解释顶层 `frames/durationSeconds`,否则会把无关任意 JSON 误分类。同一 task 可能同时存在误标为动作的预览 MP4 和最终首帧 PNG,候选查找必须先按权威对象类型做计划态分类,排除视频并要求唯一正式图片序列,不能按原始 `assetKind` 计数。账号素材仍有旧帧、但后来拖入画布的 project-resource 只剩清洗后 `fields/references` 时,project-resource dry-run 必须按同 owner / task / 首帧对象精确消费 asset 计划态结果;apply 仍要求前置 asset scope 已物理完成。canvas 判断已有 resource 是否为动作时也必须消费 project-resource 的计划态类型:旧库误标为动作、但权威对象证明为 preview MP4 且 layout 本身是 video 的图层直接跳过动作清理;layout 明确为 `image-sequence` 却指向该视频时继续形成 blocker,资源规划本身有 blocker 时也不得静默跳过。正式序列还要逐帧用稳定对象路径匹配同 owner / task 的已登记图片对象并补齐 `objectKey/assetObjectId`。迁移不得验证 layout 复制的 `sourceResourceId`:历史 layer 可能仍指向原角色,而最终素材已指向预览资源;清理副本后采用最终素材的 DB 血缘即可,新生成链路仍保持严格校验。正式与旧版结果冲突、候选为零或多个均形成 blocker;脚本诊断应直接打印 scope、ID、原因、owner/project/task、对象身份和来源资源,不能只报 blocker ID。普通 layer 顶层 `mediaType` 在迁移和响应清洗时删除,但嵌套生成参考的 `mediaType` 保留。
- 新写入与验证:动作 `generationInputs` 出现 `characterAnimation/frames/previewVideoPath/frameCount/fps/durationSeconds/screenColorHex`,或正式帧出现 `frameIndex`HTTP 与 storage 双层拒绝;其它 asset kind 的任意 JSON 不受该动作门禁影响。测试覆盖两种历史 JSON、无关顶层同名字段、正式/旧版相等与冲突、可选帧引用合并、screen color 和 frameIndex 清理、预览 MP4 重分类、幂等、blocker/hash apply、画布 placement 清理/资源补建、正式字段缺失失败关闭和 helper 单请求。
## 图片序列时长不要复用通用媒体秒数
- 现象:把角色动作、视频、音频和上传媒体都写进通用 `duration_seconds`,随后又尝试用持久化 `frame_count/fps/duration_seconds` 互相校验,造成取整口径、生成参数和实际播放时长彼此污染。
- 原因:角色动作需要的是一组图片完整播放一次的精确时长;视频 / 音频的 `durationSeconds` 是生成请求或临时运行态参数。帧数已经由数组长度唯一确定,FPS 也可按需要推导,无需维护三份可冲突真相。
- 处理:资源 / 素材只保存 `image_sequence_frames_json``image_sequence_duration_ms`,精选审核快照只冻结复制这两个正式字段。角色动作要求至少两帧且毫秒时长大于 0;播放器按 `时长毫秒 / 数组长度` 计算间隔,Spine 导出时再换算秒数并推导 FPS。音频 / 视频 `durationSeconds` 不映射到这两个字段。
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs``src/components/image-editor/ImageCanvasWorldView.tsx``src/components/image-editor/ImageCanvasExportModel.ts`
## 精选角色动作显示首帧还要检查前端 renderer 与逐帧授权
- 现象:精选接口已经返回 `imageSequenceFrames` 和正确的 5 / 6 秒成本,但创作主页或后台审核仍只显示首帧;接入播放器后又可能只有第一帧成功、后续帧换签返回 404。
- 原因:快照字段、展示 renderer 和私有对象授权是三道独立边界。公开 `imageSrc/objectKey` 只代表首帧,不能让前端自动获得完整帧集;顶层精选 exact grant 也不会自动覆盖其它帧对象。
- 处理:公开精选模型必须把 `assetKind=character-animation` 映射到序列 renderer,并携带完整帧与毫秒时长;后台素材查询和精选审核共同透传同一字段并复用 `AdminEditorAssetMedia`。生成端不能在 `ProcessedEditorCharacterAnimationFrame → EditorCharacterAnimationFramePayload` 收口时丢弃逐帧 `assetObjectId/objectKey`,正式序列 JSON 必须保留已确认对象的稳定引用。公开授权在 SpacetimeDB 同一事务快照中只按有效精选动作的同 owner 逐帧 `assetObjectId/objectKey` 匹配,不能放宽 generated 前缀。列表未交互时只读首帧,打开或激活动作预览后也只挂载当前帧和有界预读窗口,避免再次制造换签突发;单帧换签或解码失败时跳过该帧、暂停全帧失败的序列并提供显式重试,不能长期显示空白或旧帧。卡片 hover 与 focus 分别跟踪,只要任一状态仍成立就继续播放,系统请求 `prefers-reduced-motion` 时卡片和弹窗默认暂停,用户仍可在弹窗中手动播放。
- 验证:模型 / 组件测试覆盖 4 / 5 / 6 秒动作、损坏序列不回退 PNG、后台两页共用播放器和未激活列表不逐帧请求;SpacetimeDB 测试覆盖主对象、每帧对象、无关对象、跨 owner 与取消展示后的授权撤销。真实浏览器和端到端验收由人工单独执行,不把 unit / component 结果写成 E2E PASS。
## 可复用资源回填必须保持时间戳单调
- 现象:延迟重试携带比既有行更旧的调用方时间,回填图片序列字段时若无条件写入,会使 `updated_at` 倒退,导致基于时间戳的同步看不到更新或排序错误。
- 处理:同源图片序列字段只允许 `None → Some`,非空冲突失败关闭;发生回填时 `updated_at = max(existing.updated_at, request_timestamp)`。legacy 音频 repair 不派生资源级图片序列或通用时长,重放继续精确匹配。
## 历史钱包消费不能从最近流水或通用订单快照推算
- 现象:后台用户详情要展示累计花费时,直接复用只返回最近 50 条的 `list_profile_wallet_ledger`,或在充值订单每行使用的通用钱包快照里扫描该用户全部流水。
- 原因:最近流水会低估历史总额;通用钱包快照又会被订单列表反复构造,把一次按用户聚合放大为 `订单数 × 流水数` 的重复扫描。
- 处理:历史花费只累计 `asset_operation_consume` 负向流水绝对值,退款不冲减;通过 `profile_wallet_consumption_total` 在已有投影时按主键 O(1) 累加。首次上线必须在停写维护窗口由 owner 执行全量初始化,为每个已有钱包流水的用户建立投影,不能让所有存量用户的首次正常消费各自扫描历史;维护遗漏或新用户缺行时才在首次消费或详情读取中按用户索引兜底重建一次。手动对账扫描是独立高风险操作,member 必须单独持有 `profile-wallet-consumption-reconcile`,不能因为能打开共享用户详情就自动获得。
- 验证:构造消费、退款、充值退款追回和赠送混合流水,断言只累计消费;维护初始化后正常消费只按主键累加;重复详情读取不得重复扫描或重复累计;任意 Tab 权限不能调用手动对账,同时确认充值订单列表的通用钱包快照没有新增历史流水扫描。
- 症状:`code-prototype` 首次完成后 `.agent/logs/command.log` 已出现 `permission.confirm preview.start`,但客户端没有 iframe`.agent/logs/preview.log` 也没有新的 running 记录;后续即使父 run 完成也不再启动。
- 根因:旧实现调用 `start_local_game_preview` 前就把“项目 + parent run”的授权加入 attempted 集合并清空;首版完成投影与后续专业任务仍在写项目时,启动恰逢项目写锁竞争,catch 只显示错误却无法重试。
- 约束:一次性语义应按“成功或确定性终态”消费,不按“函数调用次数”消费。项目写锁竞争保留同一授权并轮询重试;成功、显式 deny 与非瞬时失败才清除。授权需持久化项目路径和 accepted runId,重启恢复时仍必须逐项匹配,切换项目不得继承。
- 回归:AppSurface 模拟第一次 `start_local_game_preview` 返回 `项目正在被其他写操作占用`、第二次成功,断言最终渲染游戏区域且启动调用恰为两次;完整 AppSurface 仍需覆盖显式 deny、停止隐藏与项目切换隔离。
- CI 时序:生产预览状态每 `1000ms` 轮询一次,回归若也使用 `waitFor` 默认 `1000ms` 上限,会在 CI 负载下于首次 interval 回调附近竞争超时。验证“授权保留期间仍继续轮询”应使用明确 `3000ms` 上限,不改生产轮询周期。合并长测试文件后还要运行全量 ESLint;单纯 autofix 只会排序、不会消除两个分支同时引入的重复 import。
## 跨窗口 CAS 锁不能用 mtime stale 删除模拟系统互斥(2026-07-30
- 现象:两个窗口基于同一 revision 保存资源布局时,正常测试看似只有一个成功;锁文件超过 stale 阈值或两个竞争者同时判断过期时,却可能各自删除 / 重建锁并同时进入 read-check-write,击穿“同 revision 最多一个成功”。无效绝对路径还会在 manifest 报错前遗留 `.agent/workbench/resource-layouts`
- 原因:`create_new` 只保证某一时刻创建文件原子,不保证“判断过期 → 删除 → 重建”整体原子;mtime 不能证明 owner 已退出,token 文本也不能阻止另一个竞争者删除新锁。先获取锁再读 manifest 又把目录创建副作用提前到了项目身份验证之前。
- 处理:锁文件作为持久入口永不由应用删除;Unix 用文件描述符持有 `flock(LOCK_EX | LOCK_NB)`Windows 用 `share_mode(0)` 独占句柄,Drop / 进程退出让操作系统释放锁。安全打开逐级拒绝符号链接 / reparse pointUnix 还核对 owner、硬链接数、inode 和 `0600`。更新携带只用于校验的 `expectedProjectId`,先只读验证 manifest,再获取系统锁并在锁内复核 projectId;不存在根、非项目根、损坏 manifest 和路径复用后的旧窗口都不能创建 workbench。revision 必须 checked increment,耗尽时不能饱和成功。
- 验证:必须覆盖活锁 mtime 被设为 epoch 后竞争者仍拿不到锁、释放后同一 inode 可重新获取、同 revision 并发双写仍恰好一个 updated / 一个 conflict,三类无效根和旧 projectId 零 workbench 副作用,以及 `u64::MAX` revision 保持原文件。锁等待超时只能返回可重试错误,不得转为 stale 删除。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs``docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`
## 旧 scope 的卡死请求不能占住新资源画布队列(2026-07-30)
- 现象:用户在 dependency 布局保存尚未返回时切到 type 或另一个项目,新 scope 已完成读取且拖动已进入队列,但因为全局活动请求引用仍指向旧 scope,新的保存会无限等待旧请求结束。
- 原因:epoch 只阻止迟到响应覆盖新状态,不会自动释放前端单写者槽;把“不能取消已经发出的请求”误写成“所有后续 scope 都必须等待它”,会把一个网络或 IPC 卡死扩大到整个 Hook 生命周期。
- 处理:FIFO 和单写者只约束同一 `projectPath + projectId + mode` scope。切换 scope 或卸载时立即放弃旧活动槽并清空旧队列,旧 Promise 仍可在后台结束,但其结果由 epoch 丢弃,finally 也只能按意图身份清理自己,不能清掉新 scope 的活动请求。后端继续用 `expectedProjectId`、CAS revision 和系统锁仲裁已经发出的旧写入。同 scope 在途 CAS 不取消;其后相同资源与 section 的排队拖动只保留最后坐标,避免连续输入造成无界队列。
- 验证:让旧 mode 更新 Promise 永不先 resolve,切换 mode 后应立即发送并完成新 mode CAS;随后再 resolve 旧请求,新布局、saving 状态和请求数均不得变化。另以百次同资源拖动证明在途请求之后只追加一笔、坐标为最后一次输入。
- 关联:`apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts``apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts`
## 资源协调冲突清除排队拖动时不能静默重试(2026-07-31)
- 现象:资源协调 CAS 在途期间,用户拖动资源形成排队 manual intent;协调请求随后 conflict 并基于权威 revision 自动重试成功,布局正确保留另一窗口结果,但界面没有提示本地拖动已经被丢弃。
- 原因:冲突分支虽然清除了当前 scope 的全部 manual intent,却只按“当前 intent 是否为 manual”或“资源协调是否停止重试”决定提示;当前 intent 为 resources 且可重试时,排队拖动的丢弃事实没有进入提示条件。
- 处理:过滤队列前记录本次是否实际清除了 manual intent。只要当前 manual 发生冲突或清除了任何排队 manual,就必须提示用户重新拖动;冲突前坐标不得自动重放,后续资源协调继续使用冲突响应的权威 revision,并且成功响应不能静默清除提示。
- 验证:定向 Hook 测试固定“resource sync revision 1 在途、manual 排队、权威 revision 2 conflict、resource retry 成功”时序,断言 retry 使用 revision 2、权威坐标保留、旧 manual 不重放且 notice 仍存在。
- 关联:`apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts``apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts`
## Rust u64 revision 不能直接穿过 JavaScript number 边界(2026-07-31
- 现象:资源布局 sidecar 的 revision 在 Rust 中可增长到完整 `u64`,但经 JSON / Tauri 返回 TypeScript 后只能用 `number` 表示;超过 `9_007_199_254_740_991` 时相邻整数会折叠为同一值,窗口可能持续 conflict,甚至用失真的 expectedRevision 破坏 CAS 判等语义。
- 原因:Rust 的 `checked_add` 只防止 `u64` 溢出,不能证明序列化后的整数仍能被 JavaScript 精确表示;纯 Rust `u64::MAX` 测试没有经过真实跨 JSON 合同。
- 处理:保留 Rust `u64` 存储类型,但把共享合同合法域冻结为 `0..=Number.MAX_SAFE_INTEGER`。共享 DTO 对 revision 自定义 serde 校验,Tauri 更新在任何项目或锁副作用前验证 expectedRevisionsidecar 读取拒绝超限值,前端在 IPC 读取、更新响应和请求发送前重复验证非负安全整数;达到上限时写入失败且 sidecar 字节不变。
- 验证:Rust 与 TypeScript 合同测试分别覆盖最大安全值往返、最大值加一拒绝;Tauri 持久层覆盖超限 expectedRevision 零 workbench 副作用、超限 sidecar 原字节保留和最大安全值递增失败;Hook 覆盖不可信读写响应不能进入 CAS。
- 关联:`server-rs/crates/shared-contracts/src/game_creation_app.rs``packages/shared/src/contracts/gameCreationApp.ts``apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs``apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts`
## 抽通用 Runtime 时不要把产品持久文件直接变成公共 ABI
- 现象:为了快速“抽 crate”,直接把 Tauri package 内的 `AgentRuntimeState`、sidecar struct 或 Runner protocol 改成 `pub`,第二个消费者虽然能编译,却同时绑定游戏 schema、UI 投影、文件路径和未稳定恢复顺序。
- 原因:代码可见性被误当成领域解耦;产品私有 DTO 中仍混有 `game-creator-*` schema、固定 profile、Provider 类型和本地持久化细节,公开后只会把后续迁移变成 breaking change。
- 处理:先从纯值对象和宿主注入契约抽取,公共 core 不依赖 Tauri、Provider DTO 或游戏 crate;产品通过 adapter 注册 capability、Agent、profile 和 completion policy。Store/Runner 等只有在事实源、事务和迁移协议单独稳定后再抽接口,不能双写或复制 sidecar。
- 验证:必须存在完全不含游戏语义的 conformance fixture,并让至少一个现役生产入口真实消费公共契约;只新增未被调用的 crate、`include!`、路径搬家或旧类型 re-export 都不算完成。
## Runtime 不能在外部工具返回后才首次记录执行意图
- 现象:Runtime 调用工具成功后准备写 observation,但进程在写入前崩溃;重启后只看到 queued action,于是再执行一次外部副作用。
- 根因:把“调用返回”当成 durable 事实,缺少工具调用前的持久 executing checkpoint;网络、文件、命令和外部 API 都不能因为“看起来幂等”就自动重放。
- 处理:先以 CAS 单独 commit `queued -> executing`,成功后才调 ToolHost;调用返回后再 commit observation。恢复见到 executing 或 ToolHost 返回 Unknown 时只能进入 reconciliation,不得自动重执行。重复 resume 不得继续增 revision 或重复 event。
- 验证:在“ToolHost 已调用、observation commit 失败”处注入故障,序列化快照并用新 engine 重载;断言重复 resume 后 ToolHost 计数仍为 1,且只有显式 reconcile observation 才恢复 running。
## Runtime 后台执行不能让大型 async frame 共用默认 worker 栈(2026-08-03
- 现象:Supervisor collaboration durable isolated spawn 恢复测试或普通 `agent.delegate` 后台委派测试在默认 Tokio worker 栈下稳定 `stack overflow`;单独运行同样失败,提高 `RUST_MIN_STACK` 后通过。
- 原因:不是业务递归。debug 构建中 pending action continuation、后台 task queue、Agent 主循环,以及 Provider、Codex CLI、Codex app-server 组合模式分发的最大分支状态都会形成大型 async poll frame;恢复路径直接进入下一层状态机、普通后台任务把完整主循环放回默认 worker,或组合 future 进入泛型 helper,都会超过默认栈。
- 处理:整个 pending continuation、它进入的后台主循环,以及完成、取消或失败后 drain 同 Agent 后续队列时,都必须跨越独立 Tokio task 轮询边界,使上层 poll 先退栈后再轮询下一层状态机。传入边界的 future 必须先装箱;若泛型 helper 直接持有大型 future,即使随后 `spawn`,调用方 async frame 仍会把它保留在默认 worker 栈上。普通后台任务、静态委派子任务和 manifest ready-task 的首次执行统一复用 16 MiB 专用 Runtime worker,并在 worker 已启动后交接 Agent 任务锁;worker 创建或交接失败要持久化当前 run 失败。Provider 物理请求必须在持久重试 helper 与非持久压缩路径构造完整请求后、进入下层泛型 control/lifecycle helper 前装箱,不能等到底层 helper 才装箱。pending 边界继续保留结构化取消语义,父 continuation 被丢弃时同步 abort 子任务。不得逐个扩大 queue worker 栈,也不得增大 CI 的 `RUST_MIN_STACK` 掩盖问题,否则生产路径仍可能崩溃。**(2026-08-15 修订)判据从「逐个列举入口」改为不变量:所有会进入 Agent 主循环的 future 必须在 `agent-runtime-worker-*` 专用线程上轮询。** 原文按入口枚举(普通后台任务、静态委派子任务、manifest ready-task 首次执行),但**恢复重启是第四个入口,从未被列进去**——`recovery_scan.rs` 手写 `tauri::async_runtime::spawn` 直接跑 `drain_game_creator_agent_background_tasks`,把与 started 入口同样深的 poll 链放在默认 2 MiB worker 上;队列 drain`spawn_next_..._with_lock`)同样留在默认栈。两条当时都还塞得下,直到 `M1B-2` 往主循环与恢复扫描加分支把余量吃穿才暴露。**枚举法漏掉一个入口不会产生任何信号**,因此改为统一常量 `AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES` 加单一 spawn helper;承载主循环的路径一律不得再手写 `tauri::async_runtime::spawn`
- 验证:失败用例必须在未设置 `RUST_MIN_STACK` 时通过;同时覆盖普通后台委派、policy batch 全组、拒绝 pending 后重规划并 drain 下一任务,以及 pending/cancellation 回归,证明任务锁只交接一次、恢复不重复生成 isolated spawn、队列继续推进且父任务取消不遗留后台子任务。**(2026-08-15 补)只断言「默认栈下没崩」不够**——余量仅剩几百字节时它依然是绿的,这次崩溃前全部用例都通过,master 侧 `drain_next_*` 只剩 512~768 KiB 余量也毫无信号。必须同时**断言线程名**:drain 入口在 `#[cfg(test)]` 下记录 `std::thread::current().name()`,用例断言其全部以 `agent-runtime-worker-` 开头。该断言与栈余量无关,已用变异验证:把 `recovery_scan.rs` 改回手写 spawn 并把 `RUST_MIN_STACK` 抬到 16 MiB(因而不会溢出),用例仍以 `["tokio-rt-worker", "tokio-rt-worker"]` 失败。修复后的验收标准是「压到 1 MiB 默认栈仍通过」,而不是「默认栈下没崩」。另需运行 `background_agent_runtime_can_delegate_task_to_other_agent``provider_retry_``provider_handoff_``response_stream_` 与 Native shell 完整门禁,全部以默认 worker 栈通过。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs`
## Provider 可扩展不能用一个全局 protocol 枚举代替实例隔离
- 现象:把 `openai_chat / openai_responses / anthropic` 直接当 Provider 身份,注册第二个同协议 endpoint 时发生 ID 冲突;或为方便调用把 API Key、base URL、raw-log 目录放进全局状态,并行请求后日志串目录。
- 原因:wire protocol 是 adapter 能力,Provider instance 才是配置与资源所有者;两者被闭集枚举合并后,无法表达同协议多租户/多 endpoint。
- 处理:core 同时校验 `ProviderInstanceId + ProviderProtocolId`registry 只以 instance ID 索引 adapteradapter 内持有独立 `LlmClient`。新协议通过实现 trait 注册,新实例通过自定义 instance ID 注册,都不得修改 core match。
- 验证:至少同时注册两个同 protocol 实例,证明 descriptor/lookup 互不污染;对每项未声明能力断言 adapter 调用计数为 0;并行 raw-log 测试必须使用两个显式临时目录,不用串行化掩盖错误路由。
## Provider 适配器不能重新实现一份 HTTP/SSE parser
- 现象:为了让 Runtime 调用中立 trait,在 core 或 AGC 内再拼一次 URL/header/request body,或自己消费 SSE;它会与 `platform-llm` 的重试、脱敏、工具分片和错误分类迅速漂移。
- 处理:adapter 只做 core DTO 与现有 `Llm*` DTO 转换,网络调用唯一落到 `LlmClient::run/stream_run`。流式 sink 保留累计文本、当前增量和 finish reasontool calls 继续从最终 response 读取。
- 验证:三种 descriptor/capability、request/response round-trip、stream callback 和稳定 error kind 单测后,仍必须运行 `platform-llm` 全量 parser 测试;只有 adapter fake 通过不能证明 wire 协议没有回归。
## 内部处理模型的可见性过滤是标题精确匹配,不是语义识别
- 现象:读文档以为「内部处理模型不会展示给普通用户」是全覆盖保证,实际历史素材的图片信息弹窗和画布 ZIP 导出里仍能看到抠图模型,例如标题“抠图模型”、取值 `动漫风格 anime-seg`
- 原因:`isEditorUserVisibleGenerationInputField` 的实现是 `field.title.trim() !== '处理模型'`,只按这一个标题字符串精确排除,既不识别语义也不探测取值。历史数据里存在标题不同但语义相同的字段,直接穿过过滤器;服务端 User/Public mapper 只清理 `generationInputs` 顶层的 `screenColorHex` / `mattingProvider` / `mattingModel`,不遍历 `fields` 数组,所以两侧都不会拦。
- 处理:本条目前不修——历史素材不迁移、不回溯清理是明确的产品决策,不得据此判定为缺陷或提交「修复」。约束只对新写入生效:新产生的 `generationInputs.fields` 不得再写入任何内部处理模型字段,无论标题叫什么。若将来要扩大过滤范围,先对生产 `generation_inputs_json` 做一次标题去重查询枚举真实存在的历史标题,不要仅凭测试夹具推断清单。
- 验证:`ImageCanvasMetadataModalView``ImageCanvasExportModel` 共用同一过滤口径,改动其一必须同时覆盖另一侧;新增过滤标题时需同时确认图片信息弹窗与画布 ZIP 两条路径。
- 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts``isEditorUserVisibleGenerationInputField`)、`src/components/image-editor/ImageCanvasExportModel.ts``src/components/image-editor/ImageCanvasMetadataModalView.tsx``src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 外部 OpenAPI v1 的「无调用方」豁免不是受控状态
- 现象:认为外部 v1 契约可以随内部脱敏需要直接改,因为「反正没人接」;`docs/openapi/genarrative-external-v1.openapi.json` 被当成内部文档同步,不走版本流程。
- 原因:API Key 由用户在个人中心 `我的 → 开发者 API Key` 自助发放,`/api/external/v1/openapi.json` 又是该批路由里唯一不要求鉴权的端点,任何登录用户都能拉规格并生成客户端。因此「无外部调用方」随时可能在无人决策的情况下变为假,不能当作长期前提。
- 处理:改外部 v1 响应前先确认 `external_api_key` 是否已有非内部账号的活跃密钥。仍无调用方时可按现行豁免直接改,但必须同步更新接入方案的「版本与兼容策略」;已有调用方时按该节规则择一处理(兼容值 / 弃用期 / 升 v2),只改 JSON 不构成合规变更。
- 验证:`external_editor_api.rs` 的 openapi 断言只校验 schema 形状,不校验兼容性,通过不等于契约安全;判定 breaking 与否以「删字段、移出 required、收窄类型、改语义、新增必填」为准。
- 关联:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md``docs/openapi/genarrative-external-v1.openapi.json``server-rs/crates/api-server/src/external_editor_api.rs``server-rs/crates/api-server/src/modules/external_api.rs`
## “继续”不能成为新游戏主题或触发首版整文件覆盖(2026-08-03)
- 现象:原根 run 已经写出并验证目标玩法,但父 Runtime 因预算、上下文或 Provider 失败;用户在同一项目输入“继续”后,页面标题变成“继续”,玩法被默认收集/点击模板替换,美术规范总览图被直接铺进游戏画面。
- 处理:严格继续意图必须在同一 Supervisor Session、同一持久 source 内继承最近失败根 run 的原始目标和 baseline,但保持新的 run/Provider/sidecar 身份;纯继续词表只能有一个权威实现,中英文短语都走同一入口,真正新需求仍独立 reset。非占位入口禁止 fallback 整体覆盖,也不能反复运行只读 smoke;当前 `code-prototype` 必须先读取并实际 patch,取得本人 mutation 后才能验证和交付。占位 fallback 只支持具备真实语义的显式模板,俄罗斯方块必须实际实现棋盘、下落、旋转、锁定和消行,未知玩法失败关闭。`art-spec.png` 只作规范参考,核心运行时位图必须来自独立派生的透明 `art-spritesheet.png` 及其 `iconImageSrcs` 本地切片;切片清单绑定当前图集 resourceId,Canvas 分别使用玩家、目标、场景和反馈四类素材。不得猜测图集是 2×2 等分、把规范板塞进画面或以纯代码核心实体绕过派生素材。
- 验证:覆盖失败根任务“水晶俄罗斯方块”后输入“继续”、连续 successor、跨 Session、跨 source、正常完成后新输入、带具体新需求、既有非占位入口先 patch 后 smoke、初始化占位的俄罗斯方块真实语义、未知玩法失败关闭、纯继续目标缺失、规范图不在运行 DOM/Canvas、真实动作前后 `sequence` 与 RAF 空转。浏览器验收必须同时比较 baseline 玩法关键文本/控件/状态和当前 revision,不能只看 Canvas 非空与三个固定按钮。
- 现象:用户要求把已有美术资源接入游戏时,固定 `code-director -> art-director / art-asset-plan -> code-prototype` 图会在缺少主 Agent 审计的情况下启动美术生成,或把“整体重做”错误实现为无条件生图;美术完成后又换了 Run,代码接入、静态检查和试玩无法形成连续责任链。
- 原因:固定节点把“是否需要美术”的语义判断编码为 Runtime 前置流程,`code-director` 成为另一个主控,而不是让真正接入游戏的 `code-prototype` 基于权威资产事实决策;如果再把固定审计策略塞进用户意图字段,Supervisor 的理解也会被 Runtime 规则覆盖。两个素材槽都缺失时若先消耗不可重试的 `art-asset-plan` 委派,其 child 又必然因缺规范图失败,整个 Run 会进入无法补救的死路。
- 处理:Supervisor 用 `intentSummary` 持久化自己对用户意图的理解,固定 `audit-existing-first` 只作安全执行策略,随后只启动 `code-prototype`。主 Agent 先 `asset.list`,完整覆盖就直接使用;仅在事实证明缺少规范图或核心图集时,才建立一个写入范围受限为 `assets/**` 的美术 durable delivery。两槽都缺失时必须先完成并认领 `art-director``EvidenceReady` delivery,再委派 `art-asset-plan`;回执返回同一主 Run 后再接入素材、原玩法语义检查、静态检查和双视口试玩。读取旧 v1 决策时必须复核其旧 fingerprint,并从完成合同绑定的有效任务迁移 intent;旧 `code-director` coverage/route 只能触发当前主 Agent 重新审计和原位替换,不能直接成为新完成证据。整体视觉重做意图同样必须经过这次审计,不能成为绕过资产复用或强制重生成的固定规则。完整 GUI / CLI DAG 不使用该例外。
- 验证:正反向测试同时证明 `intentSummary` 非空且不被固定策略代替、v1 决策与旧 route 同根恢复、完整资产零委派、真实缺口精确委派、两槽缺失时规范图优先、单个活跃 child、`game/**` 写拒绝、`assets/**` 写允许、回执恢复同一主 Run 和最终主 Agent 自验收;不能只凭 Prompt 出现关键词或 manifest 状态投影判通过。
## Tauri beforeDevCommand 失败不等于已启动客户端会自动退出(2026-08-03)
- 原因:Tauri 的字符串 `beforeDevCommand` 默认 `wait=false`。只要固定 `devUrl` 上已有可访问页面,Tauri CLI 可以在配套启动脚本完成前创建原生窗口;旧实现又直接从 npm 启动 Tauri CLI,没有在 CLI leader 退出后继续持有其 PGID / Windows 进程树。`start-dev-stack.mjs` 虽会在后端 ready 后识别 marker/API 错配,但检查时机已经晚于窗口创建,且只清理自己登记的后端和 Vite。
- 2026-08-08 后续统一:上述 `3080` 是事故发生时的历史实现,不再是当前 Linux 启动口径。AGC Vite 已纳入系统级用户端口段,首选 `start + 5`,占用时只在本用户段内漂移;外层启动器把最终端口写入 Tauri CLI 动态 `build.devUrl` 和子进程 `GENARRATIVE_AGC_VITE_PORT`,并用 Vite CLI `--port` 启动严格监听。`beforeDevCommand`、配套后端预留、WebView 与 Vite `strictPort` 必须使用同一值。Windows / macOS 仅把 `3080` 保留为兼容首选并允许统一漂移。未知归属监听器仍不得复用或主动终止,但其它用户固定 `3080` 不再阻塞 Linux 当前用户启动。
- Linux 容器边界:最小化 CI 容器的 PID 1 可能不回收孤儿后代,进程组在所有可执行成员退出后仍只剩 `Z` 僵尸;此时 `kill(-pgid, 0)` 仍成功,不能据此把已经完成的收束误报为失败。Linux 等待逻辑在 signal 探活后必须核对 `/proc/<pid>/stat`,只把同 PGID 的非 `Z / X` 成员视为存活;`/proc` 不可读时继续使用原保守判断,macOS 等其它 POSIX 平台仍只走 signal 探活。
- 验证:定向测试必须覆盖用户段 `start + 5` 映射、同段占用漂移、父子启动器严格复用最终端口、动态 Tauri `--config`、marker 与预检地址一致、未知归属监听器拒绝复用、CLI leader 先退出后同 PGID 客户端仍收到 TERM、忽略 TERM 时升级 KILL,以及 Windows taskkill 的 `/PID /T /F` 参数。正常启动后退出,确认 Tauri 客户端、Runner 和本轮自有后端 / Vite 均按生命周期收束。
- 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs``apps/ai-game-creator-shell/scripts/start-dev-stack.mjs``apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts``apps/ai-game-creator-shell/tests/start-dev-stack.test.ts`
## Git 忽略的 AGC dist 会让 Windows 继续运行旧 Linux 路径交互(2026-08-14
- 现象:源码已经移除项目页常驻路径输入框,Windows 客户端却仍显示 `/tmp/genarrative-ai-game-draft`,新“打开项目 / 新建项目”交互也没有出现。
- 原因:`apps/ai-game-creator-shell/dist/` 是 Git 忽略的本地构建产物,可能跨提交保留旧 JS;复用旧 `dist`、旧 EXE 或旧安装包时,Tauri 会继续嵌入旧前端。测试模式曾把 `/tmp` 同时当作产品初值,也让旧构建和测试夹具的边界难以辨认。
- 验证:运行 frontend dist guard 定向测试、AGC AppSurface 的双主按钮 / picker 防重复 / 首页回车自动创建回归、Tauri release `--no-bundle` smoke,并确认新 `dist` 不含旧路径;Windows 实机项目组不得出现常驻路径框或 `/tmp`,原生 picker 从系统默认位置打开。视觉验收检查 `1280×720` 最小横屏与 `1280×800` 默认窗口的紧凑项目表格和原生 picker。
- 关联:`apps/ai-game-creator-shell/src/app/constants.ts``apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts``apps/ai-game-creator-shell/src-tauri/src/commands.rs``apps/ai-game-creator-shell/src-tauri/build.rs``apps/ai-game-creator-shell/src-tauri/tauri.conf.json`
## 参考成熟项目管理器不能变成品牌复刻或伪数据列(2026-08-15)
- 现象:根据外部产品截图重做项目页时,直接照搬其 Logo、深色皮肤、收藏 / 云图标、修改时间或编辑器版本列,页面看似成熟却展示 AGC 没有的数据真相,录屏也只剩静态摆拍。
- 原因:把参考截图当成完整产品合同,没有先核对当前目录检查、manifest 和 Runtime 真正提供的字段,也没有定义视频必须证明的交互结果。
- 处理:只借鉴标题、搜索、主操作、紧凑表头 / 项目行和行尾菜单的信息层级;继续使用 Genarrative theme/token,只展示项目名称、工作区路径、GameAgent / Godot 类型、Godot 相对根和真实状态。次要操作收进行尾菜单,搜索只做本地过滤。没有权威来源的列直接不做,不用占位或推断补齐。
- 验证:DOM 与截图不得出现外部品牌或 unsupported 列;AppSurface 覆盖 populated / invalid / empty、搜索与菜单;Playwright 在 `1280×720` 测量无页面级溢出。视频控制在有用时长内,清楚展示搜索、清除、菜单、状态反馈和项目打开结果,每一段都有可观察变化。
- 关联:`apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx``apps/ai-game-creator-shell/src/features/app-shell/model.ts``apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts``apps/ai-game-creator-shell/tests/appSurface/home.suite.ts`
- 现象:首波从单个美术任务扩展为三个 Director 后,hydration 若仍只容忍 seed lane 的第一个任务在 manifest 短暂恢复 `Pending` 时收束,另外两个已启动 Director 会被卡住。另外默认 `llm.stream=false` 下的专业 final reply 虽已由 finalization 提交,但后续阶段推进项目 revision 后,早期回复会从 Runtime 查询中消失。
- 原因:hydration 例外把“首波”错误收窄成了单个固定或数组第一项任务;`visible_game_creator_agent_runtime_response_stream_at` 又把未提交流的 revision 新鲜度门误用到了已终态提交的 durable final reply。
- 处理:从当前 root source 的 seed lane 动态解析全部零依赖首波任务,只对这些 child 容忍 hydration `Pending`,后续 code prototype / preview 仍严格要求 Running/Completed。`streaming / ready` 仍要求当前 revision`committed` 回复改为依据 finalization 的稳定身份查询,不随后续项目 revision 失效。
- 验证:覆盖 `design-director / art-director / code-director` 三个 Pending 首波 child 均可投影 Completed、`code-prototype` Pending 仍被拒绝;非流式专业 Agent 在 finalization 前无 stream,提交后形成 committed stream,再推进项目 revision 后仍可查询且正文不变。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs`
## 异步生成结果未知时不能换幂等键重提(2026-07-31)
- 现象:生成提交发生客户端超时、连接中断或响应丢失后,调用方创建新的 `Idempotency-Key` 再提交一次;原任务其实已经入队,最终造成重复生成、重复扣费和重复画布 / 素材库写入。
- 原因:把“客户端没有收到结果”误判为“服务端没有受理”,又没有持久保留逻辑请求的幂等键和服务端返回的 `operationId`。托管 MCP 若绕过 External REST router 直接调用 worker 或 SpacetimeDB,也会形成第二套去重与状态语义。
- 补充:不能把“accepted 分支里没有生成 POST”误当成 GET-only 恢复。若读取账本前仍重做项目/素材目录准备、输出路径预检或请求正文构造,恢复仍可能创建远端资源或在查询 operation 前失败。恢复必须直接使用 durable snapshot;清理必须最后删除 pending 身份锚点,活动 orphan 不得自动删除。完整恢复 future 还要在默认 Tokio worker 栈下验证,不能靠测试环境调大 `RUST_MIN_STACK` 掩盖栈溢出。
- 加固:durable snapshot 必须绑定不含明文凭据的规范 base URL 服务身份指纹;服务地址漂移时恢复 POST 和 GET 都必须阻断,Developer API Key 轮换则必须继续原 operation。accepted operation 明确 failed 也不能在 observation 持久化前删账本。旧 `200` durable result 只保留允许字段与安全 objectKey/相对路径,签名 URL、query/fragment 和未知字段不落盘。只有首次提交直接返回契约明确的 `400 / 401 / 403` 才可证明未入队并清理 prepared 账本;首次结果已经未知后,恢复请求的临时鉴权错误、超时、冲突、限流、网关错误及其它意外状态均保留同一账本。账本根目录、扫描和删除必须通过受控路径解析逐级拒绝符号链接,不能让项目内链接把清理目标指向项目外。
- 代理 DNS:Clash 等透明代理可能把公网对象存储域名解析到 RFC 2544 的 `198.18.0.0/15` fake-IP。下载器只对已通过鉴权 `objectKey` 或受控 legacy path 换签得到的 URL 接受“全部地址均位于该 benchmark 段”的窄例外;直接 URL、其它本机/私网地址、公私混合解析和重定向仍必须失败关闭,不能为了兼容代理整体移除 SSRF 校验。
- 验证:覆盖“服务端已入队但提交响应丢失”后两次 POST 的 endpoint、正文 bytes 与 `Idempotency-Key` 完全相同,原键重试仍返回同一 operation,最终只出现一份 completed result 和一次计费 / 写回;恢复再次 transport 失败或临时鉴权失败仍保留同一账本;换 owner 不可见;MCP 与 REST 对同一 owner、同一请求和同一键必须命中同一 operation。
- 关联:`server-rs/crates/api-server/src/external_generation.rs``server-rs/crates/api-server/src/external_mcp.rs``docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`
## MCP 列表不能透传完整项目快照(2026-08-07)
- 现象:账号项目数量增长后,`list_editor_projects` 把每个项目的 `canvas / layers / resources` 全量透传,REST 响应超过 MCP 4 MiB 上限,Agent 因整批失败而无法展示、查重或安全选择项目;缺少必填请求体时,内部 Axum JSON extractor 的文本 `415` 又会被泛化成“非 JSON 响应”。
- 处理:项目列表 REST 保持默认 `view=full` 兼容,并提供 `view=summary`MCP 固定使用 summary 且不向 Agent 暴露或接受 `view=full`。摘要只返回 `projectId / title / updatedAt / cover`,封面取最新且存在稳定 `objectKey``project-cover-snapshot`,展示时再调用 `/assets/read-url`,不在列表内嵌图片或签名 URL。MCP 在构造内部 REST 请求前按 OpenAPI schema 校验 required body;缺正文和缺字段分别返回结构化错误,不进入写入、上传票据或计费路径。
- 验证:用 19 个完整序列化后超过 4 MiB 的项目 fixture 证明摘要仍低于上限且不含大型布局;覆盖四个历史 `415` 工具的缺正文、空对象和非对象输入,并断言项目列表工具固定 summary、调用方不能通过 query 覆盖。
- 关联:`server-rs/crates/api-server/src/external_mcp.rs``server-rs/crates/api-server/src/external_editor_api.rs``docs/openapi/genarrative-external-v1.openapi.json``docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`
## api-server 嵌入仓库外资源时必须同步容器构建上下文(2026-07-31)
- 现象:本地 `cargo test` 可以编译 MCP 与 Skill 下载模块,但 api-server 镜像在 Rust 编译阶段报 `include_str!` 找不到 OpenAPI 或 Skill 文件。
- 原因:本地工作树包含完整仓库,而容器 Rust builder 原先只复制 `server-rs/``public/`crate 中向上引用的 `docs/openapi/``.codex/skills/` 不会自动进入镜像构建文件系统。
- 处理:凡 api-server 通过 `include_str!` 使用仓库根目录资源,都要在 `deploy/container/api-server.Dockerfile` 的 builder 阶段显式复制对应权威目录;不要再复制一份内容到 crate 内形成平行事实源。
- 验证:除本地 Cargo 测试外,检查 Dockerfile 构建上下文覆盖所有 `include_str!` 相对路径;新增或移动嵌入资源时同步更新容器 COPY 和接入文档。
- 关联:`deploy/container/api-server.Dockerfile``server-rs/crates/api-server/src/external_mcp.rs``server-rs/crates/api-server/src/external_skill_api.rs``docs/openapi/genarrative-external-v1.openapi.json`
## 权威画布快照不能清掉本地待保存或在途布局(2026-08-03)
- 现象:用户拖动、缩放、改层序、背景色或 viewport 后,生成完成回包立即覆盖画布;450ms 防抖尚未触发或布局保存仍在途时,编辑静默丢失,undo 也可能被生成保护项阻断。
- 原因:服务端 revision 只能排序已提交事实,本地未落库布局没有 revision;直接清空 pending save 并整体应用权威快照等同于把“服务端更新更晚”误判成“服务端知道本地编辑”。
- 处理:保留同项目最新本地 dirty snapshot,权威回包先更新资源和生成终态,再按稳定 item ID 合并本地布局字段并基于新 revision 保存。旧权威项在新快照缺失表示后端删除,不能从 pending 或在途旧输入复活;新权威项必须合入,本地删除的旧项不能从权威回包复活。
- 生成器边界:`composerOpen``status / generatedLayerId / errorMessage` 一样属于后端生命周期事实;生成完成快照要求保持面板关闭时,不得被本地在途快照重新展开。提示词、参数和占位位置等本地布局编辑继续保留。集成测试夹具必须模拟后端真实完成快照:既有布局保持原位,完成结果层追加到末尾。同项目权威刷新还必须保留仍有效的单选、多选、生成占位选择或空选,只过滤已删除目标,不得无条件降成第一张图层的单选;首次载入 / 项目切换才设置默认选择。不要只跑 persistence Hook 单测,必须同时运行图片画布生成集成测试,覆盖完成后面板关闭、显式选择结果、背景清选和合并后 CAS 保存。
- 验证:分别覆盖防抖 pending、真实在途成功与 409、后端新增、后端删除、本地删除、viewport、背景色和生成面板完成态;运行 `npm run test -- src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`
## Provider schema 能力不能从统一工具标记直接推断(2026-08-03)
- 现象:把 OpenAI 风格 `strict` 原样透传给完整 Anthropic 工具目录,单个 schema 不支持的约束或全请求工具 / optional / union 上限会让整次 planning 返回 400。
- 处理:能力不能从 `apiKind=anthropic` 推断;只对已验证 endpoint/model 显式开启,AGC 当前仅自动识别官方 HTTPS endpoint 与 Claude 4.5+ 版本化 model id,旧模型、未知别名和第三方兼容网关默认关闭。协议适配层用官方支持关键词白名单生成 Anthropic 专用传输 schema,对已知不支持约束仅从传输副本剔除,未知关键词、不可解析 / 递归 `$ref` 和复杂度超限均失败关闭为 non-strict,不删工具或修改调用方原 schema。真实 live 样例应包含 `$defs/$ref` 嵌套 schema,并使用官方 Anthropic endpoint,第三方兼容网关不能替代官方能力证据。
## 可选 MCP server 的坏目录不能拖垮全部工具(2026-08-03)
- 现象:可选 server 已成功连接,但返回超限 schema、重复 tool identity 或要求未支持 task-mode 时,整个 MCP catalog 和本轮 Agent planning 一起失败。
- 处理:连接、tools/list、工具归一化与聚合容量都使用同一 required / optional 边界。optional 将该 server 投影为 `connected=false + error + tool_count=0`,required 保持失败关闭;被包入 `action.input``$ref` 只重定位当前 document 根的 `#` / `#/...` JSON Pointer,命名 anchor、外部 URI 与带 `$id` 的 schema resource 内 fragment 不得改写。
## React 异步读取必须在组件卸载时中止并失效(2026-08-04)
- 现象:单个 Vitest 文件全部通过,全量 CI 却在 jsdom 环境销毁后出现 `ReferenceError: window is not defined`;栈指向请求 Promise 的 `finally` 中调用 React `setState`
- 原因:测试触发了与断言无关的账户读取,较快环境中请求会在用例结束前失败,较慢 CI 中请求延迟到组件和 jsdom 均已销毁后才收束。仅用 revision 丢弃旧请求而不在卸载时推进 revision,最后一个在途请求仍会被误认作当前请求。
- 处理:调用方在未认证时不得启动受保护的钱包刷新;可取消的读取要为每轮分配 `AbortController`,新读取先失效并中止旧读取,组件卸载时同时推进 revision、abort 当前请求并清空句柄。所有 `then / catch / finally` 在更新状态前都要检查 signal 与 revision。
- 验证:定向测试覆盖卸载后请求 signal 已中止;同时复跑触发钱包刷新回调的画布生成集成测试和完整前端测试,不能以单文件偶然快速收束代替全量验证。
## 账号级轮询和并发 bootstrap 必须中止整条旧生命周期(2026-08-06)
- 现象:任务列表 `Promise.all` 一侧失败后,另一侧请求可能跨过重试和卸载继续悬挂;微信充值第一次确认返回 pending 后切换账号,旧订单的延迟重试可能使用新账号 Token 再次请求,401 路径还会影响新账号登录态。
- 原因:只用 React state 或最终回调里的 owner 判断,无法阻止已安排的 timer、下一次 HTTP 请求和同轮未完成分支继续执行;每轮重试覆盖单个 controller ref,也会遗失更早的悬挂请求。
- 处理:并发 bootstrap 每次 attempt 使用独立 `AbortController`,任一分支失败时先中止同轮 controller 再安排有界重试,卸载时中止当前 attempt。充值订单从创建成功起持有同一个 owner、账号 revision 和 `AbortController`;每次 delay、confirm 和 SSE watch 前后都校验生命周期,并把同一 signal 传到请求层;账号切换和卸载先 abort,再清理 ref、state 与旧支付回调 hash。
- 验证:bootstrap 用例覆盖“一侧 reject、另一侧 pending、重试后卸载”,并断言每轮 signal 都已中止;充值用 fake timer 证明首次确认 pending 后切换账号会中止 signal,推进全部退避时间也不会产生第二个确认请求或清理新账号 Token。
## 中止 refresh 等待不等于隔离 token 发布(2026-08-07
- 现象:A 账号的写请求 401 后开始共享 refresh,随后切换到 B。A 的 `AbortSignal` 虽然让业务请求立即结束且不再重放 POST,但底层 refresh 为了其它共享等待者不会被中止;A 的成功回包晚到时仍可能覆盖 B 的 token。
- 原因:只对 `await` 叠加 abort 保护了调用链,没有给共享 Promise 的归属和最终 token 写入加账号栅栏;单一全局 Promise 还会让 B 加入 A 已在途的 refresh。
- 处理:公开 token setter / clearer 每次都推进 auth generationrefresh 按 `generation + 发起时 access token` 共享、并以该快照 CAS 发布成功 token。快照已过期时成功回包转为失效结果,401/403 也不得清理新代际 token;新代际建立自己的 refresh Promise,旧 Promise 收尾时不得清掉新尝试。
- 验证:`src/services/apiClient.test.ts` 要等旧 refresh 完整收束后断言 B token 不变,并用两个独立 deferred response 证明 B 会发起第二个 `/api/auth/refresh`;另覆盖旧 refresh 401 晚到不清 B token。
## 下游 manifest 回调测试不能冒充实时数据源(2026-08-05)
- 现象:工作台的资源、任务与版本重投影单测保持绿色,但后台 Agent 已更新 `.agent/manifest.json` 后,打开中的工作台仍长期显示旧快照,只有重开项目才更新。
- 原因:测试 Supervisor 直接调用 `onManifestChange`,只证明 `App manifest -> WorkspaceLauncher -> ProjectDevelopmentView` 的下游桥接;真实 Runtime event 没有失效字段,监听器也没有重读 manifest。External Runner 又与 GUI 分属不同进程,Runner 内无法使用 GUI `AppHandle`,只补普通 Tauri event 仍不能形成生产链路。
- 处理:后台 manifest mutation 收敛到共用 Runtime emitterGUI 内进程用带 `manifestInvalidated` 的 Runtime updateExternal Runner 通过 GUI owner attach 登记的受令牌保护 loopback sink 转发专用失效事件。App 对当前项目做 single-flight manifest 重读,并以 mounted、项目路径和 scope version 丢弃迟到结果;WorkspaceLauncher 继续只消费完整 manifest 快照,不新增平行状态或轮询。
- 验证:集成测试必须渲染真实 `App + WorkspaceLauncher`、捕获真实 Tauri listener,让 `get_local_game_manifest` 从旧快照切换到新快照,并由非 Supervisor Agent 事件驱动资产、completed 任务、运行入口和版本卡出现;另测项目切换时旧请求迟到。测试夹具必须先等待目标 Tauri listener 注册完成,并等待项目写入最近列表后触发的只读目录状态刷新完成,再清空调用记录和发送失效事件;对“事件 -> manifest 重读 -> 工作台重投影”使用局部、有界的 `5_000ms` 等待,避免并行全量回归把合法后台检查、监听注册或异步投影调度误判为功能失败。旧的直接 `onManifestChange` 测试只能标记为下游桥接证据。
## React 资源详情焦点不能依赖重建对象身份(2026-08-05)
- 现象:音频 / 视频播放器、文档链接或收起按钮正在获得焦点时,后台 manifest 更新会把焦点突然移回详情 region;若当前资源被删除,详情虽然消失,stale focused ID 和焦点可能残留到 `body`
- 原因:资源投影每次生成新对象,`useLayoutEffect([focusedResource])` 把同一资源的内容更新误判为重新进入详情;删除路径没有显式恢复状态和可聚焦 fallback,项目 / 运行视图切换也可能沿用旧 trigger。
- 处理:焦点状态机只比较稳定 `resourceId``null -> id``idA -> idB` 聚焦详情,`idA -> idA` 保持当前 active element。显式收起 / Escape 才恢复原卡片与滚动;后台删除清理 focused / matching selected ID 并聚焦搜索框;项目或运行视图切换清空 trigger / restore。媒体预览副作用依赖稳定 ID、路径和类别,不因同 ID 对象重建先卸载控件。
- 验证:媒体控件获得焦点后用同 ID 新 manifest 重渲染并断言 active element 不变;删除资源后断言详情关闭、选中清理且搜索框获得焦点;既有收起、Escape、项目切换和运行切换测试继续通过。
## manifest 与 revision 必须作为同一一致快照发布(2026-08-05)
- 现象:旧 manifest 的 React effect 在正式素材提交后才读取项目 revision,可能把“旧内容 + 新 revision”发给父级;若它先到,真正的 commit manifest 会被误判为同 revision 分叉并失败关闭。
- 原因:manifest 和 mutation revision 分开读取,却把其中任意时刻的两个值拼成一个权威快照;单独比较 callback 到达顺序无法修复这种身份错配。
- 处理:普通 Supervisor 投影固定执行“revision 前读 -> manifest -> revision 后读”,两次 revision 相同才发布,漂移时有界重试。素材 command/event 直接使用事务返回的完整 manifest 与对应 revision。父级按 `projectPath + projectId` 单调接受更高 revision,同 revision 只允许内容一致的重复,低 revision 和分叉都不覆盖。
- 验证:分别覆盖 command/event 两种先后、成功后旧轮询和同 revision 不同 manifest;不能只用 eventId 去重而跳过 revision 防倒灌。
## 新资源自动聚焦不能把投影、布局和 DOM 当成同一时刻(2026-08-05
- 现象:保存回调已经带回 manifest,但新卡片可能尚无 dependency/type 坐标或尚未提交 DOM;立即选择会得到空画布、错误滚动,迟到回调还会抢走用户后来选择的资源。
- 原因:把 durable commit、资源投影、关系图 ready、两份布局协调和 React DOM commit 压成一个“保存成功”布尔值,缺少保存尝试身份和用户意图 generation。
- 处理:保存开始记录 `saveAttemptId + sessionId + draftId + commitId + focusGeneration`。自动定位依次等待资源投影存在、dependency/type 两份布局 settled 且都有位置、搜索条件可见和稳定 `data-resource-id` DOM 存在;按 commitId 只执行一次。切项目、切 mode、改选择/搜索、取消或开始新 flow 都推进 generation;迟到结果仍可合并权威 manifest,但不能改变选择。隐藏时保留搜索,只由显式“清除搜索并定位”建立新 generation。
- 验证:覆盖 manifest 已更新但布局未完成、DOM 后只聚焦一次、搜索隐藏、保存中切项目/改选择和连续保存;测试不得用 reload 或重开项目绕过阶段边界。
## 不要用自然语言精确 `.replace()` 维护 Runtime Prompt
- 现象:Prompt 文案稍作改写、增删空格或调整段落后,替换静默失效,代码中出现难以审阅的链式 `.replace()`
- 原因:把自然语言全文同时当内容和结构锚点,没有稳定 section 身份。
- 处理:稳定片段拆为版本化 Bundle section,由 Rust 显式按角色、平台和配置组合;`agent_runtime_native_executable_tools()` 是原生可执行工具的权威源列表,同时供 Prompt 工具目录与 native capability registry 使用,`mcp.call` 只服从当前请求的动态 MCP catalog。最终 Provider 请求构建器同样必须使用显式 section 与条件组合,不能以后置自然语言精确 `.replace()` 注入工具合同、平台规则或角色规则。安全规则保留在代码中。
- 验证:manifest 覆盖所有嵌入资源、版本一致、源列表中的原生工具全部进入 Prompt 与 native capability registry、`mcp.call` 不进入静态目录、Supervisor section 顺序和关键角色合同保持不变,并扫描 `prompt.rs` 与最终 Provider 请求构建器不再出现自然语言链式 `.replace()`;对最终 Provider 请求直接断言各角色、平台和配置分支的合同内容。
## 动态 MCP 函数参数不能只依赖 Provider schema
- 现象:动态 MCP 函数虽然带 catalog `inputSchema`Runtime 却只检查 `arguments.input` 是 object;非 strict 或兼容 Provider 可以返回缺 required、类型错误、enum 外值或 schema 外隐藏字段,并把它们原样送到外部工具。
- 风险:Provider 工具约束不是本地安全边界;特别是 `writes + readOnlyHint=true` 自动放行的工具,schema 外字段可能改变外部副作用而不进入预期确认路径。
- 处理:使用完整 JSON Schema validator 校验原始 catalog schema,不手写 required/type 子集;native parser、fingerprint enrichment 与实际 MCP 调用边界复用同一校验器。enrichment 错误必须映射回 classified `arguments-schema` repair,不能以普通字符串直接终止 run;执行点重验用于阻断升级前已经落盘的 schema 外 pending。关闭网络和文件 `$ref` 解析,schema 无法安全编译时不广告或不执行。`serde` 类型错误会包含实际字符串值,catalog miss 也会包含模型提交的 server/tool,因此这两类错误同样只能返回稳定类别,不能拼接原始错误、参数值或 schema 内容。
- 验证:覆盖 required、additionalProperties、type、enum、本地 `$defs/$ref`、HTTP/file 外部引用、无效 schema、错误脱敏,证明 legacy wrapper 在注入 fingerprint 前进入 repair,并证明带旧有效 fingerprint 的历史 pending 在实际调用前仍被 schema 拒绝。
## 2026-08-05 不要把 static smoke 当作完整专业交付
- 现象:code-prototype 已通过 `game.static_smoke`,但完成门明确报告 `missing-visible-art-slice-use`;随后每轮 thinking summary 都是“已取得验证证据”,没有新 action,最终 loop-budget-exhausted。
- 处理:确定性交付与自动 plan completion 都必须先通过完整 completion gate,并要求当前 Run 最后一条同 mutation 工具调用与 Agent DB 中严格绑定当前身份的 `status=ok` receipt 一致;pending action 的 steer cursor fingerprint 也必须一致,失败 patch 或旧 Run receipt 不能取得交付资格。新 blocker 不回退旧 completed 步骤:已有非终态步骤时用明确 repair step 替换首个非终态步骤,其余保持 pending;只有全 completed 且仍有容量时才追加。8 步已满时进入外部 repair lane;计划已有 failed 步骤时立即失败关闭。回归同时覆盖失败 patch、跨 Run receipt、非零 steer cursor、8 个 completed 与 blocker,以及 failed plan 在 ownership/blocker 不同组合下都不会继续空转。
## 2026-08-05 Runtime 时间戳必须验证 Date 范围并保持来源身份
- 现象:极大但有限的持久时间值会让 `toISOString()``RangeError`,或让界面显示 `Invalid Date`;实时回复又借用其它 Runtime 的最近活动时间,文字继续流入时仍显示几分钟前,缺失时还随前端定时器漂移。
- 处理:秒/毫秒归一化后必须再检查 `Date#getTime()`;不可表示的值统一显示“时间未知”并省略 `datetime`。实时回复只使用 response stream 自己的 `updatedAt`,不能借父/子 Runtime 活动时间或 `Date.now()`
## 2026-08-05 Pending manifest 容错必须覆盖真实终态时序
- 现象:手工把内存 state 改为 Completed 的测试通过,但真实 finalization 先写 durable Completed、再投影 manifest 时仍被 Pending 状态门拒绝;或 stale manifest 全 Completed 后,父 Run 忽略仍在运行的真实 child。
- 根因:测试没有写 durable terminal recordPending 容错只验证了非终态 journalDAG 又把 manifest `completed=true` 放在 active child 之前。终态投影和收束前检查使用了不同事实时序。
- 处理:测试必须按真实顺序分别写 durable Running 和 durable Completed。Pending 漂移只允许 state/journal 的 Running-Running 或 Completed-Completed 对;queued/waiting/failed/reconciliation 一律拒绝。active child 在无 Failed 时独立保持 DAG 活跃,项目 mutation gate 在写锁内核对当前 root,防止旧 child 污染新根 Run。
## 2026-08-05 Canvas 可达性不能在扇入调用图中回退 visited
- 根因:大 classic script 虽使用了 bounded direct-call graph,但 `javascript_named_function_is_reachable` 在递归返回时删除 visited,只阻止当前环,不记忆已经遍历的祖先。render/update 图的大量重复调用让同一节点指数重算;父完成门又在 code-prototype 未完成时提前深验四个 Canvas 切片,使第一次 wake 就同步阻塞,200 次外层重试预算完全没有机会推进。
- 处理:单次可达性查询每个 function node 最多访问一次;全 `None` alias 历史直接返回,稳定外层初始化使用有调用前置证明的快路。父完成门只深验 Completed seed taskwake 预算耗尽写入 reconciliation。格子游戏的符号坐标只在唯一数值 `COLS / ROWS / CELL` 与画布范围能共同证明时接受,普通无界动态坐标继续拒绝。
- 验证:永久 fixture 至少包含 48 层重复扇入调用、IIFE 外层素材初始化、格子常量绘制、无界坐标反例和整画布尺寸引用;真实项目的全部四个切片还要在同一轮秒级返回 true。禁止用延长 queued timeout、Tokio timeout 或 synthetic 小脚本通过来替代真实大脚本复验。
## 2026-08-05 Canvas clamp 与 parent wake 不能走字符串或易失兜底
- 根因:Canvas owner 收紧后正确禁用了含尺寸成员的字符串兜底,但 AST 数值区间器尚不认识嵌套 `Math.min / Math.max` clamp。若只查源码包含 `canvas.width`,无法证明该 Canvas 创建了当前 context,也无法排除局部伪造 `Math`
- 处理:只在 semantic 证明未遮蔽全局 `Math`、上界读取当前 context 所属 Canvas、下界为 `0` 时生成有限区间;加入错误 Canvas、遮蔽 Math 和无界坐标负向回归。不要用字符串包含、变量名白名单或把未知动态值当 `0`
- 现象:parent wake 的 200 次瞬态预算耗尽后 Runtime 仍长期显示 running,或 lane 忙、取消、child 前进、manifest 损坏时 reconciliation 被静默丢弃或覆盖新状态。
- 处理:预算耗尽错误必须向上传递;lane 忙先持久化 deferred signal,再在 lane + 项目锁内重检最新事实。结构损坏路径使用不依赖 manifest hydration 的专用 journal/state 写入,CAS 失败转为继续对账,绝不覆写并发取消或 DAG 进展。可解析的空对象/空 runId 仍是损坏身份,只有完整有效的新 Run 才能阻止旧 markerevent/audit 的同键记录必须完整比对并拒绝冲突或重复。旧 task 已终态、Runtime 非 waiting 或新 Run 接管时,deferred signal 必须写 resolved/superseded,不能留给后续 wake 永久重复 settle。
- 测试注意:autonomous child fixture 先 linked Pending、后正式 Running;终态 runId 必须拒绝复用。判断 Completed-only 诊断时按每个 seed task 的实际状态分析,不能因为 `code-prototype` Pending 就忽略已经 Completed 的 `art-asset-plan` 深验。
## macOS 安全路径测试必须使用规范化临时目录(2026-08-05)
- 现象:调用仓库上下文、Runtime context bundle 或 pending recovery 的 Rust 测试在 macOS 报“Repository root and its ancestors must not be symbolic links”,Linux CI 却可能通过;本地 HTTP 恢复夹具在完整串行测试中还可能偶发 `WouldBlock`
- 原因:`tempfile::tempdir()` 默认返回 `/var/folders/...`,而 macOS 的 `/var` 是指向 `/private/var` 的符号链接,生产安全校验会按设计拒绝该祖先;恢复测试的服务端读超时若仅为 2 秒,也会与完整测试负载下约 2 秒的首次请求形成窄竞态。
- 处理:凡测试会进入仓库可信路径校验,统一使用 `crate::tests::canonical_test_tempdir(...)`,不得削弱生产符号链接拒绝规则;loopback 夹具保留有界超时,但为完整 CI 负载留足稳定裕量。
- 验证:在 macOS 上定向运行 provider request、pending recovery、autonomous continuation 与 generation recovery 用例,再运行完整 `npm run check:native-shells`
## Mach-O 文件头校验必须覆盖反字节序魔数(2026-08-05)
- 现象:macOS arm64 的 Tauri release 已成功构建且 `file` 明确认定为 Mach-O,产物 staging 仍报“must be an executable Mach-O file”。
- 原因:脚本用 `Buffer.readUInt32BE(0)` 读取文件头,却只比较 `0xfeedfacf` 等正序数值;arm64 常见头字节是 `cf fa ed fe`,读取结果为 `0xcffaedfe`
- 处理:文件头白名单同时覆盖 32/64 位与 fat Mach-O 的正序和反字节序合法魔数,并由桌面配置门禁同时反查 staging 脚本和根级产物检查,不能改成只按扩展名或构建退出码判断。
- 验证:在 macOS 上构建真实 desktop-shell release,运行 `npm run desktop-shell:stage-release-binary`,再由 `npm run check:native-shells` 校验 staged 产物。
## 托管 MCP 新增公开域名时不能只更新网关路由(2026-08-05)
- 现象:`https://dev.genarrative.world/api/external/v1/mcp` 的 manifest、OpenAPI 和 Bearer 鉴权都正常,但鉴权后的 `initialize` 返回 `403 FORBIDDEN`;通过 SSH 隧道访问同一 api-server 的 loopback 地址却可以正常列出 tools/resources。
- 原因:`rmcp` Streamable HTTP transport 自带 DNS rebinding 防护。公网网关已经接入 dev 域名,但 `external_mcp::service()``allowed_hosts` / `allowed_origins` 仍只登记正式域名和 localhost,因此请求在 MCP 协议处理前被 transport 拒绝。
- 处理:新增公开 MCP 环境时,同批登记对应 Host 与 HTTPS Origin;不要通过客户端伪造 `Host`、关闭防护或改走内部 SpacetimeDB MCP 规避。allowlist 变更属于 api-server 发布内容,必须随正常 API release 部署到目标环境。
- 验证:自动测试使用真实公开 Host/Origin 执行 `initialize`;部署后再从公网域名完成带 Key 的 `initialize``tools/list``resources/list`、Skill resource 读取和至少一个只读业务 tool 调用。loopback 成功只能证明 MCP 实现和 Key 可用,不能替代公网 Host 验收。
- 关联:`server-rs/crates/api-server/src/external_mcp.rs``docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`
## 异步任务接受后的刷新回调不能统一套用 dialog 所有权(2026-08-05
- 现象:正式生成任务已被后端接受,用户随后删除 dialog 或切换项目,任务仍继续并可能扣费,但钱包和任务列表没有刷新;反向问题是账号切换时若 project ID 暂时相同,旧任务可能刷新新账号的任务列表。
- 原因:把 dialog / canvas 的完整 UI 所有权同时用于账号级钱包和账号内项目级任务列表,或者任务列表只比较 project ID,没有校验账号。
- 处理:按副作用分层校验。钱包只比较账号;任务列表比较账号加项目;dialog、canvas、asset 和 layer 写回继续比较账号、项目、scope version 与原 dialog。正式请求已接受后,删除 UI 状态不等于取消后端任务。
- 验证:分别覆盖删除 dialog、同账号切项目、账号 A 切到账号 B 且 project ID 保持相同,以及原账号原项目原 dialog 仍有效的正常回写。
## GUI owner 锁不能替代逐 boot 的事件接收端登记(2026-08-05
- 现象:GUI 首次启动后 manifest 事件转发正常,但 Runner 被替换为新 boot 后只剩 owner 锁和 endpoint 可用,后台更新不再到达 GUI;或者 attach 响应只确认 owner,客户端却误记当前 boot 已完整登记,后续 ensure 不再重试。
- 原因:把 OS owner 生命周期约束与进程内事件 sink attachment 混成同一状态,或在 `ensure_external_agent_runner` 之外执行一次性 attach;测试若用 actionId 等无关字段代替真实 sink port/token,也无法证明新 boot 重放的是可用接收端。
- 处理:GUI 按规范化 AppData 私有登记真实 sink port/token`ensure_external_agent_runner` 的 endpoint 复用和新 Runner 就绪两条成功路径都按 `bootId` 重放。同 boot 成功后幂等,新 boot 必须重挂;RPC、`attached``eventSinkAttached` 任一失败或缺失都不得记录成功 boot,并允许同 boot 后续重试。不同 AppData 不共享登记,未登记 CLI 不触发 attachsink token 不进入日志、错误或公共状态。
- 验证:分别覆盖真实 port/token 跨 boot 原样重放、同 boot 幂等、新 boot 重挂、普通 attach 失败、`eventSinkAttached` 缺失与 false 后同 boot 重试、AppData 隔离和未登记 CLI 零副作用。
## manifest relay 测试不能并行覆盖同一个全局 sink2026-08-05
- 现象:crate 根 relay 测试在配置全局 sink 后阻塞等待 `TcpListener::accept()`,同时 Runner GUI owner attach 测试通过另一条路径覆盖并清空 sink;事件可能被发往另一端口,原 listener 随后永久等待。断言或 `expect` 提前失败时,成功路径末尾的手动 clear 也不会执行。
- 原因:两个跨模块测试读写同一进程全局状态,却没有共用隔离边界;只给 accept 后取得的 stream 设置 read timeout 无法约束 accept 本身,payload 读取也缺少总 deadline。
- 处理:全部全局 sink 测试共用一把 test-only 串行锁,并由 RAII guard 在 `Drop` 中无条件清空;测试统一使用 `manifest_invalidation_sink_isolation_` 前缀。relay fixture 对 accept 和 payload 分别使用非阻塞轮询与总 deadline,不使用固定 sleep;生产 loopback、token、连接 / 写入超时和 payload 大小校验保持不变。
- 验证:用 `--test-threads=2` 重复运行统一 filter,覆盖正常 relay、无事件 accept 超时、不完整 payload 超时、panic 展开清理,以及 GUI owner attach 配置与 guard 清理。
## 远端图片 completed 不能冒充本地资源创建成功(2026-08-05)
- 现象:External operation 已返回 completed,但稳定引用缺失、下载失败、正式资产事务中断或 manifest 已提交而 UI 事件丢失时,界面仍可能提前显示“资源创建成功”,重复回调还可能再次下载、写文件或登记资源。
- 原因:把远端生成、媒体传输、本地 durability、manifest 投影、布局和选择压成一个 completed 布尔值;同时把 External idempotencyKey、operationId 或 taskId 暴露到公开草稿,导致恢复逻辑从非权威状态重建请求或误绑本地 task graph。
- 处理:使用私有 generation ledger 保存原请求、External 身份、稳定远端引用、固定 staging token 与本地 commit 身份;公开面只投影不可逆阶段。启动时先恢复阶段三事务,再恢复原 generation;重复 completed 先检查远端引用、staging 和 committed ledger,只有 `committed | already-committed` 才进入 manifest 投影。用户取消等待只推进 focus generation,不删除账本或伪装远端取消。
- 精修补充:`sourceImageSrc` 是可下载的稳定媒体引用,`sourceResourceId` 是资源身份,二者不能因为都可表现为字符串就填同一个 objectKey。本地 `local-asset:*` 只保留在本地 manifest 血缘;没有真实 External resourceId 时省略 `sourceResourceId`
- 验证:覆盖确认前零调用、同 key 连点、accepted 重启 GET-only、重复 completed、取消后迟到、下载后本地事务恢复、事件丢失、切项目/改选择、旧轮询隔离、实时布局与选择、精修血缘及敏感字段零泄漏。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs``apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx``docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`
## 可恢复生成账本不能持久化 direct-upload ticket2026-08-05
- 现象:为支持参考图上传中断恢复,把完整 upload ticket 放进 generation ledger;账本随之包含 Provider host、formFields、policy、signature 或临时 Authorization,项目目录泄露即可复用临时凭证。
- 原因:把“恢复所需的稳定远端身份”和“仅供一次上传的临时授权材料”当成同一种持久状态。原子 sidecar 只能保证写入完整,不能让敏感字段变安全。
- 处理:ticket 结构不实现 Serialize/Deserializehost/formFields 只在本次内存调用中使用。账本在上传前只保存稳定 bucket/objectKey;重启先用这组身份调用 object confirm,确认成功后只保留 objectKey/assetObjectId 并清掉上传中间态。账本测试必须直接序列化完整 ledger,扫描 Provider URL、Authorization、policy、signature、API Key 和 ticket 字段名。
- 验证:运行 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml private_generation_ledger_never_serializes_upload_credentials_or_provider_url`,并继续检查公开草稿、manifest、事件和普通错误不含 prompt、operationId、Key、绝对路径或媒体正文。
## 客户端内部用途目录不能直接作为 legacyPrefix2026-08-10
- 现象:本地图片精修或视频、音频等全类型资源编辑点击生成后立即提示参考资源上传失败;私有账本的 `uploadBucket/uploadObjectKey/operationId/requestBodyJson` 全为空,服务端也没有 OSS、confirm、生成或扣费记录。
- 原因:direct-upload ticket 的 `legacyPrefix` 不是任意业务目录,而是 `platform-oss::LegacyAssetPrefix` 的权威白名单值。把 `asset-canvas-references``resource-editor-references` 直接放在该字段会被 api-server 在签名之前以 `400` 拒绝;客户端若把票据、OSS 和 confirm 全折叠成一个错误码,还会掩盖真正失败阶段。
- 处理:客户端编辑器统一使用合法私有 `legacyPrefix=generated-character-drafts`,把业务用途放入 `pathSegments`:图片画布为 `editor/asset-canvas-references/<projectId>/<draftId>/<generationId>`,全类型资源编辑为 `editor/resource-editor-references/<projectId>/<operationId>`。仍严格执行 ticket → OSS form POST → object confirm,只有 confirm 返回自洽稳定 `objectKey/assetObjectId` 后才允许提交生成;不要为内部目录扩白名单或新建上传接口。图片路径按本地校验、票据、对象上传、对象确认分别使用安全错误码,票据材料继续只驻留内存。
- 验证:客户端端到端测试必须断言 confirm 早于生成 POST、请求使用精确前缀与 pathSegments、账本只持久化稳定对象身份;票据失败时断言 `operationId/requestBodyJson` 为空且 manifest 只有源资产。api-server 测试应断言生成的 key 位于 `generated-character-drafts/editor/...` 且 access 为 private。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs``apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs``server-rs/crates/api-server/src/assets.rs`
## Tauri 有平台登录 Token 不代表应调用主站画布 API2026-08-10
- 现象:客户端素材画布和全类型资源编辑从 WebView 读取平台 Access Token,把它传给 Tauri command,再调用 `/api/editor/*``/api/assets/*``/api/runtime/external-generation/jobs/*`;代码同时保留 External 分支,导致真实 UI、Runtime 和测试使用不同路径,发布客户端还错误依赖网页画布登录态。
- 原因:把“客户端壳有账号登录能力”误当成“客户端画布属于主站网页宿主”。主站和 Tauri 虽复用相同请求 DTO 与后端生成服务,但对外边界不同:主站使用站内认证路由,Tauri 远端媒体能力使用 Developer API Key 和 External v1 路由。
- 处理:Tauri 前端不读取、透传或持久化站内 Access TokenRust 只从发布 AppData 私有 `editorApi.baseUrl/apiKey` 解析 External 凭据。图片、视频、音效、BGM 的项目/素材库、上传、确认、生成、轮询与换签全部留在 `/api/external/v1`,不访问内部 job 查询或账号/profile 接口。账本只绑定 External 配置身份指纹,升级前遗留的站内 endpoint 必须进入待对账状态,不能拿 External Key 自动重放。Key 缺失或无权限只返回安全配置错误,不打印 Key、Authorization、Provider 正文或私有路径。
- 验证:前端测试断言 command input 不含 `accessToken/apiKey`Rust mock 服务器拒绝任何 `/api/editor/*``/api/assets/*``/api/runtime/external-generation/jobs/*` 请求,并覆盖 External `202`、原 operation 轮询、换签、非破坏性本地提交和账本零凭据。主站路由与 OpenAPI 未发生契约变化时不得为了客户端切换修改后端接口。
- 关联:`apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts``apps/ai-game-creator-shell/src/view/project-development/index.tsx``apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs``apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`
## prepared journal 之前同样存在正式事务崩溃窗口(2026-08-05)
- 现象:事务依次安装 before/after 快照后才写 journal;若进程在首个快照、全部快照或 journal 已写但 ledger 未写时退出,重启扫描看到 transaction 目录却无法进入原先只覆盖 prepared 之后的恢复状态机,可能留下孤儿目录或阻塞项目后续提交。
- 原因:把 `prepared` 当成事务的第一个可观察持久阶段,忽略了构造 prepared 证据本身也由多次原子文件安装组成。
- 处理:把首个快照、全部快照和 journal 后/ledger 前加入故障矩阵。无 ledger 时只允许清理受控快照与本模块临时文件;若 journal 已存在,还必须证明正式目标不存在、manifest 和 project revision 精确等于 before。未知文件、正式文件存在或权威状态漂移全部失败关闭,不能递归猜测清理。清理后同步 transaction 父目录,并允许同 commit/idempotency 身份安全重放。
- 验证:故障矩阵逐阶段恢复;额外用同一幂等身份在快照残留清理后提交两次,必须得到一次 committed、一次 already-committedmanifest 仍只有一个 canvas asset。
## 宿主事件接线不能顺手复制共享 history 栈(2026-08-05
- 现象:Tauri Surface 已复用共享 viewport/transform/renderer 数学,却另外维护 undo/redo refs、快照克隆和恢复逻辑;网站共享 hook 后续增加内容安全或字段恢复时,两端会静默分叉。
- 原因:把 Pointer 事件接线、宿主生命周期胶水和可复用 history 算法放在同一组件中,误以为没有复制整个画布目录就已经满足共享源码边界。
- 处理:两宿主直接消费共享 `useCanvasHistory`;共享 snapshot 统一覆盖 viewport、selection、图层位置和 width/height,宿主只声明本地媒体是否允许安全移除/重做。Tauri 仍可保留 Pointer capture/epoch/host callback 接线,但选择、平移、缩放、变换、renderer 和 history 状态机不得在宿主重写。
- 验证:主站 history 定向测试覆盖 resize undo/redoTauri 新建、导入、编辑、撤销重做和 durable commit 用例必须在同一共享 hook 下通过。
## 编辑器生成不能把传输重试、参考图截断和客户端 provenance 当成独立小问题(2026-08-05
- 现象:生成 POST 首次已经入队但响应丢失时,客户端自动重试产生第二个任务;第 6 张或更多参考图仍显示在 UI / 元数据里,却没有送给 provider;直接构造请求还能把任意资源 ID 写成最终素材引用。
- 原因:客户端虽在重试中复用 `x-request-id`,队列入口却用随机 job id 生成 dedupe key;前端允许无限追加,api-server 和 provider 用 `.take(...)` 静默截断;`generationInputs.references` 被当成可信持久 provenance。
- 处理:主站生成 POST 禁止自动重试,把显式复用的稳定 request id 接到队列唯一键并校验 replay payload;所有边界显式拒绝超限,前端还要预留主图槽位、统计在途上传,并在上传完成前拒绝模型切换、画布选图、提交生成、关联源图删除 / 剪切 / 素材删除和面板切换 / 关闭;reservation 必须绑定原面板上下文,批量部分失败时不能丢弃已经持久化的成功项。入队、完美像素及直接创建资源 / 素材时删除客户端 references,执行时按真实参考源和 owner 资源记录重建权威引用。历史任务比较必须兼容仅差已删除 references 的旧 payload,不能只保留旧 hash 却让 payload 比较误报冲突。
- 验证:覆盖同键同 payload / 不同 payload、普通图片第 6 张、带主图的 GPT-image-2 第 5 张额外引用、provider 6 / 15 张边界、伪造引用删除和 owned 资源 / 素材重建。
## 共享音频 Composer 架构冲突不能按单行选边(2026-08-06)
- 现象:master 的音频 composer 同时承载 SFX 与 BGM,并在组件内定义 `isSoundEffect`;功能分支把 BGM 拆成独立组件后,原组件变成 SFX-only。合并时只把 master 的条件占位表达式带回 SFX-only 组件,没有带回变量定义,最终在测试渲染阶段报 `isSoundEffect is not defined`
- 原因:冲突两侧代表不同组件架构,逐行保留看似有用的 JSX 会把一个架构中的局部条件拼进另一个架构。import 排序、格式检查和只覆盖单一 mode 的测试都不能证明这种组合成立。
- 处理:先确定权威组件边界,再按完整调用链解决冲突。图片画布音频入口当前决策是恢复一个共享 `ImageCanvasAudioGenerationComposerView`,由组件内 `isSoundEffect` 分流;BGM/SFX 的 validator、写回、锁和提交契约仍分别保持。不要只补一个常量后继续维持已经废弃的双 composer 边界。
- 验证:同时渲染 `audio-sound-effect``audio-background-music`,覆盖两个 mode 的正向控件和互斥负向断言、dialog / mode 切换、BGM 稳定 ID 与 controller 缺失的失败关闭,并运行 `ImageCanvasGenerationComposerView.test.tsx` 与 typecheck。
## SFX Worker 不能只靠分层单测证明退款和零副作用(2026-08-07)
- 现象:LLM、ElevenLabs adapter、OSS 和 metadata 各自测试都通过,但无法直接证明余额不足时外部调用为零、翻译失败不会调用 provider、OSS / DB 失败只退款一次,或项目资源 / 素材 / 画布使用同一份权威 metadata。
- 原因:正式 SFX handler 把计费、翻译、provider、持久化和写回内联在一个 future 中;分层测试只能证明单个 helper,不能证明组合顺序和“失败后不继续”。同时若把 mock 流程另写一遍,它本身又可能与生产逻辑漂移。
- 处理:抽出单一 Worker 编排函数和计费 / stage adapter。生产 adapter 代理现有正式实现:OSS 后只准备 asset object / binding 候选,项目资源、账号素材、画布和 job 终态通过同一原子提交落库;测试 adapter 逐段记录调用与注入失败。组合矩阵同时断言 charge / refund、LLM / provider / OSS / writeback 计数、稳定 reason code 和权威值等值。ElevenLabs 二进制、MIME、大小、timeout 和 MP3 仍由 loopback adapter 测试负责,组合 mock 不替代协议测试。
- 验证:自动 / 手动时长 × Loop 四组合成功;余额不足;翻译、HTTP、无效音频、时长探测、OSS PUT / HEAD、asset confirm / bind、项目资源、账号素材和画布写回逐点失败;所有 job provider POST `<= 1`,预扣后失败 refund `= 1`
## 生成结果的稳定 ID 和 job 终态都不能代替 durable receipt2026-08-06
- 现象:Provider / OSS 已成功,但项目资源、账号素材、binding、画布和 job 只完成一部分;不确定结果重放时,有时又复制一批素材或重复推进 canvas revision。inline 路径在进程重启后尤其无法判断前一次提交是否整笔完成。
- 原因:把“请求已入队”、“某个稳定 ID 已存在”或“job 已 completed”误当成整批业务记录已原子提交的证据。request fingerprint 只证明用户请求,不绑定最终 slot、派生记录、画布候选和 compact result;仅比较资源 ID 也无法发现内容漂移。
- 处理:用 `editor_generation_operation` 记录 durable receipt,分开 request fingerprint 与整笔 commit SHA-256。首次调用在同一 SpacetimeDB 事务中校验 lease 并写 object/resource/asset/binding/canvas/job/receipt;重放先查 receipt,再读回逐 slot 权威事实精确比较。receipt 缺失但 resource/asset/binding 已存在时失败关闭,不得补写 receipt;事务前已确认的 asset object 只能在 ID、bucket/key、owner、策略、媒体、来源和实体字段全部相等时复用。
- 时间与并发:`completed_at_micros` 必须为正数,object/resource/asset/binding/canvas 候选原时间字段与它一起纳入 commit SHA-256,不能在每次重放时重新取时;job 终态和完成事件只用 SpacetimeDB `ctx.timestamp`。canvas CAS 冲突后只刷新 project 并重算布局,不重跑 Provider / OSS。OSS 尚未进入该事务,无引用 object 仍是需另行清理的边界,不要宣称跨 OSS exactly-once。
- queue completion 不能把 inline 完整响应无条件同时复制到 `result``editor-agent-tool-call-result`。图集/UI 最多 64 个切片会重复携带 resource/asset/prompt/generationInputs,容易超过 job payload 512 KiB 上限并让整个原子提交回滚。必须先按普通 UI、Editor Agent、External API 的消费方契约裁剪,再把最终 JSON 交给统一 procedure。
- 消费方身份不能在提交前重新读取 summary 兼容快照来判断:该快照按设计清空 dedupe key 并删除 generationInputsEditor Agent / External API 会因此被误判成普通 UI。应在 worker 持有完整 claimed job 时把安全的 consumer kind 与 source identity 固化到调用上下文。
- procedure future 超时或连接断开不能直接映射为业务失败,远端事务可能已经提交。必须有界重放同一 prepared commit;明确 CAS 后才刷新 layout,且刷新 layout 应使用新时间,不能把项目 `updated_at` 回拨。receipt 不复制 queue payload,只存摘要并从 job 权威行回读;跨记录 object/project 一致性必须在事务内验证,不能依赖当前 builder 通常会携带完整 candidate。
- job 的 owner/kind/fingerprint/lease 都正确仍不够:`source_entity_id` 还必须绑定结果项目,来源资源必须另查存在性与 owner/project 归属;否则同 owner 的 job 可以误写别的项目,或伪造跨用户/跨项目血缘。
- Provider 成功时计费 guard 已解除,后续原子持久化失败不会自动退款。但也不能在 api-server 先独立退款再尝试 fail job:过期 worker、fail 断线或原子提交已成功但回包丢失时,会变成「结果成功且已退款」。正确边界是在同一 SpacetimeDB 事务内先 fencing 当前 lease,再同步写退款账本和失败终态;不得期待 `max_attempts = 1` 的编辑器任务再走租约耗尽路径补退。
- compact result 只能删除大 payload,不能删除消费方 DTO 必填字段或定位正式结果的稳定引用。角色动作/视频缺 `ok`、音效/BGM 缺 `prompt` 都会让 Editor Agent 把已完成 job 判成不可重试的回填失败;External 角色动作/视频如果创建了账号素材,completed 结果还必须保留 `assetId`
- `project_resource.source_resource_id` 校验不会自动覆盖 `editor_asset.source_resource_id`asset-only 结果可以没有项目资源候选,必须另查来源是本事务候选或已登记资源且属于同 owner;若本次结果有 project,还必须同 project。
- inline 模式不会走 queue `fail_job`,若计费 wrapper 在 Provider 成功时立即 disarm,后续的上传/原子持久化明确失败会扣费无结果。应在全部 inline owner handler 外统一延迟已成功 billing guard 到 durable commit;明确失败退款,但传输未知结果不退,否则远端已成功时又会变成「结果 + 退款」。
- 消费契约不能只测上游 builderExternal v1 在 durable job 入库前还有一层 allowlist compactor,必须对最终 JSON 断言 `ok / prompt / actualPrompt` 及稳定 resource/asset 引用。
- 计费 guard 的取消补偿必须区分 procedure dispatch 边界:`Build / PoolAcquire / ConnectBuild / ConnectHandshake` 等未发出阶段可确定退款;dispatch 后回包前的 future 取消与断连必须视为结果未知并保留扣款,等 durable receipt 对账。只在 error 返回后再标记 unknown 会留下取消窗口;必须在真正调用 procedure 前同步设置 task-local 标记,并在 `Procedure` 结果或确定未发出的失败后清除。
- compact DTO 的可选字段必须用最终 consumer payload 回归:Editor Agent 图片生成/修改的 `provider` 会被脱敏删除,必须是可选字段;图标/UI 正常与 source-only fallback 则必须保留 `ok / prompt / actualPrompt`。fallback 不得从可选 project resource 反推必填字段,否则无 `projectId` 任务会持久 `prompt/model=null`、尺寸为零且图标/UI 丢失 `priceMudPoints`
- receipt 存在不等于引用 object 仍然可信:省略 candidate 的已登记 object 在重放时也要回读 owner/key/task/kind/媒体身份。同时先查同 operation ID job,存在 job 却漏传 completion 必须整笔回滚,否则会得到 receipt 成功而 job 仍 running 的永久分裂。resource/asset/binding 也不得仅核对 object ID/key,必须按 operation 合法 tuple 交叉验证业务元数据。
- 验证:故障注入覆盖 resource 后 asset/binding 失败、canvas CAS 冲突、过期 lease、同 operation 异 fingerprint / 异 commit、receipt 缺失的部分既有记录、精确既有 object 复用与 object 内容漂移;成功重放必须证明记录数、时间、binding/job 事件数和 canvas revision 全部不变。
- 关联:`docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md`、Issue #134
## 付费生成不能把素材目录归属校验留到 provider 之后(2026-08-07
- 现象:登录用户给自己的合法 `projectId` 搭配不存在或属于其他账号的 `assetFolderId`,图片、改图、图集、UI 提取、视频、角色动作或音频生成会先扣泥点并调用付费 provider / OSS,直到创建 `editor_asset` 才拒绝目录;失败退款让用户成本归零,平台侧 provider 和存储成本不可逆。
- 原因:`normalize_generated_asset_folder_id` 只处理 `project`、旧 `folder-*` 和默认目录 ID 的兼容映射,不读取 SpacetimeDB;真正的 `require_owned_asset_folder` 位于生成结果持久化末端。把“失败会退款”误当成副作用补偿,漏掉退款不能撤销 provider 请求与 OSS PUT。
- 处理:所有付费编辑器生成在队列 enqueue 前和 worker / inline 执行前复用只读 `preflight_editor_generation_target_and_return`,按认证 owner 校验可选项目及归一化目录;读取失败和归属不匹配一律失败关闭。helper 返回 canonical 项目与目录并覆写后续入队 / worker / 原子准备使用的 payload,不能校验 trim 后的项目却持久化原始空白值。角色图片、角色动作、图标 spritesheet 与 UI 提取省略目录时按实际默认目录预检;默认目录允许尚未创建,自定义目录必须存在且 owned。预检不替代最终 procedure 复验,也不保证跨外部调用的目录锁定。
- 验证:源码顺序回归必须覆盖图片生成、图片修改、图标 spritesheet、UI 设计图提取、视频、角色动作、SFX 与 BGM 的 enqueue / direct 两层,证明纯本地格式和 `data:` / `blob:` 稳定引用门禁先执行,canonical target 在预检后写回 payload,远端引用解析、generation input rebuild、扣费、入队、provider 与 OSS 均留在预检之后;模块侧扫描证明预检只调用 runtime identity、项目、目录只读校验且不含 insert / update / delete,并覆盖带空白项目、`project`、旧 `folder-*`、默认目录 ID、自定义目录与 `None` 归一化。
## 聚合点赞数不能恢复当前浏览者是否点赞(2026-08-10)
- 现象:陶泥儿精选点赞写入成功、总点赞数也正确,但刷新或重新挂载后图标恢复成未点赞;前端再次点击会发出错误意图或让计数体验混乱。
- 原因:`editor_showcase_asset.like_count` 只表达全局聚合,公开列表未携带 `editor_showcase_asset_like(showcase_id:user_id)` 的 viewer 状态;前端用生命周期内的空 `Set` 充当真相,刷新必然丢失。仅靠 `likeCount > 0` 无法判断其中是否包含当前用户。
- 处理:公开列表使用可选鉴权 viewer 投影;登录态从 Bearer claims 派生 user ID,并在公开列表事务内按确定性 like 主键返回 `viewerLiked`,匿名固定 false。个性化响应禁止共享缓存或错误降级,追加 `Vary: Authorization` 时不得覆盖 handler 或内层中间件已有字段。写入采用服务端确认式更新,账号 / 鉴权 scope 变化后重载并丢弃旧请求回包;request generation 的激活与失效必须跟随已提交 effect,不能在 render 阶段修改 ref;输入 user ID 不能代替 runtime service identity 鉴权。
- 验证:覆盖刷新 / remount 保持已点赞、pending 期间不改图标计数、失败保留旧状态并播报错误、分页保留 viewer state、鉴权恢复不发匿名请求、登录 / 退出 / 换号与旧首屏 / 分页 / POST 回包竞态、被 Suspense 放弃的 viewer 渲染不影响当前已提交请求,以及无效 Bearer 返回 `401 + private,no-store + Vary: Authorization`;中间件测试另需证明已有 `Vary` 字段被保留。
## 非整除 nearest 会让逻辑像素块宽窄不一(2026-08-10)
- 现象:像素规整后的图片虽然保持了源图宽高,放大观察却能看到相邻逻辑块占用的物理列数或行数不同,表现为部分块更宽、部分块更窄;整数倍样例看起来正常,换一张网格数不能整除输入尺寸的图才复现。
- 原因:逻辑图宽高为检测后的列数、行数。把 `C × R` 的逻辑图用 nearest 恢复到 `W × H` 时,只要 `W % C != 0``H % R != 0`,目标栅格就只能在不同逻辑像素间分配 `floor / ceil` 数量的列或行;nearest 能避免混色,却不能让非整数缩放后的块严格等大。只用 `128 × 128 → 64 × 64 → 128 × 128` 这类整数倍测试会掩盖问题。
- 处理:采样仍是一格一像素;编码前只允许横纵同一整数 N 的 nearest 放大,N 取最接近规整输入尺寸的正整数,禁止非整数拉回精确 `W × H`。成品宽高比等于逻辑图,尺寸接近但不保证等于源图或占位。普通图片和角色的前置 Lanczos 交付尺寸归一仍用于确定检测输入;平底网格源与透明 RGBA 源仍必须同尺寸。
- 验证:整数倍样例与非整除样例都不得出现宽窄不一的逻辑块;响应、project resource、账号素材和结果 layer 都记录最终 PNG 实际尺寸,且只持久化一个最终 PNG,没有未放大逻辑图、输入尺寸恢复版、诊断图或额外资源。失败降级用例继续验证 Alpha / 交付尺寸守卫。
- 关联:`server-rs/crates/platform-image/src/pixel_art_snapper.rs``server-rs/crates/api-server/src/editor_project.rs``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## Codex CLI 节点不能把进程终态当成 Runtime 提交证据(2026-08-10
- 现象:`codex exec` 已启动或退出码为 `0`,但 JSONL 没有 `turn.completed`;或者已出现 `thread.started`,Runner 随后退出,重启时误以为节点已完成。GUI 进程的 PATH、安装或登录状态与交互终端不同时,还可能在默认模式下静默落回 HTTP Provider。
- 原因:CLI 进程、Provider lifecycle、retry/handoff 和 Runtime finalization 是连续但不同的 durable owner;本地进程退出不能代替现有账本的原子提交与回读。模式切换若不进入配置指纹,还会把另一执行模式的 retry/handoff 当成当前结果。
- 处理:Codex CLI 必须只作为新的节点推理适配器,继续经过原 Runtime 的 lifecycle、retry、handoff、receipt、revision、verification 和 reconciliation。只接受完整 JSONL `turn.completed` 与可验证的最终消息;`started``completed`、超时、异常退出或输出破损都继续走原失败/核对边界。`agentMode`、CLI 版本和影响输出的固定参数必须纳入配置指纹;CLI 不可用时显式报错,不得静默切回 Provider。
- 验证:确定性回归覆盖 stdin prompt、空临时 cwd、read-only/no-shell/ephemeral 参数、structured tool call 转换、缺失终态、超时进程组回收和 stderr 不泄漏;真实 smoke 必须显式 opt-in,并分开报告 CLI 协议成功与本机认证/网络结果。
## 模拟 Provider 的测试不能继承生产默认 Agent 模式(2026-08-11
- 现象:生产默认切到 `codex_app_server` 后,HTTP mock Provider 测试不再收到请求并超时;只验证 retry/handoff identity 的纯单测还会在未安装 Codex CLI 的 CI Runner 上直接失败。本机安装了 Codex 时,相关遗漏可能被掩盖。
- 原因:测试只构造 `agentLlm` 或直接调用读取全局配置的 identity helper,没有显式固定 `agentMode`;缺省配置按正式产品合同选择 `codex_app_server`,测试因此意外依赖本机 CLI 和认证环境。
- 处理:测试若验证 HTTP Provider 协议或 mock 请求,必须在测试配置中显式写入 `agentMode: provider`;只验证 Provider retry/handoff 数据结构的纯单测应调用显式接收模式的 identity helper。不得把生产默认模式改回 Provider,也不得仅为单测向通用 CI 镜像安装 Codex CLI。
- 验证:在 PATH 不含 Codex CLI 的环境运行 response-stream identity、MCP Runtime 和平台素材 mock 回归;同时保留独立的 Codex CLI/app-server 可用性与协议测试,防止 Provider 测试替代正式模式覆盖。
## 空 MCP 覆盖不会清除 Codex 用户配置中的 MCP2026-08-10
- 现象:以 `codex app-server -c 'mcp_servers={}'` 启动后,`thread/start` 仍发出用户配置中各 MCP server 的 startup 事件;若直接把这种进程当 AGC 节点 Agent,会出现 Codex 与 AGC 两套 ToolHost、副作用和审批边界。
- 原因:Codex `-c` 对 table 做配置合并,空 table 不是“删除已有所有条目”。长期 app-server 与一次性 `codex exec --ignore-user-config` 的配置隔离能力不同,不能照搬参数后假设用户配置已清空。
- 处理:为 AGC app-server 创建权限受限的临时 `CODEX_HOME`,只桥接已有 `auth.json`,不带入用户 `config.toml`、MCP、skills、hooks 或项目 rulesmodel/provider/effort 全部由 AGC 显式传入。server→client 请求一律拒绝,原生工具 item 一律协议失败。临时 HOME 和认证桥接不得写入项目、日志或持久账本。
- 验证:协议 smoke 必须观察 `thread/start` 后没有 MCP startup 事件;fake server 还要断言 API Key 不在 argv、初始化只一次、结构化输出回到 AGC ToolHost。只看到 `initialize` 成功不能证明安全隔离成立。
## 只隔离 CODEX_HOME 仍会加载用户 Skill,且 Codex 原生工具默认不全关闭(2026-08-10)
- 现象:app-server 虽然使用临时 `CODEX_HOME` 和 read-only turn,仍可能发现 `$HOME/.agents/skills`,并默认提供 cached web search、multi-agent 及其它稳定原生能力;等 `item/completed` 后再拒绝已经太晚,工具调用和额外模型成本可能已发生。
- 原因:Codex 的用户 Skill 发现根是 OS HOME,不是 `CODEX_HOME``dynamicTools=[]` 也只清空宿主动态工具,不会移除 Codex 内建工具。read-only/network off 是副作用防线,不等于从模型工具目录删除能力。
- 处理:同时隔离 `HOME / USERPROFILE / APPDATA / LOCALAPPDATA`,并在临时 workspace 创建空 `.git` 作为仓库发现边界,防止继续向父目录(例如 `/tmp`)发现 `.codex/.agents`;启动前设置 `web_search="disabled"``agents.enabled=false`,并关闭 shell/unified exec/browser/plugin/image/workspace dependency 等原生 feature;接收 `item/started` 时只允许消息、计划、推理和压缩等被动 item,其余立即 interrupt。配置中的 `webSearchEnabled=true` 必须失败关闭并提示切 `provider`
- 验证:fake app-server 检查 argv 不含 Key、专用 Key 只在环境、继承 `CODEX_API_KEY` 被移除、HOME 指向临时目录、web/multi-agent/shell 关闭;另覆盖 turn-start 回包前 drop 最终只发一次对应 interrupt。
## 多 Agent 共享一个 Codex app-server 会放大单点终态丢失(2026-08-10)
- 现象:多个节点最初已有 `started -> completed`,随后一个 app-server stdio 连接关闭,同一秒多个仍在途节点一起进入 `needs-reconciliation`;单看 threadId 不同会误以为节点已经进程隔离。
- 原因:pool 只按 LLM 凭据和路由复用进程,节点身份只用于进程内 thread map。任一 stdout framing、子进程退出或连接故障都会 drain 整个进程的 pending/turn router,使所有共享节点同时失去可信终态。
- 处理:pool key 必须包含 `projectId + agentId + sessionId + runId`,每个权威节点直接持有独立 app-server 子进程;同节点 turn 还要串行,不能向同一 thread 并发 `turn/start`。只发送当前 CLI schema 定义的字段;stderr 使用有界内存尾部并先脱敏再进入 Runner 诊断。
- 验证:至少两个节点并发各跑多轮,确认存在两个 app-server PID;终止其中一个后只有对应节点进入 reconciliation,另一个仍能收到 `turn/completed`。旧 AppData 缺 `agentMode` 且含非 Responses 路由时必须保留 `provider`,不能在项目自动恢复时批量失败。
## 模拟 Provider 的隔离 AppData 测试必须显式固定执行模式(2026-08-11)
- 现象:测试已经写入本地 mock `baseUrl / apiKey / model`,却收不到任何 HTTP 请求,日志反而显示 Codex app-server 启动或退出;Native shell 全量中多个后台 Agent 用例一起超时。
- 原因:新安装和没有迁移上下文的隔离 AppData 默认使用 `codex_app_server`。只写 `agentLlm` 不能表达测试要走 HTTP Provider;直接切换 runtime config dir 的 fixture 也不会经过会自动补 `agentMode` 的测试 helper。
- 处理:任何要断言模拟 HTTP Provider 请求的配置都必须显式写 `agentMode=provider`。测试 helper 可以统一补齐,但直接写隔离 AppData 的 fixture 仍须在自身 JSON 中声明,不能依赖仓库 `.env`、用户 AppData 或历史迁移。
- 验证:先单跑失败用例确认请求命中 mock server,再执行完整 `npm run check:native-shells`;日志中不得出现该用例启动 Codex CLI/app-server,所有 Provider/MCP 请求数量和顺序按 fixture 闭合。
## Tauri 生成与资源编辑恢复不能依赖 UI 快照、旧 Key 指纹或队列首项(2026-08-11
- 现象:应用重启后,任务视频、项目版本或 refine 草稿无法恢复;轮换 Developer API Key 后已有 operation 被误判为配置变化,已受理任务一次 401/403 还可能永久进入对账;文本 Provider 已成功但尚未 staging 时崩溃会重复调用。目录中放入大量无关文件还能绕过 pending 扫描上限。一条远端已明确失败的老 operation 会持续占据队列首项,挡住后续已受理或已下载任务;manifest 已写而 project revision 未写时,又可能被误标为 committed,或者 journal 已证明提交后因项目继续合法修改而无法补 ledger。durable committed 后遗留 staging 可能因一次删除失败而被误报为提交失败,也可能在正式媒体或 manifest 身份已经漂移时被直接删除;manifest 已有派生子版本而 journal 缺失,或旧 journal 没有 revision 身份时,也可能被猜成已经提交。派生视频再次编辑时若把 `assetObjectId` 当远端引用,生成会失败或指向错误身份。
- 原因:早期账本只保存显示层资源 ID,恢复时又依赖当前页面资源对象;refine `draftId` 只在组件 Map;配置指纹混入 Key 并把认证错误写成状态机终态;Provider 正文从内存直接进入解析/staging;扫描计数只在识别出 pending JSON 后递增;本地登记 ID 与 External generation 接受的稳定 `objectKey` 没有分层。恢复 UI 只选排序后第一项,而账本又没有远端终态失败/归档阶段;资产提交恢复把整个历史 after manifest 当作永久相等条件,没有区分目标事务事实与后续合法提交;旧 version journal 只保存 base/target 数值,不能证明完整 project revision before/after 身份。
- 处理:新账本冻结完整源快照,旧账本从权威 manifest、完成任务和版本记录有界恢复;refine 从正式 sidecar 按项目、意图、源素材和 active 状态唯一发现。服务身份用 `service-origin-v1` 哈希规范化 External base URL,确认 UI 只展示去除路径与凭据的服务 origin;旧 Key-bound 指纹由快照绑定的显式挑战迁移,确认前零网络动作,已受理任务换 Key 后只 GET 原 operation。Provider 调用前先持久化 request-issued,成功正文再写 durable handoff 后解析/stagingissued 无 handoff 只能对账。扫描在读取每个目录条目时先计数,任何文件都消耗预算。提交前复验源摘要,远端请求只使用账本已确认的稳定 `objectKey`,恢复始终复用原 operation 和请求字节。
- 队列与事务:独立恢复面板必须展示后端权威队列的所有 operation,读取失败不能伪装为空。`remote-failed` 不再重放,只能显式标为 `archived` 并保留账本;`reconciliation-required` 不能归档。派生 asset 使用 `prepared -> media-installed -> manifest-written -> revision-written -> committed` journal,只对可证明状态前向恢复;尚未证明目标写入时严格核对 before/after,已证明目标 asset/media 与 target revision 后允许 manifest/revision 被后续合法提交继续推进,并补齐同一 ledger。committed 后只有 staging 与正式媒体摘要一致、manifest 按 ID 或路径唯一精确匹配 journal asset 时才尽力清理;删除 I/O 失败保持 durable committed,身份或媒体漂移保留 staging 并进入对账。version journal 同样冻结 project revision before/after 身份;manifest 已有子版本但 journal 缺失,或旧 journal 面对已推进 revision 无法补证时都失败关闭。
- 验证:覆盖跨进程唯一 refine 草稿发现和多候选失败关闭、文本 Provider 成功到 staging 崩溃后零重复调用、任务视频/版本旧账本恢复、所有目录条目上限、Key 轮换与旧 Key 无法验证时的显式确认、Accepted 后 401/403 再换 Key 只 GET 原 operation、远端明确失败只归档且零新网络/扣费、三条乱序恢复队列、项目切换迟到结果、asset transaction 各崩溃阶段、revision 后项目继续合法修改仍补齐 ledger、committed 后 staging 清理成功/删除 I/O 失败/媒体或 manifest 漂移保留、源摘要漂移拒绝、committed 视频二次派生,以及 manifest 子版本缺 journal、旧 version journal 无法证明 revision 推进与 version journal exactly-once。
## 子 Agent 澄清不能直接穿透用户输入权限(2026-08-12)
- 现象:child 需要产品取舍时若直接调用 `user.input_request` 会被 owner gate 拒绝;若把它误走 `needs-repair`Supervisor 会错误返工而永远不向用户提问。
- 正确路径:child 返回短小的 `AGC_NEEDS_USER_INPUT_V1` envelopeRuntime 生成 `needs-user-input` delivery,父 Supervisor 认领后创建自己的 durable `user.input_request`。回答仍绑定原父 run,续建 child 由稳定 delegation identity 幂等控制。
- 验证:重复 wake / Runner 重启不得创建第二个用户输入 action;问题数量、字段长度、问题 SHA 和答案 SHA 不匹配时必须 fail-closed。child 直接请求用户输入仍应保持拒绝。
- 恢复加固:正常 completed child 的最终回复也必须进入 envelope 解析;回答后 pending 会被下一轮动作替换,因此 continuation 不能读取 current pending 作为证据,必须读取原 delivery 上的 durable request/answer 绑定。多个 child 各用一条用户请求逐一收束,禁止把不同 delivery 的问题和答案指纹拍平混用。
- 协议演进:`agent.delegate` 的澄清 continuation 字段虽然在 strict schema 中是 required nullable,但 Runtime 解析器仍必须接受完全未携带这三个字段的既有调用;只允许三者全缺失、全 `null` 或全为合法字符串,部分出现、部分字符串和非法 SHA 均失败关闭。新增 schema 字段时要同步原生函数目录断言与旧调用回归,避免协议修复轮次打乱 Supervisor 协作计划。
- 门禁优先级:已经存在真实 `preview.validate` 失败 observation 或 durable `failed_playtest_revision` 时,具体试玩修复与新 revision 重新验证门禁必须先于通用“首次 mutation”门禁;否则 Runtime 会把明确的试玩修复错误收窄成普通 pre-mutation repair,导致 Supervisor 无法选择正确的协作动作。
## 无限画布延迟草稿与零位移不能制造新状态(2026-08-11)
- 现象:r5 的 `loadDraft` 比 r6 更晚回包时会把草稿回退;pointerdown 后没有任何移动,pointerup 仍增加一条空 undo 并触发 CAS 保存;Tauri 自行维护 Shift toggle 后,Shift 单击唯一选中图层会意外清空选择。同一 `canvas.failed` 视图还可能把草稿保存或提交故障显示成生成重试。
- 原因:延迟回包只与发起时 revision 比较,没有在落地时复核当前最高 revision;指针按下就 capture history,而不是等首次真实几何变化;宿主复制了共享 selection 规则;失败状态没有携带发生故障的 operation 类别。
- 处理:generation progress、保存队列、生成/提交回包和延迟 `loadDraft` 统一用当前 scope、触发最低 revision 与回包当下草稿的单调门禁;同 revision 只允许完整相等回包。Tauri 指针和键盘选择复用 `resolveLayerPointerSelection`。pointerdown 只冻结快照,首次真实 move/resize/pan 才 capture 一次;零位移、未变选择和锁定图层不增加 undo、documentVersion 或草稿保存。
- 失败边界:`canvas.failed` 必须携带 `generation / draft-save / asset-commit / recovery / cancellation`,只有 `generation` 失败显示“返回修改/重新确认”。保存/CAS 只重试或重载,提交/恢复只安全恢复或对账,取消故障只保留草稿继续编辑;初始恢复失败也不得进入生成重试。
- 验证:用 deferred Promise 覆盖 r5/r6 逆序、保存与 progress 交错和 scope 切换;同时覆盖 Shift 单选自身、多选拖动、指针完整序列、零位移、首次有效移动只一条 history,以及五类失败的可访问名称与按钮集。
## Windows MSVC 测试不要依赖系统 OpenSSL2026-08-17
- 现象:Windows 上执行 Rust 测试时,`openssl-sys` 构建脚本因找不到 OpenSSL 开发目录或 vcpkg 而失败;机器即使带有 Strawberry Perl 的 `openssl.exe``libssl.a`,也不能直接供 `x86_64-pc-windows-msvc` 链接。
- 原因:测试代码仅为动态生成 RSA fixture 引入 `openssl` dev-dependency,从而把原本使用纯 Rust 加密实现的 crate 额外绑定到本机原生 OpenSSL 工具链;Strawberry 附带的库面向 MinGW,不等于可用的 MSVC OpenSSL SDK。
- 处理:测试优先使用明确标记、只供测试的固定 PEM fixture,并继续通过项目自身的密钥解析与签名验证路径覆盖真实行为;不要仅为生成 fixture 引入系统原生库,也不要把 MinGW OpenSSL 路径写入 `OPENSSL_DIR` 冒充 MSVC 依赖。
- 验证:运行目标 crate 的 `cargo tree -i openssl-sys --target all` 确认依赖已退出,再执行包含测试目标的 `cargo test --tests`,不能只用不会编译 dev-dependency 的 `cargo check --lib` 代替。
## 图片编辑请求与资源卡几何不能依赖宽松 DTO 或可淘汰预览(2026-08-20
- 现象一:Game Agent 快速编辑返回“平台明确拒绝”,远端没有创建任务。原因是 `/api/external/v1/editor/images/edits` 使用 `deny_unknown_fields`,客户端却把创建接口的 `assetKind` 以及本地来源字段一起发送;统一 400 文案又掩盖了真实参数错误。处理时必须按 create/edit 各自权威 DTO 组装请求,Game Agent kind 先映射为编辑器 canonical kind,并把安全业务错误分类,不能回显远端敏感 body。
- 现象二:图片预览已经读取真实宽高,但大量资源触发 LRU 淘汰后,卡片又退回 `180x128`,布局和连线随之跳动。原因是布局尺寸直接从可淘汰的 Blob/Object URL 预览缓存派生。处理时预览二进制仍按预算淘汰,但已验证的轻量 `pixelWidth/pixelHeight` 必须在当前项目、模式和资源 identity 作用域内独立保留;identity 或 scope 变化时再清理。
- 验证:分别覆盖严格 edit body、错误分类、生成占位恢复,以及超过预览缓存条目上限后首张图片仍保持真实比例、布局碰撞和依赖端点不退化。
# 2026-08-21 已有资源 ID 不等于可用于快速编辑的 canonical 来源
- manifest 中的 `source.resourceId` 可能指向历史按 `game-background` 等 Game Agent 私有 kind 登记的远端资源。只检查 ID 前缀并直接传给 `sourceReferenceId` 会在 External v1 入队前得到 `unsupported-source-kind`;本地 kind 映射只有在真正重新登记来源时才生效。
- 隐藏文件 input、逐张写 `draft-media`、前端追加图层再等待 autosave 的组合不是导入事务。失败可能表现为“按钮没反应”、留下孤立媒体或让图片出现在不可见的固定坐标。Tauri 正式链路应由原生多选和后端批量草稿更新闭环。
- 批量导入不能在安装首个媒体后继续执行带 `?` 的 ID、路径、层序或 revision 计算;这些步骤必须先完成。原子草稿写入返回错误后,回读失败属于提交结果未知,必须保留媒体并报对账错误,不能把回读错误压成“未提交”后删除可能已被草稿引用的文件。所有回滚删除失败也必须显式上报。
- 失败 generation 同时存在私有 ledger 和 draft 投影,只在 React state 中 `filter` 会在重启后复活。删除 UI 必须调用只允许明确失败任务的后端归档操作,结果未知任务不能删除。
## 运行中 generation 与 refine 来源身份不能按 create 链路处理(2026-08-21
- 草稿 hydrate 后同步等待远端 generation recovery,会让生命周期长期停在 recovering,连平移和选图也被 inert。关键事务恢复与任务恢复必须拆开:前者先完成,后者后台推进且失败只进入任务/notice。
- refine 提交若无条件校验 `source.resourceId == local-asset:<assetId>`,会拒绝本来合法的 `editor-resource-*` 已登记来源,并可能把事务卡在 `revision-installed`。创建与未登记本地资产才补 local identity;已登记 refine 必须保留并按解析后的来源身份回读。
- 画布内部 absolute overlay 只能覆盖宿主网格,嵌在左右分栏时不会遮住整个窗口。阻断性失败必须 portal 到 `document.body`,并用 fixed inset 覆盖整个 WebView;测试应验证 portal 的直接宿主和 fullscreen modifier。
## 自由画板 viewport 与资源 extent 分离(2026-08-24
- 非空资源栏目使用无限画布:普通平移的 `x / y` 不按资源 extent 夹取,窗口 resize、媒体测量和资源 extent 变化也不得把用户 viewport 拉回内容边界;共享 `MIN_SCALE/MAX_SCALE` 只约束缩放比例。只有首次进入组合或用户显式复位时,才用真实卡片包围盒计算 fit。
- 持久化布局坐标仍受 `-1_000_000..=1_000_000` 合同限制,负坐标必须进入真实内容包围盒和显式 fit;这与 viewport 能否继续平移是两层独立语义。搜索只控制卡片与连线可见性,不删除、压缩或重排布局坐标,也不能让 viewport 随 `visibleResources` 收缩跳动。
## 素材画布旧提交不能只按当前状态猜测恢复(2026-08-22)
- 现象:同一 refine 资源的旧事务停在 `reconciliation-required`,后续另一笔事务已成功并更新当前 manifest/revision。恢复器只对比旧事务自己的 before/after 快照时,会把“已被后续提交取代”永久误报为需要人工对账。
- 处理:只有后继事务 committed、项目/草稿/资源身份一致、旧 after 与后继 before 精确衔接、后继 after 与当前 manifest/revision 精确一致、两份候选文件与 manifest 唯一引用都验证通过时,才把旧事务标为 `superseded`;候选文件和审计记录都保留。任何证据不完整继续失败关闭。
- 门禁:同一资源存在 prepared 或 reconciliation 事务时,新的“设为正式图”提交必须先被拒绝并引导安全恢复,不能继续制造另一笔可能覆盖旧结果的提交。`committed/rolled-back/superseded` 是可继续后续提交的终态。
- 关联坑:正式文件路径会追加 commitId,重新打开 refine 时不能把整个文件 stem 当作下一次素材名,否则多次精修后超过 80 字符并阻塞提交。路径只推导显示名,且必须循环剥离历史 `--<uuid>` 后缀;确定性名称/用途校验要发生在读取候选与 staging 前,并按输入错误处理而不是进入事务恢复。
## 素材画布候选确认不能复用普通草稿保存(2026-08-23)
- 现象:候选挂载后用 fire-and-forget `updateDraft` 确认时,候选本身不增加前端 document version;用户立即设为最终图、导入、生成、归档或放弃草稿会携带旧 revision 与隐藏确认写入竞态。重新打开含 `candidate-ready` 的草稿还会重复保存同一画布、无意义推进 revision,并制造多窗口冲突。
- 处理:候选确认使用独立幂等宿主操作,在现有草稿锁内只更新匹配当前项目、草稿、generation、仍存在权威图层且尚未确认的私有 ledger,记录当前草稿 revision,但不改写草稿或推进 revision。普通草稿 update 只保护未确认候选,不再顺带确认。
- 前端门禁:确认任务进入 autosave FIFO,连续候选 ID 合并并串行处理;所有消费 draft revision 的提交、导入、生成、归档和 discard 必须先等待确认屏障。确认失败不得继续 revision-sensitive 操作,重复 hydrate 可以重发但后端必须零写入。
- 验证:前端覆盖确认 pending 时立即设为最终图和删除,断言提交 / update 在确认完成前均未发生;重复打开不调用 `updateDraft` 且 revision 不变。Rust 覆盖首次确认、重复确认、未知或非候选 ID、普通 update 不确认,以及确认后显式删除。
## 隔离 Codex app-server 会误吃代理的 ChatGPT 额度头(2026-08-20
- 现象:同一自定义 Responses endpoint 和 API Key 直接 HTTP 为 200,普通用户 HOME 下的 smoke 也完成,但隔离 `CODEX_HOME/HOME` 的 app-server 在真正发请求前返回 `usageLimitExceeded`,并投影 credits balance 0。
- 原因:开发网关把 `X-Codex-*` ChatGPT 账户额度头附在 API Key Provider 响应上;隔离进程没有用户 ChatGPT 额度状态覆盖,Codex 0.147 将这些头当作本地账户限制。模型、Key、MCP 和 Skill 均不是根因。
- 处理:只为 Direct conversation 启动随机 loopback `/responses` 流式代理;它不注入 Authorization,只转发请求自带 Bearer,拒绝其它方法/路径并剥离 `X-Codex-*` 账户头。不要复制用户 `auth.json` 来掩盖问题,也不要把 Provider 切换当根因修复。
- 验证:同时记录上游直接 200、未过滤时 credits=0/usage-limit、过滤后真实 turn completed;代理测试必须证明无 Bearer 拒绝、路径收窄、正文流式保留和额度头不下传。
## Codex MCP 子进程不适合直接启动桌面浏览器(2026-08-20)
- 现象:同一 `agc_browser_playtest` 在普通进程中能返回双视口截图,但从 Codex 启动的 STDIO MCP 子进程调用时 Chrome 启动超时。
- 原因:MCP 子进程继承隔离 HOME/AppData 和 Codex 进程约束;把真实浏览器或 GUI 登录态硬塞给子进程既不稳定,也扩大凭据边界。
- 处理:STDIO MCP 只做 schema 与协议适配;浏览器和付费美术通过随机 loopback 工具桥回到持有项目、登录态和正常桌面环境的客户端主进程。桥只绑定当前项目、限制请求大小和审核工具名,返回脱敏文本与有界 PNG。
- 验证:必须从真实 Codex thread 发起 MCP 调用并观察 desktop/mobile `readyState=complete` 与两张截图;直接运行 MCP 二进制成功不能替代该链路。
## 独立 Cargo workspace 的测试增量缓存会吞噬数百 GiB2026-08-22
- 现象:`server-rs/target` 与 AGC `src-tauri/target` 合计超过 `231 GiB`;其中两个 `debug/incremental``158.5 GiB`server-rs 累积 `1298` 个增量会话目录,AGC 累积 `100` 个。
- 原因:两个 Cargo workspace 拥有独立 target 和锁文件,AGC 又以 path dependency 复用若干 server-rs crate;更主要的是全量 / 分组 `cargo test` 继承增量编译,每组 crate / feature / profile hash 都可以留下新会话,Cargo 不会按仓库期望自动收缩这些历史目录。
- 处理:保留两个 workspace 的产品 / 发布边界;两边 `[profile.test]` 关闭 incremental 并固定 `debug=1`AGC dev profile 与 server-rs 对齐调试信息级别。日常用 `npm run audit:rust-build-cache` 只读核对;需要回收时先停止 Cargo / rustc,再显式运行 `npm run clean:rust-incremental -- --apply`,只删两个固定增量目录。
- 验证:清理前后各跑一次只读审计并核对磁盘可用空间;分别运行 server-rs 与 AGC 定向 `cargo test`,确认 test profile 不再生成持久 `debug/incremental` 堆积。共享 `CARGO_TARGET_DIR` 必须另做并发启动基准,不得为节省磁盘直接改变生产产物路径。
## BuildKit secret 不等于镜像内 secrets 不可提取(2026-08-22
- 现象:构建时使用 BuildKit secret mount,日志和普通 build context 都没有出现明文,于是误以为最终镜像也能不可提取地保存 secrets,随后将镜像 push 或导出给不同信任域。
- 原因:BuildKit secret mount 只避免秘密作为 `ARG` / `COPY` 进入构建上下文和中间指令;一旦 Dockerfile 把 mount 的内容安装到最终 rootfs,任何能读取、保存或运行该镜像的主体都可以提取它。
- 处理:预览固定 secrets 只从 Jenkins 宿主受控路径读取,严格校验目录 `0700`、文件 `0600`、owner、普通文件与非链接边界;只将其安装到 `api-runtime:/srv/genarrative/.env.secrets.local` 并设为 `0400`,明确排除 Nginx、Web、artifact 和其它镜像。镜像禁止推送或导出到跨信任边界。
- 更新与验证:源文件变更不会改动已存镜像,必须重建并替换容器;不能用重启代替。验收同时扫描 transcript/context/artifact 零泄漏,检查只有 API 最终 rootfs 存在目标文件,并验证容器显式运行 env 优先覆盖内置值。
## SpacetimeDB ping 健康不代表完整模块能在内存上限内实例化(2026-08-22)
- 现象:空库 `/v1/ping` 已成功且容器显示 healthy,但 `spacetime publish``Publishing module...` 后连接提前关闭,紧接着端口拒绝连接。
- 原因:当前完整模块 init 的 RSS 会超过基础 Compose 旧 `896m` cgroup 上限;内核 OOM kill SpacetimeDB,客户端只看到传输错误,容易被误判为网络竞态。
- 处理:先查 kernel journal 的 `Memory cgroup out of memory` 和目标容器 ID,再把本地/预发完整容器 SpacetimeDB 上限统一为 `2g`;保留 page pool 限制。不要只增加 publish 重试,也不要把 `/healthz` 或首页改成数据库就绪探针。
- 验证:用新空卷完成模块 publish、五服务启动和 Web/API smoke,并确认容器未 OOM、SpacetimeDB 与 API/Nginx 最终 healthy。
## 同一本地项目切换账号后不能继续信任 manifest 远端 ID2026-08-23
- 现象:本地资源文件仍存在,但账号 A 生成后切到账号 B,快速编辑、GIF/视频派生、art-spec 下游生成或直连恢复提示画布不存在、无权限或资源不属于当前项目;原请求重试仍失败。
- 原因:本地 manifest 只有一组 `source.canvasProjectId / resourceId / assetObjectId`,旧实现把它同时当作历史来源和当前账号可编辑引用。新的 operation 虽已绑定 B,却会把 A 的 project/resource/object 身份配合 B Token 发出;另一部分链路又把 A 的画布 ID 与 B 按标题选出的项目严格比较。
- 处理:manifest 远端字段只保留历史 provenance。所有远端编辑和派生先解析 `.agent/runtime/external-editor-bindings/` 中当前 principal 的项目/资源 binding;不存在时从本地正式文件按 asset ID、SHA-256、媒体类型和 canonical kind 在当前账号重登记。禁止按标题、manifest ID 或其他账号 committed ledger 自动采纳远端对象。A 的在途 operation 继续留在 A,B 只能开始自己的新操作。
- 并发与升级陷阱:首次 binding 不能用无锁的“先查后建”,否则两个 generation 会各自创建远端项目/目录;进程锁只能压住当前存活客户端,远端创建还必须携带由 binding key 派生的稳定 `Idempotency-Key`,关闭“响应已返回但 sidecar 未落盘”时重启重复创建的窗口。canonical ID 变更也不能只改新请求指纹;必须用本地 asset、路径和内容摘要白名单恢复旧 accepted/committed 账本,避免升级后已付费结果永久无法恢复。
- binding wire 升级陷阱:在仍标 v1 的 struct 上直接新增必填指纹,会让所有已有本地项目的 binding 反序列化失败,重现“画布项目丢失”。必须升为新 schema,用独立严格 legacy wire 只迁移完整通过现役不变量校验的旧文档;不能用 default 缺失字段或在身份校验前回写。
- 在途切号陷阱:只在首次 POST 前校验 session 不足以锁定 principal。获得 `operationId` 后必须先落盘,后续每次 poll、download 和本地 commit 都要复验原 session;同步 commit 的“先校验再写入”仍有切号竞态,必须用会话租约把校验与本地安装线性化。手工 Tauri 入口若使用临时幂等键,也会在 202 后切号时丢失恢复身份。切号后继续用 A Token 轮询或安装 A 结果同样是账号边界缺失。
- renderer / native 两阶段陷阱:只用一个 generation 同时表示 UI 转换和 native CAS,或在 Rust 确认前先替换 committed Token,会让迟到 install / clear 把新账号覆盖回旧账号。正确做法是 auth generation 与 native 只增 generation 分离,所有 native mutation 串行,入队前冻结 token + origin + user,并持续以显式 account / `null` 期望权威对账迟到完成。对账失败不能恢复未确认候选会话,而要清空 committed 会话与 Token。
- auth origin 竞态陷阱:只在登录前持久化服务器选择不等于冻结事务 origin;请求 A 在途时若 UI 改为 B,返回的 A Token 可能被安装到 B origin。登录、hydrate 与 refresh 必须在首个请求前冻结 origin,让 HTTP 链与 native commit 共用该快照;同时按 origin 隔离 refresh singleflight,不能让 A 的 Promise 被 B 复用。
- stale refresh 陷阱:请求在入队前写入候选 Token 后,若 queued commit 直接因 generation 过期返回却不恢复 renderer authority,本地请求会继续携带错账号 Token。另外,A refresh 失败晚于 B 登录成功时,若仍向全局发布 failed,`AuthenticatedClient` 会把 B 误登出。所有 stale early return 先恢复当前 committed / desired Token;旧 owner 的迟到 refresh failure 只返回 `stale`,不 clear、不发布 failed。
- 账本写入租约陷阱:只在进入命令时读一次当前账号,仍可能在 owner 绑定、服务身份指纹 / 挑战或确认落盘前切到 B,从而向 A 账本写入 B 身份。这些写入必须持同一冻结 platform session 租约并精确校验 ledger owner;公开的 request / confirm 命令也必须自身完成 owner 门禁,不能依赖调用方曾经走过恢复流程。
- 恢复列表泄漏陷阱:项目相同不代表账本对当前账号可见。如果列表只按 project / phase 扫描,切 B 后会展示 A 的 operation,迟到回包还可能把已清空的列表重新写回。扫描必须持当前 session 租约并按完整 owner `userId + api origin` 过滤;未绑 / 不完整的远端账本隐藏,纯本地编辑保留。renderer 还要用 auth generation 使恢复 read epoch 失效并清空相关操作状态。
- 追加审计回滚陷阱:Direct 恢复中 `asset.register` 是 append-only 审计,返回错误不能证明 append 未持久。若 file / manifest 已落盘且审计成功或结果未知,删文件或回滚 manifest 会创造“审计已存在、资源却消失”的第二种不一致,重试还可能复制审计或重新扣费。应保留 file + manifest + audit 现场并标记 `reconciliation-required`;后续 binding 失败也使用同一语义。
- Runner 跨 GUI 陷阱:WebView 的 `authGeneration` 会随 GUI 进程重启从较低值重新开始,但 busy Runner 可能仍持有旧 GUI 的高 generation。只用 generation CAS 会把新账号安装误判为过期;只把 OS owner 锁当作授权,或保留独立 `platform.session.install/clear` 入口,又会让 Runner 在单次 IPC 丢失后继续使用旧账号。Runner 协议 v7 由 owner 锁创建随机 epoch,每次会话变更先推进 durable revision claim,并且只允许与 claim 完全匹配的 `runner.attach_gui_owner` 安装会话。Runner 要持续比对 claim,失配立即清空平台会话并拒绝 Runtime 请求;GUI 同步失败还要隔离或停止旧 Runner,不能只向前端报错。
- 验证:用真实平台会话 fixture 覆盖 A→B→重启→A,逐个断言 B 的 URL、请求体和稳定引用中没有 A 的 project/resource/object ID;另测同名 localProjectId 隔离、改名不漂移、源摘要变化、非 refine 参考、视频 committed objectKey、art-spec 派生以及在途 operation 切号零网络。Runner 回归还要覆盖高 generation 旧 epoch 被低 generation 新 epoch 正确替换、迟到旧 epoch/revision attach 失败关闭、claim 改写或同步失败后旧 Runner 零 Runtime 请求。Developer Key fixture 不能替代平台账号隔离证据。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs``project/resource_editor.rs``project/asset_canvas/generation.rs``agent/generation/canvas_generation.rs``agent/direct_runtime.rs`
## Direct 美术工具不能把“包存在”当成“本次已生成”(2026-08-23)
- 现象:用户明确要求重做美术或切换游戏主题,工具仍立即返回 `assets/art-spec.png``assets/direct-game-background.png``assets/art-spritesheet.png`;新需求没有 Provider operation,游戏继续使用旧图。切片虽然已经落盘,也可能不出现在资源管理或工具结果中。
- 原因:旧 Direct 工具只有 `brief`,完整包校验成功后无条件短路;固定阶段账本恢复又未比较本次生成 prompt。切片只写文件和切片清单,未作为顶层 manifest asset 投影;工具桥只返回三条主路径并丢失切片与 warning。
- 处理:显式重做使用 `mode=regenerate`,普通请求使用 `reuse-or-create`。重生成必须由当前最新 User 消息明确授权并绑定客户端稳定 `clientTurnId`。授权先对完整原文做 Unicode NFKC 与撇号规范化,随后整串必须完整匹配审核过的独立立即执行指令,只允许句号/感叹号收尾;不得剥离引号、方括号或代码片段,动作前后也不得携带 brief、条件、否定、选择、确认、费用、延迟或其它文本。风格需求先单独描述,再由下一条独立“请重新生成美术”消息确认;不要靠扩充 deny 同义词推断付费同意。同一调用完成回包丢失只从 `completed` 持久结果等值重放,不能因重试再次扣费。App 必须在 Direct 调用前落盘原始 User 消息和回合 ID,Tauri 必须在成功返回前幂等落盘同 ID assistant 终态;同进程重复水合若命中“回合仍在运行”,只能显示瞬时占用提示,不得以稳定 assistant messageId 写成终态并抢占原执行的成功回复。恢复扫描与启动前置恢复必须发现 `resetting / compensating / anchored in-progress` 并在专用锁内恢复,重开项目只续跑真正未回答的原身份。整条付费链必须持有专用跨进程执行锁;换新回合时先持久化 `resetting` 再清理旧阶段账本,不得通过删除 workflow 留出无主窗口。崩溃补偿只恢复旧文件并清 replacement CAS 锚点,已 `prepared / accepted` 阶段账本、原 `Idempotency-Key / operationId` 必须保留,同冻结意图续跑复用旧请求;未知账本在文件 mutation 前失败关闭。只有没有任何阶段账本和替换锚点的孤立 workflow 空壳可原子接管;旧 schema 和其余冲突失败关闭。遇到 prompt 或当前 art-spec 身份不一致的未决账本必须保留原 operation 并返回对账错误。Direct app-server 可写边界只限真实 canonical `game/`canonical 项目根的原生 OS 路径字节与权威 manifest `projectId` 经域标签和独立长度前缀编码后共同绑定连接池和 thread 身份,不得写项目根、`assets/``.agent/`,也不得获得网络、命令、MCP 或权限扩权;受控工具如果需要项目级客户端状态,只能从同一真实 `game/` cwd 经相同校验内部反查项目根,不能扩大模型可写根。标准图集首次创建和重生成都要求四张透明、可见、像素及平台身份唯一的 canonical 切片;工具只回传通过私有回执、公开清单、源图和顶层登记交叉验证的 `slicePaths` 与安全 `resources`。部分/opaque/重复/缺回执切片必须告警,不能把公开清单或顶层自述身份当作 Canvas 权威。
- 同进程恢复补充:命中“同一 stable turn 仍在运行”后除禁止写 assistant 终态外,还必须删除当前 App 实例的恢复 claim。这样原调用随后成功时显式刷新能读取其终态,随后失败时也能按相同 `clientTurnId` 再次续跑;不要靠重载 WebView 清理进程内 claim,也不要用无界定时轮询制造并发调用。
- 严格图集崩溃补充:规范图和背景图的两文件 rollback 不覆盖严格图集事务已经整体修改的 `.agent/manifest.json`、私有回执、公开清单、主图集、四切片和切片清单。必须在严格调用前持久化 pending 及九项旧合同身份;重启恢复先对账底层严格事务,完整新合同直接收口完成,完整旧合同才补偿前两阶段,混合或漂移状态失败关闭。不要在严格提交成功后局部恢复前两张图。
- 部分旧包补充:rollback 的规范图/背景图必须保存旧字节与旧 manifest entry,不能把这两项缺失隐式当成空内容;显式 `regenerate` 因此只在这两项可信可回滚时开放。历史主图集、私有回执、公开清单或 canonical 切片可以缺失,但八个严格路径与受管顶层 asset identity 必须逐项冻结其真实 `Present/Some``Missing/None` 状态,补偿也必须恢复相同存在性。不要因为旧美术包缺切片而阻断重生成,也不要把本轮新建的严格文件误记成旧文件。
- 对话扫描与 claim 补充:历史中出现 `User A / User B / Assistant B` 时,B 已回答不代表 A 已回答,扫描必须继续寻找 A。成功 Direct 回复在 Rust 返回前已经落盘,前端冗余 append 失败不能据此重跑;普通错误回复的显式落盘失败时,恢复 claim 要保持到 React fallback writer 的同一 messageId append 明确收敛。writer 成功或明确失败后才释放;失败路径要停止该消息的自动迟到重试,再由显式 `/history` 复用原 stable turn。终态后及时删除 claim,避免 Set 无界增长。
## GDD 历史审批回执误触发当前恢复提示(2026-08-27)
- 现象:修改 GDD 后新版本标题和内容已正确落盘,但审批卡一直显示“审批状态正在恢复”。
- 原因:`approval pending` 是当前 lineage 最新 GDD 的单例投影;恢复扫描却让每个历史 receipt 都拿它做 identity 比对。旧 receipt 与新 pending 不同并不表示损坏。
- 处理:历史 receipt 只修复自身投影;只有最新 GDD 的 receipt 才能校验、更新或清理当前 approval pending。不要在前端隐藏 `recoveryPending`,也不要取消最新版本的 identity fail-closed 检查。
## Native shell CI 不能在测试阶段重新解析 Cargo registry2026-08-26
- 现象:原生壳 job 的依赖预取成功后,AGC 检查仍在 `platform-llm` 测试阶段重新更新 registry index,并因 `symphonia` 下载的 TLS EOF 失败。
- 原因:native job 没有显式预取 `server-rs/Cargo.toml``ai-game-creator-shell:check` 的 server-rs workspace 命令没有 `--locked`
- 处理:native job 预取 server-rs、桌面壳和 AGC 壳三份 lockfile`platform-llm``shared-contracts` 与 AGC 壳测试统一使用 `--locked`,不降低原生测试门禁。
- 验证:workflow 回归测试、锁定的 Rust 测试和原生壳门禁均需运行;若本地 EAS CLI 版本漂移,应单独报告环境阻塞,不把它误判为本次 Cargo CI 修复失败。
## Tauri Windows NSIS 工具缓存不能依赖 Jenkins systemprofile AppData2026-09-02
- 现象:AGC Windows 构建已完成 Rust release binaryTauri 下载并解压 NSIS 后,在 `Running makensis``Unable to start child process, error 0x2`
- 原因:Tauri Windows bundler 执行自己的 `<tauri_tools_path>\NSIS\makensis.exe`,默认位于当前用户 `%LOCALAPPDATA%\tauri`,不使用 PATH 中预装的 `makensis.exe`Jenkins LocalSystem/systemprofile 的 AppData 可能无法启动该缓存程序。
- 处理:Windows 专用 Tauri 配置设置 `bundle.useLocalToolsDir: true`,把工具缓存到 `src-tauri/target/.tauri/NSIS`;Jenkins 预检验证实际用户、项目工具目录可写,并在构建失败时打印实际缓存路径和绝对路径执行结果。
- 验证:不要把 PATH 中 `makensis` 可发现当作 Tauri bundler 工具可执行的充分证据;需要在 Windows Agent 上检查 `target/.tauri/NSIS/makensis.exe`、ACL、EDR/Defender 和直接 `-VERSION` 结果。