From f0076aa240600b9f087ad83c1c15b136d26c8d97 Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 26 Apr 2026 13:54:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Iteration=203=20=E2=80=94=20=E5=92=A8?= =?UTF-8?q?=E8=AF=A2=E8=BD=AE=E8=AF=A2=E3=80=81=E7=BB=9F=E8=AE=A1=E6=A6=82?= =?UTF-8?q?=E8=A7=88=E3=80=81=E5=9F=8B=E7=82=B9=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - consultation_service 支持 after_id 增量消息查询 - 小程序咨询详情页 8 秒轮询新消息 - 新增 DashboardStatsResp 综合统计端点 (/statistics/dashboard) - 新增 /analytics/batch 埋点接收端点(日志记录模式) --- .../doctor/consultation/detail/index.tsx | 42 +++++++++++++++++++ apps/miniprogram/src/services/doctor.ts | 2 +- crates/erp-health/src/dto/stats_dto.rs | 8 ++++ .../src/handler/consultation_handler.rs | 3 +- .../erp-health/src/handler/stats_handler.rs | 19 +++++++++ crates/erp-health/src/module.rs | 4 ++ .../src/service/consultation_service.rs | 12 +++++- crates/erp-server/src/handlers/analytics.rs | 34 +++++++++++++++ crates/erp-server/src/handlers/mod.rs | 1 + crates/erp-server/src/main.rs | 4 ++ 10 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 crates/erp-server/src/handlers/analytics.rs diff --git a/apps/miniprogram/src/pages/doctor/consultation/detail/index.tsx b/apps/miniprogram/src/pages/doctor/consultation/detail/index.tsx index fe45e59..61a57e5 100644 --- a/apps/miniprogram/src/pages/doctor/consultation/detail/index.tsx +++ b/apps/miniprogram/src/pages/doctor/consultation/detail/index.tsx @@ -5,6 +5,8 @@ import * as doctorApi from '@/services/doctor'; import Loading from '@/components/Loading'; import './index.scss'; +const POLL_INTERVAL = 8000; + export default function ConsultationDetail() { const router = useRouter(); const sessionId = router.params.id || ''; @@ -14,14 +16,53 @@ export default function ConsultationDetail() { const [sending, setSending] = useState(false); const [loading, setLoading] = useState(true); const scrollViewRef = useRef(''); + const pollTimerRef = useRef | null>(null); useEffect(() => { if (sessionId) { loadData(); markRead(); + startPolling(); } + return () => stopPolling(); }, [sessionId]); + const startPolling = () => { + stopPolling(); + pollTimerRef.current = setInterval(pollNewMessages, POLL_INTERVAL); + }; + + const stopPolling = () => { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + }; + + const pollNewMessages = async () => { + if (!session || session.status === 'closed') { + stopPolling(); + return; + } + try { + const lastId = messages.length > 0 ? messages[messages.length - 1].id : undefined; + const m = await doctorApi.listMessages(sessionId, { + page: 1, + page_size: 50, + after_id: lastId, + }); + const newMsgs = m.data || []; + if (newMsgs.length > 0) { + setMessages((prev) => { + const existing = new Set(prev.map((msg) => msg.id)); + const fresh = newMsgs.filter((msg) => !existing.has(msg.id)); + return [...prev, ...fresh]; + }); + scrollViewRef.current = `msg-${messages.length + newMsgs.length}`; + } + } catch { /* 轮询失败静默忽略 */ } + }; + const loadData = async () => { setLoading(true); try { @@ -32,6 +73,7 @@ export default function ConsultationDetail() { setSession(s); setMessages(m.data || []); scrollViewRef.current = `msg-${(m.data || []).length}`; + if (s.status === 'closed') stopPolling(); } catch { Taro.showToast({ title: '加载失败', icon: 'none' }); } finally { diff --git a/apps/miniprogram/src/services/doctor.ts b/apps/miniprogram/src/services/doctor.ts index c625e41..5cc69d9 100644 --- a/apps/miniprogram/src/services/doctor.ts +++ b/apps/miniprogram/src/services/doctor.ts @@ -122,7 +122,7 @@ export async function getSession(id: string) { return api.get(`/health/consultation-sessions/${id}`); } -export async function listMessages(sessionId: string, params?: { page?: number; page_size?: number }) { +export async function listMessages(sessionId: string, params?: { page?: number; page_size?: number; after_id?: string }) { return api.get<{ data: ConsultationMessage[]; total: number }>( `/health/consultation-sessions/${sessionId}/messages`, params, diff --git a/crates/erp-health/src/dto/stats_dto.rs b/crates/erp-health/src/dto/stats_dto.rs index b7d6ee8..164a4f3 100644 --- a/crates/erp-health/src/dto/stats_dto.rs +++ b/crates/erp-health/src/dto/stats_dto.rs @@ -25,3 +25,11 @@ pub struct FollowUpStatisticsResp { pub overdue: i64, pub completion_rate: f64, } + +/// 综合统计概览(一次调用返回全部关键指标)。 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct DashboardStatsResp { + pub patients: PatientStatisticsResp, + pub consultations: ConsultationStatisticsResp, + pub follow_ups: FollowUpStatisticsResp, +} diff --git a/crates/erp-health/src/handler/consultation_handler.rs b/crates/erp-health/src/handler/consultation_handler.rs index 32684af..d840d9a 100644 --- a/crates/erp-health/src/handler/consultation_handler.rs +++ b/crates/erp-health/src/handler/consultation_handler.rs @@ -26,6 +26,7 @@ pub struct SessionListParams { pub struct MessageListParams { pub page: Option, pub page_size: Option, + pub after_id: Option, } #[derive(Debug, serde::Deserialize, utoipa::ToSchema)] @@ -114,7 +115,7 @@ where let page = params.page.unwrap_or(1); let page_size = params.page_size.unwrap_or(20); let result = consultation_service::list_messages( - &state, ctx.tenant_id, session_id, page, page_size, + &state, ctx.tenant_id, session_id, page, page_size, params.after_id, ) .await?; Ok(Json(ApiResponse::ok(result))) diff --git a/crates/erp-health/src/handler/stats_handler.rs b/crates/erp-health/src/handler/stats_handler.rs index 8afb4ed..70d5397 100644 --- a/crates/erp-health/src/handler/stats_handler.rs +++ b/crates/erp-health/src/handler/stats_handler.rs @@ -46,3 +46,22 @@ where let result = stats_service::get_follow_up_statistics(&state, ctx.tenant_id).await?; Ok(Json(ApiResponse::ok(result))) } + +pub async fn get_dashboard_stats( + State(state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.patient.list")?; + let patients = stats_service::get_patient_statistics(&state, ctx.tenant_id).await?; + let consultations = stats_service::get_consultation_statistics(&state, ctx.tenant_id).await?; + let follow_ups = stats_service::get_follow_up_statistics(&state, ctx.tenant_id).await?; + Ok(Json(ApiResponse::ok(DashboardStatsResp { + patients, + consultations, + follow_ups, + }))) +} diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index ebfda96..1d6aa73 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -467,6 +467,10 @@ impl HealthModule { "/health/admin/statistics/follow-ups", axum::routing::get(stats_handler::get_follow_up_stats), ) + .route( + "/health/admin/statistics/dashboard", + axum::routing::get(stats_handler::get_dashboard_stats), + ) // 危急值阈值配置 .route( "/health/critical-value-thresholds", diff --git a/crates/erp-health/src/service/consultation_service.rs b/crates/erp-health/src/service/consultation_service.rs index 17d96ed..ac5e465 100644 --- a/crates/erp-health/src/service/consultation_service.rs +++ b/crates/erp-health/src/service/consultation_service.rs @@ -232,15 +232,23 @@ pub async fn list_messages( session_id: Uuid, page: u64, page_size: u64, + after_id: Option, ) -> HealthResult> { let limit = page_size.min(100); - let offset = page.saturating_sub(1) * limit; - let query = consultation_message::Entity::find() + let mut query = consultation_message::Entity::find() .filter(consultation_message::Column::TenantId.eq(tenant_id)) .filter(consultation_message::Column::SessionId.eq(session_id)) .filter(consultation_message::Column::DeletedAt.is_null()); + // after_id 模式:返回该 ID 之后的所有消息(用于轮询增量拉取) + if let Some(aid) = after_id { + query = query.filter(consultation_message::Column::Id.gt(aid)); + } + + let offset = page.saturating_sub(1) * limit; + let total = query.clone().count(&state.db).await?; + let total = query.clone().count(&state.db).await?; let models = query .order_by_asc(consultation_message::Column::CreatedAt) diff --git a/crates/erp-server/src/handlers/analytics.rs b/crates/erp-server/src/handlers/analytics.rs new file mode 100644 index 0000000..3750b13 --- /dev/null +++ b/crates/erp-server/src/handlers/analytics.rs @@ -0,0 +1,34 @@ +use axum::Json; +use serde::Deserialize; +use tracing; + +use erp_core::types::ApiResponse; + +#[derive(Debug, Deserialize)] +pub struct AnalyticsEvent { + pub event: String, + pub properties: Option, + pub timestamp: Option, + pub page: Option, +} + +#[derive(Debug, Deserialize)] +pub struct BatchRequest { + pub events: Vec, +} + +/// 接收小程序批量埋点事件。 +/// 当前为日志记录模式 — 后续可接入 ClickHouse/PostgreSQL 分析表。 +pub async fn batch( + Json(req): Json, +) -> Json> { + for evt in &req.events { + tracing::info!( + event = %evt.event, + page = ?evt.page, + properties = ?evt.properties, + "Analytics event received" + ); + } + Json(ApiResponse::ok(())) +} diff --git a/crates/erp-server/src/handlers/mod.rs b/crates/erp-server/src/handlers/mod.rs index 2b09700..02ba6dc 100644 --- a/crates/erp-server/src/handlers/mod.rs +++ b/crates/erp-server/src/handlers/mod.rs @@ -1,3 +1,4 @@ +pub mod analytics; pub mod audit_log; pub mod crypto_admin; pub mod health; diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index 0a98088..9796ae7 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -494,6 +494,10 @@ async fn main() -> anyhow::Result<()> { "/docs/openapi.json", axum::routing::get(handlers::openapi::openapi_spec), ) + .route( + "/analytics/batch", + axum::routing::post(handlers::analytics::batch), + ) .layer(axum::middleware::from_fn_with_state( state.clone(), middleware::rate_limit::rate_limit_by_ip,