refactor: modularize api server assets and handlers
This commit is contained in:
@@ -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、计费和领域规则。
|
||||||
- [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 和后续原生化边界。
|
- [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 和后续原生化边界。
|
||||||
- [BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”后端 DDD 技术方案,明确 `server-rs + Axum + SpacetimeDB` 分层边界、shared contracts、作品配置、runtime run、派生成绩、排行榜、`work_play_start` 埋点、migration/绑定生成策略,以及不保存原始麦克风音频的隐私与反作弊约束。
|
- [BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”后端 DDD 技术方案,明确 `server-rs + Axum + SpacetimeDB` 分层边界、shared contracts、作品配置、runtime run、派生成绩、排行榜、`work_play_start` 埋点、migration/绑定生成策略,以及不保存原始麦克风音频的隐私与反作弊约束。
|
||||||
- [BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”2D 浏览器 runtime 技术方案,明确 Phaser + TypeScript + Vite 选型、纯 TS simulation 与 Phaser renderer/DOM HUD 边界、Web Audio 输入适配、移动端权限降级和后续测试验证命令。
|
- [BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”2D 浏览器 runtime 技术方案,明确 Phaser + TypeScript + Vite 选型、纯 TS simulation 与 Phaser renderer/DOM HUD 边界、Web Audio 输入适配、移动端权限降级和后续测试验证命令。
|
||||||
|
|||||||
211
docs/technical/【后端架构】api-server大Handler瘦身执行计划-2026-05-14.md
Normal file
211
docs/technical/【后端架构】api-server大Handler瘦身执行计划-2026-05-14.md
Normal file
@@ -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 与禁止改动范围
|
||||||
|
|
||||||
|
Owner:D2 大 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/slot;Adapter 不反向知道玩法规则。
|
||||||
|
|
||||||
|
### D2-3:Application 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 不在 handler;SSE/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 状态。
|
||||||
296
docs/technical/【后端架构】api-server能力模块化与生成资产Adapter总纲-2026-05-14.md
Normal file
296
docs/technical/【后端架构】api-server能力模块化与生成资产Adapter总纲-2026-05-14.md
Normal file
@@ -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 模块化(owner:B1 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 图片 Adapter(owner:C 线图片 Adapter agent):Big Fish、Square Hole、Custom World 至少 3 个真实调用方接入同一 Adapter;旧重复 persist helper 可删除且行为不变;禁止修改计费语义。
|
||||||
|
- D1 复杂媒体 Adapter(owner:D1 复杂媒体 agent):Puzzle/Match3D/音频/视频/角色工作流/Hyper3D 只复用合适的底层持久化能力,不污染图片 interface;`/api/assets/read-url` 和 `/api/assets/read-bytes` 读取链路仍可用。
|
||||||
|
- D2 大 handler 瘦身(owner:D2 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
|
||||||
|
```
|
||||||
393
docs/technical/【后端架构】api-server路由能力模块化执行计划-2026-05-14.md
Normal file
393
docs/technical/【后端架构】api-server路由能力模块化执行计划-2026-05-14.md
Normal file
@@ -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` 可作为本地 smoke;internal 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-bytes;Hyper3D 不新增 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。
|
||||||
151
docs/technical/【后端架构】复杂媒体资产链路Adapter扩展计划-2026-05-14.md
Normal file
151
docs/technical/【后端架构】复杂媒体资产链路Adapter扩展计划-2026-05-14.md
Normal file
@@ -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 CG:storyboard 图片可走图片 Adapter,最终视频走复杂媒体底座。
|
||||||
|
- Character visual / animation:角色视觉、参考视频、导入视频和 workflow cache 的持久化复用。
|
||||||
|
- Match3D / Puzzle 资产链路中非图片资产:背景音乐、历史 GLB/Hyper3D 代理边界只做复用,不恢复新草稿 GLB 回退。
|
||||||
|
|
||||||
|
## 2. Owner 与禁止改动范围
|
||||||
|
|
||||||
|
Owner:D1 复杂媒体 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 与本系列总纲状态同步更新。
|
||||||
156
docs/technical/【后端架构】生成图片资产Adapter收口执行计划-2026-05-14.md
Normal file
156
docs/technical/【后端架构】生成图片资产Adapter收口执行计划-2026-05-14.md
Normal file
@@ -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_images:data URL、object key、remote URL,由调用方先裁定安全来源。
|
||||||
|
- output:OSS prefix、path segments、file name stem、access policy。
|
||||||
|
- asset:asset_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
645
server-rs/crates/api-server/src/big_fish/formal_assets.rs
Normal file
645
server-rs/crates/api-server/src/big_fish/formal_assets.rs
Normal file
@@ -0,0 +1,645 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct BigFishDashScopeSettings {
|
||||||
|
base_url: String,
|
||||||
|
api_key: String,
|
||||||
|
request_timeout_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BigFishGeneratedImage {
|
||||||
|
image_url: String,
|
||||||
|
task_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BigFishDownloadedImage {
|
||||||
|
mime_type: String,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BigFishFormalAssetContext {
|
||||||
|
entity_id: String,
|
||||||
|
prompt: String,
|
||||||
|
negative_prompt: String,
|
||||||
|
size: String,
|
||||||
|
asset_object_kind: String,
|
||||||
|
binding_slot: String,
|
||||||
|
path_segments: Vec<String>,
|
||||||
|
apply_transparent_background_post_process: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
const BIG_FISH_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash";
|
||||||
|
const BIG_FISH_ENTITY_KIND: &str = "big_fish_session";
|
||||||
|
|
||||||
|
pub(super) async fn generate_big_fish_formal_asset(
|
||||||
|
state: &AppState,
|
||||||
|
owner_user_id: &str,
|
||||||
|
session_id: &str,
|
||||||
|
asset_kind: &str,
|
||||||
|
level: Option<u32>,
|
||||||
|
motion_key: Option<&str>,
|
||||||
|
generated_at_micros: i64,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let session = state
|
||||||
|
.spacetime_client()
|
||||||
|
.get_big_fish_session(session_id.to_string(), owner_user_id.to_string())
|
||||||
|
.await
|
||||||
|
.map_err(map_big_fish_client_error)?;
|
||||||
|
let draft = session.draft.as_ref().ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": "big-fish",
|
||||||
|
"message": "玩法草稿尚未编译,不能生成正式图片。",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
let context = build_big_fish_formal_asset_context(
|
||||||
|
&session,
|
||||||
|
draft,
|
||||||
|
asset_kind,
|
||||||
|
level,
|
||||||
|
motion_key,
|
||||||
|
generated_at_micros,
|
||||||
|
)?;
|
||||||
|
let settings = require_big_fish_dashscope_settings(state)?;
|
||||||
|
let http_client = build_big_fish_dashscope_http_client(&settings)?;
|
||||||
|
let generated = create_big_fish_text_to_image_generation(
|
||||||
|
&http_client,
|
||||||
|
&settings,
|
||||||
|
context.prompt.as_str(),
|
||||||
|
context.negative_prompt.as_str(),
|
||||||
|
context.size.as_str(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let downloaded = download_big_fish_remote_image(
|
||||||
|
&http_client,
|
||||||
|
generated.image_url.as_str(),
|
||||||
|
"下载 Big Fish 正式图片失败",
|
||||||
|
context.apply_transparent_background_post_process,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
persist_big_fish_formal_asset(
|
||||||
|
state,
|
||||||
|
owner_user_id,
|
||||||
|
&context,
|
||||||
|
generated,
|
||||||
|
downloaded,
|
||||||
|
generated_at_micros,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_big_fish_formal_asset_context(
|
||||||
|
session: &BigFishSessionRecord,
|
||||||
|
draft: &BigFishGameDraftRecord,
|
||||||
|
asset_kind: &str,
|
||||||
|
level: Option<u32>,
|
||||||
|
motion_key: Option<&str>,
|
||||||
|
generated_at_micros: i64,
|
||||||
|
) -> Result<BigFishFormalAssetContext, AppError> {
|
||||||
|
let asset_id = format!("asset-{generated_at_micros}");
|
||||||
|
match asset_kind {
|
||||||
|
"level_main_image" => {
|
||||||
|
let level = find_big_fish_level_blueprint(draft, level)?;
|
||||||
|
let level_part = build_big_fish_level_part(Some(level.level));
|
||||||
|
Ok(BigFishFormalAssetContext {
|
||||||
|
entity_id: session.session_id.clone(),
|
||||||
|
prompt: build_big_fish_level_main_image_prompt(draft, level),
|
||||||
|
negative_prompt: BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT.to_string(),
|
||||||
|
size: "1024*1024".to_string(),
|
||||||
|
asset_object_kind: "big_fish_level_main_image".to_string(),
|
||||||
|
binding_slot: format!("level_main_image:{level_part}"),
|
||||||
|
path_segments: vec![
|
||||||
|
sanitize_big_fish_path_segment(session.session_id.as_str(), "session"),
|
||||||
|
"level-main-image".to_string(),
|
||||||
|
level_part,
|
||||||
|
asset_id,
|
||||||
|
],
|
||||||
|
apply_transparent_background_post_process: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"level_motion" => {
|
||||||
|
let level = find_big_fish_level_blueprint(draft, level)?;
|
||||||
|
let motion_key = motion_key
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| matches!(*value, "idle_float" | "move_swim"))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": "big-fish",
|
||||||
|
"message": "motionKey 必须是 idle_float 或 move_swim。",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
let level_part = build_big_fish_level_part(Some(level.level));
|
||||||
|
Ok(BigFishFormalAssetContext {
|
||||||
|
entity_id: session.session_id.clone(),
|
||||||
|
prompt: build_big_fish_level_motion_prompt(draft, level, motion_key),
|
||||||
|
negative_prompt: BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT.to_string(),
|
||||||
|
size: "1024*1024".to_string(),
|
||||||
|
asset_object_kind: "big_fish_level_motion".to_string(),
|
||||||
|
binding_slot: format!("level_motion:{level_part}:{motion_key}"),
|
||||||
|
path_segments: vec![
|
||||||
|
sanitize_big_fish_path_segment(session.session_id.as_str(), "session"),
|
||||||
|
"level-motion".to_string(),
|
||||||
|
level_part,
|
||||||
|
sanitize_big_fish_path_segment(motion_key, "motion"),
|
||||||
|
asset_id,
|
||||||
|
],
|
||||||
|
apply_transparent_background_post_process: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"stage_background" => Ok(BigFishFormalAssetContext {
|
||||||
|
entity_id: session.session_id.clone(),
|
||||||
|
prompt: build_big_fish_stage_background_prompt(draft),
|
||||||
|
negative_prompt: BIG_FISH_DEFAULT_NEGATIVE_PROMPT.to_string(),
|
||||||
|
size: "720*1280".to_string(),
|
||||||
|
asset_object_kind: "big_fish_stage_background".to_string(),
|
||||||
|
binding_slot: "stage_background".to_string(),
|
||||||
|
path_segments: vec![
|
||||||
|
sanitize_big_fish_path_segment(session.session_id.as_str(), "session"),
|
||||||
|
"stage-background".to_string(),
|
||||||
|
asset_id,
|
||||||
|
],
|
||||||
|
apply_transparent_background_post_process: false,
|
||||||
|
}),
|
||||||
|
_ => Err(
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": "big-fish",
|
||||||
|
"message": format!("assetKind `{asset_kind}` 不支持正式图片生成。"),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_big_fish_level_blueprint(
|
||||||
|
draft: &BigFishGameDraftRecord,
|
||||||
|
level: Option<u32>,
|
||||||
|
) -> Result<&BigFishLevelBlueprintRecord, AppError> {
|
||||||
|
let level = level.ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": "big-fish",
|
||||||
|
"message": "level 是等级资产生成的必填项。",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
draft
|
||||||
|
.levels
|
||||||
|
.iter()
|
||||||
|
.find(|blueprint| blueprint.level == level)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": "big-fish",
|
||||||
|
"message": format!("level `{level}` 不存在于当前 Big Fish 草稿。"),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_big_fish_dashscope_settings(
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<BigFishDashScopeSettings, AppError> {
|
||||||
|
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
|
||||||
|
if base_url.is_empty() {
|
||||||
|
return Err(
|
||||||
|
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||||
|
"provider": "dashscope",
|
||||||
|
"reason": "DASHSCOPE_BASE_URL 未配置",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let api_key = state
|
||||||
|
.config
|
||||||
|
.dashscope_api_key
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||||
|
"provider": "dashscope",
|
||||||
|
"reason": "DASHSCOPE_API_KEY 未配置",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(BigFishDashScopeSettings {
|
||||||
|
base_url: base_url.to_string(),
|
||||||
|
api_key: api_key.to_string(),
|
||||||
|
request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_big_fish_dashscope_http_client(
|
||||||
|
settings: &BigFishDashScopeSettings,
|
||||||
|
) -> Result<reqwest::Client, AppError> {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_millis(settings.request_timeout_ms))
|
||||||
|
.build()
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||||
|
"provider": "dashscope",
|
||||||
|
"message": format!("构造 DashScope HTTP 客户端失败:{error}"),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_big_fish_text_to_image_generation(
|
||||||
|
http_client: &reqwest::Client,
|
||||||
|
settings: &BigFishDashScopeSettings,
|
||||||
|
prompt: &str,
|
||||||
|
negative_prompt: &str,
|
||||||
|
size: &str,
|
||||||
|
) -> Result<BigFishGeneratedImage, AppError> {
|
||||||
|
let mut parameters = Map::from_iter([
|
||||||
|
("n".to_string(), json!(1)),
|
||||||
|
("size".to_string(), Value::String(size.to_string())),
|
||||||
|
("prompt_extend".to_string(), Value::Bool(true)),
|
||||||
|
("watermark".to_string(), Value::Bool(false)),
|
||||||
|
]);
|
||||||
|
if !negative_prompt.trim().is_empty() {
|
||||||
|
parameters.insert(
|
||||||
|
"negative_prompt".to_string(),
|
||||||
|
Value::String(negative_prompt.trim().to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = http_client
|
||||||
|
.post(format!(
|
||||||
|
"{}/services/aigc/text2image/image-synthesis",
|
||||||
|
settings.base_url
|
||||||
|
))
|
||||||
|
.header(
|
||||||
|
reqwest::header::AUTHORIZATION,
|
||||||
|
format!("Bearer {}", settings.api_key),
|
||||||
|
)
|
||||||
|
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||||
|
.header("X-DashScope-Async", "enable")
|
||||||
|
.json(&json!({
|
||||||
|
"model": BIG_FISH_TEXT_TO_IMAGE_MODEL,
|
||||||
|
"input": {
|
||||||
|
"prompt": prompt,
|
||||||
|
},
|
||||||
|
"parameters": parameters,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
map_big_fish_dashscope_request_error(format!("创建 Big Fish 图片生成任务失败:{error}"))
|
||||||
|
})?;
|
||||||
|
let status = response.status();
|
||||||
|
let response_text = response.text().await.map_err(|error| {
|
||||||
|
map_big_fish_dashscope_request_error(format!("读取 Big Fish 图片生成响应失败:{error}"))
|
||||||
|
})?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(map_big_fish_dashscope_upstream_error(
|
||||||
|
response_text.as_str(),
|
||||||
|
"创建 Big Fish 图片生成任务失败",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let payload =
|
||||||
|
parse_big_fish_json_payload(response_text.as_str(), "解析 Big Fish 图片生成响应失败")?;
|
||||||
|
let task_id = extract_big_fish_task_id(&payload).ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "dashscope",
|
||||||
|
"message": "Big Fish 图片生成任务未返回 task_id",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
|
||||||
|
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
let poll_response = http_client
|
||||||
|
.get(format!("{}/tasks/{}", settings.base_url, task_id))
|
||||||
|
.header(
|
||||||
|
reqwest::header::AUTHORIZATION,
|
||||||
|
format!("Bearer {}", settings.api_key),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
map_big_fish_dashscope_request_error(format!(
|
||||||
|
"查询 Big Fish 图片生成任务失败:{error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let poll_status = poll_response.status();
|
||||||
|
let poll_text = poll_response.text().await.map_err(|error| {
|
||||||
|
map_big_fish_dashscope_request_error(format!(
|
||||||
|
"读取 Big Fish 图片生成任务响应失败:{error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if !poll_status.is_success() {
|
||||||
|
return Err(map_big_fish_dashscope_upstream_error(
|
||||||
|
poll_text.as_str(),
|
||||||
|
"查询 Big Fish 图片生成任务失败",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let poll_payload =
|
||||||
|
parse_big_fish_json_payload(poll_text.as_str(), "解析 Big Fish 图片生成任务响应失败")?;
|
||||||
|
let task_status = find_first_big_fish_string_by_key(&poll_payload, "task_status")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if task_status == "SUCCEEDED" {
|
||||||
|
let image_url = extract_big_fish_image_urls(&poll_payload)
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "dashscope",
|
||||||
|
"message": "Big Fish 图片生成成功但未返回图片地址",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
return Ok(BigFishGeneratedImage { image_url, task_id });
|
||||||
|
}
|
||||||
|
if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") {
|
||||||
|
return Err(map_big_fish_dashscope_upstream_error(
|
||||||
|
poll_text.as_str(),
|
||||||
|
"Big Fish 图片生成任务失败",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "dashscope",
|
||||||
|
"message": "Big Fish 图片生成超时或未返回图片地址",
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_big_fish_remote_image(
|
||||||
|
http_client: &reqwest::Client,
|
||||||
|
image_url: &str,
|
||||||
|
fallback_message: &str,
|
||||||
|
apply_transparent_background_post_process: bool,
|
||||||
|
) -> Result<BigFishDownloadedImage, AppError> {
|
||||||
|
let response = http_client.get(image_url).send().await.map_err(|error| {
|
||||||
|
map_big_fish_dashscope_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("image/jpeg")
|
||||||
|
.to_string();
|
||||||
|
let bytes = response.bytes().await.map_err(|error| {
|
||||||
|
map_big_fish_dashscope_request_error(format!("{fallback_message}:{error}"))
|
||||||
|
})?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "dashscope",
|
||||||
|
"message": fallback_message,
|
||||||
|
"status": status.as_u16(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mime_type = normalize_big_fish_downloaded_image_mime_type(content_type.as_str());
|
||||||
|
let mut normalized_bytes = bytes.to_vec();
|
||||||
|
let mut normalized_mime_type = mime_type;
|
||||||
|
|
||||||
|
// 中文注释:Big Fish 的等级主图与动作关键帧要和 RPG 角色主图保持同一后处理口径。
|
||||||
|
// 因此在上游已经输出 PNG 时,统一补一层透明背景 alpha 清理,避免只靠 prompt 约束导致残留底色。
|
||||||
|
if apply_transparent_background_post_process
|
||||||
|
&& normalized_mime_type == "image/png"
|
||||||
|
&& let Some(optimized) = try_apply_background_alpha_to_png(normalized_bytes.as_slice())
|
||||||
|
{
|
||||||
|
normalized_bytes = optimized;
|
||||||
|
normalized_mime_type = "image/png".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(BigFishDownloadedImage {
|
||||||
|
mime_type: normalized_mime_type,
|
||||||
|
bytes: normalized_bytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_big_fish_formal_asset(
|
||||||
|
state: &AppState,
|
||||||
|
owner_user_id: &str,
|
||||||
|
context: &BigFishFormalAssetContext,
|
||||||
|
generated: BigFishGeneratedImage,
|
||||||
|
downloaded: BigFishDownloadedImage,
|
||||||
|
generated_at_micros: i64,
|
||||||
|
) -> Result<String, 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 image_format = normalize_generated_image_asset_mime(downloaded.mime_type.as_str());
|
||||||
|
let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
|
||||||
|
prefix: LegacyAssetPrefix::BigFishAssets,
|
||||||
|
path_segments: context.path_segments.clone(),
|
||||||
|
file_stem: "image".to_string(),
|
||||||
|
image: GeneratedImageAssetDataUrl {
|
||||||
|
format: image_format,
|
||||||
|
bytes: downloaded.bytes,
|
||||||
|
},
|
||||||
|
access: OssObjectAccess::Private,
|
||||||
|
metadata: GeneratedImageAssetAdapterMetadata {
|
||||||
|
asset_kind: Some(context.asset_object_kind.clone()),
|
||||||
|
owner_user_id: Some(owner_user_id.to_string()),
|
||||||
|
entity_kind: Some(BIG_FISH_ENTITY_KIND.to_string()),
|
||||||
|
entity_id: Some(context.entity_id.clone()),
|
||||||
|
slot: Some(context.binding_slot.clone()),
|
||||||
|
provider: Some("dashscope".to_string()),
|
||||||
|
task_id: Some(generated.task_id.clone()),
|
||||||
|
},
|
||||||
|
extra_metadata: BTreeMap::new(),
|
||||||
|
})
|
||||||
|
.map_err(map_big_fish_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_big_fish_asset_oss_error)?;
|
||||||
|
let head = oss_client
|
||||||
|
.head_object(
|
||||||
|
&http_client,
|
||||||
|
OssHeadObjectRequest {
|
||||||
|
object_key: put_result.object_key.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_big_fish_asset_oss_error)?;
|
||||||
|
let asset_object = state
|
||||||
|
.spacetime_client()
|
||||||
|
.confirm_asset_object(
|
||||||
|
build_asset_object_upsert_input(
|
||||||
|
generate_asset_object_id(generated_at_micros),
|
||||||
|
head.bucket,
|
||||||
|
head.object_key,
|
||||||
|
AssetObjectAccessPolicy::Private,
|
||||||
|
head.content_type.or(Some(persisted_mime_type)),
|
||||||
|
head.content_length,
|
||||||
|
head.etag,
|
||||||
|
context.asset_object_kind.clone(),
|
||||||
|
Some(generated.task_id),
|
||||||
|
Some(owner_user_id.to_string()),
|
||||||
|
None,
|
||||||
|
Some(context.entity_id.clone()),
|
||||||
|
generated_at_micros,
|
||||||
|
)
|
||||||
|
.map_err(map_big_fish_asset_object_prepare_error)?,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_big_fish_asset_spacetime_error)?;
|
||||||
|
state
|
||||||
|
.spacetime_client()
|
||||||
|
.bind_asset_object_to_entity(
|
||||||
|
build_asset_entity_binding_input(
|
||||||
|
generate_asset_binding_id(generated_at_micros),
|
||||||
|
asset_object.asset_object_id,
|
||||||
|
BIG_FISH_ENTITY_KIND.to_string(),
|
||||||
|
context.entity_id.clone(),
|
||||||
|
context.binding_slot.clone(),
|
||||||
|
context.asset_object_kind.clone(),
|
||||||
|
Some(owner_user_id.to_string()),
|
||||||
|
None,
|
||||||
|
generated_at_micros,
|
||||||
|
)
|
||||||
|
.map_err(map_big_fish_asset_binding_prepare_error)?,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_big_fish_asset_spacetime_error)?;
|
||||||
|
|
||||||
|
Ok(put_result.legacy_public_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_big_fish_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!("准备 Big Fish 图片资产上传请求失败:{error:?}"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_big_fish_json_payload(raw_text: &str, fallback_message: &str) -> Result<Value, AppError> {
|
||||||
|
serde_json::from_str::<Value>(raw_text).map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "dashscope",
|
||||||
|
"message": format!("{fallback_message}:{error}"),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_big_fish_task_id(payload: &Value) -> Option<String> {
|
||||||
|
find_first_big_fish_string_by_key(payload, "task_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_big_fish_image_urls(payload: &Value) -> Vec<String> {
|
||||||
|
let mut urls = Vec::new();
|
||||||
|
collect_big_fish_strings_by_key(payload, "image", &mut urls);
|
||||||
|
collect_big_fish_strings_by_key(payload, "url", &mut urls);
|
||||||
|
let mut deduped = Vec::new();
|
||||||
|
for url in urls {
|
||||||
|
if !deduped.contains(&url) {
|
||||||
|
deduped.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deduped
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_first_big_fish_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
collect_big_fish_strings_by_key(payload, target_key, &mut results);
|
||||||
|
results.into_iter().next()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_big_fish_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec<String>) {
|
||||||
|
match payload {
|
||||||
|
Value::Array(entries) => {
|
||||||
|
for entry in entries {
|
||||||
|
collect_big_fish_strings_by_key(entry, target_key, results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Object(object) => {
|
||||||
|
for (key, value) in object {
|
||||||
|
if key == target_key
|
||||||
|
&& let Some(text) = value.as_str()
|
||||||
|
{
|
||||||
|
results.push(text.to_string());
|
||||||
|
}
|
||||||
|
collect_big_fish_strings_by_key(value, target_key, results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_big_fish_downloaded_image_mime_type(content_type: &str) -> String {
|
||||||
|
let mime_type = content_type
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or("image/jpeg");
|
||||||
|
match mime_type {
|
||||||
|
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
|
||||||
|
mime_type.to_string()
|
||||||
|
}
|
||||||
|
_ => "image/jpeg".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn map_big_fish_dashscope_request_error(message: String) -> AppError {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "dashscope",
|
||||||
|
"message": message,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_big_fish_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "dashscope",
|
||||||
|
"message": parse_big_fish_api_error_message(raw_text, fallback_message),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_big_fish_api_error_message(raw_text: &str, fallback_message: &str) -> String {
|
||||||
|
let trimmed = raw_text.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return fallback_message.to_string();
|
||||||
|
}
|
||||||
|
if let Ok(payload) = serde_json::from_str::<Value>(trimmed)
|
||||||
|
&& let Some(message) = find_first_big_fish_string_by_key(&payload, "message")
|
||||||
|
.or_else(|| find_first_big_fish_string_by_key(&payload, "code"))
|
||||||
|
{
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
let excerpt = trimmed.chars().take(240).collect::<String>();
|
||||||
|
format!("{fallback_message}:{excerpt}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_big_fish_asset_object_prepare_error(error: AssetObjectFieldError) -> AppError {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": "asset-object",
|
||||||
|
"message": error.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_big_fish_asset_binding_prepare_error(error: AssetObjectFieldError) -> AppError {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": "asset-entity-binding",
|
||||||
|
"message": error.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_big_fish_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "spacetimedb",
|
||||||
|
"message": error.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_big_fish_asset_oss_error(error: platform_oss::OssError) -> AppError {
|
||||||
|
map_oss_error(error, "aliyun-oss")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_big_fish_level_part(level: Option<u32>) -> String {
|
||||||
|
level
|
||||||
|
.map(|value| format!("level-{value}"))
|
||||||
|
.unwrap_or_else(|| "stage".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
321
server-rs/crates/api-server/src/big_fish/mappers.rs
Normal file
321
server-rs/crates/api-server/src/big_fish/mappers.rs
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -2770,383 +2770,9 @@ async fn upsert_custom_world_draft_foundation_progress(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_custom_world_library_entry_response(
|
mod mappers;
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_custom_world_library_entry_response_from_work_summary(
|
use mappers::*;
|
||||||
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()))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn log_custom_world_publish_gate_diagnostics(
|
fn log_custom_world_publish_gate_diagnostics(
|
||||||
source: &str,
|
source: &str,
|
||||||
|
|||||||
380
server-rs/crates/api-server/src/custom_world/mappers.rs
Normal file
380
server-rs/crates/api-server/src/custom_world/mappers.rs
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
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()))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -17,8 +17,7 @@ use module_assets::{
|
|||||||
};
|
};
|
||||||
use platform_llm::{LlmMessage, LlmTextRequest};
|
use platform_llm::{LlmMessage, LlmTextRequest};
|
||||||
use platform_oss::{
|
use platform_oss::{
|
||||||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssSignedGetObjectUrlRequest,
|
||||||
OssSignedGetObjectUrlRequest,
|
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
@@ -26,6 +25,11 @@ use spacetime_client::SpacetimeClientError;
|
|||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
use webp::Encoder as WebpEncoder;
|
use webp::Encoder as WebpEncoder;
|
||||||
|
|
||||||
|
use crate::generated_image_assets::{
|
||||||
|
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
|
||||||
|
adapter::GeneratedImageAssetAdapterMetadata, adapter::GeneratedImageAssetPersistInput,
|
||||||
|
normalize_generated_image_asset_mime,
|
||||||
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
api_response::json_success_body,
|
api_response::json_success_body,
|
||||||
asset_billing::{execute_billable_asset_operation, execute_billable_asset_operation_with_cost},
|
asset_billing::{execute_billable_asset_operation, execute_billable_asset_operation_with_cost},
|
||||||
@@ -1084,482 +1088,15 @@ pub async fn generate_custom_world_opening_cg(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn persist_custom_world_asset(
|
mod assets;
|
||||||
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 put_result = oss_client
|
|
||||||
.put_object(
|
|
||||||
&http_client,
|
|
||||||
OssPutObjectRequest {
|
|
||||||
prefix: upload.prefix,
|
|
||||||
path_segments: upload.path_segments,
|
|
||||||
file_name: upload.file_name,
|
|
||||||
content_type: Some(upload.content_type.clone()),
|
|
||||||
access: OssObjectAccess::Private,
|
|
||||||
metadata: build_asset_metadata(
|
|
||||||
upload.asset_kind,
|
|
||||||
owner_user_id,
|
|
||||||
upload.profile_id.as_deref(),
|
|
||||||
upload.entity_kind,
|
|
||||||
upload.entity_id.as_str(),
|
|
||||||
upload.slot,
|
|
||||||
),
|
|
||||||
body: upload.body,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.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(upload.content_type)),
|
|
||||||
head.content_length,
|
|
||||||
head.etag,
|
|
||||||
upload.asset_kind.to_string(),
|
|
||||||
upload.source_job_id,
|
|
||||||
Some(owner_user_id.to_string()),
|
|
||||||
upload.profile_id.clone(),
|
|
||||||
Some(upload.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,
|
|
||||||
upload.entity_kind.to_string(),
|
|
||||||
upload.entity_id,
|
|
||||||
upload.slot.to_string(),
|
|
||||||
upload.asset_kind.to_string(),
|
|
||||||
Some(owner_user_id.to_string()),
|
|
||||||
upload.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)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn generate_opening_cg_storyboard(
|
use assets::persist_custom_world_asset;
|
||||||
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 {
|
mod opening_cg;
|
||||||
image_src: asset.image_src,
|
|
||||||
asset_id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn generate_opening_cg_video(
|
use opening_cg::{
|
||||||
state: &AppState,
|
generate_opening_cg_storyboard, generate_opening_cg_video, map_custom_world_asset_oss_error,
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_asset_metadata(
|
|
||||||
asset_kind: &str,
|
|
||||||
owner_user_id: &str,
|
|
||||||
profile_id: Option<&str>,
|
|
||||||
entity_kind: &str,
|
|
||||||
entity_id: &str,
|
|
||||||
slot: &str,
|
|
||||||
) -> BTreeMap<String, String> {
|
|
||||||
let mut metadata = BTreeMap::from([
|
|
||||||
("asset_kind".to_string(), asset_kind.to_string()),
|
|
||||||
("owner_user_id".to_string(), owner_user_id.to_string()),
|
|
||||||
("entity_kind".to_string(), entity_kind.to_string()),
|
|
||||||
("entity_id".to_string(), entity_id.to_string()),
|
|
||||||
("slot".to_string(), slot.to_string()),
|
|
||||||
]);
|
|
||||||
if let Some(profile_id) = profile_id {
|
|
||||||
metadata.insert("profile_id".to_string(), profile_id.to_string());
|
|
||||||
}
|
|
||||||
metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
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(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
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(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
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(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_custom_world_asset_oss_error(error: platform_oss::OssError) -> AppError {
|
|
||||||
map_oss_error(error, "aliyun-oss")
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn generate_entity_with_fallback(state: &AppState, profile: &Value, kind: &str) -> Value {
|
async fn generate_entity_with_fallback(state: &AppState, profile: &Value, kind: &str) -> Value {
|
||||||
let fallback = build_entity_fallback(profile, kind);
|
let fallback = build_entity_fallback(profile, kind);
|
||||||
|
|||||||
122
server-rs/crates/api-server/src/custom_world_ai/assets.rs
Normal file
122
server-rs/crates/api-server/src/custom_world_ai/assets.rs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
use super::*;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
377
server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs
Normal file
377
server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
@@ -35,6 +35,7 @@ mod custom_world_foundation_draft;
|
|||||||
mod custom_world_result_prompts;
|
mod custom_world_result_prompts;
|
||||||
mod custom_world_rpg_draft_prompts;
|
mod custom_world_rpg_draft_prompts;
|
||||||
mod error_middleware;
|
mod error_middleware;
|
||||||
|
mod generated_image_assets;
|
||||||
mod health;
|
mod health;
|
||||||
mod http_error;
|
mod http_error;
|
||||||
mod hyper3d_generation;
|
mod hyper3d_generation;
|
||||||
@@ -44,6 +45,7 @@ mod login_options;
|
|||||||
mod logout;
|
mod logout;
|
||||||
mod logout_all;
|
mod logout_all;
|
||||||
mod match3d;
|
mod match3d;
|
||||||
|
mod modules;
|
||||||
mod openai_image_generation;
|
mod openai_image_generation;
|
||||||
mod password_entry;
|
mod password_entry;
|
||||||
mod password_management;
|
mod password_management;
|
||||||
|
|||||||
@@ -2137,493 +2137,9 @@ async fn persist_match3d_generated_item_assets_snapshot(
|
|||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_match3d_agent_session_response(
|
mod mappers;
|
||||||
session: Match3DAgentSessionRecord,
|
|
||||||
) -> Match3DAgentSessionSnapshotResponse {
|
|
||||||
Match3DAgentSessionSnapshotResponse {
|
|
||||||
session_id: session.session_id,
|
|
||||||
current_turn: session.current_turn,
|
|
||||||
progress_percent: session.progress_percent,
|
|
||||||
stage: session.stage.clone(),
|
|
||||||
anchor_pack: map_match3d_anchor_pack_response_for_turn(
|
|
||||||
session.anchor_pack,
|
|
||||||
session.current_turn,
|
|
||||||
session.stage.as_str(),
|
|
||||||
),
|
|
||||||
config: session.config.map(map_match3d_config_response),
|
|
||||||
draft: session.draft.map(map_match3d_draft_response),
|
|
||||||
messages: session
|
|
||||||
.messages
|
|
||||||
.into_iter()
|
|
||||||
.map(map_match3d_message_response)
|
|
||||||
.collect(),
|
|
||||||
last_assistant_reply: session.last_assistant_reply,
|
|
||||||
published_profile_id: session.published_profile_id,
|
|
||||||
updated_at: session.updated_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_match3d_agent_session_response_with_assets(
|
use mappers::*;
|
||||||
session: Match3DAgentSessionRecord,
|
|
||||||
generated_item_assets: &[Match3DGeneratedItemAsset],
|
|
||||||
) -> Match3DAgentSessionSnapshotResponse {
|
|
||||||
let mut response = map_match3d_agent_session_response(session);
|
|
||||||
if let Some(draft) = response.draft.as_mut() {
|
|
||||||
draft.generated_item_assets = generated_item_assets
|
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.map(map_match3d_generated_item_asset_for_agent)
|
|
||||||
.collect();
|
|
||||||
if draft
|
|
||||||
.cover_image_src
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.is_empty()
|
|
||||||
{
|
|
||||||
draft.cover_image_src = resolve_match3d_default_cover_image_src(generated_item_assets);
|
|
||||||
}
|
|
||||||
let background_asset = find_match3d_generated_background_asset(generated_item_assets);
|
|
||||||
apply_match3d_background_asset_to_agent_draft(draft, background_asset);
|
|
||||||
}
|
|
||||||
response
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_match3d_anchor_pack_response_for_turn(
|
|
||||||
anchor: Match3DAnchorPackRecord,
|
|
||||||
current_turn: u32,
|
|
||||||
stage: &str,
|
|
||||||
) -> Match3DAnchorPackResponse {
|
|
||||||
let is_ready = matches!(
|
|
||||||
stage,
|
|
||||||
"ReadyToCompile"
|
|
||||||
| "ready_to_compile"
|
|
||||||
| "DraftCompiled"
|
|
||||||
| "draft_compiled"
|
|
||||||
| "draft_ready"
|
|
||||||
| "ReadyToPublish"
|
|
||||||
| "ready_to_publish"
|
|
||||||
| "Published"
|
|
||||||
| "published"
|
|
||||||
);
|
|
||||||
let collected_count = if is_ready { 3 } else { current_turn.min(3) };
|
|
||||||
|
|
||||||
Match3DAnchorPackResponse {
|
|
||||||
theme: map_match3d_anchor_item_response_for_collected(anchor.theme, collected_count >= 1),
|
|
||||||
clear_count: map_match3d_anchor_item_response_for_collected(
|
|
||||||
anchor.clear_count,
|
|
||||||
collected_count >= 2,
|
|
||||||
),
|
|
||||||
difficulty: map_match3d_anchor_item_response_for_collected(
|
|
||||||
anchor.difficulty,
|
|
||||||
collected_count >= 3,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_match3d_anchor_item_response(anchor: Match3DAnchorItemRecord) -> Match3DAnchorItemResponse {
|
|
||||||
Match3DAnchorItemResponse {
|
|
||||||
key: anchor.key,
|
|
||||||
label: anchor.label,
|
|
||||||
value: anchor.value,
|
|
||||||
status: anchor.status,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_match3d_anchor_item_response_for_collected(
|
|
||||||
anchor: Match3DAnchorItemRecord,
|
|
||||||
collected: bool,
|
|
||||||
) -> Match3DAnchorItemResponse {
|
|
||||||
if collected {
|
|
||||||
return map_match3d_anchor_item_response(anchor);
|
|
||||||
}
|
|
||||||
|
|
||||||
Match3DAnchorItemResponse {
|
|
||||||
key: anchor.key,
|
|
||||||
label: anchor.label,
|
|
||||||
value: String::new(),
|
|
||||||
status: "missing".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_match3d_config_response(config: Match3DCreatorConfigRecord) -> Match3DCreatorConfigResponse {
|
|
||||||
Match3DCreatorConfigResponse {
|
|
||||||
theme_text: config.theme_text,
|
|
||||||
reference_image_src: config.reference_image_src,
|
|
||||||
clear_count: config.clear_count,
|
|
||||||
difficulty: config.difficulty,
|
|
||||||
asset_style_id: config.asset_style_id,
|
|
||||||
asset_style_label: config.asset_style_label,
|
|
||||||
asset_style_prompt: config.asset_style_prompt,
|
|
||||||
generate_click_sound: config.generate_click_sound,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_match3d_draft_response(draft: Match3DResultDraftRecord) -> Match3DResultDraftResponse {
|
|
||||||
Match3DResultDraftResponse {
|
|
||||||
profile_id: draft.profile_id,
|
|
||||||
game_name: draft.game_name,
|
|
||||||
theme_text: draft.theme_text,
|
|
||||||
summary_text: Some(draft.summary_text.clone()),
|
|
||||||
summary: draft.summary_text,
|
|
||||||
tags: draft.tags,
|
|
||||||
cover_image_src: draft.cover_image_src,
|
|
||||||
reference_image_src: draft.reference_image_src,
|
|
||||||
clear_count: draft.clear_count,
|
|
||||||
difficulty: draft.difficulty,
|
|
||||||
total_item_count: draft.total_item_count,
|
|
||||||
publish_ready: draft.publish_ready,
|
|
||||||
blockers: draft.blockers,
|
|
||||||
background_prompt: None,
|
|
||||||
background_image_src: None,
|
|
||||||
background_image_object_key: None,
|
|
||||||
generated_background_asset: None,
|
|
||||||
generated_item_assets: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_match3d_generated_item_asset_for_agent(
|
|
||||||
asset: Match3DGeneratedItemAsset,
|
|
||||||
) -> Match3DAgentGeneratedItemAssetResponse {
|
|
||||||
Match3DAgentGeneratedItemAssetResponse {
|
|
||||||
item_id: asset.item_id,
|
|
||||||
item_name: asset.item_name,
|
|
||||||
image_src: asset.image_src,
|
|
||||||
image_object_key: asset.image_object_key,
|
|
||||||
image_views: asset
|
|
||||||
.image_views
|
|
||||||
.into_iter()
|
|
||||||
.map(map_match3d_image_view_for_agent)
|
|
||||||
.collect(),
|
|
||||||
model_src: asset.model_src,
|
|
||||||
model_object_key: asset.model_object_key,
|
|
||||||
model_file_name: asset.model_file_name,
|
|
||||||
task_uuid: asset.task_uuid,
|
|
||||||
subscription_key: asset.subscription_key,
|
|
||||||
sound_prompt: asset.sound_prompt,
|
|
||||||
background_music_title: asset.background_music_title,
|
|
||||||
background_music_style: asset.background_music_style,
|
|
||||||
background_music_prompt: asset.background_music_prompt,
|
|
||||||
background_music: asset.background_music,
|
|
||||||
click_sound: asset.click_sound,
|
|
||||||
background_asset: asset
|
|
||||||
.background_asset
|
|
||||||
.map(map_match3d_background_asset_for_agent),
|
|
||||||
status: asset.status,
|
|
||||||
error: asset.error,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_match3d_generated_item_asset_for_work(
|
|
||||||
asset: Match3DGeneratedItemAssetJson,
|
|
||||||
) -> shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse {
|
|
||||||
shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse {
|
|
||||||
item_id: asset.item_id,
|
|
||||||
item_name: asset.item_name,
|
|
||||||
image_src: asset.image_src,
|
|
||||||
image_object_key: asset.image_object_key,
|
|
||||||
image_views: asset
|
|
||||||
.image_views
|
|
||||||
.into_iter()
|
|
||||||
.map(map_match3d_image_view_for_work)
|
|
||||||
.collect(),
|
|
||||||
model_src: asset.model_src,
|
|
||||||
model_object_key: asset.model_object_key,
|
|
||||||
model_file_name: asset.model_file_name,
|
|
||||||
task_uuid: asset.task_uuid,
|
|
||||||
subscription_key: asset.subscription_key,
|
|
||||||
sound_prompt: asset.sound_prompt,
|
|
||||||
background_music_title: asset.background_music_title,
|
|
||||||
background_music_style: asset.background_music_style,
|
|
||||||
background_music_prompt: asset.background_music_prompt,
|
|
||||||
background_music: asset.background_music,
|
|
||||||
click_sound: asset.click_sound,
|
|
||||||
background_asset: asset
|
|
||||||
.background_asset
|
|
||||||
.map(map_match3d_background_asset_for_work),
|
|
||||||
status: asset.status,
|
|
||||||
error: asset.error,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_match3d_image_view_for_agent(
|
|
||||||
view: Match3DGeneratedItemImageView,
|
|
||||||
) -> shared_contracts::match3d_agent::Match3DGeneratedItemImageViewResponse {
|
|
||||||
shared_contracts::match3d_agent::Match3DGeneratedItemImageViewResponse {
|
|
||||||
view_id: view.view_id,
|
|
||||||
view_index: view.view_index,
|
|
||||||
image_src: view.image_src,
|
|
||||||
image_object_key: view.image_object_key,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_match3d_image_view_for_work(
|
|
||||||
view: Match3DGeneratedItemImageView,
|
|
||||||
) -> shared_contracts::match3d_works::Match3DGeneratedItemImageViewResponse {
|
|
||||||
shared_contracts::match3d_works::Match3DGeneratedItemImageViewResponse {
|
|
||||||
view_id: view.view_id,
|
|
||||||
view_index: view.view_index,
|
|
||||||
image_src: view.image_src,
|
|
||||||
image_object_key: view.image_object_key,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_match3d_image_view_from_work(
|
|
||||||
view: shared_contracts::match3d_works::Match3DGeneratedItemImageViewResponse,
|
|
||||||
) -> Match3DGeneratedItemImageView {
|
|
||||||
Match3DGeneratedItemImageView {
|
|
||||||
view_id: view.view_id,
|
|
||||||
view_index: view.view_index,
|
|
||||||
image_src: view.image_src,
|
|
||||||
image_object_key: view.image_object_key,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_match3d_background_asset_for_agent(
|
|
||||||
asset: Match3DGeneratedBackgroundAsset,
|
|
||||||
) -> shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse {
|
|
||||||
shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse {
|
|
||||||
prompt: asset.prompt,
|
|
||||||
image_src: asset.image_src,
|
|
||||||
image_object_key: asset.image_object_key,
|
|
||||||
container_prompt: asset.container_prompt,
|
|
||||||
container_image_src: asset.container_image_src,
|
|
||||||
container_image_object_key: asset.container_image_object_key,
|
|
||||||
status: asset.status,
|
|
||||||
error: asset.error,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_match3d_background_asset_for_work(
|
|
||||||
asset: Match3DGeneratedBackgroundAsset,
|
|
||||||
) -> shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse {
|
|
||||||
shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse {
|
|
||||||
prompt: asset.prompt,
|
|
||||||
image_src: asset.image_src,
|
|
||||||
image_object_key: asset.image_object_key,
|
|
||||||
container_prompt: asset.container_prompt,
|
|
||||||
container_image_src: asset.container_image_src,
|
|
||||||
container_image_object_key: asset.container_image_object_key,
|
|
||||||
status: asset.status,
|
|
||||||
error: asset.error,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_match3d_generated_background_asset(
|
|
||||||
assets: &[Match3DGeneratedItemAsset],
|
|
||||||
) -> Option<Match3DGeneratedBackgroundAsset> {
|
|
||||||
assets
|
|
||||||
.iter()
|
|
||||||
.find_map(|asset| asset.background_asset.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_match3d_default_cover_image_src(assets: &[Match3DGeneratedItemAsset]) -> Option<String> {
|
|
||||||
find_match3d_generated_background_asset(assets).and_then(|asset| {
|
|
||||||
asset
|
|
||||||
.container_image_src
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(str::to_string)
|
|
||||||
.or_else(|| {
|
|
||||||
asset
|
|
||||||
.container_image_object_key
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(str::to_string)
|
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
asset
|
|
||||||
.image_src
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(str::to_string)
|
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
asset
|
|
||||||
.image_object_key
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(str::to_string)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_match3d_generated_background_asset_json(
|
|
||||||
assets: &[Match3DGeneratedItemAssetJson],
|
|
||||||
) -> Option<Match3DGeneratedBackgroundAsset> {
|
|
||||||
assets
|
|
||||||
.iter()
|
|
||||||
.find_map(|asset| asset.background_asset.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_match3d_background_asset_to_agent_draft(
|
|
||||||
draft: &mut Match3DResultDraftResponse,
|
|
||||||
background_asset: Option<Match3DGeneratedBackgroundAsset>,
|
|
||||||
) {
|
|
||||||
if let Some(asset) = background_asset {
|
|
||||||
draft.background_prompt = Some(asset.prompt.clone());
|
|
||||||
draft.background_image_src = asset.image_src.clone();
|
|
||||||
draft.background_image_object_key = asset.image_object_key.clone();
|
|
||||||
draft.generated_background_asset = Some(map_match3d_background_asset_for_agent(asset));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_match3d_message_response(message: Match3DAgentMessageRecord) -> Match3DAgentMessageResponse {
|
|
||||||
Match3DAgentMessageResponse {
|
|
||||||
id: message.message_id,
|
|
||||||
role: message.role,
|
|
||||||
kind: message.kind,
|
|
||||||
text: message.text,
|
|
||||||
created_at: message.created_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_match3d_work_summary_response(item: Match3DWorkProfileRecord) -> Match3DWorkSummaryResponse {
|
|
||||||
let generated_item_asset_json =
|
|
||||||
parse_match3d_generated_item_assets(item.generated_item_assets_json.as_deref());
|
|
||||||
let background_asset = find_match3d_generated_background_asset_json(&generated_item_asset_json);
|
|
||||||
let generated_background_asset = background_asset
|
|
||||||
.clone()
|
|
||||||
.map(map_match3d_background_asset_for_work);
|
|
||||||
let generated_item_assets = generated_item_asset_json
|
|
||||||
.into_iter()
|
|
||||||
.map(map_match3d_generated_item_asset_for_work)
|
|
||||||
.collect();
|
|
||||||
Match3DWorkSummaryResponse {
|
|
||||||
work_id: item.work_id,
|
|
||||||
profile_id: item.profile_id,
|
|
||||||
owner_user_id: item.owner_user_id,
|
|
||||||
source_session_id: item.source_session_id,
|
|
||||||
game_name: item.game_name,
|
|
||||||
theme_text: item.theme_text,
|
|
||||||
summary: item.summary,
|
|
||||||
tags: item.tags,
|
|
||||||
cover_image_src: item.cover_image_src,
|
|
||||||
reference_image_src: item.reference_image_src,
|
|
||||||
clear_count: item.clear_count,
|
|
||||||
difficulty: item.difficulty,
|
|
||||||
publication_status: item.publication_status,
|
|
||||||
play_count: item.play_count,
|
|
||||||
updated_at: item.updated_at,
|
|
||||||
published_at: item.published_at,
|
|
||||||
publish_ready: item.publish_ready,
|
|
||||||
background_prompt: background_asset.as_ref().map(|asset| asset.prompt.clone()),
|
|
||||||
background_image_src: background_asset
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|asset| asset.image_src.clone()),
|
|
||||||
background_image_object_key: background_asset
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|asset| asset.image_object_key.clone()),
|
|
||||||
generated_background_asset,
|
|
||||||
generated_item_assets,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn match3d_bad_gateway(message: impl Into<String>) -> AppError {
|
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
||||||
"provider": MATCH3D_AGENT_PROVIDER,
|
|
||||||
"message": message.into(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn match3d_background_music_missing_error(message: impl Into<String>) -> AppError {
|
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
||||||
"provider": MATCH3D_AGENT_PROVIDER,
|
|
||||||
"message": message.into(),
|
|
||||||
"missingAssets": ["背景音乐"],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn require_match3d_background_music_title(
|
|
||||||
plan: &Match3DGeneratedBackgroundMusicPlan,
|
|
||||||
) -> Result<String, AppError> {
|
|
||||||
let title = normalize_match3d_audio_title(plan.title.as_str());
|
|
||||||
if title.is_empty() {
|
|
||||||
return Err(match3d_background_music_missing_error(
|
|
||||||
"抓大鹅草稿背景音乐名称为空,无法完成背景音乐生成",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(title)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_match3d_work_profile_response(item: Match3DWorkProfileRecord) -> Match3DWorkProfileResponse {
|
|
||||||
Match3DWorkProfileResponse {
|
|
||||||
summary: map_match3d_work_summary_response(item),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_match3d_run_response(run: Match3DRunRecord) -> Match3DRunSnapshotResponse {
|
|
||||||
Match3DRunSnapshotResponse {
|
|
||||||
run_id: run.run_id,
|
|
||||||
profile_id: run.profile_id,
|
|
||||||
owner_user_id: run.owner_user_id,
|
|
||||||
status: normalize_match3d_run_status(run.status.as_str()).to_string(),
|
|
||||||
snapshot_version: run.snapshot_version,
|
|
||||||
started_at_ms: run.started_at_ms,
|
|
||||||
duration_limit_ms: run.duration_limit_ms,
|
|
||||||
server_now_ms: run.server_now_ms,
|
|
||||||
remaining_ms: run.remaining_ms,
|
|
||||||
clear_count: run.clear_count,
|
|
||||||
total_item_count: run.total_item_count,
|
|
||||||
cleared_item_count: run.cleared_item_count,
|
|
||||||
items: run
|
|
||||||
.items
|
|
||||||
.into_iter()
|
|
||||||
.map(map_match3d_item_response)
|
|
||||||
.collect(),
|
|
||||||
tray_slots: run
|
|
||||||
.tray_slots
|
|
||||||
.into_iter()
|
|
||||||
.map(map_match3d_tray_slot_response)
|
|
||||||
.collect(),
|
|
||||||
failure_reason: run
|
|
||||||
.failure_reason
|
|
||||||
.map(|reason| normalize_match3d_failure_reason(reason.as_str()).to_string()),
|
|
||||||
last_confirmed_action_id: run.last_confirmed_action_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_match3d_item_response(item: Match3DItemSnapshotRecord) -> Match3DItemSnapshotResponse {
|
|
||||||
Match3DItemSnapshotResponse {
|
|
||||||
item_instance_id: item.item_instance_id,
|
|
||||||
item_type_id: item.item_type_id,
|
|
||||||
visual_key: item.visual_key,
|
|
||||||
x: item.x,
|
|
||||||
y: item.y,
|
|
||||||
radius: item.radius,
|
|
||||||
layer: item.layer,
|
|
||||||
state: normalize_match3d_item_state(item.state.as_str()).to_string(),
|
|
||||||
clickable: item.clickable,
|
|
||||||
tray_slot_index: item.tray_slot_index,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_match3d_tray_slot_response(slot: Match3DTraySlotRecord) -> Match3DTraySlotResponse {
|
|
||||||
Match3DTraySlotResponse {
|
|
||||||
slot_index: slot.slot_index,
|
|
||||||
item_instance_id: slot.item_instance_id,
|
|
||||||
item_type_id: slot.item_type_id,
|
|
||||||
visual_key: slot.visual_key,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_match3d_click_confirmation_response(
|
|
||||||
confirmation: Match3DClickConfirmationRecord,
|
|
||||||
) -> Match3DClickConfirmationResponse {
|
|
||||||
Match3DClickConfirmationResponse {
|
|
||||||
accepted: confirmation.accepted,
|
|
||||||
reject_reason: confirmation
|
|
||||||
.reject_reason
|
|
||||||
.map(|reason| normalize_match3d_click_reject_reason(reason.as_str()).to_string()),
|
|
||||||
entered_slot_index: confirmation.entered_slot_index,
|
|
||||||
cleared_item_instance_ids: confirmation.cleared_item_instance_ids,
|
|
||||||
run: map_match3d_run_response(confirmation.run),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_config_from_create_request(
|
fn build_config_from_create_request(
|
||||||
payload: &CreateMatch3DAgentSessionRequest,
|
payload: &CreateMatch3DAgentSessionRequest,
|
||||||
@@ -2861,175 +2377,9 @@ fn normalize_optional_match3d_text(value: Option<String>) -> Option<String> {
|
|||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_match3d_tag(value: &str) -> String {
|
mod tags;
|
||||||
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
|
use tags::*;
|
||||||
.trim_matches(|ch: char| {
|
|
||||||
ch.is_ascii_punctuation()
|
|
||||||
|| matches!(
|
|
||||||
ch,
|
|
||||||
',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.trim()
|
|
||||||
.chars()
|
|
||||||
.filter(|ch| !matches!(ch, '#' | '"' | '\'' | '`'))
|
|
||||||
.collect::<String>()
|
|
||||||
.chars()
|
|
||||||
.take(6)
|
|
||||||
.collect::<String>()
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fallback_match3d_work_tags(game_name: &str, theme_text: &str) -> Vec<String> {
|
|
||||||
normalize_match3d_tag_candidates([theme_text, game_name, "抓大鹅", "经典消除", "2D素材"])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn serialize_match3d_generated_item_assets(assets: &[Match3DGeneratedItemAsset]) -> Option<String> {
|
fn serialize_match3d_generated_item_assets(assets: &[Match3DGeneratedItemAsset]) -> Option<String> {
|
||||||
if assets.is_empty() {
|
if assets.is_empty() {
|
||||||
@@ -3614,9 +2964,8 @@ async fn ensure_match3d_background_music_asset(
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
let title = require_match3d_background_music_title(plan).map_err(|error| {
|
let title = require_match3d_background_music_title(plan)
|
||||||
match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error)
|
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
|
||||||
})?;
|
|
||||||
let style = normalize_match3d_audio_style(plan.style.as_str());
|
let style = normalize_match3d_audio_style(plan.style.as_str());
|
||||||
match generate_match3d_background_music_asset(state, owner_user_id, profile_id, &title, &style)
|
match generate_match3d_background_music_asset(state, owner_user_id, profile_id, &title, &style)
|
||||||
.await
|
.await
|
||||||
@@ -6556,12 +5905,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn match3d_background_music_title_is_required_for_auto_draft() {
|
fn match3d_background_music_title_is_required_for_auto_draft() {
|
||||||
let missing = require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan {
|
let missing =
|
||||||
title: " ,。 ".to_string(),
|
require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan {
|
||||||
style: "轻快, 休闲".to_string(),
|
title: " ,。 ".to_string(),
|
||||||
prompt: String::new(),
|
style: "轻快, 休闲".to_string(),
|
||||||
})
|
prompt: String::new(),
|
||||||
.expect_err("自动草稿背景音乐必须有可提交给 Suno 的曲名");
|
})
|
||||||
|
.expect_err("自动草稿背景音乐必须有可提交给 Suno 的曲名");
|
||||||
|
|
||||||
assert!(missing.body_text().contains("背景音乐"));
|
assert!(missing.body_text().contains("背景音乐"));
|
||||||
|
|
||||||
|
|||||||
490
server-rs/crates/api-server/src/match3d/mappers.rs
Normal file
490
server-rs/crates/api-server/src/match3d/mappers.rs
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) fn map_match3d_agent_session_response(
|
||||||
|
session: Match3DAgentSessionRecord,
|
||||||
|
) -> Match3DAgentSessionSnapshotResponse {
|
||||||
|
Match3DAgentSessionSnapshotResponse {
|
||||||
|
session_id: session.session_id,
|
||||||
|
current_turn: session.current_turn,
|
||||||
|
progress_percent: session.progress_percent,
|
||||||
|
stage: session.stage.clone(),
|
||||||
|
anchor_pack: map_match3d_anchor_pack_response_for_turn(
|
||||||
|
session.anchor_pack,
|
||||||
|
session.current_turn,
|
||||||
|
session.stage.as_str(),
|
||||||
|
),
|
||||||
|
config: session.config.map(map_match3d_config_response),
|
||||||
|
draft: session.draft.map(map_match3d_draft_response),
|
||||||
|
messages: session
|
||||||
|
.messages
|
||||||
|
.into_iter()
|
||||||
|
.map(map_match3d_message_response)
|
||||||
|
.collect(),
|
||||||
|
last_assistant_reply: session.last_assistant_reply,
|
||||||
|
published_profile_id: session.published_profile_id,
|
||||||
|
updated_at: session.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_match3d_agent_session_response_with_assets(
|
||||||
|
session: Match3DAgentSessionRecord,
|
||||||
|
generated_item_assets: &[Match3DGeneratedItemAsset],
|
||||||
|
) -> Match3DAgentSessionSnapshotResponse {
|
||||||
|
let mut response = map_match3d_agent_session_response(session);
|
||||||
|
if let Some(draft) = response.draft.as_mut() {
|
||||||
|
draft.generated_item_assets = generated_item_assets
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(map_match3d_generated_item_asset_for_agent)
|
||||||
|
.collect();
|
||||||
|
if draft
|
||||||
|
.cover_image_src
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
|
draft.cover_image_src = resolve_match3d_default_cover_image_src(generated_item_assets);
|
||||||
|
}
|
||||||
|
let background_asset = find_match3d_generated_background_asset(generated_item_assets);
|
||||||
|
apply_match3d_background_asset_to_agent_draft(draft, background_asset);
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_match3d_anchor_pack_response_for_turn(
|
||||||
|
anchor: Match3DAnchorPackRecord,
|
||||||
|
current_turn: u32,
|
||||||
|
stage: &str,
|
||||||
|
) -> Match3DAnchorPackResponse {
|
||||||
|
let is_ready = matches!(
|
||||||
|
stage,
|
||||||
|
"ReadyToCompile"
|
||||||
|
| "ready_to_compile"
|
||||||
|
| "DraftCompiled"
|
||||||
|
| "draft_compiled"
|
||||||
|
| "draft_ready"
|
||||||
|
| "ReadyToPublish"
|
||||||
|
| "ready_to_publish"
|
||||||
|
| "Published"
|
||||||
|
| "published"
|
||||||
|
);
|
||||||
|
let collected_count = if is_ready { 3 } else { current_turn.min(3) };
|
||||||
|
|
||||||
|
Match3DAnchorPackResponse {
|
||||||
|
theme: map_match3d_anchor_item_response_for_collected(anchor.theme, collected_count >= 1),
|
||||||
|
clear_count: map_match3d_anchor_item_response_for_collected(
|
||||||
|
anchor.clear_count,
|
||||||
|
collected_count >= 2,
|
||||||
|
),
|
||||||
|
difficulty: map_match3d_anchor_item_response_for_collected(
|
||||||
|
anchor.difficulty,
|
||||||
|
collected_count >= 3,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_match3d_anchor_item_response(anchor: Match3DAnchorItemRecord) -> Match3DAnchorItemResponse {
|
||||||
|
Match3DAnchorItemResponse {
|
||||||
|
key: anchor.key,
|
||||||
|
label: anchor.label,
|
||||||
|
value: anchor.value,
|
||||||
|
status: anchor.status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_match3d_anchor_item_response_for_collected(
|
||||||
|
anchor: Match3DAnchorItemRecord,
|
||||||
|
collected: bool,
|
||||||
|
) -> Match3DAnchorItemResponse {
|
||||||
|
if collected {
|
||||||
|
return map_match3d_anchor_item_response(anchor);
|
||||||
|
}
|
||||||
|
|
||||||
|
Match3DAnchorItemResponse {
|
||||||
|
key: anchor.key,
|
||||||
|
label: anchor.label,
|
||||||
|
value: String::new(),
|
||||||
|
status: "missing".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_match3d_config_response(config: Match3DCreatorConfigRecord) -> Match3DCreatorConfigResponse {
|
||||||
|
Match3DCreatorConfigResponse {
|
||||||
|
theme_text: config.theme_text,
|
||||||
|
reference_image_src: config.reference_image_src,
|
||||||
|
clear_count: config.clear_count,
|
||||||
|
difficulty: config.difficulty,
|
||||||
|
asset_style_id: config.asset_style_id,
|
||||||
|
asset_style_label: config.asset_style_label,
|
||||||
|
asset_style_prompt: config.asset_style_prompt,
|
||||||
|
generate_click_sound: config.generate_click_sound,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_match3d_draft_response(draft: Match3DResultDraftRecord) -> Match3DResultDraftResponse {
|
||||||
|
Match3DResultDraftResponse {
|
||||||
|
profile_id: draft.profile_id,
|
||||||
|
game_name: draft.game_name,
|
||||||
|
theme_text: draft.theme_text,
|
||||||
|
summary_text: Some(draft.summary_text.clone()),
|
||||||
|
summary: draft.summary_text,
|
||||||
|
tags: draft.tags,
|
||||||
|
cover_image_src: draft.cover_image_src,
|
||||||
|
reference_image_src: draft.reference_image_src,
|
||||||
|
clear_count: draft.clear_count,
|
||||||
|
difficulty: draft.difficulty,
|
||||||
|
total_item_count: draft.total_item_count,
|
||||||
|
publish_ready: draft.publish_ready,
|
||||||
|
blockers: draft.blockers,
|
||||||
|
background_prompt: None,
|
||||||
|
background_image_src: None,
|
||||||
|
background_image_object_key: None,
|
||||||
|
generated_background_asset: None,
|
||||||
|
generated_item_assets: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_match3d_generated_item_asset_for_agent(
|
||||||
|
asset: Match3DGeneratedItemAsset,
|
||||||
|
) -> Match3DAgentGeneratedItemAssetResponse {
|
||||||
|
Match3DAgentGeneratedItemAssetResponse {
|
||||||
|
item_id: asset.item_id,
|
||||||
|
item_name: asset.item_name,
|
||||||
|
image_src: asset.image_src,
|
||||||
|
image_object_key: asset.image_object_key,
|
||||||
|
image_views: asset
|
||||||
|
.image_views
|
||||||
|
.into_iter()
|
||||||
|
.map(map_match3d_image_view_for_agent)
|
||||||
|
.collect(),
|
||||||
|
model_src: asset.model_src,
|
||||||
|
model_object_key: asset.model_object_key,
|
||||||
|
model_file_name: asset.model_file_name,
|
||||||
|
task_uuid: asset.task_uuid,
|
||||||
|
subscription_key: asset.subscription_key,
|
||||||
|
sound_prompt: asset.sound_prompt,
|
||||||
|
background_music_title: asset.background_music_title,
|
||||||
|
background_music_style: asset.background_music_style,
|
||||||
|
background_music_prompt: asset.background_music_prompt,
|
||||||
|
background_music: asset.background_music,
|
||||||
|
click_sound: asset.click_sound,
|
||||||
|
background_asset: asset
|
||||||
|
.background_asset
|
||||||
|
.map(map_match3d_background_asset_for_agent),
|
||||||
|
status: asset.status,
|
||||||
|
error: asset.error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_match3d_generated_item_asset_for_work(
|
||||||
|
asset: Match3DGeneratedItemAssetJson,
|
||||||
|
) -> shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse {
|
||||||
|
shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse {
|
||||||
|
item_id: asset.item_id,
|
||||||
|
item_name: asset.item_name,
|
||||||
|
image_src: asset.image_src,
|
||||||
|
image_object_key: asset.image_object_key,
|
||||||
|
image_views: asset
|
||||||
|
.image_views
|
||||||
|
.into_iter()
|
||||||
|
.map(map_match3d_image_view_for_work)
|
||||||
|
.collect(),
|
||||||
|
model_src: asset.model_src,
|
||||||
|
model_object_key: asset.model_object_key,
|
||||||
|
model_file_name: asset.model_file_name,
|
||||||
|
task_uuid: asset.task_uuid,
|
||||||
|
subscription_key: asset.subscription_key,
|
||||||
|
sound_prompt: asset.sound_prompt,
|
||||||
|
background_music_title: asset.background_music_title,
|
||||||
|
background_music_style: asset.background_music_style,
|
||||||
|
background_music_prompt: asset.background_music_prompt,
|
||||||
|
background_music: asset.background_music,
|
||||||
|
click_sound: asset.click_sound,
|
||||||
|
background_asset: asset
|
||||||
|
.background_asset
|
||||||
|
.map(map_match3d_background_asset_for_work),
|
||||||
|
status: asset.status,
|
||||||
|
error: asset.error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_match3d_image_view_for_agent(
|
||||||
|
view: Match3DGeneratedItemImageView,
|
||||||
|
) -> shared_contracts::match3d_agent::Match3DGeneratedItemImageViewResponse {
|
||||||
|
shared_contracts::match3d_agent::Match3DGeneratedItemImageViewResponse {
|
||||||
|
view_id: view.view_id,
|
||||||
|
view_index: view.view_index,
|
||||||
|
image_src: view.image_src,
|
||||||
|
image_object_key: view.image_object_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_match3d_image_view_for_work(
|
||||||
|
view: Match3DGeneratedItemImageView,
|
||||||
|
) -> shared_contracts::match3d_works::Match3DGeneratedItemImageViewResponse {
|
||||||
|
shared_contracts::match3d_works::Match3DGeneratedItemImageViewResponse {
|
||||||
|
view_id: view.view_id,
|
||||||
|
view_index: view.view_index,
|
||||||
|
image_src: view.image_src,
|
||||||
|
image_object_key: view.image_object_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_match3d_image_view_from_work(
|
||||||
|
view: shared_contracts::match3d_works::Match3DGeneratedItemImageViewResponse,
|
||||||
|
) -> Match3DGeneratedItemImageView {
|
||||||
|
Match3DGeneratedItemImageView {
|
||||||
|
view_id: view.view_id,
|
||||||
|
view_index: view.view_index,
|
||||||
|
image_src: view.image_src,
|
||||||
|
image_object_key: view.image_object_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_match3d_background_asset_for_agent(
|
||||||
|
asset: Match3DGeneratedBackgroundAsset,
|
||||||
|
) -> shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse {
|
||||||
|
shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse {
|
||||||
|
prompt: asset.prompt,
|
||||||
|
image_src: asset.image_src,
|
||||||
|
image_object_key: asset.image_object_key,
|
||||||
|
container_prompt: asset.container_prompt,
|
||||||
|
container_image_src: asset.container_image_src,
|
||||||
|
container_image_object_key: asset.container_image_object_key,
|
||||||
|
status: asset.status,
|
||||||
|
error: asset.error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_match3d_background_asset_for_work(
|
||||||
|
asset: Match3DGeneratedBackgroundAsset,
|
||||||
|
) -> shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse {
|
||||||
|
shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse {
|
||||||
|
prompt: asset.prompt,
|
||||||
|
image_src: asset.image_src,
|
||||||
|
image_object_key: asset.image_object_key,
|
||||||
|
container_prompt: asset.container_prompt,
|
||||||
|
container_image_src: asset.container_image_src,
|
||||||
|
container_image_object_key: asset.container_image_object_key,
|
||||||
|
status: asset.status,
|
||||||
|
error: asset.error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn find_match3d_generated_background_asset(
|
||||||
|
assets: &[Match3DGeneratedItemAsset],
|
||||||
|
) -> Option<Match3DGeneratedBackgroundAsset> {
|
||||||
|
assets
|
||||||
|
.iter()
|
||||||
|
.find_map(|asset| asset.background_asset.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn resolve_match3d_default_cover_image_src(assets: &[Match3DGeneratedItemAsset]) -> Option<String> {
|
||||||
|
find_match3d_generated_background_asset(assets).and_then(|asset| {
|
||||||
|
asset
|
||||||
|
.container_image_src
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.or_else(|| {
|
||||||
|
asset
|
||||||
|
.container_image_object_key
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
asset
|
||||||
|
.image_src
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
asset
|
||||||
|
.image_object_key
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn find_match3d_generated_background_asset_json(
|
||||||
|
assets: &[Match3DGeneratedItemAssetJson],
|
||||||
|
) -> Option<Match3DGeneratedBackgroundAsset> {
|
||||||
|
assets
|
||||||
|
.iter()
|
||||||
|
.find_map(|asset| asset.background_asset.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn apply_match3d_background_asset_to_agent_draft(
|
||||||
|
draft: &mut Match3DResultDraftResponse,
|
||||||
|
background_asset: Option<Match3DGeneratedBackgroundAsset>,
|
||||||
|
) {
|
||||||
|
if let Some(asset) = background_asset {
|
||||||
|
draft.background_prompt = Some(asset.prompt.clone());
|
||||||
|
draft.background_image_src = asset.image_src.clone();
|
||||||
|
draft.background_image_object_key = asset.image_object_key.clone();
|
||||||
|
draft.generated_background_asset = Some(map_match3d_background_asset_for_agent(asset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_match3d_message_response(message: Match3DAgentMessageRecord) -> Match3DAgentMessageResponse {
|
||||||
|
Match3DAgentMessageResponse {
|
||||||
|
id: message.message_id,
|
||||||
|
role: message.role,
|
||||||
|
kind: message.kind,
|
||||||
|
text: message.text,
|
||||||
|
created_at: message.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_match3d_work_summary_response(item: Match3DWorkProfileRecord) -> Match3DWorkSummaryResponse {
|
||||||
|
let generated_item_asset_json =
|
||||||
|
parse_match3d_generated_item_assets(item.generated_item_assets_json.as_deref());
|
||||||
|
let background_asset = find_match3d_generated_background_asset_json(&generated_item_asset_json);
|
||||||
|
let generated_background_asset = background_asset
|
||||||
|
.clone()
|
||||||
|
.map(map_match3d_background_asset_for_work);
|
||||||
|
let generated_item_assets = generated_item_asset_json
|
||||||
|
.into_iter()
|
||||||
|
.map(map_match3d_generated_item_asset_for_work)
|
||||||
|
.collect();
|
||||||
|
Match3DWorkSummaryResponse {
|
||||||
|
work_id: item.work_id,
|
||||||
|
profile_id: item.profile_id,
|
||||||
|
owner_user_id: item.owner_user_id,
|
||||||
|
source_session_id: item.source_session_id,
|
||||||
|
game_name: item.game_name,
|
||||||
|
theme_text: item.theme_text,
|
||||||
|
summary: item.summary,
|
||||||
|
tags: item.tags,
|
||||||
|
cover_image_src: item.cover_image_src,
|
||||||
|
reference_image_src: item.reference_image_src,
|
||||||
|
clear_count: item.clear_count,
|
||||||
|
difficulty: item.difficulty,
|
||||||
|
publication_status: item.publication_status,
|
||||||
|
play_count: item.play_count,
|
||||||
|
updated_at: item.updated_at,
|
||||||
|
published_at: item.published_at,
|
||||||
|
publish_ready: item.publish_ready,
|
||||||
|
background_prompt: background_asset.as_ref().map(|asset| asset.prompt.clone()),
|
||||||
|
background_image_src: background_asset
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|asset| asset.image_src.clone()),
|
||||||
|
background_image_object_key: background_asset
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|asset| asset.image_object_key.clone()),
|
||||||
|
generated_background_asset,
|
||||||
|
generated_item_assets,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn match3d_bad_gateway(message: impl Into<String>) -> AppError {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": MATCH3D_AGENT_PROVIDER,
|
||||||
|
"message": message.into(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn match3d_background_music_missing_error(message: impl Into<String>) -> AppError {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": MATCH3D_AGENT_PROVIDER,
|
||||||
|
"message": message.into(),
|
||||||
|
"missingAssets": ["背景音乐"],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn require_match3d_background_music_title(
|
||||||
|
plan: &Match3DGeneratedBackgroundMusicPlan,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let title = normalize_match3d_audio_title(plan.title.as_str());
|
||||||
|
if title.is_empty() {
|
||||||
|
return Err(match3d_background_music_missing_error(
|
||||||
|
"抓大鹅草稿背景音乐名称为空,无法完成背景音乐生成",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_match3d_work_profile_response(item: Match3DWorkProfileRecord) -> Match3DWorkProfileResponse {
|
||||||
|
Match3DWorkProfileResponse {
|
||||||
|
summary: map_match3d_work_summary_response(item),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_match3d_run_response(run: Match3DRunRecord) -> Match3DRunSnapshotResponse {
|
||||||
|
Match3DRunSnapshotResponse {
|
||||||
|
run_id: run.run_id,
|
||||||
|
profile_id: run.profile_id,
|
||||||
|
owner_user_id: run.owner_user_id,
|
||||||
|
status: normalize_match3d_run_status(run.status.as_str()).to_string(),
|
||||||
|
snapshot_version: run.snapshot_version,
|
||||||
|
started_at_ms: run.started_at_ms,
|
||||||
|
duration_limit_ms: run.duration_limit_ms,
|
||||||
|
server_now_ms: run.server_now_ms,
|
||||||
|
remaining_ms: run.remaining_ms,
|
||||||
|
clear_count: run.clear_count,
|
||||||
|
total_item_count: run.total_item_count,
|
||||||
|
cleared_item_count: run.cleared_item_count,
|
||||||
|
items: run
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.map(map_match3d_item_response)
|
||||||
|
.collect(),
|
||||||
|
tray_slots: run
|
||||||
|
.tray_slots
|
||||||
|
.into_iter()
|
||||||
|
.map(map_match3d_tray_slot_response)
|
||||||
|
.collect(),
|
||||||
|
failure_reason: run
|
||||||
|
.failure_reason
|
||||||
|
.map(|reason| normalize_match3d_failure_reason(reason.as_str()).to_string()),
|
||||||
|
last_confirmed_action_id: run.last_confirmed_action_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_match3d_item_response(item: Match3DItemSnapshotRecord) -> Match3DItemSnapshotResponse {
|
||||||
|
Match3DItemSnapshotResponse {
|
||||||
|
item_instance_id: item.item_instance_id,
|
||||||
|
item_type_id: item.item_type_id,
|
||||||
|
visual_key: item.visual_key,
|
||||||
|
x: item.x,
|
||||||
|
y: item.y,
|
||||||
|
radius: item.radius,
|
||||||
|
layer: item.layer,
|
||||||
|
state: normalize_match3d_item_state(item.state.as_str()).to_string(),
|
||||||
|
clickable: item.clickable,
|
||||||
|
tray_slot_index: item.tray_slot_index,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_match3d_tray_slot_response(slot: Match3DTraySlotRecord) -> Match3DTraySlotResponse {
|
||||||
|
Match3DTraySlotResponse {
|
||||||
|
slot_index: slot.slot_index,
|
||||||
|
item_instance_id: slot.item_instance_id,
|
||||||
|
item_type_id: slot.item_type_id,
|
||||||
|
visual_key: slot.visual_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_match3d_click_confirmation_response(
|
||||||
|
confirmation: Match3DClickConfirmationRecord,
|
||||||
|
) -> Match3DClickConfirmationResponse {
|
||||||
|
Match3DClickConfirmationResponse {
|
||||||
|
accepted: confirmation.accepted,
|
||||||
|
reject_reason: confirmation
|
||||||
|
.reject_reason
|
||||||
|
.map(|reason| normalize_match3d_click_reject_reason(reason.as_str()).to_string()),
|
||||||
|
entered_slot_index: confirmation.entered_slot_index,
|
||||||
|
cleared_item_instance_ids: confirmation.cleared_item_instance_ids,
|
||||||
|
run: map_match3d_run_response(confirmation.run),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
172
server-rs/crates/api-server/src/match3d/tags.rs
Normal file
172
server-rs/crates/api-server/src/match3d/tags.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
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素材"])
|
||||||
|
}
|
||||||
|
|
||||||
110
server-rs/crates/api-server/src/modules/admin.rs
Normal file
110
server-rs/crates/api-server/src/modules/admin.rs
Normal file
@@ -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)),
|
||||||
|
)
|
||||||
|
}
|
||||||
54
server-rs/crates/api-server/src/modules/assets.rs
Normal file
54
server-rs/crates/api-server/src/modules/assets.rs
Normal file
@@ -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,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
111
server-rs/crates/api-server/src/modules/auth.rs
Normal file
111
server-rs/crates/api-server/src/modules/auth.rs
Normal file
@@ -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,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
119
server-rs/crates/api-server/src/modules/big_fish.rs
Normal file
119
server-rs/crates/api-server/src/modules/big_fish.rs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
use axum::{
|
||||||
|
Router, middleware,
|
||||||
|
routing::{delete, get, post},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::require_bearer_auth,
|
||||||
|
big_fish::{
|
||||||
|
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run,
|
||||||
|
get_big_fish_session, get_big_fish_works, list_big_fish_gallery,
|
||||||
|
record_big_fish_gallery_like, record_big_fish_play, remix_big_fish_gallery_work,
|
||||||
|
start_big_fish_run, stream_big_fish_message, submit_big_fish_input,
|
||||||
|
submit_big_fish_message,
|
||||||
|
},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn router(state: AppState) -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/api/runtime/big-fish/agent/sessions",
|
||||||
|
post(create_big_fish_session).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/big-fish/agent/sessions/{session_id}",
|
||||||
|
get(get_big_fish_session).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/big-fish/agent/sessions/{session_id}/messages",
|
||||||
|
post(submit_big_fish_message).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/big-fish/agent/sessions/{session_id}/messages/stream",
|
||||||
|
post(stream_big_fish_message).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/big-fish/agent/sessions/{session_id}/actions",
|
||||||
|
post(execute_big_fish_action).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/big-fish/works",
|
||||||
|
get(get_big_fish_works).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route("/api/runtime/big-fish/gallery", get(list_big_fish_gallery))
|
||||||
|
.route(
|
||||||
|
"/api/runtime/big-fish/gallery/{session_id}/remix",
|
||||||
|
post(remix_big_fish_gallery_work).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/big-fish/gallery/{session_id}/like",
|
||||||
|
post(record_big_fish_gallery_like).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/big-fish/works/{session_id}",
|
||||||
|
delete(delete_big_fish_work).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/big-fish/sessions/{session_id}/play",
|
||||||
|
post(record_big_fish_play).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/big-fish/works/{session_id}/play",
|
||||||
|
post(record_big_fish_play).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/big-fish/sessions/{session_id}/runs",
|
||||||
|
post(start_big_fish_run).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/big-fish/runs/{run_id}",
|
||||||
|
get(get_big_fish_run).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/big-fish/runs/{run_id}/input",
|
||||||
|
post(submit_big_fish_input).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
208
server-rs/crates/api-server/src/modules/custom_world.rs
Normal file
208
server-rs/crates/api-server/src/modules/custom_world.rs
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
use axum::{
|
||||||
|
Router, middleware,
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::require_bearer_auth,
|
||||||
|
custom_world::{
|
||||||
|
create_custom_world_agent_session, delete_custom_world_agent_session,
|
||||||
|
delete_custom_world_library_profile, execute_custom_world_agent_action,
|
||||||
|
generate_custom_world_profile, get_custom_world_agent_card_detail,
|
||||||
|
get_custom_world_agent_operation, get_custom_world_agent_result_view,
|
||||||
|
get_custom_world_agent_session, get_custom_world_gallery_detail,
|
||||||
|
get_custom_world_gallery_detail_by_code, get_custom_world_library,
|
||||||
|
get_custom_world_library_detail, get_custom_world_works, list_custom_world_gallery,
|
||||||
|
publish_custom_world_library_profile, put_custom_world_library_profile,
|
||||||
|
record_custom_world_gallery_like, record_custom_world_gallery_play,
|
||||||
|
remix_custom_world_gallery_profile, stream_custom_world_agent_message,
|
||||||
|
submit_custom_world_agent_message, unpublish_custom_world_library_profile,
|
||||||
|
},
|
||||||
|
custom_world_ai::{
|
||||||
|
generate_custom_world_cover_image, generate_custom_world_entity,
|
||||||
|
generate_custom_world_opening_cg, generate_custom_world_scene_image,
|
||||||
|
generate_custom_world_scene_npc, upload_custom_world_cover_image,
|
||||||
|
},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn router(state: AppState) -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world-library",
|
||||||
|
get(get_custom_world_library).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world-library/{profile_id}",
|
||||||
|
get(get_custom_world_library_detail)
|
||||||
|
.put(put_custom_world_library_profile)
|
||||||
|
.delete(delete_custom_world_library_profile)
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world-library/{profile_id}/publish",
|
||||||
|
post(publish_custom_world_library_profile).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world-library/{profile_id}/unpublish",
|
||||||
|
post(unpublish_custom_world_library_profile).route_layer(
|
||||||
|
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world-gallery",
|
||||||
|
get(list_custom_world_gallery),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}",
|
||||||
|
get(get_custom_world_gallery_detail),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/remix",
|
||||||
|
post(remix_custom_world_gallery_profile).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/play",
|
||||||
|
post(record_custom_world_gallery_play).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/like",
|
||||||
|
post(record_custom_world_gallery_like).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world-gallery/by-code/{code}",
|
||||||
|
get(get_custom_world_gallery_detail_by_code),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world/agent/sessions",
|
||||||
|
post(create_custom_world_agent_session).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world/agent/sessions/{session_id}",
|
||||||
|
get(get_custom_world_agent_session)
|
||||||
|
.delete(delete_custom_world_agent_session)
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world/agent/sessions/{session_id}/result-view",
|
||||||
|
get(get_custom_world_agent_result_view).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world/works",
|
||||||
|
get(get_custom_world_works).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world/agent/sessions/{session_id}/cards/{card_id}",
|
||||||
|
get(get_custom_world_agent_card_detail).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world/agent/sessions/{session_id}/messages",
|
||||||
|
post(submit_custom_world_agent_message).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world/agent/sessions/{session_id}/messages/stream",
|
||||||
|
post(stream_custom_world_agent_message).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world/agent/sessions/{session_id}/actions",
|
||||||
|
post(execute_custom_world_agent_action).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world/agent/sessions/{session_id}/operations/{operation_id}",
|
||||||
|
get(get_custom_world_agent_operation).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world/profile",
|
||||||
|
post(generate_custom_world_profile).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world/entity",
|
||||||
|
post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world/scene-npc",
|
||||||
|
post(generate_custom_world_scene_npc).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world/scene-image",
|
||||||
|
post(generate_custom_world_scene_image).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world/cover-image",
|
||||||
|
post(generate_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world/cover-upload",
|
||||||
|
post(upload_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world/opening-cg",
|
||||||
|
post(generate_custom_world_opening_cg).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
7
server-rs/crates/api-server/src/modules/health.rs
Normal file
7
server-rs/crates/api-server/src/modules/health.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
use axum::{Router, routing::get};
|
||||||
|
|
||||||
|
use crate::{health::health_check, state::AppState};
|
||||||
|
|
||||||
|
pub fn router(_state: AppState) -> Router<AppState> {
|
||||||
|
Router::new().route("/healthz", get(health_check))
|
||||||
|
}
|
||||||
27
server-rs/crates/api-server/src/modules/internal.rs
Normal file
27
server-rs/crates/api-server/src/modules/internal.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
use axum::{Router, middleware, routing::get};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::{
|
||||||
|
attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie,
|
||||||
|
require_bearer_auth,
|
||||||
|
},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn router(state: AppState) -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/_internal/auth/claims",
|
||||||
|
get(inspect_auth_claims).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/_internal/auth/refresh-cookie",
|
||||||
|
get(inspect_refresh_session_cookie).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
attach_refresh_session_token,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
173
server-rs/crates/api-server/src/modules/match3d.rs
Normal file
173
server-rs/crates/api-server/src/modules/match3d.rs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
use axum::{
|
||||||
|
Router, middleware,
|
||||||
|
routing::{get, post, put},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::require_bearer_auth,
|
||||||
|
match3d::{
|
||||||
|
click_match3d_item, compile_match3d_agent_draft, create_match3d_agent_session,
|
||||||
|
delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up,
|
||||||
|
generate_match3d_background_image_for_work, generate_match3d_cover_image,
|
||||||
|
generate_match3d_item_assets_for_work, generate_match3d_work_tags,
|
||||||
|
get_match3d_agent_session, get_match3d_run, get_match3d_work_detail, get_match3d_works,
|
||||||
|
list_match3d_gallery, persist_match3d_generated_model, publish_match3d_work,
|
||||||
|
put_match3d_audio_assets, put_match3d_work, restart_match3d_run, start_match3d_run,
|
||||||
|
stop_match3d_run, stream_match3d_agent_message, submit_match3d_agent_message,
|
||||||
|
},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn router(state: AppState) -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/api/creation/match3d/sessions",
|
||||||
|
post(create_match3d_agent_session).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/match3d/sessions/{session_id}",
|
||||||
|
get(get_match3d_agent_session).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/match3d/sessions/{session_id}/messages",
|
||||||
|
post(submit_match3d_agent_message).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/match3d/sessions/{session_id}/messages/stream",
|
||||||
|
post(stream_match3d_agent_message).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/match3d/sessions/{session_id}/actions",
|
||||||
|
post(execute_match3d_agent_action).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/match3d/sessions/{session_id}/compile",
|
||||||
|
post(compile_match3d_agent_draft).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/match3d/works",
|
||||||
|
get(get_match3d_works).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/match3d/works/tags",
|
||||||
|
post(generate_match3d_work_tags).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/match3d/works/{profile_id}",
|
||||||
|
get(get_match3d_work_detail)
|
||||||
|
.patch(put_match3d_work)
|
||||||
|
.put(put_match3d_work)
|
||||||
|
.delete(delete_match3d_work)
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/match3d/works/{profile_id}/audio-assets",
|
||||||
|
put(put_match3d_audio_assets).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/match3d/works/{profile_id}/cover-image",
|
||||||
|
post(generate_match3d_cover_image).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/match3d/works/{profile_id}/background-image",
|
||||||
|
post(generate_match3d_background_image_for_work).route_layer(
|
||||||
|
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/match3d/works/{profile_id}/item-assets",
|
||||||
|
post(generate_match3d_item_assets_for_work).route_layer(
|
||||||
|
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/match3d/works/{profile_id}/generated-models",
|
||||||
|
post(persist_match3d_generated_model).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/match3d/works/{profile_id}/publish",
|
||||||
|
post(publish_match3d_work).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route("/api/runtime/match3d/gallery", get(list_match3d_gallery))
|
||||||
|
.route(
|
||||||
|
"/api/runtime/match3d/works/{profile_id}/runs",
|
||||||
|
post(start_match3d_run).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/match3d/runs/{run_id}",
|
||||||
|
get(get_match3d_run).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/match3d/runs/{run_id}/click",
|
||||||
|
post(click_match3d_item).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/match3d/runs/{run_id}/stop",
|
||||||
|
post(stop_match3d_run).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/match3d/runs/{run_id}/restart",
|
||||||
|
post(restart_match3d_run).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/match3d/runs/{run_id}/time-up",
|
||||||
|
post(finish_match3d_time_up).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
13
server-rs/crates/api-server/src/modules/mod.rs
Normal file
13
server-rs/crates/api-server/src/modules/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
pub mod admin;
|
||||||
|
pub mod assets;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod big_fish;
|
||||||
|
pub mod custom_world;
|
||||||
|
pub mod health;
|
||||||
|
pub mod internal;
|
||||||
|
pub mod match3d;
|
||||||
|
pub mod platform;
|
||||||
|
pub mod profile;
|
||||||
|
pub mod puzzle;
|
||||||
|
pub mod square_hole;
|
||||||
|
pub mod story;
|
||||||
293
server-rs/crates/api-server/src/modules/platform.rs
Normal file
293
server-rs/crates/api-server/src/modules/platform.rs
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
extract::DefaultBodyLimit,
|
||||||
|
middleware,
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ai_tasks::{
|
||||||
|
append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage,
|
||||||
|
complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage,
|
||||||
|
},
|
||||||
|
auth::require_bearer_auth,
|
||||||
|
character_animation_assets::{
|
||||||
|
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
|
||||||
|
import_character_animation_video, list_character_animation_templates,
|
||||||
|
publish_character_animation, put_role_asset_workflow, resolve_role_asset_workflow,
|
||||||
|
save_character_workflow_cache,
|
||||||
|
},
|
||||||
|
character_visual_assets::{
|
||||||
|
generate_character_visual, get_character_visual_job, publish_character_visual,
|
||||||
|
},
|
||||||
|
creation_agent_document_input::parse_creation_agent_document_input,
|
||||||
|
creation_entry_config::get_creation_entry_config_handler,
|
||||||
|
hyper3d_generation::{
|
||||||
|
get_hyper3d_downloads, get_hyper3d_task_status, submit_hyper3d_image_to_model,
|
||||||
|
submit_hyper3d_text_to_model,
|
||||||
|
},
|
||||||
|
llm::proxy_llm_chat_completions,
|
||||||
|
runtime_chat::stream_runtime_npc_chat_turn,
|
||||||
|
runtime_chat_plain::{
|
||||||
|
generate_runtime_character_chat_suggestions, generate_runtime_character_chat_summary,
|
||||||
|
stream_runtime_character_chat_reply, stream_runtime_npc_chat_dialogue,
|
||||||
|
stream_runtime_npc_recruit_dialogue,
|
||||||
|
},
|
||||||
|
runtime_save::{delete_runtime_snapshot, get_runtime_snapshot, put_runtime_snapshot},
|
||||||
|
runtime_settings::{get_runtime_settings, put_runtime_settings},
|
||||||
|
state::AppState,
|
||||||
|
volcengine_speech::{
|
||||||
|
get_volcengine_speech_config, stream_volcengine_asr, stream_volcengine_tts_bidirection,
|
||||||
|
stream_volcengine_tts_sse,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024;
|
||||||
|
|
||||||
|
pub fn router(state: AppState) -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/api/llm/chat/completions",
|
||||||
|
post(proxy_llm_chat_completions).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/speech/volcengine/config",
|
||||||
|
get(get_volcengine_speech_config).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/speech/volcengine/asr/stream",
|
||||||
|
get(stream_volcengine_asr).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/speech/volcengine/tts/bidirection",
|
||||||
|
get(stream_volcengine_tts_bidirection).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/speech/volcengine/tts/sse",
|
||||||
|
post(stream_volcengine_tts_sse).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/chat/character/suggestions",
|
||||||
|
post(generate_runtime_character_chat_suggestions).route_layer(
|
||||||
|
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/chat/character/summary",
|
||||||
|
post(generate_runtime_character_chat_summary).route_layer(
|
||||||
|
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/chat/character/reply/stream",
|
||||||
|
post(stream_runtime_character_chat_reply).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/chat/npc/dialogue/stream",
|
||||||
|
post(stream_runtime_npc_chat_dialogue).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/chat/npc/turn/stream",
|
||||||
|
post(stream_runtime_npc_chat_turn).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/chat/npc/recruit/stream",
|
||||||
|
post(stream_runtime_npc_recruit_dialogue).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/creation-agent/document-inputs/parse",
|
||||||
|
post(parse_creation_agent_document_input).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/ai/tasks",
|
||||||
|
post(create_ai_task).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/ai/tasks/{task_id}/start",
|
||||||
|
post(start_ai_task).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/ai/tasks/{task_id}/stages/{stage_kind}/start",
|
||||||
|
post(start_ai_task_stage).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/ai/tasks/{task_id}/chunks",
|
||||||
|
post(append_ai_text_chunk).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/ai/tasks/{task_id}/stages/{stage_kind}/complete",
|
||||||
|
post(complete_ai_stage).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/ai/tasks/{task_id}/references",
|
||||||
|
post(attach_ai_result_reference).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/ai/tasks/{task_id}/complete",
|
||||||
|
post(complete_ai_task).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/ai/tasks/{task_id}/fail",
|
||||||
|
post(fail_ai_task).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/ai/tasks/{task_id}/cancel",
|
||||||
|
post(cancel_ai_task).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/assets/character-visual/generate",
|
||||||
|
post(generate_character_visual),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/assets/character-visual/jobs/{task_id}",
|
||||||
|
get(get_character_visual_job),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/assets/character-visual/publish",
|
||||||
|
post(publish_character_visual),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/assets/character-animation/generate",
|
||||||
|
post(generate_character_animation),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/assets/character-animation/jobs/{task_id}",
|
||||||
|
get(get_character_animation_job),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/assets/character-animation/publish",
|
||||||
|
post(publish_character_animation),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/assets/character-animation/import-video",
|
||||||
|
post(import_character_animation_video),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/assets/character-animation/templates",
|
||||||
|
get(list_character_animation_templates),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/assets/character-workflow-cache",
|
||||||
|
post(save_character_workflow_cache),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/assets/character-workflow-cache/{character_id}",
|
||||||
|
get(get_character_workflow_cache),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/custom-world/asset-studio/role/{character_id}/workflow",
|
||||||
|
post(resolve_role_asset_workflow).put(put_role_asset_workflow),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/assets/hyper3d/text-to-model",
|
||||||
|
post(submit_hyper3d_text_to_model).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/assets/hyper3d/image-to-model",
|
||||||
|
post(submit_hyper3d_image_to_model)
|
||||||
|
.layer(DefaultBodyLimit::max(
|
||||||
|
HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES,
|
||||||
|
))
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/assets/hyper3d/status",
|
||||||
|
post(get_hyper3d_task_status).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/assets/hyper3d/download",
|
||||||
|
post(get_hyper3d_downloads).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation-entry/config",
|
||||||
|
get(get_creation_entry_config_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/settings",
|
||||||
|
get(get_runtime_settings)
|
||||||
|
.put(put_runtime_settings)
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/save/snapshot",
|
||||||
|
get(get_runtime_snapshot)
|
||||||
|
.put(put_runtime_snapshot)
|
||||||
|
.delete(delete_runtime_snapshot)
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
141
server-rs/crates/api-server/src/modules/profile.rs
Normal file
141
server-rs/crates/api-server/src/modules/profile.rs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
use axum::{
|
||||||
|
Router, middleware,
|
||||||
|
routing::{get, patch, post},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::require_bearer_auth,
|
||||||
|
profile_identity::update_profile_identity,
|
||||||
|
runtime_browse_history::{
|
||||||
|
delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history,
|
||||||
|
},
|
||||||
|
runtime_profile::{
|
||||||
|
claim_profile_task_reward, create_profile_recharge_order, get_profile_analytics_metric,
|
||||||
|
get_profile_dashboard, get_profile_play_stats, get_profile_recharge_center,
|
||||||
|
get_profile_referral_invite_center, get_profile_task_center, get_profile_wallet_ledger,
|
||||||
|
redeem_profile_referral_invite_code, redeem_profile_reward_code, submit_profile_feedback,
|
||||||
|
},
|
||||||
|
runtime_save::{list_profile_save_archives, resume_profile_save_archive},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn router(state: AppState) -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/api/profile/me",
|
||||||
|
patch(update_profile_identity).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/profile/browse-history",
|
||||||
|
get(get_runtime_browse_history)
|
||||||
|
.post(post_runtime_browse_history)
|
||||||
|
.delete(delete_runtime_browse_history)
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/profile/dashboard",
|
||||||
|
get(get_profile_dashboard).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/profile/wallet-ledger",
|
||||||
|
get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/profile/recharge-center",
|
||||||
|
get(get_profile_recharge_center).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/profile/recharge/orders",
|
||||||
|
post(create_profile_recharge_order).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/profile/feedback",
|
||||||
|
post(submit_profile_feedback)
|
||||||
|
.layer(axum::extract::DefaultBodyLimit::max(6 * 1024 * 1024))
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/profile/referrals/invite-center",
|
||||||
|
get(get_profile_referral_invite_center).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/profile/referrals/redeem-code",
|
||||||
|
post(redeem_profile_referral_invite_code).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/profile/redeem-codes/redeem",
|
||||||
|
post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/profile/analytics/metric",
|
||||||
|
get(get_profile_analytics_metric).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/profile/tasks",
|
||||||
|
get(get_profile_task_center).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/profile/tasks/{task_id}/claim",
|
||||||
|
post(claim_profile_task_reward).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/profile/save-archives",
|
||||||
|
get(list_profile_save_archives).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/profile/save-archives/{world_key}",
|
||||||
|
post(resume_profile_save_archive).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/profile/play-stats",
|
||||||
|
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
184
server-rs/crates/api-server/src/modules/puzzle.rs
Normal file
184
server-rs/crates/api-server/src/modules/puzzle.rs
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
extract::DefaultBodyLimit,
|
||||||
|
middleware,
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::require_bearer_auth,
|
||||||
|
puzzle::{
|
||||||
|
advance_puzzle_next_level, claim_puzzle_work_point_incentive, create_puzzle_agent_session,
|
||||||
|
delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action,
|
||||||
|
generate_puzzle_onboarding_work, get_puzzle_agent_session, get_puzzle_gallery_detail,
|
||||||
|
get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery,
|
||||||
|
put_puzzle_work, record_puzzle_gallery_like, remix_puzzle_gallery_work,
|
||||||
|
save_puzzle_onboarding_work, start_puzzle_run, stream_puzzle_agent_message,
|
||||||
|
submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces,
|
||||||
|
update_puzzle_run_pause, use_puzzle_runtime_prop,
|
||||||
|
},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
|
||||||
|
|
||||||
|
pub fn router(state: AppState) -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/agent/sessions",
|
||||||
|
post(create_puzzle_agent_session)
|
||||||
|
// 中文注释:拼图表单会携带单张参考图 Data URL,需只给该写入入口放宽 body 上限。
|
||||||
|
.layer(DefaultBodyLimit::max(
|
||||||
|
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
|
||||||
|
))
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/agent/sessions/{session_id}",
|
||||||
|
get(get_puzzle_agent_session).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/agent/sessions/{session_id}/messages",
|
||||||
|
post(submit_puzzle_agent_message).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/agent/sessions/{session_id}/messages/stream",
|
||||||
|
post(stream_puzzle_agent_message).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/agent/sessions/{session_id}/actions",
|
||||||
|
post(execute_puzzle_agent_action)
|
||||||
|
// 中文注释:生成草稿/重新出图会复用 referenceImageSrc,避免默认 2MB JSON limit 拦截。
|
||||||
|
.layer(DefaultBodyLimit::max(
|
||||||
|
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
|
||||||
|
))
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/onboarding/generate",
|
||||||
|
post(generate_puzzle_onboarding_work).layer(DefaultBodyLimit::max(
|
||||||
|
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/onboarding/save",
|
||||||
|
post(save_puzzle_onboarding_work).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/works",
|
||||||
|
get(get_puzzle_works).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/works/{profile_id}",
|
||||||
|
get(get_puzzle_work_detail)
|
||||||
|
.put(put_puzzle_work)
|
||||||
|
.delete(delete_puzzle_work)
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/works/{profile_id}/point-incentive/claim",
|
||||||
|
post(claim_puzzle_work_point_incentive).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route("/api/runtime/puzzle/gallery", get(list_puzzle_gallery))
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/gallery/{profile_id}",
|
||||||
|
get(get_puzzle_gallery_detail),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/gallery/{profile_id}/remix",
|
||||||
|
post(remix_puzzle_gallery_work).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/gallery/{profile_id}/like",
|
||||||
|
post(record_puzzle_gallery_like).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/runs",
|
||||||
|
post(start_puzzle_run).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/runs/{run_id}",
|
||||||
|
get(get_puzzle_run).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/runs/{run_id}/swap",
|
||||||
|
post(swap_puzzle_pieces).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/runs/{run_id}/drag",
|
||||||
|
post(drag_puzzle_piece_or_group).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/runs/{run_id}/next-level",
|
||||||
|
post(advance_puzzle_next_level).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/runs/{run_id}/pause",
|
||||||
|
post(update_puzzle_run_pause).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/runs/{run_id}/props",
|
||||||
|
post(use_puzzle_runtime_prop).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/runs/{run_id}/leaderboard",
|
||||||
|
post(submit_puzzle_leaderboard).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
142
server-rs/crates/api-server/src/modules/square_hole.rs
Normal file
142
server-rs/crates/api-server/src/modules/square_hole.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
use axum::{
|
||||||
|
Router, middleware,
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::require_bearer_auth,
|
||||||
|
square_hole::{
|
||||||
|
compile_square_hole_agent_draft, create_square_hole_agent_session, delete_square_hole_work,
|
||||||
|
drop_square_hole_shape, execute_square_hole_agent_action, finish_square_hole_time_up,
|
||||||
|
get_square_hole_agent_session, get_square_hole_run, get_square_hole_work_detail,
|
||||||
|
get_square_hole_works, list_square_hole_gallery, publish_square_hole_work,
|
||||||
|
put_square_hole_work, regenerate_square_hole_work_image, restart_square_hole_run,
|
||||||
|
start_square_hole_run, stop_square_hole_run, stream_square_hole_agent_message,
|
||||||
|
submit_square_hole_agent_message,
|
||||||
|
},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn router(state: AppState) -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/api/creation/square-hole/sessions",
|
||||||
|
post(create_square_hole_agent_session).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/square-hole/sessions/{session_id}",
|
||||||
|
get(get_square_hole_agent_session).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/square-hole/sessions/{session_id}/messages",
|
||||||
|
post(submit_square_hole_agent_message).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/square-hole/sessions/{session_id}/messages/stream",
|
||||||
|
post(stream_square_hole_agent_message).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/square-hole/sessions/{session_id}/actions",
|
||||||
|
post(execute_square_hole_agent_action).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/square-hole/sessions/{session_id}/compile",
|
||||||
|
post(compile_square_hole_agent_draft).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/square-hole/works",
|
||||||
|
get(get_square_hole_works).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/square-hole/works/{profile_id}",
|
||||||
|
get(get_square_hole_work_detail)
|
||||||
|
.patch(put_square_hole_work)
|
||||||
|
.put(put_square_hole_work)
|
||||||
|
.delete(delete_square_hole_work)
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/square-hole/works/{profile_id}/publish",
|
||||||
|
post(publish_square_hole_work).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/square-hole/works/{profile_id}/images/regenerate",
|
||||||
|
post(regenerate_square_hole_work_image).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/square-hole/gallery",
|
||||||
|
get(list_square_hole_gallery),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/square-hole/works/{profile_id}/runs",
|
||||||
|
post(start_square_hole_run).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/square-hole/runs/{run_id}",
|
||||||
|
get(get_square_hole_run).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/square-hole/runs/{run_id}/drop",
|
||||||
|
post(drop_square_hole_shape).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/square-hole/runs/{run_id}/stop",
|
||||||
|
post(stop_square_hole_run).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/square-hole/runs/{run_id}/restart",
|
||||||
|
post(restart_square_hole_run).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/square-hole/runs/{run_id}/time-up",
|
||||||
|
post(finish_square_hole_time_up).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
154
server-rs/crates/api-server/src/modules/story.rs
Normal file
154
server-rs/crates/api-server/src/modules/story.rs
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
extract::DefaultBodyLimit,
|
||||||
|
middleware,
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::require_bearer_auth,
|
||||||
|
creative_agent::{
|
||||||
|
cancel_creative_agent_session, confirm_creative_puzzle_template,
|
||||||
|
create_creative_agent_session, get_creative_agent_session, stream_creative_agent_message,
|
||||||
|
stream_creative_draft_edit,
|
||||||
|
},
|
||||||
|
state::AppState,
|
||||||
|
story_battles::{
|
||||||
|
create_story_battle, create_story_npc_battle, get_story_battle_state, resolve_story_battle,
|
||||||
|
},
|
||||||
|
story_sessions::{
|
||||||
|
begin_story_runtime_session, begin_story_session, continue_story,
|
||||||
|
get_story_runtime_projection, get_story_session_state, resolve_story_runtime_action,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
|
||||||
|
|
||||||
|
pub fn router(state: AppState) -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/api/story/sessions",
|
||||||
|
post(begin_story_session).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/story/sessions/runtime",
|
||||||
|
post(begin_story_runtime_session).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/story/sessions/{story_session_id}/state",
|
||||||
|
get(get_story_session_state).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/story/sessions/{story_session_id}/runtime-projection",
|
||||||
|
get(get_story_runtime_projection).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/story/sessions/{story_session_id}/actions/resolve",
|
||||||
|
post(resolve_story_runtime_action).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/story/sessions/continue",
|
||||||
|
post(continue_story).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/story/battles",
|
||||||
|
post(create_story_battle).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/story/battles/{battle_state_id}",
|
||||||
|
get(get_story_battle_state).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/story/npc/battle",
|
||||||
|
post(create_story_npc_battle).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/story/battles/resolve",
|
||||||
|
post(resolve_story_battle).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/creative-agent/sessions",
|
||||||
|
post(create_creative_agent_session)
|
||||||
|
// 中文注释:创意 Agent 首轮允许携带参考图 URL/Data URL,沿用拼图参考图入口上限。
|
||||||
|
.layer(DefaultBodyLimit::max(
|
||||||
|
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
|
||||||
|
))
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/creative-agent/sessions/{session_id}",
|
||||||
|
get(get_creative_agent_session).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/creative-agent/sessions/{session_id}/messages/stream",
|
||||||
|
post(stream_creative_agent_message)
|
||||||
|
// 中文注释:message stream 同样可能带图片素材,避免默认 JSON limit 过早拒绝。
|
||||||
|
.layer(DefaultBodyLimit::max(
|
||||||
|
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
|
||||||
|
))
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/creative-agent/sessions/{session_id}/confirm-template",
|
||||||
|
post(confirm_creative_puzzle_template).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/creative-agent/sessions/{session_id}/draft-edits/stream",
|
||||||
|
post(stream_creative_draft_edit)
|
||||||
|
// 中文注释:草稿编辑会携带当前 puzzle draft JSON,保持和拼图草稿入口一致的 body 上限。
|
||||||
|
.layer(DefaultBodyLimit::max(
|
||||||
|
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
|
||||||
|
))
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/creative-agent/sessions/{session_id}/cancel",
|
||||||
|
post(cancel_creative_agent_session)
|
||||||
|
.route_layer(middleware::from_fn_with_state(state, require_bearer_auth)),
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
529
server-rs/crates/api-server/src/puzzle/mappers.rs
Normal file
529
server-rs/crates/api-server/src/puzzle/mappers.rs
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_agent_session_response(
|
||||||
|
session: PuzzleAgentSessionRecord,
|
||||||
|
) -> PuzzleAgentSessionSnapshotResponse {
|
||||||
|
PuzzleAgentSessionSnapshotResponse {
|
||||||
|
session_id: session.session_id,
|
||||||
|
seed_text: session.seed_text,
|
||||||
|
current_turn: session.current_turn,
|
||||||
|
progress_percent: session.progress_percent,
|
||||||
|
stage: session.stage,
|
||||||
|
anchor_pack: map_puzzle_anchor_pack_response(session.anchor_pack),
|
||||||
|
draft: session.draft.map(map_puzzle_result_draft_response),
|
||||||
|
messages: session
|
||||||
|
.messages
|
||||||
|
.into_iter()
|
||||||
|
.map(map_puzzle_agent_message_response)
|
||||||
|
.collect(),
|
||||||
|
last_assistant_reply: session.last_assistant_reply,
|
||||||
|
published_profile_id: session.published_profile_id,
|
||||||
|
suggested_actions: session
|
||||||
|
.suggested_actions
|
||||||
|
.into_iter()
|
||||||
|
.map(map_puzzle_suggested_action_response)
|
||||||
|
.collect(),
|
||||||
|
result_preview: session
|
||||||
|
.result_preview
|
||||||
|
.map(map_puzzle_result_preview_response),
|
||||||
|
updated_at: session.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_anchor_pack_response(
|
||||||
|
anchor_pack: PuzzleAnchorPackRecord,
|
||||||
|
) -> PuzzleAnchorPackResponse {
|
||||||
|
PuzzleAnchorPackResponse {
|
||||||
|
theme_promise: map_puzzle_anchor_item_response(anchor_pack.theme_promise),
|
||||||
|
visual_subject: map_puzzle_anchor_item_response(anchor_pack.visual_subject),
|
||||||
|
visual_mood: map_puzzle_anchor_item_response(anchor_pack.visual_mood),
|
||||||
|
composition_hooks: map_puzzle_anchor_item_response(anchor_pack.composition_hooks),
|
||||||
|
tags_and_forbidden: map_puzzle_anchor_item_response(anchor_pack.tags_and_forbidden),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_anchor_item_response(anchor: PuzzleAnchorItemRecord) -> PuzzleAnchorItemResponse {
|
||||||
|
PuzzleAnchorItemResponse {
|
||||||
|
key: anchor.key,
|
||||||
|
label: anchor.label,
|
||||||
|
value: anchor.value,
|
||||||
|
status: anchor.status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_result_draft_response(draft: PuzzleResultDraftRecord) -> PuzzleResultDraftResponse {
|
||||||
|
PuzzleResultDraftResponse {
|
||||||
|
work_title: draft.work_title,
|
||||||
|
work_description: draft.work_description,
|
||||||
|
level_name: draft.level_name,
|
||||||
|
summary: draft.summary,
|
||||||
|
theme_tags: draft.theme_tags,
|
||||||
|
forbidden_directives: draft.forbidden_directives,
|
||||||
|
creator_intent: draft.creator_intent.map(map_puzzle_creator_intent_response),
|
||||||
|
anchor_pack: map_puzzle_anchor_pack_response(draft.anchor_pack),
|
||||||
|
candidates: draft
|
||||||
|
.candidates
|
||||||
|
.into_iter()
|
||||||
|
.map(map_puzzle_generated_image_candidate_response)
|
||||||
|
.collect(),
|
||||||
|
selected_candidate_id: draft.selected_candidate_id,
|
||||||
|
cover_image_src: draft.cover_image_src,
|
||||||
|
cover_asset_id: draft.cover_asset_id,
|
||||||
|
generation_status: draft.generation_status,
|
||||||
|
levels: draft
|
||||||
|
.levels
|
||||||
|
.into_iter()
|
||||||
|
.map(map_puzzle_draft_level_response)
|
||||||
|
.collect(),
|
||||||
|
form_draft: draft.form_draft.map(map_puzzle_form_draft_response),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_form_draft_response(draft: PuzzleFormDraftRecord) -> PuzzleFormDraftResponse {
|
||||||
|
PuzzleFormDraftResponse {
|
||||||
|
work_title: draft.work_title,
|
||||||
|
work_description: draft.work_description,
|
||||||
|
picture_description: draft.picture_description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraftLevelResponse {
|
||||||
|
PuzzleDraftLevelResponse {
|
||||||
|
level_id: level.level_id,
|
||||||
|
level_name: level.level_name,
|
||||||
|
picture_description: level.picture_description,
|
||||||
|
picture_reference: level.picture_reference,
|
||||||
|
ui_background_prompt: level.ui_background_prompt,
|
||||||
|
ui_background_image_src: level.ui_background_image_src,
|
||||||
|
ui_background_image_object_key: level.ui_background_image_object_key,
|
||||||
|
background_music: level
|
||||||
|
.background_music
|
||||||
|
.map(map_puzzle_audio_asset_record_response),
|
||||||
|
candidates: level
|
||||||
|
.candidates
|
||||||
|
.into_iter()
|
||||||
|
.map(map_puzzle_generated_image_candidate_response)
|
||||||
|
.collect(),
|
||||||
|
selected_candidate_id: level.selected_candidate_id,
|
||||||
|
cover_image_src: level.cover_image_src,
|
||||||
|
cover_asset_id: level.cover_asset_id,
|
||||||
|
generation_status: level.generation_status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_audio_asset_record_response(asset: PuzzleAudioAssetRecord) -> CreationAudioAsset {
|
||||||
|
CreationAudioAsset {
|
||||||
|
task_id: asset.task_id,
|
||||||
|
provider: asset.provider,
|
||||||
|
asset_object_id: asset.asset_object_id,
|
||||||
|
asset_kind: asset.asset_kind,
|
||||||
|
audio_src: asset.audio_src,
|
||||||
|
prompt: asset.prompt,
|
||||||
|
title: asset.title,
|
||||||
|
updated_at: asset.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_audio_asset_domain_record(
|
||||||
|
asset: module_puzzle::PuzzleAudioAsset,
|
||||||
|
) -> PuzzleAudioAssetRecord {
|
||||||
|
PuzzleAudioAssetRecord {
|
||||||
|
task_id: asset.task_id,
|
||||||
|
provider: asset.provider,
|
||||||
|
asset_object_id: asset.asset_object_id,
|
||||||
|
asset_kind: asset.asset_kind,
|
||||||
|
audio_src: asset.audio_src,
|
||||||
|
prompt: asset.prompt,
|
||||||
|
title: asset.title,
|
||||||
|
updated_at: asset.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn puzzle_audio_asset_response_module_json(asset: &Option<CreationAudioAsset>) -> Value {
|
||||||
|
asset
|
||||||
|
.as_ref()
|
||||||
|
.map(|asset| {
|
||||||
|
json!({
|
||||||
|
"task_id": asset.task_id,
|
||||||
|
"provider": asset.provider,
|
||||||
|
"asset_object_id": asset.asset_object_id,
|
||||||
|
"asset_kind": asset.asset_kind,
|
||||||
|
"audio_src": asset.audio_src,
|
||||||
|
"prompt": asset.prompt,
|
||||||
|
"title": asset.title,
|
||||||
|
"updated_at": asset.updated_at,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn puzzle_audio_asset_record_module_json(asset: &Option<PuzzleAudioAssetRecord>) -> Value {
|
||||||
|
asset
|
||||||
|
.as_ref()
|
||||||
|
.map(|asset| {
|
||||||
|
json!({
|
||||||
|
"task_id": asset.task_id,
|
||||||
|
"provider": asset.provider,
|
||||||
|
"asset_object_id": asset.asset_object_id,
|
||||||
|
"asset_kind": asset.asset_kind,
|
||||||
|
"audio_src": asset.audio_src,
|
||||||
|
"prompt": asset.prompt,
|
||||||
|
"title": asset.title,
|
||||||
|
"updated_at": asset.updated_at,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_creator_intent_response(
|
||||||
|
intent: PuzzleCreatorIntentRecord,
|
||||||
|
) -> PuzzleCreatorIntentResponse {
|
||||||
|
PuzzleCreatorIntentResponse {
|
||||||
|
source_mode: intent.source_mode,
|
||||||
|
raw_messages_summary: intent.raw_messages_summary,
|
||||||
|
theme_promise: intent.theme_promise,
|
||||||
|
visual_subject: intent.visual_subject,
|
||||||
|
visual_mood: intent.visual_mood,
|
||||||
|
composition_hooks: intent.composition_hooks,
|
||||||
|
theme_tags: intent.theme_tags,
|
||||||
|
forbidden_directives: intent.forbidden_directives,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_generated_image_candidate_response(
|
||||||
|
candidate: PuzzleGeneratedImageCandidateRecord,
|
||||||
|
) -> PuzzleGeneratedImageCandidateResponse {
|
||||||
|
PuzzleGeneratedImageCandidateResponse {
|
||||||
|
candidate_id: candidate.candidate_id,
|
||||||
|
image_src: candidate.image_src,
|
||||||
|
asset_id: candidate.asset_id,
|
||||||
|
prompt: candidate.prompt,
|
||||||
|
actual_prompt: candidate.actual_prompt,
|
||||||
|
source_type: candidate.source_type,
|
||||||
|
selected: candidate.selected,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_agent_message_response(
|
||||||
|
message: PuzzleAgentMessageRecord,
|
||||||
|
) -> PuzzleAgentMessageResponse {
|
||||||
|
PuzzleAgentMessageResponse {
|
||||||
|
id: message.message_id,
|
||||||
|
role: message.role,
|
||||||
|
kind: message.kind,
|
||||||
|
text: message.text,
|
||||||
|
created_at: message.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_suggested_action_response(
|
||||||
|
action: PuzzleAgentSuggestedActionRecord,
|
||||||
|
) -> PuzzleAgentSuggestedActionResponse {
|
||||||
|
PuzzleAgentSuggestedActionResponse {
|
||||||
|
id: action.action_id,
|
||||||
|
action_type: action.action_type,
|
||||||
|
label: action.label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_result_preview_response(
|
||||||
|
preview: PuzzleResultPreviewRecord,
|
||||||
|
) -> PuzzleResultPreviewEnvelopeResponse {
|
||||||
|
PuzzleResultPreviewEnvelopeResponse {
|
||||||
|
draft: map_puzzle_result_draft_response(preview.draft),
|
||||||
|
blockers: preview
|
||||||
|
.blockers
|
||||||
|
.into_iter()
|
||||||
|
.map(map_puzzle_result_preview_blocker_response)
|
||||||
|
.collect(),
|
||||||
|
quality_findings: preview
|
||||||
|
.quality_findings
|
||||||
|
.into_iter()
|
||||||
|
.map(map_puzzle_result_preview_finding_response)
|
||||||
|
.collect(),
|
||||||
|
publish_ready: preview.publish_ready,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_result_preview_blocker_response(
|
||||||
|
blocker: PuzzleResultPreviewBlockerRecord,
|
||||||
|
) -> PuzzleResultPreviewBlockerResponse {
|
||||||
|
PuzzleResultPreviewBlockerResponse {
|
||||||
|
id: blocker.blocker_id,
|
||||||
|
code: blocker.code,
|
||||||
|
message: blocker.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_result_preview_finding_response(
|
||||||
|
finding: PuzzleResultPreviewFindingRecord,
|
||||||
|
) -> PuzzleResultPreviewFindingResponse {
|
||||||
|
PuzzleResultPreviewFindingResponse {
|
||||||
|
id: finding.finding_id,
|
||||||
|
severity: finding.severity,
|
||||||
|
code: finding.code,
|
||||||
|
message: finding.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_work_summary_response(
|
||||||
|
state: &AppState,
|
||||||
|
item: PuzzleWorkProfileRecord,
|
||||||
|
) -> PuzzleWorkSummaryResponse {
|
||||||
|
let author = resolve_work_author_by_user_id(
|
||||||
|
state,
|
||||||
|
&item.owner_user_id,
|
||||||
|
Some(&item.author_display_name),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
PuzzleWorkSummaryResponse {
|
||||||
|
work_id: item.work_id,
|
||||||
|
profile_id: item.profile_id,
|
||||||
|
owner_user_id: item.owner_user_id,
|
||||||
|
source_session_id: item.source_session_id,
|
||||||
|
author_display_name: author.display_name,
|
||||||
|
work_title: item.work_title,
|
||||||
|
work_description: item.work_description,
|
||||||
|
level_name: item.level_name,
|
||||||
|
summary: item.summary,
|
||||||
|
theme_tags: item.theme_tags,
|
||||||
|
cover_image_src: item.cover_image_src,
|
||||||
|
cover_asset_id: item.cover_asset_id,
|
||||||
|
publication_status: item.publication_status,
|
||||||
|
updated_at: item.updated_at,
|
||||||
|
published_at: item.published_at,
|
||||||
|
play_count: item.play_count,
|
||||||
|
remix_count: item.remix_count,
|
||||||
|
like_count: item.like_count,
|
||||||
|
recent_play_count_7d: item.recent_play_count_7d,
|
||||||
|
point_incentive_total_half_points: item.point_incentive_total_half_points,
|
||||||
|
point_incentive_claimed_points: item.point_incentive_claimed_points,
|
||||||
|
point_incentive_total_points: item.point_incentive_total_half_points as f64 / 2.0,
|
||||||
|
point_incentive_claimable_points: item
|
||||||
|
.point_incentive_total_half_points
|
||||||
|
.saturating_div(2)
|
||||||
|
.saturating_sub(item.point_incentive_claimed_points),
|
||||||
|
publish_ready: item.publish_ready,
|
||||||
|
levels: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_work_profile_response(
|
||||||
|
state: &AppState,
|
||||||
|
item: PuzzleWorkProfileRecord,
|
||||||
|
) -> PuzzleWorkProfileResponse {
|
||||||
|
let mut summary = map_puzzle_work_summary_response(state, item.clone());
|
||||||
|
summary.levels = item
|
||||||
|
.levels
|
||||||
|
.into_iter()
|
||||||
|
.map(map_puzzle_draft_level_response)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
PuzzleWorkProfileResponse {
|
||||||
|
summary,
|
||||||
|
anchor_pack: map_puzzle_anchor_pack_response(item.anchor_pack),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
|
||||||
|
PuzzleRunSnapshotResponse {
|
||||||
|
run_id: run.run_id,
|
||||||
|
entry_profile_id: run.entry_profile_id,
|
||||||
|
cleared_level_count: run.cleared_level_count,
|
||||||
|
current_level_index: run.current_level_index,
|
||||||
|
current_grid_size: run.current_grid_size,
|
||||||
|
played_profile_ids: run.played_profile_ids,
|
||||||
|
previous_level_tags: run.previous_level_tags,
|
||||||
|
current_level: run.current_level.map(map_puzzle_runtime_level_response),
|
||||||
|
recommended_next_profile_id: run.recommended_next_profile_id,
|
||||||
|
next_level_mode: run.next_level_mode,
|
||||||
|
next_level_profile_id: run.next_level_profile_id,
|
||||||
|
next_level_id: run.next_level_id,
|
||||||
|
recommended_next_works: run
|
||||||
|
.recommended_next_works
|
||||||
|
.into_iter()
|
||||||
|
.map(map_puzzle_recommended_next_work_response)
|
||||||
|
.collect(),
|
||||||
|
leaderboard_entries: run
|
||||||
|
.leaderboard_entries
|
||||||
|
.into_iter()
|
||||||
|
.map(map_puzzle_leaderboard_entry_response)
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_recommended_next_work_response(
|
||||||
|
item: PuzzleRecommendedNextWorkRecord,
|
||||||
|
) -> PuzzleRecommendedNextWorkResponse {
|
||||||
|
PuzzleRecommendedNextWorkResponse {
|
||||||
|
profile_id: item.profile_id,
|
||||||
|
level_name: item.level_name,
|
||||||
|
author_display_name: item.author_display_name,
|
||||||
|
theme_tags: item.theme_tags,
|
||||||
|
cover_image_src: item.cover_image_src,
|
||||||
|
similarity_score: item.similarity_score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn enrich_puzzle_run_author_name(
|
||||||
|
state: &AppState,
|
||||||
|
mut run: PuzzleRunRecord,
|
||||||
|
) -> PuzzleRunRecord {
|
||||||
|
if let Some(level) = run.current_level.as_mut() {
|
||||||
|
if let Ok(profile) = state
|
||||||
|
.spacetime_client()
|
||||||
|
.get_puzzle_gallery_detail(level.profile_id.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
level.author_display_name = resolve_work_author_by_user_id(
|
||||||
|
state,
|
||||||
|
&profile.owner_user_id,
|
||||||
|
Some(&profile.author_display_name),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.display_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_runtime_level_response(
|
||||||
|
level: spacetime_client::PuzzleRuntimeLevelRecord,
|
||||||
|
) -> PuzzleRuntimeLevelSnapshotResponse {
|
||||||
|
let timer_defaults =
|
||||||
|
build_puzzle_runtime_timer_response_defaults(level.level_index, level.grid_size);
|
||||||
|
let time_limit_ms = if level.time_limit_ms == 0 {
|
||||||
|
timer_defaults.time_limit_ms
|
||||||
|
} else {
|
||||||
|
level.time_limit_ms
|
||||||
|
};
|
||||||
|
let remaining_ms =
|
||||||
|
if level.remaining_ms == 0 && level.status == PuzzleRuntimeLevelStatus::Playing.as_str() {
|
||||||
|
time_limit_ms
|
||||||
|
} else {
|
||||||
|
level.remaining_ms.min(time_limit_ms)
|
||||||
|
};
|
||||||
|
PuzzleRuntimeLevelSnapshotResponse {
|
||||||
|
run_id: level.run_id,
|
||||||
|
level_index: level.level_index,
|
||||||
|
level_id: level.level_id,
|
||||||
|
grid_size: level.grid_size,
|
||||||
|
profile_id: level.profile_id,
|
||||||
|
level_name: level.level_name,
|
||||||
|
author_display_name: level.author_display_name,
|
||||||
|
theme_tags: level.theme_tags,
|
||||||
|
cover_image_src: level.cover_image_src,
|
||||||
|
ui_background_image_src: level.ui_background_image_src,
|
||||||
|
background_music: level
|
||||||
|
.background_music
|
||||||
|
.map(map_puzzle_audio_asset_record_response),
|
||||||
|
board: map_puzzle_board_response(level.board),
|
||||||
|
status: level.status,
|
||||||
|
started_at_ms: level.started_at_ms,
|
||||||
|
cleared_at_ms: level.cleared_at_ms,
|
||||||
|
elapsed_ms: level.elapsed_ms,
|
||||||
|
time_limit_ms,
|
||||||
|
remaining_ms,
|
||||||
|
paused_accumulated_ms: level.paused_accumulated_ms,
|
||||||
|
pause_started_at_ms: level.pause_started_at_ms,
|
||||||
|
freeze_accumulated_ms: level.freeze_accumulated_ms,
|
||||||
|
freeze_started_at_ms: level.freeze_started_at_ms,
|
||||||
|
freeze_until_ms: level.freeze_until_ms,
|
||||||
|
leaderboard_entries: level
|
||||||
|
.leaderboard_entries
|
||||||
|
.into_iter()
|
||||||
|
.map(map_puzzle_leaderboard_entry_response)
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PuzzleRuntimeTimerResponseDefaults {
|
||||||
|
time_limit_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_puzzle_runtime_timer_response_defaults(
|
||||||
|
level_index: u32,
|
||||||
|
grid_size: u32,
|
||||||
|
) -> PuzzleRuntimeTimerResponseDefaults {
|
||||||
|
let time_limit_ms = if level_index > 0 {
|
||||||
|
module_puzzle::resolve_puzzle_level_time_limit_ms_by_index(level_index)
|
||||||
|
} else {
|
||||||
|
module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size)
|
||||||
|
};
|
||||||
|
PuzzleRuntimeTimerResponseDefaults { time_limit_ms }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_leaderboard_entry_response(
|
||||||
|
entry: PuzzleLeaderboardEntryRecord,
|
||||||
|
) -> PuzzleLeaderboardEntryResponse {
|
||||||
|
PuzzleLeaderboardEntryResponse {
|
||||||
|
rank: entry.rank,
|
||||||
|
nickname: entry.nickname,
|
||||||
|
elapsed_ms: entry.elapsed_ms,
|
||||||
|
visible_tags: entry.visible_tags,
|
||||||
|
is_current_player: entry.is_current_player,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_board_response(
|
||||||
|
board: spacetime_client::PuzzleBoardRecord,
|
||||||
|
) -> PuzzleBoardSnapshotResponse {
|
||||||
|
PuzzleBoardSnapshotResponse {
|
||||||
|
rows: board.rows,
|
||||||
|
cols: board.cols,
|
||||||
|
pieces: board
|
||||||
|
.pieces
|
||||||
|
.into_iter()
|
||||||
|
.map(|piece| PuzzlePieceStateResponse {
|
||||||
|
piece_id: piece.piece_id,
|
||||||
|
correct_row: piece.correct_row,
|
||||||
|
correct_col: piece.correct_col,
|
||||||
|
current_row: piece.current_row,
|
||||||
|
current_col: piece.current_col,
|
||||||
|
merged_group_id: piece.merged_group_id,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
merged_groups: board
|
||||||
|
.merged_groups
|
||||||
|
.into_iter()
|
||||||
|
.map(|group| PuzzleMergedGroupStateResponse {
|
||||||
|
group_id: group.group_id,
|
||||||
|
piece_ids: group.piece_ids,
|
||||||
|
occupied_cells: group
|
||||||
|
.occupied_cells
|
||||||
|
.into_iter()
|
||||||
|
.map(|cell| PuzzleCellPositionResponse {
|
||||||
|
row: cell.row,
|
||||||
|
col: cell.col,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
selected_piece_id: board.selected_piece_id,
|
||||||
|
all_tiles_resolved: board.all_tiles_resolved,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn resolve_author_display_name(
|
||||||
|
state: &AppState,
|
||||||
|
authenticated: &AuthenticatedAccessToken,
|
||||||
|
) -> String {
|
||||||
|
state
|
||||||
|
.auth_user_service()
|
||||||
|
.get_user_by_id(authenticated.claims().user_id())
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|user| user.display_name)
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "玩家".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_puzzle_welcome_text(seed_text: &str) -> String {
|
||||||
|
if seed_text.trim().is_empty() {
|
||||||
|
return "拼图创作信息已准备好。".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
"拼图创作信息已准备好。".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
518
server-rs/crates/api-server/src/puzzle/tags.rs
Normal file
518
server-rs/crates/api-server/src/puzzle/tags.rs
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) async fn generate_puzzle_work_tags(
|
||||||
|
state: &AppState,
|
||||||
|
work_title: &str,
|
||||||
|
work_description: &str,
|
||||||
|
) -> Vec<String> {
|
||||||
|
if let Some(llm_client) = state.llm_client() {
|
||||||
|
let user_prompt = build_puzzle_tag_generation_user_prompt(work_title, work_description);
|
||||||
|
let response = llm_client
|
||||||
|
.request_text(
|
||||||
|
LlmTextRequest::new(vec![
|
||||||
|
LlmMessage::system(PUZZLE_TAG_GENERATION_SYSTEM_PROMPT),
|
||||||
|
LlmMessage::user(user_prompt),
|
||||||
|
])
|
||||||
|
.with_model(CREATION_TEMPLATE_LLM_MODEL)
|
||||||
|
.with_responses_api(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match response {
|
||||||
|
Ok(response) => {
|
||||||
|
let tags = normalize_puzzle_tag_candidates(parse_puzzle_tags_from_text(
|
||||||
|
response.content.as_str(),
|
||||||
|
));
|
||||||
|
if tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT {
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
tracing::warn!(
|
||||||
|
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
work_title,
|
||||||
|
"拼图 AI 标签数量不足,降级使用关键词补齐"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
work_title,
|
||||||
|
error = %error,
|
||||||
|
"拼图 AI 标签生成失败,降级使用关键词标签"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_puzzle_tag_candidates(build_fallback_puzzle_tags(work_title, work_description))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn parse_puzzle_tags_from_text(text: &str) -> Vec<String> {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
let json_text = if let Some(start) = trimmed.find('{')
|
||||||
|
&& let Some(end) = trimmed.rfind('}')
|
||||||
|
&& end > start
|
||||||
|
{
|
||||||
|
&trimmed[start..=end]
|
||||||
|
} else {
|
||||||
|
trimmed
|
||||||
|
};
|
||||||
|
let Ok(value) = serde_json::from_str::<Value>(json_text) else {
|
||||||
|
return normalize_puzzle_tag_candidates(trimmed.split([',', ',', '、', '\n']));
|
||||||
|
};
|
||||||
|
let Some(tags) = value.get("tags").and_then(Value::as_array) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
normalize_puzzle_tag_candidates(tags.iter().filter_map(Value::as_str))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn normalize_puzzle_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_puzzle_tag(candidate.as_ref());
|
||||||
|
if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tags.push(normalized);
|
||||||
|
if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for fallback in ["拼图", "插画", "清晰构图", "奇幻", "场景", "氛围"] {
|
||||||
|
if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !tags.iter().any(|tag| tag == fallback) {
|
||||||
|
tags.push(fallback.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn normalize_puzzle_tag(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.trim()
|
||||||
|
.trim_matches(|ch: char| {
|
||||||
|
ch.is_ascii_punctuation()
|
||||||
|
|| matches!(
|
||||||
|
ch,
|
||||||
|
',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.trim_start_matches(|ch: char| ch.is_ascii_digit() || matches!(ch, '.' | '、' | ')' | ')'))
|
||||||
|
.trim()
|
||||||
|
.chars()
|
||||||
|
.filter(|ch| !matches!(ch, '#' | '"' | '\'' | '`'))
|
||||||
|
.take(6)
|
||||||
|
.collect::<String>()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_fallback_puzzle_tags(work_title: &str, work_description: &str) -> Vec<&'static str> {
|
||||||
|
let source = format!("{work_title} {work_description}");
|
||||||
|
let mut tags = Vec::new();
|
||||||
|
for (keyword, tag) in [
|
||||||
|
("猫", "猫咪"),
|
||||||
|
("狗", "小狗"),
|
||||||
|
("神庙", "神庙遗迹"),
|
||||||
|
("遗迹", "神庙遗迹"),
|
||||||
|
("森林", "童话森林"),
|
||||||
|
("雨", "雨夜"),
|
||||||
|
("夜", "夜景"),
|
||||||
|
("城市", "城市奇景"),
|
||||||
|
("蒸汽", "蒸汽城市"),
|
||||||
|
("机械", "机械幻想"),
|
||||||
|
("海", "海岸"),
|
||||||
|
("花", "花园"),
|
||||||
|
("雪", "雪景"),
|
||||||
|
("龙", "幻想生物"),
|
||||||
|
("灯", "暖灯"),
|
||||||
|
("塔", "高塔"),
|
||||||
|
] {
|
||||||
|
if source.contains(keyword) && !tags.contains(&tag) {
|
||||||
|
tags.push(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tags.extend(["拼图", "插画", "清晰构图", "奇幻", "场景", "氛围"]);
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn save_generated_puzzle_tags_to_session(
|
||||||
|
state: &AppState,
|
||||||
|
session_id: &str,
|
||||||
|
owner_user_id: &str,
|
||||||
|
payload: &ExecutePuzzleAgentActionRequest,
|
||||||
|
generated_tags: Vec<String>,
|
||||||
|
levels_json: Option<String>,
|
||||||
|
now: i64,
|
||||||
|
) -> Result<PuzzleAgentSessionRecord, AppError> {
|
||||||
|
let session = state
|
||||||
|
.spacetime_client()
|
||||||
|
.get_puzzle_agent_session(session_id.to_string(), owner_user_id.to_string())
|
||||||
|
.await
|
||||||
|
.map_err(map_puzzle_client_error)?;
|
||||||
|
let draft = session.draft.clone().ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
"message": "拼图结果页草稿尚未生成",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
let mut levels = if let Some(levels_json) = levels_json.as_deref() {
|
||||||
|
parse_puzzle_level_records_from_module_json(levels_json)?
|
||||||
|
} else {
|
||||||
|
draft.levels.clone()
|
||||||
|
};
|
||||||
|
if levels.is_empty() {
|
||||||
|
levels = draft.levels.clone();
|
||||||
|
}
|
||||||
|
let first_level = levels.first().cloned().ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
"message": "拼图草稿缺少可编辑关卡",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
let work_title = payload
|
||||||
|
.work_title
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(draft.work_title.as_str())
|
||||||
|
.to_string();
|
||||||
|
let work_description = payload
|
||||||
|
.work_description
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(draft.work_description.as_str())
|
||||||
|
.to_string();
|
||||||
|
let levels_json = Some(serialize_puzzle_level_records_for_module(&levels)?);
|
||||||
|
let (_, profile_id) = build_stable_puzzle_work_ids(session_id);
|
||||||
|
state
|
||||||
|
.spacetime_client()
|
||||||
|
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
|
||||||
|
profile_id,
|
||||||
|
owner_user_id: owner_user_id.to_string(),
|
||||||
|
work_title: work_title.clone(),
|
||||||
|
work_description: work_description.clone(),
|
||||||
|
level_name: first_level.level_name.clone(),
|
||||||
|
summary: work_description.clone(),
|
||||||
|
theme_tags: generated_tags.clone(),
|
||||||
|
cover_image_src: first_level.cover_image_src.clone(),
|
||||||
|
cover_asset_id: first_level.cover_asset_id.clone(),
|
||||||
|
levels_json,
|
||||||
|
updated_at_micros: now,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(map_puzzle_client_error)?;
|
||||||
|
|
||||||
|
Ok(apply_generated_puzzle_tags_to_session_snapshot(
|
||||||
|
session,
|
||||||
|
generated_tags,
|
||||||
|
work_title,
|
||||||
|
work_description,
|
||||||
|
levels,
|
||||||
|
now,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn apply_generated_puzzle_tags_to_session_snapshot(
|
||||||
|
mut session: PuzzleAgentSessionRecord,
|
||||||
|
generated_tags: Vec<String>,
|
||||||
|
work_title: String,
|
||||||
|
work_description: String,
|
||||||
|
levels: Vec<PuzzleDraftLevelRecord>,
|
||||||
|
updated_at_micros: i64,
|
||||||
|
) -> PuzzleAgentSessionRecord {
|
||||||
|
let Some(draft) = session.draft.as_mut() else {
|
||||||
|
return session;
|
||||||
|
};
|
||||||
|
draft.work_title = work_title;
|
||||||
|
draft.work_description = work_description.clone();
|
||||||
|
draft.summary = work_description;
|
||||||
|
draft.theme_tags = generated_tags;
|
||||||
|
draft.levels = levels;
|
||||||
|
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||||
|
session.progress_percent = session.progress_percent.max(96);
|
||||||
|
session.stage = if is_puzzle_session_snapshot_publish_ready(draft) {
|
||||||
|
"ready_to_publish".to_string()
|
||||||
|
} else {
|
||||||
|
"image_refining".to_string()
|
||||||
|
};
|
||||||
|
session.last_assistant_reply = Some("作品标签已生成。".to_string());
|
||||||
|
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||||
|
session
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraftRecord) -> bool {
|
||||||
|
!draft.work_title.trim().is_empty()
|
||||||
|
&& !draft.work_description.trim().is_empty()
|
||||||
|
&& draft.theme_tags.len() >= module_puzzle::PUZZLE_MIN_TAG_COUNT
|
||||||
|
&& draft.theme_tags.len() <= module_puzzle::PUZZLE_MAX_TAG_COUNT
|
||||||
|
&& !draft.levels.is_empty()
|
||||||
|
&& draft.levels.iter().all(|level| {
|
||||||
|
!level.level_name.trim().is_empty()
|
||||||
|
&& level
|
||||||
|
.cover_image_src
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.is_some_and(|value| !value.is_empty())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn serialize_puzzle_level_records_for_module(
|
||||||
|
levels: &[PuzzleDraftLevelRecord],
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let payload = levels
|
||||||
|
.iter()
|
||||||
|
.map(|level| {
|
||||||
|
json!({
|
||||||
|
"level_id": level.level_id,
|
||||||
|
"level_name": level.level_name,
|
||||||
|
"picture_description": level.picture_description,
|
||||||
|
"picture_reference": level.picture_reference,
|
||||||
|
"ui_background_prompt": level.ui_background_prompt,
|
||||||
|
"ui_background_image_src": level.ui_background_image_src,
|
||||||
|
"ui_background_image_object_key": level.ui_background_image_object_key,
|
||||||
|
"background_music": puzzle_audio_asset_record_module_json(&level.background_music),
|
||||||
|
"candidates": level
|
||||||
|
.candidates
|
||||||
|
.iter()
|
||||||
|
.map(|candidate| {
|
||||||
|
json!({
|
||||||
|
"candidate_id": candidate.candidate_id,
|
||||||
|
"image_src": candidate.image_src,
|
||||||
|
"asset_id": candidate.asset_id,
|
||||||
|
"prompt": candidate.prompt,
|
||||||
|
"actual_prompt": candidate.actual_prompt,
|
||||||
|
"source_type": candidate.source_type,
|
||||||
|
"selected": candidate.selected,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
"selected_candidate_id": level.selected_candidate_id,
|
||||||
|
"cover_image_src": level.cover_image_src,
|
||||||
|
"cover_asset_id": level.cover_asset_id,
|
||||||
|
"generation_status": level.generation_status,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
serde_json::to_string(&payload).map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
"message": format!("拼图关卡列表序列化失败:{error}"),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn is_spacetimedb_connectivity_app_error(error: &AppError) -> bool {
|
||||||
|
matches!(
|
||||||
|
error.status_code(),
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE | StatusCode::GATEWAY_TIMEOUT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn ensure_non_empty(
|
||||||
|
request_context: &RequestContext,
|
||||||
|
provider: &str,
|
||||||
|
value: &str,
|
||||||
|
field_name: &str,
|
||||||
|
) -> Result<(), Response> {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return Err(puzzle_error_response(
|
||||||
|
request_context,
|
||||||
|
provider,
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": provider,
|
||||||
|
"message": format!("{field_name} is required"),
|
||||||
|
})),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn puzzle_bad_request(request_context: &RequestContext, provider: &str, message: &str) -> Response {
|
||||||
|
puzzle_error_response(
|
||||||
|
request_context,
|
||||||
|
provider,
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": provider,
|
||||||
|
"message": message,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_client_error(error: SpacetimeClientError) -> AppError {
|
||||||
|
let status = match &error {
|
||||||
|
SpacetimeClientError::ConnectDropped => StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT,
|
||||||
|
error if should_skip_asset_operation_billing_for_connectivity(error) => {
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE
|
||||||
|
}
|
||||||
|
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||||||
|
SpacetimeClientError::Procedure(message)
|
||||||
|
if message.contains("不存在")
|
||||||
|
|| message.contains("not found")
|
||||||
|
|| message.contains("does not exist") =>
|
||||||
|
{
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
}
|
||||||
|
SpacetimeClientError::Procedure(message)
|
||||||
|
if message.contains("当前模型不可用")
|
||||||
|
|| message.contains("生成失败")
|
||||||
|
|| message.contains("解析失败")
|
||||||
|
|| message.contains("缺少有效回复") =>
|
||||||
|
{
|
||||||
|
StatusCode::BAD_GATEWAY
|
||||||
|
}
|
||||||
|
_ => StatusCode::BAD_GATEWAY,
|
||||||
|
};
|
||||||
|
|
||||||
|
AppError::from_status(status).with_details(json!({
|
||||||
|
"provider": "spacetimedb",
|
||||||
|
"message": error.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn should_sync_puzzle_freeze_boundary(error: &AppError, is_freeze_time: bool) -> bool {
|
||||||
|
is_freeze_time && error.body_text().contains("操作不合法")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn is_missing_puzzle_form_draft_procedure_error(error: &SpacetimeClientError) -> bool {
|
||||||
|
matches!(error, SpacetimeClientError::Procedure(message) if
|
||||||
|
message.contains("save_puzzle_form_draft")
|
||||||
|
&& (message.contains("No such procedure")
|
||||||
|
|| message.contains("不存在")
|
||||||
|
|| message.contains("does not exist")
|
||||||
|
|| message.contains("not found")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
|
||||||
|
let message = error.to_string();
|
||||||
|
// 中文注释:历史运行态或旧 SpacetimeDB 错误快照可能仍带 APIMart 图片网关文案;当前 GPT-image-2 已统一迁移到 VectorEngine,返回给前端前先归一,避免误导排障。
|
||||||
|
let is_legacy_apimart_image_error =
|
||||||
|
message.contains("APIMart") || message.contains("apimart") || message.contains("APIMART");
|
||||||
|
let provider = if message.contains("VectorEngine")
|
||||||
|
|| message.contains("vector-engine")
|
||||||
|
|| message.contains("VECTOR_ENGINE")
|
||||||
|
|| is_legacy_apimart_image_error
|
||||||
|
{
|
||||||
|
VECTOR_ENGINE_PROVIDER
|
||||||
|
} else if message.contains("OSS") || message.contains("oss") || message.contains("参考图") {
|
||||||
|
"puzzle-assets"
|
||||||
|
} else {
|
||||||
|
"spacetimedb"
|
||||||
|
};
|
||||||
|
let status = if provider == VECTOR_ENGINE_PROVIDER
|
||||||
|
&& (message.contains("VECTOR_ENGINE_API_KEY")
|
||||||
|
|| message.contains("VECTOR_ENGINE_BASE_URL")
|
||||||
|
|| message.contains("APIMART_API_KEY")
|
||||||
|
|| message.contains("APIMART_BASE_URL")
|
||||||
|
|| message.contains("未配置"))
|
||||||
|
{
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE
|
||||||
|
} else if matches!(
|
||||||
|
error,
|
||||||
|
SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout
|
||||||
|
) || should_skip_asset_operation_billing_for_connectivity(&error)
|
||||||
|
{
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE
|
||||||
|
} else if matches!(error, SpacetimeClientError::Runtime(_))
|
||||||
|
&& (message.contains("生成")
|
||||||
|
|| message.contains("上游")
|
||||||
|
|| message.contains("VectorEngine")
|
||||||
|
|| message.contains("vector-engine")
|
||||||
|
|| message.contains("VECTOR_ENGINE")
|
||||||
|
|| is_legacy_apimart_image_error
|
||||||
|
|| message.contains("参考图")
|
||||||
|
|| message.contains("图片")
|
||||||
|
|| message.contains("OSS")
|
||||||
|
|| message.contains("oss"))
|
||||||
|
{
|
||||||
|
StatusCode::BAD_GATEWAY
|
||||||
|
} else {
|
||||||
|
match &error {
|
||||||
|
SpacetimeClientError::Procedure(message)
|
||||||
|
if message.contains("不存在")
|
||||||
|
|| message.contains("not found")
|
||||||
|
|| message.contains("does not exist") =>
|
||||||
|
{
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
}
|
||||||
|
SpacetimeClientError::Procedure(message)
|
||||||
|
if message.contains("当前模型不可用")
|
||||||
|
|| message.contains("生成失败")
|
||||||
|
|| message.contains("解析失败")
|
||||||
|
|| message.contains("缺少有效回复") =>
|
||||||
|
{
|
||||||
|
StatusCode::BAD_GATEWAY
|
||||||
|
}
|
||||||
|
SpacetimeClientError::ConnectDropped => StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT,
|
||||||
|
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||||||
|
_ => StatusCode::BAD_GATEWAY,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let user_message = normalize_legacy_puzzle_image_error_message(message.as_str());
|
||||||
|
|
||||||
|
AppError::from_status(status).with_details(json!({
|
||||||
|
"provider": provider,
|
||||||
|
"message": user_message,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn normalize_legacy_puzzle_image_error_message(message: &str) -> String {
|
||||||
|
message
|
||||||
|
.replace(
|
||||||
|
"APIMart 图片生成密钥未配置",
|
||||||
|
"VectorEngine 图片生成密钥未配置",
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
"APIMart 图片生成地址未配置",
|
||||||
|
"VectorEngine 图片生成地址未配置",
|
||||||
|
)
|
||||||
|
.replace("APIMART_API_KEY", "VECTOR_ENGINE_API_KEY")
|
||||||
|
.replace("APIMART_BASE_URL", "VECTOR_ENGINE_BASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn puzzle_error_response(
|
||||||
|
request_context: &RequestContext,
|
||||||
|
provider: &str,
|
||||||
|
error: AppError,
|
||||||
|
) -> Response {
|
||||||
|
let mut response = error.into_response_with_context(Some(request_context));
|
||||||
|
response.headers_mut().insert(
|
||||||
|
HeaderName::from_static("x-genarrative-provider"),
|
||||||
|
header::HeaderValue::from_str(provider)
|
||||||
|
.unwrap_or_else(|_| header::HeaderValue::from_static("puzzle")),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn puzzle_sse_json_event(event_name: &str, payload: Value) -> Result<Event, AppError> {
|
||||||
|
Event::default()
|
||||||
|
.event(event_name)
|
||||||
|
.json_data(payload)
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||||
|
"provider": "sse",
|
||||||
|
"message": format!("SSE payload 序列化失败:{error}"),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn puzzle_sse_json_event_or_error(event_name: &str, payload: Value) -> Event {
|
||||||
|
match puzzle_sse_json_event(event_name, payload) {
|
||||||
|
Ok(event) => event,
|
||||||
|
Err(_) => puzzle_sse_error_event_message("SSE payload 序列化失败".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn puzzle_sse_error_event_message(message: String) -> Event {
|
||||||
|
let payload = format!(
|
||||||
|
"{{\"message\":{}}}",
|
||||||
|
serde_json::to_string(&message)
|
||||||
|
.unwrap_or_else(|_| "\"SSE 错误事件序列化失败\"".to_string())
|
||||||
|
);
|
||||||
|
Event::default().event("error").data(payload)
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
315
server-rs/crates/api-server/src/square_hole/mappers.rs
Normal file
315
server-rs/crates/api-server/src/square_hole/mappers.rs
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) fn map_square_hole_agent_session_response(
|
||||||
|
session: SquareHoleAgentSessionRecord,
|
||||||
|
) -> SquareHoleSessionSnapshotResponse {
|
||||||
|
SquareHoleSessionSnapshotResponse {
|
||||||
|
session_id: session.session_id,
|
||||||
|
current_turn: session.current_turn,
|
||||||
|
progress_percent: session.progress_percent,
|
||||||
|
stage: session.stage.clone(),
|
||||||
|
anchor_pack: map_square_hole_anchor_pack_response_for_turn(
|
||||||
|
session.anchor_pack,
|
||||||
|
session.current_turn,
|
||||||
|
session.stage.as_str(),
|
||||||
|
),
|
||||||
|
config: map_square_hole_config_response(session.config),
|
||||||
|
draft: session.draft.map(map_square_hole_draft_response),
|
||||||
|
messages: session
|
||||||
|
.messages
|
||||||
|
.into_iter()
|
||||||
|
.map(map_square_hole_message_response)
|
||||||
|
.collect(),
|
||||||
|
last_assistant_reply: session.last_assistant_reply,
|
||||||
|
published_profile_id: session.published_profile_id,
|
||||||
|
updated_at: session.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_square_hole_anchor_pack_response_for_turn(
|
||||||
|
anchor: SquareHoleAnchorPackRecord,
|
||||||
|
current_turn: u32,
|
||||||
|
stage: &str,
|
||||||
|
) -> SquareHoleAnchorPackResponse {
|
||||||
|
let is_ready = matches!(
|
||||||
|
stage,
|
||||||
|
"ReadyToCompile"
|
||||||
|
| "ready_to_compile"
|
||||||
|
| "DraftCompiled"
|
||||||
|
| "draft_compiled"
|
||||||
|
| "draft_ready"
|
||||||
|
| "Published"
|
||||||
|
| "published"
|
||||||
|
);
|
||||||
|
let collected_count = if is_ready { 4 } else { current_turn.min(4) };
|
||||||
|
|
||||||
|
SquareHoleAnchorPackResponse {
|
||||||
|
theme: map_square_hole_anchor_item_response_for_collected(
|
||||||
|
anchor.theme,
|
||||||
|
collected_count >= 1,
|
||||||
|
),
|
||||||
|
twist_rule: map_square_hole_anchor_item_response_for_collected(
|
||||||
|
anchor.twist_rule,
|
||||||
|
collected_count >= 2,
|
||||||
|
),
|
||||||
|
shape_count: map_square_hole_anchor_item_response_for_collected(
|
||||||
|
anchor.shape_count,
|
||||||
|
collected_count >= 3,
|
||||||
|
),
|
||||||
|
difficulty: map_square_hole_anchor_item_response_for_collected(
|
||||||
|
anchor.difficulty,
|
||||||
|
collected_count >= 4,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_square_hole_anchor_item_response(
|
||||||
|
anchor: SquareHoleAnchorItemRecord,
|
||||||
|
) -> SquareHoleAnchorItemResponse {
|
||||||
|
SquareHoleAnchorItemResponse {
|
||||||
|
key: anchor.key,
|
||||||
|
label: anchor.label,
|
||||||
|
value: anchor.value,
|
||||||
|
status: anchor.status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_square_hole_anchor_item_response_for_collected(
|
||||||
|
anchor: SquareHoleAnchorItemRecord,
|
||||||
|
collected: bool,
|
||||||
|
) -> SquareHoleAnchorItemResponse {
|
||||||
|
if collected {
|
||||||
|
return map_square_hole_anchor_item_response(anchor);
|
||||||
|
}
|
||||||
|
|
||||||
|
SquareHoleAnchorItemResponse {
|
||||||
|
key: anchor.key,
|
||||||
|
label: anchor.label,
|
||||||
|
value: String::new(),
|
||||||
|
status: "missing".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_square_hole_config_response(
|
||||||
|
config: SquareHoleCreatorConfigRecord,
|
||||||
|
) -> SquareHoleCreatorConfigResponse {
|
||||||
|
SquareHoleCreatorConfigResponse {
|
||||||
|
theme_text: config.theme_text,
|
||||||
|
twist_rule: config.twist_rule,
|
||||||
|
shape_count: config.shape_count,
|
||||||
|
difficulty: config.difficulty,
|
||||||
|
shape_options: config
|
||||||
|
.shape_options
|
||||||
|
.into_iter()
|
||||||
|
.map(map_square_hole_shape_option_response)
|
||||||
|
.collect(),
|
||||||
|
hole_options: config
|
||||||
|
.hole_options
|
||||||
|
.into_iter()
|
||||||
|
.map(map_square_hole_hole_option_response)
|
||||||
|
.collect(),
|
||||||
|
background_prompt: config.background_prompt,
|
||||||
|
cover_image_src: config.cover_image_src,
|
||||||
|
background_image_src: config.background_image_src,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_square_hole_draft_response(
|
||||||
|
draft: SquareHoleResultDraftRecord,
|
||||||
|
) -> SquareHoleResultDraftResponse {
|
||||||
|
SquareHoleResultDraftResponse {
|
||||||
|
profile_id: draft.profile_id,
|
||||||
|
game_name: draft.game_name,
|
||||||
|
theme_text: draft.theme_text,
|
||||||
|
twist_rule: draft.twist_rule,
|
||||||
|
summary: draft.summary,
|
||||||
|
tags: draft.tags,
|
||||||
|
cover_image_src: draft.cover_image_src,
|
||||||
|
background_prompt: draft.background_prompt,
|
||||||
|
background_image_src: draft.background_image_src,
|
||||||
|
shape_options: draft
|
||||||
|
.shape_options
|
||||||
|
.into_iter()
|
||||||
|
.map(map_square_hole_shape_option_response)
|
||||||
|
.collect(),
|
||||||
|
hole_options: draft
|
||||||
|
.hole_options
|
||||||
|
.into_iter()
|
||||||
|
.map(map_square_hole_hole_option_response)
|
||||||
|
.collect(),
|
||||||
|
shape_count: draft.shape_count,
|
||||||
|
difficulty: draft.difficulty,
|
||||||
|
publish_ready: draft.publish_ready,
|
||||||
|
blockers: draft.blockers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_square_hole_message_response(
|
||||||
|
message: SquareHoleAgentMessageRecord,
|
||||||
|
) -> SquareHoleAgentMessageResponse {
|
||||||
|
SquareHoleAgentMessageResponse {
|
||||||
|
id: message.id,
|
||||||
|
role: message.role,
|
||||||
|
kind: message.kind,
|
||||||
|
text: message.text,
|
||||||
|
created_at: message.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_square_hole_work_summary_response(
|
||||||
|
item: SquareHoleWorkProfileRecord,
|
||||||
|
) -> SquareHoleWorkSummaryResponse {
|
||||||
|
SquareHoleWorkSummaryResponse {
|
||||||
|
work_id: item.work_id,
|
||||||
|
profile_id: item.profile_id,
|
||||||
|
owner_user_id: item.owner_user_id,
|
||||||
|
source_session_id: item.source_session_id,
|
||||||
|
game_name: item.game_name,
|
||||||
|
theme_text: item.theme_text,
|
||||||
|
twist_rule: item.twist_rule,
|
||||||
|
summary: item.summary,
|
||||||
|
tags: item.tags,
|
||||||
|
cover_image_src: item.cover_image_src,
|
||||||
|
background_prompt: item.background_prompt,
|
||||||
|
background_image_src: item.background_image_src,
|
||||||
|
shape_options: item
|
||||||
|
.shape_options
|
||||||
|
.into_iter()
|
||||||
|
.map(map_square_hole_work_shape_option_response)
|
||||||
|
.collect(),
|
||||||
|
hole_options: item
|
||||||
|
.hole_options
|
||||||
|
.into_iter()
|
||||||
|
.map(map_square_hole_work_hole_option_response)
|
||||||
|
.collect(),
|
||||||
|
shape_count: item.shape_count,
|
||||||
|
difficulty: item.difficulty,
|
||||||
|
publication_status: item.publication_status,
|
||||||
|
play_count: item.play_count,
|
||||||
|
updated_at: item.updated_at,
|
||||||
|
published_at: item.published_at,
|
||||||
|
publish_ready: item.publish_ready,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_square_hole_work_profile_response(
|
||||||
|
item: SquareHoleWorkProfileRecord,
|
||||||
|
) -> SquareHoleWorkProfileResponse {
|
||||||
|
SquareHoleWorkProfileResponse {
|
||||||
|
summary: map_square_hole_work_summary_response(item),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_square_hole_run_response(run: SquareHoleRunRecord) -> SquareHoleRunSnapshotResponse {
|
||||||
|
SquareHoleRunSnapshotResponse {
|
||||||
|
run_id: run.run_id,
|
||||||
|
profile_id: run.profile_id,
|
||||||
|
owner_user_id: run.owner_user_id,
|
||||||
|
status: normalize_square_hole_run_status(run.status.as_str()).to_string(),
|
||||||
|
snapshot_version: run.snapshot_version,
|
||||||
|
started_at_ms: run.started_at_ms,
|
||||||
|
duration_limit_ms: run.duration_limit_ms,
|
||||||
|
remaining_ms: run.remaining_ms,
|
||||||
|
total_shape_count: run.total_shape_count,
|
||||||
|
completed_shape_count: run.completed_shape_count,
|
||||||
|
combo: run.combo,
|
||||||
|
best_combo: run.best_combo,
|
||||||
|
score: run.score,
|
||||||
|
rule_label: run.rule_label,
|
||||||
|
background_image_src: run.background_image_src,
|
||||||
|
current_shape: run.current_shape.map(map_square_hole_shape_response),
|
||||||
|
holes: run
|
||||||
|
.holes
|
||||||
|
.into_iter()
|
||||||
|
.map(map_square_hole_hole_response)
|
||||||
|
.collect(),
|
||||||
|
last_feedback: run.last_feedback.map(map_square_hole_feedback_response),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_square_hole_shape_response(
|
||||||
|
item: SquareHoleShapeSnapshotRecord,
|
||||||
|
) -> SquareHoleShapeSnapshotResponse {
|
||||||
|
SquareHoleShapeSnapshotResponse {
|
||||||
|
shape_id: item.shape_id,
|
||||||
|
shape_kind: item.shape_kind,
|
||||||
|
label: item.label,
|
||||||
|
target_hole_id: item.target_hole_id,
|
||||||
|
color: item.color,
|
||||||
|
image_src: item.image_src,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_square_hole_hole_response(
|
||||||
|
slot: SquareHoleHoleSnapshotRecord,
|
||||||
|
) -> SquareHoleHoleSnapshotResponse {
|
||||||
|
SquareHoleHoleSnapshotResponse {
|
||||||
|
hole_id: slot.hole_id,
|
||||||
|
hole_kind: slot.hole_kind,
|
||||||
|
label: slot.label,
|
||||||
|
x: slot.x,
|
||||||
|
y: slot.y,
|
||||||
|
image_src: slot.image_src,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_square_hole_shape_option_response(
|
||||||
|
item: SquareHoleShapeOptionRecord,
|
||||||
|
) -> SquareHoleShapeOptionResponse {
|
||||||
|
SquareHoleShapeOptionResponse {
|
||||||
|
option_id: item.option_id,
|
||||||
|
shape_kind: item.shape_kind,
|
||||||
|
label: item.label,
|
||||||
|
target_hole_id: item.target_hole_id,
|
||||||
|
image_prompt: item.image_prompt,
|
||||||
|
image_src: item.image_src,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_square_hole_hole_option_response(
|
||||||
|
item: SquareHoleHoleOptionRecord,
|
||||||
|
) -> SquareHoleHoleOptionResponse {
|
||||||
|
SquareHoleHoleOptionResponse {
|
||||||
|
hole_id: item.hole_id,
|
||||||
|
hole_kind: item.hole_kind,
|
||||||
|
label: item.label,
|
||||||
|
image_prompt: item.image_prompt,
|
||||||
|
image_src: item.image_src,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_square_hole_work_shape_option_response(
|
||||||
|
item: SquareHoleShapeOptionRecord,
|
||||||
|
) -> SquareHoleWorkShapeOptionResponse {
|
||||||
|
SquareHoleWorkShapeOptionResponse {
|
||||||
|
option_id: item.option_id,
|
||||||
|
shape_kind: item.shape_kind,
|
||||||
|
label: item.label,
|
||||||
|
target_hole_id: item.target_hole_id,
|
||||||
|
image_prompt: item.image_prompt,
|
||||||
|
image_src: item.image_src,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_square_hole_work_hole_option_response(
|
||||||
|
item: SquareHoleHoleOptionRecord,
|
||||||
|
) -> SquareHoleWorkHoleOptionResponse {
|
||||||
|
SquareHoleWorkHoleOptionResponse {
|
||||||
|
hole_id: item.hole_id,
|
||||||
|
hole_kind: item.hole_kind,
|
||||||
|
label: item.label,
|
||||||
|
image_prompt: item.image_prompt,
|
||||||
|
image_src: item.image_src,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_square_hole_feedback_response(
|
||||||
|
feedback: SquareHoleDropFeedbackRecord,
|
||||||
|
) -> SquareHoleDropFeedbackResponse {
|
||||||
|
SquareHoleDropFeedbackResponse {
|
||||||
|
accepted: feedback.accepted,
|
||||||
|
reject_reason: feedback.reject_reason,
|
||||||
|
message: feedback.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
686
server-rs/crates/api-server/src/square_hole/visual_assets.rs
Normal file
686
server-rs/crates/api-server/src/square_hole/visual_assets.rs
Normal file
@@ -0,0 +1,686 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) async fn generate_square_hole_visual_assets_for_session(
|
||||||
|
state: &AppState,
|
||||||
|
request_context: &RequestContext,
|
||||||
|
authenticated: &AuthenticatedAccessToken,
|
||||||
|
session_id: String,
|
||||||
|
regenerate_visual_assets: bool,
|
||||||
|
visual_asset_slot: Option<String>,
|
||||||
|
visual_asset_option_id: Option<String>,
|
||||||
|
) -> Result<SquareHoleAgentSessionRecord, Response> {
|
||||||
|
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||||
|
let session = state
|
||||||
|
.spacetime_client()
|
||||||
|
.get_square_hole_agent_session(session_id.clone(), owner_user_id.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
square_hole_error_response(
|
||||||
|
request_context,
|
||||||
|
SQUARE_HOLE_AGENT_PROVIDER,
|
||||||
|
map_square_hole_client_error(error),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let profile_id = session
|
||||||
|
.draft
|
||||||
|
.as_ref()
|
||||||
|
.map(|draft| draft.profile_id.clone())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
square_hole_bad_request(
|
||||||
|
request_context,
|
||||||
|
SQUARE_HOLE_AGENT_PROVIDER,
|
||||||
|
"square hole 草稿尚未编译,不能生成图片资产",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let mut work = state
|
||||||
|
.spacetime_client()
|
||||||
|
.get_square_hole_work_detail(profile_id.clone(), owner_user_id.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
square_hole_error_response(
|
||||||
|
request_context,
|
||||||
|
SQUARE_HOLE_AGENT_PROVIDER,
|
||||||
|
map_square_hole_client_error(error),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let requested_slot = normalize_square_hole_visual_asset_slot(
|
||||||
|
visual_asset_slot.as_deref(),
|
||||||
|
visual_asset_option_id.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let cover_image_src = match work.cover_image_src.clone() {
|
||||||
|
Some(value)
|
||||||
|
if !should_generate_square_hole_cover_image(
|
||||||
|
requested_slot.as_ref(),
|
||||||
|
regenerate_visual_assets,
|
||||||
|
value.as_str(),
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
_ => Some(
|
||||||
|
generate_square_hole_image_data_url(
|
||||||
|
state,
|
||||||
|
&owner_user_id,
|
||||||
|
&session_id,
|
||||||
|
profile_id.as_str(),
|
||||||
|
"cover",
|
||||||
|
SQUARE_HOLE_COVER_IMAGE_KIND,
|
||||||
|
build_square_hole_cover_prompt(&work).as_str(),
|
||||||
|
"16:9",
|
||||||
|
"生成方洞挑战封面图失败",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error)
|
||||||
|
})?,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let background_image_src = match work.background_image_src.clone() {
|
||||||
|
Some(value)
|
||||||
|
if !should_generate_square_hole_background_image(
|
||||||
|
requested_slot.as_ref(),
|
||||||
|
regenerate_visual_assets,
|
||||||
|
value.as_str(),
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
_ => Some(
|
||||||
|
generate_square_hole_image_data_url(
|
||||||
|
state,
|
||||||
|
&owner_user_id,
|
||||||
|
&session_id,
|
||||||
|
profile_id.as_str(),
|
||||||
|
"background",
|
||||||
|
SQUARE_HOLE_BACKGROUND_IMAGE_KIND,
|
||||||
|
build_square_hole_background_prompt(&work).as_str(),
|
||||||
|
"16:9",
|
||||||
|
"生成方洞挑战背景图失败",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error)
|
||||||
|
})?,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let mut shape_options = work.shape_options.clone();
|
||||||
|
let prompt_work = work.clone();
|
||||||
|
for option in shape_options.iter_mut() {
|
||||||
|
if !should_generate_square_hole_shape_image(
|
||||||
|
requested_slot.as_ref(),
|
||||||
|
regenerate_visual_assets,
|
||||||
|
option,
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
option.image_src = Some(
|
||||||
|
generate_square_hole_image_data_url(
|
||||||
|
state,
|
||||||
|
&owner_user_id,
|
||||||
|
&session_id,
|
||||||
|
profile_id.as_str(),
|
||||||
|
option.option_id.as_str(),
|
||||||
|
SQUARE_HOLE_SHAPE_IMAGE_KIND,
|
||||||
|
build_square_hole_shape_prompt(&prompt_work, option).as_str(),
|
||||||
|
"1:1",
|
||||||
|
"生成方洞挑战形状贴图失败",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error)
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut hole_options = work.hole_options.clone();
|
||||||
|
for option in hole_options.iter_mut() {
|
||||||
|
if !should_generate_square_hole_hole_image(
|
||||||
|
requested_slot.as_ref(),
|
||||||
|
regenerate_visual_assets,
|
||||||
|
option,
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
option.image_src = Some(
|
||||||
|
generate_square_hole_image_data_url(
|
||||||
|
state,
|
||||||
|
&owner_user_id,
|
||||||
|
&session_id,
|
||||||
|
profile_id.as_str(),
|
||||||
|
option.hole_id.as_str(),
|
||||||
|
SQUARE_HOLE_HOLE_IMAGE_KIND,
|
||||||
|
build_square_hole_hole_prompt(&prompt_work, option).as_str(),
|
||||||
|
"1:1",
|
||||||
|
"生成方洞挑战洞口贴图失败",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error)
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
work = state
|
||||||
|
.spacetime_client()
|
||||||
|
.update_square_hole_work(SquareHoleWorkUpdateRecordInput {
|
||||||
|
profile_id,
|
||||||
|
owner_user_id: owner_user_id.clone(),
|
||||||
|
game_name: work.game_name.clone(),
|
||||||
|
theme_text: work.theme_text.clone(),
|
||||||
|
twist_rule: work.twist_rule.clone(),
|
||||||
|
summary_text: work.summary.clone(),
|
||||||
|
tags_json: serde_json::to_string(&normalize_tags(work.tags.clone()))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
cover_image_src: cover_image_src.clone().unwrap_or_default(),
|
||||||
|
background_prompt: work.background_prompt.clone(),
|
||||||
|
background_image_src: background_image_src.clone().unwrap_or_default(),
|
||||||
|
shape_options_json: serialize_square_hole_shape_option_records(&shape_options),
|
||||||
|
hole_options_json: serialize_square_hole_hole_option_records(&hole_options),
|
||||||
|
shape_count: work.shape_count,
|
||||||
|
difficulty: work.difficulty,
|
||||||
|
updated_at_micros: current_utc_micros(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
square_hole_error_response(
|
||||||
|
request_context,
|
||||||
|
SQUARE_HOLE_AGENT_PROVIDER,
|
||||||
|
map_square_hole_client_error(error),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut next_session = state
|
||||||
|
.spacetime_client()
|
||||||
|
.get_square_hole_agent_session(session_id, owner_user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
square_hole_error_response(
|
||||||
|
request_context,
|
||||||
|
SQUARE_HOLE_AGENT_PROVIDER,
|
||||||
|
map_square_hole_client_error(error),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if let Some(draft) = next_session.draft.as_mut() {
|
||||||
|
draft.cover_image_src = work.cover_image_src.clone();
|
||||||
|
draft.background_image_src = work.background_image_src.clone();
|
||||||
|
draft.background_prompt = work.background_prompt.clone();
|
||||||
|
draft.shape_options = work.shape_options.clone();
|
||||||
|
draft.hole_options = work.hole_options.clone();
|
||||||
|
}
|
||||||
|
Ok(next_session)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn regenerate_square_hole_visual_asset_for_work(
|
||||||
|
state: &AppState,
|
||||||
|
request_context: &RequestContext,
|
||||||
|
owner_user_id: String,
|
||||||
|
profile_id: String,
|
||||||
|
visual_asset_slot: String,
|
||||||
|
visual_asset_option_id: Option<String>,
|
||||||
|
) -> Result<SquareHoleWorkProfileRecord, Response> {
|
||||||
|
let mut work = state
|
||||||
|
.spacetime_client()
|
||||||
|
.get_square_hole_work_detail(profile_id.clone(), owner_user_id.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
square_hole_error_response(
|
||||||
|
request_context,
|
||||||
|
SQUARE_HOLE_WORKS_PROVIDER,
|
||||||
|
map_square_hole_client_error(error),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let requested_slot = normalize_square_hole_visual_asset_slot(
|
||||||
|
Some(visual_asset_slot.as_str()),
|
||||||
|
visual_asset_option_id.as_deref(),
|
||||||
|
)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
square_hole_bad_request(
|
||||||
|
request_context,
|
||||||
|
SQUARE_HOLE_WORKS_PROVIDER,
|
||||||
|
"图片槽位不存在",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let synthetic_session_id = work
|
||||||
|
.source_session_id
|
||||||
|
.clone()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| profile_id.clone());
|
||||||
|
let prompt_work = work.clone();
|
||||||
|
match &requested_slot {
|
||||||
|
SquareHoleVisualAssetSlotRequest::Cover => {
|
||||||
|
work.cover_image_src = Some(
|
||||||
|
generate_square_hole_image_data_url(
|
||||||
|
state,
|
||||||
|
owner_user_id.as_str(),
|
||||||
|
synthetic_session_id.as_str(),
|
||||||
|
profile_id.as_str(),
|
||||||
|
"cover",
|
||||||
|
SQUARE_HOLE_COVER_IMAGE_KIND,
|
||||||
|
build_square_hole_cover_prompt(&prompt_work).as_str(),
|
||||||
|
"16:9",
|
||||||
|
"生成方洞挑战封面图失败",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
SquareHoleVisualAssetSlotRequest::Background => {
|
||||||
|
work.background_image_src = Some(
|
||||||
|
generate_square_hole_image_data_url(
|
||||||
|
state,
|
||||||
|
owner_user_id.as_str(),
|
||||||
|
synthetic_session_id.as_str(),
|
||||||
|
profile_id.as_str(),
|
||||||
|
"background",
|
||||||
|
SQUARE_HOLE_BACKGROUND_IMAGE_KIND,
|
||||||
|
build_square_hole_background_prompt(&prompt_work).as_str(),
|
||||||
|
"16:9",
|
||||||
|
"生成方洞挑战背景图失败",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
SquareHoleVisualAssetSlotRequest::Shape(option_id) => {
|
||||||
|
let Some(option) = work
|
||||||
|
.shape_options
|
||||||
|
.iter_mut()
|
||||||
|
.find(|option| option.option_id == *option_id)
|
||||||
|
else {
|
||||||
|
return Err(square_hole_bad_request(
|
||||||
|
request_context,
|
||||||
|
SQUARE_HOLE_WORKS_PROVIDER,
|
||||||
|
"形状图片槽位不存在",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
option.image_src = Some(
|
||||||
|
generate_square_hole_image_data_url(
|
||||||
|
state,
|
||||||
|
owner_user_id.as_str(),
|
||||||
|
synthetic_session_id.as_str(),
|
||||||
|
profile_id.as_str(),
|
||||||
|
option.option_id.as_str(),
|
||||||
|
SQUARE_HOLE_SHAPE_IMAGE_KIND,
|
||||||
|
build_square_hole_shape_prompt(&prompt_work, option).as_str(),
|
||||||
|
"1:1",
|
||||||
|
"生成方洞挑战形状贴图失败",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
SquareHoleVisualAssetSlotRequest::Hole(hole_id) => {
|
||||||
|
let Some(option) = work
|
||||||
|
.hole_options
|
||||||
|
.iter_mut()
|
||||||
|
.find(|option| option.hole_id == *hole_id)
|
||||||
|
else {
|
||||||
|
return Err(square_hole_bad_request(
|
||||||
|
request_context,
|
||||||
|
SQUARE_HOLE_WORKS_PROVIDER,
|
||||||
|
"洞口图片槽位不存在",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
option.image_src = Some(
|
||||||
|
generate_square_hole_image_data_url(
|
||||||
|
state,
|
||||||
|
owner_user_id.as_str(),
|
||||||
|
synthetic_session_id.as_str(),
|
||||||
|
profile_id.as_str(),
|
||||||
|
option.hole_id.as_str(),
|
||||||
|
SQUARE_HOLE_HOLE_IMAGE_KIND,
|
||||||
|
build_square_hole_hole_prompt(&prompt_work, option).as_str(),
|
||||||
|
"1:1",
|
||||||
|
"生成方洞挑战洞口贴图失败",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
.spacetime_client()
|
||||||
|
.update_square_hole_work(SquareHoleWorkUpdateRecordInput {
|
||||||
|
profile_id,
|
||||||
|
owner_user_id,
|
||||||
|
game_name: work.game_name.clone(),
|
||||||
|
theme_text: work.theme_text.clone(),
|
||||||
|
twist_rule: work.twist_rule.clone(),
|
||||||
|
summary_text: work.summary.clone(),
|
||||||
|
tags_json: serde_json::to_string(&normalize_tags(work.tags.clone()))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
cover_image_src: work.cover_image_src.clone().unwrap_or_default(),
|
||||||
|
background_prompt: work.background_prompt.clone(),
|
||||||
|
background_image_src: work.background_image_src.clone().unwrap_or_default(),
|
||||||
|
shape_options_json: serialize_square_hole_shape_option_records(&work.shape_options),
|
||||||
|
hole_options_json: serialize_square_hole_hole_option_records(&work.hole_options),
|
||||||
|
shape_count: work.shape_count,
|
||||||
|
difficulty: work.difficulty,
|
||||||
|
updated_at_micros: current_utc_micros(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
square_hole_error_response(
|
||||||
|
request_context,
|
||||||
|
SQUARE_HOLE_WORKS_PROVIDER,
|
||||||
|
map_square_hole_client_error(error),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_square_hole_image_data_url(
|
||||||
|
state: &AppState,
|
||||||
|
owner_user_id: &str,
|
||||||
|
session_id: &str,
|
||||||
|
profile_id: &str,
|
||||||
|
slot: &str,
|
||||||
|
asset_kind: &str,
|
||||||
|
prompt: &str,
|
||||||
|
size: &str,
|
||||||
|
failure_context: &str,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let settings = require_openai_image_settings(state)?;
|
||||||
|
let http_client = build_openai_image_http_client(&settings)?;
|
||||||
|
let generated = create_openai_image_generation(
|
||||||
|
&http_client,
|
||||||
|
&settings,
|
||||||
|
prompt,
|
||||||
|
Some(build_square_hole_negative_prompt().as_str()),
|
||||||
|
size,
|
||||||
|
1,
|
||||||
|
&[],
|
||||||
|
failure_context,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "vector-engine",
|
||||||
|
"message": format!("{failure_context}:上游未返回图片"),
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let fallback_data_url = format_square_hole_data_url(&image);
|
||||||
|
match persist_square_hole_generated_asset(
|
||||||
|
state,
|
||||||
|
owner_user_id,
|
||||||
|
session_id,
|
||||||
|
profile_id,
|
||||||
|
slot,
|
||||||
|
asset_kind,
|
||||||
|
generated.task_id.as_str(),
|
||||||
|
image,
|
||||||
|
current_utc_micros(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(image_src) => Ok(image_src),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
provider = "square-hole-assets",
|
||||||
|
owner_user_id,
|
||||||
|
session_id,
|
||||||
|
profile_id,
|
||||||
|
slot,
|
||||||
|
asset_kind,
|
||||||
|
message = %error.body_text(),
|
||||||
|
"方洞图片已生成但资产持久化失败,降级回写 Data URL"
|
||||||
|
);
|
||||||
|
Ok(fallback_data_url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_square_hole_data_url(image: &DownloadedOpenAiImage) -> String {
|
||||||
|
format!(
|
||||||
|
"data:{};base64,{}",
|
||||||
|
image.mime_type,
|
||||||
|
BASE64_STANDARD.encode(&image.bytes)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn persist_square_hole_generated_asset(
|
||||||
|
state: &AppState,
|
||||||
|
owner_user_id: &str,
|
||||||
|
session_id: &str,
|
||||||
|
profile_id: &str,
|
||||||
|
slot: &str,
|
||||||
|
asset_kind: &str,
|
||||||
|
task_id: &str,
|
||||||
|
image: DownloadedOpenAiImage,
|
||||||
|
generated_at_micros: i64,
|
||||||
|
) -> Result<String, 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 storage_slot = sanitize_square_hole_asset_segment(slot, "slot");
|
||||||
|
let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
|
||||||
|
prefix: LegacyAssetPrefix::SquareHoleAssets,
|
||||||
|
path_segments: vec![
|
||||||
|
sanitize_square_hole_asset_segment(session_id, "session"),
|
||||||
|
sanitize_square_hole_asset_segment(profile_id, "profile"),
|
||||||
|
sanitize_square_hole_asset_segment(asset_kind, "asset"),
|
||||||
|
storage_slot.clone(),
|
||||||
|
format!("asset-{generated_at_micros}"),
|
||||||
|
],
|
||||||
|
file_stem: "image".to_string(),
|
||||||
|
image: GeneratedImageAssetDataUrl {
|
||||||
|
format: normalize_generated_image_asset_mime(image.mime_type.as_str()),
|
||||||
|
bytes: image.bytes,
|
||||||
|
},
|
||||||
|
access: OssObjectAccess::Private,
|
||||||
|
metadata: GeneratedImageAssetAdapterMetadata {
|
||||||
|
asset_kind: Some(asset_kind.to_string()),
|
||||||
|
owner_user_id: Some(owner_user_id.to_string()),
|
||||||
|
entity_kind: Some(SQUARE_HOLE_ENTITY_KIND.to_string()),
|
||||||
|
entity_id: Some(profile_id.to_string()),
|
||||||
|
slot: Some(slot.to_string()),
|
||||||
|
provider: Some("openai".to_string()),
|
||||||
|
task_id: Some(task_id.to_string()),
|
||||||
|
},
|
||||||
|
extra_metadata: BTreeMap::from([("profile_id".to_string(), profile_id.to_string())]),
|
||||||
|
})
|
||||||
|
.map_err(map_square_hole_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_square_hole_asset_oss_error)?;
|
||||||
|
let head = oss_client
|
||||||
|
.head_object(
|
||||||
|
&http_client,
|
||||||
|
OssHeadObjectRequest {
|
||||||
|
object_key: put_result.object_key.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_square_hole_asset_oss_error)?;
|
||||||
|
|
||||||
|
match state
|
||||||
|
.spacetime_client()
|
||||||
|
.confirm_asset_object(
|
||||||
|
build_asset_object_upsert_input(
|
||||||
|
generate_asset_object_id(generated_at_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(),
|
||||||
|
Some(task_id.to_string()),
|
||||||
|
Some(owner_user_id.to_string()),
|
||||||
|
Some(profile_id.to_string()),
|
||||||
|
Some(profile_id.to_string()),
|
||||||
|
generated_at_micros,
|
||||||
|
)
|
||||||
|
.map_err(map_square_hole_asset_field_error)?,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(asset_object) => {
|
||||||
|
if let Err(error) = state
|
||||||
|
.spacetime_client()
|
||||||
|
.bind_asset_object_to_entity(
|
||||||
|
build_asset_entity_binding_input(
|
||||||
|
generate_asset_binding_id(generated_at_micros),
|
||||||
|
asset_object.asset_object_id,
|
||||||
|
SQUARE_HOLE_ENTITY_KIND.to_string(),
|
||||||
|
profile_id.to_string(),
|
||||||
|
slot.to_string(),
|
||||||
|
asset_kind.to_string(),
|
||||||
|
Some(owner_user_id.to_string()),
|
||||||
|
Some(profile_id.to_string()),
|
||||||
|
generated_at_micros,
|
||||||
|
)
|
||||||
|
.map_err(map_square_hole_asset_field_error)?,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
provider = "spacetimedb",
|
||||||
|
owner_user_id,
|
||||||
|
session_id,
|
||||||
|
profile_id,
|
||||||
|
slot,
|
||||||
|
asset_kind,
|
||||||
|
error = %error,
|
||||||
|
"方洞图片资产绑定失败,历史素材索引可能缺少绑定记录"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
provider = "spacetimedb",
|
||||||
|
owner_user_id,
|
||||||
|
session_id,
|
||||||
|
profile_id,
|
||||||
|
slot,
|
||||||
|
asset_kind,
|
||||||
|
error = %error,
|
||||||
|
"方洞图片资产对象确认失败,历史素材索引可能缺少本次记录"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(put_result.legacy_public_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_square_hole_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:?}"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_square_hole_asset_oss_error(error: platform_oss::OssError) -> AppError {
|
||||||
|
map_oss_error(error, "aliyun-oss")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_square_hole_asset_field_error(error: AssetObjectFieldError) -> AppError {
|
||||||
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||||
|
"provider": "square-hole-assets",
|
||||||
|
"message": error.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_square_hole_asset_segment(value: &str, fallback: &str) -> String {
|
||||||
|
let sanitized = value
|
||||||
|
.trim()
|
||||||
|
.chars()
|
||||||
|
.map(|ch| {
|
||||||
|
if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch) {
|
||||||
|
ch
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<String>()
|
||||||
|
.trim_matches('-')
|
||||||
|
.to_string();
|
||||||
|
if sanitized.is_empty() {
|
||||||
|
fallback.to_string()
|
||||||
|
} else {
|
||||||
|
sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_square_hole_cover_prompt(work: &SquareHoleWorkProfileRecord) -> String {
|
||||||
|
format!(
|
||||||
|
"移动端休闲游戏封面图。主题:{}。玩法反差:{}。画面主体是贴着主题图案的几何形状正在靠近不同洞口,视觉清晰、色彩明快、偏游戏资产质感。不要文字、不要 UI、不要水印。",
|
||||||
|
clean_prompt_text(&work.theme_text, "奇怪形状"),
|
||||||
|
clean_prompt_text(&work.twist_rule, "反直觉分拣")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_square_hole_background_prompt(work: &SquareHoleWorkProfileRecord) -> String {
|
||||||
|
let custom_prompt = work.background_prompt.trim();
|
||||||
|
if !custom_prompt.is_empty() {
|
||||||
|
return format!(
|
||||||
|
"移动端休闲游戏运行背景。{}。画面中央预留清晰操作空间,边缘可有主题装饰,低噪声,不要文字、不要 UI、不要水印。",
|
||||||
|
custom_prompt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"移动端休闲游戏运行背景。主题:{}。柔和纵深、玩具盒或舞台感,中央预留清晰操作空间,不要文字、不要 UI、不要水印。",
|
||||||
|
clean_prompt_text(&work.theme_text, "奇怪形状")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_square_hole_shape_prompt(
|
||||||
|
work: &SquareHoleWorkProfileRecord,
|
||||||
|
option: &SquareHoleShapeOptionRecord,
|
||||||
|
) -> String {
|
||||||
|
let image_prompt = option.image_prompt.trim();
|
||||||
|
let option_prompt = if image_prompt.is_empty() {
|
||||||
|
format!("{} 主题的 {}", work.theme_text, option.label)
|
||||||
|
} else {
|
||||||
|
image_prompt.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"单个游戏道具贴图,透明或干净浅色背景。几何形状:{}。主题贴图:{}。要求主体居中、边缘清晰、适合贴在可拖拽形状上,不要文字、不要 UI、不要水印。",
|
||||||
|
clean_prompt_text(&option.label, "形状"),
|
||||||
|
clean_prompt_text(&option_prompt, "主题图案")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_square_hole_hole_prompt(
|
||||||
|
work: &SquareHoleWorkProfileRecord,
|
||||||
|
option: &SquareHoleHoleOptionRecord,
|
||||||
|
) -> String {
|
||||||
|
let image_prompt = option.image_prompt.trim();
|
||||||
|
let option_prompt = if image_prompt.is_empty() {
|
||||||
|
format!("{} 主题的 {}", work.theme_text, option.label)
|
||||||
|
} else {
|
||||||
|
image_prompt.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"单个游戏洞口贴图,透明或干净浅色背景。洞口名称:{}。主题贴图:{}。要求主体居中、边缘清晰、适合放在可接收拖拽形状的洞口平面上,不要文字、不要 UI、不要水印。",
|
||||||
|
clean_prompt_text(&option.label, "洞口"),
|
||||||
|
clean_prompt_text(&option_prompt, "主题洞口")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_square_hole_negative_prompt() -> String {
|
||||||
|
"文字、水印、复杂 UI、真实人物、恐怖血腥、低清晰度、过度模糊、主体被裁切、多个主体".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
@@ -168,8 +168,7 @@ pub fn start_run_with_seed_at_and_item_type_count(
|
|||||||
let profile_id =
|
let profile_id =
|
||||||
normalize_required_string(profile_id).ok_or(Match3DFieldError::MissingProfileId)?;
|
normalize_required_string(profile_id).ok_or(Match3DFieldError::MissingProfileId)?;
|
||||||
|
|
||||||
let clear_count =
|
let clear_count = normalize_match3d_runtime_clear_count(config.clear_count, config.difficulty);
|
||||||
normalize_match3d_runtime_clear_count(config.clear_count, config.difficulty);
|
|
||||||
let total_item_count = clear_count
|
let total_item_count = clear_count
|
||||||
.checked_mul(MATCH3D_ITEMS_PER_CLEAR)
|
.checked_mul(MATCH3D_ITEMS_PER_CLEAR)
|
||||||
.ok_or(Match3DFieldError::InvalidClearCount)?;
|
.ok_or(Match3DFieldError::InvalidClearCount)?;
|
||||||
@@ -333,7 +332,8 @@ fn build_initial_items(
|
|||||||
) -> Vec<Match3DItemSnapshot> {
|
) -> Vec<Match3DItemSnapshot> {
|
||||||
let mut rng = DeterministicRng::new(seed ^ ((clear_count as u64) << 32) ^ difficulty as u64);
|
let mut rng = DeterministicRng::new(seed ^ ((clear_count as u64) << 32) ^ difficulty as u64);
|
||||||
let base_radius = resolve_item_radius(difficulty);
|
let base_radius = resolve_item_radius(difficulty);
|
||||||
let item_type_count = resolve_item_type_count(clear_count, difficulty, item_type_count_override);
|
let item_type_count =
|
||||||
|
resolve_item_type_count(clear_count, difficulty, item_type_count_override);
|
||||||
let selected_visual_keys = select_visual_keys(&mut rng, theme_text, item_type_count);
|
let selected_visual_keys = select_visual_keys(&mut rng, theme_text, item_type_count);
|
||||||
let size_tier_plan = resolve_size_tier_plan(item_type_count);
|
let size_tier_plan = resolve_size_tier_plan(item_type_count);
|
||||||
let mut items = Vec::with_capacity((clear_count * MATCH3D_ITEMS_PER_CLEAR) as usize);
|
let mut items = Vec::with_capacity((clear_count * MATCH3D_ITEMS_PER_CLEAR) as usize);
|
||||||
|
|||||||
@@ -2129,10 +2129,7 @@ mod tests {
|
|||||||
let phone_info = payload.phone_info.expect("phone info should exist");
|
let phone_info = payload.phone_info.expect("phone info should exist");
|
||||||
|
|
||||||
assert_eq!(phone_info.phone_number.as_deref(), Some("+8613800138000"));
|
assert_eq!(phone_info.phone_number.as_deref(), Some("+8613800138000"));
|
||||||
assert_eq!(
|
assert_eq!(phone_info.pure_phone_number.as_deref(), Some("13800138000"));
|
||||||
phone_info.pure_phone_number.as_deref(),
|
|
||||||
Some("13800138000")
|
|
||||||
);
|
|
||||||
assert_eq!(phone_info.country_code.as_deref(), Some("86"));
|
assert_eq!(phone_info.country_code.as_deref(), Some("86"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ use module_match3d::{
|
|||||||
Match3DItemSnapshot as DomainMatch3DItemSnapshot, Match3DItemState as DomainMatch3DItemState,
|
Match3DItemSnapshot as DomainMatch3DItemSnapshot, Match3DItemState as DomainMatch3DItemState,
|
||||||
Match3DRunSnapshot as DomainMatch3DRunSnapshot, Match3DRunStatus as DomainMatch3DRunStatus,
|
Match3DRunSnapshot as DomainMatch3DRunSnapshot, Match3DRunStatus as DomainMatch3DRunStatus,
|
||||||
Match3DTraySlot as DomainMatch3DTraySlot, confirm_click_at as confirm_domain_click_at,
|
Match3DTraySlot as DomainMatch3DTraySlot, confirm_click_at as confirm_domain_click_at,
|
||||||
resolve_run_timer_at as resolve_domain_run_timer_at, start_run_with_seed_at_and_item_type_count,
|
resolve_run_timer_at as resolve_domain_run_timer_at,
|
||||||
stop_run_at as stop_domain_run_at,
|
start_run_with_seed_at_and_item_type_count, stop_run_at as stop_domain_run_at,
|
||||||
};
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
@@ -1251,12 +1251,12 @@ fn validate_publishable_work(row: &Match3DWorkProfileRow) -> Result<(), String>
|
|||||||
return Err("match3d 发布需要至少 1 个标签".to_string());
|
return Err("match3d 发布需要至少 1 个标签".to_string());
|
||||||
}
|
}
|
||||||
let config = parse_config(&row.config_json)?;
|
let config = parse_config(&row.config_json)?;
|
||||||
let required_item_types =
|
let required_item_types = module_match3d::resolve_match3d_item_type_count_for_difficulty(
|
||||||
module_match3d::resolve_match3d_item_type_count_for_difficulty(
|
config.clear_count,
|
||||||
config.clear_count,
|
config.difficulty,
|
||||||
config.difficulty,
|
) as usize;
|
||||||
) as usize;
|
let ready_item_types =
|
||||||
let ready_item_types = count_ready_generated_item_types(row.generated_item_assets_json.as_deref())?;
|
count_ready_generated_item_types(row.generated_item_assets_json.as_deref())?;
|
||||||
if ready_item_types < required_item_types {
|
if ready_item_types < required_item_types {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"match3d 发布需要至少 {required_item_types} 种物品素材,当前已有 {ready_item_types} 种"
|
"match3d 发布需要至少 {required_item_types} 种物品素材,当前已有 {ready_item_types} 种"
|
||||||
|
|||||||
@@ -17,15 +17,14 @@ use module_puzzle::{
|
|||||||
PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, PuzzleRunPropInput,
|
PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, PuzzleRunPropInput,
|
||||||
PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus,
|
PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus,
|
||||||
PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput, PuzzleWorkDeleteInput,
|
PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput, PuzzleWorkDeleteInput,
|
||||||
PuzzleWorkGetInput,
|
PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput,
|
||||||
PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, PuzzleWorkPointIncentiveClaimInput,
|
PuzzleWorkPointIncentiveClaimInput, PuzzleWorkProcedureResult, PuzzleWorkProfile,
|
||||||
PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput, PuzzleWorkUpsertInput,
|
PuzzleWorkRemixInput, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult,
|
||||||
PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft,
|
apply_publish_overrides_to_draft, apply_selected_candidate, build_form_draft_from_seed,
|
||||||
apply_selected_candidate, build_form_draft_from_seed, build_result_preview,
|
build_result_preview, compile_result_draft_from_seed, create_work_profile, infer_anchor_pack,
|
||||||
compile_result_draft_from_seed, create_work_profile, infer_anchor_pack, normalize_puzzle_draft,
|
normalize_puzzle_draft, normalize_puzzle_levels, normalize_theme_tags, publish_work_profile,
|
||||||
normalize_puzzle_levels, normalize_theme_tags, publish_work_profile, replace_puzzle_level,
|
replace_puzzle_level, select_next_profiles, selected_profile_level_after_runtime_level,
|
||||||
select_next_profiles, selected_profile_level_after_runtime_level, selected_puzzle_level,
|
selected_puzzle_level, tag_similarity_score,
|
||||||
tag_similarity_score,
|
|
||||||
};
|
};
|
||||||
use module_runtime::RuntimeProfileWalletLedgerSourceType;
|
use module_runtime::RuntimeProfileWalletLedgerSourceType;
|
||||||
use module_runtime::visible_runtime_profile_user_tags;
|
use module_runtime::visible_runtime_profile_user_tags;
|
||||||
@@ -1062,12 +1061,10 @@ fn save_puzzle_ui_background_tx(
|
|||||||
let mut next_level = target_level;
|
let mut next_level = target_level;
|
||||||
next_level.ui_background_prompt = Some(input.prompt.trim().to_string());
|
next_level.ui_background_prompt = Some(input.prompt.trim().to_string());
|
||||||
next_level.ui_background_image_src = Some(input.image_src.trim().to_string());
|
next_level.ui_background_image_src = Some(input.image_src.trim().to_string());
|
||||||
next_level.ui_background_image_object_key = input
|
next_level.ui_background_image_object_key = input.image_object_key.and_then(|value| {
|
||||||
.image_object_key
|
let trimmed = value.trim().to_string();
|
||||||
.and_then(|value| {
|
(!trimmed.is_empty()).then_some(trimmed)
|
||||||
let trimmed = value.trim().to_string();
|
});
|
||||||
(!trimmed.is_empty()).then_some(trimmed)
|
|
||||||
});
|
|
||||||
let draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
|
let draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
|
||||||
|
|
||||||
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
|
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
pub mod analytics_date_dimension;
|
pub mod analytics_date_dimension;
|
||||||
pub mod creation_entry_config;
|
|
||||||
mod browse_history;
|
mod browse_history;
|
||||||
|
pub mod creation_entry_config;
|
||||||
mod profile;
|
mod profile;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod snapshots;
|
mod snapshots;
|
||||||
|
|
||||||
pub use analytics_date_dimension::*;
|
pub use analytics_date_dimension::*;
|
||||||
pub use creation_entry_config::*;
|
|
||||||
pub use browse_history::*;
|
pub use browse_history::*;
|
||||||
|
pub use creation_entry_config::*;
|
||||||
pub use profile::*;
|
pub use profile::*;
|
||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
pub use snapshots::*;
|
pub use snapshots::*;
|
||||||
|
|||||||
Reference in New Issue
Block a user