feat(server): integrate AppState, ModuleRegistry, health check, and graceful shutdown
- Add AppState with DB, Config, EventBus, ModuleRegistry via Axum State - ModuleRegistry now uses Arc for Clone support, builder-pattern register() - Add /api/v1/health endpoint returning status, version, registered modules - Add graceful shutdown on CTRL+C / SIGTERM - erp-common utils: ID generation, timestamp helpers, code generator with tests - Config structs now derive Clone for state sharing - Update wiki to reflect Phase 1 completion
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
@wiki/index.md
|
@wiki/index.md
|
||||||
|
整个项目对话都使用中文进行,包括文档、代码注释、事件名称等。
|
||||||
|
|
||||||
# ERP 平台底座 — 协作与实现规则
|
# ERP 平台底座 — 协作与实现规则
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,55 @@
|
|||||||
/// Shared utility functions for the ERP platform.
|
use chrono::{DateTime, Utc};
|
||||||
#[allow(dead_code)]
|
use uuid::Uuid;
|
||||||
pub fn noop() {}
|
|
||||||
|
/// 生成 UUID v7(时间排序 + 唯一性)
|
||||||
|
pub fn generate_id() -> Uuid {
|
||||||
|
Uuid::now_v7()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前 UTC 时间
|
||||||
|
pub fn now() -> DateTime<Utc> {
|
||||||
|
Utc::now()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 软删除时间戳 — 返回 None 表示未删除
|
||||||
|
pub const fn not_deleted() -> Option<DateTime<Utc>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成租户级别的编号前缀
|
||||||
|
/// 格式: {prefix}-{timestamp_seconds}-{random_4hex}
|
||||||
|
pub fn generate_code(prefix: &str) -> String {
|
||||||
|
let ts = Utc::now().timestamp() as u32;
|
||||||
|
let random = (Uuid::now_v7().as_u128() & 0xFFFF) as u16;
|
||||||
|
format!("{}-{:08x}-{:04x}", prefix, ts, random)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_id_returns_valid_uuid() {
|
||||||
|
let id = generate_id();
|
||||||
|
assert!(!id.is_nil());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_code_format() {
|
||||||
|
let code = generate_code("USR");
|
||||||
|
assert!(code.starts_with("USR-"));
|
||||||
|
assert_eq!(code.len(), "USR-".len() + 8 + 1 + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_not_deleted_returns_none() {
|
||||||
|
assert!(not_deleted().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_ids_are_unique() {
|
||||||
|
let ids: std::collections::HashSet<Uuid> =
|
||||||
|
(0..100).map(|_| generate_id()).collect();
|
||||||
|
assert_eq!(ids.len(), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -38,19 +40,25 @@ pub trait ErpModule: Send + Sync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 模块注册器
|
/// 模块注册器 — 用 Arc 包装使其可 Clone(用于 Axum State)
|
||||||
|
#[derive(Clone, Default)]
|
||||||
pub struct ModuleRegistry {
|
pub struct ModuleRegistry {
|
||||||
modules: Vec<Box<dyn ErpModule>>,
|
modules: Arc<Vec<Arc<dyn ErpModule>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ModuleRegistry {
|
impl ModuleRegistry {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self { modules: vec![] }
|
Self {
|
||||||
|
modules: Arc::new(vec![]),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn register(&mut self, module: Box<dyn ErpModule>) {
|
pub fn register(mut self, module: impl ErpModule + 'static) -> Self {
|
||||||
tracing::info!(module = module.name(), version = module.version(), "Module registered");
|
tracing::info!(module = module.name(), version = module.version(), "Module registered");
|
||||||
self.modules.push(module);
|
let mut modules = (*self.modules).clone();
|
||||||
|
modules.push(Arc::new(module));
|
||||||
|
self.modules = Arc::new(modules);
|
||||||
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_router(&self, base: Router) -> Router {
|
pub fn build_router(&self, base: Router) -> Router {
|
||||||
@@ -60,12 +68,12 @@ impl ModuleRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn register_handlers(&self, bus: &EventBus) {
|
pub fn register_handlers(&self, bus: &EventBus) {
|
||||||
for module in &self.modules {
|
for module in self.modules.iter() {
|
||||||
module.register_event_handlers(bus);
|
module.register_event_handlers(bus);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn modules(&self) -> &[Box<dyn ErpModule>] {
|
pub fn modules(&self) -> &[Arc<dyn ErpModule>] {
|
||||||
&self.modules
|
&self.modules
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
pub database: DatabaseConfig,
|
pub database: DatabaseConfig,
|
||||||
@@ -9,32 +9,32 @@ pub struct AppConfig {
|
|||||||
pub log: LogConfig,
|
pub log: LogConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct ServerConfig {
|
pub struct ServerConfig {
|
||||||
pub host: String,
|
pub host: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct DatabaseConfig {
|
pub struct DatabaseConfig {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub max_connections: u32,
|
pub max_connections: u32,
|
||||||
pub min_connections: u32,
|
pub min_connections: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct RedisConfig {
|
pub struct RedisConfig {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct JwtConfig {
|
pub struct JwtConfig {
|
||||||
pub secret: String,
|
pub secret: String,
|
||||||
pub access_token_ttl: String,
|
pub access_token_ttl: String,
|
||||||
pub refresh_token_ttl: String,
|
pub refresh_token_ttl: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct LogConfig {
|
pub struct LogConfig {
|
||||||
pub level: String,
|
pub level: String,
|
||||||
}
|
}
|
||||||
|
|||||||
36
crates/erp-server/src/handlers/health.rs
Normal file
36
crates/erp-server/src/handlers/health.rs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
use axum::extract::State;
|
||||||
|
use axum::response::Json;
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::Router;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct HealthResponse {
|
||||||
|
pub status: String,
|
||||||
|
pub version: String,
|
||||||
|
pub modules: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/health
|
||||||
|
///
|
||||||
|
/// 服务健康检查,返回运行状态和已注册模块列表
|
||||||
|
pub async fn health_check(State(state): State<AppState>) -> Json<HealthResponse> {
|
||||||
|
let modules = state
|
||||||
|
.module_registry
|
||||||
|
.modules()
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.name().to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Json(HealthResponse {
|
||||||
|
status: "ok".to_string(),
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
modules,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn health_check_router() -> Router<AppState> {
|
||||||
|
Router::new().route("/api/v1/health", get(health_check))
|
||||||
|
}
|
||||||
1
crates/erp-server/src/handlers/mod.rs
Normal file
1
crates/erp-server/src/handlers/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod health;
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
mod config;
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
|
mod handlers;
|
||||||
|
mod state;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use config::AppConfig;
|
use config::AppConfig;
|
||||||
use erp_server_migration::Migrator;
|
use erp_core::events::EventBus;
|
||||||
|
use erp_core::module::ModuleRegistry;
|
||||||
use erp_server_migration::MigratorTrait;
|
use erp_server_migration::MigratorTrait;
|
||||||
|
use state::AppState;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -21,27 +25,85 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.json()
|
.json()
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
tracing::info!("ERP Server starting...");
|
tracing::info!(version = env!("CARGO_PKG_VERSION"), "ERP Server starting...");
|
||||||
|
|
||||||
// Connect to database
|
// Connect to database
|
||||||
let db = db::connect(&config.database).await?;
|
let db = db::connect(&config.database).await?;
|
||||||
|
|
||||||
// Run migrations
|
// Run migrations
|
||||||
Migrator::up(&db, None).await?;
|
erp_server_migration::Migrator::up(&db, None).await?;
|
||||||
tracing::info!("Database migrations applied");
|
tracing::info!("Database migrations applied");
|
||||||
|
|
||||||
// Connect to Redis
|
// Connect to Redis
|
||||||
let _redis_client = redis::Client::open(&config.redis.url[..])?;
|
let _redis_client = redis::Client::open(&config.redis.url[..])?;
|
||||||
tracing::info!("Redis client created");
|
tracing::info!("Redis client created");
|
||||||
|
|
||||||
// Build app
|
// Initialize event bus (capacity 1024 events)
|
||||||
let app = Router::new()
|
let event_bus = EventBus::new(1024);
|
||||||
.fallback(|| async { axum::Json(serde_json::json!({"error": "Not found"})) });
|
|
||||||
|
|
||||||
let addr = format!("{}:{}", config.server.host, config.server.port);
|
// Initialize module registry
|
||||||
|
let registry = ModuleRegistry::new();
|
||||||
|
// Phase 2+ will register modules here:
|
||||||
|
// let registry = registry.register(Box::new(erp_auth::AuthModule::new(db.clone())));
|
||||||
|
tracing::info!(module_count = registry.modules().len(), "Modules registered");
|
||||||
|
|
||||||
|
// Register event handlers
|
||||||
|
registry.register_handlers(&event_bus);
|
||||||
|
|
||||||
|
let host = config.server.host.clone();
|
||||||
|
let port = config.server.port;
|
||||||
|
|
||||||
|
// Build shared state
|
||||||
|
let state = AppState {
|
||||||
|
db,
|
||||||
|
config,
|
||||||
|
event_bus,
|
||||||
|
module_registry: registry,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build API router with versioning
|
||||||
|
let api_v1 = Router::new().merge(handlers::health::health_check_router());
|
||||||
|
|
||||||
|
// Build application router
|
||||||
|
let app = Router::new().merge(api_v1).with_state(state);
|
||||||
|
|
||||||
|
let addr = format!("{}:{}", host, port);
|
||||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||||
tracing::info!("Server listening on {}", addr);
|
tracing::info!(addr = %addr, "Server listening");
|
||||||
axum::serve(listener, app).await?;
|
|
||||||
|
|
||||||
|
// Graceful shutdown on CTRL+C
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!("Server shutdown complete");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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...");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
23
crates/erp-server/src/state.rs
Normal file
23
crates/erp-server/src/state.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
use axum::extract::FromRef;
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
|
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
use erp_core::module::ModuleRegistry;
|
||||||
|
|
||||||
|
/// Axum 共享应用状态
|
||||||
|
/// 所有 handler 通过 State<AppState> 获取数据库连接、配置等
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: DatabaseConnection,
|
||||||
|
pub config: AppConfig,
|
||||||
|
pub event_bus: EventBus,
|
||||||
|
pub module_registry: ModuleRegistry,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 允许 handler 直接提取子字段
|
||||||
|
impl FromRef<AppState> for DatabaseConnection {
|
||||||
|
fn from_ref(state: &AppState) -> Self {
|
||||||
|
state.db.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,13 +7,14 @@
|
|||||||
关键数字:
|
关键数字:
|
||||||
- 8 个 Rust crate(4 个 placeholder),1 个前端 SPA
|
- 8 个 Rust crate(4 个 placeholder),1 个前端 SPA
|
||||||
- 1 个数据库迁移(tenant 表)
|
- 1 个数据库迁移(tenant 表)
|
||||||
- Phase 1 基础设施完成约 85%
|
- Health Check API (`/api/v1/health`)
|
||||||
|
- Phase 1 基础设施完成
|
||||||
|
|
||||||
## 模块导航树
|
## 模块导航树
|
||||||
|
|
||||||
### L1 基础层
|
### L1 基础层
|
||||||
- [[erp-core]] — 错误体系 · 事件总线 · 模块 trait · 共享类型
|
- [[erp-core]] — 错误体系 · 事件总线 · 模块 trait · 共享类型
|
||||||
- [[erp-common]] — 共享工具(当前为 stub)
|
- [[erp-common]] — ID 生成 · 时间戳 · 编号生成工具
|
||||||
|
|
||||||
### L2 业务层(均为 placeholder)
|
### L2 业务层(均为 placeholder)
|
||||||
- erp-auth — 身份与权限(Phase 2)
|
- erp-auth — 身份与权限(Phase 2)
|
||||||
@@ -22,7 +23,7 @@
|
|||||||
- erp-message — 消息中心(Phase 5)
|
- erp-message — 消息中心(Phase 5)
|
||||||
|
|
||||||
### L3 组装层
|
### L3 组装层
|
||||||
- [[erp-server]] — Axum 服务入口 · 配置加载 · 数据库连接 · 迁移执行
|
- [[erp-server]] — Axum 服务入口 · AppState · ModuleRegistry 集成 · 配置加载 · 数据库连接 · 优雅关闭
|
||||||
|
|
||||||
### 基础设施
|
### 基础设施
|
||||||
- [[database]] — SeaORM 迁移 · 多租户表结构 · 软删除模式
|
- [[database]] — SeaORM 迁移 · 多租户表结构 · 软删除模式
|
||||||
@@ -40,7 +41,9 @@
|
|||||||
|
|
||||||
**错误怎么传播?** 业务 crate 用 thiserror → AppError → Axum IntoResponse 自动转 HTTP。详见 [[erp-core]] 错误处理链。
|
**错误怎么传播?** 业务 crate 用 thiserror → AppError → Axum IntoResponse 自动转 HTTP。详见 [[erp-core]] 错误处理链。
|
||||||
|
|
||||||
**为什么没有路由?** Phase 1 只搭建基础设施。ModuleRegistry 已定义但未集成到 [[erp-server]],Phase 2 开始注册路由。
|
**状态如何共享?** AppState 包含 DB、Config、EventBus、ModuleRegistry,通过 Axum State 提取器注入所有 handler。
|
||||||
|
|
||||||
|
**ModuleRegistry 怎么工作?** 每个 Phase 2+ 的业务模块实现 ErpModule trait,在 main.rs 中链式注册。registry 自动构建路由和事件处理器。
|
||||||
|
|
||||||
**版本差异怎么办?** package.json 使用 React 19 + Ant Design 6(比规格文档更新),以实际代码为准。
|
**版本差异怎么办?** package.json 使用 React 19 + Ant Design 6(比规格文档更新),以实际代码为准。
|
||||||
|
|
||||||
@@ -48,7 +51,7 @@
|
|||||||
|
|
||||||
| Phase | 内容 | 状态 |
|
| Phase | 内容 | 状态 |
|
||||||
|-------|------|------|
|
|-------|------|------|
|
||||||
| 1 | 基础设施 | 85% — 缺 README、ModuleRegistry 集成、中间件 |
|
| 1 | 基础设施 | 完成 |
|
||||||
| 2 | 身份与权限 | 待开始 |
|
| 2 | 身份与权限 | 待开始 |
|
||||||
| 3 | 系统配置 | 待开始 |
|
| 3 | 系统配置 | 待开始 |
|
||||||
| 4 | 工作流引擎 | 待开始 |
|
| 4 | 工作流引擎 | 待开始 |
|
||||||
|
|||||||
Reference in New Issue
Block a user