feat(plugin): P2-P4 插件平台演进 — 通用服务 + 质量保障 + 市场
P2 平台通用服务: - manifest 扩展: settings/numbering/templates/trigger_events/importable/exportable 声明 - 插件配置 UI: PluginSettingsForm 自动表单 + 后端校验 + 详情抽屉 Settings 标签页 - 编号规则: Host API numbering-generate + PostgreSQL 序列 + manifest 绑定 - 触发事件: data_service create/update/delete 自动发布 DomainEvent - WIT 接口: 新增 numbering-generate/setting-get Host API P3 质量保障: - plugin_validator.rs: 安全扫描(WASM大小/实体数量/字段校验) + 复杂度评分 - 运行时监控指标: RuntimeMetrics (错误率/响应时间/Fuel/内存) - 性能基准: BenchmarkResult 阈值定义 - 上传时自动安全扫描 + /validate API 端点 P4 插件市场: - 数据库迁移: plugin_market_entries + plugin_market_reviews 表 - 前端 PluginMarket 页面: 分类浏览/搜索/详情/评分 - 路由注册: /plugins/market 测试: 269 全通过 (71 erp-plugin + 41 auth + 57 config + 34 core + 50 message + 16 workflow)
This commit is contained in:
@@ -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() {
|
||||
<Route path="/messages" element={<Messages />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/plugins/admin" element={<PluginAdmin />} />
|
||||
<Route path="/plugins/market" element={<PluginMarket />} />
|
||||
<Route path="/plugins/:pluginId/tabs/:pageLabel" element={<PluginTabsPage />} />
|
||||
<Route path="/plugins/:pluginId/tree/:entityName" element={<PluginTreePage />} />
|
||||
<Route path="/plugins/:pluginId/graph/:entityName" element={<PluginGraphPage />} />
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
232
apps/web/src/components/PluginSettingsForm.tsx
Normal file
232
apps/web/src/components/PluginSettingsForm.tsx
Normal file
@@ -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<string, unknown>;
|
||||
/** 插件版本(乐观锁) */
|
||||
recordVersion: number;
|
||||
/** 保存回调 */
|
||||
onSave: (config: Record<string, unknown>, version: number) => Promise<unknown>;
|
||||
/** 是否只读 */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
/** 根据 manifest settings 声明自动渲染配置表单 */
|
||||
const PluginSettingsForm: React.FC<PluginSettingsFormProps> = ({
|
||||
fields,
|
||||
values,
|
||||
recordVersion,
|
||||
onSave,
|
||||
readOnly = false,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
const merged: Record<string, unknown> = {};
|
||||
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<string, PluginSettingField[]>();
|
||||
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 = (
|
||||
<span>
|
||||
{field.display_name}
|
||||
{field.description && (
|
||||
<Tooltip title={field.description}>
|
||||
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#999' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
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 (
|
||||
<Form.Item
|
||||
key={field.name}
|
||||
name={field.name}
|
||||
label={label}
|
||||
rules={rules}
|
||||
valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'}
|
||||
>
|
||||
{widget}
|
||||
</Form.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const groupEntries = Array.from(grouped.entries());
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={initialValues}
|
||||
disabled={readOnly}
|
||||
>
|
||||
{groupEntries.map(([group, groupFields], gi) => (
|
||||
<React.Fragment key={group || `__default_${gi}`}>
|
||||
{group ? (
|
||||
<Divider orientation="left" orientationMargin={0}>
|
||||
<Text strong>{group}</Text>
|
||||
</Divider>
|
||||
) : null}
|
||||
{groupFields.map(renderField)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{!readOnly && (
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={saving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
保存配置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
function renderWidget(field: PluginSettingField, readOnly: boolean): React.ReactNode {
|
||||
switch (field.field_type) {
|
||||
case 'text':
|
||||
return <Input disabled={readOnly} placeholder={`请输入${field.display_name}`} />;
|
||||
case 'number': {
|
||||
const props: Record<string, unknown> = {
|
||||
disabled: readOnly,
|
||||
placeholder: `请输入${field.display_name}`,
|
||||
style: { width: '100%' },
|
||||
};
|
||||
if (field.range) {
|
||||
props.min = field.range[0];
|
||||
props.max = field.range[1];
|
||||
}
|
||||
return <InputNumber {...props} />;
|
||||
}
|
||||
case 'boolean':
|
||||
return <Switch disabled={readOnly} />;
|
||||
case 'select':
|
||||
return (
|
||||
<Select
|
||||
disabled={readOnly}
|
||||
placeholder={`请选择${field.display_name}`}
|
||||
options={(field.options ?? []).map((o) => {
|
||||
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 'multiselect':
|
||||
return (
|
||||
<Select
|
||||
mode="multiple"
|
||||
disabled={readOnly}
|
||||
placeholder={`请选择${field.display_name}`}
|
||||
options={(field.options ?? []).map((o) => {
|
||||
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 <Input type="color" disabled={readOnly} style={{ width: 80 }} />;
|
||||
case 'date':
|
||||
return <DatePicker disabled={readOnly} style={{ width: '100%' }} />;
|
||||
case 'datetime':
|
||||
return <DatePicker showTime disabled={readOnly} style={{ width: '100%' }} />;
|
||||
case 'json':
|
||||
return <Input.TextArea disabled={readOnly} rows={4} placeholder="JSON 格式" />;
|
||||
default:
|
||||
return <Input disabled={readOnly} />;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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<PluginStatus, { color: string; label: string }> = {
|
||||
uploaded: { color: '#64748B', label: '已上传' },
|
||||
@@ -53,6 +58,7 @@ export default function PluginAdmin() {
|
||||
const [manifestText, setManifestText] = useState('');
|
||||
const [wasmFile, setWasmFile] = useState<File | null>(null);
|
||||
const [detailPlugin, setDetailPlugin] = useState<PluginInfo | null>(null);
|
||||
const [schemaData, setSchemaData] = useState<PluginSchemaResponse | null>(null);
|
||||
const [healthDetail, setHealthDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(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,10 +319,19 @@ version = "0.1.0""
|
||||
onClose={() => {
|
||||
setDetailPlugin(null);
|
||||
setHealthDetail(null);
|
||||
setSchemaData(null);
|
||||
}}
|
||||
width={500}
|
||||
>
|
||||
{detailPlugin && (
|
||||
<Tabs
|
||||
defaultActiveKey="info"
|
||||
items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '基本信息',
|
||||
children: (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="ID">{detailPlugin.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="名称">{detailPlugin.name}</Descriptions.Item>
|
||||
@@ -321,12 +347,10 @@ version = "0.1.0""
|
||||
<Descriptions.Item label="启用时间">{detailPlugin.enabled_at || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="实体数量">{detailPlugin.entities.length}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button
|
||||
icon={<HeartOutlined />}
|
||||
onClick={() => detailPlugin && handleHealthCheck(detailPlugin.id)}
|
||||
onClick={() => handleHealthCheck(detailPlugin.id)}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
健康检查
|
||||
@@ -345,6 +369,40 @@ version = "0.1.0""
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
...(schemaData?.settings
|
||||
? [
|
||||
{
|
||||
key: 'settings',
|
||||
label: (
|
||||
<span>
|
||||
<SettingOutlined /> 配置
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<PluginSettingsForm
|
||||
fields={schemaData.settings.fields}
|
||||
values={detailPlugin.config as Record<string, unknown>}
|
||||
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'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
|
||||
271
apps/web/src/pages/PluginMarket.tsx
Normal file
271
apps/web/src/pages/PluginMarket.tsx
Normal file
@@ -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<string, string> = {
|
||||
'财务': '#059669',
|
||||
'CRM': '#2563EB',
|
||||
'进销存': '#9333EA',
|
||||
'生产': '#DC2626',
|
||||
'人力资源': '#D97706',
|
||||
'基础': '#64748B',
|
||||
};
|
||||
|
||||
export default function PluginMarket() {
|
||||
const [plugins, setPlugins] = useState<MarketPlugin[]>([]);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [detailVisible, setDetailVisible] = useState(false);
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<MarketPlugin | null>(null);
|
||||
const [installing, setInstalling] = useState<string | null>(null);
|
||||
|
||||
// 当前已安装的插件列表(用于标识已安装状态)
|
||||
const [installedIds, setInstalledIds] = useState<Set<string>>(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 (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>
|
||||
<AppstoreOutlined /> 插件市场
|
||||
</Title>
|
||||
<Text type="secondary">发现和安装行业插件,扩展 ERP 能力</Text>
|
||||
</div>
|
||||
|
||||
{/* 搜索和分类 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Space size="middle" wrap>
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索插件..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
style={{ width: 300 }}
|
||||
allowClear
|
||||
/>
|
||||
<Button
|
||||
type={selectedCategory === null ? 'primary' : 'default'}
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
{categories.map((cat) => (
|
||||
<Button
|
||||
key={cat}
|
||||
type={selectedCategory === cat ? 'primary' : 'default'}
|
||||
onClick={() => setSelectedCategory(selectedCategory === cat ? null : cat)}
|
||||
>
|
||||
{cat}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 插件卡片网格 */}
|
||||
{filteredPlugins.length === 0 ? (
|
||||
<Empty description="暂无可用插件" />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{filteredPlugins.map((plugin) => (
|
||||
<Col xs={24} sm={12} md={8} lg={6} key={plugin.id}>
|
||||
<Card
|
||||
hoverable
|
||||
onClick={() => showDetail(plugin)}
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text strong style={{ fontSize: 16 }}>{plugin.name}</Text>
|
||||
<Tag
|
||||
color={CATEGORY_COLORS[plugin.category ?? ''] ?? '#64748B'}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
{plugin.category}
|
||||
</Tag>
|
||||
</div>
|
||||
<Paragraph
|
||||
type="secondary"
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{ minHeight: 44, marginBottom: 8 }}
|
||||
>
|
||||
{plugin.description ?? '暂无描述'}
|
||||
</Paragraph>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space size="small">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>v{plugin.version}</Text>
|
||||
{plugin.author && <Text type="secondary" style={{ fontSize: 12 }}>{plugin.author}</Text>}
|
||||
</Space>
|
||||
<Tooltip title="评分">
|
||||
<Space size={2}>
|
||||
<StarOutlined style={{ color: '#faad14', fontSize: 12 }} />
|
||||
<Text style={{ fontSize: 12 }}>
|
||||
{plugin.rating_count > 0
|
||||
? plugin.rating_avg.toFixed(1)
|
||||
: '-'}
|
||||
</Text>
|
||||
</Space>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{installedIds.has(plugin.name) && (
|
||||
<Tag color="green" style={{ marginTop: 8 }}>已安装</Tag>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Modal
|
||||
title={selectedPlugin?.name}
|
||||
open={detailVisible}
|
||||
onCancel={() => setDetailVisible(false)}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
{selectedPlugin && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Tag color={CATEGORY_COLORS[selectedPlugin.category ?? ''] ?? '#64748B'}>
|
||||
{selectedPlugin.category}
|
||||
</Tag>
|
||||
<Text type="secondary">v{selectedPlugin.version}</Text>
|
||||
<Text type="secondary">by {selectedPlugin.author ?? '未知'}</Text>
|
||||
</Space>
|
||||
</div>
|
||||
<Paragraph>{selectedPlugin.description ?? '暂无描述'}</Paragraph>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Rate disabled value={Math.round(selectedPlugin.rating_avg)} />
|
||||
<Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
{selectedPlugin.rating_count} 评分
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{selectedPlugin.tags && selectedPlugin.tags.length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{selectedPlugin.tags.map((tag) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={installing === selectedPlugin.id}
|
||||
disabled={installedIds.has(selectedPlugin.name)}
|
||||
onClick={() => handleInstall(selectedPlugin)}
|
||||
block
|
||||
>
|
||||
{installedIds.has(selectedPlugin.name) ? '已安装' : '安装'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Vec<crate::manifest::PluginTriggerEvent>> {
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String, NumberingRule> {
|
||||
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<Box<dyn std::future::Future<Output = serde_json::Value> + 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(
|
||||
|
||||
@@ -32,6 +32,9 @@ pub enum PluginError {
|
||||
|
||||
#[error("权限不足: {0}")]
|
||||
PermissionDenied(String),
|
||||
|
||||
#[error("配置校验失败: {0}")]
|
||||
ValidationError(String),
|
||||
}
|
||||
|
||||
impl From<PluginError> for AppError {
|
||||
@@ -41,7 +44,8 @@ impl From<PluginError> 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()),
|
||||
}
|
||||
|
||||
@@ -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<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)))
|
||||
}
|
||||
|
||||
@@ -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_core::events::EventBus>,
|
||||
// 跨插件实体映射:"erp-crm.customer" → "plugin_erp_crm__customer"
|
||||
pub(crate) cross_plugin_entities: HashMap<String, String>,
|
||||
// 编号规则映射:"invoice" → "INV-{YEAR}-{SEQ:4}"
|
||||
pub(crate) numbering_rules: HashMap<String, NumberingRule>,
|
||||
// 插件配置值
|
||||
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<bool, String> {
|
||||
Ok(self.permissions.contains(&permission))
|
||||
}
|
||||
|
||||
fn numbering_generate(&mut self, rule_key: String) -> Result<String, String> {
|
||||
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<sea_orm::ExecResult, sea_orm::DbErr> = 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> = 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<Vec<u8>, 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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,6 +10,18 @@ pub struct PluginManifest {
|
||||
pub events: Option<PluginEvents>,
|
||||
pub ui: Option<PluginUi>,
|
||||
pub permissions: Option<Vec<PluginPermission>>,
|
||||
/// 插件配置项声明 — 平台自动生成配置页面
|
||||
#[serde(default)]
|
||||
pub settings: Option<PluginSettings>,
|
||||
/// 编号规则声明 — 绑定实体字段到自动编号
|
||||
#[serde(default)]
|
||||
pub numbering: Option<Vec<PluginNumbering>>,
|
||||
/// 打印模板声明
|
||||
#[serde(default)]
|
||||
pub templates: Option<Vec<PluginTemplate>>,
|
||||
/// 触发事件声明 — 数据 CRUD 时自动发布域事件
|
||||
#[serde(default)]
|
||||
pub trigger_events: Option<Vec<PluginTriggerEvent>>,
|
||||
}
|
||||
|
||||
/// 插件元数据
|
||||
@@ -49,6 +61,10 @@ pub struct PluginEntity {
|
||||
pub data_scope: Option<bool>, // 是否启用行级数据权限
|
||||
#[serde(default)]
|
||||
pub is_public: Option<bool>, // 是否可被其他插件引用
|
||||
#[serde(default)]
|
||||
pub importable: Option<bool>, // 是否支持数据导入
|
||||
#[serde(default)]
|
||||
pub exportable: Option<bool>, // 是否支持数据导出
|
||||
}
|
||||
|
||||
/// 字段校验规则
|
||||
@@ -319,6 +335,133 @@ pub struct PluginPermission {
|
||||
pub data_scope_levels: Option<Vec<String>>, // 支持的数据范围等级
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// P2 平台通用服务 — manifest 扩展
|
||||
// ============================================================
|
||||
|
||||
/// 插件配置项声明 — 平台根据此声明自动生成配置页面
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginSettings {
|
||||
pub fields: Vec<PluginSettingField>,
|
||||
}
|
||||
|
||||
/// 单个配置字段
|
||||
#[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_json::Value>,
|
||||
#[serde(default)]
|
||||
pub required: bool,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
/// select/multiselect 类型的选项列表
|
||||
#[serde(default)]
|
||||
pub options: Option<Vec<serde_json::Value>>,
|
||||
/// 数值范围 [min, max]
|
||||
#[serde(default)]
|
||||
pub range: Option<(f64, f64)>,
|
||||
/// 分组名称 — 同组的字段在 UI 上放在一起
|
||||
#[serde(default)]
|
||||
pub group: Option<String>,
|
||||
}
|
||||
|
||||
/// 配置字段类型
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
/// 内联 HTML 模板(与 template_file 二选一)
|
||||
#[serde(default)]
|
||||
pub template_html: Option<String>,
|
||||
}
|
||||
|
||||
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<PluginManifest> {
|
||||
let manifest: PluginManifest =
|
||||
@@ -361,6 +504,40 @@ pub fn parse_manifest(toml_str: &str) -> PluginResult<PluginManifest> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,10 @@ impl PluginModule {
|
||||
.route(
|
||||
"/admin/plugins/{id}/upgrade",
|
||||
post(crate::handler::plugin_handler::upgrade_plugin::<S>),
|
||||
)
|
||||
.route(
|
||||
"/admin/plugins/{id}/validate",
|
||||
get(crate::handler::plugin_handler::validate_plugin::<S>),
|
||||
);
|
||||
|
||||
// 插件数据 CRUD 路由
|
||||
|
||||
304
crates/erp-plugin/src/plugin_validator.rs
Normal file
304
crates/erp-plugin/src/plugin_validator.rs
Normal file
@@ -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<String>,
|
||||
pub warnings: Vec<String>,
|
||||
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<String>,
|
||||
pub last_error_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
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<ValidationReport> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<plugin::Model> {
|
||||
find_plugin(plugin_id, tenant_id, db).await
|
||||
}
|
||||
|
||||
/// 批量查询多插件的 entities,返回 plugin_id → Vec<PluginEntityResp> 映射。
|
||||
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<String, serde_json::Value>,
|
||||
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,
|
||||
|
||||
@@ -28,6 +28,12 @@ interface host-api {
|
||||
|
||||
/// 检查当前用户权限
|
||||
check-permission: func(permission: string) -> result<bool, string>;
|
||||
|
||||
/// 根据编号规则生成下一个编号(如 INV-2026-0001)
|
||||
numbering-generate: func(rule-key: string) -> result<string, string>;
|
||||
|
||||
/// 读取插件配置项
|
||||
setting-get: func(key: string) -> result<list<u8>, string>;
|
||||
}
|
||||
|
||||
/// 插件导出的 API(宿主调用这些函数)
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user