feat(workflow): 添加自定义函数表单绑定及权限控制功能

- 新增自定义函数与表单定义绑定关系字段 FormDefinitionId
- 实现按钮调用函数时的表单作用域权限校验机制
- 实现按钮提交表单时的作用域权限检查功能
- 添加自定义函数列表按表单定义筛选查询能力
- 新增 RelationPicker 关联选择表单组件支持
- 完善菜单权限体系支持受限但可见菜单配置
- 添加文件消息大小限制验证(最大200MB)
- 优化表单聚合变量同步异常处理机制
- 增加BuildFlow管理后台入口菜单种子数据
- 补充相关单元测试和架构约束测试用例
This commit is contained in:
xiangning 2026-07-05 09:30:45 +08:00
parent 96f11b5d87
commit baaff8db63
32 changed files with 2648 additions and 22 deletions

View File

@ -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);
}

View File

@ -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,

View File

@ -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);
}

View File

@ -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; }
}

View File

@ -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)

View File

@ -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 为空/nu​​ll → 所有已认证用户可见;
/// 否则 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();
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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 去重插入缺失菜单;已存在的系统种子菜单只同步路由契约字段,

View File

@ -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))

View File

@ -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>;

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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>>;

View File

@ -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,

View File

@ -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",

View File

@ -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!;

View File

@ -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();
}
}

View File

@ -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");
}
}
}

View File

@ -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");

View File

@ -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,

View File

@ -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,架构测试无法确定仓库边界。");
}
}

View File

@ -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,架构测试无法确定仓库边界。");
}
}

View File

@ -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()
{

View File

@ -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 == "自定义表单菜单运行形态只支持列表和表单");
}
}

View File

@ -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",

View File

@ -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");
}
}

View File

@ -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();

View File

@ -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");
}
}