feat(workflow): 添加自定义函数表单绑定及权限控制功能
- 新增自定义函数与表单定义绑定关系字段 FormDefinitionId - 实现按钮调用函数时的表单作用域权限校验机制 - 实现按钮提交表单时的作用域权限检查功能 - 添加自定义函数列表按表单定义筛选查询能力 - 新增 RelationPicker 关联选择表单组件支持 - 完善菜单权限体系支持受限但可见菜单配置 - 添加文件消息大小限制验证(最大200MB) - 优化表单聚合变量同步异常处理机制 - 增加BuildFlow管理后台入口菜单种子数据 - 补充相关单元测试和架构约束测试用例
This commit is contained in:
parent
96f11b5d87
commit
baaff8db63
@ -24,7 +24,8 @@ public class CreateCustomFunctionEndpoint(IMediator mediator)
|
||||
req.Code,
|
||||
req.InputSchema,
|
||||
req.OutputSchema,
|
||||
req.TimeoutMs),
|
||||
req.TimeoutMs,
|
||||
req.FormDefinitionId),
|
||||
ct);
|
||||
await Send.ResponseAsync(result, 201, ct);
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ namespace BuildFlow.Host.Endpoints.Workflow.BuildFlow.CustomFunctions;
|
||||
|
||||
/// <summary>创建自定义函数请求。</summary>
|
||||
public sealed record CreateCustomFunctionRequest(
|
||||
Guid? FormDefinitionId,
|
||||
string Name,
|
||||
string DisplayName,
|
||||
string? Description,
|
||||
|
||||
@ -18,7 +18,12 @@ public class GetCustomFunctionListEndpoint(IMediator mediator)
|
||||
public override async Task HandleAsync(GetCustomFunctionListRequest req, CancellationToken ct)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new GetCustomFunctionListQuery(req.PageIndex, req.PageSize, req.Status, req.Keyword),
|
||||
new GetCustomFunctionListQuery(
|
||||
req.PageIndex,
|
||||
req.PageSize,
|
||||
req.Status,
|
||||
req.Keyword,
|
||||
req.FormDefinitionId),
|
||||
ct);
|
||||
await Send.OkAsync(result, ct);
|
||||
}
|
||||
|
||||
@ -9,4 +9,5 @@ public class GetCustomFunctionListRequest
|
||||
public int PageSize { get; set; } = 20;
|
||||
public BuildFlowMetadataStatus? Status { get; set; }
|
||||
public string? Keyword { get; set; }
|
||||
public Guid? FormDefinitionId { get; set; }
|
||||
}
|
||||
|
||||
@ -10,6 +10,8 @@ namespace IM.Application.Chat.Validators;
|
||||
/// </summary>
|
||||
public class SendMessageCommandValidator : AbstractValidator<SendMessageCommand>
|
||||
{
|
||||
private const long MaxFileMessageSize = 200L * 1024 * 1024;
|
||||
|
||||
public SendMessageCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ConversationId).NotEmpty().WithMessage("会话 ID 不能为空");
|
||||
@ -33,6 +35,10 @@ public class SendMessageCommandValidator : AbstractValidator<SendMessageCommand>
|
||||
.NotEmpty()
|
||||
.When(x => x.Metadata is not null)
|
||||
.WithMessage("文件消息必须包含文件名");
|
||||
RuleFor(x => x.Metadata!.FileSize)
|
||||
.LessThanOrEqualTo(MaxFileMessageSize)
|
||||
.When(x => x.Metadata?.FileSize is not null)
|
||||
.WithMessage("文件不能超过 200MB");
|
||||
});
|
||||
|
||||
// 图片消息同样要求至少有一种引用方式(直链 URL 或 bucket+objectKey)
|
||||
|
||||
@ -23,25 +23,29 @@ public class GetAllMenusQueryHandler(PlatformDbContext db) : IRequestHandler<Get
|
||||
.ToListAsync(ct);
|
||||
|
||||
var userRoleSet = new HashSet<string>(userRoles);
|
||||
var visibleMenus = allMenus.Where(m => IsMenuVisible(m, userRoleSet)).ToList();
|
||||
var visibleMenus = allMenus.Where(m => ShouldReturnMenu(m, userRoleSet)).ToList();
|
||||
|
||||
return BuildTree(visibleMenus, null);
|
||||
return BuildTree(visibleMenus, null, userRoleSet);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断菜单对当前用户是否可见。
|
||||
/// 规则:Authority 为空/null → 所有已认证用户可见;
|
||||
/// 否则 Authority(逗号分隔)中任一角色与用户角色交集即为可见。
|
||||
/// 父菜单如果不可见,子菜单即使可见也不应出现(通过递归构建树时自然处理)。
|
||||
/// 判断用户是否具备菜单访问角色。
|
||||
/// 规则:Authority 为空/null 表示所有已认证用户可访问;
|
||||
/// 否则 Authority(逗号分隔)中任一角色与用户角色交集即为有权限。
|
||||
/// </summary>
|
||||
private static bool IsMenuVisible(Domain.Entities.Menu menu, HashSet<string> userRoles)
|
||||
private static bool HasMenuAuthority(Domain.Entities.Menu menu, HashSet<string> userRoles)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(menu.Authority)) return true;
|
||||
var allowedRoles = menu.Authority.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return allowedRoles.Any(userRoles.Contains);
|
||||
}
|
||||
|
||||
private static List<MenuRouteDto> BuildTree(List<Domain.Entities.Menu> menus, long? parentId)
|
||||
private static bool ShouldReturnMenu(Domain.Entities.Menu menu, HashSet<string> userRoles)
|
||||
{
|
||||
return HasMenuAuthority(menu, userRoles) || menu.MenuVisibleWithForbidden;
|
||||
}
|
||||
|
||||
private static List<MenuRouteDto> BuildTree(List<Domain.Entities.Menu> menus, long? parentId, HashSet<string> userRoles)
|
||||
{
|
||||
return menus
|
||||
.Where(m => m.ParentId == parentId)
|
||||
@ -64,7 +68,9 @@ public class GetAllMenusQueryHandler(PlatformDbContext db) : IRequestHandler<Get
|
||||
Link = m.Link,
|
||||
HideInMenu = m.HideInMenu ? true : null,
|
||||
NoBasicLayout = m.NoBasicLayout ? true : null,
|
||||
MenuVisibleWithForbidden = m.MenuVisibleWithForbidden ? true : null,
|
||||
MenuVisibleWithForbidden = m.MenuVisibleWithForbidden && !HasMenuAuthority(m, userRoles)
|
||||
? true
|
||||
: null,
|
||||
Authority = string.IsNullOrWhiteSpace(m.Authority)
|
||||
? null
|
||||
: m.Authority.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(),
|
||||
@ -72,7 +78,7 @@ public class GetAllMenusQueryHandler(PlatformDbContext db) : IRequestHandler<Get
|
||||
FormDefinitionId = m.FormDefinitionId,
|
||||
RuntimeViewMode = m.RenderType == MenuRenderType.Route ? null : m.RuntimeViewMode.ToString(),
|
||||
},
|
||||
Children = BuildTree(menus, m.Id)
|
||||
Children = BuildTree(menus, m.Id, userRoles)
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@ -33,5 +33,13 @@ public class CreateMenuCommandValidator : AbstractValidator<CreateMenuCommand>
|
||||
{
|
||||
RuleFor(x => x.FormDefinitionId).NotEmpty().WithMessage("自定义表单菜单必须绑定表单定义");
|
||||
});
|
||||
RuleFor(x => x.RuntimeViewMode)
|
||||
.Must(IsSupportedRuntimeViewMode)
|
||||
.WithMessage("自定义表单菜单运行形态只支持列表和表单");
|
||||
}
|
||||
|
||||
private static bool IsSupportedRuntimeViewMode(MenuRuntimeViewMode runtimeViewMode)
|
||||
{
|
||||
return runtimeViewMode is MenuRuntimeViewMode.List or MenuRuntimeViewMode.Form;
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,5 +28,19 @@ public class UpdateMenuCommandValidator : AbstractValidator<UpdateMenuCommand>
|
||||
{
|
||||
RuleFor(x => x.FormDefinitionId).NotEmpty().WithMessage("自定义表单菜单必须绑定表单定义");
|
||||
});
|
||||
RuleFor(x => x).Custom((command, context) =>
|
||||
{
|
||||
if (!IsSupportedRuntimeViewMode(command.RuntimeViewMode))
|
||||
{
|
||||
context.AddFailure(
|
||||
nameof(UpdateMenuCommand.RuntimeViewMode),
|
||||
"自定义表单菜单运行形态只支持列表和表单");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsSupportedRuntimeViewMode(MenuRuntimeViewMode? runtimeViewMode)
|
||||
{
|
||||
return runtimeViewMode is null or MenuRuntimeViewMode.List or MenuRuntimeViewMode.Form;
|
||||
}
|
||||
}
|
||||
|
||||
@ -234,6 +234,12 @@ public static class SeedData
|
||||
new SeedMenu(41, "workflow.definition", "workflow", "WorkflowDefinitionList", "list", "/workflow/list/index", null, "流程定义", "lucide:list", 1, false, null, null, false, false, "Admin"),
|
||||
// 流程设计器:hideInMenu,仅作为可路由页面存在,不进侧边栏。
|
||||
new SeedMenu(42, "workflow.designer", "workflow", "WorkflowDesigner", "designer/:id", "/workflow/designer/index", null, "流程设计器", null, 2, true, null, null, false, false, "Admin"),
|
||||
|
||||
// ===== BuildFlow 低代码元数据 buildflow =====
|
||||
new SeedMenu(60, "buildflow", "", "BuildFlow", "/buildflow", null, "/buildflow/forms", "BuildFlow", "lucide:blocks", 7, false, null, null, false, false, "Admin"),
|
||||
new SeedMenu(61, "buildflow.forms", "buildflow", "BuildFlowForms", "forms", "/buildflow/forms/index", null, "表单定义", "lucide:file-pen-line", 1, false, null, null, false, false, "Admin"),
|
||||
new SeedMenu(62, "buildflow.buttons", "buildflow", "BuildFlowButtons", "buttons", "/buildflow/buttons/index", null, "自定义按钮", "lucide:mouse-pointer-click", 2, false, null, null, false, false, "Admin"),
|
||||
new SeedMenu(63, "buildflow.functions", "buildflow", "BuildFlowCustomFunctions", "custom-functions", "/buildflow/custom-functions/index", null, "自定义函数", "lucide:braces", 3, false, null, null, false, false, "Admin"),
|
||||
};
|
||||
|
||||
// 按 Code 去重插入缺失菜单;已存在的系统种子菜单只同步路由契约字段,
|
||||
|
||||
@ -59,7 +59,7 @@ public class BuildFlowButtonActionExecutor(
|
||||
var type = action["type"]?.GetValue<string>();
|
||||
if (type == "submitForm")
|
||||
{
|
||||
await SubmitFormAsync(action, input, ct);
|
||||
await SubmitFormAsync(button, action, input, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -74,6 +74,7 @@ public class BuildFlowButtonActionExecutor(
|
||||
throw new BusinessException("runFunction 动作缺少有效函数ID");
|
||||
}
|
||||
|
||||
await EnsureFunctionScopeAsync(button, functionId, ct);
|
||||
var functionInput = BuildFunctionInputJson(action["params"] as JsonObject, input);
|
||||
var result = await functionExecutionService.ExecuteAsync(functionId, functionInput, ct);
|
||||
results.Add(result);
|
||||
@ -95,10 +96,39 @@ public class BuildFlowButtonActionExecutor(
|
||||
return new BuildFlowButtonActionResult(true, results);
|
||||
}
|
||||
|
||||
private async Task SubmitFormAsync(JsonObject action, JsonObject input, CancellationToken ct)
|
||||
private async Task EnsureFunctionScopeAsync(
|
||||
BuildFlowButtonDefinition button,
|
||||
Guid functionId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var function = await db.CustomFunctions
|
||||
.Where(x => x.Id == functionId)
|
||||
.Select(x => new { x.FormDefinitionId })
|
||||
.FirstOrDefaultAsync(ct)
|
||||
?? throw new BusinessException($"自定义函数 {functionId} 不存在");
|
||||
|
||||
if (function.FormDefinitionId is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.FormDefinitionId == function.FormDefinitionId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new BusinessException("按钮不能调用其他表单的自定义函数");
|
||||
}
|
||||
|
||||
private async Task SubmitFormAsync(
|
||||
BuildFlowButtonDefinition button,
|
||||
JsonObject action,
|
||||
JsonObject input,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var formDefinitionId = ReadGuid(action, "formDefinitionId") ?? ReadGuid(input, "formDefinitionId")
|
||||
?? throw new BusinessException("submitForm 动作缺少有效表单定义ID");
|
||||
EnsureSubmitFormScope(button, formDefinitionId);
|
||||
var instanceId = ReadGuid(input, "instanceId") ?? ReadGuid(action, "instanceId") ?? Guid.NewGuid();
|
||||
if (input["values"] is not JsonObject values)
|
||||
{
|
||||
@ -121,6 +151,23 @@ public class BuildFlowButtonActionExecutor(
|
||||
input["values"] = JsonNode.Parse(saved.DataJson)?.DeepClone();
|
||||
}
|
||||
|
||||
private static void EnsureSubmitFormScope(
|
||||
BuildFlowButtonDefinition button,
|
||||
Guid formDefinitionId)
|
||||
{
|
||||
if (button.FormDefinitionId is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.FormDefinitionId == formDefinitionId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new BusinessException("按钮不能提交其他表单的数据");
|
||||
}
|
||||
|
||||
private async Task EnsurePermissionAsync(BuildFlowButtonDefinition button, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(button.PermissionCode))
|
||||
|
||||
@ -11,4 +11,5 @@ public sealed record CreateCustomFunctionCommand(
|
||||
string Code,
|
||||
string? InputSchema,
|
||||
string? OutputSchema,
|
||||
int TimeoutMs) : IRequest<CustomFunctionDto>;
|
||||
int TimeoutMs,
|
||||
Guid? FormDefinitionId = null) : IRequest<CustomFunctionDto>;
|
||||
|
||||
@ -9,6 +9,7 @@ internal static class CustomFunctionMapper
|
||||
public static CustomFunctionDto ToDto(CustomFunction entity)
|
||||
=> new(
|
||||
entity.Id,
|
||||
entity.FormDefinitionId,
|
||||
entity.Name,
|
||||
entity.DisplayName,
|
||||
entity.Description,
|
||||
|
||||
@ -5,6 +5,7 @@ namespace Workflow.Application.BuildFlow.CustomFunctions.DTOs;
|
||||
/// <summary>自定义函数 DTO,用于函数管理页面和按钮动作选择器。</summary>
|
||||
public sealed record CustomFunctionDto(
|
||||
Guid Id,
|
||||
Guid? FormDefinitionId,
|
||||
string Name,
|
||||
string DisplayName,
|
||||
string? Description,
|
||||
|
||||
@ -20,6 +20,7 @@ public class CreateCustomFunctionCommandHandler(WorkflowDbContext db)
|
||||
var entity = new CustomFunction
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FormDefinitionId = request.FormDefinitionId,
|
||||
Name = request.Name,
|
||||
DisplayName = request.DisplayName,
|
||||
Description = request.Description,
|
||||
|
||||
@ -21,6 +21,11 @@ public sealed class GetCustomFunctionListQueryHandler(WorkflowDbContext db)
|
||||
query = query.Where(x => x.Status == request.Status.Value);
|
||||
}
|
||||
|
||||
if (request.FormDefinitionId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.FormDefinitionId == request.FormDefinitionId.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Keyword))
|
||||
{
|
||||
query = query.Where(x =>
|
||||
@ -35,6 +40,7 @@ public sealed class GetCustomFunctionListQueryHandler(WorkflowDbContext db)
|
||||
.Take(request.PageSize)
|
||||
.Select(x => new CustomFunctionDto(
|
||||
x.Id,
|
||||
x.FormDefinitionId,
|
||||
x.Name,
|
||||
x.DisplayName,
|
||||
x.Description,
|
||||
|
||||
@ -10,4 +10,5 @@ public sealed record GetCustomFunctionListQuery(
|
||||
int PageIndex,
|
||||
int PageSize,
|
||||
BuildFlowMetadataStatus? Status,
|
||||
string? Keyword) : IRequest<PagedResult<CustomFunctionDto>>;
|
||||
string? Keyword,
|
||||
Guid? FormDefinitionId = null) : IRequest<PagedResult<CustomFunctionDto>>;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Data.Common;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Workflow.Application.Form.Derivation;
|
||||
@ -54,12 +55,32 @@ public sealed class FormAggregateRefreshService(WorkflowDbContext db) : IFormAgg
|
||||
}
|
||||
|
||||
targetRow.DataJson = targetData.ToJsonString(JsonOptions);
|
||||
await MergeAggregateVariablesAsync(instanceId, variableUpdates, ct);
|
||||
await TryMergeAggregateVariablesAsync(instanceId, variableUpdates, ct);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 尝试把统计字段同步到流程实例变量。动态表单可脱离流程实例独立使用,
|
||||
/// 且运行库可能存在历史 workflow schema 漂移;变量同步失败不能阻断表单数据自身落库。
|
||||
/// </summary>
|
||||
private async Task TryMergeAggregateVariablesAsync(
|
||||
Guid instanceId,
|
||||
IReadOnlyCollection<(string Path, JsonNode? Value)> variableUpdates,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await MergeAggregateVariablesAsync(instanceId, variableUpdates, ct);
|
||||
}
|
||||
catch (DbException)
|
||||
{
|
||||
// WorkflowInstance.Variables 只是流程运行时的派生镜像;
|
||||
// 自定义表单提交的权威结果已经写回 targetRow.DataJson,不能因镜像同步失败回滚。
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MergeAggregateVariablesAsync(
|
||||
Guid instanceId,
|
||||
IReadOnlyCollection<(string Path, JsonNode? Value)> variableUpdates,
|
||||
|
||||
@ -19,6 +19,7 @@ public static class SchemaValidator
|
||||
[
|
||||
"Input", "Input.TextArea", "Input.Password", "Input.Number", "InputNumber",
|
||||
"Select", "DatePicker", "TimePicker", "Switch",
|
||||
"RelationPicker",
|
||||
"Radio.Group", "Checkbox.Group",
|
||||
"Upload", "FormGrid", "FormLayout", "FormItem",
|
||||
"ArrayItems", "ArrayItems.Item", "ArrayItems.Addition",
|
||||
|
||||
@ -6,14 +6,34 @@ namespace Workflow.Domain.Entities;
|
||||
/// <summary>BuildFlow 自定义函数定义。Name 是英文标识,Code 保存受控脚本内容。</summary>
|
||||
public class CustomFunction : BaseEntity, IFullAudit
|
||||
{
|
||||
/// <summary>绑定的自定义表单定义 ID。为空表示全局可复用函数。</summary>
|
||||
public Guid? FormDefinitionId { get; set; }
|
||||
|
||||
/// <summary>函数英文标识,供按钮动作链稳定引用。</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>函数显示名称。</summary>
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>函数说明。</summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>受控脚本代码。</summary>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>函数输入 Schema JSON。</summary>
|
||||
public string? InputSchema { get; set; }
|
||||
|
||||
/// <summary>函数输出 Schema JSON。</summary>
|
||||
public string? OutputSchema { get; set; }
|
||||
|
||||
/// <summary>函数发布状态。</summary>
|
||||
public BuildFlowMetadataStatus Status { get; set; } = BuildFlowMetadataStatus.Draft;
|
||||
|
||||
/// <summary>函数版本号。</summary>
|
||||
public int Version { get; set; } = 1;
|
||||
|
||||
/// <summary>执行超时时间,单位毫秒。</summary>
|
||||
public int TimeoutMs { get; set; }
|
||||
|
||||
public string CreatedBy { get; set; } = default!;
|
||||
|
||||
@ -12,6 +12,7 @@ public class CustomFunctionConfiguration : IEntityTypeConfiguration<CustomFuncti
|
||||
builder.ToTable("wf_custom_functions");
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id).ValueGeneratedNever().HasComment("主键ID");
|
||||
builder.Property(e => e.FormDefinitionId).HasComment("绑定的自定义表单定义ID,为空表示全局可复用函数");
|
||||
builder.Property(e => e.Name).HasMaxLength(200).IsRequired().HasComment("函数英文标识");
|
||||
builder.Property(e => e.DisplayName).HasMaxLength(200).IsRequired().HasComment("函数显示名称");
|
||||
builder.Property(e => e.Description).HasMaxLength(1000).HasComment("函数说明");
|
||||
@ -27,6 +28,7 @@ public class CustomFunctionConfiguration : IEntityTypeConfiguration<CustomFuncti
|
||||
builder.Property(e => e.UpdatedAt).HasComment("更新时间");
|
||||
builder.Property(e => e.IsDeleted).HasDefaultValue(false).HasComment("是否软删除");
|
||||
builder.Property(e => e.OperatorIP).HasMaxLength(500).HasComment("操作人IP地址");
|
||||
builder.HasIndex(e => e.FormDefinitionId);
|
||||
builder.HasIndex(e => e.Name).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Workflow.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCustomFunctionFormBinding : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "form_definition_id",
|
||||
table: "wf_custom_functions",
|
||||
type: "uuid",
|
||||
nullable: true,
|
||||
comment: "绑定的自定义表单定义ID,为空表示全局可复用函数");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_wf_custom_functions_form_definition_id",
|
||||
table: "wf_custom_functions",
|
||||
column: "form_definition_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_wf_custom_functions_form_definition_id",
|
||||
table: "wf_custom_functions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "form_definition_id",
|
||||
table: "wf_custom_functions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -467,6 +467,11 @@ namespace BuildFlow.Workflow.Infrastructure.Persistence.Migrations
|
||||
.HasColumnName("display_name")
|
||||
.HasComment("函数显示名称");
|
||||
|
||||
b.Property<Guid?>("FormDefinitionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("form_definition_id")
|
||||
.HasComment("绑定的自定义表单定义ID,为空表示全局可复用函数");
|
||||
|
||||
b.Property<string>("InputSchema")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("input_schema")
|
||||
@ -528,6 +533,9 @@ namespace BuildFlow.Workflow.Infrastructure.Persistence.Migrations
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_wf_custom_functions");
|
||||
|
||||
b.HasIndex("FormDefinitionId")
|
||||
.HasDatabaseName("ix_wf_custom_functions_form_definition_id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_wf_custom_functions_name");
|
||||
|
||||
@ -554,6 +554,20 @@ public static class SeedData
|
||||
CreatedBy = systemUserId,
|
||||
UpdatedBy = systemUserId,
|
||||
},
|
||||
new()
|
||||
{
|
||||
// 关联选择:从另一个已发布自定义表单读取候选项,提交时由后端写入稳定快照。
|
||||
Id = Guid.Parse("D0000030-0000-0000-0000-000000000001"),
|
||||
Name = "RelationPicker",
|
||||
DisplayName = "关联选择",
|
||||
Category = "选择控件",
|
||||
Icon = "lucide:link-2",
|
||||
SortOrder = 14,
|
||||
DefaultSchema = """{"type":"object","title":"未命名","x-decorator":"FormItem","x-component":"RelationPicker","x-field-kind":"relation","x-relation":{"sourceCode":"supplier","idField":"id","labelField":"name","snapshotFields":["code","name"]}}""",
|
||||
SupportedProps = """{"relation":[{"key":"sourceCode","label":"关联源编码","type":"string"},{"key":"idField","label":"ID 字段","type":"string"},{"key":"labelField","label":"显示字段","type":"string"},{"key":"snapshotFields","label":"快照字段","type":"string"}]}""",
|
||||
CreatedBy = systemUserId,
|
||||
UpdatedBy = systemUserId,
|
||||
},
|
||||
// ── 选择控件·新增(需渲染器分支):级联选择、滑块 ──
|
||||
new()
|
||||
{
|
||||
@ -563,7 +577,7 @@ public static class SeedData
|
||||
DisplayName = "级联选择",
|
||||
Category = "选择控件",
|
||||
Icon = "lucide:list-tree",
|
||||
SortOrder = 14,
|
||||
SortOrder = 15,
|
||||
DefaultSchema = """{"type":"array","title":"未命名","x-decorator":"FormItem","x-component":"Cascader","x-component-props":{"placeholder":"请选择"},"x-data-source":{"type":"static","options":[]}}""",
|
||||
SupportedProps = """{"basic":[{"key":"placeholder","label":"提示文字","type":"string"},{"key":"allowClear","label":"允许清除","type":"boolean"},{"key":"multiple","label":"多选","type":"boolean"}],"dataSource":{"supported":true,"types":["static","remote"]},"validation":[{"key":"customMessage","label":"错误提示","type":"string"}]}""",
|
||||
CreatedBy = systemUserId,
|
||||
@ -577,7 +591,7 @@ public static class SeedData
|
||||
DisplayName = "滑块",
|
||||
Category = "选择控件",
|
||||
Icon = "lucide:sliders-horizontal",
|
||||
SortOrder = 15,
|
||||
SortOrder = 16,
|
||||
DefaultSchema = """{"type":"number","title":"未命名","x-decorator":"FormItem","x-component":"Slider","x-component-props":{"min":0,"max":100}}""",
|
||||
SupportedProps = """{"basic":[{"key":"min","label":"最小值","type":"number"},{"key":"max","label":"最大值","type":"number"},{"key":"step","label":"步长","type":"number"},{"key":"range","label":"双滑块","type":"boolean"}],"validation":[{"key":"customMessage","label":"错误提示","type":"string"}]}""",
|
||||
CreatedBy = systemUserId,
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
namespace BuildFlow.Architecture.Tests;
|
||||
|
||||
using Xunit;
|
||||
|
||||
/// <summary>
|
||||
/// BuildFlow 低代码后台入口种子约束。保证干净库自动具备表单、按钮、函数管理菜单。
|
||||
/// </summary>
|
||||
public class BuildFlowEntrySeedTests
|
||||
{
|
||||
[Fact]
|
||||
public void PlatformSeedDataShouldContainBuildFlowMetadataMenus()
|
||||
{
|
||||
var seedData = ReadPlatformSeedData();
|
||||
|
||||
Assert.Contains("\"buildflow\"", seedData, StringComparison.Ordinal);
|
||||
Assert.Contains("\"buildflow.forms\"", seedData, StringComparison.Ordinal);
|
||||
Assert.Contains("\"/buildflow/forms/index\"", seedData, StringComparison.Ordinal);
|
||||
Assert.Contains("\"buildflow.buttons\"", seedData, StringComparison.Ordinal);
|
||||
Assert.Contains("\"/buildflow/buttons/index\"", seedData, StringComparison.Ordinal);
|
||||
Assert.Contains("\"buildflow.functions\"", seedData, StringComparison.Ordinal);
|
||||
Assert.Contains("\"/buildflow/custom-functions/index\"", seedData, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string ReadPlatformSeedData()
|
||||
{
|
||||
var repoRoot = FindRepoRoot();
|
||||
var seedPath = Path.Combine(
|
||||
repoRoot.FullName,
|
||||
"src",
|
||||
"modules",
|
||||
"platform",
|
||||
"Infrastructure",
|
||||
"Persistence",
|
||||
"SeedData.cs");
|
||||
|
||||
return File.ReadAllText(seedPath);
|
||||
}
|
||||
|
||||
private static DirectoryInfo FindRepoRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "BuildFlow.slnx")))
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("无法定位 BuildFlow.slnx,架构测试无法确定仓库边界。");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
namespace BuildFlow.Architecture.Tests;
|
||||
|
||||
using Xunit;
|
||||
|
||||
/// <summary>
|
||||
/// BuildFlow 表单组件种子约束。保证干净库可创建前端设计器内置的运行时字段。
|
||||
/// </summary>
|
||||
public class BuildFlowFormComponentSeedTests
|
||||
{
|
||||
[Fact]
|
||||
public void WorkflowSeedDataShouldContainRelationPickerComponent()
|
||||
{
|
||||
var seedData = ReadWorkflowSeedData();
|
||||
|
||||
Assert.Contains("RelationPicker", seedData, StringComparison.Ordinal);
|
||||
Assert.Contains("\"x-field-kind\":\"relation\"", seedData, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string ReadWorkflowSeedData()
|
||||
{
|
||||
var repoRoot = FindRepoRoot();
|
||||
var seedPath = Path.Combine(
|
||||
repoRoot.FullName,
|
||||
"src",
|
||||
"modules",
|
||||
"workflow",
|
||||
"Infrastructure",
|
||||
"Persistence",
|
||||
"SeedData.cs");
|
||||
|
||||
return File.ReadAllText(seedPath);
|
||||
}
|
||||
|
||||
private static DirectoryInfo FindRepoRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "BuildFlow.slnx")))
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("无法定位 BuildFlow.slnx,架构测试无法确定仓库边界。");
|
||||
}
|
||||
}
|
||||
@ -37,6 +37,23 @@ public class SendMessageCommandValidatorTests
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_file_message_larger_than_200mb()
|
||||
{
|
||||
var metadata = new MessageMetadataDto("report.zip", 200L * 1024 * 1024 + 1, null, null, null, null, null)
|
||||
{
|
||||
BucketName = "im-files",
|
||||
ObjectKey = "im/20260705/report.zip",
|
||||
ContentType = "application/zip"
|
||||
};
|
||||
var command = CreateCommand(MessageContentType.File, "report.zip", metadata);
|
||||
|
||||
var result = _validator.Validate(command);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.ErrorMessage == "文件不能超过 200MB");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_image_message_without_preview_or_storage_reference()
|
||||
{
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
using BuildFlow.Platform.Application.Menus.Commands;
|
||||
using BuildFlow.Platform.Application.Menus.Validators;
|
||||
using BuildFlow.Platform.Domain.Enums;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace BuildFlow.Platform.Tests.Menus;
|
||||
|
||||
/// <summary>
|
||||
/// 菜单运行时形态校验测试。BuildFlow 自定义菜单当前只支持列表和表单两种形态。
|
||||
/// </summary>
|
||||
public class MenuRuntimeViewModeValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateMenuCommand_ShouldRejectDetailRuntimeViewMode()
|
||||
{
|
||||
var validator = new CreateMenuCommandValidator();
|
||||
|
||||
var result = validator.Validate(new CreateMenuCommand
|
||||
{
|
||||
Code = "buildflow.form.expense.detail",
|
||||
FormDefinitionId = Guid.NewGuid(),
|
||||
Name = "BuildFlowFormExpenseDetail",
|
||||
Path = "/buildflow/runtime/forms/expense/detail",
|
||||
RenderType = MenuRenderType.BuildFlowForm,
|
||||
RuntimeViewMode = MenuRuntimeViewMode.Detail,
|
||||
Title = "费用申请详情",
|
||||
});
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(x =>
|
||||
x.PropertyName == nameof(CreateMenuCommand.RuntimeViewMode) &&
|
||||
x.ErrorMessage == "自定义表单菜单运行形态只支持列表和表单");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateMenuCommand_ShouldRejectDetailRuntimeViewMode()
|
||||
{
|
||||
var validator = new UpdateMenuCommandValidator();
|
||||
|
||||
var result = validator.Validate(new UpdateMenuCommand
|
||||
{
|
||||
Id = 100,
|
||||
FormDefinitionId = Guid.NewGuid(),
|
||||
RenderType = MenuRenderType.BuildFlowForm,
|
||||
RuntimeViewMode = MenuRuntimeViewMode.Detail,
|
||||
});
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(x =>
|
||||
x.PropertyName == nameof(UpdateMenuCommand.RuntimeViewMode) &&
|
||||
x.ErrorMessage == "自定义表单菜单运行形态只支持列表和表单");
|
||||
}
|
||||
}
|
||||
@ -127,6 +127,133 @@ public class MenuSortTests
|
||||
route.Meta.RuntimeViewMode.Should().Be("List");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllMenusQuery_ShouldHideRestrictedMenuWithoutVisibleForbidden()
|
||||
{
|
||||
await using var db = BuildInMemoryDb();
|
||||
var userId = Guid.NewGuid();
|
||||
var roleId = Guid.NewGuid();
|
||||
|
||||
db.Users.Add(new User
|
||||
{
|
||||
Id = userId,
|
||||
Username = "viewer",
|
||||
Email = "viewer@buildflow.local",
|
||||
PasswordHash = "hash",
|
||||
CreatedBy = "test",
|
||||
UpdatedBy = "test",
|
||||
OperatorIP = "127.0.0.1",
|
||||
});
|
||||
db.Roles.Add(new Role
|
||||
{
|
||||
Id = roleId,
|
||||
Name = "Viewer",
|
||||
CreatedBy = "test",
|
||||
UpdatedBy = "test",
|
||||
OperatorIP = "127.0.0.1",
|
||||
});
|
||||
db.UserRoles.Add(new UserRole
|
||||
{
|
||||
UserId = userId,
|
||||
RoleId = roleId,
|
||||
CreatedBy = "test",
|
||||
UpdatedBy = "test",
|
||||
});
|
||||
db.Menus.Add(BuildMenu(200, "finance.expense", null, 0, authority: "Admin"));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var handler = new GetAllMenusQueryHandler(db);
|
||||
|
||||
var routes = await handler.Handle(new GetAllMenusQuery(userId.ToString()), CancellationToken.None);
|
||||
|
||||
routes.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllMenusQuery_ShouldReturnForbiddenVisibleMenuOnlyForUnauthorizedUser()
|
||||
{
|
||||
await using var db = BuildInMemoryDb();
|
||||
var viewerId = Guid.NewGuid();
|
||||
var adminId = Guid.NewGuid();
|
||||
var viewerRoleId = Guid.NewGuid();
|
||||
var adminRoleId = Guid.NewGuid();
|
||||
|
||||
db.Users.AddRange(
|
||||
new User
|
||||
{
|
||||
Id = viewerId,
|
||||
Username = "viewer",
|
||||
Email = "viewer@buildflow.local",
|
||||
PasswordHash = "hash",
|
||||
CreatedBy = "test",
|
||||
UpdatedBy = "test",
|
||||
OperatorIP = "127.0.0.1",
|
||||
},
|
||||
new User
|
||||
{
|
||||
Id = adminId,
|
||||
Username = "admin",
|
||||
Email = "admin@buildflow.local",
|
||||
PasswordHash = "hash",
|
||||
CreatedBy = "test",
|
||||
UpdatedBy = "test",
|
||||
OperatorIP = "127.0.0.1",
|
||||
});
|
||||
db.Roles.AddRange(
|
||||
new Role
|
||||
{
|
||||
Id = viewerRoleId,
|
||||
Name = "Viewer",
|
||||
CreatedBy = "test",
|
||||
UpdatedBy = "test",
|
||||
OperatorIP = "127.0.0.1",
|
||||
},
|
||||
new Role
|
||||
{
|
||||
Id = adminRoleId,
|
||||
Name = "Admin",
|
||||
CreatedBy = "test",
|
||||
UpdatedBy = "test",
|
||||
OperatorIP = "127.0.0.1",
|
||||
});
|
||||
db.UserRoles.AddRange(
|
||||
new UserRole
|
||||
{
|
||||
UserId = viewerId,
|
||||
RoleId = viewerRoleId,
|
||||
CreatedBy = "test",
|
||||
UpdatedBy = "test",
|
||||
},
|
||||
new UserRole
|
||||
{
|
||||
UserId = adminId,
|
||||
RoleId = adminRoleId,
|
||||
CreatedBy = "test",
|
||||
UpdatedBy = "test",
|
||||
});
|
||||
db.Menus.Add(BuildMenu(
|
||||
200,
|
||||
"finance.expense",
|
||||
null,
|
||||
0,
|
||||
authority: "Admin",
|
||||
menuVisibleWithForbidden: true));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var handler = new GetAllMenusQueryHandler(db);
|
||||
|
||||
var viewerRoutes = await handler.Handle(new GetAllMenusQuery(viewerId.ToString()), CancellationToken.None);
|
||||
var adminRoutes = await handler.Handle(new GetAllMenusQuery(adminId.ToString()), CancellationToken.None);
|
||||
|
||||
var viewerRoute = viewerRoutes.Should().ContainSingle().Subject;
|
||||
viewerRoute.Meta.Authority.Should().Equal("Admin");
|
||||
viewerRoute.Meta.MenuVisibleWithForbidden.Should().BeTrue();
|
||||
|
||||
var adminRoute = adminRoutes.Should().ContainSingle().Subject;
|
||||
adminRoute.Meta.Authority.Should().Equal("Admin");
|
||||
adminRoute.Meta.MenuVisibleWithForbidden.Should().BeNull();
|
||||
}
|
||||
|
||||
private static PlatformDbContext BuildInMemoryDb()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
@ -141,7 +268,9 @@ public class MenuSortTests
|
||||
int order,
|
||||
MenuRenderType renderType = MenuRenderType.Route,
|
||||
Guid? formDefinitionId = null,
|
||||
MenuRuntimeViewMode runtimeViewMode = MenuRuntimeViewMode.List)
|
||||
MenuRuntimeViewMode runtimeViewMode = MenuRuntimeViewMode.List,
|
||||
string? authority = null,
|
||||
bool menuVisibleWithForbidden = false)
|
||||
=> new()
|
||||
{
|
||||
Id = id,
|
||||
@ -154,6 +283,8 @@ public class MenuSortTests
|
||||
RenderType = renderType,
|
||||
FormDefinitionId = formDefinitionId,
|
||||
RuntimeViewMode = runtimeViewMode,
|
||||
Authority = authority,
|
||||
MenuVisibleWithForbidden = menuVisibleWithForbidden,
|
||||
CreatedBy = "test",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedBy = "test",
|
||||
|
||||
@ -176,4 +176,46 @@ public class BuildFlowApplicationTests
|
||||
result.Items.Should().ContainSingle(x => x.Id == published.Id && x.Name == "publishedRule");
|
||||
result.Items.Should().NotContain(x => x.Id == draft.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFunctionList_FiltersByFormDefinition()
|
||||
{
|
||||
await using var db = TestDbFactory.Create();
|
||||
var expenseFormId = Guid.NewGuid();
|
||||
var purchaseFormId = Guid.NewGuid();
|
||||
var createHandler = new CreateCustomFunctionCommandHandler(db);
|
||||
var expenseFunction = await createHandler.Handle(
|
||||
new CreateCustomFunctionCommand(
|
||||
"expenseAmountCalc",
|
||||
"费用金额计算",
|
||||
null,
|
||||
"return input.amount;",
|
||||
null,
|
||||
null,
|
||||
1000,
|
||||
expenseFormId),
|
||||
CancellationToken.None);
|
||||
await createHandler.Handle(
|
||||
new CreateCustomFunctionCommand(
|
||||
"purchaseAmountCalc",
|
||||
"采购金额计算",
|
||||
null,
|
||||
"return input.amount;",
|
||||
null,
|
||||
null,
|
||||
1000,
|
||||
purchaseFormId),
|
||||
CancellationToken.None);
|
||||
|
||||
var handler = new GetCustomFunctionListQueryHandler(db);
|
||||
var result = await handler.Handle(
|
||||
new GetCustomFunctionListQuery(1, 20, null, null, expenseFormId),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Total.Should().Be(1);
|
||||
result.Items.Should().ContainSingle(x =>
|
||||
x.Id == expenseFunction.Id &&
|
||||
x.FormDefinitionId == expenseFormId &&
|
||||
x.Name == "expenseAmountCalc");
|
||||
}
|
||||
}
|
||||
|
||||
@ -215,7 +215,70 @@ public class BuildFlowButtonActionTests
|
||||
(await db.BuildFlowActionExecutionLogs.CountAsync()).Should().Be(0);
|
||||
}
|
||||
|
||||
private static async Task<CustomFunction> CreatePublishedFunctionAsync(WorkflowDbContext db)
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ShouldRejectFunctionBoundToAnotherForm()
|
||||
{
|
||||
await using var db = TestDbFactory.Create();
|
||||
var buttonFormId = Guid.NewGuid();
|
||||
var functionFormId = Guid.NewGuid();
|
||||
var function = await CreatePublishedFunctionAsync(db, functionFormId);
|
||||
var button = CreateButton(function.Id);
|
||||
button.FormDefinitionId = buttonFormId;
|
||||
db.BuildFlowButtons.Add(button);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var executor = new BuildFlowButtonActionExecutor(
|
||||
db,
|
||||
new CustomFunctionExecutionService(db));
|
||||
|
||||
var act = () => executor.ExecuteAsync(
|
||||
button.Id,
|
||||
"""{"amount":1200}""",
|
||||
CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<BusinessException>()
|
||||
.WithMessage("按钮不能调用其他表单的自定义函数");
|
||||
(await db.BuildFlowActionExecutionLogs.CountAsync()).Should().Be(0);
|
||||
(await db.CustomFunctionExecutionLogs.CountAsync()).Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ShouldRejectSubmitFormForAnotherForm()
|
||||
{
|
||||
await using var db = TestDbFactory.Create();
|
||||
var buttonFormId = Guid.NewGuid();
|
||||
var submittedFormId = Guid.NewGuid();
|
||||
db.FormDefinitions.Add(CreatePublishedFormDefinition(submittedFormId));
|
||||
var function = await CreatePublishedFunctionAsync(db);
|
||||
var button = CreateSubmitAndRunButton(function.Id);
|
||||
button.FormDefinitionId = buttonFormId;
|
||||
db.BuildFlowButtons.Add(button);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var executor = new BuildFlowButtonActionExecutor(
|
||||
db,
|
||||
new CustomFunctionExecutionService(db));
|
||||
|
||||
var act = () => executor.ExecuteAsync(
|
||||
button.Id,
|
||||
$$"""
|
||||
{
|
||||
"formDefinitionId": "{{submittedFormId}}",
|
||||
"values": { "name": "张三", "amount": 1200 }
|
||||
}
|
||||
""",
|
||||
CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<BusinessException>()
|
||||
.WithMessage("按钮不能提交其他表单的数据");
|
||||
(await db.FormData.CountAsync()).Should().Be(0);
|
||||
(await db.BuildFlowActionExecutionLogs.CountAsync()).Should().Be(0);
|
||||
(await db.CustomFunctionExecutionLogs.CountAsync()).Should().Be(0);
|
||||
}
|
||||
|
||||
private static async Task<CustomFunction> CreatePublishedFunctionAsync(
|
||||
WorkflowDbContext db,
|
||||
Guid? formDefinitionId = null)
|
||||
{
|
||||
var createHandler = new CreateCustomFunctionCommandHandler(db);
|
||||
var publishHandler = new PublishCustomFunctionCommandHandler(db);
|
||||
@ -227,7 +290,8 @@ public class BuildFlowButtonActionTests
|
||||
"((System.Text.Json.JsonElement)Variables[\"amount\"]).GetDecimal() > 1000 ? \"high\" : \"normal\"",
|
||||
null,
|
||||
null,
|
||||
5000),
|
||||
5000,
|
||||
formDefinitionId),
|
||||
CancellationToken.None);
|
||||
await publishHandler.Handle(new PublishCustomFunctionCommand(function.Id), CancellationToken.None);
|
||||
return await db.CustomFunctions.FindAsync(function.Id) ?? throw new InvalidOperationException();
|
||||
|
||||
@ -477,4 +477,31 @@ public class SchemaValidatorTests
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("sourceCode"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DefaultComponentsAcceptRelationPicker()
|
||||
{
|
||||
var result = SchemaValidator.Validate("""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"supplier": {
|
||||
"type": "object",
|
||||
"title": "供应商",
|
||||
"x-component": "RelationPicker",
|
||||
"x-field-kind": "relation",
|
||||
"x-relation": {
|
||||
"sourceCode": "supplier",
|
||||
"idField": "id",
|
||||
"labelField": "name",
|
||||
"snapshotFields": ["code", "name"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
result.IsValid.Should().BeTrue(string.Join("; ", result.Errors));
|
||||
result.Fields.Should().ContainSingle(f => f.Path == "supplier");
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user