feat(scripting): 完整 CQRS 层 + 10 个管理端点

Commands: Create/Update/Delete/Publish/Disable/Test/Invoke
Queries:  List/Detail/ExecutionLogs
DTOs:     ScriptDto/ScriptExecutionLogDto + PagedResult
Endpoints (GET/POST/PUT/DELETE /scripts/*):
  - GET    /scripts              scripts.read
  - GET    /scripts/{id}         scripts.read
  - POST   /scripts              scripts.create
  - PUT    /scripts/{id}         scripts.update
  - DELETE /scripts/{id}         scripts.delete
  - POST   /scripts/{id}/publish scripts.publish
  - POST   /scripts/{id}/disable scripts.disable
  - POST   /scripts/{id}/test    scripts.test
  - GET    /scripts/{id}/logs    scripts.read
  - POST   /scripts/{name}/invoke scripts.run

Task T5 of scripting module refactor
This commit is contained in:
xiangning 2026-07-07 09:26:54 +08:00
parent bfb83957b6
commit 3d5604d80c
44 changed files with 856 additions and 0 deletions

View File

@ -0,0 +1,32 @@
using MediatR;
using Scripting.Application.Commands;
using Scripting.Application.DTOs;
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>创建脚本端点。POST /scripts。需 scripts.create 权限。</summary>
public class CreateScriptEndpoint(IMediator mediator)
: Endpoint<CreateScriptRequest, ScriptDto>
{
public override void Configure()
{
Post("/scripts");
Permissions("scripts.create");
}
public override async Task HandleAsync(CreateScriptRequest req, CancellationToken ct)
{
var result = await mediator.Send(
new CreateScriptCommand(
req.Name,
req.DisplayName,
req.Description,
req.Code,
req.InputSchema,
req.OutputSchema,
req.TimeoutMs,
req.FormDefinitionId),
ct);
await Send.ResponseAsync(result, 201, ct);
}
}

View File

@ -0,0 +1,12 @@
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>创建脚本请求。仅承载 C# 脚本字段。</summary>
public sealed record CreateScriptRequest(
string Name,
string DisplayName,
string? Description,
string Code,
string? InputSchema,
string? OutputSchema,
int TimeoutMs,
Guid? FormDefinitionId = null);

View File

@ -0,0 +1,21 @@
using MediatR;
using Scripting.Application.Commands;
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>删除脚本端点(软删除)。DELETE /scripts/{id}。需 scripts.delete 权限。</summary>
public class DeleteScriptEndpoint(IMediator mediator)
: Endpoint<DeleteScriptRequest>
{
public override void Configure()
{
Delete("/scripts/{id}");
Permissions("scripts.delete");
}
public override async Task HandleAsync(DeleteScriptRequest req, CancellationToken ct)
{
await mediator.Send(new DeleteScriptCommand(req.Id), ct);
await Send.OkAsync(true, ct);
}
}

View File

@ -0,0 +1,4 @@
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>删除脚本请求。Id 由路由 {id} 绑定。</summary>
public sealed record DeleteScriptRequest(Guid Id);

View File

@ -0,0 +1,22 @@
using MediatR;
using Scripting.Application.Commands;
using Scripting.Application.DTOs;
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>禁用脚本端点。POST /scripts/{id}/disable。需 scripts.disable 权限。</summary>
public class DisableScriptEndpoint(IMediator mediator)
: Endpoint<DisableScriptRequest, ScriptDto>
{
public override void Configure()
{
Post("/scripts/{id}/disable");
Permissions("scripts.disable");
}
public override async Task HandleAsync(DisableScriptRequest req, CancellationToken ct)
{
var result = await mediator.Send(new DisableScriptCommand(req.Id), ct);
await Send.OkAsync(result, ct);
}
}

View File

@ -0,0 +1,4 @@
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>禁用脚本请求。Id 由路由 {id} 绑定。</summary>
public sealed record DisableScriptRequest(Guid Id);

View File

@ -0,0 +1,22 @@
using MediatR;
using Scripting.Application.DTOs;
using Scripting.Application.Queries;
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>脚本详情端点。GET /scripts/{id}。需 scripts.read 权限。Id 由路由 {id} 按名绑定到请求。</summary>
public class GetScriptDetailEndpoint(IMediator mediator)
: Endpoint<GetScriptDetailRequest, ScriptDto>
{
public override void Configure()
{
Get("/scripts/{id}");
Permissions("scripts.read");
}
public override async Task HandleAsync(GetScriptDetailRequest req, CancellationToken ct)
{
var result = await mediator.Send(new GetScriptDetailQuery(req.Id), ct);
await Send.OkAsync(result, ct);
}
}

View File

@ -0,0 +1,4 @@
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>脚本详情查询请求。Id 由路由 {id} 绑定。</summary>
public sealed record GetScriptDetailRequest(Guid Id);

View File

@ -0,0 +1,29 @@
using MediatR;
using Scripting.Application.Common;
using Scripting.Application.DTOs;
using Scripting.Application.Queries;
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>脚本执行日志查询端点。GET /scripts/{id}/logs。需 scripts.read 权限。</summary>
public class GetScriptExecutionLogsEndpoint(IMediator mediator)
: Endpoint<GetScriptExecutionLogsRequest, PagedResult<ScriptExecutionLogDto>>
{
public override void Configure()
{
Get("/scripts/{id}/logs");
Permissions("scripts.read");
}
public override async Task HandleAsync(GetScriptExecutionLogsRequest req, CancellationToken ct)
{
var result = await mediator.Send(
new GetScriptExecutionLogsQuery(
req.Id,
req.PageIndex,
req.PageSize,
req.Status),
ct);
await Send.OkAsync(result, ct);
}
}

View File

@ -0,0 +1,12 @@
using Scripting.Domain.Enums;
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>脚本执行日志查询请求。Id(脚本 Id)由路由 {id} 绑定。</summary>
public sealed class GetScriptExecutionLogsRequest
{
public Guid Id { get; set; }
public int PageIndex { get; set; } = 1;
public int PageSize { get; set; } = 20;
public ScriptExecutionStatus? Status { get; set; }
}

View File

@ -0,0 +1,30 @@
using MediatR;
using Scripting.Application.Common;
using Scripting.Application.DTOs;
using Scripting.Application.Queries;
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>脚本分页查询端点。GET /scripts。需 scripts.read 权限。</summary>
public class GetScriptListEndpoint(IMediator mediator)
: Endpoint<GetScriptListRequest, PagedResult<ScriptDto>>
{
public override void Configure()
{
Get("/scripts");
Permissions("scripts.read");
}
public override async Task HandleAsync(GetScriptListRequest req, CancellationToken ct)
{
var result = await mediator.Send(
new GetScriptListQuery(
req.PageIndex,
req.PageSize,
req.Status,
req.Keyword,
req.FormDefinitionId),
ct);
await Send.OkAsync(result, ct);
}
}

View File

@ -0,0 +1,13 @@
using Scripting.Domain.Enums;
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>脚本分页查询请求。支持按状态、表单作用域、关键字筛选。</summary>
public sealed class GetScriptListRequest
{
public int PageIndex { get; set; } = 1;
public int PageSize { get; set; } = 20;
public ScriptStatus? Status { get; set; }
public string? Keyword { get; set; }
public Guid? FormDefinitionId { get; set; }
}

View File

@ -0,0 +1,33 @@
using System.Text.Json;
using MediatR;
using Scripting.Application.Commands;
using Scripting.Application.DTOs;
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>
/// 调用(按名执行)脚本端点。POST /scripts/{name}/invoke。需 scripts.run 权限。
/// 请求体 { "input": { ... } };input 可为任意 JSON 值,端点序列化为字符串后委托执行服务。
/// </summary>
public class InvokeScriptEndpoint(IMediator mediator)
: Endpoint<InvokeScriptRequest, ScriptExecutionResult>
{
public override void Configure()
{
Post("/scripts/{name}/invoke");
Permissions("scripts.run");
}
public override async Task HandleAsync(InvokeScriptRequest req, CancellationToken ct)
{
// Input 可省略(默认 {}),序列化为紧凑 JSON 字符串交给执行服务。
var inputJson = req.Input is null
? "{}"
: JsonSerializer.Serialize(req.Input.Value);
var result = await mediator.Send(
new InvokeScriptCommand(req.Name, inputJson),
ct);
await Send.OkAsync(result, ct);
}
}

View File

@ -0,0 +1,15 @@
using System.Text.Json;
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>
/// 调用脚本请求。Name 由路由 {name} 绑定;Input 为任意 JSON 对象,端点将其序列化为字符串传给执行服务。
/// </summary>
public sealed class InvokeScriptRequest
{
/// <summary>由路由 {name} 绑定的脚本英文名。</summary>
public string Name { get; set; } = string.Empty;
/// <summary>调用输入,可为任意 JSON 值(对象/数组/标量)。</summary>
public JsonElement? Input { get; set; }
}

View File

@ -0,0 +1,22 @@
using MediatR;
using Scripting.Application.Commands;
using Scripting.Application.DTOs;
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>发布脚本端点。POST /scripts/{id}/publish。需 scripts.publish 权限。</summary>
public class PublishScriptEndpoint(IMediator mediator)
: Endpoint<PublishScriptRequest, ScriptDto>
{
public override void Configure()
{
Post("/scripts/{id}/publish");
Permissions("scripts.publish");
}
public override async Task HandleAsync(PublishScriptRequest req, CancellationToken ct)
{
var result = await mediator.Send(new PublishScriptCommand(req.Id), ct);
await Send.OkAsync(result, ct);
}
}

View File

@ -0,0 +1,4 @@
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>发布脚本请求。Id 由路由 {id} 绑定。</summary>
public sealed record PublishScriptRequest(Guid Id);

View File

@ -0,0 +1,22 @@
using MediatR;
using Scripting.Application.Commands;
using Scripting.Application.DTOs;
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>测试执行脚本端点。POST /scripts/{id}/test。需 scripts.test 权限。</summary>
public class TestScriptEndpoint(IMediator mediator)
: Endpoint<TestScriptRequest, ScriptExecutionResult>
{
public override void Configure()
{
Post("/scripts/{id}/test");
Permissions("scripts.test");
}
public override async Task HandleAsync(TestScriptRequest req, CancellationToken ct)
{
var result = await mediator.Send(new TestScriptCommand(req.Id, req.InputJson), ct);
await Send.OkAsync(result, ct);
}
}

View File

@ -0,0 +1,4 @@
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>测试执行脚本请求。Id 由路由 {id} 绑定,InputJson 为请求体。</summary>
public sealed record TestScriptRequest(Guid Id, string InputJson);

View File

@ -0,0 +1,31 @@
using MediatR;
using Scripting.Application.Commands;
using Scripting.Application.DTOs;
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>更新脚本端点。PUT /scripts/{id}。需 scripts.update 权限。Id 由路由 {id} 绑定。</summary>
public class UpdateScriptEndpoint(IMediator mediator)
: Endpoint<UpdateScriptRequest, ScriptDto>
{
public override void Configure()
{
Put("/scripts/{id}");
Permissions("scripts.update");
}
public override async Task HandleAsync(UpdateScriptRequest req, CancellationToken ct)
{
var result = await mediator.Send(
new UpdateScriptCommand(
req.Id,
req.DisplayName,
req.Description,
req.Code,
req.InputSchema,
req.OutputSchema,
req.TimeoutMs),
ct);
await Send.OkAsync(result, ct);
}
}

View File

@ -0,0 +1,11 @@
namespace BuildFlow.Host.Endpoints.Scripting;
/// <summary>更新脚本请求。Id 由路由 {id} 绑定;Name/FormDefinitionId 不可改。</summary>
public sealed record UpdateScriptRequest(
Guid Id,
string DisplayName,
string? Description,
string Code,
string? InputSchema,
string? OutputSchema,
int TimeoutMs);

View File

@ -0,0 +1,18 @@
using MediatR;
using Scripting.Application.DTOs;
namespace Scripting.Application.Commands;
/// <summary>
/// 创建脚本函数命令。新建脚本默认是草稿(Draft),必须发布后才能被动作链/HTTP invoke 执行。
/// Name 为稳定英文标识(创建后不可改),供按钮引用与按名调用。
/// </summary>
public sealed record CreateScriptCommand(
string Name,
string DisplayName,
string? Description,
string Code,
string? InputSchema,
string? OutputSchema,
int TimeoutMs,
Guid? FormDefinitionId = null) : IRequest<ScriptDto>;

View File

@ -0,0 +1,6 @@
using MediatR;
namespace Scripting.Application.Commands;
/// <summary>删除脚本函数命令(软删除)。Published 状态必须先 Disable 才能删除。</summary>
public sealed record DeleteScriptCommand(Guid Id) : IRequest;

View File

@ -0,0 +1,7 @@
using MediatR;
using Scripting.Application.DTOs;
namespace Scripting.Application.Commands;
/// <summary>禁用脚本函数命令。禁用后动作链将拒绝执行该脚本,HTTP invoke 返回 400。</summary>
public sealed record DisableScriptCommand(Guid Id) : IRequest<ScriptDto>;

View File

@ -0,0 +1,7 @@
using MediatR;
using Scripting.Application.DTOs;
namespace Scripting.Application.Commands;
/// <summary>按 Name 调用已发布脚本命令(HTTP /scripts/{name}/invoke)。</summary>
public sealed record InvokeScriptCommand(string Name, string InputJson) : IRequest<ScriptExecutionResult>;

View File

@ -0,0 +1,7 @@
using MediatR;
using Scripting.Application.DTOs;
namespace Scripting.Application.Commands;
/// <summary>发布脚本函数命令。发布将状态置为 Published 并递增 Version,便于后续审计变更。</summary>
public sealed record PublishScriptCommand(Guid Id) : IRequest<ScriptDto>;

View File

@ -0,0 +1,33 @@
using Scripting.Application.DTOs;
using Scripting.Domain.Entities;
namespace Scripting.Application.Commands;
/// <summary>脚本 DTO 映射器,集中保持命令返回结构一致。注意执行日志的字段重命名(FunctionId→ScriptId)。</summary>
internal static class ScriptMapper
{
public static ScriptDto ToDto(ScriptFunction e) => new(
e.Id,
e.FormDefinitionId,
e.Name,
e.DisplayName,
e.Description,
e.Code,
e.InputSchema,
e.OutputSchema,
e.Status,
e.Version,
e.TimeoutMs);
public static ScriptExecutionLogDto ToLogDto(ScriptExecutionLog e) => new(
e.Id,
e.FunctionId,
e.FunctionName,
e.Status,
e.InputJson,
e.ResultJson,
e.ErrorMessage,
e.DurationMs,
e.CreatedAt,
e.LogLines);
}

View File

@ -0,0 +1,7 @@
using MediatR;
using Scripting.Application.DTOs;
namespace Scripting.Application.Commands;
/// <summary>测试执行脚本命令。不校验 Published 状态,允许草稿直接测,仍写日志(FunctionName 标 [TEST])。</summary>
public sealed record TestScriptCommand(Guid Id, string InputJson) : IRequest<ScriptExecutionResult>;

View File

@ -0,0 +1,17 @@
using MediatR;
using Scripting.Application.DTOs;
namespace Scripting.Application.Commands;
/// <summary>
/// 更新脚本函数命令。仅允许编辑 DisplayName/Description/Code/InputSchema/OutputSchema/TimeoutMs。
/// Name 与 FormDefinitionId 创建后即固化,不可改。Disabled 状态禁止编辑;Published 编辑保持已发布但 Version++。
/// </summary>
public sealed record UpdateScriptCommand(
Guid Id,
string DisplayName,
string? Description,
string Code,
string? InputSchema,
string? OutputSchema,
int TimeoutMs) : IRequest<ScriptDto>;

View File

@ -0,0 +1,13 @@
namespace Scripting.Application.Common;
/// <summary>
/// 通用分页结果包装。脚本模块本地副本,避免对 Workflow 模块的跨模块依赖。
/// 字段形状与 Workflow.Application.Common.PagedResult 完全一致。
/// </summary>
public class PagedResult<T>
{
public List<T> Items { get; set; } = [];
public int Total { get; set; }
public int PageIndex { get; set; }
public int PageSize { get; set; }
}

View File

@ -0,0 +1,20 @@
using Scripting.Domain.Enums;
namespace Scripting.Application.DTOs;
/// <summary>
/// 脚本函数 DTO,用于脚本管理页面和按钮动作选择器。
/// 仅承载 C# 脚本字段(HTTP 类型函数已按 §11 YAGNI 移除)。
/// </summary>
public sealed record ScriptDto(
Guid Id,
Guid? FormDefinitionId,
string Name,
string DisplayName,
string? Description,
string Code,
string? InputSchema,
string? OutputSchema,
ScriptStatus Status,
int Version,
int TimeoutMs);

View File

@ -0,0 +1,19 @@
using Scripting.Domain.Enums;
namespace Scripting.Application.DTOs;
/// <summary>
/// 脚本执行日志 DTO。注意:底层实体字段名仍是 FunctionId/FunctionName(T2 迁移保留),
/// 此处 DTO 使用 ScriptId/ScriptName 以提升对外清晰度,由映射器做字段重命名。
/// </summary>
public sealed record ScriptExecutionLogDto(
Guid Id,
Guid ScriptId,
string ScriptName,
ScriptExecutionStatus Status,
string InputJson,
string? ResultJson,
string? ErrorMessage,
long DurationMs,
DateTime CreatedAt,
string? LogLines);

View File

@ -0,0 +1,41 @@
using BuildFlow.Shared.Domain.Exceptions;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Scripting.Application.Commands;
using Scripting.Application.DTOs;
using Scripting.Domain.Entities;
using Scripting.Domain.Enums;
using Scripting.Infrastructure.Persistence;
namespace Scripting.Application.Handlers;
/// <summary>创建脚本函数处理器。校验 Name 唯一,保证按钮动作引用稳定;TimeoutMs 默认 5000ms。</summary>
public class CreateScriptCommandHandler(ScriptingDbContext db)
: IRequestHandler<CreateScriptCommand, ScriptDto>
{
public async Task<ScriptDto> Handle(CreateScriptCommand request, CancellationToken ct)
{
if (await db.Scripts.AnyAsync(x => x.Name == request.Name, ct))
{
throw new BusinessException($"脚本 {request.Name} 已存在");
}
var entity = new ScriptFunction
{
Id = Guid.NewGuid(),
FormDefinitionId = request.FormDefinitionId,
Name = request.Name,
DisplayName = request.DisplayName,
Description = request.Description,
Code = request.Code,
InputSchema = request.InputSchema,
OutputSchema = request.OutputSchema,
Status = ScriptStatus.Draft,
Version = 1,
TimeoutMs = request.TimeoutMs <= 0 ? 5000 : request.TimeoutMs,
};
db.Scripts.Add(entity);
await db.SaveChangesAsync(ct);
return ScriptMapper.ToDto(entity);
}
}

View File

@ -0,0 +1,27 @@
using BuildFlow.Shared.Domain.Exceptions;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Scripting.Application.Commands;
using Scripting.Domain.Enums;
using Scripting.Infrastructure.Persistence;
namespace Scripting.Application.Handlers;
/// <summary>删除脚本函数处理器(软删除)。Published 状态必须先 Disable 才能删除。</summary>
public class DeleteScriptCommandHandler(ScriptingDbContext db)
: IRequestHandler<DeleteScriptCommand>
{
public async Task Handle(DeleteScriptCommand request, CancellationToken ct)
{
var entity = await db.Scripts.FindAsync([request.Id], ct)
?? throw new BusinessException($"脚本 {request.Id} 不存在");
if (entity.Status == ScriptStatus.Published)
{
throw new BusinessException("已发布的脚本不能删除,请先禁用");
}
entity.IsDeleted = true;
await db.SaveChangesAsync(ct);
}
}

View File

@ -0,0 +1,23 @@
using BuildFlow.Shared.Domain.Exceptions;
using MediatR;
using Scripting.Application.Commands;
using Scripting.Application.DTOs;
using Scripting.Domain.Enums;
using Scripting.Infrastructure.Persistence;
namespace Scripting.Application.Handlers;
/// <summary>禁用脚本函数处理器。置 Disabled 后动作链与 HTTP invoke 都将拒绝执行。</summary>
public class DisableScriptCommandHandler(ScriptingDbContext db)
: IRequestHandler<DisableScriptCommand, ScriptDto>
{
public async Task<ScriptDto> Handle(DisableScriptCommand request, CancellationToken ct)
{
var entity = await db.Scripts.FindAsync([request.Id], ct)
?? throw new BusinessException($"脚本 {request.Id} 不存在");
entity.Status = ScriptStatus.Disabled;
await db.SaveChangesAsync(ct);
return ScriptMapper.ToDto(entity);
}
}

View File

@ -0,0 +1,20 @@
using BuildFlow.Shared.Domain.Exceptions;
using MediatR;
using Scripting.Application.Commands;
using Scripting.Application.DTOs;
using Scripting.Application.Queries;
using Scripting.Infrastructure.Persistence;
namespace Scripting.Application.Handlers;
/// <summary>脚本详情查询处理器:FindAsync 取实体,缺失抛 BusinessException。</summary>
public sealed class GetScriptDetailQueryHandler(ScriptingDbContext db)
: IRequestHandler<GetScriptDetailQuery, ScriptDto>
{
public async Task<ScriptDto> Handle(GetScriptDetailQuery request, CancellationToken ct)
{
var entity = await db.Scripts.FindAsync([request.Id], ct)
?? throw new BusinessException($"脚本 {request.Id} 不存在");
return ScriptMapper.ToDto(entity);
}
}

View File

@ -0,0 +1,52 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Scripting.Application.Common;
using Scripting.Application.DTOs;
using Scripting.Application.Queries;
using Scripting.Infrastructure.Persistence;
namespace Scripting.Application.Handlers;
/// <summary>脚本执行日志查询处理器:按 FunctionId 过滤,可选状态过滤,按 CreatedAt 倒序分页。</summary>
public sealed class GetScriptExecutionLogsQueryHandler(ScriptingDbContext db)
: IRequestHandler<GetScriptExecutionLogsQuery, PagedResult<ScriptExecutionLogDto>>
{
public async Task<PagedResult<ScriptExecutionLogDto>> Handle(
GetScriptExecutionLogsQuery request,
CancellationToken ct)
{
var query = db.ScriptExecutionLogs
.Where(x => x.FunctionId == request.ScriptId);
if (request.Status.HasValue)
{
query = query.Where(x => x.Status == request.Status.Value);
}
var total = await query.CountAsync(ct);
var items = await query
.OrderByDescending(x => x.CreatedAt)
.Skip((request.PageIndex - 1) * request.PageSize)
.Take(request.PageSize)
.Select(x => new ScriptExecutionLogDto(
x.Id,
x.FunctionId,
x.FunctionName,
x.Status,
x.InputJson,
x.ResultJson,
x.ErrorMessage,
x.DurationMs,
x.CreatedAt,
x.LogLines))
.ToListAsync(ct);
return new PagedResult<ScriptExecutionLogDto>
{
Items = items,
Total = total,
PageIndex = request.PageIndex,
PageSize = request.PageSize,
};
}
}

View File

@ -0,0 +1,64 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Scripting.Application.Common;
using Scripting.Application.DTOs;
using Scripting.Application.Queries;
using Scripting.Infrastructure.Persistence;
namespace Scripting.Application.Handlers;
/// <summary>脚本列表查询处理器:过滤 + 按 UpdatedAt 倒序分页,投影到 ScriptDto。</summary>
public sealed class GetScriptListQueryHandler(ScriptingDbContext db)
: IRequestHandler<GetScriptListQuery, PagedResult<ScriptDto>>
{
public async Task<PagedResult<ScriptDto>> Handle(
GetScriptListQuery request,
CancellationToken ct)
{
var query = db.Scripts.AsQueryable();
if (request.Status.HasValue)
{
query = query.Where(x => x.Status == request.Status.Value);
}
if (request.FormDefinitionId.HasValue)
{
query = query.Where(x => x.FormDefinitionId == request.FormDefinitionId.Value);
}
if (!string.IsNullOrWhiteSpace(request.Keyword))
{
query = query.Where(x =>
x.Name.Contains(request.Keyword) ||
x.DisplayName.Contains(request.Keyword));
}
var total = await query.CountAsync(ct);
var items = await query
.OrderByDescending(x => x.UpdatedAt)
.Skip((request.PageIndex - 1) * request.PageSize)
.Take(request.PageSize)
.Select(x => new ScriptDto(
x.Id,
x.FormDefinitionId,
x.Name,
x.DisplayName,
x.Description,
x.Code,
x.InputSchema,
x.OutputSchema,
x.Status,
x.Version,
x.TimeoutMs))
.ToListAsync(ct);
return new PagedResult<ScriptDto>
{
Items = items,
Total = total,
PageIndex = request.PageIndex,
PageSize = request.PageSize,
};
}
}

View File

@ -0,0 +1,14 @@
using MediatR;
using Scripting.Application.Commands;
using Scripting.Application.DTOs;
using Scripting.Application.Services;
namespace Scripting.Application.Handlers;
/// <summary>按 Name 调用脚本处理器,委托执行服务(仅 Published 脚本可调用)。</summary>
public class InvokeScriptCommandHandler(IScriptExecutionService executionService)
: IRequestHandler<InvokeScriptCommand, ScriptExecutionResult>
{
public Task<ScriptExecutionResult> Handle(InvokeScriptCommand request, CancellationToken ct)
=> executionService.ExecuteByNameAsync(request.Name, request.InputJson, ct);
}

View File

@ -0,0 +1,24 @@
using BuildFlow.Shared.Domain.Exceptions;
using MediatR;
using Scripting.Application.Commands;
using Scripting.Application.DTOs;
using Scripting.Domain.Enums;
using Scripting.Infrastructure.Persistence;
namespace Scripting.Application.Handlers;
/// <summary>发布脚本函数处理器。置 Published 并 Version++,便于后续审计变更。</summary>
public class PublishScriptCommandHandler(ScriptingDbContext db)
: IRequestHandler<PublishScriptCommand, ScriptDto>
{
public async Task<ScriptDto> Handle(PublishScriptCommand request, CancellationToken ct)
{
var entity = await db.Scripts.FindAsync([request.Id], ct)
?? throw new BusinessException($"脚本 {request.Id} 不存在");
entity.Status = ScriptStatus.Published;
entity.Version++;
await db.SaveChangesAsync(ct);
return ScriptMapper.ToDto(entity);
}
}

View File

@ -0,0 +1,14 @@
using MediatR;
using Scripting.Application.Commands;
using Scripting.Application.DTOs;
using Scripting.Application.Services;
namespace Scripting.Application.Handlers;
/// <summary>测试执行脚本处理器,委托执行服务以保证测试与运行时一致。</summary>
public class TestScriptCommandHandler(IScriptExecutionService executionService)
: IRequestHandler<TestScriptCommand, ScriptExecutionResult>
{
public Task<ScriptExecutionResult> Handle(TestScriptCommand request, CancellationToken ct)
=> executionService.ExecuteTestAsync(request.Id, request.InputJson, ct);
}

View File

@ -0,0 +1,42 @@
using BuildFlow.Shared.Domain.Exceptions;
using MediatR;
using Scripting.Application.Commands;
using Scripting.Application.DTOs;
using Scripting.Domain.Enums;
using Scripting.Infrastructure.Persistence;
namespace Scripting.Application.Handlers;
/// <summary>
/// 更新脚本函数处理器。Disabled 禁止编辑;Published 编辑保持已发布但 Version++(调用方可据此感知变更)。
/// </summary>
public class UpdateScriptCommandHandler(ScriptingDbContext db)
: IRequestHandler<UpdateScriptCommand, ScriptDto>
{
public async Task<ScriptDto> Handle(UpdateScriptCommand request, CancellationToken ct)
{
var entity = await db.Scripts.FindAsync([request.Id], ct)
?? throw new BusinessException($"脚本 {request.Id} 不存在");
if (entity.Status == ScriptStatus.Disabled)
{
throw new BusinessException("已禁用的脚本不能编辑");
}
entity.DisplayName = request.DisplayName;
entity.Description = request.Description;
entity.Code = request.Code;
entity.InputSchema = request.InputSchema;
entity.OutputSchema = request.OutputSchema;
entity.TimeoutMs = request.TimeoutMs <= 0 ? 5000 : request.TimeoutMs;
// 已发布脚本被编辑后保持已发布,但递增版本号,便于调用方/审计感知变更。
if (entity.Status == ScriptStatus.Published)
{
entity.Version++;
}
await db.SaveChangesAsync(ct);
return ScriptMapper.ToDto(entity);
}
}

View File

@ -0,0 +1,7 @@
using MediatR;
using Scripting.Application.DTOs;
namespace Scripting.Application.Queries;
/// <summary>按 Id 查询脚本函数详情。</summary>
public sealed record GetScriptDetailQuery(Guid Id) : IRequest<ScriptDto>;

View File

@ -0,0 +1,13 @@
using MediatR;
using Scripting.Application.Common;
using Scripting.Application.DTOs;
using Scripting.Domain.Enums;
namespace Scripting.Application.Queries;
/// <summary>分页查询某脚本的执行日志。注意底层实体字段是 FunctionId(映射自 ScriptId)。</summary>
public sealed record GetScriptExecutionLogsQuery(
Guid ScriptId,
int PageIndex,
int PageSize,
ScriptExecutionStatus? Status = null) : IRequest<PagedResult<ScriptExecutionLogDto>>;

View File

@ -0,0 +1,14 @@
using MediatR;
using Scripting.Application.Common;
using Scripting.Application.DTOs;
using Scripting.Domain.Enums;
namespace Scripting.Application.Queries;
/// <summary>分页查询脚本函数。支持按状态、表单作用域、关键字(Name/DisplayName)筛选。</summary>
public sealed record GetScriptListQuery(
int PageIndex,
int PageSize,
ScriptStatus? Status,
string? Keyword,
Guid? FormDefinitionId = null) : IRequest<PagedResult<ScriptDto>>;