功能修复: 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 统一格式化
1908 lines
65 KiB
Rust
1908 lines
65 KiB
Rust
use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, FromQueryResult, QueryFilter, Statement};
|
||
use uuid::Uuid;
|
||
|
||
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::{DynamicTableManager, sanitize_identifier};
|
||
use crate::entity::plugin;
|
||
use crate::entity::plugin_entity;
|
||
use crate::error::PluginError;
|
||
use crate::manifest::PluginField;
|
||
use crate::state::EntityInfo;
|
||
|
||
/// 根据 plugin 数据库 ID 查找 manifest 中匹配 entity 的触发事件
|
||
async fn find_trigger_events(
|
||
plugin_db_id: Uuid,
|
||
entity_name: &str,
|
||
db: &sea_orm::DatabaseConnection,
|
||
) -> AppResult<Vec<crate::manifest::PluginTriggerEvent>> {
|
||
let model = plugin::Entity::find_by_id(plugin_db_id)
|
||
.one(db)
|
||
.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 triggers = manifest
|
||
.trigger_events
|
||
.unwrap_or_default()
|
||
.into_iter()
|
||
.filter(|t| t.entity == entity_name)
|
||
.collect();
|
||
Ok(triggers)
|
||
}
|
||
|
||
/// 发布触发事件
|
||
#[allow(clippy::too_many_arguments)]
|
||
async fn emit_trigger_events(
|
||
triggers: &[crate::manifest::PluginTriggerEvent],
|
||
action: &str,
|
||
entity_name: &str,
|
||
record_id: &str,
|
||
tenant_id: Uuid,
|
||
data: Option<&serde_json::Value>,
|
||
event_bus: &EventBus,
|
||
db: &sea_orm::DatabaseConnection,
|
||
manifest_id: &str,
|
||
) {
|
||
use crate::manifest::PluginTriggerOn;
|
||
for trigger in triggers {
|
||
let should_fire = match &trigger.on {
|
||
PluginTriggerOn::Create => action == "create",
|
||
PluginTriggerOn::Update => action == "update",
|
||
PluginTriggerOn::Delete => action == "delete",
|
||
PluginTriggerOn::CreateOrUpdate => action == "create" || action == "update",
|
||
};
|
||
if should_fire {
|
||
let payload = serde_json::json!({
|
||
"event": trigger.name,
|
||
"entity": entity_name,
|
||
"record_id": record_id,
|
||
"data": data,
|
||
"plugin_id": manifest_id,
|
||
"trigger_name": trigger.name,
|
||
"action": action,
|
||
});
|
||
// 发布原始触发事件
|
||
let event =
|
||
erp_core::events::DomainEvent::new(&trigger.name, tenant_id, payload.clone());
|
||
event_bus.publish(event, db).await;
|
||
|
||
// 同时发布 plugin.trigger.{manifest_id} 事件用于通知引擎
|
||
let notify_event = erp_core::events::DomainEvent::new(
|
||
format!("plugin.trigger.{}.{}", manifest_id, trigger.name),
|
||
tenant_id,
|
||
payload,
|
||
);
|
||
event_bus.publish(notify_event, db).await;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 行级数据权限参数 — 传递到 service 层注入 SQL 条件
|
||
pub struct DataScopeParams {
|
||
pub scope_level: String,
|
||
pub user_id: Uuid,
|
||
pub dept_member_ids: Vec<Uuid>,
|
||
pub owner_field: String,
|
||
}
|
||
|
||
pub struct PluginDataService;
|
||
|
||
impl PluginDataService {
|
||
/// 创建插件数据
|
||
pub async fn create(
|
||
plugin_id: Uuid,
|
||
entity_name: &str,
|
||
tenant_id: Uuid,
|
||
operator_id: Uuid,
|
||
data: serde_json::Value,
|
||
db: &sea_orm::DatabaseConnection,
|
||
_event_bus: &EventBus,
|
||
) -> AppResult<PluginDataResp> {
|
||
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?;
|
||
|
||
let (sql, values) =
|
||
DynamicTableManager::build_insert_sql(&info.table_name, tenant_id, operator_id, &data);
|
||
|
||
#[derive(FromQueryResult)]
|
||
struct InsertResult {
|
||
id: Uuid,
|
||
data: serde_json::Value,
|
||
created_at: chrono::DateTime<chrono::Utc>,
|
||
updated_at: chrono::DateTime<chrono::Utc>,
|
||
version: i32,
|
||
}
|
||
|
||
let result = InsertResult::find_by_statement(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
sql,
|
||
values,
|
||
))
|
||
.one(db)
|
||
.await?
|
||
.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),
|
||
db,
|
||
)
|
||
.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 {
|
||
id: result.id.to_string(),
|
||
data: result.data,
|
||
created_at: Some(result.created_at),
|
||
updated_at: Some(result.updated_at),
|
||
version: Some(result.version),
|
||
})
|
||
}
|
||
|
||
/// 列表查询(支持过滤/搜索/排序/Generated Column 路由/数据权限)
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub async fn list(
|
||
plugin_id: Uuid,
|
||
entity_name: &str,
|
||
tenant_id: Uuid,
|
||
page: u64,
|
||
page_size: u64,
|
||
db: &sea_orm::DatabaseConnection,
|
||
filter: Option<serde_json::Value>,
|
||
search: Option<String>,
|
||
sort_by: Option<String>,
|
||
sort_order: Option<String>,
|
||
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?;
|
||
|
||
// 获取 searchable 字段列表
|
||
let entity_fields = info.fields()?;
|
||
let search_tuple = {
|
||
let searchable: Vec<&str> = entity_fields
|
||
.iter()
|
||
.filter(|f| f.searchable == Some(true))
|
||
.map(|f| f.name.as_str())
|
||
.collect();
|
||
match (searchable.is_empty(), &search) {
|
||
(false, Some(kw)) => Some((searchable.join(","), kw.clone())),
|
||
_ => None,
|
||
}
|
||
};
|
||
|
||
// 构建数据权限条件(count 查询只有 tenant_id 占 $1,scope 从 $2 开始)
|
||
let count_scope = build_scope_sql(&scope, &info.generated_fields, 2);
|
||
|
||
// Count
|
||
let (count_sql, mut count_values) =
|
||
DynamicTableManager::build_count_sql(&info.table_name, tenant_id);
|
||
let count_sql = merge_scope_condition(count_sql, &count_scope);
|
||
count_values.extend(count_scope.1);
|
||
|
||
#[derive(FromQueryResult)]
|
||
struct CountResult {
|
||
count: i64,
|
||
}
|
||
let total = CountResult::find_by_statement(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
count_sql,
|
||
count_values,
|
||
))
|
||
.one(db)
|
||
.await?
|
||
.map(|r| r.count as u64)
|
||
.unwrap_or(0);
|
||
|
||
// Query — 使用 Generated Column 路由
|
||
let offset = page.saturating_sub(1) * page_size;
|
||
let (sql, mut values) = DynamicTableManager::build_filtered_query_sql_ex(
|
||
&info.table_name,
|
||
tenant_id,
|
||
page_size,
|
||
offset,
|
||
filter,
|
||
search_tuple,
|
||
sort_by,
|
||
sort_order,
|
||
&info.generated_fields,
|
||
)
|
||
.map_err(AppError::Validation)?;
|
||
|
||
// 注入数据权限条件(scope 参数索引接在 values 之后)
|
||
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 {
|
||
id: Uuid,
|
||
data: serde_json::Value,
|
||
created_at: chrono::DateTime<chrono::Utc>,
|
||
updated_at: chrono::DateTime<chrono::Utc>,
|
||
version: i32,
|
||
}
|
||
|
||
let rows = DataRow::find_by_statement(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
sql,
|
||
values,
|
||
))
|
||
.all(db)
|
||
.await?;
|
||
|
||
let items = rows
|
||
.into_iter()
|
||
.map(|r| PluginDataResp {
|
||
id: r.id.to_string(),
|
||
data: r.data,
|
||
created_at: Some(r.created_at),
|
||
updated_at: Some(r.updated_at),
|
||
version: Some(r.version),
|
||
})
|
||
.collect();
|
||
|
||
Ok((items, total))
|
||
}
|
||
|
||
/// 按 ID 获取
|
||
pub async fn get_by_id(
|
||
plugin_id: Uuid,
|
||
entity_name: &str,
|
||
id: Uuid,
|
||
tenant_id: Uuid,
|
||
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);
|
||
|
||
#[derive(FromQueryResult)]
|
||
struct DataRow {
|
||
id: Uuid,
|
||
data: serde_json::Value,
|
||
created_at: chrono::DateTime<chrono::Utc>,
|
||
updated_at: chrono::DateTime<chrono::Utc>,
|
||
version: i32,
|
||
}
|
||
|
||
let row = DataRow::find_by_statement(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
sql,
|
||
values,
|
||
))
|
||
.one(db)
|
||
.await?
|
||
.ok_or_else(|| AppError::NotFound("记录不存在".to_string()))?;
|
||
|
||
Ok(PluginDataResp {
|
||
id: row.id.to_string(),
|
||
data: row.data,
|
||
created_at: Some(row.created_at),
|
||
updated_at: Some(row.updated_at),
|
||
version: Some(row.version),
|
||
})
|
||
}
|
||
|
||
/// 更新
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub async fn update(
|
||
plugin_id: Uuid,
|
||
entity_name: &str,
|
||
id: Uuid,
|
||
tenant_id: Uuid,
|
||
operator_id: Uuid,
|
||
data: serde_json::Value,
|
||
expected_version: i32,
|
||
db: &sea_orm::DatabaseConnection,
|
||
_event_bus: &EventBus,
|
||
) -> AppResult<PluginDataResp> {
|
||
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?;
|
||
|
||
// 循环引用检测
|
||
for field in &fields {
|
||
if field.no_cycle == Some(true) && data.get(&field.name).is_some() {
|
||
check_no_cycle(id, field, &data, &info.table_name, tenant_id, db).await?;
|
||
}
|
||
}
|
||
|
||
let (sql, values) = DynamicTableManager::build_update_sql(
|
||
&info.table_name,
|
||
id,
|
||
tenant_id,
|
||
operator_id,
|
||
&data,
|
||
expected_version,
|
||
);
|
||
|
||
#[derive(FromQueryResult)]
|
||
struct UpdateResult {
|
||
id: Uuid,
|
||
data: serde_json::Value,
|
||
created_at: chrono::DateTime<chrono::Utc>,
|
||
updated_at: chrono::DateTime<chrono::Utc>,
|
||
version: i32,
|
||
}
|
||
|
||
let result = UpdateResult::find_by_statement(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
sql,
|
||
values,
|
||
))
|
||
.one(db)
|
||
.await?
|
||
.ok_or(AppError::VersionMismatch)?;
|
||
|
||
audit_service::record(
|
||
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
|
||
&& 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 {
|
||
id: result.id.to_string(),
|
||
data: result.data,
|
||
created_at: Some(result.created_at),
|
||
updated_at: Some(result.updated_at),
|
||
version: Some(result.version),
|
||
})
|
||
}
|
||
|
||
/// 部分更新(PATCH)— 只合并提供的字段
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub async fn partial_update(
|
||
plugin_id: Uuid,
|
||
entity_name: &str,
|
||
id: Uuid,
|
||
tenant_id: Uuid,
|
||
operator_id: Uuid,
|
||
partial_data: serde_json::Value,
|
||
expected_version: i32,
|
||
db: &sea_orm::DatabaseConnection,
|
||
) -> AppResult<PluginDataResp> {
|
||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||
let fields = info.fields()?;
|
||
|
||
// 合并现有数据后校验,确保 partial update 也能触发 required/pattern/ref 校验
|
||
let existing = Self::get_by_id(plugin_id, entity_name, id, tenant_id, db).await?;
|
||
let merged = {
|
||
let mut base = existing.data.as_object().cloned().unwrap_or_default();
|
||
if let Some(patch) = partial_data.as_object() {
|
||
for (k, v) in patch {
|
||
base.insert(k.clone(), v.clone());
|
||
}
|
||
}
|
||
serde_json::Value::Object(base)
|
||
};
|
||
|
||
validate_data(&merged, &fields)?;
|
||
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,
|
||
);
|
||
|
||
#[derive(FromQueryResult)]
|
||
struct PatchResult {
|
||
id: Uuid,
|
||
data: serde_json::Value,
|
||
created_at: chrono::DateTime<chrono::Utc>,
|
||
updated_at: chrono::DateTime<chrono::Utc>,
|
||
version: i32,
|
||
}
|
||
|
||
let result = PatchResult::find_by_statement(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
sql,
|
||
values,
|
||
))
|
||
.one(db)
|
||
.await?
|
||
.ok_or(AppError::VersionMismatch)?;
|
||
|
||
Ok(PluginDataResp {
|
||
id: result.id.to_string(),
|
||
data: result.data,
|
||
created_at: Some(result.created_at),
|
||
updated_at: Some(result.updated_at),
|
||
version: Some(result.version),
|
||
})
|
||
}
|
||
|
||
/// 删除(软删除)
|
||
pub async fn delete(
|
||
plugin_id: Uuid,
|
||
entity_name: &str,
|
||
id: Uuid,
|
||
tenant_id: Uuid,
|
||
db: &sea_orm::DatabaseConnection,
|
||
_event_bus: &EventBus,
|
||
) -> AppResult<()> {
|
||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||
|
||
// 解析 entity schema 获取 relations
|
||
let entity_def: crate::manifest::PluginEntity =
|
||
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 relation in &entity_def.relations {
|
||
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 => {
|
||
let check_sql = format!(
|
||
"SELECT 1 as chk FROM \"{}\" WHERE data->>'{}' = $1 AND tenant_id = $2 AND deleted_at IS NULL LIMIT 1",
|
||
rel_table, fk
|
||
);
|
||
#[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,
|
||
[id.to_string().into(), tenant_id.into()],
|
||
))
|
||
.one(db)
|
||
.await?;
|
||
if has_ref.is_some() {
|
||
return Err(AppError::Validation(format!(
|
||
"存在关联的 {} 记录,无法删除",
|
||
relation.entity
|
||
)));
|
||
}
|
||
}
|
||
crate::manifest::OnDeleteStrategy::Nullify => {
|
||
let nullify_sql = format!(
|
||
"UPDATE \"{}\" SET data = jsonb_set(data, '{{{}}}', 'null'), updated_at = NOW() WHERE data->>'{}' = $1 AND tenant_id = $2 AND deleted_at IS NULL",
|
||
rel_table, fk, fk
|
||
);
|
||
db.execute(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
nullify_sql,
|
||
[id.to_string().into(), tenant_id.into()],
|
||
))
|
||
.await?;
|
||
}
|
||
crate::manifest::OnDeleteStrategy::Cascade => {
|
||
let cascade_sql = format!(
|
||
"UPDATE \"{}\" SET deleted_at = NOW(), updated_at = NOW() WHERE data->>'{}' = $1 AND tenant_id = $2 AND deleted_at IS NULL",
|
||
rel_table, fk
|
||
);
|
||
db.execute(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
cascade_sql,
|
||
[id.to_string().into(), tenant_id.into()],
|
||
))
|
||
.await?;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 软删除主记录
|
||
let (sql, values) = DynamicTableManager::build_delete_sql(&info.table_name, id, tenant_id);
|
||
|
||
db.execute(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
sql,
|
||
values,
|
||
))
|
||
.await?;
|
||
|
||
audit_service::record(
|
||
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
|
||
&& 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,
|
||
tenant_id: Uuid,
|
||
db: &sea_orm::DatabaseConnection,
|
||
filter: Option<serde_json::Value>,
|
||
search: Option<String>,
|
||
sort_by: Option<String>,
|
||
sort_order: Option<String>,
|
||
format: Option<String>,
|
||
cache: &moka::sync::Cache<String, EntityInfo>,
|
||
scope: Option<DataScopeParams>,
|
||
) -> 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 entity_fields = info.fields()?;
|
||
let search_tuple = {
|
||
let searchable: Vec<&str> = entity_fields
|
||
.iter()
|
||
.filter(|f| f.searchable == Some(true))
|
||
.map(|f| f.name.as_str())
|
||
.collect();
|
||
match (searchable.is_empty(), &search) {
|
||
(false, Some(kw)) => Some((searchable.join(","), kw.clone())),
|
||
_ => None,
|
||
}
|
||
};
|
||
|
||
let (sql, mut values) = DynamicTableManager::build_filtered_query_sql_ex(
|
||
&info.table_name,
|
||
tenant_id,
|
||
10000,
|
||
0,
|
||
filter,
|
||
search_tuple,
|
||
sort_by,
|
||
sort_order,
|
||
&info.generated_fields,
|
||
)
|
||
.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,
|
||
}
|
||
|
||
let rows = DataRow::find_by_statement(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
sql,
|
||
values,
|
||
))
|
||
.all(db)
|
||
.await?;
|
||
|
||
let data: Vec<serde_json::Value> = rows.into_iter().map(|r| r.data).collect();
|
||
let fmt = format.as_deref().unwrap_or("json").to_lowercase();
|
||
|
||
match fmt.as_str() {
|
||
"csv" => Ok(ExportPayload::Csv(Self::to_csv(&data, &entity_fields)?)),
|
||
"xlsx" => Ok(ExportPayload::Xlsx(Self::to_xlsx(&data, &entity_fields)?)),
|
||
_ => Ok(ExportPayload::Json(data)),
|
||
}
|
||
}
|
||
|
||
fn to_csv(
|
||
rows: &[serde_json::Value],
|
||
fields: &[crate::manifest::PluginField],
|
||
) -> 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)))?;
|
||
|
||
for row in rows {
|
||
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()
|
||
.map_err(|e| AppError::Internal(format!("CSV 刷新失败: {}", e)))
|
||
}
|
||
|
||
fn to_xlsx(
|
||
rows: &[serde_json::Value],
|
||
fields: &[crate::manifest::PluginField],
|
||
) -> AppResult<Vec<u8>> {
|
||
use rust_xlsxwriter::*;
|
||
|
||
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);
|
||
|
||
for (col, field) in fields.iter().enumerate() {
|
||
let label = field.display_name.as_deref().unwrap_or(&field.name);
|
||
ws.write_string_with_format(0, col as u16, label, &header_fmt)
|
||
.map_err(|e| AppError::Internal(format!("XLSX 写头失败: {}", e)))?;
|
||
}
|
||
|
||
for (row_idx, row) in rows.iter().enumerate() {
|
||
for (col, field) in fields.iter().enumerate() {
|
||
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::Bool(b)) => {
|
||
ws.write_string(row_num, col as u16, b.to_string()).ok();
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
let buf = wb
|
||
.save_to_buffer()
|
||
.map_err(|e| AppError::Internal(format!("XLSX 保存失败: {}", e)))?;
|
||
Ok(buf.to_vec())
|
||
}
|
||
|
||
/// 批量导入数据(逐行校验 + 逐行插入)
|
||
pub async fn import(
|
||
plugin_id: Uuid,
|
||
entity_name: &str,
|
||
tenant_id: Uuid,
|
||
operator_id: Uuid,
|
||
rows: Vec<serde_json::Value>,
|
||
db: &sea_orm::DatabaseConnection,
|
||
event_bus: &EventBus,
|
||
) -> AppResult<crate::data_dto::ImportResult> {
|
||
use crate::data_dto::{ImportResult, ImportRowError};
|
||
|
||
if rows.len() > 1000 {
|
||
return Err(AppError::Validation("单次导入上限 1000 行".to_string()));
|
||
}
|
||
|
||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||
let fields = info.fields()?;
|
||
|
||
let mut success_count = 0usize;
|
||
let mut row_errors: Vec<ImportRowError> = Vec::new();
|
||
|
||
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()],
|
||
});
|
||
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()],
|
||
});
|
||
continue;
|
||
}
|
||
|
||
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;
|
||
|
||
match result {
|
||
Ok(_) => success_count += 1,
|
||
Err(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,
|
||
),
|
||
db,
|
||
)
|
||
.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 {
|
||
success_count,
|
||
error_count: row_errors.len(),
|
||
errors: row_errors,
|
||
})
|
||
}
|
||
|
||
/// 批量操作 — batch_delete / batch_update
|
||
pub async fn batch(
|
||
plugin_id: Uuid,
|
||
entity_name: &str,
|
||
tenant_id: Uuid,
|
||
operator_id: Uuid,
|
||
req: BatchActionReq,
|
||
db: &sea_orm::DatabaseConnection,
|
||
) -> AppResult<u64> {
|
||
if req.ids.is_empty() {
|
||
return Err(AppError::Validation("ids 不能为空".to_string()));
|
||
}
|
||
if req.ids.len() > 100 {
|
||
return Err(AppError::Validation("批量操作上限 100 条".to_string()));
|
||
}
|
||
|
||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||
let ids: Vec<Uuid> = req
|
||
.ids
|
||
.iter()
|
||
.map(|s| Uuid::parse_str(s))
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.map_err(|_| AppError::Validation("ids 中包含无效的 UUID".to_string()))?;
|
||
|
||
let affected = match req.action.as_str() {
|
||
"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))
|
||
})?;
|
||
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 fk = sanitize_identifier(&relation.foreign_key);
|
||
match relation.on_delete {
|
||
crate::manifest::OnDeleteStrategy::Restrict => {
|
||
let check_sql = format!(
|
||
"SELECT 1 as chk FROM \"{}\" WHERE data->>'{}' = $1 AND tenant_id = $2 AND deleted_at IS NULL LIMIT 1",
|
||
rel_table, fk
|
||
);
|
||
#[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?;
|
||
if has_ref.is_some() {
|
||
return Err(AppError::Validation(format!(
|
||
"记录 {} 存在关联的 {} 记录,无法删除",
|
||
del_id, relation.entity
|
||
)));
|
||
}
|
||
}
|
||
crate::manifest::OnDeleteStrategy::Nullify => {
|
||
let nullify_sql = format!(
|
||
"UPDATE \"{}\" SET data = jsonb_set(data, '{{{}}}', 'null'), updated_at = NOW() WHERE data->>'{}' = $1 AND tenant_id = $2 AND deleted_at IS NULL",
|
||
rel_table, fk, fk
|
||
);
|
||
db.execute(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
nullify_sql,
|
||
[del_id.to_string().into(), tenant_id.into()],
|
||
))
|
||
.await?;
|
||
}
|
||
crate::manifest::OnDeleteStrategy::Cascade => {
|
||
let cascade_sql = format!(
|
||
"UPDATE \"{}\" SET deleted_at = NOW(), updated_at = NOW() WHERE data->>'{}' = $1 AND tenant_id = $2 AND deleted_at IS NULL",
|
||
rel_table, fk
|
||
);
|
||
db.execute(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
cascade_sql,
|
||
[del_id.to_string().into(), tenant_id.into()],
|
||
))
|
||
.await?;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let placeholders: Vec<String> = ids
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, _)| format!("${}", i + 2))
|
||
.collect();
|
||
let sql = format!(
|
||
"UPDATE \"{}\" SET deleted_at = NOW(), updated_at = NOW() \
|
||
WHERE tenant_id = $1 AND id IN ({}) AND deleted_at IS NULL",
|
||
info.table_name,
|
||
placeholders.join(", ")
|
||
);
|
||
let mut values = vec![tenant_id.into()];
|
||
for id in &ids {
|
||
values.push((*id).into());
|
||
}
|
||
let result = db
|
||
.execute(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
sql,
|
||
values,
|
||
))
|
||
.await?;
|
||
result.rows_affected()
|
||
}
|
||
"batch_update" => {
|
||
let update_data = req.data.ok_or_else(|| {
|
||
AppError::Validation("batch_update 需要 data 字段".to_string())
|
||
})?;
|
||
let mut set_expr = "data".to_string();
|
||
if let Some(obj) = update_data.as_object() {
|
||
for key in obj.keys() {
|
||
let clean_key = sanitize_identifier(key);
|
||
set_expr = format!(
|
||
"jsonb_set({}, '{{{}}}', $2::jsonb->'{}', true)",
|
||
set_expr, clean_key, clean_key
|
||
);
|
||
}
|
||
}
|
||
let placeholders: Vec<String> = ids
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, _)| format!("${}", i + 3))
|
||
.collect();
|
||
let sql = format!(
|
||
"UPDATE \"{}\" SET data = {}, updated_at = NOW(), updated_by = $1, version = version + 1 \
|
||
WHERE tenant_id = $1 AND id IN ({}) AND deleted_at IS NULL",
|
||
info.table_name,
|
||
set_expr,
|
||
placeholders.join(", ")
|
||
);
|
||
let mut values = vec![operator_id.into()];
|
||
values.push(
|
||
serde_json::to_string(&update_data)
|
||
.unwrap_or_default()
|
||
.into(),
|
||
);
|
||
for id in &ids {
|
||
values.push((*id).into());
|
||
}
|
||
let result = db
|
||
.execute(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
sql,
|
||
values,
|
||
))
|
||
.await?;
|
||
result.rows_affected()
|
||
}
|
||
_ => {
|
||
return Err(AppError::Validation(format!(
|
||
"不支持的批量操作: {}",
|
||
req.action
|
||
)));
|
||
}
|
||
};
|
||
|
||
Ok(affected)
|
||
}
|
||
|
||
/// 统计记录数(支持过滤和搜索)
|
||
pub async fn count(
|
||
plugin_id: Uuid,
|
||
entity_name: &str,
|
||
tenant_id: Uuid,
|
||
db: &sea_orm::DatabaseConnection,
|
||
filter: Option<serde_json::Value>,
|
||
search: Option<String>,
|
||
scope: Option<DataScopeParams>,
|
||
) -> AppResult<u64> {
|
||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||
|
||
let entity_fields = info.fields()?;
|
||
let search_tuple = {
|
||
let searchable: Vec<&str> = entity_fields
|
||
.iter()
|
||
.filter(|f| f.searchable == Some(true))
|
||
.map(|f| f.name.as_str())
|
||
.collect();
|
||
match (searchable.is_empty(), &search) {
|
||
(false, Some(kw)) => Some((searchable.join(","), kw.clone())),
|
||
_ => None,
|
||
}
|
||
};
|
||
|
||
let (mut sql, mut values) = DynamicTableManager::build_filtered_count_sql(
|
||
&info.table_name,
|
||
tenant_id,
|
||
filter,
|
||
search_tuple,
|
||
)
|
||
.map_err(AppError::Validation)?;
|
||
|
||
// 合并数据权限条件
|
||
let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1);
|
||
if !scope_condition.0.is_empty() {
|
||
sql = merge_scope_condition(sql, &scope_condition);
|
||
values.extend(scope_condition.1);
|
||
}
|
||
|
||
#[derive(FromQueryResult)]
|
||
struct CountResult {
|
||
count: i64,
|
||
}
|
||
|
||
let result = CountResult::find_by_statement(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
sql,
|
||
values,
|
||
))
|
||
.one(db)
|
||
.await?
|
||
.map(|r| r.count as u64)
|
||
.unwrap_or(0);
|
||
|
||
Ok(result)
|
||
}
|
||
|
||
/// 聚合查询 — 按字段分组计数
|
||
/// 返回 [(分组键, 计数), ...]
|
||
pub async fn aggregate(
|
||
plugin_id: Uuid,
|
||
entity_name: &str,
|
||
tenant_id: Uuid,
|
||
db: &sea_orm::DatabaseConnection,
|
||
group_by_field: &str,
|
||
filter: Option<serde_json::Value>,
|
||
scope: Option<DataScopeParams>,
|
||
) -> AppResult<Vec<(String, i64)>> {
|
||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||
|
||
let (mut sql, mut values) = DynamicTableManager::build_aggregate_sql(
|
||
&info.table_name,
|
||
tenant_id,
|
||
group_by_field,
|
||
filter,
|
||
)
|
||
.map_err(AppError::Validation)?;
|
||
|
||
// 合并数据权限条件
|
||
let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1);
|
||
if !scope_condition.0.is_empty() {
|
||
sql = merge_scope_condition(sql, &scope_condition);
|
||
values.extend(scope_condition.1);
|
||
}
|
||
|
||
#[derive(FromQueryResult)]
|
||
struct AggRow {
|
||
key: Option<String>,
|
||
count: i64,
|
||
}
|
||
|
||
let rows = AggRow::find_by_statement(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
sql,
|
||
values,
|
||
))
|
||
.all(db)
|
||
.await?;
|
||
|
||
let result = rows
|
||
.into_iter()
|
||
.map(|r| (r.key.unwrap_or_default(), r.count))
|
||
.collect();
|
||
|
||
Ok(result)
|
||
}
|
||
|
||
/// 多聚合查询 — 支持 COUNT + SUM/AVG/MIN/MAX
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub async fn aggregate_multi(
|
||
plugin_id: Uuid,
|
||
entity_name: &str,
|
||
tenant_id: Uuid,
|
||
db: &sea_orm::DatabaseConnection,
|
||
group_by_field: &str,
|
||
aggregations: &[(String, String)],
|
||
filter: Option<serde_json::Value>,
|
||
scope: Option<DataScopeParams>,
|
||
) -> AppResult<Vec<AggregateMultiRow>> {
|
||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||
|
||
let (mut sql, mut values) = DynamicTableManager::build_aggregate_multi_sql(
|
||
&info.table_name,
|
||
tenant_id,
|
||
group_by_field,
|
||
aggregations,
|
||
filter,
|
||
)
|
||
.map_err(AppError::Validation)?;
|
||
|
||
let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1);
|
||
if !scope_condition.0.is_empty() {
|
||
sql = merge_scope_condition(sql, &scope_condition);
|
||
values.extend(scope_condition.1);
|
||
}
|
||
|
||
// 使用 json_agg 包装整行,返回 JSON 数组
|
||
let json_sql = format!("SELECT json_agg(row_to_json(t)) as data FROM ({}) t", sql);
|
||
|
||
#[derive(Debug, FromQueryResult)]
|
||
struct JsonResult {
|
||
data: Option<serde_json::Value>,
|
||
}
|
||
|
||
let result = JsonResult::find_by_statement(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
json_sql,
|
||
values,
|
||
))
|
||
.one(db)
|
||
.await?;
|
||
|
||
let json_rows: Vec<serde_json::Value> = result
|
||
.and_then(|r| r.data)
|
||
.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();
|
||
|
||
Ok(rows)
|
||
}
|
||
|
||
/// 聚合查询(预留 Redis 缓存接口)
|
||
pub async fn aggregate_cached(
|
||
plugin_id: Uuid,
|
||
entity_name: &str,
|
||
tenant_id: Uuid,
|
||
db: &sea_orm::DatabaseConnection,
|
||
group_by_field: &str,
|
||
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
|
||
}
|
||
|
||
/// 时间序列聚合 — 按时间字段截断为 day/week/month 统计计数
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub async fn timeseries(
|
||
plugin_id: Uuid,
|
||
entity_name: &str,
|
||
tenant_id: Uuid,
|
||
db: &sea_orm::DatabaseConnection,
|
||
time_field: &str,
|
||
time_grain: &str,
|
||
start: Option<String>,
|
||
end: Option<String>,
|
||
scope: Option<DataScopeParams>,
|
||
) -> AppResult<Vec<crate::data_dto::TimeseriesItem>> {
|
||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||
|
||
let (mut sql, mut values) = DynamicTableManager::build_timeseries_sql(
|
||
&info.table_name,
|
||
tenant_id,
|
||
time_field,
|
||
time_grain,
|
||
start.as_deref(),
|
||
end.as_deref(),
|
||
)
|
||
.map_err(AppError::Validation)?;
|
||
|
||
// 合并数据权限条件
|
||
let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1);
|
||
if !scope_condition.0.is_empty() {
|
||
sql = merge_scope_condition(sql, &scope_condition);
|
||
values.extend(scope_condition.1);
|
||
}
|
||
|
||
#[derive(FromQueryResult)]
|
||
struct TsRow {
|
||
period: Option<String>,
|
||
count: i64,
|
||
}
|
||
|
||
let rows = TsRow::find_by_statement(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
sql,
|
||
values,
|
||
))
|
||
.all(db)
|
||
.await?;
|
||
|
||
Ok(rows
|
||
.into_iter()
|
||
.map(|r| crate::data_dto::TimeseriesItem {
|
||
period: r.period.unwrap_or_default(),
|
||
count: r.count,
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
/// 对账扫描: 检查指定插件所有实体的跨插件引用是否有悬空引用
|
||
pub async fn reconcile_references(
|
||
plugin_id: Uuid,
|
||
tenant_id: Uuid,
|
||
db: &sea_orm::DatabaseConnection,
|
||
) -> AppResult<crate::data_dto::ReconciliationReport> {
|
||
let manifest_id = resolve_manifest_id(plugin_id, tenant_id, db).await?;
|
||
|
||
// 获取该插件所有实体
|
||
let entities = plugin_entity::Entity::find()
|
||
.filter(plugin_entity::Column::PluginId.eq(plugin_id))
|
||
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
|
||
.filter(plugin_entity::Column::DeletedAt.is_null())
|
||
.all(db)
|
||
.await?;
|
||
|
||
let mut valid_count: i64 = 0;
|
||
let mut dangling_count: i64 = 0;
|
||
let mut details = Vec::new();
|
||
|
||
for entity_rec in &entities {
|
||
let schema: crate::manifest::PluginEntity =
|
||
serde_json::from_value(entity_rec.schema_json.clone())
|
||
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
|
||
|
||
// 找出所有有 ref_entity 的字段
|
||
let ref_fields: Vec<&PluginField> = schema
|
||
.fields
|
||
.iter()
|
||
.filter(|f| f.ref_entity.is_some())
|
||
.collect();
|
||
|
||
if ref_fields.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
let table_name = DynamicTableManager::table_name(&manifest_id, &entity_rec.entity_name);
|
||
|
||
for field in &ref_fields {
|
||
let col = sanitize_identifier(&field.name);
|
||
|
||
#[derive(FromQueryResult)]
|
||
#[allow(dead_code)] // FromQueryResult 映射需要 id 字段,通过 is_some 检查
|
||
struct RefRow {
|
||
id: Uuid,
|
||
// 动态列 — SeaORM 无法直接映射,用 JSON 构建
|
||
}
|
||
|
||
// 查询所有有 ref 值的记录
|
||
let ref_sql = format!(
|
||
"SELECT id, {} as ref_val FROM {} WHERE tenant_id = $1 AND deleted_at IS NULL AND {} IS NOT NULL",
|
||
col, table_name, col,
|
||
);
|
||
|
||
#[derive(FromQueryResult)]
|
||
struct RefValRow {
|
||
id: Uuid,
|
||
ref_val: String,
|
||
}
|
||
|
||
let rows = RefValRow::find_by_statement(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
ref_sql,
|
||
[tenant_id.into()],
|
||
))
|
||
.all(db)
|
||
.await?;
|
||
|
||
for row in rows {
|
||
// 验证 ref_val 是有效的 UUID 且目标记录存在
|
||
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);
|
||
let target_table = DynamicTableManager::table_name(ref_plugin, ref_entity_name);
|
||
|
||
let check_sql = format!(
|
||
"SELECT COUNT(*) as cnt FROM {} WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL",
|
||
target_table,
|
||
);
|
||
|
||
#[derive(FromQueryResult)]
|
||
struct CountRow {
|
||
cnt: i64,
|
||
}
|
||
|
||
let count_row = CountRow::find_by_statement(Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
check_sql,
|
||
[target_uuid.into(), tenant_id.into()],
|
||
))
|
||
.one(db)
|
||
.await?
|
||
.unwrap_or(CountRow { cnt: 0 });
|
||
|
||
if count_row.cnt > 0 {
|
||
valid_count += 1;
|
||
} else {
|
||
dangling_count += 1;
|
||
details.push(crate::data_dto::DanglingRef {
|
||
entity: entity_rec.entity_name.clone(),
|
||
field: field.name.clone(),
|
||
record_id: row.id.to_string(),
|
||
dangling_value: row.ref_val,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(crate::data_dto::ReconciliationReport {
|
||
valid_count,
|
||
dangling_count,
|
||
details,
|
||
})
|
||
}
|
||
}
|
||
|
||
/// 从 plugins 表解析 manifest metadata.id(如 "erp-crm")
|
||
pub async fn resolve_manifest_id(
|
||
plugin_id: Uuid,
|
||
tenant_id: Uuid,
|
||
db: &sea_orm::DatabaseConnection,
|
||
) -> AppResult<String> {
|
||
let model = plugin::Entity::find()
|
||
.filter(plugin::Column::Id.eq(plugin_id))
|
||
.filter(plugin::Column::TenantId.eq(tenant_id))
|
||
.filter(plugin::Column::DeletedAt.is_null())
|
||
.one(db)
|
||
.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)))?;
|
||
|
||
Ok(manifest.metadata.id)
|
||
}
|
||
|
||
/// 从 plugin_entities 表获取实体完整信息(带租户隔离)
|
||
/// 注意:此函数不填充 generated_fields,仅用于非 list 场景
|
||
async fn resolve_entity_info(
|
||
plugin_id: Uuid,
|
||
entity_name: &str,
|
||
tenant_id: Uuid,
|
||
db: &sea_orm::DatabaseConnection,
|
||
) -> AppResult<EntityInfo> {
|
||
let entity = plugin_entity::Entity::find()
|
||
.filter(plugin_entity::Column::PluginId.eq(plugin_id))
|
||
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
|
||
.filter(plugin_entity::Column::EntityName.eq(entity_name))
|
||
.filter(plugin_entity::Column::DeletedAt.is_null())
|
||
.one(db)
|
||
.await?
|
||
.ok_or_else(|| {
|
||
AppError::NotFound(format!("插件实体 {}/{} 不存在", plugin_id, entity_name))
|
||
})?;
|
||
|
||
Ok(EntityInfo {
|
||
table_name: entity.table_name,
|
||
schema_json: entity.schema_json,
|
||
generated_fields: vec![], // 旧路径,不追踪 generated_fields
|
||
})
|
||
}
|
||
|
||
/// 从缓存或数据库获取实体信息(带 generated_fields 解析)
|
||
pub async fn resolve_entity_info_cached(
|
||
plugin_id: Uuid,
|
||
entity_name: &str,
|
||
tenant_id: Uuid,
|
||
db: &sea_orm::DatabaseConnection,
|
||
cache: &moka::sync::Cache<String, EntityInfo>,
|
||
) -> AppResult<EntityInfo> {
|
||
let cache_key = format!("{}:{}:{}", plugin_id, entity_name, tenant_id);
|
||
if let Some(info) = cache.get(&cache_key) {
|
||
return Ok(info);
|
||
}
|
||
|
||
let entity = plugin_entity::Entity::find()
|
||
.filter(plugin_entity::Column::PluginId.eq(plugin_id))
|
||
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
|
||
.filter(plugin_entity::Column::EntityName.eq(entity_name))
|
||
.filter(plugin_entity::Column::DeletedAt.is_null())
|
||
.one(db)
|
||
.await?
|
||
.ok_or_else(|| {
|
||
AppError::NotFound(format!("插件实体 {}/{} 不存在", plugin_id, entity_name))
|
||
})?;
|
||
|
||
// 解析 generated_fields
|
||
let entity_def: crate::manifest::PluginEntity =
|
||
serde_json::from_value(entity.schema_json.clone())
|
||
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
|
||
let generated_fields: Vec<String> = entity_def
|
||
.fields
|
||
.iter()
|
||
.filter(|f| f.field_type.supports_generated_column())
|
||
.filter(|f| {
|
||
f.unique
|
||
|| f.sortable == Some(true)
|
||
|| f.filterable == Some(true)
|
||
|| (f.required && (f.sortable == Some(true) || f.filterable == Some(true)))
|
||
})
|
||
.map(|f| sanitize_identifier(&f.name))
|
||
.collect();
|
||
|
||
let info = EntityInfo {
|
||
table_name: entity.table_name,
|
||
schema_json: entity.schema_json,
|
||
generated_fields,
|
||
};
|
||
|
||
cache.insert(cache_key, info.clone());
|
||
Ok(info)
|
||
}
|
||
|
||
/// 跨插件实体解析 — 按 manifest_id + entity_name 查找目标插件的实体信息
|
||
pub async fn resolve_cross_plugin_entity(
|
||
target_manifest_id: &str,
|
||
entity_name: &str,
|
||
tenant_id: Uuid,
|
||
db: &sea_orm::DatabaseConnection,
|
||
) -> AppResult<EntityInfo> {
|
||
let entity = plugin_entity::Entity::find()
|
||
.filter(plugin_entity::Column::ManifestId.eq(target_manifest_id))
|
||
.filter(plugin_entity::Column::EntityName.eq(entity_name))
|
||
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
|
||
.filter(plugin_entity::Column::DeletedAt.is_null())
|
||
.one(db)
|
||
.await?
|
||
.ok_or_else(|| {
|
||
AppError::NotFound(format!(
|
||
"跨插件实体 {}/{} 不存在或未公开",
|
||
target_manifest_id, entity_name
|
||
))
|
||
})?;
|
||
|
||
let entity_def: crate::manifest::PluginEntity =
|
||
serde_json::from_value(entity.schema_json.clone())
|
||
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
|
||
let generated_fields: Vec<String> = entity_def
|
||
.fields
|
||
.iter()
|
||
.filter(|f| f.field_type.supports_generated_column())
|
||
.filter(|f| {
|
||
f.unique
|
||
|| f.sortable == Some(true)
|
||
|| f.filterable == Some(true)
|
||
|| (f.required && (f.sortable == Some(true) || f.filterable == Some(true)))
|
||
})
|
||
.map(|f| sanitize_identifier(&f.name))
|
||
.collect();
|
||
|
||
Ok(EntityInfo {
|
||
table_name: entity.table_name,
|
||
schema_json: entity.schema_json,
|
||
generated_fields,
|
||
})
|
||
}
|
||
|
||
/// 检查目标插件是否安装且活跃
|
||
pub async fn is_plugin_active(
|
||
target_manifest_id: &str,
|
||
tenant_id: Uuid,
|
||
db: &sea_orm::DatabaseConnection,
|
||
) -> bool {
|
||
// 通过 plugin_entities 的 manifest_id 找到 plugin_id,再检查 plugins 表状态
|
||
let entity = plugin_entity::Entity::find()
|
||
.filter(plugin_entity::Column::ManifestId.eq(target_manifest_id))
|
||
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
|
||
.filter(plugin_entity::Column::DeletedAt.is_null())
|
||
.one(db)
|
||
.await;
|
||
|
||
let Some(entity) = entity.ok().flatten() else {
|
||
return false;
|
||
};
|
||
|
||
let plugin = plugin::Entity::find_by_id(entity.plugin_id)
|
||
.filter(plugin::Column::TenantId.eq(tenant_id))
|
||
.filter(plugin::Column::DeletedAt.is_null())
|
||
.one(db)
|
||
.await;
|
||
|
||
matches!(plugin.ok().flatten(), Some(p) if p.status == "running" || p.status == "installed")
|
||
}
|
||
|
||
/// 校验数据:检查 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()))?;
|
||
|
||
for field in fields {
|
||
let label = field.display_name.as_deref().unwrap_or(&field.name);
|
||
|
||
// required 检查
|
||
if field.required && !obj.contains_key(&field.name) {
|
||
return Err(AppError::Validation(format!("字段 '{}' 不能为空", label)));
|
||
}
|
||
|
||
// 正则校验
|
||
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()));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 校验外键引用 — 检查 ref_entity 字段指向的记录是否存在
|
||
/// 支持同插件引用和跨插件引用(ref_plugin 字段)
|
||
/// 核心原则:跨插件引用目标插件未安装时跳过校验(软警告)
|
||
#[allow(clippy::too_many_arguments)]
|
||
async fn validate_ref_entities(
|
||
data: &serde_json::Value,
|
||
fields: &[PluginField],
|
||
current_entity: &str,
|
||
plugin_id: Uuid,
|
||
tenant_id: Uuid,
|
||
db: &sea_orm::DatabaseConnection,
|
||
is_create: bool,
|
||
record_id: Option<Uuid>,
|
||
) -> AppResult<()> {
|
||
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 str_val = val.as_str().unwrap_or("").trim().to_string();
|
||
|
||
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!(
|
||
"字段 '{}' 的值 '{}' 不是有效的 UUID",
|
||
field.display_name.as_deref().unwrap_or(&field.name),
|
||
str_val
|
||
))
|
||
})?;
|
||
|
||
// 自引用 + create:跳过(记录尚未存在)
|
||
if ref_entity_name == current_entity && field.ref_plugin.is_none() && is_create {
|
||
continue;
|
||
}
|
||
// 自引用 + update:检查是否引用自身
|
||
if ref_entity_name == current_entity
|
||
&& field.ref_plugin.is_none()
|
||
&& !is_create
|
||
&& let Some(rid) = record_id
|
||
&& ref_id == rid
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// 确定目标表名
|
||
let ref_table = if let Some(target_plugin) = &field.ref_plugin {
|
||
// 跨插件引用 — 检查目标插件是否活跃
|
||
if !is_plugin_active(target_plugin, tenant_id, db).await {
|
||
// 目标插件未安装/禁用 → 跳过校验(软警告,不阻塞)
|
||
tracing::debug!(
|
||
field = %field.name,
|
||
target_plugin = %target_plugin,
|
||
"跨插件引用目标插件未活跃,跳过校验"
|
||
);
|
||
continue;
|
||
}
|
||
// 目标插件活跃 → 解析目标表名
|
||
match resolve_cross_plugin_entity(target_plugin, ref_entity_name, tenant_id, db).await {
|
||
Ok(info) => info.table_name,
|
||
Err(e) => {
|
||
tracing::warn!(
|
||
field = %field.name,
|
||
target_plugin = %target_plugin,
|
||
entity = %ref_entity_name,
|
||
error = %e,
|
||
"跨插件实体解析失败,跳过校验"
|
||
);
|
||
continue;
|
||
}
|
||
}
|
||
} else {
|
||
// 同插件引用 — 使用原有逻辑
|
||
let manifest_id = resolve_manifest_id(plugin_id, tenant_id, db).await?;
|
||
DynamicTableManager::table_name(&manifest_id, ref_entity_name)
|
||
};
|
||
|
||
let check_sql = format!(
|
||
"SELECT 1 as check_result FROM \"{}\" WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL LIMIT 1",
|
||
ref_table
|
||
);
|
||
#[derive(FromQueryResult)]
|
||
#[allow(dead_code)] // FromQueryResult 映射需要 check_result 字段,仅检查是否存在
|
||
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?;
|
||
|
||
if result.is_none() {
|
||
return Err(AppError::Validation(format!(
|
||
"引用的 {} 记录不存在(ID: {})",
|
||
ref_entity_name, ref_id
|
||
)));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 循环引用检测 — 用于 no_cycle 字段
|
||
async fn check_no_cycle(
|
||
record_id: Uuid,
|
||
field: &PluginField,
|
||
data: &serde_json::Value,
|
||
table_name: &str,
|
||
tenant_id: Uuid,
|
||
db: &sea_orm::DatabaseConnection,
|
||
) -> AppResult<()> {
|
||
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(());
|
||
}
|
||
|
||
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];
|
||
let mut current_id = new_parent_id;
|
||
|
||
for _ in 0..100 {
|
||
if visited.contains(¤t_id) {
|
||
let label = field.display_name.as_deref().unwrap_or(&field.name);
|
||
return Err(AppError::Validation(format!(
|
||
"字段 '{}' 形成循环引用",
|
||
label
|
||
)));
|
||
}
|
||
visited.push(current_id);
|
||
|
||
let query_sql = format!(
|
||
"SELECT data->>'{}' as parent FROM \"{}\" WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL",
|
||
field_name, table_name
|
||
);
|
||
#[derive(FromQueryResult)]
|
||
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?;
|
||
|
||
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()))?;
|
||
}
|
||
None => break,
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 从 DataScopeParams 构建 SQL 条件片段和参数
|
||
fn build_scope_sql(
|
||
scope: &Option<DataScopeParams>,
|
||
generated_fields: &[String],
|
||
next_param_idx: usize,
|
||
) -> (String, Vec<sea_orm::Value>) {
|
||
match scope {
|
||
Some(s) => DynamicTableManager::build_data_scope_condition_with_params(
|
||
&s.scope_level,
|
||
&s.user_id,
|
||
&s.owner_field,
|
||
&s.dept_member_ids,
|
||
next_param_idx,
|
||
generated_fields,
|
||
),
|
||
None => (String::new(), vec![]),
|
||
}
|
||
}
|
||
|
||
/// 将数据权限条件合并到现有 SQL 中
|
||
///
|
||
/// `scope_condition` 是 `(sql_fragment, params)` 元组。
|
||
/// sql_fragment 格式为 `"field = $N OR ..."`,可直接拼接到 WHERE 子句。
|
||
fn merge_scope_condition(sql: String, scope_condition: &(String, Vec<sea_orm::Value>)) -> String {
|
||
if scope_condition.0.is_empty() {
|
||
return sql;
|
||
}
|
||
// 在 "deleted_at IS NULL" 之后追加 scope 条件
|
||
// 因为所有查询都包含 WHERE ... AND "deleted_at" IS NULL ...
|
||
// 我们在合适的位置追加 AND (scope_condition)
|
||
if sql.contains("\"deleted_at\" IS NULL") {
|
||
sql.replace(
|
||
"\"deleted_at\" IS NULL",
|
||
&format!("\"deleted_at\" IS NULL AND ({})", scope_condition.0),
|
||
)
|
||
} else if sql.contains("deleted_at IS NULL") {
|
||
sql.replace(
|
||
"deleted_at IS NULL",
|
||
&format!("deleted_at IS NULL AND ({})", scope_condition.0),
|
||
)
|
||
} else {
|
||
// 回退:直接追加到 WHERE 子句末尾
|
||
sql
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod validate_tests {
|
||
use super::*;
|
||
use crate::manifest::{FieldValidation, PluginField, PluginFieldType};
|
||
|
||
fn make_field(name: &str, pattern: Option<&str>, message: Option<&str>) -> PluginField {
|
||
PluginField {
|
||
name: name.to_string(),
|
||
field_type: PluginFieldType::String,
|
||
required: false,
|
||
validation: pattern.map(|p| FieldValidation {
|
||
pattern: Some(p.to_string()),
|
||
message: message.map(|m| m.to_string()),
|
||
}),
|
||
..PluginField::default_for_field()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn validate_phone_pattern_rejects_invalid() {
|
||
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());
|
||
}
|
||
|
||
#[test]
|
||
fn validate_phone_pattern_accepts_valid() {
|
||
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());
|
||
}
|
||
|
||
#[test]
|
||
fn validate_empty_optional_field_skips_pattern() {
|
||
let fields = vec![make_field("phone", Some("^1[3-9]\\d{9}$"), None)];
|
||
let data = serde_json::json!({"phone": ""});
|
||
let result = validate_data(&data, &fields);
|
||
assert!(result.is_ok());
|
||
}
|
||
}
|