feat: add wechat miniprogram webview login

This commit is contained in:
2026-05-03 19:05:45 +08:00
parent 9baa515a75
commit ce98a29c4d
17 changed files with 758 additions and 42 deletions

View File

@@ -123,7 +123,9 @@ use crate::{
begin_story_runtime_session, begin_story_session, continue_story,
get_story_runtime_projection, get_story_session_state, resolve_story_runtime_action,
},
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;
@@ -241,6 +243,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(
@@ -2716,6 +2722,103 @@ 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_bind_phone_merges_into_existing_phone_user() {
let config = AppConfig {

View File

@@ -52,11 +52,14 @@ 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_state_ttl_minutes: u32,
pub wechat_mock_user_id: String,
pub wechat_mock_union_id: Option<String>,
@@ -146,12 +149,16 @@ 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_state_ttl_minutes: 15,
wechat_mock_user_id: "wx-mock-user".to_string(),
wechat_mock_union_id: Some("wx-mock-union".to_string()),
@@ -355,6 +362,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;
}
@@ -376,6 +387,11 @@ 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_state_ttl_minutes) =
read_first_positive_u32_env(&["WECHAT_STATE_TTL_MINUTES"])
{

View File

@@ -9,7 +9,8 @@ use module_auth::{
};
use platform_auth::WechatAuthScene;
use shared_contracts::auth::{
WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery, WechatStartQuery,
WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery,
WechatMiniProgramLoginRequest, WechatMiniProgramLoginResponse, WechatStartQuery,
WechatStartResponse,
};
use time::OffsetDateTime;
@@ -234,6 +235,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,7 @@
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_USER_INFO_ENDPOINT, WechatAuthConfig,
WechatProvider,
};
use crate::config::AppConfig;
@@ -11,6 +12,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 +26,10 @@ 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,
),
config.wechat_mock_user_id.clone(),
config.wechat_mock_union_id.clone(),
config.wechat_mock_display_name.clone(),