feat(plugin): P1 跨插件数据引用系统 — 后端 Phase 1-3
实现跨插件实体引用的基础后端能力:
Phase 1 — Manifest 扩展 + Entity Registry 数据层:
- PluginField 新增 ref_plugin/ref_fallback_label 支持跨插件引用声明
- PluginRelation 新增 name/relation_type/display_field(CRM 已在用的字段)
- PluginEntity 新增 is_public 标记可被其他插件引用的实体
- 数据库迁移:plugin_entities 新增 manifest_id + is_public 列 + 索引
- SeaORM Entity 和 install 流程同步更新
Phase 2 — 后端跨插件引用解析 + 校验:
- data_service: 新增 resolve_cross_plugin_entity/is_plugin_active 函数
- validate_ref_entities: 支持 ref_plugin 字段,目标插件未安装时跳过校验(软警告)
- host.rs: HostState 新增 cross_plugin_entities 映射,db_query 支持点分记号
- engine.rs: execute_wasm 自动构建跨插件实体映射
Phase 3 — API 端点:
- POST /plugins/{id}/{entity}/resolve-labels 批量标签解析
- GET /plugin-registry/entities 公开实体注册表查询
This commit is contained in:
@@ -55,6 +55,18 @@ pub struct AggregateItem {
|
|||||||
pub count: i64,
|
pub count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 多聚合查询响应项
|
||||||
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct AggregateMultiRow {
|
||||||
|
/// 分组键
|
||||||
|
pub key: String,
|
||||||
|
/// 计数
|
||||||
|
pub count: i64,
|
||||||
|
/// 聚合指标: {"sum_amount": 5000.0, "avg_price": 25.5}
|
||||||
|
#[serde(default)]
|
||||||
|
pub metrics: std::collections::HashMap<String, f64>,
|
||||||
|
}
|
||||||
|
|
||||||
/// 聚合查询参数
|
/// 聚合查询参数
|
||||||
#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)]
|
#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)]
|
||||||
pub struct AggregateQueryParams {
|
pub struct AggregateQueryParams {
|
||||||
@@ -64,6 +76,26 @@ pub struct AggregateQueryParams {
|
|||||||
pub filter: Option<String>,
|
pub filter: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 多聚合查询请求体
|
||||||
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct AggregateMultiReq {
|
||||||
|
/// 分组字段名
|
||||||
|
pub group_by: String,
|
||||||
|
/// 聚合定义列表: [{"func": "sum", "field": "amount"}]
|
||||||
|
pub aggregations: Vec<AggregateDefDto>,
|
||||||
|
/// JSON 格式过滤
|
||||||
|
pub filter: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单个聚合定义
|
||||||
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct AggregateDefDto {
|
||||||
|
/// 聚合函数: count, sum, avg, min, max
|
||||||
|
pub func: String,
|
||||||
|
/// 字段名
|
||||||
|
pub field: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// 统计查询参数
|
/// 统计查询参数
|
||||||
#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)]
|
#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)]
|
||||||
pub struct CountQueryParams {
|
pub struct CountQueryParams {
|
||||||
@@ -105,3 +137,29 @@ pub struct TimeseriesItem {
|
|||||||
/// 计数
|
/// 计数
|
||||||
pub count: i64,
|
pub count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 跨插件引用 DTO ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 批量标签解析请求
|
||||||
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct ResolveLabelsReq {
|
||||||
|
/// 字段名 → UUID 列表
|
||||||
|
pub fields: std::collections::HashMap<String, Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量标签解析响应
|
||||||
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct ResolveLabelsResp {
|
||||||
|
/// 字段名 → { uuid: label } 映射
|
||||||
|
pub labels: serde_json::Value,
|
||||||
|
/// 字段名 → 目标插件元信息
|
||||||
|
pub meta: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 公开实体信息(实体注册表查询响应)
|
||||||
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct PublicEntityResp {
|
||||||
|
pub manifest_id: String,
|
||||||
|
pub entity_name: String,
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use erp_core::audit_service;
|
|||||||
use erp_core::error::{AppError, AppResult};
|
use erp_core::error::{AppError, AppResult};
|
||||||
use erp_core::events::EventBus;
|
use erp_core::events::EventBus;
|
||||||
|
|
||||||
use crate::data_dto::{BatchActionReq, PluginDataResp};
|
use crate::data_dto::{AggregateMultiRow, BatchActionReq, PluginDataResp};
|
||||||
use crate::dynamic_table::{sanitize_identifier, DynamicTableManager};
|
use crate::dynamic_table::{sanitize_identifier, DynamicTableManager};
|
||||||
use crate::entity::plugin;
|
use crate::entity::plugin;
|
||||||
use crate::entity::plugin_entity;
|
use crate::entity::plugin_entity;
|
||||||
@@ -700,6 +700,70 @@ impl PluginDataService {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 多聚合查询 — 支持 COUNT + SUM/AVG/MIN/MAX
|
||||||
|
pub async fn aggregate_multi(
|
||||||
|
plugin_id: Uuid,
|
||||||
|
entity_name: &str,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
group_by_field: &str,
|
||||||
|
aggregations: &[(String, String)],
|
||||||
|
filter: Option<serde_json::Value>,
|
||||||
|
scope: Option<DataScopeParams>,
|
||||||
|
) -> AppResult<Vec<AggregateMultiRow>> {
|
||||||
|
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||||
|
|
||||||
|
let (mut sql, mut values) = DynamicTableManager::build_aggregate_multi_sql(
|
||||||
|
&info.table_name,
|
||||||
|
tenant_id,
|
||||||
|
group_by_field,
|
||||||
|
aggregations,
|
||||||
|
filter,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Validation(e))?;
|
||||||
|
|
||||||
|
let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1);
|
||||||
|
if !scope_condition.0.is_empty() {
|
||||||
|
sql = merge_scope_condition(sql, &scope_condition);
|
||||||
|
values.extend(scope_condition.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 json_agg 包装整行,返回 JSON 数组
|
||||||
|
let json_sql = format!("SELECT json_agg(row_to_json(t)) as data FROM ({}) t", sql);
|
||||||
|
|
||||||
|
#[derive(Debug, FromQueryResult)]
|
||||||
|
struct JsonResult {
|
||||||
|
data: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = JsonResult::find_by_statement(Statement::from_sql_and_values(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
json_sql,
|
||||||
|
values,
|
||||||
|
))
|
||||||
|
.one(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let json_rows: Vec<serde_json::Value> = result
|
||||||
|
.and_then(|r| r.data)
|
||||||
|
.and_then(|d| d.as_array().cloned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let rows = json_rows.into_iter().map(|v| AggregateMultiRow {
|
||||||
|
key: v.get("key").and_then(|k| k.as_str()).unwrap_or_default().to_string(),
|
||||||
|
count: v.get("count").and_then(|c| c.as_i64()).unwrap_or(0),
|
||||||
|
metrics: v.as_object()
|
||||||
|
.map(|m| m.iter()
|
||||||
|
.filter(|(k, _)| *k != "key" && *k != "count")
|
||||||
|
.map(|(k, v)| (k.clone(), v.as_f64().unwrap_or(0.0)))
|
||||||
|
.collect()
|
||||||
|
)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
/// 聚合查询(预留 Redis 缓存接口)
|
/// 聚合查询(预留 Redis 缓存接口)
|
||||||
pub async fn aggregate_cached(
|
pub async fn aggregate_cached(
|
||||||
plugin_id: Uuid,
|
plugin_id: Uuid,
|
||||||
@@ -866,6 +930,77 @@ pub async fn resolve_entity_info_cached(
|
|||||||
Ok(info)
|
Ok(info)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 跨插件实体解析 — 按 manifest_id + entity_name 查找目标插件的实体信息
|
||||||
|
pub async fn resolve_cross_plugin_entity(
|
||||||
|
target_manifest_id: &str,
|
||||||
|
entity_name: &str,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AppResult<EntityInfo> {
|
||||||
|
let entity = plugin_entity::Entity::find()
|
||||||
|
.filter(plugin_entity::Column::ManifestId.eq(target_manifest_id))
|
||||||
|
.filter(plugin_entity::Column::EntityName.eq(entity_name))
|
||||||
|
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(plugin_entity::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::NotFound(format!(
|
||||||
|
"跨插件实体 {}/{} 不存在或未公开",
|
||||||
|
target_manifest_id, entity_name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let entity_def: crate::manifest::PluginEntity =
|
||||||
|
serde_json::from_value(entity.schema_json.clone())
|
||||||
|
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
|
||||||
|
let generated_fields: Vec<String> = entity_def
|
||||||
|
.fields
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.field_type.supports_generated_column())
|
||||||
|
.filter(|f| {
|
||||||
|
f.unique
|
||||||
|
|| f.sortable == Some(true)
|
||||||
|
|| f.filterable == Some(true)
|
||||||
|
|| (f.required && (f.sortable == Some(true) || f.filterable == Some(true)))
|
||||||
|
})
|
||||||
|
.map(|f| sanitize_identifier(&f.name))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(EntityInfo {
|
||||||
|
table_name: entity.table_name,
|
||||||
|
schema_json: entity.schema_json,
|
||||||
|
generated_fields,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查目标插件是否安装且活跃
|
||||||
|
pub async fn is_plugin_active(
|
||||||
|
target_manifest_id: &str,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> bool {
|
||||||
|
// 通过 plugin_entities 的 manifest_id 找到 plugin_id,再检查 plugins 表状态
|
||||||
|
let entity = plugin_entity::Entity::find()
|
||||||
|
.filter(plugin_entity::Column::ManifestId.eq(target_manifest_id))
|
||||||
|
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(plugin_entity::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let Some(entity) = entity.ok().flatten() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let plugin = plugin::Entity::find_by_id(entity.plugin_id)
|
||||||
|
.filter(plugin::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(plugin::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
matches!(plugin.ok().flatten(), Some(p) if p.status == "running" || p.status == "installed")
|
||||||
|
}
|
||||||
|
|
||||||
/// 校验数据:检查 required 字段 + 正则校验
|
/// 校验数据:检查 required 字段 + 正则校验
|
||||||
fn validate_data(data: &serde_json::Value, fields: &[PluginField]) -> AppResult<()> {
|
fn validate_data(data: &serde_json::Value, fields: &[PluginField]) -> AppResult<()> {
|
||||||
let obj = data.as_object().ok_or_else(|| {
|
let obj = data.as_object().ok_or_else(|| {
|
||||||
@@ -904,6 +1039,8 @@ fn validate_data(data: &serde_json::Value, fields: &[PluginField]) -> AppResult<
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 校验外键引用 — 检查 ref_entity 字段指向的记录是否存在
|
/// 校验外键引用 — 检查 ref_entity 字段指向的记录是否存在
|
||||||
|
/// 支持同插件引用和跨插件引用(ref_plugin 字段)
|
||||||
|
/// 核心原则:跨插件引用目标插件未安装时跳过校验(软警告)
|
||||||
async fn validate_ref_entities(
|
async fn validate_ref_entities(
|
||||||
data: &serde_json::Value,
|
data: &serde_json::Value,
|
||||||
fields: &[PluginField],
|
fields: &[PluginField],
|
||||||
@@ -935,19 +1072,47 @@ async fn validate_ref_entities(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 自引用 + create:跳过(记录尚未存在)
|
// 自引用 + create:跳过(记录尚未存在)
|
||||||
if ref_entity_name == current_entity && is_create {
|
if ref_entity_name == current_entity && field.ref_plugin.is_none() && is_create {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 自引用 + update:检查是否引用自身
|
// 自引用 + update:检查是否引用自身
|
||||||
if ref_entity_name == current_entity && !is_create {
|
if ref_entity_name == current_entity && field.ref_plugin.is_none() && !is_create {
|
||||||
if let Some(rid) = record_id {
|
if let Some(rid) = record_id {
|
||||||
if ref_id == rid { continue; }
|
if ref_id == rid { continue; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询被引用记录是否存在
|
// 确定目标表名
|
||||||
let manifest_id = resolve_manifest_id(plugin_id, tenant_id, db).await?;
|
let ref_table = if let Some(target_plugin) = &field.ref_plugin {
|
||||||
let ref_table = DynamicTableManager::table_name(&manifest_id, ref_entity_name);
|
// 跨插件引用 — 检查目标插件是否活跃
|
||||||
|
if !is_plugin_active(target_plugin, tenant_id, db).await {
|
||||||
|
// 目标插件未安装/禁用 → 跳过校验(软警告,不阻塞)
|
||||||
|
tracing::debug!(
|
||||||
|
field = %field.name,
|
||||||
|
target_plugin = %target_plugin,
|
||||||
|
"跨插件引用目标插件未活跃,跳过校验"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 目标插件活跃 → 解析目标表名
|
||||||
|
match resolve_cross_plugin_entity(target_plugin, ref_entity_name, tenant_id, db).await {
|
||||||
|
Ok(info) => info.table_name,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
field = %field.name,
|
||||||
|
target_plugin = %target_plugin,
|
||||||
|
entity = %ref_entity_name,
|
||||||
|
error = %e,
|
||||||
|
"跨插件实体解析失败,跳过校验"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 同插件引用 — 使用原有逻辑
|
||||||
|
let manifest_id = resolve_manifest_id(plugin_id, tenant_id, db).await?;
|
||||||
|
DynamicTableManager::table_name(&manifest_id, ref_entity_name)
|
||||||
|
};
|
||||||
|
|
||||||
let check_sql = format!(
|
let check_sql = format!(
|
||||||
"SELECT 1 as check_result FROM \"{}\" WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL LIMIT 1",
|
"SELECT 1 as check_result FROM \"{}\" WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL LIMIT 1",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use uuid::Uuid;
|
|||||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||||
|
|
||||||
use crate::error::{PluginError, PluginResult};
|
use crate::error::{PluginError, PluginResult};
|
||||||
use crate::manifest::{PluginEntity, PluginFieldType};
|
use crate::manifest::{PluginEntity, PluginField, PluginFieldType};
|
||||||
|
|
||||||
/// 消毒标识符:只保留 ASCII 字母、数字、下划线,防止 SQL 注入
|
/// 消毒标识符:只保留 ASCII 字母、数字、下划线,防止 SQL 注入
|
||||||
pub(crate) fn sanitize_identifier(input: &str) -> String {
|
pub(crate) fn sanitize_identifier(input: &str) -> String {
|
||||||
@@ -14,6 +14,13 @@ pub(crate) fn sanitize_identifier(input: &str) -> String {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Schema 演进字段差异
|
||||||
|
pub struct FieldDiff {
|
||||||
|
pub new_filterable: Vec<PluginField>,
|
||||||
|
pub new_sortable: Vec<PluginField>,
|
||||||
|
pub new_searchable: Vec<PluginField>,
|
||||||
|
}
|
||||||
|
|
||||||
/// 动态表管理器 — 处理插件动态创建/删除的数据库表
|
/// 动态表管理器 — 处理插件动态创建/删除的数据库表
|
||||||
pub struct DynamicTableManager;
|
pub struct DynamicTableManager;
|
||||||
|
|
||||||
@@ -158,6 +165,102 @@ impl DynamicTableManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Schema 演进:对比新旧实体字段,返回需要新增 Generated Column 的差异
|
||||||
|
pub fn diff_entity_fields(old: &PluginEntity, new: &PluginEntity) -> FieldDiff {
|
||||||
|
let old_names: std::collections::HashSet<String> =
|
||||||
|
old.fields.iter().map(|f| f.name.clone()).collect();
|
||||||
|
|
||||||
|
let mut new_filterable = Vec::new();
|
||||||
|
let mut new_sortable = Vec::new();
|
||||||
|
let mut new_searchable = Vec::new();
|
||||||
|
|
||||||
|
for field in &new.fields {
|
||||||
|
if old_names.contains(&field.name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 新增字段 + 需要 Generated Column 的条件
|
||||||
|
let needs_gen = field.unique
|
||||||
|
|| field.sortable == Some(true)
|
||||||
|
|| field.filterable == Some(true);
|
||||||
|
if needs_gen {
|
||||||
|
new_filterable.push(field.clone());
|
||||||
|
if field.sortable == Some(true) {
|
||||||
|
new_sortable.push(field.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if field.searchable == Some(true) && matches!(field.field_type, PluginFieldType::String) {
|
||||||
|
new_searchable.push(field.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FieldDiff { new_filterable, new_sortable, new_searchable }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schema 演进:为已有实体新增 Generated Column 和索引
|
||||||
|
pub async fn alter_add_generated_columns(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
plugin_id: &str,
|
||||||
|
entity: &PluginEntity,
|
||||||
|
diff: &FieldDiff,
|
||||||
|
) -> PluginResult<()> {
|
||||||
|
let table_name = Self::table_name(plugin_id, &entity.name);
|
||||||
|
let mut statements = Vec::new();
|
||||||
|
|
||||||
|
for field in &diff.new_filterable {
|
||||||
|
if !field.field_type.supports_generated_column() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let col_name = format!("_f_{}", sanitize_identifier(&field.name));
|
||||||
|
let sql_type = field.field_type.generated_sql_type();
|
||||||
|
let expr = field.field_type.generated_expr(&sanitize_identifier(&field.name));
|
||||||
|
let _safe_field = sanitize_identifier(&field.name);
|
||||||
|
|
||||||
|
statements.push(format!(
|
||||||
|
"ALTER TABLE \"{}\" ADD COLUMN IF NOT EXISTS \"{}\" {} GENERATED ALWAYS AS ({}) STORED",
|
||||||
|
table_name, col_name, sql_type, expr
|
||||||
|
));
|
||||||
|
|
||||||
|
let col_idx = format!("{}_{}", sanitize_identifier(&table_name), col_name);
|
||||||
|
if field.unique {
|
||||||
|
statements.push(format!(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS \"idx_{}_uniq\" ON \"{}\" (tenant_id, \"{}\") WHERE deleted_at IS NULL",
|
||||||
|
col_idx, table_name, col_name
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
statements.push(format!(
|
||||||
|
"CREATE INDEX IF NOT EXISTS \"idx_{}\" ON \"{}\" (tenant_id, \"{}\") WHERE deleted_at IS NULL",
|
||||||
|
col_idx, table_name, col_name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for field in &diff.new_searchable {
|
||||||
|
let sf = sanitize_identifier(&field.name);
|
||||||
|
let col_name = format!("_f_{}", sf);
|
||||||
|
let col_idx = format!("{}_{}trgm", sanitize_identifier(&table_name), col_name);
|
||||||
|
statements.push(format!(
|
||||||
|
"CREATE INDEX IF NOT EXISTS \"idx_{}\" ON \"{}\" USING gin (\"{}\" gin_trgm_ops) WHERE deleted_at IS NULL AND \"{}\" IS NOT NULL",
|
||||||
|
col_idx, table_name, col_name, col_name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for sql in &statements {
|
||||||
|
tracing::info!(sql = %sql, "Executing ALTER TABLE");
|
||||||
|
db.execute_unprepared(sql).await.map_err(|e| {
|
||||||
|
tracing::error!(sql = %sql, error = %e, "ALTER TABLE failed");
|
||||||
|
PluginError::DatabaseError(e.to_string())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
table = %table_name,
|
||||||
|
added_columns = diff.new_filterable.len(),
|
||||||
|
added_search_indexes = diff.new_searchable.len(),
|
||||||
|
"Schema evolution: Generated Columns added"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 检查表是否存在
|
/// 检查表是否存在
|
||||||
pub async fn table_exists(db: &DatabaseConnection, table_name: &str) -> PluginResult<bool> {
|
pub async fn table_exists(db: &DatabaseConnection, table_name: &str) -> PluginResult<bool> {
|
||||||
#[derive(FromQueryResult)]
|
#[derive(FromQueryResult)]
|
||||||
@@ -461,6 +564,82 @@ impl DynamicTableManager {
|
|||||||
Ok((sql, values))
|
Ok((sql, values))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 构建多聚合函数 SQL(支持 COUNT/SUM/AVG/MIN/MAX)
|
||||||
|
pub fn build_aggregate_multi_sql(
|
||||||
|
table_name: &str,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
group_by_field: &str,
|
||||||
|
aggregations: &[(String, String)], // (func, field) e.g. ("sum", "amount")
|
||||||
|
filter: Option<serde_json::Value>,
|
||||||
|
) -> Result<(String, Vec<Value>), String> {
|
||||||
|
let clean_group = sanitize_identifier(group_by_field);
|
||||||
|
if clean_group.is_empty() {
|
||||||
|
return Err(format!("无效的分组字段名: {}", group_by_field));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut conditions = vec![
|
||||||
|
format!("\"tenant_id\" = ${}", 1),
|
||||||
|
"\"deleted_at\" IS NULL".to_string(),
|
||||||
|
];
|
||||||
|
let mut param_idx = 2;
|
||||||
|
let mut values: Vec<Value> = vec![tenant_id.into()];
|
||||||
|
|
||||||
|
if let Some(f) = filter {
|
||||||
|
if let Some(obj) = f.as_object() {
|
||||||
|
for (key, val) in obj {
|
||||||
|
let clean_key = sanitize_identifier(key);
|
||||||
|
if clean_key.is_empty() {
|
||||||
|
return Err(format!("无效的过滤字段名: {}", key));
|
||||||
|
}
|
||||||
|
conditions.push(format!("\"data\"->>'{}' = ${}", clean_key, param_idx));
|
||||||
|
values.push(Value::String(Some(Box::new(
|
||||||
|
val.as_str().unwrap_or("").to_string(),
|
||||||
|
))));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut select_parts = vec![
|
||||||
|
format!("\"_f_{}\" as key", clean_group),
|
||||||
|
"COUNT(*) as count".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (func, field) in aggregations {
|
||||||
|
let clean_field = sanitize_identifier(field);
|
||||||
|
let func_lower = func.to_lowercase();
|
||||||
|
match func_lower.as_str() {
|
||||||
|
"sum" => select_parts.push(format!(
|
||||||
|
"COALESCE(SUM(\"_f_{}\"), 0) as sum_{}", clean_field, clean_field
|
||||||
|
)),
|
||||||
|
"avg" => select_parts.push(format!(
|
||||||
|
"COALESCE(AVG(\"_f_{}\"), 0) as avg_{}", clean_field, clean_field
|
||||||
|
)),
|
||||||
|
"min" => select_parts.push(format!(
|
||||||
|
"MIN(\"_f_{}\") as min_{}", clean_field, clean_field
|
||||||
|
)),
|
||||||
|
"max" => select_parts.push(format!(
|
||||||
|
"MAX(\"_f_{}\") as max_{}", clean_field, clean_field
|
||||||
|
)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT {} \
|
||||||
|
FROM \"{}\" \
|
||||||
|
WHERE {} \
|
||||||
|
GROUP BY \"_f_{}\" \
|
||||||
|
ORDER BY count DESC",
|
||||||
|
select_parts.join(", "),
|
||||||
|
table_name,
|
||||||
|
conditions.join(" AND "),
|
||||||
|
clean_group,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((sql, values))
|
||||||
|
}
|
||||||
|
|
||||||
/// 构建带过滤条件的查询 SQL
|
/// 构建带过滤条件的查询 SQL
|
||||||
pub fn build_filtered_query_sql(
|
pub fn build_filtered_query_sql(
|
||||||
table_name: &str,
|
table_name: &str,
|
||||||
@@ -1132,6 +1311,7 @@ mod tests {
|
|||||||
indexes: vec![],
|
indexes: vec![],
|
||||||
relations: vec![],
|
relations: vec![],
|
||||||
data_scope: None,
|
data_scope: None,
|
||||||
|
is_public: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity);
|
let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity);
|
||||||
@@ -1174,6 +1354,7 @@ mod tests {
|
|||||||
indexes: vec![],
|
indexes: vec![],
|
||||||
relations: vec![],
|
relations: vec![],
|
||||||
data_scope: None,
|
data_scope: None,
|
||||||
|
is_public: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity);
|
let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
use std::panic::AssertUnwindSafe;
|
use std::panic::AssertUnwindSafe;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use sea_orm::{ConnectionTrait, DatabaseConnection, Statement, TransactionTrait};
|
use sea_orm::{ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, QueryFilter, Statement, TransactionTrait};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -315,6 +316,18 @@ impl PluginEngine {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将插件从一个 key 重命名为另一个 key(用于热更新的原子替换)
|
||||||
|
pub async fn rename_plugin(&self, old_id: &str, new_id: &str) -> PluginResult<()> {
|
||||||
|
let (_, loaded) = self.plugins.remove(old_id)
|
||||||
|
.ok_or_else(|| PluginError::NotFound(old_id.to_string()))?;
|
||||||
|
let mut loaded = Arc::try_unwrap(loaded)
|
||||||
|
.map_err(|_| PluginError::ExecutionError("插件仍被引用,无法重命名".to_string()))?;
|
||||||
|
loaded.id = new_id.to_string();
|
||||||
|
self.plugins.insert(new_id.to_string(), Arc::new(loaded));
|
||||||
|
tracing::info!(old_id, new_id, "Plugin renamed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 健康检查
|
/// 健康检查
|
||||||
pub async fn health_check(&self, plugin_id: &str) -> PluginResult<serde_json::Value> {
|
pub async fn health_check(&self, plugin_id: &str) -> PluginResult<serde_json::Value> {
|
||||||
let loaded = self.get_loaded(plugin_id)?;
|
let loaded = self.get_loaded(plugin_id)?;
|
||||||
@@ -456,13 +469,20 @@ impl PluginEngine {
|
|||||||
{
|
{
|
||||||
let loaded = self.get_loaded(plugin_id)?;
|
let loaded = self.get_loaded(plugin_id)?;
|
||||||
|
|
||||||
|
// 构建跨插件实体映射(从 manifest 的 ref_plugin 字段提取)
|
||||||
|
let cross_plugin_entities = Self::build_cross_plugin_map(&loaded.manifest, &self.db, exec_ctx.tenant_id).await;
|
||||||
|
|
||||||
// 创建新的 Store + HostState,使用真实的租户/用户上下文
|
// 创建新的 Store + HostState,使用真实的租户/用户上下文
|
||||||
let state = HostState::new(
|
// 传入 db 和 event_bus 启用混合执行模式(插件可自主查询数据)
|
||||||
|
let mut state = HostState::new_with_db(
|
||||||
plugin_id.to_string(),
|
plugin_id.to_string(),
|
||||||
exec_ctx.tenant_id,
|
exec_ctx.tenant_id,
|
||||||
exec_ctx.user_id,
|
exec_ctx.user_id,
|
||||||
exec_ctx.permissions.clone(),
|
exec_ctx.permissions.clone(),
|
||||||
|
self.db.clone(),
|
||||||
|
self.event_bus.clone(),
|
||||||
);
|
);
|
||||||
|
state.cross_plugin_entities = cross_plugin_entities;
|
||||||
let mut store = Store::new(&self.engine, state);
|
let mut store = Store::new(&self.engine, state);
|
||||||
store
|
store
|
||||||
.set_fuel(self.config.default_fuel)
|
.set_fuel(self.config.default_fuel)
|
||||||
@@ -521,6 +541,42 @@ impl PluginEngine {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从 manifest 的 ref_plugin 字段构建跨插件实体映射
|
||||||
|
/// 返回: { "erp-crm.customer" → "plugin_erp_crm__customer", ... }
|
||||||
|
async fn build_cross_plugin_map(
|
||||||
|
manifest: &crate::manifest::PluginManifest,
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
) -> HashMap<String, String> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
let Some(schema) = &manifest.schema else { return map };
|
||||||
|
|
||||||
|
for entity in &schema.entities {
|
||||||
|
for field in &entity.fields {
|
||||||
|
if let (Some(target_plugin), Some(ref_entity)) = (&field.ref_plugin, &field.ref_entity) {
|
||||||
|
let key = format!("{}.{}", target_plugin, ref_entity);
|
||||||
|
// 从 plugin_entities 表查找目标表名
|
||||||
|
let table_name = crate::entity::plugin_entity::Entity::find()
|
||||||
|
.filter(crate::entity::plugin_entity::Column::ManifestId.eq(target_plugin.as_str()))
|
||||||
|
.filter(crate::entity::plugin_entity::Column::EntityName.eq(ref_entity.as_str()))
|
||||||
|
.filter(crate::entity::plugin_entity::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(crate::entity::plugin_entity::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.table_name);
|
||||||
|
|
||||||
|
if let Some(tn) = table_name {
|
||||||
|
map.insert(key, tn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
/// 刷新 HostState 中的 pending_ops 到数据库。
|
/// 刷新 HostState 中的 pending_ops 到数据库。
|
||||||
///
|
///
|
||||||
/// 使用事务包裹所有数据库操作确保原子性。
|
/// 使用事务包裹所有数据库操作确保原子性。
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ pub struct Model {
|
|||||||
pub entity_name: String,
|
pub entity_name: String,
|
||||||
pub table_name: String,
|
pub table_name: String,
|
||||||
pub schema_json: serde_json::Value,
|
pub schema_json: serde_json::Value,
|
||||||
|
pub manifest_id: String,
|
||||||
|
pub is_public: bool,
|
||||||
pub created_at: DateTimeUtc,
|
pub created_at: DateTimeUtc,
|
||||||
pub updated_at: DateTimeUtc,
|
pub updated_at: DateTimeUtc,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ use erp_core::rbac::require_permission;
|
|||||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||||
|
|
||||||
use crate::data_dto::{
|
use crate::data_dto::{
|
||||||
AggregateItem, AggregateQueryParams, BatchActionReq, CountQueryParams, CreatePluginDataReq,
|
AggregateItem, AggregateMultiReq, AggregateMultiRow, AggregateQueryParams, BatchActionReq,
|
||||||
PatchPluginDataReq, PluginDataListParams, PluginDataResp, TimeseriesItem, TimeseriesParams,
|
CountQueryParams, CreatePluginDataReq, PatchPluginDataReq, PluginDataListParams,
|
||||||
UpdatePluginDataReq,
|
PluginDataResp, PublicEntityResp, ResolveLabelsReq, ResolveLabelsResp,
|
||||||
|
TimeseriesItem, TimeseriesParams, UpdatePluginDataReq,
|
||||||
};
|
};
|
||||||
use crate::data_service::{DataScopeParams, PluginDataService, resolve_manifest_id};
|
use crate::data_service::{DataScopeParams, PluginDataService, resolve_manifest_id};
|
||||||
use crate::state::PluginState;
|
use crate::state::PluginState;
|
||||||
@@ -566,3 +567,214 @@ async fn check_entity_data_scope(
|
|||||||
|
|
||||||
Ok(schema.data_scope.unwrap_or(false))
|
Ok(schema.data_scope.unwrap_or(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/plugins/{plugin_id}/{entity}/aggregate-multi",
|
||||||
|
request_body = AggregateMultiReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<Vec<AggregateMultiRow>>),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "插件数据"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/plugins/{plugin_id}/{entity}/aggregate-multi — 多聚合查询
|
||||||
|
pub async fn aggregate_multi_plugin_data<S>(
|
||||||
|
State(state): State<PluginState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path((plugin_id, entity)): Path<(Uuid, String)>,
|
||||||
|
Json(body): Json<AggregateMultiReq>,
|
||||||
|
) -> Result<Json<ApiResponse<Vec<AggregateMultiRow>>>, AppError>
|
||||||
|
where
|
||||||
|
PluginState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||||
|
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
|
||||||
|
require_permission(&ctx, &fine_perm)?;
|
||||||
|
|
||||||
|
let scope = resolve_data_scope(
|
||||||
|
&ctx, &manifest_id, &entity, &fine_perm, &state.db,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
let aggregations: Vec<(String, String)> = body.aggregations
|
||||||
|
.iter()
|
||||||
|
.map(|a| (a.func.clone(), a.field.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let rows = PluginDataService::aggregate_multi(
|
||||||
|
plugin_id,
|
||||||
|
&entity,
|
||||||
|
ctx.tenant_id,
|
||||||
|
&state.db,
|
||||||
|
&body.group_by,
|
||||||
|
&aggregations,
|
||||||
|
body.filter,
|
||||||
|
scope,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(rows)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 跨插件引用:批量标签解析 ────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 批量解析引用字段的显示标签
|
||||||
|
///
|
||||||
|
/// POST /api/v1/plugins/{plugin_id}/{entity}/resolve-labels
|
||||||
|
pub async fn resolve_ref_labels<S>(
|
||||||
|
Path((plugin_id, entity)): Path<(Uuid, String)>,
|
||||||
|
State(state): State<PluginState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Json(body): Json<ResolveLabelsReq>,
|
||||||
|
) -> Result<Json<ApiResponse<ResolveLabelsResp>>, AppError>
|
||||||
|
where
|
||||||
|
PluginState: FromRef<S>,
|
||||||
|
{
|
||||||
|
use sea_orm::{FromQueryResult, Statement};
|
||||||
|
use crate::data_service::{resolve_cross_plugin_entity, is_plugin_active};
|
||||||
|
use crate::manifest::PluginEntity;
|
||||||
|
|
||||||
|
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||||
|
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
|
||||||
|
require_permission(&ctx, &fine_perm)?;
|
||||||
|
|
||||||
|
// 获取当前实体的 schema
|
||||||
|
let entity_info = crate::data_service::resolve_entity_info_cached(
|
||||||
|
plugin_id, &entity, ctx.tenant_id, &state.db, &state.entity_cache,
|
||||||
|
).await?;
|
||||||
|
let entity_def: PluginEntity =
|
||||||
|
serde_json::from_value(entity_info.schema_json).map_err(|e|
|
||||||
|
AppError::Internal(format!("解析 entity schema 失败: {}", e))
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut labels = serde_json::Map::<String, serde_json::Value>::new();
|
||||||
|
let mut meta = serde_json::Map::<String, serde_json::Value>::new();
|
||||||
|
|
||||||
|
for (field_name, uuids) in &body.fields {
|
||||||
|
// 查找字段定义
|
||||||
|
let field_def = entity_def.fields.iter().find(|f| &f.name == field_name);
|
||||||
|
let Some(field_def) = field_def else { continue };
|
||||||
|
let Some(ref_entity_name) = &field_def.ref_entity else { continue };
|
||||||
|
|
||||||
|
let target_plugin = field_def.ref_plugin.as_deref().unwrap_or(&manifest_id);
|
||||||
|
let label_field = field_def.ref_label_field.as_deref().unwrap_or("name");
|
||||||
|
|
||||||
|
let installed = is_plugin_active(target_plugin, ctx.tenant_id, &state.db).await;
|
||||||
|
|
||||||
|
// meta 信息
|
||||||
|
meta.insert(field_name.clone(), serde_json::json!({
|
||||||
|
"target_plugin": target_plugin,
|
||||||
|
"target_entity": ref_entity_name,
|
||||||
|
"label_field": label_field,
|
||||||
|
"plugin_installed": installed,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if !installed {
|
||||||
|
// 目标插件未安装 → 所有 UUID 返回 null
|
||||||
|
let nulls: serde_json::Map<String, serde_json::Value> = uuids.iter()
|
||||||
|
.map(|u| (u.clone(), serde_json::Value::Null))
|
||||||
|
.collect();
|
||||||
|
labels.insert(field_name.clone(), serde_json::Value::Object(nulls));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析目标表名
|
||||||
|
let target_table = if field_def.ref_plugin.is_some() {
|
||||||
|
match resolve_cross_plugin_entity(target_plugin, ref_entity_name, ctx.tenant_id, &state.db).await {
|
||||||
|
Ok(info) => info.table_name,
|
||||||
|
Err(_) => {
|
||||||
|
let nulls: serde_json::Map<String, serde_json::Value> = uuids.iter()
|
||||||
|
.map(|u| (u.clone(), serde_json::Value::Null))
|
||||||
|
.collect();
|
||||||
|
labels.insert(field_name.clone(), serde_json::Value::Object(nulls));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
crate::dynamic_table::DynamicTableManager::table_name(target_plugin, ref_entity_name)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 批量查询标签
|
||||||
|
let uuid_strs: Vec<String> = uuids.iter().filter_map(|u| Uuid::parse_str(u).ok()).map(|u| u.to_string()).collect();
|
||||||
|
if uuid_strs.is_empty() {
|
||||||
|
labels.insert(field_name.clone(), serde_json::json!({}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建 IN 子句参数
|
||||||
|
let placeholders: Vec<String> = (2..uuid_strs.len() + 2).map(|i| format!("${}", i)).collect();
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT id::text, data->>'{}' as label FROM \"{}\" WHERE id IN ({}) AND tenant_id = $1 AND deleted_at IS NULL",
|
||||||
|
label_field, target_table, placeholders.join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut values: Vec<sea_orm::Value> = vec![ctx.tenant_id.into()];
|
||||||
|
for u in uuid_strs {
|
||||||
|
values.push(u.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromQueryResult)]
|
||||||
|
struct LabelRow { id: String, label: Option<String> }
|
||||||
|
|
||||||
|
let rows = LabelRow::find_by_statement(Statement::from_sql_and_values(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
sql,
|
||||||
|
values,
|
||||||
|
)).all(&state.db).await?;
|
||||||
|
|
||||||
|
let mut field_labels: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
|
||||||
|
// 初始化所有请求的 UUID 为 null
|
||||||
|
for u in uuids {
|
||||||
|
field_labels.insert(u.clone(), serde_json::Value::Null);
|
||||||
|
}
|
||||||
|
// 用查询结果填充
|
||||||
|
for row in rows {
|
||||||
|
field_labels.insert(row.id, serde_json::Value::String(row.label.unwrap_or_default()));
|
||||||
|
}
|
||||||
|
|
||||||
|
labels.insert(field_name.clone(), serde_json::Value::Object(field_labels));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(ResolveLabelsResp {
|
||||||
|
labels: serde_json::Value::Object(labels),
|
||||||
|
meta: serde_json::Value::Object(meta),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 跨插件引用:实体注册表查询 ────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 查询所有可跨插件引用的公开实体
|
||||||
|
///
|
||||||
|
/// GET /api/v1/plugin-registry/entities
|
||||||
|
pub async fn list_public_entities<S>(
|
||||||
|
State(state): State<PluginState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<Json<ApiResponse<Vec<PublicEntityResp>>>, AppError>
|
||||||
|
where
|
||||||
|
PluginState: FromRef<S>,
|
||||||
|
{
|
||||||
|
use crate::entity::plugin_entity;
|
||||||
|
use sea_orm::{EntityTrait, QueryFilter, ColumnTrait};
|
||||||
|
|
||||||
|
let entities = plugin_entity::Entity::find()
|
||||||
|
.filter(plugin_entity::Column::TenantId.eq(ctx.tenant_id))
|
||||||
|
.filter(plugin_entity::Column::IsPublic.eq(true))
|
||||||
|
.filter(plugin_entity::Column::DeletedAt.is_null())
|
||||||
|
.all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let result: Vec<PublicEntityResp> = entities.iter().map(|e| {
|
||||||
|
let display_name = e.schema_json.get("display_name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(&e.entity_name)
|
||||||
|
.to_string();
|
||||||
|
PublicEntityResp {
|
||||||
|
manifest_id: e.manifest_id.clone(),
|
||||||
|
entity_name: e.entity_name.clone(),
|
||||||
|
display_name,
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use wasmtime::StoreLimits;
|
use wasmtime::StoreLimits;
|
||||||
|
|
||||||
use crate::erp::plugin::host_api;
|
use crate::erp::plugin::host_api;
|
||||||
|
use crate::dynamic_table::DynamicTableManager;
|
||||||
|
use crate::engine::PluginEngine;
|
||||||
|
|
||||||
/// 待刷新的写操作
|
/// 待刷新的写操作
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -31,10 +34,9 @@ pub enum PendingOp {
|
|||||||
|
|
||||||
/// Host 端状态 — 绑定到每个 WASM Store 实例
|
/// Host 端状态 — 绑定到每个 WASM Store 实例
|
||||||
///
|
///
|
||||||
/// 采用延迟执行模式:
|
/// 支持两种执行模式:
|
||||||
/// - 读操作 (db_query, config_get, current_user) → 调用前预填充
|
/// - **预填充模式**(db = None):读操作从预填充缓存取,向后兼容
|
||||||
/// - 写操作 (db_insert, db_update, db_delete, event_publish) → 入队 pending_ops
|
/// - **混合执行模式**(db = Some):读操作走实时 SQL + 写操作保持延迟批量
|
||||||
/// - WASM 调用结束后由 engine 刷新 pending_ops 执行真实 DB 操作
|
|
||||||
pub struct HostState {
|
pub struct HostState {
|
||||||
pub(crate) limits: StoreLimits,
|
pub(crate) limits: StoreLimits,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -43,7 +45,7 @@ pub struct HostState {
|
|||||||
pub(crate) user_id: Uuid,
|
pub(crate) user_id: Uuid,
|
||||||
pub(crate) permissions: Vec<String>,
|
pub(crate) permissions: Vec<String>,
|
||||||
pub(crate) plugin_id: String,
|
pub(crate) plugin_id: String,
|
||||||
// 预填充的读取缓存
|
// 预填充的读取缓存(向后兼容)
|
||||||
pub(crate) query_results: HashMap<String, Vec<u8>>,
|
pub(crate) query_results: HashMap<String, Vec<u8>>,
|
||||||
pub(crate) config_cache: HashMap<String, Vec<u8>>,
|
pub(crate) config_cache: HashMap<String, Vec<u8>>,
|
||||||
pub(crate) current_user_json: Vec<u8>,
|
pub(crate) current_user_json: Vec<u8>,
|
||||||
@@ -51,6 +53,11 @@ pub struct HostState {
|
|||||||
pub(crate) pending_ops: Vec<PendingOp>,
|
pub(crate) pending_ops: Vec<PendingOp>,
|
||||||
// 日志
|
// 日志
|
||||||
pub(crate) logs: Vec<(String, String)>,
|
pub(crate) logs: Vec<(String, String)>,
|
||||||
|
// 混合执行模式:数据库连接和事件总线
|
||||||
|
pub(crate) db: Option<DatabaseConnection>,
|
||||||
|
pub(crate) event_bus: Option<erp_core::events::EventBus>,
|
||||||
|
// 跨插件实体映射:"erp-crm.customer" → "plugin_erp_crm__customer"
|
||||||
|
pub(crate) cross_plugin_entities: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HostState {
|
impl HostState {
|
||||||
@@ -75,8 +82,26 @@ impl HostState {
|
|||||||
current_user_json: serde_json::to_vec(¤t_user).unwrap_or_default(),
|
current_user_json: serde_json::to_vec(¤t_user).unwrap_or_default(),
|
||||||
pending_ops: Vec::new(),
|
pending_ops: Vec::new(),
|
||||||
logs: Vec::new(),
|
logs: Vec::new(),
|
||||||
|
db: None,
|
||||||
|
event_bus: None,
|
||||||
|
cross_plugin_entities: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 创建带数据库连接的 HostState(混合执行模式)
|
||||||
|
pub fn new_with_db(
|
||||||
|
plugin_id: String,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
permissions: Vec<String>,
|
||||||
|
db: DatabaseConnection,
|
||||||
|
event_bus: erp_core::events::EventBus,
|
||||||
|
) -> Self {
|
||||||
|
let mut state = Self::new(plugin_id, tenant_id, user_id, permissions);
|
||||||
|
state.db = Some(db);
|
||||||
|
state.event_bus = Some(event_bus);
|
||||||
|
state
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 实现 bindgen 生成的 Host trait — 插件调用 Host API 的入口
|
// 实现 bindgen 生成的 Host trait — 插件调用 Host API 的入口
|
||||||
@@ -99,13 +124,110 @@ impl host_api::Host for HostState {
|
|||||||
fn db_query(
|
fn db_query(
|
||||||
&mut self,
|
&mut self,
|
||||||
entity: String,
|
entity: String,
|
||||||
_filter: Vec<u8>,
|
filter: Vec<u8>,
|
||||||
_pagination: Vec<u8>,
|
pagination: Vec<u8>,
|
||||||
) -> Result<Vec<u8>, String> {
|
) -> Result<Vec<u8>, String> {
|
||||||
self.query_results
|
// 预填充模式(向后兼容)
|
||||||
.get(&entity)
|
if self.db.is_none() {
|
||||||
.cloned()
|
return self.query_results
|
||||||
.ok_or_else(|| format!("实体 '{}' 的查询结果未预填充", entity))
|
.get(&entity)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| format!("实体 '{}' 的查询结果未预填充", entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = self.db.clone().unwrap();
|
||||||
|
let event_bus = self.event_bus.clone()
|
||||||
|
.ok_or("事件总线不可用")?;
|
||||||
|
|
||||||
|
// 先 flush pending writes(确保读后写一致性)
|
||||||
|
let ops = std::mem::take(&mut self.pending_ops);
|
||||||
|
if !ops.is_empty() {
|
||||||
|
let rt = tokio::runtime::Handle::current();
|
||||||
|
rt.block_on(PluginEngine::flush_ops(
|
||||||
|
&db,
|
||||||
|
&self.plugin_id,
|
||||||
|
ops,
|
||||||
|
self.tenant_id,
|
||||||
|
self.user_id,
|
||||||
|
&event_bus,
|
||||||
|
))
|
||||||
|
.map_err(|e| format!("flush pending ops 失败: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 filter 和 pagination
|
||||||
|
let filter_val: Option<serde_json::Value> = if filter.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
serde_json::from_slice(&filter).ok()
|
||||||
|
};
|
||||||
|
|
||||||
|
let pagination_val: Option<serde_json::Value> = if pagination.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
serde_json::from_slice(&pagination).ok()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 构建查询 — 支持点分记号跨插件查询(如 "erp-crm.customer")
|
||||||
|
let table_name = if entity.contains('.') {
|
||||||
|
self.cross_plugin_entities
|
||||||
|
.get(&entity)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| format!("跨插件实体 '{}' 未注册", entity))?
|
||||||
|
} else {
|
||||||
|
DynamicTableManager::table_name(&self.plugin_id, &entity)
|
||||||
|
};
|
||||||
|
|
||||||
|
let limit = pagination_val
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.get("limit"))
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(50);
|
||||||
|
let offset = pagination_val
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.get("offset"))
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let (sql, values) = DynamicTableManager::build_filtered_query_sql(
|
||||||
|
&table_name,
|
||||||
|
self.tenant_id,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
filter_val,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("查询构建失败: {}", e))?;
|
||||||
|
|
||||||
|
// 执行查询
|
||||||
|
let rt = tokio::runtime::Handle::current();
|
||||||
|
let rows = rt.block_on(async {
|
||||||
|
use sea_orm::{FromQueryResult, Statement};
|
||||||
|
#[derive(Debug, FromQueryResult)]
|
||||||
|
struct QueryRow {
|
||||||
|
data: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = QueryRow::find_by_statement(Statement::from_sql_and_values(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
sql,
|
||||||
|
values,
|
||||||
|
))
|
||||||
|
.all(&db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("查询执行失败: {}", e))?;
|
||||||
|
|
||||||
|
let items: Vec<serde_json::Value> = results
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| r.data)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok::<Vec<serde_json::Value>, String>(items)
|
||||||
|
})
|
||||||
|
.map_err(|e: String| e)?;
|
||||||
|
|
||||||
|
serde_json::to_vec(&rows).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn db_update(
|
fn db_update(
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ pub struct PluginEntity {
|
|||||||
pub relations: Vec<PluginRelation>,
|
pub relations: Vec<PluginRelation>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub data_scope: Option<bool>, // 是否启用行级数据权限
|
pub data_scope: Option<bool>, // 是否启用行级数据权限
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_public: Option<bool>, // 是否可被其他插件引用
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 字段校验规则
|
/// 字段校验规则
|
||||||
@@ -87,6 +89,8 @@ pub struct PluginField {
|
|||||||
pub no_cycle: Option<bool>, // 禁止循环引用
|
pub no_cycle: Option<bool>, // 禁止循环引用
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub scope_role: Option<String>, // 标记为数据权限的"所有者"字段
|
pub scope_role: Option<String>, // 标记为数据权限的"所有者"字段
|
||||||
|
pub ref_plugin: Option<String>, // 跨插件引用的目标插件 manifest ID(如 "erp-crm")
|
||||||
|
pub ref_fallback_label: Option<String>, // 目标插件未安装时的降级显示文本
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 字段类型
|
/// 字段类型
|
||||||
@@ -158,6 +162,8 @@ impl PluginField {
|
|||||||
validation: None,
|
validation: None,
|
||||||
no_cycle: None,
|
no_cycle: None,
|
||||||
scope_role: None,
|
scope_role: None,
|
||||||
|
ref_plugin: None,
|
||||||
|
ref_fallback_label: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,6 +192,12 @@ pub struct PluginRelation {
|
|||||||
pub entity: String,
|
pub entity: String,
|
||||||
pub foreign_key: String,
|
pub foreign_key: String,
|
||||||
pub on_delete: OnDeleteStrategy,
|
pub on_delete: OnDeleteStrategy,
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: Option<String>, // 关联名称(UI 显示用)
|
||||||
|
#[serde(default, alias = "type")]
|
||||||
|
pub relation_type: Option<String>, // "one_to_many" | "many_to_one" | "many_to_many"
|
||||||
|
#[serde(default)]
|
||||||
|
pub display_field: Option<String>, // 关联记录的显示字段
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 事件订阅配置
|
/// 事件订阅配置
|
||||||
@@ -916,6 +928,98 @@ cascade_filter = "customer_id"
|
|||||||
assert_eq!(contact_field.cascade_filter.as_deref(), Some("customer_id"));
|
assert_eq!(contact_field.cascade_filter.as_deref(), Some("customer_id"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_field_with_cross_plugin_ref() {
|
||||||
|
let toml = r#"
|
||||||
|
[metadata]
|
||||||
|
id = "erp-inventory"
|
||||||
|
name = "进销存"
|
||||||
|
version = "0.2.0"
|
||||||
|
dependencies = ["erp-crm"]
|
||||||
|
|
||||||
|
[schema]
|
||||||
|
[[schema.entities]]
|
||||||
|
name = "sales_order"
|
||||||
|
display_name = "销售订单"
|
||||||
|
|
||||||
|
[[schema.entities.fields]]
|
||||||
|
name = "customer_id"
|
||||||
|
field_type = "uuid"
|
||||||
|
display_name = "客户"
|
||||||
|
ui_widget = "entity_select"
|
||||||
|
ref_plugin = "erp-crm"
|
||||||
|
ref_entity = "customer"
|
||||||
|
ref_label_field = "name"
|
||||||
|
ref_search_fields = ["name", "code"]
|
||||||
|
ref_fallback_label = "CRM 客户"
|
||||||
|
"#;
|
||||||
|
let manifest = parse_manifest(toml).unwrap();
|
||||||
|
let field = &manifest.schema.unwrap().entities[0].fields[0];
|
||||||
|
assert_eq!(field.ref_plugin.as_deref(), Some("erp-crm"));
|
||||||
|
assert_eq!(field.ref_entity.as_deref(), Some("customer"));
|
||||||
|
assert_eq!(field.ref_label_field.as_deref(), Some("name"));
|
||||||
|
assert_eq!(field.ref_fallback_label.as_deref(), Some("CRM 客户"));
|
||||||
|
assert_eq!(manifest.metadata.dependencies, vec!["erp-crm"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_entity_with_is_public() {
|
||||||
|
let toml = r#"
|
||||||
|
[metadata]
|
||||||
|
id = "erp-crm"
|
||||||
|
name = "CRM"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[schema]
|
||||||
|
[[schema.entities]]
|
||||||
|
name = "customer"
|
||||||
|
display_name = "客户"
|
||||||
|
is_public = true
|
||||||
|
|
||||||
|
[[schema.entities]]
|
||||||
|
name = "internal_config"
|
||||||
|
display_name = "内部配置"
|
||||||
|
"#;
|
||||||
|
let manifest = parse_manifest(toml).unwrap();
|
||||||
|
let entities = &manifest.schema.unwrap().entities;
|
||||||
|
assert_eq!(entities[0].is_public, Some(true));
|
||||||
|
assert_eq!(entities[1].is_public, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_relation_with_name_and_type() {
|
||||||
|
let toml = r#"
|
||||||
|
[metadata]
|
||||||
|
id = "test"
|
||||||
|
name = "Test"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[schema]
|
||||||
|
[[schema.entities]]
|
||||||
|
name = "customer"
|
||||||
|
display_name = "客户"
|
||||||
|
|
||||||
|
[[schema.entities.fields]]
|
||||||
|
name = "code"
|
||||||
|
field_type = "string"
|
||||||
|
display_name = "编码"
|
||||||
|
|
||||||
|
[[schema.entities.relations]]
|
||||||
|
entity = "contact"
|
||||||
|
foreign_key = "customer_id"
|
||||||
|
on_delete = "cascade"
|
||||||
|
name = "contacts"
|
||||||
|
type = "one_to_many"
|
||||||
|
display_field = "name"
|
||||||
|
"#;
|
||||||
|
let manifest = parse_manifest(toml).unwrap();
|
||||||
|
let relation = &manifest.schema.unwrap().entities[0].relations[0];
|
||||||
|
assert_eq!(relation.entity, "contact");
|
||||||
|
assert_eq!(relation.name.as_deref(), Some("contacts"));
|
||||||
|
assert_eq!(relation.relation_type.as_deref(), Some("one_to_many"));
|
||||||
|
assert_eq!(relation.display_field.as_deref(), Some("name"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_kanban_page() {
|
fn parse_kanban_page() {
|
||||||
let toml = r#"
|
let toml = r#"
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ impl PluginModule {
|
|||||||
"/plugins/{plugin_id}/{entity}/aggregate",
|
"/plugins/{plugin_id}/{entity}/aggregate",
|
||||||
get(crate::handler::data_handler::aggregate_plugin_data::<S>),
|
get(crate::handler::data_handler::aggregate_plugin_data::<S>),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/plugins/{plugin_id}/{entity}/aggregate-multi",
|
||||||
|
post(crate::handler::data_handler::aggregate_multi_plugin_data::<S>),
|
||||||
|
)
|
||||||
// 批量操作路由
|
// 批量操作路由
|
||||||
.route(
|
.route(
|
||||||
"/plugins/{plugin_id}/{entity}/batch",
|
"/plugins/{plugin_id}/{entity}/batch",
|
||||||
@@ -100,8 +104,20 @@ impl PluginModule {
|
|||||||
.route(
|
.route(
|
||||||
"/plugins/{plugin_id}/{entity}/timeseries",
|
"/plugins/{plugin_id}/{entity}/timeseries",
|
||||||
get(crate::handler::data_handler::get_plugin_timeseries::<S>),
|
get(crate::handler::data_handler::get_plugin_timeseries::<S>),
|
||||||
|
)
|
||||||
|
// 跨插件引用:批量标签解析
|
||||||
|
.route(
|
||||||
|
"/plugins/{plugin_id}/{entity}/resolve-labels",
|
||||||
|
post(crate::handler::data_handler::resolve_ref_labels::<S>),
|
||||||
);
|
);
|
||||||
|
|
||||||
admin_routes.merge(data_routes)
|
// 实体注册表路由
|
||||||
|
let registry_routes = Router::new()
|
||||||
|
.route(
|
||||||
|
"/plugin-registry/entities",
|
||||||
|
get(crate::handler::data_handler::list_public_entities::<S>),
|
||||||
|
);
|
||||||
|
|
||||||
|
admin_routes.merge(data_routes).merge(registry_routes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ mod m20260417_000034_seed_plugin_permissions;
|
|||||||
mod m20260418_000035_pg_trgm_and_entity_columns;
|
mod m20260418_000035_pg_trgm_and_entity_columns;
|
||||||
mod m20260418_000036_add_data_scope_to_role_permissions;
|
mod m20260418_000036_add_data_scope_to_role_permissions;
|
||||||
mod m20260419_000037_create_user_departments;
|
mod m20260419_000037_create_user_departments;
|
||||||
|
mod m20260419_000038_fix_crm_permission_codes;
|
||||||
|
mod m20260419_000039_entity_registry_columns;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -81,6 +83,8 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260418_000035_pg_trgm_and_entity_columns::Migration),
|
Box::new(m20260418_000035_pg_trgm_and_entity_columns::Migration),
|
||||||
Box::new(m20260418_000036_add_data_scope_to_role_permissions::Migration),
|
Box::new(m20260418_000036_add_data_scope_to_role_permissions::Migration),
|
||||||
Box::new(m20260419_000037_create_user_departments::Migration),
|
Box::new(m20260419_000037_create_user_departments::Migration),
|
||||||
|
Box::new(m20260419_000038_fix_crm_permission_codes::Migration),
|
||||||
|
Box::new(m20260419_000039_entity_registry_columns::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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> {
|
||||||
|
// plugin_entities 新增 manifest_id 列 — 避免跨插件查询时 JOIN plugins 表
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
ALTER TABLE plugin_entities
|
||||||
|
ADD COLUMN IF NOT EXISTS manifest_id TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE plugin_entities
|
||||||
|
ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- 回填 manifest_id(从 plugins.manifest_json 提取 metadata.id)
|
||||||
|
UPDATE plugin_entities pe
|
||||||
|
SET manifest_id = COALESCE(p.manifest_json->'metadata'->>'id', '')
|
||||||
|
FROM plugins p
|
||||||
|
WHERE pe.plugin_id = p.id AND pe.deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- 跨插件实体查找索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plugin_entities_cross_ref
|
||||||
|
ON plugin_entities (manifest_id, entity_name, tenant_id)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
DROP INDEX IF EXISTS idx_plugin_entities_cross_ref;
|
||||||
|
ALTER TABLE plugin_entities DROP COLUMN IF EXISTS is_public;
|
||||||
|
ALTER TABLE plugin_entities DROP COLUMN IF EXISTS manifest_id;
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
430
plans/eager-sleeping-yao.md
Normal file
430
plans/eager-sleeping-yao.md
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
# P1 跨插件数据引用系统 — 实施计划
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
插件平台 P0 增强(混合执行模型/扩展聚合/原子回滚/Schema 演进)已全部完成。当前有两个行业插件(CRM + 进销存)运行在 WASM 插件系统上,但**跨插件数据引用完全不支持** — 进销存的 `customer_id` 只能存裸 UUID,无校验、无显示、无关联。
|
||||||
|
|
||||||
|
本计划实现 P1 跨插件数据引用系统,使插件能声明式引用其他插件的实体,并以财务插件作为验证载体。
|
||||||
|
|
||||||
|
**核心原则:** 外部引用永远是**软警告**,永不硬阻塞用户操作。
|
||||||
|
|
||||||
|
## 设计决策
|
||||||
|
|
||||||
|
| 决策点 | 方案 | 理由 |
|
||||||
|
|--------|------|------|
|
||||||
|
| Entity Registry | 复用 `plugin_entities` 表 + 新增 `manifest_id` 列 | 表已有 entity_name/table_name/schema_json,加列即可,无需新表 |
|
||||||
|
| 跨插件引用标识 | 新增 `ref_plugin: Option<String>` 字段 | 比设计文档的 `ref_scope="external"` 更明确,直接指定目标插件 ID |
|
||||||
|
| WIT 接口变更 | **不修改** WIT | 避免 recompile 所有插件,Host 层用点分记号 `"erp-crm.customer"` 解析 |
|
||||||
|
| 表格列标签解析 | 新增批量 resolve-labels 端点 | O(1) 网络请求,`WHERE id = ANY($1)` 索引查找 |
|
||||||
|
| 悬空引用对账 | 插件 re-enable 时异步触发 + 手动触发端点 | 不阻塞主流程,后台扫描 |
|
||||||
|
|
||||||
|
## 实施阶段总览
|
||||||
|
|
||||||
|
| Phase | 内容 | 依赖 | 预估 |
|
||||||
|
|-------|------|------|------|
|
||||||
|
| 1 | Manifest 扩展 + Entity Registry 数据层 | 无 | 1天 |
|
||||||
|
| 2 | 后端跨插件引用解析 + 校验 | Phase 1 | 1天 |
|
||||||
|
| 3 | API 端点(resolve-labels / registry / scan) | Phase 2 | 1天 |
|
||||||
|
| 4 | 前端改造(EntitySelect + 列标签 + 降级) | Phase 3 | 1.5天 |
|
||||||
|
| 5 | 悬空引用对账 | Phase 2 | 1天 |
|
||||||
|
| 6 | 验证(进销存插件改造 + 端到端测试) | Phase 1-5 | 0.5天 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Manifest 扩展 + Entity Registry 数据层
|
||||||
|
|
||||||
|
> 纯数据结构和迁移,零运行时影响。现有插件完全兼容。
|
||||||
|
|
||||||
|
### 1.1 manifest.rs — 扩展 PluginField
|
||||||
|
|
||||||
|
文件:`crates/erp-plugin/src/manifest.rs`
|
||||||
|
|
||||||
|
在 `PluginField` struct(~line 82)新增:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub ref_plugin: Option<String>, // 目标插件 manifest ID(如 "erp-crm")
|
||||||
|
pub ref_fallback_label: Option<String>, // 目标插件未安装时的降级显示文本
|
||||||
|
```
|
||||||
|
|
||||||
|
两个新字段加 `#[serde(default)]`,向后兼容。
|
||||||
|
|
||||||
|
### 1.2 manifest.rs — 扩展 PluginRelation
|
||||||
|
|
||||||
|
CRM 的 plugin.toml 已在使用 `name`/`type`/`display_field`,但当前 struct 只解析 `entity`/`foreign_key`/`on_delete`,其余被 serde 静默丢弃。补齐:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct PluginRelation {
|
||||||
|
pub entity: String,
|
||||||
|
pub foreign_key: String,
|
||||||
|
pub on_delete: OnDeleteStrategy,
|
||||||
|
pub name: Option<String>, // serde(default)
|
||||||
|
pub relation_type: Option<String>, // serde(default), "one_to_many" 等
|
||||||
|
pub display_field: Option<String>, // serde(default)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 manifest.rs — 扩展 PluginEntity
|
||||||
|
|
||||||
|
新增 `is_public` 标记实体是否可被其他插件引用:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub is_public: Option<bool>, // serde(default), false by default
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 数据库迁移 — plugin_entities 新增列
|
||||||
|
|
||||||
|
新迁移文件:`crates/erp-server/migration/src/m{timestamp}_entity_registry_columns.rs`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 新增 manifest_id 列,避免每次 JOIN plugins 表
|
||||||
|
ALTER TABLE plugin_entities
|
||||||
|
ADD COLUMN IF NOT EXISTS manifest_id TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- 新增 is_public 列
|
||||||
|
ALTER TABLE plugin_entities
|
||||||
|
ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- 回填 manifest_id(从 plugins.manifest_json 提取)
|
||||||
|
UPDATE plugin_entities pe
|
||||||
|
SET manifest_id = p.manifest_json->'metadata'->>'id'
|
||||||
|
FROM plugins p
|
||||||
|
WHERE pe.plugin_id = p.id AND pe.deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- 跨插件查找索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plugin_entities_cross_ref
|
||||||
|
ON plugin_entities (manifest_id, entity_name, tenant_id)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.5 SeaORM Entity 更新
|
||||||
|
|
||||||
|
文件:`crates/erp-plugin/src/entity/plugin_entity.rs`
|
||||||
|
|
||||||
|
新增字段映射:
|
||||||
|
```rust
|
||||||
|
pub manifest_id: String, // Column("manifest_id")
|
||||||
|
pub is_public: bool, // Column("is_public")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.6 service.rs — install 时填充新列
|
||||||
|
|
||||||
|
文件:`crates/erp-plugin/src/service.rs` (~line 112)
|
||||||
|
|
||||||
|
在 `install` 方法创建 `plugin_entity` 记录时,设置:
|
||||||
|
```rust
|
||||||
|
manifest_id: Set(manifest.metadata.id.clone()),
|
||||||
|
is_public: Set(entity_def.is_public.unwrap_or(false)),
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.7 单元测试
|
||||||
|
|
||||||
|
- 解析含 `ref_plugin` + `ref_fallback_label` 的字段
|
||||||
|
- 解析含 `name`/`type`/`display_field` 的 relation
|
||||||
|
- 解析含 `is_public` 的 entity
|
||||||
|
- 旧格式 TOML(无新字段)仍正常解析
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: 后端跨插件引用解析 + 校验
|
||||||
|
|
||||||
|
> 让 `validate_ref_entities` 和 `db_query` 能解析其他插件的实体表。
|
||||||
|
|
||||||
|
### 2.1 data_service.rs — 跨插件实体解析
|
||||||
|
|
||||||
|
文件:`crates/erp-plugin/src/data_service.rs`
|
||||||
|
|
||||||
|
新增函数:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// 按 manifest_id + entity_name 跨插件解析实体信息
|
||||||
|
pub async fn resolve_cross_plugin_entity(
|
||||||
|
target_manifest_id: &str,
|
||||||
|
entity_name: &str,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
) -> AppResult<EntityInfo>
|
||||||
|
```
|
||||||
|
|
||||||
|
查询 `plugin_entities` 表(`manifest_id = target AND entity_name = name AND tenant_id AND deleted_at IS NULL`),构建 `EntityInfo`。
|
||||||
|
|
||||||
|
### 2.2 data_service.rs — 修改 validate_ref_entities
|
||||||
|
|
||||||
|
文件:`crates/erp-plugin/src/data_service.rs` (~line 971)
|
||||||
|
|
||||||
|
当前逻辑:`resolve_manifest_id(plugin_id)` → `table_name(manifest_id, ref_entity)` — 始终用本插件的 manifest_id。
|
||||||
|
|
||||||
|
改为:
|
||||||
|
1. 若 `field.ref_plugin` 存在 → 用 `ref_plugin` 作为 target_manifest_id
|
||||||
|
2. 检查目标插件是否安装且活跃(查 `plugins` 表 status in `["running","installed"]`)
|
||||||
|
3. **目标插件活跃** → 解析目标表名 → 执行 UUID 存在性校验(与现有逻辑相同)
|
||||||
|
4. **目标插件未安装/禁用** → **跳过校验**(软警告,不阻塞)
|
||||||
|
5. 若 `field.ref_plugin` 不存在 → 走原有同插件逻辑(完全兼容)
|
||||||
|
|
||||||
|
### 2.3 host.rs — HostState 跨插件实体映射
|
||||||
|
|
||||||
|
文件:`crates/erp-plugin/src/host.rs`
|
||||||
|
|
||||||
|
新增字段到 `HostState`:
|
||||||
|
```rust
|
||||||
|
pub(crate) cross_plugin_entities: HashMap<String, String>,
|
||||||
|
// key: "erp-crm.customer" → value: "plugin_erp_crm__customer"
|
||||||
|
```
|
||||||
|
|
||||||
|
修改 `db_query`(~line 168):
|
||||||
|
```rust
|
||||||
|
let table_name = if entity.contains('.') {
|
||||||
|
// 点分记号 "erp-crm.customer" → 跨插件查询
|
||||||
|
self.cross_plugin_entities.get(&entity)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| format!("跨插件实体 '{}' 未注册", entity))?
|
||||||
|
} else {
|
||||||
|
DynamicTableManager::table_name(&self.plugin_id, &entity)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 engine.rs — 构建跨插件映射
|
||||||
|
|
||||||
|
文件:`crates/erp-plugin/src/engine.rs` (~line 473)
|
||||||
|
|
||||||
|
`execute_wasm` 创建 `HostState` 后,从 manifest 的所有 `ref_plugin` 字段解析跨插件实体映射:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 从 manifest 提取所有 ref_plugin + ref_entity 组合
|
||||||
|
// 查 plugin_entities 表获取实际 table_name
|
||||||
|
// 填入 HostState.cross_plugin_entities
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 集成测试
|
||||||
|
|
||||||
|
- 同插件 ref_entity → 行为不变(回归)
|
||||||
|
- 跨插件 ref_plugin + 目标插件活跃 → 校验通过/拒绝
|
||||||
|
- 跨插件 ref_plugin + 目标插件未安装 → 跳过校验,不报错
|
||||||
|
- host.rs db_query 点分记号 → 正确路由到目标插件表
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: API 端点
|
||||||
|
|
||||||
|
> 新增 3 个端点支撑前端跨插件功能。
|
||||||
|
|
||||||
|
### 3.1 批量标签解析(核心)
|
||||||
|
|
||||||
|
文件:`crates/erp-plugin/src/handler/data_handler.rs`
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/plugins/{plugin_id}/{entity}/resolve-labels
|
||||||
|
```
|
||||||
|
|
||||||
|
请求:
|
||||||
|
```json
|
||||||
|
{ "fields": { "customer_id": ["uuid1", "uuid2"] } }
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"customer_id": { "uuid1": "张三", "uuid2": "李四" },
|
||||||
|
"_meta": {
|
||||||
|
"customer_id": {
|
||||||
|
"target_plugin": "erp-crm",
|
||||||
|
"target_entity": "customer",
|
||||||
|
"label_field": "name",
|
||||||
|
"plugin_installed": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
逻辑:
|
||||||
|
1. 从 entity schema 读取每个 field 的 `ref_plugin` / `ref_entity` / `ref_label_field`
|
||||||
|
2. 对每个 field,解析目标表名(同 Phase 2 逻辑)
|
||||||
|
3. `SELECT id, data->>'label_field' as label FROM target_table WHERE id = ANY($1) AND tenant_id = $2`
|
||||||
|
4. 目标插件未安装 → 返回 `{ uuid: null }` + `plugin_installed: false`
|
||||||
|
|
||||||
|
### 3.2 实体注册表查询
|
||||||
|
|
||||||
|
文件:`crates/erp-plugin/src/handler/data_handler.rs`
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/plugin-registry/entities?is_public=true
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "manifest_id": "erp-crm", "entity_name": "customer", "display_name": "客户", "label_fields": ["name"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
从 `plugin_entities` 查询 `is_public = true AND deleted_at IS NULL`,关联 plugin 状态。
|
||||||
|
|
||||||
|
### 3.3 悬空引用扫描
|
||||||
|
|
||||||
|
文件:`crates/erp-plugin/src/handler/data_handler.rs`
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/plugins/{plugin_id}/scan-dangling-refs
|
||||||
|
```
|
||||||
|
|
||||||
|
异步触发扫描,返回扫描结果。详见 Phase 5。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: 前端改造
|
||||||
|
|
||||||
|
### 4.1 扩展 TypeScript 类型
|
||||||
|
|
||||||
|
文件:`apps/web/src/api/plugins.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// PluginFieldSchema 新增
|
||||||
|
ref_plugin?: string;
|
||||||
|
ref_fallback_label?: string;
|
||||||
|
|
||||||
|
// PluginEntitySchema 新增
|
||||||
|
is_public?: boolean;
|
||||||
|
```
|
||||||
|
|
||||||
|
文件:`apps/web/src/api/pluginData.ts` 新增:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
resolveRefLabels(pluginId, entity, fields): Promise<ResolveLabelsResult>
|
||||||
|
getPluginEntityRegistry(params?): Promise<RegistryEntity[]>
|
||||||
|
scanDanglingRefs(pluginId): Promise<ScanResult>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 EntitySelect 跨插件支持
|
||||||
|
|
||||||
|
文件:`apps/web/src/components/EntitySelect.tsx`
|
||||||
|
|
||||||
|
新增 props:`refPlugin?: string`, `fallbackLabel?: string`
|
||||||
|
|
||||||
|
核心改动:
|
||||||
|
- `refPlugin` 存在时 → 调用 `listPluginData(refPlugin, entity, ...)` 而非 `listPluginData(pluginId, entity, ...)`
|
||||||
|
- 目标插件不可达(404) → 显示灰色禁用 Input + 警告图标 + fallbackLabel
|
||||||
|
- 正常情况 → 保持现有 Select 行为
|
||||||
|
|
||||||
|
### 4.3 PluginCRUDPage 表格列标签解析
|
||||||
|
|
||||||
|
文件:`apps/web/src/pages/PluginCRUDPage.tsx`
|
||||||
|
|
||||||
|
**新增 hook:useResolveRefLabels**
|
||||||
|
|
||||||
|
数据加载后,收集所有 ref 字段的 UUID 值,调用 `resolveRefLabels` 批量获取标签。
|
||||||
|
|
||||||
|
**修改列渲染**(~line 263):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ref 字段渲染逻辑
|
||||||
|
if (f.ref_entity) {
|
||||||
|
const label = resolvedLabels[f.name]?.[uuid];
|
||||||
|
const installed = labelMeta[f.name]?.plugin_installed !== false;
|
||||||
|
if (!installed) return <Tag color="default">{f.ref_fallback_label || '外部引用'}</Tag>;
|
||||||
|
if (label === null) return <Tag color="warning">无效引用</Tag>;
|
||||||
|
return <Tag color="blue">{label}</Tag>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改 entity_select 表单渲染**(~line 341):
|
||||||
|
|
||||||
|
传 `refPlugin` 和 `fallbackLabel` 给 EntitySelect。
|
||||||
|
|
||||||
|
### 4.4 Detail Drawer 引用标签
|
||||||
|
|
||||||
|
在详情 Descriptions 中,对 ref 字段同样展示解析后的标签而非裸 UUID。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: 悬空引用对账
|
||||||
|
|
||||||
|
### 5.1 新增 reconciliation.rs
|
||||||
|
|
||||||
|
文件:`crates/erp-plugin/src/reconciliation.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct DanglingRef {
|
||||||
|
pub entity: String,
|
||||||
|
pub field: String,
|
||||||
|
pub record_id: Uuid,
|
||||||
|
pub ref_value: String,
|
||||||
|
pub reason: String, // "target_not_found" | "target_plugin_disabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn scan_dangling_refs(
|
||||||
|
manifest_id: &str, tenant_id: Uuid, db: &DatabaseConnection
|
||||||
|
) -> Vec<DanglingRef>
|
||||||
|
```
|
||||||
|
|
||||||
|
逻辑:遍历插件所有实体的 `ref_plugin` 字段,批量校验每个引用 UUID 是否存在于目标表。
|
||||||
|
|
||||||
|
### 5.2 数据库表
|
||||||
|
|
||||||
|
新迁移创建 `plugin_ref_scan_results` 表:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS plugin_ref_scan_results (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
plugin_id UUID NOT NULL REFERENCES plugins(id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'running',
|
||||||
|
total_scanned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dangling_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
result_json JSONB NOT NULL DEFAULT '[]',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 触发时机
|
||||||
|
|
||||||
|
- `service.rs::enable` 中:插件重新启用时,异步扫描依赖此插件的其他插件
|
||||||
|
- 手动触发:管理员在 UI 点击 "扫描悬空引用"
|
||||||
|
|
||||||
|
### 5.4 前端
|
||||||
|
|
||||||
|
在 PluginAdmin 的插件详情 Drawer 中新增 "扫描引用" 按钮 + 扫描结果列表。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: 验证
|
||||||
|
|
||||||
|
### 6.1 改造进销存插件
|
||||||
|
|
||||||
|
文件:`crates/erp-plugin-inventory/plugin.toml`
|
||||||
|
|
||||||
|
- `sales_order.customer_id` 增加 `ref_plugin = "erp-crm"`, `ref_entity = "customer"`, `ref_label_field = "name"`, `ref_fallback_label = "CRM 客户"`
|
||||||
|
- `metadata.dependencies` 添加 `"erp-crm"`
|
||||||
|
|
||||||
|
### 6.2 改造 CRM 插件
|
||||||
|
|
||||||
|
文件:`crates/erp-plugin-crm/plugin.toml`
|
||||||
|
|
||||||
|
- `customer` 实体增加 `is_public = true`
|
||||||
|
|
||||||
|
### 6.3 端到端验证矩阵
|
||||||
|
|
||||||
|
| 场景 | 预期 |
|
||||||
|
|------|------|
|
||||||
|
| CRM 已安装 → 进销存创建订单选择客户 | EntitySelect 下拉显示 CRM 客户列表 |
|
||||||
|
| CRM 未安装 → 进销存创建订单 | customer_id 字段降级为灰色文本输入 |
|
||||||
|
| CRM 已安装 → 订单列表显示客户名 | 表格列显示蓝色 Tag "张三" |
|
||||||
|
| CRM 卸载 → 重新安装 → 扫描悬空引用 | 对账报告显示悬空记录 |
|
||||||
|
| 财务插件独立安装(无 CRM) | 所有功能正常,客户字段降级 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证清单
|
||||||
|
|
||||||
|
- [ ] `cargo check` 全 workspace 通过
|
||||||
|
- [ ] `cargo test --workspace` 全部通过
|
||||||
|
- [ ] 数据库迁移正/反向执行
|
||||||
|
- [ ] 现有插件(CRM/进销存)功能不受影响
|
||||||
|
- [ ] 新增端点通过 API 测试
|
||||||
|
- [ ] 前端 `pnpm build` 通过
|
||||||
|
- [ ] 浏览器端到端操作验证
|
||||||
Reference in New Issue
Block a user