feat: add baby object match edutainment flow
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -4053,6 +4053,108 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_reset_allows_login_with_new_password_only() {
|
||||
let config = AppConfig {
|
||||
sms_auth_enabled: true,
|
||||
..AppConfig::default()
|
||||
};
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
seed_phone_user_with_password(&state, "13800138026", TEST_PASSWORD).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let send_code_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/phone/send-code")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"phone": "13800138026",
|
||||
"scene": "reset_password"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("reset code request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("reset code request should succeed");
|
||||
assert_eq!(send_code_response.status(), StatusCode::OK);
|
||||
|
||||
let reset_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/password/reset")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"phone": "13800138026",
|
||||
"code": "123456",
|
||||
"newPassword": "secret456"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("reset password request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("reset password request should succeed");
|
||||
assert_eq!(reset_response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
reset_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("genarrative_refresh_session="))
|
||||
);
|
||||
|
||||
let old_password_response =
|
||||
password_login_request(app.clone(), "13800138026", TEST_PASSWORD).await;
|
||||
assert_eq!(old_password_response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let new_password_response = password_login_request(app, "13800138026", "secret456").await;
|
||||
assert_eq!(new_password_response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_change_allows_login_with_new_password_only() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &seed_user, "sess_password_change");
|
||||
let app = build_router(state);
|
||||
|
||||
let change_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/password/change")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"currentPassword": TEST_PASSWORD,
|
||||
"newPassword": "secret456"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("change password request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("change password request should succeed");
|
||||
assert_eq!(change_response.status(), StatusCode::OK);
|
||||
|
||||
let old_password_response =
|
||||
password_login_request(app.clone(), "13800138027", TEST_PASSWORD).await;
|
||||
assert_eq!(old_password_response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let new_password_response = password_login_request(app, "13800138027", "secret456").await;
|
||||
assert_eq!(new_password_response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_rejects_email_or_username_identifier() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
@@ -3372,6 +3372,13 @@ fn match3d_bad_request(
|
||||
)
|
||||
}
|
||||
|
||||
fn match3d_bad_gateway(message: impl Into<String>) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "match3d",
|
||||
"message": message.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_match3d_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let status = match &error {
|
||||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||||
|
||||
@@ -40,6 +40,13 @@ pub async fn change_password(
|
||||
})
|
||||
.await
|
||||
.map_err(map_password_management_error)?;
|
||||
state
|
||||
.sync_auth_store_snapshot_to_spacetime()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("同步认证快照失败:{error}"))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -87,6 +94,13 @@ pub async fn reset_password(
|
||||
module_auth::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(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
error::Error,
|
||||
fmt,
|
||||
fmt, fs,
|
||||
sync::{Arc, Mutex},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use module_ai::{AiTaskService, InMemoryAiTaskStore};
|
||||
@@ -369,18 +370,18 @@ impl AppState {
|
||||
pool_size: config.spacetime_pool_size,
|
||||
procedure_timeout: config.spacetime_procedure_timeout,
|
||||
});
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
match spacetime_client
|
||||
.export_auth_store_snapshot_from_tables()
|
||||
.await
|
||||
{
|
||||
Ok(snapshot) => {
|
||||
if let Some(snapshot_json) = snapshot.snapshot_json {
|
||||
if !snapshot_json.trim().is_empty() {
|
||||
let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
|
||||
.map_err(AppStateInitError::AuthStore)?;
|
||||
info!("已从 SpacetimeDB 表恢复认证快照");
|
||||
return Self::new_with_auth_store(config, auth_store);
|
||||
}
|
||||
if let Some(candidate) = auth_store_candidate_from_snapshot_record(
|
||||
snapshot,
|
||||
AuthStoreRestoreSource::SpacetimeTables,
|
||||
)? {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -390,13 +391,11 @@ impl AppState {
|
||||
|
||||
match spacetime_client.get_auth_store_snapshot().await {
|
||||
Ok(snapshot) => {
|
||||
if let Some(snapshot_json) = snapshot.snapshot_json {
|
||||
if !snapshot_json.trim().is_empty() {
|
||||
let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
|
||||
.map_err(AppStateInitError::AuthStore)?;
|
||||
info!("已从 SpacetimeDB 快照记录恢复认证快照");
|
||||
return Self::new_with_auth_store(config, auth_store);
|
||||
}
|
||||
if let Some(candidate) = auth_store_candidate_from_snapshot_record(
|
||||
snapshot,
|
||||
AuthStoreRestoreSource::SpacetimeSnapshot,
|
||||
)? {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -404,6 +403,30 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(candidate) = auth_store_candidate_from_local_file(&config)? {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
|
||||
if let Some(candidate) = select_auth_store_restore_candidate(candidates) {
|
||||
let source = candidate.source;
|
||||
let should_sync_to_spacetime = source == AuthStoreRestoreSource::LocalFile;
|
||||
let state = Self::new_with_auth_store(config, candidate.auth_store)?;
|
||||
info!(
|
||||
source = source.as_str(),
|
||||
updated_at_micros = candidate.updated_at_micros,
|
||||
"已恢复认证快照"
|
||||
);
|
||||
if should_sync_to_spacetime {
|
||||
if let Err(error) = state.sync_auth_store_snapshot_to_spacetime().await {
|
||||
warn!(
|
||||
error = %error,
|
||||
"本地认证快照回写 SpacetimeDB 失败,当前启动继续"
|
||||
);
|
||||
}
|
||||
}
|
||||
return Ok(state);
|
||||
}
|
||||
|
||||
Self::new(config)
|
||||
}
|
||||
|
||||
@@ -695,6 +718,95 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum AuthStoreRestoreSource {
|
||||
SpacetimeTables,
|
||||
SpacetimeSnapshot,
|
||||
LocalFile,
|
||||
}
|
||||
|
||||
impl AuthStoreRestoreSource {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::SpacetimeTables => "spacetime_tables",
|
||||
Self::SpacetimeSnapshot => "spacetime_snapshot",
|
||||
Self::LocalFile => "local_file",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AuthStoreRestoreCandidate {
|
||||
source: AuthStoreRestoreSource,
|
||||
updated_at_micros: Option<i64>,
|
||||
auth_store: InMemoryAuthStore,
|
||||
}
|
||||
|
||||
fn auth_store_candidate_from_snapshot_record(
|
||||
snapshot: spacetime_client::AuthStoreSnapshotRecord,
|
||||
source: AuthStoreRestoreSource,
|
||||
) -> Result<Option<AuthStoreRestoreCandidate>, AppStateInitError> {
|
||||
let Some(snapshot_json) = snapshot
|
||||
.snapshot_json
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
|
||||
.map_err(AppStateInitError::AuthStore)?;
|
||||
|
||||
Ok(Some(AuthStoreRestoreCandidate {
|
||||
source,
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
auth_store,
|
||||
}))
|
||||
}
|
||||
|
||||
fn auth_store_candidate_from_local_file(
|
||||
config: &AppConfig,
|
||||
) -> Result<Option<AuthStoreRestoreCandidate>, AppStateInitError> {
|
||||
if !config.auth_store_path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let updated_at_micros = fs::metadata(&config.auth_store_path)
|
||||
.ok()
|
||||
.and_then(|metadata| metadata.modified().ok())
|
||||
.and_then(system_time_to_unix_micros);
|
||||
let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone())
|
||||
.map_err(AppStateInitError::AuthStore)?;
|
||||
|
||||
Ok(Some(AuthStoreRestoreCandidate {
|
||||
source: AuthStoreRestoreSource::LocalFile,
|
||||
updated_at_micros,
|
||||
auth_store,
|
||||
}))
|
||||
}
|
||||
|
||||
fn system_time_to_unix_micros(system_time: SystemTime) -> Option<i64> {
|
||||
let duration = system_time.duration_since(UNIX_EPOCH).ok()?;
|
||||
i64::try_from(duration.as_micros()).ok()
|
||||
}
|
||||
|
||||
fn select_auth_store_restore_candidate(
|
||||
candidates: Vec<AuthStoreRestoreCandidate>,
|
||||
) -> Option<AuthStoreRestoreCandidate> {
|
||||
candidates.into_iter().max_by_key(|candidate| {
|
||||
(
|
||||
candidate.updated_at_micros.unwrap_or(i64::MIN),
|
||||
auth_store_restore_source_priority(candidate.source),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn auth_store_restore_source_priority(source: AuthStoreRestoreSource) -> u8 {
|
||||
match source {
|
||||
AuthStoreRestoreSource::SpacetimeSnapshot => 3,
|
||||
AuthStoreRestoreSource::SpacetimeTables => 2,
|
||||
AuthStoreRestoreSource::LocalFile => 1,
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AppStateInitError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
|
||||
Reference in New Issue
Block a user