fix: repair api server merge fallout
This commit is contained in:
66
docs/technical/API_SERVER_MERGE_COMPILE_FIX_2026-05-02.md
Normal file
66
docs/technical/API_SERVER_MERGE_COMPILE_FIX_2026-05-02.md
Normal 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。
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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::{
|
||||||
|
|||||||
@@ -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::{
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 规范化后的手机号快照。
|
/// 规范化后的手机号快照。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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_{}_{}",
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"]')!);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 进入推荐出的下一关。
|
* 进入推荐出的下一关。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user