新增 DiscoverService 并发聚合 4 个数据区: - daily_inspiration: MD5 哈希确定性日更推荐,匿名作者名 - hot_topics: 标签频率统计 Top 8 - featured_templates: 官方模板最多 6 个 - expert_diaries: 评论数热度排序,去重最多 5 位作者 GET /api/v1/diary/discover + utoipa 文档 + diary.journal.read 权限守卫
918 lines
34 KiB
Rust
918 lines
34 KiB
Rust
mod config;
|
||
mod db;
|
||
mod handlers;
|
||
mod middleware;
|
||
mod outbox;
|
||
mod state;
|
||
mod tasks;
|
||
|
||
/// OpenAPI 规范定义 — 通过 utoipa derive 合并各模块 schema。
|
||
#[derive(OpenApi)]
|
||
#[openapi(info(
|
||
title = "ERP Platform API",
|
||
version = "0.1.0",
|
||
description = "ERP 平台底座 REST API 文档"
|
||
))]
|
||
struct ApiDoc;
|
||
|
||
/// Auth 模块的 OpenAPI 路径收集
|
||
#[derive(OpenApi)]
|
||
#[openapi(
|
||
paths(
|
||
erp_auth::handler::auth_handler::login,
|
||
erp_auth::handler::auth_handler::refresh,
|
||
erp_auth::handler::auth_handler::logout,
|
||
erp_auth::handler::auth_handler::change_password,
|
||
erp_auth::handler::user_handler::list_users,
|
||
erp_auth::handler::user_handler::create_user,
|
||
erp_auth::handler::user_handler::get_user,
|
||
erp_auth::handler::user_handler::update_user,
|
||
erp_auth::handler::user_handler::delete_user,
|
||
erp_auth::handler::user_handler::assign_roles,
|
||
erp_auth::handler::role_handler::list_roles,
|
||
erp_auth::handler::role_handler::create_role,
|
||
erp_auth::handler::role_handler::get_role,
|
||
erp_auth::handler::role_handler::update_role,
|
||
erp_auth::handler::role_handler::delete_role,
|
||
erp_auth::handler::role_handler::assign_permissions,
|
||
erp_auth::handler::role_handler::get_role_permissions,
|
||
erp_auth::handler::role_handler::list_permissions,
|
||
),
|
||
components(schemas(
|
||
erp_auth::dto::LoginReq,
|
||
erp_auth::dto::LoginResp,
|
||
erp_auth::dto::RefreshReq,
|
||
erp_auth::dto::UserResp,
|
||
erp_auth::dto::CreateUserReq,
|
||
erp_auth::dto::UpdateUserReq,
|
||
erp_auth::dto::RoleResp,
|
||
erp_auth::dto::CreateRoleReq,
|
||
erp_auth::dto::UpdateRoleReq,
|
||
erp_auth::dto::PermissionResp,
|
||
erp_auth::dto::AssignPermissionsReq,
|
||
erp_auth::dto::ChangePasswordReq,
|
||
))
|
||
)]
|
||
struct AuthApiDoc;
|
||
|
||
/// Config 模块的 OpenAPI 路径收集
|
||
#[derive(OpenApi)]
|
||
#[openapi(
|
||
paths(
|
||
erp_config::handler::dictionary_handler::list_dictionaries,
|
||
erp_config::handler::dictionary_handler::create_dictionary,
|
||
erp_config::handler::dictionary_handler::update_dictionary,
|
||
erp_config::handler::dictionary_handler::delete_dictionary,
|
||
erp_config::handler::dictionary_handler::list_items_by_code,
|
||
erp_config::handler::dictionary_handler::create_item,
|
||
erp_config::handler::dictionary_handler::update_item,
|
||
erp_config::handler::menu_handler::get_menus,
|
||
erp_config::handler::menu_handler::create_menu,
|
||
erp_config::handler::menu_handler::update_menu,
|
||
erp_config::handler::menu_handler::delete_menu,
|
||
erp_config::handler::numbering_handler::list_numbering_rules,
|
||
erp_config::handler::numbering_handler::create_numbering_rule,
|
||
erp_config::handler::numbering_handler::update_numbering_rule,
|
||
erp_config::handler::numbering_handler::generate_number,
|
||
erp_config::handler::numbering_handler::delete_numbering_rule,
|
||
erp_config::handler::theme_handler::get_theme,
|
||
erp_config::handler::theme_handler::update_theme,
|
||
erp_config::handler::language_handler::list_languages,
|
||
erp_config::handler::language_handler::update_language,
|
||
erp_config::handler::setting_handler::get_setting,
|
||
erp_config::handler::setting_handler::update_setting,
|
||
erp_config::handler::setting_handler::delete_setting,
|
||
),
|
||
components(schemas(
|
||
erp_config::dto::DictionaryResp,
|
||
erp_config::dto::CreateDictionaryReq,
|
||
erp_config::dto::UpdateDictionaryReq,
|
||
erp_config::dto::DictionaryItemResp,
|
||
erp_config::dto::CreateDictionaryItemReq,
|
||
erp_config::dto::UpdateDictionaryItemReq,
|
||
erp_config::dto::MenuResp,
|
||
erp_config::dto::CreateMenuReq,
|
||
erp_config::dto::UpdateMenuReq,
|
||
erp_config::dto::NumberingRuleResp,
|
||
erp_config::dto::CreateNumberingRuleReq,
|
||
erp_config::dto::UpdateNumberingRuleReq,
|
||
erp_config::dto::ThemeResp,
|
||
))
|
||
)]
|
||
struct ConfigApiDoc;
|
||
|
||
/// Workflow 模块的 OpenAPI 路径收集
|
||
#[derive(OpenApi)]
|
||
#[openapi(
|
||
paths(
|
||
erp_workflow::handler::definition_handler::list_definitions,
|
||
erp_workflow::handler::definition_handler::create_definition,
|
||
erp_workflow::handler::definition_handler::get_definition,
|
||
erp_workflow::handler::definition_handler::update_definition,
|
||
erp_workflow::handler::definition_handler::publish_definition,
|
||
erp_workflow::handler::instance_handler::start_instance,
|
||
erp_workflow::handler::instance_handler::list_instances,
|
||
erp_workflow::handler::instance_handler::get_instance,
|
||
erp_workflow::handler::instance_handler::suspend_instance,
|
||
erp_workflow::handler::instance_handler::terminate_instance,
|
||
erp_workflow::handler::instance_handler::resume_instance,
|
||
erp_workflow::handler::task_handler::list_pending_tasks,
|
||
erp_workflow::handler::task_handler::list_completed_tasks,
|
||
erp_workflow::handler::task_handler::complete_task,
|
||
erp_workflow::handler::task_handler::delegate_task,
|
||
),
|
||
components(schemas(
|
||
erp_workflow::dto::ProcessDefinitionResp,
|
||
erp_workflow::dto::CreateProcessDefinitionReq,
|
||
erp_workflow::dto::UpdateProcessDefinitionReq,
|
||
erp_workflow::dto::ProcessInstanceResp,
|
||
erp_workflow::dto::StartInstanceReq,
|
||
erp_workflow::dto::TaskResp,
|
||
erp_workflow::dto::CompleteTaskReq,
|
||
erp_workflow::dto::DelegateTaskReq,
|
||
))
|
||
)]
|
||
struct WorkflowApiDoc;
|
||
|
||
/// Message 模块的 OpenAPI 路径收集
|
||
#[derive(OpenApi)]
|
||
#[openapi(
|
||
paths(
|
||
erp_message::handler::message_handler::list_messages,
|
||
erp_message::handler::message_handler::unread_count,
|
||
erp_message::handler::message_handler::send_message,
|
||
erp_message::handler::message_handler::mark_read,
|
||
erp_message::handler::message_handler::mark_all_read,
|
||
erp_message::handler::message_handler::delete_message,
|
||
erp_message::handler::template_handler::list_templates,
|
||
erp_message::handler::template_handler::create_template,
|
||
erp_message::handler::subscription_handler::update_subscription,
|
||
),
|
||
components(schemas(
|
||
erp_message::dto::MessageResp,
|
||
erp_message::dto::SendMessageReq,
|
||
erp_message::dto::MessageQuery,
|
||
erp_message::dto::UnreadCountResp,
|
||
erp_message::dto::MessageTemplateResp,
|
||
erp_message::dto::CreateTemplateReq,
|
||
erp_message::dto::MessageSubscriptionResp,
|
||
erp_message::dto::UpdateSubscriptionReq,
|
||
))
|
||
)]
|
||
struct MessageApiDoc;
|
||
|
||
/// Diary 模块的 OpenAPI 路径收集
|
||
#[cfg(feature = "diary")]
|
||
#[derive(OpenApi)]
|
||
#[openapi(
|
||
paths(
|
||
erp_diary::handler::journal_handler::create_journal,
|
||
erp_diary::handler::journal_handler::get_journal,
|
||
erp_diary::handler::journal_handler::update_journal,
|
||
erp_diary::handler::journal_handler::delete_journal,
|
||
erp_diary::handler::journal_handler::list_journals,
|
||
erp_diary::handler::class_handler::create_class,
|
||
erp_diary::handler::class_handler::join_class,
|
||
erp_diary::handler::class_handler::get_class,
|
||
erp_diary::handler::class_handler::list_members,
|
||
erp_diary::handler::class_handler::my_classes,
|
||
erp_diary::handler::class_handler::list_all_classes,
|
||
erp_diary::handler::class_handler::update_class,
|
||
erp_diary::handler::class_handler::deactivate_class,
|
||
erp_diary::handler::class_handler::reset_class_code,
|
||
erp_diary::handler::comment_handler::create_comment,
|
||
erp_diary::handler::comment_handler::list_comments,
|
||
erp_diary::handler::comment_handler::delete_comment,
|
||
erp_diary::handler::topic_handler::assign_topic,
|
||
erp_diary::handler::topic_handler::list_topics,
|
||
erp_diary::handler::topic_handler::update_topic,
|
||
erp_diary::handler::topic_handler::deactivate_topic,
|
||
erp_diary::handler::sticker_handler::list_sticker_packs,
|
||
erp_diary::handler::sticker_handler::list_stickers_in_pack,
|
||
erp_diary::handler::sticker_handler::create_sticker_pack,
|
||
erp_diary::handler::sticker_handler::update_sticker_pack,
|
||
erp_diary::handler::sticker_handler::delete_sticker_pack,
|
||
erp_diary::handler::sticker_handler::create_sticker,
|
||
erp_diary::handler::sticker_handler::list_templates,
|
||
erp_diary::handler::sticker_handler::get_template,
|
||
erp_diary::handler::achievement_handler::list_achievements,
|
||
erp_diary::handler::achievement_handler::unlock_achievement,
|
||
erp_diary::handler::stats_handler::get_mood_stats,
|
||
erp_diary::handler::sync_handler::sync_journals,
|
||
erp_diary::handler::parent_handler::bind_child,
|
||
erp_diary::handler::parent_handler::list_children,
|
||
erp_diary::handler::parent_handler::get_child_journals,
|
||
erp_diary::handler::parent_handler::export_child_data,
|
||
erp_diary::handler::parent_handler::delete_child_data,
|
||
erp_diary::handler::parent_handler::unbind_child,
|
||
erp_diary::handler::parent_handler::list_pending_bindings,
|
||
erp_diary::handler::parent_handler::confirm_binding,
|
||
erp_diary::handler::parent_handler::reject_binding,
|
||
erp_diary::handler::discover_handler::get_discover,
|
||
),
|
||
components(schemas(
|
||
erp_diary::dto::CreateJournalReq,
|
||
erp_diary::dto::UpdateJournalReq,
|
||
erp_diary::dto::JournalResp,
|
||
erp_diary::dto::CreateClassReq,
|
||
erp_diary::dto::JoinClassReq,
|
||
erp_diary::dto::UpdateClassReq,
|
||
erp_diary::dto::ResetClassCodeResp,
|
||
erp_diary::dto::ClassResp,
|
||
erp_diary::dto::SyncReq,
|
||
erp_diary::dto::SyncResp,
|
||
erp_diary::dto::ConflictInfo,
|
||
erp_diary::dto::ClassMemberResp,
|
||
erp_diary::dto::CreateTopicReq,
|
||
erp_diary::dto::TopicResp,
|
||
erp_diary::dto::UpdateTopicReq,
|
||
erp_diary::dto::CreateCommentReq,
|
||
erp_diary::dto::CommentResp,
|
||
erp_diary::dto::NotificationPayload,
|
||
erp_diary::dto::MoodStatsResp,
|
||
erp_diary::dto::MoodCount,
|
||
erp_diary::dto::StickerPackResp,
|
||
erp_diary::dto::StickerResp,
|
||
erp_diary::dto::TemplateResp,
|
||
erp_diary::dto::AchievementResp,
|
||
erp_diary::dto::CreateStickerPackReq,
|
||
erp_diary::dto::UpdateStickerPackReq,
|
||
erp_diary::dto::CreateStickerReq,
|
||
erp_diary::handler::parent_handler::BindChildReq,
|
||
erp_diary::handler::parent_handler::DeleteChildDataReq,
|
||
erp_diary::handler::parent_handler::BindingResp,
|
||
erp_diary::handler::parent_handler::DeleteResultResp,
|
||
erp_diary::dto::DiscoverResp,
|
||
erp_diary::dto::InspirationItem,
|
||
erp_diary::dto::TagCount,
|
||
erp_diary::dto::ExpertDiaryItem,
|
||
))
|
||
)]
|
||
struct DiaryApiDoc;
|
||
|
||
use axum::Router;
|
||
use axum::middleware as axum_middleware;
|
||
use config::AppConfig;
|
||
use erp_auth::middleware::jwt_auth_middleware_fn;
|
||
use state::AppState;
|
||
use tower_http::services::ServeDir;
|
||
use tracing_subscriber::EnvFilter;
|
||
use utoipa::OpenApi;
|
||
|
||
use erp_core::events::EventBus;
|
||
use erp_core::module::{ErpModule, ModuleContext, ModuleRegistry};
|
||
use erp_server_migration::MigratorTrait;
|
||
use sea_orm::{ConnectionTrait, FromQueryResult};
|
||
|
||
#[tokio::main]
|
||
async fn main() -> anyhow::Result<()> {
|
||
// Load config
|
||
let config = AppConfig::load()?;
|
||
|
||
// ── 安全检查:拒绝默认密钥 ──────────────────────────
|
||
if config.jwt.secret == "__MUST_SET_VIA_ENV__" || config.jwt.secret == "change-me-in-production"
|
||
{
|
||
tracing::error!("JWT 密钥为默认值,拒绝启动。请设置环境变量 ERP__JWT__SECRET");
|
||
std::process::exit(1);
|
||
}
|
||
if config.database.url == "__MUST_SET_VIA_ENV__" {
|
||
tracing::error!("数据库 URL 为默认占位值,拒绝启动。请设置环境变量 ERP__DATABASE__URL");
|
||
std::process::exit(1);
|
||
}
|
||
if config.redis.url == "__MUST_SET_VIA_ENV__" {
|
||
tracing::error!("Redis URL 为默认占位值,拒绝启动。请设置环境变量 ERP__REDIS__URL");
|
||
std::process::exit(1);
|
||
}
|
||
if !config.wechat.dev_mode
|
||
&& (config.wechat.appid == "__MUST_SET_VIA_ENV__"
|
||
|| config.wechat.secret == "__MUST_SET_VIA_ENV__")
|
||
{
|
||
tracing::error!(
|
||
"微信凭据为默认占位值,拒绝启动。请设置环境变量 ERP__WECHAT__APPID 和 ERP__WECHAT__SECRET"
|
||
);
|
||
std::process::exit(1);
|
||
}
|
||
// 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, and resolve the actual tenant ID
|
||
let default_tenant_id = {
|
||
#[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 tenant 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");
|
||
row.id
|
||
}
|
||
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 tenant (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");
|
||
|
||
// Seed AI workflow definitions
|
||
if let Err(e) =
|
||
erp_workflow::service::ai_workflow_seed::ensure_ai_workflows(&db, new_tenant_id)
|
||
.await
|
||
{
|
||
tracing::warn!(error = %e, "Failed to seed AI workflow definitions");
|
||
}
|
||
|
||
new_tenant_id
|
||
}
|
||
}
|
||
};
|
||
|
||
// 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 diary module (暖记业务)
|
||
#[cfg(feature = "diary")]
|
||
let diary_module = erp_diary::DiaryModule;
|
||
#[cfg(feature = "diary")]
|
||
tracing::info!(
|
||
module = diary_module.name(),
|
||
version = diary_module.version(),
|
||
"Diary module initialized"
|
||
);
|
||
|
||
// Initialize module registry and register modules
|
||
let registry = ModuleRegistry::new()
|
||
.register(auth_module)
|
||
.register(config_module)
|
||
.register(workflow_module)
|
||
.register(message_module);
|
||
|
||
#[cfg(feature = "diary")]
|
||
let registry = registry.register(diary_module);
|
||
tracing::info!(
|
||
module_count = registry.modules().len(),
|
||
"Modules registered"
|
||
);
|
||
|
||
// Initialize plugin engine
|
||
let plugin_config = erp_plugin::engine::PluginEngineConfig::default();
|
||
let plugin_engine =
|
||
erp_plugin::engine::PluginEngine::new(db.clone(), event_bus.clone(), plugin_config)?;
|
||
tracing::info!("Plugin engine initialized");
|
||
|
||
// Register plugin module
|
||
let plugin_module = erp_plugin::module::PluginModule;
|
||
let registry = registry.register(plugin_module);
|
||
|
||
// Register event handlers
|
||
registry.register_handlers(&event_bus);
|
||
|
||
// Startup all modules (按拓扑顺序调用 on_startup)
|
||
let module_ctx = ModuleContext {
|
||
db: db.clone(),
|
||
event_bus: event_bus.clone(),
|
||
};
|
||
registry.startup_all(&module_ctx).await?;
|
||
tracing::info!("All modules started");
|
||
|
||
// 同步所有模块声明的权限到数据库(upsert)
|
||
sync_module_permissions(&db, ®istry, default_tenant_id).await?;
|
||
|
||
// 恢复运行中的插件(服务器重启后自动重新加载)
|
||
match plugin_engine.recover_plugins(&db).await {
|
||
Ok(recovered) => {
|
||
let count: usize = recovered.len();
|
||
tracing::info!(count, "Plugins recovered");
|
||
}
|
||
Err(e) => {
|
||
tracing::error!(error = %e, "Failed to recover plugins");
|
||
}
|
||
}
|
||
|
||
// 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 plugin notification listener (plugin.trigger.* → admin notifications)
|
||
erp_plugin::notification::start_notification_listener(db.clone(), event_bus.clone());
|
||
tracing::info!("Plugin notification listener started");
|
||
|
||
// Start outbox relay (LISTEN/NOTIFY + fallback poll for pending domain events)
|
||
outbox::start_outbox_relay(db.clone(), event_bus.clone(), config.database.url.clone());
|
||
tracing::info!("Outbox relay started");
|
||
|
||
// Start timeout checker (scan overdue tasks every 60s)
|
||
erp_workflow::WorkflowModule::start_timeout_checker(db.clone(), event_bus.clone());
|
||
tracing::info!("Timeout checker 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 PII crypto — used by auth module for token encryption
|
||
let pii_crypto = if config.crypto.kek == "__MUST_SET_VIA_ENV__" {
|
||
#[cfg(debug_assertions)]
|
||
{
|
||
tracing::warn!("⚠️ PII KEK 使用开发默认值,仅用于本地开发");
|
||
erp_core::crypto::PiiCrypto::dev_default()
|
||
}
|
||
#[cfg(not(debug_assertions))]
|
||
{
|
||
panic!(
|
||
"ERP__CRYPTO__KEK must be set in production. Use a 64-char hex string (32 bytes)."
|
||
);
|
||
}
|
||
} else {
|
||
erp_core::crypto::PiiCrypto::from_kek_hex(&config.crypto.kek)
|
||
.expect("PII KEK must be valid 64-char hex (32 bytes). Set ERP__CRYPTO__KEK")
|
||
};
|
||
|
||
// Build shared state
|
||
|
||
let cron_heartbeat = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_secs(),
|
||
));
|
||
|
||
let state = AppState {
|
||
db,
|
||
config,
|
||
event_bus,
|
||
module_registry: registry,
|
||
redis: redis_client.clone(),
|
||
default_tenant_id,
|
||
plugin_engine,
|
||
plugin_entity_cache: moka::sync::Cache::builder()
|
||
.max_capacity(1000)
|
||
.time_to_idle(std::time::Duration::from_secs(300))
|
||
.build(),
|
||
pii_crypto,
|
||
cron_heartbeat: cron_heartbeat.clone(),
|
||
};
|
||
|
||
// Start background tasks with heartbeat
|
||
tasks::start_event_cleanup(state.db.clone(), state.cron_heartbeat.clone());
|
||
tasks::start_pool_metrics(state.db.clone(), state.cron_heartbeat.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, but IP-based rate limiting)
|
||
// Layer execution order (outer → inner): account_lockout → rate_limit_by_ip
|
||
// So account lockout check runs FIRST, then IP rate limiting
|
||
let public_routes = Router::new()
|
||
.merge(erp_auth::AuthModule::public_routes())
|
||
.layer(axum::middleware::from_fn_with_state(
|
||
state.clone(),
|
||
middleware::rate_limit::account_lockout_middleware,
|
||
))
|
||
.layer(axum::middleware::from_fn_with_state(
|
||
state.clone(),
|
||
middleware::rate_limit::rate_limit_by_ip,
|
||
))
|
||
.with_state(state.clone());
|
||
|
||
// Refresh token routes — higher rate limit (30/min) than login (5/min)
|
||
let refresh_routes = Router::new()
|
||
.merge(erp_auth::AuthModule::refresh_routes())
|
||
.layer(axum::middleware::from_fn_with_state(
|
||
state.clone(),
|
||
middleware::rate_limit::rate_limit_refresh_by_ip,
|
||
))
|
||
.with_state(state.clone());
|
||
|
||
// Unthrottled public routes (health, docs, brand) — no rate limiting
|
||
let unthrottled_routes = Router::new()
|
||
.merge(handlers::health::health_check_router())
|
||
.route(
|
||
"/docs/openapi.json",
|
||
axum::routing::get(handlers::openapi::openapi_spec),
|
||
)
|
||
.merge(erp_config::ConfigModule::public_routes())
|
||
.with_state(state.clone());
|
||
|
||
// Clone jwt_secret for upload auth before protected_routes closure moves it
|
||
let secret_for_uploads = jwt_secret.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())
|
||
.merge(erp_plugin::module::PluginModule::protected_routes());
|
||
|
||
#[cfg(feature = "diary")]
|
||
let protected_routes = protected_routes.merge(erp_diary::DiaryModule::protected_routes());
|
||
|
||
let protected_routes = protected_routes
|
||
.merge(handlers::audit_log::audit_log_router())
|
||
.route(
|
||
"/upload",
|
||
axum::routing::post(handlers::upload::upload_file),
|
||
)
|
||
.route(
|
||
"/admin/tenants/{id}/rotate-key",
|
||
axum::routing::post(handlers::crypto_admin::rotate_tenant_key),
|
||
)
|
||
.route(
|
||
"/analytics/batch",
|
||
axum::routing::post(handlers::analytics::batch),
|
||
)
|
||
.layer(axum::middleware::from_fn(
|
||
middleware::frozen_module::frozen_module_middleware,
|
||
))
|
||
.layer(axum::middleware::from_fn_with_state(
|
||
state.clone(),
|
||
middleware::rate_limit::rate_limit_by_user,
|
||
))
|
||
.layer({
|
||
let db = state.db.clone();
|
||
let jwt_secret_for_auth = jwt_secret.clone();
|
||
axum_middleware::from_fn(move |req, next| {
|
||
let secret = jwt_secret_for_auth.clone();
|
||
let db = db.clone();
|
||
async move { jwt_auth_middleware_fn(secret, Some(db), req, next).await }
|
||
})
|
||
})
|
||
// Tenant RLS — 在 JWT 之后执行,SET app.current_tenant_id
|
||
.layer({
|
||
let db = state.db.clone();
|
||
axum_middleware::from_fn(move |req, next| {
|
||
let db = db.clone();
|
||
async move { middleware::tenant_rls::tenant_rls_middleware(db, 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 upload_dir = state.config.storage.upload_dir.clone();
|
||
let uploads_router = Router::new()
|
||
.fallback_service(ServeDir::new(&upload_dir))
|
||
.layer(axum_middleware::from_fn(move |req, next| {
|
||
let secret = secret_for_uploads.clone();
|
||
async move { upload_auth_middleware(secret, req, next).await }
|
||
}));
|
||
let app = Router::new()
|
||
.nest(
|
||
"/api/v1",
|
||
unthrottled_routes
|
||
.merge(public_routes)
|
||
.merge(refresh_routes)
|
||
.merge(protected_routes),
|
||
)
|
||
.nest("/uploads", uploads_router)
|
||
.layer(axum::middleware::from_fn(
|
||
middleware::metrics::metrics_middleware,
|
||
))
|
||
.layer(cors)
|
||
.layer(axum::middleware::from_fn(security_headers_middleware));
|
||
|
||
// Start Prometheus metrics exporter on a separate port
|
||
let metrics_port = state.config.server.metrics_port;
|
||
middleware::metrics::start_metrics_server(metrics_port);
|
||
|
||
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?;
|
||
|
||
// 优雅关闭所有模块(按拓扑逆序)
|
||
state.module_registry.shutdown_all().await?;
|
||
tracing::info!("Server shutdown complete");
|
||
Ok(())
|
||
}
|
||
|
||
/// JWT auth middleware for `/uploads` file serving.
|
||
///
|
||
/// Accepts token from either `Authorization: Bearer <token>` header
|
||
/// or `?token=<token>` query parameter (for browser `<img>` / direct downloads).
|
||
async fn upload_auth_middleware(
|
||
jwt_secret: String,
|
||
req: axum::extract::Request,
|
||
next: axum::middleware::Next,
|
||
) -> Result<axum::response::Response, erp_core::error::AppError> {
|
||
use erp_auth::service::token_service::TokenService;
|
||
|
||
let token = req
|
||
.headers()
|
||
.get(axum::http::header::AUTHORIZATION)
|
||
.and_then(|v| v.to_str().ok())
|
||
.and_then(|v| v.strip_prefix("Bearer "))
|
||
.map(|s| s.to_string())
|
||
.or_else(|| {
|
||
req.uri().query().and_then(|q| {
|
||
q.split('&').find_map(|pair| {
|
||
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
|
||
if k == "token" && !v.is_empty() {
|
||
Some(v.to_string())
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
})
|
||
});
|
||
|
||
let token = token.ok_or(erp_core::error::AppError::Unauthorized)?;
|
||
|
||
let claims = TokenService::decode_token(&token, &jwt_secret)
|
||
.map_err(|_| erp_core::error::AppError::Unauthorized)?;
|
||
|
||
if claims.token_type != "access" {
|
||
return Err(erp_core::error::AppError::Unauthorized);
|
||
}
|
||
|
||
Ok(next.run(req).await)
|
||
}
|
||
|
||
/// 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] == "*" {
|
||
#[cfg(not(debug_assertions))]
|
||
{
|
||
tracing::error!("CORS wildcard '*' is not allowed in production builds");
|
||
panic!(
|
||
"Refusing to start with CORS wildcard in release mode. Set ERP__CORS__ALLOWED_ORIGINS to specific domains."
|
||
);
|
||
}
|
||
#[cfg(debug_assertions)]
|
||
{
|
||
tracing::warn!(
|
||
"⚠️ CORS 允许所有来源 — 仅限开发环境使用!\
|
||
生产环境请通过 ERP__CORS__ALLOWED_ORIGINS 设置具体的来源域名"
|
||
);
|
||
return tower_http::cors::CorsLayer::permissive();
|
||
}
|
||
}
|
||
|
||
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 security_headers_middleware(
|
||
req: axum::extract::Request,
|
||
next: axum::middleware::Next,
|
||
) -> axum::response::Response {
|
||
use axum::http::{HeaderValue, header};
|
||
|
||
let mut response = next.run(req).await;
|
||
let headers = response.headers_mut();
|
||
headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
|
||
headers.insert(
|
||
header::X_CONTENT_TYPE_OPTIONS,
|
||
HeaderValue::from_static("nosniff"),
|
||
);
|
||
headers.insert(
|
||
header::HeaderName::from_static("x-xss-protection"),
|
||
HeaderValue::from_static("1; mode=block"),
|
||
);
|
||
headers.insert(
|
||
header::HeaderName::from_static("referrer-policy"),
|
||
HeaderValue::from_static("strict-origin-when-cross-origin"),
|
||
);
|
||
headers.insert(
|
||
header::STRICT_TRANSPORT_SECURITY,
|
||
HeaderValue::from_static("max-age=63072000; includeSubDomains; preload"),
|
||
);
|
||
headers.insert(
|
||
header::HeaderName::from_static("content-security-policy"),
|
||
HeaderValue::from_static(
|
||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; \
|
||
img-src 'self' data: blob: https:; connect-src 'self' wss:; \
|
||
frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
|
||
),
|
||
);
|
||
headers.insert(
|
||
header::HeaderName::from_static("permissions-policy"),
|
||
HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
|
||
);
|
||
response
|
||
}
|
||
|
||
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...");
|
||
},
|
||
}
|
||
}
|
||
|
||
/// 同步所有模块声明的权限到数据库。
|
||
///
|
||
/// 对每个模块的 `permissions()` 返回的权限执行 upsert:
|
||
/// - 新权限:INSERT
|
||
/// - 已有权限(同 tenant_id + code):跳过
|
||
///
|
||
/// 同时将新权限分配给 admin 角色。
|
||
async fn sync_module_permissions(
|
||
db: &sea_orm::DatabaseConnection,
|
||
registry: &erp_core::module::ModuleRegistry,
|
||
tenant_id: uuid::Uuid,
|
||
) -> Result<(), anyhow::Error> {
|
||
let system_user_id = uuid::Uuid::nil();
|
||
let mut total_new = 0u32;
|
||
|
||
for module in registry.modules() {
|
||
let perms = module.permissions();
|
||
if perms.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
for perm in perms {
|
||
let result = db.execute(sea_orm::Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
r#"INSERT INTO permissions (id, tenant_id, code, name, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW(), $8, $8, NULL, 1)
|
||
ON CONFLICT (tenant_id, code) WHERE deleted_at IS NULL DO NOTHING"#,
|
||
[
|
||
uuid::Uuid::now_v7().into(),
|
||
tenant_id.into(),
|
||
perm.code.clone().into(),
|
||
perm.name.clone().into(),
|
||
perm.module.clone().into(),
|
||
perm.code.split('.').next_back().unwrap_or("manage").into(),
|
||
perm.description.clone().into(),
|
||
system_user_id.into(),
|
||
],
|
||
)).await?;
|
||
|
||
let rows = result.rows_affected();
|
||
if rows > 0 {
|
||
total_new += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 每次启动都确保 admin 角色拥有所有模块权限(防止权限-角色关联缺失)
|
||
db.execute(sea_orm::Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
r#"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||
SELECT r.id, p.id, p.tenant_id, 'all', NOW(), NOW(), $1, $1, NULL, 1
|
||
FROM permissions p
|
||
JOIN roles r ON r.code = 'admin' AND r.tenant_id = p.tenant_id AND r.deleted_at IS NULL
|
||
WHERE p.tenant_id = $2
|
||
ON CONFLICT DO NOTHING"#,
|
||
[system_user_id.into(), tenant_id.into()],
|
||
)).await?;
|
||
|
||
if total_new > 0 {
|
||
tracing::info!(
|
||
total_new,
|
||
"New module permissions synced and bound to admin role"
|
||
);
|
||
}
|
||
|
||
Ok(())
|
||
}
|