This commit is contained in:
2025-08-28 00:55:35 +08:00
commit 410f54a65e
93 changed files with 9863 additions and 0 deletions

10
backend/.env Normal file
View File

@ -0,0 +1,10 @@
RUST_LOG=info,udmin=debug
APP_ENV=development
APP_HOST=0.0.0.0
APP_PORT=8080
DB_URL=mysql://root:123456@127.0.0.1:3306/udmin
JWT_SECRET=dev_secret_change_me
JWT_ISS=udmin
JWT_ACCESS_EXP_SECS=1800
JWT_REFRESH_EXP_SECS=1209600
CORS_ALLOW_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175

10
backend/.env.example Normal file
View File

@ -0,0 +1,10 @@
RUST_LOG=info,udmin=debug
APP_ENV=development
APP_HOST=0.0.0.0
APP_PORT=8080
DB_URL=mysql://root:123456@127.0.0.1:3306/udmin
JWT_SECRET=please_change_me
JWT_ISS=udmin
JWT_ACCESS_EXP_SECS=1800
JWT_REFRESH_EXP_SECS=1209600
CORS_ALLOW_ORIGINS=http://localhost:5173

4144
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

39
backend/Cargo.toml Normal file
View File

@ -0,0 +1,39 @@
[package]
name = "udmin"
version = "0.1.0"
edition = "2024" # ✅ 升级到最新 Rust Edition
[dependencies]
axum = "0.8.4"
tokio = { version = "1.47.1", features = ["full"] }
tower = "0.5.0"
tower-http = { version = "0.6.6", features = ["cors", "trace"] }
hyper = { version = "1" }
bytes = "1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_with = "3.14.0"
sea-orm = { version = "1.1.14", features = ["sqlx-mysql", "sqlx-sqlite", "runtime-tokio-rustls", "macros"] }
jsonwebtoken = "9.3.1"
argon2 = "0.5.3" # 或升级到 3.0.0(注意 API 可能不兼容)
uuid = { version = "1.11.0", features = ["serde", "v4"] }
chrono = { version = "0.4", features = ["serde"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
config = "0.14"
dotenvy = "0.15"
thiserror = "1.0"
anyhow = "1.0"
once_cell = "1.19.0"
utoipa = { version = "5.4.0", features = ["axum_extras", "chrono", "uuid"] }
utoipa-swagger-ui = { version = "6.0.0", features = ["axum"] }
sha2 = "0.10"
rand = "0.8"
async-trait = "0.1"
[dependencies.migration]
path = "migration"
[profile.release]
lto = true
codegen-units = 1

5
backend/cookies.txt Normal file
View File

@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1756996792 refresh_token eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsInVpZCI6MSwiaXNzIjoidWRtaW4iLCJleHAiOjE3NTY5OTY3OTIsInR5cCI6InJlZnJlc2gifQ.XllW1VXSni2F548WFm1tjG3gwWPou_QE1JRabz0RZTE

View File

@ -0,0 +1,10 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
[dependencies]
sea-orm-migration = { version = "1.1.14" }
argon2 = "0.5"
chrono = { version = "0.4", features = ["serde"] }
rand = "0.8"

View File

@ -0,0 +1,34 @@
pub use sea_orm_migration::prelude::*;
mod m20220101_000001_create_users;
mod m20220101_000002_create_refresh_tokens;
mod m20220101_000003_create_roles;
mod m20220101_000004_create_permissions;
mod m20220101_000005_create_menus;
mod m20220101_000006_create_rbac_relations;
mod m20220101_000007_create_departments;
mod m20220101_000008_add_keep_alive_to_menus;
mod m20220101_000009_create_request_logs;
// 新增岗位与用户岗位关联
mod m20220101_000010_create_positions;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20220101_000001_create_users::Migration),
Box::new(m20220101_000002_create_refresh_tokens::Migration),
Box::new(m20220101_000003_create_roles::Migration),
Box::new(m20220101_000004_create_permissions::Migration),
Box::new(m20220101_000005_create_menus::Migration),
Box::new(m20220101_000006_create_rbac_relations::Migration),
Box::new(m20220101_000007_create_departments::Migration),
Box::new(m20220101_000008_add_keep_alive_to_menus::Migration),
Box::new(m20220101_000009_create_request_logs::Migration),
// 注册岗位迁移
Box::new(m20220101_000010_create_positions::Migration),
]
}
}

View File

@ -0,0 +1,65 @@
use sea_orm_migration::prelude::*;
use sea_orm_migration::sea_orm::DatabaseBackend;
use argon2::{Argon2, password_hash::{SaltString, PasswordHasher}};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Users::Table)
.if_not_exists()
.col(ColumnDef::new(Users::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(Users::Username).string().not_null().unique_key())
.col(ColumnDef::new(Users::PasswordHash).string().not_null())
.col(ColumnDef::new(Users::Nickname).string().null())
.col(ColumnDef::new(Users::Status).integer().not_null().default(1))
.col(ColumnDef::new(Users::CreatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Users::UpdatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.to_owned()
)
.await?;
// seed admin user (cross-DB)
let salt = SaltString::generate(&mut rand::thread_rng());
let hash = Argon2::default().hash_password("Admin@123".as_bytes(), &salt).unwrap().to_string();
let backend = manager.get_database_backend();
let conn = manager.get_connection();
match backend {
DatabaseBackend::MySql => {
conn.execute_unprepared(&format!(
"INSERT INTO users (username, password_hash, nickname, status) VALUES ('admin', '{}', 'Administrator', 1) ON DUPLICATE KEY UPDATE username=username",
hash
))
.await?;
}
DatabaseBackend::Postgres => {
conn.execute_unprepared(&format!(
"INSERT INTO users (username, password_hash, nickname, status) VALUES ('admin', '{}', 'Administrator', 1) ON CONFLICT (username) DO NOTHING",
hash
))
.await?;
}
DatabaseBackend::Sqlite => {
conn.execute_unprepared(&format!(
"INSERT OR IGNORE INTO users (username, password_hash, nickname, status) VALUES ('admin', '{}', 'Administrator', 1)",
hash
))
.await?;
}
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(Users::Table).to_owned()).await
}
}
#[derive(Iden)]
enum Users { Table, Id, Username, PasswordHash, Nickname, Status, CreatedAt, UpdatedAt }

View File

@ -0,0 +1,31 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(RefreshTokens::Table)
.if_not_exists()
.col(ColumnDef::new(RefreshTokens::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(RefreshTokens::UserId).big_integer().not_null())
.col(ColumnDef::new(RefreshTokens::TokenHash).string().not_null().unique_key())
.col(ColumnDef::new(RefreshTokens::ExpiresAt).timestamp().not_null())
.col(ColumnDef::new(RefreshTokens::CreatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.to_owned()
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(RefreshTokens::Table).to_owned()).await
}
}
#[derive(Iden)]
enum RefreshTokens { Table, Id, UserId, TokenHash, ExpiresAt, CreatedAt }

View File

@ -0,0 +1,33 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Roles::Table)
.if_not_exists()
.col(ColumnDef::new(Roles::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(Roles::Name).string().not_null())
.col(ColumnDef::new(Roles::Code).string().not_null().unique_key())
.col(ColumnDef::new(Roles::Description).string().null())
.col(ColumnDef::new(Roles::Status).integer().not_null().default(1))
.col(ColumnDef::new(Roles::CreatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Roles::UpdatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.to_owned()
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(Roles::Table).to_owned()).await
}
}
#[derive(Iden)]
enum Roles { Table, Id, Name, Code, Description, Status, CreatedAt, UpdatedAt }

View File

@ -0,0 +1,17 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
// 占位迁移:原始迁移文件缺失,但已应用。此处为空实现以满足迁移链完整性。
Ok(())
}
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
// 占位迁移,无需回滚实际变更
Ok(())
}
}

View File

@ -0,0 +1,39 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Menus::Table)
.if_not_exists()
.col(ColumnDef::new(Menus::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(Menus::ParentId).big_integer().null())
.col(ColumnDef::new(Menus::Name).string().not_null())
.col(ColumnDef::new(Menus::Path).string().null())
.col(ColumnDef::new(Menus::Component).string().null())
.col(ColumnDef::new(Menus::Type).integer().not_null())
.col(ColumnDef::new(Menus::Icon).string().null())
.col(ColumnDef::new(Menus::OrderNo).integer().not_null().default(0))
.col(ColumnDef::new(Menus::Visible).boolean().not_null().default(true))
.col(ColumnDef::new(Menus::Status).integer().not_null().default(1))
.col(ColumnDef::new(Menus::Perms).string().null())
.col(ColumnDef::new(Menus::CreatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Menus::UpdatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.to_owned()
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(Menus::Table).to_owned()).await
}
}
#[derive(Iden)]
enum Menus { Table, Id, ParentId, Name, Path, Component, Type, Icon, OrderNo, Visible, Status, Perms, CreatedAt, UpdatedAt }

View File

@ -0,0 +1,46 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// user_roles
manager.create_table(
Table::create()
.table(UserRoles::Table)
.if_not_exists()
.col(ColumnDef::new(UserRoles::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(UserRoles::UserId).big_integer().not_null())
.col(ColumnDef::new(UserRoles::RoleId).big_integer().not_null())
.index(Index::create().name("idx_user_role_uniq").col(UserRoles::UserId).col(UserRoles::RoleId).unique())
.to_owned()
).await?;
// role_menus
manager.create_table(
Table::create()
.table(RoleMenus::Table)
.if_not_exists()
.col(ColumnDef::new(RoleMenus::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(RoleMenus::RoleId).big_integer().not_null())
.col(ColumnDef::new(RoleMenus::MenuId).big_integer().not_null())
.index(Index::create().name("idx_role_menu_uniq").col(RoleMenus::RoleId).col(RoleMenus::MenuId).unique())
.to_owned()
).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(RoleMenus::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(UserRoles::Table).to_owned()).await
}
}
#[derive(Iden)]
enum UserRoles { Table, Id, UserId, RoleId }
#[derive(Iden)]
enum RoleMenus { Table, Id, RoleId, MenuId }

