fix: 修复测试发现的 7 个问题 + 全 workspace clippy 清零
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

功能修复:
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:
iven
2026-05-07 23:43:14 +08:00
parent 786f57c151
commit 6d5a711d2c
323 changed files with 15662 additions and 6603 deletions

View File

@@ -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(&current_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());