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("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasComment("创建人ID"); + + b.Property("DataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasComment("表单数据JSON"); + + b.Property("FormDefinitionId") + .HasColumnType("uuid") + .HasComment("关联的表单定义ID"); + + b.Property("InstanceId") + .HasColumnType("uuid") + .HasComment("关联的流程实例ID"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否软删除"); + + b.Property("OperatorIP") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("操作人IP地址"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasComment("更新人ID"); + + b.HasKey("Id"); + + b.HasIndex("DataJson"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("DataJson"), "GIN"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("DataJson"), new[] { "jsonb_path_ops" }); + + b.HasIndex("InstanceId"); + + b.ToTable("wf_form_data", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.FormDefinition", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasComment("主键ID"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("表单编码,唯一标识"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasComment("创建人ID"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("表单描述"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否软删除"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("表单名称"); + + b.Property("OperatorIP") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("操作人IP地址"); + + b.Property("SchemaJson") + .HasColumnType("jsonb") + .HasComment("表单Schema JSON"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("表单状态(Draft=草稿, Published=已发布, Disabled=已禁用)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasComment("更新人ID"); + + b.Property("Version") + .HasColumnType("integer") + .HasComment("版本号"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.ToTable("wf_form_definitions", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.FormDefinitionVersion", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasComment("主键ID"); + + b.Property("ChangeSummary") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasComment("变更摘要"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasComment("创建人ID"); + + b.Property("FormDefinitionId") + .HasColumnType("uuid") + .HasComment("所属表单定义ID"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否软删除"); + + b.Property("OperatorIP") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("操作人IP地址"); + + b.Property("SchemaJson") + .HasColumnType("jsonb") + .HasComment("该版本的Schema JSON快照"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasComment("触发来源 Update/Publish"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasComment("更新人ID"); + + b.Property("Version") + .HasColumnType("integer") + .HasComment("版本号"); + + b.HasKey("Id"); + + b.HasIndex("FormDefinitionId"); + + b.HasIndex("FormDefinitionId", "Version"); + + b.ToTable("wf_form_definition_versions", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowDefinition", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasComment("主键ID"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("工作流编码,唯一标识"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasComment("创建人ID"); + + b.Property("DefinitionJson") + .HasColumnType("jsonb") + .HasComment("工作流定义JSON(含节点/边的完整结构)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("工作流描述"); + + b.Property("FormDefinitionId") + .HasColumnType("uuid") + .HasComment("关联的表单定义ID"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否软删除"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasComment("是否启用"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("工作流名称"); + + b.Property("OperatorIP") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("操作人IP地址"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("定义状态(Draft=草稿, Published=已发布)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasComment("更新人ID"); + + b.Property("Version") + .HasColumnType("integer") + .HasComment("版本号"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.ToTable("wf_workflow_definitions", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowEdge", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasComment("主键ID"); + + b.Property("Condition") + .HasColumnType("text") + .HasComment("条件表达式"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasComment("创建人ID"); + + b.Property("DefinitionId") + .HasColumnType("uuid") + .HasComment("所属工作流定义ID"); + + b.Property("EdgeType") + .HasColumnType("integer") + .HasComment("边类型(Normal=普通, Condition=条件)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否软删除"); + + b.Property("Label") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("边标签"); + + b.Property("OperatorIP") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("操作人IP地址"); + + b.Property("Order") + .HasColumnType("integer") + .HasComment("排序号"); + + b.Property("SourceNodeId") + .HasColumnType("uuid") + .HasComment("起始节点ID"); + + b.Property("TargetNodeId") + .HasColumnType("uuid") + .HasComment("目标节点ID"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasComment("更新人ID"); + + b.HasKey("Id"); + + b.HasIndex("DefinitionId"); + + b.ToTable("wf_workflow_edges", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowInstance", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasComment("主键ID"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("完成时间"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasComment("创建人ID"); + + b.Property("DefinitionId") + .HasColumnType("uuid") + .HasComment("关联的工作流定义ID"); + + b.Property("InitiatorId") + .HasColumnType("uuid") + .HasComment("发起人ID"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否软删除"); + + b.Property("OperatorIP") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("操作人IP地址"); + + b.Property("ParentInstanceId") + .HasColumnType("uuid") + .HasComment("父流程实例ID(子流程场景)"); + + b.Property("ParentTokenId") + .HasColumnType("uuid") + .HasComment("父流程中等待的令牌ID"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasComment("启动时间"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("实例状态(Running/Suspended/Completed/Terminated)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("流程实例标题"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasComment("更新人ID"); + + b.Property("Variables") + .HasColumnType("jsonb") + .HasComment("流程变量JSON"); + + b.HasKey("Id"); + + b.HasIndex("DefinitionId"); + + b.ToTable("wf_workflow_instances", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowNode", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasComment("主键ID"); + + b.Property("Config") + .HasColumnType("jsonb") + .HasComment("节点配置JSON(审批人、表单绑定等)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasComment("创建人ID"); + + b.Property("DefinitionId") + .HasColumnType("uuid") + .HasComment("所属工作流定义ID"); + + b.Property("FormDefinitionId") + .HasColumnType("uuid") + .HasComment("关联的表单定义ID,用于审批/抄送节点"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否软删除"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("节点名称"); + + b.Property("NodeType") + .HasColumnType("integer") + .HasComment("节点类型(Start/End/Approval/Cc/Condition/Parallel/SubProcess)"); + + b.Property("OperatorIP") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("操作人IP地址"); + + b.Property("PositionX") + .HasColumnType("integer") + .HasComment("画布X坐标"); + + b.Property("PositionY") + .HasColumnType("integer") + .HasComment("画布Y坐标"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasComment("更新人ID"); + + b.HasKey("Id"); + + b.HasIndex("DefinitionId"); + + b.HasIndex("FormDefinitionId"); + + b.ToTable("wf_workflow_nodes", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowTask", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasComment("主键ID"); + + b.Property("AssigneeId") + .HasColumnType("uuid") + .HasComment("当前处理人ID"); + + b.Property("AssigneeRole") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("当前处理人角色"); + + b.Property("CandidateRoles") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("候选角色列表,逗号分隔"); + + b.Property("CandidateUsers") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("候选用户ID列表,逗号分隔"); + + b.Property("Comment") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasComment("审批意见"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("完成时间"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasComment("创建人ID"); + + b.Property("DelegatedFromId") + .HasColumnType("uuid") + .HasComment("原处理人ID(委托场景)"); + + b.Property("DueAt") + .HasColumnType("timestamp with time zone") + .HasComment("截止时间"); + + b.Property("InstanceId") + .HasColumnType("uuid") + .HasComment("所属流程实例ID"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否软删除"); + + b.Property("NodeId") + .HasColumnType("uuid") + .HasComment("关联的节点定义ID"); + + b.Property("OperatorIP") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("操作人IP地址"); + + b.Property("Result") + .HasColumnType("jsonb") + .HasComment("处理结果JSON"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("任务状态(Pending/Approved/Rejected/Transferred/Delegated)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("任务标题"); + + b.Property("TokenId") + .HasColumnType("uuid") + .HasComment("关联的令牌ID"); + + b.Property("Type") + .HasColumnType("integer") + .HasComment("任务类型(Approval=审批, Cc=抄送)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasComment("更新人ID"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.ToTable("wf_workflow_tasks", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasComment("主键ID"); + + b.Property("ArrivedAt") + .HasColumnType("timestamp with time zone") + .HasComment("到达当前节点的时间"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("令牌完成/消费时间"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasComment("创建人ID"); + + b.Property("InstanceId") + .HasColumnType("uuid") + .HasComment("所属流程实例ID"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否软删除"); + + b.Property("NodeId") + .HasColumnType("uuid") + .HasComment("当前所在节点ID"); + + b.Property("OperatorIP") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("操作人IP地址"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("令牌状态(Active/Consumed/Terminated)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasComment("更新人ID"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.ToTable("wf_workflow_tokens", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowEdge", b => + { + b.HasOne("Workflow.Domain.Entities.WorkflowDefinition", null) + .WithMany("Edges") + .HasForeignKey("DefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowNode", b => + { + b.HasOne("Workflow.Domain.Entities.WorkflowDefinition", null) + .WithMany("Nodes") + .HasForeignKey("DefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowTask", b => + { + b.HasOne("Workflow.Domain.Entities.WorkflowInstance", null) + .WithMany("Tasks") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowToken", b => + { + b.HasOne("Workflow.Domain.Entities.WorkflowInstance", null) + .WithMany("Tokens") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowDefinition", b => + { + b.Navigation("Edges"); + + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowInstance", b => + { + b.Navigation("Tasks"); + + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Workflow.Infrastructure/Migrations/20260613200544_AddFormDefinitionVersions.cs b/src/Workflow.Infrastructure/Migrations/20260613200544_AddFormDefinitionVersions.cs new file mode 100644 index 0000000..5ae120c --- /dev/null +++ b/src/Workflow.Infrastructure/Migrations/20260613200544_AddFormDefinitionVersions.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Workflow.Infrastructure.Migrations; + +/// +public partial class AddFormDefinitionVersions : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "wf_form_definition_versions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, comment: "主键ID"), + form_definition_id = table.Column(type: "uuid", nullable: false, comment: "所属表单定义ID"), + version = table.Column(type: "integer", nullable: false, comment: "版本号"), + schema_json = table.Column(type: "jsonb", nullable: true, comment: "该版本的Schema JSON快照"), + source = table.Column(type: "character varying(50)", maxLength: 50, nullable: false, comment: "触发来源 Update/Publish"), + change_summary = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true, comment: "变更摘要"), + created_by = table.Column(type: "uuid", nullable: false, comment: "创建人ID"), + created_at = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间"), + updated_by = table.Column(type: "uuid", nullable: false, comment: "更新人ID"), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, comment: "更新时间"), + is_deleted = table.Column(type: "boolean", nullable: false, defaultValue: false, comment: "是否软删除"), + operator_ip = table.Column(type: "character varying(500)", maxLength: 500, nullable: true, comment: "操作人IP地址") + }, + constraints: table => + { + table.PrimaryKey("pk_wf_form_definition_versions", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_wf_form_definition_versions_form_definition_id", + table: "wf_form_definition_versions", + column: "form_definition_id"); + + migrationBuilder.CreateIndex( + name: "ix_wf_form_definition_versions_form_definition_id_version", + table: "wf_form_definition_versions", + columns: new[] { "form_definition_id", "version" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "wf_form_definition_versions"); + } +} diff --git a/src/Workflow.Infrastructure/Migrations/20260614040507_AddNotifications.Designer.cs b/src/Workflow.Infrastructure/Migrations/20260614040507_AddNotifications.Designer.cs new file mode 100644 index 0000000..c58259b --- /dev/null +++ b/src/Workflow.Infrastructure/Migrations/20260614040507_AddNotifications.Designer.cs @@ -0,0 +1,1038 @@ +// +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("20260614040507_AddNotifications")] + partial class AddNotifications + { + /// + 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("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasComment("创建人ID"); + + b.Property("DataJson") + .IsRequired() + .HasColumnType("jsonb") + .HasComment("表单数据JSON"); + + b.Property("FormDefinitionId") + .HasColumnType("uuid") + .HasComment("关联的表单定义ID"); + + b.Property("InstanceId") + .HasColumnType("uuid") + .HasComment("关联的流程实例ID"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否软删除"); + + b.Property("OperatorIP") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("操作人IP地址"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasComment("更新人ID"); + + b.HasKey("Id"); + + b.HasIndex("DataJson"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("DataJson"), "GIN"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("DataJson"), new[] { "jsonb_path_ops" }); + + b.HasIndex("InstanceId"); + + b.ToTable("wf_form_data", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.FormDefinition", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasComment("主键ID"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("表单编码,唯一标识"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasComment("创建人ID"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("表单描述"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否软删除"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("表单名称"); + + b.Property("OperatorIP") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("操作人IP地址"); + + b.Property("SchemaJson") + .HasColumnType("jsonb") + .HasComment("表单Schema JSON"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("表单状态(Draft=草稿, Published=已发布, Disabled=已禁用)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasComment("更新人ID"); + + b.Property("Version") + .HasColumnType("integer") + .HasComment("版本号"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.ToTable("wf_form_definitions", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.FormDefinitionVersion", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasComment("主键ID"); + + b.Property("ChangeSummary") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasComment("变更摘要"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasComment("创建人ID"); + + b.Property("FormDefinitionId") + .HasColumnType("uuid") + .HasComment("所属表单定义ID"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否软删除"); + + b.Property("OperatorIP") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("操作人IP地址"); + + b.Property("SchemaJson") + .HasColumnType("jsonb") + .HasComment("该版本的Schema JSON快照"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasComment("触发来源 Update/Publish"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasComment("更新人ID"); + + b.Property("Version") + .HasColumnType("integer") + .HasComment("版本号"); + + b.HasKey("Id"); + + b.HasIndex("FormDefinitionId"); + + b.HasIndex("FormDefinitionId", "Version"); + + b.ToTable("wf_form_definition_versions", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.Notification", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id") + .HasComment("主键ID"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("category") + .HasComment("通知分类"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("content") + .HasComment("通知正文"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by") + .HasComment("创建人ID"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted") + .HasComment("是否软删除"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_read") + .HasComment("是否已读"); + + b.Property("OperatorIP") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("operator_ip") + .HasComment("操作人IP地址"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("read_at") + .HasComment("阅读时间"); + + b.Property("RecipientRole") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("recipient_role") + .HasComment("收件角色名(role:规则)"); + + b.Property("RecipientUserId") + .HasColumnType("uuid") + .HasColumnName("recipient_user_id") + .HasComment("收件人用户ID(user:规则)"); + + b.Property("RelatedInstanceId") + .HasColumnType("uuid") + .HasColumnName("related_instance_id") + .HasComment("关联流程实例ID"); + + b.Property("RelatedTaskId") + .HasColumnType("uuid") + .HasColumnName("related_task_id") + .HasComment("关联任务ID"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("title") + .HasComment("通知标题"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by") + .HasComment("更新人ID"); + + b.HasKey("Id"); + + b.HasIndex("RelatedInstanceId") + .HasDatabaseName("ix_wf_notifications_related_instance_id"); + + b.HasIndex("RecipientUserId", "IsRead") + .HasDatabaseName("ix_wf_notifications_recipient_user_id_is_read"); + + b.ToTable("wf_notifications", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WebhookDelivery", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id") + .HasComment("主键ID"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts") + .HasComment("已尝试次数"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("category") + .HasComment("通知分类"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by") + .HasComment("创建人ID"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("http_method") + .HasComment("HTTP方法"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error") + .HasComment("最近一次错误信息"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts") + .HasComment("最大尝试次数"); + + b.Property("NextRetryAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_retry_at") + .HasComment("下次重试时间"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload") + .HasComment("投递JSON body"); + + b.Property("RelatedInstanceId") + .HasColumnType("uuid") + .HasColumnName("related_instance_id") + .HasComment("关联流程实例ID"); + + b.Property("RelatedTaskId") + .HasColumnType("uuid") + .HasColumnName("related_task_id") + .HasComment("关联任务ID"); + + b.Property("ResponseBody") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("response_body") + .HasComment("响应体"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasComment("投递状态:pending/delivered/failed"); + + b.Property("StatusCode") + .HasColumnType("integer") + .HasColumnName("status_code") + .HasComment("HTTP响应码"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by") + .HasComment("更新人ID"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("url") + .HasComment("目标URL"); + + b.HasKey("Id"); + + b.HasIndex("NextRetryAt") + .HasDatabaseName("ix_wf_webhook_deliveries_next_retry_at"); + + b.HasIndex("Status") + .HasDatabaseName("ix_wf_webhook_deliveries_status"); + + b.ToTable("wf_webhook_deliveries", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowDefinition", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasComment("主键ID"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("工作流编码,唯一标识"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasComment("创建人ID"); + + b.Property("DefinitionJson") + .HasColumnType("jsonb") + .HasComment("工作流定义JSON(含节点/边的完整结构)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("工作流描述"); + + b.Property("FormDefinitionId") + .HasColumnType("uuid") + .HasComment("关联的表单定义ID"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否软删除"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasComment("是否启用"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("工作流名称"); + + b.Property("OperatorIP") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("操作人IP地址"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("定义状态(Draft=草稿, Published=已发布)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasComment("更新人ID"); + + b.Property("Version") + .HasColumnType("integer") + .HasComment("版本号"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.ToTable("wf_workflow_definitions", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowEdge", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasComment("主键ID"); + + b.Property("Condition") + .HasColumnType("text") + .HasComment("条件表达式"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasComment("创建人ID"); + + b.Property("DefinitionId") + .HasColumnType("uuid") + .HasComment("所属工作流定义ID"); + + b.Property("EdgeType") + .HasColumnType("integer") + .HasComment("边类型(Normal=普通, Condition=条件)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否软删除"); + + b.Property("Label") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("边标签"); + + b.Property("OperatorIP") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("操作人IP地址"); + + b.Property("Order") + .HasColumnType("integer") + .HasComment("排序号"); + + b.Property("SourceNodeId") + .HasColumnType("uuid") + .HasComment("起始节点ID"); + + b.Property("TargetNodeId") + .HasColumnType("uuid") + .HasComment("目标节点ID"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasComment("更新人ID"); + + b.HasKey("Id"); + + b.HasIndex("DefinitionId"); + + b.ToTable("wf_workflow_edges", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowInstance", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasComment("主键ID"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("完成时间"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasComment("创建人ID"); + + b.Property("DefinitionId") + .HasColumnType("uuid") + .HasComment("关联的工作流定义ID"); + + b.Property("InitiatorId") + .HasColumnType("uuid") + .HasComment("发起人ID"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否软删除"); + + b.Property("OperatorIP") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("操作人IP地址"); + + b.Property("ParentInstanceId") + .HasColumnType("uuid") + .HasComment("父流程实例ID(子流程场景)"); + + b.Property("ParentTokenId") + .HasColumnType("uuid") + .HasComment("父流程中等待的令牌ID"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasComment("启动时间"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("实例状态(Running/Suspended/Completed/Terminated)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("流程实例标题"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasComment("更新人ID"); + + b.Property("Variables") + .HasColumnType("jsonb") + .HasComment("流程变量JSON"); + + b.HasKey("Id"); + + b.HasIndex("DefinitionId"); + + b.ToTable("wf_workflow_instances", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowNode", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasComment("主键ID"); + + b.Property("Config") + .HasColumnType("jsonb") + .HasComment("节点配置JSON(审批人、表单绑定等)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasComment("创建人ID"); + + b.Property("DefinitionId") + .HasColumnType("uuid") + .HasComment("所属工作流定义ID"); + + b.Property("FormDefinitionId") + .HasColumnType("uuid") + .HasComment("关联的表单定义ID,用于审批/抄送节点"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否软删除"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("节点名称"); + + b.Property("NodeType") + .HasColumnType("integer") + .HasComment("节点类型(Start/End/Approval/Cc/Condition/Parallel/SubProcess)"); + + b.Property("OperatorIP") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("操作人IP地址"); + + b.Property("PositionX") + .HasColumnType("integer") + .HasComment("画布X坐标"); + + b.Property("PositionY") + .HasColumnType("integer") + .HasComment("画布Y坐标"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasComment("更新人ID"); + + b.HasKey("Id"); + + b.HasIndex("DefinitionId"); + + b.HasIndex("FormDefinitionId"); + + b.ToTable("wf_workflow_nodes", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowTask", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasComment("主键ID"); + + b.Property("AssigneeId") + .HasColumnType("uuid") + .HasComment("当前处理人ID"); + + b.Property("AssigneeRole") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("当前处理人角色"); + + b.Property("CandidateRoles") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("候选角色列表,逗号分隔"); + + b.Property("CandidateUsers") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("候选用户ID列表,逗号分隔"); + + b.Property("Comment") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasComment("审批意见"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("完成时间"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasComment("创建人ID"); + + b.Property("DelegatedFromId") + .HasColumnType("uuid") + .HasComment("原处理人ID(委托场景)"); + + b.Property("DueAt") + .HasColumnType("timestamp with time zone") + .HasComment("截止时间"); + + b.Property("InstanceId") + .HasColumnType("uuid") + .HasComment("所属流程实例ID"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否软删除"); + + b.Property("NodeId") + .HasColumnType("uuid") + .HasComment("关联的节点定义ID"); + + b.Property("OperatorIP") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("操作人IP地址"); + + b.Property("Result") + .HasColumnType("jsonb") + .HasComment("处理结果JSON"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("任务状态(Pending/Approved/Rejected/Transferred/Delegated)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("任务标题"); + + b.Property("TokenId") + .HasColumnType("uuid") + .HasComment("关联的令牌ID"); + + b.Property("Type") + .HasColumnType("integer") + .HasComment("任务类型(Approval=审批, Cc=抄送)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasComment("更新人ID"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.ToTable("wf_workflow_tasks", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasComment("主键ID"); + + b.Property("ArrivedAt") + .HasColumnType("timestamp with time zone") + .HasComment("到达当前节点的时间"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("令牌完成/消费时间"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasComment("创建人ID"); + + b.Property("InstanceId") + .HasColumnType("uuid") + .HasComment("所属流程实例ID"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否软删除"); + + b.Property("NodeId") + .HasColumnType("uuid") + .HasComment("当前所在节点ID"); + + b.Property("OperatorIP") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("操作人IP地址"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("令牌状态(Active/Consumed/Terminated)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasComment("更新人ID"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.ToTable("wf_workflow_tokens", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowEdge", b => + { + b.HasOne("Workflow.Domain.Entities.WorkflowDefinition", null) + .WithMany("Edges") + .HasForeignKey("DefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowNode", b => + { + b.HasOne("Workflow.Domain.Entities.WorkflowDefinition", null) + .WithMany("Nodes") + .HasForeignKey("DefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowTask", b => + { + b.HasOne("Workflow.Domain.Entities.WorkflowInstance", null) + .WithMany("Tasks") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowToken", b => + { + b.HasOne("Workflow.Domain.Entities.WorkflowInstance", null) + .WithMany("Tokens") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowDefinition", b => + { + b.Navigation("Edges"); + + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowInstance", b => + { + b.Navigation("Tasks"); + + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Workflow.Infrastructure/Migrations/20260614040507_AddNotifications.cs b/src/Workflow.Infrastructure/Migrations/20260614040507_AddNotifications.cs new file mode 100644 index 0000000..24065a6 --- /dev/null +++ b/src/Workflow.Infrastructure/Migrations/20260614040507_AddNotifications.cs @@ -0,0 +1,99 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Workflow.Infrastructure.Migrations +{ + /// + public partial class AddNotifications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "wf_notifications", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, comment: "主键ID"), + recipient_user_id = table.Column(type: "uuid", nullable: true, comment: "收件人用户ID(user:规则)"), + recipient_role = table.Column(type: "character varying(200)", maxLength: 200, nullable: true, comment: "收件角色名(role:规则)"), + title = table.Column(type: "character varying(500)", maxLength: 500, nullable: false, comment: "通知标题"), + content = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: false, comment: "通知正文"), + category = table.Column(type: "character varying(50)", maxLength: 50, nullable: false, comment: "通知分类"), + related_instance_id = table.Column(type: "uuid", nullable: true, comment: "关联流程实例ID"), + related_task_id = table.Column(type: "uuid", nullable: true, comment: "关联任务ID"), + is_read = table.Column(type: "boolean", nullable: false, defaultValue: false, comment: "是否已读"), + read_at = table.Column(type: "timestamp with time zone", nullable: true, comment: "阅读时间"), + created_by = table.Column(type: "uuid", nullable: false, comment: "创建人ID"), + created_at = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间"), + updated_by = table.Column(type: "uuid", nullable: false, comment: "更新人ID"), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, comment: "更新时间"), + is_deleted = table.Column(type: "boolean", nullable: false, defaultValue: false, comment: "是否软删除"), + operator_ip = table.Column(type: "character varying(500)", maxLength: 500, nullable: true, comment: "操作人IP地址") + }, + constraints: table => + { + table.PrimaryKey("PK_wf_notifications", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "wf_webhook_deliveries", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, comment: "主键ID"), + url = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false, comment: "目标URL"), + payload = table.Column(type: "jsonb", nullable: false, comment: "投递JSON body"), + http_method = table.Column(type: "character varying(10)", maxLength: 10, nullable: false, comment: "HTTP方法"), + status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, comment: "投递状态:pending/delivered/failed"), + status_code = table.Column(type: "integer", nullable: true, comment: "HTTP响应码"), + response_body = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: true, comment: "响应体"), + attempts = table.Column(type: "integer", nullable: false, comment: "已尝试次数"), + max_attempts = table.Column(type: "integer", nullable: false, comment: "最大尝试次数"), + next_retry_at = table.Column(type: "timestamp with time zone", nullable: true, comment: "下次重试时间"), + last_error = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true, comment: "最近一次错误信息"), + category = table.Column(type: "character varying(50)", maxLength: 50, nullable: false, comment: "通知分类"), + related_instance_id = table.Column(type: "uuid", nullable: true, comment: "关联流程实例ID"), + related_task_id = table.Column(type: "uuid", nullable: true, comment: "关联任务ID"), + created_by = table.Column(type: "uuid", nullable: false, comment: "创建人ID"), + created_at = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间"), + updated_by = table.Column(type: "uuid", nullable: false, comment: "更新人ID"), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, comment: "更新时间") + }, + constraints: table => + { + table.PrimaryKey("PK_wf_webhook_deliveries", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_wf_notifications_recipient_user_id_is_read", + table: "wf_notifications", + columns: new[] { "recipient_user_id", "is_read" }); + + migrationBuilder.CreateIndex( + name: "ix_wf_notifications_related_instance_id", + table: "wf_notifications", + column: "related_instance_id"); + + migrationBuilder.CreateIndex( + name: "ix_wf_webhook_deliveries_next_retry_at", + table: "wf_webhook_deliveries", + column: "next_retry_at"); + + migrationBuilder.CreateIndex( + name: "ix_wf_webhook_deliveries_status", + table: "wf_webhook_deliveries", + column: "status"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "wf_notifications"); + + migrationBuilder.DropTable( + name: "wf_webhook_deliveries"); + } + } +} diff --git a/src/Workflow.Infrastructure/Migrations/WorkflowDbContextModelSnapshot.cs b/src/Workflow.Infrastructure/Migrations/WorkflowDbContextModelSnapshot.cs index 2a8bba1..af1c2f7 100644 --- a/src/Workflow.Infrastructure/Migrations/WorkflowDbContextModelSnapshot.cs +++ b/src/Workflow.Infrastructure/Migrations/WorkflowDbContextModelSnapshot.cs @@ -234,6 +234,290 @@ namespace Workflow.Infrastructure.Migrations b.ToTable("wf_form_definitions", (string)null); }); + modelBuilder.Entity("Workflow.Domain.Entities.FormDefinitionVersion", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasComment("主键ID"); + + b.Property("ChangeSummary") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasComment("变更摘要"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasComment("创建人ID"); + + b.Property("FormDefinitionId") + .HasColumnType("uuid") + .HasComment("所属表单定义ID"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否软删除"); + + b.Property("OperatorIP") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasComment("操作人IP地址"); + + b.Property("SchemaJson") + .HasColumnType("jsonb") + .HasComment("该版本的Schema JSON快照"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasComment("触发来源 Update/Publish"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasComment("更新人ID"); + + b.Property("Version") + .HasColumnType("integer") + .HasComment("版本号"); + + b.HasKey("Id"); + + b.HasIndex("FormDefinitionId"); + + b.HasIndex("FormDefinitionId", "Version"); + + b.ToTable("wf_form_definition_versions", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.Notification", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id") + .HasComment("主键ID"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("category") + .HasComment("通知分类"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("content") + .HasComment("通知正文"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by") + .HasComment("创建人ID"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted") + .HasComment("是否软删除"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_read") + .HasComment("是否已读"); + + b.Property("OperatorIP") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("operator_ip") + .HasComment("操作人IP地址"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("read_at") + .HasComment("阅读时间"); + + b.Property("RecipientRole") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("recipient_role") + .HasComment("收件角色名(role:规则)"); + + b.Property("RecipientUserId") + .HasColumnType("uuid") + .HasColumnName("recipient_user_id") + .HasComment("收件人用户ID(user:规则)"); + + b.Property("RelatedInstanceId") + .HasColumnType("uuid") + .HasColumnName("related_instance_id") + .HasComment("关联流程实例ID"); + + b.Property("RelatedTaskId") + .HasColumnType("uuid") + .HasColumnName("related_task_id") + .HasComment("关联任务ID"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("title") + .HasComment("通知标题"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by") + .HasComment("更新人ID"); + + b.HasKey("Id"); + + b.HasIndex("RelatedInstanceId") + .HasDatabaseName("ix_wf_notifications_related_instance_id"); + + b.HasIndex("RecipientUserId", "IsRead") + .HasDatabaseName("ix_wf_notifications_recipient_user_id_is_read"); + + b.ToTable("wf_notifications", (string)null); + }); + + modelBuilder.Entity("Workflow.Domain.Entities.WebhookDelivery", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id") + .HasComment("主键ID"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts") + .HasComment("已尝试次数"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("category") + .HasComment("通知分类"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasComment("创建时间"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by") + .HasComment("创建人ID"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("http_method") + .HasComment("HTTP方法"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error") + .HasComment("最近一次错误信息"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts") + .HasComment("最大尝试次数"); + + b.Property("NextRetryAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_retry_at") + .HasComment("下次重试时间"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload") + .HasComment("投递JSON body"); + + b.Property("RelatedInstanceId") + .HasColumnType("uuid") + .HasColumnName("related_instance_id") + .HasComment("关联流程实例ID"); + + b.Property("RelatedTaskId") + .HasColumnType("uuid") + .HasColumnName("related_task_id") + .HasComment("关联任务ID"); + + b.Property("ResponseBody") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("response_body") + .HasComment("响应体"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasComment("投递状态:pending/delivered/failed"); + + b.Property("StatusCode") + .HasColumnType("integer") + .HasColumnName("status_code") + .HasComment("HTTP响应码"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasComment("更新时间"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by") + .HasComment("更新人ID"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("url") + .HasComment("目标URL"); + + b.HasKey("Id"); + + b.HasIndex("NextRetryAt") + .HasDatabaseName("ix_wf_webhook_deliveries_next_retry_at"); + + b.HasIndex("Status") + .HasDatabaseName("ix_wf_webhook_deliveries_status"); + + b.ToTable("wf_webhook_deliveries", (string)null); + }); + modelBuilder.Entity("Workflow.Domain.Entities.WorkflowDefinition", b => { b.Property("Id") diff --git a/src/Workflow.Infrastructure/Persistence/Configurations/FormDefinitionVersionConfiguration.cs b/src/Workflow.Infrastructure/Persistence/Configurations/FormDefinitionVersionConfiguration.cs new file mode 100644 index 0000000..164d030 --- /dev/null +++ b/src/Workflow.Infrastructure/Persistence/Configurations/FormDefinitionVersionConfiguration.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Workflow.Domain.Entities; + +namespace Workflow.Infrastructure.Persistence.Configurations; + +public class FormDefinitionVersionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("wf_form_definition_versions"); + + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).ValueGeneratedNever().HasComment("主键ID"); + builder.Property(e => e.FormDefinitionId).HasComment("所属表单定义ID"); + builder.Property(e => e.Version).HasComment("版本号"); + builder.Property(e => e.SchemaJson).HasColumnType("jsonb").HasComment("该版本的Schema JSON快照"); + builder.Property(e => e.Source).HasMaxLength(50).HasComment("触发来源 Update/Publish"); + builder.Property(e => e.ChangeSummary).HasMaxLength(1000).HasComment("变更摘要"); + builder.Property(e => e.CreatedBy).HasComment("创建人ID"); + builder.Property(e => e.CreatedAt).HasComment("创建时间"); + builder.Property(e => e.UpdatedBy).HasComment("更新人ID"); + builder.Property(e => e.UpdatedAt).HasComment("更新时间"); + builder.Property(e => e.IsDeleted).HasDefaultValue(false).HasComment("是否软删除"); + builder.Property(e => e.OperatorIP).HasMaxLength(500).HasComment("操作人IP地址"); + + builder.HasIndex(e => new { e.FormDefinitionId, e.Version }); + builder.HasIndex(e => e.FormDefinitionId); + } +} diff --git a/src/Workflow.Infrastructure/Persistence/Configurations/NotificationConfiguration.cs b/src/Workflow.Infrastructure/Persistence/Configurations/NotificationConfiguration.cs new file mode 100644 index 0000000..1cbaa79 --- /dev/null +++ b/src/Workflow.Infrastructure/Persistence/Configurations/NotificationConfiguration.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Workflow.Domain.Entities; + +namespace Workflow.Infrastructure.Persistence.Configurations; + +public class NotificationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // 注意:设计时工厂未启用 UseSnakeCaseNamingConvention(与运行时 Program.cs 不一致), + // 故此处显式指定 snake_case 列名,保证迁移生成的列与运行时期望一致。 + builder.ToTable("wf_notifications"); + + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).HasColumnName("id").ValueGeneratedNever().HasComment("主键ID"); + builder.Property(e => e.RecipientUserId).HasColumnName("recipient_user_id").HasComment("收件人用户ID(user:规则)"); + builder.Property(e => e.RecipientRole).HasColumnName("recipient_role").HasMaxLength(200).HasComment("收件角色名(role:规则)"); + builder.Property(e => e.Title).HasColumnName("title").HasMaxLength(500).HasComment("通知标题"); + builder.Property(e => e.Content).HasColumnName("content").HasMaxLength(4000).HasComment("通知正文"); + builder.Property(e => e.Category).HasColumnName("category").HasMaxLength(50).HasComment("通知分类"); + builder.Property(e => e.RelatedInstanceId).HasColumnName("related_instance_id").HasComment("关联流程实例ID"); + builder.Property(e => e.RelatedTaskId).HasColumnName("related_task_id").HasComment("关联任务ID"); + builder.Property(e => e.IsRead).HasColumnName("is_read").HasDefaultValue(false).HasComment("是否已读"); + builder.Property(e => e.ReadAt).HasColumnName("read_at").HasComment("阅读时间"); + builder.Property(e => e.CreatedBy).HasColumnName("created_by").HasComment("创建人ID"); + builder.Property(e => e.CreatedAt).HasColumnName("created_at").HasComment("创建时间"); + builder.Property(e => e.UpdatedBy).HasColumnName("updated_by").HasComment("更新人ID"); + builder.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasComment("更新时间"); + builder.Property(e => e.IsDeleted).HasColumnName("is_deleted").HasDefaultValue(false).HasComment("是否软删除"); + builder.Property(e => e.OperatorIP).HasColumnName("operator_ip").HasMaxLength(500).HasComment("操作人IP地址"); + + builder.HasIndex(e => new { e.RecipientUserId, e.IsRead }).HasDatabaseName("ix_wf_notifications_recipient_user_id_is_read"); + builder.HasIndex(e => e.RelatedInstanceId).HasDatabaseName("ix_wf_notifications_related_instance_id"); + } +} diff --git a/src/Workflow.Infrastructure/Persistence/Configurations/WebhookDeliveryConfiguration.cs b/src/Workflow.Infrastructure/Persistence/Configurations/WebhookDeliveryConfiguration.cs new file mode 100644 index 0000000..db9e6f7 --- /dev/null +++ b/src/Workflow.Infrastructure/Persistence/Configurations/WebhookDeliveryConfiguration.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Workflow.Domain.Entities; + +namespace Workflow.Infrastructure.Persistence.Configurations; + +public class WebhookDeliveryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // 显式 snake_case 列名(设计时工厂未启用 UseSnakeCaseNamingConvention)。 + builder.ToTable("wf_webhook_deliveries"); + + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).HasColumnName("id").ValueGeneratedNever().HasComment("主键ID"); + builder.Property(e => e.Url).HasColumnName("url").HasMaxLength(2000).HasComment("目标URL"); + builder.Property(e => e.Payload).HasColumnName("payload").HasColumnType("jsonb").HasComment("投递JSON body"); + builder.Property(e => e.HttpMethod).HasColumnName("http_method").HasMaxLength(10).HasComment("HTTP方法"); + builder.Property(e => e.Status).HasColumnName("status").HasMaxLength(20).HasComment("投递状态:pending/delivered/failed"); + builder.Property(e => e.StatusCode).HasColumnName("status_code").HasComment("HTTP响应码"); + builder.Property(e => e.ResponseBody).HasColumnName("response_body").HasMaxLength(4000).HasComment("响应体"); + builder.Property(e => e.Attempts).HasColumnName("attempts").HasComment("已尝试次数"); + builder.Property(e => e.MaxAttempts).HasColumnName("max_attempts").HasComment("最大尝试次数"); + builder.Property(e => e.NextRetryAt).HasColumnName("next_retry_at").HasComment("下次重试时间"); + builder.Property(e => e.LastError).HasColumnName("last_error").HasMaxLength(2000).HasComment("最近一次错误信息"); + builder.Property(e => e.Category).HasColumnName("category").HasMaxLength(50).HasComment("通知分类"); + builder.Property(e => e.RelatedInstanceId).HasColumnName("related_instance_id").HasComment("关联流程实例ID"); + builder.Property(e => e.RelatedTaskId).HasColumnName("related_task_id").HasComment("关联任务ID"); + builder.Property(e => e.CreatedBy).HasColumnName("created_by").HasComment("创建人ID"); + builder.Property(e => e.CreatedAt).HasColumnName("created_at").HasComment("创建时间"); + builder.Property(e => e.UpdatedBy).HasColumnName("updated_by").HasComment("更新人ID"); + builder.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasComment("更新时间"); + + builder.HasIndex(e => e.Status).HasDatabaseName("ix_wf_webhook_deliveries_status"); + builder.HasIndex(e => e.NextRetryAt).HasDatabaseName("ix_wf_webhook_deliveries_next_retry_at"); + } +} diff --git a/src/Workflow.Infrastructure/Persistence/Interceptors/AuditInterceptor.cs b/src/Workflow.Infrastructure/Persistence/Interceptors/AuditInterceptor.cs index 3da4c7a..2f0b1a4 100644 --- a/src/Workflow.Infrastructure/Persistence/Interceptors/AuditInterceptor.cs +++ b/src/Workflow.Infrastructure/Persistence/Interceptors/AuditInterceptor.cs @@ -5,6 +5,11 @@ using Workflow.Domain.Common; namespace Workflow.Infrastructure.Persistence.Interceptors; +/// +/// EF Core 保存变更拦截器:在 SaveChanges 前自动填充审计字段(创建/更新人与时间、操作 IP), +/// 并把物理删除改写为软删除(IsDeleted=true)。统一通过 ICurrentUserContext 获取操作者身份, +/// 确保所有写操作的审计信息来源一致、不可被业务代码绕过。 +/// public class AuditInterceptor : SaveChangesInterceptor { private readonly ICurrentUserContext _currentUserContext; @@ -35,6 +40,7 @@ public class AuditInterceptor : SaveChangesInterceptor { if (context is null) return; + // 审计基线:操作者身份 + IP + 统一 UTC 时间(避免服务器时区差异导致时间错乱) var userId = _currentUserContext.GetUserId(); var ipAddress = _currentUserContext.GetIPAddress(); var now = DateTime.UtcNow; @@ -44,15 +50,18 @@ public class AuditInterceptor : SaveChangesInterceptor switch (entry.State) { case EntityState.Added: + // 新增:同时填充创建与更新字段(创建即首次更新) SetCreatedFields(entry, userId, now); SetUpdatedFields(entry, userId, now); SetOperatorIP(entry, ipAddress); break; case EntityState.Modified: + // 修改:仅刷新更新字段与 IP,创建信息保持不变 SetUpdatedFields(entry, userId, now); SetOperatorIP(entry, ipAddress); break; case EntityState.Deleted: + // 删除:交由软删除处理,避免物理删除导致审计链断裂 HandleSoftDelete(entry, userId, now, ipAddress); break; } diff --git a/src/Workflow.Infrastructure/Persistence/SeedData.cs b/src/Workflow.Infrastructure/Persistence/SeedData.cs index ed22831..e9cb564 100644 --- a/src/Workflow.Infrastructure/Persistence/SeedData.cs +++ b/src/Workflow.Infrastructure/Persistence/SeedData.cs @@ -7,10 +7,12 @@ public static class SeedData { public static async Task SeedAsync(WorkflowDbContext db) { - if (db.WorkflowDefinitions.Any()) return; - + // 组件注册是基础设施,独立于业务数据,始终执行(内部按 Id 幂等补插) await SeedComponentsAsync(db); + // 表单 / 工作流定义的种子数据仅在数据库为空时写入,避免覆盖用户已有数据 + if (db.WorkflowDefinitions.Any()) return; + var systemUserId = Guid.Parse("00000000-0000-0000-0000-000000000001"); // ── 请假表单(Formily JSON Schema)── @@ -150,7 +152,8 @@ public static class SeedData Id = Guid.Parse("B0000001-0000-0000-0000-000000000002"), DefinitionId = Guid.Parse("B0000000-0000-0000-0000-000000000001"), NodeType = NodeType.Approval, Name = "直属主管审批", - Config = """{ "assigneeRule": "role:manager" }""", + // timeoutMinutes=1440(1天)+ autoApproveOnTimeout=true:演示超时自动处理 + Config = """{ "assigneeRule": "role:manager", "timeoutMinutes": 1440, "autoApproveOnTimeout": true }""", FormDefinitionId = Guid.Parse("A0000000-0000-0000-0000-000000000001"), CreatedBy = systemUserId, UpdatedBy = systemUserId, }, @@ -166,7 +169,8 @@ public static class SeedData Id = Guid.Parse("B0000001-0000-0000-0000-000000000004"), DefinitionId = Guid.Parse("B0000000-0000-0000-0000-000000000001"), NodeType = NodeType.Approval, Name = "HR 审批", - Config = """{ "assigneeRule": "role:hr" }""", + // timeoutMinutes=2880(2天)+ autoApproveOnTimeout=false:演示超时自动驳回 + Config = """{ "assigneeRule": "role:hr", "timeoutMinutes": 2880, "autoApproveOnTimeout": false }""", CreatedBy = systemUserId, UpdatedBy = systemUserId, }, new() @@ -374,7 +378,8 @@ public static class SeedData private static async Task SeedComponentsAsync(WorkflowDbContext db) { - if (db.FormComponentRegistries.Any()) return; + // 幂等:按 Id 补插缺失的组件(支持升级后新增组件自动注册) + var existingIds = db.FormComponentRegistries.Select(c => c.Id).ToHashSet(); var systemUserId = Guid.Parse("00000000-0000-0000-0000-000000000001"); @@ -423,7 +428,7 @@ public static class SeedData new() { Id = Guid.Parse("D0000004-0000-0000-0000-000000000001"), - Name = "Input.Number", + Name = "InputNumber", DisplayName = "数字输入", Category = "基础输入", Icon = "lucide:hash", @@ -571,9 +576,40 @@ public static class SeedData CreatedBy = systemUserId, UpdatedBy = systemUserId, }, + new() + { + Id = Guid.Parse("D0000015-0000-0000-0000-000000000001"), + Name = "Tabs", + DisplayName = "标签页", + Category = "布局容器", + Icon = "lucide:panel-top", + SortOrder = 43, + DefaultSchema = """{"type":"void","title":"未命名","x-decorator":"FormItem","x-component":"Tabs","x-component-props":{}}""", + SupportedProps = """{"tab":{"label":"页签标题"}}""", + CreatedBy = systemUserId, + UpdatedBy = systemUserId, + }, + new() + { + Id = Guid.Parse("D0000016-0000-0000-0000-000000000001"), + Name = "Collapse", + DisplayName = "折叠面板", + Category = "布局容器", + Icon = "lucide:chevrons-down-up", + SortOrder = 44, + DefaultSchema = """{"type":"void","title":"未命名","x-decorator":"FormItem","x-component":"Collapse","x-component-props":{}}""", + SupportedProps = """{"header":{"label":"面板标题"}}""", + CreatedBy = systemUserId, + UpdatedBy = systemUserId, + }, }; - db.FormComponentRegistries.AddRange(components); - await db.SaveChangesAsync(); + // 仅插入尚未注册的组件(按 Id 去重),已存在的不动 + var toAdd = components.Where(c => !existingIds.Contains(c.Id)).ToList(); + if (toAdd.Count > 0) + { + db.FormComponentRegistries.AddRange(toAdd); + await db.SaveChangesAsync(); + } } } diff --git a/src/Workflow.Infrastructure/Persistence/WorkflowDbContext.cs b/src/Workflow.Infrastructure/Persistence/WorkflowDbContext.cs index 77e7e1a..986370b 100644 --- a/src/Workflow.Infrastructure/Persistence/WorkflowDbContext.cs +++ b/src/Workflow.Infrastructure/Persistence/WorkflowDbContext.cs @@ -6,6 +6,11 @@ using Workflow.Infrastructure.Persistence.Interceptors; namespace Workflow.Infrastructure.Persistence; +/// +/// 工作流持久化上下文。所有表使用 wf_ 前缀(由各 EntityTypeConfiguration 定义)。 +/// 注册了 AuditInterceptor 自动维护审计字段;并对所有 ISoftDelete 实体统一生成 +/// 全局查询过滤器,使业务查询自动排除已软删除记录。 +/// public class WorkflowDbContext : DbContext { private readonly ICurrentUserContext? _currentUserContext; @@ -18,7 +23,10 @@ public class WorkflowDbContext : DbContext public DbSet WorkflowTasks => Set(); public DbSet FormDefinitions => Set(); public DbSet FormData => Set(); + public DbSet FormDefinitionVersions => Set(); public DbSet FormComponentRegistries => Set(); + public DbSet Notifications => Set(); + public DbSet WebhookDeliveries => Set(); public WorkflowDbContext(DbContextOptions options) : base(options) { } @@ -27,6 +35,8 @@ public class WorkflowDbContext : DbContext _currentUserContext = currentUserContext; } + // 注意:此处通过 OnConfiguring 注册拦截器(而非 DI AddInterceptors),与 rag-backend 约定一致, + // 保证无参构造(如设计时工厂、测试夹具)也能正常工作。 protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { if (_currentUserContext is not null) @@ -37,8 +47,11 @@ public class WorkflowDbContext : DbContext protected override void OnModelCreating(ModelBuilder modelBuilder) { + // 自动发现本程序集内所有 IEntityTypeConfiguration 实现(含 wf_ 表前缀配置) modelBuilder.ApplyConfigurationsFromAssembly(typeof(WorkflowDbContext).Assembly); + // 为所有实现了 ISoftDelete 的实体动态生成 HasQueryFilter(e => !e.IsDeleted), + // 无需在每个 Configuration 中手写,保证软删除过滤全局一致 foreach (var entityType in modelBuilder.Model.GetEntityTypes()) { if (typeof(ISoftDelete).IsAssignableFrom(entityType.ClrType)) @@ -49,6 +62,7 @@ public class WorkflowDbContext : DbContext } } + /// 用表达式树动态构造 e => !e.IsDeleted,避免对每个实体类型手写泛型过滤。 private static LambdaExpression CreateSoftDeleteFilter(Type entityType) { var param = Expression.Parameter(entityType, "e"); diff --git a/tests/Workflow.Tests/Engine/ProcessEngineTests.cs b/tests/Workflow.Tests/Engine/ProcessEngineTests.cs index 877b967..3d8fa55 100644 --- a/tests/Workflow.Tests/Engine/ProcessEngineTests.cs +++ b/tests/Workflow.Tests/Engine/ProcessEngineTests.cs @@ -121,6 +121,65 @@ public class ProcessEngineTests tasks[0].Status.Should().Be(TaskStatus.Pending); } + [Fact] + public async Task ProcessApprovalNode_SetsDueAtFromTimeoutMinutesConfig() + { + var userId = Guid.NewGuid(); + var approvalNode = CreateNode(NodeType.Approval); + approvalNode.Config = """{ "assigneeRule": "user:""" + userId + "\", \"timeoutMinutes\": 1440 }"; + + var (_, instance) = PersistSetup([approvalNode], []); + var token = PersistToken(instance, approvalNode); + + var before = DateTime.UtcNow; + await _engine.ProcessNodeAsync(instance, token, approvalNode); + var after = DateTime.UtcNow; + + var task = await _dbContext.WorkflowTasks.SingleAsync(t => t.InstanceId == instance.Id); + task.DueAt.Should().NotBeNull(); + // DueAt 应在 before+1440min 到 after+1440min 之间(容忍执行耗时) + task.DueAt.Should().BeOnOrAfter(before.AddMinutes(1440)).And.BeOnOrBefore(after.AddMinutes(1440).AddSeconds(1)); + } + + [Fact] + public async Task ProcessApprovalNode_NoTimeoutConfig_DueAtIsNull() + { + var userId = Guid.NewGuid(); + var approvalNode = CreateNode(NodeType.Approval); + approvalNode.Config = """{ "assigneeRule": "user:""" + userId + "\" }"; + + var (_, instance) = PersistSetup([approvalNode], []); + var token = PersistToken(instance, approvalNode); + + await _engine.ProcessNodeAsync(instance, token, approvalNode); + + var task = await _dbContext.WorkflowTasks.SingleAsync(t => t.InstanceId == instance.Id); + task.DueAt.Should().BeNull(); + } + + [Fact] + public async Task ProcessApprovalNode_TriggersTaskArrivedNotificationWhenServiceRegistered() + { + // 注册 INotificationService,验证任务到达时通知被触发 + var capture = new NotificationCaptureService(); + var services = new ServiceCollection(); + services.AddSingleton(capture); + _serviceProvider = services.BuildServiceProvider(); + _engine = new ProcessEngine(_dbContext, _serviceProvider, new ConditionEvaluator()); + + var userId = Guid.NewGuid(); + var approvalNode = CreateNode(NodeType.Approval); + approvalNode.Config = """{ "assigneeRule": "user:""" + userId + "\" }"; + + var (_, inst) = PersistSetup([approvalNode], []); + var tok = PersistToken(inst, approvalNode); + + await _engine.ProcessNodeAsync(inst, tok, approvalNode); + + capture.ArrivedTasks.Should().HaveCount(1); + capture.ArrivedTasks[0].AssigneeId.Should().Be(userId); + } + [Fact] public async Task CompleteTask_Approved_PropagatesTokenAlongApprovedEdge() { @@ -659,6 +718,203 @@ public class ProcessEngineTests }; } + // ============================================================ + // Parallel Fork → Approvals → Join: full pipeline + // ============================================================ + + /// + /// 端到端:并行 fork 分出两条审批分支,两条均审批通过后汇聚到 join, + /// join 仅在两个 token 都到达时才合并并推进到下游。 + /// 这是 fork/join 最常出问题的真实路径(既有测试分别测 fork 与 join,未覆盖整条链)。 + /// + [Fact] + public async Task ParallelFork_BothBranchesApproved_JoinsAndPropagatesOnce() + { + var forkNode = CreateNode(NodeType.Parallel); + var approvalA = CreateNode(NodeType.Approval); + approvalA.Config = """{ "assigneeRule": "role:userA" }"""; + var approvalB = CreateNode(NodeType.Approval); + approvalB.Config = """{ "assigneeRule": "role:userB" }"""; + var joinNode = CreateNode(NodeType.Parallel); + var afterJoin = CreateNode(NodeType.End); + + var edgeForkA = CreateEdge(forkNode, approvalA); + var edgeForkB = CreateEdge(forkNode, approvalB); + var edgeAJoin = CreateEdge(approvalA, joinNode); + var edgeBJoin = CreateEdge(approvalB, joinNode); + var edgeJoinOut = CreateEdge(joinNode, afterJoin); + + var (_, instance) = PersistSetup( + [forkNode, approvalA, approvalB, joinNode, afterJoin], + [edgeForkA, edgeForkB, edgeAJoin, edgeBJoin, edgeJoinOut]); + + var forkToken = PersistToken(instance, forkNode); + + // Fork:分出两个 token 到两个审批节点 + await _engine.ProcessNodeAsync(instance, forkToken, forkNode); + var activeAfterFork = await _dbContext.WorkflowTokens + .Where(t => t.InstanceId == instance.Id && t.Status == TokenStatus.Active) + .ToListAsync(); + activeAfterFork.Should().HaveCount(2); + + // 完成分支 A 的审批 → token 推进到 join,但 join 应等待 + var taskA = await _dbContext.WorkflowTasks.FirstAsync(t => t.NodeId == approvalA.Id); + await _engine.CompleteTaskAsync(taskA, TaskResult.Approved); + + var activeAfterA = await _dbContext.WorkflowTokens + .Where(t => t.InstanceId == instance.Id && t.Status == TokenStatus.Active) + .ToListAsync(); + // 分支 A 的 token 已进入 join 等待;分支 B 仍未完成 → 仍 2 个 active(1 在 join,1 在 approvalB) + activeAfterA.Should().HaveCount(2); + activeAfterA.Should().Contain(t => t.NodeId == approvalB.Id); + instance.Status.Should().NotBe(InstanceStatus.Completed); + + // 完成分支 B 的审批 → 第二个 token 到达 join,满足合并条件 + var taskB = await _dbContext.WorkflowTasks.FirstAsync(t => t.NodeId == approvalB.Id); + await _engine.CompleteTaskAsync(taskB, TaskResult.Approved); + + // join 合并后产生 1 个 token → 进入 End 节点被消费 → 无残留 Active token,实例完成。 + // 关键不变量:两个 join 入口 token 都被消费(已合并),且实例确实完成。 + var activeAfterB = await _dbContext.WorkflowTokens + .Where(t => t.InstanceId == instance.Id && t.Status == TokenStatus.Active) + .ToListAsync(); + activeAfterB.Should().BeEmpty("End 节点应已消费 join 合并后的 token"); + + instance.Status.Should().Be(InstanceStatus.Completed); + + // join 处的两个入口 token 均应已消费 + var joinTokens = await _dbContext.WorkflowTokens + .Where(t => t.InstanceId == instance.Id && t.NodeId == joinNode.Id) + .ToListAsync(); + joinTokens.Should().OnlyContain(t => t.Status == TokenStatus.Consumed); + joinTokens.Should().HaveCount(2); + } + + // ============================================================ + // SubProcess boundaries + // ============================================================ + + /// + /// 边界:SubProcess 节点未配置 definitionId 时必须明确拒绝。 + /// + [Fact] + public async Task ProcessSubProcessNode_WithoutDefinitionId_ThrowsBusinessException() + { + var subProcessNode = CreateNode(NodeType.SubProcess); + subProcessNode.Config = "{}"; // 无 definitionId + + var (_, instance) = PersistSetup([subProcessNode], []); + var token = PersistToken(instance, subProcessNode); + + var act = () => _engine.ProcessNodeAsync(instance, token, subProcessNode); + + await act.Should().ThrowAsync() + .WithMessage("*definitionId*"); + } + + /// + /// 边界:SubProcess 完成但其节点没有出边时,父 token 应被消费(不残留), + /// 而非静默挂着。锁定 HandleSubProcessCompletionAsync 的当前行为。 + /// + [Fact] + public async Task SubProcessComplete_WithNoOutgoingEdge_ConsumesWaitingToken() + { + var subProcessNode = CreateNode(NodeType.SubProcess); + subProcessNode.Config = """{ "definitionId": "00000000-0000-0000-0000-000000000001" }"""; + + var (_, parentInstance) = PersistSetup([subProcessNode], []); + var parentToken = PersistToken(parentInstance, subProcessNode); + + var childInstance = new WorkflowInstance + { + Id = Guid.NewGuid(), + DefinitionId = Guid.Parse("00000000-0000-0000-0000-000000000001"), + ParentInstanceId = parentInstance.Id, + ParentTokenId = parentToken.Id, + Status = InstanceStatus.Completed, + Variables = "{}", + }; + _dbContext.WorkflowInstances.Add(childInstance); + await _dbContext.SaveChangesAsync(); + + await _engine.HandleSubProcessCompletionAsync(childInstance); + + // 父 token 必须被消费,不可残留 Active + var refreshedToken = await _dbContext.WorkflowTokens.FindAsync(parentToken.Id); + refreshedToken!.Status.Should().Be(TokenStatus.Consumed); + } + + // ============================================================ + // Token Duplicate-Propagation Guard (idempotency) + // ============================================================ + + /// + /// 边界:已完成(已审批)的任务不可被再次完成,否则会重复推进 token, + /// 产生额外的下游 token 并可能创建重复任务。必须保持幂等/拒绝。 + /// + [Fact] + public async Task CompleteTask_AlreadyCompletedTask_ThrowsOrIsIdempotent() + { + var approvalNode = CreateNode(NodeType.Approval); + var nextNode = CreateNode(NodeType.Approval); + var approvedEdge = CreateEdge(approvalNode, nextNode, EdgeType.Approved); + approvalNode.Config = """{ "assigneeRule": "role:user" }"""; + + var (_, instance) = PersistSetup([approvalNode, nextNode], [approvedEdge]); + var token = PersistToken(instance, approvalNode); + var task = PersistTask(instance, token, approvalNode); + + // 第一次完成:正常推进到 nextNode + await _engine.CompleteTaskAsync(task, TaskResult.Approved); + + var activeTokensAfterFirst = await _dbContext.WorkflowTokens + .Where(t => t.InstanceId == instance.Id && t.Status == TokenStatus.Active && t.NodeId == nextNode.Id) + .ToListAsync(); + activeTokensAfterFirst.Should().HaveCount(1); + + // 第二次完成同一任务:必须抛错(或幂等不产生新 token),绝不可产生第二个下游 token + var secondAct = () => _engine.CompleteTaskAsync(task, TaskResult.Approved); + + await secondAct.Should().ThrowAsync(); + + // 仍只有 1 个活跃下游 token,未被重复推进 + var activeTokensAfterSecond = await _dbContext.WorkflowTokens + .Where(t => t.InstanceId == instance.Id && t.Status == TokenStatus.Active && t.NodeId == nextNode.Id) + .ToListAsync(); + activeTokensAfterSecond.Should().HaveCount(1); + } + + /// + /// 边界:同一 Approval 节点的 token 不可被 ProcessNodeAsync 重复处理, + /// 否则会在同一节点创建多份审批任务。第二次处理必须拒绝(或幂等)。 + /// + [Fact] + public async Task ProcessApprovalNode_TokenAlreadyProcessed_ThrowsOrIsIdempotent() + { + var userId = Guid.NewGuid(); + var approvalNode = CreateNode(NodeType.Approval); + approvalNode.Config = $"{{ \"assigneeRule\": \"user:{userId}\" }}"; + + var (_, instance) = PersistSetup([approvalNode], []); + var token = PersistToken(instance, approvalNode); + + // 第一次处理:创建 1 个任务 + await _engine.ProcessNodeAsync(instance, token, approvalNode); + var tasksAfterFirst = await _dbContext.WorkflowTasks + .Where(t => t.InstanceId == instance.Id) + .ToListAsync(); + tasksAfterFirst.Should().HaveCount(1); + + // 第二次处理同一 token:必须拒绝(或幂等),不可创建第二个任务 + var secondAct = () => _engine.ProcessNodeAsync(instance, token, approvalNode); + await secondAct.Should().ThrowAsync(); + + var tasksAfterSecond = await _dbContext.WorkflowTasks + .Where(t => t.InstanceId == instance.Id) + .ToListAsync(); + tasksAfterSecond.Should().HaveCount(1); + } + private static WorkflowEdge CreateEdge( WorkflowNode source, WorkflowNode target, @@ -773,4 +1029,21 @@ public class ProcessEngineTests throw new InvalidOperationException("Simulated action failure"); } } + + /// 测试用通知捕获器:记录任务到达/通过/驳回通知,便于断言引擎触发了通知。 + private class NotificationCaptureService : Workflow.Application.Notifications.INotificationService + { + public List ArrivedTasks { get; } = []; + public List ApprovedTasks { get; } = []; + public List RejectedTasks { get; } = []; + + public Task NotifyTaskArrivedAsync(WorkflowTask task, CancellationToken ct = default) + { ArrivedTasks.Add(task); return Task.CompletedTask; } + public Task NotifyTaskApprovedAsync(WorkflowTask task, CancellationToken ct = default) + { ApprovedTasks.Add(task); return Task.CompletedTask; } + public Task NotifyTaskRejectedAsync(WorkflowTask task, CancellationToken ct = default) + { RejectedTasks.Add(task); return Task.CompletedTask; } + public Task NotifyTaskUrgedAsync(WorkflowTask task, CancellationToken ct = default) => Task.CompletedTask; + public Task NotifyTaskTimeoutAsync(WorkflowTask task, bool autoApproved, CancellationToken ct = default) => Task.CompletedTask; + } } diff --git a/tests/Workflow.Tests/Form/FormDataTests.cs b/tests/Workflow.Tests/Form/FormDataTests.cs index 4ca2021..0229501 100644 --- a/tests/Workflow.Tests/Form/FormDataTests.cs +++ b/tests/Workflow.Tests/Form/FormDataTests.cs @@ -168,6 +168,96 @@ public class FormDataTests count.Should().Be(0); } + /// + /// 边界:表单已被软删除时提交数据,必须给出准确错误(表单已被删除), + /// 而非误导性的「表单定义 ... 不存在」(表单实际存在,只是被删除)。 + /// + [Fact] + public async Task SubmitFormData_WithSoftDeletedFormDefinition_ThrowsAccurateError() + { + var formId = Guid.NewGuid(); + var instanceId = Guid.NewGuid(); + var dataJson = """{"name":"张三","age":25,"gender":"male"}"""; + + await using var db = await _fixture.CreateDbContextWithSeedAsync( + testName: nameof(SubmitFormData_WithSoftDeletedFormDefinition_ThrowsAccurateError), + seedAction: ctx => + { + var form = CreatePublishedFormDefinition(formId); + form.IsDeleted = true; + ctx.FormDefinitions.Add(form); + }); + + var handler = new SubmitFormDataCommandHandler(db); + var act = () => handler.Handle( + new SubmitFormDataCommand(FormDefinitionId: formId, InstanceId: instanceId, DataJson: dataJson), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*表单*删除*"); + + var count = await db.FormData.CountAsync(); + count.Should().Be(0); + } + + /// + /// 边界:确实不存在的表单 ID,必须给出准确错误(不存在)。 + /// 与软删除场景区分,确保错误信息不会混淆。 + /// + [Fact] + public async Task SubmitFormData_WithTrulyNonExistentFormDefinition_ThrowsAccurateError() + { + var formId = Guid.NewGuid(); + var instanceId = Guid.NewGuid(); + var dataJson = """{"name":"张三","age":25,"gender":"male"}"""; + + await using var db = _fixture.CreateDbContext( + testName: nameof(SubmitFormData_WithTrulyNonExistentFormDefinition_ThrowsAccurateError)); + + var handler = new SubmitFormDataCommandHandler(db); + var act = () => handler.Handle( + new SubmitFormDataCommand(FormDefinitionId: formId, InstanceId: instanceId, DataJson: dataJson), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*不存在*"); + + var count = await db.FormData.CountAsync(); + count.Should().Be(0); + } + + /// + /// 边界:表单状态为 Disabled(已停用)时提交数据,必须严格阻断。 + /// 产品决策:Disabled = 严格阻断(新提交一律拒绝)。 + /// + [Fact] + public async Task SubmitFormData_WithDisabledForm_ThrowsBusinessException() + { + var formId = Guid.NewGuid(); + var instanceId = Guid.NewGuid(); + var dataJson = """{"name":"张三","age":25,"gender":"male"}"""; + + await using var db = await _fixture.CreateDbContextWithSeedAsync( + testName: nameof(SubmitFormData_WithDisabledForm_ThrowsBusinessException), + seedAction: ctx => + { + var form = CreatePublishedFormDefinition(formId); + form.Status = FormStatus.Disabled; + ctx.FormDefinitions.Add(form); + }); + + var handler = new SubmitFormDataCommandHandler(db); + var act = () => handler.Handle( + new SubmitFormDataCommand(FormDefinitionId: formId, InstanceId: instanceId, DataJson: dataJson), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*表单*停用*"); + + var count = await db.FormData.CountAsync(); + count.Should().Be(0); + } + // ==================================================================== // GetFormDataByInstance // ==================================================================== diff --git a/tests/Workflow.Tests/Form/FormDataValidatorTests.cs b/tests/Workflow.Tests/Form/FormDataValidatorTests.cs new file mode 100644 index 0000000..54fc4e6 --- /dev/null +++ b/tests/Workflow.Tests/Form/FormDataValidatorTests.cs @@ -0,0 +1,324 @@ +using FluentAssertions; +using Workflow.Application.Form.Schema; +using Xunit; + +namespace Workflow.Tests.Form; + +public class FormDataValidatorTests +{ + private const string Schema = """ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "姓名", + "required": true, + "x-component": "Input", + "x-validator": [ + { "type": "minLength", "value": 2, "message": "姓名至少 2 个字符" } + ] + }, + "age": { + "type": "number", + "title": "年龄", + "x-component": "InputNumber", + "x-validator": [ + { "type": "min", "value": 0, "message": "年龄不能小于 0" }, + { "type": "max", "value": 150, "message": "年龄不能大于 150" } + ] + }, + "gender": { + "type": "string", + "title": "性别", + "required": true, + "x-component": "Select", + "x-data-source": { + "type": "static", + "options": [ + { "label": "男", "value": "male" }, + { "label": "女", "value": "female" } + ] + } + } + } + } + """; + + private static readonly HashSet AllowedComponents = ["Input", "InputNumber", "Select"]; + + [Fact] + public void Validate_WithValidData_ReturnsValid() + { + var result = FormDataValidator.Validate(Schema, """{"name":"张三","age":25,"gender":"male"}""", AllowedComponents); + + result.IsValid.Should().BeTrue(string.Join("; ", result.Errors)); + result.Errors.Should().BeEmpty(); + } + + [Fact] + public void Validate_WithMissingRequiredField_ReturnsFieldError() + { + var result = FormDataValidator.Validate(Schema, """{"age":25,"gender":"male"}""", AllowedComponents); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Contains("姓名") && e.Contains("必填")); + } + + [Fact] + public void Validate_WithEnumMismatch_ReturnsFieldError() + { + var result = FormDataValidator.Validate(Schema, """{"name":"张三","age":25,"gender":"other"}""", AllowedComponents); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Contains("gender") && e.Contains("允许范围")); + } + + [Fact] + public void Validate_WithLengthAndRangeViolations_ReturnsAllErrors() + { + var result = FormDataValidator.Validate(Schema, """{"name":"李","age":151,"gender":"male"}""", AllowedComponents); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Contains("姓名至少 2 个字符")); + result.Errors.Should().Contain(e => e.Contains("年龄不能大于 150")); + } + + [Fact] + public void Validate_HiddenFieldByReaction_SkipsRequiredCheck() + { + // leaveType != sick 时(visible 规则未命中),reason 被联动隐藏。 + // reason 即使必填缺失也不应报错。 + const string schema = """ + { + "type": "object", + "properties": { + "leaveType": { + "type": "string", + "title": "请假类型", + "x-component": "Select", + "x-reactions": [ + { + "type": "condition", + "target": "reason", + "when": { "source": "leaveType", "operator": "eq", "value": "sick" }, + "action": "visible" + } + ] + }, + "reason": { + "type": "string", + "title": "原因", + "required": true, + "x-component": "Input" + } + } + } + """; + + // leaveType=annual 未命中 visible 规则 → reason 隐藏 → 跳过必填 + var result = FormDataValidator.Validate( + schema, + """{"leaveType":"annual"}""", + ["Input", "Select"]); + + result.IsValid.Should().BeTrue(string.Join("; ", result.Errors)); + } + + [Fact] + public void Validate_NestedFieldInContainer_ReadsValueByDottedPath() + { + // FormGrid/FormLayout 等容器下的嵌套字段:field.Path 为点分路径(dateRange.startDate), + // 提交数据是嵌套对象 {dateRange:{startDate:...}}。校验器必须按点分路径取值, + // 否则嵌套必填字段会被误判为缺失,导致带容器的表单无法通过校验。 + const string schema = """ + { + "type": "object", + "properties": { + "leaveType": { + "type": "string", + "title": "请假类型", + "required": true, + "x-component": "Select" + }, + "dateRange": { + "type": "void", + "title": "日期范围", + "x-component": "FormGrid", + "properties": { + "startDate": { + "type": "string", + "title": "开始日期", + "required": true, + "x-component": "DatePicker" + }, + "endDate": { + "type": "string", + "title": "结束日期", + "required": true, + "x-component": "DatePicker" + } + } + } + } + } + """; + + var result = FormDataValidator.Validate( + schema, + """{"leaveType":"annual","dateRange":{"startDate":"2026-06-15","endDate":"2026-06-16"}}""", + ["Input", "Select", "DatePicker", "FormGrid"]); + + result.IsValid.Should().BeTrue(string.Join("; ", result.Errors)); + } + + [Fact] + public void Validate_NestedFieldMissing_ReportsRequiredError() + { + const string schema = """ + { + "type": "object", + "properties": { + "dateRange": { + "type": "void", + "x-component": "FormGrid", + "properties": { + "startDate": { + "type": "string", + "title": "开始日期", + "required": true, + "x-component": "DatePicker" + } + } + } + } + } + """; + + var result = FormDataValidator.Validate( + schema, + """{"dateRange":{}}""", + ["DatePicker", "FormGrid"]); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Contains("开始日期") && e.Contains("必填")); + } + + [Fact] + public void Validate_VisibleRequiredField_StillEnforced() + { + // leaveType=sick 时(visible 规则命中),reason 可见且必填,缺失应报错 + const string schema = """ + { + "type": "object", + "properties": { + "leaveType": { + "type": "string", + "title": "请假类型", + "x-component": "Select", + "x-reactions": [ + { + "type": "condition", + "target": "reason", + "when": { "source": "leaveType", "operator": "eq", "value": "sick" }, + "action": "visible" + } + ] + }, + "reason": { + "type": "string", + "title": "原因", + "required": true, + "x-component": "Input" + } + } + } + """; + + // leaveType=sick 命中 visible 规则 → reason 显示 → 必填校验生效 + var result = FormDataValidator.Validate( + schema, + """{"leaveType":"sick"}""", + ["Input", "Select"]); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Contains("原因") && e.Contains("必填")); + } + + [Fact] + public void Validate_FieldHiddenByPermissionForCurrentNode_SkipsRequiredCheck() + { + // 字段 salary 配置了在"HR审批"节点 hidden。提交时若按 HR审批 节点校验,salary 缺失应跳过必填。 + const string schema = """ + { + "type": "object", + "properties": { + "name": { "type": "string", "title": "姓名", "required": true, "x-component": "Input" }, + "salary": { + "type": "number", "title": "薪资", "required": true, "x-component": "InputNumber", + "x-field-permission": { "HR审批": "hidden" } + } + } + } + """; + + // 不传节点:salary 是必填,缺失应报错 + var noNode = FormDataValidator.Validate(schema, """{"name":"张三"}""", ["Input", "InputNumber"]); + noNode.IsValid.Should().BeFalse("无节点上下文时不做权限过滤,salary 必填缺失应报错"); + + // 传 HR审批 节点:salary 在该节点 hidden,应跳过必填校验 → 通过 + var hrNode = FormDataValidator.Validate( + schema, """{"name":"张三"}""", ["Input", "InputNumber"], currentNodeKey: "HR审批"); + hrNode.IsValid.Should().BeTrue(string.Join("; ", hrNode.Errors)); + } + + [Fact] + public void Validate_FieldVisibleForCurrentNode_StillEnforcesRequired() + { + // salary 在"直属主管审批"节点 visible(可见可编辑),缺失应报错 + const string schema = """ + { + "type": "object", + "properties": { + "salary": { + "type": "number", "title": "薪资", "required": true, "x-component": "InputNumber", + "x-field-permission": { "直属主管审批": "visible", "HR审批": "hidden" } + } + } + } + """; + + var result = FormDataValidator.Validate( + schema, """{}""", ["InputNumber"], currentNodeKey: "直属主管审批"); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Contains("薪资") && e.Contains("必填")); + } + + [Fact] + public void Validate_PermissionDefaultFallback_AppliesWhenNodeNotExplicitlyConfigured() + { + // salary 配了 __default__: hidden。当前节点"未知节点"未显式配置 → 走 default → 隐藏 → 跳过必填 + const string schema = """ + { + "type": "object", + "properties": { + "salary": { + "type": "number", "title": "薪资", "required": true, "x-component": "InputNumber", + "x-field-permission": { "__default__": "hidden", "直属主管审批": "visible" } + } + } + } + """; + + // 直属主管审批:显式 visible → 必填生效 → 缺失报错 + var manager = FormDataValidator.Validate( + schema, """{}""", ["InputNumber"], currentNodeKey: "直属主管审批"); + manager.IsValid.Should().BeFalse("直属主管审批节点 salary 可见,必填缺失应报错"); + + // 未知节点:走 __default__ = hidden → 跳过必填 → 通过 + var unknown = FormDataValidator.Validate( + schema, """{}""", ["InputNumber"], currentNodeKey: "未知节点"); + unknown.IsValid.Should().BeTrue("未知节点走 __default__ hidden,应跳过必填"); + } +} diff --git a/tests/Workflow.Tests/Form/FormDefinitionTests.cs b/tests/Workflow.Tests/Form/FormDefinitionTests.cs index 9f21d19..2b2a547 100644 --- a/tests/Workflow.Tests/Form/FormDefinitionTests.cs +++ b/tests/Workflow.Tests/Form/FormDefinitionTests.cs @@ -309,7 +309,7 @@ public class FormDefinitionTests CancellationToken.None); result.Items.Should().HaveCount(10); - result.TotalCount.Should().Be(15); + result.Total.Should().Be(15); } // ==================================================================== diff --git a/tests/Workflow.Tests/Form/FormDeletionReferenceTests.cs b/tests/Workflow.Tests/Form/FormDeletionReferenceTests.cs new file mode 100644 index 0000000..013d241 --- /dev/null +++ b/tests/Workflow.Tests/Form/FormDeletionReferenceTests.cs @@ -0,0 +1,183 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Workflow.Application.Form.FormDefinition.Commands; +using Workflow.Domain.Entities; +using Workflow.Domain.Enums; +using Workflow.Domain.Exceptions; +using Workflow.Infrastructure.Persistence; +using Xunit; + +namespace Workflow.Tests.Form; + +/// +/// 表单设计器与流程设计器耦合关系下的删除边界测试。 +/// +/// 背景:FormDefinition 被软删除后,全局查询过滤器会让 GetFormDefinitionByIdQuery 等读取路径 +/// 直接抛 NotFoundException;StartWorkflowInstanceCommand 也会抛 BusinessException。 +/// 因此删除一张仍被流程定义/节点引用的表单,会在运行期产生孤儿引用。 +/// 删除前必须阻断并给出明确错误,而不是让下游崩溃。 +/// +[Collection("FormTests")] +public class FormDeletionReferenceTests +{ + private readonly FormTestFixture _fixture; + + public FormDeletionReferenceTests(FormTestFixtureClassFixture fixture) + { + _fixture = fixture; + } + + private const string ValidSchema = """ + { + "type": "object", + "properties": { + "name": { "type": "string", "title": "姓名", "x-decorator": "FormItem", "x-component": "Input" } + } + } + """; + + private static FormDefinition NewForm(Guid id, string code = "REF_FORM") => new() + { + Id = id, + Name = "引用测试表单", + Code = code, + Version = 1, + Status = FormStatus.Published, + SchemaJson = ValidSchema, + }; + + // ---------------------------------------------------------------- + // 已被流程定义引用:删除必须被阻断 + // ---------------------------------------------------------------- + [Fact] + public async Task DeleteFormDefinition_ReferencedByWorkflowDefinition_ThrowsBusinessException() + { + var formId = Guid.NewGuid(); + await using var db = await _fixture.CreateDbContextWithSeedAsync( + testName: nameof(DeleteFormDefinition_ReferencedByWorkflowDefinition_ThrowsBusinessException), + seedAction: ctx => + { + ctx.FormDefinitions.Add(NewForm(formId)); + ctx.WorkflowDefinitions.Add(new WorkflowDefinition + { + Id = Guid.NewGuid(), + Name = "引用该表单的流程", + Code = "WF_REF_FORM_1", + Version = 1, + Status = DefinitionStatus.Draft, + IsEnabled = true, + FormDefinitionId = formId, + }); + }); + + var handler = new DeleteFormDefinitionCommandHandler(db); + var act = () => handler.Handle(new DeleteFormDefinitionCommand(formId), CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*流程*"); + + // 删除被阻断,表单保持可见 + var stillThere = await db.FormDefinitions.FindAsync(formId); + stillThere.Should().NotBeNull(); + stillThere!.IsDeleted.Should().BeFalse(); + } + + // ---------------------------------------------------------------- + // 已被流程节点引用:删除必须被阻断 + // ---------------------------------------------------------------- + [Fact] + public async Task DeleteFormDefinition_ReferencedByWorkflowNode_ThrowsBusinessException() + { + var formId = Guid.NewGuid(); + var definitionId = Guid.NewGuid(); + await using var db = await _fixture.CreateDbContextWithSeedAsync( + testName: nameof(DeleteFormDefinition_ReferencedByWorkflowNode_ThrowsBusinessException), + seedAction: ctx => + { + ctx.FormDefinitions.Add(NewForm(formId)); + ctx.WorkflowDefinitions.Add(new WorkflowDefinition + { + Id = definitionId, + Name = "节点引用表单的流程", + Code = "WF_REF_FORM_2", + Version = 1, + Status = DefinitionStatus.Draft, + IsEnabled = true, + }); + ctx.WorkflowNodes.Add(new WorkflowNode + { + Id = Guid.NewGuid(), + DefinitionId = definitionId, + NodeType = NodeType.Approval, + Name = "审批节点", + FormDefinitionId = formId, + }); + }); + + var handler = new DeleteFormDefinitionCommandHandler(db); + var act = () => handler.Handle(new DeleteFormDefinitionCommand(formId), CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*流程*"); + + var stillThere = await db.FormDefinitions.FindAsync(formId); + stillThere.Should().NotBeNull(); + stillThere!.IsDeleted.Should().BeFalse(); + } + + // ---------------------------------------------------------------- + // 仅被已软删除的流程引用:可正常删除(孤儿引用不应永久锁死表单) + // ---------------------------------------------------------------- + [Fact] + public async Task DeleteFormDefinition_OnlyReferencedBySoftDeletedDefinition_AllowsDeletion() + { + var formId = Guid.NewGuid(); + await using var db = await _fixture.CreateDbContextWithSeedAsync( + testName: nameof(DeleteFormDefinition_OnlyReferencedBySoftDeletedDefinition_AllowsDeletion), + seedAction: ctx => + { + ctx.FormDefinitions.Add(NewForm(formId)); + ctx.WorkflowDefinitions.Add(new WorkflowDefinition + { + Id = Guid.NewGuid(), + Name = "已删除的流程", + Code = "WF_DELETED", + Version = 1, + Status = DefinitionStatus.Draft, + IsEnabled = true, + FormDefinitionId = formId, + IsDeleted = true, + }); + }); + + var handler = new DeleteFormDefinitionCommandHandler(db); + await handler.Handle(new DeleteFormDefinitionCommand(formId), CancellationToken.None); + + var normalQuery = await db.FormDefinitions.FirstOrDefaultAsync(f => f.Id == formId); + normalQuery.Should().BeNull(); + + var deleted = await db.FormDefinitions.IgnoreQueryFilters().FirstAsync(f => f.Id == formId); + deleted.IsDeleted.Should().BeTrue(); + } + + // ---------------------------------------------------------------- + // 无任何引用:可正常删除(回归现有行为,确保不误伤) + // ---------------------------------------------------------------- + [Fact] + public async Task DeleteFormDefinition_NotReferenced_Succeeds() + { + var formId = Guid.NewGuid(); + await using var db = await _fixture.CreateDbContextWithSeedAsync( + testName: nameof(DeleteFormDefinition_NotReferenced_Succeeds), + seedAction: ctx => ctx.FormDefinitions.Add(NewForm(formId, code: "UNREF"))); + + var handler = new DeleteFormDefinitionCommandHandler(db); + await handler.Handle(new DeleteFormDefinitionCommand(formId), CancellationToken.None); + + var normalQuery = await db.FormDefinitions.FirstOrDefaultAsync(f => f.Id == formId); + normalQuery.Should().BeNull(); + + var deleted = await db.FormDefinitions.IgnoreQueryFilters().FirstAsync(f => f.Id == formId); + deleted.IsDeleted.Should().BeTrue(); + } +} diff --git a/tests/Workflow.Tests/Form/SchemaDifferTests.cs b/tests/Workflow.Tests/Form/SchemaDifferTests.cs new file mode 100644 index 0000000..36c98c4 --- /dev/null +++ b/tests/Workflow.Tests/Form/SchemaDifferTests.cs @@ -0,0 +1,166 @@ +using FluentAssertions; +using Workflow.Application.Form.Schema; +using Xunit; + +namespace Workflow.Tests.Form; + +public class SchemaDifferTests +{ + private const string OldSchema = """ + { + "type": "object", + "properties": { + "name": { "type": "string", "title": "姓名", "required": true, "x-component": "Input" }, + "age": { "type": "number", "title": "年龄", "x-component": "InputNumber" }, + "gender": { "type": "string", "title": "性别", "x-component": "Select" } + } + } + """; + + [Fact] + public void Diff_NoChanges_ReturnsEmpty() + { + var diff = SchemaDiffer.Diff(OldSchema, OldSchema); + + diff.Added.Should().BeEmpty(); + diff.Removed.Should().BeEmpty(); + diff.Modified.Should().BeEmpty(); + } + + [Fact] + public void Diff_DetectsAddedField() + { + var newSchema = """ + { + "type": "object", + "properties": { + "name": { "type": "string", "title": "姓名", "required": true, "x-component": "Input" }, + "age": { "type": "number", "title": "年龄", "x-component": "InputNumber" }, + "gender": { "type": "string", "title": "性别", "x-component": "Select" }, + "email": { "type": "string", "title": "邮箱", "x-component": "Input" } + } + } + """; + + var diff = SchemaDiffer.Diff(OldSchema, newSchema); + + diff.Added.Should().ContainSingle(f => f.Path == "email"); + diff.Removed.Should().BeEmpty(); + diff.Modified.Should().BeEmpty(); + } + + [Fact] + public void Diff_DetectsRemovedField() + { + var newSchema = """ + { + "type": "object", + "properties": { + "name": { "type": "string", "title": "姓名", "required": true, "x-component": "Input" }, + "age": { "type": "number", "title": "年龄", "x-component": "InputNumber" } + } + } + """; + + var diff = SchemaDiffer.Diff(OldSchema, newSchema); + + diff.Removed.Should().ContainSingle(f => f.Path == "gender"); + diff.Added.Should().BeEmpty(); + } + + [Fact] + public void Diff_DetectsComponentChange() + { + var newSchema = """ + { + "type": "object", + "properties": { + "name": { "type": "string", "title": "姓名", "required": true, "x-component": "Input" }, + "age": { "type": "number", "title": "年龄", "x-component": "Input" }, + "gender": { "type": "string", "title": "性别", "x-component": "Select" } + } + } + """; + + var diff = SchemaDiffer.Diff(OldSchema, newSchema); + + diff.Modified.Should().ContainSingle(f => f.Path == "age" && f.Change!.Contains("组件")); + } + + [Fact] + public void Diff_DetectsRequiredChange() + { + var newSchema = """ + { + "type": "object", + "properties": { + "name": { "type": "string", "title": "姓名", "x-component": "Input" }, + "age": { "type": "number", "title": "年龄", "x-component": "InputNumber" }, + "gender": { "type": "string", "title": "性别", "x-component": "Select" } + } + } + """; + + var diff = SchemaDiffer.Diff(OldSchema, newSchema); + + diff.Modified.Should().ContainSingle(f => f.Path == "name" && f.Change!.Contains("必填")); + } + + [Fact] + public void Diff_HandlesNullInputs() + { + var diff = SchemaDiffer.Diff(null, null); + + diff.Added.Should().BeEmpty(); + diff.Removed.Should().BeEmpty(); + diff.Modified.Should().BeEmpty(); + } + + [Fact] + public void Diff_HandlesInvalidJson() + { + var diff = SchemaDiffer.Diff("not json", "{ }"); + + // 无效的旧 schema 视为空,新 schema 也空,无差异 + diff.Added.Should().BeEmpty(); + diff.Removed.Should().BeEmpty(); + } + + [Fact] + public void Diff_HandlesNestedFields() + { + var oldNested = """ + { + "type": "object", + "properties": { + "card": { + "type": "void", + "x-component": "Card", + "properties": { + "name": { "type": "string", "title": "姓名", "x-component": "Input" } + } + } + } + } + """; + var newNested = """ + { + "type": "object", + "properties": { + "card": { + "type": "void", + "x-component": "Card", + "properties": { + "name": { "type": "string", "title": "姓名", "x-component": "Input" }, + "age": { "type": "number", "title": "年龄", "x-component": "InputNumber" } + } + } + } + } + """; + + var diff = SchemaDiffer.Diff(oldNested, newNested); + + diff.Added.Should().ContainSingle(f => f.Path == "card.age"); + } +} diff --git a/tests/Workflow.Tests/Form/SchemaValidatorTests.cs b/tests/Workflow.Tests/Form/SchemaValidatorTests.cs new file mode 100644 index 0000000..ff48ad9 --- /dev/null +++ b/tests/Workflow.Tests/Form/SchemaValidatorTests.cs @@ -0,0 +1,414 @@ +using FluentAssertions; +using Workflow.Application.Form.Schema; +using Xunit; + +namespace Workflow.Tests.Form; + +public class SchemaValidatorTests +{ + private static readonly HashSet AllowedComponents = + ["Input", "InputNumber", "Select", "Radio.Group", "Checkbox.Group", "FormGrid", "Card"]; + + [Fact] + public void Validate_NormalizesInputNumberAliasAndExtractsFieldSummary() + { + const string schema = """ + { + "type": "object", + "properties": { + "amount": { + "type": "number", + "title": "金额", + "required": true, + "x-component": "Input.Number", + "x-validator": [ + { "type": "min", "value": 0, "message": "不能小于 0" }, + { "type": "max", "value": 9999, "message": "不能大于 9999" } + ] + } + } + } + """; + + var result = SchemaValidator.Validate(schema, AllowedComponents); + + result.IsValid.Should().BeTrue(string.Join("; ", result.Errors)); + result.Fields.Should().ContainSingle(); + var field = result.Fields.Single(); + field.Path.Should().Be("amount"); + field.JsonType.Should().Be("number"); + field.Component.Should().Be("InputNumber"); + field.Required.Should().BeTrue(); + field.Validators.Select(v => v.Type).Should().Contain(["min", "max"]); + } + + [Fact] + public void Validate_AcceptsInputNumberWhenAllowedComponentsUseLegacyAlias() + { + const string schema = """ + { + "type": "object", + "properties": { + "amount": { + "type": "number", + "title": "金额", + "x-component": "InputNumber" + } + } + } + """; + var allowedComponents = new HashSet { "Input.Number" }; + + var result = SchemaValidator.Validate(schema, allowedComponents); + + result.IsValid.Should().BeTrue(string.Join("; ", result.Errors)); + result.Fields.Single().Component.Should().Be("InputNumber"); + } + + [Fact] + public void Validate_RejectsInvalidFieldKey() + { + const string schema = """ + { + "type": "object", + "properties": { + "123 bad": { + "type": "string", + "title": "坏字段", + "x-component": "Input" + } + } + } + """; + + var result = SchemaValidator.Validate(schema, AllowedComponents); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Contains("字段名") && e.Contains("123 bad")); + } + + [Fact] + public void Validate_ExtractsStaticDataSourceOptions() + { + const string schema = """ + { + "type": "object", + "properties": { + "gender": { + "type": "string", + "title": "性别", + "required": true, + "x-component": "Select", + "x-data-source": { + "type": "static", + "options": [ + { "label": "男", "value": "male" }, + { "label": "女", "value": "female", "disabled": true } + ] + } + } + } + } + """; + + var result = SchemaValidator.Validate(schema, AllowedComponents); + + result.IsValid.Should().BeTrue(string.Join("; ", result.Errors)); + var field = result.Fields.Single(); + field.Options.Should().HaveCount(2); + field.Options[0].Label.Should().Be("男"); + field.Options[0].Value.GetString().Should().Be("male"); + field.Options[1].Disabled.Should().BeTrue(); + } + + [Fact] + public void Validate_ConvertsLegacyTopLevelRequiredIntoFieldSummary() + { + const string schema = """ + { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "title": "姓名", + "x-component": "Input" + } + } + } + """; + + var result = SchemaValidator.Validate(schema, AllowedComponents); + + result.IsValid.Should().BeTrue(string.Join("; ", result.Errors)); + result.Fields.Single().Required.Should().BeTrue(); + } + + [Fact] + public void Validate_AcceptsValidConditionReaction() + { + const string schema = """ + { + "type": "object", + "properties": { + "leaveType": { + "type": "string", + "title": "请假类型", + "x-component": "Select", + "x-reactions": [ + { + "type": "condition", + "target": "reason", + "when": { "source": "leaveType", "operator": "eq", "value": "annual" }, + "action": "visible" + } + ] + }, + "reason": { + "type": "string", + "title": "原因", + "x-component": "Input" + } + } + } + """; + + var result = SchemaValidator.Validate(schema, AllowedComponents); + + result.IsValid.Should().BeTrue(string.Join("; ", result.Errors)); + } + + [Fact] + public void Validate_AcceptsValidDataReactionWithLiteralOperand() + { + const string schema = """ + { + "type": "object", + "properties": { + "price": { + "type": "number", + "title": "单价", + "x-component": "InputNumber", + "x-reactions": [ + { + "type": "data", + "target": "total", + "expression": { "left": "price", "operator": "multiply", "right": "8" } + } + ] + }, + "total": { + "type": "number", + "title": "总价", + "x-component": "InputNumber" + } + } + } + """; + + var result = SchemaValidator.Validate(schema, AllowedComponents); + + result.IsValid.Should().BeTrue(string.Join("; ", result.Errors)); + } + + [Fact] + public void Validate_RejectsReactionTargetFieldNotFound() + { + const string schema = """ + { + "type": "object", + "properties": { + "leaveType": { + "type": "string", + "title": "请假类型", + "x-component": "Select", + "x-reactions": [ + { + "type": "condition", + "target": "nonExist", + "when": { "source": "leaveType", "operator": "eq", "value": "annual" }, + "action": "visible" + } + ] + } + } + } + """; + + var result = SchemaValidator.Validate(schema, AllowedComponents); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Contains("target") && e.Contains("nonExist")); + } + + [Fact] + public void Validate_RejectsInvalidReactionOperator() + { + const string schema = """ + { + "type": "object", + "properties": { + "leaveType": { + "type": "string", + "title": "请假类型", + "x-component": "Select", + "x-reactions": [ + { + "type": "condition", + "target": "reason", + "when": { "source": "leaveType", "operator": "equals", "value": "annual" }, + "action": "visible" + } + ] + }, + "reason": { + "type": "string", + "title": "原因", + "x-component": "Input" + } + } + } + """; + + var result = SchemaValidator.Validate(schema, AllowedComponents); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Contains("when.operator") && e.Contains("equals")); + } + + [Fact] + public void Validate_AcceptsRelativeRemoteDataSource() + { + const string schema = """ + { + "type": "object", + "properties": { + "user": { + "type": "string", + "title": "用户", + "x-component": "Select", + "x-data-source": { + "type": "remote", + "url": "/forms", + "method": "GET", + "labelField": "name", + "valueField": "id" + } + } + } + } + """; + + var result = SchemaValidator.Validate(schema, AllowedComponents); + + result.IsValid.Should().BeTrue(string.Join("; ", result.Errors)); + } + + [Fact] + public void Validate_RejectsAbsoluteRemoteUrl() + { + foreach (var url in new[] { "http://evil.com/x", "https://evil.com/x", "//evil.com/x", "ftp://x/y" }) + { + var schema = "{\"type\":\"object\",\"properties\":{\"user\":{\"type\":\"string\",\"title\":\"用户\",\"x-component\":\"Select\",\"x-data-source\":{\"type\":\"remote\",\"url\":\"" + url + "\",\"method\":\"GET\"}}}}"; + + var result = SchemaValidator.Validate(schema, AllowedComponents); + + result.IsValid.Should().BeFalse($"url={url} should be rejected"); + result.Errors.Should().Contain(e => e.Contains("url") && e.Contains("相对路径")); + } + } + + [Fact] + public void Validate_RejectsRemoteDataSourceWithoutUrl() + { + const string schema = """ + { + "type": "object", + "properties": { + "user": { + "type": "string", + "title": "用户", + "x-component": "Select", + "x-data-source": { + "type": "remote" + } + } + } + } + """; + + var result = SchemaValidator.Validate(schema, AllowedComponents); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Contains("remote") && e.Contains("url")); + } + + [Fact] + public void Validate_FieldPermission_WithValidActions_Passes() + { + const string schema = """ + { + "type": "object", + "properties": { + "salary": { + "type": "number", "title": "薪资", "x-component": "InputNumber", + "x-field-permission": { + "直属主管审批": "visible", + "HR审批": "readonly", + "财务审批": "hidden", + "__default__": "hidden" + } + } + } + } + """; + + var result = SchemaValidator.Validate(schema, AllowedComponents); + + result.IsValid.Should().BeTrue(string.Join("; ", result.Errors)); + } + + [Fact] + public void Validate_FieldPermission_WithInvalidAction_Fails() + { + const string schema = """ + { + "type": "object", + "properties": { + "salary": { + "type": "number", "x-component": "InputNumber", + "x-field-permission": { "HR审批": "invisible" } + } + } + } + """; + + var result = SchemaValidator.Validate(schema, AllowedComponents); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Contains("字段权限") && e.Contains("invisible")); + } + + [Fact] + public void Validate_ExtractsFieldPermissionIntoFieldSummary() + { + const string schema = """ + { + "type": "object", + "properties": { + "salary": { + "type": "number", "x-component": "InputNumber", + "x-field-permission": { "HR审批": "hidden" } + } + } + } + """; + + var result = SchemaValidator.Validate(schema, AllowedComponents); + + result.IsValid.Should().BeTrue(); + var salary = result.Fields.Should().ContainSingle(f => f.Path == "salary").Subject; + salary.FieldPermission.Should().NotBeNull(); + salary.FieldPermission!["HR审批"].Should().Be("hidden"); + } +} diff --git a/tests/Workflow.Tests/Handlers/EdgeCommandHandlerTests.cs b/tests/Workflow.Tests/Handlers/EdgeCommandHandlerTests.cs index ec5c203..b1a1ef6 100644 --- a/tests/Workflow.Tests/Handlers/EdgeCommandHandlerTests.cs +++ b/tests/Workflow.Tests/Handlers/EdgeCommandHandlerTests.cs @@ -118,6 +118,53 @@ public class EdgeCommandHandlerTests await act.Should().ThrowAsync(); } + [Fact] + public async Task CreateEdge_WithNodeOutsideDefinition_ThrowsBusinessException() + { + // Arrange + await using var db = CreateDbContext(); + var (definition, sourceNode, _) = await SeedDefinitionWithNodes(db); + + var otherDefinition = new WorkflowDefinition + { + Id = Guid.NewGuid(), + Name = "Other Workflow", + Code = $"other-wf-{Guid.NewGuid():N}", + Status = DefinitionStatus.Draft, + Version = 1 + }; + var targetNodeInOtherDefinition = new WorkflowNode + { + Id = Guid.NewGuid(), + DefinitionId = otherDefinition.Id, + NodeType = NodeType.End, + Name = "Other End", + PositionX = 100, + PositionY = 100 + }; + db.WorkflowDefinitions.Add(otherDefinition); + db.WorkflowNodes.Add(targetNodeInOtherDefinition); + await db.SaveChangesAsync(); + + var handler = new CreateEdgeCommandHandler(db); + var command = new CreateEdgeCommand( + DefinitionId: definition.Id, + SourceNodeId: sourceNode.Id, + TargetNodeId: targetNodeInOtherDefinition.Id, + EdgeType: EdgeType.Normal, + Label: null, + Condition: null, + Order: 0 + ); + + // Act + var act = () => handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*same workflow definition*"); + } + #endregion #region UpdateEdge diff --git a/tests/Workflow.Tests/Handlers/NodeCommandHandlerTests.cs b/tests/Workflow.Tests/Handlers/NodeCommandHandlerTests.cs index bede064..ba7220c 100644 --- a/tests/Workflow.Tests/Handlers/NodeCommandHandlerTests.cs +++ b/tests/Workflow.Tests/Handlers/NodeCommandHandlerTests.cs @@ -34,6 +34,22 @@ public class NodeCommandHandlerTests return definition; } + private static async Task SeedFormDefinition(WorkflowDbContext db) + { + var form = new FormDefinition + { + Id = Guid.NewGuid(), + Name = "Approval Form", + Code = $"approval-form-{Guid.NewGuid():N}", + Status = FormStatus.Published, + Version = 1, + SchemaJson = """{"type":"object","properties":{}}""" + }; + db.FormDefinitions.Add(form); + await db.SaveChangesAsync(); + return form; + } + #region CreateNode [Fact] @@ -50,7 +66,8 @@ public class NodeCommandHandlerTests Name: "Start Node", Config: null, PositionX: 100, - PositionY: 200 + PositionY: 200, + FormDefinitionId: null ); // Act @@ -70,6 +87,37 @@ public class NodeCommandHandlerTests entity!.DefinitionId.Should().Be(definition.Id); } + [Fact] + public async Task CreateNode_WithFormDefinition_SavesFormRelation() + { + // Arrange + await using var db = CreateDbContext(); + var definition = await SeedDefinition(db); + var form = await SeedFormDefinition(db); + + var handler = new CreateNodeCommandHandler(db); + var command = new CreateNodeCommand( + DefinitionId: definition.Id, + NodeType: NodeType.Approval, + Name: "Manager Approval", + Config: null, + PositionX: 100, + PositionY: 200, + FormDefinitionId: form.Id + ); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.FormDefinitionId.Should().Be(form.Id); + result.FormName.Should().Be("Approval Form"); + + var entity = await db.WorkflowNodes.FindAsync(result.Id); + entity.Should().NotBeNull(); + entity!.FormDefinitionId.Should().Be(form.Id); + } + [Fact] public async Task CreateNode_WithMissingDefinition_ThrowsNotFoundException() { @@ -82,7 +130,8 @@ public class NodeCommandHandlerTests Name: "Orphan Node", Config: null, PositionX: 0, - PositionY: 0 + PositionY: 0, + FormDefinitionId: null ); // Act @@ -92,6 +141,73 @@ public class NodeCommandHandlerTests await act.Should().ThrowAsync(); } + /// + /// 不变量:仅 Approval/Cc 节点可绑定表单。非审批/抄送节点(如 Condition) + /// 携带 FormDefinitionId 时必须被拒绝,与 UI 契约(NodePropertyDrawer.vue:199)一致。 + /// + [Theory] + [InlineData(NodeType.Start)] + [InlineData(NodeType.End)] + [InlineData(NodeType.Condition)] + [InlineData(NodeType.Parallel)] + [InlineData(NodeType.SubProcess)] + public async Task CreateNode_NonApprovalOrCcWithForm_ThrowsBusinessException(NodeType nodeType) + { + // Arrange + await using var db = CreateDbContext(); + var definition = await SeedDefinition(db); + var form = await SeedFormDefinition(db); + + var handler = new CreateNodeCommandHandler(db); + var command = new CreateNodeCommand( + DefinitionId: definition.Id, + NodeType: nodeType, + Name: $"{nodeType} Node", + Config: null, + PositionX: 0, + PositionY: 0, + FormDefinitionId: form.Id + ); + + // Act + var act = () => handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*表单*审批*抄送*"); + + // 不得创建任何节点 + var nodes = await db.WorkflowNodes.ToListAsync(); + nodes.Should().BeEmpty(); + } + + /// + /// 回归保护:Approval 节点仍可正常绑定表单。 + /// + [Theory] + [InlineData(NodeType.Approval)] + [InlineData(NodeType.Cc)] + public async Task CreateNode_ApprovalOrCcWithForm_Succeeds(NodeType nodeType) + { + await using var db = CreateDbContext(); + var definition = await SeedDefinition(db); + var form = await SeedFormDefinition(db); + + var handler = new CreateNodeCommandHandler(db); + var command = new CreateNodeCommand( + DefinitionId: definition.Id, + NodeType: nodeType, + Name: $"{nodeType} Node", + Config: null, + PositionX: 0, + PositionY: 0, + FormDefinitionId: form.Id + ); + + var result = await handler.Handle(command, CancellationToken.None); + result.FormDefinitionId.Should().Be(form.Id); + } + #endregion #region UpdateNode @@ -121,7 +237,8 @@ public class NodeCommandHandlerTests Name: "Updated Name", Config: "{\"timeout\": 300}", PositionX: 200, - PositionY: 300 + PositionY: 300, + FormDefinitionId: null ); // Act @@ -141,6 +258,48 @@ public class NodeCommandHandlerTests entity.PositionX.Should().Be(200); } + [Fact] + public async Task UpdateNode_WithFormDefinition_UpdatesFormRelation() + { + // Arrange + await using var db = CreateDbContext(); + var definition = await SeedDefinition(db); + var form = await SeedFormDefinition(db); + + var nodeId = Guid.NewGuid(); + db.WorkflowNodes.Add(new WorkflowNode + { + Id = nodeId, + DefinitionId = definition.Id, + NodeType = NodeType.Approval, + Name = "Approval", + PositionX = 50, + PositionY = 50 + }); + await db.SaveChangesAsync(); + + var handler = new UpdateNodeCommandHandler(db); + var command = new UpdateNodeCommand( + NodeId: nodeId, + Name: "Approval", + Config: null, + PositionX: 50, + PositionY: 50, + FormDefinitionId: form.Id + ); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.FormDefinitionId.Should().Be(form.Id); + result.FormName.Should().Be("Approval Form"); + + var entity = await db.WorkflowNodes.FindAsync(nodeId); + entity.Should().NotBeNull(); + entity!.FormDefinitionId.Should().Be(form.Id); + } + [Fact] public async Task UpdateNode_WithMissingNode_ThrowsNotFoundException() { @@ -152,7 +311,8 @@ public class NodeCommandHandlerTests Name: "Ghost", Config: null, PositionX: 0, - PositionY: 0 + PositionY: 0, + FormDefinitionId: null ); // Act @@ -162,6 +322,53 @@ public class NodeCommandHandlerTests await act.Should().ThrowAsync(); } + /// + /// 不变量:已存在的 Condition 节点(NodeType 不可变)在更新时绑定表单必须被拒绝。 + /// 因 UpdateNodeCommand 不含 NodeType,校验需基于实体当前的 NodeType。 + /// + [Fact] + public async Task UpdateNode_ExistingConditionNodeWithForm_ThrowsBusinessException() + { + // Arrange + await using var db = CreateDbContext(); + var definition = await SeedDefinition(db); + var form = await SeedFormDefinition(db); + + var nodeId = Guid.NewGuid(); + db.WorkflowNodes.Add(new WorkflowNode + { + Id = nodeId, + DefinitionId = definition.Id, + NodeType = NodeType.Condition, + Name = "Branch", + PositionX = 0, + PositionY = 0 + }); + await db.SaveChangesAsync(); + + var handler = new UpdateNodeCommandHandler(db); + var command = new UpdateNodeCommand( + NodeId: nodeId, + Name: "Branch", + Config: null, + PositionX: 0, + PositionY: 0, + FormDefinitionId: form.Id + ); + + // Act + var act = () => handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*表单*审批*抄送*"); + + // 原有 FormDefinitionId 不得被篡改 + var entity = await db.WorkflowNodes.FindAsync(nodeId); + entity.Should().NotBeNull(); + entity!.FormDefinitionId.Should().BeNull(); + } + #endregion #region DeleteNode @@ -196,6 +403,52 @@ public class NodeCommandHandlerTests nodes.Should().BeEmpty(); } + [Fact] + public async Task DeleteNode_RemovesConnectedEdges() + { + // Arrange + await using var db = CreateDbContext(); + var definition = await SeedDefinition(db); + + var sourceNode = new WorkflowNode + { + Id = Guid.NewGuid(), + DefinitionId = definition.Id, + NodeType = NodeType.Approval, + Name = "Source", + PositionX = 0, + PositionY = 0 + }; + var targetNode = new WorkflowNode + { + Id = Guid.NewGuid(), + DefinitionId = definition.Id, + NodeType = NodeType.End, + Name = "Target", + PositionX = 200, + PositionY = 0 + }; + db.WorkflowNodes.AddRange(sourceNode, targetNode); + db.WorkflowEdges.Add(new WorkflowEdge + { + Id = Guid.NewGuid(), + DefinitionId = definition.Id, + SourceNodeId = sourceNode.Id, + TargetNodeId = targetNode.Id, + EdgeType = EdgeType.Normal + }); + await db.SaveChangesAsync(); + + var handler = new DeleteNodeCommandHandler(db); + + // Act + await handler.Handle(new DeleteNodeCommand(sourceNode.Id), CancellationToken.None); + + // Assert + var edges = await db.WorkflowEdges.ToListAsync(); + edges.Should().BeEmpty(); + } + [Fact] public async Task DeleteNode_WithMissingNode_ThrowsNotFoundException() { diff --git a/tests/Workflow.Tests/Handlers/WorkflowInstanceHandlerTests.cs b/tests/Workflow.Tests/Handlers/WorkflowInstanceHandlerTests.cs index 9c8694d..8c4df7c 100644 --- a/tests/Workflow.Tests/Handlers/WorkflowInstanceHandlerTests.cs +++ b/tests/Workflow.Tests/Handlers/WorkflowInstanceHandlerTests.cs @@ -188,6 +188,330 @@ public class WorkflowInstanceHandlerTests .WithMessage("*disabled*"); } + [Fact] + public async Task StartInstance_WithFormDataJson_MergesFormDataIntoVariables() + { + // Arrange + var formId = Guid.NewGuid(); + await using var db = await SeedPublishedDefinitionAsync("form-vars-test"); + var definition = await db.WorkflowDefinitions.FirstAsync(d => d.Code == "form-vars-test"); + definition.FormDefinitionId = formId; + db.FormDefinitions.Add(new FormDefinition + { + Id = formId, + Name = "Expense Form", + Code = "expense-form", + Status = FormStatus.Published, + Version = 1, + SchemaJson = """{"type":"object","properties":{}}""" + }); + await db.SaveChangesAsync(); + + var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new())); + var command = new StartWorkflowInstanceCommand( + DefinitionCode: "form-vars-test", + Title: "Expense", + Variables: null, + FormDataJson: """{"amount":6500,"category":"travel"}""" + ); + + // Act + var instanceId = await handler.Handle(command, CancellationToken.None); + + // Assert + var instance = await db.WorkflowInstances.FindAsync(instanceId); + instance.Should().NotBeNull(); + instance!.Variables.Should().NotBeNull(); + instance.Variables.Should().Contain("\"amount\":6500"); + instance.Variables.Should().Contain("\"category\":\"travel\""); + + var formData = await db.FormData.SingleAsync(f => f.InstanceId == instanceId); + formData.FormDefinitionId.Should().Be(formId); + formData.DataJson.Should().Be("""{"amount":6500,"category":"travel"}"""); + } + + /// + /// 边界:流程定义绑定的启动表单已被软删除时启动实例,必须给出准确错误 + /// (表单已被删除),而非误导性的「表单定义 ... 不存在」。 + /// 且不得创建实例、不得保存运行时记录(Token 等)。 + /// + [Fact] + public async Task StartInstance_WithSoftDeletedStartForm_ThrowsAccurateErrorAndPersistsNothing() + { + // Arrange + var formId = Guid.NewGuid(); + await using var db = await SeedPublishedDefinitionAsync("deleted-start-form-test"); + var definition = await db.WorkflowDefinitions.FirstAsync(d => d.Code == "deleted-start-form-test"); + definition.FormDefinitionId = formId; + db.FormDefinitions.Add(new FormDefinition + { + Id = formId, + Name = "Deleted Start Form", + Code = "deleted-start-form", + Status = FormStatus.Published, + Version = 1, + SchemaJson = """{"type":"object","properties":{}}""", + IsDeleted = true, + }); + await db.SaveChangesAsync(); + + var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new())); + var command = new StartWorkflowInstanceCommand( + DefinitionCode: "deleted-start-form-test", + Title: "X", + Variables: null, + FormDataJson: """{"amount":100}""" + ); + + // Act + var act = () => handler.Handle(command, CancellationToken.None); + + // Assert:准确错误,而非误导性的「不存在」 + await act.Should().ThrowAsync() + .WithMessage("*表单*删除*"); + + // 不留任何运行时记录 + (await db.WorkflowInstances.CountAsync()).Should().Be(0); + (await db.FormData.CountAsync()).Should().Be(0); + (await db.WorkflowTokens.CountAsync()).Should().Be(0); + } + + /// + /// 边界:流程定义绑定的启动表单确实不存在(既未存在也未删除),必须给出准确错误(不存在)。 + /// 与软删除场景区分。 + /// + [Fact] + public async Task StartInstance_WithTrulyNonExistentStartForm_ThrowsAccurateError() + { + var formId = Guid.NewGuid(); + await using var db = await SeedPublishedDefinitionAsync("nonexistent-start-form-test"); + var definition = await db.WorkflowDefinitions.FirstAsync(d => d.Code == "nonexistent-start-form-test"); + definition.FormDefinitionId = formId; + await db.SaveChangesAsync(); + + var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new())); + var command = new StartWorkflowInstanceCommand( + DefinitionCode: "nonexistent-start-form-test", + Title: "X", + Variables: null, + FormDataJson: """{"amount":100}""" + ); + + var act = () => handler.Handle(command, CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*不存在*"); + } + + /// + /// 边界:流程定义绑定的启动表单状态为 Disabled 时启动实例,必须严格阻断。 + /// 产品决策:Disabled = 严格阻断(新启动一律拒绝)。且不得创建任何运行时记录。 + /// + [Fact] + public async Task StartInstance_WithDisabledStartForm_ThrowsBusinessExceptionAndPersistsNothing() + { + var formId = Guid.NewGuid(); + await using var db = await SeedPublishedDefinitionAsync("disabled-start-form-test"); + var definition = await db.WorkflowDefinitions.FirstAsync(d => d.Code == "disabled-start-form-test"); + definition.FormDefinitionId = formId; + db.FormDefinitions.Add(new FormDefinition + { + Id = formId, + Name = "Disabled Start Form", + Code = "disabled-start-form", + Status = FormStatus.Disabled, + Version = 1, + SchemaJson = """{"type":"object","properties":{}}""", + }); + await db.SaveChangesAsync(); + + var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new())); + var command = new StartWorkflowInstanceCommand( + DefinitionCode: "disabled-start-form-test", + Title: "X", + Variables: null, + FormDataJson: """{"amount":100}""" + ); + + var act = () => handler.Handle(command, CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*表单*停用*"); + + (await db.WorkflowInstances.CountAsync()).Should().Be(0); + (await db.FormData.CountAsync()).Should().Be(0); + (await db.WorkflowTokens.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task StartInstance_WithFormDataAndVariables_MergesBothWithExplicitVariablesTakingPrecedence() + { + // Arrange + var formId = Guid.NewGuid(); + await using var db = await SeedPublishedDefinitionAsync("merge-vars-test"); + var definition = await db.WorkflowDefinitions.FirstAsync(d => d.Code == "merge-vars-test"); + definition.FormDefinitionId = formId; + db.FormDefinitions.Add(new FormDefinition + { + Id = formId, + Name = "Leave Form", + Code = "leave-form", + Status = FormStatus.Published, + Version = 1, + SchemaJson = """{"type":"object","properties":{}}""" + }); + await db.SaveChangesAsync(); + + var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new())); + var command = new StartWorkflowInstanceCommand( + DefinitionCode: "merge-vars-test", + Title: "Leave", + Variables: """{"days":5,"urgent":true}""", + FormDataJson: """{"days":2,"leaveType":"annual"}""" + ); + + // Act + var instanceId = await handler.Handle(command, CancellationToken.None); + + // Assert + var instance = await db.WorkflowInstances.FindAsync(instanceId); + instance.Should().NotBeNull(); + instance!.Variables.Should().NotBeNull(); + instance.Variables.Should().Contain("\"days\":5"); + instance.Variables.Should().Contain("\"urgent\":true"); + instance.Variables.Should().Contain("\"leaveType\":\"annual\""); + } + + [Fact] + public async Task StartWorkflowInstance_WithInvalidFormData_ThrowsBusinessExceptionAndDoesNotPersistRuntimeRecords() + { + // Arrange + var formId = Guid.NewGuid(); + await using var db = await SeedPublishedDefinitionAsync("invalid-start-form-test"); + var definition = await db.WorkflowDefinitions.FirstAsync(d => d.Code == "invalid-start-form-test"); + definition.FormDefinitionId = formId; + db.FormDefinitions.Add(new FormDefinition + { + Id = formId, + Name = "启动表单", + Code = "start-form", + Status = FormStatus.Published, + Version = 1, + SchemaJson = """ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "姓名", + "required": true, + "x-component": "Input" + } + } + } + """ + }); + await db.SaveChangesAsync(); + + var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new())); + var command = new StartWorkflowInstanceCommand( + DefinitionCode: "invalid-start-form-test", + Title: "Invalid Start Form", + Variables: null, + FormDataJson: """{"name":""}""" + ); + + // Act + var act = () => handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*表单数据校验失败*姓名*"); + + db.FormData.Should().BeEmpty(); + db.WorkflowInstances.Should().BeEmpty(); + db.WorkflowTokens.Should().BeEmpty(); + } + + [Fact] + public async Task StartWorkflowInstance_WithNestedFormData_AcceptsValidAndRejectsMissingNestedRequired() + { + // 嵌套表单:FormGrid 容器下的 startDate/endDate 字段 Path 形如 "dateRange.startDate", + // 提交数据是嵌套对象 {dateRange:{startDate:...}}。FormDataValidator 必须按点分路径取值, + // 否则带容器的表单无法通过发起流程的校验(回归 bug:扁平 TryGetValue 找不到嵌套值)。 + var formId = Guid.NewGuid(); + await using var db = await SeedPublishedDefinitionAsync("nested-start-form-test"); + var definition = await db.WorkflowDefinitions.FirstAsync(d => d.Code == "nested-start-form-test"); + definition.FormDefinitionId = formId; + db.FormDefinitions.Add(new FormDefinition + { + Id = formId, + Name = "嵌套启动表单", + Code = "nested-start-form", + Status = FormStatus.Published, + Version = 1, + SchemaJson = """ + { + "type": "object", + "properties": { + "leaveType": { + "type": "string", + "title": "请假类型", + "required": true, + "x-component": "Select" + }, + "dateRange": { + "type": "void", + "title": "日期范围", + "x-component": "FormGrid", + "properties": { + "startDate": { + "type": "string", + "title": "开始日期", + "required": true, + "x-component": "DatePicker" + }, + "endDate": { + "type": "string", + "title": "结束日期", + "required": true, + "x-component": "DatePicker" + } + } + } + } + } + """ + }); + await db.SaveChangesAsync(); + + var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new())); + + // 合法嵌套数据:必须成功发起(这是回归 bug 修复的核心断言) + var instanceId = await handler.Handle( + new StartWorkflowInstanceCommand( + "nested-start-form-test", + "嵌套合法发起", + null, + """{"leaveType":"annual","dateRange":{"startDate":"2026-06-15","endDate":"2026-06-16"}}"""), + CancellationToken.None); + + instanceId.Should().NotBeEmpty(); + db.FormData.Should().ContainSingle(f => f.InstanceId == instanceId); + + // 嵌套必填缺失:必须被拒 + var act = () => handler.Handle( + new StartWorkflowInstanceCommand( + "nested-start-form-test", + "嵌套缺失", + null, + """{"leaveType":"annual","dateRange":{}}"""), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*开始日期*"); + } + #endregion #region WithdrawWorkflowInstance diff --git a/tests/Workflow.Tests/Handlers/WorkflowTaskHandlerTests.cs b/tests/Workflow.Tests/Handlers/WorkflowTaskHandlerTests.cs index 2ffe29a..005e9d5 100644 --- a/tests/Workflow.Tests/Handlers/WorkflowTaskHandlerTests.cs +++ b/tests/Workflow.Tests/Handlers/WorkflowTaskHandlerTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using Microsoft.EntityFrameworkCore; +using System.Text.Json.Nodes; using Workflow.Application.Engine; using Workflow.Application.Features.WorkflowTasks.Commands; using Workflow.Application.Features.WorkflowTasks.Queries; @@ -15,6 +16,20 @@ namespace Workflow.Tests.Handlers; public class WorkflowTaskHandlerTests { + private const string ApprovalFormSchema = """ + { + "type": "object", + "required": ["approvalScore"], + "properties": { + "approvalScore": { + "type": "number", + "title": "审批分", + "x-component": "InputNumber" + } + } + } + """; + private static WorkflowDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() @@ -98,6 +113,7 @@ public class WorkflowTaskHandlerTests var updated = await db.WorkflowTasks.FindAsync(taskId); updated.Should().NotBeNull(); updated!.Status.Should().Be(TaskStatus.Approved); + updated.Result.Should().Be("\"approved\""); } [Fact] @@ -163,6 +179,100 @@ public class WorkflowTaskHandlerTests #endregion + #region TaskDetail + + [Fact] + public async Task GetTaskById_ReturnsNodeFormAndInstanceFormData() + { + // Arrange + await using var db = CreateDbContext(); + var definitionId = Guid.NewGuid(); + var startFormId = Guid.NewGuid(); + var nodeFormId = Guid.NewGuid(); + var instanceId = Guid.NewGuid(); + var tokenId = Guid.NewGuid(); + var nodeId = Guid.NewGuid(); + var taskId = Guid.NewGuid(); + + db.FormDefinitions.AddRange( + new FormDefinition + { + Id = startFormId, + Name = "发起表单", + Code = "start-form", + SchemaJson = ApprovalFormSchema + }, + new FormDefinition + { + Id = nodeFormId, + Name = "审批表单", + Code = "approval-form", + SchemaJson = ApprovalFormSchema + }); + db.WorkflowDefinitions.Add(new WorkflowDefinition + { + Id = definitionId, + Name = "Test", + Code = "test", + FormDefinitionId = startFormId, + Status = DefinitionStatus.Published, + IsEnabled = true + }); + db.WorkflowInstances.Add(new WorkflowInstance + { + Id = instanceId, + DefinitionId = definitionId, + Status = InstanceStatus.Running + }); + db.FormData.Add(new FormData + { + Id = Guid.NewGuid(), + FormDefinitionId = startFormId, + InstanceId = instanceId, + DataJson = """{"amount":6500}""" + }); + db.WorkflowTokens.Add(new WorkflowToken + { + Id = tokenId, + InstanceId = instanceId, + NodeId = nodeId, + Status = TokenStatus.Active + }); + db.WorkflowNodes.Add(new WorkflowNode + { + Id = nodeId, + DefinitionId = definitionId, + NodeType = NodeType.Approval, + Name = "主管审批", + FormDefinitionId = nodeFormId + }); + db.WorkflowTasks.Add(new WorkflowTask + { + Id = taskId, + InstanceId = instanceId, + TokenId = tokenId, + NodeId = nodeId, + Status = TaskStatus.Pending, + Title = "报销审批" + }); + await db.SaveChangesAsync(); + + var handler = new GetTaskByIdQueryHandler(db); + + // Act + var result = await handler.Handle(new GetTaskByIdQuery(taskId), CancellationToken.None); + + // Assert + result.NodeId.Should().Be(nodeId); + result.NodeName.Should().Be("主管审批"); + result.FormDefinitionId.Should().Be(nodeFormId); + result.FormName.Should().Be("审批表单"); + result.FormSchemaJson.Should().Be(ApprovalFormSchema); + result.InstanceFormDataJson.Should().Be("""{"amount":6500}"""); + } + + #endregion + #region RejectTask [Fact] @@ -193,6 +303,313 @@ public class WorkflowTaskHandlerTests #endregion + [Fact] + public async Task ApproveTask_WithNodeFormData_SavesFormDataAndMergesInstanceVariables() + { + // Arrange + var taskId = Guid.NewGuid(); + var assigneeId = Guid.NewGuid(); + var formId = Guid.NewGuid(); + var (db, instanceId, _, nodeId) = await SeedTaskWithWorkflowAsync(taskId, assigneeId); + + db.FormDefinitions.Add(new FormDefinition + { + Id = formId, + Name = "审批表单", + Code = "approval-form", + SchemaJson = ApprovalFormSchema + }); + + var node = await db.WorkflowNodes.FindAsync(nodeId); + node!.FormDefinitionId = formId; + + var instance = await db.WorkflowInstances.FindAsync(instanceId); + instance!.Variables = """{"amount":6500}"""; + await db.SaveChangesAsync(); + + var handler = new ApproveTaskCommandHandler(db, new ProcessEngine(db, null!, new())); + var command = new ApproveTaskCommand( + TaskId: taskId, + UserId: assigneeId, + Comment: "Looks good", + FormDataJson: """{"approvalScore":98}""" + ); + + // Act + await handler.Handle(command, CancellationToken.None); + + // Assert + var savedData = await db.FormData.SingleAsync(f => f.InstanceId == instanceId && f.FormDefinitionId == formId); + savedData.DataJson.Should().Be("""{"approvalScore":98}"""); + + var updatedInstance = await db.WorkflowInstances.FindAsync(instanceId); + var variables = JsonNode.Parse(updatedInstance!.Variables!)!.AsObject(); + variables["amount"]!.GetValue().Should().Be(6500); + variables["approvalScore"]!.GetValue().Should().Be(98); + } + + /// + /// 边界:节点绑定的表单已被软删除时,审批提交表单必须给出准确错误信息, + /// 而不是误导性的「表单定义 ... 不存在」(表单实际存在,只是被删除)。 + /// 此时不可继续提交表单数据。 + /// + [Fact] + public async Task ApproveTask_WithSoftDeletedFormDefinition_ThrowsAccurateError() + { + // Arrange + var taskId = Guid.NewGuid(); + var assigneeId = Guid.NewGuid(); + var formId = Guid.NewGuid(); + var (db, _, _, nodeId) = await SeedTaskWithWorkflowAsync(taskId, assigneeId); + + // 用 IgnoreQueryFilters 绕过全局软删除过滤器插入一张已删除的表单 + db.FormDefinitions.Add(new FormDefinition + { + Id = formId, + Name = "已被删除的审批表单", + Code = "deleted-approval-form", + SchemaJson = ApprovalFormSchema, + IsDeleted = true, + }); + await db.SaveChangesAsync(); + + var node = await db.WorkflowNodes.FindAsync(nodeId); + node!.FormDefinitionId = formId; + await db.SaveChangesAsync(); + + var handler = new ApproveTaskCommandHandler(db, new ProcessEngine(db, null!, new())); + var command = new ApproveTaskCommand( + TaskId: taskId, + UserId: assigneeId, + Comment: "approve", + FormDataJson: """{"approvalScore":98}""" + ); + + // Act + var act = () => handler.Handle(command, CancellationToken.None); + + // Assert:必须是明确、准确的错误,而非误导性的「不存在」 + await act.Should().ThrowAsync() + .WithMessage("*表单*删除*"); + + // 且不得写入任何表单数据 + var savedData = await db.FormData.ToListAsync(); + savedData.Should().BeEmpty(); + } + + /// + /// 边界:节点未绑定任何表单,却提交了表单数据 —— 当前行为是阻断并提示, + /// 此测试锁定该不变量,避免误把数据写到未关联的实体。 + /// + [Fact] + public async Task ApproveTask_WithFormDataButNodeHasNoForm_ThrowsBusinessException() + { + var taskId = Guid.NewGuid(); + var assigneeId = Guid.NewGuid(); + var (db, _, _, _) = await SeedTaskWithWorkflowAsync(taskId, assigneeId); + + var handler = new ApproveTaskCommandHandler(db, new ProcessEngine(db, null!, new())); + var command = new ApproveTaskCommand( + TaskId: taskId, + UserId: assigneeId, + Comment: "approve", + FormDataJson: """{"approvalScore":98}""" + ); + + var act = () => handler.Handle(command, CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*未绑定表单*"); + + var savedData = await db.FormData.ToListAsync(); + savedData.Should().BeEmpty(); + } + + /// + /// 边界:节点绑定的表单状态为 Disabled 时,审批提交表单必须严格阻断。 + /// 产品决策:Disabled = 严格阻断(审批提交一律拒绝)。且不得写入任何表单数据。 + /// + [Fact] + public async Task ApproveTask_WithDisabledForm_ThrowsBusinessException() + { + var taskId = Guid.NewGuid(); + var assigneeId = Guid.NewGuid(); + var formId = Guid.NewGuid(); + var (db, _, _, nodeId) = await SeedTaskWithWorkflowAsync(taskId, assigneeId); + + db.FormDefinitions.Add(new FormDefinition + { + Id = formId, + Name = "停用的审批表单", + Code = "disabled-approval-form", + SchemaJson = ApprovalFormSchema, + Status = FormStatus.Disabled, + }); + await db.SaveChangesAsync(); + + var node = await db.WorkflowNodes.FindAsync(nodeId); + node!.FormDefinitionId = formId; + await db.SaveChangesAsync(); + + var handler = new ApproveTaskCommandHandler(db, new ProcessEngine(db, null!, new())); + var command = new ApproveTaskCommand( + TaskId: taskId, + UserId: assigneeId, + Comment: "approve", + FormDataJson: """{"approvalScore":98}""" + ); + + var act = () => handler.Handle(command, CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*表单*停用*"); + + var savedData = await db.FormData.ToListAsync(); + savedData.Should().BeEmpty(); + } + + #region MarkCcTaskRead + + /// + /// 正常路径:Cc 任务可被其 assignee 标记为已读,状态变为 Read,CompletedAt 被记录。 + /// 不涉及 token 路由(Cc 任务为知会性质)。 + /// + [Fact] + public async Task MarkCcTaskRead_PendingCcTaskByAssignee_MarksRead() + { + await using var db = CreateDbContext(); + var taskId = Guid.NewGuid(); + var assigneeId = Guid.NewGuid(); + + db.WorkflowTasks.Add(new WorkflowTask + { + Id = taskId, + InstanceId = Guid.NewGuid(), + TokenId = Guid.NewGuid(), + AssigneeId = assigneeId, + Type = TaskType.Cc, + Status = TaskStatus.Pending, + }); + await db.SaveChangesAsync(); + + var handler = new MarkCcTaskReadCommandHandler(db); + await handler.Handle(new MarkCcTaskReadCommand(taskId, assigneeId), CancellationToken.None); + + var task = await db.WorkflowTasks.FindAsync(taskId); + task.Should().NotBeNull(); + task!.Status.Should().Be(TaskStatus.Read); + task.CompletedAt.Should().NotBeNull(); + } + + /// + /// 边界:非 Cc 类型(审批任务)不可被标记已读——标记已读仅适用于知会类任务。 + /// + [Theory] + [InlineData(TaskType.Approval)] + public async Task MarkCcTaskRead_NonCcTask_ThrowsBusinessException(TaskType type) + { + await using var db = CreateDbContext(); + var taskId = Guid.NewGuid(); + var assigneeId = Guid.NewGuid(); + + db.WorkflowTasks.Add(new WorkflowTask + { + Id = taskId, + InstanceId = Guid.NewGuid(), + TokenId = Guid.NewGuid(), + AssigneeId = assigneeId, + Type = type, + Status = TaskStatus.Pending, + }); + await db.SaveChangesAsync(); + + var handler = new MarkCcTaskReadCommandHandler(db); + var act = () => handler.Handle(new MarkCcTaskReadCommand(taskId, assigneeId), CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*Cc*"); + + var task = await db.WorkflowTasks.FindAsync(taskId); + task!.Status.Should().Be(TaskStatus.Pending); + } + + /// + /// 边界:已读/已完结的 Cc 任务不可重复标记(幂等性/防重复)。 + /// + [Theory] + [InlineData(TaskStatus.Read)] + public async Task MarkCcTaskRead_AlreadyRead_ThrowsBusinessException(TaskStatus status) + { + await using var db = CreateDbContext(); + var taskId = Guid.NewGuid(); + var assigneeId = Guid.NewGuid(); + + db.WorkflowTasks.Add(new WorkflowTask + { + Id = taskId, + InstanceId = Guid.NewGuid(), + TokenId = Guid.NewGuid(), + AssigneeId = assigneeId, + Type = TaskType.Cc, + Status = status, + }); + await db.SaveChangesAsync(); + + var handler = new MarkCcTaskReadCommandHandler(db); + var act = () => handler.Handle(new MarkCcTaskReadCommand(taskId, assigneeId), CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*已读*"); + + var task = await db.WorkflowTasks.FindAsync(taskId); + task!.Status.Should().Be(status); + } + + /// + /// 边界:仅 assignee 可标记自己的 Cc 任务已读,非 assignee 被拒绝(防越权)。 + /// + [Fact] + public async Task MarkCcTaskRead_ByNonAssignee_ThrowsUnauthorizedException() + { + await using var db = CreateDbContext(); + var taskId = Guid.NewGuid(); + var assigneeId = Guid.NewGuid(); + var otherUser = Guid.NewGuid(); + + db.WorkflowTasks.Add(new WorkflowTask + { + Id = taskId, + InstanceId = Guid.NewGuid(), + TokenId = Guid.NewGuid(), + AssigneeId = assigneeId, + Type = TaskType.Cc, + Status = TaskStatus.Pending, + }); + await db.SaveChangesAsync(); + + var handler = new MarkCcTaskReadCommandHandler(db); + var act = () => handler.Handle(new MarkCcTaskReadCommand(taskId, otherUser), CancellationToken.None); + + await act.Should().ThrowAsync(); + + var task = await db.WorkflowTasks.FindAsync(taskId); + task!.Status.Should().Be(TaskStatus.Pending); + } + + /// + /// 边界:不存在的任务 ID 抛 NotFoundException。 + /// + [Fact] + public async Task MarkCcTaskRead_NonExistentTask_ThrowsNotFoundException() + { + await using var db = CreateDbContext(); + var handler = new MarkCcTaskReadCommandHandler(db); + var act = () => handler.Handle(new MarkCcTaskReadCommand(Guid.NewGuid(), Guid.NewGuid()), CancellationToken.None); + await act.Should().ThrowAsync(); + } + + #endregion + #region TransferTask [Fact] @@ -272,6 +689,86 @@ public class WorkflowTaskHandlerTests original.CompletedAt.Should().NotBeNull(); } + /// + /// 边界:非 Pending 状态(已审批)的任务不可被转办,否则会重复推进流程。 + /// 当前缺失状态校验——必须补上。 + /// + [Theory] + [InlineData(TaskStatus.Approved)] + [InlineData(TaskStatus.Rejected)] + [InlineData(TaskStatus.Transferred)] + [InlineData(TaskStatus.Delegated)] + public async Task TransferTask_NonPendingTask_ThrowsBusinessException(TaskStatus status) + { + await using var db = CreateDbContext(); + var taskId = Guid.NewGuid(); + var fromUserId = Guid.NewGuid(); + var toUserId = Guid.NewGuid(); + + var task = new WorkflowTask + { + Id = taskId, + InstanceId = Guid.NewGuid(), + TokenId = Guid.NewGuid(), + AssigneeId = fromUserId, + Status = status + }; + db.WorkflowTasks.Add(task); + await db.SaveChangesAsync(); + + var handler = new TransferTaskCommandHandler(db); + var command = new TransferTaskCommand(taskId, fromUserId, toUserId); + + var act = () => handler.Handle(command, CancellationToken.None); + + await act.Should().ThrowAsync(); + + // 原任务状态不变,未创建新任务 + var original = await db.WorkflowTasks.FindAsync(taskId); + original!.Status.Should().Be(status); + var allTasks = await db.WorkflowTasks.ToListAsync(); + allTasks.Should().HaveCount(1); + } + + /// + /// 边界:转办必须校验调用者是当前 assignee(与 Approve/Reject 一致),否则任何人可转办他人任务。 + /// 当前缺失授权校验——必须补上。 + /// + [Fact] + public async Task TransferTask_ByNonAssignee_ThrowsUnauthorizedException() + { + await using var db = CreateDbContext(); + var taskId = Guid.NewGuid(); + var fromUserId = Guid.NewGuid(); + var otherUser = Guid.NewGuid(); + var toUserId = Guid.NewGuid(); + + var task = new WorkflowTask + { + Id = taskId, + InstanceId = Guid.NewGuid(), + TokenId = Guid.NewGuid(), + AssigneeId = fromUserId, + Status = TaskStatus.Pending + }; + db.WorkflowTasks.Add(task); + await db.SaveChangesAsync(); + + var handler = new TransferTaskCommandHandler(db); + // otherUser 冒充转办 fromUserId 的任务 + var command = new TransferTaskCommand(taskId, otherUser, toUserId); + + var act = () => handler.Handle(command, CancellationToken.None); + + await act.Should().ThrowAsync(); + + // 原任务状态不变,未创建新任务 + var original = await db.WorkflowTasks.FindAsync(taskId); + original!.Status.Should().Be(TaskStatus.Pending); + var allTasks = await db.WorkflowTasks.ToListAsync(); + allTasks.Should().HaveCount(1); + } + #endregion #region DelegateTask @@ -320,6 +817,84 @@ public class WorkflowTaskHandlerTests delegatedTask!.Status.Should().Be(TaskStatus.Pending); } + /// + /// 边界:非 Pending 状态(已审批/已转办/已委派)的任务不可被委派。 + /// 当前缺失状态校验——必须补上。 + /// + [Theory] + [InlineData(TaskStatus.Approved)] + [InlineData(TaskStatus.Rejected)] + [InlineData(TaskStatus.Transferred)] + [InlineData(TaskStatus.Delegated)] + public async Task DelegateTask_NonPendingTask_ThrowsBusinessException(TaskStatus status) + { + await using var db = CreateDbContext(); + var taskId = Guid.NewGuid(); + var fromUserId = Guid.NewGuid(); + var toUserId = Guid.NewGuid(); + + var task = new WorkflowTask + { + Id = taskId, + InstanceId = Guid.NewGuid(), + TokenId = Guid.NewGuid(), + AssigneeId = fromUserId, + Status = status + }; + db.WorkflowTasks.Add(task); + await db.SaveChangesAsync(); + + var handler = new DelegateTaskCommandHandler(db); + var command = new DelegateTaskCommand(taskId, fromUserId, toUserId); + + var act = () => handler.Handle(command, CancellationToken.None); + + await act.Should().ThrowAsync(); + + var original = await db.WorkflowTasks.FindAsync(taskId); + original!.Status.Should().Be(status); + var allTasks = await db.WorkflowTasks.ToListAsync(); + allTasks.Should().HaveCount(1); + } + + /// + /// 边界:委派必须校验调用者是当前 assignee(与 Approve/Reject 一致),否则任何人可委派他人任务。 + /// 当前缺失授权校验——必须补上。 + /// + [Fact] + public async Task DelegateTask_ByNonAssignee_ThrowsUnauthorizedException() + { + await using var db = CreateDbContext(); + var taskId = Guid.NewGuid(); + var fromUserId = Guid.NewGuid(); + var otherUser = Guid.NewGuid(); + var toUserId = Guid.NewGuid(); + + var task = new WorkflowTask + { + Id = taskId, + InstanceId = Guid.NewGuid(), + TokenId = Guid.NewGuid(), + AssigneeId = fromUserId, + Status = TaskStatus.Pending + }; + db.WorkflowTasks.Add(task); + await db.SaveChangesAsync(); + + var handler = new DelegateTaskCommandHandler(db); + // otherUser 冒充委派 fromUserId 的任务 + var command = new DelegateTaskCommand(taskId, otherUser, toUserId); + + var act = () => handler.Handle(command, CancellationToken.None); + + await act.Should().ThrowAsync(); + + var original = await db.WorkflowTasks.FindAsync(taskId); + original!.Status.Should().Be(TaskStatus.Pending); + var allTasks = await db.WorkflowTasks.ToListAsync(); + allTasks.Should().HaveCount(1); + } + #endregion #region GetPendingTasks diff --git a/tests/Workflow.Tests/Notification/NotificationQueryTests.cs b/tests/Workflow.Tests/Notification/NotificationQueryTests.cs new file mode 100644 index 0000000..aa9d6ba --- /dev/null +++ b/tests/Workflow.Tests/Notification/NotificationQueryTests.cs @@ -0,0 +1,144 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Workflow.Application.Features.Notifications; +using Workflow.Domain.Entities; +using Workflow.Domain.Exceptions; +using Workflow.Infrastructure.Persistence; +using Xunit; + +namespace Workflow.Tests.Notifications; + +public class NotificationQueryTests +{ + private static WorkflowDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new WorkflowDbContext(options); + } + + private static Notification NewNotification( + Guid? recipientUserId = null, string? recipientRole = null, + bool isRead = false, string category = "task-arrived") + => new() + { + Id = Guid.NewGuid(), + RecipientUserId = recipientUserId, + RecipientRole = recipientRole, + Title = "T", + Content = "C", + Category = category, + IsRead = isRead, + CreatedAt = DateTime.UtcNow + }; + + [Fact] + public async Task GetNotifications_ReturnsBothUserAndRoleTargeted() + { + var db = CreateDbContext(); + var userId = Guid.NewGuid(); + db.Notifications.AddRange( + NewNotification(recipientUserId: userId), // 给该用户 + NewNotification(recipientRole: "manager"), // 给 manager 角色 + NewNotification(recipientUserId: Guid.NewGuid()), // 给别人 + NewNotification(recipientRole: "hr")); // 给别的角色 + await db.SaveChangesAsync(); + + var handler = new GetNotificationsQueryHandler(db); + var result = await handler.Handle( + new GetNotificationsQuery(userId, ["manager"], false, 1, 20), default); + + result.Items.Should().HaveCount(2); + result.Total.Should().Be(2); + } + + [Fact] + public async Task GetNotifications_UnreadOnly_FiltersReadOnes() + { + var db = CreateDbContext(); + var userId = Guid.NewGuid(); + db.Notifications.AddRange( + NewNotification(recipientUserId: userId, isRead: false), + NewNotification(recipientUserId: userId, isRead: false), + NewNotification(recipientUserId: userId, isRead: true)); + await db.SaveChangesAsync(); + + var handler = new GetNotificationsQueryHandler(db); + var result = await handler.Handle( + new GetNotificationsQuery(userId, [], true, 1, 20), default); + + result.Items.Should().HaveCount(2); + } + + [Fact] + public async Task GetUnreadCount_CountsUserAndRoleTargeted() + { + var db = CreateDbContext(); + var userId = Guid.NewGuid(); + db.Notifications.AddRange( + NewNotification(recipientUserId: userId, isRead: false), + NewNotification(recipientUserId: userId, isRead: true), + NewNotification(recipientRole: "manager", isRead: false), + NewNotification(recipientRole: "manager", isRead: false)); + await db.SaveChangesAsync(); + + var handler = new GetUnreadNotificationCountHandler(db); + var count = await handler.Handle( + new GetUnreadNotificationCountQuery(userId, ["manager"]), default); + + count.Should().Be(3); // 1 user + 2 role + } + + [Fact] + public async Task MarkNotificationRead_SetsReadAndReadAt() + { + var db = CreateDbContext(); + var userId = Guid.NewGuid(); + var n = NewNotification(recipientUserId: userId, isRead: false); + db.Notifications.Add(n); + await db.SaveChangesAsync(); + + var handler = new MarkNotificationReadCommandHandler(db); + await handler.Handle(new MarkNotificationReadCommand(n.Id, userId), default); + + var updated = await db.Notifications.FindAsync(n.Id); + updated!.IsRead.Should().BeTrue(); + updated.ReadAt.Should().NotBeNull(); + } + + [Fact] + public async Task MarkNotificationRead_NotOwnNotification_Throws() + { + var db = CreateDbContext(); + var n = NewNotification(recipientUserId: Guid.NewGuid()); + db.Notifications.Add(n); + await db.SaveChangesAsync(); + + var handler = new MarkNotificationReadCommandHandler(db); + var act = () => handler.Handle( + new MarkNotificationReadCommand(n.Id, Guid.NewGuid()), default); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task MarkAllNotificationsRead_MarksAllUserAndRoleTargeted() + { + var db = CreateDbContext(); + var userId = Guid.NewGuid(); + db.Notifications.AddRange( + NewNotification(recipientUserId: userId, isRead: false), + NewNotification(recipientRole: "manager", isRead: false), + NewNotification(recipientRole: "hr", isRead: false)); // 不属于该用户 + await db.SaveChangesAsync(); + + var handler = new MarkAllNotificationsReadCommandHandler(db); + await handler.Handle( + new MarkAllNotificationsReadCommand(userId, ["manager"]), default); + + var all = await db.Notifications.ToListAsync(); + all.Count(n => n.IsRead).Should().Be(2); // user + manager 两读,hr 仍未读 + all.First(n => n.RecipientRole == "hr").IsRead.Should().BeFalse(); + } +} diff --git a/tests/Workflow.Tests/Notification/NotificationServiceTests.cs b/tests/Workflow.Tests/Notification/NotificationServiceTests.cs new file mode 100644 index 0000000..0bced73 --- /dev/null +++ b/tests/Workflow.Tests/Notification/NotificationServiceTests.cs @@ -0,0 +1,246 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Workflow.Application.Notifications; +using Workflow.Domain.Entities; +using Workflow.Domain.Enums; +using Workflow.Infrastructure.Persistence; +using Xunit; + +using TaskStatus = Workflow.Domain.Enums.TaskStatus; + +namespace Workflow.Tests.Notifications; + +public class NotificationServiceTests +{ + private static WorkflowDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new WorkflowDbContext(options); + } + + private static NotificationOptions DefaultOptions() => new() + { + Webhook = new NotificationOptions.WebhookSection + { + AllowedHosts = [], // 默认禁用 Webhook,专注测站内信 + DefaultUrl = "" + } + }; + + private static WorkflowTask NewTask(Guid? assigneeId = null, string? role = null) + { + var instanceId = Guid.NewGuid(); + return new WorkflowTask + { + Id = Guid.NewGuid(), + InstanceId = instanceId, + TokenId = Guid.NewGuid(), + NodeId = Guid.NewGuid(), + Title = "测试任务", + AssigneeId = assigneeId, + AssigneeRole = role, + Type = TaskType.Approval, + Status = TaskStatus.Pending + }; + } + + private static WorkflowInstance NewInstance(Guid instanceId, Guid initiatorId) + => new() + { + Id = instanceId, + DefinitionId = Guid.NewGuid(), + Title = "测试流程", + InitiatorId = initiatorId, + Status = InstanceStatus.Running + }; + + [Fact] + public async Task NotifyTaskArrived_ByUserId_CreatesNotificationForAssignee() + { + var db = CreateDbContext(); + var assigneeId = Guid.NewGuid(); + var task = NewTask(assigneeId: assigneeId); + db.WorkflowInstances.Add(NewInstance(task.InstanceId, Guid.NewGuid())); + await db.SaveChangesAsync(); + + var svc = new NotificationService(db, Options.Create(DefaultOptions())); + await svc.NotifyTaskArrivedAsync(task); + await db.SaveChangesAsync(); + + var notifications = await db.Notifications.ToListAsync(); + notifications.Should().HaveCount(1); + notifications[0].RecipientUserId.Should().Be(assigneeId); + notifications[0].RecipientRole.Should().BeNull(); + notifications[0].Category.Should().Be("task-arrived"); + notifications[0].IsRead.Should().BeFalse(); + notifications[0].RelatedTaskId.Should().Be(task.Id); + } + + [Fact] + public async Task NotifyTaskArrived_ByRole_CreatesRoleScopedNotification() + { + var db = CreateDbContext(); + var task = NewTask(role: "manager"); + db.WorkflowInstances.Add(NewInstance(task.InstanceId, Guid.NewGuid())); + await db.SaveChangesAsync(); + + var svc = new NotificationService(db, Options.Create(DefaultOptions())); + await svc.NotifyTaskArrivedAsync(task); + await db.SaveChangesAsync(); + + var n = await db.Notifications.SingleAsync(); + n.RecipientRole.Should().Be("manager"); + n.RecipientUserId.Should().BeNull(); + } + + [Fact] + public async Task NotifyTaskApproved_NotifiesBothAssigneeAndInitiator() + { + var db = CreateDbContext(); + var assigneeId = Guid.NewGuid(); + var initiatorId = Guid.NewGuid(); + var task = NewTask(assigneeId: assigneeId); + db.WorkflowInstances.Add(NewInstance(task.InstanceId, initiatorId)); + await db.SaveChangesAsync(); + + var svc = new NotificationService(db, Options.Create(DefaultOptions())); + await svc.NotifyTaskApprovedAsync(task); + await db.SaveChangesAsync(); + + var notifications = await db.Notifications.ToListAsync(); + notifications.Should().HaveCount(2); + notifications.Should().Contain(n => n.RecipientUserId == assigneeId); + notifications.Should().Contain(n => n.RecipientUserId == initiatorId); + notifications.Should().OnlyContain(n => n.Category == "approved"); + } + + [Fact] + public async Task NotifyTaskApproved_AssigneeIsInitiator_AvoidsDuplicate() + { + var db = CreateDbContext(); + var userId = Guid.NewGuid(); + var task = NewTask(assigneeId: userId); + db.WorkflowInstances.Add(NewInstance(task.InstanceId, userId)); + await db.SaveChangesAsync(); + + var svc = new NotificationService(db, Options.Create(DefaultOptions())); + await svc.NotifyTaskApprovedAsync(task); + await db.SaveChangesAsync(); + + var notifications = await db.Notifications.ToListAsync(); + notifications.Should().HaveCount(1, "受理人与发起人相同时不应重复通知"); + } + + [Fact] + public async Task NotifyTaskRejected_NotifiesInitiator() + { + var db = CreateDbContext(); + var initiatorId = Guid.NewGuid(); + var task = NewTask(role: "manager"); + db.WorkflowInstances.Add(NewInstance(task.InstanceId, initiatorId)); + await db.SaveChangesAsync(); + + var svc = new NotificationService(db, Options.Create(DefaultOptions())); + await svc.NotifyTaskRejectedAsync(task); + await db.SaveChangesAsync(); + + var n = await db.Notifications.SingleAsync(x => x.RecipientUserId == initiatorId); + n.Category.Should().Be("rejected"); + } + + [Fact] + public async Task NotifyTaskTimeout_AutoApproved_UsesCorrectCategoryAndText() + { + var db = CreateDbContext(); + var task = NewTask(assigneeId: Guid.NewGuid()); + db.WorkflowInstances.Add(NewInstance(task.InstanceId, Guid.NewGuid())); + await db.SaveChangesAsync(); + + var svc = new NotificationService(db, Options.Create(DefaultOptions())); + await svc.NotifyTaskTimeoutAsync(task, autoApproved: true); + await db.SaveChangesAsync(); + + var notifications = await db.Notifications.ToListAsync(); + notifications.Should().HaveCountGreaterThanOrEqualTo(1); + notifications.Should().OnlyContain(n => n.Category == "timeout-approved"); + notifications.Should().OnlyContain(n => n.Title.Contains("自动通过")); + } + + [Fact] + public async Task Notify_WithWebhookConfigured_CreatesWebhookDeliveryPending() + { + var db = CreateDbContext(); + var task = NewTask(assigneeId: Guid.NewGuid()); + db.WorkflowInstances.Add(NewInstance(task.InstanceId, Guid.NewGuid())); + await db.SaveChangesAsync(); + + var options = new NotificationOptions + { + Webhook = new NotificationOptions.WebhookSection + { + AllowedHosts = ["example.com"], + DefaultUrl = "https://example.com/hook" + } + }; + var svc = new NotificationService(db, Options.Create(options)); + await svc.NotifyTaskArrivedAsync(task); + await db.SaveChangesAsync(); + + var delivery = await db.WebhookDeliveries.SingleAsync(); + delivery.Status.Should().Be("pending"); + delivery.Url.Should().Be("https://example.com/hook"); + delivery.Attempts.Should().Be(0); + delivery.Payload.Should().Contain("task-arrived"); + } + + [Theory] + [InlineData("https://example.com/hook", true)] + [InlineData("http://localhost:8080/hook", false)] // localhost 拒绝 + [InlineData("http://127.0.0.1/hook", false)] // 回环 IP 拒绝 + [InlineData("http://10.0.0.5/hook", false)] // 私有网段拒绝 + [InlineData("http://192.168.1.1/hook", false)] // 私有网段拒绝 + [InlineData("not-a-url", false)] // 非法 URL 拒绝 + public void IsHostAllowed_EnforcesSsrfProtection(string url, bool expected) + { + var db = CreateDbContext(); + var options = new NotificationOptions + { + Webhook = new NotificationOptions.WebhookSection + { + AllowedHosts = ["example.com"] + } + }; + var svc = new NotificationService(db, Options.Create(options)); + + svc.IsHostAllowed(url).Should().Be(expected); + } + + [Fact] + public async Task Notify_WebhookHostNotInAllowlist_SkipsWebhookDelivery() + { + var db = CreateDbContext(); + var task = NewTask(assigneeId: Guid.NewGuid()); + db.WorkflowInstances.Add(NewInstance(task.InstanceId, Guid.NewGuid())); + await db.SaveChangesAsync(); + + // DefaultUrl 指向不在白名单的域 + var options = new NotificationOptions + { + Webhook = new NotificationOptions.WebhookSection + { + AllowedHosts = ["allowed.com"], + DefaultUrl = "https://evil.com/hook" + } + }; + var svc = new NotificationService(db, Options.Create(options)); + await svc.NotifyTaskArrivedAsync(task); + await db.SaveChangesAsync(); + + // 站内信仍创建(必达),但 Webhook 因 SSRF 防护被跳过 + (await db.Notifications.CountAsync()).Should().Be(1); + (await db.WebhookDeliveries.CountAsync()).Should().Be(0); + } +} diff --git a/tests/Workflow.Tests/Scheduler/TimeoutSchedulerTests.cs b/tests/Workflow.Tests/Scheduler/TimeoutSchedulerTests.cs new file mode 100644 index 0000000..5cdfe45 --- /dev/null +++ b/tests/Workflow.Tests/Scheduler/TimeoutSchedulerTests.cs @@ -0,0 +1,238 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Workflow.Application.Engine; +using Workflow.Application.Notifications; +using Workflow.Application.Scheduler; +using Workflow.Domain.Entities; +using Workflow.Domain.Enums; +using Workflow.Domain.Expressions; +using Workflow.Infrastructure.Persistence; +using Xunit; + +using TaskStatus = Workflow.Domain.Enums.TaskStatus; + +namespace Workflow.Tests.Scheduler; + +/// +/// 测试用的 Noop 通知服务:不真正落库通知,仅记录调用情况,便于断言超时调度器是否触发了通知。 +/// +internal sealed class NoopNotificationService : INotificationService +{ + public int TimeoutCallCount { get; private set; } + public List TimeoutTasks { get; } = []; + public List AutoApprovedFlags { get; } = []; + + public Task NotifyTaskArrivedAsync(WorkflowTask task, CancellationToken ct = default) => Task.CompletedTask; + public Task NotifyTaskApprovedAsync(WorkflowTask task, CancellationToken ct = default) => Task.CompletedTask; + public Task NotifyTaskRejectedAsync(WorkflowTask task, CancellationToken ct = default) => Task.CompletedTask; + public Task NotifyTaskUrgedAsync(WorkflowTask task, CancellationToken ct = default) => Task.CompletedTask; + + public Task NotifyTaskTimeoutAsync(WorkflowTask task, bool autoApproved, CancellationToken ct = default) + { + TimeoutCallCount++; + TimeoutTasks.Add(task); + AutoApprovedFlags.Add(autoApproved); + return Task.CompletedTask; + } +} + +/// +/// OverdueTaskProcessor 核心逻辑测试:逾期任务自动处理、Suspended 守卫、autoApproveOnTimeout 解析、 +/// DueAt=null 跳过、空转。直接构造处理器实例,不依赖 HostedService/DI scope。 +/// +public class TimeoutSchedulerTests +{ + private static WorkflowDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new WorkflowDbContext(options); + } + + /// 构造 OverdueTaskProcessor:共享 DbContext + 引擎 + 通知捕获器。 + private static (OverdueTaskProcessor processor, WorkflowDbContext db, NoopNotificationService notifier) Build(WorkflowDbContext db) + { + var notifier = new NoopNotificationService(); + // ProcessEngine 需要 IServiceProvider 解析 INotificationService;构造一个最小 provider + var services = new ServiceCollection(); + services.AddSingleton(notifier); + var provider = services.BuildServiceProvider(); + var engine = new ProcessEngine(db, provider, new ConditionEvaluator()); + var processor = new OverdueTaskProcessor(db, engine, notifier, NullLogger.Instance); + return (processor, db, notifier); + } + + /// 种子一条已逾期、Running 实例的 Pending 任务 + 完整的下游 End 节点(让 CompleteTaskAsync 能推进)。 + private static async Task<(WorkflowInstance instance, WorkflowTask task)> SeedOverdueAsync( + WorkflowDbContext db, bool autoApprove, int overdueMinutes = 30, EdgeType edgeType = EdgeType.Approved) + { + var definitionId = Guid.NewGuid(); + var instanceId = Guid.NewGuid(); + var approvalNodeId = Guid.NewGuid(); + var endNodeId = Guid.NewGuid(); + var assigneeId = Guid.NewGuid(); + + db.WorkflowDefinitions.Add(new WorkflowDefinition + { + Id = definitionId, Name = "超时测试流程", Code = "timeout-test-" + Guid.NewGuid(), + Status = DefinitionStatus.Published, IsEnabled = true + }); + db.WorkflowNodes.AddRange( + new WorkflowNode { Id = approvalNodeId, DefinitionId = definitionId, NodeType = NodeType.Approval, Name = "审批", Config = "{ \"assigneeRule\": \"user:" + assigneeId + "\", \"autoApproveOnTimeout\": " + (autoApprove ? "true" : "false") + " }" }, + new WorkflowNode { Id = endNodeId, DefinitionId = definitionId, NodeType = NodeType.End, Name = "结束" }); + db.WorkflowEdges.Add(new WorkflowEdge + { + Id = Guid.NewGuid(), DefinitionId = definitionId, + SourceNodeId = approvalNodeId, TargetNodeId = endNodeId, EdgeType = edgeType + }); + + var instance = new WorkflowInstance + { + Id = instanceId, DefinitionId = definitionId, Title = "测试实例", + InitiatorId = Guid.NewGuid(), Status = InstanceStatus.Running + }; + db.WorkflowInstances.Add(instance); + + var tokenId = Guid.NewGuid(); + db.WorkflowTokens.Add(new WorkflowToken { Id = tokenId, InstanceId = instanceId, NodeId = approvalNodeId, Status = TokenStatus.Active }); + + var task = new WorkflowTask + { + Id = Guid.NewGuid(), InstanceId = instanceId, TokenId = tokenId, NodeId = approvalNodeId, + Title = "逾期任务", AssigneeId = assigneeId, + Type = TaskType.Approval, Status = TaskStatus.Pending, + DueAt = DateTime.UtcNow.AddMinutes(-overdueMinutes) // 已逾期 + }; + db.WorkflowTasks.Add(task); + await db.SaveChangesAsync(); + return (instance, task); + } + + [Fact] + public async Task Execute_AutoApproveTrue_AutoCompletesAsApproved() + { + var db = CreateDbContext(); + var (processor, _, notifier) = Build(db); + var (instance, task) = await SeedOverdueAsync(db, autoApprove: true); + + await processor.ExecuteAsync(); + + var updated = await db.WorkflowTasks.FirstAsync(t => t.Id == task.Id); + updated.Status.Should().Be(TaskStatus.Approved); + updated.Comment.Should().Contain("自动通过"); + notifier.TimeoutCallCount.Should().Be(1); + notifier.AutoApprovedFlags.Should().Contain(true); + } + + [Fact] + public async Task Execute_AutoApproveFalse_AutoCompletesAsRejected() + { + var db = CreateDbContext(); + var (processor, _, notifier) = Build(db); + // 驳回需 Rejected 边 + var (instance, task) = await SeedOverdueAsync(db, autoApprove: false, edgeType: EdgeType.Rejected); + + await processor.ExecuteAsync(); + + var updated = await db.WorkflowTasks.FirstAsync(t => t.Id == task.Id); + updated.Status.Should().Be(TaskStatus.Rejected); + updated.Comment.Should().Contain("自动驳回"); + notifier.TimeoutCallCount.Should().Be(1); + notifier.AutoApprovedFlags.Should().Contain(false); + } + + [Fact] + public async Task Execute_SuspendedInstance_Skipped() + { + // 关键守卫:CompleteTaskAsync 不检查 instance.Status,调度器必须过滤 Suspended 实例 + var db = CreateDbContext(); + var (processor, _, notifier) = Build(db); + var (instance, task) = await SeedOverdueAsync(db, autoApprove: true); + instance.Status = InstanceStatus.Suspended; + await db.SaveChangesAsync(); + + await processor.ExecuteAsync(); + + var unchanged = await db.WorkflowTasks.FirstAsync(t => t.Id == task.Id); + unchanged.Status.Should().Be(TaskStatus.Pending, "已挂起实例的任务不应被超时处理"); + notifier.TimeoutCallCount.Should().Be(0); + } + + [Fact] + public async Task Execute_NoDueAt_Skipped() + { + var db = CreateDbContext(); + var (processor, _, notifier) = Build(db); + var (_, task) = await SeedOverdueAsync(db, autoApprove: true); + task.DueAt = null; + await db.SaveChangesAsync(); + + await processor.ExecuteAsync(); + + var unchanged = await db.WorkflowTasks.FirstAsync(t => t.Id == task.Id); + unchanged.Status.Should().Be(TaskStatus.Pending, "无 DueAt 的任务不应被处理"); + notifier.TimeoutCallCount.Should().Be(0); + } + + [Fact] + public async Task Execute_NotYetOverdue_Skipped() + { + var db = CreateDbContext(); + var (processor, _, notifier) = Build(db); + var (_, task) = await SeedOverdueAsync(db, autoApprove: true); + task.DueAt = DateTime.UtcNow.AddMinutes(30); // 还没到期 + await db.SaveChangesAsync(); + + await processor.ExecuteAsync(); + + var unchanged = await db.WorkflowTasks.FirstAsync(t => t.Id == task.Id); + unchanged.Status.Should().Be(TaskStatus.Pending, "未到期的任务不应被处理"); + notifier.TimeoutCallCount.Should().Be(0); + } + + [Fact] + public async Task Execute_AlreadyCompleted_Skipped() + { + var db = CreateDbContext(); + var (processor, _, notifier) = Build(db); + var (_, task) = await SeedOverdueAsync(db, autoApprove: true); + task.Status = TaskStatus.Approved; + await db.SaveChangesAsync(); + + await processor.ExecuteAsync(); + + notifier.TimeoutCallCount.Should().Be(0, "已处理的任务不应再次被超时处理"); + } + + [Fact] + public async Task Execute_NoOverdueTasks_Noop() + { + var db = CreateDbContext(); + var (processor, _, notifier) = Build(db); + + await processor.ExecuteAsync(); // 空库,应正常返回不报错 + + notifier.TimeoutCallCount.Should().Be(0); + } + + [Fact] + public void NodeConfigParser_ParsesTimeoutConfig() + { + var config = NodeConfigParser.Parse("""{ "timeoutMinutes": 1440, "autoApproveOnTimeout": true }"""); + + NodeConfigParser.GetInt(config, "timeoutMinutes").Should().Be(1440); + NodeConfigParser.GetBool(config, "autoApproveOnTimeout").Should().BeTrue(); + } + + [Fact] + public void NodeConfigParser_MissingKeys_ReturnsNull() + { + var config = NodeConfigParser.Parse("""{ "assigneeRule": "role:manager" }"""); + + NodeConfigParser.GetInt(config, "timeoutMinutes").Should().BeNull(); + NodeConfigParser.GetBool(config, "autoApproveOnTimeout").Should().BeNull(); + } +} diff --git a/tests/Workflow.Tests/Serialization/TimestampJsonConverterTests.cs b/tests/Workflow.Tests/Serialization/TimestampJsonConverterTests.cs new file mode 100644 index 0000000..66b6bab --- /dev/null +++ b/tests/Workflow.Tests/Serialization/TimestampJsonConverterTests.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using FluentAssertions; +using Workflow.Api.Serialization; +using Xunit; + +namespace Workflow.Tests.Serialization; + +/// +/// 验证统一数据规范:DateTime / DateTimeOffset 序列化为 UTC 毫秒时间戳(long)。 +/// 这是第二期"后端数据规范统一"的核心契约 —— 后端只输出毫秒时间戳,时区/格式化交给前端。 +/// +public class TimestampJsonConverterTests +{ + private static JsonSerializerOptions BuildOptions() => new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new TimestampDateTimeConverter(), new TimestampDateTimeOffsetConverter() } + }; + + private class SampleDto + { + public Guid Id { get; set; } + public DateTime CreatedAt { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + } + + [Fact] + public void DateTime_serializes_to_millisecond_epoch_number() + { + // 固定时刻:2026-06-14T03:30:00Z + var utc = new DateTime(2026, 6, 14, 3, 30, 0, DateTimeKind.Utc); + var expectedMs = new DateTimeOffset(utc, TimeSpan.Zero).ToUnixTimeMilliseconds(); + + var dto = new SampleDto { Id = Guid.NewGuid(), CreatedAt = utc }; + var json = JsonSerializer.Serialize(dto, BuildOptions()); + + using var doc = JsonDocument.Parse(json); + doc.RootElement.GetProperty("createdAt").ValueKind.Should().Be(JsonValueKind.Number); + doc.RootElement.GetProperty("createdAt").GetInt64().Should().Be(expectedMs); + } + + [Fact] + public void DateTimeOffset_serializes_to_millisecond_epoch_number() + { + var dto = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero); + var expectedMs = dto.ToUnixTimeMilliseconds(); + + var json = JsonSerializer.Serialize(new { t = dto }, BuildOptions()); + + using var doc = JsonDocument.Parse(json); + doc.RootElement.GetProperty("t").GetInt64().Should().Be(expectedMs); + } + + [Fact] + public void Guid_serializes_as_string() + { + // 验证 ID 统一为字符串(Guid 天然序列化为字符串,无需额外转换器) + var id = Guid.NewGuid(); + var dto = new SampleDto { Id = id }; + var json = JsonSerializer.Serialize(dto, BuildOptions()); + + using var doc = JsonDocument.Parse(json); + doc.RootElement.GetProperty("id").ValueKind.Should().Be(JsonValueKind.String); + doc.RootElement.GetProperty("id").GetGuid().Should().Be(id); + } + + [Fact] + public void Deserializes_epoch_number_back_to_utc_dateTime() + { + var utc = new DateTime(2026, 6, 14, 3, 30, 0, DateTimeKind.Utc); + var ms = new DateTimeOffset(utc, TimeSpan.Zero).ToUnixTimeMilliseconds(); + var json = $$"""{"id":"00000000-0000-0000-0000-000000000000","createdAt":{{ms}}}"""; + + var dto = JsonSerializer.Deserialize(json, BuildOptions())!; + + dto.CreatedAt.Should().Be(utc); + dto.CreatedAt.Kind.Should().Be(DateTimeKind.Utc); + } + + [Fact] + public void Deserializes_legacy_iso_string_for_backward_compatibility() + { + // 旧客户端/旧数据可能仍是 ISO 字符串,必须能读回(向后兼容) + var json = """{"id":"00000000-0000-0000-0000-000000000000","createdAt":"2026-06-14T03:30:00Z"}"""; + + var dto = JsonSerializer.Deserialize(json, BuildOptions())!; + + dto.CreatedAt.Should().Be(new DateTime(2026, 6, 14, 3, 30, 0, DateTimeKind.Utc)); + } +} diff --git a/tests/Workflow.Tests/Workflow.Tests.csproj b/tests/Workflow.Tests/Workflow.Tests.csproj index 4995859..889e0ca 100644 --- a/tests/Workflow.Tests/Workflow.Tests.csproj +++ b/tests/Workflow.Tests/Workflow.Tests.csproj @@ -19,5 +19,6 @@ +