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:
@@ -1,13 +1,13 @@
|
||||
use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, FromQueryResult, QueryFilter, Statement};
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::audit::{AuditLog};
|
||||
use erp_core::audit::AuditLog;
|
||||
use erp_core::audit_service;
|
||||
use erp_core::error::{AppError, AppResult};
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
use crate::data_dto::{AggregateMultiRow, BatchActionReq, PluginDataResp};
|
||||
use crate::dynamic_table::{sanitize_identifier, DynamicTableManager};
|
||||
use crate::dynamic_table::{DynamicTableManager, sanitize_identifier};
|
||||
use crate::entity::plugin;
|
||||
use crate::entity::plugin_entity;
|
||||
use crate::error::PluginError;
|
||||
@@ -25,11 +25,11 @@ async fn find_trigger_events(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("插件 {} 不存在", plugin_db_id)))?;
|
||||
|
||||
let manifest: crate::manifest::PluginManifest =
|
||||
serde_json::from_value(model.manifest_json)
|
||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
let manifest: crate::manifest::PluginManifest = serde_json::from_value(model.manifest_json)
|
||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
|
||||
let triggers = manifest.trigger_events
|
||||
let triggers = manifest
|
||||
.trigger_events
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|t| t.entity == entity_name)
|
||||
@@ -38,6 +38,7 @@ async fn find_trigger_events(
|
||||
}
|
||||
|
||||
/// 发布触发事件
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn emit_trigger_events(
|
||||
triggers: &[crate::manifest::PluginTriggerEvent],
|
||||
action: &str,
|
||||
@@ -68,11 +69,8 @@ async fn emit_trigger_events(
|
||||
"action": action,
|
||||
});
|
||||
// 发布原始触发事件
|
||||
let event = erp_core::events::DomainEvent::new(
|
||||
&trigger.name,
|
||||
tenant_id,
|
||||
payload.clone(),
|
||||
);
|
||||
let event =
|
||||
erp_core::events::DomainEvent::new(&trigger.name, tenant_id, payload.clone());
|
||||
event_bus.publish(event, db).await;
|
||||
|
||||
// 同时发布 plugin.trigger.{manifest_id} 事件用于通知引擎
|
||||
@@ -110,7 +108,17 @@ impl PluginDataService {
|
||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let fields = info.fields()?;
|
||||
validate_data(&data, &fields)?;
|
||||
validate_ref_entities(&data, &fields, entity_name, plugin_id, tenant_id, db, true, None).await?;
|
||||
validate_ref_entities(
|
||||
&data,
|
||||
&fields,
|
||||
entity_name,
|
||||
plugin_id,
|
||||
tenant_id,
|
||||
db,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (sql, values) =
|
||||
DynamicTableManager::build_insert_sql(&info.table_name, tenant_id, operator_id, &data);
|
||||
@@ -134,17 +142,33 @@ impl PluginDataService {
|
||||
.ok_or_else(|| PluginError::DatabaseError("INSERT 未返回结果".to_string()))?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "plugin.data.create", entity_name)
|
||||
.with_resource_id(result.id),
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"plugin.data.create",
|
||||
entity_name,
|
||||
)
|
||||
.with_resource_id(result.id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 触发事件发布
|
||||
if let Ok(triggers) = find_trigger_events(plugin_id, entity_name, db).await {
|
||||
if let Ok(mid) = resolve_manifest_id(plugin_id, tenant_id, db).await {
|
||||
emit_trigger_events(&triggers, "create", entity_name, &result.id.to_string(), tenant_id, Some(&result.data), _event_bus, db, &mid).await;
|
||||
}
|
||||
if let Ok(triggers) = find_trigger_events(plugin_id, entity_name, db).await
|
||||
&& let Ok(mid) = resolve_manifest_id(plugin_id, tenant_id, db).await
|
||||
{
|
||||
emit_trigger_events(
|
||||
&triggers,
|
||||
"create",
|
||||
entity_name,
|
||||
&result.id.to_string(),
|
||||
tenant_id,
|
||||
Some(&result.data),
|
||||
_event_bus,
|
||||
db,
|
||||
&mid,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(PluginDataResp {
|
||||
@@ -157,6 +181,7 @@ impl PluginDataService {
|
||||
}
|
||||
|
||||
/// 列表查询(支持过滤/搜索/排序/Generated Column 路由/数据权限)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn list(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
@@ -171,8 +196,7 @@ impl PluginDataService {
|
||||
cache: &moka::sync::Cache<String, EntityInfo>,
|
||||
scope: Option<DataScopeParams>,
|
||||
) -> AppResult<(Vec<PluginDataResp>, u64)> {
|
||||
let info =
|
||||
resolve_entity_info_cached(plugin_id, entity_name, tenant_id, db, cache).await?;
|
||||
let info = resolve_entity_info_cached(plugin_id, entity_name, tenant_id, db, cache).await?;
|
||||
|
||||
// 获取 searchable 字段列表
|
||||
let entity_fields = info.fields()?;
|
||||
@@ -224,7 +248,7 @@ impl PluginDataService {
|
||||
sort_order,
|
||||
&info.generated_fields,
|
||||
)
|
||||
.map_err(|e| AppError::Validation(e))?;
|
||||
.map_err(AppError::Validation)?;
|
||||
|
||||
// 注入数据权限条件(scope 参数索引接在 values 之后)
|
||||
let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1);
|
||||
@@ -271,7 +295,8 @@ impl PluginDataService {
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<PluginDataResp> {
|
||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let (sql, values) = DynamicTableManager::build_get_by_id_sql(&info.table_name, id, tenant_id);
|
||||
let (sql, values) =
|
||||
DynamicTableManager::build_get_by_id_sql(&info.table_name, id, tenant_id);
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct DataRow {
|
||||
@@ -301,6 +326,7 @@ impl PluginDataService {
|
||||
}
|
||||
|
||||
/// 更新
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn update(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
@@ -315,7 +341,17 @@ impl PluginDataService {
|
||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let fields = info.fields()?;
|
||||
validate_data(&data, &fields)?;
|
||||
validate_ref_entities(&data, &fields, entity_name, plugin_id, tenant_id, db, false, Some(id)).await?;
|
||||
validate_ref_entities(
|
||||
&data,
|
||||
&fields,
|
||||
entity_name,
|
||||
plugin_id,
|
||||
tenant_id,
|
||||
db,
|
||||
false,
|
||||
Some(id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 循环引用检测
|
||||
for field in &fields {
|
||||
@@ -349,20 +385,36 @@ impl PluginDataService {
|
||||
))
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::VersionMismatch)?;
|
||||
.ok_or(AppError::VersionMismatch)?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "plugin.data.update", entity_name)
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"plugin.data.update",
|
||||
entity_name,
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 触发事件发布
|
||||
if let Ok(triggers) = find_trigger_events(plugin_id, entity_name, db).await {
|
||||
if let Ok(mid) = resolve_manifest_id(plugin_id, tenant_id, db).await {
|
||||
emit_trigger_events(&triggers, "update", entity_name, &result.id.to_string(), tenant_id, Some(&result.data), _event_bus, db, &mid).await;
|
||||
}
|
||||
if let Ok(triggers) = find_trigger_events(plugin_id, entity_name, db).await
|
||||
&& let Ok(mid) = resolve_manifest_id(plugin_id, tenant_id, db).await
|
||||
{
|
||||
emit_trigger_events(
|
||||
&triggers,
|
||||
"update",
|
||||
entity_name,
|
||||
&result.id.to_string(),
|
||||
tenant_id,
|
||||
Some(&result.data),
|
||||
_event_bus,
|
||||
db,
|
||||
&mid,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(PluginDataResp {
|
||||
@@ -375,6 +427,7 @@ impl PluginDataService {
|
||||
}
|
||||
|
||||
/// 部分更新(PATCH)— 只合并提供的字段
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn partial_update(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
@@ -401,10 +454,25 @@ impl PluginDataService {
|
||||
};
|
||||
|
||||
validate_data(&merged, &fields)?;
|
||||
validate_ref_entities(&merged, &fields, entity_name, plugin_id, tenant_id, db, false, Some(id)).await?;
|
||||
validate_ref_entities(
|
||||
&merged,
|
||||
&fields,
|
||||
entity_name,
|
||||
plugin_id,
|
||||
tenant_id,
|
||||
db,
|
||||
false,
|
||||
Some(id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (sql, values) = DynamicTableManager::build_patch_sql(
|
||||
&info.table_name, id, tenant_id, operator_id, partial_data, expected_version,
|
||||
&info.table_name,
|
||||
id,
|
||||
tenant_id,
|
||||
operator_id,
|
||||
partial_data,
|
||||
expected_version,
|
||||
);
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
@@ -417,8 +485,13 @@ impl PluginDataService {
|
||||
}
|
||||
|
||||
let result = PatchResult::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres, sql, values,
|
||||
)).one(db).await?.ok_or_else(|| AppError::VersionMismatch)?;
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or(AppError::VersionMismatch)?;
|
||||
|
||||
Ok(PluginDataResp {
|
||||
id: result.id.to_string(),
|
||||
@@ -460,12 +533,16 @@ impl PluginDataService {
|
||||
);
|
||||
#[derive(FromQueryResult)]
|
||||
#[allow(dead_code)] // FromQueryResult 映射需要 chk 字段,仅检查是否存在
|
||||
struct RefCheck { chk: Option<i32> }
|
||||
struct RefCheck {
|
||||
chk: Option<i32>,
|
||||
}
|
||||
let has_ref = RefCheck::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
check_sql,
|
||||
[id.to_string().into(), tenant_id.into()],
|
||||
)).one(db).await?;
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
if has_ref.is_some() {
|
||||
return Err(AppError::Validation(format!(
|
||||
"存在关联的 {} 记录,无法删除",
|
||||
@@ -482,7 +559,8 @@ impl PluginDataService {
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
nullify_sql,
|
||||
[id.to_string().into(), tenant_id.into()],
|
||||
)).await?;
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
crate::manifest::OnDeleteStrategy::Cascade => {
|
||||
let cascade_sql = format!(
|
||||
@@ -493,7 +571,8 @@ impl PluginDataService {
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
cascade_sql,
|
||||
[id.to_string().into(), tenant_id.into()],
|
||||
)).await?;
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -509,23 +588,34 @@ impl PluginDataService {
|
||||
.await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, None, "plugin.data.delete", entity_name)
|
||||
.with_resource_id(id),
|
||||
AuditLog::new(tenant_id, None, "plugin.data.delete", entity_name).with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 触发事件发布
|
||||
if let Ok(triggers) = find_trigger_events(plugin_id, entity_name, db).await {
|
||||
if let Ok(mid) = resolve_manifest_id(plugin_id, tenant_id, db).await {
|
||||
emit_trigger_events(&triggers, "delete", entity_name, &id.to_string(), tenant_id, None, _event_bus, db, &mid).await;
|
||||
}
|
||||
if let Ok(triggers) = find_trigger_events(plugin_id, entity_name, db).await
|
||||
&& let Ok(mid) = resolve_manifest_id(plugin_id, tenant_id, db).await
|
||||
{
|
||||
emit_trigger_events(
|
||||
&triggers,
|
||||
"delete",
|
||||
entity_name,
|
||||
&id.to_string(),
|
||||
tenant_id,
|
||||
None,
|
||||
_event_bus,
|
||||
db,
|
||||
&mid,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 导出数据(支持 JSON/CSV/XLSX 格式)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn export(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
@@ -541,8 +631,7 @@ impl PluginDataService {
|
||||
) -> AppResult<crate::data_dto::ExportPayload> {
|
||||
use crate::data_dto::ExportPayload;
|
||||
|
||||
let info =
|
||||
resolve_entity_info_cached(plugin_id, entity_name, tenant_id, db, cache).await?;
|
||||
let info = resolve_entity_info_cached(plugin_id, entity_name, tenant_id, db, cache).await?;
|
||||
|
||||
let entity_fields = info.fields()?;
|
||||
let search_tuple = {
|
||||
@@ -568,14 +657,16 @@ impl PluginDataService {
|
||||
sort_order,
|
||||
&info.generated_fields,
|
||||
)
|
||||
.map_err(|e| AppError::Validation(e))?;
|
||||
.map_err(AppError::Validation)?;
|
||||
|
||||
let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1);
|
||||
let sql = merge_scope_condition(sql, &scope_condition);
|
||||
values.extend(scope_condition.1);
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct DataRow { data: serde_json::Value }
|
||||
struct DataRow {
|
||||
data: serde_json::Value,
|
||||
}
|
||||
|
||||
let rows = DataRow::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
@@ -601,19 +692,26 @@ impl PluginDataService {
|
||||
) -> AppResult<Vec<u8>> {
|
||||
let mut wtr = csv::Writer::from_writer(Vec::new());
|
||||
let headers: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
|
||||
wtr.write_record(&headers).map_err(|e| AppError::Internal(format!("CSV 写头失败: {}", e)))?;
|
||||
wtr.write_record(&headers)
|
||||
.map_err(|e| AppError::Internal(format!("CSV 写头失败: {}", e)))?;
|
||||
|
||||
for row in rows {
|
||||
let record: Vec<String> = headers.iter().map(|h| {
|
||||
row.get(*h).and_then(|v| match v {
|
||||
serde_json::Value::String(s) => Some(s.clone()),
|
||||
serde_json::Value::Number(n) => Some(n.to_string()),
|
||||
serde_json::Value::Bool(b) => Some(b.to_string()),
|
||||
serde_json::Value::Null => Some(String::new()),
|
||||
other => Some(other.to_string()),
|
||||
}).unwrap_or_default()
|
||||
}).collect();
|
||||
wtr.write_record(&record).map_err(|e| AppError::Internal(format!("CSV 写行失败: {}", e)))?;
|
||||
let record: Vec<String> = headers
|
||||
.iter()
|
||||
.map(|h| {
|
||||
row.get(*h)
|
||||
.map(|v| match v {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::Bool(b) => b.to_string(),
|
||||
serde_json::Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect();
|
||||
wtr.write_record(&record)
|
||||
.map_err(|e| AppError::Internal(format!("CSV 写行失败: {}", e)))?;
|
||||
}
|
||||
|
||||
wtr.into_inner()
|
||||
@@ -628,7 +726,10 @@ impl PluginDataService {
|
||||
|
||||
let mut wb = Workbook::new();
|
||||
let ws = wb.add_worksheet();
|
||||
let header_fmt = Format::new().set_bold().set_background_color(Color::RGB(0x4F46E5)).set_font_color(Color::White);
|
||||
let header_fmt = Format::new()
|
||||
.set_bold()
|
||||
.set_background_color(Color::RGB(0x4F46E5))
|
||||
.set_font_color(Color::White);
|
||||
|
||||
for (col, field) in fields.iter().enumerate() {
|
||||
let label = field.display_name.as_deref().unwrap_or(&field.name);
|
||||
@@ -641,18 +742,26 @@ impl PluginDataService {
|
||||
let val = row.get(&field.name);
|
||||
let row_num = (row_idx + 1) as u32;
|
||||
match val {
|
||||
Some(serde_json::Value::String(s)) => { ws.write_string(row_num, col as u16, s).ok(); }
|
||||
Some(serde_json::Value::Number(n)) => {
|
||||
if let Some(f) = n.as_f64() { ws.write_number(row_num, col as u16, f).ok(); }
|
||||
else { ws.write_string(row_num, col as u16, &n.to_string()).ok(); }
|
||||
Some(serde_json::Value::String(s)) => {
|
||||
ws.write_string(row_num, col as u16, s).ok();
|
||||
}
|
||||
Some(serde_json::Value::Number(n)) => {
|
||||
if let Some(f) = n.as_f64() {
|
||||
ws.write_number(row_num, col as u16, f).ok();
|
||||
} else {
|
||||
ws.write_string(row_num, col as u16, n.to_string()).ok();
|
||||
}
|
||||
}
|
||||
Some(serde_json::Value::Bool(b)) => {
|
||||
ws.write_string(row_num, col as u16, b.to_string()).ok();
|
||||
}
|
||||
Some(serde_json::Value::Bool(b)) => { ws.write_string(row_num, col as u16, &b.to_string()).ok(); }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let buf = wb.save_to_buffer()
|
||||
let buf = wb
|
||||
.save_to_buffer()
|
||||
.map_err(|e| AppError::Internal(format!("XLSX 保存失败: {}", e)))?;
|
||||
Ok(buf.to_vec())
|
||||
}
|
||||
@@ -681,45 +790,83 @@ impl PluginDataService {
|
||||
|
||||
for (i, row_data) in rows.iter().enumerate() {
|
||||
if let Err(e) = validate_data(row_data, &fields) {
|
||||
row_errors.push(ImportRowError { row: i, errors: vec![e.to_string()] });
|
||||
row_errors.push(ImportRowError {
|
||||
row: i,
|
||||
errors: vec![e.to_string()],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = validate_ref_entities(row_data, &fields, entity_name, plugin_id, tenant_id, db, true, None).await {
|
||||
row_errors.push(ImportRowError { row: i, errors: vec![e.to_string()] });
|
||||
if let Err(e) = validate_ref_entities(
|
||||
row_data,
|
||||
&fields,
|
||||
entity_name,
|
||||
plugin_id,
|
||||
tenant_id,
|
||||
db,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
row_errors.push(ImportRowError {
|
||||
row: i,
|
||||
errors: vec![e.to_string()],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let (sql, values) =
|
||||
DynamicTableManager::build_insert_sql(&info.table_name, tenant_id, operator_id, row_data);
|
||||
let (sql, values) = DynamicTableManager::build_insert_sql(
|
||||
&info.table_name,
|
||||
tenant_id,
|
||||
operator_id,
|
||||
row_data,
|
||||
);
|
||||
|
||||
let result = db.execute(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
)).await;
|
||||
let result = db
|
||||
.execute(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => success_count += 1,
|
||||
Err(e) => {
|
||||
row_errors.push(ImportRowError { row: i, errors: vec![format!("写入失败: {}", e)] });
|
||||
row_errors.push(ImportRowError {
|
||||
row: i,
|
||||
errors: vec![format!("写入失败: {}", e)],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "plugin.data.import", entity_name),
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"plugin.data.import",
|
||||
entity_name,
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Ok(triggers) = find_trigger_events(plugin_id, entity_name, db).await {
|
||||
if let Ok(mid) = resolve_manifest_id(plugin_id, tenant_id, db).await {
|
||||
emit_trigger_events(
|
||||
&triggers, "create", entity_name,
|
||||
&format!("batch_import:{}", success_count),
|
||||
tenant_id, None, event_bus, db, &mid,
|
||||
).await;
|
||||
}
|
||||
if let Ok(triggers) = find_trigger_events(plugin_id, entity_name, db).await
|
||||
&& let Ok(mid) = resolve_manifest_id(plugin_id, tenant_id, db).await
|
||||
{
|
||||
emit_trigger_events(
|
||||
&triggers,
|
||||
"create",
|
||||
entity_name,
|
||||
&format!("batch_import:{}", success_count),
|
||||
tenant_id,
|
||||
None,
|
||||
event_bus,
|
||||
db,
|
||||
&mid,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(ImportResult {
|
||||
@@ -757,13 +904,15 @@ impl PluginDataService {
|
||||
"batch_delete" => {
|
||||
// 批量删除前先执行级联策略(逐条,复用 delete 的级联逻辑)
|
||||
let entity_def: crate::manifest::PluginEntity =
|
||||
serde_json::from_value(info.schema_json.clone())
|
||||
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
|
||||
serde_json::from_value(info.schema_json.clone()).map_err(|e| {
|
||||
AppError::Internal(format!("解析 entity schema 失败: {}", e))
|
||||
})?;
|
||||
let manifest_id = resolve_manifest_id(plugin_id, tenant_id, db).await?;
|
||||
|
||||
for &del_id in &ids {
|
||||
for relation in &entity_def.relations {
|
||||
let rel_table = DynamicTableManager::table_name(&manifest_id, &relation.entity);
|
||||
let rel_table =
|
||||
DynamicTableManager::table_name(&manifest_id, &relation.entity);
|
||||
let fk = sanitize_identifier(&relation.foreign_key);
|
||||
match relation.on_delete {
|
||||
crate::manifest::OnDeleteStrategy::Restrict => {
|
||||
@@ -773,12 +922,17 @@ impl PluginDataService {
|
||||
);
|
||||
#[derive(FromQueryResult)]
|
||||
#[allow(dead_code)] // FromQueryResult 映射需要 chk 字段,仅检查是否存在
|
||||
struct RefCheck { chk: Option<i32> }
|
||||
let has_ref = RefCheck::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
check_sql,
|
||||
[del_id.to_string().into(), tenant_id.into()],
|
||||
)).one(db).await?;
|
||||
struct RefCheck {
|
||||
chk: Option<i32>,
|
||||
}
|
||||
let has_ref =
|
||||
RefCheck::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
check_sql,
|
||||
[del_id.to_string().into(), tenant_id.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
if has_ref.is_some() {
|
||||
return Err(AppError::Validation(format!(
|
||||
"记录 {} 存在关联的 {} 记录,无法删除",
|
||||
@@ -795,7 +949,8 @@ impl PluginDataService {
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
nullify_sql,
|
||||
[del_id.to_string().into(), tenant_id.into()],
|
||||
)).await?;
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
crate::manifest::OnDeleteStrategy::Cascade => {
|
||||
let cascade_sql = format!(
|
||||
@@ -806,7 +961,8 @@ impl PluginDataService {
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
cascade_sql,
|
||||
[del_id.to_string().into(), tenant_id.into()],
|
||||
)).await?;
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -884,7 +1040,7 @@ impl PluginDataService {
|
||||
return Err(AppError::Validation(format!(
|
||||
"不支持的批量操作: {}",
|
||||
req.action
|
||||
)))
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -922,7 +1078,7 @@ impl PluginDataService {
|
||||
filter,
|
||||
search_tuple,
|
||||
)
|
||||
.map_err(|e| AppError::Validation(e))?;
|
||||
.map_err(AppError::Validation)?;
|
||||
|
||||
// 合并数据权限条件
|
||||
let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1);
|
||||
@@ -968,7 +1124,7 @@ impl PluginDataService {
|
||||
group_by_field,
|
||||
filter,
|
||||
)
|
||||
.map_err(|e| AppError::Validation(e))?;
|
||||
.map_err(AppError::Validation)?;
|
||||
|
||||
// 合并数据权限条件
|
||||
let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1);
|
||||
@@ -1000,6 +1156,7 @@ impl PluginDataService {
|
||||
}
|
||||
|
||||
/// 多聚合查询 — 支持 COUNT + SUM/AVG/MIN/MAX
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn aggregate_multi(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
@@ -1019,7 +1176,7 @@ impl PluginDataService {
|
||||
aggregations,
|
||||
filter,
|
||||
)
|
||||
.map_err(|e| AppError::Validation(e))?;
|
||||
.map_err(AppError::Validation)?;
|
||||
|
||||
let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1);
|
||||
if !scope_condition.0.is_empty() {
|
||||
@@ -1048,17 +1205,26 @@ impl PluginDataService {
|
||||
.and_then(|d| d.as_array().cloned())
|
||||
.unwrap_or_default();
|
||||
|
||||
let rows = json_rows.into_iter().map(|v| AggregateMultiRow {
|
||||
key: v.get("key").and_then(|k| k.as_str()).unwrap_or_default().to_string(),
|
||||
count: v.get("count").and_then(|c| c.as_i64()).unwrap_or(0),
|
||||
metrics: v.as_object()
|
||||
.map(|m| m.iter()
|
||||
.filter(|(k, _)| *k != "key" && *k != "count")
|
||||
.map(|(k, v)| (k.clone(), v.as_f64().unwrap_or(0.0)))
|
||||
.collect()
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
}).collect();
|
||||
let rows = json_rows
|
||||
.into_iter()
|
||||
.map(|v| AggregateMultiRow {
|
||||
key: v
|
||||
.get("key")
|
||||
.and_then(|k| k.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
count: v.get("count").and_then(|c| c.as_i64()).unwrap_or(0),
|
||||
metrics: v
|
||||
.as_object()
|
||||
.map(|m| {
|
||||
m.iter()
|
||||
.filter(|(k, _)| *k != "key" && *k != "count")
|
||||
.map(|(k, v)| (k.clone(), v.as_f64().unwrap_or(0.0)))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -1073,10 +1239,20 @@ impl PluginDataService {
|
||||
filter: Option<serde_json::Value>,
|
||||
) -> AppResult<Vec<(String, i64)>> {
|
||||
// TODO: 未来版本添加 Redis 缓存层
|
||||
Self::aggregate(plugin_id, entity_name, tenant_id, db, group_by_field, filter, None).await
|
||||
Self::aggregate(
|
||||
plugin_id,
|
||||
entity_name,
|
||||
tenant_id,
|
||||
db,
|
||||
group_by_field,
|
||||
filter,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 时间序列聚合 — 按时间字段截断为 day/week/month 统计计数
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn timeseries(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
@@ -1098,7 +1274,7 @@ impl PluginDataService {
|
||||
start.as_deref(),
|
||||
end.as_deref(),
|
||||
)
|
||||
.map_err(|e| AppError::Validation(e))?;
|
||||
.map_err(AppError::Validation)?;
|
||||
|
||||
// 合并数据权限条件
|
||||
let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1);
|
||||
@@ -1156,7 +1332,9 @@ impl PluginDataService {
|
||||
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
|
||||
|
||||
// 找出所有有 ref_entity 的字段
|
||||
let ref_fields: Vec<&PluginField> = schema.fields.iter()
|
||||
let ref_fields: Vec<&PluginField> = schema
|
||||
.fields
|
||||
.iter()
|
||||
.filter(|f| f.ref_entity.is_some())
|
||||
.collect();
|
||||
|
||||
@@ -1198,7 +1376,9 @@ impl PluginDataService {
|
||||
|
||||
for row in rows {
|
||||
// 验证 ref_val 是有效的 UUID 且目标记录存在
|
||||
let Ok(target_uuid) = Uuid::parse_str(&row.ref_val) else { continue };
|
||||
let Ok(target_uuid) = Uuid::parse_str(&row.ref_val) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let ref_entity_name = field.ref_entity.as_deref().unwrap_or("");
|
||||
let ref_plugin = field.ref_plugin.as_deref().unwrap_or(&manifest_id);
|
||||
@@ -1260,9 +1440,8 @@ pub async fn resolve_manifest_id(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("插件 {} 不存在", plugin_id)))?;
|
||||
|
||||
let manifest: crate::manifest::PluginManifest =
|
||||
serde_json::from_value(model.manifest_json)
|
||||
.map_err(|e| AppError::Internal(format!("解析插件 manifest 失败: {}", e)))?;
|
||||
let manifest: crate::manifest::PluginManifest = serde_json::from_value(model.manifest_json)
|
||||
.map_err(|e| AppError::Internal(format!("解析插件 manifest 失败: {}", e)))?;
|
||||
|
||||
Ok(manifest.metadata.id)
|
||||
}
|
||||
@@ -1417,9 +1596,9 @@ pub async fn is_plugin_active(
|
||||
|
||||
/// 校验数据:检查 required 字段 + 正则校验
|
||||
fn validate_data(data: &serde_json::Value, fields: &[PluginField]) -> AppResult<()> {
|
||||
let obj = data.as_object().ok_or_else(|| {
|
||||
AppError::Validation("data 必须是 JSON 对象".to_string())
|
||||
})?;
|
||||
let obj = data
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::Validation("data 必须是 JSON 对象".to_string()))?;
|
||||
|
||||
for field in fields {
|
||||
let label = field.display_name.as_deref().unwrap_or(&field.name);
|
||||
@@ -1430,20 +1609,18 @@ fn validate_data(data: &serde_json::Value, fields: &[PluginField]) -> AppResult<
|
||||
}
|
||||
|
||||
// 正则校验
|
||||
if let Some(validation) = &field.validation {
|
||||
if let Some(pattern) = &validation.pattern {
|
||||
if let Some(val) = obj.get(&field.name) {
|
||||
let str_val = val.as_str().unwrap_or("");
|
||||
if !str_val.is_empty() {
|
||||
let re = regex::Regex::new(pattern)
|
||||
.map_err(|e| AppError::Internal(format!("正则表达式编译失败: {}", e)))?;
|
||||
if !re.is_match(str_val) {
|
||||
let default_msg = format!("字段 '{}' 格式不正确", label);
|
||||
let msg = validation.message.as_deref()
|
||||
.unwrap_or(&default_msg);
|
||||
return Err(AppError::Validation(msg.to_string()));
|
||||
}
|
||||
}
|
||||
if let Some(validation) = &field.validation
|
||||
&& let Some(pattern) = &validation.pattern
|
||||
&& let Some(val) = obj.get(&field.name)
|
||||
{
|
||||
let str_val = val.as_str().unwrap_or("");
|
||||
if !str_val.is_empty() {
|
||||
let re = regex::Regex::new(pattern)
|
||||
.map_err(|e| AppError::Internal(format!("正则表达式编译失败: {}", e)))?;
|
||||
if !re.is_match(str_val) {
|
||||
let default_msg = format!("字段 '{}' 格式不正确", label);
|
||||
let msg = validation.message.as_deref().unwrap_or(&default_msg);
|
||||
return Err(AppError::Validation(msg.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1455,6 +1632,7 @@ fn validate_data(data: &serde_json::Value, fields: &[PluginField]) -> AppResult<
|
||||
/// 校验外键引用 — 检查 ref_entity 字段指向的记录是否存在
|
||||
/// 支持同插件引用和跨插件引用(ref_plugin 字段)
|
||||
/// 核心原则:跨插件引用目标插件未安装时跳过校验(软警告)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn validate_ref_entities(
|
||||
data: &serde_json::Value,
|
||||
fields: &[PluginField],
|
||||
@@ -1465,17 +1643,25 @@ async fn validate_ref_entities(
|
||||
is_create: bool,
|
||||
record_id: Option<Uuid>,
|
||||
) -> AppResult<()> {
|
||||
let obj = data.as_object().ok_or_else(|| {
|
||||
AppError::Validation("data 必须是 JSON 对象".to_string())
|
||||
})?;
|
||||
let obj = data
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::Validation("data 必须是 JSON 对象".to_string()))?;
|
||||
|
||||
for field in fields {
|
||||
let Some(ref_entity_name) = &field.ref_entity else { continue };
|
||||
let Some(val) = obj.get(&field.name) else { continue };
|
||||
let Some(ref_entity_name) = &field.ref_entity else {
|
||||
continue;
|
||||
};
|
||||
let Some(val) = obj.get(&field.name) else {
|
||||
continue;
|
||||
};
|
||||
let str_val = val.as_str().unwrap_or("").trim().to_string();
|
||||
|
||||
if str_val.is_empty() && !field.required { continue; }
|
||||
if str_val.is_empty() { continue; }
|
||||
if str_val.is_empty() && !field.required {
|
||||
continue;
|
||||
}
|
||||
if str_val.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ref_id = Uuid::parse_str(&str_val).map_err(|_| {
|
||||
AppError::Validation(format!(
|
||||
@@ -1490,10 +1676,13 @@ async fn validate_ref_entities(
|
||||
continue;
|
||||
}
|
||||
// 自引用 + update:检查是否引用自身
|
||||
if ref_entity_name == current_entity && field.ref_plugin.is_none() && !is_create {
|
||||
if let Some(rid) = record_id {
|
||||
if ref_id == rid { continue; }
|
||||
}
|
||||
if ref_entity_name == current_entity
|
||||
&& field.ref_plugin.is_none()
|
||||
&& !is_create
|
||||
&& let Some(rid) = record_id
|
||||
&& ref_id == rid
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 确定目标表名
|
||||
@@ -1534,12 +1723,16 @@ async fn validate_ref_entities(
|
||||
);
|
||||
#[derive(FromQueryResult)]
|
||||
#[allow(dead_code)] // FromQueryResult 映射需要 check_result 字段,仅检查是否存在
|
||||
struct ExistsCheck { check_result: Option<i32> }
|
||||
struct ExistsCheck {
|
||||
check_result: Option<i32>,
|
||||
}
|
||||
let result = ExistsCheck::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
check_sql,
|
||||
[ref_id.into(), tenant_id.into()],
|
||||
)).one(db).await?;
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
if result.is_none() {
|
||||
return Err(AppError::Validation(format!(
|
||||
@@ -1560,13 +1753,16 @@ async fn check_no_cycle(
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<()> {
|
||||
let Some(val) = data.get(&field.name) else { return Ok(()) };
|
||||
let Some(val) = data.get(&field.name) else {
|
||||
return Ok(());
|
||||
};
|
||||
let new_parent = val.as_str().unwrap_or("").trim().to_string();
|
||||
if new_parent.is_empty() { return Ok(()); }
|
||||
if new_parent.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let new_parent_id = Uuid::parse_str(&new_parent).map_err(|_| {
|
||||
AppError::Validation("parent_id 不是有效的 UUID".to_string())
|
||||
})?;
|
||||
let new_parent_id = Uuid::parse_str(&new_parent)
|
||||
.map_err(|_| AppError::Validation("parent_id 不是有效的 UUID".to_string()))?;
|
||||
|
||||
let field_name = sanitize_identifier(&field.name);
|
||||
let mut visited = vec![record_id];
|
||||
@@ -1576,7 +1772,8 @@ async fn check_no_cycle(
|
||||
if visited.contains(¤t_id) {
|
||||
let label = field.display_name.as_deref().unwrap_or(&field.name);
|
||||
return Err(AppError::Validation(format!(
|
||||
"字段 '{}' 形成循环引用", label
|
||||
"字段 '{}' 形成循环引用",
|
||||
label
|
||||
)));
|
||||
}
|
||||
visited.push(current_id);
|
||||
@@ -1586,20 +1783,25 @@ async fn check_no_cycle(
|
||||
field_name, table_name
|
||||
);
|
||||
#[derive(FromQueryResult)]
|
||||
struct ParentRow { parent: Option<String> }
|
||||
struct ParentRow {
|
||||
parent: Option<String>,
|
||||
}
|
||||
let row = ParentRow::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
query_sql,
|
||||
[current_id.into(), tenant_id.into()],
|
||||
)).one(db).await?;
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(r) => {
|
||||
let parent = r.parent.unwrap_or_default().trim().to_string();
|
||||
if parent.is_empty() { break; }
|
||||
current_id = Uuid::parse_str(&parent).map_err(|_| {
|
||||
AppError::Internal("parent_id 不是有效的 UUID".to_string())
|
||||
})?;
|
||||
if parent.is_empty() {
|
||||
break;
|
||||
}
|
||||
current_id = Uuid::parse_str(&parent)
|
||||
.map_err(|_| AppError::Internal("parent_id 不是有效的 UUID".to_string()))?;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
@@ -1673,7 +1875,11 @@ mod validate_tests {
|
||||
|
||||
#[test]
|
||||
fn validate_phone_pattern_rejects_invalid() {
|
||||
let fields = vec![make_field("phone", Some("^1[3-9]\\d{9}$"), Some("手机号格式不正确"))];
|
||||
let fields = vec![make_field(
|
||||
"phone",
|
||||
Some("^1[3-9]\\d{9}$"),
|
||||
Some("手机号格式不正确"),
|
||||
)];
|
||||
let data = serde_json::json!({"phone": "1234"});
|
||||
let result = validate_data(&data, &fields);
|
||||
assert!(result.is_err());
|
||||
@@ -1681,7 +1887,11 @@ mod validate_tests {
|
||||
|
||||
#[test]
|
||||
fn validate_phone_pattern_accepts_valid() {
|
||||
let fields = vec![make_field("phone", Some("^1[3-9]\\d{9}$"), Some("手机号格式不正确"))];
|
||||
let fields = vec![make_field(
|
||||
"phone",
|
||||
Some("^1[3-9]\\d{9}$"),
|
||||
Some("手机号格式不正确"),
|
||||
)];
|
||||
let data = serde_json::json!({"phone": "13812345678"});
|
||||
let result = validate_data(&data, &fields);
|
||||
assert!(result.is_ok());
|
||||
|
||||
Reference in New Issue
Block a user