feat: Iteration 1 — 审计日志IP记录、文件上传、医护端API、小程序角色切换
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

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:
iven
2026-04-26 13:13:25 +08:00
parent 1326b3e504
commit a0b72b0f73
21 changed files with 679 additions and 12 deletions

View File

@@ -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()

View File

@@ -2,3 +2,4 @@ pub mod audit_log;
pub mod crypto_admin;
pub mod health;
pub mod openapi;
pub mod upload;

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

View File

@@ -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);