diff --git a/docs/technical/API_SERVER_MERGE_COMPILE_FIX_2026-05-02.md b/docs/technical/API_SERVER_MERGE_COMPILE_FIX_2026-05-02.md new file mode 100644 index 00000000..d567b2af --- /dev/null +++ b/docs/technical/API_SERVER_MERGE_COMPILE_FIX_2026-05-02.md @@ -0,0 +1,66 @@ +# api-server 合并后编译修复记录 + +日期:`2026-05-02` + +## 背景 + +`codex/ddd` 合入 `master` 后,`api-server` 编译失败。问题集中在合并后的跨 crate 契约缺口:`api-server` 已引用新接口或新字段,但对应的领域 crate 与 HTTP 转接层没有同步补齐。 + +## 修复范围 + +1. `module-auth` 补齐个人资料更新契约: + - 新增 `UpdateProfileInput` 与 `UpdateProfileResult`。 + - `AuthUser` 增加 `avatar_url` 与 `created_at`,并通过 `serde(default)` 兼容旧认证快照。 + - `PasswordEntryService::update_profile` 统一校验昵称与头像 data URL,并写回认证快照。 +2. 微信绑定手机号结果补齐 `activated_new_user`: + - 待绑定微信账号绑定新手机号时返回 `true`,用于注册奖励发放。 + - 待绑定微信账号合并到已有手机号账号时返回 `false`。 +3. 拼图运行态补齐 HTTP 转接: + - `POST /api/runtime/puzzle/runs/{run_id}/drag` 读取 `DragPuzzlePieceRequest`。 + - 转发到 SpacetimeDB client 的 `drag_puzzle_piece_or_group` procedure 包装。 +4. runtime story 聊天接口改用当前 shared contract: + - 旧 `runtime_story::RuntimeStorySnapshotPayload` 已删除。 + - `api-server` 侧临时别名到 `story::StoryRuntimeSnapshotPayload`,保持现有请求结构不漂移。 +5. `api-server` 全量测试修复: + - `custom_world_foundation_draft` 的 mock LLM 响应仍是旧 Chat Completions 结构。 + - 当前 `LlmClient` 默认走 Responses API,测试 mock 已改为 `output[].content[].text` 结构。 +6. 前端全量测试期望补齐: + - 自定义世界结果页的第二幕场景预览断言改为校验第二幕生成图。 + - 拼图下一关交互测试保留后端下一关调用断言,并明确只调用一次。 + - 拼图正式 run 客户端补回 `/drag` 调用包装,测试 mock 同步走正式 run 的 `swap/drag` 服务路径。 +7. 前端门禁合并缺口修复: + - 拼图测试运行前更新作品时同步提交 `levels`,对齐当前 `updatePuzzleWork` 契约。 + - 大鱼和 Match3D 测试 mock 对齐当前共享契约,避免 typecheck 阻塞。 + - 移除 Vite dev proxy 中重复的 `/api/creation` key,避免 build gate 将 warning 视为失败。 + +## 验证 + +本次修复应至少通过: + +```powershell +cargo check -p module-auth --manifest-path server-rs\Cargo.toml +cargo check -p api-server --manifest-path server-rs\Cargo.toml +cargo test -p module-auth --manifest-path server-rs\Cargo.toml +cargo test -p api-server --manifest-path server-rs\Cargo.toml +npm run check:encoding +npm test +npm run typecheck +npm run build +npm run check:content +``` + +后端代码变更后,按项目约束还需要用 `npm run api-server:maincloud` 做一次启动验证。 + +本轮最终结果: + +- `cargo test -p module-auth --manifest-path server-rs\Cargo.toml` 已通过,结果为 `17 passed; 0 failed`。 +- `cargo test -p api-server --manifest-path server-rs\Cargo.toml` 已通过,结果为 `237 passed; 0 failed; 4 ignored`。 +- `cargo test --manifest-path server-rs\Cargo.toml` 已通过,结果同 `api-server` 默认测试。 +- `npm test` 已通过,结果为 `160 passed` 个测试文件、`704 passed` 个用例。 +- `npm run typecheck`、`npm run build`、`npm run check:content`、`npm run check:encoding`、`git diff --check` 已通过。 +- `npm run api-server:maincloud` 已完成启动烟测,`/healthz` 返回 `200`;期间 Maincloud 订阅恢复出现 `503` warning,但未阻止服务启动。 + +仍需单独处理的非本轮阻塞: + +- `cargo test --workspace --manifest-path server-rs\Cargo.toml` 在 Windows 原生测试链接 SpacetimeDB module crate 时失败,缺失 `bytes_sink_write`、`console_log`、`table_id_from_name`、`identity`、`datastore_table_scan_bsatn` 等 SpacetimeDB 宿主符号;这是 module crate 原生 Windows test 链接环境问题。 +- `npm run check` 当前仍会停在全仓 `lint:eslint`,涉及大量既有 import 排序、未使用符号和 hook dependency lint debt;本轮触碰文件已清掉 lint error,仅 `PlatformEntryFlowShellImpl.tsx` 保留既有 hook dependency warnings。 diff --git a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs index df2aab05..90b79300 100644 --- a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs +++ b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs @@ -2739,13 +2739,19 @@ mod tests { fn llm_response(content: &str) -> String { json!({ "id": "resp_01", - "choices": [ + "model": "test-model", + "output": [ { - "message": { - "content": content, - } + "type": "message", + "content": [ + { + "type": "output_text", + "text": content, + } + ], } - ] + ], + "status": "completed" }) .to_string() } diff --git a/server-rs/crates/api-server/src/llm.rs b/server-rs/crates/api-server/src/llm.rs index 6d1c8fbc..0f5da782 100644 --- a/server-rs/crates/api-server/src/llm.rs +++ b/server-rs/crates/api-server/src/llm.rs @@ -7,7 +7,7 @@ use axum::{ sse::{Event, Sse}, }, }; -use platform_llm::{LlmError, LlmMessage, LlmMessageRole, LlmTextProtocol, LlmTextRequest}; +use platform_llm::{LlmMessage, LlmMessageRole, LlmTextProtocol, LlmTextRequest}; use serde_json::{Value, json}; use shared_contracts::llm::{ LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole, diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 7c819e37..90a5aaae 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -1276,6 +1276,58 @@ pub async fn swap_puzzle_pieces( )) } +pub async fn drag_puzzle_piece_or_group( + State(state): State, + AxumPath(run_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; + ensure_non_empty( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + &payload.piece_id, + "pieceId", + )?; + + let run = state + .spacetime_client() + .drag_puzzle_piece_or_group(PuzzleRunDragRecordInput { + run_id, + owner_user_id: authenticated.claims().user_id().to_string(), + piece_id: payload.piece_id, + target_row: payload.target_row, + target_col: payload.target_col, + dragged_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), + }, + )) +} + pub async fn advance_puzzle_next_level( State(state): State, AxumPath(run_id): AxumPath, diff --git a/server-rs/crates/api-server/src/runtime_chat.rs b/server-rs/crates/api-server/src/runtime_chat.rs index 0536fd91..855b4ba6 100644 --- a/server-rs/crates/api-server/src/runtime_chat.rs +++ b/server-rs/crates/api-server/src/runtime_chat.rs @@ -10,7 +10,7 @@ use axum::{ use platform_llm::{LlmMessage, LlmTextRequest}; use serde::Deserialize; use serde_json::{Value, json}; -use shared_contracts::runtime_story::RuntimeStorySnapshotPayload; +use shared_contracts::story::StoryRuntimeSnapshotPayload as RuntimeStorySnapshotPayload; use std::convert::Infallible; use module_runtime_story::{ diff --git a/server-rs/crates/api-server/src/runtime_chat_plain.rs b/server-rs/crates/api-server/src/runtime_chat_plain.rs index 8e1b17e8..27ff0a7c 100644 --- a/server-rs/crates/api-server/src/runtime_chat_plain.rs +++ b/server-rs/crates/api-server/src/runtime_chat_plain.rs @@ -10,7 +10,7 @@ use axum::{ use platform_llm::{LlmMessage, LlmTextRequest}; use serde::Deserialize; use serde_json::{Value, json}; -use shared_contracts::runtime_story::RuntimeStorySnapshotPayload; +use shared_contracts::story::StoryRuntimeSnapshotPayload as RuntimeStorySnapshotPayload; use std::convert::Infallible; use crate::{ diff --git a/server-rs/crates/module-auth/src/application.rs b/server-rs/crates/module-auth/src/application.rs index 31e618c6..032fb9c6 100644 --- a/server-rs/crates/module-auth/src/application.rs +++ b/server-rs/crates/module-auth/src/application.rs @@ -29,6 +29,11 @@ pub struct ChangePasswordResult { pub user: AuthUser, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UpdateProfileResult { + pub user: AuthUser, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct ResetPasswordResult { pub user: AuthUser, @@ -76,6 +81,7 @@ pub struct ConsumeWechatAuthStateResult { #[derive(Clone, Debug, PartialEq, Eq)] pub struct BindWechatPhoneResult { pub user: AuthUser, + pub activated_new_user: bool, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/server-rs/crates/module-auth/src/commands.rs b/server-rs/crates/module-auth/src/commands.rs index eaf5ee28..35302bd3 100644 --- a/server-rs/crates/module-auth/src/commands.rs +++ b/server-rs/crates/module-auth/src/commands.rs @@ -29,6 +29,13 @@ pub struct ResetPasswordInput { pub new_password: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UpdateProfileInput { + pub user_id: String, + pub display_name: Option, + pub avatar_url: Option, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct SendPhoneCodeInput { pub phone_number: String, diff --git a/server-rs/crates/module-auth/src/domain.rs b/server-rs/crates/module-auth/src/domain.rs index d139138f..f1a4f599 100644 --- a/server-rs/crates/module-auth/src/domain.rs +++ b/server-rs/crates/module-auth/src/domain.rs @@ -55,11 +55,15 @@ pub struct AuthUser { pub public_user_code: String, pub username: String, pub display_name: String, + #[serde(default)] + pub avatar_url: Option, pub phone_number_masked: Option, pub login_method: AuthLoginMethod, pub binding_status: AuthBindingStatus, pub wechat_bound: bool, pub token_version: u64, + #[serde(default)] + pub created_at: String, } /// 规范化后的手机号快照。 diff --git a/server-rs/crates/module-auth/src/errors.rs b/server-rs/crates/module-auth/src/errors.rs index 539b0689..29541f18 100644 --- a/server-rs/crates/module-auth/src/errors.rs +++ b/server-rs/crates/module-auth/src/errors.rs @@ -8,6 +8,9 @@ use std::{error::Error, fmt}; pub enum PasswordEntryError { InvalidPhoneNumber, InvalidPasswordLength, + InvalidDisplayName, + InvalidAvatarDataUrl, + EmptyProfileUpdate, InvalidPublicUserCode, InvalidCredentials, UserNotFound, @@ -61,6 +64,9 @@ impl fmt::Display for PasswordEntryError { match self { Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"), Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"), + Self::InvalidDisplayName => f.write_str("昵称格式不正确"), + Self::InvalidAvatarDataUrl => f.write_str("头像图片格式不正确"), + Self::EmptyProfileUpdate => f.write_str("请至少修改昵称或头像"), Self::InvalidPublicUserCode => f.write_str("叙世号格式不正确"), Self::InvalidCredentials => f.write_str("手机号或密码错误"), Self::UserNotFound => f.write_str("用户不存在"), @@ -135,6 +141,9 @@ pub(crate) fn map_password_store_error(error: PasswordEntryError) -> RefreshSess PasswordEntryError::Store(message) => RefreshSessionError::Store(message), PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPasswordLength + | PasswordEntryError::InvalidDisplayName + | PasswordEntryError::InvalidAvatarDataUrl + | PasswordEntryError::EmptyProfileUpdate | PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidCredentials | PasswordEntryError::UserNotFound @@ -150,6 +159,9 @@ pub(crate) fn map_password_error_to_phone_error(error: PasswordEntryError) -> Ph PasswordEntryError::PasswordHash(message) => PhoneAuthError::PasswordHash(message), PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPasswordLength + | PasswordEntryError::InvalidDisplayName + | PasswordEntryError::InvalidAvatarDataUrl + | PasswordEntryError::EmptyProfileUpdate | PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidCredentials | PasswordEntryError::UserNotFound => PhoneAuthError::Store("用户仓储读取失败".to_string()), @@ -161,6 +173,9 @@ pub(crate) fn map_password_error_to_logout_error(error: PasswordEntryError) -> L PasswordEntryError::Store(message) => LogoutError::Store(message), PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPasswordLength + | PasswordEntryError::InvalidDisplayName + | PasswordEntryError::InvalidAvatarDataUrl + | PasswordEntryError::EmptyProfileUpdate | PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidCredentials | PasswordEntryError::UserNotFound diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index a90ad867..46aa2d46 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -1,4 +1,4 @@ -mod application; +mod application; mod commands; mod domain; mod errors; @@ -203,6 +203,30 @@ impl PasswordEntryService { .map(|maybe_user| maybe_user.map(|stored| PublicUserSearchResult { user: stored.user })) } + pub fn update_profile( + &self, + input: UpdateProfileInput, + ) -> Result { + let display_name = match input.display_name { + Some(value) => Some(normalize_profile_display_name(value.as_str())?), + None => None, + }; + let avatar_url = match input.avatar_url { + Some(value) => Some(normalize_profile_avatar_url(value.as_str())?), + None => None, + }; + if display_name.is_none() && avatar_url.is_none() { + return Err(PasswordEntryError::EmptyProfileUpdate); + } + + let user = self + .store + .update_user_profile(&input.user_id, display_name, avatar_url)? + .ok_or(PasswordEntryError::UserNotFound)?; + + Ok(UpdateProfileResult { user }) + } + pub async fn change_password( &self, input: ChangePasswordInput, @@ -594,11 +618,14 @@ impl PhoneAuthService { return Err(PhoneAuthError::UserStateMismatch); } - let merged_user = self + let (merged_user, activated_new_user) = self .store .bind_wechat_phone_to_user(&input.user_id, normalized_phone)?; - Ok(BindWechatPhoneResult { user: merged_user }) + Ok(BindWechatPhoneResult { + user: merged_user, + activated_new_user, + }) } } @@ -985,6 +1012,36 @@ impl InMemoryAuthStore { .cloned()) } + fn update_user_profile( + &self, + user_id: &str, + display_name: Option, + avatar_url: Option, + ) -> Result, PasswordEntryError> { + let mut state = self + .inner + .lock() + .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; + + for stored_user in state.users_by_username.values_mut() { + if stored_user.user.id != user_id { + continue; + } + + if let Some(display_name) = display_name { + stored_user.user.display_name = display_name; + } + if let Some(avatar_url) = avatar_url { + stored_user.user.avatar_url = Some(avatar_url); + } + let next_user = stored_user.user.clone(); + self.persist_password_state(&state)?; + return Ok(Some(next_user)); + } + + Ok(None) + } + fn create_phone_user( &self, phone_number: PhoneNumberSnapshot, @@ -1001,6 +1058,9 @@ impl InMemoryAuthStore { )); } + let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| { + PhoneAuthError::Store(format!("用户创建时间格式化失败:{message}")) + })?; let sequence = state.next_user_id; let user_id = format!("user_{sequence:08}"); let public_user_code = build_public_user_code(sequence); @@ -1011,11 +1071,13 @@ impl InMemoryAuthStore { public_user_code, username: username.clone(), display_name, + avatar_url: None, phone_number_masked: Some(phone_number.masked_national_number.clone()), login_method: AuthLoginMethod::Phone, binding_status: AuthBindingStatus::Active, wechat_bound: false, token_version: 1, + created_at, }; state .phone_to_user_id @@ -1048,6 +1110,9 @@ impl InMemoryAuthStore { return Err(PasswordEntryError::InvalidCredentials); } + let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| { + PasswordEntryError::Store(format!("用户创建时间格式化失败:{message}")) + })?; let sequence = state.next_user_id; let user_id = format!("user_{sequence:08}"); let public_user_code = build_public_user_code(sequence); @@ -1058,11 +1123,13 @@ impl InMemoryAuthStore { public_user_code, username: username.clone(), display_name, + avatar_url: None, phone_number_masked: Some(phone_number.masked_national_number.clone()), login_method: AuthLoginMethod::Password, binding_status: AuthBindingStatus::Active, wechat_bound: false, token_version: 1, + created_at, }; state .phone_to_user_id @@ -1091,11 +1158,15 @@ impl InMemoryAuthStore { .lock() .map_err(|_| WechatAuthError::Store("用户仓储锁已中毒".to_string()))?; + let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| { + WechatAuthError::Store(format!("用户创建时间格式化失败:{message}")) + })?; let sequence = state.next_user_id; let user_id = format!("user_{sequence:08}"); let public_user_code = build_public_user_code(sequence); state.next_user_id += 1; let username = build_system_username("wechat", state.next_user_id); + let avatar_url = normalize_optional_string(profile.avatar_url.clone()); let display_name = profile .display_name .as_deref() @@ -1108,11 +1179,13 @@ impl InMemoryAuthStore { public_user_code, username: username.clone(), display_name, + avatar_url: avatar_url.clone(), phone_number_masked: None, login_method: AuthLoginMethod::Wechat, binding_status: AuthBindingStatus::PendingBindPhone, wechat_bound: true, token_version: 1, + created_at, }; state.users_by_username.insert( username, @@ -1128,7 +1201,7 @@ impl InMemoryAuthStore { provider_uid: normalize_required_string(&profile.provider_uid).unwrap_or_default(), provider_union_id: normalize_optional_string(profile.provider_union_id), display_name: normalize_optional_string(profile.display_name), - avatar_url: normalize_optional_string(profile.avatar_url), + avatar_url, }; if let Some(provider_union_id) = identity.provider_union_id.clone() { state @@ -1454,7 +1527,7 @@ impl InMemoryAuthStore { &self, pending_user_id: &str, phone_number: PhoneNumberSnapshot, - ) -> Result { + ) -> Result<(AuthUser, bool), PhoneAuthError> { let mut state = self .inner .lock() @@ -1501,7 +1574,7 @@ impl InMemoryAuthStore { let next_user = target_user.user.clone(); self.persist_phone_state(&state)?; - return Ok(next_user); + return Ok((next_user, false)); } state @@ -1520,7 +1593,7 @@ impl InMemoryAuthStore { let next_user = stored_user.user.clone(); self.persist_phone_state(&state)?; - Ok(next_user) + Ok((next_user, true)) } fn find_session_by_refresh_token_hash( @@ -1819,6 +1892,40 @@ async fn verify_stored_password_user( }) } +fn normalize_profile_display_name(value: &str) -> Result { + let Some(display_name) = normalize_required_string(value) else { + return Err(PasswordEntryError::InvalidDisplayName); + }; + let length = display_name.chars().count(); + if !(2..=20).contains(&length) { + return Err(PasswordEntryError::InvalidDisplayName); + } + if !display_name.chars().all(|character| { + character == '_' + || character.is_ascii_alphanumeric() + || is_common_chinese_character(character) + }) { + return Err(PasswordEntryError::InvalidDisplayName); + } + + Ok(display_name) +} + +fn normalize_profile_avatar_url(value: &str) -> Result { + let Some(avatar_url) = normalize_required_string(value) else { + return Err(PasswordEntryError::InvalidAvatarDataUrl); + }; + if !avatar_url.starts_with("data:image/") || !avatar_url.contains(";base64,") { + return Err(PasswordEntryError::InvalidAvatarDataUrl); + } + + Ok(avatar_url) +} + +fn is_common_chinese_character(character: char) -> bool { + ('\u{4e00}'..='\u{9fff}').contains(&character) +} + fn build_random_password_seed() -> String { format!( "seed_{}_{}", diff --git a/src/components/CustomWorldResultView.test.tsx b/src/components/CustomWorldResultView.test.tsx index 267f8123..bacb490c 100644 --- a/src/components/CustomWorldResultView.test.tsx +++ b/src/components/CustomWorldResultView.test.tsx @@ -5,8 +5,8 @@ import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { expect, test, vi } from 'vitest'; -import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types'; import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient'; +import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types'; import { RpgCreationResultView } from './rpg-creation-result/RpgCreationResultView'; vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => { @@ -418,7 +418,7 @@ test('landmark tab previews every generated act image while keeping chapter deta (screen.getByRole('img', { name: '沉钟栈桥-钟楼回响', }) as HTMLImageElement).getAttribute('src'), - ).toBe('/generated-custom-world-scenes/scene-act-1.png'); + ).toBe('/generated-custom-world-scenes/scene-act-2.png'); }); test('readOnly result view hides edit and create actions for agent preview mode', async () => { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 177fe170..61b12e97 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -142,8 +142,8 @@ import { usePuzzleRuntimeProp as consumePuzzleRuntimeProp, } from '../../services/puzzle-runtime'; import { - applyLocalPuzzleFreezeTime, advanceLocalPuzzleLevel, + applyLocalPuzzleFreezeTime, dragLocalPuzzlePiece, extendLocalPuzzleTime, isLocalPuzzleRun, @@ -151,7 +151,6 @@ import { resolvePuzzleRestartLevelId, restartLocalPuzzleLevel, setLocalPuzzlePaused, - startLocalPuzzleRun, submitLocalPuzzleLeaderboard, swapLocalPuzzlePieces, } from '../../services/puzzle-runtime/puzzleLocalRuntime'; @@ -222,6 +221,7 @@ type PuzzleRuntimeReturnStage = | 'platform'; type BigFishRuntimeReturnStage = 'big-fish-result' | 'work-detail' | 'platform'; +type BigFishRuntimeSessionSource = 'draft' | 'work' | null; type PuzzleSaveArchiveState = { runtimeKind?: unknown; @@ -953,12 +953,12 @@ export function PlatformEntryFlowShellImpl({ title: string; publicWorkCode: string; } | null>(null); - const [bigFishRuntimeWork, setBigFishRuntimeWork] = + const [_bigFishRuntimeWork, setBigFishRuntimeWork] = useState(null); const [bigFishRuntimeStartedAt, setBigFishRuntimeStartedAt] = useState< number | null >(null); - const [bigFishRuntimeSessionSource, setBigFishRuntimeSessionSource] = + const [_bigFishRuntimeSessionSource, setBigFishRuntimeSessionSource] = useState(null); const [bigFishRuntimeReturnStage, setBigFishRuntimeReturnStage] = useState('platform'); @@ -2142,7 +2142,7 @@ export function PlatformEntryFlowShellImpl({ ], ); - const buildPuzzleTestWork = useCallback( + const _buildPuzzleTestWork = useCallback( (draft: PuzzleResultDraft) => { const profileId = puzzleSession?.publishedProfileId ?? @@ -2206,11 +2206,14 @@ export function PlatformEntryFlowShellImpl({ setPuzzleError(null); try { const { item } = await updatePuzzleWork(profileId, { + workTitle: draft.workTitle, + workDescription: draft.workDescription, levelName: draft.levelName, summary: draft.summary, themeTags: draft.themeTags, coverImageSrc: draft.coverImageSrc, coverAssetId: draft.coverAssetId, + levels: draft.levels ?? [], }); const { run } = await startPuzzleRun({ profileId: item.profileId }); setSelectedPuzzleDetail(item); diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 455e6479..ed90d85a 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -61,9 +61,11 @@ import { } from '../../services/puzzle-gallery'; import { advancePuzzleNextLevel, + dragPuzzlePieceOrGroup, getPuzzleRun, startPuzzleRun, submitPuzzleLeaderboard, + swapPuzzlePieces, updatePuzzleRunPause, usePuzzleRuntimeProp, } from '../../services/puzzle-runtime'; @@ -220,6 +222,7 @@ vi.mock('../../services/puzzle-gallery', () => ({ vi.mock('../../services/puzzle-runtime', () => ({ advancePuzzleNextLevel: vi.fn(), + dragPuzzlePieceOrGroup: vi.fn(), getPuzzleRun: vi.fn(), startPuzzleRun: vi.fn(), swapPuzzlePieces: vi.fn(), @@ -1193,7 +1196,7 @@ beforeEach(() => { vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]); vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]); vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]); - vi.mocked(recordBigFishPlay).mockResolvedValue(undefined); + vi.mocked(recordBigFishPlay).mockResolvedValue({ items: [] }); vi.mocked(recordRpgEntryWorldGalleryPlay).mockImplementation( async (ownerUserId, profileId) => ({ ownerUserId, @@ -1676,28 +1679,30 @@ beforeEach(() => { { entityId: 'owned-1', level: 1, - position: payload.direction, + position: payload, radius: 12, offscreenSeconds: 0, }, ], wildEntities: [], - cameraCenter: payload.direction, - lastInput: payload.direction, + cameraCenter: payload, + lastInput: payload, eventLog: ['机械鱼群继续巡游。'], updatedAt: '2026-04-25T12:12:01.000Z', }, })); - vi.mocked(recordBigFishPlay).mockResolvedValue(undefined); + vi.mocked(recordBigFishPlay).mockResolvedValue({ items: [] }); vi.mocked(match3dCreationClient.createSession).mockResolvedValue({ - session: null, + session: buildMockMatch3DAgentSession(), }); vi.mocked(match3dCreationClient.getSession).mockResolvedValue({ - session: null, + session: buildMockMatch3DAgentSession(), }); - vi.mocked(match3dCreationClient.streamMessage).mockResolvedValue(null); + vi.mocked(match3dCreationClient.streamMessage).mockResolvedValue( + buildMockMatch3DAgentSession(), + ); vi.mocked(match3dCreationClient.executeAction).mockResolvedValue({ - session: null, + session: buildMockMatch3DAgentSession(), }); vi.mocked(listMatch3DWorks).mockResolvedValue({ items: [], @@ -2931,6 +2936,12 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l }, }, }); + vi.mocked(dragPuzzlePieceOrGroup).mockResolvedValue({ + run: clearedSecondLevel, + }); + vi.mocked(swapPuzzlePieces).mockResolvedValue({ + run: clearedSecondLevel, + }); vi.mocked(dragLocalPuzzlePiece).mockReturnValue(clearedSecondLevel); vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedSecondLevel); @@ -2983,7 +2994,7 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l await waitFor(() => { expect(advancePuzzleNextLevel).toHaveBeenCalledWith(firstLevel.runId); }); - expect(advancePuzzleNextLevel).not.toHaveBeenCalled(); + expect(advancePuzzleNextLevel).toHaveBeenCalledTimes(1); expect((await screen.findAllByText('星桥机关')).length).toBeGreaterThan(0); await user.click(document.querySelector('[data-piece-id="piece-0"]')!); diff --git a/src/services/puzzle-runtime/puzzleRuntimeClient.ts b/src/services/puzzle-runtime/puzzleRuntimeClient.ts index 8a83bf24..a4805f02 100644 --- a/src/services/puzzle-runtime/puzzleRuntimeClient.ts +++ b/src/services/puzzle-runtime/puzzleRuntimeClient.ts @@ -77,6 +77,27 @@ export async function swapPuzzlePieces( ); } +/** + * 提交拖拽拼图块或已合并拼图组后的目标格。 + */ +export async function dragPuzzlePieceOrGroup( + runId: string, + payload: DragPuzzlePieceRequest, +) { + return requestJson( + `${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/drag`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + '拖动拼图块失败', + { + retry: PUZZLE_RUNTIME_WRITE_RETRY, + }, + ); +} + /** * 进入推荐出的下一关。 */ diff --git a/vite.config.ts b/vite.config.ts index 112d6c45..0a5136b5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -92,11 +92,6 @@ export default defineConfig(({mode}) => { changeOrigin: true, secure: false, }, - '/api/creation': { - target: runtimeServerTarget, - changeOrigin: true, - secure: false, - }, '/api/custom-world': { target: runtimeServerTarget, changeOrigin: true,