diff --git a/Cargo.lock b/Cargo.lock index 3c34df6..29f0902 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2106,6 +2106,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -2692,6 +2698,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -4930,7 +4946,13 @@ dependencies = [ "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", "tokio", "tokio-util", @@ -5045,6 +5067,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/Cargo.toml b/Cargo.toml index 7dd1ed6..d50adfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ tokio = { version = "1", features = ["full"] } # Web axum = { version = "0.8", features = ["multipart"] } tower = "0.5" -tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] } +tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "fs"] } # Database sea-orm = { version = "1.1", features = [ diff --git a/apps/miniprogram/src/app.config.ts b/apps/miniprogram/src/app.config.ts index fe14b45..c0449c7 100644 --- a/apps/miniprogram/src/app.config.ts +++ b/apps/miniprogram/src/app.config.ts @@ -29,6 +29,7 @@ export default defineAppConfig({ 'pages/profile/settings/index', 'pages/legal/user-agreement', 'pages/legal/privacy-policy', + 'pages/doctor/index', ], tabBar: { color: '#94A3B8', diff --git a/apps/miniprogram/src/pages/doctor/index.scss b/apps/miniprogram/src/pages/doctor/index.scss new file mode 100644 index 0000000..08674a3 --- /dev/null +++ b/apps/miniprogram/src/pages/doctor/index.scss @@ -0,0 +1,71 @@ +.doctor-home { + min-height: 100vh; + background: #f0f4f8; + padding: 32px; + + &__header { + margin-bottom: 40px; + } + + &__title { + font-size: 40px; + font-weight: 700; + color: #0f172a; + display: block; + margin-bottom: 16px; + } + + &__greeting { + font-size: 28px; + color: #64748b; + } + + &__section { + margin-bottom: 32px; + } + + &__section-title { + font-size: 30px; + font-weight: 600; + color: #334155; + display: block; + margin-bottom: 24px; + } + + &__grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + } + + &__card { + background: #fff; + border-radius: 16px; + padding: 32px; + text-align: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + } + + &__card-num { + font-size: 48px; + font-weight: 700; + color: #0891b2; + display: block; + margin-bottom: 8px; + } + + &__card-label { + font-size: 24px; + color: #64748b; + } + + &__footer { + margin-top: 80px; + text-align: center; + } + + &__logout { + color: #ef4444; + font-size: 28px; + } +} diff --git a/apps/miniprogram/src/pages/doctor/index.tsx b/apps/miniprogram/src/pages/doctor/index.tsx new file mode 100644 index 0000000..36eadf1 --- /dev/null +++ b/apps/miniprogram/src/pages/doctor/index.tsx @@ -0,0 +1,48 @@ +import { View, Text } from '@tarojs/components'; +import { useAuthStore } from '@/stores/auth'; +import Taro from '@tarojs/taro'; +import './index.scss'; + +export default function DoctorHome() { + const { user, logout } = useAuthStore(); + + const handleLogout = () => { + logout(); + Taro.redirectTo({ url: '/pages/login/index' }); + }; + + return ( + + + 医护工作台 + {user?.display_name || user?.username || '医生'},您好 + + + + 工作概览 + + Taro.showToast({ title: '开发中', icon: 'none' })}> + - + 待回复咨询 + + Taro.showToast({ title: '开发中', icon: 'none' })}> + - + 待处理随访 + + Taro.showToast({ title: '开发中', icon: 'none' })}> + - + 今日咨询 + + Taro.showToast({ title: '开发中', icon: 'none' })}> + - + 我的患者 + + + + + + 退出登录 + + + ); +} diff --git a/apps/miniprogram/src/pages/login/index.tsx b/apps/miniprogram/src/pages/login/index.tsx index af2176b..ad0efe1 100644 --- a/apps/miniprogram/src/pages/login/index.tsx +++ b/apps/miniprogram/src/pages/login/index.tsx @@ -9,6 +9,17 @@ export default function Login() { const [agreed, setAgreed] = useState(false); const { login, bindPhone, loading } = useAuthStore(); + const { login, bindPhone, loading, isMedicalStaff } = useAuthStore(); + + /** 登录/绑定成功后根据角色跳转 */ + const navigateAfterLogin = () => { + if (isMedicalStaff()) { + Taro.redirectTo({ url: '/pages/doctor/index' }); + } else { + Taro.switchTab({ url: '/pages/index/index' }); + } + }; + const handleWechatLogin = async () => { if (!agreed) { Taro.showToast({ title: '请先阅读并同意用户协议', icon: 'none' }); @@ -18,7 +29,7 @@ export default function Login() { const { code } = await Taro.login(); const result = await login(code); if (result) { - Taro.switchTab({ url: '/pages/index/index' }); + navigateAfterLogin(); } else { setNeedBind(true); Taro.showToast({ title: '请授权手机号完成绑定', icon: 'none' }); @@ -41,7 +52,7 @@ export default function Login() { const { encryptedData, iv } = e.detail; const success = await bindPhone(encryptedData, iv); if (success) { - Taro.switchTab({ url: '/pages/index/index' }); + navigateAfterLogin(); } else { Taro.showToast({ title: '绑定失败,请重试', icon: 'none' }); } diff --git a/apps/miniprogram/src/stores/auth.ts b/apps/miniprogram/src/stores/auth.ts index 04cb602..56fdb52 100644 --- a/apps/miniprogram/src/stores/auth.ts +++ b/apps/miniprogram/src/stores/auth.ts @@ -12,7 +12,8 @@ interface BindPhoneResp { interface AuthState { token: string | null; refreshToken: string | null; - user: { id: string; username: string; display_name?: string; phone?: string } | null; + user: { id: string; username: string; display_name?: string; phone?: string; tenant_id?: string } | null; + roles: string[]; currentPatient: authApi.PatientInfo | null; patients: authApi.PatientInfo[]; loading: boolean; @@ -23,22 +24,30 @@ interface AuthState { loadPatients: () => Promise; logout: () => void; restore: () => void; + isMedicalStaff: () => boolean; } export const useAuthStore = create((set, get) => ({ token: null, refreshToken: null, user: null, + roles: [], currentPatient: null, patients: [], loading: false, + isMedicalStaff: () => { + const { roles } = get(); + return roles.some((r) => r === 'doctor' || r === 'nurse' || r === 'admin'); + }, + restore: () => { const token = secureGet('access_token') || null; const refreshToken = secureGet('refresh_token') || null; const user = Taro.getStorageSync('user') || null; + const roles = Taro.getStorageSync('user_roles') || []; const currentPatient = Taro.getStorageSync('current_patient') || null; - set({ token, refreshToken, user, currentPatient }); + set({ token, refreshToken, user, roles, currentPatient }); }, login: async (code: string) => { @@ -47,11 +56,13 @@ export const useAuthStore = create((set, get) => ({ const resp = await authApi.wechatLogin(code); if (resp.bound && resp.token) { const { access_token, refresh_token, user } = resp.token; + const roles = (resp as any).roles?.map((r: any) => r.code || r.name || r) || []; secureSet('access_token', access_token); secureSet('refresh_token', refresh_token); Taro.setStorageSync('user', user); + Taro.setStorageSync('user_roles', roles); Taro.setStorageSync('tenant_id', (user as any).tenant_id || ''); - set({ token: access_token, refreshToken: refresh_token, user, loading: false }); + set({ token: access_token, refreshToken: refresh_token, user, roles, loading: false }); return true; } Taro.setStorageSync('wechat_openid', resp.openid); @@ -71,14 +82,16 @@ export const useAuthStore = create((set, get) => ({ set({ loading: false }); return false; } - const resp = await authApi.wechatBindPhone(openid, encryptedData, iv) as BindPhoneResp; + const resp = await authApi.wechatBindPhone(openid, encryptedData, iv) as any; const { access_token, refresh_token, user } = resp; + const roles = resp.roles?.map((r: any) => r.code || r.name || r) || []; secureSet('access_token', access_token); secureSet('refresh_token', refresh_token); Taro.setStorageSync('user', user); + Taro.setStorageSync('user_roles', roles); Taro.setStorageSync('tenant_id', user.tenant_id || ''); Taro.removeStorageSync('wechat_openid'); - set({ token: access_token, refreshToken: refresh_token, user, loading: false }); + set({ token: access_token, refreshToken: refresh_token, user, roles, loading: false }); return true; } catch { set({ loading: false }); @@ -108,9 +121,10 @@ export const useAuthStore = create((set, get) => ({ secureRemove('access_token'); secureRemove('refresh_token'); Taro.removeStorageSync('user'); + Taro.removeStorageSync('user_roles'); Taro.removeStorageSync('current_patient'); Taro.removeStorageSync('current_patient_id'); - set({ token: null, refreshToken: null, user: null, currentPatient: null, patients: [] }); + set({ token: null, refreshToken: null, user: null, roles: [], currentPatient: null, patients: [] }); Taro.redirectTo({ url: '/pages/login/index' }); }, })); diff --git a/apps/miniprogram/src/utils/secure-storage.ts b/apps/miniprogram/src/utils/secure-storage.ts index e7adeb0..bb1dcf0 100644 --- a/apps/miniprogram/src/utils/secure-storage.ts +++ b/apps/miniprogram/src/utils/secure-storage.ts @@ -2,6 +2,11 @@ import Taro from '@tarojs/taro'; import CryptoJS from 'crypto-js'; const ENCRYPTION_KEY = process.env.TARO_APP_ENCRYPTION_KEY || ''; +const IS_DEV = process.env.NODE_ENV !== 'production'; + +if (!ENCRYPTION_KEY && IS_DEV) { + console.warn('[secure-storage] TARO_APP_ENCRYPTION_KEY 未设置,敏感数据将以明文存储'); +} function encrypt(plaintext: string): string { if (!ENCRYPTION_KEY) return plaintext; diff --git a/crates/erp-auth/src/middleware/jwt_auth.rs b/crates/erp-auth/src/middleware/jwt_auth.rs index 985a922..4fc1cdc 100644 --- a/crates/erp-auth/src/middleware/jwt_auth.rs +++ b/crates/erp-auth/src/middleware/jwt_auth.rs @@ -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 关联表) diff --git a/crates/erp-core/src/audit_service.rs b/crates/erp-core/src/audit_service.rs index 36d97db..604c8e7 100644 --- a/crates/erp-core/src/audit_service.rs +++ b/crates/erp-core/src/audit_service.rs @@ -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), diff --git a/crates/erp-core/src/lib.rs b/crates/erp-core/src/lib.rs index ed8fd5e..afbe95c 100644 --- a/crates/erp-core/src/lib.rs +++ b/crates/erp-core/src/lib.rs @@ -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; diff --git a/crates/erp-core/src/request_info.rs b/crates/erp-core/src/request_info.rs new file mode 100644 index 0000000..d6549de --- /dev/null +++ b/crates/erp-core/src/request_info.rs @@ -0,0 +1,54 @@ +/// 请求来源信息(IP 地址 + User-Agent)。 +/// +/// 通过 `tokio::task_local!` 在请求生命周期内传递, +/// JWT 中间件设置,审计服务自动读取。 +#[derive(Debug, Clone, Default)] +pub struct RequestInfo { + pub ip_address: Option, + pub user_agent: Option, +} + +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 { + REQUEST_INFO.try_with(|info| info.clone()).ok() + } +} diff --git a/crates/erp-health/src/handler/consultation_handler.rs b/crates/erp-health/src/handler/consultation_handler.rs index c92f419..32684af 100644 --- a/crates/erp-health/src/handler/consultation_handler.rs +++ b/crates/erp-health/src/handler/consultation_handler.rs @@ -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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + HealthState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where + HealthState: FromRef, + 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))) +} diff --git a/crates/erp-health/src/handler/patient_handler.rs b/crates/erp-health/src/handler/patient_handler.rs index cd1d63c..09a1dac 100644 --- a/crates/erp-health/src/handler/patient_handler.rs +++ b/crates/erp-health/src/handler/patient_handler.rs @@ -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, + pub description: Option, +} + +pub async fn create_tag( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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, + pub color: Option, + pub description: Option, + pub version: i32, +} + +pub async fn update_tag( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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(()))) +} diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index d400ae6..ebfda96 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -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", diff --git a/crates/erp-server/config/default.toml b/crates/erp-server/config/default.toml index a3a3afb..2b763c5 100644 --- a/crates/erp-server/config/default.toml +++ b/crates/erp-server/config/default.toml @@ -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" diff --git a/crates/erp-server/src/config.rs b/crates/erp-server/src/config.rs index 39bcdb3..ad4acac 100644 --- a/crates/erp-server/src/config.rs +++ b/crates/erp-server/src/config.rs @@ -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::().unwrap_or(10) * 1024 * 1024 + } else if let Some(num) = s.strip_suffix("KB") { + num.trim().parse::().unwrap_or(1024) * 1024 + } else if let Some(num) = s.strip_suffix("GB") { + num.trim().parse::().unwrap_or(1) * 1024 * 1024 * 1024 + } else { + s.parse::().unwrap_or(10 * 1024 * 1024) + } + } +} + impl AppConfig { pub fn load() -> anyhow::Result { let config = config::Config::builder() diff --git a/crates/erp-server/src/handlers/mod.rs b/crates/erp-server/src/handlers/mod.rs index 8e8c090..2b09700 100644 --- a/crates/erp-server/src/handlers/mod.rs +++ b/crates/erp-server/src/handlers/mod.rs @@ -2,3 +2,4 @@ pub mod audit_log; pub mod crypto_admin; pub mod health; pub mod openapi; +pub mod upload; diff --git a/crates/erp-server/src/handlers/upload.rs b/crates/erp-server/src/handlers/upload.rs new file mode 100644 index 0000000..21339ac --- /dev/null +++ b/crates/erp-server/src/handlers/upload.rs @@ -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), + (status = 413, description = "文件过大"), + (status = 400, description = "无文件或不支持的类型"), + ), + tag = "文件上传", +)] +pub async fn upload_file( + State(state): State, + Extension(ctx): Extension, + mut multipart: Multipart, +) -> Result>, AppError> +where + AppState: FromRef, + 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 = 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) + } +} diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index 6a04134..0a98088 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -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); diff --git a/docs/discussions/2026-04-26-next-steps-roadmap.md b/docs/discussions/2026-04-26-next-steps-roadmap.md new file mode 100644 index 0000000..b5b4fe1 --- /dev/null +++ b/docs/discussions/2026-04-26-next-steps-roadmap.md @@ -0,0 +1,65 @@ +# 项目下一步工作安排 — 发散式讨论 + +> 日期: 2026-04-26 | 参与者: iven + Claude + +## 背景 + +17 天内已建成完整 ERP 底座 + erp-health 35 实体 + Web 62 页面 + 患者小程序 31 页面。 +客户需求(三端功能)已整理,10+ 份设计文档评审完毕但多数未实施。 +需要确定下一步工作方向和优先级排序。 + +## 讨论要点 + +### 第一轮:业务方向确认 + +- **业务目标:** 全面铺开 — 多方向并行推进,不设单一优先方向 +- **医护端策略:** 复用 + 扩展 — 在现有患者小程序中增加角色判断,根据角色切换 TabBar 和页面 +- **商城策略:** 中优先级 — 先做积分体系(签到/规则/兑换),微信支付和物流后续接入 +- **测试策略:** 边做边补 — 新功能 TDD,旧代码在 bugfix 时渐进补充 + +### 第二轮:关键技术决策 + +- **文件上传:** 本地先行 — 先用本地文件系统 + Nginx 静态服务,后续迁移到对象存储 +- **咨询实时化:** 轮询先行 — 先用 5-10s 轮询 + 微信通知,后续升级 WebSocket +- **统计报表:** 三方向并行 — 健康数据中心 + 运营统计报告 + 小程序埋点分析 +- **时间节奏:** 滚动迭代 — 不设硬时间线,按功能完成度推进,每两周 review + +### 第三轮:五大工作流 + +1. **P0 安全与合规** — 危急值消费者、审计日志补全、小程序安全清理、EventBus 可靠性 +2. **医护端小程序** — 复用现有小程序 + 角色切换,约 12-15 新页面 +3. **实时通知推送** — 通知分发器 → SSE → 微信模板消息 → WebSocket(渐进式) +4. **积分商城** — 积分核心 → 商品管理 → 微信支付 → 物流配送 → 售后(渐进式) +5. **质量与测试** — 边做边补,每周五半天集中补测试 + +## 结论 + +### 核心决策汇总 + +| 决策项 | 结论 | +|--------|------| +| 业务目标 | 全面铺开,多工作流并行 | +| 医护端策略 | 复用现有小程序,角色切换 | +| 商城策略 | 中优先级,先积分后支付 | +| 测试策略 | 边做边补,新功能 TDD | +| 文件上传 | 本地先行,后续迁移对象存储 | +| 咨询实时化 | 轮询先行,后续升级 WebSocket | +| 统计报表 | 三方向并行(健康数据 + 运营 + 埋点) | +| 时间节奏 | 滚动迭代,双周 review | + +### 滚动迭代计划 + +**Iteration 1:** P0 安全修复 + 医护端后端 API + 文件上传基础版 + 小程序角色切换框架 + +**Iteration 2:** 医护端小程序页面 + 通知推送 + 积分核心 + 健康数据统计 + +**Iteration 3:** 咨询轮询优化 + 积分商品管理 + 运营报表 + 埋点分析 + 测试提升 + CMS 完善 + +**后续:** 微信支付 / 物流对接 / WebSocket 升级 / AI 报告集成 / 对象存储迁移 + +### 风险 + +- 全面铺开导致各方向半成品 → 每个工作流定义 MVP +- 医护端复用导致代码耦合 → 共享服务层抽离,页面按角色隔离 +- 微信支付资质不全 → 先做积分虚拟兑换 +- 测试覆盖率持续低下 → 每个 PR 至少 1 个测试用例