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 { fn llm_response(content: &str) -> String {
json!({ json!({
"id": "resp_01", "id": "resp_01",
"choices": [ "model": "test-model",
"output": [
{ {
"message": { "type": "message",
"content": content, "content": [
} {
"type": "output_text",
"text": content,
}
],
} }
] ],
"status": "completed"
}) })
.to_string() .to_string()
} }

View File

@@ -7,7 +7,7 @@ use axum::{
sse::{Event, Sse}, sse::{Event, Sse},
}, },
}; };
use platform_llm::{LlmError, LlmMessage, LlmMessageRole, LlmTextProtocol, LlmTextRequest}; use platform_llm::{LlmMessage, LlmMessageRole, LlmTextProtocol, LlmTextRequest};
use serde_json::{Value, json}; use serde_json::{Value, json};
use shared_contracts::llm::{ use shared_contracts::llm::{
LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole, 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( pub async fn advance_puzzle_next_level(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(run_id): AxumPath<String>, AxumPath(run_id): AxumPath<String>,

View File

@@ -10,7 +10,7 @@ use axum::{
use platform_llm::{LlmMessage, LlmTextRequest}; use platform_llm::{LlmMessage, LlmTextRequest};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use shared_contracts::runtime_story::RuntimeStorySnapshotPayload; use shared_contracts::story::StoryRuntimeSnapshotPayload as RuntimeStorySnapshotPayload;
use std::convert::Infallible; use std::convert::Infallible;
use module_runtime_story::{ use module_runtime_story::{

View File

@@ -10,7 +10,7 @@ use axum::{
use platform_llm::{LlmMessage, LlmTextRequest}; use platform_llm::{LlmMessage, LlmTextRequest};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use shared_contracts::runtime_story::RuntimeStorySnapshotPayload; use shared_contracts::story::StoryRuntimeSnapshotPayload as RuntimeStorySnapshotPayload;
use std::convert::Infallible; use std::convert::Infallible;
use crate::{ use crate::{

View File

@@ -29,6 +29,11 @@ pub struct ChangePasswordResult {
pub user: AuthUser, pub user: AuthUser,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdateProfileResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResetPasswordResult { pub struct ResetPasswordResult {
pub user: AuthUser, pub user: AuthUser,
@@ -76,6 +81,7 @@ pub struct ConsumeWechatAuthStateResult {
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct BindWechatPhoneResult { pub struct BindWechatPhoneResult {
pub user: AuthUser, pub user: AuthUser,
pub activated_new_user: bool,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]

View File

@@ -29,6 +29,13 @@ pub struct ResetPasswordInput {
pub new_password: String, 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)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct SendPhoneCodeInput { pub struct SendPhoneCodeInput {
pub phone_number: String, pub phone_number: String,

View File

@@ -55,11 +55,15 @@ pub struct AuthUser {
pub public_user_code: String, pub public_user_code: String,
pub username: String, pub username: String,
pub display_name: String, pub display_name: String,
#[serde(default)]
pub avatar_url: Option<String>,
pub phone_number_masked: Option<String>, pub phone_number_masked: Option<String>,
pub login_method: AuthLoginMethod, pub login_method: AuthLoginMethod,
pub binding_status: AuthBindingStatus, pub binding_status: AuthBindingStatus,
pub wechat_bound: bool, pub wechat_bound: bool,
pub token_version: u64, pub token_version: u64,
#[serde(default)]
pub created_at: String,
} }
/// 规范化后的手机号快照。 /// 规范化后的手机号快照。

View File

@@ -8,6 +8,9 @@ use std::{error::Error, fmt};
pub enum PasswordEntryError { pub enum PasswordEntryError {
InvalidPhoneNumber, InvalidPhoneNumber,
InvalidPasswordLength, InvalidPasswordLength,
InvalidDisplayName,
InvalidAvatarDataUrl,
EmptyProfileUpdate,
InvalidPublicUserCode, InvalidPublicUserCode,
InvalidCredentials, InvalidCredentials,
UserNotFound, UserNotFound,
@@ -61,6 +64,9 @@ impl fmt::Display for PasswordEntryError {
match self { match self {
Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"), Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"),
Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"), 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::InvalidPublicUserCode => f.write_str("叙世号格式不正确"),
Self::InvalidCredentials => f.write_str("手机号或密码错误"), Self::InvalidCredentials => f.write_str("手机号或密码错误"),
Self::UserNotFound => 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::Store(message) => RefreshSessionError::Store(message),
PasswordEntryError::InvalidPhoneNumber PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidDisplayName
| PasswordEntryError::InvalidAvatarDataUrl
| PasswordEntryError::EmptyProfileUpdate
| PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials | PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound | 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::PasswordHash(message) => PhoneAuthError::PasswordHash(message),
PasswordEntryError::InvalidPhoneNumber PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidDisplayName
| PasswordEntryError::InvalidAvatarDataUrl
| PasswordEntryError::EmptyProfileUpdate
| PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials | PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound => PhoneAuthError::Store("用户仓储读取失败".to_string()), | 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::Store(message) => LogoutError::Store(message),
PasswordEntryError::InvalidPhoneNumber PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidDisplayName
| PasswordEntryError::InvalidAvatarDataUrl
| PasswordEntryError::EmptyProfileUpdate
| PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials | PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound | PasswordEntryError::UserNotFound

View File

@@ -1,4 +1,4 @@
mod application; mod application;
mod commands; mod commands;
mod domain; mod domain;
mod errors; mod errors;
@@ -203,6 +203,30 @@ impl PasswordEntryService {
.map(|maybe_user| maybe_user.map(|stored| PublicUserSearchResult { user: stored.user })) .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( pub async fn change_password(
&self, &self,
input: ChangePasswordInput, input: ChangePasswordInput,
@@ -594,11 +618,14 @@ impl PhoneAuthService {
return Err(PhoneAuthError::UserStateMismatch); return Err(PhoneAuthError::UserStateMismatch);
} }
let merged_user = self let (merged_user, activated_new_user) = self
.store .store
.bind_wechat_phone_to_user(&input.user_id, normalized_phone)?; .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()) .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( fn create_phone_user(
&self, &self,
phone_number: PhoneNumberSnapshot, 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 sequence = state.next_user_id;
let user_id = format!("user_{sequence:08}"); let user_id = format!("user_{sequence:08}");
let public_user_code = build_public_user_code(sequence); let public_user_code = build_public_user_code(sequence);
@@ -1011,11 +1071,13 @@ impl InMemoryAuthStore {
public_user_code, public_user_code,
username: username.clone(), username: username.clone(),
display_name, display_name,
avatar_url: None,
phone_number_masked: Some(phone_number.masked_national_number.clone()), phone_number_masked: Some(phone_number.masked_national_number.clone()),
login_method: AuthLoginMethod::Phone, login_method: AuthLoginMethod::Phone,
binding_status: AuthBindingStatus::Active, binding_status: AuthBindingStatus::Active,
wechat_bound: false, wechat_bound: false,
token_version: 1, token_version: 1,
created_at,
}; };
state state
.phone_to_user_id .phone_to_user_id
@@ -1048,6 +1110,9 @@ impl InMemoryAuthStore {
return Err(PasswordEntryError::InvalidCredentials); 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 sequence = state.next_user_id;
let user_id = format!("user_{sequence:08}"); let user_id = format!("user_{sequence:08}");
let public_user_code = build_public_user_code(sequence); let public_user_code = build_public_user_code(sequence);
@@ -1058,11 +1123,13 @@ impl InMemoryAuthStore {
public_user_code, public_user_code,
username: username.clone(), username: username.clone(),
display_name, display_name,
avatar_url: None,
phone_number_masked: Some(phone_number.masked_national_number.clone()), phone_number_masked: Some(phone_number.masked_national_number.clone()),
login_method: AuthLoginMethod::Password, login_method: AuthLoginMethod::Password,
binding_status: AuthBindingStatus::Active, binding_status: AuthBindingStatus::Active,
wechat_bound: false, wechat_bound: false,
token_version: 1, token_version: 1,
created_at,
}; };
state state
.phone_to_user_id .phone_to_user_id
@@ -1091,11 +1158,15 @@ impl InMemoryAuthStore {
.lock() .lock()
.map_err(|_| WechatAuthError::Store("用户仓储锁已中毒".to_string()))?; .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 sequence = state.next_user_id;
let user_id = format!("user_{sequence:08}"); let user_id = format!("user_{sequence:08}");
let public_user_code = build_public_user_code(sequence); let public_user_code = build_public_user_code(sequence);
state.next_user_id += 1; state.next_user_id += 1;
let username = build_system_username("wechat", state.next_user_id); let username = build_system_username("wechat", state.next_user_id);
let avatar_url = normalize_optional_string(profile.avatar_url.clone());
let display_name = profile let display_name = profile
.display_name .display_name
.as_deref() .as_deref()
@@ -1108,11 +1179,13 @@ impl InMemoryAuthStore {
public_user_code, public_user_code,
username: username.clone(), username: username.clone(),
display_name, display_name,
avatar_url: avatar_url.clone(),
phone_number_masked: None, phone_number_masked: None,
login_method: AuthLoginMethod::Wechat, login_method: AuthLoginMethod::Wechat,
binding_status: AuthBindingStatus::PendingBindPhone, binding_status: AuthBindingStatus::PendingBindPhone,
wechat_bound: true, wechat_bound: true,
token_version: 1, token_version: 1,
created_at,
}; };
state.users_by_username.insert( state.users_by_username.insert(
username, username,
@@ -1128,7 +1201,7 @@ impl InMemoryAuthStore {
provider_uid: normalize_required_string(&profile.provider_uid).unwrap_or_default(), provider_uid: normalize_required_string(&profile.provider_uid).unwrap_or_default(),
provider_union_id: normalize_optional_string(profile.provider_union_id), provider_union_id: normalize_optional_string(profile.provider_union_id),
display_name: normalize_optional_string(profile.display_name), 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() { if let Some(provider_union_id) = identity.provider_union_id.clone() {
state state
@@ -1454,7 +1527,7 @@ impl InMemoryAuthStore {
&self, &self,
pending_user_id: &str, pending_user_id: &str,
phone_number: PhoneNumberSnapshot, phone_number: PhoneNumberSnapshot,
) -> Result<AuthUser, PhoneAuthError> { ) -> Result<(AuthUser, bool), PhoneAuthError> {
let mut state = self let mut state = self
.inner .inner
.lock() .lock()
@@ -1501,7 +1574,7 @@ impl InMemoryAuthStore {
let next_user = target_user.user.clone(); let next_user = target_user.user.clone();
self.persist_phone_state(&state)?; self.persist_phone_state(&state)?;
return Ok(next_user); return Ok((next_user, false));
} }
state state
@@ -1520,7 +1593,7 @@ impl InMemoryAuthStore {
let next_user = stored_user.user.clone(); let next_user = stored_user.user.clone();
self.persist_phone_state(&state)?; self.persist_phone_state(&state)?;
Ok(next_user) Ok((next_user, true))
} }
fn find_session_by_refresh_token_hash( 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 { fn build_random_password_seed() -> String {
format!( format!(
"seed_{}_{}", "seed_{}_{}",

View File

@@ -5,8 +5,8 @@ import userEvent from '@testing-library/user-event';
import { useState } from 'react'; import { useState } from 'react';
import { expect, test, vi } from 'vitest'; import { expect, test, vi } from 'vitest';
import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types';
import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient'; import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient';
import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types';
import { RpgCreationResultView } from './rpg-creation-result/RpgCreationResultView'; import { RpgCreationResultView } from './rpg-creation-result/RpgCreationResultView';
vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => { 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', { (screen.getByRole('img', {
name: '沉钟栈桥-钟楼回响', name: '沉钟栈桥-钟楼回响',
}) as HTMLImageElement).getAttribute('src'), }) 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 () => { test('readOnly result view hides edit and create actions for agent preview mode', async () => {

View File

@@ -142,8 +142,8 @@ import {
usePuzzleRuntimeProp as consumePuzzleRuntimeProp, usePuzzleRuntimeProp as consumePuzzleRuntimeProp,
} from '../../services/puzzle-runtime'; } from '../../services/puzzle-runtime';
import { import {
applyLocalPuzzleFreezeTime,
advanceLocalPuzzleLevel, advanceLocalPuzzleLevel,
applyLocalPuzzleFreezeTime,
dragLocalPuzzlePiece, dragLocalPuzzlePiece,
extendLocalPuzzleTime, extendLocalPuzzleTime,
isLocalPuzzleRun, isLocalPuzzleRun,
@@ -151,7 +151,6 @@ import {
resolvePuzzleRestartLevelId, resolvePuzzleRestartLevelId,
restartLocalPuzzleLevel, restartLocalPuzzleLevel,
setLocalPuzzlePaused, setLocalPuzzlePaused,
startLocalPuzzleRun,
submitLocalPuzzleLeaderboard, submitLocalPuzzleLeaderboard,
swapLocalPuzzlePieces, swapLocalPuzzlePieces,
} from '../../services/puzzle-runtime/puzzleLocalRuntime'; } from '../../services/puzzle-runtime/puzzleLocalRuntime';
@@ -222,6 +221,7 @@ type PuzzleRuntimeReturnStage =
| 'platform'; | 'platform';
type BigFishRuntimeReturnStage = 'big-fish-result' | 'work-detail' | 'platform'; type BigFishRuntimeReturnStage = 'big-fish-result' | 'work-detail' | 'platform';
type BigFishRuntimeSessionSource = 'draft' | 'work' | null;
type PuzzleSaveArchiveState = { type PuzzleSaveArchiveState = {
runtimeKind?: unknown; runtimeKind?: unknown;
@@ -953,12 +953,12 @@ export function PlatformEntryFlowShellImpl({
title: string; title: string;
publicWorkCode: string; publicWorkCode: string;
} | null>(null); } | null>(null);
const [bigFishRuntimeWork, setBigFishRuntimeWork] = const [_bigFishRuntimeWork, setBigFishRuntimeWork] =
useState<BigFishWorkSummary | null>(null); useState<BigFishWorkSummary | null>(null);
const [bigFishRuntimeStartedAt, setBigFishRuntimeStartedAt] = useState< const [bigFishRuntimeStartedAt, setBigFishRuntimeStartedAt] = useState<
number | null number | null
>(null); >(null);
const [bigFishRuntimeSessionSource, setBigFishRuntimeSessionSource] = const [_bigFishRuntimeSessionSource, setBigFishRuntimeSessionSource] =
useState<BigFishRuntimeSessionSource>(null); useState<BigFishRuntimeSessionSource>(null);
const [bigFishRuntimeReturnStage, setBigFishRuntimeReturnStage] = const [bigFishRuntimeReturnStage, setBigFishRuntimeReturnStage] =
useState<BigFishRuntimeReturnStage>('platform'); useState<BigFishRuntimeReturnStage>('platform');
@@ -2142,7 +2142,7 @@ export function PlatformEntryFlowShellImpl({
], ],
); );
const buildPuzzleTestWork = useCallback( const _buildPuzzleTestWork = useCallback(
(draft: PuzzleResultDraft) => { (draft: PuzzleResultDraft) => {
const profileId = const profileId =
puzzleSession?.publishedProfileId ?? puzzleSession?.publishedProfileId ??
@@ -2206,11 +2206,14 @@ export function PlatformEntryFlowShellImpl({
setPuzzleError(null); setPuzzleError(null);
try { try {
const { item } = await updatePuzzleWork(profileId, { const { item } = await updatePuzzleWork(profileId, {
workTitle: draft.workTitle,
workDescription: draft.workDescription,
levelName: draft.levelName, levelName: draft.levelName,
summary: draft.summary, summary: draft.summary,
themeTags: draft.themeTags, themeTags: draft.themeTags,
coverImageSrc: draft.coverImageSrc, coverImageSrc: draft.coverImageSrc,
coverAssetId: draft.coverAssetId, coverAssetId: draft.coverAssetId,
levels: draft.levels ?? [],
}); });
const { run } = await startPuzzleRun({ profileId: item.profileId }); const { run } = await startPuzzleRun({ profileId: item.profileId });
setSelectedPuzzleDetail(item); setSelectedPuzzleDetail(item);

View File

@@ -61,9 +61,11 @@ import {
} from '../../services/puzzle-gallery'; } from '../../services/puzzle-gallery';
import { import {
advancePuzzleNextLevel, advancePuzzleNextLevel,
dragPuzzlePieceOrGroup,
getPuzzleRun, getPuzzleRun,
startPuzzleRun, startPuzzleRun,
submitPuzzleLeaderboard, submitPuzzleLeaderboard,
swapPuzzlePieces,
updatePuzzleRunPause, updatePuzzleRunPause,
usePuzzleRuntimeProp, usePuzzleRuntimeProp,
} from '../../services/puzzle-runtime'; } from '../../services/puzzle-runtime';
@@ -220,6 +222,7 @@ vi.mock('../../services/puzzle-gallery', () => ({
vi.mock('../../services/puzzle-runtime', () => ({ vi.mock('../../services/puzzle-runtime', () => ({
advancePuzzleNextLevel: vi.fn(), advancePuzzleNextLevel: vi.fn(),
dragPuzzlePieceOrGroup: vi.fn(),
getPuzzleRun: vi.fn(), getPuzzleRun: vi.fn(),
startPuzzleRun: vi.fn(), startPuzzleRun: vi.fn(),
swapPuzzlePieces: vi.fn(), swapPuzzlePieces: vi.fn(),
@@ -1193,7 +1196,7 @@ beforeEach(() => {
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]); vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]); vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]); vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
vi.mocked(recordBigFishPlay).mockResolvedValue(undefined); vi.mocked(recordBigFishPlay).mockResolvedValue({ items: [] });
vi.mocked(recordRpgEntryWorldGalleryPlay).mockImplementation( vi.mocked(recordRpgEntryWorldGalleryPlay).mockImplementation(
async (ownerUserId, profileId) => ({ async (ownerUserId, profileId) => ({
ownerUserId, ownerUserId,
@@ -1676,28 +1679,30 @@ beforeEach(() => {
{ {
entityId: 'owned-1', entityId: 'owned-1',
level: 1, level: 1,
position: payload.direction, position: payload,
radius: 12, radius: 12,
offscreenSeconds: 0, offscreenSeconds: 0,
}, },
], ],
wildEntities: [], wildEntities: [],
cameraCenter: payload.direction, cameraCenter: payload,
lastInput: payload.direction, lastInput: payload,
eventLog: ['机械鱼群继续巡游。'], eventLog: ['机械鱼群继续巡游。'],
updatedAt: '2026-04-25T12:12:01.000Z', updatedAt: '2026-04-25T12:12:01.000Z',
}, },
})); }));
vi.mocked(recordBigFishPlay).mockResolvedValue(undefined); vi.mocked(recordBigFishPlay).mockResolvedValue({ items: [] });
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({ vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
session: null, session: buildMockMatch3DAgentSession(),
}); });
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({ 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({ vi.mocked(match3dCreationClient.executeAction).mockResolvedValue({
session: null, session: buildMockMatch3DAgentSession(),
}); });
vi.mocked(listMatch3DWorks).mockResolvedValue({ vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [], 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(dragLocalPuzzlePiece).mockReturnValue(clearedSecondLevel);
vi.mocked(swapLocalPuzzlePieces).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(() => { await waitFor(() => {
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(firstLevel.runId); expect(advancePuzzleNextLevel).toHaveBeenCalledWith(firstLevel.runId);
}); });
expect(advancePuzzleNextLevel).not.toHaveBeenCalled(); expect(advancePuzzleNextLevel).toHaveBeenCalledTimes(1);
expect((await screen.findAllByText('星桥机关')).length).toBeGreaterThan(0); expect((await screen.findAllByText('星桥机关')).length).toBeGreaterThan(0);
await user.click(document.querySelector('[data-piece-id="piece-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, changeOrigin: true,
secure: false, secure: false,
}, },
'/api/creation': {
target: runtimeServerTarget,
changeOrigin: true,
secure: false,
},
'/api/custom-world': { '/api/custom-world': {
target: runtimeServerTarget, target: runtimeServerTarget,
changeOrigin: true, changeOrigin: true,