fix: 修复测试发现的 7 个问题 + 全 workspace clippy 清零
功能修复: 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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
///
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(())
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod frozen_module;
|
||||
pub mod metrics;
|
||||
pub mod rate_limit;
|
||||
pub mod tenant_rls;
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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, "去重记录清理失败"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user