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:
iven
2026-05-18 10:24:40 +08:00
parent 38b0d91407
commit d623f8b2ff
36 changed files with 5564 additions and 189 deletions

View File

@@ -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)))
}

View File

@@ -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)))

View File

@@ -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)))

View File

@@ -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)))

View File

@@ -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()

View File

@@ -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>,