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:
iven
2026-04-11 12:36:34 +08:00
parent 5c899e6f4a
commit 3a05523d23
35 changed files with 283 additions and 187 deletions

5
Cargo.lock generated
View File

@@ -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",

View 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
);
}

View File

@@ -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');
}
}
}, },
})); }));

View File

@@ -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?;

View File

@@ -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?;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
}) })
} }

View File

@@ -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);
} }

View File

@@ -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()));
} }
} }

View File

@@ -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);
} }

View File

@@ -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

View File

@@ -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

View File

@@ -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)]

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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()));
} }

View File

@@ -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(

View File

@@ -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))

View File

@@ -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(&params.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(&params.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 {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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"

View File

@@ -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])
} }
} }

View File

@@ -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));
} }
// 布尔字面量 // 布尔字面量

View File

@@ -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,

View File

@@ -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,

View File

@@ -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?;

View File

@@ -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

View File

@@ -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,