fix: 修复测试发现的 7 个问题 + 全 workspace clippy 清零
功能修复: 1. 患者创建空名称验证:后端添加 name.trim().is_empty() 检查 2. 仪表盘统计容错:单个查询失败返回零值而非 500 3. FHIR 路由修复:从 /fhir 移到 /api/v1/fhir 保持一致 4. 冻结模块后端中间件:新增 frozen_module_middleware 拦截冻结路径 5. 积分端点权限码:health.health-data.list → health.points.list 6. 角色权限迁移:护士补充 devices.list,运营补充 points.list/manage 7. 测试结果文档:R01-R05 角色测试 + T00/T10 结果归档 Clippy 全 workspace 清零(14→0 errors): - erp-core: 修复 empty doc line、collapsible if、redundant closure 等 9 处 - erp-health: 修复 too_many_arguments、unused var、unnecessary parens 等 58 处 - erp-ai: 修复 dead_code、unused import 等 11 处 - erp-plugin: 修复 too_many_arguments、wildcard pattern 等 11 处 - erp-server-migration: 修复 enum_variant_names 5 处 - erp-auth/config/workflow/message: 各 1-3 处 工程改进: - lint-staged 配置迁移到 .lintstagedrc.js(函数式避免文件列表传给 clippy) - cargo fmt 统一格式化
This commit is contained in:
@@ -10,13 +10,13 @@ use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
use crate::data_dto::{
|
||||
AggregateItem, AggregateMultiReq, AggregateMultiRow, AggregateQueryParams, BatchActionReq,
|
||||
CountQueryParams, CreatePluginDataReq, ExportParams, ImportReq, ImportResult,
|
||||
PatchPluginDataReq, PluginDataListParams,
|
||||
PluginDataResp, PublicEntityResp, ReconciliationReport, ResolveLabelsReq, ResolveLabelsResp,
|
||||
TimeseriesItem, TimeseriesParams, UpdatePluginDataReq, UserViewReq, UserViewResp,
|
||||
PatchPluginDataReq, PluginDataListParams, PluginDataResp, PublicEntityResp,
|
||||
ReconciliationReport, ResolveLabelsReq, ResolveLabelsResp, TimeseriesItem, TimeseriesParams,
|
||||
UpdatePluginDataReq, UserViewReq, UserViewResp,
|
||||
};
|
||||
use sea_orm::{ConnectionTrait, Statement};
|
||||
use crate::data_service::{DataScopeParams, PluginDataService, resolve_manifest_id};
|
||||
use crate::state::PluginState;
|
||||
use sea_orm::{ConnectionTrait, Statement};
|
||||
|
||||
/// 获取当前用户对指定权限的 data_scope 等级
|
||||
///
|
||||
@@ -61,10 +61,7 @@ async fn get_data_scope(
|
||||
///
|
||||
/// 当前返回 TenantContext 中的 department_ids。
|
||||
/// 未来实现递归查询部门树时将支持 include_sub_depts 参数。
|
||||
async fn get_dept_members(
|
||||
ctx: &TenantContext,
|
||||
_include_sub_depts: bool,
|
||||
) -> Vec<Uuid> {
|
||||
async fn get_dept_members(ctx: &TenantContext, _include_sub_depts: bool) -> Vec<Uuid> {
|
||||
// 当前 department_ids 为空时返回空列表
|
||||
// 未来实现递归查询部门树
|
||||
if ctx.department_ids.is_empty() {
|
||||
@@ -109,9 +106,7 @@ where
|
||||
require_permission(&ctx, &fine_perm)?;
|
||||
|
||||
// 解析数据权限范围
|
||||
let scope = resolve_data_scope(
|
||||
&ctx, &manifest_id, &entity, &fine_perm, &state.db,
|
||||
).await?;
|
||||
let scope = resolve_data_scope(&ctx, &manifest_id, &entity, &fine_perm, &state.db).await?;
|
||||
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
@@ -282,9 +277,16 @@ where
|
||||
require_permission(&ctx, &fine_perm)?;
|
||||
|
||||
let result = PluginDataService::partial_update(
|
||||
plugin_id, &entity, id, ctx.tenant_id, ctx.user_id,
|
||||
req.data, req.version, &state.db,
|
||||
).await?;
|
||||
plugin_id,
|
||||
&entity,
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
req.data,
|
||||
req.version,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
@@ -394,9 +396,7 @@ where
|
||||
require_permission(&ctx, &fine_perm)?;
|
||||
|
||||
// 解析数据权限范围
|
||||
let scope = resolve_data_scope(
|
||||
&ctx, &manifest_id, &entity, &fine_perm, &state.db,
|
||||
).await?;
|
||||
let scope = resolve_data_scope(&ctx, &manifest_id, &entity, &fine_perm, &state.db).await?;
|
||||
|
||||
// 解析 filter JSON
|
||||
let filter: Option<serde_json::Value> = params
|
||||
@@ -444,9 +444,7 @@ where
|
||||
require_permission(&ctx, &fine_perm)?;
|
||||
|
||||
// 解析数据权限范围
|
||||
let scope = resolve_data_scope(
|
||||
&ctx, &manifest_id, &entity, &fine_perm, &state.db,
|
||||
).await?;
|
||||
let scope = resolve_data_scope(&ctx, &manifest_id, &entity, &fine_perm, &state.db).await?;
|
||||
|
||||
// 解析 filter JSON
|
||||
let filter: Option<serde_json::Value> = params
|
||||
@@ -499,9 +497,7 @@ where
|
||||
require_permission(&ctx, &fine_perm)?;
|
||||
|
||||
// 解析数据权限范围
|
||||
let scope = resolve_data_scope(
|
||||
&ctx, &manifest_id, &entity, &fine_perm, &state.db,
|
||||
).await?;
|
||||
let scope = resolve_data_scope(&ctx, &manifest_id, &entity, &fine_perm, &state.db).await?;
|
||||
|
||||
let result = PluginDataService::timeseries(
|
||||
plugin_id,
|
||||
@@ -563,9 +559,8 @@ async fn check_entity_data_scope(
|
||||
|
||||
let Some(e) = entity else { return Ok(false) };
|
||||
|
||||
let schema: crate::manifest::PluginEntity =
|
||||
serde_json::from_value(e.schema_json)
|
||||
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
|
||||
let schema: crate::manifest::PluginEntity = serde_json::from_value(e.schema_json)
|
||||
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
|
||||
|
||||
Ok(schema.data_scope.unwrap_or(false))
|
||||
}
|
||||
@@ -595,11 +590,10 @@ where
|
||||
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
|
||||
require_permission(&ctx, &fine_perm)?;
|
||||
|
||||
let scope = resolve_data_scope(
|
||||
&ctx, &manifest_id, &entity, &fine_perm, &state.db,
|
||||
).await?;
|
||||
let scope = resolve_data_scope(&ctx, &manifest_id, &entity, &fine_perm, &state.db).await?;
|
||||
|
||||
let aggregations: Vec<(String, String)> = body.aggregations
|
||||
let aggregations: Vec<(String, String)> = body
|
||||
.aggregations
|
||||
.iter()
|
||||
.map(|a| (a.func.clone(), a.field.clone()))
|
||||
.collect();
|
||||
@@ -633,9 +627,9 @@ pub async fn resolve_ref_labels<S>(
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
{
|
||||
use sea_orm::{FromQueryResult, Statement};
|
||||
use crate::data_service::{resolve_cross_plugin_entity, is_plugin_active};
|
||||
use crate::data_service::{is_plugin_active, resolve_cross_plugin_entity};
|
||||
use crate::manifest::PluginEntity;
|
||||
use sea_orm::{FromQueryResult, Statement};
|
||||
|
||||
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
|
||||
@@ -643,12 +637,15 @@ where
|
||||
|
||||
// 获取当前实体的 schema
|
||||
let entity_info = crate::data_service::resolve_entity_info_cached(
|
||||
plugin_id, &entity, ctx.tenant_id, &state.db, &state.entity_cache,
|
||||
).await?;
|
||||
let entity_def: PluginEntity =
|
||||
serde_json::from_value(entity_info.schema_json).map_err(|e|
|
||||
AppError::Internal(format!("解析 entity schema 失败: {}", e))
|
||||
)?;
|
||||
plugin_id,
|
||||
&entity,
|
||||
ctx.tenant_id,
|
||||
&state.db,
|
||||
&state.entity_cache,
|
||||
)
|
||||
.await?;
|
||||
let entity_def: PluginEntity = serde_json::from_value(entity_info.schema_json)
|
||||
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
|
||||
|
||||
let mut labels = serde_json::Map::<String, serde_json::Value>::new();
|
||||
let mut meta = serde_json::Map::<String, serde_json::Value>::new();
|
||||
@@ -657,7 +654,9 @@ where
|
||||
// 查找字段定义
|
||||
let field_def = entity_def.fields.iter().find(|f| &f.name == field_name);
|
||||
let Some(field_def) = field_def else { continue };
|
||||
let Some(ref_entity_name) = &field_def.ref_entity else { continue };
|
||||
let Some(ref_entity_name) = &field_def.ref_entity else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let target_plugin = field_def.ref_plugin.as_deref().unwrap_or(&manifest_id);
|
||||
let label_field = field_def.ref_label_field.as_deref().unwrap_or("name");
|
||||
@@ -665,16 +664,20 @@ where
|
||||
let installed = is_plugin_active(target_plugin, ctx.tenant_id, &state.db).await;
|
||||
|
||||
// meta 信息
|
||||
meta.insert(field_name.clone(), serde_json::json!({
|
||||
"target_plugin": target_plugin,
|
||||
"target_entity": ref_entity_name,
|
||||
"label_field": label_field,
|
||||
"plugin_installed": installed,
|
||||
}));
|
||||
meta.insert(
|
||||
field_name.clone(),
|
||||
serde_json::json!({
|
||||
"target_plugin": target_plugin,
|
||||
"target_entity": ref_entity_name,
|
||||
"label_field": label_field,
|
||||
"plugin_installed": installed,
|
||||
}),
|
||||
);
|
||||
|
||||
if !installed {
|
||||
// 目标插件未安装 → 所有 UUID 返回 null
|
||||
let nulls: serde_json::Map<String, serde_json::Value> = uuids.iter()
|
||||
let nulls: serde_json::Map<String, serde_json::Value> = uuids
|
||||
.iter()
|
||||
.map(|u| (u.clone(), serde_json::Value::Null))
|
||||
.collect();
|
||||
labels.insert(field_name.clone(), serde_json::Value::Object(nulls));
|
||||
@@ -683,10 +686,18 @@ where
|
||||
|
||||
// 解析目标表名
|
||||
let target_table = if field_def.ref_plugin.is_some() {
|
||||
match resolve_cross_plugin_entity(target_plugin, ref_entity_name, ctx.tenant_id, &state.db).await {
|
||||
match resolve_cross_plugin_entity(
|
||||
target_plugin,
|
||||
ref_entity_name,
|
||||
ctx.tenant_id,
|
||||
&state.db,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(info) => info.table_name,
|
||||
Err(_) => {
|
||||
let nulls: serde_json::Map<String, serde_json::Value> = uuids.iter()
|
||||
let nulls: serde_json::Map<String, serde_json::Value> = uuids
|
||||
.iter()
|
||||
.map(|u| (u.clone(), serde_json::Value::Null))
|
||||
.collect();
|
||||
labels.insert(field_name.clone(), serde_json::Value::Object(nulls));
|
||||
@@ -698,33 +709,48 @@ where
|
||||
};
|
||||
|
||||
// 批量查询标签
|
||||
let uuid_strs: Vec<String> = uuids.iter().filter_map(|u| Uuid::parse_str(u).ok()).map(|u| u.to_string()).collect();
|
||||
let uuid_strs: Vec<String> = uuids
|
||||
.iter()
|
||||
.filter_map(|u| Uuid::parse_str(u).ok())
|
||||
.map(|u| u.to_string())
|
||||
.collect();
|
||||
if uuid_strs.is_empty() {
|
||||
labels.insert(field_name.clone(), serde_json::json!({}));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 构建 IN 子句参数
|
||||
let placeholders: Vec<String> = (2..uuid_strs.len() + 2).map(|i| format!("${}", i)).collect();
|
||||
let placeholders: Vec<String> = (2..uuid_strs.len() + 2)
|
||||
.map(|i| format!("${}", i))
|
||||
.collect();
|
||||
let sql = format!(
|
||||
"SELECT id::text, data->>'{}' as label FROM \"{}\" WHERE id IN ({}) AND tenant_id = $1 AND deleted_at IS NULL",
|
||||
label_field, target_table, placeholders.join(", ")
|
||||
label_field,
|
||||
target_table,
|
||||
placeholders.join(", ")
|
||||
);
|
||||
|
||||
let mut values: Vec<sea_orm::Value> = vec![ctx.tenant_id.into()];
|
||||
for u in &uuid_strs {
|
||||
let uuid: Uuid = u.parse().map_err(|e| AppError::Internal(format!("invalid uuid: {}", e)))?;
|
||||
let uuid: Uuid = u
|
||||
.parse()
|
||||
.map_err(|e| AppError::Internal(format!("invalid uuid: {}", e)))?;
|
||||
values.push(uuid.into());
|
||||
}
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct LabelRow { id: String, label: Option<String> }
|
||||
struct LabelRow {
|
||||
id: String,
|
||||
label: Option<String>,
|
||||
}
|
||||
|
||||
let rows = LabelRow::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
)).all(&state.db).await?;
|
||||
))
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let mut field_labels: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
|
||||
// 初始化所有请求的 UUID 为 null
|
||||
@@ -733,7 +759,10 @@ where
|
||||
}
|
||||
// 用查询结果填充
|
||||
for row in rows {
|
||||
field_labels.insert(row.id, serde_json::Value::String(row.label.unwrap_or_default()));
|
||||
field_labels.insert(
|
||||
row.id,
|
||||
serde_json::Value::String(row.label.unwrap_or_default()),
|
||||
);
|
||||
}
|
||||
|
||||
labels.insert(field_name.clone(), serde_json::Value::Object(field_labels));
|
||||
@@ -758,7 +787,7 @@ where
|
||||
PluginState: FromRef<S>,
|
||||
{
|
||||
use crate::entity::plugin_entity;
|
||||
use sea_orm::{EntityTrait, QueryFilter, ColumnTrait};
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
|
||||
let entities = plugin_entity::Entity::find()
|
||||
.filter(plugin_entity::Column::TenantId.eq(ctx.tenant_id))
|
||||
@@ -767,18 +796,23 @@ where
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let result: Vec<PublicEntityResp> = entities.iter().map(|e| {
|
||||
let display_name = e.schema_json.get("display_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&e.entity_name)
|
||||
.to_string();
|
||||
PublicEntityResp {
|
||||
manifest_id: e.manifest_id.clone(),
|
||||
plugin_id: e.plugin_id.to_string(),
|
||||
entity_name: e.entity_name.clone(),
|
||||
display_name,
|
||||
}
|
||||
}).collect();
|
||||
let result: Vec<PublicEntityResp> = entities
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let display_name = e
|
||||
.schema_json
|
||||
.get("display_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&e.entity_name)
|
||||
.to_string();
|
||||
PublicEntityResp {
|
||||
manifest_id: e.manifest_id.clone(),
|
||||
plugin_id: e.plugin_id.to_string(),
|
||||
entity_name: e.entity_name.clone(),
|
||||
display_name,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
@@ -807,16 +841,14 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
use crate::data_dto::ExportPayload;
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::body::Body;
|
||||
use axum::http::{StatusCode, header};
|
||||
|
||||
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
|
||||
require_permission(&ctx, &fine_perm)?;
|
||||
|
||||
let scope = resolve_data_scope(
|
||||
&ctx, &manifest_id, &entity, &fine_perm, &state.db,
|
||||
).await?;
|
||||
let scope = resolve_data_scope(&ctx, &manifest_id, &entity, &fine_perm, &state.db).await?;
|
||||
|
||||
let filter: Option<serde_json::Value> = params
|
||||
.filter
|
||||
@@ -838,7 +870,11 @@ where
|
||||
)
|
||||
.await?;
|
||||
|
||||
let filename = format!("{}_export_{}", entity, chrono::Utc::now().format("%Y%m%d%H%M%S"));
|
||||
let filename = format!(
|
||||
"{}_export_{}",
|
||||
entity,
|
||||
chrono::Utc::now().format("%Y%m%d%H%M%S")
|
||||
);
|
||||
match payload {
|
||||
ExportPayload::Json(data) => {
|
||||
let body = serde_json::to_string(&ApiResponse::ok(data))
|
||||
@@ -849,22 +885,27 @@ where
|
||||
.body(Body::from(body))
|
||||
.unwrap())
|
||||
}
|
||||
ExportPayload::Csv(bytes) => {
|
||||
Ok(axum::response::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/csv; charset=utf-8")
|
||||
.header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}.csv\"", filename))
|
||||
.body(Body::from(bytes))
|
||||
.unwrap())
|
||||
}
|
||||
ExportPayload::Xlsx(bytes) => {
|
||||
Ok(axum::response::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
.header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}.xlsx\"", filename))
|
||||
.body(Body::from(bytes))
|
||||
.unwrap())
|
||||
}
|
||||
ExportPayload::Csv(bytes) => Ok(axum::response::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/csv; charset=utf-8")
|
||||
.header(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{}.csv\"", filename),
|
||||
)
|
||||
.body(Body::from(bytes))
|
||||
.unwrap()),
|
||||
ExportPayload::Xlsx(bytes) => Ok(axum::response::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
.header(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{}.xlsx\"", filename),
|
||||
)
|
||||
.body(Body::from(bytes))
|
||||
.unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,12 +968,8 @@ where
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
|
||||
let report = PluginDataService::reconcile_references(
|
||||
plugin_id,
|
||||
ctx.tenant_id,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
let report =
|
||||
PluginDataService::reconcile_references(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(report)))
|
||||
}
|
||||
@@ -982,16 +1019,19 @@ where
|
||||
|
||||
let mid = manifest_id.clone();
|
||||
let ent = entity.clone();
|
||||
let items = rows.into_iter().map(|r| UserViewResp {
|
||||
id: r.id.to_string(),
|
||||
plugin_id: mid.clone(),
|
||||
entity_name: ent.clone(),
|
||||
view_name: r.view_name,
|
||||
view_config: r.view_config,
|
||||
is_default: r.is_default,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}).collect();
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|r| UserViewResp {
|
||||
id: r.id.to_string(),
|
||||
plugin_id: mid.clone(),
|
||||
entity_name: ent.clone(),
|
||||
view_name: r.view_name,
|
||||
view_config: r.view_config,
|
||||
is_default: r.is_default,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(ApiResponse::ok(items)))
|
||||
}
|
||||
@@ -1067,11 +1107,15 @@ where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
state.db.execute(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"DELETE FROM plugin_user_views WHERE id = $1 AND tenant_id = $2 AND user_id = $3",
|
||||
[view_id.into(), ctx.tenant_id.into(), ctx.user_id.into()],
|
||||
)).await.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
state
|
||||
.db
|
||||
.execute(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"DELETE FROM plugin_user_views WHERE id = $1 AND tenant_id = $2 AND user_id = $3",
|
||||
[view_id.into(), ctx.tenant_id.into(), ctx.user_id.into()],
|
||||
))
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user