Refine play type integration flow and docs

This commit is contained in:
2026-06-03 00:57:24 +08:00
parent dbe4c902b4
commit 67ba40c678
35 changed files with 2226 additions and 619 deletions

View File

@@ -40,6 +40,15 @@ pub async fn password_entry(
state.password_entry_service().execute(input).await
}
.map_err(map_password_entry_error)?;
let session_client = resolve_session_client_context(&headers);
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
if result.created {
crate::registration_reward::grant_new_user_registration_wallet_reward(
&state,
@@ -48,8 +57,6 @@ pub async fn password_entry(
)
.await;
}
let session_client = resolve_session_client_context(&headers);
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
@@ -57,13 +64,6 @@ pub async fn password_entry(
AuthLoginMethod::Password,
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
let mut headers = HeaderMap::new();
attach_set_cookie_header(

View File

@@ -100,13 +100,6 @@ pub async fn reset_password(
&session_client,
module_auth::AuthLoginMethod::Password,
)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&result.user.id,
module_auth::AuthLoginMethod::Password,
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await
@@ -114,6 +107,13 @@ pub async fn reset_password(
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&result.user.id,
module_auth::AuthLoginMethod::Password,
)
.await;
let mut headers = HeaderMap::new();
attach_set_cookie_header(

View File

@@ -151,6 +151,20 @@ pub async fn phone_login(
}
};
let created = result.created;
let session_client = resolve_session_client_context(&headers);
let signed_session = create_auth_session(
&state,
&result.user,
&session_client,
AuthLoginMethod::Phone,
)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
if created {
crate::registration_reward::grant_new_user_registration_wallet_reward(
&state,
@@ -170,13 +184,6 @@ pub async fn phone_login(
} else {
None
};
let session_client = resolve_session_client_context(&headers);
let signed_session = create_auth_session(
&state,
&result.user,
&session_client,
AuthLoginMethod::Phone,
)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
@@ -184,13 +191,6 @@ pub async fn phone_login(
AuthLoginMethod::Phone,
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
let mut headers = HeaderMap::new();
attach_set_cookie_header(

View File

@@ -2,8 +2,8 @@ use super::*;
pub(crate) fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String {
build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
title: None,
work_description: None,
title: payload.work_title.as_deref(),
work_description: payload.work_description.as_deref(),
picture_description: payload
.picture_description
.as_deref()
@@ -32,8 +32,8 @@ pub(crate) async fn save_puzzle_form_payload_before_compile(
now: i64,
) -> Result<String, Response> {
let seed_text = build_puzzle_form_seed_text_from_parts(
None,
None,
payload.work_title.as_deref(),
payload.work_description.as_deref(),
payload
.picture_description
.as_deref()

View File

@@ -725,8 +725,8 @@ pub async fn execute_puzzle_agent_action(
}
"save_puzzle_form_draft" => {
let seed_text = build_puzzle_form_seed_text_from_parts(
None,
None,
payload.work_title.as_deref(),
payload.work_description.as_deref(),
payload
.picture_description
.as_deref()

View File

@@ -384,6 +384,28 @@ fn puzzle_compile_error_preserves_vector_engine_unavailable_status() {
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn puzzle_form_seed_text_includes_work_metadata() {
let payload = CreatePuzzleAgentSessionRequest {
seed_text: Some("旧 seed 会被画面描述兜底覆盖。".to_string()),
work_title: Some("雨夜猫街".to_string()),
work_description: Some("123".to_string()),
picture_description: Some("一只猫在雨夜灯牌下回头。".to_string()),
reference_image_src: None,
reference_image_srcs: Vec::new(),
reference_image_asset_object_id: None,
reference_image_asset_object_ids: Vec::new(),
image_model: None,
ai_redraw: Some(true),
};
let seed_text = build_puzzle_form_seed_text(&payload);
assert!(seed_text.contains("作品名称:雨夜猫街"));
assert!(seed_text.contains("作品描述123"));
assert!(seed_text.contains("画面描述:一只猫在雨夜灯牌下回头。"));
}
#[tokio::test]
async fn puzzle_compile_error_normalizes_legacy_apimart_image_message() {
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(

View File

@@ -56,13 +56,6 @@ pub async fn refresh_session(
Some(&rotated.session.issued_by_provider),
Some(&rotated.session.client_info),
)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&rotated.user.id,
rotated.session.issued_by_provider.clone(),
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await
@@ -70,6 +63,13 @@ pub async fn refresh_session(
AppError::from_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&rotated.user.id,
rotated.session.issued_by_provider.clone(),
)
.await;
let mut headers = HeaderMap::new();
attach_set_cookie_header(

View File

@@ -591,7 +591,7 @@ impl AppState {
)
.map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?;
// 当前进程内 auth_store 是认证请求的即时工作集SpacetimeDB 正式认证表用于跨进程恢复。
// 远端数据库挂起或网络异常时,只降级后续恢复能力,不能让已成功的登录/刷新/退出回滚为失败
// 认证变更必须在返回客户端前写入 SpacetimeDB避免只在本进程内成功、重启后丢失账号或会话
#[cfg(not(test))]
if let Err(error) = self
.spacetime_client
@@ -600,9 +600,9 @@ impl AppState {
{
warn!(
error = %error,
"认证快照导入 SpacetimeDB 正式表失败,当前认证流程继续"
"认证快照导入 SpacetimeDB 正式表失败,当前认证流程中止"
);
return Ok(());
return Err(error);
}
#[cfg(not(test))]
Ok(())

View File

@@ -145,13 +145,6 @@ pub async fn handle_wechat_callback(
&session_client,
AuthLoginMethod::Wechat,
)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&result.user.id,
AuthLoginMethod::Wechat,
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await
@@ -159,6 +152,13 @@ pub async fn handle_wechat_callback(
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&result.user.id,
AuthLoginMethod::Wechat,
)
.await;
let mut response = Redirect::to(&build_auth_result_redirect_url(
&redirect_path,
&[
@@ -241,6 +241,20 @@ pub async fn bind_wechat_phone(
.await
.map_err(map_wechat_bind_phone_error)?
};
let session_client = resolve_session_client_context(&headers);
let signed_session = create_auth_session(
&state,
&result.user,
&session_client,
AuthLoginMethod::Wechat,
)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
if result.activated_new_user {
crate::registration_reward::grant_new_user_registration_wallet_reward(
&state,
@@ -249,13 +263,6 @@ pub async fn bind_wechat_phone(
)
.await;
}
let session_client = resolve_session_client_context(&headers);
let signed_session = create_auth_session(
&state,
&result.user,
&session_client,
AuthLoginMethod::Wechat,
)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
@@ -263,13 +270,6 @@ pub async fn bind_wechat_phone(
AuthLoginMethod::Wechat,
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
let mut response_headers = HeaderMap::new();
attach_set_cookie_header(

View File

@@ -11,7 +11,7 @@ pub use errors::*;
pub use events::*;
use std::{
collections::HashMap,
collections::{HashMap, HashSet},
sync::{Arc, Mutex},
};
@@ -918,16 +918,47 @@ impl Default for InMemoryAuthStoreState {
impl InMemoryAuthStoreState {
fn from_persistent_snapshot(snapshot: PersistentAuthStoreSnapshot) -> Self {
let existing_user_ids = snapshot
.users_by_username
.values()
.map(|stored| stored.user.id.clone())
.collect::<HashSet<_>>();
let phone_to_user_id = snapshot
.phone_to_user_id
.into_iter()
.filter(|(_, user_id)| existing_user_ids.contains(user_id))
.collect();
let sessions_by_id = snapshot
.sessions_by_id
.into_iter()
.filter(|(_, stored)| existing_user_ids.contains(&stored.session.user_id))
.collect::<HashMap<_, _>>();
let session_id_by_refresh_token_hash = snapshot
.session_id_by_refresh_token_hash
.into_iter()
.filter(|(_, session_id)| sessions_by_id.contains_key(session_id))
.collect();
let wechat_identity_by_provider_uid = snapshot
.wechat_identity_by_provider_uid
.into_iter()
.filter(|(_, identity)| existing_user_ids.contains(&identity.user_id))
.collect();
let user_id_by_provider_union_id = snapshot
.user_id_by_provider_union_id
.into_iter()
.filter(|(_, user_id)| existing_user_ids.contains(user_id))
.collect();
Self {
next_user_id: snapshot.next_user_id,
users_by_username: snapshot.users_by_username,
phone_to_user_id: snapshot.phone_to_user_id,
sessions_by_id: snapshot.sessions_by_id,
session_id_by_refresh_token_hash: snapshot.session_id_by_refresh_token_hash,
phone_to_user_id,
sessions_by_id,
session_id_by_refresh_token_hash,
phone_codes_by_key: HashMap::new(),
wechat_states_by_token: HashMap::new(),
wechat_identity_by_provider_uid: snapshot.wechat_identity_by_provider_uid,
user_id_by_provider_union_id: snapshot.user_id_by_provider_union_id,
wechat_identity_by_provider_uid,
user_id_by_provider_union_id,
}
}
@@ -1159,10 +1190,17 @@ impl InMemoryAuthStore {
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?;
if state.phone_to_user_id.contains_key(&phone_number.e164) {
return Err(PhoneAuthError::Store(
"手机号已存在,无法重复创建账号".to_string(),
));
if let Some(existing_user_id) = state.phone_to_user_id.get(&phone_number.e164).cloned() {
let existing_user_exists = state
.users_by_username
.values()
.any(|stored_user| stored_user.user.id == existing_user_id);
if existing_user_exists {
return Err(PhoneAuthError::Store(
"手机号已存在,无法重复创建账号".to_string(),
));
}
state.phone_to_user_id.remove(&phone_number.e164);
}
let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| {
@@ -1213,8 +1251,15 @@ impl InMemoryAuthStore {
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
if state.phone_to_user_id.contains_key(&phone_number.e164) {
return Err(PasswordEntryError::InvalidCredentials);
if let Some(existing_user_id) = state.phone_to_user_id.get(&phone_number.e164).cloned() {
let existing_user_exists = state
.users_by_username
.values()
.any(|stored_user| stored_user.user.id == existing_user_id);
if existing_user_exists {
return Err(PasswordEntryError::InvalidCredentials);
}
state.phone_to_user_id.remove(&phone_number.e164);
}
let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| {
@@ -2629,6 +2674,54 @@ mod tests {
assert_eq!(rotated.user.id, user.id);
}
#[tokio::test]
async fn snapshot_json_drops_orphan_phone_index_before_phone_login() {
let snapshot = PersistentAuthStoreSnapshot {
next_user_id: 9,
users_by_username: HashMap::new(),
phone_to_user_id: HashMap::from([(
"+8613800138032".to_string(),
"user_missing_phone_owner".to_string(),
)]),
sessions_by_id: HashMap::new(),
session_id_by_refresh_token_hash: HashMap::new(),
wechat_identity_by_provider_uid: HashMap::new(),
user_id_by_provider_union_id: HashMap::new(),
};
let snapshot_json = serde_json::to_string(&snapshot).expect("snapshot should serialize");
let restored_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
.expect("snapshot json should restore");
let phone_service = build_phone_service(restored_store);
let now = OffsetDateTime::now_utc();
phone_service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138032".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone code should send");
let result = phone_service
.login(
PhoneLoginInput {
phone_number: "13800138032".to_string(),
verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
},
now + Duration::seconds(1),
)
.await
.expect("orphan phone index should not block phone login");
assert!(result.created);
assert_eq!(
result.user.phone_number_masked.as_deref(),
Some("138****8032")
);
}
#[tokio::test]
async fn password_entry_rejects_email_or_username_identifier() {
let service = build_password_service(build_store());

View File

@@ -454,6 +454,10 @@ fn export_auth_store_snapshot_from_tables_tx(
.meta_id()
.find(&AUTH_STORE_PROJECTION_META_ID.to_string())
.map(|row| row.updated_at.to_micros_since_unix_epoch());
let valid_user_ids = users
.iter()
.map(|user| user.user_id.clone())
.collect::<std::collections::HashSet<_>>();
let mut phone_identity_by_user_id = std::collections::HashMap::new();
let mut phone_to_user_id = std::collections::HashMap::new();
@@ -461,6 +465,10 @@ fn export_auth_store_snapshot_from_tables_tx(
let mut user_id_by_provider_union_id = std::collections::HashMap::new();
for identity in identities {
if !valid_user_ids.contains(&identity.user_id) {
continue;
}
match identity.provider.as_str() {
"phone" => {
let phone_number = identity
@@ -529,6 +537,10 @@ fn export_auth_store_snapshot_from_tables_tx(
let mut sessions_by_id = std::collections::HashMap::new();
let mut session_id_by_refresh_token_hash = std::collections::HashMap::new();
for session in sessions {
if !valid_user_ids.contains(&session.user_id) {
continue;
}
let client_info = serde_json::from_str::<serde_json::Value>(&session.client_info_json)
.map_err(|error| format!("refresh session 客户端信息 JSON 解析失败:{error}"))?;
session_id_by_refresh_token_hash.insert(
@@ -693,10 +705,9 @@ mod tests {
#[test]
fn auth_store_snapshot_user_row_key_is_stable_after_username_change() {
let mut before = sample_snapshot();
let before = sample_snapshot();
let mut after = sample_snapshot();
after.users_by_username.clear();
let mut renamed_user = before
let mut renamed_user = after
.users_by_username
.remove("phone_42")
.expect("sample user exists");

View File

@@ -5521,6 +5521,7 @@ mod tests {
deleted_at: None,
created_at: Timestamp::from_micros_since_unix_epoch(1),
updated_at: Timestamp::from_micros_since_unix_epoch(1),
visible: true,
}
}

View File

@@ -1415,6 +1415,7 @@ mod tests {
height: 1536,
})),
back_button_asset_json: None,
visible: true,
}
}
}