feat: support mini program phone authorization binding

This commit is contained in:
2026-05-12 22:30:24 +08:00
parent 26139f80d3
commit e36a562098
17 changed files with 657 additions and 31 deletions

View File

@@ -3801,6 +3801,113 @@ mod tests {
);
}
#[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 {

View File

@@ -64,6 +64,8 @@ pub struct AppConfig {
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>,
@@ -178,6 +180,10 @@ impl Default for AppConfig {
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()),
@@ -426,6 +432,16 @@ impl AppConfig {
{
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

@@ -5,7 +5,8 @@ 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::{
@@ -191,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,

View File

@@ -1,7 +1,8 @@
use platform_auth::{
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_AUTHORIZE_ENDPOINT,
DEFAULT_WECHAT_JS_CODE_SESSION_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;
@@ -30,6 +31,14 @@ pub fn build_wechat_provider(config: &AppConfig) -> WechatProvider {
&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(),

View File

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

View File

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

View File

@@ -42,6 +42,10 @@ pub const DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT: &str =
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>;
@@ -184,6 +188,8 @@ pub struct WechatAuthConfig {
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,
@@ -224,6 +230,15 @@ pub struct RealWechatProvider {
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)]
@@ -325,6 +340,31 @@ struct WechatJsCodeSessionResponse {
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 使用首字母大写字段名,这里必须显式映射,避免把成功响应误判成空值。
@@ -648,6 +688,8 @@ impl WechatAuthConfig {
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,
@@ -664,6 +706,8 @@ impl WechatAuthConfig {
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,
@@ -717,6 +761,8 @@ impl WechatProvider {
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,
})
}
@@ -755,6 +801,28 @@ impl WechatProvider {
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 {
@@ -990,6 +1058,141 @@ impl RealWechatProvider {
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(
@@ -1919,6 +2122,8 @@ mod tests {
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(),
@@ -1950,6 +2155,8 @@ mod tests {
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(),

View File

@@ -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)]
@@ -332,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"
})
);
}
}