fix: repair api server merge fallout

This commit is contained in:
kdletters
2026-05-02 14:18:12 +08:00
parent 8f4ca9abfa
commit 9b5aa25fe9
16 changed files with 330 additions and 37 deletions

View File

@@ -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。

View File

@@ -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()
}

View File

@@ -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,

View File

@@ -1276,6 +1276,58 @@ pub async fn swap_puzzle_pieces(
))
}
pub async fn drag_puzzle_piece_or_group(
State(state): State<AppState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<DragPuzzlePieceRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
AxumPath(run_id): AxumPath<String>,

View File

@@ -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::{

View File

@@ -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::{

View File

@@ -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)]

View File

@@ -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<String>,
pub avatar_url: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SendPhoneCodeInput {
pub phone_number: String,

View File

@@ -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<String>,
pub phone_number_masked: Option<String>,
pub login_method: AuthLoginMethod,
pub binding_status: AuthBindingStatus,
pub wechat_bound: bool,
pub token_version: u64,
#[serde(default)]
pub created_at: String,
}
/// 规范化后的手机号快照。

View File

@@ -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

View File

@@ -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<UpdateProfileResult, PasswordEntryError> {
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<String>,
avatar_url: Option<String>,
) -> Result<Option<AuthUser>, 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<AuthUser, PhoneAuthError> {
) -> 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<String, PasswordEntryError> {
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<String, PasswordEntryError> {
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_{}_{}",

View File

@@ -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 () => {

View File

@@ -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<BigFishWorkSummary | null>(null);
const [bigFishRuntimeStartedAt, setBigFishRuntimeStartedAt] = useState<
number | null
>(null);
const [bigFishRuntimeSessionSource, setBigFishRuntimeSessionSource] =
const [_bigFishRuntimeSessionSource, setBigFishRuntimeSessionSource] =
useState<BigFishRuntimeSessionSource>(null);
const [bigFishRuntimeReturnStage, setBigFishRuntimeReturnStage] =
useState<BigFishRuntimeReturnStage>('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);

View File

@@ -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"]')!);

View File

@@ -77,6 +77,27 @@ export async function swapPuzzlePieces(
);
}
/**
* 提交拖拽拼图块或已合并拼图组后的目标格。
*/
export async function dragPuzzlePieceOrGroup(
runId: string,
payload: DragPuzzlePieceRequest,
) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/drag`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'拖动拼图块失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
},
);
}
/**
* 进入推荐出的下一关。
*/

View File

@@ -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,