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 个测试用例