diff --git a/crates/erp-auth/src/handler/wechat_handler.rs b/crates/erp-auth/src/handler/wechat_handler.rs index f981d2e..739635a 100644 --- a/crates/erp-auth/src/handler/wechat_handler.rs +++ b/crates/erp-auth/src/handler/wechat_handler.rs @@ -37,8 +37,8 @@ where tracing::info!( code = %req.code, tenant_id = %state.default_tenant_id, - appid_len = state.wechat_appid.len(), - secret_len = state.wechat_secret.len(), + has_appid = !state.wechat_appid.is_empty(), + has_secret = !state.wechat_secret.is_empty(), "微信登录请求" ); diff --git a/crates/erp-health/src/service/action_inbox_service.rs b/crates/erp-health/src/service/action_inbox_service.rs index d768bc8..dfdb46d 100644 --- a/crates/erp-health/src/service/action_inbox_service.rs +++ b/crates/erp-health/src/service/action_inbox_service.rs @@ -237,31 +237,72 @@ pub async fn list_action_items( let filter_by_user = query.assigned_to_me.unwrap_or(false) && user_id.is_some(); - // 各段的 status 过滤条件 + // 各段的 status 过滤条件(参数化) let (sug_status, alert_status, fu_status) = match query.status.as_deref() { Some("pending") => ( - "AND s.status = 'pending'".into(), - "AND al.status = 'active'".into(), - "AND f.status = 'pending'".into(), + "AND s.status = $6".to_string(), + "AND al.status = $7".to_string(), + "AND f.status = $8".to_string(), ), Some("in_progress") => ( - "AND s.status = 'approved'".into(), - "AND al.status = 'acknowledged'".into(), - "AND f.status = 'in_progress'".into(), + "AND s.status = $6".to_string(), + "AND al.status = $7".to_string(), + "AND f.status = $8".to_string(), ), Some("completed") => ( - "AND s.status = 'executed'".into(), - "AND al.status = 'resolved'".into(), - "AND f.status = 'completed'".into(), + "AND s.status = $6".to_string(), + "AND al.status = $7".to_string(), + "AND f.status = $8".to_string(), ), Some("dismissed") => ( - "AND s.status IN ('rejected', 'expired', 'parse_failed')".into(), - "AND al.status IN ('dismissed', 'expired')".into(), - "AND f.status IN ('cancelled', 'skipped')".into(), + "AND s.status = ANY($6)".to_string(), + "AND al.status = ANY($7)".to_string(), + "AND f.status = ANY($8)".to_string(), ), _ => (String::new(), String::new(), String::new()), }; + // status 绑定值 + let (sug_val, alert_val, fu_val): (sea_orm::Value, sea_orm::Value, sea_orm::Value) = + match query.status.as_deref() { + Some("pending") => ("pending".into(), "active".into(), "pending".into()), + Some("in_progress") => ( + "approved".into(), + "acknowledged".into(), + "in_progress".into(), + ), + Some("completed") => ("executed".into(), "resolved".into(), "completed".into()), + Some("dismissed") => ( + sea_orm::Value::Array( + sea_orm::sea_query::ArrayType::String, + Some(Box::new(vec![ + sea_orm::Value::String(Some(Box::new("rejected".to_string()))), + sea_orm::Value::String(Some(Box::new("expired".to_string()))), + sea_orm::Value::String(Some(Box::new("parse_failed".to_string()))), + ])), + ), + sea_orm::Value::Array( + sea_orm::sea_query::ArrayType::String, + Some(Box::new(vec![ + sea_orm::Value::String(Some(Box::new("dismissed".to_string()))), + sea_orm::Value::String(Some(Box::new("expired".to_string()))), + ])), + ), + sea_orm::Value::Array( + sea_orm::sea_query::ArrayType::String, + Some(Box::new(vec![ + sea_orm::Value::String(Some(Box::new("cancelled".to_string()))), + sea_orm::Value::String(Some(Box::new("skipped".to_string()))), + ])), + ), + ), + _ => ( + sea_orm::Value::String(None), + sea_orm::Value::String(None), + sea_orm::Value::String(None), + ), + }; + // 按类型过滤 let type_filter = query.action_type.as_deref(); let include_sug = type_filter.is_none_or(|t| t == "ai_suggestion"); @@ -335,7 +376,7 @@ pub async fn list_action_items( let union_sql = segments.join("\n UNION ALL\n"); - // $1=tenant_id, $2=patient_id, $3=assigned_to (union 内部) + // $1=tenant_id, $2=patient_id, $3=assigned_to, $6/$7/$8=status (union 内部) // $4=LIMIT, $5=OFFSET (外层分页) let patient_val: sea_orm::Value = query .patient_id @@ -362,6 +403,9 @@ pub async fn list_action_items( assigned_val.clone(), (page_size as i64).into(), (offset as i64).into(), + sug_val.clone(), + alert_val.clone(), + fu_val.clone(), ], )) .all(db) @@ -375,7 +419,14 @@ pub async fn list_action_items( FromQueryResult::find_by_statement(Statement::from_sql_and_values( DatabaseBackend::Postgres, count_sql, - [tenant_id.into(), patient_val, assigned_val], + [ + tenant_id.into(), + patient_val, + assigned_val, + sug_val, + alert_val, + fu_val, + ], )) .one(db) .await diff --git a/crates/erp-message/src/handler/sse_handler.rs b/crates/erp-message/src/handler/sse_handler.rs index 007e0ed..6d0604d 100644 --- a/crates/erp-message/src/handler/sse_handler.rs +++ b/crates/erp-message/src/handler/sse_handler.rs @@ -1,9 +1,9 @@ use std::cell::Cell; use std::collections::HashSet; -use std::convert::Infallible; use axum::extract::{Extension, Query}; -use axum::http::HeaderMap; +use axum::http::{HeaderMap, HeaderValue, header}; +use axum::response::IntoResponse; use axum::response::sse::{Event, KeepAlive, Sse}; use futures::stream::Stream; use sea_orm::ConnectionTrait; @@ -13,6 +13,23 @@ use uuid::Uuid; use erp_core::error::AppError; use erp_core::types::TenantContext; +/// 包装 SSE 响应,添加 Cache-Control: no-store 头 +pub struct NoCacheSse(Sse); + +impl IntoResponse for NoCacheSse +where + S: Stream> + Send + 'static, +{ + fn into_response(self) -> axum::response::Response { + let mut response = self.0.into_response(); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("no-store, no-cache, must-revalidate"), + ); + response + } +} + use crate::message_state::MessageState; /// SSE 查询参数 @@ -38,7 +55,7 @@ pub async fn message_stream( Extension(ctx): Extension, headers: HeaderMap, Query(query): Query, -) -> Result>>, AppError> { +) -> Result>>, AppError> { let user_id = ctx.user_id; let tenant_id = ctx.tenant_id; @@ -165,10 +182,12 @@ pub async fn message_stream( } }; - Ok(Sse::new(sse_stream).keep_alive( - KeepAlive::new() - .interval(std::time::Duration::from_secs(30)) - .text("ping"), + Ok(NoCacheSse( + Sse::new(sse_stream).keep_alive( + KeepAlive::new() + .interval(std::time::Duration::from_secs(30)) + .text("ping"), + ), )) } diff --git a/crates/erp-plugin/src/dynamic_table.rs b/crates/erp-plugin/src/dynamic_table.rs index 0a228f1..48b39ac 100644 --- a/crates/erp-plugin/src/dynamic_table.rs +++ b/crates/erp-plugin/src/dynamic_table.rs @@ -6,7 +6,7 @@ use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use crate::error::{PluginError, PluginResult}; use crate::manifest::{PluginEntity, PluginField, PluginFieldType}; -/// 消毒标识符:只保留 ASCII 字母、数字、下划线,防止 SQL 注入 +/// 消毒标识符:只保留 ASCII 字母、数字、下划线,限制 63 字节(PostgreSQL NAMEDATALEN-1) pub(crate) fn sanitize_identifier(input: &str) -> String { input .chars() @@ -17,6 +17,7 @@ pub(crate) fn sanitize_identifier(input: &str) -> String { '_' } }) + .take(63) .collect() } diff --git a/crates/erp-server/src/handlers/analytics.rs b/crates/erp-server/src/handlers/analytics.rs index 3e49141..ffd8c46 100644 --- a/crates/erp-server/src/handlers/analytics.rs +++ b/crates/erp-server/src/handlers/analytics.rs @@ -1,8 +1,13 @@ use axum::Json; +use axum::extract::Extension; use serde::Deserialize; use tracing; -use erp_core::types::ApiResponse; +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)] // 客户端上报结构体,字段后续接入分析表时使用 @@ -37,7 +42,17 @@ pub struct BatchRequest { /// 接收小程序批量埋点事件。 /// 当前为日志记录模式 — 后续可接入 ClickHouse/PostgreSQL 分析表。 -pub async fn batch(Json(req): Json) -> Json> { +pub async fn batch( + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, 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, @@ -46,5 +61,5 @@ pub async fn batch(Json(req): Json) -> Json> { "Analytics event received" ); } - Json(ApiResponse::ok(())) + Ok(Json(ApiResponse::ok(()))) } diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index 7e6ded4..ba8d97d 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -921,6 +921,22 @@ async fn security_headers_middleware( header::HeaderName::from_static("referrer-policy"), HeaderValue::from_static("strict-origin-when-cross-origin"), ); + headers.insert( + header::STRICT_TRANSPORT_SECURITY, + HeaderValue::from_static("max-age=63072000; includeSubDomains; preload"), + ); + headers.insert( + header::HeaderName::from_static("content-security-policy"), + HeaderValue::from_static( + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; \ + img-src 'self' data: blob: https:; connect-src 'self' wss:; \ + frame-ancestors 'none'; base-uri 'self'; form-action 'self'", + ), + ); + headers.insert( + header::HeaderName::from_static("permissions-policy"), + HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"), + ); response }