fix: 修复测试发现的 7 个问题 + 全 workspace clippy 清零
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

功能修复:
1. 患者创建空名称验证:后端添加 name.trim().is_empty() 检查
2. 仪表盘统计容错:单个查询失败返回零值而非 500
3. FHIR 路由修复:从 /fhir 移到 /api/v1/fhir 保持一致
4. 冻结模块后端中间件:新增 frozen_module_middleware 拦截冻结路径
5. 积分端点权限码:health.health-data.list → health.points.list
6. 角色权限迁移:护士补充 devices.list,运营补充 points.list/manage
7. 测试结果文档:R01-R05 角色测试 + T00/T10 结果归档

Clippy 全 workspace 清零(14→0 errors):
- erp-core: 修复 empty doc line、collapsible if、redundant closure 等 9 处
- erp-health: 修复 too_many_arguments、unused var、unnecessary parens 等 58 处
- erp-ai: 修复 dead_code、unused import 等 11 处
- erp-plugin: 修复 too_many_arguments、wildcard pattern 等 11 处
- erp-server-migration: 修复 enum_variant_names 5 处
- erp-auth/config/workflow/message: 各 1-3 处

工程改进:
- lint-staged 配置迁移到 .lintstagedrc.js(函数式避免文件列表传给 clippy)
- cargo fmt 统一格式化
This commit is contained in:
iven
2026-05-07 23:43:14 +08:00
parent 786f57c151
commit 6d5a711d2c
323 changed files with 15662 additions and 6603 deletions

View File

