feat: initialize ERP base platform (extracted from HMS)

- Stripped 11 business crates (health, ai, dialysis, plugins)
- Cleaned AppState, AppConfig, main.rs from business coupling
- Reduced migrations from 169 to 53 (base-only)
- Removed health_provider trait from erp-core
- Removed business integration tests
- Removed gateway rate limiting middleware
- Base capabilities: auth, RBAC, JWT, config, workflow, message, plugin, audit, crypto, RLS, multi-tenant

Cargo check: OK
Cargo test: OK
This commit is contained in:
iven
2026-05-31 20:35:57 +08:00
commit 59856ac2fc
639 changed files with 124710 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
[package]
name = "erp-auth"
version.workspace = true
edition.workspace = true
[dependencies]
erp-core.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
uuid.workspace = true
chrono.workspace = true
axum.workspace = true
sea-orm.workspace = true
tracing.workspace = true
thiserror.workspace = true
jsonwebtoken.workspace = true
argon2.workspace = true
sha2.workspace = true
validator.workspace = true
utoipa.workspace = true
async-trait.workspace = true
reqwest.workspace = true
aes.workspace = true
cbc.workspace = true
hex.workspace = true
base64 = "0.22"
redis.workspace = true
dashmap.workspace = true

View File

@@ -0,0 +1,83 @@
use erp_core::crypto::PiiCrypto;
use erp_core::events::EventBus;
use sea_orm::DatabaseConnection;
use uuid::Uuid;
/// Auth-specific state extracted from the server's AppState via `FromRef`.
///
/// This avoids a circular dependency between erp-auth and erp-server.
/// The server crate implements `FromRef<AppState> for AuthState` so that
/// Axum handlers in erp-auth can extract `State<AuthState>` directly.
///
/// Contains everything the auth handlers need:
/// - Database connection for user/credential lookups
/// - EventBus for publishing domain events
/// - JWT configuration for token signing and validation
/// - Default tenant ID for the bootstrap phase
#[derive(Clone)]
pub struct AuthState {
pub db: DatabaseConnection,
pub event_bus: EventBus,
pub jwt_secret: String,
pub access_ttl_secs: i64,
pub refresh_ttl_secs: i64,
pub default_tenant_id: Uuid,
pub wechat_appid: String,
pub wechat_secret: String,
pub wechat_dev_mode: bool,
pub redis: Option<redis::Client>,
pub crypto: PiiCrypto,
}
/// Parse a human-readable TTL string (e.g. "15m", "7d", "1h", "900s") into seconds.
///
/// Falls back to parsing the raw string as seconds if no unit suffix is recognized.
pub fn parse_ttl(ttl: &str) -> i64 {
let ttl = ttl.trim();
if let Some(num) = ttl.strip_suffix('s') {
num.parse::<i64>().unwrap_or(900)
} else if let Some(num) = ttl.strip_suffix('m') {
num.parse::<i64>().map(|n| n * 60).unwrap_or(900)
} else if let Some(num) = ttl.strip_suffix('h') {
num.parse::<i64>().map(|n| n * 3600).unwrap_or(900)
} else if let Some(num) = ttl.strip_suffix('d') {
num.parse::<i64>().map(|n| n * 86400).unwrap_or(900)
} else {
ttl.parse::<i64>().unwrap_or(900)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_ttl_seconds() {
assert_eq!(parse_ttl("900s"), 900);
}
#[test]
fn parse_ttl_minutes() {
assert_eq!(parse_ttl("15m"), 900);
}
#[test]
fn parse_ttl_hours() {
assert_eq!(parse_ttl("1h"), 3600);
}
#[test]
fn parse_ttl_days() {
assert_eq!(parse_ttl("7d"), 604800);
}
#[test]
fn parse_ttl_raw_number() {
assert_eq!(parse_ttl("300"), 300);
}
#[test]
fn parse_ttl_fallback_on_invalid() {
assert_eq!(parse_ttl("invalid"), 900);
}
}

506
crates/erp-auth/src/dto.rs Normal file
View File

@@ -0,0 +1,506 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use validator::Validate;
use erp_core::sanitize::{sanitize_option, sanitize_string};
// --- Auth DTOs ---
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct LoginReq {
#[validate(length(min = 1, message = "用户名不能为空"))]
pub username: String,
#[validate(length(min = 1, max = 128, message = "密码长度需在1-128之间"))]
pub password: String,
/// 客户端类型: "miniprogram" 允许患者角色登录
#[serde(default)]
pub client_type: Option<String>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct LoginResp {
pub access_token: String,
pub refresh_token: String,
pub expires_in: u64,
pub user: UserResp,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct RefreshReq {
pub refresh_token: String,
}
// --- Wechat DTOs ---
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct WechatLoginReq {
#[validate(length(min = 1, message = "code 不能为空"))]
pub code: String,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct WechatLoginResp {
pub bound: bool,
pub openid: String,
pub token: Option<LoginResp>,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct WechatBindPhoneReq {
#[validate(length(min = 1, message = "openid 不能为空"))]
pub openid: String,
#[validate(length(min = 1, message = "encrypted_data 不能为空"))]
pub encrypted_data: String,
#[validate(length(min = 1, message = "iv 不能为空"))]
pub iv: String,
}
/// 修改密码请求
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct ChangePasswordReq {
#[validate(length(min = 1, message = "当前密码不能为空"))]
pub current_password: String,
#[validate(length(min = 6, max = 128, message = "新密码长度需在6-128之间"))]
pub new_password: String,
}
/// 管理员重置用户密码请求
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct ResetPasswordReq {
#[validate(length(min = 6, max = 128, message = "新密码长度需在6-128之间"))]
pub new_password: String,
#[validate(range(min = 0))]
pub version: i32,
}
// --- User DTOs ---
#[derive(Debug, Serialize, ToSchema)]
pub struct UserResp {
pub id: Uuid,
pub username: String,
pub email: Option<String>,
pub phone: Option<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub status: String,
pub roles: Vec<RoleResp>,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct CreateUserReq {
#[validate(length(min = 1, max = 50))]
pub username: String,
#[validate(length(min = 6, max = 128))]
pub password: String,
#[validate(email)]
pub email: Option<String>,
#[validate(length(max = 20))]
pub phone: Option<String>,
#[validate(length(max = 100))]
pub display_name: Option<String>,
}
impl CreateUserReq {
/// 清理所有用户输入字段中的 HTML 标签,防止存储型 XSS。
pub fn sanitize(&mut self) {
self.username = sanitize_string(&self.username);
self.email = sanitize_option(self.email.take());
self.phone = sanitize_option(self.phone.take());
self.display_name = sanitize_option(self.display_name.take());
}
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateUserReq {
#[validate(email)]
pub email: Option<String>,
#[validate(length(max = 20))]
pub phone: Option<String>,
#[validate(length(max = 100))]
pub display_name: Option<String>,
#[validate(length(min = 1, max = 20))]
pub status: Option<String>,
pub version: i32,
}
impl UpdateUserReq {
/// 清理所有用户输入字段中的 HTML 标签,防止存储型 XSS。
pub fn sanitize(&mut self) {
self.email = sanitize_option(self.email.take());
self.phone = sanitize_option(self.phone.take());
self.display_name = sanitize_option(self.display_name.take());
}
}
// --- Role DTOs ---
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct RoleResp {
pub id: Uuid,
pub name: String,
pub code: String,
pub description: Option<String>,
pub is_system: bool,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct CreateRoleReq {
#[validate(length(min = 1, max = 50))]
pub name: String,
#[validate(length(min = 1, max = 50))]
pub code: String,
pub description: Option<String>,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateRoleReq {
#[validate(length(min = 1, max = 50))]
pub name: Option<String>,
pub description: Option<String>,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct AssignRolesReq {
#[validate(length(min = 1, message = "至少需要分配一个角色"))]
pub role_ids: Vec<Uuid>,
}
// --- Permission DTOs ---
#[derive(Debug, Serialize, ToSchema)]
pub struct PermissionResp {
pub id: Uuid,
pub code: String,
pub name: String,
pub resource: String,
pub action: String,
pub description: Option<String>,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct AssignPermissionsReq {
#[validate(length(min = 1, message = "至少需要分配一个权限"))]
pub permission_ids: Vec<Uuid>,
}
// --- Organization DTOs ---
#[derive(Debug, Serialize, ToSchema)]
pub struct OrganizationResp {
pub id: Uuid,
pub name: String,
pub code: Option<String>,
pub parent_id: Option<Uuid>,
pub path: Option<String>,
pub level: i32,
pub sort_order: i32,
pub children: Vec<OrganizationResp>,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct CreateOrganizationReq {
#[validate(length(min = 1))]
pub name: String,
pub code: Option<String>,
pub parent_id: Option<Uuid>,
pub sort_order: Option<i32>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateOrganizationReq {
pub name: Option<String>,
pub code: Option<String>,
pub sort_order: Option<i32>,
pub version: i32,
}
// --- Department DTOs ---
#[derive(Debug, Serialize, ToSchema)]
pub struct DepartmentResp {
pub id: Uuid,
pub org_id: Uuid,
pub name: String,
pub code: Option<String>,
pub parent_id: Option<Uuid>,
pub manager_id: Option<Uuid>,
pub path: Option<String>,
pub sort_order: i32,
pub children: Vec<DepartmentResp>,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct CreateDepartmentReq {
#[validate(length(min = 1))]
pub name: String,
pub code: Option<String>,
pub parent_id: Option<Uuid>,
pub manager_id: Option<Uuid>,
pub sort_order: Option<i32>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateDepartmentReq {
pub name: Option<String>,
pub code: Option<String>,
pub manager_id: Option<Uuid>,
pub sort_order: Option<i32>,
pub version: i32,
}
// --- Position DTOs ---
#[derive(Debug, Serialize, ToSchema)]
pub struct PositionResp {
pub id: Uuid,
pub dept_id: Uuid,
pub name: String,
pub code: Option<String>,
pub level: i32,
pub sort_order: i32,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct CreatePositionReq {
#[validate(length(min = 1))]
pub name: String,
pub code: Option<String>,
pub level: Option<i32>,
pub sort_order: Option<i32>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdatePositionReq {
pub name: Option<String>,
pub code: Option<String>,
pub level: Option<i32>,
pub sort_order: Option<i32>,
pub version: i32,
}
#[cfg(test)]
mod tests {
use super::*;
use validator::Validate;
#[test]
fn login_req_valid() {
let req = LoginReq {
username: "admin".to_string(),
password: "password123".to_string(),
client_type: None,
};
assert!(req.validate().is_ok());
}
#[test]
fn login_req_empty_username_fails() {
let req = LoginReq {
username: "".to_string(),
password: "password123".to_string(),
client_type: None,
};
let result = req.validate();
assert!(result.is_err());
}
#[test]
fn change_password_req_valid() {
let req = ChangePasswordReq {
current_password: "oldPassword123".to_string(),
new_password: "newPassword456".to_string(),
};
assert!(req.validate().is_ok());
}
#[test]
fn change_password_req_empty_current_fails() {
let req = ChangePasswordReq {
current_password: "".to_string(),
new_password: "newPassword456".to_string(),
};
assert!(req.validate().is_err());
}
#[test]
fn change_password_req_short_new_fails() {
let req = ChangePasswordReq {
current_password: "oldPassword123".to_string(),
new_password: "12345".to_string(), // min 6
};
assert!(req.validate().is_err());
}
#[test]
fn change_password_req_long_new_fails() {
let req = ChangePasswordReq {
current_password: "oldPassword123".to_string(),
new_password: "a".repeat(129), // max 128
};
assert!(req.validate().is_err());
}
#[test]
fn login_req_empty_password_fails() {
let req = LoginReq {
username: "admin".to_string(),
password: "".to_string(),
client_type: None,
};
assert!(req.validate().is_err());
}
#[test]
fn create_user_req_valid() {
let req = CreateUserReq {
username: "alice".to_string(),
password: "secret123".to_string(),
email: Some("alice@example.com".to_string()),
phone: None,
display_name: Some("Alice".to_string()),
};
assert!(req.validate().is_ok());
}
#[test]
fn create_user_req_short_password_fails() {
let req = CreateUserReq {
username: "bob".to_string(),
password: "12345".to_string(), // min 6
email: None,
phone: None,
display_name: None,
};
assert!(req.validate().is_err());
}
#[test]
fn create_user_req_empty_username_fails() {
let req = CreateUserReq {
username: "".to_string(),
password: "secret123".to_string(),
email: None,
phone: None,
display_name: None,
};
assert!(req.validate().is_err());
}
#[test]
fn create_user_req_invalid_email_fails() {
let req = CreateUserReq {
username: "charlie".to_string(),
password: "secret123".to_string(),
email: Some("not-an-email".to_string()),
phone: None,
display_name: None,
};
assert!(req.validate().is_err());
}
#[test]
fn create_user_req_long_username_fails() {
let req = CreateUserReq {
username: "a".repeat(51), // max 50
password: "secret123".to_string(),
email: None,
phone: None,
display_name: None,
};
assert!(req.validate().is_err());
}
#[test]
fn create_role_req_valid() {
let req = CreateRoleReq {
name: "管理员".to_string(),
code: "admin".to_string(),
description: Some("系统管理员".to_string()),
};
assert!(req.validate().is_ok());
}
#[test]
fn create_role_req_empty_name_fails() {
let req = CreateRoleReq {
name: "".to_string(),
code: "admin".to_string(),
description: None,
};
assert!(req.validate().is_err());
}
#[test]
fn create_role_req_empty_code_fails() {
let req = CreateRoleReq {
name: "管理员".to_string(),
code: "".to_string(),
description: None,
};
assert!(req.validate().is_err());
}
#[test]
fn create_org_req_valid() {
let req = CreateOrganizationReq {
name: "总部".to_string(),
code: Some("HQ".to_string()),
parent_id: None,
sort_order: Some(0),
};
assert!(req.validate().is_ok());
}
#[test]
fn create_org_req_empty_name_fails() {
let req = CreateOrganizationReq {
name: "".to_string(),
code: None,
parent_id: None,
sort_order: None,
};
assert!(req.validate().is_err());
}
#[test]
fn create_dept_req_valid() {
let req = CreateDepartmentReq {
name: "技术部".to_string(),
code: Some("TECH".to_string()),
parent_id: None,
manager_id: None,
sort_order: Some(1),
};
assert!(req.validate().is_ok());
}
#[test]
fn create_position_req_valid() {
let req = CreatePositionReq {
name: "高级工程师".to_string(),
code: Some("SENIOR".to_string()),
level: Some(3),
sort_order: None,
};
assert!(req.validate().is_ok());
}
#[test]
fn create_position_req_empty_name_fails() {
let req = CreatePositionReq {
name: "".to_string(),
code: None,
level: None,
sort_order: None,
};
assert!(req.validate().is_err());
}
}

View File

@@ -0,0 +1,68 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "departments")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub org_id: Uuid,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manager_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
pub sort_order: i32,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub created_by: Uuid,
pub updated_by: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::organization::Entity",
from = "Column::OrgId",
to = "super::organization::Column::Id",
on_delete = "Restrict"
)]
Organization,
#[sea_orm(has_many = "super::position::Entity")]
Position,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::ManagerId",
to = "super::user::Column::Id",
on_delete = "SetNull"
)]
Manager,
}
impl Related<super::organization::Entity> for Entity {
fn to() -> RelationDef {
Relation::Organization.def()
}
}
impl Related<super::position::Entity> for Entity {
fn to() -> RelationDef {
Relation::Position.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::Manager.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,12 @@
pub mod department;
pub mod organization;
pub mod permission;
pub mod position;
pub mod role;
pub mod role_permission;
pub mod user;
pub mod user_credential;
pub mod user_department;
pub mod user_role;
pub mod user_token;
pub mod wechat_user;

View File

@@ -0,0 +1,40 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "organizations")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
pub level: i32,
pub sort_order: i32,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub created_by: Uuid,
pub updated_by: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::department::Entity")]
Department,
}
impl Related<super::department::Entity> for Entity {
fn to() -> RelationDef {
Relation::Department.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,37 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "permissions")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub code: String,
pub name: String,
pub resource: String,
pub action: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub created_by: Uuid,
pub updated_by: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::role_permission::Entity")]
RolePermission,
}
impl Related<super::role_permission::Entity> for Entity {
fn to() -> RelationDef {
Relation::RolePermission.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,42 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "positions")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub dept_id: Uuid,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
pub level: i32,
pub sort_order: i32,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub created_by: Uuid,
pub updated_by: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::department::Entity",
from = "Column::DeptId",
to = "super::department::Column::Id",
on_delete = "Restrict"
)]
Department,
}
impl Related<super::department::Entity> for Entity {
fn to() -> RelationDef {
Relation::Department.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,44 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "roles")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub name: String,
pub code: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub is_system: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub created_by: Uuid,
pub updated_by: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::role_permission::Entity")]
RolePermission,
#[sea_orm(has_many = "super::user_role::Entity")]
UserRole,
}
impl Related<super::role_permission::Entity> for Entity {
fn to() -> RelationDef {
Relation::RolePermission.def()
}
}
impl Related<super::user_role::Entity> for Entity {
fn to() -> RelationDef {
Relation::UserRole.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,53 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "role_permissions")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub role_id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
pub permission_id: Uuid,
pub tenant_id: Uuid,
/// 行级数据权限范围: all, self, department, department_tree
pub data_scope: String,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub created_by: Uuid,
pub updated_by: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::role::Entity",
from = "Column::RoleId",
to = "super::role::Column::Id",
on_delete = "Cascade"
)]
Role,
#[sea_orm(
belongs_to = "super::permission::Entity",
from = "Column::PermissionId",
to = "super::permission::Column::Id",
on_delete = "Cascade"
)]
Permission,
}
impl Related<super::role::Entity> for Entity {
fn to() -> RelationDef {
Relation::Role.def()
}
}
impl Related<super::permission::Entity> for Entity {
fn to() -> RelationDef {
Relation::Permission.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,59 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub username: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_login_at: Option<DateTimeUtc>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub created_by: Uuid,
pub updated_by: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::user_credential::Entity")]
UserCredential,
#[sea_orm(has_many = "super::user_token::Entity")]
UserToken,
#[sea_orm(has_many = "super::user_role::Entity")]
UserRole,
}
impl Related<super::user_credential::Entity> for Entity {
fn to() -> RelationDef {
Relation::UserCredential.def()
}
}
impl Related<super::user_token::Entity> for Entity {
fn to() -> RelationDef {
Relation::UserToken.def()
}
}
impl Related<super::user_role::Entity> for Entity {
fn to() -> RelationDef {
Relation::UserRole.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,41 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "user_credentials")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub user_id: Uuid,
pub credential_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub credential_data: Option<serde_json::Value>,
pub verified: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub created_by: Uuid,
pub updated_by: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[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 {}

View File

@@ -0,0 +1,54 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "user_departments")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
pub department_id: Uuid,
pub tenant_id: Uuid,
pub is_primary: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[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,
#[sea_orm(
belongs_to = "super::department::Entity",
from = "Column::DepartmentId",
to = "super::department::Column::Id",
on_delete = "Cascade"
)]
Department,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl Related<super::department::Entity> for Entity {
fn to() -> RelationDef {
Relation::Department.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,51 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "user_roles")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
pub role_id: Uuid,
pub tenant_id: Uuid,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub created_by: Uuid,
pub updated_by: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[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,
#[sea_orm(
belongs_to = "super::role::Entity",
from = "Column::RoleId",
to = "super::role::Column::Id",
on_delete = "Cascade"
)]
Role,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl Related<super::role::Entity> for Entity {
fn to() -> RelationDef {
Relation::Role.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,44 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "user_tokens")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub user_id: Uuid,
pub token_hash: String,
pub token_type: String,
pub expires_at: DateTimeUtc,
#[serde(skip_serializing_if = "Option::is_none")]
pub revoked_at: Option<DateTimeUtc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_info: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub created_by: Uuid,
pub updated_by: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[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 {}

View File

@@ -0,0 +1,41 @@
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,
pub created_by: Option<Uuid>,
pub updated_by: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[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 {}

View File

@@ -0,0 +1,105 @@
use erp_core::error::AppError;
/// Auth module error types
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("用户名或密码错误")]
InvalidCredentials,
#[error("Token 已过期")]
TokenExpired,
#[error("Token 已被吊销")]
TokenRevoked,
#[error("用户已被{0}")]
UserDisabled(String),
#[error("密码哈希错误")]
HashError(String),
#[error("JWT 错误: {0}")]
JwtError(#[from] jsonwebtoken::errors::Error),
#[error("数据库错误: {0}")]
DbError(String),
#[error("{0}")]
Validation(String),
#[error("{0}")]
Forbidden(String),
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
VersionMismatch,
}
impl From<AuthError> for AppError {
fn from(err: AuthError) -> Self {
match err {
AuthError::InvalidCredentials => AppError::Unauthorized,
AuthError::TokenExpired => AppError::Unauthorized,
AuthError::TokenRevoked => AppError::Unauthorized,
AuthError::UserDisabled(s) => AppError::Forbidden(s),
AuthError::Validation(s) => AppError::Validation(s),
AuthError::Forbidden(s) => AppError::Forbidden(s),
AuthError::DbError(_) => AppError::Internal(err.to_string()),
AuthError::HashError(_) => AppError::Internal(err.to_string()),
AuthError::JwtError(_) => AppError::Unauthorized,
AuthError::VersionMismatch => AppError::VersionMismatch,
}
}
}
pub type AuthResult<T> = Result<T, AuthError>;
#[cfg(test)]
mod tests {
use super::*;
use erp_core::error::AppError;
#[test]
fn auth_error_invalid_credentials_maps_to_unauthorized() {
let app: AppError = AuthError::InvalidCredentials.into();
match app {
AppError::Unauthorized => {}
other => panic!("Expected Unauthorized, got {:?}", other),
}
}
#[test]
fn auth_error_token_expired_maps_to_unauthorized() {
let app: AppError = AuthError::TokenExpired.into();
match app {
AppError::Unauthorized => {}
other => panic!("Expected Unauthorized, got {:?}", other),
}
}
#[test]
fn auth_error_user_disabled_maps_to_forbidden() {
let app: AppError = AuthError::UserDisabled("已禁用".to_string()).into();
match app {
AppError::Forbidden(msg) => assert_eq!(msg, "已禁用"),
other => panic!("Expected Forbidden, got {:?}", other),
}
}
#[test]
fn auth_error_hash_error_maps_to_internal() {
let app: AppError = AuthError::HashError("argon2 failed".to_string()).into();
match app {
AppError::Internal(_) => {}
other => panic!("Expected Internal, got {:?}", other),
}
}
#[test]
fn auth_error_validation_maps_to_validation() {
let app: AppError = AuthError::Validation("用户名已存在".to_string()).into();
match app {
AppError::Validation(msg) => assert_eq!(msg, "用户名已存在"),
other => panic!("Expected Validation, got {:?}", other),
}
}
}

View File

@@ -0,0 +1,192 @@
use axum::Extension;
use axum::extract::{FromRef, State};
use axum::http::HeaderMap;
use axum::response::Json;
use validator::Validate;
use erp_core::error::AppError;
use erp_core::types::{ApiResponse, TenantContext};
use crate::auth_state::AuthState;
use crate::dto::{ChangePasswordReq, LoginReq, LoginResp, RefreshReq};
use crate::service::auth_service::{AuthService, JwtConfig, RequestInfo};
/// 从请求头中提取客户端信息。
fn extract_request_info(headers: &HeaderMap) -> RequestInfo {
let ip = headers
.get("x-forwarded-for")
.or_else(|| headers.get("x-real-ip"))
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string());
let user_agent = headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
RequestInfo { ip, user_agent }
}
#[utoipa::path(
post,
path = "/api/v1/auth/login",
request_body = LoginReq,
responses(
(status = 200, description = "登录成功", body = ApiResponse<LoginResp>),
(status = 400, description = "请求参数错误"),
(status = 401, description = "用户名或密码错误"),
),
tag = "认证"
)]
/// POST /api/v1/auth/login
///
/// Authenticates a user with username and password, returning access and refresh tokens.
///
/// During the bootstrap phase, the tenant_id is taken from `AuthState::default_tenant_id`.
/// In production, this will come from a tenant-resolution middleware.
pub async fn login<S>(
State(state): State<AuthState>,
headers: HeaderMap,
Json(req): Json<LoginReq>,
) -> Result<Json<ApiResponse<LoginResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let req_info = extract_request_info(&headers);
let tenant_id = state.default_tenant_id;
let jwt_config = JwtConfig {
secret: &state.jwt_secret,
access_ttl_secs: state.access_ttl_secs,
refresh_ttl_secs: state.refresh_ttl_secs,
};
let resp = AuthService::login(
tenant_id,
&req.username,
&req.password,
&state.db,
&jwt_config,
&state.event_bus,
Some(&req_info),
req.client_type.as_deref(),
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
post,
path = "/api/v1/auth/refresh",
request_body = RefreshReq,
responses(
(status = 200, description = "刷新成功", body = ApiResponse<LoginResp>),
(status = 401, description = "刷新令牌无效或已过期"),
),
tag = "认证"
)]
/// POST /api/v1/auth/refresh
///
/// Validates an existing refresh token, revokes it (rotation), and issues
/// a new access + refresh token pair.
pub async fn refresh<S>(
State(state): State<AuthState>,
Json(req): Json<RefreshReq>,
) -> Result<Json<ApiResponse<LoginResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
let jwt_config = JwtConfig {
secret: &state.jwt_secret,
access_ttl_secs: state.access_ttl_secs,
refresh_ttl_secs: state.refresh_ttl_secs,
};
let resp = AuthService::refresh(&req.refresh_token, &state.db, &jwt_config).await?;
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
post,
path = "/api/v1/auth/logout",
responses(
(status = 200, description = "已成功登出"),
(status = 401, description = "未授权"),
),
security(("bearer_auth" = [])),
tag = "认证"
)]
/// POST /api/v1/auth/logout
///
/// Revokes all refresh tokens for the authenticated user, effectively
/// logging them out on all devices.
pub async fn logout<S>(
State(state): State<AuthState>,
headers: HeaderMap,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
let req_info = extract_request_info(&headers);
AuthService::logout(ctx.user_id, ctx.tenant_id, &state.db, Some(&req_info)).await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("已成功登出".to_string()),
}))
}
#[utoipa::path(
post,
path = "/api/v1/auth/change-password",
request_body = ChangePasswordReq,
responses(
(status = 200, description = "密码修改成功,需重新登录"),
(status = 400, description = "当前密码不正确"),
(status = 401, description = "未授权"),
),
security(("bearer_auth" = [])),
tag = "认证"
)]
/// POST /api/v1/auth/change-password
///
/// 修改当前登录用户的密码。修改成功后所有已签发的 refresh token 将被吊销,
/// 用户需要在所有设备上重新登录。
pub async fn change_password<S>(
State(state): State<AuthState>,
headers: HeaderMap,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<ChangePasswordReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let req_info = extract_request_info(&headers);
AuthService::change_password(
ctx.user_id,
ctx.tenant_id,
&req.current_password,
&req.new_password,
&state.db,
Some(&req_info),
)
.await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("密码修改成功,请重新登录".to_string()),
}))
}

