feat(plugin): P2-P4 插件平台演进 — 通用服务 + 质量保障 + 市场
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

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:
iven
2026-04-19 12:16:24 +08:00
parent c4b1e9e56d
commit e429448c42
20 changed files with 1889 additions and 46 deletions

View File

@@ -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 />} />

View File

@@ -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';
}

View 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;

View File

@@ -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,49 +319,90 @@ version = &quot;0.1.0&quot;"
onClose={() => { onClose={() => {
setDetailPlugin(null); setDetailPlugin(null);
setHealthDetail(null); setHealthDetail(null);
setSchemaData(null);
}} }}
width={500} width={500}
> >
{detailPlugin && ( {detailPlugin && (
<Descriptions column={1} bordered size="small"> <Tabs
<Descriptions.Item label="ID">{detailPlugin.id}</Descriptions.Item> defaultActiveKey="info"
<Descriptions.Item label="名称">{detailPlugin.name}</Descriptions.Item> items={[
<Descriptions.Item label="版本">{detailPlugin.version}</Descriptions.Item> {
<Descriptions.Item label="状态"> key: 'info',
<Tag color={STATUS_CONFIG[detailPlugin.status]?.color}> label: '基本信息',
{STATUS_CONFIG[detailPlugin.status]?.label || detailPlugin.status} children: (
</Tag> <>
</Descriptions.Item> <Descriptions column={1} bordered size="small">
<Descriptions.Item label="作者">{detailPlugin.author || '-'}</Descriptions.Item> <Descriptions.Item label="ID">{detailPlugin.id}</Descriptions.Item>
<Descriptions.Item label="描述">{detailPlugin.description || '-'}</Descriptions.Item> <Descriptions.Item label="名称">{detailPlugin.name}</Descriptions.Item>
<Descriptions.Item label="安装时间">{detailPlugin.installed_at || '-'}</Descriptions.Item> <Descriptions.Item label="版本">{detailPlugin.version}</Descriptions.Item>
<Descriptions.Item label="启用时间">{detailPlugin.enabled_at || '-'}</Descriptions.Item> <Descriptions.Item label="状态">
<Descriptions.Item label="实体数量">{detailPlugin.entities.length}</Descriptions.Item> <Tag color={STATUS_CONFIG[detailPlugin.status]?.color}>
</Descriptions> {STATUS_CONFIG[detailPlugin.status]?.label || detailPlugin.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="作者">{detailPlugin.author || '-'}</Descriptions.Item>
<Descriptions.Item label="描述">{detailPlugin.description || '-'}</Descriptions.Item>
<Descriptions.Item label="安装时间">{detailPlugin.installed_at || '-'}</Descriptions.Item>
<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={() => handleHealthCheck(detailPlugin.id)}
style={{ marginBottom: 8 }}
>
</Button>
{healthDetail && (
<pre
style={{
background: token.colorBgContainer,
padding: 12,
borderRadius: 6,
fontSize: 12,
overflow: 'auto',
}}
>
{JSON.stringify(healthDetail, null, 2)}
</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'}
/>
),
},
]
: []),
]}
/>
)} )}
<div style={{ marginTop: 16 }}>
<Button
icon={<HeartOutlined />}
onClick={() => detailPlugin && handleHealthCheck(detailPlugin.id)}
style={{ marginBottom: 8 }}
>
</Button>
{healthDetail && (
<pre
style={{
background: token.colorBgContainer,
padding: 12,
borderRadius: 6,
fontSize: 12,
overflow: 'auto',
}}
>
{JSON.stringify(healthDetail, null, 2)}
</pre>
)}
</div>
</Drawer> </Drawer>
</div> </div>
); );

View 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>
);
}

View File

@@ -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(())
} }

View File

@@ -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);

View File

@@ -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(

View File

@@ -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()),
} }

View File

@@ -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)))
}

View File

@@ -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())
}
} }

View File

@@ -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;

View File

@@ -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));
}
} }

View File

@@ -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 路由

View 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());
}
}

View File

@@ -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,

View File

@@ -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宿主调用这些函数

View File

@@ -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),
] ]
} }
} }

View File

@@ -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
}
}

View File

@@ -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,
} }
} }