fix(plugin): P1 跨插件引用修复 — DateTime generated column + resolve-labels UUID 类型 + EntitySelect manifest→UUID 映射
- 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:
Binary file not shown.
@@ -198,6 +198,7 @@ export async function resolveRefLabels(
|
||||
|
||||
export interface PublicEntity {
|
||||
manifest_id: string;
|
||||
plugin_id: string;
|
||||
entity_name: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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}.%`。
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user