diff --git a/CLAUDE.md b/CLAUDE.md index 23fe4a5..0c8f756 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 回调) | ✅ 原型验证 | diff --git a/Cargo.lock b/Cargo.lock index 31f2f87..a09fdec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index e632cb4..e9cbe7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0d07f8c..52520e0 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> diff --git a/apps/web/src/api/pluginData.ts b/apps/web/src/api/pluginData.ts new file mode 100644 index 0000000..4e629a8 --- /dev/null +++ b/apps/web/src/api/pluginData.ts @@ -0,0 +1,71 @@ +import client from './client'; + +export interface PluginDataRecord { + id: string; + data: Record; + 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, +) { + 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, + 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}`); +} diff --git a/apps/web/src/api/plugins.ts b/apps/web/src/api/plugins.ts new file mode 100644 index 0000000..bbceb47 --- /dev/null +++ b/apps/web/src/api/plugins.ts @@ -0,0 +1,121 @@ +import client from './client'; + +export interface PaginatedResponse { + 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; + 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 }>( + '/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 }; + }>(`/admin/plugins/${id}/health`); + return data.data; +} + +export async function updatePluginConfig(id: string, config: Record, 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 }>( + `/admin/plugins/${id}/schema`, + ); + return data.data; +} diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index 9744e30..2382793 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -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: , label: '系统设置' }, + { key: '/plugins/admin', icon: , label: '插件管理' }, ]; const routeTitleMap: Record = { @@ -52,6 +55,7 @@ const routeTitleMap: Record = { '/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 }) ))} + {/* 菜单组:插件 */} + {pluginMenuItems.length > 0 && ( + <> + {!sidebarCollapsed &&
插件
} +
+ {pluginMenuItems.map((item) => ( + , + label: item.label, + }} + isActive={currentPath === item.key} + collapsed={sidebarCollapsed} + onClick={() => navigate(item.key)} + /> + ))} +
+ + )} + {/* 菜单组:系统 */} {!sidebarCollapsed &&
系统
}
@@ -187,7 +219,9 @@ export default function MainLayout({ children }: { children: React.ReactNode }) {sidebarCollapsed ? : }
- {routeTitleMap[currentPath] || '页面'} + {routeTitleMap[currentPath] || + pluginMenuItems.find((p) => p.key === currentPath)?.label || + '页面'} diff --git a/apps/web/src/pages/PluginAdmin.tsx b/apps/web/src/pages/PluginAdmin.tsx new file mode 100644 index 0000000..0e5e4ff --- /dev/null +++ b/apps/web/src/pages/PluginAdmin.tsx @@ -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 = { + 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([]); + 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(null); + const [detailPlugin, setDetailPlugin] = useState(null); + const [healthDetail, setHealthDetail] = useState | null>(null); + const [actionLoading, setActionLoading] = useState(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, 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( + , + ); + break; + case 'installed': + btns.push( + , + ); + break; + case 'enabled': + case 'running': + btns.push( + , + ); + break; + case 'disabled': + btns.push( + , + ); + 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 {cfg.label}; + }, + }, + { title: '作者', dataIndex: 'author', key: 'author', width: 120 }, + { + title: '描述', + dataIndex: 'description', + key: 'description', + ellipsis: true, + }, + { + title: '操作', + key: 'action', + width: 320, + render: (_: unknown, record: PluginInfo) => ( + + {getActions(record)} + + handleAction(record.id, async () => { await purgePlugin(record.id); return record; }, '清除')} + > + + + + ), + }, + ]; + + return ( +
+
+ + + + +
+ + setPage(p), + showTotal: (t) => `共 ${t} 个插件`, + }} + /> + + setUploadModalOpen(false)} + okText="上传" + width={600} + > +
+ + { + setWasmFile(file); + return false; + }} + maxCount={1} + accept=".wasm" + fileList={wasmFile ? [wasmFile as unknown as Parameters[0]] : []} + onRemove={() => setWasmFile(null)} + > + + + + + setManifestText(e.target.value)} + placeholder="[metadata] +id = "my-plugin" +name = "我的插件" +version = "0.1.0"" + /> + + +
+ + { + setDetailPlugin(null); + setHealthDetail(null); + }} + width={500} + > + {detailPlugin && ( + + {detailPlugin.id} + {detailPlugin.name} + {detailPlugin.version} + + + {STATUS_CONFIG[detailPlugin.status]?.label || detailPlugin.status} + + + {detailPlugin.author || '-'} + {detailPlugin.description || '-'} + {detailPlugin.installed_at || '-'} + {detailPlugin.enabled_at || '-'} + {detailPlugin.entities.length} + + )} + +
+ + {healthDetail && ( +
+              {JSON.stringify(healthDetail, null, 2)}
+            
+ )} +
+
+ + ); +} diff --git a/apps/web/src/pages/PluginCRUDPage.tsx b/apps/web/src/pages/PluginCRUDPage.tsx new file mode 100644 index 0000000..5977e90 --- /dev/null +++ b/apps/web/src/pages/PluginCRUDPage.tsx @@ -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[]>([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [fields, setFields] = useState([]); + const [displayName, setDisplayName] = useState(entityName || ''); + const [modalOpen, setModalOpen] = useState(false); + const [editRecord, setEditRecord] = useState | 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) => { + if (!pluginId || !entityName) return; + // 去除内部字段 + const { _id, _version, ...data } = values as Record & { _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) => { + 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 ? : ; + return String(val ?? '-'); + }, + })), + { + title: '操作', + key: 'action', + width: 150, + render: (_: unknown, record: Record) => ( + + + handleDelete(record)}> + + + + ), + }, + ]; + + // 动态生成表单字段 + const renderFormField = (field: FieldDef) => { + const widget = field.ui_widget || field.field_type; + switch (widget) { + case 'number': + case 'integer': + case 'float': + case 'decimal': + return ; + case 'boolean': + return ; + case 'date': + case 'datetime': + return ; + case 'select': + return ( + + ); + default: + return ; + } + }; + + return ( +
+
+

{displayName}

+ + + + +
+ +
setPage(p), + showTotal: (t) => `共 ${t} 条`, + }} + /> + + { + setModalOpen(false); + setEditRecord(null); + }} + onOk={() => form.submit()} + destroyOnClose + > +
+ {fields.map((field) => ( + + {renderFormField(field)} + + ))} + +
+ + ); +} diff --git a/apps/web/src/stores/plugin.ts b/apps/web/src/stores/plugin.ts new file mode 100644 index 0000000..4ba5d6a --- /dev/null +++ b/apps/web/src/stores/plugin.ts @@ -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; + refreshMenuItems: () => void; +} + +export const usePluginStore = create((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 }); + }, +})); diff --git a/crates/erp-auth/src/service/seed.rs b/crates/erp-auth/src/service/seed.rs index 489d247..bb6ec87 100644 --- a/crates/erp-auth/src/service/seed.rs +++ b/crates/erp-auth/src/service/seed.rs @@ -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. diff --git a/crates/erp-core/src/events.rs b/crates/erp-core/src/events.rs index 488a5a2..58b5482 100644 --- a/crates/erp-core/src/events.rs +++ b/crates/erp-core/src/events.rs @@ -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, +} + +impl FilteredEventReceiver { + /// 接收下一个匹配的事件 + pub async fn recv(&mut self) -> Option { + 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 { 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, + }, + ) + } } diff --git a/crates/erp-core/src/lib.rs b/crates/erp-core/src/lib.rs index 8177c84..b4c2a50 100644 --- a/crates/erp-core/src/lib.rs +++ b/crates/erp-core/src/lib.rs @@ -6,3 +6,6 @@ pub mod events; pub mod module; pub mod rbac; pub mod types; + +// 便捷导出 +pub use module::{ModuleContext, ModuleType}; diff --git a/crates/erp-core/src/module.rs b/crates/erp-core/src/module.rs index c21862c..98239e7 100644 --- a/crates/erp-core/src/module.rs +++ b/crates/erp-core/src/module.rs @@ -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 { + 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] { &self.modules } + + /// 按名称获取模块 + pub fn get_module(&self, name: &str) -> Option> { + self.modules.iter().find(|m| m.name() == name).cloned() + } + + /// 按拓扑排序返回模块(依赖在前,被依赖在后) + /// + /// 使用 Kahn 算法,环检测返回 Validation 错误。 + pub fn sorted_modules(&self) -> AppResult>> { + 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![vec![]; n]; + let mut in_degree: Vec = 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 = (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)> { + 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()); + } } diff --git a/crates/erp-plugin/Cargo.toml b/crates/erp-plugin/Cargo.toml new file mode 100644 index 0000000..b50d7b5 --- /dev/null +++ b/crates/erp-plugin/Cargo.toml @@ -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 } diff --git a/crates/erp-plugin/src/data_dto.rs b/crates/erp-plugin/src/data_dto.rs new file mode 100644 index 0000000..f4934c9 --- /dev/null +++ b/crates/erp-plugin/src/data_dto.rs @@ -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>, + pub updated_at: Option>, + pub version: Option, +} + +/// 创建插件数据请求 +#[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, + pub page_size: Option, + pub search: Option, +} diff --git a/crates/erp-plugin/src/data_service.rs b/crates/erp-plugin/src/data_service.rs new file mode 100644 index 0000000..8f5960e --- /dev/null +++ b/crates/erp-plugin/src/data_service.rs @@ -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 { + 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, + updated_at: chrono::DateTime, + 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, 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, + updated_at: chrono::DateTime, + 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 { + 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, + updated_at: chrono::DateTime, + 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 { + 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, + updated_at: chrono::DateTime, + 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 { + 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) +} diff --git a/crates/erp-plugin/src/dto.rs b/crates/erp-plugin/src/dto.rs new file mode 100644 index 0000000..fafbcd7 --- /dev/null +++ b/crates/erp-plugin/src/dto.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + pub status: String, + pub config: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub installed_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled_at: Option>, + pub entities: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub permissions: Option>, + 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, + pub page_size: Option, + pub status: Option, + pub search: Option, +} diff --git a/crates/erp-plugin/src/dynamic_table.rs b/crates/erp-plugin/src/dynamic_table.rs new file mode 100644 index 0000000..c1ceb30 --- /dev/null +++ b/crates/erp-plugin/src/dynamic_table.rs @@ -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 { + #[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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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) + } +} diff --git a/crates/erp-plugin/src/engine.rs b/crates/erp-plugin/src/engine.rs new file mode 100644 index 0000000..ac2dc70 --- /dev/null +++ b/crates/erp-plugin/src/engine.rs @@ -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, + pub status: RwLock, + pub event_handles: RwLock>>, +} + +/// WASM 执行上下文 — 传递真实的租户和用户信息 +#[derive(Debug, Clone)] +pub struct ExecutionContext { + pub tenant_id: Uuid, + pub user_id: Uuid, + pub permissions: Vec, +} + +/// 插件引擎 — 管理所有已加载插件的 WASM 运行时 +#[derive(Clone)] +pub struct PluginEngine { + engine: Arc, + db: DatabaseConnection, + event_bus: EventBus, + plugins: Arc>>, + config: PluginEngineConfig, +} + +impl PluginEngine { + /// 创建新的插件引擎 + pub fn new( + db: DatabaseConnection, + event_bus: EventBus, + config: PluginEngineConfig, + ) -> PluginResult { + 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>(&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 { + 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 { + 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 { + 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> { + 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> { + 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( + &self, + plugin_id: &str, + exec_ctx: &ExecutionContext, + operation: F, + ) -> PluginResult + where + F: FnOnce(&mut Store, &PluginWorld) -> PluginResult + + 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, Vec) = + 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, + 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::().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::().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::().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, +} diff --git a/crates/erp-plugin/src/entity/mod.rs b/crates/erp-plugin/src/entity/mod.rs new file mode 100644 index 0000000..0ae3634 --- /dev/null +++ b/crates/erp-plugin/src/entity/mod.rs @@ -0,0 +1,3 @@ +pub mod plugin; +pub mod plugin_entity; +pub mod plugin_event_subscription; diff --git a/crates/erp-plugin/src/entity/plugin.rs b/crates/erp-plugin/src/entity/plugin.rs new file mode 100644 index 0000000..a867b9e --- /dev/null +++ b/crates/erp-plugin/src/entity/plugin.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + pub status: String, + pub manifest_json: serde_json::Value, + #[serde(skip)] + pub wasm_binary: Vec, + pub wasm_hash: String, + pub config_json: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub installed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled_at: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + 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 for Entity { + fn to() -> RelationDef { + Relation::PluginEntity.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-plugin/src/entity/plugin_entity.rs b/crates/erp-plugin/src/entity/plugin_entity.rs new file mode 100644 index 0000000..08ecf26 --- /dev/null +++ b/crates/erp-plugin/src/entity/plugin_entity.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + 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 for Entity { + fn to() -> RelationDef { + Relation::Plugin.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-plugin/src/entity/plugin_event_subscription.rs b/crates/erp-plugin/src/entity/plugin_event_subscription.rs new file mode 100644 index 0000000..de73dc1 --- /dev/null +++ b/crates/erp-plugin/src/entity/plugin_event_subscription.rs @@ -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 for Entity { + fn to() -> RelationDef { + Relation::Plugin.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-plugin/src/error.rs b/crates/erp-plugin/src/error.rs new file mode 100644 index 0000000..7e2968c --- /dev/null +++ b/crates/erp-plugin/src/error.rs @@ -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 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 = Result; diff --git a/crates/erp-plugin/src/handler/data_handler.rs b/crates/erp-plugin/src/handler/data_handler.rs new file mode 100644 index 0000000..cc1d0b1 --- /dev/null +++ b/crates/erp-plugin/src/handler/data_handler.rs @@ -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>), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// GET /api/v1/plugins/{plugin_id}/{entity} — 列表 +pub async fn list_plugin_data( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity)): Path<(Uuid, String)>, + Query(params): Query, +) -> Result>>, AppError> +where + PluginState: FromRef, + 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), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// POST /api/v1/plugins/{plugin_id}/{entity} — 创建 +pub async fn create_plugin_data( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity)): Path<(Uuid, String)>, + Json(req): Json, +) -> Result>, AppError> +where + PluginState: FromRef, + 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), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// GET /api/v1/plugins/{plugin_id}/{entity}/{id} — 详情 +pub async fn get_plugin_data( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity, id)): Path<(Uuid, String, Uuid)>, +) -> Result>, AppError> +where + PluginState: FromRef, + 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), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// PUT /api/v1/plugins/{plugin_id}/{entity}/{id} — 更新 +pub async fn update_plugin_data( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity, id)): Path<(Uuid, String, Uuid)>, + Json(req): Json, +) -> Result>, AppError> +where + PluginState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity, id)): Path<(Uuid, String, Uuid)>, +) -> Result>, AppError> +where + PluginState: FromRef, + 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(()))) +} diff --git a/crates/erp-plugin/src/handler/mod.rs b/crates/erp-plugin/src/handler/mod.rs new file mode 100644 index 0000000..f82418d --- /dev/null +++ b/crates/erp-plugin/src/handler/mod.rs @@ -0,0 +1,2 @@ +pub mod data_handler; +pub mod plugin_handler; diff --git a/crates/erp-plugin/src/handler/plugin_handler.rs b/crates/erp-plugin/src/handler/plugin_handler.rs new file mode 100644 index 0000000..6e6d06d --- /dev/null +++ b/crates/erp-plugin/src/handler/plugin_handler.rs @@ -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), + (status = 401, description = "未授权"), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// POST /api/v1/admin/plugins/upload — 上传插件 (multipart: wasm + manifest) +pub async fn upload_plugin( + State(state): State, + Extension(ctx): Extension, + mut multipart: Multipart, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + + let mut wasm_binary: Option> = None; + let mut manifest_toml: Option = 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>), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// GET /api/v1/admin/plugins — 列表 +pub async fn list_plugins( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result>>, AppError> +where + PluginState: FromRef, + 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), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// GET /api/v1/admin/plugins/{id} — 详情 +pub async fn get_plugin( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + PluginState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + PluginState: FromRef, + 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), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// POST /api/v1/admin/plugins/{id}/install — 安装 +pub async fn install_plugin( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + let 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), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// POST /api/v1/admin/plugins/{id}/enable — 启用 +pub async fn enable_plugin( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + let 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), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// POST /api/v1/admin/plugins/{id}/disable — 停用 +pub async fn disable_plugin( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + let 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), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// POST /api/v1/admin/plugins/{id}/uninstall — 卸载 +pub async fn uninstall_plugin( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + let 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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + PluginState: FromRef, + 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), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// GET /api/v1/admin/plugins/{id}/health — 健康检查 +pub async fn health_check_plugin( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + PluginState: FromRef, + 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), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// PUT /api/v1/admin/plugins/{id}/config — 更新配置 +pub async fn update_plugin_config( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + PluginState: FromRef, + 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))) +} diff --git a/crates/erp-plugin/src/host.rs b/crates/erp-plugin/src/host.rs new file mode 100644 index 0000000..d163e8c --- /dev/null +++ b/crates/erp-plugin/src/host.rs @@ -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, + }, + Update { + entity: String, + id: String, + data: Vec, + version: i64, + }, + Delete { + entity: String, + id: String, + }, + PublishEvent { + event_type: String, + payload: Vec, + }, +} + +/// 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, + pub(crate) plugin_id: String, + // 预填充的读取缓存 + pub(crate) query_results: HashMap>, + pub(crate) config_cache: HashMap>, + pub(crate) current_user_json: Vec, + // 待刷新的写操作 + pub(crate) pending_ops: Vec, + // 日志 + pub(crate) logs: Vec<(String, String)>, +} + +impl HostState { + pub fn new( + plugin_id: String, + tenant_id: Uuid, + user_id: Uuid, + permissions: Vec, + ) -> 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) -> Result, 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, + _pagination: Vec, + ) -> Result, String> { + self.query_results + .get(&entity) + .cloned() + .ok_or_else(|| format!("实体 '{}' 的查询结果未预填充", entity)) + } + + fn db_update( + &mut self, + entity: String, + id: String, + data: Vec, + version: i64, + ) -> Result, 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) -> Result<(), String> { + self.pending_ops.push(PendingOp::PublishEvent { + event_type, + payload, + }); + Ok(()) + } + + fn config_get(&mut self, key: String) -> Result, 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, String> { + Ok(self.current_user_json.clone()) + } + + fn check_permission(&mut self, permission: String) -> Result { + Ok(self.permissions.contains(&permission)) + } +} diff --git a/crates/erp-plugin/src/lib.rs b/crates/erp-plugin/src/lib.rs new file mode 100644 index 0000000..bc3c4b6 --- /dev/null +++ b/crates/erp-plugin/src/lib.rs @@ -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; diff --git a/crates/erp-plugin/src/manifest.rs b/crates/erp-plugin/src/manifest.rs new file mode 100644 index 0000000..ed6a4e4 --- /dev/null +++ b/crates/erp-plugin/src/manifest.rs @@ -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, + pub events: Option, + pub ui: Option, + pub permissions: Option>, +} + +/// 插件元数据 +#[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, + #[serde(default)] + pub dependencies: Vec, +} + +/// 插件 Schema — 定义动态实体 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginSchema { + pub entities: Vec, +} + +/// 插件实体定义 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginEntity { + pub name: String, + pub display_name: String, + #[serde(default)] + pub fields: Vec, + #[serde(default)] + pub indexes: Vec, +} + +/// 插件字段定义 +#[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, + pub display_name: Option, + pub ui_widget: Option, + pub options: Option>, +} + +/// 字段类型 +#[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, + #[serde(default)] + pub unique: bool, +} + +/// 事件订阅配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginEvents { + pub subscribe: Vec, +} + +/// UI 页面配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginUi { + pub pages: Vec, +} + +/// 插件页面定义 +#[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, +} + +/// 权限定义 +#[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 { + 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()); + } +} diff --git a/crates/erp-plugin/src/module.rs b/crates/erp-plugin/src/module.rs new file mode 100644 index 0000000..f9d5e2b --- /dev/null +++ b/crates/erp-plugin/src/module.rs @@ -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() -> Router + where + crate::state::PluginState: axum::extract::FromRef, + S: Clone + Send + Sync + 'static, + { + let admin_routes = Router::new() + .route("/admin/plugins/upload", post(crate::handler::plugin_handler::upload_plugin::)) + .route("/admin/plugins", get(crate::handler::plugin_handler::list_plugins::)) + .route( + "/admin/plugins/{id}", + get(crate::handler::plugin_handler::get_plugin::) + .delete(crate::handler::plugin_handler::purge_plugin::), + ) + .route( + "/admin/plugins/{id}/schema", + get(crate::handler::plugin_handler::get_plugin_schema::), + ) + .route( + "/admin/plugins/{id}/install", + post(crate::handler::plugin_handler::install_plugin::), + ) + .route( + "/admin/plugins/{id}/enable", + post(crate::handler::plugin_handler::enable_plugin::), + ) + .route( + "/admin/plugins/{id}/disable", + post(crate::handler::plugin_handler::disable_plugin::), + ) + .route( + "/admin/plugins/{id}/uninstall", + post(crate::handler::plugin_handler::uninstall_plugin::), + ) + .route( + "/admin/plugins/{id}/health", + get(crate::handler::plugin_handler::health_check_plugin::), + ) + .route( + "/admin/plugins/{id}/config", + put(crate::handler::plugin_handler::update_plugin_config::), + ); + + // 插件数据 CRUD 路由 + let data_routes = Router::new() + .route( + "/plugins/{plugin_id}/{entity}", + get(crate::handler::data_handler::list_plugin_data::) + .post(crate::handler::data_handler::create_plugin_data::), + ) + .route( + "/plugins/{plugin_id}/{entity}/{id}", + get(crate::handler::data_handler::get_plugin_data::) + .put(crate::handler::data_handler::update_plugin_data::) + .delete(crate::handler::data_handler::delete_plugin_data::), + ); + + admin_routes.merge(data_routes) + } +} diff --git a/crates/erp-plugin/src/service.rs b/crates/erp-plugin/src/service.rs new file mode 100644 index 0000000..eb4c8ee --- /dev/null +++ b/crates/erp-plugin/src/service.rs @@ -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, + manifest_toml: &str, + db: &sea_orm::DatabaseConnection, + ) -> AppResult { + // 解析 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 { + 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 { + 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 { + 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 { + 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, 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 { + 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 { + 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 { + 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 { + 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> + 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> { + 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, +) -> 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, + } +} diff --git a/crates/erp-plugin/src/state.rs b/crates/erp-plugin/src/state.rs new file mode 100644 index 0000000..6e1c420 --- /dev/null +++ b/crates/erp-plugin/src/state.rs @@ -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, +} diff --git a/crates/erp-plugin/wit/plugin.wit b/crates/erp-plugin/wit/plugin.wit new file mode 100644 index 0000000..a61ca68 --- /dev/null +++ b/crates/erp-plugin/wit/plugin.wit @@ -0,0 +1,48 @@ +package erp:plugin; + +/// 宿主暴露给插件的 API(插件 import 这些函数) +interface host-api { + /// 插入记录(宿主自动注入 tenant_id, id, created_at 等标准字段) + db-insert: func(entity: string, data: list) -> result, string>; + + /// 查询记录(自动注入 tenant_id 过滤 + 软删除过滤) + db-query: func(entity: string, filter: list, pagination: list) -> result, string>; + + /// 更新记录(自动检查 version 乐观锁) + db-update: func(entity: string, id: string, data: list, version: s64) -> result, string>; + + /// 软删除记录 + db-delete: func(entity: string, id: string) -> result<_, string>; + + /// 发布领域事件 + event-publish: func(event-type: string, payload: list) -> result<_, string>; + + /// 读取系统配置 + config-get: func(key: string) -> result, string>; + + /// 写日志(自动关联 tenant_id + plugin_id) + log-write: func(level: string, message: string); + + /// 获取当前用户信息 + current-user: func() -> result, string>; + + /// 检查当前用户权限 + check-permission: func(permission: string) -> result; +} + +/// 插件导出的 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) -> result<_, string>; +} + +world plugin-world { + import host-api; + export plugin-api; +} diff --git a/crates/erp-server/Cargo.toml b/crates/erp-server/Cargo.toml index 8cf986a..5185547 100644 --- a/crates/erp-server/Cargo.toml +++ b/crates/erp-server/Cargo.toml @@ -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 diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index d889cda..622e04a 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -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), ] } } diff --git a/crates/erp-server/migration/src/m20260414_000032_fix_settings_unique_index_null.rs b/crates/erp-server/migration/src/m20260414_000032_fix_settings_unique_index_null.rs index b86d845..e5f77a9 100644 --- a/crates/erp-server/migration/src/m20260414_000032_fix_settings_unique_index_null.rs +++ b/crates/erp-server/migration/src/m20260414_000032_fix_settings_unique_index_null.rs @@ -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(()) } diff --git a/crates/erp-server/migration/src/m20260417_000033_create_plugins.rs b/crates/erp-server/migration/src/m20260417_000033_create_plugins.rs new file mode 100644 index 0000000..8326679 --- /dev/null +++ b/crates/erp-server/migration/src/m20260417_000033_create_plugins.rs @@ -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 + } +} diff --git a/crates/erp-server/migration/src/m20260417_000034_seed_plugin_permissions.rs b/crates/erp-server/migration/src/m20260417_000034_seed_plugin_permissions.rs new file mode 100644 index 0000000..51c8847 --- /dev/null +++ b/crates/erp-server/migration/src/m20260417_000034_seed_plugin_permissions.rs @@ -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(()) + } +} diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index 26dbc92..22d1414 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -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(()) } diff --git a/crates/erp-server/src/state.rs b/crates/erp-server/src/state.rs index 0e9545c..f242042 100644 --- a/crates/erp-server/src/state.rs +++ b/crates/erp-server/src/state.rs @@ -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`. @@ -80,3 +82,14 @@ impl FromRef for erp_message::MessageState { } } } + +/// Allow erp-plugin handlers to extract their required state. +impl FromRef 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(), + } + } +} diff --git a/dev.ps1 b/dev.ps1 new file mode 100644 index 0000000..10fbce1 --- /dev/null +++ b/dev.ps1 @@ -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 +} diff --git a/docs/superpowers/plans/2026-04-13-wasm-plugin-system-plan.md b/docs/superpowers/plans/2026-04-13-wasm-plugin-system-plan.md new file mode 100644 index 0000000..ab42ccc --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-wasm-plugin-system-plan.md @@ -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, +} + +pub struct ModuleContext { + pub db: sea_orm::DatabaseConnection, + pub event_bus: crate::events::EventBus, + pub config: Arc, +} + +// 在 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 { + Ok(ModuleHealth { status: "ok".into(), details: None }) +} +fn public_routes(&self) -> Option { None } // 需要 axum 依赖 +fn protected_routes(&self) -> Option { None } +fn migrations(&self) -> Vec> { vec![] } +fn config_schema(&self) -> Option { 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> { ... } + pub fn build_routes(&self) -> (axum::Router, axum::Router) { + // 遍历 modules,收集 public_routes + protected_routes + } + fn topological_sort(&self) -> crate::error::AppResult>> { + // 基于 dependencies() 的 Kahn 算法拓扑排序 + } + pub async fn startup_all(&self, ctx: &ModuleContext) -> crate::error::AppResult<()> { + // 按拓扑顺序调用 on_startup + } + pub async fn health_check_all(&self) -> HashMap { ... } +} +``` + +- [ ] **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()` 关联函数改为 `fn public_routes(&self) -> Option` trait 方法 +- 同样处理 `protected_routes` +- 添加 `fn id()` 返回与 `name()` 相同值 + +每个模块的改动模式: +```rust +// 之前: pub fn public_routes() -> Router where ... { Router::new().route(...) } +// 之后: +fn public_routes(&self) -> Option { + 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 { 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; +pub type SubscriptionId = Uuid; + +pub struct EventBus { + sender: broadcast::Sender, + handlers: Arc>>>, +} + +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`。 + +- [ ] **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`:查询 `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 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; + 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 +} /> +``` +同时添加 `/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 页面后续迭代 | diff --git a/docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md b/docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md new file mode 100644 index 0000000..690dc20 --- /dev/null +++ b/docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md @@ -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>; + + /// 查询记录(自动注入 tenant_id 过滤 + 软删除过滤) + fn db_query(&mut self, entity: &str, filter: &[u8], pagination: &[u8]) -> Result; + + /// 更新记录(自动检查 version 乐观锁) + fn db_update(&mut self, entity: &str, id: &str, data: &[u8], version: i64) -> Result; + + /// 软删除记录 + fn db_delete(&mut self, entity: &str, id: &str) -> Result<()>; + + /// 原始查询(仅允许 SELECT,自动注入 tenant_id 过滤) + fn db_raw_query(&mut self, sql: &str, params: &[u8]) -> Result; + + // === 事件总线 === + + /// 发布领域事件 + fn event_publish(&mut self, event_type: &str, payload: &[u8]) -> Result<()>; + + // === 配置 === + + /// 读取系统配置(插件作用域内) + fn config_get(&mut self, key: &str) -> Result; + + // === 日志 === + + /// 写日志(自动关联 tenant_id + plugin_id) + fn log_write(&mut self, level: &str, message: &str); + + // === 用户/权限 === + + /// 获取当前用户信息 + fn current_user(&mut self) -> Result; + + /// 检查当前用户权限 + fn check_permission(&mut self, permission: &str) -> Result; +} +``` + +### 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 { + // 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 { + Ok(ModuleHealth { status: "ok".into(), details: None }) + } + + // 路由 + fn public_routes(&self) -> Option { None } + fn protected_routes(&self) -> Option { None } + + // 数据库 + fn migrations(&self) -> Vec> { 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 { None } + + fn as_any(&self) -> &dyn Any; +} + +pub enum ModuleType { Native, Wasm } + +pub struct ModuleHealth { + pub status: String, + pub details: Option, +} + +pub struct ModuleContext { + pub db: DatabaseConnection, + pub event_bus: EventBus, + pub config: Arc, +} +``` + +### 9.2 ModuleRegistry v2 + +```rust +pub struct ModuleRegistry { + modules: Arc>>, + wasm_runtime: Arc, + index: Arc>, +} + +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; + + // 自动收集所有路由 + pub fn build_routes(&self) -> (Router, Router); + + // 自动收集所有迁移 + pub fn collect_migrations(&self) -> Vec>; + + // 拓扑排序(基于 dependencies) + fn topological_sort(&self) -> AppResult>>; + + // 按 ID 查找模块 + pub fn get_module(&self, id: &str) -> Option<&Arc>; +} +``` + +### 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) -> 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, + ) -> SubscriptionHandle { + // 在内部 HashMap> 中注册 + // publish() 时根据 event_type 分发到匹配的 handler + } + + /// 取消订阅(用于插件停用时清理) + pub fn unsubscribe(&self, handle: SubscriptionHandle) { /* ... */ } +} +``` + +### B.2 插件事件处理器包装 + +```rust +struct PluginEventHandler { + plugin_id: String, + handler_fn: Box, +} + +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) -> result, string>; + db-query: func(entity: string, filter: list, pagination: list) -> result, string>; + db-update: func(entity: string, id: string, data: list, version: s64) -> result, string>; + db-delete: func(entity: string, id: string) -> result<_, string>; + db-aggregate: func(entity: string, query: list) -> result, string>; + + /// 事件总线 + event-publish: func(event-type: string, payload: list) -> result<_, string>; + + /// 配置 + config-get: func(key: string) -> result, string>; + + /// 日志 + log-write: func(level: string, message: string); + + /// 用户/权限 + current-user: func() -> result, string>; + check-permission: func(permission: string) -> result; +} + +interface plugin { + /// 插件初始化(加载时调用一次) + init: func() -> result<_, string>; + + /// 租户创建时调用 + on-tenant-created: func(tenant-id: string) -> result<_, string>; + + /// 处理订阅的事件 + handle-event: func(event-type: string, payload: list) -> result<_, string>; + + /// 自定义页面渲染(仅 type=custom 页面) + render-page: func(page-path: string, params: list) -> result, string>; + + /// 自定义页面操作处理 + handle-action: func(page-path: string, action: string, data: list) -> result, 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` diff --git a/wiki/index.md b/wiki/index.md index e35a940..719cc12 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -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 验证通过 | +| - | 插件系统集成 | 已集成到主服务 | ## 关键文档索引