chore: 干净 ERP 基座 — 删除 health/ai/wechat 业务代码
删除内容: - 前端: health/(67文件), ai/(2文件), Copilot, MediaPicker, 相关API/Store/Hook - 后端: wechat_handler, wechat_service, wechat_user entity, analytics handler, ai_workflow_seed - 配置: WechatConfig, AppConfig.wechat, AuthState wechat 字段 - 启动: 微信凭据检查块, ensure_ai_workflows() 调用 - 迁移: 新增 m20260613_000170_drop_wechat_users.rs - 脚本: api_test_health_alert.py, api_test_mp.py, mpsync.sh/ps1 - E2E: health-data page, flows/ 目录 保留: erp-core/auth/workflow/message/config/plugin + 基座前端 + 通用组件
This commit is contained in:
49
crates/erp-server/Cargo.toml
Normal file
49
crates/erp-server/Cargo.toml
Normal file
@@ -0,0 +1,49 @@
|
||||
[package]
|
||||
name = "erp-server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "erp-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
erp-core.workspace = true
|
||||
tokio.workspace = true
|
||||
axum.workspace = true
|
||||
tower.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
config.workspace = true
|
||||
sea-orm.workspace = true
|
||||
sqlx.workspace = true
|
||||
redis.workspace = true
|
||||
utoipa.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde.workspace = true
|
||||
erp-server-migration = { path = "migration" }
|
||||
erp-auth.workspace = true
|
||||
erp-config.workspace = true
|
||||
erp-workflow.workspace = true
|
||||
erp-message.workspace = true
|
||||
erp-plugin.workspace = true
|
||||
anyhow.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
moka = { version = "0.12", features = ["sync"] }
|
||||
metrics.workspace = true
|
||||
metrics-exporter-prometheus.workspace = true
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
erp-auth = { workspace = true }
|
||||
erp-plugin = { workspace = true }
|
||||
erp-workflow = { workspace = true }
|
||||
erp-core = { workspace = true }
|
||||
async-trait.workspace = true
|
||||
futures.workspace = true
|
||||
sha2.workspace = true
|
||||
hex.workspace = true
|
||||
69
crates/erp-server/config/default.toml
Normal file
69
crates/erp-server/config/default.toml
Normal file
@@ -0,0 +1,69 @@
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 3000
|
||||
|
||||
[database]
|
||||
url = "__MUST_SET_VIA_ENV__"
|
||||
max_connections = 20
|
||||
min_connections = 5
|
||||
|
||||
[redis]
|
||||
url = "__MUST_SET_VIA_ENV__"
|
||||
|
||||
[jwt]
|
||||
secret = "__MUST_SET_VIA_ENV__"
|
||||
access_token_ttl = "15m"
|
||||
refresh_token_ttl = "7d"
|
||||
|
||||
[auth]
|
||||
super_admin_password = "__MUST_SET_VIA_ENV__"
|
||||
|
||||
[log]
|
||||
level = "info"
|
||||
|
||||
[cors]
|
||||
# Comma-separated allowed origins. Use "*" for development only.
|
||||
allowed_origins = "http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:3000"
|
||||
|
||||
[wechat]
|
||||
appid = "__MUST_SET_VIA_ENV__"
|
||||
secret = "__MUST_SET_VIA_ENV__"
|
||||
# dev_mode = true 跳过 jscode2session,允许微信开发者工具模拟器登录
|
||||
# 生产环境必须为 false(默认)
|
||||
dev_mode = false
|
||||
|
||||
[health]
|
||||
aes_key = "__MUST_SET_VIA_ENV__"
|
||||
hmac_key = "__MUST_SET_VIA_ENV__"
|
||||
|
||||
[crypto]
|
||||
kek = "__MUST_SET_VIA_ENV__"
|
||||
|
||||
[ai]
|
||||
default_provider = "ollama"
|
||||
# AI API 密钥。留空则禁用 AI 功能;生产环境必须通过 ERP__AI__API_KEY 设置。
|
||||
api_key = ""
|
||||
model = "qwen3:4b"
|
||||
max_tokens = 2048
|
||||
temperature = 0.3
|
||||
cache_ttl_seconds = 604800
|
||||
rate_limit_patient_daily = 10
|
||||
|
||||
[ai.providers.ollama]
|
||||
provider_type = "ollama"
|
||||
base_url = "http://localhost:11434"
|
||||
default_model = "qwen3:4b"
|
||||
max_tokens = 2048
|
||||
temperature = 0.3
|
||||
is_enabled = true
|
||||
|
||||
[storage]
|
||||
upload_dir = "./uploads"
|
||||
max_file_size = "10MB"
|
||||
# 签名 URL 密钥(生产环境必须通过 ERP__STORAGE__SECRET_KEY 环境变量设置)
|
||||
secret_key = "dev-only-secret-key-change-in-production"
|
||||
|
||||
[rate_limit]
|
||||
# Redis 不可达时是否拒绝请求(fail-close)。默认 true = 安全优先。
|
||||
# 开发环境可设为 false 以避免 Redis 依赖:ERP__RATE_LIMIT__FAIL_CLOSE=false
|
||||
fail_close = true
|
||||
8
crates/erp-server/migration/Cargo.toml
Normal file
8
crates/erp-server/migration/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "erp-server-migration"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
sea-orm-migration.workspace = true
|
||||
tokio.workspace = true
|
||||
122
crates/erp-server/migration/src/lib.rs
Normal file
122
crates/erp-server/migration/src/lib.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
#![allow(clippy::too_many_arguments)]
|
||||
|
||||
pub use sea_orm_migration::prelude::*;
|
||||
|
||||
mod m20260410_000001_create_tenant;
|
||||
mod m20260411_000002_create_users;
|
||||
mod m20260411_000003_create_user_credentials;
|
||||
mod m20260411_000004_create_user_tokens;
|
||||
mod m20260411_000005_create_roles;
|
||||
mod m20260411_000006_create_permissions;
|
||||
mod m20260411_000007_create_role_permissions;
|
||||
mod m20260411_000008_create_user_roles;
|
||||
mod m20260411_000009_create_organizations;
|
||||
mod m20260411_000010_create_departments;
|
||||
mod m20260411_000011_create_positions;
|
||||
mod m20260412_000012_create_dictionaries;
|
||||
mod m20260412_000013_create_dictionary_items;
|
||||
mod m20260412_000014_create_menus;
|
||||
mod m20260412_000015_create_menu_roles;
|
||||
mod m20260412_000016_create_settings;
|
||||
mod m20260412_000017_create_numbering_rules;
|
||||
mod m20260412_000018_create_process_definitions;
|
||||
mod m20260412_000019_create_process_instances;
|
||||
mod m20260412_000020_create_tokens;
|
||||
mod m20260412_000021_create_tasks;
|
||||
mod m20260412_000022_create_process_variables;
|
||||
mod m20260413_000023_create_message_templates;
|
||||
mod m20260413_000024_create_messages;
|
||||
mod m20260413_000025_create_message_subscriptions;
|
||||
mod m20260413_000026_create_audit_logs;
|
||||
mod m20260414_000027_fix_unique_indexes_soft_delete;
|
||||
mod m20260414_000028_add_standard_fields_to_tokens;
|
||||
mod m20260414_000029_add_standard_fields_to_process_variables;
|
||||
mod m20260415_000030_add_version_to_message_tables;
|
||||
mod m20260416_000031_create_domain_events;
|
||||
mod m20260414_000032_fix_settings_unique_index_null;
|
||||
mod m20260417_000033_create_plugins;
|
||||
mod m20260417_000034_seed_plugin_permissions;
|
||||
mod m20260418_000035_pg_trgm_and_entity_columns;
|
||||
mod m20260418_000036_add_data_scope_to_role_permissions;
|
||||
mod m20260419_000037_create_user_departments;
|
||||
mod m20260419_000039_entity_registry_columns;
|
||||
mod m20260419_000040_plugin_market;
|
||||
mod m20260419_000041_plugin_user_views;
|
||||
mod m20260423_000043_create_wechat_users;
|
||||
mod m20260427_000062_create_tenant_crypto_keys;
|
||||
mod m20260427_000084_domain_events_cleanup;
|
||||
mod m20260427_000085_processed_events;
|
||||
mod m20260427_000086_enable_rls_all_tables;
|
||||
mod m20260427_000087_audit_logs_hash_chain;
|
||||
mod m20260428_000088_rls_policy_strict;
|
||||
mod m20260428_000089_blind_indexes;
|
||||
mod m20260428_000091_dead_letter_events;
|
||||
mod m20260504_000106_create_api_clients;
|
||||
mod m20260513_000144_enforce_version_optimistic_lock;
|
||||
mod m20260518_000149_fix_admin_permissions;
|
||||
mod m20260529_000169_supplement_rls_for_new_tables;
|
||||
mod m20260613_000170_drop_wechat_users;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigratorTrait for Migrator {
|
||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||
vec![
|
||||
Box::new(m20260410_000001_create_tenant::Migration),
|
||||
Box::new(m20260411_000002_create_users::Migration),
|
||||
Box::new(m20260411_000003_create_user_credentials::Migration),
|
||||
Box::new(m20260411_000004_create_user_tokens::Migration),
|
||||
Box::new(m20260411_000005_create_roles::Migration),
|
||||
Box::new(m20260411_000006_create_permissions::Migration),
|
||||
Box::new(m20260411_000007_create_role_permissions::Migration),
|
||||
Box::new(m20260411_000008_create_user_roles::Migration),
|
||||
Box::new(m20260411_000009_create_organizations::Migration),
|
||||
Box::new(m20260411_000010_create_departments::Migration),
|
||||
Box::new(m20260411_000011_create_positions::Migration),
|
||||
Box::new(m20260412_000012_create_dictionaries::Migration),
|
||||
Box::new(m20260412_000013_create_dictionary_items::Migration),
|
||||
Box::new(m20260412_000014_create_menus::Migration),
|
||||
Box::new(m20260412_000015_create_menu_roles::Migration),
|
||||
Box::new(m20260412_000016_create_settings::Migration),
|
||||
Box::new(m20260412_000017_create_numbering_rules::Migration),
|
||||
Box::new(m20260412_000018_create_process_definitions::Migration),
|
||||
Box::new(m20260412_000019_create_process_instances::Migration),
|
||||
Box::new(m20260412_000020_create_tokens::Migration),
|
||||
Box::new(m20260412_000021_create_tasks::Migration),
|
||||
Box::new(m20260412_000022_create_process_variables::Migration),
|
||||
Box::new(m20260413_000023_create_message_templates::Migration),
|
||||
Box::new(m20260413_000024_create_messages::Migration),
|
||||
Box::new(m20260413_000025_create_message_subscriptions::Migration),
|
||||
Box::new(m20260413_000026_create_audit_logs::Migration),
|
||||
Box::new(m20260414_000027_fix_unique_indexes_soft_delete::Migration),
|
||||
Box::new(m20260414_000028_add_standard_fields_to_tokens::Migration),
|
||||
Box::new(m20260414_000029_add_standard_fields_to_process_variables::Migration),
|
||||
Box::new(m20260415_000030_add_version_to_message_tables::Migration),
|
||||
Box::new(m20260416_000031_create_domain_events::Migration),
|
||||
Box::new(m20260414_000032_fix_settings_unique_index_null::Migration),
|
||||
Box::new(m20260417_000033_create_plugins::Migration),
|
||||
Box::new(m20260417_000034_seed_plugin_permissions::Migration),
|
||||
Box::new(m20260418_000035_pg_trgm_and_entity_columns::Migration),
|
||||
Box::new(m20260418_000036_add_data_scope_to_role_permissions::Migration),
|
||||
Box::new(m20260419_000037_create_user_departments::Migration),
|
||||
Box::new(m20260419_000039_entity_registry_columns::Migration),
|
||||
Box::new(m20260419_000040_plugin_market::Migration),
|
||||
Box::new(m20260419_000041_plugin_user_views::Migration),
|
||||
Box::new(m20260423_000043_create_wechat_users::Migration),
|
||||
Box::new(m20260427_000062_create_tenant_crypto_keys::Migration),
|
||||
Box::new(m20260427_000084_domain_events_cleanup::Migration),
|
||||
Box::new(m20260427_000085_processed_events::Migration),
|
||||
Box::new(m20260427_000086_enable_rls_all_tables::Migration),
|
||||
Box::new(m20260427_000087_audit_logs_hash_chain::Migration),
|
||||
Box::new(m20260428_000088_rls_policy_strict::Migration),
|
||||
Box::new(m20260428_000089_blind_indexes::Migration),
|
||||
Box::new(m20260428_000091_dead_letter_events::Migration),
|
||||
Box::new(m20260504_000106_create_api_clients::Migration),
|
||||
Box::new(m20260513_000144_enforce_version_optimistic_lock::Migration),
|
||||
Box::new(m20260518_000149_fix_admin_permissions::Migration),
|
||||
Box::new(m20260529_000169_supplement_rls_for_new_tables::Migration),
|
||||
Box::new(m20260613_000170_drop_wechat_users::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Tenant::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Tenant::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Tenant::Name).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Tenant::Code)
|
||||
.string()
|
||||
.not_null()
|
||||
.unique_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Tenant::Status)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("active"),
|
||||
)
|
||||
.col(ColumnDef::new(Tenant::Settings).json().null())
|
||||
.col(
|
||||
ColumnDef::new(Tenant::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Tenant::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Tenant::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Tenant::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Tenant {
|
||||
Table,
|
||||
Id,
|
||||
Name,
|
||||
Code,
|
||||
Status,
|
||||
Settings,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
DeletedAt,
|
||||
}
|
||||
104
crates/erp-server/migration/src/m20260411_000002_create_users.rs
Normal file
104
crates/erp-server/migration/src/m20260411_000002_create_users.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Users::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Users::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Users::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(Users::Username).string().not_null())
|
||||
.col(ColumnDef::new(Users::Email).string().null())
|
||||
.col(ColumnDef::new(Users::Phone).string().null())
|
||||
.col(ColumnDef::new(Users::DisplayName).string().null())
|
||||
.col(ColumnDef::new(Users::AvatarUrl).string().null())
|
||||
.col(
|
||||
ColumnDef::new(Users::Status)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("active"),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Users::LastLoginAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Users::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Users::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(Users::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(Users::UpdatedBy).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Users::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Users::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_users_tenant_id")
|
||||
.table(Users::Table)
|
||||
.col(Users::TenantId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_users_tenant_username ON users (tenant_id, username) WHERE deleted_at IS NULL".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Users::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Users {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
Username,
|
||||
Email,
|
||||
Phone,
|
||||
DisplayName,
|
||||
AvatarUrl,
|
||||
Status,
|
||||
LastLoginAt,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(UserCredentials::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(UserCredentials::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(UserCredentials::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(UserCredentials::UserId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(UserCredentials::CredentialType)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("password"),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(UserCredentials::CredentialData)
|
||||
.json()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(UserCredentials::Verified)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(false),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(UserCredentials::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(UserCredentials::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(UserCredentials::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(UserCredentials::UpdatedBy).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(UserCredentials::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(UserCredentials::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.foreign_key(
|
||||
&mut ForeignKey::create()
|
||||
.name("fk_user_credentials_user_id")
|
||||
.from(UserCredentials::Table, UserCredentials::UserId)
|
||||
.to(Users::Table, Users::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_user_credentials_tenant_id")
|
||||
.table(UserCredentials::Table)
|
||||
.col(UserCredentials::TenantId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_user_credentials_user_id")
|
||||
.table(UserCredentials::Table)
|
||||
.col(UserCredentials::UserId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(UserCredentials::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum UserCredentials {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
UserId,
|
||||
CredentialType,
|
||||
CredentialData,
|
||||
Verified,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Users {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(UserTokens::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(UserTokens::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(UserTokens::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(UserTokens::UserId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(UserTokens::TokenHash)
|
||||
.string()
|
||||
.not_null()
|
||||
.unique_key(),
|
||||
)
|
||||
.col(ColumnDef::new(UserTokens::TokenType).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(UserTokens::ExpiresAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(UserTokens::RevokedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(ColumnDef::new(UserTokens::DeviceInfo).string().null())
|
||||
.col(
|
||||
ColumnDef::new(UserTokens::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(UserTokens::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(UserTokens::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(UserTokens::UpdatedBy).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(UserTokens::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(UserTokens::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.foreign_key(
|
||||
&mut ForeignKey::create()
|
||||
.name("fk_user_tokens_user_id")
|
||||
.from(UserTokens::Table, UserTokens::UserId)
|
||||
.to(Users::Table, Users::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_user_tokens_tenant_id")
|
||||
.table(UserTokens::Table)
|
||||
.col(UserTokens::TenantId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_user_tokens_user_id")
|
||||
.table(UserTokens::Table)
|
||||
.col(UserTokens::UserId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_user_tokens_token_hash")
|
||||
.table(UserTokens::Table)
|
||||
.col(UserTokens::TokenHash)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(UserTokens::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum UserTokens {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
UserId,
|
||||
TokenHash,
|
||||
TokenType,
|
||||
ExpiresAt,
|
||||
RevokedAt,
|
||||
DeviceInfo,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Users {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
101
crates/erp-server/migration/src/m20260411_000005_create_roles.rs
Normal file
101
crates/erp-server/migration/src/m20260411_000005_create_roles.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Roles::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Roles::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Roles::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(Roles::Name).string().not_null())
|
||||
.col(ColumnDef::new(Roles::Code).string().not_null())
|
||||
.col(ColumnDef::new(Roles::Description).text().null())
|
||||
.col(
|
||||
ColumnDef::new(Roles::IsSystem)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(false),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Roles::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Roles::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(Roles::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(Roles::UpdatedBy).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Roles::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Roles::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_roles_tenant_id")
|
||||
.table(Roles::Table)
|
||||
.col(Roles::TenantId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_roles_tenant_code")
|
||||
.table(Roles::Table)
|
||||
.col(Roles::TenantId)
|
||||
.col(Roles::Code)
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Roles::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Roles {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
Name,
|
||||
Code,
|
||||
Description,
|
||||
IsSystem,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Permissions::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Permissions::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Permissions::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(Permissions::Code).string().not_null())
|
||||
.col(ColumnDef::new(Permissions::Name).string().not_null())
|
||||
.col(ColumnDef::new(Permissions::Resource).string().not_null())
|
||||
.col(ColumnDef::new(Permissions::Action).string().not_null())
|
||||
.col(ColumnDef::new(Permissions::Description).text().null())
|
||||
.col(
|
||||
ColumnDef::new(Permissions::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Permissions::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(Permissions::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(Permissions::UpdatedBy).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Permissions::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Permissions::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_permissions_tenant_id")
|
||||
.table(Permissions::Table)
|
||||
.col(Permissions::TenantId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_permissions_tenant_code")
|
||||
.table(Permissions::Table)
|
||||
.col(Permissions::TenantId)
|
||||
.col(Permissions::Code)
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Permissions::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Permissions {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
Code,
|
||||
Name,
|
||||
Resource,
|
||||
Action,
|
||||
Description,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(RolePermissions::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(RolePermissions::RoleId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(RolePermissions::PermissionId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(RolePermissions::TenantId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(RolePermissions::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(RolePermissions::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(RolePermissions::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(RolePermissions::UpdatedBy).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(RolePermissions::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(RolePermissions::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.primary_key(
|
||||
Index::create()
|
||||
.col(RolePermissions::RoleId)
|
||||
.col(RolePermissions::PermissionId),
|
||||
)
|
||||
.foreign_key(
|
||||
&mut ForeignKey::create()
|
||||
.name("fk_role_permissions_role_id")
|
||||
.from(RolePermissions::Table, RolePermissions::RoleId)
|
||||
.to(Roles::Table, Roles::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.foreign_key(
|
||||
&mut ForeignKey::create()
|
||||
.name("fk_role_permissions_permission_id")
|
||||
.from(RolePermissions::Table, RolePermissions::PermissionId)
|
||||
.to(Permissions::Table, Permissions::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_role_permissions_tenant_id")
|
||||
.table(RolePermissions::Table)
|
||||
.col(RolePermissions::TenantId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(RolePermissions::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum RolePermissions {
|
||||
Table,
|
||||
RoleId,
|
||||
PermissionId,
|
||||
TenantId,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Roles {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Permissions {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(UserRoles::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(UserRoles::UserId).uuid().not_null())
|
||||
.col(ColumnDef::new(UserRoles::RoleId).uuid().not_null())
|
||||
.col(ColumnDef::new(UserRoles::TenantId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(UserRoles::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(UserRoles::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(UserRoles::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(UserRoles::UpdatedBy).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(UserRoles::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(UserRoles::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.primary_key(
|
||||
Index::create()
|
||||
.col(UserRoles::UserId)
|
||||
.col(UserRoles::RoleId),
|
||||
)
|
||||
.foreign_key(
|
||||
&mut ForeignKey::create()
|
||||
.name("fk_user_roles_user_id")
|
||||
.from(UserRoles::Table, UserRoles::UserId)
|
||||
.to(Users::Table, Users::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.foreign_key(
|
||||
&mut ForeignKey::create()
|
||||
.name("fk_user_roles_role_id")
|
||||
.from(UserRoles::Table, UserRoles::RoleId)
|
||||
.to(Roles::Table, Roles::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_user_roles_tenant_id")
|
||||
.table(UserRoles::Table)
|
||||
.col(UserRoles::TenantId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(UserRoles::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum UserRoles {
|
||||
Table,
|
||||
UserId,
|
||||
RoleId,
|
||||
TenantId,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Users {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Roles {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Organizations::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Organizations::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Organizations::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(Organizations::Name).string().not_null())
|
||||
.col(ColumnDef::new(Organizations::Code).string().null())
|
||||
.col(ColumnDef::new(Organizations::ParentId).uuid().null())
|
||||
.col(ColumnDef::new(Organizations::Path).string().null())
|
||||
.col(
|
||||
ColumnDef::new(Organizations::Level)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Organizations::SortOrder)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Organizations::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Organizations::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(Organizations::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(Organizations::UpdatedBy).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Organizations::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Organizations::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.foreign_key(
|
||||
&mut ForeignKey::create()
|
||||
.name("fk_organizations_parent_id")
|
||||
.from(Organizations::Table, Organizations::ParentId)
|
||||
.to(Organizations::Table, Organizations::Id)
|
||||
.on_delete(ForeignKeyAction::Restrict)
|
||||
.to_owned(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_organizations_tenant_id")
|
||||
.table(Organizations::Table)
|
||||
.col(Organizations::TenantId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_organizations_tenant_code ON organizations (tenant_id, code) WHERE code IS NOT NULL".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Organizations::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Organizations {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
Name,
|
||||
Code,
|
||||
ParentId,
|
||||
Path,
|
||||
Level,
|
||||
SortOrder,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Departments::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Departments::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Departments::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(Departments::OrgId).uuid().not_null())
|
||||
.col(ColumnDef::new(Departments::Name).string().not_null())
|
||||
.col(ColumnDef::new(Departments::Code).string().null())
|
||||
.col(ColumnDef::new(Departments::ParentId).uuid().null())
|
||||
.col(ColumnDef::new(Departments::ManagerId).uuid().null())
|
||||
.col(ColumnDef::new(Departments::Path).string().null())
|
||||
.col(
|
||||
ColumnDef::new(Departments::SortOrder)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Departments::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Departments::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(Departments::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(Departments::UpdatedBy).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Departments::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Departments::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.foreign_key(
|
||||
&mut ForeignKey::create()
|
||||
.name("fk_departments_org_id")
|
||||
.from(Departments::Table, Departments::OrgId)
|
||||
.to(Organizations::Table, Organizations::Id)
|
||||
.on_delete(ForeignKeyAction::Restrict)
|
||||
.to_owned(),
|
||||
)
|
||||
.foreign_key(
|
||||
&mut ForeignKey::create()
|
||||
.name("fk_departments_parent_id")
|
||||
.from(Departments::Table, Departments::ParentId)
|
||||
.to(Departments::Table, Departments::Id)
|
||||
.on_delete(ForeignKeyAction::Restrict)
|
||||
.to_owned(),
|
||||
)
|
||||
.foreign_key(
|
||||
&mut ForeignKey::create()
|
||||
.name("fk_departments_manager_id")
|
||||
.from(Departments::Table, Departments::ManagerId)
|
||||
.to(Users::Table, Users::Id)
|
||||
.on_delete(ForeignKeyAction::SetNull)
|
||||
.to_owned(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_departments_tenant_id")
|
||||
.table(Departments::Table)
|
||||
.col(Departments::TenantId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_departments_org_id")
|
||||
.table(Departments::Table)
|
||||
.col(Departments::OrgId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Departments::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Departments {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
OrgId,
|
||||
Name,
|
||||
Code,
|
||||
ParentId,
|
||||
ManagerId,
|
||||
Path,
|
||||
SortOrder,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Organizations {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Users {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Positions::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Positions::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Positions::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(Positions::DeptId).uuid().not_null())
|
||||
.col(ColumnDef::new(Positions::Name).string().not_null())
|
||||
.col(ColumnDef::new(Positions::Code).string().null())
|
||||
.col(
|
||||
ColumnDef::new(Positions::Level)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Positions::SortOrder)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Positions::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Positions::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(Positions::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(Positions::UpdatedBy).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Positions::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Positions::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.foreign_key(
|
||||
&mut ForeignKey::create()
|
||||
.name("fk_positions_dept_id")
|
||||
.from(Positions::Table, Positions::DeptId)
|
||||
.to(Departments::Table, Departments::Id)
|
||||
.on_delete(ForeignKeyAction::Restrict)
|
||||
.to_owned(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_positions_tenant_id")
|
||||
.table(Positions::Table)
|
||||
.col(Positions::TenantId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_positions_dept_id")
|
||||
.table(Positions::Table)
|
||||
.col(Positions::DeptId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Positions::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Positions {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
DeptId,
|
||||
Name,
|
||||
Code,
|
||||
Level,
|
||||
SortOrder,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Departments {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Dictionaries::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Dictionaries::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Dictionaries::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(Dictionaries::Name).string().not_null())
|
||||
.col(ColumnDef::new(Dictionaries::Code).string().not_null())
|
||||
.col(ColumnDef::new(Dictionaries::Description).text().null())
|
||||
.col(
|
||||
ColumnDef::new(Dictionaries::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Dictionaries::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(Dictionaries::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(Dictionaries::UpdatedBy).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Dictionaries::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Dictionaries::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_dictionaries_tenant_id")
|
||||
.table(Dictionaries::Table)
|
||||
.col(Dictionaries::TenantId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_dictionaries_tenant_code ON dictionaries (tenant_id, code) WHERE deleted_at IS NULL".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Dictionaries::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Dictionaries {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
Name,
|
||||
Code,
|
||||
Description,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(DictionaryItems::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(DictionaryItems::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(DictionaryItems::TenantId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(DictionaryItems::DictionaryId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(DictionaryItems::Label).string().not_null())
|
||||
.col(ColumnDef::new(DictionaryItems::Value).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(DictionaryItems::SortOrder)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(ColumnDef::new(DictionaryItems::Color).string().null())
|
||||
.col(
|
||||
ColumnDef::new(DictionaryItems::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(DictionaryItems::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(DictionaryItems::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(DictionaryItems::UpdatedBy).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(DictionaryItems::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(DictionaryItems::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_dict_items_dictionary")
|
||||
.from(DictionaryItems::Table, DictionaryItems::DictionaryId)
|
||||
.to(Dictionaries::Table, Dictionaries::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_dict_items_dictionary_id")
|
||||
.table(DictionaryItems::Table)
|
||||
.col(DictionaryItems::DictionaryId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_dict_items_dict_value ON dictionary_items (dictionary_id, value) WHERE deleted_at IS NULL".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(DictionaryItems::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Dictionaries {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum DictionaryItems {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
DictionaryId,
|
||||
Label,
|
||||
Value,
|
||||
SortOrder,
|
||||
Color,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
124
crates/erp-server/migration/src/m20260412_000014_create_menus.rs
Normal file
124
crates/erp-server/migration/src/m20260412_000014_create_menus.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Menus::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Menus::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Menus::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(Menus::ParentId).uuid().null())
|
||||
.col(ColumnDef::new(Menus::Title).string().not_null())
|
||||
.col(ColumnDef::new(Menus::Path).string().null())
|
||||
.col(ColumnDef::new(Menus::Icon).string().null())
|
||||
.col(
|
||||
ColumnDef::new(Menus::SortOrder)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Menus::Visible)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(true),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Menus::MenuType)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("page"),
|
||||
)
|
||||
.col(ColumnDef::new(Menus::Permission).string().null())
|
||||
.col(
|
||||
ColumnDef::new(Menus::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Menus::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(Menus::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(Menus::UpdatedBy).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Menus::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Menus::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_menus_parent")
|
||||
.from(Menus::Table, Menus::ParentId)
|
||||
.to(Menus::Table, Menus::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_menus_tenant_id")
|
||||
.table(Menus::Table)
|
||||
.col(Menus::TenantId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_menus_parent_id")
|
||||
.table(Menus::Table)
|
||||
.col(Menus::ParentId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Menus::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Menus {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
ParentId,
|
||||
Title,
|
||||
Path,
|
||||
Icon,
|
||||
SortOrder,
|
||||
Visible,
|
||||
MenuType,
|
||||
Permission,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(MenuRoles::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(MenuRoles::MenuId).uuid().not_null())
|
||||
.col(ColumnDef::new(MenuRoles::RoleId).uuid().not_null())
|
||||
.col(ColumnDef::new(MenuRoles::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(MenuRoles::Id).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(MenuRoles::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MenuRoles::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(MenuRoles::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(MenuRoles::UpdatedBy).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(MenuRoles::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MenuRoles::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.primary_key(Index::create().col(MenuRoles::Id))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_menu_roles_unique ON menu_roles (menu_id, role_id) WHERE deleted_at IS NULL".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_menu_roles_menu_id")
|
||||
.table(MenuRoles::Table)
|
||||
.col(MenuRoles::MenuId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_menu_roles_role_id")
|
||||
.table(MenuRoles::Table)
|
||||
.col(MenuRoles::RoleId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(MenuRoles::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum MenuRoles {
|
||||
Table,
|
||||
Id,
|
||||
MenuId,
|
||||
RoleId,
|
||||
TenantId,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Settings::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Settings::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Settings::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(Settings::Scope).string().not_null())
|
||||
.col(ColumnDef::new(Settings::ScopeId).uuid().null())
|
||||
.col(ColumnDef::new(Settings::SettingKey).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Settings::SettingValue)
|
||||
.json_binary()
|
||||
.not_null()
|
||||
.default(Expr::val("{}")),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Settings::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Settings::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(Settings::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(Settings::UpdatedBy).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Settings::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Settings::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_settings_tenant_id")
|
||||
.table(Settings::Table)
|
||||
.col(Settings::TenantId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_settings_scope_key ON settings (tenant_id, scope, scope_id, setting_key) WHERE deleted_at IS NULL".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Settings::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Settings {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
Scope,
|
||||
ScopeId,
|
||||
SettingKey,
|
||||
SettingValue,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(NumberingRules::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(NumberingRules::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(NumberingRules::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(NumberingRules::Name).string().not_null())
|
||||
.col(ColumnDef::new(NumberingRules::Code).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(NumberingRules::Prefix)
|
||||
.string()
|
||||
.not_null()
|
||||
.default(""),
|
||||
)
|
||||
.col(ColumnDef::new(NumberingRules::DateFormat).string().null())
|
||||
.col(
|
||||
ColumnDef::new(NumberingRules::SeqLength)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(4),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(NumberingRules::SeqStart)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(NumberingRules::SeqCurrent)
|
||||
.big_integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(NumberingRules::Separator)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("-"),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(NumberingRules::ResetCycle)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("never"),
|
||||
)
|
||||
.col(ColumnDef::new(NumberingRules::LastResetDate).date().null())
|
||||
.col(
|
||||
ColumnDef::new(NumberingRules::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(NumberingRules::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(NumberingRules::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(NumberingRules::UpdatedBy).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(NumberingRules::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(NumberingRules::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_numbering_rules_tenant_id")
|
||||
.table(NumberingRules::Table)
|
||||
.col(NumberingRules::TenantId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_numbering_rules_tenant_code ON numbering_rules (tenant_id, code) WHERE deleted_at IS NULL".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(NumberingRules::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum NumberingRules {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
Name,
|
||||
Code,
|
||||
Prefix,
|
||||
DateFormat,
|
||||
SeqLength,
|
||||
SeqStart,
|
||||
SeqCurrent,
|
||||
Separator,
|
||||
ResetCycle,
|
||||
LastResetDate,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(ProcessDefinitions::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(ProcessDefinitions::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessDefinitions::TenantId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(ProcessDefinitions::Name).string().not_null())
|
||||
.col(ColumnDef::new(ProcessDefinitions::Key).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(ProcessDefinitions::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.col(ColumnDef::new(ProcessDefinitions::Category).string().null())
|
||||
.col(
|
||||
ColumnDef::new(ProcessDefinitions::Description)
|
||||
.text()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessDefinitions::Nodes)
|
||||
.json_binary()
|
||||
.not_null()
|
||||
.default(Expr::val("[]")),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessDefinitions::Edges)
|
||||
.json_binary()
|
||||
.not_null()
|
||||
.default(Expr::val("[]")),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessDefinitions::Status)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("draft"),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessDefinitions::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessDefinitions::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessDefinitions::CreatedBy)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessDefinitions::UpdatedBy)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessDefinitions::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessDefinitions::VersionField)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_process_definitions_tenant_id")
|
||||
.table(ProcessDefinitions::Table)
|
||||
.col(ProcessDefinitions::TenantId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_process_definitions_key_version ON process_definitions (tenant_id, key, version) WHERE deleted_at IS NULL".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(ProcessDefinitions::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum ProcessDefinitions {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
Name,
|
||||
Key,
|
||||
Version,
|
||||
Category,
|
||||
Description,
|
||||
Nodes,
|
||||
Edges,
|
||||
Status,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
VersionField,
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(ProcessInstances::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(ProcessInstances::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(ProcessInstances::TenantId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(ProcessInstances::DefinitionId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessInstances::BusinessKey)
|
||||
.string()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessInstances::Status)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("running"),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessInstances::StartedBy)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessInstances::StartedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessInstances::CompletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessInstances::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessInstances::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessInstances::CreatedBy)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessInstances::UpdatedBy)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessInstances::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessInstances::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_instances_tenant_status")
|
||||
.table(ProcessInstances::Table)
|
||||
.col(ProcessInstances::TenantId)
|
||||
.col(ProcessInstances::Status)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_instances_definition")
|
||||
.from(ProcessInstances::Table, ProcessInstances::DefinitionId)
|
||||
.to(ProcessDefinitions::Table, ProcessDefinitions::Id)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(ProcessInstances::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum ProcessInstances {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
DefinitionId,
|
||||
BusinessKey,
|
||||
Status,
|
||||
StartedBy,
|
||||
StartedAt,
|
||||
CompletedAt,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum ProcessDefinitions {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Tokens::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Tokens::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Tokens::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(Tokens::InstanceId).uuid().not_null())
|
||||
.col(ColumnDef::new(Tokens::NodeId).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Tokens::Status)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("active"),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Tokens::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Tokens::ConsumedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_tokens_instance")
|
||||
.table(Tokens::Table)
|
||||
.col(Tokens::InstanceId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_tokens_instance")
|
||||
.from(Tokens::Table, Tokens::InstanceId)
|
||||
.to(ProcessInstances::Table, ProcessInstances::Id)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Tokens::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Tokens {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
InstanceId,
|
||||
NodeId,
|
||||
Status,
|
||||
CreatedAt,
|
||||
ConsumedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum ProcessInstances {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
155
crates/erp-server/migration/src/m20260412_000021_create_tasks.rs
Normal file
155
crates/erp-server/migration/src/m20260412_000021_create_tasks.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Tasks::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Tasks::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Tasks::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(Tasks::InstanceId).uuid().not_null())
|
||||
.col(ColumnDef::new(Tasks::TokenId).uuid().not_null())
|
||||
.col(ColumnDef::new(Tasks::NodeId).string().not_null())
|
||||
.col(ColumnDef::new(Tasks::NodeName).string().null())
|
||||
.col(ColumnDef::new(Tasks::AssigneeId).uuid().null())
|
||||
.col(ColumnDef::new(Tasks::CandidateGroups).json_binary().null())
|
||||
.col(
|
||||
ColumnDef::new(Tasks::Status)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("pending"),
|
||||
)
|
||||
.col(ColumnDef::new(Tasks::Outcome).string().null())
|
||||
.col(ColumnDef::new(Tasks::FormData).json_binary().null())
|
||||
.col(
|
||||
ColumnDef::new(Tasks::DueDate)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Tasks::CompletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Tasks::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Tasks::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(Tasks::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(Tasks::UpdatedBy).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Tasks::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Tasks::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_tasks_assignee")
|
||||
.table(Tasks::Table)
|
||||
.col(Tasks::TenantId)
|
||||
.col(Tasks::AssigneeId)
|
||||
.col(Tasks::Status)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_tasks_instance")
|
||||
.table(Tasks::Table)
|
||||
.col(Tasks::InstanceId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_tasks_instance")
|
||||
.from(Tasks::Table, Tasks::InstanceId)
|
||||
.to(ProcessInstances::Table, ProcessInstances::Id)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_tasks_token")
|
||||
.from(Tasks::Table, Tasks::TokenId)
|
||||
.to(Tokens::Table, Tokens::Id)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Tasks::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Tasks {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
InstanceId,
|
||||
TokenId,
|
||||
NodeId,
|
||||
NodeName,
|
||||
AssigneeId,
|
||||
CandidateGroups,
|
||||
Status,
|
||||
Outcome,
|
||||
FormData,
|
||||
DueDate,
|
||||
CompletedAt,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum ProcessInstances {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Tokens {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(ProcessVariables::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(ProcessVariables::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(ProcessVariables::TenantId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(ProcessVariables::InstanceId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(ProcessVariables::Name).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(ProcessVariables::VarType)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("string"),
|
||||
)
|
||||
.col(ColumnDef::new(ProcessVariables::ValueString).text().null())
|
||||
.col(
|
||||
ColumnDef::new(ProcessVariables::ValueNumber)
|
||||
.double()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessVariables::ValueBoolean)
|
||||
.boolean()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ProcessVariables::ValueDate)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_process_variables_instance_name ON process_variables (instance_id, name)".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_variables_instance")
|
||||
.from(ProcessVariables::Table, ProcessVariables::InstanceId)
|
||||
.to(ProcessInstances::Table, ProcessInstances::Id)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(ProcessVariables::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum ProcessVariables {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
InstanceId,
|
||||
Name,
|
||||
VarType,
|
||||
ValueString,
|
||||
ValueNumber,
|
||||
ValueBoolean,
|
||||
ValueDate,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum ProcessInstances {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(MessageTemplates::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(MessageTemplates::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(MessageTemplates::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(MessageTemplates::Name).string().not_null())
|
||||
.col(ColumnDef::new(MessageTemplates::Code).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(MessageTemplates::Channel)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("in_app"),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageTemplates::TitleTemplate)
|
||||
.string()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageTemplates::BodyTemplate)
|
||||
.text()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageTemplates::Language)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("zh-CN"),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageTemplates::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageTemplates::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageTemplates::CreatedBy)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageTemplates::UpdatedBy)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageTemplates::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_message_templates_tenant_code ON message_templates (tenant_id, code) WHERE deleted_at IS NULL".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(MessageTemplates::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum MessageTemplates {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
Name,
|
||||
Code,
|
||||
Channel,
|
||||
TitleTemplate,
|
||||
BodyTemplate,
|
||||
Language,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Messages::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Messages::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Messages::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(Messages::TemplateId).uuid().null())
|
||||
.col(ColumnDef::new(Messages::SenderId).uuid().null())
|
||||
.col(
|
||||
ColumnDef::new(Messages::SenderType)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("system"),
|
||||
)
|
||||
.col(ColumnDef::new(Messages::RecipientId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Messages::RecipientType)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("user"),
|
||||
)
|
||||
.col(ColumnDef::new(Messages::Title).string().not_null())
|
||||
.col(ColumnDef::new(Messages::Body).text().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Messages::Priority)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("normal"),
|
||||
)
|
||||
.col(ColumnDef::new(Messages::BusinessType).string().null())
|
||||
.col(ColumnDef::new(Messages::BusinessId).uuid().null())
|
||||
.col(
|
||||
ColumnDef::new(Messages::IsRead)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(false),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Messages::ReadAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Messages::IsArchived)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(false),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Messages::ArchivedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Messages::SentAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Messages::Status)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("sent"),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Messages::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Messages::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(Messages::CreatedBy).uuid().not_null())
|
||||
.col(ColumnDef::new(Messages::UpdatedBy).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Messages::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE INDEX idx_messages_tenant_recipient ON messages (tenant_id, recipient_id) WHERE deleted_at IS NULL".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE INDEX idx_messages_tenant_recipient_unread ON messages (tenant_id, recipient_id) WHERE deleted_at IS NULL AND is_read = false".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE INDEX idx_messages_tenant_business ON messages (tenant_id, business_type, business_id) WHERE deleted_at IS NULL".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_messages_template")
|
||||
.from(Messages::Table, Messages::TemplateId)
|
||||
.to(MessageTemplates::Table, MessageTemplates::Id)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Messages::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Messages {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
TemplateId,
|
||||
SenderId,
|
||||
SenderType,
|
||||
RecipientId,
|
||||
RecipientType,
|
||||
Title,
|
||||
Body,
|
||||
Priority,
|
||||
BusinessType,
|
||||
BusinessId,
|
||||
IsRead,
|
||||
ReadAt,
|
||||
IsArchived,
|
||||
ArchivedAt,
|
||||
SentAt,
|
||||
Status,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum MessageTemplates {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(MessageSubscriptions::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::TenantId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::UserId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::NotificationTypes)
|
||||
.json()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::ChannelPreferences)
|
||||
.json()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::DndEnabled)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(false),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::DndStart)
|
||||
.string()
|
||||
.null(),
|
||||
)
|
||||
.col(ColumnDef::new(MessageSubscriptions::DndEnd).string().null())
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::CreatedBy)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::UpdatedBy)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MessageSubscriptions::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_message_subscriptions_tenant_user ON message_subscriptions (tenant_id, user_id) WHERE deleted_at IS NULL".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(MessageSubscriptions::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum MessageSubscriptions {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
UserId,
|
||||
NotificationTypes,
|
||||
ChannelPreferences,
|
||||
DndEnabled,
|
||||
DndStart,
|
||||
DndEnd,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(AuditLogs::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(AuditLogs::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(AuditLogs::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(AuditLogs::UserId).uuid().null())
|
||||
.col(ColumnDef::new(AuditLogs::Action).string().not_null())
|
||||
.col(ColumnDef::new(AuditLogs::ResourceType).string().not_null())
|
||||
.col(ColumnDef::new(AuditLogs::ResourceId).uuid().null())
|
||||
.col(ColumnDef::new(AuditLogs::OldValue).json().null())
|
||||
.col(ColumnDef::new(AuditLogs::NewValue).json().null())
|
||||
.col(ColumnDef::new(AuditLogs::IpAddress).string().null())
|
||||
.col(ColumnDef::new(AuditLogs::UserAgent).text().null())
|
||||
.col(
|
||||
ColumnDef::new(AuditLogs::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.get_connection()
|
||||
.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE INDEX idx_audit_logs_tenant ON audit_logs (tenant_id)".to_string(),
|
||||
))
|
||||
.await
|
||||
.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
manager
|
||||
.get_connection()
|
||||
.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE INDEX idx_audit_logs_resource ON audit_logs (resource_type, resource_id)"
|
||||
.to_string(),
|
||||
))
|
||||
.await
|
||||
.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(AuditLogs::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum AuditLogs {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
UserId,
|
||||
Action,
|
||||
ResourceType,
|
||||
ResourceId,
|
||||
OldValue,
|
||||
NewValue,
|
||||
IpAddress,
|
||||
UserAgent,
|
||||
CreatedAt,
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use sea_orm::DatabaseBackend;
|
||||
use sea_orm::Statement;
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
/// Recreate unique indexes on roles and permissions to include soft-delete awareness.
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.get_connection()
|
||||
.execute(Statement::from_string(
|
||||
DatabaseBackend::Postgres,
|
||||
"DROP INDEX IF EXISTS idx_roles_tenant_code".to_string(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.get_connection()
|
||||
.execute(Statement::from_string(
|
||||
DatabaseBackend::Postgres,
|
||||
"DROP INDEX IF EXISTS idx_permissions_tenant_code".to_string(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.get_connection()
|
||||
.execute(Statement::from_string(
|
||||
DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_roles_tenant_code \
|
||||
ON roles (tenant_id, code) \
|
||||
WHERE deleted_at IS NULL"
|
||||
.to_string(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.get_connection()
|
||||
.execute(Statement::from_string(
|
||||
DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_permissions_tenant_code \
|
||||
ON permissions (tenant_id, code) \
|
||||
WHERE deleted_at IS NULL"
|
||||
.to_string(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.get_connection()
|
||||
.execute(Statement::from_string(
|
||||
DatabaseBackend::Postgres,
|
||||
"DROP INDEX IF EXISTS idx_roles_tenant_code".to_string(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.get_connection()
|
||||
.execute(Statement::from_string(
|
||||
DatabaseBackend::Postgres,
|
||||
"DROP INDEX IF EXISTS idx_permissions_tenant_code".to_string(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.get_connection()
|
||||
.execute(Statement::from_string(
|
||||
DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_roles_tenant_code \
|
||||
ON roles (tenant_id, code)"
|
||||
.to_string(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.get_connection()
|
||||
.execute(Statement::from_string(
|
||||
DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_permissions_tenant_code \
|
||||
ON permissions (tenant_id, code)"
|
||||
.to_string(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
/// 为 tokens 表添加缺失的标准字段: updated_at, created_by, updated_by, deleted_at, version。
|
||||
///
|
||||
/// tokens 表原始迁移缺少 ERP 标准要求的审计和乐观锁字段。
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Tokens::Table)
|
||||
.add_column(
|
||||
ColumnDef::new(Tokens::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.add_column(
|
||||
ColumnDef::new(Tokens::CreatedBy)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.default(Expr::val("00000000-0000-0000-0000-000000000000")),
|
||||
)
|
||||
.add_column(
|
||||
ColumnDef::new(Tokens::UpdatedBy)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.default(Expr::val("00000000-0000-0000-0000-000000000000")),
|
||||
)
|
||||
.add_column(
|
||||
ColumnDef::new(Tokens::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.add_column(
|
||||
ColumnDef::new(Tokens::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Tokens::Table)
|
||||
.drop_column(Tokens::UpdatedAt)
|
||||
.drop_column(Tokens::CreatedBy)
|
||||
.drop_column(Tokens::UpdatedBy)
|
||||
.drop_column(Tokens::DeletedAt)
|
||||
.drop_column(Tokens::Version)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Tokens {
|
||||
Table,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
/// 为 process_variables 表添加缺失的标准字段: created_at, updated_at, created_by, updated_by, deleted_at, version。
|
||||
///
|
||||
/// process_variables 表原始迁移缺少 ERP 标准要求的审计和乐观锁字段。
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(ProcessVariables::Table)
|
||||
.add_column(
|
||||
ColumnDef::new(ProcessVariables::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.add_column(
|
||||
ColumnDef::new(ProcessVariables::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.add_column(
|
||||
ColumnDef::new(ProcessVariables::CreatedBy)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.default(Expr::val("00000000-0000-0000-0000-000000000000")),
|
||||
)
|
||||
.add_column(
|
||||
ColumnDef::new(ProcessVariables::UpdatedBy)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.default(Expr::val("00000000-0000-0000-0000-000000000000")),
|
||||
)
|
||||
.add_column(
|
||||
ColumnDef::new(ProcessVariables::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.add_column(
|
||||
ColumnDef::new(ProcessVariables::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(ProcessVariables::Table)
|
||||
.drop_column(ProcessVariables::CreatedAt)
|
||||
.drop_column(ProcessVariables::UpdatedAt)
|
||||
.drop_column(ProcessVariables::CreatedBy)
|
||||
.drop_column(ProcessVariables::UpdatedBy)
|
||||
.drop_column(ProcessVariables::DeletedAt)
|
||||
.drop_column(ProcessVariables::Version)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum ProcessVariables {
|
||||
Table,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
/// 修复 settings 表唯一索引:原索引使用 scope_id 列,当 scope_id 为 NULL 时
|
||||
/// PostgreSQL B-tree 不认为两行重复(NULL != NULL),导致可插入重复数据。
|
||||
/// 修复方案:使用 COALESCE(scope_id, '00000000-0000-0000-0000-000000000000')
|
||||
/// 将 NULL 转为固定 UUID,使索引能正确约束 NULL scope_id 的行。
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 1. 删除旧索引
|
||||
manager
|
||||
.get_connection()
|
||||
.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"DROP INDEX IF EXISTS idx_settings_scope_key".to_string(),
|
||||
))
|
||||
.await
|
||||
.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
// 2. 先清理可能已存在的重复数据(保留每组最新的一条)
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"
|
||||
DELETE FROM settings a USING settings b
|
||||
WHERE a.id < b.id
|
||||
AND a.tenant_id = b.tenant_id
|
||||
AND a.scope = b.scope
|
||||
AND a.setting_key = b.setting_key
|
||||
AND a.deleted_at IS NULL
|
||||
AND b.deleted_at IS NULL
|
||||
AND COALESCE(a.scope_id, '00000000-0000-0000-0000-000000000000') = COALESCE(b.scope_id, '00000000-0000-0000-0000-000000000000')
|
||||
"#.to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
// 3. 创建新索引,使用 COALESCE 处理 NULL scope_id
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_settings_scope_key ON settings (tenant_id, scope, COALESCE(scope_id, '00000000-0000-0000-0000-000000000000'), setting_key) WHERE deleted_at IS NULL".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 回滚:删除新索引,恢复旧索引
|
||||
manager
|
||||
.get_connection()
|
||||
.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"DROP INDEX IF EXISTS idx_settings_scope_key".to_string(),
|
||||
))
|
||||
.await
|
||||
.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_settings_scope_key ON settings (tenant_id, scope, scope_id, setting_key) WHERE deleted_at IS NULL".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
/// 为三个消息表添加缺失的 version 列(乐观锁字段)。
|
||||
///
|
||||
/// CLAUDE.md 要求所有表包含 version 字段用于乐观锁,但消息模块的三个表遗漏了。
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// message_templates
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(MessageTemplates::Table)
|
||||
.add_column(
|
||||
ColumnDef::new(MessageTemplates::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// messages
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Messages::Table)
|
||||
.add_column(
|
||||
ColumnDef::new(Messages::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// message_subscriptions
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(MessageSubscriptions::Table)
|
||||
.add_column(
|
||||
ColumnDef::new(MessageSubscriptions::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(MessageTemplates::Table)
|
||||
.drop_column(MessageTemplates::Version)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Messages::Table)
|
||||
.drop_column(Messages::Version)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(MessageSubscriptions::Table)
|
||||
.drop_column(MessageSubscriptions::Version)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum MessageTemplates {
|
||||
Table,
|
||||
Version,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Messages {
|
||||
Table,
|
||||
Version,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum MessageSubscriptions {
|
||||
Table,
|
||||
Version,
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("domain_events"))
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("event_type"))
|
||||
.string_len(200)
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("payload")).json().null())
|
||||
.col(ColumnDef::new(Alias::new("correlation_id")).uuid().null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("status"))
|
||||
.string_len(20)
|
||||
.not_null()
|
||||
.default("pending"),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("attempts"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("last_error")).text().null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("published_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_domain_events_status")
|
||||
.table(Alias::new("domain_events"))
|
||||
.col(Alias::new("status"))
|
||||
.col(Alias::new("created_at"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_domain_events_tenant")
|
||||
.table(Alias::new("domain_events"))
|
||||
.col(Alias::new("tenant_id"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Alias::new("domain_events")).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 1. plugins 表 — 插件注册与生命周期
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("plugins"))
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("name"))
|
||||
.string_len(200)
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("plugin_version"))
|
||||
.string_len(50)
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("description")).text().null())
|
||||
.col(ColumnDef::new(Alias::new("author")).string_len(200).null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("status"))
|
||||
.string_len(20)
|
||||
.not_null()
|
||||
.default("uploaded"),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("manifest_json"))
|
||||
.json()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("wasm_binary"))
|
||||
.binary()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("wasm_hash"))
|
||||
.string_len(64)
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("config_json"))
|
||||
.json()
|
||||
.not_null()
|
||||
.default(Expr::val("{}")),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("error_message")).text().null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("installed_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("enabled_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
// 标准字段
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("updated_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("created_by")).uuid().null())
|
||||
.col(ColumnDef::new(Alias::new("updated_by")).uuid().null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("deleted_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("version"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_plugins_tenant_status")
|
||||
.table(Alias::new("plugins"))
|
||||
.col(Alias::new("tenant_id"))
|
||||
.col(Alias::new("status"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_plugins_name")
|
||||
.table(Alias::new("plugins"))
|
||||
.col(Alias::new("name"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 2. plugin_entities 表 — 插件动态表注册
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("plugin_entities"))
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("plugin_id")).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("entity_name"))
|
||||
.string_len(100)
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("table_name"))
|
||||
.string_len(200)
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("schema_json")).json().not_null())
|
||||
// 标准字段
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("updated_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("created_by")).uuid().null())
|
||||
.col(ColumnDef::new(Alias::new("updated_by")).uuid().null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("deleted_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("version"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_plugin_entities_plugin")
|
||||
.table(Alias::new("plugin_entities"))
|
||||
.col(Alias::new("plugin_id"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 3. plugin_event_subscriptions 表 — 事件订阅
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("plugin_event_subscriptions"))
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("plugin_id")).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("event_pattern"))
|
||||
.string_len(200)
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_plugin_event_subs_plugin")
|
||||
.table(Alias::new("plugin_event_subscriptions"))
|
||||
.col(Alias::new("plugin_id"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(
|
||||
Table::drop()
|
||||
.table(Alias::new("plugin_event_subscriptions"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.drop_table(
|
||||
Table::drop()
|
||||
.table(Alias::new("plugin_entities"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.drop_table(Table::drop().table(Alias::new("plugins")).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
/// 为已存在的租户补充 plugin 模块权限,并分配给 admin 角色。
|
||||
/// seed_tenant_auth 只在租户创建时执行,已存在的租户缺少 plugin 相关权限。
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
// 插入 plugin 权限(如果不存在)
|
||||
db.execute(sea_orm::Statement::from_string(
|
||||
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)
|
||||
SELECT gen_random_uuid(), t.id, 'plugin.admin', '插件管理', 'plugin', 'admin', '管理插件全生命周期', NOW(), NOW(), '00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000', NULL, 1
|
||||
FROM tenant t
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM permissions p WHERE p.code = 'plugin.admin' AND p.tenant_id = t.id AND p.deleted_at IS NULL
|
||||
)
|
||||
"#.to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
db.execute(sea_orm::Statement::from_string(
|
||||
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)
|
||||
SELECT gen_random_uuid(), t.id, 'plugin.list', '查看插件', 'plugin', 'list', '查看插件列表', NOW(), NOW(), '00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000', NULL, 1
|
||||
FROM tenant t
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM permissions p WHERE p.code = 'plugin.list' AND p.tenant_id = t.id AND p.deleted_at IS NULL
|
||||
)
|
||||
"#.to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
// 将 plugin 权限分配给 admin 角色(如果尚未分配)
|
||||
db.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"
|
||||
INSERT INTO role_permissions (role_id, permission_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||||
SELECT r.id, p.id, r.tenant_id, NOW(), NOW(), '00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000', NULL, 1
|
||||
FROM roles r
|
||||
JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code IN ('plugin.admin', 'plugin.list') AND p.deleted_at IS NULL
|
||||
WHERE r.code = 'admin' AND r.deleted_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp
|
||||
WHERE rp.role_id = r.id AND rp.permission_id = p.id AND rp.deleted_at IS NULL
|
||||
)
|
||||
"#.to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
// 删除 plugin 权限的角色关联
|
||||
db.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM permissions WHERE code IN ('plugin.admin', 'plugin.list')
|
||||
)
|
||||
"#
|
||||
.to_string(),
|
||||
))
|
||||
.await
|
||||
.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
// 删除 plugin 权限
|
||||
db.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"DELETE FROM permissions WHERE code IN ('plugin.admin', 'plugin.list')".to_string(),
|
||||
))
|
||||
.await
|
||||
.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 启用 pg_trgm 扩展(加速 ILIKE '%keyword%' 搜索)
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
||||
.await?;
|
||||
|
||||
// 插件实体列元数据表 — 记录哪些字段被提取为 Generated Column
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("plugin_entity_columns"))
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.default(Expr::cust("gen_random_uuid()"))
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("plugin_entity_id"))
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("field_name")).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("column_name"))
|
||||
.string()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("sql_type")).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("is_generated"))
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(true),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::cust("NOW()")),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// plugin_entity_id 外键
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_plugin_entity_columns_entity")
|
||||
.from(
|
||||
Alias::new("plugin_entity_columns"),
|
||||
Alias::new("plugin_entity_id"),
|
||||
)
|
||||
.to(Alias::new("plugin_entities"), Alias::new("id"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(
|
||||
Table::drop()
|
||||
.table(Alias::new("plugin_entity_columns"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
// pg_trgm 不卸载(其他功能可能依赖)
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 添加 data_scope 列 — 行级数据权限范围
|
||||
// 可选值: all, self, department, department_tree
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Alias::new("role_permissions"))
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("data_scope"))
|
||||
.string()
|
||||
.not_null()
|
||||
.default("all"),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Alias::new("role_permissions"))
|
||||
.drop_column(Alias::new("data_scope"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("user_departments"))
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Alias::new("user_id")).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("department_id"))
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("is_primary"))
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(false),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("updated_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("created_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("version"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.primary_key(
|
||||
Index::create()
|
||||
.col(Alias::new("user_id"))
|
||||
.col(Alias::new("department_id")),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 索引:按租户 + 用户查询部门
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.if_not_exists()
|
||||
.name("idx_user_departments_tenant_user")
|
||||
.table(Alias::new("user_departments"))
|
||||
.col(Alias::new("tenant_id"))
|
||||
.col(Alias::new("user_id"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 索引:按部门查询成员
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.if_not_exists()
|
||||
.name("idx_user_departments_dept")
|
||||
.table(Alias::new("user_departments"))
|
||||
.col(Alias::new("department_id"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(
|
||||
Table::drop()
|
||||
.table(Alias::new("user_departments"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// plugin_entities 新增 manifest_id 列 — 避免跨插件查询时 JOIN plugins 表
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared(
|
||||
r#"
|
||||
ALTER TABLE plugin_entities
|
||||
ADD COLUMN IF NOT EXISTS manifest_id TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE plugin_entities
|
||||
ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- 回填 manifest_id(从 plugins.manifest_json 提取 metadata.id)
|
||||
UPDATE plugin_entities pe
|
||||
SET manifest_id = COALESCE(p.manifest_json->'metadata'->>'id', '')
|
||||
FROM plugins p
|
||||
WHERE pe.plugin_id = p.id AND pe.deleted_at IS NULL;
|
||||
|
||||
-- 跨插件实体查找索引
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_entities_cross_ref
|
||||
ON plugin_entities (manifest_id, entity_name, tenant_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared(
|
||||
r#"
|
||||
DROP INDEX IF EXISTS idx_plugin_entities_cross_ref;
|
||||
ALTER TABLE plugin_entities DROP COLUMN IF EXISTS is_public;
|
||||
ALTER TABLE plugin_entities DROP COLUMN IF EXISTS manifest_id;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
/// 插件市场目录表 — P4 插件市场基础设施
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("plugin_market_entries"))
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("plugin_id")).string().not_null())
|
||||
.col(ColumnDef::new(Alias::new("name")).string().not_null())
|
||||
.col(ColumnDef::new(Alias::new("version")).string().not_null())
|
||||
.col(ColumnDef::new(Alias::new("description")).text())
|
||||
.col(ColumnDef::new(Alias::new("author")).string())
|
||||
.col(ColumnDef::new(Alias::new("category")).string()) // 行业分类
|
||||
.col(ColumnDef::new(Alias::new("tags")).json()) // 标签列表
|
||||
.col(ColumnDef::new(Alias::new("icon_url")).string())
|
||||
.col(ColumnDef::new(Alias::new("screenshots")).json()) // 截图 URL 列表
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("wasm_binary"))
|
||||
.binary()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("manifest_toml"))
|
||||
.text()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("wasm_hash")).string().not_null())
|
||||
.col(ColumnDef::new(Alias::new("min_platform_version")).string())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("status"))
|
||||
.string()
|
||||
.not_null()
|
||||
.default("published"),
|
||||
) // published | suspended
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("download_count"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("rating_avg"))
|
||||
.decimal()
|
||||
.not_null()
|
||||
.default(0.0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("rating_count"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("changelog")).text()) // 版本更新日志
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("updated_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 插件市场评论/评分表
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("plugin_market_reviews"))
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("user_id")).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("market_entry_id"))
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("rating")).integer().not_null()) // 1-5
|
||||
.col(ColumnDef::new(Alias::new("review_text")).text())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 唯一索引:每个用户对每个市场条目只能评一次
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.if_not_exists()
|
||||
.unique()
|
||||
.name("uq_market_review_tenant_user_entry")
|
||||
.table(Alias::new("plugin_market_reviews"))
|
||||
.col(Alias::new("tenant_id"))
|
||||
.col(Alias::new("user_id"))
|
||||
.col(Alias::new("market_entry_id"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(
|
||||
Table::drop()
|
||||
.table(Alias::new("plugin_market_reviews"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.drop_table(
|
||||
Table::drop()
|
||||
.table(Alias::new("plugin_market_entries"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
/// 插件用户视图 — 用户自定义的列表视图配置
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("plugin_user_views"))
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("user_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("plugin_id")).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("entity_name"))
|
||||
.string()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("view_name")).string().not_null())
|
||||
.col(ColumnDef::new(Alias::new("view_config")).json().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("is_default"))
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(false),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("updated_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(
|
||||
Table::drop()
|
||||
.table(Alias::new("plugin_user_views"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(WechatUsers::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(WechatUsers::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(WechatUsers::TenantId).uuid().not_null())
|
||||
.col(ColumnDef::new(WechatUsers::Openid).string().not_null())
|
||||
.col(ColumnDef::new(WechatUsers::UnionId).string())
|
||||
.col(ColumnDef::new(WechatUsers::UserId).uuid().not_null())
|
||||
.col(ColumnDef::new(WechatUsers::Phone).string())
|
||||
.col(
|
||||
ColumnDef::new(WechatUsers::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(WechatUsers::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(WechatUsers::CreatedBy).uuid())
|
||||
.col(ColumnDef::new(WechatUsers::UpdatedBy).uuid())
|
||||
.col(ColumnDef::new(WechatUsers::DeletedAt).timestamp_with_time_zone())
|
||||
.col(
|
||||
ColumnDef::new(WechatUsers::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_wechat_users_openid")
|
||||
.table(WechatUsers::Table)
|
||||
.col(WechatUsers::Openid)
|
||||
.col(WechatUsers::TenantId)
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_index(Index::drop().name("idx_wechat_users_openid").to_owned())
|
||||
.await?;
|
||||
manager
|
||||
.drop_table(Table::drop().table(WechatUsers::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum WechatUsers {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
Openid,
|
||||
UnionId,
|
||||
UserId,
|
||||
Phone,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
DeletedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
Version,
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(TenantCryptoKey::Table)
|
||||
.col(
|
||||
ColumnDef::new(TenantCryptoKey::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(TenantCryptoKey::TenantId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(TenantCryptoKey::EncryptedDek)
|
||||
.string_len(128)
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(TenantCryptoKey::KeyVersion)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(TenantCryptoKey::IsActive)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(true),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(TenantCryptoKey::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(TenantCryptoKey::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(TenantCryptoKey::CreatedBy).uuid())
|
||||
.col(ColumnDef::new(TenantCryptoKey::UpdatedBy).uuid())
|
||||
.col(ColumnDef::new(TenantCryptoKey::DeletedAt).timestamp_with_time_zone())
|
||||
.col(
|
||||
ColumnDef::new(TenantCryptoKey::Version)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.index(
|
||||
Index::create()
|
||||
.col(TenantCryptoKey::TenantId)
|
||||
.col(TenantCryptoKey::KeyVersion)
|
||||
.unique(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_tenant_crypto_keys_tenant")
|
||||
.table(TenantCryptoKey::Table)
|
||||
.col(TenantCryptoKey::TenantId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(TenantCryptoKey::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum TenantCryptoKey {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
EncryptedDek,
|
||||
KeyVersion,
|
||||
IsActive,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
Version,
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 归档表 — 与 domain_events 结构相同,用于存放 >90 天的已发布事件
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("domain_events_archive"))
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("event_type"))
|
||||
.string_len(200)
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("payload")).json().null())
|
||||
.col(ColumnDef::new(Alias::new("correlation_id")).uuid().null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("status"))
|
||||
.string_len(20)
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("attempts"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("last_error")).text().null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("published_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("archived_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_domain_events_archive_created")
|
||||
.table(Alias::new("domain_events_archive"))
|
||||
.col(Alias::new("created_at"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 清理函数:将 >90 天的已发布事件迁移到归档表
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared(
|
||||
r#"
|
||||
CREATE OR REPLACE FUNCTION cleanup_old_published_events(
|
||||
retention_days INT DEFAULT 90,
|
||||
batch_size INT DEFAULT 1000
|
||||
) RETURNS INT AS $$
|
||||
DECLARE
|
||||
moved_count INT;
|
||||
BEGIN
|
||||
INSERT INTO domain_events_archive (id, tenant_id, event_type, payload, correlation_id, status, attempts, last_error, created_at, published_at)
|
||||
SELECT id, tenant_id, event_type, payload, correlation_id, status, attempts, last_error, created_at, published_at
|
||||
FROM domain_events
|
||||
WHERE status = 'published'
|
||||
AND published_at < NOW() - (retention_days || ' days')::INTERVAL
|
||||
ORDER BY created_at ASC
|
||||
LIMIT batch_size;
|
||||
|
||||
GET DIAGNOSTICS moved_count = ROW_COUNT;
|
||||
|
||||
DELETE FROM domain_events
|
||||
WHERE ctid IN (
|
||||
SELECT ctid FROM domain_events
|
||||
WHERE status = 'published'
|
||||
AND published_at < NOW() - (retention_days || ' days')::INTERVAL
|
||||
ORDER BY created_at ASC
|
||||
LIMIT batch_size
|
||||
);
|
||||
|
||||
RETURN moved_count;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared("DROP FUNCTION IF EXISTS cleanup_old_published_events(INT, INT);")
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_table(
|
||||
Table::drop()
|
||||
.table(Alias::new("domain_events_archive"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("processed_events"))
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Alias::new("event_id")).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("consumer_id"))
|
||||
.string_len(200)
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("processed_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.primary_key(
|
||||
Index::create()
|
||||
.col(Alias::new("event_id"))
|
||||
.col(Alias::new("consumer_id")),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 7 天 TTL 清理函数
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared(
|
||||
r#"
|
||||
CREATE OR REPLACE FUNCTION cleanup_old_processed_events(
|
||||
retention_days INT DEFAULT 7,
|
||||
batch_size INT DEFAULT 1000
|
||||
) RETURNS INT AS $$
|
||||
DECLARE
|
||||
deleted_count INT;
|
||||
BEGIN
|
||||
DELETE FROM processed_events
|
||||
WHERE ctid IN (
|
||||
SELECT ctid FROM processed_events
|
||||
WHERE processed_at < NOW() - (retention_days || ' days')::INTERVAL
|
||||
LIMIT batch_size
|
||||
);
|
||||
|
||||
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||
RETURN deleted_count;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared("DROP FUNCTION IF EXISTS cleanup_old_processed_events(INT, INT);")
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_table(
|
||||
Table::drop()
|
||||
.table(Alias::new("processed_events"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let conn = manager.get_connection();
|
||||
|
||||
// PL/pgSQL 动态为所有含 tenant_id 列的表启用 RLS
|
||||
conn.execute_unprepared(
|
||||
r#"
|
||||
DO $$
|
||||
DECLARE
|
||||
tbl TEXT;
|
||||
BEGIN
|
||||
FOR tbl IN
|
||||
SELECT c.table_name FROM information_schema.columns c
|
||||
JOIN information_schema.tables t
|
||||
ON c.table_name = t.table_name AND c.table_schema = t.table_schema
|
||||
WHERE c.column_name = 'tenant_id'
|
||||
AND c.table_schema = 'public'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
ORDER BY c.table_name
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
|
||||
EXECUTE format('DROP POLICY IF EXISTS tenant_isolation ON %I', tbl);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY tenant_isolation ON %I USING (
|
||||
current_setting(''app.current_tenant_id'', true) = ''''
|
||||
OR tenant_id = current_setting(''app.current_tenant_id'', true)::uuid
|
||||
)',
|
||||
tbl
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let conn = manager.get_connection();
|
||||
|
||||
conn.execute_unprepared(
|
||||
r#"
|
||||
DO $$
|
||||
DECLARE
|
||||
tbl TEXT;
|
||||
BEGIN
|
||||
FOR tbl IN
|
||||
SELECT c.table_name FROM information_schema.columns c
|
||||
JOIN information_schema.tables t
|
||||
ON c.table_name = t.table_name AND c.table_schema = t.table_schema
|
||||
WHERE c.column_name = 'tenant_id'
|
||||
AND c.table_schema = 'public'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
ORDER BY c.table_name
|
||||
LOOP
|
||||
EXECUTE format('DROP POLICY IF EXISTS tenant_isolation ON %I', tbl);
|
||||
EXECUTE format('ALTER TABLE %I DISABLE ROW LEVEL SECURITY', tbl);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let conn = manager.get_connection();
|
||||
|
||||
conn.execute_unprepared("ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS prev_hash TEXT")
|
||||
.await?;
|
||||
|
||||
conn.execute_unprepared("ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS record_hash TEXT")
|
||||
.await?;
|
||||
|
||||
// 为 record_hash 创建索引(用于快速查找最新哈希)
|
||||
conn.execute_unprepared(
|
||||
"CREATE INDEX IF NOT EXISTS idx_audit_logs_record_hash
|
||||
ON audit_logs (record_hash) WHERE record_hash IS NOT NULL",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 按 tenant_id + created_at DESC 查找最新哈希的索引
|
||||
conn.execute_unprepared(
|
||||
"CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant_created
|
||||
ON audit_logs (tenant_id, created_at DESC)",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let conn = manager.get_connection();
|
||||
|
||||
conn.execute_unprepared("DROP INDEX IF EXISTS idx_audit_logs_tenant_created")
|
||||
.await?;
|
||||
|
||||
conn.execute_unprepared("DROP INDEX IF EXISTS idx_audit_logs_record_hash")
|
||||
.await?;
|
||||
|
||||
conn.execute_unprepared("ALTER TABLE audit_logs DROP COLUMN IF EXISTS record_hash")
|
||||
.await?;
|
||||
|
||||
conn.execute_unprepared("ALTER TABLE audit_logs DROP COLUMN IF EXISTS prev_hash")
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let conn = manager.get_connection();
|
||||
|
||||
// 替换所有表的 RLS 策略:移除空字符串绕过条件
|
||||
// 原策略允许 current_setting(...) = '' 时通过(绕过 RLS),现在要求变量已设置且匹配
|
||||
conn.execute_unprepared(
|
||||
r#"
|
||||
DO $$
|
||||
DECLARE
|
||||
tbl TEXT;
|
||||
BEGIN
|
||||
FOR tbl IN
|
||||
SELECT c.table_name FROM information_schema.columns c
|
||||
JOIN information_schema.tables t
|
||||
ON c.table_name = t.table_name AND c.table_schema = t.table_schema
|
||||
WHERE c.column_name = 'tenant_id'
|
||||
AND c.table_schema = 'public'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
ORDER BY c.table_name
|
||||
LOOP
|
||||
EXECUTE format('DROP POLICY IF EXISTS tenant_isolation ON %I', tbl);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY tenant_isolation ON %I USING (
|
||||
current_setting(''app.current_tenant_id'', true) != ''''
|
||||
AND tenant_id = current_setting(''app.current_tenant_id'', true)::uuid
|
||||
)',
|
||||
tbl
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let conn = manager.get_connection();
|
||||
|
||||
// 回滚:恢复允许空字符串绕过的原策略
|
||||
conn.execute_unprepared(
|
||||
r#"
|
||||
DO $$
|
||||
DECLARE
|
||||
tbl TEXT;
|
||||
BEGIN
|
||||
FOR tbl IN
|
||||
SELECT c.table_name FROM information_schema.columns c
|
||||
JOIN information_schema.tables t
|
||||
ON c.table_name = t.table_name AND c.table_schema = t.table_schema
|
||||
WHERE c.column_name = 'tenant_id'
|
||||
AND c.table_schema = 'public'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
ORDER BY c.table_name
|
||||
LOOP
|
||||
EXECUTE format('DROP POLICY IF EXISTS tenant_isolation ON %I', tbl);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY tenant_isolation ON %I USING (
|
||||
current_setting(''app.current_tenant_id'', true) = ''''
|
||||
OR tenant_id = current_setting(''app.current_tenant_id'', true)::uuid
|
||||
)',
|
||||
tbl
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[derive(Iden)]
|
||||
enum BlindIndex {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
EntityType,
|
||||
EntityId,
|
||||
FieldName,
|
||||
BlindHash,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(BlindIndex::Table)
|
||||
.col(
|
||||
ColumnDef::new(BlindIndex::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key()
|
||||
.default(PgFunc::gen_random_uuid()),
|
||||
)
|
||||
.col(ColumnDef::new(BlindIndex::TenantId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(BlindIndex::EntityType)
|
||||
.string_len(64)
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(BlindIndex::EntityId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(BlindIndex::FieldName)
|
||||
.string_len(64)
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(BlindIndex::BlindHash)
|
||||
.string_len(64)
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(BlindIndex::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(BlindIndex::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.index(
|
||||
Index::create()
|
||||
.col(BlindIndex::TenantId)
|
||||
.col(BlindIndex::EntityType)
|
||||
.col(BlindIndex::FieldName)
|
||||
.col(BlindIndex::BlindHash)
|
||||
.unique(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_blind_hashes")
|
||||
.table(BlindIndex::Table)
|
||||
.col(BlindIndex::TenantId)
|
||||
.col(BlindIndex::EntityType)
|
||||
.col(BlindIndex::FieldName)
|
||||
.col(BlindIndex::BlindHash)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(BlindIndex::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[derive(Iden)]
|
||||
enum DeadLetterEvent {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
OriginalEventId,
|
||||
EventType,
|
||||
Payload,
|
||||
ConsumerId,
|
||||
Attempts,
|
||||
LastError,
|
||||
CreatedAt,
|
||||
ResolvedAt,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(DeadLetterEvent::Table)
|
||||
.col(
|
||||
ColumnDef::new(DeadLetterEvent::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key()
|
||||
.default(PgFunc::gen_random_uuid()),
|
||||
)
|
||||
.col(ColumnDef::new(DeadLetterEvent::TenantId).uuid())
|
||||
.col(
|
||||
ColumnDef::new(DeadLetterEvent::OriginalEventId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(DeadLetterEvent::EventType)
|
||||
.string_len(128)
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(DeadLetterEvent::Payload).json_binary())
|
||||
.col(
|
||||
ColumnDef::new(DeadLetterEvent::ConsumerId)
|
||||
.string_len(128)
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(DeadLetterEvent::Attempts)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(ColumnDef::new(DeadLetterEvent::LastError).text())
|
||||
.col(
|
||||
ColumnDef::new(DeadLetterEvent::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(DeadLetterEvent::ResolvedAt).timestamp_with_time_zone())
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(DeadLetterEvent::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("api_clients"))
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.default(Expr::cust("gen_random_uuid()")),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("client_id"))
|
||||
.string_len(128)
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("client_secret_hash"))
|
||||
.string_len(256)
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("client_name"))
|
||||
.string_len(200)
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("scopes")).json().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("allowed_patient_ids"))
|
||||
.json()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("rate_limit_per_minute"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(60),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("is_active"))
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(true),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("token_lifetime_seconds"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(3600),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::cust("NOW()")),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("updated_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::cust("NOW()")),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("created_by")).uuid().null())
|
||||
.col(ColumnDef::new(Alias::new("updated_by")).uuid().null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("deleted_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("version"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.primary_key(Index::create().col(Alias::new("id")))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_api_clients_client_id_unique")
|
||||
.table(Alias::new("api_clients"))
|
||||
.col(Alias::new("client_id"))
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_api_clients_tenant_id")
|
||||
.table(Alias::new("api_clients"))
|
||||
.col(Alias::new("tenant_id"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Alias::new("api_clients")).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
// 1. 创建触发器函数:适用于 `version` 列(erp-health / erp-auth / erp-config 等所有模块)
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
CREATE OR REPLACE FUNCTION enforce_version() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW.version IS DISTINCT FROM OLD.version + 1 THEN
|
||||
RAISE EXCEPTION 'Optimistic lock conflict on %: expected version %, got %',
|
||||
TG_TABLE_NAME, OLD.version + 1, NEW.version;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 2. 创建触发器函数:适用于 `version_lock` 列(erp-ai 模块)
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
CREATE OR REPLACE FUNCTION enforce_version_lock() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW.version_lock IS DISTINCT FROM OLD.version_lock + 1 THEN
|
||||
RAISE EXCEPTION 'Optimistic lock conflict on %: expected version_lock %, got %',
|
||||
TG_TABLE_NAME, OLD.version_lock + 1, NEW.version_lock;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 3. 自动发现所有含 version / version_lock 的表并绑定触发器
|
||||
// 排除 market_entry(version 是 String,非乐观锁)
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
DO $$
|
||||
DECLARE
|
||||
rec RECORD;
|
||||
trig_name text;
|
||||
BEGIN
|
||||
FOR rec IN
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE column_name IN ('version', 'version_lock')
|
||||
AND table_schema = 'public'
|
||||
AND table_name NOT IN ('market_entry', 'process_definitions', 'ai_prompt')
|
||||
LOOP
|
||||
IF rec.column_name = 'version' THEN
|
||||
trig_name := 'trg_enforce_version';
|
||||
EXECUTE format(
|
||||
'CREATE TRIGGER %I BEFORE UPDATE ON %I
|
||||
FOR EACH ROW EXECUTE FUNCTION enforce_version()',
|
||||
trig_name, rec.table_name
|
||||
);
|
||||
ELSE
|
||||
trig_name := 'trg_enforce_version_lock';
|
||||
EXECUTE format(
|
||||
'CREATE TRIGGER %I BEFORE UPDATE ON %I
|
||||
FOR EACH ROW EXECUTE FUNCTION enforce_version_lock()',
|
||||
trig_name, rec.table_name
|
||||
);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
// 1. 删除所有乐观锁触发器
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
DO $$
|
||||
DECLARE
|
||||
rec RECORD;
|
||||
BEGIN
|
||||
FOR rec IN
|
||||
SELECT event_object_table AS table_name, trigger_name
|
||||
FROM information_schema.triggers
|
||||
WHERE trigger_name IN ('trg_enforce_version', 'trg_enforce_version_lock')
|
||||
LOOP
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I', rec.trigger_name, rec.table_name);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 2. 删除触发器函数
|
||||
db.execute_unprepared("DROP FUNCTION IF EXISTS enforce_version() CASCADE")
|
||||
.await?;
|
||||
db.execute_unprepared("DROP FUNCTION IF EXISTS enforce_version_lock() CASCADE")
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! 修复 admin 角色权限绑定
|
||||
//!
|
||||
//! 根因链:
|
||||
//! 1. m20260506_000126 对部分角色执行了软删除(SET deleted_at = NOW())
|
||||
//! 2. m20260508_000131 执行 `DELETE FROM role_permissions WHERE deleted_at IS NOT NULL`
|
||||
//! 物理删除了所有被软删除的记录
|
||||
//! 3. m20260508_000131 只重新分配了 doctor/nurse/operator 的权限,遗漏了 admin 角色
|
||||
//! 4. 后续的 assign_permissions API 调用可能在内部先软删除再 INSERT,
|
||||
//! INSERT 失败时 admin 权限全部丢失
|
||||
//!
|
||||
//! 本迁移:
|
||||
//! - Step 1: 恢复所有被软删除的 admin role_permissions(deleted_at IS NOT NULL → NULL)
|
||||
//! - Step 2: 插入所有缺失的 admin role_permissions(ON CONFLICT DO NOTHING 保证幂等)
|
||||
//!
|
||||
//! 覆盖范围:全系统 128 个权限码(auth/config/workflow/message/plugin/health/ai/copilot/points)
|
||||
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
// ================================================================
|
||||
// Step 1: 恢复被软删除的 admin role_permissions
|
||||
// ================================================================
|
||||
// 如果 admin 的某些权限记录仍然存在但被软删除了,恢复它们
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
UPDATE role_permissions rp
|
||||
SET deleted_at = NULL, updated_at = NOW(), version = rp.version + 1
|
||||
FROM roles r
|
||||
WHERE rp.role_id = r.id
|
||||
AND r.code = 'admin'
|
||||
AND r.deleted_at IS NULL
|
||||
AND rp.deleted_at IS NOT NULL
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ================================================================
|
||||
// Step 2: 插入缺失的 admin role_permissions
|
||||
// ================================================================
|
||||
// 将 permissions 表中所有未被软删除的权限绑定到 admin 角色
|
||||
// ON CONFLICT (role_id, permission_id) DO NOTHING — 已存在(含刚恢复的)的跳过
|
||||
db.execute_unprepared(
|
||||
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, r.tenant_id, 'all',
|
||||
NOW(), NOW(), r.id, r.id,
|
||||
NULL, 1
|
||||
FROM roles r
|
||||
JOIN permissions p ON p.tenant_id = r.tenant_id AND p.deleted_at IS NULL
|
||||
WHERE r.code = 'admin' AND r.deleted_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp
|
||||
WHERE rp.role_id = r.id
|
||||
AND rp.permission_id = p.id
|
||||
AND rp.deleted_at IS NULL
|
||||
)
|
||||
ON CONFLICT (role_id, permission_id) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 不回滚 — 这是修复性迁移,admin 应该始终拥有全部权限
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let conn = manager.get_connection();
|
||||
|
||||
// 为 m000088 之后创建的新表补充 RLS 策略。
|
||||
// 幂等操作:仅影响尚未启用 RLS 或缺少策略的表。
|
||||
conn.execute_unprepared(
|
||||
r#"
|
||||
DO $$
|
||||
DECLARE
|
||||
tbl TEXT;
|
||||
policy_exists BOOLEAN;
|
||||
BEGIN
|
||||
FOR tbl IN
|
||||
SELECT c.table_name FROM information_schema.columns c
|
||||
JOIN information_schema.tables t
|
||||
ON c.table_name = t.table_name AND c.table_schema = t.table_schema
|
||||
WHERE c.column_name = 'tenant_id'
|
||||
AND c.table_schema = 'public'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
ORDER BY c.table_name
|
||||
LOOP
|
||||
-- 启用 RLS(幂等)
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
|
||||
|
||||
-- 检查是否已有 tenant_isolation 策略
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE tablename = tbl
|
||||
AND policyname = 'tenant_isolation'
|
||||
) INTO policy_exists;
|
||||
|
||||
IF NOT policy_exists THEN
|
||||
EXECUTE format(
|
||||
'CREATE POLICY tenant_isolation ON %I USING (
|
||||
current_setting(''app.current_tenant_id'', true) != ''''
|
||||
AND tenant_id = current_setting(''app.current_tenant_id'', true)::uuid
|
||||
)',
|
||||
tbl
|
||||
);
|
||||
RAISE NOTICE 'Created RLS policy for table: %', tbl;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 回滚不需要移除 RLS,保持 m000088 的策略不变
|
||||
// 此迁移补充的 RLS 策略在 down() 中保留,因为 m000088 已处理回滚
|
||||
let _ = manager;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 先删索引,再删表
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.name("idx_wechat_users_openid")
|
||||
.table(Alias::new("wechat_users"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_table(Table::drop().table(Alias::new("wechat_users")).to_owned())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 回滚:重建 wechat_users 表(完整定义,与 m20260423_000043 一致)
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("wechat_users"))
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("openid")).string().not_null())
|
||||
.col(ColumnDef::new(Alias::new("union_id")).string())
|
||||
.col(ColumnDef::new(Alias::new("user_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("phone")).string())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("updated_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("created_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("version"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_wechat_users_openid")
|
||||
.table(Alias::new("wechat_users"))
|
||||
.col(Alias::new("openid"))
|
||||
.col(Alias::new("tenant_id"))
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
147
crates/erp-server/src/config.rs
Normal file
147
crates/erp-server/src/config.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub server: ServerConfig,
|
||||
pub database: DatabaseConfig,
|
||||
pub redis: RedisConfig,
|
||||
pub jwt: JwtConfig,
|
||||
pub auth: AuthConfig,
|
||||
pub log: LogConfig,
|
||||
pub cors: CorsConfig,
|
||||
pub crypto: CryptoConfig,
|
||||
pub storage: StorageConfig,
|
||||
#[serde(default)]
|
||||
pub rate_limit: RateLimitConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
#[serde(default = "default_metrics_port")]
|
||||
pub metrics_port: u16,
|
||||
}
|
||||
|
||||
fn default_metrics_port() -> u16 {
|
||||
9090
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DatabaseConfig {
|
||||
pub url: String,
|
||||
pub max_connections: u32,
|
||||
pub min_connections: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RedisConfig {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct JwtConfig {
|
||||
pub secret: String,
|
||||
pub access_token_ttl: String,
|
||||
pub refresh_token_ttl: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LogConfig {
|
||||
pub level: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AuthConfig {
|
||||
pub super_admin_password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CorsConfig {
|
||||
/// Comma-separated list of allowed origins.
|
||||
/// Use "*" to allow all origins (development only).
|
||||
pub allowed_origins: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CryptoConfig {
|
||||
/// Master KEK (64 字符 hex 编码,32 字节)。用于加密保护每租户 DEK。
|
||||
/// Phase A 阶段同时作为全局数据加密密钥使用。
|
||||
pub kek: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct StorageConfig {
|
||||
/// 文件上传目录(本地存储)
|
||||
pub upload_dir: String,
|
||||
/// 单文件最大大小(如 "10MB")
|
||||
pub max_file_size: String,
|
||||
/// 签名 URL 密钥(HMAC-SHA256)
|
||||
#[serde(default = "default_secret_key")]
|
||||
pub secret_key: String,
|
||||
}
|
||||
|
||||
fn default_secret_key() -> String {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
"dev-only-secret-key-change-in-production".to_string()
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
panic!("ERP__STORAGE__SECRET_KEY 必须设置(生产环境不允许使用默认签名密钥)")
|
||||
}
|
||||
}
|
||||
|
||||
impl StorageConfig {
|
||||
/// 解析 max_file_size 为字节数
|
||||
pub fn max_file_size_bytes(&self) -> u64 {
|
||||
let s = self.max_file_size.to_uppercase();
|
||||
if let Some(num) = s.strip_suffix("MB") {
|
||||
num.trim().parse::<u64>().unwrap_or(10) * 1024 * 1024
|
||||
} else if let Some(num) = s.strip_suffix("KB") {
|
||||
num.trim().parse::<u64>().unwrap_or(1024) * 1024
|
||||
} else if let Some(num) = s.strip_suffix("GB") {
|
||||
num.trim().parse::<u64>().unwrap_or(1) * 1024 * 1024 * 1024
|
||||
} else {
|
||||
s.parse::<u64>().unwrap_or(10 * 1024 * 1024)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RateLimitConfig {
|
||||
/// Redis 不可达时是否拒绝请求(fail-close)。
|
||||
/// true = 安全优先,Redis 故障时返回 503。
|
||||
/// false = 可用性优先,Redis 故障时放行。
|
||||
#[serde(default = "default_fail_close")]
|
||||
pub fail_close: bool,
|
||||
}
|
||||
|
||||
fn default_fail_close() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for RateLimitConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fail_close: default_fail_close(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn load() -> anyhow::Result<Self> {
|
||||
let config = config::Config::builder()
|
||||
.add_source(config::File::with_name("config/default"))
|
||||
.add_source(config::Environment::with_prefix("ERP").separator("__"))
|
||||
.build()?;
|
||||
let app_config: Self = config.try_deserialize()?;
|
||||
|
||||
// 安全检查:禁止在生产使用默认 JWT 密钥
|
||||
if app_config.jwt.secret == "change-me-in-production" {
|
||||
tracing::warn!("⚠️ JWT 密钥使用默认值,请通过 ERP__JWT__SECRET 环境变量设置安全密钥");
|
||||
}
|
||||
|
||||
Ok(app_config)
|
||||
}
|
||||
}
|
||||
16
crates/erp-server/src/db.rs
Normal file
16
crates/erp-server/src/db.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::DatabaseConfig;
|
||||
|
||||
pub async fn connect(config: &DatabaseConfig) -> anyhow::Result<DatabaseConnection> {
|
||||
let mut opt = ConnectOptions::new(&config.url);
|
||||
opt.max_connections(config.max_connections)
|
||||
.min_connections(config.min_connections)
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.idle_timeout(Duration::from_secs(600));
|
||||
|
||||
let db = Database::connect(opt).await?;
|
||||
tracing::info!("Database connected successfully");
|
||||
Ok(db)
|
||||
}
|
||||
156
crates/erp-server/src/handlers/audit_log.rs
Normal file
156
crates/erp-server/src/handlers/audit_log.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
use axum::Router;
|
||||
use axum::extract::{Extension, FromRef, Query, State};
|
||||
use axum::response::Json;
|
||||
use axum::routing::get;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use erp_core::entity::audit_log;
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuditLogQuery {
|
||||
pub resource_type: Option<String>,
|
||||
pub user_id: Option<uuid::Uuid>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuditLogResp {
|
||||
pub id: uuid::Uuid,
|
||||
pub tenant_id: uuid::Uuid,
|
||||
pub user_id: Option<uuid::Uuid>,
|
||||
pub user_name: Option<String>,
|
||||
pub action: String,
|
||||
pub resource_type: String,
|
||||
pub resource_id: Option<uuid::Uuid>,
|
||||
pub old_value: Option<serde_json::Value>,
|
||||
pub new_value: Option<serde_json::Value>,
|
||||
pub ip_address: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<audit_log::Model> for AuditLogResp {
|
||||
fn from(m: audit_log::Model) -> Self {
|
||||
Self {
|
||||
id: m.id,
|
||||
tenant_id: m.tenant_id,
|
||||
user_id: m.user_id,
|
||||
user_name: None,
|
||||
action: m.action,
|
||||
resource_type: m.resource_type,
|
||||
resource_id: m.resource_id,
|
||||
old_value: m.old_value,
|
||||
new_value: m.new_value,
|
||||
ip_address: m.ip_address,
|
||||
user_agent: m.user_agent,
|
||||
created_at: m.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_user_names(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
items: &[audit_log::Model],
|
||||
) -> std::collections::HashMap<uuid::Uuid, String> {
|
||||
use erp_auth::entity::user;
|
||||
|
||||
let user_ids: Vec<uuid::Uuid> = items
|
||||
.iter()
|
||||
.filter_map(|i| i.user_id)
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
if user_ids.is_empty() {
|
||||
return std::collections::HashMap::new();
|
||||
}
|
||||
|
||||
let users = user::Entity::find()
|
||||
.filter(user::Column::Id.is_in(user_ids))
|
||||
.all(db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
users
|
||||
.into_iter()
|
||||
.map(|u| {
|
||||
let name = u
|
||||
.display_name
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or(u.username);
|
||||
(u.id, name)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// GET /audit-logs
|
||||
pub async fn list_audit_logs<S>(
|
||||
State(db): State<sea_orm::DatabaseConnection>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<AuditLogQuery>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<AuditLogResp>>>, AppError>
|
||||
where
|
||||
sea_orm::DatabaseConnection: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let page = params.page.unwrap_or(1).max(1);
|
||||
let page_size = params.page_size.unwrap_or(20).min(100);
|
||||
let tenant_id = ctx.tenant_id;
|
||||
|
||||
let mut q = audit_log::Entity::find().filter(audit_log::Column::TenantId.eq(tenant_id));
|
||||
|
||||
if let Some(rt) = ¶ms.resource_type {
|
||||
q = q.filter(audit_log::Column::ResourceType.eq(rt.clone()));
|
||||
}
|
||||
if let Some(uid) = ¶ms.user_id {
|
||||
q = q.filter(audit_log::Column::UserId.eq(*uid));
|
||||
}
|
||||
|
||||
let paginator = q
|
||||
.order_by_desc(audit_log::Column::CreatedAt)
|
||||
.paginate(&db, page_size);
|
||||
|
||||
let total = paginator
|
||||
.num_items()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("查询审计日志失败: {e}")))?;
|
||||
|
||||
let items = paginator
|
||||
.fetch_page(page - 1)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("查询审计日志失败: {e}")))?;
|
||||
|
||||
let user_map = resolve_user_names(&db, &items).await;
|
||||
|
||||
let resp_items: Vec<AuditLogResp> = items
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
let user_name = m.user_id.and_then(|uid| user_map.get(&uid).cloned());
|
||||
let mut resp = AuditLogResp::from(m);
|
||||
resp.user_name = user_name;
|
||||
resp
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total_pages = total.div_ceil(page_size);
|
||||
|
||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: resp_items,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages,
|
||||
})))
|
||||
}
|
||||
|
||||
pub fn audit_log_router<S>() -> Router<S>
|
||||
where
|
||||
sea_orm::DatabaseConnection: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new().route("/audit-logs", get(list_audit_logs))
|
||||
}
|
||||
76
crates/erp-server/src/handlers/crypto_admin.rs
Normal file
76
crates/erp-server/src/handlers/crypto_admin.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use axum::Extension;
|
||||
use axum::Json;
|
||||
use axum::extract::{FromRef, Path, State};
|
||||
use sea_orm::{ConnectionTrait, DatabaseBackend, Statement};
|
||||
use serde_json::{Value, json};
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// POST /api/v1/admin/tenants/:id/rotate-key
|
||||
/// 密钥轮换 — 生成新 DEK,持久化到 tenant_crypto_keys,使缓存失效
|
||||
pub async fn rotate_tenant_key<S>(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(tenant_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<Value>>, AppError>
|
||||
where
|
||||
AppState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "tenant.manage")?;
|
||||
|
||||
// 读取当前最大版本号
|
||||
let max_version: Option<i32> = {
|
||||
let row = state.db.query_one(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
"SELECT COALESCE(MAX(key_version), 0) as v FROM tenant_crypto_keys WHERE tenant_id = $1 AND deleted_at IS NULL",
|
||||
[tenant_id.into()],
|
||||
)).await.map_err(|e| AppError::Internal(format!("查询密钥版本失败: {}", e)))?;
|
||||
row.and_then(|r| r.try_get_by_index::<i32>(0).ok())
|
||||
};
|
||||
let current_version = max_version.unwrap_or(0);
|
||||
let new_version = current_version + 1;
|
||||
|
||||
// 将旧版本标记为不活跃
|
||||
state.db.execute(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
"UPDATE tenant_crypto_keys SET is_active = false, updated_at = now() WHERE tenant_id = $1 AND is_active = true AND deleted_at IS NULL",
|
||||
[tenant_id.into()],
|
||||
)).await.map_err(|e| AppError::Internal(format!("停用旧 DEK 失败: {}", e)))?;
|
||||
|
||||
// 生成新 DEK 并用 KEK 加密
|
||||
let kek = state.pii_crypto.kek();
|
||||
let (_new_dek, encrypted_dek) = erp_core::crypto::DekManager::generate_new_dek(kek)
|
||||
.map_err(|e| AppError::Internal(format!("生成新 DEK 失败: {}", e)))?;
|
||||
|
||||
// 持久化新 DEK
|
||||
let new_id = Uuid::now_v7();
|
||||
state.db.execute(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
"INSERT INTO tenant_crypto_keys (id, tenant_id, encrypted_dek, key_version, is_active, created_at, updated_at, version) VALUES ($1, $2, $3, $4, true, now(), now(), 1)",
|
||||
[new_id.into(), tenant_id.into(), encrypted_dek.into(), new_version.into()],
|
||||
)).await.map_err(|e| AppError::Internal(format!("存储新 DEK 失败: {}", e)))?;
|
||||
|
||||
// 使 DEK 缓存失效
|
||||
state.pii_crypto.invalidate_dek(tenant_id);
|
||||
|
||||
tracing::info!(
|
||||
tenant_id = %tenant_id,
|
||||
old_version = current_version,
|
||||
new_version = new_version,
|
||||
"密钥轮换完成(新 DEK 已持久化,缓存已清除)"
|
||||
);
|
||||
|
||||
Ok(Json(ApiResponse::ok(json!({
|
||||
"message": "密钥轮换已完成",
|
||||
"tenant_id": tenant_id,
|
||||
"old_version": current_version,
|
||||
"new_version": new_version,
|
||||
"note": "后台重加密任务需要单独触发,旧数据仍可用旧 DEK 解密"
|
||||
}))))
|
||||
}
|
||||
135
crates/erp-server/src/handlers/health.rs
Normal file
135
crates/erp-server/src/handlers/health.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
use axum::Router;
|
||||
use axum::extract::State;
|
||||
use axum::response::Json;
|
||||
use axum::routing::get;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HealthResponse {
|
||||
pub status: String,
|
||||
pub version: String,
|
||||
pub modules: Vec<String>,
|
||||
}
|
||||
|
||||
/// GET /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,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReadyResponse {
|
||||
pub status: String,
|
||||
pub version: String,
|
||||
pub database: ComponentStatus,
|
||||
pub redis: ComponentStatus,
|
||||
pub modules: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ComponentStatus {
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub latency_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /health/ready — 就绪检查(含 DB + Redis 连通性)
|
||||
pub async fn readiness_check(State(state): State<AppState>) -> Json<ReadyResponse> {
|
||||
let modules = state
|
||||
.module_registry
|
||||
.modules()
|
||||
.iter()
|
||||
.map(|m| m.name().to_string())
|
||||
.collect();
|
||||
|
||||
let (db_status, redis_status) =
|
||||
tokio::join!(check_database(&state.db), check_redis(&state.redis),);
|
||||
|
||||
let overall = if db_status.status == "ok" && redis_status.status == "ok" {
|
||||
"ok"
|
||||
} else if db_status.status == "ok" {
|
||||
"degraded"
|
||||
} else {
|
||||
"unavailable"
|
||||
};
|
||||
|
||||
Json(ReadyResponse {
|
||||
status: overall.to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
database: db_status,
|
||||
redis: redis_status,
|
||||
modules,
|
||||
})
|
||||
}
|
||||
|
||||
async fn check_database(db: &sea_orm::DatabaseConnection) -> ComponentStatus {
|
||||
use sea_orm::ConnectionTrait;
|
||||
let start = std::time::Instant::now();
|
||||
let stmt =
|
||||
sea_orm::Statement::from_string(sea_orm::DatabaseBackend::Postgres, "SELECT 1".to_string());
|
||||
match db.query_one(stmt).await {
|
||||
Ok(_) => ComponentStatus {
|
||||
status: "ok".to_string(),
|
||||
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Database health check failed");
|
||||
ComponentStatus {
|
||||
status: "error".to_string(),
|
||||
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||
error: Some("connection failed".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_redis(client: &redis::Client) -> ComponentStatus {
|
||||
let start = std::time::Instant::now();
|
||||
match client.get_multiplexed_async_connection().await {
|
||||
Ok(mut conn) => match redis::cmd("PING").query_async::<String>(&mut conn).await {
|
||||
Ok(_) => ComponentStatus {
|
||||
status: "ok".to_string(),
|
||||
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Redis PING failed");
|
||||
ComponentStatus {
|
||||
status: "error".to_string(),
|
||||
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||
error: Some("connection failed".to_string()),
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Redis connection failed");
|
||||
ComponentStatus {
|
||||
status: "error".to_string(),
|
||||
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||
error: Some("connection failed".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn health_check_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/health", get(health_check))
|
||||
.route("/health/live", get(health_check))
|
||||
.route("/health/ready", get(readiness_check))
|
||||
}
|
||||
5
crates/erp-server/src/handlers/mod.rs
Normal file
5
crates/erp-server/src/handlers/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod audit_log;
|
||||
pub mod crypto_admin;
|
||||
pub mod health;
|
||||
pub mod openapi;
|
||||
pub mod upload;
|
||||
25
crates/erp-server/src/handlers/openapi.rs
Normal file
25
crates/erp-server/src/handlers/openapi.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use crate::{ApiDoc, AuthApiDoc, ConfigApiDoc, MessageApiDoc, WorkflowApiDoc};
|
||||
|
||||
/// GET /docs/openapi.json
|
||||
///
|
||||
/// 返回 OpenAPI 3.0 规范 JSON 文档,合并所有模块的路径和 schema。
|
||||
/// 仅在 debug 模式下可用,生产构建返回 404。
|
||||
pub async fn openapi_spec() -> Response {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let mut spec = ApiDoc::openapi();
|
||||
spec.merge(AuthApiDoc::openapi());
|
||||
spec.merge(ConfigApiDoc::openapi());
|
||||
spec.merge(WorkflowApiDoc::openapi());
|
||||
spec.merge(MessageApiDoc::openapi());
|
||||
Json(serde_json::to_value(spec).unwrap_or_default()).into_response()
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
(axum::http::StatusCode::NOT_FOUND, "Not Found").into_response()
|
||||
}
|
||||
}
|
||||
220
crates/erp-server/src/handlers/upload.rs
Normal file
220
crates/erp-server/src/handlers/upload.rs
Normal file
@@ -0,0 +1,220 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Multipart, State};
|
||||
use axum::response::Json;
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct UploadResp {
|
||||
pub url: String,
|
||||
pub filename: String,
|
||||
pub size: u64,
|
||||
pub content_type: String,
|
||||
}
|
||||
|
||||
/// 上传单个文件。
|
||||
///
|
||||
/// 接受 multipart/form-data,将文件保存到本地目录,
|
||||
/// 返回可通过 `/uploads/` 前缀访问的 URL。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/upload",
|
||||
request_body(content_type = "multipart/form-data"),
|
||||
responses(
|
||||
(status = 200, description = "上传成功", body = ApiResponse<UploadResp>),
|
||||
(status = 413, description = "文件过大"),
|
||||
(status = 400, description = "无文件或不支持的类型"),
|
||||
),
|
||||
tag = "文件上传",
|
||||
)]
|
||||
pub async fn upload_file<S>(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<ApiResponse<UploadResp>>, AppError>
|
||||
where
|
||||
AppState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let max_size = state.config.storage.max_file_size_bytes();
|
||||
let upload_dir = &state.config.storage.upload_dir;
|
||||
|
||||
// 确保上传目录存在
|
||||
let base_dir = std::path::Path::new(upload_dir);
|
||||
let tenant_dir = base_dir.join(ctx.tenant_id.to_string());
|
||||
tokio::fs::create_dir_all(&tenant_dir)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("创建上传目录失败: {}", e)))?;
|
||||
|
||||
// 读取第一个 field 作为上传文件
|
||||
let field = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| AppError::Validation(format!("读取上传数据失败: {}", e)))?
|
||||
.ok_or_else(|| AppError::Validation("未找到上传文件".to_string()))?;
|
||||
|
||||
let content_type = field
|
||||
.content_type()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
|
||||
// 验证文件类型
|
||||
validate_content_type(&content_type)?;
|
||||
|
||||
let original_name = field.name().unwrap_or("file").to_string();
|
||||
|
||||
let data = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::Validation(format!("读取文件数据失败: {}", e)))?;
|
||||
|
||||
if data.len() as u64 > max_size {
|
||||
return Err(AppError::Validation(format!(
|
||||
"文件大小超过限制(最大 {})",
|
||||
format_size(max_size)
|
||||
)));
|
||||
}
|
||||
|
||||
// 校验 magic bytes:验证文件实际内容与声明的 Content-Type 一致
|
||||
validate_magic_bytes(&content_type, &data)?;
|
||||
|
||||
// 生成唯一文件名,保留原始扩展名
|
||||
let ext = std::path::Path::new(&original_name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("bin");
|
||||
let file_id = Uuid::now_v7();
|
||||
let filename = format!("{}.{}", file_id, ext);
|
||||
|
||||
let file_path = tenant_dir.join(&filename);
|
||||
let data_vec: Vec<u8> = data.to_vec();
|
||||
tokio::fs::write(&file_path, &data_vec)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("写入文件失败: {}", e)))?;
|
||||
|
||||
let url = format!("/uploads/{}/{}", ctx.tenant_id, filename);
|
||||
|
||||
tracing::info!(
|
||||
tenant_id = %ctx.tenant_id,
|
||||
filename = %filename,
|
||||
size = data_vec.len(),
|
||||
content_type = %content_type,
|
||||
"文件上传成功"
|
||||
);
|
||||
|
||||
Ok(Json(ApiResponse::ok(UploadResp {
|
||||
url,
|
||||
filename: original_name,
|
||||
size: data_vec.len() as u64,
|
||||
content_type,
|
||||
})))
|
||||
}
|
||||
|
||||
/// 允许的文件类型
|
||||
fn validate_content_type(content_type: &str) -> Result<(), AppError> {
|
||||
const ALLOWED: &[&str] = &[
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
];
|
||||
|
||||
if !ALLOWED.contains(&content_type) {
|
||||
return Err(AppError::Validation(format!(
|
||||
"不支持的文件类型: {}",
|
||||
content_type
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 校验文件 magic bytes(文件签名)与声明的 Content-Type 是否一致。
|
||||
///
|
||||
/// 防止攻击者通过修改 Content-Type 头上传恶意文件。
|
||||
/// 对于 Office 格式等复杂签名,跳过 magic bytes 校验(仅依赖白名单)。
|
||||
fn validate_magic_bytes(content_type: &str, data: &[u8]) -> Result<(), AppError> {
|
||||
// 需要至少几个字节才能校验
|
||||
if data.is_empty() {
|
||||
return Err(AppError::Validation("文件内容为空".to_string()));
|
||||
}
|
||||
|
||||
let signature: &[u8] = match content_type {
|
||||
"image/jpeg" => {
|
||||
// JPEG: FF D8 FF
|
||||
b"\xFF\xD8\xFF"
|
||||
}
|
||||
"image/png" => {
|
||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
b"\x89PNG\r\n\x1A\n"
|
||||
}
|
||||
"image/gif" => {
|
||||
// GIF: 47 49 46 38 (GIF8)
|
||||
b"GIF8"
|
||||
}
|
||||
"image/webp" => {
|
||||
// WebP: RIFF....WEBP (12 bytes)
|
||||
// 前 4 字节: 52 49 46 46 (RIFF)
|
||||
// 字节 8-11: 57 45 42 50 (WEBP)
|
||||
if data.len() < 12 {
|
||||
return Err(AppError::Validation(
|
||||
"文件数据不足,无法验证 WebP 格式".to_string(),
|
||||
));
|
||||
}
|
||||
let riff_ok = &data[0..4] == b"RIFF";
|
||||
let webp_ok = &data[8..12] == b"WEBP";
|
||||
if riff_ok && webp_ok {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(AppError::Validation(
|
||||
"文件内容与声明的类型 (image/webp) 不匹配".to_string(),
|
||||
));
|
||||
}
|
||||
"application/pdf" => {
|
||||
// PDF: 25 50 44 46 (%PDF)
|
||||
b"%PDF"
|
||||
}
|
||||
// Office 格式的 magic bytes 较复杂(OLE2 / ZIP-based OOXML),
|
||||
// 仅依赖白名单,跳过 magic bytes 校验
|
||||
"application/msword"
|
||||
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
| "application/vnd.ms-excel"
|
||||
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => {
|
||||
return Ok(());
|
||||
}
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
if data.len() < signature.len() {
|
||||
return Err(AppError::Validation(
|
||||
"文件数据不足,无法验证文件格式".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if &data[..signature.len()] != signature {
|
||||
return Err(AppError::Validation(format!(
|
||||
"文件内容与声明的类型 ({}) 不匹配",
|
||||
content_type
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_size(bytes: u64) -> String {
|
||||
if bytes >= 1024 * 1024 * 1024 {
|
||||
format!("{}GB", bytes / (1024 * 1024 * 1024))
|
||||
} else if bytes >= 1024 * 1024 {
|
||||
format!("{}MB", bytes / (1024 * 1024))
|
||||
} else {
|
||||
format!("{}KB", bytes / 1024)
|
||||
}
|
||||
}
|
||||
789
crates/erp-server/src/main.rs
Normal file
789
crates/erp-server/src/main.rs
Normal file
@@ -0,0 +1,789 @@
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
// 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");
|
||||
|
||||
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 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"
|
||||
);
|
||||
|
||||
// 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())
|
||||
.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),
|
||||
)
|
||||
.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(())
|
||||
}
|
||||
37
crates/erp-server/src/middleware/frozen_module.rs
Normal file
37
crates/erp-server/src/middleware/frozen_module.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use axum::Json;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
/// 冻结模块路径前缀列表。
|
||||
///
|
||||
/// 这些模块前端已通过 FROZEN_ROUTES 守卫拦截,后端也需同步拦截,
|
||||
/// 防止直接调 API 绕过限制。
|
||||
const FROZEN_PREFIXES: &[&str] = &[
|
||||
"/api/v1/health/care-plans",
|
||||
"/api/v1/health/shifts",
|
||||
"/api/v1/health/family-proxy",
|
||||
"/api/v1/health/medications",
|
||||
"/api/v1/health/dialysis",
|
||||
"/api/v1/health/schedules",
|
||||
];
|
||||
|
||||
pub async fn frozen_module_middleware(req: Request<Body>, next: Next) -> Response {
|
||||
let path = req.uri().path();
|
||||
|
||||
for prefix in FROZEN_PREFIXES {
|
||||
if path.starts_with(prefix) {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error": "该功能正在优化中,暂不可用"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
next.run(req).await
|
||||
}
|
||||
126
crates/erp-server/src/middleware/metrics.rs
Normal file
126
crates/erp-server/src/middleware/metrics.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use axum::extract::Request;
|
||||
use axum::http::Method;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use metrics::{counter, histogram};
|
||||
use std::time::Instant;
|
||||
|
||||
/// HTTP 请求指标中间件。
|
||||
///
|
||||
/// 记录两个 Prometheus 指标:
|
||||
/// - `http_requests_total` — 计数器,标签: method, path, status
|
||||
/// - `http_request_duration_seconds` — 直方图,标签: method, path, status
|
||||
pub async fn metrics_middleware(req: Request, next: Next) -> Response {
|
||||
let method = method_label(req.method());
|
||||
let path = path_label(req.uri().path());
|
||||
|
||||
let start = Instant::now();
|
||||
let resp = next.run(req).await;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
let status = resp.status().as_u16().to_string();
|
||||
|
||||
let labels = [
|
||||
("method", method.clone()),
|
||||
("path", path.clone()),
|
||||
("status", status.clone()),
|
||||
];
|
||||
|
||||
counter!("http_requests_total", &labels).increment(1);
|
||||
histogram!("http_request_duration_seconds", &labels).record(elapsed.as_secs_f64());
|
||||
|
||||
resp
|
||||
}
|
||||
|
||||
fn method_label(method: &Method) -> String {
|
||||
method.as_str().to_owned()
|
||||
}
|
||||
|
||||
/// 归一化路径:将 UUID 段替换为 `:id`,避免高基数。
|
||||
fn path_label(path: &str) -> String {
|
||||
let parts: Vec<&str> = path
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| if looks_like_uuid(s) { ":id" } else { s })
|
||||
.collect();
|
||||
if parts.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("/{}", parts.join("/"))
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_uuid(s: &str) -> bool {
|
||||
s.len() == 36
|
||||
&& s.chars().filter(|c| *c == '-').count() == 4
|
||||
&& s.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
|
||||
}
|
||||
|
||||
/// 在独立端口启动 Prometheus exporter。
|
||||
pub fn start_metrics_server(port: u16) {
|
||||
let builder = metrics_exporter_prometheus::PrometheusBuilder::new();
|
||||
let recorder = builder.build_recorder();
|
||||
let handle = recorder.handle();
|
||||
|
||||
if let Err(e) = metrics::set_global_recorder(recorder) {
|
||||
tracing::error!(error = %e, "Failed to install Prometheus recorder");
|
||||
return;
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let app = axum::Router::new()
|
||||
.route(
|
||||
"/metrics",
|
||||
axum::routing::get(move || {
|
||||
let handle = handle.clone();
|
||||
async move {
|
||||
let body = handle.render();
|
||||
axum::response::IntoResponse::into_response((
|
||||
[(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
"text/plain; version=0.0.4",
|
||||
)],
|
||||
body,
|
||||
))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.fallback(|| async { axum::http::StatusCode::NOT_FOUND.into_response() as Response });
|
||||
|
||||
let addr = format!("0.0.0.0:{port}");
|
||||
match tokio::net::TcpListener::bind(&addr).await {
|
||||
Ok(listener) => {
|
||||
tracing::info!(addr = %addr, "Prometheus metrics server listening");
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
tracing::error!(error = %e, "Metrics server error");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, addr = %addr, "Failed to bind metrics server");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn path_label_normalizes_uuids() {
|
||||
assert_eq!(path_label("/api/v1/users"), "/api/v1/users");
|
||||
assert_eq!(
|
||||
path_label("/api/v1/users/01234567-89ab-cdef-0123-456789abcdef/posts"),
|
||||
"/api/v1/users/:id/posts"
|
||||
);
|
||||
assert_eq!(path_label("/"), "/");
|
||||
assert_eq!(path_label(""), "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_uuid_checks_format() {
|
||||
assert!(looks_like_uuid("01234567-89ab-cdef-0123-456789abcdef"));
|
||||
assert!(!looks_like_uuid("not-a-uuid"));
|
||||
assert!(!looks_like_uuid("short"));
|
||||
}
|
||||
}
|
||||
4
crates/erp-server/src/middleware/mod.rs
Normal file
4
crates/erp-server/src/middleware/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod frozen_module;
|
||||
pub mod metrics;
|
||||
pub mod rate_limit;
|
||||
pub mod tenant_rls;
|
||||
326
crates/erp-server/src/middleware/rate_limit.rs
Normal file
326
crates/erp-server/src/middleware/rate_limit.rs
Normal file
@@ -0,0 +1,326 @@
|
||||
use axum::body::Body;
|
||||
use axum::extract::State;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use redis::AsyncCommands;
|
||||
use serde::Serialize;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Redis 连接失败时间戳缓存(毫秒),5 秒内复用失败状态避免重复连接尝试
|
||||
static REDIS_LAST_FAIL_MS: AtomicU64 = AtomicU64::new(0);
|
||||
const REDIS_FAIL_CACHE_SECS: u64 = 5;
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
fn is_redis_cached_failed() -> bool {
|
||||
let last = REDIS_LAST_FAIL_MS.load(Ordering::Relaxed);
|
||||
last > 0 && now_ms().saturating_sub(last) < REDIS_FAIL_CACHE_SECS * 1000
|
||||
}
|
||||
|
||||
fn mark_redis_failed() {
|
||||
REDIS_LAST_FAIL_MS.store(now_ms(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// 限流错误响应。
|
||||
#[derive(Serialize)]
|
||||
struct RateLimitResponse {
|
||||
error: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// 账户锁定配置。
|
||||
const ACCOUNT_LOCKOUT_MAX_FAILURES: i64 = 5;
|
||||
const ACCOUNT_LOCKOUT_TTL_SECS: i64 = 900; // 15 分钟
|
||||
|
||||
/// 基于 Redis 的 IP 限流中间件(登录等敏感操作,5 次/分钟)。
|
||||
pub async fn rate_limit_by_ip(
|
||||
State(state): State<AppState>,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let identifier = extract_client_ip(req.headers());
|
||||
let fail_close = state.config.rate_limit.fail_close;
|
||||
apply_rate_limit(
|
||||
RateLimitParams {
|
||||
redis_client: &state.redis,
|
||||
fail_close,
|
||||
max_requests: 5,
|
||||
window_secs: 60,
|
||||
prefix: "login",
|
||||
},
|
||||
&identifier,
|
||||
req,
|
||||
next,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 基于 Redis 的 IP 限流中间件(Token 刷新,30 次/分钟)。
|
||||
pub async fn rate_limit_refresh_by_ip(
|
||||
State(state): State<AppState>,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let identifier = extract_client_ip(req.headers());
|
||||
let fail_close = state.config.rate_limit.fail_close;
|
||||
apply_rate_limit(
|
||||
RateLimitParams {
|
||||
redis_client: &state.redis,
|
||||
fail_close,
|
||||
max_requests: 30,
|
||||
window_secs: 60,
|
||||
prefix: "refresh",
|
||||
},
|
||||
&identifier,
|
||||
req,
|
||||
next,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 基于 Redis 的用户限流中间件。
|
||||
///
|
||||
/// 从 TenantContext 中读取 user_id 作为标识符。
|
||||
pub async fn rate_limit_by_user(
|
||||
State(state): State<AppState>,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let identifier = req
|
||||
.extensions()
|
||||
.get::<erp_core::types::TenantContext>()
|
||||
.map(|ctx| ctx.user_id.to_string())
|
||||
.unwrap_or_else(|| "anonymous".to_string());
|
||||
let fail_close = state.config.rate_limit.fail_close;
|
||||
apply_rate_limit(
|
||||
RateLimitParams {
|
||||
redis_client: &state.redis,
|
||||
fail_close,
|
||||
max_requests: 300,
|
||||
window_secs: 60,
|
||||
prefix: "api",
|
||||
},
|
||||
&identifier,
|
||||
req,
|
||||
next,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Redis 不可达时的安全响应(fail-close 模式)。
|
||||
fn service_unavailable(prefix: &str) -> Response {
|
||||
let body = RateLimitResponse {
|
||||
error: "Service Unavailable".to_string(),
|
||||
message: "服务暂时不可用,请稍后重试".to_string(),
|
||||
};
|
||||
tracing::error!("Redis 不可达,fail-close 模式拒绝请求 [{}]", prefix);
|
||||
(StatusCode::SERVICE_UNAVAILABLE, axum::Json(body)).into_response()
|
||||
}
|
||||
|
||||
/// 限流参数,打包以避免函数签名过长。
|
||||
struct RateLimitParams<'a> {
|
||||
redis_client: &'a redis::Client,
|
||||
fail_close: bool,
|
||||
max_requests: u64,
|
||||
window_secs: u64,
|
||||
prefix: &'a str,
|
||||
}
|
||||
|
||||
/// 执行限流检查。
|
||||
async fn apply_rate_limit(
|
||||
params: RateLimitParams<'_>,
|
||||
identifier: &str,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// 快速路径:Redis 在缓存期内已知不可用,跳过连接尝试
|
||||
if is_redis_cached_failed() {
|
||||
if params.fail_close {
|
||||
return service_unavailable(params.prefix);
|
||||
}
|
||||
return next.run(req).await;
|
||||
}
|
||||
|
||||
let key = format!("rate_limit:{}:{}", params.prefix, identifier);
|
||||
|
||||
let mut conn = match params.redis_client.get_multiplexed_async_connection().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
mark_redis_failed();
|
||||
tracing::warn!(error = %e, "Redis 连接失败 [{}]({}秒内不再重试)", params.prefix, REDIS_FAIL_CACHE_SECS);
|
||||
if params.fail_close {
|
||||
return service_unavailable(params.prefix);
|
||||
}
|
||||
return next.run(req).await;
|
||||
}
|
||||
};
|
||||
|
||||
let count: i64 = match redis::cmd("INCR").arg(&key).query_async(&mut conn).await {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
mark_redis_failed();
|
||||
tracing::warn!(error = %e, "Redis INCR 失败 [{}]", params.prefix);
|
||||
if params.fail_close {
|
||||
return service_unavailable(params.prefix);
|
||||
}
|
||||
return next.run(req).await;
|
||||
}
|
||||
};
|
||||
|
||||
// 首次请求设置 TTL
|
||||
if count == 1 {
|
||||
let _: Result<(), _> = conn.expire(&key, params.window_secs as i64).await;
|
||||
}
|
||||
|
||||
if count > params.max_requests as i64 {
|
||||
let body = RateLimitResponse {
|
||||
error: "Too Many Requests".to_string(),
|
||||
message: "请求过于频繁,请稍后重试".to_string(),
|
||||
};
|
||||
return (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response();
|
||||
}
|
||||
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
/// 账户级登录锁定中间件。
|
||||
///
|
||||
/// 针对登录接口(POST /api/v1/auth/login),在 IP 限流之前执行:
|
||||
/// 1. 解析请求体提取 username
|
||||
/// 2. 检查 Redis 中该 username 的失败次数
|
||||
/// 3. 超过阈值(5次)则拒绝请求
|
||||
/// 4. 观察响应状态码:401 递增失败计数,200 清除计数
|
||||
pub async fn account_lockout_middleware(
|
||||
State(state): State<AppState>,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let fail_close = state.config.rate_limit.fail_close;
|
||||
|
||||
// 获取 Redis 连接
|
||||
let mut conn = match state.redis.get_multiplexed_async_connection().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
mark_redis_failed();
|
||||
tracing::warn!(error = %e, "Redis 连接失败 [login_lockout]");
|
||||
if fail_close {
|
||||
return service_unavailable("login_lockout");
|
||||
}
|
||||
return next.run(req).await;
|
||||
}
|
||||
};
|
||||
|
||||
// 读取请求体以提取 username
|
||||
let (parts, body) = req.into_parts();
|
||||
let bytes = match axum::body::to_bytes(body, 1024).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "读取登录请求体失败,放行");
|
||||
let req = Request::from_parts(parts, Body::from(Vec::new()));
|
||||
return next.run(req).await;
|
||||
}
|
||||
};
|
||||
|
||||
// 解析 username
|
||||
let username = serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||
.ok()
|
||||
.and_then(|v| v.get("username")?.as_str().map(|s| s.to_string()));
|
||||
|
||||
let username = match username {
|
||||
Some(u) if !u.is_empty() => u,
|
||||
_ => {
|
||||
let req = Request::from_parts(parts, Body::from(bytes.to_vec()));
|
||||
return next.run(req).await;
|
||||
}
|
||||
};
|
||||
|
||||
// 检查账户锁定状态
|
||||
let lockout_key = format!("login_fail:{}", username);
|
||||
let fail_count: i64 = conn.get(&lockout_key).await.unwrap_or(0);
|
||||
|
||||
if fail_count >= ACCOUNT_LOCKOUT_MAX_FAILURES {
|
||||
tracing::warn!(
|
||||
username = %username,
|
||||
fail_count = fail_count,
|
||||
"账户已被临时锁定"
|
||||
);
|
||||
let body = RateLimitResponse {
|
||||
error: "Too Many Requests".to_string(),
|
||||
message: "账户已被临时锁定,请15分钟后重试".to_string(),
|
||||
};
|
||||
return (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response();
|
||||
}
|
||||
|
||||
// 用原始 body 重建请求,转发到 handler
|
||||
let req = Request::from_parts(parts, Body::from(bytes.to_vec()));
|
||||
let response = next.run(req).await;
|
||||
|
||||
// 观察响应状态码
|
||||
let status = response.status();
|
||||
let (parts, body) = response.into_parts();
|
||||
|
||||
let body_bytes = axum::body::to_bytes(body, 1024 * 1024)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
// 登录失败:递增失败计数
|
||||
let new_count: i64 = match redis::cmd("INCR")
|
||||
.arg(&lockout_key)
|
||||
.query_async(&mut conn)
|
||||
.await
|
||||
{
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Redis INCR 失败计数失败");
|
||||
let resp = Response::from_parts(parts, Body::from(body_bytes.to_vec()));
|
||||
return resp;
|
||||
}
|
||||
};
|
||||
|
||||
// 首次失败时设置 TTL
|
||||
if new_count == 1 {
|
||||
let _: Result<(), _> = conn.expire(&lockout_key, ACCOUNT_LOCKOUT_TTL_SECS).await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
username = %username,
|
||||
fail_count = new_count,
|
||||
remaining = ACCOUNT_LOCKOUT_MAX_FAILURES - new_count,
|
||||
"登录失败,递增失败计数"
|
||||
);
|
||||
} else if status.is_success() {
|
||||
// 登录成功:清除失败计数
|
||||
let _: Result<(), _> = conn.del(&lockout_key).await;
|
||||
tracing::info!(username = %username, "登录成功,清除失败计数");
|
||||
}
|
||||
|
||||
// 重建并返回原始响应
|
||||
|
||||
Response::from_parts(parts, Body::from(body_bytes.to_vec()))
|
||||
}
|
||||
|
||||
/// 从请求头中提取客户端 IP。
|
||||
fn extract_client_ip(headers: &axum::http::HeaderMap) -> String {
|
||||
headers
|
||||
.get("x-forwarded-for")
|
||||
.or_else(|| headers.get("x-real-ip"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| {
|
||||
// x-forwarded-for 可能包含多个 IP,取第一个
|
||||
s.split(',').next().unwrap_or(s).trim().to_string()
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
// NOTE: rate_limit_by_gateway was removed during base extraction.
|
||||
// It depended on erp_health::gateway_auth::GatewayAuthContext.
|
||||
// Projects needing gateway rate limiting should add it in their own middleware.
|
||||
50
crates/erp-server/src/middleware/tenant_rls.rs
Normal file
50
crates/erp-server/src/middleware/tenant_rls.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use erp_core::types::TenantContext;
|
||||
use sea_orm::{ConnectionTrait, DatabaseBackend, Statement};
|
||||
|
||||
/// Tenant RLS 中间件。
|
||||
///
|
||||
/// 从 request extensions 中提取 `TenantContext`,在数据库连接上设置
|
||||
/// `app.current_tenant_id`,使 PostgreSQL RLS 策略自动按租户过滤。
|
||||
///
|
||||
/// 请求处理完成后自动 RESET 设置,防止连接池复用时泄漏。
|
||||
///
|
||||
/// SET 失败时仅 warn 不阻断请求(RLS 是安全网,主隔离仍在应用层)。
|
||||
pub async fn tenant_rls_middleware(
|
||||
db: sea_orm::DatabaseConnection,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let tenant_id = req
|
||||
.extensions()
|
||||
.get::<TenantContext>()
|
||||
.map(|ctx| ctx.tenant_id);
|
||||
|
||||
if let Some(tid) = tenant_id {
|
||||
// SET app.current_tenant_id — RLS 策略读取此值(参数化查询防止注入)
|
||||
if let Err(e) = db
|
||||
.execute(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
"SET app.current_tenant_id = $1",
|
||||
[tid.into()],
|
||||
))
|
||||
.await
|
||||
{
|
||||
tracing::warn!(tenant_id = %tid, error = %e, "SET app.current_tenant_id 失败(RLS 未激活)");
|
||||
}
|
||||
}
|
||||
|
||||
let response = next.run(req).await;
|
||||
|
||||
// RESET — 防止连接池复用时泄漏租户上下文
|
||||
if tenant_id.is_some()
|
||||
&& let Err(e) = db.execute_unprepared("RESET app.current_tenant_id").await
|
||||
{
|
||||
tracing::debug!(error = %e, "RESET app.current_tenant_id 失败(非致命)");
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
137
crates/erp-server/src/outbox.rs
Normal file
137
crates/erp-server/src/outbox.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
|
||||
};
|
||||
use sqlx::postgres::PgListener;
|
||||
use std::time::Duration;
|
||||
|
||||
use erp_core::entity::domain_event;
|
||||
use erp_core::events::{DomainEvent, EventBus};
|
||||
|
||||
const MAX_RETRY: i32 = 5;
|
||||
const FALLBACK_POLL_INTERVAL_SECS: u64 = 30;
|
||||
const NOTIFY_CHANNEL: &str = "outbox_channel";
|
||||
const RECONNECT_DELAY_SECS: u64 = 5;
|
||||
|
||||
/// 启动 outbox relay 后台任务。
|
||||
///
|
||||
/// 先执行一次性扫描(处理服务重启前遗留的 pending 事件),
|
||||
/// 然后通过 PostgreSQL LISTEN/NOTIFY 监听新事件,配合 30s 兜底轮询。
|
||||
pub fn start_outbox_relay(
|
||||
db: sea_orm::DatabaseConnection,
|
||||
event_bus: EventBus,
|
||||
database_url: String,
|
||||
) {
|
||||
let db_clone = db.clone();
|
||||
let event_bus_clone = event_bus.clone();
|
||||
let url = database_url.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// 启动时立即处理一次(恢复重启前未广播的事件)
|
||||
match process_pending_events(&db_clone, &event_bus_clone).await {
|
||||
Ok(count) if count > 0 => tracing::info!(count = count, "启动时 outbox relay 恢复完成"),
|
||||
Ok(_) => tracing::info!("启动时 outbox relay 无待处理事件"),
|
||||
Err(e) => tracing::warn!(error = %e, "启动时 outbox relay 处理失败"),
|
||||
}
|
||||
|
||||
// 进入 LISTEN/NOTIFY 主循环(带自动重连)
|
||||
loop {
|
||||
if let Err(e) = run_listener(&db_clone, &event_bus_clone, &url).await {
|
||||
tracing::warn!(error = %e, "PgListener 断开连接,{}s 后重连", RECONNECT_DELAY_SECS);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(RECONNECT_DELAY_SECS)).await;
|
||||
|
||||
// 重连后执行一次兜底扫描
|
||||
if let Err(e) = process_pending_events(&db_clone, &event_bus_clone).await {
|
||||
tracing::warn!(error = %e, "重连后 outbox relay 处理失败");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 运行 PgListener 监听循环。
|
||||
///
|
||||
/// 使用 `tokio::select!` 在 LISTEN 通知和 30s 定时器之间竞争,
|
||||
/// 确保即使 NOTIFY 丢失也能兜底处理。
|
||||
async fn run_listener(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
database_url: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut listener = PgListener::connect(database_url).await?;
|
||||
listener.listen(NOTIFY_CHANNEL).await?;
|
||||
tracing::info!("Outbox relay LISTEN/NOTIFY 已连接,监听 {}", NOTIFY_CHANNEL);
|
||||
|
||||
let mut fallback = tokio::time::interval(Duration::from_secs(FALLBACK_POLL_INTERVAL_SECS));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// LISTEN/NOTIFY 通知触发
|
||||
notification = listener.recv() => {
|
||||
match notification {
|
||||
Ok(notif) => {
|
||||
tracing::debug!(
|
||||
channel = %notif.channel(),
|
||||
payload = %notif.payload(),
|
||||
"收到 outbox NOTIFY"
|
||||
);
|
||||
if let Err(e) = process_pending_events(db, event_bus).await {
|
||||
tracing::warn!(error = %e, "NOTIFY 触发的 outbox 处理失败");
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
// 30s 兜底轮询
|
||||
_ = fallback.tick() => {
|
||||
tracing::debug!("outbox relay 兜底轮询触发");
|
||||
if let Err(e) = process_pending_events(db, event_bus).await {
|
||||
tracing::warn!(error = %e, "兜底轮询 outbox 处理失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_pending_events(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> Result<usize, sea_orm::DbErr> {
|
||||
let pending = domain_event::Entity::find()
|
||||
.filter(domain_event::Column::Status.eq("pending"))
|
||||
.filter(domain_event::Column::Attempts.lt(MAX_RETRY))
|
||||
.order_by_asc(domain_event::Column::CreatedAt)
|
||||
.limit(100)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
if pending.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let count = pending.len();
|
||||
tracing::info!(count = count, "处理待发领域事件");
|
||||
|
||||
for event_model in pending {
|
||||
// 重建 DomainEvent 并广播(保留原始 ID 和时间戳)
|
||||
let domain_event = DomainEvent {
|
||||
id: event_model.id,
|
||||
event_type: event_model.event_type.clone(),
|
||||
tenant_id: event_model.tenant_id,
|
||||
payload: event_model.payload.clone().unwrap_or(serde_json::json!({})),
|
||||
timestamp: event_model.created_at,
|
||||
correlation_id: event_model.correlation_id.unwrap_or(event_model.id),
|
||||
};
|
||||
|
||||
event_bus.broadcast(domain_event);
|
||||
|
||||
// 标记为 published,增加 attempts 计数
|
||||
let mut active: domain_event::ActiveModel = event_model.into();
|
||||
active.status = Set("published".to_string());
|
||||
active.published_at = Set(Some(Utc::now()));
|
||||
active.attempts = Set(erp_core::sea_orm_ext::bump_version(&active.attempts));
|
||||
active.update(db).await?;
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
107
crates/erp-server/src/state.rs
Normal file
107
crates/erp-server/src/state.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::FromRef;
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::module::ModuleRegistry;
|
||||
|
||||
/// Axum shared application state.
|
||||
/// All handlers access database connections, configuration, etc. through `State<AppState>`.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: DatabaseConnection,
|
||||
pub config: AppConfig,
|
||||
pub event_bus: EventBus,
|
||||
pub module_registry: ModuleRegistry,
|
||||
pub redis: redis::Client,
|
||||
/// 实际的默认租户 ID,从数据库种子数据中获取。
|
||||
pub default_tenant_id: uuid::Uuid,
|
||||
/// 插件引擎
|
||||
pub plugin_engine: erp_plugin::engine::PluginEngine,
|
||||
/// 插件实体缓存
|
||||
pub plugin_entity_cache: moka::sync::Cache<String, erp_plugin::state::EntityInfo>,
|
||||
/// PII 加密服务(KEK + DEK 管理)
|
||||
pub pii_crypto: erp_core::crypto::PiiCrypto,
|
||||
/// 定时任务心跳(unix timestamp secs),每个 cron tick 更新
|
||||
pub cron_heartbeat: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
/// Allow handlers to extract `DatabaseConnection` directly from `State<AppState>`.
|
||||
impl FromRef<AppState> for DatabaseConnection {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.db.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow handlers to extract `EventBus` directly from `State<AppState>`.
|
||||
impl FromRef<AppState> for EventBus {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.event_bus.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow erp-auth handlers to extract their required state without depending on erp-server.
|
||||
///
|
||||
/// This bridges the gap: erp-auth defines `AuthState` with the fields it needs,
|
||||
/// and erp-server fills them from `AppState`.
|
||||
impl FromRef<AppState> for erp_auth::AuthState {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
use erp_auth::auth_state::parse_ttl;
|
||||
|
||||
Self {
|
||||
db: state.db.clone(),
|
||||
event_bus: state.event_bus.clone(),
|
||||
jwt_secret: state.config.jwt.secret.clone(),
|
||||
access_ttl_secs: parse_ttl(&state.config.jwt.access_token_ttl),
|
||||
refresh_ttl_secs: parse_ttl(&state.config.jwt.refresh_token_ttl),
|
||||
default_tenant_id: state.default_tenant_id,
|
||||
redis: Some(state.redis.clone()),
|
||||
crypto: state.pii_crypto.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow erp-config handlers to extract their required state without depending on erp-server.
|
||||
impl FromRef<AppState> for erp_config::ConfigState {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
Self {
|
||||
db: state.db.clone(),
|
||||
event_bus: state.event_bus.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow erp-workflow handlers to extract their required state without depending on erp-server.
|
||||
impl FromRef<AppState> for erp_workflow::WorkflowState {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
Self {
|
||||
db: state.db.clone(),
|
||||
event_bus: state.event_bus.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow erp-message handlers to extract their required state without depending on erp-server.
|
||||
impl FromRef<AppState> for erp_message::MessageState {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
Self {
|
||||
db: state.db.clone(),
|
||||
event_bus: state.event_bus.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow erp-plugin handlers to extract their required state.
|
||||
impl FromRef<AppState> for erp_plugin::state::PluginState {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
Self {
|
||||
db: state.db.clone(),
|
||||
event_bus: state.event_bus.clone(),
|
||||
engine: state.plugin_engine.clone(),
|
||||
entity_cache: state.plugin_entity_cache.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
125
crates/erp-server/src/tasks.rs
Normal file
125
crates/erp-server/src/tasks.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn touch_heartbeat(heartbeat: &Arc<AtomicU64>) {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
heartbeat.store(now, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// 启动事件清理后台任务。
|
||||
///
|
||||
/// 每日执行一次:
|
||||
/// - 调用 `cleanup_old_published_events()` 归档 >7 天的已发布事件
|
||||
/// - 调用 `cleanup_old_processed_events()` 清理 >7 天的去重记录
|
||||
pub fn start_event_cleanup(db: sea_orm::DatabaseConnection, heartbeat: Arc<AtomicU64>) {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(86400));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Err(e) = run_cleanup(&db).await {
|
||||
tracing::warn!(error = %e, "事件清理任务执行失败");
|
||||
}
|
||||
touch_heartbeat(&heartbeat);
|
||||
}
|
||||
});
|
||||
tracing::info!("事件清理任务已启动(每 24 小时执行一次)");
|
||||
}
|
||||
|
||||
async fn run_cleanup(db: &sea_orm::DatabaseConnection) -> Result<(), sea_orm::DbErr> {
|
||||
use sea_orm::ConnectionTrait;
|
||||
|
||||
// 归档 >7 天的已发布事件
|
||||
match db
|
||||
.execute_unprepared("SELECT cleanup_old_published_events(7, 1000)")
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
tracing::info!(rows_affected = result.rows_affected(), "已发布事件归档完成");
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %e, "已发布事件归档失败"),
|
||||
}
|
||||
|
||||
// 清理 >7 天的去重记录
|
||||
match db
|
||||
.execute_unprepared("SELECT cleanup_old_processed_events(7, 1000)")
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
tracing::info!(rows_affected = result.rows_affected(), "去重记录清理完成");
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %e, "去重记录清理失败"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 启动 DB 连接池 + EventBus 积压指标采样任务。
|
||||
///
|
||||
/// 每 30 秒采样一次并导出为 Prometheus gauge:
|
||||
/// - `db_pool_connections_active` — 当前活跃连接数
|
||||
/// - `db_pool_connections_idle` — 当前空闲连接数
|
||||
/// - `eventbus_pending_total` — pending 状态的领域事件数
|
||||
pub fn start_pool_metrics(db: sea_orm::DatabaseConnection, heartbeat: Arc<AtomicU64>) {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
sample_pool_metrics(&db).await;
|
||||
sample_eventbus_backlog(&db).await;
|
||||
touch_heartbeat(&heartbeat);
|
||||
}
|
||||
});
|
||||
tracing::info!("DB 连接池 + EventBus 积压指标采样已启动(每 30 秒采样一次)");
|
||||
}
|
||||
|
||||
async fn sample_pool_metrics(db: &sea_orm::DatabaseConnection) {
|
||||
use sea_orm::FromQueryResult;
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct CountRow {
|
||||
cnt: i64,
|
||||
}
|
||||
|
||||
// 通过 pg_stat_activity 查询当前连接数
|
||||
let stmt = sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"SELECT COUNT(*)::bigint AS cnt FROM pg_stat_activity WHERE state = 'active'".to_string(),
|
||||
);
|
||||
if let Ok(Some(row)) = CountRow::find_by_statement(stmt).one(db).await {
|
||||
metrics::gauge!("db_pool_connections_active").set(row.cnt as f64);
|
||||
}
|
||||
|
||||
let stmt = sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"SELECT COUNT(*)::bigint AS cnt FROM pg_stat_activity WHERE state = 'idle'".to_string(),
|
||||
);
|
||||
if let Ok(Some(row)) = CountRow::find_by_statement(stmt).one(db).await {
|
||||
metrics::gauge!("db_pool_connections_idle").set(row.cnt as f64);
|
||||
}
|
||||
}
|
||||
|
||||
async fn sample_eventbus_backlog(db: &sea_orm::DatabaseConnection) {
|
||||
use sea_orm::FromQueryResult;
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct CountRow {
|
||||
cnt: i64,
|
||||
}
|
||||
|
||||
let stmt = sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"SELECT COUNT(*)::bigint AS cnt FROM domain_events WHERE status = 'pending'".to_string(),
|
||||
);
|
||||
match CountRow::find_by_statement(stmt).one(db).await {
|
||||
Ok(Some(row)) => {
|
||||
metrics::gauge!("eventbus_pending_total").set(row.cnt as f64);
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!("EventBus 积压采样:无法获取 pending 事件数");
|
||||
}
|
||||
}
|
||||
}
|
||||
8
crates/erp-server/tests/integration.rs
Normal file
8
crates/erp-server/tests/integration.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
#[path = "integration/auth_tests.rs"]
|
||||
mod auth_tests;
|
||||
#[path = "integration/plugin_tests.rs"]
|
||||
mod plugin_tests;
|
||||
#[path = "integration/test_db.rs"]
|
||||
mod test_db;
|
||||
#[path = "integration/workflow_tests.rs"]
|
||||
mod workflow_tests;
|
||||
129
crates/erp-server/tests/integration/auth_tests.rs
Normal file
129
crates/erp-server/tests/integration/auth_tests.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
use erp_auth::dto::CreateUserReq;
|
||||
use erp_auth::service::user_service::UserService;
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
use super::test_db::TestDb;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_user_crud() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = test_db.db();
|
||||
let tenant_id = uuid::Uuid::new_v4();
|
||||
let operator_id = uuid::Uuid::new_v4();
|
||||
let event_bus = EventBus::new(100);
|
||||
|
||||
// 创建用户
|
||||
let user = UserService::create(
|
||||
tenant_id,
|
||||
operator_id,
|
||||
&CreateUserReq {
|
||||
username: "testuser".to_string(),
|
||||
password: "TestPass123".to_string(),
|
||||
email: Some("test@example.com".to_string()),
|
||||
phone: None,
|
||||
display_name: Some("测试用户".to_string()),
|
||||
},
|
||||
db,
|
||||
&event_bus,
|
||||
)
|
||||
.await
|
||||
.expect("创建用户失败");
|
||||
|
||||
assert_eq!(user.username, "testuser");
|
||||
assert_eq!(user.status, "active");
|
||||
|
||||
// 按 ID 查询
|
||||
let found = UserService::get_by_id(user.id, tenant_id, db)
|
||||
.await
|
||||
.expect("查询用户失败");
|
||||
assert_eq!(found.username, "testuser");
|
||||
assert_eq!(found.email, Some("test@example.com".to_string()));
|
||||
|
||||
// 列表查询
|
||||
let (users, total) = UserService::list(
|
||||
tenant_id,
|
||||
&Pagination {
|
||||
page: Some(1),
|
||||
page_size: Some(10),
|
||||
},
|
||||
None,
|
||||
db,
|
||||
)
|
||||
.await
|
||||
.expect("用户列表查询失败");
|
||||
assert_eq!(total, 1);
|
||||
assert_eq!(users[0].username, "testuser");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tenant_isolation() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = test_db.db();
|
||||
let tenant_a = uuid::Uuid::new_v4();
|
||||
let tenant_b = uuid::Uuid::new_v4();
|
||||
let operator_id = uuid::Uuid::new_v4();
|
||||
let event_bus = EventBus::new(100);
|
||||
|
||||
// 租户 A 创建用户
|
||||
let user_a = UserService::create(
|
||||
tenant_a,
|
||||
operator_id,
|
||||
&CreateUserReq {
|
||||
username: "user_a".to_string(),
|
||||
password: "Pass123456".to_string(),
|
||||
email: None,
|
||||
phone: None,
|
||||
display_name: None,
|
||||
},
|
||||
db,
|
||||
&event_bus,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 租户 B 列表查询不应看到租户 A 的用户
|
||||
let (users_b, total_b) = UserService::list(
|
||||
tenant_b,
|
||||
&Pagination {
|
||||
page: Some(1),
|
||||
page_size: Some(10),
|
||||
},
|
||||
None,
|
||||
db,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(total_b, 0);
|
||||
assert!(users_b.is_empty());
|
||||
|
||||
// 租户 B 通过 ID 查询租户 A 的用户应返回错误
|
||||
let result = UserService::get_by_id(user_a.id, tenant_b, db).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_username_uniqueness_within_tenant() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = test_db.db();
|
||||
let tenant_id = uuid::Uuid::new_v4();
|
||||
let operator_id = uuid::Uuid::new_v4();
|
||||
let event_bus = EventBus::new(100);
|
||||
|
||||
let req = CreateUserReq {
|
||||
username: "duplicate".to_string(),
|
||||
password: "Pass123456".to_string(),
|
||||
email: None,
|
||||
phone: None,
|
||||
display_name: None,
|
||||
};
|
||||
|
||||
// 第一次创建成功
|
||||
UserService::create(tenant_id, operator_id, &req, db, &event_bus)
|
||||
.await
|
||||
.expect("创建用户应成功");
|
||||
|
||||
// 同租户重复用户名应失败
|
||||
let result = UserService::create(tenant_id, operator_id, &req, db, &event_bus).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
213
crates/erp-server/tests/integration/plugin_tests.rs
Normal file
213
crates/erp-server/tests/integration/plugin_tests.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
use erp_plugin::dynamic_table::DynamicTableManager;
|
||||
use erp_plugin::manifest::{
|
||||
PluginEntity, PluginField, PluginFieldType, PluginManifest, PluginMetadata, PluginSchema,
|
||||
};
|
||||
use sea_orm::{ConnectionTrait, FromQueryResult};
|
||||
|
||||
use super::test_db::TestDb;
|
||||
|
||||
/// 构造一个最小默认值的 PluginField(外部 crate 无法使用 #[cfg(test)] 的 default_for_field)
|
||||
fn make_field(name: &str, field_type: PluginFieldType) -> PluginField {
|
||||
PluginField {
|
||||
name: name.to_string(),
|
||||
field_type,
|
||||
required: false,
|
||||
unique: false,
|
||||
default: None,
|
||||
display_name: None,
|
||||
ui_widget: None,
|
||||
options: None,
|
||||
searchable: None,
|
||||
filterable: None,
|
||||
sortable: None,
|
||||
visible_when: None,
|
||||
ref_entity: None,
|
||||
ref_label_field: None,
|
||||
ref_search_fields: None,
|
||||
cascade_from: None,
|
||||
cascade_filter: None,
|
||||
validation: None,
|
||||
no_cycle: None,
|
||||
scope_role: None,
|
||||
ref_plugin: None,
|
||||
ref_fallback_label: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建测试用 manifest
|
||||
fn make_test_manifest() -> PluginManifest {
|
||||
PluginManifest {
|
||||
metadata: PluginMetadata {
|
||||
id: "erp-test".to_string(),
|
||||
name: "测试插件".to_string(),
|
||||
version: "0.1.0".to_string(),
|
||||
description: "集成测试用".to_string(),
|
||||
author: "test".to_string(),
|
||||
min_platform_version: None,
|
||||
dependencies: vec![],
|
||||
},
|
||||
schema: Some(PluginSchema {
|
||||
entities: vec![PluginEntity {
|
||||
name: "item".to_string(),
|
||||
display_name: "测试项".to_string(),
|
||||
is_public: None,
|
||||
fields: vec![
|
||||
PluginField {
|
||||
name: "code".to_string(),
|
||||
field_type: PluginFieldType::String,
|
||||
required: true,
|
||||
unique: true,
|
||||
display_name: Some("编码".to_string()),
|
||||
searchable: Some(true),
|
||||
..make_field("code", PluginFieldType::String)
|
||||
},
|
||||
PluginField {
|
||||
name: "name".to_string(),
|
||||
field_type: PluginFieldType::String,
|
||||
required: true,
|
||||
display_name: Some("名称".to_string()),
|
||||
searchable: Some(true),
|
||||
..make_field("name", PluginFieldType::String)
|
||||
},
|
||||
PluginField {
|
||||
name: "status".to_string(),
|
||||
field_type: PluginFieldType::String,
|
||||
filterable: Some(true),
|
||||
display_name: Some("状态".to_string()),
|
||||
..make_field("status", PluginFieldType::String)
|
||||
},
|
||||
PluginField {
|
||||
name: "sort_order".to_string(),
|
||||
field_type: PluginFieldType::Integer,
|
||||
sortable: Some(true),
|
||||
display_name: Some("排序".to_string()),
|
||||
..make_field("sort_order", PluginFieldType::Integer)
|
||||
},
|
||||
],
|
||||
indexes: vec![],
|
||||
relations: vec![],
|
||||
data_scope: None,
|
||||
importable: None,
|
||||
exportable: None,
|
||||
}],
|
||||
}),
|
||||
events: None,
|
||||
ui: None,
|
||||
permissions: None,
|
||||
settings: None,
|
||||
numbering: None,
|
||||
templates: None,
|
||||
trigger_events: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dynamic_table_create_and_query() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = test_db.db();
|
||||
|
||||
let manifest = make_test_manifest();
|
||||
let entity = &manifest.schema.as_ref().unwrap().entities[0];
|
||||
|
||||
// 创建动态表
|
||||
DynamicTableManager::create_table(db, "erp_test", entity)
|
||||
.await
|
||||
.expect("创建动态表失败");
|
||||
|
||||
let table_name = DynamicTableManager::table_name("erp_test", &entity.name);
|
||||
|
||||
// 验证表存在
|
||||
let exists = DynamicTableManager::table_exists(db, &table_name)
|
||||
.await
|
||||
.expect("检查表存在失败");
|
||||
assert!(exists, "动态表应存在");
|
||||
|
||||
// 插入数据
|
||||
let tenant_id = uuid::Uuid::new_v4();
|
||||
let user_id = uuid::Uuid::new_v4();
|
||||
let data = serde_json::json!({
|
||||
"code": "ITEM001",
|
||||
"name": "测试项目",
|
||||
"status": "active",
|
||||
"sort_order": 1
|
||||
});
|
||||
|
||||
let (sql, values) =
|
||||
DynamicTableManager::build_insert_sql(&table_name, tenant_id, user_id, &data);
|
||||
db.execute(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.await
|
||||
.expect("插入数据失败");
|
||||
|
||||
// 查询数据
|
||||
let (sql, values) = DynamicTableManager::build_query_sql(&table_name, tenant_id, 10, 0);
|
||||
#[derive(FromQueryResult)]
|
||||
struct Row {
|
||||
id: uuid::Uuid,
|
||||
data: serde_json::Value,
|
||||
}
|
||||
let rows = Row::find_by_statement(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.all(db)
|
||||
.await
|
||||
.expect("查询数据失败");
|
||||
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].data["code"], "ITEM001");
|
||||
assert_eq!(rows[0].data["name"], "测试项目");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tenant_isolation_in_dynamic_table() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = test_db.db();
|
||||
|
||||
let manifest = make_test_manifest();
|
||||
let entity = &manifest.schema.as_ref().unwrap().entities[0];
|
||||
|
||||
DynamicTableManager::create_table(db, "erp_test_iso", entity)
|
||||
.await
|
||||
.expect("创建动态表失败");
|
||||
|
||||
let table_name = DynamicTableManager::table_name("erp_test_iso", &entity.name);
|
||||
|
||||
let tenant_a = uuid::Uuid::new_v4();
|
||||
let tenant_b = uuid::Uuid::new_v4();
|
||||
let user_id = uuid::Uuid::new_v4();
|
||||
|
||||
// 租户 A 插入数据
|
||||
let data_a = serde_json::json!({"code": "A001", "name": "租户A数据", "status": "active", "sort_order": 1});
|
||||
let (sql, values) =
|
||||
DynamicTableManager::build_insert_sql(&table_name, tenant_a, user_id, &data_a);
|
||||
db.execute(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 租户 B 查询不应看到租户 A 的数据
|
||||
let (sql, values) = DynamicTableManager::build_query_sql(&table_name, tenant_b, 10, 0);
|
||||
#[derive(FromQueryResult)]
|
||||
struct Row {
|
||||
id: uuid::Uuid,
|
||||
data: serde_json::Value,
|
||||
}
|
||||
let rows = Row::find_by_statement(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.all(db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(rows.is_empty(), "租户 B 不应看到租户 A 的数据");
|
||||
}
|
||||
114
crates/erp-server/tests/integration/test_db.rs
Normal file
114
crates/erp-server/tests/integration/test_db.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
use sea_orm::{ConnectionTrait, Database, DatabaseBackend, Statement};
|
||||
use std::sync::Arc;
|
||||
|
||||
use erp_server_migration::MigratorTrait;
|
||||
|
||||
/// 全局信号量:限制同时创建数据库的测试数量,避免 PostgreSQL 连接耗尽
|
||||
static DB_SEMAPHORE: std::sync::OnceLock<Arc<tokio::sync::Semaphore>> = std::sync::OnceLock::new();
|
||||
|
||||
fn db_semaphore() -> &'static Arc<tokio::sync::Semaphore> {
|
||||
DB_SEMAPHORE.get_or_init(|| Arc::new(tokio::sync::Semaphore::new(4)))
|
||||
}
|
||||
|
||||
/// 测试数据库 — 使用本地 PostgreSQL 创建隔离测试库
|
||||
///
|
||||
/// 连接本地 PostgreSQL(wiki/infrastructure.md 配置),为每个测试创建独立的测试数据库。
|
||||
/// 不依赖 Docker/Testcontainers,与开发环境一致。
|
||||
pub struct TestDb {
|
||||
db: Option<sea_orm::DatabaseConnection>,
|
||||
db_name: String,
|
||||
_permit: Option<tokio::sync::OwnedSemaphorePermit>,
|
||||
}
|
||||
|
||||
impl TestDb {
|
||||
pub async fn new() -> Self {
|
||||
let permit = db_semaphore()
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.expect("信号量获取失败");
|
||||
|
||||
let db_name = format!("erp_test_{}", uuid::Uuid::now_v7().simple());
|
||||
|
||||
let admin_url = std::env::var("TEST_DB_URL")
|
||||
.unwrap_or_else(|_| "postgres://postgres:123123@localhost:5432/postgres".to_string());
|
||||
|
||||
let admin_db = Database::connect(&admin_url)
|
||||
.await
|
||||
.expect("连接本地 PostgreSQL 失败,请确认服务正在运行");
|
||||
|
||||
admin_db
|
||||
.execute(Statement::from_string(
|
||||
DatabaseBackend::Postgres,
|
||||
format!("CREATE DATABASE \"{}\"", db_name),
|
||||
))
|
||||
.await
|
||||
.expect("创建测试数据库失败");
|
||||
|
||||
drop(admin_db);
|
||||
|
||||
// 从 admin_url 推导测试库 URL(替换路径部分)
|
||||
let test_url = if let Some(pos) = admin_url.rfind('/') {
|
||||
format!("{}/{}", &admin_url[..pos], db_name)
|
||||
} else {
|
||||
format!("postgres://postgres:123123@localhost:5432/{}", db_name)
|
||||
};
|
||||
let db = Database::connect(&test_url)
|
||||
.await
|
||||
.expect("连接测试数据库失败");
|
||||
|
||||
// 运行所有迁移
|
||||
erp_server_migration::Migrator::up(&db, None)
|
||||
.await
|
||||
.expect("执行数据库迁移失败");
|
||||
|
||||
Self {
|
||||
db: Some(db),
|
||||
db_name,
|
||||
_permit: Some(permit),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取数据库连接引用
|
||||
pub fn db(&self) -> &sea_orm::DatabaseConnection {
|
||||
self.db.as_ref().expect("数据库连接已被释放")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDb {
|
||||
fn drop(&mut self) {
|
||||
let db_name = self.db_name.clone();
|
||||
self.db.take();
|
||||
|
||||
// 尝试在独立线程中清理,避免在 tokio runtime 内创建新 runtime
|
||||
let _ = std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build();
|
||||
if let Ok(rt) = rt {
|
||||
rt.block_on(async {
|
||||
let admin_url = std::env::var("TEST_DB_URL")
|
||||
.unwrap_or_else(|_| "postgres://postgres:123123@localhost:5432/postgres".to_string());
|
||||
if let Ok(admin_db) = Database::connect(&admin_url).await {
|
||||
let disconnect_sql = format!(
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{}'",
|
||||
db_name
|
||||
);
|
||||
admin_db
|
||||
.execute(Statement::from_string(DatabaseBackend::Postgres, disconnect_sql))
|
||||
.await
|
||||
.ok();
|
||||
|
||||
admin_db
|
||||
.execute(Statement::from_string(
|
||||
DatabaseBackend::Postgres,
|
||||
format!("DROP DATABASE IF EXISTS \"{}\"", db_name),
|
||||
))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
247
crates/erp-server/tests/integration/workflow_tests.rs
Normal file
247
crates/erp-server/tests/integration/workflow_tests.rs
Normal file
@@ -0,0 +1,247 @@
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::types::Pagination;
|
||||
use erp_workflow::dto::{
|
||||
CompleteTaskReq, CreateProcessDefinitionReq, EdgeDef, NodeDef, NodeType, StartInstanceReq,
|
||||
};
|
||||
use erp_workflow::service::definition_service::DefinitionService;
|
||||
use erp_workflow::service::instance_service::InstanceService;
|
||||
use erp_workflow::service::task_service::TaskService;
|
||||
|
||||
use super::test_db::TestDb;
|
||||
|
||||
/// 构建一个最简单的线性流程:开始 → 审批 → 结束
|
||||
/// assignee 指向 operator_id,使 list_pending 能查到任务
|
||||
fn make_simple_definition(
|
||||
name: &str,
|
||||
key: &str,
|
||||
assignee_id: Option<uuid::Uuid>,
|
||||
) -> CreateProcessDefinitionReq {
|
||||
CreateProcessDefinitionReq {
|
||||
name: name.to_string(),
|
||||
key: key.to_string(),
|
||||
category: Some("test".to_string()),
|
||||
description: Some("集成测试流程".to_string()),
|
||||
nodes: vec![
|
||||
NodeDef {
|
||||
id: "start".to_string(),
|
||||
node_type: NodeType::StartEvent,
|
||||
name: "开始".to_string(),
|
||||
assignee_id: None,
|
||||
candidate_groups: None,
|
||||
service_type: None,
|
||||
service_config: None,
|
||||
position: None,
|
||||
},
|
||||
NodeDef {
|
||||
id: "approve".to_string(),
|
||||
node_type: NodeType::UserTask,
|
||||
name: "审批".to_string(),
|
||||
assignee_id,
|
||||
candidate_groups: None,
|
||||
service_type: None,
|
||||
service_config: None,
|
||||
position: None,
|
||||
},
|
||||
NodeDef {
|
||||
id: "end".to_string(),
|
||||
node_type: NodeType::EndEvent,
|
||||
name: "结束".to_string(),
|
||||
assignee_id: None,
|
||||
candidate_groups: None,
|
||||
service_type: None,
|
||||
service_config: None,
|
||||
position: None,
|
||||
},
|
||||
],
|
||||
edges: vec![
|
||||
EdgeDef {
|
||||
id: "e1".to_string(),
|
||||
source: "start".to_string(),
|
||||
target: "approve".to_string(),
|
||||
condition: None,
|
||||
label: None,
|
||||
},
|
||||
EdgeDef {
|
||||
id: "e2".to_string(),
|
||||
source: "approve".to_string(),
|
||||
target: "end".to_string(),
|
||||
condition: None,
|
||||
label: None,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_workflow_definition_crud() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = test_db.db();
|
||||
let tenant_id = uuid::Uuid::new_v4();
|
||||
let operator_id = uuid::Uuid::new_v4();
|
||||
let event_bus = EventBus::new(100);
|
||||
|
||||
let def = DefinitionService::create(
|
||||
tenant_id,
|
||||
operator_id,
|
||||
&make_simple_definition("测试流程", "test-flow-1", None),
|
||||
db,
|
||||
&event_bus,
|
||||
)
|
||||
.await
|
||||
.expect("创建流程定义失败");
|
||||
|
||||
assert_eq!(def.name, "测试流程");
|
||||
assert_eq!(def.status, "draft");
|
||||
|
||||
let (defs, total) = DefinitionService::list(
|
||||
tenant_id,
|
||||
&Pagination {
|
||||
page: Some(1),
|
||||
page_size: Some(10),
|
||||
},
|
||||
db,
|
||||
)
|
||||
.await
|
||||
.expect("查询流程定义列表失败");
|
||||
assert_eq!(total, 1);
|
||||
assert_eq!(defs[0].name, "测试流程");
|
||||
|
||||
let found = DefinitionService::get_by_id(def.id, tenant_id, db)
|
||||
.await
|
||||
.expect("查询流程定义失败");
|
||||
assert_eq!(found.id, def.id);
|
||||
|
||||
let published = DefinitionService::publish(def.id, tenant_id, operator_id, db, &event_bus)
|
||||
.await
|
||||
.expect("发布流程定义失败");
|
||||
assert_eq!(published.status, "published");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_workflow_instance_lifecycle() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = test_db.db();
|
||||
let tenant_id = uuid::Uuid::new_v4();
|
||||
let operator_id = uuid::Uuid::new_v4();
|
||||
let event_bus = EventBus::new(100);
|
||||
|
||||
let def = DefinitionService::create(
|
||||
tenant_id,
|
||||
operator_id,
|
||||
&make_simple_definition("生命周期测试", "lifecycle-flow", Some(operator_id)),
|
||||
db,
|
||||
&event_bus,
|
||||
)
|
||||
.await
|
||||
.expect("创建流程定义失败");
|
||||
|
||||
let def = DefinitionService::publish(def.id, tenant_id, operator_id, db, &event_bus)
|
||||
.await
|
||||
.expect("发布流程定义失败");
|
||||
|
||||
let instance = InstanceService::start(
|
||||
tenant_id,
|
||||
operator_id,
|
||||
&StartInstanceReq {
|
||||
definition_id: def.id,
|
||||
business_key: Some("测试实例".to_string()),
|
||||
variables: None,
|
||||
},
|
||||
db,
|
||||
&event_bus,
|
||||
)
|
||||
.await
|
||||
.expect("启动流程实例失败");
|
||||
|
||||
assert_eq!(instance.status, "running");
|
||||
|
||||
let (tasks, task_total) = TaskService::list_pending(
|
||||
tenant_id,
|
||||
operator_id,
|
||||
&Pagination {
|
||||
page: Some(1),
|
||||
page_size: Some(10),
|
||||
},
|
||||
db,
|
||||
)
|
||||
.await
|
||||
.expect("查询待办任务失败");
|
||||
assert_eq!(task_total, 1);
|
||||
assert_eq!(tasks[0].status, "pending");
|
||||
|
||||
let completed = TaskService::complete(
|
||||
tasks[0].id,
|
||||
tenant_id,
|
||||
operator_id,
|
||||
&CompleteTaskReq {
|
||||
outcome: "approved".to_string(),
|
||||
form_data: Some(serde_json::json!({"comment": "同意"})),
|
||||
},
|
||||
db,
|
||||
&event_bus,
|
||||
)
|
||||
.await
|
||||
.expect("完成任务失败");
|
||||
assert_eq!(completed.status, "completed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_workflow_tenant_isolation() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = test_db.db();
|
||||
let tenant_a = uuid::Uuid::new_v4();
|
||||
let tenant_b = uuid::Uuid::new_v4();
|
||||
let operator_id = uuid::Uuid::new_v4();
|
||||
let event_bus = EventBus::new(100);
|
||||
|
||||
let def_a = DefinitionService::create(
|
||||
tenant_a,
|
||||
operator_id,
|
||||
&make_simple_definition("租户A流程", "tenant-a-flow", None),
|
||||
db,
|
||||
&event_bus,
|
||||
)
|
||||
.await
|
||||
.expect("创建流程定义失败");
|
||||
|
||||
let (defs_b, total_b) = DefinitionService::list(
|
||||
tenant_b,
|
||||
&Pagination {
|
||||
page: Some(1),
|
||||
page_size: Some(10),
|
||||
},
|
||||
db,
|
||||
)
|
||||
.await
|
||||
.expect("查询流程定义列表失败");
|
||||
assert_eq!(total_b, 0);
|
||||
assert!(defs_b.is_empty());
|
||||
|
||||
let result = DefinitionService::get_by_id(def_a.id, tenant_b, db).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_event_bus_pub_sub() {
|
||||
let event_bus = EventBus::new(100);
|
||||
let tenant_id = uuid::Uuid::new_v4();
|
||||
|
||||
let (mut receiver, _handle) = event_bus.subscribe_filtered("user.".to_string());
|
||||
|
||||
let event = erp_core::events::DomainEvent::new(
|
||||
"user.created",
|
||||
tenant_id,
|
||||
serde_json::json!({"username": "test"}),
|
||||
);
|
||||
event_bus.broadcast(event);
|
||||
|
||||
let other_event =
|
||||
erp_core::events::DomainEvent::new("workflow.started", tenant_id, serde_json::json!({}));
|
||||
event_bus.broadcast(other_event);
|
||||
|
||||
let received = receiver.recv().await;
|
||||
assert!(received.is_some());
|
||||
let received = received.unwrap();
|
||||
assert_eq!(received.event_type, "user.created");
|
||||
assert_eq!(received.payload["username"], "test");
|
||||
}
|
||||
Reference in New Issue
Block a user