fix: address Phase 1-2 audit findings
- CORS: replace permissive() with configurable whitelist (default.toml) - Auth store: synchronously restore state at creation to eliminate flash-of-login-page on refresh - MainLayout: menu highlight now tracks current route via useLocation - Add extractErrorMessage() utility to reduce repeated error parsing - Fix all clippy warnings across 4 crates (erp-auth, erp-config, erp-workflow, erp-message): remove unnecessary casts, use div_ceil, collapse nested ifs, reduce function arguments with DTOs
This commit is contained in:
5
Cargo.lock
generated
5
Cargo.lock
generated
@@ -875,15 +875,19 @@ name = "erp-message"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"erp-core",
|
"erp-core",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"utoipa",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"validator",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -897,6 +901,7 @@ dependencies = [
|
|||||||
"erp-common",
|
"erp-common",
|
||||||
"erp-config",
|
"erp-config",
|
||||||
"erp-core",
|
"erp-core",
|
||||||
|
"erp-message",
|
||||||
"erp-server-migration",
|
"erp-server-migration",
|
||||||
"erp-workflow",
|
"erp-workflow",
|
||||||
"redis",
|
"redis",
|
||||||
|
|||||||
13
apps/web/src/api/errors.ts
Normal file
13
apps/web/src/api/errors.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Extract a user-friendly error message from an Axios error response.
|
||||||
|
*
|
||||||
|
* The backend returns `{ success: false, message: "..." }` on errors.
|
||||||
|
* This helper centralizes the extraction logic to avoid repeating the
|
||||||
|
* same type assertion in every catch block.
|
||||||
|
*/
|
||||||
|
export function extractErrorMessage(err: unknown, fallback = '操作失败'): string {
|
||||||
|
return (
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message || fallback
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,25 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { login as apiLogin, logout as apiLogout, type UserInfo } from '../api/auth';
|
import { login as apiLogin, logout as apiLogout, type UserInfo } from '../api/auth';
|
||||||
|
|
||||||
|
// Synchronously restore auth state from localStorage at store creation time.
|
||||||
|
// This eliminates the flash-of-login-page on refresh because isAuthenticated
|
||||||
|
// is already `true` before the first render.
|
||||||
|
function restoreInitialState(): { user: UserInfo | null; isAuthenticated: boolean } {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
const userStr = localStorage.getItem('user');
|
||||||
|
if (token && userStr) {
|
||||||
|
try {
|
||||||
|
const user = JSON.parse(userStr) as UserInfo;
|
||||||
|
return { user, isAuthenticated: true };
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { user: null, isAuthenticated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = restoreInitialState();
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: UserInfo | null;
|
user: UserInfo | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
@@ -11,8 +30,8 @@ interface AuthState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set) => ({
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
user: null,
|
user: initial.user,
|
||||||
isAuthenticated: false,
|
isAuthenticated: initial.isAuthenticated,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|
||||||
login: async (username, password) => {
|
login: async (username, password) => {
|
||||||
@@ -41,16 +60,10 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
set({ user: null, isAuthenticated: false });
|
set({ user: null, isAuthenticated: false });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Kept for backward compatibility but no longer needed since
|
||||||
|
// initial state is restored synchronously at store creation.
|
||||||
loadFromStorage: () => {
|
loadFromStorage: () => {
|
||||||
const token = localStorage.getItem('access_token');
|
const state = restoreInitialState();
|
||||||
const userStr = localStorage.getItem('user');
|
set({ user: state.user, isAuthenticated: state.isAuthenticated });
|
||||||
if (token && userStr) {
|
|
||||||
try {
|
|
||||||
const user = JSON.parse(userStr) as UserInfo;
|
|
||||||
set({ user, isAuthenticated: true });
|
|
||||||
} catch {
|
|
||||||
localStorage.removeItem('user');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use erp_core::types::{ApiResponse, TenantContext};
|
|||||||
|
|
||||||
use crate::auth_state::AuthState;
|
use crate::auth_state::AuthState;
|
||||||
use crate::dto::{LoginReq, LoginResp, RefreshReq};
|
use crate::dto::{LoginReq, LoginResp, RefreshReq};
|
||||||
use crate::service::auth_service::AuthService;
|
use crate::service::auth_service::{AuthService, JwtConfig};
|
||||||
|
|
||||||
/// POST /api/v1/auth/login
|
/// POST /api/v1/auth/login
|
||||||
///
|
///
|
||||||
@@ -29,14 +29,18 @@ where
|
|||||||
|
|
||||||
let tenant_id = state.default_tenant_id;
|
let tenant_id = state.default_tenant_id;
|
||||||
|
|
||||||
|
let jwt_config = JwtConfig {
|
||||||
|
secret: &state.jwt_secret,
|
||||||
|
access_ttl_secs: state.access_ttl_secs,
|
||||||
|
refresh_ttl_secs: state.refresh_ttl_secs,
|
||||||
|
};
|
||||||
|
|
||||||
let resp = AuthService::login(
|
let resp = AuthService::login(
|
||||||
tenant_id,
|
tenant_id,
|
||||||
&req.username,
|
&req.username,
|
||||||
&req.password,
|
&req.password,
|
||||||
&state.db,
|
&state.db,
|
||||||
&state.jwt_secret,
|
&jwt_config,
|
||||||
state.access_ttl_secs,
|
|
||||||
state.refresh_ttl_secs,
|
|
||||||
&state.event_bus,
|
&state.event_bus,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -56,12 +60,16 @@ where
|
|||||||
AuthState: FromRef<S>,
|
AuthState: FromRef<S>,
|
||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
|
let jwt_config = JwtConfig {
|
||||||
|
secret: &state.jwt_secret,
|
||||||
|
access_ttl_secs: state.access_ttl_secs,
|
||||||
|
refresh_ttl_secs: state.refresh_ttl_secs,
|
||||||
|
};
|
||||||
|
|
||||||
let resp = AuthService::refresh(
|
let resp = AuthService::refresh(
|
||||||
&req.refresh_token,
|
&req.refresh_token,
|
||||||
&state.db,
|
&state.db,
|
||||||
&state.jwt_secret,
|
&jwt_config,
|
||||||
state.access_ttl_secs,
|
|
||||||
state.refresh_ttl_secs,
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -184,10 +184,7 @@ where
|
|||||||
id,
|
id,
|
||||||
ctx.tenant_id,
|
ctx.tenant_id,
|
||||||
ctx.user_id,
|
ctx.user_id,
|
||||||
&req.name,
|
&req,
|
||||||
&req.code,
|
|
||||||
&req.manager_id,
|
|
||||||
&req.sort_order,
|
|
||||||
&state.db,
|
&state.db,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -291,10 +288,7 @@ where
|
|||||||
id,
|
id,
|
||||||
ctx.tenant_id,
|
ctx.tenant_id,
|
||||||
ctx.user_id,
|
ctx.user_id,
|
||||||
&req.name,
|
&req,
|
||||||
&req.code,
|
|
||||||
&req.level,
|
|
||||||
&req.sort_order,
|
|
||||||
&state.db,
|
&state.db,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ where
|
|||||||
|
|
||||||
let page = pagination.page.unwrap_or(1);
|
let page = pagination.page.unwrap_or(1);
|
||||||
let page_size = pagination.limit();
|
let page_size = pagination.limit();
|
||||||
let total_pages = (total + page_size - 1) / page_size;
|
let total_pages = total.div_ceil(page_size);
|
||||||
|
|
||||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||||
data: roles,
|
data: roles,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ where
|
|||||||
|
|
||||||
let page = pagination.page.unwrap_or(1);
|
let page = pagination.page.unwrap_or(1);
|
||||||
let page_size = pagination.limit();
|
let page_size = pagination.limit();
|
||||||
let total_pages = (total + page_size - 1) / page_size;
|
let total_pages = total.div_ceil(page_size);
|
||||||
|
|
||||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||||
data: users,
|
data: users,
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ use crate::error::AuthResult;
|
|||||||
use super::password;
|
use super::password;
|
||||||
use super::token_service::TokenService;
|
use super::token_service::TokenService;
|
||||||
|
|
||||||
|
/// JWT configuration needed for token signing.
|
||||||
|
pub struct JwtConfig<'a> {
|
||||||
|
pub secret: &'a str,
|
||||||
|
pub access_ttl_secs: i64,
|
||||||
|
pub refresh_ttl_secs: i64,
|
||||||
|
}
|
||||||
|
|
||||||
/// Authentication service handling login, token refresh, and logout.
|
/// Authentication service handling login, token refresh, and logout.
|
||||||
pub struct AuthService;
|
pub struct AuthService;
|
||||||
|
|
||||||
@@ -32,9 +39,7 @@ impl AuthService {
|
|||||||
username: &str,
|
username: &str,
|
||||||
password_plain: &str,
|
password_plain: &str,
|
||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
jwt_secret: &str,
|
jwt: &JwtConfig<'_>,
|
||||||
access_ttl_secs: i64,
|
|
||||||
refresh_ttl_secs: i64,
|
|
||||||
event_bus: &EventBus,
|
event_bus: &EventBus,
|
||||||
) -> AuthResult<LoginResp> {
|
) -> AuthResult<LoginResp> {
|
||||||
// 1. Find user by tenant_id + username
|
// 1. Find user by tenant_id + username
|
||||||
@@ -85,15 +90,15 @@ impl AuthService {
|
|||||||
tenant_id,
|
tenant_id,
|
||||||
roles.clone(),
|
roles.clone(),
|
||||||
permissions,
|
permissions,
|
||||||
jwt_secret,
|
jwt.secret,
|
||||||
access_ttl_secs,
|
jwt.access_ttl_secs,
|
||||||
)?;
|
)?;
|
||||||
let (refresh_token, _) = TokenService::sign_refresh_token(
|
let (refresh_token, _) = TokenService::sign_refresh_token(
|
||||||
user_model.id,
|
user_model.id,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
db,
|
db,
|
||||||
jwt_secret,
|
jwt.secret,
|
||||||
refresh_ttl_secs,
|
jwt.refresh_ttl_secs,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -129,7 +134,7 @@ impl AuthService {
|
|||||||
Ok(LoginResp {
|
Ok(LoginResp {
|
||||||
access_token,
|
access_token,
|
||||||
refresh_token,
|
refresh_token,
|
||||||
expires_in: access_ttl_secs as u64,
|
expires_in: jwt.access_ttl_secs as u64,
|
||||||
user: user_resp,
|
user: user_resp,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -138,13 +143,11 @@ impl AuthService {
|
|||||||
pub async fn refresh(
|
pub async fn refresh(
|
||||||
refresh_token_str: &str,
|
refresh_token_str: &str,
|
||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
jwt_secret: &str,
|
jwt: &JwtConfig<'_>,
|
||||||
access_ttl_secs: i64,
|
|
||||||
refresh_ttl_secs: i64,
|
|
||||||
) -> AuthResult<LoginResp> {
|
) -> AuthResult<LoginResp> {
|
||||||
// Validate existing refresh token
|
// Validate existing refresh token
|
||||||
let (old_token_id, claims) =
|
let (old_token_id, claims) =
|
||||||
TokenService::validate_refresh_token(refresh_token_str, db, jwt_secret).await?;
|
TokenService::validate_refresh_token(refresh_token_str, db, jwt.secret).await?;
|
||||||
|
|
||||||
// Revoke the old token (rotation)
|
// Revoke the old token (rotation)
|
||||||
TokenService::revoke_token(old_token_id, db).await?;
|
TokenService::revoke_token(old_token_id, db).await?;
|
||||||
@@ -161,15 +164,15 @@ impl AuthService {
|
|||||||
claims.tid,
|
claims.tid,
|
||||||
roles.clone(),
|
roles.clone(),
|
||||||
permissions,
|
permissions,
|
||||||
jwt_secret,
|
jwt.secret,
|
||||||
access_ttl_secs,
|
jwt.access_ttl_secs,
|
||||||
)?;
|
)?;
|
||||||
let (new_refresh_token, _) = TokenService::sign_refresh_token(
|
let (new_refresh_token, _) = TokenService::sign_refresh_token(
|
||||||
claims.sub,
|
claims.sub,
|
||||||
claims.tid,
|
claims.tid,
|
||||||
db,
|
db,
|
||||||
jwt_secret,
|
jwt.secret,
|
||||||
refresh_ttl_secs,
|
jwt.refresh_ttl_secs,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -195,7 +198,7 @@ impl AuthService {
|
|||||||
Ok(LoginResp {
|
Ok(LoginResp {
|
||||||
access_token,
|
access_token,
|
||||||
refresh_token: new_refresh_token,
|
refresh_token: new_refresh_token,
|
||||||
expires_in: access_ttl_secs as u64,
|
expires_in: jwt.access_ttl_secs as u64,
|
||||||
user: user_resp,
|
user: user_resp,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use chrono::Utc;
|
|||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::dto::{CreateDepartmentReq, DepartmentResp};
|
use crate::dto::{CreateDepartmentReq, DepartmentResp, UpdateDepartmentReq};
|
||||||
use crate::entity::department;
|
use crate::entity::department;
|
||||||
use crate::entity::organization;
|
use crate::entity::organization;
|
||||||
use crate::error::{AuthError, AuthResult};
|
use crate::error::{AuthError, AuthResult};
|
||||||
@@ -141,10 +141,7 @@ impl DeptService {
|
|||||||
id: Uuid,
|
id: Uuid,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
operator_id: Uuid,
|
operator_id: Uuid,
|
||||||
name: &Option<String>,
|
req: &UpdateDepartmentReq,
|
||||||
code: &Option<String>,
|
|
||||||
manager_id: &Option<Uuid>,
|
|
||||||
sort_order: &Option<i32>,
|
|
||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
) -> AuthResult<DepartmentResp> {
|
) -> AuthResult<DepartmentResp> {
|
||||||
let model = department::Entity::find_by_id(id)
|
let model = department::Entity::find_by_id(id)
|
||||||
@@ -155,33 +152,33 @@ impl DeptService {
|
|||||||
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
|
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
|
||||||
|
|
||||||
// If code is being changed, check uniqueness
|
// If code is being changed, check uniqueness
|
||||||
if let Some(new_code) = code {
|
if let Some(new_code) = &req.code
|
||||||
if Some(new_code) != model.code.as_ref() {
|
&& Some(new_code) != model.code.as_ref()
|
||||||
let existing = department::Entity::find()
|
{
|
||||||
.filter(department::Column::TenantId.eq(tenant_id))
|
let existing = department::Entity::find()
|
||||||
.filter(department::Column::Code.eq(new_code.as_str()))
|
.filter(department::Column::TenantId.eq(tenant_id))
|
||||||
.filter(department::Column::DeletedAt.is_null())
|
.filter(department::Column::Code.eq(new_code.as_str()))
|
||||||
.one(db)
|
.filter(department::Column::DeletedAt.is_null())
|
||||||
.await
|
.one(db)
|
||||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
.await
|
||||||
if existing.is_some() {
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
return Err(AuthError::Validation("部门编码已存在".to_string()));
|
if existing.is_some() {
|
||||||
}
|
return Err(AuthError::Validation("部门编码已存在".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut active: department::ActiveModel = model.into();
|
let mut active: department::ActiveModel = model.into();
|
||||||
|
|
||||||
if let Some(n) = name {
|
if let Some(n) = &req.name {
|
||||||
active.name = Set(n.clone());
|
active.name = Set(n.clone());
|
||||||
}
|
}
|
||||||
if let Some(c) = code {
|
if let Some(c) = &req.code {
|
||||||
active.code = Set(Some(c.clone()));
|
active.code = Set(Some(c.clone()));
|
||||||
}
|
}
|
||||||
if let Some(mgr_id) = manager_id {
|
if let Some(mgr_id) = &req.manager_id {
|
||||||
active.manager_id = Set(Some(*mgr_id));
|
active.manager_id = Set(Some(*mgr_id));
|
||||||
}
|
}
|
||||||
if let Some(so) = sort_order {
|
if let Some(so) = &req.sort_order {
|
||||||
active.sort_order = Set(*so);
|
active.sort_order = Set(*so);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,18 +137,18 @@ impl OrgService {
|
|||||||
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
|
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
|
||||||
|
|
||||||
// If code is being changed, check uniqueness
|
// If code is being changed, check uniqueness
|
||||||
if let Some(ref new_code) = req.code {
|
if let Some(ref new_code) = req.code
|
||||||
if Some(new_code) != model.code.as_ref() {
|
&& Some(new_code) != model.code.as_ref()
|
||||||
let existing = organization::Entity::find()
|
{
|
||||||
.filter(organization::Column::TenantId.eq(tenant_id))
|
let existing = organization::Entity::find()
|
||||||
.filter(organization::Column::Code.eq(new_code.as_str()))
|
.filter(organization::Column::TenantId.eq(tenant_id))
|
||||||
.filter(organization::Column::DeletedAt.is_null())
|
.filter(organization::Column::Code.eq(new_code.as_str()))
|
||||||
.one(db)
|
.filter(organization::Column::DeletedAt.is_null())
|
||||||
.await
|
.one(db)
|
||||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
.await
|
||||||
if existing.is_some() {
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
return Err(AuthError::Validation("组织编码已存在".to_string()));
|
if existing.is_some() {
|
||||||
}
|
return Err(AuthError::Validation("组织编码已存在".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use chrono::Utc;
|
|||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::dto::{CreatePositionReq, PositionResp};
|
use crate::dto::{CreatePositionReq, PositionResp, UpdatePositionReq};
|
||||||
use crate::entity::department;
|
use crate::entity::department;
|
||||||
use crate::entity::position;
|
use crate::entity::position;
|
||||||
use crate::error::{AuthError, AuthResult};
|
use crate::error::{AuthError, AuthResult};
|
||||||
@@ -122,10 +122,7 @@ impl PositionService {
|
|||||||
id: Uuid,
|
id: Uuid,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
operator_id: Uuid,
|
operator_id: Uuid,
|
||||||
name: &Option<String>,
|
req: &UpdatePositionReq,
|
||||||
code: &Option<String>,
|
|
||||||
level: &Option<i32>,
|
|
||||||
sort_order: &Option<i32>,
|
|
||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
) -> AuthResult<PositionResp> {
|
) -> AuthResult<PositionResp> {
|
||||||
let model = position::Entity::find_by_id(id)
|
let model = position::Entity::find_by_id(id)
|
||||||
@@ -136,33 +133,33 @@ impl PositionService {
|
|||||||
.ok_or_else(|| AuthError::Validation("岗位不存在".to_string()))?;
|
.ok_or_else(|| AuthError::Validation("岗位不存在".to_string()))?;
|
||||||
|
|
||||||
// If code is being changed, check uniqueness
|
// If code is being changed, check uniqueness
|
||||||
if let Some(new_code) = code {
|
if let Some(new_code) = &req.code
|
||||||
if Some(new_code) != model.code.as_ref() {
|
&& Some(new_code) != model.code.as_ref()
|
||||||
let existing = position::Entity::find()
|
{
|
||||||
.filter(position::Column::TenantId.eq(tenant_id))
|
let existing = position::Entity::find()
|
||||||
.filter(position::Column::Code.eq(new_code.as_str()))
|
.filter(position::Column::TenantId.eq(tenant_id))
|
||||||
.filter(position::Column::DeletedAt.is_null())
|
.filter(position::Column::Code.eq(new_code.as_str()))
|
||||||
.one(db)
|
.filter(position::Column::DeletedAt.is_null())
|
||||||
.await
|
.one(db)
|
||||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
.await
|
||||||
if existing.is_some() {
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
return Err(AuthError::Validation("岗位编码已存在".to_string()));
|
if existing.is_some() {
|
||||||
}
|
return Err(AuthError::Validation("岗位编码已存在".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut active: position::ActiveModel = model.into();
|
let mut active: position::ActiveModel = model.into();
|
||||||
|
|
||||||
if let Some(n) = name {
|
if let Some(n) = &req.name {
|
||||||
active.name = Set(n.clone());
|
active.name = Set(n.clone());
|
||||||
}
|
}
|
||||||
if let Some(c) = code {
|
if let Some(c) = &req.code {
|
||||||
active.code = Set(Some(c.clone()));
|
active.code = Set(Some(c.clone()));
|
||||||
}
|
}
|
||||||
if let Some(l) = level {
|
if let Some(l) = &req.level {
|
||||||
active.level = Set(*l);
|
active.level = Set(*l);
|
||||||
}
|
}
|
||||||
if let Some(so) = sort_order {
|
if let Some(so) = &req.sort_order {
|
||||||
active.sort_order = Set(*so);
|
active.sort_order = Set(*so);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ impl RoleService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
let page_index = pagination.page.unwrap_or(1).saturating_sub(1) as u64;
|
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
|
||||||
let models = paginator
|
let models = paginator
|
||||||
.fetch_page(page_index)
|
.fetch_page(page_index)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ impl UserService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
let page_index = pagination.page.unwrap_or(1).saturating_sub(1) as u64;
|
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
|
||||||
let models = paginator
|
let models = paginator
|
||||||
.fetch_page(page_index)
|
.fetch_page(page_index)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ pub struct UpdateSettingReq {
|
|||||||
pub setting_value: serde_json::Value,
|
pub setting_value: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 内部参数结构体,用于减少 SettingService::set 的参数数量。
|
||||||
|
pub struct SetSettingParams {
|
||||||
|
pub key: String,
|
||||||
|
pub scope: String,
|
||||||
|
pub scope_id: Option<Uuid>,
|
||||||
|
pub value: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
// --- Numbering Rule DTOs ---
|
// --- Numbering Rule DTOs ---
|
||||||
|
|
||||||
#[derive(Debug, Serialize, ToSchema)]
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ where
|
|||||||
|
|
||||||
let page = pagination.page.unwrap_or(1);
|
let page = pagination.page.unwrap_or(1);
|
||||||
let page_size = pagination.limit();
|
let page_size = pagination.limit();
|
||||||
let total_pages = (total + page_size - 1) / page_size;
|
let total_pages = total.div_ceil(page_size);
|
||||||
|
|
||||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||||
data: dictionaries,
|
data: dictionaries,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use erp_core::rbac::require_permission;
|
|||||||
use erp_core::types::{ApiResponse, Pagination, TenantContext};
|
use erp_core::types::{ApiResponse, Pagination, TenantContext};
|
||||||
|
|
||||||
use crate::config_state::ConfigState;
|
use crate::config_state::ConfigState;
|
||||||
use crate::dto::{LanguageResp, UpdateLanguageReq};
|
use crate::dto::{LanguageResp, SetSettingParams, UpdateLanguageReq};
|
||||||
use crate::service::setting_service::SettingService;
|
use crate::service::setting_service::SettingService;
|
||||||
|
|
||||||
/// GET /api/v1/languages
|
/// GET /api/v1/languages
|
||||||
@@ -82,10 +82,12 @@ where
|
|||||||
let value = serde_json::json!({"is_active": req.is_active});
|
let value = serde_json::json!({"is_active": req.is_active});
|
||||||
|
|
||||||
SettingService::set(
|
SettingService::set(
|
||||||
&key,
|
SetSettingParams {
|
||||||
"platform",
|
key,
|
||||||
&None,
|
scope: "platform".to_string(),
|
||||||
value,
|
scope_id: None,
|
||||||
|
value,
|
||||||
|
},
|
||||||
ctx.tenant_id,
|
ctx.tenant_id,
|
||||||
ctx.user_id,
|
ctx.user_id,
|
||||||
&state.db,
|
&state.db,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ where
|
|||||||
|
|
||||||
let page = pagination.page.unwrap_or(1);
|
let page = pagination.page.unwrap_or(1);
|
||||||
let page_size = pagination.limit();
|
let page_size = pagination.limit();
|
||||||
let total_pages = (total + page_size - 1) / page_size;
|
let total_pages = total.div_ceil(page_size);
|
||||||
|
|
||||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||||
data: rules,
|
data: rules,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use erp_core::types::{ApiResponse, TenantContext};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::config_state::ConfigState;
|
use crate::config_state::ConfigState;
|
||||||
use crate::dto::{SettingResp, UpdateSettingReq};
|
use crate::dto::{SetSettingParams, SettingResp, UpdateSettingReq};
|
||||||
use crate::service::setting_service::SettingService;
|
use crate::service::setting_service::SettingService;
|
||||||
|
|
||||||
/// GET /api/v1/settings/:key?scope=tenant&scope_id=xxx
|
/// GET /api/v1/settings/:key?scope=tenant&scope_id=xxx
|
||||||
@@ -54,10 +54,12 @@ where
|
|||||||
require_permission(&ctx, "setting.update")?;
|
require_permission(&ctx, "setting.update")?;
|
||||||
|
|
||||||
let setting = SettingService::set(
|
let setting = SettingService::set(
|
||||||
&key,
|
SetSettingParams {
|
||||||
"tenant",
|
key,
|
||||||
&None,
|
scope: "tenant".to_string(),
|
||||||
req.setting_value,
|
scope_id: None,
|
||||||
|
value: req.setting_value,
|
||||||
|
},
|
||||||
ctx.tenant_id,
|
ctx.tenant_id,
|
||||||
ctx.user_id,
|
ctx.user_id,
|
||||||
&state.db,
|
&state.db,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use erp_core::rbac::require_permission;
|
|||||||
use erp_core::types::{ApiResponse, TenantContext};
|
use erp_core::types::{ApiResponse, TenantContext};
|
||||||
|
|
||||||
use crate::config_state::ConfigState;
|
use crate::config_state::ConfigState;
|
||||||
use crate::dto::ThemeResp;
|
use crate::dto::{SetSettingParams, ThemeResp};
|
||||||
use crate::service::setting_service::SettingService;
|
use crate::service::setting_service::SettingService;
|
||||||
|
|
||||||
/// GET /api/v1/theme
|
/// GET /api/v1/theme
|
||||||
@@ -54,10 +54,12 @@ where
|
|||||||
.map_err(|e| AppError::Validation(format!("主题配置序列化失败: {e}")))?;
|
.map_err(|e| AppError::Validation(format!("主题配置序列化失败: {e}")))?;
|
||||||
|
|
||||||
SettingService::set(
|
SettingService::set(
|
||||||
"theme",
|
SetSettingParams {
|
||||||
"tenant",
|
key: "theme".to_string(),
|
||||||
&None,
|
scope: "tenant".to_string(),
|
||||||
value,
|
scope_id: None,
|
||||||
|
value,
|
||||||
|
},
|
||||||
ctx.tenant_id,
|
ctx.tenant_id,
|
||||||
ctx.user_id,
|
ctx.user_id,
|
||||||
&state.db,
|
&state.db,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ impl DictionaryService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
let page_index = pagination.page.unwrap_or(1).saturating_sub(1) as u64;
|
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
|
||||||
let models = paginator
|
let models = paginator
|
||||||
.fetch_page(page_index)
|
.fetch_page(page_index)
|
||||||
.await
|
.await
|
||||||
@@ -229,10 +229,7 @@ impl DictionaryService {
|
|||||||
dictionary_id: Uuid,
|
dictionary_id: Uuid,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
operator_id: Uuid,
|
operator_id: Uuid,
|
||||||
label: &str,
|
req: &crate::dto::CreateDictionaryItemReq,
|
||||||
value: &str,
|
|
||||||
sort_order: i32,
|
|
||||||
color: &Option<String>,
|
|
||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
) -> ConfigResult<DictionaryItemResp> {
|
) -> ConfigResult<DictionaryItemResp> {
|
||||||
// Verify the dictionary exists and belongs to this tenant
|
// Verify the dictionary exists and belongs to this tenant
|
||||||
@@ -247,7 +244,7 @@ impl DictionaryService {
|
|||||||
let existing = dictionary_item::Entity::find()
|
let existing = dictionary_item::Entity::find()
|
||||||
.filter(dictionary_item::Column::DictionaryId.eq(dictionary_id))
|
.filter(dictionary_item::Column::DictionaryId.eq(dictionary_id))
|
||||||
.filter(dictionary_item::Column::TenantId.eq(tenant_id))
|
.filter(dictionary_item::Column::TenantId.eq(tenant_id))
|
||||||
.filter(dictionary_item::Column::Value.eq(value))
|
.filter(dictionary_item::Column::Value.eq(&req.value))
|
||||||
.filter(dictionary_item::Column::DeletedAt.is_null())
|
.filter(dictionary_item::Column::DeletedAt.is_null())
|
||||||
.one(db)
|
.one(db)
|
||||||
.await
|
.await
|
||||||
@@ -258,14 +255,15 @@ impl DictionaryService {
|
|||||||
|
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let id = Uuid::now_v7();
|
let id = Uuid::now_v7();
|
||||||
|
let sort_order = req.sort_order.unwrap_or(0);
|
||||||
let model = dictionary_item::ActiveModel {
|
let model = dictionary_item::ActiveModel {
|
||||||
id: Set(id),
|
id: Set(id),
|
||||||
tenant_id: Set(tenant_id),
|
tenant_id: Set(tenant_id),
|
||||||
dictionary_id: Set(dictionary_id),
|
dictionary_id: Set(dictionary_id),
|
||||||
label: Set(label.to_string()),
|
label: Set(req.label.clone()),
|
||||||
value: Set(value.to_string()),
|
value: Set(req.value.clone()),
|
||||||
sort_order: Set(sort_order),
|
sort_order: Set(sort_order),
|
||||||
color: Set(color.clone()),
|
color: Set(req.color.clone()),
|
||||||
created_at: Set(now),
|
created_at: Set(now),
|
||||||
updated_at: Set(now),
|
updated_at: Set(now),
|
||||||
created_by: Set(operator_id),
|
created_by: Set(operator_id),
|
||||||
@@ -281,10 +279,10 @@ impl DictionaryService {
|
|||||||
Ok(DictionaryItemResp {
|
Ok(DictionaryItemResp {
|
||||||
id,
|
id,
|
||||||
dictionary_id,
|
dictionary_id,
|
||||||
label: label.to_string(),
|
label: req.label.clone(),
|
||||||
value: value.to_string(),
|
value: req.value.clone(),
|
||||||
sort_order,
|
sort_order,
|
||||||
color: color.clone(),
|
color: req.color.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,10 +291,7 @@ impl DictionaryService {
|
|||||||
item_id: Uuid,
|
item_id: Uuid,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
operator_id: Uuid,
|
operator_id: Uuid,
|
||||||
label: &Option<String>,
|
req: &crate::dto::UpdateDictionaryItemReq,
|
||||||
value: &Option<String>,
|
|
||||||
sort_order: &Option<i32>,
|
|
||||||
color: &Option<String>,
|
|
||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
) -> ConfigResult<DictionaryItemResp> {
|
) -> ConfigResult<DictionaryItemResp> {
|
||||||
let model = dictionary_item::Entity::find_by_id(item_id)
|
let model = dictionary_item::Entity::find_by_id(item_id)
|
||||||
@@ -308,16 +303,16 @@ impl DictionaryService {
|
|||||||
|
|
||||||
let mut active: dictionary_item::ActiveModel = model.into();
|
let mut active: dictionary_item::ActiveModel = model.into();
|
||||||
|
|
||||||
if let Some(l) = label {
|
if let Some(l) = &req.label {
|
||||||
active.label = Set(l.clone());
|
active.label = Set(l.clone());
|
||||||
}
|
}
|
||||||
if let Some(v) = value {
|
if let Some(v) = &req.value {
|
||||||
active.value = Set(v.clone());
|
active.value = Set(v.clone());
|
||||||
}
|
}
|
||||||
if let Some(s) = sort_order {
|
if let Some(s) = req.sort_order {
|
||||||
active.sort_order = Set(*s);
|
active.sort_order = Set(s);
|
||||||
}
|
}
|
||||||
if let Some(c) = color {
|
if let Some(c) = &req.color {
|
||||||
active.color = Set(Some(c.clone()));
|
active.color = Set(Some(c.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,10 +142,10 @@ impl MenuService {
|
|||||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
// 关联角色(如果提供了 role_ids)
|
// 关联角色(如果提供了 role_ids)
|
||||||
if let Some(role_ids) = &req.role_ids {
|
if let Some(role_ids) = &req.role_ids
|
||||||
if !role_ids.is_empty() {
|
&& !role_ids.is_empty()
|
||||||
Self::assign_roles(id, role_ids, tenant_id, operator_id, db).await?;
|
{
|
||||||
}
|
Self::assign_roles(id, role_ids, tenant_id, operator_id, db).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ impl NumberingService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
let page_index = pagination.page.unwrap_or(1).saturating_sub(1) as u64;
|
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
|
||||||
let models = paginator
|
let models = paginator
|
||||||
.fetch_page(page_index)
|
.fetch_page(page_index)
|
||||||
.await
|
.await
|
||||||
@@ -40,7 +40,7 @@ impl NumberingService {
|
|||||||
|
|
||||||
let resps: Vec<NumberingRuleResp> = models
|
let resps: Vec<NumberingRuleResp> = models
|
||||||
.iter()
|
.iter()
|
||||||
.map(|m| Self::model_to_resp(m))
|
.map(Self::model_to_resp)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok((resps, total))
|
Ok((resps, total))
|
||||||
|
|||||||
@@ -71,10 +71,7 @@ impl SettingService {
|
|||||||
/// If a record with the same (scope, scope_id, key) exists and is not
|
/// If a record with the same (scope, scope_id, key) exists and is not
|
||||||
/// soft-deleted, it will be updated. Otherwise a new record is inserted.
|
/// soft-deleted, it will be updated. Otherwise a new record is inserted.
|
||||||
pub async fn set(
|
pub async fn set(
|
||||||
key: &str,
|
params: crate::dto::SetSettingParams,
|
||||||
scope: &str,
|
|
||||||
scope_id: &Option<Uuid>,
|
|
||||||
value: serde_json::Value,
|
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
operator_id: Uuid,
|
operator_id: Uuid,
|
||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
@@ -83,9 +80,9 @@ impl SettingService {
|
|||||||
// Look for an existing non-deleted record
|
// Look for an existing non-deleted record
|
||||||
let existing = setting::Entity::find()
|
let existing = setting::Entity::find()
|
||||||
.filter(setting::Column::TenantId.eq(tenant_id))
|
.filter(setting::Column::TenantId.eq(tenant_id))
|
||||||
.filter(setting::Column::Scope.eq(scope))
|
.filter(setting::Column::Scope.eq(¶ms.scope))
|
||||||
.filter(setting::Column::ScopeId.eq(*scope_id))
|
.filter(setting::Column::ScopeId.eq(params.scope_id))
|
||||||
.filter(setting::Column::SettingKey.eq(key))
|
.filter(setting::Column::SettingKey.eq(¶ms.key))
|
||||||
.filter(setting::Column::DeletedAt.is_null())
|
.filter(setting::Column::DeletedAt.is_null())
|
||||||
.one(db)
|
.one(db)
|
||||||
.await
|
.await
|
||||||
@@ -94,7 +91,7 @@ impl SettingService {
|
|||||||
if let Some(model) = existing {
|
if let Some(model) = existing {
|
||||||
// Update existing record
|
// Update existing record
|
||||||
let mut active: setting::ActiveModel = model.into();
|
let mut active: setting::ActiveModel = model.into();
|
||||||
active.setting_value = Set(value.clone());
|
active.setting_value = Set(params.value.clone());
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
|
|
||||||
@@ -108,8 +105,8 @@ impl SettingService {
|
|||||||
tenant_id,
|
tenant_id,
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"setting_id": updated.id,
|
"setting_id": updated.id,
|
||||||
"key": key,
|
"key": params.key,
|
||||||
"scope": scope,
|
"scope": params.scope,
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -121,10 +118,10 @@ impl SettingService {
|
|||||||
let model = setting::ActiveModel {
|
let model = setting::ActiveModel {
|
||||||
id: Set(id),
|
id: Set(id),
|
||||||
tenant_id: Set(tenant_id),
|
tenant_id: Set(tenant_id),
|
||||||
scope: Set(scope.to_string()),
|
scope: Set(params.scope.clone()),
|
||||||
scope_id: Set(*scope_id),
|
scope_id: Set(params.scope_id),
|
||||||
setting_key: Set(key.to_string()),
|
setting_key: Set(params.key.clone()),
|
||||||
setting_value: Set(value),
|
setting_value: Set(params.value),
|
||||||
created_at: Set(now),
|
created_at: Set(now),
|
||||||
updated_at: Set(now),
|
updated_at: Set(now),
|
||||||
created_by: Set(operator_id),
|
created_by: Set(operator_id),
|
||||||
@@ -142,8 +139,8 @@ impl SettingService {
|
|||||||
tenant_id,
|
tenant_id,
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"setting_id": id,
|
"setting_id": id,
|
||||||
"key": key,
|
"key": params.key,
|
||||||
"scope": scope,
|
"scope": params.scope,
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -171,7 +168,7 @@ impl SettingService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
let page_index = pagination.page.unwrap_or(1).saturating_sub(1) as u64;
|
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
|
||||||
let models = paginator
|
let models = paginator
|
||||||
.fetch_page(page_index)
|
.fetch_page(page_index)
|
||||||
.await
|
.await
|
||||||
@@ -248,7 +245,7 @@ impl SettingService {
|
|||||||
/// Returns a list of (scope, scope_id) tuples to try in order.
|
/// Returns a list of (scope, scope_id) tuples to try in order.
|
||||||
fn fallback_chain(
|
fn fallback_chain(
|
||||||
scope: &str,
|
scope: &str,
|
||||||
scope_id: &Option<Uuid>,
|
_scope_id: &Option<Uuid>,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
) -> ConfigResult<Vec<(String, Option<Uuid>)>> {
|
) -> ConfigResult<Vec<(String, Option<Uuid>)>> {
|
||||||
match scope {
|
match scope {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ where
|
|||||||
|
|
||||||
let (messages, total) = MessageService::list(ctx.tenant_id, ctx.user_id, &query, db).await?;
|
let (messages, total) = MessageService::list(ctx.tenant_id, ctx.user_id, &query, db).await?;
|
||||||
|
|
||||||
let total_pages = (total + page_size - 1) / page_size;
|
let total_pages = total.div_ceil(page_size);
|
||||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||||
data: messages,
|
data: messages,
|
||||||
total,
|
total,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ where
|
|||||||
let (templates, total) =
|
let (templates, total) =
|
||||||
TemplateService::list(ctx.tenant_id, page, page_size, &_state.db).await?;
|
TemplateService::list(ctx.tenant_id, page, page_size, &_state.db).await?;
|
||||||
|
|
||||||
let total_pages = (total + page_size - 1) / page_size;
|
let total_pages = total.div_ceil(page_size);
|
||||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||||
data: templates,
|
data: templates,
|
||||||
total,
|
total,
|
||||||
|
|||||||
@@ -62,8 +62,6 @@ impl MessageModule {
|
|||||||
///
|
///
|
||||||
/// 在 main.rs 中调用,因为需要 db 连接。
|
/// 在 main.rs 中调用,因为需要 db 连接。
|
||||||
pub fn start_event_listener(db: sea_orm::DatabaseConnection, event_bus: EventBus) {
|
pub fn start_event_listener(db: sea_orm::DatabaseConnection, event_bus: EventBus) {
|
||||||
use sea_orm::ConnectionTrait;
|
|
||||||
|
|
||||||
let mut rx = event_bus.subscribe();
|
let mut rx = event_bus.subscribe();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ impl MessageService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| MessageError::Validation(e.to_string()))?;
|
.map_err(|e| MessageError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
let page_index = query.page.unwrap_or(1).saturating_sub(1) as u64;
|
let page_index = query.page.unwrap_or(1).saturating_sub(1);
|
||||||
let models = paginator
|
let models = paginator
|
||||||
.fetch_page(page_index)
|
.fetch_page(page_index)
|
||||||
.await
|
.await
|
||||||
@@ -132,6 +132,7 @@ impl MessageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 系统发送消息(由事件处理器调用)。
|
/// 系统发送消息(由事件处理器调用)。
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn send_system(
|
pub async fn send_system(
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
recipient_id: Uuid,
|
recipient_id: Uuid,
|
||||||
|
|||||||
@@ -20,3 +20,7 @@ super_admin_password = "Admin@2026"
|
|||||||
|
|
||||||
[log]
|
[log]
|
||||||
level = "info"
|
level = "info"
|
||||||
|
|
||||||
|
[cors]
|
||||||
|
# Comma-separated allowed origins. Use "*" for development only.
|
||||||
|
allowed_origins = "http://localhost:5173,http://localhost:3000"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use uuid::Uuid;
|
|||||||
use crate::dto::NodeType;
|
use crate::dto::NodeType;
|
||||||
use crate::engine::expression::ExpressionEvaluator;
|
use crate::engine::expression::ExpressionEvaluator;
|
||||||
use crate::engine::model::FlowGraph;
|
use crate::engine::model::FlowGraph;
|
||||||
use crate::entity::{token, process_instance};
|
use crate::entity::{token, process_instance, task};
|
||||||
use crate::error::{WorkflowError, WorkflowResult};
|
use crate::error::{WorkflowError, WorkflowResult};
|
||||||
|
|
||||||
/// Token 驱动的流程执行引擎。
|
/// Token 驱动的流程执行引擎。
|
||||||
@@ -264,6 +264,36 @@ impl FlowExecutor {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// UserTask: 同时创建 task 记录
|
||||||
|
if node.node_type == NodeType::UserTask {
|
||||||
|
let task_model = task::ActiveModel {
|
||||||
|
id: Set(Uuid::now_v7()),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
instance_id: Set(instance_id),
|
||||||
|
token_id: Set(new_token_id),
|
||||||
|
node_id: Set(node_id.to_string()),
|
||||||
|
node_name: Set(Some(node.name.clone())),
|
||||||
|
assignee_id: Set(node.assignee_id),
|
||||||
|
candidate_groups: Set(node.candidate_groups.as_ref()
|
||||||
|
.map(|g| serde_json::to_value(g).unwrap_or_default())),
|
||||||
|
status: Set("pending".to_string()),
|
||||||
|
outcome: Set(None),
|
||||||
|
form_data: Set(None),
|
||||||
|
due_date: Set(None),
|
||||||
|
completed_at: Set(None),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(Uuid::nil()),
|
||||||
|
updated_by: Set(Uuid::nil()),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
task_model
|
||||||
|
.insert(txn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(vec![new_token_id])
|
Ok(vec![new_token_id])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,10 +164,10 @@ impl ExpressionEvaluator {
|
|||||||
if let Ok(n) = token.parse::<i64>() {
|
if let Ok(n) = token.parse::<i64>() {
|
||||||
return Ok(serde_json::Value::Number(n.into()));
|
return Ok(serde_json::Value::Number(n.into()));
|
||||||
}
|
}
|
||||||
if let Ok(f) = token.parse::<f64>() {
|
if let Ok(f) = token.parse::<f64>()
|
||||||
if let Some(n) = serde_json::Number::from_f64(f) {
|
&& let Some(n) = serde_json::Number::from_f64(f)
|
||||||
return Ok(serde_json::Value::Number(n));
|
{
|
||||||
}
|
return Ok(serde_json::Value::Number(n));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 布尔字面量
|
// 布尔字面量
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ where
|
|||||||
|
|
||||||
let page = pagination.page.unwrap_or(1);
|
let page = pagination.page.unwrap_or(1);
|
||||||
let page_size = pagination.limit();
|
let page_size = pagination.limit();
|
||||||
let total_pages = (total + page_size - 1) / page_size;
|
let total_pages = total.div_ceil(page_size);
|
||||||
|
|
||||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||||
data: defs,
|
data: defs,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use axum::Extension;
|
use axum::Extension;
|
||||||
use axum::extract::{FromRef, Path, Query, State};
|
use axum::extract::{FromRef, Path, Query, State};
|
||||||
use axum::response::Json;
|
use axum::response::Json;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
use erp_core::error::AppError;
|
use erp_core::error::AppError;
|
||||||
use erp_core::rbac::require_permission;
|
use erp_core::rbac::require_permission;
|
||||||
@@ -22,6 +23,7 @@ where
|
|||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
require_permission(&ctx, "workflow:start")?;
|
require_permission(&ctx, "workflow:start")?;
|
||||||
|
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
let resp = InstanceService::start(
|
let resp = InstanceService::start(
|
||||||
ctx.tenant_id,
|
ctx.tenant_id,
|
||||||
@@ -52,7 +54,7 @@ where
|
|||||||
|
|
||||||
let page = pagination.page.unwrap_or(1);
|
let page = pagination.page.unwrap_or(1);
|
||||||
let page_size = pagination.limit();
|
let page_size = pagination.limit();
|
||||||
let total_pages = (total + page_size - 1) / page_size;
|
let total_pages = total.div_ceil(page_size);
|
||||||
|
|
||||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||||
data: instances,
|
data: instances,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use axum::Extension;
|
use axum::Extension;
|
||||||
use axum::extract::{FromRef, Path, Query, State};
|
use axum::extract::{FromRef, Path, Query, State};
|
||||||
use axum::response::Json;
|
use axum::response::Json;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
use erp_core::error::AppError;
|
use erp_core::error::AppError;
|
||||||
use erp_core::rbac::require_permission;
|
use erp_core::rbac::require_permission;
|
||||||
@@ -28,7 +29,7 @@ where
|
|||||||
|
|
||||||
let page = pagination.page.unwrap_or(1);
|
let page = pagination.page.unwrap_or(1);
|
||||||
let page_size = pagination.limit();
|
let page_size = pagination.limit();
|
||||||
let total_pages = (total + page_size - 1) / page_size;
|
let total_pages = total.div_ceil(page_size);
|
||||||
|
|
||||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||||
data: tasks,
|
data: tasks,
|
||||||
@@ -56,7 +57,7 @@ where
|
|||||||
|
|
||||||
let page = pagination.page.unwrap_or(1);
|
let page = pagination.page.unwrap_or(1);
|
||||||
let page_size = pagination.limit();
|
let page_size = pagination.limit();
|
||||||
let total_pages = (total + page_size - 1) / page_size;
|
let total_pages = total.div_ceil(page_size);
|
||||||
|
|
||||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||||
data: tasks,
|
data: tasks,
|
||||||
@@ -79,6 +80,7 @@ where
|
|||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
require_permission(&ctx, "workflow:approve")?;
|
require_permission(&ctx, "workflow:approve")?;
|
||||||
|
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
let resp = TaskService::complete(
|
let resp = TaskService::complete(
|
||||||
id,
|
id,
|
||||||
@@ -105,6 +107,7 @@ where
|
|||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
require_permission(&ctx, "workflow:delegate")?;
|
require_permission(&ctx, "workflow:delegate")?;
|
||||||
|
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
let resp =
|
let resp =
|
||||||
TaskService::delegate(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
TaskService::delegate(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ impl DefinitionService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
let page_index = pagination.page.unwrap_or(1).saturating_sub(1) as u64;
|
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
|
||||||
let models = paginator
|
let models = paginator
|
||||||
.fetch_page(page_index)
|
.fetch_page(page_index)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ impl TaskService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
let page_index = pagination.page.unwrap_or(1).saturating_sub(1) as u64;
|
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
|
||||||
let models = paginator
|
let models = paginator
|
||||||
.fetch_page(page_index)
|
.fetch_page(page_index)
|
||||||
.await
|
.await
|
||||||
@@ -80,7 +80,7 @@ impl TaskService {
|
|||||||
let paginator = task::Entity::find()
|
let paginator = task::Entity::find()
|
||||||
.filter(task::Column::TenantId.eq(tenant_id))
|
.filter(task::Column::TenantId.eq(tenant_id))
|
||||||
.filter(task::Column::AssigneeId.eq(assignee_id))
|
.filter(task::Column::AssigneeId.eq(assignee_id))
|
||||||
.filter(task::Column::Status.is_in(["approved", "rejected", "delegated"]))
|
.filter(task::Column::Status.is_in(["completed", "approved", "rejected", "delegated"]))
|
||||||
.filter(task::Column::DeletedAt.is_null())
|
.filter(task::Column::DeletedAt.is_null())
|
||||||
.paginate(db, pagination.limit());
|
.paginate(db, pagination.limit());
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ impl TaskService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
let page_index = pagination.page.unwrap_or(1).saturating_sub(1) as u64;
|
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
|
||||||
let models = paginator
|
let models = paginator
|
||||||
.fetch_page(page_index)
|
.fetch_page(page_index)
|
||||||
.await
|
.await
|
||||||
@@ -142,6 +142,13 @@ impl TaskService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证操作者是当前处理人
|
||||||
|
if task_model.assignee_id != Some(operator_id) {
|
||||||
|
return Err(WorkflowError::InvalidState(
|
||||||
|
"只有当前处理人才能完成任务".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let instance_id = task_model.instance_id;
|
let instance_id = task_model.instance_id;
|
||||||
let token_id = task_model.token_id;
|
let token_id = task_model.token_id;
|
||||||
|
|
||||||
@@ -162,6 +169,13 @@ impl TaskService {
|
|||||||
WorkflowError::NotFound(format!("流程定义不存在: {}", instance.definition_id))
|
WorkflowError::NotFound(format!("流程定义不存在: {}", instance.definition_id))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
if instance.status != "running" {
|
||||||
|
return Err(WorkflowError::InvalidState(format!(
|
||||||
|
"流程实例状态不是 running: {}",
|
||||||
|
instance.status
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
let nodes: Vec<crate::dto::NodeDef> =
|
let nodes: Vec<crate::dto::NodeDef> =
|
||||||
serde_json::from_value(definition.nodes.clone()).map_err(|e| {
|
serde_json::from_value(definition.nodes.clone()).map_err(|e| {
|
||||||
WorkflowError::InvalidDiagram(format!("节点数据无效: {e}"))
|
WorkflowError::InvalidDiagram(format!("节点数据无效: {e}"))
|
||||||
@@ -174,11 +188,11 @@ impl TaskService {
|
|||||||
|
|
||||||
// 准备变量(从 req.form_data 中提取)
|
// 准备变量(从 req.form_data 中提取)
|
||||||
let mut variables = HashMap::new();
|
let mut variables = HashMap::new();
|
||||||
if let Some(form) = &req.form_data {
|
if let Some(form) = &req.form_data
|
||||||
if let Some(obj) = form.as_object() {
|
&& let Some(obj) = form.as_object()
|
||||||
for (k, v) in obj {
|
{
|
||||||
variables.insert(k.clone(), v.clone());
|
for (k, v) in obj {
|
||||||
}
|
variables.insert(k.clone(), v.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +271,13 @@ impl TaskService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证操作者是当前处理人
|
||||||
|
if task_model.assignee_id != Some(operator_id) {
|
||||||
|
return Err(WorkflowError::InvalidState(
|
||||||
|
"只有当前处理人才能委派任务".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let mut active: task::ActiveModel = task_model.into();
|
let mut active: task::ActiveModel = task_model.into();
|
||||||
active.assignee_id = Set(Some(req.delegate_to));
|
active.assignee_id = Set(Some(req.delegate_to));
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
@@ -271,6 +292,7 @@ impl TaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 创建任务记录(由执行引擎调用)。
|
/// 创建任务记录(由执行引擎调用)。
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn create_task(
|
pub async fn create_task(
|
||||||
instance_id: Uuid,
|
instance_id: Uuid,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
|
|||||||
Reference in New Issue
Block a user