mod config; mod db; mod handlers; mod middleware; mod outbox; mod state; /// OpenAPI 规范定义(预留,未来可通过 utoipa derive 合并各模块 schema)。 #[derive(OpenApi)] #[openapi( info( title = "ERP Platform API", version = "0.1.0", description = "ERP 平台底座 REST API 文档" ) )] #[allow(dead_code)] struct ApiDoc; use axum::Router; use axum::middleware as axum_middleware; use config::AppConfig; use erp_auth::middleware::jwt_auth_middleware_fn; use state::AppState; use tracing_subscriber::EnvFilter; use utoipa::OpenApi; use erp_core::events::EventBus; use erp_core::module::{ErpModule, ModuleRegistry}; use erp_server_migration::MigratorTrait; use sea_orm::{ConnectionTrait, FromQueryResult}; #[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!(version = env!("CARGO_PKG_VERSION"), "ERP Server starting..."); // Connect to database let db = db::connect(&config.database).await?; // Run migrations erp_server_migration::Migrator::up(&db, None).await?; tracing::info!("Database migrations applied"); // Seed default tenant and auth data if not present { #[derive(sea_orm::FromQueryResult)] struct TenantId { id: uuid::Uuid, } let existing = TenantId::find_by_statement(sea_orm::Statement::from_string( sea_orm::DatabaseBackend::Postgres, "SELECT id FROM tenants WHERE deleted_at IS NULL LIMIT 1".to_string(), )) .one(&db) .await .map_err(|e| anyhow::anyhow!("Failed to query tenants: {}", e))?; match existing { Some(row) => { tracing::info!(tenant_id = %row.id, "Default tenant already exists, skipping seed"); } None => { let new_tenant_id = uuid::Uuid::now_v7(); // Insert default tenant using raw SQL (no tenant entity in erp-server) db.execute(sea_orm::Statement::from_sql_and_values( sea_orm::DatabaseBackend::Postgres, "INSERT INTO tenants (id, name, code, status, created_at, updated_at) VALUES ($1, $2, $3, $4, NOW(), NOW())", [ new_tenant_id.into(), "Default Tenant".into(), "default".into(), "active".into(), ], )) .await .map_err(|e| anyhow::anyhow!("Failed to create default tenant: {}", e))?; tracing::info!(tenant_id = %new_tenant_id, "Created default tenant"); // Seed auth data (permissions, roles, admin user) erp_auth::service::seed::seed_tenant_auth( &db, new_tenant_id, &config.auth.super_admin_password, ) .await .map_err(|e| anyhow::anyhow!("Failed to seed auth data: {}", e))?; tracing::info!(tenant_id = %new_tenant_id, "Default tenant ready with auth seed data"); } } } // Connect to Redis let redis_client = redis::Client::open(&config.redis.url[..])?; tracing::info!("Redis client created"); // Initialize event bus (capacity 1024 events) let event_bus = EventBus::new(1024); // Initialize auth module let auth_module = erp_auth::AuthModule::new(); tracing::info!(module = auth_module.name(), version = auth_module.version(), "Auth module initialized"); // Initialize config module let config_module = erp_config::ConfigModule::new(); tracing::info!(module = config_module.name(), version = config_module.version(), "Config module initialized"); // Initialize workflow module let workflow_module = erp_workflow::WorkflowModule::new(); tracing::info!(module = workflow_module.name(), version = workflow_module.version(), "Workflow module initialized"); // Initialize message module let message_module = erp_message::MessageModule::new(); tracing::info!(module = message_module.name(), version = message_module.version(), "Message module initialized"); // Initialize module registry and register modules let registry = ModuleRegistry::new() .register(auth_module) .register(config_module) .register(workflow_module) .register(message_module); tracing::info!(module_count = registry.modules().len(), "Modules registered"); // Register event handlers registry.register_handlers(&event_bus); // Start message event listener (workflow events → message notifications) erp_message::MessageModule::start_event_listener(db.clone(), event_bus.clone()); tracing::info!("Message event listener started"); // Start outbox relay (re-publish pending domain events) outbox::start_outbox_relay(db.clone(), event_bus.clone()); tracing::info!("Outbox relay started"); let host = config.server.host.clone(); let port = config.server.port; // Extract JWT secret for middleware construction let jwt_secret = config.jwt.secret.clone(); // Build shared state let state = AppState { db, config, event_bus, module_registry: registry, redis: redis_client.clone(), }; // --- Build the router --- // // The router is split into two layers: // 1. Public routes: no JWT required (health, login, refresh) // 2. Protected routes: JWT required (user CRUD, logout) // // Both layers share the same AppState. The protected layer wraps routes // with the jwt_auth_middleware_fn. // Public routes (no authentication) let public_routes = Router::new() .merge(handlers::health::health_check_router()) .merge(erp_auth::AuthModule::public_routes()) .route("/docs/openapi.json", axum::routing::get(handlers::openapi::openapi_spec)) .with_state(state.clone()); // Protected routes (JWT authentication required) // User-based rate limiting (100 req/min) applied after JWT auth let protected_routes = erp_auth::AuthModule::protected_routes() .merge(erp_config::ConfigModule::protected_routes()) .merge(erp_workflow::WorkflowModule::protected_routes()) .merge(erp_message::MessageModule::protected_routes()) .layer(axum::middleware::from_fn_with_state( state.clone(), middleware::rate_limit::rate_limit_by_user, )) .layer(axum_middleware::from_fn(move |req, next| { let secret = jwt_secret.clone(); async move { jwt_auth_middleware_fn(secret, req, next).await } })) .with_state(state.clone()); // Merge public + protected into the final application router // All API routes are nested under /api/v1 let cors = build_cors_layer(&state.config.cors.allowed_origins); let app = Router::new() .nest("/api/v1", public_routes.merge(protected_routes)) .layer(cors); let addr = format!("{}:{}", host, port); let listener = tokio::net::TcpListener::bind(&addr).await?; tracing::info!(addr = %addr, "Server listening"); // Graceful shutdown on CTRL+C axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await?; tracing::info!("Server shutdown complete"); Ok(()) } /// Build a CORS layer from the comma-separated allowed origins config. /// /// If the config is "*", allows all origins (development mode). /// Otherwise, parses each origin as a URL and restricts to those origins only. fn build_cors_layer(allowed_origins: &str) -> tower_http::cors::CorsLayer { use axum::http::HeaderValue; use tower_http::cors::AllowOrigin; let origins = allowed_origins .split(',') .map(|s| s.trim()) .filter(|s| !s.is_empty()) .collect::>(); if origins.len() == 1 && origins[0] == "*" { tracing::warn!("CORS: allowing all origins — only use in development!"); tower_http::cors::CorsLayer::permissive() } else { let allowed: Vec = origins .iter() .filter_map(|o| o.parse::().ok()) .collect(); tracing::info!(origins = ?origins, "CORS: restricting to allowed origins"); tower_http::cors::CorsLayer::new() .allow_origin(AllowOrigin::list(allowed)) .allow_methods([ axum::http::Method::GET, axum::http::Method::POST, axum::http::Method::PUT, axum::http::Method::DELETE, axum::http::Method::PATCH, ]) .allow_headers([ axum::http::header::AUTHORIZATION, axum::http::header::CONTENT_TYPE, ]) .allow_credentials(true) } } async fn shutdown_signal() { let ctrl_c = async { tokio::signal::ctrl_c() .await .expect("failed to install CTRL+C handler"); }; #[cfg(unix)] let terminate = async { tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) .expect("failed to install signal handler") .recv() .await; }; #[cfg(not(unix))] let terminate = std::future::pending::<()>(); tokio::select! { _ = ctrl_c => { tracing::info!("Received CTRL+C, shutting down gracefully..."); }, _ = terminate => { tracing::info!("Received SIGTERM, shutting down gracefully..."); }, } }