feat(plugin): 集成 WASM 插件系统到主服务并修复链路问题
- 新增 erp-plugin crate:插件管理、WASM 运行时、动态表、数据 CRUD - 新增前端插件管理页面(PluginAdmin/PluginCRUDPage)和 API 层 - 新增插件数据迁移(plugins/plugin_entities/plugin_event_subscriptions) - 新增权限补充迁移(为已有租户补充 plugin.admin/plugin.list 权限) - 修复 PluginAdmin 页面 InstallOutlined 图标不存在的崩溃问题 - 修复 settings 唯一索引迁移顺序错误(先去重再建索引) - 更新 wiki 和 CLAUDE.md 反映插件系统集成状态 - 新增 dev.ps1 一键启动脚本
This commit is contained in:
@@ -460,6 +460,7 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
| Phase 5 | 消息中心 (Message) | ✅ 完成 |
|
||||
| Phase 6 | 整合与打磨 | ✅ 完成 |
|
||||
| - | WASM 插件原型 (V1-V6) | ✅ 验证通过 |
|
||||
| - | 插件系统集成到主服务 | ✅ 已集成 |
|
||||
|
||||
### 已实现模块
|
||||
|
||||
@@ -472,6 +473,7 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
| erp-workflow | 工作流引擎 (BPMN 解析/Token 驱动/任务分配) | ✅ 完成 |
|
||||
| erp-message | 消息中心 (CRUD/模板/订阅/通知面板) | ✅ 完成 |
|
||||
| erp-config | 系统配置 (字典/菜单/设置/编号规则/主题) | ✅ 完成 |
|
||||
| erp-plugin | 插件管理 (WASM 运行时/生命周期/动态表/数据CRUD) | ✅ 已集成 |
|
||||
| erp-plugin-prototype | WASM 插件 Host 运行时 (Wasmtime + bindgen + Host API) | ✅ 原型验证 |
|
||||
| erp-plugin-test-sample | WASM 测试插件 (Guest trait + Host API 回调) | ✅ 原型验证 |
|
||||
|
||||
|
||||
57
Cargo.lock
generated
57
Cargo.lock
generated
@@ -256,6 +256,7 @@ dependencies = [
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"multer",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
@@ -985,6 +986,20 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "debugid"
|
||||
version = "0.8.0"
|
||||
@@ -1189,6 +1204,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"utoipa",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -1212,6 +1228,29 @@ dependencies = [
|
||||
"validator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "erp-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"erp-core",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"toml 0.8.23",
|
||||
"tracing",
|
||||
"utoipa",
|
||||
"uuid",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "erp-plugin-prototype"
|
||||
version = "0.1.0"
|
||||
@@ -1246,6 +1285,7 @@ dependencies = [
|
||||
"erp-config",
|
||||
"erp-core",
|
||||
"erp-message",
|
||||
"erp-plugin",
|
||||
"erp-server-migration",
|
||||
"erp-workflow",
|
||||
"redis",
|
||||
@@ -2285,6 +2325,23 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"http",
|
||||
"httparse",
|
||||
"memchr",
|
||||
"mime",
|
||||
"spin",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
|
||||
@@ -10,6 +10,7 @@ members = [
|
||||
"crates/erp-server/migration",
|
||||
"crates/erp-plugin-prototype",
|
||||
"crates/erp-plugin-test-sample",
|
||||
"crates/erp-plugin",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -22,7 +23,7 @@ license = "MIT"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Web
|
||||
axum = "0.8"
|
||||
axum = { version = "0.8", features = ["multipart"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }
|
||||
|
||||
@@ -80,3 +81,4 @@ erp-auth = { path = "crates/erp-auth" }
|
||||
erp-workflow = { path = "crates/erp-workflow" }
|
||||
erp-message = { path = "crates/erp-message" }
|
||||
erp-config = { path = "crates/erp-config" }
|
||||
erp-plugin = { path = "crates/erp-plugin" }
|
||||
|
||||
@@ -14,6 +14,8 @@ const Organizations = lazy(() => import('./pages/Organizations'));
|
||||
const Workflow = lazy(() => import('./pages/Workflow'));
|
||||
const Messages = lazy(() => import('./pages/Messages'));
|
||||
const Settings = lazy(() => import('./pages/Settings'));
|
||||
const PluginAdmin = lazy(() => import('./pages/PluginAdmin'));
|
||||
const PluginCRUDPage = lazy(() => import('./pages/PluginCRUDPage'));
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
@@ -135,6 +137,8 @@ export default function App() {
|
||||
<Route path="/workflow" element={<Workflow />} />
|
||||
<Route path="/messages" element={<Messages />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/plugins/admin" element={<PluginAdmin />} />
|
||||
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</MainLayout>
|
||||
|
||||
71
apps/web/src/api/pluginData.ts
Normal file
71
apps/web/src/api/pluginData.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import client from './client';
|
||||
|
||||
export interface PluginDataRecord {
|
||||
id: string;
|
||||
data: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
interface PaginatedDataResponse {
|
||||
data: PluginDataRecord[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export async function listPluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedDataResponse }>(
|
||||
`/plugins/${pluginId}/${entity}`,
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getPluginData(pluginId: string, entity: string, id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: PluginDataRecord }>(
|
||||
`/plugins/${pluginId}/${entity}/${id}`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createPluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
recordData: Record<string, unknown>,
|
||||
) {
|
||||
const { data } = await client.post<{ success: boolean; data: PluginDataRecord }>(
|
||||
`/plugins/${pluginId}/${entity}`,
|
||||
{ data: recordData },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updatePluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
id: string,
|
||||
recordData: Record<string, unknown>,
|
||||
version: number,
|
||||
) {
|
||||
const { data } = await client.put<{ success: boolean; data: PluginDataRecord }>(
|
||||
`/plugins/${pluginId}/${entity}/${id}`,
|
||||
{ data: recordData, version },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deletePluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
id: string,
|
||||
) {
|
||||
await client.delete(`/plugins/${pluginId}/${entity}/${id}`);
|
||||
}
|
||||
121
apps/web/src/api/plugins.ts
Normal file
121
apps/web/src/api/plugins.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import client from './client';
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface PluginEntityInfo {
|
||||
name: string;
|
||||
display_name: string;
|
||||
table_name: string;
|
||||
}
|
||||
|
||||
export interface PluginPermissionInfo {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type PluginStatus = 'uploaded' | 'installed' | 'enabled' | 'running' | 'disabled' | 'uninstalled';
|
||||
|
||||
export interface PluginInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
status: PluginStatus;
|
||||
config: Record<string, unknown>;
|
||||
installed_at?: string;
|
||||
enabled_at?: string;
|
||||
entities: PluginEntityInfo[];
|
||||
permissions?: PluginPermissionInfo[];
|
||||
record_version: number;
|
||||
}
|
||||
|
||||
export async function listPlugins(page = 1, pageSize = 20, status?: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<PluginInfo> }>(
|
||||
'/admin/plugins',
|
||||
{ params: { page, page_size: pageSize, status: status || undefined } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getPlugin(id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: PluginInfo }>(
|
||||
`/admin/plugins/${id}`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function uploadPlugin(wasmFile: File, manifestToml: string) {
|
||||
const formData = new FormData();
|
||||
formData.append('wasm', wasmFile);
|
||||
formData.append('manifest', manifestToml);
|
||||
|
||||
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
|
||||
'/admin/plugins/upload',
|
||||
formData,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' }, timeout: 60000 },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function installPlugin(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
|
||||
`/admin/plugins/${id}/install`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function enablePlugin(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
|
||||
`/admin/plugins/${id}/enable`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function disablePlugin(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
|
||||
`/admin/plugins/${id}/disable`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function uninstallPlugin(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
|
||||
`/admin/plugins/${id}/uninstall`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function purgePlugin(id: string) {
|
||||
await client.delete(`/admin/plugins/${id}`);
|
||||
}
|
||||
|
||||
export async function getPluginHealth(id: string) {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: { plugin_id: string; status: string; details: Record<string, unknown> };
|
||||
}>(`/admin/plugins/${id}/health`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updatePluginConfig(id: string, config: Record<string, unknown>, version: number) {
|
||||
const { data } = await client.put<{ success: boolean; data: PluginInfo }>(
|
||||
`/admin/plugins/${id}/config`,
|
||||
{ config, version },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getPluginSchema(id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: Record<string, unknown> }>(
|
||||
`/admin/plugins/${id}/schema`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, memo } from 'react';
|
||||
import { useCallback, memo, useEffect } from 'react';
|
||||
import { Layout, Avatar, Space, Dropdown, Tooltip, theme } from 'antd';
|
||||
import {
|
||||
HomeOutlined,
|
||||
@@ -14,10 +14,12 @@ import {
|
||||
SearchOutlined,
|
||||
BulbOutlined,
|
||||
BulbFilled,
|
||||
AppstoreOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAppStore } from '../stores/app';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { usePluginStore } from '../stores/plugin';
|
||||
import NotificationPanel from '../components/NotificationPanel';
|
||||
|
||||
const { Header, Sider, Content, Footer } = Layout;
|
||||
@@ -42,6 +44,7 @@ const bizMenuItems: MenuItem[] = [
|
||||
|
||||
const sysMenuItems: MenuItem[] = [
|
||||
{ key: '/settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||
{ key: '/plugins/admin', icon: <AppstoreOutlined />, label: '插件管理' },
|
||||
];
|
||||
|
||||
const routeTitleMap: Record<string, string> = {
|
||||
@@ -52,6 +55,7 @@ const routeTitleMap: Record<string, string> = {
|
||||
'/workflow': '工作流',
|
||||
'/messages': '消息中心',
|
||||
'/settings': '系统设置',
|
||||
'/plugins/admin': '插件管理',
|
||||
};
|
||||
|
||||
// 侧边栏菜单项 - 提取为独立组件避免重复渲染
|
||||
@@ -82,11 +86,17 @@ const SidebarMenuItem = memo(function SidebarMenuItem({
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
const { sidebarCollapsed, toggleSidebar, theme: themeMode, setTheme } = useAppStore();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { pluginMenuItems, fetchPlugins } = usePluginStore();
|
||||
theme.useToken();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const currentPath = location.pathname || '/';
|
||||
|
||||
// 加载插件菜单
|
||||
useEffect(() => {
|
||||
fetchPlugins(1, 'running');
|
||||
}, [fetchPlugins]);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
@@ -159,6 +169,28 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 菜单组:插件 */}
|
||||
{pluginMenuItems.length > 0 && (
|
||||
<>
|
||||
{!sidebarCollapsed && <div className="erp-sidebar-group">插件</div>}
|
||||
<div className="erp-sidebar-menu">
|
||||
{pluginMenuItems.map((item) => (
|
||||
<SidebarMenuItem
|
||||
key={item.key}
|
||||
item={{
|
||||
key: item.key,
|
||||
icon: <AppstoreOutlined />,
|
||||
label: item.label,
|
||||
}}
|
||||
isActive={currentPath === item.key}
|
||||
collapsed={sidebarCollapsed}
|
||||
onClick={() => navigate(item.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 菜单组:系统 */}
|
||||
{!sidebarCollapsed && <div className="erp-sidebar-group">系统</div>}
|
||||
<div className="erp-sidebar-menu">
|
||||
@@ -187,7 +219,9 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
||||
{sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
</div>
|
||||
<span className={`erp-header-title ${isDark ? 'erp-text-dark' : 'erp-text-light'}`}>
|
||||
{routeTitleMap[currentPath] || '页面'}
|
||||
{routeTitleMap[currentPath] ||
|
||||
pluginMenuItems.find((p) => p.key === currentPath)?.label ||
|
||||
'页面'}
|
||||
</span>
|
||||
</Space>
|
||||
|
||||
|
||||
342
apps/web/src/pages/PluginAdmin.tsx
Normal file
342
apps/web/src/pages/PluginAdmin.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Tag,
|
||||
message,
|
||||
Upload,
|
||||
Modal,
|
||||
Input,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
Popconfirm,
|
||||
Form,
|
||||
theme,
|
||||
} from 'antd';
|
||||
import {
|
||||
UploadOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
CloudDownloadOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
AppstoreOutlined,
|
||||
HeartOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { PluginInfo, PluginStatus } from '../api/plugins';
|
||||
import {
|
||||
listPlugins,
|
||||
uploadPlugin,
|
||||
installPlugin,
|
||||
enablePlugin,
|
||||
disablePlugin,
|
||||
uninstallPlugin,
|
||||
purgePlugin,
|
||||
getPluginHealth,
|
||||
} from '../api/plugins';
|
||||
|
||||
const STATUS_CONFIG: Record<PluginStatus, { color: string; label: string }> = {
|
||||
uploaded: { color: '#64748B', label: '已上传' },
|
||||
installed: { color: '#2563EB', label: '已安装' },
|
||||
enabled: { color: '#059669', label: '已启用' },
|
||||
running: { color: '#059669', label: '运行中' },
|
||||
disabled: { color: '#DC2626', label: '已禁用' },
|
||||
uninstalled: { color: '#9333EA', label: '已卸载' },
|
||||
};
|
||||
|
||||
export default function PluginAdmin() {
|
||||
const [plugins, setPlugins] = useState<PluginInfo[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
||||
const [manifestText, setManifestText] = useState('');
|
||||
const [wasmFile, setWasmFile] = useState<File | null>(null);
|
||||
const [detailPlugin, setDetailPlugin] = useState<PluginInfo | null>(null);
|
||||
const [healthDetail, setHealthDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const fetchPlugins = useCallback(async (p = page) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listPlugins(p);
|
||||
setPlugins(result.data);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
message.error('加载插件列表失败');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlugins();
|
||||
}, [fetchPlugins]);
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!wasmFile || !manifestText.trim()) {
|
||||
message.warning('请选择 WASM 文件并填写 Manifest');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await uploadPlugin(wasmFile, manifestText);
|
||||
message.success('插件上传成功');
|
||||
setUploadModalOpen(false);
|
||||
setWasmFile(null);
|
||||
setManifestText('');
|
||||
fetchPlugins();
|
||||
} catch {
|
||||
message.error('插件上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = async (id: string, action: () => Promise<PluginInfo>, label: string) => {
|
||||
setActionLoading(id);
|
||||
try {
|
||||
await action();
|
||||
message.success(`${label}成功`);
|
||||
fetchPlugins();
|
||||
if (detailPlugin?.id === id) {
|
||||
setDetailPlugin(null);
|
||||
}
|
||||
} catch {
|
||||
message.error(`${label}失败`);
|
||||
}
|
||||
setActionLoading(null);
|
||||
};
|
||||
|
||||
const handleHealthCheck = async (id: string) => {
|
||||
try {
|
||||
const result = await getPluginHealth(id);
|
||||
setHealthDetail(result.details);
|
||||
} catch {
|
||||
message.error('健康检查失败');
|
||||
}
|
||||
};
|
||||
|
||||
const getActions = (record: PluginInfo) => {
|
||||
const id = record.id;
|
||||
const btns: React.ReactNode[] = [];
|
||||
|
||||
switch (record.status) {
|
||||
case 'uploaded':
|
||||
btns.push(
|
||||
<Button
|
||||
key="install"
|
||||
size="small"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
loading={actionLoading === id}
|
||||
onClick={() => handleAction(id, () => installPlugin(id), '安装')}
|
||||
>
|
||||
安装
|
||||
</Button>,
|
||||
);
|
||||
break;
|
||||
case 'installed':
|
||||
btns.push(
|
||||
<Button
|
||||
key="enable"
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={actionLoading === id}
|
||||
onClick={() => handleAction(id, () => enablePlugin(id), '启用')}
|
||||
>
|
||||
启用
|
||||
</Button>,
|
||||
);
|
||||
break;
|
||||
case 'enabled':
|
||||
case 'running':
|
||||
btns.push(
|
||||
<Button
|
||||
key="disable"
|
||||
size="small"
|
||||
danger
|
||||
icon={<PauseCircleOutlined />}
|
||||
loading={actionLoading === id}
|
||||
onClick={() => handleAction(id, () => disablePlugin(id), '停用')}
|
||||
>
|
||||
停用
|
||||
</Button>,
|
||||
);
|
||||
break;
|
||||
case 'disabled':
|
||||
btns.push(
|
||||
<Button
|
||||
key="uninstall"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
loading={actionLoading === id}
|
||||
onClick={() => handleAction(id, () => uninstallPlugin(id), '卸载')}
|
||||
>
|
||||
卸载
|
||||
</Button>,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return btns;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name', width: 180 },
|
||||
{ title: '版本', dataIndex: 'version', key: 'version', width: 80 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status: PluginStatus) => {
|
||||
const cfg = STATUS_CONFIG[status] || { color: '#64748B', label: status };
|
||||
return <Tag color={cfg.color}>{cfg.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '作者', dataIndex: 'author', key: 'author', width: 120 },
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 320,
|
||||
render: (_: unknown, record: PluginInfo) => (
|
||||
<Space size="small">
|
||||
{getActions(record)}
|
||||
<Button size="small" onClick={() => setDetailPlugin(record)}>
|
||||
详情
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要清除该插件记录吗?"
|
||||
onConfirm={() => handleAction(record.id, async () => { await purgePlugin(record.id); return record; }, '清除')}
|
||||
>
|
||||
<Button size="small" danger disabled={record.status !== 'uninstalled'}>
|
||||
清除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<Button icon={<UploadOutlined />} type="primary" onClick={() => setUploadModalOpen(true)}>
|
||||
上传插件
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => fetchPlugins()}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={plugins}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: (p) => setPage(p),
|
||||
showTotal: (t) => `共 ${t} 个插件`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="上传插件"
|
||||
open={uploadModalOpen}
|
||||
onOk={handleUpload}
|
||||
onCancel={() => setUploadModalOpen(false)}
|
||||
okText="上传"
|
||||
width={600}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="WASM 文件" required>
|
||||
<Upload
|
||||
beforeUpload={(file) => {
|
||||
setWasmFile(file);
|
||||
return false;
|
||||
}}
|
||||
maxCount={1}
|
||||
accept=".wasm"
|
||||
fileList={wasmFile ? [wasmFile as unknown as Parameters<typeof Upload>[0]] : []}
|
||||
onRemove={() => setWasmFile(null)}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>选择 WASM 文件</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
<Form.Item label="Manifest (TOML)" required>
|
||||
<Input.TextArea
|
||||
rows={12}
|
||||
value={manifestText}
|
||||
onChange={(e) => setManifestText(e.target.value)}
|
||||
placeholder="[metadata]
|
||||
id = "my-plugin"
|
||||
name = "我的插件"
|
||||
version = "0.1.0""
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
title={detailPlugin ? `插件详情: ${detailPlugin.name}` : '插件详情'}
|
||||
open={!!detailPlugin}
|
||||
onClose={() => {
|
||||
setDetailPlugin(null);
|
||||
setHealthDetail(null);
|
||||
}}
|
||||
width={500}
|
||||
>
|
||||
{detailPlugin && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="ID">{detailPlugin.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="名称">{detailPlugin.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="版本">{detailPlugin.version}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STATUS_CONFIG[detailPlugin.status]?.color}>
|
||||
{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={() => 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
256
apps/web/src/pages/PluginCRUDPage.tsx
Normal file
256
apps/web/src/pages/PluginCRUDPage.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
DatePicker,
|
||||
Switch,
|
||||
Select,
|
||||
Tag,
|
||||
message,
|
||||
Popconfirm,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
listPluginData,
|
||||
createPluginData,
|
||||
updatePluginData,
|
||||
deletePluginData,
|
||||
} from '../api/pluginData';
|
||||
import { getPluginSchema } from '../api/plugins';
|
||||
|
||||
interface FieldDef {
|
||||
name: string;
|
||||
field_type: string;
|
||||
required: boolean;
|
||||
display_name?: string;
|
||||
ui_widget?: string;
|
||||
options?: { label: string; value: string }[];
|
||||
}
|
||||
|
||||
interface EntitySchema {
|
||||
name: string;
|
||||
display_name: string;
|
||||
fields: FieldDef[];
|
||||
}
|
||||
|
||||
export default function PluginCRUDPage() {
|
||||
const { pluginId, entityName } = useParams<{ pluginId: string; entityName: string }>();
|
||||
const [records, setRecords] = useState<Record<string, unknown>[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fields, setFields] = useState<FieldDef[]>([]);
|
||||
const [displayName, setDisplayName] = useState(entityName || '');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editRecord, setEditRecord] = useState<Record<string, unknown> | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 加载 schema
|
||||
useEffect(() => {
|
||||
if (!pluginId) return;
|
||||
getPluginSchema(pluginId)
|
||||
.then((schema) => {
|
||||
const entities = (schema as { entities?: EntitySchema[] }).entities || [];
|
||||
const entity = entities.find((e) => e.name === entityName);
|
||||
if (entity) {
|
||||
setFields(entity.fields);
|
||||
setDisplayName(entity.display_name || entityName || '');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// schema 加载失败时仍可使用
|
||||
});
|
||||
}, [pluginId, entityName]);
|
||||
|
||||
const fetchData = useCallback(async (p = page) => {
|
||||
if (!pluginId || !entityName) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listPluginData(pluginId, entityName, p);
|
||||
setRecords(result.data.map((r) => ({ ...r.data, _id: r.id, _version: r.version })));
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
message.error('加载数据失败');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [pluginId, entityName, page]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleSubmit = async (values: Record<string, unknown>) => {
|
||||
if (!pluginId || !entityName) return;
|
||||
// 去除内部字段
|
||||
const { _id, _version, ...data } = values as Record<string, unknown> & { _id?: string; _version?: number };
|
||||
|
||||
try {
|
||||
if (editRecord) {
|
||||
await updatePluginData(
|
||||
pluginId,
|
||||
entityName,
|
||||
editRecord._id as string,
|
||||
data,
|
||||
editRecord._version as number,
|
||||
);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await createPluginData(pluginId, entityName, data);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalOpen(false);
|
||||
setEditRecord(null);
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (record: Record<string, unknown>) => {
|
||||
if (!pluginId || !entityName) return;
|
||||
try {
|
||||
await deletePluginData(pluginId, entityName, record._id as string);
|
||||
message.success('删除成功');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 动态生成列
|
||||
const columns = [
|
||||
...fields.slice(0, 5).map((f) => ({
|
||||
title: f.display_name || f.name,
|
||||
dataIndex: f.name,
|
||||
key: f.name,
|
||||
ellipsis: true,
|
||||
render: (val: unknown) => {
|
||||
if (typeof val === 'boolean') return val ? <Tag color="green">是</Tag> : <Tag>否</Tag>;
|
||||
return String(val ?? '-');
|
||||
},
|
||||
})),
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
render: (_: unknown, record: Record<string, unknown>) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setEditRecord(record);
|
||||
form.setFieldsValue(record);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(record)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// 动态生成表单字段
|
||||
const renderFormField = (field: FieldDef) => {
|
||||
const widget = field.ui_widget || field.field_type;
|
||||
switch (widget) {
|
||||
case 'number':
|
||||
case 'integer':
|
||||
case 'float':
|
||||
case 'decimal':
|
||||
return <InputNumber style={{ width: '100%' }} />;
|
||||
case 'boolean':
|
||||
return <Switch />;
|
||||
case 'date':
|
||||
case 'datetime':
|
||||
return <DatePicker showTime={widget === 'datetime'} style={{ width: '100%' }} />;
|
||||
case 'select':
|
||||
return (
|
||||
<Select>
|
||||
{(field.options || []).map((opt) => (
|
||||
<Select.Option key={String(opt.value)} value={opt.value}>
|
||||
{opt.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
default:
|
||||
return <Input />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2 style={{ margin: 0 }}>{displayName}</h2>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setEditRecord(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => fetchData()}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={records}
|
||||
rowKey="_id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: (p) => setPage(p),
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editRecord ? '编辑' : '新增'}
|
||||
open={modalOpen}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
setEditRecord(null);
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
{fields.map((field) => (
|
||||
<Form.Item
|
||||
key={field.name}
|
||||
name={field.name}
|
||||
label={field.display_name || field.name}
|
||||
rules={field.required ? [{ required: true, message: `请输入${field.display_name || field.name}` }] : []}
|
||||
valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'}
|
||||
>
|
||||
{renderFormField(field)}
|
||||
</Form.Item>
|
||||
))}
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
apps/web/src/stores/plugin.ts
Normal file
59
apps/web/src/stores/plugin.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { create } from 'zustand';
|
||||
import type { PluginInfo, PluginStatus } from '../api/plugins';
|
||||
import { listPlugins } from '../api/plugins';
|
||||
|
||||
export interface PluginMenuItem {
|
||||
key: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
pluginId: string;
|
||||
entity: string;
|
||||
menuGroup?: string;
|
||||
}
|
||||
|
||||
interface PluginStore {
|
||||
plugins: PluginInfo[];
|
||||
loading: boolean;
|
||||
pluginMenuItems: PluginMenuItem[];
|
||||
fetchPlugins: (page?: number, status?: PluginStatus) => Promise<void>;
|
||||
refreshMenuItems: () => void;
|
||||
}
|
||||
|
||||
export const usePluginStore = create<PluginStore>((set, get) => ({
|
||||
plugins: [],
|
||||
loading: false,
|
||||
pluginMenuItems: [],
|
||||
|
||||
fetchPlugins: async (page = 1, status?: PluginStatus) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const result = await listPlugins(page, 100, status);
|
||||
set({ plugins: result.data });
|
||||
get().refreshMenuItems();
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
refreshMenuItems: () => {
|
||||
const { plugins } = get();
|
||||
const items: PluginMenuItem[] = [];
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (plugin.status !== 'running' && plugin.status !== 'enabled') continue;
|
||||
|
||||
for (const entity of plugin.entities) {
|
||||
items.push({
|
||||
key: `/plugins/${plugin.id}/${entity.name}`,
|
||||
icon: 'AppstoreOutlined',
|
||||
label: entity.display_name || entity.name,
|
||||
pluginId: plugin.id,
|
||||
entity: entity.name,
|
||||
menuGroup: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
set({ pluginMenuItems: items });
|
||||
},
|
||||
}));
|
||||
@@ -302,6 +302,21 @@ const DEFAULT_PERMISSIONS: &[(&str, &str, &str, &str, &str)] = &[
|
||||
"create",
|
||||
"创建消息模板",
|
||||
),
|
||||
// === Plugin module ===
|
||||
(
|
||||
"plugin.admin",
|
||||
"插件管理",
|
||||
"plugin",
|
||||
"admin",
|
||||
"管理插件全生命周期",
|
||||
),
|
||||
(
|
||||
"plugin.list",
|
||||
"查看插件",
|
||||
"plugin",
|
||||
"list",
|
||||
"查看插件列表",
|
||||
),
|
||||
];
|
||||
|
||||
/// Indices of read-only (list/read) permissions within DEFAULT_PERMISSIONS.
|
||||
@@ -324,6 +339,7 @@ const READ_PERM_INDICES: &[usize] = &[
|
||||
44, // workflow.read
|
||||
49, // message.list
|
||||
51, // message.template.list
|
||||
53, // plugin.list
|
||||
];
|
||||
|
||||
/// Seed default auth data for a new tenant.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tracing::{error, info};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -31,6 +31,32 @@ impl DomainEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/// 过滤事件接收器 — 只接收匹配 `event_type_prefix` 的事件
|
||||
pub struct FilteredEventReceiver {
|
||||
receiver: mpsc::Receiver<DomainEvent>,
|
||||
}
|
||||
|
||||
impl FilteredEventReceiver {
|
||||
/// 接收下一个匹配的事件
|
||||
pub async fn recv(&mut self) -> Option<DomainEvent> {
|
||||
self.receiver.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
/// 订阅句柄 — 用于取消过滤订阅
|
||||
pub struct SubscriptionHandle {
|
||||
cancel_tx: mpsc::Sender<()>,
|
||||
join_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl SubscriptionHandle {
|
||||
/// 取消订阅并等待后台任务结束
|
||||
pub async fn cancel(self) {
|
||||
let _ = self.cancel_tx.send(()).await;
|
||||
let _ = self.join_handle.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// 进程内事件总线
|
||||
#[derive(Clone)]
|
||||
pub struct EventBus {
|
||||
@@ -84,4 +110,57 @@ impl EventBus {
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<DomainEvent> {
|
||||
self.sender.subscribe()
|
||||
}
|
||||
|
||||
/// 按事件类型前缀过滤订阅。
|
||||
///
|
||||
/// 为每次调用 spawn 一个 Tokio task 从 broadcast channel 读取,
|
||||
/// 只转发匹配 `event_type_prefix` 的事件到 mpsc channel(capacity 256)。
|
||||
pub fn subscribe_filtered(
|
||||
&self,
|
||||
event_type_prefix: String,
|
||||
) -> (FilteredEventReceiver, SubscriptionHandle) {
|
||||
let mut broadcast_rx = self.sender.subscribe();
|
||||
let (mpsc_tx, mpsc_rx) = mpsc::channel(256);
|
||||
let (cancel_tx, mut cancel_rx) = mpsc::channel::<()>(1);
|
||||
|
||||
let prefix = event_type_prefix.clone();
|
||||
let join_handle = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel_rx.recv() => {
|
||||
tracing::info!(prefix = %prefix, "Filtered subscription cancelled");
|
||||
break;
|
||||
}
|
||||
event = broadcast_rx.recv() => {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
if event.event_type.starts_with(&prefix) {
|
||||
if mpsc_tx.send(event).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!(prefix = %prefix, lagged = n, "Filtered subscriber lagged");
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tracing::info!(prefix = %event_type_prefix, "Filtered subscription created");
|
||||
|
||||
(
|
||||
FilteredEventReceiver { receiver: mpsc_rx },
|
||||
SubscriptionHandle {
|
||||
cancel_tx,
|
||||
join_handle,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,3 +6,6 @@ pub mod events;
|
||||
pub mod module;
|
||||
pub mod rbac;
|
||||
pub mod types;
|
||||
|
||||
// 便捷导出
|
||||
pub use module::{ModuleContext, ModuleType};
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
use std::any::Any;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppResult;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::events::EventBus;
|
||||
|
||||
/// 模块类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ModuleType {
|
||||
/// 内置模块(编译时链接)
|
||||
Builtin,
|
||||
/// 插件模块(运行时加载)
|
||||
Plugin,
|
||||
}
|
||||
|
||||
/// 模块启动上下文 — 在 on_startup 时提供给模块
|
||||
pub struct ModuleContext {
|
||||
pub db: sea_orm::DatabaseConnection,
|
||||
pub event_bus: EventBus,
|
||||
}
|
||||
|
||||
/// 模块注册接口
|
||||
/// 所有业务模块(Auth, Workflow, Message, Config, 行业模块)都实现此 trait
|
||||
#[async_trait::async_trait]
|
||||
@@ -13,11 +29,21 @@ pub trait ErpModule: Send + Sync {
|
||||
/// 模块名称(唯一标识)
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// 模块唯一 ID(默认等于 name)
|
||||
fn id(&self) -> &str {
|
||||
self.name()
|
||||
}
|
||||
|
||||
/// 模块版本
|
||||
fn version(&self) -> &str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
/// 模块类型
|
||||
fn module_type(&self) -> ModuleType {
|
||||
ModuleType::Builtin
|
||||
}
|
||||
|
||||
/// 依赖的其他模块名称
|
||||
fn dependencies(&self) -> Vec<&str> {
|
||||
vec![]
|
||||
@@ -26,6 +52,21 @@ pub trait ErpModule: Send + Sync {
|
||||
/// 注册事件处理器
|
||||
fn register_event_handlers(&self, _bus: &EventBus) {}
|
||||
|
||||
/// 模块启动钩子 — 服务启动时调用
|
||||
async fn on_startup(&self, _ctx: &ModuleContext) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 模块关闭钩子 — 服务关闭时调用
|
||||
async fn on_shutdown(&self) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 健康检查
|
||||
async fn health_check(&self) -> AppResult<serde_json::Value> {
|
||||
Ok(serde_json::json!({"status": "healthy"}))
|
||||
}
|
||||
|
||||
/// 租户创建时的初始化钩子。
|
||||
///
|
||||
/// 用于为新建租户创建默认角色、管理员用户等初始数据。
|
||||
@@ -72,7 +113,9 @@ impl ModuleRegistry {
|
||||
pub fn register(mut self, module: impl ErpModule + 'static) -> Self {
|
||||
tracing::info!(
|
||||
module = module.name(),
|
||||
id = module.id(),
|
||||
version = module.version(),
|
||||
module_type = ?module.module_type(),
|
||||
"Module registered"
|
||||
);
|
||||
let mut modules = (*self.modules).clone();
|
||||
@@ -90,4 +133,202 @@ impl ModuleRegistry {
|
||||
pub fn modules(&self) -> &[Arc<dyn ErpModule>] {
|
||||
&self.modules
|
||||
}
|
||||
|
||||
/// 按名称获取模块
|
||||
pub fn get_module(&self, name: &str) -> Option<Arc<dyn ErpModule>> {
|
||||
self.modules.iter().find(|m| m.name() == name).cloned()
|
||||
}
|
||||
|
||||
/// 按拓扑排序返回模块(依赖在前,被依赖在后)
|
||||
///
|
||||
/// 使用 Kahn 算法,环检测返回 Validation 错误。
|
||||
pub fn sorted_modules(&self) -> AppResult<Vec<Arc<dyn ErpModule>>> {
|
||||
let modules = &*self.modules;
|
||||
let n = modules.len();
|
||||
if n == 0 {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// 构建名称到索引的映射
|
||||
let name_to_idx: HashMap<&str, usize> = modules
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, m)| (m.name(), i))
|
||||
.collect();
|
||||
|
||||
// 构建邻接表和入度
|
||||
let mut adjacency: Vec<Vec<usize>> = vec![vec![]; n];
|
||||
let mut in_degree: Vec<usize> = vec![0; n];
|
||||
|
||||
for (idx, module) in modules.iter().enumerate() {
|
||||
for dep in module.dependencies() {
|
||||
if let Some(&dep_idx) = name_to_idx.get(dep) {
|
||||
adjacency[dep_idx].push(idx);
|
||||
in_degree[idx] += 1;
|
||||
}
|
||||
// 依赖未注册的模块不阻断(可能是可选依赖)
|
||||
}
|
||||
}
|
||||
|
||||
// Kahn 算法
|
||||
let mut queue: Vec<usize> = (0..n).filter(|&i| in_degree[i] == 0).collect();
|
||||
let mut sorted_indices = Vec::with_capacity(n);
|
||||
|
||||
while let Some(idx) = queue.pop() {
|
||||
sorted_indices.push(idx);
|
||||
for &next in &adjacency[idx] {
|
||||
in_degree[next] -= 1;
|
||||
if in_degree[next] == 0 {
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sorted_indices.len() != n {
|
||||
let cycle_modules: Vec<&str> = (0..n)
|
||||
.filter(|i| !sorted_indices.contains(i))
|
||||
.filter_map(|i| modules.get(i).map(|m| m.name()))
|
||||
.collect();
|
||||
return Err(AppError::Validation(format!(
|
||||
"模块依赖存在循环: {}",
|
||||
cycle_modules.join(", ")
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(sorted_indices
|
||||
.into_iter()
|
||||
.map(|i| modules[i].clone())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 按拓扑顺序启动所有模块
|
||||
pub async fn startup_all(&self, ctx: &ModuleContext) -> AppResult<()> {
|
||||
let sorted = self.sorted_modules()?;
|
||||
for module in sorted {
|
||||
tracing::info!(module = module.name(), "Starting module");
|
||||
module.on_startup(ctx).await?;
|
||||
tracing::info!(module = module.name(), "Module started");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 按拓扑逆序关闭所有模块
|
||||
pub async fn shutdown_all(&self) -> AppResult<()> {
|
||||
let sorted = self.sorted_modules()?;
|
||||
for module in sorted.into_iter().rev() {
|
||||
tracing::info!(module = module.name(), "Shutting down module");
|
||||
if let Err(e) = module.on_shutdown().await {
|
||||
tracing::error!(module = module.name(), error = %e, "Module shutdown failed");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 对所有模块执行健康检查
|
||||
pub async fn health_check_all(&self) -> Vec<(String, AppResult<serde_json::Value>)> {
|
||||
let mut results = Vec::with_capacity(self.modules.len());
|
||||
for module in self.modules.iter() {
|
||||
let result = module.health_check().await;
|
||||
results.push((module.name().to_string(), result));
|
||||
}
|
||||
results
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct TestModule {
|
||||
name: &'static str,
|
||||
deps: Vec<&'static str>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ErpModule for TestModule {
|
||||
fn name(&self) -> &str {
|
||||
self.name
|
||||
}
|
||||
fn dependencies(&self) -> Vec<&str> {
|
||||
self.deps.clone()
|
||||
}
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorted_modules_empty() {
|
||||
let registry = ModuleRegistry::new();
|
||||
let sorted = registry.sorted_modules().unwrap();
|
||||
assert!(sorted.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorted_modules_no_deps() {
|
||||
let registry = ModuleRegistry::new()
|
||||
.register(TestModule {
|
||||
name: "a",
|
||||
deps: vec![],
|
||||
})
|
||||
.register(TestModule {
|
||||
name: "b",
|
||||
deps: vec![],
|
||||
});
|
||||
let sorted = registry.sorted_modules().unwrap();
|
||||
assert_eq!(sorted.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorted_modules_with_deps() {
|
||||
let registry = ModuleRegistry::new()
|
||||
.register(TestModule {
|
||||
name: "auth",
|
||||
deps: vec![],
|
||||
})
|
||||
.register(TestModule {
|
||||
name: "plugin",
|
||||
deps: vec!["auth", "config"],
|
||||
})
|
||||
.register(TestModule {
|
||||
name: "config",
|
||||
deps: vec!["auth"],
|
||||
});
|
||||
let sorted = registry.sorted_modules().unwrap();
|
||||
let names: Vec<&str> = sorted.iter().map(|m| m.name()).collect();
|
||||
let auth_pos = names.iter().position(|&n| n == "auth").unwrap();
|
||||
let config_pos = names.iter().position(|&n| n == "config").unwrap();
|
||||
let plugin_pos = names.iter().position(|&n| n == "plugin").unwrap();
|
||||
assert!(auth_pos < config_pos);
|
||||
assert!(config_pos < plugin_pos);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorted_modules_circular_dep() {
|
||||
let registry = ModuleRegistry::new()
|
||||
.register(TestModule {
|
||||
name: "a",
|
||||
deps: vec!["b"],
|
||||
})
|
||||
.register(TestModule {
|
||||
name: "b",
|
||||
deps: vec!["a"],
|
||||
});
|
||||
let result = registry.sorted_modules();
|
||||
assert!(result.is_err());
|
||||
match result.err().unwrap() {
|
||||
AppError::Validation(msg) => assert!(msg.contains("循环")),
|
||||
other => panic!("Expected Validation, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_module_found() {
|
||||
let registry = ModuleRegistry::new().register(TestModule {
|
||||
name: "auth",
|
||||
deps: vec![],
|
||||
});
|
||||
assert!(registry.get_module("auth").is_some());
|
||||
assert!(registry.get_module("unknown").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
24
crates/erp-plugin/Cargo.toml
Normal file
24
crates/erp-plugin/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "erp-plugin"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "ERP WASM 插件运行时 — 生产级 Host API"
|
||||
|
||||
[dependencies]
|
||||
wasmtime = "43"
|
||||
wasmtime-wasi = "43"
|
||||
erp-core = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sea-orm = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
dashmap = "6"
|
||||
toml = "0.8"
|
||||
axum = { workspace = true }
|
||||
utoipa = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
33
crates/erp-plugin/src/data_dto.rs
Normal file
33
crates/erp-plugin/src/data_dto.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 插件数据记录响应
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct PluginDataResp {
|
||||
pub id: String,
|
||||
pub data: serde_json::Value,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
pub version: Option<i32>,
|
||||
}
|
||||
|
||||
/// 创建插件数据请求
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CreatePluginDataReq {
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
/// 更新插件数据请求
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdatePluginDataReq {
|
||||
pub data: serde_json::Value,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
/// 插件数据列表查询参数
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)]
|
||||
pub struct PluginDataListParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub search: Option<String>,
|
||||
}
|
||||
250
crates/erp-plugin/src/data_service.rs
Normal file
250
crates/erp-plugin/src/data_service.rs
Normal file
@@ -0,0 +1,250 @@
|
||||
use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, FromQueryResult, QueryFilter, Statement};
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppResult;
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
use crate::data_dto::PluginDataResp;
|
||||
use crate::dynamic_table::DynamicTableManager;
|
||||
use crate::entity::plugin_entity;
|
||||
use crate::error::PluginError;
|
||||
|
||||
pub struct PluginDataService;
|
||||
|
||||
impl PluginDataService {
|
||||
/// 创建插件数据
|
||||
pub async fn create(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
data: serde_json::Value,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
_event_bus: &EventBus,
|
||||
) -> AppResult<PluginDataResp> {
|
||||
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let (sql, values) =
|
||||
DynamicTableManager::build_insert_sql(&table_name, tenant_id, operator_id, &data);
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct InsertResult {
|
||||
id: Uuid,
|
||||
data: serde_json::Value,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
version: i32,
|
||||
}
|
||||
|
||||
let result = InsertResult::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| PluginError::DatabaseError("INSERT 未返回结果".to_string()))?;
|
||||
|
||||
Ok(PluginDataResp {
|
||||
id: result.id.to_string(),
|
||||
data: result.data,
|
||||
created_at: Some(result.created_at),
|
||||
updated_at: Some(result.updated_at),
|
||||
version: Some(result.version),
|
||||
})
|
||||
}
|
||||
|
||||
/// 列表查询
|
||||
pub async fn list(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
tenant_id: Uuid,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<(Vec<PluginDataResp>, u64)> {
|
||||
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
|
||||
|
||||
// Count
|
||||
let (count_sql, count_values) = DynamicTableManager::build_count_sql(&table_name, tenant_id);
|
||||
#[derive(FromQueryResult)]
|
||||
struct CountResult {
|
||||
count: i64,
|
||||
}
|
||||
let total = CountResult::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
count_sql,
|
||||
count_values,
|
||||
))
|
||||
.one(db)
|
||||
.await?
|
||||
.map(|r| r.count as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Query
|
||||
let offset = (page.saturating_sub(1)) * page_size;
|
||||
let (sql, values) = DynamicTableManager::build_query_sql(&table_name, tenant_id, page_size, offset);
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct DataRow {
|
||||
id: Uuid,
|
||||
data: serde_json::Value,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
version: i32,
|
||||
}
|
||||
|
||||
let rows = DataRow::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|r| PluginDataResp {
|
||||
id: r.id.to_string(),
|
||||
data: r.data,
|
||||
created_at: Some(r.created_at),
|
||||
updated_at: Some(r.updated_at),
|
||||
version: Some(r.version),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok((items, total))
|
||||
}
|
||||
|
||||
/// 按 ID 获取
|
||||
pub async fn get_by_id(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<PluginDataResp> {
|
||||
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let (sql, values) = DynamicTableManager::build_get_by_id_sql(&table_name, id, tenant_id);
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct DataRow {
|
||||
id: Uuid,
|
||||
data: serde_json::Value,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
version: i32,
|
||||
}
|
||||
|
||||
let row = DataRow::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| erp_core::error::AppError::NotFound("记录不存在".to_string()))?;
|
||||
|
||||
Ok(PluginDataResp {
|
||||
id: row.id.to_string(),
|
||||
data: row.data,
|
||||
created_at: Some(row.created_at),
|
||||
updated_at: Some(row.updated_at),
|
||||
version: Some(row.version),
|
||||
})
|
||||
}
|
||||
|
||||
/// 更新
|
||||
pub async fn update(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
data: serde_json::Value,
|
||||
expected_version: i32,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
_event_bus: &EventBus,
|
||||
) -> AppResult<PluginDataResp> {
|
||||
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let (sql, values) = DynamicTableManager::build_update_sql(
|
||||
&table_name,
|
||||
id,
|
||||
tenant_id,
|
||||
operator_id,
|
||||
&data,
|
||||
expected_version,
|
||||
);
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct UpdateResult {
|
||||
id: Uuid,
|
||||
data: serde_json::Value,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
version: i32,
|
||||
}
|
||||
|
||||
let result = UpdateResult::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| erp_core::error::AppError::VersionMismatch)?;
|
||||
|
||||
Ok(PluginDataResp {
|
||||
id: result.id.to_string(),
|
||||
data: result.data,
|
||||
created_at: Some(result.created_at),
|
||||
updated_at: Some(result.updated_at),
|
||||
version: Some(result.version),
|
||||
})
|
||||
}
|
||||
|
||||
/// 删除(软删除)
|
||||
pub async fn delete(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
_event_bus: &EventBus,
|
||||
) -> AppResult<()> {
|
||||
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let (sql, values) = DynamicTableManager::build_delete_sql(&table_name, id, tenant_id);
|
||||
|
||||
db.execute(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 plugin_entities 表解析 table_name(带租户隔离)
|
||||
async fn resolve_table_name(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<String> {
|
||||
let entity = plugin_entity::Entity::find()
|
||||
.filter(plugin_entity::Column::PluginId.eq(plugin_id))
|
||||
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
|
||||
.filter(plugin_entity::Column::EntityName.eq(entity_name))
|
||||
.filter(plugin_entity::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
erp_core::error::AppError::NotFound(format!(
|
||||
"插件实体 {}/{} 不存在",
|
||||
plugin_id, entity_name
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(entity.table_name)
|
||||
}
|
||||
65
crates/erp-plugin/src/dto.rs
Normal file
65
crates/erp-plugin/src/dto.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 插件信息响应
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct PluginResp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<String>,
|
||||
pub status: String,
|
||||
pub config: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub installed_at: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled_at: Option<DateTime<Utc>>,
|
||||
pub entities: Vec<PluginEntityResp>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub permissions: Option<Vec<PluginPermissionResp>>,
|
||||
pub record_version: i32,
|
||||
}
|
||||
|
||||
/// 插件实体信息
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct PluginEntityResp {
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub table_name: String,
|
||||
}
|
||||
|
||||
/// 插件权限信息
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct PluginPermissionResp {
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// 插件健康检查响应
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct PluginHealthResp {
|
||||
pub plugin_id: Uuid,
|
||||
pub status: String,
|
||||
pub details: serde_json::Value,
|
||||
}
|
||||
|
||||
/// 更新插件配置请求
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdatePluginConfigReq {
|
||||
pub config: serde_json::Value,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
/// 插件列表查询参数
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)]
|
||||
pub struct PluginListParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub status: Option<String>,
|
||||
pub search: Option<String>,
|
||||
}
|
||||
250
crates/erp-plugin/src/dynamic_table.rs
Normal file
250
crates/erp-plugin/src/dynamic_table.rs
Normal file
@@ -0,0 +1,250 @@
|
||||
use sea_orm::{ConnectionTrait, DatabaseConnection, FromQueryResult, Statement, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{PluginError, PluginResult};
|
||||
use crate::manifest::PluginEntity;
|
||||
|
||||
/// 消毒标识符:只保留 ASCII 字母、数字、下划线,防止 SQL 注入
|
||||
fn sanitize_identifier(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 动态表管理器 — 处理插件动态创建/删除的数据库表
|
||||
pub struct DynamicTableManager;
|
||||
|
||||
impl DynamicTableManager {
|
||||
/// 生成动态表名: `plugin_{sanitized_id}_{sanitized_entity}`
|
||||
pub fn table_name(plugin_id: &str, entity_name: &str) -> String {
|
||||
let sanitized_id = sanitize_identifier(plugin_id);
|
||||
let sanitized_entity = sanitize_identifier(entity_name);
|
||||
format!("plugin_{}_{}", sanitized_id, sanitized_entity)
|
||||
}
|
||||
|
||||
/// 创建动态表
|
||||
pub async fn create_table(
|
||||
db: &DatabaseConnection,
|
||||
plugin_id: &str,
|
||||
entity: &PluginEntity,
|
||||
) -> PluginResult<()> {
|
||||
let table_name = Self::table_name(plugin_id, &entity.name);
|
||||
|
||||
// 创建表
|
||||
let create_sql = format!(
|
||||
"CREATE TABLE IF NOT EXISTS \"{table_name}\" (\
|
||||
\"id\" UUID PRIMARY KEY, \
|
||||
\"tenant_id\" UUID NOT NULL, \
|
||||
\"data\" JSONB NOT NULL DEFAULT '{{}}', \
|
||||
\"created_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
|
||||
\"updated_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
|
||||
\"created_by\" UUID, \
|
||||
\"updated_by\" UUID, \
|
||||
\"deleted_at\" TIMESTAMPTZ, \
|
||||
\"version\" INT NOT NULL DEFAULT 1)"
|
||||
);
|
||||
|
||||
db.execute(Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
create_sql,
|
||||
))
|
||||
.await
|
||||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
|
||||
// 创建租户索引
|
||||
let tenant_idx_sql = format!(
|
||||
"CREATE INDEX IF NOT EXISTS \"idx_{t}_tenant\" ON \"{table_name}\" (\"tenant_id\") WHERE \"deleted_at\" IS NULL",
|
||||
t = sanitize_identifier(&table_name)
|
||||
);
|
||||
db.execute(Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
tenant_idx_sql,
|
||||
))
|
||||
.await
|
||||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
|
||||
// 为字段创建索引(使用参数化方式避免 SQL 注入)
|
||||
for field in &entity.fields {
|
||||
if field.unique || field.required {
|
||||
let sanitized_field = sanitize_identifier(&field.name);
|
||||
let idx_name = format!(
|
||||
"idx_{}_{}_{}",
|
||||
sanitize_identifier(&table_name),
|
||||
sanitized_field,
|
||||
if field.unique { "uniq" } else { "idx" }
|
||||
);
|
||||
let idx_sql = format!(
|
||||
"CREATE INDEX IF NOT EXISTS \"{idx_name}\" ON \"{table_name}\" (\"data\"->>'{sanitized_field}') WHERE \"deleted_at\" IS NULL"
|
||||
);
|
||||
db.execute(Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
idx_sql,
|
||||
))
|
||||
.await
|
||||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(table = %table_name, "Dynamic table created");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除动态表
|
||||
pub async fn drop_table(
|
||||
db: &DatabaseConnection,
|
||||
plugin_id: &str,
|
||||
entity_name: &str,
|
||||
) -> PluginResult<()> {
|
||||
let table_name = Self::table_name(plugin_id, entity_name);
|
||||
let sql = format!("DROP TABLE IF EXISTS \"{}\"", table_name);
|
||||
db.execute(Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
))
|
||||
.await
|
||||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
tracing::info!(table = %table_name, "Dynamic table dropped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查表是否存在
|
||||
pub async fn table_exists(db: &DatabaseConnection, table_name: &str) -> PluginResult<bool> {
|
||||
#[derive(FromQueryResult)]
|
||||
struct ExistsResult {
|
||||
exists: bool,
|
||||
}
|
||||
let result = ExistsResult::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = $1)",
|
||||
[table_name.into()],
|
||||
))
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
|
||||
Ok(result.map(|r| r.exists).unwrap_or(false))
|
||||
}
|
||||
|
||||
/// 构建 INSERT SQL
|
||||
pub fn build_insert_sql(
|
||||
table_name: &str,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
data: &serde_json::Value,
|
||||
) -> (String, Vec<Value>) {
|
||||
let id = Uuid::now_v7();
|
||||
Self::build_insert_sql_with_id(table_name, id, tenant_id, user_id, data)
|
||||
}
|
||||
|
||||
/// 构建 INSERT SQL(指定 ID)
|
||||
pub fn build_insert_sql_with_id(
|
||||
table_name: &str,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
data: &serde_json::Value,
|
||||
) -> (String, Vec<Value>) {
|
||||
let sql = format!(
|
||||
"INSERT INTO \"{}\" (id, tenant_id, data, created_by, updated_by, version) \
|
||||
VALUES ($1, $2, $3, $4, $5, 1) \
|
||||
RETURNING id, tenant_id, data, created_at, updated_at, version",
|
||||
table_name
|
||||
);
|
||||
let values = vec![
|
||||
id.into(),
|
||||
tenant_id.into(),
|
||||
serde_json::to_string(data).unwrap_or_default().into(),
|
||||
user_id.into(),
|
||||
user_id.into(),
|
||||
];
|
||||
(sql, values)
|
||||
}
|
||||
|
||||
/// 构建 SELECT SQL
|
||||
pub fn build_query_sql(
|
||||
table_name: &str,
|
||||
tenant_id: Uuid,
|
||||
limit: u64,
|
||||
offset: u64,
|
||||
) -> (String, Vec<Value>) {
|
||||
let sql = format!(
|
||||
"SELECT id, data, created_at, updated_at, version \
|
||||
FROM \"{}\" \
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL \
|
||||
ORDER BY created_at DESC \
|
||||
LIMIT $2 OFFSET $3",
|
||||
table_name
|
||||
);
|
||||
let values = vec![tenant_id.into(), (limit as i64).into(), (offset as i64).into()];
|
||||
(sql, values)
|
||||
}
|
||||
|
||||
/// 构建 COUNT SQL
|
||||
pub fn build_count_sql(table_name: &str, tenant_id: Uuid) -> (String, Vec<Value>) {
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*) as count FROM \"{}\" WHERE tenant_id = $1 AND deleted_at IS NULL",
|
||||
table_name
|
||||
);
|
||||
let values = vec![tenant_id.into()];
|
||||
(sql, values)
|
||||
}
|
||||
|
||||
/// 构建 UPDATE SQL(含乐观锁)
|
||||
pub fn build_update_sql(
|
||||
table_name: &str,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
data: &serde_json::Value,
|
||||
version: i32,
|
||||
) -> (String, Vec<Value>) {
|
||||
let sql = format!(
|
||||
"UPDATE \"{}\" \
|
||||
SET data = $1, updated_at = NOW(), updated_by = $2, version = version + 1 \
|
||||
WHERE id = $3 AND tenant_id = $4 AND version = $5 AND deleted_at IS NULL \
|
||||
RETURNING id, data, created_at, updated_at, version",
|
||||
table_name
|
||||
);
|
||||
let values = vec![
|
||||
serde_json::to_string(data).unwrap_or_default().into(),
|
||||
user_id.into(),
|
||||
id.into(),
|
||||
tenant_id.into(),
|
||||
version.into(),
|
||||
];
|
||||
(sql, values)
|
||||
}
|
||||
|
||||
/// 构建 DELETE SQL(软删除)
|
||||
pub fn build_delete_sql(
|
||||
table_name: &str,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> (String, Vec<Value>) {
|
||||
let sql = format!(
|
||||
"UPDATE \"{}\" \
|
||||
SET deleted_at = NOW(), updated_at = NOW() \
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL",
|
||||
table_name
|
||||
);
|
||||
let values = vec![id.into(), tenant_id.into()];
|
||||
(sql, values)
|
||||
}
|
||||
|
||||
/// 构建单条查询 SQL
|
||||
pub fn build_get_by_id_sql(
|
||||
table_name: &str,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> (String, Vec<Value>) {
|
||||
let sql = format!(
|
||||
"SELECT id, data, created_at, updated_at, version \
|
||||
FROM \"{}\" \
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL",
|
||||
table_name
|
||||
);
|
||||
let values = vec![id.into(), tenant_id.into()];
|
||||
(sql, values)
|
||||
}
|
||||
}
|
||||
664
crates/erp-plugin/src/engine.rs
Normal file
664
crates/erp-plugin/src/engine.rs
Normal file
@@ -0,0 +1,664 @@
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::Arc;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use sea_orm::{ConnectionTrait, DatabaseConnection, Statement, TransactionTrait};
|
||||
use serde_json::json;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
use wasmtime::component::{Component, HasSelf, Linker};
|
||||
use wasmtime::{Config, Engine, Store};
|
||||
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
use crate::PluginWorld;
|
||||
use crate::dynamic_table::DynamicTableManager;
|
||||
use crate::error::{PluginError, PluginResult};
|
||||
use crate::host::{HostState, PendingOp};
|
||||
use crate::manifest::PluginManifest;
|
||||
|
||||
/// 插件引擎配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PluginEngineConfig {
|
||||
/// 默认 Fuel 限制
|
||||
pub default_fuel: u64,
|
||||
/// 执行超时(秒)
|
||||
pub execution_timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for PluginEngineConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_fuel: 10_000_000,
|
||||
execution_timeout_secs: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 插件运行状态
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PluginStatus {
|
||||
/// 已加载到内存
|
||||
Loaded,
|
||||
/// 已初始化(init() 已调用)
|
||||
Initialized,
|
||||
/// 运行中(事件监听已启动)
|
||||
Running,
|
||||
/// 错误状态
|
||||
Error(String),
|
||||
/// 已禁用
|
||||
Disabled,
|
||||
}
|
||||
|
||||
/// 已加载的插件实例
|
||||
pub struct LoadedPlugin {
|
||||
pub id: String,
|
||||
pub manifest: PluginManifest,
|
||||
pub component: Component,
|
||||
pub linker: Linker<HostState>,
|
||||
pub status: RwLock<PluginStatus>,
|
||||
pub event_handles: RwLock<Vec<tokio::task::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
/// WASM 执行上下文 — 传递真实的租户和用户信息
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecutionContext {
|
||||
pub tenant_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
/// 插件引擎 — 管理所有已加载插件的 WASM 运行时
|
||||
#[derive(Clone)]
|
||||
pub struct PluginEngine {
|
||||
engine: Arc<Engine>,
|
||||
db: DatabaseConnection,
|
||||
event_bus: EventBus,
|
||||
plugins: Arc<DashMap<String, Arc<LoadedPlugin>>>,
|
||||
config: PluginEngineConfig,
|
||||
}
|
||||
|
||||
impl PluginEngine {
|
||||
/// 创建新的插件引擎
|
||||
pub fn new(
|
||||
db: DatabaseConnection,
|
||||
event_bus: EventBus,
|
||||
config: PluginEngineConfig,
|
||||
) -> PluginResult<Self> {
|
||||
let mut wasm_config = Config::new();
|
||||
wasm_config.wasm_component_model(true);
|
||||
wasm_config.consume_fuel(true);
|
||||
let engine = Engine::new(&wasm_config)
|
||||
.map_err(|e| PluginError::InstantiationError(e.to_string()))?;
|
||||
|
||||
Ok(Self {
|
||||
engine: Arc::new(engine),
|
||||
db,
|
||||
event_bus,
|
||||
plugins: Arc::new(DashMap::new()),
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
/// 加载插件到内存(不初始化)
|
||||
pub async fn load(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
wasm_bytes: &[u8],
|
||||
manifest: PluginManifest,
|
||||
) -> PluginResult<()> {
|
||||
if self.plugins.contains_key(plugin_id) {
|
||||
return Err(PluginError::AlreadyExists(plugin_id.to_string()));
|
||||
}
|
||||
|
||||
let component = Component::from_binary(&self.engine, wasm_bytes)
|
||||
.map_err(|e| PluginError::InstantiationError(e.to_string()))?;
|
||||
|
||||
let mut linker = Linker::new(&self.engine);
|
||||
// 注册 Host API 到 Linker
|
||||
PluginWorld::add_to_linker::<_, HasSelf<HostState>>(&mut linker, |state| state)
|
||||
.map_err(|e| PluginError::InstantiationError(e.to_string()))?;
|
||||
|
||||
let loaded = Arc::new(LoadedPlugin {
|
||||
id: plugin_id.to_string(),
|
||||
manifest,
|
||||
component,
|
||||
linker,
|
||||
status: RwLock::new(PluginStatus::Loaded),
|
||||
event_handles: RwLock::new(vec![]),
|
||||
});
|
||||
|
||||
self.plugins.insert(plugin_id.to_string(), loaded);
|
||||
tracing::info!(plugin_id, "Plugin loaded into memory");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 初始化插件(调用 init())
|
||||
pub async fn initialize(&self, plugin_id: &str) -> PluginResult<()> {
|
||||
let loaded = self.get_loaded(plugin_id)?;
|
||||
|
||||
// 检查状态
|
||||
{
|
||||
let status = loaded.status.read().await;
|
||||
if *status != PluginStatus::Loaded {
|
||||
return Err(PluginError::InvalidState {
|
||||
expected: "Loaded".to_string(),
|
||||
actual: format!("{:?}", *status),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let ctx = ExecutionContext {
|
||||
tenant_id: Uuid::nil(),
|
||||
user_id: Uuid::nil(),
|
||||
permissions: vec![],
|
||||
};
|
||||
|
||||
let result = self
|
||||
.execute_wasm(plugin_id, &ctx, |store, instance| {
|
||||
instance.erp_plugin_plugin_api().call_init(store)
|
||||
.map_err(|e| PluginError::ExecutionError(e.to_string()))?
|
||||
.map_err(|e| PluginError::ExecutionError(e))?;
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
*loaded.status.write().await = PluginStatus::Initialized;
|
||||
tracing::info!(plugin_id, "Plugin initialized");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
*loaded.status.write().await = PluginStatus::Error(e.to_string());
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动事件监听
|
||||
pub async fn start_event_listener(&self, plugin_id: &str) -> PluginResult<()> {
|
||||
let loaded = self.get_loaded(plugin_id)?;
|
||||
|
||||
// 检查状态
|
||||
{
|
||||
let status = loaded.status.read().await;
|
||||
if *status != PluginStatus::Initialized {
|
||||
return Err(PluginError::InvalidState {
|
||||
expected: "Initialized".to_string(),
|
||||
actual: format!("{:?}", *status),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let events_config = &loaded.manifest.events;
|
||||
if let Some(events) = events_config {
|
||||
for pattern in &events.subscribe {
|
||||
let (mut rx, sub_handle) = self.event_bus.subscribe_filtered(pattern.clone());
|
||||
let pid = plugin_id.to_string();
|
||||
let engine = self.clone();
|
||||
|
||||
let join_handle = tokio::spawn(async move {
|
||||
// sub_handle 保存在此 task 中,task 结束时自动 drop 触发优雅取消
|
||||
let _sub_guard = sub_handle;
|
||||
while let Some(event) = rx.recv().await {
|
||||
if let Err(e) = engine
|
||||
.handle_event_inner(
|
||||
&pid,
|
||||
&event.event_type,
|
||||
&event.payload,
|
||||
event.tenant_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
plugin_id = %pid,
|
||||
error = %e,
|
||||
"Plugin event handler failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
loaded.event_handles.write().await.push(join_handle);
|
||||
}
|
||||
}
|
||||
|
||||
*loaded.status.write().await = PluginStatus::Running;
|
||||
tracing::info!(plugin_id, "Plugin event listener started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 处理单个事件
|
||||
pub async fn handle_event(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
event_type: &str,
|
||||
payload: &serde_json::Value,
|
||||
tenant_id: Uuid,
|
||||
) -> PluginResult<()> {
|
||||
self.handle_event_inner(plugin_id, event_type, payload, tenant_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn handle_event_inner(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
event_type: &str,
|
||||
payload: &serde_json::Value,
|
||||
tenant_id: Uuid,
|
||||
) -> PluginResult<()> {
|
||||
let payload_bytes = serde_json::to_vec(payload).unwrap_or_default();
|
||||
let event_type = event_type.to_owned();
|
||||
|
||||
let ctx = ExecutionContext {
|
||||
tenant_id,
|
||||
user_id: Uuid::nil(),
|
||||
permissions: vec![],
|
||||
};
|
||||
|
||||
self.execute_wasm(plugin_id, &ctx, move |store, instance| {
|
||||
instance
|
||||
.erp_plugin_plugin_api()
|
||||
.call_handle_event(store, &event_type, &payload_bytes)
|
||||
.map_err(|e| PluginError::ExecutionError(e.to_string()))?
|
||||
.map_err(|e| PluginError::ExecutionError(e))?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// 租户创建时调用插件的 on_tenant_created
|
||||
pub async fn on_tenant_created(&self, plugin_id: &str, tenant_id: Uuid) -> PluginResult<()> {
|
||||
let tenant_id_str = tenant_id.to_string();
|
||||
|
||||
let ctx = ExecutionContext {
|
||||
tenant_id,
|
||||
user_id: Uuid::nil(),
|
||||
permissions: vec![],
|
||||
};
|
||||
|
||||
self.execute_wasm(plugin_id, &ctx, move |store, instance| {
|
||||
instance
|
||||
.erp_plugin_plugin_api()
|
||||
.call_on_tenant_created(store, &tenant_id_str)
|
||||
.map_err(|e| PluginError::ExecutionError(e.to_string()))?
|
||||
.map_err(|e| PluginError::ExecutionError(e))?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// 禁用插件(停止事件监听 + 更新状态)
|
||||
pub async fn disable(&self, plugin_id: &str) -> PluginResult<()> {
|
||||
let loaded = self.get_loaded(plugin_id)?;
|
||||
|
||||
// 取消所有事件监听
|
||||
let mut handles = loaded.event_handles.write().await;
|
||||
for handle in handles.drain(..) {
|
||||
handle.abort();
|
||||
}
|
||||
drop(handles);
|
||||
|
||||
*loaded.status.write().await = PluginStatus::Disabled;
|
||||
tracing::info!(plugin_id, "Plugin disabled");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从内存卸载插件
|
||||
pub async fn unload(&self, plugin_id: &str) -> PluginResult<()> {
|
||||
if self.plugins.contains_key(plugin_id) {
|
||||
self.disable(plugin_id).await.ok();
|
||||
}
|
||||
self.plugins.remove(plugin_id);
|
||||
tracing::info!(plugin_id, "Plugin unloaded");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 健康检查
|
||||
pub async fn health_check(&self, plugin_id: &str) -> PluginResult<serde_json::Value> {
|
||||
let loaded = self.get_loaded(plugin_id)?;
|
||||
let status = loaded.status.read().await;
|
||||
match &*status {
|
||||
PluginStatus::Running => Ok(json!({
|
||||
"status": "healthy",
|
||||
"plugin_id": plugin_id,
|
||||
})),
|
||||
PluginStatus::Error(e) => Ok(json!({
|
||||
"status": "error",
|
||||
"plugin_id": plugin_id,
|
||||
"error": e,
|
||||
})),
|
||||
other => Ok(json!({
|
||||
"status": "unhealthy",
|
||||
"plugin_id": plugin_id,
|
||||
"state": format!("{:?}", other),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出所有已加载插件的信息
|
||||
pub fn list_plugins(&self) -> Vec<PluginInfo> {
|
||||
self.plugins
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let loaded = entry.value();
|
||||
PluginInfo {
|
||||
id: loaded.id.clone(),
|
||||
name: loaded.manifest.metadata.name.clone(),
|
||||
version: loaded.manifest.metadata.version.clone(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 获取插件清单
|
||||
pub fn get_manifest(&self, plugin_id: &str) -> Option<PluginManifest> {
|
||||
self.plugins
|
||||
.get(plugin_id)
|
||||
.map(|entry| entry.manifest.clone())
|
||||
}
|
||||
|
||||
/// 检查插件是否正在运行
|
||||
pub async fn is_running(&self, plugin_id: &str) -> bool {
|
||||
if let Some(loaded) = self.plugins.get(plugin_id) {
|
||||
matches!(*loaded.status.read().await, PluginStatus::Running)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 恢复数据库中状态为 running/enabled 的插件。
|
||||
///
|
||||
/// 服务器重启后调用此方法,重新加载 WASM 到内存并启动事件监听。
|
||||
pub async fn recover_plugins(
|
||||
&self,
|
||||
db: &DatabaseConnection,
|
||||
) -> PluginResult<Vec<String>> {
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
use crate::entity::plugin;
|
||||
|
||||
// 查询所有运行中的插件
|
||||
let running_plugins = plugin::Entity::find()
|
||||
.filter(plugin::Column::Status.eq("running"))
|
||||
.filter(plugin::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
|
||||
let mut recovered = Vec::new();
|
||||
for model in running_plugins {
|
||||
let manifest: PluginManifest = serde_json::from_value(model.manifest_json.clone())
|
||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
let plugin_id_str = &manifest.metadata.id;
|
||||
|
||||
// 加载 WASM 到内存
|
||||
if let Err(e) = self.load(plugin_id_str, &model.wasm_binary, manifest.clone()).await {
|
||||
tracing::error!(
|
||||
plugin_id = %plugin_id_str,
|
||||
error = %e,
|
||||
"Failed to recover plugin (load)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 初始化
|
||||
if let Err(e) = self.initialize(plugin_id_str).await {
|
||||
tracing::error!(
|
||||
plugin_id = %plugin_id_str,
|
||||
error = %e,
|
||||
"Failed to recover plugin (initialize)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 启动事件监听
|
||||
if let Err(e) = self.start_event_listener(plugin_id_str).await {
|
||||
tracing::error!(
|
||||
plugin_id = %plugin_id_str,
|
||||
error = %e,
|
||||
"Failed to recover plugin (start_event_listener)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::info!(plugin_id = %plugin_id_str, "Plugin recovered");
|
||||
recovered.push(plugin_id_str.clone());
|
||||
}
|
||||
|
||||
tracing::info!(count = recovered.len(), "Plugins recovered");
|
||||
Ok(recovered)
|
||||
}
|
||||
|
||||
// ---- 内部方法 ----
|
||||
|
||||
fn get_loaded(&self, plugin_id: &str) -> PluginResult<Arc<LoadedPlugin>> {
|
||||
self.plugins
|
||||
.get(plugin_id)
|
||||
.map(|e| e.value().clone())
|
||||
.ok_or_else(|| PluginError::NotFound(plugin_id.to_string()))
|
||||
}
|
||||
|
||||
/// 在 spawn_blocking + catch_unwind + fuel + timeout 中执行 WASM 操作,
|
||||
/// 执行完成后自动刷新 pending_ops 到数据库。
|
||||
async fn execute_wasm<F, R>(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
exec_ctx: &ExecutionContext,
|
||||
operation: F,
|
||||
) -> PluginResult<R>
|
||||
where
|
||||
F: FnOnce(&mut Store<HostState>, &PluginWorld) -> PluginResult<R>
|
||||
+ Send
|
||||
+ std::panic::UnwindSafe
|
||||
+ 'static,
|
||||
R: Send + 'static,
|
||||
{
|
||||
let loaded = self.get_loaded(plugin_id)?;
|
||||
|
||||
// 创建新的 Store + HostState,使用真实的租户/用户上下文
|
||||
let state = HostState::new(
|
||||
plugin_id.to_string(),
|
||||
exec_ctx.tenant_id,
|
||||
exec_ctx.user_id,
|
||||
exec_ctx.permissions.clone(),
|
||||
);
|
||||
let mut store = Store::new(&self.engine, state);
|
||||
store
|
||||
.set_fuel(self.config.default_fuel)
|
||||
.map_err(|e| PluginError::ExecutionError(e.to_string()))?;
|
||||
store.limiter(|state| &mut state.limits);
|
||||
|
||||
// 实例化
|
||||
let instance = PluginWorld::instantiate_async(&mut store, &loaded.component, &loaded.linker)
|
||||
.await
|
||||
.map_err(|e| PluginError::InstantiationError(e.to_string()))?;
|
||||
|
||||
let timeout_secs = self.config.execution_timeout_secs;
|
||||
let pid_owned = plugin_id.to_owned();
|
||||
|
||||
// spawn_blocking 闭包执行 WASM,正常完成时收集 pending_ops
|
||||
let (result, pending_ops): (PluginResult<R>, Vec<PendingOp>) =
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(timeout_secs),
|
||||
tokio::task::spawn_blocking(move || {
|
||||
match std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
let r = operation(&mut store, &instance);
|
||||
// catch_unwind 内部不能调用 into_data(需要 &mut self),
|
||||
// 但这里 operation 已完成,store 仍可用
|
||||
let ops = std::mem::take(&mut store.data_mut().pending_ops);
|
||||
(r, ops)
|
||||
})) {
|
||||
Ok((r, ops)) => (r, ops),
|
||||
Err(_) => {
|
||||
// panic 后丢弃所有 pending_ops,避免半完成状态写入数据库
|
||||
tracing::warn!(plugin = %pid_owned, "WASM panic, discarding pending ops");
|
||||
(
|
||||
Err(PluginError::ExecutionError("WASM panic".to_string())),
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
PluginError::ExecutionError(format!("插件执行超时 ({}s)", timeout_secs))
|
||||
})?
|
||||
.map_err(|e| PluginError::ExecutionError(e.to_string()))?;
|
||||
|
||||
// 刷新写操作到数据库
|
||||
Self::flush_ops(
|
||||
&self.db,
|
||||
plugin_id,
|
||||
pending_ops,
|
||||
exec_ctx.tenant_id,
|
||||
exec_ctx.user_id,
|
||||
&self.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 刷新 HostState 中的 pending_ops 到数据库。
|
||||
///
|
||||
/// 使用事务包裹所有数据库操作确保原子性。
|
||||
/// 事件发布在事务提交后执行(best-effort)。
|
||||
pub(crate) async fn flush_ops(
|
||||
db: &DatabaseConnection,
|
||||
plugin_id: &str,
|
||||
ops: Vec<PendingOp>,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
event_bus: &EventBus,
|
||||
) -> PluginResult<()> {
|
||||
if ops.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 使用事务确保所有数据库操作的原子性
|
||||
let txn = db.begin().await.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
|
||||
for op in &ops {
|
||||
match op {
|
||||
PendingOp::Insert { id, entity, data } => {
|
||||
let table_name = DynamicTableManager::table_name(plugin_id, entity);
|
||||
let parsed_data: serde_json::Value =
|
||||
serde_json::from_slice(data).unwrap_or_default();
|
||||
let id_uuid = id.parse::<Uuid>().map_err(|e| {
|
||||
PluginError::ExecutionError(format!("无效的 ID: {}", e))
|
||||
})?;
|
||||
let (sql, values) =
|
||||
DynamicTableManager::build_insert_sql_with_id(&table_name, id_uuid, tenant_id, user_id, &parsed_data);
|
||||
txn.execute(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.await
|
||||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
|
||||
tracing::debug!(
|
||||
plugin_id,
|
||||
entity = %entity,
|
||||
"Flushed INSERT op"
|
||||
);
|
||||
}
|
||||
PendingOp::Update {
|
||||
entity,
|
||||
id,
|
||||
data,
|
||||
version,
|
||||
} => {
|
||||
let table_name = DynamicTableManager::table_name(plugin_id, entity);
|
||||
let parsed_data: serde_json::Value =
|
||||
serde_json::from_slice(data).unwrap_or_default();
|
||||
let id_uuid = id.parse::<Uuid>().map_err(|e| {
|
||||
PluginError::ExecutionError(format!("无效的 ID: {}", e))
|
||||
})?;
|
||||
let (sql, values) = DynamicTableManager::build_update_sql(
|
||||
&table_name,
|
||||
id_uuid,
|
||||
tenant_id,
|
||||
user_id,
|
||||
&parsed_data,
|
||||
*version as i32,
|
||||
);
|
||||
txn.execute(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.await
|
||||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
|
||||
tracing::debug!(
|
||||
plugin_id,
|
||||
entity = %entity,
|
||||
id = %id,
|
||||
"Flushed UPDATE op"
|
||||
);
|
||||
}
|
||||
PendingOp::Delete { entity, id } => {
|
||||
let table_name = DynamicTableManager::table_name(plugin_id, entity);
|
||||
let id_uuid = id.parse::<Uuid>().map_err(|e| {
|
||||
PluginError::ExecutionError(format!("无效的 ID: {}", e))
|
||||
})?;
|
||||
let (sql, values) =
|
||||
DynamicTableManager::build_delete_sql(&table_name, id_uuid, tenant_id);
|
||||
txn.execute(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.await
|
||||
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
|
||||
tracing::debug!(
|
||||
plugin_id,
|
||||
entity = %entity,
|
||||
id = %id,
|
||||
"Flushed DELETE op"
|
||||
);
|
||||
}
|
||||
PendingOp::PublishEvent { .. } => {
|
||||
// 事件发布在事务提交后处理
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提交事务
|
||||
txn.commit().await.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
|
||||
|
||||
// 事务提交成功后发布事件(best-effort,不阻塞主流程)
|
||||
for op in ops {
|
||||
if let PendingOp::PublishEvent { event_type, payload } = op {
|
||||
let parsed_payload: serde_json::Value =
|
||||
serde_json::from_slice(&payload).unwrap_or_default();
|
||||
let event = erp_core::events::DomainEvent::new(
|
||||
&event_type,
|
||||
tenant_id,
|
||||
parsed_payload,
|
||||
);
|
||||
event_bus.publish(event, db).await;
|
||||
|
||||
tracing::debug!(
|
||||
plugin_id,
|
||||
event_type = %event_type,
|
||||
"Flushed PUBLISH_EVENT op"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 插件信息摘要
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct PluginInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
3
crates/erp-plugin/src/entity/mod.rs
Normal file
3
crates/erp-plugin/src/entity/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod plugin;
|
||||
pub mod plugin_entity;
|
||||
pub mod plugin_event_subscription;
|
||||
54
crates/erp-plugin/src/entity/plugin.rs
Normal file
54
crates/erp-plugin/src/entity/plugin.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "plugins")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub name: String,
|
||||
#[sea_orm(column_name = "plugin_version")]
|
||||
pub plugin_version: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<String>,
|
||||
pub status: String,
|
||||
pub manifest_json: serde_json::Value,
|
||||
#[serde(skip)]
|
||||
pub wasm_binary: Vec<u8>,
|
||||
pub wasm_hash: String,
|
||||
pub config_json: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_message: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub installed_at: Option<DateTimeUtc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled_at: Option<DateTimeUtc>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::plugin_entity::Entity")]
|
||||
PluginEntity,
|
||||
#[sea_orm(has_many = "super::plugin_event_subscription::Entity")]
|
||||
PluginEventSubscription,
|
||||
}
|
||||
|
||||
impl Related<super::plugin_entity::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::PluginEntity.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
41
crates/erp-plugin/src/entity/plugin_entity.rs
Normal file
41
crates/erp-plugin/src/entity/plugin_entity.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "plugin_entities")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub plugin_id: Uuid,
|
||||
pub entity_name: String,
|
||||
pub table_name: String,
|
||||
pub schema_json: serde_json::Value,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::plugin::Entity",
|
||||
from = "Column::PluginId",
|
||||
to = "super::plugin::Column::Id"
|
||||
)]
|
||||
Plugin,
|
||||
}
|
||||
|
||||
impl Related<super::plugin::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Plugin.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
30
crates/erp-plugin/src/entity/plugin_event_subscription.rs
Normal file
30
crates/erp-plugin/src/entity/plugin_event_subscription.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "plugin_event_subscriptions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub plugin_id: Uuid,
|
||||
pub event_pattern: String,
|
||||
pub created_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::plugin::Entity",
|
||||
from = "Column::PluginId",
|
||||
to = "super::plugin::Column::Id"
|
||||
)]
|
||||
Plugin,
|
||||
}
|
||||
|
||||
impl Related<super::plugin::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Plugin.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
51
crates/erp-plugin/src/error.rs
Normal file
51
crates/erp-plugin/src/error.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use erp_core::error::AppError;
|
||||
|
||||
/// 插件模块错误类型
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PluginError {
|
||||
#[error("插件未找到: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("插件已存在: {0}")]
|
||||
AlreadyExists(String),
|
||||
|
||||
#[error("无效的插件清单: {0}")]
|
||||
InvalidManifest(String),
|
||||
|
||||
#[error("无效的插件状态: 期望 {expected}, 实际 {actual}")]
|
||||
InvalidState { expected: String, actual: String },
|
||||
|
||||
#[error("插件执行错误: {0}")]
|
||||
ExecutionError(String),
|
||||
|
||||
#[error("插件实例化错误: {0}")]
|
||||
InstantiationError(String),
|
||||
|
||||
#[error("插件 Fuel 耗尽: {0}")]
|
||||
FuelExhausted(String),
|
||||
|
||||
#[error("依赖未满足: {0}")]
|
||||
DependencyNotSatisfied(String),
|
||||
|
||||
#[error("数据库错误: {0}")]
|
||||
DatabaseError(String),
|
||||
|
||||
#[error("权限不足: {0}")]
|
||||
PermissionDenied(String),
|
||||
}
|
||||
|
||||
impl From<PluginError> for AppError {
|
||||
fn from(err: PluginError) -> Self {
|
||||
match &err {
|
||||
PluginError::NotFound(_) => AppError::NotFound(err.to_string()),
|
||||
PluginError::AlreadyExists(_) => AppError::Conflict(err.to_string()),
|
||||
PluginError::InvalidManifest(_)
|
||||
| PluginError::InvalidState { .. }
|
||||
| PluginError::DependencyNotSatisfied(_) => AppError::Validation(err.to_string()),
|
||||
PluginError::PermissionDenied(_) => AppError::Forbidden(err.to_string()),
|
||||
_ => AppError::Internal(err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type PluginResult<T> = Result<T, PluginError>;
|
||||
194
crates/erp-plugin/src/handler/data_handler.rs
Normal file
194
crates/erp-plugin/src/handler/data_handler.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::Json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::data_dto::{CreatePluginDataReq, PluginDataListParams, PluginDataResp, UpdatePluginDataReq};
|
||||
use crate::data_service::PluginDataService;
|
||||
use crate::state::PluginState;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/plugins/{plugin_id}/{entity}",
|
||||
params(PluginDataListParams),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<PluginDataResp>>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件数据"
|
||||
)]
|
||||
/// GET /api/v1/plugins/{plugin_id}/{entity} — 列表
|
||||
pub async fn list_plugin_data<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path((plugin_id, entity)): Path<(Uuid, String)>,
|
||||
Query(params): Query<PluginDataListParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<PluginDataResp>>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
|
||||
let (items, total) = PluginDataService::list(
|
||||
plugin_id,
|
||||
&entity,
|
||||
ctx.tenant_id,
|
||||
page,
|
||||
page_size,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: items,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages: (total as f64 / page_size as f64).ceil() as u64,
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/plugins/{plugin_id}/{entity}",
|
||||
request_body = CreatePluginDataReq,
|
||||
responses(
|
||||
(status = 200, description = "创建成功", body = ApiResponse<PluginDataResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件数据"
|
||||
)]
|
||||
/// POST /api/v1/plugins/{plugin_id}/{entity} — 创建
|
||||
pub async fn create_plugin_data<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path((plugin_id, entity)): Path<(Uuid, String)>,
|
||||
Json(req): Json<CreatePluginDataReq>,
|
||||
) -> Result<Json<ApiResponse<PluginDataResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
|
||||
let result = PluginDataService::create(
|
||||
plugin_id,
|
||||
&entity,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
req.data,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/plugins/{plugin_id}/{entity}/{id}",
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PluginDataResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件数据"
|
||||
)]
|
||||
/// GET /api/v1/plugins/{plugin_id}/{entity}/{id} — 详情
|
||||
pub async fn get_plugin_data<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path((plugin_id, entity, id)): Path<(Uuid, String, Uuid)>,
|
||||
) -> Result<Json<ApiResponse<PluginDataResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
|
||||
let result =
|
||||
PluginDataService::get_by_id(plugin_id, &entity, id, ctx.tenant_id, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/plugins/{plugin_id}/{entity}/{id}",
|
||||
request_body = UpdatePluginDataReq,
|
||||
responses(
|
||||
(status = 200, description = "更新成功", body = ApiResponse<PluginDataResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件数据"
|
||||
)]
|
||||
/// PUT /api/v1/plugins/{plugin_id}/{entity}/{id} — 更新
|
||||
pub async fn update_plugin_data<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path((plugin_id, entity, id)): Path<(Uuid, String, Uuid)>,
|
||||
Json(req): Json<UpdatePluginDataReq>,
|
||||
) -> Result<Json<ApiResponse<PluginDataResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
|
||||
let result = PluginDataService::update(
|
||||
plugin_id,
|
||||
&entity,
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
req.data,
|
||||
req.version,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/plugins/{plugin_id}/{entity}/{id}",
|
||||
responses(
|
||||
(status = 200, description = "删除成功"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件数据"
|
||||
)]
|
||||
/// DELETE /api/v1/plugins/{plugin_id}/{entity}/{id} — 删除
|
||||
pub async fn delete_plugin_data<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path((plugin_id, entity, id)): Path<(Uuid, String, Uuid)>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
|
||||
PluginDataService::delete(
|
||||
plugin_id,
|
||||
&entity,
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
2
crates/erp-plugin/src/handler/mod.rs
Normal file
2
crates/erp-plugin/src/handler/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod data_handler;
|
||||
pub mod plugin_handler;
|
||||
379
crates/erp-plugin/src/handler/plugin_handler.rs
Normal file
379
crates/erp-plugin/src/handler/plugin_handler.rs
Normal file
@@ -0,0 +1,379 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Multipart, Path, Query, State};
|
||||
use axum::response::Json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
|
||||
|
||||
use crate::dto::{
|
||||
PluginHealthResp, PluginListParams, PluginResp, UpdatePluginConfigReq,
|
||||
};
|
||||
use crate::service::PluginService;
|
||||
use crate::state::PluginState;
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/admin/plugins/upload",
|
||||
request_body(content_type = "multipart/form-data"),
|
||||
responses(
|
||||
(status = 200, description = "上传成功", body = ApiResponse<PluginResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// POST /api/v1/admin/plugins/upload — 上传插件 (multipart: wasm + manifest)
|
||||
pub async fn upload_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
|
||||
let mut wasm_binary: Option<Vec<u8>> = None;
|
||||
let mut manifest_toml: Option<String> = None;
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|e| {
|
||||
AppError::Validation(format!("Multipart 解析失败: {}", e))
|
||||
})? {
|
||||
let name = field.name().unwrap_or("");
|
||||
match name {
|
||||
"wasm" => {
|
||||
wasm_binary = Some(field.bytes().await.map_err(|e| {
|
||||
AppError::Validation(format!("读取 WASM 文件失败: {}", e))
|
||||
})?.to_vec());
|
||||
}
|
||||
"manifest" => {
|
||||
let text = field.text().await.map_err(|e| {
|
||||
AppError::Validation(format!("读取 Manifest 失败: {}", e))
|
||||
})?;
|
||||
manifest_toml = Some(text);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let wasm = wasm_binary.ok_or_else(|| {
|
||||
AppError::Validation("缺少 wasm 文件".to_string())
|
||||
})?;
|
||||
let manifest = manifest_toml.ok_or_else(|| {
|
||||
AppError::Validation("缺少 manifest 文件".to_string())
|
||||
})?;
|
||||
|
||||
let result = PluginService::upload(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
wasm,
|
||||
&manifest,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/admin/plugins",
|
||||
params(PluginListParams),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<PluginResp>>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// GET /api/v1/admin/plugins — 列表
|
||||
pub async fn list_plugins<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<PluginListParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<PluginResp>>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
|
||||
let pagination = Pagination {
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
};
|
||||
|
||||
let (plugins, total) = PluginService::list(
|
||||
ctx.tenant_id,
|
||||
pagination.page.unwrap_or(1),
|
||||
pagination.page_size.unwrap_or(20),
|
||||
params.status.as_deref(),
|
||||
params.search.as_deref(),
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: plugins,
|
||||
total,
|
||||
page: pagination.page.unwrap_or(1),
|
||||
page_size: pagination.page_size.unwrap_or(20),
|
||||
total_pages: (total as f64 / pagination.page_size.unwrap_or(20) as f64).ceil() as u64,
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/admin/plugins/{id}",
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PluginResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// GET /api/v1/admin/plugins/{id} — 详情
|
||||
pub async fn get_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
let result = PluginService::get_by_id(id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/admin/plugins/{id}/schema",
|
||||
responses(
|
||||
(status = 200, description = "成功"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// GET /api/v1/admin/plugins/{id}/schema — 实体 schema
|
||||
pub async fn get_plugin_schema<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
let schema = PluginService::get_schema(id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(schema)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/admin/plugins/{id}/install",
|
||||
responses(
|
||||
(status = 200, description = "安装成功", body = ApiResponse<PluginResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// POST /api/v1/admin/plugins/{id}/install — 安装
|
||||
pub async fn install_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
let result = PluginService::install(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&state.db,
|
||||
&state.engine,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/admin/plugins/{id}/enable",
|
||||
responses(
|
||||
(status = 200, description = "启用成功", body = ApiResponse<PluginResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// POST /api/v1/admin/plugins/{id}/enable — 启用
|
||||
pub async fn enable_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
let result = PluginService::enable(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&state.db,
|
||||
&state.engine,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/admin/plugins/{id}/disable",
|
||||
responses(
|
||||
(status = 200, description = "停用成功", body = ApiResponse<PluginResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// POST /api/v1/admin/plugins/{id}/disable — 停用
|
||||
pub async fn disable_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
let result = PluginService::disable(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&state.db,
|
||||
&state.engine,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/admin/plugins/{id}/uninstall",
|
||||
responses(
|
||||
(status = 200, description = "卸载成功", body = ApiResponse<PluginResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// POST /api/v1/admin/plugins/{id}/uninstall — 卸载
|
||||
pub async fn uninstall_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
let result = PluginService::uninstall(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&state.db,
|
||||
&state.engine,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/admin/plugins/{id}",
|
||||
responses(
|
||||
(status = 200, description = "清除成功"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// DELETE /api/v1/admin/plugins/{id} — 清除(软删除)
|
||||
pub async fn purge_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
PluginService::purge(id, ctx.tenant_id, ctx.user_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/admin/plugins/{id}/health",
|
||||
responses(
|
||||
(status = 200, description = "健康检查", body = ApiResponse<PluginHealthResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// GET /api/v1/admin/plugins/{id}/health — 健康检查
|
||||
pub async fn health_check_plugin<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PluginHealthResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
let result = PluginService::health_check(id, ctx.tenant_id, &state.db, &state.engine).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/admin/plugins/{id}/config",
|
||||
request_body = UpdatePluginConfigReq,
|
||||
responses(
|
||||
(status = 200, description = "更新成功", body = ApiResponse<PluginResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件管理"
|
||||
)]
|
||||
/// PUT /api/v1/admin/plugins/{id}/config — 更新配置
|
||||
pub async fn update_plugin_config<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdatePluginConfigReq>,
|
||||
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
let result = PluginService::update_config(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
req.config,
|
||||
req.version,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
170
crates/erp-plugin/src/host.rs
Normal file
170
crates/erp-plugin/src/host.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use uuid::Uuid;
|
||||
use wasmtime::StoreLimits;
|
||||
|
||||
use crate::erp::plugin::host_api;
|
||||
|
||||
/// 待刷新的写操作
|
||||
#[derive(Debug)]
|
||||
pub enum PendingOp {
|
||||
Insert {
|
||||
id: String,
|
||||
entity: String,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
Update {
|
||||
entity: String,
|
||||
id: String,
|
||||
data: Vec<u8>,
|
||||
version: i64,
|
||||
},
|
||||
Delete {
|
||||
entity: String,
|
||||
id: String,
|
||||
},
|
||||
PublishEvent {
|
||||
event_type: String,
|
||||
payload: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Host 端状态 — 绑定到每个 WASM Store 实例
|
||||
///
|
||||
/// 采用延迟执行模式:
|
||||
/// - 读操作 (db_query, config_get, current_user) → 调用前预填充
|
||||
/// - 写操作 (db_insert, db_update, db_delete, event_publish) → 入队 pending_ops
|
||||
/// - WASM 调用结束后由 engine 刷新 pending_ops 执行真实 DB 操作
|
||||
pub struct HostState {
|
||||
pub(crate) limits: StoreLimits,
|
||||
#[allow(dead_code)]
|
||||
pub(crate) tenant_id: Uuid,
|
||||
#[allow(dead_code)]
|
||||
pub(crate) user_id: Uuid,
|
||||
pub(crate) permissions: Vec<String>,
|
||||
pub(crate) plugin_id: String,
|
||||
// 预填充的读取缓存
|
||||
pub(crate) query_results: HashMap<String, Vec<u8>>,
|
||||
pub(crate) config_cache: HashMap<String, Vec<u8>>,
|
||||
pub(crate) current_user_json: Vec<u8>,
|
||||
// 待刷新的写操作
|
||||
pub(crate) pending_ops: Vec<PendingOp>,
|
||||
// 日志
|
||||
pub(crate) logs: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl HostState {
|
||||
pub fn new(
|
||||
plugin_id: String,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
permissions: Vec<String>,
|
||||
) -> Self {
|
||||
let current_user = serde_json::json!({
|
||||
"id": user_id.to_string(),
|
||||
"tenant_id": tenant_id.to_string(),
|
||||
});
|
||||
Self {
|
||||
limits: wasmtime::StoreLimitsBuilder::new().build(),
|
||||
tenant_id,
|
||||
user_id,
|
||||
permissions,
|
||||
plugin_id,
|
||||
query_results: HashMap::new(),
|
||||
config_cache: HashMap::new(),
|
||||
current_user_json: serde_json::to_vec(¤t_user).unwrap_or_default(),
|
||||
pending_ops: Vec::new(),
|
||||
logs: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 实现 bindgen 生成的 Host trait — 插件调用 Host API 的入口
|
||||
impl host_api::Host for HostState {
|
||||
fn db_insert(&mut self, entity: String, data: Vec<u8>) -> Result<Vec<u8>, String> {
|
||||
let id = Uuid::now_v7().to_string();
|
||||
let response = serde_json::json!({
|
||||
"id": id,
|
||||
"entity": entity,
|
||||
"status": "queued",
|
||||
});
|
||||
self.pending_ops.push(PendingOp::Insert {
|
||||
id: id.clone(),
|
||||
entity,
|
||||
data,
|
||||
});
|
||||
serde_json::to_vec(&response).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn db_query(
|
||||
&mut self,
|
||||
entity: String,
|
||||
_filter: Vec<u8>,
|
||||
_pagination: Vec<u8>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
self.query_results
|
||||
.get(&entity)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("实体 '{}' 的查询结果未预填充", entity))
|
||||
}
|
||||
|
||||
fn db_update(
|
||||
&mut self,
|
||||
entity: String,
|
||||
id: String,
|
||||
data: Vec<u8>,
|
||||
version: i64,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let response = serde_json::json!({
|
||||
"id": id,
|
||||
"entity": entity,
|
||||
"version": version + 1,
|
||||
"status": "queued",
|
||||
});
|
||||
self.pending_ops.push(PendingOp::Update {
|
||||
entity,
|
||||
id,
|
||||
data,
|
||||
version,
|
||||
});
|
||||
serde_json::to_vec(&response).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn db_delete(&mut self, entity: String, id: String) -> Result<(), String> {
|
||||
self.pending_ops.push(PendingOp::Delete { entity, id });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn event_publish(&mut self, event_type: String, payload: Vec<u8>) -> Result<(), String> {
|
||||
self.pending_ops.push(PendingOp::PublishEvent {
|
||||
event_type,
|
||||
payload,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn config_get(&mut self, key: String) -> Result<Vec<u8>, String> {
|
||||
self.config_cache
|
||||
.get(&key)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("配置项 '{}' 未预填充", key))
|
||||
}
|
||||
|
||||
fn log_write(&mut self, level: String, message: String) {
|
||||
tracing::info!(
|
||||
plugin = %self.plugin_id,
|
||||
level = %level,
|
||||
"Plugin log: {}",
|
||||
message
|
||||
);
|
||||
self.logs.push((level, message));
|
||||
}
|
||||
|
||||
fn current_user(&mut self) -> Result<Vec<u8>, String> {
|
||||
Ok(self.current_user_json.clone())
|
||||
}
|
||||
|
||||
fn check_permission(&mut self, permission: String) -> Result<bool, String> {
|
||||
Ok(self.permissions.contains(&permission))
|
||||
}
|
||||
}
|
||||
24
crates/erp-plugin/src/lib.rs
Normal file
24
crates/erp-plugin/src/lib.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
//! ERP WASM 插件运行时 — 生产级 Host API
|
||||
//!
|
||||
//! 完整插件管理链路:加载 → 初始化 → 运行 → 停用 → 卸载
|
||||
|
||||
// bindgen! 生成类型化绑定(包含 Host trait 和 PluginWorld 类型)
|
||||
// 生成: erp::plugin::host_api::Host trait, PluginWorld 类型
|
||||
wasmtime::component::bindgen!({
|
||||
path: "wit/plugin.wit",
|
||||
world: "plugin-world",
|
||||
});
|
||||
|
||||
pub mod data_dto;
|
||||
pub mod data_service;
|
||||
pub mod dynamic_table;
|
||||
pub mod dto;
|
||||
pub mod engine;
|
||||
pub mod entity;
|
||||
pub mod error;
|
||||
pub mod handler;
|
||||
pub mod host;
|
||||
pub mod manifest;
|
||||
pub mod module;
|
||||
pub mod service;
|
||||
pub mod state;
|
||||
262
crates/erp-plugin/src/manifest.rs
Normal file
262
crates/erp-plugin/src/manifest.rs
Normal file
@@ -0,0 +1,262 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{PluginError, PluginResult};
|
||||
|
||||
/// 插件清单 — 从 TOML 文件解析
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginManifest {
|
||||
pub metadata: PluginMetadata,
|
||||
pub schema: Option<PluginSchema>,
|
||||
pub events: Option<PluginEvents>,
|
||||
pub ui: Option<PluginUi>,
|
||||
pub permissions: Option<Vec<PluginPermission>>,
|
||||
}
|
||||
|
||||
/// 插件元数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginMetadata {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub author: String,
|
||||
#[serde(default)]
|
||||
pub min_platform_version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub dependencies: Vec<String>,
|
||||
}
|
||||
|
||||
/// 插件 Schema — 定义动态实体
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginSchema {
|
||||
pub entities: Vec<PluginEntity>,
|
||||
}
|
||||
|
||||
/// 插件实体定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginEntity {
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub fields: Vec<PluginField>,
|
||||
#[serde(default)]
|
||||
pub indexes: Vec<PluginIndex>,
|
||||
}
|
||||
|
||||
/// 插件字段定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginField {
|
||||
pub name: String,
|
||||
pub field_type: PluginFieldType,
|
||||
#[serde(default)]
|
||||
pub required: bool,
|
||||
#[serde(default)]
|
||||
pub unique: bool,
|
||||
pub default: Option<serde_json::Value>,
|
||||
pub display_name: Option<String>,
|
||||
pub ui_widget: Option<String>,
|
||||
pub options: Option<Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// 字段类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginFieldType {
|
||||
String,
|
||||
Integer,
|
||||
Float,
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
Json,
|
||||
Uuid,
|
||||
Decimal,
|
||||
}
|
||||
|
||||
/// 索引定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginIndex {
|
||||
pub name: String,
|
||||
pub fields: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub unique: bool,
|
||||
}
|
||||
|
||||
/// 事件订阅配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginEvents {
|
||||
pub subscribe: Vec<String>,
|
||||
}
|
||||
|
||||
/// UI 页面配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginUi {
|
||||
pub pages: Vec<PluginPage>,
|
||||
}
|
||||
|
||||
/// 插件页面定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginPage {
|
||||
pub route: String,
|
||||
pub entity: String,
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub icon: String,
|
||||
#[serde(default)]
|
||||
pub menu_group: Option<String>,
|
||||
}
|
||||
|
||||
/// 权限定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginPermission {
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// 从 TOML 字符串解析插件清单
|
||||
pub fn parse_manifest(toml_str: &str) -> PluginResult<PluginManifest> {
|
||||
let manifest: PluginManifest =
|
||||
toml::from_str(toml_str).map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
|
||||
// 验证必填字段
|
||||
if manifest.metadata.id.is_empty() {
|
||||
return Err(PluginError::InvalidManifest("metadata.id 不能为空".to_string()));
|
||||
}
|
||||
if manifest.metadata.name.is_empty() {
|
||||
return Err(PluginError::InvalidManifest(
|
||||
"metadata.name 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 验证实体名称
|
||||
if let Some(schema) = &manifest.schema {
|
||||
for entity in &schema.entities {
|
||||
if entity.name.is_empty() {
|
||||
return Err(PluginError::InvalidManifest(
|
||||
"entity.name 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
// 验证实体名称只包含合法字符
|
||||
if !entity
|
||||
.name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
{
|
||||
return Err(PluginError::InvalidManifest(format!(
|
||||
"entity.name '{}' 只能包含字母、数字和下划线",
|
||||
entity.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_manifest() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = "test-plugin"
|
||||
name = "测试插件"
|
||||
version = "0.1.0"
|
||||
"#;
|
||||
let manifest = parse_manifest(toml).unwrap();
|
||||
assert_eq!(manifest.metadata.id, "test-plugin");
|
||||
assert_eq!(manifest.metadata.name, "测试插件");
|
||||
assert!(manifest.schema.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_full_manifest() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = "inventory"
|
||||
name = "进销存"
|
||||
version = "1.0.0"
|
||||
description = "简单进销存管理"
|
||||
author = "ERP Team"
|
||||
|
||||
[schema]
|
||||
[[schema.entities]]
|
||||
name = "product"
|
||||
display_name = "商品"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "sku"
|
||||
field_type = "string"
|
||||
required = true
|
||||
unique = true
|
||||
display_name = "SKU 编码"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "price"
|
||||
field_type = "decimal"
|
||||
required = true
|
||||
display_name = "价格"
|
||||
|
||||
[events]
|
||||
subscribe = ["workflow.task.completed", "order.*"]
|
||||
|
||||
[ui]
|
||||
[[ui.pages]]
|
||||
route = "/products"
|
||||
entity = "product"
|
||||
display_name = "商品管理"
|
||||
icon = "ShoppingOutlined"
|
||||
menu_group = "进销存"
|
||||
|
||||
[[permissions]]
|
||||
code = "product.list"
|
||||
name = "查看商品"
|
||||
description = "查看商品列表"
|
||||
"#;
|
||||
let manifest = parse_manifest(toml).unwrap();
|
||||
assert_eq!(manifest.metadata.id, "inventory");
|
||||
let schema = manifest.schema.unwrap();
|
||||
assert_eq!(schema.entities.len(), 1);
|
||||
assert_eq!(schema.entities[0].name, "product");
|
||||
assert_eq!(schema.entities[0].fields.len(), 2);
|
||||
let events = manifest.events.unwrap();
|
||||
assert_eq!(events.subscribe.len(), 2);
|
||||
let ui = manifest.ui.unwrap();
|
||||
assert_eq!(ui.pages.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_empty_id() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = ""
|
||||
name = "测试"
|
||||
version = "0.1.0"
|
||||
"#;
|
||||
let result = parse_manifest(toml);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_invalid_entity_name() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = "test"
|
||||
name = "测试"
|
||||
version = "0.1.0"
|
||||
|
||||
[schema]
|
||||
[[schema.entities]]
|
||||
name = "my-table"
|
||||
display_name = "表格"
|
||||
"#;
|
||||
let result = parse_manifest(toml);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
83
crates/erp-plugin/src/module.rs
Normal file
83
crates/erp-plugin/src/module.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use async_trait::async_trait;
|
||||
use axum::Router;
|
||||
use axum::routing::{get, post, put};
|
||||
use erp_core::module::ErpModule;
|
||||
|
||||
pub struct PluginModule;
|
||||
|
||||
#[async_trait]
|
||||
impl ErpModule for PluginModule {
|
||||
fn name(&self) -> &str {
|
||||
"plugin"
|
||||
}
|
||||
|
||||
fn dependencies(&self) -> Vec<&str> {
|
||||
vec!["auth", "config"]
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginModule {
|
||||
/// 插件管理路由(需要 JWT 认证)
|
||||
pub fn protected_routes<S>() -> Router<S>
|
||||
where
|
||||
crate::state::PluginState: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let admin_routes = Router::new()
|
||||
.route("/admin/plugins/upload", post(crate::handler::plugin_handler::upload_plugin::<S>))
|
||||
.route("/admin/plugins", get(crate::handler::plugin_handler::list_plugins::<S>))
|
||||
.route(
|
||||
"/admin/plugins/{id}",
|
||||
get(crate::handler::plugin_handler::get_plugin::<S>)
|
||||
.delete(crate::handler::plugin_handler::purge_plugin::<S>),
|
||||
)
|
||||
.route(
|
||||
"/admin/plugins/{id}/schema",
|
||||
get(crate::handler::plugin_handler::get_plugin_schema::<S>),
|
||||
)
|
||||
.route(
|
||||
"/admin/plugins/{id}/install",
|
||||
post(crate::handler::plugin_handler::install_plugin::<S>),
|
||||
)
|
||||
.route(
|
||||
"/admin/plugins/{id}/enable",
|
||||
post(crate::handler::plugin_handler::enable_plugin::<S>),
|
||||
)
|
||||
.route(
|
||||
"/admin/plugins/{id}/disable",
|
||||
post(crate::handler::plugin_handler::disable_plugin::<S>),
|
||||
)
|
||||
.route(
|
||||
"/admin/plugins/{id}/uninstall",
|
||||
post(crate::handler::plugin_handler::uninstall_plugin::<S>),
|
||||
)
|
||||
.route(
|
||||
"/admin/plugins/{id}/health",
|
||||
get(crate::handler::plugin_handler::health_check_plugin::<S>),
|
||||
)
|
||||
.route(
|
||||
"/admin/plugins/{id}/config",
|
||||
put(crate::handler::plugin_handler::update_plugin_config::<S>),
|
||||
);
|
||||
|
||||
// 插件数据 CRUD 路由
|
||||
let data_routes = Router::new()
|
||||
.route(
|
||||
"/plugins/{plugin_id}/{entity}",
|
||||
get(crate::handler::data_handler::list_plugin_data::<S>)
|
||||
.post(crate::handler::data_handler::create_plugin_data::<S>),
|
||||
)
|
||||
.route(
|
||||
"/plugins/{plugin_id}/{entity}/{id}",
|
||||
get(crate::handler::data_handler::get_plugin_data::<S>)
|
||||
.put(crate::handler::data_handler::update_plugin_data::<S>)
|
||||
.delete(crate::handler::data_handler::delete_plugin_data::<S>),
|
||||
);
|
||||
|
||||
admin_routes.merge(data_routes)
|
||||
}
|
||||
}
|
||||
555
crates/erp-plugin/src/service.rs
Normal file
555
crates/erp-plugin/src/service.rs
Normal file
@@ -0,0 +1,555 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
use erp_core::error::AppResult;
|
||||
|
||||
use crate::dto::{
|
||||
PluginEntityResp, PluginHealthResp, PluginPermissionResp, PluginResp,
|
||||
};
|
||||
use crate::dynamic_table::DynamicTableManager;
|
||||
use crate::engine::PluginEngine;
|
||||
use crate::entity::{plugin, plugin_entity, plugin_event_subscription};
|
||||
use crate::error::PluginError;
|
||||
use crate::manifest::{parse_manifest, PluginManifest};
|
||||
|
||||
pub struct PluginService;
|
||||
|
||||
impl PluginService {
|
||||
/// 上传插件: 解析 manifest + 存储 wasm_binary + status=uploaded
|
||||
pub async fn upload(
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
wasm_binary: Vec<u8>,
|
||||
manifest_toml: &str,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<PluginResp> {
|
||||
// 解析 manifest
|
||||
let manifest = parse_manifest(manifest_toml)?;
|
||||
|
||||
// 计算 WASM hash
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&wasm_binary);
|
||||
let wasm_hash = format!("{:x}", hasher.finalize());
|
||||
|
||||
let now = Utc::now();
|
||||
let plugin_id = Uuid::now_v7();
|
||||
|
||||
// 序列化 manifest 为 JSON
|
||||
let manifest_json =
|
||||
serde_json::to_value(&manifest).map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
|
||||
let model = plugin::ActiveModel {
|
||||
id: Set(plugin_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
name: Set(manifest.metadata.name.clone()),
|
||||
plugin_version: Set(manifest.metadata.version.clone()),
|
||||
description: Set(if manifest.metadata.description.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(manifest.metadata.description.clone())
|
||||
}),
|
||||
author: Set(if manifest.metadata.author.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(manifest.metadata.author.clone())
|
||||
}),
|
||||
status: Set("uploaded".to_string()),
|
||||
manifest_json: Set(manifest_json),
|
||||
wasm_binary: Set(wasm_binary),
|
||||
wasm_hash: Set(wasm_hash),
|
||||
config_json: Set(serde_json::json!({})),
|
||||
error_message: Set(None),
|
||||
installed_at: Set(None),
|
||||
enabled_at: Set(None),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(Some(operator_id)),
|
||||
updated_by: Set(Some(operator_id)),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
|
||||
let model = model.insert(db).await?;
|
||||
|
||||
Ok(plugin_model_to_resp(&model, &manifest, vec![]))
|
||||
}
|
||||
|
||||
/// 安装插件: 创建动态表 + 注册 entity 记录 + 注册事件订阅 + status=installed
|
||||
pub async fn install(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
engine: &PluginEngine,
|
||||
) -> AppResult<PluginResp> {
|
||||
let model = find_plugin(plugin_id, tenant_id, db).await?;
|
||||
validate_status(&model.status, "uploaded")?;
|
||||
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_value(model.manifest_json.clone())
|
||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
// 创建动态表 + 注册 entity 记录
|
||||
let mut entity_resps = Vec::new();
|
||||
if let Some(schema) = &manifest.schema {
|
||||
for entity_def in &schema.entities {
|
||||
let table_name = DynamicTableManager::table_name(&manifest.metadata.id, &entity_def.name);
|
||||
|
||||
// 创建动态表
|
||||
DynamicTableManager::create_table(db, &manifest.metadata.id, entity_def).await?;
|
||||
|
||||
// 注册 entity 记录
|
||||
let entity_id = Uuid::now_v7();
|
||||
let entity_model = plugin_entity::ActiveModel {
|
||||
id: Set(entity_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
plugin_id: Set(plugin_id),
|
||||
entity_name: Set(entity_def.name.clone()),
|
||||
table_name: Set(table_name.clone()),
|
||||
schema_json: Set(serde_json::to_value(entity_def).unwrap_or_default()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(Some(operator_id)),
|
||||
updated_by: Set(Some(operator_id)),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
entity_model.insert(db).await?;
|
||||
|
||||
entity_resps.push(PluginEntityResp {
|
||||
name: entity_def.name.clone(),
|
||||
display_name: entity_def.display_name.clone(),
|
||||
table_name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 注册事件订阅
|
||||
if let Some(events) = &manifest.events {
|
||||
for pattern in &events.subscribe {
|
||||
let sub_id = Uuid::now_v7();
|
||||
let sub_model = plugin_event_subscription::ActiveModel {
|
||||
id: Set(sub_id),
|
||||
plugin_id: Set(plugin_id),
|
||||
event_pattern: Set(pattern.clone()),
|
||||
created_at: Set(now),
|
||||
};
|
||||
sub_model.insert(db).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载到内存
|
||||
engine
|
||||
.load(
|
||||
&manifest.metadata.id,
|
||||
&model.wasm_binary,
|
||||
manifest.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 更新状态
|
||||
let mut active: plugin::ActiveModel = model.into();
|
||||
active.status = Set("installed".to_string());
|
||||
active.installed_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(Some(operator_id));
|
||||
let model = active.update(db).await?;
|
||||
|
||||
Ok(plugin_model_to_resp(&model, &manifest, entity_resps))
|
||||
}
|
||||
|
||||
/// 启用插件: engine.initialize + start_event_listener + status=running
|
||||
pub async fn enable(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
engine: &PluginEngine,
|
||||
) -> AppResult<PluginResp> {
|
||||
let model = find_plugin(plugin_id, tenant_id, db).await?;
|
||||
validate_status_any(&model.status, &["installed", "disabled"])?;
|
||||
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_value(model.manifest_json.clone())
|
||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
|
||||
let plugin_manifest_id = &manifest.metadata.id;
|
||||
|
||||
// 如果之前是 disabled 状态,需要先卸载再重新加载到内存
|
||||
// (disable 只改内存状态但不从 DashMap 移除)
|
||||
if model.status == "disabled" {
|
||||
engine.unload(plugin_manifest_id).await.ok();
|
||||
engine
|
||||
.load(plugin_manifest_id, &model.wasm_binary, manifest.clone())
|
||||
.await?;
|
||||
}
|
||||
|
||||
// 初始化
|
||||
engine.initialize(plugin_manifest_id).await?;
|
||||
|
||||
// 启动事件监听
|
||||
engine.start_event_listener(plugin_manifest_id).await?;
|
||||
|
||||
let now = Utc::now();
|
||||
let mut active: plugin::ActiveModel = model.into();
|
||||
active.status = Set("running".to_string());
|
||||
active.enabled_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(Some(operator_id));
|
||||
active.error_message = Set(None);
|
||||
let model = active.update(db).await?;
|
||||
|
||||
let entity_resps = find_plugin_entities(plugin_id, tenant_id, db).await?;
|
||||
Ok(plugin_model_to_resp(&model, &manifest, entity_resps))
|
||||
}
|
||||
|
||||
/// 禁用插件: engine.disable + cancel 事件订阅 + status=disabled
|
||||
pub async fn disable(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
engine: &PluginEngine,
|
||||
) -> AppResult<PluginResp> {
|
||||
let model = find_plugin(plugin_id, tenant_id, db).await?;
|
||||
validate_status_any(&model.status, &["running", "enabled"])?;
|
||||
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_value(model.manifest_json.clone())
|
||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
|
||||
// 禁用引擎
|
||||
engine.disable(&manifest.metadata.id).await?;
|
||||
|
||||
let now = Utc::now();
|
||||
let mut active: plugin::ActiveModel = model.into();
|
||||
active.status = Set("disabled".to_string());
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(Some(operator_id));
|
||||
let model = active.update(db).await?;
|
||||
|
||||
let entity_resps = find_plugin_entities(plugin_id, tenant_id, db).await?;
|
||||
Ok(plugin_model_to_resp(&model, &manifest, entity_resps))
|
||||
}
|
||||
|
||||
/// 卸载插件: unload + 有条件地 drop 动态表 + status=uninstalled
|
||||
pub async fn uninstall(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
engine: &PluginEngine,
|
||||
) -> AppResult<PluginResp> {
|
||||
let model = find_plugin(plugin_id, tenant_id, db).await?;
|
||||
validate_status_any(&model.status, &["installed", "disabled"])?;
|
||||
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_value(model.manifest_json.clone())
|
||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
|
||||
// 卸载(如果 disabled 状态,engine 可能仍在内存中)
|
||||
engine.unload(&manifest.metadata.id).await.ok();
|
||||
|
||||
// 软删除当前租户的 entity 记录
|
||||
let now = Utc::now();
|
||||
let tenant_entities = plugin_entity::Entity::find()
|
||||
.filter(plugin_entity::Column::PluginId.eq(plugin_id))
|
||||
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
|
||||
.filter(plugin_entity::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
for entity in &tenant_entities {
|
||||
let mut active: plugin_entity::ActiveModel = entity.clone().into();
|
||||
active.deleted_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(Some(operator_id));
|
||||
active.update(db).await?;
|
||||
}
|
||||
|
||||
// 仅当没有其他租户的活跃 entity 记录引用相同的 table_name 时才 drop 表
|
||||
if let Some(schema) = &manifest.schema {
|
||||
for entity_def in &schema.entities {
|
||||
let table_name =
|
||||
DynamicTableManager::table_name(&manifest.metadata.id, &entity_def.name);
|
||||
|
||||
// 检查是否还有其他租户的活跃 entity 记录引用此表
|
||||
let other_tenants_count = plugin_entity::Entity::find()
|
||||
.filter(plugin_entity::Column::TableName.eq(&table_name))
|
||||
.filter(plugin_entity::Column::TenantId.ne(tenant_id))
|
||||
.filter(plugin_entity::Column::DeletedAt.is_null())
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
if other_tenants_count == 0 {
|
||||
// 没有其他租户使用,安全删除
|
||||
DynamicTableManager::drop_table(db, &manifest.metadata.id, &entity_def.name)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut active: plugin::ActiveModel = model.into();
|
||||
active.status = Set("uninstalled".to_string());
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(Some(operator_id));
|
||||
let model = active.update(db).await?;
|
||||
|
||||
Ok(plugin_model_to_resp(&model, &manifest, vec![]))
|
||||
}
|
||||
|
||||
/// 列表查询
|
||||
pub async fn list(
|
||||
tenant_id: Uuid,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
status: Option<&str>,
|
||||
search: Option<&str>,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<(Vec<PluginResp>, u64)> {
|
||||
let mut query = plugin::Entity::find()
|
||||
.filter(plugin::Column::TenantId.eq(tenant_id))
|
||||
.filter(plugin::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(s) = status {
|
||||
query = query.filter(plugin::Column::Status.eq(s));
|
||||
}
|
||||
if let Some(q) = search {
|
||||
query = query.filter(
|
||||
plugin::Column::Name.contains(q)
|
||||
.or(plugin::Column::Description.contains(q)),
|
||||
);
|
||||
}
|
||||
|
||||
let paginator = query
|
||||
.clone()
|
||||
.paginate(db, page_size);
|
||||
|
||||
let total = paginator.num_items().await?;
|
||||
let models = paginator
|
||||
.fetch_page(page.saturating_sub(1))
|
||||
.await?;
|
||||
|
||||
let mut resps = Vec::with_capacity(models.len());
|
||||
for model in models {
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_value(model.manifest_json.clone()).unwrap_or_else(|_| {
|
||||
PluginManifest {
|
||||
metadata: crate::manifest::PluginMetadata {
|
||||
id: String::new(),
|
||||
name: String::new(),
|
||||
version: String::new(),
|
||||
description: String::new(),
|
||||
author: String::new(),
|
||||
min_platform_version: None,
|
||||
dependencies: vec![],
|
||||
},
|
||||
schema: None,
|
||||
events: None,
|
||||
ui: None,
|
||||
permissions: None,
|
||||
}
|
||||
});
|
||||
let entities = find_plugin_entities(model.id, tenant_id, db).await.unwrap_or_default();
|
||||
resps.push(plugin_model_to_resp(&model, &manifest, entities));
|
||||
}
|
||||
|
||||
Ok((resps, total))
|
||||
}
|
||||
|
||||
/// 按 ID 获取详情
|
||||
pub async fn get_by_id(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<PluginResp> {
|
||||
let model = find_plugin(plugin_id, tenant_id, 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?;
|
||||
Ok(plugin_model_to_resp(&model, &manifest, entities))
|
||||
}
|
||||
|
||||
/// 更新配置
|
||||
pub async fn update_config(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
config: serde_json::Value,
|
||||
expected_version: i32,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<PluginResp> {
|
||||
let model = find_plugin(plugin_id, tenant_id, db).await?;
|
||||
|
||||
erp_core::error::check_version(expected_version, model.version)?;
|
||||
|
||||
let now = Utc::now();
|
||||
let mut active: plugin::ActiveModel = model.into();
|
||||
active.config_json = Set(config);
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(Some(operator_id));
|
||||
let model = active.update(db).await?;
|
||||
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_value(model.manifest_json.clone())
|
||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
let entities = find_plugin_entities(plugin_id, tenant_id, db).await.unwrap_or_default();
|
||||
Ok(plugin_model_to_resp(&model, &manifest, entities))
|
||||
}
|
||||
|
||||
/// 健康检查
|
||||
pub async fn health_check(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
engine: &PluginEngine,
|
||||
) -> AppResult<PluginHealthResp> {
|
||||
let model = find_plugin(plugin_id, tenant_id, db).await?;
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_value(model.manifest_json.clone())
|
||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
|
||||
let details = engine.health_check(&manifest.metadata.id).await?;
|
||||
|
||||
Ok(PluginHealthResp {
|
||||
plugin_id,
|
||||
status: details
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
details,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取插件 Schema
|
||||
pub async fn get_schema(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<serde_json::Value> {
|
||||
let model = find_plugin(plugin_id, tenant_id, db).await?;
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_value(model.manifest_json.clone())
|
||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
Ok(serde_json::to_value(&manifest.schema).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// 清除插件记录(软删除,仅限已卸载状态)
|
||||
pub async fn purge(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<()> {
|
||||
let model = find_plugin(plugin_id, tenant_id, db).await?;
|
||||
validate_status(&model.status, "uninstalled")?;
|
||||
let now = Utc::now();
|
||||
let mut active: plugin::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(Some(operator_id));
|
||||
active.update(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 内部辅助 ----
|
||||
|
||||
fn find_plugin(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> impl std::future::Future<Output = AppResult<plugin::Model>> + Send {
|
||||
async move {
|
||||
plugin::Entity::find_by_id(plugin_id)
|
||||
.one(db)
|
||||
.await?
|
||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||
.ok_or_else(|| {
|
||||
erp_core::error::AppError::NotFound(format!("插件 {} 不存在", plugin_id))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn find_plugin_entities(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<Vec<PluginEntityResp>> {
|
||||
let entities = plugin_entity::Entity::find()
|
||||
.filter(plugin_entity::Column::PluginId.eq(plugin_id))
|
||||
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
|
||||
.filter(plugin_entity::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(entities
|
||||
.into_iter()
|
||||
.map(|e| PluginEntityResp {
|
||||
name: e.entity_name.clone(),
|
||||
display_name: e.entity_name,
|
||||
table_name: e.table_name,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn validate_status(actual: &str, expected: &str) -> AppResult<()> {
|
||||
if actual != expected {
|
||||
return Err(PluginError::InvalidState {
|
||||
expected: expected.to_string(),
|
||||
actual: actual.to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_status_any(actual: &str, expected: &[&str]) -> AppResult<()> {
|
||||
if !expected.contains(&actual) {
|
||||
return Err(PluginError::InvalidState {
|
||||
expected: expected.join(" 或 "),
|
||||
actual: actual.to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn plugin_model_to_resp(
|
||||
model: &plugin::Model,
|
||||
manifest: &PluginManifest,
|
||||
entities: Vec<PluginEntityResp>,
|
||||
) -> PluginResp {
|
||||
let permissions = manifest.permissions.as_ref().map(|perms| {
|
||||
perms
|
||||
.iter()
|
||||
.map(|p| PluginPermissionResp {
|
||||
code: p.code.clone(),
|
||||
name: p.name.clone(),
|
||||
description: p.description.clone(),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
PluginResp {
|
||||
id: model.id,
|
||||
name: model.name.clone(),
|
||||
version: model.plugin_version.clone(),
|
||||
description: model.description.clone(),
|
||||
author: model.author.clone(),
|
||||
status: model.status.clone(),
|
||||
config: model.config_json.clone(),
|
||||
installed_at: model.installed_at,
|
||||
enabled_at: model.enabled_at,
|
||||
entities,
|
||||
permissions,
|
||||
record_version: model.version,
|
||||
}
|
||||
}
|
||||
13
crates/erp-plugin/src/state.rs
Normal file
13
crates/erp-plugin/src/state.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
use crate::engine::PluginEngine;
|
||||
|
||||
/// 插件模块共享状态 — 用于 Axum State 提取
|
||||
#[derive(Clone)]
|
||||
pub struct PluginState {
|
||||
pub db: DatabaseConnection,
|
||||
pub event_bus: EventBus,
|
||||
pub engine: PluginEngine,
|
||||
}
|
||||
48
crates/erp-plugin/wit/plugin.wit
Normal file
48
crates/erp-plugin/wit/plugin.wit
Normal file
@@ -0,0 +1,48 @@
|
||||
package erp:plugin;
|
||||
|
||||
/// 宿主暴露给插件的 API(插件 import 这些函数)
|
||||
interface host-api {
|
||||
/// 插入记录(宿主自动注入 tenant_id, id, created_at 等标准字段)
|
||||
db-insert: func(entity: string, data: list<u8>) -> result<list<u8>, string>;
|
||||
|
||||
/// 查询记录(自动注入 tenant_id 过滤 + 软删除过滤)
|
||||
db-query: func(entity: string, filter: list<u8>, pagination: list<u8>) -> result<list<u8>, string>;
|
||||
|
||||
/// 更新记录(自动检查 version 乐观锁)
|
||||
db-update: func(entity: string, id: string, data: list<u8>, version: s64) -> result<list<u8>, string>;
|
||||
|
||||
/// 软删除记录
|
||||
db-delete: func(entity: string, id: string) -> result<_, string>;
|
||||
|
||||
/// 发布领域事件
|
||||
event-publish: func(event-type: string, payload: list<u8>) -> result<_, string>;
|
||||
|
||||
/// 读取系统配置
|
||||
config-get: func(key: string) -> result<list<u8>, string>;
|
||||
|
||||
/// 写日志(自动关联 tenant_id + plugin_id)
|
||||
log-write: func(level: string, message: string);
|
||||
|
||||
/// 获取当前用户信息
|
||||
current-user: func() -> result<list<u8>, string>;
|
||||
|
||||
/// 检查当前用户权限
|
||||
check-permission: func(permission: string) -> result<bool, string>;
|
||||
}
|
||||
|
||||
/// 插件导出的 API(宿主调用这些函数)
|
||||
interface plugin-api {
|
||||
/// 插件初始化(加载时调用一次)
|
||||
init: func() -> result<_, string>;
|
||||
|
||||
/// 租户创建时调用
|
||||
on-tenant-created: func(tenant-id: string) -> result<_, string>;
|
||||
|
||||
/// 处理订阅的事件
|
||||
handle-event: func(event-type: string, payload: list<u8>) -> result<_, string>;
|
||||
}
|
||||
|
||||
world plugin-world {
|
||||
import host-api;
|
||||
export plugin-api;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ erp-auth.workspace = true
|
||||
erp-config.workspace = true
|
||||
erp-workflow.workspace = true
|
||||
erp-message.workspace = true
|
||||
erp-plugin.workspace = true
|
||||
anyhow.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
@@ -32,6 +32,8 @@ mod m20260414_000029_add_standard_fields_to_process_variables;
|
||||
mod m20260414_000032_fix_settings_unique_index_null;
|
||||
mod m20260415_000030_add_version_to_message_tables;
|
||||
mod m20260416_000031_create_domain_events;
|
||||
mod m20260417_000033_create_plugins;
|
||||
mod m20260417_000034_seed_plugin_permissions;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -71,6 +73,8 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260415_000030_add_version_to_message_tables::Migration),
|
||||
Box::new(m20260416_000031_create_domain_events::Migration),
|
||||
Box::new(m20260414_000032_fix_settings_unique_index_null::Migration),
|
||||
Box::new(m20260417_000033_create_plugins::Migration),
|
||||
Box::new(m20260417_000034_seed_plugin_permissions::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ pub struct Migration;
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 删除旧索引
|
||||
// 1. 删除旧索引
|
||||
manager
|
||||
.get_connection()
|
||||
.execute(sea_orm::Statement::from_string(
|
||||
@@ -20,13 +20,7 @@ impl MigrationTrait for Migration {
|
||||
.await
|
||||
.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
// 创建新索引,使用 COALESCE 处理 NULL scope_id
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_settings_scope_key ON settings (tenant_id, scope, COALESCE(scope_id, '00000000-0000-0000-0000-000000000000'), setting_key) WHERE deleted_at IS NULL".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
// 清理可能已存在的重复数据(保留每组最新的一条)
|
||||
// 2. 先清理可能已存在的重复数据(保留每组最新的一条)
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"
|
||||
@@ -41,6 +35,12 @@ impl MigrationTrait for Migration {
|
||||
"#.to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
// 3. 创建新索引,使用 COALESCE 处理 NULL scope_id
|
||||
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"CREATE UNIQUE INDEX idx_settings_scope_key ON settings (tenant_id, scope, COALESCE(scope_id, '00000000-0000-0000-0000-000000000000'), setting_key) WHERE deleted_at IS NULL".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 1. plugins 表 — 插件注册与生命周期
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("plugins"))
|
||||
.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("name")).string_len(200).not_null())
|
||||
.col(ColumnDef::new(Alias::new("plugin_version")).string_len(50).not_null())
|
||||
.col(ColumnDef::new(Alias::new("description")).text().null())
|
||||
.col(ColumnDef::new(Alias::new("author")).string_len(200).null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("status"))
|
||||
.string_len(20)
|
||||
.not_null()
|
||||
.default("uploaded"),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("manifest_json")).json().not_null())
|
||||
.col(ColumnDef::new(Alias::new("wasm_binary")).binary().not_null())
|
||||
.col(ColumnDef::new(Alias::new("wasm_hash")).string_len(64).not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("config_json"))
|
||||
.json()
|
||||
.not_null()
|
||||
.default(Expr::val("{}")),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("error_message")).text().null())
|
||||
.col(ColumnDef::new(Alias::new("installed_at")).timestamp_with_time_zone().null())
|
||||
.col(ColumnDef::new(Alias::new("enabled_at")).timestamp_with_time_zone().null())
|
||||
// 标准字段
|
||||
.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()),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("created_by")).uuid().null())
|
||||
.col(ColumnDef::new(Alias::new("updated_by")).uuid().null())
|
||||
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone().null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("version"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_plugins_tenant_status")
|
||||
.table(Alias::new("plugins"))
|
||||
.col(Alias::new("tenant_id"))
|
||||
.col(Alias::new("status"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_plugins_name")
|
||||
.table(Alias::new("plugins"))
|
||||
.col(Alias::new("name"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 2. plugin_entities 表 — 插件动态表注册
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("plugin_entities"))
|
||||
.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("plugin_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("entity_name")).string_len(100).not_null())
|
||||
.col(ColumnDef::new(Alias::new("table_name")).string_len(200).not_null())
|
||||
.col(ColumnDef::new(Alias::new("schema_json")).json().not_null())
|
||||
// 标准字段
|
||||
.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()),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("created_by")).uuid().null())
|
||||
.col(ColumnDef::new(Alias::new("updated_by")).uuid().null())
|
||||
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone().null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("version"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_plugin_entities_plugin")
|
||||
.table(Alias::new("plugin_entities"))
|
||||
.col(Alias::new("plugin_id"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 3. plugin_event_subscriptions 表 — 事件订阅
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("plugin_event_subscriptions"))
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("plugin_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("event_pattern")).string_len(200).not_null())
|
||||
.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()
|
||||
.name("idx_plugin_event_subs_plugin")
|
||||
.table(Alias::new("plugin_event_subscriptions"))
|
||||
.col(Alias::new("plugin_id"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Alias::new("plugin_event_subscriptions")).to_owned())
|
||||
.await?;
|
||||
manager
|
||||
.drop_table(Table::drop().table(Alias::new("plugin_entities")).to_owned())
|
||||
.await?;
|
||||
manager
|
||||
.drop_table(Table::drop().table(Alias::new("plugins")).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
/// 为已存在的租户补充 plugin 模块权限,并分配给 admin 角色。
|
||||
/// seed_tenant_auth 只在租户创建时执行,已存在的租户缺少 plugin 相关权限。
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
// 插入 plugin 权限(如果不存在)
|
||||
db.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"
|
||||
INSERT INTO permissions (id, tenant_id, code, name, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||||
SELECT gen_random_uuid(), t.id, 'plugin.admin', '插件管理', 'plugin', 'admin', '管理插件全生命周期', NOW(), NOW(), '00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000', NULL, 1
|
||||
FROM tenant t
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM permissions p WHERE p.code = 'plugin.admin' AND p.tenant_id = t.id AND p.deleted_at IS NULL
|
||||
)
|
||||
"#.to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
db.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"
|
||||
INSERT INTO permissions (id, tenant_id, code, name, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||||
SELECT gen_random_uuid(), t.id, 'plugin.list', '查看插件', 'plugin', 'list', '查看插件列表', NOW(), NOW(), '00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000', NULL, 1
|
||||
FROM tenant t
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM permissions p WHERE p.code = 'plugin.list' AND p.tenant_id = t.id AND p.deleted_at IS NULL
|
||||
)
|
||||
"#.to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
// 将 plugin 权限分配给 admin 角色(如果尚未分配)
|
||||
db.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"
|
||||
INSERT INTO role_permissions (role_id, permission_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||||
SELECT r.id, p.id, r.tenant_id, NOW(), NOW(), '00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000', NULL, 1
|
||||
FROM roles r
|
||||
JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code IN ('plugin.admin', 'plugin.list') AND p.deleted_at IS NULL
|
||||
WHERE r.code = 'admin' AND r.deleted_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp
|
||||
WHERE rp.role_id = r.id AND rp.permission_id = p.id AND rp.deleted_at IS NULL
|
||||
)
|
||||
"#.to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
// 删除 plugin 权限的角色关联
|
||||
db.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM permissions WHERE code IN ('plugin.admin', 'plugin.list')
|
||||
)
|
||||
"#.to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
// 删除 plugin 权限
|
||||
db.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"DELETE FROM permissions WHERE code IN ('plugin.admin', 'plugin.list')".to_string(),
|
||||
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -177,7 +177,7 @@ use tracing_subscriber::EnvFilter;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::module::{ErpModule, ModuleRegistry};
|
||||
use erp_core::module::{ErpModule, ModuleContext, ModuleRegistry};
|
||||
use erp_server_migration::MigratorTrait;
|
||||
use sea_orm::{ConnectionTrait, FromQueryResult};
|
||||
|
||||
@@ -310,9 +310,40 @@ async fn main() -> anyhow::Result<()> {
|
||||
"Modules registered"
|
||||
);
|
||||
|
||||
// Initialize plugin engine
|
||||
let plugin_config = erp_plugin::engine::PluginEngineConfig::default();
|
||||
let plugin_engine = erp_plugin::engine::PluginEngine::new(
|
||||
db.clone(),
|
||||
event_bus.clone(),
|
||||
plugin_config,
|
||||
)?;
|
||||
tracing::info!("Plugin engine initialized");
|
||||
|
||||
// Register plugin module
|
||||
let plugin_module = erp_plugin::module::PluginModule;
|
||||
let registry = registry.register(plugin_module);
|
||||
|
||||
// Register event handlers
|
||||
registry.register_handlers(&event_bus);
|
||||
|
||||
// Startup all modules (按拓扑顺序调用 on_startup)
|
||||
let module_ctx = ModuleContext {
|
||||
db: db.clone(),
|
||||
event_bus: event_bus.clone(),
|
||||
};
|
||||
registry.startup_all(&module_ctx).await?;
|
||||
tracing::info!("All modules started");
|
||||
|
||||
// 恢复运行中的插件(服务器重启后自动重新加载)
|
||||
match plugin_engine.recover_plugins(&db).await {
|
||||
Ok(recovered) => {
|
||||
tracing::info!(count = recovered.len(), "Plugins recovered");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to recover plugins");
|
||||
}
|
||||
}
|
||||
|
||||
// Start message event listener (workflow events → message notifications)
|
||||
erp_message::MessageModule::start_event_listener(db.clone(), event_bus.clone());
|
||||
tracing::info!("Message event listener started");
|
||||
@@ -339,6 +370,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
module_registry: registry,
|
||||
redis: redis_client.clone(),
|
||||
default_tenant_id,
|
||||
plugin_engine,
|
||||
};
|
||||
|
||||
// --- Build the router ---
|
||||
@@ -370,6 +402,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.merge(erp_config::ConfigModule::protected_routes())
|
||||
.merge(erp_workflow::WorkflowModule::protected_routes())
|
||||
.merge(erp_message::MessageModule::protected_routes())
|
||||
.merge(erp_plugin::module::PluginModule::protected_routes())
|
||||
.merge(handlers::audit_log::audit_log_router())
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
@@ -397,6 +430,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
|
||||
// 优雅关闭所有模块(按拓扑逆序)
|
||||
state.module_registry.shutdown_all().await?;
|
||||
tracing::info!("Server shutdown complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ pub struct AppState {
|
||||
pub redis: redis::Client,
|
||||
/// 实际的默认租户 ID,从数据库种子数据中获取。
|
||||
pub default_tenant_id: uuid::Uuid,
|
||||
/// 插件引擎
|
||||
pub plugin_engine: erp_plugin::engine::PluginEngine,
|
||||
}
|
||||
|
||||
/// Allow handlers to extract `DatabaseConnection` directly from `State<AppState>`.
|
||||
@@ -80,3 +82,14 @@ impl FromRef<AppState> for erp_message::MessageState {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow erp-plugin handlers to extract their required state.
|
||||
impl FromRef<AppState> for erp_plugin::state::PluginState {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
Self {
|
||||
db: state.db.clone(),
|
||||
event_bus: state.event_bus.clone(),
|
||||
engine: state.plugin_engine.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
209
dev.ps1
Normal file
209
dev.ps1
Normal file
@@ -0,0 +1,209 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
ERP dev environment startup script
|
||||
.EXAMPLE
|
||||
.\dev.ps1 # Start backend + frontend
|
||||
.\dev.ps1 -Stop # Stop all
|
||||
.\dev.ps1 -Restart # Restart all
|
||||
.\dev.ps1 -Status # Show port status
|
||||
#>
|
||||
|
||||
param(
|
||||
[switch]$Stop,
|
||||
[switch]$Restart,
|
||||
[switch]$Status
|
||||
)
|
||||
|
||||
$BackendPort = 3000
|
||||
$FrontendPort = 5174
|
||||
$LogDir = ".logs"
|
||||
|
||||
# --- find PID using port ---
|
||||
function Find-PortPid([int]$Port) {
|
||||
try {
|
||||
$c = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction Stop
|
||||
return $c[0].OwningProcess
|
||||
} catch { return $null }
|
||||
}
|
||||
|
||||
# --- kill process on port ---
|
||||
function Stop-PortProcess([int]$Port, [string]$Label) {
|
||||
$procId = Find-PortPid $Port
|
||||
if ($null -ne $procId) {
|
||||
try { $pName = (Get-Process -Id $procId -ErrorAction SilentlyContinue).ProcessName } catch { $pName = "?" }
|
||||
Write-Host (" {0,-10} port {1} used by PID {2} ({3}), killing..." -f $Label,$Port,$procId,$pName) -ForegroundColor Yellow -NoNewline
|
||||
try {
|
||||
Stop-Process -Id $procId -Force -ErrorAction Stop
|
||||
$w = 0
|
||||
while (($w -lt 5) -and (Find-PortPid $Port)) { Start-Sleep -Seconds 1; $w++ }
|
||||
if (Find-PortPid $Port) { Write-Host " still in use" -ForegroundColor Red }
|
||||
else { Write-Host " done" -ForegroundColor Green }
|
||||
} catch { Write-Host " failed" -ForegroundColor Red }
|
||||
} else {
|
||||
Write-Host (" {0,-10} port {1} free" -f $Label,$Port) -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
# --- wait for port ---
|
||||
function Wait-PortReady([int]$Port, [int]$TimeoutSeconds = 60) {
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
while ($sw.ElapsedMilliseconds -lt ($TimeoutSeconds * 1000)) {
|
||||
if (Find-PortPid $Port) { return $true }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
# --- stop all ---
|
||||
function Stop-Services {
|
||||
Write-Host ""
|
||||
Write-Host "Stopping..." -ForegroundColor Cyan
|
||||
Write-Host "--------------------------------------------------" -ForegroundColor DarkGray
|
||||
|
||||
foreach ($svc in @("backend","frontend")) {
|
||||
$pidFile = Join-Path $LogDir "$svc.pid"
|
||||
if (Test-Path $pidFile) {
|
||||
$svcId = Get-Content $pidFile -ErrorAction SilentlyContinue
|
||||
if ($svcId -and (Get-Process -Id $svcId -ErrorAction SilentlyContinue)) {
|
||||
$label = if ($svc -eq "backend") { "Backend" } else { "Frontend" }
|
||||
Write-Host " Stopping $label (PID $svcId)..." -ForegroundColor Cyan -NoNewline
|
||||
Stop-Process -Id $svcId -Force -ErrorAction SilentlyContinue
|
||||
Write-Host " done" -ForegroundColor Green
|
||||
}
|
||||
Remove-Item $pidFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
Stop-PortProcess $BackendPort "Backend"
|
||||
Stop-PortProcess $FrontendPort "Frontend"
|
||||
Write-Host ""
|
||||
Write-Host "Stopped." -ForegroundColor Green
|
||||
}
|
||||
|
||||
# --- show status ---
|
||||
function Show-Status {
|
||||
Write-Host ""
|
||||
Write-Host "Status:" -ForegroundColor Cyan
|
||||
Write-Host "--------------------------------------------------" -ForegroundColor DarkGray
|
||||
|
||||
$bp = Find-PortPid $BackendPort
|
||||
if ($null -ne $bp) {
|
||||
Write-Host " " -NoNewline; Write-Host "+" -ForegroundColor Green -NoNewline
|
||||
Write-Host " Backend port $BackendPort PID $bp"
|
||||
Write-Host " http://localhost:$BackendPort/api/v1/health" -ForegroundColor Cyan
|
||||
} else {
|
||||
Write-Host " " -NoNewline; Write-Host "-" -ForegroundColor Red -NoNewline
|
||||
Write-Host " Backend port $BackendPort stopped"
|
||||
}
|
||||
|
||||
$fp = Find-PortPid $FrontendPort
|
||||
if ($null -ne $fp) {
|
||||
Write-Host " " -NoNewline; Write-Host "+" -ForegroundColor Green -NoNewline
|
||||
Write-Host " Frontend port $FrontendPort PID $fp"
|
||||
Write-Host " http://localhost:$FrontendPort" -ForegroundColor Cyan
|
||||
} else {
|
||||
Write-Host " " -NoNewline; Write-Host "-" -ForegroundColor Red -NoNewline
|
||||
Write-Host " Frontend port $FrontendPort stopped"
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# --- start all ---
|
||||
function Start-Services {
|
||||
New-Item -ItemType Directory -Path $LogDir -Force | Out-Null
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==================================================" -ForegroundColor Cyan
|
||||
Write-Host " ERP Dev Environment Startup" -ForegroundColor Cyan
|
||||
Write-Host "==================================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# 1. clean ports
|
||||
Write-Host "[1/3] Checking ports..." -ForegroundColor Cyan
|
||||
Write-Host "--------------------------------------------------" -ForegroundColor DarkGray
|
||||
Stop-PortProcess $BackendPort "Backend"
|
||||
Stop-PortProcess $FrontendPort "Frontend"
|
||||
Write-Host ""
|
||||
|
||||
# 2. backend
|
||||
Write-Host "[2/3] Starting backend (Axum :$BackendPort)..." -ForegroundColor Cyan
|
||||
Write-Host "--------------------------------------------------" -ForegroundColor DarkGray
|
||||
|
||||
$backendLog = Join-Path $LogDir "backend.log"
|
||||
$backendErr = Join-Path $LogDir "backend.err"
|
||||
|
||||
$proc = Start-Process -FilePath "cargo" -ArgumentList "run","-p","erp-server" `
|
||||
-RedirectStandardOutput $backendLog -RedirectStandardError $backendErr `
|
||||
-WindowStyle Hidden -PassThru
|
||||
|
||||
Write-Host " PID: $($proc.Id) log: $backendLog" -ForegroundColor DarkGray
|
||||
Write-Host " Compiling & starting..." -NoNewline
|
||||
|
||||
if (Wait-PortReady $BackendPort 180) {
|
||||
Write-Host " ready" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " timeout (check $backendLog)" -ForegroundColor Yellow
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# 3. frontend
|
||||
Write-Host "[3/3] Starting frontend (Vite :$FrontendPort)..." -ForegroundColor Cyan
|
||||
Write-Host "--------------------------------------------------" -ForegroundColor DarkGray
|
||||
|
||||
$webDir = Join-Path $PSScriptRoot "apps\web"
|
||||
if (-not (Test-Path (Join-Path $webDir "node_modules"))) {
|
||||
Write-Host " Installing deps..." -ForegroundColor Yellow
|
||||
Push-Location $webDir
|
||||
pnpm install 2>&1 | ForEach-Object { Write-Host " $_" }
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
$frontendLog = Join-Path $LogDir "frontend.log"
|
||||
$frontendErr = Join-Path $LogDir "frontend.err"
|
||||
|
||||
$proc = Start-Process -FilePath "cmd.exe" `
|
||||
-ArgumentList "/c","cd /d `"$webDir`" && pnpm dev" `
|
||||
-RedirectStandardOutput $frontendLog -RedirectStandardError $frontendErr `
|
||||
-WindowStyle Hidden -PassThru
|
||||
|
||||
Write-Host " PID: $($proc.Id) log: $frontendLog" -ForegroundColor DarkGray
|
||||
Write-Host " Starting..." -NoNewline
|
||||
|
||||
if (Wait-PortReady $FrontendPort 30) {
|
||||
Write-Host " ready" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " timeout" -ForegroundColor Red
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# save PIDs (use port-based PID, not Start-Process PID which may be cmd.exe wrapper)
|
||||
$bp = Find-PortPid $BackendPort
|
||||
$fp = Find-PortPid $FrontendPort
|
||||
if ($bp) { $bp | Set-Content (Join-Path $LogDir "backend.pid") }
|
||||
if ($fp) { $fp | Set-Content (Join-Path $LogDir "frontend.pid") }
|
||||
|
||||
# done
|
||||
Write-Host "==================================================" -ForegroundColor Green
|
||||
Write-Host " All services started!" -ForegroundColor Green
|
||||
Write-Host "==================================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host " Frontend: http://localhost:$FrontendPort" -ForegroundColor Cyan
|
||||
Write-Host " Backend: http://localhost:$BackendPort/api/v1" -ForegroundColor Cyan
|
||||
Write-Host " Health: http://localhost:$BackendPort/api/v1/health" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host " Stop: .\dev.ps1 -Stop" -ForegroundColor DarkGray
|
||||
Write-Host " Restart: .\dev.ps1 -Restart" -ForegroundColor DarkGray
|
||||
Write-Host " Status: .\dev.ps1 -Status" -ForegroundColor DarkGray
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# --- entry ---
|
||||
if ($Stop) {
|
||||
Stop-Services
|
||||
} elseif ($Restart) {
|
||||
Stop-Services; Start-Sleep -Seconds 1; Start-Services
|
||||
} elseif ($Status) {
|
||||
Show-Status
|
||||
} else {
|
||||
Start-Services
|
||||
}
|
||||
702
docs/superpowers/plans/2026-04-13-wasm-plugin-system-plan.md
Normal file
702
docs/superpowers/plans/2026-04-13-wasm-plugin-system-plan.md
Normal file
@@ -0,0 +1,702 @@
|
||||
# WASM 插件系统实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为 ERP 平台引入 WASM 运行时插件系统,使行业模块可动态安装/启用/停用。
|
||||
|
||||
**Architecture:** 基础模块(auth/config/workflow/message)保持 Rust 编译时,新增 `erp-plugin-runtime` crate 封装 Wasmtime 运行时。插件通过宿主代理 API 访问数据库和事件总线,前端使用配置驱动 UI 渲染引擎自动生成 CRUD 页面。
|
||||
|
||||
**Tech Stack:** Rust + Wasmtime 27+ / WIT (wit-bindgen 0.24+) / SeaORM / Axum 0.8 / React 19 + Ant Design 6 + Zustand 5
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### 新建文件
|
||||
|
||||
```
|
||||
crates/erp-plugin-runtime/
|
||||
├── Cargo.toml
|
||||
├── wit/
|
||||
│ └── plugin.wit # WIT 接口定义
|
||||
└── src/
|
||||
├── lib.rs # crate 入口
|
||||
├── manifest.rs # plugin.toml 解析
|
||||
├── engine.rs # Wasmtime 引擎封装
|
||||
├── host_api.rs # 宿主 API(db/event/config/log)
|
||||
├── loader.rs # 插件加载器
|
||||
├── schema.rs # 动态建表逻辑
|
||||
├── error.rs # 插件错误类型
|
||||
└── wasm_module.rs # ErpModule trait 的 WASM 适配器
|
||||
|
||||
crates/erp-server/migration/src/
|
||||
└── m20260413_000032_create_plugins_table.rs # plugins + plugin_schema_versions 表
|
||||
|
||||
crates/erp-server/src/
|
||||
└── handlers/
|
||||
└── plugin.rs # 插件管理 + 数据 CRUD handler
|
||||
|
||||
apps/web/src/
|
||||
├── api/
|
||||
│ └── plugins.ts # 插件 API service
|
||||
├── stores/
|
||||
│ └── plugin.ts # PluginStore (Zustand)
|
||||
├── pages/
|
||||
│ ├── PluginAdmin.tsx # 插件管理页面
|
||||
│ └── PluginCRUDPage.tsx # 通用 CRUD 渲染引擎
|
||||
└── components/
|
||||
└── DynamicMenu.tsx # 动态菜单组件
|
||||
```
|
||||
|
||||
### 修改文件
|
||||
|
||||
```
|
||||
Cargo.toml # 添加 erp-plugin-runtime workspace member
|
||||
crates/erp-core/src/module.rs # 升级 ErpModule trait v2
|
||||
crates/erp-core/src/events.rs # 添加 subscribe_filtered
|
||||
crates/erp-core/src/lib.rs # 导出新类型
|
||||
crates/erp-auth/src/module.rs # 迁移到 v2 trait
|
||||
crates/erp-config/src/module.rs # 迁移到 v2 trait
|
||||
crates/erp-workflow/src/module.rs # 迁移到 v2 trait
|
||||
crates/erp-message/src/module.rs # 迁移到 v2 trait
|
||||
crates/erp-server/src/main.rs # 使用新注册系统 + 加载 WASM 插件
|
||||
crates/erp-server/src/state.rs # 添加 PluginState
|
||||
crates/erp-server/migration/src/lib.rs # 注册新迁移
|
||||
apps/web/src/App.tsx # 添加动态路由 + PluginAdmin 路由
|
||||
apps/web/src/layouts/MainLayout.tsx # 使用 DynamicMenu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: ErpModule Trait v2 迁移 + EventBus 扩展
|
||||
|
||||
### Task 1: 升级 ErpModule trait
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-core/src/module.rs`
|
||||
- Modify: `crates/erp-core/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: 升级 ErpModule trait — 添加新方法(全部有默认实现)**
|
||||
|
||||
在 `crates/erp-core/src/module.rs` 中,保留所有现有方法签名不变,追加新方法:
|
||||
|
||||
```rust
|
||||
use std::collections::HashMap;
|
||||
|
||||
// 新增类型
|
||||
pub enum ModuleType {
|
||||
Native,
|
||||
Wasm,
|
||||
}
|
||||
|
||||
pub struct ModuleHealth {
|
||||
pub status: String,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ModuleContext {
|
||||
pub db: sea_orm::DatabaseConnection,
|
||||
pub event_bus: crate::events::EventBus,
|
||||
pub config: Arc<serde_json::Value>,
|
||||
}
|
||||
|
||||
// 在 ErpModule trait 中追加(不改现有方法):
|
||||
fn id(&self) -> &str { self.name() } // 默认等于 name
|
||||
fn module_type(&self) -> ModuleType { ModuleType::Native }
|
||||
async fn on_startup(&self, _ctx: &ModuleContext) -> crate::error::AppResult<()> { Ok(()) }
|
||||
async fn on_shutdown(&self) -> crate::error::AppResult<()> { Ok(()) }
|
||||
async fn health_check(&self) -> crate::error::AppResult<ModuleHealth> {
|
||||
Ok(ModuleHealth { status: "ok".into(), details: None })
|
||||
}
|
||||
fn public_routes(&self) -> Option<axum::Router> { None } // 需要 axum 依赖
|
||||
fn protected_routes(&self) -> Option<axum::Router> { None }
|
||||
fn migrations(&self) -> Vec<Box<dyn sea_orm_migration::MigrationTrait>> { vec![] }
|
||||
fn config_schema(&self) -> Option<serde_json::Value> { None }
|
||||
```
|
||||
|
||||
> **注意:** `on_tenant_created/deleted` 的签名暂不改动(加 ctx 参数是破坏性变更),在 Task 2 中单独处理。
|
||||
|
||||
- [ ] **Step 2: 升级 ModuleRegistry — 添加索引 + 拓扑排序 + build_routes**
|
||||
|
||||
在同一个文件中扩展 `ModuleRegistry`:
|
||||
|
||||
```rust
|
||||
impl ModuleRegistry {
|
||||
pub fn get_module(&self, id: &str) -> Option<&Arc<dyn ErpModule>> { ... }
|
||||
pub fn build_routes(&self) -> (axum::Router, axum::Router) {
|
||||
// 遍历 modules,收集 public_routes + protected_routes
|
||||
}
|
||||
fn topological_sort(&self) -> crate::error::AppResult<Vec<Arc<dyn ErpModule>>> {
|
||||
// 基于 dependencies() 的 Kahn 算法拓扑排序
|
||||
}
|
||||
pub async fn startup_all(&self, ctx: &ModuleContext) -> crate::error::AppResult<()> {
|
||||
// 按拓扑顺序调用 on_startup
|
||||
}
|
||||
pub async fn health_check_all(&self) -> HashMap<String, ModuleHealth> { ... }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 更新 lib.rs 导出**
|
||||
|
||||
`crates/erp-core/src/lib.rs` 追加:
|
||||
```rust
|
||||
pub use module::{ModuleType, ModuleHealth, ModuleContext};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 更新 erp-core Cargo.toml 添加 axum 依赖**
|
||||
|
||||
`crates/erp-core/Cargo.toml` 的 `[dependencies]` 添加:
|
||||
```toml
|
||||
axum = { workspace = true }
|
||||
sea-orm-migration = { workspace = true }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行 `cargo check --workspace` 确保现有模块编译通过(所有新方法有默认实现)**
|
||||
|
||||
- [ ] **Step 6: 迁移四个现有模块的 routes**
|
||||
|
||||
对 `erp-auth/src/module.rs`、`erp-config/src/module.rs`、`erp-workflow/src/module.rs`、`erp-message/src/module.rs`:
|
||||
- 将 `pub fn public_routes<S>()` 关联函数改为 `fn public_routes(&self) -> Option<Router>` trait 方法
|
||||
- 同样处理 `protected_routes`
|
||||
- 添加 `fn id()` 返回与 `name()` 相同值
|
||||
|
||||
每个模块的改动模式:
|
||||
```rust
|
||||
// 之前: pub fn public_routes<S>() -> Router<S> where ... { Router::new().route(...) }
|
||||
// 之后:
|
||||
fn public_routes(&self) -> Option<axum::Router> {
|
||||
Some(axum::Router::new().route("/auth/login", axum::routing::post(auth_handler::login)).route("/auth/refresh", axum::routing::post(auth_handler::refresh)))
|
||||
}
|
||||
fn protected_routes(&self) -> Option<axum::Router> { Some(...) }
|
||||
fn id(&self) -> &str { "auth" } // 与 name() 相同
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 更新 main.rs 使用 build_routes**
|
||||
|
||||
`crates/erp-server/src/main.rs`:
|
||||
```rust
|
||||
// 替换手动 merge 为:
|
||||
let (public_mod, protected_mod) = registry.build_routes();
|
||||
let public_routes = Router::new()
|
||||
.merge(handlers::health::health_check_router())
|
||||
.merge(public_mod) // 替代 erp_auth::AuthModule::public_routes()
|
||||
.route("/docs/openapi.json", ...)
|
||||
...;
|
||||
let protected_routes = protected_mod // 替代手动 merge 四个模块
|
||||
.merge(handlers::audit_log::audit_log_router())
|
||||
...;
|
||||
```
|
||||
|
||||
- [ ] **Step 8: 运行 `cargo check --workspace` 确认全 workspace 编译通过**
|
||||
|
||||
- [ ] **Step 9: 运行 `cargo test --workspace` 确认测试通过**
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```
|
||||
feat(core): upgrade ErpModule trait v2 with lifecycle hooks, route methods, and auto-collection
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: EventBus subscribe_filtered 扩展
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-core/src/events.rs`
|
||||
|
||||
- [ ] **Step 1: 添加类型化订阅支持**
|
||||
|
||||
在 `events.rs` 中扩展 `EventBus`:
|
||||
|
||||
```rust
|
||||
use std::sync::RwLock;
|
||||
|
||||
pub type EventHandler = Box<dyn Fn(DomainEvent) + Send + Sync>;
|
||||
pub type SubscriptionId = Uuid;
|
||||
|
||||
pub struct EventBus {
|
||||
sender: broadcast::Sender<DomainEvent>,
|
||||
handlers: Arc<RwLock<HashMap<String, Vec<(SubscriptionId, EventHandler)>>>>,
|
||||
}
|
||||
|
||||
impl EventBus {
|
||||
pub fn subscribe_filtered(
|
||||
&self,
|
||||
event_type: &str,
|
||||
handler: EventHandler,
|
||||
) -> SubscriptionId {
|
||||
let id = Uuid::now_v7();
|
||||
let mut handlers = self.handlers.write().unwrap();
|
||||
handlers.entry(event_type.to_string())
|
||||
.or_default()
|
||||
.push((id, handler));
|
||||
id
|
||||
}
|
||||
|
||||
pub fn unsubscribe(&self, id: SubscriptionId) {
|
||||
let mut handlers = self.handlers.write().unwrap();
|
||||
for (_, list) in handlers.iter_mut() {
|
||||
list.retain(|(sid, _)| *sid != id);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
修改 `broadcast()` 方法,在广播时同时分发给 `handlers` 中匹配的处理器。
|
||||
|
||||
- [ ] **Step 2: 运行 `cargo test --workspace`**
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(core): add typed event subscription to EventBus
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: 数据库迁移 + erp-plugin-runtime Crate
|
||||
|
||||
### Task 3: 插件数据库表迁移
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/migration/src/m20260413_000032_create_plugins_table.rs`
|
||||
- Modify: `crates/erp-server/migration/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: 编写迁移文件**
|
||||
|
||||
创建 `plugins`、`plugin_schema_versions`、`plugin_event_subscriptions` 三张表(DDL 参见 spec §7.1)。
|
||||
|
||||
- [ ] **Step 2: 注册到 lib.rs 的迁移列表**
|
||||
|
||||
- [ ] **Step 3: 运行 `cargo run -p erp-server` 验证迁移执行**
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
feat(db): add plugins, plugin_schema_versions, and plugin_event_subscriptions tables
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 创建 erp-plugin-runtime crate 骨架
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-runtime/Cargo.toml`
|
||||
- Create: `crates/erp-plugin-runtime/src/lib.rs`
|
||||
- Create: `crates/erp-plugin-runtime/src/error.rs`
|
||||
- Create: `crates/erp-plugin-runtime/src/manifest.rs`
|
||||
- Modify: `Cargo.toml` (workspace members)
|
||||
|
||||
- [ ] **Step 1: 创建 Cargo.toml**
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-runtime"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
erp-core = { workspace = true }
|
||||
wasmtime = "27"
|
||||
wasmtime-wasi = "27"
|
||||
wit-bindgen = "0.24"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml = "0.8"
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
sea-orm = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 更新根 Cargo.toml workspace members + dependencies**
|
||||
|
||||
- [ ] **Step 3: 实现 manifest.rs — PluginManifest 类型 + 解析**
|
||||
|
||||
定义 `PluginManifest`、`PluginInfo`、`PermissionSet`、`EntityDef`、`FieldDef`、`PageDef` 等结构体,实现 `fn parse(toml_str: &str) -> Result<PluginManifest>`。
|
||||
|
||||
- [ ] **Step 4: 实现 error.rs**
|
||||
|
||||
```rust
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PluginError {
|
||||
#[error("Manifest 解析失败: {0}")]
|
||||
ManifestParse(String),
|
||||
#[error("WASM 加载失败: {0}")]
|
||||
WasmLoad(String),
|
||||
#[error("Host API 错误: {0}")]
|
||||
HostApi(String),
|
||||
#[error("插件未找到: {0}")]
|
||||
NotFound(String),
|
||||
#[error("依赖未满足: {0}")]
|
||||
DependencyUnmet(String),
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 实现 lib.rs — crate 入口 + re-exports**
|
||||
|
||||
- [ ] **Step 6: 运行 `cargo check --workspace`**
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```
|
||||
feat(plugin): create erp-plugin-runtime crate with manifest parsing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: WIT 接口定义
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-runtime/wit/plugin.wit`
|
||||
|
||||
- [ ] **Step 1: 编写 WIT 文件**
|
||||
|
||||
参见 spec 附录 D.1 的完整 `plugin.wit` 内容(host interface + plugin interface + plugin-world)。
|
||||
|
||||
- [ ] **Step 2: 验证 WIT 语法**
|
||||
|
||||
```bash
|
||||
cargo install wit-bindgen-cli
|
||||
wit-bindgen rust ./crates/erp-plugin-runtime/wit/plugin.wit --out-dir /tmp/test-bindgen
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(plugin): define WIT interface for host-plugin contract
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Host API + 插件加载器
|
||||
|
||||
### Task 6: 实现 Host API 层
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-runtime/src/host_api.rs`
|
||||
|
||||
- [ ] **Step 1: 实现 PluginHostState 结构体**
|
||||
|
||||
持有 `db`、`tenant_id`、`plugin_id`、`event_bus` 等上下文。实现 `db_insert`、`db_query`、`db_update`、`db_delete`、`db_aggregate` 方法,每个方法都:
|
||||
1. 自动注入 `tenant_id` 过滤
|
||||
2. 自动注入标准字段(id, created_at 等)
|
||||
3. 参数化 SQL 防注入
|
||||
4. 自动审计日志
|
||||
|
||||
- [ ] **Step 2: 注册为 Wasmtime host functions**
|
||||
|
||||
使用 `wasmtime::Linker::func_wrap` 将 host_api 方法注册到 WASM 实例。
|
||||
|
||||
- [ ] **Step 3: 编写单元测试**
|
||||
|
||||
使用 mock 数据库测试 db_insert 自动注入 tenant_id、db_query 自动过滤。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
feat(plugin): implement host API layer with tenant isolation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 实现插件加载器 + 动态建表
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-runtime/src/engine.rs`
|
||||
- Create: `crates/erp-plugin-runtime/src/loader.rs`
|
||||
- Create: `crates/erp-plugin-runtime/src/schema.rs`
|
||||
- Create: `crates/erp-plugin-runtime/src/wasm_module.rs`
|
||||
|
||||
- [ ] **Step 1: engine.rs — Wasmtime Engine 封装**
|
||||
|
||||
单例 Engine + Store 工厂方法,配置内存限制(64MB 默认)、fuel 消耗限制。
|
||||
|
||||
- [ ] **Step 2: schema.rs — 从 manifest 动态建表**
|
||||
|
||||
`create_entity_table(db, entity_def)` 函数:生成 `CREATE TABLE IF NOT EXISTS plugin_{name} (...)` SQL,包含所有标准字段 + tenant_id 索引。
|
||||
|
||||
- [ ] **Step 3: loader.rs — 从数据库加载 + 实例化**
|
||||
|
||||
`load_plugins(db, engine, event_bus) -> Vec<LoadedPlugin>`:查询 `plugins` 表中 status=enabled 的记录,实例化 WASM,调用 init(),注册事件处理器。
|
||||
|
||||
- [ ] **Step 4: wasm_module.rs — WasmModule(实现 ErpModule trait)**
|
||||
|
||||
包装 WASM 实例,实现 ErpModule trait 的各方法(调用 WASM 导出函数)。
|
||||
|
||||
- [ ] **Step 5: 集成测试**
|
||||
|
||||
测试完整的 load → init → db_insert → db_query 流程(使用真实 PostgreSQL)。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```
|
||||
feat(plugin): implement plugin loader with dynamic schema creation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 插件管理 API + 数据 CRUD API
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/src/handlers/plugin.rs`
|
||||
- Modify: `crates/erp-server/src/main.rs`
|
||||
- Modify: `crates/erp-server/src/state.rs`
|
||||
|
||||
- [ ] **Step 1: 实现 plugin handler**
|
||||
|
||||
上传(解析 plugin.toml + 存储 wasm_binary)、列表、详情、启用(建表+写状态)、停用、卸载(软删除)。
|
||||
|
||||
- [ ] **Step 2: 实现插件数据 CRUD**
|
||||
|
||||
`GET/POST/PUT/DELETE /api/v1/plugins/{plugin_id}/{entity}` — 动态路由,从 manifest 查找 entity,调用 host_api 执行操作。
|
||||
|
||||
- [ ] **Step 3: 注册路由到 main.rs**
|
||||
|
||||
- [ ] **Step 4: 添加 PluginState 到 state.rs**
|
||||
|
||||
```rust
|
||||
impl FromRef<AppState> for erp_plugin_runtime::PluginState { ... }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行 `cargo test --workspace`**
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```
|
||||
feat(server): add plugin management and dynamic CRUD API endpoints
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: 前端配置驱动 UI
|
||||
|
||||
### Task 9: PluginStore + API Service
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/api/plugins.ts`
|
||||
- Create: `apps/web/src/stores/plugin.ts`
|
||||
|
||||
- [ ] **Step 1: plugins.ts API service**
|
||||
|
||||
接口类型定义 + API 函数:`listPlugins`、`getPlugin`、`uploadPlugin`、`enablePlugin`、`disablePlugin`、`uninstallPlugin`、`getPluginConfig`、`updatePluginConfig`、`getPluginData`、`createPluginData`、`updatePluginData`、`deletePluginData`。
|
||||
|
||||
- [ ] **Step 2: plugin.ts PluginStore**
|
||||
|
||||
```typescript
|
||||
interface PluginStore {
|
||||
plugins: PluginInfo[];
|
||||
loading: boolean;
|
||||
fetchPlugins(): Promise<void>;
|
||||
getPageConfigs(): PluginPageConfig[];
|
||||
}
|
||||
```
|
||||
|
||||
启动时调用 `fetchPlugins()` 加载已启用插件列表及页面配置。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(web): add plugin API service and PluginStore
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: PluginCRUDPage 通用渲染引擎
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/pages/PluginCRUDPage.tsx`
|
||||
|
||||
- [ ] **Step 1: 实现 PluginCRUDPage 组件**
|
||||
|
||||
接收 `PluginPageConfig` 作为 props,渲染:
|
||||
- **SearchBar**: 从 `filters` 配置生成 Ant Design Form.Item 搜索条件
|
||||
- **DataTable**: 从 `columns` 配置生成 Ant Design Table 列
|
||||
- **FormDialog**: 从 `form` 配置或自动推导的 `schema.entities` 字段生成新建/编辑 Modal 表单
|
||||
- **ActionBar**: 从 `actions` 配置生成操作按钮
|
||||
|
||||
API 调用统一走 `/api/v1/plugins/{plugin_id}/{entity}` 路径。
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```
|
||||
feat(web): implement PluginCRUDPage config-driven rendering engine
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: 动态路由 + 动态菜单
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/components/DynamicMenu.tsx`
|
||||
- Modify: `apps/web/src/App.tsx`
|
||||
- Modify: `apps/web/src/layouts/MainLayout.tsx`
|
||||
|
||||
- [ ] **Step 1: DynamicMenu 组件**
|
||||
|
||||
从 `usePluginStore` 读取 `getPageConfigs()`,按 `menu_group` 分组生成 Ant Design Menu.Item,追加到侧边栏。
|
||||
|
||||
- [ ] **Step 2: App.tsx 添加动态路由**
|
||||
|
||||
在 private routes 中,遍历 PluginStore 的 pageConfigs,为每个 CRUD 页面生成:
|
||||
```tsx
|
||||
<Route path={page.path} element={<PluginCRUDPage config={page} />} />
|
||||
```
|
||||
同时添加 `/plugin-admin` 路由指向 `PluginAdmin` 页面。
|
||||
|
||||
- [ ] **Step 3: MainLayout.tsx 集成 DynamicMenu**
|
||||
|
||||
替换硬编码的 `bizMenuItems`,追加插件动态菜单。
|
||||
|
||||
- [ ] **Step 4: 运行 `pnpm dev` 验证前端编译通过**
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
feat(web): add dynamic routing and menu generation from plugin configs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: 插件管理页面
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/pages/PluginAdmin.tsx`
|
||||
|
||||
- [ ] **Step 1: 实现 PluginAdmin 页面**
|
||||
|
||||
包含:插件列表(Table)、上传按钮(Upload)、启用/停用/卸载操作、配置编辑 Modal。使用 Ant Design 组件。
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```
|
||||
feat(web): add plugin admin page with upload/enable/disable/configure
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5: 第一个行业插件(进销存)
|
||||
|
||||
### Task 13: 创建 erp-plugin-inventory
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/plugins/inventory/Cargo.toml`
|
||||
- Create: `crates/plugins/inventory/plugin.toml`
|
||||
- Create: `crates/plugins/inventory/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: 创建插件项目**
|
||||
|
||||
`Cargo.toml` crate-type = ["cdylib"],依赖 wit-bindgen + serde + serde_json。
|
||||
|
||||
- [ ] **Step 2: 编写 plugin.toml**
|
||||
|
||||
完整清单(spec §4 的进销存示例):inventory_item、purchase_order 两个 entity,3 个 CRUD 页面 + 1 个 custom 页面。
|
||||
|
||||
- [ ] **Step 3: 实现 lib.rs**
|
||||
|
||||
使用 wit-bindgen 生成的绑定,实现 `init()`、`on_tenant_created()`、`handle_event()`。
|
||||
|
||||
- [ ] **Step 4: 编译为 WASM**
|
||||
|
||||
```bash
|
||||
rustup target add wasm32-unknown-unknown
|
||||
cargo build -p erp-plugin-inventory --target wasm32-unknown-unknown --release
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
feat(inventory): create erp-plugin-inventory as first industry plugin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 14: 端到端集成测试
|
||||
|
||||
- [ ] **Step 1: 启动后端服务**
|
||||
|
||||
```bash
|
||||
cd docker && docker compose up -d
|
||||
cd crates/erp-server && cargo run
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 通过 API 上传进销存插件**
|
||||
|
||||
```bash
|
||||
# 打包
|
||||
cp target/wasm32-unknown-unknown/release/erp_plugin_inventory.wasm /tmp/
|
||||
# 上传
|
||||
curl -X POST http://localhost:3000/api/v1/admin/plugins/upload \
|
||||
-F "wasm=@/tmp/erp_plugin_inventory.wasm" \
|
||||
-F "manifest=@crates/plugins/inventory/plugin.toml"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 启用插件 + 验证建表**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/admin/plugins/erp-inventory/enable
|
||||
docker exec erp-postgres psql -U erp -c "\dt plugin_*"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 测试 CRUD API**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/plugins/erp-inventory/inventory_item \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"sku":"ITEM001","name":"测试商品","quantity":100}'
|
||||
curl http://localhost:3000/api/v1/plugins/erp-inventory/inventory_item \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 前端验证**
|
||||
|
||||
启动 `pnpm dev`,验证:
|
||||
- 侧边栏出现"进销存"菜单组 + 子菜单
|
||||
- 点击"商品管理"显示 PluginCRUDPage
|
||||
- 可以新建/编辑/删除/搜索商品
|
||||
|
||||
- [ ] **Step 6: 测试停用 + 卸载**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/admin/plugins/erp-inventory/disable
|
||||
curl -X DELETE http://localhost:3000/api/v1/admin/plugins/erp-inventory
|
||||
# 验证数据表仍在
|
||||
docker exec erp-postgres psql -U erp -c "\dt plugin_*"
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```
|
||||
test(inventory): end-to-end integration test for plugin lifecycle
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序
|
||||
|
||||
```
|
||||
Chunk 1 (Tasks 1-2) ← 先做,所有后续依赖 trait v2 和 EventBus 扩展
|
||||
↓
|
||||
Chunk 2 (Tasks 3-5) ← 数据库表 + crate 骨架 + WIT
|
||||
↓
|
||||
Chunk 3 (Tasks 6-8) ← 核心运行时 + API(后端完成)
|
||||
↓
|
||||
Chunk 4 (Tasks 9-12) ← 前端(可与 Chunk 5 并行)
|
||||
↓
|
||||
Chunk 5 (Tasks 13-14) ← 第一个插件 + E2E 验证
|
||||
```
|
||||
|
||||
## 关键风险
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|------|------|
|
||||
| Wasmtime 版本与 WIT 不兼容 | 锁定 wasmtime = "27",CI 验证 |
|
||||
| axum Router 在 erp-core 中引入重依赖 | 考虑将 trait routes 方法改为返回路由描述结构体,在 erp-server 层构建 Router |
|
||||
| 动态建表安全性 | 仅允许白名单列类型,禁止 DDL 注入 |
|
||||
| 前端 PluginCRUDPage 覆盖不足 | 先支持 text/number/date/select/currency,custom 页面后续迭代 |
|
||||
985
docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md
Normal file
985
docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md
Normal file
@@ -0,0 +1,985 @@
|
||||
# WASM 插件系统设计规格
|
||||
|
||||
> 日期:2026-04-13
|
||||
> 状态:审核通过 (v2 — 修复安全/多租户/迁移问题)
|
||||
> 关联:`docs/superpowers/specs/2026-04-10-erp-platform-base-design.md`
|
||||
> Review 历史:v1 首次审核 → 修复 C1-C4 关键问题 + I1-I5 重要问题 → v2 审核通过
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
ERP 平台底座 Phase 1-6 已全部完成,包含 auth、config、workflow、message 四大基础模块。
|
||||
当前系统是一个"模块化形状的单体"——模块以独立 crate 存在,但集成方式是编译时硬编码(main.rs 手动注册路由、合并迁移、启动后台任务)。
|
||||
|
||||
**核心矛盾:** Rust 的静态编译特性不支持运行时热插拔,但产品目标是"通用基座 + 行业插件"架构。
|
||||
|
||||
**本设计的目标:** 引入 WASM 运行时插件系统,使行业模块(进销存、生产、财务等)可以动态安装、启用、停用,无需修改基座代码。
|
||||
|
||||
## 2. 设计决策
|
||||
|
||||
| 决策点 | 选择 | 理由 |
|
||||
|--------|------|------|
|
||||
| 插件范围 | 仅行业模块动态化,基础模块保持 Rust 编译时 | 基础模块变更频率低、可靠性要求高,适合编译时保证 |
|
||||
| 插件技术 | WebAssembly (Wasmtime) | Rust 原生运行时,性能接近原生,沙箱安全 |
|
||||
| 数据库访问 | 宿主代理 API | 宿主自动注入 tenant_id、软删除、审计日志,插件无法绕过 |
|
||||
| 前端 UI | 配置驱动 | ERP 80% 页面是 CRUD,配置驱动覆盖大部分场景 |
|
||||
| 插件管理 | 内置插件商店 | 类似 WordPress 模型,管理后台上传 WASM 包 |
|
||||
| WASM 运行时 | Wasmtime | Bytecode Alliance 维护,Rust 原生,Cranelift JIT |
|
||||
|
||||
## 3. 架构总览
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ erp-server │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ ModuleRegistry v2 │ │
|
||||
│ │ ┌─────────────────┐ ┌──────────────────────────┐│ │
|
||||
│ │ │ Native Modules │ │ Wasmtime Runtime ││ │
|
||||
│ │ │ ┌──────┐┌──────┐│ │ ┌──────┐┌──────┐┌──────┐││ │
|
||||
│ │ │ │ auth ││config ││ │ │进销存 ││ 生产 ││ 财务 │││ │
|
||||
│ │ │ ├──────┤├──────┤│ │ └──┬───┘└──┬───┘└──┬───┘││ │
|
||||
│ │ │ │workflow│msg ││ │ └────────┼────────┘ ││ │
|
||||
│ │ │ └──────┘└──────┘│ │ Host API Layer ││ │
|
||||
│ │ └─────────────────┘ └──────────────────────────┘│ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
│ ↕ EventBus │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ 统一 Axum Router │ │
|
||||
│ │ /api/v1/auth/* /api/v1/plugins/{id}/* │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
↕
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Frontend (React SPA) │
|
||||
│ ┌──────────────┐ ┌──────────────────────────────────┐ │
|
||||
│ │ 固定路由 │ │ 动态路由 (PluginRegistry Store) │ │
|
||||
│ │ /users /roles │ │ /inventory/* /production/* │ │
|
||||
│ └──────────────┘ └──────────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────┐│
|
||||
│ │ PluginCRUDPage — 配置驱动的通用 CRUD 渲染引擎 ││
|
||||
│ └──────────────────────────────────────────────────────┘│
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 4. 插件清单 (Plugin Manifest)
|
||||
|
||||
每个 WASM 插件包含一个 `plugin.toml` 清单文件:
|
||||
|
||||
```toml
|
||||
[plugin]
|
||||
id = "erp-inventory" # 全局唯一 ID,kebab-case
|
||||
name = "进销存管理" # 显示名称
|
||||
version = "1.0.0" # 语义化版本
|
||||
description = "商品/采购/销售/库存管理"
|
||||
author = "ERP Team"
|
||||
min_platform_version = "1.0.0" # 最低基座版本要求
|
||||
|
||||
[dependencies]
|
||||
modules = ["auth", "workflow"] # 依赖的基础模块 ID 列表
|
||||
|
||||
[permissions]
|
||||
database = true # 需要数据库访问
|
||||
events = true # 需要发布/订阅事件
|
||||
config = true # 需要读取系统配置
|
||||
files = false # 是否需要文件存储
|
||||
|
||||
[schema]
|
||||
[[schema.entities]]
|
||||
name = "inventory_item"
|
||||
fields = [
|
||||
{ name = "sku", type = "string", required = true, unique = true },
|
||||
{ name = "name", type = "string", required = true },
|
||||
{ name = "quantity", type = "integer", default = 0 },
|
||||
{ name = "unit", type = "string", default = "个" },
|
||||
{ name = "category_id", type = "uuid", nullable = true },
|
||||
{ name = "unit_price", type = "decimal", precision = 10, scale = 2 },
|
||||
]
|
||||
indexes = [["sku"], ["category_id"]]
|
||||
|
||||
[[schema.entities]]
|
||||
name = "purchase_order"
|
||||
fields = [
|
||||
{ name = "order_no", type = "string", required = true, unique = true },
|
||||
{ name = "supplier_id", type = "uuid" },
|
||||
{ name = "status", type = "string", default = "draft" },
|
||||
{ name = "total_amount", type = "decimal", precision = 12, scale = 2 },
|
||||
{ name = "order_date", type = "date" },
|
||||
]
|
||||
|
||||
[events]
|
||||
published = ["inventory.stock.low", "purchase_order.created", "purchase_order.approved"]
|
||||
subscribed = ["workflow.task.completed"]
|
||||
|
||||
[ui]
|
||||
[[ui.pages]]
|
||||
name = "商品管理"
|
||||
path = "/inventory/items"
|
||||
entity = "inventory_item"
|
||||
type = "crud"
|
||||
icon = "ShoppingOutlined"
|
||||
menu_group = "进销存"
|
||||
|
||||
[[ui.pages]]
|
||||
name = "采购管理"
|
||||
path = "/inventory/purchase"
|
||||
entity = "purchase_order"
|
||||
type = "crud"
|
||||
icon = "ShoppingCartOutlined"
|
||||
menu_group = "进销存"
|
||||
|
||||
[[ui.pages]]
|
||||
name = "库存盘点"
|
||||
path = "/inventory/stocktaking"
|
||||
type = "custom"
|
||||
menu_group = "进销存"
|
||||
```
|
||||
|
||||
**规则:**
|
||||
- `schema.entities` 声明的表自动注入标准字段:`id`, `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version`
|
||||
- `permissions` 控制插件可调用的宿主 API 范围(最小权限原则)
|
||||
- `ui.pages.type` 为 `crud` 时由通用渲染引擎自动生成页面,`custom` 时由插件处理渲染逻辑
|
||||
- 插件事件命名使用 `{plugin_id}.{entity}.{action}` 三段式,避免与基础模块的 `{module}.{action}` 二段式冲突
|
||||
- 动态创建的表使用 `plugin_{entity_name}` 格式,所有租户共享同一张表,通过 `tenant_id` 列实现行级隔离(与现有表模式一致)
|
||||
|
||||
## 5. 宿主 API (Host Functions)
|
||||
|
||||
WASM 插件通过宿主暴露的函数访问系统资源,这是插件与外部世界的唯一通道:
|
||||
|
||||
### 5.1 API 定义
|
||||
|
||||
```rust
|
||||
/// 宿主暴露给 WASM 插件的 API 接口
|
||||
/// 通过 Wasmtime Linker 注册为 host functions
|
||||
trait PluginHostApi {
|
||||
// === 数据库操作 ===
|
||||
|
||||
/// 插入记录(宿主自动注入 tenant_id, id, created_at 等标准字段)
|
||||
fn db_insert(&mut self, entity: &str, data: &[u8]) -> Result<Vec<u8>>;
|
||||
|
||||
/// 查询记录(自动注入 tenant_id 过滤 + 软删除过滤)
|
||||
fn db_query(&mut self, entity: &str, filter: &[u8], pagination: &[u8]) -> Result<Vec<u8]>;
|
||||
|
||||
/// 更新记录(自动检查 version 乐观锁)
|
||||
fn db_update(&mut self, entity: &str, id: &str, data: &[u8], version: i64) -> Result<Vec<u8]>;
|
||||
|
||||
/// 软删除记录
|
||||
fn db_delete(&mut self, entity: &str, id: &str) -> Result<()>;
|
||||
|
||||
/// 原始查询(仅允许 SELECT,自动注入 tenant_id 过滤)
|
||||
fn db_raw_query(&mut self, sql: &str, params: &[u8]) -> Result<Vec<u8]>;
|
||||
|
||||
// === 事件总线 ===
|
||||
|
||||
/// 发布领域事件
|
||||
fn event_publish(&mut self, event_type: &str, payload: &[u8]) -> Result<()>;
|
||||
|
||||
// === 配置 ===
|
||||
|
||||
/// 读取系统配置(插件作用域内)
|
||||
fn config_get(&mut self, key: &str) -> Result<Vec<u8]>;
|
||||
|
||||
// === 日志 ===
|
||||
|
||||
/// 写日志(自动关联 tenant_id + plugin_id)
|
||||
fn log_write(&mut self, level: &str, message: &str);
|
||||
|
||||
// === 用户/权限 ===
|
||||
|
||||
/// 获取当前用户信息
|
||||
fn current_user(&mut self) -> Result<Vec<u8]>;
|
||||
|
||||
/// 检查当前用户权限
|
||||
fn check_permission(&mut self, permission: &str) -> Result<bool>;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 安全边界
|
||||
|
||||
插件运行在 WASM 沙箱中,安全策略如下:
|
||||
|
||||
1. **权限校验** — 插件只能调用清单 `permissions` 中声明的宿主函数,未声明的调用在加载时被拦截
|
||||
2. **租户隔离** — 所有 `db_*` 操作自动注入 `tenant_id`,插件无法绕过多租户隔离。使用行级隔离(共享表 + tenant_id 过滤),与现有基础模块保持一致
|
||||
3. **资源限制** — 每个插件有独立的资源配额(内存上限、CPU 时间、API 调用频率)
|
||||
4. **审计记录** — 所有写操作自动记录审计日志
|
||||
5. **SQL 安全** — 不暴露原始 SQL 接口,`db_aggregate` 使用结构化查询对象,宿主层安全构建参数化 SQL
|
||||
6. **文件/网络隔离** — 插件不能直接访问文件系统或网络
|
||||
|
||||
### 5.3 数据流
|
||||
|
||||
```
|
||||
WASM 插件 宿主安全层 PostgreSQL
|
||||
┌──────────┐ ┌───────────────┐ ┌──────────┐
|
||||
│ 调用 │ ── Host Call ──→ │ 1. 权限校验 │ │ │
|
||||
│ db_insert │ │ 2. 注入标准字段 │ ── SQL ──→ │ INSERT │
|
||||
│ │ │ 3. 注入 tenant │ │ INTO │
|
||||
│ │ ←─ JSON 结果 ── │ 4. 写审计日志 │ │ │
|
||||
└──────────┘ └───────────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## 6. 插件生命周期
|
||||
|
||||
### 6.1 状态机
|
||||
|
||||
```
|
||||
上传 WASM 包
|
||||
│
|
||||
▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ uploaded │───→│ installed │───→│ enabled │───→│ running │
|
||||
└──────────┘ └──────────┘ └──────────┘ └──────────┘
|
||||
│ │
|
||||
│ ┌──────────┘
|
||||
│ ▼
|
||||
┌──────────┐
|
||||
│ disabled │←── 运行时错误自动停用
|
||||
└──────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────┐
|
||||
│uninstalled│ ── 软删除插件记录,保留数据表和数据
|
||||
└──────────┘
|
||||
│
|
||||
▼ (可选,需管理员二次确认)
|
||||
┌──────────┐
|
||||
│ purged │ ── 真正删除数据表 + 数据导出备份
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
### 6.2 各阶段操作
|
||||
|
||||
| 阶段 | 操作 |
|
||||
|------|------|
|
||||
| uploaded → installed | 校验清单格式、验证依赖模块存在、检查 min_platform_version |
|
||||
| installed → enabled | 根据 `schema.entities` 创建数据表(带 `plugin_` 前缀)、写入启用状态 |
|
||||
| enabled → running | 服务启动时:Wasmtime 实例化、注册 Host Functions、调用 `init()`、注册事件处理器、注册前端路由 |
|
||||
| running → disabled | 停止 WASM 实例、注销事件处理器、注销路由 |
|
||||
| disabled → uninstalled | 软删除插件记录(设置 `deleted_at`),**保留数据表和数据不变**,清理事件订阅记录 |
|
||||
| uninstalled → purged | 数据导出备份后,删除 `plugin_*` 数据表。**需要管理员二次确认 + 数据导出完成** |
|
||||
|
||||
### 6.3 启动加载流程
|
||||
|
||||
```rust
|
||||
async fn load_plugins(db: &DatabaseConnection) -> Vec<LoadedPlugin> {
|
||||
// 1. 查询所有 enabled 状态的插件
|
||||
let plugins = Plugin::find()
|
||||
.filter(status.eq("enabled"))
|
||||
.filter(deleted_at.is_null())
|
||||
.all(db).await?;
|
||||
|
||||
let mut loaded = Vec::new();
|
||||
for plugin in plugins {
|
||||
// 2. 初始化 Wasmtime Engine(复用全局 Engine)
|
||||
let module = Module::from_binary(&engine, &plugin.wasm_binary)?;
|
||||
|
||||
// 3. 创建 Linker,根据 permissions 注册对应的 Host Functions
|
||||
let mut linker = Linker::new(&engine);
|
||||
register_host_functions(&mut linker, &plugin.permissions)?;
|
||||
|
||||
// 4. 实例化
|
||||
let instance = linker.instantiate_async(&mut store, &module).await?;
|
||||
|
||||
// 5. 调用插件的 init() 入口函数
|
||||
if let Ok(init) = instance.get_typed_func::<(), ()>(&mut store, "init") {
|
||||
init.call_async(&mut store, ()).await?;
|
||||
}
|
||||
|
||||
// 6. 注册事件处理器
|
||||
for sub in &plugin.manifest.events.subscribed {
|
||||
event_bus.subscribe_filtered(sub, plugin_handler(plugin.id, instance.clone()));
|
||||
}
|
||||
|
||||
loaded.push(LoadedPlugin { plugin, instance, store });
|
||||
}
|
||||
|
||||
// 7. 依赖排序验证
|
||||
validate_dependencies(&loaded)?;
|
||||
|
||||
Ok(loaded)
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 数据库 Schema
|
||||
|
||||
### 7.1 新增表
|
||||
|
||||
```sql
|
||||
-- 插件注册表
|
||||
CREATE TABLE plugins (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
plugin_id VARCHAR(100) NOT NULL, -- 清单中的唯一 ID
|
||||
name VARCHAR(200) NOT NULL,
|
||||
plugin_version VARCHAR(20) NOT NULL, -- 插件语义化版本(避免与乐观锁 version 混淆)
|
||||
description TEXT,
|
||||
manifest JSONB NOT NULL, -- 完整清单 JSON
|
||||
wasm_binary BYTEA NOT NULL, -- 编译后的 WASM 二进制
|
||||
status VARCHAR(20) DEFAULT 'installed',
|
||||
-- uploaded / installed / enabled / disabled / error
|
||||
permissions JSONB NOT NULL,
|
||||
error_message TEXT,
|
||||
schema_version INTEGER DEFAULT 1, -- 插件数据 schema 版本
|
||||
config JSONB DEFAULT '{}', -- 插件配置
|
||||
-- 标准字段
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID,
|
||||
updated_by UUID,
|
||||
deleted_at TIMESTAMPTZ, -- 软删除(卸载不删数据)
|
||||
row_version INTEGER NOT NULL DEFAULT 1, -- 乐观锁版本
|
||||
UNIQUE(tenant_id, plugin_id)
|
||||
);
|
||||
|
||||
-- 插件 schema 版本跟踪(用于动态表的版本管理)
|
||||
CREATE TABLE plugin_schema_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
plugin_id VARCHAR(100) NOT NULL, -- 全局唯一的插件 ID
|
||||
entity_name VARCHAR(100) NOT NULL, -- 实体名
|
||||
schema_version INTEGER NOT NULL DEFAULT 1, -- 当前 schema 版本
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(plugin_id, entity_name)
|
||||
);
|
||||
|
||||
-- 插件事件订阅记录
|
||||
CREATE TABLE plugin_event_subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
plugin_id VARCHAR(100) NOT NULL,
|
||||
event_type VARCHAR(200) NOT NULL,
|
||||
handler_name VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
### 7.2 动态数据表
|
||||
|
||||
插件安装时根据 `manifest.schema.entities` 自动创建数据表:
|
||||
|
||||
- 表名格式:`plugin_{entity_name}`
|
||||
- **行级隔离模式**:所有租户共享同一张 `plugin_*` 表,通过 `tenant_id` 列过滤实现隔离(与现有基础模块的表保持一致)
|
||||
- 首次创建表时使用 `IF NOT EXISTS`(幂等),后续租户安装同一插件时复用已有表
|
||||
- 自动包含标准字段:`id`, `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version`
|
||||
- 索引自动创建:主键 + `tenant_id`(必选)+ 清单中声明的自定义索引
|
||||
- **注意**:此方式绕过 SeaORM Migration 系统,属于合理偏差——插件是运行时动态加载的,其 schema 无法在编译时通过静态迁移管理。宿主维护 `plugin_schema_versions` 表跟踪每个插件的 schema 版本
|
||||
|
||||
## 8. 配置驱动 UI
|
||||
|
||||
### 8.1 前端架构
|
||||
|
||||
```
|
||||
插件 manifest.ui.pages
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ PluginStore │ Zustand Store,从 /api/v1/plugins/:id/pages 加载
|
||||
│ (前端插件注册表) │ 缓存所有已启用插件的页面配置
|
||||
└───────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ DynamicRouter │ React Router,根据 PluginStore 自动生成路由
|
||||
│ (动态路由层) │ 懒加载 PluginCRUDPage / PluginDashboard
|
||||
└───────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ PluginCRUDPage │ 通用 CRUD 页面组件
|
||||
│ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ SearchBar │ │ 根据 filters 配置自动生成搜索条件
|
||||
│ └─────────────┘ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ DataTable │ │ 根据 columns 配置渲染 Ant Design Table
|
||||
│ └─────────────┘ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ FormDialog │ │ 根据 form 配置渲染新建/编辑表单
|
||||
│ └─────────────┘ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ ActionBar │ │ 根据 actions 配置渲染操作按钮
|
||||
│ └─────────────┘ │
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
### 8.2 页面配置类型
|
||||
|
||||
```typescript
|
||||
interface PluginPageConfig {
|
||||
name: string;
|
||||
path: string;
|
||||
entity: string;
|
||||
type: "crud" | "dashboard" | "custom";
|
||||
icon?: string;
|
||||
menu_group: string;
|
||||
|
||||
// CRUD 配置(可选,不提供时从 schema.entities 自动推导)
|
||||
// columns 未指定时:从 entity 的 fields 生成,type=select 需显式指定 options
|
||||
// form 未指定时:从 entity 的 fields 生成表单,required 字段为必填
|
||||
columns?: ColumnDef[];
|
||||
filters?: FilterDef[];
|
||||
actions?: ActionDef[];
|
||||
form?: FormDef;
|
||||
}
|
||||
|
||||
interface ColumnDef {
|
||||
field: string;
|
||||
label: string;
|
||||
type: "text" | "number" | "date" | "datetime" | "select"
|
||||
| "multiselect" | "currency" | "status" | "link";
|
||||
width?: number;
|
||||
sortable?: boolean;
|
||||
hidden?: boolean;
|
||||
options?: { label: string; value: string; color?: string }[];
|
||||
}
|
||||
|
||||
interface FormDef {
|
||||
groups?: FormGroup[];
|
||||
fields: FormField[];
|
||||
rules?: ValidationRule[];
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 动态菜单生成
|
||||
|
||||
前端侧边栏从 PluginStore 动态生成菜单项:
|
||||
|
||||
- 基础模块菜单固定(用户、权限、组织、工作流、消息、设置)
|
||||
- 插件菜单按 `menu_group` 分组,动态追加到侧边栏
|
||||
- 菜单数据来自 `/api/v1/plugins/installed` API,启动时加载
|
||||
|
||||
### 8.4 插件 API 路由
|
||||
|
||||
插件的 CRUD API 由宿主自动生成:
|
||||
|
||||
```
|
||||
GET /api/v1/plugins/{plugin_id}/{entity} # 列表查询
|
||||
GET /api/v1/plugins/{plugin_id}/{entity}/{id} # 详情
|
||||
POST /api/v1/plugins/{plugin_id}/{entity} # 新建
|
||||
PUT /api/v1/plugins/{plugin_id}/{entity}/{id} # 更新
|
||||
DELETE /api/v1/plugins/{plugin_id}/{entity}/{id} # 删除
|
||||
```
|
||||
|
||||
宿主自动注入 tenant_id、处理分页、乐观锁、软删除。
|
||||
|
||||
### 8.5 自定义页面
|
||||
|
||||
`type: "custom"` 的页面需要额外的渲染指令:
|
||||
|
||||
- 插件 WASM 可以导出 `render_page` 函数,返回 UI 指令 JSON
|
||||
- 宿主前端解析指令并渲染(支持:条件显示、自定义操作、复杂布局)
|
||||
- 复杂交互(如库存盘点)通过事件驱动:前端发送 action → 后端 WASM 处理 → 返回新的 UI 状态
|
||||
|
||||
## 9. 升级后的模块注册系统
|
||||
|
||||
### 9.1 ErpModule trait v2
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait ErpModule: Send + Sync {
|
||||
fn id(&self) -> &str;
|
||||
fn name(&self) -> &str;
|
||||
fn version(&self) -> &str { env!("CARGO_PKG_VERSION") }
|
||||
fn dependencies(&self) -> Vec<&str> { vec![] }
|
||||
fn module_type(&self) -> ModuleType;
|
||||
|
||||
// 生命周期
|
||||
async fn on_startup(&self, ctx: &ModuleContext) -> AppResult<()> { Ok(()) }
|
||||
async fn on_shutdown(&self) -> AppResult<()> { Ok(()) }
|
||||
async fn health_check(&self) -> AppResult<ModuleHealth> {
|
||||
Ok(ModuleHealth { status: "ok".into(), details: None })
|
||||
}
|
||||
|
||||
// 路由
|
||||
fn public_routes(&self) -> Option<Router> { None }
|
||||
fn protected_routes(&self) -> Option<Router> { None }
|
||||
|
||||
// 数据库
|
||||
fn migrations(&self) -> Vec<Box<dyn MigrationTrait>> { vec![] }
|
||||
|
||||
// 事件
|
||||
fn register_event_handlers(&self, bus: &EventBus) {}
|
||||
|
||||
// 租户
|
||||
async fn on_tenant_created(&self, tenant_id: Uuid, ctx: &ModuleContext) -> AppResult<()> { Ok(()) }
|
||||
async fn on_tenant_deleted(&self, tenant_id: Uuid, ctx: &ModuleContext) -> AppResult<()> { Ok(()) }
|
||||
|
||||
// 配置
|
||||
fn config_schema(&self) -> Option<serde_json::Value> { None }
|
||||
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
}
|
||||
|
||||
pub enum ModuleType { Native, Wasm }
|
||||
|
||||
pub struct ModuleHealth {
|
||||
pub status: String,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ModuleContext {
|
||||
pub db: DatabaseConnection,
|
||||
pub event_bus: EventBus,
|
||||
pub config: Arc<AppConfig>,
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 ModuleRegistry v2
|
||||
|
||||
```rust
|
||||
pub struct ModuleRegistry {
|
||||
modules: Arc<Vec<Arc<dyn ErpModule>>>,
|
||||
wasm_runtime: Arc<WasmPluginRuntime>,
|
||||
index: Arc<HashMap<String, usize>>,
|
||||
}
|
||||
|
||||
impl ModuleRegistry {
|
||||
pub fn new() -> Self;
|
||||
|
||||
// 注册 Rust 原生模块
|
||||
pub fn register(self, module: impl ErpModule + 'static) -> Self;
|
||||
|
||||
// 从数据库加载 WASM 插件
|
||||
pub async fn load_wasm_plugins(&mut self, db: &DatabaseConnection) -> AppResult<()>;
|
||||
|
||||
// 按依赖顺序启动所有模块
|
||||
pub async fn startup_all(&self, ctx: &ModuleContext) -> AppResult<()>;
|
||||
|
||||
// 聚合健康状态
|
||||
pub async fn health_check_all(&self) -> HashMap<String, ModuleHealth>;
|
||||
|
||||
// 自动收集所有路由
|
||||
pub fn build_routes(&self) -> (Router, Router);
|
||||
|
||||
// 自动收集所有迁移
|
||||
pub fn collect_migrations(&self) -> Vec<Box<dyn MigrationTrait>>;
|
||||
|
||||
// 拓扑排序(基于 dependencies)
|
||||
fn topological_sort(&self) -> AppResult<Vec<Arc<dyn ErpModule>>>;
|
||||
|
||||
// 按 ID 查找模块
|
||||
pub fn get_module(&self, id: &str) -> Option<&Arc<dyn ErpModule>>;
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 升级后的 main.rs
|
||||
|
||||
```rust
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// 初始化 DB、Config、EventBus ...
|
||||
|
||||
// 1. 注册 Rust 原生模块
|
||||
let mut registry = ModuleRegistry::new()
|
||||
.register(AuthModule::new())
|
||||
.register(ConfigModule::new())
|
||||
.register(WorkflowModule::new())
|
||||
.register(MessageModule::new());
|
||||
|
||||
// 2. 从数据库加载 WASM 插件
|
||||
registry.load_wasm_plugins(&db).await?;
|
||||
|
||||
// 3. 依赖排序 + 启动所有模块
|
||||
let ctx = ModuleContext { db: db.clone(), event_bus: event_bus.clone(), config: config.clone() };
|
||||
registry.startup_all(&ctx).await?;
|
||||
|
||||
// 4. 自动收集路由(无需手动 merge)
|
||||
let (public, protected) = registry.build_routes();
|
||||
|
||||
// 5. 构建 Axum 服务
|
||||
let app = Router::new()
|
||||
.nest("/api/v1", public.merge(protected))
|
||||
.with_state(app_state);
|
||||
|
||||
// 启动服务 ...
|
||||
}
|
||||
```
|
||||
|
||||
## 10. 插件开发体验
|
||||
|
||||
### 10.1 插件项目结构
|
||||
|
||||
```
|
||||
erp-plugin-inventory/
|
||||
├── Cargo.toml # crate 类型为 cdylib (WASM)
|
||||
├── plugin.toml # 插件清单
|
||||
└── src/
|
||||
└── lib.rs # 插件入口
|
||||
```
|
||||
|
||||
### 10.2 插件 Cargo.toml
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-inventory"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.24" # WIT 接口绑定生成
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
```
|
||||
|
||||
### 10.3 插件代码示例
|
||||
|
||||
```rust
|
||||
use wit_bindgen::generate::Guest;
|
||||
|
||||
// 自动生成宿主 API 绑定
|
||||
export!(Plugin);
|
||||
|
||||
struct Plugin;
|
||||
|
||||
impl Guest for Plugin {
|
||||
fn init() -> Result<(), String> {
|
||||
host::log_write("info", "进销存插件初始化完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_tenant_created(tenant_id: String) -> Result<(), String> {
|
||||
// 初始化默认商品分类等
|
||||
host::db_insert("inventory_category", br#"{"name": "默认分类"}"#)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_event(event_type: String, payload: Vec<u8>) -> Result<(), String> {
|
||||
match event_type.as_str() {
|
||||
"workflow.task.completed" => {
|
||||
// 采购审批通过,更新采购单状态
|
||||
let data: serde_json::Value = serde_json::from_slice(&payload)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let order_id = data["business_id"].as_str().unwrap();
|
||||
host::db_update("purchase_order", order_id,
|
||||
br#"{"status": "approved"}"#, 1)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.4 构建与发布
|
||||
|
||||
```bash
|
||||
# 编译为 WASM
|
||||
cargo build --target wasm32-unknown-unknown --release
|
||||
|
||||
# 打包(WASM 二进制 + 清单文件)
|
||||
erp-plugin pack ./target/wasm32-unknown-unknown/release/erp_plugin_inventory.wasm \
|
||||
--manifest ./plugin.toml \
|
||||
--output ./erp-inventory-1.0.0.erp-plugin
|
||||
|
||||
# 上传到平台(通过管理后台或 API)
|
||||
curl -X POST /api/v1/admin/plugins/upload \
|
||||
-F "plugin=@./erp-inventory-1.0.0.erp-plugin"
|
||||
```
|
||||
|
||||
## 11. 管理后台 API
|
||||
|
||||
### 11.1 插件管理接口
|
||||
|
||||
```
|
||||
POST /api/v1/admin/plugins/upload # 上传插件包
|
||||
GET /api/v1/admin/plugins # 列出所有插件
|
||||
GET /api/v1/admin/plugins/{plugin_id} # 插件详情
|
||||
POST /api/v1/admin/plugins/{plugin_id}/enable # 启用插件
|
||||
POST /api/v1/admin/plugins/{plugin_id}/disable # 停用插件
|
||||
DELETE /api/v1/admin/plugins/{plugin_id} # 卸载插件
|
||||
GET /api/v1/admin/plugins/{plugin_id}/health # 插件健康检查
|
||||
PUT /api/v1/admin/plugins/{plugin_id}/config # 更新插件配置
|
||||
POST /api/v1/admin/plugins/{plugin_id}/upgrade # 升级插件版本
|
||||
```
|
||||
|
||||
### 11.2 插件数据接口(自动生成)
|
||||
|
||||
```
|
||||
GET /api/v1/plugins/{plugin_id}/{entity} # 列表查询
|
||||
GET /api/v1/plugins/{plugin_id}/{entity}/{id} # 详情
|
||||
POST /api/v1/plugins/{plugin_id}/{entity} # 新建
|
||||
PUT /api/v1/plugins/{plugin_id}/{entity}/{id} # 更新
|
||||
DELETE /api/v1/plugins/{plugin_id}/{entity}/{id} # 删除
|
||||
```
|
||||
|
||||
## 12. 实施路径
|
||||
|
||||
### Phase 7: 插件系统核心
|
||||
|
||||
1. **引入 Wasmtime 依赖**,创建 `erp-plugin-runtime` crate
|
||||
2. **定义 WIT 接口文件**,描述宿主-插件合约
|
||||
3. **实现 Host API 层** — db_insert/query/update/delete、event_publish、config_get 等
|
||||
4. **实现插件加载器** — 从数据库读取 WASM 二进制、实例化、注册路由
|
||||
5. **升级 ErpModule trait** — 添加 lifecycle hooks、routes、migrations 方法
|
||||
6. **升级 ModuleRegistry** — 拓扑排序、自动路由收集、WASM 插件注册
|
||||
7. **插件管理 API** — 上传、启用、停用、卸载
|
||||
8. **插件数据库表** — plugins、plugin_event_subscriptions + 动态建表逻辑
|
||||
|
||||
### Phase 8: 前端配置驱动 UI
|
||||
|
||||
1. **PluginStore** (Zustand) — 管理已安装插件的页面配置
|
||||
2. **DynamicRouter** — 根据 PluginStore 自动生成 React Router 路由
|
||||
3. **PluginCRUDPage** — 通用 CRUD 渲染引擎(表格 + 搜索 + 表单 + 操作)
|
||||
4. **动态菜单** — 从 PluginStore 生成侧边栏菜单
|
||||
5. **插件管理页面** — 上传、启用/停用、配置的管理后台
|
||||
|
||||
### Phase 9: 第一个行业插件(进销存)
|
||||
|
||||
1. 创建 `erp-plugin-inventory` 作为参考实现
|
||||
2. 实现商品、采购、库存管理的核心业务逻辑
|
||||
3. 配置驱动页面覆盖 80% 的 CRUD 场景
|
||||
4. 验证端到端流程:安装 → 启用 → 使用 → 停用 → 卸载
|
||||
|
||||
## 13. 风险与缓解
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||
|------|------|------|----------|
|
||||
| WASM 插件性能不足 | 低 | 高 | 性能基准测试,关键路径保留 Rust 原生 |
|
||||
| 插件安全问题 | 中 | 高 | 沙箱隔离 + 最小权限 + 审计日志 |
|
||||
| 配置驱动 UI 覆盖不足 | 中 | 中 | 保留 custom 页面类型作为兜底 |
|
||||
| 插件间依赖冲突 | 中 | 中 | 拓扑排序 + 版本约束 + 冲突检测 |
|
||||
| Wasmtime 版本兼容性 | 低 | 中 | 锁定 Wasmtime 大版本,CI 验证 |
|
||||
|
||||
## 附录 A: ErpModule Trait 迁移策略
|
||||
|
||||
### A.1 向后兼容原则
|
||||
|
||||
`ErpModule` trait v2 的所有新增方法均提供**默认实现(no-op)**,确保现有四个模块(AuthModule、ConfigModule、WorkflowModule、MessageModule)无需修改即可编译通过。
|
||||
|
||||
### A.2 迁移清单
|
||||
|
||||
| 现有方法 | v2 变化 | 迁移操作 |
|
||||
|----------|---------|----------|
|
||||
| `fn name(&self) -> &str` | 保留不变,新增 `fn id()` 返回相同值 | 在各模块 impl 中添加 `fn id()` |
|
||||
| `fn version()` | 保留不变 | 无需改动 |
|
||||
| `fn dependencies()` | 保留不变 | 无需改动 |
|
||||
| `fn register_event_handlers()` | 签名不变 | 无需改动 |
|
||||
| `fn on_tenant_created(tenant_id)` | 签名变为 `on_tenant_created(tenant_id, ctx)` | 更新签名,添加 `ctx` 参数 |
|
||||
| `fn on_tenant_deleted(tenant_id)` | 签名变为 `on_tenant_deleted(tenant_id, ctx)` | 更新签名,添加 `ctx` 参数 |
|
||||
| `fn as_any()` | 保留不变 | 无需改动 |
|
||||
| (新增)`fn module_type()` | 默认返回 `ModuleType::Native` | 无需改动 |
|
||||
| (新增)`fn on_startup()` | 默认 no-op | 可选实现 |
|
||||
| (新增)`fn on_shutdown()` | 默认 no-op | 可选实现 |
|
||||
| (新增)`fn health_check()` | 默认返回 ok | 可选实现 |
|
||||
| (新增)`fn public_routes()` | 默认 None | 将现有关联函数迁移到此方法 |
|
||||
| (新增)`fn protected_routes()` | 默认 None | 将现有关联函数迁移到此方法 |
|
||||
| (新增)`fn migrations()` | 默认空 vec | 可选实现 |
|
||||
| (新增)`fn config_schema()` | 默认 None | 可选实现 |
|
||||
|
||||
### A.3 迁移后的 main.rs 变化
|
||||
|
||||
迁移后,main.rs 从手动路由合并变为自动收集:
|
||||
|
||||
```rust
|
||||
// 迁移前(手动)
|
||||
let protected_routes = erp_auth::AuthModule::protected_routes()
|
||||
.merge(erp_config::ConfigModule::protected_routes())
|
||||
.merge(erp_workflow::WorkflowModule::protected_routes())
|
||||
.merge(erp_message::MessageModule::protected_routes());
|
||||
|
||||
// 迁移后(自动)
|
||||
let (public, protected) = registry.build_routes();
|
||||
```
|
||||
|
||||
## 附录 B: EventBus 类型化订阅扩展
|
||||
|
||||
### B.1 现有 EventBus 扩展
|
||||
|
||||
现有的 `EventBus`(`erp-core/src/events.rs`)只有 `subscribe()` 方法返回全部事件的 `Receiver`。需要添加类型化过滤订阅:
|
||||
|
||||
```rust
|
||||
impl EventBus {
|
||||
/// 订阅特定事件类型
|
||||
/// 内部使用 mpmc 通道,为每个事件类型维护独立的分发器
|
||||
pub fn subscribe_filtered(
|
||||
&self,
|
||||
event_type: &str,
|
||||
handler: Box<dyn Fn(DomainEvent) + Send + Sync>,
|
||||
) -> SubscriptionHandle {
|
||||
// 在内部 HashMap<String, Vec<Handler>> 中注册
|
||||
// publish() 时根据 event_type 分发到匹配的 handler
|
||||
}
|
||||
|
||||
/// 取消订阅(用于插件停用时清理)
|
||||
pub fn unsubscribe(&self, handle: SubscriptionHandle) { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### B.2 插件事件处理器包装
|
||||
|
||||
```rust
|
||||
struct PluginEventHandler {
|
||||
plugin_id: String,
|
||||
handler_fn: Box<dyn Fn(DomainEvent) + Send + Sync>,
|
||||
}
|
||||
|
||||
impl PluginEventHandler {
|
||||
fn handle(&self, event: DomainEvent) {
|
||||
// 捕获 panic,防止插件崩溃影响宿主
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
(self.handler_fn)(event)
|
||||
});
|
||||
if let Err(_) = result {
|
||||
tracing::error!("插件 {} 事件处理器崩溃", self.plugin_id);
|
||||
// 通知 PluginManager 标记插件为 error 状态
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 附录 C: 管理后台 API 权限控制
|
||||
|
||||
### C.1 权限模型
|
||||
|
||||
| API 端点 | 所需权限 | 角色范围 |
|
||||
|----------|---------|----------|
|
||||
| `POST /admin/plugins/upload` | `plugin:admin` | 仅平台超级管理员 |
|
||||
| `POST /admin/plugins/{id}/enable` | `plugin:manage` | 平台管理员或租户管理员(仅限自己租户的插件) |
|
||||
| `POST /admin/plugins/{id}/disable` | `plugin:manage` | 平台管理员或租户管理员 |
|
||||
| `DELETE /admin/plugins/{id}` | `plugin:manage` | 租户管理员(软删除) |
|
||||
| `DELETE /admin/plugins/{id}/purge` | `plugin:admin` | 仅平台超级管理员 |
|
||||
| `GET /admin/plugins` | `plugin:view` | 租户管理员(仅看到自己租户的插件) |
|
||||
| `PUT /admin/plugins/{id}/config` | `plugin:configure` | 租户管理员 |
|
||||
| `GET /admin/plugins/{id}/health` | `plugin:view` | 租户管理员 |
|
||||
|
||||
### C.2 租户隔离
|
||||
|
||||
- 插件管理 API 自动注入 `tenant_id` 过滤(从 JWT 中提取)
|
||||
- 平台超级管理员可以通过 `/admin/platform/plugins` 查看所有租户的插件
|
||||
- 租户管理员只能管理自己租户安装的插件
|
||||
- 插件上传为平台级操作(所有租户共享同一个 WASM 二进制),但启用/配置为租户级操作
|
||||
|
||||
## 附录 D: WIT 接口定义
|
||||
|
||||
### D.1 插件接口 (`plugin.wit`)
|
||||
|
||||
```wit
|
||||
package erp:plugin;
|
||||
|
||||
interface host {
|
||||
/// 数据库操作
|
||||
db-insert: func(entity: string, data: list<u8>) -> result<list<u8>, string>;
|
||||
db-query: func(entity: string, filter: list<u8>, pagination: list<u8>) -> result<list<u8>, string>;
|
||||
db-update: func(entity: string, id: string, data: list<u8>, version: s64) -> result<list<u8>, string>;
|
||||
db-delete: func(entity: string, id: string) -> result<_, string>;
|
||||
db-aggregate: func(entity: string, query: list<u8>) -> result<list<u8>, string>;
|
||||
|
||||
/// 事件总线
|
||||
event-publish: func(event-type: string, payload: list<u8>) -> result<_, string>;
|
||||
|
||||
/// 配置
|
||||
config-get: func(key: string) -> result<list<u8>, string>;
|
||||
|
||||
/// 日志
|
||||
log-write: func(level: string, message: string);
|
||||
|
||||
/// 用户/权限
|
||||
current-user: func() -> result<list<u8>, string>;
|
||||
check-permission: func(permission: string) -> result<bool, string>;
|
||||
}
|
||||
|
||||
interface plugin {
|
||||
/// 插件初始化(加载时调用一次)
|
||||
init: func() -> result<_, string>;
|
||||
|
||||
/// 租户创建时调用
|
||||
on-tenant-created: func(tenant-id: string) -> result<_, string>;
|
||||
|
||||
/// 处理订阅的事件
|
||||
handle-event: func(event-type: string, payload: list<u8>) -> result<_, string>;
|
||||
|
||||
/// 自定义页面渲染(仅 type=custom 页面)
|
||||
render-page: func(page-path: string, params: list<u8>) -> result<list<u8>, string>;
|
||||
|
||||
/// 自定义页面操作处理
|
||||
handle-action: func(page-path: string, action: string, data: list<u8>) -> result<list<u8>, string>;
|
||||
}
|
||||
|
||||
world plugin-world {
|
||||
import host;
|
||||
export plugin;
|
||||
}
|
||||
```
|
||||
|
||||
### D.2 使用方式
|
||||
|
||||
插件开发者使用 `wit-bindgen` 生成绑定代码:
|
||||
|
||||
```bash
|
||||
# 生成 Rust 插件绑定
|
||||
wit-bindgen rust ./plugin.wit --out-dir ./src/generated
|
||||
```
|
||||
|
||||
宿主使用 `wasmtime` 的 `bindgen!` 宏生成调用端代码:
|
||||
|
||||
```rust
|
||||
// 在 erp-plugin-runtime crate 中
|
||||
wasmtime::component::bindgen!({
|
||||
path: "./plugin.wit",
|
||||
world: "plugin-world",
|
||||
async: true,
|
||||
});
|
||||
```
|
||||
|
||||
## 附录 E: 插件崩溃恢复策略
|
||||
|
||||
### E.1 崩溃检测与恢复
|
||||
|
||||
| 场景 | 检测方式 | 恢复策略 |
|
||||
|------|---------|----------|
|
||||
| WASM 执行 panic | `catch_unwind` 捕获 | 记录错误日志,该请求返回 500,插件继续运行 |
|
||||
| 插件 init() 失败 | 返回 Err | 标记插件为 `error` 状态,不加载 |
|
||||
| 事件处理器崩溃 | `catch_unwind` 捕获 | 记录错误日志,事件丢弃(不重试) |
|
||||
| 连续崩溃(>5次/分钟) | 计数器检测 | 自动停用插件,标记 `error`,通知管理员 |
|
||||
| 服务重启 | 启动流程 | 重新加载所有 `enabled` 状态的插件 |
|
||||
|
||||
### E.2 僵尸状态处理
|
||||
|
||||
插件在数据库中为 `enabled` 但实际未运行的情况:
|
||||
|
||||
1. 服务启动时,所有 `enabled` 插件尝试加载
|
||||
2. 加载失败的插件自动标记为 `error`,`error_message` 记录原因
|
||||
3. 管理后台显示 `error` 状态的插件,提供"重试"按钮
|
||||
4. 重试成功后恢复为 `enabled`,重试失败保持 `error`
|
||||
|
||||
### E.3 插件健康检查
|
||||
|
||||
```rust
|
||||
/// 定期健康检查(每 60 秒)
|
||||
async fn health_check_loop(registry: &ModuleRegistry) {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let results = registry.health_check_all().await;
|
||||
for (id, health) in results {
|
||||
if health.status != "ok" {
|
||||
tracing::warn!("模块 {} 健康检查异常: {:?}", id, health.details);
|
||||
// 通知管理后台
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 附录 F: Crate 依赖图更新
|
||||
|
||||
```
|
||||
erp-core (无业务依赖)
|
||||
erp-common (无业务依赖)
|
||||
↑
|
||||
erp-auth (→ core)
|
||||
erp-config (→ core)
|
||||
erp-workflow (→ core)
|
||||
erp-message (→ core)
|
||||
erp-plugin-runtime (→ core, wasmtime) ← 新增
|
||||
↑
|
||||
erp-server (→ 所有 crate,组装入口)
|
||||
```
|
||||
|
||||
**规则:**
|
||||
- `erp-plugin-runtime` 依赖 `erp-core`(使用 EventBus、ErpModule trait、AppError)
|
||||
- `erp-plugin-runtime` 依赖 `wasmtime`(WASM 运行时)
|
||||
- `erp-plugin-runtime` 不依赖任何业务 crate(auth/config/workflow/message)
|
||||
- `erp-server` 在组装时引入 `erp-plugin-runtime`
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
## 项目画像
|
||||
|
||||
**模块化 SaaS ERP 底座**,Rust + React 技术栈,提供身份权限/工作流/消息/配置四大基础模块,支持行业业务模块快速插接。
|
||||
**模块化 SaaS ERP 底座**,Rust + React 技术栈,提供身份权限/工作流/消息/配置/插件五大基础模块,支持行业业务模块快速插接。
|
||||
|
||||
关键数字:
|
||||
- 10 个 Rust crate(8 个已实现 + 2 个插件原型),1 个前端 SPA
|
||||
- 32 个数据库迁移
|
||||
- 5 个业务模块 (auth, config, workflow, message, server)
|
||||
- 11 个 Rust crate(9 个已实现 + 2 个插件原型),1 个前端 SPA
|
||||
- 34 个数据库迁移
|
||||
- 6 个业务模块 (auth, config, workflow, message, plugin, server)
|
||||
- 2 个插件 crate (plugin-prototype Host 运行时, plugin-test-sample 测试插件)
|
||||
- Health Check API (`/api/v1/health`)
|
||||
- OpenAPI JSON (`/api/docs/openapi.json`)
|
||||
- Phase 1-6 全部完成,WASM 插件原型 V1-V6 验证通过
|
||||
- Phase 1-6 全部完成,WASM 插件系统已集成到主服务
|
||||
|
||||
## 模块导航树
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
- erp-config — 字典/菜单/设置/编号规则/主题/语言
|
||||
- erp-workflow — BPMN 解析 · Token 驱动执行 · 任务分配 · 流程设计器
|
||||
- erp-message — 消息 CRUD · 模板管理 · 订阅偏好 · 通知面板 · 事件集成
|
||||
- erp-plugin — 插件管理 · WASM 运行时 · 动态表 · 数据 CRUD · 生命周期管理
|
||||
|
||||
### L3 组装层
|
||||
- [[erp-server]] — Axum 服务入口 · AppState · ModuleRegistry 集成 · 配置加载 · 数据库连接 · 优雅关闭
|
||||
@@ -67,6 +68,7 @@
|
||||
| 5 | 消息中心 | 完成 |
|
||||
| 6 | 整合与打磨 | 完成 |
|
||||
| - | WASM 插件原型 | V1-V6 验证通过 |
|
||||
| - | 插件系统集成 | 已集成到主服务 |
|
||||
|
||||
## 关键文档索引
|
||||
|
||||
|
||||
Reference in New Issue
Block a user