diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 1b2bcf30..1cb669a8 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -56,8 +56,9 @@ - 背景:`server-rs/crates/api-server/src/puzzle.rs` 已膨胀为数千行大文件,混合 Axum handler、草稿编译、图片生成、VectorEngine / OSS 持久化、DTO mapper、标签生成和测试;继续在单文件内迭代会降低定位和评审效率。 - 决策:原超大 `puzzle.rs` 改为同名入口 `server-rs/crates/api-server/src/puzzle.rs` 加 `server-rs/crates/api-server/src/puzzle/` 子模块目录。`puzzle.rs` 只保留聚合入口和 handler re-export;`handlers.rs` 放 HTTP handler;`draft.rs` 放表单草稿 / 编译 / snapshot helper;`generation.rs` 放图片与 UI 背景生成编排;`vector_engine.rs` 放 VectorEngine、下载、OSS、asset object / binding 和错误归一;`mappers.rs` / `tags.rs` 保留映射和标签 / 错误 helper;`tests.rs` 承接原 puzzle 单测。 +- 2026-05-21 追加决策:拼图 HTTP/BFF handler 不再直接提取完整 `AppState`,统一通过 `PuzzleApiState` 暴露拼图能力需要的 SpacetimeDB facade、gallery cache、OSS、作者查询、LLM 和少量配置快照。`modules/puzzle.rs` 仍接收全局 `AppState` 以挂接鉴权和回到全局路由树,但内部路由先 `.with_state(PuzzleApiState::from_ref(&state))`,handler 使用 `State`。确需复用计费、外部失败审计等仍要求 `AppState` 的横切 helper 时,先经 `PuzzleApiState::root_state()` 显式过渡,后续再继续收窄。 - 边界:本次只改变 `api-server` 内部文件组织,不改变 `/api/runtime/puzzle/*` 路由、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义或计费语义。领域规则后续仍应逐步沉到 `module-puzzle`,SpacetimeDB 表、reducer、procedure 和 row shape 仍留在 `spacetime-module`。 -- 影响范围:`server-rs/crates/api-server/src/puzzle/`、`server-rs/crates/api-server/src/modules/puzzle.rs` 的 handler 引用、后端架构文档。 +- 影响范围:`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/api-server/src/puzzle/`、`server-rs/crates/api-server/src/modules/puzzle.rs` 的 handler 引用、后端架构文档。 - 验证方式:执行 `cargo check -p api-server --manifest-path server-rs\Cargo.toml`;后续若改动 puzzle API 行为,再按对应路由补充定向测试和 `npm run dev:api-server` `/healthz` smoke。 - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 53bf43ee..9fd6a96e 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -77,13 +77,16 @@ npm run check:server-rs-ddd 1. 每个能力 Module 只暴露 `router(state) -> Router`,由 `app.rs` 统一 `.merge(...)`。 2. `app.rs` 只保留全局 middleware、TraceLayer、request context、tracking middleware、入口开关和少量顶层 glue。 -3. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现。 -4. 大 handler 拆分时优先按 `router.rs`、`handlers.rs`、`application.rs`、`assets.rs`、`mapper.rs`、`errors.rs` 分层。`handlers.rs` 只做 Axum extract、鉴权和 request/response,业务规则继续下沉到 `module-*`。 -5. 手写 Rust 模块入口统一使用同名 `.rs` 文件,例如 `puzzle.rs` + `puzzle/*.rs`、`match3d.rs` + `match3d/*.rs`;不要再新增 `mod.rs` 入口。生成的 SpacetimeDB Rust bindings 也由生成脚本同步为 `module_bindings.rs` + `module_bindings/*.rs` 布局。 +3. 能力 Module 可在路由内部用 `FromRef` 派生自己的 Feature State,例如 `PuzzleApiState`。全局 `AppState` 仍作为进程组合根、鉴权层和全局中间件状态,但业务 handler 优先只提取对应 Feature State,不直接暴露完整 `AppState`。 +4. Feature State 只暴露该能力实际需要的 facade / adapter / 配置快照;若必须复用仍要求 `AppState` 的横切 helper(例如计费、外部失败审计或通用 tracking),应通过 Feature State 的窄方法或显式 `root_state()` 过渡,并在后续继续收窄。 +5. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现,再收窄 handler 可见状态。 +6. 大 handler 拆分时优先按 `router.rs`、`handlers.rs`、`application.rs`、`assets.rs`、`mapper.rs`、`errors.rs` 分层。`handlers.rs` 只做 Axum extract、鉴权和 request/response,业务规则继续下沉到 `module-*`。 +7. 手写 Rust 模块入口统一使用同名 `.rs` 文件,例如 `puzzle.rs` + `puzzle/*.rs`、`match3d.rs` + `match3d/*.rs`;不要再新增 `mod.rs` 入口。生成的 SpacetimeDB Rust bindings 也由生成脚本同步为 `module_bindings.rs` + `module_bindings/*.rs` 布局。 拼图 `api-server` 内部拆分: - `server-rs/crates/api-server/src/modules/puzzle.rs` 只负责路由装配、鉴权层和参考图 body limit;对外继续引用同一批 handler 名称。 +- `server-rs/crates/api-server/src/state.rs` 中的 `PuzzleApiState` 是拼图 HTTP/BFF 的 Feature State,集中暴露 `SpacetimeClient`、`PuzzleGalleryCache`、OSS client、作者查询所需认证服务、拼图 LLM client 和少量 VectorEngine / Agent 配置快照。拼图 handler 只提取 `State`,不得重新改回 `State`。 - `server-rs/crates/api-server/src/puzzle.rs` 只作为聚合入口,保留共享 import / 常量、内部模块声明和 handler re-export,不继续承载大段实现。 - `server-rs/crates/api-server/src/puzzle/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP/SSE 响应。 - `server-rs/crates/api-server/src/puzzle/draft.rs` 承接表单草稿保存、草稿编译、首关命名、UI 背景 prompt、降级 snapshot 和初始资产就绪校验。 diff --git a/packages/shared/src/contracts/puzzleAgentActions.ts b/packages/shared/src/contracts/puzzleAgentActions.ts index 9e6d2cb3..501b8cc4 100644 --- a/packages/shared/src/contracts/puzzleAgentActions.ts +++ b/packages/shared/src/contracts/puzzleAgentActions.ts @@ -52,6 +52,8 @@ export type PuzzleAgentActionRequest = pictureDescription?: string; referenceImageSrc?: string | null; referenceImageSrcs?: string[]; + referenceImageAssetObjectId?: string | null; + referenceImageAssetObjectIds?: string[]; imageModel?: string | null; aiRedraw?: boolean; } @@ -63,6 +65,8 @@ export type PuzzleAgentActionRequest = pictureDescription?: string; referenceImageSrc?: string | null; referenceImageSrcs?: string[]; + referenceImageAssetObjectId?: string | null; + referenceImageAssetObjectIds?: string[]; imageModel?: string | null; aiRedraw?: boolean; candidateCount?: number; @@ -73,6 +77,8 @@ export type PuzzleAgentActionRequest = promptText?: string | null; referenceImageSrc?: string | null; referenceImageSrcs?: string[]; + referenceImageAssetObjectId?: string | null; + referenceImageAssetObjectIds?: string[]; imageModel?: string | null; aiRedraw?: boolean; candidateCount?: number; diff --git a/packages/shared/src/contracts/puzzleAgentSession.ts b/packages/shared/src/contracts/puzzleAgentSession.ts index f216f263..2859bb73 100644 --- a/packages/shared/src/contracts/puzzleAgentSession.ts +++ b/packages/shared/src/contracts/puzzleAgentSession.ts @@ -51,6 +51,8 @@ export interface CreatePuzzleAgentSessionRequest { pictureDescription?: string; referenceImageSrc?: string | null; referenceImageSrcs?: string[]; + referenceImageAssetObjectId?: string | null; + referenceImageAssetObjectIds?: string[]; imageModel?: string | null; aiRedraw?: boolean; } diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index 33d46ae5..1c709b96 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -394,9 +394,13 @@ pub async fn confirm_asset_object( let result = state .spacetime_client() .confirm_asset_object( - build_confirm_asset_object_upsert_input(oss_client, payload) - .await - .map_err(map_confirm_asset_object_prepare_error)?, + build_confirm_asset_object_upsert_input( + oss_client, + payload, + authenticated.claims().user_id(), + ) + .await + .map_err(map_confirm_asset_object_prepare_error)?, ) .await .map_err(map_confirm_asset_object_error)?; @@ -592,6 +596,7 @@ fn supported_asset_history_kind_message() -> String { async fn build_confirm_asset_object_upsert_input( oss_client: &platform_oss::OssClient, payload: ConfirmAssetObjectRequest, + authenticated_owner_user_id: &str, ) -> Result { let configured_bucket = oss_client.config_bucket().to_string(); let resolved_bucket = payload @@ -629,6 +634,14 @@ async fn build_confirm_asset_object_upsert_input( { return Err(ConfirmAssetObjectPrepareError::ContentLengthMismatch); } + let owner_user_id = normalize_optional_value(payload.owner_user_id).or_else(|| { + let owner = authenticated_owner_user_id.trim(); + if owner.is_empty() { + None + } else { + Some(owner.to_string()) + } + }); let now_micros = current_utc_micros(); build_asset_object_upsert_input( @@ -645,7 +658,7 @@ async fn build_confirm_asset_object_upsert_input( normalize_optional_value(payload.content_hash), payload.asset_kind, payload.source_job_id, - payload.owner_user_id, + owner_user_id, payload.profile_id, payload.entity_id, now_micros, diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index 75f731b3..3a599422 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -109,9 +109,8 @@ const MATCH3D_WORK_METADATA_LLM_MODEL: &str = "gpt-4o"; const MATCH3D_ITEM_SIZE_LARGE: &str = "大"; const MATCH3D_ITEM_SIZE_MEDIUM: &str = "中"; const MATCH3D_ITEM_SIZE_SMALL: &str = "小"; -const MATCH3D_CONTAINER_REFERENCE_IMAGE_BYTES: &[u8] = include_bytes!( - "../../../../public/match3d-background-references/pot-fused-reference.png" -); +const MATCH3D_CONTAINER_REFERENCE_IMAGE_BYTES: &[u8] = + include_bytes!("../../../../public/match3d-background-references/pot-fused-reference.png"); const MATCH3D_PUBLIC_REFERENCE_IMAGE_PATH_PREFIX: &str = "public/"; const MATCH3D_QUESTION_THEME: &str = "你想创作什么题材"; const MATCH3D_QUESTION_CLEAR_COUNT: &str = "需要消除多少次才能通关"; diff --git a/server-rs/crates/api-server/src/match3d/item_assets.rs b/server-rs/crates/api-server/src/match3d/item_assets.rs index 87f86542..51c270ed 100644 --- a/server-rs/crates/api-server/src/match3d/item_assets.rs +++ b/server-rs/crates/api-server/src/match3d/item_assets.rs @@ -1,12 +1,12 @@ use super::*; +#[cfg(test)] +use crate::generated_asset_sheets::crop_generated_asset_sheet_view_edge_matte; use crate::generated_asset_sheets::{ GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage, build_generated_asset_sheet_prompt, persist_generated_asset_sheet_bytes, slice_generated_asset_sheet, }; -#[cfg(test)] -use crate::generated_asset_sheets::crop_generated_asset_sheet_view_edge_matte; pub(super) async fn generate_match3d_item_assets( state: &AppState, diff --git a/server-rs/crates/api-server/src/match3d/tests.rs b/server-rs/crates/api-server/src/match3d/tests.rs index 04b93cbc..74417201 100644 --- a/server-rs/crates/api-server/src/match3d/tests.rs +++ b/server-rs/crates/api-server/src/match3d/tests.rs @@ -1164,7 +1164,9 @@ fn match3d_container_reference_image_is_embedded_for_api_only_deploy() { assert_eq!(reference.mime_type, "image/png"); assert_eq!(reference.file_name, "match3d-container-reference.png"); assert!( - reference.bytes.starts_with(&[137, 80, 78, 71, 13, 10, 26, 10]), + reference + .bytes + .starts_with(&[137, 80, 78, 71, 13, 10, 26, 10]), "container reference image should be PNG bytes" ); } diff --git a/server-rs/crates/api-server/src/modules/puzzle.rs b/server-rs/crates/api-server/src/modules/puzzle.rs index 55197b0d..fc2e18cb 100644 --- a/server-rs/crates/api-server/src/modules/puzzle.rs +++ b/server-rs/crates/api-server/src/modules/puzzle.rs @@ -1,6 +1,6 @@ use axum::{ Router, - extract::DefaultBodyLimit, + extract::{DefaultBodyLimit, FromRef}, middleware, routing::{get, post}, }; @@ -17,12 +17,13 @@ use crate::{ submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces, update_puzzle_run_pause, use_puzzle_runtime_prop, }, - state::AppState, + state::{AppState, PuzzleApiState}, }; const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; pub fn router(state: AppState) -> Router { + // 中文注释:拼图 handler 只接收 PuzzleApiState,鉴权层仍使用全局 AppState。 Router::new() .route( "/api/runtime/puzzle/agent/sessions", @@ -181,4 +182,6 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) + .with_state(PuzzleApiState::from_ref(&state)) + .with_state(state) } diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 018c02c7..3c1afb06 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -21,10 +21,8 @@ use module_assets::{ }; use module_puzzle::{PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus}; use platform_llm::{LlmMessage, LlmMessageContentPart, LlmTextRequest}; -use platform_oss::{ - LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest, - OssSignedGetObjectUrlRequest, -}; +use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest}; +use platform_oss::{OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest}; use serde_json::{Map, Value, json}; use shared_contracts::{ creation_audio::CreationAudioAsset, @@ -105,12 +103,9 @@ use crate::{ }, puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json}, request_context::RequestContext, - state::AppState, - vector_engine_audio_generation::{ - GeneratedCreationAudioTarget, generate_background_music_asset_for_creation, - }, - work_author::resolve_work_author_by_user_id, - work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, + state::PuzzleApiState, + work_author::resolve_puzzle_work_author_by_user_id, + work_play_tracking::{WorkPlayTrackingDraft, record_puzzle_work_play_start_after_success}, }; const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent"; @@ -121,8 +116,6 @@ const PUZZLE_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2"; const PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW: &str = "gemini-3.1-flash-image-preview"; const PUZZLE_IMAGE_GENERATION_POINTS_COST: u64 = 2; const PUZZLE_ENTITY_KIND: &str = "puzzle_work"; -const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND: &str = "puzzle_background_music"; -const PUZZLE_BACKGROUND_MUSIC_SLOT: &str = "background_music"; #[cfg(test)] const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024"; const PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE: &str = "1024x1024"; diff --git a/server-rs/crates/api-server/src/puzzle/draft.rs b/server-rs/crates/api-server/src/puzzle/draft.rs index d301c0b5..55216125 100644 --- a/server-rs/crates/api-server/src/puzzle/draft.rs +++ b/server-rs/crates/api-server/src/puzzle/draft.rs @@ -24,7 +24,7 @@ pub(crate) fn build_puzzle_form_seed_text_from_parts( } pub(crate) async fn save_puzzle_form_payload_before_compile( - state: &AppState, + state: &PuzzleApiState, request_context: &RequestContext, session_id: &str, owner_user_id: &str, @@ -76,7 +76,7 @@ pub(crate) async fn save_puzzle_form_payload_before_compile( } pub(crate) async fn create_seeded_puzzle_session_when_form_save_missing( - state: &AppState, + state: &PuzzleApiState, request_context: &RequestContext, session_id: &str, owner_user_id: &str, @@ -209,7 +209,7 @@ pub(crate) fn parse_puzzle_level_records_from_module_json( } pub(crate) async fn get_puzzle_session_for_image_generation( - state: &AppState, + state: &PuzzleApiState, session_id: String, owner_user_id: String, payload: &ExecutePuzzleAgentActionRequest, @@ -469,7 +469,7 @@ impl PuzzleLevelNaming { } pub(crate) async fn generate_puzzle_first_level_name( - state: &AppState, + state: &PuzzleApiState, picture_description: &str, ) -> PuzzleLevelNaming { if let Some(llm_client) = state.llm_client() { @@ -511,7 +511,7 @@ pub(crate) async fn generate_puzzle_first_level_name( } pub(crate) async fn generate_puzzle_first_level_name_from_image( - state: &AppState, + state: &PuzzleApiState, picture_description: &str, image: &PuzzleDownloadedImage, ) -> Option { @@ -1033,42 +1033,8 @@ pub(crate) fn attach_puzzle_level_ui_background( levels[index].ui_background_image_object_key = Some(generated.object_key); } -pub(crate) async fn generate_puzzle_background_music_required( - state: &AppState, - owner_user_id: &str, - profile_id: &str, - title: &str, -) -> 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": "拼图草稿背景音乐名称为空,无法完成背景音乐生成", - })), - ); - } - generate_background_music_asset_for_creation( - state, - owner_user_id, - String::new(), - normalized_title.to_string(), - Some("轻快, 拼图, 循环, instrumental".to_string()), - None, - GeneratedCreationAudioTarget { - entity_kind: PUZZLE_ENTITY_KIND.to_string(), - entity_id: profile_id.to_string(), - slot: PUZZLE_BACKGROUND_MUSIC_SLOT.to_string(), - asset_kind: PUZZLE_BACKGROUND_MUSIC_ASSET_KIND.to_string(), - profile_id: Some(profile_id.to_string()), - storage_prefix: LegacyAssetPrefix::PuzzleAssets, - }, - ) - .await -} - pub(crate) async fn generate_puzzle_initial_ui_background_required( - state: &AppState, + state: &PuzzleApiState, owner_user_id: &str, session_id: &str, draft: &PuzzleResultDraftRecord, @@ -1128,7 +1094,7 @@ pub(crate) fn find_puzzle_level_for_initial_asset_check<'a>( } pub(crate) async fn compile_puzzle_draft_with_initial_cover( - state: &AppState, + state: &PuzzleApiState, session_id: String, owner_user_id: String, prompt_text: Option<&str>, @@ -1398,7 +1364,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover( } pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( - state: &AppState, + state: &PuzzleApiState, session_id: String, owner_user_id: String, prompt_text: Option<&str>, @@ -1417,7 +1383,12 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( })?; let http_client = reqwest::Client::new(); let uploaded_downloaded_image = - resolve_puzzle_reference_image_as_data_url(state, &http_client, uploaded_image_src) + resolve_puzzle_reference_image( + state, + &http_client, + uploaded_image_src, + Some(owner_user_id.as_str()), + ) .await .map(PuzzleDownloadedImage::from_resolved_reference_image) .map_err(|error| { @@ -1425,7 +1396,7 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "field": "referenceImageSrc", - "message": "关闭 AI 重绘时上传图必须是图片 Data URL 或历史生成图片路径。", + "message": "关闭 AI 重绘时上传图必须是拼图图片 assetObjectId、图片 Data URL 或历史生成图片路径。", })) } else { error diff --git a/server-rs/crates/api-server/src/puzzle/generation.rs b/server-rs/crates/api-server/src/puzzle/generation.rs index 5055d0c3..68079bc5 100644 --- a/server-rs/crates/api-server/src/puzzle/generation.rs +++ b/server-rs/crates/api-server/src/puzzle/generation.rs @@ -18,7 +18,7 @@ pub(crate) fn should_fallback_puzzle_reference_edit_to_generation(error: &AppErr } pub(crate) async fn generate_puzzle_image_candidates( - state: &AppState, + state: &PuzzleApiState, owner_user_id: &str, session_id: &str, level_name: &str, @@ -58,8 +58,13 @@ pub(crate) async fn generate_puzzle_image_candidates( .filter(|_| should_use_reference_image_edit) { Some(source) => { - let resolved = - resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?; + let resolved = resolve_puzzle_reference_image( + state, + &http_client, + source, + Some(owner_user_id), + ) + .await?; tracing::info!( provider = resolved_model.provider_name(), image_model = resolved_model.request_model_name(), @@ -219,13 +224,13 @@ pub(crate) async fn generate_puzzle_image_candidates( } pub(crate) async fn generate_puzzle_ui_background_image( - state: &AppState, + state: &PuzzleApiState, owner_user_id: &str, session_id: &str, level_name: &str, prompt: &str, ) -> Result { - let settings = require_openai_image_settings(state)?; + let settings = require_openai_image_settings(state.root_state())?; let http_client = build_openai_image_http_client(&settings)?; let generated = create_openai_image_generation( &http_client, diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index a47c00b1..795ae397 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -1,7 +1,7 @@ use super::*; pub async fn create_puzzle_agent_session( - State(state): State, + State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, @@ -46,7 +46,7 @@ pub async fn create_puzzle_agent_session( } pub async fn generate_puzzle_onboarding_work( - State(state): State, + State(state): State, Extension(request_context): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { @@ -161,7 +161,7 @@ pub async fn generate_puzzle_onboarding_work( } pub async fn save_puzzle_onboarding_work( - State(state): State, + State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, @@ -270,7 +270,7 @@ pub async fn save_puzzle_onboarding_work( } pub async fn get_puzzle_agent_session( - State(state): State, + State(state): State, AxumPath(session_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -303,7 +303,7 @@ pub async fn get_puzzle_agent_session( } pub async fn submit_puzzle_agent_message( - State(state): State, + State(state): State, AxumPath(session_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -359,7 +359,7 @@ pub async fn submit_puzzle_agent_message( llm_client: state.llm_client(), session: &submitted_session, quick_fill_requested: payload.quick_fill_requested.unwrap_or(false), - enable_web_search: state.config.creation_agent_llm_web_search_enabled, + enable_web_search: state.creation_agent_llm_web_search_enabled(), }, |_| {}, ) @@ -401,7 +401,7 @@ pub async fn submit_puzzle_agent_message( } pub async fn stream_puzzle_agent_message( - State(state): State, + State(state): State, AxumPath(session_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -464,7 +464,7 @@ pub async fn stream_puzzle_agent_message( llm_client: state.llm_client(), session: &session, quick_fill_requested, - enable_web_search: state.config.creation_agent_llm_web_search_enabled, + enable_web_search: state.creation_agent_llm_web_search_enabled(), }, move |text| { let _ = reply_tx.send(text.to_string()); @@ -554,7 +554,7 @@ pub async fn stream_puzzle_agent_message( } pub async fn execute_puzzle_agent_action( - State(state): State, + State(state): State, AxumPath(session_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -595,6 +595,8 @@ pub async fn execute_puzzle_agent_action( has_reference_image = has_puzzle_reference_images( payload.reference_image_src.as_deref(), payload.reference_image_srcs.as_slice(), + payload.reference_image_asset_object_id.as_deref(), + payload.reference_image_asset_object_ids.as_slice(), ), "拼图 Agent action 开始执行" ); @@ -604,6 +606,8 @@ pub async fn execute_puzzle_agent_action( let reference_image_sources = collect_puzzle_reference_image_sources( payload.reference_image_src.as_deref(), payload.reference_image_srcs.as_slice(), + payload.reference_image_asset_object_id.as_deref(), + payload.reference_image_asset_object_ids.as_slice(), ); let primary_reference_image_src = reference_image_sources.first().map(String::as_str); let prompt_text = payload @@ -627,7 +631,7 @@ pub async fn execute_puzzle_agent_action( }; let session = if ai_redraw { execute_billable_asset_operation_with_cost( - &state, + state.root_state(), &owner_user_id, "puzzle_initial_image", &billing_asset_id, @@ -652,7 +656,7 @@ pub async fn execute_puzzle_agent_action( compile_session_id.clone(), owner_user_id.clone(), prompt_text, - payload.reference_image_src.as_deref(), + primary_reference_image_src, now, ) .await @@ -737,7 +741,7 @@ pub async fn execute_puzzle_agent_action( })) }); let session = execute_billable_asset_operation_with_cost( - &state, + state.root_state(), &owner_user_id, "puzzle_generated_image", &billing_asset_id, @@ -787,6 +791,8 @@ pub async fn execute_puzzle_agent_action( let reference_image_sources = collect_puzzle_reference_image_sources( payload.reference_image_src.as_deref(), payload.reference_image_srcs.as_slice(), + payload.reference_image_asset_object_id.as_deref(), + payload.reference_image_asset_object_ids.as_slice(), ); let primary_reference_image_src = reference_image_sources.first().map(String::as_str); @@ -942,7 +948,7 @@ pub async fn execute_puzzle_agent_action( })) }); let session = execute_billable_asset_operation_with_cost( - &state, + state.root_state(), &owner_user_id, "puzzle_ui_background_image", &billing_asset_id, @@ -1147,7 +1153,7 @@ pub async fn execute_puzzle_agent_action( let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id); let author_display_name = resolve_author_display_name(&state, &authenticated); let profile = execute_billable_asset_operation( - &state, + state.root_state(), &owner_user_id, "puzzle_publish_work", &work_id, @@ -1235,7 +1241,7 @@ pub async fn execute_puzzle_agent_action( } pub async fn get_puzzle_works( - State(state): State, + State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { @@ -1263,7 +1269,7 @@ pub async fn get_puzzle_works( } pub async fn get_puzzle_work_detail( - State(state): State, + State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(_authenticated): Extension, @@ -1296,7 +1302,7 @@ pub async fn get_puzzle_work_detail( } pub async fn put_puzzle_work( - State(state): State, + State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1355,7 +1361,7 @@ pub async fn put_puzzle_work( } pub async fn delete_puzzle_work( - State(state): State, + State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1391,7 +1397,7 @@ pub async fn delete_puzzle_work( } pub async fn claim_puzzle_work_point_incentive( - State(state): State, + State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1428,7 +1434,7 @@ pub async fn claim_puzzle_work_point_incentive( } pub async fn list_puzzle_gallery( - State(state): State, + State(state): State, Extension(request_context): Extension, ) -> Result { if let Some(response) = state.puzzle_gallery_cache().read_fresh_response().await { @@ -1487,7 +1493,7 @@ pub async fn list_puzzle_gallery( } pub async fn get_puzzle_gallery_detail( - State(state): State, + State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, ) -> Result, Response> { @@ -1519,7 +1525,7 @@ pub async fn get_puzzle_gallery_detail( } pub async fn record_puzzle_gallery_like( - State(state): State, + State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1556,7 +1562,7 @@ pub async fn record_puzzle_gallery_like( } pub async fn remix_puzzle_gallery_work( - State(state): State, + State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1599,7 +1605,7 @@ pub async fn remix_puzzle_gallery_work( } pub async fn start_puzzle_run( - State(state): State, + State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, @@ -1639,7 +1645,7 @@ pub async fn start_puzzle_run( ) })?; - record_work_play_start_after_success( + record_puzzle_work_play_start_after_success( &state, &request_context, WorkPlayTrackingDraft::new( @@ -1665,7 +1671,7 @@ pub async fn start_puzzle_run( } pub async fn get_puzzle_run( - State(state): State, + State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1693,7 +1699,7 @@ pub async fn get_puzzle_run( } pub async fn swap_puzzle_pieces( - State(state): State, + State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1750,7 +1756,7 @@ pub async fn swap_puzzle_pieces( } pub async fn drag_puzzle_piece_or_group( - State(state): State, + State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1802,7 +1808,7 @@ pub async fn drag_puzzle_piece_or_group( } pub async fn advance_puzzle_next_level( - State(state): State, + State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1854,7 +1860,7 @@ pub async fn advance_puzzle_next_level( } pub async fn update_puzzle_run_pause( - State(state): State, + State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1898,7 +1904,7 @@ pub async fn update_puzzle_run_pause( } pub async fn use_puzzle_runtime_prop( - State(state): State, + State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1944,7 +1950,7 @@ pub async fn use_puzzle_runtime_prop( let fallback_run_id = run_id.clone(); let fallback_owner_user_id = owner_user_id.clone(); let run_result = execute_billable_asset_operation( - &state, + state.root_state(), &owner_user_id, billing_asset_kind, billing_asset_id.as_str(), @@ -1996,7 +2002,7 @@ pub async fn use_puzzle_runtime_prop( } pub async fn submit_puzzle_leaderboard( - State(state): State, + State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, diff --git a/server-rs/crates/api-server/src/puzzle/mappers.rs b/server-rs/crates/api-server/src/puzzle/mappers.rs index 6e6c91fd..db33ea10 100644 --- a/server-rs/crates/api-server/src/puzzle/mappers.rs +++ b/server-rs/crates/api-server/src/puzzle/mappers.rs @@ -343,11 +343,11 @@ fn has_puzzle_level_image(level: &PuzzleDraftLevelRecord) -> bool { } pub(super) fn map_puzzle_work_summary_response( - state: &AppState, + state: &PuzzleApiState, item: PuzzleWorkProfileRecord, ) -> PuzzleWorkSummaryResponse { let generation_status = resolve_puzzle_work_generation_status(&item); - let author = resolve_work_author_by_user_id( + let author = resolve_puzzle_work_author_by_user_id( state, &item.owner_user_id, Some(&item.author_display_name), @@ -391,10 +391,10 @@ pub(super) fn map_puzzle_work_summary_response( } pub(super) fn map_puzzle_gallery_card_response( - state: &AppState, + state: &PuzzleApiState, item: PuzzleGalleryCardRecord, ) -> PuzzleWorkSummaryResponse { - let author = resolve_work_author_by_user_id( + let author = resolve_puzzle_work_author_by_user_id( state, &item.owner_user_id, Some(&item.author_display_name), @@ -434,7 +434,7 @@ pub(super) fn map_puzzle_gallery_card_response( } pub(super) fn map_puzzle_work_profile_response( - state: &AppState, + state: &PuzzleApiState, item: PuzzleWorkProfileRecord, ) -> PuzzleWorkProfileResponse { let mut summary = map_puzzle_work_summary_response(state, item.clone()); @@ -491,7 +491,7 @@ pub(super) fn map_puzzle_recommended_next_work_response( } pub(super) async fn enrich_puzzle_run_author_name( - state: &AppState, + state: &PuzzleApiState, mut run: PuzzleRunRecord, ) -> PuzzleRunRecord { if let Some(level) = run.current_level.as_mut() { @@ -500,7 +500,7 @@ pub(super) async fn enrich_puzzle_run_author_name( .get_puzzle_gallery_detail(level.profile_id.clone()) .await { - level.author_display_name = resolve_work_author_by_user_id( + level.author_display_name = resolve_puzzle_work_author_by_user_id( state, &profile.owner_user_id, Some(&profile.author_display_name), @@ -632,7 +632,7 @@ pub(super) fn map_puzzle_board_response( } pub(super) fn resolve_author_display_name( - state: &AppState, + state: &PuzzleApiState, authenticated: &AuthenticatedAccessToken, ) -> String { state diff --git a/server-rs/crates/api-server/src/puzzle/tags.rs b/server-rs/crates/api-server/src/puzzle/tags.rs index e97f0f44..aee79739 100644 --- a/server-rs/crates/api-server/src/puzzle/tags.rs +++ b/server-rs/crates/api-server/src/puzzle/tags.rs @@ -1,7 +1,7 @@ use super::*; pub(super) async fn generate_puzzle_work_tags( - state: &AppState, + state: &PuzzleApiState, work_title: &str, work_description: &str, ) -> Vec { @@ -143,7 +143,7 @@ pub(super) fn build_fallback_puzzle_tags( } pub(super) async fn save_generated_puzzle_tags_to_session( - state: &AppState, + state: &PuzzleApiState, session_id: &str, owner_user_id: &str, payload: &ExecutePuzzleAgentActionRequest, diff --git a/server-rs/crates/api-server/src/puzzle/tests.rs b/server-rs/crates/api-server/src/puzzle/tests.rs index 69425e82..cc5633e2 100644 --- a/server-rs/crates/api-server/src/puzzle/tests.rs +++ b/server-rs/crates/api-server/src/puzzle/tests.rs @@ -41,6 +41,7 @@ fn puzzle_vector_engine_generation_fallback_includes_reference_image() { mime_type: "image/png".to_string(), bytes_len: cursor.get_ref().len(), bytes: cursor.into_inner(), + signed_read_url: None, }; let body = build_puzzle_vector_engine_image_request_body( @@ -64,6 +65,33 @@ fn puzzle_vector_engine_generation_fallback_includes_reference_image() { ); } +#[test] +fn puzzle_vector_engine_generation_prefers_signed_reference_url() { + let reference_image = PuzzleResolvedReferenceImage { + mime_type: "image/png".to_string(), + bytes_len: 4, + bytes: b"test".to_vec(), + signed_read_url: Some( + "https://oss.example/generated-puzzle-assets/reference.png?x-oss-signature=abc" + .to_string(), + ), + }; + + let body = build_puzzle_vector_engine_image_request_body( + PuzzleImageModel::GptImage2, + "参考图里的小猫做成拼图主图。", + PUZZLE_DEFAULT_NEGATIVE_PROMPT, + PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, + 1, + Some(&reference_image), + ); + + assert_eq!( + body["image"][0], + "https://oss.example/generated-puzzle-assets/reference.png?x-oss-signature=abc" + ); +} + #[test] fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() { let settings = PuzzleVectorEngineSettings { @@ -131,6 +159,8 @@ fn puzzle_reference_image_sources_are_deduped_and_limited() { "data:image/png;base64,e".to_string(), "data:image/png;base64,f".to_string(), ], + None, + &[], ); assert_eq!(sources.len(), 5); @@ -139,6 +169,62 @@ fn puzzle_reference_image_sources_are_deduped_and_limited() { assert!(!sources.contains(&"data:image/png;base64,f".to_string())); } +#[test] +fn puzzle_reference_image_sources_prefer_asset_object_ids() { + let sources = collect_puzzle_reference_image_sources( + Some("data:image/png;base64,legacy"), + &["/generated-puzzle-assets/legacy.png".to_string()], + Some("asset-main-1"), + &[ + "asset-main-1".to_string(), + "asset-prompt-1".to_string(), + "asset-prompt-2".to_string(), + ], + ); + + assert_eq!( + sources, + vec![ + "asset-object:asset-main-1".to_string(), + "asset-object:asset-prompt-1".to_string(), + "asset-object:asset-prompt-2".to_string(), + "data:image/png;base64,legacy".to_string(), + "/generated-puzzle-assets/legacy.png".to_string(), + ] + ); +} + +#[test] +fn puzzle_asset_object_reference_requires_matching_owner() { + let asset_object = module_assets::AssetObjectRecord { + asset_object_id: "assetobj_reference_1".to_string(), + bucket: "genarrative-assets".to_string(), + object_key: "generated-puzzle-assets/reference/image.png".to_string(), + access_policy: module_assets::AssetObjectAccessPolicy::Private, + content_type: Some("image/png".to_string()), + content_length: 1024, + content_hash: None, + version: 1, + source_job_id: None, + owner_user_id: Some("user-other".to_string()), + profile_id: None, + entity_id: None, + asset_kind: "puzzle_cover_image".to_string(), + created_at: "2026-05-21T00:00:00Z".to_string(), + updated_at: "2026-05-21T00:00:00Z".to_string(), + }; + + let error = validate_puzzle_reference_asset_object( + &asset_object, + Some("user-current"), + "genarrative-assets", + ) + .expect_err("其他账号的参考图资产应被拒绝"); + + assert_eq!(error.status_code(), StatusCode::FORBIDDEN); + assert!(error.body_text().contains("不属于当前账号")); +} + #[test] fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() { let error = map_puzzle_vector_engine_request_error( @@ -250,6 +336,8 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() { prompt_text: None, reference_image_src: None, reference_image_srcs: Vec::new(), + reference_image_asset_object_id: None, + reference_image_asset_object_ids: Vec::new(), image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()), ai_redraw: None, candidate_count: Some(1), @@ -383,6 +471,7 @@ fn puzzle_uploaded_cover_can_reuse_resolved_history_image() { mime_type: "image/png".to_string(), bytes_len: 8, bytes: b"pngbytes".to_vec(), + signed_read_url: None, }; let downloaded = PuzzleDownloadedImage::from_resolved_reference_image(resolved); @@ -410,6 +499,8 @@ fn puzzle_first_level_name_snapshot_defaults_work_title() { prompt_text: None, reference_image_src: None, reference_image_srcs: Vec::new(), + reference_image_asset_object_id: None, + reference_image_asset_object_ids: Vec::new(), image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()), ai_redraw: None, candidate_count: Some(1), @@ -614,7 +705,9 @@ fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() { #[test] fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() { - let state = AppState::new(crate::config::AppConfig::default()).expect("state should build"); + let app_state = crate::state::AppState::new(crate::config::AppConfig::default()) + .expect("state should build"); + let state: PuzzleApiState = axum::extract::FromRef::from_ref(&app_state); let level = PuzzleDraftLevelRecord { level_id: "puzzle-level-1".to_string(), level_name: "雨夜猫街".to_string(), diff --git a/server-rs/crates/api-server/src/puzzle/vector_engine.rs b/server-rs/crates/api-server/src/puzzle/vector_engine.rs index 40383193..e2ebbad6 100644 --- a/server-rs/crates/api-server/src/puzzle/vector_engine.rs +++ b/server-rs/crates/api-server/src/puzzle/vector_engine.rs @@ -37,6 +37,7 @@ pub(crate) struct PuzzleResolvedReferenceImage { pub(crate) mime_type: String, pub(crate) bytes_len: usize, pub(crate) bytes: Vec, + pub(crate) signed_read_url: Option, } pub(crate) struct GeneratedPuzzleImageCandidate { @@ -109,13 +110,9 @@ pub(crate) fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageMode } pub(crate) fn require_puzzle_vector_engine_settings( - state: &AppState, + state: &PuzzleApiState, ) -> Result { - let base_url = state - .config - .vector_engine_base_url - .trim() - .trim_end_matches('/'); + let base_url = state.vector_engine_base_url().trim().trim_end_matches('/'); if base_url.is_empty() { return Err( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ @@ -127,9 +124,7 @@ pub(crate) fn require_puzzle_vector_engine_settings( } let api_key = state - .config - .vector_engine_api_key - .as_deref() + .vector_engine_api_key() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { @@ -147,11 +142,11 @@ pub(crate) fn require_puzzle_vector_engine_settings( } pub(crate) fn build_puzzle_image_http_client( - state: &AppState, + state: &PuzzleApiState, image_model: PuzzleImageModel, ) -> Result { let provider = image_model.provider_name(); - let request_timeout_ms = state.config.vector_engine_image_request_timeout_ms; + let request_timeout_ms = state.vector_engine_image_request_timeout_ms(); reqwest::Client::builder() .timeout(Duration::from_millis(request_timeout_ms.max(1))) @@ -397,11 +392,19 @@ pub(crate) fn build_puzzle_vector_engine_image_request_body( ("n".to_string(), json!(candidate_count.clamp(1, 1))), ("size".to_string(), Value::String(size.to_string())), ]); - if let Some(reference_image) = reference_image - && let Some(reference_data_url) = + if let Some(reference_image) = reference_image { + if let Some(signed_read_url) = reference_image + .signed_read_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + body.insert("image".to_string(), json!([signed_read_url])); + } else if let Some(reference_data_url) = build_puzzle_generation_reference_image_data_url(reference_image) - { - body.insert("image".to_string(), json!([reference_data_url])); + { + body.insert("image".to_string(), json!([reference_data_url])); + } } Value::Object(body) @@ -462,6 +465,48 @@ pub(crate) fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> b pub(crate) fn collect_puzzle_reference_image_sources( legacy_reference_image_src: Option<&str>, reference_image_srcs: &[String], + reference_image_asset_object_id: Option<&str>, + reference_image_asset_object_ids: &[String], +) -> Vec { + let mut sources = Vec::new(); + for source in reference_image_asset_object_id + .into_iter() + .chain(reference_image_asset_object_ids.iter().map(String::as_str)) + .map(|asset_object_id| { + asset_object_id + .trim() + .strip_prefix("asset-object:") + .unwrap_or_else(|| asset_object_id.trim()) + }) + .filter(|asset_object_id| !asset_object_id.is_empty()) + .map(|asset_object_id| format!("asset-object:{asset_object_id}")) + .chain( + legacy_reference_image_src + .into_iter() + .chain(reference_image_srcs.iter().map(String::as_str)) + .map(str::to_string), + ) + { + let normalized = source.trim(); + if normalized.is_empty() { + continue; + } + if !sources + .iter() + .any(|existing: &String| existing == normalized) + { + sources.push(normalized.to_string()); + } + if sources.len() >= PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT { + break; + } + } + sources +} + +pub(crate) fn collect_legacy_puzzle_reference_image_sources( + legacy_reference_image_src: Option<&str>, + reference_image_srcs: &[String], ) -> Vec { let mut sources = Vec::new(); for source in legacy_reference_image_src @@ -488,9 +533,16 @@ pub(crate) fn collect_puzzle_reference_image_sources( pub(crate) fn has_puzzle_reference_images( legacy_reference_image_src: Option<&str>, reference_image_srcs: &[String], + reference_image_asset_object_id: Option<&str>, + reference_image_asset_object_ids: &[String], ) -> bool { - !collect_puzzle_reference_image_sources(legacy_reference_image_src, reference_image_srcs) - .is_empty() + !collect_puzzle_reference_image_sources( + legacy_reference_image_src, + reference_image_srcs, + reference_image_asset_object_id, + reference_image_asset_object_ids, + ) + .is_empty() } pub(crate) fn should_use_puzzle_reference_image_edit( @@ -546,10 +598,19 @@ pub(crate) async fn download_puzzle_images_from_urls( Ok(PuzzleGeneratedImages { task_id, images }) } -pub(crate) async fn resolve_puzzle_reference_image_as_data_url( - state: &AppState, +pub(crate) fn parse_puzzle_asset_object_reference(source: &str) -> Option<&str> { + source + .trim() + .strip_prefix("asset-object:") + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +pub(crate) async fn resolve_puzzle_reference_image( + state: &PuzzleApiState, http_client: &reqwest::Client, source: &str, + owner_user_id: Option<&str>, ) -> Result { let trimmed = source.trim(); if trimmed.is_empty() { @@ -562,6 +623,16 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url( ); } + if let Some(asset_object_id) = parse_puzzle_asset_object_reference(trimmed) { + return resolve_puzzle_reference_asset_object( + state, + http_client, + asset_object_id, + owner_user_id, + ) + .await; + } + if let Some(parsed) = parse_puzzle_image_data_url(trimmed) { let bytes_len = parsed.bytes.len(); if bytes_len > PUZZLE_REFERENCE_IMAGE_MAX_BYTES { @@ -579,6 +650,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url( mime_type: parsed.mime_type, bytes_len, bytes: parsed.bytes, + signed_read_url: None, }); } @@ -587,7 +659,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "puzzle", "field": "referenceImageSrc", - "message": "参考图必须是 Data URL 或 /generated-* 旧路径。", + "message": "参考图必须是 assetObjectId、Data URL 或 /generated-* 旧路径。", })), ); } @@ -598,7 +670,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "puzzle", "field": "referenceImageSrc", - "message": "参考图当前只支持 /generated-* 旧路径。", + "message": "参考图当前只支持 assetObjectId 或 /generated-* 旧路径。", })), ); } @@ -615,8 +687,159 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url( expire_seconds: Some(60), }) .map_err(map_puzzle_asset_oss_error)?; + let signed_read_url = signed.signed_url; + download_signed_puzzle_reference_image( + http_client, + signed_read_url, + object_key, + None, + "referenceImageSrc", + ) + .await +} + +pub(crate) async fn resolve_puzzle_reference_image_as_data_url( + state: &PuzzleApiState, + http_client: &reqwest::Client, + source: &str, +) -> Result { + resolve_puzzle_reference_image(state, http_client, source, None).await +} + +async fn resolve_puzzle_reference_asset_object( + state: &PuzzleApiState, + http_client: &reqwest::Client, + asset_object_id: &str, + owner_user_id: Option<&str>, +) -> Result { + let asset_object = state + .spacetime_client() + .get_asset_object(asset_object_id.to_string()) + .await + .map_err(map_puzzle_client_error)? + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-object", + "field": "referenceImageAssetObjectId", + "assetObjectId": asset_object_id, + "message": "参考图资产不存在或当前账号不可见。", + })) + })?; + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + validate_puzzle_reference_asset_object( + &asset_object, + owner_user_id, + oss_client.config_bucket(), + )?; + let signed = oss_client + .sign_get_object_url(OssSignedGetObjectUrlRequest { + object_key: asset_object.object_key.clone(), + expire_seconds: Some(60), + }) + .map_err(map_puzzle_asset_oss_error)?; + let content_type = asset_object.content_type.clone(); + download_signed_puzzle_reference_image( + http_client, + signed.signed_url, + asset_object.object_key.as_str(), + content_type.as_deref(), + "referenceImageAssetObjectId", + ) + .await +} + +pub(crate) fn validate_puzzle_reference_asset_object( + asset_object: &module_assets::AssetObjectRecord, + owner_user_id: Option<&str>, + oss_bucket: &str, +) -> Result<(), AppError> { + if asset_object.bucket.trim() != oss_bucket.trim() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-object", + "field": "referenceImageAssetObjectId", + "assetObjectId": asset_object.asset_object_id, + "message": "参考图资产 bucket 与当前服务 OSS 配置不一致。", + })), + ); + } + if asset_object.asset_kind.trim() != "puzzle_cover_image" { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-object", + "field": "referenceImageAssetObjectId", + "assetObjectId": asset_object.asset_object_id, + "message": "参考图资产类型不属于拼图图片。", + })), + ); + } + let content_type = asset_object + .content_type + .as_deref() + .map(str::trim) + .unwrap_or_default(); + if !content_type.starts_with("image/") { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-object", + "field": "referenceImageAssetObjectId", + "assetObjectId": asset_object.asset_object_id, + "message": "参考图资产不是图片类型。", + })), + ); + } + if asset_object.content_length == 0 + || asset_object.content_length > PUZZLE_REFERENCE_IMAGE_MAX_BYTES as u64 + { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-object", + "field": "referenceImageAssetObjectId", + "assetObjectId": asset_object.asset_object_id, + "message": "参考图资产大小不符合拼图生成要求。", + "maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES, + "actualBytes": asset_object.content_length, + })), + ); + } + if let Some(expected_owner_user_id) = owner_user_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let actual_owner_user_id = asset_object + .owner_user_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + if actual_owner_user_id != Some(expected_owner_user_id) { + return Err( + AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({ + "provider": "asset-object", + "field": "referenceImageAssetObjectId", + "assetObjectId": asset_object.asset_object_id, + "message": "参考图资产不属于当前账号。", + })), + ); + } + } + + Ok(()) +} + +async fn download_signed_puzzle_reference_image( + http_client: &reqwest::Client, + signed_read_url: String, + object_key: &str, + fallback_content_type: Option<&str>, + field: &str, +) -> Result { let response = http_client - .get(signed.signed_url) + .get(signed_read_url.as_str()) .send() .await .map_err(|error| map_puzzle_image_request_error(format!("读取拼图参考图失败:{error}")))?; @@ -625,6 +848,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url( .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) + .or(fallback_content_type) .unwrap_or("image/png") .to_string(); let body = response.bytes().await.map_err(|error| { @@ -636,6 +860,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url( "provider": "aliyun-oss", "message": format!("读取参考图失败,状态码:{status}"), "objectKey": object_key, + "field": field, })), ); } @@ -645,6 +870,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url( "provider": "aliyun-oss", "message": "读取参考图失败:对象内容为空", "objectKey": object_key, + "field": field, })), ); } @@ -655,6 +881,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url( mime_type, bytes_len, bytes: body.to_vec(), + signed_read_url: Some(signed_read_url), }) } @@ -693,7 +920,7 @@ pub(crate) async fn download_puzzle_remote_image( } pub(crate) async fn persist_puzzle_generated_asset( - state: &AppState, + state: &PuzzleApiState, owner_user_id: &str, session_id: &str, level_name: &str, @@ -805,7 +1032,7 @@ pub(crate) async fn persist_puzzle_generated_asset( } pub(crate) async fn persist_puzzle_ui_background_image( - state: &AppState, + state: &PuzzleApiState, owner_user_id: &str, session_id: &str, level_name: &str, diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 9249e4e5..5458e693 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -141,6 +141,86 @@ impl FromRef for BackpressureState { } } +#[derive(Clone, Debug)] +pub struct PuzzleApiState { + root_state: AppState, + spacetime_client: SpacetimeClient, + puzzle_gallery_cache: PuzzleGalleryCache, + oss_client: Option, + auth_user_service: AuthUserService, + llm_client: Option, + creative_agent_gpt5_client: Option, + creation_agent_llm_web_search_enabled: bool, + vector_engine_image_request_timeout_ms: u64, +} + +impl PuzzleApiState { + pub fn root_state(&self) -> &AppState { + &self.root_state + } + + pub fn spacetime_client(&self) -> &SpacetimeClient { + &self.spacetime_client + } + + pub fn puzzle_gallery_cache(&self) -> &PuzzleGalleryCache { + &self.puzzle_gallery_cache + } + + pub fn oss_client(&self) -> Option<&OssClient> { + self.oss_client.as_ref() + } + + pub fn auth_user_service(&self) -> &AuthUserService { + &self.auth_user_service + } + + pub fn llm_client(&self) -> Option<&LlmClient> { + self.llm_client.as_ref() + } + + pub fn creative_agent_gpt5_client(&self) -> Option<&LlmClient> { + self.creative_agent_gpt5_client.as_ref() + } + + pub fn creation_agent_llm_web_search_enabled(&self) -> bool { + self.creation_agent_llm_web_search_enabled + } + + pub fn vector_engine_image_request_timeout_ms(&self) -> u64 { + self.vector_engine_image_request_timeout_ms + } + + pub fn vector_engine_base_url(&self) -> &str { + self.root_state.config.vector_engine_base_url.as_str() + } + + pub fn vector_engine_api_key(&self) -> Option<&str> { + self.root_state.config.vector_engine_api_key.as_deref() + } +} + +impl FromRef for PuzzleApiState { + fn from_ref(state: &AppState) -> Self { + // 中文注释:拼图路由只暴露本能力需要的依赖快照,避免 handler 直接看见完整 AppState。 + Self { + root_state: state.clone(), + spacetime_client: state.spacetime_client.clone(), + puzzle_gallery_cache: state.puzzle_gallery_cache.clone(), + oss_client: state.oss_client.clone(), + auth_user_service: state.auth_user_service.clone(), + llm_client: state.llm_client.clone(), + creative_agent_gpt5_client: state.creative_agent_gpt5_client.clone(), + creation_agent_llm_web_search_enabled: state + .config + .creation_agent_llm_web_search_enabled, + vector_engine_image_request_timeout_ms: state + .config + .vector_engine_image_request_timeout_ms, + } + } +} + // Axum/Hyper 会在路由树和连接 service 上频繁 clone state;AppState 外层必须保持浅拷贝。 #[derive(Debug)] pub struct AppStateInner { @@ -1319,4 +1399,23 @@ mod tests { ); assert!(client.config().official_fallback()); } + + #[test] + fn puzzle_api_state_exposes_puzzle_dependency_snapshot() { + let mut config = AppConfig::default(); + config.creation_agent_llm_web_search_enabled = false; + config.vector_engine_image_request_timeout_ms = 987_654; + + let state = AppState::new(config).expect("state should build"); + let puzzle_state: PuzzleApiState = FromRef::from_ref(&state); + + assert!(!puzzle_state.creation_agent_llm_web_search_enabled()); + assert_eq!( + puzzle_state.vector_engine_image_request_timeout_ms(), + 987_654 + ); + assert!(puzzle_state.llm_client().is_none()); + assert!(puzzle_state.creative_agent_gpt5_client().is_none()); + assert!(puzzle_state.oss_client().is_none()); + } } diff --git a/server-rs/crates/api-server/src/work_author.rs b/server-rs/crates/api-server/src/work_author.rs index e45ebfdd..38b4bea6 100644 --- a/server-rs/crates/api-server/src/work_author.rs +++ b/server-rs/crates/api-server/src/work_author.rs @@ -1,6 +1,6 @@ use module_auth::AuthUser; -use crate::state::AppState; +use crate::state::{AppState, PuzzleApiState}; #[derive(Clone, Debug, PartialEq, Eq)] pub struct WorkAuthorSummary { @@ -14,6 +14,34 @@ pub fn resolve_work_author_by_user_id( owner_user_id: &str, fallback_display_name: Option<&str>, fallback_public_user_code: Option<&str>, +) -> WorkAuthorSummary { + resolve_work_author_by_user_id_with_service( + state.auth_user_service(), + owner_user_id, + fallback_display_name, + fallback_public_user_code, + ) +} + +pub fn resolve_puzzle_work_author_by_user_id( + state: &PuzzleApiState, + owner_user_id: &str, + fallback_display_name: Option<&str>, + fallback_public_user_code: Option<&str>, +) -> WorkAuthorSummary { + resolve_work_author_by_user_id_with_service( + state.auth_user_service(), + owner_user_id, + fallback_display_name, + fallback_public_user_code, + ) +} + +fn resolve_work_author_by_user_id_with_service( + auth_user_service: &module_auth::AuthUserService, + owner_user_id: &str, + fallback_display_name: Option<&str>, + fallback_public_user_code: Option<&str>, ) -> WorkAuthorSummary { let fallback_display_name = normalize_optional_text(fallback_display_name).unwrap_or_else(|| "玩家".to_string()); @@ -26,7 +54,7 @@ pub fn resolve_work_author_by_user_id( }; }; - match state.auth_user_service().get_user_by_id(&owner_user_id) { + match auth_user_service.get_user_by_id(&owner_user_id) { Ok(Some(user)) => map_auth_user_to_work_author_summary(user, fallback_display_name), Ok(None) | Err(_) => WorkAuthorSummary { display_name: fallback_display_name, diff --git a/server-rs/crates/api-server/src/work_play_tracking.rs b/server-rs/crates/api-server/src/work_play_tracking.rs index cad8eb56..33d722db 100644 --- a/server-rs/crates/api-server/src/work_play_tracking.rs +++ b/server-rs/crates/api-server/src/work_play_tracking.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use crate::{ auth::AuthenticatedAccessToken, request_context::RequestContext, - state::AppState, + state::{AppState, PuzzleApiState}, tracking::{TrackingEventDraft, record_tracking_event_after_success}, }; @@ -68,6 +68,22 @@ pub(crate) async fn record_work_play_start_after_success( state: &AppState, request_context: &RequestContext, draft: WorkPlayTrackingDraft, +) { + record_work_play_start_input_after_success(state, request_context, draft).await; +} + +pub(crate) async fn record_puzzle_work_play_start_after_success( + state: &PuzzleApiState, + request_context: &RequestContext, + draft: WorkPlayTrackingDraft, +) { + record_work_play_start_input_after_success(state.root_state(), request_context, draft).await; +} + +async fn record_work_play_start_input_after_success( + state: &AppState, + request_context: &RequestContext, + draft: WorkPlayTrackingDraft, ) { let mut metadata = json!({ "operation": WORK_PLAY_START_EVENT_KEY, diff --git a/server-rs/crates/shared-contracts/src/puzzle_agent.rs b/server-rs/crates/shared-contracts/src/puzzle_agent.rs index aec6e3e9..7f416ca7 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_agent.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_agent.rs @@ -18,6 +18,10 @@ pub struct CreatePuzzleAgentSessionRequest { #[serde(default)] pub reference_image_srcs: Vec, #[serde(default)] + pub reference_image_asset_object_id: Option, + #[serde(default)] + pub reference_image_asset_object_ids: Vec, + #[serde(default)] pub image_model: Option, #[serde(default)] pub ai_redraw: Option, @@ -43,6 +47,10 @@ pub struct ExecutePuzzleAgentActionRequest { #[serde(default)] pub reference_image_srcs: Vec, #[serde(default)] + pub reference_image_asset_object_id: Option, + #[serde(default)] + pub reference_image_asset_object_ids: Vec, + #[serde(default)] pub image_model: Option, #[serde(default)] pub ai_redraw: Option, diff --git a/server-rs/crates/spacetime-client/src/assets.rs b/server-rs/crates/spacetime-client/src/assets.rs index 4cb2c2b7..f8814228 100644 --- a/server-rs/crates/spacetime-client/src/assets.rs +++ b/server-rs/crates/spacetime-client/src/assets.rs @@ -46,6 +46,21 @@ impl SpacetimeClient { .await } + pub async fn get_asset_object( + &self, + asset_object_id: String, + ) -> Result, SpacetimeClientError> { + self.read_after_connect("get_asset_object", move |connection| { + Ok(connection + .db() + .asset_object() + .asset_object_id() + .find(&asset_object_id) + .map(map_asset_object_row)) + }) + .await + } + pub async fn bind_asset_object_to_entity( &self, input: module_assets::AssetEntityBindingInput, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 26ff1ad9..281bad2b 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -585,6 +585,7 @@ impl SpacetimeClient { "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'", "SELECT * FROM creation_entry_config", "SELECT * FROM creation_entry_type_config", + "SELECT * FROM asset_object", ] { if let Ok(subscription) = self .subscribe_cached_read_model_query(connection, broken.clone(), query, false) diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 23ce69a8..e6499a6b 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -120,7 +120,9 @@ pub use self::runtime_profile::{ pub use self::story::{VisualNovelRuntimeEventRecord, VisualNovelRuntimeEventRecordInput}; pub(crate) use self::ai::map_ai_task_procedure_result; -pub(crate) use self::assets::{map_entity_binding_procedure_result, map_procedure_result}; +pub(crate) use self::assets::{ + map_asset_object_row, map_entity_binding_procedure_result, map_procedure_result, +}; pub(crate) use self::auth::{ map_auth_store_snapshot_import_procedure_result, map_auth_store_snapshot_procedure_result, }; diff --git a/server-rs/crates/spacetime-client/src/mapper/assets.rs b/server-rs/crates/spacetime-client/src/mapper/assets.rs index 0e9586f3..8c46ab82 100644 --- a/server-rs/crates/spacetime-client/src/mapper/assets.rs +++ b/server-rs/crates/spacetime-client/src/mapper/assets.rs @@ -115,6 +115,26 @@ pub(crate) fn map_snapshot( } } +pub(crate) fn map_asset_object_row(row: AssetObject) -> AssetObjectRecord { + build_asset_object_record(module_assets::AssetObjectUpsertSnapshot { + asset_object_id: row.asset_object_id, + bucket: row.bucket, + object_key: row.object_key, + access_policy: map_access_policy_back(row.access_policy), + content_type: row.content_type, + content_length: row.content_length, + content_hash: row.content_hash, + version: row.version, + source_job_id: row.source_job_id, + owner_user_id: row.owner_user_id, + profile_id: row.profile_id, + entity_id: row.entity_id, + asset_kind: row.asset_kind, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + }) +} + pub(crate) fn map_access_policy( value: AssetObjectAccessPolicy, ) -> crate::module_bindings::AssetObjectAccessPolicy { diff --git a/src/components/common/CreativeImageInputPanel.tsx b/src/components/common/CreativeImageInputPanel.tsx index 678b1c2d..db299227 100644 --- a/src/components/common/CreativeImageInputPanel.tsx +++ b/src/components/common/CreativeImageInputPanel.tsx @@ -14,6 +14,7 @@ export type CreativeImageInputReferenceImage = { id: string; label: string; imageSrc: string; + assetObjectId?: string | null; }; export type CreativeImageInputPanelLabels = { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 834740ae..2c1b5455 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -1956,6 +1956,8 @@ function buildPuzzleCompileActionFromFormPayload( ...(pictureDescription ? { pictureDescription } : {}), referenceImageSrc: payload?.referenceImageSrc || null, referenceImageSrcs: payload?.referenceImageSrcs ?? [], + referenceImageAssetObjectId: payload?.referenceImageAssetObjectId ?? null, + referenceImageAssetObjectIds: payload?.referenceImageAssetObjectIds ?? [], imageModel: payload?.imageModel ?? null, aiRedraw: payload?.aiRedraw ?? true, candidateCount: 1, @@ -1978,6 +1980,8 @@ function buildPuzzleFormPayloadFromSession( pictureDescription, referenceImageSrc: null, referenceImageSrcs: [], + referenceImageAssetObjectId: null, + referenceImageAssetObjectIds: [], imageModel: null, aiRedraw: true, }; @@ -2008,6 +2012,8 @@ function buildPuzzleFormPayloadFromAction( ? (payload.referenceImageSrc ?? null) : (payload.referenceImageSrc ?? null), referenceImageSrcs: payload.referenceImageSrcs ?? [], + referenceImageAssetObjectId: payload.referenceImageAssetObjectId ?? null, + referenceImageAssetObjectIds: payload.referenceImageAssetObjectIds ?? [], imageModel: payload.action === 'compile_puzzle_draft' ? (payload.imageModel ?? null) @@ -5542,6 +5548,10 @@ export function PlatformEntryFlowShellImpl({ pictureDescription: payload.pictureDescription ?? '', referenceImageSrc: payload.referenceImageSrc ?? null, referenceImageSrcs: payload.referenceImageSrcs ?? [], + referenceImageAssetObjectId: + payload.referenceImageAssetObjectId ?? null, + referenceImageAssetObjectIds: + payload.referenceImageAssetObjectIds ?? [], imageModel: payload.imageModel ?? null, aiRedraw: payload.aiRedraw ?? true, }); diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx index 67c304a8..1eb8c53f 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx @@ -29,6 +29,7 @@ vi.mock('../ResolvedAssetImage', () => ({ vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({ puzzleAssetClient: { listHistoryAssets: vi.fn(), + uploadReferenceImage: vi.fn(), }, })); @@ -90,6 +91,19 @@ beforeEach(() => { if (!Element.prototype.scrollIntoView) { Element.prototype.scrollIntoView = () => {}; } + vi.mocked(puzzleAssetClient.uploadReferenceImage).mockImplementation( + async ({ file }) => ({ + assetObjectId: `asset-reference-${file.name}`, + assetKind: 'puzzle_cover_image', + objectKey: `generated-puzzle-assets/reference/${file.name}`, + imageSrc: `/generated-puzzle-assets/reference/${file.name}`, + ownerUserId: 'user-1', + profileId: null, + entityId: null, + createdAt: '1713686400.000000Z', + updatedAt: '1713686400.000000Z', + }), + ); }); afterEach(() => { @@ -190,6 +204,8 @@ test('puzzle workspace submits the work form instead of agent chat', () => { pictureDescription: '一只猫在雨夜灯牌下回头。', referenceImageSrc: null, referenceImageSrcs: [], + referenceImageAssetObjectId: null, + referenceImageAssetObjectIds: [], imageModel: 'gpt-image-2', aiRedraw: true, }); @@ -325,8 +341,10 @@ test('puzzle workspace selects a history image from the upload card', async () = expect(onCreateFromForm).toHaveBeenCalledWith({ seedText: '保留历史图里的主体,改成晴天花园。', pictureDescription: '保留历史图里的主体,改成晴天花园。', - referenceImageSrc: '/generated-puzzle-assets/history/image.png', + referenceImageSrc: null, referenceImageSrcs: [], + referenceImageAssetObjectId: 'asset-history-1', + referenceImageAssetObjectIds: [], imageModel: 'gpt-image-2', aiRedraw: true, }); @@ -384,6 +402,8 @@ test('puzzle workspace falls back to compile action for restored sessions', () = promptText: '潮雾中的灯塔与断桥', referenceImageSrc: null, referenceImageSrcs: [], + referenceImageAssetObjectId: null, + referenceImageAssetObjectIds: [], imageModel: 'gpt-image-2', aiRedraw: true, candidateCount: 1, @@ -484,6 +504,8 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => { pictureDescription: '旧街灯牌下的猫和发光雨伞。', referenceImageSrc: null, referenceImageSrcs: [], + referenceImageAssetObjectId: null, + referenceImageAssetObjectIds: [], imageModel: 'gpt-image-2', aiRedraw: true, }); @@ -528,8 +550,10 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () => expect(onCreateFromForm).toHaveBeenCalledWith({ seedText: 'first-level.png', pictureDescription: 'first-level.png', - referenceImageSrc: uploadedDataUrl, + referenceImageSrc: '/generated-puzzle-assets/reference/first-level.png', referenceImageSrcs: [], + referenceImageAssetObjectId: 'asset-reference-first-level.png', + referenceImageAssetObjectIds: [], imageModel: 'gpt-image-2', aiRedraw: false, }); @@ -584,6 +608,8 @@ test('puzzle workspace submits history image when AI redraw is off', async () => pictureDescription: '历史素材 · image.png', referenceImageSrc: '/generated-puzzle-assets/history/image.png', referenceImageSrcs: [], + referenceImageAssetObjectId: 'asset-history-1', + referenceImageAssetObjectIds: [], imageModel: 'gpt-image-2', aiRedraw: false, }); @@ -593,6 +619,17 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a const onCreateFromForm = vi.fn(); const uploadedDataUrl = 'data:image/png;base64,uploaded-square'; stubReferenceImageUpload(uploadedDataUrl); + vi.mocked(puzzleAssetClient.uploadReferenceImage).mockResolvedValue({ + assetObjectId: 'asset-reference-main-1', + assetKind: 'puzzle_cover_image', + objectKey: 'generated-puzzle-assets/reference/main-1.png', + imageSrc: '/generated-puzzle-assets/reference/main-1.png', + ownerUserId: 'user-1', + profileId: null, + entityId: null, + createdAt: '1713686400.000000Z', + updatedAt: '1713686400.000000Z', + }); render( { expect(screen.getByAltText('拼图图片')).toBeTruthy(); }); + expect(puzzleAssetClient.uploadReferenceImage).toHaveBeenCalledWith({ + file: expect.any(File), + }); fireEvent.change(screen.getByLabelText('画面AI重绘要求(提示词)'), { target: { value: '保留上传画面的主体和构图,改成雨夜灯街。' }, }); @@ -621,8 +661,101 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a expect(onCreateFromForm).toHaveBeenCalledWith({ seedText: '保留上传画面的主体和构图,改成雨夜灯街。', pictureDescription: '保留上传画面的主体和构图,改成雨夜灯街。', - referenceImageSrc: uploadedDataUrl, + referenceImageSrc: null, referenceImageSrcs: [], + referenceImageAssetObjectId: 'asset-reference-main-1', + referenceImageAssetObjectIds: [], + imageModel: 'gpt-image-2', + aiRedraw: true, + }); +}); + +test('puzzle workspace uploads prompt references as asset object ids', async () => { + const onCreateFromForm = vi.fn(); + const uploadedSources = [ + 'data:image/png;base64,reference-1', + 'data:image/png;base64,reference-2', + ]; + let readIndex = 0; + stubReferenceImageUpload(uploadedSources[0] ?? 'data:image/png;base64,reference-1'); + class MockFileReader { + result: string | null = null; + onload: null | (() => void) = null; + onerror: null | (() => void) = null; + + readAsDataURL() { + this.result = uploadedSources[readIndex] ?? uploadedSources[0] ?? ''; + readIndex += 1; + this.onload?.(); + } + } + vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader); + vi.mocked(puzzleAssetClient.uploadReferenceImage) + .mockResolvedValueOnce({ + assetObjectId: 'asset-reference-prompt-1', + assetKind: 'puzzle_cover_image', + objectKey: 'generated-puzzle-assets/reference/prompt-1.png', + imageSrc: '/generated-puzzle-assets/reference/prompt-1.png', + ownerUserId: 'user-1', + profileId: null, + entityId: null, + createdAt: '1713686400.000000Z', + updatedAt: '1713686400.000000Z', + }) + .mockResolvedValueOnce({ + assetObjectId: 'asset-reference-prompt-2', + assetKind: 'puzzle_cover_image', + objectKey: 'generated-puzzle-assets/reference/prompt-2.png', + imageSrc: '/generated-puzzle-assets/reference/prompt-2.png', + ownerUserId: 'user-1', + profileId: null, + entityId: null, + createdAt: '1713686400.000000Z', + updatedAt: '1713686400.000000Z', + }); + + render( + {}} + onSubmitMessage={() => {}} + onExecuteAction={() => {}} + onCreateFromForm={onCreateFromForm} + />, + ); + + fireEvent.change(screen.getByLabelText('画面描述'), { + target: { value: '一只猫在雨夜灯牌下回头。' }, + }); + fireEvent.change(screen.getByLabelText('上传参考图', { selector: 'input' }), { + target: { + files: uploadedSources.map( + (_source, index) => + new File(['x'], `reference-${index + 1}.png`, { + type: 'image/png', + }), + ), + }, + }); + + await waitFor(() => { + expect(screen.getAllByRole('button', { name: /预览参考图/u })).toHaveLength( + 2, + ); + }); + fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u })); + confirmPuzzlePointCost(); + + expect(onCreateFromForm).toHaveBeenCalledWith({ + seedText: '一只猫在雨夜灯牌下回头。', + pictureDescription: '一只猫在雨夜灯牌下回头。', + referenceImageSrc: null, + referenceImageSrcs: [], + referenceImageAssetObjectId: null, + referenceImageAssetObjectIds: [ + 'asset-reference-prompt-1', + 'asset-reference-prompt-2', + ], imageModel: 'gpt-image-2', aiRedraw: true, }); @@ -705,7 +838,15 @@ test('puzzle workspace uploads prompt reference images from the description box' seedText: '一只猫在雨夜灯牌下回头。', pictureDescription: '一只猫在雨夜灯牌下回头。', referenceImageSrc: null, - referenceImageSrcs: uploadedSources.slice(0, 5), + referenceImageSrcs: [], + referenceImageAssetObjectId: null, + referenceImageAssetObjectIds: [ + 'asset-reference-reference-1.png', + 'asset-reference-reference-2.png', + 'asset-reference-reference-3.png', + 'asset-reference-reference-4.png', + 'asset-reference-reference-5.png', + ], imageModel: 'gpt-image-2', aiRedraw: true, }); diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx index 4ce71a15..d941464a 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx @@ -16,9 +16,11 @@ import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works import { cropPuzzleReferenceImageDataUrl, isPuzzleReferenceImageSquare, + puzzleReferenceImageDataUrlToFile, readPuzzleReferenceImageAsDataUrl, readPuzzleReferenceImageForUpload, } from '../../services/puzzleReferenceImage'; +import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient'; import { CreativeImageInputPanel, type CreativeImageInputReferenceImage, @@ -54,6 +56,7 @@ type PuzzleAgentWorkspaceProps = { type PuzzleFormState = { pictureDescription: string; referenceImageSrc: string; + referenceImageAssetObjectId: string; referenceImageLabel: string; referenceImageSrcs: CreativeImageInputReferenceImage[]; imageModel: PuzzleImageModelId; @@ -63,6 +66,7 @@ type PuzzleFormState = { const EMPTY_FORM_STATE: PuzzleFormState = { pictureDescription: '', referenceImageSrc: '', + referenceImageAssetObjectId: '', referenceImageLabel: '', referenceImageSrcs: [], imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, @@ -74,6 +78,7 @@ const PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT = 5; type PuzzleImageCropState = { source: string; label: string; + fileName: string; imageSize: { width: number; height: number }; cropRect: SquareImageCropRect; error: string | null; @@ -97,11 +102,14 @@ function resolveInitialFormState( return { pictureDescription: formDraft.pictureDescription ?? '', referenceImageSrc: initialFormPayload?.referenceImageSrc ?? '', + referenceImageAssetObjectId: + initialFormPayload?.referenceImageAssetObjectId ?? '', referenceImageLabel: initialFormPayload?.referenceImageSrc ? '已选择拼图图片' : '', referenceImageSrcs: createPuzzlePromptReferenceImagesFromSources( initialFormPayload?.referenceImageSrcs, + initialFormPayload?.referenceImageAssetObjectIds, ), imageModel: normalizePuzzleImageModel(initialFormPayload?.imageModel), aiRedraw: initialFormPayload?.aiRedraw ?? true, @@ -115,11 +123,14 @@ function resolveInitialFormState( initialFormPayload.seedText ?? '', referenceImageSrc: initialFormPayload.referenceImageSrc ?? '', + referenceImageAssetObjectId: + initialFormPayload.referenceImageAssetObjectId ?? '', referenceImageLabel: initialFormPayload.referenceImageSrc ? '已选择拼图图片' : '', referenceImageSrcs: createPuzzlePromptReferenceImagesFromSources( initialFormPayload.referenceImageSrcs, + initialFormPayload.referenceImageAssetObjectIds, ), imageModel: normalizePuzzleImageModel(initialFormPayload.imageModel), aiRedraw: initialFormPayload.aiRedraw ?? true, @@ -138,6 +149,7 @@ function resolveInitialFormState( session.seedText || '', referenceImageSrc: '', + referenceImageAssetObjectId: '', referenceImageLabel: '', referenceImageSrcs: [], imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, @@ -166,14 +178,46 @@ function normalizePuzzlePromptReferenceSources( function createPuzzlePromptReferenceImagesFromSources( sources: readonly string[] | null | undefined, + assetObjectIds: readonly string[] | null | undefined = [], ): CreativeImageInputReferenceImage[] { - return normalizePuzzlePromptReferenceSources(sources).map( + const assetIds = normalizePuzzleAssetObjectIds(assetObjectIds); + const sourceImages = normalizePuzzlePromptReferenceSources(sources).map( (imageSrc, index) => ({ id: `restored:${index}:${imageSrc}`, label: `参考图 ${index + 1}`, imageSrc, + assetObjectId: assetIds[index] ?? null, }), ); + if (sourceImages.length > 0) { + return sourceImages; + } + + return assetIds.map((assetObjectId, index) => ({ + id: `restored-asset:${index}:${assetObjectId}`, + label: `参考图 ${index + 1}`, + imageSrc: '', + assetObjectId, + })); +} + +function normalizePuzzleAssetObjectIds( + assetObjectIds: readonly (string | null | undefined)[] | null | undefined, +) { + const normalizedIds: string[] = []; + for (const assetObjectId of assetObjectIds ?? []) { + const normalized = assetObjectId?.trim() ?? ''; + if ( + normalized && + !normalizedIds.some((current) => current === normalized) + ) { + normalizedIds.push(normalized); + } + if (normalizedIds.length >= PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT) { + break; + } + } + return normalizedIds; } function addPuzzlePromptReferenceImage( @@ -256,6 +300,21 @@ export function PuzzleAgentWorkspace({ ), [formState.referenceImageSrc, formState.referenceImageSrcs], ); + const promptReferenceAssetObjectIds = useMemo( + () => + formState.referenceImageSrc + ? [] + : normalizePuzzleAssetObjectIds( + formState.referenceImageSrcs.map((image) => image.assetObjectId), + ), + [formState.referenceImageSrc, formState.referenceImageSrcs], + ); + const mainReferenceImageSrcForPayload = + formState.referenceImageAssetObjectId && formState.aiRedraw + ? null + : formState.referenceImageSrc || null; + const promptReferenceImageSrcsForPayload = + promptReferenceAssetObjectIds.length > 0 ? [] : promptReferenceImageSrcs; const canSubmit = formState.aiRedraw ? Boolean(pictureDescription) && !isBusy : Boolean(formState.referenceImageSrc) && !isBusy; @@ -263,16 +322,21 @@ export function PuzzleAgentWorkspace({ () => ({ seedText: pictureDescription, pictureDescription, - referenceImageSrc: formState.referenceImageSrc || null, - referenceImageSrcs: promptReferenceImageSrcs, + referenceImageSrc: mainReferenceImageSrcForPayload, + referenceImageSrcs: promptReferenceImageSrcsForPayload, + referenceImageAssetObjectId: + formState.referenceImageAssetObjectId || null, + referenceImageAssetObjectIds: promptReferenceAssetObjectIds, imageModel: formState.imageModel, aiRedraw: formState.aiRedraw, }), [ formState.aiRedraw, - formState.referenceImageSrc, + formState.referenceImageAssetObjectId, formState.imageModel, - promptReferenceImageSrcs, + mainReferenceImageSrcForPayload, + promptReferenceAssetObjectIds, + promptReferenceImageSrcsForPayload, pictureDescription, ], ); @@ -280,6 +344,8 @@ export function PuzzleAgentWorkspace({ autosavePayload.pictureDescription, autosavePayload.referenceImageSrc, autosavePayload.referenceImageSrcs, + autosavePayload.referenceImageAssetObjectId, + autosavePayload.referenceImageAssetObjectIds, autosavePayload.aiRedraw, autosavePayload.imageModel, ]); @@ -333,6 +399,7 @@ export function PuzzleAgentWorkspace({ setCropState({ source: uploadImage.dataUrl, label: file.name.trim() || '本地拼图图片', + fileName: file.name.trim() || 'puzzle-reference.jpg', imageSize, cropRect: buildCenteredSquareImageCropRect(imageSize), error: null, @@ -342,9 +409,11 @@ export function PuzzleAgentWorkspace({ return; } + const asset = await puzzleAssetClient.uploadReferenceImage({ file }); setFormState((current) => ({ ...current, - referenceImageSrc: uploadImage.dataUrl, + referenceImageSrc: asset.imageSrc || uploadImage.dataUrl, + referenceImageAssetObjectId: asset.assetObjectId, referenceImageLabel: file.name.trim() || '本地拼图图片', })); setReferenceImageError(null); @@ -372,11 +441,18 @@ export function PuzzleAgentWorkspace({ try { const images = await Promise.all( - files.slice(0, remainingSlots).map(async (file, index) => ({ - id: `prompt-upload:${Date.now()}:${index}:${file.name}`, - label: file.name.trim() || `参考图 ${index + 1}`, - imageSrc: await readPuzzleReferenceImageAsDataUrl(file), - })), + files.slice(0, remainingSlots).map(async (file, index) => { + const [imageSrc, asset] = await Promise.all([ + readPuzzleReferenceImageAsDataUrl(file), + puzzleAssetClient.uploadReferenceImage({ file }), + ]); + return { + id: `prompt-upload:${Date.now()}:${index}:${file.name}`, + label: file.name.trim() || `参考图 ${index + 1}`, + imageSrc: asset.imageSrc || imageSrc, + assetObjectId: asset.assetObjectId, + }; + }), ); setFormState((current) => ({ ...current, @@ -439,9 +515,15 @@ export function PuzzleAgentWorkspace({ cropY: currentCropState.cropRect.y, cropSize: currentCropState.cropRect.size, }); + const file = puzzleReferenceImageDataUrlToFile( + dataUrl, + currentCropState.fileName, + ); + const asset = await puzzleAssetClient.uploadReferenceImage({ file }); setFormState((current) => ({ ...current, - referenceImageSrc: dataUrl, + referenceImageSrc: asset.imageSrc || dataUrl, + referenceImageAssetObjectId: asset.assetObjectId, referenceImageLabel: currentCropState.label, })); setCropState(null); @@ -482,8 +564,11 @@ export function PuzzleAgentWorkspace({ const payload = { seedText: payloadPictureDescription, pictureDescription: payloadPictureDescription, - referenceImageSrc: formState.referenceImageSrc || null, - referenceImageSrcs: promptReferenceImageSrcs, + referenceImageSrc: mainReferenceImageSrcForPayload, + referenceImageSrcs: promptReferenceImageSrcsForPayload, + referenceImageAssetObjectId: + formState.referenceImageAssetObjectId || null, + referenceImageAssetObjectIds: promptReferenceAssetObjectIds, imageModel: formState.imageModel, aiRedraw: formState.aiRedraw, }; @@ -499,8 +584,11 @@ export function PuzzleAgentWorkspace({ action: 'compile_puzzle_draft', promptText: payloadPictureDescription, pictureDescription: payloadPictureDescription, - referenceImageSrc: formState.referenceImageSrc || null, - referenceImageSrcs: promptReferenceImageSrcs, + referenceImageSrc: mainReferenceImageSrcForPayload, + referenceImageSrcs: promptReferenceImageSrcsForPayload, + referenceImageAssetObjectId: + formState.referenceImageAssetObjectId || null, + referenceImageAssetObjectIds: promptReferenceAssetObjectIds, imageModel: formState.imageModel, aiRedraw: formState.aiRedraw, candidateCount: 1, @@ -510,6 +598,7 @@ export function PuzzleAgentWorkspace({ setFormState((current) => ({ ...current, referenceImageSrc: '', + referenceImageAssetObjectId: '', referenceImageLabel: '', aiRedraw: true, })); @@ -645,6 +734,7 @@ export function PuzzleAgentWorkspace({ setFormState((current) => ({ ...current, referenceImageSrc: asset.imageSrc, + referenceImageAssetObjectId: asset.assetObjectId, referenceImageLabel: getPuzzleHistoryAssetReferenceLabel( asset.imageSrc, ), diff --git a/src/services/puzzle-works/puzzleAssetClient.ts b/src/services/puzzle-works/puzzleAssetClient.ts index 4b5d0e9a..30c90ddf 100644 --- a/src/services/puzzle-works/puzzleAssetClient.ts +++ b/src/services/puzzle-works/puzzleAssetClient.ts @@ -13,6 +13,153 @@ export type PuzzleHistoryAsset = { updatedAt: string; }; +export type PuzzleReferenceAsset = PuzzleHistoryAsset & { + objectKey: string; +}; + +type DirectUploadTicketResponse = { + upload: { + bucket: string; + host: string; + objectKey: string; + legacyPublicPath: string; + formFields: Record; + }; +}; + +type ConfirmAssetObjectResponse = { + assetObject: { + assetObjectId: string; + objectKey: string; + assetKind: 'puzzle_cover_image'; + ownerUserId?: string | null; + profileId?: string | null; + entityId?: string | null; + createdAt?: string; + updatedAt?: string; + }; +}; + +const PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES = 12 * 1024 * 1024; + +const MIME_BY_EXTENSION: Record = { + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', +}; + +function resolvePuzzleImageContentType(file: File) { + if (file.type.trim()) { + return file.type.trim(); + } + + const extension = file.name.split('.').pop()?.trim().toLowerCase() ?? ''; + return MIME_BY_EXTENSION[extension] ?? 'application/octet-stream'; +} + +function validatePuzzleReferenceImageFile(file: File) { + const contentType = resolvePuzzleImageContentType(file); + if (file.size <= 0) { + throw new Error('参考图文件为空,请重新选择。'); + } + if (file.size > PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES) { + throw new Error('参考图过大,请压缩后再上传。'); + } + if (!contentType.startsWith('image/')) { + throw new Error('参考图必须是图片文件。'); + } +} + +async function postDirectUploadFile( + upload: DirectUploadTicketResponse['upload'], + file: File, +) { + const formData = new FormData(); + Object.entries(upload.formFields).forEach(([key, value]) => { + if (value !== null && value !== undefined) { + formData.append(key, value); + } + }); + formData.append('file', file); + + const response = await fetch(upload.host, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error('上传拼图参考图失败。'); + } +} + +export async function uploadPuzzleReferenceImage(payload: { + file: File; +}): Promise { + validatePuzzleReferenceImageFile(payload.file); + const contentType = resolvePuzzleImageContentType(payload.file); + const uploadedAt = Date.now(); + const ticket = await requestJson( + '/api/assets/direct-upload-tickets', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + legacyPrefix: 'generated-puzzle-assets', + pathSegments: ['puzzle-reference', 'draft', `${uploadedAt}`], + fileName: payload.file.name, + contentType, + access: 'private', + maxSizeBytes: PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES, + metadata: { + asset_kind: 'puzzle_cover_image', + puzzle_slot: 'reference_image', + }, + }), + }, + '创建拼图参考图上传凭证失败', + ); + + await postDirectUploadFile(ticket.upload, payload.file); + + const confirmed = await requestJson( + '/api/assets/objects/confirm', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + bucket: ticket.upload.bucket, + objectKey: ticket.upload.objectKey, + contentType, + contentLength: payload.file.size, + assetKind: 'puzzle_cover_image', + accessPolicy: 'private', + }), + }, + '确认拼图参考图失败', + ); + + return { + assetObjectId: confirmed.assetObject.assetObjectId, + assetKind: confirmed.assetObject.assetKind, + objectKey: confirmed.assetObject.objectKey, + imageSrc: ticket.upload.legacyPublicPath, + ownerUserId: confirmed.assetObject.ownerUserId, + ownerLabel: confirmed.assetObject.ownerUserId + ? `账号 ${confirmed.assetObject.ownerUserId}` + : '当前账号', + profileId: confirmed.assetObject.profileId, + entityId: confirmed.assetObject.entityId, + createdAt: confirmed.assetObject.createdAt ?? '', + updatedAt: confirmed.assetObject.updatedAt ?? '', + }; +} + +export const puzzleReferenceAssetTestUtils = { + maxUploadBytes: PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES, + validateFile: validatePuzzleReferenceImageFile, +}; + /** * 读取历史拼图图片素材。结果页只把它们作为参考图来源, * 不直接替换当前正式图,正式图仍由后端单图生成链路写回。 @@ -34,4 +181,5 @@ export async function listPuzzleHistoryAssets(payload: { limit?: number }) { export const puzzleAssetClient = { listHistoryAssets: listPuzzleHistoryAssets, + uploadReferenceImage: uploadPuzzleReferenceImage, }; diff --git a/src/services/puzzleReferenceImage.ts b/src/services/puzzleReferenceImage.ts index 4cb66ca4..1eac5862 100644 --- a/src/services/puzzleReferenceImage.ts +++ b/src/services/puzzleReferenceImage.ts @@ -238,3 +238,18 @@ export async function cropPuzzleReferenceImageDataUrl({ ), ); } + +export function puzzleReferenceImageDataUrlToFile( + dataUrl: string, + fileName = 'puzzle-reference.jpg', +) { + const [metadata = '', encoded = ''] = dataUrl.split(',', 2); + const mimeType = + metadata.match(/^data:([^;]+);base64$/u)?.[1] ?? 'image/jpeg'; + const binary = atob(encoded); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return new File([bytes], fileName, { type: mimeType }); +}