init
This commit is contained in:
15
backend/src/db.rs
Normal file
15
backend/src/db.rs
Normal 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
26
backend/src/error.rs
Normal 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
75
backend/src/main.rs
Normal 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(())
|
||||
}
|
||||
56
backend/src/middlewares/jwt.rs
Normal file
56
backend/src/middlewares/jwt.rs
Normal 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() }
|
||||
}
|
||||
73
backend/src/middlewares/logging.rs
Normal file
73
backend/src/middlewares/logging.rs
Normal 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
|
||||
}
|
||||
2
backend/src/middlewares/mod.rs
Normal file
2
backend/src/middlewares/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod jwt;
|
||||
pub mod logging;
|
||||
19
backend/src/models/department.rs
Normal file
19
backend/src/models/department.rs
Normal 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 {}
|
||||
27
backend/src/models/menu.rs
Normal file
27
backend/src/models/menu.rs
Normal 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
12
backend/src/models/mod.rs
Normal 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;
|
||||
21
backend/src/models/position.rs
Normal file
21
backend/src/models/position.rs
Normal 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 {}
|
||||
17
backend/src/models/refresh_token.rs
Normal file
17
backend/src/models/refresh_token.rs
Normal 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 {}
|
||||
23
backend/src/models/request_log.rs
Normal file
23
backend/src/models/request_log.rs
Normal 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 {}
|
||||
20
backend/src/models/role.rs
Normal file
20
backend/src/models/role.rs
Normal 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 {}
|
||||
15
backend/src/models/role_menu.rs
Normal file
15
backend/src/models/role_menu.rs
Normal 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 {}
|
||||
20
backend/src/models/user.rs
Normal file
20
backend/src/models/user.rs
Normal 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 {}
|
||||
15
backend/src/models/user_department.rs
Normal file
15
backend/src/models/user_department.rs
Normal 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 {}
|
||||
15
backend/src/models/user_position.rs
Normal file
15
backend/src/models/user_position.rs
Normal 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 {}
|
||||
15
backend/src/models/user_role.rs
Normal file
15
backend/src/models/user_role.rs
Normal 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
13
backend/src/response.rs
Normal 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 } }
|
||||
}
|
||||
65
backend/src/routes/auth.rs
Normal file
65
backend/src/routes/auth.rs
Normal 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)))
|
||||
}
|
||||
36
backend/src/routes/departments.rs
Normal file
36
backend/src/routes/departments.rs
Normal 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)))
|
||||
}
|
||||
9
backend/src/routes/logs.rs
Normal file
9
backend/src/routes/logs.rs
Normal 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)))
|
||||
}
|
||||
36
backend/src/routes/menus.rs
Normal file
36
backend/src/routes/menus.rs
Normal 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
23
backend/src/routes/mod.rs
Normal 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())
|
||||
}
|
||||
38
backend/src/routes/positions.rs
Normal file
38
backend/src/routes/positions.rs
Normal 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)))
|
||||
}
|
||||
52
backend/src/routes/roles.rs
Normal file
52
backend/src/routes/roles.rs
Normal 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)))
|
||||
}
|
||||
88
backend/src/routes/users.rs
Normal file
88
backend/src/routes/users.rs
Normal 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)))
|
||||
}
|
||||
72
backend/src/services/auth_service.rs
Normal file
72
backend/src/services/auth_service.rs
Normal 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))
|
||||
}
|
||||
84
backend/src/services/department_service.rs
Normal file
84
backend/src/services/department_service.rs
Normal 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(())
|
||||
}
|
||||
71
backend/src/services/log_service.rs
Normal file
71
backend/src/services/log_service.rs
Normal 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 })
|
||||
}
|
||||
84
backend/src/services/menu_service.rs
Normal file
84
backend/src/services/menu_service.rs
Normal 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(())
|
||||
}
|
||||
8
backend/src/services/mod.rs
Normal file
8
backend/src/services/mod.rs
Normal 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;
|
||||
87
backend/src/services/position_service.rs
Normal file
87
backend/src/services/position_service.rs
Normal 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(())
|
||||
}
|
||||
136
backend/src/services/role_service.rs
Normal file
136
backend/src/services/role_service.rs
Normal 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(())
|
||||
}
|
||||
74
backend/src/services/user_service.rs
Normal file
74
backend/src/services/user_service.rs
Normal 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
1
backend/src/utils/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod password;
|
||||
15
backend/src/utils/password.rs
Normal file
15
backend/src/utils/password.rs
Normal 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())
|
||||
}
|
||||
Reference in New Issue
Block a user