View File

@ -0,0 +1,53 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// departments
manager
.create_table(
Table::create()
.table(Departments::Table)
.if_not_exists()
.col(ColumnDef::new(Departments::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(Departments::ParentId).big_integer().null())
.col(ColumnDef::new(Departments::Name).string().not_null())
.col(ColumnDef::new(Departments::OrderNo).integer().not_null().default(0))
.col(ColumnDef::new(Departments::Status).integer().not_null().default(1))
.col(ColumnDef::new(Departments::CreatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Departments::UpdatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.to_owned()
)
.await?;
// user_departments
manager
.create_table(
Table::create()
.table(UserDepartments::Table)
.if_not_exists()
.col(ColumnDef::new(UserDepartments::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(UserDepartments::UserId).big_integer().not_null())
.col(ColumnDef::new(UserDepartments::DepartmentId).big_integer().not_null())
.index(Index::create().name("idx_user_department_uniq").col(UserDepartments::UserId).col(UserDepartments::DepartmentId).unique())
.to_owned()
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(UserDepartments::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(Departments::Table).to_owned()).await
}
}
#[derive(Iden)]
enum Departments { Table, Id, ParentId, Name, OrderNo, Status, CreatedAt, UpdatedAt }
#[derive(Iden)]
enum UserDepartments { Table, Id, UserId, DepartmentId }

View File

@ -0,0 +1,35 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Menus::Table)
.add_column(ColumnDef::new(Menus::KeepAlive).boolean().not_null().default(true))
.to_owned()
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Menus::Table)
.drop_column(Menus::KeepAlive)
.to_owned()
)
.await
}
}
#[derive(DeriveIden)]
enum Menus {
Table,
KeepAlive,
}

View File

@ -0,0 +1,49 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(RequestLogs::Table)
.if_not_exists()
.col(ColumnDef::new(RequestLogs::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(RequestLogs::Path).string().not_null())
.col(ColumnDef::new(RequestLogs::Method).string().not_null())
.col(ColumnDef::new(RequestLogs::RequestParams).text().null())
.col(ColumnDef::new(RequestLogs::ResponseParams).text().null())
.col(ColumnDef::new(RequestLogs::StatusCode).integer().not_null().default(200))
.col(ColumnDef::new(RequestLogs::UserId).big_integer().null())
.col(ColumnDef::new(RequestLogs::Username).string().null())
.col(ColumnDef::new(RequestLogs::RequestTime).timestamp().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(RequestLogs::DurationMs).big_integer().not_null().default(0))
.col(ColumnDef::new(RequestLogs::CreatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.to_owned()
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(RequestLogs::Table).to_owned()).await
}
}
#[derive(Iden)]
enum RequestLogs {
Table,
Id,
Path,
Method,
RequestParams,
ResponseParams,
StatusCode,
UserId,
Username,
RequestTime,
DurationMs,
CreatedAt,
}

View File

@ -0,0 +1,52 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// positions
manager
.create_table(
Table::create()
.table(Positions::Table)
.if_not_exists()
.col(ColumnDef::new(Positions::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(Positions::Name).string().not_null())
.col(ColumnDef::new(Positions::Code).string().not_null().unique_key())
.col(ColumnDef::new(Positions::Description).string().null())
.col(ColumnDef::new(Positions::Status).integer().not_null().default(1))
.col(ColumnDef::new(Positions::OrderNo).integer().not_null().default(0))
.col(ColumnDef::new(Positions::CreatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Positions::UpdatedAt).timestamp().not_null().default(Expr::current_timestamp()))
.to_owned()
)
.await?;
// user_positions
manager.create_table(
Table::create()
.table(UserPositions::Table)
.if_not_exists()
.col(ColumnDef::new(UserPositions::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(UserPositions::UserId).big_integer().not_null())
.col(ColumnDef::new(UserPositions::PositionId).big_integer().not_null())
.index(Index::create().name("idx_user_position_uniq").col(UserPositions::UserId).col(UserPositions::PositionId).unique())
.to_owned()
).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(UserPositions::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(Positions::Table).to_owned()).await
}
}
#[derive(Iden)]
enum Positions { Table, Id, Name, Code, Description, Status, OrderNo, CreatedAt, UpdatedAt }
#[derive(Iden)]
enum UserPositions { Table, Id, UserId, PositionId }

15
backend/src/db.rs Normal file
View File

@ -0,0 +1,15 @@
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
use std::time::Duration;
pub type Db = DatabaseConnection;
pub async fn init_db() -> anyhow::Result<Db> {
let mut opt = ConnectOptions::new(std::env::var("DB_URL")?);
opt.max_connections(20)
.min_connections(5)
.connect_timeout(Duration::from_secs(8))
.idle_timeout(Duration::from_secs(600))
.sqlx_logging(false);
let conn = Database::connect(opt).await?;
Ok(conn)
}

26
backend/src/error.rs Normal file
View File

@ -0,0 +1,26 @@
use axum::{http::StatusCode, response::IntoResponse, Json};
use crate::response::ApiResponse;
#[derive(thiserror::Error, Debug)]
pub enum AppError {
#[error("unauthorized")] Unauthorized,
#[error("forbidden")] Forbidden,
#[error("bad request: {0}")] BadRequest(String),
#[error("not found")] NotFound,
#[error(transparent)] Db(#[from] sea_orm::DbErr),
#[error(transparent)] Jwt(#[from] jsonwebtoken::errors::Error),
#[error(transparent)] Anyhow(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let (status, code, msg) = match &self {
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, 401, "unauthorized".to_string()),
AppError::Forbidden => (StatusCode::FORBIDDEN, 403, "forbidden".to_string()),
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, 400, m.clone()),
AppError::NotFound => (StatusCode::NOT_FOUND, 404, "not found".into()),
_ => (StatusCode::INTERNAL_SERVER_ERROR, 500, "internal error".into()),
};
(status, Json(ApiResponse::<serde_json::Value> { code, message: msg, data: None })).into_response()
}
}

75
backend/src/main.rs Normal file
View File

@ -0,0 +1,75 @@
mod db;
mod response;
mod error;
pub mod middlewares;
pub mod models;
pub mod services;
pub mod routes;
pub mod utils;
use axum::Router;
use axum::http::{HeaderValue, Method};
use tower_http::cors::{CorsLayer, Any, AllowOrigin};
use migration::MigratorTrait;
use axum::middleware;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::from_default_env()).init();
let db = db::init_db().await?;
// run migrations
migration::Migrator::up(&db, None).await.expect("migration up");
let allow_origins = std::env::var("CORS_ALLOW_ORIGINS").unwrap_or_else(|_| "http://localhost:5173".into());
let origin_values: Vec<HeaderValue> = allow_origins
.split(',')
.filter_map(|s| HeaderValue::from_str(s.trim()).ok())
.collect();
let allowed_methods = [
Method::GET,
Method::POST,
Method::PUT,
Method::PATCH,
Method::DELETE,
Method::OPTIONS,
];
let cors = if origin_values.is_empty() {
// 当允许任意来源时,不能与 allow_credentials(true) 同时使用
CorsLayer::new()
.allow_origin(Any)
.allow_methods(allowed_methods.clone())
.allow_headers([
axum::http::header::ACCEPT,
axum::http::header::CONTENT_TYPE,
axum::http::header::AUTHORIZATION,
])
.allow_credentials(false)
} else {
CorsLayer::new()
.allow_origin(AllowOrigin::list(origin_values))
.allow_methods(allowed_methods)
.allow_headers([
axum::http::header::ACCEPT,
axum::http::header::CONTENT_TYPE,
axum::http::header::AUTHORIZATION,
])
.allow_credentials(true)
};
let api = routes::api_router().with_state(db.clone());
let app = Router::new()
.nest("/api", api)
.layer(cors)
.layer(middleware::from_fn_with_state(db.clone(), middlewares::logging::request_logger));
let addr = format!("{}:{}", std::env::var("APP_HOST").unwrap_or("0.0.0.0".into()), std::env::var("APP_PORT").unwrap_or("8080".into()));
tracing::info!("listening on {}", addr);
axum::serve(tokio::net::TcpListener::bind(addr).await?, app).await?;
Ok(())
}

View File

