refactor: SchemaValidator supports dynamic component whitelist from registry
This commit is contained in:
parent
d704c02d18
commit
84ccc48615
@ -1,18 +1,21 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Workflow.Application.Form.DTOs;
|
||||
using Workflow.Domain.Entities;
|
||||
using Workflow.Application.Form.Schema;
|
||||
using Workflow.Domain.Enums;
|
||||
using Workflow.Domain.Exceptions;
|
||||
using Workflow.Infrastructure.Persistence;
|
||||
|
||||
namespace Workflow.Application.Form.FormDefinition.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 创建表单定义:接收 Formily JSON Schema,校验后存入 SchemaJson。
|
||||
/// </summary>
|
||||
public record CreateFormDefinitionCommand(
|
||||
string Name,
|
||||
string Code,
|
||||
string? Description,
|
||||
List<FormFieldDto> Fields
|
||||
string SchemaJson
|
||||
) : IRequest<FormDefinitionDto>;
|
||||
|
||||
public class CreateFormDefinitionCommandHandler(WorkflowDbContext db)
|
||||
@ -20,7 +23,6 @@ public class CreateFormDefinitionCommandHandler(WorkflowDbContext db)
|
||||
{
|
||||
public async Task<FormDefinitionDto> Handle(CreateFormDefinitionCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 检查编码是否重复
|
||||
var exists = await db.FormDefinitions
|
||||
.AnyAsync(f => f.Code == request.Code, cancellationToken);
|
||||
if (exists)
|
||||
@ -28,6 +30,18 @@ public class CreateFormDefinitionCommandHandler(WorkflowDbContext db)
|
||||
throw new BusinessException($"表单编码 {request.Code} 已存在");
|
||||
}
|
||||
|
||||
var components = await db.FormComponentRegistries
|
||||
.Where(c => c.IsActive)
|
||||
.Select(c => c.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
var allowedSet = components.ToHashSet();
|
||||
|
||||
var validation = SchemaValidator.Validate(request.SchemaJson, allowedSet);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
throw new BusinessException($"Schema 校验失败: {string.Join("; ", validation.Errors)}");
|
||||
}
|
||||
|
||||
var entity = new Domain.Entities.FormDefinition
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@ -36,25 +50,9 @@ public class CreateFormDefinitionCommandHandler(WorkflowDbContext db)
|
||||
Description = request.Description,
|
||||
Version = 1,
|
||||
Status = FormStatus.Draft,
|
||||
SchemaJson = "{}",
|
||||
SchemaJson = request.SchemaJson,
|
||||
};
|
||||
|
||||
foreach (var fieldDto in request.Fields)
|
||||
{
|
||||
entity.Fields.Add(new FormDefinitionField
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FormDefinitionId = entity.Id,
|
||||
FieldKey = fieldDto.FieldKey,
|
||||
Label = fieldDto.Label,
|
||||
FieldType = fieldDto.FieldType,
|
||||
Required = fieldDto.Required,
|
||||
DefaultValue = fieldDto.DefaultValue,
|
||||
Config = fieldDto.Config,
|
||||
SortOrder = fieldDto.SortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
db.FormDefinitions.Add(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
|
||||
@ -1,16 +1,20 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Workflow.Application.Form.DTOs;
|
||||
using Workflow.Application.Form.Schema;
|
||||
using Workflow.Domain.Exceptions;
|
||||
using Workflow.Infrastructure.Persistence;
|
||||
|
||||
namespace Workflow.Application.Form.FormDefinition.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 更新表单定义:接收新的 Formily JSON Schema,校验后替换 SchemaJson 并递增 Version。
|
||||
/// </summary>
|
||||
public record UpdateFormDefinitionCommand(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
List<FormFieldDto> Fields
|
||||
string SchemaJson
|
||||
) : IRequest<FormDefinitionDto>;
|
||||
|
||||
public class UpdateFormDefinitionCommandHandler(WorkflowDbContext db)
|
||||
@ -19,57 +23,25 @@ public class UpdateFormDefinitionCommandHandler(WorkflowDbContext db)
|
||||
public async Task<FormDefinitionDto> Handle(UpdateFormDefinitionCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.FormDefinitions
|
||||
.Include(f => f.Fields)
|
||||
.FirstOrDefaultAsync(f => f.Id == request.Id, cancellationToken)
|
||||
?? throw new NotFoundException($"表单 {request.Id} 不存在");
|
||||
|
||||
var components = await db.FormComponentRegistries
|
||||
.Where(c => c.IsActive)
|
||||
.Select(c => c.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
var allowedSet = components.ToHashSet();
|
||||
|
||||
var validation = SchemaValidator.Validate(request.SchemaJson, allowedSet);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
throw new BusinessException($"Schema 校验失败: {string.Join("; ", validation.Errors)}");
|
||||
}
|
||||
|
||||
entity.Name = request.Name;
|
||||
entity.Description = request.Description;
|
||||
|
||||
// 根据 FieldKey 匹配,处理字段的增删改
|
||||
var existingFields = entity.Fields.ToDictionary(f => f.FieldKey);
|
||||
var requestedKeys = request.Fields.Select(f => f.FieldKey).ToHashSet();
|
||||
|
||||
// 删除不在请求中的字段
|
||||
var fieldsToRemove = entity.Fields
|
||||
.Where(f => !requestedKeys.Contains(f.FieldKey))
|
||||
.ToList();
|
||||
foreach (var field in fieldsToRemove)
|
||||
{
|
||||
entity.Fields.Remove(field);
|
||||
db.FormDefinitionFields.Remove(field);
|
||||
}
|
||||
|
||||
// 更新已有字段或新增字段
|
||||
foreach (var fieldDto in request.Fields)
|
||||
{
|
||||
if (existingFields.TryGetValue(fieldDto.FieldKey, out var existingField))
|
||||
{
|
||||
// 更新已有字段属性
|
||||
existingField.Label = fieldDto.Label;
|
||||
existingField.FieldType = fieldDto.FieldType;
|
||||
existingField.Required = fieldDto.Required;
|
||||
existingField.DefaultValue = fieldDto.DefaultValue;
|
||||
existingField.Config = fieldDto.Config;
|
||||
existingField.SortOrder = fieldDto.SortOrder;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 新增字段
|
||||
entity.Fields.Add(new Domain.Entities.FormDefinitionField
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FormDefinitionId = entity.Id,
|
||||
FieldKey = fieldDto.FieldKey,
|
||||
Label = fieldDto.Label,
|
||||
FieldType = fieldDto.FieldType,
|
||||
Required = fieldDto.Required,
|
||||
DefaultValue = fieldDto.DefaultValue,
|
||||
Config = fieldDto.Config,
|
||||
SortOrder = fieldDto.SortOrder,
|
||||
});
|
||||
}
|
||||
}
|
||||
entity.SchemaJson = request.SchemaJson;
|
||||
entity.Version++;
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
|
||||
135
src/Workflow.Application/Form/Schema/SchemaValidator.cs
Normal file
135
src/Workflow.Application/Form/Schema/SchemaValidator.cs
Normal file
@ -0,0 +1,135 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Workflow.Application.Form.Schema;
|
||||
|
||||
/// <summary>
|
||||
/// 校验 Formily JSON Schema 的合法性,并从中提取数据字段摘要。
|
||||
/// 校验规则:顶层必须是 object + properties;节点类型和 x-component 必须在白名单内;
|
||||
/// void 节点不能有 required/enum;递归校验嵌套 properties。
|
||||
/// </summary>
|
||||
public static class SchemaValidator
|
||||
{
|
||||
private static readonly HashSet<string> AllowedTypes =
|
||||
["string", "number", "integer", "boolean", "array", "object", "void"];
|
||||
|
||||
private static readonly HashSet<string> DefaultAllowedComponents =
|
||||
[
|
||||
"Input", "Input.TextArea", "Input.Password", "Input.Number",
|
||||
"Select", "DatePicker", "TimePicker", "Switch",
|
||||
"Radio.Group", "Checkbox.Group",
|
||||
"Upload", "FormGrid", "FormLayout", "FormItem",
|
||||
"ArrayItems", "ArrayItems.Item", "ArrayItems.Addition",
|
||||
"ArrayItems.SortHandle", "ArrayItems.Remove",
|
||||
"Space", "Card", "Tabs", "TabPane", "Collapse", "CollapsePanel",
|
||||
"Editable", "Editable.Popover"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// 校验 Formily Schema 并提取数据字段摘要。
|
||||
/// 当 allowedComponents 为 null 时,使用内置的 DefaultAllowedComponents 作为回退白名单。
|
||||
/// </summary>
|
||||
public static SchemaValidationResult Validate(string schemaJson, HashSet<string>? allowedComponents = null)
|
||||
{
|
||||
var allowed = allowedComponents ?? DefaultAllowedComponents;
|
||||
var errors = new List<string>();
|
||||
var fields = new List<FieldSummary>();
|
||||
|
||||
JsonElement root;
|
||||
try
|
||||
{
|
||||
root = JsonSerializer.Deserialize<JsonElement>(schemaJson);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return new SchemaValidationResult(false, [$"JSON 格式无效: {ex.Message}"], []);
|
||||
}
|
||||
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return new SchemaValidationResult(false, ["Schema 必须是 JSON 对象"], []);
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("type", out var typeProp) || typeProp.GetString() != "object")
|
||||
{
|
||||
errors.Add("Schema 顶层 type 必须为 'object'");
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("properties", out var props) || props.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
errors.Add("Schema 顶层必须包含 properties 对象");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var prop in props.EnumerateObject())
|
||||
{
|
||||
ValidateNode(prop.Name, prop.Value, errors, fields, "", allowed);
|
||||
}
|
||||
}
|
||||
|
||||
return new SchemaValidationResult(errors.Count == 0, errors, fields);
|
||||
}
|
||||
|
||||
private static void ValidateNode(
|
||||
string name,
|
||||
JsonElement node,
|
||||
List<string> errors,
|
||||
List<FieldSummary> fields,
|
||||
string parentPath,
|
||||
HashSet<string> allowed)
|
||||
{
|
||||
var path = string.IsNullOrEmpty(parentPath) ? name : $"{parentPath}.{name}";
|
||||
|
||||
if (node.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
errors.Add($"节点 {path} 必须是 JSON 对象");
|
||||
return;
|
||||
}
|
||||
|
||||
var typeValue = node.TryGetProperty("type", out var tp) ? tp.GetString() : null;
|
||||
if (typeValue is null || !AllowedTypes.Contains(typeValue))
|
||||
{
|
||||
errors.Add($"节点 {path} 的 type '{typeValue}' 不合法,允许值: {string.Join(", ", AllowedTypes)}");
|
||||
}
|
||||
|
||||
if (node.TryGetProperty("x-component", out var compProp))
|
||||
{
|
||||
var comp = compProp.GetString();
|
||||
if (comp is not null && !allowed.Contains(comp))
|
||||
{
|
||||
errors.Add($"节点 {path} 的 x-component '{comp}' 不在允许列表中");
|
||||
}
|
||||
}
|
||||
|
||||
var isVoid = typeValue == "void";
|
||||
|
||||
if (isVoid && node.TryGetProperty("required", out _))
|
||||
{
|
||||
errors.Add($"节点 {path} 是 void 类型,不能设置 required");
|
||||
}
|
||||
|
||||
if (isVoid && node.TryGetProperty("enum", out _))
|
||||
{
|
||||
errors.Add($"节点 {path} 是 void 类型,不能设置 enum");
|
||||
}
|
||||
|
||||
if (!isVoid && typeValue is not null)
|
||||
{
|
||||
var title = node.TryGetProperty("title", out var titleProp) ? titleProp.GetString() : null;
|
||||
var required = node.TryGetProperty("required", out var reqProp) && reqProp.GetBoolean();
|
||||
fields.Add(new FieldSummary(path, typeValue, required, title));
|
||||
}
|
||||
|
||||
if (node.TryGetProperty("properties", out var childProps) && childProps.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var child in childProps.EnumerateObject())
|
||||
{
|
||||
ValidateNode(child.Name, child.Value, errors, fields, path, allowed);
|
||||
}
|
||||
}
|
||||
|
||||
if (node.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
ValidateNode("items", items, errors, fields, path, allowed);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user