Merge branch 'master' into opt/compile-warning
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m22s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / Backend tests (pull_request) Successful in 4m1s
Project CI / Frontend tests (pull_request) Successful in 2m10s
Project CI / Native shell tests (pull_request) Successful in 6m0s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m4s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m6s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m20s
Project CI / Repository checks (pull_request) Successful in 1m52s

This commit is contained in:
2026-09-23 22:36:36 +08:00
59 changed files with 3054 additions and 315 deletions
@@ -1,5 +1,15 @@
# 决策记录
## 2026-09-23 运行视窗:右下角全屏预览 + 没有内容就自动收起的信息栏
- 背景:运行页右下角缺一个把游戏画面放大到整屏的入口;运行视窗下方常驻「信息展示 / 数值微调」两张卡片,没有选中资源时就是两块空白,验收现场提出「没有功能就暂时隐藏」。
- 决策一:新增 `useElementFullscreen`(`apps/ai-game-creator-shell/src/features/project-workspace/`),用标准**元素级** Fullscreen API 把**画面那一格**(`.game-run-preview`)送进全屏——不接 Tauri 窗口级全屏,那是把整块工作台连对话栏一起放大的「全屏应用」,不是「全屏预览画面」。入口贴在画面右下角,全屏后仍在原位可点退出;按钮态只认 `fullscreenchange`,Esc、宿主退出都会回落。`requestFullscreen` 不存在或被 `fullscreenEnabled === false` 关掉时整枚入口不渲染,不留点了没反应的按钮。
- 决策二:`.game-run-panels` 改成「有内容才存在」的可收起信息栏。判据只有「有没有内容」(当前 = 存在资源选中态——在资源画布或浮层资源面板里选中一张资源后切到运行页签仍保留,信息展示渲染它的只读字段;运行画面上的「点选素材」只往对话插入引用,不改选中):内容从无到有自动展开、从有到无自动收起,同一段内容里用户手动收 / 展不被别的渲染重开。收起态只剩一行「收起信息栏 / 展开信息栏」按钮,条目卡片的 `156px` 最小高度不再变成空白色块;只有一栏内容时卡片铺满整行。手动态按资源 id 在渲染期派生(不挂 effect 回写):手动收 / 展只对做出动作时的那张资源有效,换到别的资源回到默认(有内容即展开),同一张资源即使清空选中后再选回也仍记得上一次的手动状态;这样「刚有内容」的那一帧就已经是展开态,不会先画一帧收起态再展开。
- 决策三:「数值微调」暂时没有登记表(前端没有数据源),按用户口径在没有功能时先不渲染它的区域标题与卡片,对应 `label / input` 声明一并删除;登记表接进来时与内容一起回归。这一条覆盖 PRD §3.4 原先「保留两个面板标题、不得因空内容压缩」的口径,PRD 与技术方案已同步改写。
- 决策四(同日收口全屏回归):用户报「退出全屏后画布仍保持全屏比例」。根因不在全屏本身,而在运行画面的尺寸上报回灌——自适应页面把视口原样报回(内容尺寸 = 容器尺寸),宿主把它当成「内容高水位」,`resolveLocalGamePreviewFitLayout` 的 `max(容器, 内容)` 就把画布钉在全屏那一帧的尺寸上;退出后 iframe 视口不再变化,桥也不会再上报,于是永远回不去(实测 1015×660 → 全屏 1416×808 → 退出仍是 1416×808、缩放到 0.72,画面按全屏比例缩成一条带黑边的窄幅)。修法:内容尺寸与它被接受时的容器尺寸在两个轴上都相等(<1px)时不算高水位,直接按容器尺寸给画布;真比容器高的页面(内容 ≠ 视口,桥注入的原始动机)仍按原生尺寸缩放显示。回归用例 `tests/localGamePreviewFrame.test.ts` 的 `returns the fitted iframe to the container after the host viewport shrinks`(改前必红,实测 1416px vs 1015px)。
- 决策四的残余边界(明确不修):若某个**固定尺寸**页面恰好等于它被接受时的容器尺寸,且缩小容器后它上报的内容尺寸再不变,就会一直按容器取画布(页面自身溢出被裁)。评审提过「内容尺寸没变也把这条记录改认新容器」,我实现后又**实测回退**了:那条过渡期上报(内容还是放大前的旧值、视口已是缩小后的容器)会被当成固有尺寸,全屏那类问题原样复现且同样永久(iframe 回到旧尺寸后桥不再上报)。两者在宿主拿到的数据上不可区分,按 AGC 常态(桥对自适应与「固定画布但自适应文档」两类页面实测都报「内容 = 视口」)选自适应优先;页面报告新内容尺寸时立即回到 `max(容器, 内容)` 等比缩小(用例 `refits to the reported content size after the container shrinks`)。根治方向在桥 / 协议侧:尺寸消息再带一个「本页是否视口耦合」的布尔(桥内部已有逐元素耦合采样与排除耦合后的边界),拟合直接按它判定,不必用两个数字相等去猜——属桥与协议的独立变更,本 PR 不做。
- 验证:新增 `tests/runPreviewFullscreen.test.tsx`(补出 jsdom 缺失的 Fullscreen API:按钮住在画面那一格里、点击 → `requestFullscreen` → 退出全屏,以及宿主没有该 API 时不渲染);`tests/localGamePreviewFrame.test.ts` 抽出 `renderFittedFrame` 夹具并补上面两条用例;AGC 子集补「信息栏有内容自动展开 / 手动收起 / 再展开」,并把「没有内容时运行页仍渲染两张卡片」的旧断言改成整栏不渲染(`数值微调面板` 这条已随删除面消失的 label 断言同步删掉,避免恒真)。`apps/ai-game-creator-shell:check:web` 全量通过(`tsc` + 1812 项,合并上游退役提交后的口径)、编码检查与 `git diff --check` 通过;并用真实 Chromium(挂同一份组件 + 客户端真实注入的尺寸桥脚本,`fullbleed` 与 `fixed` 两种游戏页)冒烟:右下角按钮只把画面那一格送进全屏且可退出、退出后画布缩回容器尺寸、选中资源后信息栏自动展开(190px)、收起后画面变高(26px→636px)、再展开恢复。
## 2026-09-23 自绘标题栏是窗口边框:弹层从它下方开始,焦点陷阱放行它
- 背景:AGC 打开任意一个 `ThemedModal` 弹窗(发布面板、发布进度、资源预览、账本、错误报告等)后,右上角「最小化 / 最大化 / 关闭」点击没有任何反应,标题栏拖拽也不能移动窗口;关掉弹窗立刻恢复。原因是标题栏在模态之外,而 `focus-trap-react` 在 document 捕获阶段监听 `mousedown`/`touchstart`/`click`,模态外的点击被 `preventDefault()` 且 `click` 直接 `stopImmediatePropagation()` —— React 的监听在更内层,事件到不了它,所以表现是「点了没反应」而不是报错。另有 `.app-update-overlay` 用 `inset: 0` 真的把标题栏盖住了。
@@ -8424,6 +8434,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 背景:项目开发工作台的中央运行视窗尺寸小于部分生成游戏的页面布局高度时,滚动条来自 loopback iframe 内部;宿主只隐藏 overflow 会直接裁掉标题、Canvas 或控制区,不能满足完整试玩。
- 决策:客户端本地 preview server 为 UTF-8 HTML 注入固定同源尺寸桥;注入器按真实 HTML tokenizer 边界保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,省略结束标签时只在已证明安全的文档位置注入。桥通过根节点 `ResizeObserver`、页面 load、窗口 resize 与字体就绪重新测量;页面可见时以 `500ms` 低频兜底探测至多 `512` 个元素边界,探测截断时不采用可能低估的部分样本,并排除随 viewport 同步变化的布局自反馈。它不订阅整页 DOM 突变,并只在尺寸元组真实变化时上报文档与浏览上下文宽高。宿主只接受当前 iframe source 与当前授权 loopback origin 的固定版本消息,按实际内容和可用容器计算最大为 `1` 的等比缩放并居中显示;宿主把最近一次合法上报的 viewport 与正式内容尺寸分开保存,首次收到自身 fit 切换产生的新 viewport 测量时只推进观察值、不反向改写 fit,viewport 稳定后的真实内容增减仍可重新适配。容器 resize 期间保留当前内容尺寸和已观察 viewport,只按新的可用空间连续重算缩放,避免拖动窗口时在原生尺寸与 fit 之间闪烁;preview URL 变化时才清空两者并重新测量。陈旧 viewport、重复内容尺寸和首次宿主回灌均不更新状态。运行视窗不再提供 iframe 横纵滚动条,内容适配不改游戏文件、manifest、PreviewRegistry 或运行业务状态,非 UTF-8 HTML 保持原样。
- 验证:前端组件测试锁定容器 resize 时 iframe 不恢复原生尺寸;纯函数覆盖无需缩放、纵向超高缩放、宿主首次应用 viewport 时保持当前 fit、容器 resize 后保持当前 fit、稳定 viewport 下内容增高 / 缩短、重复内容尺寸去重、过期 viewport 与非法消息;Rust preview server 测试锁定尺寸去重、无全页 MutationObserver、低频有界探测、截断保护、固定 body 与 viewport 耦合布局不振荡、真实 HTML 上下文注入、注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text / template / plaintext / foreign content、省略结束标签、大小写结束标签、重复 `src` 和幂等注入;再以 Issue #250 附件的 `min-height: 100vh` 页面在桌面最小窗口和更高窗口人工确认完整画面、无循环缩放、拖动窗口时无原生尺寸闪切、动态内容变化后仍适配、无纵向滚动条且指针 / 键盘交互仍可用。
- 补充(2026-09-23):上面「容器 resize 期间保留内容尺寸」只对**真比容器高 / 宽**的页面成立。上报的内容尺寸恰好等于它被接受时的容器尺寸时(自适应页面把视口原样报回来)不算内容高水位——容器缩小后画布必须跟着缩回。否则运行画面被放大一次(例如「全屏预览」)就会把画布钉在那个尺寸上,退出后仍按全屏比例缩进小容器,且 iframe 视口不变 ⇒ 桥不再上报 ⇒ 永远回不去。判据与回归用例见 2026-09-23 两条条目(`resolveLocalGamePreviewFitLayout` 的回退分支与 `returns the fitted iframe to the container after the host viewport shrinks`)。
## 2026-08-23 Direct Codex 显式重生成与切片一等资源
@@ -8621,7 +8632,8 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
## 2026-08-31 LLM Router 独立账号与后置扣费修订
- 每个 Genarrative 用户在认证成功后都必须幂等准备独立 Router 账号:api-server 使用管理员 Token 创建随机密码普通用户,查询用户 ID,设置用户 `group=taonier`,登录、创建或复用固定标识 `agc_auto_generate` 的无限额度 Token(Token/API Key 使用 `default` 分组;发现旧 Token 为其它分组时先更新为 `default`)并签发 API Key。Router 账号用户名、随机密码、access token(如需)和 API Key 作为一个服务端加密 bundle 保存到 `llm_router_account.credential_ciphertext`,脱敏账号信息和 API Key 核心字段保存到 `llm_router_account`;客户端和普通用户永远不可见 Router Key。管理员 Token 仅存在 api-server 私有配置,不写入数据库或日志;Router 凭据只来源于这条正式账号流程。
- 每个 Genarrative 用户在认证成功后都必须幂等准备独立 Router 账号:api-server 使用管理员 Token 创建随机密码普通用户,查询用户 ID,设置用户 `group=taonier`,登录、创建或复用固定标识 `agc_auto_generate` 的无限额度 Token(Token/API Key 使用 `taonier` 分组;发现旧 Token 为其它分组时先更新为 `taonier`)并签发 API Key。Router 账号用户名、随机密码、access token(如需)和 API Key 作为一个服务端加密 bundle 保存到 `llm_router_account.credential_ciphertext`,脱敏账号信息和 API Key 核心字段保存到 `llm_router_account`;客户端和普通用户永远不可见 Router Key。管理员 Token 仅存在 api-server 私有配置,不写入数据库或日志;Router 凭据只来源于这条正式账号流程。
- 2026-09-23 调整:Token/API Key 分组由 `default` 改为与 Router 用户同组的 `taonier`;创建后无条件 PUT、登录恢复同样 PUT,只要 Token 分组不是 `taonier` 就纠正回来(常量见 `external_api_keys.rs` 的 `LLM_ROUTER_TOKEN_GROUP`)。该契约要求 Router 侧 `taonier` 分组已挂载所需模型与套餐,分组缺模型时会失败为 `model_not_found`;存量已签发且分组为 `default` 的 Key 会在该账号下次 provisioning / 登录恢复 / 显式准备 Key 时被纠正。
- 该账号 provisioning 使用持久 saga 状态:远端注册、登录、token 或 Key 签发结果不确定时进入 `unknown` / `reconciliation_required`,禁止重复注册;远端 Key 已确定签发但本地 `llm_router_account` 写入失败时保持 `key_issued`,后续使用确定 key id 重试落库。Router 确定返回 401/403 时撤销当前 Key 并把账号状态置为 `retryable`,复用已保存的账号密码重新签发替代 Key。
- AGC 调用固定为客户端 access token -> api-server -> Router。计费读取账号 `used_quota`,每 50000 quota 扣 1 泥点,美元数值乘 10、不乘汇率。首次模型调用前以当前累计额度完整建立免追扣基线,之后调用前后同步;扣钱包、写 `llm_router_consume` 流水与推进已结算额度同事务完成。小数和余额不足未支付部分继续累计,失败或重复同步不推进已结算额度,不使用本地 WAL 或余数队列。完整合同见 `docs/technical/【技术方案】LLM累计额度结算-2026-09-05.md`。
- AGC 状态面收口:Tauri `check_game_creator_llm_config` 只返回账号凭据状态、官方路由锁定状态和运行参数;不序列化 Router 地址、模型、协议名或任何密钥/凭据字段,内部固定路由仅留在运行时配置与服务端代理中。
@@ -9287,3 +9299,21 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 影响范围:`server-rs/crates/api-server/src/{admin.rs,admin_recharge.rs}`、`server-rs/crates/shared-contracts/src/admin.rs`、`apps/admin-web/src/api/adminApiTypes.ts`、`apps/admin-web/src/pages/{AdminRechargeOrderPage.tsx,AdminRedeemCodePage.tsx}`、`apps/admin-web/src/components/AdminUserDetailDialog.tsx`、对应三个用例文件与后端架构数据契约文档。
- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server cumulative_recharge`(2 条新用例)与 `cargo check -p api-server`;`npm run admin-web:typecheck`;`npx vitest run apps/admin-web/src`(220 passed),其中三个定向文件 33 passed(新增「未支付订单不显示实付金额,发放泥点单独成列」与「累计充值读取不到时展示未知,不用订单列表近似」)。
- 边界(未验证):未连真实生产库核对历史订单的累计充值数值,也未跑真实栈 API smoke。
## 2026-09-23 后台表查询枚举列按 schema 展示变体名,不再只看数值
- 背景:后台「表查询」页的枚举列直接落回通用解码,只有 `profile_recharge_order` 的 `kind` / `status` 做了硬编码映射,其余 20 多张表的枚举列(如 `profile_wallet_ledger.source_type`、`tracking_event.scope_kind`、`profile_membership.tier`)都显示成 SATS 原始数值,运营在后台看不到枚举值。
- 决策(按 schema 自动解析):api-server 在 `server-rs/crates/api-server/src/admin.rs` 读取 SpacetimeDB schema 的 `typespace.types` 与表的 `product_type_ref`,对每个「`Sum` 且所有变体都是单元变体」的列生成「列名 → 按变体索引排列的展示名」;变体名归一到 snake_case,与后台既有枚举字符串(`points` / `paid` / `asset_operation_consume`)同口径,因此原硬编码映射的展示结果不变,新增表与新增枚举列不再需要改代码。
- 决策(边界):`Option<枚举>` 列单独标记为可空(`[0, [索引, []]]` 出变体名、`[1, []]` 仍是空值),`Option<普通值>` 与带载荷的 Sum 不参与映射,继续走通用解码(`Some` 解包、`None` 归空、时间戳原样透出);单变体枚举同样要出名字。映射同时作用于 `cells` 与 `raw`,关键词搜索、结构化筛选和稳定排序都按展示名生效。schema 读取失败时表查询以「表不存在」失败,不会退回展示数字。
- 影响范围:`server-rs/crates/api-server/src/admin.rs`(新增 schema 解析与 `build_admin_database_enum_labels`,删除 `normalize_admin_database_known_enum`,`parse_admin_database_table_rows_sql_response` / 行构建与归一化改为接收枚举映射)、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md`。前端与 DTO 不变。
- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server admin::tests`(86 passed,含新增 `admin_database_enum_labels_come_from_schema_variants` 与改写后的充值订单枚举用例);用本地 dev schema 逐表回放同一算法,85 张表里 32 个枚举列全部解析出展示名、0 个残留;`cargo check -p api-server`、`cargo fmt --all --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。
- 边界(未验证):没有对真实 HTTP 表查询响应做端到端比对(本地 dev api-server 仍是改动前二进制,未重启)。
## 2026-09-23 游戏发行入口改为服务端按模板派生:管理员不再手填地址
- 背景:游戏审核通过要求管理员手填绝对 HTTPS `entryUrl`,现场出现「不知道该填什么、随手填一个外部站点也能过校验」的风险;而每游戏独立来源本身完全能由 gameId 推出,人工输入没有增加任何判断。
- 决策(唯一口径):部署侧用 `GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE` 配置带 `{gameId}` 占位符的模板(生产形如 `https://{gameId}.games.<发行域名>/`);审核通过时 `api-server` 读版本取 gameId、替换模板、再走原有绝对 HTTPS / 无凭据 / 无 query / 无 fragment 校验后写入公开投影。后台审核请求 DTO 删除 `entryUrl`,页面不再渲染输入框,也不要求二次确认。
- 决策(失败关闭):模板缺 `{gameId}`、生产未配置模板、gameId 含非主机安全字符或派生结果非法时,审核通过直接失败,不回落主站、内网或任意外部地址;非生产未配置模板时回落 `http://127.0.0.1:<bind_port>/api/game-distribution/releases/{gameId}/`,保持免 TLS 的本地内嵌游玩验证。
- 边界:`entryUrl` 仍是公开投影字段,只是改由服务端写入;审核请求摘要不再包含它,表结构与版本回读不变;模板变更只影响之后新通过审核的版本,历史版本已冻结的 `entry_url` 不改写。
- 影响面:`server-rs/crates/api-server/src/{config.rs,modules/game_distribution.rs}`、`apps/admin-web/src/{api/adminApiTypes.ts,api/adminApiClient.test.ts,pages/AdminGameDistributionReviewPage.tsx,pages/AdminGameDistributionReviewPage.test.tsx}`、`scripts/check-game-distribution-media-e2e.mjs`、`deploy/{nginx,env,container}`、平台与运维主规范、发行里程碑实施计划。
- 验证:`cargo check -p api-server`、`cargo test -p api-server game_distribution`(31 passed)、admin-web 定向 Vitest(19 passed)与 `apps/admin-web` typecheck、`npm run check:release-origin-config`、`npm run check:doc-index`、`npm run check:encoding`、`git diff --check` 全部通过;真实栈端到端(真实 OSS + SpacetimeDB + 审核通过)未在本轮复跑。