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 {
|
export interface PublicEntity {
|
||||||
manifest_id: string;
|
manifest_id: string;
|
||||||
|
plugin_id: string;
|
||||||
entity_name: string;
|
entity_name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Select, Spin, Input, Tooltip } from 'antd';
|
import { Select, Spin, Input, Tooltip } from 'antd';
|
||||||
import { QuestionCircleOutlined } from '@ant-design/icons';
|
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { listPluginData } from '../api/pluginData';
|
import { listPluginData, getPluginEntityRegistry } from '../api/pluginData';
|
||||||
|
|
||||||
interface EntitySelectProps {
|
interface EntitySelectProps {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
@@ -37,12 +37,38 @@ export default function EntitySelect({
|
|||||||
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
|
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [targetUnavailable, setTargetUnavailable] = useState(false);
|
const [targetUnavailable, setTargetUnavailable] = useState(false);
|
||||||
|
const [resolvedPluginId, setResolvedPluginId] = useState<string | null>(null);
|
||||||
|
|
||||||
// 跨插件时使用目标插件 ID 查询
|
// 跨插件时:先解析 manifest_id → plugin UUID
|
||||||
const effectivePluginId = refPlugin || pluginId;
|
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(
|
const fetchData = useCallback(
|
||||||
async (keyword?: string) => {
|
async (keyword?: string) => {
|
||||||
|
if (!resolvedPluginId && refPlugin) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const filter: Record<string, string> | undefined =
|
const filter: Record<string, string> | undefined =
|
||||||
@@ -70,12 +96,14 @@ export default function EntitySelect({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[effectivePluginId, entity, labelField, cascadeFrom, cascadeFilter, cascadeValue, refPlugin],
|
[effectivePluginId, entity, labelField, cascadeFrom, cascadeFilter, cascadeValue, refPlugin, resolvedPluginId],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
if (resolvedPluginId || !refPlugin) {
|
||||||
}, [fetchData]);
|
fetchData();
|
||||||
|
}
|
||||||
|
}, [fetchData, resolvedPluginId, refPlugin]);
|
||||||
|
|
||||||
// 目标插件未安装 → 降级显示
|
// 目标插件未安装 → 降级显示
|
||||||
if (targetUnavailable) {
|
if (targetUnavailable) {
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ pub struct ResolveLabelsResp {
|
|||||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
pub struct PublicEntityResp {
|
pub struct PublicEntityResp {
|
||||||
pub manifest_id: String,
|
pub manifest_id: String,
|
||||||
|
pub plugin_id: String,
|
||||||
pub entity_name: String,
|
pub entity_name: String,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -710,8 +710,9 @@ where
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut values: Vec<sea_orm::Value> = vec![ctx.tenant_id.into()];
|
let mut values: Vec<sea_orm::Value> = vec![ctx.tenant_id.into()];
|
||||||
for u in uuid_strs {
|
for u in &uuid_strs {
|
||||||
values.push(u.into());
|
let uuid: Uuid = u.parse().map_err(|e| AppError::Internal(format!("invalid uuid: {}", e)))?;
|
||||||
|
values.push(uuid.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(FromQueryResult)]
|
#[derive(FromQueryResult)]
|
||||||
@@ -771,6 +772,7 @@ where
|
|||||||
.to_string();
|
.to_string();
|
||||||
PublicEntityResp {
|
PublicEntityResp {
|
||||||
manifest_id: e.manifest_id.clone(),
|
manifest_id: e.manifest_id.clone(),
|
||||||
|
plugin_id: e.plugin_id.to_string(),
|
||||||
entity_name: e.entity_name.clone(),
|
entity_name: e.entity_name.clone(),
|
||||||
display_name,
|
display_name,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,7 +118,8 @@ impl PluginFieldType {
|
|||||||
Self::Decimal => "NUMERIC",
|
Self::Decimal => "NUMERIC",
|
||||||
Self::Boolean => "BOOLEAN",
|
Self::Boolean => "BOOLEAN",
|
||||||
Self::Date => "DATE",
|
Self::Date => "DATE",
|
||||||
Self::DateTime => "TIMESTAMPTZ",
|
// TIMESTAMPTZ cast 不是 immutable,generated column 不支持类型转换,存为 TEXT
|
||||||
|
Self::DateTime => "TEXT",
|
||||||
Self::Uuid => "UUID",
|
Self::Uuid => "UUID",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,7 +127,7 @@ impl PluginFieldType {
|
|||||||
/// Generated Column 的表达式
|
/// Generated Column 的表达式
|
||||||
pub fn generated_expr(&self, field_name: &str) -> String {
|
pub fn generated_expr(&self, field_name: &str) -> String {
|
||||||
match self {
|
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()),
|
_ => format!("(data->>'{}')::{}", field_name, self.generated_sql_type()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -687,7 +688,7 @@ label = "空标签页"
|
|||||||
assert_eq!(PluginFieldType::Decimal.generated_sql_type(), "NUMERIC");
|
assert_eq!(PluginFieldType::Decimal.generated_sql_type(), "NUMERIC");
|
||||||
assert_eq!(PluginFieldType::Boolean.generated_sql_type(), "BOOLEAN");
|
assert_eq!(PluginFieldType::Boolean.generated_sql_type(), "BOOLEAN");
|
||||||
assert_eq!(PluginFieldType::Date.generated_sql_type(), "DATE");
|
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::Uuid.generated_sql_type(), "UUID");
|
||||||
assert_eq!(PluginFieldType::Json.generated_sql_type(), "TEXT");
|
assert_eq!(PluginFieldType::Json.generated_sql_type(), "TEXT");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ impl PluginService {
|
|||||||
entity_name: Set(entity_def.name.clone()),
|
entity_name: Set(entity_def.name.clone()),
|
||||||
table_name: Set(table_name.clone()),
|
table_name: Set(table_name.clone()),
|
||||||
schema_json: Set(serde_json::to_value(entity_def).unwrap_or_default()),
|
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),
|
created_at: Set(now),
|
||||||
updated_at: Set(now),
|
updated_at: Set(now),
|
||||||
created_by: Set(Some(operator_id)),
|
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");
|
tracing::info!(manifest_id = %manifest.metadata.id, "Loading plugin into engine");
|
||||||
engine
|
engine
|
||||||
@@ -204,6 +210,9 @@ impl PluginService {
|
|||||||
|
|
||||||
let plugin_manifest_id = &manifest.metadata.id;
|
let plugin_manifest_id = &manifest.metadata.id;
|
||||||
|
|
||||||
|
// 确保插件权限已分配给 admin 角色(幂等操作)
|
||||||
|
grant_permissions_to_admin(db, tenant_id, plugin_manifest_id).await?;
|
||||||
|
|
||||||
// 如果之前是 disabled 状态,需要先卸载再重新加载到内存
|
// 如果之前是 disabled 状态,需要先卸载再重新加载到内存
|
||||||
// (disable 只改内存状态但不从 DashMap 移除)
|
// (disable 只改内存状态但不从 DashMap 移除)
|
||||||
if model.status == "disabled" {
|
if model.status == "disabled" {
|
||||||
@@ -551,32 +560,51 @@ impl PluginService {
|
|||||||
|
|
||||||
let plugin_manifest_id = &new_manifest.metadata.id;
|
let plugin_manifest_id = &new_manifest.metadata.id;
|
||||||
|
|
||||||
// 对比 schema — 为新增实体创建动态表
|
// 对比 schema — 为新增实体创建动态表 + 已有实体字段演进
|
||||||
if let Some(new_schema) = &new_manifest.schema {
|
if let Some(new_schema) = &new_manifest.schema {
|
||||||
let old_entities: Vec<&str> = old_manifest
|
let old_schema = old_manifest.schema.as_ref();
|
||||||
.schema
|
|
||||||
.as_ref()
|
|
||||||
.map(|s| s.entities.iter().map(|e| e.name.as_str()).collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
for entity in &new_schema.entities {
|
for entity in &new_schema.entities {
|
||||||
if !old_entities.contains(&entity.name.as_str()) {
|
let old_entity = old_schema
|
||||||
tracing::info!(entity = %entity.name, "创建新增实体表");
|
.and_then(|s| s.entities.iter().find(|e| e.name == entity.name));
|
||||||
DynamicTableManager::create_table(db, plugin_manifest_id, entity).await?;
|
|
||||||
|
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
|
// 先加载新版本到临时 key,确保成功后再替换旧版本(原子回滚)
|
||||||
engine.unload(plugin_manifest_id).await.ok();
|
let temp_id = format!("{}__upgrade_{}", plugin_manifest_id, Uuid::now_v7());
|
||||||
engine
|
engine
|
||||||
.load(plugin_manifest_id, &new_wasm, new_manifest.clone())
|
.load(&temp_id, &new_wasm, new_manifest.clone())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!(error = %e, "新版本 WASM 加载失败");
|
tracing::error!(error = %e, "新版本 WASM 加载失败,旧版本仍在运行");
|
||||||
e
|
e
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// 新版本加载成功,卸载旧版本并重命名新版本为正式 key
|
||||||
|
engine.unload(plugin_manifest_id).await.ok();
|
||||||
|
engine.rename_plugin(&temp_id, plugin_manifest_id).await?;
|
||||||
|
|
||||||
// 更新数据库记录
|
// 更新数据库记录
|
||||||
let wasm_hash = {
|
let wasm_hash = {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
@@ -822,6 +850,76 @@ async fn register_plugin_permissions(
|
|||||||
Ok(())
|
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}.%`。
|
/// 使用 raw SQL 按前缀匹配清理:`{plugin_manifest_id}.%`。
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ fn make_field(name: &str, field_type: PluginFieldType) -> PluginField {
|
|||||||
validation: None,
|
validation: None,
|
||||||
no_cycle: None,
|
no_cycle: None,
|
||||||
scope_role: None,
|
scope_role: None,
|
||||||
|
ref_plugin: None,
|
||||||
|
ref_fallback_label: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ fn make_test_manifest() -> PluginManifest {
|
|||||||
entities: vec![PluginEntity {
|
entities: vec![PluginEntity {
|
||||||
name: "item".to_string(),
|
name: "item".to_string(),
|
||||||
display_name: "测试项".to_string(),
|
display_name: "测试项".to_string(),
|
||||||
|
is_public: None,
|
||||||
fields: vec![
|
fields: vec![
|
||||||
PluginField {
|
PluginField {
|
||||||
name: "code".to_string(),
|
name: "code".to_string(),
|
||||||
@@ -95,7 +98,7 @@ fn make_test_manifest() -> PluginManifest {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_dynamic_table_create_and_query() {
|
async fn test_dynamic_table_create_and_query() {
|
||||||
let test_db = TestDb::new().await;
|
let test_db = TestDb::new().await;
|
||||||
let db = &test_db.db;
|
let db = test_db.db();
|
||||||
|
|
||||||
let manifest = make_test_manifest();
|
let manifest = make_test_manifest();
|
||||||
let entity = &manifest.schema.as_ref().unwrap().entities[0];
|
let entity = &manifest.schema.as_ref().unwrap().entities[0];
|
||||||
@@ -156,7 +159,7 @@ async fn test_dynamic_table_create_and_query() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_tenant_isolation_in_dynamic_table() {
|
async fn test_tenant_isolation_in_dynamic_table() {
|
||||||
let test_db = TestDb::new().await;
|
let test_db = TestDb::new().await;
|
||||||
let db = &test_db.db;
|
let db = test_db.db();
|
||||||
|
|
||||||
let manifest = make_test_manifest();
|
let manifest = make_test_manifest();
|
||||||
let entity = &manifest.schema.as_ref().unwrap().entities[0];
|
let entity = &manifest.schema.as_ref().unwrap().entities[0];
|
||||||
|
|||||||
Reference in New Issue
Block a user