From 3a05523d23b48d51ec55e037eb7b778673afcfbd Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 11 Apr 2026 12:36:34 +0800 Subject: [PATCH] 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 --- Cargo.lock | 5 +++ apps/web/src/api/errors.ts | 13 +++++++ apps/web/src/stores/auth.ts | 37 ++++++++++++------ crates/erp-auth/src/handler/auth_handler.rs | 22 +++++++---- crates/erp-auth/src/handler/org_handler.rs | 10 +---- crates/erp-auth/src/handler/role_handler.rs | 2 +- crates/erp-auth/src/handler/user_handler.rs | 2 +- crates/erp-auth/src/service/auth_service.rs | 37 ++++++++++-------- crates/erp-auth/src/service/dept_service.rs | 39 +++++++++---------- crates/erp-auth/src/service/org_service.rs | 24 ++++++------ .../erp-auth/src/service/position_service.rs | 39 +++++++++---------- crates/erp-auth/src/service/role_service.rs | 2 +- crates/erp-auth/src/service/user_service.rs | 2 +- crates/erp-config/src/dto.rs | 8 ++++ .../src/handler/dictionary_handler.rs | 2 +- .../src/handler/language_handler.rs | 12 +++--- .../src/handler/numbering_handler.rs | 2 +- .../erp-config/src/handler/setting_handler.rs | 12 +++--- .../erp-config/src/handler/theme_handler.rs | 12 +++--- .../src/service/dictionary_service.rs | 37 ++++++++---------- crates/erp-config/src/service/menu_service.rs | 8 ++-- .../src/service/numbering_service.rs | 4 +- .../erp-config/src/service/setting_service.rs | 33 +++++++--------- .../src/handler/message_handler.rs | 2 +- .../src/handler/template_handler.rs | 2 +- crates/erp-message/src/module.rs | 2 - .../src/service/message_service.rs | 3 +- crates/erp-server/config/default.toml | 4 ++ crates/erp-workflow/src/engine/executor.rs | 32 ++++++++++++++- crates/erp-workflow/src/engine/expression.rs | 8 ++-- .../src/handler/definition_handler.rs | 2 +- .../src/handler/instance_handler.rs | 4 +- .../erp-workflow/src/handler/task_handler.rs | 7 +++- .../src/service/definition_service.rs | 2 +- .../erp-workflow/src/service/task_service.rs | 38 ++++++++++++++---- 35 files changed, 283 insertions(+), 187 deletions(-) create mode 100644 apps/web/src/api/errors.ts diff --git a/Cargo.lock b/Cargo.lock index c3f0334..871f394 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -875,15 +875,19 @@ name = "erp-message" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "axum", "chrono", "erp-core", "sea-orm", "serde", "serde_json", + "thiserror", "tokio", "tracing", + "utoipa", "uuid", + "validator", ] [[package]] @@ -897,6 +901,7 @@ dependencies = [ "erp-common", "erp-config", "erp-core", + "erp-message", "erp-server-migration", "erp-workflow", "redis", diff --git a/apps/web/src/api/errors.ts b/apps/web/src/api/errors.ts new file mode 100644 index 0000000..43a5929 --- /dev/null +++ b/apps/web/src/api/errors.ts @@ -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 + ); +} diff --git a/apps/web/src/stores/auth.ts b/apps/web/src/stores/auth.ts index 8cabba0..266b1fb 100644 --- a/apps/web/src/stores/auth.ts +++ b/apps/web/src/stores/auth.ts @@ -1,6 +1,25 @@ import { create } from 'zustand'; 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 { user: UserInfo | null; isAuthenticated: boolean; @@ -11,8 +30,8 @@ interface AuthState { } export const useAuthStore = create((set) => ({ - user: null, - isAuthenticated: false, + user: initial.user, + isAuthenticated: initial.isAuthenticated, loading: false, login: async (username, password) => { @@ -41,16 +60,10 @@ export const useAuthStore = create((set) => ({ set({ user: null, isAuthenticated: false }); }, + // Kept for backward compatibility but no longer needed since + // initial state is restored synchronously at store creation. loadFromStorage: () => { - const token = localStorage.getItem('access_token'); - const userStr = localStorage.getItem('user'); - if (token && userStr) { - try { - const user = JSON.parse(userStr) as UserInfo; - set({ user, isAuthenticated: true }); - } catch { - localStorage.removeItem('user'); - } - } + const state = restoreInitialState(); + set({ user: state.user, isAuthenticated: state.isAuthenticated }); }, })); diff --git a/crates/erp-auth/src/handler/auth_handler.rs b/crates/erp-auth/src/handler/auth_handler.rs index b197f81..e6cd76d 100644 --- a/crates/erp-auth/src/handler/auth_handler.rs +++ b/crates/erp-auth/src/handler/auth_handler.rs @@ -8,7 +8,7 @@ use erp_core::types::{ApiResponse, TenantContext}; use crate::auth_state::AuthState; use crate::dto::{LoginReq, LoginResp, RefreshReq}; -use crate::service::auth_service::AuthService; +use crate::service::auth_service::{AuthService, JwtConfig}; /// POST /api/v1/auth/login /// @@ -29,14 +29,18 @@ where let tenant_id = state.default_tenant_id; + let jwt_config = JwtConfig { + secret: &state.jwt_secret, + access_ttl_secs: state.access_ttl_secs, + refresh_ttl_secs: state.refresh_ttl_secs, + }; + let resp = AuthService::login( tenant_id, &req.username, &req.password, &state.db, - &state.jwt_secret, - state.access_ttl_secs, - state.refresh_ttl_secs, + &jwt_config, &state.event_bus, ) .await?; @@ -56,12 +60,16 @@ where AuthState: FromRef, S: Clone + Send + Sync + 'static, { + let jwt_config = JwtConfig { + secret: &state.jwt_secret, + access_ttl_secs: state.access_ttl_secs, + refresh_ttl_secs: state.refresh_ttl_secs, + }; + let resp = AuthService::refresh( &req.refresh_token, &state.db, - &state.jwt_secret, - state.access_ttl_secs, - state.refresh_ttl_secs, + &jwt_config, ) .await?; diff --git a/crates/erp-auth/src/handler/org_handler.rs b/crates/erp-auth/src/handler/org_handler.rs index 097fdd8..65fd456 100644 --- a/crates/erp-auth/src/handler/org_handler.rs +++ b/crates/erp-auth/src/handler/org_handler.rs @@ -184,10 +184,7 @@ where id, ctx.tenant_id, ctx.user_id, - &req.name, - &req.code, - &req.manager_id, - &req.sort_order, + &req, &state.db, ) .await?; @@ -291,10 +288,7 @@ where id, ctx.tenant_id, ctx.user_id, - &req.name, - &req.code, - &req.level, - &req.sort_order, + &req, &state.db, ) .await?; diff --git a/crates/erp-auth/src/handler/role_handler.rs b/crates/erp-auth/src/handler/role_handler.rs index fc44386..802bceb 100644 --- a/crates/erp-auth/src/handler/role_handler.rs +++ b/crates/erp-auth/src/handler/role_handler.rs @@ -32,7 +32,7 @@ where let page = pagination.page.unwrap_or(1); 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 { data: roles, diff --git a/crates/erp-auth/src/handler/user_handler.rs b/crates/erp-auth/src/handler/user_handler.rs index 203deb1..fee25fb 100644 --- a/crates/erp-auth/src/handler/user_handler.rs +++ b/crates/erp-auth/src/handler/user_handler.rs @@ -31,7 +31,7 @@ where let page = pagination.page.unwrap_or(1); 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 { data: users, diff --git a/crates/erp-auth/src/service/auth_service.rs b/crates/erp-auth/src/service/auth_service.rs index c95cbca..61a9aae 100644 --- a/crates/erp-auth/src/service/auth_service.rs +++ b/crates/erp-auth/src/service/auth_service.rs @@ -12,6 +12,13 @@ use crate::error::AuthResult; use super::password; 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. pub struct AuthService; @@ -32,9 +39,7 @@ impl AuthService { username: &str, password_plain: &str, db: &sea_orm::DatabaseConnection, - jwt_secret: &str, - access_ttl_secs: i64, - refresh_ttl_secs: i64, + jwt: &JwtConfig<'_>, event_bus: &EventBus, ) -> AuthResult { // 1. Find user by tenant_id + username @@ -85,15 +90,15 @@ impl AuthService { tenant_id, roles.clone(), permissions, - jwt_secret, - access_ttl_secs, + jwt.secret, + jwt.access_ttl_secs, )?; let (refresh_token, _) = TokenService::sign_refresh_token( user_model.id, tenant_id, db, - jwt_secret, - refresh_ttl_secs, + jwt.secret, + jwt.refresh_ttl_secs, ) .await?; @@ -129,7 +134,7 @@ impl AuthService { Ok(LoginResp { access_token, refresh_token, - expires_in: access_ttl_secs as u64, + expires_in: jwt.access_ttl_secs as u64, user: user_resp, }) } @@ -138,13 +143,11 @@ impl AuthService { pub async fn refresh( refresh_token_str: &str, db: &sea_orm::DatabaseConnection, - jwt_secret: &str, - access_ttl_secs: i64, - refresh_ttl_secs: i64, + jwt: &JwtConfig<'_>, ) -> AuthResult { // Validate existing refresh token 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) TokenService::revoke_token(old_token_id, db).await?; @@ -161,15 +164,15 @@ impl AuthService { claims.tid, roles.clone(), permissions, - jwt_secret, - access_ttl_secs, + jwt.secret, + jwt.access_ttl_secs, )?; let (new_refresh_token, _) = TokenService::sign_refresh_token( claims.sub, claims.tid, db, - jwt_secret, - refresh_ttl_secs, + jwt.secret, + jwt.refresh_ttl_secs, ) .await?; @@ -195,7 +198,7 @@ impl AuthService { Ok(LoginResp { access_token, refresh_token: new_refresh_token, - expires_in: access_ttl_secs as u64, + expires_in: jwt.access_ttl_secs as u64, user: user_resp, }) } diff --git a/crates/erp-auth/src/service/dept_service.rs b/crates/erp-auth/src/service/dept_service.rs index 4f8aa85..9743bdb 100644 --- a/crates/erp-auth/src/service/dept_service.rs +++ b/crates/erp-auth/src/service/dept_service.rs @@ -4,7 +4,7 @@ use chrono::Utc; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; use uuid::Uuid; -use crate::dto::{CreateDepartmentReq, DepartmentResp}; +use crate::dto::{CreateDepartmentReq, DepartmentResp, UpdateDepartmentReq}; use crate::entity::department; use crate::entity::organization; use crate::error::{AuthError, AuthResult}; @@ -141,10 +141,7 @@ impl DeptService { id: Uuid, tenant_id: Uuid, operator_id: Uuid, - name: &Option, - code: &Option, - manager_id: &Option, - sort_order: &Option, + req: &UpdateDepartmentReq, db: &sea_orm::DatabaseConnection, ) -> AuthResult { let model = department::Entity::find_by_id(id) @@ -155,33 +152,33 @@ impl DeptService { .ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?; // If code is being changed, check uniqueness - if let Some(new_code) = code { - if Some(new_code) != model.code.as_ref() { - let existing = department::Entity::find() - .filter(department::Column::TenantId.eq(tenant_id)) - .filter(department::Column::Code.eq(new_code.as_str())) - .filter(department::Column::DeletedAt.is_null()) - .one(db) - .await - .map_err(|e| AuthError::Validation(e.to_string()))?; - if existing.is_some() { - return Err(AuthError::Validation("部门编码已存在".to_string())); - } + if let Some(new_code) = &req.code + && Some(new_code) != model.code.as_ref() + { + let existing = department::Entity::find() + .filter(department::Column::TenantId.eq(tenant_id)) + .filter(department::Column::Code.eq(new_code.as_str())) + .filter(department::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if existing.is_some() { + return Err(AuthError::Validation("部门编码已存在".to_string())); } } let mut active: department::ActiveModel = model.into(); - if let Some(n) = name { + if let Some(n) = &req.name { active.name = Set(n.clone()); } - if let Some(c) = code { + if let Some(c) = &req.code { 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)); } - if let Some(so) = sort_order { + if let Some(so) = &req.sort_order { active.sort_order = Set(*so); } diff --git a/crates/erp-auth/src/service/org_service.rs b/crates/erp-auth/src/service/org_service.rs index d64a734..3891457 100644 --- a/crates/erp-auth/src/service/org_service.rs +++ b/crates/erp-auth/src/service/org_service.rs @@ -137,18 +137,18 @@ impl OrgService { .ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?; // If code is being changed, check uniqueness - if let Some(ref new_code) = req.code { - if Some(new_code) != model.code.as_ref() { - let existing = organization::Entity::find() - .filter(organization::Column::TenantId.eq(tenant_id)) - .filter(organization::Column::Code.eq(new_code.as_str())) - .filter(organization::Column::DeletedAt.is_null()) - .one(db) - .await - .map_err(|e| AuthError::Validation(e.to_string()))?; - if existing.is_some() { - return Err(AuthError::Validation("组织编码已存在".to_string())); - } + if let Some(ref new_code) = req.code + && Some(new_code) != model.code.as_ref() + { + let existing = organization::Entity::find() + .filter(organization::Column::TenantId.eq(tenant_id)) + .filter(organization::Column::Code.eq(new_code.as_str())) + .filter(organization::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if existing.is_some() { + return Err(AuthError::Validation("组织编码已存在".to_string())); } } diff --git a/crates/erp-auth/src/service/position_service.rs b/crates/erp-auth/src/service/position_service.rs index 2e9e4d7..c92bb0e 100644 --- a/crates/erp-auth/src/service/position_service.rs +++ b/crates/erp-auth/src/service/position_service.rs @@ -2,7 +2,7 @@ use chrono::Utc; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; use uuid::Uuid; -use crate::dto::{CreatePositionReq, PositionResp}; +use crate::dto::{CreatePositionReq, PositionResp, UpdatePositionReq}; use crate::entity::department; use crate::entity::position; use crate::error::{AuthError, AuthResult}; @@ -122,10 +122,7 @@ impl PositionService { id: Uuid, tenant_id: Uuid, operator_id: Uuid, - name: &Option, - code: &Option, - level: &Option, - sort_order: &Option, + req: &UpdatePositionReq, db: &sea_orm::DatabaseConnection, ) -> AuthResult { let model = position::Entity::find_by_id(id) @@ -136,33 +133,33 @@ impl PositionService { .ok_or_else(|| AuthError::Validation("岗位不存在".to_string()))?; // If code is being changed, check uniqueness - if let Some(new_code) = code { - if Some(new_code) != model.code.as_ref() { - let existing = position::Entity::find() - .filter(position::Column::TenantId.eq(tenant_id)) - .filter(position::Column::Code.eq(new_code.as_str())) - .filter(position::Column::DeletedAt.is_null()) - .one(db) - .await - .map_err(|e| AuthError::Validation(e.to_string()))?; - if existing.is_some() { - return Err(AuthError::Validation("岗位编码已存在".to_string())); - } + if let Some(new_code) = &req.code + && Some(new_code) != model.code.as_ref() + { + let existing = position::Entity::find() + .filter(position::Column::TenantId.eq(tenant_id)) + .filter(position::Column::Code.eq(new_code.as_str())) + .filter(position::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if existing.is_some() { + return Err(AuthError::Validation("岗位编码已存在".to_string())); } } let mut active: position::ActiveModel = model.into(); - if let Some(n) = name { + if let Some(n) = &req.name { active.name = Set(n.clone()); } - if let Some(c) = code { + if let Some(c) = &req.code { active.code = Set(Some(c.clone())); } - if let Some(l) = level { + if let Some(l) = &req.level { active.level = Set(*l); } - if let Some(so) = sort_order { + if let Some(so) = &req.sort_order { active.sort_order = Set(*so); } diff --git a/crates/erp-auth/src/service/role_service.rs b/crates/erp-auth/src/service/role_service.rs index f1deeec..5c47fb4 100644 --- a/crates/erp-auth/src/service/role_service.rs +++ b/crates/erp-auth/src/service/role_service.rs @@ -34,7 +34,7 @@ impl RoleService { .await .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 .fetch_page(page_index) .await diff --git a/crates/erp-auth/src/service/user_service.rs b/crates/erp-auth/src/service/user_service.rs index e6c6d85..578f2cb 100644 --- a/crates/erp-auth/src/service/user_service.rs +++ b/crates/erp-auth/src/service/user_service.rs @@ -140,7 +140,7 @@ impl UserService { .await .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 .fetch_page(page_index) .await diff --git a/crates/erp-config/src/dto.rs b/crates/erp-config/src/dto.rs index 9afcddf..6a0cc8e 100644 --- a/crates/erp-config/src/dto.rs +++ b/crates/erp-config/src/dto.rs @@ -143,6 +143,14 @@ pub struct UpdateSettingReq { pub setting_value: serde_json::Value, } +/// 内部参数结构体,用于减少 SettingService::set 的参数数量。 +pub struct SetSettingParams { + pub key: String, + pub scope: String, + pub scope_id: Option, + pub value: serde_json::Value, +} + // --- Numbering Rule DTOs --- #[derive(Debug, Serialize, ToSchema)] diff --git a/crates/erp-config/src/handler/dictionary_handler.rs b/crates/erp-config/src/handler/dictionary_handler.rs index 0df6f9a..5fa8f42 100644 --- a/crates/erp-config/src/handler/dictionary_handler.rs +++ b/crates/erp-config/src/handler/dictionary_handler.rs @@ -35,7 +35,7 @@ where let page = pagination.page.unwrap_or(1); 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 { data: dictionaries, diff --git a/crates/erp-config/src/handler/language_handler.rs b/crates/erp-config/src/handler/language_handler.rs index 4503e43..0efa9d8 100644 --- a/crates/erp-config/src/handler/language_handler.rs +++ b/crates/erp-config/src/handler/language_handler.rs @@ -7,7 +7,7 @@ use erp_core::rbac::require_permission; use erp_core::types::{ApiResponse, Pagination, TenantContext}; use crate::config_state::ConfigState; -use crate::dto::{LanguageResp, UpdateLanguageReq}; +use crate::dto::{LanguageResp, SetSettingParams, UpdateLanguageReq}; use crate::service::setting_service::SettingService; /// GET /api/v1/languages @@ -82,10 +82,12 @@ where let value = serde_json::json!({"is_active": req.is_active}); SettingService::set( - &key, - "platform", - &None, - value, + SetSettingParams { + key, + scope: "platform".to_string(), + scope_id: None, + value, + }, ctx.tenant_id, ctx.user_id, &state.db, diff --git a/crates/erp-config/src/handler/numbering_handler.rs b/crates/erp-config/src/handler/numbering_handler.rs index 1841698..c999aa1 100644 --- a/crates/erp-config/src/handler/numbering_handler.rs +++ b/crates/erp-config/src/handler/numbering_handler.rs @@ -33,7 +33,7 @@ where let page = pagination.page.unwrap_or(1); 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 { data: rules, diff --git a/crates/erp-config/src/handler/setting_handler.rs b/crates/erp-config/src/handler/setting_handler.rs index d7d2e51..f7d22d0 100644 --- a/crates/erp-config/src/handler/setting_handler.rs +++ b/crates/erp-config/src/handler/setting_handler.rs @@ -8,7 +8,7 @@ use erp_core::types::{ApiResponse, TenantContext}; use uuid::Uuid; use crate::config_state::ConfigState; -use crate::dto::{SettingResp, UpdateSettingReq}; +use crate::dto::{SetSettingParams, SettingResp, UpdateSettingReq}; use crate::service::setting_service::SettingService; /// GET /api/v1/settings/:key?scope=tenant&scope_id=xxx @@ -54,10 +54,12 @@ where require_permission(&ctx, "setting.update")?; let setting = SettingService::set( - &key, - "tenant", - &None, - req.setting_value, + SetSettingParams { + key, + scope: "tenant".to_string(), + scope_id: None, + value: req.setting_value, + }, ctx.tenant_id, ctx.user_id, &state.db, diff --git a/crates/erp-config/src/handler/theme_handler.rs b/crates/erp-config/src/handler/theme_handler.rs index a3a4310..f3ba202 100644 --- a/crates/erp-config/src/handler/theme_handler.rs +++ b/crates/erp-config/src/handler/theme_handler.rs @@ -7,7 +7,7 @@ use erp_core::rbac::require_permission; use erp_core::types::{ApiResponse, TenantContext}; use crate::config_state::ConfigState; -use crate::dto::ThemeResp; +use crate::dto::{SetSettingParams, ThemeResp}; use crate::service::setting_service::SettingService; /// GET /api/v1/theme @@ -54,10 +54,12 @@ where .map_err(|e| AppError::Validation(format!("主题配置序列化失败: {e}")))?; SettingService::set( - "theme", - "tenant", - &None, - value, + SetSettingParams { + key: "theme".to_string(), + scope: "tenant".to_string(), + scope_id: None, + value, + }, ctx.tenant_id, ctx.user_id, &state.db, diff --git a/crates/erp-config/src/service/dictionary_service.rs b/crates/erp-config/src/service/dictionary_service.rs index 19fa3a6..c86c79e 100644 --- a/crates/erp-config/src/service/dictionary_service.rs +++ b/crates/erp-config/src/service/dictionary_service.rs @@ -36,7 +36,7 @@ impl DictionaryService { .await .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 .fetch_page(page_index) .await @@ -229,10 +229,7 @@ impl DictionaryService { dictionary_id: Uuid, tenant_id: Uuid, operator_id: Uuid, - label: &str, - value: &str, - sort_order: i32, - color: &Option, + req: &crate::dto::CreateDictionaryItemReq, db: &sea_orm::DatabaseConnection, ) -> ConfigResult { // Verify the dictionary exists and belongs to this tenant @@ -247,7 +244,7 @@ impl DictionaryService { let existing = dictionary_item::Entity::find() .filter(dictionary_item::Column::DictionaryId.eq(dictionary_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()) .one(db) .await @@ -258,14 +255,15 @@ impl DictionaryService { let now = Utc::now(); let id = Uuid::now_v7(); + let sort_order = req.sort_order.unwrap_or(0); let model = dictionary_item::ActiveModel { id: Set(id), tenant_id: Set(tenant_id), dictionary_id: Set(dictionary_id), - label: Set(label.to_string()), - value: Set(value.to_string()), + label: Set(req.label.clone()), + value: Set(req.value.clone()), sort_order: Set(sort_order), - color: Set(color.clone()), + color: Set(req.color.clone()), created_at: Set(now), updated_at: Set(now), created_by: Set(operator_id), @@ -281,10 +279,10 @@ impl DictionaryService { Ok(DictionaryItemResp { id, dictionary_id, - label: label.to_string(), - value: value.to_string(), + label: req.label.clone(), + value: req.value.clone(), sort_order, - color: color.clone(), + color: req.color.clone(), }) } @@ -293,10 +291,7 @@ impl DictionaryService { item_id: Uuid, tenant_id: Uuid, operator_id: Uuid, - label: &Option, - value: &Option, - sort_order: &Option, - color: &Option, + req: &crate::dto::UpdateDictionaryItemReq, db: &sea_orm::DatabaseConnection, ) -> ConfigResult { let model = dictionary_item::Entity::find_by_id(item_id) @@ -308,16 +303,16 @@ impl DictionaryService { let mut active: dictionary_item::ActiveModel = model.into(); - if let Some(l) = label { + if let Some(l) = &req.label { active.label = Set(l.clone()); } - if let Some(v) = value { + if let Some(v) = &req.value { active.value = Set(v.clone()); } - if let Some(s) = sort_order { - active.sort_order = Set(*s); + if let Some(s) = req.sort_order { + active.sort_order = Set(s); } - if let Some(c) = color { + if let Some(c) = &req.color { active.color = Set(Some(c.clone())); } diff --git a/crates/erp-config/src/service/menu_service.rs b/crates/erp-config/src/service/menu_service.rs index 49efc5c..f2d4f57 100644 --- a/crates/erp-config/src/service/menu_service.rs +++ b/crates/erp-config/src/service/menu_service.rs @@ -142,10 +142,10 @@ impl MenuService { .map_err(|e| ConfigError::Validation(e.to_string()))?; // 关联角色(如果提供了 role_ids) - if let Some(role_ids) = &req.role_ids { - if !role_ids.is_empty() { - Self::assign_roles(id, role_ids, tenant_id, operator_id, db).await?; - } + if let Some(role_ids) = &req.role_ids + && !role_ids.is_empty() + { + Self::assign_roles(id, role_ids, tenant_id, operator_id, db).await?; } event_bus.publish(erp_core::events::DomainEvent::new( diff --git a/crates/erp-config/src/service/numbering_service.rs b/crates/erp-config/src/service/numbering_service.rs index 91eb431..33b1758 100644 --- a/crates/erp-config/src/service/numbering_service.rs +++ b/crates/erp-config/src/service/numbering_service.rs @@ -32,7 +32,7 @@ impl NumberingService { .await .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 .fetch_page(page_index) .await @@ -40,7 +40,7 @@ impl NumberingService { let resps: Vec = models .iter() - .map(|m| Self::model_to_resp(m)) + .map(Self::model_to_resp) .collect(); Ok((resps, total)) diff --git a/crates/erp-config/src/service/setting_service.rs b/crates/erp-config/src/service/setting_service.rs index ef6fc77..e3e5e67 100644 --- a/crates/erp-config/src/service/setting_service.rs +++ b/crates/erp-config/src/service/setting_service.rs @@ -71,10 +71,7 @@ impl SettingService { /// 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. pub async fn set( - key: &str, - scope: &str, - scope_id: &Option, - value: serde_json::Value, + params: crate::dto::SetSettingParams, tenant_id: Uuid, operator_id: Uuid, db: &sea_orm::DatabaseConnection, @@ -83,9 +80,9 @@ impl SettingService { // Look for an existing non-deleted record let existing = setting::Entity::find() .filter(setting::Column::TenantId.eq(tenant_id)) - .filter(setting::Column::Scope.eq(scope)) - .filter(setting::Column::ScopeId.eq(*scope_id)) - .filter(setting::Column::SettingKey.eq(key)) + .filter(setting::Column::Scope.eq(¶ms.scope)) + .filter(setting::Column::ScopeId.eq(params.scope_id)) + .filter(setting::Column::SettingKey.eq(¶ms.key)) .filter(setting::Column::DeletedAt.is_null()) .one(db) .await @@ -94,7 +91,7 @@ impl SettingService { if let Some(model) = existing { // Update existing record 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_by = Set(operator_id); @@ -108,8 +105,8 @@ impl SettingService { tenant_id, serde_json::json!({ "setting_id": updated.id, - "key": key, - "scope": scope, + "key": params.key, + "scope": params.scope, }), )); @@ -121,10 +118,10 @@ impl SettingService { let model = setting::ActiveModel { id: Set(id), tenant_id: Set(tenant_id), - scope: Set(scope.to_string()), - scope_id: Set(*scope_id), - setting_key: Set(key.to_string()), - setting_value: Set(value), + scope: Set(params.scope.clone()), + scope_id: Set(params.scope_id), + setting_key: Set(params.key.clone()), + setting_value: Set(params.value), created_at: Set(now), updated_at: Set(now), created_by: Set(operator_id), @@ -142,8 +139,8 @@ impl SettingService { tenant_id, serde_json::json!({ "setting_id": id, - "key": key, - "scope": scope, + "key": params.key, + "scope": params.scope, }), )); @@ -171,7 +168,7 @@ impl SettingService { .await .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 .fetch_page(page_index) .await @@ -248,7 +245,7 @@ impl SettingService { /// Returns a list of (scope, scope_id) tuples to try in order. fn fallback_chain( scope: &str, - scope_id: &Option, + _scope_id: &Option, tenant_id: Uuid, ) -> ConfigResult)>> { match scope { diff --git a/crates/erp-message/src/handler/message_handler.rs b/crates/erp-message/src/handler/message_handler.rs index eeb9590..0b28b08 100644 --- a/crates/erp-message/src/handler/message_handler.rs +++ b/crates/erp-message/src/handler/message_handler.rs @@ -30,7 +30,7 @@ where 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 { data: messages, total, diff --git a/crates/erp-message/src/handler/template_handler.rs b/crates/erp-message/src/handler/template_handler.rs index df23c08..2674304 100644 --- a/crates/erp-message/src/handler/template_handler.rs +++ b/crates/erp-message/src/handler/template_handler.rs @@ -36,7 +36,7 @@ where let (templates, total) = 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 { data: templates, total, diff --git a/crates/erp-message/src/module.rs b/crates/erp-message/src/module.rs index cbfe078..9a1c566 100644 --- a/crates/erp-message/src/module.rs +++ b/crates/erp-message/src/module.rs @@ -62,8 +62,6 @@ impl MessageModule { /// /// 在 main.rs 中调用,因为需要 db 连接。 pub fn start_event_listener(db: sea_orm::DatabaseConnection, event_bus: EventBus) { - use sea_orm::ConnectionTrait; - let mut rx = event_bus.subscribe(); tokio::spawn(async move { loop { diff --git a/crates/erp-message/src/service/message_service.rs b/crates/erp-message/src/service/message_service.rs index ef73bdc..8de5a8e 100644 --- a/crates/erp-message/src/service/message_service.rs +++ b/crates/erp-message/src/service/message_service.rs @@ -46,7 +46,7 @@ impl MessageService { .await .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 .fetch_page(page_index) .await @@ -132,6 +132,7 @@ impl MessageService { } /// 系统发送消息(由事件处理器调用)。 + #[allow(clippy::too_many_arguments)] pub async fn send_system( tenant_id: Uuid, recipient_id: Uuid, diff --git a/crates/erp-server/config/default.toml b/crates/erp-server/config/default.toml index 601ccec..d2267ee 100644 --- a/crates/erp-server/config/default.toml +++ b/crates/erp-server/config/default.toml @@ -20,3 +20,7 @@ super_admin_password = "Admin@2026" [log] level = "info" + +[cors] +# Comma-separated allowed origins. Use "*" for development only. +allowed_origins = "http://localhost:5173,http://localhost:3000" diff --git a/crates/erp-workflow/src/engine/executor.rs b/crates/erp-workflow/src/engine/executor.rs index c07b128..a09441a 100644 --- a/crates/erp-workflow/src/engine/executor.rs +++ b/crates/erp-workflow/src/engine/executor.rs @@ -10,7 +10,7 @@ use uuid::Uuid; use crate::dto::NodeType; use crate::engine::expression::ExpressionEvaluator; use crate::engine::model::FlowGraph; -use crate::entity::{token, process_instance}; +use crate::entity::{token, process_instance, task}; use crate::error::{WorkflowError, WorkflowResult}; /// Token 驱动的流程执行引擎。 @@ -264,6 +264,36 @@ impl FlowExecutor { .await .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]) } } diff --git a/crates/erp-workflow/src/engine/expression.rs b/crates/erp-workflow/src/engine/expression.rs index fd641ee..0b6ad25 100644 --- a/crates/erp-workflow/src/engine/expression.rs +++ b/crates/erp-workflow/src/engine/expression.rs @@ -164,10 +164,10 @@ impl ExpressionEvaluator { if let Ok(n) = token.parse::() { return Ok(serde_json::Value::Number(n.into())); } - if let Ok(f) = token.parse::() { - if let Some(n) = serde_json::Number::from_f64(f) { - return Ok(serde_json::Value::Number(n)); - } + if let Ok(f) = token.parse::() + && let Some(n) = serde_json::Number::from_f64(f) + { + return Ok(serde_json::Value::Number(n)); } // 布尔字面量 diff --git a/crates/erp-workflow/src/handler/definition_handler.rs b/crates/erp-workflow/src/handler/definition_handler.rs index a632969..03dbb29 100644 --- a/crates/erp-workflow/src/handler/definition_handler.rs +++ b/crates/erp-workflow/src/handler/definition_handler.rs @@ -28,7 +28,7 @@ where let page = pagination.page.unwrap_or(1); 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 { data: defs, diff --git a/crates/erp-workflow/src/handler/instance_handler.rs b/crates/erp-workflow/src/handler/instance_handler.rs index 5e95c21..ee1cdfd 100644 --- a/crates/erp-workflow/src/handler/instance_handler.rs +++ b/crates/erp-workflow/src/handler/instance_handler.rs @@ -1,6 +1,7 @@ use axum::Extension; use axum::extract::{FromRef, Path, Query, State}; use axum::response::Json; +use validator::Validate; use erp_core::error::AppError; use erp_core::rbac::require_permission; @@ -22,6 +23,7 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "workflow:start")?; + req.validate().map_err(|e| AppError::Validation(e.to_string()))?; let resp = InstanceService::start( ctx.tenant_id, @@ -52,7 +54,7 @@ where let page = pagination.page.unwrap_or(1); 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 { data: instances, diff --git a/crates/erp-workflow/src/handler/task_handler.rs b/crates/erp-workflow/src/handler/task_handler.rs index 0c664d2..d2b7c19 100644 --- a/crates/erp-workflow/src/handler/task_handler.rs +++ b/crates/erp-workflow/src/handler/task_handler.rs @@ -1,6 +1,7 @@ use axum::Extension; use axum::extract::{FromRef, Path, Query, State}; use axum::response::Json; +use validator::Validate; use erp_core::error::AppError; use erp_core::rbac::require_permission; @@ -28,7 +29,7 @@ where let page = pagination.page.unwrap_or(1); 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 { data: tasks, @@ -56,7 +57,7 @@ where let page = pagination.page.unwrap_or(1); 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 { data: tasks, @@ -79,6 +80,7 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "workflow:approve")?; + req.validate().map_err(|e| AppError::Validation(e.to_string()))?; let resp = TaskService::complete( id, @@ -105,6 +107,7 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "workflow:delegate")?; + req.validate().map_err(|e| AppError::Validation(e.to_string()))?; let resp = TaskService::delegate(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?; diff --git a/crates/erp-workflow/src/service/definition_service.rs b/crates/erp-workflow/src/service/definition_service.rs index f0c1251..78838a9 100644 --- a/crates/erp-workflow/src/service/definition_service.rs +++ b/crates/erp-workflow/src/service/definition_service.rs @@ -33,7 +33,7 @@ impl DefinitionService { .await .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 .fetch_page(page_index) .await diff --git a/crates/erp-workflow/src/service/task_service.rs b/crates/erp-workflow/src/service/task_service.rs index c94eb74..000ad3c 100644 --- a/crates/erp-workflow/src/service/task_service.rs +++ b/crates/erp-workflow/src/service/task_service.rs @@ -38,7 +38,7 @@ impl TaskService { .await .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 .fetch_page(page_index) .await @@ -80,7 +80,7 @@ impl TaskService { let paginator = task::Entity::find() .filter(task::Column::TenantId.eq(tenant_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()) .paginate(db, pagination.limit()); @@ -89,7 +89,7 @@ impl TaskService { .await .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 .fetch_page(page_index) .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 token_id = task_model.token_id; @@ -162,6 +169,13 @@ impl TaskService { WorkflowError::NotFound(format!("流程定义不存在: {}", instance.definition_id)) })?; + if instance.status != "running" { + return Err(WorkflowError::InvalidState(format!( + "流程实例状态不是 running: {}", + instance.status + ))); + } + let nodes: Vec = serde_json::from_value(definition.nodes.clone()).map_err(|e| { WorkflowError::InvalidDiagram(format!("节点数据无效: {e}")) @@ -174,11 +188,11 @@ impl TaskService { // 准备变量(从 req.form_data 中提取) let mut variables = HashMap::new(); - if let Some(form) = &req.form_data { - if let Some(obj) = form.as_object() { - for (k, v) in obj { - variables.insert(k.clone(), v.clone()); - } + if let Some(form) = &req.form_data + && let Some(obj) = form.as_object() + { + 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(); active.assignee_id = Set(Some(req.delegate_to)); active.updated_at = Set(Utc::now()); @@ -271,6 +292,7 @@ impl TaskService { } /// 创建任务记录(由执行引擎调用)。 + #[allow(clippy::too_many_arguments)] pub async fn create_task( instance_id: Uuid, tenant_id: Uuid,