@ -0,0 +1,56 @@
use axum::{http::HeaderMap, http::header::AUTHORIZATION};
use chrono::{Utc, Duration as ChronoDuration};
use jsonwebtoken::{EncodingKey, DecodingKey, Header, Validation};
use serde::{Serialize, Deserialize};
use crate::error::AppError;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: String,
pub uid: i64,
pub iss: String,
pub exp: usize,
pub typ: String, // access or refresh
}
pub fn encode_token(claims: &Claims, secret: &str) -> Result<String, AppError> {
let key = EncodingKey::from_secret(secret.as_bytes());
Ok(jsonwebtoken::encode(&Header::default(), claims, &key)?)
}
pub fn decode_token(token: &str, secret: &str) -> Result<Claims, AppError> {
let key = DecodingKey::from_secret(secret.as_bytes());
let data = jsonwebtoken::decode::<Claims>(token, &key, &Validation::default())?;
Ok(data.claims)
}
#[derive(Clone, Debug)]
pub struct AuthUser { pub uid: i64, pub username: String }
impl<S> axum::extract::FromRequestParts<S> for AuthUser where S: Send + Sync + 'static {
type Rejection = AppError;
async fn from_request_parts(parts: &mut axum::http::request::Parts, _state: &S) -> Result<Self, Self::Rejection> {
let headers: &HeaderMap = &parts.headers;
let auth = headers.get(AUTHORIZATION).ok_or(AppError::Unauthorized)?;
let auth = auth.to_str().map_err(|_| AppError::Unauthorized)?;
let token = auth.strip_prefix("Bearer ").ok_or(AppError::Unauthorized)?;
let secret = std::env::var("JWT_SECRET").map_err(|_| AppError::Unauthorized)?;
let claims = decode_token(token, &secret)?;
if claims.typ != "access" { return Err(AppError::Unauthorized); }
Ok(AuthUser { uid: claims.uid, username: claims.sub })
}
}
pub fn new_access_claims(uid: i64, username: &str) -> Claims {
let iss = std::env::var("JWT_ISS").unwrap_or_else(|_| "udmin".into());
let exp_secs: i64 = std::env::var("JWT_ACCESS_EXP_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(1800);
let exp = (Utc::now() + ChronoDuration::seconds(exp_secs)).timestamp() as usize;
Claims { sub: username.to_string(), uid, iss, exp, typ: "access".into() }
}
pub fn new_refresh_claims(uid: i64, username: &str) -> Claims {
let iss = std::env::var("JWT_ISS").unwrap_or_else(|_| "udmin".into());
let exp_secs: i64 = std::env::var("JWT_REFRESH_EXP_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(1209600);
let exp = (Utc::now() + ChronoDuration::seconds(exp_secs)).timestamp() as usize;
Claims { sub: username.to_string(), uid, iss, exp, typ: "refresh".into() }
}

View File

@ -0,0 +1,73 @@
use axum::{extract::State, http::{Request, HeaderMap, header::AUTHORIZATION}, middleware::Next, response::Response, body::Body};
use chrono::{Utc, FixedOffset};
use std::time::Instant;
use crate::{db::Db, services::log_service::{self, CreateLogInput}};
const BODY_LIMIT: usize = 256 * 1024; // 256 KiB 上限
fn parse_user(headers: &HeaderMap) -> (Option<i64>, Option<String>) {
if let Some(auth) = headers.get(AUTHORIZATION) {
if let Ok(s) = auth.to_str() {
if let Some(token) = s.strip_prefix("Bearer ") {
if let Ok(secret) = std::env::var("JWT_SECRET") {
if let Ok(claims) = crate::middlewares::jwt::decode_token(token, &secret) {
return (Some(claims.uid), Some(claims.sub));
}
}
}
}
}
(None, None)
}
pub async fn request_logger(State(db): State<Db>, req: Request<Body>, next: Next) -> Response {
// 跳过 GET 请求的日志记录
if *req.method() == axum::http::Method::GET {
return next.run(req).await;
}
let start = Instant::now();
let request_time = Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap());
let path = req.uri().path().to_string();
let method = req.method().to_string();
let (user_id, username) = parse_user(req.headers());
// capture query and body up to a limit
let query = req.uri().query().unwrap_or("");
let mut req_params = if !query.is_empty() { format!("?{}", query) } else { String::new() };
// 读取并还原请求 Body限制大小
let (parts, body) = req.into_parts();
let body_bytes = match axum::body::to_bytes(body, BODY_LIMIT).await { Ok(b) => b, Err(_) => axum::body::Bytes::new() };
if !body_bytes.is_empty() {
let mut s = String::from_utf8_lossy(&body_bytes).to_string();
if s.len() > 4096 { s.truncate(4096); }
if !req_params.is_empty() { req_params.push_str(" "); }
req_params.push_str(&s);
}
let req = Request::from_parts(parts, Body::from(body_bytes.clone()));
let res = next.run(req).await;
// capture response body
let status = res.status().as_u16() as i32;
let duration_ms = start.elapsed().as_millis() as i64;
let (parts, body) = res.into_parts();
let resp_bytes = match axum::body::to_bytes(body, BODY_LIMIT).await { Ok(b) => b, Err(_) => axum::body::Bytes::new() };
let mut resp_str = String::from_utf8_lossy(&resp_bytes).to_string();
if resp_str.len() > 4096 { resp_str.truncate(4096); }
let res = Response::from_parts(parts, Body::from(resp_bytes));
let _ = log_service::create(&db, CreateLogInput {
path,
method,
request_params: if req_params.is_empty() { None } else { Some(req_params) },
response_params: if resp_str.is_empty() { None } else { Some(resp_str) },
status_code: status,
user_id,
username,
request_time,
duration_ms,
}).await;
res
}

View File

@ -0,0 +1,2 @@
pub mod jwt;
pub mod logging;

View File

@ -0,0 +1,19 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, serde::Serialize, serde::Deserialize)]
#[sea_orm(table_name = "departments")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub parent_id: Option<i64>,
pub name: String,
pub order_no: i32,
pub status: i32,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,27 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, serde::Serialize, serde::Deserialize)]
#[sea_orm(table_name = "menus")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub parent_id: Option<i64>,
pub name: String,
pub path: Option<String>,
pub component: Option<String>,
pub r#type: i32,
pub icon: Option<String>,
pub order_no: i32,
pub visible: bool,
pub status: i32,
// 新增:是否缓存页面
pub keep_alive: bool,
pub perms: Option<String>,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

12
backend/src/models/mod.rs Normal file
View File

@ -0,0 +1,12 @@
pub mod user;
pub mod refresh_token;
pub mod role;
pub mod menu;
pub mod user_role;
pub mod role_menu;
pub mod department;
pub mod user_department;
pub mod request_log;
// 新增岗位与用户岗位关联模型
pub mod position;
pub mod user_position;

View File

@ -0,0 +1,21 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, serde::Serialize, serde::Deserialize)]
#[sea_orm(table_name = "positions")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub name: String,
#[sea_orm(unique)]
pub code: String,
pub description: Option<String>,
pub status: i32,
pub order_no: i32,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,17 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "refresh_tokens")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub user_id: i64,
pub token_hash: String,
pub expires_at: DateTimeWithTimeZone,
pub created_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,23 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, serde::Serialize, serde::Deserialize)]
#[sea_orm(table_name = "request_logs")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub path: String,
pub method: String,
pub request_params: Option<String>,
pub response_params: Option<String>,
pub status_code: i32,
pub user_id: Option<i64>,
pub username: Option<String>,
pub request_time: DateTimeWithTimeZone,
pub duration_ms: i64,
pub created_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,20 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, serde::Serialize, serde::Deserialize)]
#[sea_orm(table_name = "roles")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub name: String,
#[sea_orm(unique)]
pub code: String,
pub description: Option<String>,
pub status: i32,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,15 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "role_menus")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub role_id: i64,
pub menu_id: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,20 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, serde::Serialize, serde::Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
#[sea_orm(unique)]
pub username: String,
pub password_hash: String,
pub nickname: Option<String>,
pub status: i32,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,15 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user_departments")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub user_id: i64,
pub department_id: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,15 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user_positions")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub user_id: i64,
pub position_id: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,15 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user_roles")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub user_id: i64,
pub role_id: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

13
backend/src/response.rs Normal file
View File

@ -0,0 +1,13 @@
use serde::Serialize;
#[derive(Serialize)]
pub struct ApiResponse<T> {
pub code: i32,
pub message: String,
pub data: Option<T>,
}
impl<T> ApiResponse<T> {
pub fn ok(data: T) -> Self { Self { code: 0, message: "ok".into(), data: Some(data) } }
pub fn err(code: i32, message: impl Into<String>) -> Self { Self { code, message: message.into(), data: None } }
}

View File

@ -0,0 +1,65 @@
use axum::{Router, routing::{post, get}, Json, extract::State};
use axum::http::{HeaderMap, HeaderValue};
use serde::{Serialize, Deserialize};
use crate::{db::Db, response::ApiResponse, error::AppError};
use crate::services::{auth_service, role_service, menu_service};
use crate::middlewares::jwt::{AuthUser, decode_token};
use crate::models::user;
#[derive(Deserialize)]
pub struct LoginReq { pub username: String, pub password: String }
#[derive(Serialize)]
pub struct UserInfo { pub id: i64, pub username: String, pub nickname: Option<String>, pub status: i32 }
impl From<user::Model> for UserInfo {
fn from(m: user::Model) -> Self { Self { id: m.id, username: m.username, nickname: m.nickname, status: m.status } }
}
#[derive(Serialize)]
pub struct LoginResp { pub access_token: String, pub user: UserInfo }
pub fn router() -> Router<Db> {
Router::new()
.route("/login", post(login))
.route("/logout", post(logout))
.route("/refresh", get(refresh))
.route("/menus", get(current_user_menus))
}
pub async fn login(State(db): State<Db>, Json(req): Json<LoginReq>) -> Result<(HeaderMap, Json<ApiResponse<LoginResp>>), AppError> {
let res = auth_service::login(&db, req.username, req.password).await?;
let mut headers = HeaderMap::new();
let cookie = format!("refresh_token={}; HttpOnly; SameSite=Lax; Path=/; Max-Age=1209600", res.refresh);
headers.insert(axum::http::header::SET_COOKIE, HeaderValue::from_str(&cookie).unwrap());
Ok((headers, Json(ApiResponse::ok(LoginResp { access_token: res.access, user: res.user.into() }))))
}
pub async fn logout(State(db): State<Db>, user: AuthUser) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
auth_service::logout(&db, user.uid).await?;
Ok(Json(ApiResponse::ok(serde_json::json!({"success": true}))))
}
pub async fn refresh(State(db): State<Db>, headers: HeaderMap) -> Result<(HeaderMap, Json<ApiResponse<serde_json::Value>>), AppError> {
let cookie_header = headers.get(axum::http::header::COOKIE).and_then(|v| v.to_str().ok()).unwrap_or("");
let refresh = cookie_header.split(';').find_map(|p| {
let kv: Vec<&str> = p.trim().splitn(2, '=').collect();
if kv.len()==2 && kv[0]=="refresh_token" { Some(kv[1].to_string()) } else { None }
}).ok_or(AppError::Unauthorized)?;
let secret = std::env::var("JWT_SECRET").unwrap();
let claims = decode_token(&refresh, &secret)?;
if claims.typ != "refresh" { return Err(AppError::Unauthorized); }
let (access, new_refresh) = auth_service::rotate_refresh(&db, claims.uid, refresh).await?;
let mut headers = HeaderMap::new();
let cookie = format!("refresh_token={}; HttpOnly; SameSite=Lax; Path=/; Max-Age=1209600", new_refresh);
headers.insert(axum::http::header::SET_COOKIE, HeaderValue::from_str(&cookie).unwrap());
Ok((headers, Json(ApiResponse::ok(serde_json::json!({"access_token": access})))) )
}
pub async fn current_user_menus(State(db): State<Db>, user: AuthUser) -> Result<Json<ApiResponse<Vec<menu_service::MenuInfo>>>, AppError> {
let ids = role_service::get_menu_ids_by_user_id(&db, user.uid).await?;
let menus = menu_service::list_by_ids(&db, ids).await?;
Ok(Json(ApiResponse::ok(menus)))
}

