diff --git a/Cargo.toml b/Cargo.toml index 12b15b1..c12888e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,9 @@ validator = { version = "0.19", features = ["derive"] } # Async trait async-trait = "0.1" +# HTTP client +reqwest = { version = "0.12", features = ["json"] } + # CSV and Excel export csv = "1" rust_xlsxwriter = "0.82" diff --git a/crates/erp-auth/Cargo.toml b/crates/erp-auth/Cargo.toml index a9f99ee..e1c0ff3 100644 --- a/crates/erp-auth/Cargo.toml +++ b/crates/erp-auth/Cargo.toml @@ -21,3 +21,4 @@ sha2.workspace = true validator.workspace = true utoipa.workspace = true async-trait.workspace = true +reqwest.workspace = true diff --git a/crates/erp-auth/src/auth_state.rs b/crates/erp-auth/src/auth_state.rs index 549b844..d407640 100644 --- a/crates/erp-auth/src/auth_state.rs +++ b/crates/erp-auth/src/auth_state.rs @@ -21,6 +21,8 @@ pub struct AuthState { pub access_ttl_secs: i64, pub refresh_ttl_secs: i64, 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. diff --git a/crates/erp-auth/src/dto.rs b/crates/erp-auth/src/dto.rs index b578ad7..0a38577 100644 --- a/crates/erp-auth/src/dto.rs +++ b/crates/erp-auth/src/dto.rs @@ -28,6 +28,27 @@ pub struct RefreshReq { 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, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct WechatBindPhoneReq { + pub openid: String, + pub encrypted_data: String, + pub iv: String, +} + /// 修改密码请求 #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct ChangePasswordReq { diff --git a/crates/erp-auth/src/entity/mod.rs b/crates/erp-auth/src/entity/mod.rs index 399cf21..6884281 100644 --- a/crates/erp-auth/src/entity/mod.rs +++ b/crates/erp-auth/src/entity/mod.rs @@ -9,3 +9,4 @@ pub mod user_credential; pub mod user_department; pub mod user_role; pub mod user_token; +pub mod wechat_user; diff --git a/crates/erp-auth/src/entity/wechat_user.rs b/crates/erp-auth/src/entity/wechat_user.rs new file mode 100644 index 0000000..067d3d9 --- /dev/null +++ b/crates/erp-auth/src/entity/wechat_user.rs @@ -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, + pub user_id: Uuid, + pub phone: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, +} + +#[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 for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-auth/src/handler/mod.rs b/crates/erp-auth/src/handler/mod.rs index 1c02fab..70ab8b2 100644 --- a/crates/erp-auth/src/handler/mod.rs +++ b/crates/erp-auth/src/handler/mod.rs @@ -2,3 +2,4 @@ pub mod auth_handler; pub mod org_handler; pub mod role_handler; pub mod user_handler; +pub mod wechat_handler; diff --git a/crates/erp-auth/src/handler/wechat_handler.rs b/crates/erp-auth/src/handler/wechat_handler.rs new file mode 100644 index 0000000..ccfc6df --- /dev/null +++ b/crates/erp-auth/src/handler/wechat_handler.rs @@ -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), + (status = 400, description = "请求参数错误"), + ), + tag = "认证" +)] +/// POST /api/v1/auth/wechat/login +/// +/// 微信小程序登录:用 code 换 openid,查询绑定状态。 +/// 已绑定用户直接返回 JWT,未绑定用户返回 openid 供后续绑定。 +pub async fn wechat_login( + State(state): State, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + 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), + (status = 400, description = "请求参数错误"), + ), + tag = "认证" +)] +/// POST /api/v1/auth/wechat/bind-phone +/// +/// 微信手机号绑定:解密手机号,创建/关联 user,签发 JWT。 +pub async fn wechat_bind_phone( + State(state): State, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + 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))) +} diff --git a/crates/erp-auth/src/module.rs b/crates/erp-auth/src/module.rs index 8fbf883..1cabf38 100644 --- a/crates/erp-auth/src/module.rs +++ b/crates/erp-auth/src/module.rs @@ -5,7 +5,7 @@ use erp_core::error::AppResult; use erp_core::events::EventBus; 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. /// @@ -30,6 +30,14 @@ impl AuthModule { Router::new() .route("/auth/login", axum::routing::post(auth_handler::login)) .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. diff --git a/crates/erp-auth/src/service/auth_service.rs b/crates/erp-auth/src/service/auth_service.rs index d65e736..db96f6e 100644 --- a/crates/erp-auth/src/service/auth_service.rs +++ b/crates/erp-auth/src/service/auth_service.rs @@ -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, diff --git a/crates/erp-auth/src/service/mod.rs b/crates/erp-auth/src/service/mod.rs index b1ad67d..94a1673 100644 --- a/crates/erp-auth/src/service/mod.rs +++ b/crates/erp-auth/src/service/mod.rs @@ -8,3 +8,4 @@ pub mod role_service; pub mod seed; pub mod token_service; pub mod user_service; +pub mod wechat_service; diff --git a/crates/erp-auth/src/service/wechat_service.rs b/crates/erp-auth/src/service/wechat_service.rs new file mode 100644 index 0000000..66ae687 --- /dev/null +++ b/crates/erp-auth/src/service/wechat_service.rs @@ -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, + session_key: Option, + unionid: Option, + errcode: Option, + errmsg: Option, +} + +pub struct WechatService; + +impl WechatService { + /// 用微信 code 换取 openid,查询绑定状态。 + pub async fn login( + state: &AuthState, + tenant_id: Uuid, + code: &str, + ) -> AuthResult { + 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 { + 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 { + 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 { + 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 { + 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) +} diff --git a/crates/erp-server/config/default.toml b/crates/erp-server/config/default.toml index 5ac820d..e578e97 100644 --- a/crates/erp-server/config/default.toml +++ b/crates/erp-server/config/default.toml @@ -24,3 +24,7 @@ level = "info" [cors] # 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" + +[wechat] +appid = "__MUST_SET_VIA_ENV__" +secret = "__MUST_SET_VIA_ENV__" diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 4e5b7c9..7b841e4 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -42,6 +42,7 @@ mod m20260419_000039_entity_registry_columns; mod m20260419_000040_plugin_market; mod m20260419_000041_plugin_user_views; mod m20260423_000042_create_health_tables; +mod m20260423_000043_create_wechat_users; pub struct Migrator; @@ -91,6 +92,7 @@ impl MigratorTrait for Migrator { Box::new(m20260419_000040_plugin_market::Migration), Box::new(m20260419_000041_plugin_user_views::Migration), Box::new(m20260423_000042_create_health_tables::Migration), + Box::new(m20260423_000043_create_wechat_users::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260423_000043_create_wechat_users.rs b/crates/erp-server/migration/src/m20260423_000043_create_wechat_users.rs new file mode 100644 index 0000000..3890d18 --- /dev/null +++ b/crates/erp-server/migration/src/m20260423_000043_create_wechat_users.rs @@ -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, +} diff --git a/crates/erp-server/src/config.rs b/crates/erp-server/src/config.rs index 926d16e..a52c3e8 100644 --- a/crates/erp-server/src/config.rs +++ b/crates/erp-server/src/config.rs @@ -9,6 +9,7 @@ pub struct AppConfig { pub auth: AuthConfig, pub log: LogConfig, pub cors: CorsConfig, + pub wechat: WechatConfig, } #[derive(Debug, Clone, Deserialize)] @@ -53,6 +54,12 @@ pub struct CorsConfig { pub allowed_origins: String, } +#[derive(Debug, Clone, Deserialize)] +pub struct WechatConfig { + pub appid: String, + pub secret: String, +} + impl AppConfig { pub fn load() -> anyhow::Result { let config = config::Config::builder() diff --git a/crates/erp-server/src/state.rs b/crates/erp-server/src/state.rs index eeba090..43de8a9 100644 --- a/crates/erp-server/src/state.rs +++ b/crates/erp-server/src/state.rs @@ -51,6 +51,8 @@ impl FromRef for erp_auth::AuthState { access_ttl_secs: parse_ttl(&state.config.jwt.access_token_ttl), refresh_ttl_secs: parse_ttl(&state.config.jwt.refresh_token_ttl), default_tenant_id: state.default_tenant_id, + wechat_appid: state.config.wechat.appid.clone(), + wechat_secret: state.config.wechat.secret.clone(), } } }