View File

@@ -0,0 +1,5 @@
pub mod auth_handler;
pub mod org_handler;
pub mod role_handler;
pub mod user_handler;
pub mod wechat_handler;

View File

@@ -0,0 +1,460 @@
use axum::Extension;
use axum::extract::{FromRef, Path, State};
use axum::response::Json;
use validator::Validate;
use erp_core::error::AppError;
use erp_core::types::{ApiResponse, TenantContext};
use uuid::Uuid;
use crate::auth_state::AuthState;
use crate::dto::{
CreateDepartmentReq, CreateOrganizationReq, CreatePositionReq, DepartmentResp,
OrganizationResp, PositionResp, UpdateDepartmentReq, UpdateOrganizationReq, UpdatePositionReq,
};
use crate::service::dept_service::DeptService;
use crate::service::org_service::OrgService;
use crate::service::position_service::PositionService;
use erp_core::rbac::require_permission;
// --- Organization handlers ---
#[utoipa::path(
get,
path = "/api/v1/organizations",
responses(
(status = 200, description = "成功", body = ApiResponse<Vec<OrganizationResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// GET /api/v1/organizations
///
/// List all organizations within the current tenant as a nested tree.
/// Requires the `organization.list` permission.
pub async fn list_organizations<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<OrganizationResp>>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "organization.list")?;
let tree = OrgService::get_tree(ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(tree)))
}
#[utoipa::path(
post,
path = "/api/v1/organizations",
request_body = CreateOrganizationReq,
responses(
(status = 200, description = "创建成功", body = ApiResponse<OrganizationResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// POST /api/v1/organizations
///
/// Create a new organization within the current tenant.
/// Requires the `organization.create` permission.
pub async fn create_organization<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateOrganizationReq>,
) -> Result<Json<ApiResponse<OrganizationResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "organization.create")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let org = OrgService::create(
ctx.tenant_id,
ctx.user_id,
&req,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(org)))
}
#[utoipa::path(
put,
path = "/api/v1/organizations/{id}",
params(("id" = Uuid, Path, description = "组织ID")),
request_body = UpdateOrganizationReq,
responses(
(status = 200, description = "更新成功", body = ApiResponse<OrganizationResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "组织不存在"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// PUT /api/v1/organizations/{id}
///
/// Update editable organization fields (name, code, sort_order).
/// Requires the `organization.update` permission.
pub async fn update_organization<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateOrganizationReq>,
) -> Result<Json<ApiResponse<OrganizationResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "organization.update")?;
let org = OrgService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
Ok(Json(ApiResponse::ok(org)))
}
#[utoipa::path(
delete,
path = "/api/v1/organizations/{id}",
params(("id" = Uuid, Path, description = "组织ID")),
responses(
(status = 200, description = "组织已删除"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "组织不存在"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// DELETE /api/v1/organizations/{id}
///
/// Soft-delete an organization by ID.
/// Requires the `organization.delete` permission.
pub async fn delete_organization<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "organization.delete")?;
OrgService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("组织已删除".to_string()),
}))
}
// --- Department handlers ---
#[utoipa::path(
get,
path = "/api/v1/organizations/{org_id}/departments",
params(("org_id" = Uuid, Path, description = "组织ID")),
responses(
(status = 200, description = "成功", body = ApiResponse<Vec<DepartmentResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// GET /api/v1/organizations/{org_id}/departments
///
/// List all departments for an organization as a nested tree.
/// Requires the `department.list` permission.
pub async fn list_departments<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(org_id): Path<Uuid>,
) -> Result<Json<ApiResponse<Vec<DepartmentResp>>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "department.list")?;
let tree = DeptService::list_tree(org_id, ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(tree)))
}
#[utoipa::path(
post,
path = "/api/v1/organizations/{org_id}/departments",
params(("org_id" = Uuid, Path, description = "组织ID")),
request_body = CreateDepartmentReq,
responses(
(status = 200, description = "创建成功", body = ApiResponse<DepartmentResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// POST /api/v1/organizations/{org_id}/departments
///
/// Create a new department under the specified organization.
/// Requires the `department.create` permission.
pub async fn create_department<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(org_id): Path<Uuid>,
Json(req): Json<CreateDepartmentReq>,
) -> Result<Json<ApiResponse<DepartmentResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "department.create")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let dept = DeptService::create(
org_id,
ctx.tenant_id,
ctx.user_id,
&req,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(dept)))
}
#[utoipa::path(
put,
path = "/api/v1/departments/{id}",
params(("id" = Uuid, Path, description = "部门ID")),
request_body = UpdateDepartmentReq,
responses(
(status = 200, description = "更新成功", body = ApiResponse<DepartmentResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "部门不存在"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// PUT /api/v1/departments/{id}
///
/// Update editable department fields (name, code, manager_id, sort_order).
/// Requires the `department.update` permission.
pub async fn update_department<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateDepartmentReq>,
) -> Result<Json<ApiResponse<DepartmentResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "department.update")?;
let dept = DeptService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
Ok(Json(ApiResponse::ok(dept)))
}
#[utoipa::path(
delete,
path = "/api/v1/departments/{id}",
params(("id" = Uuid, Path, description = "部门ID")),
responses(
(status = 200, description = "部门已删除"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "部门不存在"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// DELETE /api/v1/departments/{id}
///
/// Soft-delete a department by ID.
/// Requires the `department.delete` permission.
pub async fn delete_department<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "department.delete")?;
DeptService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("部门已删除".to_string()),
}))
}
// --- Position handlers ---
#[utoipa::path(
get,
path = "/api/v1/departments/{dept_id}/positions",
params(("dept_id" = Uuid, Path, description = "部门ID")),
responses(
(status = 200, description = "成功", body = ApiResponse<Vec<PositionResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// GET /api/v1/departments/{dept_id}/positions
///
/// List all positions for a department.
/// Requires the `position.list` permission.
pub async fn list_positions<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(dept_id): Path<Uuid>,
) -> Result<Json<ApiResponse<Vec<PositionResp>>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "position.list")?;
let positions = PositionService::list(dept_id, ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(positions)))
}
#[utoipa::path(
post,
path = "/api/v1/departments/{dept_id}/positions",
params(("dept_id" = Uuid, Path, description = "部门ID")),
request_body = CreatePositionReq,
responses(
(status = 200, description = "创建成功", body = ApiResponse<PositionResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// POST /api/v1/departments/{dept_id}/positions
///
/// Create a new position under the specified department.
/// Requires the `position.create` permission.
pub async fn create_position<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(dept_id): Path<Uuid>,
Json(req): Json<CreatePositionReq>,
) -> Result<Json<ApiResponse<PositionResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "position.create")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let pos = PositionService::create(
dept_id,
ctx.tenant_id,
ctx.user_id,
&req,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(pos)))
}
#[utoipa::path(
put,
path = "/api/v1/positions/{id}",
params(("id" = Uuid, Path, description = "岗位ID")),
request_body = UpdatePositionReq,
responses(
(status = 200, description = "更新成功", body = ApiResponse<PositionResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "岗位不存在"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// PUT /api/v1/positions/{id}
///
/// Update editable position fields (name, code, level, sort_order).
/// Requires the `position.update` permission.
pub async fn update_position<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<UpdatePositionReq>,
) -> Result<Json<ApiResponse<PositionResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "position.update")?;
let pos = PositionService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
Ok(Json(ApiResponse::ok(pos)))
}
#[utoipa::path(
delete,
path = "/api/v1/positions/{id}",
params(("id" = Uuid, Path, description = "岗位ID")),
responses(
(status = 200, description = "岗位已删除"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "岗位不存在"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// DELETE /api/v1/positions/{id}
///
/// Soft-delete a position by ID.
/// Requires the `position.delete` permission.
pub async fn delete_position<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "position.delete")?;
PositionService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("岗位已删除".to_string()),
}))
}

View File