View File

@ -0,0 +1,36 @@
use axum::{Router, routing::{get, put}, extract::{Path, Query, State}, Json};
use serde::Deserialize;
use crate::{db::Db, services::department_service, response::ApiResponse, error::AppError};
#[derive(Deserialize)]
struct ListParams { keyword: Option<String> }
pub fn router() -> Router<Db> { Router::new()
.route("/departments", get(list).post(create))
.route("/departments/{id}", put(update).delete(delete_department))
}
async fn list(State(db): State<Db>, Query(p): Query<ListParams>) -> Result<Json<ApiResponse<Vec<department_service::DepartmentInfo>>>, AppError> {
let res = department_service::list_all(&db, p.keyword).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct CreateReq { parent_id: Option<i64>, name: String, order_no: Option<i32>, status: Option<i32> }
async fn create(State(db): State<Db>, Json(req): Json<CreateReq>) -> Result<Json<ApiResponse<department_service::DepartmentInfo>>, AppError> {
let res = department_service::create(&db, department_service::CreateDepartmentInput { parent_id: req.parent_id, name: req.name, order_no: req.order_no, status: req.status }).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct UpdateReq { parent_id: Option<i64>, name: Option<String>, order_no: Option<i32>, status: Option<i32> }
async fn update(State(db): State<Db>, Path(id): Path<i64>, Json(req): Json<UpdateReq>) -> Result<Json<ApiResponse<department_service::DepartmentInfo>>, AppError> {
let res = department_service::update(&db, id, department_service::UpdateDepartmentInput { parent_id: req.parent_id, name: req.name, order_no: req.order_no, status: req.status }).await?;
Ok(Json(ApiResponse::ok(res)))
}
async fn delete_department(State(db): State<Db>, Path(id): Path<i64>) -> Result<Json<ApiResponse<bool>>, AppError> {
department_service::delete(&db, id).await?;
Ok(Json(ApiResponse::ok(true)))
}

View File

@ -0,0 +1,9 @@
use axum::{Router, routing::get, extract::{Query, State}, Json};
use crate::{db::Db, response::ApiResponse, services::log_service, error::AppError};
pub fn router() -> Router<Db> { Router::new().route("/logs", get(list)) }
async fn list(State(db): State<Db>, Query(p): Query<log_service::ListParams>) -> Result<Json<ApiResponse<log_service::PageResp<log_service::LogInfo>>>, AppError> {
let res = log_service::list(&db, p).await.map_err(|e| AppError::Anyhow(anyhow::anyhow!(e)))?;
Ok(Json(ApiResponse::ok(res)))
}

View File

@ -0,0 +1,36 @@
use axum::{Router, routing::{get, put}, extract::{Path, Query, State}, Json};
use serde::Deserialize;
use crate::{db::Db, services::menu_service, response::ApiResponse, error::AppError};
#[derive(Deserialize)]
struct ListParams { keyword: Option<String> }
pub fn router() -> Router<Db> { Router::new()
.route("/menus", get(list).post(create))
.route("/menus/{id}", put(update).delete(delete_menu))
}
async fn list(State(db): State<Db>, Query(p): Query<ListParams>) -> Result<Json<ApiResponse<Vec<menu_service::MenuInfo>>>, AppError> {
let res = menu_service::list_all(&db, p.keyword).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct CreateReq { parent_id: Option<i64>, name: String, path: Option<String>, component: Option<String>, r#type: i32, icon: Option<String>, order_no: Option<i32>, visible: Option<bool>, status: Option<i32>, keep_alive: Option<bool>, perms: Option<String> }
async fn create(State(db): State<Db>, Json(req): Json<CreateReq>) -> Result<Json<ApiResponse<menu_service::MenuInfo>>, AppError> {
let res = menu_service::create(&db, menu_service::CreateMenuInput { parent_id: req.parent_id, name: req.name, path: req.path, component: req.component, r#type: req.r#type, icon: req.icon, order_no: req.order_no, visible: req.visible, status: req.status, keep_alive: req.keep_alive, perms: req.perms }).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct UpdateReq { parent_id: Option<i64>, name: Option<String>, path: Option<String>, component: Option<String>, r#type: Option<i32>, icon: Option<String>, order_no: Option<i32>, visible: Option<bool>, status: Option<i32>, keep_alive: Option<bool>, perms: Option<String> }
async fn update(State(db): State<Db>, Path(id): Path<i64>, Json(req): Json<UpdateReq>) -> Result<Json<ApiResponse<menu_service::MenuInfo>>, AppError> {
let res = menu_service::update(&db, id, menu_service::UpdateMenuInput { parent_id: req.parent_id, name: req.name, path: req.path, component: req.component, r#type: req.r#type, icon: req.icon, order_no: req.order_no, visible: req.visible, status: req.status, keep_alive: req.keep_alive, perms: req.perms }).await?;
Ok(Json(ApiResponse::ok(res)))
}
async fn delete_menu(State(db): State<Db>, Path(id): Path<i64>) -> Result<Json<ApiResponse<bool>>, AppError> {
menu_service::delete(&db, id).await?;
Ok(Json(ApiResponse::ok(true)))
}

23
backend/src/routes/mod.rs Normal file
View File

@ -0,0 +1,23 @@
pub mod auth;
pub mod users;
pub mod roles;
pub mod menus;
pub mod departments;
pub mod logs;
// 新增岗位
pub mod positions;
use axum::Router;
use crate::db::Db;
pub fn api_router() -> Router<Db> {
Router::new()
.nest("/auth", auth::router())
.merge(users::router())
.merge(roles::router())
.merge(menus::router())
.merge(departments::router())
.merge(logs::router())
// 合并岗位路由
.merge(positions::router())
}

View File

@ -0,0 +1,38 @@
use axum::{Router, routing::{get, put}, extract::{Path, Query, State}, Json};
use serde::Deserialize;
use crate::{db::Db, services::position_service, response::ApiResponse, error::AppError};
#[derive(Deserialize)]
struct PageParams { page: Option<u64>, page_size: Option<u64>, keyword: Option<String> }
pub fn router() -> Router<Db> { Router::new()
.route("/positions", get(list).post(create))
.route("/positions/{id}", put(update).delete(delete_position))
}
async fn list(State(db): State<Db>, Query(p): Query<PageParams>) -> Result<Json<ApiResponse<position_service::PageResp<position_service::PositionInfo>>>, AppError> {
let page = p.page.unwrap_or(1); let page_size = p.page_size.unwrap_or(10);
let res = position_service::list(&db, page, page_size, p.keyword).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct CreateReq { name: String, code: String, description: Option<String>, status: Option<i32>, order_no: Option<i32> }
async fn create(State(db): State<Db>, Json(req): Json<CreateReq>) -> Result<Json<ApiResponse<position_service::PositionInfo>>, AppError> {
let res = position_service::create(&db, position_service::CreatePositionInput { name: req.name, code: req.code, description: req.description, status: req.status, order_no: req.order_no }).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct UpdateReq { name: Option<String>, description: Option<String>, status: Option<i32>, order_no: Option<i32> }
async fn update(State(db): State<Db>, Path(id): Path<i64>, Json(req): Json<UpdateReq>) -> Result<Json<ApiResponse<position_service::PositionInfo>>, AppError> {
let res = position_service::update(&db, id, position_service::UpdatePositionInput { name: req.name, description: req.description, status: req.status, order_no: req.order_no }).await?;
Ok(Json(ApiResponse::ok(res)))
}
async fn delete_position(State(db): State<Db>, Path(id): Path<i64>) -> Result<Json<ApiResponse<bool>>, AppError> {
position_service::delete(&db, id).await?;
Ok(Json(ApiResponse::ok(true)))
}

View File

@ -0,0 +1,52 @@
use axum::{Router, routing::{get, put}, extract::{Path, Query, State}, Json};
use serde::Deserialize;
use crate::{db::Db, services::role_service, response::ApiResponse, error::AppError};
#[derive(Deserialize)]
struct PageParams { page: Option<u64>, page_size: Option<u64>, keyword: Option<String> }
pub fn router() -> Router<Db> { Router::new()
.route("/roles", get(list).post(create))
.route("/roles/{id}", put(update).delete(delete_role))
.route("/roles/{id}/menus", get(get_menus).put(set_menus))
}
async fn list(State(db): State<Db>, Query(p): Query<PageParams>) -> Result<Json<ApiResponse<role_service::PageResp<role_service::RoleInfo>>>, AppError> {
let page = p.page.unwrap_or(1); let page_size = p.page_size.unwrap_or(10);
let res = role_service::list(&db, page, page_size, p.keyword).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct CreateReq { name: String, code: String, description: Option<String>, status: Option<i32> }
async fn create(State(db): State<Db>, Json(req): Json<CreateReq>) -> Result<Json<ApiResponse<role_service::RoleInfo>>, AppError> {
let res = role_service::create(&db, role_service::CreateRoleInput { name: req.name, code: req.code, description: req.description, status: req.status }).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct UpdateReq { name: Option<String>, description: Option<String>, status: Option<i32> }
async fn update(State(db): State<Db>, Path(id): Path<i64>, Json(req): Json<UpdateReq>) -> Result<Json<ApiResponse<role_service::RoleInfo>>, AppError> {
let res = role_service::update(&db, id, role_service::UpdateRoleInput { name: req.name, description: req.description, status: req.status }).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct IdsReq { ids: Vec<i64> }
async fn get_menus(State(db): State<Db>, Path(id): Path<i64>) -> Result<Json<ApiResponse<Vec<i64>>>, AppError> {
let ids = role_service::get_menu_ids(&db, id).await?;
Ok(Json(ApiResponse::ok(ids)))
}
async fn set_menus(State(db): State<Db>, Path(id): Path<i64>, Json(req): Json<IdsReq>) -> Result<Json<ApiResponse<bool>>, AppError> {
role_service::set_menu_ids(&db, id, req.ids).await?;
Ok(Json(ApiResponse::ok(true)))
}
async fn delete_role(State(db): State<Db>, Path(id): Path<i64>) -> Result<Json<ApiResponse<bool>>, AppError> {
role_service::delete(&db, id).await?;
Ok(Json(ApiResponse::ok(true)))
}

View File

@ -0,0 +1,88 @@
use axum::{Router, routing::{get, post, put}, extract::{Path, Query, State}, Json};
use serde::Deserialize;
use crate::{db::Db, services::{user_service, role_service, department_service, position_service}, response::ApiResponse};
#[derive(Deserialize)]
struct PageParams { page: Option<u64>, page_size: Option<u64>, keyword: Option<String> }
pub fn router() -> Router<Db> { Router::new()
.route("/users", get(list).post(create))
.route("/users/{id}", put(update).delete(delete_user))
.route("/users/{id}/reset_password", post(reset_password))
.route("/users/{id}/roles", get(get_roles).put(set_roles))
.route("/users/{id}/departments", get(get_departments).put(set_departments))
// 新增岗位关联
.route("/users/{id}/positions", get(get_positions).put(set_positions))
}
async fn list(State(db): State<Db>, Query(p): Query<PageParams>) -> Result<Json<ApiResponse<user_service::PageResp<user_service::UserInfo>>>, crate::error::AppError> {
let page = p.page.unwrap_or(1); let page_size = p.page_size.unwrap_or(10);
let res = user_service::list(&db, page, page_size, p.keyword).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct CreateReq { username: String, password: String, nickname: Option<String>, status: Option<i32> }
async fn create(State(db): State<Db>, Json(req): Json<CreateReq>) -> Result<Json<ApiResponse<user_service::UserInfo>>, crate::error::AppError> {
let input = user_service::CreateUserInput { username: req.username, password: req.password, nickname: req.nickname, status: req.status };
let res = user_service::create(&db, input).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct UpdateReq { nickname: Option<String>, status: Option<i32> }
async fn update(State(db): State<Db>, Path(id): Path<i64>, Json(req): Json<UpdateReq>) -> Result<Json<ApiResponse<user_service::UserInfo>>, crate::error::AppError> {
let res = user_service::update(&db, id, user_service::UpdateUserInput { nickname: req.nickname, status: req.status }).await?;
Ok(Json(ApiResponse::ok(res)))
}
#[derive(Deserialize)]
struct IdsReq { ids: Vec<i64> }
async fn get_roles(State(db): State<Db>, Path(id): Path<i64>) -> Result<Json<ApiResponse<Vec<i64>>>, crate::error::AppError> {
let ids = role_service::get_role_ids_by_user_id(&db, id).await?;
Ok(Json(ApiResponse::ok(ids)))
}
async fn set_roles(State(db): State<Db>, Path(id): Path<i64>, Json(req): Json<IdsReq>) -> Result<Json<ApiResponse<bool>>, crate::error::AppError> {
role_service::set_role_ids_by_user_id(&db, id, req.ids).await?;
Ok(Json(ApiResponse::ok(true)))
}
// 新增:用户部门分配
async fn get_departments(State(db): State<Db>, Path(id): Path<i64>) -> Result<Json<ApiResponse<Vec<i64>>>, crate::error::AppError> {
let ids = department_service::get_department_ids_by_user_id(&db, id).await?;
Ok(Json(ApiResponse::ok(ids)))
}
async fn set_departments(State(db): State<Db>, Path(id): Path<i64>, Json(req): Json<IdsReq>) -> Result<Json<ApiResponse<bool>>, crate::error::AppError> {
department_service::set_department_ids_by_user_id(&db, id, req.ids).await?;
Ok(Json(ApiResponse::ok(true)))
}
// 新增:用户岗位分配
async fn get_positions(State(db): State<Db>, Path(id): Path<i64>) -> Result<Json<ApiResponse<Vec<i64>>>, crate::error::AppError> {
let ids = position_service::get_position_ids_by_user_id(&db, id).await?;
Ok(Json(ApiResponse::ok(ids)))
}
async fn set_positions(State(db): State<Db>, Path(id): Path<i64>, Json(req): Json<IdsReq>) -> Result<Json<ApiResponse<bool>>, crate::error::AppError> {
position_service::set_position_ids_by_user_id(&db, id, req.ids).await?;
Ok(Json(ApiResponse::ok(true)))
}
#[derive(Deserialize)]
struct ResetReq { password: String }
async fn reset_password(State(db): State<Db>, Path(id): Path<i64>, Json(req): Json<ResetReq>) -> Result<Json<ApiResponse<()>>, crate::error::AppError> {
let input = user_service::ResetPasswordInput { password: req.password };
user_service::reset_password(&db, id, input).await?;
Ok(Json(ApiResponse::ok(())))
}
async fn delete_user(State(db): State<Db>, Path(id): Path<i64>) -> Result<Json<ApiResponse<bool>>, crate::error::AppError> {
user_service::delete(&db, id).await?;
Ok(Json(ApiResponse::ok(true)))
}

View File

@ -0,0 +1,72 @@
use sea_orm::{EntityTrait, ColumnTrait, QueryFilter, ActiveModelTrait, Set};
use crate::{db::Db, models::{user, refresh_token}, utils::password, error::AppError};
use chrono::{Utc, Duration, FixedOffset};
use sha2::{Sha256, Digest};
use sea_orm::ActiveValue::NotSet;
pub struct LoginResult { pub user: user::Model, pub access: String, pub refresh: String }
pub async fn login(db: &Db, username: String, password_plain: String) -> Result<LoginResult, AppError> {
let u = user::Entity::find().filter(user::Column::Username.eq(username.clone())).one(db).await?.ok_or(AppError::Unauthorized)?;
if u.status != 1 { return Err(AppError::Forbidden); }
let ok = password::verify_password(&password_plain, &u.password_hash).map_err(|_| AppError::Unauthorized)?;
if !ok { return Err(AppError::Unauthorized); }
let access_claims = crate::middlewares::jwt::new_access_claims(u.id, &u.username);
let refresh_claims = crate::middlewares::jwt::new_refresh_claims(u.id, &u.username);
let secret = std::env::var("JWT_SECRET").unwrap();
let access = crate::middlewares::jwt::encode_token(&access_claims, &secret)?;
let refresh = crate::middlewares::jwt::encode_token(&refresh_claims, &secret)?;
// persist refresh token hash
let mut hasher = Sha256::new(); hasher.update(refresh.as_bytes());
let token_hash = format!("{:x}", hasher.finalize());
let exp_secs = std::env::var("JWT_REFRESH_EXP_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(1209600);
let expires = (Utc::now() + Duration::seconds(exp_secs as i64)).with_timezone(&FixedOffset::east_opt(0).unwrap());
let created = Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap());
let am = refresh_token::ActiveModel {
id: NotSet,
user_id: Set(u.id),
token_hash: Set(token_hash),
expires_at: Set(expires),
created_at: Set(created),
};
let _ = am.insert(db).await?;
Ok(LoginResult { user: u, access, refresh })
}
pub async fn logout(db: &Db, uid: i64) -> Result<(), AppError> {
let _ = refresh_token::Entity::delete_many().filter(refresh_token::Column::UserId.eq(uid)).exec(db).await?;
Ok(())
}
pub async fn rotate_refresh(db: &Db, uid: i64, old_refresh: String) -> Result<(String, String), AppError> {
let mut hasher = Sha256::new(); hasher.update(old_refresh.as_bytes());
let token_hash = format!("{:x}", hasher.finalize());
let existing = refresh_token::Entity::find().filter(refresh_token::Column::UserId.eq(uid)).filter(refresh_token::Column::TokenHash.eq(token_hash.clone())).one(db).await?;
if existing.is_none() { return Err(AppError::Unauthorized); }
let u = user::Entity::find_by_id(uid).one(db).await?.ok_or(AppError::Unauthorized)?;
let access_claims = crate::middlewares::jwt::new_access_claims(u.id, &u.username);
let refresh_claims = crate::middlewares::jwt::new_refresh_claims(u.id, &u.username);
let secret = std::env::var("JWT_SECRET").unwrap();
let access = crate::middlewares::jwt::encode_token(&access_claims, &secret)?;
let refresh = crate::middlewares::jwt::encode_token(&refresh_claims, &secret)?;
let _ = refresh_token::Entity::delete_many().filter(refresh_token::Column::UserId.eq(uid)).filter(refresh_token::Column::TokenHash.eq(token_hash)).exec(db).await?;
let mut hasher2 = Sha256::new(); hasher2.update(refresh.as_bytes());
let token_hash2 = format!("{:x}", hasher2.finalize());
let exp_secs = std::env::var("JWT_REFRESH_EXP_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(1209600);
let expires = (Utc::now() + Duration::seconds(exp_secs as i64)).with_timezone(&FixedOffset::east_opt(0).unwrap());
let created = Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap());
let am = refresh_token::ActiveModel {
id: NotSet,
user_id: Set(u.id),
token_hash: Set(token_hash2),
expires_at: Set(expires),
created_at: Set(created),
};
let _ = am.insert(db).await?;
Ok((access, refresh))
}

View File

@ -0,0 +1,84 @@
use sea_orm::{EntityTrait, ColumnTrait, QueryFilter, ActiveModelTrait, Set, QueryOrder, TransactionTrait, QuerySelect};
use sea_orm::prelude::DateTimeWithTimeZone;
use crate::{db::Db, models::{department, user_department}, error::AppError};
use sea_orm::PaginatorTrait;
#[derive(Clone, Debug, serde::Serialize)]
pub struct DepartmentInfo { pub id: i64, pub parent_id: Option<i64>, pub name: String, pub order_no: i32, pub status: i32, pub created_at: DateTimeWithTimeZone }
impl From<department::Model> for DepartmentInfo { fn from(m: department::Model) -> Self { Self { id: m.id, parent_id: m.parent_id, name: m.name, order_no: m.order_no, status: m.status, created_at: m.created_at } } }
pub async fn list_all(db: &Db, keyword: Option<String>) -> Result<Vec<DepartmentInfo>, AppError> {
let mut selector = department::Entity::find();
if let Some(k) = keyword { let like = format!("%{}%", k); selector = selector.filter(department::Column::Name.like(like)); }
let models = selector.order_by_asc(department::Column::OrderNo).order_by_asc(department::Column::Id).all(db).await?;
Ok(models.into_iter().map(Into::into).collect())
}
#[derive(serde::Deserialize)]
pub struct CreateDepartmentInput { pub parent_id: Option<i64>, pub name: String, pub order_no: Option<i32>, pub status: Option<i32> }
pub async fn create(db: &Db, input: CreateDepartmentInput) -> Result<DepartmentInfo, AppError> {
let mut am: department::ActiveModel = Default::default();
am.parent_id = Set(input.parent_id);
am.name = Set(input.name);
am.order_no = Set(input.order_no.unwrap_or(0));
am.status = Set(input.status.unwrap_or(1));
let m = am.insert(db).await?;
Ok(m.into())
}
#[derive(serde::Deserialize)]
pub struct UpdateDepartmentInput { pub parent_id: Option<i64>, pub name: Option<String>, pub order_no: Option<i32>, pub status: Option<i32> }
pub async fn update(db: &Db, id: i64, input: UpdateDepartmentInput) -> Result<DepartmentInfo, AppError> {
let m = department::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let mut am: department::ActiveModel = m.into();
if let Some(v) = input.parent_id { am.parent_id = Set(Some(v)); }
if let Some(v) = input.name { am.name = Set(v); }
if let Some(v) = input.order_no { am.order_no = Set(v); }
if let Some(v) = input.status { am.status = Set(v); }
let m = am.update(db).await?;
Ok(m.into())
}
// user <-> departments
pub async fn get_department_ids_by_user_id(db: &Db, user_id: i64) -> Result<Vec<i64>, AppError> {
let ids = user_department::Entity::find()
.filter(user_department::Column::UserId.eq(user_id))
.select_only()
.column(user_department::Column::DepartmentId)
.into_tuple::<i64>()
.all(db)
.await?;
Ok(ids)
}
#[derive(serde::Deserialize)]
pub struct SetIdsInput { pub ids: Vec<i64> }
pub async fn set_department_ids_by_user_id(db: &Db, user_id: i64, ids: Vec<i64>) -> Result<(), AppError> {
let txn = db.begin().await?;
user_department::Entity::delete_many()
.filter(user_department::Column::UserId.eq(user_id))
.exec(&txn)
.await?;
if !ids.is_empty() {
let mut items: Vec<user_department::ActiveModel> = Vec::with_capacity(ids.len());
for did in ids { let mut am: user_department::ActiveModel = Default::default(); am.user_id = Set(user_id); am.department_id = Set(did); items.push(am); }
user_department::Entity::insert_many(items).exec(&txn).await?;
}
txn.commit().await?;
Ok(())
}
// 新增:删除部门(禁止删除存在子部门的项),并清理用户关联
pub async fn delete(db: &Db, id: i64) -> Result<(), AppError> {
let _ = department::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let child_count = department::Entity::find().filter(department::Column::ParentId.eq(Some(id))).count(db).await?;
if child_count > 0 { return Err(AppError::BadRequest("请先删除子部门".into())); }
let txn = db.begin().await?;
user_department::Entity::delete_many().filter(user_department::Column::DepartmentId.eq(id)).exec(&txn).await?;
let _ = department::Entity::delete_by_id(id).exec(&txn).await?;
txn.commit().await?;
Ok(())
}

View File

@ -0,0 +1,71 @@
use crate::{db::Db, models::request_log};
use sea_orm::{ActiveModelTrait, Set, EntityTrait, ColumnTrait, QueryFilter, PaginatorTrait, QueryOrder};
use chrono::{DateTime, FixedOffset, Utc};
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct CreateLogInput {
pub path: String,
pub method: String,
pub request_params: Option<String>,
pub response_params: Option<String>,
pub status_code: i32,
pub user_id: Option<i64>,
pub username: Option<String>,
pub request_time: DateTime<FixedOffset>,
pub duration_ms: i64,
}
pub async fn create(db: &Db, input: CreateLogInput) -> anyhow::Result<i64> {
let am = request_log::ActiveModel {
id: Default::default(),
path: Set(input.path),
method: Set(input.method),
request_params: Set(input.request_params),
response_params: Set(input.response_params),
status_code: Set(input.status_code),
user_id: Set(input.user_id),
username: Set(input.username),
request_time: Set(input.request_time),
duration_ms: Set(input.duration_ms),
created_at: Set(Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap())),
};
let m = am.insert(db).await?;
Ok(m.id)
}
#[derive(serde::Serialize, Clone, Debug)]
pub struct LogInfo {
pub id: i64,
pub path: String,
pub method: String,
pub request_params: Option<String>,
pub response_params: Option<String>,
pub status_code: i32,
pub user_id: Option<i64>,
pub username: Option<String>,
pub request_time: chrono::DateTime<FixedOffset>,
pub duration_ms: i64,
}
impl From<request_log::Model> for LogInfo {
fn from(m: request_log::Model) -> Self {
Self { id: m.id, path: m.path, method: m.method, request_params: m.request_params, response_params: m.response_params, status_code: m.status_code, user_id: m.user_id, username: m.username, request_time: m.request_time, duration_ms: m.duration_ms }
}
}
#[derive(serde::Serialize)]
pub struct PageResp<T> { pub items: Vec<T>, pub total: u64, pub page: u64, pub page_size: u64 }
#[derive(serde::Deserialize)]
pub struct ListParams { pub page: Option<u64>, pub page_size: Option<u64>, pub path: Option<String>, pub start_time: Option<String>, pub end_time: Option<String> }
pub async fn list(db: &Db, p: ListParams) -> anyhow::Result<PageResp<LogInfo>> {
let page = p.page.unwrap_or(1); let page_size = p.page_size.unwrap_or(10);
let mut selector = request_log::Entity::find();
if let Some(path) = p.path { let like = format!("%{}%", path); selector = selector.filter(request_log::Column::Path.like(like)); }
if let Some(start) = p.start_time.as_deref() { if let Ok(dt) = start.parse::<chrono::DateTime<FixedOffset>>() { selector = selector.filter(request_log::Column::RequestTime.gte(dt)); } }
if let Some(end) = p.end_time.as_deref() { if let Ok(dt) = end.parse::<chrono::DateTime<FixedOffset>>() { selector = selector.filter(request_log::Column::RequestTime.lte(dt)); } }
let paginator = selector.order_by_desc(request_log::Column::Id).paginate(db, page_size);
let total = paginator.num_items().await? as u64;
let models = paginator.fetch_page(if page>0 { page-1 } else { 0 }).await?;
Ok(PageResp { items: models.into_iter().map(Into::into).collect(), total, page, page_size })
}

View File

@ -0,0 +1,84 @@
use sea_orm::{EntityTrait, ColumnTrait, QueryFilter, ActiveModelTrait, Set, QueryOrder};
use sea_orm::prelude::DateTimeWithTimeZone;
use crate::{db::Db, models::menu, error::AppError};
use sea_orm::PaginatorTrait;
#[derive(Clone, Debug, serde::Serialize)]
pub struct MenuInfo { pub id: i64, pub parent_id: Option<i64>, pub name: String, pub path: Option<String>, pub component: Option<String>, pub r#type: i32, pub icon: Option<String>, pub order_no: i32, pub visible: bool, pub status: i32, pub keep_alive: bool, pub perms: Option<String>, pub created_at: DateTimeWithTimeZone }
impl From<menu::Model> for MenuInfo { fn from(m: menu::Model) -> Self { Self { id: m.id, parent_id: m.parent_id, name: m.name, path: m.path, component: m.component, r#type: m.r#type, icon: m.icon, order_no: m.order_no, visible: m.visible, status: m.status, keep_alive: m.keep_alive, perms: m.perms, created_at: m.created_at } } }
pub async fn list_all(db: &Db, keyword: Option<String>) -> Result<Vec<MenuInfo>, AppError> {
let mut selector = menu::Entity::find();
if let Some(k) = keyword { let like = format!("%{}%", k); selector = selector.filter(menu::Column::Name.like(like)); }
let models = selector.order_by_asc(menu::Column::OrderNo).order_by_asc(menu::Column::Id).all(db).await?;
Ok(models.into_iter().map(Into::into).collect())
}
#[derive(serde::Deserialize)]
pub struct CreateMenuInput { pub parent_id: Option<i64>, pub name: String, pub path: Option<String>, pub component: Option<String>, pub r#type: i32, pub icon: Option<String>, pub order_no: Option<i32>, pub visible: Option<bool>, pub status: Option<i32>, pub keep_alive: Option<bool>, pub perms: Option<String> }
pub async fn create(db: &Db, input: CreateMenuInput) -> Result<MenuInfo, AppError> {
let mut am: menu::ActiveModel = Default::default();
am.parent_id = Set(input.parent_id);
am.name = Set(input.name);
am.path = Set(input.path);
am.component = Set(input.component);
am.r#type = Set(input.r#type);
am.icon = Set(input.icon);
am.order_no = Set(input.order_no.unwrap_or(0));
am.visible = Set(input.visible.unwrap_or(true));
am.status = Set(input.status.unwrap_or(1));
am.keep_alive = Set(input.keep_alive.unwrap_or(true));
am.perms = Set(input.perms);
let m = am.insert(db).await?;
Ok(m.into())
}
#[derive(serde::Deserialize)]
pub struct UpdateMenuInput { pub parent_id: Option<i64>, pub name: Option<String>, pub path: Option<String>, pub component: Option<String>, pub r#type: Option<i32>, pub icon: Option<String>, pub order_no: Option<i32>, pub visible: Option<bool>, pub status: Option<i32>, pub keep_alive: Option<bool>, pub perms: Option<String> }
pub async fn update(db: &Db, id: i64, input: UpdateMenuInput) -> Result<MenuInfo, AppError> {
let m = menu::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let mut am: menu::ActiveModel = m.into();
if let Some(v) = input.parent_id { am.parent_id = Set(Some(v)); }
if let Some(v) = input.name { am.name = Set(v); }
if let Some(v) = input.path { am.path = Set(Some(v)); }
if let Some(v) = input.component { am.component = Set(Some(v)); }
if let Some(v) = input.r#type { am.r#type = Set(v); }
if let Some(v) = input.icon { am.icon = Set(Some(v)); }
if let Some(v) = input.order_no { am.order_no = Set(v); }
if let Some(v) = input.visible { am.visible = Set(v); }
if let Some(v) = input.status { am.status = Set(v); }
if let Some(v) = input.keep_alive { am.keep_alive = Set(v); }
if let Some(v) = input.perms { am.perms = Set(Some(v)); }
let m = am.update(db).await?;
Ok(m.into())
}
pub async fn list_by_ids(db: &Db, ids: Vec<i64>) -> Result<Vec<MenuInfo>, AppError> {
if ids.is_empty() { return Ok(vec![]); }
let models = menu::Entity::find()
.filter(menu::Column::Id.is_in(ids))
.filter(menu::Column::Visible.eq(true))
.filter(menu::Column::Status.eq(1))
.order_by_asc(menu::Column::OrderNo)
.order_by_asc(menu::Column::Id)
.all(db)
.await?;
Ok(models.into_iter().map(Into::into).collect())
}
// 新增:删除菜单(禁止删除存在子菜单的项),并清理角色关联
use crate::models::role_menu;
use sea_orm::TransactionTrait;
pub async fn delete(db: &Db, id: i64) -> Result<(), AppError> {
let _ = menu::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let child_count = menu::Entity::find().filter(menu::Column::ParentId.eq(Some(id))).count(db).await?;
if child_count > 0 { return Err(AppError::BadRequest("请先删除子菜单".into())); }
let txn = db.begin().await?;
role_menu::Entity::delete_many().filter(role_menu::Column::MenuId.eq(id)).exec(&txn).await?;
let _ = menu::Entity::delete_by_id(id).exec(&txn).await?;
txn.commit().await?;
Ok(())
}

View File

@ -0,0 +1,8 @@
pub mod auth_service;
pub mod user_service;
pub mod role_service;
pub mod menu_service;
pub mod department_service;
pub mod log_service;
// 新增岗位服务
pub mod position_service;

View File

@ -0,0 +1,87 @@
use sea_orm::{EntityTrait, ColumnTrait, QueryFilter, PaginatorTrait, ActiveModelTrait, Set, QueryOrder, TransactionTrait, QuerySelect};
use sea_orm::prelude::DateTimeWithTimeZone;
use crate::{db::Db, models::{position, user_position}, error::AppError};
#[derive(Clone, Debug, serde::Serialize)]
pub struct PositionInfo { pub id: i64, pub name: String, pub code: String, pub description: Option<String>, pub status: i32, pub order_no: i32, pub created_at: DateTimeWithTimeZone }
impl From<position::Model> for PositionInfo { fn from(m: position::Model) -> Self { Self { id: m.id, name: m.name, code: m.code, description: m.description, status: m.status, order_no: m.order_no, created_at: m.created_at } } }
#[derive(Clone, Debug, serde::Serialize)]
pub struct PageResp<T> { pub items: Vec<T>, pub total: u64, pub page: u64, pub page_size: u64 }
pub async fn list(db: &Db, page: u64, page_size: u64, keyword: Option<String>) -> Result<PageResp<PositionInfo>, AppError> {
let mut selector = position::Entity::find();
if let Some(k) = keyword { let like = format!("%{}%", k); selector = selector.filter(position::Column::Name.like(like.clone()).or(position::Column::Code.like(like))); }
let paginator = selector.order_by_desc(position::Column::Id).paginate(db, page_size);
let total = paginator.num_items().await? as u64;
let models = paginator.fetch_page(if page>0 { page-1 } else { 0 }).await?;
Ok(PageResp { items: models.into_iter().map(Into::into).collect(), total, page, page_size })
}
#[derive(serde::Deserialize)]
pub struct CreatePositionInput { pub name: String, pub code: String, pub description: Option<String>, pub status: Option<i32>, pub order_no: Option<i32> }
pub async fn create(db: &Db, input: CreatePositionInput) -> Result<PositionInfo, AppError> {
if position::Entity::find().filter(position::Column::Code.eq(input.code.clone())).one(db).await?.is_some() {
return Err(AppError::BadRequest("position code already exists".into()));
}
let mut am: position::ActiveModel = Default::default();
am.name = Set(input.name);
am.code = Set(input.code);
am.description = Set(input.description);
am.status = Set(input.status.unwrap_or(1));
am.order_no = Set(input.order_no.unwrap_or(0));
let m = am.insert(db).await?;
Ok(m.into())
}
#[derive(serde::Deserialize)]
pub struct UpdatePositionInput { pub name: Option<String>, pub description: Option<String>, pub status: Option<i32>, pub order_no: Option<i32> }
pub async fn update(db: &Db, id: i64, input: UpdatePositionInput) -> Result<PositionInfo, AppError> {
let m = position::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let mut am: position::ActiveModel = m.into();
if let Some(v) = input.name { am.name = Set(v); }
if let Some(v) = input.description { am.description = Set(Some(v)); }
if let Some(v) = input.status { am.status = Set(v); }
if let Some(v) = input.order_no { am.order_no = Set(v); }
let m = am.update(db).await?;
Ok(m.into())
}
pub async fn delete(db: &Db, id: i64) -> Result<(), AppError> {
let _ = position::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let txn = db.begin().await?;
user_position::Entity::delete_many().filter(user_position::Column::PositionId.eq(id)).exec(&txn).await?;
let _ = position::Entity::delete_by_id(id).exec(&txn).await?;
txn.commit().await?;
Ok(())
}
pub async fn get_position_ids_by_user_id(db: &Db, user_id: i64) -> Result<Vec<i64>, AppError> {
let ids = user_position::Entity::find()
.filter(user_position::Column::UserId.eq(user_id))
.select_only()
.column(user_position::Column::PositionId)
.into_tuple::<i64>()
.all(db)
.await?;
Ok(ids)
}
pub async fn set_position_ids_by_user_id(db: &Db, user_id: i64, position_ids: Vec<i64>) -> Result<(), AppError> {
let txn = db.begin().await?;
user_position::Entity::delete_many().filter(user_position::Column::UserId.eq(user_id)).exec(&txn).await?;
if !position_ids.is_empty() {
let mut items: Vec<user_position::ActiveModel> = Vec::with_capacity(position_ids.len());
for pid in position_ids {
let mut am: user_position::ActiveModel = Default::default();
am.user_id = Set(user_id);
am.position_id = Set(pid);
items.push(am);
}
user_position::Entity::insert_many(items).exec(&txn).await?;
}
txn.commit().await?;
Ok(())
}

View File

@ -0,0 +1,136 @@
use sea_orm::{EntityTrait, ColumnTrait, QueryFilter, PaginatorTrait, ActiveModelTrait, Set, QueryOrder};
use sea_orm::prelude::DateTimeWithTimeZone;
use crate::{db::Db, models::role, error::AppError};
#[derive(Clone, Debug, serde::Serialize)]
pub struct RoleInfo { pub id: i64, pub name: String, pub code: String, pub description: Option<String>, pub status: i32, pub created_at: DateTimeWithTimeZone }
impl From<role::Model> for RoleInfo { fn from(m: role::Model) -> Self { Self { id: m.id, name: m.name, code: m.code, description: m.description, status: m.status, created_at: m.created_at } } }
#[derive(Clone, Debug, serde::Serialize)]
pub struct PageResp<T> { pub items: Vec<T>, pub total: u64, pub page: u64, pub page_size: u64 }
pub async fn list(db: &Db, page: u64, page_size: u64, keyword: Option<String>) -> Result<PageResp<RoleInfo>, AppError> {
let mut selector = role::Entity::find();
if let Some(k) = keyword { let like = format!("%{}%", k); selector = selector.filter(role::Column::Name.like(like.clone()).or(role::Column::Code.like(like))); }
let paginator = selector.order_by_desc(role::Column::Id).paginate(db, page_size);
let total = paginator.num_items().await? as u64;
let models = paginator.fetch_page(if page>0 { page-1 } else { 0 }).await?;
Ok(PageResp { items: models.into_iter().map(Into::into).collect(), total, page, page_size })
}
#[derive(serde::Deserialize)]
pub struct CreateRoleInput { pub name: String, pub code: String, pub description: Option<String>, pub status: Option<i32> }
pub async fn create(db: &Db, input: CreateRoleInput) -> Result<RoleInfo, AppError> {
if role::Entity::find().filter(role::Column::Code.eq(input.code.clone())).one(db).await?.is_some() {
return Err(AppError::BadRequest("role code already exists".into()));
}
let mut am: role::ActiveModel = Default::default();
am.name = Set(input.name);
am.code = Set(input.code);
am.description = Set(input.description);
am.status = Set(input.status.unwrap_or(1));
let m = am.insert(db).await?;
Ok(m.into())
}
#[derive(serde::Deserialize)]
pub struct UpdateRoleInput { pub name: Option<String>, pub description: Option<String>, pub status: Option<i32> }
pub async fn update(db: &Db, id: i64, input: UpdateRoleInput) -> Result<RoleInfo, AppError> {
let m = role::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let mut am: role::ActiveModel = m.into();
if let Some(v) = input.name { am.name = Set(v); }
if let Some(v) = input.description { am.description = Set(Some(v)); }
if let Some(v) = input.status { am.status = Set(v); }
let m = am.update(db).await?;
Ok(m.into())
}
// --- Role assignments: menus only ---
use crate::models::{role_menu, user_role};
use sea_orm::{TransactionTrait, QuerySelect};
pub async fn get_menu_ids(db: &Db, role_id: i64) -> Result<Vec<i64>, AppError> {
let ids = role_menu::Entity::find()
.filter(role_menu::Column::RoleId.eq(role_id))
.select_only()
.column(role_menu::Column::MenuId)
.into_tuple::<i64>()
.all(db)
.await?;
Ok(ids)
}
pub async fn set_menu_ids(db: &Db, role_id: i64, ids: Vec<i64>) -> Result<(), AppError> {
let txn = db.begin().await?;
role_menu::Entity::delete_many()
.filter(role_menu::Column::RoleId.eq(role_id))
.exec(&txn)
.await?;
if !ids.is_empty() {
let mut items: Vec<role_menu::ActiveModel> = Vec::with_capacity(ids.len());
for mid in ids { let mut am: role_menu::ActiveModel = Default::default(); am.role_id = Set(role_id); am.menu_id = Set(mid); items.push(am); }
role_menu::Entity::insert_many(items).exec(&txn).await?;
}
txn.commit().await?;
Ok(())
}
pub async fn get_role_ids_by_user_id(db: &Db, user_id: i64) -> Result<Vec<i64>, AppError> {
let ids = user_role::Entity::find()
.filter(user_role::Column::UserId.eq(user_id))
.select_only()
.column(user_role::Column::RoleId)
.into_tuple::<i64>()
.all(db)
.await?;
Ok(ids)
}
pub async fn get_menu_ids_by_user_id(db: &Db, user_id: i64) -> Result<Vec<i64>, AppError> {
let role_ids = get_role_ids_by_user_id(db, user_id).await?;
if role_ids.is_empty() { return Ok(vec![]); }
let mut ids = role_menu::Entity::find()
.filter(role_menu::Column::RoleId.is_in(role_ids))
.select_only()
.column(role_menu::Column::MenuId)
.into_tuple::<i64>()
.all(db)
.await?;
ids.sort_unstable();
ids.dedup();
Ok(ids)
}
pub async fn set_role_ids_by_user_id(db: &Db, user_id: i64, role_ids: Vec<i64>) -> Result<(), AppError> {
let txn = db.begin().await?;
user_role::Entity::delete_many()
.filter(user_role::Column::UserId.eq(user_id))
.exec(&txn)
.await?;
if !role_ids.is_empty() {
let mut items: Vec<user_role::ActiveModel> = Vec::with_capacity(role_ids.len());
for rid in role_ids {
let mut am: user_role::ActiveModel = Default::default();
am.user_id = Set(user_id);
am.role_id = Set(rid);
items.push(am);
}
user_role::Entity::insert_many(items).exec(&txn).await?;
}
txn.commit().await?;
Ok(())
}
// 新增:删除角色(清理用户与权限、菜单关联)
pub async fn delete(db: &Db, id: i64) -> Result<(), AppError> {
let _ = role::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let txn = db.begin().await?;
role_menu::Entity::delete_many().filter(role_menu::Column::RoleId.eq(id)).exec(&txn).await?;
user_role::Entity::delete_many().filter(user_role::Column::RoleId.eq(id)).exec(&txn).await?;
let _ = role::Entity::delete_by_id(id).exec(&txn).await?;
txn.commit().await?;
Ok(())
}

View File

@ -0,0 +1,74 @@
use sea_orm::{EntityTrait, ColumnTrait, QueryFilter, PaginatorTrait, ActiveModelTrait, Set, QueryOrder};
use sea_orm::prelude::DateTimeWithTimeZone;
use crate::{db::Db, models::user, utils::password, error::AppError};
#[derive(Clone, Debug, serde::Serialize)]
pub struct UserInfo { pub id: i64, pub username: String, pub nickname: Option<String>, pub status: i32, pub created_at: DateTimeWithTimeZone }
impl From<user::Model> for UserInfo { fn from(m: user::Model) -> Self { Self { id: m.id, username: m.username, nickname: m.nickname, status: m.status, created_at: m.created_at } } }
#[derive(Clone, Debug, serde::Serialize)]
pub struct PageResp<T> { pub items: Vec<T>, pub total: u64, pub page: u64, pub page_size: u64 }
pub async fn list(db: &Db, page: u64, page_size: u64, keyword: Option<String>) -> Result<PageResp<UserInfo>, AppError> {
let mut selector = user::Entity::find();
if let Some(k) = keyword { let like = format!("%{}%", k); selector = selector.filter(user::Column::Username.like(like.clone()).or(user::Column::Nickname.like(like))); }
let paginator = selector.order_by_desc(user::Column::Id).paginate(db, page_size);
let total = paginator.num_items().await? as u64;
let models = paginator.fetch_page(if page>0 { page-1 } else { 0 }).await?;
Ok(PageResp { items: models.into_iter().map(Into::into).collect(), total, page, page_size })
}
#[derive(serde::Deserialize)]
pub struct CreateUserInput { pub username: String, pub password: String, pub nickname: Option<String>, pub status: Option<i32> }
pub async fn create(db: &Db, input: CreateUserInput) -> Result<UserInfo, AppError> {
if user::Entity::find().filter(user::Column::Username.eq(input.username.clone())).one(db).await?.is_some() {
return Err(AppError::BadRequest("username already exists".into()));
}
let hash = password::hash_password(&input.password)?;
let mut am: user::ActiveModel = Default::default();
am.username = Set(input.username);
am.password_hash = Set(hash);
am.nickname = Set(input.nickname);
am.status = Set(input.status.unwrap_or(1));
let m = am.insert(db).await?;
Ok(m.into())
}
#[derive(serde::Deserialize)]
pub struct UpdateUserInput { pub nickname: Option<String>, pub status: Option<i32> }
pub async fn update(db: &Db, id: i64, input: UpdateUserInput) -> Result<UserInfo, AppError> {
let m = user::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let mut am: user::ActiveModel = m.into();
if let Some(n) = input.nickname { am.nickname = Set(Some(n)); }
if let Some(s) = input.status { am.status = Set(s); }
let m = am.update(db).await?;
Ok(m.into())
}
#[derive(serde::Deserialize)]
pub struct ResetPasswordInput { pub password: String }
pub async fn reset_password(db: &Db, id: i64, input: ResetPasswordInput) -> Result<(), AppError> {
let m = user::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let mut am: user::ActiveModel = m.into();
am.password_hash = Set(password::hash_password(&input.password)?);
let _ = am.update(db).await?;
Ok(())
}
// 新增:删除用户(包含清理关联关系)
use crate::models::{user_role, user_department, refresh_token};
use sea_orm::TransactionTrait;
pub async fn delete(db: &Db, id: i64) -> Result<(), AppError> {
let _ = user::Entity::find_by_id(id).one(db).await?.ok_or(AppError::NotFound)?;
let txn = db.begin().await?;
user_role::Entity::delete_many().filter(user_role::Column::UserId.eq(id)).exec(&txn).await?;
user_department::Entity::delete_many().filter(user_department::Column::UserId.eq(id)).exec(&txn).await?;
refresh_token::Entity::delete_many().filter(refresh_token::Column::UserId.eq(id)).exec(&txn).await?;
let _ = user::Entity::delete_by_id(id).exec(&txn).await?;
txn.commit().await?;
Ok(())
}

1
backend/src/utils/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod password;

View File

@ -0,0 +1,15 @@
pub fn hash_password(plain: &str) -> anyhow::Result<String> {
use argon2::{password_hash::{SaltString, PasswordHasher}, Argon2};
let salt = SaltString::generate(&mut rand::thread_rng());
let hashed = Argon2::default()
.hash_password(plain.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!(e.to_string()))?
.to_string();
Ok(hashed)
}
pub fn verify_password(plain: &str, hashed: &str) -> anyhow::Result<bool> {
use argon2::{password_hash::{PasswordHash, PasswordVerifier}, Argon2};
let parsed = PasswordHash::new(&hashed).map_err(|e| anyhow::anyhow!(e.to_string()))?;
Ok(Argon2::default().verify_password(plain.as_bytes(), &parsed).is_ok())
}

BIN
backend/udmin.db Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.