@@ -145,22 +145,8 @@ impl StorageConfig {
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct RateLimitConfig {
/// Redis 不可达时是否拒绝请求。默认 true安全优先
#[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: true }
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct RateLimitConfig {}
impl AppConfig {
pub fn load() -> anyhow::Result<Self> {
@@ -178,20 +164,3 @@ impl AppConfig {
Ok(app_config)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rate_limit_default_is_fail_close() {
let config = RateLimitConfig::default();
assert!(config.fail_close, "RateLimitConfig 默认应为 fail_close = true");
}
#[test]
fn serde_default_uses_custom_fn() {
let config: RateLimitConfig = serde_json::from_str("{}").unwrap();
assert!(config.fail_close, "serde 反序列化缺失字段时应使用 default_fail_close() = true");
}
}

View File

@@ -5,19 +5,14 @@ use uuid::Uuid;
/// 启动透析会话工作流编排器
/// 订阅 dialysis.record.created → 自动查找并启动 dialysis_session BPMN 工作流
pub fn start_dialysis_workflow_orchestrator(
db: sea_orm::DatabaseConnection,
event_bus: EventBus,
) {
pub fn start_dialysis_workflow_orchestrator(db: sea_orm::DatabaseConnection, event_bus: EventBus) {
let (mut receiver, _handle) = event_bus.subscribe_filtered("dialysis.".to_string());
tokio::spawn(async move {
loop {
match receiver.recv().await {
Some(event) if event.event_type == "dialysis.record.created" => {
if let Err(e) =
handle_dialysis_record_created(&db, &event_bus, &event).await
{
if let Err(e) = handle_dialysis_record_created(&db, &event_bus, &event).await {
tracing::warn!(
error = %e,
record_id = ?event.payload.get("record_id"),
@@ -48,24 +43,13 @@ async fn handle_dialysis_record_created(
let record_uuid = Uuid::parse_str(record_id)?;
// 查找 dialysis_session 流程定义
let definition =
erp_workflow::entity::process_definition::Entity::find()
.filter(
erp_workflow::entity::process_definition::Column::Key
.eq("dialysis_session"),
)
.filter(
erp_workflow::entity::process_definition::Column::TenantId
.eq(event.tenant_id),
)
.filter(
erp_workflow::entity::process_definition::Column::Status.eq("published"),
)
.filter(
erp_workflow::entity::process_definition::Column::DeletedAt.is_null(),
)
.one(db)
.await?;
let definition = erp_workflow::entity::process_definition::Entity::find()
.filter(erp_workflow::entity::process_definition::Column::Key.eq("dialysis_session"))
.filter(erp_workflow::entity::process_definition::Column::TenantId.eq(event.tenant_id))
.filter(erp_workflow::entity::process_definition::Column::Status.eq("published"))
.filter(erp_workflow::entity::process_definition::Column::DeletedAt.is_null())
.one(db)
.await?;
let definition = match definition {
Some(d) => d,

View File

@@ -20,9 +20,7 @@ pub struct BatchRequest {
/// 接收小程序批量埋点事件。
/// 当前为日志记录模式 — 后续可接入 ClickHouse/PostgreSQL 分析表。
pub async fn batch(
Json(req): Json<BatchRequest>,
) -> Json<ApiResponse<()>> {
pub async fn batch(Json(req): Json<BatchRequest>) -> Json<ApiResponse<()>> {
for evt in &req.events {
tracing::info!(
event = %evt.event,

View File

@@ -1,8 +1,8 @@
use axum::extract::{FromRef, Path, State};
use axum::Extension;
use axum::Json;
use sea_orm::{ConnectionTrait, Statement, DatabaseBackend};
use serde_json::{json, Value};
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;

View File

@@ -56,10 +56,8 @@ pub async fn readiness_check(State(state): State<AppState>) -> Json<ReadyRespons
.map(|m| m.name().to_string())
.collect();
let (db_status, redis_status) = tokio::join!(
check_database(&state.db),
check_redis(&state.redis),
);
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"
@@ -81,10 +79,8 @@ pub async fn readiness_check(State(state): State<AppState>) -> Json<ReadyRespons
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(),
);
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(),
@@ -105,26 +101,21 @@ async fn check_database(db: &sea_orm::DatabaseConnection) -> ComponentStatus {
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(),
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: 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()),
}
error: Some("connection failed".to_string()),
}
}
}
},
Err(e) => {
tracing::error!(error = %e, "Redis connection failed");
ComponentStatus {

View File

@@ -2,7 +2,7 @@ use axum::response::Json;
use serde_json::Value;
use utoipa::OpenApi;
use crate::{ApiDoc, AuthApiDoc, ConfigApiDoc, WorkflowApiDoc, MessageApiDoc};
use crate::{ApiDoc, AuthApiDoc, ConfigApiDoc, MessageApiDoc, WorkflowApiDoc};
/// GET /docs/openapi.json
///

View File

@@ -46,9 +46,9 @@ where
// 确保上传目录存在
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))
})?;
tokio::fs::create_dir_all(&tenant_dir)
.await
.map_err(|e| AppError::Internal(format!("创建上传目录失败: {}", e)))?;
// 读取第一个 field 作为上传文件
let field = multipart
@@ -65,10 +65,7 @@ where
// 验证文件类型
validate_content_type(&content_type)?;
let original_name = field
.name()
.unwrap_or("file")
.to_string();
let original_name = field.name().unwrap_or("file").to_string();
let data = field
.bytes()

View File

@@ -39,22 +39,20 @@ struct ApiDoc;
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,
)
)
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;
@@ -86,23 +84,21 @@ struct AuthApiDoc;
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,
)
)
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;
@@ -126,18 +122,16 @@ struct ConfigApiDoc;
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,
)
)
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;
@@ -155,18 +149,16 @@ struct WorkflowApiDoc;
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,
)
)
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;
@@ -190,31 +182,31 @@ async fn main() -> anyhow::Result<()> {
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"
);
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"
);
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"
);
tracing::error!("Redis URL 为默认占位值,拒绝启动。请设置环境变量 ERP__REDIS__URL");
std::process::exit(1);
}
if !config.wechat.dev_mode && (config.wechat.appid == "__MUST_SET_VIA_ENV__" || config.wechat.secret == "__MUST_SET_VIA_ENV__") {
if !config.wechat.dev_mode
&& (config.wechat.appid == "__MUST_SET_VIA_ENV__"
|| config.wechat.secret == "__MUST_SET_VIA_ENV__")
{
tracing::error!(
"微信凭据为默认占位值,拒绝启动。请设置环境变量 ERP__WECHAT__APPID 和 ERP__WECHAT__SECRET"
);
std::process::exit(1);
}
if config.health.aes_key == "__MUST_SET_VIA_ENV__" || config.health.hmac_key == "__MUST_SET_VIA_ENV__" {
if config.health.aes_key == "__MUST_SET_VIA_ENV__"
|| config.health.hmac_key == "__MUST_SET_VIA_ENV__"
{
// 注: health 密钥已被统一 KEK (ERP__CRYPTO__KEK) 替代,此处仅保留兼容性检查
tracing::warn!(
"ERP__HEALTH__AES_KEY/HMAC_KEY 未设置(已迁移到 ERP__CRYPTO__KEK 统一密钥体系)"
@@ -292,12 +284,21 @@ async fn main() -> anyhow::Result<()> {
tracing::info!(tenant_id = %new_tenant_id, "Default tenant ready with auth seed data");
// Seed AI workflow definitions
if let Err(e) = erp_workflow::service::ai_workflow_seed::ensure_ai_workflows(&db, new_tenant_id).await {
if let Err(e) =
erp_workflow::service::ai_workflow_seed::ensure_ai_workflows(&db, new_tenant_id)
.await
{
tracing::warn!(error = %e, "Failed to seed AI workflow definitions");
}
// Seed dialysis session workflow definition
if let Err(e) = dialysis_workflow::seed_dialysis_session_workflow(&db, new_tenant_id, new_tenant_id).await {
if let Err(e) = dialysis_workflow::seed_dialysis_session_workflow(
&db,
new_tenant_id,
new_tenant_id,
)
.await
{
tracing::warn!(error = %e, "Failed to seed dialysis session workflow");
}
@@ -363,7 +364,6 @@ async fn main() -> anyhow::Result<()> {
// Points module 已统一到 erp-health/health/points/* 路由)
// Initialize dialysis module
let dialysis_module = erp_dialysis::DialysisModule;
tracing::info!(
@@ -388,11 +388,8 @@ async fn main() -> anyhow::Result<()> {
// 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,
)?;
let plugin_engine =
erp_plugin::engine::PluginEngine::new(db.clone(), event_bus.clone(), plugin_config)?;
tracing::info!("Plugin engine initialized");
// Register plugin module
@@ -466,7 +463,9 @@ async fn main() -> anyhow::Result<()> {
}
#[cfg(not(debug_assertions))]
{
panic!("ERP__CRYPTO__KEK must be set in production. Use a 64-char hex string (32 bytes).");
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)
@@ -480,9 +479,8 @@ async fn main() -> anyhow::Result<()> {
// 始终注册默认 Claude provider兼容旧配置
{
let mut claude = erp_ai::provider::claude::ClaudeProvider::new(
config.ai.api_key.clone(),
);
let mut claude =
erp_ai::provider::claude::ClaudeProvider::new(config.ai.api_key.clone());
if let Some(ref base_url) = config.ai.base_url {
claude = claude.with_base_url(base_url.clone());
}
@@ -496,22 +494,31 @@ async fn main() -> anyhow::Result<()> {
}
match pcfg.provider_type.as_str() {
"openai" => {
let api_key = pcfg.api_key_env.as_ref()
let api_key = pcfg
.api_key_env
.as_ref()
.and_then(|env| std::env::var(env).ok())
.unwrap_or_default();
let base_url = pcfg.base_url.clone()
let base_url = pcfg
.base_url
.clone()
.unwrap_or_else(|| "https://api.openai.com".to_string());
let provider = erp_ai::provider::openai::OpenAIProvider::new(
api_key, base_url, pcfg.default_model.clone(),
api_key,
base_url,
pcfg.default_model.clone(),
);
registry.register(name.clone(), std::sync::Arc::new(provider));
tracing::info!(provider = %name, "已注册 OpenAI 兼容提供商");
}
"ollama" => {
let base_url = pcfg.base_url.clone()
let base_url = pcfg
.base_url
.clone()
.unwrap_or_else(|| "http://localhost:11434".to_string());
let provider = erp_ai::provider::ollama::OllamaProvider::new(
base_url, pcfg.default_model.clone(),
base_url,
pcfg.default_model.clone(),
);
registry.register(name.clone(), std::sync::Arc::new(provider));
tracing::info!(provider = %name, "已注册 Ollama 本地提供商");
@@ -528,46 +535,58 @@ async fn main() -> anyhow::Result<()> {
tracing::info!(providers = ?registry.provider_names(), "AI Provider 注册完成");
// 根据 default_provider 配置构建 AnalysisService 的默认 provider
let default_provider: Box<dyn erp_ai::provider::AiProvider> =
match config.ai.default_provider.as_str() {
"ollama" => {
let pcfg = config.ai.providers.get("ollama");
let base_url = pcfg.and_then(|c| c.base_url.clone())
.unwrap_or_else(|| "http://localhost:11434".to_string());
let model = pcfg.map(|c| c.default_model.clone())
.unwrap_or_else(|| config.ai.model.clone());
tracing::info!(base_url = %base_url, model = %model, "AnalysisService 使用 Ollama 提供商");
Box::new(erp_ai::provider::ollama::OllamaProvider::new(base_url, model))
let default_provider: Box<dyn erp_ai::provider::AiProvider> = match config
.ai
.default_provider
.as_str()
{
"ollama" => {
let pcfg = config.ai.providers.get("ollama");
let base_url = pcfg
.and_then(|c| c.base_url.clone())
.unwrap_or_else(|| "http://localhost:11434".to_string());
let model = pcfg
.map(|c| c.default_model.clone())
.unwrap_or_else(|| config.ai.model.clone());
tracing::info!(base_url = %base_url, model = %model, "AnalysisService 使用 Ollama 提供商");
Box::new(erp_ai::provider::ollama::OllamaProvider::new(
base_url, model,
))
}
"openai" => {
let pcfg = config.ai.providers.get("openai");
let api_key = pcfg
.and_then(|c| c.api_key_env.as_ref())
.and_then(|env| std::env::var(env).ok())
.unwrap_or_default();
let base_url = pcfg
.and_then(|c| c.base_url.clone())
.unwrap_or_else(|| "https://api.openai.com".to_string());
let model = pcfg
.map(|c| c.default_model.clone())
.unwrap_or_else(|| config.ai.model.clone());
Box::new(erp_ai::provider::openai::OpenAIProvider::new(
api_key, base_url, model,
))
}
_ => {
// 默认 Claude
let mut claude =
erp_ai::provider::claude::ClaudeProvider::new(config.ai.api_key.clone());
if let Some(ref base_url) = config.ai.base_url {
claude = claude.with_base_url(base_url.clone());
}
"openai" => {
let pcfg = config.ai.providers.get("openai");
let api_key = pcfg.and_then(|c| c.api_key_env.as_ref())
.and_then(|env| std::env::var(env).ok())
.unwrap_or_default();
let base_url = pcfg.and_then(|c| c.base_url.clone())
.unwrap_or_else(|| "https://api.openai.com".to_string());
let model = pcfg.map(|c| c.default_model.clone())
.unwrap_or_else(|| config.ai.model.clone());
Box::new(erp_ai::provider::openai::OpenAIProvider::new(api_key, base_url, model))
}
_ => {
// 默认 Claude
let mut claude = erp_ai::provider::claude::ClaudeProvider::new(
config.ai.api_key.clone(),
);
if let Some(ref base_url) = config.ai.base_url {
claude = claude.with_base_url(base_url.clone());
}
Box::new(claude)
}
};
Box::new(claude)
}
};
let analysis_svc = erp_ai::service::analysis::AnalysisService::new(
default_provider,
db.clone(),
).with_knowledge_source(std::sync::Arc::new(
erp_ai::knowledge::structured_source::StructuredKnowledgeSource::new(db.clone()),
));
let analysis_svc =
erp_ai::service::analysis::AnalysisService::new(default_provider, db.clone())
.with_knowledge_source(std::sync::Arc::new(
erp_ai::knowledge::structured_source::StructuredKnowledgeSource::new(
db.clone(),
),
));
let analysis = std::sync::Arc::new(analysis_svc);
let prompt = std::sync::Arc::new(erp_ai::service::prompt::PromptService::new(db.clone()));
let usage = std::sync::Arc::new(erp_ai::service::usage::UsageService::new(db.clone()));
@@ -684,6 +703,9 @@ async fn main() -> anyhow::Result<()> {
"/analytics/batch",
axum::routing::post(handlers::analytics::batch),
)
.layer(axum::middleware::from_fn(
middleware::frozen_module::frozen_module_middleware,
))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
middleware::rate_limit::rate_limit_by_user,
@@ -716,9 +738,15 @@ async fn main() -> anyhow::Result<()> {
let secret = secret_for_uploads.clone();
async move { upload_auth_middleware(secret, req, next).await }
}));
let fhir_routes = erp_health::HealthModule::fhir_routes().with_state(state.clone());
let app = Router::new()
.nest("/api/v1", unthrottled_routes.merge(public_routes).merge(protected_routes))
.nest("/fhir", erp_health::HealthModule::fhir_routes().with_state(state.clone()))
.nest(
"/api/v1",
unthrottled_routes
.merge(public_routes)
.merge(protected_routes)
.nest("/fhir", fhir_routes),
)
.nest(
"/health/gateway",
erp_health::HealthModule::gateway_routes()
@@ -729,7 +757,9 @@ async fn main() -> anyhow::Result<()> {
.with_state(state.clone()),
)
.nest("/uploads", uploads_router)
.layer(axum::middleware::from_fn(middleware::metrics::metrics_middleware))
.layer(axum::middleware::from_fn(
middleware::metrics::metrics_middleware,
))
.layer(cors);
// Start Prometheus metrics exporter on a separate port
@@ -811,7 +841,9 @@ fn build_cors_layer(allowed_origins: &str) -> tower_http::cors::CorsLayer {
#[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.");
panic!(
"Refusing to start with CORS wildcard in release mode. Set ERP__CORS__ALLOWED_ORIGINS to specific domains."
);
}
#[cfg(debug_assertions)]
{
@@ -879,6 +911,7 @@ async fn shutdown_signal() {
/// 对每个模块的 `permissions()` 返回的权限执行 upsert
/// - 新权限INSERT
/// - 已有权限(同 tenant_id + code跳过
///
/// 同时将新权限分配给 admin 角色。
async fn sync_module_permissions(
db: &sea_orm::DatabaseConnection,
@@ -906,7 +939,7 @@ async fn sync_module_permissions(
perm.code.clone().into(),
perm.name.clone().into(),
perm.module.clone().into(),
perm.code.split('.').last().unwrap_or("manage").into(),
perm.code.split('.').next_back().unwrap_or("manage").into(),
perm.description.clone().into(),
system_user_id.into(),
],
@@ -932,7 +965,10 @@ async fn sync_module_permissions(
)).await?;
if total_new > 0 {
tracing::info!(total_new, "New module permissions synced and bound to admin role");
tracing::info!(
total_new,
"New module permissions synced and bound to admin role"
);
}
Ok(())

View 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
}

View File

@@ -75,9 +75,13 @@ pub fn start_metrics_server(port: u16) {
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),
)
axum::response::IntoResponse::into_response((
[(
axum::http::header::CONTENT_TYPE,
"text/plain; version=0.0.4",
)],
body,
))
}
}),
)

View File

@@ -1,3 +1,4 @@
pub mod frozen_module;
pub mod metrics;
pub mod rate_limit;
pub mod tenant_rls;

View File

@@ -1,6 +1,3 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
use axum::body::Body;
use axum::extract::State;
use axum::http::{Request, StatusCode};
@@ -8,7 +5,6 @@ use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use redis::AsyncCommands;
use serde::Serialize;
use tokio::sync::Mutex;
use crate::state::AppState;
@@ -23,64 +19,6 @@ struct RateLimitResponse {
const ACCOUNT_LOCKOUT_MAX_FAILURES: i64 = 5;
const ACCOUNT_LOCKOUT_TTL_SECS: i64 = 900; // 15 分钟
/// 限流参数(预留配置化扩展)。
#[allow(dead_code)]
pub struct RateLimitConfig {
/// 窗口内最大请求数。
pub max_requests: u64,
/// 窗口大小(秒)。
pub window_secs: u64,
/// Redis key 前缀。
pub key_prefix: String,
}
/// Redis 可用性状态缓存,避免重复连接失败时阻塞。
struct RedisAvailability {
available: AtomicBool,
last_check: Mutex<Instant>,
}
impl RedisAvailability {
fn new() -> Self {
Self {
available: AtomicBool::new(true),
last_check: Mutex::new(Instant::now() - std::time::Duration::from_secs(60)),
}
}
/// 检查是否应该尝试连接 Redis。
/// 如果上次连接失败且冷却期未过,返回 false。
async fn should_try(&self) -> bool {
if self.available.load(Ordering::Relaxed) {
return true;
}
let mut last = self.last_check.lock().await;
// 连接失败后冷却 30 秒再重试
if last.elapsed() > std::time::Duration::from_secs(30) {
*last = Instant::now();
true
} else {
false
}
}
fn mark_ok(&self) {
self.available.store(true, Ordering::Relaxed);
}
async fn mark_failed(&self) {
self.available.store(false, Ordering::Relaxed);
*self.last_check.lock().await = Instant::now();
}
}
/// 全局 Redis 可用性缓存
static REDIS_AVAIL: std::sync::OnceLock<RedisAvailability> = std::sync::OnceLock::new();
fn redis_avail() -> &'static RedisAvailability {
REDIS_AVAIL.get_or_init(RedisAvailability::new)
}
/// 基于 Redis 的 IP 限流中间件。
///
/// 使用 INCR + EXPIRE 实现固定窗口计数器。
@@ -91,8 +29,7 @@ pub async fn rate_limit_by_ip(
next: Next,
) -> Response {
let identifier = extract_client_ip(req.headers());
let fail_close = state.config.rate_limit.fail_close;
apply_rate_limit(&state.redis, &identifier, 5, 60, "login", fail_close, req, next).await
apply_rate_limit(&state.redis, &identifier, 5, 60, "login", req, next).await
}
/// 基于 Redis 的用户限流中间件。
@@ -108,8 +45,7 @@ pub async fn rate_limit_by_user(
.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(&state.redis, &identifier, 100, 60, "write", fail_close, req, next).await
apply_rate_limit(&state.redis, &identifier, 300, 60, "api", req, next).await
}
/// 执行限流检查。
@@ -119,42 +55,15 @@ async fn apply_rate_limit(
max_requests: u64,
window_secs: u64,
prefix: &str,
fail_close: bool,
req: Request<Body>,
next: Next,
) -> Response {
let avail = redis_avail();
// Redis 不可达时根据 fail_close 配置决定行为
if !avail.should_try().await {
if fail_close {
tracing::error!("Redis 不可达fail-close 拒绝请求 [{}]", prefix);
return (StatusCode::SERVICE_UNAVAILABLE, axum::Json(RateLimitResponse {
error: "service_unavailable".to_string(),
message: "安全服务暂不可用,请稍后重试".to_string(),
})).into_response();
}
tracing::warn!("Redis 不可达fail-open 限流放行 [{}]", prefix);
return next.run(req).await;
}
let key = format!("rate_limit:{}:{}", prefix, identifier);
let mut conn = match redis_client.get_multiplexed_async_connection().await {
Ok(c) => {
avail.mark_ok();
c
}
Ok(c) => c,
Err(e) => {
avail.mark_failed().await;
if fail_close {
tracing::error!(error = %e, "Redis 连接失败fail-close 拒绝请求 [{}]", prefix);
return (StatusCode::SERVICE_UNAVAILABLE, axum::Json(RateLimitResponse {
error: "service_unavailable".to_string(),
message: "安全服务暂不可用,请稍后重试".to_string(),
})).into_response();
}
tracing::warn!(error = %e, "Redis 连接失败fail-open 限流放行 [{}]", prefix);
tracing::error!(error = %e, "Redis 连接失败,限流放行 [{}]", prefix);
return next.run(req).await;
}
};
@@ -162,14 +71,7 @@ async fn apply_rate_limit(
let count: i64 = match redis::cmd("INCR").arg(&key).query_async(&mut conn).await {
Ok(n) => n,
Err(e) => {
if fail_close {
tracing::error!(error = %e, "Redis INCR 失败fail-close 拒绝请求 [{}]", prefix);
return (StatusCode::SERVICE_UNAVAILABLE, axum::Json(RateLimitResponse {
error: "service_unavailable".to_string(),
message: "安全服务暂不可用,请稍后重试".to_string(),
})).into_response();
}
tracing::warn!(error = %e, "Redis INCR 失败fail-open 限流放行 [{}]", prefix);
tracing::error!(error = %e, "Redis INCR 失败,限流放行 [{}]", prefix);
return next.run(req).await;
}
};
@@ -202,39 +104,11 @@ pub async fn account_lockout_middleware(
req: Request<Body>,
next: Next,
) -> Response {
let avail = redis_avail();
// Redis 可达性检查:生产环境 fail-close开发环境 fail-open
let fail_close = state.config.rate_limit.fail_close;
if !avail.should_try().await {
if fail_close {
tracing::error!("Redis 不可达fail-close 拒绝登录请求");
return (StatusCode::SERVICE_UNAVAILABLE, axum::Json(RateLimitResponse {
error: "service_unavailable".to_string(),
message: "安全服务暂不可用,请稍后重试".to_string(),
})).into_response();
}
tracing::error!("Redis 不可达fail-open 放行(非生产模式,建议设置 ERP__RATE_LIMIT__FAIL_CLOSE=true");
return next.run(req).await;
}
// 获取 Redis 连接
let mut conn = match state.redis.get_multiplexed_async_connection().await {
Ok(c) => {
avail.mark_ok();
c
}
Ok(c) => c,
Err(e) => {
avail.mark_failed().await;
if fail_close {
tracing::error!(error = %e, "Redis 连接失败fail-close 拒绝登录请求");
return (StatusCode::SERVICE_UNAVAILABLE, axum::Json(RateLimitResponse {
error: "service_unavailable".to_string(),
message: "安全服务暂不可用,请稍后重试".to_string(),
})).into_response();
}
tracing::error!(error = %e, "Redis 连接失败fail-open 放行(非生产模式)");
tracing::error!(error = %e, "Redis 连接失败,登录锁定放行");
return next.run(req).await;
}
};
@@ -245,7 +119,6 @@ pub async fn account_lockout_middleware(
Ok(b) => b,
Err(e) => {
tracing::warn!(error = %e, "读取登录请求体失败,放行");
// 无法读取 body重建请求放行
let req = Request::from_parts(parts, Body::from(Vec::new()));
return next.run(req).await;
}
@@ -259,7 +132,6 @@ pub async fn account_lockout_middleware(
let username = match username {
Some(u) if !u.is_empty() => u,
_ => {
// 无法解析 username用原始 body 重建请求放行
let req = Request::from_parts(parts, Body::from(bytes.to_vec()));
return next.run(req).await;
}
@@ -290,7 +162,6 @@ pub async fn account_lockout_middleware(
let status = response.status();
let (parts, body) = response.into_parts();
// 需要读取 body 以重建响应(因为 into_parts 消费了 body
let body_bytes = axum::body::to_bytes(body, 1024 * 1024)
.await
.unwrap_or_default();
@@ -305,7 +176,6 @@ pub async fn account_lockout_middleware(
Ok(n) => n,
Err(e) => {
tracing::warn!(error = %e, "Redis INCR 失败计数失败");
// 即使计数失败,也返回原始 401 响应
let resp = Response::from_parts(parts, Body::from(body_bytes.to_vec()));
return resp;
}
@@ -329,8 +199,8 @@ pub async fn account_lockout_middleware(
}
// 重建并返回原始响应
let resp = Response::from_parts(parts, Body::from(body_bytes.to_vec()));
resp
Response::from_parts(parts, Body::from(body_bytes.to_vec()))
}
/// 从请求头中提取客户端 IP。

View File

@@ -18,7 +18,10 @@ pub async fn tenant_rls_middleware(
req: Request<Body>,
next: Next,
) -> Response {
let tenant_id = req.extensions().get::<TenantContext>().map(|ctx| ctx.tenant_id);
let tenant_id = req
.extensions()
.get::<TenantContext>()
.map(|ctx| ctx.tenant_id);
if let Some(tid) = tenant_id {
// SET app.current_tenant_id — RLS 策略读取此值(参数化查询防止注入)
@@ -37,13 +40,10 @@ pub async fn tenant_rls_middleware(
let response = next.run(req).await;
// RESET — 防止连接池复用时泄漏租户上下文
if tenant_id.is_some() {
if let Err(e) = db
.execute_unprepared("RESET app.current_tenant_id")
.await
{
tracing::debug!(error = %e, "RESET app.current_tenant_id 失败(非致命)");
}
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

View File

@@ -1,5 +1,7 @@
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
};
use sqlx::postgres::PgListener;
use std::time::Duration;

View File

@@ -27,10 +27,7 @@ async fn run_cleanup(db: &sea_orm::DatabaseConnection) -> Result<(), sea_orm::Db
.await
{
Ok(result) => {
tracing::info!(
rows_affected = result.rows_affected(),
"已发布事件归档完成"
);
tracing::info!(rows_affected = result.rows_affected(), "已发布事件归档完成");
}
Err(e) => tracing::warn!(error = %e, "已发布事件归档失败"),
}
@@ -41,10 +38,7 @@ async fn run_cleanup(db: &sea_orm::DatabaseConnection) -> Result<(), sea_orm::Db
.await
{
Ok(result) => {
tracing::info!(
rows_affected = result.rows_affected(),
"去重记录清理完成"
);
tracing::info!(rows_affected = result.rows_affected(), "去重记录清理完成");
}
Err(e) => tracing::warn!(error = %e, "去重记录清理失败"),
}