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 端点
370 lines
12 KiB
Rust
370 lines
12 KiB
Rust
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),
|
|
})))
|
|
}
|