From 9f878286e7b6f11b7b04842beb6b5045af6a8b2a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=90=91=E5=AE=81?= <1772105645@qq.com>
Date: Sun, 14 Jun 2026 15:03:11 +0800
Subject: [PATCH] feat: form versioning, notification center, scheduler and
webhooks
- Add FormDefinitionVersion with compare/versions endpoints and schema differ
- Add Notification entity, endpoints and application features
- Add Scheduler (timeout) and WebhookDispatcher services
- Add FormDataValidator/FieldPermissionEvaluator/ReactionEvaluator
- Add workflow task mark-read, CC support and SystemUserContext
- Add EF migrations for form versions and notifications
- Add unit tests for form schema, notifications, scheduler and serialization
---
.../Configuration/ComparatorConfiguration.cs | 6 +
.../Configuration/JwtAuthConfiguration.cs | 34 +-
.../NotificationConfiguration.cs | 39 +
.../Form/CompareFormVersionsEndpoint.cs | 36 +
.../Form/CreateFormComponentEndpoint.cs | 1 -
.../Form/CreateFormDefinitionEndpoint.cs | 1 -
.../Form/DeleteFormComponentEndpoint.cs | 1 -
.../Form/DeleteFormDefinitionEndpoint.cs | 1 -
.../Form/DisableFormDefinitionEndpoint.cs | 5 +-
.../Form/GetFormComponentByIdEndpoint.cs | 1 -
.../Form/GetFormComponentsEndpoint.cs | 1 -
.../Form/GetFormDataByInstanceEndpoint.cs | 1 -
.../Form/GetFormDefinitionByIdEndpoint.cs | 1 -
.../Form/GetFormDefinitionListEndpoint.cs | 1 -
.../Endpoints/Form/GetFormVersionsEndpoint.cs | 29 +
.../Form/PublishFormDefinitionEndpoint.cs | 1 -
.../Endpoints/Form/SubmitFormDataEndpoint.cs | 1 -
.../Form/UpdateFormComponentEndpoint.cs | 1 -
.../Form/UpdateFormDefinitionEndpoint.cs | 1 -
.../Notification/NotificationEndpoints.cs | 104 ++
.../WorkflowDefinition/CreateEdgeEndpoint.cs | 1 -
.../WorkflowDefinition/CreateNodeEndpoint.cs | 11 +-
.../CreateWorkflowDefinitionEndpoint.cs | 1 -
.../WorkflowDefinition/DeleteEdgeEndpoint.cs | 5 +-
.../WorkflowDefinition/DeleteNodeEndpoint.cs | 5 +-
.../DeleteWorkflowDefinitionEndpoint.cs | 1 -
.../DisableWorkflowDefinitionEndpoint.cs | 1 -
.../GetWorkflowDefinitionByIdEndpoint.cs | 1 -
.../GetWorkflowDefinitionListEndpoint.cs | 1 -
.../PublishWorkflowDefinitionEndpoint.cs | 1 -
.../WorkflowDefinition/UpdateEdgeEndpoint.cs | 1 -
.../WorkflowDefinition/UpdateNodeEndpoint.cs | 10 +-
.../UpdateWorkflowDefinitionEndpoint.cs | 1 -
.../GetWorkflowInstanceByIdEndpoint.cs | 1 -
.../GetWorkflowInstanceListEndpoint.cs | 1 -
.../MonitorWorkflowInstancesEndpoint.cs | 1 -
.../ResumeWorkflowInstanceEndpoint.cs | 1 -
.../StartWorkflowInstanceEndpoint.cs | 1 -
.../SuspendWorkflowInstanceEndpoint.cs | 1 -
.../WithdrawWorkflowInstanceEndpoint.cs | 1 -
.../WorkflowTask/ApproveTaskEndpoint.cs | 4 +-
.../WorkflowTask/DelegateTaskEndpoint.cs | 1 -
.../WorkflowTask/GetCcTasksEndpoint.cs | 1 -
.../WorkflowTask/GetHistoryTasksEndpoint.cs | 1 -
.../WorkflowTask/GetOverdueTasksEndpoint.cs | 1 -
.../WorkflowTask/GetPendingTasksEndpoint.cs | 1 -
.../WorkflowTask/GetTaskByIdEndpoint.cs | 3 +-
.../WorkflowTask/MarkCcTaskReadEndpoint.cs | 36 +
.../WorkflowTask/RejectTaskEndpoint.cs | 1 -
.../WorkflowTask/TransferTaskEndpoint.cs | 1 -
.../WorkflowTask/UrgeTaskEndpoint.cs | 1 -
.../Middleware/ApiResponseMiddleware.cs | 25 +-
.../Middleware/GlobalExceptionMiddleware.cs | 7 +
src/Workflow.Api/Program.cs | 20 +-
src/Workflow.Api/Protos/auth.proto | 35 -
.../Serialization/TimestampJsonConverter.cs | 63 +
src/Workflow.Api/Services/AuthGrpcClient.cs | 47 -
.../Services/CurrentUserContext.cs | 31 +-
.../Services/SystemUserContext.cs | 18 +
.../Services/TimeoutSchedulerService.cs | 65 ++
.../Services/WebhookDispatcherService.cs | 220 ++++
src/Workflow.Api/Workflow.Api.csproj | 6 +-
src/Workflow.Api/appsettings.json | 19 +-
.../Engine/NodeConfigParser.cs | 59 +
.../Engine/ProcessEngine.cs | 224 +++-
.../Notifications/NotificationQueries.cs | 150 +++
.../Commands/CreateEdgeCommand.cs | 8 +
.../Commands/CreateNodeCommand.cs | 28 +-
.../Commands/DeleteNodeCommand.cs | 6 +
.../PublishWorkflowDefinitionCommand.cs | 6 +
.../Commands/UpdateNodeCommand.cs | 28 +-
.../DTOs/WorkflowDefinitionDTOs.cs | 4 +-
.../Queries/GetWorkflowDefinitionByIdQuery.cs | 18 +-
.../Commands/StartWorkflowInstanceCommand.cs | 96 +-
.../WithdrawWorkflowInstanceCommand.cs | 7 +
.../Queries/MonitorWorkflowInstancesQuery.cs | 2 +
.../Commands/ApproveTaskCommand.cs | 114 +-
.../Commands/DelegateTaskCommand.cs | 18 +
.../Commands/MarkCcTaskReadCommand.cs | 53 +
.../Commands/RejectTaskCommand.cs | 6 +
.../Commands/TransferTaskCommand.cs | 18 +
.../WorkflowTasks/Commands/UrgeTaskCommand.cs | 17 +-
.../WorkflowTasks/DTOs/WorkflowTaskDTOs.cs | 17 +
.../Queries/GetOverdueTasksQuery.cs | 3 +
.../Queries/GetPendingTasksQuery.cs | 1 +
.../WorkflowTasks/Queries/GetTaskByIdQuery.cs | 67 +-
.../Form/DTOs/FormVersionDTOs.cs | 22 +
.../Form/DTOs/PagedResult.cs | 2 +-
.../Commands/SubmitFormDataCommand.cs | 60 +-
.../Commands/CreateFormDefinitionCommand.cs | 2 +-
.../Commands/DeleteFormDefinitionCommand.cs | 16 +
.../Commands/PublishFormDefinitionCommand.cs | 11 +
.../Commands/UpdateFormDefinitionCommand.cs | 13 +-
.../Queries/CompareFormVersionsQuery.cs | 79 ++
.../Queries/GetFormDefinitionListQuery.cs | 2 +-
.../Queries/GetFormVersionsQuery.cs | 35 +
.../Form/Schema/FieldPermissionEvaluator.cs | 67 ++
.../Form/Schema/FieldSummary.cs | 50 +-
.../Form/Schema/FormDataValidationResult.cs | 8 +
.../Form/Schema/FormDataValidator.cs | 234 ++++
.../Form/Schema/ReactionEvaluator.cs | 140 +++
.../Form/Schema/SchemaDiffer.cs | 128 ++
.../Form/Schema/SchemaValidator.cs | 494 +++++++-
.../Notifications/INotificationService.cs | 26 +
.../Notifications/NotificationOptions.cs | 48 +
.../Notifications/NotificationService.cs | 199 ++++
.../Scheduler/OverdueTaskProcessor.cs | 122 ++
.../Workflow.Application.csproj | 3 +
src/Workflow.Domain/Common/Interfaces.cs | 7 +-
.../Entities/FormComponentRegistry.cs | 6 +
src/Workflow.Domain/Entities/FormData.cs | 5 +
.../Entities/FormDefinition.cs | 5 +
.../Entities/FormDefinitionVersion.cs | 31 +
src/Workflow.Domain/Entities/Notification.cs | 35 +
.../Entities/WebhookDelivery.cs | 41 +
.../Entities/WorkflowDefinition.cs | 9 +
src/Workflow.Domain/Entities/WorkflowEdge.cs | 7 +
.../Entities/WorkflowInstance.cs | 8 +
src/Workflow.Domain/Entities/WorkflowNode.cs | 1 +
src/Workflow.Domain/Entities/WorkflowTask.cs | 11 +
src/Workflow.Domain/Entities/WorkflowToken.cs | 6 +
.../Enums/StateMachineEnums.cs | 16 +
src/Workflow.Domain/Enums/WorkflowEnums.cs | 20 +-
src/Workflow.Domain/Exceptions/Exceptions.cs | 4 +
.../Expressions/ConditionEvaluator.cs | 30 +-
.../StateMachine/InstanceStateMachine.cs | 40 +
.../StateMachine/TaskStateMachine.cs | 33 +
.../StateMachine/TokenStateMachine.cs | 22 +
...0544_AddFormDefinitionVersions.Designer.cs | 819 +++++++++++++
...0260613200544_AddFormDefinitionVersions.cs | 52 +
...0260614040507_AddNotifications.Designer.cs | 1038 +++++++++++++++++
.../20260614040507_AddNotifications.cs | 99 ++
.../WorkflowDbContextModelSnapshot.cs | 284 +++++
.../FormDefinitionVersionConfiguration.cs | 30 +
.../NotificationConfiguration.cs | 36 +
.../WebhookDeliveryConfiguration.cs | 37 +
.../Interceptors/AuditInterceptor.cs | 9 +
.../Persistence/SeedData.cs | 52 +-
.../Persistence/WorkflowDbContext.cs | 14 +
.../Engine/ProcessEngineTests.cs | 273 +++++
tests/Workflow.Tests/Form/FormDataTests.cs | 90 ++
.../Form/FormDataValidatorTests.cs | 324 +++++
.../Form/FormDefinitionTests.cs | 2 +-
.../Form/FormDeletionReferenceTests.cs | 183 +++
.../Workflow.Tests/Form/SchemaDifferTests.cs | 166 +++
.../Form/SchemaValidatorTests.cs | 414 +++++++
.../Handlers/EdgeCommandHandlerTests.cs | 47 +
.../Handlers/NodeCommandHandlerTests.cs | 261 ++++-
.../Handlers/WorkflowInstanceHandlerTests.cs | 324 +++++
.../Handlers/WorkflowTaskHandlerTests.cs | 575 +++++++++
.../Notification/NotificationQueryTests.cs | 144 +++
.../Notification/NotificationServiceTests.cs | 246 ++++
.../Scheduler/TimeoutSchedulerTests.cs | 238 ++++
.../TimestampJsonConverterTests.cs | 90 ++
tests/Workflow.Tests/Workflow.Tests.csproj | 1 +
155 files changed, 9482 insertions(+), 334 deletions(-)
create mode 100644 src/Workflow.Api/Configuration/NotificationConfiguration.cs
create mode 100644 src/Workflow.Api/Endpoints/Form/CompareFormVersionsEndpoint.cs
create mode 100644 src/Workflow.Api/Endpoints/Form/GetFormVersionsEndpoint.cs
create mode 100644 src/Workflow.Api/Endpoints/Notification/NotificationEndpoints.cs
create mode 100644 src/Workflow.Api/Endpoints/WorkflowTask/MarkCcTaskReadEndpoint.cs
delete mode 100644 src/Workflow.Api/Protos/auth.proto
create mode 100644 src/Workflow.Api/Serialization/TimestampJsonConverter.cs
delete mode 100644 src/Workflow.Api/Services/AuthGrpcClient.cs
create mode 100644 src/Workflow.Api/Services/SystemUserContext.cs
create mode 100644 src/Workflow.Api/Services/TimeoutSchedulerService.cs
create mode 100644 src/Workflow.Api/Services/WebhookDispatcherService.cs
create mode 100644 src/Workflow.Application/Engine/NodeConfigParser.cs
create mode 100644 src/Workflow.Application/Features/Notifications/NotificationQueries.cs
create mode 100644 src/Workflow.Application/Features/WorkflowTasks/Commands/MarkCcTaskReadCommand.cs
create mode 100644 src/Workflow.Application/Form/DTOs/FormVersionDTOs.cs
create mode 100644 src/Workflow.Application/Form/FormDefinition/Queries/CompareFormVersionsQuery.cs
create mode 100644 src/Workflow.Application/Form/FormDefinition/Queries/GetFormVersionsQuery.cs
create mode 100644 src/Workflow.Application/Form/Schema/FieldPermissionEvaluator.cs
create mode 100644 src/Workflow.Application/Form/Schema/FormDataValidationResult.cs
create mode 100644 src/Workflow.Application/Form/Schema/FormDataValidator.cs
create mode 100644 src/Workflow.Application/Form/Schema/ReactionEvaluator.cs
create mode 100644 src/Workflow.Application/Form/Schema/SchemaDiffer.cs
create mode 100644 src/Workflow.Application/Notifications/INotificationService.cs
create mode 100644 src/Workflow.Application/Notifications/NotificationOptions.cs
create mode 100644 src/Workflow.Application/Notifications/NotificationService.cs
create mode 100644 src/Workflow.Application/Scheduler/OverdueTaskProcessor.cs
create mode 100644 src/Workflow.Domain/Entities/FormDefinitionVersion.cs
create mode 100644 src/Workflow.Domain/Entities/Notification.cs
create mode 100644 src/Workflow.Domain/Entities/WebhookDelivery.cs
create mode 100644 src/Workflow.Infrastructure/Migrations/20260613200544_AddFormDefinitionVersions.Designer.cs
create mode 100644 src/Workflow.Infrastructure/Migrations/20260613200544_AddFormDefinitionVersions.cs
create mode 100644 src/Workflow.Infrastructure/Migrations/20260614040507_AddNotifications.Designer.cs
create mode 100644 src/Workflow.Infrastructure/Migrations/20260614040507_AddNotifications.cs
create mode 100644 src/Workflow.Infrastructure/Persistence/Configurations/FormDefinitionVersionConfiguration.cs
create mode 100644 src/Workflow.Infrastructure/Persistence/Configurations/NotificationConfiguration.cs
create mode 100644 src/Workflow.Infrastructure/Persistence/Configurations/WebhookDeliveryConfiguration.cs
create mode 100644 tests/Workflow.Tests/Form/FormDataValidatorTests.cs
create mode 100644 tests/Workflow.Tests/Form/FormDeletionReferenceTests.cs
create mode 100644 tests/Workflow.Tests/Form/SchemaDifferTests.cs
create mode 100644 tests/Workflow.Tests/Form/SchemaValidatorTests.cs
create mode 100644 tests/Workflow.Tests/Notification/NotificationQueryTests.cs
create mode 100644 tests/Workflow.Tests/Notification/NotificationServiceTests.cs
create mode 100644 tests/Workflow.Tests/Scheduler/TimeoutSchedulerTests.cs
create mode 100644 tests/Workflow.Tests/Serialization/TimestampJsonConverterTests.cs
diff --git a/src/Workflow.Api/Configuration/ComparatorConfiguration.cs b/src/Workflow.Api/Configuration/ComparatorConfiguration.cs
index c52b485..77c9dc1 100644
--- a/src/Workflow.Api/Configuration/ComparatorConfiguration.cs
+++ b/src/Workflow.Api/Configuration/ComparatorConfiguration.cs
@@ -4,6 +4,12 @@ using Workflow.Domain.Expressions.Comparators;
namespace Workflow.Api.Configuration;
+///
+/// 条件求值策略链的 DI 注册。注册顺序决定责任链优先级——
+/// Numeric → DateTime → Boolean → Collection → String → Range,
+/// 当多个对比器声明同一操作符(如 ==)时,靠前的优先被尝试。
+/// Registry 注册为 Singleton(无状态),对比器本身为 Transient。
+///
public static class ComparatorConfiguration
{
public static IServiceCollection AddValueComparators(this IServiceCollection services)
diff --git a/src/Workflow.Api/Configuration/JwtAuthConfiguration.cs b/src/Workflow.Api/Configuration/JwtAuthConfiguration.cs
index 26e68ca..b09debf 100644
--- a/src/Workflow.Api/Configuration/JwtAuthConfiguration.cs
+++ b/src/Workflow.Api/Configuration/JwtAuthConfiguration.cs
@@ -1,28 +1,30 @@
+using System.Security.Claims;
using FastEndpoints.Security;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
namespace Workflow.Api.Configuration;
+///
+/// JWT 认证配置:切换为标准 OIDC(JWKS 自动从 SSO 拉取,RS256 验签)。
+/// MapInboundClaims=false 保留短名 claim(sub),与其它服务统一读取方式。
+///
public static class JwtAuthConfiguration
{
- public static void AddJwtAuthentication(this IServiceCollection services, IConfiguration configuration)
+ public static IServiceCollection AddJwtAuthentication(this IServiceCollection services, IConfiguration config)
{
- var signingKey = configuration.GetSection("Jwt")["SigningKey"]
- ?? throw new InvalidOperationException("Jwt:SigningKey is not configured.");
+ var authority = config["Jwt:Authority"];
- var issuer = configuration.GetSection("Jwt")["Issuer"];
- var audience = configuration.GetSection("Jwt")["Audience"];
-
- services.AddAuthenticationJwtBearer(
- s => s.SigningKey = signingKey,
- o =>
- {
- if (issuer is not null)
- o.TokenValidationParameters.ValidIssuer = issuer;
- if (audience is not null)
- o.TokenValidationParameters.ValidAudience = audience;
- o.TokenValidationParameters.NameClaimType = System.Security.Claims.ClaimTypes.NameIdentifier;
- });
+ services.AddAuthentication("Bearer").AddJwtBearer(options =>
+ {
+ options.Authority = authority;
+ options.MapInboundClaims = false;
+ options.RequireHttpsMetadata = config.GetValue("Jwt:RequireHttps", false);
+ options.TokenValidationParameters.ValidateAudience = false;
+ });
services.AddAuthorization();
+ return services;
}
}
diff --git a/src/Workflow.Api/Configuration/NotificationConfiguration.cs b/src/Workflow.Api/Configuration/NotificationConfiguration.cs
new file mode 100644
index 0000000..de088ae
--- /dev/null
+++ b/src/Workflow.Api/Configuration/NotificationConfiguration.cs
@@ -0,0 +1,39 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Workflow.Application.Notifications;
+using Workflow.Application.Scheduler;
+using Workflow.Api.Services;
+
+namespace Workflow.Api.Configuration;
+
+///
+/// 通知系统 DI 注册扩展。统一注册:
+/// - NotificationOptions(IOptions 强类型绑定,对应 appsettings 的 Notification 段)
+/// - INotificationService → NotificationService(scoped,随 DbContext 生命周期)
+/// - SystemUserContext(后台调度器用,单独注册避免覆盖 HTTP 上下文的 CurrentUserContext)
+/// - HttpClient 工厂(Webhook 投递用)
+/// - 后台调度器 HostedService(超时扫描 + Webhook 投递)
+///
+public static class NotificationConfiguration
+{
+ public static IServiceCollection AddNotifications(this IServiceCollection services, IConfiguration configuration)
+ {
+ // 强类型配置绑定
+ services.Configure(configuration.GetSection("Notification"));
+
+ // 通知服务(scoped:依赖 scoped DbContext)
+ services.AddScoped();
+
+ // 超时任务处理器(scoped:依赖 scoped DbContext + ProcessEngine)
+ services.AddScoped();
+
+ // 系统用户上下文:供后台调度器使用(不注册为 ICurrentUserContext 默认实现,
+ // 避免覆盖基于 HttpContext 的 CurrentUserContext;由调度器自行解析)
+ services.AddSingleton();
+
+ // Webhook 投递用的 HttpClient
+ services.AddHttpClient("Webhook");
+
+ return services;
+ }
+}
diff --git a/src/Workflow.Api/Endpoints/Form/CompareFormVersionsEndpoint.cs b/src/Workflow.Api/Endpoints/Form/CompareFormVersionsEndpoint.cs
new file mode 100644
index 0000000..9caffb7
--- /dev/null
+++ b/src/Workflow.Api/Endpoints/Form/CompareFormVersionsEndpoint.cs
@@ -0,0 +1,36 @@
+using FastEndpoints;
+using MediatR;
+using Workflow.Application.Form.DTOs;
+using Workflow.Application.Form.FormDefinition.Queries;
+
+namespace Workflow.Api.Endpoints.Form;
+
+public class CompareFormVersionsEndpoint : Endpoint
+{
+ private readonly IMediator _mediator;
+
+ public CompareFormVersionsEndpoint(IMediator mediator) => _mediator = mediator;
+
+ public override void Configure()
+ {
+ Get("/forms/{Id}/versions/compare");
+ Summary(s =>
+ {
+ s.Summary = "对比表单的两个版本";
+ });
+ }
+
+ public override async Task HandleAsync(CompareFormVersionsRequest req, CancellationToken ct)
+ {
+ var query = new CompareFormVersionsQuery(req.Id, req.OldVersionId, req.NewVersionId);
+ var result = await _mediator.Send(query, ct);
+ await Send.OkAsync(result, ct);
+ }
+}
+
+public class CompareFormVersionsRequest
+{
+ public Guid Id { get; set; }
+ public Guid? OldVersionId { get; set; }
+ public Guid? NewVersionId { get; set; }
+}
diff --git a/src/Workflow.Api/Endpoints/Form/CreateFormComponentEndpoint.cs b/src/Workflow.Api/Endpoints/Form/CreateFormComponentEndpoint.cs
index 779b54c..3c066a0 100644
--- a/src/Workflow.Api/Endpoints/Form/CreateFormComponentEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/Form/CreateFormComponentEndpoint.cs
@@ -14,7 +14,6 @@ public class CreateFormComponentEndpoint : Endpoint
{
s.Summary = "创建组件注册";
diff --git a/src/Workflow.Api/Endpoints/Form/CreateFormDefinitionEndpoint.cs b/src/Workflow.Api/Endpoints/Form/CreateFormDefinitionEndpoint.cs
index cf4e348..3791fbc 100644
--- a/src/Workflow.Api/Endpoints/Form/CreateFormDefinitionEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/Form/CreateFormDefinitionEndpoint.cs
@@ -10,7 +10,6 @@ public class CreateFormDefinitionEndpoint(IMediator mediator) : Endpoint
{
s.Summary = "创建表单定义(传入 Formily JSON Schema)";
diff --git a/src/Workflow.Api/Endpoints/Form/DeleteFormComponentEndpoint.cs b/src/Workflow.Api/Endpoints/Form/DeleteFormComponentEndpoint.cs
index 1657557..fe26b39 100644
--- a/src/Workflow.Api/Endpoints/Form/DeleteFormComponentEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/Form/DeleteFormComponentEndpoint.cs
@@ -13,7 +13,6 @@ public class DeleteFormComponentEndpoint : Endpoint
public override void Configure()
{
Delete("/form-components/{Id}");
- AllowAnonymous();
Summary(s =>
{
s.Summary = "删除组件注册(软删除)";
diff --git a/src/Workflow.Api/Endpoints/Form/DeleteFormDefinitionEndpoint.cs b/src/Workflow.Api/Endpoints/Form/DeleteFormDefinitionEndpoint.cs
index f9b0bf5..1ede443 100644
--- a/src/Workflow.Api/Endpoints/Form/DeleteFormDefinitionEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/Form/DeleteFormDefinitionEndpoint.cs
@@ -13,7 +13,6 @@ public class DeleteFormDefinitionEndpoint : Endpoint
{
s.Summary = "Delete a form definition (soft delete)";
diff --git a/src/Workflow.Api/Endpoints/Form/DisableFormDefinitionEndpoint.cs b/src/Workflow.Api/Endpoints/Form/DisableFormDefinitionEndpoint.cs
index 0985fac..8f11141 100644
--- a/src/Workflow.Api/Endpoints/Form/DisableFormDefinitionEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/Form/DisableFormDefinitionEndpoint.cs
@@ -12,7 +12,6 @@ public class DisableFormDefinitionEndpoint(IMediator mediator) : Endpoint
{
s.Summary = "禁用表单定义";
@@ -23,7 +22,9 @@ public class DisableFormDefinitionEndpoint(IMediator mediator) : Endpoint
{
s.Summary = "根据ID获取组件注册详情";
diff --git a/src/Workflow.Api/Endpoints/Form/GetFormComponentsEndpoint.cs b/src/Workflow.Api/Endpoints/Form/GetFormComponentsEndpoint.cs
index c7f9ab3..6980174 100644
--- a/src/Workflow.Api/Endpoints/Form/GetFormComponentsEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/Form/GetFormComponentsEndpoint.cs
@@ -14,7 +14,6 @@ public class GetFormComponentsEndpoint : EndpointWithoutRequest
{
s.Summary = "获取组件注册列表";
diff --git a/src/Workflow.Api/Endpoints/Form/GetFormDataByInstanceEndpoint.cs b/src/Workflow.Api/Endpoints/Form/GetFormDataByInstanceEndpoint.cs
index 7d9c340..a687974 100644
--- a/src/Workflow.Api/Endpoints/Form/GetFormDataByInstanceEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/Form/GetFormDataByInstanceEndpoint.cs
@@ -14,7 +14,6 @@ public class GetFormDataByInstanceEndpoint : Endpoint
{
s.Summary = "Get form data by workflow instance id";
diff --git a/src/Workflow.Api/Endpoints/Form/GetFormDefinitionByIdEndpoint.cs b/src/Workflow.Api/Endpoints/Form/GetFormDefinitionByIdEndpoint.cs
index 883f133..7adf26a 100644
--- a/src/Workflow.Api/Endpoints/Form/GetFormDefinitionByIdEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/Form/GetFormDefinitionByIdEndpoint.cs
@@ -14,7 +14,6 @@ public class GetFormDefinitionByIdEndpoint : Endpoint
{
s.Summary = "Get form definition detail by id (includes fields)";
diff --git a/src/Workflow.Api/Endpoints/Form/GetFormDefinitionListEndpoint.cs b/src/Workflow.Api/Endpoints/Form/GetFormDefinitionListEndpoint.cs
index 09dbda1..7749f71 100644
--- a/src/Workflow.Api/Endpoints/Form/GetFormDefinitionListEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/Form/GetFormDefinitionListEndpoint.cs
@@ -15,7 +15,6 @@ public class GetFormDefinitionListEndpoint : Endpoint
{
s.Summary = "Get paginated list of form definitions";
diff --git a/src/Workflow.Api/Endpoints/Form/GetFormVersionsEndpoint.cs b/src/Workflow.Api/Endpoints/Form/GetFormVersionsEndpoint.cs
new file mode 100644
index 0000000..53c68e3
--- /dev/null
+++ b/src/Workflow.Api/Endpoints/Form/GetFormVersionsEndpoint.cs
@@ -0,0 +1,29 @@
+using FastEndpoints;
+using MediatR;
+using Workflow.Application.Form.DTOs;
+using Workflow.Application.Form.FormDefinition.Queries;
+
+namespace Workflow.Api.Endpoints.Form;
+
+public class GetFormVersionsEndpoint : EndpointWithoutRequest>
+{
+ private readonly IMediator _mediator;
+
+ public GetFormVersionsEndpoint(IMediator mediator) => _mediator = mediator;
+
+ public override void Configure()
+ {
+ Get("/forms/{Id}/versions");
+ Summary(s =>
+ {
+ s.Summary = "获取表单的历史版本列表";
+ });
+ }
+
+ public override async Task HandleAsync(CancellationToken ct)
+ {
+ var id = Route("Id");
+ var result = await _mediator.Send(new GetFormVersionsQuery(id), ct);
+ await Send.OkAsync(result, ct);
+ }
+}
diff --git a/src/Workflow.Api/Endpoints/Form/PublishFormDefinitionEndpoint.cs b/src/Workflow.Api/Endpoints/Form/PublishFormDefinitionEndpoint.cs
index 28799f6..288dca8 100644
--- a/src/Workflow.Api/Endpoints/Form/PublishFormDefinitionEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/Form/PublishFormDefinitionEndpoint.cs
@@ -9,7 +9,6 @@ public class PublishFormDefinitionEndpoint(IMediator mediator) : EndpointWithout
public override void Configure()
{
Post("/forms/{Id}/publish");
- AllowAnonymous();
Summary(s =>
{
s.Summary = "Publish a form definition";
diff --git a/src/Workflow.Api/Endpoints/Form/SubmitFormDataEndpoint.cs b/src/Workflow.Api/Endpoints/Form/SubmitFormDataEndpoint.cs
index 38b221b..c68c59e 100644
--- a/src/Workflow.Api/Endpoints/Form/SubmitFormDataEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/Form/SubmitFormDataEndpoint.cs
@@ -13,7 +13,6 @@ public class SubmitFormDataEndpoint : Endpoint
public override void Configure()
{
Post("/forms/data");
- AllowAnonymous();
Summary(s =>
{
s.Summary = "Submit form data for a workflow instance";
diff --git a/src/Workflow.Api/Endpoints/Form/UpdateFormComponentEndpoint.cs b/src/Workflow.Api/Endpoints/Form/UpdateFormComponentEndpoint.cs
index 2bc876f..5f38857 100644
--- a/src/Workflow.Api/Endpoints/Form/UpdateFormComponentEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/Form/UpdateFormComponentEndpoint.cs
@@ -14,7 +14,6 @@ public class UpdateFormComponentEndpoint : Endpoint
{
s.Summary = "更新组件注册";
diff --git a/src/Workflow.Api/Endpoints/Form/UpdateFormDefinitionEndpoint.cs b/src/Workflow.Api/Endpoints/Form/UpdateFormDefinitionEndpoint.cs
index acaed99..2324712 100644
--- a/src/Workflow.Api/Endpoints/Form/UpdateFormDefinitionEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/Form/UpdateFormDefinitionEndpoint.cs
@@ -17,7 +17,6 @@ public class UpdateFormDefinitionEndpoint : Endpoint
{
s.Summary = "更新表单定义(传入新的 Formily JSON Schema)";
diff --git a/src/Workflow.Api/Endpoints/Notification/NotificationEndpoints.cs b/src/Workflow.Api/Endpoints/Notification/NotificationEndpoints.cs
new file mode 100644
index 0000000..a230a85
--- /dev/null
+++ b/src/Workflow.Api/Endpoints/Notification/NotificationEndpoints.cs
@@ -0,0 +1,104 @@
+using System.Security.Claims;
+using FastEndpoints;
+using MediatR;
+using Workflow.Application.Common;
+using Workflow.Application.Features.Notifications;
+using Workflow.Domain.Common;
+
+namespace Workflow.Api.Endpoints.Notification;
+
+/// 从 JWT claims 提取当前用户的角色列表(兼容 ClaimTypes.Role 与 "role" 两种 claim 名)。
+internal static class NotificationClaimsExtensions
+{
+ public static IReadOnlyList GetUserRoles(this ClaimsPrincipal user)
+ {
+ // 兼容 MapInboundClaims=false 下 role 保持短名,以及标准 ClaimTypes.Role
+ return user.FindAll("role")
+ .Concat(user.FindAll(ClaimTypes.Role))
+ .Select(c => c.Value)
+ .Where(v => !string.IsNullOrWhiteSpace(v))
+ .Distinct()
+ .ToList();
+ }
+}
+
+public class GetNotificationsEndpoint(IMediator mediator, ICurrentUserContext userContext)
+ : Endpoint>
+{
+ public override void Configure()
+ {
+ Get("/notifications");
+ Summary(s => s.Summary = "获取当前用户的通知列表(含按角色匹配的通知)");
+ }
+
+ public override async Task HandleAsync(GetNotificationsRequest req, CancellationToken ct)
+ {
+ var userId = userContext.GetUserId();
+ var roles = User.GetUserRoles();
+ var result = await mediator.Send(
+ new GetNotificationsQuery(userId, roles, req.UnreadOnly, req.PageIndex, req.PageSize), ct);
+ await Send.OkAsync(result, ct);
+ }
+}
+
+public class GetNotificationsRequest
+{
+ public bool UnreadOnly { get; set; }
+ public int PageIndex { get; set; } = 1;
+ public int PageSize { get; set; } = 20;
+}
+
+public class GetUnreadNotificationCountEndpoint(IMediator mediator, ICurrentUserContext userContext) : EndpointWithoutRequest
+{
+ public override void Configure()
+ {
+ Get("/notifications/unread-count");
+ Summary(s => s.Summary = "获取当前用户的未读通知数(前端角标用)");
+ }
+
+ public override async Task HandleAsync(CancellationToken ct)
+ {
+ var userId = userContext.GetUserId();
+ var roles = User.GetUserRoles();
+ var count = await mediator.Send(new GetUnreadNotificationCountQuery(userId, roles), ct);
+ await Send.OkAsync(count, ct);
+ }
+}
+
+public class MarkNotificationReadEndpoint(IMediator mediator, ICurrentUserContext userContext) : EndpointWithoutRequest
+{
+ public override void Configure()
+ {
+ Post("/notifications/{Id}/read");
+ Summary(s => s.Summary = "标记单条通知为已读");
+ }
+
+ public override async Task HandleAsync(CancellationToken ct)
+ {
+ var id = Route("Id");
+ var userId = userContext.GetUserId();
+ await mediator.Send(new MarkNotificationReadCommand(id, userId), ct);
+ HttpContext.Response.StatusCode = 200;
+ HttpContext.Response.ContentType = "application/json";
+ await HttpContext.Response.WriteAsync("{}", ct);
+ }
+}
+
+public class MarkAllNotificationsReadEndpoint(IMediator mediator, ICurrentUserContext userContext) : EndpointWithoutRequest
+{
+ public override void Configure()
+ {
+ Post("/notifications/read-all");
+ Summary(s => s.Summary = "标记当前用户所有通知为已读");
+ }
+
+ public override async Task HandleAsync(CancellationToken ct)
+ {
+ var userId = userContext.GetUserId();
+ var roles = User.GetUserRoles();
+ await mediator.Send(new MarkAllNotificationsReadCommand(userId, roles), ct);
+ HttpContext.Response.StatusCode = 200;
+ HttpContext.Response.ContentType = "application/json";
+ await HttpContext.Response.WriteAsync("{}", ct);
+ }
+}
diff --git a/src/Workflow.Api/Endpoints/WorkflowDefinition/CreateEdgeEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowDefinition/CreateEdgeEndpoint.cs
index 97740a8..b75f216 100644
--- a/src/Workflow.Api/Endpoints/WorkflowDefinition/CreateEdgeEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowDefinition/CreateEdgeEndpoint.cs
@@ -15,7 +15,6 @@ public class CreateEdgeEndpoint : Endpoint
public override void Configure()
{
Post("/workflow-definitions/{DefinitionId}/edges");
- AllowAnonymous();
Summary(s =>
{
s.Summary = "Create a new edge in a workflow definition";
diff --git a/src/Workflow.Api/Endpoints/WorkflowDefinition/CreateNodeEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowDefinition/CreateNodeEndpoint.cs
index fff983d..ff842fd 100644
--- a/src/Workflow.Api/Endpoints/WorkflowDefinition/CreateNodeEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowDefinition/CreateNodeEndpoint.cs
@@ -15,7 +15,6 @@ public class CreateNodeEndpoint : Endpoint
public override void Configure()
{
Post("/workflow-definitions/{DefinitionId}/nodes");
- AllowAnonymous();
Summary(s =>
{
s.Summary = "Create a new node in a workflow definition";
@@ -24,7 +23,14 @@ public class CreateNodeEndpoint : Endpoint
public override async Task HandleAsync(CreateNodeRequest req, CancellationToken ct)
{
- var command = new CreateNodeCommand(req.DefinitionId, (NodeType)req.NodeType, req.Name, req.Config, req.PositionX, req.PositionY);
+ var command = new CreateNodeCommand(
+ req.DefinitionId,
+ (NodeType)req.NodeType,
+ req.Name,
+ req.Config,
+ req.PositionX,
+ req.PositionY,
+ req.FormDefinitionId);
var result = await _mediator.Send(command, ct);
await Send.ResponseAsync(result, 201, ct);
}
@@ -38,4 +44,5 @@ public class CreateNodeRequest
public string? Config { get; set; }
public int PositionX { get; set; }
public int PositionY { get; set; }
+ public Guid? FormDefinitionId { get; set; }
}
diff --git a/src/Workflow.Api/Endpoints/WorkflowDefinition/CreateWorkflowDefinitionEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowDefinition/CreateWorkflowDefinitionEndpoint.cs
index 8ef2d03..acde5b0 100644
--- a/src/Workflow.Api/Endpoints/WorkflowDefinition/CreateWorkflowDefinitionEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowDefinition/CreateWorkflowDefinitionEndpoint.cs
@@ -14,7 +14,6 @@ public class CreateWorkflowDefinitionEndpoint : Endpoint
{
s.Summary = "Create a new workflow definition";
diff --git a/src/Workflow.Api/Endpoints/WorkflowDefinition/DeleteEdgeEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowDefinition/DeleteEdgeEndpoint.cs
index 3553352..d393bd0 100644
--- a/src/Workflow.Api/Endpoints/WorkflowDefinition/DeleteEdgeEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowDefinition/DeleteEdgeEndpoint.cs
@@ -13,7 +13,6 @@ public class DeleteEdgeEndpoint : Endpoint
public override void Configure()
{
Delete("/workflow-definitions/{DefinitionId}/edges/{EdgeId}");
- AllowAnonymous();
Summary(s =>
{
s.Summary = "Delete an edge from a workflow definition";
@@ -24,7 +23,9 @@ public class DeleteEdgeEndpoint : Endpoint
{
var command = new DeleteEdgeCommand(req.EdgeId);
await _mediator.Send(command, ct);
- await Send.OkAsync(ct);
+ HttpContext.Response.StatusCode = 200;
+ HttpContext.Response.ContentType = "application/json";
+ await HttpContext.Response.WriteAsync("{}", ct);
}
}
diff --git a/src/Workflow.Api/Endpoints/WorkflowDefinition/DeleteNodeEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowDefinition/DeleteNodeEndpoint.cs
index ffc5dda..9ab0a65 100644
--- a/src/Workflow.Api/Endpoints/WorkflowDefinition/DeleteNodeEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowDefinition/DeleteNodeEndpoint.cs
@@ -13,7 +13,6 @@ public class DeleteNodeEndpoint : Endpoint
public override void Configure()
{
Delete("/workflow-definitions/{DefinitionId}/nodes/{NodeId}");
- AllowAnonymous();
Summary(s =>
{
s.Summary = "Delete a node from a workflow definition";
@@ -24,7 +23,9 @@ public class DeleteNodeEndpoint : Endpoint
{
var command = new DeleteNodeCommand(req.NodeId);
await _mediator.Send(command, ct);
- await Send.OkAsync(ct);
+ HttpContext.Response.StatusCode = 200;
+ HttpContext.Response.ContentType = "application/json";
+ await HttpContext.Response.WriteAsync("{}", ct);
}
}
diff --git a/src/Workflow.Api/Endpoints/WorkflowDefinition/DeleteWorkflowDefinitionEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowDefinition/DeleteWorkflowDefinitionEndpoint.cs
index 87780b0..3c87a28 100644
--- a/src/Workflow.Api/Endpoints/WorkflowDefinition/DeleteWorkflowDefinitionEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowDefinition/DeleteWorkflowDefinitionEndpoint.cs
@@ -13,7 +13,6 @@ public class DeleteWorkflowDefinitionEndpoint : Endpoint
{
s.Summary = "Delete a workflow definition (soft delete)";
diff --git a/src/Workflow.Api/Endpoints/WorkflowDefinition/DisableWorkflowDefinitionEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowDefinition/DisableWorkflowDefinitionEndpoint.cs
index d690f68..c500a2a 100644
--- a/src/Workflow.Api/Endpoints/WorkflowDefinition/DisableWorkflowDefinitionEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowDefinition/DisableWorkflowDefinitionEndpoint.cs
@@ -9,7 +9,6 @@ public class DisableWorkflowDefinitionEndpoint(IMediator mediator) : EndpointWit
public override void Configure()
{
Post("/workflow-definitions/{Id}/disable");
- AllowAnonymous();
Summary(s =>
{
s.Summary = "Disable a workflow definition";
diff --git a/src/Workflow.Api/Endpoints/WorkflowDefinition/GetWorkflowDefinitionByIdEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowDefinition/GetWorkflowDefinitionByIdEndpoint.cs
index a53f24a..6120a4d 100644
--- a/src/Workflow.Api/Endpoints/WorkflowDefinition/GetWorkflowDefinitionByIdEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowDefinition/GetWorkflowDefinitionByIdEndpoint.cs
@@ -14,7 +14,6 @@ public class GetWorkflowDefinitionByIdEndpoint : Endpoint
{
s.Summary = "Get workflow definition detail by id (includes nodes and edges)";
diff --git a/src/Workflow.Api/Endpoints/WorkflowDefinition/GetWorkflowDefinitionListEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowDefinition/GetWorkflowDefinitionListEndpoint.cs
index 30bdced..643f62f 100644
--- a/src/Workflow.Api/Endpoints/WorkflowDefinition/GetWorkflowDefinitionListEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowDefinition/GetWorkflowDefinitionListEndpoint.cs
@@ -16,7 +16,6 @@ public class GetWorkflowDefinitionListEndpoint : Endpoint
{
s.Summary = "Get paginated list of workflow definitions";
diff --git a/src/Workflow.Api/Endpoints/WorkflowDefinition/PublishWorkflowDefinitionEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowDefinition/PublishWorkflowDefinitionEndpoint.cs
index fa400a6..ada906d 100644
--- a/src/Workflow.Api/Endpoints/WorkflowDefinition/PublishWorkflowDefinitionEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowDefinition/PublishWorkflowDefinitionEndpoint.cs
@@ -9,7 +9,6 @@ public class PublishWorkflowDefinitionEndpoint(IMediator mediator) : EndpointWit
public override void Configure()
{
Post("/workflow-definitions/{Id}/publish");
- AllowAnonymous();
Summary(s =>
{
s.Summary = "Publish a workflow definition";
diff --git a/src/Workflow.Api/Endpoints/WorkflowDefinition/UpdateEdgeEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowDefinition/UpdateEdgeEndpoint.cs
index d4b979b..933c33f 100644
--- a/src/Workflow.Api/Endpoints/WorkflowDefinition/UpdateEdgeEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowDefinition/UpdateEdgeEndpoint.cs
@@ -15,7 +15,6 @@ public class UpdateEdgeEndpoint : Endpoint
public override void Configure()
{
Put("/workflow-definitions/{DefinitionId}/edges/{EdgeId}");
- AllowAnonymous();
Summary(s =>
{
s.Summary = "Update an edge in a workflow definition";
diff --git a/src/Workflow.Api/Endpoints/WorkflowDefinition/UpdateNodeEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowDefinition/UpdateNodeEndpoint.cs
index c58b181..2a10e48 100644
--- a/src/Workflow.Api/Endpoints/WorkflowDefinition/UpdateNodeEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowDefinition/UpdateNodeEndpoint.cs
@@ -14,7 +14,6 @@ public class UpdateNodeEndpoint : Endpoint
public override void Configure()
{
Put("/workflow-definitions/{DefinitionId}/nodes/{NodeId}");
- AllowAnonymous();
Summary(s =>
{
s.Summary = "Update a node in a workflow definition";
@@ -23,7 +22,13 @@ public class UpdateNodeEndpoint : Endpoint
public override async Task HandleAsync(UpdateNodeRequest req, CancellationToken ct)
{
- var command = new UpdateNodeCommand(req.NodeId, req.Name, req.Config, req.PositionX, req.PositionY);
+ var command = new UpdateNodeCommand(
+ req.NodeId,
+ req.Name,
+ req.Config,
+ req.PositionX,
+ req.PositionY,
+ req.FormDefinitionId);
var result = await _mediator.Send(command, ct);
await Send.OkAsync(result, ct);
}
@@ -37,4 +42,5 @@ public class UpdateNodeRequest
public string? Config { get; set; }
public int PositionX { get; set; }
public int PositionY { get; set; }
+ public Guid? FormDefinitionId { get; set; }
}
diff --git a/src/Workflow.Api/Endpoints/WorkflowDefinition/UpdateWorkflowDefinitionEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowDefinition/UpdateWorkflowDefinitionEndpoint.cs
index c4808b8..d356712 100644
--- a/src/Workflow.Api/Endpoints/WorkflowDefinition/UpdateWorkflowDefinitionEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowDefinition/UpdateWorkflowDefinitionEndpoint.cs
@@ -14,7 +14,6 @@ public class UpdateWorkflowDefinitionEndpoint : Endpoint
{
s.Summary = "Update a workflow definition";
diff --git a/src/Workflow.Api/Endpoints/WorkflowInstance/GetWorkflowInstanceByIdEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowInstance/GetWorkflowInstanceByIdEndpoint.cs
index 7fb308d..0f927b5 100644
--- a/src/Workflow.Api/Endpoints/WorkflowInstance/GetWorkflowInstanceByIdEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowInstance/GetWorkflowInstanceByIdEndpoint.cs
@@ -14,7 +14,6 @@ public class GetWorkflowInstanceByIdEndpoint : Endpoint
{
s.Summary = "Get workflow instance detail by id (includes tokens and tasks)";
diff --git a/src/Workflow.Api/Endpoints/WorkflowInstance/GetWorkflowInstanceListEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowInstance/GetWorkflowInstanceListEndpoint.cs
index 46b2164..7fb21c4 100644
--- a/src/Workflow.Api/Endpoints/WorkflowInstance/GetWorkflowInstanceListEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowInstance/GetWorkflowInstanceListEndpoint.cs
@@ -16,7 +16,6 @@ public class GetWorkflowInstanceListEndpoint : Endpoint
{
s.Summary = "Get paginated list of workflow instances";
diff --git a/src/Workflow.Api/Endpoints/WorkflowInstance/MonitorWorkflowInstancesEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowInstance/MonitorWorkflowInstancesEndpoint.cs
index 096954c..24191b9 100644
--- a/src/Workflow.Api/Endpoints/WorkflowInstance/MonitorWorkflowInstancesEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowInstance/MonitorWorkflowInstancesEndpoint.cs
@@ -14,7 +14,6 @@ public class MonitorWorkflowInstancesEndpoint : EndpointWithoutRequest
{
s.Summary = "Get workflow monitoring statistics";
diff --git a/src/Workflow.Api/Endpoints/WorkflowInstance/ResumeWorkflowInstanceEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowInstance/ResumeWorkflowInstanceEndpoint.cs
index eb8c2a3..8258217 100644
--- a/src/Workflow.Api/Endpoints/WorkflowInstance/ResumeWorkflowInstanceEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowInstance/ResumeWorkflowInstanceEndpoint.cs
@@ -9,7 +9,6 @@ public class ResumeWorkflowInstanceEndpoint(IMediator mediator) : EndpointWithou
public override void Configure()
{
Post("/workflow-instances/{Id}/resume");
- AllowAnonymous();
Summary(s =>
{
s.Summary = "Resume a suspended workflow instance";
diff --git a/src/Workflow.Api/Endpoints/WorkflowInstance/StartWorkflowInstanceEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowInstance/StartWorkflowInstanceEndpoint.cs
index c4ce888..9718805 100644
--- a/src/Workflow.Api/Endpoints/WorkflowInstance/StartWorkflowInstanceEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowInstance/StartWorkflowInstanceEndpoint.cs
@@ -13,7 +13,6 @@ public class StartWorkflowInstanceEndpoint : Endpoint
{
s.Summary = "Start a new workflow instance";
diff --git a/src/Workflow.Api/Endpoints/WorkflowInstance/SuspendWorkflowInstanceEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowInstance/SuspendWorkflowInstanceEndpoint.cs
index b766c8c..564b903 100644
--- a/src/Workflow.Api/Endpoints/WorkflowInstance/SuspendWorkflowInstanceEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowInstance/SuspendWorkflowInstanceEndpoint.cs
@@ -9,7 +9,6 @@ public class SuspendWorkflowInstanceEndpoint(IMediator mediator) : EndpointWitho
public override void Configure()
{
Post("/workflow-instances/{Id}/suspend");
- AllowAnonymous();
Summary(s =>
{
s.Summary = "Suspend a running workflow instance";
diff --git a/src/Workflow.Api/Endpoints/WorkflowInstance/WithdrawWorkflowInstanceEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowInstance/WithdrawWorkflowInstanceEndpoint.cs
index a3183a6..b7bafdb 100644
--- a/src/Workflow.Api/Endpoints/WorkflowInstance/WithdrawWorkflowInstanceEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowInstance/WithdrawWorkflowInstanceEndpoint.cs
@@ -10,7 +10,6 @@ public class WithdrawWorkflowInstanceEndpoint(IMediator mediator, ICurrentUserCo
public override void Configure()
{
Post("/workflow-instances/{Id}/withdraw");
- AllowAnonymous();
Summary(s =>
{
s.Summary = "Withdraw a workflow instance (initiator only)";
diff --git a/src/Workflow.Api/Endpoints/WorkflowTask/ApproveTaskEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowTask/ApproveTaskEndpoint.cs
index 905d411..c5098db 100644
--- a/src/Workflow.Api/Endpoints/WorkflowTask/ApproveTaskEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowTask/ApproveTaskEndpoint.cs
@@ -13,7 +13,6 @@ public class ApproveTaskEndpoint : Endpoint
public override void Configure()
{
Post("/workflow-tasks/{Id}/approve");
- AllowAnonymous();
Summary(s =>
{
s.Summary = "Approve a pending task";
@@ -22,7 +21,7 @@ public class ApproveTaskEndpoint : Endpoint
public override async Task HandleAsync(ApproveTaskRequest req, CancellationToken ct)
{
- var command = new ApproveTaskCommand(req.Id, req.UserId, req.Comment);
+ var command = new ApproveTaskCommand(req.Id, req.UserId, req.Comment, req.FormDataJson);
await _mediator.Send(command, ct);
HttpContext.Response.StatusCode = 200;
HttpContext.Response.ContentType = "application/json";
@@ -35,4 +34,5 @@ public class ApproveTaskRequest
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string? Comment { get; set; }
+ public string? FormDataJson { get; set; }
}
diff --git a/src/Workflow.Api/Endpoints/WorkflowTask/DelegateTaskEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowTask/DelegateTaskEndpoint.cs
index ad4b760..82f4014 100644
--- a/src/Workflow.Api/Endpoints/WorkflowTask/DelegateTaskEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowTask/DelegateTaskEndpoint.cs
@@ -13,7 +13,6 @@ public class DelegateTaskEndpoint : Endpoint
public override void Configure()
{
Post("/workflow-tasks/{Id}/delegate");
- AllowAnonymous();
Summary(s =>
{
s.Summary = "Delegate a task to another user";
diff --git a/src/Workflow.Api/Endpoints/WorkflowTask/GetCcTasksEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowTask/GetCcTasksEndpoint.cs
index edd7575..a299c53 100644
--- a/src/Workflow.Api/Endpoints/WorkflowTask/GetCcTasksEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowTask/GetCcTasksEndpoint.cs
@@ -15,7 +15,6 @@ public class GetCcTasksEndpoint : Endpoint
{
s.Summary = "Get CC (carbon copy) tasks for a user";
diff --git a/src/Workflow.Api/Endpoints/WorkflowTask/GetHistoryTasksEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowTask/GetHistoryTasksEndpoint.cs
index 0abaacf..3f2df2b 100644
--- a/src/Workflow.Api/Endpoints/WorkflowTask/GetHistoryTasksEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowTask/GetHistoryTasksEndpoint.cs
@@ -15,7 +15,6 @@ public class GetHistoryTasksEndpoint : Endpoint
{
s.Summary = "Get completed (approved/rejected) tasks for a user";
diff --git a/src/Workflow.Api/Endpoints/WorkflowTask/GetOverdueTasksEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowTask/GetOverdueTasksEndpoint.cs
index cb8c032..a3907c2 100644
--- a/src/Workflow.Api/Endpoints/WorkflowTask/GetOverdueTasksEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowTask/GetOverdueTasksEndpoint.cs
@@ -15,7 +15,6 @@ public class GetOverdueTasksEndpoint : Endpoint
{
s.Summary = "Get overdue tasks (past due date, still pending)";
diff --git a/src/Workflow.Api/Endpoints/WorkflowTask/GetPendingTasksEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowTask/GetPendingTasksEndpoint.cs
index e5af952..db5034a 100644
--- a/src/Workflow.Api/Endpoints/WorkflowTask/GetPendingTasksEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowTask/GetPendingTasksEndpoint.cs
@@ -15,7 +15,6 @@ public class GetPendingTasksEndpoint : Endpoint
{
s.Summary = "Get pending tasks for a user";
diff --git a/src/Workflow.Api/Endpoints/WorkflowTask/GetTaskByIdEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowTask/GetTaskByIdEndpoint.cs
index 0ff51d9..25d926b 100644
--- a/src/Workflow.Api/Endpoints/WorkflowTask/GetTaskByIdEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowTask/GetTaskByIdEndpoint.cs
@@ -5,7 +5,7 @@ using Workflow.Application.Features.WorkflowTasks.Queries;
namespace Workflow.Api.Endpoints.WorkflowTask;
-public class GetTaskByIdEndpoint : Endpoint
+public class GetTaskByIdEndpoint : Endpoint
{
private readonly IMediator _mediator;
@@ -14,7 +14,6 @@ public class GetTaskByIdEndpoint : Endpoint
{
s.Summary = "Get task detail by id";
diff --git a/src/Workflow.Api/Endpoints/WorkflowTask/MarkCcTaskReadEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowTask/MarkCcTaskReadEndpoint.cs
new file mode 100644
index 0000000..3bed1c6
--- /dev/null
+++ b/src/Workflow.Api/Endpoints/WorkflowTask/MarkCcTaskReadEndpoint.cs
@@ -0,0 +1,36 @@
+using FastEndpoints;
+using MediatR;
+using Workflow.Application.Features.WorkflowTasks.Commands;
+
+namespace Workflow.Api.Endpoints.WorkflowTask;
+
+public class MarkCcTaskReadEndpoint : Endpoint
+{
+ private readonly IMediator _mediator;
+
+ public MarkCcTaskReadEndpoint(IMediator mediator) => _mediator = mediator;
+
+ public override void Configure()
+ {
+ Post("/workflow-tasks/{Id}/mark-read");
+ Summary(s =>
+ {
+ s.Summary = "Mark a CC task as read";
+ });
+ }
+
+ public override async Task HandleAsync(MarkCcTaskReadRequest req, CancellationToken ct)
+ {
+ var command = new MarkCcTaskReadCommand(req.Id, req.UserId);
+ await _mediator.Send(command, ct);
+ HttpContext.Response.StatusCode = 200;
+ HttpContext.Response.ContentType = "application/json";
+ await HttpContext.Response.WriteAsync("{}", ct);
+ }
+}
+
+public class MarkCcTaskReadRequest
+{
+ public Guid Id { get; set; }
+ public Guid UserId { get; set; }
+}
diff --git a/src/Workflow.Api/Endpoints/WorkflowTask/RejectTaskEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowTask/RejectTaskEndpoint.cs
index 280c22e..eeefd53 100644
--- a/src/Workflow.Api/Endpoints/WorkflowTask/RejectTaskEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowTask/RejectTaskEndpoint.cs
@@ -13,7 +13,6 @@ public class RejectTaskEndpoint : Endpoint
public override void Configure()
{
Post("/workflow-tasks/{Id}/reject");
- AllowAnonymous();
Summary(s =>
{
s.Summary = "Reject a pending task";
diff --git a/src/Workflow.Api/Endpoints/WorkflowTask/TransferTaskEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowTask/TransferTaskEndpoint.cs
index 548fc20..1753d0f 100644
--- a/src/Workflow.Api/Endpoints/WorkflowTask/TransferTaskEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowTask/TransferTaskEndpoint.cs
@@ -13,7 +13,6 @@ public class TransferTaskEndpoint : Endpoint
public override void Configure()
{
Post("/workflow-tasks/{Id}/transfer");
- AllowAnonymous();
Summary(s =>
{
s.Summary = "Transfer a task to another user";
diff --git a/src/Workflow.Api/Endpoints/WorkflowTask/UrgeTaskEndpoint.cs b/src/Workflow.Api/Endpoints/WorkflowTask/UrgeTaskEndpoint.cs
index 1b7d401..3c807e3 100644
--- a/src/Workflow.Api/Endpoints/WorkflowTask/UrgeTaskEndpoint.cs
+++ b/src/Workflow.Api/Endpoints/WorkflowTask/UrgeTaskEndpoint.cs
@@ -13,7 +13,6 @@ public class UrgeTaskEndpoint : Endpoint
public override void Configure()
{
Post("/workflow-tasks/{Id}/urge");
- AllowAnonymous();
Summary(s =>
{
s.Summary = "Urge a pending task (send notification to assignee)";
diff --git a/src/Workflow.Api/Middleware/ApiResponseMiddleware.cs b/src/Workflow.Api/Middleware/ApiResponseMiddleware.cs
index e7d77d3..7403e45 100644
--- a/src/Workflow.Api/Middleware/ApiResponseMiddleware.cs
+++ b/src/Workflow.Api/Middleware/ApiResponseMiddleware.cs
@@ -1,9 +1,22 @@
using System.Text.Json;
+using Workflow.Api.Serialization;
namespace Workflow.Api.Middleware;
+///
+/// 统一响应包装中间件。拦截下游返回的成功响应(2xx JSON),自动包成标准信封 { code: 0, data, message: "ok" }。
+/// 跳过场景:错误响应(≥400,已由 GlobalExceptionMiddleware 处理)、非 JSON 响应、Swagger 文档页面。
+/// 通过临时 MemoryStream 缓存原始响应体实现读取后再写回,异常时恢复原始流并向上抛出。
+///
public class ApiResponseMiddleware
{
+ // 复用与 FastEndpoints 一致的序列化选项:驼峰 + 时间毫秒时间戳,确保信封包裹时时间格式不回退
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ Converters = { new TimestampDateTimeConverter(), new TimestampDateTimeOffsetConverter() }
+ };
+
private readonly RequestDelegate _next;
public ApiResponseMiddleware(RequestDelegate next)
@@ -13,6 +26,7 @@ public class ApiResponseMiddleware
public async Task InvokeAsync(HttpContext context)
{
+ // 备份原始响应流,用 MemoryStream 替换以便下游写入后可被本中间件读取
var originalBodyStream = context.Response.Body;
using var responseBody = new MemoryStream();
@@ -24,6 +38,7 @@ public class ApiResponseMiddleware
}
catch
{
+ // 下游抛异常时必须先恢复原始流,否则 GlobalExceptionMiddleware 写入会落到已释放的 MemoryStream
context.Response.Body = originalBodyStream;
throw;
}
@@ -33,7 +48,7 @@ public class ApiResponseMiddleware
responseBody.Seek(0, SeekOrigin.Begin);
var responseText = await new StreamReader(responseBody).ReadToEndAsync();
- // Skip wrapping for non-JSON responses, error responses, or Swagger
+ // 跳过包装的三类场景:错误响应、非 JSON、Swagger 文档
if (context.Response.StatusCode >= 400 ||
context.Response.ContentType?.Contains("application/json") != true ||
context.Request.Path.StartsWithSegments("/swagger"))
@@ -43,7 +58,7 @@ public class ApiResponseMiddleware
return;
}
- // Parse the original response and wrap it in the standard envelope
+ // 解析原始响应体;解析失败则原样作为字符串塞入 data,避免吞数据
object? data;
if (string.IsNullOrWhiteSpace(responseText))
{
@@ -68,11 +83,9 @@ public class ApiResponseMiddleware
message = "ok"
};
- var json = JsonSerializer.Serialize(wrappedResponse, new JsonSerializerOptions
- {
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase
- });
+ var json = JsonSerializer.Serialize(wrappedResponse, JsonOptions);
+ // 包装后统一返回 200,真实业务状态由 code 字段表达
context.Response.StatusCode = 200;
context.Response.ContentLength = System.Text.Encoding.UTF8.GetByteCount(json);
await context.Response.WriteAsync(json);
diff --git a/src/Workflow.Api/Middleware/GlobalExceptionMiddleware.cs b/src/Workflow.Api/Middleware/GlobalExceptionMiddleware.cs
index 709ca31..d7fd13f 100644
--- a/src/Workflow.Api/Middleware/GlobalExceptionMiddleware.cs
+++ b/src/Workflow.Api/Middleware/GlobalExceptionMiddleware.cs
@@ -4,6 +4,12 @@ using Workflow.Domain.Exceptions;
namespace Workflow.Api.Middleware;
+///
+/// 全局异常处理中间件。捕获下游管道抛出的所有异常,按异常类型映射为 HTTP 状态码,
+/// 并统一输出错误响应信封 { code, message, data: null }。
+/// 业务异常(4xx)记 Warning,未处理异常(5xx)记 Error 以便监控告警。
+/// 故意不向前端透传 5xx 的内部异常详情,避免泄露堆栈等敏感信息。
+///
public class GlobalExceptionMiddleware
{
private readonly RequestDelegate _next;
@@ -29,6 +35,7 @@ public class GlobalExceptionMiddleware
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
+ // 异常类型 → (HTTP 状态码, 对外消息) 映射。5xx 不透传原始消息,统一返回通用提示
var (statusCode, message) = exception switch
{
BusinessException ex => ((int)HttpStatusCode.BadRequest, ex.Message),
diff --git a/src/Workflow.Api/Program.cs b/src/Workflow.Api/Program.cs
index 4505b0a..5c3eaba 100644
--- a/src/Workflow.Api/Program.cs
+++ b/src/Workflow.Api/Program.cs
@@ -1,12 +1,13 @@
using System.Text.Json;
using FastEndpoints;
using FastEndpoints.Swagger;
-using Grpc.Net.Client;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Workflow.Api.Configuration;
using Workflow.Api.Middleware;
+using Workflow.Api.Serialization;
using Workflow.Api.Services;
+using Workflow.Application.Engine;
using Workflow.Application.Form.FormDefinition.Commands;
using Workflow.Domain.Common;
using Workflow.Domain.Expressions;
@@ -28,16 +29,17 @@ builder.Services.AddDbContext((sp, options) =>
options.UseSnakeCaseNamingConvention();
});
-// gRPC Auth Client
-var authServerUrl = builder.Configuration["Grpc:AuthServerUrl"] ?? "http://localhost:50051";
-builder.Services.AddSingleton(_ => GrpcChannel.ForAddress(authServerUrl));
-builder.Services.AddSingleton();
-
// MediatR
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(CreateFormDefinitionCommand).Assembly));
// Value Comparators
builder.Services.AddValueComparators();
+builder.Services.AddScoped();
+
+// Notifications & Schedulers
+builder.Services.AddNotifications(builder.Configuration);
+builder.Services.AddHostedService();
+builder.Services.AddHostedService();
// FastEndpoints
builder.Services.AddFastEndpoints();
@@ -70,7 +72,8 @@ if (args.Contains("--seed"))
return;
}
-// Middleware pipeline (order matters)
+// 中间件管道顺序敏感:CORS → ApiResponse 包装 → 全局异常 → 认证 → 授权 → FastEndpoints
+// ApiResponse 必须在 GlobalException 之前:它需要捕获下游异常并恢复响应流,再交由 GlobalException 输出错误信封
app.UseCors();
app.UseMiddleware();
app.UseMiddleware();
@@ -80,6 +83,9 @@ app.UseFastEndpoints(config =>
{
config.Endpoints.RoutePrefix = "api";
config.Serializer.Options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
+ // 统一数据规范:时间字段输出 UTC 毫秒时间戳
+ config.Serializer.Options.Converters.Add(new TimestampDateTimeConverter());
+ config.Serializer.Options.Converters.Add(new TimestampDateTimeOffsetConverter());
});
app.UseSwaggerGen();
diff --git a/src/Workflow.Api/Protos/auth.proto b/src/Workflow.Api/Protos/auth.proto
deleted file mode 100644
index 26ec48b..0000000
--- a/src/Workflow.Api/Protos/auth.proto
+++ /dev/null
@@ -1,35 +0,0 @@
-syntax = "proto3";
-
-package auth;
-
-option csharp_namespace = "Workflow.Api.Grpc";
-
-service AuthService {
- rpc ValidateToken (ValidateTokenRequest) returns (ValidateTokenResponse);
- rpc CheckPermission (CheckPermissionRequest) returns (CheckPermissionResponse);
-}
-
-message ValidateTokenRequest {
- string token = 1;
-}
-
-message ValidateTokenResponse {
- bool valid = 1;
- string user_id = 2;
- string username = 3;
- string email = 4;
- repeated string roles = 5;
- repeated string permissions = 6;
- int64 expires_at = 7;
-}
-
-message CheckPermissionRequest {
- string token = 1;
- string permission = 2;
-}
-
-message CheckPermissionResponse {
- bool allowed = 1;
- string user_id = 2;
- repeated string roles = 3;
-}
diff --git a/src/Workflow.Api/Serialization/TimestampJsonConverter.cs b/src/Workflow.Api/Serialization/TimestampJsonConverter.cs
new file mode 100644
index 0000000..4450d4c
--- /dev/null
+++ b/src/Workflow.Api/Serialization/TimestampJsonConverter.cs
@@ -0,0 +1,63 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Workflow.Api.Serialization;
+
+///
+/// 统一数据规范:所有时间字段以 UTC 毫秒时间戳(long)形式在 JSON 中传输。
+/// 后端只负责按标准输出毫秒时间戳,时区/格式化统一由前端处理。
+/// 读:JSON 数字(毫秒)→ DateTime(Utc);写入:DateTime → 毫秒 long。
+///
+public sealed class TimestampDateTimeConverter : JsonConverter
+{
+ public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ // 兼容数字(毫秒时间戳)与字符串(ISO 8601,向后兼容旧客户端)
+ if (reader.TokenType == JsonTokenType.Number)
+ {
+ return DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64()).UtcDateTime;
+ }
+
+ if (reader.TokenType == JsonTokenType.String)
+ {
+ return DateTime.Parse(reader.GetString()!, null, System.Globalization.DateTimeStyles.RoundtripKind);
+ }
+
+ throw new JsonException("Expected a number (ms timestamp) or string (ISO date) for DateTime field.");
+ }
+
+ public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
+ {
+ // 统一转 UTC 毫秒时间戳;Unspecified 按当前时区推算(极少见,审计时间均为 Utc)
+ var utc = value.Kind == DateTimeKind.Unspecified
+ ? DateTime.SpecifyKind(value, DateTimeKind.Utc)
+ : value.ToUniversalTime();
+ writer.WriteNumberValue(new DateTimeOffset(utc, TimeSpan.Zero).ToUnixTimeMilliseconds());
+ }
+}
+
+///
+/// DateTimeOffset 版本:同样输出 UTC 毫秒时间戳。
+///
+public sealed class TimestampDateTimeOffsetConverter : JsonConverter
+{
+ public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.Number)
+ {
+ return DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64());
+ }
+
+ if (reader.TokenType == JsonTokenType.String)
+ {
+ return DateTimeOffset.Parse(reader.GetString()!);
+ }
+
+ throw new JsonException("Expected a number (ms timestamp) or string (ISO date) for DateTimeOffset field.");
+ }
+
+ public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
+ {
+ writer.WriteNumberValue(value.ToUnixTimeMilliseconds());
+ }
+}
diff --git a/src/Workflow.Api/Services/AuthGrpcClient.cs b/src/Workflow.Api/Services/AuthGrpcClient.cs
deleted file mode 100644
index c23fb50..0000000
--- a/src/Workflow.Api/Services/AuthGrpcClient.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-using Grpc.Core;
-using Grpc.Net.Client;
-using Workflow.Api.Grpc;
-
-namespace Workflow.Api.Services;
-
-public interface IAuthGrpcClient
-{
- Task<(bool Valid, string UserId, List Roles, List Permissions)> ValidateTokenAsync(string token);
- Task<(bool Allowed, string UserId, List Roles)> CheckPermissionAsync(string token, string permission);
-}
-
-public class AuthGrpcClient : IAuthGrpcClient
-{
- private readonly AuthService.AuthServiceClient _client;
-
- public AuthGrpcClient(GrpcChannel channel)
- {
- _client = new AuthService.AuthServiceClient(channel);
- }
-
- public async Task<(bool Valid, string UserId, List Roles, List Permissions)> ValidateTokenAsync(string token)
- {
- try
- {
- var response = await _client.ValidateTokenAsync(new ValidateTokenRequest { Token = token });
- return (response.Valid, response.UserId, response.Roles.ToList(), response.Permissions.ToList());
- }
- catch (RpcException)
- {
- return (false, string.Empty, [], []);
- }
- }
-
- public async Task<(bool Allowed, string UserId, List Roles)> CheckPermissionAsync(string token, string permission)
- {
- try
- {
- var response = await _client.CheckPermissionAsync(new CheckPermissionRequest { Token = token, Permission = permission });
- return (response.Allowed, response.UserId, response.Roles.ToList());
- }
- catch (RpcException)
- {
- return (false, string.Empty, []);
- }
- }
-}
diff --git a/src/Workflow.Api/Services/CurrentUserContext.cs b/src/Workflow.Api/Services/CurrentUserContext.cs
index dc93a00..0a78d35 100644
--- a/src/Workflow.Api/Services/CurrentUserContext.cs
+++ b/src/Workflow.Api/Services/CurrentUserContext.cs
@@ -1,34 +1,33 @@
using System.Security.Claims;
+using Microsoft.AspNetCore.Http;
+using Microsoft.IdentityModel.JsonWebTokens;
using Workflow.Domain.Common;
namespace Workflow.Api.Services;
-public class CurrentUserContext : ICurrentUserContext
+///
+/// 基于 HttpContext 的当前用户上下文实现。从已认证的 JWT claims 中读取用户 ID
+/// 供 AuditInterceptor 填充审计字段。注意:JWT 配置中 MapInboundClaims=false,
+/// 因此 sub claim 保持短名,必须用 JwtRegisteredClaimNames.Sub 读取,而非 ClaimTypes.NameIdentifier。
+/// 未认证或 claim 缺失时返回 Guid.Empty(由调用方自行处理,不抛异常以免阻断后台任务等无 HTTP 上下文场景)。
+///
+public class CurrentUserContext(IHttpContextAccessor httpContextAccessor) : ICurrentUserContext
{
- private readonly IHttpContextAccessor _httpContextAccessor;
-
- public CurrentUserContext(IHttpContextAccessor httpContextAccessor)
- {
- _httpContextAccessor = httpContextAccessor;
- }
-
public Guid GetUserId()
{
- var httpContext = _httpContextAccessor.HttpContext;
+ var httpContext = httpContextAccessor.HttpContext;
if (httpContext?.User?.Identity?.IsAuthenticated == true)
{
- var userIdClaim = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)
- ?? httpContext.User.FindFirstValue("user_id")
- ?? "system";
-
- return Guid.TryParse(userIdClaim, out var userId) ? userId : Guid.Empty;
+ // MapInboundClaims=false,sub 保持短名,统一读取
+ var userIdClaim = httpContext.User.FindFirstValue(JwtRegisteredClaimNames.Sub);
+ if (Guid.TryParse(userIdClaim, out var userId))
+ return userId;
}
return Guid.Empty;
}
public string? GetIPAddress()
{
- var httpContext = _httpContextAccessor.HttpContext;
- return httpContext?.Connection?.RemoteIpAddress?.ToString();
+ return httpContextAccessor.HttpContext?.Connection?.RemoteIpAddress?.ToString();
}
}
diff --git a/src/Workflow.Api/Services/SystemUserContext.cs b/src/Workflow.Api/Services/SystemUserContext.cs
new file mode 100644
index 0000000..2987175
--- /dev/null
+++ b/src/Workflow.Api/Services/SystemUserContext.cs
@@ -0,0 +1,18 @@
+using Workflow.Domain.Common;
+
+namespace Workflow.Api.Services;
+
+///
+/// 系统身份上下文:供后台调度器(TimeoutSchedulerService / WebhookDispatcherService)等
+/// 无 HTTP 请求上下文的场景使用,由 AuditInterceptor 填充审计字段时标识为系统操作。
+/// SystemUserId 为固定 Guid,便于审计查询中区分人工操作与系统自动处理。
+///
+public class SystemUserContext : ICurrentUserContext
+{
+ /// 系统用户固定标识,全库一致,便于审计追溯。
+ public static readonly Guid SystemUserId = Guid.Parse("00000000-0000-0000-0000-000000000001");
+
+ public Guid GetUserId() => SystemUserId;
+
+ public string? GetIPAddress() => null;
+}
diff --git a/src/Workflow.Api/Services/TimeoutSchedulerService.cs b/src/Workflow.Api/Services/TimeoutSchedulerService.cs
new file mode 100644
index 0000000..11b62c0
--- /dev/null
+++ b/src/Workflow.Api/Services/TimeoutSchedulerService.cs
@@ -0,0 +1,65 @@
+using Microsoft.Extensions.Options;
+using Workflow.Application.Notifications;
+using Workflow.Application.Scheduler;
+
+namespace Workflow.Api.Services;
+
+///
+/// 超时任务调度器:后台 HostedService 壳,周期性调用 OverdueTaskProcessor 扫描逾期任务。
+/// 核心逻辑在 Workflow.Application.Scheduler.OverdueTaskProcessor(可单元测试)。
+///
+/// BackgroundService 是单例,通过 IServiceScopeFactory 创建独立 scope 解析 scoped 依赖(DbContext 等)。
+///
+public class TimeoutSchedulerService : BackgroundService
+{
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly ILogger _logger;
+ private readonly NotificationOptions _options;
+
+ public TimeoutSchedulerService(
+ IServiceScopeFactory scopeFactory,
+ ILogger logger,
+ IOptions options)
+ {
+ _scopeFactory = scopeFactory;
+ _logger = logger;
+ _options = options.Value;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation("TimeoutScheduler 启动,轮询间隔 {Interval}s", _options.Scheduler.PollIntervalSeconds);
+
+ // 启动时先等待一轮,避免与应用启动并发争抢资源
+ await Task.Delay(_options.Scheduler.StartupDelaySeconds * 1000, stoppingToken);
+
+ using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_options.Scheduler.PollIntervalSeconds));
+
+ do
+ {
+ try
+ {
+ await RunScanAsync(stoppingToken);
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "TimeoutScheduler 单轮处理异常");
+ }
+ }
+ while (await timer.WaitForNextTickAsync(stoppingToken));
+
+ _logger.LogInformation("TimeoutScheduler 停止");
+ }
+
+ /// 执行一轮扫描:创建 scope,解析 OverdueTaskProcessor,调用其 ExecuteAsync。
+ public async Task RunScanAsync(CancellationToken ct = default)
+ {
+ using var scope = _scopeFactory.CreateScope();
+ var processor = scope.ServiceProvider.GetRequiredService();
+ await processor.ExecuteAsync(ct);
+ }
+}
diff --git a/src/Workflow.Api/Services/WebhookDispatcherService.cs b/src/Workflow.Api/Services/WebhookDispatcherService.cs
new file mode 100644
index 0000000..c45dcab
--- /dev/null
+++ b/src/Workflow.Api/Services/WebhookDispatcherService.cs
@@ -0,0 +1,220 @@
+using System.Net;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+using Workflow.Application.Notifications;
+using Workflow.Domain.Entities;
+using Workflow.Infrastructure.Persistence;
+
+namespace Workflow.Api.Services;
+
+///
+/// Webhook 后台投递服务:轮询 wf_webhook_deliveries 中 status=pending 且到下次重试时间的记录,
+/// 用 HttpClient POST 投递,记录响应、错误与重试计划。
+///
+/// 设计要点:
+/// 1. SSRF 防护:投递前再次校验 Url 主机在 AllowedHosts 白名单内(落库时已校验,此处兜底),
+/// 拒绝 localhost/私有 IP,防止内网探测。
+/// 2. 重试:失败按指数退避(2^attempts 分钟)安排下次重试,达 MaxAttempts 标 failed。
+/// 3. BackgroundService 单例,通过 IServiceScopeFactory 创建独立 scope 访问 scoped DbContext。
+///
+public class WebhookDispatcherService : BackgroundService
+{
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly ILogger _logger;
+ private readonly NotificationOptions _options;
+
+ public WebhookDispatcherService(
+ IServiceScopeFactory scopeFactory,
+ IHttpClientFactory httpClientFactory,
+ IOptions options,
+ ILogger logger)
+ {
+ _scopeFactory = scopeFactory;
+ _httpClientFactory = httpClientFactory;
+ _logger = logger;
+ _options = options.Value;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ // 未配置任何 AllowedHosts 时,Webhook 投递被完全禁用,服务空转退出(避免无谓轮询)
+ if (_options.Webhook.AllowedHosts is null || _options.Webhook.AllowedHosts.Length == 0)
+ {
+ _logger.LogInformation("WebhookDispatcher:AllowedHosts 为空,Webhook 投递已禁用,服务退出");
+ return;
+ }
+
+ _logger.LogInformation("WebhookDispatcher 启动,轮询间隔 {Interval}s", _options.Webhook.PollIntervalSeconds);
+
+ await Task.Delay(_options.Scheduler.StartupDelaySeconds * 1000, stoppingToken);
+
+ using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_options.Webhook.PollIntervalSeconds));
+
+ do
+ {
+ try
+ {
+ await DispatchPendingAsync(stoppingToken);
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "WebhookDispatcher 单轮投递异常");
+ }
+ }
+ while (await timer.WaitForNextTickAsync(stoppingToken));
+
+ _logger.LogInformation("WebhookDispatcher 停止");
+ }
+
+ /// 暴露给单元测试:直接执行一轮投递。
+ public async Task DispatchPendingAsync(CancellationToken ct = default)
+ {
+ using var scope = _scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var now = DateTime.UtcNow;
+
+ // 取出到下次重试时间的 pending 记录,限制每轮批量避免单轮过长
+ var pending = await db.WebhookDeliveries
+ .Where(d => d.Status == "pending"
+ && d.Attempts < d.MaxAttempts
+ && (d.NextRetryAt == null || d.NextRetryAt <= now))
+ .OrderBy(d => d.CreatedAt)
+ .Take(50)
+ .ToListAsync(ct);
+
+ if (pending.Count == 0)
+ return;
+
+ var client = _httpClientFactory.CreateClient("Webhook");
+ client.Timeout = TimeSpan.FromSeconds(_options.Webhook.TimeoutSeconds);
+
+ foreach (var delivery in pending)
+ {
+ await DeliverOneAsync(db, client, delivery, ct);
+ }
+ }
+
+ private async Task DeliverOneAsync(
+ WorkflowDbContext db, HttpClient client, WebhookDelivery delivery, CancellationToken ct)
+ {
+ // SSRF 兜底校验(落库时已校验,此处防御配置变更或脏数据)
+ if (!IsHostAllowed(delivery.Url))
+ {
+ delivery.Status = "failed";
+ delivery.LastError = "目标主机不在 AllowedHosts 白名单(SSRF 防护拒绝)";
+ delivery.Attempts = delivery.MaxAttempts; // 直接终止
+ delivery.UpdatedAt = DateTime.UtcNow;
+ await db.SaveChangesAsync(ct);
+ _logger.LogWarning("Webhook {Id} 投递被 SSRF 防护拦截:{Url}", delivery.Id, delivery.Url);
+ return;
+ }
+
+ delivery.Attempts++;
+ delivery.UpdatedAt = DateTime.UtcNow;
+
+ try
+ {
+ using var content = new StringContent(delivery.Payload, System.Text.Encoding.UTF8, "application/json");
+ using var request = new HttpRequestMessage(new HttpMethod(delivery.HttpMethod), delivery.Url) { Content = content };
+ using var response = await client.SendAsync(request, ct);
+
+ delivery.StatusCode = (int)response.StatusCode;
+
+ // 2xx 视为成功
+ if (response.IsSuccessStatusCode)
+ {
+ delivery.Status = "delivered";
+ delivery.ResponseBody = await TruncateAsync(response.Content, ct);
+ delivery.NextRetryAt = null;
+ delivery.LastError = null;
+ _logger.LogInformation("Webhook {Id} 投递成功({Status})", delivery.Id, delivery.StatusCode);
+ }
+ else
+ {
+ // 非 2xx:记录响应,按重试策略安排下次重试
+ delivery.ResponseBody = await TruncateAsync(response.Content, ct);
+ ScheduleRetryOrFail(delivery, $"HTTP {delivery.StatusCode}");
+ }
+ }
+ catch (Exception ex)
+ {
+ delivery.StatusCode = null;
+ ScheduleRetryOrFail(delivery, ex.Message);
+ _logger.LogWarning(ex, "Webhook {Id} 投递异常", delivery.Id);
+ }
+
+ await db.SaveChangesAsync(ct);
+ }
+
+ ///
+ /// 按指数退避(2^attempts 分钟)安排下次重试;达 MaxAttempts 则标记 failed。
+ ///
+ private void ScheduleRetryOrFail(WebhookDelivery delivery, string error)
+ {
+ delivery.LastError = error;
+ if (delivery.Attempts >= delivery.MaxAttempts)
+ {
+ delivery.Status = "failed";
+ delivery.NextRetryAt = null;
+ _logger.LogWarning("Webhook {Id} 达最大尝试次数 {Max},标记 failed", delivery.Id, delivery.MaxAttempts);
+ }
+ else
+ {
+ // 仍在 pending,安排下次重试(指数退避:1次=2分钟,2次=4分钟,3次=8分钟…)
+ var delayMinutes = Math.Pow(2, delivery.Attempts);
+ delivery.NextRetryAt = DateTime.UtcNow.AddMinutes(delayMinutes);
+ }
+ }
+
+ private static async Task TruncateAsync(HttpContent content, CancellationToken ct)
+ {
+ try
+ {
+ var body = await content.ReadAsStringAsync(ct);
+ return body.Length > 4000 ? body[..4000] : body;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ /// SSRF 防护:校验 URL 主机在白名单内,且拒绝 localhost 与私有/内网 IP。
+ internal bool IsHostAllowed(string url)
+ {
+ if (_options.Webhook.AllowedHosts is null || _options.Webhook.AllowedHosts.Length == 0)
+ return false;
+
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
+ return false;
+
+ var host = uri.Host;
+
+ // 拒绝 localhost
+ if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ // 拒绝私有/内网 IP(防止白名单被绕过指向内网)
+ if (IPAddress.TryParse(host, out var ip))
+ {
+ if (IPAddress.IsLoopback(ip))
+ return false;
+ // 私有地址段:10.x / 172.16-31.x / 192.168.x
+ if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
+ {
+ var bytes = ip.GetAddressBytes();
+ if (bytes[0] == 10) return false;
+ if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return false;
+ if (bytes[0] == 192 && bytes[1] == 168) return false;
+ }
+ }
+
+ return _options.Webhook.AllowedHosts
+ .Any(allowed => string.Equals(allowed, host, StringComparison.OrdinalIgnoreCase));
+ }
+}
diff --git a/src/Workflow.Api/Workflow.Api.csproj b/src/Workflow.Api/Workflow.Api.csproj
index ceaf18f..389fdfd 100644
--- a/src/Workflow.Api/Workflow.Api.csproj
+++ b/src/Workflow.Api/Workflow.Api.csproj
@@ -7,11 +7,7 @@
-
-
-
-
-
+
diff --git a/src/Workflow.Api/appsettings.json b/src/Workflow.Api/appsettings.json
index 57525c2..3e70bd8 100644
--- a/src/Workflow.Api/appsettings.json
+++ b/src/Workflow.Api/appsettings.json
@@ -4,12 +4,21 @@
"Default": "Host=localhost;Port=5432;Database=workflow;Username=rag;Password=rag123"
},
"Jwt": {
- "Issuer": "rag-api",
- "Audience": "rag-client",
- "SigningKey": "RagJwtSecretKey2026MustBeAtLeast32CharsLong!"
+ "Authority": "http://localhost:5215",
+ "RequireHttps": false
},
- "Grpc": {
- "AuthServerUrl": "http://localhost:50051"
+ "Notification": {
+ "Scheduler": {
+ "PollIntervalSeconds": 60,
+ "StartupDelaySeconds": 15
+ },
+ "Webhook": {
+ "PollIntervalSeconds": 30,
+ "TimeoutSeconds": 10,
+ "MaxAttempts": 3,
+ "AllowedHosts": [],
+ "DefaultUrl": ""
+ }
},
"Logging": {
"LogLevel": {
diff --git a/src/Workflow.Application/Engine/NodeConfigParser.cs b/src/Workflow.Application/Engine/NodeConfigParser.cs
new file mode 100644
index 0000000..8b362eb
--- /dev/null
+++ b/src/Workflow.Application/Engine/NodeConfigParser.cs
@@ -0,0 +1,59 @@
+using System.Text.Json;
+
+namespace Workflow.Application.Engine;
+
+///
+/// 节点 Config(JSON)解析工具。提取自 ProcessEngine 的私有 ParseConfig/GetString/GetInt/GetBool,
+/// 供 TimeoutSchedulerService 等应用层组件复用,避免重复实现 JSON 解析逻辑。
+///
+public static class NodeConfigParser
+{
+ public static Dictionary Parse(string? config)
+ {
+ if (string.IsNullOrWhiteSpace(config))
+ return new();
+
+ try
+ {
+ return JsonSerializer.Deserialize>(config) ?? new();
+ }
+ catch (JsonException)
+ {
+ return new();
+ }
+ }
+
+ public static string? GetString(Dictionary config, string key)
+ {
+ if (config.TryGetValue(key, out var element) && element.ValueKind == JsonValueKind.String)
+ return element.GetString();
+ return null;
+ }
+
+ public static int? GetInt(Dictionary config, string key)
+ {
+ if (!config.TryGetValue(key, out var element))
+ return null;
+
+ return element.ValueKind switch
+ {
+ JsonValueKind.Number when element.TryGetInt32(out var v) => v,
+ JsonValueKind.String when int.TryParse(element.GetString(), out var v) => v,
+ _ => null
+ };
+ }
+
+ public static bool? GetBool(Dictionary config, string key)
+ {
+ if (!config.TryGetValue(key, out var element))
+ return null;
+
+ return element.ValueKind switch
+ {
+ JsonValueKind.True => true,
+ JsonValueKind.False => false,
+ JsonValueKind.String when bool.TryParse(element.GetString(), out var v) => v,
+ _ => null
+ };
+ }
+}
diff --git a/src/Workflow.Application/Engine/ProcessEngine.cs b/src/Workflow.Application/Engine/ProcessEngine.cs
index 82d8591..c3d6e7f 100644
--- a/src/Workflow.Application/Engine/ProcessEngine.cs
+++ b/src/Workflow.Application/Engine/ProcessEngine.cs
@@ -1,6 +1,7 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
+using Workflow.Application.Notifications;
using Workflow.Domain.Entities;
using Workflow.Domain.Enums;
using Workflow.Domain.Exceptions;
@@ -12,8 +13,15 @@ using TaskStatus = Workflow.Domain.Enums.TaskStatus;
namespace Workflow.Application.Engine;
///
-/// Core workflow engine that handles token propagation through the workflow graph.
-/// Processes nodes by type: Start, End, Approval, Cc, Condition, Parallel, SubProcess.
+/// 工作流核心引擎,负责在流程图中传播 Token(执行控制权)。
+/// Token 沿边在节点间接力:旧 token 被 Consumed,新 token 在下游节点 Active,形成推进链路。
+/// 引擎按节点类型分发处理:Start / End / Approval / Cc / Condition / Parallel / SubProcess,
+/// 七种节点类型各自定义 Token 的消费与再生产规则。
+/// 关键约束:
+/// - Approval / SubProcess 节点会让 token 暂停等待人工或子流程回调;
+/// - Cc / Condition / Parallel(fork) 节点立即消费当前 token 并向下传播;
+/// - Parallel(join) 节点会等待所有入边 token 到齐后才合并并继续;
+/// - 所有节点处理都在同一 DbContext 事务内完成,保证 token 与任务状态一致。
///
public class ProcessEngine
{
@@ -29,7 +37,8 @@ public class ProcessEngine
}
///
- /// Starts a workflow instance by finding the Start node and propagating tokens.
+ /// 启动一个流程实例:定位 Start 节点 → 实例置为 Running → 在 Start 节点创建首个 Active token →
+ /// 立即处理 Start 节点把 token 推向第一个下游节点。
///
public async Task StartAsync(WorkflowInstance instance)
{
@@ -45,7 +54,7 @@ public class ProcessEngine
// Set instance status to Running
instance.Status = InstanceStatus.Running;
- // Create a token at the start node
+ // 在 Start 节点放置首个 Active token,作为后续传播的种子
var token = new WorkflowToken
{
Id = Guid.NewGuid(),
@@ -57,12 +66,13 @@ public class ProcessEngine
await _dbContext.SaveChangesAsync();
- // Immediately process the start node
+ // 立即驱动 Start 节点,把 token 推进到第一个业务节点
await ProcessNodeAsync(instance, token, startNode);
}
///
- /// Processes a node based on its type, propagating tokens accordingly.
+ /// 按节点类型分发处理。每个节点类型决定如何消费当前 token、是否产生新 token、
+ /// 以及是否需要暂停等待(如 Approval 等待人工、SubProcess 等待子实例完成)。
///
public async Task ProcessNodeAsync(WorkflowInstance instance, WorkflowToken token, WorkflowNode node)
{
@@ -95,13 +105,21 @@ public class ProcessEngine
}
///
- /// Completes a workflow task, executing actions and propagating tokens.
+ /// 完成一个审批任务:更新任务状态 → 触发对应 hook → 按审批结果选择边(Approved 优先于 Normal,Rejected 独立)
+ /// → 消费任务所属 token → 在目标节点创建新 token → 递归处理目标节点。
+ /// 此方法是人工驱动流程推进的唯一入口。
///
public async Task CompleteTaskAsync(WorkflowTask task, TaskResult result)
{
+ // 防重复推进:已完成的任务不可再次完成,否则会重复创建下游 token 并可能重复创建任务。
+ if (task.Status != TaskStatus.Pending)
+ {
+ throw new BusinessException($"任务 {task.Id} 已处理(当前状态:{task.Status}),不可重复完成");
+ }
+
// Update task status
task.Status = result == TaskResult.Approved ? TaskStatus.Approved : TaskStatus.Rejected;
- task.Result = result.ToString().ToLowerInvariant();
+ task.Result = JsonSerializer.Serialize(result.ToString().ToLowerInvariant());
task.CompletedAt = DateTime.UtcNow;
// Find the node for this task
@@ -113,10 +131,15 @@ public class ProcessEngine
var hook = result == TaskResult.Approved ? "onApproved" : "onRejected";
await ExecuteActionsSafely(node, hook, task.InstanceId, task.Result);
+ // 直接调用通知服务(不依赖吞异常的 hook,保证必达)。
+ // 通知落库随主 DbContext 一起提交;INotificationService 未注册时静默跳过(零破坏)。
+ await NotifyTaskResultAsync(task, result);
+
// Find the token for this task
var token = await _dbContext.WorkflowTokens.FindAsync(task.TokenId);
// Find outgoing edges filtered by result
+ // 边选择策略:通过时优先 Approved 边,缺省回退 Normal;驳回时必须命中 Rejected 边
var edges = await GetOutgoingEdgesAsync(node.Id);
var targetEdge = result == TaskResult.Approved
? edges.FirstOrDefault(e => e.EdgeType == EdgeType.Approved)
@@ -140,7 +163,7 @@ public class ProcessEngine
token.CompletedAt = DateTime.UtcNow;
}
- // Create new token at target node
+ // 在目标节点投放新 token,把执行控制权移交下游
var newToken = new WorkflowToken
{
Id = Guid.NewGuid(),
@@ -161,10 +184,12 @@ public class ProcessEngine
}
///
- /// Handles the completion of a sub-process instance by propagating the parent token.
+ /// 处理子流程实例完成后的回调:找到父实例中等待该子流程的 token,消费它并在父流程继续向下传播。
+ /// 父子实例通过 ParentInstanceId / ParentTokenId 关联。
///
public async Task HandleSubProcessCompletionAsync(WorkflowInstance childInstance)
{
+ // 非子流程实例(无父实例)直接忽略
if (childInstance.ParentInstanceId is null)
return;
@@ -174,7 +199,7 @@ public class ProcessEngine
if (parentInstance is null)
return;
- // Find the token that was waiting for this sub-process
+ // 找到当初停在 SubProcess 节点、等待本子流程完成的那个父 token
var waitingToken = childInstance.ParentTokenId.HasValue
? await _dbContext.WorkflowTokens.FindAsync(childInstance.ParentTokenId.Value)
: null;
@@ -182,16 +207,16 @@ public class ProcessEngine
if (waitingToken is null)
return;
- // Find the sub-process node
+ // 找到 SubProcess 节点本身,以取其下游边
var subProcessNode = await FindNodeAsync(waitingToken.NodeId);
if (subProcessNode is null)
return;
- // Consume the waiting token
+ // 等待 token 生命周期结束
waitingToken.Status = TokenStatus.Consumed;
waitingToken.CompletedAt = DateTime.UtcNow;
- // Find outgoing edge and propagate
+ // 在父流程中继续向下投放新 token
var edges = await GetOutgoingEdgesAsync(subProcessNode.Id);
var edge = edges.FirstOrDefault();
@@ -220,6 +245,7 @@ public class ProcessEngine
// Node Processing Methods
// ============================================================
+ /// Start 节点:消费 token,沿第一条出边投放新 token,推进到首个业务节点。
private async Task ProcessStartNodeAsync(WorkflowInstance instance, WorkflowToken token, WorkflowNode node)
{
var edges = await GetOutgoingEdgesAsync(node.Id);
@@ -230,7 +256,7 @@ public class ProcessEngine
token.Status = TokenStatus.Consumed;
token.CompletedAt = DateTime.UtcNow;
- // Create new token at the target of the first edge
+ // Start 节点约定只走第一条出边,多出边应使用 Parallel 节点表达
var targetEdge = edges.First();
var newToken = new WorkflowToken
{
@@ -251,6 +277,7 @@ public class ProcessEngine
}
}
+ /// End 节点:消费 token;若实例已无任何活跃 token,则标记实例完成。
private async Task ProcessEndNodeAsync(WorkflowInstance instance, WorkflowToken token, WorkflowNode node)
{
// Consume the token
@@ -259,7 +286,7 @@ public class ProcessEngine
await _dbContext.SaveChangesAsync();
- // Check if all tokens for this instance are consumed
+ // 收敛判定:所有并行分支必须全部到达各自的 End 才算整体完成,否则保持 Running
var allTokens = await _dbContext.WorkflowTokens
.Where(t => t.InstanceId == instance.Id)
.ToListAsync();
@@ -274,11 +301,24 @@ public class ProcessEngine
}
}
+ ///
+ /// Approval 节点:创建待办审批任务,token 保持 Active 直到 被调用。
+ /// 受理人规则支持 "user:{guid}"(指定用户)和 "role:{name}"(指定角色)两种前缀语法。
+ ///
private async Task ProcessApprovalNodeAsync(WorkflowInstance instance, WorkflowToken token, WorkflowNode node)
{
+ // 防重复处理:若该 token 已存在待处理任务,说明节点已被处理过,拒绝重复创建任务。
+ // (token 被审批完成时会置为 Consumed,此处仅拦截异常路径下同一 Active token 被二次处理。)
+ var alreadyHasPendingTask = await _dbContext.WorkflowTasks
+ .AnyAsync(t => t.TokenId == token.Id && t.Status == TaskStatus.Pending);
+ if (alreadyHasPendingTask)
+ {
+ throw new BusinessException($"节点 {node.Name}({node.Id})的 token 已创建待处理任务,不可重复处理");
+ }
+
var config = ParseConfig(node.Config);
- // Parse assignee
+ // 受理人规则解析:支持 "user:" 与 "role:" 两种前缀
var assigneeRule = GetString(config, "assigneeRule");
// Create workflow task
@@ -288,6 +328,7 @@ public class ProcessEngine
InstanceId = instance.Id,
TokenId = token.Id,
NodeId = node.Id,
+ Title = $"{instance.Title} - {node.Name}",
Type = TaskType.Approval,
Status = TaskStatus.Pending,
};
@@ -296,15 +337,25 @@ public class ProcessEngine
{
if (assigneeRule.StartsWith("user:"))
{
+ // 指定具体用户:解析 user: 后的 GUID 作为受理人
var userIdStr = assigneeRule["user:".Length..];
task.AssigneeId = Guid.TryParse(userIdStr, out var userId) ? userId : null;
}
else if (assigneeRule.StartsWith("role:"))
{
+ // 指定角色:按角色名匹配,由前端/上层按角色解析实际受理人
task.AssigneeRole = assigneeRule["role:".Length..];
}
}
+ // 节点超时配置:config.timeoutMinutes(分钟)设置任务截止时间 DueAt,
+ // 供 TimeoutSchedulerService 扫描逾期任务触发 TimeoutAutoProcess。
+ var timeoutMinutes = GetInt(config, "timeoutMinutes");
+ if (timeoutMinutes is { } minutes && minutes > 0)
+ {
+ task.DueAt = DateTime.UtcNow.AddMinutes(minutes);
+ }
+
_dbContext.WorkflowTasks.Add(task);
// Execute onEnter actions
@@ -312,17 +363,25 @@ public class ProcessEngine
await _dbContext.SaveChangesAsync();
- // Token stays active - waiting for approval
+ // 任务到达通知(审批任务创建后通知受理人)。未注册 INotificationService 时静默跳过。
+ await NotifyTaskArrivedSafelyAsync(task);
+
+ // Token 保持 Active 状态:流程在此暂停,等待人工通过 CompleteTaskAsync 推进
}
+ ///
+ /// Cc(抄送)节点:为每个收件人创建知会任务,随后立即消费 token 向下传播。
+ /// 抄送是 fire-and-forget 语义——不等待收件人查阅即继续主流程。
+ ///
private async Task ProcessCcNodeAsync(WorkflowInstance instance, WorkflowToken token, WorkflowNode node)
{
var config = ParseConfig(node.Config);
- // Parse recipients
+ // 收件人列表(用户 GUID 字符串数组)
var recipients = GetArray(config, "recipients");
- // Create CC tasks for each recipient
+ // 为每个收件人生成独立的知会任务;不阻塞主流程
+ var createdCcTasks = new List();
foreach (var recipient in recipients)
{
var ccTask = new WorkflowTask
@@ -331,17 +390,19 @@ public class ProcessEngine
InstanceId = instance.Id,
TokenId = token.Id,
NodeId = node.Id,
+ Title = $"{instance.Title} - {node.Name}",
AssigneeId = Guid.TryParse(recipient, out var rcpId) ? rcpId : null,
Type = TaskType.Cc,
Status = TaskStatus.Pending,
};
_dbContext.WorkflowTasks.Add(ccTask);
+ createdCcTasks.Add(ccTask);
}
// Execute onEnter actions
await ExecuteActionsSafely(node, "onEnter", instance.Id);
- // Consume current token
+ // 抄送任务创建后立即消费当前 token,沿第一条出边继续主流程
token.Status = TokenStatus.Consumed;
token.CompletedAt = DateTime.UtcNow;
@@ -373,25 +434,34 @@ public class ProcessEngine
{
await _dbContext.SaveChangesAsync();
}
+
+ // 抄送到达通知:通知所有收件人。未注册 INotificationService 时静默跳过。
+ foreach (var ccTask in createdCcTasks)
+ {
+ await NotifyTaskArrivedSafelyAsync(ccTask);
+ }
}
+ ///
+ /// Condition(条件)节点:按 Order 排序逐条评估出边条件,首个匹配分支胜出(first-match-wins)。
+ /// 命中分支后消费当前 token,向目标节点投放新 token。无任何分支命中则抛异常(流程定义错误)。
+ ///
private async Task ProcessConditionNodeAsync(WorkflowInstance instance, WorkflowToken token, WorkflowNode node)
{
var edges = await GetOutgoingEdgesAsync(node.Id);
var variables = ParseVariables(instance.Variables);
- // Sort edges by order, then evaluate conditions
+ // 按 Order 升序确保分支评估顺序稳定可控
var orderedEdges = edges.OrderBy(e => e.Order).ToList();
foreach (var edge in orderedEdges)
{
if (_conditionEvaluator.Evaluate(edge.Condition, variables))
{
- // Found matching branch - consume current token
+ // 命中分支:消费当前 token,向分支目标投放新 token
token.Status = TokenStatus.Consumed;
token.CompletedAt = DateTime.UtcNow;
- // Create new token at target
var newToken = new WorkflowToken
{
Id = Guid.NewGuid(),
@@ -403,7 +473,6 @@ public class ProcessEngine
await _dbContext.SaveChangesAsync();
- // Process the target node
var targetNode = await FindNodeAsync(edge.TargetNodeId);
if (targetNode is not null)
{
@@ -414,10 +483,16 @@ public class ProcessEngine
}
}
- // No matching branch found
+ // 所有分支均不匹配:流程定义本身存在缺陷,直接报错而不是静默挂起
throw new BusinessException("No condition branch matched");
}
+ ///
+ /// Parallel(并行)节点:根据出入边数量自动判定 Fork / Join / 透传三种行为。
+ /// - Fork(多出边):消费当前 token,为每条出边生成一个独立 token,实现并发分支;
+ /// - Join(多入边):等待所有入边的 token 到齐,全部消费后合并为一个 token 继续向下;
+ /// - 透传(单入单出):直接接力。
+ ///
private async Task ProcessParallelNodeAsync(WorkflowInstance instance, WorkflowToken token, WorkflowNode node)
{
var outgoingEdges = await GetOutgoingEdgesAsync(node.Id);
@@ -425,7 +500,7 @@ public class ProcessEngine
if (outgoingEdges.Count > 1)
{
- // Fork behavior: create one token per outgoing edge
+ // Fork:1 个 token 进 → N 个 token 出,开启并行分支
token.Status = TokenStatus.Consumed;
token.CompletedAt = DateTime.UtcNow;
@@ -446,6 +521,7 @@ public class ProcessEngine
var targetNode = await FindNodeAsync(edge.TargetNodeId);
if (targetNode is not null)
{
+ // Fork 时为每个分支分别 SaveChanges,确保每条分支的 token 都已落库后再独立推进
await _dbContext.SaveChangesAsync();
await ProcessNodeAsync(instance, newToken, targetNode);
}
@@ -453,7 +529,8 @@ public class ProcessEngine
}
else if (incomingEdges.Count > 1)
{
- // Join behavior: check if all tokens have arrived
+ // Join:N 个 token 进 → 1 个 token 出,合并并行分支
+ // 统计当前节点上的活跃 token 数,与入边数量比较以判定是否全部到齐
var tokensAtNode = await _dbContext.WorkflowTokens
.Where(t => t.InstanceId == instance.Id
&& t.NodeId == node.Id
@@ -462,7 +539,7 @@ public class ProcessEngine
if (tokensAtNode.Count >= incomingEdges.Count)
{
- // All tokens arrived - consume all and create one new token
+ // 全部到齐:一次性消费所有入边 token,合并为单个出边 token
foreach (var t in tokensAtNode)
{
t.Status = TokenStatus.Consumed;
@@ -497,13 +574,13 @@ public class ProcessEngine
}
else
{
- // Not all tokens arrived yet - wait
+ // 分支尚未全部到达:本次仅持久化已到达的 token,等待剩余分支汇入,token 暂不向下传播
await _dbContext.SaveChangesAsync();
}
}
else
{
- // Simple pass-through (single in, single out)
+ // 单入单出:等同于普通节点,直接接力消费并产生新 token
token.Status = TokenStatus.Consumed;
token.CompletedAt = DateTime.UtcNow;
@@ -534,6 +611,11 @@ public class ProcessEngine
}
}
+ ///
+ /// SubProcess(子流程)节点:依据 config.definitionId 创建子实例并启动,父 token 停在本节点等待。
+ /// 父子实例通过 ParentInstanceId / ParentTokenId 关联;子实例完成后由
+ /// 唤醒父流程继续推进。
+ ///
private async Task ProcessSubProcessNodeAsync(WorkflowInstance instance, WorkflowToken token, WorkflowNode node)
{
var config = ParseConfig(node.Config);
@@ -545,7 +627,7 @@ public class ProcessEngine
if (!Guid.TryParse(definitionIdStr, out var definitionId))
throw new BusinessException("SubProcess definitionId must be a valid GUID");
- // Create child instance
+ // 创建子实例,记录父实例与父 token 以便完成后回调
var childInstance = new WorkflowInstance
{
Id = Guid.NewGuid(),
@@ -559,7 +641,7 @@ public class ProcessEngine
await _dbContext.SaveChangesAsync();
- // Token stays active at the sub-process node, waiting for child completion
+ // 父 token 保持 Active 状态停在本节点,等待子流程完成回调后由 HandleSubProcessCompletionAsync 处理
}
// ============================================================
@@ -597,6 +679,7 @@ public class ProcessEngine
return new();
var result = new Dictionary();
+ // 把 JsonElement 还原为强类型对象,供条件求值时按类型分派对比器
foreach (var kvp in dict)
{
result[kvp.Key] = kvp.Value.ValueKind switch
@@ -612,31 +695,16 @@ public class ProcessEngine
}
catch (JsonException)
{
+ // 变量 JSON 解析失败不阻断流程,按空变量处理(条件求值会因字段缺失返回 false)
return new();
}
}
private static Dictionary ParseConfig(string? config)
- {
- if (string.IsNullOrWhiteSpace(config))
- return new();
-
- try
- {
- return JsonSerializer.Deserialize>(config) ?? new();
- }
- catch (JsonException)
- {
- return new();
- }
- }
+ => NodeConfigParser.Parse(config);
private static string? GetString(Dictionary config, string key)
- {
- if (config.TryGetValue(key, out var element) && element.ValueKind == JsonValueKind.String)
- return element.GetString();
- return null;
- }
+ => NodeConfigParser.GetString(config, key);
private static List GetArray(Dictionary config, string key)
{
@@ -649,6 +717,58 @@ public class ProcessEngine
.ToList();
}
+ private static int? GetInt(Dictionary config, string key)
+ => NodeConfigParser.GetInt(config, key);
+
+ private static bool? GetBool(Dictionary config, string key)
+ => NodeConfigParser.GetBool(config, key);
+
+ ///
+ /// 安全发送任务到达通知:从 DI 解析 INotificationService(可能未注册,如单元测试场景),
+ /// 未注册或发送异常均不阻断主流程。通知落库随主 DbContext 一起提交。
+ ///
+ private async Task NotifyTaskArrivedSafelyAsync(WorkflowTask task)
+ {
+ try
+ {
+ var notifier = _serviceProvider.GetService();
+ if (notifier is null)
+ return;
+ await notifier.NotifyTaskArrivedAsync(task);
+ }
+ catch
+ {
+ // 通知是旁路副作用,失败不得阻断 token 推进
+ }
+ }
+
+ ///
+ /// 安全发送任务审批结果通知(通过/驳回)。与 NotifyTaskArrivedSafelyAsync 同样静默容错。
+ ///
+ private async Task NotifyTaskResultAsync(WorkflowTask task, TaskResult result)
+ {
+ try
+ {
+ var notifier = _serviceProvider.GetService();
+ if (notifier is null)
+ return;
+
+ if (result == TaskResult.Approved)
+ await notifier.NotifyTaskApprovedAsync(task);
+ else
+ await notifier.NotifyTaskRejectedAsync(task);
+ }
+ catch
+ {
+ // 通知是旁路副作用,失败不得阻断 token 推进
+ }
+ }
+
+ ///
+ /// 安全执行节点动作钩子(onEnter / onApproved / onRejected)。
+ /// 从 DI 按 key 解析 INodeAction,未注册或执行异常均被吞掉,
+ /// 确保副作用类动作(如发通知)永不阻断主流程的 token 传播。
+ ///
private async Task ExecuteActionsSafely(WorkflowNode node, string hook, Guid instanceId, string? result = null)
{
var config = ParseConfig(node.Config);
@@ -685,7 +805,7 @@ public class ProcessEngine
}
catch
{
- // Action failure does not block token propagation
+ // 故意吞掉异常:节点动作属于旁路副作用(通知/日志等),其失败不得阻断主流程推进
}
}
}
diff --git a/src/Workflow.Application/Features/Notifications/NotificationQueries.cs b/src/Workflow.Application/Features/Notifications/NotificationQueries.cs
new file mode 100644
index 0000000..d6cd956
--- /dev/null
+++ b/src/Workflow.Application/Features/Notifications/NotificationQueries.cs
@@ -0,0 +1,150 @@
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+using Workflow.Application.Common;
+using Workflow.Domain.Entities;
+using Workflow.Domain.Exceptions;
+using Workflow.Infrastructure.Persistence;
+
+namespace Workflow.Application.Features.Notifications;
+
+public record NotificationItemDto(
+ Guid Id,
+ Guid? RecipientUserId,
+ string? RecipientRole,
+ string Title,
+ string Content,
+ string Category,
+ Guid? RelatedInstanceId,
+ Guid? RelatedTaskId,
+ bool IsRead,
+ DateTime? ReadAt,
+ DateTime CreatedAt);
+
+/// 查询当前用户的通知列表(含按角色匹配的通知)。
+public record GetNotificationsQuery(
+ Guid UserId,
+ IReadOnlyList UserRoles,
+ bool UnreadOnly,
+ int PageIndex,
+ int PageSize
+) : IRequest>;
+
+public class GetNotificationsQueryHandler(WorkflowDbContext db)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ GetNotificationsQuery request, CancellationToken cancellationToken)
+ {
+ var query = db.Notifications.AsNoTracking();
+
+ // 收件人匹配:直接发给该用户 OR 发给该用户的某个角色
+ query = query.Where(n =>
+ (n.RecipientUserId == request.UserId) ||
+ (n.RecipientRole != null && request.UserRoles.Contains(n.RecipientRole)));
+
+ if (request.UnreadOnly)
+ {
+ query = query.Where(n => !n.IsRead);
+ }
+
+ var total = await query.CountAsync(cancellationToken);
+ var items = await query
+ .OrderByDescending(n => n.CreatedAt)
+ .Skip((request.PageIndex - 1) * request.PageSize)
+ .Take(request.PageSize)
+ .Select(n => new NotificationItemDto(
+ n.Id, n.RecipientUserId, n.RecipientRole,
+ n.Title, n.Content, n.Category,
+ n.RelatedInstanceId, n.RelatedTaskId,
+ n.IsRead, n.ReadAt, n.CreatedAt))
+ .ToListAsync(cancellationToken);
+
+ return new PagedResult
+ {
+ Items = items,
+ Total = total,
+ PageIndex = request.PageIndex,
+ PageSize = request.PageSize
+ };
+ }
+}
+
+/// 查询当前用户的未读通知数(前端角标用)。
+public record GetUnreadNotificationCountQuery(
+ Guid UserId,
+ IReadOnlyList UserRoles
+) : IRequest;
+
+public class GetUnreadNotificationCountHandler(WorkflowDbContext db)
+ : IRequestHandler
+{
+ public async Task Handle(GetUnreadNotificationCountQuery request, CancellationToken cancellationToken)
+ {
+ return await db.Notifications
+ .Where(n => !n.IsRead &&
+ ((n.RecipientUserId == request.UserId) ||
+ (n.RecipientRole != null && request.UserRoles.Contains(n.RecipientRole))))
+ .CountAsync(cancellationToken);
+ }
+}
+
+/// 标记单条通知已读。
+public record MarkNotificationReadCommand(Guid NotificationId, Guid UserId) : IRequest;
+
+public class MarkNotificationReadCommandHandler(WorkflowDbContext db)
+ : IRequestHandler
+{
+ public async Task Handle(MarkNotificationReadCommand request, CancellationToken cancellationToken)
+ {
+ var notification = await db.Notifications.FirstOrDefaultAsync(
+ n => n.Id == request.NotificationId, cancellationToken)
+ ?? throw new NotFoundException($"通知 '{request.NotificationId}' 不存在");
+
+ // 权限校验:只能标记发给自己的通知
+ if (notification.RecipientUserId != request.UserId)
+ {
+ throw new BusinessException("只能标记自己的通知为已读");
+ }
+
+ if (!notification.IsRead)
+ {
+ notification.IsRead = true;
+ notification.ReadAt = DateTime.UtcNow;
+ notification.UpdatedAt = DateTime.UtcNow;
+ await db.SaveChangesAsync(cancellationToken);
+ }
+
+ return Unit.Value;
+ }
+}
+
+/// 标记当前用户所有通知为已读。
+public record MarkAllNotificationsReadCommand(Guid UserId, IReadOnlyList UserRoles) : IRequest;
+
+public class MarkAllNotificationsReadCommandHandler(WorkflowDbContext db)
+ : IRequestHandler
+{
+ public async Task Handle(MarkAllNotificationsReadCommand request, CancellationToken cancellationToken)
+ {
+ var now = DateTime.UtcNow;
+ var unread = await db.Notifications
+ .Where(n => !n.IsRead &&
+ ((n.RecipientUserId == request.UserId) ||
+ (n.RecipientRole != null && request.UserRoles.Contains(n.RecipientRole))))
+ .ToListAsync(cancellationToken);
+
+ foreach (var n in unread)
+ {
+ n.IsRead = true;
+ n.ReadAt = now;
+ n.UpdatedAt = now;
+ }
+
+ if (unread.Count > 0)
+ {
+ await db.SaveChangesAsync(cancellationToken);
+ }
+
+ return Unit.Value;
+ }
+}
diff --git a/src/Workflow.Application/Features/WorkflowDefinitions/Commands/CreateEdgeCommand.cs b/src/Workflow.Application/Features/WorkflowDefinitions/Commands/CreateEdgeCommand.cs
index ec7c788..70394f6 100644
--- a/src/Workflow.Application/Features/WorkflowDefinitions/Commands/CreateEdgeCommand.cs
+++ b/src/Workflow.Application/Features/WorkflowDefinitions/Commands/CreateEdgeCommand.cs
@@ -22,12 +22,20 @@ public class CreateEdgeCommandHandler(WorkflowDbContext db)
{
public async Task Handle(CreateEdgeCommand request, CancellationToken cancellationToken)
{
+ _ = await db.WorkflowDefinitions.FindAsync([request.DefinitionId], cancellationToken)
+ ?? throw new NotFoundException($"Workflow definition '{request.DefinitionId}' not found.");
+
var sourceNode = await db.WorkflowNodes.FindAsync([request.SourceNodeId], cancellationToken)
?? throw new NotFoundException($"Source node '{request.SourceNodeId}' not found.");
var targetNode = await db.WorkflowNodes.FindAsync([request.TargetNodeId], cancellationToken)
?? throw new NotFoundException($"Target node '{request.TargetNodeId}' not found.");
+ if (sourceNode.DefinitionId != request.DefinitionId || targetNode.DefinitionId != request.DefinitionId)
+ {
+ throw new BusinessException("Source and target nodes must belong to the same workflow definition as the edge.");
+ }
+
var entity = new WorkflowEdge
{
Id = Guid.NewGuid(),
diff --git a/src/Workflow.Application/Features/WorkflowDefinitions/Commands/CreateNodeCommand.cs b/src/Workflow.Application/Features/WorkflowDefinitions/Commands/CreateNodeCommand.cs
index 1b3331e..a3c67f4 100644
--- a/src/Workflow.Application/Features/WorkflowDefinitions/Commands/CreateNodeCommand.cs
+++ b/src/Workflow.Application/Features/WorkflowDefinitions/Commands/CreateNodeCommand.cs
@@ -14,7 +14,8 @@ public record CreateNodeCommand(
string Name,
string? Config,
int PositionX,
- int PositionY
+ int PositionY,
+ Guid? FormDefinitionId
) : IRequest;
public class CreateNodeCommandHandler(WorkflowDbContext db)
@@ -25,6 +26,24 @@ public class CreateNodeCommandHandler(WorkflowDbContext db)
var definition = await db.WorkflowDefinitions.FindAsync([request.DefinitionId], cancellationToken)
?? throw new NotFoundException($"Workflow definition '{request.DefinitionId}' not found.");
+ // 不变量:仅 Approval/Cc 节点可绑定表单。其它节点携带 FormDefinitionId 直接拒绝,
+ // 与 UI 契约(NodePropertyDrawer.vue:199)一致,避免脏数据被持久化后永不渲染。
+ if (request.FormDefinitionId.HasValue
+ && request.NodeType is not (NodeType.Approval or NodeType.Cc))
+ {
+ throw new BusinessException($"节点类型 {request.NodeType} 不支持绑定表单,仅审批(Approval)与抄送(Cc)节点可绑定表单");
+ }
+
+ string? formName = null;
+ if (request.FormDefinitionId.HasValue)
+ {
+ formName = await db.FormDefinitions
+ .Where(f => f.Id == request.FormDefinitionId.Value)
+ .Select(f => f.Name)
+ .FirstOrDefaultAsync(cancellationToken)
+ ?? throw new NotFoundException($"Form definition '{request.FormDefinitionId.Value}' not found.");
+ }
+
var entity = new WorkflowNode
{
Id = Guid.NewGuid(),
@@ -33,7 +52,8 @@ public class CreateNodeCommandHandler(WorkflowDbContext db)
Name = request.Name,
Config = request.Config,
PositionX = request.PositionX,
- PositionY = request.PositionY
+ PositionY = request.PositionY,
+ FormDefinitionId = request.FormDefinitionId
};
db.WorkflowNodes.Add(entity);
@@ -45,7 +65,9 @@ public class CreateNodeCommandHandler(WorkflowDbContext db)
entity.Name,
entity.Config,
entity.PositionX,
- entity.PositionY
+ entity.PositionY,
+ entity.FormDefinitionId,
+ formName
);
}
}
diff --git a/src/Workflow.Application/Features/WorkflowDefinitions/Commands/DeleteNodeCommand.cs b/src/Workflow.Application/Features/WorkflowDefinitions/Commands/DeleteNodeCommand.cs
index a7c63b0..6a2878e 100644
--- a/src/Workflow.Application/Features/WorkflowDefinitions/Commands/DeleteNodeCommand.cs
+++ b/src/Workflow.Application/Features/WorkflowDefinitions/Commands/DeleteNodeCommand.cs
@@ -1,4 +1,5 @@
using MediatR;
+using Microsoft.EntityFrameworkCore;
using Workflow.Domain.Exceptions;
using Workflow.Infrastructure.Persistence;
@@ -14,6 +15,11 @@ public class DeleteNodeCommandHandler(WorkflowDbContext db)
var entity = await db.WorkflowNodes.FindAsync([request.NodeId], cancellationToken)
?? throw new NotFoundException($"Workflow node '{request.NodeId}' not found.");
+ var connectedEdges = await db.WorkflowEdges
+ .Where(e => e.SourceNodeId == request.NodeId || e.TargetNodeId == request.NodeId)
+ .ToListAsync(cancellationToken);
+
+ db.WorkflowEdges.RemoveRange(connectedEdges);
db.WorkflowNodes.Remove(entity);
await db.SaveChangesAsync(cancellationToken);
diff --git a/src/Workflow.Application/Features/WorkflowDefinitions/Commands/PublishWorkflowDefinitionCommand.cs b/src/Workflow.Application/Features/WorkflowDefinitions/Commands/PublishWorkflowDefinitionCommand.cs
index cdf8e11..40829cd 100644
--- a/src/Workflow.Application/Features/WorkflowDefinitions/Commands/PublishWorkflowDefinitionCommand.cs
+++ b/src/Workflow.Application/Features/WorkflowDefinitions/Commands/PublishWorkflowDefinitionCommand.cs
@@ -5,8 +5,13 @@ using Workflow.Infrastructure.Persistence;
namespace Workflow.Application.Features.WorkflowDefinitions.Commands;
+/// 发布流程定义命令:仅 Draft 状态可发布,发布后内容不可再改且可被实例化。
public record PublishWorkflowDefinitionCommand(Guid Id) : IRequest;
+///
+/// 发布处理器:将状态从 Draft 切换为 Published 并自增版本号。
+/// 版本自增保证每次发布都产生新的不可变快照,已运行的旧实例仍绑定其启动时的版本。
+///
public class PublishWorkflowDefinitionCommandHandler(WorkflowDbContext db)
: IRequestHandler
{
@@ -15,6 +20,7 @@ public class PublishWorkflowDefinitionCommandHandler(WorkflowDbContext db)
var entity = await db.WorkflowDefinitions.FindAsync([request.Id], cancellationToken)
?? throw new NotFoundException($"Workflow definition '{request.Id}' not found.");
+ // 仅草稿可发布:防止已发布/已停用的定义被重复发布,保证版本语义清晰
if (entity.Status != DefinitionStatus.Draft)
{
throw new BusinessException("Only draft workflow definitions can be published.");
diff --git a/src/Workflow.Application/Features/WorkflowDefinitions/Commands/UpdateNodeCommand.cs b/src/Workflow.Application/Features/WorkflowDefinitions/Commands/UpdateNodeCommand.cs
index 988044b..fa024c2 100644
--- a/src/Workflow.Application/Features/WorkflowDefinitions/Commands/UpdateNodeCommand.cs
+++ b/src/Workflow.Application/Features/WorkflowDefinitions/Commands/UpdateNodeCommand.cs
@@ -1,5 +1,7 @@
using MediatR;
+using Microsoft.EntityFrameworkCore;
using Workflow.Application.Features.WorkflowDefinitions.DTOs;
+using Workflow.Domain.Enums;
using Workflow.Domain.Exceptions;
using Workflow.Infrastructure.Persistence;
@@ -10,7 +12,8 @@ public record UpdateNodeCommand(
string Name,
string? Config,
int PositionX,
- int PositionY
+ int PositionY,
+ Guid? FormDefinitionId
) : IRequest;
public class UpdateNodeCommandHandler(WorkflowDbContext db)
@@ -21,10 +24,29 @@ public class UpdateNodeCommandHandler(WorkflowDbContext db)
var entity = await db.WorkflowNodes.FindAsync([request.NodeId], cancellationToken)
?? throw new NotFoundException($"Workflow node '{request.NodeId}' not found.");
+ // 不变量:仅 Approval/Cc 节点可绑定表单。UpdateNodeCommand 不含 NodeType,
+ // 故校验基于实体当前的 NodeType。与 UI 契约(NodePropertyDrawer.vue:199)一致。
+ if (request.FormDefinitionId.HasValue
+ && entity.NodeType is not (NodeType.Approval or NodeType.Cc))
+ {
+ throw new BusinessException($"节点类型 {entity.NodeType} 不支持绑定表单,仅审批(Approval)与抄送(Cc)节点可绑定表单");
+ }
+
+ string? formName = null;
+ if (request.FormDefinitionId.HasValue)
+ {
+ formName = await db.FormDefinitions
+ .Where(f => f.Id == request.FormDefinitionId.Value)
+ .Select(f => f.Name)
+ .FirstOrDefaultAsync(cancellationToken)
+ ?? throw new NotFoundException($"Form definition '{request.FormDefinitionId.Value}' not found.");
+ }
+
entity.Name = request.Name;
entity.Config = request.Config;
entity.PositionX = request.PositionX;
entity.PositionY = request.PositionY;
+ entity.FormDefinitionId = request.FormDefinitionId;
await db.SaveChangesAsync(cancellationToken);
@@ -34,7 +56,9 @@ public class UpdateNodeCommandHandler(WorkflowDbContext db)
entity.Name,
entity.Config,
entity.PositionX,
- entity.PositionY
+ entity.PositionY,
+ entity.FormDefinitionId,
+ formName
);
}
}
diff --git a/src/Workflow.Application/Features/WorkflowDefinitions/DTOs/WorkflowDefinitionDTOs.cs b/src/Workflow.Application/Features/WorkflowDefinitions/DTOs/WorkflowDefinitionDTOs.cs
index 8a3b148..170202a 100644
--- a/src/Workflow.Application/Features/WorkflowDefinitions/DTOs/WorkflowDefinitionDTOs.cs
+++ b/src/Workflow.Application/Features/WorkflowDefinitions/DTOs/WorkflowDefinitionDTOs.cs
@@ -36,7 +36,9 @@ public record WorkflowNodeDto(
string Name,
string? Config,
int PositionX,
- int PositionY
+ int PositionY,
+ Guid? FormDefinitionId,
+ string? FormName
);
public record WorkflowEdgeDto(
diff --git a/src/Workflow.Application/Features/WorkflowDefinitions/Queries/GetWorkflowDefinitionByIdQuery.cs b/src/Workflow.Application/Features/WorkflowDefinitions/Queries/GetWorkflowDefinitionByIdQuery.cs
index 910de7a..693bfea 100644
--- a/src/Workflow.Application/Features/WorkflowDefinitions/Queries/GetWorkflowDefinitionByIdQuery.cs
+++ b/src/Workflow.Application/Features/WorkflowDefinitions/Queries/GetWorkflowDefinitionByIdQuery.cs
@@ -28,6 +28,18 @@ public class GetWorkflowDefinitionByIdQueryHandler(WorkflowDbContext db)
.FirstOrDefaultAsync(cancellationToken);
}
+ var nodeFormIds = entity.Nodes
+ .Where(n => n.FormDefinitionId.HasValue)
+ .Select(n => n.FormDefinitionId!.Value)
+ .Distinct()
+ .ToList();
+
+ var nodeFormNames = nodeFormIds.Count == 0
+ ? new Dictionary()
+ : await db.FormDefinitions
+ .Where(f => nodeFormIds.Contains(f.Id))
+ .ToDictionaryAsync(f => f.Id, f => f.Name, cancellationToken);
+
return new WorkflowDefinitionDetailDto(
entity.Id,
entity.Name,
@@ -45,7 +57,11 @@ public class GetWorkflowDefinitionByIdQueryHandler(WorkflowDbContext db)
n.Name,
n.Config,
n.PositionX,
- n.PositionY
+ n.PositionY,
+ n.FormDefinitionId,
+ n.FormDefinitionId.HasValue && nodeFormNames.TryGetValue(n.FormDefinitionId.Value, out var nodeFormName)
+ ? nodeFormName
+ : null
)).ToList(),
entity.Edges.Select(e => new WorkflowEdgeDto(
e.Id,
diff --git a/src/Workflow.Application/Features/WorkflowInstances/Commands/StartWorkflowInstanceCommand.cs b/src/Workflow.Application/Features/WorkflowInstances/Commands/StartWorkflowInstanceCommand.cs
index 6521b4b..40cd1bb 100644
--- a/src/Workflow.Application/Features/WorkflowInstances/Commands/StartWorkflowInstanceCommand.cs
+++ b/src/Workflow.Application/Features/WorkflowInstances/Commands/StartWorkflowInstanceCommand.cs
@@ -1,6 +1,9 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
+using System.Text.Json;
+using System.Text.Json.Nodes;
using Workflow.Application.Engine;
+using Workflow.Application.Form.Schema;
using Workflow.Domain.Entities;
using Workflow.Domain.Enums;
using Workflow.Domain.Exceptions;
@@ -8,6 +11,7 @@ using Workflow.Infrastructure.Persistence;
namespace Workflow.Application.Features.WorkflowInstances.Commands;
+/// 启动流程实例命令:按 DefinitionCode 解析定义 → 校验表单数据 → 创建实例 → 落库初始表单数据 → 触发引擎从 Start 节点传播 token。
public record StartWorkflowInstanceCommand(
string DefinitionCode,
string Title,
@@ -15,6 +19,10 @@ public record StartWorkflowInstanceCommand(
string? FormDataJson
) : IRequest;
+///
+/// 启动流程实例处理器。流程变量来源优先级:显式 Variables 覆盖 FormDataJson 字段
+/// (通过 BuildVariablesJson 合并),保证表单字段既能作为条件求值变量,又可被显式变量覆写。
+///
public class StartWorkflowInstanceCommandHandler(WorkflowDbContext db, ProcessEngine processEngine)
: IRequestHandler
{
@@ -29,13 +37,45 @@ public class StartWorkflowInstanceCommandHandler(WorkflowDbContext db, ProcessEn
throw new BusinessException($"流程定义 '{request.DefinitionCode}' 已禁用");
}
+ FormDefinition? startFormDefinition = null;
+ if (!string.IsNullOrEmpty(request.FormDataJson) && definition.FormDefinitionId.HasValue)
+ {
+ // 绕过软删除过滤器加载表单定义,以区分「已被删除」与「确实不存在」两种情况,
+ // 避免抛出误导性的「不存在」错误(表单实际存在,只是被删除)。
+ startFormDefinition = await db.FormDefinitions
+ .IgnoreQueryFilters()
+ .FirstOrDefaultAsync(f => f.Id == definition.FormDefinitionId.Value, cancellationToken);
+
+ if (startFormDefinition is null)
+ {
+ throw new BusinessException($"表单定义 {definition.FormDefinitionId.Value} 不存在");
+ }
+
+ if (startFormDefinition.IsDeleted)
+ {
+ throw new BusinessException($"表单定义 {startFormDefinition.Name}({definition.FormDefinitionId.Value})已被删除,无法启动流程");
+ }
+
+ // 产品决策:FormStatus.Disabled 严格阻断——停用的表单不允许启动新流程。
+ if (startFormDefinition.Status == FormStatus.Disabled)
+ {
+ throw new BusinessException($"表单定义 {startFormDefinition.Name}({definition.FormDefinitionId.Value})已停用,无法启动流程");
+ }
+
+ var validation = FormDataValidator.Validate(startFormDefinition.SchemaJson ?? "{}", request.FormDataJson);
+ if (!validation.IsValid)
+ {
+ throw new BusinessException($"表单数据校验失败: {string.Join("; ", validation.Errors)}");
+ }
+ }
+
var instance = new WorkflowInstance
{
Id = Guid.NewGuid(),
DefinitionId = definition.Id,
Title = request.Title,
Status = InstanceStatus.Running,
- Variables = request.Variables,
+ Variables = BuildVariablesJson(request.FormDataJson, request.Variables),
InitiatorId = Guid.Empty
};
@@ -43,12 +83,12 @@ public class StartWorkflowInstanceCommandHandler(WorkflowDbContext db, ProcessEn
await db.SaveChangesAsync(cancellationToken);
// Save form data if provided and definition has an associated form
- if (!string.IsNullOrEmpty(request.FormDataJson) && definition.FormDefinitionId.HasValue)
+ if (!string.IsNullOrEmpty(request.FormDataJson) && startFormDefinition is not null)
{
db.FormData.Add(new Domain.Entities.FormData
{
Id = Guid.NewGuid(),
- FormDefinitionId = definition.FormDefinitionId.Value,
+ FormDefinitionId = startFormDefinition.Id,
InstanceId = instance.Id,
DataJson = request.FormDataJson,
});
@@ -60,4 +100,54 @@ public class StartWorkflowInstanceCommandHandler(WorkflowDbContext db, ProcessEn
return instance.Id;
}
+
+ ///
+ /// 合并流程变量:先合并表单数据,再合并显式变量(后者覆盖前者同名字段)。
+ /// 合并语义:表单字段既可作为条件变量,又允许调用方显式覆写;任一来源为非法 JSON 时静默跳过。
+ /// 全部为空时返回 null,表示实例无初始变量。
+ ///
+ private static string? BuildVariablesJson(string? formDataJson, string? variablesJson)
+ {
+ var merged = new JsonObject();
+ var hasMergedValues = false;
+
+ hasMergedValues |= TryMergeObject(merged, formDataJson);
+
+ if (string.IsNullOrWhiteSpace(variablesJson))
+ {
+ return hasMergedValues ? merged.ToJsonString() : null;
+ }
+
+ var variablesMerged = TryMergeObject(merged, variablesJson);
+ if (!variablesMerged && !hasMergedValues)
+ {
+ return variablesJson;
+ }
+
+ return merged.ToJsonString();
+ }
+
+ private static bool TryMergeObject(JsonObject target, string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return false;
+
+ try
+ {
+ var node = JsonNode.Parse(json);
+ if (node is not JsonObject obj)
+ return false;
+
+ foreach (var kvp in obj)
+ {
+ target[kvp.Key] = kvp.Value?.DeepClone();
+ }
+
+ return obj.Count > 0;
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
+ }
}
diff --git a/src/Workflow.Application/Features/WorkflowInstances/Commands/WithdrawWorkflowInstanceCommand.cs b/src/Workflow.Application/Features/WorkflowInstances/Commands/WithdrawWorkflowInstanceCommand.cs
index 063fd95..1aa3139 100644
--- a/src/Workflow.Application/Features/WorkflowInstances/Commands/WithdrawWorkflowInstanceCommand.cs
+++ b/src/Workflow.Application/Features/WorkflowInstances/Commands/WithdrawWorkflowInstanceCommand.cs
@@ -7,11 +7,16 @@ using Workflow.Infrastructure.Persistence;
namespace Workflow.Application.Features.WorkflowInstances.Commands;
+/// 撤回流程实例命令。仅发起人可撤回,且需流程尚未处理任何节点(无已结束任务)。
public record WithdrawWorkflowInstanceCommand(
Guid InstanceId,
Guid UserId
) : IRequest;
+///
+/// 撤回处理器:通过 InstanceStateMachine 校验转换合法性后,将实例置为 Terminated,
+/// 并同步终止所有活跃 token,防止引擎继续推进已撤回的流程。
+///
public class WithdrawWorkflowInstanceCommandHandler(WorkflowDbContext db)
: IRequestHandler
{
@@ -24,6 +29,7 @@ public class WithdrawWorkflowInstanceCommandHandler(WorkflowDbContext db)
.Where(t => t.InstanceId == request.InstanceId)
.ToListAsync(cancellationToken);
+ // 已处理节点判定:存在任何非 Pending 任务即视为流程已推进,不可撤回
var hasProcessedTasks = tasks.Any(t => t.Status != Domain.Enums.TaskStatus.Pending);
var context = new InstanceTransitionContext
@@ -34,6 +40,7 @@ public class WithdrawWorkflowInstanceCommandHandler(WorkflowDbContext db)
var stateMachine = new InstanceStateMachine();
instance.Status = stateMachine.Transition(instance.Status, InstanceOperation.Withdraw, context);
+ // 同步终止所有 token,切断流程图的执行控制权,避免遗留 Active token 被引擎继续推进
var tokens = await db.WorkflowTokens
.Where(t => t.InstanceId == request.InstanceId)
.ToListAsync(cancellationToken);
diff --git a/src/Workflow.Application/Features/WorkflowInstances/Queries/MonitorWorkflowInstancesQuery.cs b/src/Workflow.Application/Features/WorkflowInstances/Queries/MonitorWorkflowInstancesQuery.cs
index 8e54bad..f46d0da 100644
--- a/src/Workflow.Application/Features/WorkflowInstances/Queries/MonitorWorkflowInstancesQuery.cs
+++ b/src/Workflow.Application/Features/WorkflowInstances/Queries/MonitorWorkflowInstancesQuery.cs
@@ -8,6 +8,7 @@ using TaskStatus = Workflow.Domain.Enums.TaskStatus;
namespace Workflow.Application.Features.WorkflowInstances.Queries;
+/// 流程监控统计查询:聚合实例各状态数量与待办/逾期任务数,供监控大屏展示。
public record MonitorWorkflowInstancesQuery : IRequest;
public class MonitorWorkflowInstancesQueryHandler(WorkflowDbContext db)
@@ -22,6 +23,7 @@ public class MonitorWorkflowInstancesQueryHandler(WorkflowDbContext db)
var terminatedInstances = await db.WorkflowInstances.CountAsync(i => i.Status == InstanceStatus.Terminated, cancellationToken);
var pendingTasks = await db.WorkflowTasks.CountAsync(t => t.Status == TaskStatus.Pending, cancellationToken);
+ // 逾期判定:仅统计 Pending 且 DueAt 早于当前 UTC 时间的任务。DueAt 与比较基准均使用 UTC,避免时区偏差
var overdueTasks = await db.WorkflowTasks
.CountAsync(t => t.Status == TaskStatus.Pending && t.DueAt != null && t.DueAt < DateTime.UtcNow, cancellationToken);
diff --git a/src/Workflow.Application/Features/WorkflowTasks/Commands/ApproveTaskCommand.cs b/src/Workflow.Application/Features/WorkflowTasks/Commands/ApproveTaskCommand.cs
index 6443265..674a8ee 100644
--- a/src/Workflow.Application/Features/WorkflowTasks/Commands/ApproveTaskCommand.cs
+++ b/src/Workflow.Application/Features/WorkflowTasks/Commands/ApproveTaskCommand.cs
@@ -1,17 +1,28 @@
using MediatR;
+using Microsoft.EntityFrameworkCore;
+using System.Text.Json;
+using System.Text.Json.Nodes;
using Workflow.Application.Engine;
+using Workflow.Application.Form.Schema;
+using Workflow.Domain.Entities;
using Workflow.Domain.Enums;
using Workflow.Domain.Exceptions;
using Workflow.Infrastructure.Persistence;
namespace Workflow.Application.Features.WorkflowTasks.Commands;
+/// 审批通过任务命令。仅任务受理人(AssigneeId)可执行,可选提交节点表单数据。
public record ApproveTaskCommand(
Guid TaskId,
Guid UserId,
- string? Comment
+ string? Comment,
+ string? FormDataJson = null
) : IRequest;
+///
+/// 审批通过处理器:校验受理人身份 → 持久化表单数据(若提供)→ 合并表单字段到实例变量 →
+/// 调用引擎 CompleteTaskAsync 推进流程。表单字段合并进变量后即可被下游条件节点用于求值。
+///
public class ApproveTaskCommandHandler(WorkflowDbContext db, ProcessEngine processEngine)
: IRequestHandler
{
@@ -20,6 +31,7 @@ public class ApproveTaskCommandHandler(WorkflowDbContext db, ProcessEngine proce
var task = await db.WorkflowTasks.FindAsync([request.TaskId], cancellationToken)
?? throw new NotFoundException($"Task '{request.TaskId}' not found.");
+ // 受理人鉴权:仅任务指定的 AssigneeId 可审批,防止越权操作他人任务
if (task.AssigneeId != request.UserId)
{
throw new UnauthorizedException("Only the assignee can approve this task.");
@@ -27,8 +39,108 @@ public class ApproveTaskCommandHandler(WorkflowDbContext db, ProcessEngine proce
task.Comment = request.Comment;
+ if (!string.IsNullOrWhiteSpace(request.FormDataJson))
+ {
+ await SaveNodeFormDataAsync(task, request.FormDataJson, cancellationToken);
+ }
+
await processEngine.CompleteTaskAsync(task, TaskResult.Approved);
return Unit.Value;
}
+
+ private async Task SaveNodeFormDataAsync(
+ WorkflowTask task,
+ string formDataJson,
+ CancellationToken cancellationToken)
+ {
+ var node = await db.WorkflowNodes
+ .AsNoTracking()
+ .FirstOrDefaultAsync(n => n.Id == task.NodeId, cancellationToken)
+ ?? throw new BusinessException("Task node not found");
+
+ if (!node.FormDefinitionId.HasValue)
+ {
+ throw new BusinessException("当前任务节点未绑定表单,无法提交表单数据");
+ }
+
+ // 绕过软删除过滤器加载表单定义,以区分「已被删除」与「确实不存在」两种情况,
+ // 避免抛出误导性的「不存在」错误(表单实际存在,只是被删除)。
+ var formDefinition = await db.FormDefinitions
+ .IgnoreQueryFilters()
+ .AsNoTracking()
+ .FirstOrDefaultAsync(f => f.Id == node.FormDefinitionId.Value, cancellationToken);
+
+ if (formDefinition is null)
+ {
+ throw new BusinessException($"节点绑定的表单定义 {node.FormDefinitionId.Value} 不存在");
+ }
+
+ if (formDefinition.IsDeleted)
+ {
+ throw new BusinessException($"节点绑定的表单定义 {formDefinition.Name}({node.FormDefinitionId.Value})已被删除,无法提交表单数据");
+ }
+
+ // 产品决策:FormStatus.Disabled 严格阻断——停用的表单不接受审批提交。
+ if (formDefinition.Status == FormStatus.Disabled)
+ {
+ throw new BusinessException($"节点绑定的表单定义 {formDefinition.Name}({node.FormDefinitionId.Value})已停用,无法提交表单数据");
+ }
+
+ var validation = FormDataValidator.Validate(
+ formDefinition.SchemaJson ?? "{}",
+ formDataJson,
+ currentNodeKey: node.Name);
+ if (!validation.IsValid)
+ {
+ throw new BusinessException($"表单数据校验失败: {string.Join("; ", validation.Errors)}");
+ }
+
+ var instance = await db.WorkflowInstances
+ .FirstOrDefaultAsync(i => i.Id == task.InstanceId, cancellationToken)
+ ?? throw new BusinessException("Instance not found");
+
+ db.FormData.Add(new FormData
+ {
+ Id = Guid.NewGuid(),
+ FormDefinitionId = formDefinition.Id,
+ InstanceId = task.InstanceId,
+ DataJson = formDataJson,
+ });
+
+ instance.Variables = MergeVariablesJson(instance.Variables, formDataJson);
+ }
+
+ ///
+ /// 将节点表单字段合并进实例变量:表单字段覆盖同名已有变量,
+ /// 使审批中填写的表单数据可被下游 Condition 节点作为求值依据。
+ ///
+ private static string MergeVariablesJson(string? variablesJson, string formDataJson)
+ {
+ var merged = new JsonObject();
+ TryMergeObject(merged, variablesJson);
+ TryMergeObject(merged, formDataJson);
+ return merged.ToJsonString();
+ }
+
+ private static void TryMergeObject(JsonObject target, string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return;
+
+ try
+ {
+ if (JsonNode.Parse(json) is not JsonObject obj)
+ return;
+
+ foreach (var kvp in obj)
+ {
+ target[kvp.Key] = kvp.Value?.DeepClone();
+ }
+ }
+ catch (JsonException)
+ {
+ // Invalid existing variables should not prevent a valid task form from being stored.
+ }
+ }
}
diff --git a/src/Workflow.Application/Features/WorkflowTasks/Commands/DelegateTaskCommand.cs b/src/Workflow.Application/Features/WorkflowTasks/Commands/DelegateTaskCommand.cs
index 5eb5f0a..3ad7045 100644
--- a/src/Workflow.Application/Features/WorkflowTasks/Commands/DelegateTaskCommand.cs
+++ b/src/Workflow.Application/Features/WorkflowTasks/Commands/DelegateTaskCommand.cs
@@ -22,6 +22,24 @@ public class DelegateTaskCommandHandler(WorkflowDbContext db)
var task = await db.WorkflowTasks.FindAsync([request.TaskId], cancellationToken)
?? throw new NotFoundException($"Task '{request.TaskId}' not found.");
+ // 状态校验:仅 Pending 任务可委派,避免重复推进或委派已完结任务。
+ if (task.Status != TaskStatus.Pending)
+ {
+ throw new BusinessException($"任务 '{request.TaskId}' 当前状态为 {task.Status},不可委派(仅待处理任务可委派)");
+ }
+
+ // 授权校验:仅当前 assignee 可委派,与 Approve/Reject/Transfer 一致。
+ if (task.AssigneeId != request.FromUserId)
+ {
+ throw new UnauthorizedException("只有当前办理人可以委派此任务。");
+ }
+
+ // 禁止委派给自己(无意义且会造成脏数据)。
+ if (request.FromUserId == request.ToUserId)
+ {
+ throw new BusinessException("不能将任务委派给自己。");
+ }
+
// Create a new delegated task for the target user
var newTask = new WorkflowTask
{
diff --git a/src/Workflow.Application/Features/WorkflowTasks/Commands/MarkCcTaskReadCommand.cs b/src/Workflow.Application/Features/WorkflowTasks/Commands/MarkCcTaskReadCommand.cs
new file mode 100644
index 0000000..a5a3bfe
--- /dev/null
+++ b/src/Workflow.Application/Features/WorkflowTasks/Commands/MarkCcTaskReadCommand.cs
@@ -0,0 +1,53 @@
+using MediatR;
+using Workflow.Domain.Enums;
+using Workflow.Domain.Exceptions;
+using Workflow.Infrastructure.Persistence;
+
+using TaskStatus = Workflow.Domain.Enums.TaskStatus;
+
+namespace Workflow.Application.Features.WorkflowTasks.Commands;
+
+///
+/// 标记 Cc(抄送)任务为已读。仅适用于 Cc 任务:Cc 为知会性质,不参与 token 路由,
+/// 故此处仅更新任务状态,不调用 ProcessEngine。
+/// 解决 Cc 任务永久处于 Pending、无法清理的功能缺口。
+///
+public record MarkCcTaskReadCommand(
+ Guid TaskId,
+ Guid UserId
+) : IRequest;
+
+public class MarkCcTaskReadCommandHandler(WorkflowDbContext db)
+ : IRequestHandler
+{
+ public async Task Handle(MarkCcTaskReadCommand request, CancellationToken cancellationToken)
+ {
+ var task = await db.WorkflowTasks.FindAsync([request.TaskId], cancellationToken)
+ ?? throw new NotFoundException($"Task '{request.TaskId}' not found.");
+
+ // 仅 Cc 任务可标记已读:审批/转办等任务有各自的生命周期,不应通过此路径变更状态。
+ if (task.Type != TaskType.Cc)
+ {
+ throw new BusinessException($"任务 '{request.TaskId}' 不是抄送(Cc)任务,不可标记已读");
+ }
+
+ // 授权校验:仅 assignee 可标记自己的抄送任务已读。
+ if (task.AssigneeId != request.UserId)
+ {
+ throw new UnauthorizedException("只有任务办理人可以标记此抄送任务为已读。");
+ }
+
+ // 幂等/防重复:已读的 Cc 任务不可重复标记。
+ if (task.Status != TaskStatus.Pending)
+ {
+ throw new BusinessException($"抄送任务 '{request.TaskId}' 已是 {task.Status} 状态,不可重复标记已读");
+ }
+
+ task.Status = TaskStatus.Read;
+ task.CompletedAt = DateTime.UtcNow;
+
+ await db.SaveChangesAsync(cancellationToken);
+
+ return Unit.Value;
+ }
+}
diff --git a/src/Workflow.Application/Features/WorkflowTasks/Commands/RejectTaskCommand.cs b/src/Workflow.Application/Features/WorkflowTasks/Commands/RejectTaskCommand.cs
index 272d3ff..79cf04c 100644
--- a/src/Workflow.Application/Features/WorkflowTasks/Commands/RejectTaskCommand.cs
+++ b/src/Workflow.Application/Features/WorkflowTasks/Commands/RejectTaskCommand.cs
@@ -6,12 +6,17 @@ using Workflow.Infrastructure.Persistence;
namespace Workflow.Application.Features.WorkflowTasks.Commands;
+/// 驳回任务命令。仅任务受理人(AssigneeId)可执行,引擎将沿 Rejected 边向下传播。
public record RejectTaskCommand(
Guid TaskId,
Guid UserId,
string? Comment
) : IRequest;
+///
+/// 驳回处理器:校验受理人身份后调用引擎 CompleteTaskAsync(TaskResult.Rejected),
+/// 引擎会寻找 Rejected 类型的出边继续推进;若无 Rejected 边则抛 BusinessException。
+///
public class RejectTaskCommandHandler(WorkflowDbContext db, ProcessEngine processEngine)
: IRequestHandler
{
@@ -20,6 +25,7 @@ public class RejectTaskCommandHandler(WorkflowDbContext db, ProcessEngine proces
var task = await db.WorkflowTasks.FindAsync([request.TaskId], cancellationToken)
?? throw new NotFoundException($"Task '{request.TaskId}' not found.");
+ // 受理人鉴权:仅 AssigneeId 可驳回
if (task.AssigneeId != request.UserId)
{
throw new UnauthorizedException("Only the assignee can reject this task.");
diff --git a/src/Workflow.Application/Features/WorkflowTasks/Commands/TransferTaskCommand.cs b/src/Workflow.Application/Features/WorkflowTasks/Commands/TransferTaskCommand.cs
index f8d9c42..5fb970b 100644
--- a/src/Workflow.Application/Features/WorkflowTasks/Commands/TransferTaskCommand.cs
+++ b/src/Workflow.Application/Features/WorkflowTasks/Commands/TransferTaskCommand.cs
@@ -22,6 +22,24 @@ public class TransferTaskCommandHandler(WorkflowDbContext db)
var task = await db.WorkflowTasks.FindAsync([request.TaskId], cancellationToken)
?? throw new NotFoundException($"Task '{request.TaskId}' not found.");
+ // 状态校验:仅 Pending 任务可转办,避免重复推进或转办已完结任务。
+ if (task.Status != TaskStatus.Pending)
+ {
+ throw new BusinessException($"任务 '{request.TaskId}' 当前状态为 {task.Status},不可转办(仅待处理任务可转办)");
+ }
+
+ // 授权校验:仅当前 assignee 可转办,与 Approve/Reject 一致。
+ if (task.AssigneeId != request.FromUserId)
+ {
+ throw new UnauthorizedException("只有当前办理人可以转办此任务。");
+ }
+
+ // 禁止转办给自己(无意义且会造成脏数据)。
+ if (request.FromUserId == request.ToUserId)
+ {
+ throw new BusinessException("不能将任务转办给自己。");
+ }
+
// Create a new task for the target user
var newTask = new WorkflowTask
{
diff --git a/src/Workflow.Application/Features/WorkflowTasks/Commands/UrgeTaskCommand.cs b/src/Workflow.Application/Features/WorkflowTasks/Commands/UrgeTaskCommand.cs
index dbadb0c..9bb280d 100644
--- a/src/Workflow.Application/Features/WorkflowTasks/Commands/UrgeTaskCommand.cs
+++ b/src/Workflow.Application/Features/WorkflowTasks/Commands/UrgeTaskCommand.cs
@@ -1,4 +1,5 @@
using MediatR;
+using Workflow.Application.Notifications;
using Workflow.Domain.Enums;
using Workflow.Domain.Exceptions;
using Workflow.Infrastructure.Persistence;
@@ -12,7 +13,13 @@ public record UrgeTaskCommand(
Guid UserId
) : IRequest;
-public class UrgeTaskCommandHandler(WorkflowDbContext db)
+///
+/// 催办命令:校验任务为 Pending 后,通过 INotificationService 向受理人发送催办通知。
+/// 通知落库随主事务提交(必达)。INotificationService 未注册时静默跳过(单元测试场景)。
+///
+public class UrgeTaskCommandHandler(
+ WorkflowDbContext db,
+ INotificationService? notifier = null)
: IRequestHandler
{
public async Task Handle(UrgeTaskCommand request, CancellationToken cancellationToken)
@@ -25,9 +32,11 @@ public class UrgeTaskCommandHandler(WorkflowDbContext db)
throw new BusinessException("Only pending tasks can be urged.");
}
- // Urge logic: in a real system this would send a notification
- // For now, we validate the task is pending and return success
- // Notification/email sending would be handled by a domain event or outbox pattern
+ // 催办通知:通知受理人尽快处理。通知落库随下方 SaveChangesAsync 一起提交。
+ if (notifier is not null)
+ {
+ await notifier.NotifyTaskUrgedAsync(task, cancellationToken);
+ }
await db.SaveChangesAsync(cancellationToken);
diff --git a/src/Workflow.Application/Features/WorkflowTasks/DTOs/WorkflowTaskDTOs.cs b/src/Workflow.Application/Features/WorkflowTasks/DTOs/WorkflowTaskDTOs.cs
index 7a2b645..fcafc9c 100644
--- a/src/Workflow.Application/Features/WorkflowTasks/DTOs/WorkflowTaskDTOs.cs
+++ b/src/Workflow.Application/Features/WorkflowTasks/DTOs/WorkflowTaskDTOs.cs
@@ -11,3 +11,20 @@ public record WorkflowTaskListItemDto(
string? Title,
DateTime? CompletedAt
);
+
+public record WorkflowTaskDetailDto(
+ Guid Id,
+ Guid InstanceId,
+ Guid TokenId,
+ Guid NodeId,
+ string? NodeName,
+ Guid? AssigneeId,
+ TaskStatus Status,
+ string? Title,
+ DateTime? CompletedAt,
+ Guid? FormDefinitionId,
+ string? FormName,
+ string? FormSchemaJson,
+ string? NodeFormDataJson,
+ string? InstanceFormDataJson
+);
diff --git a/src/Workflow.Application/Features/WorkflowTasks/Queries/GetOverdueTasksQuery.cs b/src/Workflow.Application/Features/WorkflowTasks/Queries/GetOverdueTasksQuery.cs
index 7e00625..5482b94 100644
--- a/src/Workflow.Application/Features/WorkflowTasks/Queries/GetOverdueTasksQuery.cs
+++ b/src/Workflow.Application/Features/WorkflowTasks/Queries/GetOverdueTasksQuery.cs
@@ -9,6 +9,7 @@ using TaskStatus = Workflow.Domain.Enums.TaskStatus;
namespace Workflow.Application.Features.WorkflowTasks.Queries;
+/// 逾期任务查询:Pending 且 DueAt 已早于当前 UTC 时间。可按 UserId 过滤为"我的逾期任务"。
public record GetOverdueTasksQuery(
Guid? UserId,
int PageIndex,
@@ -20,9 +21,11 @@ public class GetOverdueTasksQueryHandler(WorkflowDbContext db)
{
public async Task> Handle(GetOverdueTasksQuery request, CancellationToken cancellationToken)
{
+ // 逾期判定基准统一为 UTC;按 DueAt 升序,最紧迫的逾期任务排在前
var query = db.WorkflowTasks
.Where(t => t.Status == TaskStatus.Pending && t.DueAt != null && t.DueAt < DateTime.UtcNow);
+ // UserId 过滤:传入则只看该用户的逾期任务,未传则返回全员逾期视图(管理端用)
if (request.UserId.HasValue)
{
query = query.Where(t => t.AssigneeId == request.UserId.Value);
diff --git a/src/Workflow.Application/Features/WorkflowTasks/Queries/GetPendingTasksQuery.cs b/src/Workflow.Application/Features/WorkflowTasks/Queries/GetPendingTasksQuery.cs
index ab2ed7e..48b2f31 100644
--- a/src/Workflow.Application/Features/WorkflowTasks/Queries/GetPendingTasksQuery.cs
+++ b/src/Workflow.Application/Features/WorkflowTasks/Queries/GetPendingTasksQuery.cs
@@ -9,6 +9,7 @@ using TaskStatus = Workflow.Domain.Enums.TaskStatus;
namespace Workflow.Application.Features.WorkflowTasks.Queries;
+/// 我的待办任务查询:按 AssigneeId 严格过滤,仅返回该用户的 Pending 任务。
public record GetPendingTasksQuery(
Guid UserId,
int PageIndex,
diff --git a/src/Workflow.Application/Features/WorkflowTasks/Queries/GetTaskByIdQuery.cs b/src/Workflow.Application/Features/WorkflowTasks/Queries/GetTaskByIdQuery.cs
index 5244508..ccff47e 100644
--- a/src/Workflow.Application/Features/WorkflowTasks/Queries/GetTaskByIdQuery.cs
+++ b/src/Workflow.Application/Features/WorkflowTasks/Queries/GetTaskByIdQuery.cs
@@ -6,24 +6,79 @@ using Workflow.Infrastructure.Persistence;
namespace Workflow.Application.Features.WorkflowTasks.Queries;
-public record GetTaskByIdQuery(Guid Id) : IRequest;
+public record GetTaskByIdQuery(Guid Id) : IRequest;
public class GetTaskByIdQueryHandler(WorkflowDbContext db)
- : IRequestHandler
+ : IRequestHandler
{
- public async Task Handle(GetTaskByIdQuery request, CancellationToken cancellationToken)
+ public async Task Handle(GetTaskByIdQuery request, CancellationToken cancellationToken)
{
- var task = await db.WorkflowTasks.FindAsync([request.Id], cancellationToken)
+ var task = await db.WorkflowTasks
+ .AsNoTracking()
+ .FirstOrDefaultAsync(t => t.Id == request.Id, cancellationToken)
?? throw new NotFoundException($"Task '{request.Id}' not found.");
- return new WorkflowTaskListItemDto(
+ var node = await db.WorkflowNodes
+ .AsNoTracking()
+ .FirstOrDefaultAsync(n => n.Id == task.NodeId, cancellationToken);
+
+ var formDefinition = node?.FormDefinitionId is null
+ ? null
+ : await db.FormDefinitions
+ .AsNoTracking()
+ .FirstOrDefaultAsync(f => f.Id == node.FormDefinitionId.Value, cancellationToken);
+
+ var nodeFormDataJson = node?.FormDefinitionId is null
+ ? null
+ : await db.FormData
+ .AsNoTracking()
+ .Where(f => f.InstanceId == task.InstanceId && f.FormDefinitionId == node.FormDefinitionId.Value)
+ .OrderByDescending(f => f.CreatedAt)
+ .Select(f => f.DataJson)
+ .FirstOrDefaultAsync(cancellationToken);
+
+ var instanceFormDataJson = await GetInstanceFormDataJsonAsync(task.InstanceId, cancellationToken);
+
+ return new WorkflowTaskDetailDto(
task.Id,
task.InstanceId,
task.TokenId,
+ task.NodeId,
+ node?.Name,
task.AssigneeId,
task.Status,
task.Title,
- task.CompletedAt
+ task.CompletedAt,
+ node?.FormDefinitionId,
+ formDefinition?.Name,
+ formDefinition?.SchemaJson,
+ nodeFormDataJson,
+ instanceFormDataJson
);
}
+
+ private async Task GetInstanceFormDataJsonAsync(Guid instanceId, CancellationToken cancellationToken)
+ {
+ var definitionFormId = await db.WorkflowInstances
+ .AsNoTracking()
+ .Where(i => i.Id == instanceId)
+ .Join(
+ db.WorkflowDefinitions.AsNoTracking(),
+ instance => instance.DefinitionId,
+ definition => definition.Id,
+ (_, definition) => definition.FormDefinitionId)
+ .FirstOrDefaultAsync(cancellationToken);
+
+ if (definitionFormId is null)
+ {
+ return null;
+ }
+
+ return await db.FormData
+ .AsNoTracking()
+ .Where(f => f.InstanceId == instanceId && f.FormDefinitionId == definitionFormId.Value)
+ .OrderByDescending(f => f.CreatedAt)
+ .Select(f => f.DataJson)
+ .FirstOrDefaultAsync(cancellationToken);
+ }
}
diff --git a/src/Workflow.Application/Form/DTOs/FormVersionDTOs.cs b/src/Workflow.Application/Form/DTOs/FormVersionDTOs.cs
new file mode 100644
index 0000000..2a647c3
--- /dev/null
+++ b/src/Workflow.Application/Form/DTOs/FormVersionDTOs.cs
@@ -0,0 +1,22 @@
+using Workflow.Application.Form.Schema;
+
+namespace Workflow.Application.Form.DTOs;
+
+/// 表单版本历史条目
+public class FormVersionDto
+{
+ public Guid Id { get; set; }
+ public int Version { get; set; }
+ public string? SchemaJson { get; set; }
+ public string Source { get; set; } = "Update";
+ public string? ChangeSummary { get; set; }
+ public DateTime CreatedAt { get; set; }
+}
+
+/// 两个版本的对比结果
+public class FormVersionCompareDto
+{
+ public FormVersionDto? OldVersion { get; set; }
+ public FormVersionDto? NewVersion { get; set; }
+ public SchemaDiff Diff { get; set; } = new([], [], []);
+}
diff --git a/src/Workflow.Application/Form/DTOs/PagedResult.cs b/src/Workflow.Application/Form/DTOs/PagedResult.cs
index b93eb5f..7407eae 100644
--- a/src/Workflow.Application/Form/DTOs/PagedResult.cs
+++ b/src/Workflow.Application/Form/DTOs/PagedResult.cs
@@ -3,7 +3,7 @@ namespace Workflow.Application.Form.DTOs;
public class PagedResult
{
public List Items { get; set; } = new();
- public int TotalCount { get; set; }
+ public int Total { get; set; }
public int PageIndex { get; set; }
public int PageSize { get; set; }
}
diff --git a/src/Workflow.Application/Form/FormData/Commands/SubmitFormDataCommand.cs b/src/Workflow.Application/Form/FormData/Commands/SubmitFormDataCommand.cs
index f9683bf..50fdfe3 100644
--- a/src/Workflow.Application/Form/FormData/Commands/SubmitFormDataCommand.cs
+++ b/src/Workflow.Application/Form/FormData/Commands/SubmitFormDataCommand.cs
@@ -1,7 +1,7 @@
-using System.Text.Json;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Workflow.Application.Form.Schema;
+using Workflow.Domain.Enums;
using Workflow.Domain.Exceptions;
using Workflow.Infrastructure.Persistence;
@@ -22,46 +22,32 @@ public class SubmitFormDataCommandHandler(WorkflowDbContext db)
{
public async Task Handle(SubmitFormDataCommand request, CancellationToken cancellationToken)
{
+ // 绕过软删除过滤器加载表单定义,以区分「已被删除」与「确实不存在」两种情况,
+ // 避免抛出误导性的「不存在」错误(表单实际存在,只是被删除)。
var formDefinition = await db.FormDefinitions
- .FirstOrDefaultAsync(f => f.Id == request.FormDefinitionId, cancellationToken)
- ?? throw new BusinessException($"表单定义 {request.FormDefinitionId} 不存在");
+ .IgnoreQueryFilters()
+ .FirstOrDefaultAsync(f => f.Id == request.FormDefinitionId, cancellationToken);
- var schemaValidation = SchemaValidator.Validate(formDefinition.SchemaJson ?? "{}");
- var fieldSummaries = schemaValidation.Fields;
-
- var data = JsonSerializer.Deserialize>(
- request.DataJson,
- new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
- ?? new Dictionary();
-
- foreach (var field in fieldSummaries.Where(f => f.Required))
+ if (formDefinition is null)
{
- if (!data.ContainsKey(field.Path) || IsNullOrEmpty(data[field.Path]))
- {
- var label = !string.IsNullOrEmpty(field.Title) ? field.Title : field.Path;
- throw new BusinessException($"必填字段 {label} 缺失");
- }
+ throw new BusinessException($"表单定义 {request.FormDefinitionId} 不存在");
}
- foreach (var field in fieldSummaries)
+ if (formDefinition.IsDeleted)
{
- if (!data.TryGetValue(field.Path, out var value)) continue;
+ throw new BusinessException($"表单定义 {formDefinition.Name}({request.FormDefinitionId})已被删除,无法提交表单数据");
+ }
- if (field.JsonType is "number" or "integer")
- {
- if (value.ValueKind == JsonValueKind.String)
- {
- var str = value.GetString();
- if (!double.TryParse(str, out _))
- {
- throw new BusinessException($"字段 {field.Path} 类型不匹配,期望数字");
- }
- }
- else if (value.ValueKind != JsonValueKind.Number)
- {
- throw new BusinessException($"字段 {field.Path} 类型不匹配,期望数字");
- }
- }
+ // 产品决策:FormStatus.Disabled 严格阻断——停用的表单不接受新提交。
+ if (formDefinition.Status == FormStatus.Disabled)
+ {
+ throw new BusinessException($"表单定义 {formDefinition.Name}({request.FormDefinitionId})已停用,无法提交表单数据");
+ }
+
+ var validation = FormDataValidator.Validate(formDefinition.SchemaJson ?? "{}", request.DataJson);
+ if (!validation.IsValid)
+ {
+ throw new BusinessException($"表单数据校验失败: {string.Join("; ", validation.Errors)}");
}
var formData = new Domain.Entities.FormData
@@ -77,10 +63,4 @@ public class SubmitFormDataCommandHandler(WorkflowDbContext db)
return formData.Id;
}
-
- private static bool IsNullOrEmpty(JsonElement element)
- {
- return element.ValueKind == JsonValueKind.Null ||
- (element.ValueKind == JsonValueKind.String && string.IsNullOrEmpty(element.GetString()));
- }
}
diff --git a/src/Workflow.Application/Form/FormDefinition/Commands/CreateFormDefinitionCommand.cs b/src/Workflow.Application/Form/FormDefinition/Commands/CreateFormDefinitionCommand.cs
index 6c002f8..88a48bf 100644
--- a/src/Workflow.Application/Form/FormDefinition/Commands/CreateFormDefinitionCommand.cs
+++ b/src/Workflow.Application/Form/FormDefinition/Commands/CreateFormDefinitionCommand.cs
@@ -34,7 +34,7 @@ public class CreateFormDefinitionCommandHandler(WorkflowDbContext db)
.Where(c => c.IsActive)
.Select(c => c.Name)
.ToListAsync(cancellationToken);
- var allowedSet = components.ToHashSet();
+ var allowedSet = components.Count == 0 ? null : components.ToHashSet();
var validation = SchemaValidator.Validate(request.SchemaJson, allowedSet);
if (!validation.IsValid)
diff --git a/src/Workflow.Application/Form/FormDefinition/Commands/DeleteFormDefinitionCommand.cs b/src/Workflow.Application/Form/FormDefinition/Commands/DeleteFormDefinitionCommand.cs
index 2e9d0ae..37f9fbb 100644
--- a/src/Workflow.Application/Form/FormDefinition/Commands/DeleteFormDefinitionCommand.cs
+++ b/src/Workflow.Application/Form/FormDefinition/Commands/DeleteFormDefinitionCommand.cs
@@ -16,6 +16,22 @@ public class DeleteFormDefinitionCommandHandler(WorkflowDbContext db)
.FirstOrDefaultAsync(f => f.Id == request.Id, cancellationToken)
?? throw new NotFoundException($"表单 {request.Id} 不存在");
+ // 删除前检查:表单若仍被活跃流程引用(流程定义的 FormDefinitionId 或流程节点的
+ // FormDefinitionId),阻断删除。WorkflowDefinition/WorkflowNode 均实现 ISoftDelete,
+ // 全局查询过滤器会自动排除已软删除的引用,因此仅被已删除流程引用时不会被锁死。
+ var referencedByDefinition = await db.WorkflowDefinitions
+ .AsNoTracking()
+ .AnyAsync(d => d.FormDefinitionId == request.Id, cancellationToken);
+
+ var referencedByNode = await db.WorkflowNodes
+ .AsNoTracking()
+ .AnyAsync(n => n.FormDefinitionId == request.Id, cancellationToken);
+
+ if (referencedByDefinition || referencedByNode)
+ {
+ throw new BusinessException("该表单正被流程引用,无法删除");
+ }
+
entity.IsDeleted = true;
await db.SaveChangesAsync(cancellationToken);
}
diff --git a/src/Workflow.Application/Form/FormDefinition/Commands/PublishFormDefinitionCommand.cs b/src/Workflow.Application/Form/FormDefinition/Commands/PublishFormDefinitionCommand.cs
index 24700d7..966c848 100644
--- a/src/Workflow.Application/Form/FormDefinition/Commands/PublishFormDefinitionCommand.cs
+++ b/src/Workflow.Application/Form/FormDefinition/Commands/PublishFormDefinitionCommand.cs
@@ -1,5 +1,6 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
+using Workflow.Domain.Entities;
using Workflow.Domain.Enums;
using Workflow.Domain.Exceptions;
using Workflow.Infrastructure.Persistence;
@@ -23,6 +24,16 @@ public class PublishFormDefinitionCommandHandler(WorkflowDbContext db)
}
entity.Status = FormStatus.Published;
+
+ // 发布时也记录一条版本快照,便于追溯发布时刻的 schema
+ db.FormDefinitionVersions.Add(new FormDefinitionVersion
+ {
+ FormDefinitionId = entity.Id,
+ Version = entity.Version,
+ SchemaJson = entity.SchemaJson,
+ Source = "Publish",
+ });
+
await db.SaveChangesAsync(cancellationToken);
}
}
diff --git a/src/Workflow.Application/Form/FormDefinition/Commands/UpdateFormDefinitionCommand.cs b/src/Workflow.Application/Form/FormDefinition/Commands/UpdateFormDefinitionCommand.cs
index 0644f5f..42e9795 100644
--- a/src/Workflow.Application/Form/FormDefinition/Commands/UpdateFormDefinitionCommand.cs
+++ b/src/Workflow.Application/Form/FormDefinition/Commands/UpdateFormDefinitionCommand.cs
@@ -2,6 +2,7 @@ using MediatR;
using Microsoft.EntityFrameworkCore;
using Workflow.Application.Form.DTOs;
using Workflow.Application.Form.Schema;
+using Workflow.Domain.Entities;
using Workflow.Domain.Exceptions;
using Workflow.Infrastructure.Persistence;
@@ -9,6 +10,7 @@ namespace Workflow.Application.Form.FormDefinition.Commands;
///
/// 更新表单定义:接收新的 Formily JSON Schema,校验后替换 SchemaJson 并递增 Version。
+/// 同时写入一条历史版本快照,用于版本对比。
///
public record UpdateFormDefinitionCommand(
Guid Id,
@@ -30,7 +32,7 @@ public class UpdateFormDefinitionCommandHandler(WorkflowDbContext db)
.Where(c => c.IsActive)
.Select(c => c.Name)
.ToListAsync(cancellationToken);
- var allowedSet = components.ToHashSet();
+ var allowedSet = components.Count == 0 ? null : components.ToHashSet();
var validation = SchemaValidator.Validate(request.SchemaJson, allowedSet);
if (!validation.IsValid)
@@ -43,6 +45,15 @@ public class UpdateFormDefinitionCommandHandler(WorkflowDbContext db)
entity.SchemaJson = request.SchemaJson;
entity.Version++;
+ // 写入历史版本快照
+ db.FormDefinitionVersions.Add(new FormDefinitionVersion
+ {
+ FormDefinitionId = entity.Id,
+ Version = entity.Version,
+ SchemaJson = request.SchemaJson,
+ Source = "Update",
+ });
+
await db.SaveChangesAsync(cancellationToken);
return new FormDefinitionDto
diff --git a/src/Workflow.Application/Form/FormDefinition/Queries/CompareFormVersionsQuery.cs b/src/Workflow.Application/Form/FormDefinition/Queries/CompareFormVersionsQuery.cs
new file mode 100644
index 0000000..23e8668
--- /dev/null
+++ b/src/Workflow.Application/Form/FormDefinition/Queries/CompareFormVersionsQuery.cs
@@ -0,0 +1,79 @@
+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.Queries;
+
+/// 对比表单的两个历史版本(或当前版本与某历史版本)
+public record CompareFormVersionsQuery(Guid FormDefinitionId, Guid? OldVersionId, Guid? NewVersionId)
+ : IRequest;
+
+public class CompareFormVersionsQueryHandler(WorkflowDbContext db)
+ : IRequestHandler
+{
+ public async Task Handle(CompareFormVersionsQuery request, CancellationToken cancellationToken)
+ {
+ var form = await db.FormDefinitions
+ .FirstOrDefaultAsync(f => f.Id == request.FormDefinitionId, cancellationToken)
+ ?? throw new NotFoundException($"表单 {request.FormDefinitionId} 不存在");
+
+ // 新版本:指定版本快照,否则用当前表单
+ FormVersionDto? newVersion = null;
+ if (request.NewVersionId is { } newId)
+ {
+ var nv = await db.FormDefinitionVersions.FirstOrDefaultAsync(v => v.Id == newId, cancellationToken);
+ if (nv is not null)
+ {
+ newVersion = ToDto(nv);
+ }
+ }
+ newVersion ??= new FormVersionDto
+ {
+ Id = Guid.Empty,
+ Version = form.Version,
+ SchemaJson = form.SchemaJson,
+ Source = "Current",
+ CreatedAt = form.UpdatedAt,
+ };
+
+ // 旧版本:指定版本快照,否则取当前版本的前一个快照
+ FormVersionDto? oldVersion = null;
+ if (request.OldVersionId is { } oldId)
+ {
+ var ov = await db.FormDefinitionVersions.FirstOrDefaultAsync(v => v.Id == oldId, cancellationToken);
+ if (ov is not null) oldVersion = ToDto(ov);
+ }
+ if (oldVersion is null)
+ {
+ // 自动取比 newVersion 早一个的快照
+ var prev = await db.FormDefinitionVersions
+ .Where(v => v.FormDefinitionId == request.FormDefinitionId && v.Version < newVersion.Version)
+ .OrderByDescending(v => v.Version)
+ .ThenByDescending(v => v.CreatedAt)
+ .FirstOrDefaultAsync(cancellationToken);
+ if (prev is not null) oldVersion = ToDto(prev);
+ }
+
+ var diff = SchemaDiffer.Diff(oldVersion?.SchemaJson, newVersion.SchemaJson);
+
+ return new FormVersionCompareDto
+ {
+ OldVersion = oldVersion,
+ NewVersion = newVersion,
+ Diff = diff,
+ };
+ }
+
+ private static FormVersionDto ToDto(Workflow.Domain.Entities.FormDefinitionVersion v) => new()
+ {
+ Id = v.Id,
+ Version = v.Version,
+ SchemaJson = v.SchemaJson,
+ Source = v.Source,
+ ChangeSummary = v.ChangeSummary,
+ CreatedAt = v.CreatedAt,
+ };
+}
diff --git a/src/Workflow.Application/Form/FormDefinition/Queries/GetFormDefinitionListQuery.cs b/src/Workflow.Application/Form/FormDefinition/Queries/GetFormDefinitionListQuery.cs
index ccf4273..05fccaa 100644
--- a/src/Workflow.Application/Form/FormDefinition/Queries/GetFormDefinitionListQuery.cs
+++ b/src/Workflow.Application/Form/FormDefinition/Queries/GetFormDefinitionListQuery.cs
@@ -44,7 +44,7 @@ public class GetFormDefinitionListQueryHandler(WorkflowDbContext db)
return new PagedResult
{
Items = items,
- TotalCount = totalCount,
+ Total = totalCount,
PageIndex = request.PageIndex,
PageSize = request.PageSize,
};
diff --git a/src/Workflow.Application/Form/FormDefinition/Queries/GetFormVersionsQuery.cs b/src/Workflow.Application/Form/FormDefinition/Queries/GetFormVersionsQuery.cs
new file mode 100644
index 0000000..0a98054
--- /dev/null
+++ b/src/Workflow.Application/Form/FormDefinition/Queries/GetFormVersionsQuery.cs
@@ -0,0 +1,35 @@
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+using Workflow.Application.Form.DTOs;
+using Workflow.Domain.Exceptions;
+using Workflow.Infrastructure.Persistence;
+
+namespace Workflow.Application.Form.FormDefinition.Queries;
+
+/// 获取表单的历史版本列表(按版本号降序)
+public record GetFormVersionsQuery(Guid FormDefinitionId) : IRequest>;
+
+public class GetFormVersionsQueryHandler(WorkflowDbContext db)
+ : IRequestHandler>
+{
+ public async Task> Handle(GetFormVersionsQuery request, CancellationToken cancellationToken)
+ {
+ var exists = await db.FormDefinitions.AnyAsync(f => f.Id == request.FormDefinitionId, cancellationToken);
+ if (!exists) throw new NotFoundException($"表单 {request.FormDefinitionId} 不存在");
+
+ return await db.FormDefinitionVersions
+ .Where(v => v.FormDefinitionId == request.FormDefinitionId)
+ .OrderByDescending(v => v.Version)
+ .ThenByDescending(v => v.CreatedAt)
+ .Select(v => new FormVersionDto
+ {
+ Id = v.Id,
+ Version = v.Version,
+ SchemaJson = v.SchemaJson,
+ Source = v.Source,
+ ChangeSummary = v.ChangeSummary,
+ CreatedAt = v.CreatedAt,
+ })
+ .ToListAsync(cancellationToken);
+ }
+}
diff --git a/src/Workflow.Application/Form/Schema/FieldPermissionEvaluator.cs b/src/Workflow.Application/Form/Schema/FieldPermissionEvaluator.cs
new file mode 100644
index 0000000..ef2c3ea
--- /dev/null
+++ b/src/Workflow.Application/Form/Schema/FieldPermissionEvaluator.cs
@@ -0,0 +1,67 @@
+namespace Workflow.Application.Form.Schema;
+
+///
+/// 字段级数据权限求值器:根据当前审批节点名,计算应被隐藏(hidden)的字段路径集合。
+///
+/// 设计与 ReactionEvaluator 对齐:两者都产出"应跳过的字段路径集合",在 FormDataValidator
+/// 中与联动隐藏集合 union 后统一跳过必填校验。隐藏字段不参与校验,因为用户在前端看不到、也无法填写。
+///
+/// 权限解析优先级:精确节点名命中 > __default__ 兜底 > 默认 visible(可见)。
+///
+public static class FieldPermissionEvaluator
+{
+ ///
+ /// 计算在指定节点下应被隐藏(hidden)的字段路径集合。
+ /// currentNodeKey 为 null 时返回空集(无节点上下文 = 不做权限过滤,如普通表单数据录入)。
+ ///
+ public static HashSet GetHiddenFields(
+ IReadOnlyList fields,
+ string? currentNodeKey)
+ {
+ var hidden = new HashSet();
+
+ if (string.IsNullOrWhiteSpace(currentNodeKey))
+ {
+ return hidden;
+ }
+
+ foreach (var field in fields)
+ {
+ var action = ResolveAction(field.FieldPermission, currentNodeKey);
+ if (action == "hidden")
+ {
+ hidden.Add(field.Path);
+ }
+ }
+
+ return hidden;
+ }
+
+ ///
+ /// 解析某字段在指定节点的权限动作。优先级:
+ /// 1. 精确节点名命中
+ /// 2. __default__ 兜底
+ /// 3. 默认 visible
+ ///
+ internal static string ResolveAction(
+ IReadOnlyDictionary? permission,
+ string currentNodeKey)
+ {
+ if (permission is null || permission.Count == 0)
+ {
+ return "visible";
+ }
+
+ if (permission.TryGetValue(currentNodeKey, out var exact))
+ {
+ return exact;
+ }
+
+ if (permission.TryGetValue("__default__", out var fallback))
+ {
+ return fallback;
+ }
+
+ return "visible";
+ }
+}
diff --git a/src/Workflow.Application/Form/Schema/FieldSummary.cs b/src/Workflow.Application/Form/Schema/FieldSummary.cs
index 469ee89..23e6b7c 100644
--- a/src/Workflow.Application/Form/Schema/FieldSummary.cs
+++ b/src/Workflow.Application/Form/Schema/FieldSummary.cs
@@ -1,11 +1,59 @@
namespace Workflow.Application.Form.Schema;
+using System.Text.Json;
+
///
/// 从 Formily Schema 中提取的数据字段摘要,用于 FormData 提交时的后端校验。
///
public record FieldSummary(
string Path,
string JsonType,
+ string? Component,
bool Required,
- string? Title
+ string? Title,
+ IReadOnlyList Options,
+ IReadOnlyList Validators,
+ IReadOnlyList Reactions,
+ ///
+ /// 字段级数据权限:key=节点名(或特殊键 __default__/__initiator__),
+ /// value=权限动作(visible 可见可编辑 / readonly 只读 / hidden 隐藏)。
+ /// null 表示该字段未配置任何节点权限(所有节点均默认可见可编辑)。
+ ///
+ IReadOnlyDictionary? FieldPermission
+);
+
+public record FormOptionSummary(
+ string Label,
+ JsonElement Value,
+ bool Disabled
+);
+
+public record FormValidatorSummary(
+ string Type,
+ JsonElement? Value,
+ IReadOnlyList Values,
+ string? Message
+);
+
+///
+/// 字段上的联动规则摘要。type=condition 时 When/Action 有值;type=data 时 Expression 有值。
+///
+public record ReactionSummary(
+ string Type,
+ string? Target,
+ ReactionWhenSummary? When,
+ string? Action,
+ ReactionExpressionSummary? Expression
+);
+
+public record ReactionWhenSummary(
+ string Source,
+ string Operator,
+ JsonElement? Value
+);
+
+public record ReactionExpressionSummary(
+ string Left,
+ string Operator,
+ string Right
);
diff --git a/src/Workflow.Application/Form/Schema/FormDataValidationResult.cs b/src/Workflow.Application/Form/Schema/FormDataValidationResult.cs
new file mode 100644
index 0000000..2b2e549
--- /dev/null
+++ b/src/Workflow.Application/Form/Schema/FormDataValidationResult.cs
@@ -0,0 +1,8 @@
+namespace Workflow.Application.Form.Schema;
+
+public record FormDataValidationResult(bool IsValid, IReadOnlyList Errors)
+{
+ public static FormDataValidationResult Valid() => new(true, []);
+
+ public static FormDataValidationResult Invalid(IReadOnlyList errors) => new(false, errors);
+}
diff --git a/src/Workflow.Application/Form/Schema/FormDataValidator.cs b/src/Workflow.Application/Form/Schema/FormDataValidator.cs
new file mode 100644
index 0000000..4eb42fa
--- /dev/null
+++ b/src/Workflow.Application/Form/Schema/FormDataValidator.cs
@@ -0,0 +1,234 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace Workflow.Application.Form.Schema;
+
+///
+/// 表单数据校验器。结合 SchemaValidator 解析出的字段元数据,对提交的表单 JSON 做必填/类型/选项/规则四类校验。
+/// 关键设计:联动隐藏的字段跳过校验(与前端 useReactions 行为对齐);
+/// 字段级数据权限 hidden 的字段同样跳过(与前端 applyFieldPermissions 行为对齐);
+/// 嵌套字段支持点分路径取值(兼容容器组件嵌套结构与旧扁平提交)。
+///
+public static class FormDataValidator
+{
+ ///
+ /// 校验表单数据是否符合 schema 定义。返回错误列表,空表示通过。
+ ///
+ /// 表单结构定义 JSON
+ /// 提交的表单数据 JSON
+ /// 允许的组件白名单;为 null 表示不限制(用于隔离租户/场景自定义组件)
+ ///
+ /// 当前审批节点名(用于字段级数据权限:hidden 字段跳过必填校验)。
+ /// 为 null 表示无节点上下文(如普通表单数据录入),不做权限过滤。
+ ///
+ public static FormDataValidationResult Validate(
+ string schemaJson,
+ string dataJson,
+ HashSet? allowedComponents = null,
+ string? currentNodeKey = null)
+ {
+ var schemaValidation = SchemaValidator.Validate(schemaJson, allowedComponents);
+ if (!schemaValidation.IsValid)
+ {
+ return FormDataValidationResult.Invalid(schemaValidation.Errors);
+ }
+
+ JsonElement dataRoot;
+ try
+ {
+ dataRoot = JsonSerializer.Deserialize(
+ dataJson,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ }
+ catch (JsonException ex)
+ {
+ return FormDataValidationResult.Invalid([$"表单数据 JSON 格式无效: {ex.Message}"]);
+ }
+
+ // 兼容:旧逻辑把 dataJson 反序列化为扁平字典。ReactionEvaluator 仍依赖扁平字典,
+ // 这里按原有契约构建一份(顶层 key → JsonElement),不包含嵌套字段。
+ var flatData = dataRoot.ValueKind == JsonValueKind.Object
+ ? dataRoot.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.Clone())
+ : new Dictionary();
+
+ var errors = new List();
+
+ // 求值联动规则:被联动隐藏的字段不参与校验(跳过必填/类型/选项/规则检查)
+ var hiddenFields = ReactionEvaluator.GetHiddenFields(schemaValidation.Fields, flatData);
+
+ // 字段级数据权限:当前节点下应隐藏(hidden)的字段也跳过校验。
+ // 与联动隐藏集合 union,复用同一条 if (hiddenFields.Contains(path)) continue 跳过逻辑。
+ if (!string.IsNullOrWhiteSpace(currentNodeKey))
+ {
+ hiddenFields.UnionWith(
+ FieldPermissionEvaluator.GetHiddenFields(schemaValidation.Fields, currentNodeKey));
+ }
+
+ foreach (var field in schemaValidation.Fields)
+ {
+ // 联动隐藏或权限隐藏的字段直接跳过(用户在前端看不到,提交的值无意义)
+ if (hiddenFields.Contains(field.Path)) continue;
+
+ var label = string.IsNullOrWhiteSpace(field.Title) ? field.Path : field.Title;
+ // 按点分路径在嵌套 JSON 中取值(与前端 useValidation.getValue 行为对齐):
+ // FormGrid/FormLayout 等容器下的字段 Path 形如 "dateRange.startDate",
+ // 而提交数据是嵌套对象 {dateRange:{startDate:...}},必须逐层下钻。
+ var exists = TryGetByDottedPath(dataRoot, field.Path, out var value);
+
+ if (field.Required && (!exists || IsNullOrEmpty(value)))
+ {
+ errors.Add($"必填字段 {label} 缺失");
+ continue;
+ }
+
+ if (!exists || IsNullOrEmpty(value)) continue;
+
+ ValidateJsonType(field, value, errors);
+ ValidateOptions(field, value, errors);
+ ValidateRules(field, value, label, errors);
+ }
+
+ return errors.Count == 0
+ ? FormDataValidationResult.Valid()
+ : FormDataValidationResult.Invalid(errors);
+ }
+
+ private static bool IsNullOrEmpty(JsonElement element)
+ {
+ return element.ValueKind == JsonValueKind.Null ||
+ (element.ValueKind == JsonValueKind.String && string.IsNullOrEmpty(element.GetString()));
+ }
+
+ private static void ValidateJsonType(FieldSummary field, JsonElement value, List errors)
+ {
+ if (field.JsonType is "number" or "integer")
+ {
+ if (value.ValueKind == JsonValueKind.String)
+ {
+ if (!double.TryParse(value.GetString(), out _))
+ {
+ errors.Add($"字段 {field.Path} 类型不匹配,期望数字");
+ }
+ }
+ else if (value.ValueKind != JsonValueKind.Number)
+ {
+ errors.Add($"字段 {field.Path} 类型不匹配,期望数字");
+ }
+ }
+
+ if (field.JsonType == "boolean" && value.ValueKind is not JsonValueKind.True and not JsonValueKind.False)
+ {
+ errors.Add($"字段 {field.Path} 类型不匹配,期望布尔值");
+ }
+
+ if (field.JsonType == "array" && value.ValueKind != JsonValueKind.Array)
+ {
+ errors.Add($"字段 {field.Path} 类型不匹配,期望数组");
+ }
+ }
+
+ private static void ValidateOptions(FieldSummary field, JsonElement value, List errors)
+ {
+ if (field.Options.Count == 0) return;
+
+ var current = value.ToString();
+ var allowed = field.Options
+ .Where(o => !o.Disabled)
+ .Select(o => o.Value.ToString())
+ .ToHashSet();
+
+ if (!allowed.Contains(current))
+ {
+ errors.Add($"字段 {field.Path} 的值不在允许范围内");
+ }
+ }
+
+ private static void ValidateRules(FieldSummary field, JsonElement value, string label, List errors)
+ {
+ foreach (var rule in field.Validators)
+ {
+ switch (rule.Type)
+ {
+ case "minLength":
+ if (value.ValueKind == JsonValueKind.String &&
+ rule.Value.HasValue &&
+ value.GetString()!.Length < rule.Value.Value.GetInt32())
+ {
+ errors.Add(rule.Message ?? $"{label} 长度过短");
+ }
+ break;
+ case "maxLength":
+ if (value.ValueKind == JsonValueKind.String &&
+ rule.Value.HasValue &&
+ value.GetString()!.Length > rule.Value.Value.GetInt32())
+ {
+ errors.Add(rule.Message ?? $"{label} 长度过长");
+ }
+ break;
+ case "min":
+ if (TryGetNumber(value, out var minNumber) &&
+ rule.Value.HasValue &&
+ minNumber < rule.Value.Value.GetDouble())
+ {
+ errors.Add(rule.Message ?? $"{label} 不能小于 {rule.Value.Value}");
+ }
+ break;
+ case "max":
+ if (TryGetNumber(value, out var maxNumber) &&
+ rule.Value.HasValue &&
+ maxNumber > rule.Value.Value.GetDouble())
+ {
+ errors.Add(rule.Message ?? $"{label} 不能大于 {rule.Value.Value}");
+ }
+ break;
+ case "pattern":
+ if (value.ValueKind == JsonValueKind.String &&
+ rule.Value.HasValue &&
+ !Regex.IsMatch(value.GetString() ?? "", rule.Value.Value.GetString() ?? ""))
+ {
+ errors.Add(rule.Message ?? $"{label} 格式不正确");
+ }
+ break;
+ }
+ }
+ }
+
+ private static bool TryGetNumber(JsonElement value, out double number)
+ {
+ if (value.ValueKind == JsonValueKind.Number) return value.TryGetDouble(out number);
+ if (value.ValueKind == JsonValueKind.String) return double.TryParse(value.GetString(), out number);
+ number = 0;
+ return false;
+ }
+
+ ///
+ /// 按点分路径(如 "dateRange.startDate")在嵌套 JSON 中逐层取值。
+ /// 支持两种数据形态:
+ /// 1. 嵌套对象:{dateRange:{startDate:"2026-06-15"}}
+ /// 2. 扁平 key:{"dateRange.startDate":"2026-06-15"}(兼容旧提交)
+ /// 与前端 useValidation.getValue 的路径解析行为保持一致。
+ ///
+ private static bool TryGetByDottedPath(JsonElement root, string path, out JsonElement value)
+ {
+ // 优先尝试扁平 key(直接命中),避免破坏旧契约
+ if (root.ValueKind == JsonValueKind.Object &&
+ root.TryGetProperty(path, out value))
+ {
+ return true;
+ }
+
+ var current = root;
+ foreach (var segment in path.Split('.'))
+ {
+ if (current.ValueKind != JsonValueKind.Object ||
+ !current.TryGetProperty(segment, out current))
+ {
+ value = default;
+ return false;
+ }
+ }
+
+ value = current;
+ return true;
+ }
+}
diff --git a/src/Workflow.Application/Form/Schema/ReactionEvaluator.cs b/src/Workflow.Application/Form/Schema/ReactionEvaluator.cs
new file mode 100644
index 0000000..98d3dec
--- /dev/null
+++ b/src/Workflow.Application/Form/Schema/ReactionEvaluator.cs
@@ -0,0 +1,140 @@
+namespace Workflow.Application.Form.Schema;
+
+using System.Text.Json;
+
+///
+/// 后端联动规则求值器。根据提交的数据计算哪些字段被联动规则隐藏,
+/// 以便 FormDataValidator 跳过对这些字段的必填校验。
+///
+/// 语义与前端 useReactions.evaluateReactions、设计器 LinkageDrawer UI 一致:
+/// - condition + action=visible:when 条件成立时,target **显示**
+/// (UI 文案为「显示目标字段」)。因此一个 target 被隐藏,当且仅当
+/// 所有指向它的 visible 规则的条件都不成立(没有任何规则要求显示它)。
+/// - 字段若未被任何 visible 规则约束,则默认显示,不进入隐藏集合。
+/// - data 类型不影响显隐,仅计算值,此处不参与。
+///
+public static class ReactionEvaluator
+{
+ ///
+ /// 返回被联动隐藏的字段路径集合。
+ ///
+ public static HashSet GetHiddenFields(
+ IReadOnlyList fields,
+ Dictionary data)
+ {
+ // 收集每个 target 的所有 visible 规则条件
+ var visibleTargets = new Dictionary>();
+
+ foreach (var field in fields)
+ {
+ foreach (var reaction in field.Reactions)
+ {
+ if (reaction.Type != "condition") continue;
+ if (reaction.Action != "visible") continue;
+ if (reaction.When is null || string.IsNullOrEmpty(reaction.Target)) continue;
+
+ if (!visibleTargets.TryGetValue(reaction.Target, out var list))
+ {
+ list = [];
+ visibleTargets[reaction.Target] = list;
+ }
+ list.Add(reaction.When);
+ }
+ }
+
+ // 一个 target 被隐藏,当且仅当它受 visible 规则约束、且所有规则条件都不成立
+ var hidden = new HashSet();
+ foreach (var (target, whens) in visibleTargets)
+ {
+ var anyShown = whens.Any(when => ConditionMatches(when, data));
+ if (!anyShown)
+ {
+ hidden.Add(target);
+ }
+ }
+
+ return hidden;
+ }
+
+ private static bool ConditionMatches(ReactionWhenSummary when, Dictionary data)
+ {
+ if (!data.TryGetValue(when.Source, out var rawValue)) return false;
+
+ return when.Operator switch
+ {
+ "eq" => ValueEquals(rawValue, when.Value),
+ "neq" => !ValueEquals(rawValue, when.Value),
+ "contains" => Contains(rawValue, when.Value),
+ "gt" => CompareNumbers(rawValue, when.Value) > 0,
+ "gte" => CompareNumbers(rawValue, when.Value) >= 0,
+ "lt" => CompareNumbers(rawValue, when.Value) < 0,
+ "lte" => CompareNumbers(rawValue, when.Value) <= 0,
+ _ => false,
+ };
+ }
+
+ private static bool ValueEquals(JsonElement actual, JsonElement? expected)
+ {
+ if (expected is null) return false;
+ var exp = expected.Value;
+
+ // 字符串比较
+ if (actual.ValueKind == JsonValueKind.String && exp.ValueKind == JsonValueKind.String)
+ {
+ return actual.GetString() == exp.GetString();
+ }
+ // 数字比较
+ if (actual.ValueKind == JsonValueKind.Number && exp.ValueKind == JsonValueKind.Number)
+ {
+ return actual.GetDouble() == exp.GetDouble();
+ }
+ // 跨类型:把字符串值和数字值统一比较(前端可能传数字字面量字符串)
+ if (actual.ValueKind == JsonValueKind.String && exp.ValueKind == JsonValueKind.Number)
+ {
+ return double.TryParse(actual.GetString(), out var a) && a == exp.GetDouble();
+ }
+ if (actual.ValueKind == JsonValueKind.Number && exp.ValueKind == JsonValueKind.String)
+ {
+ return double.TryParse(exp.GetString(), out var e) && actual.GetDouble() == e;
+ }
+ // 布尔
+ if (actual.ValueKind is JsonValueKind.True or JsonValueKind.False &&
+ exp.ValueKind is JsonValueKind.True or JsonValueKind.False)
+ {
+ return actual.GetBoolean() == exp.GetBoolean();
+ }
+ return actual.ToString() == exp.ToString();
+ }
+
+ private static bool Contains(JsonElement actual, JsonElement? expected)
+ {
+ if (expected is null) return false;
+ if (actual.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var item in actual.EnumerateArray())
+ {
+ if (ValueEquals(item, expected)) return true;
+ }
+ return false;
+ }
+ return (actual.GetString() ?? "").Contains(expected.Value.GetString() ?? "");
+ }
+
+ private static int CompareNumbers(JsonElement actual, JsonElement? expected)
+ {
+ if (expected is null) return -1;
+ if (!TryGetNumber(actual, out var a) || !TryGetNumber(expected.Value, out var e))
+ {
+ return -1;
+ }
+ return a.CompareTo(e);
+ }
+
+ private static bool TryGetNumber(JsonElement el, out double number)
+ {
+ if (el.ValueKind == JsonValueKind.Number) return el.TryGetDouble(out number);
+ if (el.ValueKind == JsonValueKind.String) return double.TryParse(el.GetString(), out number);
+ number = 0;
+ return false;
+ }
+}
diff --git a/src/Workflow.Application/Form/Schema/SchemaDiffer.cs b/src/Workflow.Application/Form/Schema/SchemaDiffer.cs
new file mode 100644
index 0000000..2e4b678
--- /dev/null
+++ b/src/Workflow.Application/Form/Schema/SchemaDiffer.cs
@@ -0,0 +1,128 @@
+using System.Text.Json;
+
+namespace Workflow.Application.Form.Schema;
+
+///
+/// 表单 Schema 差异。按字段路径列出新增、删除、修改的字段。
+///
+public record SchemaDiff(
+ IReadOnlyList Added,
+ IReadOnlyList Removed,
+ IReadOnlyList Modified);
+
+public record FieldDiff(
+ string Path,
+ string? Title,
+ string? Component,
+ string? Change);
+
+///
+/// 递归对比两个 Formily Schema,产出字段级别的差异。
+///
+public static class SchemaDiffer
+{
+ public static SchemaDiff Diff(string? oldSchemaJson, string? newSchemaJson)
+ {
+ var oldFields = CollectFields(oldSchemaJson);
+ var newFields = CollectFields(newSchemaJson);
+
+ var added = new List();
+ var removed = new List();
+ var modified = new List();
+
+ var oldKeys = oldFields.Keys.ToHashSet();
+ var newKeys = newFields.Keys.ToHashSet();
+
+ // 新增字段
+ foreach (var key in newKeys.Except(oldKeys))
+ {
+ var f = newFields[key];
+ added.Add(new FieldDiff(key, f.Title, f.Component, "新增字段"));
+ }
+
+ // 删除字段
+ foreach (var key in oldKeys.Except(newKeys))
+ {
+ var f = oldFields[key];
+ removed.Add(new FieldDiff(key, f.Title, f.Component, "删除字段"));
+ }
+
+ // 修改字段(component / required / title 变化)
+ foreach (var key in oldKeys.Intersect(newKeys))
+ {
+ var oldF = oldFields[key];
+ var newF = newFields[key];
+ var changes = new List();
+
+ if (oldF.Component != newF.Component)
+ changes.Add($"组件 {oldF.Component} → {newF.Component}");
+ if (oldF.Required != newF.Required)
+ changes.Add($"必填 {oldF.Required} → {newF.Required}");
+ if (!string.Equals(oldF.Title, newF.Title, StringComparison.Ordinal))
+ changes.Add($"标题 \"{oldF.Title}\" → \"{newF.Title}\"");
+ if (oldF.JsonType != newF.JsonType)
+ changes.Add($"类型 {oldF.JsonType} → {newF.JsonType}");
+
+ if (changes.Count > 0)
+ {
+ modified.Add(new FieldDiff(key, newF.Title, newF.Component, string.Join("; ", changes)));
+ }
+ }
+
+ return new SchemaDiff(added, removed, modified);
+ }
+
+ private static Dictionary CollectFields(string? schemaJson)
+ {
+ var result = new Dictionary();
+ if (string.IsNullOrWhiteSpace(schemaJson)) return result;
+
+ JsonDocument doc;
+ try
+ {
+ doc = JsonDocument.Parse(schemaJson);
+ }
+ catch
+ {
+ return result;
+ }
+
+ if (doc.RootElement.ValueKind == JsonValueKind.Object &&
+ doc.RootElement.TryGetProperty("properties", out var props) &&
+ props.ValueKind == JsonValueKind.Object)
+ {
+ WalkProperties(props, "", result);
+ }
+
+ return result;
+ }
+
+ private static void WalkProperties(JsonElement props, string prefix, Dictionary result)
+ {
+ foreach (var prop in props.EnumerateObject())
+ {
+ if (prop.Value.ValueKind != JsonValueKind.Object) continue;
+ var path = string.IsNullOrEmpty(prefix) ? prop.Name : $"{prefix}.{prop.Name}";
+
+ var node = prop.Value;
+ var type = node.TryGetProperty("type", out var t) ? t.GetString() ?? "" : "";
+ var component = node.TryGetProperty("x-component", out var c) ? c.GetString() ?? "" : "";
+ var title = node.TryGetProperty("title", out var ti) ? ti.GetString() : null;
+ var required = node.TryGetProperty("required", out var r) && r.ValueKind == JsonValueKind.True;
+
+ // 只记录叶子字段(非 void/object 的容器类型)
+ if (type is not ("void" or "object"))
+ {
+ result[path] = new FieldInfo(title, component, type, required);
+ }
+
+ // 递归子节点
+ if (node.TryGetProperty("properties", out var childProps) && childProps.ValueKind == JsonValueKind.Object)
+ {
+ WalkProperties(childProps, path, result);
+ }
+ }
+ }
+
+ private record FieldInfo(string? Title, string? Component, string JsonType, bool Required);
+}
diff --git a/src/Workflow.Application/Form/Schema/SchemaValidator.cs b/src/Workflow.Application/Form/Schema/SchemaValidator.cs
index 4297997..6e73402 100644
--- a/src/Workflow.Application/Form/Schema/SchemaValidator.cs
+++ b/src/Workflow.Application/Form/Schema/SchemaValidator.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using System.Text.RegularExpressions;
namespace Workflow.Application.Form.Schema;
@@ -9,12 +10,14 @@ namespace Workflow.Application.Form.Schema;
///
public static class SchemaValidator
{
+ private static readonly Regex FieldKeyPattern = new("^[a-zA-Z][a-zA-Z0-9_]*$", RegexOptions.Compiled);
+
private static readonly HashSet AllowedTypes =
["string", "number", "integer", "boolean", "array", "object", "void"];
private static readonly HashSet DefaultAllowedComponents =
[
- "Input", "Input.TextArea", "Input.Password", "Input.Number",
+ "Input", "Input.TextArea", "Input.Password", "Input.Number", "InputNumber",
"Select", "DatePicker", "TimePicker", "Switch",
"Radio.Group", "Checkbox.Group",
"Upload", "FormGrid", "FormLayout", "FormItem",
@@ -24,13 +27,31 @@ public static class SchemaValidator
"Editable", "Editable.Popover"
];
+ private static readonly HashSet ReactionTypes = ["condition", "data"];
+
+ private static readonly HashSet ConditionOperators =
+ ["eq", "neq", "gt", "gte", "lt", "lte", "contains"];
+
+ private static readonly HashSet ExpressionOperators =
+ ["add", "subtract", "multiply", "divide"];
+
+ private static readonly HashSet ReactionActions =
+ ["visible", "disabled", "required"];
+
+ /// 字段级数据权限允许的动作值(x-field-permission 的 value)。
+ private static readonly HashSet FieldPermissionActions =
+ ["visible", "readonly", "hidden"];
+
///
/// 校验 Formily Schema 并提取数据字段摘要。
/// 当 allowedComponents 为 null 时,使用内置的 DefaultAllowedComponents 作为回退白名单。
///
public static SchemaValidationResult Validate(string schemaJson, HashSet? allowedComponents = null)
{
- var allowed = allowedComponents ?? DefaultAllowedComponents;
+ var allowed = (allowedComponents ?? DefaultAllowedComponents)
+ .Select(NormalizeComponent)
+ .OfType()
+ .ToHashSet();
var errors = new List();
var fields = new List();
@@ -54,6 +75,19 @@ public static class SchemaValidator
errors.Add("Schema 顶层 type 必须为 'object'");
}
+ var topLevelRequired = new HashSet();
+ if (root.TryGetProperty("required", out var requiredProp) && requiredProp.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var item in requiredProp.EnumerateArray())
+ {
+ var requiredName = item.GetString();
+ if (!string.IsNullOrWhiteSpace(requiredName))
+ {
+ topLevelRequired.Add(requiredName);
+ }
+ }
+ }
+
if (!root.TryGetProperty("properties", out var props) || props.ValueKind != JsonValueKind.Object)
{
errors.Add("Schema 顶层必须包含 properties 对象");
@@ -62,10 +96,17 @@ public static class SchemaValidator
{
foreach (var prop in props.EnumerateObject())
{
- ValidateNode(prop.Name, prop.Value, errors, fields, "", allowed);
+ ValidateNode(prop.Name, prop.Value, errors, fields, "", allowed, topLevelRequired);
}
}
+ // 所有字段收集完成后,校验联动规则中的字段引用(target/when.source/expression.left,right)
+ if (errors.Count == 0)
+ {
+ var fieldPaths = fields.Select(f => f.Path).ToHashSet();
+ ValidateReactionReferences(root, fieldPaths, errors);
+ }
+
return new SchemaValidationResult(errors.Count == 0, errors, fields);
}
@@ -75,7 +116,8 @@ public static class SchemaValidator
List errors,
List fields,
string parentPath,
- HashSet allowed)
+ HashSet allowed,
+ HashSet topLevelRequired)
{
var path = string.IsNullOrEmpty(parentPath) ? name : $"{parentPath}.{name}";
@@ -85,18 +127,26 @@ public static class SchemaValidator
return;
}
+ if (!FieldKeyPattern.IsMatch(name))
+ {
+ errors.Add($"节点 {path} 的字段名 '{name}' 不合法,必须以字母开头且只能包含字母、数字、下划线");
+ }
+
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 component = node.TryGetProperty("x-component", out var compProp)
+ ? NormalizeComponent(compProp.GetString())
+ : null;
+
+ if (component is not null)
{
- var comp = compProp.GetString();
- if (comp is not null && !allowed.Contains(comp))
+ if (!allowed.Contains(component))
{
- errors.Add($"节点 {path} 的 x-component '{comp}' 不在允许列表中");
+ errors.Add($"节点 {path} 的 x-component '{component}' 不在允许列表中");
}
}
@@ -115,21 +165,441 @@ public static class SchemaValidator
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));
+ fields.Add(new FieldSummary(
+ path,
+ typeValue,
+ component,
+ IsRequired(node, topLevelRequired, path, name),
+ title,
+ ExtractOptions(node),
+ ExtractValidators(node),
+ ExtractReactions(node),
+ ExtractFieldPermission(node)
+ ));
}
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);
+ ValidateNode(child.Name, child.Value, errors, fields, path, allowed, topLevelRequired);
}
}
+ ValidateReactions(node, path, errors);
+ ValidateDataSource(node, path, errors);
+ ValidateFieldPermission(node, path, errors);
+
if (node.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Object)
{
- ValidateNode("items", items, errors, fields, path, allowed);
+ ValidateNode("items", items, errors, fields, path, allowed, topLevelRequired);
}
}
+
+ private static string? NormalizeComponent(string? component)
+ {
+ return component switch
+ {
+ "Input.Number" => "InputNumber",
+ _ => component
+ };
+ }
+
+ private static bool IsRequired(JsonElement node, HashSet topLevelRequired, string path, string name)
+ {
+ if (node.TryGetProperty("required", out var reqProp) && reqProp.ValueKind == JsonValueKind.True)
+ {
+ return true;
+ }
+
+ return topLevelRequired.Contains(path) || topLevelRequired.Contains(name);
+ }
+
+ private static IReadOnlyList ExtractOptions(JsonElement node)
+ {
+ var options = new List();
+
+ if (node.TryGetProperty("x-data-source", out var ds) &&
+ ds.ValueKind == JsonValueKind.Object &&
+ ds.TryGetProperty("type", out var typeProp) &&
+ typeProp.GetString() == "static" &&
+ ds.TryGetProperty("options", out var optionArray) &&
+ optionArray.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var option in optionArray.EnumerateArray())
+ {
+ if (option.ValueKind != JsonValueKind.Object) continue;
+ var label = option.TryGetProperty("label", out var labelProp) ? labelProp.GetString() ?? "" : "";
+ var value = option.TryGetProperty("value", out var valueProp) ? valueProp.Clone() : default;
+ var disabled = option.TryGetProperty("disabled", out var disabledProp) && disabledProp.ValueKind == JsonValueKind.True;
+ options.Add(new FormOptionSummary(label, value, disabled));
+ }
+ }
+ else if (node.TryGetProperty("enum", out var enumArray) && enumArray.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var item in enumArray.EnumerateArray())
+ {
+ if (item.ValueKind == JsonValueKind.Object)
+ {
+ var label = item.TryGetProperty("label", out var labelProp) ? labelProp.GetString() ?? "" : "";
+ var value = item.TryGetProperty("value", out var valueProp) ? valueProp.Clone() : default;
+ options.Add(new FormOptionSummary(label, value, false));
+ }
+ else
+ {
+ options.Add(new FormOptionSummary(item.ToString(), item.Clone(), false));
+ }
+ }
+ }
+
+ return options;
+ }
+
+ private static IReadOnlyList ExtractValidators(JsonElement node)
+ {
+ var validators = new List();
+ if (!node.TryGetProperty("x-validator", out var validatorArray) || validatorArray.ValueKind != JsonValueKind.Array)
+ {
+ return validators;
+ }
+
+ foreach (var validator in validatorArray.EnumerateArray())
+ {
+ if (validator.ValueKind != JsonValueKind.Object) continue;
+ var type = validator.TryGetProperty("type", out var typeProp) ? typeProp.GetString() : null;
+ if (string.IsNullOrWhiteSpace(type)) continue;
+
+ JsonElement? value = validator.TryGetProperty("value", out var valueProp) ? valueProp.Clone() : null;
+ var values = new List();
+ if (validator.TryGetProperty("values", out var valuesProp) && valuesProp.ValueKind == JsonValueKind.Array)
+ {
+ values.AddRange(valuesProp.EnumerateArray().Select(v => v.Clone()));
+ }
+
+ var message = validator.TryGetProperty("message", out var messageProp) ? messageProp.GetString() : null;
+ validators.Add(new FormValidatorSummary(type, value, values, message));
+ }
+
+ return validators;
+ }
+
+ ///
+ /// 提取节点上的联动规则摘要,供后端校验时求值(如跳过被联动隐藏的字段)。
+ ///
+ private static IReadOnlyList ExtractReactions(JsonElement node)
+ {
+ var reactions = new List();
+ if (!node.TryGetProperty("x-reactions", out var arr) || arr.ValueKind != JsonValueKind.Array)
+ {
+ return reactions;
+ }
+
+ foreach (var r in arr.EnumerateArray())
+ {
+ if (r.ValueKind != JsonValueKind.Object) continue;
+ var type = r.TryGetProperty("type", out var t) ? t.GetString() : null;
+ if (type is null) continue;
+
+ var target = r.TryGetProperty("target", out var tgt) ? tgt.GetString() : null;
+
+ ReactionWhenSummary? when = null;
+ if (r.TryGetProperty("when", out var w) && w.ValueKind == JsonValueKind.Object)
+ {
+ var source = w.TryGetProperty("source", out var s) ? s.GetString() ?? "" : "";
+ var op = w.TryGetProperty("operator", out var o) ? o.GetString() ?? "" : "";
+ var val = w.TryGetProperty("value", out var v) ? v.Clone() : (JsonElement?)null;
+ when = new ReactionWhenSummary(source, op, val);
+ }
+
+ var action = r.TryGetProperty("action", out var a) ? a.GetString() : null;
+
+ ReactionExpressionSummary? expr = null;
+ if (r.TryGetProperty("expression", out var e) && e.ValueKind == JsonValueKind.Object)
+ {
+ var left = e.TryGetProperty("left", out var l) ? l.GetString() ?? "" : "";
+ var op = e.TryGetProperty("operator", out var eo) ? eo.GetString() ?? "" : "";
+ var right = e.TryGetProperty("right", out var rt) ? rt.GetString() ?? "" : "";
+ expr = new ReactionExpressionSummary(left, op, right);
+ }
+
+ reactions.Add(new ReactionSummary(type, target, when, action, expr));
+ }
+
+ return reactions;
+ }
+
+ ///
+ /// 校验单个节点上 x-reactions 的结构合法性:type/target/action/operator 取值必须合法。
+ /// 字段引用(target/source 是否指向已存在字段)由 ValidateReactionReferences 统一处理。
+ ///
+ private static void ValidateReactions(JsonElement node, string path, List errors)
+ {
+ if (!node.TryGetProperty("x-reactions", out var reactions) || reactions.ValueKind != JsonValueKind.Array)
+ {
+ return;
+ }
+
+ var index = 0;
+ foreach (var reaction in reactions.EnumerateArray())
+ {
+ var where = $"节点 {path} 的联动规则 #{index}";
+ if (reaction.ValueKind != JsonValueKind.Object)
+ {
+ errors.Add($"{where} 必须是 JSON 对象");
+ index++;
+ continue;
+ }
+
+ var type = reaction.TryGetProperty("type", out var typeProp) ? typeProp.GetString() : null;
+ if (type is null || !ReactionTypes.Contains(type))
+ {
+ errors.Add($"{where} 的 type '{type}' 不合法,允许值: {string.Join(", ", ReactionTypes)}");
+ index++;
+ continue;
+ }
+
+ var target = reaction.TryGetProperty("target", out var targetProp) ? targetProp.GetString() : null;
+ if (string.IsNullOrWhiteSpace(target))
+ {
+ errors.Add($"{where} 缺少 target(受影响的字段名)");
+ }
+
+ if (type == "condition")
+ {
+ if (reaction.TryGetProperty("when", out var when) && when.ValueKind == JsonValueKind.Object)
+ {
+ var op = when.TryGetProperty("operator", out var opProp) ? opProp.GetString() : null;
+ if (op is null || !ConditionOperators.Contains(op))
+ {
+ errors.Add($"{where} 的 when.operator '{op}' 不合法,允许值: {string.Join(", ", ConditionOperators)}");
+ }
+ var source = when.TryGetProperty("source", out var sourceProp) ? sourceProp.GetString() : null;
+ if (string.IsNullOrWhiteSpace(source))
+ {
+ errors.Add($"{where} 缺少 when.source(来源字段名)");
+ }
+ }
+ else
+ {
+ errors.Add($"{where} 是 condition 类型,必须包含 when 对象");
+ }
+
+ var action = reaction.TryGetProperty("action", out var actionProp) ? actionProp.GetString() : null;
+ if (action is null || !ReactionActions.Contains(action))
+ {
+ errors.Add($"{where} 的 action '{action}' 不合法,允许值: {string.Join(", ", ReactionActions)}");
+ }
+ }
+ else // data
+ {
+ if (reaction.TryGetProperty("expression", out var expr) && expr.ValueKind == JsonValueKind.Object)
+ {
+ var op = expr.TryGetProperty("operator", out var opProp) ? opProp.GetString() : null;
+ if (op is null || !ExpressionOperators.Contains(op))
+ {
+ errors.Add($"{where} 的 expression.operator '{op}' 不合法,允许值: {string.Join(", ", ExpressionOperators)}");
+ }
+ if (!expr.TryGetProperty("left", out var leftProp) || leftProp.ValueKind != JsonValueKind.String)
+ {
+ errors.Add($"{where} 的 expression.left 必须是字符串(字段名或数字字面量)");
+ }
+ if (!expr.TryGetProperty("right", out var rightProp) || rightProp.ValueKind != JsonValueKind.String)
+ {
+ errors.Add($"{where} 的 expression.right 必须是字符串(字段名或数字字面量)");
+ }
+ }
+ else
+ {
+ errors.Add($"{where} 是 data 类型,必须包含 expression 对象");
+ }
+ }
+
+ index++;
+ }
+ }
+
+ ///
+ /// 校验联动规则中的字段引用:reaction.target、when.source、expression.left/right
+ /// 当这些值形似合法字段名(字母开头)时,必须指向 schema 内已存在的字段路径。
+ /// 纯数字字面量(如 "8"、"0")不视为字段引用,跳过校验。
+ ///
+ private static void ValidateReactionReferences(JsonElement root, HashSet fieldPaths, List errors)
+ {
+ if (!root.TryGetProperty("properties", out var props) || props.ValueKind != JsonValueKind.Object)
+ {
+ return;
+ }
+
+ WalkReactionReferences(props, "", fieldPaths, errors);
+ }
+
+ private static void WalkReactionReferences(JsonElement props, string parentPath, HashSet fieldPaths, List errors)
+ {
+ foreach (var prop in props.EnumerateObject())
+ {
+ var path = string.IsNullOrEmpty(parentPath) ? prop.Name : $"{parentPath}.{prop.Name}";
+ var node = prop.Value;
+ if (node.ValueKind != JsonValueKind.Object) continue;
+
+ if (node.TryGetProperty("x-reactions", out var reactions) && reactions.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var reaction in reactions.EnumerateArray())
+ {
+ if (reaction.ValueKind != JsonValueKind.Object) continue;
+ var type = reaction.TryGetProperty("type", out var typeProp) ? typeProp.GetString() : null;
+
+ var target = reaction.TryGetProperty("target", out var targetProp) ? targetProp.GetString() : null;
+ CheckFieldReference(target, fieldPaths, errors, $"节点 {path} 的联动规则 target '{target}'");
+
+ if (type == "condition" &&
+ reaction.TryGetProperty("when", out var when) && when.ValueKind == JsonValueKind.Object)
+ {
+ var source = when.TryGetProperty("source", out var sourceProp) ? sourceProp.GetString() : null;
+ CheckFieldReference(source, fieldPaths, errors, $"节点 {path} 的联动规则 when.source '{source}'");
+ }
+ else if (type == "data" &&
+ reaction.TryGetProperty("expression", out var expr) && expr.ValueKind == JsonValueKind.Object)
+ {
+ var left = expr.TryGetProperty("left", out var leftProp) ? leftProp.GetString() : null;
+ CheckFieldReference(left, fieldPaths, errors, $"节点 {path} 的联动规则 expression.left '{left}'");
+ var right = expr.TryGetProperty("right", out var rightProp) ? rightProp.GetString() : null;
+ CheckFieldReference(right, fieldPaths, errors, $"节点 {path} 的联动规则 expression.right '{right}'");
+ }
+ }
+ }
+
+ if (node.TryGetProperty("properties", out var childProps) && childProps.ValueKind == JsonValueKind.Object)
+ {
+ WalkReactionReferences(childProps, path, fieldPaths, errors);
+ }
+ }
+ }
+
+ ///
+ /// 当 token 形似字段名(字母开头、非纯数字)时,校验它是否存在于 fieldPaths。
+ /// 纯数字字符串视为字面量,跳过校验。
+ ///
+ private static void CheckFieldReference(string? token, HashSet fieldPaths, List errors, string errorPrefix)
+ {
+ if (string.IsNullOrWhiteSpace(token)) return;
+ // 纯数字(含小数、负号)视为字面量,不校验
+ if (double.TryParse(token, out _)) return;
+ if (!fieldPaths.Contains(token))
+ {
+ errors.Add($"{errorPrefix} 引用了不存在的字段");
+ }
+ }
+
+ private static readonly HashSet DataSourceTypes = ["static", "remote"];
+ private static readonly HashSet DataSourceMethods = ["GET", "POST"];
+
+ ///
+ /// 校验 x-data-source 配置。remote 类型要求 url 为相对路径(安全约束:禁止绝对地址/外网请求),
+ /// method 合法,labelField/valueField 非空。
+ ///
+ private static void ValidateDataSource(JsonElement node, string path, List errors)
+ {
+ if (!node.TryGetProperty("x-data-source", out var ds) || ds.ValueKind != JsonValueKind.Object)
+ {
+ return;
+ }
+
+ var where = $"节点 {path} 的数据源";
+ var type = ds.TryGetProperty("type", out var typeProp) ? typeProp.GetString() : null;
+ if (type is null || !DataSourceTypes.Contains(type))
+ {
+ errors.Add($"{where} 的 type '{type}' 不合法,允许值: {string.Join(", ", DataSourceTypes)}");
+ return;
+ }
+
+ if (type == "static") return;
+
+ // remote 校验
+ var url = ds.TryGetProperty("url", out var urlProp) ? urlProp.GetString() : null;
+ if (string.IsNullOrWhiteSpace(url))
+ {
+ errors.Add($"{where} 是 remote 类型,必须配置 url");
+ }
+ else
+ {
+ // 安全约束:url 必须是相对路径,禁止绝对地址(http(s)://)防止 SSRF / 外网请求
+ var trimmed = url.Trim();
+ if (trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
+ trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
+ trimmed.StartsWith("//", StringComparison.Ordinal) ||
+ trimmed.Contains("://", StringComparison.Ordinal))
+ {
+ errors.Add($"{where} 的 url 必须是相对路径(以 / 开头),不允许绝对地址");
+ }
+ else if (!trimmed.StartsWith('/'))
+ {
+ errors.Add($"{where} 的 url 必须以 / 开头(相对路径)");
+ }
+ }
+
+ var method = ds.TryGetProperty("method", out var methodProp) ? methodProp.GetString() : null;
+ if (method is not null && method != "GET" && !DataSourceMethods.Contains(method))
+ {
+ errors.Add($"{where} 的 method '{method}' 不合法,允许值: {string.Join(", ", DataSourceMethods)}");
+ }
+ }
+
+ ///
+ /// 校验 x-field-permission 结构:必须是对象,key 为节点名(非空字符串),
+ /// value 必须是 visible/readonly/hidden 三者之一。
+ ///
+ private static void ValidateFieldPermission(JsonElement node, string path, List errors)
+ {
+ if (!node.TryGetProperty("x-field-permission", out var perm) || perm.ValueKind != JsonValueKind.Object)
+ {
+ return;
+ }
+
+ var where = $"节点 {path} 的字段权限配置";
+ foreach (var entry in perm.EnumerateObject())
+ {
+ if (string.IsNullOrWhiteSpace(entry.Name))
+ {
+ errors.Add($"{where} 存在空的节点名 key");
+ }
+
+ if (entry.Value.ValueKind != JsonValueKind.String)
+ {
+ errors.Add($"{where} 中节点 '{entry.Name}' 的权限值必须是字符串");
+ continue;
+ }
+
+ var action = entry.Value.GetString();
+ if (action is null || !FieldPermissionActions.Contains(action))
+ {
+ errors.Add($"{where} 中节点 '{entry.Name}' 的权限 '{action}' 不合法,允许值: {string.Join(", ", FieldPermissionActions)}");
+ }
+ }
+ }
+
+ ///
+ /// 提取 x-field-permission 为字典摘要。null/非对象返回 null(表示未配置权限)。
+ ///
+ private static IReadOnlyDictionary? ExtractFieldPermission(JsonElement node)
+ {
+ if (!node.TryGetProperty("x-field-permission", out var perm) || perm.ValueKind != JsonValueKind.Object)
+ {
+ return null;
+ }
+
+ var dict = new Dictionary();
+ foreach (var entry in perm.EnumerateObject())
+ {
+ if (entry.Value.ValueKind == JsonValueKind.String)
+ {
+ var action = entry.Value.GetString();
+ if (!string.IsNullOrWhiteSpace(action))
+ {
+ dict[entry.Name] = action;
+ }
+ }
+ }
+
+ return dict.Count > 0 ? dict : null;
+ }
}
diff --git a/src/Workflow.Application/Notifications/INotificationService.cs b/src/Workflow.Application/Notifications/INotificationService.cs
new file mode 100644
index 0000000..5a004b7
--- /dev/null
+++ b/src/Workflow.Application/Notifications/INotificationService.cs
@@ -0,0 +1,26 @@
+using Workflow.Domain.Entities;
+
+namespace Workflow.Application.Notifications;
+
+///
+/// 通知服务抽象。统一处理任务生命周期事件触发的站内信与 Webhook 通知。
+/// 实现须保证:站内信 Notification 记录在调用方事务内同步落库(必达),
+/// Webhook 由 WebhookDispatcherService 异步投递(失败可重试)。
+///
+public interface INotificationService
+{
+ /// 任务到达:审批/抄送任务创建后通知受理人。
+ Task NotifyTaskArrivedAsync(WorkflowTask task, CancellationToken ct = default);
+
+ /// 审批通过:通知发起人与下一节点受理人(如适用)。
+ Task NotifyTaskApprovedAsync(WorkflowTask task, CancellationToken ct = default);
+
+ /// 驳回:通知发起人任务被驳回。
+ Task NotifyTaskRejectedAsync(WorkflowTask task, CancellationToken ct = default);
+
+ /// 催办:通知受理人尽快处理。
+ Task NotifyTaskUrgedAsync(WorkflowTask task, CancellationToken ct = default);
+
+ /// 超时自动处理:通知相关人任务因超时被系统自动通过/驳回。
+ Task NotifyTaskTimeoutAsync(WorkflowTask task, bool autoApproved, CancellationToken ct = default);
+}
diff --git a/src/Workflow.Application/Notifications/NotificationOptions.cs b/src/Workflow.Application/Notifications/NotificationOptions.cs
new file mode 100644
index 0000000..62722a0
--- /dev/null
+++ b/src/Workflow.Application/Notifications/NotificationOptions.cs
@@ -0,0 +1,48 @@
+namespace Workflow.Application.Notifications;
+
+///
+/// 通知系统配置,对应 appsettings.json 的 Notification 段。
+/// 首次在本项目引入 IOptions 模式(比现有 config["key"] 更可读),仅供通知模块使用。
+///
+public class NotificationOptions
+{
+ public TimeoutSchedulerSection Scheduler { get; set; } = new();
+
+ public WebhookSection Webhook { get; set; } = new();
+
+ public class TimeoutSchedulerSection
+ {
+ /// 超时扫描轮询间隔(秒)。
+ public int PollIntervalSeconds { get; set; } = 60;
+
+ /// 应用启动后延迟多少秒开始首次扫描,避免与启动初始化争抢资源。
+ public int StartupDelaySeconds { get; set; } = 15;
+ }
+
+ public class WebhookSection
+ {
+ /// Webhook 后台投递轮询间隔(秒)。
+ public int PollIntervalSeconds { get; set; } = 30;
+
+ /// HTTP 请求超时(秒)。
+ public int TimeoutSeconds { get; set; } = 10;
+
+ /// 最大尝试次数(含首次)。超过则标 failed 不再重试。
+ public int MaxAttempts { get; set; } = 3;
+
+ ///
+ /// 允许的 Webhook 目标主机白名单(不含端口/协议,大小写不敏感)。
+ /// 空数组表示禁用所有 Webhook 投递(SSRF 防护的默认安全姿态)。
+ ///
+ public string[] AllowedHosts { get; set; } = [];
+
+ ///
+ /// 默认 Webhook 目标 URL。当节点未单独配置 webhook 时,通知事件投递到此地址。
+ /// 必须在 AllowedHosts 白名单内才生效。空字符串表示不配置全局默认 Webhook。
+ ///
+ public string DefaultUrl { get; set; } = string.Empty;
+ }
+}
+
+/// TimeoutSchedulerOptions 的伴生别名(与 TimeoutSchedulerService.cs 中定义一致),
+/// 此处不再重复,仅用 NotificationOptions.Scheduler 统一配置。
diff --git a/src/Workflow.Application/Notifications/NotificationService.cs b/src/Workflow.Application/Notifications/NotificationService.cs
new file mode 100644
index 0000000..c5bd67c
--- /dev/null
+++ b/src/Workflow.Application/Notifications/NotificationService.cs
@@ -0,0 +1,199 @@
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+using Workflow.Domain.Entities;
+using Workflow.Domain.Exceptions;
+using Workflow.Infrastructure.Persistence;
+
+namespace Workflow.Application.Notifications;
+
+///
+/// 通知服务实现。处理任务生命周期事件:
+/// - 站内信:同步落库 Notification(必达,随调用方事务提交)
+/// - Webhook:落库 WebhookDelivery 标 pending,由 WebhookDispatcherService 异步投递
+///
+/// 接收人解析策略:优先 task.AssigneeId(user: 规则),其次 task.AssigneeRole(role: 规则)。
+/// 审批通过/驳回/催办场景,除当前受理人外,还会通知实例发起人(作为业务相关方)。
+///
+public class NotificationService(
+ WorkflowDbContext db,
+ IOptions options) : INotificationService
+{
+ private readonly NotificationOptions _options = options.Value;
+
+ public Task NotifyTaskArrivedAsync(WorkflowTask task, CancellationToken ct = default)
+ => CreateAndDispatchAsync(task, "task-arrived",
+ $"您有新的待办任务:{task.Title}", "请尽快处理。", ct: ct);
+
+ public Task NotifyTaskApprovedAsync(WorkflowTask task, CancellationToken ct = default)
+ => CreateAndDispatchAsync(task, "approved",
+ $"任务已通过:{task.Title}", "该任务已被审批通过。", includeInitiator: true, ct: ct);
+
+ public Task NotifyTaskRejectedAsync(WorkflowTask task, CancellationToken ct = default)
+ => CreateAndDispatchAsync(task, "rejected",
+ $"任务被驳回:{task.Title}", "该任务已被驳回,请关注后续处理。", includeInitiator: true, ct: ct);
+
+ public Task NotifyTaskUrgedAsync(WorkflowTask task, CancellationToken ct = default)
+ => CreateAndDispatchAsync(task, "urged",
+ $"任务催办:{task.Title}", "请尽快处理该待办任务。", ct: ct);
+
+ public Task NotifyTaskTimeoutAsync(WorkflowTask task, bool autoApproved, CancellationToken ct = default)
+ {
+ var verb = autoApproved ? "自动通过" : "自动驳回";
+ var category = autoApproved ? "timeout-approved" : "timeout-rejected";
+ return CreateAndDispatchAsync(task, category,
+ $"任务超时{verb}:{task.Title}",
+ $"该任务因超时已被系统{verb}。", includeInitiator: true, ct: ct);
+ }
+
+ ///
+ /// 统一的通知落库逻辑:为受理人(及发起人)创建站内信,并为配置的 Webhook 创建投递记录。
+ /// 所有写操作都在调用方的 DbContext/事务内,确保与业务状态同生共死(必达)。
+ ///
+ private async Task CreateAndDispatchAsync(
+ WorkflowTask task, string category, string title, string content,
+ bool includeInitiator = false, CancellationToken ct = default)
+ {
+ var now = DateTime.UtcNow;
+
+ // 1. 给当前受理人创建站内信
+ var recipientNotifications = new List();
+
+ if (task.AssigneeId.HasValue)
+ {
+ recipientNotifications.Add(NewNotification(task, category, title, content, now,
+ recipientUserId: task.AssigneeId.Value, recipientRole: null));
+ }
+ else if (!string.IsNullOrWhiteSpace(task.AssigneeRole))
+ {
+ // role: 规则:发给该角色全体(前端按角色展开),存一条 role 维度的通知
+ recipientNotifications.Add(NewNotification(task, category, title, content, now,
+ recipientUserId: null, recipientRole: task.AssigneeRole));
+ }
+
+ // 2. 业务相关方(发起人)也通知一份(审批通过/驳回/超时场景)
+ if (includeInitiator)
+ {
+ var initiator = await GetInitiatorIdAsync(task.InstanceId, ct);
+ if (initiator.HasValue)
+ {
+ // 避免发起人同时是受理人时重复通知
+ if (!recipientNotifications.Any(n => n.RecipientUserId == initiator.Value))
+ {
+ recipientNotifications.Add(NewNotification(task, category, title, content, now,
+ recipientUserId: initiator.Value, recipientRole: null));
+ }
+ }
+ }
+
+ if (recipientNotifications.Count > 0)
+ {
+ await db.Notifications.AddRangeAsync(recipientNotifications, ct);
+ }
+
+ // 3. Webhook 投递记录(若有配置)
+ await CreateWebhookDeliveryAsync(task, category, title, content, now, ct);
+ }
+
+ private static Notification NewNotification(
+ WorkflowTask task, string category, string title, string content, DateTime now,
+ Guid? recipientUserId, string? recipientRole) => new()
+ {
+ Id = Guid.NewGuid(),
+ RecipientUserId = recipientUserId,
+ RecipientRole = recipientRole,
+ Title = title,
+ Content = content,
+ Category = category,
+ RelatedInstanceId = task.InstanceId,
+ RelatedTaskId = task.Id,
+ IsRead = false,
+ CreatedAt = now,
+ UpdatedAt = now
+ };
+
+ private async Task GetInitiatorIdAsync(Guid instanceId, CancellationToken ct)
+ {
+ var instance = await db.WorkflowInstances
+ .AsNoTracking()
+ .FirstOrDefaultAsync(i => i.Id == instanceId, ct);
+ return instance?.InitiatorId;
+ }
+
+ private async Task CreateWebhookDeliveryAsync(
+ WorkflowTask task, string category, string title, string content, DateTime now, CancellationToken ct)
+ {
+ var webhookUrl = ResolveWebhookUrl();
+ if (string.IsNullOrWhiteSpace(webhookUrl))
+ return;
+
+ // SSRF 防护:URL 主机必须在白名单内
+ if (!IsHostAllowed(webhookUrl))
+ return;
+
+ var payload = JsonSerializer.Serialize(new
+ {
+ @event = category,
+ title,
+ content,
+ task = new
+ {
+ id = task.Id,
+ instanceId = task.InstanceId,
+ nodeId = task.NodeId,
+ title = task.Title,
+ assigneeId = task.AssigneeId,
+ assigneeRole = task.AssigneeRole,
+ type = task.Type.ToString(),
+ dueAt = task.DueAt
+ },
+ timestamp = now
+ });
+
+ var delivery = new WebhookDelivery
+ {
+ Id = Guid.NewGuid(),
+ Url = webhookUrl,
+ Payload = payload,
+ HttpMethod = "POST",
+ Status = "pending",
+ Attempts = 0,
+ MaxAttempts = _options.Webhook.MaxAttempts,
+ NextRetryAt = now,
+ Category = category,
+ RelatedInstanceId = task.InstanceId,
+ RelatedTaskId = task.Id,
+ CreatedAt = now,
+ UpdatedAt = now
+ };
+
+ await db.WebhookDeliveries.AddAsync(delivery, ct);
+ }
+
+ ///
+ /// 解析 Webhook 目标 URL:优先节点 config 的 webhookUrl,否则用全局 DefaultUrl。
+ ///
+ private string ResolveWebhookUrl()
+ {
+ // 当前实现使用全局 DefaultUrl;节点级 webhookUrl 配置可后续扩展(需 ProcessEngine 传入 node.Config)
+ var url = _options.Webhook.DefaultUrl;
+ return string.IsNullOrWhiteSpace(url) ? string.Empty : url;
+ }
+
+ ///
+ /// SSRF 防护:校验 URL 的主机是否在配置的白名单 AllowedHosts 内。
+ /// 白名单为空时拒绝所有(默认安全姿态)。
+ ///
+ internal bool IsHostAllowed(string url)
+ {
+ if (_options.Webhook.AllowedHosts is null || _options.Webhook.AllowedHosts.Length == 0)
+ return false;
+
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
+ return false;
+
+ var host = uri.Host;
+ return _options.Webhook.AllowedHosts
+ .Any(allowed => string.Equals(allowed, host, StringComparison.OrdinalIgnoreCase));
+ }
+}
diff --git a/src/Workflow.Application/Scheduler/OverdueTaskProcessor.cs b/src/Workflow.Application/Scheduler/OverdueTaskProcessor.cs
new file mode 100644
index 0000000..c0fb02f
--- /dev/null
+++ b/src/Workflow.Application/Scheduler/OverdueTaskProcessor.cs
@@ -0,0 +1,122 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Workflow.Application.Engine;
+using Workflow.Application.Notifications;
+using Workflow.Domain.Entities;
+using Workflow.Domain.Enums;
+using Workflow.Infrastructure.Persistence;
+
+using TaskStatus = Workflow.Domain.Enums.TaskStatus;
+
+namespace Workflow.Application.Scheduler;
+
+///
+/// 逾期任务处理器:扫描逾期的 Pending 任务并触发自动处理(TimeoutAutoProcess)。
+/// 纯应用层逻辑,无 HostedService 依赖,便于单元测试。
+/// 由 TimeoutSchedulerService(Api 层的 BackgroundService)周期性调用 ExecuteAsync。
+///
+/// 关键约束(调研所得,须严格遵守):
+/// 1. CompleteTaskAsync 本身不校验 instance.Status,故查询须 JOIN 过滤 instance.Status == Running,
+/// 否则会推进已挂起/已终止实例的任务,破坏暂停语义。
+/// 2. 每个任务独立 try/catch,单个失败不影响其它任务。
+/// 3. 事务为可选:生产 Postgres 走真事务保证原子;InMemory provider 不支持事务时降级无事务。
+///
+public class OverdueTaskProcessor(
+ WorkflowDbContext db,
+ ProcessEngine processEngine,
+ INotificationService notifier,
+ ILogger? logger = null)
+{
+ /// 执行一轮逾期任务扫描与自动处理。
+ public async Task ExecuteAsync(CancellationToken ct = default)
+ {
+ var now = DateTime.UtcNow;
+
+ // 关键守卫:只处理 Running 实例的逾期 Pending 任务。
+ // CompleteTaskAsync 不校验 instance.Status,此处必须过滤,避免推进 Suspended/Terminated 实例。
+ var overdueTasks = await (
+ from t in db.WorkflowTasks.AsNoTracking()
+ join i in db.WorkflowInstances on t.InstanceId equals i.Id
+ where t.Status == TaskStatus.Pending
+ && t.DueAt != null
+ && t.DueAt < now
+ && i.Status == InstanceStatus.Running
+ && !i.IsDeleted
+ select new OverdueTask(t.Id, t.InstanceId, t.NodeId, t.Title)
+ ).ToListAsync(ct);
+
+ if (overdueTasks.Count == 0)
+ return;
+
+ logger?.LogInformation("发现 {Count} 个逾期任务待自动处理", overdueTasks.Count);
+
+ foreach (var info in overdueTasks)
+ {
+ await ProcessOneAsync(info, ct);
+ }
+ }
+
+ private async Task ProcessOneAsync(OverdueTask info, CancellationToken ct)
+ {
+ // 事务为可选:生产 Postgres 走真事务;InMemory 不支持事务时降级无事务。
+ Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null;
+ try
+ {
+ try { tx = await db.Database.BeginTransactionAsync(ct); }
+ catch (InvalidOperationException) { /* InMemory 等不支持事务的 provider */ }
+
+ await ProcessOneCoreAsync(info, ct);
+
+ if (tx is not null) await tx.CommitAsync(ct);
+ }
+ catch (Exception ex)
+ {
+ if (tx is not null) { try { await tx.RollbackAsync(ct); } catch { } }
+ logger?.LogError(ex, "逾期任务 {TaskId} 自动处理失败", info.Id);
+ }
+ finally
+ {
+ if (tx is not null) await tx.DisposeAsync();
+ }
+ }
+
+ private async Task ProcessOneCoreAsync(OverdueTask info, CancellationToken ct)
+ {
+ // 重新取出可追踪实体(AsNoTracking 拿的是投影)
+ var task = await db.WorkflowTasks.FirstOrDefaultAsync(t => t.Id == info.Id, ct);
+ if (task is null || task.Status != TaskStatus.Pending)
+ return; // 并发:可能已被人工处理
+
+ var node = await db.WorkflowNodes.FirstOrDefaultAsync(n => n.Id == info.NodeId, ct);
+ if (node is null)
+ {
+ logger?.LogWarning("逾期任务 {TaskId} 的节点 {NodeId} 不存在,跳过", info.Id, info.NodeId);
+ return;
+ }
+
+ // autoApproveOnTimeout 默认 true(与 TaskStateMachine.TaskTransitionContext 一致)
+ var config = NodeConfigParser.Parse(node.Config);
+ var autoApprove = NodeConfigParser.GetBool(config, "autoApproveOnTimeout") ?? true;
+
+ // 审计痕迹
+ task.Comment = autoApprove
+ ? $"[系统] 节点超时(截止 {task.DueAt:O}),自动通过"
+ : $"[系统] 节点超时(截止 {task.DueAt:O}),自动驳回";
+
+ var result = autoApprove ? TaskResult.Approved : TaskResult.Rejected;
+
+ // 复用引擎的"完成任务 + 推进 token"逻辑
+ await processEngine.CompleteTaskAsync(task, result);
+
+ // 通知(自带异常保护,不阻断主流程)
+ try { await notifier.NotifyTaskTimeoutAsync(task, autoApproved: autoApprove, ct); }
+ catch (Exception notifyEx)
+ {
+ logger?.LogWarning(notifyEx, "逾期任务 {TaskId} 的超时通知发送失败(不影响自动处理结果)", info.Id);
+ }
+
+ logger?.LogInformation("逾期任务 {TaskId} 已自动 {Result}", info.Id, result);
+ }
+
+ private sealed record OverdueTask(Guid Id, Guid InstanceId, Guid NodeId, string Title);
+}
diff --git a/src/Workflow.Application/Workflow.Application.csproj b/src/Workflow.Application/Workflow.Application.csproj
index 67afb3d..fdb9005 100644
--- a/src/Workflow.Application/Workflow.Application.csproj
+++ b/src/Workflow.Application/Workflow.Application.csproj
@@ -5,4 +5,7 @@
+
+
+
diff --git a/src/Workflow.Domain/Common/Interfaces.cs b/src/Workflow.Domain/Common/Interfaces.cs
index e9b8e6e..fc4e4e3 100644
--- a/src/Workflow.Domain/Common/Interfaces.cs
+++ b/src/Workflow.Domain/Common/Interfaces.cs
@@ -1,5 +1,6 @@
namespace Workflow.Domain.Common;
+/// 审计字段标记接口:实体实现后由 AuditInterceptor 自动填充创建/更新人与时间。
public interface IAuditable
{
Guid CreatedBy { get; set; }
@@ -8,18 +9,22 @@ public interface IAuditable
DateTime UpdatedAt { get; set; }
}
+/// 软删除标记接口:EF Core 查询过滤器自动隐藏 IsDeleted=true 的记录,不实际删除数据。
public interface ISoftDelete
{
- bool IsDeleted { get; set; }
+ bool IsDeleted { get; set; }
}
+/// 操作者 IP 标记接口:用于审计溯源,记录每次操作的来源 IP。
public interface IHasOperatorIP
{
string? OperatorIP { get; set; }
}
+/// 完整审计聚合:审计 + 软删除 + 操作 IP,工作流核心实体的标配。
public interface IFullAudit : IAuditable, ISoftDelete, IHasOperatorIP;
+/// 当前用户上下文抽象,供应用层获取登录用户身份与 IP,填充审计字段。
public interface ICurrentUserContext
{
Guid GetUserId();
diff --git a/src/Workflow.Domain/Entities/FormComponentRegistry.cs b/src/Workflow.Domain/Entities/FormComponentRegistry.cs
index 43bc13a..d7e18f4 100644
--- a/src/Workflow.Domain/Entities/FormComponentRegistry.cs
+++ b/src/Workflow.Domain/Entities/FormComponentRegistry.cs
@@ -2,13 +2,19 @@ using Workflow.Domain.Common;
namespace Workflow.Domain.Entities;
+///
+/// 表单组件注册表:定义可拖拽使用的表单组件元信息(名称、图标、默认 Schema、支持属性)。
+/// 供前端表单设计器渲染组件面板;IsActive 控制是否在前端可见。
+///
public class FormComponentRegistry : BaseEntity, IFullAudit
{
public string Name { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string Icon { get; set; } = string.Empty;
+ /// 组件默认 Formily Schema 片段,拖入设计器时作为初始结构。
public string DefaultSchema { get; set; } = string.Empty;
+ /// 组件可配置的属性清单(JSON),约束设计器属性面板渲染范围。
public string SupportedProps { get; set; } = "{}";
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
diff --git a/src/Workflow.Domain/Entities/FormData.cs b/src/Workflow.Domain/Entities/FormData.cs
index b29bab5..c9b2056 100644
--- a/src/Workflow.Domain/Entities/FormData.cs
+++ b/src/Workflow.Domain/Entities/FormData.cs
@@ -2,9 +2,14 @@ using Workflow.Domain.Common;
namespace Workflow.Domain.Entities;
+///
+/// 表单数据:流程实例在某表单定义下提交的具体填写内容。
+/// 一条流程实例可有多条 FormData(启动时主表单 + 各审批节点表单),通过 InstanceId 关联。
+///
public class FormData : BaseEntity, IFullAudit
{
public Guid FormDefinitionId { get; set; }
+ /// 所属流程实例 ID。同一实例可对应多份表单数据(启动表单 + 各节点表单)。
public Guid InstanceId { get; set; }
public string DataJson { get; set; } = string.Empty;
diff --git a/src/Workflow.Domain/Entities/FormDefinition.cs b/src/Workflow.Domain/Entities/FormDefinition.cs
index 369faa1..c546180 100644
--- a/src/Workflow.Domain/Entities/FormDefinition.cs
+++ b/src/Workflow.Domain/Entities/FormDefinition.cs
@@ -3,12 +3,17 @@ using Workflow.Domain.Enums;
namespace Workflow.Domain.Entities;
+///
+/// 表单定义:基于 Formily JSON Schema 描述的动态表单。可被流程定义(主表单)和节点(审批/抄送表单)引用。
+/// 版本快照写入 FormDefinitionVersion;Status=Disabled 的表单会被 StartInstance/Approve 等严格阻断。
+///
public class FormDefinition : BaseEntity, IFullAudit
{
public string Name { get; set; } = string.Empty;
public string Code { get; set; } = string.Empty;
public int Version { get; set; } = 1;
public string? Description { get; set; }
+ /// Formily Schema JSON。由 SchemaValidator 校验合法性,FormDataValidator 据此校验提交数据。
public string? SchemaJson { get; set; }
public FormStatus Status { get; set; } = FormStatus.Draft;
diff --git a/src/Workflow.Domain/Entities/FormDefinitionVersion.cs b/src/Workflow.Domain/Entities/FormDefinitionVersion.cs
new file mode 100644
index 0000000..81747e8
--- /dev/null
+++ b/src/Workflow.Domain/Entities/FormDefinitionVersion.cs
@@ -0,0 +1,31 @@
+using Workflow.Domain.Common;
+
+namespace Workflow.Domain.Entities;
+
+///
+/// 表单定义的历史版本快照。每次保存或发布时写入一条,用于版本对比和回溯。
+///
+public class FormDefinitionVersion : BaseEntity, IAuditable
+{
+ /// 所属表单定义 ID
+ public Guid FormDefinitionId { get; set; }
+
+ /// 版本号(与保存时的 FormDefinition.Version 对应)
+ public int Version { get; set; }
+
+ /// 该版本的 Schema JSON 快照
+ public string? SchemaJson { get; set; }
+
+ /// 触发来源:Update(保存)/ Publish(发布)
+ public string Source { get; set; } = "Update";
+
+ /// 变更摘要(可选)
+ public string? ChangeSummary { get; set; }
+
+ public Guid CreatedBy { get; set; }
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ public Guid UpdatedBy { get; set; }
+ public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
+ public bool IsDeleted { get; set; } = false;
+ public string? OperatorIP { get; set; }
+}
diff --git a/src/Workflow.Domain/Entities/Notification.cs b/src/Workflow.Domain/Entities/Notification.cs
new file mode 100644
index 0000000..db01865
--- /dev/null
+++ b/src/Workflow.Domain/Entities/Notification.cs
@@ -0,0 +1,35 @@
+using Workflow.Domain.Common;
+
+namespace Workflow.Domain.Entities;
+
+///
+/// 站内通知(站内信)。任务到达、审批、驳回、催办、超时等事件触发的对内消息。
+/// RecipientUserId 与 RecipientRole 互斥:按 user: 规则派发到具体用户,按 role: 规则派发到角色全体。
+///
+public class Notification : BaseEntity, IFullAudit
+{
+ /// 收件人用户 ID(按 user: 规则派发时使用)。与 RecipientRole 互斥。
+ public Guid? RecipientUserId { get; set; }
+
+ /// 收件角色名(按 role: 规则派发时使用,由前端/上层按角色展开为实际用户)。与 RecipientUserId 互斥。
+ public string? RecipientRole { get; set; }
+
+ public string Title { get; set; } = string.Empty;
+ public string Content { get; set; } = string.Empty;
+
+ /// 通知分类:task-arrived / approved / rejected / urged / timeout / cc / system。
+ public string Category { get; set; } = string.Empty;
+
+ public Guid? RelatedInstanceId { get; set; }
+ public Guid? RelatedTaskId { get; set; }
+
+ public bool IsRead { get; set; } = false;
+ public DateTime? ReadAt { get; set; }
+
+ public Guid CreatedBy { get; set; }
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ public Guid UpdatedBy { get; set; }
+ public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
+ public bool IsDeleted { get; set; } = false;
+ public string? OperatorIP { get; set; }
+}
diff --git a/src/Workflow.Domain/Entities/WebhookDelivery.cs b/src/Workflow.Domain/Entities/WebhookDelivery.cs
new file mode 100644
index 0000000..0f2fa03
--- /dev/null
+++ b/src/Workflow.Domain/Entities/WebhookDelivery.cs
@@ -0,0 +1,41 @@
+using Workflow.Domain.Common;
+
+namespace Workflow.Domain.Entities;
+
+///
+/// Webhook 投递记录。每次通知事件需外发 Webhook 时创建一条 pending 记录,
+/// 由 WebhookDispatcherService 后台轮询投递,记录每次尝试结果与重试计划,确保可追溯、可重试。
+///
+public class WebhookDelivery : BaseEntity, IAuditable
+{
+ /// 目标 URL(必须是配置白名单 AllowedHosts 内的相对/绝对地址,防 SSRF)。
+ public string Url { get; set; } = string.Empty;
+
+ /// 投递的 JSON body。
+ public string Payload { get; set; } = string.Empty;
+
+ public string HttpMethod { get; set; } = "POST";
+
+ /// 投递状态:pending / delivered / failed。
+ public string Status { get; set; } = "pending";
+
+ public int? StatusCode { get; set; }
+ public string? ResponseBody { get; set; }
+
+ /// 已尝试次数。达 MaxAttempts 后标 failed 不再重试。
+ public int Attempts { get; set; } = 0;
+ public int MaxAttempts { get; set; } = 3;
+ public DateTime? NextRetryAt { get; set; }
+ public string? LastError { get; set; }
+
+ /// 关联的通知分类(用于 payload 构造与筛选)。
+ public string Category { get; set; } = string.Empty;
+
+ public Guid? RelatedInstanceId { get; set; }
+ public Guid? RelatedTaskId { get; set; }
+
+ public Guid CreatedBy { get; set; }
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ public Guid UpdatedBy { get; set; }
+ public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/src/Workflow.Domain/Entities/WorkflowDefinition.cs b/src/Workflow.Domain/Entities/WorkflowDefinition.cs
index 3842355..6048246 100644
--- a/src/Workflow.Domain/Entities/WorkflowDefinition.cs
+++ b/src/Workflow.Domain/Entities/WorkflowDefinition.cs
@@ -3,15 +3,24 @@ using Workflow.Domain.Enums;
namespace Workflow.Domain.Entities;
+///
+/// 工作流定义:流程的模板(蓝图)。一个定义可被多次实例化为 WorkflowInstance。
+/// 只有 Published 状态的定义才允许启动实例;Version 支持同 Code 多版本并存。
+/// 节点与边以独立实体存储(Nodes/Edges 导航属性),DefinitionJson 为完整图的冗余快照。
+///
public class WorkflowDefinition : BaseEntity, IFullAudit
{
public string Name { get; set; } = string.Empty;
+ /// 业务编码,与 Version 组合标识一个具体版本的定义。
public string Code { get; set; } = string.Empty;
+ /// 版本号,同 Code 下递增。实例化时绑定具体版本,已发布版本内容不可变。
public int Version { get; set; } = 1;
public string? Description { get; set; }
public DefinitionStatus Status { get; set; } = DefinitionStatus.Draft;
+ /// 完整流程图的 JSON 快照(节点+边),便于导出/导入与前端渲染。
public string? DefinitionJson { get; set; }
public bool IsEnabled { get; set; } = true;
+ /// 主表单定义 ID。实例启动时收集的初始表单数据。
public Guid? FormDefinitionId { get; set; }
public List Nodes { get; set; } = new();
diff --git a/src/Workflow.Domain/Entities/WorkflowEdge.cs b/src/Workflow.Domain/Entities/WorkflowEdge.cs
index 0bb32e3..2fd9acb 100644
--- a/src/Workflow.Domain/Entities/WorkflowEdge.cs
+++ b/src/Workflow.Domain/Entities/WorkflowEdge.cs
@@ -3,14 +3,21 @@ using Workflow.Domain.Enums;
namespace Workflow.Domain.Entities;
+///
+/// 工作流边:连接两个节点的有向流转关系。Condition 节点按 Order 升序逐条评估边的 Condition;
+/// Approval 节点根据审批结果与 EdgeType 共同决定走哪条出边(见 ProcessEngine.CompleteTaskAsync)。
+///
public class WorkflowEdge : BaseEntity, IFullAudit
{
public Guid DefinitionId { get; set; }
public Guid SourceNodeId { get; set; }
public Guid TargetNodeId { get; set; }
+ /// 边类型:决定 Approval 节点出边选择优先级(Approved 优先于 Normal 回退,Rejected 独立)。
public EdgeType EdgeType { get; set; } = EdgeType.Normal;
public string? Label { get; set; }
+ /// 条件表达式(简单文本或 JSON 树)。Condition 节点据此判定是否走该边。
public string? Condition { get; set; }
+ /// 分支评估顺序,值小的优先。仅 Condition 节点生效,用于稳定分支判定次序。
public int Order { get; set; }
public Guid CreatedBy { get; set; }
diff --git a/src/Workflow.Domain/Entities/WorkflowInstance.cs b/src/Workflow.Domain/Entities/WorkflowInstance.cs
index 50375ce..ef2fa95 100644
--- a/src/Workflow.Domain/Entities/WorkflowInstance.cs
+++ b/src/Workflow.Domain/Entities/WorkflowInstance.cs
@@ -3,16 +3,24 @@ using Workflow.Domain.Enums;
namespace Workflow.Domain.Entities;
+///
+/// 工作流实例:一次具体的流程运行。基于某个 WorkflowDefinition 启动,
+/// 由 ProcessEngine 在节点间传播 Token 推进。可作为子流程被另一实例引用
+/// (通过 ParentInstanceId / ParentTokenId 关联父实例与等待中的父 token)。
+///
public class WorkflowInstance : BaseEntity, IFullAudit
{
public Guid DefinitionId { get; set; }
public string Title { get; set; } = string.Empty;
public InstanceStatus Status { get; set; } = InstanceStatus.Running;
+ /// 流程变量(JSON 字符串)。条件节点求值、节点动作钩子均从此读取运行时数据。
public string? Variables { get; set; }
public Guid InitiatorId { get; set; }
public DateTime StartedAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
+ /// 父实例 ID。非 null 表示本实例是某个 SubProcess 节点派生的子流程。
public Guid? ParentInstanceId { get; set; }
+ /// 父实例中等待本子流程完成的 token ID。子流程完成后用于唤醒父流程继续推进。
public Guid? ParentTokenId { get; set; }
public List Tokens { get; set; } = new();
diff --git a/src/Workflow.Domain/Entities/WorkflowNode.cs b/src/Workflow.Domain/Entities/WorkflowNode.cs
index e843e7d..a6ede26 100644
--- a/src/Workflow.Domain/Entities/WorkflowNode.cs
+++ b/src/Workflow.Domain/Entities/WorkflowNode.cs
@@ -11,6 +11,7 @@ public class WorkflowNode : BaseEntity, IFullAudit
public Guid DefinitionId { get; set; }
public NodeType NodeType { get; set; }
public string Name { get; set; } = string.Empty;
+ /// 节点配置(JSON)。不同节点类型字段不同:Approval 含 assigneeRule/onEnter/onApproved/onRejected;Cc 含 recipients;SubProcess 含 definitionId。
public string? Config { get; set; }
public int PositionX { get; set; }
public int PositionY { get; set; }
diff --git a/src/Workflow.Domain/Entities/WorkflowTask.cs b/src/Workflow.Domain/Entities/WorkflowTask.cs
index 9c3e586..a08f438 100644
--- a/src/Workflow.Domain/Entities/WorkflowTask.cs
+++ b/src/Workflow.Domain/Entities/WorkflowTask.cs
@@ -5,21 +5,32 @@ using TaskStatus = Workflow.Domain.Enums.TaskStatus;
namespace Workflow.Domain.Entities;
+///
+/// 工作流任务:流程中需要人工介入的单元。Approval 任务需人工决策驱动流程推进;
+/// Cc 任务仅作知会,不阻塞主流程。任务通过 TokenId 关联其所属的执行控制权 token。
+///
public class WorkflowTask : BaseEntity, IFullAudit
{
public Guid InstanceId { get; set; }
+ /// 任务关联的 token ID。CompleteTaskAsync 完成任务时会消费此 token 并向下游投放新 token。
public Guid TokenId { get; set; }
public Guid NodeId { get; set; }
public string Title { get; set; } = string.Empty;
+ /// 指定用户受理人。与 AssigneeRole 互斥,由节点 config 的 "user:" 规则解析。
public Guid? AssigneeId { get; set; }
+ /// 指定角色受理人。与 AssigneeId 互斥,由节点 config 的 "role:" 规则解析。
public string? AssigneeRole { get; set; }
+ /// 委派来源:记录任务被委派时的原审批人,便于 DelegateComplete 后回交。
public Guid? DelegatedFromId { get; set; }
+ /// 候选用户列表(JSON 数组字符串),用于会签/抢单场景。
public string? CandidateUsers { get; set; }
+ /// 候选角色列表(JSON 数组字符串),用于会签/抢单场景。
public string? CandidateRoles { get; set; }
public TaskType Type { get; set; } = TaskType.Approval;
public TaskStatus Status { get; set; } = TaskStatus.Pending;
public string? Result { get; set; }
public string? Comment { get; set; }
+ /// 任务截止时间,用于超时自动处理(TimeoutAutoProcess)与逾期统计。
public DateTime? DueAt { get; set; }
public DateTime? CompletedAt { get; set; }
diff --git a/src/Workflow.Domain/Entities/WorkflowToken.cs b/src/Workflow.Domain/Entities/WorkflowToken.cs
index 50c0afc..04d6049 100644
--- a/src/Workflow.Domain/Entities/WorkflowToken.cs
+++ b/src/Workflow.Domain/Entities/WorkflowToken.cs
@@ -3,9 +3,15 @@ using Workflow.Domain.Enums;
namespace Workflow.Domain.Entities;
+///
+/// 工作流 Token:流程图中的"执行控制权"载体。沿边在节点间接力传播——
+/// 旧 token 被 Consumed,新 token 在下游节点 Active,驱动流程推进。
+/// Parallel Fork 会同时存在多个 Active token;Join 时所有入边 token 齐备后合并为一个。
+///
public class WorkflowToken : BaseEntity, IFullAudit
{
public Guid InstanceId { get; set; }
+ /// token 当前所在节点 ID。Approval/SubProcess 节点上 token 保持 Active 等待外部推进。
public Guid NodeId { get; set; }
public TokenStatus Status { get; set; } = TokenStatus.Active;
public DateTime ArrivedAt { get; set; } = DateTime.UtcNow;
diff --git a/src/Workflow.Domain/Enums/StateMachineEnums.cs b/src/Workflow.Domain/Enums/StateMachineEnums.cs
index 9f2160e..8bfd8d7 100644
--- a/src/Workflow.Domain/Enums/StateMachineEnums.cs
+++ b/src/Workflow.Domain/Enums/StateMachineEnums.cs
@@ -1,26 +1,42 @@
namespace Workflow.Domain.StateMachine;
+/// 流程实例可执行的操作。与 InstanceStatus 组合构成 InstanceStateMachine 的转换表。
public enum InstanceOperation
{
+ /// 完成实例。仅当无活跃 token 时合法。
Complete,
+ /// 挂起实例。需管理员或发起人权限。
Suspend,
+ /// 恢复挂起的实例。需管理员或发起人权限。
Resume,
+ /// 强制终止实例。仅管理员。
Terminate,
+ /// 发起人撤回实例。需发起人身份且尚未处理任何节点。
Withdraw
}
+/// 审批任务可执行的操作。与 TaskStatus 组合构成 TaskStateMachine 的转换表。
public enum TaskOperation
{
+ /// 审批通过。
Approve,
+ /// 审批驳回。
Reject,
+ /// 转交他人,原任务流转结束。
Transfer,
+ /// 委派他人先行处理,任务进入 Delegated 暂态。
Delegate,
+ /// 被委派人处理完毕,任务回到 Pending 由原审批人确认。
DelegateComplete,
+ /// 任务超时自动处理,按 TaskTransitionContext.AutoApproveOnTimeout 决定通过或驳回。
TimeoutAutoProcess
}
+/// Token 可执行的操作。与 TokenStatus 组合构成 TokenStateMachine 的转换表。
public enum TokenOperation
{
+ /// 节点处理完成,token 正常消费。
NodeComplete,
+ /// 所属实例被终止,token 同步终止。
InstanceTerminate
}
diff --git a/src/Workflow.Domain/Enums/WorkflowEnums.cs b/src/Workflow.Domain/Enums/WorkflowEnums.cs
index 213c9e4..aab49e8 100644
--- a/src/Workflow.Domain/Enums/WorkflowEnums.cs
+++ b/src/Workflow.Domain/Enums/WorkflowEnums.cs
@@ -1,5 +1,6 @@
namespace Workflow.Domain.Enums;
+/// 流程定义生命周期状态。Draft 可编辑,Published 不可改且可被实例化,Disabled 仅停用历史定义。
public enum DefinitionStatus
{
Draft = 0,
@@ -7,6 +8,7 @@ public enum DefinitionStatus
Disabled = 2
}
+/// 流程实例运行时状态。InstanceStateMachine 仅允许 Running ↔ Suspended 互转及向 Completed/Terminated 单向终态转换。
public enum InstanceStatus
{
Pending = 0,
@@ -16,6 +18,7 @@ public enum InstanceStatus
Terminated = 4
}
+/// Token(执行控制权)状态。Active 表示在节点上等待/流转中,Consumed 为正常消费,Terminated 为实例被强制终止。
public enum TokenStatus
{
Active = 0,
@@ -23,21 +26,26 @@ public enum TokenStatus
Terminated = 2
}
+/// 审批任务状态。Pending 为待办;Approved/Rejected/Transferred 为终态;Delegated 为委派中可回 Pending;Read 仅用于 Cc 知会任务。
public enum TaskStatus
{
Pending = 0,
Approved = 1,
Rejected = 2,
Transferred = 3,
- Delegated = 4
+ Delegated = 4,
+ /// Cc(抄送)任务被知会人标记为已读。仅适用于 Cc 任务,不参与 token 路由。
+ Read = 5
}
+/// 任务类型。Approval 需人工决策并驱动流程,Cc 仅作知会、不阻塞主流程。
public enum TaskType
{
Approval = 0,
Cc = 1
}
+/// 流程节点类型。每种类型在 ProcessEngine 中有独立的 Token 传播规则。
public enum NodeType
{
Start = 0,
@@ -49,6 +57,7 @@ public enum NodeType
SubProcess = 6
}
+/// 表单定义生命周期状态,语义同 。
public enum FormStatus
{
Draft = 0,
@@ -56,6 +65,7 @@ public enum FormStatus
Disabled = 2
}
+/// 表单字段类型,决定前端渲染控件与数据校验方式。
public enum FieldType
{
Input = 0,
@@ -69,15 +79,23 @@ public enum FieldType
Checkbox = 8
}
+/// 审批任务的人工决策结果。驱动 CompleteTaskAsync 选择不同 EdgeType 的出边。
public enum TaskResult
{
Approved = 0,
Rejected = 1
}
+///
+/// 流程边类型。在 CompleteTaskAsync 中影响出边选择优先级:
+/// 通过时优先取 Approved 边,缺省回退 Normal;驳回时必须命中 Rejected 边。
+///
public enum EdgeType
{
+ /// 普通边,无条件流转或作为通过路径的缺省回退。
Normal = 0,
+ /// 审批通过专用边,通过时优先匹配。
Approved = 1,
+ /// 审批驳回专用边,驳回时必须匹配。
Rejected = 2
}
diff --git a/src/Workflow.Domain/Exceptions/Exceptions.cs b/src/Workflow.Domain/Exceptions/Exceptions.cs
index e29a75b..bf7f7c7 100644
--- a/src/Workflow.Domain/Exceptions/Exceptions.cs
+++ b/src/Workflow.Domain/Exceptions/Exceptions.cs
@@ -1,20 +1,24 @@
namespace Workflow.Domain.Exceptions;
+/// 业务规则异常。GlobalExceptionMiddleware 映射为 HTTP 400。
public class BusinessException : Exception
{
public BusinessException(string message) : base(message) { }
}
+/// 资源未找到异常。GlobalExceptionMiddleware 映射为 HTTP 404。
public class NotFoundException : Exception
{
public NotFoundException(string message) : base(message) { }
}
+/// 鉴权失败异常。GlobalExceptionMiddleware 映射为 HTTP 401。
public class UnauthorizedException : Exception
{
public UnauthorizedException(string message) : base(message) { }
}
+/// 状态机非法转换异常。三套状态机在遇到非法 (状态, 操作) 组合或权限/约束校验失败时抛出。映射为 HTTP 400。
public class InvalidStateTransitionException : Exception
{
public InvalidStateTransitionException(string message) : base(message) { }
diff --git a/src/Workflow.Domain/Expressions/ConditionEvaluator.cs b/src/Workflow.Domain/Expressions/ConditionEvaluator.cs
index d9722fe..ff73c74 100644
--- a/src/Workflow.Domain/Expressions/ConditionEvaluator.cs
+++ b/src/Workflow.Domain/Expressions/ConditionEvaluator.cs
@@ -14,6 +14,8 @@ public class ConditionEvaluator
public ConditionEvaluator()
{
+ // 内置对比器集合,注册顺序即责任链优先级:Numeric → DateTime → Boolean → Collection → String → Range
+ // 当多个对比器声明同一操作符(如 == 被数值/日期/布尔/字符串同时支持)时,按此顺序逐个尝试,首个返回 true 胜出。
var builtInComparators = new IValueComparator[]
{
new NumericComparator(),
@@ -31,17 +33,28 @@ public class ConditionEvaluator
_registry = registry;
}
+ ///
+ /// 评估条件表达式。返回 true 表示条件成立。
+ /// 支持两种语法:
+ /// 1) 简单文本表达式(如 "amount > 5000"),由正则解析后委托对比器;
+ /// 2) JSON 条件树(含 and/or 递归组合与叶子比较节点),递归求值。
+ /// 空/空白条件视为恒真;variables 为 null 时视为条件不成立(缺字段无法比较)。
+ ///
public bool Evaluate(string? condition, Dictionary? variables)
{
+ // 空条件:视为无条件通过(用于无条件分支或默认出边)
if (string.IsNullOrWhiteSpace(condition))
return true;
+ // 无变量上下文:无法比较任何字段,统一返回 false
if (variables is null)
return false;
+ // 先尝试简单文本表达式(性能更优,覆盖大多数单字段场景)
if (TryParseSimpleExpression(condition, out var field, out var op, out var value))
return EvaluateSimpleExpression(field, op, value, variables);
+ // 否则按 JSON 条件树处理
try
{
using var doc = JsonDocument.Parse(condition);
@@ -49,6 +62,7 @@ public class ConditionEvaluator
}
catch (JsonException)
{
+ // 既非简单表达式也非合法 JSON:判定为条件不成立
return false;
}
}
@@ -61,11 +75,15 @@ public class ConditionEvaluator
return _registry.TryCompare(rawFieldValue, op, value);
}
+ ///
+ /// 递归求值 JSON 条件树节点。识别 and(全真)/ or(任一真)逻辑组合,否则按叶子比较节点处理。
+ ///
private bool EvaluateElement(JsonElement element, Dictionary variables)
{
if (element.ValueKind != JsonValueKind.Object)
return false;
+ // and 节点:所有子条件必须全部成立(短路求值,遇 false 立即返回)
if (element.TryGetProperty("and", out var andArray))
{
if (andArray.ValueKind != JsonValueKind.Array)
@@ -80,6 +98,7 @@ public class ConditionEvaluator
return true;
}
+ // or 节点:任一子条件成立即通过(短路求值,遇 true 立即返回)
if (element.TryGetProperty("or", out var orArray))
{
if (orArray.ValueKind != JsonValueKind.Array)
@@ -94,6 +113,7 @@ public class ConditionEvaluator
return false;
}
+ // 叶子节点:{ field, op, value } 形式的原子比较
return EvaluateComparison(element, variables);
}
@@ -114,6 +134,8 @@ public class ConditionEvaluator
return _registry.TryCompare(rawFieldValue, op, valueElement);
}
+ // 简单文本表达式正则:field op value 三段式,value 支持双引号、单引号、裸值三种写法
+ // 操作符必须列全,否则无法被识别(注意 >= <= 需排在 > < 之前以避免误匹配)
private static readonly Regex SimpleExpressionRegex =
new("""^\s*(\w+)\s*(==|!=|>=|<=|>|<|contains|startsWith|endsWith|isEmpty|in|notIn|between)\s*(?:"([^"]*?)"|'([^']*?)'|(.*?))\s*$""", RegexOptions.Compiled);
@@ -129,13 +151,17 @@ public class ConditionEvaluator
field = match.Groups[1].Value;
op = match.Groups[2].Value;
- // Groups: 3=双引号, 4=单引号, 5=无引号
+ // Groups: 3=双引号, 4=单引号, 5=无引号 —— 三者互斥,按优先级取首个匹配的捕获组
value = (match.Groups[3].Success ? match.Groups[3].Value
: match.Groups[4].Success ? match.Groups[4].Value
: match.Groups[5].Value).Trim();
return true;
}
+ ///
+ /// 默认对比器注册中心(无 DI 时的内置实现)。按操作符索引对比器列表,
+ /// TryCompare 时按注册顺序逐个尝试(责任链),首个返回 true 胜出。
+ ///
private sealed class DefaultRegistry : IValueComparatorRegistry
{
private readonly Dictionary> _comparatorsByOperator;
@@ -145,6 +171,7 @@ public class ConditionEvaluator
{
_comparatorsByOperator = new Dictionary>();
+ // 把每个对比器按其支持的操作符反向索引;同一操作符可对应多个对比器,保留注册顺序
foreach (var comparator in comparators)
{
foreach (var op in comparator.SupportedOperators)
@@ -168,6 +195,7 @@ public class ConditionEvaluator
if (!_comparatorsByOperator.TryGetValue(operatorName, out var comparators))
return false;
+ // 责任链:依次询问每个声明支持该操作符的对比器,任一返回 true 即整体通过
foreach (var comparator in comparators)
{
if (comparator.Compare(fieldValue, operatorName, conditionValue))
diff --git a/src/Workflow.Domain/StateMachine/InstanceStateMachine.cs b/src/Workflow.Domain/StateMachine/InstanceStateMachine.cs
index c7bb2e0..45886f5 100644
--- a/src/Workflow.Domain/StateMachine/InstanceStateMachine.cs
+++ b/src/Workflow.Domain/StateMachine/InstanceStateMachine.cs
@@ -3,25 +3,59 @@ using Workflow.Domain.Exceptions;
namespace Workflow.Domain.StateMachine;
+///
+/// 流程实例状态转换上下文。携带每次转换前需要校验的运行时事实
+/// (是否还有活跃 token、操作者身份、是否已有节点被处理过)。
+/// 用 init 属性强制在构造时一次性赋值,避免转换过程中被篡改。
+///
public class InstanceTransitionContext
{
+ /// 当前实例是否仍存在 Active 状态的 token。用于 Complete 校验:必须全部消费完才允许结束。
public bool HasActiveTokens { get; init; }
+ /// 操作者是否为管理员。管理员拥有挂起/恢复/终止等高权限操作的能力。
public bool IsAdmin { get; init; }
+ /// 操作者是否为流程发起人。发起人独享撤回权限,并可挂起/恢复自己的实例。
public bool IsInitiator { get; init; }
+ /// 实例是否已经有节点被处理过。用于 Withdraw 校验:流程一旦推进过即不可撤回。
public bool HasProcessedNodes { get; init; }
}
+///
+/// 流程实例状态机(无状态)。采用 (currentState, operation) 元组模式匹配定义合法转换表,
+/// 所有副作用由调用方负责;本类只判定转换是否合法,并通过 context 执行业务约束校验。
+/// 合法转换:
+/// Running + Complete → Completed (需无活跃 token)
+/// Running + Suspend → Suspended (需管理员或发起人)
+/// Suspended+ Resume → Running (需管理员或发起人)
+/// Running + Terminate → Terminated (需管理员)
+/// Running + Withdraw → Terminated (需发起人且尚未处理任何节点)
+/// 其余组合均抛 InvalidStateTransitionException。
+///
public class InstanceStateMachine
{
+ ///
+ /// 根据当前状态与操作计算目标状态。先匹配状态转换表,再由具体 TransitionXxx 方法做业务约束校验。
+ ///
+ /// 实例当前状态
+ /// 请求执行的操作
+ /// 转换上下文(身份/token状态等运行时事实)
+ /// 转换后的新状态
+ /// 状态/操作组合非法,或业务约束校验未通过
public InstanceStatus Transition(InstanceStatus currentState, InstanceOperation operation, InstanceTransitionContext context)
{
return (currentState, operation) switch
{
+ // 运行中 → 完成:仅当无活跃 token(所有分支都已到达 End 节点)
(InstanceStatus.Running, InstanceOperation.Complete) => TransitionComplete(context),
+ // 运行中 → 挂起:暂停流程推进,需管理员或发起人权限
(InstanceStatus.Running, InstanceOperation.Suspend) => TransitionSuspend(context),
+ // 挂起 → 运行:恢复流程推进,需管理员或发起人权限
(InstanceStatus.Suspended, InstanceOperation.Resume) => TransitionResume(context),
+ // 运行中 → 终止:强制结束流程,仅管理员
(InstanceStatus.Running, InstanceOperation.Terminate) => TransitionTerminate(context),
+ // 运行中 → 终止(撤回语义):发起人主动撤回,且流程尚未真正推进
(InstanceStatus.Running, InstanceOperation.Withdraw) => TransitionWithdraw(context),
+ // 其余所有 (状态, 操作) 组合均为非法转换
_ => throw new InvalidStateTransitionException(
$"Invalid instance state transition: cannot perform '{operation}' on instance in '{currentState}' state.")
};
@@ -29,6 +63,7 @@ public class InstanceStateMachine
private static InstanceStatus TransitionComplete(InstanceTransitionContext context)
{
+ // 完成前置约束:流程图所有分支都必须收敛到 End,存在活跃 token 说明尚有未结束的分支
if (context.HasActiveTokens)
throw new InvalidStateTransitionException(
"Cannot complete instance: there are still active tokens.");
@@ -38,6 +73,7 @@ public class InstanceStateMachine
private static InstanceStatus TransitionSuspend(InstanceTransitionContext context)
{
+ // 权限约束:仅管理员或发起人可挂起,防止他人随意冻结他人流程
if (!context.IsAdmin && !context.IsInitiator)
throw new InvalidStateTransitionException(
"Only admin or initiator can suspend the instance.");
@@ -47,6 +83,7 @@ public class InstanceStateMachine
private static InstanceStatus TransitionResume(InstanceTransitionContext context)
{
+ // 权限约束:恢复权限与挂起权限保持一致,避免权限不对称
if (!context.IsAdmin && !context.IsInitiator)
throw new InvalidStateTransitionException(
"Only admin or initiator can resume the instance.");
@@ -56,6 +93,7 @@ public class InstanceStateMachine
private static InstanceStatus TransitionTerminate(InstanceTransitionContext context)
{
+ // 权限约束:终止是不可逆操作,权限收窄为仅管理员
if (!context.IsAdmin)
throw new InvalidStateTransitionException(
"Only admin can terminate the instance.");
@@ -65,10 +103,12 @@ public class InstanceStateMachine
private static InstanceStatus TransitionWithdraw(InstanceTransitionContext context)
{
+ // 权限约束:撤回是发起人专属操作
if (!context.IsInitiator)
throw new InvalidStateTransitionException(
"Only the initiator can withdraw the instance.");
+ // 业务约束:一旦流程已推进到下游节点(已有处理记录),撤回会破坏审批痕迹的完整性,故禁止
if (context.HasProcessedNodes)
throw new InvalidStateTransitionException(
"Cannot withdraw instance: there are already processed nodes.");
diff --git a/src/Workflow.Domain/StateMachine/TaskStateMachine.cs b/src/Workflow.Domain/StateMachine/TaskStateMachine.cs
index 99da4b9..cbfc214 100644
--- a/src/Workflow.Domain/StateMachine/TaskStateMachine.cs
+++ b/src/Workflow.Domain/StateMachine/TaskStateMachine.cs
@@ -5,24 +5,57 @@ using TaskStatus = Workflow.Domain.Enums.TaskStatus;
namespace Workflow.Domain.StateMachine;
+///
+/// 任务状态转换上下文。目前仅承载超时自动处理时的去向策略。
+///
public class TaskTransitionContext
{
+ ///
+ /// 任务超时后是否自动按"通过"处理。默认 true,可通过配置改为按"拒绝"处理。
+ /// 仅作用于 TimeoutAutoProcess 操作。
+ ///
public bool AutoApproveOnTimeout { get; init; } = true;
}
+///
+/// 审批任务状态机(无状态)。采用 (currentState, operation) 元组模式匹配定义合法转换表。
+/// 合法转换:
+/// Pending + Approve → Approved (审批通过)
+/// Pending + Reject → Rejected (审批驳回)
+/// Pending + Transfer → Transferred (转交他人处理,原任务结束)
+/// Pending + Delegate → Delegated (委派他人,任务暂时挂起)
+/// Delegated + DelegateComplete → Pending (委派完成,回到待办给原审批人确认)
+/// Pending + TimeoutAutoProcess → Approved/Rejected(按 AutoApproveOnTimeout 决定)
+/// 其余组合(如对已完成任务再次操作)均抛 InvalidStateTransitionException。
+///
public class TaskStateMachine
{
+ ///
+ /// 根据当前状态与操作计算目标状态。
+ ///
+ /// 任务当前状态
+ /// 请求执行的操作
+ /// 转换上下文(含超时处理策略)
+ /// 转换后的新状态
+ /// 状态/操作组合非法
public TaskStatus Transition(TaskStatus currentState, TaskOperation operation, TaskTransitionContext context)
{
return (currentState, operation) switch
{
+ // 待办 → 通过:正常审批通过
(TaskStatus.Pending, TaskOperation.Approve) => TaskStatus.Approved,
+ // 待办 → 驳回:审批人拒绝
(TaskStatus.Pending, TaskOperation.Reject) => TaskStatus.Rejected,
+ // 待办 → 转交:审批人将任务整体转给他人,原任务流转结束(不同于委派)
(TaskStatus.Pending, TaskOperation.Transfer) => TaskStatus.Transferred,
+ // 待办 → 委派:临时交给他人先行处理,处理后任务回到待办状态等待原审批人最终确认
(TaskStatus.Pending, TaskOperation.Delegate) => TaskStatus.Delegated,
+ // 待办 → 超时自动处理:依据配置自动通过或驳回,避免流程卡死
(TaskStatus.Pending, TaskOperation.TimeoutAutoProcess) =>
context.AutoApproveOnTimeout ? TaskStatus.Approved : TaskStatus.Rejected,
+ // 委派 → 待办:被委派人处理完毕后,任务交回原审批人继续处理(不直接终态)
(TaskStatus.Delegated, TaskOperation.DelegateComplete) => TaskStatus.Pending,
+ // 已结束的任务(通过/驳回/转交)不允许再次操作,防止重复推进
_ => throw new InvalidStateTransitionException(
$"Invalid task state transition: cannot perform '{operation}' on task in '{currentState}' state.")
};
diff --git a/src/Workflow.Domain/StateMachine/TokenStateMachine.cs b/src/Workflow.Domain/StateMachine/TokenStateMachine.cs
index 480e3cf..b8541b6 100644
--- a/src/Workflow.Domain/StateMachine/TokenStateMachine.cs
+++ b/src/Workflow.Domain/StateMachine/TokenStateMachine.cs
@@ -3,18 +3,40 @@ using Workflow.Domain.Exceptions;
namespace Workflow.Domain.StateMachine;
+///
+/// Token 状态转换上下文。当前 token 转换仅依赖状态本身,无额外上下文需求,预留为空。
+///
public class TokenTransitionContext
{
}
+///
+/// 流程 Token 状态机(无状态)。Token 是流程引擎中"执行控制权"的载体,
+/// 沿流程图边在节点间传播。采用 (currentState, operation) 元组模式匹配定义合法转换表。
+/// 合法转换:
+/// Active + NodeComplete → Consumed (节点处理完成,token 被消费,通常由下游新 token 接力)
+/// Active + InstanceTerminate→ Terminated (所在实例被终止,token 随之强制终止)
+/// 已消费/已终止的 token 不可再变回 Active,避免执行控制权被重复激活。
+///
public class TokenStateMachine
{
+ ///
+ /// 根据当前状态与操作计算目标状态。
+ ///
+ /// token 当前状态
+ /// 请求执行的操作
+ /// 转换上下文(当前无字段,预留)
+ /// 转换后的新状态
+ /// 状态/操作组合非法
public TokenStatus Transition(TokenStatus currentState, TokenOperation operation, TokenTransitionContext context)
{
return (currentState, operation) switch
{
+ // 活跃 → 已消费:节点处理完毕,token 生命周期正常结束
(TokenStatus.Active, TokenOperation.NodeComplete) => TokenStatus.Consumed,
+ // 活跃 → 已终止:所属实例被强制终止,token 同步终止,不再传播
(TokenStatus.Active, TokenOperation.InstanceTerminate) => TokenStatus.Terminated,
+ // 已消费/已终止的 token 不能再被操作,保证执行权不会被重复消费
_ => throw new InvalidStateTransitionException(
$"Invalid token state transition: cannot perform '{operation}' on token in '{currentState}' state.")
};
diff --git a/src/Workflow.Infrastructure/Migrations/20260613200544_AddFormDefinitionVersions.Designer.cs b/src/Workflow.Infrastructure/Migrations/20260613200544_AddFormDefinitionVersions.Designer.cs
new file mode 100644
index 0000000..563b9b4
--- /dev/null
+++ b/src/Workflow.Infrastructure/Migrations/20260613200544_AddFormDefinitionVersions.Designer.cs
@@ -0,0 +1,819 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Workflow.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace Workflow.Infrastructure.Migrations
+{
+ [DbContext(typeof(WorkflowDbContext))]
+ [Migration("20260613200544_AddFormDefinitionVersions")]
+ partial class AddFormDefinitionVersions
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.7")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Workflow.Domain.Entities.FormComponentRegistry", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasComment("主键ID");
+
+ b.Property("Category")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasComment("组件分类");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid")
+ .HasComment("创建人ID");
+
+ b.Property("DefaultSchema")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasComment("组件默认Schema JSON");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasComment("组件显示名称");
+
+ b.Property("Icon")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasComment("组件图标");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasComment("是否启用");
+
+ b.Property("IsDeleted")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasComment("是否软删除");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasComment("组件名称(英文标识)");
+
+ b.Property("OperatorIP")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasComment("操作人IP地址");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasComment("排序号");
+
+ b.Property("SupportedProps")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasComment("组件支持的属性配置JSON");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("更新时间");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uuid")
+ .HasComment("更新人ID");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Category");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("wf_form_component_registry", (string)null);
+ });
+
+ modelBuilder.Entity("Workflow.Domain.Entities.FormData", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasComment("主键ID");
+
+ b.Property