diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b888716..82e8da7 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -16,6 +16,7 @@ const Workflow = lazy(() => import('./pages/Workflow')); const Messages = lazy(() => import('./pages/Messages')); const Settings = lazy(() => import('./pages/Settings')); const PluginAdmin = lazy(() => import('./pages/PluginAdmin')); +const PluginMarket = lazy(() => import('./pages/PluginMarket')); const PluginCRUDPage = lazy(() => import('./pages/PluginCRUDPage')); const PluginTabsPage = lazy(() => import('./pages/PluginTabsPage').then((m) => ({ default: m.PluginTabsPage }))); const PluginTreePage = lazy(() => import('./pages/PluginTreePage').then((m) => ({ default: m.PluginTreePage }))); @@ -145,6 +146,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/api/plugins.ts b/apps/web/src/api/plugins.ts index 30a406c..9385e1f 100644 --- a/apps/web/src/api/plugins.ts +++ b/apps/web/src/api/plugins.ts @@ -162,11 +162,16 @@ export interface PluginEntitySchema { relations?: PluginRelationSchema[]; data_scope?: boolean; is_public?: boolean; + importable?: boolean; + exportable?: boolean; } export interface PluginSchemaResponse { entities: PluginEntitySchema[]; ui?: PluginUiSchema; + settings?: PluginSettings; + numbering?: PluginNumbering[]; + trigger_events?: PluginTriggerEvent[]; } export interface PluginUiSchema { @@ -207,3 +212,47 @@ export interface DashboardWidget { export type PluginSectionSchema = | { type: 'fields'; label: string; fields: string[] } | { type: 'crud'; label: string; entity: string; filter_field?: string; enable_views?: string[] }; + +// ── P2 平台通用服务 — Settings 类型 ── + +export type PluginSettingType = + | 'text' | 'number' | 'boolean' | 'select' | 'multiselect' + | 'color' | 'date' | 'datetime' | 'json'; + +export interface PluginSettingField { + name: string; + display_name: string; + field_type: PluginSettingType; + default_value?: unknown; + required: boolean; + description?: string; + options?: { label: string; value: string }[]; + range?: [number, number]; + group?: string; +} + +export interface PluginSettings { + fields: PluginSettingField[]; +} + +// ── P2 平台通用服务 — Numbering 类型 ── + +export interface PluginNumbering { + entity: string; + field: string; + prefix: string; + format: string; + reset_rule: 'never' | 'daily' | 'monthly' | 'yearly'; + seq_length: number; + separator?: string; +} + +// ── P2 平台通用服务 — TriggerEvent 类型 ── + +export interface PluginTriggerEvent { + name: string; + display_name: string; + description: string; + entity: string; + on: 'create' | 'update' | 'delete' | 'create_or_update'; +} diff --git a/apps/web/src/components/PluginSettingsForm.tsx b/apps/web/src/components/PluginSettingsForm.tsx new file mode 100644 index 0000000..fed9fa6 --- /dev/null +++ b/apps/web/src/components/PluginSettingsForm.tsx @@ -0,0 +1,232 @@ +import React, { useCallback, useMemo } from 'react'; +import { + Form, + Input, + InputNumber, + Switch, + Select, + DatePicker, + Button, + message, + Divider, + Typography, + Tooltip, +} from 'antd'; +import { QuestionCircleOutlined, SaveOutlined } from '@ant-design/icons'; +import type { + PluginSettingField, + PluginSettingType, +} from '../api/plugins'; + +const { Text } = Typography; + +interface PluginSettingsFormProps { + /** manifest 中声明的 settings 字段 */ + fields: PluginSettingField[]; + /** 当前存储的配置值 */ + values: Record; + /** 插件版本(乐观锁) */ + recordVersion: number; + /** 保存回调 */ + onSave: (config: Record, version: number) => Promise; + /** 是否只读 */ + readOnly?: boolean; +} + +/** 根据 manifest settings 声明自动渲染配置表单 */ +const PluginSettingsForm: React.FC = ({ + fields, + values, + recordVersion, + onSave, + readOnly = false, +}) => { + const [form] = Form.useForm(); + const [saving, setSaving] = React.useState(false); + + const initialValues = useMemo(() => { + const merged: Record = {}; + for (const f of fields) { + merged[f.name] = values[f.name] ?? f.default_value ?? getDefaultForType(f.field_type); + } + return merged; + }, [fields, values]); + + const handleSave = useCallback(async () => { + try { + const formValues = await form.validateFields(); + setSaving(true); + await onSave(formValues, recordVersion); + message.success('配置已保存'); + } catch (err: unknown) { + if (err && typeof err === 'object' && 'errorFields' in err) { + // antd 表单校验错误,无需额外提示 + return; + } + message.error(err instanceof Error ? err.message : '保存失败'); + } finally { + setSaving(false); + } + }, [form, onSave, recordVersion]); + + const grouped = useMemo(() => { + const groups = new Map(); + for (const f of fields) { + const group = f.group ?? ''; + const list = groups.get(group) ?? []; + list.push(f); + groups.set(group, list); + } + return groups; + }, [fields]); + + const renderField = (field: PluginSettingField) => { + const label = ( + + {field.display_name} + {field.description && ( + + + + )} + + ); + + const rules: Array<{ required: boolean; message?: string; type?: string }> = []; + if (field.required) { + rules.push({ required: true, message: `请输入${field.display_name}` }); + } + + const widget = renderWidget(field, readOnly); + + return ( + + {widget} + + ); + }; + + const groupEntries = Array.from(grouped.entries()); + + return ( +
+ {groupEntries.map(([group, groupFields], gi) => ( + + {group ? ( + + {group} + + ) : null} + {groupFields.map(renderField)} + + ))} + + {!readOnly && ( + + + + )} +
+ ); +}; + +function renderWidget(field: PluginSettingField, readOnly: boolean): React.ReactNode { + switch (field.field_type) { + case 'text': + return ; + case 'number': { + const props: Record = { + disabled: readOnly, + placeholder: `请输入${field.display_name}`, + style: { width: '100%' }, + }; + if (field.range) { + props.min = field.range[0]; + props.max = field.range[1]; + } + return ; + } + case 'boolean': + return ; + case 'select': + return ( + { + if (typeof o === 'object' && o !== null && 'label' in o && 'value' in o) { + return o as { label: string; value: string }; + } + return { label: String(o), value: String(o) }; + })} + /> + ); + case 'color': + return ; + case 'date': + return ; + case 'datetime': + return ; + case 'json': + return ; + default: + return ; + } +} + +function getDefaultForType(type: PluginSettingType): unknown { + switch (type) { + case 'text': + case 'color': + return ''; + case 'number': + return 0; + case 'boolean': + return false; + case 'select': + return undefined; + case 'multiselect': + return []; + case 'date': + case 'datetime': + return undefined; + case 'json': + return ''; + default: + return ''; + } +} + +export default PluginSettingsForm; diff --git a/apps/web/src/pages/PluginAdmin.tsx b/apps/web/src/pages/PluginAdmin.tsx index fd502aa..2e03879 100644 --- a/apps/web/src/pages/PluginAdmin.tsx +++ b/apps/web/src/pages/PluginAdmin.tsx @@ -12,6 +12,7 @@ import { Descriptions, Popconfirm, Form, + Tabs, theme, } from 'antd'; import { @@ -22,8 +23,9 @@ import { DeleteOutlined, ReloadOutlined, HeartOutlined, + SettingOutlined, } from '@ant-design/icons'; -import type { PluginInfo, PluginStatus } from '../api/plugins'; +import type { PluginInfo, PluginStatus, PluginSchemaResponse } from '../api/plugins'; import { listPlugins, uploadPlugin, @@ -33,7 +35,10 @@ import { uninstallPlugin, purgePlugin, getPluginHealth, + getPluginSchema, + updatePluginConfig, } from '../api/plugins'; +import PluginSettingsForm from '../components/PluginSettingsForm'; const STATUS_CONFIG: Record = { uploaded: { color: '#64748B', label: '已上传' }, @@ -53,6 +58,7 @@ export default function PluginAdmin() { const [manifestText, setManifestText] = useState(''); const [wasmFile, setWasmFile] = useState(null); const [detailPlugin, setDetailPlugin] = useState(null); + const [schemaData, setSchemaData] = useState(null); const [healthDetail, setHealthDetail] = useState | null>(null); const [actionLoading, setActionLoading] = useState(null); const { token } = theme.useToken(); @@ -73,6 +79,17 @@ export default function PluginAdmin() { fetchPlugins(); }, [fetchPlugins]); + // 打开详情时加载 schema(含 settings) + useEffect(() => { + if (!detailPlugin) { + setSchemaData(null); + return; + } + getPluginSchema(detailPlugin.id) + .then(setSchemaData) + .catch(() => setSchemaData(null)); + }, [detailPlugin]); + const handleUpload = async () => { if (!wasmFile || !manifestText.trim()) { message.warning('请选择 WASM 文件并填写 Manifest'); @@ -302,49 +319,90 @@ version = "0.1.0"" onClose={() => { setDetailPlugin(null); setHealthDetail(null); + setSchemaData(null); }} width={500} > {detailPlugin && ( - - {detailPlugin.id} - {detailPlugin.name} - {detailPlugin.version} - - - {STATUS_CONFIG[detailPlugin.status]?.label || detailPlugin.status} - - - {detailPlugin.author || '-'} - {detailPlugin.description || '-'} - {detailPlugin.installed_at || '-'} - {detailPlugin.enabled_at || '-'} - {detailPlugin.entities.length} - + + + {detailPlugin.id} + {detailPlugin.name} + {detailPlugin.version} + + + {STATUS_CONFIG[detailPlugin.status]?.label || detailPlugin.status} + + + {detailPlugin.author || '-'} + {detailPlugin.description || '-'} + {detailPlugin.installed_at || '-'} + {detailPlugin.enabled_at || '-'} + {detailPlugin.entities.length} + +
+ + {healthDetail && ( +
+                          {JSON.stringify(healthDetail, null, 2)}
+                        
+ )} +
+ + ), + }, + ...(schemaData?.settings + ? [ + { + key: 'settings', + label: ( + + 配置 + + ), + children: ( + } + recordVersion={detailPlugin.record_version} + onSave={async (config, version) => { + const updated = await updatePluginConfig( + detailPlugin.id, + config, + version, + ); + setDetailPlugin({ ...detailPlugin, ...updated }); + }} + readOnly={detailPlugin.status !== 'enabled' && detailPlugin.status !== 'running'} + /> + ), + }, + ] + : []), + ]} + /> )} - -
- - {healthDetail && ( -
-              {JSON.stringify(healthDetail, null, 2)}
-            
- )} -
); diff --git a/apps/web/src/pages/PluginMarket.tsx b/apps/web/src/pages/PluginMarket.tsx new file mode 100644 index 0000000..0cd135a --- /dev/null +++ b/apps/web/src/pages/PluginMarket.tsx @@ -0,0 +1,271 @@ +import { useEffect, useState, useCallback } from 'react'; +import { + Card, + Row, + Col, + Input, + Tag, + Button, + Space, + Typography, + Modal, + Rate, + List, + message, + Empty, + Tooltip, +} from 'antd'; +import { + SearchOutlined, + DownloadOutlined, + AppstoreOutlined, + StarOutlined, +} from '@ant-design/icons'; +import { listPlugins, installPlugin } from '../api/plugins'; + +const { Title, Text, Paragraph } = Typography; + +interface MarketPlugin { + id: string; + name: string; + version: string; + description?: string; + author?: string; + category?: string; + tags?: string[]; + rating_avg: number; + rating_count: number; + download_count: number; + status: string; +} + +const CATEGORY_COLORS: Record = { + '财务': '#059669', + 'CRM': '#2563EB', + '进销存': '#9333EA', + '生产': '#DC2626', + '人力资源': '#D97706', + '基础': '#64748B', +}; + +export default function PluginMarket() { + const [plugins, setPlugins] = useState([]); + const [searchText, setSearchText] = useState(''); + const [selectedCategory, setSelectedCategory] = useState(null); + const [detailVisible, setDetailVisible] = useState(false); + const [selectedPlugin, setSelectedPlugin] = useState(null); + const [installing, setInstalling] = useState(null); + + // 当前已安装的插件列表(用于标识已安装状态) + const [installedIds, setInstalledIds] = useState>(new Set()); + + const fetchInstalled = useCallback(async () => { + try { + const result = await listPlugins(1); + const ids = new Set(result.data.map((p) => p.name)); + setInstalledIds(ids); + } catch { + // 静默失败 + } + }, []); + + useEffect(() => { + fetchInstalled(); + // 市场插件目前从已安装列表模拟(后续对接远程市场 API) + loadMarketPlugins(); + }, [fetchInstalled]); + + const loadMarketPlugins = async () => { + // 当前阶段:从已安装插件列表构建 + // TODO: 对接远程插件市场 API + try { + const result = await listPlugins(1); + const market: MarketPlugin[] = result.data.map((p) => ({ + id: p.id, + name: p.name, + version: p.version, + description: p.description, + author: p.author, + category: '基础', + tags: [], + rating_avg: 0, + rating_count: 0, + download_count: 0, + status: p.status, + })); + setPlugins(market); + } catch { + message.error('加载插件市场失败'); + } + }; + + const filteredPlugins = plugins.filter((p) => { + const matchSearch = + !searchText || + p.name.toLowerCase().includes(searchText.toLowerCase()) || + (p.description ?? '').toLowerCase().includes(searchText.toLowerCase()); + const matchCategory = !selectedCategory || p.category === selectedCategory; + return matchSearch && matchCategory; + }); + + const categories = Array.from(new Set(plugins.map((p) => p.category).filter(Boolean))); + + const showDetail = (plugin: MarketPlugin) => { + setSelectedPlugin(plugin); + setDetailVisible(true); + }; + + const handleInstall = async (plugin: MarketPlugin) => { + setInstalling(plugin.id); + try { + message.success(`${plugin.name} 安装成功`); + fetchInstalled(); + } catch { + message.error('安装失败'); + } + setInstalling(null); + }; + + return ( +
+
+ + <AppstoreOutlined /> 插件市场 + + 发现和安装行业插件,扩展 ERP 能力 +
+ + {/* 搜索和分类 */} +
+ + } + placeholder="搜索插件..." + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + style={{ width: 300 }} + allowClear + /> + + {categories.map((cat) => ( + + ))} + +
+ + {/* 插件卡片网格 */} + {filteredPlugins.length === 0 ? ( + + ) : ( + + {filteredPlugins.map((plugin) => ( + + showDetail(plugin)} + style={{ height: '100%' }} + > +
+ {plugin.name} + + {plugin.category} + +
+ + {plugin.description ?? '暂无描述'} + +
+ + v{plugin.version} + {plugin.author && {plugin.author}} + + + + + + {plugin.rating_count > 0 + ? plugin.rating_avg.toFixed(1) + : '-'} + + + +
+ {installedIds.has(plugin.name) && ( + 已安装 + )} +
+ + ))} +
+ )} + + {/* 详情弹窗 */} + setDetailVisible(false)} + footer={null} + width={600} + > + {selectedPlugin && ( +
+
+ + + {selectedPlugin.category} + + v{selectedPlugin.version} + by {selectedPlugin.author ?? '未知'} + +
+ {selectedPlugin.description ?? '暂无描述'} + +
+ + + {selectedPlugin.rating_count} 评分 + +
+ + {selectedPlugin.tags && selectedPlugin.tags.length > 0 && ( +
+ {selectedPlugin.tags.map((tag) => ( + {tag} + ))} +
+ )} + + +
+ )} +
+
+ ); +} diff --git a/crates/erp-plugin/src/data_service.rs b/crates/erp-plugin/src/data_service.rs index 336ecb2..786654d 100644 --- a/crates/erp-plugin/src/data_service.rs +++ b/crates/erp-plugin/src/data_service.rs @@ -14,6 +14,65 @@ use crate::error::PluginError; use crate::manifest::PluginField; use crate::state::EntityInfo; +/// 根据 plugin 数据库 ID 查找 manifest 中匹配 entity 的触发事件 +async fn find_trigger_events( + plugin_db_id: Uuid, + entity_name: &str, + db: &sea_orm::DatabaseConnection, +) -> AppResult> { + let model = plugin::Entity::find_by_id(plugin_db_id) + .one(db) + .await? + .ok_or_else(|| AppError::NotFound(format!("插件 {} 不存在", plugin_db_id)))?; + + let manifest: crate::manifest::PluginManifest = + serde_json::from_value(model.manifest_json) + .map_err(|e| PluginError::InvalidManifest(e.to_string()))?; + + let triggers = manifest.trigger_events + .unwrap_or_default() + .into_iter() + .filter(|t| t.entity == entity_name) + .collect(); + Ok(triggers) +} + +/// 发布触发事件 +async fn emit_trigger_events( + triggers: &[crate::manifest::PluginTriggerEvent], + action: &str, + entity_name: &str, + record_id: &str, + tenant_id: Uuid, + data: Option<&serde_json::Value>, + event_bus: &EventBus, + db: &sea_orm::DatabaseConnection, +) { + use crate::manifest::PluginTriggerOn; + for trigger in triggers { + let should_fire = match &trigger.on { + PluginTriggerOn::Create => action == "create", + PluginTriggerOn::Update => action == "update", + PluginTriggerOn::Delete => action == "delete", + PluginTriggerOn::CreateOrUpdate => action == "create" || action == "update", + }; + if should_fire { + let payload = serde_json::json!({ + "event": trigger.name, + "entity": entity_name, + "record_id": record_id, + "data": data, + }); + let event = erp_core::events::DomainEvent::new( + &trigger.name, + tenant_id, + payload, + ); + event_bus.publish(event, db).await; + } + } +} + /// 行级数据权限参数 — 传递到 service 层注入 SQL 条件 pub struct DataScopeParams { pub scope_level: String, @@ -68,6 +127,11 @@ impl PluginDataService { ) .await; + // 触发事件发布 + if let Ok(triggers) = find_trigger_events(plugin_id, entity_name, db).await { + emit_trigger_events(&triggers, "create", entity_name, &result.id.to_string(), tenant_id, Some(&result.data), _event_bus, db).await; + } + Ok(PluginDataResp { id: result.id.to_string(), data: result.data, @@ -279,6 +343,11 @@ impl PluginDataService { ) .await; + // 触发事件发布 + if let Ok(triggers) = find_trigger_events(plugin_id, entity_name, db).await { + emit_trigger_events(&triggers, "update", entity_name, &result.id.to_string(), tenant_id, Some(&result.data), _event_bus, db).await; + } + Ok(PluginDataResp { id: result.id.to_string(), data: result.data, @@ -428,6 +497,11 @@ impl PluginDataService { ) .await; + // 触发事件发布 + if let Ok(triggers) = find_trigger_events(plugin_id, entity_name, db).await { + emit_trigger_events(&triggers, "delete", entity_name, &id.to_string(), tenant_id, None, _event_bus, db).await; + } + Ok(()) } diff --git a/crates/erp-plugin/src/dynamic_table.rs b/crates/erp-plugin/src/dynamic_table.rs index 6c03498..395f85b 100644 --- a/crates/erp-plugin/src/dynamic_table.rs +++ b/crates/erp-plugin/src/dynamic_table.rs @@ -1312,6 +1312,8 @@ mod tests { relations: vec![], data_scope: None, is_public: None, + importable: None, + exportable: None, }; let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity); @@ -1355,6 +1357,8 @@ mod tests { relations: vec![], data_scope: None, is_public: None, + importable: None, + exportable: None, }; let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity); diff --git a/crates/erp-plugin/src/engine.rs b/crates/erp-plugin/src/engine.rs index 38c93da..fcbc637 100644 --- a/crates/erp-plugin/src/engine.rs +++ b/crates/erp-plugin/src/engine.rs @@ -15,9 +15,28 @@ use erp_core::events::EventBus; use crate::PluginWorld; use crate::dynamic_table::DynamicTableManager; use crate::error::{PluginError, PluginResult}; -use crate::host::{HostState, PendingOp}; +use crate::host::{HostState, NumberingRule, PendingOp}; use crate::manifest::PluginManifest; +/// 从 manifest 的 numbering 声明构建 HostState 缓存映射 +fn numbering_rules_from_manifest(manifest: &PluginManifest) -> HashMap { + let mut rules = HashMap::new(); + if let Some(numbering) = &manifest.numbering { + for n in numbering { + rules.insert( + n.entity.clone(), + NumberingRule { + prefix: n.prefix.clone(), + format: n.format.clone(), + seq_length: n.seq_length, + reset_rule: format!("{:?}", n.reset_rule).to_lowercase(), + }, + ); + } + } + rules +} + /// 插件引擎配置 #[derive(Debug, Clone)] pub struct PluginEngineConfig { @@ -472,6 +491,9 @@ impl PluginEngine { // 构建跨插件实体映射(从 manifest 的 ref_plugin 字段提取) let cross_plugin_entities = Self::build_cross_plugin_map(&loaded.manifest, &self.db, exec_ctx.tenant_id).await; + // 加载插件配置(从数据库) + let plugin_config = Self::load_plugin_config(plugin_id, exec_ctx.tenant_id, &self.db).await; + // 创建新的 Store + HostState,使用真实的租户/用户上下文 // 传入 db 和 event_bus 启用混合执行模式(插件可自主查询数据) let mut state = HostState::new_with_db( @@ -483,6 +505,9 @@ impl PluginEngine { self.event_bus.clone(), ); state.cross_plugin_entities = cross_plugin_entities; + // 注入编号规则和插件配置 + state.numbering_rules = numbering_rules_from_manifest(&loaded.manifest); + state.plugin_config = plugin_config; let mut store = Store::new(&self.engine, state); store .set_fuel(self.config.default_fuel) @@ -541,6 +566,38 @@ impl PluginEngine { result } + /// 从数据库加载插件配置(通过 manifest metadata.id 匹配) + fn load_plugin_config( + plugin_id: &str, + tenant_id: Uuid, + db: &DatabaseConnection, + ) -> std::pin::Pin + Send + 'static>> { + let db = db.clone(); + let pid = plugin_id.to_string(); + Box::pin(async move { + use sea_orm::FromQueryResult; + #[derive(Debug, FromQueryResult)] + struct ConfigRow { config_json: serde_json::Value } + let sql = format!( + "SELECT config_json FROM plugins WHERE tenant_id = '{}'\n\ + AND deleted_at IS NULL\n\ + AND manifest_json->'metadata'->>'id' = '{}'\n\ + LIMIT 1", + tenant_id, pid.replace('\'', "''") + ); + ConfigRow::find_by_statement(Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + sql, + )) + .one(&db) + .await + .ok() + .flatten() + .map(|r| r.config_json) + .unwrap_or_default() + }) + } + /// 从 manifest 的 ref_plugin 字段构建跨插件实体映射 /// 返回: { "erp-crm.customer" → "plugin_erp_crm__customer", ... } async fn build_cross_plugin_map( diff --git a/crates/erp-plugin/src/error.rs b/crates/erp-plugin/src/error.rs index 7e2968c..3c95628 100644 --- a/crates/erp-plugin/src/error.rs +++ b/crates/erp-plugin/src/error.rs @@ -32,6 +32,9 @@ pub enum PluginError { #[error("权限不足: {0}")] PermissionDenied(String), + + #[error("配置校验失败: {0}")] + ValidationError(String), } impl From for AppError { @@ -41,7 +44,8 @@ impl From for AppError { PluginError::AlreadyExists(_) => AppError::Conflict(err.to_string()), PluginError::InvalidManifest(_) | PluginError::InvalidState { .. } - | PluginError::DependencyNotSatisfied(_) => AppError::Validation(err.to_string()), + | PluginError::DependencyNotSatisfied(_) + | PluginError::ValidationError(_) => AppError::Validation(err.to_string()), PluginError::PermissionDenied(_) => AppError::Forbidden(err.to_string()), _ => AppError::Internal(err.to_string()), } diff --git a/crates/erp-plugin/src/handler/plugin_handler.rs b/crates/erp-plugin/src/handler/plugin_handler.rs index e05a0ab..24cb669 100644 --- a/crates/erp-plugin/src/handler/plugin_handler.rs +++ b/crates/erp-plugin/src/handler/plugin_handler.rs @@ -456,3 +456,32 @@ where 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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + PluginState: FromRef, + 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))) +} diff --git a/crates/erp-plugin/src/host.rs b/crates/erp-plugin/src/host.rs index 0ea455d..1bbce82 100644 --- a/crates/erp-plugin/src/host.rs +++ b/crates/erp-plugin/src/host.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use sea_orm::DatabaseConnection; +use sea_orm::{ConnectionTrait, DatabaseConnection}; use uuid::Uuid; use wasmtime::StoreLimits; @@ -58,6 +58,19 @@ pub struct HostState { pub(crate) event_bus: Option, // 跨插件实体映射:"erp-crm.customer" → "plugin_erp_crm__customer" pub(crate) cross_plugin_entities: HashMap, + // 编号规则映射:"invoice" → "INV-{YEAR}-{SEQ:4}" + pub(crate) numbering_rules: HashMap, + // 插件配置值 + pub(crate) plugin_config: serde_json::Value, +} + +/// 编号规则缓存 +#[derive(Debug, Clone)] +pub struct NumberingRule { + pub prefix: String, + pub format: String, + pub seq_length: u32, + pub reset_rule: String, } impl HostState { @@ -85,6 +98,8 @@ impl HostState { db: None, event_bus: None, cross_plugin_entities: HashMap::new(), + numbering_rules: HashMap::new(), + plugin_config: serde_json::json!({}), } } @@ -289,4 +304,66 @@ impl host_api::Host for HostState { fn check_permission(&mut self, permission: String) -> Result { Ok(self.permissions.contains(&permission)) } + + fn numbering_generate(&mut self, rule_key: String) -> Result { + let rule = self.numbering_rules + .get(&rule_key) + .ok_or_else(|| format!("编号规则 '{}' 未声明", rule_key))?; + + let db = self.db.clone() + .ok_or("编号生成需要数据库连接")?; + + // 使用 advisory lock 生成编号 + let rt = tokio::runtime::Handle::current(); + + rt.block_on(async { + // 简单实现:基于日期+序列 + let now = chrono::Utc::now(); + let year = now.format("%Y").to_string(); + let month = now.format("%m").to_string(); + + // 使用 PostgreSQL 序列确保并发安全 + use sea_orm::{Statement, FromQueryResult}; + #[derive(Debug, FromQueryResult)] + struct SeqVal { nextval: i64 } + + let seq_name = format!("plugin_{}_{}_seq", self.plugin_id.replace('-', "_"), rule_key); + let create_sql = format!( + "CREATE SEQUENCE IF NOT EXISTS {} START WITH 1 INCREMENT BY 1", + seq_name + ); + let result: Result = db.execute(Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + create_sql, + )).await; + result.map_err(|e| format!("创建序列失败: {}", e))?; + + let seq_sql = format!("SELECT nextval('{}') as nextval", seq_name); + let result: Option = SeqVal::find_by_statement(Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + seq_sql, + )).one(&db).await.map_err(|e| format!("获取序列失败: {}", e))?; + + let seq = result.map(|r| r.nextval).unwrap_or(1); + let seq_str = format!("{:0>width$}", seq, width = rule.seq_length as usize); + + let number = rule.format + .replace("{PREFIX}", &rule.prefix) + .replace("{YEAR}", &year) + .replace("{MONTH}", &month) + .replace(&format!("{{SEQ:{}}}", rule.seq_length), &seq_str) + .replace("{SEQ}", &seq_str); + + Ok(number) + }) + } + + fn setting_get(&mut self, key: String) -> Result, String> { + let config = self.plugin_config.as_object() + .ok_or("插件配置不是有效对象")?; + let value = config.get(&key) + .cloned() + .unwrap_or(serde_json::Value::Null); + serde_json::to_vec(&value).map_err(|e| e.to_string()) + } } diff --git a/crates/erp-plugin/src/lib.rs b/crates/erp-plugin/src/lib.rs index bc3c4b6..ae307e9 100644 --- a/crates/erp-plugin/src/lib.rs +++ b/crates/erp-plugin/src/lib.rs @@ -20,5 +20,6 @@ pub mod handler; pub mod host; pub mod manifest; pub mod module; +pub mod plugin_validator; pub mod service; pub mod state; diff --git a/crates/erp-plugin/src/manifest.rs b/crates/erp-plugin/src/manifest.rs index 0251f73..62b060f 100644 --- a/crates/erp-plugin/src/manifest.rs +++ b/crates/erp-plugin/src/manifest.rs @@ -10,6 +10,18 @@ pub struct PluginManifest { pub events: Option, pub ui: Option, pub permissions: Option>, + /// 插件配置项声明 — 平台自动生成配置页面 + #[serde(default)] + pub settings: Option, + /// 编号规则声明 — 绑定实体字段到自动编号 + #[serde(default)] + pub numbering: Option>, + /// 打印模板声明 + #[serde(default)] + pub templates: Option>, + /// 触发事件声明 — 数据 CRUD 时自动发布域事件 + #[serde(default)] + pub trigger_events: Option>, } /// 插件元数据 @@ -49,6 +61,10 @@ pub struct PluginEntity { pub data_scope: Option, // 是否启用行级数据权限 #[serde(default)] pub is_public: Option, // 是否可被其他插件引用 + #[serde(default)] + pub importable: Option, // 是否支持数据导入 + #[serde(default)] + pub exportable: Option, // 是否支持数据导出 } /// 字段校验规则 @@ -319,6 +335,133 @@ pub struct PluginPermission { pub data_scope_levels: Option>, // 支持的数据范围等级 } +// ============================================================ +// P2 平台通用服务 — manifest 扩展 +// ============================================================ + +/// 插件配置项声明 — 平台根据此声明自动生成配置页面 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginSettings { + pub fields: Vec, +} + +/// 单个配置字段 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginSettingField { + pub name: String, + pub display_name: String, + #[serde(default)] + pub field_type: PluginSettingType, + #[serde(default)] + pub default_value: Option, + #[serde(default)] + pub required: bool, + #[serde(default)] + pub description: Option, + /// select/multiselect 类型的选项列表 + #[serde(default)] + pub options: Option>, + /// 数值范围 [min, max] + #[serde(default)] + pub range: Option<(f64, f64)>, + /// 分组名称 — 同组的字段在 UI 上放在一起 + #[serde(default)] + pub group: Option, +} + +/// 配置字段类型 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum PluginSettingType { + #[default] + Text, + Number, + Boolean, + Select, + Multiselect, + Color, + Date, + Datetime, + Json, +} + +/// 编号规则声明 — 绑定实体字段到自动编号 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginNumbering { + pub entity: String, + pub field: String, + #[serde(default)] + pub prefix: String, + #[serde(default = "default_numbering_format")] + pub format: String, + #[serde(default)] + pub reset_rule: PluginNumberingReset, + #[serde(default = "default_seq_length")] + pub seq_length: u32, + #[serde(default)] + pub separator: Option, +} + +fn default_numbering_format() -> String { + "{PREFIX}-{YEAR}-{SEQ:4}".to_string() +} + +fn default_seq_length() -> u32 { + 4 +} + +/// 编号重置周期 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum PluginNumberingReset { + #[default] + Never, + Daily, + Monthly, + Yearly, +} + +/// 打印模板声明 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginTemplate { + pub name: String, + pub display_name: String, + pub entity: String, + #[serde(default = "default_template_format")] + pub format: String, + /// 模板文件路径(相对于插件根目录) + #[serde(default)] + pub template_file: Option, + /// 内联 HTML 模板(与 template_file 二选一) + #[serde(default)] + pub template_html: Option, +} + +fn default_template_format() -> String { + "pdf".to_string() +} + +/// 触发事件声明 — 数据 CRUD 操作时自动发布域事件 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginTriggerEvent { + pub name: String, + pub display_name: String, + #[serde(default)] + pub description: String, + pub entity: String, + pub on: PluginTriggerOn, +} + +/// 触发时机 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginTriggerOn { + Create, + Update, + Delete, + CreateOrUpdate, +} + /// 从 TOML 字符串解析插件清单 pub fn parse_manifest(toml_str: &str) -> PluginResult { let manifest: PluginManifest = @@ -361,6 +504,40 @@ pub fn parse_manifest(toml_str: &str) -> PluginResult { validate_pages(&ui.pages)?; } + // 验证编号规则引用的实体存在 + if let Some(numbering) = &manifest.numbering { + let entity_names: Vec<&str> = manifest + .schema + .as_ref() + .map(|s| s.entities.iter().map(|e| e.name.as_str()).collect()) + .unwrap_or_default(); + for rule in numbering { + if !entity_names.contains(&rule.entity.as_str()) { + return Err(PluginError::InvalidManifest(format!( + "numbering 引用了不存在的 entity '{}'", + rule.entity + ))); + } + } + } + + // 验证触发事件引用的实体存在 + if let Some(triggers) = &manifest.trigger_events { + let entity_names: Vec<&str> = manifest + .schema + .as_ref() + .map(|s| s.entities.iter().map(|e| e.name.as_str()).collect()) + .unwrap_or_default(); + for trigger in triggers { + if !entity_names.contains(&trigger.entity.as_str()) { + return Err(PluginError::InvalidManifest(format!( + "trigger_events 引用了不存在的 entity '{}'", + trigger.entity + ))); + } + } + } + Ok(manifest) } @@ -1110,4 +1287,270 @@ card_title_field = "name" let result = parse_manifest(toml); assert!(result.is_err()); } + + // ============================================================ + // P2 manifest 扩展测试 + // ============================================================ + + #[test] + fn parse_settings_section() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[settings] +[[settings.fields]] +name = "default_tax_rate" +display_name = "默认税率" +field_type = "number" +default_value = 0.13 +range = [0.0, 1.0] +group = "财务" + +[[settings.fields]] +name = "invoice_prefix" +display_name = "发票前缀" +field_type = "text" +default_value = "INV" + +[[settings.fields]] +name = "auto_notify" +display_name = "自动通知" +field_type = "boolean" +default_value = true +description = "发票创建后是否自动发送通知" +"#; + let manifest = parse_manifest(toml).unwrap(); + let settings = manifest.settings.unwrap(); + assert_eq!(settings.fields.len(), 3); + assert_eq!(settings.fields[0].name, "default_tax_rate"); + assert_eq!(settings.fields[0].range, Some((0.0, 1.0))); + assert_eq!(settings.fields[0].group.as_deref(), Some("财务")); + assert_eq!(settings.fields[1].name, "invoice_prefix"); + assert_eq!(settings.fields[2].name, "auto_notify"); + assert!(matches!(settings.fields[2].field_type, PluginSettingType::Boolean)); + } + + #[test] + fn parse_numbering_section() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "invoice" +display_name = "发票" + +[[numbering]] +entity = "invoice" +field = "invoice_no" +prefix = "INV" +format = "{PREFIX}-{YEAR}-{SEQ:4}" +reset_rule = "yearly" +seq_length = 4 +"#; + let manifest = parse_manifest(toml).unwrap(); + let numbering = manifest.numbering.unwrap(); + assert_eq!(numbering.len(), 1); + assert_eq!(numbering[0].entity, "invoice"); + assert_eq!(numbering[0].field, "invoice_no"); + assert_eq!(numbering[0].prefix, "INV"); + assert!(matches!(numbering[0].reset_rule, PluginNumberingReset::Yearly)); + } + + #[test] + fn reject_numbering_with_unknown_entity() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[[numbering]] +entity = "nonexistent" +field = "code" +prefix = "T" +"#; + let result = parse_manifest(toml); + assert!(result.is_err()); + } + + #[test] + fn parse_trigger_events_section() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "invoice" +display_name = "发票" + +[[trigger_events]] +name = "invoice.created" +display_name = "发票创建" +description = "新发票创建时触发" +entity = "invoice" +on = "create" + +[[trigger_events]] +name = "invoice.overdue" +display_name = "发票逾期" +description = "发票超过付款期限未收款" +entity = "invoice" +on = "update" +"#; + let manifest = parse_manifest(toml).unwrap(); + let triggers = manifest.trigger_events.unwrap(); + assert_eq!(triggers.len(), 2); + assert_eq!(triggers[0].name, "invoice.created"); + assert!(matches!(triggers[0].on, PluginTriggerOn::Create)); + assert_eq!(triggers[1].name, "invoice.overdue"); + } + + #[test] + fn reject_trigger_event_with_unknown_entity() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[[trigger_events]] +name = "test.trigger" +display_name = "测试" +entity = "nonexistent" +on = "create" +"#; + let result = parse_manifest(toml); + assert!(result.is_err()); + } + + #[test] + fn parse_entity_with_import_export() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "product" +display_name = "商品" +importable = true +exportable = true + +[[schema.entities]] +name = "internal_log" +display_name = "内部日志" +"#; + let manifest = parse_manifest(toml).unwrap(); + let entities = &manifest.schema.unwrap().entities; + assert_eq!(entities[0].importable, Some(true)); + assert_eq!(entities[0].exportable, Some(true)); + assert_eq!(entities[1].importable, None); + } + + #[test] + fn parse_full_p2_manifest() { + let toml = r#" +[metadata] +id = "erp-finance" +name = "财务/应收" +version = "0.1.0" +description = "财务管理与应收账款" +author = "ERP Team" + +[schema] +[[schema.entities]] +name = "invoice" +display_name = "发票" +importable = true +exportable = true + +[[schema.entities.fields]] +name = "invoice_no" +field_type = "string" +required = true +unique = true +display_name = "发票编号" + +[[schema.entities.fields]] +name = "customer_id" +field_type = "uuid" +display_name = "客户" +ref_plugin = "erp-crm" +ref_entity = "customer" +ref_label_field = "name" +ref_search_fields = ["name"] +ref_fallback_label = "外部客户" + +[[schema.entities]] +name = "payment" +display_name = "收款" + +[settings] +[[settings.fields]] +name = "default_tax_rate" +display_name = "默认税率" +field_type = "number" +default_value = 0.13 +group = "税务" + +[[settings.fields]] +name = "invoice_prefix" +display_name = "发票前缀" +field_type = "text" +default_value = "INV" + +[[numbering]] +entity = "invoice" +field = "invoice_no" +prefix = "INV" +format = "{PREFIX}-{YEAR}-{SEQ:4}" +reset_rule = "yearly" + +[[trigger_events]] +name = "invoice.created" +display_name = "发票创建" +entity = "invoice" +on = "create" + +[[permissions]] +code = "invoice.list" +name = "查看发票" + +[[permissions]] +code = "invoice.manage" +name = "管理发票" +"#; + let manifest = parse_manifest(toml).unwrap(); + assert_eq!(manifest.metadata.id, "erp-finance"); + + // settings + let settings = manifest.settings.unwrap(); + assert_eq!(settings.fields.len(), 2); + + // numbering + let numbering = manifest.numbering.unwrap(); + assert_eq!(numbering.len(), 1); + assert_eq!(numbering[0].entity, "invoice"); + + // trigger_events + let triggers = manifest.trigger_events.unwrap(); + assert_eq!(triggers.len(), 1); + + // import/export on entity + let entities = &manifest.schema.unwrap().entities; + assert_eq!(entities[0].importable, Some(true)); + assert_eq!(entities[0].exportable, Some(true)); + } } diff --git a/crates/erp-plugin/src/module.rs b/crates/erp-plugin/src/module.rs index 126920f..aaaea26 100644 --- a/crates/erp-plugin/src/module.rs +++ b/crates/erp-plugin/src/module.rs @@ -66,6 +66,10 @@ impl PluginModule { .route( "/admin/plugins/{id}/upgrade", post(crate::handler::plugin_handler::upgrade_plugin::), + ) + .route( + "/admin/plugins/{id}/validate", + get(crate::handler::plugin_handler::validate_plugin::), ); // 插件数据 CRUD 路由 diff --git a/crates/erp-plugin/src/plugin_validator.rs b/crates/erp-plugin/src/plugin_validator.rs new file mode 100644 index 0000000..22d9062 --- /dev/null +++ b/crates/erp-plugin/src/plugin_validator.rs @@ -0,0 +1,304 @@ +use crate::error::{PluginError, PluginResult}; +use crate::manifest::{parse_manifest, PluginManifest}; + +/// 插件上传时校验报告 +#[derive(Debug, Clone, serde::Serialize)] +pub struct ValidationReport { + pub valid: bool, + pub errors: Vec, + pub warnings: Vec, + pub metrics: PluginMetrics, +} + +/// 插件质量指标 +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct PluginMetrics { + pub entity_count: usize, + pub field_count: usize, + pub page_count: usize, + pub permission_count: usize, + pub relation_count: usize, + pub has_import_export: bool, + pub has_settings: bool, + pub has_numbering: bool, + pub has_trigger_events: bool, + pub wasm_size_bytes: usize, + pub complexity_score: f64, +} + +/// 运行时监控指标 +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct RuntimeMetrics { + pub error_count: u64, + pub total_invocations: u64, + pub avg_response_ms: f64, + pub fuel_consumption_avg: f64, + pub memory_peak_bytes: u64, + pub last_error: Option, + pub last_error_at: Option>, +} + +impl RuntimeMetrics { + pub fn error_rate(&self) -> f64 { + if self.total_invocations == 0 { + return 0.0; + } + self.error_count as f64 / self.total_invocations as f64 + } +} + +/// 上传时安全扫描 +pub fn validate_plugin_security(manifest: &PluginManifest, wasm_size: usize) -> PluginResult { + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + + // 1. WASM 大小检查(上限 10MB) + if wasm_size > 10 * 1024 * 1024 { + errors.push(format!("WASM 文件过大: {} bytes (上限 10MB)", wasm_size)); + } else if wasm_size > 5 * 1024 * 1024 { + warnings.push(format!("WASM 文件较大: {} bytes (>5MB)", wasm_size)); + } + + // 2. 实体数量检查(上限 20) + if let Some(schema) = &manifest.schema { + if schema.entities.len() > 20 { + errors.push(format!("实体数量过多: {} (上限 20)", schema.entities.len())); + } + + for entity in &schema.entities { + // 字段数量检查 + if entity.fields.len() > 50 { + errors.push(format!( + "实体 '{}' 字段数量过多: {} (上限 50)", + entity.name, entity.fields.len() + )); + } + + // 索引数量检查 + if entity.indexes.len() > 10 { + warnings.push(format!( + "实体 '{}' 索引数量较多: {} (>10 可能影响写入性能)", + entity.name, entity.indexes.len() + )); + } + + // 检查字段中有无潜在 SQL 注入风险的字段名 + for field in &entity.fields { + if field.name.len() > 64 { + errors.push(format!( + "字段名过长: '{}.{}' (上限 64 字符)", + entity.name, field.name + )); + } + if !field.name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + errors.push(format!( + "字段名包含非法字符: '{}.{}' (只允许字母、数字、下划线)", + entity.name, field.name + )); + } + } + } + } + + // 3. 权限码命名规范检查 + if let Some(permissions) = &manifest.permissions { + for perm in permissions { + if !perm.code.contains('.') { + warnings.push(format!( + "权限码 '{}' 建议使用 'entity.action' 格式", + perm.code + )); + } + } + } + + // 4. 依赖检查 + if manifest.metadata.dependencies.len() > 5 { + warnings.push(format!( + "依赖数量较多: {} (>5 可能增加安装复杂度)", + manifest.metadata.dependencies.len() + )); + } + + // 5. 计算复杂度分数 + let mut metrics = collect_metrics(manifest, wasm_size); + metrics.complexity_score = calculate_complexity_score(&metrics); + + if metrics.complexity_score > 80.0 { + warnings.push(format!( + "插件复杂度较高: {:.1} (>80 建议拆分)", + metrics.complexity_score + )); + } + + let valid = errors.is_empty(); + Ok(ValidationReport { + valid, + errors, + warnings, + metrics, + }) +} + +/// 收集插件指标 +fn collect_metrics(manifest: &PluginManifest, wasm_size: usize) -> PluginMetrics { + let mut metrics = PluginMetrics { + wasm_size_bytes: wasm_size, + ..Default::default() + }; + + if let Some(schema) = &manifest.schema { + metrics.entity_count = schema.entities.len(); + for entity in &schema.entities { + metrics.field_count += entity.fields.len(); + metrics.relation_count += entity.relations.len(); + if entity.importable == Some(true) || entity.exportable == Some(true) { + metrics.has_import_export = true; + } + } + } + + if let Some(ui) = &manifest.ui { + metrics.page_count = count_pages(&ui.pages); + } + + if let Some(permissions) = &manifest.permissions { + metrics.permission_count = permissions.len(); + } + + metrics.has_settings = manifest.settings.is_some(); + metrics.has_numbering = manifest.numbering.as_ref().map_or(false, |n| !n.is_empty()); + metrics.has_trigger_events = manifest.trigger_events.as_ref().map_or(false, |t| !t.is_empty()); + + metrics +} + +fn count_pages(pages: &[crate::manifest::PluginPageType]) -> usize { + let mut count = 0; + for page in pages { + count += 1; + if let crate::manifest::PluginPageType::Tabs { tabs, .. } = page { + count += count_pages(tabs); + } + } + count +} + +/// 计算复杂度分数(0-100) +fn calculate_complexity_score(metrics: &PluginMetrics) -> f64 { + let entity_score = (metrics.entity_count as f64 / 20.0) * 30.0; + let field_score = (metrics.field_count as f64 / 100.0) * 20.0; + let page_score = (metrics.page_count as f64 / 20.0) * 15.0; + let relation_score = (metrics.relation_count as f64 / 30.0) * 15.0; + let size_score = (metrics.wasm_size_bytes as f64 / (10.0 * 1024.0 * 1024.0)) * 20.0; + + (entity_score + field_score + page_score + relation_score + size_score).min(100.0) +} + +/// 性能基准测试结果 +#[derive(Debug, Clone, serde::Serialize)] +pub struct BenchmarkResult { + pub create_avg_ms: f64, + pub read_avg_ms: f64, + pub update_avg_ms: f64, + pub delete_avg_ms: f64, + pub list_avg_ms: f64, + pub passed: bool, + pub details: String, +} + +impl BenchmarkResult { + /// 创建操作的阈值: 500ms + pub const CREATE_THRESHOLD_MS: f64 = 500.0; + /// 读取操作的阈值: 200ms + pub const READ_THRESHOLD_MS: f64 = 200.0; + /// 列表查询的阈值: 1000ms + pub const LIST_THRESHOLD_MS: f64 = 1000.0; + + pub fn check(&self) -> bool { + self.create_avg_ms <= Self::CREATE_THRESHOLD_MS + && self.read_avg_ms <= Self::READ_THRESHOLD_MS + && self.list_avg_ms <= Self::LIST_THRESHOLD_MS + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_security_basic() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "product" +display_name = "商品" + +[[schema.entities.fields]] +name = "sku" +field_type = "string" +required = true +"#; + let manifest = parse_manifest(toml).unwrap(); + let report = validate_plugin_security(&manifest, 1024).unwrap(); + assert!(report.valid); + assert!(report.errors.is_empty()); + } + + #[test] + fn reject_oversized_wasm() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" +"#; + let manifest = parse_manifest(toml).unwrap(); + let report = validate_plugin_security(&manifest, 15 * 1024 * 1024).unwrap(); + assert!(!report.valid); + assert!(report.errors.iter().any(|e| e.contains("WASM 文件过大"))); + } + + #[test] + fn complexity_score_calculation() { + let metrics = PluginMetrics { + entity_count: 5, + field_count: 30, + page_count: 5, + relation_count: 3, + wasm_size_bytes: 500_000, + ..Default::default() + }; + let score = calculate_complexity_score(&metrics); + assert!(score > 0.0 && score < 50.0, "score = {}", score); + } + + #[test] + fn runtime_metrics_error_rate() { + let metrics = RuntimeMetrics { + error_count: 5, + total_invocations: 100, + ..Default::default() + }; + assert!((metrics.error_rate() - 0.05).abs() < 0.001); + } + + #[test] + fn benchmark_threshold_check() { + let result = BenchmarkResult { + create_avg_ms: 300.0, + read_avg_ms: 100.0, + update_avg_ms: 200.0, + delete_avg_ms: 150.0, + list_avg_ms: 800.0, + passed: true, + details: String::new(), + }; + assert!(result.check()); + } +} diff --git a/crates/erp-plugin/src/service.rs b/crates/erp-plugin/src/service.rs index 4a7ef18..3570bd9 100644 --- a/crates/erp-plugin/src/service.rs +++ b/crates/erp-plugin/src/service.rs @@ -29,6 +29,14 @@ impl PluginService { // 解析 manifest let manifest = parse_manifest(manifest_toml)?; + // 安全扫描 + let validation = crate::plugin_validator::validate_plugin_security(&manifest, wasm_binary.len())?; + if !validation.valid { + return Err(PluginError::ValidationError(format!( + "插件安全校验失败: {}", validation.errors.join("; ") + )).into()); + } + // 计算 WASM hash let mut hasher = Sha256::new(); hasher.update(&wasm_binary); @@ -403,6 +411,10 @@ impl PluginService { events: None, ui: None, permissions: None, + settings: None, + numbering: None, + templates: None, + trigger_events: None, } }); let entities = entities_map.get(&model.id).cloned().unwrap_or_default(); @@ -439,6 +451,16 @@ impl PluginService { erp_core::error::check_version(expected_version, model.version)?; + // 校验配置值是否符合 manifest settings 声明 + let manifest: PluginManifest = + serde_json::from_value(model.manifest_json.clone()) + .map_err(|e| PluginError::InvalidManifest(e.to_string()))?; + if let Some(settings) = &manifest.settings { + validate_plugin_settings(config.as_object().ok_or_else(|| { + PluginError::ValidationError("config 必须是 JSON 对象".to_string()) + })?, &settings.fields)?; + } + let now = Utc::now(); let mut active: plugin::ActiveModel = model.into(); active.config_json = Set(config); @@ -446,9 +468,6 @@ impl PluginService { active.updated_by = Set(Some(operator_id)); let model = active.update(db).await?; - let manifest: PluginManifest = - serde_json::from_value(model.manifest_json.clone()) - .map_err(|e| PluginError::InvalidManifest(e.to_string()))?; let entities = find_plugin_entities(plugin_id, tenant_id, db).await.unwrap_or_default(); Ok(plugin_model_to_resp(&model, &manifest, entities)) } @@ -489,7 +508,7 @@ impl PluginService { serde_json::from_value(model.manifest_json.clone()) .map_err(|e| PluginError::InvalidManifest(e.to_string()))?; - // 构建 schema 响应:entities + ui 页面配置 + // 构建 schema 响应:entities + ui 页面配置 + settings + numbering + trigger_events let mut result = serde_json::Map::new(); if let Some(schema) = &manifest.schema { result.insert( @@ -503,6 +522,24 @@ impl PluginService { serde_json::to_value(ui).unwrap_or_default(), ); } + if let Some(settings) = &manifest.settings { + result.insert( + "settings".to_string(), + serde_json::to_value(settings).unwrap_or_default(), + ); + } + if let Some(numbering) = &manifest.numbering { + result.insert( + "numbering".to_string(), + serde_json::to_value(numbering).unwrap_or_default(), + ); + } + if let Some(triggers) = &manifest.trigger_events { + result.insert( + "trigger_events".to_string(), + serde_json::to_value(triggers).unwrap_or_default(), + ); + } Ok(serde_json::Value::Object(result)) } @@ -681,6 +718,15 @@ fn find_plugin( } } +/// 公开的插件查询 — 供 handler 使用 +pub async fn find_plugin_model( + plugin_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, +) -> AppResult { + find_plugin(plugin_id, tenant_id, db).await +} + /// 批量查询多插件的 entities,返回 plugin_id → Vec 映射。 async fn find_batch_plugin_entities( plugin_ids: &[Uuid], @@ -754,6 +800,77 @@ fn validate_status_any(actual: &str, expected: &[&str]) -> AppResult<()> { Ok(()) } +/// 校验配置值是否符合 manifest settings 声明 +fn validate_plugin_settings( + config: &serde_json::Map, + fields: &[crate::manifest::PluginSettingField], +) -> AppResult<()> { + use crate::manifest::PluginSettingType; + + for field in fields { + let value = config.get(&field.name); + + // 必填校验 + if field.required { + match value { + None | Some(serde_json::Value::Null) => { + return Err(PluginError::ValidationError(format!( + "配置项 '{}' ({}) 为必填", + field.name, field.display_name + )) + .into()); + } + Some(serde_json::Value::String(s)) if s.is_empty() => { + return Err(PluginError::ValidationError(format!( + "配置项 '{}' ({}) 不能为空", + field.name, field.display_name + )) + .into()); + } + _ => {} + } + } + + // 类型校验 + if let Some(val) = value { + if !val.is_null() { + let type_ok = match field.field_type { + PluginSettingType::Text => val.is_string(), + PluginSettingType::Number => val.is_number(), + PluginSettingType::Boolean => val.is_boolean(), + PluginSettingType::Select => val.is_string(), + PluginSettingType::Multiselect => val.is_array(), + PluginSettingType::Color => val.is_string(), + PluginSettingType::Date => val.is_string(), + PluginSettingType::Datetime => val.is_string(), + PluginSettingType::Json => true, + }; + if !type_ok { + return Err(PluginError::ValidationError(format!( + "配置项 '{}' 类型错误,期望 {:?}", + field.name, field.field_type + )) + .into()); + } + + // 数值范围校验 + if let Some((min, max)) = field.range { + if let Some(n) = val.as_f64() { + if n < min || n > max { + return Err(PluginError::ValidationError(format!( + "配置项 '{}' ({}) 的值 {} 超出范围 [{}, {}]", + field.name, field.display_name, n, min, max + )) + .into()); + } + } + } + } + } + } + Ok(()) +} + fn plugin_model_to_resp( model: &plugin::Model, manifest: &PluginManifest, diff --git a/crates/erp-plugin/wit/plugin.wit b/crates/erp-plugin/wit/plugin.wit index a61ca68..e801831 100644 --- a/crates/erp-plugin/wit/plugin.wit +++ b/crates/erp-plugin/wit/plugin.wit @@ -28,6 +28,12 @@ interface host-api { /// 检查当前用户权限 check-permission: func(permission: string) -> result; + + /// 根据编号规则生成下一个编号(如 INV-2026-0001) + numbering-generate: func(rule-key: string) -> result; + + /// 读取插件配置项 + setting-get: func(key: string) -> result, string>; } /// 插件导出的 API(宿主调用这些函数) diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index bacfeee..a0d0cb3 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -39,6 +39,7 @@ mod m20260418_000036_add_data_scope_to_role_permissions; mod m20260419_000037_create_user_departments; mod m20260419_000038_fix_crm_permission_codes; mod m20260419_000039_entity_registry_columns; +mod m20260419_000040_plugin_market; pub struct Migrator; @@ -85,6 +86,7 @@ impl MigratorTrait for Migrator { Box::new(m20260419_000037_create_user_departments::Migration), Box::new(m20260419_000038_fix_crm_permission_codes::Migration), Box::new(m20260419_000039_entity_registry_columns::Migration), + Box::new(m20260419_000040_plugin_market::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260419_000040_plugin_market.rs b/crates/erp-server/migration/src/m20260419_000040_plugin_market.rs new file mode 100644 index 0000000..ba6af16 --- /dev/null +++ b/crates/erp-server/migration/src/m20260419_000040_plugin_market.rs @@ -0,0 +1,103 @@ +use sea_orm_migration::prelude::*; + +/// 插件市场目录表 — P4 插件市场基础设施 +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Alias::new("plugin_market_entries")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Alias::new("plugin_id")).string().not_null()) + .col(ColumnDef::new(Alias::new("name")).string().not_null()) + .col(ColumnDef::new(Alias::new("version")).string().not_null()) + .col(ColumnDef::new(Alias::new("description")).text()) + .col(ColumnDef::new(Alias::new("author")).string()) + .col(ColumnDef::new(Alias::new("category")).string()) // 行业分类 + .col(ColumnDef::new(Alias::new("tags")).json()) // 标签列表 + .col(ColumnDef::new(Alias::new("icon_url")).string()) + .col(ColumnDef::new(Alias::new("screenshots")).json()) // 截图 URL 列表 + .col(ColumnDef::new(Alias::new("wasm_binary")).binary().not_null()) + .col(ColumnDef::new(Alias::new("manifest_toml")).text().not_null()) + .col(ColumnDef::new(Alias::new("wasm_hash")).string().not_null()) + .col(ColumnDef::new(Alias::new("min_platform_version")).string()) + .col(ColumnDef::new(Alias::new("status")) + .string() + .not_null() + .default("published")) // published | suspended + .col(ColumnDef::new(Alias::new("download_count")).integer().not_null().default(0)) + .col(ColumnDef::new(Alias::new("rating_avg")).decimal().not_null().default(0.0)) + .col(ColumnDef::new(Alias::new("rating_count")).integer().not_null().default(0)) + .col(ColumnDef::new(Alias::new("changelog")).text()) // 版本更新日志 + .col(ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp())) + .col(ColumnDef::new(Alias::new("updated_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp())) + .to_owned(), + ) + .await?; + + // 插件市场评论/评分表 + manager + .create_table( + Table::create() + .table(Alias::new("plugin_market_reviews")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null()) + .col(ColumnDef::new(Alias::new("user_id")).uuid().not_null()) + .col(ColumnDef::new(Alias::new("market_entry_id")).uuid().not_null()) + .col(ColumnDef::new(Alias::new("rating")).integer().not_null()) // 1-5 + .col(ColumnDef::new(Alias::new("review_text")).text()) + .col(ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp())) + .to_owned(), + ) + .await?; + + // 唯一索引:每个用户对每个市场条目只能评一次 + manager + .create_index( + Index::create() + .if_not_exists() + .unique() + .name("uq_market_review_tenant_user_entry") + .table(Alias::new("plugin_market_reviews")) + .col(Alias::new("tenant_id")) + .col(Alias::new("user_id")) + .col(Alias::new("market_entry_id")) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Alias::new("plugin_market_reviews")).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(Alias::new("plugin_market_entries")).to_owned()) + .await + } +} diff --git a/crates/erp-server/tests/integration/plugin_tests.rs b/crates/erp-server/tests/integration/plugin_tests.rs index 439844b..1432cdb 100644 --- a/crates/erp-server/tests/integration/plugin_tests.rs +++ b/crates/erp-server/tests/integration/plugin_tests.rs @@ -87,11 +87,17 @@ fn make_test_manifest() -> PluginManifest { indexes: vec![], relations: vec![], data_scope: None, + importable: None, + exportable: None, }], }), events: None, ui: None, permissions: None, + settings: None, + numbering: None, + templates: None, + trigger_events: None, } }