This commit is contained in:
2026-05-13 03:10:55 +08:00
154 changed files with 16812 additions and 708 deletions

View File

@@ -176,7 +176,9 @@ use crate::{
get_volcengine_speech_config, stream_volcengine_asr, stream_volcengine_tts_bidirection,
stream_volcengine_tts_sse,
},
wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login},
wechat_auth::{
bind_wechat_phone, handle_wechat_callback, login_wechat_mini_program, start_wechat_login,
},
};
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
@@ -346,6 +348,10 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/auth/phone/login", post(phone_login))
.route("/api/auth/wechat/start", get(start_wechat_login))
.route("/api/auth/wechat/callback", get(handle_wechat_callback))
.route(
"/api/auth/wechat/miniprogram-login",
post(login_wechat_mini_program),
)
.route(
"/api/auth/wechat/bind-phone",
post(bind_wechat_phone).route_layer(middleware::from_fn_with_state(
@@ -3728,6 +3734,210 @@ mod tests {
);
}
#[tokio::test]
async fn wechat_miniprogram_login_returns_system_token_and_marks_session_source() {
let config = AppConfig {
wechat_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/wechat/miniprogram-login")
.header("content-type", "application/json")
.header("x-client-type", "mini_program")
.header("x-client-runtime", "wechat_mini_program")
.header("x-client-platform", "ios")
.header("x-client-instance-id", "mini-instance-001")
.header("x-mini-program-app-id", "wx-mini-test")
.header("x-mini-program-env", "develop")
.body(Body::from(
serde_json::json!({
"code": "wx-mini-code-001"
})
.to_string(),
))
.expect("mini program login request should build"),
)
.await
.expect("mini program login request should succeed");
assert_eq!(login_response.status(), StatusCode::OK);
let refresh_cookie = login_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("refresh cookie should exist")
.to_string();
let login_body = login_response
.into_body()
.collect()
.await
.expect("mini program login body should collect")
.to_bytes();
let login_payload: Value =
serde_json::from_slice(&login_body).expect("mini program login payload should be json");
let token = login_payload["token"]
.as_str()
.expect("system token should exist")
.to_string();
assert_eq!(
login_payload["bindingStatus"],
Value::String("pending_bind_phone".to_string())
);
assert_eq!(
login_payload["user"]["loginMethod"],
Value::String("wechat".to_string())
);
assert!(refresh_cookie.contains("genarrative_refresh_session="));
let sessions_response = app
.oneshot(
Request::builder()
.uri("/api/auth/sessions")
.header("authorization", format!("Bearer {token}"))
.header("cookie", refresh_cookie)
.body(Body::empty())
.expect("sessions request should build"),
)
.await
.expect("sessions request should succeed");
assert_eq!(sessions_response.status(), StatusCode::OK);
let sessions_body = sessions_response
.into_body()
.collect()
.await
.expect("sessions body should collect")
.to_bytes();
let sessions_payload: Value =
serde_json::from_slice(&sessions_body).expect("sessions payload should be json");
assert_eq!(
sessions_payload["sessions"][0]["clientType"],
Value::String("mini_program".to_string())
);
assert_eq!(
sessions_payload["sessions"][0]["clientRuntime"],
Value::String("wechat_mini_program".to_string())
);
assert_eq!(
sessions_payload["sessions"][0]["miniProgramAppId"],
Value::String("wx-mini-test".to_string())
);
}
#[tokio::test]
async fn wechat_miniprogram_bind_phone_code_activates_pending_user() {
let config = AppConfig {
wechat_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/wechat/miniprogram-login")
.header("content-type", "application/json")
.header("x-client-type", "mini_program")
.header("x-client-runtime", "wechat_mini_program")
.header("x-client-platform", "ios")
.header("x-client-instance-id", "mini-bind-instance-001")
.header("x-mini-program-app-id", "wx-mini-test")
.header("x-mini-program-env", "develop")
.body(Body::from(
serde_json::json!({
"code": "wx-mini-code-bind-001"
})
.to_string(),
))
.expect("mini program login request should build"),
)
.await
.expect("mini program login request should succeed");
assert_eq!(login_response.status(), StatusCode::OK);
let login_body = login_response
.into_body()
.collect()
.await
.expect("mini program login body should collect")
.to_bytes();
let login_payload: Value =
serde_json::from_slice(&login_body).expect("mini program login payload should be json");
let token = login_payload["token"]
.as_str()
.expect("system token should exist")
.to_string();
assert_eq!(
login_payload["bindingStatus"],
Value::String("pending_bind_phone".to_string())
);
let bind_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/wechat/bind-phone")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-client-type", "mini_program")
.header("x-client-runtime", "wechat_mini_program")
.header("x-client-platform", "ios")
.header("x-client-instance-id", "mini-bind-instance-001")
.header("x-mini-program-app-id", "wx-mini-test")
.header("x-mini-program-env", "develop")
.body(Body::from(
serde_json::json!({
"wechatPhoneCode": "13800138000"
})
.to_string(),
))
.expect("bind request should build"),
)
.await
.expect("bind request should succeed");
assert_eq!(bind_response.status(), StatusCode::OK);
assert!(
bind_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("genarrative_refresh_session="))
);
let bind_body = bind_response
.into_body()
.collect()
.await
.expect("bind body should collect")
.to_bytes();
let bind_payload: Value =
serde_json::from_slice(&bind_body).expect("bind payload should be json");
assert_eq!(
bind_payload["user"]["bindingStatus"],
Value::String("active".to_string())
);
assert_eq!(bind_payload["user"]["wechatBound"], Value::Bool(true));
assert_eq!(
bind_payload["user"]["phoneNumberMasked"],
Value::String("138****8000".to_string())
);
assert!(
bind_payload["token"]
.as_str()
.is_some_and(|value| !value.is_empty())
);
}
#[tokio::test]
async fn wechat_bind_phone_merges_into_existing_phone_user() {
let config = AppConfig {
@@ -4083,6 +4293,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"));

View File

@@ -56,11 +56,16 @@ pub struct AppConfig {
pub wechat_auth_provider: String,
pub wechat_app_id: Option<String>,
pub wechat_app_secret: Option<String>,
pub wechat_mini_program_app_id: Option<String>,
pub wechat_mini_program_app_secret: Option<String>,
pub wechat_callback_path: String,
pub wechat_redirect_path: String,
pub wechat_authorize_endpoint: String,
pub wechat_access_token_endpoint: String,
pub wechat_user_info_endpoint: String,
pub wechat_js_code_session_endpoint: String,
pub wechat_stable_access_token_endpoint: String,
pub wechat_phone_number_endpoint: String,
pub wechat_state_ttl_minutes: u32,
pub wechat_mock_user_id: String,
pub wechat_mock_union_id: Option<String>,
@@ -165,12 +170,20 @@ impl Default for AppConfig {
wechat_auth_provider: "mock".to_string(),
wechat_app_id: None,
wechat_app_secret: None,
wechat_mini_program_app_id: None,
wechat_mini_program_app_secret: None,
wechat_callback_path: "/api/auth/wechat/callback".to_string(),
wechat_redirect_path: "/".to_string(),
wechat_authorize_endpoint: "https://open.weixin.qq.com/connect/qrconnect".to_string(),
wechat_access_token_endpoint: "https://api.weixin.qq.com/sns/oauth2/access_token"
.to_string(),
wechat_user_info_endpoint: "https://api.weixin.qq.com/sns/userinfo".to_string(),
wechat_js_code_session_endpoint: "https://api.weixin.qq.com/sns/jscode2session"
.to_string(),
wechat_stable_access_token_endpoint: "https://api.weixin.qq.com/cgi-bin/stable_token"
.to_string(),
wechat_phone_number_endpoint:
"https://api.weixin.qq.com/wxa/business/getuserphonenumber".to_string(),
wechat_state_ttl_minutes: 15,
wechat_mock_user_id: "wx-mock-user".to_string(),
wechat_mock_union_id: Some("wx-mock-union".to_string()),
@@ -389,6 +402,10 @@ impl AppConfig {
}
config.wechat_app_id = read_first_non_empty_env(&["WECHAT_APP_ID"]);
config.wechat_app_secret = read_first_non_empty_env(&["WECHAT_APP_SECRET"]);
config.wechat_mini_program_app_id =
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_APP_ID", "WECHAT_APP_ID"]);
config.wechat_mini_program_app_secret =
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_APP_SECRET", "WECHAT_APP_SECRET"]);
if let Some(wechat_callback_path) = read_first_non_empty_env(&["WECHAT_CALLBACK_PATH"]) {
config.wechat_callback_path = wechat_callback_path;
}
@@ -410,6 +427,21 @@ impl AppConfig {
{
config.wechat_user_info_endpoint = wechat_user_info_endpoint;
}
if let Some(wechat_js_code_session_endpoint) =
read_first_non_empty_env(&["WECHAT_JS_CODE_SESSION_ENDPOINT"])
{
config.wechat_js_code_session_endpoint = wechat_js_code_session_endpoint;
}
if let Some(wechat_stable_access_token_endpoint) =
read_first_non_empty_env(&["WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT"])
{
config.wechat_stable_access_token_endpoint = wechat_stable_access_token_endpoint;
}
if let Some(wechat_phone_number_endpoint) =
read_first_non_empty_env(&["WECHAT_PHONE_NUMBER_ENDPOINT"])
{
config.wechat_phone_number_endpoint = wechat_phone_number_endpoint;
}
if let Some(wechat_state_ttl_minutes) =
read_first_positive_u32_env(&["WECHAT_STATE_TTL_MINUTES"])
{

View File

@@ -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(

View File

@@ -57,8 +57,8 @@ use spacetime_client::{
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput,
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord,
PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord,
PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord,
PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord,
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
@@ -2189,7 +2189,9 @@ fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraft
ui_background_prompt: level.ui_background_prompt,
ui_background_image_src: level.ui_background_image_src,
ui_background_image_object_key: level.ui_background_image_object_key,
background_music: level.background_music.map(map_puzzle_audio_asset_record_response),
background_music: level
.background_music
.map(map_puzzle_audio_asset_record_response),
candidates: level
.candidates
.into_iter()
@@ -2506,7 +2508,9 @@ fn map_puzzle_runtime_level_response(
theme_tags: level.theme_tags,
cover_image_src: level.cover_image_src,
ui_background_image_src: level.ui_background_image_src,
background_music: level.background_music.map(map_puzzle_audio_asset_record_response),
background_music: level
.background_music
.map(map_puzzle_audio_asset_record_response),
board: map_puzzle_board_response(level.board),
status: level.status,
started_at_ms: level.started_at_ms,
@@ -2800,7 +2804,9 @@ fn parse_puzzle_level_records_from_module_json(
ui_background_prompt: level.ui_background_prompt,
ui_background_image_src: level.ui_background_image_src,
ui_background_image_object_key: level.ui_background_image_object_key,
background_music: level.background_music.map(map_puzzle_audio_asset_domain_record),
background_music: level
.background_music
.map(map_puzzle_audio_asset_domain_record),
candidates: level
.candidates
.into_iter()
@@ -3413,6 +3419,24 @@ fn attach_puzzle_level_background_music(
});
}
fn attach_puzzle_level_ui_background(
levels: &mut [PuzzleDraftLevelRecord],
level_id: &str,
prompt: String,
generated: GeneratedPuzzleUiBackgroundResponse,
) {
let Some(index) = levels
.iter()
.position(|level| level.level_id == level_id)
.or_else(|| (!levels.is_empty()).then_some(0))
else {
return;
};
levels[index].ui_background_prompt = Some(prompt);
levels[index].ui_background_image_src = Some(generated.image_src);
levels[index].ui_background_image_object_key = Some(generated.object_key);
}
async fn try_generate_puzzle_background_music(
state: &AppState,
owner_user_id: &str,
@@ -3456,6 +3480,37 @@ async fn try_generate_puzzle_background_music(
}
}
async fn try_generate_puzzle_initial_ui_background(
state: &AppState,
owner_user_id: &str,
session_id: &str,
draft: &PuzzleResultDraftRecord,
target_level: &PuzzleDraftLevelRecord,
) -> Option<(String, GeneratedPuzzleUiBackgroundResponse)> {
let prompt = normalize_puzzle_ui_background_prompt("", draft, target_level);
match generate_puzzle_ui_background_image(
state,
owner_user_id,
session_id,
target_level.level_name.as_str(),
prompt.as_str(),
)
.await
{
Ok(generated) => Some((prompt, generated)),
Err(error) => {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id,
level_id = %target_level.level_id,
error = %error,
"拼图草稿 UI 背景图自动生成失败,保留草稿并允许结果页重试"
);
None
}
}
}
async fn compile_puzzle_draft_with_initial_cover(
state: &AppState,
session_id: String,
@@ -3540,9 +3595,24 @@ async fn compile_puzzle_draft_with_initial_cover(
music,
);
}
let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(
&updated_levels,
)?);
if let Some((ui_prompt, ui_background)) = try_generate_puzzle_initial_ui_background(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
&draft,
&target_level,
)
.await
{
attach_puzzle_level_ui_background(
&mut updated_levels,
target_level.level_id.as_str(),
ui_prompt,
ui_background,
);
}
let levels_json_with_generated_name =
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
let candidates_json = serde_json::to_string(
&candidates
.iter()
@@ -3674,7 +3744,7 @@ async fn compile_puzzle_draft_with_uploaded_cover(
&target_level.picture_description,
&draft.summary,
);
// 中文注释:关闭 AI 重绘时不请求 VectorEngine,也不进入光点扣费流程;上传图直接成为首关正式图候选。
// 中文注释:关闭 AI 重绘时首关图不请求 VectorEngine上传图直接成为首关正式图候选。
let candidate_id = format!(
"{}-candidate-{}",
compiled_session.session_id,
@@ -3714,9 +3784,24 @@ async fn compile_puzzle_draft_with_uploaded_cover(
music,
);
}
let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(
&updated_levels,
)?);
if let Some((ui_prompt, ui_background)) = try_generate_puzzle_initial_ui_background(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
&draft,
&target_level,
)
.await
{
attach_puzzle_level_ui_background(
&mut updated_levels,
target_level.level_id.as_str(),
ui_prompt,
ui_background,
);
}
let levels_json_with_generated_name =
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
let persisted_upload = persist_puzzle_generated_asset(
state,
owner_user_id.as_str(),
@@ -4728,12 +4813,12 @@ async fn load_puzzle_ui_background_reference_data_url() -> Result<String, AppErr
}))
})?;
if bytes.is_empty() {
return Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(
json!({
return Err(
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图 UI 背景参考图为空",
}),
));
})),
);
}
Ok(format!(
"data:image/png;base64,{}",
@@ -5047,8 +5132,7 @@ mod tests {
let levels_json = serialize_puzzle_levels_response(&request_context, &[level])
.expect("levels should serialize");
let payload: Value =
serde_json::from_str(&levels_json).expect("levels json should parse");
let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse");
assert_eq!(
payload[0]["background_music"]["audio_src"],
Value::String("/generated-puzzle-assets/audio.mp3".to_string())
@@ -5104,8 +5188,7 @@ mod tests {
let levels_json = serialize_puzzle_levels_response(&request_context, &[level])
.expect("levels should serialize");
let payload: Value =
serde_json::from_str(&levels_json).expect("levels json should parse");
let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse");
assert_eq!(
payload[0]["ui_background_prompt"],
Value::String("雨夜猫街竖屏拼图UI背景".to_string())
@@ -5128,10 +5211,8 @@ mod tests {
#[test]
fn puzzle_ui_background_prompt_keeps_square_boundary_constraint() {
let prompt = build_puzzle_ui_background_request_prompt_for_test(
"雨夜猫街",
"雨夜猫街主题背景",
);
let prompt =
build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景");
assert!(prompt.contains("9:16"));
assert!(prompt.contains("中央必须预留清晰正方形拼图区"));
@@ -5139,6 +5220,36 @@ mod tests {
assert!(prompt.contains("不要画文字"));
}
#[test]
fn puzzle_ui_background_initial_attach_updates_first_level_fields() {
let draft = test_puzzle_draft_record();
let generated = GeneratedPuzzleUiBackgroundResponse {
image_src: "/generated-puzzle-assets/session/ui/background.png".to_string(),
object_key: "generated-puzzle-assets/session/ui/background.png".to_string(),
};
let mut levels = draft.levels.clone();
attach_puzzle_level_ui_background(
&mut levels,
"puzzle-level-1",
"雨夜猫街移动端拼图UI背景".to_string(),
generated,
);
assert_eq!(
levels[0].ui_background_prompt.as_deref(),
Some("雨夜猫街移动端拼图UI背景")
);
assert_eq!(
levels[0].ui_background_image_src.as_deref(),
Some("/generated-puzzle-assets/session/ui/background.png")
);
assert_eq!(
levels[0].ui_background_image_object_key.as_deref(),
Some("generated-puzzle-assets/session/ui/background.png")
);
}
fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord {
let item = PuzzleAnchorItemRecord {
key: "visualSubject".to_string(),

View File

@@ -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 {

View File

@@ -5,11 +5,13 @@ use axum::{
response::{IntoResponse, Redirect, Response},
};
use module_auth::{
AuthLoginMethod, BindWechatPhoneInput, CreateWechatAuthStateInput, WechatAuthError,
AuthLoginMethod, BindWechatPhoneInput, BindWechatVerifiedPhoneInput,
CreateWechatAuthStateInput, WechatAuthError,
};
use platform_auth::WechatAuthScene;
use shared_contracts::auth::{
WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery, WechatStartQuery,
WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery,
WechatMiniProgramLoginRequest, WechatMiniProgramLoginResponse, WechatStartQuery,
WechatStartResponse,
};
use time::OffsetDateTime;
@@ -190,18 +192,55 @@ pub async fn bind_wechat_phone(
if !state.config.wechat_auth_enabled {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"));
}
let result = state
.phone_auth_service()
.bind_wechat_phone(
BindWechatPhoneInput {
let result = if let Some(wechat_phone_code) = payload
.wechat_phone_code
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
let phone_profile = state
.wechat_provider()
.resolve_mini_program_phone_number(Some(wechat_phone_code))
.await
.map_err(map_wechat_provider_error)?;
state
.phone_auth_service()
.bind_wechat_verified_phone(BindWechatVerifiedPhoneInput {
user_id: authenticated.claims().user_id().to_string(),
phone_number: payload.phone,
verify_code: payload.code,
},
OffsetDateTime::now_utc(),
)
.await
.map_err(map_wechat_bind_phone_error)?;
phone_number: phone_profile.phone_number,
})
.await
.map_err(map_wechat_bind_phone_error)?
} else {
let phone = payload
.phone
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少需要绑定的手机号")
})?;
let code = payload
.code
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少短信验证码")
})?;
state
.phone_auth_service()
.bind_wechat_phone(
BindWechatPhoneInput {
user_id: authenticated.claims().user_id().to_string(),
phone_number: phone.to_string(),
verify_code: code.to_string(),
},
OffsetDateTime::now_utc(),
)
.await
.map_err(map_wechat_bind_phone_error)?
};
if result.activated_new_user {
crate::registration_reward::grant_new_user_registration_wallet_reward(
&state,
@@ -250,6 +289,68 @@ pub async fn bind_wechat_phone(
))
}
pub async fn login_wechat_mini_program(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
headers: HeaderMap,
Json(payload): Json<WechatMiniProgramLoginRequest>,
) -> Result<impl IntoResponse, AppError> {
if !state.config.wechat_auth_enabled {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"));
}
let code = payload.code.trim();
if code.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少微信授权 code")
);
}
let profile = state
.wechat_provider()
.resolve_mini_program_login_profile(Some(code))
.await
.map_err(map_wechat_provider_error)?;
let result = state
.wechat_auth_service()
.resolve_login(module_auth::ResolveWechatLoginInput {
profile: map_wechat_profile_to_domain(profile),
})
.await
.map_err(map_wechat_auth_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}"))
})?;
let mut response_headers = HeaderMap::new();
attach_set_cookie_header(
&mut response_headers,
build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?,
);
Ok((
response_headers,
json_success_body(
Some(&request_context),
WechatMiniProgramLoginResponse {
token: signed_session.access_token,
binding_status: result.user.binding_status.as_str().to_string(),
user: map_auth_user_payload(result.user),
},
),
))
}
fn resolve_wechat_scene(user_agent: Option<&str>) -> Result<WechatAuthScene, AppError> {
let user_agent = user_agent.unwrap_or_default();
let is_wechat = user_agent.contains("MicroMessenger");

View File

@@ -1,6 +1,8 @@
use platform_auth::{
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_AUTHORIZE_ENDPOINT,
DEFAULT_WECHAT_USER_INFO_ENDPOINT, WechatAuthConfig, WechatProvider,
DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT, DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT,
DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_USER_INFO_ENDPOINT,
WechatAuthConfig, WechatProvider,
};
use crate::config::AppConfig;
@@ -11,6 +13,8 @@ pub fn build_wechat_provider(config: &AppConfig) -> WechatProvider {
config.wechat_auth_provider.clone(),
config.wechat_app_id.clone(),
config.wechat_app_secret.clone(),
config.wechat_mini_program_app_id.clone(),
config.wechat_mini_program_app_secret.clone(),
normalize_wechat_endpoint(
&config.wechat_authorize_endpoint,
DEFAULT_WECHAT_AUTHORIZE_ENDPOINT,
@@ -23,6 +27,18 @@ pub fn build_wechat_provider(config: &AppConfig) -> WechatProvider {
&config.wechat_user_info_endpoint,
DEFAULT_WECHAT_USER_INFO_ENDPOINT,
),
normalize_wechat_endpoint(
&config.wechat_js_code_session_endpoint,
DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT,
),
normalize_wechat_endpoint(
&config.wechat_stable_access_token_endpoint,
DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT,
),
normalize_wechat_endpoint(
&config.wechat_phone_number_endpoint,
DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT,
),
config.wechat_mock_user_id.clone(),
config.wechat_mock_union_id.clone(),
config.wechat_mock_display_name.clone(),