feat: Iteration 1 — 审计日志IP记录、文件上传、医护端API、小程序角色切换
Iteration 1 六项任务全部完成: 1. 审计日志IP记录 — task_local RequestInfo 自动注入 IP/user_agent 2. 文件上传服务 — multipart 上传 + ServeDir 静态文件服务 3. 医护端后端API — 医生工作台仪表盘 + 患者标签CRUD + 会话已读 4. 小程序角色切换 — 登录后根据角色跳转医护台/患者首页 5. 小程序安全加固 — secure-storage 开发模式警告 6. 讨论记录归档 — docs/discussions/
This commit is contained in:
@@ -3,6 +3,8 @@ use axum::http::Request;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::request_info::REQUEST_INFO;
|
||||
use erp_core::request_info::RequestInfo;
|
||||
use erp_core::types::TenantContext;
|
||||
|
||||
use crate::service::token_service::TokenService;
|
||||
@@ -13,6 +15,9 @@ use crate::service::token_service::TokenService;
|
||||
/// using `TokenService::decode_token`, and injects a `TenantContext` into the
|
||||
/// request extensions so downstream handlers can access tenant/user identity.
|
||||
///
|
||||
/// 同时提取请求的 IP 地址和 User-Agent,通过 task_local 传递给审计服务,
|
||||
/// 使所有审计日志自动记录来源信息。
|
||||
///
|
||||
/// The `jwt_secret` parameter is passed explicitly by the server crate at
|
||||
/// middleware construction time, avoiding any circular dependency between
|
||||
/// erp-auth and erp-server.
|
||||
@@ -58,6 +63,9 @@ pub async fn jwt_auth_middleware_fn(
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
// 提取请求来源信息(IP + User-Agent),用于审计日志
|
||||
let request_info = RequestInfo::from_headers(req.headers());
|
||||
|
||||
let ctx = TenantContext {
|
||||
tenant_id: claims.tid,
|
||||
user_id: claims.sub,
|
||||
@@ -72,7 +80,8 @@ pub async fn jwt_auth_middleware_fn(
|
||||
let mut req = Request::from_parts(parts, body);
|
||||
req.extensions_mut().insert(ctx);
|
||||
|
||||
Ok(next.run(req).await)
|
||||
// 在 task_local scope 中运行后续处理,审计服务可自动读取请求信息
|
||||
Ok(REQUEST_INFO.scope(request_info, next.run(req)).await)
|
||||
}
|
||||
|
||||
/// 查询用户所属的所有部门 ID(通过 user_departments 关联表)
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
use crate::audit::AuditLog;
|
||||
use crate::entity::audit_log;
|
||||
use crate::request_info::RequestInfo;
|
||||
use sea_orm::{ActiveModelTrait, Set};
|
||||
use tracing;
|
||||
|
||||
/// 持久化审计日志到 audit_logs 表。
|
||||
///
|
||||
/// 使用 fire-and-forget 模式:失败仅记录 warning 日志,不影响业务操作。
|
||||
pub async fn record(log: AuditLog, db: &sea_orm::DatabaseConnection) {
|
||||
///
|
||||
/// 自动从 task_local 读取当前请求的 IP 和 User-Agent,
|
||||
/// 如果 AuditLog 中已有 ip_address/user_agent 则不覆盖。
|
||||
pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) {
|
||||
// 自动填充请求来源信息(仅当调用方未显式设置时)
|
||||
if log.ip_address.is_none() || log.user_agent.is_none() {
|
||||
if let Some(info) = RequestInfo::try_current() {
|
||||
if log.ip_address.is_none() {
|
||||
log.ip_address = info.ip_address;
|
||||
}
|
||||
if log.user_agent.is_none() {
|
||||
log.user_agent = info.user_agent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let model = audit_log::ActiveModel {
|
||||
id: Set(log.id),
|
||||
tenant_id: Set(log.tenant_id),
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
pub mod audit;
|
||||
pub mod audit_service;
|
||||
pub mod crypto;
|
||||
pub mod entity;
|
||||
pub mod error;
|
||||
pub mod events;
|
||||
pub mod health_provider;
|
||||
pub mod module;
|
||||
pub mod rbac;
|
||||
pub mod request_info;
|
||||
pub mod sanitize;
|
||||
pub mod types;
|
||||
|
||||
|
||||
54
crates/erp-core/src/request_info.rs
Normal file
54
crates/erp-core/src/request_info.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
/// 请求来源信息(IP 地址 + User-Agent)。
|
||||
///
|
||||
/// 通过 `tokio::task_local!` 在请求生命周期内传递,
|
||||
/// JWT 中间件设置,审计服务自动读取。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RequestInfo {
|
||||
pub ip_address: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
}
|
||||
|
||||
tokio::task_local! {
|
||||
/// 当前请求的来源信息。
|
||||
///
|
||||
/// 在 JWT 中间件中通过 `REQUEST_INFO.scope(info, future)` 设置,
|
||||
/// 在 `audit_service::record()` 中自动读取。
|
||||
pub static REQUEST_INFO: RequestInfo;
|
||||
}
|
||||
|
||||
impl RequestInfo {
|
||||
/// 从 HTTP 请求头中提取 IP 地址和 User-Agent。
|
||||
///
|
||||
/// IP 优先级:X-Forwarded-For > X-Real-IP > 直接连接(不记录)。
|
||||
pub fn from_headers(headers: &axum::http::HeaderMap) -> Self {
|
||||
let ip_address = headers
|
||||
.get("X-Forwarded-For")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| {
|
||||
// X-Forwarded-For 可能包含多个 IP,取第一个(客户端真实 IP)
|
||||
s.split(',').next().unwrap_or(s).trim().to_string()
|
||||
})
|
||||
.or_else(|| {
|
||||
headers
|
||||
.get("X-Real-IP")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
});
|
||||
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Self {
|
||||
ip_address,
|
||||
user_agent,
|
||||
}
|
||||
}
|
||||
|
||||
/// 尝试从 task_local 中读取当前请求信息。
|
||||
/// 如果不在请求上下文中(如后台任务),返回 None。
|
||||
pub fn try_current() -> Option<Self> {
|
||||
REQUEST_INFO.try_with(|info| info.clone()).ok()
|
||||
}
|
||||
}
|
||||
@@ -188,3 +188,64 @@ where
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
/// 标记会话消息为已读。
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/consultation-sessions/{id}/read",
|
||||
responses(
|
||||
(status = 200, description = "标记成功"),
|
||||
(status = 404, description = "会话不存在"),
|
||||
),
|
||||
tag = "咨询管理",
|
||||
)]
|
||||
pub async fn mark_session_read<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.consultation.manage")?;
|
||||
let is_doctor = crate::entity::doctor_profile::Entity::find()
|
||||
.filter(crate::entity::doctor_profile::Column::UserId.eq(ctx.user_id))
|
||||
.filter(crate::entity::doctor_profile::Column::TenantId.eq(ctx.tenant_id))
|
||||
.filter(crate::entity::doctor_profile::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?
|
||||
.is_some();
|
||||
let role = if is_doctor { "doctor" } else { "patient" };
|
||||
consultation_service::mark_session_read(
|
||||
&state, ctx.tenant_id, id, ctx.user_id, role,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
/// 获取当前医生的仪表盘数据。
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/doctor/dashboard",
|
||||
responses(
|
||||
(status = 200, description = "仪表盘数据"),
|
||||
),
|
||||
tag = "医护端",
|
||||
)]
|
||||
pub async fn get_doctor_dashboard<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<consultation_service::DoctorDashboard>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.consultation.list")?;
|
||||
let result = consultation_service::get_doctor_dashboard(
|
||||
&state, ctx.tenant_id, ctx.user_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -319,3 +319,74 @@ where
|
||||
let tags = patient_service::list_tags(&state, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(tags)))
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct CreateTagReq {
|
||||
pub name: String,
|
||||
pub color: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_tag<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreateTagReq>,
|
||||
) -> Result<Json<ApiResponse<patient_service::TagResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.patient.manage")?;
|
||||
let result = patient_service::create_tag(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id),
|
||||
patient_service::CreateTagReq {
|
||||
name: req.name, color: req.color, description: req.description,
|
||||
},
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdateTagWithVersion {
|
||||
pub name: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
pub async fn update_tag<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateTagWithVersion>,
|
||||
) -> Result<Json<ApiResponse<patient_service::TagResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.patient.manage")?;
|
||||
let result = patient_service::update_tag(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id),
|
||||
patient_service::UpdateTagReq {
|
||||
name: req.name, color: req.color, description: req.description, version: req.version,
|
||||
},
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn delete_tag<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<DeleteWithVersion>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.patient.manage")?;
|
||||
patient_service::delete_tag(
|
||||
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -93,7 +93,13 @@ impl HealthModule {
|
||||
)
|
||||
.route(
|
||||
"/health/patient-tags",
|
||||
axum::routing::get(patient_handler::list_tags),
|
||||
axum::routing::get(patient_handler::list_tags)
|
||||
.post(patient_handler::create_tag),
|
||||
)
|
||||
.route(
|
||||
"/health/patient-tags/{id}",
|
||||
axum::routing::put(patient_handler::update_tag)
|
||||
.delete(patient_handler::delete_tag),
|
||||
)
|
||||
.route(
|
||||
"/health/patients/{id}/health-summary",
|
||||
@@ -289,10 +295,19 @@ impl HealthModule {
|
||||
"/health/consultation-sessions/{id}/close",
|
||||
axum::routing::put(consultation_handler::close_session),
|
||||
)
|
||||
.route(
|
||||
"/health/consultation-sessions/{id}/read",
|
||||
axum::routing::put(consultation_handler::mark_session_read),
|
||||
)
|
||||
.route(
|
||||
"/health/consultation-messages",
|
||||
axum::routing::post(consultation_handler::create_message),
|
||||
)
|
||||
// 医生仪表盘
|
||||
.route(
|
||||
"/health/doctor/dashboard",
|
||||
axum::routing::get(consultation_handler::get_doctor_dashboard),
|
||||
)
|
||||
// 医护管理
|
||||
.route(
|
||||
"/health/doctors",
|
||||
|
||||
@@ -33,6 +33,9 @@ secret = "__MUST_SET_VIA_ENV__"
|
||||
aes_key = "__MUST_SET_VIA_ENV__"
|
||||
hmac_key = "__MUST_SET_VIA_ENV__"
|
||||
|
||||
[crypto]
|
||||
kek = "__MUST_SET_VIA_ENV__"
|
||||
|
||||
[ai]
|
||||
default_provider = "claude"
|
||||
api_key = ""
|
||||
@@ -42,3 +45,7 @@ max_tokens = 2048
|
||||
temperature = 0.3
|
||||
cache_ttl_seconds = 604800
|
||||
rate_limit_patient_daily = 10
|
||||
|
||||
[storage]
|
||||
upload_dir = "./uploads"
|
||||
max_file_size = "10MB"
|
||||
|
||||
@@ -11,7 +11,9 @@ pub struct AppConfig {
|
||||
pub cors: CorsConfig,
|
||||
pub wechat: WechatConfig,
|
||||
pub health: HealthConfig,
|
||||
pub crypto: CryptoConfig,
|
||||
pub ai: AiConfig,
|
||||
pub storage: StorageConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -70,6 +72,13 @@ pub struct HealthConfig {
|
||||
pub hmac_key: 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 AiConfig {
|
||||
pub default_provider: String,
|
||||
@@ -82,6 +91,30 @@ pub struct AiConfig {
|
||||
pub rate_limit_patient_daily: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct StorageConfig {
|
||||
/// 文件上传目录(本地存储)
|
||||
pub upload_dir: String,
|
||||
/// 单文件最大大小(如 "10MB")
|
||||
pub max_file_size: String,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn load() -> anyhow::Result<Self> {
|
||||
let config = config::Config::builder()
|
||||
|
||||
@@ -2,3 +2,4 @@ pub mod audit_log;
|
||||
pub mod crypto_admin;
|
||||
pub mod health;
|
||||
pub mod openapi;
|
||||
pub mod upload;
|
||||
|
||||
148
crates/erp-server/src/handlers/upload.rs
Normal file
148
crates/erp-server/src/handlers/upload.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
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)
|
||||
)));
|
||||
}
|
||||
|
||||
// 生成唯一文件名,保留原始扩展名
|
||||
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(())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,7 @@ 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;
|
||||
|
||||
@@ -509,6 +510,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
.merge(erp_health::HealthModule::protected_routes())
|
||||
.merge(erp_ai::AiModule::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),
|
||||
@@ -530,8 +535,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
// 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 app = Router::new()
|
||||
.nest("/api/v1", public_routes.merge(protected_routes))
|
||||
.nest_service("/uploads", ServeDir::new(&upload_dir))
|
||||
.layer(cors);
|
||||
|
||||
let addr = format!("{}:{}", host, port);
|
||||
|
||||
Reference in New Issue
Block a user