feat: 初始化ERP平台底座项目结构
- 添加基础crate结构(erp-core, erp-common) - 实现核心模块trait和事件总线 - 配置Docker开发环境(PostgreSQL+Redis) - 添加Tauri桌面端基础框架 - 设置CI/CD工作流 - 编写项目协作规范文档(CLAUDE.md)
This commit is contained in:
16
crates/erp-auth/Cargo.toml
Normal file
16
crates/erp-auth/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "erp-auth"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
erp-core.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
axum.workspace = true
|
||||
sea-orm.workspace = true
|
||||
tracing.workspace = true
|
||||
anyhow.workspace = true
|
||||
1
crates/erp-auth/src/lib.rs
Normal file
1
crates/erp-auth/src/lib.rs
Normal file
@@ -0,0 +1 @@
|
||||
// erp-auth: 身份与权限模块 (Phase 2)
|
||||
11
crates/erp-common/Cargo.toml
Normal file
11
crates/erp-common/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "erp-common"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
1
crates/erp-common/src/lib.rs
Normal file
1
crates/erp-common/src/lib.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod utils;
|
||||
1
crates/erp-common/src/utils.rs
Normal file
1
crates/erp-common/src/utils.rs
Normal file
@@ -0,0 +1 @@
|
||||
/// Shared utility functions for the ERP platform.
|
||||
16
crates/erp-config/Cargo.toml
Normal file
16
crates/erp-config/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "erp-config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
erp-core.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
axum.workspace = true
|
||||
sea-orm.workspace = true
|
||||
tracing.workspace = true
|
||||
anyhow.workspace = true
|
||||
1
crates/erp-config/src/lib.rs
Normal file
1
crates/erp-config/src/lib.rs
Normal file
@@ -0,0 +1 @@
|
||||
// erp-config: 系统配置模块 (Phase 3)
|
||||
16
crates/erp-core/Cargo.toml
Normal file
16
crates/erp-core/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "erp-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
axum.workspace = true
|
||||
sea-orm.workspace = true
|
||||
78
crates/erp-core/src/error.rs
Normal file
78
crates/erp-core/src/error.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
/// 统一错误响应格式
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 平台级错误类型
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error("资源未找到: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("验证失败: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("未授权")]
|
||||
Unauthorized,
|
||||
|
||||
#[error("禁止访问: {0}")]
|
||||
Forbidden(String),
|
||||
|
||||
#[error("冲突: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("内部错误: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
AppError::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
AppError::Validation(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "未授权".to_string()),
|
||||
AppError::Forbidden(_) => (StatusCode::FORBIDDEN, self.to_string()),
|
||||
AppError::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
||||
AppError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "内部错误".to_string()),
|
||||
};
|
||||
|
||||
let body = ErrorResponse {
|
||||
error: status.canonical_reason().unwrap_or("Error").to_string(),
|
||||
message,
|
||||
details: None,
|
||||
};
|
||||
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for AppError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
AppError::Internal(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sea_orm::DbErr> for AppError {
|
||||
fn from(err: sea_orm::DbErr) -> Self {
|
||||
match err {
|
||||
sea_orm::DbErr::RecordNotFound(msg) => AppError::NotFound(msg),
|
||||
sea_orm::DbErr::Query(sea_orm::RuntimeErr::SqlxError(e))
|
||||
if e.to_string().contains("duplicate key") =>
|
||||
{
|
||||
AppError::Conflict("记录已存在".to_string())
|
||||
}
|
||||
_ => AppError::Internal(err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
61
crates/erp-core/src/events.rs
Normal file
61
crates/erp-core/src/events.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{error, info};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 领域事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DomainEvent {
|
||||
pub id: Uuid,
|
||||
pub event_type: String,
|
||||
pub tenant_id: Uuid,
|
||||
pub payload: serde_json::Value,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub correlation_id: Uuid,
|
||||
}
|
||||
|
||||
impl DomainEvent {
|
||||
pub fn new(event_type: impl Into<String>, tenant_id: Uuid, payload: serde_json::Value) -> Self {
|
||||
Self {
|
||||
id: Uuid::now_v7(),
|
||||
event_type: event_type.into(),
|
||||
tenant_id,
|
||||
payload,
|
||||
timestamp: Utc::now(),
|
||||
correlation_id: Uuid::now_v7(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 事件处理器 trait
|
||||
pub trait EventHandler: Send + Sync {
|
||||
fn event_types(&self) -> Vec<String>;
|
||||
fn handle(&self, event: &DomainEvent) -> impl std::future::Future<Output = anyhow::Result<()>> + Send;
|
||||
}
|
||||
|
||||
/// 进程内事件总线
|
||||
#[derive(Clone)]
|
||||
pub struct EventBus {
|
||||
sender: broadcast::Sender<DomainEvent>,
|
||||
}
|
||||
|
||||
impl EventBus {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
let (sender, _) = broadcast::channel(capacity);
|
||||
Self { sender }
|
||||
}
|
||||
|
||||
/// 发布事件
|
||||
pub fn publish(&self, event: DomainEvent) {
|
||||
info!(event_type = %event.event_type, event_id = %event.id, "Event published");
|
||||
if let Err(e) = self.sender.send(event) {
|
||||
error!("Failed to publish event: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 订阅所有事件,返回接收端
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<DomainEvent> {
|
||||
self.sender.subscribe()
|
||||
}
|
||||
}
|
||||
4
crates/erp-core/src/lib.rs
Normal file
4
crates/erp-core/src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod error;
|
||||
pub mod events;
|
||||
pub mod module;
|
||||
pub mod types;
|
||||
76
crates/erp-core/src/module.rs
Normal file
76
crates/erp-core/src/module.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use axum::Router;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppResult;
|
||||
use crate::events::EventBus;
|
||||
|
||||
/// 模块注册接口
|
||||
/// 所有业务模块(Auth, Workflow, Message, Config, 行业模块)都实现此 trait
|
||||
pub trait ErpModule: Send + Sync {
|
||||
/// 模块名称(唯一标识)
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// 模块版本
|
||||
fn version(&self) -> &str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
/// 依赖的其他模块名称
|
||||
fn dependencies(&self) -> Vec<&str> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// 注册 Axum 路由
|
||||
fn register_routes(&self, router: Router) -> Router;
|
||||
|
||||
/// 注册事件处理器
|
||||
fn register_event_handlers(&self, _bus: &EventBus) {}
|
||||
|
||||
/// 租户创建时的初始化钩子
|
||||
fn on_tenant_created(
|
||||
&self,
|
||||
_tenant_id: Uuid,
|
||||
) -> impl std::future::Future<Output = AppResult<()>> + Send {
|
||||
async { Ok(()) }
|
||||
}
|
||||
|
||||
/// 租户删除时的清理钩子
|
||||
fn on_tenant_deleted(
|
||||
&self,
|
||||
_tenant_id: Uuid,
|
||||
) -> impl std::future::Future<Output = AppResult<()>> + Send {
|
||||
async { Ok(()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// 模块注册器
|
||||
pub struct ModuleRegistry {
|
||||
modules: Vec<Box<dyn ErpModule>>,
|
||||
}
|
||||
|
||||
impl ModuleRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self { modules: vec![] }
|
||||
}
|
||||
|
||||
pub fn register(&mut self, module: Box<dyn ErpModule>) {
|
||||
tracing::info!(module = module.name(), version = module.version(), "Module registered");
|
||||
self.modules.push(module);
|
||||
}
|
||||
|
||||
pub fn build_router(&self, base: Router) -> Router {
|
||||
self.modules
|
||||
.iter()
|
||||
.fold(base, |router, m| m.register_routes(router))
|
||||
}
|
||||
|
||||
pub fn register_handlers(&self, bus: &EventBus) {
|
||||
for module in &self.modules {
|
||||
module.register_event_handlers(bus);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn modules(&self) -> &[Box<dyn ErpModule>] {
|
||||
&self.modules
|
||||
}
|
||||
}
|
||||
70
crates/erp-core/src/types.rs
Normal file
70
crates/erp-core/src/types.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 所有数据库实体的公共字段
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BaseFields {
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
pub deleted_at: Option<DateTime<Utc>>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
/// 分页请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Pagination {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
impl Pagination {
|
||||
pub fn offset(&self) -> u64 {
|
||||
(self.page.unwrap_or(1).saturating_sub(1)) * self.limit()
|
||||
}
|
||||
|
||||
pub fn limit(&self) -> u64 {
|
||||
self.page_size.unwrap_or(20).min(100)
|
||||
}
|
||||
}
|
||||
|
||||
/// 分页响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PaginatedResponse<T> {
|
||||
pub data: Vec<T>,
|
||||
pub total: u64,
|
||||
pub page: u64,
|
||||
pub page_size: u64,
|
||||
pub total_pages: u64,
|
||||
}
|
||||
|
||||
/// API 统一响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ApiResponse<T: Serialize> {
|
||||
pub success: bool,
|
||||
pub data: Option<T>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl<T: Serialize> ApiResponse<T> {
|
||||
pub fn ok(data: T) -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
data: Some(data),
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 租户上下文(中间件注入)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TenantContext {
|
||||
pub tenant_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub roles: Vec<String>,
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
16
crates/erp-message/Cargo.toml
Normal file
16
crates/erp-message/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "erp-message"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
erp-core.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
axum.workspace = true
|
||||
sea-orm.workspace = true
|
||||
tracing.workspace = true
|
||||
anyhow.workspace = true
|
||||
1
crates/erp-message/src/lib.rs
Normal file
1
crates/erp-message/src/lib.rs
Normal file
@@ -0,0 +1 @@
|
||||
// erp-message: 消息中心模块 (Phase 5)
|
||||
25
crates/erp-server/Cargo.toml
Normal file
25
crates/erp-server/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "erp-server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "erp-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
erp-core.workspace = true
|
||||
erp-common.workspace = true
|
||||
tokio.workspace = true
|
||||
axum.workspace = true
|
||||
tower.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
config.workspace = true
|
||||
sea-orm.workspace = true
|
||||
redis.workspace = true
|
||||
utoipa.workspace = true
|
||||
utoipa-swagger-ui.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde.workspace = true
|
||||
19
crates/erp-server/config/default.toml
Normal file
19
crates/erp-server/config/default.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 3000
|
||||
|
||||
[database]
|
||||
url = "postgres://erp:erp_dev_2024@localhost:5432/erp"
|
||||
max_connections = 20
|
||||
min_connections = 5
|
||||
|
||||
[redis]
|
||||
url = "redis://localhost:6379"
|
||||
|
||||
[jwt]
|
||||
secret = "change-me-in-production"
|
||||
access_token_ttl = "15m"
|
||||
refresh_token_ttl = "7d"
|
||||
|
||||
[log]
|
||||
level = "info"
|
||||
50
crates/erp-server/src/config.rs
Normal file
50
crates/erp-server/src/config.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub server: ServerConfig,
|
||||
pub database: DatabaseConfig,
|
||||
pub redis: RedisConfig,
|
||||
pub jwt: JwtConfig,
|
||||
pub log: LogConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DatabaseConfig {
|
||||
pub url: String,
|
||||
pub max_connections: u32,
|
||||
pub min_connections: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RedisConfig {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct JwtConfig {
|
||||
pub secret: String,
|
||||
pub access_token_ttl: String,
|
||||
pub refresh_token_ttl: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LogConfig {
|
||||
pub level: String,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn load() -> anyhow::Result<Self> {
|
||||
let config = config::Config::builder()
|
||||
.add_source(config::File::with_name("config/default"))
|
||||
.add_source(config::Environment::with_prefix("ERP").separator("__"))
|
||||
.build()?;
|
||||
Ok(config.try_deserialize()?)
|
||||
}
|
||||
}
|
||||
16
crates/erp-server/src/db.rs
Normal file
16
crates/erp-server/src/db.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::DatabaseConfig;
|
||||
|
||||
pub async fn connect(config: &DatabaseConfig) -> anyhow::Result<DatabaseConnection> {
|
||||
let mut opt = ConnectOptions::new(&config.url);
|
||||
opt.max_connections(config.max_connections)
|
||||
.min_connections(config.min_connections)
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.idle_timeout(Duration::from_secs(600));
|
||||
|
||||
let db = Database::connect(opt).await?;
|
||||
tracing::info!("Database connected successfully");
|
||||
Ok(db)
|
||||
}
|
||||
41
crates/erp-server/src/main.rs
Normal file
41
crates/erp-server/src/main.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
mod config;
|
||||
mod db;
|
||||
|
||||
use axum::Router;
|
||||
use config::AppConfig;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Load config
|
||||
let config = AppConfig::load()?;
|
||||
|
||||
// Initialize tracing
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new(&config.log.level)),
|
||||
)
|
||||
.json()
|
||||
.init();
|
||||
|
||||
tracing::info!("ERP Server starting...");
|
||||
|
||||
// Connect to database
|
||||
let db = db::connect(&config.database).await?;
|
||||
|
||||
// Connect to Redis
|
||||
let _redis_client = redis::Client::open(&config.redis.url[..])?;
|
||||
tracing::info!("Redis client created");
|
||||
|
||||
// Build app
|
||||
let app = Router::new()
|
||||
.fallback(|| async { axum::Json(serde_json::json!({"error": "Not found"})) });
|
||||
|
||||
let addr = format!("{}:{}", config.server.host, config.server.port);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
tracing::info!("Server listening on {}", addr);
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
16
crates/erp-workflow/Cargo.toml
Normal file
16
crates/erp-workflow/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "erp-workflow"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
erp-core.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
axum.workspace = true
|
||||
sea-orm.workspace = true
|
||||
tracing.workspace = true
|
||||
anyhow.workspace = true
|
||||
1
crates/erp-workflow/src/lib.rs
Normal file
1
crates/erp-workflow/src/lib.rs
Normal file
@@ -0,0 +1 @@
|
||||
// erp-workflow: 工作流引擎模块 (Phase 4)
|
||||
Reference in New Issue
Block a user