diff --git a/.codex/skills/behavior-driven-development b/.codex/skills/behavior-driven-development deleted file mode 120000 index 31fbb4e7..00000000 --- a/.codex/skills/behavior-driven-development +++ /dev/null @@ -1 +0,0 @@ -C:/proj/Genarrative/.hermes/skills/behavior-driven-development \ No newline at end of file diff --git a/.codex/skills/behavior-driven-development b/.codex/skills/behavior-driven-development new file mode 100644 index 00000000..31fbb4e7 --- /dev/null +++ b/.codex/skills/behavior-driven-development @@ -0,0 +1 @@ +C:/proj/Genarrative/.hermes/skills/behavior-driven-development \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 9c860439..8290da85 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -37,6 +37,12 @@ module.exports = { 'simple-import-sort/exports': 'off', }, }, + { + files: ['src/components/match3d-runtime/Match3DPhysicsBoard.tsx'], + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, ], plugins: [ '@typescript-eslint', diff --git a/.gitignore b/.gitignore index 94bcb2eb..d752b483 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ temp*build*/ /logs .worktrees/ .env.secrets.local + +# Local load-test data extracted from private migration files +scripts/loadtest/data/*.local.json diff --git a/.hermes/plans/2026-05-11_195214-k6-works-list-load-test-plan.md b/.hermes/plans/2026-05-11_195214-k6-works-list-load-test-plan.md new file mode 100644 index 00000000..5599c99f --- /dev/null +++ b/.hermes/plans/2026-05-11_195214-k6-works-list-load-test-plan.md @@ -0,0 +1,310 @@ +# K6 作品列表压测计划(使用 spacetime-migration-7.json 作为数据源) + +## 目标 + +使用 K6 对 Genarrative 的“作品列表”相关接口进行压测,并将用户提供的 `spacetime-migration-7.json` 作为压测数据源;数据处理时**只导入作品列表相关数据**,不导入用户、会话、钱包、埋点、运行存档等非作品表,避免把敏感或无关数据带入压测环境。 + +## 当前上下文 + +- 工作区:`/c/proj/Genarrative` +- 原始迁移文件:`C:\Users\DSK\AppData\Local\hermes\cache\documents\doc_150e84029b2d_spacetime-migration-7.json` +- 已确认原始迁移文件结构: + - `schema_version = 1` + - `tables = 53` + - 作品相关表中当前有数据的重点表: + - `puzzle_work_profile`:80 行 + - `custom_world_profile`:1 行 + - `match3d_work_profile`:0 行 + - `big_fish_*`:当前样本中相关表为 0 行 + - 原始文件还包含 `user_account`、`auth_identity`、`refresh_session`、`profile_wallet_ledger`、`asset_object`、运行记录等数据,压测导入时必须过滤。 +- 当前仓库未发现现成 K6 脚本或 `k6` 相关文件,需要新增压测脚本与数据提取脚本。 +- `package.json` 当前有 `dev/dev:rust/test/check` 等脚本,未发现 K6 npm script。 + +## 范围约束 + +### 本次只导入/使用 + +1. 作品列表表: + - `puzzle_work_profile` + - `custom_world_profile` + - 后续若接口覆盖其他玩法,可扩展: + - `match3d_work_profile` + - `square_hole_work_profile`(以实际 SpacetimeDB 表名为准) + - `big_fish_work_profile`(以实际 SpacetimeDB 表名为准) + - `visual_novel_work_profile`(以实际 SpacetimeDB 表名为准) +2. 为作品列表卡片展示所需的最小字段: + - 稳定 ID:`profile_id`、`work_id` 或 `public_work_code` + - 标题:`work_title` / `level_name` / `world_name` + - 描述:`work_description` / `summary` / `summary_text` / `subtitle` + - 作者:`owner_user_id`、`author_display_name`、`author_public_user_code` + - 封面:`cover_image_src`、`cover_asset_id`(如果接口只返回 asset id,则压测阶段不额外导入二进制 asset) + - 状态与计数:`publication_status`、`published_at`、`play_count`、`like_count`、`remix_count` + - 作品内容摘要:`levels_json`、`profile_payload_json`、`theme_tags_json` 等列表渲染或进入作品详情可能需要的 JSON 字段 + +### 本次不导入/不使用 + +- 认证与账号:`user_account`、`auth_identity`、`refresh_session`、`auth_store_snapshot` +- 用户资产与钱包:`profile_wallet_ledger`、`profile_dashboard_state`、`profile_redeem_*`、`profile_invite_*` +- 游玩历史/存档/运行态:`profile_played_world`、`public_work_play_daily_stat`、`puzzle_runtime_run`、`profile_save_archive`、`runtime_snapshot` 等 +- AI 任务过程:`ai_task`、`ai_task_stage`、`ai_text_chunk` +- asset 二进制与绑定:`asset_object`、`asset_entity_binding`,除非后续确认作品列表接口强依赖它们;即便需要,也只导入作品列表封面所需的最小 metadata,不导入原始大对象。 + +## 推荐目录与文件 + +建议新增: + +```text +.hermes/plans/2026-05-11_195214-k6-works-list-load-test-plan.md # 本计划 +scripts/loadtest/extract-works-list-data.mjs # 从迁移文件提取作品列表数据 +scripts/loadtest/k6-works-list.js # K6 压测脚本 +scripts/loadtest/data/works-list.sample.json # 过滤后的样例数据(不要提交敏感原始迁移全量) +scripts/loadtest/README.md # 执行说明与指标阈值 +``` + +可选新增 npm scripts: + +```json +{ + "loadtest:extract-works": "node scripts/loadtest/extract-works-list-data.mjs", + "loadtest:k6:works": "k6 run scripts/loadtest/k6-works-list.js" +} +``` + +## 数据提取方案 + +### 输入 + +默认读取: + +```bash +node scripts/loadtest/extract-works-list-data.mjs \ + --input "C:/Users/DSK/AppData/Local/hermes/cache/documents/doc_150e84029b2d_spacetime-migration-7.json" \ + --output scripts/loadtest/data/works-list.local.json +``` + +### 输出结构 + +建议输出为 K6 直接可读的 JSON: + +```json +{ + "source": "spacetime-migration-7.json", + "generatedAt": "", + "tables": { + "puzzle_work_profile": [ + { + "profile_id": "...", + "work_id": "...", + "owner_user_id": "...", + "work_title": "...", + "work_description": "...", + "publication_status": "Published", + "published_at": { "__timestamp_micros_since_unix_epoch__": 0 }, + "play_count": 0, + "like_count": 0, + "levels_json": "..." + } + ], + "custom_world_profile": [] + }, + "workIds": { + "puzzle": [""], + "customWorld": [""] + } +} +``` + +### 过滤原则 + +1. 按 `tables[].name` 白名单过滤,只保留作品 profile 表。 +2. 对每个 row 再按字段白名单过滤,避免误带账号、手机号、token、钱包流水等字段。 +3. 对特别大的字段进行处理: + - `cover_image_src` 如果是 `data:image/...base64`,默认替换为占位符或截断,避免压测数据文件过大。 + - `levels_json`、`profile_payload_json` 保留原文,但可以记录大小;如果过大,再提供 `--compact` 选项只保留摘要。 +4. 输出 `.local.json` 默认加入 `.gitignore`;如果要提交样例数据,只提交脱敏/裁剪后的 `works-list.sample.json`。 + +## K6 压测接口矩阵 + +需要先确认本地 api-server 实际端口。默认以 `http://127.0.0.1:8787` 为例,实际运行时通过环境变量覆盖: + +```bash +BASE_URL=http://127.0.0.1: k6 run scripts/loadtest/k6-works-list.js +``` + +初版建议覆盖以下“作品列表”读接口,具体路径以仓库服务端路由为准,实施时需要通过搜索 api-server 路由确认: + +| 场景 | 目的 | 候选路径 | +| --- | --- | --- | +| 拼图作品列表 | 作品列表主场景之一,当前数据量最多 | `/api/creation/puzzle/works` 或实际 puzzle works list route | +| RPG/自定义世界作品列表 | 使用 `custom_world_profile` 数据 | `/api/creation/custom-world/works` 或实际 custom world works route | +| 作品详情/启动前读取 | 模拟用户从列表点进作品 | `/api/creation/*/works/:profileId` 或 `/api/runtime/*/works/:profileId` | +| 公开作品库 | 如果首页/发现页依赖 | `/api/runtime/*/works` 或 gallery/list route | + +> 注意:不要凭空固定 endpoint。实施阶段先用 `search_files` / 路由源码确认真实路径,再写入 K6 脚本。 + +## K6 场景设计 + +### 阶段 1:基线 smoke + +目的:确认脚本、数据和目标服务可用。 + +```js +export const options = { + scenarios: { + smoke: { + executor: 'constant-vus', + vus: 1, + duration: '30s' + } + }, + thresholds: { + http_req_failed: ['rate<0.01'], + http_req_duration: ['p(95)<800'] + } +}; +``` + +### 阶段 2:常规读压 + +目的:模拟日常列表浏览。 + +- `constant-vus`: 10/25/50 三档 +- 每个 VU 随机选择作品类型和列表分页参数 +- `sleep(0.5~2s)` 模拟用户停留 +- 阈值建议: + - `http_req_failed < 1%` + - `p95 < 800ms` + - `p99 < 1500ms` + +### 阶段 3:峰值/突刺 + +目的:模拟首页入口或活动导致的作品列表突增。 + +- `ramping-arrival-rate` +- 从 5 RPS 增长到 100 RPS,维持 2~5 分钟,再降回 +- 单独输出 `checks`:列表接口状态码、响应 JSON shape、items 数量 + +### 阶段 4:容量探索 + +目的:找瓶颈,不作为每次回归必跑。 + +- 每轮提升 RPS 或 VU +- 观察:api-server CPU/内存、SpacetimeDB 日志、错误率、p95/p99 +- 一旦 `http_req_failed >= 5%` 或 p95 持续超过 2s,停止继续加压并记录容量点。 + +## K6 脚本设计要点 + +1. 使用 `SharedArray` 加载 `works-list.local.json`,避免每个 VU 重复解析大 JSON。 +2. 基于数据源里的 `profile_id` / `work_id` 随机抽样,保证请求覆盖真实作品 ID。 +3. 对列表接口添加分页/排序 query,例如: + - `?limit=20&offset=0` + - `?pageSize=20&cursor=...`(以真实 API 为准) +4. 使用 `check()` 验证: + - HTTP 200 + - 响应体是 JSON + - `items` 或 `works` 是数组 + - 列表项包含 `profileId/profile_id`、标题字段、状态字段 +5. 使用 `Trend` / `Rate` 细分指标: + - `works_list_duration` + - `works_detail_duration` + - `works_list_shape_error_rate` +6. 支持环境变量: + +```bash +BASE_URL=http://127.0.0.1:8787 \ +WORKS_DATA=scripts/loadtest/data/works-list.local.json \ +SCENARIO=baseline \ +k6 run scripts/loadtest/k6-works-list.js +``` + +## 实施步骤 + +1. **确认路由** + - 搜索 api-server / BFF 的作品列表路由。 + - 明确各玩法对应 endpoint、鉴权要求、分页参数、返回字段。 +2. **实现数据提取脚本** + - 新增 `scripts/loadtest/extract-works-list-data.mjs`。 + - 只按表白名单读取作品列表 profile 表。 + - 对字段做白名单与脱敏/截断。 + - 输出 `works-list.local.json`。 +3. **生成本地压测数据** + - 用用户提供的迁移文件生成 `scripts/loadtest/data/works-list.local.json`。 + - 验证输出只包含作品表和作品字段。 +4. **实现 K6 脚本** + - 新增 `scripts/loadtest/k6-works-list.js`。 + - 支持 `BASE_URL`、`WORKS_DATA`、`SCENARIO`。 + - 覆盖列表接口,必要时增加详情/启动前读取接口。 +5. **新增执行说明** + - 在 `scripts/loadtest/README.md` 写明:安装 K6、启动本地 dev 栈、提取数据、运行 smoke/baseline/spike、查看结果。 +6. **本地验证** + - 启动 Genarrative dev 栈;注意端口可能漂移,使用实际 api-server 端口。 + - 跑 smoke:`SCENARIO=smoke`。 + - 确认失败率、p95、响应 shape。 +7. **可选集成 npm scripts** + - 如果团队希望标准化入口,再加入 `package.json` scripts。 +8. **记录结果** + - 将 smoke/baseline/spike 的结果摘要追加到 `scripts/loadtest/README.md` 或单独保存到 `.hermes/plans/` 的结果文档中。 + +## 启动与运行建议 + +本地服务启动按当前 Genarrative dev 栈约定: + +```bash +npm run dev +``` + +如果 SpacetimeDB/API/Vite 端口被占用,项目脚本会寻找可用端口;压测时必须从启动日志中读取实际 api-server 地址,并传给 K6: + +```bash +BASE_URL=http://127.0.0.1: \ +WORKS_DATA=scripts/loadtest/data/works-list.local.json \ +SCENARIO=smoke \ +k6 run scripts/loadtest/k6-works-list.js +``` + +## 验证标准 + +### 数据源验证 + +- `works-list.local.json` 中只出现作品 profile 表。 +- 不出现以下字段或内容: + - `password_hash` + - `refresh_token_hash` + - `phone_number_e164` + - `phone_number_masked` + - `wallet_ledger_id` + - `auth_identity` + - `user_account` +- `puzzle_work_profile` 行数应接近原始文件中的 80 行。 +- `custom_world_profile` 行数应接近原始文件中的 1 行。 + +### K6 smoke 验证 + +- 所有目标接口返回 2xx。 +- `http_req_failed < 1%`。 +- 响应 JSON shape 与 shared contracts 对齐:`items` 或 `works` 数组。 +- K6 输出中能区分不同 endpoint 的耗时。 + +### 性能阈值初稿 + +- Smoke:`p95 < 800ms`,失败率 `< 1%` +- Baseline:`p95 < 1000ms`,`p99 < 2000ms`,失败率 `< 1%` +- Spike:允许短暂 p95 抖动,但 1 分钟内应恢复;失败率 `< 5%` + +阈值后续需要结合本地机器性能、SpacetimeDB 本地模式和正式部署规格调整。 + +## 风险与注意事项 + +1. **原始迁移文件包含敏感数据。** 必须只提取作品列表白名单字段,禁止把原始 JSON 全量提交到仓库。 +2. **base64 封面可能导致压测数据膨胀。** 默认截断或替换为占位符,除非本次明确要测封面 payload 对响应体积的影响。 +3. **本地 SpacetimeDB 与 api-server 端口会漂移。** 不要硬编码端口,运行时通过 `BASE_URL` 注入。 +4. **列表接口可能需要鉴权。** 若实际接口要求登录,不要导入真实 refresh session;应使用本地测试账号或专门的压测 token 生成流程。 +5. **作品表名/接口路径可能与候选名称不完全一致。** 实施前必须以源码路由为准。 +6. **本计划仅保存压测方案,不执行实际压测。** 后续执行时再创建/修改脚本、导出过滤数据、跑 K6 并记录结果。 + +## 开放问题 + +1. 压测目标是本地 dev 栈、测试环境,还是预发/生产只读接口?不同环境阈值和安全边界不同。 +2. “作品列表”是否只包含拼图和自定义世界,还是要覆盖 match3d、square-hole、big-fish、visual-novel 的统一列表入口? +3. 是否允许使用专门压测账号/token?如果接口无鉴权则无需处理。 +4. 是否需要测封面/asset 加载,还是只测作品列表 JSON API? diff --git a/.hermes/plans/2026-05-11_205645-genarrative-disaster-recovery.md b/.hermes/plans/2026-05-11_205645-genarrative-disaster-recovery.md new file mode 100644 index 00000000..99985241 --- /dev/null +++ b/.hermes/plans/2026-05-11_205645-genarrative-disaster-recovery.md @@ -0,0 +1,447 @@ +# Genarrative 容灾方案设计计划 + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** 基于当前 Genarrative 单机生产部署、Jenkins 流水线、SpacetimeDB 与 Rust `api-server` 架构,补齐一套可落地、可演练、可审计的容灾方案。 + +**Architecture:** 首版容灾不引入复杂多活系统,优先围绕现有 `systemd + Nginx + SpacetimeDB + api-server + Jenkins` 单机生产推荐方案做“备份可恢复、版本可回滚、故障可切换、演练可复盘”。方案采用分层容灾:入口层、静态资源层、API 服务层、SpacetimeDB 数据层、外部服务与密钥层、Jenkins/发布链路层。 + +**Tech Stack:** Nginx、systemd、SpacetimeDB self-hosting、Rust `api-server` / Axum、Jenkins Pipeline、Shell/Node.js 运维脚本、仓库 `deploy/` 与 `docs/technical/` 文档体系。 + +--- + +## 1. 当前上下文与已确认事实 + +### 1.1 当前生产部署口径 + +来自 `docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md` 的现状: + +- 生产为单机推荐方案,不使用 Docker。 +- 公网入口为 Nginx,负责 HTTPS、静态站点、后台静态页面、维护页、`/admin/api/` 与临时 `/api/*` 反向代理。 +- SpacetimeDB 作为 systemd 服务运行: + - `spacetimedb.service` + - 监听:`127.0.0.1:3101` + - 数据根目录:`/stdb` +- Rust `api-server` 作为 systemd 服务运行: + - `genarrative-api.service` + - 监听:`127.0.0.1:8082` + - 环境文件:`/etc/genarrative/api-server.env` +- 静态站点发布到 release/current 目录: + - `/opt/genarrative/releases//` + - `/opt/genarrative/current` + - `/srv/genarrative/web` +- 已有维护模式: + - 开关文件:`/var/lib/genarrative/maintenance/enabled` + - API 发布、SpacetimeDB 模块发布、数据库导入、服务器配置变更必须进入维护模式。 +- 已有数据库导入导出 Jenkins Job: + - `Genarrative-Database-Export` + - `Genarrative-Database-Import` + - 对应文件:`jenkins/Jenkinsfile.production-database-export`、`jenkins/Jenkinsfile.production-database-import` +- 已有回滚基本口径: + - Web 回滚:切 `/srv/genarrative/web` 或 `/opt/genarrative/current` 到上一版本并 reload Nginx。 + - API 回滚:切 `/opt/genarrative/current` 到上一版本并重启 `genarrative-api.service`。 + - SpacetimeDB 模块回滚:发布上一版本 `spacetime_module.wasm`。 + - 数据回滚:使用导入流水线恢复指定备份,必须进入维护模式。 + +### 1.2 关键风险 + +- 当前是单机生产拓扑,单机磁盘、系统盘、`/stdb`、Nginx 或公网 IP 故障会造成整体不可用。 +- SpacetimeDB 是核心业务真相,容灾重点必须围绕 `/stdb`、数据库导出产物、schema 迁移与导入验证。 +- `/etc/genarrative/api-server.env` 持有生产密钥,不能进入 Git,也不能写进普通备份明文归档。 +- Jenkins controller/agent 同时承担构建、发布、备份、导入导出编排;Jenkins 不可用时仍需要有最小人工恢复路径。 +- 外部 LLM、图片、语音、3D 网关不是本仓库可控系统,容灾只能做到配置降级、超时隔离、能力熔断与可观测告警。 + +--- + +## 2. 容灾目标 + +### 2.1 恢复目标建议 + +| 灾难类型 | 目标 RTO | 目标 RPO | 首版策略 | +| --- | ---: | ---: | --- | +| Web 静态资源发布失败 | 5 分钟 | 0 | release/current 原子切换回滚 | +| API 发布失败 | 10 分钟 | 0 | 维护模式 + 上一版二进制回滚 | +| SpacetimeDB wasm 发布失败 | 15 分钟 | 0 或按迁移前备份 | 发布前导出 + 上一版 wasm 回滚 | +| 数据误写 / 迁移失败 | 30-60 分钟 | 最近一次导出点 | 导入流水线从备份恢复 | +| 生产机磁盘损坏 | 2-4 小时 | 最近一次异地备份 | 新机器 provision + 拉取 release 包 + 恢复数据库 | +| Jenkins controller 不可用 | 1-2 小时 | 不影响线上数据 | 手工脚本恢复 + Jenkins 备份恢复 | +| 第三方模型网关不可用 | 5-15 分钟内降级 | 不丢核心数据 | 配置切换 / 功能熔断 / 队列失败可重试 | + +### 2.2 首版不做 + +- 不做跨地域双活写入。 +- 不做 SpacetimeDB 在线主从复制,除非后续官方能力与项目压测验证支持。 +- 不让前端绕过 `api-server` 直接承担正式业务真相。 +- 不把生产密钥、Token、数据库 dump、Jenkins secret 写入 Git。 +- 不恢复旧 `server-node`、Express、PostgreSQL 或 Docker 一体化部署方案。 + +--- + +## 3. 总体容灾设计 + +### 3.1 分层策略 + +1. **入口层:Nginx / DNS / HTTPS** + - 保留 Nginx 配置模板在 Git:`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`。 + - 为 release 环境建立 Nginx 配置备份与证书恢复流程。 + - 明确 DNS 切换预案:生产机不可恢复时,将域名指向灾备机公网 IP。 + +2. **静态资源层:Web / Admin Web** + - 依赖 `web.tar.gz`、`web.tar.gz.sha256`、`release-manifest.json`。 + - 保留最近 N 个 release 目录与构建产物指针。 + - 回滚只切软链,不重新构建。 + +3. **API 服务层:Rust `api-server`** + - 依赖归档的 `api-server` 二进制、checksum、`release-manifest.json`。 + - `/etc/genarrative/api-server.env` 通过加密备份或密钥管理恢复,不进入 release 包。 + - systemd unit 由 `deploy/systemd/genarrative-api.service` 重新安装。 + +4. **数据层:SpacetimeDB** + - 每次高风险发布前强制导出数据库。 + - 定时导出:建议每天至少 1 次;高活跃期可每 4 小时 1 次。 + - 导出产物同时保存在:Jenkins 归档 + 生产机 `SERVER_BACKUP_DIRECTORY` + 异地对象存储/备份机。 + - 导入前自动生成安全备份,保留当前实现口径。 + +5. **发布编排层:Jenkins** + - Jenkins Job、Jenkinsfile 在 Git 中可恢复。 + - Jenkins controller 配置、凭据、插件清单需要额外备份。 + - 发布 agent 使用 inbound + systemd 自恢复,agent secret 仅存在目标机或 Jenkins 凭据。 + +6. **密钥与外部服务层** + - `/etc/genarrative/api-server.env`、Jenkins Secret Text、SSH PEM、agent secret 不进 Git。 + - 制定密钥清单和恢复责任人,但不在仓库记录明文。 + - 外部服务配置按 `docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md` 维护必配项。 + +--- + +## 4. 建议新增/更新的文档 + +### Task 1: 新增生产容灾技术方案文档 + +**Objective:** 形成团队可共享、可执行的容灾总纲。 + +**Files:** +- Create: `docs/technical/PRODUCTION_DISASTER_RECOVERY_PLAN_2026-05-11.md` +- Modify: `docs/technical/README.md`(若已有技术索引,应加入该文档入口) +- Optional Modify: `.hermes/shared-memory/project-overview.md`(只加稳定索引,不写敏感信息) + +**文档必须覆盖:** + +1. 容灾目标:RTO/RPO 表。 +2. 生产资产清单:Nginx、systemd、release/current、`/stdb`、`/etc/genarrative/api-server.env`、Jenkins、构建产物。 +3. 备份策略: + - 数据库导出。 + - release 产物保留。 + - Nginx/systemd/env 配置备份。 + - Jenkins 配置备份。 +4. 恢复流程: + - Web 回滚。 + - API 回滚。 + - Stdb module 回滚。 + - 数据恢复。 + - 整机重建。 +5. 演练计划:每月一次数据库恢复演练,每季度一次整机重建演练。 +6. 安全边界:密钥不进 Git,备份加密,最小权限。 +7. 验收命令与人工检查清单。 + +**Verification:** + +```bash +npm run check:encoding +``` + +Expected: PASS,无中文乱码、无 BOM/CRLF 问题。 + +--- + +## 5. 建议新增/更新的脚本与流水线 + +### Task 2: 增强数据库定时备份流水线 + +**Objective:** 把现有人工导出扩展为可定时执行、可异地保存、可审计的备份流程。 + +**Files:** +- Modify: `jenkins/Jenkinsfile.production-database-export` +- Modify: `docs/technical/PRODUCTION_DISASTER_RECOVERY_PLAN_2026-05-11.md` +- Optional Create: `scripts/deploy/production-backup-sync.sh` + +**Implementation notes:** + +- 在 Jenkins Job 中保留人工触发能力,同时建议配置 cron: + - development:每天凌晨。 + - release:每天凌晨或业务低峰。 +- 增加备份命名规范: + - `spacetime-migration---.json` +- 增加 `SERVER_BACKUP_DIRECTORY` 默认建议: + - `/var/backups/genarrative/spacetimedb//` +- 增加备份保留策略: + - 本机保留 7-14 天。 + - 异地保留 30-90 天。 +- 如实现 `production-backup-sync.sh`,只做同步框架,不硬编码真实 bucket、账号、endpoint 或密钥。 + +**Verification:** + +```bash +bash -n scripts/deploy/production-backup-sync.sh +npm run check:encoding +``` + +Expected: shell 语法通过;文档编码检查通过。 + +--- + +### Task 3: 增加灾备恢复 Runbook + +**Objective:** 在真正故障时不依赖临场推理,按清单执行恢复。 + +**Files:** +- Create: `docs/operations/PRODUCTION_DR_RUNBOOK_2026-05-11.md` +- Modify: `docs/operations/README.md`(如果存在) + +**Runbook sections:** + +1. 故障分级:P0/P1/P2。 +2. 第一响应: + - 判断 Nginx 是否在线。 + - 判断 `genarrative-api.service` 是否在线。 + - 判断 `spacetimedb.service` 是否在线。 + - 判断磁盘是否满。 + - 判断 Jenkins agent 是否在线。 +3. 快速止血: + - 开维护模式。 + - 禁止继续发布。 + - 保留现场日志。 +4. 回滚流程: + - Web 回滚命令。 + - API 回滚命令。 + - Stdb wasm 回滚命令。 +5. 数据恢复流程: + - 选择备份。 + - dry-run 导入。 + - 确认导入。 + - smoke test。 +6. 整机重建流程: + - 新机器 provision。 + - 恢复 `/etc/genarrative/api-server.env`。 + - 恢复 SpacetimeDB 数据。 + - 发布最近稳定 release。 + - DNS 切换。 +7. 复盘模板。 + +**Verification:** + +```bash +npm run check:encoding +``` + +Expected: PASS。 + +--- + +### Task 4: 增加备份健康检查与恢复演练记录模板 + +**Objective:** 防止“有备份但不可恢复”。 + +**Files:** +- Create: `docs/operations/DR_DRILL_REPORT_TEMPLATE.md` +- Optional Create: `scripts/deploy/verify-database-backup.sh` +- Modify: `docs/technical/PRODUCTION_DISASTER_RECOVERY_PLAN_2026-05-11.md` + +**建议检查项:** + +- 备份文件存在且大小非 0。 +- 备份文件 checksum 可验证。 +- 备份文件可被 `Genarrative-Database-Import` dry-run 解析。 +- 最近一次备份时间未超过 RPO 阈值。 +- 导入后 `/healthz` 可用。 +- 首页、后台登录页、关键 API smoke 可用。 + +**Verification:** + +```bash +bash -n scripts/deploy/verify-database-backup.sh +npm run check:encoding +``` + +Expected: PASS。 + +--- + +## 6. 具体恢复流程草案 + +### 6.1 Web 静态资源回滚 + +1. 进入目标机。 +2. 查看 release 目录:`/opt/genarrative/releases/`。 +3. 选择上一个稳定版本。 +4. 切换 `/srv/genarrative/web` 或 `/opt/genarrative/current` 软链。 +5. 执行 Nginx 配置检查与 reload。 +6. 访问首页与后台静态入口。 + +验收: + +- `/` 返回最新稳定页面。 +- `/admin/` 返回后台页面。 +- 静态资源无 404。 + +### 6.2 API 回滚 + +1. 开维护模式。 +2. 切 `/opt/genarrative/current` 到上一版包含稳定 `api-server` 的 release。 +3. 重启 `genarrative-api.service`。 +4. 本机检查 `http://127.0.0.1:8082/healthz`。 +5. 检查 Nginx 反代路径。 +6. 解除维护模式。 + +验收: + +- `systemctl status genarrative-api.service` 正常。 +- `/healthz` 正常。 +- 后台 `/admin/api/*` 基础接口正常。 + +### 6.3 SpacetimeDB 模块回滚 + +1. 开维护模式。 +2. 确认目标数据库名与当前 API 环境一致:`GENARRATIVE_SPACETIME_DATABASE`。 +3. 选择上一版 `spacetime_module.wasm`。 +4. 使用 `spacetimedb` 服务用户发布上一版 wasm。 +5. 重启或检查 `spacetimedb.service`。 +6. 检查 `api-server` 对目标数据库访问。 +7. 解除维护模式。 + +注意:如果 schema 已迁移且旧 wasm 不兼容当前数据,需要走数据恢复,不应直接盲目发布旧 wasm。 + +### 6.4 数据恢复 + +1. 开维护模式。 +2. 从 Jenkins 归档或 `SERVER_BACKUP_DIRECTORY` 选择备份。 +3. 先执行导入 dry-run。 +4. 真正导入前生成当前数据库安全备份。 +5. 执行导入。 +6. 执行 smoke test。 +7. 解除维护模式。 + +必须记录: + +- 备份文件名。 +- 来源 Job/build number。 +- 恢复目标 database。 +- 恢复开始/结束时间。 +- 恢复后验证结果。 + +### 6.5 整机重建 + +1. 准备新 Linux 机器。 +2. 接入 Jenkins release deploy agent,或准备人工 SSH 运维路径。 +3. 运行 `Genarrative-Server-Provision`: + - 创建用户和目录。 + - 安装 SpacetimeDB。 + - 安装 systemd unit。 + - 安装 Nginx 配置。 +4. 恢复 `/etc/genarrative/api-server.env`。 +5. 发布最近稳定 Web/API/Stdb 产物。 +6. 导入最近一次有效数据库备份。 +7. smoke test。 +8. 切 DNS。 +9. 观察 30-60 分钟。 + +--- + +## 7. 文件可能变更清单 + +首版落地建议按以下文件收口: + +- Create: `docs/technical/PRODUCTION_DISASTER_RECOVERY_PLAN_2026-05-11.md` +- Create: `docs/operations/PRODUCTION_DR_RUNBOOK_2026-05-11.md` +- Create: `docs/operations/DR_DRILL_REPORT_TEMPLATE.md` +- Modify: `docs/technical/README.md` +- Modify: `docs/operations/README.md`(若存在) +- Modify: `.hermes/shared-memory/project-overview.md`(仅增加文档索引) +- Optional Modify: `jenkins/Jenkinsfile.production-database-export` +- Optional Modify: `jenkins/Jenkinsfile.production-database-import` +- Optional Create: `scripts/deploy/production-backup-sync.sh` +- Optional Create: `scripts/deploy/verify-database-backup.sh` + +--- + +## 8. 测试与验收 + +### 8.1 文档与编码 + +```bash +npm run check:encoding +``` + +Expected: PASS。 + +### 8.2 Shell 脚本语法 + +如新增 shell 脚本: + +```bash +bash -n scripts/deploy/production-backup-sync.sh +bash -n scripts/deploy/verify-database-backup.sh +``` + +Expected: PASS。 + +### 8.3 Jenkinsfile 静态检查 + +建议在 Jenkins UI 或本地 Jenkins Pipeline Linter 中检查: + +- `jenkins/Jenkinsfile.production-database-export` +- `jenkins/Jenkinsfile.production-database-import` + +Expected: Pipeline syntax valid。 + +### 8.4 演练验收 + +至少完成一次 development 目标演练: + +1. 触发 `Genarrative-Database-Export`。 +2. 确认备份产物存在并归档。 +3. 使用 `Genarrative-Database-Import` dry-run 验证备份可解析。 +4. 不覆盖生产数据的前提下,记录演练报告。 + +release 目标演练应在业务低峰进行,并先确认通知渠道可用。 + +--- + +## 9. 风险、取舍与开放问题 + +### 9.1 风险 + +- 单机生产仍存在物理机级单点故障,首版只能通过“快速重建 + 异地备份”降低恢复时间。 +- SpacetimeDB schema 回滚不一定可逆,必须把发布前备份作为强约束。 +- Jenkins controller 若在本地 Windows,controller 自身备份和恢复需要单独制定,不应只依赖 agent 自恢复。 +- 外部模型网关失败可能影响创作能力,但不应影响已发布作品浏览和后台基础能力。 + +### 9.2 取舍 + +- 选择先做可执行 runbook 和备份恢复演练,而不是直接引入复杂多活。 +- 选择继续复用现有 Jenkins 导入导出流水线,降低工程改造风险。 +- 选择不把密钥恢复细节写死到 Git 文档,避免泄露。 + +### 9.3 开放问题 + +1. release 环境是否已经有独立备份机或对象存储?如果有,需要补充备份同步目标,但不能提交密钥。 +2. Jenkins controller 的 `JENKINS_HOME` 当前实际部署在哪里?是否已有周期备份? +3. 生产域名 DNS TTL 当前是多少?是否可降低到适合故障切换的值? +4. `/stdb` 所在磁盘是否独立于系统盘?是否已有磁盘水位告警? +5. release 环境的通知渠道除邮件外是否需要接入企业微信/飞书/Telegram? + +--- + +## 10. 推荐实施顺序 + +1. 先只落文档:技术方案 + runbook + 演练模板。 +2. 在 development 目标做一次数据库导出 + dry-run 导入演练。 +3. 根据演练结果补脚本:备份同步、备份健康检查。 +4. 再把 release 备份设置为定时任务。 +5. 最后规划整机重建演练与 DNS 切换演练。 + +首版完成标准: + +- 团队任一成员打开 runbook,即可在 30 分钟内完成 Web/API 回滚或数据库备份 dry-run 恢复。 +- 最近一次数据库备份时间、备份位置、checksum、恢复演练结果可追溯。 +- 生产密钥仍只存在于服务器/Jenkins 凭据/加密备份中,不进入 Git。 diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 2d0c0c6d..980871b5 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-05-11 拼图与抓大鹅结果页音频资产复用通用创作音频链路 + +- 背景:拼图和抓大鹅结果页需要接入 Suno 背景音乐,抓大鹅还需要物体点击音效,但当前两类作品没有独立的作品级音频表或 metadata 字段。 +- 决策:新增 `/api/creation/audio/*` 通用创作音频路由,后端统一负责 VectorEngine 音频任务、OSS 转存、`asset_object` 与 `asset_entity_binding` 写入;视觉小说旧路由保留并复用同一持久化逻辑。拼图背景音乐暂存到首关 `levels_json[0].backgroundMusic/background_music`;抓大鹅背景音乐暂存到 `generated_item_assets_json[0].backgroundMusic/background_music`,单物体点击音效存到对应 item 的 `clickSound/click_sound`。本轮不新增 SpacetimeDB 表和字段。 +- 影响范围:拼图结果页、抓大鹅结果页、抓大鹅运行态音频播放、通用创作音频 shared contracts、`api-server` 音频路由和资产绑定。 +- 验证方式:执行拼图/抓大鹅结果页定向测试、`npm run typecheck`、`cargo test -p api-server vector_engine_audio_generation`、`cargo test -p shared-contracts creation_audio`、`cargo check -p api-server`,真实生成需配置 VectorEngine 与 OSS 私密环境。 +- 关联文档:`docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md`。 + ## 2026-05-10 儿童动作 Demo 视觉资产统一为绘本草地舞台 - 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格,并且要预留 image-2 真实背景图的固定接入位。 @@ -24,6 +32,14 @@ - 验证方式:检查 `/child-motion-demo` 舞台是否在未生成资产时仍有可用草地绘本兜底;补齐 VectorEngine 私密配置后运行 `npm run assets:child-motion-demo -- --live` 应能写出默认背景文件。 - 关联文档:`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`、`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`。 +## 2026-05-10 方洞挑战从创作页入口和作品架隐藏 + +- 背景:运营节奏要求创作页完全隐藏方洞挑战,不能只隐藏新建入口后仍从创作页作品架暴露已有方洞草稿或已发布作品。 +- 决策:SpacetimeDB `creation_entry_type_config` 中 `square-hole.visible=false` 作为创作页统一开关;创作 Tab 模板入口、旧选择弹层、创作 Hub 卡带和创作页作品架都基于该开关隐藏方洞挑战。既有方洞详情、作品号、广场和运行态链路暂不删除,api-server 路由熔断只按 `open=false` 禁用玩法 API。 +- 影响范围:SpacetimeDB 入口配置默认种子、`platformEntryCreationTypes`、`CustomWorldCreationHub`、`PlatformEntryFlowShellImpl` 以及创作入口相关文档和回归测试。 +- 验证方式:执行入口配置、创作 Hub 和平台入口交互定向测试,确认看不到“方洞挑战” Tab、按钮和作品架条目。 +- 关联文档:`docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md`、`docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`。 + ## 2026-05-10 运行态输入设备抽象层全项目通用化 - 背景:拼图运行态接入 mocap 后,鼠标/触控和 mocap 各自维护输入逻辑会导致合并大块、拖拽语义和取消会话行为不一致;后续其他玩法也需要复用体感、摇杆、键盘等设备输入。 @@ -118,7 +134,7 @@ - 背景:平台计划新增 2048 游戏玩法模板,需要同时适配前端 stage、HTTP 路由、Rust 模块、SpacetimeDB 表和公开作品号;裸 `2048` 不适合作为模块或文件命名前缀。 - 决策:面向用户展示名保持 `2048`,工程玩法 ID 固定为 `twenty-forty-eight`,Rust 模块与表前缀使用 `twenty_forty_eight`,公开作品号前缀使用 `TF-`;玩法按完整闭环设计,包含 Agent 创作、结果页、试玩、发布、公开运行、后端棋盘裁决、排行榜和作品架 / 广场接入。 -- 影响范围:后续 `src/config/newWorkEntryConfig.ts`、平台 `SelectionStage`、前端 `twenty-forty-eight-*` 组件与 service、`module-twenty-forty-eight`、`shared-contracts`、`spacetime-module` 表、`spacetime-client` facade、`api-server` 路由、作品号和 PRD 索引。 +- 影响范围:后续 SpacetimeDB 创作入口配置、平台 `SelectionStage`、前端 `twenty-forty-eight-*` 组件与 service、`module-twenty-forty-eight`、`shared-contracts`、`spacetime-module` 表、`spacetime-client` facade、`api-server` 路由、作品号和 PRD 索引。 - 验证方式:后续落地时确认用户可见标题为 `2048`,代码、路由和表统一使用 `twenty-forty-eight` / `twenty_forty_eight`;移动、合并、生成新方块、目标达成、失败和榜单成绩由后端正式裁决,前端不伪造分数或目标达成。 - 关联文档:`docs/prd/AI_NATIVE_2048_GAMEPLAY_TEMPLATE_PRD_2026-05-05.md`。 @@ -221,10 +237,26 @@ ## 2026-05-10 视觉小说入口收敛为单句创作 + 画风选择 - 背景:视觉小说入口页要对齐抓大鹅式的线性创作入口,只保留最小可用输入,避免再暴露文档 / 空白 / 对话式工作台。 -- 决策:入口页只展示一句话创作输入框和横向视觉画风卡片;画风通过 `seedText` 追加 `视觉画风` 和 `画风要求` 两行透传给既有创作链路;点击生成后先进入 `visual-novel-generating` 过程页,再自动进入 `visual-novel-result`。 -- 影响范围:`VisualNovelAgentWorkspace`、`PlatformEntryFlowShellImpl`、`platformEntryTypes`、视觉小说 PRD;不新增后端字段或数据库结构。 -- 验证方式:视觉小说工作台单测通过,`npm run check:encoding` 通过;`npm run typecheck` 仍受仓库里 `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` 的既有类型错误影响。 -- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`。 +- 决策:入口页只展示一句话创作输入框和横向视觉画风卡片;画风通过 `seedText` 追加 `视觉画风` 和 `画风要求` 两行透传给既有创作链路;点击生成后先进入 `visual-novel-generating` 过程页,再自动进入 `visual-novel-result`。画风卡片主视觉固定消费 `public/visual-novel-style-references/` 下由 VectorEngine `gpt-image-2-all` 生成的静态参考图,不在前端运行时现场调用生图接口。 +- 影响范围:`VisualNovelAgentWorkspace`、`visualNovelEntryGeneration`、`PlatformEntryFlowShellImpl`、视觉小说 PRD 和创作 Tab 设计文档;不新增后端字段或数据库结构。 +- 验证方式:执行 `npm run test -- VisualNovelAgentWorkspace`、视觉小说工作台相关 ESLint、`npx prettier --check` 和 `npm run check:encoding`;`npm run typecheck` 若失败需先区分是否来自无关 Match3D / RPG 既有改动。 +- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`。 + +## 2026-05-10 用户标签只做后端白名单投影 + +- 背景:运营邀请码需要给账号打标签,但标签默认不能暴露到前端通用用户资料;拼图排行榜仅需展示特定标签。 +- 决策:`user_account.user_tags` 保存账号标签,数据库默认 `None`,业务按空数组读取;后台预置邀请码使用后授予的标签不再使用独立列,统一存放并解析自 `profile_invite_code.metadata_json.userTags`,兼容读取 `user_tags`。通用登录态和个人资料不返回原始标签。首版只在拼图排行榜 `visibleTags` 中白名单投影 `北科`。 +- 影响范围:用户认证表、邀请码后台、邀请兑换事务、拼图排行榜响应和 UI。 +- 验证方式:表结构变更需同步 `migration.rs`、`SPACETIMEDB_TABLE_CATALOG.md` 和 SpacetimeDB bindings;后端运行 `cargo check -p api-server`,后台运行 `npm run admin-web:typecheck`。 +- 关联文档:`docs/technical/USER_TAG_INVITE_AND_PUZZLE_LEADERBOARD_2026-05-10.md`。 + +## 2026-05-10 抓大鹅草稿元信息由 gpt-4o 生成 + +- 背景:抓大鹅草稿生成需要基于入口题材设定生成作品名称,结果页作品信息要对齐拼图草稿,不再把封面和作品名称拆成两个模块。 +- 决策:`match3d_compile_draft` 使用 `gpt-4o` 生成 `gameName` 与 3 到 6 个标签;`summary` 默认保持空字符串;标签可由结果页 `作品信息` Tab 手动编辑或再次 AI 生成。草稿生成会产出 3 个物品图片并立即调用 Rodin 生成 GLB,图片和模型一起写入 `generated_item_assets_json`,运行态必须优先消费 `generatedItemAssets[].modelSrc` / `modelObjectKey`,默认积木只做兜底。 +- 影响范围:`api-server` Match3D 编译、Match3D works 标签接口、结果页 `作品信息` 与 `3D素材` Tab、运行态 `Match3DRuntimeShell` / `Match3DPhysicsBoard`、生成进度和 Match3D 技术文档。 +- 验证方式:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`、`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts`、`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`,并用 `npm run api-server` 检查 `/healthz`。 +- 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`、`docs/technical/MATCH3D_RODIN_ASSET_TAB_2026-05-10.md`。 ## 2026-05-07 移动端整页缩放由入口统一锁定 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index a7d72aa6..9f63fe06 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -155,6 +155,14 @@ - 验证:重新发布日志应显示创建新的数据库,而不是更新旧数据库;若仍显示更新或继续 `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 解析报错。 @@ -437,13 +445,21 @@ - 验证:`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 模型下载 +## 抓大鹅草稿生成恢复 Rodin 后要并行生成模型、同步长超时和 GLB 私有读取 -- 现象:抓大鹅草稿生成时 Hyper3D 状态已完成,但下载列表为空或没有可用模型文件,`/api/creation/match3d/sessions/{sessionId}/actions` 返回 `502 Bad Gateway`,前端提示 `Hyper3D 已完成但未返回可下载模型文件`。 -- 原因:草稿生成链路曾在切割图片后立即并行调用 Rodin 图生模型,并把模型下载成功作为草稿完成前置条件;上游完成态和可下载文件列表不是强一致,容易把本来可用的图片草稿卡死。 -- 处理:草稿阶段只生成物品名、素材图、切割独立图片并上传 OSS,返回 `status = image_ready`;Rodin 3D 模型生成留到结果页 `3D素材` Tab 手动触发。 -- 验证:草稿响应中的 `generatedItemAssets[].imageSrc` 有值、`modelSrc` 为空、状态为 `image_ready`;结果页显示 `图片已就绪` 和 `0 文件`,不会自动请求 Hyper3D 下载。 -- 关联:`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`。 +- 现象:抓大鹅草稿生成重新接回 Rodin 后,前端可能在模型轮询和 GLB 转存完成前超时;或 Hyper3D 控制台显示 3 个任务已完成,但草稿进度页仍停留在 `生成3D模型`;或结果页把 `/generated-match3d-assets/.../model.glb` 直接当浏览器 URL 加载导致私有 OSS/CORS 读取失败。 +- 原因:`match3d_compile_draft` 会完成作品元信息、素材图、切图上传、3 件 Rodin 图生模型、GLB 下载和 OSS 转存,耗时远长于普通 Agent action;如果 3 件 Rodin 模型逐个提交和轮询,等待时间会线性叠加;同时 generated 私有资产不能被 Three.js 直接 `fetch`。 +- 处理:切图和图片入库后,所有 Rodin 图生模型任务必须并行提交、并行轮询、并行下载转存;Match3D creation client 的 `executeAction` 必须给长超时,当前为 20 分钟;生成进度页要包含 `生成3D模型` 阶段,但它不是 Hyper3D task 订阅页,而是在长 action 执行期间旁路轮询 session / work detail,并用 profile 的 `generatedItemAssets` 更新完成数量;控制台看到 Rodin `Done` 后仍需等待下载列表、GLB 下载、OSS 转存和草稿 JSON 写回。结果页模型预览、场内运行态和备选栏预览都必须通过 `/api/assets/read-bytes` 读取 GLB 字节后交给 Three.js GLTFLoader,不要直接请求裸 generated 路径。排查时按同一个 session/profile 查看 api-server 日志:`抓大鹅 Rodin 状态轮询返回`、`抓大鹅 Rodin 下载列表轮询返回`、`抓大鹅 Rodin GLB 下载完成`、`抓大鹅 Rodin GLB 转存 OSS 完成`;同时检查前端 work detail 响应里的 `generatedItemAssets[].status/modelObjectKey/error`。 +- 验证:`npm run test -- src\services\miniGameDraftGenerationProgress.test.ts src\components\match3d-result\Match3DResultView.test.tsx src\components\match3d-runtime\Match3DRuntimeShell.test.tsx`、`npm run typecheck`;真实联调需配置 `VECTOR_ENGINE_API_KEY`、`HYPER3D_API_KEY` 和 OSS 变量。 +- 关联:`src/services/match3d-creation/match3dCreationClient.ts`、`src/services/creation-agent/creationAgentClientFactory.ts`、`src/components/match3d-result/Match3DModelPreview.tsx`、`src/components/match3d-runtime/Match3DPhysicsBoard.tsx`、`server-rs/crates/api-server/src/match3d.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 + +## Rodin 完成态后下载列表可能延迟或字段漂移 + +- 现象:抓大鹅草稿生成时 Rodin 状态已完成,但 `/api/creation/match3d/sessions/{sessionId}/actions` 返回 502,提示 `{物品名} 3D 模型已完成但未返回可下载模型文件:{taskUuid}`。 +- 原因:Hyper3D Rodin 官方 `check-status_reset_v` 示例要求看 `jobs` 列表,只有所有 job 都 `Done` 才能进入下载;`download-results_reset_v` 还明确要求用生成响应顶层 `uuid` 作为 `task_uuid`,不要用 `jobs.uuids` 子任务 uuid。旧聚合若只看 root status 或第一个 job,可能在 preview job 完成但模型 job 仍在生成时提前下载。另外任务完成和下载列表文件发布不是强同步的;上游下载结果还可能使用 `fileUrl`、`signedUrl`、`presignedUrl`、`fileName` 等字段别名,旧解析器只识别 `url/downloadUrl/name/file_name/filename` 时会得到空列表。 +- 处理:`query_task_status` 聚合状态必须以 `jobs` 为准:任一 failed 即 failed,全部 done 才 done;`match3d_compile_draft` 在状态完成后对 `query_downloads` 继续轮询;下载解析兼容常见 URL 和文件名字段别名;模型选择优先 `.glb`,可兜底到非图片下载文件,但只有 preview/png/jpg/webp 这类预览图时必须继续失败,不能伪装成 GLB。 +- 验证:`cargo test -p api-server match3d_model_download --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server extracts_download_files --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server hyper3d --manifest-path server-rs/Cargo.toml`。 +- 关联:`server-rs/crates/api-server/src/match3d.rs`、`server-rs/crates/api-server/src/hyper3d_generation.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 ## 抓大鹅切图路径不能只用中文物品名 @@ -461,6 +477,30 @@ - 验证:`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`。 +## 抓大鹅试玩和正式运行态不要只读草稿页本地模型预览 + +- 现象:历史草稿页 `3D素材` Tab 能看到水果模型,但点击试玩或从推荐 / 公开作品进入正式抓大鹅时,局内仍显示默认积木素材。 +- 原因:结果页手动 `重新生成` 曾只更新本地 `assetDrafts.downloads`,没有把新的 GLB 写回 `generatedItemAssets`;历史数据还可能只有 `modelObjectKey` 而没有 `modelSrc`;推荐流内嵌运行态若只读卡片摘要,卡片缺素材时会把已持久化 profile 模型丢掉;本次生成 response 的 draft 也可能比 profile 旧,只带图片而不带模型。 +- 处理:结果页模型预览和运行态都按 `modelSrc || modelObjectKey` 读取;手动重新生成成功后把素材草稿重新序列化并写回作品 profile;`Match3DResultView` 合并同 `itemId` 的 draft/profile 素材,用 profile 已有模型补齐旧 draft;推荐流内嵌运行态启动前若卡片摘要没有生成素材,补读 `getMatch3DWorkDetail(profileId)` 并把详情资产传给 `Match3DRuntimeShell`。 +- 验证:执行 `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[].modelSrc/modelObjectKey`。 +- 关联:`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`。 + +## 抓大鹅标签清洗不要把 `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`。 + +## 用户标签不要直接外显,SpacetimeDB Vec 字段不要写 default 宏 + +- 现象:给 `user_account.user_tags` 或邀请码独立标签列写 `#[default(Vec::::new())]` 时,SpacetimeDB WASM 构建报 `destructor of Vec cannot be evaluated at compile-time`。 +- 原因:SpacetimeDB 的 table default 宏会走编译期常量求值,不能直接使用有析构逻辑的堆分配类型默认值。 +- 处理:`user_account.user_tags` 使用 `Option>` + `#[default(None::>)]` 表达数据库默认空,业务层统一把 `None` 归一化为空数组;邀请码授予标签复用 `metadata_json.userTags` 存储和解析,不再新增独立 Vec 列。用户标签原始值不得进入登录态、个人资料等通用响应,只能在明确业务白名单里投影,例如拼图排行榜 `visibleTags` 首版仅允许 `北科`。 +- 验证:`npm run spacetime:generate -- --rust-only` 能通过;`user_account` 旧迁移 JSON 缺字段时能导入,`profile_invite_code` 缺 `metadata_json` 时按 `{}` 兼容。 +- 关联:`docs/technical/USER_TAG_INVITE_AND_PUZZLE_LEADERBOARD_2026-05-10.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。 + ## 公开作品详情深链找不到作品不能停在空详情页 - 现象:直接访问 `/works/detail?work=PZ-...`,作品不存在或已下架时会弹出“作品不存在或已下架,将返回首页。”;关闭提示后仍可能停在大白屏。 diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 6d37d80e..013d36f4 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -182,7 +182,6 @@ export interface AdminUpsertProfileRedeemCodeRequest { export interface AdminUpsertProfileInviteCodeRequest { inviteCode: string; metadata?: Record; - grantedUserTags: string[]; startsAt?: string | null; expiresAt?: string | null; } @@ -229,7 +228,6 @@ export interface ProfileInviteCodeAdminResponse { userId: string; inviteCode: string; metadata: Record; - grantedUserTags: string[]; startsAt?: string | null; expiresAt?: string | null; status: 'pending' | 'active' | 'expired'; diff --git a/apps/admin-web/src/pages/AdminInviteCodePage.tsx b/apps/admin-web/src/pages/AdminInviteCodePage.tsx index 48f7c3c4..5952d042 100644 --- a/apps/admin-web/src/pages/AdminInviteCodePage.tsx +++ b/apps/admin-web/src/pages/AdminInviteCodePage.tsx @@ -78,10 +78,13 @@ export function AdminInviteCodePage({ setIsSaving(true); try { + const metadata = withMetadataUserTags( + parseMetadata(metadataText), + parseUserTags(grantedTagsText), + ); const payload: AdminUpsertProfileInviteCodeRequest = { inviteCode: inviteCode.trim(), - metadata: parseMetadata(metadataText), - grantedUserTags: parseUserTags(grantedTagsText), + metadata, startsAt: startsAt ? toIsoDateTime(startsAt) : null, expiresAt: expiresAt ? toIsoDateTime(expiresAt) : null, }; @@ -117,7 +120,7 @@ export function AdminInviteCodePage({ setInviteCode(entry.inviteCode); setStartsAt(toDateTimeLocalValue(entry.startsAt)); setExpiresAt(toDateTimeLocalValue(entry.expiresAt)); - setGrantedTagsText(entry.grantedUserTags.join('、')); + setGrantedTagsText(metadataUserTags(entry.metadata).join('、')); setMetadataText(JSON.stringify(entry.metadata, null, 2)); } @@ -252,7 +255,7 @@ export function AdminInviteCodePage({ - + @@ -291,7 +294,7 @@ export function AdminInviteCodePage({
标签
- +
@@ -338,6 +341,29 @@ function TagList({tags}: {tags: string[]}) { ); } +function metadataUserTags(metadata: Record) { + const raw = metadata.userTags ?? metadata.user_tags; + if (!Array.isArray(raw)) { + return []; + } + + return parseUserTags(raw.filter((value): value is string => typeof value === 'string').join('、')); +} + +function withMetadataUserTags( + metadata: Record, + tags: string[], +): Record { + const next = {...metadata}; + delete next.user_tags; + if (tags.length) { + next.userTags = tags; + } else { + delete next.userTags; + } + return next; +} + function parseUserTags(value: string) { const tags: string[] = []; for (const raw of value.split(/[\n,,;;、]+/)) { diff --git a/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md b/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md index 1738becb..b2330aa5 100644 --- a/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md +++ b/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md @@ -7,7 +7,7 @@ 创作 Tab 恢复为模板选择入口,但不回到旧的大卡片选择面板: 1. 首屏保留现有创作页布局骨架,顶部标题固定为“10分钟创作一个精品互动玩法”。 -2. 选择模板入口改为横向 Tab,数据来自 `src/config/newWorkEntryConfig.ts` 的可见玩法配置。 +2. 选择模板入口改为横向 Tab,数据来自 `GET /api/creation-entry/config` 返回的可见玩法配置。 3. 默认选中“拼图”模板,并在创作 Tab 内直接展示拼图创作表单。 4. 智能创作入口从可见模板中隐藏,保留既有 `creative-agent` 运行链路用于后续内部恢复或草稿目标打开。 5. 草稿、发现、我的等一级 Tab 职责不变,作品管理仍在草稿 Tab。 @@ -18,7 +18,7 @@ ```text 标题:10分钟创作一个精品互动玩法 -模板 Tab:拼图 / 方洞挑战 / 视觉小说 / AIRP +模板 Tab:拼图 / 抓大鹅 / 视觉小说(敬请期待)/ AIRP 默认内容:拼图创作表单 ``` @@ -34,21 +34,24 @@ 1. 打开“创作”一级 Tab 时默认停留在拼图 Tab,不主动创建拼图 session。 2. 点击拼图表单“生成草稿”后,才创建拼图 session 并执行 `compile_puzzle_draft`。 3. 拼图表单内的模板按钮使用 `tablist / tab` 语义,点击后只填充画面描述。 -4. 点击非拼图且已开放的模板 Tab 时,进入该玩法既有工作台;未开放模板保持禁用。 +4. 点击非拼图且已开放的模板 Tab 时,进入该玩法既有工作台;视觉小说与 AIRP 当前保持敬请期待禁用态。 5. `creative-agent` 不出现在模板 Tab 和选择弹层中,不再作为创作 Tab 首屏入口。 +6. 方洞挑战暂时从创作页完全隐藏,不出现在模板 Tab、旧选择弹层和创作 Hub 卡片中;既有作品链路继续保留。 ## 4. 验收 1. 点击“创作”后首屏出现“10分钟创作一个精品互动玩法”。 2. 顶部选择模板入口为 Tab,拼图 Tab 默认 `aria-selected=true`。 3. 创作 Tab 默认显示拼图创作表单内容,且不显示旧“Hi, 朋友”、输入框或智能创作快捷按钮。 -4. 隐藏的智能创作类型不出现在模板 Tab、旧选择弹层和创作 Hub 卡片中。 +4. 隐藏的智能创作类型与方洞挑战不出现在模板 Tab、旧选择弹层和创作 Hub 卡片中。 5. 草稿页返回创作页后仍回到同一模板入口,并可保留拼图表单草稿内容。 ## 5. 嵌入式表单 UI 细节 2026-05-10 补充:抓大鹅与视觉小说作为创作 Tab 内嵌表单时,风格类横滑选择器应统一使用浅底卡片、柔和玫瑰色选中态和小圆点确认标记。不要使用大面积黑色渐变、黑底胶囊标签或高饱和红色外框,以免在输入框下方误读为错误提示。 +2026-05-10 追加:视觉小说画风选项已改为使用 `public/visual-novel-style-references/` 下由 VectorEngine `gpt-image-2-all` 生成的参考图,作为横向卡片主视觉。 + 嵌入式表单控件保持以下口径: 1. 大文本输入框使用白底、低饱和边框和轻量 focus ring。 diff --git a/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md b/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md index a17f8e3d..d0e1f45a 100644 --- a/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md +++ b/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md @@ -114,3 +114,13 @@ - 未登录用户点击推荐页封面时,再次打开同一个登录弹窗;登录成功后由既有受保护动作继续进入作品详情或玩法入口。 - 未登录状态下点击“下一个”只切换下一张推荐封面,不触发登录弹窗,也不启动玩法。 - 已登录用户继续沿用推荐页内嵌运行态、上下滑切换和底部“下一个”行为。 + +## 10. 2026-05-11 草稿生成中与新完成标记 + +草稿生成过程页允许用户直接返回创作中心并自由使用平台其它功能: + +- 点击生成过程页的返回按钮时,当前生成任务继续在后台执行,页面回到创作中心,不清空生成状态。 +- 用户再进入草稿 Tab 并点击同一草稿时,若生成仍未完成,进入对应生成过程页查看最新进度;若已完成,直接进入对应结果页。 +- 草稿作品卡在生成中展示“生成中”状态标记;新生成完成且用户尚未查看的草稿在卡片右上角展示红点。 +- 底部一级“草稿”Tab 在存在未查看新完成草稿时展示红点;用户点击查看带红点的作品后,该作品红点消失。若草稿页已无任何带红点作品,底部“草稿”Tab 红点同步消失。 +- 生成完成时如果用户仍停留在对应生成过程页,可自动进入结果页;如果用户已经回到创作中心或其它功能页,不打断当前操作。 diff --git a/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md b/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md index 43bcdebf..eeba4d8a 100644 --- a/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md +++ b/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md @@ -124,7 +124,7 @@ Match3D 必须形成独立玩法域,后续技术方案至少需要覆盖: 3. 不做排行榜正式展示。 4. 不做道具,但需要预留功能口。 5. 不做洗牌、重置、旋转、放大等局内操作。 -6. 不做真实 3D 模型。 +6. 不做多批次真实 3D 模型生成;当前草稿生成只固定产出 `3` 个 GLB 模型并写入 OSS。 7. 不做真实 3D 物理遮挡。 8. 不做真实物理碰撞结算。 9. 不做必须试玩通关才能发布的门槛。 @@ -161,7 +161,7 @@ Match3D 首版参考拼图后期的入口表单收集方式,而不是早期的 题材决定后续生成或选择物品素材的方向。用户可以自定义主题,例如水果、玩具、食物、符号等。 -首版 demo 不接入真实图片生成。当前运行态可消除物统一使用题材方向的 25 个积木件类型表现,不使用透明气泡,也不在图案上放文字标识。前端首版用差异化颜色、积木造型和 3D 程序化模型表现可消除物,避免玩家在堆叠状态下难以辨认。 +当前抓大鹅草稿生成会固定生成 `3` 个题材物品:素材图切割出的独立图片会作为 Rodin 图生 3D 参考图,生成出的 GLB 模型必须转存 OSS,并随作品 profile 的 `generatedItemAssets` 持久化。运行态优先使用这些生成模型;只有模型缺失、加载失败或未进入 3D 渲染模式时,才回退到 25 个默认积木件视觉键。默认素材不使用透明气泡,也不在图案上放文字标识。 可消除物尺寸使用五档相对体积规则:XL 型相对体积为 `1.60~2.30`,L 型为 `1.25~1.60`,M 型为 `1.00`,XS 型为 `0.65~0.85`,S 型为 `0.35~0.50`。单局中 XL / L / M / XS / S 按本局使用的消除物类型数的 `20% / 30% / 30% / 15% / 5%` 分配;非整数配额按最大余数补齐,确保总数等于本局使用类型数量。同一关卡内同一个颜色和造型的物品只能对应一个尺寸档位;可存在同尺寸但不同颜色和造型的物品。后端运行态通过 `radius` 下发权威尺寸,前端只按快照表现。 @@ -195,7 +195,7 @@ Match3D 首版参考拼图后期的入口表单收集方式,而不是早期的 ## 6.3 参考图片 -抓大鹅入口页不展示参考图片上传。题材表现先由题材文本和草稿切割图片链路承接;后续需要 3D 模型时,在结果页 `3D素材` Tab 以切割图片作为图生模型参考图手动触发。 +抓大鹅入口页不展示参考图片上传。题材表现由题材文本和草稿切割图片链路承接;草稿生成阶段会直接以切割图片作为 Rodin 图生模型参考图生成首批 GLB。结果页 `3D素材` Tab 仍可对单个素材触发重新生成。 --- @@ -222,9 +222,9 @@ Match3D 首版参考拼图后期的入口表单收集方式,而不是早期的 ## 7.3 素材生成边界 -抓大鹅草稿生成链路会生成首批 `3` 个题材物品素材:文本模型生成物品名,VectorEngine 生成 `2*2` 素材图并切割独立图片。入口页选择的 `assetStylePrompt` 必须写入素材图提示词;结果页手动 Rodin 图生模型时,继续以该物品图片和默认提示词作为起点。 +抓大鹅草稿生成链路会生成首批 `3` 个题材物品素材:文本模型生成物品名,VectorEngine 生成 `2*2` 素材图并切割独立图片,再以每张独立图片调用 Rodin 图生 3D,下载 `.glb` 并转存 OSS。入口页选择的 `assetStylePrompt` 必须写入素材图提示词;结果页手动 Rodin 图生模型时,继续以该物品图片和默认提示词作为起点。 -生成出的独立图片先作为草稿页 `3D素材` Tab 的预览资产返回,状态为 `image_ready`,模型文件为空。正式平台资产绑定、Rodin 生成模型转存和二次编辑流程以后续技术方案为准。 +生成出的独立图片与 GLB 模型都必须作为草稿页 `3D素材` Tab 的预览资产返回。模型生成成功时 `generatedItemAssets[].status = model_ready`,并携带 `modelSrc`、`modelObjectKey`、`modelFileName`、`taskUuid` 和 `subscriptionKey`;正式平台资产绑定和更完整的二次编辑流程以后续技术方案为准。 ## 7.4 发布前试玩 @@ -297,12 +297,14 @@ itemTypeCount = clearCount <= 25 ? clearCount : 25 ## 8.5 物品资产 -首版 demo 使用 2D 图案素材。 +当前 demo 使用生成 GLB 优先、默认积木兜底的物品资产策略。 -1. demo 至少提供 `25` 种彼此不同的颜色与几何造型组合素材,支撑 `clearCount > 25` 时的类型上限。 -2. 当前 demo 使用 25 个积木件视觉键作为默认素材池;前端首版必须把这些视觉键映射为无文字的纯色 2D 图标和程序化 3D 积木模型,不能显示为透明气泡或文字标记。 -3. 后续可以尝试替换为伪 3D 或 3D 模型。 -4. 用户题材主题后续会映射为符合常识预期的物品集合。 +1. demo 至少提供 `25` 种彼此不同的颜色与几何造型组合默认素材,支撑 `clearCount > 25` 时的类型上限和 GLB 缺失兜底。 +2. 有 `generatedItemAssets[].modelSrc` 或 `modelObjectKey` 时,运行态与备选栏必须优先读取该 GLB;默认积木件只作为加载失败、模型缺失或 2D 回退时的兜底素材池。 +3. 前端读取生成模型必须通过 `/api/assets/read-bytes` 获取私有 OSS 字节,再交给 Three.js `GLTFLoader` 解析;不得直接把 `/generated-match3d-assets/...` 当裸 URL 请求。 +4. 当前固定 `clearCount = 3` 的生成草稿中,运行态 `match3d-type-01/02/03` 按类型编号顺序映射到生成出的 `3` 个模型;后续恢复更大生成数量时,模型列表顺序必须继续与类型编号稳定对应。 +5. 默认积木视觉键仍需映射为无文字的纯色 2D 图标和程序化 3D 积木模型,不能显示为透明气泡或文字标记。 +6. 用户题材主题后续会映射为符合常识预期的物品集合。 示例:水果题材可以对应红色苹果、黄色香蕉、紫色葡萄等。 @@ -706,10 +708,10 @@ GET /api/runtime/match3d/runs/:runId 3. 入口页不展示参考图上传。 4. 内置风格显示画风参考图,自定义风格通过独立面板填写并进入提交 payload。 5. 移动端入口页所有内容一屏展示,不产生纵向滚动。 -6. 系统可生成待发布结果页,并在草稿中返回首批切割图片素材预览。 +6. 系统可生成待发布结果页,并在草稿中返回首批切割图片与 OSS GLB 模型素材预览。 7. 用户可编辑游戏名称、标签、封面图等基础信息。 8. 用户可发布前试玩,且试玩失败不阻断发布。 -9. 运行态能展示圆形空间、倒计时、物品和 `7` 格备选栏。 +9. 运行态能展示圆形空间、倒计时、物品和 `7` 格备选栏;存在 `generatedItemAssets` 模型时必须优先展示生成 GLB,而不是默认积木素材。 10. 物品可重叠、遮挡、堆叠。 11. 被完全遮挡物品不可点击,露出可点击区域的物品可点击。 12. 点击通过后物品飞入备选栏。 diff --git a/docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md b/docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md index 18a413c7..ea644c98 100644 --- a/docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md +++ b/docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md @@ -50,18 +50,18 @@ 外部仓库文件只作为玩法语义参考,不能作为目录结构迁入依据: -| 外部参考 | 可参考内容 | Genarrative 落点 | -| --- | --- | --- | -| `interface/routes/visual.js` | 视觉小说创作和运行时路由语义 | `server-rs/crates/api-server/src/visual_novel.rs` | -| `interface/handlers/visual/sendActionStream.js` | 流式动作推进事件 | Axum SSE handler + `shared-contracts` typed envelope | -| `interface/handlers/visual/getHistory.js` | 历史记录读取 | 平台 runtime history API | -| `interface/handlers/visual/saves.js` | 存档语义 | 平台统一 `profile/save-archives` | -| `services/visual/gameLogic.js` | step 解析、会话状态推进 | `server-rs/crates/module-visual-novel` | -| `services/visual/storyGeneration.js` | 创作底稿生成语义 | `platform-agent` / `platform-llm` + visual novel Tool | -| `prompts/visual.gm.system/1.0.1/body.js` | 视觉小说 GM 输出结构经验 | 新 prompt 必须适配 Genarrative 契约,不原样照搬平台规则 | -| `src/page/Galgame.tsx` | 运行时界面结构 | `src/components/visual-novel-runtime/VisualNovelRuntimeShell.tsx` | -| `src/hooks/galgame/useGalgameController.ts` | 前端运行时状态组织 | visual novel runtime hooks | -| `src/page/GameSettingsEditor.tsx` | 创作编辑器模块划分 | visual novel result / workspace 组件 | +| 外部参考 | 可参考内容 | Genarrative 落点 | +| ----------------------------------------------- | ---------------------------- | ----------------------------------------------------------------- | +| `interface/routes/visual.js` | 视觉小说创作和运行时路由语义 | `server-rs/crates/api-server/src/visual_novel.rs` | +| `interface/handlers/visual/sendActionStream.js` | 流式动作推进事件 | Axum SSE handler + `shared-contracts` typed envelope | +| `interface/handlers/visual/getHistory.js` | 历史记录读取 | 平台 runtime history API | +| `interface/handlers/visual/saves.js` | 存档语义 | 平台统一 `profile/save-archives` | +| `services/visual/gameLogic.js` | step 解析、会话状态推进 | `server-rs/crates/module-visual-novel` | +| `services/visual/storyGeneration.js` | 创作底稿生成语义 | `platform-agent` / `platform-llm` + visual novel Tool | +| `prompts/visual.gm.system/1.0.1/body.js` | 视觉小说 GM 输出结构经验 | 新 prompt 必须适配 Genarrative 契约,不原样照搬平台规则 | +| `src/page/Galgame.tsx` | 运行时界面结构 | `src/components/visual-novel-runtime/VisualNovelRuntimeShell.tsx` | +| `src/hooks/galgame/useGalgameController.ts` | 前端运行时状态组织 | visual novel runtime hooks | +| `src/page/GameSettingsEditor.tsx` | 创作编辑器模块划分 | visual novel result / workspace 组件 | --- @@ -69,7 +69,7 @@ ### 3.1 必须完成的模板闭环 -1. 平台创作中心展示 `视觉小说` 入口,并在实现完成后从 `open: false` 改为 `open: true`。 +1. 平台创作中心展示 `视觉小说` 入口;2026-05-11 起当前运营状态回退为 `open: false`,入口显示敬请期待,不允许创建新视觉小说草稿。 2. 创作者可选择 `idea`、`document`、`blank` 三种起点创建视觉小说底稿。 3. Agent 或表单工作台生成 / 编辑同一份 `VisualNovelResultDraft`。 4. 结果页可编辑世界观、角色、场景、剧情阶段、资产和运行时配置。 @@ -160,8 +160,8 @@ 5. 玩家身份。 6. 3 到 6 个主要角色。 7. 3 到 8 个可用场景。 -7. 3 到 6 个剧情阶段。 -8. 初始场景、初始旁白和第一轮可选行动。 +8. 3 到 6 个剧情阶段。 +9. 初始场景、初始旁白和第一轮可选行动。 ### 5.2 文档创建 `document` @@ -399,7 +399,15 @@ export type VisualNovelAgentActionKind = ```ts export type VisualNovelAgentStreamEvent = | { type: 'start'; sessionId: string } - | { type: 'phase'; phase: 'perception' | 'reasoning' | 'drafting' | 'reflection' | 'finalizing' } + | { + type: 'phase'; + phase: + | 'perception' + | 'reasoning' + | 'drafting' + | 'reflection' + | 'finalizing'; + } | { type: 'text_delta'; text: string } | { type: 'draft_patch'; patch: VisualNovelDraftPatch } | { type: 'action_required'; action: VisualNovelAgentPendingAction } @@ -558,35 +566,35 @@ export type VisualNovelRuntimeStreamEvent = ### 9.1 创作 session -| 方法 | 路由 | 用途 | -| --- | --- | --- | -| `POST` | `/api/creation/visual-novel/sessions` | 创建视觉小说创作 session | -| `GET` | `/api/creation/visual-novel/sessions/{session_id}` | 读取 session snapshot | -| `POST` | `/api/creation/visual-novel/sessions/{session_id}/messages` | 非流式发送创作消息 | -| `POST` | `/api/creation/visual-novel/sessions/{session_id}/messages/stream` | 流式发送创作消息 | -| `POST` | `/api/creation/visual-novel/sessions/{session_id}/actions` | 执行结构化创作 action | -| `POST` | `/api/creation/visual-novel/sessions/{session_id}/compile` | 编译为 work profile 草稿 | +| 方法 | 路由 | 用途 | +| ------ | ------------------------------------------------------------------ | ------------------------ | +| `POST` | `/api/creation/visual-novel/sessions` | 创建视觉小说创作 session | +| `GET` | `/api/creation/visual-novel/sessions/{session_id}` | 读取 session snapshot | +| `POST` | `/api/creation/visual-novel/sessions/{session_id}/messages` | 非流式发送创作消息 | +| `POST` | `/api/creation/visual-novel/sessions/{session_id}/messages/stream` | 流式发送创作消息 | +| `POST` | `/api/creation/visual-novel/sessions/{session_id}/actions` | 执行结构化创作 action | +| `POST` | `/api/creation/visual-novel/sessions/{session_id}/compile` | 编译为 work profile 草稿 | ### 9.2 作品草稿与发布 -| 方法 | 路由 | 用途 | -| --- | --- | --- | -| `GET` | `/api/creation/visual-novel/works` | 读取当前用户视觉小说作品草稿列表 | -| `GET` | `/api/creation/visual-novel/works/{profile_id}` | 读取作品详情 | -| `PUT/PATCH` | `/api/creation/visual-novel/works/{profile_id}` | 更新作品草稿 | -| `DELETE` | `/api/creation/visual-novel/works/{profile_id}` | 删除未发布草稿或用户自有作品 | -| `POST` | `/api/creation/visual-novel/works/{profile_id}/publish` | 发布到平台作品体系 | +| 方法 | 路由 | 用途 | +| ----------- | ------------------------------------------------------- | -------------------------------- | +| `GET` | `/api/creation/visual-novel/works` | 读取当前用户视觉小说作品草稿列表 | +| `GET` | `/api/creation/visual-novel/works/{profile_id}` | 读取作品详情 | +| `PUT/PATCH` | `/api/creation/visual-novel/works/{profile_id}` | 更新作品草稿 | +| `DELETE` | `/api/creation/visual-novel/works/{profile_id}` | 删除未发布草稿或用户自有作品 | +| `POST` | `/api/creation/visual-novel/works/{profile_id}/publish` | 发布到平台作品体系 | ### 9.3 运行时 -| 方法 | 路由 | 用途 | -| --- | --- | --- | -| `GET` | `/api/runtime/visual-novel/gallery` | 读取平台聚合后的视觉小说公开作品列表 | -| `POST` | `/api/runtime/visual-novel/works/{profile_id}/runs` | 创建测试或正式 run | -| `GET` | `/api/runtime/visual-novel/runs/{run_id}` | 读取 run snapshot | -| `POST` | `/api/runtime/visual-novel/runs/{run_id}/actions/stream` | 提交选择 / 自由行动并流式推进 | -| `GET` | `/api/runtime/visual-novel/runs/{run_id}/history` | 读取当前 run 历史 | -| `POST` | `/api/runtime/visual-novel/runs/{run_id}/regenerate` | 从历史节点重生成 | +| 方法 | 路由 | 用途 | +| ------ | -------------------------------------------------------- | ------------------------------------ | +| `GET` | `/api/runtime/visual-novel/gallery` | 读取平台聚合后的视觉小说公开作品列表 | +| `POST` | `/api/runtime/visual-novel/works/{profile_id}/runs` | 创建测试或正式 run | +| `GET` | `/api/runtime/visual-novel/runs/{run_id}` | 读取 run snapshot | +| `POST` | `/api/runtime/visual-novel/runs/{run_id}/actions/stream` | 提交选择 / 自由行动并流式推进 | +| `GET` | `/api/runtime/visual-novel/runs/{run_id}/history` | 读取当前 run 历史 | +| `POST` | `/api/runtime/visual-novel/runs/{run_id}/regenerate` | 从历史节点重生成 | ### 9.4 存档 @@ -627,16 +635,16 @@ GET /api/runtime/profile/save-archives ### 10.1 crate 职责 -| crate / 层 | 职责 | -| --- | --- | +| crate / 层 | 职责 | +| -------------------------------------- | ------------------------------------------------------------- | | `server-rs/crates/module-visual-novel` | 纯领域规则:草稿校验、step 解析、run 状态推进、历史重生成边界 | -| `server-rs/crates/shared-contracts` | DTO、请求响应、SSE envelope、草稿和运行时契约 | -| `server-rs/crates/spacetime-module` | 表、reducer、procedure、migration 和事务编排 | -| `server-rs/crates/spacetime-client` | api-server 到 SpacetimeDB 的 typed facade | -| `server-rs/crates/api-server` | Axum 路由、鉴权、SSE、LLM 编排、资产和钱包 facade 调用 | -| `server-rs/crates/platform-llm` | 视觉小说创作和运行时 GM 的模型调用 | -| `server-rs/crates/platform-oss` | 文档、图片、音乐等资产对象读写 | -| `server-rs/crates/platform-agent` | 如复用创意 Agent,负责 Agent 编排和工具注册 | +| `server-rs/crates/shared-contracts` | DTO、请求响应、SSE envelope、草稿和运行时契约 | +| `server-rs/crates/spacetime-module` | 表、reducer、procedure、migration 和事务编排 | +| `server-rs/crates/spacetime-client` | api-server 到 SpacetimeDB 的 typed facade | +| `server-rs/crates/api-server` | Axum 路由、鉴权、SSE、LLM 编排、资产和钱包 facade 调用 | +| `server-rs/crates/platform-llm` | 视觉小说创作和运行时 GM 的模型调用 | +| `server-rs/crates/platform-oss` | 文档、图片、音乐等资产对象读写 | +| `server-rs/crates/platform-agent` | 如复用创意 Agent,负责 Agent 编排和工具注册 | ### 10.2 领域规则 @@ -654,14 +662,14 @@ GET /api/runtime/profile/save-archives 新增表必须同步 `migration.rs`、表目录和 bindings: -| 表 | 用途 | -| --- | --- | -| `visual_novel_agent_session` | 创作 session 主表 | -| `visual_novel_agent_message` | 创作消息和模型回复 | -| `visual_novel_work_profile` | 视觉小说作品草稿 / 发布 profile | -| `visual_novel_runtime_run` | 玩家或测试 run | -| `visual_novel_runtime_history_entry` | 运行时历史 | -| `visual_novel_runtime_event` | 可审计事件,不用于回放 | +| 表 | 用途 | +| ------------------------------------ | ------------------------------- | +| `visual_novel_agent_session` | 创作 session 主表 | +| `visual_novel_agent_message` | 创作消息和模型回复 | +| `visual_novel_work_profile` | 视觉小说作品草稿 / 发布 profile | +| `visual_novel_runtime_run` | 玩家或测试 run | +| `visual_novel_runtime_history_entry` | 运行时历史 | +| `visual_novel_runtime_event` | 可审计事件,不用于回放 | 不得新增: @@ -684,27 +692,27 @@ GET /api/runtime/profile/save-archives ### 11.1 入口配置 -当前 `src/config/newWorkEntryConfig.ts` 已存在: +入口配置事实源已经迁移到 SpacetimeDB 的 `creation_entry_type_config` 表,默认种子位于 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`。2026-05-11 起视觉小说当前默认状态为: ```ts { id: 'visual-novel', title: '视觉小说', - subtitle: '敬请期待', + subtitle: '分支叙事体验', badge: '敬请期待', visible: true, open: false, } ``` -实现完成后更新为: +重新开放创建时再通过后台入口开关或默认种子更新为: ```ts { id: 'visual-novel', title: '视觉小说', - subtitle: 'AI 生成可玩的视觉小说', - badge: '新玩法', + subtitle: '分支叙事体验', + badge: '可创建', visible: true, open: true, } @@ -781,6 +789,8 @@ service client 要复用现有请求封装、鉴权和错误提示风格,不 3. 生成草稿按钮。 4. 错误、忙碌与禁用态。 +视觉画风卡片使用 `public/visual-novel-style-references/` 下的 `gpt-image-2-all` 参考图,分别对应映画动画、水彩绘本、像素霓虹、水墨幻想、柔彩校园和暗色哥特。卡片只承载图像和标签,不额外展示说明文案。 + 点击生成草稿后进入 `visual-novel-generating` 过程页,过程页复用平台已有生成进度面板,展示一句话输入、画风和草稿生成阶段;完成后自动进入 `visual-novel-result` 草稿页。 不展示: @@ -975,13 +985,13 @@ V1 默认提供平台统一存档能力;如果平台存档 UI 当前按槽位 ### 16.1 并行批次总览 -| 批次 | 可并行任务 | 进入条件 | 汇合点 | -| --- | --- | --- | --- | -| Batch 0 | `VN-00` | PRD 已冻结 | 所有人以本文为唯一口径 | -| Batch 1 | `VN-01`、`VN-02`、`VN-03`、`VN-04` | `VN-00` 完成 | 契约、领域、UI 骨架、Prompt 口径可对齐 | -| Batch 2 | `VN-05`、`VN-06`、`VN-07`、`VN-08` | `VN-01` 产出契约初稿;`VN-02` 产出表草案 | 后端 API、前端创作、前端运行时可联调 | -| Batch 3 | `VN-09`、`VN-10`、`VN-11` | `VN-05`、`VN-06`、`VN-07`、`VN-08` 主链路可跑 | 发布、存档、广场、负向扫描收口 | -| Batch 4 | `VN-12`、`VN-13` | 主链路完成 | 全链路验收和文档同步 | +| 批次 | 可并行任务 | 进入条件 | 汇合点 | +| ------- | ---------------------------------- | --------------------------------------------- | -------------------------------------- | +| Batch 0 | `VN-00` | PRD 已冻结 | 所有人以本文为唯一口径 | +| Batch 1 | `VN-01`、`VN-02`、`VN-03`、`VN-04` | `VN-00` 完成 | 契约、领域、UI 骨架、Prompt 口径可对齐 | +| Batch 2 | `VN-05`、`VN-06`、`VN-07`、`VN-08` | `VN-01` 产出契约初稿;`VN-02` 产出表草案 | 后端 API、前端创作、前端运行时可联调 | +| Batch 3 | `VN-09`、`VN-10`、`VN-11` | `VN-05`、`VN-06`、`VN-07`、`VN-08` 主链路可跑 | 发布、存档、广场、负向扫描收口 | +| Batch 4 | `VN-12`、`VN-13` | 主链路完成 | 全链路验收和文档同步 | 并行规则: @@ -992,22 +1002,22 @@ V1 默认提供平台统一存档能力;如果平台存档 UI 当前按槽位 ### 16.2 并行任务具体要求速查表 -| 任务 | 可并行窗口 | 输入依赖 | 必须完成 | 禁止事项 | 验收口径 | -| --- | --- | --- | --- | --- | --- | -| `VN-00` 口径冻结 | Batch 0 | 本 PRD、旧 TXT 文档、Hermes 决策记录 | 冻结 `visual-novel` 只做模板玩法、平台接口、删除回放;标注旧文档冲突口径 | 不再使用“原样迁入外部平台工程”作为实现目标 | 文档和 Hermes 记录明确三条硬边界;`npm run check:encoding` 通过 | -| `VN-01` 契约与领域 | Batch 1 | PRD 第 6 到 8 章契约 | TS contracts、Rust shared-contracts、`module-visual-novel`、领域单测 | 不写 HTTP、DB reducer、LLM、UI;不出现 replay 类型 | TS/Rust 契约一致;草稿校验、step 解析、状态推进、重生成测试通过 | -| `VN-02` SpacetimeDB 与 facade | Batch 1 | `VN-01` 契约草案、SpacetimeDB 约束文档 | 表、reducer/procedure、migration、表目录、bindings、`spacetime-client` facade | 不手改 bindings 绕过 schema;不建 replay 表或私有 save 表 | schema 可生成;表目录写明 event 不是回放;`cargo check -p spacetime-client` 通过 | -| `VN-03` Prompt / LLM 工具 | Batch 1 | `VN-01` draft 与 step 契约 | 创作 prompt、运行 GM prompt、repair prompt、工具参数 | 不照搬外部平台规则、扣费规则、回放规则;不让前端猜业务 step | fixture 输出可解析;坏格式进入 repair 或可重试错误 | -| `VN-04` 前端 UI 骨架 | Batch 1 | PRD 第 12 章 UI 要求 | workspace、result、runtime mock 骨架;移动端优先布局;独立面板模式 | 不接真实 API;不写规则长文;不出现回放入口 | 桌面 / 移动基础布局可检查;长文本不撑破按钮 | -| `VN-05` API Server | Batch 2 | `VN-01`、`VN-02`,真实 LLM 依赖 `VN-03` | creation、works、runtime、history、regenerate 路由;SSE;平台 save archive 接入 | 不把领域规则塞 handler;不新增 replay 路由;不新增私有 save API | `/api/creation/visual-novel/*` 和 `/api/runtime/visual-novel/*` 可 smoke;`cargo check -p api-server` 通过 | -| `VN-06` 前端 service 与入口 | Batch 2 | `VN-01` TS 契约草案;mock 可先行 | service client、selection stage、入口分流、壳层挂载、登录态清理 | 不设计表单细节;不写 runtime UI 细节;不绕过平台入口 | 创作中心可进入 workspace;service 路由与 PRD 一致;`npm run typecheck` 通过 | -| `VN-07` 创作工作台与结果页 | Batch 2 | `VN-04`、`VN-06`,真实生成依赖 `VN-05` | `idea` / `document` / `blank`、Agent 进度、结果页表单、保存草稿、测试 run 入口 | 不做正式玩家 runtime;不新增说明页;复杂内容不在卡片下展开 | 三种创建起点可用;草稿写入 `VisualNovelResultDraft`;发布校验 issue 可见 | -| `VN-08` 前端运行时 | Batch 2 | `VN-04`、`VN-06`,真实运行依赖 `VN-05` | runtime shell、SSE 消费、step 渲染、历史、设置、存档、重生成 | 不做回放 UI;历史不生成分享播放页;`raw_text` 不作为业务真相 | typed step 可渲染;历史与重生成可用;save archive 可继续体验 | -| `VN-09` 作品架 / 广场 / 发布 | Batch 3 | `VN-05` works/gallery API、`VN-06` 壳层 | work summary 聚合、作品架、公开聚合、public work code、详情跳转 | 不新增独立视觉小说市场;不迁入外部平台详情页;不做分享回放 | 发布后作品架可见;公开作品可启动 run;聚合 key 不冲突 | -| `VN-10` 资产与文档输入 | Batch 3 | `VN-05` action/API、平台资产接口 | 文档资产读取、封面/场景/角色/音乐资产引用、可选图片生成 action | 不迁入外部 R2;不存大 Data URL;不绕过资产鉴权 | 文档创建使用资产引用;SpacetimeDB 不保存二进制或大 base64 | -| `VN-11` 负向扫描 | Batch 1 到发布前 | 所有任务增量改动 | 扫描 replay 和外部平台功能;补负向测试或脚本清单 | 不把历史误判为回放;不做大范围重构 | 新工程代码无 replay;外部平台账号/订单/会员/后台等未误入 | -| `VN-12` 全链路验收 | Batch 4 | `VN-05` 到 `VN-10` 主链路完成 | 创作、结果页、测试 run、发布、正式 run、历史、重生成、存档联调 | 不跳过移动端;不只测 mock;不忽略负向验收 | 全链路可跑;前端 typecheck、后端相关 cargo 检查、编码检查通过 | -| `VN-13` 文档与交接 | Batch 4 | 实际工程落地结果 | PRD、技术方案、表目录、Hermes 决策 / 踩坑同步 | 不让旧 TXT 文档重新成为实现口径;不留下过期接口描述 | 新开发者可按最新文档维护;文档与代码一致 | +| 任务 | 可并行窗口 | 输入依赖 | 必须完成 | 禁止事项 | 验收口径 | +| ----------------------------- | ---------------- | --------------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `VN-00` 口径冻结 | Batch 0 | 本 PRD、旧 TXT 文档、Hermes 决策记录 | 冻结 `visual-novel` 只做模板玩法、平台接口、删除回放;标注旧文档冲突口径 | 不再使用“原样迁入外部平台工程”作为实现目标 | 文档和 Hermes 记录明确三条硬边界;`npm run check:encoding` 通过 | +| `VN-01` 契约与领域 | Batch 1 | PRD 第 6 到 8 章契约 | TS contracts、Rust shared-contracts、`module-visual-novel`、领域单测 | 不写 HTTP、DB reducer、LLM、UI;不出现 replay 类型 | TS/Rust 契约一致;草稿校验、step 解析、状态推进、重生成测试通过 | +| `VN-02` SpacetimeDB 与 facade | Batch 1 | `VN-01` 契约草案、SpacetimeDB 约束文档 | 表、reducer/procedure、migration、表目录、bindings、`spacetime-client` facade | 不手改 bindings 绕过 schema;不建 replay 表或私有 save 表 | schema 可生成;表目录写明 event 不是回放;`cargo check -p spacetime-client` 通过 | +| `VN-03` Prompt / LLM 工具 | Batch 1 | `VN-01` draft 与 step 契约 | 创作 prompt、运行 GM prompt、repair prompt、工具参数 | 不照搬外部平台规则、扣费规则、回放规则;不让前端猜业务 step | fixture 输出可解析;坏格式进入 repair 或可重试错误 | +| `VN-04` 前端 UI 骨架 | Batch 1 | PRD 第 12 章 UI 要求 | workspace、result、runtime mock 骨架;移动端优先布局;独立面板模式 | 不接真实 API;不写规则长文;不出现回放入口 | 桌面 / 移动基础布局可检查;长文本不撑破按钮 | +| `VN-05` API Server | Batch 2 | `VN-01`、`VN-02`,真实 LLM 依赖 `VN-03` | creation、works、runtime、history、regenerate 路由;SSE;平台 save archive 接入 | 不把领域规则塞 handler;不新增 replay 路由;不新增私有 save API | `/api/creation/visual-novel/*` 和 `/api/runtime/visual-novel/*` 可 smoke;`cargo check -p api-server` 通过 | +| `VN-06` 前端 service 与入口 | Batch 2 | `VN-01` TS 契约草案;mock 可先行 | service client、selection stage、入口分流、壳层挂载、登录态清理 | 不设计表单细节;不写 runtime UI 细节;不绕过平台入口 | 创作中心可进入 workspace;service 路由与 PRD 一致;`npm run typecheck` 通过 | +| `VN-07` 创作工作台与结果页 | Batch 2 | `VN-04`、`VN-06`,真实生成依赖 `VN-05` | `idea` / `document` / `blank`、Agent 进度、结果页表单、保存草稿、测试 run 入口 | 不做正式玩家 runtime;不新增说明页;复杂内容不在卡片下展开 | 三种创建起点可用;草稿写入 `VisualNovelResultDraft`;发布校验 issue 可见 | +| `VN-08` 前端运行时 | Batch 2 | `VN-04`、`VN-06`,真实运行依赖 `VN-05` | runtime shell、SSE 消费、step 渲染、历史、设置、存档、重生成 | 不做回放 UI;历史不生成分享播放页;`raw_text` 不作为业务真相 | typed step 可渲染;历史与重生成可用;save archive 可继续体验 | +| `VN-09` 作品架 / 广场 / 发布 | Batch 3 | `VN-05` works/gallery API、`VN-06` 壳层 | work summary 聚合、作品架、公开聚合、public work code、详情跳转 | 不新增独立视觉小说市场;不迁入外部平台详情页;不做分享回放 | 发布后作品架可见;公开作品可启动 run;聚合 key 不冲突 | +| `VN-10` 资产与文档输入 | Batch 3 | `VN-05` action/API、平台资产接口 | 文档资产读取、封面/场景/角色/音乐资产引用、可选图片生成 action | 不迁入外部 R2;不存大 Data URL;不绕过资产鉴权 | 文档创建使用资产引用;SpacetimeDB 不保存二进制或大 base64 | +| `VN-11` 负向扫描 | Batch 1 到发布前 | 所有任务增量改动 | 扫描 replay 和外部平台功能;补负向测试或脚本清单 | 不把历史误判为回放;不做大范围重构 | 新工程代码无 replay;外部平台账号/订单/会员/后台等未误入 | +| `VN-12` 全链路验收 | Batch 4 | `VN-05` 到 `VN-10` 主链路完成 | 创作、结果页、测试 run、发布、正式 run、历史、重生成、存档联调 | 不跳过移动端;不只测 mock;不忽略负向验收 | 全链路可跑;前端 typecheck、后端相关 cargo 检查、编码检查通过 | +| `VN-13` 文档与交接 | Batch 4 | 实际工程落地结果 | PRD、技术方案、表目录、Hermes 决策 / 踩坑同步 | 不让旧 TXT 文档重新成为实现口径;不留下过期接口描述 | 新开发者可按最新文档维护;文档与代码一致 | 每个任务的交付回复必须包含: @@ -1143,20 +1153,20 @@ cd server-rs cargo check -p spacetime-client ``` - 阻塞关系: - - 1. 依赖 `VN-01` 的 Rust 契约。 - 2. 阻塞 `VN-05` 的真实数据库联调。 +阻塞关系: - VN-02 实施收口记录(2026-05-05): +1. 依赖 `VN-01` 的 Rust 契约。 +2. 阻塞 `VN-05` 的真实数据库联调。 - 1. 已新增 `server-rs/crates/spacetime-module/src/visual_novel.rs`,落地 `visual_novel_agent_session`、`visual_novel_agent_message`、`visual_novel_work_profile`、`visual_novel_runtime_run`、`visual_novel_runtime_history_entry`、`visual_novel_runtime_event` 六张表及对应 procedure。 - 2. 已同步 `server-rs/crates/spacetime-module/src/lib.rs`、`server-rs/crates/spacetime-module/src/migration.rs`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md` 和 Rust bindings。 - 3. 已新增 `server-rs/crates/spacetime-client/src/visual_novel.rs` typed facade,并从 `server-rs/crates/spacetime-client/src/lib.rs` 导出视觉小说 record / input 类型。 - 4. `visual_novel_runtime_event` 只作为 `public event` 审计事件表使用;`visual_novel_runtime_history_entry` 只保存继续体验与历史重生成所需 step 和快照哈希,二者均不是 replay 数据源。 - 5. 本阶段未新增 Axum 路由、LLM prompt、前端 UI、replay 表、replay 路由或私有 save 表;后续 VN-05 只应通过本阶段 facade 接入真实数据库。 - - ### VN-03:创作与运行 Prompt / LLM 工具 +VN-02 实施收口记录(2026-05-05): + +1. 已新增 `server-rs/crates/spacetime-module/src/visual_novel.rs`,落地 `visual_novel_agent_session`、`visual_novel_agent_message`、`visual_novel_work_profile`、`visual_novel_runtime_run`、`visual_novel_runtime_history_entry`、`visual_novel_runtime_event` 六张表及对应 procedure。 +2. 已同步 `server-rs/crates/spacetime-module/src/lib.rs`、`server-rs/crates/spacetime-module/src/migration.rs`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md` 和 Rust bindings。 +3. 已新增 `server-rs/crates/spacetime-client/src/visual_novel.rs` typed facade,并从 `server-rs/crates/spacetime-client/src/lib.rs` 导出视觉小说 record / input 类型。 +4. `visual_novel_runtime_event` 只作为 `public event` 审计事件表使用;`visual_novel_runtime_history_entry` 只保存继续体验与历史重生成所需 step 和快照哈希,二者均不是 replay 数据源。 +5. 本阶段未新增 Axum 路由、LLM prompt、前端 UI、replay 表、replay 路由或私有 save 表;后续 VN-05 只应通过本阶段 facade 接入真实数据库。 + +### VN-03:创作与运行 Prompt / LLM 工具 负责范围: @@ -1744,7 +1754,7 @@ VN-11 从 Batch 1 开始持续运行,最终阻塞发布。 ### 17.1 正向验收 -1. `visual-novel` 入口可见并可点击创建。 +1. `visual-novel` 入口当前可见但处于敬请期待禁用态;重新开放 `open=true` 后,再验收点击创建闭环。 2. 一句话创建能生成可编辑底稿。 3. 文档创建能读取平台文档资产并生成底稿。 4. 空白创建能进入结果页。 diff --git a/docs/technical/ADMIN_CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md b/docs/technical/ADMIN_CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md index 7f9d4a27..44929935 100644 --- a/docs/technical/ADMIN_CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md +++ b/docs/technical/ADMIN_CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md @@ -65,7 +65,7 @@ Admin Web -> spacetime-module creation_entry_type_config 表 ``` -`visible=false` 会让创作中心不展示对应入口;`open=false` 会让前端展示锁定态;api-server 的运行态熔断继续以 `visible && open` 判断路由是否可用。 +`visible=false` 会让创作中心不展示对应入口;`open=false` 会让前端展示锁定态,并让 api-server 熔断对应玩法创作 / 运行态 API。隐藏入口但仍保留既有作品号、广场详情或试玩链路时,应只关闭 `visible`,不要关闭 `open`。 ## 注意 diff --git a/docs/technical/CREATION_ENTRY_CONFIG_AND_RUNTIME_NOT_FOUND_RECOVERY_2026-05-10.md b/docs/technical/CREATION_ENTRY_CONFIG_AND_RUNTIME_NOT_FOUND_RECOVERY_2026-05-10.md index 4568eb50..6ab599ef 100644 --- a/docs/technical/CREATION_ENTRY_CONFIG_AND_RUNTIME_NOT_FOUND_RECOVERY_2026-05-10.md +++ b/docs/technical/CREATION_ENTRY_CONFIG_AND_RUNTIME_NOT_FOUND_RECOVERY_2026-05-10.md @@ -36,7 +36,7 @@ SpacetimeDB 新增两张表: 其中: - `visible=false`:前端隐藏入口。 -- `open=false`:前端展示为锁定/暂不可创建,api-server 也可据此熔断运行时入口。 +- `open=false`:前端展示为锁定/暂不可创建,api-server 据此熔断对应玩法 API;只隐藏创作页入口但保留既有作品链路时不要关闭 `open`。 - `sort_order`:数据库排序字段,前端只做可见/锁定分组派生。 ## API diff --git a/docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md b/docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md index 68e6c299..5900b0f3 100644 --- a/docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md +++ b/docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md @@ -13,6 +13,8 @@ - `https://developer.hyper3d.ai/api-specification/rodin-generation-gen2` - `https://developer.hyper3d.ai/api-specification/check-status` - `https://developer.hyper3d.ai/api-specification/download-results` +- `https://developer.hyper3d.ai/api-specification/check-status_reset_v` +- `https://developer.hyper3d.ai/api-specification/download-results_reset_v` 上游接口: @@ -24,6 +26,11 @@ POST https://api.hyper3d.com/api/v2/download Rodin Gen-2 提交接口必须使用 `multipart/form-data`。文本生成时提交 `prompt`;图片生成时提交一个或多个 `images` 文件,可选 `prompt` 作为辅助描述。两种模式均固定提交 `tier=Gen-2`。 +官方 `*_reset_v` 文档对状态和下载有两个关键约束: + +1. 生成接口返回的顶层 `uuid` 是后续下载接口的 `task_uuid`,不要使用 `jobs.uuids` 中的子任务 uuid 作为下载参数。 +2. 状态接口使用 `subscription_key` 查询,并返回 `jobs[]`;只有所有 job 的 `status` 都为 `Done` 才能进入下载,任一 job `Failed` 都应视为任务失败。 + ## 3. 环境变量 ```text @@ -111,7 +118,7 @@ RODIN_MODEL_REQUEST_TIMEOUT_MS } ``` -状态查询会把上游 `Waiting / Generating / Done / Failed` 归一化为 `waiting / generating / done / failed / unknown`。下载接口只返回上游 `list.name` 与 `list.url`,不在后端转存文件。 +状态查询会把上游 `Waiting / Generating / Done / Failed` 归一化为 `waiting / generating / done / failed / unknown`,整体状态必须以 `jobs[]` 聚合结果为准。下载接口只返回上游 `list.name` 与 `list.url`,不在 Hyper3D 代理路由中转存文件;具体玩法若需要持久化模型,应在玩法编排层等待 `Done` 后再下载并转存。 ## 7. 验收 diff --git a/docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md b/docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md index 1e894d92..4650cb3c 100644 --- a/docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md +++ b/docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md @@ -11,19 +11,22 @@ 入口仍复用 `Match3DAgentWorkspace` 表单。点击 `生成抓大鹅草稿` 后: 1. 创建 Match3D session。 -2. 进入 `match3d-generating` 生成过程页。 -3. 过程页复用拼图生成页的 `CustomWorldGenerationView` 结构。 -4. 生成成功后自动进入 `match3d-result`。 -5. 生成失败时停留在生成过程页,允许重新生成或返回创作中心。 +2. 后端先用当前题材和本地兜底元信息创建同一个 Match3D 草稿 profile,草稿 Tab 必须立即能看到这份存档。 +3. 进入 `match3d-generating` 生成过程页。 +4. 过程页复用拼图生成页的 `CustomWorldGenerationView` 结构。 +5. 生成成功后自动进入 `match3d-result`。 +6. 生成失败时停留在生成过程页,允许重新生成或返回创作中心;重新生成必须复用同一个 session / profile,并从缺失的素材阶段继续,不新建第二份草稿。 生成页步骤固定为: ```text -生成游戏名称 -> 生成物品名称 -> 生成素材图 -> 切割独立图片 -> 上传图片资产 -> 写入草稿页 +生成游戏名称 -> 生成物品名称 -> 生成素材图 -> 切割独立图片 -> 上传图片资产 -> 生成3D模型 -> 写入草稿页 ``` 生成页只展示题材和物品数量,不展示玩法规则说明。 +当前 `match3d-generating` 进度页不是后端 task 状态订阅页,而是一个覆盖 `match3d_compile_draft` 长 action 的本地时间进度页:前端每 500ms 以本地时间刷新阶段展示,真正的生成完成仍以 action 返回为准。为避免长 action 未返回时页面完全无感,生成页在 `match3d_compile_draft` 执行期间每 3 秒旁路读取一次 session 和 work detail,并用 profile 中已写回的 `generatedItemAssets` 更新 `生成3D模型` 的完成数量。Hyper3D 控制台中看到 3 个 Rodin 任务已经 `Done` 后,页面仍可能继续停留在 `生成3D模型`,此时通常表示后端还在等待下载列表、下载 GLB、转存 OSS 或写回 `generated_item_assets_json`;若 `generatedItemAssets` 已出现 `model_ready`,前端应逐步显示完成数量。排查时应看 api-server 日志中的 `抓大鹅 Rodin 状态轮询返回`、`抓大鹅 Rodin 下载列表轮询返回`、`抓大鹅 Rodin GLB 下载完成` 和 `抓大鹅 Rodin GLB 转存 OSS 完成`。 + ## 3. 后端编排边界 外部模型和 OSS 上传全部由 `api-server` 编排,不进入 SpacetimeDB reducer。SpacetimeDB 继续只负责 Match3D 会话、草稿和作品 profile 的确定性写入。 @@ -32,17 +35,19 @@ 1. 读取 session config。 2. 将本次 MVP 的 `clearCount` 固定为 `3`,并同步用于草稿编译。 -3. 基于入口页题材设定文本调用文本模型生成作品元信息。模型固定请求 `gpt-4o`,只返回 JSON,其中 `gameName` 为 4 到 12 个中文字符的游戏名称,`tags` 为 3 到 6 个中文短标签;`summary` 首版必须保持空字符串,结果页 `作品描述` 默认留给用户填写。 -4. 调用文本模型生成 `3` 个题材下的短物品名称。 -5. 调用项目当前图片链路 VectorEngine `gpt-image-2-all` 生成一张 `1:1` 素材图,提示词必须合入入口页选择的 `assetStylePrompt`。历史 `nanobanana2` 图片选项当前按项目统一决策回落到 VectorEngine,不重新接入 APIMart 图片网关。 -6. 将素材图按 `n*n` 网格切割成独立图片。当前 `3` 件物品使用 `2*2` 网格,取前 `3` 格。 -7. 将素材图和每张独立图片上传到 OSS,其中独立图片作为草稿页素材预览和后续 Rodin 图生模型参考图。 -8. 调用现有 SpacetimeDB compile procedure 写入草稿,并把本次生成的独立物品图片列表序列化写入 `match3d_work_profile.generated_item_assets_json`。这一步对标拼图的 `save_puzzle_generated_images`:生成资产不能只挂在本次 HTTP response 上,否则退出结果页后从草稿架读取 `getMatch3DWorkDetail` 会丢失素材列表。 -9. 在 HTTP 返回的 draft/profile DTO 中附带本次生成的素材资产预览信息,独立图片状态为 `image_ready`,模型字段保持为空;后续重进草稿页时从 work profile 的持久化 `generatedItemAssets` 恢复同一批素材。 +3. 先调用 SpacetimeDB compile procedure 写入草稿。首次执行使用新 `profileId`;重试时复用 session draft / work profile 中已有 `profileId`。这一步不能等待 LLM、图片、OSS 或 Rodin 成功后才执行。 +4. 基于入口页题材设定文本调用文本模型生成作品元信息。模型固定请求 `gpt-4o`,只返回 JSON,其中 `gameName` 为 4 到 12 个中文字符的游戏名称,`tags` 为 3 到 6 个中文短标签;`summary` 首版必须保持空字符串,结果页 `作品描述` 默认留给用户填写。文本模型不可用时保留第 3 步的本地兜底,不阻断草稿。 +5. 调用文本模型生成 `3` 个题材下的短物品名称。 +6. 调用项目当前图片链路 VectorEngine `gpt-image-2-all` 生成一张 `1:1` 素材图,提示词必须合入入口页选择的 `assetStylePrompt`。历史 `nanobanana2` 图片选项当前按项目统一决策回落到 VectorEngine,不重新接入 APIMart 图片网关。 +7. 将素材图按 `n*n` 网格切割成独立图片。当前 `3` 件物品使用 `2*2` 网格,取前 `3` 格。 +8. 将素材图和每张独立图片上传到 OSS,其中独立图片作为草稿页素材预览和 Rodin 图生模型参考图;每次获得可恢复的图片资产后,都要回写 `match3d_work_profile.generated_item_assets_json`。 +9. 使用每张独立图片作为参考图,并行调用 Hyper3D Rodin 图生模型;所有 3D 模型任务必须在同一阶段同时提交、同时轮询状态、同时下载并转存 OSS,禁止逐个物品串行等待模型完成。每个任务按官方 `check-status_reset_v` / `download-results_reset_v` 文档轮询状态和下载:状态查询使用 `subscription_key`,整体完成态以 `jobs[]` 聚合为准;下载查询使用生成响应顶层 `uuid` 作为 `task_uuid`,不能使用 `jobs.uuids` 子任务 uuid。只有 `jobs` 全部进入 `Done` 才能视为任务完成,任一 job `Failed` 则失败。完成后选择 `.glb` 下载文件,并把 GLB 转存到 OSS。Rodin 的 `subscriptionKey` 是上游 opaque token,不做 256 字符这类短文本长度限制。Rodin 任务状态进入完成态后,下载列表仍可能延迟发布;后端必须对下载列表继续轮询,并兼容 `url`、`downloadUrl`、`fileUrl`、`signedUrl` 等下载字段别名,只有预览图而没有模型文件时不能伪装成 GLB 成功。 +10. Rodin 每批完成后继续回写 `generated_item_assets_json`。成功素材状态为 `model_ready`;失败素材保留图片引用并记录 `error`,下次 `match3d_compile_draft` 只继续缺失模型的素材,不重复生成已完成的 GLB。 +11. 在 HTTP 返回的 draft/profile DTO 中附带本次生成的素材资产预览信息;后续重进草稿页时从 work profile 的持久化 `generatedItemAssets` 恢复同一批素材。 若文本模型不可用或返回无法解析,后端必须降级为 `{themeText}抓大鹅` 与本地标签兜底,不阻断素材生成;但描述仍保持空字符串。 -草稿生成阶段不调用 Hyper3D Rodin,不等待 `subscriptionKey`,也不下载模型文件;Rodin 生成只在结果页 `3D素材` Tab 由用户手动触发。手动生成得到的上游下载 URL 仍不得直接写入 Match3D profile,后续正式资产绑定以独立技术方案为准。 +草稿生成阶段会调用 Hyper3D Rodin 并等待 GLB 下载完成;前端 `match3d_compile_draft` action 请求超时必须覆盖该长耗时链路,当前 Match3D client 使用 20 分钟超时。Rodin 单模型状态轮询预算为 10 分钟,下载列表发布轮询预算为 5 分钟;GLB 下载和 OSS PutObject 各自设置 3 分钟 HTTP 超时,避免上游下载或转存连接长期悬挂。由于 3 个模型并行生成,总耗时按最慢模型计算,不能按模型数量线性叠加。结果页 `3D素材` Tab 直接加载已生成模型;用户点击 `重新生成` 时再复用 Rodin 安全代理,首版重新生成只更新当前页面内预览状态,后续正式资产绑定以独立技术方案为准。 ## 4. 图片提示词 @@ -83,16 +88,47 @@ generated-match3d-assets ```text generated-match3d-assets/{sessionId}/{profileId}/material-sheet/{taskId}/sheet.png -generated-match3d-assets/{sessionId}/{profileId}/items/{itemSlug}/image.png +generated-match3d-assets/{sessionId}/{profileId}/items/{itemSlug}/image/image.png +generated-match3d-assets/{sessionId}/{profileId}/items/{itemSlug}/model/{taskUuid}/model.glb ``` `itemSlug` 必须带 `itemId` 前缀,例如 `match3d-item-1-item`。中文物品名清洗后可能都退回 `item`,不能只用物品名做路径,否则多张切割图会写到同一个 object key,导致草稿页预览图全部一致。 -HTTP DTO 同时返回 `imageSrc`、`imageObjectKey`、空的 `modelSrc`、空的 `modelObjectKey` 和 `status = image_ready`。前端预览图片继续走 `ResolvedAssetImage` 换签;后续手动生成的模型文件也必须通过 `useResolvedAssetReadUrl` / `/api/assets/read-url` 换签后打开,不直接请求裸 `/generated-match3d-assets/...` 路径。 +HTTP DTO 同时返回 `imageSrc`、`imageObjectKey`、`modelSrc`、`modelObjectKey`、`modelFileName`、`taskUuid`、`subscriptionKey` 和 `status`。模型生成成功后 `status = model_ready`;若后续允许部分模型失败降级,失败素材必须带 `error`,且不能伪装成可预览模型。前端模型预览必须通过 `/api/assets/read-bytes` 读取私有 GLB 字节并转成 Blob URL 后交给 Three.js,不直接请求裸 `/generated-match3d-assets/...` 路径。 + +## 5.1 运行态模型消费 + +生成模型不仅用于结果页预览,也必须进入游戏运行态。运行态入口的传递链路为: + +```text +Match3DWorkProfile / PlatformMatch3DGalleryCard +-> Match3DRuntimeShell(generatedItemAssets) +-> Match3DPhysicsBoard / Match3DTrayPreviewBoard +``` + +`Match3DPhysicsBoard` 与 `Match3DTrayPreviewBoard` 按运行快照中的 `itemTypeId` 稳定排序后,把生成出的模型顺序映射到对应类型。当前 MVP 固定 `clearCount = 3`,因此 `match3d-type-01/02/03` 分别对应生成列表的第 `1/2/3` 个模型;后续恢复更多物品生成时,后端必须继续保证 `generatedItemAssets` 顺序与类型编号一致。 + +前端加载规则: + +1. 优先读取 `modelSrc`;为空时使用 `modelObjectKey`。 +2. 通过 `readAssetBytes` 调用 `/api/assets/read-bytes`,由同源后端读取 OSS 私有对象字节。 +3. 使用 Three.js `GLTFLoader.parseAsync` 解析 GLB 字节,并按物品类型缓存模板。 +4. 场内每个物品和备选栏预览都从模板 clone 独立对象,点击命中继续写入 `itemInstanceId`。 +5. 物理碰撞和边界仍沿用现有 `visualKey` 的程序化几何,生成 GLB 只替换视觉模型,不承接规则真相。 +6. 模型缺失、读取失败或 WebGL 回退时,继续使用默认积木素材,不能阻断开局、点击、入槽或结算;调试模式下需要输出加载失败的 `itemTypeId`、模型来源和错误信息,便于区分“资产没有传入”和“GLB 字节读取或解析失败”。 + +结果页点击 `试玩` 时,前端必须把当前结果页可见的 `generatedItemAssets` 带入运行态启动入参。`PUT /api/runtime/match3d/works/{profileId}` 若因为并发或旧快照返回了缺少素材的 profile,`Match3DResultView` 需要把当前 draft / profile 的素材重新合并到运行态 profile,并在启动试玩前调用生成素材保存接口把当前可见的 `generatedItemAssets` 写回作品 profile;不能只在内存里把素材补到 `onStartTestRun(profile)`。发布同理必须先落库当前素材,再调用 `publish_match3d_work`,否则公开推荐流和正式运行态只能读到旧 profile 快照,历史草稿尤其容易表现为结果页有 3D 模型、正式游戏仍是默认积木。若历史草稿同时存在旧 `draft.generatedItemAssets` 和较新的 `profile.generatedItemAssets`,同 `itemId` 下以 profile 中已有的 `modelSrc` / `modelObjectKey` 补齐 draft,不能让旧 draft 把模型状态覆盖回 `image_ready`。`PlatformEntryFlowShellImpl` 在渲染 `match3d-runtime` 时按 `run.profileId` 优先使用当前 `match3dProfile.generatedItemAssets`,只有 profileId 不匹配时才读取 `selectedPublicWorkDetail.generatedItemAssets`。推荐流内嵌正式运行态也必须走同一解析器;当推荐卡片摘要缺少素材时,启动前补读 `getMatch3DWorkDetail(profileId)`,把详情里的生成模型写入 `match3dProfile` 后再传给运行态。这样可以避免从公开详情页残留状态或推荐卡片旧摘要进入试玩 / 正式游戏时,把已生成草稿的 3D 模型覆盖成空列表。 ## 6. 自动保存与草稿恢复 -抓大鹅结果页的基础信息自动保存继续调用 `PUT /api/runtime/match3d/works/{profileId}` 更新名称、题材、描述、标签、封面、消除数和难度;该保存不得清空 `generated_item_assets_json`。SpacetimeDB `update_match3d_work` / `publish_match3d_work` 必须保留当前行的生成素材 JSON。 +点击 `生成抓大鹅草稿` 后,草稿存档创建与素材生成解耦: + +1. 首次 compile 必须先写 `match3d_work_profile` 草稿行,即使后续卡在文本模型、图片生成、OSS 上传、Rodin 生成或下载转存任意阶段。 +2. 失败态前端要重新读取 session / work detail,并刷新草稿作品架,保证用户离开生成页后仍能在草稿 Tab 找到这份作品。 +3. 重新生成时优先使用当前 session 的 `draft.profileId` 或 `publishedProfileId`,不得重新创建 session;后端读取同一 profile 的 `generated_item_assets_json` 后,只补齐缺失图片或缺失模型的阶段。 +4. 已有 `status = model_ready` 且带 `modelSrc` / `modelObjectKey` 的素材视为完成,不再重复调用 Rodin。 + +抓大鹅结果页的基础信息自动保存继续调用 `PUT /api/runtime/match3d/works/{profileId}` 更新名称、题材、描述、标签、封面、消除数和难度;该保存不得清空 `generated_item_assets_json`。结果页 `3D素材` Tab 手动点击 `重新生成` 并拿到 GLB 下载文件后,必须把当前素材草稿重新序列化成 `generatedItemAssets` 并写回作品 profile;否则页面内预览会显示新模型,但试玩、发布和重进草稿仍会读取旧的空模型快照。SpacetimeDB `update_match3d_work` / `publish_match3d_work` 必须保留当前行的生成素材 JSON。 草稿架重进路径为: @@ -100,7 +136,7 @@ HTTP DTO 同时返回 `imageSrc`、`imageObjectKey`、空的 `modelSrc`、空的 草稿 Tab -> getMatch3DWorkDetail(profileId) -> Match3DResultView(profile.generatedItemAssets) ``` -因此 `map_match3d_work_summary_response` / `map_match3d_work_profile_response` 需要从 work profile snapshot 反序列化 `generated_item_assets_json` 并输出 `generatedItemAssets`。前端 `Match3DResultView` 仍保持现有优先级:本次生成流程内有 `draft.generatedItemAssets` 时用 draft;从草稿架重进没有 draft 时,用 `profile.generatedItemAssets`;两者都没有才回退到默认 3D 素材占位。 +因此 `map_match3d_work_summary_response` / `map_match3d_work_profile_response` 需要从 work profile snapshot 反序列化 `generated_item_assets_json` 并输出 `generatedItemAssets`。前端 `Match3DResultView` 的读取顺序为:有 `draft.generatedItemAssets` 时先用 draft 保留本次生成顺序和图片;同 `itemId` 在 `profile.generatedItemAssets` 中已有模型字段时,用 profile 模型字段补齐 draft;从草稿架重进没有 draft 时,用 `profile.generatedItemAssets`;两者都没有才回退到默认 3D 素材占位。 结果页 `作品信息` Tab 字段命名对齐拼图草稿: @@ -111,7 +147,7 @@ HTTP DTO 同时返回 `imageSrc`、`imageObjectKey`、空的 `modelSrc`、空的 `3D素材` 详情页只保留: -1. 模型预览区:优先加载 `modelSrc` 对应 GLB,支持拖动旋转;没有模型时展示空预览。 +1. 模型预览区:优先加载 `modelSrc` 对应 GLB,缺失时加载 `modelObjectKey`,支持拖动旋转;没有模型时展示空预览。 2. 素材名称输入。 3. `重新生成` 按钮。 @@ -125,6 +161,8 @@ HTTP DTO 同时返回 `imageSrc`、`imageObjectKey`、空的 `modelSrc`、空的 npm run check:encoding npm run test -- src\services\miniGameDraftGenerationProgress.test.ts 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 npm run typecheck cargo test -p shared-contracts match3d --manifest-path server-rs\Cargo.toml cargo test -p spacetime-client match3d --manifest-path server-rs\Cargo.toml @@ -135,4 +173,4 @@ cargo check -p spacetime-client --manifest-path server-rs\Cargo.toml cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml ``` -真实草稿生成需要本地私密环境配置 `VECTOR_ENGINE_API_KEY` 和 OSS 访问变量;`HYPER3D_API_KEY` 只在结果页手动生成 3D 模型时需要。后端改动后使用 `npm run api-server` 启动,并检查 `/healthz`。 +真实草稿生成需要本地私密环境配置 `VECTOR_ENGINE_API_KEY`、`HYPER3D_API_KEY` 和 OSS 访问变量。后端改动后使用 `npm run api-server` 启动,并检查 `/healthz`。 diff --git a/docs/technical/MATCH3D_RODIN_ASSET_TAB_2026-05-10.md b/docs/technical/MATCH3D_RODIN_ASSET_TAB_2026-05-10.md index 810dec1c..f8639640 100644 --- a/docs/technical/MATCH3D_RODIN_ASSET_TAB_2026-05-10.md +++ b/docs/technical/MATCH3D_RODIN_ASSET_TAB_2026-05-10.md @@ -18,13 +18,13 @@ ## 3. Rodin 任务边界 -前端只维护当前页面内的临时重新生成任务状态: +前端只维护当前页面内的临时重新生成任务状态;草稿生成得到的正式模型资产从 `generatedItemAssets.modelSrc` 恢复: 1. 素材槽位名称。 2. 模型预览。草稿生成的 `/generated-match3d-assets/...` GLB 必须通过同源 `/api/assets/read-bytes` 由后端换签并读取字节,前端再转成 Blob URL 后交给 Three.js GLTFLoader,避免浏览器直接 `fetch` OSS 签名 URL 时被 CORS 拦截。 3. 图生模型参考图只作为重新生成的隐藏输入来源,不在详情页展示。上传图片在前端直接读成 Data URL;草稿生成的 `/generated-match3d-assets/...` 图片必须通过 `/api/assets/read-bytes` 转成 Data URL 后提交给 Hyper3D。 -4. Hyper3D `taskUuid` 与 `subscriptionKey`。 -5. 查询到的状态、进度与下载文件列表。 +4. Hyper3D `taskUuid` 与 `subscriptionKey` 仅用于重新生成过程,不在详情页展示。 +5. 查询到的状态、进度与下载文件列表仅作为内部状态,不在详情页展示。 正式资产链后续再接: diff --git a/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md b/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md index b5e0f275..c7c5439b 100644 --- a/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md +++ b/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md @@ -2,18 +2,19 @@ ## 背景 -创作中心的模板 Tab、平台创作类型弹层和旧“新建作品”卡片配置都依赖同一组玩法模板。此前入口开放状态、隐藏状态和中文文案集中写在 `src/components/platform-entry/platformEntryCreationTypes.ts` 与入口组件中,后续切换玩法开放节奏时容易出现多个入口不一致。 +创作中心的模板 Tab、平台创作类型弹层和旧“新建作品”卡片配置都依赖同一组玩法模板。此前入口开放状态、隐藏状态和中文文案集中写在前端配置与入口组件中,后续切换玩法开放节奏时容易出现多个入口不一致。当前入口配置事实源已经迁移到 SpacetimeDB,由 `api-server` 通过 `GET /api/creation-entry/config` 下发。 ## 落地规则 -1. 新建作品入口配置统一放在 `src/config/newWorkEntryConfig.ts`。 +1. 新建作品入口配置统一存放在 SpacetimeDB 的 `creation_entry_config` / `creation_entry_type_config` 表;默认种子位于 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`。 2. `visible` 控制玩法是否展示在创作 Tab 模板入口、新建作品入口和创作类型弹层中。 -3. `open` 控制玩法是否允许点击创建;`open: false` 时入口保持展示但禁用。 +3. `open` 控制玩法是否允许点击创建以及对应创作 / runtime API 路由是否放行;`open: false` 时入口保持展示但禁用,并由 `api-server` 熔断对应玩法 API。 4. `title`、`subtitle`、`badge` 控制玩法卡片文案。 5. `startCard` 控制旧创作中心顶部新建作品模块的标题、辅助文案和移动端角标文案;当前创作 Tab 首屏标题固定在 `PlatformEntryFlowShellImpl.tsx`,不再由 `startCard` 控制。 6. `typeModal` 控制平台创作类型弹层标题和描述。 7. 入口排序仍遵循“可创建玩法在前,未开放玩法在后”;同组内部沿用配置顺序。 8. `creative-agent` 可以继续保留运行链路,但默认 `visible: false`,不出现在创作 Tab 模板入口。 +9. 前端 `src/components/platform-entry/platformEntryCreationTypes.ts` 只做展示派生,不再承载默认入口配置。 ## 当前状态 @@ -23,17 +24,17 @@ | 大鱼吃小鱼 | 否 | 是 | 功能仍保留,不在新建作品入口展示 | | 拼图 | 是 | 是 | 创作 Tab 默认选中并内嵌展示拼图创作表单,提交后进入拼图草稿生成 | | 抓大鹅 | 是 | 是 | 点击后进入抓大鹅 Match3D Agent 共创工作台 | -| 方洞挑战 | 是 | 是 | 点击后进入方洞挑战 Agent 共创工作台,支持草稿、结果页、发布、试玩、作品架与广场 | +| 方洞挑战 | 否 | 是 | 创作页入口暂时完全隐藏,既有草稿、结果页、发布、试玩、作品架与广场链路保留 | | AIRP | 是 | 否 | 保留入口,显示敬请期待 | -| 视觉小说 | 是 | 是 | 点击后进入视觉小说创作工作台 | +| 视觉小说 | 是 | 否 | 保留入口,显示敬请期待,暂不允许创建视觉小说草稿 | | 智能创作 | 否 | 是 | 入口隐藏,既有 `creative-agent` 链路保留 | ## 验收 -1. 修改 `src/config/newWorkEntryConfig.ts` 后,创作 Tab 模板入口、创作中心顶部卡带和平台创作类型弹层应同步变化。 +1. 修改 SpacetimeDB 入口配置后,创作 Tab 模板入口、创作中心顶部卡带和平台创作类型弹层应同步变化。 2. 隐藏玩法不触发入口预加载,也不出现在新建作品入口中。 3. 未开放玩法点击态保持禁用,不应进入鉴权或创建会话链路。 4. 已开放玩法点击后必须进入对应创建链路;若用户未登录,先走登录保护。 5. 创作 Tab 首屏应显示“10分钟创作一个精品互动玩法”,并默认展示拼图创作表单。 6. 智能创作入口隐藏后,不应出现“Hi, 朋友”“问一问百梦”或“一句话生成闪应用”等旧首页入口。 -7. 方洞挑战作品发布后应生成 `SH-` 作品号,并能从作品架、广场详情和试玩 runtime 回到同一作品详情。 +7. 方洞挑战入口隐藏后,不应出现在创作 Tab 模板入口、创作中心顶部卡带、平台创作类型弹层和创作页作品架中;既有 `SH-` 作品号、广场详情和试玩 runtime 链路不因此删除。 diff --git a/docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md b/docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md new file mode 100644 index 00000000..42326afc --- /dev/null +++ b/docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md @@ -0,0 +1,89 @@ +# 拼图与抓大鹅结果页音乐 Tab 2026-05-11 + +## 1. 范围 + +本方案把 VectorEngine 音频生成能力从视觉小说结果页扩展到拼图与抓大鹅结果页: + +1. 拼图结果页新增 `音乐` Tab,支持通过 Suno 生成作品背景音乐。 +2. 抓大鹅结果页新增 `音乐` Tab,支持通过 Suno 生成作品背景音乐。 +3. 抓大鹅 `3D素材` Tab 支持为每个生成物体通过 Vidu 生成点击音效。 + +本轮不新增 SpacetimeDB 表,不修改表字段,不把供应商密钥下发到前端。 + +## 2. 通用音频接口 + +后端在既有视觉小说音频路由外新增通用创作音频路由: + +| 方法 | 路由 | 用途 | +| --- | --- | --- | +| `POST` | `/api/creation/audio/background-music` | 提交 Suno 背景音乐任务 | +| `POST` | `/api/creation/audio/background-music/{task_id}/asset` | 查询并转存 Suno 音频资产 | +| `POST` | `/api/creation/audio/sound-effect` | 提交 Vidu 音效任务 | +| `POST` | `/api/creation/audio/sound-effect/{task_id}/asset` | 查询并转存 Vidu 音效资产 | + +通用转存请求由前端传入 `entityKind`、`entityId`、`slot`、`assetKind`、`profileId`。后端仍负责: + +1. 校验 VectorEngine 与 OSS 环境变量。 +2. 轮询供应商任务结果。 +3. 下载音频字节。 +4. 写入 OSS 私有对象。 +5. 确认 `asset_object` 并绑定 `asset_entity_binding`。 + +视觉小说原路由保持兼容,内部继续复用同一套提交、轮询、转存逻辑。 + +## 3. 数据落点 + +### 3.1 拼图 + +拼图作品没有独立作品级 metadata 字段。背景音乐随 `levels_json` 保存到首个 `PuzzleDraftLevel.backgroundMusic`: + +```json +{ + "levelId": "puzzle-level-1", + "backgroundMusic": { + "taskId": "suno-task", + "provider": "vector-engine-suno", + "assetObjectId": "assetobj_1", + "assetKind": "puzzle_background_music", + "audioSrc": "/generated-puzzle-assets/..." + } +} +``` + +运行态后续可从当前关卡快照或作品详情读取该字段作为背景音乐源;若字段为空,继续使用现有程序化背景音乐兜底。 + +### 3.2 抓大鹅 + +抓大鹅作品级音频与物体点击音效复用 `generated_item_assets_json` 数组保存,不新增表字段: + +1. 作品背景音乐暂存到第一个 `Match3DGeneratedItemAsset.backgroundMusic`,表示当前 work profile 的作品级背景音乐。 +2. 单个物体点击音效保存到对应 `Match3DGeneratedItemAsset.clickSound`。 + +这是一个兼容性折中:当前 Match3D work profile 没有 work-level metadata 字段,而 `generated_item_assets_json` 已经随作品详情、草稿架、运行态入口稳定传递。后续若新增正式作品 metadata 表达,应迁移 `backgroundMusic` 到作品级字段。 + +## 4. 前端交互 + +结果页 UI 保持轻量: + +1. `音乐` Tab 只展示必要输入、生成按钮、状态与音频预览,不展示供应商规则说明。 +2. 生成完成后立即写回本地草稿状态,并触发既有保存链路或专用保存接口。 +3. 抓大鹅每个物体音效生成入口放在对应素材详情面板内,不在列表下方展开大段配置。 + +## 5. 验收 + +建议执行: + +```powershell +npm run check:encoding +npm run test -- src\components\puzzle-result\PuzzleResultView.test.tsx +npm run test -- src\components\match3d-result\Match3DResultView.test.tsx +npm run typecheck +cargo test -p shared-contracts creation_audio --manifest-path server-rs\Cargo.toml +cargo test -p shared-contracts puzzle --manifest-path server-rs\Cargo.toml +cargo test -p shared-contracts match3d --manifest-path server-rs\Cargo.toml +cargo test -p api-server vector_engine_audio_generation --manifest-path server-rs\Cargo.toml +cargo test -p api-server match3d --manifest-path server-rs\Cargo.toml +cargo check -p api-server --manifest-path server-rs\Cargo.toml +``` + +真实生成 smoke 需要本地私密环境配置 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 与 OSS 变量。后端改动后使用 `npm run api-server` 启动,并确认 `/healthz`。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 8bb751a1..0ccb2799 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -7,7 +7,7 @@ - [PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md](./PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md):记录直接访问公开作品详情深链时作品不存在或已下架的回首页修复,避免关闭提示后停在 `work-detail` 空状态白屏。 - [CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md](./CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md):冻结儿童动作识别互动玩法 Demo 固定热身关的开发落地规格,覆盖横屏展示、摄像头背景虚化、角色剪影、绿色圆环 2 秒保持、动作教学、当前会话内空间边界记录和后续关卡安全暂停规则。 - [RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md](./RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md):记录运行态输入设备抽象层,明确鼠标、触控、mocap 等设备统一归一为通用拖拽语义,玩法组件只负责解释目标和落点。 -- [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异。 +- [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异;同时冻结 `shared-contracts` 不得反向依赖 `platform-*`,避免 SpacetimeDB 模块发布时拉入 `wasm-bindgen`。 - [DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md](./DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md):记录本地完整 Rust 栈启动时 `api-server`、主站 Vite 和后台 Vite 端口占用的误判根因、脚本预检策略和 Windows 排障命令。 - [VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md](./VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md):记录 GPT-image-2 图片生成从 APIMart 迁移到 VectorEngine `gpt-image-2-all` 的接口、环境变量、尺寸映射、错误口径和验收命令。 - [SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md](./SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md):记录本地 `spacetime publish` 被 sccache wrapper 通信异常阻断时的根因、debug 构建参数口径和手动排障命令。 diff --git a/docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md b/docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md index e3e7042b..770963b2 100644 --- a/docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md +++ b/docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md @@ -16,7 +16,9 @@ 4. 成员 crate 只保留自身需要表达的差异,例如 `features`、`optional = true` 或 target-specific dependency。 5. 需要关闭 default features 的依赖,应优先在 workspace 根依赖中声明;成员 crate 不再重复覆盖同一项。 6. `module-assets` 这类有默认服务端 feature 的领域 crate,在 workspace 根内按 `default-features = false` 维护;需要服务端 OSS/HTTP 能力的 adapter crate 显式启用 `features = ["server-service"]`。 -7. 面向 SpacetimeDB WASM 的依赖链不得隐式启用原生 HTTP / OSS / Web 平台依赖;例如 `shared-contracts` 的 `assets` 模块通过 `oss-contracts` feature 暴露给 `api-server`,`spacetime-module` 路径只消费关闭默认 feature 后的纯 DTO 子集。 +7. `shared-contracts` 只能承载前后端公开 DTO 和轻量枚举,禁止直接依赖 `platform-*` 服务实现 crate;需要把平台实现响应转换为公开 DTO 时,转换函数放在 `api-server` 等 adapter 层。 +8. 面向 SpacetimeDB WASM 的依赖链不得隐式启用原生 HTTP / OSS / Web 平台依赖;例如 `shared-contracts` 的 `assets` 模块通过不依赖 `platform-oss` 的 `oss-contracts` feature 暴露给 `api-server`,`spacetime-module` 路径只消费关闭默认 feature 后的纯 DTO 子集。 +9. `spacetime-module` 的传递依赖不能包含 `reqwest`、`web-sys`、`js-sys`、`wasm-bindgen` 等 Web/HTTP 客户端链路;发布前可用 `cargo tree -i wasm-bindgen --manifest-path server-rs/Cargo.toml -p spacetime-module --target wasm32-unknown-unknown` 排查。 ## 3. 本次收敛范围 @@ -57,14 +59,33 @@ npm.cmd run check:encoding -- docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDA ## 6. SpacetimeDB WASM 依赖边界 +2026-05-11 本地重置 SpacetimeDB 并重新发布 `xushi-p4wfr` 时,`spacetime publish` 在 Rust 编译成功后报 `wasm-bindgen detected`。排查命令显示链路为: + +```text +spacetime-module -> module-runtime -> shared-contracts -> platform-oss -> reqwest -> wasm-bindgen +``` + +根因是 `shared-contracts` 为了复用 OSS 直传/读签名返回类型,直接依赖了 `platform-oss`。这违反 DDD 分层边界:契约 crate 不能依赖平台副作用实现,否则所有引用契约的纯领域和 SpacetimeDB 模块都会被迫拉入 HTTP client。 + `spacetime publish` 会构建 `spacetime-module` 的 `wasm32-unknown-unknown` 目标。这个目标不能包含 `wasm-bindgen`,也不应通过 DTO crate 间接拉入 `reqwest`、`web-sys` 或浏览器 WebAssembly 平台依赖。 +修正口径: + +1. `shared-contracts::assets` 定义独立的公开 DTO 和 `DirectUploadObjectAccess` 轻量枚举。 +2. `platform-oss` 保持 OSS 签名、读写请求和错误分类实现,不被契约层引用。 +3. `api-server::assets` 负责把 `platform_oss::OssPostObjectResponse` / `OssSignedGetObjectUrlResponse` 转成 `shared-contracts` DTO。 +4. 后续新增外部平台能力时,重复使用这个边界:平台 crate 不得被 `shared-contracts`、`module-*` 或 `spacetime-module` 反向依赖。 + 已验证的排查命令: ```powershell +cargo tree -i wasm-bindgen --manifest-path server-rs\Cargo.toml -p spacetime-module --target wasm32-unknown-unknown cargo tree -i wasm-bindgen --manifest-path server-rs\crates\spacetime-module\Cargo.toml --target wasm32-unknown-unknown cargo tree --manifest-path server-rs\crates\spacetime-module\Cargo.toml --target wasm32-unknown-unknown | Select-String -Pattern 'wasm-bindgen|platform-oss|reqwest' cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml --target wasm32-unknown-unknown +cargo check -p shared-contracts --manifest-path server-rs\Cargo.toml +cargo check -p api-server --manifest-path server-rs\Cargo.toml +spacetime publish xushi-p4wfr --server local --module-path server-rs\crates\spacetime-module --build-options="--debug" -c=on-conflict --yes ``` -若反向树显示 `reqwest -> platform-oss -> shared-contracts -> module-* -> spacetime-module`,优先检查新增的 `shared-contracts` 或领域 crate 依赖是否忘记关闭默认 feature。原生 `api-server` 需要资产上传契约时,应在自身 `Cargo.toml` 显式启用 `shared-contracts` 的 `oss-contracts` feature,而不是让 workspace 根依赖默认启用。 +若反向树显示 `reqwest -> platform-oss -> shared-contracts -> module-* -> spacetime-module`,优先检查新增的 `shared-contracts` 或领域 crate 依赖是否忘记关闭默认 feature,或 `shared-contracts` feature 是否错误依赖了平台实现 crate。原生 `api-server` 需要资产上传契约时,应在自身 `Cargo.toml` 显式启用 `shared-contracts` 的 `oss-contracts` feature,而不是让 workspace 根依赖默认启用。 diff --git a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md index 38065907..3b33ced9 100644 --- a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md +++ b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md @@ -72,8 +72,8 @@ SELECT * FROM auth_store_snapshot WHERE snapshot_id = 'default'; ### `user_account` - 作用:用户账号主表,保存用户名、公开百梦号、手机号掩码、登录方式、密码登录开关、token 版本和默认不前端展示的运营标签。 -- 结构:`user_id PK: String`, `public_user_code: String`, `username: String`, `display_name: String`, `avatar_url: Option`, `phone_number_masked: Option`, `phone_number_e164: Option`, `login_method: String`, `binding_status: String`, `wechat_bound: bool`, `password_hash: String`, `password_login_enabled: bool`, `token_version: u64`, `user_tags: Vec`。 -- 说明:`user_tags` 默认空数组,只允许后端白名单投影到特定业务接口;不得在登录态、个人资料等通用前端响应中直接暴露。 +- 结构:`user_id PK: String`, `public_user_code: String`, `username: String`, `display_name: String`, `avatar_url: Option`, `phone_number_masked: Option`, `phone_number_e164: Option`, `login_method: String`, `binding_status: String`, `wechat_bound: bool`, `password_hash: String`, `password_login_enabled: bool`, `token_version: u64`, `user_tags: Option>`。 +- 说明:`user_tags` 数据库默认 `None`,业务读取时按空数组归一化;只允许后端白名单投影到特定业务接口,不得在登录态、个人资料等通用前端响应中直接暴露。 - 索引:`username`, `public_user_code`。 ```sql @@ -256,11 +256,11 @@ SELECT * FROM profile_redeem_code_usage WHERE user_id = ''; ### `profile_invite_code` -- 作用:用户邀请中心的邀请码主表,也承载后台运营预置邀请码和使用后授予账号的运营标签。 -- 结构:`user_id PK: String`, `invite_code: String`, `metadata_json: String`, `created_at: Timestamp`, `updated_at: Timestamp`, `starts_at: Option`, `expires_at: Option`, `granted_user_tags: Vec`。 +- 作用:用户邀请中心的邀请码主表,也承载后台运营预置邀请码和使用后授予账号的运营标签配置。 +- 结构:`user_id PK: String`, `invite_code: String`, `metadata_json: String`, `created_at: Timestamp`, `updated_at: Timestamp`, `starts_at: Option`, `expires_at: Option`。 - 索引:主键 `user_id`,唯一索引 `invite_code`。 - 后台读取:`GET /admin/api/profile/invite-codes` 只返回 `user_id` 以 `admin:` 开头的后台预置码;普通用户自己的邀请码不得进入后台运营列表。 -- 说明:`granted_user_tags` 默认空数组;用户注册填写该邀请码后合并进 `user_account.user_tags`,不回改历史用户。 +- 说明:使用该邀请码后授予的标签存放在 `metadata_json.userTags`,服务端兼容读取 `metadata_json.user_tags`;用户注册填写该邀请码后合并进 `user_account.user_tags`,不回改历史用户。 ```sql SELECT * FROM profile_invite_code WHERE user_id = ''; @@ -665,7 +665,7 @@ SELECT * FROM match3d_agent_message WHERE session_id = '' ORDER BY c - 作用:抓大鹅 Match3D 作品主表,保存作品基础信息、配置、发布状态、游玩次数和草稿生成出的独立物品素材引用。 - 结构:`profile_id PK: String`, `owner_user_id: String`, `source_session_id: String`, `author_display_name: String`, `game_name: String`, `theme_text: String`, `summary_text: String`, `tags_json: String`, `cover_image_src: String`, `cover_asset_id: String`, `clear_count: u32`, `difficulty: u32`, `config_json: String`, `publication_status: String`, `play_count: u32`, `updated_at: Timestamp`, `published_at: Option`, `generated_item_assets_json: Option`。 -- 说明:`generated_item_assets_json` 保存 `Match3DGeneratedItemAsset` 数组 JSON,用于草稿页退出后从作品架重进时恢复 `3D素材` Tab 中的切割图片预览;基础信息自动保存和发布必须保留该字段。 +- 说明:`generated_item_assets_json` 保存 `Match3DGeneratedItemAsset` 数组 JSON,用于草稿页退出后从作品架重进时恢复 `3D素材` Tab 中的切割图片和 GLB 模型预览;运行态也通过该字段拿到 `modelSrc` / `modelObjectKey` 并优先渲染生成模型。基础信息自动保存和发布必须保留该字段。 - 索引:`owner_user_id`, `publication_status`。 ```sql diff --git a/docs/technical/USER_TAG_INVITE_AND_PUZZLE_LEADERBOARD_2026-05-10.md b/docs/technical/USER_TAG_INVITE_AND_PUZZLE_LEADERBOARD_2026-05-10.md index 6fe00dc3..3dc7d609 100644 --- a/docs/technical/USER_TAG_INVITE_AND_PUZZLE_LEADERBOARD_2026-05-10.md +++ b/docs/technical/USER_TAG_INVITE_AND_PUZZLE_LEADERBOARD_2026-05-10.md @@ -1,13 +1,13 @@ # 用户标签、邀请码授予与拼图榜单展示方案 -更新时间:`2026-05-10` +更新时间:`2026-05-11` ## 1. 目标 本次新增用户标签系统的最小闭环: -1. `user_account` 增加账号标签字段,默认空。 -2. 后台预置邀请码可配置授予标签。 +1. `user_account` 增加账号标签字段,数据库默认空,业务读取时按空数组处理。 +2. 后台预置邀请码可通过原有 `metadata` 字段配置授予标签。 3. 用户填写带标签的邀请码后,把标签合并到自己的账号。 4. 标签默认不在前端资料页、邀请中心或通用接口展示。 5. 拼图排行榜仅对白名单标签做展示,首版只展示 `北科`。 @@ -16,19 +16,21 @@ ### `user_account.user_tags` -- 类型:`Vec`。 -- 默认:空数组。 +- 类型:`Option>`。 +- 默认:`None`,业务层读取时统一按空数组处理。 - 语义:账号级运营标签,属于后台与服务端投影数据,不作为普通前端个人资料字段。 - 写入:首版只由邀请码兑换链路合并写入。 -- 迁移:旧迁移包和旧数据库按空数组兼容。 +- 迁移:旧迁移包和旧数据库按 `null` 兼容,再由业务层归一化为空数组。 -### `profile_invite_code.granted_user_tags` +### `profile_invite_code.metadata_json.userTags` -- 类型:`Vec`。 -- 默认:空数组。 +- 类型:`metadata_json` 对象里的 `userTags: string[]`。 +- 默认:字段缺失或空数组时不授予标签。 - 语义:使用该邀请码后授予被邀请账号的标签列表。 - 范围:后台运营预置码和普通用户个人邀请码都可存字段,但后台表单首版只允许管理员配置预置码。 -- 迁移:旧邀请码按空数组兼容。 +- 存储:不再新增或使用独立的邀请码标签列;后台保存时把用户标签写回 `metadata.userTags`。 +- 解析:服务端优先读取 `metadata_json.userTags`,并兼容解析 `metadata_json.user_tags`。 +- 迁移:旧邀请码缺少 `metadata_json` 时按 `{}` 兼容;旧迁移包里已废弃的独立字段会在导入时丢弃。 ## 3. 标签归一化 @@ -45,35 +47,38 @@ 1. 写入 `profile_referral_relation`。 2. 发放原有双方奖励。 -3. 读取 `profile_invite_code.granted_user_tags`。 +3. 从 `profile_invite_code.metadata_json` 解析 `userTags` / `user_tags`。 4. 将这些标签合并进 `user_account.user_tags`。 -管理员更新邀请码时,`grantedUserTags` 代表覆盖该邀请码之后授予的标签集合;空数组代表不授予标签。更新邀请码不会回溯修改已经使用过该邀请码的账号。 +管理员更新邀请码时,后台表单里的用户标签会覆盖写入 `metadata.userTags`;空数组代表不授予标签。更新邀请码不会回溯修改已经使用过该邀请码的账号。 ## 5. API 契约 -后台邀请码 upsert 请求增加: +后台邀请码 upsert 请求继续只提交 `metadata`,标签写在 `metadata.userTags` 中: ```json { "inviteCode": "BEIKE2026", - "grantedUserTags": ["北科"], - "metadata": {}, + "metadata": { + "userTags": ["北科"] + }, "startsAt": null, "expiresAt": null } ``` -后台邀请码列表和 upsert 返回增加同名字段: +后台邀请码列表和 upsert 返回继续回传 `metadata`: ```json { "inviteCode": "BEIKE2026", - "grantedUserTags": ["北科"] + "metadata": { + "userTags": ["北科"] + } } ``` -用户侧邀请中心、账号资料、登录返回和普通 profile 接口不返回 `userTags`。 +后台表单展示时从 `metadata.userTags` 回显用户标签;用户侧邀请中心、账号资料、登录返回和普通 profile 接口不返回 `userTags`。 ## 6. 拼图排行榜展示 @@ -94,9 +99,10 @@ ## 7. 验收 -1. 新账号 `user_account.user_tags` 默认为空。 -2. 后台创建邀请码时可填写 `北科`,列表和结果面板可回显。 +1. 新账号 `user_account.user_tags` 数据库默认为 `None`,业务读取为空数组。 +2. 后台创建邀请码时可填写 `北科`,请求和返回的 `metadata.userTags` 可回显。 3. 用户填写该邀请码后,账号表 `user_tags` 包含 `北科`。 4. 不带标签的邀请码不改变账号标签。 5. 拼图排行榜中带 `北科` 的用户昵称下方显示 `北科`,其它标签不显示。 -6. 执行 `npm run check:encoding`、`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`,并按影响范围执行后台 typecheck / 拼图前端测试。 +6. 生成 SpacetimeDB bindings 后,不再出现独立的邀请码标签字段。 +7. 执行 `npm run check:encoding`、`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`,并按影响范围执行后台 typecheck / 拼图前端测试。 diff --git a/package.json b/package.json index 14208738..71d5ba5a 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,8 @@ "format:check": "prettier --check .", "test": "vitest run", "test:watch": "vitest", + "loadtest:extract-works": "node scripts/loadtest/extract-works-list-data.mjs", + "loadtest:k6:works": "k6 run scripts/loadtest/k6-works-list.js", "check": "npm run lint && npm run test && npm run build && npm run check:content", "check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts", "check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts", diff --git a/packages/shared/src/contracts/creationAudio.ts b/packages/shared/src/contracts/creationAudio.ts new file mode 100644 index 00000000..e1ad6cc3 --- /dev/null +++ b/packages/shared/src/contracts/creationAudio.ts @@ -0,0 +1,53 @@ +export type CreationAudioGenerationKind = + | 'background_music' + | 'sound_effect'; + +export interface CreationAudioAsset { + taskId: string; + provider: string; + assetObjectId?: string | null; + assetKind?: string | null; + audioSrc: string; + prompt?: string | null; + title?: string | null; + updatedAt?: string | null; +} + +export interface CreateBackgroundMusicRequest { + prompt: string; + title: string; + tags?: string | null; + model?: string | null; +} + +export interface CreateSoundEffectRequest { + prompt: string; + duration?: number | null; + seed?: number | null; +} + +export interface AudioGenerationTaskResponse { + kind: CreationAudioGenerationKind; + taskId: string; + provider: string; + status: string; +} + +export interface PublishGeneratedAudioAssetRequest { + entityKind: string; + entityId: string; + slot: string; + assetKind: string; + profileId?: string | null; + storagePrefix?: 'puzzle_assets' | 'match3d_assets' | 'custom_world_scenes' | null; +} + +export interface GeneratedAudioAssetResponse { + kind: CreationAudioGenerationKind; + taskId: string; + provider: string; + status: string; + assetObjectId?: string | null; + assetKind?: string | null; + audioSrc?: string | null; +} diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts index 9ba7bd91..fade9a2a 100644 --- a/packages/shared/src/contracts/index.ts +++ b/packages/shared/src/contracts/index.ts @@ -1,4 +1,5 @@ export type * from './creativeAgent'; +export type * from './creationAudio'; export type * from './hyper3d'; export type * from './puzzleCreativeTemplate'; export type * from './visualNovel'; diff --git a/packages/shared/src/contracts/match3dWorks.ts b/packages/shared/src/contracts/match3dWorks.ts index 358caeb2..b6505c1b 100644 --- a/packages/shared/src/contracts/match3dWorks.ts +++ b/packages/shared/src/contracts/match3dWorks.ts @@ -2,6 +2,8 @@ * 抓大鹅 Match3D 作品读写共享契约。 * 首版作品发布必须补齐游戏名称、标签、封面、题材、消除次数和难度。 */ +import type { CreationAudioAsset } from './creationAudio'; + export type Match3DWorkPublicationStatus = 'draft' | 'published' | string; export type Match3DGeneratedItemAssetStatus = @@ -22,10 +24,16 @@ export interface Match3DGeneratedItemAsset { modelFileName?: string | null; taskUuid?: string | null; subscriptionKey?: string | null; + backgroundMusic?: CreationAudioAsset | null; + clickSound?: CreationAudioAsset | null; status: Match3DGeneratedItemAssetStatus; error?: string | null; } +export interface PutMatch3DAudioAssetsRequest { + generatedItemAssets: Match3DGeneratedItemAsset[]; +} + export interface PutMatch3DWorkRequest { gameName: string; themeText?: string; @@ -37,6 +45,15 @@ export interface PutMatch3DWorkRequest { difficulty: number; } +export interface GenerateMatch3DWorkTagsRequest { + gameName: string; + themeText: string; +} + +export interface GenerateMatch3DWorkTagsResponse { + tags: string[]; +} + export interface Match3DWorkSummary { workId: string; profileId: string; diff --git a/packages/shared/src/contracts/puzzleAgentDraft.ts b/packages/shared/src/contracts/puzzleAgentDraft.ts index 203ace00..91d6f5f8 100644 --- a/packages/shared/src/contracts/puzzleAgentDraft.ts +++ b/packages/shared/src/contracts/puzzleAgentDraft.ts @@ -1,4 +1,5 @@ import type { JsonObject } from './common'; +import type { CreationAudioAsset } from './creationAudio'; export type PuzzleAnchorStatus = | 'missing' @@ -47,6 +48,7 @@ export interface PuzzleDraftLevel { levelName: string; pictureDescription: string; pictureReference?: string | null; + backgroundMusic?: CreationAudioAsset | null; candidates: PuzzleGeneratedImageCandidate[]; selectedCandidateId: string | null; coverImageSrc: string | null; diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index db1e55f3..18bc892c 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -352,7 +352,6 @@ export type AdminDisableProfileRedeemCodeRequest = { export type AdminUpsertProfileInviteCodeRequest = { inviteCode: string; metadata?: Record | null; - grantedUserTags?: string[]; startsAt?: string | null; expiresAt?: string | null; }; @@ -361,7 +360,6 @@ export type ProfileInviteCodeAdminResponse = { userId: string; inviteCode: string; metadata: Record; - grantedUserTags: string[]; startsAt?: string | null; expiresAt?: string | null; status: 'pending' | 'active' | 'expired'; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5012b0bd..523ae03d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -3,6 +3,7 @@ export * from './contracts/auth'; export type * from './contracts/bigFish'; export * from './contracts/common'; export type * from './contracts/creationAgentDocumentInput'; +export type * from './contracts/creationAudio'; export type * from './contracts/creativeAgent'; export type * from './contracts/customWorldAgent'; export type * from './contracts/hyper3d'; diff --git a/public/visual-novel-style-references/cinematic-anime.png b/public/visual-novel-style-references/cinematic-anime.png new file mode 100644 index 00000000..630763de Binary files /dev/null and b/public/visual-novel-style-references/cinematic-anime.png differ diff --git a/public/visual-novel-style-references/dark-gothic.png b/public/visual-novel-style-references/dark-gothic.png new file mode 100644 index 00000000..74640d90 Binary files /dev/null and b/public/visual-novel-style-references/dark-gothic.png differ diff --git a/public/visual-novel-style-references/ink-fantasy.png b/public/visual-novel-style-references/ink-fantasy.png new file mode 100644 index 00000000..98736cb0 Binary files /dev/null and b/public/visual-novel-style-references/ink-fantasy.png differ diff --git a/public/visual-novel-style-references/pixel-noir.png b/public/visual-novel-style-references/pixel-noir.png new file mode 100644 index 00000000..9461c9fa Binary files /dev/null and b/public/visual-novel-style-references/pixel-noir.png differ diff --git a/public/visual-novel-style-references/soft-pastel.png b/public/visual-novel-style-references/soft-pastel.png new file mode 100644 index 00000000..c9a50572 Binary files /dev/null and b/public/visual-novel-style-references/soft-pastel.png differ diff --git a/public/visual-novel-style-references/watercolor.png b/public/visual-novel-style-references/watercolor.png new file mode 100644 index 00000000..ba54f420 Binary files /dev/null and b/public/visual-novel-style-references/watercolor.png differ diff --git a/scripts/loadtest/README.md b/scripts/loadtest/README.md new file mode 100644 index 00000000..0b406675 --- /dev/null +++ b/scripts/loadtest/README.md @@ -0,0 +1,205 @@ +# Genarrative 作品列表 K6 压测 + +本目录用于对“作品列表/公开广场”读接口做本地压测。数据源来自私有 SpacetimeDB migration,但提取脚本只输出作品 profile 白名单表,并对用户、作者、作品号、asset id 等标识做稳定映射。 + +## 文件 + +- `extract-works-list-data.mjs`:从 migration JSON 提取作品列表压测数据;本地输出也会脱敏路由 ID,因此默认用于列表接口压测,详情接口需先把同一份脱敏数据导入目标环境。 +- `k6-works-list.js`:K6 压测脚本。 +- `data/spacetime-migration-7.local.json`:本地私有原始数据副本,已被 `.gitignore` 忽略,不要提交。 +- `data/works-list.local.json`:本地脱敏压测数据,已被 `.gitignore` 忽略,不要提交。 +- `data/works-list.sample.json`:可提交的少量脱敏样例。 + +## 数据边界 + +允许导入的表: + +- `puzzle_work_profile` +- `custom_world_profile` +- `match3d_work_profile` +- `square_hole_work_profile` +- `big_fish_work_profile` +- `visual_novel_work_profile` + +明确不导入: + +- 账号/认证:`user_account`、`auth_identity`、`refresh_session`、`auth_store_snapshot` +- 钱包/邀请:`profile_wallet_ledger`、`profile_redeem_*`、`profile_invite_*` +- 游玩历史/埋点/存档:`public_work_play_daily_stat`、`profile_played_world`、`puzzle_runtime_run`、`profile_save_archive`、`runtime_snapshot` +- AI 任务过程:`ai_task`、`ai_task_stage`、`ai_text_chunk` +- asset 二进制:`asset_object`、`asset_entity_binding` + +提取脚本会移除 `source_session_id` / `source_agent_session_id` 等会话派生字段;这些字段不属于作品列表卡片压测必要字段。 + +## 重新提取数据 + +从仓库根目录执行: + +```bash +npm run loadtest:extract-works -- \ + --input scripts/loadtest/data/spacetime-migration-7.local.json \ + --output scripts/loadtest/data/works-list.local.json \ + --sample-output scripts/loadtest/data/works-list.sample.json +``` + +也可以直接执行: + +```bash +node scripts/loadtest/extract-works-list-data.mjs \ + --input scripts/loadtest/data/spacetime-migration-7.local.json \ + --output scripts/loadtest/data/works-list.local.json \ + --sample-output scripts/loadtest/data/works-list.sample.json +``` + +当前 local 全量提取结果: + +- `puzzle_work_profile`: 80 +- `custom_world_profile`: 1 +- `match3d_work_profile`: 0 +- `normalizedWorks`: 81 + +当前可提交 sample 结果: + +- `puzzle_work_profile`: 3 +- `custom_world_profile`: 1 +- `match3d_work_profile`: 0 +- `normalizedWorks`: 4 + +## 真实接口 + +已从 `server-rs/crates/api-server/src/app.rs` 确认的读接口: + +公开接口,无需 Bearer token: + +- `GET /api/runtime/puzzle/gallery` +- `GET /api/runtime/puzzle/gallery/{profile_id}` +- `GET /api/runtime/custom-world-gallery` +- `GET /api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}` +- `GET /api/runtime/custom-world-gallery/by-code/{code}` + +需要 Bearer token 的个人作品列表接口: + +- `GET /api/runtime/puzzle/works` +- `GET /api/runtime/puzzle/works/{profile_id}` +- `GET /api/runtime/custom-world/works` + +K6 脚本默认只跑公开列表接口;传入 `AUTH_TOKEN` 后会额外跑需要登录态的个人作品列表接口。当前真实列表 handler 未暴露分页/排序 query 参数,因此脚本不追加 `limit/offset`;若后续接口增加分页参数,再在 K6 中补随机分页。 + +详情接口默认不压测,因为本地数据中的 `profile_id` / `owner_user_id` 已脱敏,直接请求未导入脱敏数据的目标服务会 404。只有在目标环境已导入同一份脱敏数据,或改用真实 ID 本地文件时,才设置 `DETAIL_RATIO` 大于 0;详情请求不把 404 视为成功。 + +## 启动服务 + +按项目约定启动本地 dev 栈: + +```bash +npm run dev +``` + +注意端口可能漂移。以启动日志中的实际 api-server 端口为准,然后传给 K6。 + +注意:K6 的 `open()` 会按 `k6-works-list.js` 所在目录解析相对路径,因此 `WORKS_DATA` 应写成 `data/works-list.local.json`,不要写成 `scripts/loadtest/data/works-list.local.json`。 + +Bash / Git Bash: + +```bash +BASE_URL=http://127.0.0.1: WORKS_DATA=data/works-list.local.json npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max" +``` + +PowerShell: + +```powershell +$env:BASE_URL="http://127.0.0.1:" +$env:WORKS_DATA="data/works-list.local.json" +npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max" +``` + +## Smoke + +```bash +BASE_URL=http://127.0.0.1:8787 \ +WORKS_DATA=data/works-list.local.json \ +SCENARIO=smoke \ +DETAIL_RATIO=0 \ +npm run loadtest:k6:works +``` + +默认:1 VU / 30s。 + +## Baseline + +```bash +BASE_URL=http://127.0.0.1:8787 \ +WORKS_DATA=data/works-list.local.json \ +SCENARIO=baseline \ +VUS=10 \ +DURATION=3m \ +DETAIL_RATIO=0 \ +npm run loadtest:k6:works +``` + +默认阈值: + +- `http_req_failed < 1%` +- `http_req_duration p95 < 800ms` +- `http_req_duration p99 < 1500ms` +- `works_list_shape_error_rate < 1%` + +## Spike + +```bash +BASE_URL=http://127.0.0.1:8787 \ +WORKS_DATA=data/works-list.local.json \ +SCENARIO=spike \ +START_RPS=5 \ +PEAK_RPS=100 \ +HOLD=2m \ +DETAIL_RATIO=0 \ +npm run loadtest:k6:works +``` + +默认阈值: + +- `http_req_failed < 5%` +- `http_req_duration p95 < 2000ms` +- `works_list_shape_error_rate < 5%` + +## 带登录态压测个人作品列表 + +先通过本地登录或接口获取 access token,然后传入: + +```bash +BASE_URL=http://127.0.0.1:8787 \ +AUTH_TOKEN='' \ +SCENARIO=smoke \ +DETAIL_RATIO=0 \ +npm run loadtest:k6:works +``` + +不要把 token 写入仓库文件、README 或 shell history 中可共享的位置。 + +## 详情接口压测 + +仅当目标环境存在 `WORKS_DATA` 中的同一批 `profileId/ownerUserId` 时启用: + +```bash +BASE_URL=http://127.0.0.1:8787 \ +WORKS_DATA=data/works-list.local.json \ +SCENARIO=smoke \ +DETAIL_RATIO=0.35 \ +npm run loadtest:k6:works +``` + +如果详情请求返回 404,说明压测数据 ID 未导入目标环境或目标服务数据不一致,应先修正数据源,不要把 404 当成功。 + +## 排障 + +- 如果公开 gallery 返回 `creation_entry_disabled` 或 503,检查本地 creation entry 配置是否禁用了对应入口。 +- 如果个人作品列表返回 401,确认 `AUTH_TOKEN` 是当前 api-server 可识别的 access token。 +- 如果详情全部 404,确认是否已向目标环境导入与 `WORKS_DATA` 一致的数据。 + +## 验证命令 + +```bash +npx vitest run scripts/loadtest/extract-works-list-data.test.ts +npx eslint scripts/loadtest/extract-works-list-data.mjs scripts/loadtest/extract-works-list-data.test.ts scripts/loadtest/k6-works-list.js +``` diff --git a/scripts/loadtest/data/works-list.sample.json b/scripts/loadtest/data/works-list.sample.json new file mode 100644 index 00000000..250157bd --- /dev/null +++ b/scripts/loadtest/data/works-list.sample.json @@ -0,0 +1,214 @@ +{ + "source": "spacetime-migration-7.local.json", + "generatedAt": "2026-05-11T13:09:51.569Z", + "counts": { + "puzzle_work_profile": 3, + "custom_world_profile": 1, + "match3d_work_profile": 0 + }, + "tables": { + "puzzle_work_profile": [ + { + "profile_id": "profile-001", + "work_id": "work-001", + "owner_user_id": "user-001", + "author_display_name": "author-001", + "cover_asset_id": "asset-001", + "cover_image_src": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png", + "work_title": "化学家", + "level_name": "文学家", + "summary": "几个文学家正站在山上面对着瀑布侃侃而谈", + "work_description": "一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室", + "levels_json": "[{\"level_id\":\"puzzle-level-1777649242577-7\",\"level_name\":\"文学家\",\"picture_description\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"candidates\":[{\"candidate_id\":\"puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2\",\"image_src\":\"/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png\",\"asset_id\":\"asset-1777649330373133\",\"prompt\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"actual_prompt\":\"请生成一张高清插画。画面主体:几个文学家正站在山上面对着瀑布侃侃而谈。画面…", + "anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"化学家\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"化学家、拼图、插画;禁止标题字\",\"status\":\"I…", + "theme_tags_json": "[\"化学家\",\"拼图\",\"插画\",\"禁止标题字\"]", + "publication_status": { + "Published": [] + }, + "play_count": 1, + "like_count": 0, + "remix_count": 1, + "updated_at": { + "__timestamp_micros_since_unix_epoch__": 1777703338322544 + }, + "created_at": { + "__timestamp_micros_since_unix_epoch__": 1777648804043558 + }, + "published_at": { + "__timestamp_micros_since_unix_epoch__": 1777649364112270 + } + }, + { + "profile_id": "profile-002", + "work_id": "work-002", + "owner_user_id": "user-002", + "author_display_name": "author-002", + "work_title": "我不知道", + "level_name": "", + "summary": "你猜我是谁", + "work_description": "你猜我是谁", + "levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"真不知道\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]", + "anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"我不知道\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"真不知道\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"我不知道、拼图、插画;禁止标题字\",\"status\":\"Inferred\"}}", + "theme_tags_json": "[\"我不知道\"]", + "publication_status": { + "Draft": [] + }, + "play_count": 0, + "like_count": 0, + "remix_count": 0, + "updated_at": { + "__timestamp_micros_since_unix_epoch__": 1777619351714201 + }, + "created_at": { + "__timestamp_micros_since_unix_epoch__": 1777619336673245 + } + }, + { + "profile_id": "profile-003", + "work_id": "work-003", + "owner_user_id": "user-003", + "author_display_name": "author-002", + "work_title": "", + "level_name": "", + "summary": "", + "work_description": "", + "levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]", + "anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"\",\"status\":\"Missing\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"\",\"status\":\"Missing\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"\",\"status\":\"Missing\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"\",\"status\":\"Missing\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"\",\"status\":\"Missing\"}}", + "theme_tags_json": "[\"拼图\",\"插画\",\"清晰构图\"]", + "publication_status": { + "Draft": [] + }, + "play_count": 0, + "like_count": 0, + "remix_count": 0, + "updated_at": { + "__timestamp_micros_since_unix_epoch__": 1777622285252380 + }, + "created_at": { + "__timestamp_micros_since_unix_epoch__": 1777622285252380 + } + } + ], + "custom_world_profile": [ + { + "profile_id": "profile-081", + "owner_user_id": "user-002", + "author_display_name": "author-012", + "author_public_user_code": "author-code-001", + "world_name": "青春飞扬校园", + "summary_text": "在现代校园中,玩家摆脱内卷,追求真实成长", + "subtitle": "反内卷的自由学习之旅", + "profile_payload_json": "{\"anchorContent\":null,\"anchorPack\":null,\"attributeSchema\":{\"generatedFrom\":{\"conflictCore\":\"与传统教育模式的冲突\",\"settingSummary\":\"在现代校园中,玩家摆脱内卷,追求真实成长\",\"tone\":\"积极向上,充满活力与创新\",\"worldName\":\"青春飞扬校园\",\"worldType\":\"CUSTOM\"},\"id\":\"schema:rpg-agent:1e15b44d:v1\",\"schemaVersion\":1,\"slots\":[{\"name\":\"知识储备\",\"slotId\":\"axis_a\"},{\"name\":\"创新思维\",\"slotId\":\"axis_b\"},{\"name\":\"社交能力\",\"slotId\":\"axis_c\"},{\"name\":\"抗压能力\",\"slotId\":\"axis_d\"},{\"name\":\"自我认知\",\"slotId\":\"axis_e\"},{\"name\":\"团队协作\",\"slotId\":\"axis_f\"}],\"worldId\":\"custom:青春飞扬校…", + "publication_status": { + "Draft": [] + }, + "play_count": 0, + "like_count": 0, + "remix_count": 0, + "updated_at": { + "__timestamp_micros_since_unix_epoch__": 1777532006629209 + }, + "created_at": { + "__timestamp_micros_since_unix_epoch__": 1777531745887256 + } + } + ], + "match3d_work_profile": [] + }, + "profileIds": { + "puzzle": [ + "profile-001", + "profile-002", + "profile-003" + ], + "customWorld": [ + "profile-081" + ], + "match3d": [], + "squareHole": [], + "bigFish": [], + "visualNovel": [] + }, + "workIds": { + "puzzle": [ + "work-001", + "work-002", + "work-003" + ], + "customWorld": [], + "match3d": [], + "squareHole": [], + "bigFish": [], + "visualNovel": [] + }, + "normalizedWorks": [ + { + "type": "puzzle", + "workId": "work-001", + "profileId": "profile-001", + "ownerUserId": "user-001", + "title": "化学家", + "subtitle": "几个文学家正站在山上面对着瀑布侃侃而谈", + "publicationStatus": { + "Published": [] + }, + "playCount": 1, + "likeCount": 0, + "remixCount": 1, + "coverImageSrc": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png", + "updatedAt": { + "__timestamp_micros_since_unix_epoch__": 1777703338322544 + } + }, + { + "type": "puzzle", + "workId": "work-002", + "profileId": "profile-002", + "ownerUserId": "user-002", + "title": "我不知道", + "subtitle": "你猜我是谁", + "publicationStatus": { + "Draft": [] + }, + "playCount": 0, + "likeCount": 0, + "remixCount": 0, + "updatedAt": { + "__timestamp_micros_since_unix_epoch__": 1777619351714201 + } + }, + { + "type": "puzzle", + "workId": "work-003", + "profileId": "profile-003", + "ownerUserId": "user-003", + "title": "", + "subtitle": "", + "publicationStatus": { + "Draft": [] + }, + "playCount": 0, + "likeCount": 0, + "remixCount": 0, + "updatedAt": { + "__timestamp_micros_since_unix_epoch__": 1777622285252380 + } + }, + { + "type": "customWorld", + "profileId": "profile-081", + "ownerUserId": "user-002", + "title": "青春飞扬校园", + "subtitle": "反内卷的自由学习之旅", + "publicationStatus": { + "Draft": [] + }, + "playCount": 0, + "likeCount": 0, + "remixCount": 0, + "updatedAt": { + "__timestamp_micros_since_unix_epoch__": 1777532006629209 + } + } + ] +} diff --git a/scripts/loadtest/extract-works-list-data.mjs b/scripts/loadtest/extract-works-list-data.mjs new file mode 100644 index 00000000..b7738e07 --- /dev/null +++ b/scripts/loadtest/extract-works-list-data.mjs @@ -0,0 +1,370 @@ +#!/usr/bin/env node +import { readFile, writeFile } from 'node:fs/promises'; +import { basename } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ALLOWED_TABLES = new Set([ + 'puzzle_work_profile', + 'custom_world_profile', + 'match3d_work_profile', + 'square_hole_work_profile', + 'big_fish_work_profile', + 'visual_novel_work_profile', +]); + +const WORK_TABLE_TYPES = { + puzzle_work_profile: 'puzzle', + custom_world_profile: 'customWorld', + match3d_work_profile: 'match3d', + square_hole_work_profile: 'squareHole', + big_fish_work_profile: 'bigFish', + visual_novel_work_profile: 'visualNovel', +}; + +const TABLE_OUTPUT_ORDER = [ + 'puzzle_work_profile', + 'custom_world_profile', + 'match3d_work_profile', + 'square_hole_work_profile', + 'big_fish_work_profile', + 'visual_novel_work_profile', +]; + +const WORK_TYPES = ['puzzle', 'customWorld', 'match3d', 'squareHole', 'bigFish', 'visualNovel']; +const SHORT_TEXT_LIMIT = 120; +const LONG_TEXT_LIMIT = 500; +const SENSITIVE_PATTERN = /(token|secret|password|passwd|phone|wallet|credential|authorization|auth[_-]?key|api[_-]?key)/giu; + +class StableMapper { + constructor(prefix) { + this.prefix = prefix; + this.values = new Map(); + } + + map(value) { + if (value === undefined || value === null || value === '') return value; + const key = String(value); + if (!this.values.has(key)) { + this.values.set( + key, + `${this.prefix}-${String(this.values.size + 1).padStart(3, '0')}`, + ); + } + return this.values.get(key); + } +} + +function createContext() { + return { + user: new StableMapper('user'), + session: new StableMapper('session'), + author: new StableMapper('author'), + authorCode: new StableMapper('author-code'), + publicWorkCode: new StableMapper('public-work-code'), + coverAsset: new StableMapper('asset'), + work: new StableMapper('work'), + profile: new StableMapper('profile'), + }; +} + +function createWorkTypeBuckets() { + return Object.fromEntries(WORK_TYPES.map((type) => [type, []])); +} + +function unwrapSpacetimeOption(value) { + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).length === 1 + ) { + if (Object.prototype.hasOwnProperty.call(value, 'some')) return value.some; + if (Object.prototype.hasOwnProperty.call(value, 'none')) return undefined; + } + return value; +} + +function truncateText(value, limit) { + if (value === undefined || value === null) return value; + const text = String(value).replace(/\s+/g, ' ').trim(); + if (text.length <= limit) return text; + return `${text.slice(0, limit)}…`; +} + +function redactSensitiveText(value) { + if (value === undefined || value === null) return value; + return String(value).replace(SENSITIVE_PATTERN, '[redacted]'); +} + +function sanitizeCoverImageSrc(value) { + const unwrapped = unwrapSpacetimeOption(value); + if (unwrapped === undefined || unwrapped === null || unwrapped === '') return unwrapped; + const text = String(unwrapped); + if (text.startsWith('data:image/')) return '[redacted-data-image]'; + let withoutQuery = text.split('?')[0].split('#')[0]; + if (withoutQuery.length > 180) withoutQuery = `${withoutQuery.slice(0, 180)}…`; + return withoutQuery; +} + +function sanitizeLargeJson(value) { + const unwrapped = unwrapSpacetimeOption(value); + if (unwrapped === undefined || unwrapped === null) return unwrapped; + if (typeof unwrapped === 'string') { + return truncateText(redactSensitiveText(unwrapped), LONG_TEXT_LIMIT); + } + try { + return truncateText(redactSensitiveText(JSON.stringify(unwrapped)), LONG_TEXT_LIMIT); + } catch { + return truncateText(redactSensitiveText(String(unwrapped)), LONG_TEXT_LIMIT); + } +} + +function firstDefined(row, keys) { + for (const key of keys) { + if (row[key] !== undefined && row[key] !== null) return row[key]; + } + return undefined; +} + +function sanitizeShortField(row, sanitized, key) { + if (row[key] !== undefined) { + sanitized[key] = truncateText(unwrapSpacetimeOption(row[key]), SHORT_TEXT_LIMIT); + } +} + +function sanitizeWorkRow(row, ctx) { + const sanitized = {}; + const profileId = unwrapSpacetimeOption(firstDefined(row, ['profile_id', 'profileId'])); + const workId = unwrapSpacetimeOption(firstDefined(row, ['work_id', 'workId'])); + + if (profileId !== undefined) sanitized.profile_id = ctx.profile.map(profileId); + if (workId !== undefined) sanitized.work_id = ctx.work.map(workId); + if (row.owner_user_id !== undefined) { + sanitized.owner_user_id = ctx.user.map(unwrapSpacetimeOption(row.owner_user_id)); + } + if (row.user_id !== undefined) sanitized.user_id = ctx.user.map(unwrapSpacetimeOption(row.user_id)); + + if (row.author_display_name !== undefined) { + sanitized.author_display_name = ctx.author.map(unwrapSpacetimeOption(row.author_display_name)); + } + if (row.public_work_code !== undefined) { + sanitized.public_work_code = ctx.publicWorkCode.map(unwrapSpacetimeOption(row.public_work_code)); + } + if (row.author_public_user_code !== undefined) { + sanitized.author_public_user_code = ctx.authorCode.map( + unwrapSpacetimeOption(row.author_public_user_code), + ); + } + if (row.cover_asset_id !== undefined) { + sanitized.cover_asset_id = ctx.coverAsset.map(unwrapSpacetimeOption(row.cover_asset_id)); + } + if (row.cover_image_src !== undefined) sanitized.cover_image_src = sanitizeCoverImageSrc(row.cover_image_src); + + for (const key of [ + 'title', + 'work_title', + 'level_name', + 'world_name', + 'summary', + 'summary_text', + 'description', + 'work_description', + 'subtitle', + ]) { + sanitizeShortField(row, sanitized, key); + } + + for (const key of ['levels_json', 'profile_payload_json', 'anchor_pack_json', 'theme_tags_json']) { + if (row[key] !== undefined) sanitized[key] = sanitizeLargeJson(row[key]); + } + + const passthroughKeys = [ + 'publication_status', + 'publicationStatus', + 'play_count', + 'playCount', + 'like_count', + 'likeCount', + 'remix_count', + 'remixCount', + 'updated_at', + 'created_at', + 'published_at', + 'visibility', + 'status', + 'category', + 'tags', + ]; + for (const key of passthroughKeys) { + if (row[key] !== undefined) sanitized[key] = unwrapSpacetimeOption(row[key]); + } + + return sanitized; +} + +function normalizeWork(tableName, row) { + const type = WORK_TABLE_TYPES[tableName]; + return { + type, + workId: row.work_id, + profileId: row.profile_id, + ownerUserId: row.owner_user_id, + publicWorkCode: row.public_work_code, + title: row.title ?? row.work_title ?? row.level_name ?? row.world_name, + subtitle: row.subtitle ?? row.summary_text ?? row.summary ?? row.work_description ?? row.description, + publicationStatus: row.publicationStatus ?? row.publication_status ?? row.status, + playCount: row.playCount ?? row.play_count ?? 0, + likeCount: row.likeCount ?? row.like_count ?? 0, + remixCount: row.remixCount ?? row.remix_count ?? 0, + coverImageSrc: row.cover_image_src, + updatedAt: row.updated_at, + }; +} + +function toRowsByTable(input) { + const tables = Array.isArray(input?.tables) ? input.tables : []; + const result = new Map(); + for (const table of tables) { + if (!ALLOWED_TABLES.has(table?.name)) continue; + result.set(table.name, Array.isArray(table.rows) ? table.rows : []); + } + return result; +} + +export function extractWorksListData(input, options = {}) { + const ctx = createContext(); + const rowsByTable = toRowsByTable(input); + const outputTables = {}; + const counts = {}; + const profileIds = createWorkTypeBuckets(); + const workIds = createWorkTypeBuckets(); + const normalizedWorks = []; + + for (const tableName of TABLE_OUTPUT_ORDER) { + const sourceRows = rowsByTable.get(tableName); + if (!sourceRows) continue; + const sanitizedRows = sourceRows.map((row) => sanitizeWorkRow(row, ctx)); + outputTables[tableName] = sanitizedRows; + counts[tableName] = sanitizedRows.length; + + const type = WORK_TABLE_TYPES[tableName]; + if (type) { + for (const row of sanitizedRows) { + if (row.profile_id) profileIds[type].push(row.profile_id); + if (row.work_id) workIds[type].push(row.work_id); + normalizedWorks.push(normalizeWork(tableName, row)); + } + } + } + + return { + source: options.source ?? 'unknown', + generatedAt: options.generatedAt ?? new Date().toISOString(), + counts, + tables: outputTables, + profileIds, + workIds, + normalizedWorks, + }; +} + +function createSampleOutput(output, maxRowsPerTable = 3) { + const tables = {}; + const counts = {}; + const allowedWorkIds = new Set(); + const allowedProfileIds = new Set(); + + for (const [tableName, rows] of Object.entries(output.tables)) { + tables[tableName] = rows.slice(0, maxRowsPerTable); + counts[tableName] = tables[tableName].length; + const type = WORK_TABLE_TYPES[tableName]; + if (type) { + for (const row of tables[tableName]) { + if (row.work_id) allowedWorkIds.add(row.work_id); + if (row.profile_id) allowedProfileIds.add(row.profile_id); + } + } + } + + const profileIds = Object.fromEntries( + Object.entries(output.profileIds).map(([type, ids]) => [ + type, + ids.filter((id) => allowedProfileIds.has(id)).slice(0, maxRowsPerTable), + ]), + ); + const workIds = Object.fromEntries( + Object.entries(output.workIds).map(([type, ids]) => [ + type, + ids.filter((id) => allowedWorkIds.has(id)).slice(0, maxRowsPerTable), + ]), + ); + const normalizedWorks = output.normalizedWorks + .filter((work) => allowedWorkIds.has(work.workId) || allowedProfileIds.has(work.profileId)) + .slice(0, maxRowsPerTable * 6); + + return { + ...output, + counts, + tables, + profileIds, + workIds, + normalizedWorks, + }; +} + +function parseArgs(argv) { + const args = {}; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--input' || arg === '--output' || arg === '--sample-output') { + const value = argv[index + 1]; + if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value`); + args[arg.slice(2)] = value; + index += 1; + } else if (arg === '--help' || arg === '-h') { + args.help = true; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + return args; +} + +function usage() { + return 'Usage: node scripts/loadtest/extract-works-list-data.mjs --input --output [--sample-output ]'; +} + +export async function runCli(argv = process.argv.slice(2)) { + const args = parseArgs(argv); + if (args.help) { + console.log(usage()); + return; + } + if (!args.input) throw new Error('Missing required --input. ' + usage()); + if (!args.output) throw new Error('Missing required --output. ' + usage()); + + const raw = await readFile(args.input, 'utf8'); + const migration = JSON.parse(raw); + const output = extractWorksListData(migration, { source: basename(args.input) }); + await writeFile(args.output, `${JSON.stringify(output, null, 2)}\n`, 'utf8'); + + if (args['sample-output']) { + const sample = createSampleOutput(output); + await writeFile(args['sample-output'], `${JSON.stringify(sample, null, 2)}\n`, 'utf8'); + } + + console.log( + `works-list extracted: source=${output.source}, tables=${Object.keys(output.tables).length}, normalizedWorks=${output.normalizedWorks.length}`, + ); + for (const [tableName, count] of Object.entries(output.counts)) { + console.log(` ${tableName}: ${count}`); + } +} + +const isDirectRun = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isDirectRun) { + runCli().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/scripts/loadtest/extract-works-list-data.test.ts b/scripts/loadtest/extract-works-list-data.test.ts new file mode 100644 index 00000000..28b6f1c5 --- /dev/null +++ b/scripts/loadtest/extract-works-list-data.test.ts @@ -0,0 +1,247 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +import { describe, expect, it } from 'vitest'; + +import { extractWorksListData } from './extract-works-list-data.mjs'; + +const execFileAsync = promisify(execFile); +const scriptPath = fileURLToPath(new URL('./extract-works-list-data.mjs', import.meta.url)); + +const fixtureMigration = { + schema_version: 7, + tables: [ + { + name: 'puzzle_work_profile', + rows: [ + { + profile_id: 'profile-real-aaa', + work_id: 'work-real-aaa', + owner_user_id: 'owner-secret-123', + author_display_name: 'Alice Secret', + author_public_user_code: 'author-code-secret', + public_work_code: 'public-code-secret', + title: '超长标题'.repeat(20), + summary: 'summary '.repeat(80), + description: 'description '.repeat(120), + publication_status: 'published', + play_count: 42, + like_count: 7, + cover_asset_id: { some: 'asset-secret-cover' }, + cover_image_src: { some: 'https://cdn.example.test/cover.png?token=***&sig=abc' }, + levels_json: JSON.stringify({ secret: 'level-token-value', data: 'x'.repeat(2000) }), + theme_tags_json: JSON.stringify(['化学家', '实验室']), + remix_count: 2, + updated_at: '2026-05-01T00:00:00Z', + }, + { + profile_id: 'profile-real-bbb', + work_id: 'work-real-bbb', + owner_user_id: 'owner-secret-123', + author_display_name: 'Alice Secret', + publication_status: 'draft', + play_count: 3, + }, + ], + }, + { + name: 'custom_world_profile', + rows: [ + { + profile_id: 'world-profile-secret', + work_id: 'world-work-secret', + owner_user_id: 'world-owner-secret', + title: '世界作品', + profile_payload_json: '{"large":"' + 'y'.repeat(2000) + '"}', + }, + ], + }, + { + name: 'public_work_play_daily_stat', + rows: [ + { + source_type: 'puzzle', + profile_id: 'profile-real-aaa', + owner_user_id: 'owner-secret-123', + user_id: 'player-secret-456', + source_session_id: 'session-secret-789', + played_day: '2026-05-01', + play_count: 12, + updated_at: '2026-05-02T00:00:00Z', + }, + ], + }, + { + name: 'user_account', + rows: [ + { + user_id: 'owner-secret-123', + phone: '+8613800138000', + auth_token: 'auth-token-secret', + wallet_balance: 999, + }, + ], + }, + { + name: 'refresh_session', + rows: [{ token: 'refresh-token-secret', source_session_id: 'session-secret-789' }], + }, + { + name: 'profile_wallet_ledger', + rows: [{ wallet_id: 'wallet-secret', amount: 100 }], + }, + ], +}; + +async function withTempDir(fn) { + const dir = await mkdtemp(path.join(tmpdir(), 'works-list-test-')); + try { + return await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +describe('extractWorksListData', () => { + it('只保留作品 profile 白名单表,禁用的行为/敏感表不会出现在输出 JSON 字符串中', () => { + const output = extractWorksListData(fixtureMigration, { source: 'fixture.local.json' }); + const serialized = JSON.stringify(output); + + expect(Object.keys(output.tables).sort()).toEqual([ + 'custom_world_profile', + 'puzzle_work_profile', + ]); + expect(serialized).not.toContain('public_work_play_daily_stat'); + expect(serialized).not.toContain('user_account'); + expect(serialized).not.toContain('refresh_session'); + expect(serialized).not.toContain('profile_wallet_ledger'); + expect(serialized).not.toContain('+8613800138000'); + expect(serialized).not.toContain('auth-token-secret'); + expect(serialized).not.toContain('wallet-secret'); + }); + + it('不会输出 owner/user/session/auth/token/phone/wallet 等敏感原值,owner 稳定映射', () => { + const output = extractWorksListData(fixtureMigration, { source: 'fixture.local.json' }); + const serialized = JSON.stringify(output); + + for (const secret of [ + 'owner-secret-123', + 'player-secret-456', + 'session-secret-789', + 'Alice Secret', + 'author-code-secret', + 'public-code-secret', + 'asset-secret-cover', + 'SECRET_TOKEN', + ]) { + expect(serialized).not.toContain(secret); + } + + expect(output.tables.puzzle_work_profile[0].owner_user_id).toBe('user-001'); + expect(output.tables.puzzle_work_profile[1].owner_user_id).toBe('user-001'); + expect(output.tables.puzzle_work_profile[0].author_display_name).toBe('author-001'); + expect(serialized).not.toContain('level-token-value'); + }); + + it('puzzle 数据生成 profileIds/workIds 和 normalizedWorks,并保留列表展示字段', () => { + const output = extractWorksListData(fixtureMigration, { source: 'fixture.local.json' }); + + expect(output.source).toBe('fixture.local.json'); + expect(output.generatedAt).toEqual(expect.any(String)); + expect(output.counts.puzzle_work_profile).toBe(2); + expect(output.profileIds.puzzle).toEqual(['profile-001', 'profile-002']); + expect(output.workIds.puzzle).toEqual(['work-001', 'work-002']); + expect(output.normalizedWorks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'puzzle', + workId: 'work-001', + profileId: 'profile-001', + publicationStatus: 'published', + playCount: 42, + title: expect.any(String), + remixCount: 2, + }), + ]), + ); + expect(output.tables.puzzle_work_profile[0].cover_image_src).toBe('https://cdn.example.test/cover.png'); + expect(output.tables.puzzle_work_profile[0].theme_tags_json).toBe('["化学家","实验室"]'); + }); + + it('data image、URL token 和绝对输入路径不会泄露到输出', async () => { + await withTempDir(async (dir) => { + const input = path.join(dir, 'migration.local.json'); + const output = path.join(dir, 'works-list.local.json'); + await writeFile( + input, + JSON.stringify({ + tables: [ + { + name: 'puzzle_work_profile', + rows: [ + { + profile_id: 'profile-real', + work_id: 'work-real', + cover_image_src: { some: 'data:image/png;base64,SECRET_IMAGE_BYTES' }, + levels_json: JSON.stringify({ token: 'SECRET_TOKEN_VALUE', title: 'safe' }), + }, + ], + }, + ], + }), + 'utf8', + ); + + await execFileAsync(process.execPath, [scriptPath, '--input', input, '--output', output]); + const extracted = JSON.parse(await readFile(output, 'utf8')); + const serialized = JSON.stringify(extracted); + + expect(extracted.source).toBe('migration.local.json'); + expect(serialized).not.toContain(dir); + expect(serialized).not.toContain('SECRET_IMAGE_BYTES'); + expect(serialized).not.toContain('SECRET_TOKEN_VALUE'); + expect(extracted.tables.puzzle_work_profile[0].cover_image_src).toBe('[redacted-data-image]'); + }); + }); + + it('sample-output 只输出少量脱敏样例', async () => { + await withTempDir(async (dir) => { + const input = path.join(dir, 'migration.local.json'); + const output = path.join(dir, 'works-list.local.json'); + const sampleOutput = path.join(dir, 'works-list.sample.json'); + const manyRows = Array.from({ length: 5 }, (_, index) => ({ + profile_id: `profile-real-${index}`, + work_id: `work-real-${index}`, + owner_user_id: `owner-secret-${index}`, + title: `作品 ${index}`, + publication_status: 'published', + play_count: index, + })); + await writeFile( + input, + JSON.stringify({ tables: [{ name: 'puzzle_work_profile', rows: manyRows }] }), + 'utf8', + ); + + await execFileAsync(process.execPath, [scriptPath, '--input', input, '--output', output, '--sample-output', sampleOutput]); + const sample = JSON.parse(await readFile(sampleOutput, 'utf8')); + const serialized = JSON.stringify(sample); + + expect(sample.tables.puzzle_work_profile).toHaveLength(3); + expect(sample.normalizedWorks).toHaveLength(3); + expect(serialized).not.toContain('owner-secret-0'); + expect(serialized).not.toContain('work-real-0'); + }); + }); + + it('CLI 参数缺失时退出非 0 并输出清晰错误', async () => { + await expect(execFileAsync(process.execPath, [scriptPath, '--input', 'missing.json'])).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining('--output'), + }); + }); +}); diff --git a/scripts/loadtest/k6-works-list.js b/scripts/loadtest/k6-works-list.js new file mode 100644 index 00000000..45e51a82 --- /dev/null +++ b/scripts/loadtest/k6-works-list.js @@ -0,0 +1,229 @@ +/* global __ENV */ +import { check, sleep } from 'k6'; +import { SharedArray } from 'k6/data'; +import http from 'k6/http'; +import { Rate, Trend } from 'k6/metrics'; + +// k6 resolves open() paths relative to this script file, not the shell cwd. +const DEFAULT_WORKS_DATA = 'data/works-list.local.json'; +const WORKS_DATA = __ENV.WORKS_DATA || DEFAULT_WORKS_DATA; +const BASE_URL = (__ENV.BASE_URL || 'http://127.0.0.1:8787').replace(/\/+$/u, ''); +const AUTH_TOKEN = __ENV.AUTH_TOKEN || ''; +const SCENARIO = __ENV.SCENARIO || 'smoke'; +const REQUEST_TIMEOUT = __ENV.REQUEST_TIMEOUT || '30s'; +const SLEEP_MIN_SECONDS = Number(__ENV.SLEEP_MIN_SECONDS || '0.5'); +const SLEEP_MAX_SECONDS = Number(__ENV.SLEEP_MAX_SECONDS || '2'); +const DETAIL_RATIO = Number(__ENV.DETAIL_RATIO || '0'); + +const worksListShapeErrorRate = new Rate('works_list_shape_error_rate'); +const worksDetailShapeErrorRate = new Rate('works_detail_shape_error_rate'); +const worksListDuration = new Trend('works_list_duration'); +const worksDetailDuration = new Trend('works_detail_duration'); + +const data = new SharedArray('works-list-data', () => [JSON.parse(open(WORKS_DATA))])[0]; +const normalizedWorks = Array.isArray(data.normalizedWorks) ? data.normalizedWorks : []; + +const scenarioOptions = { + smoke: { + scenarios: { + smoke: { + executor: 'constant-vus', + vus: Number(__ENV.VUS || 1), + duration: __ENV.DURATION || '30s', + }, + }, + thresholds: { + http_req_failed: ['rate<0.01'], + http_req_duration: ['p(95)<800'], + works_list_shape_error_rate: ['rate<0.01'], + }, + }, + baseline: { + scenarios: { + baseline: { + executor: 'constant-vus', + vus: Number(__ENV.VUS || 10), + duration: __ENV.DURATION || '3m', + }, + }, + thresholds: { + http_req_failed: ['rate<0.01'], + http_req_duration: ['p(95)<800', 'p(99)<1500'], + works_list_shape_error_rate: ['rate<0.01'], + }, + }, + spike: { + scenarios: { + spike: { + executor: 'ramping-arrival-rate', + preAllocatedVUs: Number(__ENV.PREALLOCATED_VUS || 50), + maxVUs: Number(__ENV.MAX_VUS || 200), + timeUnit: '1s', + stages: [ + { target: Number(__ENV.START_RPS || 5), duration: __ENV.RAMP_UP || '30s' }, + { target: Number(__ENV.PEAK_RPS || 100), duration: __ENV.HOLD || '2m' }, + { target: Number(__ENV.END_RPS || 5), duration: __ENV.RAMP_DOWN || '30s' }, + ], + }, + }, + thresholds: { + http_req_failed: ['rate<0.05'], + http_req_duration: ['p(95)<2000'], + works_list_shape_error_rate: ['rate<0.05'], + }, + }, +}; + +export const options = scenarioOptions[SCENARIO] || scenarioOptions.smoke; + +const PUBLIC_ENDPOINTS = [ + { + name: 'puzzle_gallery_list', + method: 'GET', + path: '/api/runtime/puzzle/gallery', + expectCollectionKeys: ['items', 'works', 'entries'], + }, + { + name: 'custom_world_gallery_list', + method: 'GET', + path: '/api/runtime/custom-world-gallery', + expectCollectionKeys: ['entries', 'items', 'works'], + }, +]; + +const AUTH_ENDPOINTS = [ + { + name: 'puzzle_works_list', + method: 'GET', + path: '/api/runtime/puzzle/works', + expectCollectionKeys: ['items', 'works'], + }, + { + name: 'custom_world_works_list', + method: 'GET', + path: '/api/runtime/custom-world/works', + expectCollectionKeys: ['items', 'entries', 'works'], + }, +]; + +function requestParams(endpointName) { + const headers = { 'x-genarrative-response-envelope': 'v1' }; + if (AUTH_TOKEN) headers.Authorization = `Bearer ${AUTH_TOKEN}`; + return { + headers, + timeout: REQUEST_TIMEOUT, + tags: { endpoint: endpointName }, + }; +} + +function buildUrl(path) { + return `${BASE_URL}${path}`; +} + +function parseJson(response) { + try { + return response.json(); + } catch (_) { + return null; + } +} + +function unwrapPayload(json) { + if (!json || typeof json !== 'object') return null; + if (json.data && typeof json.data === 'object') return json.data; + return json; +} + +function hasCollection(payload, keys) { + return keys.some((key) => Array.isArray(payload?.[key])); +} + +function firstCollection(payload, keys) { + for (const key of keys) { + if (Array.isArray(payload?.[key])) return payload[key]; + } + return []; +} + +function hasListItemShape(payload, keys) { + const collection = firstCollection(payload, keys); + if (collection.length === 0) return true; + const item = collection[0]; + const hasId = Boolean( + item?.profileId || item?.profile_id || item?.workId || item?.work_id || item?.publicWorkCode, + ); + const hasTitle = Boolean( + item?.title || item?.workTitle || item?.work_title || item?.levelName || item?.worldName, + ); + return hasId && hasTitle; +} + +function randomItem(items) { + if (!items.length) return null; + return items[Math.floor(Math.random() * items.length)]; +} + +function listEndpoints() { + return AUTH_TOKEN ? PUBLIC_ENDPOINTS.concat(AUTH_ENDPOINTS) : PUBLIC_ENDPOINTS; +} + +function detailEndpointFor(work) { + if (!work || !work.profileId) return null; + if (work.type === 'puzzle') { + return { + name: 'puzzle_gallery_detail', + path: `/api/runtime/puzzle/gallery/${encodeURIComponent(work.profileId)}`, + expectKeys: ['item', 'work', 'entry'], + }; + } + if (work.type === 'customWorld' && work.profileId && work.ownerUserId) { + return { + name: 'custom_world_gallery_detail', + path: `/api/runtime/custom-world-gallery/${encodeURIComponent(work.ownerUserId)}/${encodeURIComponent(work.profileId)}`, + expectKeys: ['entry', 'item', 'work'], + }; + } + return null; +} + +function performListRequest(endpoint) { + const url = buildUrl(endpoint.path); + const response = http.request(endpoint.method, url, null, requestParams(endpoint.name)); + worksListDuration.add(response.timings.duration, { endpoint: endpoint.name }); + const json = parseJson(response); + const payload = unwrapPayload(json); + const ok = check(response, { + [`${endpoint.name} status is 200`]: (res) => res.status === 200, + [`${endpoint.name} returns json object`]: () => Boolean(payload), + [`${endpoint.name} has collection`]: () => hasCollection(payload, endpoint.expectCollectionKeys), + [`${endpoint.name} list item shape`]: () => hasListItemShape(payload, endpoint.expectCollectionKeys), + }); + worksListShapeErrorRate.add(!ok, { endpoint: endpoint.name }); +} + +function performDetailRequest() { + const endpoint = detailEndpointFor(randomItem(normalizedWorks)); + if (!endpoint) return; + + const response = http.get(buildUrl(endpoint.path), requestParams(endpoint.name)); + worksDetailDuration.add(response.timings.duration, { endpoint: endpoint.name }); + const json = parseJson(response); + const payload = unwrapPayload(json); + const ok = check(response, { + [`${endpoint.name} status is 200`]: (res) => res.status === 200, + [`${endpoint.name} has detail payload`]: () => endpoint.expectKeys.some((key) => payload?.[key]), + }); + worksDetailShapeErrorRate.add(!ok, { endpoint: endpoint.name }); +} + +export default function () { + for (const endpoint of listEndpoints()) { + performListRequest(endpoint); + } + if (normalizedWorks.length && DETAIL_RATIO > 0 && Math.random() < DETAIL_RATIO) { + performDetailRequest(); + } + + const jitter = SLEEP_MIN_SECONDS + Math.random() * Math.max(0, SLEEP_MAX_SECONDS - SLEEP_MIN_SECONDS); + sleep(jitter); +} diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index c8ad43b0..c8cc837b 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -3009,7 +3009,6 @@ dependencies = [ name = "shared-contracts" version = "0.1.0" dependencies = [ - "platform-oss", "serde", "serde_json", ] diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 29f0ec59..739cdb41 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -19,13 +19,12 @@ use serde::Deserialize; use serde_json::{Map, Value}; use shared_contracts::admin::{ AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload, - AdminUpsertCreationEntryTypeConfigRequest, AdminDatabaseOverviewPayload, AdminDatabaseTableListResponse, AdminDatabaseTableRowPayload, AdminDatabaseTableRowsQuery, AdminDatabaseTableRowsResponse, AdminDatabaseTableStatPayload, AdminDebugHeaderInput, AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest, AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload, AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventListQuery, - AdminTrackingEventListResponse, + AdminTrackingEventListResponse, AdminUpsertCreationEntryTypeConfigRequest, }; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; @@ -196,13 +195,15 @@ pub async fn admin_list_database_table_rows( Ok(json_success_body(Some(&request_context), response)) } - pub async fn admin_get_creation_entry_config( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, ) -> Result, AppError> { - let config = state.get_creation_entry_config().await.map_err(map_admin_spacetime_error)?; + let config = state + .get_creation_entry_config() + .await + .map_err(map_admin_spacetime_error)?; Ok(json_success_body( Some(&request_context), AdminCreationEntryConfigResponse { @@ -1328,8 +1329,10 @@ mod tests { use axum::{http::StatusCode, response::IntoResponse}; use serde_json::json; use shared_contracts::admin::{ - AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload, - AdminUpsertCreationEntryTypeConfigRequest,AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery}; + AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload, + AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery, + AdminUpsertCreationEntryTypeConfigRequest, + }; #[test] fn normalize_debug_path_rejects_absolute_url() { diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 972f8432..048c8721 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -5,7 +5,7 @@ use axum::{ http::Request, middleware, response::Response, - routing::{delete, get, post}, + routing::{delete, get, post, put}, }; use tower_http::{ classify::ServerErrorsFailureClass, @@ -16,8 +16,8 @@ use tracing::{Level, Span, error, info, info_span, warn}; use crate::{ admin::{ admin_debug_http, admin_get_creation_entry_config, admin_list_database_table_rows, - admin_list_database_tables, admin_list_tracking_events, admin_login, admin_me, admin_overview, - admin_upsert_creation_entry_config, require_admin_auth, + admin_list_database_tables, admin_list_tracking_events, admin_login, admin_me, + admin_overview, admin_upsert_creation_entry_config, require_admin_auth, }, ai_tasks::{ append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage, @@ -90,10 +90,10 @@ use crate::{ match3d::{ click_match3d_item, compile_match3d_agent_draft, create_match3d_agent_session, delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up, - get_match3d_agent_session, get_match3d_run, get_match3d_work_detail, get_match3d_works, - list_match3d_gallery, publish_match3d_work, put_match3d_work, restart_match3d_run, - start_match3d_run, stop_match3d_run, stream_match3d_agent_message, - submit_match3d_agent_message, + generate_match3d_work_tags, get_match3d_agent_session, get_match3d_run, + get_match3d_work_detail, get_match3d_works, list_match3d_gallery, publish_match3d_work, + put_match3d_audio_assets, put_match3d_work, restart_match3d_run, start_match3d_run, + stop_match3d_run, stream_match3d_agent_message, submit_match3d_agent_message, }, password_entry::password_entry, password_management::{change_password, reset_password}, @@ -156,7 +156,9 @@ use crate::{ }, tracking::record_route_tracking_event_after_success, vector_engine_audio_generation::{ + create_background_music_task, create_sound_effect_task, create_visual_novel_background_music_task, create_visual_novel_sound_effect_task, + publish_background_music_asset, publish_sound_effect_asset, publish_visual_novel_background_music_asset, publish_visual_novel_sound_effect_asset, }, visual_novel::{ @@ -924,6 +926,13 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/creation/match3d/works/tags", + post(generate_match3d_work_tags).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/creation/match3d/works/{profile_id}", get(get_match3d_work_detail) @@ -935,6 +944,13 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/creation/match3d/works/{profile_id}/audio-assets", + put(put_match3d_audio_assets).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/creation/match3d/works/{profile_id}/publish", post(publish_match3d_work).route_layer(middleware::from_fn_with_state( @@ -1510,7 +1526,7 @@ pub fn build_router(state: AppState) -> Router { )), ) .route("/api/auth/password/reset", post(reset_password)) - // 后端 runtime/API 路由读取入口配置做统一熔断,避免前端隐藏后后端仍可被直接访问。 + // 后端创作/运行态 API 路由只按 open 做熔断;visible 仅控制创作页入口展示。 .layer(middleware::from_fn_with_state( state.clone(), require_creation_entry_route_enabled, @@ -1763,6 +1779,34 @@ fn visual_novel_router(state: AppState) -> Router { middleware::from_fn_with_state(state.clone(), require_bearer_auth), ), ) + .route( + "/api/creation/audio/background-music", + post(create_background_music_task).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/audio/background-music/{task_id}/asset", + post(publish_background_music_asset).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/audio/sound-effect", + post(create_sound_effect_task).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/audio/sound-effect/{task_id}/asset", + post(publish_sound_effect_asset).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/visual-novel/gallery", get(list_visual_novel_gallery), @@ -1993,10 +2037,45 @@ mod tests { assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); let body = read_json_response(response).await; - assert_eq!(body["error"]["details"]["reason"], "creation_entry_disabled"); + assert_eq!( + body["error"]["details"]["reason"], + "creation_entry_disabled" + ); assert_eq!(body["error"]["details"]["creationTypeId"], "puzzle"); } + #[tokio::test] + async fn disabled_visual_novel_creation_route_returns_service_unavailable() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/creation/visual-novel/sessions") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "sourceMode": "idea", + "seedText": "雨夜书店", + "sourceAssetIds": [] + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = read_json_response(response).await; + assert_eq!( + body["error"]["details"]["reason"], + "creation_entry_disabled" + ); + assert_eq!(body["error"]["details"]["creationTypeId"], "visual-novel"); + } + #[tokio::test] async fn healthz_returns_standard_envelope_when_requested() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); @@ -4683,7 +4762,9 @@ mod tests { #[tokio::test] async fn visual_novel_creation_route_requires_authentication() { - let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + let state = AppState::new(AppConfig::default()).expect("state should build"); + state.set_test_creation_entry_route_enabled("visual-novel", true); + let app = build_router(state); let response = app .oneshot( diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index ea7e6c80..41b01cd6 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -19,8 +19,9 @@ use shared_contracts::assets::{ AssetBindingPayload, AssetHistoryEntryPayload, AssetHistoryListResponse, AssetHistoryQuery, AssetObjectPayload, AssetReadUrlPayload, BindAssetObjectRequest, BindAssetObjectResponse, ConfirmAssetObjectAccessPolicy, ConfirmAssetObjectRequest, ConfirmAssetObjectResponse, - CreateDirectUploadTicketRequest, CreateDirectUploadTicketResponse, DirectUploadTicketPayload, - GetAssetReadUrlResponse, GetReadUrlQuery, + CreateDirectUploadTicketRequest, CreateDirectUploadTicketResponse, DirectUploadObjectAccess, + DirectUploadTicketFormFields, DirectUploadTicketPayload, GetAssetReadUrlResponse, + GetReadUrlQuery, }; use spacetime_client::SpacetimeClientError; @@ -44,7 +45,8 @@ const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 7] = [ "square_hole_shape_image", "square_hole_hole_image", ]; -const ASSET_READ_BYTES_MAX_SIZE_BYTES: u64 = 10 * 1024 * 1024; +// 中文注释:同源字节读取同时服务图片转 Data URL 与 Match3D 私有 GLB 预览,Rodin GLB 可能明显超过图片上限。 +const ASSET_READ_BYTES_MAX_SIZE_BYTES: u64 = 120 * 1024 * 1024; const ASSET_READ_BYTES_DEFAULT_EXPIRE_SECONDS: u64 = 300; pub async fn create_direct_upload_ticket( @@ -73,7 +75,10 @@ pub async fn create_direct_upload_ticket( path_segments: payload.path_segments, file_name: payload.file_name, content_type: payload.content_type, - access: payload.access.unwrap_or(OssObjectAccess::Private), + access: payload + .access + .map(direct_upload_access_to_oss) + .unwrap_or(OssObjectAccess::Private), metadata: payload.metadata, max_size_bytes: payload.max_size_bytes, expire_seconds: payload.expire_seconds, @@ -85,7 +90,7 @@ pub async fn create_direct_upload_ticket( "message": error.to_string(), })) })?; - let upload = DirectUploadTicketPayload::from(signed); + let upload = direct_upload_ticket_payload_from_oss(signed); record_asset_tracking_event( &state, @@ -149,11 +154,76 @@ pub async fn get_asset_read_url( Ok(json_success_body( Some(&request_context), GetAssetReadUrlResponse { - read: AssetReadUrlPayload::from(signed), + read: asset_read_url_payload_from_oss(signed), }, )) } +fn direct_upload_access_to_oss(value: DirectUploadObjectAccess) -> OssObjectAccess { + match value { + DirectUploadObjectAccess::Public => OssObjectAccess::Public, + DirectUploadObjectAccess::Private => OssObjectAccess::Private, + } +} + +fn direct_upload_access_from_oss(value: OssObjectAccess) -> DirectUploadObjectAccess { + match value { + OssObjectAccess::Public => DirectUploadObjectAccess::Public, + OssObjectAccess::Private => DirectUploadObjectAccess::Private, + } +} + +fn direct_upload_ticket_payload_from_oss( + value: platform_oss::OssPostObjectResponse, +) -> DirectUploadTicketPayload { + DirectUploadTicketPayload { + signature_version: value.signature_version.to_string(), + provider: value.provider.to_string(), + bucket: value.bucket, + endpoint: value.endpoint, + host: value.host, + object_key: value.object_key, + legacy_public_path: value.legacy_public_path, + content_type: value.content_type, + access: direct_upload_access_from_oss(value.access), + key_prefix: value.key_prefix, + expires_at: value.expires_at, + max_size_bytes: value.max_size_bytes, + success_action_status: value.success_action_status, + form_fields: direct_upload_ticket_form_fields_from_oss(value.form_fields), + } +} + +fn direct_upload_ticket_form_fields_from_oss( + value: platform_oss::OssPostObjectFormFields, +) -> DirectUploadTicketFormFields { + DirectUploadTicketFormFields { + key: value.key, + policy: value.policy, + signature_version: value.signature_version, + credential: value.credential, + date: value.date, + signature: value.signature, + success_action_status: value.success_action_status, + content_type: value.content_type, + metadata: value.metadata, + } +} + +fn asset_read_url_payload_from_oss( + value: platform_oss::OssSignedGetObjectUrlResponse, +) -> AssetReadUrlPayload { + AssetReadUrlPayload { + provider: value.provider.to_string(), + bucket: value.bucket, + endpoint: value.endpoint, + host: value.host, + object_key: value.object_key, + expires_at: value.expires_at, + signed_url: value.signed_url, + } +} + pub async fn get_asset_read_bytes( State(state): State, Query(query): Query, diff --git a/server-rs/crates/api-server/src/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs index 11f86f81..2efa038d 100644 --- a/server-rs/crates/api-server/src/creation_entry_config.rs +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -8,6 +8,9 @@ use axum::{ }; use serde_json::{Value, json}; +#[cfg(test)] +use module_runtime::build_creation_entry_config_response; + use crate::{ api_response::json_success_body, http_error::AppError, request_context::RequestContext, state::AppState, @@ -31,7 +34,7 @@ pub async fn get_creation_entry_config_handler( Ok(json_success_body(Some(&request_context), config)) } -/// 中文注释:api-server 路由熔断只拦运行态/API 请求,不改变前端入口展示规则。 +/// 中文注释:api-server 路由熔断只拦创作/运行态 API 请求,不改变前端入口展示规则。 pub async fn require_creation_entry_route_enabled( State(state): State, request: Request, @@ -84,6 +87,9 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> { if normalized.starts_with("/api/runtime/visual-novel") { return Some("visual-novel"); } + if normalized.starts_with("/api/creation/visual-novel") { + return Some("visual-novel"); + } None } @@ -92,8 +98,8 @@ fn creation_entry_error_response(request_context: &RequestContext, error: AppErr } #[cfg(test)] -pub(crate) fn test_creation_entry_config_response( -) -> shared_contracts::creation_entry_config::CreationEntryConfigResponse { +pub(crate) fn test_creation_entry_config_response() +-> shared_contracts::creation_entry_config::CreationEntryConfigResponse { build_creation_entry_config_response(module_runtime::CreationEntryConfigSnapshot { config_id: module_runtime::CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string(), start_card: module_runtime::CreationEntryStartCardSnapshot { @@ -111,8 +117,8 @@ pub(crate) fn test_creation_entry_config_response( test_creation_type("big-fish", false, true, 20), test_creation_type("puzzle", true, true, 30), test_creation_type("match3d", true, true, 40), - test_creation_type("square-hole", true, true, 50), - test_creation_type("visual-novel", true, true, 60), + test_creation_type("square-hole", false, true, 50), + test_creation_type("visual-novel", true, false, 60), test_creation_type("airp", true, false, 70), test_creation_type("creative-agent", false, true, 80), ], @@ -162,6 +168,10 @@ mod tests { resolve_creation_entry_route_id("/api/runtime/visual-novel/works"), Some("visual-novel"), ); + assert_eq!( + resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"), + Some("visual-novel"), + ); assert_eq!(resolve_creation_entry_route_id("/healthz"), None); } } diff --git a/server-rs/crates/api-server/src/http_error.rs b/server-rs/crates/api-server/src/http_error.rs index ae936851..02bf8d05 100644 --- a/server-rs/crates/api-server/src/http_error.rs +++ b/server-rs/crates/api-server/src/http_error.rs @@ -89,6 +89,14 @@ impl IntoResponse for AppError { } } +impl std::fmt::Display for AppError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.body_text().as_str()) + } +} + +impl std::error::Error for AppError {} + impl From for Response { fn from(error: AppError) -> Self { error.into_response() diff --git a/server-rs/crates/api-server/src/hyper3d_generation.rs b/server-rs/crates/api-server/src/hyper3d_generation.rs index 4d9329df..b5d8b691 100644 --- a/server-rs/crates/api-server/src/hyper3d_generation.rs +++ b/server-rs/crates/api-server/src/hyper3d_generation.rs @@ -231,12 +231,7 @@ pub(crate) async fn query_task_status( .await?; let jobs = extract_job_statuses(&response); - let status = normalize_task_status( - find_first_string_by_key(&response, "status") - .or_else(|| jobs.first().map(|job| job.status.clone())) - .as_deref() - .unwrap_or("unknown"), - ); + let status = resolve_hyper3d_overall_status(&response, &jobs); Ok(contract::Hyper3dTaskStatusResponse { ok: true, @@ -539,13 +534,38 @@ fn extract_job_statuses(payload: &Value) -> Vec String { + if !jobs.is_empty() { + if jobs.iter().any(|job| job.status == "failed") { + return "failed".to_string(); + } + if jobs.iter().all(|job| job.status == "done") { + return "done".to_string(); + } + if jobs.iter().any(|job| job.status == "generating") { + return "generating".to_string(); + } + if jobs.iter().any(|job| job.status == "waiting") { + return "waiting".to_string(); + } + return "unknown".to_string(); + } + + normalize_task_status( + find_first_string_by_key(payload, "status") + .as_deref() + .unwrap_or("unknown"), + ) +} + fn extract_job_uuids(payload: &Value) -> Vec { let mut job_uuids = Vec::new(); - if let Some(jobs) = find_first_array_by_keys(payload, &["jobs"]) { - for job in jobs { - if let Some(uuid) = find_first_string_by_keys(job, &["uuid", "task_uuid", "taskUuid"]) - && !job_uuids.contains(&uuid) - { + if let Some(jobs) = payload.get("jobs") { + for uuid in collect_strings_by_keys(jobs, &["uuid", "task_uuid", "taskUuid", "uuids"]) { + if !job_uuids.contains(&uuid) { job_uuids.push(uuid); } } @@ -580,6 +600,12 @@ fn collect_download_files(value: &Value, output: &mut Vec, task_uuid: Option, subscription_key: Option, + background_music: Option, + click_sound: Option, status: String, error: Option, } +#[derive(Clone, Debug)] +struct Match3DGeneratedWorkMetadata { + game_name: String, + tags: Vec, +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct Match3DGeneratedItemAssetJson { @@ -135,6 +151,10 @@ struct Match3DGeneratedItemAssetJson { task_uuid: Option, #[serde(default)] subscription_key: Option, + #[serde(default)] + background_music: Option, + #[serde(default)] + click_sound: Option, status: String, #[serde(default)] error: Option, @@ -146,6 +166,18 @@ struct Match3DAssetUpload { object_key: String, } +#[derive(Clone, Debug)] +struct Match3DGeneratedItemModelSeed { + item_id: String, + item_name: String, + item_slug: String, + image_upload: Match3DAssetUpload, + image_bytes: Vec, + background_music: Option, + click_sound: Option, + generated_at_micros: i64, +} + struct Match3DRodinModelAsset { task_uuid: String, subscription_key: String, @@ -172,6 +204,19 @@ pub(crate) struct CompileMatch3DDraftRequest { cover_image_src: Option, } +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GenerateMatch3DWorkTagsRequest { + game_name: String, + theme_text: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GenerateMatch3DWorkTagsResponse { + tags: Vec, +} + pub async fn create_match3d_agent_session( State(state): State, Extension(request_context): Extension, @@ -571,6 +616,104 @@ pub async fn put_match3d_work( )) } +pub async fn put_match3d_audio_assets( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let existing = state + .spacetime_client() + .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + let session_id = existing.source_session_id.clone().ok_or_else(|| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": MATCH3D_WORKS_PROVIDER, + "message": "抓大鹅作品缺少来源 session,无法写回音频素材", + })), + ) + })?; + let assets = payload + .generated_item_assets + .into_iter() + .map(Match3DGeneratedItemAsset::from) + .collect::>(); + let session = upsert_match3d_draft_snapshot( + &state, + &request_context, + &authenticated, + session_id, + owner_user_id.clone(), + profile_id.clone(), + Some(existing.game_name), + Some(existing.summary), + Some(serde_json::to_string(&existing.tags).unwrap_or_default()), + existing.cover_image_src, + None, + serialize_match3d_generated_item_assets(&assets), + ) + .await?; + + let item = state + .spacetime_client() + .get_match3d_work_detail(profile_id, owner_user_id) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + let _ = session; + Ok(json_success_body( + Some(&request_context), + Match3DWorkMutationResponse { + item: map_match3d_work_profile_response(item), + }, + )) +} + +pub async fn generate_match3d_work_tags( + State(state): State, + Extension(request_context): Extension, + Extension(_authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; + let tags = generate_match3d_work_tags_for_profile( + &state, + payload.game_name.as_str(), + payload.theme_text.as_str(), + ) + .await; + + Ok(json_success_body( + Some(&request_context), + GenerateMatch3DWorkTagsResponse { tags }, + )) +} + pub async fn publish_match3d_work( State(state): State, Path(profile_id): Path, @@ -968,7 +1111,7 @@ async fn compile_match3d_draft_for_session( cover_image_src: Option, ) -> Result<(Match3DAgentSessionRecord, Vec), Response> { let owner_user_id = authenticated.claims().user_id().to_string(); - let session = state + let initial_session = state .spacetime_client() .get_match3d_agent_session(session_id.clone(), owner_user_id.clone()) .await @@ -979,14 +1122,16 @@ async fn compile_match3d_draft_for_session( map_match3d_client_error(error), ) })?; - let mut config = resolve_config_or_default(session.config.as_ref()); + let mut config = resolve_config_or_default(initial_session.config.as_ref()); config.clear_count = MATCH3D_GENERATED_CLEAR_COUNT; // 中文注释:抓大鹅入口已支持表单直创;完整表单创建的 session // 不需要再伪造三轮聊天,只要配置字段完整即可进入草稿编译。 let has_complete_form_config = !config.theme_text.trim().is_empty() && config.clear_count > 0 && (1..=10).contains(&config.difficulty); - if !has_complete_form_config && (session.current_turn < 3 || session.progress_percent < 100) { + if !has_complete_form_config + && (initial_session.current_turn < 3 || initial_session.progress_percent < 100) + { return Err(match3d_bad_request( request_context, MATCH3D_AGENT_PROVIDER, @@ -994,37 +1139,128 @@ async fn compile_match3d_draft_for_session( )); } - let tags_json = tags - .as_ref() - .map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default()); + let requested_game_name = normalize_optional_match3d_text(game_name); + let requested_summary = + normalize_optional_match3d_text(summary).or_else(|| Some(String::new())); + let requested_tags = tags.map(normalize_tags).filter(|items| !items.is_empty()); + let fallback_work_metadata = fallback_match3d_work_metadata(config.theme_text.as_str()); + let profile_id = resolve_match3d_draft_profile_id(&initial_session); + let initial_game_name = requested_game_name + .clone() + .unwrap_or_else(|| fallback_work_metadata.game_name.clone()); + let initial_tags = requested_tags + .clone() + .unwrap_or_else(|| fallback_work_metadata.tags.clone()); + let mut session = upsert_match3d_draft_snapshot( + state, + request_context, + authenticated, + session_id.clone(), + owner_user_id.clone(), + profile_id.clone(), + Some(initial_game_name), + requested_summary.clone(), + Some(serde_json::to_string(&initial_tags).unwrap_or_default()), + cover_image_src.clone(), + None, + None, + ) + .await?; - let profile_id = build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX); + if session.draft.is_none() { + return Err(match3d_error_response( + request_context, + MATCH3D_AGENT_PROVIDER, + match3d_bad_gateway("抓大鹅草稿创建失败,请稍后重试"), + )); + } + + let generated_work_metadata = generate_match3d_work_metadata(state, &config).await; + let resolved_game_name = requested_game_name.unwrap_or(generated_work_metadata.game_name); + let resolved_tags = requested_tags.unwrap_or(generated_work_metadata.tags); + session = upsert_match3d_draft_snapshot( + state, + request_context, + authenticated, + session_id, + owner_user_id.clone(), + profile_id.clone(), + Some(resolved_game_name), + requested_summary, + Some(serde_json::to_string(&resolved_tags).unwrap_or_default()), + cover_image_src, + None, + None, + ) + .await?; + + let existing_assets = get_match3d_existing_generated_item_assets( + state, + owner_user_id.as_str(), + profile_id.as_str(), + ) + .await; let generated_item_assets = generate_match3d_item_assets( state, request_context, + authenticated, owner_user_id.as_str(), session.session_id.as_str(), profile_id.as_str(), &config, + existing_assets, ) .await?; - let session = state + Ok((session, generated_item_assets)) +} + +fn resolve_match3d_draft_profile_id(session: &Match3DAgentSessionRecord) -> String { + session + .draft + .as_ref() + .map(|draft| draft.profile_id.trim()) + .filter(|profile_id| !profile_id.is_empty()) + .or_else(|| { + session + .published_profile_id + .as_deref() + .map(str::trim) + .filter(|profile_id| !profile_id.is_empty()) + }) + .map(str::to_string) + .unwrap_or_else(|| build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX)) +} + +#[allow(clippy::too_many_arguments)] +async fn upsert_match3d_draft_snapshot( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + session_id: String, + owner_user_id: String, + profile_id: String, + game_name: Option, + summary_text: Option, + tags_json: Option, + cover_image_src: Option, + cover_asset_id: Option, + generated_item_assets_json: Option, +) -> Result { + state .spacetime_client() .compile_match3d_draft(Match3DCompileDraftRecordInput { session_id, owner_user_id, profile_id, author_display_name: resolve_author_display_name(state, authenticated), - game_name: game_name.or_else(|| Some(format!("{}抓大鹅", config.theme_text))), - summary_text: summary, + game_name, + summary_text, tags_json, cover_image_src, - cover_asset_id: None, + cover_asset_id, compiled_at_micros: current_utc_micros(), - generated_item_assets_json: serialize_match3d_generated_item_assets( - &generated_item_assets, - ), + generated_item_assets_json, }) .await .map_err(|error| { @@ -1033,9 +1269,63 @@ async fn compile_match3d_draft_for_session( MATCH3D_AGENT_PROVIDER, map_match3d_client_error(error), ) - })?; + }) +} - Ok((session, generated_item_assets)) +async fn get_match3d_existing_generated_item_assets( + state: &AppState, + owner_user_id: &str, + profile_id: &str, +) -> Vec { + match state + .spacetime_client() + .get_match3d_work_detail(profile_id.to_string(), owner_user_id.to_string()) + .await + { + Ok(profile) => { + parse_match3d_generated_item_assets(profile.generated_item_assets_json.as_deref()) + .into_iter() + .map(Match3DGeneratedItemAsset::from) + .collect() + } + Err(error) => { + tracing::debug!( + provider = MATCH3D_AGENT_PROVIDER, + profile_id, + error = %error, + "读取抓大鹅已有素材失败,按空素材继续生成" + ); + Vec::new() + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn persist_match3d_generated_item_assets_snapshot( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + session_id: &str, + owner_user_id: &str, + profile_id: &str, + assets: &[Match3DGeneratedItemAsset], +) -> Result<(), Response> { + upsert_match3d_draft_snapshot( + state, + request_context, + authenticated, + session_id.to_string(), + owner_user_id.to_string(), + profile_id.to_string(), + None, + None, + None, + None, + None, + serialize_match3d_generated_item_assets(assets), + ) + .await + .map(|_| ()) } fn map_match3d_agent_session_response( @@ -1180,6 +1470,8 @@ fn map_match3d_generated_item_asset_for_agent( model_file_name: asset.model_file_name, task_uuid: asset.task_uuid, subscription_key: asset.subscription_key, + background_music: asset.background_music, + click_sound: asset.click_sound, status: asset.status, error: asset.error, } @@ -1198,6 +1490,8 @@ fn map_match3d_generated_item_asset_for_work( model_file_name: asset.model_file_name, task_uuid: asset.task_uuid, subscription_key: asset.subscription_key, + background_music: asset.background_music, + click_sound: asset.click_sound, status: asset.status, error: asset.error, } @@ -1535,11 +1829,11 @@ fn first_positive_integer(text: &str) -> Option { } fn normalize_tags(tags: Vec) -> Vec { - let mut result = Vec::new(); + let mut result: Vec = Vec::new(); for tag in tags { - let trimmed = tag.trim(); - if !trimmed.is_empty() && !result.iter().any(|value| value == trimmed) { - result.push(trimmed.to_string()); + let trimmed = normalize_match3d_tag(tag.as_str()); + if !trimmed.is_empty() && !result.iter().any(|value| value == &trimmed) { + result.push(trimmed); } if result.len() >= 6 { break; @@ -1548,6 +1842,138 @@ fn normalize_tags(tags: Vec) -> Vec { result } +fn normalize_optional_match3d_text(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn normalize_match3d_tag(value: &str) -> String { + let trimmed = value.trim(); + let without_number_prefix = trimmed + .char_indices() + .find_map(|(index, ch)| { + if index == 0 || !matches!(ch, '.' | '、' | ')' | ')') { + return None; + } + let prefix = &trimmed[..index]; + if prefix.chars().all(|candidate| candidate.is_ascii_digit()) { + Some(trimmed[index + ch.len_utf8()..].trim_start()) + } else { + None + } + }) + .unwrap_or(trimmed); + + without_number_prefix + .trim_matches(|ch: char| { + ch.is_ascii_punctuation() + || matches!( + ch, + ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' + ) + }) + .trim() + .chars() + .filter(|ch| !matches!(ch, '#' | '"' | '\'' | '`')) + .collect::() + .chars() + .take(6) + .collect::() +} + +fn normalize_match3d_tag_candidates(candidates: impl IntoIterator) -> Vec +where + S: AsRef, +{ + let mut tags = Vec::new(); + for candidate in candidates { + let normalized = normalize_match3d_tag(candidate.as_ref()); + if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) { + continue; + } + tags.push(normalized); + if tags.len() >= 6 { + break; + } + } + for fallback in ["抓大鹅", "经典消除", "3D素材", "轻量休闲", "收集", "挑战"] { + if tags.len() >= 6 { + break; + } + if !tags.iter().any(|tag| tag == fallback) { + tags.push(fallback.to_string()); + } + } + tags +} + +async fn generate_match3d_work_tags_for_profile( + state: &AppState, + game_name: &str, + theme_text: &str, +) -> Vec { + let Some(llm_client) = state + .creative_agent_gpt5_client() + .or_else(|| state.llm_client()) + else { + return fallback_match3d_work_tags(game_name, theme_text); + }; + let user_prompt = format!( + "题材设定:{}\n作品名称:{}\n请生成 3 到 6 个适合抓大鹅作品发布的中文短标签。要求:只返回 JSON 数组,每项 2 到 6 个汉字,不要解释。", + theme_text, game_name + ); + let response = llm_client + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system("你是抓大鹅作品标签编辑,只返回 JSON 字符串数组。"), + LlmMessage::user(user_prompt), + ]) + .with_model(MATCH3D_WORK_METADATA_LLM_MODEL) + .with_responses_api(), + ) + .await; + + match response { + Ok(response) => { + let tags = parse_match3d_tags_from_text(response.content.as_str()); + if tags.len() >= MATCH3D_MIN_GENERATED_TAG_COUNT { + return tags; + } + fallback_match3d_work_tags(game_name, theme_text) + } + Err(error) => { + tracing::warn!( + provider = MATCH3D_WORKS_PROVIDER, + game_name, + error = %error, + "抓大鹅 AI 标签生成失败,降级使用本地标签" + ); + fallback_match3d_work_tags(game_name, theme_text) + } + } +} + +const MATCH3D_MIN_GENERATED_TAG_COUNT: usize = 3; + +fn parse_match3d_tags_from_text(raw: &str) -> Vec { + let raw = raw.trim(); + let json_text = if let Some(start) = raw.find('[') + && let Some(end) = raw.rfind(']') + && end > start + { + &raw[start..=end] + } else { + raw + }; + let parsed = serde_json::from_str::>(json_text).unwrap_or_default(); + normalize_match3d_tag_candidates(parsed) +} + +fn fallback_match3d_work_tags(game_name: &str, theme_text: &str) -> Vec { + normalize_match3d_tag_candidates([theme_text, game_name, "抓大鹅", "经典消除", "3D素材"]) +} + fn serialize_match3d_generated_item_assets(assets: &[Match3DGeneratedItemAsset]) -> Option { if assets.is_empty() { return None; @@ -1580,6 +2006,50 @@ impl From for Match3DGeneratedItemAssetJson { model_file_name: asset.model_file_name, task_uuid: asset.task_uuid, subscription_key: asset.subscription_key, + background_music: asset.background_music, + click_sound: asset.click_sound, + status: asset.status, + error: asset.error, + } + } +} + +impl From for Match3DGeneratedItemAsset { + fn from(asset: Match3DGeneratedItemAssetJson) -> Self { + Self { + item_id: asset.item_id, + item_name: asset.item_name, + image_src: asset.image_src, + image_object_key: asset.image_object_key, + model_src: asset.model_src, + model_object_key: asset.model_object_key, + model_file_name: asset.model_file_name, + task_uuid: asset.task_uuid, + subscription_key: asset.subscription_key, + background_music: asset.background_music, + click_sound: asset.click_sound, + status: asset.status, + error: asset.error, + } + } +} + +impl From + for Match3DGeneratedItemAsset +{ + fn from(asset: shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse) -> Self { + Self { + item_id: asset.item_id, + item_name: asset.item_name, + image_src: asset.image_src, + image_object_key: asset.image_object_key, + model_src: asset.model_src, + model_object_key: asset.model_object_key, + model_file_name: asset.model_file_name, + task_uuid: asset.task_uuid, + subscription_key: asset.subscription_key, + background_music: asset.background_music, + click_sound: asset.click_sound, status: asset.status, error: asset.error, } @@ -1603,12 +2073,78 @@ fn resolve_author_display_name( async fn generate_match3d_item_assets( state: &AppState, request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, owner_user_id: &str, session_id: &str, profile_id: &str, config: &Match3DConfigJson, + existing_assets: Vec, ) -> Result, Response> { // 中文注释:外部模型、下载和 OSS 写入都留在 api-server,SpacetimeDB reducer 只保存确定性草稿。 + if existing_assets + .iter() + .filter(|asset| is_match3d_generated_asset_model_ready(asset)) + .count() + >= MATCH3D_GENERATED_ITEM_COUNT + { + return Ok(sort_match3d_generated_assets(existing_assets) + .into_iter() + .take(MATCH3D_GENERATED_ITEM_COUNT) + .collect()); + } + + let mut assets = normalize_match3d_generated_item_assets_for_resume(existing_assets); + if !has_match3d_required_item_images(&assets) { + assets = ensure_match3d_item_image_assets( + state, + request_context, + authenticated, + owner_user_id, + session_id, + profile_id, + config, + assets, + ) + .await?; + } + + let assets = fill_match3d_item_models( + state, + request_context, + authenticated, + owner_user_id, + session_id, + profile_id, + config, + assets, + ) + .await?; + persist_match3d_generated_item_assets_snapshot( + state, + request_context, + authenticated, + session_id, + owner_user_id, + profile_id, + &assets, + ) + .await?; + + Ok(assets) +} + +#[allow(clippy::too_many_arguments)] +async fn ensure_match3d_item_image_assets( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + config: &Match3DConfigJson, + existing_assets: Vec, +) -> Result, Response> { + let mut assets = normalize_match3d_generated_item_assets_for_resume(existing_assets); let item_names = generate_match3d_item_names(state, config) .await .map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?; @@ -1634,17 +2170,19 @@ async fn generate_match3d_item_assets( let item_images = slice_match3d_material_sheet(&material_sheet.image, &item_names) .map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?; - let mut item_assets = Vec::with_capacity(item_images.len()); for (index, item_image) in item_images.into_iter().enumerate() { let item_name = item_names .get(index) .cloned() .unwrap_or_else(|| format!("物品{}", index + 1)); let item_id = format!("match3d-item-{}", index + 1); - let item_slug = format!( - "{item_id}-{}", - sanitize_match3d_asset_segment(&item_name, "item") - ); + if assets + .iter() + .any(|asset| asset.item_id == item_id && is_match3d_generated_asset_image_ready(asset)) + { + continue; + } + let item_slug = build_match3d_item_slug(item_id.as_str(), item_name.as_str()); let image_bytes = item_image.bytes; let image_upload = persist_match3d_generated_bytes( state, @@ -1661,36 +2199,192 @@ async fn generate_match3d_item_assets( ) .await .map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?; - let model_asset = generate_match3d_rodin_model_asset( + upsert_match3d_generated_item_asset( + &mut assets, + Match3DGeneratedItemAsset { + item_id, + item_name, + image_src: Some(image_upload.src), + image_object_key: Some(image_upload.object_key), + model_src: None, + model_object_key: None, + model_file_name: None, + task_uuid: None, + subscription_key: None, + background_music: None, + click_sound: None, + status: "image_ready".to_string(), + error: None, + }, + ); + persist_match3d_generated_item_assets_snapshot( state, - owner_user_id, + request_context, + authenticated, session_id, + owner_user_id, profile_id, - &item_slug, - &item_name, - config, - image_bytes, - generated_at_micros.saturating_add(100 + index as i64), + &assets, + ) + .await?; + } + + Ok(assets) +} + +#[allow(clippy::too_many_arguments)] +async fn fill_match3d_item_models( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + config: &Match3DConfigJson, + assets: Vec, +) -> Result, Response> { + let mut assets = normalize_match3d_generated_item_assets_for_resume(assets); + let mut model_seeds = Vec::new(); + for (index, asset) in assets.iter().enumerate() { + if is_match3d_generated_asset_model_ready(asset) { + continue; + } + let Some(image_object_key) = asset.image_object_key.as_deref() else { + continue; + }; + let image_bytes = read_match3d_generated_object_bytes( + state, + image_object_key, + "读取抓大鹅物品参考图失败", + MATCH3D_ITEM_IMAGE_MAX_BYTES, ) .await .map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?; - item_assets.push(Match3DGeneratedItemAsset { - item_id, - item_name, - image_src: Some(image_upload.src), - image_object_key: Some(image_upload.object_key), - model_src: Some(model_asset.upload.src), - model_object_key: Some(model_asset.upload.object_key), - model_file_name: Some(model_asset.model_file_name), - task_uuid: Some(model_asset.task_uuid), - subscription_key: Some(model_asset.subscription_key), - status: "model_ready".to_string(), - error: None, + let item_slug = build_match3d_item_slug(asset.item_id.as_str(), asset.item_name.as_str()); + model_seeds.push(Match3DGeneratedItemModelSeed { + item_id: asset.item_id.clone(), + item_name: asset.item_name.clone(), + item_slug, + image_upload: Match3DAssetUpload { + src: asset + .image_src + .clone() + .unwrap_or_else(|| format!("/{image_object_key}")), + object_key: image_object_key.to_string(), + }, + image_bytes, + background_music: asset.background_music.clone(), + click_sound: asset.click_sound.clone(), + generated_at_micros: current_utc_micros().saturating_add(100 + index as i64), }); } + if model_seeds.is_empty() { + return Ok(assets); + } + + // 中文注释:Rodin 单个模型耗时不可控,必须在图片切割和入库后并行提交所有图生模型, + // 避免多个物品的排队和轮询时间串行叠加导致 action 超时。 + let mut model_tasks = model_seeds + .into_iter() + .map(|seed| async move { + let Match3DGeneratedItemModelSeed { + item_id, + item_name, + item_slug, + image_upload, + image_bytes, + background_music, + click_sound, + generated_at_micros, + } = seed; + let model_result = generate_match3d_rodin_model_asset( + state, + owner_user_id, + session_id, + profile_id, + item_slug.as_str(), + item_name.as_str(), + config, + image_bytes, + generated_at_micros, + ) + .await; + + match model_result { + Ok(model_asset) => Match3DGeneratedItemAsset { + item_id, + item_name, + image_src: Some(image_upload.src), + image_object_key: Some(image_upload.object_key), + model_src: Some(model_asset.upload.src), + model_object_key: Some(model_asset.upload.object_key), + model_file_name: Some(model_asset.model_file_name), + task_uuid: Some(model_asset.task_uuid), + subscription_key: Some(model_asset.subscription_key), + background_music, + click_sound, + status: "model_ready".to_string(), + error: None, + }, + Err(error) => Match3DGeneratedItemAsset { + item_id, + item_name, + image_src: Some(image_upload.src), + image_object_key: Some(image_upload.object_key), + model_src: None, + model_object_key: None, + model_file_name: None, + task_uuid: None, + subscription_key: None, + background_music, + click_sound, + status: "model_failed".to_string(), + error: Some(error.to_string()), + }, + } + }) + .collect::>(); + + while let Some(model_asset) = model_tasks.next().await { + upsert_match3d_generated_item_asset(&mut assets, model_asset); + persist_match3d_generated_item_assets_snapshot( + state, + request_context, + authenticated, + session_id, + owner_user_id, + profile_id, + &assets, + ) + .await?; + } + + let failed_items = assets + .iter() + .filter(|asset| !is_match3d_generated_asset_model_ready(asset)) + .filter_map(|asset| { + asset + .error + .as_deref() + .map(str::trim) + .filter(|error| !error.is_empty()) + .map(|error| format!("{}:{error}", asset.item_name)) + }) + .collect::>(); + if !failed_items.is_empty() { + return Err(match3d_error_response( + request_context, + MATCH3D_AGENT_PROVIDER, + match3d_bad_gateway(format!( + "抓大鹅 3D 模型生成未完成:{}", + failed_items.join(";") + )), + )); + } + // 中文注释:草稿阶段必须同时产出 GLB 模型,结果页直接加载模型预览。 - Ok(item_assets) + Ok(assets) } struct Match3DMaterialSheet { @@ -1702,6 +2396,93 @@ struct Match3DSlicedItemImage { bytes: Vec, } +async fn generate_match3d_work_metadata( + state: &AppState, + config: &Match3DConfigJson, +) -> Match3DGeneratedWorkMetadata { + let Some(llm_client) = state + .creative_agent_gpt5_client() + .or_else(|| state.llm_client()) + else { + return fallback_match3d_work_metadata(config.theme_text.as_str()); + }; + let system_prompt = "你是抓大鹅游戏的作品命名编辑,只返回 JSON。"; + let user_prompt = format!( + "题材设定:{}\n请生成抓大鹅游戏作品元信息。要求:只返回 JSON 对象,字段为 gameName、tags。gameName 为 4 到 12 个中文字符,不要包含“作品”“游戏”;tags 为 3 到 6 个中文短标签,每个 2 到 6 个汉字。不要生成描述。", + config.theme_text + ); + let response = llm_client + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(system_prompt), + LlmMessage::user(user_prompt), + ]) + .with_model(MATCH3D_WORK_METADATA_LLM_MODEL) + .with_responses_api(), + ) + .await; + + match response { + Ok(response) => parse_match3d_work_metadata(response.content.as_str()) + .unwrap_or_else(|| fallback_match3d_work_metadata(config.theme_text.as_str())), + Err(error) => { + tracing::warn!( + provider = MATCH3D_AGENT_PROVIDER, + theme_text = config.theme_text.as_str(), + error = %error, + "抓大鹅作品名称生成失败,降级使用本地元信息" + ); + fallback_match3d_work_metadata(config.theme_text.as_str()) + } + } +} + +fn parse_match3d_work_metadata(raw: &str) -> Option { + let raw = raw.trim(); + let json_text = if let Some(start) = raw.find('{') + && let Some(end) = raw.rfind('}') + && end > start + { + &raw[start..=end] + } else { + raw + }; + let value = serde_json::from_str::(json_text).ok()?; + let game_name = normalize_match3d_game_name(value.get("gameName")?.as_str()?); + if game_name.is_empty() { + return None; + } + let tags = value + .get("tags") + .and_then(Value::as_array) + .map(|items| normalize_match3d_tag_candidates(items.iter().filter_map(Value::as_str))) + .unwrap_or_default(); + Some(Match3DGeneratedWorkMetadata { + game_name, + tags: normalize_match3d_tag_candidates(tags), + }) +} + +fn normalize_match3d_game_name(raw: &str) -> String { + raw.trim() + .trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、']) + .chars() + .filter(|character| !character.is_control()) + .take(16) + .collect::() + .trim() + .to_string() +} + +fn fallback_match3d_work_metadata(theme_text: &str) -> Match3DGeneratedWorkMetadata { + let theme = theme_text.trim(); + let normalized_theme = if theme.is_empty() { "主题" } else { theme }; + Match3DGeneratedWorkMetadata { + game_name: format!("{normalized_theme}抓大鹅"), + tags: normalize_match3d_tag_candidates([normalized_theme, "抓大鹅", "经典消除", "3D素材"]), + } +} + async fn generate_match3d_item_names( state: &AppState, config: &Match3DConfigJson, @@ -1780,6 +2561,102 @@ fn fallback_match3d_item_names(theme_text: &str) -> Vec { .collect() } +fn normalize_match3d_generated_item_assets_for_resume( + assets: Vec, +) -> Vec { + let mut normalized = Vec::new(); + for asset in sort_match3d_generated_assets(assets) { + if asset.item_id.trim().is_empty() + || normalized + .iter() + .any(|candidate: &Match3DGeneratedItemAsset| candidate.item_id == asset.item_id) + { + continue; + } + normalized.push(asset); + if normalized.len() >= MATCH3D_GENERATED_ITEM_COUNT { + break; + } + } + normalized +} + +fn sort_match3d_generated_assets( + mut assets: Vec, +) -> Vec { + assets.sort_by(|left, right| { + match3d_item_sort_index(left.item_id.as_str()) + .cmp(&match3d_item_sort_index(right.item_id.as_str())) + .then_with(|| left.item_id.cmp(&right.item_id)) + }); + assets +} + +fn match3d_item_sort_index(item_id: &str) -> u32 { + item_id + .rsplit('-') + .next() + .and_then(|value| value.parse::().ok()) + .unwrap_or(u32::MAX) +} + +fn is_match3d_generated_asset_model_ready(asset: &Match3DGeneratedItemAsset) -> bool { + asset.status == "model_ready" + && (asset + .model_object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + || asset + .model_src + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some()) +} + +fn is_match3d_generated_asset_image_ready(asset: &Match3DGeneratedItemAsset) -> bool { + asset + .image_object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() +} + +fn has_match3d_required_item_images(assets: &[Match3DGeneratedItemAsset]) -> bool { + assets.len() >= MATCH3D_GENERATED_ITEM_COUNT + && assets + .iter() + .take(MATCH3D_GENERATED_ITEM_COUNT) + .all(is_match3d_generated_asset_image_ready) +} + +fn upsert_match3d_generated_item_asset( + assets: &mut Vec, + asset: Match3DGeneratedItemAsset, +) { + if let Some(current) = assets + .iter_mut() + .find(|candidate| candidate.item_id == asset.item_id) + { + *current = asset; + *assets = sort_match3d_generated_assets(std::mem::take(assets)); + return; + } + assets.push(asset); + *assets = sort_match3d_generated_assets(std::mem::take(assets)); +} + +fn build_match3d_item_slug(item_id: &str, item_name: &str) -> String { + format!( + "{}-{}", + sanitize_match3d_asset_segment(item_id, "match3d-item"), + sanitize_match3d_asset_segment(item_name, "item") + ) +} + async fn generate_match3d_material_sheet( state: &AppState, config: &Match3DConfigJson, @@ -1825,6 +2702,15 @@ async fn generate_match3d_rodin_model_asset( generated_at_micros: i64, ) -> Result { let image_data_url = build_match3d_png_data_url(&image_bytes); + tracing::info!( + provider = MATCH3D_AGENT_PROVIDER, + session_id, + profile_id, + item_slug, + item_name, + image_bytes = image_bytes.len(), + "抓大鹅 Rodin 图生模型提交开始" + ); let submit_response = submit_image_to_model( state, hyper3d_contract::Hyper3dImageToModelRequest { @@ -1843,26 +2729,50 @@ async fn generate_match3d_rodin_model_asset( }, ) .await?; + tracing::info!( + provider = MATCH3D_AGENT_PROVIDER, + session_id, + profile_id, + item_slug, + item_name, + task_uuid = submit_response.task_uuid.as_str(), + subscription_key_len = submit_response.subscription_key.len(), + job_count = submit_response.job_uuids.len(), + "抓大鹅 Rodin 图生模型提交完成" + ); wait_for_match3d_rodin_model( state, submit_response.subscription_key.as_str(), - item_name, - ) - .await?; - let download_response = query_downloads( - state, - hyper3d_contract::Hyper3dDownloadRequest { - task_uuid: submit_response.task_uuid.clone(), - }, - ) - .await?; - let model_file = select_match3d_glb_download( - &download_response.files, submit_response.task_uuid.as_str(), item_name, - )?; - let downloaded_model = download_match3d_rodin_model(model_file).await?; + ) + .await?; + let model_file = + wait_for_match3d_rodin_download_file(state, submit_response.task_uuid.as_str(), item_name) + .await?; + tracing::info!( + provider = MATCH3D_AGENT_PROVIDER, + session_id, + profile_id, + item_slug, + item_name, + task_uuid = submit_response.task_uuid.as_str(), + file_name = model_file.name.as_str(), + "抓大鹅 Rodin 下载文件已选中" + ); + let downloaded_model = download_match3d_rodin_model(&model_file).await?; + tracing::info!( + provider = MATCH3D_AGENT_PROVIDER, + session_id, + profile_id, + item_slug, + item_name, + task_uuid = submit_response.task_uuid.as_str(), + file_name = downloaded_model.file_name.as_str(), + bytes = downloaded_model.bytes.len(), + "抓大鹅 Rodin GLB 下载完成" + ); let uploaded_model = persist_match3d_generated_bytes( state, owner_user_id, @@ -1882,6 +2792,16 @@ async fn generate_match3d_rodin_model_asset( generated_at_micros, ) .await?; + tracing::info!( + provider = MATCH3D_AGENT_PROVIDER, + session_id, + profile_id, + item_slug, + item_name, + task_uuid = submit_response.task_uuid.as_str(), + object_key = uploaded_model.object_key.as_str(), + "抓大鹅 Rodin GLB 转存 OSS 完成" + ); Ok(Match3DRodinModelAsset { task_uuid: submit_response.task_uuid, @@ -1912,6 +2832,7 @@ fn build_match3d_rodin_model_prompt(config: &Match3DConfigJson, item_name: &str) async fn wait_for_match3d_rodin_model( state: &AppState, subscription_key: &str, + task_uuid: &str, item_name: &str, ) -> Result<(), AppError> { for attempt in 0..MATCH3D_RODIN_STATUS_MAX_ATTEMPTS { @@ -1922,8 +2843,31 @@ async fn wait_for_match3d_rodin_model( }, ) .await?; + let should_log_progress = attempt == 0 + || matches!(status_response.status.as_str(), "done" | "failed") + || (attempt + 1) % 12 == 0; + if should_log_progress { + tracing::info!( + provider = MATCH3D_AGENT_PROVIDER, + item_name, + task_uuid, + attempt = attempt + 1, + status = status_response.status.as_str(), + job_count = status_response.jobs.len(), + "抓大鹅 Rodin 状态轮询返回" + ); + } match status_response.status.as_str() { - "done" => return Ok(()), + "done" => { + tracing::info!( + provider = MATCH3D_AGENT_PROVIDER, + item_name, + task_uuid, + attempts = attempt + 1, + "抓大鹅 Rodin 任务状态完成" + ); + return Ok(()); + } "failed" => { let message = status_response .jobs @@ -1940,10 +2884,7 @@ async fn wait_for_match3d_rodin_model( } if attempt + 1 < MATCH3D_RODIN_STATUS_MAX_ATTEMPTS { - tokio::time::sleep(Duration::from_millis( - MATCH3D_RODIN_STATUS_POLL_INTERVAL_MS, - )) - .await; + tokio::time::sleep(Duration::from_millis(MATCH3D_RODIN_STATUS_POLL_INTERVAL_MS)).await; } } @@ -1952,29 +2893,112 @@ async fn wait_for_match3d_rodin_model( ))) } -fn select_match3d_glb_download<'a>( - files: &'a [hyper3d_contract::Hyper3dDownloadFilePayload], +async fn wait_for_match3d_rodin_download_file( + state: &AppState, task_uuid: &str, item_name: &str, -) -> Result<&'a hyper3d_contract::Hyper3dDownloadFilePayload, AppError> { +) -> Result { + for attempt in 0..MATCH3D_RODIN_DOWNLOAD_MAX_ATTEMPTS { + let download_response = query_downloads( + state, + hyper3d_contract::Hyper3dDownloadRequest { + task_uuid: task_uuid.to_string(), + }, + ) + .await?; + let should_log_progress = + attempt == 0 || !download_response.files.is_empty() || (attempt + 1) % 6 == 0; + if should_log_progress { + tracing::info!( + provider = MATCH3D_AGENT_PROVIDER, + item_name, + task_uuid, + attempt = attempt + 1, + file_count = download_response.files.len(), + "抓大鹅 Rodin 下载列表轮询返回" + ); + } + if let Some(model_file) = find_match3d_glb_download(&download_response.files) { + tracing::info!( + provider = MATCH3D_AGENT_PROVIDER, + item_name, + task_uuid, + attempt = attempt + 1, + file_name = model_file.name.as_str(), + "抓大鹅 Rodin 下载列表已返回模型文件" + ); + return Ok(model_file.clone()); + } + + // 中文注释:Rodin 状态 Done 后下载列表偶尔会延迟发布,短轮询避免把已完成任务误判失败。 + if attempt + 1 < MATCH3D_RODIN_DOWNLOAD_MAX_ATTEMPTS { + tokio::time::sleep(Duration::from_millis( + MATCH3D_RODIN_DOWNLOAD_POLL_INTERVAL_MS, + )) + .await; + } + } + + Err(match3d_bad_gateway(format!( + "{item_name} 3D 模型已完成但未返回可下载模型文件:{task_uuid}" + ))) +} + +fn find_match3d_glb_download( + files: &[hyper3d_contract::Hyper3dDownloadFilePayload], +) -> Option<&hyper3d_contract::Hyper3dDownloadFilePayload> { files .iter() - .find(|file| { - file.name.to_ascii_lowercase().ends_with(".glb") - || file.url.to_ascii_lowercase().split('?').next().unwrap_or("").ends_with(".glb") - }) - .or_else(|| files.first()) - .ok_or_else(|| { - match3d_bad_gateway(format!( - "{item_name} 3D 模型已完成但未返回可下载模型文件:{task_uuid}" - )) + .find(|file| match3d_download_file_has_extension(file, ".glb")) + .or_else(|| { + files + .iter() + .find(|file| !is_match3d_preview_or_image_download(file)) }) } +fn match3d_download_file_has_extension( + file: &hyper3d_contract::Hyper3dDownloadFilePayload, + extension: &str, +) -> bool { + file.name.to_ascii_lowercase().ends_with(extension) + || file + .url + .to_ascii_lowercase() + .split('?') + .next() + .unwrap_or("") + .ends_with(extension) +} + +fn is_match3d_preview_or_image_download( + file: &hyper3d_contract::Hyper3dDownloadFilePayload, +) -> bool { + let name = file.name.to_ascii_lowercase(); + let url_path = file.url.to_ascii_lowercase(); + let url_path = url_path.split('?').next().unwrap_or(url_path.as_str()); + name.contains("preview") + || url_path.contains("preview") + || [".png", ".jpg", ".jpeg", ".webp", ".gif"] + .iter() + .any(|extension| name.ends_with(extension) || url_path.ends_with(extension)) +} + async fn download_match3d_rodin_model( file: &hyper3d_contract::Hyper3dDownloadFilePayload, ) -> Result { - let response = reqwest::Client::new() + let http_client = reqwest::Client::builder() + .timeout(Duration::from_millis( + MATCH3D_RODIN_MODEL_DOWNLOAD_TIMEOUT_MS, + )) + .build() + .map_err(|error| match3d_bad_gateway(format!("构造 Rodin 模型下载客户端失败:{error}")))?; + tracing::info!( + provider = MATCH3D_AGENT_PROVIDER, + file_name = file.name.as_str(), + "抓大鹅 Rodin GLB 下载开始" + ); + let response = http_client .get(file.url.as_str()) .send() .await @@ -1996,6 +3020,9 @@ async fn download_match3d_rodin_model( status.as_u16() ))); } + if !is_match3d_downloaded_model_payload(file.name.as_str(), content_type.as_str()) { + return Err(match3d_bad_gateway("Rodin 下载结果不是 GLB 模型文件")); + } if bytes.is_empty() || bytes.len() > MATCH3D_RODIN_MAX_MODEL_BYTES { return Err(match3d_bad_gateway("Rodin 模型内容为空或超过大小上限")); } @@ -2007,25 +3034,96 @@ async fn download_match3d_rodin_model( }) } +fn is_match3d_downloaded_model_payload(file_name: &str, content_type: &str) -> bool { + let normalized_file_name = file_name.to_ascii_lowercase(); + let normalized_content_type = content_type + .split(';') + .next() + .unwrap_or(content_type) + .trim() + .to_ascii_lowercase(); + normalized_file_name.ends_with(".glb") + || matches!( + normalized_content_type.as_str(), + "model/gltf-binary" | "application/octet-stream" + ) +} + fn normalize_match3d_model_file_name(raw: &str) -> String { let trimmed = raw.trim().rsplit('/').next().unwrap_or(raw).trim(); let without_query = trimmed.split('?').next().unwrap_or(trimmed).trim(); - let sanitized = sanitize_match3d_asset_segment(without_query, "model"); - if sanitized.to_ascii_lowercase().ends_with(".glb") { - sanitized - } else { - "model.glb".to_string() - } + let normalized = without_query.to_ascii_lowercase(); + let stem = without_query + .strip_suffix(".glb") + .or_else(|| { + normalized + .strip_suffix(".glb") + .map(|_| &without_query[..without_query.len().saturating_sub(4)]) + }) + .unwrap_or(without_query); + let sanitized_stem = sanitize_match3d_asset_segment(stem, "model"); + format!("{sanitized_stem}.glb") } fn normalize_match3d_model_content_type(raw: &str) -> String { let normalized = raw.split(';').next().unwrap_or(raw).trim().to_lowercase(); - if normalized == "model/gltf-binary" || normalized == "application/octet-stream" { + if normalized == "model/gltf-binary" { return normalized; } "model/gltf-binary".to_string() } +async fn read_match3d_generated_object_bytes( + state: &AppState, + object_key: &str, + message_prefix: &str, + max_size_bytes: usize, +) -> Result, AppError> { + let object_key = object_key.trim().trim_start_matches('/'); + if object_key.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "match3d-assets", + "message": format!("{message_prefix}:objectKey 不能为空"), + })), + ); + } + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + let signed = oss_client + .sign_get_object_url(platform_oss::OssSignedGetObjectUrlRequest { + object_key: object_key.to_string(), + expire_seconds: Some(300), + }) + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let response = reqwest::Client::new() + .get(signed.signed_url.as_str()) + .send() + .await + .map_err(|error| match3d_bad_gateway(format!("{message_prefix}:{error}")))?; + let status = response.status(); + if !status.is_success() { + return Err(match3d_bad_gateway(format!( + "{message_prefix}:HTTP {}", + status.as_u16() + ))); + } + let bytes = response + .bytes() + .await + .map_err(|error| match3d_bad_gateway(format!("{message_prefix}:{error}")))?; + if bytes.is_empty() || bytes.len() > max_size_bytes { + return Err(match3d_bad_gateway(format!( + "{message_prefix}:内容为空或超过大小上限" + ))); + } + Ok(bytes.to_vec()) +} + fn build_match3d_material_sheet_prompt( config: &Match3DConfigJson, item_names: &[String], @@ -2133,9 +3231,13 @@ async fn persist_match3d_generated_bytes( ); } + let oss_http_client = reqwest::Client::builder() + .timeout(Duration::from_millis(MATCH3D_OSS_PUT_TIMEOUT_MS)) + .build() + .map_err(|error| match3d_bad_gateway(format!("构造抓大鹅 OSS 上传客户端失败:{error}")))?; let put_result = oss_client .put_object( - &reqwest::Client::new(), + &oss_http_client, OssPutObjectRequest { prefix: LegacyAssetPrefix::Match3DAssets, path_segments: std::iter::once(session_id) @@ -2445,6 +3547,202 @@ mod tests { ); } + #[test] + fn match3d_work_metadata_parses_gpt4o_json() { + let metadata = parse_match3d_work_metadata( + r#"{"gameName":"果园大鹅宴","tags":["水果","抓大鹅","经典消除","轻量休闲"]}"#, + ) + .expect("metadata should parse"); + + assert_eq!(metadata.game_name, "果园大鹅宴"); + assert_eq!( + metadata.tags, + vec!["水果", "抓大鹅", "经典消除", "轻量休闲", "3D素材", "收集"] + ); + } + + #[test] + fn match3d_work_metadata_fallback_keeps_empty_description_boundary() { + let metadata = fallback_match3d_work_metadata("水果"); + + assert_eq!(metadata.game_name, "水果抓大鹅"); + assert!(metadata.tags.contains(&"水果".to_string())); + assert!(metadata.tags.contains(&"抓大鹅".to_string())); + } + + #[test] + fn match3d_tag_normalization_only_strips_numbered_list_prefix() { + assert_eq!(normalize_match3d_tag("3D素材"), "3D素材"); + assert_eq!(normalize_match3d_tag("1. 3D素材"), "3D素材"); + assert_eq!(normalize_match3d_tag("2、轻量休闲"), "轻量休闲"); + } + + #[test] + fn match3d_model_download_metadata_normalizes_to_glb() { + assert_eq!( + normalize_match3d_model_file_name("https://example.com/Fruit Model.GLB?token=1"), + "fruit-model.glb" + ); + assert_eq!(normalize_match3d_model_file_name("模型文件"), "model.glb"); + assert_eq!( + normalize_match3d_model_content_type("application/octet-stream"), + "model/gltf-binary" + ); + assert_eq!( + normalize_match3d_model_content_type("model/gltf-binary; charset=utf-8"), + "model/gltf-binary" + ); + } + + #[test] + fn match3d_generated_asset_resume_keeps_ready_models_first() { + let assets = normalize_match3d_generated_item_assets_for_resume(vec![ + Match3DGeneratedItemAsset { + item_id: "match3d-item-2".to_string(), + item_name: "苹果".to_string(), + image_src: Some("/generated-match3d-assets/s/p/items/i2/image.png".to_string()), + image_object_key: Some( + "generated-match3d-assets/s/p/items/i2/image.png".to_string(), + ), + model_src: Some( + "/generated-match3d-assets/s/p/items/i2/model/model.glb".to_string(), + ), + model_object_key: Some( + "generated-match3d-assets/s/p/items/i2/model/model.glb".to_string(), + ), + model_file_name: Some("model.glb".to_string()), + task_uuid: Some("task-2".to_string()), + subscription_key: Some("sub-2".to_string()), + background_music: None, + click_sound: None, + status: "model_ready".to_string(), + error: None, + }, + Match3DGeneratedItemAsset { + item_id: "match3d-item-1".to_string(), + item_name: "草莓".to_string(), + image_src: Some("/generated-match3d-assets/s/p/items/i1/image.png".to_string()), + image_object_key: Some( + "generated-match3d-assets/s/p/items/i1/image.png".to_string(), + ), + model_src: None, + model_object_key: None, + model_file_name: None, + task_uuid: None, + subscription_key: None, + background_music: None, + click_sound: None, + status: "image_ready".to_string(), + error: None, + }, + ]); + + assert_eq!(assets[0].item_id, "match3d-item-1"); + assert_eq!(assets[1].item_id, "match3d-item-2"); + assert!(is_match3d_generated_asset_model_ready(&assets[1])); + assert!(!is_match3d_generated_asset_model_ready(&assets[0])); + } + + #[test] + fn match3d_required_item_images_require_object_keys() { + let assets = vec![ + Match3DGeneratedItemAsset { + item_id: "match3d-item-1".to_string(), + item_name: "草莓".to_string(), + image_src: Some("/generated-match3d-assets/s/p/items/i1/image.png".to_string()), + image_object_key: Some( + "generated-match3d-assets/s/p/items/i1/image.png".to_string(), + ), + model_src: None, + model_object_key: None, + model_file_name: None, + task_uuid: None, + subscription_key: None, + background_music: None, + click_sound: None, + status: "image_ready".to_string(), + error: None, + }, + Match3DGeneratedItemAsset { + item_id: "match3d-item-2".to_string(), + item_name: "苹果".to_string(), + image_src: Some("/generated-match3d-assets/s/p/items/i2/image.png".to_string()), + image_object_key: Some( + "generated-match3d-assets/s/p/items/i2/image.png".to_string(), + ), + model_src: None, + model_object_key: None, + model_file_name: None, + task_uuid: None, + subscription_key: None, + background_music: None, + click_sound: None, + status: "image_ready".to_string(), + error: None, + }, + Match3DGeneratedItemAsset { + item_id: "match3d-item-3".to_string(), + item_name: "香蕉".to_string(), + image_src: Some("/generated-match3d-assets/s/p/items/i3/image.png".to_string()), + image_object_key: None, + model_src: None, + model_object_key: None, + model_file_name: None, + task_uuid: None, + subscription_key: None, + background_music: None, + click_sound: None, + status: "image_ready".to_string(), + error: None, + }, + ]; + + assert!(!has_match3d_required_item_images(&assets)); + } + + #[test] + fn match3d_model_download_prefers_glb_file() { + let files = vec![ + hyper3d_contract::Hyper3dDownloadFilePayload { + name: "preview.png".to_string(), + url: "https://cdn.example/preview.png".to_string(), + }, + hyper3d_contract::Hyper3dDownloadFilePayload { + name: "model".to_string(), + url: "https://cdn.example/model.glb?token=1".to_string(), + }, + ]; + + let selected = find_match3d_glb_download(&files).expect("glb download should be selected"); + + assert_eq!(selected.url, "https://cdn.example/model.glb?token=1"); + } + + #[test] + fn match3d_model_download_falls_back_to_first_file() { + let files = vec![hyper3d_contract::Hyper3dDownloadFilePayload { + name: "model".to_string(), + url: "https://cdn.example/download?id=1".to_string(), + }]; + + let selected = + find_match3d_glb_download(&files).expect("opaque download url should be accepted"); + + assert_eq!(selected.url, "https://cdn.example/download?id=1"); + } + + #[test] + fn match3d_model_download_does_not_accept_preview_image_only() { + let files = vec![hyper3d_contract::Hyper3dDownloadFilePayload { + name: "preview.png".to_string(), + url: "https://cdn.example/preview.png".to_string(), + }]; + + let result = find_match3d_glb_download(&files); + + assert!(result.is_none()); + } + #[test] fn match3d_work_summary_maps_persisted_generated_item_assets() { let response = map_match3d_work_summary_response(Match3DWorkProfileRecord { diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 0be38519..37797e7f 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -26,6 +26,7 @@ use platform_oss::{ }; use serde_json::{Map, Value, json}; use shared_contracts::{ + creation_audio::CreationAudioAsset, puzzle_agent::{ CreatePuzzleAgentSessionRequest, ExecutePuzzleAgentActionRequest, PuzzleAgentActionResponse, PuzzleAgentMessageResponse, PuzzleAgentOperationResponse, @@ -56,7 +57,7 @@ use spacetime_client::{ PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, - PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, + PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, @@ -228,6 +229,7 @@ pub async fn generate_puzzle_onboarding_work( level_name: level_name.clone(), picture_description: prompt_text.clone(), picture_reference: None, + background_music: None, candidates, selected_candidate_id: Some(selected.candidate_id.clone()), cover_image_src: Some(selected.image_src.clone()), @@ -2059,6 +2061,7 @@ fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraft level_name: level.level_name, picture_description: level.picture_description, picture_reference: level.picture_reference, + background_music: level.background_music.map(map_puzzle_audio_asset_record_response), candidates: level .candidates .into_iter() @@ -2071,6 +2074,70 @@ fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraft } } +fn map_puzzle_audio_asset_record_response(asset: PuzzleAudioAssetRecord) -> CreationAudioAsset { + CreationAudioAsset { + task_id: asset.task_id, + provider: asset.provider, + asset_object_id: asset.asset_object_id, + asset_kind: asset.asset_kind, + audio_src: asset.audio_src, + prompt: asset.prompt, + title: asset.title, + updated_at: asset.updated_at, + } +} + +fn map_puzzle_audio_asset_domain_record( + asset: module_puzzle::PuzzleAudioAsset, +) -> PuzzleAudioAssetRecord { + PuzzleAudioAssetRecord { + task_id: asset.task_id, + provider: asset.provider, + asset_object_id: asset.asset_object_id, + asset_kind: asset.asset_kind, + audio_src: asset.audio_src, + prompt: asset.prompt, + title: asset.title, + updated_at: asset.updated_at, + } +} + +fn puzzle_audio_asset_response_module_json(asset: &Option) -> Value { + asset + .as_ref() + .map(|asset| { + json!({ + "task_id": asset.task_id, + "provider": asset.provider, + "asset_object_id": asset.asset_object_id, + "asset_kind": asset.asset_kind, + "audio_src": asset.audio_src, + "prompt": asset.prompt, + "title": asset.title, + "updated_at": asset.updated_at, + }) + }) + .unwrap_or(Value::Null) +} + +fn puzzle_audio_asset_record_module_json(asset: &Option) -> Value { + asset + .as_ref() + .map(|asset| { + json!({ + "task_id": asset.task_id, + "provider": asset.provider, + "asset_object_id": asset.asset_object_id, + "asset_kind": asset.asset_kind, + "audio_src": asset.audio_src, + "prompt": asset.prompt, + "title": asset.title, + "updated_at": asset.updated_at, + }) + }) + .unwrap_or(Value::Null) +} + fn map_puzzle_creator_intent_response( intent: PuzzleCreatorIntentRecord, ) -> PuzzleCreatorIntentResponse { @@ -2600,6 +2667,7 @@ fn parse_puzzle_level_records_from_module_json( level_name: level.level_name, picture_description: level.picture_description, picture_reference: level.picture_reference, + background_music: level.background_music.map(map_puzzle_audio_asset_domain_record), candidates: level .candidates .into_iter() @@ -2767,6 +2835,7 @@ fn serialize_puzzle_levels_response( "level_name": level.level_name, "picture_description": level.picture_description, "picture_reference": level.picture_reference, + "background_music": puzzle_audio_asset_response_module_json(&level.background_music), "candidates": level .candidates .iter() @@ -2815,6 +2884,7 @@ fn normalize_puzzle_levels_json_for_module(value: Option<&str>) -> Result