diff --git a/CLAUDE.md b/CLAUDE.md index ccb4438..068e66d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,5 @@ @wiki/index.md +整个项目对话都使用中文进行,包括文档、代码注释、事件名称等。 # ERP 平台底座 — 协作与实现规则 diff --git a/crates/erp-common/src/utils.rs b/crates/erp-common/src/utils.rs index 52fc3a7..903f163 100644 --- a/crates/erp-common/src/utils.rs +++ b/crates/erp-common/src/utils.rs @@ -1,3 +1,55 @@ -/// Shared utility functions for the ERP platform. -#[allow(dead_code)] -pub fn noop() {} +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// 生成 UUID v7(时间排序 + 唯一性) +pub fn generate_id() -> Uuid { + Uuid::now_v7() +} + +/// 获取当前 UTC 时间 +pub fn now() -> DateTime { + Utc::now() +} + +/// 软删除时间戳 — 返回 None 表示未删除 +pub const fn not_deleted() -> Option> { + 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 = + (0..100).map(|_| generate_id()).collect(); + assert_eq!(ids.len(), 100); + } +} diff --git a/crates/erp-core/src/module.rs b/crates/erp-core/src/module.rs index 8660ad8..326df45 100644 --- a/crates/erp-core/src/module.rs +++ b/crates/erp-core/src/module.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use axum::Router; use uuid::Uuid; @@ -38,19 +40,25 @@ pub trait ErpModule: Send + Sync { } } -/// 模块注册器 +/// 模块注册器 — 用 Arc 包装使其可 Clone(用于 Axum State) +#[derive(Clone, Default)] pub struct ModuleRegistry { - modules: Vec>, + modules: Arc>>, } impl ModuleRegistry { pub fn new() -> Self { - Self { modules: vec![] } + Self { + modules: Arc::new(vec![]), + } } - pub fn register(&mut self, module: Box) { + pub fn register(mut self, module: impl ErpModule + 'static) -> Self { 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 { @@ -60,12 +68,12 @@ impl ModuleRegistry { } pub fn register_handlers(&self, bus: &EventBus) { - for module in &self.modules { + for module in self.modules.iter() { module.register_event_handlers(bus); } } - pub fn modules(&self) -> &[Box] { + pub fn modules(&self) -> &[Arc] { &self.modules } } diff --git a/crates/erp-server/src/config.rs b/crates/erp-server/src/config.rs index b1850e6..be91477 100644 --- a/crates/erp-server/src/config.rs +++ b/crates/erp-server/src/config.rs @@ -1,6 +1,6 @@ use serde::Deserialize; -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct AppConfig { pub server: ServerConfig, pub database: DatabaseConfig, @@ -9,32 +9,32 @@ pub struct AppConfig { pub log: LogConfig, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct ServerConfig { pub host: String, pub port: u16, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct DatabaseConfig { pub url: String, pub max_connections: u32, pub min_connections: u32, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct RedisConfig { pub url: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct JwtConfig { pub secret: String, pub access_token_ttl: String, pub refresh_token_ttl: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct LogConfig { pub level: String, } diff --git a/crates/erp-server/src/handlers/health.rs b/crates/erp-server/src/handlers/health.rs new file mode 100644 index 0000000..e92e063 --- /dev/null +++ b/crates/erp-server/src/handlers/health.rs @@ -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, +} + +/// GET /api/v1/health +/// +/// 服务健康检查,返回运行状态和已注册模块列表 +pub async fn health_check(State(state): State) -> Json { + 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 { + Router::new().route("/api/v1/health", get(health_check)) +} diff --git a/crates/erp-server/src/handlers/mod.rs b/crates/erp-server/src/handlers/mod.rs new file mode 100644 index 0000000..43a7c76 --- /dev/null +++ b/crates/erp-server/src/handlers/mod.rs @@ -0,0 +1 @@ +pub mod health; diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index bb19772..d9be07b 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -1,10 +1,14 @@ mod config; mod db; +mod handlers; +mod state; use axum::Router; use config::AppConfig; -use erp_server_migration::Migrator; +use erp_core::events::EventBus; +use erp_core::module::ModuleRegistry; use erp_server_migration::MigratorTrait; +use state::AppState; use tracing_subscriber::EnvFilter; #[tokio::main] @@ -21,27 +25,85 @@ async fn main() -> anyhow::Result<()> { .json() .init(); - tracing::info!("ERP Server starting..."); + tracing::info!(version = env!("CARGO_PKG_VERSION"), "ERP Server starting..."); // Connect to database let db = db::connect(&config.database).await?; // Run migrations - Migrator::up(&db, None).await?; + erp_server_migration::Migrator::up(&db, None).await?; tracing::info!("Database migrations applied"); // 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"})) }); + // Initialize event bus (capacity 1024 events) + let event_bus = EventBus::new(1024); - 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?; - tracing::info!("Server listening on {}", addr); - axum::serve(listener, app).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(()) } + +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..."); + }, + } +} diff --git a/crates/erp-server/src/state.rs b/crates/erp-server/src/state.rs new file mode 100644 index 0000000..dda34d6 --- /dev/null +++ b/crates/erp-server/src/state.rs @@ -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 获取数据库连接、配置等 +#[derive(Clone)] +pub struct AppState { + pub db: DatabaseConnection, + pub config: AppConfig, + pub event_bus: EventBus, + pub module_registry: ModuleRegistry, +} + +/// 允许 handler 直接提取子字段 +impl FromRef for DatabaseConnection { + fn from_ref(state: &AppState) -> Self { + state.db.clone() + } +} diff --git a/wiki/index.md b/wiki/index.md index d58982f..7ced320 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -7,13 +7,14 @@ 关键数字: - 8 个 Rust crate(4 个 placeholder),1 个前端 SPA - 1 个数据库迁移(tenant 表) -- Phase 1 基础设施完成约 85% +- Health Check API (`/api/v1/health`) +- Phase 1 基础设施完成 ## 模块导航树 ### L1 基础层 - [[erp-core]] — 错误体系 · 事件总线 · 模块 trait · 共享类型 -- [[erp-common]] — 共享工具(当前为 stub) +- [[erp-common]] — ID 生成 · 时间戳 · 编号生成工具 ### L2 业务层(均为 placeholder) - erp-auth — 身份与权限(Phase 2) @@ -22,7 +23,7 @@ - erp-message — 消息中心(Phase 5) ### L3 组装层 -- [[erp-server]] — Axum 服务入口 · 配置加载 · 数据库连接 · 迁移执行 +- [[erp-server]] — Axum 服务入口 · AppState · ModuleRegistry 集成 · 配置加载 · 数据库连接 · 优雅关闭 ### 基础设施 - [[database]] — SeaORM 迁移 · 多租户表结构 · 软删除模式 @@ -40,7 +41,9 @@ **错误怎么传播?** 业务 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(比规格文档更新),以实际代码为准。 @@ -48,7 +51,7 @@ | Phase | 内容 | 状态 | |-------|------|------| -| 1 | 基础设施 | 85% — 缺 README、ModuleRegistry 集成、中间件 | +| 1 | 基础设施 | 完成 | | 2 | 身份与权限 | 待开始 | | 3 | 系统配置 | 待开始 | | 4 | 工作流引擎 | 待开始 |