Add domain_events migration and SeaORM entity. Modify EventBus::publish to persist events before broadcasting (best-effort: DB failure logs warning but still broadcasts in-memory). Update all 19 publish call sites across 4 crates to pass db reference. Add outbox relay background task that polls pending events every 5s and re-broadcasts them, ensuring no events are lost on server restart.
286 lines
9.7 KiB
Rust
286 lines
9.7 KiB
Rust
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::<Vec<_>>();
|
||
|
||
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<HeaderValue> = origins
|
||
.iter()
|
||
.filter_map(|o| o.parse::<HeaderValue>().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...");
|
||
},
|
||
}
|
||
}
|