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 Messages = lazy(() => import('./pages/Messages'));
|
||||||
const Settings = lazy(() => import('./pages/Settings'));
|
const Settings = lazy(() => import('./pages/Settings'));
|
||||||
const PluginAdmin = lazy(() => import('./pages/PluginAdmin'));
|
const PluginAdmin = lazy(() => import('./pages/PluginAdmin'));
|
||||||
|
const PluginMarket = lazy(() => import('./pages/PluginMarket'));
|
||||||
const PluginCRUDPage = lazy(() => import('./pages/PluginCRUDPage'));
|
const PluginCRUDPage = lazy(() => import('./pages/PluginCRUDPage'));
|
||||||
const PluginTabsPage = lazy(() => import('./pages/PluginTabsPage').then((m) => ({ default: m.PluginTabsPage })));
|
const PluginTabsPage = lazy(() => import('./pages/PluginTabsPage').then((m) => ({ default: m.PluginTabsPage })));
|
||||||
const PluginTreePage = lazy(() => import('./pages/PluginTreePage').then((m) => ({ default: m.PluginTreePage })));
|
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="/messages" element={<Messages />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/plugins/admin" element={<PluginAdmin />} />
|
<Route path="/plugins/admin" element={<PluginAdmin />} />
|
||||||
|
<Route path="/plugins/market" element={<PluginMarket />} />
|
||||||
<Route path="/plugins/:pluginId/tabs/:pageLabel" element={<PluginTabsPage />} />
|
<Route path="/plugins/:pluginId/tabs/:pageLabel" element={<PluginTabsPage />} />
|
||||||
<Route path="/plugins/:pluginId/tree/:entityName" element={<PluginTreePage />} />
|
<Route path="/plugins/:pluginId/tree/:entityName" element={<PluginTreePage />} />
|
||||||
<Route path="/plugins/:pluginId/graph/:entityName" element={<PluginGraphPage />} />
|
<Route path="/plugins/:pluginId/graph/:entityName" element={<PluginGraphPage />} />
|
||||||
|
|||||||
@@ -162,11 +162,16 @@ export interface PluginEntitySchema {
|
|||||||
relations?: PluginRelationSchema[];
|
relations?: PluginRelationSchema[];
|
||||||
data_scope?: boolean;
|
data_scope?: boolean;
|
||||||
is_public?: boolean;
|
is_public?: boolean;
|
||||||
|
importable?: boolean;
|
||||||
|
exportable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginSchemaResponse {
|
export interface PluginSchemaResponse {
|
||||||
entities: PluginEntitySchema[];
|
entities: PluginEntitySchema[];
|
||||||
ui?: PluginUiSchema;
|
ui?: PluginUiSchema;
|
||||||
|
settings?: PluginSettings;
|
||||||
|
numbering?: PluginNumbering[];
|
||||||
|
trigger_events?: PluginTriggerEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginUiSchema {
|
export interface PluginUiSchema {
|
||||||
@@ -207,3 +212,47 @@ export interface DashboardWidget {
|
|||||||
export type PluginSectionSchema =
|
export type PluginSectionSchema =
|
||||||
| { type: 'fields'; label: string; fields: string[] }
|
| { type: 'fields'; label: string; fields: string[] }
|
||||||
| { type: 'crud'; label: string; entity: string; filter_field?: string; enable_views?: 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,
|
Descriptions,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Form,
|
Form,
|
||||||
|
Tabs,
|
||||||
theme,
|
theme,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
@@ -22,8 +23,9 @@ import {
|
|||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
HeartOutlined,
|
HeartOutlined,
|
||||||
|
SettingOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { PluginInfo, PluginStatus } from '../api/plugins';
|
import type { PluginInfo, PluginStatus, PluginSchemaResponse } from '../api/plugins';
|
||||||
import {
|
import {
|
||||||
listPlugins,
|
listPlugins,
|
||||||
uploadPlugin,
|
uploadPlugin,
|
||||||
@@ -33,7 +35,10 @@ import {
|
|||||||
uninstallPlugin,
|
uninstallPlugin,
|
||||||
purgePlugin,
|
purgePlugin,
|
||||||
getPluginHealth,
|
getPluginHealth,
|
||||||
|
getPluginSchema,
|
||||||
|
updatePluginConfig,
|
||||||
} from '../api/plugins';
|
} from '../api/plugins';
|
||||||
|
import PluginSettingsForm from '../components/PluginSettingsForm';
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<PluginStatus, { color: string; label: string }> = {
|
const STATUS_CONFIG: Record<PluginStatus, { color: string; label: string }> = {
|
||||||
uploaded: { color: '#64748B', label: '已上传' },
|
uploaded: { color: '#64748B', label: '已上传' },
|
||||||
@@ -53,6 +58,7 @@ export default function PluginAdmin() {
|
|||||||
const [manifestText, setManifestText] = useState('');
|
const [manifestText, setManifestText] = useState('');
|
||||||
const [wasmFile, setWasmFile] = useState<File | null>(null);
|
const [wasmFile, setWasmFile] = useState<File | null>(null);
|
||||||
const [detailPlugin, setDetailPlugin] = useState<PluginInfo | 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 [healthDetail, setHealthDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
@@ -73,6 +79,17 @@ export default function PluginAdmin() {
|
|||||||
fetchPlugins();
|
fetchPlugins();
|
||||||
}, [fetchPlugins]);
|
}, [fetchPlugins]);
|
||||||
|
|
||||||
|
// 打开详情时加载 schema(含 settings)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detailPlugin) {
|
||||||
|
setSchemaData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
getPluginSchema(detailPlugin.id)
|
||||||
|
.then(setSchemaData)
|
||||||
|
.catch(() => setSchemaData(null));
|
||||||
|
}, [detailPlugin]);
|
||||||
|
|
||||||
const handleUpload = async () => {
|
const handleUpload = async () => {
|
||||||
if (!wasmFile || !manifestText.trim()) {
|
if (!wasmFile || !manifestText.trim()) {
|
||||||
message.warning('请选择 WASM 文件并填写 Manifest');
|
message.warning('请选择 WASM 文件并填写 Manifest');
|
||||||
@@ -302,10 +319,19 @@ version = "0.1.0""
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
setDetailPlugin(null);
|
setDetailPlugin(null);
|
||||||
setHealthDetail(null);
|
setHealthDetail(null);
|
||||||
|
setSchemaData(null);
|
||||||
}}
|
}}
|
||||||
width={500}
|
width={500}
|
||||||
>
|
>
|
||||||
{detailPlugin && (
|
{detailPlugin && (
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="info"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'info',
|
||||||
|
label: '基本信息',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
<Descriptions column={1} bordered size="small">
|
<Descriptions column={1} bordered size="small">
|
||||||
<Descriptions.Item label="ID">{detailPlugin.id}</Descriptions.Item>
|
<Descriptions.Item label="ID">{detailPlugin.id}</Descriptions.Item>
|
||||||
<Descriptions.Item label="名称">{detailPlugin.name}</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.enabled_at || '-'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="实体数量">{detailPlugin.entities.length}</Descriptions.Item>
|
<Descriptions.Item label="实体数量">{detailPlugin.entities.length}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<Button
|
<Button
|
||||||
icon={<HeartOutlined />}
|
icon={<HeartOutlined />}
|
||||||
onClick={() => detailPlugin && handleHealthCheck(detailPlugin.id)}
|
onClick={() => handleHealthCheck(detailPlugin.id)}
|
||||||
style={{ marginBottom: 8 }}
|
style={{ marginBottom: 8 }}
|
||||||
>
|
>
|
||||||
健康检查
|
健康检查
|
||||||
@@ -345,6 +369,40 @@ version = "0.1.0""
|
|||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</Drawer>
|
||||||
</div>
|
</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::manifest::PluginField;
|
||||||
use crate::state::EntityInfo;
|
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 条件
|
/// 行级数据权限参数 — 传递到 service 层注入 SQL 条件
|
||||||
pub struct DataScopeParams {
|
pub struct DataScopeParams {
|
||||||
pub scope_level: String,
|
pub scope_level: String,
|
||||||
@@ -68,6 +127,11 @@ impl PluginDataService {
|
|||||||
)
|
)
|
||||||
.await;
|
.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 {
|
Ok(PluginDataResp {
|
||||||
id: result.id.to_string(),
|
id: result.id.to_string(),
|
||||||
data: result.data,
|
data: result.data,
|
||||||
@@ -279,6 +343,11 @@ impl PluginDataService {
|
|||||||
)
|
)
|
||||||
.await;
|
.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 {
|
Ok(PluginDataResp {
|
||||||
id: result.id.to_string(),
|
id: result.id.to_string(),
|
||||||
data: result.data,
|
data: result.data,
|
||||||
@@ -428,6 +497,11 @@ impl PluginDataService {
|
|||||||
)
|
)
|
||||||
.await;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1312,6 +1312,8 @@ mod tests {
|
|||||||
relations: vec![],
|
relations: vec![],
|
||||||
data_scope: None,
|
data_scope: None,
|
||||||
is_public: None,
|
is_public: None,
|
||||||
|
importable: None,
|
||||||
|
exportable: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity);
|
let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity);
|
||||||
@@ -1355,6 +1357,8 @@ mod tests {
|
|||||||
relations: vec![],
|
relations: vec![],
|
||||||
data_scope: None,
|
data_scope: None,
|
||||||
is_public: None,
|
is_public: None,
|
||||||
|
importable: None,
|
||||||
|
exportable: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity);
|
let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity);
|
||||||
|
|||||||
@@ -15,9 +15,28 @@ use erp_core::events::EventBus;
|
|||||||
use crate::PluginWorld;
|
use crate::PluginWorld;
|
||||||
use crate::dynamic_table::DynamicTableManager;
|
use crate::dynamic_table::DynamicTableManager;
|
||||||
use crate::error::{PluginError, PluginResult};
|
use crate::error::{PluginError, PluginResult};
|
||||||
use crate::host::{HostState, PendingOp};
|
use crate::host::{HostState, NumberingRule, PendingOp};
|
||||||
use crate::manifest::PluginManifest;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PluginEngineConfig {
|
pub struct PluginEngineConfig {
|
||||||
@@ -472,6 +491,9 @@ impl PluginEngine {
|
|||||||
// 构建跨插件实体映射(从 manifest 的 ref_plugin 字段提取)
|
// 构建跨插件实体映射(从 manifest 的 ref_plugin 字段提取)
|
||||||
let cross_plugin_entities = Self::build_cross_plugin_map(&loaded.manifest, &self.db, exec_ctx.tenant_id).await;
|
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,使用真实的租户/用户上下文
|
// 创建新的 Store + HostState,使用真实的租户/用户上下文
|
||||||
// 传入 db 和 event_bus 启用混合执行模式(插件可自主查询数据)
|
// 传入 db 和 event_bus 启用混合执行模式(插件可自主查询数据)
|
||||||
let mut state = HostState::new_with_db(
|
let mut state = HostState::new_with_db(
|
||||||
@@ -483,6 +505,9 @@ impl PluginEngine {
|
|||||||
self.event_bus.clone(),
|
self.event_bus.clone(),
|
||||||
);
|
);
|
||||||
state.cross_plugin_entities = cross_plugin_entities;
|
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);
|
let mut store = Store::new(&self.engine, state);
|
||||||
store
|
store
|
||||||
.set_fuel(self.config.default_fuel)
|
.set_fuel(self.config.default_fuel)
|
||||||
@@ -541,6 +566,38 @@ impl PluginEngine {
|
|||||||
result
|
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 字段构建跨插件实体映射
|
/// 从 manifest 的 ref_plugin 字段构建跨插件实体映射
|
||||||
/// 返回: { "erp-crm.customer" → "plugin_erp_crm__customer", ... }
|
/// 返回: { "erp-crm.customer" → "plugin_erp_crm__customer", ... }
|
||||||
async fn build_cross_plugin_map(
|
async fn build_cross_plugin_map(
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ pub enum PluginError {
|
|||||||
|
|
||||||
#[error("权限不足: {0}")]
|
#[error("权限不足: {0}")]
|
||||||
PermissionDenied(String),
|
PermissionDenied(String),
|
||||||
|
|
||||||
|
#[error("配置校验失败: {0}")]
|
||||||
|
ValidationError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<PluginError> for AppError {
|
impl From<PluginError> for AppError {
|
||||||
@@ -41,7 +44,8 @@ impl From<PluginError> for AppError {
|
|||||||
PluginError::AlreadyExists(_) => AppError::Conflict(err.to_string()),
|
PluginError::AlreadyExists(_) => AppError::Conflict(err.to_string()),
|
||||||
PluginError::InvalidManifest(_)
|
PluginError::InvalidManifest(_)
|
||||||
| PluginError::InvalidState { .. }
|
| 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()),
|
PluginError::PermissionDenied(_) => AppError::Forbidden(err.to_string()),
|
||||||
_ => AppError::Internal(err.to_string()),
|
_ => AppError::Internal(err.to_string()),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -456,3 +456,32 @@ where
|
|||||||
|
|
||||||
Ok(Json(ApiResponse::ok(result)))
|
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 std::collections::HashMap;
|
||||||
|
|
||||||
use sea_orm::DatabaseConnection;
|
use sea_orm::{ConnectionTrait, DatabaseConnection};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use wasmtime::StoreLimits;
|
use wasmtime::StoreLimits;
|
||||||
|
|
||||||
@@ -58,6 +58,19 @@ pub struct HostState {
|
|||||||
pub(crate) event_bus: Option<erp_core::events::EventBus>,
|
pub(crate) event_bus: Option<erp_core::events::EventBus>,
|
||||||
// 跨插件实体映射:"erp-crm.customer" → "plugin_erp_crm__customer"
|
// 跨插件实体映射:"erp-crm.customer" → "plugin_erp_crm__customer"
|
||||||
pub(crate) cross_plugin_entities: HashMap<String, String>,
|
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 {
|
impl HostState {
|
||||||
@@ -85,6 +98,8 @@ impl HostState {
|
|||||||
db: None,
|
db: None,
|
||||||
event_bus: None,
|
event_bus: None,
|
||||||
cross_plugin_entities: HashMap::new(),
|
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> {
|
fn check_permission(&mut self, permission: String) -> Result<bool, String> {
|
||||||
Ok(self.permissions.contains(&permission))
|
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 host;
|
||||||
pub mod manifest;
|
pub mod manifest;
|
||||||
pub mod module;
|
pub mod module;
|
||||||
|
pub mod plugin_validator;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ pub struct PluginManifest {
|
|||||||
pub events: Option<PluginEvents>,
|
pub events: Option<PluginEvents>,
|
||||||
pub ui: Option<PluginUi>,
|
pub ui: Option<PluginUi>,
|
||||||
pub permissions: Option<Vec<PluginPermission>>,
|
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>, // 是否启用行级数据权限
|
pub data_scope: Option<bool>, // 是否启用行级数据权限
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub is_public: Option<bool>, // 是否可被其他插件引用
|
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>>, // 支持的数据范围等级
|
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 字符串解析插件清单
|
/// 从 TOML 字符串解析插件清单
|
||||||
pub fn parse_manifest(toml_str: &str) -> PluginResult<PluginManifest> {
|
pub fn parse_manifest(toml_str: &str) -> PluginResult<PluginManifest> {
|
||||||
let manifest: PluginManifest =
|
let manifest: PluginManifest =
|
||||||
@@ -361,6 +504,40 @@ pub fn parse_manifest(toml_str: &str) -> PluginResult<PluginManifest> {
|
|||||||
validate_pages(&ui.pages)?;
|
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)
|
Ok(manifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1110,4 +1287,270 @@ card_title_field = "name"
|
|||||||
let result = parse_manifest(toml);
|
let result = parse_manifest(toml);
|
||||||
assert!(result.is_err());
|
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(
|
.route(
|
||||||
"/admin/plugins/{id}/upgrade",
|
"/admin/plugins/{id}/upgrade",
|
||||||
post(crate::handler::plugin_handler::upgrade_plugin::<S>),
|
post(crate::handler::plugin_handler::upgrade_plugin::<S>),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/admin/plugins/{id}/validate",
|
||||||
|
get(crate::handler::plugin_handler::validate_plugin::<S>),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 插件数据 CRUD 路由
|
// 插件数据 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
|
// 解析 manifest
|
||||||
let manifest = parse_manifest(manifest_toml)?;
|
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
|
// 计算 WASM hash
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(&wasm_binary);
|
hasher.update(&wasm_binary);
|
||||||
@@ -403,6 +411,10 @@ impl PluginService {
|
|||||||
events: None,
|
events: None,
|
||||||
ui: None,
|
ui: None,
|
||||||
permissions: None,
|
permissions: None,
|
||||||
|
settings: None,
|
||||||
|
numbering: None,
|
||||||
|
templates: None,
|
||||||
|
trigger_events: None,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let entities = entities_map.get(&model.id).cloned().unwrap_or_default();
|
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)?;
|
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 now = Utc::now();
|
||||||
let mut active: plugin::ActiveModel = model.into();
|
let mut active: plugin::ActiveModel = model.into();
|
||||||
active.config_json = Set(config);
|
active.config_json = Set(config);
|
||||||
@@ -446,9 +468,6 @@ impl PluginService {
|
|||||||
active.updated_by = Set(Some(operator_id));
|
active.updated_by = Set(Some(operator_id));
|
||||||
let model = active.update(db).await?;
|
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();
|
let entities = find_plugin_entities(plugin_id, tenant_id, db).await.unwrap_or_default();
|
||||||
Ok(plugin_model_to_resp(&model, &manifest, entities))
|
Ok(plugin_model_to_resp(&model, &manifest, entities))
|
||||||
}
|
}
|
||||||
@@ -489,7 +508,7 @@ impl PluginService {
|
|||||||
serde_json::from_value(model.manifest_json.clone())
|
serde_json::from_value(model.manifest_json.clone())
|
||||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
.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();
|
let mut result = serde_json::Map::new();
|
||||||
if let Some(schema) = &manifest.schema {
|
if let Some(schema) = &manifest.schema {
|
||||||
result.insert(
|
result.insert(
|
||||||
@@ -503,6 +522,24 @@ impl PluginService {
|
|||||||
serde_json::to_value(ui).unwrap_or_default(),
|
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))
|
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> 映射。
|
/// 批量查询多插件的 entities,返回 plugin_id → Vec<PluginEntityResp> 映射。
|
||||||
async fn find_batch_plugin_entities(
|
async fn find_batch_plugin_entities(
|
||||||
plugin_ids: &[Uuid],
|
plugin_ids: &[Uuid],
|
||||||
@@ -754,6 +800,77 @@ fn validate_status_any(actual: &str, expected: &[&str]) -> AppResult<()> {
|
|||||||
Ok(())
|
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(
|
fn plugin_model_to_resp(
|
||||||
model: &plugin::Model,
|
model: &plugin::Model,
|
||||||
manifest: &PluginManifest,
|
manifest: &PluginManifest,
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ interface host-api {
|
|||||||
|
|
||||||
/// 检查当前用户权限
|
/// 检查当前用户权限
|
||||||
check-permission: func(permission: string) -> result<bool, string>;
|
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(宿主调用这些函数)
|
/// 插件导出的 API(宿主调用这些函数)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ mod m20260418_000036_add_data_scope_to_role_permissions;
|
|||||||
mod m20260419_000037_create_user_departments;
|
mod m20260419_000037_create_user_departments;
|
||||||
mod m20260419_000038_fix_crm_permission_codes;
|
mod m20260419_000038_fix_crm_permission_codes;
|
||||||
mod m20260419_000039_entity_registry_columns;
|
mod m20260419_000039_entity_registry_columns;
|
||||||
|
mod m20260419_000040_plugin_market;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260419_000037_create_user_departments::Migration),
|
Box::new(m20260419_000037_create_user_departments::Migration),
|
||||||
Box::new(m20260419_000038_fix_crm_permission_codes::Migration),
|
Box::new(m20260419_000038_fix_crm_permission_codes::Migration),
|
||||||
Box::new(m20260419_000039_entity_registry_columns::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![],
|
indexes: vec![],
|
||||||
relations: vec![],
|
relations: vec![],
|
||||||
data_scope: None,
|
data_scope: None,
|
||||||
|
importable: None,
|
||||||
|
exportable: None,
|
||||||
}],
|
}],
|
||||||
}),
|
}),
|
||||||
events: None,
|
events: None,
|
||||||
ui: None,
|
ui: None,
|
||||||
permissions: None,
|
permissions: None,
|
||||||
|
settings: None,
|
||||||
|
numbering: None,
|
||||||
|
templates: None,
|
||||||
|
trigger_events: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user