Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
This commit is contained in:
@@ -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"));
|
||||
|
||||
@@ -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"])
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -67,6 +67,12 @@ pub struct BindWechatPhoneInput {
|
||||
pub verify_code: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct BindWechatVerifiedPhoneInput {
|
||||
pub user_id: String,
|
||||
pub phone_number: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CreateRefreshSessionInput {
|
||||
pub user_id: String,
|
||||
|
||||
@@ -627,6 +627,33 @@ impl PhoneAuthService {
|
||||
activated_new_user,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn bind_wechat_verified_phone(
|
||||
&self,
|
||||
input: BindWechatVerifiedPhoneInput,
|
||||
) -> Result<BindWechatPhoneResult, PhoneAuthError> {
|
||||
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
|
||||
let current_user = self
|
||||
.store
|
||||
.find_by_user_id(&input.user_id)
|
||||
.map_err(map_password_error_to_phone_error)?
|
||||
.ok_or(PhoneAuthError::UserNotFound)?;
|
||||
if current_user.user.binding_status != AuthBindingStatus::PendingBindPhone {
|
||||
return Err(PhoneAuthError::UserStateMismatch);
|
||||
}
|
||||
if !current_user.user.wechat_bound {
|
||||
return Err(PhoneAuthError::UserStateMismatch);
|
||||
}
|
||||
|
||||
let (merged_user, activated_new_user) = self
|
||||
.store
|
||||
.bind_wechat_phone_to_user(&input.user_id, normalized_phone)?;
|
||||
|
||||
Ok(BindWechatPhoneResult {
|
||||
user: merged_user,
|
||||
activated_new_user,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl WechatAuthStateService {
|
||||
|
||||
@@ -40,6 +40,12 @@ pub const DEFAULT_WECHAT_IN_APP_AUTHORIZE_ENDPOINT: &str =
|
||||
pub const DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT: &str =
|
||||
"https://api.weixin.qq.com/sns/oauth2/access_token";
|
||||
pub const DEFAULT_WECHAT_USER_INFO_ENDPOINT: &str = "https://api.weixin.qq.com/sns/userinfo";
|
||||
pub const DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT: &str =
|
||||
"https://api.weixin.qq.com/sns/jscode2session";
|
||||
pub const DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT: &str =
|
||||
"https://api.weixin.qq.com/cgi-bin/stable_token";
|
||||
pub const DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT: &str =
|
||||
"https://api.weixin.qq.com/wxa/business/getuserphonenumber";
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
@@ -176,9 +182,14 @@ pub struct WechatAuthConfig {
|
||||
pub provider: String,
|
||||
pub app_id: Option<String>,
|
||||
pub app_secret: Option<String>,
|
||||
pub mini_program_app_id: Option<String>,
|
||||
pub mini_program_app_secret: Option<String>,
|
||||
pub authorize_endpoint: String,
|
||||
pub access_token_endpoint: String,
|
||||
pub user_info_endpoint: String,
|
||||
pub js_code_session_endpoint: String,
|
||||
pub stable_access_token_endpoint: String,
|
||||
pub phone_number_endpoint: String,
|
||||
pub mock_user_id: String,
|
||||
pub mock_union_id: Option<String>,
|
||||
pub mock_display_name: String,
|
||||
@@ -211,11 +222,23 @@ pub struct MockWechatProvider {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RealWechatProvider {
|
||||
client: Client,
|
||||
app_id: String,
|
||||
app_secret: String,
|
||||
app_id: Option<String>,
|
||||
app_secret: Option<String>,
|
||||
mini_program_app_id: Option<String>,
|
||||
mini_program_app_secret: Option<String>,
|
||||
authorize_endpoint: String,
|
||||
access_token_endpoint: String,
|
||||
user_info_endpoint: String,
|
||||
js_code_session_endpoint: String,
|
||||
stable_access_token_endpoint: String,
|
||||
phone_number_endpoint: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WechatPhoneNumberProfile {
|
||||
pub phone_number: String,
|
||||
pub pure_phone_number: Option<String>,
|
||||
pub country_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -309,6 +332,39 @@ struct WechatUserInfoResponse {
|
||||
errmsg: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WechatJsCodeSessionResponse {
|
||||
openid: Option<String>,
|
||||
unionid: Option<String>,
|
||||
errcode: Option<i64>,
|
||||
errmsg: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WechatStableAccessTokenResponse {
|
||||
access_token: Option<String>,
|
||||
errcode: Option<i64>,
|
||||
errmsg: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WechatPhoneNumberResponse {
|
||||
errcode: Option<i64>,
|
||||
errmsg: Option<String>,
|
||||
#[serde(default)]
|
||||
phone_info: Option<WechatPhoneNumberInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WechatPhoneNumberInfo {
|
||||
#[serde(default)]
|
||||
phone_number: Option<String>,
|
||||
#[serde(default)]
|
||||
pure_phone_number: Option<String>,
|
||||
#[serde(default)]
|
||||
country_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AliyunSendSmsVerifyCodeResponse {
|
||||
// 阿里云 RPC 原始 JSON 使用首字母大写字段名,这里必须显式映射,避免把成功响应误判成空值。
|
||||
@@ -626,9 +682,14 @@ impl WechatAuthConfig {
|
||||
provider: String,
|
||||
app_id: Option<String>,
|
||||
app_secret: Option<String>,
|
||||
mini_program_app_id: Option<String>,
|
||||
mini_program_app_secret: Option<String>,
|
||||
authorize_endpoint: String,
|
||||
access_token_endpoint: String,
|
||||
user_info_endpoint: String,
|
||||
js_code_session_endpoint: String,
|
||||
stable_access_token_endpoint: String,
|
||||
phone_number_endpoint: String,
|
||||
mock_user_id: String,
|
||||
mock_union_id: Option<String>,
|
||||
mock_display_name: String,
|
||||
@@ -639,9 +700,14 @@ impl WechatAuthConfig {
|
||||
provider,
|
||||
app_id,
|
||||
app_secret,
|
||||
mini_program_app_id,
|
||||
mini_program_app_secret,
|
||||
authorize_endpoint,
|
||||
access_token_endpoint,
|
||||
user_info_endpoint,
|
||||
js_code_session_endpoint,
|
||||
stable_access_token_endpoint,
|
||||
phone_number_endpoint,
|
||||
mock_user_id,
|
||||
mock_union_id,
|
||||
mock_display_name,
|
||||
@@ -665,20 +731,38 @@ impl WechatProvider {
|
||||
});
|
||||
}
|
||||
|
||||
let Some(app_id) = config.app_id else {
|
||||
return Self::Disabled;
|
||||
};
|
||||
let Some(app_secret) = config.app_secret else {
|
||||
let has_web_oauth_config = config
|
||||
.app_id
|
||||
.as_ref()
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
&& config
|
||||
.app_secret
|
||||
.as_ref()
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
let has_mini_program_config = config
|
||||
.mini_program_app_id
|
||||
.as_ref()
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
&& config
|
||||
.mini_program_app_secret
|
||||
.as_ref()
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
if !has_web_oauth_config && !has_mini_program_config {
|
||||
return Self::Disabled;
|
||||
};
|
||||
|
||||
Self::Real(RealWechatProvider {
|
||||
client: Client::new(),
|
||||
app_id,
|
||||
app_secret,
|
||||
app_id: config.app_id,
|
||||
app_secret: config.app_secret,
|
||||
mini_program_app_id: config.mini_program_app_id,
|
||||
mini_program_app_secret: config.mini_program_app_secret,
|
||||
authorize_endpoint: config.authorize_endpoint,
|
||||
access_token_endpoint: config.access_token_endpoint,
|
||||
user_info_endpoint: config.user_info_endpoint,
|
||||
js_code_session_endpoint: config.js_code_session_endpoint,
|
||||
stable_access_token_endpoint: config.stable_access_token_endpoint,
|
||||
phone_number_endpoint: config.phone_number_endpoint,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -706,6 +790,39 @@ impl WechatProvider {
|
||||
Self::Real(provider) => provider.resolve_callback_profile(code).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resolve_mini_program_login_profile(
|
||||
&self,
|
||||
code: Option<&str>,
|
||||
) -> Result<WechatIdentityProfile, WechatProviderError> {
|
||||
match self {
|
||||
Self::Disabled => Err(WechatProviderError::Disabled),
|
||||
Self::Mock(provider) => Ok(provider.resolve_callback_profile(code)),
|
||||
Self::Real(provider) => provider.resolve_mini_program_login_profile(code).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resolve_mini_program_phone_number(
|
||||
&self,
|
||||
code: Option<&str>,
|
||||
) -> Result<WechatPhoneNumberProfile, WechatProviderError> {
|
||||
match self {
|
||||
Self::Disabled => Err(WechatProviderError::Disabled),
|
||||
Self::Mock(_) => {
|
||||
let phone_number = code
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("13800138000")
|
||||
.to_string();
|
||||
Ok(WechatPhoneNumberProfile {
|
||||
phone_number: phone_number.clone(),
|
||||
pure_phone_number: Some(phone_number),
|
||||
country_code: Some("86".to_string()),
|
||||
})
|
||||
}
|
||||
Self::Real(provider) => provider.resolve_mini_program_phone_number(code).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MockWechatProvider {
|
||||
@@ -738,8 +855,11 @@ impl RealWechatProvider {
|
||||
let mut url = Url::parse(endpoint).map_err(|error| {
|
||||
WechatProviderError::InvalidConfig(format!("微信授权地址非法:{error}"))
|
||||
})?;
|
||||
let app_id = self.app_id.as_ref().ok_or_else(|| {
|
||||
WechatProviderError::InvalidConfig("微信开放平台 AppID 未配置".to_string())
|
||||
})?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("appid", &self.app_id)
|
||||
.append_pair("appid", app_id)
|
||||
.append_pair("redirect_uri", callback_url)
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair(
|
||||
@@ -762,13 +882,19 @@ impl RealWechatProvider {
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or(WechatProviderError::MissingCode)?;
|
||||
|
||||
let app_id = self.app_id.as_ref().ok_or_else(|| {
|
||||
WechatProviderError::InvalidConfig("微信开放平台 AppID 未配置".to_string())
|
||||
})?;
|
||||
let app_secret = self.app_secret.as_ref().ok_or_else(|| {
|
||||
WechatProviderError::InvalidConfig("微信开放平台 AppSecret 未配置".to_string())
|
||||
})?;
|
||||
let mut access_token_url = Url::parse(&self.access_token_endpoint).map_err(|error| {
|
||||
WechatProviderError::InvalidConfig(format!("微信 access_token 地址非法:{error}"))
|
||||
})?;
|
||||
access_token_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("appid", &self.app_id)
|
||||
.append_pair("secret", &self.app_secret)
|
||||
.append_pair("appid", app_id)
|
||||
.append_pair("secret", app_secret)
|
||||
.append_pair("code", code)
|
||||
.append_pair("grant_type", "authorization_code");
|
||||
|
||||
@@ -854,6 +980,219 @@ impl RealWechatProvider {
|
||||
avatar_url: user_info_payload.headimgurl,
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_mini_program_login_profile(
|
||||
&self,
|
||||
code: Option<&str>,
|
||||
) -> Result<WechatIdentityProfile, WechatProviderError> {
|
||||
let code = code
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or(WechatProviderError::MissingCode)?;
|
||||
let app_id = self
|
||||
.mini_program_app_id
|
||||
.as_ref()
|
||||
.or(self.app_id.as_ref())
|
||||
.ok_or_else(|| {
|
||||
WechatProviderError::InvalidConfig("微信小程序 AppID 未配置".to_string())
|
||||
})?;
|
||||
let app_secret = self
|
||||
.mini_program_app_secret
|
||||
.as_ref()
|
||||
.or(self.app_secret.as_ref())
|
||||
.ok_or_else(|| {
|
||||
WechatProviderError::InvalidConfig("微信小程序 AppSecret 未配置".to_string())
|
||||
})?;
|
||||
|
||||
let mut js_code_session_url =
|
||||
Url::parse(&self.js_code_session_endpoint).map_err(|error| {
|
||||
WechatProviderError::InvalidConfig(format!("微信 jscode2session 地址非法:{error}"))
|
||||
})?;
|
||||
js_code_session_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("appid", app_id)
|
||||
.append_pair("secret", app_secret)
|
||||
.append_pair("js_code", code)
|
||||
.append_pair("grant_type", "authorization_code");
|
||||
|
||||
let payload = self
|
||||
.client
|
||||
.get(js_code_session_url.as_str())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
warn!(error = %error, "微信小程序 jscode2session 请求失败");
|
||||
WechatProviderError::RequestFailed(
|
||||
"微信小程序登录失败:jscode2session 请求失败".to_string(),
|
||||
)
|
||||
})?
|
||||
.json::<WechatJsCodeSessionResponse>()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
warn!(error = %error, "微信小程序 jscode2session 响应解析失败");
|
||||
WechatProviderError::DeserializeFailed(
|
||||
"微信小程序登录失败:jscode2session 响应非法".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(errcode) = payload.errcode.filter(|value| *value != 0) {
|
||||
return Err(WechatProviderError::Upstream(format!(
|
||||
"微信小程序登录失败:{}",
|
||||
payload
|
||||
.errmsg
|
||||
.unwrap_or_else(|| format!("jscode2session 返回错误 {errcode}"))
|
||||
)));
|
||||
}
|
||||
|
||||
let provider_uid = payload
|
||||
.openid
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
WechatProviderError::MissingProfile("微信小程序登录失败:缺少 openid".to_string())
|
||||
})?;
|
||||
|
||||
Ok(WechatIdentityProfile {
|
||||
provider_uid,
|
||||
provider_union_id: payload.unionid,
|
||||
display_name: None,
|
||||
avatar_url: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_mini_program_phone_number(
|
||||
&self,
|
||||
code: Option<&str>,
|
||||
) -> Result<WechatPhoneNumberProfile, WechatProviderError> {
|
||||
let code = code
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or(WechatProviderError::MissingCode)?;
|
||||
let app_id = self
|
||||
.mini_program_app_id
|
||||
.as_ref()
|
||||
.or(self.app_id.as_ref())
|
||||
.ok_or_else(|| {
|
||||
WechatProviderError::InvalidConfig("微信小程序 AppID 未配置".to_string())
|
||||
})?;
|
||||
let app_secret = self
|
||||
.mini_program_app_secret
|
||||
.as_ref()
|
||||
.or(self.app_secret.as_ref())
|
||||
.ok_or_else(|| {
|
||||
WechatProviderError::InvalidConfig("微信小程序 AppSecret 未配置".to_string())
|
||||
})?;
|
||||
|
||||
let access_token = self
|
||||
.request_mini_program_access_token(app_id, app_secret)
|
||||
.await?;
|
||||
let mut phone_number_url = Url::parse(&self.phone_number_endpoint).map_err(|error| {
|
||||
WechatProviderError::InvalidConfig(format!("微信手机号接口地址非法:{error}"))
|
||||
})?;
|
||||
phone_number_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("access_token", &access_token);
|
||||
|
||||
let payload = self
|
||||
.client
|
||||
.post(phone_number_url.as_str())
|
||||
.json(&serde_json::json!({ "code": code }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
warn!(error = %error, "微信小程序手机号请求失败");
|
||||
WechatProviderError::RequestFailed("微信手机号授权失败:手机号请求失败".to_string())
|
||||
})?
|
||||
.json::<WechatPhoneNumberResponse>()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
warn!(error = %error, "微信小程序手机号响应解析失败");
|
||||
WechatProviderError::DeserializeFailed(
|
||||
"微信手机号授权失败:手机号响应非法".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(errcode) = payload.errcode.filter(|value| *value != 0) {
|
||||
return Err(WechatProviderError::Upstream(format!(
|
||||
"微信手机号授权失败:{}",
|
||||
payload
|
||||
.errmsg
|
||||
.unwrap_or_else(|| format!("getuserphonenumber 返回错误 {errcode}"))
|
||||
)));
|
||||
}
|
||||
|
||||
let phone_info = payload.phone_info.ok_or_else(|| {
|
||||
WechatProviderError::MissingProfile("微信手机号授权失败:缺少手机号信息".to_string())
|
||||
})?;
|
||||
let phone_number = phone_info
|
||||
.pure_phone_number
|
||||
.clone()
|
||||
.or(phone_info.phone_number.clone())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
WechatProviderError::MissingProfile("微信手机号授权失败:缺少手机号".to_string())
|
||||
})?;
|
||||
|
||||
Ok(WechatPhoneNumberProfile {
|
||||
phone_number,
|
||||
pure_phone_number: phone_info.pure_phone_number,
|
||||
country_code: phone_info.country_code,
|
||||
})
|
||||
}
|
||||
|
||||
async fn request_mini_program_access_token(
|
||||
&self,
|
||||
app_id: &str,
|
||||
app_secret: &str,
|
||||
) -> Result<String, WechatProviderError> {
|
||||
let url = Url::parse(&self.stable_access_token_endpoint).map_err(|error| {
|
||||
WechatProviderError::InvalidConfig(format!("微信 stable_token 地址非法:{error}"))
|
||||
})?;
|
||||
let payload = self
|
||||
.client
|
||||
.post(url.as_str())
|
||||
.json(&serde_json::json!({
|
||||
"grant_type": "client_credential",
|
||||
"appid": app_id,
|
||||
"secret": app_secret,
|
||||
"force_refresh": false
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
warn!(error = %error, "微信小程序 stable_token 请求失败");
|
||||
WechatProviderError::RequestFailed(
|
||||
"微信手机号授权失败:access_token 请求失败".to_string(),
|
||||
)
|
||||
})?
|
||||
.json::<WechatStableAccessTokenResponse>()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
warn!(error = %error, "微信小程序 stable_token 响应解析失败");
|
||||
WechatProviderError::DeserializeFailed(
|
||||
"微信手机号授权失败:access_token 响应非法".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(errcode) = payload.errcode.filter(|value| *value != 0) {
|
||||
return Err(WechatProviderError::Upstream(format!(
|
||||
"微信手机号授权失败:{}",
|
||||
payload
|
||||
.errmsg
|
||||
.unwrap_or_else(|| format!("stable_token 返回错误 {errcode}"))
|
||||
)));
|
||||
}
|
||||
|
||||
payload
|
||||
.access_token
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
WechatProviderError::Upstream(
|
||||
payload
|
||||
.errmsg
|
||||
.unwrap_or_else(|| "微信手机号授权失败:缺少 access_token".to_string()),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn build_mock_wechat_authorization_url(
|
||||
@@ -1777,9 +2116,14 @@ mod tests {
|
||||
"mock".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
DEFAULT_WECHAT_AUTHORIZE_ENDPOINT.to_string(),
|
||||
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT.to_string(),
|
||||
DEFAULT_WECHAT_USER_INFO_ENDPOINT.to_string(),
|
||||
DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT.to_string(),
|
||||
DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT.to_string(),
|
||||
DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT.to_string(),
|
||||
"wx-user-001".to_string(),
|
||||
Some("wx-union-001".to_string()),
|
||||
"微信测试用户".to_string(),
|
||||
@@ -1805,9 +2149,14 @@ mod tests {
|
||||
"mock".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
DEFAULT_WECHAT_AUTHORIZE_ENDPOINT.to_string(),
|
||||
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT.to_string(),
|
||||
DEFAULT_WECHAT_USER_INFO_ENDPOINT.to_string(),
|
||||
DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT.to_string(),
|
||||
DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT.to_string(),
|
||||
DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT.to_string(),
|
||||
"wx-user-001".to_string(),
|
||||
Some("wx-union-001".to_string()),
|
||||
"微信测试用户".to_string(),
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
7. `auth/wechat/start`
|
||||
8. `auth/wechat/callback`
|
||||
9. `auth/wechat/bind-phone`
|
||||
10. `auth/wechat/miniprogram-login`
|
||||
|
||||
当前阶段继续补齐的 Stage3 公开请求 DTO:
|
||||
|
||||
|
||||
@@ -211,8 +211,12 @@ pub struct WechatCallbackQuery {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatBindPhoneRequest {
|
||||
pub phone: String,
|
||||
pub code: String,
|
||||
#[serde(default)]
|
||||
pub phone: Option<String>,
|
||||
#[serde(default)]
|
||||
pub code: Option<String>,
|
||||
#[serde(default)]
|
||||
pub wechat_phone_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -222,6 +226,20 @@ pub struct WechatBindPhoneResponse {
|
||||
pub user: AuthUserPayload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatMiniProgramLoginRequest {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatMiniProgramLoginResponse {
|
||||
pub token: String,
|
||||
pub binding_status: String,
|
||||
pub user: AuthUserPayload,
|
||||
}
|
||||
|
||||
pub fn build_available_login_methods(
|
||||
sms_auth_enabled: bool,
|
||||
password_auth_enabled: bool,
|
||||
@@ -318,4 +336,23 @@ mod tests {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wechat_bind_phone_request_accepts_mini_program_phone_code() {
|
||||
let payload = serde_json::to_value(WechatBindPhoneRequest {
|
||||
phone: None,
|
||||
code: None,
|
||||
wechat_phone_code: Some("wx-phone-code-001".to_string()),
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
"phone": null,
|
||||
"code": null,
|
||||
"wechatPhoneCode": "wx-phone-code-001"
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::*;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = custom_world_profile,
|
||||
@@ -1549,11 +1549,26 @@ fn list_custom_world_gallery_snapshots(
|
||||
) -> Result<Vec<CustomWorldGalleryEntrySnapshot>, String> {
|
||||
sync_missing_custom_world_gallery_entries(ctx)?;
|
||||
|
||||
let mut entries = ctx
|
||||
let entries = ctx
|
||||
.db
|
||||
.custom_world_gallery_entry()
|
||||
.iter()
|
||||
.map(|row| build_custom_world_gallery_entry_snapshot(ctx, &row))
|
||||
.collect::<Vec<_>>();
|
||||
let profile_ids = entries
|
||||
.iter()
|
||||
.map(|row| row.profile_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let recent_play_counts = count_recent_public_work_plays_for_profiles(
|
||||
ctx,
|
||||
"custom-world",
|
||||
&profile_ids,
|
||||
ctx.timestamp.to_micros_since_unix_epoch(),
|
||||
);
|
||||
let mut entries = entries
|
||||
.iter()
|
||||
.map(|row| {
|
||||
build_custom_world_gallery_entry_snapshot_with_recent_counts(row, &recent_play_counts)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
entries.sort_by(|left, right| {
|
||||
@@ -5078,6 +5093,19 @@ fn build_custom_world_draft_card_snapshot(
|
||||
fn build_custom_world_gallery_entry_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
row: &CustomWorldGalleryEntry,
|
||||
) -> CustomWorldGalleryEntrySnapshot {
|
||||
let recent_play_counts = count_recent_public_work_plays_for_profiles(
|
||||
ctx,
|
||||
"custom-world",
|
||||
&[row.profile_id.clone()],
|
||||
ctx.timestamp.to_micros_since_unix_epoch(),
|
||||
);
|
||||
build_custom_world_gallery_entry_snapshot_with_recent_counts(row, &recent_play_counts)
|
||||
}
|
||||
|
||||
fn build_custom_world_gallery_entry_snapshot_with_recent_counts(
|
||||
row: &CustomWorldGalleryEntry,
|
||||
recent_play_counts: &HashMap<String, u32>,
|
||||
) -> CustomWorldGalleryEntrySnapshot {
|
||||
CustomWorldGalleryEntrySnapshot {
|
||||
profile_id: row.profile_id.clone(),
|
||||
@@ -5095,12 +5123,10 @@ fn build_custom_world_gallery_entry_snapshot(
|
||||
play_count: row.play_count,
|
||||
remix_count: row.remix_count,
|
||||
like_count: row.like_count,
|
||||
recent_play_count_7d: count_recent_public_work_plays(
|
||||
ctx,
|
||||
"custom-world",
|
||||
&row.profile_id,
|
||||
ctx.timestamp.to_micros_since_unix_epoch(),
|
||||
),
|
||||
recent_play_count_7d: recent_play_counts
|
||||
.get(&row.profile_id)
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
published_at_micros: row.published_at.to_micros_since_unix_epoch(),
|
||||
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use crate::runtime::{
|
||||
ProfilePlayedWorkUpsertInput, ProfileSaveArchiveUpsertInput, PublicWorkLikeRecordInput,
|
||||
PublicWorkPlayRecordInput, add_profile_observed_play_time, count_recent_public_work_plays,
|
||||
grant_profile_wallet_points, record_public_work_like, record_public_work_play,
|
||||
upsert_profile_played_work, upsert_profile_save_archive,
|
||||
count_recent_public_work_plays_for_profiles, grant_profile_wallet_points,
|
||||
record_public_work_like, record_public_work_play, upsert_profile_played_work,
|
||||
upsert_profile_save_archive,
|
||||
};
|
||||
use module_puzzle::{
|
||||
PUZZLE_MAX_TAG_COUNT, PUZZLE_NEXT_LEVEL_MODE_NONE, PUZZLE_NEXT_LEVEL_MODE_SAME_WORK,
|
||||
@@ -1480,12 +1481,21 @@ fn delete_puzzle_work_tx(
|
||||
|
||||
fn list_puzzle_gallery_tx(ctx: &TxContext) -> Result<Vec<PuzzleWorkProfile>, String> {
|
||||
let now_micros = ctx.timestamp.to_micros_since_unix_epoch();
|
||||
let mut items = ctx
|
||||
let rows = ctx
|
||||
.db
|
||||
.puzzle_work_profile()
|
||||
.iter()
|
||||
.filter(|row| row.publication_status == PuzzlePublicationStatus::Published)
|
||||
.map(|row| build_puzzle_work_profile_from_row_with_recent_count(ctx, &row, now_micros))
|
||||
.collect::<Vec<_>>();
|
||||
let profile_ids = rows
|
||||
.iter()
|
||||
.map(|row| row.profile_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let recent_play_counts =
|
||||
count_recent_public_work_plays_for_profiles(ctx, "puzzle", &profile_ids, now_micros);
|
||||
let mut items = rows
|
||||
.iter()
|
||||
.map(|row| build_puzzle_work_profile_from_row_with_recent_counts(row, &recent_play_counts))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros));
|
||||
Ok(items)
|
||||
@@ -2356,6 +2366,18 @@ fn build_puzzle_work_profile_from_row_with_recent_count(
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
fn build_puzzle_work_profile_from_row_with_recent_counts(
|
||||
row: &PuzzleWorkProfileRow,
|
||||
recent_play_counts: &std::collections::HashMap<String, u32>,
|
||||
) -> Result<PuzzleWorkProfile, String> {
|
||||
let mut profile = build_puzzle_work_profile_from_row_without_recent_count(row)?;
|
||||
profile.recent_play_count_7d = recent_play_counts
|
||||
.get(&row.profile_id)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
fn build_puzzle_work_profile_from_row_without_recent_count(
|
||||
row: &PuzzleWorkProfileRow,
|
||||
) -> Result<PuzzleWorkProfile, String> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::*;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
const PUBLIC_WORK_PLAY_DAY_MICROS: i64 = 86_400_000_000;
|
||||
const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7;
|
||||
@@ -1298,25 +1299,94 @@ pub(crate) fn count_recent_public_work_plays(
|
||||
profile_id: &str,
|
||||
now_micros: i64,
|
||||
) -> u32 {
|
||||
count_recent_public_work_plays_for_profiles(
|
||||
ctx,
|
||||
source_type,
|
||||
&[profile_id.to_string()],
|
||||
now_micros,
|
||||
)
|
||||
.remove(profile_id.trim())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub(crate) fn count_recent_public_work_plays_for_profiles(
|
||||
ctx: &ReducerContext,
|
||||
source_type: &str,
|
||||
profile_ids: &[String],
|
||||
now_micros: i64,
|
||||
) -> HashMap<String, u32> {
|
||||
let source_type = source_type.trim();
|
||||
let profile_id = profile_id.trim();
|
||||
if source_type.is_empty() || profile_id.is_empty() {
|
||||
return 0;
|
||||
if source_type.is_empty() || profile_ids.is_empty() {
|
||||
return HashMap::new();
|
||||
}
|
||||
|
||||
let current_day = public_work_play_day_from_micros(now_micros);
|
||||
let first_day = current_day.saturating_sub(PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS - 1);
|
||||
|
||||
ctx.db
|
||||
.public_work_play_daily_stat()
|
||||
let requested_profile_ids = profile_ids
|
||||
.iter()
|
||||
.filter(|row| {
|
||||
row.source_type == source_type
|
||||
&& row.profile_id == profile_id
|
||||
&& row.played_day >= first_day
|
||||
&& row.played_day <= current_day
|
||||
})
|
||||
.fold(0u32, |total, row| total.saturating_add(row.play_count))
|
||||
.map(|profile_id| profile_id.trim())
|
||||
.filter(|profile_id| !profile_id.is_empty())
|
||||
.collect::<HashSet<_>>();
|
||||
let mut counts = HashMap::new();
|
||||
|
||||
for profile_id in requested_profile_ids {
|
||||
let mut total = 0u32;
|
||||
for played_day in first_day..=current_day {
|
||||
let day_total = ctx
|
||||
.db
|
||||
.public_work_play_daily_stat()
|
||||
.by_public_work_play_daily_stat_work_day()
|
||||
.filter((source_type, profile_id, played_day))
|
||||
.fold(0u32, |sum, row| sum.saturating_add(row.play_count));
|
||||
total = total.saturating_add(day_total);
|
||||
}
|
||||
if total > 0 {
|
||||
counts.insert(profile_id.to_string(), total);
|
||||
}
|
||||
}
|
||||
|
||||
counts
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn build_recent_public_work_play_counts(
|
||||
rows: impl IntoIterator<Item = PublicWorkPlayDailyStat>,
|
||||
source_type: &str,
|
||||
profile_ids: &[String],
|
||||
now_micros: i64,
|
||||
) -> HashMap<String, u32> {
|
||||
let source_type = source_type.trim();
|
||||
if source_type.is_empty() || profile_ids.is_empty() {
|
||||
return HashMap::new();
|
||||
}
|
||||
|
||||
let requested_profile_ids = profile_ids
|
||||
.iter()
|
||||
.map(|profile_id| profile_id.trim())
|
||||
.filter(|profile_id| !profile_id.is_empty())
|
||||
.collect::<HashSet<_>>();
|
||||
if requested_profile_ids.is_empty() {
|
||||
return HashMap::new();
|
||||
}
|
||||
|
||||
let current_day = public_work_play_day_from_micros(now_micros);
|
||||
let first_day = current_day.saturating_sub(PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS - 1);
|
||||
let mut counts = HashMap::new();
|
||||
|
||||
for row in rows {
|
||||
if row.source_type != source_type
|
||||
|| !requested_profile_ids.contains(row.profile_id.as_str())
|
||||
|| row.played_day < first_day
|
||||
|| row.played_day > current_day
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry = counts.entry(row.profile_id.clone()).or_insert(0u32);
|
||||
*entry = entry.saturating_add(row.play_count);
|
||||
}
|
||||
|
||||
counts
|
||||
}
|
||||
|
||||
fn public_work_play_day_from_micros(value: i64) -> i64 {
|
||||
@@ -1335,6 +1405,75 @@ fn build_public_work_like_id(source_type: &str, profile_id: &str, user_id: &str)
|
||||
format!("{source_type}:{profile_id}:{user_id}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recent_public_work_play_counts_group_requested_profiles_in_window() {
|
||||
let now_micros = PUBLIC_WORK_PLAY_DAY_MICROS * 10;
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(now_micros);
|
||||
let rows = vec![
|
||||
PublicWorkPlayDailyStat {
|
||||
stat_id: "puzzle:profile-a:10".to_string(),
|
||||
source_type: "puzzle".to_string(),
|
||||
owner_user_id: "user-a".to_string(),
|
||||
profile_id: "profile-a".to_string(),
|
||||
played_day: 10,
|
||||
play_count: 3,
|
||||
updated_at,
|
||||
},
|
||||
PublicWorkPlayDailyStat {
|
||||
stat_id: "puzzle:profile-a:4".to_string(),
|
||||
source_type: "puzzle".to_string(),
|
||||
owner_user_id: "user-a".to_string(),
|
||||
profile_id: "profile-a".to_string(),
|
||||
played_day: 4,
|
||||
play_count: 5,
|
||||
updated_at,
|
||||
},
|
||||
PublicWorkPlayDailyStat {
|
||||
stat_id: "puzzle:profile-a:3".to_string(),
|
||||
source_type: "puzzle".to_string(),
|
||||
owner_user_id: "user-a".to_string(),
|
||||
profile_id: "profile-a".to_string(),
|
||||
played_day: 3,
|
||||
play_count: 99,
|
||||
updated_at,
|
||||
},
|
||||
PublicWorkPlayDailyStat {
|
||||
stat_id: "custom-world:profile-a:10".to_string(),
|
||||
source_type: "custom-world".to_string(),
|
||||
owner_user_id: "user-a".to_string(),
|
||||
profile_id: "profile-a".to_string(),
|
||||
played_day: 10,
|
||||
play_count: 7,
|
||||
updated_at,
|
||||
},
|
||||
PublicWorkPlayDailyStat {
|
||||
stat_id: "puzzle:profile-b:9".to_string(),
|
||||
source_type: "puzzle".to_string(),
|
||||
owner_user_id: "user-b".to_string(),
|
||||
profile_id: "profile-b".to_string(),
|
||||
played_day: 9,
|
||||
play_count: 11,
|
||||
updated_at,
|
||||
},
|
||||
];
|
||||
|
||||
let counts = build_recent_public_work_play_counts(
|
||||
rows,
|
||||
"puzzle",
|
||||
&["profile-a".to_string(), "profile-b".to_string()],
|
||||
now_micros,
|
||||
);
|
||||
|
||||
assert_eq!(counts.get("profile-a"), Some(&8));
|
||||
assert_eq!(counts.get("profile-b"), Some(&11));
|
||||
assert_eq!(counts.get("profile-c"), None);
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_profile_dashboard_state(ctx: &ReducerContext, user_id: &str, updated_at: Timestamp) {
|
||||
if ctx
|
||||
.db
|
||||
|
||||
Reference in New Issue
Block a user