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 {
|
||||
json!({
|
||||
"id": "resp_01",
|
||||
"choices": [
|
||||
"model": "test-model",
|
||||
"output": [
|
||||
{
|
||||
"message": {
|
||||
"content": content,
|
||||
}
|
||||
"type": "message",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": content,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
],
|
||||
"status": "completed"
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
/// 规范化后的手机号快照。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_{}_{}",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]')!);
|
||||
|
||||
@@ -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,
|
||||
secure: false,
|
||||
},
|
||||
'/api/creation': {
|
||||
target: runtimeServerTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/api/custom-world': {
|
||||
target: runtimeServerTarget,
|
||||
changeOrigin: true,
|
||||
|
||||
Reference in New Issue
Block a user