diff --git a/docs/technical/README.md b/docs/technical/README.md index 5faa5eb3..358dce20 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -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 和后续原生化边界。 - [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 输入适配、移动端权限降级和后续测试验证命令。 diff --git a/docs/technical/【后端架构】api-server大Handler瘦身执行计划-2026-05-14.md b/docs/technical/【后端架构】api-server大Handler瘦身执行计划-2026-05-14.md new file mode 100644 index 00000000..6502fcdc --- /dev/null +++ b/docs/technical/【后端架构】api-server大Handler瘦身执行计划-2026-05-14.md @@ -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)`、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 状态。 diff --git a/docs/technical/【后端架构】api-server能力模块化与生成资产Adapter总纲-2026-05-14.md b/docs/technical/【后端架构】api-server能力模块化与生成资产Adapter总纲-2026-05-14.md new file mode 100644 index 00000000..99a81aa0 --- /dev/null +++ b/docs/technical/【后端架构】api-server能力模块化与生成资产Adapter总纲-2026-05-14.md @@ -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//router.rs` 暴露 `router(state) -> Router`,迁移时保持原 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 +``` diff --git a/docs/technical/【后端架构】api-server路由能力模块化执行计划-2026-05-14.md b/docs/technical/【后端架构】api-server路由能力模块化执行计划-2026-05-14.md new file mode 100644 index 00000000..aab1d62a --- /dev/null +++ b/docs/technical/【后端架构】api-server路由能力模块化执行计划-2026-05-14.md @@ -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 +``` + +第一阶段 handler 实现仍可留在原文件;本阶段只改变路由装配位置。`app.rs` 最终只负责: + +- 构建 shared state。 +- 注入全局 CORS、TraceLayer、request context、tracking/auth middleware。 +- `.merge(modules::::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:/healthz +``` + +禁止用 `api-server:maincloud` 作为 smoke。 diff --git a/docs/technical/【后端架构】复杂媒体资产链路Adapter扩展计划-2026-05-14.md b/docs/technical/【后端架构】复杂媒体资产链路Adapter扩展计划-2026-05-14.md new file mode 100644 index 00000000..d25fbc9c --- /dev/null +++ b/docs/technical/【后端架构】复杂媒体资产链路Adapter扩展计划-2026-05-14.md @@ -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 +``` + +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 与本系列总纲状态同步更新。 diff --git a/docs/technical/【后端架构】生成图片资产Adapter收口执行计划-2026-05-14.md b/docs/technical/【后端架构】生成图片资产Adapter收口执行计划-2026-05-14.md new file mode 100644 index 00000000..706d33ba --- /dev/null +++ b/docs/technical/【后端架构】生成图片资产Adapter收口执行计划-2026-05-14.md @@ -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 +``` + +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。 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 85c86f4a..fc4d342b 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1,11 +1,11 @@ use axum::{ Router, body::Body, - extract::{DefaultBodyLimit, Extension}, + extract::Extension, http::Request, middleware, response::Response, - routing::{delete, get, post, put}, + routing::{get, post}, }; use tower_http::{ classify::ServerErrorsFailureClass, @@ -14,148 +14,14 @@ use tower_http::{ use tracing::{Level, Span, error, info, info_span, warn}; use crate::{ - admin::{ - admin_debug_http, admin_get_creation_entry_config, admin_list_database_table_rows, - admin_list_database_tables, admin_list_tracking_events, admin_login, admin_me, - admin_overview, admin_upsert_creation_entry_config, require_admin_auth, - }, - 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, - }, - 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::{ - AuthenticatedAccessToken, attach_refresh_session_token, inspect_auth_claims, - inspect_refresh_session_cookie, 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}, - 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, - }, - 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, require_creation_entry_route_enabled, - }, - 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, - }, - 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, - }, + auth::{AuthenticatedAccessToken, require_bearer_auth}, + creation_entry_config::require_creation_entry_route_enabled, error_middleware::normalize_error_response, - health::health_check, - hyper3d_generation::{ - get_hyper3d_downloads, get_hyper3d_task_status, submit_hyper3d_image_to_model, - submit_hyper3d_text_to_model, - }, - llm::proxy_llm_chat_completions, - login_options::auth_login_options, - logout::logout, - logout_all::logout_all, - 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, - }, - password_entry::password_entry, - password_management::{change_password, reset_password}, - phone_auth::{phone_login, send_phone_code}, - profile_identity::update_profile_identity, - 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, - }, - refresh_session::refresh_session, + modules, request_context::{RequestContext, attach_request_context, resolve_request_id}, response_headers::propagate_request_id_header, - runtime_browse_history::{ - delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history, - }, - 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_inventory::get_runtime_inventory_state, - 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, - 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::{ - delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives, - put_runtime_snapshot, resume_profile_save_archive, - }, - runtime_settings::{get_runtime_settings, put_runtime_settings}, - 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, - 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, - }, tracking::record_route_tracking_event_after_success, vector_engine_audio_generation::{ create_background_music_task, create_sound_effect_task, @@ -171,1314 +37,32 @@ use crate::{ start_visual_novel_run, stream_visual_novel_action, stream_visual_novel_message, submit_visual_novel_message, update_visual_novel_work, }, - volcengine_speech::{ - get_volcengine_speech_config, stream_volcengine_asr, stream_volcengine_tts_bidirection, - stream_volcengine_tts_sse, - }, - wechat_auth::{ - bind_wechat_phone, handle_wechat_callback, login_wechat_mini_program, start_wechat_login, - }, wechat_pay::handle_wechat_pay_notify, }; -const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; -const PROFILE_FEEDBACK_BODY_LIMIT_BYTES: usize = 6 * 1024 * 1024; -const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024; - // 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。 pub fn build_router(state: AppState) -> Router { let slow_request_threshold_ms = state.config.slow_request_threshold_ms; Router::new() - .route("/admin/api/login", 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", - 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", - 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", - post(admin_disable_profile_task_config).route_layer(middleware::from_fn_with_state( - state.clone(), - require_admin_auth, - )), - ) - .route( - "/healthz", - get(|Extension(request_context): Extension<_>| async move { - health_check(Extension(request_context)).await - }), - ) - .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, - )), - ) - .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/profile/me", - axum::routing::patch(update_profile_identity).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/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/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, - )), - ) - .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/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/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/read-url", get(get_asset_read_url)) - .route("/api/assets/read-bytes", get(get_asset_read_bytes)) - .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/assets/history", - get(get_asset_history).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, - )), - ) - .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/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, - )), - ) - .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, - )), - ) - .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, - )), - ) - .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, - )), - ) - .merge(creative_agent_router(state.clone())) + .merge(modules::admin::router(state.clone())) + .merge(modules::health::router(state.clone())) + .merge(modules::internal::router(state.clone())) + .merge(modules::auth::router(state.clone())) + .merge(modules::profile::router(state.clone())) + .merge(modules::assets::router(state.clone())) + .merge(modules::platform::router(state.clone())) + .merge(modules::story::router(state.clone())) + .merge(modules::custom_world::router(state.clone())) + .merge(modules::big_fish::router(state.clone())) + .merge(modules::match3d::router(state.clone())) + .merge(modules::square_hole::router(state.clone())) + .merge(modules::puzzle::router(state.clone())) .merge(visual_novel_router(state.clone())) - .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, - )), - ) - .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, - )), - ) - .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/recharge/wechat/notify", post(handle_wechat_pay_notify), ) - .route( - "/api/profile/feedback", - post(submit_profile_feedback) - // 中文注释:反馈首版允许最多四张 1MB Data URL 图片,只给该接口放宽 body limit。 - .layer(DefaultBodyLimit::max(PROFILE_FEEDBACK_BODY_LIMIT_BYTES)) - .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/runtime/sessions/{runtime_session_id}/inventory", get(get_runtime_inventory_state).route_layer(middleware::from_fn_with_state( @@ -1486,92 +70,6 @@ pub fn build_router(state: AppState) -> Router { 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, - )), - ) - .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/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)) // 后端创作/运行态 API 路由只按 open 做熔断;visible 仅控制创作页入口展示。 .layer(middleware::from_fn_with_state( state.clone(), @@ -1673,65 +171,6 @@ async fn record_api_tracking_after_success( response } -fn creative_agent_router(state: AppState) -> Router { - Router::new() - .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)), - ) -} - fn visual_novel_router(state: AppState) -> Router { Router::new() .route( diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index dcf1928d..1ba28ec5 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -17,7 +17,7 @@ use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, }; -use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest}; +use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; use serde_json::{Map, Value, json}; use shared_contracts::big_fish::{ BigFishActionResponse, BigFishAgentMessageResponse, BigFishAnchorItemResponse, @@ -52,6 +52,10 @@ use crate::prompt::big_fish::{ build_big_fish_level_main_image_prompt, build_big_fish_level_motion_prompt, build_big_fish_stage_background_prompt, }; +use crate::generated_image_assets::{ + GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, adapter::GeneratedImageAssetAdapterMetadata, + adapter::GeneratedImageAssetPersistInput, normalize_generated_image_asset_mime, +}; use crate::{ ai_generation_drafts::{ AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter, @@ -835,979 +839,13 @@ pub async fn execute_big_fish_action( )) } -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, - } -} +mod mappers; -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), - } -} +use mappers::*; -fn map_big_fish_anchor_item_response(anchor: BigFishAnchorItemRecord) -> BigFishAnchorItemResponse { - BigFishAnchorItemResponse { - key: anchor.key, - label: anchor.label, - value: anchor.value, - status: anchor.status, - } -} +mod formal_assets; -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), - } -} - -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, - } -} - -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, - } -} - -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, - } -} - -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, - } -} - -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, - } -} - -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, - } -} - -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, - } -} - -fn map_big_fish_vector2_response(vector: BigFishVector2Record) -> BigFishVector2Response { - BigFishVector2Response { - x: vector.x, - y: vector.y, - } -} - -async fn compile_big_fish_draft_only( - state: &AppState, - session_id: String, - owner_user_id: String, - now: i64, -) -> Result { - // 中文注释:大鱼吃小鱼草稿阶段只负责编译结果页草稿,不在这一步串行生成主图、动作或背景。 - // 这些资产统一留在结果页工坊按需触发,避免 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 -} - -async fn load_big_fish_session_with_retry( - state: &AppState, - session_id: String, - owner_user_id: String, -) -> Result { - 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)) -} - -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), - } -} - -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, - }, - } -} - -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, - } -} - -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, - } -} - -fn build_big_fish_welcome_text(seed_text: &str) -> String { - if seed_text.trim().is_empty() { - return "我会先帮你确定大鱼吃小鱼的核心锚点。可以从主题生态、成长阶梯或风险节奏开始。" - .to_string(); - } - "我已经收到你的玩法起点,会先把它整理成锚点并准备结果页草稿。".to_string() -} - -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, - extension: String, - bytes: Vec, -} - -struct BigFishFormalAssetContext { - entity_id: String, - prompt: String, - negative_prompt: String, - size: String, - asset_object_kind: String, - binding_slot: String, - path_segments: Vec, - 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"; - -async fn generate_big_fish_formal_asset( - state: &AppState, - owner_user_id: &str, - session_id: &str, - asset_kind: &str, - level: Option, - motion_key: Option<&str>, - generated_at_micros: i64, -) -> Result { - 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, - motion_key: Option<&str>, - generated_at_micros: i64, -) -> Result { - 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, -) -> 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 { - 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::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 { - 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 { - 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; - let mut extension = big_fish_mime_to_extension(normalized_mime_type.as_str()).to_string(); - - // 中文注释: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(); - extension = "png".to_string(); - } - - Ok(BigFishDownloadedImage { - extension, - 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 { - 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: LegacyAssetPrefix::BigFishAssets, - path_segments: context.path_segments.clone(), - file_name: format!("image.{}", downloaded.extension), - content_type: Some(downloaded.mime_type.clone()), - access: OssObjectAccess::Private, - metadata: build_big_fish_asset_metadata( - context.asset_object_kind.as_str(), - owner_user_id, - BIG_FISH_ENTITY_KIND, - context.entity_id.as_str(), - context.binding_slot.as_str(), - ), - body: downloaded.bytes, - }, - ) - .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(downloaded.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 build_big_fish_asset_metadata( - asset_kind: &str, - owner_user_id: &str, - entity_kind: &str, - entity_id: &str, - slot: &str, -) -> BTreeMap { - 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()), - ]) -} - -fn parse_big_fish_json_payload(raw_text: &str, fallback_message: &str) -> Result { - serde_json::from_str::(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 { - find_first_big_fish_string_by_key(payload, "task_id") -} - -fn extract_big_fish_image_urls(payload: &Value) -> Vec { - 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 { - 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) { - 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 big_fish_mime_to_extension(mime_type: &str) -> &str { - match mime_type { - "image/png" => "png", - "image/webp" => "webp", - "image/gif" => "gif", - _ => "jpg", - } -} - -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::(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::(); - 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) -> String { - level - .map(|value| format!("level-{value}")) - .unwrap_or_else(|| "stage".to_string()) -} +use formal_assets::generate_big_fish_formal_asset; fn sanitize_big_fish_path_segment(value: &str, fallback: &str) -> String { let sanitized = value diff --git a/server-rs/crates/api-server/src/big_fish/formal_assets.rs b/server-rs/crates/api-server/src/big_fish/formal_assets.rs new file mode 100644 index 00000000..b2acce6a --- /dev/null +++ b/server-rs/crates/api-server/src/big_fish/formal_assets.rs @@ -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, +} + +struct BigFishFormalAssetContext { + entity_id: String, + prompt: String, + negative_prompt: String, + size: String, + asset_object_kind: String, + binding_slot: String, + path_segments: Vec, + 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, + motion_key: Option<&str>, + generated_at_micros: i64, +) -> Result { + 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, + motion_key: Option<&str>, + generated_at_micros: i64, +) -> Result { + 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, +) -> 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 { + 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::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 { + 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 { + 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 { + 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 { + serde_json::from_str::(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 { + find_first_big_fish_string_by_key(payload, "task_id") +} + +fn extract_big_fish_image_urls(payload: &Value) -> Vec { + 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 { + 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) { + 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::(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::(); + 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) -> String { + level + .map(|value| format!("level-{value}")) + .unwrap_or_else(|| "stage".to_string()) +} + diff --git a/server-rs/crates/api-server/src/big_fish/mappers.rs b/server-rs/crates/api-server/src/big_fish/mappers.rs new file mode 100644 index 00000000..160a207c --- /dev/null +++ b/server-rs/crates/api-server/src/big_fish/mappers.rs @@ -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 { + // 中文注释:大鱼吃小鱼草稿阶段只负责编译结果页草稿,不在这一步串行生成主图、动作或背景。 + // 这些资产统一留在结果页工坊按需触发,避免 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 { + 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() +} + + diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index 23fc5136..d5fcb403 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -2770,383 +2770,9 @@ async fn upsert_custom_world_draft_foundation_progress( }) } -fn map_custom_world_library_entry_response( - state: &AppState, - entry: CustomWorldLibraryEntryRecord, -) -> CustomWorldLibraryEntryResponse { - let author = resolve_work_author_by_user_id( - state, - &entry.owner_user_id, - Some(&entry.author_display_name), - entry.author_public_user_code.as_deref(), - ); - CustomWorldLibraryEntryResponse { - owner_user_id: entry.owner_user_id, - profile_id: entry.profile_id, - public_work_code: entry.public_work_code, - author_public_user_code: author.public_user_code.or(entry.author_public_user_code), - profile: entry.profile, - visibility: entry.visibility, - published_at: entry.published_at, - updated_at: entry.updated_at, - author_display_name: author.display_name, - world_name: entry.world_name, - subtitle: entry.subtitle, - summary_text: entry.summary_text, - cover_image_src: entry.cover_image_src, - theme_mode: entry.theme_mode, - playable_npc_count: entry.playable_npc_count, - landmark_count: entry.landmark_count, - play_count: entry.play_count, - remix_count: entry.remix_count, - like_count: entry.like_count, - recent_play_count_7d: 0, - } -} +mod mappers; -fn map_custom_world_library_entry_response_from_work_summary( - state: &AppState, - item: CustomWorldWorkSummaryRecord, - owner_user_id: &str, -) -> Option { - 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::(); - 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.as_object().and_then(|object| { - if object.is_empty() { - None - } else { - Some(Value::Object(object.clone())) - } - }) -} +use mappers::*; fn log_custom_world_publish_gate_diagnostics( source: &str, diff --git a/server-rs/crates/api-server/src/custom_world/mappers.rs b/server-rs/crates/api-server/src/custom_world/mappers.rs new file mode 100644 index 00000000..26c5288b --- /dev/null +++ b/server-rs/crates/api-server/src/custom_world/mappers.rs @@ -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 { + 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::(); + 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.as_object().and_then(|object| { + if object.is_empty() { + None + } else { + Some(Value::Object(object.clone())) + } + }) +} + diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index 8bed094a..12280439 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -17,8 +17,7 @@ use module_assets::{ }; use platform_llm::{LlmMessage, LlmTextRequest}; use platform_oss::{ - LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest, - OssSignedGetObjectUrlRequest, + LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssSignedGetObjectUrlRequest, }; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; @@ -26,6 +25,11 @@ use spacetime_client::SpacetimeClientError; use tokio::time::sleep; use webp::Encoder as WebpEncoder; +use crate::generated_image_assets::{ + GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, + adapter::GeneratedImageAssetAdapterMetadata, adapter::GeneratedImageAssetPersistInput, + normalize_generated_image_asset_mime, +}; use crate::{ api_response::json_success_body, 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( - state: &AppState, - owner_user_id: &str, - upload: PreparedAssetUpload, - mut response: GeneratedAssetResponse, -) -> Result { - 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) -} +mod assets; -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 { - 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?; +use assets::persist_custom_world_asset; - Ok(GeneratedOpeningCgStoryboard { - image_src: asset.image_src, - asset_id, - }) -} +mod opening_cg; -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 { - 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, - video: DownloadedRemoteVideo, -) -> Result { - 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 { - 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 { - 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 { - 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 { - 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") -} +use opening_cg::{ + generate_opening_cg_storyboard, generate_opening_cg_video, map_custom_world_asset_oss_error, +}; async fn generate_entity_with_fallback(state: &AppState, profile: &Value, kind: &str) -> Value { let fallback = build_entity_fallback(profile, kind); diff --git a/server-rs/crates/api-server/src/custom_world_ai/assets.rs b/server-rs/crates/api-server/src/custom_world_ai/assets.rs new file mode 100644 index 00000000..bb0c58d9 --- /dev/null +++ b/server-rs/crates/api-server/src/custom_world_ai/assets.rs @@ -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 { + 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) +} + diff --git a/server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs b/server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs new file mode 100644 index 00000000..bcfc305a --- /dev/null +++ b/server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs @@ -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 { + 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 { + 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, + video: DownloadedRemoteVideo, +) -> Result { + 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 { + 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 { + 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 { + 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") +} + diff --git a/server-rs/crates/api-server/src/generated_image_assets/adapter.rs b/server-rs/crates/api-server/src/generated_image_assets/adapter.rs new file mode 100644 index 00000000..76432896 --- /dev/null +++ b/server-rs/crates/api-server/src/generated_image_assets/adapter.rs @@ -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, + pub(crate) file_stem: String, + pub(crate) image: GeneratedImageAssetDataUrl, + pub(crate) access: OssObjectAccess, + pub(crate) metadata: GeneratedImageAssetAdapterMetadata, + pub(crate) extra_metadata: BTreeMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct GeneratedImageAssetAdapterMetadata { + pub(crate) asset_kind: Option, + pub(crate) owner_user_id: Option, + pub(crate) entity_kind: Option, + pub(crate) entity_id: Option, + pub(crate) slot: Option, + pub(crate) provider: Option, + pub(crate) task_id: Option, +} + +#[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 { + 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 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()) + ); + } +} diff --git a/server-rs/crates/api-server/src/generated_image_assets/helpers.rs b/server-rs/crates/api-server/src/generated_image_assets/helpers.rs new file mode 100644 index 00000000..f12c98a2 --- /dev/null +++ b/server-rs/crates/api-server/src/generated_image_assets/helpers.rs @@ -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, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct GeneratedImageAssetMetadataInput { + pub(crate) asset_kind: Option, + pub(crate) owner_user_id: Option, + pub(crate) entity_kind: Option, + pub(crate) entity_id: Option, + pub(crate) slot: Option, + pub(crate) provider: Option, + pub(crate) task_id: Option, +} + +#[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, +) -> 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 { + 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 { + 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 { + 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, + overlay: BTreeMap, +) -> BTreeMap { + 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, + key: &str, + value: Option, +) { + 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::() + .trim_matches('-') + .to_string() +} + +fn sanitize_generated_image_asset_file_name( + raw: &str, +) -> Result { + 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())); + } +} diff --git a/server-rs/crates/api-server/src/generated_image_assets/mod.rs b/server-rs/crates/api-server/src/generated_image_assets/mod.rs new file mode 100644 index 00000000..ba1aad6d --- /dev/null +++ b/server-rs/crates/api-server/src/generated_image_assets/mod.rs @@ -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, +}; diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 28d8f091..5107c628 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -35,6 +35,7 @@ mod custom_world_foundation_draft; mod custom_world_result_prompts; mod custom_world_rpg_draft_prompts; mod error_middleware; +mod generated_image_assets; mod health; mod http_error; mod hyper3d_generation; @@ -44,6 +45,7 @@ mod login_options; mod logout; mod logout_all; mod match3d; +mod modules; mod openai_image_generation; mod password_entry; mod password_management; diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index 5cb41bd2..974a17c9 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -2137,493 +2137,9 @@ async fn persist_match3d_generated_item_assets_snapshot( .map(|_| ()) } -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, - } -} +mod mappers; -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 -} - -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 { - assets - .iter() - .find_map(|asset| asset.background_asset.clone()) -} - -fn resolve_match3d_default_cover_image_src(assets: &[Match3DGeneratedItemAsset]) -> Option { - 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 { - assets - .iter() - .find_map(|asset| asset.background_asset.clone()) -} - -fn apply_match3d_background_asset_to_agent_draft( - draft: &mut Match3DResultDraftResponse, - background_asset: Option, -) { - 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) -> 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) -> 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 { - 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), - } -} +use mappers::*; fn build_config_from_create_request( payload: &CreateMatch3DAgentSessionRequest, @@ -2861,175 +2377,9 @@ fn normalize_optional_match3d_text(value: Option) -> Option { .filter(|value| !value.is_empty()) } -fn normalize_match3d_tag(value: &str) -> String { - let trimmed = value.trim(); - let without_number_prefix = trimmed - .char_indices() - .find_map(|(index, ch)| { - if index == 0 || !matches!(ch, '.' | '、' | ')' | ')') { - return None; - } - let prefix = &trimmed[..index]; - if prefix.chars().all(|candidate| candidate.is_ascii_digit()) { - Some(trimmed[index + ch.len_utf8()..].trim_start()) - } else { - None - } - }) - .unwrap_or(trimmed); +mod tags; - without_number_prefix - .trim_matches(|ch: char| { - ch.is_ascii_punctuation() - || matches!( - ch, - ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' - ) - }) - .trim() - .chars() - .filter(|ch| !matches!(ch, '#' | '"' | '\'' | '`')) - .collect::() - .chars() - .take(6) - .collect::() -} - -fn normalize_match3d_tag_candidates(candidates: impl IntoIterator) -> Vec -where - S: AsRef, -{ - let mut tags = Vec::new(); - for candidate in candidates { - let normalized = normalize_match3d_tag(candidate.as_ref()); - if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) { - continue; - } - tags.push(normalized); - if tags.len() >= 6 { - break; - } - } - for fallback in ["抓大鹅", "经典消除", "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 { - 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> { - 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 { - 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 { - 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 { - let raw = raw.trim(); - let json_text = if let Some(start) = raw.find('[') - && let Some(end) = raw.rfind(']') - && end > start - { - &raw[start..=end] - } else { - raw - }; - let parsed = serde_json::from_str::>(json_text).unwrap_or_default(); - normalize_match3d_tag_candidates(parsed) -} - -fn fallback_match3d_work_tags(game_name: &str, theme_text: &str) -> Vec { - normalize_match3d_tag_candidates([theme_text, game_name, "抓大鹅", "经典消除", "2D素材"]) -} +use tags::*; fn serialize_match3d_generated_item_assets(assets: &[Match3DGeneratedItemAsset]) -> Option { 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| { - match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error) - })?; + let title = require_match3d_background_music_title(plan) + .map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?; let style = normalize_match3d_audio_style(plan.style.as_str()); match generate_match3d_background_music_asset(state, owner_user_id, profile_id, &title, &style) .await @@ -6556,12 +5905,13 @@ mod tests { #[test] fn match3d_background_music_title_is_required_for_auto_draft() { - let missing = require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan { - title: " ,。 ".to_string(), - style: "轻快, 休闲".to_string(), - prompt: String::new(), - }) - .expect_err("自动草稿背景音乐必须有可提交给 Suno 的曲名"); + let missing = + require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan { + title: " ,。 ".to_string(), + style: "轻快, 休闲".to_string(), + prompt: String::new(), + }) + .expect_err("自动草稿背景音乐必须有可提交给 Suno 的曲名"); assert!(missing.body_text().contains("背景音乐")); diff --git a/server-rs/crates/api-server/src/match3d/mappers.rs b/server-rs/crates/api-server/src/match3d/mappers.rs new file mode 100644 index 00000000..23640f6e --- /dev/null +++ b/server-rs/crates/api-server/src/match3d/mappers.rs @@ -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 { + assets + .iter() + .find_map(|asset| asset.background_asset.clone()) +} + +pub(super) fn resolve_match3d_default_cover_image_src(assets: &[Match3DGeneratedItemAsset]) -> Option { + 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 { + 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, +) { + 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) -> 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) -> 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 { + 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), + } +} + diff --git a/server-rs/crates/api-server/src/match3d/tags.rs b/server-rs/crates/api-server/src/match3d/tags.rs new file mode 100644 index 00000000..c71ae3cd --- /dev/null +++ b/server-rs/crates/api-server/src/match3d/tags.rs @@ -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::() + .chars() + .take(6) + .collect::() +} + +pub(super) fn normalize_match3d_tag_candidates(candidates: impl IntoIterator) -> Vec +where + S: AsRef, +{ + let mut tags = Vec::new(); + for candidate in candidates { + let normalized = normalize_match3d_tag(candidate.as_ref()); + if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) { + continue; + } + tags.push(normalized); + if tags.len() >= 6 { + break; + } + } + for fallback in ["抓大鹅", "经典消除", "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 { + 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> { + 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 { + 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 { + 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 { + let raw = raw.trim(); + let json_text = if let Some(start) = raw.find('[') + && let Some(end) = raw.rfind(']') + && end > start + { + &raw[start..=end] + } else { + raw + }; + let parsed = serde_json::from_str::>(json_text).unwrap_or_default(); + normalize_match3d_tag_candidates(parsed) +} + +pub(super) fn fallback_match3d_work_tags(game_name: &str, theme_text: &str) -> Vec { + normalize_match3d_tag_candidates([theme_text, game_name, "抓大鹅", "经典消除", "2D素材"]) +} + diff --git a/server-rs/crates/api-server/src/modules/admin.rs b/server-rs/crates/api-server/src/modules/admin.rs new file mode 100644 index 00000000..a3afe62d --- /dev/null +++ b/server-rs/crates/api-server/src/modules/admin.rs @@ -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 { + 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)), + ) +} diff --git a/server-rs/crates/api-server/src/modules/assets.rs b/server-rs/crates/api-server/src/modules/assets.rs new file mode 100644 index 00000000..a03e8372 --- /dev/null +++ b/server-rs/crates/api-server/src/modules/assets.rs @@ -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 { + 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, + )), + ) +} diff --git a/server-rs/crates/api-server/src/modules/auth.rs b/server-rs/crates/api-server/src/modules/auth.rs new file mode 100644 index 00000000..d3455b39 --- /dev/null +++ b/server-rs/crates/api-server/src/modules/auth.rs @@ -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 { + 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, + )), + ) +} diff --git a/server-rs/crates/api-server/src/modules/big_fish.rs b/server-rs/crates/api-server/src/modules/big_fish.rs new file mode 100644 index 00000000..9ce7bbd2 --- /dev/null +++ b/server-rs/crates/api-server/src/modules/big_fish.rs @@ -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 { + 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, + )), + ) +} diff --git a/server-rs/crates/api-server/src/modules/custom_world.rs b/server-rs/crates/api-server/src/modules/custom_world.rs new file mode 100644 index 00000000..e747f98e --- /dev/null +++ b/server-rs/crates/api-server/src/modules/custom_world.rs @@ -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 { + 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, + )), + ) +} diff --git a/server-rs/crates/api-server/src/modules/health.rs b/server-rs/crates/api-server/src/modules/health.rs new file mode 100644 index 00000000..5e2f19ac --- /dev/null +++ b/server-rs/crates/api-server/src/modules/health.rs @@ -0,0 +1,7 @@ +use axum::{Router, routing::get}; + +use crate::{health::health_check, state::AppState}; + +pub fn router(_state: AppState) -> Router { + Router::new().route("/healthz", get(health_check)) +} diff --git a/server-rs/crates/api-server/src/modules/internal.rs b/server-rs/crates/api-server/src/modules/internal.rs new file mode 100644 index 00000000..c45ca5d7 --- /dev/null +++ b/server-rs/crates/api-server/src/modules/internal.rs @@ -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 { + 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, + )), + ) +} diff --git a/server-rs/crates/api-server/src/modules/match3d.rs b/server-rs/crates/api-server/src/modules/match3d.rs new file mode 100644 index 00000000..db88f0c3 --- /dev/null +++ b/server-rs/crates/api-server/src/modules/match3d.rs @@ -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 { + 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, + )), + ) +} diff --git a/server-rs/crates/api-server/src/modules/mod.rs b/server-rs/crates/api-server/src/modules/mod.rs new file mode 100644 index 00000000..95fbf028 --- /dev/null +++ b/server-rs/crates/api-server/src/modules/mod.rs @@ -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; diff --git a/server-rs/crates/api-server/src/modules/platform.rs b/server-rs/crates/api-server/src/modules/platform.rs new file mode 100644 index 00000000..12efa9f0 --- /dev/null +++ b/server-rs/crates/api-server/src/modules/platform.rs @@ -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 { + 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, + )), + ) +} diff --git a/server-rs/crates/api-server/src/modules/profile.rs b/server-rs/crates/api-server/src/modules/profile.rs new file mode 100644 index 00000000..670f4931 --- /dev/null +++ b/server-rs/crates/api-server/src/modules/profile.rs @@ -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 { + 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, + )), + ) +} diff --git a/server-rs/crates/api-server/src/modules/puzzle.rs b/server-rs/crates/api-server/src/modules/puzzle.rs new file mode 100644 index 00000000..55197b0d --- /dev/null +++ b/server-rs/crates/api-server/src/modules/puzzle.rs @@ -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 { + 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, + )), + ) +} diff --git a/server-rs/crates/api-server/src/modules/square_hole.rs b/server-rs/crates/api-server/src/modules/square_hole.rs new file mode 100644 index 00000000..93abd129 --- /dev/null +++ b/server-rs/crates/api-server/src/modules/square_hole.rs @@ -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 { + 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, + )), + ) +} diff --git a/server-rs/crates/api-server/src/modules/story.rs b/server-rs/crates/api-server/src/modules/story.rs new file mode 100644 index 00000000..34d85255 --- /dev/null +++ b/server-rs/crates/api-server/src/modules/story.rs @@ -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 { + 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)), + ) +} diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index f13d6a29..c3f010f9 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -2092,532 +2092,9 @@ pub async fn submit_puzzle_leaderboard( )) } -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, - } -} +mod mappers; -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), - } -} - -fn map_puzzle_anchor_item_response(anchor: PuzzleAnchorItemRecord) -> PuzzleAnchorItemResponse { - PuzzleAnchorItemResponse { - key: anchor.key, - label: anchor.label, - value: anchor.value, - status: anchor.status, - } -} - -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), - } -} - -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, - } -} - -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, - } -} - -fn map_puzzle_audio_asset_record_response(asset: PuzzleAudioAssetRecord) -> CreationAudioAsset { - CreationAudioAsset { - task_id: asset.task_id, - provider: asset.provider, - asset_object_id: asset.asset_object_id, - asset_kind: asset.asset_kind, - audio_src: asset.audio_src, - prompt: asset.prompt, - title: asset.title, - updated_at: asset.updated_at, - } -} - -fn map_puzzle_audio_asset_domain_record( - asset: module_puzzle::PuzzleAudioAsset, -) -> PuzzleAudioAssetRecord { - PuzzleAudioAssetRecord { - task_id: asset.task_id, - provider: asset.provider, - asset_object_id: asset.asset_object_id, - asset_kind: asset.asset_kind, - audio_src: asset.audio_src, - prompt: asset.prompt, - title: asset.title, - updated_at: asset.updated_at, - } -} - -fn puzzle_audio_asset_response_module_json(asset: &Option) -> Value { - asset - .as_ref() - .map(|asset| { - json!({ - "task_id": asset.task_id, - "provider": asset.provider, - "asset_object_id": asset.asset_object_id, - "asset_kind": asset.asset_kind, - "audio_src": asset.audio_src, - "prompt": asset.prompt, - "title": asset.title, - "updated_at": asset.updated_at, - }) - }) - .unwrap_or(Value::Null) -} - -fn puzzle_audio_asset_record_module_json(asset: &Option) -> Value { - asset - .as_ref() - .map(|asset| { - json!({ - "task_id": asset.task_id, - "provider": asset.provider, - "asset_object_id": asset.asset_object_id, - "asset_kind": asset.asset_kind, - "audio_src": asset.audio_src, - "prompt": asset.prompt, - "title": asset.title, - "updated_at": asset.updated_at, - }) - }) - .unwrap_or(Value::Null) -} - -fn map_puzzle_creator_intent_response( - intent: PuzzleCreatorIntentRecord, -) -> PuzzleCreatorIntentResponse { - 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, - } -} - -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, - } -} - -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, - } -} - -fn map_puzzle_suggested_action_response( - action: PuzzleAgentSuggestedActionRecord, -) -> PuzzleAgentSuggestedActionResponse { - PuzzleAgentSuggestedActionResponse { - id: action.action_id, - action_type: action.action_type, - label: action.label, - } -} - -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, - } -} - -fn map_puzzle_result_preview_blocker_response( - blocker: PuzzleResultPreviewBlockerRecord, -) -> PuzzleResultPreviewBlockerResponse { - PuzzleResultPreviewBlockerResponse { - id: blocker.blocker_id, - code: blocker.code, - message: blocker.message, - } -} - -fn map_puzzle_result_preview_finding_response( - finding: PuzzleResultPreviewFindingRecord, -) -> PuzzleResultPreviewFindingResponse { - PuzzleResultPreviewFindingResponse { - id: finding.finding_id, - severity: finding.severity, - code: finding.code, - message: finding.message, - } -} - -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(), - } -} - -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), - } -} - -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(), - } -} - -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, - } -} - -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 -} - -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 } -} - -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, - } -} - -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, - } -} - -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()) -} - -fn build_puzzle_welcome_text(seed_text: &str) -> String { - if seed_text.trim().is_empty() { - return "拼图创作信息已准备好。".to_string(); - } - - "拼图创作信息已准备好。".to_string() -} +use mappers::*; fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String { build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { @@ -3444,10 +2921,12 @@ async fn generate_puzzle_background_music_required( ) -> Result { let normalized_title = title.trim(); if normalized_title.is_empty() { - return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": "拼图草稿背景音乐名称为空,无法完成背景音乐生成", - }))); + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图草稿背景音乐名称为空,无法完成背景音乐生成", + })), + ); } generate_background_music_asset_for_creation( state, @@ -3517,11 +2996,13 @@ fn ensure_puzzle_initial_level_assets_ready( missing.push("UI背景图"); } - Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": format!("拼图草稿资源生成未完成:缺少{}", missing.join("、")), - "missingAssets": missing, - }))) + Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图草稿资源生成未完成:缺少{}", missing.join("、")), + "missingAssets": missing, + })), + ) } fn find_puzzle_level_for_initial_asset_check<'a>( @@ -4104,521 +3585,9 @@ fn apply_generated_puzzle_ui_background_to_session_snapshot( session } -async fn generate_puzzle_work_tags( - state: &AppState, - work_title: &str, - work_description: &str, -) -> Vec { - 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 标签生成失败,降级使用关键词标签" - ); - } - } - } +mod tags; - normalize_puzzle_tag_candidates(build_fallback_puzzle_tags(work_title, work_description)) -} - -fn parse_puzzle_tags_from_text(text: &str) -> Vec { - 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::(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)) -} - -fn normalize_puzzle_tag_candidates(candidates: impl IntoIterator) -> Vec -where - S: AsRef, -{ - 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 -} - -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::() -} - -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 -} - -async fn save_generated_puzzle_tags_to_session( - state: &AppState, - session_id: &str, - owner_user_id: &str, - payload: &ExecutePuzzleAgentActionRequest, - generated_tags: Vec, - levels_json: Option, - now: i64, -) -> Result { - 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, - )) -} - -fn apply_generated_puzzle_tags_to_session_snapshot( - mut session: PuzzleAgentSessionRecord, - generated_tags: Vec, - work_title: String, - work_description: String, - levels: Vec, - 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 -} - -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()) - }) -} - -fn serialize_puzzle_level_records_for_module( - levels: &[PuzzleDraftLevelRecord], -) -> Result { - 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::>(), - "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::>(); - 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}"), - })) - }) -} - -fn is_spacetimedb_connectivity_app_error(error: &AppError) -> bool { - matches!( - error.status_code(), - StatusCode::SERVICE_UNAVAILABLE | StatusCode::GATEWAY_TIMEOUT - ) -} - -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(()) -} - -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, - })), - ) -} - -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(), - })) -} - -fn should_sync_puzzle_freeze_boundary(error: &AppError, is_freeze_time: bool) -> bool { - is_freeze_time && error.body_text().contains("操作不合法") -} - -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"))) -} - -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, - })) -} - -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") -} - -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 -} - -fn puzzle_sse_json_event(event_name: &str, payload: Value) -> Result { - 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}"), - })) - }) -} - -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()), - } -} - -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) -} +use tags::*; fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError { if error.code() == "UPSTREAM_ERROR" { diff --git a/server-rs/crates/api-server/src/puzzle/mappers.rs b/server-rs/crates/api-server/src/puzzle/mappers.rs new file mode 100644 index 00000000..ce302d03 --- /dev/null +++ b/server-rs/crates/api-server/src/puzzle/mappers.rs @@ -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) -> 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) -> 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() +} + diff --git a/server-rs/crates/api-server/src/puzzle/tags.rs b/server-rs/crates/api-server/src/puzzle/tags.rs new file mode 100644 index 00000000..16ad46e6 --- /dev/null +++ b/server-rs/crates/api-server/src/puzzle/tags.rs @@ -0,0 +1,518 @@ +use super::*; + +pub(super) async fn generate_puzzle_work_tags( + state: &AppState, + work_title: &str, + work_description: &str, +) -> Vec { + 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 { + 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::(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(candidates: impl IntoIterator) -> Vec +where + S: AsRef, +{ + 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::() +} + +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, + levels_json: Option, + now: i64, +) -> Result { + 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, + work_title: String, + work_description: String, + levels: Vec, + 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 { + 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::>(), + "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::>(); + 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::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) +} + diff --git a/server-rs/crates/api-server/src/square_hole.rs b/server-rs/crates/api-server/src/square_hole.rs index e2fa232a..45f0e05a 100644 --- a/server-rs/crates/api-server/src/square_hole.rs +++ b/server-rs/crates/api-server/src/square_hole.rs @@ -24,7 +24,7 @@ use module_square_hole::{ SQUARE_HOLE_SESSION_ID_PREFIX, default_background_prompt, normalize_hole_options, normalize_shape_options, }; -use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest}; +use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use shared_contracts::{ @@ -61,6 +61,11 @@ use spacetime_client::{ SquareHoleShapeSnapshotRecord, SquareHoleWorkProfileRecord, SquareHoleWorkUpdateRecordInput, }; +use crate::generated_image_assets::{ + GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, + adapter::GeneratedImageAssetAdapterMetadata, adapter::GeneratedImageAssetPersistInput, + normalize_generated_image_asset_mime, +}; use crate::{ ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter}, api_response::json_success_body, @@ -1130,1006 +1135,15 @@ async fn compile_square_hole_draft_for_session( }) } -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, - visual_asset_option_id: Option, -) -> Result { - 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), - ) - })?; +mod visual_assets; - let requested_slot = normalize_square_hole_visual_asset_slot( - visual_asset_slot.as_deref(), - visual_asset_option_id.as_deref(), - ); +use visual_assets::{ + generate_square_hole_visual_assets_for_session, regenerate_square_hole_visual_asset_for_work, +}; - 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) - })?, - ); - } +mod mappers; - 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) -} - -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, -) -> Result { - 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 { - 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 { - 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 put_result = oss_client - .put_object( - &http_client, - OssPutObjectRequest { - 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_name: format!("image.{}", image.extension), - content_type: Some(image.mime_type.clone()), - access: OssObjectAccess::Private, - metadata: build_square_hole_asset_metadata( - asset_kind, - owner_user_id, - profile_id, - slot, - ), - body: image.bytes, - }, - ) - .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(image.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 build_square_hole_asset_metadata( - asset_kind: &str, - owner_user_id: &str, - profile_id: &str, - slot: &str, -) -> BTreeMap { - BTreeMap::from([ - ("asset_kind".to_string(), asset_kind.to_string()), - ("owner_user_id".to_string(), owner_user_id.to_string()), - ("profile_id".to_string(), profile_id.to_string()), - ( - "entity_kind".to_string(), - SQUARE_HOLE_ENTITY_KIND.to_string(), - ), - ("entity_id".to_string(), profile_id.to_string()), - ("slot".to_string(), slot.to_string()), - ]) -} - -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::() - .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() -} - -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, - } -} - -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, - ), - } -} - -fn map_square_hole_anchor_item_response( - anchor: SquareHoleAnchorItemRecord, -) -> SquareHoleAnchorItemResponse { - SquareHoleAnchorItemResponse { - key: anchor.key, - label: anchor.label, - value: anchor.value, - status: anchor.status, - } -} - -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(), - } -} - -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, - } -} - -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, - } -} - -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, - } -} - -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, - } -} - -fn map_square_hole_work_profile_response( - item: SquareHoleWorkProfileRecord, -) -> SquareHoleWorkProfileResponse { - SquareHoleWorkProfileResponse { - summary: map_square_hole_work_summary_response(item), - } -} - -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), - } -} - -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, - } -} - -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, - } -} - -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, - } -} - -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, - } -} - -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, - } -} - -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, - } -} - -fn map_square_hole_feedback_response( - feedback: SquareHoleDropFeedbackRecord, -) -> SquareHoleDropFeedbackResponse { - SquareHoleDropFeedbackResponse { - accepted: feedback.accepted, - reject_reason: feedback.reject_reason, - message: feedback.message, - } -} +use mappers::*; fn build_config_from_create_request( payload: &CreateSquareHoleSessionRequest, diff --git a/server-rs/crates/api-server/src/square_hole/mappers.rs b/server-rs/crates/api-server/src/square_hole/mappers.rs new file mode 100644 index 00000000..e5200112 --- /dev/null +++ b/server-rs/crates/api-server/src/square_hole/mappers.rs @@ -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, + } +} + diff --git a/server-rs/crates/api-server/src/square_hole/visual_assets.rs b/server-rs/crates/api-server/src/square_hole/visual_assets.rs new file mode 100644 index 00000000..d13095eb --- /dev/null +++ b/server-rs/crates/api-server/src/square_hole/visual_assets.rs @@ -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, + visual_asset_option_id: Option, +) -> Result { + 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, +) -> Result { + 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 { + 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 { + 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::() + .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() +} + diff --git a/server-rs/crates/module-match3d/src/application.rs b/server-rs/crates/module-match3d/src/application.rs index b3e76847..8e4caa84 100644 --- a/server-rs/crates/module-match3d/src/application.rs +++ b/server-rs/crates/module-match3d/src/application.rs @@ -168,8 +168,7 @@ pub fn start_run_with_seed_at_and_item_type_count( let profile_id = normalize_required_string(profile_id).ok_or(Match3DFieldError::MissingProfileId)?; - let clear_count = - normalize_match3d_runtime_clear_count(config.clear_count, config.difficulty); + let clear_count = normalize_match3d_runtime_clear_count(config.clear_count, config.difficulty); let total_item_count = clear_count .checked_mul(MATCH3D_ITEMS_PER_CLEAR) .ok_or(Match3DFieldError::InvalidClearCount)?; @@ -333,7 +332,8 @@ fn build_initial_items( ) -> Vec { let mut rng = DeterministicRng::new(seed ^ ((clear_count as u64) << 32) ^ difficulty as u64); 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 size_tier_plan = resolve_size_tier_plan(item_type_count); let mut items = Vec::with_capacity((clear_count * MATCH3D_ITEMS_PER_CLEAR) as usize); diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index ffa21519..4643931e 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -2129,10 +2129,7 @@ mod tests { 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.pure_phone_number.as_deref(), - Some("13800138000") - ); + assert_eq!(phone_info.pure_phone_number.as_deref(), Some("13800138000")); assert_eq!(phone_info.country_code.as_deref(), Some("86")); } diff --git a/server-rs/crates/spacetime-module/src/match3d/mod.rs b/server-rs/crates/spacetime-module/src/match3d/mod.rs index 754d7f2d..8c0ff224 100644 --- a/server-rs/crates/spacetime-module/src/match3d/mod.rs +++ b/server-rs/crates/spacetime-module/src/match3d/mod.rs @@ -13,8 +13,8 @@ use module_match3d::{ Match3DItemSnapshot as DomainMatch3DItemSnapshot, Match3DItemState as DomainMatch3DItemState, Match3DRunSnapshot as DomainMatch3DRunSnapshot, Match3DRunStatus as DomainMatch3DRunStatus, 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, - stop_run_at as stop_domain_run_at, + resolve_run_timer_at as resolve_domain_run_timer_at, + start_run_with_seed_at_and_item_type_count, stop_run_at as stop_domain_run_at, }; use serde::Serialize; use serde::de::DeserializeOwned; @@ -1251,12 +1251,12 @@ fn validate_publishable_work(row: &Match3DWorkProfileRow) -> Result<(), String> return Err("match3d 发布需要至少 1 个标签".to_string()); } let config = parse_config(&row.config_json)?; - let required_item_types = - module_match3d::resolve_match3d_item_type_count_for_difficulty( - config.clear_count, - config.difficulty, - ) as usize; - let ready_item_types = count_ready_generated_item_types(row.generated_item_assets_json.as_deref())?; + let required_item_types = module_match3d::resolve_match3d_item_type_count_for_difficulty( + config.clear_count, + config.difficulty, + ) as usize; + let ready_item_types = + count_ready_generated_item_types(row.generated_item_assets_json.as_deref())?; if ready_item_types < required_item_types { return Err(format!( "match3d 发布需要至少 {required_item_types} 种物品素材,当前已有 {ready_item_types} 种" diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 32fb54e2..703e880e 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -17,15 +17,14 @@ use module_puzzle::{ PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput, PuzzleWorkDeleteInput, - PuzzleWorkGetInput, - PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, PuzzleWorkPointIncentiveClaimInput, - PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput, PuzzleWorkUpsertInput, - PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, - apply_selected_candidate, build_form_draft_from_seed, build_result_preview, - compile_result_draft_from_seed, create_work_profile, infer_anchor_pack, normalize_puzzle_draft, - normalize_puzzle_levels, normalize_theme_tags, publish_work_profile, replace_puzzle_level, - select_next_profiles, selected_profile_level_after_runtime_level, selected_puzzle_level, - tag_similarity_score, + PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, + PuzzleWorkPointIncentiveClaimInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, + PuzzleWorkRemixInput, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult, + apply_publish_overrides_to_draft, apply_selected_candidate, build_form_draft_from_seed, + build_result_preview, compile_result_draft_from_seed, create_work_profile, infer_anchor_pack, + normalize_puzzle_draft, normalize_puzzle_levels, normalize_theme_tags, publish_work_profile, + replace_puzzle_level, select_next_profiles, selected_profile_level_after_runtime_level, + selected_puzzle_level, tag_similarity_score, }; use module_runtime::RuntimeProfileWalletLedgerSourceType; use module_runtime::visible_runtime_profile_user_tags; @@ -1062,12 +1061,10 @@ fn save_puzzle_ui_background_tx( let mut next_level = target_level; 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_object_key = input - .image_object_key - .and_then(|value| { - let trimmed = value.trim().to_string(); - (!trimmed.is_empty()).then_some(trimmed) - }); + next_level.ui_background_image_object_key = input.image_object_key.and_then(|value| { + 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 saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros); diff --git a/server-rs/crates/spacetime-module/src/runtime/mod.rs b/server-rs/crates/spacetime-module/src/runtime/mod.rs index 52386257..730b89e2 100644 --- a/server-rs/crates/spacetime-module/src/runtime/mod.rs +++ b/server-rs/crates/spacetime-module/src/runtime/mod.rs @@ -1,13 +1,13 @@ pub mod analytics_date_dimension; -pub mod creation_entry_config; mod browse_history; +pub mod creation_entry_config; mod profile; mod settings; mod snapshots; pub use analytics_date_dimension::*; -pub use creation_entry_config::*; pub use browse_history::*; +pub use creation_entry_config::*; pub use profile::*; pub use settings::*; pub use snapshots::*;