@@ -0,0 +1,320 @@
use axum::Extension;
use axum::extract::{FromRef, Path, Query, State};
use axum::response::Json;
use validator::Validate;
use erp_core::error::AppError;
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
use uuid::Uuid;
use crate::auth_state::AuthState;
use crate::dto::{AssignPermissionsReq, CreateRoleReq, PermissionResp, RoleResp, UpdateRoleReq};
use crate::service::permission_service::PermissionService;
use crate::service::role_service::RoleService;
use erp_core::rbac::require_permission;
#[utoipa::path(
get,
path = "/api/v1/roles",
params(Pagination),
responses(
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<RoleResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "角色管理"
)]
/// GET /api/v1/roles
///
/// List roles within the current tenant with pagination.
/// Requires the `role.list` permission.
pub async fn list_roles<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Query(pagination): Query<Pagination>,
) -> Result<Json<ApiResponse<PaginatedResponse<RoleResp>>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "role.list")?;
let (roles, total) = RoleService::list(ctx.tenant_id, &pagination, &state.db).await?;
let page = pagination.page.unwrap_or(1);
let page_size = pagination.limit();
let total_pages = total.div_ceil(page_size);
Ok(Json(ApiResponse::ok(PaginatedResponse {
data: roles,
total,
page,
page_size,
total_pages,
})))
}
#[utoipa::path(
post,
path = "/api/v1/roles",
request_body = CreateRoleReq,
responses(
(status = 200, description = "创建成功", body = ApiResponse<RoleResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "角色管理"
)]
/// POST /api/v1/roles
///
/// Create a new role within the current tenant.
/// Requires the `role.create` permission.
pub async fn create_role<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateRoleReq>,
) -> Result<Json<ApiResponse<RoleResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "role.create")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let role = RoleService::create(
ctx.tenant_id,
ctx.user_id,
&req.name,
&req.code,
&req.description,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(role)))
}
#[utoipa::path(
get,
path = "/api/v1/roles/{id}",
params(("id" = Uuid, Path, description = "角色ID")),
responses(
(status = 200, description = "成功", body = ApiResponse<RoleResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "角色不存在"),
),
security(("bearer_auth" = [])),
tag = "角色管理"
)]
/// GET /api/v1/roles/:id
///
/// Fetch a single role by ID within the current tenant.
/// Requires the `role.read` permission.
pub async fn get_role<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<RoleResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "role.read")?;
let role = RoleService::get_by_id(id, ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(role)))
}
#[utoipa::path(
put,
path = "/api/v1/roles/{id}",
params(("id" = Uuid, Path, description = "角色ID")),
request_body = UpdateRoleReq,
responses(
(status = 200, description = "更新成功", body = ApiResponse<RoleResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "角色不存在"),
),
security(("bearer_auth" = [])),
tag = "角色管理"
)]
/// PUT /api/v1/roles/:id
///
/// Update editable role fields (name, description).
/// Requires the `role.update` permission.
pub async fn update_role<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateRoleReq>,
) -> Result<Json<ApiResponse<RoleResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "role.update")?;
let role = RoleService::update(
id,
ctx.tenant_id,
ctx.user_id,
&req.name,
&req.description,
req.version,
&state.db,
)
.await?;
Ok(Json(ApiResponse::ok(role)))
}
#[utoipa::path(
delete,
path = "/api/v1/roles/{id}",
params(("id" = Uuid, Path, description = "角色ID")),
responses(
(status = 200, description = "角色已删除"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "角色不存在"),
),
security(("bearer_auth" = [])),
tag = "角色管理"
)]
/// DELETE /api/v1/roles/:id
///
/// Soft-delete a role by ID within the current tenant.
/// System roles cannot be deleted.
/// Requires the `role.delete` permission.
pub async fn delete_role<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "role.delete")?;
RoleService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("角色已删除".to_string()),
}))
}
#[utoipa::path(
post,
path = "/api/v1/roles/{id}/permissions",
params(("id" = Uuid, Path, description = "角色ID")),
request_body = AssignPermissionsReq,
responses(
(status = 200, description = "权限分配成功"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "角色不存在"),
),
security(("bearer_auth" = [])),
tag = "角色管理"
)]
/// POST /api/v1/roles/:id/permissions
///
/// Replace all permission assignments for a role.
/// Requires the `role.update` permission.
pub async fn assign_permissions<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<AssignPermissionsReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "role.update")?;
RoleService::assign_permissions(
id,
ctx.tenant_id,
ctx.user_id,
&req.permission_ids,
&state.db,
)
.await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("权限分配成功".to_string()),
}))
}
#[utoipa::path(
get,
path = "/api/v1/roles/{id}/permissions",
params(("id" = Uuid, Path, description = "角色ID")),
responses(
(status = 200, description = "成功", body = ApiResponse<Vec<PermissionResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "角色不存在"),
),
security(("bearer_auth" = [])),
tag = "角色管理"
)]
/// GET /api/v1/roles/:id/permissions
///
/// Fetch all permissions assigned to a role.
/// Requires the `role.read` permission.
pub async fn get_role_permissions<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<Vec<PermissionResp>>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "role.read")?;
let perms = RoleService::get_role_permissions(id, ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(perms)))
}
#[utoipa::path(
get,
path = "/api/v1/permissions",
responses(
(status = 200, description = "成功", body = ApiResponse<Vec<PermissionResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "权限管理"
)]
/// GET /api/v1/permissions
///
/// List all permissions within the current tenant.
/// Requires the `permission.list` permission.
pub async fn list_permissions<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<PermissionResp>>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "permission.list")?;
let perms = PermissionService::list(ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(perms)))
}

View File

@@ -0,0 +1,322 @@
use axum::Extension;
use axum::extract::{FromRef, Path, Query, State};
use axum::response::Json;
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use validator::Validate;
use erp_core::error::AppError;
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
use uuid::Uuid;
use crate::auth_state::AuthState;
use crate::dto::{CreateUserReq, ResetPasswordReq, RoleResp, UpdateUserReq, UserResp};
use crate::service::user_service::UserService;
use erp_core::rbac::require_permission;
/// Query parameters for user list endpoint.
#[derive(Debug, Deserialize, IntoParams)]
pub struct UserListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
/// Optional search term — filters by username (case-insensitive contains).
pub search: Option<String>,
}
#[utoipa::path(
get,
path = "/api/v1/users",
params(UserListParams),
responses(
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<UserResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "用户管理"
)]
/// GET /api/v1/users
///
/// List users within the current tenant with pagination and optional search.
/// Requires the `user.list` permission.
pub async fn list_users<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<UserListParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<UserResp>>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "user.list")?;
let pagination = Pagination {
page: params.page,
page_size: params.page_size,
};
let (users, total) = UserService::list(
ctx.tenant_id,
&pagination,
params.search.as_deref(),
&state.db,
)
.await?;
let page = pagination.page.unwrap_or(1);
let page_size = pagination.limit();
let total_pages = total.div_ceil(page_size);
Ok(Json(ApiResponse::ok(PaginatedResponse {
data: users,
total,
page,
page_size,
total_pages,
})))
}
#[utoipa::path(
post,
path = "/api/v1/users",
request_body = CreateUserReq,
responses(
(status = 200, description = "创建成功", body = ApiResponse<UserResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "用户管理"
)]
/// POST /api/v1/users
///
/// Create a new user within the current tenant.
/// Requires the `user.create` permission.
pub async fn create_user<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Json(mut req): Json<CreateUserReq>,
) -> Result<Json<ApiResponse<UserResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "user.create")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
req.sanitize();
let user = UserService::create(
ctx.tenant_id,
ctx.user_id,
&req,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(user)))
}
#[utoipa::path(
get,
path = "/api/v1/users/{id}",
params(("id" = Uuid, Path, description = "用户ID")),
responses(
(status = 200, description = "成功", body = ApiResponse<UserResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "用户不存在"),
),
security(("bearer_auth" = [])),
tag = "用户管理"
)]
/// GET /api/v1/users/:id
///
/// Fetch a single user by ID within the current tenant.
/// Requires the `user.read` permission.
pub async fn get_user<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<UserResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "user.read")?;
let user = UserService::get_by_id(id, ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(user)))
}
#[utoipa::path(
put,
path = "/api/v1/users/{id}",
params(("id" = Uuid, Path, description = "用户ID")),
request_body = UpdateUserReq,
responses(
(status = 200, description = "更新成功", body = ApiResponse<UserResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "用户不存在"),
),
security(("bearer_auth" = [])),
tag = "用户管理"
)]
/// PUT /api/v1/users/:id
///
/// Update editable user fields.
/// Requires the `user.update` permission.
pub async fn update_user<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(mut req): Json<UpdateUserReq>,
) -> Result<Json<ApiResponse<UserResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "user.update")?;
req.sanitize();
let user = UserService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
Ok(Json(ApiResponse::ok(user)))
}
#[utoipa::path(
delete,
path = "/api/v1/users/{id}",
params(("id" = Uuid, Path, description = "用户ID")),
responses(
(status = 200, description = "用户已删除"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "用户不存在"),
),
security(("bearer_auth" = [])),
tag = "用户管理"
)]
/// DELETE /api/v1/users/:id
///
/// Soft-delete a user by ID within the current tenant.
/// Requires the `user.delete` permission.
pub async fn delete_user<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "user.delete")?;
UserService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("用户已删除".to_string()),
}))
}
/// Assign roles request body.
#[derive(Debug, Deserialize, ToSchema)]
pub struct AssignRolesReq {
pub role_ids: Vec<Uuid>,
}
/// Assign roles response.
#[derive(Debug, Serialize, ToSchema)]
pub struct AssignRolesResp {
pub roles: Vec<RoleResp>,
}
#[utoipa::path(
post,
path = "/api/v1/users/{id}/roles",
params(("id" = Uuid, Path, description = "用户ID")),
request_body = AssignRolesReq,
responses(
(status = 200, description = "角色分配成功", body = ApiResponse<AssignRolesResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "用户不存在"),
),
security(("bearer_auth" = [])),
tag = "用户管理"
)]
/// POST /api/v1/users/:id/roles
///
/// Replace all role assignments for a user within the current tenant.
/// Requires the `user.update` permission.
pub async fn assign_roles<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<AssignRolesReq>,
) -> Result<Json<ApiResponse<AssignRolesResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "user.update")?;
let roles =
UserService::assign_roles(id, ctx.tenant_id, ctx.user_id, &req.role_ids, &state.db).await?;
Ok(Json(ApiResponse::ok(AssignRolesResp { roles })))
}
#[utoipa::path(
post,
path = "/api/v1/users/{id}/reset-password",
params(("id" = Uuid, Path, description = "用户ID")),
request_body = ResetPasswordReq,
responses(
(status = 200, description = "密码重置成功"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "用户不存在"),
),
security(("bearer_auth" = [])),
tag = "用户管理"
)]
/// POST /api/v1/users/{id}/reset-password
///
/// 管理员重置指定用户密码。需要 `user.reset-password` 权限。
pub async fn reset_password<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<ResetPasswordReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "user.reset-password")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
UserService::reset_password(
id,
ctx.tenant_id,
ctx.user_id,
&req.new_password,
req.version,
&state.db,
)
.await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("密码重置成功".to_string()),
}))
}

View File

@@ -0,0 +1,86 @@
use axum::extract::{FromRef, State};
use axum::response::Json;
use validator::Validate;
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,
{
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
tracing::info!(
code = %req.code,
tenant_id = %state.default_tenant_id,
has_appid = !state.wechat_appid.is_empty(),
has_secret = !state.wechat_secret.is_empty(),
"微信登录请求"
);
// TODO: 多租户微信登录需要设计租户解析策略(如 per-appid 映射或登录后选择租户)
let tenant_id = state.default_tenant_id;
let resp = WechatService::login(&state, tenant_id, &req.code).await?;
tracing::info!(
bound = resp.bound,
has_token = resp.token.is_some(),
"微信登录结果"
);
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,
{
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
// TODO: 多租户微信登录需要设计租户解析策略
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)))
}

View File

@@ -0,0 +1,11 @@
pub mod auth_state;
pub mod dto;
pub mod entity;
pub mod error;
pub mod handler;
pub mod middleware;
pub mod module;
pub mod service;
pub use auth_state::AuthState;
pub use module::AuthModule;

View File

@@ -0,0 +1,273 @@
use axum::body::Body;
use axum::http::Request;
use axum::middleware::Next;
use axum::response::Response;
use dashmap::DashMap;
use erp_core::error::AppError;
use erp_core::request_info::REQUEST_INFO;
use erp_core::request_info::RequestInfo;
use erp_core::types::{DataScope, TenantContext};
use crate::service::token_service::TokenService;
type DeptIds = Vec<uuid::Uuid>;
type DataScopes = std::collections::HashMap<String, DataScope>;
type ScopeCacheEntry = (DeptIds, DataScopes, std::time::Instant);
/// 用户权限数据缓存user_id -> (department_ids, data_scopes, cached_at)
/// DashMap 分片并发,读写无锁竞争
static USER_SCOPE_CACHE: std::sync::LazyLock<DashMap<uuid::Uuid, ScopeCacheEntry>> =
std::sync::LazyLock::new(DashMap::new);
/// Access Token 吊销黑名单token_hash -> 过期时间戳)
/// key = SHA-256(token) 前 16 字符value = token 的 exp 时间戳
/// 惰性清理:检查时自动移除过期条目
static TOKEN_BLACKLIST: std::sync::LazyLock<DashMap<String, i64>> =
std::sync::LazyLock::new(DashMap::new);
const SCOPE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60);
/// 吊销单个 access token直到其自然过期
pub fn revoke_access_token(token: &str, exp: i64) {
let hash = token_hash(token);
TOKEN_BLACKLIST.insert(hash, exp);
}
/// 吊销用户所有 token清除权限缓存强制下次请求重新认证
pub fn revoke_all_user_tokens(user_id: uuid::Uuid) {
USER_SCOPE_CACHE.remove(&user_id);
}
/// 检查 token 是否已被吊销
fn is_token_revoked(token: &str, _exp: i64) -> bool {
let now = chrono::Utc::now().timestamp();
// 惰性清理过期条目
if TOKEN_BLACKLIST.len() > 10_000 {
TOKEN_BLACKLIST.retain(|_, exp_ts| *exp_ts > now);
}
let hash = token_hash(token);
match TOKEN_BLACKLIST.get(&hash) {
Some(exp_ts) => {
if *exp_ts <= now {
drop(exp_ts);
TOKEN_BLACKLIST.remove(&hash);
false
} else {
true
}
}
None => false,
}
}
fn token_hash(token: &str) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
token.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
/// JWT authentication middleware function.
///
/// Extracts the `Bearer` token from the `Authorization` header, validates it
/// using `TokenService::decode_token`, and injects a `TenantContext` into the
/// request extensions so downstream handlers can access tenant/user identity.
///
/// 同时提取请求的 IP 地址和 User-Agent通过 task_local 传递给审计服务,
/// 使所有审计日志自动记录来源信息。
///
/// The `jwt_secret` parameter is passed explicitly by the server crate at
/// middleware construction time, avoiding any circular dependency between
/// erp-auth and erp-server.
///
/// When `db` is provided, the middleware queries `user_departments` to populate
/// `department_ids` in the `TenantContext`. If `db` is `None` or the query fails,
/// `department_ids` defaults to an empty list (equivalent to "all" data scope).
///
/// # Errors
///
/// Returns `AppError::Unauthorized` if:
/// - The `Authorization` header is missing
/// - The header value does not start with `"Bearer "`
/// - The token cannot be decoded or has expired
/// - The token type is not "access"
pub async fn jwt_auth_middleware_fn(
jwt_secret: String,
db: Option<sea_orm::DatabaseConnection>,
req: Request<Body>,
next: Next,
) -> Result<Response, AppError> {
// 优先从 Authorization 头提取 token
// 回退到 URL query parameter ?token=xxxSSE/EventSource 无法设置自定义头)
let token = req
.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "))
.map(String::from)
.or_else(|| {
req.uri().query().and_then(|q| {
q.split('&')
.find_map(|pair| pair.strip_prefix("token="))
.map(String::from)
})
})
.ok_or(AppError::Unauthorized)?;
let claims =
TokenService::decode_token(&token, &jwt_secret).map_err(|_| AppError::Unauthorized)?;
// 检查 token 是否已被吊销(密码修改/管理员强制下线)
if is_token_revoked(&token, claims.exp) {
return Err(AppError::Unauthorized);
}
// Verify this is an access token, not a refresh token
if claims.token_type != "access" {
return Err(AppError::Unauthorized);
}
// 查询用户所属部门 ID 列表 + 权限数据范围(带 60 秒缓存)
let cached = USER_SCOPE_CACHE.get(&claims.sub).and_then(|entry| {
let (_, _, at) = entry.value();
if at.elapsed() < SCOPE_CACHE_TTL {
let (depts, scopes, _) = entry.value();
Some((depts.clone(), scopes.clone()))
} else {
drop(entry);
USER_SCOPE_CACHE.remove(&claims.sub);
None
}
});
let (department_ids, permission_data_scopes) = match cached {
Some(hit) => hit,
None => fetch_and_cache_scopes(claims.sub, claims.tid, &db).await,
};
// 提取请求来源信息IP + User-Agent用于审计日志
let request_info = RequestInfo::from_headers(req.headers());
let ctx = TenantContext {
tenant_id: claims.tid,
user_id: claims.sub,
roles: claims.roles,
permissions: claims.permissions,
department_ids,
permission_data_scopes,
};
// Reconstruct the request with the TenantContext injected into extensions.
// We cannot borrow `req` mutably after reading headers, so we rebuild.
let (parts, body) = req.into_parts();
let mut req = Request::from_parts(parts, body);
req.extensions_mut().insert(ctx);
// 在 task_local scope 中运行后续处理,审计服务可自动读取请求信息
Ok(REQUEST_INFO.scope(request_info, next.run(req)).await)
}
/// 查询用户所属的所有部门 ID通过 user_departments 关联表)
async fn fetch_user_department_ids(
user_id: uuid::Uuid,
tenant_id: uuid::Uuid,
db: &sea_orm::DatabaseConnection,
) -> Vec<uuid::Uuid> {
use crate::entity::user_department;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
user_department::Entity::find()
.filter(user_department::Column::UserId.eq(user_id))
.filter(user_department::Column::TenantId.eq(tenant_id))
.filter(user_department::Column::DeletedAt.is_null())
.all(db)
.await
.map(|rows| rows.into_iter().map(|r| r.department_id).collect())
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "查询用户部门列表失败,默认为空");
vec![]
})
}
/// 查询用户每个权限的数据范围(从 role_permissions 表)
async fn fetch_permission_data_scopes(
user_id: uuid::Uuid,
tenant_id: uuid::Uuid,
db: &sea_orm::DatabaseConnection,
) -> std::collections::HashMap<String, DataScope> {
use sea_orm::ConnectionTrait;
let sql = r#"
SELECT p.code, MIN(
CASE rp.data_scope
WHEN 'all' THEN 0
WHEN 'department_tree' THEN 1
WHEN 'department' THEN 2
WHEN 'self' THEN 3
ELSE 0
END
) AS scope_rank,
MIN(rp.data_scope) AS data_scope
FROM user_roles ur
JOIN role_permissions rp ON ur.role_id = rp.role_id AND ur.tenant_id = rp.tenant_id
JOIN permissions p ON rp.permission_id = p.id
WHERE ur.user_id = $1
AND ur.tenant_id = $2
AND ur.deleted_at IS NULL
AND rp.deleted_at IS NULL
GROUP BY p.code
"#;
let stmt = sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[user_id.into(), tenant_id.into()],
);
match db.query_all(stmt).await {
Ok(rows) => {
let mut scopes = std::collections::HashMap::new();
for row in rows {
if let (Ok(code), Ok(scope)) = (
row.try_get_by_index::<String>(0),
row.try_get_by_index::<String>(2),
) {
scopes.insert(code, DataScope::parse_scope(&scope));
}
}
scopes
}
Err(e) => {
tracing::warn!(error = %e, "查询权限数据范围失败,默认全部 All");
std::collections::HashMap::new()
}
}
}
/// 从 DB 查询部门 + 权限范围,并写入缓存
async fn fetch_and_cache_scopes(
user_id: uuid::Uuid,
tenant_id: uuid::Uuid,
db: &Option<sea_orm::DatabaseConnection>,
) -> (
Vec<uuid::Uuid>,
std::collections::HashMap<String, DataScope>,
) {
let depts = match db {
Some(conn) => fetch_user_department_ids(user_id, tenant_id, conn).await,
None => vec![],
};
let scopes = match db {
Some(conn) => fetch_permission_data_scopes(user_id, tenant_id, conn).await,
None => std::collections::HashMap::new(),
};
USER_SCOPE_CACHE.insert(
user_id,
(depts.clone(), scopes.clone(), std::time::Instant::now()),
);
// 惰性淘汰过期条目,防止 DashMap 无限增长
if USER_SCOPE_CACHE.len() > 500 {
let now = std::time::Instant::now();
USER_SCOPE_CACHE.retain(|_, (_, _, at)| now.duration_since(*at) < SCOPE_CACHE_TTL);
}
(depts, scopes)
}

View File

@@ -0,0 +1,4 @@
pub mod jwt_auth;
pub use erp_core::rbac::{require_any_permission, require_permission, require_role};
pub use jwt_auth::{jwt_auth_middleware_fn, revoke_access_token, revoke_all_user_tokens};

View File

