refactor: modularize api server assets and handlers #23

Merged
kdletters merged 3 commits from hermes/hermes-4fd30995 into master 2026-05-15 06:01:16 +08:00
50 changed files with 8929 additions and 6238 deletions
+5
View File
@@ -4,6 +4,11 @@
## 文档列表
- [【后端架构】api-server能力模块化与生成资产Adapter总纲-2026-05-14.md](./【后端架构】api-server能力模块化与生成资产Adapter总纲-2026-05-14.md):冻结 api-server 能力模块化、生成资产 Adapter、复杂媒体链路和大 Handler 瘦身的总边界,明确多 agent 并行 owner、禁止改动范围、阶段退出条件与验证命令。
- [【后端架构】api-server路由能力模块化执行计划-2026-05-14.md](./【后端架构】api-server路由能力模块化执行计划-2026-05-14.md):记录 app.rs 路由按 admin/auth/assets/platform/creation/runtime/profile/story 等能力迁入 modules router 的执行计划,要求 route path、method、middleware 和 handler contract 不变。
- [【后端架构】生成图片资产Adapter收口执行计划-2026-05-14.md](./【后端架构】生成图片资产Adapter收口执行计划-2026-05-14.md):记录 Big Fish、Square Hole、Custom World 生成图片的 provider 归一、下载/base64 解码、OSS、asset_object confirm 和 entity binding 收口计划。
- [【后端架构】复杂媒体资产链路Adapter扩展计划-2026-05-14.md](./【后端架构】复杂媒体资产链路Adapter扩展计划-2026-05-14.md):记录音频、视频、角色工作流、Hyper3D/GLB 等复杂媒体只复用媒体持久化底座、不污染图片 Adapter 的扩展计划。
- [【后端架构】api-server大Handler瘦身执行计划-2026-05-14.md](./【后端架构】api-server大Handler瘦身执行计划-2026-05-14.md):记录 api-server 大 handler 拆成 router、handlers、application、assets、mapper、errors 的执行计划,明确不改 contract、schema、计费和领域规则。
- [【前端体验】图像组件统一封装与复用边界-2026-05-14.md](./【前端体验】图像组件统一封装与复用边界-2026-05-14.md):冻结创作页统一图像输入面板 `CreativeImageInputPanel` 的受控边界、主图上传、画面描述、多参考图、AI 重绘开关、预览和提交口径,供拼图、抓大鹅封面和后续创作页复用。
- [WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md](./WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md):记录微信小程序 `web-view` 壳的最小接入范围、需要填写的 H5 业务域名、微信后台配置、`npm run check:wechat-miniprogram-auth` 可重复登录链路 smoke 和后续原生化边界。
- [BABY_LOVE_DRAWING_RUNTIME_DEMO_IMPLEMENTATION_2026-05-13.md](./BABY_LOVE_DRAWING_RUNTIME_DEMO_IMPLEMENTATION_2026-05-13.md):冻结寓教于乐 `宝贝爱画` 独立本地 Demo 运行态实现方案,明确发现页默认卡片、`/runtime/baby-love-drawing` 路由、画板交互、mocap/键鼠调试映射、本地保存和 VectorEngine image-2 绘画魔法后端代理。
@@ -0,0 +1,211 @@
# api-server 大 Handler 瘦身执行计划
状态:D2 首批已落地;Big Fish、Square Hole、Custom World AI、Match3D、Puzzle、Custom World 已完成低风险 mapper/tag/asset glue 拆分,后续继续按 owner 深化 application/errors 拆分
日期:2026-05-14
范围:只拆分 `server-rs/crates/api-server` 内大 handler 文件的内部职责;不改 HTTP contract、DTO、SpacetimeDB schema、module-* 领域规则、前端行为、计费语义和 provider 策略。
## 1. 目标
`app.rs` 路由装配迁入 `modules/*/router.rs` 后,继续把超大 handler 文件拆成清晰层次,避免 HTTP 解析、应用编排、资产持久化、DTO mapper、错误映射和玩法策略继续堆在单文件。
目标分层:
```text
router.rs 只挂 route 与 middleware
handlers.rs 只做 Axum extractor、鉴权上下文、Json/Sse response envelope
application.rs 编排 spacetime-client facade、platform provider、asset adapter、计费 wrapper
assets.rs 当前能力私有的 asset request 构造与 adapter 调用 glue
mapper.rs HTTP DTO <-> application input/output 映射
errors.rs 当前能力到 AppError/envelope 的映射
```
本阶段不是 DDD 领域重构。凡是玩法裁决、实体规则、钱包语义、表结构语义已经属于 `module-*``spacetime-module``spacetime-client` 的,不得搬回 `api-server`
## 2. Owner 与禁止改动范围
OwnerD2 大 Handler 瘦身 agent。建议一次只领取一个能力 owner,避免和 B/C/D1 冲突。
首批候选 owner
- `big_fish.rs`
- `square_hole.rs`
- `custom_world_ai.rs` / `custom_world.rs`
- `puzzle.rs`
- `match3d.rs`
- `visual_novel.rs`
- `character_visual_assets.rs`
- `character_animation_assets.rs`
- `vector_engine_audio_generation.rs`
禁止本阶段修改:
- route path、HTTP method、handler 函数对外 contract、DTO 字段和 error envelope。
- `shared-contracts` 公开类型,除非另开 contract owner 文档。
- `spacetime-module` schema/procedure 和 Rust bindings。
- `module-*` 领域规则和命令语义。
- `asset_billing.rs` 的扣费/退款策略。
- 图片 Adapter、复杂媒体 Adapter 的公共接口;只允许调用,不允许在瘦身切片里顺手改接口。
- 前端调用路径、页面行为和测试快照。
## 3. 拆分原则
### 3.1 Handler 只做 HTTP 边界
允许保留:
- Axum extractor`State``Extension``Path``Query``Json`
- 请求体基础解析和鉴权上下文读取。
- 调用 application service。
- 统一包装 `Json(ApiResponse<T>)`、SSE 或 `AppError`
禁止保留:
- provider task create/poll/download 细节。
- OSS object key 拼接、MIME 推断、asset_object confirm、entity binding。
- 大段 SpacetimeDB row JSON mapper。
- 玩法规则判断、发布门槛、运行态裁决。
### 3.2 Application 只做编排
Application 层可以:
-`spacetime-client` facade。
-`platform-*` provider client 或既有 provider helper。
- 调图片/复杂媒体 Adapter。
- 维持既有计费 wrapper 的调用位置。
- 组装 application output。
Application 层不允许新增领域真相;需要新增领域规则时必须暂停并拆给对应 `module-*` owner。
### 3.3 Mapper 可独立测试
Mapper 拆出后应优先覆盖:
- row snapshot JSON 到 HTTP response 的兼容字段。
- legacy ID/path/kind/slot 字符串映射。
- null/缺字段 fallback 的既有行为。
## 4. 能力级执行顺序
### D2-0:基线扫描
每领取一个文件先记录:
- 当前公开 route 与 handler 名称。
- 当前 provider/asset/计费/spacetime-client 调用点。
- 当前已有测试命令和缺口。
- 与 B/C/D1 正在修改的文件是否冲突。
退出条件:只写本地执行 notes 或更新本文件状态,不改 Rust 行为。
### D2-1:低风险纯移动
- 先拆 `types.rs`/`mapper.rs`/`errors.rs` 中纯类型和纯函数。
- 不改函数签名,不改错误文案。
- 每次移动后跑 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`
退出条件:git diff 显示主要是 move/extract;无业务逻辑重写。
### D2-2:资产 glue 下沉
- 对已接入图片 Adapter 或复杂媒体 Adapter 的能力,把 request 构造放到能力私有 `assets.rs`
- 删除 handler 内重复 OSS/confirm/binding 代码。
- 保留计费外层在原 application 编排位置。
退出条件:调用方仍显式传入 asset kind/entity kind/slotAdapter 不反向知道玩法规则。
### D2-3Application service 固化
- 每个能力暴露少量 `pub(crate)` application 函数。
- handler 不直接调多个 facade/provider/adapter。
- 对 SSE handler 保持流式语义,不包成非流式完整字符串。
退出条件:handler 文件行数显著下降;provider/asset/spacetime 调用集中在 application/assets 层。
### D2-4:清理与索引更新
- 删除无用私有 helper。
- 更新对应技术文档状态。
- 如果新建能力目录,确保 `mod.rs` 导出最小化。
退出条件:无未使用代码、无重复 helper、README/TODO 状态同步。
## 5. 文件级 owner 建议
| 能力 | 建议目标目录 | 可并行边界 | 退出条件 |
| --- | --- | --- | --- |
| Big Fish | `modules/runtime/big_fish/` | 不碰 Square Hole/Puzzle;图片 Adapter 接口由 C 线 owner 控制 | 正式图 asset glue 不在 handlerSSE/works/gallery route contract 不变 |
| Square Hole | `modules/creation/square_hole/``modules/runtime/square_hole/` | 不改 Puzzle 图片规则 | 图片重生成 fallback Data URL 行为不变 |
| Custom World | `modules/runtime/custom_world/` | opening CG 视频等复杂媒体与 D1 协调 | profile/entity/scene/cover/opening route contract 不变 |
| Puzzle | `modules/runtime/puzzle/` | 不改前端即时运行态规则 | 运行态后端真相接口和排行榜语义不变 |
| Match3D | `modules/creation/match3d/``modules/runtime/match3d/` | 不恢复 Rodin/GLB 新草稿 | 创作草稿、发布、运行态接口不变 |
| Visual Novel/audio | `modules/creation/visual_novel/` | 音频持久化与 D1 协调 | SSE、compile、音频 asset route 不变 |
| Character assets | `modules/assets/character_visual/``character_animation/` | 不改 workflow cache contract | 角色视觉/动作发布、导入、模板接口不变 |
## 6. 验收命令
每个能力切片至少运行:
```bash
cargo check -p api-server --manifest-path server-rs/Cargo.toml
cargo test -p api-server --manifest-path server-rs/Cargo.toml
npm run check:encoding
git diff --check
```
按能力追加定向测试:
```bash
cargo test -p api-server big_fish --manifest-path server-rs/Cargo.toml
cargo test -p api-server square_hole --manifest-path server-rs/Cargo.toml
cargo test -p api-server custom_world --manifest-path server-rs/Cargo.toml
cargo test -p api-server puzzle --manifest-path server-rs/Cargo.toml
cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml
cargo test -p api-server visual_novel --manifest-path server-rs/Cargo.toml
```
如果 route 装配已迁移,还要跑:
```bash
cargo test -p api-server app --manifest-path server-rs/Cargo.toml
```
## 7. 当前落地状态(2026-05-14
本轮已按“不改 route/HTTP contract/DTO/SpacetimeDB schema”的低风险策略完成首批机械拆分,并通过 `cargo check -p api-server`
- `big_fish.rs`:正式图生成、下载与持久化拆入 `big_fish/formal_assets.rs`;会话/作品/运行态 response 映射与欢迎语拆入 `big_fish/mappers.rs`
- `square_hole.rs`:视觉资源生成、Adapter 持久化与图片 prompt glue 拆入 `square_hole/visual_assets.rs`response mapper 拆入 `square_hole/mappers.rs`
- `custom_world_ai.rs`:通用图片资产持久化拆入 `custom_world_ai/assets.rs`opening CG storyboard/video 生成与持久化拆入 `custom_world_ai/opening_cg.rs`
- `match3d.rs`response mapper 拆入 `match3d/mappers.rs`;标签生成/归一化拆入 `match3d/tags.rs`
- `puzzle.rs`response mapper 拆入 `puzzle/mappers.rs`;标签生成/保存/发布就绪判断拆入 `puzzle/tags.rs`
- `custom_world.rs`library/gallery/work response mapper 拆入 `custom_world/mappers.rs`
拆分后当前主文件规模约为:
| 文件 | 当前行数 | 已拆出模块 |
| --- | ---: | --- |
| `big_fish.rs` | 1005 | `formal_assets.rs``mappers.rs` |
| `square_hole.rs` | 1674 | `visual_assets.rs``mappers.rs` |
| `custom_world_ai.rs` | 3113 | `assets.rs``opening_cg.rs` |
| `custom_world.rs` | 3519 | `mappers.rs` |
| `puzzle.rs` | 5609 | `mappers.rs``tags.rs` |
| `match3d.rs` | 6541 | `mappers.rs``tags.rs` |
后续建议继续拆分:
- `match3d`: `draft.rs``background_and_cover.rs``material_sheet.rs``apimart_image.rs`
- `puzzle`: `session_form.rs``draft_compile.rs``image_provider.rs``errors.rs`
- `custom_world`: `publish_gate.rs``foundation_job.rs``foundation_assets.rs``errors.rs`
- `square_hole`: `config.rs``errors.rs`
- `big_fish`: `errors.rs`,以及按需将 application 编排从 handler 中继续拆出。
## 8. 完成定义
D2 完成必须同时满足:
- 至少 3 个大 handler 完成 route/handler/application/assets/mapper/errors 的职责拆分。
- handler 不再直接包含大段 provider 下载、OSS 上传、asset_object confirm、entity binding 逻辑。
- 所有拆分前 route、method、DTO、error envelope、计费外层和 SpacetimeDB schema 不变。
- 不新增领域规则到 `api-server`
- 验收命令通过,并同步更新 README 与本系列总纲/TODO 状态。
@@ -0,0 +1,296 @@
# api-server 能力模块化与生成资产 Adapter 总纲
状态:A 线文档基线已补齐;B1 路由模块化已由 controller 接手并以 cargo check 通过为当前编码基线;后续 C/D 线按本文 owner 拆分执行
日期:2026-05-14
范围:只约束 `server-rs/crates/api-server` 内部结构,不改 HTTP contract、DTO、SpacetimeDB schema、前端行为和计费语义。
## 1. 背景与目标
当前 `api-server` 仍以超大 `app.rs` 直接装配全部 Axum route,并由 `big_fish.rs``square_hole.rs``custom_world_ai.rs``puzzle.rs``match3d.rs``character_visual_assets.rs``character_animation_assets.rs``vector_engine_audio_generation.rs` 等大 handler 文件承载 HTTP 解析、平台编排、生成资产下载/解码、OSS 上传、asset_object confirm、entity binding、计费包裹和部分玩法策略。
本轮总目标是分阶段把能力模块化和生成资产 Adapter 收口落地到可维护结构:
- `app.rs` 只保留全局 middleware、TraceLayer、request context、tracking middleware、模块 router merge/nest 和少量 glue。
- 每个能力以 `modules/<capability>/router.rs` 暴露 `router(state) -> Router<AppState>`,迁移时保持原 route 字符串和 handler 函数不变。
- 图片生成资产先收口到 `generated_image_assets` 内部 Adapter,复用 provider 生成、下载/base64 解码、MIME/extension 归一、OSS private upload、HEAD/确认、asset_object confirm、entity binding。
- 音频、视频、GLB/Hyper3D/character assets 只复用底层“媒体持久化 + asset_object + binding”能力,不强行塞进图片 Adapter。
## 2. 不变边界
必须遵守既有 DDD 总纲和 G1 契约矩阵:
- 后端主线为 `server-rs + Axum + SpacetimeDB`
- `api-server` 只做 HTTP/SSE/BFF、鉴权、DTO 映射、平台服务编排、错误 envelope 映射、读写 facade 调用。
- 领域规则不沉回 handler;新增模块不得把玩法裁决、实体规则、钱包语义、表结构语义写成 HTTP 层私有规则。
- `module-*` 保持领域规则 owner`spacetime-module` 保持真相源 schema/procedure owner`spacetime-client` 保持 facade/adapter owner。
- 本轮默认不改 HTTP route、DTO 字段、error envelope、SpacetimeDB schema、前端行为、计费语义。
- `asset_billing.rs` 仍由调用方显式包裹;生成资产 Adapter 不扣费、不退款、不读钱包。
- 生成资产读取继续走 `/api/assets/read-url``/api/assets/read-bytes` 换签/代理链路,不恢复 `/generated-*` 直读代理。
- 禁止新增或复活 Maincloud 口径;smoke 以 `/healthz` 为准。
## 3. 当前源码入口
- 路由装配:`server-rs/crates/api-server/src/app.rs`
- 资产基础 BFF`assets.rs`
- 图片 provider 公共 helper`openai_image_generation.rs`
- 玩法/创作大 handler`big_fish.rs``square_hole.rs``custom_world_ai.rs``custom_world.rs``puzzle.rs``match3d.rs`
- 复杂资产:`vector_engine_audio_generation.rs``character_visual_assets.rs``character_animation_assets.rs`
- Prompt 辅助同名文件:`prompt/big_fish.rs``prompt/square_hole.rs`,不是 HTTP handler owner。
## 4. Route inventory 总览
完整执行细节见《api-server路由能力模块化执行计划》。当前 `app.rs` route 按能力归类如下。
### admin
Handler 主要在 `admin.rs``admin_creation_entry.rs``admin_profile.rs`
- `/admin/api/login`
- `/admin/api/me`
- `/admin/api/overview`
- `/admin/api/debug/http`
- `/admin/api/tracking/events`
- `/admin/api/database/tables`
- `/admin/api/database/tables/{table_name}/rows`
- `/admin/api/creation-entry/config`
- `/admin/api/profile/redeem-codes`
- `/admin/api/profile/redeem-codes/disable`
- `/admin/api/profile/invite-codes`
- `/admin/api/profile/tasks`
- `/admin/api/profile/tasks/disable`
### health/internal
Handler 主要在 `app.rs` glue、`auth.rs`
- `/healthz`
- `/_internal/auth/claims`
- `/_internal/auth/refresh-cookie`
### auth
Handler 主要在 `auth.rs``wechat_auth.rs`
- `/api/auth/login-options`
- `/api/auth/public-users/by-code/{code}`
- `/api/auth/public-users/by-id/{user_id}`
- `/api/auth/me`
- `/api/auth/sessions`
- `/api/auth/sessions/{session_id}/revoke`
- `/api/auth/refresh`
- `/api/auth/phone/send-code`
- `/api/auth/phone/login`
- `/api/auth/wechat/start`
- `/api/auth/wechat/callback`
- `/api/auth/wechat/miniprogram-login`
- `/api/auth/wechat/bind-phone`
- `/api/auth/logout`
- `/api/auth/logout-all`
- `/api/auth/entry`
- `/api/auth/password/change`
- `/api/auth/password/reset`
### assets
Handler 主要在 `assets.rs``character_visual_assets.rs``character_animation_assets.rs``hyper3d.rs`
- `/api/assets/direct-upload-tickets`
- `/api/assets/sts-upload-credentials`
- `/api/assets/objects/confirm`
- `/api/assets/objects/bind`
- `/api/assets/read-url`
- `/api/assets/read-bytes`
- `/api/assets/history`
- `/api/assets/character-visual/generate`
- `/api/assets/character-visual/jobs/{task_id}`
- `/api/assets/character-visual/publish`
- `/api/assets/character-animation/generate`
- `/api/assets/character-animation/jobs/{task_id}`
- `/api/assets/character-animation/publish`
- `/api/assets/character-animation/import-video`
- `/api/assets/character-animation/templates`
- `/api/assets/character-workflow-cache`
- `/api/assets/character-workflow-cache/{character_id}`
- `/api/runtime/custom-world/asset-studio/role/{character_id}/workflow`
- `/api/assets/hyper3d/text-to-model`
- `/api/assets/hyper3d/image-to-model`
- `/api/assets/hyper3d/status`
- `/api/assets/hyper3d/download`
### platform/BFF
Handler 主要在 `llm.rs``speech.rs``ai_tasks.rs``creation_entry.rs``runtime_chat.rs`
- `/api/llm/chat/completions`
- `/api/speech/volcengine/config`
- `/api/speech/volcengine/asr/stream`
- `/api/speech/volcengine/tts/bidirection`
- `/api/speech/volcengine/tts/sse`
- `/api/ai/tasks``{task_id}` start/chunks/complete/fail/cancel/stages/references 子路由
- `/api/creation-entry/config`
- `/api/runtime/chat/character/suggestions`
- `/api/runtime/chat/character/summary`
- `/api/runtime/chat/character/reply/stream`
- `/api/runtime/chat/npc/dialogue/stream`
- `/api/runtime/chat/npc/turn/stream`
- `/api/runtime/chat/npc/recruit/stream`
- `/api/runtime/creation-agent/document-inputs/parse`
### creation
Handler 主要在 `match3d.rs``square_hole.rs``visual_novel.rs``vector_engine_audio_generation.rs`
- `/api/creation/match3d/*`
- `/api/creation/square-hole/*`
- `/api/creation/visual-novel/*`
- `/api/creation/visual-novel/audio/*`
- `/api/creation/audio/background-music`
- `/api/creation/audio/background-music/{task_id}/asset`
- `/api/creation/audio/sound-effect`
- `/api/creation/audio/sound-effect/{task_id}/asset`
### runtime/gameplay
Handler 主要在 `custom_world.rs``custom_world_ai.rs``big_fish.rs``puzzle.rs``match3d.rs``square_hole.rs``visual_novel.rs`
- `/api/runtime/settings`
- `/api/runtime/save/snapshot`
- `/api/runtime/custom-world-library*`
- `/api/runtime/custom-world-gallery*`
- `/api/runtime/custom-world/agent/*`
- `/api/runtime/custom-world/works`
- `/api/runtime/custom-world/profile|entity|scene-npc|scene-image|cover-image|cover-upload|opening-cg`
- `/api/runtime/big-fish/*`
- `/api/runtime/puzzle/*`
- `/api/runtime/match3d/*`
- `/api/runtime/square-hole/*`
- `/api/runtime/visual-novel/*`
- `/api/runtime/creative-agent/*`
- `/api/runtime/sessions/{runtime_session_id}/inventory`
### profile
Handler 主要在 `profile.rs``runtime_profile.rs``tracking.rs`
- `/api/profile/me`
- `/api/profile/browse-history`
- `/api/profile/dashboard`
- `/api/profile/wallet-ledger`
- `/api/profile/recharge-center`
- `/api/profile/recharge/orders`
- `/api/profile/recharge/wechat/notify`
- `/api/profile/feedback`
- `/api/profile/referrals/invite-center`
- `/api/profile/referrals/redeem-code`
- `/api/profile/redeem-codes/redeem`
- `/api/profile/analytics/metric`
- `/api/profile/tasks`
- `/api/profile/tasks/{task_id}/claim`
- `/api/profile/save-archives`
- `/api/profile/save-archives/{world_key}`
- `/api/profile/play-stats`
### story
Handler 主要在 `story.rs``combat.rs``runtime_inventory.rs`
- `/api/story/sessions`
- `/api/story/sessions/runtime`
- `/api/story/sessions/{story_session_id}/state`
- `/api/story/sessions/{story_session_id}/runtime-projection`
- `/api/story/sessions/{story_session_id}/actions/resolve`
- `/api/story/sessions/continue`
- `/api/story/battles`
- `/api/story/battles/{battle_state_id}`
- `/api/story/npc/battle`
- `/api/story/battles/resolve`
## 5. 生成资产链路 inventory 总览
详细迁移计划见图片 Adapter、复杂媒体 Adapter 文档。图片链路由 C 线收口;音频、视频、GLB/Hyper3D、角色工作流等复杂媒体由 D1 线只复用媒体持久化底座,不反向扩大图片 Adapter interface。
| 链路 | provider | 下载/解码 | OSS prefix | asset kind | entity binding | 计费位置 | 降级行为 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| Big Fish 正式图 | DashScope `wan2.2-t2i-flash` | 轮询 task 后 HTTP GET 图片 URL | `LegacyAssetPrefix::BigFishAssets` | 由 assetKind 映射主图/动作图/舞台背景等 | `big_fish_session` + session/entity id + slot | `big_fish.rs` 调用方 `execute_billable_asset_operation` | 配置缺失/上游失败直接错误;gallery 对部分 Spacetime 运行错误软降级 |
| Square Hole 图片重生成 | OpenAI/VectorEngine GPT image helper | URL 下载或 base64/data URL 解码 | `LegacyAssetPrefix::SquareHoleAssets` | 方洞作品图片槽位相关 kind | profile/work + image slot | 调用方包裹 | 生成成功但入库失败保留 Data URL 回包 |
| Custom World 场景/封面 | VectorEngine GPT image 2 / OpenAI helper | URL 下载或 base64 解码 | `LegacyAssetPrefix::CustomWorldScenes` 等 | scene/cover/opening storyboard | `custom_world_profile` 或 profile/landmark/scene slot | `custom_world_ai.rs` 调用方包裹 | entity/scene 生成存在 LLM fallback;资产持久化失败按当前错误口径返回 |
| Puzzle 图片 | GPT image 2 generations/edits | multipart/base64/URL 结果归一 | `LegacyAssetPrefix::PuzzleAssets` | puzzle level/background/generated image,另有 `puzzle_background_music` | puzzle profile/run/level slot | `puzzle.rs` 调用方包裹 | connectivity 可按既有规则跳过部分计费;运行态 fallback 保持原逻辑 |
| Match3D 图片 | APIMart/VectorEngine/OpenAI image helper | 下载、切图、透明化、校准后入库 | `LegacyAssetPrefix::Match3DAssets` | cover/background/item material sheet,音频 kind 另列 | match3d profile/session slot | `match3d.rs` 调用方包裹 | 新草稿不回退 Rodin/GLB;部分连接错误按现有计费跳过规则处理 |
| Visual Novel 音频 | VectorEngine Suno/Vidu | 任务提交后按 task publish 下载音频 | 视觉小说/creation audio scope | `visual_novel_music``visual_novel_ambient_sound` | `visual_novel_scene` + scene id + `music`/`ambient_sound` | `vector_engine_audio_generation.rs` 调用方包裹 | 上游/下载失败显式错误,不混入图片 Adapter |
| 通用音频 | VectorEngine Suno/Vidu | 同上 | creation audio scope | background_music/sound_effect 由调用方目标指定 | creation target entity/slot | 调用方包裹 | 不与 VN 场景语义混用 |
| 视频 Opening CG | Ark/火山视频 + storyboard | 先生 storyboard,再图生视频,下载 remote video | Custom World 相关 prefix | `custom_world_opening_cg_storyboard``custom_world_opening_cg_video` | `custom_world_profile` + opening cg slots | `execute_billable_asset_operation_with_cost` 固定点数 | 配置缺失/超时显式错误,不应静默降级 |
| Character visual assets | GPT image helper / role asset workflow | base64/data URL/下载后入库 | 角色视觉资产相关 prefix | character_visual / reference / workflow cache | `character` + visual slots | 调用方包裹或当前无扣费处保持不变 | workflow cache 可无缓存继续生成 |
| Character animation assets | Ark video 或阶段占位 | data:video base64 导入、remote video 下载、预览视频保存 | 角色动画资产相关 prefix | `character_animation``character_animation_reference_video``character_workflow_cache` | `character` + animation slots | 当前调用方语义保持 | stage1 image sequence/video placeholder 继续保留 |
| Hyper3D/GLB | Hyper3D Rodin 历史代理 | status/download 列表代理,历史转存可复用 OSS | Hyper3D/model prefix | model/glb 相关历史 kind | 作品/profile 绑定视历史链路 | 不新增计费语义 | 当前 Match3D 新草稿不再回退 Rodin/GLB |
## 6. 并行执行规则
### 单 owner 文件/模块
同一时间只允许一个 agent 修改:
- `server-rs/crates/api-server/src/app.rs`
- 未来 `server-rs/crates/api-server/src/modules/mod.rs`
- 未来 `server-rs/crates/api-server/src/modules/assets/generated_image_assets/*`
- `asset_billing.rs`
- `openai_image_generation.rs`
- `assets.rs`
- `docs/technical/README.md`
- 原 TODO 文档
### 可并行能力 owner
在 route inventory 和 Adapter interface 冻结后,可按能力分配:
- admin/auth/internal/health route module
- assets/character assets/hyper3d route module
- profile/runtime settings/save route module
- Big Fish
- Square Hole
- Custom World
- Puzzle
- Match3D
- Visual Novel/audio
- story/combat/inventory
并行前提:只改自己能力目录和对应 handler;跨能力公共 helper 必须先锁单 owner 并在文档中声明。
## 7. 阶段与退出条件
- A0 文档基线(owner:A 线文档 agent):5 篇执行文档和 README/TODO 索引更新;`npm run check:encoding``git diff --check` 通过;不改 Rust 代码。
- B1 route 模块化(ownerB1 route agent/controller):route inventory 全部仍可在新 modules router 中找到;旧明确下线 route 仍 404;`cargo check -p api-server --manifest-path server-rs/Cargo.toml``cargo test -p api-server app --manifest-path server-rs/Cargo.toml` 或等价 route 测试通过。
- C1-C4 图片 AdapterownerC 线图片 Adapter agent):Big Fish、Square Hole、Custom World 至少 3 个真实调用方接入同一 Adapter;旧重复 persist helper 可删除且行为不变;禁止修改计费语义。
- D1 复杂媒体 AdapterownerD1 复杂媒体 agent):Puzzle/Match3D/音频/视频/角色工作流/Hyper3D 只复用合适的底层持久化能力,不污染图片 interface;`/api/assets/read-url``/api/assets/read-bytes` 读取链路仍可用。
- D2 大 handler 瘦身(ownerD2 handler 瘦身 agent):route、handler、application、assets、mapper、errors 分层清晰;不扩大到领域 crate 重构;每次只领取一个能力 owner。
## 8. TODO / 多 agent 拆分状态
| 线别 | Owner | 当前状态 | 只允许改动 | 禁止改动 | 退出条件 |
| --- | --- | --- | --- | --- | --- |
| A0 文档基线 | A 线文档 agent | 本次补齐缺失复杂媒体与大 Handler 文档,更新 README 和本 TODO 视角 | `docs/technical/【后端架构】*2026-05-14.md``docs/technical/README.md` | Rust 代码、Cargo 配置、前端、schema | `npm run check:encoding``git diff --check` 通过 |
| B1 路由模块化 | B1 route agent/controller | controller 已接手,当前 cargo check 通过;后续以 B1 diff 为准 | `app.rs``modules/*/router.rs`、必要 `mod.rs` | handler 行为、DTO、middleware 顺序、计费 | route inventory 完整、cargo check/route tests 通过 |
| C 图片 Adapter | C 线图片 Adapter agent | 待执行 | `modules/assets/generated_image_assets/*`,以及 Big Fish/Square Hole/Custom World 接线文件 | `asset_billing.rs`、schema、公开 contract、复杂媒体接口 | 三类真实图片调用方共用 Adapter |
| D1 复杂媒体 Adapter | D1 复杂媒体 agent | 待执行 | `modules/assets/media_assets/*` 与音频/视频/角色资产接线文件 | 图片 Adapter interface、provider 策略、schema、计费 | 至少音频和视频两类复用 media persist |
| D2 大 Handler 瘦身 | D2 handler 瘦身 agent | 待 B/C/D1 稳定后执行 | 单个能力 handler 及其能力目录内 `handlers/application/assets/mapper/errors` | 领域 crate、contract、schema、前端、计费 | 至少 3 个大 handler 职责拆分完成 |
并行要求:同一文件同一时间只允许一个 owner;跨线公共 helper 必须先在对应执行文档中声明 owner 和退出条件。若编码 diff 与本 TODO 冲突,以最新通过验收命令的代码事实为准,并同步修正文档。
## 9. 验证命令
文档阶段:
```bash
npm run check:encoding
git diff --check
```
后续编码阶段按变更范围追加:
```bash
cargo check -p api-server --manifest-path server-rs/Cargo.toml
cargo test -p api-server --manifest-path server-rs/Cargo.toml
cargo test -p api-server app --manifest-path server-rs/Cargo.toml
npm run check:server-rs-ddd
npm run api-server
# 另开终端 curl /healthz
```
@@ -0,0 +1,393 @@
# api-server 路由能力模块化执行计划
状态:A 线文档基线已补齐;B1 低风险路由模块已落地并通过 route/app smoke、cargo check、编码检查;后续 B2/B3 继续按本文 owner 拆分执行
日期:2026-05-14
范围:只移动/重组 `api-server` 路由装配;不改 route path、HTTP method、handler 函数签名、DTO、鉴权策略、middleware 顺序和前端行为。
## 1. 目标
把当前 `app.rs` 中所有 `.route(...)` 按能力迁入 `server-rs/crates/api-server/src/modules/`,每个能力暴露:
```rust
pub(crate) fn router(state: AppState) -> Router<AppState>
```
第一阶段 handler 实现仍可留在原文件;本阶段只改变路由装配位置。`app.rs` 最终只负责:
- 构建 shared state。
- 注入全局 CORS、TraceLayer、request context、tracking/auth middleware。
- `.merge(modules::<capability>::router(state.clone()))`
- 保留 `/healthz` 等极少量 glue,或迁到 `modules/health` 后统一 merge。
## 2. 建议目录
```text
server-rs/crates/api-server/src/modules/
mod.rs
admin/router.rs
auth/router.rs
assets/router.rs
profile/router.rs
platform/router.rs
creation/router.rs
runtime/router.rs
story/router.rs
internal/router.rs
health/router.rs
```
可进一步拆分:
- `creation/{match3d,square_hole,visual_novel,audio}/router.rs`
- `runtime/{custom_world,big_fish,puzzle,match3d,square_hole,visual_novel,creative_agent,settings,save}/router.rs`
- `assets/{base,character_visual,character_animation,hyper3d}/router.rs`
## 3. Route inventory 与 owner
### 3.1 admin
Owner`modules/admin/router.rs`。主要 handler 文件:`admin.rs``admin_creation_entry.rs``admin_profile.rs`
- `/admin/api/login`
- `/admin/api/me`
- `/admin/api/overview`
- `/admin/api/debug/http`
- `/admin/api/tracking/events`
- `/admin/api/database/tables`
- `/admin/api/database/tables/{table_name}/rows`
- `/admin/api/creation-entry/config`
- `/admin/api/profile/redeem-codes`
- `/admin/api/profile/redeem-codes/disable`
- `/admin/api/profile/invite-codes`
- `/admin/api/profile/tasks`
- `/admin/api/profile/tasks/disable`
退出条件:后台接口路径、鉴权 middleware、错误 envelope 不变。
### 3.2 health/internal
Owner`modules/health/router.rs``modules/internal/router.rs`。主要 handler`app.rs` glue、`auth.rs`
- `/healthz`
- `/_internal/auth/claims`
- `/_internal/auth/refresh-cookie`
退出条件:`/healthz` 可作为本地 smokeinternal route 不暴露新增契约。
### 3.3 auth
Owner`modules/auth/router.rs`。主要 handler`auth.rs``wechat_auth.rs`
- `/api/auth/login-options`
- `/api/auth/public-users/by-code/{code}`
- `/api/auth/public-users/by-id/{user_id}`
- `/api/auth/me`
- `/api/auth/sessions`
- `/api/auth/sessions/{session_id}/revoke`
- `/api/auth/refresh`
- `/api/auth/phone/send-code`
- `/api/auth/phone/login`
- `/api/auth/wechat/start`
- `/api/auth/wechat/callback`
- `/api/auth/wechat/miniprogram-login`
- `/api/auth/wechat/bind-phone`
- `/api/auth/logout`
- `/api/auth/logout-all`
- `/api/auth/entry`
- `/api/auth/password/change`
- `/api/auth/password/reset`
退出条件:cookie/session/refresh 行为不变;手机号/微信配置门控不变。
### 3.4 assets
Owner`modules/assets/router.rs`。主要 handler`assets.rs``character_visual_assets.rs``character_animation_assets.rs``hyper3d.rs`
- `/api/assets/direct-upload-tickets`
- `/api/assets/sts-upload-credentials`
- `/api/assets/objects/confirm`
- `/api/assets/objects/bind`
- `/api/assets/read-url`
- `/api/assets/read-bytes`
- `/api/assets/history`
- `/api/assets/character-visual/generate`
- `/api/assets/character-visual/jobs/{task_id}`
- `/api/assets/character-visual/publish`
- `/api/assets/character-animation/generate`
- `/api/assets/character-animation/jobs/{task_id}`
- `/api/assets/character-animation/publish`
- `/api/assets/character-animation/import-video`
- `/api/assets/character-animation/templates`
- `/api/assets/character-workflow-cache`
- `/api/assets/character-workflow-cache/{character_id}`
- `/api/runtime/custom-world/asset-studio/role/{character_id}/workflow`(可暂挂 assets module,但文档注明 runtime 前缀历史兼容)
- `/api/assets/hyper3d/text-to-model`
- `/api/assets/hyper3d/image-to-model`
- `/api/assets/hyper3d/status`
- `/api/assets/hyper3d/download`
退出条件:私有资产读取仍走 read-url/read-bytesHyper3D 不新增 Match3D 新草稿回退。
### 3.5 platform/BFF
Owner`modules/platform/router.rs`。主要 handler`llm.rs``speech.rs``ai_tasks.rs``creation_entry.rs``runtime_chat.rs`
- `/api/llm/chat/completions`
- `/api/speech/volcengine/config`
- `/api/speech/volcengine/asr/stream`
- `/api/speech/volcengine/tts/bidirection`
- `/api/speech/volcengine/tts/sse`
- `/api/runtime/chat/character/suggestions`
- `/api/runtime/chat/character/summary`
- `/api/runtime/chat/character/reply/stream`
- `/api/runtime/chat/npc/dialogue/stream`
- `/api/runtime/chat/npc/turn/stream`
- `/api/runtime/chat/npc/recruit/stream`
- `/api/runtime/creation-agent/document-inputs/parse`
- `/api/ai/tasks`
- `/api/ai/tasks/{task_id}/start`
- `/api/ai/tasks/{task_id}/stages/{stage_kind}/start`
- `/api/ai/tasks/{task_id}/chunks`
- `/api/ai/tasks/{task_id}/stages/{stage_kind}/complete`
- `/api/ai/tasks/{task_id}/references`
- `/api/ai/tasks/{task_id}/complete`
- `/api/ai/tasks/{task_id}/fail`
- `/api/ai/tasks/{task_id}/cancel`
- `/api/creation-entry/config`
退出条件:SSE 流式 route 不被非流式 wrapper 改写;外部服务错误分类不变。
### 3.6 creation
Owner`modules/creation/router.rs`,可由子模块并行。主要 handler:`match3d.rs``square_hole.rs``visual_novel.rs``vector_engine_audio_generation.rs`
Match3D
- `/api/creation/match3d/sessions`
- `/api/creation/match3d/sessions/{session_id}`
- `/api/creation/match3d/sessions/{session_id}/messages`
- `/api/creation/match3d/sessions/{session_id}/messages/stream`
- `/api/creation/match3d/sessions/{session_id}/actions`
- `/api/creation/match3d/sessions/{session_id}/compile`
- `/api/creation/match3d/works`
- `/api/creation/match3d/works/tags`
- `/api/creation/match3d/works/{profile_id}`
- `/api/creation/match3d/works/{profile_id}/audio-assets`
- `/api/creation/match3d/works/{profile_id}/cover-image`
- `/api/creation/match3d/works/{profile_id}/background-image`
- `/api/creation/match3d/works/{profile_id}/item-assets`
- `/api/creation/match3d/works/{profile_id}/generated-models`
- `/api/creation/match3d/works/{profile_id}/publish`
Square Hole
- `/api/creation/square-hole/sessions`
- `/api/creation/square-hole/sessions/{session_id}`
- `/api/creation/square-hole/sessions/{session_id}/messages`
- `/api/creation/square-hole/sessions/{session_id}/messages/stream`
- `/api/creation/square-hole/sessions/{session_id}/actions`
- `/api/creation/square-hole/sessions/{session_id}/compile`
- `/api/creation/square-hole/works`
- `/api/creation/square-hole/works/{profile_id}`
- `/api/creation/square-hole/works/{profile_id}/publish`
- `/api/creation/square-hole/works/{profile_id}/images/regenerate`
Visual Novel 与音频:
- `/api/creation/visual-novel/sessions`
- `/api/creation/visual-novel/sessions/{session_id}`
- `/api/creation/visual-novel/sessions/{session_id}/messages`
- `/api/creation/visual-novel/sessions/{session_id}/messages/stream`
- `/api/creation/visual-novel/sessions/{session_id}/actions`
- `/api/creation/visual-novel/sessions/{session_id}/compile`
- `/api/creation/visual-novel/works`
- `/api/creation/visual-novel/works/{profile_id}`
- `/api/creation/visual-novel/works/{profile_id}/publish`
- `/api/creation/visual-novel/audio/background-music`
- `/api/creation/visual-novel/audio/background-music/{task_id}/asset`
- `/api/creation/visual-novel/audio/sound-effect`
- `/api/creation/visual-novel/audio/sound-effect/{task_id}/asset`
- `/api/creation/audio/background-music`
- `/api/creation/audio/background-music/{task_id}/asset`
- `/api/creation/audio/sound-effect`
- `/api/creation/audio/sound-effect/{task_id}/asset`
退出条件:所有 creation route method 和 auth extension 不变。
### 3.7 runtime
Owner`modules/runtime/router.rs`,建议按玩法子模块并行。
Custom World
- `/api/runtime/settings`
- `/api/runtime/save/snapshot`
- `/api/runtime/custom-world-library`
- `/api/runtime/custom-world-library/{profile_id}`
- `/api/runtime/custom-world-library/{profile_id}/publish`
- `/api/runtime/custom-world-library/{profile_id}/unpublish`
- `/api/runtime/custom-world-gallery`
- `/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}`
- `/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/remix`
- `/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/play`
- `/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/like`
- `/api/runtime/custom-world-gallery/by-code/{code}`
- `/api/runtime/custom-world/agent/sessions`
- `/api/runtime/custom-world/agent/sessions/{session_id}`
- `/api/runtime/custom-world/agent/sessions/{session_id}/result-view`
- `/api/runtime/custom-world/works`
- `/api/runtime/custom-world/agent/sessions/{session_id}/cards/{card_id}`
- `/api/runtime/custom-world/agent/sessions/{session_id}/messages`
- `/api/runtime/custom-world/agent/sessions/{session_id}/messages/stream`
- `/api/runtime/custom-world/agent/sessions/{session_id}/actions`
- `/api/runtime/custom-world/agent/sessions/{session_id}/operations/{operation_id}`
- `/api/runtime/custom-world/profile`
- `/api/runtime/custom-world/entity`
- `/api/runtime/custom-world/scene-npc`
- `/api/runtime/custom-world/scene-image`
- `/api/runtime/custom-world/cover-image`
- `/api/runtime/custom-world/cover-upload`
- `/api/runtime/custom-world/opening-cg`
Big Fish
- `/api/runtime/big-fish/agent/sessions`
- `/api/runtime/big-fish/agent/sessions/{session_id}`
- `/api/runtime/big-fish/agent/sessions/{session_id}/messages`
- `/api/runtime/big-fish/agent/sessions/{session_id}/messages/stream`
- `/api/runtime/big-fish/agent/sessions/{session_id}/actions`
- `/api/runtime/big-fish/works`
- `/api/runtime/big-fish/gallery`
- `/api/runtime/big-fish/gallery/{session_id}/remix`
- `/api/runtime/big-fish/gallery/{session_id}/like`
- `/api/runtime/big-fish/works/{session_id}`
- `/api/runtime/big-fish/sessions/{session_id}/play`
- `/api/runtime/big-fish/works/{session_id}/play`
- `/api/runtime/big-fish/sessions/{session_id}/runs`
- `/api/runtime/big-fish/runs/{run_id}`
- `/api/runtime/big-fish/runs/{run_id}/input`
Puzzle
- `/api/runtime/puzzle/agent/sessions`
- `/api/runtime/puzzle/agent/sessions/{session_id}`
- `/api/runtime/puzzle/agent/sessions/{session_id}/messages`
- `/api/runtime/puzzle/agent/sessions/{session_id}/messages/stream`
- `/api/runtime/puzzle/agent/sessions/{session_id}/actions`
- `/api/runtime/puzzle/onboarding/generate`
- `/api/runtime/puzzle/onboarding/save`
- `/api/runtime/puzzle/works`
- `/api/runtime/puzzle/works/{profile_id}`
- `/api/runtime/puzzle/works/{profile_id}/point-incentive/claim`
- `/api/runtime/puzzle/gallery`
- `/api/runtime/puzzle/gallery/{profile_id}`
- `/api/runtime/puzzle/gallery/{profile_id}/remix`
- `/api/runtime/puzzle/gallery/{profile_id}/like`
- `/api/runtime/puzzle/runs`
- `/api/runtime/puzzle/runs/{run_id}`
- `/api/runtime/puzzle/runs/{run_id}/swap`
- `/api/runtime/puzzle/runs/{run_id}/drag`
- `/api/runtime/puzzle/runs/{run_id}/next-level`
- `/api/runtime/puzzle/runs/{run_id}/pause`
- `/api/runtime/puzzle/runs/{run_id}/props`
- `/api/runtime/puzzle/runs/{run_id}/leaderboard`
Match3D/Square Hole/Visual Novel/Creative Agent
- `/api/runtime/match3d/gallery`
- `/api/runtime/match3d/works/{profile_id}/runs`
- `/api/runtime/match3d/runs/{run_id}`
- `/api/runtime/match3d/runs/{run_id}/click|stop|restart|time-up`
- `/api/runtime/square-hole/gallery`
- `/api/runtime/square-hole/works/{profile_id}/runs`
- `/api/runtime/square-hole/runs/{run_id}`
- `/api/runtime/square-hole/runs/{run_id}/drop|stop|restart|time-up`
- `/api/runtime/visual-novel/gallery`
- `/api/runtime/visual-novel/works/{profile_id}/runs`
- `/api/runtime/visual-novel/runs/{run_id}`
- `/api/runtime/visual-novel/runs/{run_id}/actions/stream|history|regenerate`
- `/api/runtime/creative-agent/sessions`
- `/api/runtime/creative-agent/sessions/{session_id}`
- `/api/runtime/creative-agent/sessions/{session_id}/messages/stream`
- `/api/runtime/creative-agent/sessions/{session_id}/confirm-template`
- `/api/runtime/creative-agent/sessions/{session_id}/draft-edits/stream`
- `/api/runtime/creative-agent/sessions/{session_id}/cancel`
- `/api/runtime/sessions/{runtime_session_id}/inventory`
退出条件:运行态玩法 route 后端真相源保持现状;不恢复旧 `/api/custom-world/*` 非 runtime 前缀。
### 3.8 profile
Owner`modules/profile/router.rs`。主要 handler`profile.rs``runtime_profile.rs``tracking.rs`
- `/api/profile/me`
- `/api/profile/browse-history`
- `/api/profile/dashboard`
- `/api/profile/wallet-ledger`
- `/api/profile/recharge-center`
- `/api/profile/recharge/orders`
- `/api/profile/recharge/wechat/notify`
- `/api/profile/feedback`
- `/api/profile/referrals/invite-center`
- `/api/profile/referrals/redeem-code`
- `/api/profile/redeem-codes/redeem`
- `/api/profile/analytics/metric`
- `/api/profile/tasks`
- `/api/profile/tasks/{task_id}/claim`
- `/api/profile/save-archives`
- `/api/profile/save-archives/{world_key}`
- `/api/profile/play-stats`
退出条件:钱包、任务、邀请码、充值语义不变。
### 3.9 story
Owner`modules/story/router.rs`。主要 handler`story.rs``combat.rs``runtime_inventory.rs`
- `/api/story/sessions`
- `/api/story/sessions/runtime`
- `/api/story/sessions/{story_session_id}/state`
- `/api/story/sessions/{story_session_id}/runtime-projection`
- `/api/story/sessions/{story_session_id}/actions/resolve`
- `/api/story/sessions/continue`
- `/api/story/battles`
- `/api/story/battles/{battle_state_id}`
- `/api/story/npc/battle`
- `/api/story/battles/resolve`
退出条件:继续使用 story session scoped route;旧 `/api/runtime/story/*` 不重新挂载。
## 4. 执行顺序
1. 新建 `modules/mod.rs` 和低风险子模块骨架。
2. 迁移 health/internal/admin/auth/assets/profile route;每迁一组跑 route 编译测试。
3. 迁移 platform route,特别保护 SSE handler 类型。
4. 迁移 creation/runtime/story route。
5. 清理 `app.rs` 中重复 route 装配,只保留 merge。
6. 用脚本/测试确认 route 字符串未丢失。
## 5. 单 owner 与并行规则
- `app.rs``modules/mod.rs` 必须单 owner。
- 同一能力的 `router.rs` 单 owner。
- 不同能力 router 可并行,但不能同时改公共 middleware、state、auth extractor。
- Handler 文件拆分不属于本阶段;若必须触碰 handler,只允许 import 路径修正。
## 6. 验证命令
```bash
cargo test -p api-server app --manifest-path server-rs/Cargo.toml
cargo check -p api-server --manifest-path server-rs/Cargo.toml
npm run check:server-rs-ddd
npm run check:encoding
git diff --check
```
可选人工 smoke
```bash
npm run api-server
curl -fsS http://127.0.0.1:<API_PORT>/healthz
```
禁止用 `api-server:maincloud` 作为 smoke。
@@ -0,0 +1,151 @@
# 复杂媒体资产链路 Adapter 扩展计划
状态:待 D1 线执行;依赖 C 线图片 Adapter 的持久化底座稳定后开始
日期:2026-05-14
范围:只约束 `server-rs/crates/api-server` 内复杂媒体资产的持久化与绑定复用方式;不把音频、视频、GLB、角色工作流强行塞入图片生成 Adapter;不改 HTTP contract、DTO、SpacetimeDB schema、OSS 访问策略、前端行为和计费语义。
## 1. 目标
在图片 Adapter 收口之后,抽出可复用的“媒体持久化 + asset_object confirm + entity binding”底座,让复杂媒体链路减少重复代码,但保留各自 provider、任务轮询、业务语义和回包契约。
目标链路:
```text
provider 或外部任务产物
-> media source 归一(remote URL / data URL / base64 / bytes / object key
-> MIME/extension/文件名归一
-> OSS private upload 或确认已有 object key
-> 可选 HEAD/大小/类型检查
-> module-assets asset_object confirm
-> asset entity binding
-> 返回调用方需要的 legacy path/object key/asset id/metadata
```
本计划不定义新的公开 API,只定义 `api-server` 内部复用能力。首批完成必须至少覆盖:
- Visual Novel / 通用创作音频:背景音乐、音效任务发布后的音频下载入库。
- Custom World opening CGstoryboard 图片可走图片 Adapter,最终视频走复杂媒体底座。
- Character visual / animation:角色视觉、参考视频、导入视频和 workflow cache 的持久化复用。
- Match3D / Puzzle 资产链路中非图片资产:背景音乐、历史 GLB/Hyper3D 代理边界只做复用,不恢复新草稿 GLB 回退。
## 2. Owner 与禁止改动范围
OwnerD1 复杂媒体 Adapter agent。
单 owner 文件/目录:
- `server-rs/crates/api-server/src/modules/assets/media_assets/*`(建议新增)
- `server-rs/crates/api-server/src/vector_engine_audio_generation.rs` 中音频持久化接线
- `server-rs/crates/api-server/src/character_visual_assets.rs` 中角色视觉持久化接线
- `server-rs/crates/api-server/src/character_animation_assets.rs` 中角色动作/视频持久化接线
- `server-rs/crates/api-server/src/hyper3d.rs` 中历史模型代理持久化接线
禁止本阶段修改:
- `asset_billing.rs`:复杂媒体 Adapter 不扣费、不退款、不判断钱包。
- `shared-contracts`:不新增或改动公开 DTO。
- `spacetime-module` schema/procedure:不新增表、不改 reducer 签名。
- 前端页面、service、路由路径。
- provider 策略:不切换 Suno/Vidu/Ark/Hyper3D/DashScope/OpenAI 模型,不改任务轮询超时语义。
- `/generated-*` 直读代理:禁止恢复;读取仍走 `/api/assets/read-url``/api/assets/read-bytes`
## 3. 建议模块边界
建议目录:
```text
server-rs/crates/api-server/src/modules/assets/media_assets/
mod.rs
persist.rs
source.rs
types.rs
errors.rs
```
建议只暴露 crate 内 API
```rust
pub(crate) async fn persist_generated_media_asset(
state: &AppState,
request: GeneratedMediaAssetPersistRequest,
) -> Result<GeneratedMediaAssetPersistOutput, AppError>
```
Adapter 只负责媒体持久化和资产绑定,不负责:
- 生成 prompt、提交 provider 任务、轮询 provider 状态。
- 玩法 draft/profile JSON 写回。
- 运行态裁决、发布校验、作品可见性。
- 计费、退款、钱包流水。
- 复杂媒体失败后的业务 fallback 决策。
## 4. 链路 inventory 与迁移策略
| 链路 | 当前 owner | 媒体类型 | 来源 | Adapter 复用点 | 禁止改变 | 退出条件 |
| --- | --- | --- | --- | --- | --- | --- |
| Visual Novel 背景音乐 | `vector_engine_audio_generation.rs` | audio | VectorEngine Suno/Vidu task publish URL | 下载、MIME/extension、OSS、confirm、binding | VN 场景字段、task id、错误 envelope、计费外层 | VN 音频生成成功后仍能通过 read-url/read-bytes 读取 |
| 通用创作背景音乐/音效 | `vector_engine_audio_generation.rs` | audio | 同上 | 同上 | 不混用 VN 场景语义 | creation target entity/slot 不变 |
| Custom World opening CG video | `custom_world_ai.rs` 或后续分层文件 | video | Ark/火山视频 task 结果 URL | 视频下载、OSS、confirm、binding | storyboard->video 顺序、固定点数计费、超时错误 | storyboard 图片仍走图片 Adapter,最终视频走 media persist |
| Character visual reference/workflow | `character_visual_assets.rs` | image/cache metadata | GPT image helper、workflow cache | 可复用 media persist 的 source/OSS/confirm/binding;图片生成 provider 不迁入复杂媒体 | 角色 workflow cache 可空继续生成 | 角色视觉发布链路回包字段不变 |
| Character animation publish/import | `character_animation_assets.rs` | video / image sequence | data:video base64、remote video、阶段占位 | data URL/base64 解码、视频 OSS、confirm、binding | stage1 placeholder 语义、import-video contract | 导入视频和发布视频都不再复制 OSS/confirm 代码 |
| Match3D 背景音乐 | `match3d.rs` | audio | 现有生成/上传链路 | 仅复用音频持久化 | 不恢复 Rodin/GLB 新草稿回退 | 图片素材仍按图片 Adapter 计划处理 |
| Puzzle 背景音乐 | `puzzle.rs` | audio | 现有生成/上传链路 | 仅复用音频持久化 | puzzle 运行态和排行榜语义不变 | `puzzle_background_music` kind/binding 不变 |
| Hyper3D/GLB 历史代理 | `hyper3d.rs` | model/glb | Hyper3D Rodin status/download | 如存在转存需求,仅复用 media persist | Match3D 新草稿禁止回退 Rodin/GLB | 历史代理 route contract 不变 |
## 5. 分阶段执行
### D1-0:只抽类型与 source 归一
- 新增 media source 类型:`RemoteUrl``DataUrl``Base64Bytes``Bytes``ExistingObjectKey`
- 新增 `MediaAssetKind`/`GeneratedMediaAssetPersistRequest`/`GeneratedMediaAssetPersistOutput`
- 不接任何调用方。
退出条件:`cargo check -p api-server --manifest-path server-rs/Cargo.toml` 通过;无公开 contract 变化。
### D1-1:接音频持久化
- 先接 Visual Novel 背景音乐/音效。
- 再接通用 creation audio。
- 保留 provider submit/poll/publish 在 `vector_engine_audio_generation.rs` 或后续 application 层。
退出条件:音频 asset kind、entity kind、slot、task id 和错误语义不变;重复下载/OSS/confirm 逻辑减少。
### D1-2:接视频持久化
- 接 opening CG 最终视频。
- 接 character animation import/publish 视频。
- data URL/base64 视频解析必须复用 source 层,禁止各 handler 再各写一份。
退出条件:视频入库后仍走私有资产读取链路;opening CG 固定点数计费外层不变。
### D1-3:接角色工作流与历史模型边界
- 角色视觉/动作 workflow cache 只复用持久化,不改缓存命中策略。
- Hyper3D 只做历史代理可选转存复用,不扩大到 Match3D 新草稿。
退出条件:角色资产发布和历史 Hyper3D route contract 不变;文档状态更新。
## 6. 验收命令
文档或小步接线后必须运行:
```bash
cargo check -p api-server --manifest-path server-rs/Cargo.toml
cargo test -p api-server --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 character_animation --manifest-path server-rs/Cargo.toml
npm run check:encoding
git diff --check
```
如果执行外部 provider smoke,只允许读取本地显式环境变量;日志、文档和测试快照中禁止写出 API key/token。
## 7. 完成定义
D1 完成必须同时满足:
- 至少音频和视频两类复杂媒体通过同一个 media persist 底座。
- 图片生成仍走图片 Adapter,不被复杂媒体接口反向污染。
- 计费外层、HTTP route、DTO、SpacetimeDB schema、OSS 私有读取链路全部不变。
- 大 handler 中重复的下载/base64 解码/OSS 上传/asset_object confirm/entity binding 代码有明确删除或替换记录。
- README 与本系列总纲状态同步更新。
@@ -0,0 +1,156 @@
# 生成图片资产 Adapter 收口执行计划
状态:待 C 线执行
日期:2026-05-14
范围:只新增/使用 `api-server` 内部生成图片资产 Adapter;不改 HTTP contract、DTO、SpacetimeDB schema、OSS 访问策略、前端行为、计费语义。
## 1. 目标
把 Big Fish、Square Hole、Custom World 中重复的图片生成持久化链路收口为一个内部能力:
```text
provider 生成
-> 下载 URL 或 base64/data URL 解码
-> MIME/extension 归一
-> OSS private upload
-> 可选 HEAD/存在性确认
-> module-assets asset_object confirm
-> asset entity binding
-> 返回 legacy_public_path/object_key/asset_object_id/mime/extension/task_id/actual_prompt
```
首批必须接入 3 个真实调用方才算完成:
- Big Fish 正式图片:主图、动作图、舞台背景等。
- Square Hole 图片:作品图片槽位重生成。
- Custom World 场景图/封面图/opening storyboard 中的稳定单图链路。
## 2. 建议模块边界
建议放在:
```text
server-rs/crates/api-server/src/modules/assets/generated_image_assets/
mod.rs
adapter.rs
provider.rs
persist.rs
types.rs
errors.rs
```
对外只暴露 crate 内部 API,不进入 `shared-contracts`
```rust
pub(crate) async fn generate_and_persist_image(
state: &AppState,
request: GeneratedImageAssetRequest,
) -> Result<GeneratedImageAssetOutput, AppError>
```
Adapter 不做:
- 不扣费、不退款、不判断钱包。
- 不生成玩法 prompt。
- 不修改玩法 draft/profile JSON。
- 不决定 retry/fallback 策略,除通用下载/入库错误映射外。
- 不写 SpacetimeDB schema/procedure。
## 3. Interface 草案
### 3.1 输入字段
- provider`OpenAiImage` / `VectorEngineGptImage2` / `DashScopeTextToImage` / `PreGeneratedImage`
- prompt / negative_prompt / size / count。
- reference_imagesdata URL、object key、remote URL,由调用方先裁定安全来源。
- outputOSS prefix、path segments、file name stem、access policy。
- assetasset_kind、entity_kind、entity_id、slot、owner_user_id、profile_id、source_job_id、metadata。
- post_process:可选透明背景、裁剪、MIME 强制转换;首版只接入已有透明背景后处理,不新增玩法规则。
- fallback_policy:调用方指定 `ReturnDataUrlOnPersistFailure` / `FailFast` 等,默认 fail fast。
### 3.2 输出字段
- `legacy_public_path`
- `object_key`
- `asset_object_id`
- `mime_type`
- `extension`
- `task_id`
- `actual_prompt`
- `width/height`(若当前链路已有则保留,没有不强行新增 contract)
- `metadata`
## 4. 图片资产 inventory
| 调用方 | provider | 下载/解码 | OSS prefix | asset kind | entity binding | 计费位置 | 降级行为 | Adapter 迁移策略 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Big Fish 正式图 | DashScope `wan2.2-t2i-flash``DASHSCOPE_BASE_URL`/API key 配置 | 创建 task、轮询状态、HTTP GET 图片 URL;可选透明背景后处理 | `LegacyAssetPrefix::BigFishAssets` | 由请求 assetKind 映射主鱼主图、动作图、舞台背景、等级资产等 | `BIG_FISH_ENTITY_KIND = big_fish_session`,绑定 session/entity id + slot | `big_fish.rs` 在调用正式图片动作处 `execute_billable_asset_operation` | 上游失败显式错误;gallery 另有 Spacetime runtime/connect dropped 软降级,不属于 Adapter | 先抽 DashScope provider + persist,保留 prompt/assetKind 校验在 Big Fish application 层 |
| Square Hole 图片重生成 | `openai_image_generation.rs` OpenAI/VectorEngine GPT image helper | `create_openai_image_generation` 返回 URL/base64/data URL 后下载/解码 | `LegacyAssetPrefix::SquareHoleAssets` | 方洞作品图片槽位相关 kind | profile/work entity + image slot | 调用方现有计费包裹保持 | 生成成功但入库失败返回 Data URL 的兼容行为必须保留 | 使用 `fallback_policy = ReturnDataUrlOnPersistFailure`slot/作品 JSON 更新仍在 Square Hole 层 |
| Custom World 场景图 | VectorEngine GPT image 2 / OpenAI helper | URL 下载或 base64 解码 | `LegacyAssetPrefix::CustomWorldScenes` | scene image kind | profile/landmark/scene entity slot | `custom_world_ai.rs` 场景图调用处包裹 | entity/scene 文本生成存在 fallback;图片入库失败按当前错误口径 | 抽 `persist_custom_world_asset` 中图片分支;scene/npc/profile 更新留在 Custom World 层 |
| Custom World 封面图 | VectorEngine GPT image 2 / OpenAI helper | 同上 | Custom World cover prefix/segments | cover image kind | `custom_world_profile`/profile id + cover slot | `execute_billable_asset_operation` | 失败显式错误 | 接入同一 Adapter,保留 cover upload 手动上传链路不混入生成图 provider |
| Opening CG storyboard | GPT image 2 | 生成 storyboard 图片并下载 | Custom World opening prefix/segments | `custom_world_opening_cg_storyboard` | `custom_world_profile` + `opening_cg_storyboard` | opening CG 固定点数计费外层 | storyboard 成功后才进入视频;失败显式错误 | 可复用图片 Adapter,但 opening video 仍归复杂媒体计划 |
## 5. 迁移步骤
### 阶段 C1:抽只读类型和 persist 能力
- 新增 `GeneratedImageAssetRequest/Output`
- 抽 OSS put、asset_object confirm、entity binding 公共 persist。
- 先不接 provider,允许调用方传入已下载图片 bytes。
- 迁移目标:Square Hole 或 Custom World 中最小一条 persist helper。
退出条件:一条链路编译通过;原 HTTP 回包不变。
### 阶段 C2:接 OpenAI/VectorEngine provider
- 封装 `openai_image_generation.rs` 的 settings/client/create/download 结果归一。
- Square Hole 接入 provider + persist。
- Custom World 场景图/封面图接入。
退出条件:Square Hole 生成成功但入库失败回退 Data URL 的行为仍被测试或代码路径覆盖。
### 阶段 C3:接 DashScope provider
- 抽 Big Fish DashScope settings/client/task create/poll/download。
- 保留 Big Fish assetKind、level、motionKey、prompt 构造、透明背景策略在 Big Fish 层。
- 正式图入库走同一 persist。
退出条件:Big Fish 不再有独立 OSS + asset_object + binding 重复实现;计费外层不变。
### 阶段 C4:删除重复 helper
- 删除已迁移的私有 persist/download helper。
- 保留 provider 特定错误 message 与现有 envelope 尽量一致。
- 更新本系列文档状态。
退出条件:三类调用方都经过 Adapter;旧 helper 无未使用残留。
## 6. 单 owner 与并行规则
单 owner
- `modules/assets/generated_image_assets/*`
- `openai_image_generation.rs` 如果需要改 public helper
- `asset_billing.rs` 禁止本阶段修改,除非单独批准
可并行:
- Big Fish 接入 owner 只改 `big_fish.rs` 和对应 tests。
- Square Hole 接入 owner 只改 `square_hole.rs`
- Custom World 接入 owner 只改 `custom_world_ai.rs`
合并顺序必须是:Adapter skeleton -> Square Hole/Custom World -> Big Fish -> 清理。
## 7. 验证命令
```bash
cargo check -p api-server --manifest-path server-rs/Cargo.toml
cargo test -p api-server --manifest-path server-rs/Cargo.toml
cargo test -p api-server big_fish --manifest-path server-rs/Cargo.toml
cargo test -p api-server square_hole --manifest-path server-rs/Cargo.toml
cargo test -p api-server custom_world --manifest-path server-rs/Cargo.toml
npm run check:encoding
git diff --check
```
如执行外部 provider smoke,只允许使用显式本地环境变量;不要在日志或文档中写出 API key/token。
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,331 @@
use super::*;
pub(super) fn map_big_fish_session_response(
session: BigFishSessionRecord,
) -> BigFishSessionSnapshotResponse {
BigFishSessionSnapshotResponse {
session_id: session.session_id,
current_turn: session.current_turn,
progress_percent: session.progress_percent,
stage: session.stage,
anchor_pack: map_big_fish_anchor_pack_response(session.anchor_pack),
draft: session.draft.map(map_big_fish_draft_response),
asset_slots: session
.asset_slots
.into_iter()
.map(map_big_fish_asset_slot_response)
.collect(),
asset_coverage: map_big_fish_asset_coverage_response(session.asset_coverage),
messages: session
.messages
.into_iter()
.map(map_big_fish_agent_message_response)
.collect(),
last_assistant_reply: session.last_assistant_reply,
publish_ready: session.publish_ready,
updated_at: session.updated_at,
}
}
pub(super) fn map_big_fish_anchor_pack_response(
anchor_pack: BigFishAnchorPackRecord,
) -> BigFishAnchorPackResponse {
BigFishAnchorPackResponse {
gameplay_promise: map_big_fish_anchor_item_response(anchor_pack.gameplay_promise),
ecology_visual_theme: map_big_fish_anchor_item_response(anchor_pack.ecology_visual_theme),
growth_ladder: map_big_fish_anchor_item_response(anchor_pack.growth_ladder),
risk_tempo: map_big_fish_anchor_item_response(anchor_pack.risk_tempo),
}
}
pub(super) fn map_big_fish_anchor_item_response(
anchor: BigFishAnchorItemRecord,
) -> BigFishAnchorItemResponse {
BigFishAnchorItemResponse {
key: anchor.key,
label: anchor.label,
value: anchor.value,
status: anchor.status,
}
}
pub(super) fn map_big_fish_draft_response(
draft: BigFishGameDraftRecord,
) -> BigFishGameDraftResponse {
BigFishGameDraftResponse {
title: draft.title,
subtitle: draft.subtitle,
core_fun: draft.core_fun,
ecology_theme: draft.ecology_theme,
levels: draft
.levels
.into_iter()
.map(map_big_fish_level_response)
.collect(),
background: map_big_fish_background_response(draft.background),
runtime_params: map_big_fish_runtime_params_response(draft.runtime_params),
}
}
pub(super) fn map_big_fish_level_response(
level: BigFishLevelBlueprintRecord,
) -> BigFishLevelBlueprintResponse {
BigFishLevelBlueprintResponse {
level: level.level,
name: level.name,
one_line_fantasy: level.one_line_fantasy,
text_description: level.text_description,
silhouette_direction: level.silhouette_direction,
size_ratio: level.size_ratio,
visual_description: level.visual_description,
visual_prompt_seed: level.visual_prompt_seed,
idle_motion_description: level.idle_motion_description,
move_motion_description: level.move_motion_description,
motion_prompt_seed: level.motion_prompt_seed,
merge_source_level: level.merge_source_level,
prey_window: level.prey_window,
threat_window: level.threat_window,
is_final_level: level.is_final_level,
}
}
pub(super) fn map_big_fish_background_response(
background: BigFishBackgroundBlueprintRecord,
) -> BigFishBackgroundBlueprintResponse {
BigFishBackgroundBlueprintResponse {
theme: background.theme,
color_mood: background.color_mood,
foreground_hints: background.foreground_hints,
midground_composition: background.midground_composition,
background_depth: background.background_depth,
safe_play_area_hint: background.safe_play_area_hint,
spawn_edge_hint: background.spawn_edge_hint,
background_prompt_seed: background.background_prompt_seed,
}
}
pub(super) fn map_big_fish_runtime_params_response(
params: BigFishRuntimeParamsRecord,
) -> BigFishRuntimeParamsResponse {
BigFishRuntimeParamsResponse {
level_count: params.level_count,
merge_count_per_upgrade: params.merge_count_per_upgrade,
spawn_target_count: params.spawn_target_count,
leader_move_speed: params.leader_move_speed,
follower_catch_up_speed: params.follower_catch_up_speed,
offscreen_cull_seconds: params.offscreen_cull_seconds,
prey_spawn_delta_levels: params.prey_spawn_delta_levels,
threat_spawn_delta_levels: params.threat_spawn_delta_levels,
win_level: params.win_level,
}
}
pub(super) fn map_big_fish_asset_slot_response(
slot: BigFishAssetSlotRecord,
) -> BigFishAssetSlotResponse {
BigFishAssetSlotResponse {
slot_id: slot.slot_id,
asset_kind: slot.asset_kind,
level: slot.level,
motion_key: slot.motion_key,
status: slot.status,
asset_url: slot.asset_url,
prompt_snapshot: slot.prompt_snapshot,
updated_at: slot.updated_at,
}
}
pub(super) fn map_big_fish_asset_coverage_response(
coverage: BigFishAssetCoverageRecord,
) -> BigFishAssetCoverageResponse {
BigFishAssetCoverageResponse {
level_main_image_ready_count: coverage.level_main_image_ready_count,
level_motion_ready_count: coverage.level_motion_ready_count,
background_ready: coverage.background_ready,
required_level_count: coverage.required_level_count,
publish_ready: coverage.publish_ready,
blockers: coverage.blockers,
}
}
pub(super) fn map_big_fish_run_response(
run: BigFishRuntimeRunRecord,
) -> BigFishRuntimeSnapshotResponse {
BigFishRuntimeSnapshotResponse {
run_id: run.run_id,
session_id: run.session_id,
status: run.status,
tick: run.tick,
player_level: run.player_level,
win_level: run.win_level,
leader_entity_id: run.leader_entity_id,
owned_entities: run
.owned_entities
.into_iter()
.map(map_big_fish_runtime_entity_response)
.collect(),
wild_entities: run
.wild_entities
.into_iter()
.map(map_big_fish_runtime_entity_response)
.collect(),
camera_center: map_big_fish_vector2_response(run.camera_center),
last_input: map_big_fish_vector2_response(run.last_input),
event_log: run.event_log,
updated_at: run.updated_at,
}
}
pub(super) fn map_big_fish_runtime_entity_response(
entity: BigFishRuntimeEntityRecord,
) -> BigFishRuntimeEntityResponse {
BigFishRuntimeEntityResponse {
entity_id: entity.entity_id,
level: entity.level,
position: map_big_fish_vector2_response(entity.position),
radius: entity.radius,
offscreen_seconds: entity.offscreen_seconds,
}
}
pub(super) fn map_big_fish_vector2_response(
vector: BigFishVector2Record,
) -> BigFishVector2Response {
BigFishVector2Response {
x: vector.x,
y: vector.y,
}
}
pub(super) async fn compile_big_fish_draft_only(
state: &AppState,
session_id: String,
owner_user_id: String,
now: i64,
) -> Result<BigFishSessionRecord, SpacetimeClientError> {
// 中文注释:大鱼吃小鱼草稿阶段只负责编译结果页草稿,不在这一步串行生成主图、动作或背景。
// 这些资产统一留在结果页工坊按需触发,避免 compile action 因长耗时资产任务卡在首步等待态。
let session =
load_big_fish_session_with_retry(state, session_id.clone(), owner_user_id.clone()).await?;
let anchor_pack = map_record_anchor_pack_to_domain(&session.anchor_pack);
let compiled_draft =
compile_big_fish_draft_with_fallback(state.llm_client(), &anchor_pack).await;
let draft_json = serde_json::to_string(&compiled_draft).ok();
state
.spacetime_client()
.compile_big_fish_draft(BigFishDraftCompileRecordInput {
session_id,
owner_user_id,
draft_json,
compiled_at_micros: now,
})
.await
}
pub(super) async fn load_big_fish_session_with_retry(
state: &AppState,
session_id: String,
owner_user_id: String,
) -> Result<BigFishSessionRecord, SpacetimeClientError> {
let mut last_retryable_error = None;
for attempt in 0..2 {
match state
.spacetime_client()
.get_big_fish_session(session_id.clone(), owner_user_id.clone())
.await
{
Ok(session) => return Ok(session),
Err(error @ SpacetimeClientError::Timeout)
| Err(error @ SpacetimeClientError::ConnectDropped) => {
last_retryable_error = Some(error);
if attempt == 0 {
sleep(Duration::from_millis(250)).await;
continue;
}
}
Err(error) => return Err(error),
}
}
Err(last_retryable_error.unwrap_or(SpacetimeClientError::Timeout))
}
pub(super) fn map_record_anchor_pack_to_domain(
anchor_pack: &BigFishAnchorPackRecord,
) -> module_big_fish::BigFishAnchorPack {
module_big_fish::BigFishAnchorPack {
gameplay_promise: map_record_anchor_item_to_domain(&anchor_pack.gameplay_promise),
ecology_visual_theme: map_record_anchor_item_to_domain(&anchor_pack.ecology_visual_theme),
growth_ladder: map_record_anchor_item_to_domain(&anchor_pack.growth_ladder),
risk_tempo: map_record_anchor_item_to_domain(&anchor_pack.risk_tempo),
}
}
pub(super) fn map_record_anchor_item_to_domain(
anchor_item: &BigFishAnchorItemRecord,
) -> module_big_fish::BigFishAnchorItem {
module_big_fish::BigFishAnchorItem {
key: anchor_item.key.clone(),
label: anchor_item.label.clone(),
value: anchor_item.value.clone(),
status: match anchor_item.status.as_str() {
"confirmed" => module_big_fish::BigFishAnchorStatus::Confirmed,
"locked" => module_big_fish::BigFishAnchorStatus::Locked,
"inferred" => module_big_fish::BigFishAnchorStatus::Inferred,
_ => module_big_fish::BigFishAnchorStatus::Missing,
},
}
}
pub(super) fn map_big_fish_agent_message_response(
message: BigFishAgentMessageRecord,
) -> BigFishAgentMessageResponse {
BigFishAgentMessageResponse {
id: message.message_id,
role: message.role,
kind: message.kind,
text: message.text,
created_at: message.created_at,
}
}
pub(super) fn map_big_fish_work_summary_response(
state: &AppState,
item: BigFishWorkSummaryRecord,
) -> BigFishWorkSummaryResponse {
let author = resolve_work_author_by_user_id(state, &item.owner_user_id, None, None);
BigFishWorkSummaryResponse {
work_id: item.work_id,
source_session_id: item.source_session_id,
owner_user_id: item.owner_user_id,
author_display_name: author.display_name,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
cover_image_src: item.cover_image_src,
status: item.status,
updated_at: current_timestamp_micros_to_string(item.updated_at_micros),
published_at: item
.published_at_micros
.map(current_timestamp_micros_to_string),
publish_ready: item.publish_ready,
level_count: item.level_count,
level_main_image_ready_count: item.level_main_image_ready_count,
level_motion_ready_count: item.level_motion_ready_count,
background_ready: item.background_ready,
play_count: item.play_count,
remix_count: item.remix_count,
like_count: item.like_count,
recent_play_count_7d: item.recent_play_count_7d,
}
}
pub(super) fn build_big_fish_welcome_text(seed_text: &str) -> String {
if seed_text.trim().is_empty() {
return "我会先帮你确定大鱼吃小鱼的核心锚点。可以从主题生态、成长阶梯或风险节奏开始。"
.to_string();
}
"我已经收到你的玩法起点,会先把它整理成锚点并准备结果页草稿。".to_string()
}
+2 -376
View File
@@ -2770,383 +2770,9 @@ async fn upsert_custom_world_draft_foundation_progress(
})
}
fn map_custom_world_library_entry_response(
state: &AppState,
entry: CustomWorldLibraryEntryRecord,
) -> CustomWorldLibraryEntryResponse {
let author = resolve_work_author_by_user_id(
state,
&entry.owner_user_id,
Some(&entry.author_display_name),
entry.author_public_user_code.as_deref(),
);
CustomWorldLibraryEntryResponse {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
public_work_code: entry.public_work_code,
author_public_user_code: author.public_user_code.or(entry.author_public_user_code),
profile: entry.profile,
visibility: entry.visibility,
published_at: entry.published_at,
updated_at: entry.updated_at,
author_display_name: author.display_name,
world_name: entry.world_name,
subtitle: entry.subtitle,
summary_text: entry.summary_text,
cover_image_src: entry.cover_image_src,
theme_mode: entry.theme_mode,
playable_npc_count: entry.playable_npc_count,
landmark_count: entry.landmark_count,
play_count: entry.play_count,
remix_count: entry.remix_count,
like_count: entry.like_count,
recent_play_count_7d: 0,
}
}
mod mappers;
fn map_custom_world_library_entry_response_from_work_summary(
state: &AppState,
item: CustomWorldWorkSummaryRecord,
owner_user_id: &str,
) -> Option<CustomWorldLibraryEntryResponse> {
let profile_id = item.profile_id.as_ref()?.clone();
let profile = build_custom_world_library_list_profile_payload(&item, &profile_id);
let author = resolve_work_author_by_user_id(state, owner_user_id, None, None);
Some(CustomWorldLibraryEntryResponse {
owner_user_id: owner_user_id.to_string(),
public_work_code: (item.status == "published")
.then(|| build_public_work_code_from_profile_id(&profile_id)),
profile_id,
author_public_user_code: author.public_user_code,
profile,
visibility: item.status,
published_at: item.published_at,
updated_at: item.updated_at,
author_display_name: author.display_name,
world_name: item.title,
subtitle: item.subtitle,
summary_text: item.summary,
cover_image_src: item.cover_image_src,
theme_mode: "mythic".to_string(),
playable_npc_count: item.playable_npc_count,
landmark_count: item.landmark_count,
play_count: 0,
remix_count: 0,
like_count: 0,
recent_play_count_7d: 0,
})
}
fn build_public_work_code_from_profile_id(profile_id: &str) -> String {
let digits = profile_id
.chars()
.filter(|character| character.is_ascii_digit())
.collect::<String>();
let normalized_digits = if digits.is_empty() {
let checksum = profile_id.bytes().fold(0u32, |accumulator, value| {
accumulator.wrapping_mul(131) + u32::from(value)
});
format!("{:08}", checksum % 100_000_000)
} else {
format!("{:0>8}", &digits[digits.len().saturating_sub(8)..])
};
format!("CW-{normalized_digits}")
}
fn build_custom_world_library_list_profile_payload(
item: &CustomWorldWorkSummaryRecord,
profile_id: &str,
) -> Value {
json!({
"id": profile_id,
"name": item.title,
"subtitle": item.subtitle,
"summary": item.summary,
"tone": "",
"playerGoal": "",
"settingText": "",
"themeMode": "mythic",
"templateWorldType": "WUXIA",
"compatibilityTemplateWorldType": Value::Null,
"cover": item.cover_image_src.as_ref().map(|image_src| json!({
"sourceType": "generated",
"imageSrc": image_src,
})),
"majorFactions": [],
"coreConflicts": [],
"playableNpcs": [],
"storyNpcs": [],
"items": [],
"camp": Value::Null,
"landmarks": [],
"ownedSettingLayers": Value::Null,
})
}
fn map_custom_world_gallery_card_response(
state: &AppState,
entry: CustomWorldGalleryEntryRecord,
) -> CustomWorldGalleryCardResponse {
let author = resolve_work_author_by_user_id(
state,
&entry.owner_user_id,
Some(&entry.author_display_name),
Some(&entry.author_public_user_code),
);
CustomWorldGalleryCardResponse {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
public_work_code: entry.public_work_code,
author_public_user_code: author
.public_user_code
.unwrap_or(entry.author_public_user_code),
visibility: entry.visibility,
published_at: entry.published_at,
updated_at: entry.updated_at,
author_display_name: author.display_name,
world_name: entry.world_name,
subtitle: entry.subtitle,
summary_text: entry.summary_text,
cover_image_src: entry.cover_image_src,
theme_mode: entry.theme_mode,
playable_npc_count: entry.playable_npc_count,
landmark_count: entry.landmark_count,
play_count: entry.play_count,
remix_count: entry.remix_count,
like_count: entry.like_count,
recent_play_count_7d: entry.recent_play_count_7d,
}
}
fn map_custom_world_work_summary_response(
item: CustomWorldWorkSummaryRecord,
) -> CustomWorldWorkSummaryResponse {
CustomWorldWorkSummaryResponse {
work_id: item.work_id,
source_type: item.source_type,
status: item.status,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
cover_image_src: item.cover_image_src,
cover_render_mode: item.cover_render_mode,
cover_character_image_srcs: item.cover_character_image_srcs,
updated_at: item.updated_at,
published_at: item.published_at,
stage: item.stage,
stage_label: item.stage_label,
playable_npc_count: item.playable_npc_count,
landmark_count: item.landmark_count,
role_visual_ready_count: item.role_visual_ready_count,
role_animation_ready_count: item.role_animation_ready_count,
role_asset_summary_label: item.role_asset_summary_label,
session_id: item.session_id,
profile_id: item.profile_id,
can_resume: item.can_resume,
can_enter_world: item.can_enter_world,
blocker_count: item.blocker_count,
publish_ready: item.publish_ready,
}
}
fn map_custom_world_agent_session_response(
session: CustomWorldAgentSessionRecord,
) -> CustomWorldAgentSessionSnapshotResponse {
CustomWorldAgentSessionSnapshotResponse {
session_id: session.session_id,
current_turn: session.current_turn,
anchor_content: session.anchor_content,
progress_percent: session.progress_percent,
last_assistant_reply: session.last_assistant_reply,
stage: session.stage,
focus_card_id: session.focus_card_id,
creator_intent: session.creator_intent,
creator_intent_readiness: session.creator_intent_readiness,
anchor_pack: session.anchor_pack,
lock_state: session.lock_state,
draft_profile: session.draft_profile,
messages: session
.messages
.into_iter()
.map(map_custom_world_agent_message_response)
.collect(),
draft_cards: session
.draft_cards
.into_iter()
.map(map_custom_world_draft_card_response)
.collect(),
pending_clarifications: session.pending_clarifications,
suggested_actions: session.suggested_actions,
recommended_replies: session.recommended_replies,
quality_findings: session.quality_findings,
asset_coverage: session.asset_coverage,
checkpoints: session
.checkpoints
.into_iter()
.map(map_custom_world_agent_checkpoint_response)
.collect(),
supported_actions: session
.supported_actions
.into_iter()
.map(map_custom_world_supported_action_response)
.collect(),
publish_gate: session
.publish_gate
.map(map_custom_world_publish_gate_response),
result_preview: session.result_preview,
updated_at: session.updated_at,
}
}
fn build_custom_world_creation_result_view_response(
session: CustomWorldAgentSessionRecord,
) -> CustomWorldCreationResultViewResponse {
let profile_from_preview = session
.result_preview
.as_ref()
.and_then(|preview| preview.get("preview"))
.and_then(normalize_json_object_value);
let profile_from_draft =
if profile_from_preview.is_none() && is_agent_result_stage(session.stage.as_str()) {
normalize_json_object_value(&session.draft_profile)
// 中文注释:legacyResultProfile 只在服务端作为历史会话恢复兜底,
// 前端不再直接解释 legacy 字段的真相优先级。
.or_else(|| {
session
.draft_profile
.get("legacyResultProfile")
.and_then(normalize_json_object_value)
})
} else {
None
};
let (profile, profile_source) = match (profile_from_preview, profile_from_draft) {
(Some(profile), _) => (Some(profile), "result_preview"),
(None, Some(profile)) => (Some(profile), "draft_profile"),
(None, None) => (None, "none"),
};
let publish_ready = session
.publish_gate
.as_ref()
.map(|gate| gate.publish_ready)
.or_else(|| {
session
.result_preview
.as_ref()
.and_then(|preview| preview.get("publishReady"))
.and_then(Value::as_bool)
})
.unwrap_or(false);
let can_enter_world = session
.publish_gate
.as_ref()
.map(|gate| gate.can_enter_world)
.or_else(|| {
session
.result_preview
.as_ref()
.and_then(|preview| preview.get("canEnterWorld"))
.and_then(Value::as_bool)
})
.unwrap_or(false);
let blocker_count = session
.publish_gate
.as_ref()
.map(|gate| gate.blocker_count)
.or_else(|| {
session
.result_preview
.as_ref()
.and_then(|preview| preview.get("blockers"))
.and_then(Value::as_array)
.map(|items| items.len() as u32)
})
.unwrap_or(0);
let has_profile = profile.is_some();
let generation_failed = session.stage == "error"
|| session
.messages
.iter()
.any(|message| message.kind == "warning" && message.text.contains("失败"));
let result_stage = is_agent_result_stage(session.stage.as_str());
let (
target_stage,
generation_view_source,
result_view_source,
recovery_action,
recovery_reason,
) = if has_profile && result_stage {
(
"custom-world-result",
None,
Some("agent-draft"),
"open_result",
None,
)
} else if generation_failed {
(
"custom-world-generating",
Some("agent-draft-foundation"),
None,
"resume_generation",
Some("当前草稿生成失败或缺少结果预览,需要回到生成过程页继续处理。"),
)
} else {
(
"agent-workspace",
None,
None,
"continue_agent",
Some("当前会话还没有可打开的结果页真相源。"),
)
};
let can_sync_result_profile = is_agent_result_profile_sync_stage(session.stage.as_str());
CustomWorldCreationResultViewResponse {
session: map_custom_world_agent_session_response(session),
profile,
profile_source: profile_source.to_string(),
target_stage: target_stage.to_string(),
generation_view_source: generation_view_source.map(ToOwned::to_owned),
result_view_source: result_view_source.map(ToOwned::to_owned),
can_autosave_library: has_profile && result_stage,
can_sync_result_profile,
publish_ready,
can_enter_world,
blocker_count,
recovery_action: recovery_action.to_string(),
recovery_reason: recovery_reason.map(ToOwned::to_owned),
}
}
fn is_agent_result_stage(stage: &str) -> bool {
matches!(
stage,
"object_refining"
| "visual_refining"
| "long_tail_review"
| "ready_to_publish"
| "published"
)
}
fn is_agent_result_profile_sync_stage(stage: &str) -> bool {
matches!(
stage,
"object_refining" | "visual_refining" | "long_tail_review" | "ready_to_publish"
)
}
fn normalize_json_object_value(value: &Value) -> Option<Value> {
value.as_object().and_then(|object| {
if object.is_empty() {
None
} else {
Some(Value::Object(object.clone()))
}
})
}
use mappers::*;
fn log_custom_world_publish_gate_diagnostics(
source: &str,
@@ -0,0 +1,379 @@
use super::*;
pub(super) fn map_custom_world_library_entry_response(
state: &AppState,
entry: CustomWorldLibraryEntryRecord,
) -> CustomWorldLibraryEntryResponse {
let author = resolve_work_author_by_user_id(
state,
&entry.owner_user_id,
Some(&entry.author_display_name),
entry.author_public_user_code.as_deref(),
);
CustomWorldLibraryEntryResponse {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
public_work_code: entry.public_work_code,
author_public_user_code: author.public_user_code.or(entry.author_public_user_code),
profile: entry.profile,
visibility: entry.visibility,
published_at: entry.published_at,
updated_at: entry.updated_at,
author_display_name: author.display_name,
world_name: entry.world_name,
subtitle: entry.subtitle,
summary_text: entry.summary_text,
cover_image_src: entry.cover_image_src,
theme_mode: entry.theme_mode,
playable_npc_count: entry.playable_npc_count,
landmark_count: entry.landmark_count,
play_count: entry.play_count,
remix_count: entry.remix_count,
like_count: entry.like_count,
recent_play_count_7d: 0,
}
}
pub(super) fn map_custom_world_library_entry_response_from_work_summary(
state: &AppState,
item: CustomWorldWorkSummaryRecord,
owner_user_id: &str,
) -> Option<CustomWorldLibraryEntryResponse> {
let profile_id = item.profile_id.as_ref()?.clone();
let profile = build_custom_world_library_list_profile_payload(&item, &profile_id);
let author = resolve_work_author_by_user_id(state, owner_user_id, None, None);
Some(CustomWorldLibraryEntryResponse {
owner_user_id: owner_user_id.to_string(),
public_work_code: (item.status == "published")
.then(|| build_public_work_code_from_profile_id(&profile_id)),
profile_id,
author_public_user_code: author.public_user_code,
profile,
visibility: item.status,
published_at: item.published_at,
updated_at: item.updated_at,
author_display_name: author.display_name,
world_name: item.title,
subtitle: item.subtitle,
summary_text: item.summary,
cover_image_src: item.cover_image_src,
theme_mode: "mythic".to_string(),
playable_npc_count: item.playable_npc_count,
landmark_count: item.landmark_count,
play_count: 0,
remix_count: 0,
like_count: 0,
recent_play_count_7d: 0,
})
}
pub(super) fn build_public_work_code_from_profile_id(profile_id: &str) -> String {
let digits = profile_id
.chars()
.filter(|character| character.is_ascii_digit())
.collect::<String>();
let normalized_digits = if digits.is_empty() {
let checksum = profile_id.bytes().fold(0u32, |accumulator, value| {
accumulator.wrapping_mul(131) + u32::from(value)
});
format!("{:08}", checksum % 100_000_000)
} else {
format!("{:0>8}", &digits[digits.len().saturating_sub(8)..])
};
format!("CW-{normalized_digits}")
}
pub(super) fn build_custom_world_library_list_profile_payload(
item: &CustomWorldWorkSummaryRecord,
profile_id: &str,
) -> Value {
json!({
"id": profile_id,
"name": item.title,
"subtitle": item.subtitle,
"summary": item.summary,
"tone": "",
"playerGoal": "",
"settingText": "",
"themeMode": "mythic",
"templateWorldType": "WUXIA",
"compatibilityTemplateWorldType": Value::Null,
"cover": item.cover_image_src.as_ref().map(|image_src| json!({
"sourceType": "generated",
"imageSrc": image_src,
})),
"majorFactions": [],
"coreConflicts": [],
"playableNpcs": [],
"storyNpcs": [],
"items": [],
"camp": Value::Null,
"landmarks": [],
"ownedSettingLayers": Value::Null,
})
}
pub(super) fn map_custom_world_gallery_card_response(
state: &AppState,
entry: CustomWorldGalleryEntryRecord,
) -> CustomWorldGalleryCardResponse {
let author = resolve_work_author_by_user_id(
state,
&entry.owner_user_id,
Some(&entry.author_display_name),
Some(&entry.author_public_user_code),
);
CustomWorldGalleryCardResponse {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
public_work_code: entry.public_work_code,
author_public_user_code: author
.public_user_code
.unwrap_or(entry.author_public_user_code),
visibility: entry.visibility,
published_at: entry.published_at,
updated_at: entry.updated_at,
author_display_name: author.display_name,
world_name: entry.world_name,
subtitle: entry.subtitle,
summary_text: entry.summary_text,
cover_image_src: entry.cover_image_src,
theme_mode: entry.theme_mode,
playable_npc_count: entry.playable_npc_count,
landmark_count: entry.landmark_count,
play_count: entry.play_count,
remix_count: entry.remix_count,
like_count: entry.like_count,
recent_play_count_7d: entry.recent_play_count_7d,
}
}
pub(super) fn map_custom_world_work_summary_response(
item: CustomWorldWorkSummaryRecord,
) -> CustomWorldWorkSummaryResponse {
CustomWorldWorkSummaryResponse {
work_id: item.work_id,
source_type: item.source_type,
status: item.status,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
cover_image_src: item.cover_image_src,
cover_render_mode: item.cover_render_mode,
cover_character_image_srcs: item.cover_character_image_srcs,
updated_at: item.updated_at,
published_at: item.published_at,
stage: item.stage,
stage_label: item.stage_label,
playable_npc_count: item.playable_npc_count,
landmark_count: item.landmark_count,
role_visual_ready_count: item.role_visual_ready_count,
role_animation_ready_count: item.role_animation_ready_count,
role_asset_summary_label: item.role_asset_summary_label,
session_id: item.session_id,
profile_id: item.profile_id,
can_resume: item.can_resume,
can_enter_world: item.can_enter_world,
blocker_count: item.blocker_count,
publish_ready: item.publish_ready,
}
}
pub(super) fn map_custom_world_agent_session_response(
session: CustomWorldAgentSessionRecord,
) -> CustomWorldAgentSessionSnapshotResponse {
CustomWorldAgentSessionSnapshotResponse {
session_id: session.session_id,
current_turn: session.current_turn,
anchor_content: session.anchor_content,
progress_percent: session.progress_percent,
last_assistant_reply: session.last_assistant_reply,
stage: session.stage,
focus_card_id: session.focus_card_id,
creator_intent: session.creator_intent,
creator_intent_readiness: session.creator_intent_readiness,
anchor_pack: session.anchor_pack,
lock_state: session.lock_state,
draft_profile: session.draft_profile,
messages: session
.messages
.into_iter()
.map(map_custom_world_agent_message_response)
.collect(),
draft_cards: session
.draft_cards
.into_iter()
.map(map_custom_world_draft_card_response)
.collect(),
pending_clarifications: session.pending_clarifications,
suggested_actions: session.suggested_actions,
recommended_replies: session.recommended_replies,
quality_findings: session.quality_findings,
asset_coverage: session.asset_coverage,
checkpoints: session
.checkpoints
.into_iter()
.map(map_custom_world_agent_checkpoint_response)
.collect(),
supported_actions: session
.supported_actions
.into_iter()
.map(map_custom_world_supported_action_response)
.collect(),
publish_gate: session
.publish_gate
.map(map_custom_world_publish_gate_response),
result_preview: session.result_preview,
updated_at: session.updated_at,
}
}
pub(super) fn build_custom_world_creation_result_view_response(
session: CustomWorldAgentSessionRecord,
) -> CustomWorldCreationResultViewResponse {
let profile_from_preview = session
.result_preview
.as_ref()
.and_then(|preview| preview.get("preview"))
.and_then(normalize_json_object_value);
let profile_from_draft =
if profile_from_preview.is_none() && is_agent_result_stage(session.stage.as_str()) {
normalize_json_object_value(&session.draft_profile)
// 中文注释:legacyResultProfile 只在服务端作为历史会话恢复兜底,
// 前端不再直接解释 legacy 字段的真相优先级。
.or_else(|| {
session
.draft_profile
.get("legacyResultProfile")
.and_then(normalize_json_object_value)
})
} else {
None
};
let (profile, profile_source) = match (profile_from_preview, profile_from_draft) {
(Some(profile), _) => (Some(profile), "result_preview"),
(None, Some(profile)) => (Some(profile), "draft_profile"),
(None, None) => (None, "none"),
};
let publish_ready = session
.publish_gate
.as_ref()
.map(|gate| gate.publish_ready)
.or_else(|| {
session
.result_preview
.as_ref()
.and_then(|preview| preview.get("publishReady"))
.and_then(Value::as_bool)
})
.unwrap_or(false);
let can_enter_world = session
.publish_gate
.as_ref()
.map(|gate| gate.can_enter_world)
.or_else(|| {
session
.result_preview
.as_ref()
.and_then(|preview| preview.get("canEnterWorld"))
.and_then(Value::as_bool)
})
.unwrap_or(false);
let blocker_count = session
.publish_gate
.as_ref()
.map(|gate| gate.blocker_count)
.or_else(|| {
session
.result_preview
.as_ref()
.and_then(|preview| preview.get("blockers"))
.and_then(Value::as_array)
.map(|items| items.len() as u32)
})
.unwrap_or(0);
let has_profile = profile.is_some();
let generation_failed = session.stage == "error"
|| session
.messages
.iter()
.any(|message| message.kind == "warning" && message.text.contains("失败"));
let result_stage = is_agent_result_stage(session.stage.as_str());
let (
target_stage,
generation_view_source,
result_view_source,
recovery_action,
recovery_reason,
) = if has_profile && result_stage {
(
"custom-world-result",
None,
Some("agent-draft"),
"open_result",
None,
)
} else if generation_failed {
(
"custom-world-generating",
Some("agent-draft-foundation"),
None,
"resume_generation",
Some("当前草稿生成失败或缺少结果预览,需要回到生成过程页继续处理。"),
)
} else {
(
"agent-workspace",
None,
None,
"continue_agent",
Some("当前会话还没有可打开的结果页真相源。"),
)
};
let can_sync_result_profile = is_agent_result_profile_sync_stage(session.stage.as_str());
CustomWorldCreationResultViewResponse {
session: map_custom_world_agent_session_response(session),
profile,
profile_source: profile_source.to_string(),
target_stage: target_stage.to_string(),
generation_view_source: generation_view_source.map(ToOwned::to_owned),
result_view_source: result_view_source.map(ToOwned::to_owned),
can_autosave_library: has_profile && result_stage,
can_sync_result_profile,
publish_ready,
can_enter_world,
blocker_count,
recovery_action: recovery_action.to_string(),
recovery_reason: recovery_reason.map(ToOwned::to_owned),
}
}
pub(super) fn is_agent_result_stage(stage: &str) -> bool {
matches!(
stage,
"object_refining"
| "visual_refining"
| "long_tail_review"
| "ready_to_publish"
| "published"
)
}
pub(super) fn is_agent_result_profile_sync_stage(stage: &str) -> bool {
matches!(
stage,
"object_refining" | "visual_refining" | "long_tail_review" | "ready_to_publish"
)
}
pub(super) fn normalize_json_object_value(value: &Value) -> Option<Value> {
value.as_object().and_then(|object| {
if object.is_empty() {
None
} else {
Some(Value::Object(object.clone()))
}
})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,122 @@
use super::opening_cg::{
map_asset_binding_prepare_error, map_asset_object_prepare_error,
map_custom_world_asset_oss_error, map_custom_world_asset_spacetime_error,
map_custom_world_generated_image_asset_error,
};
use super::*;
pub(super) async fn persist_custom_world_asset(
state: &AppState,
owner_user_id: &str,
upload: PreparedAssetUpload,
mut response: GeneratedAssetResponse,
) -> Result<GeneratedAssetResponse, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let http_client = reqwest::Client::new();
let PreparedAssetUpload {
prefix,
path_segments,
file_name,
content_type,
body,
asset_kind,
entity_kind,
entity_id,
profile_id,
slot,
source_job_id,
} = upload;
let file_stem = file_name
.rsplit_once('.')
.map(|(stem, _)| stem)
.unwrap_or(file_name.as_str())
.to_string();
let prepared =
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
prefix,
path_segments,
file_stem,
image: GeneratedImageAssetDataUrl {
format: normalize_generated_image_asset_mime(content_type.as_str()),
bytes: body,
},
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some(asset_kind.to_string()),
owner_user_id: Some(owner_user_id.to_string()),
entity_kind: Some(entity_kind.to_string()),
entity_id: Some(entity_id.clone()),
slot: Some(slot.to_string()),
provider: None,
task_id: source_job_id.clone(),
},
extra_metadata: profile_id
.as_ref()
.map(|profile_id| BTreeMap::from([("profile_id".to_string(), profile_id.clone())]))
.unwrap_or_default(),
})
.map_err(map_custom_world_generated_image_asset_error)?;
let persisted_mime_type = prepared.format.mime_type.clone();
let put_result = oss_client
.put_object(&http_client, prepared.request)
.await
.map_err(map_custom_world_asset_oss_error)?;
// custom world 图片链正式改为 OSS 真值确认,不再把 put_object 返回值直接当成唯一对象真相。
let head = oss_client
.head_object(
&http_client,
OssHeadObjectRequest {
object_key: put_result.object_key.clone(),
},
)
.await
.map_err(map_custom_world_asset_oss_error)?;
let now_micros = current_utc_micros();
let asset_object = state
.spacetime_client()
.confirm_asset_object(
build_asset_object_upsert_input(
generate_asset_object_id(now_micros),
head.bucket,
head.object_key,
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(persisted_mime_type)),
head.content_length,
head.etag,
asset_kind.to_string(),
source_job_id,
Some(owner_user_id.to_string()),
profile_id.clone(),
Some(entity_id.clone()),
now_micros,
)
.map_err(map_asset_object_prepare_error)?,
)
.await
.map_err(map_custom_world_asset_spacetime_error)?;
state
.spacetime_client()
.bind_asset_object_to_entity(
build_asset_entity_binding_input(
generate_asset_binding_id(now_micros),
asset_object.asset_object_id,
entity_kind.to_string(),
entity_id.clone(),
slot.to_string(),
asset_kind.to_string(),
Some(owner_user_id.to_string()),
profile_id,
now_micros,
)
.map_err(map_asset_binding_prepare_error)?,
)
.await
.map_err(map_custom_world_asset_spacetime_error)?;
response.image_src = put_result.legacy_public_path;
Ok(response)
}
@@ -0,0 +1,376 @@
use super::*;
pub(super) async fn generate_opening_cg_storyboard(
state: &AppState,
owner_user_id: &str,
http_client: &reqwest::Client,
settings: &crate::openai_image_generation::OpenAiImageSettings,
normalized: &NormalizedOpeningCgRequest,
reference_images: &[String],
) -> Result<GeneratedOpeningCgStoryboard, AppError> {
let generated = create_openai_image_generation(
http_client,
settings,
normalized.storyboard_prompt.as_str(),
None,
OPENING_CG_STORYBOARD_IMAGE_SIZE,
1,
reference_images,
"开局 CG 故事板生成失败",
)
.await?;
let downloaded = generated
.images
.into_iter()
.next()
.map(downloaded_openai_to_custom_world_image)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "开局 CG 故事板生成成功但未返回图片。",
}))
})?;
let asset_id = format!("opening-cg-storyboard-{}", current_utc_millis());
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldScenes,
path_segments: vec![
sanitize_storage_segment(
normalized
.profile_id
.as_deref()
.unwrap_or(normalized.world_name.as_str()),
"world",
),
"opening-cg".to_string(),
sanitize_storage_segment(normalized.opening_cg_id.as_str(), "opening-cg"),
],
file_name: format!("storyboard.{}", downloaded.extension),
content_type: downloaded.mime_type,
body: downloaded.bytes,
asset_kind: OPENING_CG_STORYBOARD_ASSET_KIND,
entity_kind: OPENING_CG_ENTITY_KIND,
entity_id: normalized
.profile_id
.clone()
.unwrap_or_else(|| normalized.world_name.clone()),
profile_id: normalized.profile_id.clone(),
slot: OPENING_CG_STORYBOARD_SLOT,
source_job_id: Some(generated.task_id.clone()),
};
let asset = persist_custom_world_asset(
state,
owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some(GPT_IMAGE_2_MODEL.to_string()),
size: Some(OPENING_CG_STORYBOARD_IMAGE_SIZE.to_string()),
task_id: Some(generated.task_id.clone()),
prompt: Some(normalized.storyboard_prompt.clone()),
actual_prompt: generated.actual_prompt,
},
)
.await?;
Ok(GeneratedOpeningCgStoryboard {
image_src: asset.image_src,
asset_id,
})
}
pub(super) async fn generate_opening_cg_video(
state: &AppState,
owner_user_id: &str,
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
normalized: &NormalizedOpeningCgRequest,
storyboard_reference_data_url: &str,
) -> Result<GeneratedOpeningCgVideo, AppError> {
let upstream_task_id = create_ark_storyboard_to_video_task(
http_client,
settings,
normalized.video_prompt.as_str(),
storyboard_reference_data_url,
)
.await?;
let video_url =
wait_for_ark_content_generation_task(http_client, settings, upstream_task_id.as_str())
.await?;
let downloaded =
download_generated_video(http_client, video_url.as_str(), "下载开局 CG 视频失败").await?;
let asset_id = format!("opening-cg-video-{}", current_utc_millis());
let video_src = persist_opening_cg_video_asset(
state,
owner_user_id,
normalized,
asset_id.as_str(),
Some(upstream_task_id.clone()),
downloaded,
)
.await?;
Ok(GeneratedOpeningCgVideo {
video_src,
asset_id,
})
}
pub(super) async fn persist_opening_cg_video_asset(
state: &AppState,
owner_user_id: &str,
normalized: &NormalizedOpeningCgRequest,
asset_id: &str,
source_job_id: Option<String>,
video: DownloadedRemoteVideo,
) -> Result<String, AppError> {
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldScenes,
path_segments: vec![
sanitize_storage_segment(
normalized
.profile_id
.as_deref()
.unwrap_or(normalized.world_name.as_str()),
"world",
),
"opening-cg".to_string(),
sanitize_storage_segment(normalized.opening_cg_id.as_str(), "opening-cg"),
],
file_name: format!("opening.{}", video.extension),
content_type: video.mime_type,
body: video.bytes,
asset_kind: OPENING_CG_VIDEO_ASSET_KIND,
entity_kind: OPENING_CG_ENTITY_KIND,
entity_id: normalized
.profile_id
.clone()
.unwrap_or_else(|| normalized.world_name.clone()),
profile_id: normalized.profile_id.clone(),
slot: OPENING_CG_VIDEO_SLOT,
source_job_id,
};
let asset = persist_custom_world_asset(
state,
owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id: asset_id.to_string(),
source_type: "generated".to_string(),
model: Some("ark-seedance".to_string()),
size: Some(format!(
"{}:{}:{}s",
OPENING_CG_VIDEO_RESOLUTION,
OPENING_CG_VIDEO_RATIO,
OPENING_CG_VIDEO_DURATION_SECONDS
)),
task_id: None,
prompt: Some(normalized.video_prompt.clone()),
actual_prompt: None,
},
)
.await?;
Ok(asset.image_src)
}
async fn create_ark_storyboard_to_video_task(
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
prompt: &str,
storyboard_reference_data_url: &str,
) -> Result<String, AppError> {
let response = http_client
.post(format!("{}/contents/generations/tasks", settings.base_url))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(&json!({
"model": settings.model,
"content": [
{
"type": "text",
"text": prompt,
},
{
"type": "image_url",
"image_url": {
"url": storyboard_reference_data_url,
},
"role": "reference_image",
}
],
"resolution": OPENING_CG_VIDEO_RESOLUTION,
"ratio": OPENING_CG_VIDEO_RATIO,
"duration": OPENING_CG_VIDEO_DURATION_SECONDS,
"watermark": false,
"audio": true,
"generate_audio": true,
"web_search": true,
"enable_web_search": true,
}))
.send()
.await
.map_err(|error| {
map_ark_video_request_error(format!("请求 Seedance 视频服务失败:{error}"))
})?;
let status = response.status();
let text = response.text().await.map_err(|error| {
map_ark_video_request_error(format!("读取 Seedance 视频任务响应失败:{error}"))
})?;
if !status.is_success() {
return Err(parse_ark_video_upstream_error(
text.as_str(),
"创建开局 CG 视频任务失败。",
));
}
let payload = parse_ark_video_json_payload(text.as_str(), "创建开局 CG 视频任务失败。")?;
extract_ark_task_id(&payload.payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "开局 CG 视频任务未返回任务 id。",
}))
})
}
async fn wait_for_ark_content_generation_task(
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
task_id: &str,
) -> Result<String, AppError> {
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
while Instant::now() < deadline {
let response = http_client
.get(format!(
"{}/contents/generations/tasks/{}",
settings.base_url, task_id
))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.send()
.await
.map_err(|error| {
map_ark_video_request_error(format!("查询 Seedance 视频任务失败:{error}"))
})?;
let status = response.status();
let text = response.text().await.map_err(|error| {
map_ark_video_request_error(format!("读取 Seedance 视频任务响应失败:{error}"))
})?;
if !status.is_success() {
return Err(parse_ark_video_upstream_error(
text.as_str(),
"查询开局 CG 视频任务失败。",
));
}
let payload = parse_ark_video_json_payload(text.as_str(), "查询开局 CG 视频任务失败。")?;
if let Some(video_url) = extract_video_url(&payload.payload) {
return Ok(video_url);
}
let normalized_status = normalize_generation_task_status(
extract_generation_task_status(&payload.payload).as_str(),
);
if is_completed_generation_task_status(normalized_status.as_str()) {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "开局 CG 视频任务完成但没有返回 video_url。",
"taskId": task_id,
})),
);
}
if is_failed_generation_task_status(normalized_status.as_str()) {
return Err(parse_ark_video_upstream_error(
text.as_str(),
"开局 CG 视频任务执行失败。",
));
}
sleep(Duration::from_millis(ARK_VIDEO_TASK_POLL_INTERVAL_MS)).await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "开局 CG 视频生成超时,请稍后重试。",
"taskId": task_id,
})),
)
}
async fn download_generated_video(
http_client: &reqwest::Client,
video_url: &str,
fallback_message: &str,
) -> Result<DownloadedRemoteVideo, AppError> {
let response = http_client
.get(video_url)
.send()
.await
.map_err(|error| map_ark_video_request_error(format!("{fallback_message}{error}")))?;
let status = response.status();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("video/mp4")
.to_string();
let body = response
.bytes()
.await
.map_err(|error| map_ark_video_request_error(format!("{fallback_message}{error}")))?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": fallback_message,
"status": status.as_u16(),
})),
);
}
let normalized_mime_type = normalize_downloaded_video_mime_type(content_type.as_str());
Ok(DownloadedRemoteVideo {
extension: video_mime_to_extension(normalized_mime_type.as_str()).to_string(),
mime_type: normalized_mime_type,
bytes: body.to_vec(),
})
}
pub(super) fn map_custom_world_generated_image_asset_error(
error: crate::generated_image_assets::helpers::GeneratedImageAssetHelperError,
) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "generated-image-assets",
"message": format!("准备自定义世界图片资产上传请求失败:{error:?}"),
}))
}
pub(super) fn map_asset_object_prepare_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"message": error.to_string(),
}))
}
pub(super) fn map_asset_binding_prepare_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-entity-binding",
"message": error.to_string(),
}))
}
pub(super) fn map_custom_world_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
pub(super) fn map_custom_world_asset_oss_error(error: platform_oss::OssError) -> AppError {
map_oss_error(error, "aliyun-oss")
}
@@ -0,0 +1,163 @@
use std::collections::BTreeMap;
use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest};
use super::helpers::{
GeneratedImageAssetDataUrl, GeneratedImageAssetHelperError, GeneratedImageAssetImageFormat,
GeneratedImageAssetStoragePaths, build_generated_image_asset_metadata,
build_generated_image_asset_storage_paths, merge_generated_image_asset_metadata,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GeneratedImageAssetAdapterBoundary;
impl GeneratedImageAssetAdapterBoundary {
pub(crate) const BILLING_BOUNDARY_COMMENT: &'static str = "generated_image_assets adapter only prepares asset payloads; callers own billing before or after persistence.";
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GeneratedImageAssetAdapter;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GeneratedImageAssetPersistInput {
pub(crate) prefix: LegacyAssetPrefix,
pub(crate) path_segments: Vec<String>,
pub(crate) file_stem: String,
pub(crate) image: GeneratedImageAssetDataUrl,
pub(crate) access: OssObjectAccess,
pub(crate) metadata: GeneratedImageAssetAdapterMetadata,
pub(crate) extra_metadata: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct GeneratedImageAssetAdapterMetadata {
pub(crate) asset_kind: Option<String>,
pub(crate) owner_user_id: Option<String>,
pub(crate) entity_kind: Option<String>,
pub(crate) entity_id: Option<String>,
pub(crate) slot: Option<String>,
pub(crate) provider: Option<String>,
pub(crate) task_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GeneratedImageAssetPreparedPut {
pub(crate) request: OssPutObjectRequest,
pub(crate) storage_paths: GeneratedImageAssetStoragePaths,
pub(crate) format: GeneratedImageAssetImageFormat,
}
impl GeneratedImageAssetAdapter {
/// Adapter boundary: this skeleton intentionally does not read, reserve, charge, refund,
/// or otherwise mutate billing state. Real callers must keep billing orchestration outside
/// generated_image_assets when they migrate onto this adapter.
pub(crate) fn prepare_put_object(
input: GeneratedImageAssetPersistInput,
) -> Result<GeneratedImageAssetPreparedPut, GeneratedImageAssetHelperError> {
let file_name = format!(
"{}.{}",
input.file_stem.trim(),
input.image.format.extension
);
let storage_paths = build_generated_image_asset_storage_paths(
input.prefix,
&input.path_segments,
file_name.as_str(),
)?;
let metadata = merge_generated_image_asset_metadata(
build_generated_image_asset_metadata(input.metadata.into()),
input.extra_metadata,
);
let format = input.image.format.clone();
Ok(GeneratedImageAssetPreparedPut {
request: OssPutObjectRequest {
prefix: input.prefix,
path_segments: input.path_segments,
file_name,
content_type: Some(format.mime_type.clone()),
access: input.access,
metadata,
body: input.image.bytes,
},
storage_paths,
format,
})
}
}
impl From<GeneratedImageAssetAdapterMetadata> for super::helpers::GeneratedImageAssetMetadataInput {
fn from(value: GeneratedImageAssetAdapterMetadata) -> Self {
Self {
asset_kind: value.asset_kind,
owner_user_id: value.owner_user_id,
entity_kind: value.entity_kind,
entity_id: value.entity_id,
slot: value.slot,
provider: value.provider,
task_id: value.task_id,
}
}
}
#[cfg(test)]
mod generated_image_assets_adapter_tests {
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use super::*;
use crate::generated_image_assets::helpers::decode_generated_image_asset_data_url;
#[test]
fn generated_image_assets_adapter_prepares_put_without_billing_side_effects() {
let image = decode_generated_image_asset_data_url(&format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(b"png bytes")
))
.expect("image should decode");
let prepared =
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
prefix: LegacyAssetPrefix::SquareHoleAssets,
path_segments: vec!["work/1".to_string(), "cover".to_string()],
file_stem: "image".to_string(),
image,
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some("square-hole-cover".to_string()),
owner_user_id: Some("user-1".to_string()),
entity_kind: Some("work".to_string()),
entity_id: Some("work-1".to_string()),
slot: Some("cover".to_string()),
provider: Some("dashscope".to_string()),
task_id: Some("task-1".to_string()),
},
extra_metadata: BTreeMap::from([("caller".to_string(), "unit-test".to_string())]),
})
.expect("put object should be prepared");
assert_eq!(
GeneratedImageAssetAdapterBoundary::BILLING_BOUNDARY_COMMENT,
"generated_image_assets adapter only prepares asset payloads; callers own billing before or after persistence."
);
assert_eq!(prepared.request.prefix, LegacyAssetPrefix::SquareHoleAssets);
assert_eq!(prepared.request.file_name, "image.png");
assert_eq!(prepared.request.content_type, Some("image/png".to_string()));
assert_eq!(prepared.request.body, b"png bytes");
assert_eq!(
prepared.storage_paths.object_key,
"generated-square-hole-assets/work-1/cover/image.png"
);
assert_eq!(
prepared.storage_paths.legacy_public_path,
"/generated-square-hole-assets/work-1/cover/image.png"
);
assert_eq!(
prepared.request.metadata.get("asset_kind"),
Some(&"square-hole-cover".to_string())
);
assert_eq!(
prepared.request.metadata.get("caller"),
Some(&"unit-test".to_string())
);
}
}
@@ -0,0 +1,291 @@
use std::collections::BTreeMap;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use platform_oss::LegacyAssetPrefix;
const DEFAULT_IMAGE_MIME: &str = "image/jpeg";
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GeneratedImageAssetImageFormat {
pub(crate) mime_type: String,
pub(crate) extension: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GeneratedImageAssetDataUrl {
pub(crate) format: GeneratedImageAssetImageFormat,
pub(crate) bytes: Vec<u8>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct GeneratedImageAssetMetadataInput {
pub(crate) asset_kind: Option<String>,
pub(crate) owner_user_id: Option<String>,
pub(crate) entity_kind: Option<String>,
pub(crate) entity_id: Option<String>,
pub(crate) slot: Option<String>,
pub(crate) provider: Option<String>,
pub(crate) task_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GeneratedImageAssetStoragePaths {
pub(crate) object_key: String,
pub(crate) legacy_public_path: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum GeneratedImageAssetHelperError {
InvalidDataUrl,
UnsupportedEncoding,
DecodeBase64(String),
InvalidFileName,
}
pub(crate) fn normalize_generated_image_asset_mime(
raw_content_type: impl AsRef<str>,
) -> GeneratedImageAssetImageFormat {
let mime_type = raw_content_type
.as_ref()
.split(';')
.next()
.map(str::trim)
.unwrap_or(DEFAULT_IMAGE_MIME)
.to_ascii_lowercase();
match mime_type.as_str() {
"image/png" => image_format("image/png", "png"),
"image/webp" => image_format("image/webp", "webp"),
"image/gif" => image_format("image/gif", "gif"),
"image/jpeg" | "image/jpg" | "application/octet-stream" | "" => {
image_format(DEFAULT_IMAGE_MIME, "jpg")
}
_ => image_format(DEFAULT_IMAGE_MIME, "jpg"),
}
}
pub(crate) fn decode_generated_image_asset_data_url(
raw_data_url: &str,
) -> Result<GeneratedImageAssetDataUrl, GeneratedImageAssetHelperError> {
let (metadata, encoded) = raw_data_url
.trim()
.split_once(',')
.ok_or(GeneratedImageAssetHelperError::InvalidDataUrl)?;
let metadata = metadata.trim();
if !metadata.to_ascii_lowercase().starts_with("data:") {
return Err(GeneratedImageAssetHelperError::InvalidDataUrl);
}
let header = &metadata["data:".len()..];
let mut parts = header
.split(';')
.map(str::trim)
.filter(|part| !part.is_empty());
let mime_type = parts.next().unwrap_or(DEFAULT_IMAGE_MIME);
let is_base64 = parts.any(|part| part.eq_ignore_ascii_case("base64"));
if !is_base64 {
return Err(GeneratedImageAssetHelperError::UnsupportedEncoding);
}
let bytes = BASE64_STANDARD
.decode(encoded.trim())
.map_err(|error| GeneratedImageAssetHelperError::DecodeBase64(error.to_string()))?;
Ok(GeneratedImageAssetDataUrl {
format: normalize_generated_image_asset_mime(mime_type),
bytes,
})
}
pub(crate) fn build_generated_image_asset_storage_paths(
prefix: LegacyAssetPrefix,
path_segments: &[String],
file_name: &str,
) -> Result<GeneratedImageAssetStoragePaths, GeneratedImageAssetHelperError> {
let file_name = sanitize_generated_image_asset_file_name(file_name)?;
let mut parts = vec![prefix.as_str().to_string()];
parts.extend(
path_segments
.iter()
.map(|segment| sanitize_generated_image_asset_path_segment(segment))
.filter(|segment| !segment.is_empty()),
);
parts.push(file_name);
let object_key = parts.join("/");
Ok(GeneratedImageAssetStoragePaths {
legacy_public_path: format!("/{object_key}"),
object_key,
})
}
pub(crate) fn build_generated_image_asset_metadata(
input: GeneratedImageAssetMetadataInput,
) -> BTreeMap<String, String> {
let mut metadata = BTreeMap::new();
insert_optional_metadata(&mut metadata, "asset_kind", input.asset_kind);
insert_optional_metadata(&mut metadata, "owner_user_id", input.owner_user_id);
insert_optional_metadata(&mut metadata, "entity_kind", input.entity_kind);
insert_optional_metadata(&mut metadata, "entity_id", input.entity_id);
insert_optional_metadata(&mut metadata, "slot", input.slot);
insert_optional_metadata(&mut metadata, "provider", input.provider);
insert_optional_metadata(&mut metadata, "task_id", input.task_id);
metadata
}
pub(crate) fn merge_generated_image_asset_metadata(
base: BTreeMap<String, String>,
overlay: BTreeMap<String, String>,
) -> BTreeMap<String, String> {
let mut merged = BTreeMap::new();
for (key, value) in base.into_iter().chain(overlay) {
let key = key.trim();
let value = value.trim();
if key.is_empty() || value.is_empty() {
continue;
}
merged.insert(key.to_string(), value.to_string());
}
merged
}
fn image_format(mime_type: &str, extension: &str) -> GeneratedImageAssetImageFormat {
GeneratedImageAssetImageFormat {
mime_type: mime_type.to_string(),
extension: extension.to_string(),
}
}
fn insert_optional_metadata(
metadata: &mut BTreeMap<String, String>,
key: &str,
value: Option<String>,
) {
if let Some(value) = value {
let value = value.trim();
if !value.is_empty() {
metadata.insert(key.to_string(), value.to_string());
}
}
}
fn sanitize_generated_image_asset_path_segment(raw: &str) -> String {
raw.trim()
.trim_matches('/')
.chars()
.map(|ch| match ch {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
ch if ch.is_control() => '-',
ch => ch,
})
.collect::<String>()
.trim_matches('-')
.to_string()
}
fn sanitize_generated_image_asset_file_name(
raw: &str,
) -> Result<String, GeneratedImageAssetHelperError> {
let sanitized = sanitize_generated_image_asset_path_segment(raw);
if sanitized.is_empty() || sanitized == "." || sanitized == ".." || sanitized.contains('/') {
return Err(GeneratedImageAssetHelperError::InvalidFileName);
}
Ok(sanitized)
}
#[cfg(test)]
mod generated_image_assets_tests {
use std::collections::BTreeMap;
use super::*;
#[test]
fn generated_image_assets_normalize_mime_and_extension() {
assert_eq!(
normalize_generated_image_asset_mime(" image/PNG; charset=utf-8 "),
image_format("image/png", "png")
);
assert_eq!(
normalize_generated_image_asset_mime("image/jpg"),
image_format("image/jpeg", "jpg")
);
assert_eq!(
normalize_generated_image_asset_mime("text/plain"),
image_format("image/jpeg", "jpg")
);
}
#[test]
fn generated_image_assets_decode_data_url_base64() {
let decoded = decode_generated_image_asset_data_url("data:image/webp;base64,aGVsbG8=")
.expect("data url should decode");
assert_eq!(decoded.format, image_format("image/webp", "webp"));
assert_eq!(decoded.bytes, b"hello");
}
#[test]
fn generated_image_assets_reject_non_base64_data_url() {
assert_eq!(
decode_generated_image_asset_data_url("data:image/png,hello").unwrap_err(),
GeneratedImageAssetHelperError::UnsupportedEncoding
);
}
#[test]
fn generated_image_assets_build_object_key_and_legacy_path() {
let paths = build_generated_image_asset_storage_paths(
LegacyAssetPrefix::BigFishAssets,
&[" world/001 ".to_string(), "slot:cover".to_string()],
" image.png ",
)
.expect("paths should build");
assert_eq!(
paths.object_key,
"generated-big-fish-assets/world-001/slot-cover/image.png"
);
assert_eq!(
paths.legacy_public_path,
"/generated-big-fish-assets/world-001/slot-cover/image.png"
);
}
#[test]
fn generated_image_assets_merge_metadata_trims_and_overlay_wins() {
let base = BTreeMap::from([
("asset_kind".to_string(), " old ".to_string()),
("empty".to_string(), " ".to_string()),
]);
let overlay = BTreeMap::from([
("asset_kind".to_string(), "cover".to_string()),
(" task_id ".to_string(), " task-1 ".to_string()),
]);
assert_eq!(
merge_generated_image_asset_metadata(base, overlay),
BTreeMap::from([
("asset_kind".to_string(), "cover".to_string()),
("task_id".to_string(), "task-1".to_string()),
])
);
}
#[test]
fn generated_image_assets_build_metadata_omits_blank_values() {
let metadata = build_generated_image_asset_metadata(GeneratedImageAssetMetadataInput {
asset_kind: Some(" scene ".to_string()),
owner_user_id: Some("".to_string()),
entity_kind: Some("world".to_string()),
entity_id: None,
slot: Some(" cover ".to_string()),
provider: Some("dashscope".to_string()),
task_id: Some(" task-1 ".to_string()),
});
assert_eq!(metadata.get("asset_kind"), Some(&"scene".to_string()));
assert_eq!(metadata.get("owner_user_id"), None);
assert_eq!(metadata.get("slot"), Some(&"cover".to_string()));
assert_eq!(metadata.get("task_id"), Some(&"task-1".to_string()));
}
}
@@ -0,0 +1,13 @@
// 中文注释:C0 先落公共骨架,真实调用方迁移到 C1 后再移除未使用豁免。
#![allow(dead_code, unused_imports)]
pub mod adapter;
pub mod helpers;
pub(crate) use adapter::{GeneratedImageAssetAdapter, GeneratedImageAssetAdapterBoundary};
pub(crate) use helpers::{
GeneratedImageAssetDataUrl, GeneratedImageAssetImageFormat, GeneratedImageAssetMetadataInput,
GeneratedImageAssetStoragePaths, build_generated_image_asset_metadata,
build_generated_image_asset_storage_paths, decode_generated_image_asset_data_url,
merge_generated_image_asset_metadata, normalize_generated_image_asset_mime,
};
+2
View File
@@ -38,6 +38,7 @@ mod custom_world_rpg_draft_prompts;
mod edutainment_baby_drawing;
mod edutainment_baby_object;
mod error_middleware;
mod generated_image_assets;
mod health;
mod http_error;
mod hyper3d_generation;
@@ -47,6 +48,7 @@ mod login_options;
mod logout;
mod logout_all;
mod match3d;
mod modules;
mod openai_image_generation;
mod password_entry;
mod password_management;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,173 @@
use super::*;
pub(super) 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::<String>()
.chars()
.take(6)
.collect::<String>()
}
pub(super) fn normalize_match3d_tag_candidates<S>(
candidates: impl IntoIterator<Item = S>,
) -> Vec<String>
where
S: AsRef<str>,
{
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 ["抓大鹅", "经典消除", "2D素材", "轻量休闲", "收集", "挑战"] {
if tags.len() >= 6 {
break;
}
if !tags.iter().any(|tag| tag == fallback) {
tags.push(fallback.to_string());
}
}
tags
}
pub(super) async fn generate_match3d_work_tags_for_profile(
state: &AppState,
game_name: &str,
theme_text: &str,
summary: Option<&str>,
) -> Vec<String> {
request_match3d_work_tags_with_llm(state, game_name, theme_text, summary)
.await
.unwrap_or_else(|| fallback_match3d_work_tags(game_name, theme_text))
}
pub(super) async fn request_match3d_work_tags_with_llm(
state: &AppState,
game_name: &str,
theme_text: &str,
summary: Option<&str>,
) -> Option<Vec<String>> {
let Some(llm_client) = state
.creative_agent_gpt5_client()
.or_else(|| state.llm_client())
else {
return None;
};
let user_prompt = format!(
"题材设定:{}\n作品名称:{}\n作品描述:{}\n请生成 3 到 6 个适合抓大鹅作品发布的中文短标签。要求:只返回 JSON 数组,每项 2 到 6 个汉字,不要解释。",
theme_text,
game_name,
summary.unwrap_or_default()
);
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 Some(tags);
}
tracing::warn!(
provider = MATCH3D_WORKS_PROVIDER,
game_name,
"抓大鹅 AI 标签生成数量不足,降级使用本地标签"
);
None
}
Err(error) => {
tracing::warn!(
provider = MATCH3D_WORKS_PROVIDER,
game_name,
error = %error,
"抓大鹅 AI 标签生成失败,降级使用本地标签"
);
None
}
}
}
pub(super) async fn generate_match3d_work_tags_for_plan(
state: &AppState,
game_name: &str,
theme_text: &str,
summary: &str,
plan_tags: &[String],
) -> Vec<String> {
if let Some(tags) =
request_match3d_work_tags_with_llm(state, game_name, theme_text, Some(summary)).await
{
return tags;
}
merge_match3d_plan_tags_with_fallback(game_name, theme_text, plan_tags)
}
pub(super) fn merge_match3d_plan_tags_with_fallback(
game_name: &str,
theme_text: &str,
plan_tags: &[String],
) -> Vec<String> {
let mut candidates = plan_tags.to_vec();
candidates.extend(fallback_match3d_work_tags(game_name, theme_text));
normalize_match3d_tag_candidates(candidates)
}
const MATCH3D_MIN_GENERATED_TAG_COUNT: usize = 3;
pub(super) fn parse_match3d_tags_from_text(raw: &str) -> Vec<String> {
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::<Vec<String>>(json_text).unwrap_or_default();
normalize_match3d_tag_candidates(parsed)
}
pub(super) fn fallback_match3d_work_tags(game_name: &str, theme_text: &str) -> Vec<String> {
normalize_match3d_tag_candidates([theme_text, game_name, "抓大鹅", "经典消除", "2D素材"])
}
@@ -0,0 +1,110 @@
use axum::{Router, middleware, routing::get};
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,
},
runtime_profile::{
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
admin_list_profile_invite_codes, admin_list_profile_redeem_codes,
admin_list_profile_task_configs, admin_upsert_profile_invite_code,
admin_upsert_profile_redeem_code, admin_upsert_profile_task_config,
},
state::AppState,
};
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route("/admin/api/login", axum::routing::post(admin_login))
.route(
"/admin/api/me",
get(admin_me).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/overview",
get(admin_overview).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/debug/http",
axum::routing::post(admin_debug_http).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/tracking/events",
get(admin_list_tracking_events).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/database/tables",
get(admin_list_database_tables).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/database/tables/{table_name}/rows",
get(admin_list_database_table_rows).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/creation-entry/config",
get(admin_get_creation_entry_config)
.post(admin_upsert_creation_entry_config)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/profile/redeem-codes",
get(admin_list_profile_redeem_codes)
.post(admin_upsert_profile_redeem_code)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/profile/redeem-codes/disable",
axum::routing::post(admin_disable_profile_redeem_code).route_layer(
middleware::from_fn_with_state(state.clone(), require_admin_auth),
),
)
.route(
"/admin/api/profile/invite-codes",
get(admin_list_profile_invite_codes)
.post(admin_upsert_profile_invite_code)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/profile/tasks",
get(admin_list_profile_task_configs)
.post(admin_upsert_profile_task_config)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/profile/tasks/disable",
axum::routing::post(admin_disable_profile_task_config)
.route_layer(middleware::from_fn_with_state(state, require_admin_auth)),
)
}
@@ -0,0 +1,54 @@
use axum::{
Router, middleware,
routing::{get, post},
};
use crate::{
assets::{
bind_asset_object_to_entity, confirm_asset_object, create_direct_upload_ticket,
create_sts_upload_credentials, get_asset_history, get_asset_read_bytes, get_asset_read_url,
},
auth::require_bearer_auth,
state::AppState,
};
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/assets/direct-upload-tickets",
post(create_direct_upload_ticket).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/sts-upload-credentials",
post(create_sts_upload_credentials).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/objects/confirm",
post(confirm_asset_object).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/objects/bind",
post(bind_asset_object_to_entity).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route("/api/assets/read-url", get(get_asset_read_url))
.route("/api/assets/read-bytes", get(get_asset_read_bytes))
.route(
"/api/assets/history",
get(get_asset_history).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
}
@@ -0,0 +1,111 @@
use axum::{
Router, middleware,
routing::{get, post},
};
use crate::{
auth::{attach_refresh_session_token, require_bearer_auth},
auth_me::auth_me,
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
auth_sessions::{auth_sessions, revoke_auth_session},
login_options::auth_login_options,
logout::logout,
logout_all::logout_all,
password_entry::password_entry,
password_management::{change_password, reset_password},
phone_auth::{phone_login, send_phone_code},
refresh_session::refresh_session,
state::AppState,
wechat_auth::{
bind_wechat_phone, handle_wechat_callback, login_wechat_mini_program, start_wechat_login,
},
};
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route("/api/auth/login-options", get(auth_login_options))
.route(
"/api/auth/public-users/by-code/{code}",
get(get_public_user_by_code),
)
.route(
"/api/auth/public-users/by-id/{user_id}",
get(get_public_user_by_id),
)
.route(
"/api/auth/me",
get(auth_me).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/auth/sessions",
get(auth_sessions)
.route_layer(middleware::from_fn_with_state(
state.clone(),
attach_refresh_session_token,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/auth/sessions/{session_id}/revoke",
post(revoke_auth_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/auth/refresh",
post(refresh_session).route_layer(middleware::from_fn_with_state(
state.clone(),
attach_refresh_session_token,
)),
)
.route("/api/auth/phone/send-code", post(send_phone_code))
.route("/api/auth/phone/login", post(phone_login))
.route("/api/auth/wechat/start", get(start_wechat_login))
.route("/api/auth/wechat/callback", get(handle_wechat_callback))
.route(
"/api/auth/wechat/miniprogram-login",
post(login_wechat_mini_program),
)
.route(
"/api/auth/wechat/bind-phone",
post(bind_wechat_phone).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route("/api/auth/entry", post(password_entry))
.route(
"/api/auth/password/change",
post(change_password).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route("/api/auth/password/reset", post(reset_password))
.route(
"/api/auth/logout",
post(logout)
.route_layer(middleware::from_fn_with_state(
state.clone(),
attach_refresh_session_token,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/auth/logout-all",
post(logout_all).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
}

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