feat: initialize ERP base platform (extracted from HMS)
- Stripped 11 business crates (health, ai, dialysis, plugins) - Cleaned AppState, AppConfig, main.rs from business coupling - Reduced migrations from 169 to 53 (base-only) - Removed health_provider trait from erp-core - Removed business integration tests - Removed gateway rate limiting middleware - Base capabilities: auth, RBAC, JWT, config, workflow, message, plugin, audit, crypto, RLS, multi-tenant Cargo check: OK Cargo test: OK
This commit is contained in:
65
crates/erp-server/src/handlers/analytics.rs
Normal file
65
crates/erp-server/src/handlers/analytics.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use axum::Json;
|
||||
use axum::extract::Extension;
|
||||
use serde::Deserialize;
|
||||
use tracing;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
|
||||
const MAX_EVENTS_PER_BATCH: usize = 100;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)] // 客户端上报结构体,字段后续接入分析表时使用
|
||||
pub struct AnalyticsEvent {
|
||||
pub event: String,
|
||||
pub properties: Option<serde_json::Value>,
|
||||
#[serde(deserialize_with = "deserialize_flexible_timestamp")]
|
||||
pub timestamp: Option<String>,
|
||||
pub page: Option<String>,
|
||||
pub user_id: Option<String>,
|
||||
pub patient_id: Option<String>,
|
||||
}
|
||||
|
||||
fn deserialize_flexible_timestamp<'de, D>(de: D) -> Result<Option<String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de;
|
||||
let val = Option::<serde_json::Value>::deserialize(de)?;
|
||||
match val {
|
||||
None => Ok(None),
|
||||
Some(serde_json::Value::String(s)) => Ok(Some(s)),
|
||||
Some(serde_json::Value::Number(n)) => Ok(Some(n.to_string())),
|
||||
_ => Err(de::Error::custom("timestamp must be string or number")),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BatchRequest {
|
||||
pub events: Vec<AnalyticsEvent>,
|
||||
}
|
||||
|
||||
/// 接收小程序批量埋点事件。
|
||||
/// 当前为日志记录模式 — 后续可接入 ClickHouse/PostgreSQL 分析表。
|
||||
pub async fn batch(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<BatchRequest>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
require_permission(&ctx, "system.analytics.submit")?;
|
||||
if req.events.len() > MAX_EVENTS_PER_BATCH {
|
||||
return Err(AppError::Validation(format!(
|
||||
"批量埋点事件数不能超过 {} 条",
|
||||
MAX_EVENTS_PER_BATCH
|
||||
)));
|
||||
}
|
||||
for evt in &req.events {
|
||||
tracing::info!(
|
||||
event = %evt.event,
|
||||
page = ?evt.page,
|
||||
properties = ?evt.properties,
|
||||
"Analytics event received"
|
||||
);
|
||||
}
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
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))
|
||||
}
|
||||
6
crates/erp-server/src/handlers/mod.rs
Normal file
6
crates/erp-server/src/handlers/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod analytics;
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user