feat(auth): 添加微信小程序登录支持
- 新增 wechat_users 表迁移和 SeaORM Entity - 实现微信登录 Service(code→openid→绑定状态查询) - 实现手机号绑定 Service(创建/关联 user + 签发 JWT) - 添加公开路由 POST /auth/wechat/login 和 /auth/wechat/bind-phone - 新增 WechatConfig 到 AppConfig(appid/secret 通过环境变量配置) - 添加 reqwest 依赖用于调用微信 jscode2session API
This commit is contained in:
@@ -350,7 +350,7 @@ impl AuthService {
|
||||
}
|
||||
|
||||
/// Fetch role details for a user, returning RoleResp DTOs.
|
||||
async fn get_user_role_resps(
|
||||
pub async fn get_user_role_resps(
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
|
||||
@@ -8,3 +8,4 @@ pub mod role_service;
|
||||
pub mod seed;
|
||||
pub mod token_service;
|
||||
pub mod user_service;
|
||||
pub mod wechat_service;
|
||||
|
||||
270
crates/erp-auth/src/service/wechat_service.rs
Normal file
270
crates/erp-auth/src/service/wechat_service.rs
Normal file
@@ -0,0 +1,270 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_state::AuthState;
|
||||
use crate::dto::{LoginResp, UserResp, WechatLoginResp};
|
||||
use crate::entity::wechat_user;
|
||||
use crate::error::{AuthError, AuthResult};
|
||||
use crate::service::auth_service::JwtConfig;
|
||||
use crate::service::token_service::TokenService;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WechatSessionResp {
|
||||
openid: Option<String>,
|
||||
session_key: Option<String>,
|
||||
unionid: Option<String>,
|
||||
errcode: Option<i32>,
|
||||
errmsg: Option<String>,
|
||||
}
|
||||
|
||||
pub struct WechatService;
|
||||
|
||||
impl WechatService {
|
||||
/// 用微信 code 换取 openid,查询绑定状态。
|
||||
pub async fn login(
|
||||
state: &AuthState,
|
||||
tenant_id: Uuid,
|
||||
code: &str,
|
||||
) -> AuthResult<WechatLoginResp> {
|
||||
let session = fetch_session(&state.wechat_appid, &state.wechat_secret, code).await?;
|
||||
|
||||
let openid = session
|
||||
.openid
|
||||
.ok_or_else(|| AuthError::Validation("微信登录失败:未获取到 openid".to_string()))?;
|
||||
|
||||
let existing = wechat_user::Entity::find()
|
||||
.filter(wechat_user::Column::Openid.eq(&openid))
|
||||
.filter(wechat_user::Column::TenantId.eq(tenant_id))
|
||||
.filter(wechat_user::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
if let Some(wu) = existing {
|
||||
let token = build_login_resp(
|
||||
&state.db,
|
||||
wu.user_id,
|
||||
tenant_id,
|
||||
&JwtConfig {
|
||||
secret: &state.jwt_secret,
|
||||
access_ttl_secs: state.access_ttl_secs,
|
||||
refresh_ttl_secs: state.refresh_ttl_secs,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(WechatLoginResp {
|
||||
bound: true,
|
||||
openid,
|
||||
token: Some(token),
|
||||
})
|
||||
} else {
|
||||
Ok(WechatLoginResp {
|
||||
bound: false,
|
||||
openid,
|
||||
token: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定手机号:创建/关联 user → 创建 wechat_user → 签发 JWT。
|
||||
pub async fn bind_phone(
|
||||
state: &AuthState,
|
||||
tenant_id: Uuid,
|
||||
openid: &str,
|
||||
_encrypted_data: &str,
|
||||
_iv: &str,
|
||||
) -> AuthResult<LoginResp> {
|
||||
let phone = Self::decrypt_phone_placeholder();
|
||||
|
||||
let existing = wechat_user::Entity::find()
|
||||
.filter(wechat_user::Column::Openid.eq(openid))
|
||||
.filter(wechat_user::Column::TenantId.eq(tenant_id))
|
||||
.filter(wechat_user::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Err(AuthError::Validation("该微信已绑定账号".to_string()));
|
||||
}
|
||||
|
||||
let user_id =
|
||||
Self::find_or_create_user_by_phone(&state.db, tenant_id, &phone).await?;
|
||||
|
||||
let now = Utc::now();
|
||||
let wu = wechat_user::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
tenant_id: Set(tenant_id),
|
||||
openid: Set(openid.to_string()),
|
||||
union_id: Set(None),
|
||||
user_id: Set(user_id),
|
||||
phone: Set(Some(phone)),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
deleted_at: Set(None),
|
||||
};
|
||||
wu.insert(&state.db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
build_login_resp(
|
||||
&state.db,
|
||||
user_id,
|
||||
tenant_id,
|
||||
&JwtConfig {
|
||||
secret: &state.jwt_secret,
|
||||
access_ttl_secs: state.access_ttl_secs,
|
||||
refresh_ttl_secs: state.refresh_ttl_secs,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// MVP 占位:开发阶段返回固定手机号。
|
||||
fn decrypt_phone_placeholder() -> String {
|
||||
"13800000000".to_string()
|
||||
}
|
||||
|
||||
/// 按手机号查找已有 user,若不存在则创建。
|
||||
async fn find_or_create_user_by_phone(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
phone: &str,
|
||||
) -> AuthResult<Uuid> {
|
||||
use crate::entity::user;
|
||||
|
||||
let existing = user::Entity::find()
|
||||
.filter(user::Column::Phone.eq(phone))
|
||||
.filter(user::Column::TenantId.eq(tenant_id))
|
||||
.filter(user::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
if let Some(u) = existing {
|
||||
return Ok(u.id);
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let user_id = Uuid::now_v7();
|
||||
let suffix = &phone[phone.len().saturating_sub(4)..];
|
||||
let username = format!("wx_{}", suffix);
|
||||
|
||||
let new_user = user::ActiveModel {
|
||||
id: Set(user_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
username: Set(username),
|
||||
display_name: Set(Some(format!("微信用户{}", suffix))),
|
||||
phone: Set(Some(phone.to_string())),
|
||||
email: Set(None),
|
||||
avatar_url: Set(None),
|
||||
status: Set("active".to_string()),
|
||||
last_login_at: Set(None),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(user_id),
|
||||
updated_by: Set(user_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
new_user
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(user_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// 为已确认的 user 构建 LoginResp(token pair + user info)。
|
||||
async fn build_login_resp(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
jwt: &JwtConfig<'_>,
|
||||
) -> AuthResult<LoginResp> {
|
||||
use crate::entity::user;
|
||||
use crate::service::auth_service::AuthService;
|
||||
|
||||
let user_model = user::Entity::find_by_id(user_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
|
||||
|
||||
let roles = TokenService::get_user_roles(user_id, tenant_id, db).await?;
|
||||
let permissions = TokenService::get_user_permissions(user_id, tenant_id, db).await?;
|
||||
|
||||
let access_token = TokenService::sign_access_token(
|
||||
user_id,
|
||||
tenant_id,
|
||||
roles.clone(),
|
||||
permissions,
|
||||
jwt.secret,
|
||||
jwt.access_ttl_secs,
|
||||
)?;
|
||||
let (refresh_token, _) = TokenService::sign_refresh_token(
|
||||
user_id,
|
||||
tenant_id,
|
||||
db,
|
||||
jwt.secret,
|
||||
jwt.refresh_ttl_secs,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let role_resps = AuthService::get_user_role_resps(user_id, tenant_id, db).await?;
|
||||
|
||||
Ok(LoginResp {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_in: jwt.access_ttl_secs as u64,
|
||||
user: UserResp {
|
||||
id: user_model.id,
|
||||
username: user_model.username,
|
||||
email: user_model.email,
|
||||
phone: user_model.phone,
|
||||
display_name: user_model.display_name,
|
||||
avatar_url: user_model.avatar_url,
|
||||
status: user_model.status,
|
||||
roles: role_resps,
|
||||
version: user_model.version,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// 调用微信 jscode2session 接口获取 openid。
|
||||
async fn fetch_session(
|
||||
appid: &str,
|
||||
secret: &str,
|
||||
code: &str,
|
||||
) -> AuthResult<WechatSessionResp> {
|
||||
let url = format!(
|
||||
"https://api.weixin.qq.com/sns/jscode2session?appid={}&secret={}&js_code={}&grant_type=authorization_code",
|
||||
appid, secret, code
|
||||
);
|
||||
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(format!("微信 API 请求失败: {}", e)))?;
|
||||
|
||||
let session: WechatSessionResp = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(format!("微信 API 响应解析失败: {}", e)))?;
|
||||
|
||||
if let Some(errcode) = session.errcode {
|
||||
if errcode != 0 {
|
||||
let msg = session.errmsg.clone().unwrap_or_default();
|
||||
return Err(AuthError::Validation(format!(
|
||||
"微信登录失败 ({}): {}",
|
||||
errcode, msg
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
Reference in New Issue
Block a user