feat(plugin): P1-P4 审计修复 — 第一批 (Excel/CSV导出 + 市场API + 对账扫描)
1.1 Excel/CSV 导出:
- 后端 export 支持 format 参数 (json/csv/xlsx)
- rust_xlsxwriter 生成带样式 Excel
- 前端导出按钮改为 Dropdown 格式选择 (JSON/CSV/Excel)
- blob 下载支持 CSV/XLSX 二进制格式
1.2 市场后端 API + 前端对接:
- SeaORM Entity: market_entry, market_review
- API: 浏览/详情/一键安装/评论列表/提交评分
- 一键安装: upload → install → enable 一条龙 + 依赖检查
- 前端 PluginMarket 对接真实 API (搜索/分类/安装/评分)
1.3 对账扫描:
- reconcile_references() 扫描跨插件引用悬空 UUID
- POST /plugins/{plugin_id}/reconcile 端点
This commit is contained in:
@@ -505,7 +505,7 @@ impl PluginDataService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 导出数据(不分页,复用 list 的过滤逻辑)
|
||||
/// 导出数据(支持 JSON/CSV/XLSX 格式)
|
||||
pub async fn export(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
@@ -515,13 +515,15 @@ impl PluginDataService {
|
||||
search: Option<String>,
|
||||
sort_by: Option<String>,
|
||||
sort_order: Option<String>,
|
||||
format: Option<String>,
|
||||
cache: &moka::sync::Cache<String, EntityInfo>,
|
||||
scope: Option<DataScopeParams>,
|
||||
) -> AppResult<Vec<serde_json::Value>> {
|
||||
) -> 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
|
||||
@@ -535,7 +537,6 @@ impl PluginDataService {
|
||||
}
|
||||
};
|
||||
|
||||
// 查询所有匹配行(上限 10000)
|
||||
let (sql, mut values) = DynamicTableManager::build_filtered_query_sql_ex(
|
||||
&info.table_name,
|
||||
tenant_id,
|
||||
@@ -549,7 +550,6 @@ impl PluginDataService {
|
||||
)
|
||||
.map_err(|e| AppError::Validation(e))?;
|
||||
|
||||
// 注入数据权限
|
||||
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);
|
||||
@@ -565,7 +565,76 @@ impl PluginDataService {
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| r.data).collect())
|
||||
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).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)))?;
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
/// 批量导入数据(逐行校验 + 逐行插入)
|
||||
@@ -1037,6 +1106,120 @@ impl PluginDataService {
|
||||
})
|
||||
.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)]
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user