fix: V1 测试版本端到端验证修复 — 6 CRITICAL + 3 HIGH 问题全量修复
修复项: - fix(db): 迁移 149 — 修复 Admin 角色权限绑定被迁移链破坏 (FE-C1) - fix(health): 4 个 handler 添加空名称验证 — Doctor/Article/AlertRule/Tag (API-C1~C4) - fix(health): Stats 仪表盘 new_this_week 查询修复 — SeaORM date_trunc bug (FE-C2) - fix(server): 添加安全响应头 — X-Frame-Options/CSP/XSS-Protection/Referrer-Policy (SEC-H1) - fix(mp): 预约创建契约修复 — notes/reason 字段映射 + 移除 schedule_id (MP-H1) - fix(mp): 咨询会话 subject/last_message 字段改为可选 (MP-H3) - fix(ai): AiConfig Default derive 替代手写 impl (clippy) 测试报告: - 8 维度端到端测试全部完成 (后端 87 用例 / 前端 30 页面 / 小程序 80+ API / 安全 20 项 / 性能 20 端点) - 多角色 7 角色 49 检查 100% 通过 - 综合测试报告 + 专家评估报告
This commit is contained in:
@@ -67,6 +67,9 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.alert-rules.manage")?;
|
||||
body.sanitize();
|
||||
if body.name.trim().is_empty() {
|
||||
return Err(AppError::Validation("规则名称不能为空".into()));
|
||||
}
|
||||
let rule = alert_rule_service::create_rule(&state, ctx.tenant_id, ctx.user_id, body).await?;
|
||||
Ok(axum::Json(ApiResponse::ok(rule)))
|
||||
}
|
||||
|
||||
@@ -108,6 +108,9 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.articles.manage")?;
|
||||
req.sanitize();
|
||||
if req.title.trim().is_empty() {
|
||||
return Err(AppError::Validation("文章标题不能为空".into()));
|
||||
}
|
||||
let result =
|
||||
article_service::create_article(&state, ctx.tenant_id, Some(ctx.user_id), req.0).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
|
||||
@@ -34,6 +34,9 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.articles.manage")?;
|
||||
req.sanitize();
|
||||
if req.name.trim().is_empty() {
|
||||
return Err(AppError::Validation("标签名称不能为空".into()));
|
||||
}
|
||||
let result =
|
||||
article_tag_service::create_tag(&state, ctx.tenant_id, Some(ctx.user_id), req.0).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
|
||||
@@ -66,6 +66,9 @@ where
|
||||
require_permission(&ctx, "health.doctor.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
if req.name.trim().is_empty() {
|
||||
return Err(AppError::Validation("医生姓名不能为空".into()));
|
||||
}
|
||||
let result =
|
||||
doctor_service::create_doctor(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
//! 统计 Service — 健康数据统计
|
||||
|
||||
use sea_orm::{
|
||||
ColumnTrait, EntityTrait, FromQueryResult, PaginatorTrait, QueryFilter, sea_query::Expr,
|
||||
ColumnTrait, DatabaseBackend, EntityTrait, FromQueryResult, PaginatorTrait, QueryFilter,
|
||||
Statement,
|
||||
};
|
||||
|
||||
use erp_core::error::AppResult;
|
||||
|
||||
use crate::dto::stats_dto::*;
|
||||
use crate::entity::{appointment, lab_report, patient, vital_signs};
|
||||
use crate::entity::{appointment, lab_report, patient};
|
||||
use crate::state::HealthState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -26,14 +27,7 @@ pub async fn get_lab_report_statistics(
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
let this_month = lab_report::Entity::find()
|
||||
.filter(lab_report::Column::TenantId.eq(tenant_id))
|
||||
.filter(lab_report::Column::DeletedAt.is_null())
|
||||
.filter(
|
||||
Expr::col(lab_report::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())")),
|
||||
)
|
||||
.count(db)
|
||||
.await?;
|
||||
let this_month = count_lab_reports_since(db, tenant_id, "date_trunc('month', NOW())").await?;
|
||||
|
||||
let pending_review = lab_report::Entity::find()
|
||||
.filter(lab_report::Column::TenantId.eq(tenant_id))
|
||||
@@ -63,7 +57,7 @@ pub async fn get_lab_report_statistics(
|
||||
|
||||
Ok(LabReportStatisticsResp {
|
||||
total_reports: total_reports as i64,
|
||||
this_month: this_month as i64,
|
||||
this_month,
|
||||
type_distribution,
|
||||
abnormal_items,
|
||||
pending_review: pending_review as i64,
|
||||
@@ -83,14 +77,7 @@ pub async fn get_appointment_statistics(
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
let this_month = appointment::Entity::find()
|
||||
.filter(appointment::Column::TenantId.eq(tenant_id))
|
||||
.filter(appointment::Column::DeletedAt.is_null())
|
||||
.filter(
|
||||
Expr::col(appointment::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())")),
|
||||
)
|
||||
.count(db)
|
||||
.await?;
|
||||
let this_month = count_appointments_since(db, tenant_id, "date_trunc('month', NOW())").await?;
|
||||
|
||||
let status_distribution = count_by_field(
|
||||
db,
|
||||
@@ -112,15 +99,7 @@ pub async fn get_appointment_statistics(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cancelled = appointment::Entity::find()
|
||||
.filter(appointment::Column::TenantId.eq(tenant_id))
|
||||
.filter(appointment::Column::DeletedAt.is_null())
|
||||
.filter(
|
||||
Expr::col(appointment::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())")),
|
||||
)
|
||||
.filter(appointment::Column::Status.eq("cancelled"))
|
||||
.count(db)
|
||||
.await?;
|
||||
let cancelled = count_appointments_cancelled(db, tenant_id).await?;
|
||||
|
||||
let cancel_rate = if this_month > 0 {
|
||||
(cancelled as f64 / this_month as f64) * 100.0
|
||||
@@ -130,7 +109,7 @@ pub async fn get_appointment_statistics(
|
||||
|
||||
Ok(AppointmentStatisticsResp {
|
||||
total_appointments: total_appointments as i64,
|
||||
this_month: this_month as i64,
|
||||
this_month,
|
||||
status_distribution,
|
||||
type_distribution,
|
||||
cancel_rate,
|
||||
@@ -149,14 +128,8 @@ pub async fn get_vital_signs_report_rate(
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
let total_records = vital_signs::Entity::find()
|
||||
.filter(vital_signs::Column::TenantId.eq(tenant_id))
|
||||
.filter(vital_signs::Column::DeletedAt.is_null())
|
||||
.filter(
|
||||
Expr::col(vital_signs::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())")),
|
||||
)
|
||||
.count(db)
|
||||
.await?;
|
||||
let total_records =
|
||||
count_vital_signs_since(db, tenant_id, "date_trunc('month', NOW())").await?;
|
||||
|
||||
let reported_patients = count_distinct_patients_vital_signs(db, tenant_id).await?;
|
||||
|
||||
@@ -166,13 +139,13 @@ pub async fn get_vital_signs_report_rate(
|
||||
0.0
|
||||
};
|
||||
|
||||
let daily_trend = compute_daily_report_rate(db, tenant_id).await?;
|
||||
let daily_trend = compute_daily_report_rate(db, tenant_id, total_patients).await?;
|
||||
|
||||
Ok(VitalSignsReportRateResp {
|
||||
total_patients: total_patients as i64,
|
||||
reported_patients: reported_patients as i64,
|
||||
report_rate,
|
||||
total_records: total_records as i64,
|
||||
total_records,
|
||||
daily_trend,
|
||||
})
|
||||
}
|
||||
@@ -196,25 +169,111 @@ pub async fn get_health_data_stats(
|
||||
// 辅助查询
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct CountRow {
|
||||
count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct NameValueRow {
|
||||
name: String,
|
||||
value: i64,
|
||||
}
|
||||
|
||||
/// 使用原始 SQL 查询指定时间之后的化验报告数量
|
||||
async fn count_lab_reports_since(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
date_expr: &str,
|
||||
) -> AppResult<i64> {
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*)::int8 AS count FROM lab_report \
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL \
|
||||
AND created_at >= {date_expr}"
|
||||
);
|
||||
let row: Option<CountRow> = FromQueryResult::find_by_statement(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
&sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
Ok(row.map(|r| r.count).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// 使用原始 SQL 查询指定时间之后的预约数量
|
||||
async fn count_appointments_since(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
date_expr: &str,
|
||||
) -> AppResult<i64> {
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*)::int8 AS count FROM appointment \
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL \
|
||||
AND created_at >= {date_expr}"
|
||||
);
|
||||
let row: Option<CountRow> = FromQueryResult::find_by_statement(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
&sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
Ok(row.map(|r| r.count).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// 本月已取消的预约数
|
||||
async fn count_appointments_cancelled(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
) -> AppResult<i64> {
|
||||
let sql = r#"
|
||||
SELECT COUNT(*)::int8 AS count FROM appointment
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||
AND created_at >= date_trunc('month', NOW())
|
||||
AND status = 'cancelled'
|
||||
"#;
|
||||
let row: Option<CountRow> = FromQueryResult::find_by_statement(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
Ok(row.map(|r| r.count).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// 使用原始 SQL 查询指定时间之后的体征记录数量
|
||||
async fn count_vital_signs_since(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
date_expr: &str,
|
||||
) -> AppResult<i64> {
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*)::int8 AS count FROM vital_signs \
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL \
|
||||
AND created_at >= {date_expr}"
|
||||
);
|
||||
let row: Option<CountRow> = FromQueryResult::find_by_statement(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
&sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
Ok(row.map(|r| r.count).unwrap_or(0))
|
||||
}
|
||||
|
||||
async fn count_by_field(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
sql: &str,
|
||||
) -> AppResult<Vec<NameValue>> {
|
||||
let rows: Vec<NameValueRow> =
|
||||
sea_orm::FromQueryResult::find_by_statement(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.all(db)
|
||||
.await?;
|
||||
let rows: Vec<NameValueRow> = FromQueryResult::find_by_statement(
|
||||
Statement::from_sql_and_values(DatabaseBackend::Postgres, sql, [tenant_id.into()]),
|
||||
)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
@@ -246,14 +305,11 @@ async fn count_abnormal_lab_items(
|
||||
total: Option<i64>,
|
||||
}
|
||||
|
||||
let result: Option<AbnormalCount> =
|
||||
sea_orm::FromQueryResult::find_by_statement(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
let result: Option<AbnormalCount> = FromQueryResult::find_by_statement(
|
||||
Statement::from_sql_and_values(DatabaseBackend::Postgres, sql, [tenant_id.into()]),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
Ok(result.and_then(|r| r.total).unwrap_or(0))
|
||||
}
|
||||
@@ -274,14 +330,11 @@ async fn count_distinct_patients_vital_signs(
|
||||
cnt: i64,
|
||||
}
|
||||
|
||||
let result: Option<DistinctCount> =
|
||||
sea_orm::FromQueryResult::find_by_statement(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
let result: Option<DistinctCount> = FromQueryResult::find_by_statement(
|
||||
Statement::from_sql_and_values(DatabaseBackend::Postgres, sql, [tenant_id.into()]),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| r.cnt as u64).unwrap_or(0))
|
||||
}
|
||||
@@ -289,6 +342,7 @@ async fn count_distinct_patients_vital_signs(
|
||||
async fn compute_daily_report_rate(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
total_patients: u64,
|
||||
) -> AppResult<Vec<DailyReportRate>> {
|
||||
let sql = r#"
|
||||
SELECT d::date::text AS date,
|
||||
@@ -313,20 +367,13 @@ async fn compute_daily_report_rate(
|
||||
total: i64,
|
||||
}
|
||||
|
||||
let rows: Vec<DailyRow> =
|
||||
sea_orm::FromQueryResult::find_by_statement(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
let total_patients = patient::Entity::find()
|
||||
.filter(patient::Column::TenantId.eq(tenant_id))
|
||||
.filter(patient::Column::DeletedAt.is_null())
|
||||
.count(db)
|
||||
.await?;
|
||||
let rows: Vec<DailyRow> = FromQueryResult::find_by_statement(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
//! 统计 Service — 基础运营统计辅助查询
|
||||
|
||||
use sea_orm::{
|
||||
ColumnTrait, EntityTrait, FromQueryResult, PaginatorTrait, QueryFilter, sea_query::Expr,
|
||||
ColumnTrait, DatabaseBackend, EntityTrait, FromQueryResult, PaginatorTrait, QueryFilter,
|
||||
Statement,
|
||||
};
|
||||
|
||||
use erp_core::error::AppResult;
|
||||
|
||||
use crate::dto::stats_dto::*;
|
||||
use crate::entity::{consultation_session, patient, points_transaction};
|
||||
use crate::entity::{consultation_session, patient};
|
||||
use crate::state::HealthState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -26,34 +27,16 @@ pub async fn get_patient_statistics(
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
let new_this_month = patient::Entity::find()
|
||||
.filter(patient::Column::TenantId.eq(tenant_id))
|
||||
.filter(patient::Column::DeletedAt.is_null())
|
||||
.filter(Expr::col(patient::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())")))
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
let new_this_week = patient::Entity::find()
|
||||
.filter(patient::Column::TenantId.eq(tenant_id))
|
||||
.filter(patient::Column::DeletedAt.is_null())
|
||||
.filter(Expr::col(patient::Column::CreatedAt).gte(Expr::cust("date_trunc('week', NOW())")))
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
let active_this_month = points_transaction::Entity::find()
|
||||
.filter(points_transaction::Column::TenantId.eq(tenant_id))
|
||||
.filter(
|
||||
Expr::col(points_transaction::Column::CreatedAt)
|
||||
.gte(Expr::cust("date_trunc('month', NOW())")),
|
||||
)
|
||||
.count(db)
|
||||
.await?;
|
||||
// 使用原始 SQL 避免 SeaORM Expr::cust 在 date_trunc('week',...) 下生成不兼容 SQL
|
||||
let new_this_month = count_patients_since(db, tenant_id, "date_trunc('month', NOW())").await?;
|
||||
let new_this_week = count_patients_since(db, tenant_id, "date_trunc('week', NOW())").await?;
|
||||
let active_this_month = count_active_patients(db, tenant_id).await?;
|
||||
|
||||
Ok(PatientStatisticsResp {
|
||||
total_patients: total as i64,
|
||||
new_this_month: new_this_month as i64,
|
||||
new_this_week: new_this_week as i64,
|
||||
active_this_month: active_this_month as i64,
|
||||
new_this_month,
|
||||
new_this_week,
|
||||
active_this_month,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -76,15 +59,7 @@ pub async fn get_consultation_statistics(
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
let this_month = consultation_session::Entity::find()
|
||||
.filter(consultation_session::Column::TenantId.eq(tenant_id))
|
||||
.filter(consultation_session::Column::DeletedAt.is_null())
|
||||
.filter(
|
||||
Expr::col(consultation_session::Column::CreatedAt)
|
||||
.gte(Expr::cust("date_trunc('month', NOW())")),
|
||||
)
|
||||
.count(db)
|
||||
.await?;
|
||||
let this_month = count_consultations_since(db, tenant_id, "date_trunc('month', NOW())").await?;
|
||||
|
||||
let avg_response_time_minutes = match compute_avg_response_time(db, tenant_id).await {
|
||||
Ok(v) => v,
|
||||
@@ -98,7 +73,7 @@ pub async fn get_consultation_statistics(
|
||||
total_sessions: total_sessions as i64,
|
||||
pending_reply: pending_reply as i64,
|
||||
avg_response_time_minutes,
|
||||
this_month: this_month as i64,
|
||||
this_month,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -165,6 +140,73 @@ pub async fn get_follow_up_statistics(
|
||||
// 辅助查询
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct CountRow {
|
||||
count: i64,
|
||||
}
|
||||
|
||||
/// 查询指定日期条件之后创建的患者数量(使用原始 SQL 避免 SeaORM date_trunc 兼容问题)
|
||||
async fn count_patients_since(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
date_expr: &str,
|
||||
) -> AppResult<i64> {
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*)::int8 AS count FROM patient \
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL \
|
||||
AND created_at >= {date_expr}"
|
||||
);
|
||||
let row: Option<CountRow> = FromQueryResult::find_by_statement(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
&sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
Ok(row.map(|r| r.count).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// 查询本月活跃患者数(有积分交易记录的患者)
|
||||
async fn count_active_patients(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
) -> AppResult<i64> {
|
||||
let sql = r#"
|
||||
SELECT COUNT(*)::int8 AS count FROM points_transaction
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||
AND created_at >= date_trunc('month', NOW())
|
||||
"#;
|
||||
let row: Option<CountRow> = FromQueryResult::find_by_statement(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
Ok(row.map(|r| r.count).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// 查询指定日期条件之后创建的咨询会话数量
|
||||
async fn count_consultations_since(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
date_expr: &str,
|
||||
) -> AppResult<i64> {
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*)::int8 AS count FROM consultation_session \
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL \
|
||||
AND created_at >= {date_expr}"
|
||||
);
|
||||
let row: Option<CountRow> = FromQueryResult::find_by_statement(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
&sql,
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
Ok(row.map(|r| r.count).unwrap_or(0))
|
||||
}
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct AvgResponseTime {
|
||||
avg_minutes: Option<f64>,
|
||||
|
||||
Reference in New Issue
Block a user