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:
iven
2026-04-19 14:32:06 +08:00
parent 120f3fe867
commit 4bcb4beaa5
16 changed files with 1243 additions and 151 deletions

View File

@@ -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"