diff --git a/apps/web/public/inventory.wasm b/apps/web/public/inventory.wasm index ec18f60..f402120 100644 Binary files a/apps/web/public/inventory.wasm and b/apps/web/public/inventory.wasm differ diff --git a/apps/web/src/api/pluginData.ts b/apps/web/src/api/pluginData.ts index 6ab3d22..1b66b82 100644 --- a/apps/web/src/api/pluginData.ts +++ b/apps/web/src/api/pluginData.ts @@ -198,6 +198,7 @@ export async function resolveRefLabels( export interface PublicEntity { manifest_id: string; + plugin_id: string; entity_name: string; display_name: string; } diff --git a/apps/web/src/components/EntitySelect.tsx b/apps/web/src/components/EntitySelect.tsx index edf59cb..b5f1795 100644 --- a/apps/web/src/components/EntitySelect.tsx +++ b/apps/web/src/components/EntitySelect.tsx @@ -1,7 +1,7 @@ import { Select, Spin, Input, Tooltip } from 'antd'; import { QuestionCircleOutlined } from '@ant-design/icons'; import { useState, useEffect, useCallback } from 'react'; -import { listPluginData } from '../api/pluginData'; +import { listPluginData, getPluginEntityRegistry } from '../api/pluginData'; interface EntitySelectProps { pluginId: string; @@ -37,12 +37,38 @@ export default function EntitySelect({ const [options, setOptions] = useState<{ value: string; label: string }[]>([]); const [loading, setLoading] = useState(false); const [targetUnavailable, setTargetUnavailable] = useState(false); + const [resolvedPluginId, setResolvedPluginId] = useState(null); - // 跨插件时使用目标插件 ID 查询 - const effectivePluginId = refPlugin || pluginId; + // 跨插件时:先解析 manifest_id → plugin UUID + useEffect(() => { + if (!refPlugin) { + setResolvedPluginId(pluginId); + return; + } + let cancelled = false; + (async () => { + try { + const registry = await getPluginEntityRegistry(); + const match = registry.find((e) => e.manifest_id === refPlugin && e.entity_name === entity); + if (!cancelled) { + setResolvedPluginId(match ? match.plugin_id : null); + if (!match) setTargetUnavailable(true); + } + } catch { + if (!cancelled) { + setTargetUnavailable(true); + setResolvedPluginId(null); + } + } + })(); + return () => { cancelled = true; }; + }, [refPlugin, pluginId, entity]); + + const effectivePluginId = resolvedPluginId || pluginId; const fetchData = useCallback( async (keyword?: string) => { + if (!resolvedPluginId && refPlugin) return; setLoading(true); try { const filter: Record | undefined = @@ -70,12 +96,14 @@ export default function EntitySelect({ setLoading(false); } }, - [effectivePluginId, entity, labelField, cascadeFrom, cascadeFilter, cascadeValue, refPlugin], + [effectivePluginId, entity, labelField, cascadeFrom, cascadeFilter, cascadeValue, refPlugin, resolvedPluginId], ); useEffect(() => { - fetchData(); - }, [fetchData]); + if (resolvedPluginId || !refPlugin) { + fetchData(); + } + }, [fetchData, resolvedPluginId, refPlugin]); // 目标插件未安装 → 降级显示 if (targetUnavailable) { diff --git a/crates/erp-plugin/src/data_dto.rs b/crates/erp-plugin/src/data_dto.rs index e70596b..a245346 100644 --- a/crates/erp-plugin/src/data_dto.rs +++ b/crates/erp-plugin/src/data_dto.rs @@ -160,6 +160,7 @@ pub struct ResolveLabelsResp { #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct PublicEntityResp { pub manifest_id: String, + pub plugin_id: String, pub entity_name: String, pub display_name: String, } diff --git a/crates/erp-plugin/src/handler/data_handler.rs b/crates/erp-plugin/src/handler/data_handler.rs index 2181102..866436a 100644 --- a/crates/erp-plugin/src/handler/data_handler.rs +++ b/crates/erp-plugin/src/handler/data_handler.rs @@ -710,8 +710,9 @@ where ); let mut values: Vec = vec![ctx.tenant_id.into()]; - for u in uuid_strs { - values.push(u.into()); + for u in &uuid_strs { + let uuid: Uuid = u.parse().map_err(|e| AppError::Internal(format!("invalid uuid: {}", e)))?; + values.push(uuid.into()); } #[derive(FromQueryResult)] @@ -771,6 +772,7 @@ where .to_string(); PublicEntityResp { manifest_id: e.manifest_id.clone(), + plugin_id: e.plugin_id.to_string(), entity_name: e.entity_name.clone(), display_name, } diff --git a/crates/erp-plugin/src/manifest.rs b/crates/erp-plugin/src/manifest.rs index e529dfb..0251f73 100644 --- a/crates/erp-plugin/src/manifest.rs +++ b/crates/erp-plugin/src/manifest.rs @@ -118,7 +118,8 @@ impl PluginFieldType { Self::Decimal => "NUMERIC", Self::Boolean => "BOOLEAN", Self::Date => "DATE", - Self::DateTime => "TIMESTAMPTZ", + // TIMESTAMPTZ cast 不是 immutable,generated column 不支持类型转换,存为 TEXT + Self::DateTime => "TEXT", Self::Uuid => "UUID", } } @@ -126,7 +127,7 @@ impl PluginFieldType { /// Generated Column 的表达式 pub fn generated_expr(&self, field_name: &str) -> String { match self { - Self::String | Self::Json => format!("data->>'{}'", field_name), + Self::String | Self::Json | Self::DateTime => format!("data->>'{}'", field_name), _ => format!("(data->>'{}')::{}", field_name, self.generated_sql_type()), } } @@ -687,7 +688,7 @@ label = "空标签页" assert_eq!(PluginFieldType::Decimal.generated_sql_type(), "NUMERIC"); assert_eq!(PluginFieldType::Boolean.generated_sql_type(), "BOOLEAN"); assert_eq!(PluginFieldType::Date.generated_sql_type(), "DATE"); - assert_eq!(PluginFieldType::DateTime.generated_sql_type(), "TIMESTAMPTZ"); + assert_eq!(PluginFieldType::DateTime.generated_sql_type(), "TEXT"); assert_eq!(PluginFieldType::Uuid.generated_sql_type(), "UUID"); assert_eq!(PluginFieldType::Json.generated_sql_type(), "TEXT"); } diff --git a/crates/erp-plugin/src/service.rs b/crates/erp-plugin/src/service.rs index 21027e2..4a7ef18 100644 --- a/crates/erp-plugin/src/service.rs +++ b/crates/erp-plugin/src/service.rs @@ -117,6 +117,8 @@ impl PluginService { 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()), + manifest_id: Set(manifest.metadata.id.clone()), + is_public: Set(entity_def.is_public.unwrap_or(false)), created_at: Set(now), updated_at: Set(now), created_by: Set(Some(operator_id)), @@ -166,6 +168,10 @@ impl PluginService { })?; } + // 将插件权限自动分配给 admin 角色 + tracing::info!("Granting plugin permissions to admin role"); + grant_permissions_to_admin(db, tenant_id, &manifest.metadata.id).await?; + // 加载到内存 tracing::info!(manifest_id = %manifest.metadata.id, "Loading plugin into engine"); engine @@ -204,6 +210,9 @@ impl PluginService { let plugin_manifest_id = &manifest.metadata.id; + // 确保插件权限已分配给 admin 角色(幂等操作) + grant_permissions_to_admin(db, tenant_id, plugin_manifest_id).await?; + // 如果之前是 disabled 状态,需要先卸载再重新加载到内存 // (disable 只改内存状态但不从 DashMap 移除) if model.status == "disabled" { @@ -551,32 +560,51 @@ impl PluginService { let plugin_manifest_id = &new_manifest.metadata.id; - // 对比 schema — 为新增实体创建动态表 + // 对比 schema — 为新增实体创建动态表 + 已有实体字段演进 if let Some(new_schema) = &new_manifest.schema { - let old_entities: Vec<&str> = old_manifest - .schema - .as_ref() - .map(|s| s.entities.iter().map(|e| e.name.as_str()).collect()) - .unwrap_or_default(); + let old_schema = old_manifest.schema.as_ref(); for entity in &new_schema.entities { - if !old_entities.contains(&entity.name.as_str()) { - tracing::info!(entity = %entity.name, "创建新增实体表"); - DynamicTableManager::create_table(db, plugin_manifest_id, entity).await?; + let old_entity = old_schema + .and_then(|s| s.entities.iter().find(|e| e.name == entity.name)); + + match old_entity { + None => { + tracing::info!(entity = %entity.name, "创建新增实体表"); + DynamicTableManager::create_table(db, plugin_manifest_id, entity).await?; + } + Some(old) => { + let diff = DynamicTableManager::diff_entity_fields(old, entity); + if !diff.new_filterable.is_empty() || !diff.new_searchable.is_empty() { + tracing::info!( + entity = %entity.name, + new_cols = diff.new_filterable.len(), + new_search = diff.new_searchable.len(), + "Schema 演进:新增 Generated Column" + ); + DynamicTableManager::alter_add_generated_columns( + db, plugin_manifest_id, entity, &diff + ).await?; + } + } } } } - // 卸载旧 WASM 并加载新 WASM - engine.unload(plugin_manifest_id).await.ok(); + // 先加载新版本到临时 key,确保成功后再替换旧版本(原子回滚) + let temp_id = format!("{}__upgrade_{}", plugin_manifest_id, Uuid::now_v7()); engine - .load(plugin_manifest_id, &new_wasm, new_manifest.clone()) + .load(&temp_id, &new_wasm, new_manifest.clone()) .await .map_err(|e| { - tracing::error!(error = %e, "新版本 WASM 加载失败"); + tracing::error!(error = %e, "新版本 WASM 加载失败,旧版本仍在运行"); e })?; + // 新版本加载成功,卸载旧版本并重命名新版本为正式 key + engine.unload(plugin_manifest_id).await.ok(); + engine.rename_plugin(&temp_id, plugin_manifest_id).await?; + // 更新数据库记录 let wasm_hash = { let mut hasher = Sha256::new(); @@ -822,6 +850,76 @@ async fn register_plugin_permissions( Ok(()) } +/// 将插件的所有权限分配给 admin 角色。 +/// +/// 使用 raw SQL 按 manifest_id 前缀匹配权限,INSERT 到 role_permissions。 +/// ON CONFLICT DO NOTHING 保证幂等。 +pub async fn grant_permissions_to_admin( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + plugin_manifest_id: &str, +) -> AppResult<()> { + let prefix = format!("{}.%", plugin_manifest_id); + + let sql = r#" + INSERT INTO role_permissions (tenant_id, role_id, permission_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) + SELECT + p.tenant_id, + r.id, + p.id, + 'all', + NOW(), NOW(), + r.id, r.id, + NULL, 1 + FROM permissions p + CROSS JOIN roles r + WHERE p.tenant_id = $1 + AND r.tenant_id = $1 + AND r.code = 'admin' + AND r.deleted_at IS NULL + AND p.code LIKE $2 + AND p.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM role_permissions rp + WHERE rp.permission_id = p.id + AND rp.role_id = r.id + AND rp.deleted_at IS NULL + ) + ON CONFLICT (role_id, permission_id) DO NOTHING + "#; + + let result = db + .execute(sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + vec![ + sea_orm::Value::from(tenant_id), + sea_orm::Value::from(prefix.clone()), + ], + )) + .await + .map_err(|e| { + tracing::error!( + plugin = plugin_manifest_id, + error = %e, + "分配插件权限给 admin 角色失败" + ); + PluginError::DatabaseError(format!( + "分配插件权限给 admin 角色失败: {}", + e + )) + })?; + + let rows = result.rows_affected(); + tracing::info!( + plugin = plugin_manifest_id, + rows_affected = rows, + tenant_id = %tenant_id, + "插件权限已分配给 admin 角色" + ); + Ok(()) +} + /// 清理插件注册的权限(软删除)。 /// /// 使用 raw SQL 按前缀匹配清理:`{plugin_manifest_id}.%`。 diff --git a/crates/erp-server/tests/integration/plugin_tests.rs b/crates/erp-server/tests/integration/plugin_tests.rs index d7761cc..439844b 100644 --- a/crates/erp-server/tests/integration/plugin_tests.rs +++ b/crates/erp-server/tests/integration/plugin_tests.rs @@ -29,6 +29,8 @@ fn make_field(name: &str, field_type: PluginFieldType) -> PluginField { validation: None, no_cycle: None, scope_role: None, + ref_plugin: None, + ref_fallback_label: None, } } @@ -48,6 +50,7 @@ fn make_test_manifest() -> PluginManifest { entities: vec![PluginEntity { name: "item".to_string(), display_name: "测试项".to_string(), + is_public: None, fields: vec![ PluginField { name: "code".to_string(), @@ -95,7 +98,7 @@ fn make_test_manifest() -> PluginManifest { #[tokio::test] async fn test_dynamic_table_create_and_query() { let test_db = TestDb::new().await; - let db = &test_db.db; + let db = test_db.db(); let manifest = make_test_manifest(); let entity = &manifest.schema.as_ref().unwrap().entities[0]; @@ -156,7 +159,7 @@ async fn test_dynamic_table_create_and_query() { #[tokio::test] async fn test_tenant_isolation_in_dynamic_table() { let test_db = TestDb::new().await; - let db = &test_db.db; + let db = test_db.db(); let manifest = make_test_manifest(); let entity = &manifest.schema.as_ref().unwrap().entities[0];