@@ -0,0 +1,372 @@
use axum::Router;
use uuid::Uuid;
use erp_core::error::AppResult;
use erp_core::events::EventBus;
use erp_core::module::{ErpModule, PermissionDescriptor};
use crate::handler::{auth_handler, org_handler, role_handler, user_handler, wechat_handler};
/// Auth module implementing the `ErpModule` trait.
///
/// Manages identity, authentication, and user CRUD within the ERP platform.
/// This module has no dependencies on other business modules.
pub struct AuthModule;
impl AuthModule {
pub fn new() -> Self {
Self
}
/// Build public (unauthenticated) routes for the auth module.
///
/// These routes do not require a valid JWT token.
/// The caller wraps this into whatever state type the application uses.
pub fn public_routes<S>() -> Router<S>
where
crate::auth_state::AuthState: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
.route("/auth/login", axum::routing::post(auth_handler::login))
.route(
"/auth/wechat/login",
axum::routing::post(wechat_handler::wechat_login),
)
.route(
"/auth/wechat/bind-phone",
axum::routing::post(wechat_handler::wechat_bind_phone),
)
}
/// Refresh token routes — public but with higher rate limit (30/min vs 5/min for login).
pub fn refresh_routes<S>() -> Router<S>
where
crate::auth_state::AuthState: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new().route("/auth/refresh", axum::routing::post(auth_handler::refresh))
}
/// Build protected (authenticated) routes for the auth module.
///
/// These routes require a valid JWT token, verified by the middleware layer.
/// The caller wraps this into whatever state type the application uses.
pub fn protected_routes<S>() -> Router<S>
where
crate::auth_state::AuthState: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
.route("/auth/logout", axum::routing::post(auth_handler::logout))
.route(
"/auth/change-password",
axum::routing::post(auth_handler::change_password),
)
.route(
"/users",
axum::routing::get(user_handler::list_users).post(user_handler::create_user),
)
.route(
"/users/{id}",
axum::routing::get(user_handler::get_user)
.put(user_handler::update_user)
.delete(user_handler::delete_user),
)
.route(
"/users/{id}/roles",
axum::routing::post(user_handler::assign_roles),
)
.route(
"/users/{id}/reset-password",
axum::routing::post(user_handler::reset_password),
)
.route(
"/roles",
axum::routing::get(role_handler::list_roles).post(role_handler::create_role),
)
// 精确匹配 /roles/permissions必须在 /roles/{id} 之前注册
.route(
"/roles/permissions",
axum::routing::get(role_handler::list_permissions),
)
.route(
"/roles/{id}",
axum::routing::get(role_handler::get_role)
.put(role_handler::update_role)
.delete(role_handler::delete_role),
)
.route(
"/roles/{id}/permissions",
axum::routing::get(role_handler::get_role_permissions)
.post(role_handler::assign_permissions),
)
.route(
"/permissions",
axum::routing::get(role_handler::list_permissions),
)
// Organization routes
.route(
"/organizations",
axum::routing::get(org_handler::list_organizations)
.post(org_handler::create_organization),
)
.route(
"/organizations/{id}",
axum::routing::put(org_handler::update_organization)
.delete(org_handler::delete_organization),
)
// Department routes (nested under organization)
.route(
"/organizations/{org_id}/departments",
axum::routing::get(org_handler::list_departments)
.post(org_handler::create_department),
)
.route(
"/departments/{id}",
axum::routing::put(org_handler::update_department)
.delete(org_handler::delete_department),
)
// Position routes (nested under department)
.route(
"/departments/{dept_id}/positions",
axum::routing::get(org_handler::list_positions).post(org_handler::create_position),
)
.route(
"/positions/{id}",
axum::routing::put(org_handler::update_position)
.delete(org_handler::delete_position),
)
}
}
impl Default for AuthModule {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl ErpModule for AuthModule {
fn name(&self) -> &str {
"auth"
}
fn version(&self) -> &str {
env!("CARGO_PKG_VERSION")
}
fn dependencies(&self) -> Vec<&str> {
// Auth is a foundational module with no business-module dependencies.
vec![]
}
fn register_event_handlers(&self, _bus: &EventBus) {
// Auth 模块暂无跨模块事件订阅需求
}
async fn on_tenant_created(
&self,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
_event_bus: &EventBus,
) -> AppResult<()> {
let password = std::env::var("ERP__SUPER_ADMIN_PASSWORD").map_err(|_| {
tracing::error!("环境变量 ERP__SUPER_ADMIN_PASSWORD 未设置,无法初始化租户认证");
erp_core::error::AppError::Internal("ERP__SUPER_ADMIN_PASSWORD 未设置".to_string())
})?;
crate::service::seed::seed_tenant_auth(db, tenant_id, &password)
.await
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
tracing::info!(tenant_id = %tenant_id, "Tenant auth initialized");
Ok(())
}
async fn on_tenant_deleted(
&self,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AppResult<()> {
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
let now = Utc::now();
// 软删除该租户下所有用户
let users = crate::entity::user::Entity::find()
.filter(crate::entity::user::Column::TenantId.eq(tenant_id))
.filter(crate::entity::user::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
for user_model in users {
let current_version = user_model.version;
let active: crate::entity::user::ActiveModel = user_model.into();
let mut to_update: crate::entity::user::ActiveModel = active;
to_update.deleted_at = Set(Some(now));
to_update.updated_at = Set(now);
to_update.version = Set(current_version + 1);
let _ = to_update
.update(db)
.await
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
}
tracing::info!(tenant_id = %tenant_id, "Tenant users soft-deleted");
Ok(())
}
fn permissions(&self) -> Vec<PermissionDescriptor> {
vec![
PermissionDescriptor {
code: "user.list".into(),
name: "查看用户列表".into(),
description: "查看用户列表".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "user.create".into(),
name: "创建用户".into(),
description: "创建新用户".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "user.read".into(),
name: "查看用户详情".into(),
description: "查看用户信息".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "user.update".into(),
name: "编辑用户".into(),
description: "编辑用户信息".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "user.delete".into(),
name: "删除用户".into(),
description: "软删除用户".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "user.reset-password".into(),
name: "重置用户密码".into(),
description: "管理员重置指定用户密码".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "role.list".into(),
name: "查看角色列表".into(),
description: "查看角色列表".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "role.create".into(),
name: "创建角色".into(),
description: "创建新角色".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "role.read".into(),
name: "查看角色详情".into(),
description: "查看角色信息".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "role.update".into(),
name: "编辑角色".into(),
description: "编辑角色".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "role.delete".into(),
name: "删除角色".into(),
description: "删除角色".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "permission.list".into(),
name: "查看权限".into(),
description: "查看权限列表".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "organization.list".into(),
name: "查看组织列表".into(),
description: "查看组织列表".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "organization.create".into(),
name: "创建组织".into(),
description: "创建组织".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "organization.update".into(),
name: "编辑组织".into(),
description: "编辑组织".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "organization.delete".into(),
name: "删除组织".into(),
description: "删除组织".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "department.list".into(),
name: "查看部门列表".into(),
description: "查看部门列表".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "department.create".into(),
name: "创建部门".into(),
description: "创建部门".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "department.update".into(),
name: "编辑部门".into(),
description: "编辑部门".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "department.delete".into(),
name: "删除部门".into(),
description: "删除部门".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "position.list".into(),
name: "查看岗位列表".into(),
description: "查看岗位列表".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "position.create".into(),
name: "创建岗位".into(),
description: "创建岗位".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "position.update".into(),
name: "编辑岗位".into(),
description: "编辑岗位".into(),
module: "auth".into(),
},
PermissionDescriptor {
code: "position.delete".into(),
name: "删除岗位".into(),
description: "删除岗位".into(),
module: "auth".into(),
},
]
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@@ -0,0 +1,414 @@
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
use crate::dto::{LoginResp, RoleResp, UserResp};
use crate::entity::{role, user, user_credential, user_role};
use crate::error::AuthError;
use crate::middleware::revoke_all_user_tokens as revoke_access_token_cache;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::events::EventBus;
use crate::error::AuthResult;
use super::password;
use super::token_service::TokenService;
/// 请求来源信息,用于审计日志记录。
pub struct RequestInfo {
pub ip: Option<String>,
pub user_agent: Option<String>,
}
/// JWT configuration needed for token signing.
pub struct JwtConfig<'a> {
pub secret: &'a str,
pub access_ttl_secs: i64,
pub refresh_ttl_secs: i64,
}
/// Authentication service handling login, token refresh, and logout.
pub struct AuthService;
impl AuthService {
/// Authenticate a user and issue access + refresh tokens.
///
/// Steps:
/// 1. Look up user by tenant + username (soft-delete aware)
/// 2. Verify user status is "active"
/// 3. Fetch the stored password credential
/// 4. Verify password hash
/// 5. Collect roles and permissions
/// 6. Sign JWT tokens
/// 7. Update last_login_at
/// 8. Publish login event
#[allow(clippy::too_many_arguments)]
pub async fn login(
tenant_id: Uuid,
username: &str,
password_plain: &str,
db: &sea_orm::DatabaseConnection,
jwt: &JwtConfig<'_>,
event_bus: &EventBus,
req_info: Option<&RequestInfo>,
client_type: Option<&str>,
) -> AuthResult<LoginResp> {
// 1. Find user by tenant_id + username
let user_model = match user::Entity::find()
.filter(user::Column::TenantId.eq(tenant_id))
.filter(user::Column::Username.eq(username))
.filter(user::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
{
Some(m) => m,
None => {
// 审计:用户不存在(登录失败)
audit_service::record(
AuditLog::new(tenant_id, None, "user.login_failed", "user").with_request_info(
req_info.as_ref().and_then(|r| r.ip.clone()),
req_info.as_ref().and_then(|r| r.user_agent.clone()),
),
db,
)
.await;
return Err(AuthError::InvalidCredentials);
}
};
// 2. Check user status
if user_model.status != "active" {
return Err(AuthError::UserDisabled(user_model.status.clone()));
}
// 3. Find password credential
let cred = user_credential::Entity::find()
.filter(user_credential::Column::UserId.eq(user_model.id))
.filter(user_credential::Column::CredentialType.eq("password"))
.filter(user_credential::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.ok_or(AuthError::InvalidCredentials)?;
// 4. Verify password
let stored_hash = cred
.credential_data
.as_ref()
.and_then(|v| v.get("hash").and_then(|h| h.as_str()))
.ok_or(AuthError::InvalidCredentials)?;
if !password::verify_password(password_plain, stored_hash)? {
// 审计:密码错误(登录失败)
audit_service::record(
AuditLog::new(tenant_id, Some(user_model.id), "user.login_failed", "user")
.with_request_info(
req_info.as_ref().and_then(|r| r.ip.clone()),
req_info.as_ref().and_then(|r| r.user_agent.clone()),
),
db,
)
.await;
return Err(AuthError::InvalidCredentials);
}
// 5. Get roles and permissions
let roles: Vec<String> = TokenService::get_user_roles(user_model.id, tenant_id, db).await?;
// 纯患者角色不允许登录管理端(同时拥有医护角色则放行)
// 小程序端 (client_type=miniprogram) 允许患者登录
let medical_roles = ["doctor", "nurse", "admin", "health_manager", "operator"];
let is_pure_patient =
roles.iter().all(|r| r == "patient") && roles.iter().any(|r| r == "patient");
let has_medical_role = roles.iter().any(|r| medical_roles.contains(&r.as_str()));
let is_miniprogram = client_type == Some("miniprogram");
if is_pure_patient && !has_medical_role && !is_miniprogram {
return Err(AuthError::Forbidden("患者账号请使用小程序登录".to_string()));
}
// 小程序端仅允许患者角色登录,医护角色请使用管理端
let has_patient_role = roles.iter().any(|r| r == "patient");
if is_miniprogram && !has_patient_role {
return Err(AuthError::Forbidden("医护账号请使用管理端登录".to_string()));
}
let permissions = TokenService::get_user_permissions(user_model.id, tenant_id, db).await?;
// 6. Sign tokens
let access_token = TokenService::sign_access_token(
user_model.id,
tenant_id,
roles.clone(),
permissions,
jwt.secret,
jwt.access_ttl_secs,
)?;
let (refresh_token, _) = TokenService::sign_refresh_token(
user_model.id,
tenant_id,
db,
jwt.secret,
jwt.refresh_ttl_secs,
)
.await?;
// 7. Update last_login_at
let mut user_active: user::ActiveModel = user_model.clone().into();
user_active.last_login_at = Set(Some(Utc::now()));
user_active.updated_at = Set(Utc::now());
user_active.version = Set(user_active.version.take().unwrap_or(0) + 1);
user_active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
// 8. Build response
let role_resps = Self::get_user_role_resps(user_model.id, tenant_id, db).await?;
let user_resp = UserResp {
id: user_model.id,
username: user_model.username.clone(),
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,
};
// 9. Publish event
event_bus.publish(erp_core::events::DomainEvent::new(
"user.login",
tenant_id,
erp_core::events::build_event_payload(serde_json::json!({ "user_id": user_model.id, "username": user_model.username })),
), db).await;
// 审计:登录成功
audit_service::record(
AuditLog::new(tenant_id, Some(user_model.id), "user.login", "user")
.with_resource_id(user_model.id)
.with_request_info(
req_info.as_ref().and_then(|r| r.ip.clone()),
req_info.as_ref().and_then(|r| r.user_agent.clone()),
),
db,
)
.await;
Ok(LoginResp {
access_token,
refresh_token,
expires_in: jwt.access_ttl_secs as u64,
user: user_resp,
})
}
/// Refresh the token pair: validate the old refresh token, revoke it, issue a new pair.
pub async fn refresh(
refresh_token_str: &str,
db: &sea_orm::DatabaseConnection,
jwt: &JwtConfig<'_>,
) -> AuthResult<LoginResp> {
// Atomically validate and revoke the old refresh token (prevents TOCTOU race)
let claims =
TokenService::validate_and_revoke_atomic(refresh_token_str, db, jwt.secret).await?;
// Fetch fresh roles and permissions
let roles: Vec<String> = TokenService::get_user_roles(claims.sub, claims.tid, db).await?;
let permissions = TokenService::get_user_permissions(claims.sub, claims.tid, db).await?;
// Sign new token pair
let access_token = TokenService::sign_access_token(
claims.sub,
claims.tid,
roles.clone(),
permissions,
jwt.secret,
jwt.access_ttl_secs,
)?;
let (new_refresh_token, _) = TokenService::sign_refresh_token(
claims.sub,
claims.tid,
db,
jwt.secret,
jwt.refresh_ttl_secs,
)
.await?;
// Fetch user for the response
let user_model = user::Entity::find_by_id(claims.sub)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.ok_or(AuthError::TokenRevoked)?;
// 验证用户属于 JWT 中声明的租户
if user_model.tenant_id != claims.tid {
tracing::warn!(
user_id = %claims.sub,
jwt_tenant = %claims.tid,
actual_tenant = %user_model.tenant_id,
"Token tenant_id 与用户实际租户不匹配"
);
return Err(AuthError::TokenRevoked);
}
let role_resps = Self::get_user_role_resps(claims.sub, claims.tid, db).await?;
let user_resp = 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,
};
Ok(LoginResp {
access_token,
refresh_token: new_refresh_token,
expires_in: jwt.access_ttl_secs as u64,
user: user_resp,
})
}
/// Revoke all refresh tokens for a user, effectively logging them out everywhere.
pub async fn logout(
user_id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
req_info: Option<&RequestInfo>,
) -> AuthResult<()> {
TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?;
// 清除 access token 权限缓存,强制重新认证
revoke_access_token_cache(user_id);
// 审计:登出
audit_service::record(
AuditLog::new(tenant_id, Some(user_id), "user.logout", "user")
.with_resource_id(user_id)
.with_request_info(
req_info.as_ref().and_then(|r| r.ip.clone()),
req_info.as_ref().and_then(|r| r.user_agent.clone()),
),
db,
)
.await;
Ok(())
}
/// Change password for the authenticated user.
///
/// Steps:
/// 1. Verify current password
/// 2. Hash the new password
/// 3. Update the credential record
/// 4. Revoke all existing refresh tokens (force re-login)
pub async fn change_password(
user_id: Uuid,
tenant_id: Uuid,
current_password: &str,
new_password: &str,
db: &sea_orm::DatabaseConnection,
req_info: Option<&RequestInfo>,
) -> AuthResult<()> {
// 1. Find the user's password credential
let cred = user_credential::Entity::find()
.filter(user_credential::Column::UserId.eq(user_id))
.filter(user_credential::Column::TenantId.eq(tenant_id))
.filter(user_credential::Column::CredentialType.eq("password"))
.filter(user_credential::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.ok_or_else(|| AuthError::Validation("用户凭证不存在".to_string()))?;
// 2. Verify current password
let stored_hash = cred
.credential_data
.as_ref()
.and_then(|v| v.get("hash").and_then(|h| h.as_str()))
.ok_or_else(|| AuthError::Validation("用户凭证异常".to_string()))?;
if !password::verify_password(current_password, stored_hash)? {
return Err(AuthError::Validation("当前密码不正确".to_string()));
}
// 3. Hash new password and update credential
let new_hash = password::hash_password(new_password)?;
let current_version = cred.version;
let mut cred_active: user_credential::ActiveModel = cred.into();
cred_active.credential_data = Set(Some(serde_json::json!({ "hash": new_hash })));
cred_active.updated_at = Set(Utc::now());
cred_active.version = Set(current_version + 1);
cred_active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
// 4. Revoke all refresh tokens — force re-login on all devices
TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?;
// 清除 access token 权限缓存,密码修改后所有已签发的 access token 强制失效
revoke_access_token_cache(user_id);
// 审计:密码修改
audit_service::record(
AuditLog::new(tenant_id, Some(user_id), "user.change_password", "user")
.with_resource_id(user_id)
.with_request_info(
req_info.as_ref().and_then(|r| r.ip.clone()),
req_info.as_ref().and_then(|r| r.user_agent.clone()),
),
db,
)
.await;
tracing::info!(user_id = %user_id, "Password changed successfully");
Ok(())
}
/// Fetch role details for a user, returning RoleResp DTOs.
pub async fn get_user_role_resps(
user_id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<Vec<RoleResp>> {
let user_roles = user_role::Entity::find()
.filter(user_role::Column::UserId.eq(user_id))
.filter(user_role::Column::TenantId.eq(tenant_id))
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let role_ids: Vec<Uuid> = user_roles.iter().map(|ur| ur.role_id).collect();
if role_ids.is_empty() {
return Ok(vec![]);
}
let roles = role::Entity::find()
.filter(role::Column::Id.is_in(role_ids))
.filter(role::Column::TenantId.eq(tenant_id))
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
Ok(roles
.iter()
.map(|r| RoleResp {
id: r.id,
name: r.name.clone(),
code: r.code.clone(),
description: r.description.clone(),
is_system: r.is_system,
version: r.version,
})
.collect())
}
}

View File

@@ -0,0 +1,414 @@
use std::collections::HashMap;
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
use crate::dto::{CreateDepartmentReq, DepartmentResp, UpdateDepartmentReq};
use crate::entity::department;
use crate::entity::organization;
use crate::entity::position;
use crate::entity::user_department;
use crate::error::{AuthError, AuthResult};
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::events::EventBus;
/// Department CRUD service -- create, read, update, soft-delete departments
/// within an organization, supporting tree-structured hierarchy.
pub struct DeptService;
impl DeptService {
/// Fetch all departments for an organization as a nested tree.
///
/// Root departments (parent_id = None) form the top level.
pub async fn list_tree(
org_id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<Vec<DepartmentResp>> {
// Verify the organization exists
let _org = organization::Entity::find_by_id(org_id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
let items = department::Entity::find()
.filter(department::Column::TenantId.eq(tenant_id))
.filter(department::Column::OrgId.eq(org_id))
.filter(department::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
Ok(build_dept_tree(&items))
}
/// Create a new department under the specified organization.
///
/// If `parent_id` is provided, computes `path` from the parent department.
/// Otherwise, path is computed from the organization root.
pub async fn create(
org_id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
req: &CreateDepartmentReq,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> AuthResult<DepartmentResp> {
// Verify the organization exists
let org = organization::Entity::find_by_id(org_id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
// Check code uniqueness within tenant if code is provided
if let Some(ref code) = req.code {
let existing = department::Entity::find()
.filter(department::Column::TenantId.eq(tenant_id))
.filter(department::Column::Code.eq(code.as_str()))
.filter(department::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if existing.is_some() {
return Err(AuthError::Validation("部门编码已存在".to_string()));
}
}
// Check name uniqueness within the same organization
let name_exists = department::Entity::find()
.filter(department::Column::TenantId.eq(tenant_id))
.filter(department::Column::OrgId.eq(org_id))
.filter(department::Column::Name.eq(&req.name))
.filter(department::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if name_exists.is_some() {
return Err(AuthError::Validation("部门名称已存在".to_string()));
}
// Compute path from parent department or organization root
let path = if let Some(parent_id) = req.parent_id {
let parent = department::Entity::find_by_id(parent_id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|d| {
d.tenant_id == tenant_id && d.org_id == org_id && d.deleted_at.is_none()
})
.ok_or_else(|| AuthError::Validation("父级部门不存在".to_string()))?;
let parent_path = parent.path.clone().unwrap_or_default();
Some(format!("{}{}/", parent_path, parent.id))
} else {
// Root department under the organization
let org_path = org.path.clone().unwrap_or_default();
Some(format!("{}{}/", org_path, org.id))
};
let now = Utc::now();
let id = Uuid::now_v7();
let model = department::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
org_id: Set(org_id),
name: Set(req.name.clone()),
code: Set(req.code.clone()),
parent_id: Set(req.parent_id),
manager_id: Set(req.manager_id),
path: Set(path),
sort_order: Set(req.sort_order.unwrap_or(0)),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
model
.insert(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"department.created",
tenant_id,
serde_json::json!({ "dept_id": id, "org_id": org_id, "name": req.name }),
),
db,
)
.await;
audit_service::record(
AuditLog::new(
tenant_id,
Some(operator_id),
"department.create",
"department",
)
.with_resource_id(id),
db,
)
.await;
Ok(DepartmentResp {
id,
org_id,
name: req.name.clone(),
code: req.code.clone(),
parent_id: req.parent_id,
manager_id: req.manager_id,
path: None,
sort_order: req.sort_order.unwrap_or(0),
children: vec![],
version: 1,
})
}
/// Update editable department fields (name, code, manager_id, sort_order).
pub async fn update(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
req: &UpdateDepartmentReq,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<DepartmentResp> {
let model = department::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
// If code is being changed, check uniqueness
if let Some(new_code) = &req.code
&& Some(new_code) != model.code.as_ref()
{
let existing = department::Entity::find()
.filter(department::Column::TenantId.eq(tenant_id))
.filter(department::Column::Code.eq(new_code.as_str()))
.filter(department::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if existing.is_some() {
return Err(AuthError::Validation("部门编码已存在".to_string()));
}
}
// If name is being changed, check uniqueness within the same org (exclude self)
if let Some(ref new_name) = req.name
&& new_name != &model.name
{
let name_exists = department::Entity::find()
.filter(department::Column::TenantId.eq(tenant_id))
.filter(department::Column::OrgId.eq(model.org_id))
.filter(department::Column::Name.eq(new_name.as_str()))
.filter(department::Column::DeletedAt.is_null())
.filter(department::Column::Id.ne(id))
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if name_exists.is_some() {
return Err(AuthError::Validation("部门名称已存在".to_string()));
}
}
let next_ver = check_version(req.version, model.version)
.map_err(|e| AuthError::Validation(e.to_string()))?;
let mut active: department::ActiveModel = model.into();
if let Some(n) = &req.name {
active.name = Set(n.clone());
}
if let Some(c) = &req.code {
active.code = Set(Some(c.clone()));
}
if let Some(mgr_id) = &req.manager_id {
active.manager_id = Set(Some(*mgr_id));
}
if let Some(so) = &req.sort_order {
active.sort_order = Set(*so);
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let updated = active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(
tenant_id,
Some(operator_id),
"department.update",
"department",
)
.with_resource_id(id),
db,
)
.await;
Ok(DepartmentResp {
id: updated.id,
org_id: updated.org_id,
name: updated.name.clone(),
code: updated.code.clone(),
parent_id: updated.parent_id,
manager_id: updated.manager_id,
path: updated.path.clone(),
sort_order: updated.sort_order,
children: vec![],
version: updated.version,
})
}
/// Soft-delete a department by setting the `deleted_at` timestamp.
///
/// Will not delete if child departments exist.
pub async fn delete(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> AuthResult<()> {
let model = department::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
// Check for child departments
let children = department::Entity::find()
.filter(department::Column::TenantId.eq(tenant_id))
.filter(department::Column::ParentId.eq(id))
.filter(department::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if children.is_some() {
return Err(AuthError::Validation(
"该部门下存在子部门,无法删除".to_string(),
));
}
// Check for positions under this department
let positions = position::Entity::find()
.filter(position::Column::TenantId.eq(tenant_id))
.filter(position::Column::DeptId.eq(id))
.filter(position::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if positions.is_some() {
return Err(AuthError::Validation(
"该部门下存在岗位,无法删除".to_string(),
));
}
// Check for users assigned to this department
let users = user_department::Entity::find()
.filter(user_department::Column::TenantId.eq(tenant_id))
.filter(user_department::Column::DepartmentId.eq(id))
.filter(user_department::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if users.is_some() {
return Err(AuthError::Validation(
"该部门下存在用户,无法删除".to_string(),
));
}
let current_version = model.version;
let mut active: department::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(current_version + 1);
active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"department.deleted",
tenant_id,
serde_json::json!({ "dept_id": id }),
),
db,
)
.await;
audit_service::record(
AuditLog::new(
tenant_id,
Some(operator_id),
"department.delete",
"department",
)
.with_resource_id(id),
db,
)
.await;
Ok(())
}
}
/// Build a nested tree of `DepartmentResp` from a flat list of models.
fn build_dept_tree(items: &[department::Model]) -> Vec<DepartmentResp> {
let mut children_map: HashMap<Option<Uuid>, Vec<&department::Model>> = HashMap::new();
for item in items {
children_map.entry(item.parent_id).or_default().push(item);
}
fn build_node(
item: &department::Model,
map: &HashMap<Option<Uuid>, Vec<&department::Model>>,
) -> DepartmentResp {
let children = map
.get(&Some(item.id))
.map(|items| items.iter().map(|i| build_node(i, map)).collect())
.unwrap_or_default();
DepartmentResp {
id: item.id,
org_id: item.org_id,
name: item.name.clone(),
code: item.code.clone(),
parent_id: item.parent_id,
manager_id: item.manager_id,
path: item.path.clone(),
sort_order: item.sort_order,
children,
version: item.version,
}
}
children_map
.get(&None)
.map(|root_items| {
root_items
.iter()
.map(|item| build_node(item, &children_map))
.collect()
})
.unwrap_or_default()
}

View File

@@ -0,0 +1,11 @@
pub mod auth_service;
pub mod dept_service;
pub mod org_service;
pub mod password;
pub mod permission_service;
pub mod position_service;
pub mod role_service;
pub mod seed;
pub mod token_service;
pub mod user_service;
pub mod wechat_service;

View File

@@ -0,0 +1,494 @@
use std::collections::HashMap;
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
use crate::dto::{CreateOrganizationReq, OrganizationResp, UpdateOrganizationReq};
use crate::entity::department;
use crate::entity::organization;
use crate::error::{AuthError, AuthResult};
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::events::EventBus;
/// Organization CRUD service -- create, read, update, soft-delete organizations
/// within a tenant, supporting tree-structured hierarchy with path and level.
pub struct OrgService;
impl OrgService {
/// Fetch all organizations for a tenant as a flat list (not deleted).
pub async fn list_flat(
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<Vec<organization::Model>> {
let items = organization::Entity::find()
.filter(organization::Column::TenantId.eq(tenant_id))
.filter(organization::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
Ok(items)
}
/// Fetch all organizations for a tenant as a nested tree.
///
/// Root nodes have `parent_id = None`. Children are grouped by `parent_id`.
pub async fn get_tree(
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<Vec<OrganizationResp>> {
let items = Self::list_flat(tenant_id, db).await?;
Ok(build_org_tree(&items))
}
/// Create a new organization within the current tenant.
///
/// If `parent_id` is provided, computes `path` from the parent's path and id,
/// and sets `level = parent.level + 1`. Otherwise, level defaults to 1.
pub async fn create(
tenant_id: Uuid,
operator_id: Uuid,
req: &CreateOrganizationReq,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> AuthResult<OrganizationResp> {
// Check code uniqueness within tenant if code is provided
if let Some(ref code) = req.code {
let existing = organization::Entity::find()
.filter(organization::Column::TenantId.eq(tenant_id))
.filter(organization::Column::Code.eq(code.as_str()))
.filter(organization::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if existing.is_some() {
return Err(AuthError::Validation("组织编码已存在".to_string()));
}
}
// Check name uniqueness within tenant
let name_exists = organization::Entity::find()
.filter(organization::Column::TenantId.eq(tenant_id))
.filter(organization::Column::Name.eq(&req.name))
.filter(organization::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if name_exists.is_some() {
return Err(AuthError::Validation("组织名称已存在".to_string()));
}
let (path, level) = if let Some(parent_id) = req.parent_id {
let parent = organization::Entity::find_by_id(parent_id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("父级组织不存在".to_string()))?;
let parent_path = parent.path.clone().unwrap_or_default();
let computed_path = format!("{}{}/", parent_path, parent.id);
(Some(computed_path), parent.level + 1)
} else {
(None, 1)
};
let now = Utc::now();
let id = Uuid::now_v7();
let model = organization::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
name: Set(req.name.clone()),
code: Set(req.code.clone()),
parent_id: Set(req.parent_id),
path: Set(path),
level: Set(level),
sort_order: Set(req.sort_order.unwrap_or(0)),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
model
.insert(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"organization.created",
tenant_id,
serde_json::json!({ "org_id": id, "name": req.name }),
),
db,
)
.await;
audit_service::record(
AuditLog::new(
tenant_id,
Some(operator_id),
"organization.create",
"organization",
)
.with_resource_id(id),
db,
)
.await;
Ok(OrganizationResp {
id,
name: req.name.clone(),
code: req.code.clone(),
parent_id: req.parent_id,
path: None,
level,
sort_order: req.sort_order.unwrap_or(0),
children: vec![],
version: 1,
})
}
/// Update editable organization fields (name, code, sort_order).
pub async fn update(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
req: &UpdateOrganizationReq,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<OrganizationResp> {
let model = organization::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
// If code is being changed, check uniqueness
if let Some(ref new_code) = req.code
&& Some(new_code) != model.code.as_ref()
{
let existing = organization::Entity::find()
.filter(organization::Column::TenantId.eq(tenant_id))
.filter(organization::Column::Code.eq(new_code.as_str()))
.filter(organization::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if existing.is_some() {
return Err(AuthError::Validation("组织编码已存在".to_string()));
}
}
// If name is being changed, check uniqueness (exclude self)
if let Some(ref new_name) = req.name
&& new_name != &model.name
{
let name_exists = organization::Entity::find()
.filter(organization::Column::TenantId.eq(tenant_id))
.filter(organization::Column::Name.eq(new_name.as_str()))
.filter(organization::Column::DeletedAt.is_null())
.filter(organization::Column::Id.ne(id))
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if name_exists.is_some() {
return Err(AuthError::Validation("组织名称已存在".to_string()));
}
}
let next_ver = check_version(req.version, model.version)
.map_err(|e| AuthError::Validation(e.to_string()))?;
let mut active: organization::ActiveModel = model.into();
if let Some(ref name) = req.name {
active.name = Set(name.clone());
}
if let Some(ref code) = req.code {
active.code = Set(Some(code.clone()));
}
if let Some(sort_order) = req.sort_order {
active.sort_order = Set(sort_order);
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let updated = active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(
tenant_id,
Some(operator_id),
"organization.update",
"organization",
)
.with_resource_id(id),
db,
)
.await;
Ok(OrganizationResp {
id: updated.id,
name: updated.name.clone(),
code: updated.code.clone(),
parent_id: updated.parent_id,
path: updated.path.clone(),
level: updated.level,
sort_order: updated.sort_order,
children: vec![],
version: updated.version,
})
}
/// Soft-delete an organization by setting the `deleted_at` timestamp.
pub async fn delete(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> AuthResult<()> {
let model = organization::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
// Check for child organizations
let children = organization::Entity::find()
.filter(organization::Column::TenantId.eq(tenant_id))
.filter(organization::Column::ParentId.eq(id))
.filter(organization::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if children.is_some() {
return Err(AuthError::Validation(
"该组织下存在子组织,无法删除".to_string(),
));
}
// Check for departments under this organization
let depts = department::Entity::find()
.filter(department::Column::TenantId.eq(tenant_id))
.filter(department::Column::OrgId.eq(id))
.filter(department::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if depts.is_some() {
return Err(AuthError::Validation(
"该组织下存在部门,无法删除".to_string(),
));
}
let current_version = model.version;
let mut active: organization::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(current_version + 1);
active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"organization.deleted",
tenant_id,
serde_json::json!({ "org_id": id }),
),
db,
)
.await;
audit_service::record(
AuditLog::new(
tenant_id,
Some(operator_id),
"organization.delete",
"organization",
)
.with_resource_id(id),
db,
)
.await;
Ok(())
}
}
/// Build a nested tree of `OrganizationResp` from a flat list of models.
///
/// Root nodes (parent_id = None) form the top level. Each node recursively
/// includes its children grouped by parent_id.
pub(crate) fn build_org_tree(items: &[organization::Model]) -> Vec<OrganizationResp> {
let mut children_map: HashMap<Option<Uuid>, Vec<&organization::Model>> = HashMap::new();
for item in items {
children_map.entry(item.parent_id).or_default().push(item);
}
fn build_node(
item: &organization::Model,
map: &HashMap<Option<Uuid>, Vec<&organization::Model>>,
) -> OrganizationResp {
let children = map
.get(&Some(item.id))
.map(|items| items.iter().map(|i| build_node(i, map)).collect())
.unwrap_or_default();
OrganizationResp {
id: item.id,
name: item.name.clone(),
code: item.code.clone(),
parent_id: item.parent_id,
path: item.path.clone(),
level: item.level,
sort_order: item.sort_order,
children,
version: item.version,
}
}
children_map
.get(&None)
.map(|root_items| {
root_items
.iter()
.map(|item| build_node(item, &children_map))
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use chrono::Utc;
use uuid::Uuid;
use crate::entity::organization;
use super::*;
fn make_org(
id: Uuid,
tenant_id: Uuid,
name: &str,
parent_id: Option<Uuid>,
level: i32,
version: i32,
) -> organization::Model {
organization::Model {
id,
tenant_id,
name: name.to_string(),
code: None,
parent_id,
path: None,
level,
sort_order: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
created_by: Uuid::now_v7(),
updated_by: Uuid::now_v7(),
deleted_at: None,
version,
}
}
#[test]
fn build_org_tree_empty() {
let tree = build_org_tree(&[]);
assert!(tree.is_empty());
}
#[test]
fn build_org_tree_single_root() {
let tid = Uuid::now_v7();
let root_id = Uuid::now_v7();
let items = vec![make_org(root_id, tid, "总公司", None, 1, 1)];
let tree = build_org_tree(&items);
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].name, "总公司");
assert!(tree[0].children.is_empty());
}
#[test]
fn build_org_tree_multiple_roots() {
let tid = Uuid::now_v7();
let items = vec![
make_org(Uuid::now_v7(), tid, "公司A", None, 1, 1),
make_org(Uuid::now_v7(), tid, "公司B", None, 1, 1),
];
let tree = build_org_tree(&items);
assert_eq!(tree.len(), 2);
}
#[test]
fn build_org_tree_nested_children() {
let tid = Uuid::now_v7();
let root_id = Uuid::now_v7();
let child1_id = Uuid::now_v7();
let child2_id = Uuid::now_v7();
let grandchild_id = Uuid::now_v7();
let items = vec![
make_org(root_id, tid, "总公司", None, 1, 1),
make_org(child1_id, tid, "分公司A", Some(root_id), 2, 1),
make_org(child2_id, tid, "分公司B", Some(root_id), 2, 1),
make_org(grandchild_id, tid, "部门A1", Some(child1_id), 3, 1),
];
let tree = build_org_tree(&items);
assert_eq!(tree.len(), 1); // one root
assert_eq!(tree[0].children.len(), 2); // two children
assert_eq!(tree[0].children[0].children.len(), 1); // one grandchild
assert_eq!(tree[0].children[0].children[0].name, "部门A1");
}
#[test]
fn build_org_tree_deep_nesting() {
let tid = Uuid::now_v7();
let l1 = Uuid::now_v7();
let l2 = Uuid::now_v7();
let l3 = Uuid::now_v7();
let l4 = Uuid::now_v7();
let items = vec![
make_org(l1, tid, "L1", None, 1, 1),
make_org(l2, tid, "L2", Some(l1), 2, 1),
make_org(l3, tid, "L3", Some(l2), 3, 1),
make_org(l4, tid, "L4", Some(l3), 4, 1),
];
let tree = build_org_tree(&items);
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].children[0].children[0].children[0].name, "L4");
}
#[test]
fn build_org_tree_preserves_version() {
let tid = Uuid::now_v7();
let root_id = Uuid::now_v7();
let items = vec![make_org(root_id, tid, "测试", None, 1, 5)];
let tree = build_org_tree(&items);
assert_eq!(tree[0].version, 5);
}
}

View File

@@ -0,0 +1,56 @@
use argon2::{
Argon2,
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
};
use crate::error::{AuthError, AuthResult};
/// Hash a plaintext password using Argon2 with a random salt.
///
/// Returns a PHC-format string suitable for database storage.
pub fn hash_password(plain: &str) -> AuthResult<String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(plain.as_bytes(), &salt)
.map_err(|e| AuthError::HashError(e.to_string()))?;
Ok(hash.to_string())
}
/// Verify a plaintext password against a stored PHC-format hash.
///
/// Returns `Ok(true)` if the password matches, `Ok(false)` if not.
pub fn verify_password(plain: &str, hash: &str) -> AuthResult<bool> {
let parsed = PasswordHash::new(hash).map_err(|e| AuthError::HashError(e.to_string()))?;
Ok(Argon2::default()
.verify_password(plain.as_bytes(), &parsed)
.is_ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_and_verify() {
let hash = hash_password("test123").unwrap();
assert!(
verify_password("test123", &hash).unwrap(),
"Correct password should verify"
);
assert!(
!verify_password("wrong", &hash).unwrap(),
"Wrong password should not verify"
);
}
#[test]
fn test_hash_is_unique() {
let hash1 = hash_password("same_password").unwrap();
let hash2 = hash_password("same_password").unwrap();
assert_ne!(
hash1, hash2,
"Two hashes of the same password should differ (different salts)"
);
}
}

View File

@@ -0,0 +1,38 @@
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use uuid::Uuid;
use crate::dto::PermissionResp;
use crate::entity::permission;
use crate::error::AuthResult;
/// Permission read-only service — list permissions within a tenant.
///
/// Permissions are seeded by the system and not typically created via API.
pub struct PermissionService;
impl PermissionService {
/// List all active permissions within a tenant.
pub async fn list(
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<Vec<PermissionResp>> {
let perms = permission::Entity::find()
.filter(permission::Column::TenantId.eq(tenant_id))
.filter(permission::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| crate::error::AuthError::Validation(e.to_string()))?;
Ok(perms
.iter()
.map(|p| PermissionResp {
id: p.id,
code: p.code.clone(),
name: p.name.clone(),
resource: p.resource.clone(),
action: p.action.clone(),
description: p.description.clone(),
})
.collect())
}
}

View File

@@ -0,0 +1,259 @@
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
use crate::dto::{CreatePositionReq, PositionResp, UpdatePositionReq};
use crate::entity::department;
use crate::entity::position;
use crate::error::{AuthError, AuthResult};
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::events::EventBus;
/// Position CRUD service -- create, read, update, soft-delete positions
/// within a department.
pub struct PositionService;
impl PositionService {
/// List all positions for a department within the given tenant.
pub async fn list(
dept_id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<Vec<PositionResp>> {
// Verify the department exists
let _dept = department::Entity::find_by_id(dept_id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
let items = position::Entity::find()
.filter(position::Column::TenantId.eq(tenant_id))
.filter(position::Column::DeptId.eq(dept_id))
.filter(position::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
Ok(items
.iter()
.map(|p| PositionResp {
id: p.id,
dept_id: p.dept_id,
name: p.name.clone(),
code: p.code.clone(),
level: p.level,
sort_order: p.sort_order,
version: p.version,
})
.collect())
}
/// Create a new position under the specified department.
pub async fn create(
dept_id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
req: &CreatePositionReq,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> AuthResult<PositionResp> {
// Verify the department exists
let _dept = department::Entity::find_by_id(dept_id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
// Check code uniqueness within tenant if code is provided
if let Some(ref code) = req.code {
let existing = position::Entity::find()
.filter(position::Column::TenantId.eq(tenant_id))
.filter(position::Column::Code.eq(code.as_str()))
.filter(position::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if existing.is_some() {
return Err(AuthError::Validation("岗位编码已存在".to_string()));
}
}
let now = Utc::now();
let id = Uuid::now_v7();
let model = position::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
dept_id: Set(dept_id),
name: Set(req.name.clone()),
code: Set(req.code.clone()),
level: Set(req.level.unwrap_or(1)),
sort_order: Set(req.sort_order.unwrap_or(0)),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
model
.insert(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"position.created",
tenant_id,
serde_json::json!({ "position_id": id, "dept_id": dept_id, "name": req.name }),
),
db,
)
.await;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "position.create", "position")
.with_resource_id(id),
db,
)
.await;
Ok(PositionResp {
id,
dept_id,
name: req.name.clone(),
code: req.code.clone(),
level: req.level.unwrap_or(1),
sort_order: req.sort_order.unwrap_or(0),
version: 1,
})
}
/// Update editable position fields (name, code, level, sort_order).
pub async fn update(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
req: &UpdatePositionReq,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<PositionResp> {
let model = position::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("岗位不存在".to_string()))?;
// If code is being changed, check uniqueness
if let Some(new_code) = &req.code
&& Some(new_code) != model.code.as_ref()
{
let existing = position::Entity::find()
.filter(position::Column::TenantId.eq(tenant_id))
.filter(position::Column::Code.eq(new_code.as_str()))
.filter(position::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if existing.is_some() {
return Err(AuthError::Validation("岗位编码已存在".to_string()));
}
}
let next_ver = check_version(req.version, model.version)
.map_err(|e| AuthError::Validation(e.to_string()))?;
let mut active: position::ActiveModel = model.into();
if let Some(n) = &req.name {
active.name = Set(n.clone());
}
if let Some(c) = &req.code {
active.code = Set(Some(c.clone()));
}
if let Some(l) = &req.level {
active.level = Set(*l);
}
if let Some(so) = &req.sort_order {
active.sort_order = Set(*so);
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let updated = active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "position.update", "position")
.with_resource_id(id),
db,
)
.await;
Ok(PositionResp {
id: updated.id,
dept_id: updated.dept_id,
name: updated.name.clone(),
code: updated.code.clone(),
level: updated.level,
sort_order: updated.sort_order,
version: updated.version,
})
}
/// Soft-delete a position by setting the `deleted_at` timestamp.
pub async fn delete(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> AuthResult<()> {
let model = position::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("岗位不存在".to_string()))?;
let current_version = model.version;
let mut active: position::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(current_version + 1);
active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"position.deleted",
tenant_id,
serde_json::json!({ "position_id": id }),
),
db,
)
.await;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "position.delete", "position")
.with_resource_id(id),
db,
)
.await;
Ok(())
}
}

View File

@@ -0,0 +1,370 @@
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
use uuid::Uuid;
use crate::dto::{PermissionResp, RoleResp};
use crate::entity::{permission, role, role_permission};
use crate::error::AuthError;
use crate::error::AuthResult;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::events::EventBus;
use erp_core::types::Pagination;
/// Role CRUD service — create, read, update, soft-delete roles within a tenant,
/// and manage role-permission assignments.
pub struct RoleService;
impl RoleService {
/// List roles within a tenant with pagination.
///
/// Returns `(roles, total_count)`.
pub async fn list(
tenant_id: Uuid,
pagination: &Pagination,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<(Vec<RoleResp>, u64)> {
let paginator = role::Entity::find()
.filter(role::Column::TenantId.eq(tenant_id))
.filter(role::Column::DeletedAt.is_null())
.paginate(db, pagination.limit());
let total = paginator
.num_items()
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
let models = paginator
.fetch_page(page_index)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let resps: Vec<RoleResp> = models
.iter()
.map(|m| RoleResp {
id: m.id,
name: m.name.clone(),
code: m.code.clone(),
description: m.description.clone(),
is_system: m.is_system,
version: m.version,
})
.collect();
Ok((resps, total))
}
/// Fetch a single role by ID, scoped to the given tenant.
pub async fn get_by_id(
id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<RoleResp> {
let model = role::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
Ok(RoleResp {
id: model.id,
name: model.name.clone(),
code: model.code.clone(),
description: model.description.clone(),
is_system: model.is_system,
version: model.version,
})
}
/// Create a new role within the current tenant.
///
/// Validates code uniqueness, then inserts the record and publishes
/// a `role.created` domain event.
pub async fn create(
tenant_id: Uuid,
operator_id: Uuid,
name: &str,
code: &str,
description: &Option<String>,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> AuthResult<RoleResp> {
// Check code uniqueness within tenant
let existing = role::Entity::find()
.filter(role::Column::TenantId.eq(tenant_id))
.filter(role::Column::Code.eq(code))
.filter(role::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if existing.is_some() {
return Err(AuthError::Validation("角色编码已存在".to_string()));
}
let now = Utc::now();
let id = Uuid::now_v7();
let model = role::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
name: Set(name.to_string()),
code: Set(code.to_string()),
description: Set(description.clone()),
is_system: Set(false),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
model
.insert(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"role.created",
tenant_id,
serde_json::json!({ "role_id": id, "code": code }),
),
db,
)
.await;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "role.create", "role").with_resource_id(id),
db,
)
.await;
Ok(RoleResp {
id,
name: name.to_string(),
code: code.to_string(),
description: description.clone(),
is_system: false,
version: 1,
})
}
/// Update editable role fields (name and description).
///
/// Code and is_system cannot be changed after creation.
pub async fn update(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
name: &Option<String>,
description: &Option<String>,
version: i32,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<RoleResp> {
let model = role::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
let old_json = serde_json::to_value(&model).unwrap_or(serde_json::Value::Null);
let next_ver = check_version(version, model.version)
.map_err(|e| AuthError::Validation(e.to_string()))?;
let mut active: role::ActiveModel = model.into();
if let Some(name) = name {
active.name = Set(name.clone());
}
if let Some(desc) = description {
active.description = Set(Some(desc.clone()));
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let updated = active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let new_json = serde_json::to_value(&updated).unwrap_or(serde_json::Value::Null);
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "role.update", "role")
.with_resource_id(id)
.with_changes(Some(old_json), Some(new_json)),
db,
)
.await;
Ok(RoleResp {
id: updated.id,
name: updated.name.clone(),
code: updated.code.clone(),
description: updated.description.clone(),
is_system: updated.is_system,
version: updated.version,
})
}
/// Soft-delete a role by setting the `deleted_at` timestamp.
///
/// System roles cannot be deleted.
pub async fn delete(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> AuthResult<()> {
let model = role::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
if model.is_system {
return Err(AuthError::Validation("系统角色不可删除".to_string()));
}
let current_version = model.version;
let mut active: role::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(current_version + 1);
active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"role.deleted",
tenant_id,
serde_json::json!({ "role_id": id }),
),
db,
)
.await;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "role.delete", "role").with_resource_id(id),
db,
)
.await;
Ok(())
}
/// Replace all permission assignments for a role.
///
/// Soft-deletes existing assignments and creates new ones.
pub async fn assign_permissions(
role_id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
permission_ids: &[Uuid],
db: &sea_orm::DatabaseConnection,
) -> AuthResult<()> {
// Verify the role exists and belongs to this tenant
let _role = role::Entity::find_by_id(role_id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
// Soft-delete existing role_permission rows
let existing = role_permission::Entity::find()
.filter(role_permission::Column::RoleId.eq(role_id))
.filter(role_permission::Column::TenantId.eq(tenant_id))
.filter(role_permission::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let now = Utc::now();
for rp in existing {
let mut active: role_permission::ActiveModel = rp.into();
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(active.version.take().unwrap_or(0) + 1);
active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
}
// Insert new role_permission rows
for perm_id in permission_ids {
let rp = role_permission::ActiveModel {
role_id: Set(role_id),
permission_id: Set(*perm_id),
tenant_id: Set(tenant_id),
data_scope: Set("all".to_string()),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
rp.insert(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
}
Ok(())
}
/// Fetch all permissions assigned to a role.
///
/// Resolves through the role_permission join table.
pub async fn get_role_permissions(
role_id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<Vec<PermissionResp>> {
let rp_rows = role_permission::Entity::find()
.filter(role_permission::Column::RoleId.eq(role_id))
.filter(role_permission::Column::TenantId.eq(tenant_id))
.filter(role_permission::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let perm_ids: Vec<Uuid> = rp_rows.iter().map(|rp| rp.permission_id).collect();
if perm_ids.is_empty() {
return Ok(vec![]);
}
let perms = permission::Entity::find()
.filter(permission::Column::Id.is_in(perm_ids))
.filter(permission::Column::TenantId.eq(tenant_id))
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
Ok(perms
.iter()
.map(|p| PermissionResp {
id: p.id,
code: p.code.clone(),
name: p.name.clone(),
resource: p.resource.clone(),
action: p.action.clone(),
description: p.description.clone(),
})
.collect())
}
}

View File

@@ -0,0 +1,547 @@
use sea_orm::{ActiveModelTrait, Set};
use uuid::Uuid;
use crate::entity::{permission, role, role_permission, user, user_credential, user_role};
use crate::error::AuthError;
use super::password;
/// Permission definitions to seed for every new tenant.
/// Each tuple is: (code, name, resource, action, description)
///
/// 编码使用点分隔 (`resource.action`),与 handler 中的 `require_permission` 调用保持一致。
const DEFAULT_PERMISSIONS: &[(&str, &str, &str, &str, &str)] = &[
// === Auth module ===
("user.list", "查看用户列表", "user", "list", "查看用户列表"),
("user.create", "创建用户", "user", "create", "创建新用户"),
("user.read", "查看用户详情", "user", "read", "查看用户信息"),
("user.update", "编辑用户", "user", "update", "编辑用户信息"),
("user.delete", "删除用户", "user", "delete", "软删除用户"),
("role.list", "查看角色列表", "role", "list", "查看角色列表"),
("role.create", "创建角色", "role", "create", "创建新角色"),
("role.read", "查看角色详情", "role", "read", "查看角色信息"),
("role.update", "编辑角色", "role", "update", "编辑角色"),
("role.delete", "删除角色", "role", "delete", "删除角色"),
(
"permission.list",
"查看权限",
"permission",
"list",
"查看权限列表",
),
(
"organization.list",
"查看组织列表",
"organization",
"list",
"查看组织列表",
),
(
"organization.create",
"创建组织",
"organization",
"create",
"创建组织",
),
(
"organization.update",
"编辑组织",
"organization",
"update",
"编辑组织",
),
(
"organization.delete",
"删除组织",
"organization",
"delete",
"删除组织",
),
(
"department.list",
"查看部门列表",
"department",
"list",
"查看部门列表",
),
(
"department.create",
"创建部门",
"department",
"create",
"创建部门",
),
(
"department.update",
"编辑部门",
"department",
"update",
"编辑部门",
),
(
"department.delete",
"删除部门",
"department",
"delete",
"删除部门",
),
(
"position.list",
"查看岗位列表",
"position",
"list",
"查看岗位列表",
),
(
"position.create",
"创建岗位",
"position",
"create",
"创建岗位",
),
(
"position.update",
"编辑岗位",
"position",
"update",
"编辑岗位",
),
(
"position.delete",
"删除岗位",
"position",
"delete",
"删除岗位",
),
// === Config module ===
(
"dictionary.list",
"查看字典",
"dictionary",
"list",
"查看数据字典",
),
(
"dictionary.create",
"创建字典",
"dictionary",
"create",
"创建数据字典",
),
(
"dictionary.update",
"编辑字典",
"dictionary",
"update",
"编辑数据字典",
),
(
"dictionary.delete",
"删除字典",
"dictionary",
"delete",
"删除数据字典",
),
("menu.list", "查看菜单", "menu", "list", "查看菜单配置"),
("menu.update", "编辑菜单", "menu", "update", "编辑菜单配置"),
(
"setting.read",
"查看配置",
"setting",
"read",
"查看系统参数",
),
(
"setting.update",
"编辑配置",
"setting",
"update",
"编辑系统参数",
),
(
"setting.delete",
"删除配置",
"setting",
"delete",
"删除系统参数",
),
(
"numbering.list",
"查看编号规则",
"numbering",
"list",
"查看编号规则",
),
(
"numbering.create",
"创建编号规则",
"numbering",
"create",
"创建编号规则",
),
(
"numbering.update",
"编辑编号规则",
"numbering",
"update",
"编辑编号规则",
),
(
"numbering.delete",
"删除编号规则",
"numbering",
"delete",
"删除编号规则",
),
(
"numbering.generate",
"生成编号",
"numbering",
"generate",
"生成文档编号",
),
("theme.read", "查看主题", "theme", "read", "查看主题设置"),
(
"theme.update",
"编辑主题",
"theme",
"update",
"编辑主题设置",
),
(
"language.list",
"查看语言",
"language",
"list",
"查看语言配置",
),
(
"language.update",
"编辑语言",
"language",
"update",
"编辑语言设置",
),
// === Workflow module ===
(
"workflow.create",
"创建流程",
"workflow",
"create",
"创建流程定义",
),
(
"workflow.list",
"查看流程",
"workflow",
"list",
"查看流程列表",
),
(
"workflow.read",
"查看流程详情",
"workflow",
"read",
"查看流程定义详情",
),
(
"workflow.update",
"编辑流程",
"workflow",
"update",
"编辑流程定义",
),
(
"workflow.publish",
"发布流程",
"workflow",
"publish",
"发布流程定义",
),
(
"workflow.start",
"发起流程",
"workflow",
"start",
"发起流程实例",
),
(
"workflow.approve",
"审批任务",
"workflow",
"approve",
"审批流程任务",
),
(
"workflow.delegate",
"委派任务",
"workflow",
"delegate",
"委派流程任务",
),
// === Message module ===
(
"message.list",
"查看消息",
"message",
"list",
"查看消息列表",
),
("message.send", "发送消息", "message", "send", "发送新消息"),
(
"message.template.list",
"查看消息模板",
"message.template",
"list",
"查看消息模板列表",
),
(
"message.template.create",
"创建消息模板",
"message.template",
"create",
"创建消息模板",
),
(
"message.template.manage",
"管理消息模板",
"message.template",
"manage",
"编辑、删除消息模板",
),
// === Plugin module ===
(
"plugin.admin",
"插件管理",
"plugin",
"admin",
"管理插件全生命周期",
),
("plugin.list", "查看插件", "plugin", "list", "查看插件列表"),
// === Server level ===
(
"tenant.manage",
"租户管理",
"tenant",
"manage",
"管理租户级设置(密钥轮换等)",
),
];
/// Indices of read-only (list/read) permissions within DEFAULT_PERMISSIONS.
const READ_PERM_INDICES: &[usize] = &[
0, // user.list
2, // user.read
5, // role.list
7, // role.read
10, // permission.list
11, // organization.list
15, // department.list
19, // position.list
23, // dictionary.list
28, // menu.list
30, // setting.read
32, // numbering.list
37, // theme.read
39, // language.list
43, // workflow.list
44, // workflow.read
49, // message.list
51, // message.template.list
54, // plugin.list
];
/// Seed default auth data for a new tenant.
///
/// Creates:
/// - 56 permissions covering auth/config/workflow/message/plugin modules
/// - An "admin" system role with all permissions
/// - A "viewer" system role with read-only permissions
/// - A super-admin user with the admin role and a password credential
pub async fn seed_tenant_auth(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
super_admin_password: &str,
) -> Result<(), AuthError> {
let now = chrono::Utc::now();
let system_user_id = Uuid::nil();
// 1. Create permissions
let mut perm_ids: Vec<Uuid> = Vec::with_capacity(DEFAULT_PERMISSIONS.len());
for (code, name, resource, action, desc) in DEFAULT_PERMISSIONS {
let perm_id = Uuid::now_v7();
perm_ids.push(perm_id);
let perm = permission::ActiveModel {
id: Set(perm_id),
tenant_id: Set(tenant_id),
code: Set(code.to_string()),
name: Set(name.to_string()),
resource: Set(resource.to_string()),
action: Set(action.to_string()),
description: Set(Some(desc.to_string())),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(system_user_id),
updated_by: Set(system_user_id),
deleted_at: Set(None),
version: Set(1),
};
perm.insert(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
}
// 2. Create "admin" role with all permissions
let admin_role_id = Uuid::now_v7();
let admin_role = role::ActiveModel {
id: Set(admin_role_id),
tenant_id: Set(tenant_id),
name: Set("管理员".to_string()),
code: Set("admin".to_string()),
description: Set(Some("系统管理员,拥有所有权限".to_string())),
is_system: Set(true),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(system_user_id),
updated_by: Set(system_user_id),
deleted_at: Set(None),
version: Set(1),
};
admin_role
.insert(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
// Assign all permissions to admin role
for perm_id in &perm_ids {
let rp = role_permission::ActiveModel {
role_id: Set(admin_role_id),
permission_id: Set(*perm_id),
tenant_id: Set(tenant_id),
data_scope: Set("all".to_string()),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(system_user_id),
updated_by: Set(system_user_id),
deleted_at: Set(None),
version: Set(1),
};
rp.insert(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
}
// 3. Create "viewer" role with read-only permissions
let viewer_role_id = Uuid::now_v7();
let viewer_role = role::ActiveModel {
id: Set(viewer_role_id),
tenant_id: Set(tenant_id),
name: Set("查看者".to_string()),
code: Set("viewer".to_string()),
description: Set(Some("只读用户,可查看所有数据".to_string())),
is_system: Set(true),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(system_user_id),
updated_by: Set(system_user_id),
deleted_at: Set(None),
version: Set(1),
};
viewer_role
.insert(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
// Assign read permissions to viewer role
for idx in READ_PERM_INDICES {
if *idx < perm_ids.len() {
let rp = role_permission::ActiveModel {
role_id: Set(viewer_role_id),
permission_id: Set(perm_ids[*idx]),
tenant_id: Set(tenant_id),
data_scope: Set("all".to_string()),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(system_user_id),
updated_by: Set(system_user_id),
deleted_at: Set(None),
version: Set(1),
};
rp.insert(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
}
}
// 4. Create super admin user
let admin_user_id = Uuid::now_v7();
let password_hash = password::hash_password(super_admin_password)?;
let admin_user = user::ActiveModel {
id: Set(admin_user_id),
tenant_id: Set(tenant_id),
username: Set("admin".to_string()),
email: Set(None),
phone: Set(None),
display_name: Set(Some("系统管理员".to_string())),
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(system_user_id),
updated_by: Set(system_user_id),
deleted_at: Set(None),
version: Set(1),
};
admin_user
.insert(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
// Create password credential for admin user
let cred = user_credential::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
user_id: Set(admin_user_id),
credential_type: Set("password".to_string()),
credential_data: Set(Some(serde_json::json!({ "hash": password_hash }))),
verified: Set(true),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(system_user_id),
updated_by: Set(system_user_id),
deleted_at: Set(None),
version: Set(1),
};
cred.insert(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
// 5. Assign admin role to admin user
let user_role_assignment = user_role::ActiveModel {
user_id: Set(admin_user_id),
role_id: Set(admin_role_id),
tenant_id: Set(tenant_id),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(system_user_id),
updated_by: Set(system_user_id),
deleted_at: Set(None),
version: Set(1),
};
user_role_assignment
.insert(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
tracing::info!(
tenant_id = %tenant_id,
admin_user_id = %admin_user_id,
"Seeded tenant auth: admin user, 2 roles, {} permissions",
DEFAULT_PERMISSIONS.len()
);
Ok(())
}

View File

@@ -0,0 +1,326 @@
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::entity::{permission, role, role_permission, user_role, user_token};
use crate::error::AuthError;
use crate::error::AuthResult;
/// JWT claims embedded in access and refresh tokens.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
/// Subject — the user ID
pub sub: Uuid,
/// Tenant ID
pub tid: Uuid,
/// Role codes assigned to this user
pub roles: Vec<String>,
/// Permission codes granted to this user
pub permissions: Vec<String>,
/// Expiry (unix timestamp)
pub exp: i64,
/// Issued at (unix timestamp)
pub iat: i64,
/// Token type: "access" or "refresh"
pub token_type: String,
}
/// Stateless service for JWT token signing, validation, and revocation.
pub struct TokenService;
impl TokenService {
/// Sign a short-lived access token containing roles and permissions.
pub fn sign_access_token(
user_id: Uuid,
tenant_id: Uuid,
roles: Vec<String>,
permissions: Vec<String>,
secret: &str,
ttl_secs: i64,
) -> AuthResult<String> {
let now = Utc::now();
let claims = Claims {
sub: user_id,
tid: tenant_id,
roles,
permissions,
exp: now.timestamp() + ttl_secs,
iat: now.timestamp(),
token_type: "access".to_string(),
};
let header = jsonwebtoken::Header::default();
let encoded = jsonwebtoken::encode(
&header,
&claims,
&jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
)?;
Ok(encoded)
}
/// Sign a long-lived refresh token and persist its SHA-256 hash in the database.
///
/// Returns the raw token string (sent to client) and the database row ID.
pub async fn sign_refresh_token(
user_id: Uuid,
tenant_id: Uuid,
db: &DatabaseConnection,
secret: &str,
ttl_secs: i64,
) -> AuthResult<(String, Uuid)> {
let now = Utc::now();
let token_id = Uuid::now_v7();
let claims = Claims {
sub: user_id,
tid: tenant_id,
roles: vec![],
permissions: vec![],
exp: now.timestamp() + ttl_secs,
iat: now.timestamp(),
token_type: "refresh".to_string(),
};
let raw_token = jsonwebtoken::encode(
&jsonwebtoken::Header::default(),
&claims,
&jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
)?;
// Store the SHA-256 hash — the raw token is never persisted.
let hash = sha256_hex(&raw_token);
let token_model = user_token::ActiveModel {
id: Set(token_id),
tenant_id: Set(tenant_id),
user_id: Set(user_id),
token_hash: Set(hash),
token_type: Set("refresh".to_string()),
expires_at: Set(now + chrono::Duration::seconds(ttl_secs)),
revoked_at: Set(None),
device_info: 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),
};
token_model
.insert(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
Ok((raw_token, token_id))
}
/// Validate a refresh token against the database.
///
/// Returns the database row ID and decoded claims.
pub async fn validate_refresh_token(
token: &str,
db: &DatabaseConnection,
secret: &str,
) -> AuthResult<(Uuid, Claims)> {
let claims = Self::decode_token(token, secret)?;
if claims.token_type != "refresh" {
return Err(AuthError::Validation("不是 refresh token".to_string()));
}
let hash = sha256_hex(token);
let token_row = user_token::Entity::find()
.filter(user_token::Column::TokenHash.eq(hash))
.filter(user_token::Column::TenantId.eq(claims.tid))
.filter(user_token::Column::RevokedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.ok_or(AuthError::TokenRevoked)?;
Ok((token_row.id, claims))
}
/// Decode and validate any JWT token, returning the claims.
pub fn decode_token(token: &str, secret: &str) -> AuthResult<Claims> {
let data = jsonwebtoken::decode::<Claims>(
token,
&jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()),
&jsonwebtoken::Validation::default(),
)?;
Ok(data.claims)
}
/// Revoke a specific refresh token by database ID.
/// Verifies that the token belongs to the specified user for security.
pub async fn revoke_token(
token_id: Uuid,
user_id: Uuid,
db: &DatabaseConnection,
) -> AuthResult<()> {
let token_row = user_token::Entity::find_by_id(token_id)
.filter(user_token::Column::UserId.eq(user_id))
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.ok_or(AuthError::TokenRevoked)?;
let mut active: user_token::ActiveModel = token_row.into();
active.revoked_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.version = Set(active.version.take().unwrap_or(0) + 1);
active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
Ok(())
}
/// Atomically validate and revoke a refresh token by hash.
/// This prevents TOCTOU race conditions during concurrent refresh requests.
/// Returns the decoded claims on success, or TokenRevoked if already consumed.
pub async fn validate_and_revoke_atomic(
token: &str,
db: &DatabaseConnection,
secret: &str,
) -> AuthResult<Claims> {
let claims = Self::decode_token(token, secret)?;
if claims.token_type != "refresh" {
return Err(AuthError::Validation("不是 refresh token".to_string()));
}
let hash = sha256_hex(token);
let now = Utc::now();
let result = user_token::Entity::update_many()
.col_expr(
user_token::Column::RevokedAt,
sea_orm::sea_query::Expr::value(Some(now.naive_utc())),
)
.col_expr(
user_token::Column::UpdatedAt,
sea_orm::sea_query::Expr::value(now.naive_utc()),
)
.col_expr(
user_token::Column::Version,
sea_orm::sea_query::Expr::col(user_token::Column::Version).add(1),
)
.filter(user_token::Column::TokenHash.eq(&hash))
.filter(user_token::Column::UserId.eq(claims.sub))
.filter(user_token::Column::TenantId.eq(claims.tid))
.filter(user_token::Column::RevokedAt.is_null())
.exec(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if result.rows_affected == 0 {
return Err(AuthError::TokenRevoked);
}
Ok(claims)
}
/// Revoke all non-revoked refresh tokens for a given user within a tenant.
pub async fn revoke_all_user_tokens(
user_id: Uuid,
tenant_id: Uuid,
db: &DatabaseConnection,
) -> AuthResult<()> {
let tokens = user_token::Entity::find()
.filter(user_token::Column::UserId.eq(user_id))
.filter(user_token::Column::TenantId.eq(tenant_id))
.filter(user_token::Column::RevokedAt.is_null())
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let now = Utc::now();
for token in tokens {
let mut active: user_token::ActiveModel = token.into();
active.revoked_at = Set(Some(now));
active.updated_at = Set(now);
active.version = Set(active.version.take().unwrap_or(0) + 1);
active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
}
Ok(())
}
/// Look up a user's permission codes through user_roles -> role_permissions -> permissions.
pub async fn get_user_permissions(
user_id: Uuid,
tenant_id: Uuid,
db: &DatabaseConnection,
) -> AuthResult<Vec<String>> {
let user_role_rows = user_role::Entity::find()
.filter(user_role::Column::UserId.eq(user_id))
.filter(user_role::Column::TenantId.eq(tenant_id))
.filter(user_role::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let role_ids: Vec<Uuid> = user_role_rows.iter().map(|ur| ur.role_id).collect();
if role_ids.is_empty() {
return Ok(vec![]);
}
let role_perm_rows = role_permission::Entity::find()
.filter(role_permission::Column::RoleId.is_in(role_ids))
.filter(role_permission::Column::TenantId.eq(tenant_id))
.filter(role_permission::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let perm_ids: Vec<Uuid> = role_perm_rows.iter().map(|rp| rp.permission_id).collect();
if perm_ids.is_empty() {
return Ok(vec![]);
}
let perms = permission::Entity::find()
.filter(permission::Column::Id.is_in(perm_ids))
.filter(permission::Column::TenantId.eq(tenant_id))
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
Ok(perms.iter().map(|p| p.code.clone()).collect())
}
/// Look up a user's role codes through user_roles -> roles.
pub async fn get_user_roles(
user_id: Uuid,
tenant_id: Uuid,
db: &DatabaseConnection,
) -> AuthResult<Vec<String>> {
let user_role_rows = user_role::Entity::find()
.filter(user_role::Column::UserId.eq(user_id))
.filter(user_role::Column::TenantId.eq(tenant_id))
.filter(user_role::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let role_ids: Vec<Uuid> = user_role_rows.iter().map(|ur| ur.role_id).collect();
if role_ids.is_empty() {
return Ok(vec![]);
}
let roles = role::Entity::find()
.filter(role::Column::Id.is_in(role_ids))
.filter(role::Column::TenantId.eq(tenant_id))
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
Ok(roles.iter().map(|r| r.code.clone()).collect())
}
}
/// Compute a SHA-256 hex digest of the input string.
fn sha256_hex(input: &str) -> String {
let hash = Sha256::digest(input.as_bytes());
format!("{:x}", hash)
}

View File

@@ -0,0 +1,629 @@
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
use std::collections::HashMap;
use uuid::Uuid;
use crate::dto::{CreateUserReq, RoleResp, UpdateUserReq, UserResp};
use crate::entity::{role, user, user_credential, user_role};
use crate::error::AuthError;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::events::EventBus;
use erp_core::types::Pagination;
use crate::error::AuthResult;
use super::password;
/// User CRUD service — create, read, update, soft-delete users within a tenant.
pub struct UserService;
impl UserService {
/// Create a new user with a password credential.
///
/// Validates username uniqueness within the tenant, hashes the password,
/// and publishes a `user.created` event.
pub async fn create(
tenant_id: Uuid,
operator_id: Uuid,
req: &CreateUserReq,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> AuthResult<UserResp> {
// Check username uniqueness within tenant
let existing = user::Entity::find()
.filter(user::Column::TenantId.eq(tenant_id))
.filter(user::Column::Username.eq(&req.username))
.filter(user::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if existing.is_some() {
return Err(AuthError::Validation("用户名已存在".to_string()));
}
let now = Utc::now();
let user_id = Uuid::now_v7();
// Insert user record
let user_model = user::ActiveModel {
id: Set(user_id),
tenant_id: Set(tenant_id),
username: Set(req.username.clone()),
email: Set(req.email.clone()),
phone: Set(req.phone.clone()),
display_name: Set(req.display_name.clone()),
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(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
user_model
.insert(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
// Insert password credential
let hash = password::hash_password(&req.password)?;
let cred = user_credential::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
user_id: Set(user_id),
credential_type: Set("password".to_string()),
credential_data: Set(Some(serde_json::json!({ "hash": hash }))),
verified: Set(true),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
cred.insert(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
// Publish domain event
event_bus
.publish(
erp_core::events::DomainEvent::new(
"user.created",
tenant_id,
serde_json::json!({ "user_id": user_id, "username": req.username }),
),
db,
)
.await;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "user.create", "user")
.with_resource_id(user_id),
db,
)
.await;
Ok(UserResp {
id: user_id,
username: req.username.clone(),
email: req.email.clone(),
phone: req.phone.clone(),
display_name: req.display_name.clone(),
avatar_url: None,
status: "active".to_string(),
roles: vec![],
version: 1,
})
}
/// Fetch a single user by ID, scoped to the given tenant.
pub async fn get_by_id(
id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<UserResp> {
let user_model = user::Entity::find()
.filter(user::Column::Id.eq(id))
.filter(user::Column::TenantId.eq(tenant_id))
.filter(user::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
let roles = Self::fetch_user_role_resps(id, tenant_id, db).await?;
Ok(model_to_resp(&user_model, roles))
}
/// List users within a tenant with pagination and optional search.
///
/// Returns `(users, total_count)`. When `search` is provided, filters
/// by username using case-insensitive substring match.
pub async fn list(
tenant_id: Uuid,
pagination: &Pagination,
search: Option<&str>,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<(Vec<UserResp>, u64)> {
let mut query = user::Entity::find()
.filter(user::Column::TenantId.eq(tenant_id))
.filter(user::Column::DeletedAt.is_null());
if let Some(term) = search
&& !term.is_empty()
{
use sea_orm::sea_query::Expr;
query = query.filter(Expr::col(user::Column::Username).like(format!("%{}%", term)));
}
let paginator = query.paginate(db, pagination.limit());
let total = paginator
.num_items()
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
let models = paginator
.fetch_page(page_index)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let mut resps = Vec::with_capacity(models.len());
// 批量查询所有用户的角色N+1 → 3 固定查询)
let user_ids: Vec<Uuid> = models.iter().map(|m| m.id).collect();
let role_map = Self::fetch_batch_user_role_resps(&user_ids, tenant_id, db).await;
for m in models {
let roles = role_map.get(&m.id).cloned().unwrap_or_default();
resps.push(model_to_resp(&m, roles));
}
Ok((resps, total))
}
/// Update editable user fields.
///
/// Supports updating email, phone, display_name, and status.
/// Status must be one of: "active", "disabled", "locked".
pub async fn update(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
req: &UpdateUserReq,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<UserResp> {
let user_model = user::Entity::find()
.filter(user::Column::Id.eq(id))
.filter(user::Column::TenantId.eq(tenant_id))
.filter(user::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
let old_json = serde_json::to_value(&user_model).unwrap_or(serde_json::Value::Null);
let next_ver = check_version(req.version, user_model.version)
.map_err(|e| AuthError::Validation(e.to_string()))?;
let mut active: user::ActiveModel = user_model.into();
if let Some(email) = &req.email {
active.email = Set(Some(email.clone()));
}
if let Some(phone) = &req.phone {
active.phone = Set(Some(phone.clone()));
}
if let Some(display_name) = &req.display_name {
active.display_name = Set(Some(display_name.clone()));
}
if let Some(status) = &req.status {
if !["active", "disabled", "locked"].contains(&status.as_str()) {
return Err(AuthError::Validation("无效的状态值".to_string()));
}
active.status = Set(status.clone());
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let updated = active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let new_json = serde_json::to_value(&updated).unwrap_or(serde_json::Value::Null);
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "user.update", "user")
.with_resource_id(id)
.with_changes(Some(old_json), Some(new_json)),
db,
)
.await;
let roles = Self::fetch_user_role_resps(id, tenant_id, db).await?;
Ok(model_to_resp(&updated, roles))
}
/// Soft-delete a user by setting the `deleted_at` timestamp.
pub async fn delete(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> AuthResult<()> {
let user_model = user::Entity::find()
.filter(user::Column::Id.eq(id))
.filter(user::Column::TenantId.eq(tenant_id))
.filter(user::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
let current_version = user_model.version;
let mut active: user::ActiveModel = user_model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(current_version + 1);
active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"user.deleted",
tenant_id,
serde_json::json!({ "user_id": id }),
),
db,
)
.await;
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "user.delete", "user").with_resource_id(id),
db,
)
.await;
Ok(())
}
/// Replace all role assignments for a user within a tenant.
pub async fn assign_roles(
user_id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
role_ids: &[Uuid],
db: &sea_orm::DatabaseConnection,
) -> AuthResult<Vec<RoleResp>> {
// 验证用户存在
let _user = user::Entity::find()
.filter(user::Column::Id.eq(user_id))
.filter(user::Column::TenantId.eq(tenant_id))
.filter(user::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
// 验证所有角色存在且属于当前租户
if !role_ids.is_empty() {
let found = role::Entity::find()
.filter(role::Column::Id.is_in(role_ids.iter().copied()))
.filter(role::Column::TenantId.eq(tenant_id))
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if found.len() != role_ids.len() {
return Err(AuthError::Validation(
"部分角色不存在或不属于当前租户".to_string(),
));
}
}
// 删除旧的角色分配
user_role::Entity::delete_many()
.filter(user_role::Column::UserId.eq(user_id))
.filter(user_role::Column::TenantId.eq(tenant_id))
.exec(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
// 创建新的角色分配
let now = chrono::Utc::now();
for &role_id in role_ids {
let assignment = user_role::ActiveModel {
user_id: Set(user_id),
role_id: Set(role_id),
tenant_id: Set(tenant_id),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
assignment
.insert(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
}
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "user.assign_roles", "user")
.with_resource_id(user_id),
db,
)
.await;
Self::fetch_user_role_resps(user_id, tenant_id, db).await
}
/// 批量查询多用户的角色,返回 user_id → RoleResp 映射。
///
/// 使用 3 次固定查询替代 N+1用户角色关联 → 角色 → 分组组装。
async fn fetch_batch_user_role_resps(
user_ids: &[Uuid],
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> HashMap<Uuid, Vec<RoleResp>> {
if user_ids.is_empty() {
return HashMap::new();
}
// 1. 批量查询 user_role 关联
let user_roles: Vec<user_role::Model> = user_role::Entity::find()
.filter(user_role::Column::UserId.is_in(user_ids.iter().copied()))
.filter(user_role::Column::TenantId.eq(tenant_id))
.all(db)
.await
.unwrap_or_default();
let role_ids: Vec<Uuid> = user_roles.iter().map(|ur| ur.role_id).collect();
// 2. 批量查询角色
let roles: Vec<role::Model> = if role_ids.is_empty() {
vec![]
} else {
role::Entity::find()
.filter(role::Column::Id.is_in(role_ids.iter().copied()))
.filter(role::Column::TenantId.eq(tenant_id))
.filter(role::Column::DeletedAt.is_null())
.all(db)
.await
.unwrap_or_default()
};
let role_map: HashMap<Uuid, &role::Model> = roles.iter().map(|r| (r.id, r)).collect();
// 3. 按 user_id 分组
let mut result: HashMap<Uuid, Vec<RoleResp>> = HashMap::new();
for ur in &user_roles {
let resp = role_map
.get(&ur.role_id)
.map(|r| RoleResp {
id: r.id,
name: r.name.clone(),
code: r.code.clone(),
description: r.description.clone(),
is_system: r.is_system,
version: r.version,
})
.unwrap_or_else(|| RoleResp {
id: ur.role_id,
name: "Unknown".into(),
code: "unknown".into(),
description: None,
is_system: false,
version: 0,
});
result.entry(ur.user_id).or_default().push(resp);
}
result
}
/// 管理员重置指定用户密码。
pub async fn reset_password(
user_id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
new_password: &str,
version: i32,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<()> {
// 1. 验证用户存在且属于当前租户
let user_model = user::Entity::find()
.filter(user::Column::Id.eq(user_id))
.filter(user::Column::TenantId.eq(tenant_id))
.filter(user::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
let _next_version = check_version(version, user_model.version)
.map_err(|_| AuthError::Validation("版本冲突,请刷新后重试".to_string()))?;
// 2. 查找密码凭证
let cred = user_credential::Entity::find()
.filter(user_credential::Column::UserId.eq(user_id))
.filter(user_credential::Column::TenantId.eq(tenant_id))
.filter(user_credential::Column::CredentialType.eq("password"))
.filter(user_credential::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.ok_or_else(|| AuthError::Validation("用户凭证不存在".to_string()))?;
// 3. 哈希新密码并更新凭证
let new_hash = password::hash_password(new_password)?;
let cred_version = cred.version;
let mut cred_active: user_credential::ActiveModel = cred.into();
cred_active.credential_data = Set(Some(serde_json::json!({ "hash": new_hash })));
cred_active.updated_at = Set(Utc::now());
cred_active.updated_by = Set(operator_id);
cred_active.version = Set(cred_version + 1);
cred_active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
// 4. 吊销所有 refresh token
super::token_service::TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?;
// 5. 审计日志
audit_service::record(
AuditLog::new(tenant_id, Some(operator_id), "user.reset_password", "user")
.with_resource_id(user_id),
db,
)
.await;
tracing::info!(user_id = %user_id, operator_id = %operator_id, "Password reset by admin");
Ok(())
}
/// Fetch role details for a single user, returning RoleResp DTOs.
async fn fetch_user_role_resps(
user_id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<Vec<RoleResp>> {
let user_roles = user_role::Entity::find()
.filter(user_role::Column::UserId.eq(user_id))
.filter(user_role::Column::TenantId.eq(tenant_id))
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let role_ids: Vec<Uuid> = user_roles.iter().map(|ur| ur.role_id).collect();
if role_ids.is_empty() {
return Ok(vec![]);
}
let roles = role::Entity::find()
.filter(role::Column::Id.is_in(role_ids))
.filter(role::Column::TenantId.eq(tenant_id))
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
Ok(roles
.iter()
.map(|r| RoleResp {
id: r.id,
name: r.name.clone(),
code: r.code.clone(),
description: r.description.clone(),
is_system: r.is_system,
version: r.version,
})
.collect())
}
}
/// Convert a SeaORM user Model and its role DTOs into a UserResp.
pub(crate) fn model_to_resp(m: &user::Model, roles: Vec<RoleResp>) -> UserResp {
UserResp {
id: m.id,
username: m.username.clone(),
email: m.email.clone(),
phone: m.phone.clone(),
display_name: m.display_name.clone(),
avatar_url: m.avatar_url.clone(),
status: m.status.clone(),
roles,
version: m.version,
}
}
#[cfg(test)]
mod tests {
use chrono::Utc;
use uuid::Uuid;
use crate::dto::RoleResp;
use crate::entity::user;
use super::*;
fn make_user_model(
id: Uuid,
tenant_id: Uuid,
username: &str,
status: &str,
version: i32,
) -> user::Model {
user::Model {
id,
tenant_id,
username: username.to_string(),
email: None,
phone: None,
display_name: None,
avatar_url: None,
status: status.to_string(),
last_login_at: None,
created_at: Utc::now(),
updated_at: Utc::now(),
created_by: Uuid::now_v7(),
updated_by: Uuid::now_v7(),
deleted_at: None,
version,
}
}
#[test]
fn model_to_resp_maps_basic_fields() {
let id = Uuid::now_v7();
let tid = Uuid::now_v7();
let m = make_user_model(id, tid, "alice", "active", 1);
let resp = model_to_resp(&m, vec![]);
assert_eq!(resp.id, id);
assert_eq!(resp.username, "alice");
assert_eq!(resp.status, "active");
assert_eq!(resp.version, 1);
assert!(resp.roles.is_empty());
}
#[test]
fn model_to_resp_includes_roles() {
let id = Uuid::now_v7();
let tid = Uuid::now_v7();
let m = make_user_model(id, tid, "bob", "active", 2);
let roles = vec![
RoleResp {
id: Uuid::now_v7(),
name: "管理员".to_string(),
code: "admin".to_string(),
description: None,
is_system: true,
version: 1,
},
RoleResp {
id: Uuid::now_v7(),
name: "用户".to_string(),
code: "user".to_string(),
description: None,
is_system: false,
version: 1,
},
];
let resp = model_to_resp(&m, roles);
assert_eq!(resp.roles.len(), 2);
assert_eq!(resp.roles[0].code, "admin");
assert_eq!(resp.version, 2);
}
}

View File

@@ -0,0 +1,575 @@
use aes::cipher::{BlockDecryptMut, KeyIvInit, block_padding::Pkcs7};
use base64::Engine;
use cbc::Decryptor;
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::Instant;
use tokio::sync::Mutex;
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;
use erp_core::sanitize::sanitize_string;
type Aes128CbcDec = Decryptor<aes::Aes128>;
/// 内存降级缓存Redis 不可用时使用)
struct SessionEntry {
session_key: String,
created_at: Instant,
}
static MEMORY_FALLBACK: LazyLock<Mutex<HashMap<String, SessionEntry>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
const SESSION_TTL_SECS: u64 = 300;
/// Redis key 前缀
const REDIS_KEY_PREFIX: &str = "wechat:session:";
#[derive(Debug, Deserialize)]
struct WechatSessionResp {
openid: Option<String>,
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> {
tracing::info!(
appid = %state.wechat_appid,
code = %code,
"fetch_session 开始"
);
let session = fetch_session(
&state.wechat_appid,
&state.wechat_secret,
code,
state.wechat_dev_mode,
)
.await?;
let openid = session
.openid
.clone()
.ok_or_else(|| AuthError::Validation("微信登录失败:未获取到 openid".to_string()))?;
// 缓存 session_keyRedis 优先,内存降级)
if let Some(sk) = &session.session_key
&& let Err(e) = Self::store_session_key_redis(&state.redis, &openid, sk).await
{
tracing::warn!(openid = %openid, error = %e, "Redis session_key 存储失败,降级内存");
let mut cache = MEMORY_FALLBACK.lock().await;
cache.insert(
openid.clone(),
SessionEntry {
session_key: sk.clone(),
created_at: Instant::now(),
},
);
}
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> {
// Dev 模式mock session_key 无法解密真实微信加密数据,直接使用 mock 手机号
let phone = if state.wechat_dev_mode {
let hash = openid
.bytes()
.fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u32));
let suffix = hash % 10000;
tracing::warn!(%openid, mock_phone = format!("1380000{suffix:04}"), "开发模式:跳过手机号解密,使用 mock 手机号");
format!("1380000{suffix:04}")
} else {
let session_key = Self::get_session_key(&state.redis, openid).await?;
decrypt_phone_number(&session_key, encrypted_data, iv)?
};
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, &state.crypto).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.clone())),
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,
crypto: &erp_core::crypto::PiiCrypto,
) -> 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(sanitize_string(&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()))?;
// 自动分配 patient 角色
Self::assign_patient_role(db, tenant_id, user_id).await?;
// 自动创建或关联 patient 记录
Self::ensure_patient_record(db, tenant_id, user_id, phone, crypto).await?;
Ok(user_id)
}
async fn assign_patient_role(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
user_id: Uuid,
) -> AuthResult<()> {
use crate::entity::role;
use crate::entity::user_role;
let patient_role = role::Entity::find()
.filter(role::Column::Code.eq("patient"))
.filter(role::Column::TenantId.eq(tenant_id))
.filter(role::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::DbError(e.to_string()))?;
if let Some(r) = patient_role {
let now = Utc::now();
let ur = user_role::ActiveModel {
user_id: Set(user_id),
role_id: Set(r.id),
tenant_id: Set(tenant_id),
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),
};
ur.insert(db)
.await
.map_err(|e| AuthError::DbError(e.to_string()))?;
tracing::info!(%user_id, role_id = %r.id, "已为新用户分配 patient 角色");
} else {
tracing::warn!(%tenant_id, "patient 角色不存在,跳过角色分配");
}
Ok(())
}
/// 自动创建或关联 patient 记录。
///
/// 1. 如果已有 user_id 关联的 patient → 跳过
/// 2. 如果手机号盲索引匹配到未绑定的已有患者 → 合并(关联 user_id
/// 3. 否则 → 创建新的 patient 记录
async fn ensure_patient_record(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
user_id: Uuid,
phone: &str,
crypto: &erp_core::crypto::PiiCrypto,
) -> AuthResult<()> {
use sea_orm::{ConnectionTrait, Statement};
// 使用 raw SQL 避免跨 crate 依赖 erp-health 的 entity
let result: Option<sea_orm::QueryResult> = db
.query_one(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
"SELECT id FROM patient WHERE user_id = $1 AND tenant_id = $2 AND deleted_at IS NULL LIMIT 1",
[user_id.into(), tenant_id.into()],
))
.await
.map_err(|e| AuthError::DbError(e.to_string()))?;
if result.is_some() {
tracing::debug!(%user_id, "patient 记录已存在,跳过创建");
return Ok(());
}
// 智能合并:用手机号盲索引查找未绑定的已有患者(管理员/护士建档)
let phone_hash = erp_core::crypto::hmac_hash(crypto.hmac_key(), phone);
let blind_match: Option<sea_orm::QueryResult> = db
.query_one(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
r#"SELECT bi.entity_id AS patient_id
FROM blind_index bi
JOIN patient p ON p.id = bi.entity_id AND p.tenant_id = $2 AND p.deleted_at IS NULL
WHERE bi.entity_type = 'patient'
AND bi.field_name = 'emergency_contact_phone'
AND bi.blind_hash = $1
AND bi.tenant_id = $2
AND p.user_id IS NULL
LIMIT 1"#,
[phone_hash.as_str().into(), tenant_id.into()],
))
.await
.map_err(|e| AuthError::DbError(e.to_string()))?;
if let Some(row) = blind_match {
let patient_id: Uuid = row
.try_get("", "patient_id")
.map_err(|e| AuthError::DbError(format!("blind_index parse: {}", e)))?;
db.execute(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
"UPDATE patient SET user_id = $1, updated_at = NOW(), updated_by = $1 WHERE id = $2 AND user_id IS NULL",
[user_id.into(), patient_id.into()],
))
.await
.map_err(|e| AuthError::DbError(e.to_string()))?;
tracing::info!(%user_id, %patient_id, "手机号盲索引合并 patient");
return Ok(());
}
let suffix = &phone[phone.len().saturating_sub(4)..];
let patient_id = Uuid::now_v7();
let now = Utc::now();
db.execute(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
r#"INSERT INTO patient (id, tenant_id, user_id, name, gender, status, verification_status, source, created_at, updated_at, created_by, updated_by, deleted_at, version)
VALUES ($1, $2, $3, $4, NULL, 'active', 'pending', 'wechat_miniprogram', $5, $5, $3, $3, NULL, 1)
ON CONFLICT DO NOTHING"#,
[
patient_id.into(),
tenant_id.into(),
user_id.into(),
sanitize_string(&format!("微信用户{}", suffix)).into(),
now.into(),
],
))
.await
.map_err(|e| AuthError::DbError(e.to_string()))?;
tracing::info!(%user_id, %patient_id, "已自动创建 patient 记录");
Ok(())
}
async fn store_session_key_redis(
redis: &Option<redis::Client>,
openid: &str,
session_key: &str,
) -> AuthResult<()> {
let client = redis
.as_ref()
.ok_or_else(|| AuthError::DbError("Redis 未配置".into()))?;
let mut conn = client
.get_multiplexed_async_connection()
.await
.map_err(|e| AuthError::DbError(format!("Redis 连接失败: {e}")))?;
let key = format!("{}{}", REDIS_KEY_PREFIX, openid);
redis::cmd("SET")
.arg(&key)
.arg(session_key)
.arg("EX")
.arg(SESSION_TTL_SECS)
.query_async::<String>(&mut conn)
.await
.map_err(|e| AuthError::DbError(format!("Redis SET 失败: {e}")))?;
Ok(())
}
async fn get_session_key(redis: &Option<redis::Client>, openid: &str) -> AuthResult<String> {
// 1. 尝试 Redis
if let Some(client) = redis
&& let Ok(mut conn) = client.get_multiplexed_async_connection().await
{
let key = format!("{}{}", REDIS_KEY_PREFIX, openid);
let result: Option<String> = redis::cmd("GETDEL")
.arg(&key)
.query_async::<Option<String>>(&mut conn)
.await
.unwrap_or(None);
if let Some(sk) = result {
return Ok(sk);
}
}
// 2. 降级到内存
let mut cache = MEMORY_FALLBACK.lock().await;
if let Some(entry) = cache.get(openid) {
if entry.created_at.elapsed().as_secs() < SESSION_TTL_SECS {
let sk = entry.session_key.clone();
cache.remove(openid);
return Ok(sk);
}
cache.remove(openid);
}
Err(AuthError::Validation(
"未找到 session_key请重新登录".to_string(),
))
}
}
/// AES-128-CBC 解密微信手机号
fn decrypt_phone_number(session_key: &str, encrypted_data: &str, iv: &str) -> AuthResult<String> {
let engine = base64::engine::general_purpose::STANDARD;
let key_bytes = engine
.decode(session_key)
.map_err(|e| AuthError::Validation(format!("session_key base64 解码失败: {}", e)))?;
let iv_bytes = engine
.decode(iv)
.map_err(|e| AuthError::Validation(format!("iv base64 解码失败: {}", e)))?;
let ciphertext = engine
.decode(encrypted_data)
.map_err(|e| AuthError::Validation(format!("encrypted_data base64 解码失败: {}", e)))?;
if key_bytes.len() != 16 {
return Err(AuthError::Validation("session_key 长度不正确".to_string()));
}
if iv_bytes.len() != 16 {
return Err(AuthError::Validation("iv 长度不正确".to_string()));
}
let decryptor = Aes128CbcDec::new_from_slices(&key_bytes, &iv_bytes)
.map_err(|e| AuthError::Validation(format!("AES 初始化失败: {}", e)))?;
let mut buf = ciphertext;
let decrypted = decryptor
.decrypt_padded_mut::<Pkcs7>(&mut buf)
.map_err(|e| AuthError::Validation(format!("AES 解密失败: {}", e)))?;
let plaintext = String::from_utf8(decrypted.to_vec())
.map_err(|_| AuthError::Validation("解密结果非 UTF-8".to_string()))?;
// 微信返回的 JSON 包含 watermark 等字段,提取 phone_number
let info: serde_json::Value = serde_json::from_str(&plaintext)
.map_err(|e| AuthError::Validation(format!("解密结果 JSON 解析失败: {}", e)))?;
info.get("phoneNumber")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| AuthError::Validation("解密结果中无 phoneNumber".to_string()))
}
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,
dev_mode: bool,
) -> AuthResult<WechatSessionResp> {
// 开发模式降级:跳过 jscode2session为 DevTools 模拟器生成确定性 mock openid
if dev_mode {
let mock_openid = format!("dev_mock_{}", &code[..8.min(code.len())]);
tracing::warn!(%mock_openid, "开发模式:使用 mock openid跳过 jscode2session");
return Ok(WechatSessionResp {
openid: Some(mock_openid),
session_key: Some("dev_mock_session_key".to_string()),
unionid: None,
errcode: None,
errmsg: None,
});
}
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
&& errcode != 0
{
let msg = session.errmsg.clone().unwrap_or_default();
tracing::error!(errcode, errmsg = %msg, "微信 jscode2session 返回错误");
return Err(AuthError::Validation(format!(
"微信登录失败 ({}): {}",
errcode, msg
)));
}
tracing::info!(
has_openid = session.openid.is_some(),
has_session_key = session.session_key.is_some(),
"微信 jscode2session 成功"
);
Ok(session)
}