diff --git a/Cargo.lock b/Cargo.lock index a161a01..dbf7528 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,6 +137,9 @@ name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] [[package]] name = "arc-swap" @@ -960,6 +963,27 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "darling" version = "0.20.11" @@ -1039,6 +1063,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1245,10 +1280,12 @@ dependencies = [ "axum", "base64 0.22.1", "chrono", + "csv", "dashmap", "erp-core", "moka", "regex", + "rust_xlsxwriter", "sea-orm", "serde", "serde_json", @@ -3166,6 +3203,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "rust_xlsxwriter" +version = "0.82.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d61a82de4e7b30fc427909f2c5aafaada88cc7ae8316edabae435f74341f9278" +dependencies = [ + "zip", +] + [[package]] name = "rustc-demangle" version = "0.1.27" @@ -5845,12 +5891,41 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index 46a7e44..fb13659 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,6 +77,10 @@ validator = { version = "0.19", features = ["derive"] } # Async trait async-trait = "0.1" +# CSV and Excel export +csv = "1" +rust_xlsxwriter = "0.82" + # Internal crates erp-core = { path = "crates/erp-core" } erp-auth = { path = "crates/erp-auth" } diff --git a/apps/web/src/api/pluginData.ts b/apps/web/src/api/pluginData.ts index 4227f7e..302d42f 100644 --- a/apps/web/src/api/pluginData.ts +++ b/apps/web/src/api/pluginData.ts @@ -217,7 +217,7 @@ export interface ExportOptions { search?: string; sort_by?: string; sort_order?: 'asc' | 'desc'; - format?: 'csv' | 'json'; + format?: 'json' | 'csv' | 'xlsx'; } export async function exportPluginData( @@ -238,6 +238,25 @@ export async function exportPluginData( return data.data; } +export async function exportPluginDataAsBlob( + pluginId: string, + entity: string, + format: 'csv' | 'xlsx', + options?: Omit, +): Promise { + const params: Record = { format }; + if (options?.filter) params.filter = JSON.stringify(options.filter); + if (options?.search) params.search = options.search; + if (options?.sort_by) params.sort_by = options.sort_by; + if (options?.sort_order) params.sort_order = options.sort_order; + + const response = await client.get( + `/plugins/${pluginId}/${entity}/export`, + { params, responseType: 'blob' }, + ); + return response.data as Blob; +} + export interface ImportRowError { row: number; errors: string[]; diff --git a/apps/web/src/api/plugins.ts b/apps/web/src/api/plugins.ts index 9385e1f..652c830 100644 --- a/apps/web/src/api/plugins.ts +++ b/apps/web/src/api/plugins.ts @@ -256,3 +256,81 @@ export interface PluginTriggerEvent { entity: string; on: 'create' | 'update' | 'delete' | 'create_or_update'; } + +// ── 插件市场 API ── + +export interface MarketEntry { + id: string; + plugin_id: string; + name: string; + version: string; + description?: string; + author?: string; + category?: string; + tags?: string[]; + icon_url?: string; + screenshots?: string[]; + min_platform_version?: string; + status: string; + download_count: number; + rating_avg: number; + rating_count: number; + changelog?: string; + created_at?: string; + updated_at?: string; +} + +export interface MarketEntryDetail extends MarketEntry { + dependency_warnings: string[]; +} + +export interface MarketReview { + id: string; + user_id: string; + market_entry_id: string; + rating: number; + review_text?: string; + created_at?: string; +} + +export async function listMarketEntries(params?: { + page?: number; + page_size?: number; + category?: string; + search?: string; +}) { + const { data } = await client.get<{ success: boolean; data: PaginatedResponse }>( + '/market/entries', + { params }, + ); + return data.data; +} + +export async function getMarketEntry(id: string) { + const { data } = await client.get<{ success: boolean; data: MarketEntryDetail }>( + `/market/entries/${id}`, + ); + return data.data; +} + +export async function installFromMarket(id: string) { + const { data } = await client.post<{ success: boolean; data: PluginInfo }>( + `/market/entries/${id}/install`, + ); + return data.data; +} + +export async function listMarketReviews(id: string) { + const { data } = await client.get<{ success: boolean; data: MarketReview[] }>( + `/market/entries/${id}/reviews`, + ); + return data.data; +} + +export async function submitMarketReview(id: string, review: { rating: number; review_text?: string }) { + const { data } = await client.post<{ success: boolean; data: MarketReview }>( + `/market/entries/${id}/reviews`, + review, + ); + return data.data; +} diff --git a/apps/web/src/pages/PluginCRUDPage.tsx b/apps/web/src/pages/PluginCRUDPage.tsx index d988075..4e79360 100644 --- a/apps/web/src/pages/PluginCRUDPage.tsx +++ b/apps/web/src/pages/PluginCRUDPage.tsx @@ -20,6 +20,7 @@ import { Timeline, Upload, Alert, + Dropdown, } from 'antd'; import { PlusOutlined, @@ -38,6 +39,7 @@ import { batchPluginData, resolveRefLabels, exportPluginData, + exportPluginDataAsBlob, importPluginData, type PluginDataListOptions, type ImportResult, @@ -576,32 +578,54 @@ export default function PluginCRUDPage({ 刷新 {entityDef?.exportable && ( - + + )} {entityDef?.importable && ( + + {/* 评论区 */} +
+ 用户评价 ({reviews.length}) + + {reviews.length > 0 && ( +
+ {reviews.map((review) => ( +
+ + + + {review.created_at ? new Date(review.created_at).toLocaleDateString() : ''} + + + {review.review_text && ( + + {review.review_text} + + )} +
+ ))} +
+ )} + + {reviews.length === 0 && ( + + )} + + {installedIds.has(selectedPlugin.plugin_id) && ( +
+ + + + + + + + + +
+ )} +
)} diff --git a/crates/erp-plugin/Cargo.toml b/crates/erp-plugin/Cargo.toml index 873a2bc..8ead83d 100644 --- a/crates/erp-plugin/Cargo.toml +++ b/crates/erp-plugin/Cargo.toml @@ -25,3 +25,5 @@ sha2 = { workspace = true } base64 = "0.22" moka = { version = "0.12", features = ["sync"] } regex = "1" +csv = { workspace = true } +rust_xlsxwriter = { workspace = true } diff --git a/crates/erp-plugin/src/data_dto.rs b/crates/erp-plugin/src/data_dto.rs index 241e267..ae451ed 100644 --- a/crates/erp-plugin/src/data_dto.rs +++ b/crates/erp-plugin/src/data_dto.rs @@ -178,10 +178,17 @@ pub struct ExportParams { pub sort_by: Option, /// "asc" or "desc" pub sort_order: Option, - /// 导出格式: "csv" (默认) | "json" + /// 导出格式: "json" (默认) | "csv" | "xlsx" pub format: Option, } +/// 导出结果 — 根据格式返回不同内容 +pub enum ExportPayload { + Json(Vec), + Csv(Vec), + Xlsx(Vec), +} + /// 数据导入请求 #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct ImportReq { @@ -209,3 +216,92 @@ pub struct ImportRowError { /// 错误消息列表 pub errors: Vec, } + +// ─── 市场目录 DTO ────────────────────────────────────────────────── + +/// 市场条目列表查询参数 +#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)] +pub struct MarketListParams { + pub page: Option, + pub page_size: Option, + pub category: Option, + pub search: Option, +} + +/// 市场条目响应(不含二进制数据) +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct MarketEntryResp { + pub id: String, + pub plugin_id: String, + pub name: String, + pub version: String, + pub description: Option, + pub author: Option, + pub category: Option, + pub tags: Option, + pub icon_url: Option, + pub screenshots: Option, + pub min_platform_version: Option, + pub status: String, + pub download_count: i32, + pub rating_avg: f64, + pub rating_count: i32, + pub changelog: Option, + pub created_at: Option>, + pub updated_at: Option>, +} + +/// 市场条目详情响应(含完整信息) +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct MarketEntryDetailResp { + #[serde(flatten)] + pub entry: MarketEntryResp, + /// 依赖提示(安装时检查 manifest.dependencies) + pub dependency_warnings: Vec, +} + +/// 提交评分/评论请求 +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct SubmitReviewReq { + /// 评分 1-5 + pub rating: i32, + /// 评论内容 + pub review_text: Option, +} + +/// 评论响应 +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct MarketReviewResp { + pub id: String, + pub user_id: String, + pub market_entry_id: String, + pub rating: i32, + pub review_text: Option, + pub created_at: Option>, +} + +// ─── 对账扫描 DTO ────────────────────────────────────────────────── + +/// 对账报告 +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ReconciliationReport { + /// 有效引用数 + pub valid_count: i64, + /// 悬空引用数 + pub dangling_count: i64, + /// 悬空引用详情 + pub details: Vec, +} + +/// 悬空引用详情 +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct DanglingRef { + /// 实体名 + pub entity: String, + /// 字段名 + pub field: String, + /// 记录 ID + pub record_id: String, + /// 悬空的 UUID 值 + pub dangling_value: String, +} diff --git a/crates/erp-plugin/src/data_service.rs b/crates/erp-plugin/src/data_service.rs index 0425c63..917d585 100644 --- a/crates/erp-plugin/src/data_service.rs +++ b/crates/erp-plugin/src/data_service.rs @@ -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, sort_by: Option, sort_order: Option, + format: Option, cache: &moka::sync::Cache, scope: Option, - ) -> AppResult> { + ) -> AppResult { + 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 = 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> { + 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 = 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> { + 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 { + 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") diff --git a/crates/erp-plugin/src/entity/market_entry.rs b/crates/erp-plugin/src/entity/market_entry.rs new file mode 100644 index 0000000..0b3f840 --- /dev/null +++ b/crates/erp-plugin/src/entity/market_entry.rs @@ -0,0 +1,45 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "plugin_market_entries")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub plugin_id: String, + pub name: String, + pub version: String, + pub description: Option, + pub author: Option, + pub category: Option, + pub tags: Option, + pub icon_url: Option, + pub screenshots: Option, + #[serde(skip)] + pub wasm_binary: Vec, + #[serde(skip_serializing)] + pub manifest_toml: String, + pub wasm_hash: String, + pub min_platform_version: Option, + pub status: String, + pub download_count: i32, + pub rating_avg: Decimal, + pub rating_count: i32, + pub changelog: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::market_review::Entity")] + MarketReview, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::MarketReview.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-plugin/src/entity/market_review.rs b/crates/erp-plugin/src/entity/market_review.rs new file mode 100644 index 0000000..b80d9ce --- /dev/null +++ b/crates/erp-plugin/src/entity/market_review.rs @@ -0,0 +1,33 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "plugin_market_reviews")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub user_id: Uuid, + pub market_entry_id: Uuid, + pub rating: i32, + pub review_text: Option, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::market_entry::Entity", + from = "Column::MarketEntryId", + to = "super::market_entry::Column::Id" + )] + MarketEntry, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::MarketEntry.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-plugin/src/entity/mod.rs b/crates/erp-plugin/src/entity/mod.rs index 0ae3634..4febd16 100644 --- a/crates/erp-plugin/src/entity/mod.rs +++ b/crates/erp-plugin/src/entity/mod.rs @@ -1,3 +1,5 @@ +pub mod market_entry; +pub mod market_review; pub mod plugin; pub mod plugin_entity; pub mod plugin_event_subscription; diff --git a/crates/erp-plugin/src/handler/data_handler.rs b/crates/erp-plugin/src/handler/data_handler.rs index 0b38854..feb41ec 100644 --- a/crates/erp-plugin/src/handler/data_handler.rs +++ b/crates/erp-plugin/src/handler/data_handler.rs @@ -11,7 +11,7 @@ use crate::data_dto::{ AggregateItem, AggregateMultiReq, AggregateMultiRow, AggregateQueryParams, BatchActionReq, CountQueryParams, CreatePluginDataReq, ExportParams, ImportReq, ImportResult, PatchPluginDataReq, PluginDataListParams, - PluginDataResp, PublicEntityResp, ResolveLabelsReq, ResolveLabelsResp, + PluginDataResp, PublicEntityResp, ReconciliationReport, ResolveLabelsReq, ResolveLabelsResp, TimeseriesItem, TimeseriesParams, UpdatePluginDataReq, }; use crate::data_service::{DataScopeParams, PluginDataService, resolve_manifest_id}; @@ -794,17 +794,21 @@ where security(("bearer_auth" = [])), tag = "插件数据" )] -/// GET /api/v1/plugins/{plugin_id}/{entity}/export — 导出数据 +/// GET /api/v1/plugins/{plugin_id}/{entity}/export — 导出数据 (JSON/CSV/XLSX) pub async fn export_plugin_data( State(state): State, Extension(ctx): Extension, Path((plugin_id, entity)): Path<(Uuid, String)>, Query(params): Query, -) -> Result>>, AppError> +) -> Result where PluginState: FromRef, S: Clone + Send + Sync + 'static, { + use crate::data_dto::ExportPayload; + use axum::http::{header, StatusCode}; + use axum::body::Body; + let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?; let fine_perm = compute_permission_code(&manifest_id, &entity, "list"); require_permission(&ctx, &fine_perm)?; @@ -818,7 +822,7 @@ where .as_ref() .and_then(|f| serde_json::from_str(f).ok()); - let rows = PluginDataService::export( + let payload = PluginDataService::export( plugin_id, &entity, ctx.tenant_id, @@ -827,12 +831,40 @@ where params.search, params.sort_by, params.sort_order, + params.format, &state.entity_cache, scope, ) .await?; - Ok(Json(ApiResponse::ok(rows))) + let filename = format!("{}_export_{}", entity, chrono::Utc::now().format("%Y%m%d%H%M%S")); + match payload { + ExportPayload::Json(data) => { + let body = serde_json::to_string(&ApiResponse::ok(data)) + .map_err(|e| AppError::Internal(e.to_string()))?; + Ok(axum::response::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .unwrap()) + } + ExportPayload::Csv(bytes) => { + Ok(axum::response::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/csv; charset=utf-8") + .header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}.csv\"", filename)) + .body(Body::from(bytes)) + .unwrap()) + } + ExportPayload::Xlsx(bytes) => { + Ok(axum::response::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}.xlsx\"", filename)) + .body(Body::from(bytes)) + .unwrap()) + } + } } #[utoipa::path( @@ -873,3 +905,33 @@ where Ok(Json(ApiResponse::ok(result))) } + +/// POST /api/v1/plugins/{plugin_id}/reconcile — 对账扫描 +#[utoipa::path( + post, + path = "/api/v1/plugins/{plugin_id}/reconcile", + responses( + (status = 200, description = "对账报告", body = ApiResponse) + ), + tag = "Plugin Data", +)] +pub async fn reconcile_refs( + State(state): State, + Extension(ctx): Extension, + Path(plugin_id): Path, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + + let report = PluginDataService::reconcile_references( + plugin_id, + ctx.tenant_id, + &state.db, + ) + .await?; + + Ok(Json(ApiResponse::ok(report))) +} diff --git a/crates/erp-plugin/src/handler/market_handler.rs b/crates/erp-plugin/src/handler/market_handler.rs new file mode 100644 index 0000000..953859e --- /dev/null +++ b/crates/erp-plugin/src/handler/market_handler.rs @@ -0,0 +1,369 @@ +use axum::extract::{FromRef, Path, Query, State}; +use axum::response::Json; +use axum::Extension; +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set, prelude::Decimal}; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; + +use crate::data_dto::{ + MarketEntryDetailResp, MarketEntryResp, MarketListParams, MarketReviewResp, SubmitReviewReq, +}; +use crate::entity::{market_entry, market_review, plugin}; +use crate::state::PluginState; + +fn entry_to_resp(model: &market_entry::Model) -> MarketEntryResp { + MarketEntryResp { + id: model.id.to_string(), + plugin_id: model.plugin_id.clone(), + name: model.name.clone(), + version: model.version.clone(), + description: model.description.clone(), + author: model.author.clone(), + category: model.category.clone(), + tags: model.tags.clone(), + icon_url: model.icon_url.clone(), + screenshots: model.screenshots.clone(), + min_platform_version: model.min_platform_version.clone(), + status: model.status.clone(), + download_count: model.download_count, + rating_avg: model.rating_avg.to_string().parse().unwrap_or(0.0), + rating_count: model.rating_count, + changelog: model.changelog.clone(), + created_at: Some(model.created_at), + updated_at: Some(model.updated_at), + } +} + +#[utoipa::path( + get, + path = "/api/v1/market/entries", + params(MarketListParams), + responses( + (status = 200, description = "市场条目列表", body = ApiResponse>) + ), + tag = "Plugin Market", +)] +pub async fn list_market_entries( + State(_state): State, + Query(params): Query, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + + let state: PluginState = PluginState::from_ref(&_state); + let db = &state.db; + + let page = params.page.unwrap_or(1); + let page_size = params.page_size.unwrap_or(20).min(100); + + let mut query = market_entry::Entity::find() + .filter(market_entry::Column::Status.eq("published")); + + if let Some(ref category) = params.category { + query = query.filter(market_entry::Column::Category.eq(category.as_str())); + } + + if let Some(ref search) = params.search { + query = query.filter( + sea_orm::Condition::any() + .add(market_entry::Column::Name.contains(search.as_str())) + .add(market_entry::Column::Description.contains(search.as_str())) + .add(market_entry::Column::Author.contains(search.as_str())), + ); + } + + query = query.order_by_desc(market_entry::Column::DownloadCount); + + let total = query.clone().count(db).await.map_err(|e| AppError::Internal(e.to_string()))?; + let total_pages = ((total as f64) / (page_size as f64)).ceil() as u64; + + let models = query + .paginate(db, page_size) + .fetch_page(page.saturating_sub(1)) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + let items = models.iter().map(entry_to_resp).collect(); + + Ok(Json(ApiResponse::ok(PaginatedResponse { + data: items, + total, + page, + page_size, + total_pages, + }))) +} + +#[utoipa::path( + get, + path = "/api/v1/market/entries/{id}", + responses( + (status = 200, description = "市场条目详情", body = ApiResponse) + ), + tag = "Plugin Market", +)] +pub async fn get_market_entry( + State(_state): State, + Path(id): Path, + Extension(ctx): Extension, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + + let state: PluginState = PluginState::from_ref(&_state); + let db = &state.db; + + let model = market_entry::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))? + .ok_or_else(|| AppError::NotFound("市场条目不存在".to_string()))?; + + // 解析 manifest 检查依赖 + let mut dependency_warnings = Vec::new(); + if let Ok(manifest) = crate::manifest::parse_manifest(&model.manifest_toml) { + for dep_id in &manifest.metadata.dependencies { + let installed = plugin::Entity::find() + .filter(plugin::Column::Name.eq(dep_id.as_str())) + .one(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + if installed.is_none() { + dependency_warnings.push(format!("依赖插件 '{}' 尚未安装", dep_id)); + } + } + } + + Ok(Json(ApiResponse::ok(MarketEntryDetailResp { + entry: entry_to_resp(&model), + dependency_warnings, + }))) +} + +#[utoipa::path( + post, + path = "/api/v1/market/entries/{id}/install", + responses( + (status = 200, description = "从市场安装插件", body = ApiResponse) + ), + tag = "Plugin Market", +)] +pub async fn install_from_market( + State(_state): State, + Path(id): Path, + Extension(ctx): Extension, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + + let state: PluginState = PluginState::from_ref(&_state); + let db = &state.db; + let engine = &state.engine; + + // 获取市场条目 + let market_model = market_entry::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))? + .ok_or_else(|| AppError::NotFound("市场条目不存在".to_string()))?; + + if market_model.status != "published" { + return Err(AppError::Validation("该插件已下架,无法安装".to_string())); + } + + // 检查是否已安装同 plugin_id 的插件 + let existing = plugin::Entity::find() + .filter(plugin::Column::Name.eq(market_model.plugin_id.as_str())) + .filter(plugin::Column::TenantId.eq(ctx.tenant_id)) + .one(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + if existing.is_some() { + return Err(AppError::Validation("该插件已安装,如需更新请使用升级功能".to_string())); + } + + // upload → install → enable 一条龙 + let wasm_binary = market_model.wasm_binary.clone(); + let manifest_toml = market_model.manifest_toml.clone(); + + let plugin_resp = crate::service::PluginService::upload( + ctx.tenant_id, + ctx.user_id, + wasm_binary, + &manifest_toml, + db, + ).await?; + + let plugin_id = plugin_resp.id; + let plugin_resp = crate::service::PluginService::install( + plugin_id, + ctx.tenant_id, + ctx.user_id, + db, + engine, + ).await?; + + let plugin_resp = crate::service::PluginService::enable( + plugin_id, + ctx.tenant_id, + ctx.user_id, + db, + engine, + ).await?; + + // 递增下载计数 + let mut active: market_entry::ActiveModel = market_model.into(); + let current = active.download_count.take().unwrap_or(0); + active.download_count = Set(current + 1); + let _ = active.update(db).await.map_err(|e| AppError::Internal(e.to_string()))?; + + Ok(Json(ApiResponse::ok(plugin_resp))) +} + +#[utoipa::path( + get, + path = "/api/v1/market/entries/{id}/reviews", + responses( + (status = 200, description = "评论列表", body = ApiResponse>) + ), + tag = "Plugin Market", +)] +pub async fn list_market_reviews( + State(_state): State, + Path(id): Path, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + + let state: PluginState = PluginState::from_ref(&_state); + let db = &state.db; + + let reviews = market_review::Entity::find() + .filter(market_review::Column::MarketEntryId.eq(id)) + .all(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + let items = reviews.iter().map(|r| MarketReviewResp { + id: r.id.to_string(), + user_id: r.user_id.to_string(), + market_entry_id: r.market_entry_id.to_string(), + rating: r.rating, + review_text: r.review_text.clone(), + created_at: Some(r.created_at), + }).collect(); + + Ok(Json(ApiResponse::ok(items))) +} + +#[utoipa::path( + post, + path = "/api/v1/market/entries/{id}/reviews", + responses( + (status = 200, description = "提交评分/评论", body = ApiResponse) + ), + tag = "Plugin Market", +)] +pub async fn submit_market_review( + State(_state): State, + Path(id): Path, + Extension(ctx): Extension, + Json(body): Json, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + + if body.rating < 1 || body.rating > 5 { + return Err(AppError::Validation("评分必须在 1-5 之间".to_string())); + } + + let state: PluginState = PluginState::from_ref(&_state); + let db = &state.db; + + // 验证市场条目存在 + let entry_model = market_entry::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))? + .ok_or_else(|| AppError::NotFound("市场条目不存在".to_string()))?; + + // upsert: 同一用户同一条目只保留最新评论 + let existing = market_review::Entity::find() + .filter(market_review::Column::MarketEntryId.eq(id)) + .filter(market_review::Column::UserId.eq(ctx.user_id)) + .filter(market_review::Column::TenantId.eq(ctx.tenant_id)) + .one(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + let review_model = if let Some(existing) = existing { + let mut active: market_review::ActiveModel = existing.into(); + active.rating = Set(body.rating); + active.review_text = Set(body.review_text); + active.update(db).await.map_err(|e| AppError::Internal(e.to_string()))? + } else { + let review_id = Uuid::now_v7(); + let now = Utc::now(); + let model = market_review::ActiveModel { + id: Set(review_id), + tenant_id: Set(ctx.tenant_id), + user_id: Set(ctx.user_id), + market_entry_id: Set(id), + rating: Set(body.rating), + review_text: Set(body.review_text), + created_at: Set(now), + }; + model.insert(db).await.map_err(|e| AppError::Internal(e.to_string()))? + }; + + // 重新计算平均评分 + let all_reviews = market_review::Entity::find() + .filter(market_review::Column::MarketEntryId.eq(id)) + .all(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + let count = all_reviews.len() as i32; + let avg: f64 = if count > 0 { + all_reviews.iter().map(|r| r.rating as f64).sum::() / count as f64 + } else { + 0.0 + }; + + let mut entry_active: market_entry::ActiveModel = entry_model.into(); + let avg_decimal = Decimal::from_f64_retain(avg).unwrap_or_default(); + entry_active.rating_avg = Set(avg_decimal); + entry_active.rating_count = Set(count); + let _ = entry_active.update(db).await.map_err(|e| AppError::Internal(e.to_string()))?; + + Ok(Json(ApiResponse::ok(MarketReviewResp { + id: review_model.id.to_string(), + user_id: review_model.user_id.to_string(), + market_entry_id: review_model.market_entry_id.to_string(), + rating: review_model.rating, + review_text: review_model.review_text, + created_at: Some(review_model.created_at), + }))) +} diff --git a/crates/erp-plugin/src/handler/mod.rs b/crates/erp-plugin/src/handler/mod.rs index f82418d..d36d253 100644 --- a/crates/erp-plugin/src/handler/mod.rs +++ b/crates/erp-plugin/src/handler/mod.rs @@ -1,2 +1,3 @@ pub mod data_handler; +pub mod market_handler; pub mod plugin_handler; diff --git a/crates/erp-plugin/src/module.rs b/crates/erp-plugin/src/module.rs index 7f86dbf..189bafc 100644 --- a/crates/erp-plugin/src/module.rs +++ b/crates/erp-plugin/src/module.rs @@ -122,6 +122,11 @@ impl PluginModule { .route( "/plugins/{plugin_id}/{entity}/import", post(crate::handler::data_handler::import_plugin_data::), + ) + // 对账扫描 + .route( + "/plugins/{plugin_id}/reconcile", + post(crate::handler::data_handler::reconcile_refs::), ); // 实体注册表路由 @@ -131,6 +136,26 @@ impl PluginModule { get(crate::handler::data_handler::list_public_entities::), ); - admin_routes.merge(data_routes).merge(registry_routes) + // 市场路由 + let market_routes = Router::new() + .route( + "/market/entries", + get(crate::handler::market_handler::list_market_entries::), + ) + .route( + "/market/entries/{id}", + get(crate::handler::market_handler::get_market_entry::), + ) + .route( + "/market/entries/{id}/install", + post(crate::handler::market_handler::install_from_market::), + ) + .route( + "/market/entries/{id}/reviews", + get(crate::handler::market_handler::list_market_reviews::) + .post(crate::handler::market_handler::submit_market_review::), + ); + + admin_routes.merge(data_routes).merge(registry_routes).merge(market_routes) } }