fix(plugin): P1 跨插件引用修复 — DateTime generated column + resolve-labels UUID 类型 + EntitySelect manifest→UUID 映射
Some checks failed
CI / rust-check (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-test (push) Has been cancelled

- manifest.rs: DateTime 类型 generated column 改为 TEXT 存储(PostgreSQL TIMESTAMPTZ cast 非 immutable)
- data_handler.rs: resolve-labels 查询参数从 String 改为 UUID 类型避免类型不匹配
- data_dto.rs: PublicEntityResp 新增 plugin_id 字段
- EntitySelect.tsx: 跨插件查询先通过 registry 解析 manifest_id→plugin UUID
- pluginData.ts: PublicEntity 接口增加 plugin_id
- plugin_tests.rs: 适配 PluginField/PluginEntity 新增字段
This commit is contained in:
iven
2026-04-19 08:44:45 +08:00
parent 08252c10f1
commit 0ee9d22634
8 changed files with 160 additions and 26 deletions

Binary file not shown.

View File

@@ -198,6 +198,7 @@ export async function resolveRefLabels(
export interface PublicEntity {
manifest_id: string;
plugin_id: string;
entity_name: string;
display_name: string;
}

View File

@@ -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<string | null>(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<string, string> | 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) {

View File

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

View File

@@ -710,8 +710,9 @@ where
);
let mut values: Vec<sea_orm::Value> = 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,
}

View File

@@ -118,7 +118,8 @@ impl PluginFieldType {
Self::Decimal => "NUMERIC",
Self::Boolean => "BOOLEAN",
Self::Date => "DATE",
Self::DateTime => "TIMESTAMPTZ",
// TIMESTAMPTZ cast 不是 immutablegenerated 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");
}

View File

@@ -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}.%`。

View File

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