MEDIUM: - WechatLoginReq/WechatBindPhoneReq 添加 Validate 派生 + 字段校验规则 - handler 中调用 req.validate() 并 map_err 转换 - 新增 AuthError::DbError 变体,wechat_service 所有 DB 错误从 Validation 改为 DbError - DbError 映射到 AppError::Internal,不再误导前端 LOW: - fetch_session 改用 reqwest Client.query() 构建参数,自动 URL 编码 - app.tsx PropsWithChildren<any> 改为 Record<string, unknown> - login handleGetPhone 回调 e: any 改为内联类型 - appointment/create 4 个事件回调 e: any 改为内联类型 - health/input catch (e: any) 改为 catch (e: unknown) + instanceof 守卫 - report/detail Object.entries 去掉 [string, any] 类型断言 - wechat_service 移除 decrypt_phone_placeholder 函数,内联占位注释
270 lines
8.0 KiB
Rust
270 lines
8.0 KiB
Rust
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>,
|
|
#[allow(dead_code)]
|
|
session_key: Option<String>,
|
|
#[allow(dead_code)]
|
|
unionid: Option<String>,
|
|
errcode: Option<i32>,
|
|
errmsg: Option<String>,
|
|
}
|
|
|
|
pub struct WechatService;
|
|
|
|
impl WechatService {
|
|
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::DbError(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,
|
|
})
|
|
}
|
|
}
|
|
|
|
pub async fn bind_phone(
|
|
state: &AuthState,
|
|
tenant_id: Uuid,
|
|
openid: &str,
|
|
_encrypted_data: &str,
|
|
_iv: &str,
|
|
) -> AuthResult<LoginResp> {
|
|
// MVP 占位:开发阶段使用固定手机号,上线前替换为真实微信手机号解密
|
|
let phone = "13800000000";
|
|
|
|
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::DbError(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.to_string())),
|
|
created_at: Set(now),
|
|
updated_at: Set(now),
|
|
created_by: Set(Some(user_id)),
|
|
updated_by: Set(Some(user_id)),
|
|
deleted_at: Set(None),
|
|
version: Set(1),
|
|
};
|
|
wu.insert(&state.db)
|
|
.await
|
|
.map_err(|e| AuthError::DbError(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
|
|
}
|
|
|
|
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::DbError(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 new_user = user::ActiveModel {
|
|
id: Set(user_id),
|
|
tenant_id: Set(tenant_id),
|
|
username: Set(format!("wx_{}", suffix)),
|
|
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::DbError(e.to_string()))?;
|
|
|
|
Ok(user_id)
|
|
}
|
|
}
|
|
|
|
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::DbError(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,
|
|
},
|
|
})
|
|
}
|
|
|
|
async fn fetch_session(
|
|
appid: &str,
|
|
secret: &str,
|
|
code: &str,
|
|
) -> AuthResult<WechatSessionResp> {
|
|
let client = reqwest::Client::new();
|
|
let resp = client
|
|
.get("https://api.weixin.qq.com/sns/jscode2session")
|
|
.query(&[
|
|
("appid", appid),
|
|
("secret", secret),
|
|
("js_code", code),
|
|
("grant_type", "authorization_code"),
|
|
])
|
|
.send()
|
|
.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)
|
|
}
|