feat: initialize Nuanji (Warm Notes) project
- Base platform from base.git (ERP base: auth, core, config, message, workflow, plugin) - Created erp-diary module skeleton (lib.rs, dto.rs, error.rs, event.rs, state.rs) - Integrated erp-diary into workspace and erp-server - Added DiaryModule registration in main.rs - Added DiaryState FromRef in state.rs - Diary routes mounted (empty routes, ready for implementation) - Product design spec v1.2 preserved in docs/ - Implementation plan preserved in plans/ Cargo check: OK Cargo test: OK (78+ base tests passing)
This commit is contained in:
1121
crates/erp-plugin/src/handler/data_handler.rs
Normal file
1121
crates/erp-plugin/src/handler/data_handler.rs
Normal file
File diff suppressed because it is too large
Load Diff
386
crates/erp-plugin/src/handler/market_handler.rs
Normal file
386
crates/erp-plugin/src/handler/market_handler.rs
Normal file
@@ -0,0 +1,386 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::Json;
|
||||
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),
|
||||
})))
|
||||
}
|
||||
3
crates/erp-plugin/src/handler/mod.rs
Normal file
3
crates/erp-plugin/src/handler/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod data_handler;
|
||||
pub mod market_handler;
|
||||
pub mod plugin_handler;
|
||||
510
crates/erp-plugin/src/handler/plugin_handler.rs
Normal file
510
crates/erp-plugin/src/handler/plugin_handler.rs
Normal file
@@ -0,0 +1,510 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Multipart, Path, Query, State};
|
||||
use axum::response::Json;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
|
||||
|
||||
use crate::dto::{PluginHealthResp, PluginListParams, PluginResp, UpdatePluginConfigReq};
|
||||
use crate::service::PluginService;
|
||||
use crate::state::PluginState;
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/admin/plugins/upload",
|
||||
request_body(content_type = "multipart/form-data"),
|
||||
responses(
|
||||
(status = 200, description = "上传成功", body = ApiResponse<PluginResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// POST /api/v1/admin/plugins/upload — 上传插件 (multipart: wasm + manifest)
|
||||
pub async fn upload_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
|
||||
let mut wasm_binary: Option<Vec<u8>> = None;
|
||||
let mut manifest_toml: Option<String> = None;
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| AppError::Validation(format!("Multipart 解析失败: {}", e)))?
|
||||
{
|
||||
let name = field.name().unwrap_or("");
|
||||
match name {
|
||||
"wasm" => {
|
||||
wasm_binary = Some(
|
||||
field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::Validation(format!("读取 WASM 文件失败: {}", e)))?
|
||||
.to_vec(),
|
||||
);
|
||||
}
|
||||
"manifest" => {
|
||||
let bytes = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::Validation(format!("读取 Manifest 失败: {}", e)))?;
|
||||
let text = String::from_utf8(bytes.to_vec()).map_err(|e| {
|
||||
AppError::Validation(format!("Manifest 不是有效的 UTF-8: {}", e))
|
||||
})?;
|
||||
manifest_toml = Some(text);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let wasm = wasm_binary.ok_or_else(|| AppError::Validation("缺少 wasm 文件".to_string()))?;
|
||||
let manifest =
|
||||
manifest_toml.ok_or_else(|| AppError::Validation("缺少 manifest 文件".to_string()))?;
|
||||
|
||||
let result =
|
||||
PluginService::upload(ctx.tenant_id, ctx.user_id, wasm, &manifest, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/admin/plugins",
|
||||
params(PluginListParams),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<PluginResp>>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// GET /api/v1/admin/plugins — 列表
|
||||
pub async fn list_plugins<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<PluginListParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<PluginResp>>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
|
||||
let pagination = Pagination {
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
};
|
||||
|
||||
let (plugins, total) = PluginService::list(
|
||||
ctx.tenant_id,
|
||||
pagination.page.unwrap_or(1),
|
||||
pagination.page_size.unwrap_or(20),
|
||||
params.status.as_deref(),
|
||||
params.search.as_deref(),
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: plugins,
|
||||
total,
|
||||
page: pagination.page.unwrap_or(1),
|
||||
page_size: pagination.page_size.unwrap_or(20),
|
||||
total_pages: (total as f64 / pagination.page_size.unwrap_or(20) as f64).ceil() as u64,
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/admin/plugins/{id}",
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PluginResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// GET /api/v1/admin/plugins/{id} — 详情
|
||||
pub async fn get_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
let result = PluginService::get_by_id(id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/admin/plugins/{id}/schema",
|
||||
responses(
|
||||
(status = 200, description = "成功"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// GET /api/v1/admin/plugins/{id}/schema — 实体 schema
|
||||
pub async fn get_plugin_schema<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
let schema = PluginService::get_schema(id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(schema)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/admin/plugins/{id}/install",
|
||||
responses(
|
||||
(status = 200, description = "安装成功", body = ApiResponse<PluginResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// POST /api/v1/admin/plugins/{id}/install — 安装
|
||||
pub async fn install_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
let result = PluginService::install(id, ctx.tenant_id, ctx.user_id, &state.db, &state.engine)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Install failed");
|
||||
e
|
||||
})?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/admin/plugins/{id}/enable",
|
||||
responses(
|
||||
(status = 200, description = "启用成功", body = ApiResponse<PluginResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// POST /api/v1/admin/plugins/{id}/enable — 启用
|
||||
pub async fn enable_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
let result =
|
||||
PluginService::enable(id, ctx.tenant_id, ctx.user_id, &state.db, &state.engine).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/admin/plugins/{id}/disable",
|
||||
responses(
|
||||
(status = 200, description = "停用成功", body = ApiResponse<PluginResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// POST /api/v1/admin/plugins/{id}/disable — 停用
|
||||
pub async fn disable_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
let result =
|
||||
PluginService::disable(id, ctx.tenant_id, ctx.user_id, &state.db, &state.engine).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/admin/plugins/{id}/uninstall",
|
||||
responses(
|
||||
(status = 200, description = "卸载成功", body = ApiResponse<PluginResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// POST /api/v1/admin/plugins/{id}/uninstall — 卸载
|
||||
pub async fn uninstall_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
let result =
|
||||
PluginService::uninstall(id, ctx.tenant_id, ctx.user_id, &state.db, &state.engine).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/admin/plugins/{id}",
|
||||
responses(
|
||||
(status = 200, description = "清除成功"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// DELETE /api/v1/admin/plugins/{id} — 清除(软删除)
|
||||
pub async fn purge_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
PluginService::purge(id, ctx.tenant_id, ctx.user_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/admin/plugins/{id}/health",
|
||||
responses(
|
||||
(status = 200, description = "健康检查", body = ApiResponse<PluginHealthResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// GET /api/v1/admin/plugins/{id}/health — 健康检查
|
||||
pub async fn health_check_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PluginHealthResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
let result = PluginService::health_check(id, ctx.tenant_id, &state.db, &state.engine).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/admin/plugins/{id}/metrics",
|
||||
responses(
|
||||
(status = 200, description = "运行时指标", body = ApiResponse<serde_json::Value>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// GET /api/v1/admin/plugins/{id}/metrics — 运行时指标
|
||||
pub async fn get_plugin_metrics<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
|
||||
// 通过 plugin_id 找到 manifest_id,再查询 metrics
|
||||
let manifest_id =
|
||||
crate::data_service::resolve_manifest_id(id, ctx.tenant_id, &state.db).await?;
|
||||
let metrics = state.engine.get_metrics(&manifest_id).await?;
|
||||
|
||||
let avg_ms = if metrics.total_invocations > 0 {
|
||||
metrics.total_response_ms / metrics.total_invocations as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"plugin_id": manifest_id,
|
||||
"total_invocations": metrics.total_invocations,
|
||||
"error_count": metrics.error_count,
|
||||
"avg_response_ms": avg_ms,
|
||||
"last_error": metrics.last_error,
|
||||
"last_invocation_at": metrics.last_invocation_at,
|
||||
}))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/admin/plugins/{id}/config",
|
||||
request_body = UpdatePluginConfigReq,
|
||||
responses(
|
||||
(status = 200, description = "更新成功", body = ApiResponse<PluginResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// PUT /api/v1/admin/plugins/{id}/config — 更新配置
|
||||
pub async fn update_plugin_config<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdatePluginConfigReq>,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
let result = PluginService::update_config(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
req.config,
|
||||
req.version,
|
||||
&state.db,
|
||||
Some(&state.event_bus),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/admin/plugins/{id}/upgrade",
|
||||
request_body(content_type = "multipart/form-data"),
|
||||
responses(
|
||||
(status = 200, description = "升级成功", body = ApiResponse<PluginResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// POST /api/v1/admin/plugins/{id}/upgrade — 热更新插件
|
||||
///
|
||||
/// 上传新版本 WASM + manifest,对比 schema 变更,执行增量 DDL,
|
||||
/// 更新插件记录。失败时保持旧版本继续运行。
|
||||
pub async fn upgrade_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
|
||||
let mut wasm_binary: Option<Vec<u8>> = None;
|
||||
let mut manifest_toml: Option<String> = None;
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| AppError::Validation(format!("Multipart 解析失败: {}", e)))?
|
||||
{
|
||||
let name = field.name().unwrap_or("");
|
||||
match name {
|
||||
"wasm" => {
|
||||
wasm_binary = Some(
|
||||
field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::Validation(format!("读取 WASM 文件失败: {}", e)))?
|
||||
.to_vec(),
|
||||
);
|
||||
}
|
||||
"manifest" => {
|
||||
let bytes = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::Validation(format!("读取 Manifest 失败: {}", e)))?;
|
||||
manifest_toml = Some(String::from_utf8(bytes.to_vec()).map_err(|e| {
|
||||
AppError::Validation(format!("Manifest 不是有效的 UTF-8: {}", e))
|
||||
})?);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let wasm = wasm_binary.ok_or_else(|| AppError::Validation("缺少 wasm 文件".to_string()))?;
|
||||
let manifest =
|
||||
manifest_toml.ok_or_else(|| AppError::Validation("缺少 manifest 文件".to_string()))?;
|
||||
|
||||
let result = PluginService::upgrade(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
wasm,
|
||||
&manifest,
|
||||
&state.db,
|
||||
&state.engine,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/admin/plugins/{id}/validate",
|
||||
params(("id" = Uuid, Path, description = "插件 ID")),
|
||||
responses((status = 200, description = "安全验证报告")),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// GET /api/v1/admin/plugins/{id}/validate — 获取插件安全验证报告
|
||||
pub async fn validate_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<crate::plugin_validator::ValidationReport>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
|
||||
let model = crate::service::find_plugin_model(id, ctx.tenant_id, &state.db).await?;
|
||||
let manifest: crate::manifest::PluginManifest =
|
||||
serde_json::from_value(model.manifest_json.clone())
|
||||
.map_err(|e| AppError::Validation(format!("manifest 解析失败: {}", e)))?;
|
||||
|
||||
let report =
|
||||
crate::plugin_validator::validate_plugin_security(&manifest, model.wasm_binary.len())?;
|
||||
Ok(Json(ApiResponse::ok(report)))
|
||||
}
|
||||
Reference in New Issue
Block a user