feat(plugin): P1-P4 审计修复 — 第一批 (Excel/CSV导出 + 市场API + 对账扫描)
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

1.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

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

View File

@@ -178,10 +178,17 @@ pub struct ExportParams {
pub sort_by: Option<String>,
/// "asc" or "desc"
pub sort_order: Option<String>,
/// 导出格式: "csv" (默认) | "json"
/// 导出格式: "json" (默认) | "csv" | "xlsx"
pub format: Option<String>,
}
/// 导出结果 — 根据格式返回不同内容
pub enum ExportPayload {
Json(Vec<serde_json::Value>),
Csv(Vec<u8>),
Xlsx(Vec<u8>),
}
/// 数据导入请求
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ImportReq {
@@ -209,3 +216,92 @@ pub struct ImportRowError {
/// 错误消息列表
pub errors: Vec<String>,
}
// ─── 市场目录 DTO ──────────────────────────────────────────────────
/// 市场条目列表查询参数
#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)]
pub struct MarketListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub category: Option<String>,
pub search: Option<String>,
}
/// 市场条目响应(不含二进制数据)
#[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<String>,
pub author: Option<String>,
pub category: Option<String>,
pub tags: Option<serde_json::Value>,
pub icon_url: Option<String>,
pub screenshots: Option<serde_json::Value>,
pub min_platform_version: Option<String>,
pub status: String,
pub download_count: i32,
pub rating_avg: f64,
pub rating_count: i32,
pub changelog: Option<String>,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
/// 市场条目详情响应(含完整信息)
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MarketEntryDetailResp {
#[serde(flatten)]
pub entry: MarketEntryResp,
/// 依赖提示(安装时检查 manifest.dependencies
pub dependency_warnings: Vec<String>,
}
/// 提交评分/评论请求
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SubmitReviewReq {
/// 评分 1-5
pub rating: i32,
/// 评论内容
pub review_text: Option<String>,
}
/// 评论响应
#[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<String>,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
}
// ─── 对账扫描 DTO ──────────────────────────────────────────────────
/// 对账报告
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ReconciliationReport {
/// 有效引用数
pub valid_count: i64,
/// 悬空引用数
pub dangling_count: i64,
/// 悬空引用详情
pub details: Vec<DanglingRef>,
}
/// 悬空引用详情
#[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,
}

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"

View File

@@ -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<String>,
pub author: Option<String>,
pub category: Option<String>,
pub tags: Option<serde_json::Value>,
pub icon_url: Option<String>,
pub screenshots: Option<serde_json::Value>,
#[serde(skip)]
pub wasm_binary: Vec<u8>,
#[serde(skip_serializing)]
pub manifest_toml: String,
pub wasm_hash: String,
pub min_platform_version: Option<String>,
pub status: String,
pub download_count: i32,
pub rating_avg: Decimal,
pub rating_count: i32,
pub changelog: Option<String>,
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<super::market_review::Entity> for Entity {
fn to() -> RelationDef {
Relation::MarketReview.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -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<String>,
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<super::market_entry::Entity> for Entity {
fn to() -> RelationDef {
Relation::MarketEntry.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,3 +1,5 @@
pub mod market_entry;
pub mod market_review;
pub mod plugin;
pub mod plugin_entity;
pub mod plugin_event_subscription;

View File

@@ -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<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path((plugin_id, entity)): Path<(Uuid, String)>,
Query(params): Query<ExportParams>,
) -> Result<Json<ApiResponse<Vec<serde_json::Value>>>, AppError>
) -> Result<axum::response::Response, AppError>
where
PluginState: FromRef<S>,
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<ReconciliationReport>)
),
tag = "Plugin Data",
)]
pub async fn reconcile_refs<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path(plugin_id): Path<Uuid>,
) -> Result<Json<ApiResponse<crate::data_dto::ReconciliationReport>>, AppError>
where
PluginState: FromRef<S>,
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)))
}

View File

@@ -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<PaginatedResponse<MarketEntryResp>>)
),
tag = "Plugin Market",
)]
pub async fn list_market_entries<S>(
State(_state): State<S>,
Query(params): Query<MarketListParams>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<PaginatedResponse<MarketEntryResp>>>, AppError>
where
PluginState: FromRef<S>,
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<MarketEntryDetailResp>)
),
tag = "Plugin Market",
)]
pub async fn get_market_entry<S>(
State(_state): State<S>,
Path(id): Path<Uuid>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<MarketEntryDetailResp>>, AppError>
where
PluginState: FromRef<S>,
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<crate::dto::PluginResp>)
),
tag = "Plugin Market",
)]
pub async fn install_from_market<S>(
State(_state): State<S>,
Path(id): Path<Uuid>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<crate::dto::PluginResp>>, AppError>
where
PluginState: FromRef<S>,
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<Vec<MarketReviewResp>>)
),
tag = "Plugin Market",
)]
pub async fn list_market_reviews<S>(
State(_state): State<S>,
Path(id): Path<Uuid>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<MarketReviewResp>>>, AppError>
where
PluginState: FromRef<S>,
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<MarketReviewResp>)
),
tag = "Plugin Market",
)]
pub async fn submit_market_review<S>(
State(_state): State<S>,
Path(id): Path<Uuid>,
Extension(ctx): Extension<TenantContext>,
Json(body): Json<SubmitReviewReq>,
) -> Result<Json<ApiResponse<MarketReviewResp>>, AppError>
where
PluginState: FromRef<S>,
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::<f64>() / 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),
})))
}

View File

@@ -1,2 +1,3 @@
pub mod data_handler;
pub mod market_handler;
pub mod plugin_handler;

View File

@@ -122,6 +122,11 @@ impl PluginModule {
.route(
"/plugins/{plugin_id}/{entity}/import",
post(crate::handler::data_handler::import_plugin_data::<S>),
)
// 对账扫描
.route(
"/plugins/{plugin_id}/reconcile",
post(crate::handler::data_handler::reconcile_refs::<S>),
);
// 实体注册表路由
@@ -131,6 +136,26 @@ impl PluginModule {
get(crate::handler::data_handler::list_public_entities::<S>),
);
admin_routes.merge(data_routes).merge(registry_routes)
// 市场路由
let market_routes = Router::new()
.route(
"/market/entries",
get(crate::handler::market_handler::list_market_entries::<S>),
)
.route(
"/market/entries/{id}",
get(crate::handler::market_handler::get_market_entry::<S>),
)
.route(
"/market/entries/{id}/install",
post(crate::handler::market_handler::install_from_market::<S>),
)
.route(
"/market/entries/{id}/reviews",
get(crate::handler::market_handler::list_market_reviews::<S>)
.post(crate::handler::market_handler::submit_market_review::<S>),
);
admin_routes.merge(data_routes).merge(registry_routes).merge(market_routes)
}
}