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:
@@ -80,6 +80,9 @@ validator = { version = "0.19", features = ["derive"] }
|
|||||||
# Async trait
|
# Async trait
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
# HTTP client
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
|
||||||
# CSV and Excel export
|
# CSV and Excel export
|
||||||
csv = "1"
|
csv = "1"
|
||||||
rust_xlsxwriter = "0.82"
|
rust_xlsxwriter = "0.82"
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ sha2.workspace = true
|
|||||||
validator.workspace = true
|
validator.workspace = true
|
||||||
utoipa.workspace = true
|
utoipa.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ pub struct AuthState {
|
|||||||
pub access_ttl_secs: i64,
|
pub access_ttl_secs: i64,
|
||||||
pub refresh_ttl_secs: i64,
|
pub refresh_ttl_secs: i64,
|
||||||
pub default_tenant_id: Uuid,
|
pub default_tenant_id: Uuid,
|
||||||
|
pub wechat_appid: String,
|
||||||
|
pub wechat_secret: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a human-readable TTL string (e.g. "15m", "7d", "1h", "900s") into seconds.
|
/// Parse a human-readable TTL string (e.g. "15m", "7d", "1h", "900s") into seconds.
|
||||||
|
|||||||
@@ -28,6 +28,27 @@ pub struct RefreshReq {
|
|||||||
pub refresh_token: String,
|
pub refresh_token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Wechat DTOs ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct WechatLoginReq {
|
||||||
|
pub code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct WechatLoginResp {
|
||||||
|
pub bound: bool,
|
||||||
|
pub openid: String,
|
||||||
|
pub token: Option<LoginResp>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct WechatBindPhoneReq {
|
||||||
|
pub openid: String,
|
||||||
|
pub encrypted_data: String,
|
||||||
|
pub iv: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// 修改密码请求
|
/// 修改密码请求
|
||||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
pub struct ChangePasswordReq {
|
pub struct ChangePasswordReq {
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ pub mod user_credential;
|
|||||||
pub mod user_department;
|
pub mod user_department;
|
||||||
pub mod user_role;
|
pub mod user_role;
|
||||||
pub mod user_token;
|
pub mod user_token;
|
||||||
|
pub mod wechat_user;
|
||||||
|
|||||||
38
crates/erp-auth/src/entity/wechat_user.rs
Normal file
38
crates/erp-auth/src/entity/wechat_user.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "wechat_users")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub openid: String,
|
||||||
|
#[sea_orm(column_name = "union_id")]
|
||||||
|
pub union_id: Option<String>,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub phone: Option<String>,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::user::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "super::user::Column::Id",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
User,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::user::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::User.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
@@ -2,3 +2,4 @@ pub mod auth_handler;
|
|||||||
pub mod org_handler;
|
pub mod org_handler;
|
||||||
pub mod role_handler;
|
pub mod role_handler;
|
||||||
pub mod user_handler;
|
pub mod user_handler;
|
||||||
|
pub mod wechat_handler;
|
||||||
|
|||||||
77
crates/erp-auth/src/handler/wechat_handler.rs
Normal file
77
crates/erp-auth/src/handler/wechat_handler.rs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
use axum::extract::{FromRef, State};
|
||||||
|
use axum::response::Json;
|
||||||
|
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::types::ApiResponse;
|
||||||
|
|
||||||
|
use crate::auth_state::AuthState;
|
||||||
|
use crate::dto::{LoginResp, WechatBindPhoneReq, WechatLoginReq, WechatLoginResp};
|
||||||
|
use crate::service::wechat_service::WechatService;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/wechat/login",
|
||||||
|
request_body = WechatLoginReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "微信登录成功", body = ApiResponse<WechatLoginResp>),
|
||||||
|
(status = 400, description = "请求参数错误"),
|
||||||
|
),
|
||||||
|
tag = "认证"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/auth/wechat/login
|
||||||
|
///
|
||||||
|
/// 微信小程序登录:用 code 换 openid,查询绑定状态。
|
||||||
|
/// 已绑定用户直接返回 JWT,未绑定用户返回 openid 供后续绑定。
|
||||||
|
pub async fn wechat_login<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Json(req): Json<WechatLoginReq>,
|
||||||
|
) -> Result<Json<ApiResponse<WechatLoginResp>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
if req.code.is_empty() {
|
||||||
|
return Err(AppError::Validation("code 不能为空".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tenant_id = state.default_tenant_id;
|
||||||
|
let resp = WechatService::login(&state, tenant_id, &req.code).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(resp)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/wechat/bind-phone",
|
||||||
|
request_body = WechatBindPhoneReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "绑定成功", body = ApiResponse<LoginResp>),
|
||||||
|
(status = 400, description = "请求参数错误"),
|
||||||
|
),
|
||||||
|
tag = "认证"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/auth/wechat/bind-phone
|
||||||
|
///
|
||||||
|
/// 微信手机号绑定:解密手机号,创建/关联 user,签发 JWT。
|
||||||
|
pub async fn wechat_bind_phone<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Json(req): Json<WechatBindPhoneReq>,
|
||||||
|
) -> Result<Json<ApiResponse<LoginResp>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
if req.openid.is_empty() {
|
||||||
|
return Err(AppError::Validation("openid 不能为空".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tenant_id = state.default_tenant_id;
|
||||||
|
let resp = WechatService::bind_phone(
|
||||||
|
&state,
|
||||||
|
tenant_id,
|
||||||
|
&req.openid,
|
||||||
|
&req.encrypted_data,
|
||||||
|
&req.iv,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(ApiResponse::ok(resp)))
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ use erp_core::error::AppResult;
|
|||||||
use erp_core::events::EventBus;
|
use erp_core::events::EventBus;
|
||||||
use erp_core::module::ErpModule;
|
use erp_core::module::ErpModule;
|
||||||
|
|
||||||
use crate::handler::{auth_handler, org_handler, role_handler, user_handler};
|
use crate::handler::{auth_handler, org_handler, role_handler, user_handler, wechat_handler};
|
||||||
|
|
||||||
/// Auth module implementing the `ErpModule` trait.
|
/// Auth module implementing the `ErpModule` trait.
|
||||||
///
|
///
|
||||||
@@ -30,6 +30,14 @@ impl AuthModule {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/auth/login", axum::routing::post(auth_handler::login))
|
.route("/auth/login", axum::routing::post(auth_handler::login))
|
||||||
.route("/auth/refresh", axum::routing::post(auth_handler::refresh))
|
.route("/auth/refresh", axum::routing::post(auth_handler::refresh))
|
||||||
|
.route(
|
||||||
|
"/auth/wechat/login",
|
||||||
|
axum::routing::post(wechat_handler::wechat_login),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/auth/wechat/bind-phone",
|
||||||
|
axum::routing::post(wechat_handler::wechat_bind_phone),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build protected (authenticated) routes for the auth module.
|
/// Build protected (authenticated) routes for the auth module.
|
||||||
|
|||||||
@@ -350,7 +350,7 @@ impl AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch role details for a user, returning RoleResp DTOs.
|
/// 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,
|
user_id: Uuid,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ pub mod role_service;
|
|||||||
pub mod seed;
|
pub mod seed;
|
||||||
pub mod token_service;
|
pub mod token_service;
|
||||||
pub mod user_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)
|
||||||
|
}
|
||||||
@@ -24,3 +24,7 @@ level = "info"
|
|||||||
[cors]
|
[cors]
|
||||||
# Comma-separated allowed origins. Use "*" for development only.
|
# Comma-separated allowed origins. Use "*" for development only.
|
||||||
allowed_origins = "http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:3000"
|
allowed_origins = "http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:3000"
|
||||||
|
|
||||||
|
[wechat]
|
||||||
|
appid = "__MUST_SET_VIA_ENV__"
|
||||||
|
secret = "__MUST_SET_VIA_ENV__"
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ mod m20260419_000039_entity_registry_columns;
|
|||||||
mod m20260419_000040_plugin_market;
|
mod m20260419_000040_plugin_market;
|
||||||
mod m20260419_000041_plugin_user_views;
|
mod m20260419_000041_plugin_user_views;
|
||||||
mod m20260423_000042_create_health_tables;
|
mod m20260423_000042_create_health_tables;
|
||||||
|
mod m20260423_000043_create_wechat_users;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260419_000040_plugin_market::Migration),
|
Box::new(m20260419_000040_plugin_market::Migration),
|
||||||
Box::new(m20260419_000041_plugin_user_views::Migration),
|
Box::new(m20260419_000041_plugin_user_views::Migration),
|
||||||
Box::new(m20260423_000042_create_health_tables::Migration),
|
Box::new(m20260423_000042_create_health_tables::Migration),
|
||||||
|
Box::new(m20260423_000043_create_wechat_users::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(WechatUsers::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(ColumnDef::new(WechatUsers::Id).uuid().not_null().primary_key())
|
||||||
|
.col(ColumnDef::new(WechatUsers::TenantId).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(WechatUsers::Openid).string().not_null())
|
||||||
|
.col(ColumnDef::new(WechatUsers::UnionId).string())
|
||||||
|
.col(ColumnDef::new(WechatUsers::UserId).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(WechatUsers::Phone).string())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(WechatUsers::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(WechatUsers::UpdatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(WechatUsers::DeletedAt)
|
||||||
|
.timestamp_with_time_zone(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_wechat_users_openid")
|
||||||
|
.table(WechatUsers::Table)
|
||||||
|
.col(WechatUsers::Openid)
|
||||||
|
.col(WechatUsers::TenantId)
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_index(Index::drop().name("idx_wechat_users_openid").to_owned())
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(WechatUsers::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum WechatUsers {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
TenantId,
|
||||||
|
Openid,
|
||||||
|
UnionId,
|
||||||
|
UserId,
|
||||||
|
Phone,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
DeletedAt,
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ pub struct AppConfig {
|
|||||||
pub auth: AuthConfig,
|
pub auth: AuthConfig,
|
||||||
pub log: LogConfig,
|
pub log: LogConfig,
|
||||||
pub cors: CorsConfig,
|
pub cors: CorsConfig,
|
||||||
|
pub wechat: WechatConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -53,6 +54,12 @@ pub struct CorsConfig {
|
|||||||
pub allowed_origins: String,
|
pub allowed_origins: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct WechatConfig {
|
||||||
|
pub appid: String,
|
||||||
|
pub secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn load() -> anyhow::Result<Self> {
|
pub fn load() -> anyhow::Result<Self> {
|
||||||
let config = config::Config::builder()
|
let config = config::Config::builder()
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ impl FromRef<AppState> for erp_auth::AuthState {
|
|||||||
access_ttl_secs: parse_ttl(&state.config.jwt.access_token_ttl),
|
access_ttl_secs: parse_ttl(&state.config.jwt.access_token_ttl),
|
||||||
refresh_ttl_secs: parse_ttl(&state.config.jwt.refresh_token_ttl),
|
refresh_ttl_secs: parse_ttl(&state.config.jwt.refresh_token_ttl),
|
||||||
default_tenant_id: state.default_tenant_id,
|
default_tenant_id: state.default_tenant_id,
|
||||||
|
wechat_appid: state.config.wechat.appid.clone(),
|
||||||
|
wechat_secret: state.config.wechat.secret.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user