From 505c46ec05b8eda217b41a4252926a3749c5f276 Mon Sep 17 00:00:00 2001 From: xiangning Date: Tue, 7 Jul 2026 09:07:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(scripting):=20=E6=B7=BB=E5=8A=A0=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E7=B1=BB=E5=9E=8B=E5=85=83=E6=95=B0=E6=8D=AE=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E4=B8=8E=E6=8C=89=E9=92=AE=E5=8A=A8=E4=BD=9C=E9=93=BE?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 扩展 CreateBuildFlowButtonDefinitionRequest 添加 BeforeActionsJson 和 AfterActionsJson 字段 - 为自定义函数请求添加 Type、HttpMethod、HttpUrl、HttpHeaders、HttpBodyTemplate 字段 - 添加 ScriptTypeRegistry 服务用于反射扫描 BuildFlow.* 程序集中的 MediatR 类型 - 实现 GetScriptAssemblyListEndpoint 和 GetScriptTypeMetaEndpoint 两个新端点 - 添加 ScriptTypeMeta 相关 DTO 和 GetScriptTypeMetaQuery 查询处理器 - 为年份和年月字符串添加单元测试避免误判为 DateTime 类型 - 在应用启动时自动扫描并缓存脚本可用类型元数据 --- .../GetScriptAssemblyListEndpoint.cs | 24 ++ .../Scripting/GetScriptTypeMetaEndpoint.cs | 28 ++ .../Scripting/GetScriptTypeMetaRequest.cs | 17 ++ ...CreateBuildFlowButtonDefinitionEndpoint.cs | 2 + .../CreateBuildFlowButtonDefinitionRequest.cs | 4 +- .../CreateCustomFunctionEndpoint.cs | 5 + .../CreateCustomFunctionRequest.cs | 9 +- .../Application/DTOs/ScriptTypeMeta.cs | 15 ++ .../Application/DTOs/ScriptTypeMetaPage.cs | 8 + .../Queries/GetScriptAssemblyListQuery.cs | 24 ++ .../Queries/GetScriptTypeMetaQuery.cs | 24 ++ .../Application/ScriptingApplicationModule.cs | 13 + .../Services/ScriptTypeRegistry.cs | 243 ++++++++++++++++++ ...riptExecutionServiceParseVariablesTests.cs | 22 ++ .../ScriptTypeRegistryTests.cs | 182 +++++++++++++ 15 files changed, 618 insertions(+), 2 deletions(-) create mode 100644 src/BuildFlow.Host/Endpoints/Scripting/GetScriptAssemblyListEndpoint.cs create mode 100644 src/BuildFlow.Host/Endpoints/Scripting/GetScriptTypeMetaEndpoint.cs create mode 100644 src/BuildFlow.Host/Endpoints/Scripting/GetScriptTypeMetaRequest.cs create mode 100644 src/modules/scripting/Application/DTOs/ScriptTypeMeta.cs create mode 100644 src/modules/scripting/Application/DTOs/ScriptTypeMetaPage.cs create mode 100644 src/modules/scripting/Application/Queries/GetScriptAssemblyListQuery.cs create mode 100644 src/modules/scripting/Application/Queries/GetScriptTypeMetaQuery.cs create mode 100644 src/modules/scripting/Application/Services/ScriptTypeRegistry.cs create mode 100644 tests/BuildFlow.Scripting.Tests/ScriptTypeRegistryTests.cs diff --git a/src/BuildFlow.Host/Endpoints/Scripting/GetScriptAssemblyListEndpoint.cs b/src/BuildFlow.Host/Endpoints/Scripting/GetScriptAssemblyListEndpoint.cs new file mode 100644 index 0000000..6cd60e0 --- /dev/null +++ b/src/BuildFlow.Host/Endpoints/Scripting/GetScriptAssemblyListEndpoint.cs @@ -0,0 +1,24 @@ +using MediatR; +using Scripting.Application.Queries; + +namespace BuildFlow.Host.Endpoints.Scripting; + +/// +/// 获取脚本可访问的程序集白名单(调试沙箱用)。 +/// GET /scripts/meta/assemblies。需 scripts.read 权限(由 T7 种子数据授权)。 +/// +public class GetScriptAssemblyListEndpoint(IMediator mediator) + : EndpointWithoutRequest> +{ + public override void Configure() + { + Get("/scripts/meta/assemblies"); + Permissions("scripts.read"); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var result = await mediator.Send(new GetScriptAssemblyListQuery(), ct); + await Send.OkAsync(result, ct); + } +} diff --git a/src/BuildFlow.Host/Endpoints/Scripting/GetScriptTypeMetaEndpoint.cs b/src/BuildFlow.Host/Endpoints/Scripting/GetScriptTypeMetaEndpoint.cs new file mode 100644 index 0000000..a709616 --- /dev/null +++ b/src/BuildFlow.Host/Endpoints/Scripting/GetScriptTypeMetaEndpoint.cs @@ -0,0 +1,28 @@ +using MediatR; +using Scripting.Application.DTOs; +using Scripting.Application.Queries; + +namespace BuildFlow.Host.Endpoints.Scripting; + +/// +/// 获取脚本可调用的 MediatR 类型元数据(Monaco 方法面板用)。 +/// GET /scripts/meta/types?keyword=&kind=Query&pageIndex=1&pageSize=50。 +/// 需 scripts.read 权限(由 T7 种子数据授权)。 +/// +public class GetScriptTypeMetaEndpoint(IMediator mediator) + : Endpoint +{ + public override void Configure() + { + Get("/scripts/meta/types"); + Permissions("scripts.read"); + } + + public override async Task HandleAsync(GetScriptTypeMetaRequest req, CancellationToken ct) + { + var result = await mediator.Send( + new GetScriptTypeMetaQuery(req.Keyword, req.Kind, req.PageIndex, req.PageSize), + ct); + await Send.OkAsync(result, ct); + } +} diff --git a/src/BuildFlow.Host/Endpoints/Scripting/GetScriptTypeMetaRequest.cs b/src/BuildFlow.Host/Endpoints/Scripting/GetScriptTypeMetaRequest.cs new file mode 100644 index 0000000..7a41165 --- /dev/null +++ b/src/BuildFlow.Host/Endpoints/Scripting/GetScriptTypeMetaRequest.cs @@ -0,0 +1,17 @@ +namespace BuildFlow.Host.Endpoints.Scripting; + +/// 脚本类型元数据查询请求。 +public class GetScriptTypeMetaRequest +{ + /// 关键字(按类名/全名/命名空间模糊匹配,可选)。 + public string? Keyword { get; set; } + + /// 类型分类过滤:"Query" 或 "Command"(可选)。 + public string? Kind { get; set; } + + /// 页码(从 1 开始)。 + public int PageIndex { get; set; } = 1; + + /// 每页条数。 + public int PageSize { get; set; } = 50; +} diff --git a/src/BuildFlow.Host/Endpoints/workflow/BuildFlow/Buttons/CreateBuildFlowButtonDefinitionEndpoint.cs b/src/BuildFlow.Host/Endpoints/workflow/BuildFlow/Buttons/CreateBuildFlowButtonDefinitionEndpoint.cs index 57ed8f4..5a94862 100644 --- a/src/BuildFlow.Host/Endpoints/workflow/BuildFlow/Buttons/CreateBuildFlowButtonDefinitionEndpoint.cs +++ b/src/BuildFlow.Host/Endpoints/workflow/BuildFlow/Buttons/CreateBuildFlowButtonDefinitionEndpoint.cs @@ -30,7 +30,9 @@ public class CreateBuildFlowButtonDefinitionEndpoint(IMediator mediator) req.VisibleWhenJson, req.DisabledWhenJson, req.ConfirmJson, + req.BeforeActionsJson, req.ActionsJson, + req.AfterActionsJson, req.SortOrder), ct); await Send.ResponseAsync(result, 201, ct); diff --git a/src/BuildFlow.Host/Endpoints/workflow/BuildFlow/Buttons/CreateBuildFlowButtonDefinitionRequest.cs b/src/BuildFlow.Host/Endpoints/workflow/BuildFlow/Buttons/CreateBuildFlowButtonDefinitionRequest.cs index c101674..f2e32a6 100644 --- a/src/BuildFlow.Host/Endpoints/workflow/BuildFlow/Buttons/CreateBuildFlowButtonDefinitionRequest.cs +++ b/src/BuildFlow.Host/Endpoints/workflow/BuildFlow/Buttons/CreateBuildFlowButtonDefinitionRequest.cs @@ -2,7 +2,7 @@ using Workflow.Domain.Enums; namespace BuildFlow.Host.Endpoints.Workflow.BuildFlow.Buttons; -/// 创建 BuildFlow 按钮定义请求。 +/// 创建 BuildFlow 按钮定义请求。含三阶段动作链(before/main/after)。 public sealed record CreateBuildFlowButtonDefinitionRequest( Guid? ApplicationId, Guid? PageDefinitionId, @@ -16,5 +16,7 @@ public sealed record CreateBuildFlowButtonDefinitionRequest( string? VisibleWhenJson, string? DisabledWhenJson, string? ConfirmJson, + string? BeforeActionsJson, string ActionsJson, + string? AfterActionsJson, int SortOrder); diff --git a/src/BuildFlow.Host/Endpoints/workflow/BuildFlow/CustomFunctions/CreateCustomFunctionEndpoint.cs b/src/BuildFlow.Host/Endpoints/workflow/BuildFlow/CustomFunctions/CreateCustomFunctionEndpoint.cs index 65eb114..614141a 100644 --- a/src/BuildFlow.Host/Endpoints/workflow/BuildFlow/CustomFunctions/CreateCustomFunctionEndpoint.cs +++ b/src/BuildFlow.Host/Endpoints/workflow/BuildFlow/CustomFunctions/CreateCustomFunctionEndpoint.cs @@ -21,7 +21,12 @@ public class CreateCustomFunctionEndpoint(IMediator mediator) req.Name, req.DisplayName, req.Description, + req.Type, req.Code, + req.HttpMethod, + req.HttpUrl, + req.HttpHeaders, + req.HttpBodyTemplate, req.InputSchema, req.OutputSchema, req.TimeoutMs, diff --git a/src/BuildFlow.Host/Endpoints/workflow/BuildFlow/CustomFunctions/CreateCustomFunctionRequest.cs b/src/BuildFlow.Host/Endpoints/workflow/BuildFlow/CustomFunctions/CreateCustomFunctionRequest.cs index cb19326..15f2caf 100644 --- a/src/BuildFlow.Host/Endpoints/workflow/BuildFlow/CustomFunctions/CreateCustomFunctionRequest.cs +++ b/src/BuildFlow.Host/Endpoints/workflow/BuildFlow/CustomFunctions/CreateCustomFunctionRequest.cs @@ -1,12 +1,19 @@ +using Workflow.Domain.Enums; + namespace BuildFlow.Host.Endpoints.Workflow.BuildFlow.CustomFunctions; -/// 创建自定义函数请求。 +/// 创建自定义函数请求。Type=Script 使用 Code;Type=Http 使用 HttpMethod/Url/Headers/BodyTemplate。 public sealed record CreateCustomFunctionRequest( Guid? FormDefinitionId, string Name, string DisplayName, string? Description, + CustomFunctionType Type, string Code, + string? HttpMethod, + string? HttpUrl, + string? HttpHeaders, + string? HttpBodyTemplate, string? InputSchema, string? OutputSchema, int TimeoutMs); diff --git a/src/modules/scripting/Application/DTOs/ScriptTypeMeta.cs b/src/modules/scripting/Application/DTOs/ScriptTypeMeta.cs new file mode 100644 index 0000000..084b5f7 --- /dev/null +++ b/src/modules/scripting/Application/DTOs/ScriptTypeMeta.cs @@ -0,0 +1,15 @@ +namespace Scripting.Application.DTOs; + +/// 一个可被脚本调用的 MediatR request 类型元数据。供 Monaco 方法面板展示。 +public sealed record ScriptTypeMeta( + string FullName, // e.g. "Platform.Application.Users.Queries.GetUserQuery" + string Name, // e.g. "GetUserQuery" + string Namespace, + string Assembly, + string Kind, // "Query" 或 "Command" + string? ResponseType, // 返回类型友好名(如 "UserDto");null 表示返回 Unit + IReadOnlyList Properties, + string? Description); // XML doc summary(v1 恒为 null,预留) + +/// 脚本类型属性元数据:属性名 + 友好类型名。 +public sealed record ScriptPropertyMeta(string Name, string Type); diff --git a/src/modules/scripting/Application/DTOs/ScriptTypeMetaPage.cs b/src/modules/scripting/Application/DTOs/ScriptTypeMetaPage.cs new file mode 100644 index 0000000..49b57ad --- /dev/null +++ b/src/modules/scripting/Application/DTOs/ScriptTypeMetaPage.cs @@ -0,0 +1,8 @@ +namespace Scripting.Application.DTOs; + +/// 脚本类型元数据分页结果。 +public sealed record ScriptTypeMetaPage( + List Items, + int Total, + int PageIndex, + int PageSize); diff --git a/src/modules/scripting/Application/Queries/GetScriptAssemblyListQuery.cs b/src/modules/scripting/Application/Queries/GetScriptAssemblyListQuery.cs new file mode 100644 index 0000000..cbed6b7 --- /dev/null +++ b/src/modules/scripting/Application/Queries/GetScriptAssemblyListQuery.cs @@ -0,0 +1,24 @@ +using MediatR; +using Scripting.Application.Services; + +namespace Scripting.Application.Queries; + +/// +/// 查询脚本可访问的程序集白名单(用于调试沙箱:脚本里 using 的类型必须来自这些程序集)。 +/// 返回已扫描到的所有 BuildFlow.*(非 Tests/Infrastructure)程序集名的去重列表。 +/// +public record GetScriptAssemblyListQuery : IRequest>; + +public class GetScriptAssemblyListQueryHandler(ScriptTypeRegistry registry) + : IRequestHandler> +{ + public Task> Handle(GetScriptAssemblyListQuery req, CancellationToken ct) + { + var assemblies = registry.GetAll() + .Select(m => m.Assembly) + .Distinct() + .OrderBy(a => a) + .ToList(); + return Task.FromResult(assemblies); + } +} diff --git a/src/modules/scripting/Application/Queries/GetScriptTypeMetaQuery.cs b/src/modules/scripting/Application/Queries/GetScriptTypeMetaQuery.cs new file mode 100644 index 0000000..285c65c --- /dev/null +++ b/src/modules/scripting/Application/Queries/GetScriptTypeMetaQuery.cs @@ -0,0 +1,24 @@ +using MediatR; +using Scripting.Application.DTOs; +using Scripting.Application.Services; + +namespace Scripting.Application.Queries; + +/// 查询可用脚本类型元数据(Monaco 方法面板用)。支持关键字、Kind 过滤与分页。 +public record GetScriptTypeMetaQuery( + string? Keyword, + string? Kind, + int PageIndex = 1, + int PageSize = 50) : IRequest; + +/// 查询处理器:委托 做过滤/分页。 +public class GetScriptTypeMetaQueryHandler(ScriptTypeRegistry registry) + : IRequestHandler +{ + public Task Handle(GetScriptTypeMetaQuery req, CancellationToken ct) + { + var items = registry.Search(req.Keyword, req.Kind, req.PageIndex, req.PageSize).ToList(); + var total = registry.Total(req.Keyword, req.Kind); + return Task.FromResult(new ScriptTypeMetaPage(items, total, req.PageIndex, req.PageSize)); + } +} diff --git a/src/modules/scripting/Application/ScriptingApplicationModule.cs b/src/modules/scripting/Application/ScriptingApplicationModule.cs index 8a8ab54..61c7b91 100644 --- a/src/modules/scripting/Application/ScriptingApplicationModule.cs +++ b/src/modules/scripting/Application/ScriptingApplicationModule.cs @@ -1,7 +1,9 @@ using BuildFlow.Platform.Application; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Scripting.Application.Services; using Scripting.Infrastructure; +using Volo.Abp; using Volo.Abp.Modularity; namespace Scripting.Application; @@ -19,5 +21,16 @@ public class ScriptingApplicationModule : AbpModule services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly)); services.AddScoped(); + // 脚本类型注册表:内存缓存,单例。启动时扫描一次(见 OnApplicationInitialization)。 + services.TryAddSingleton(); + } + + /// + /// 应用启动后触发一次反射扫描,填充 缓存。 + /// 此时所有业务模块的程序集已加载,扫描可覆盖完整的 BuildFlow.* IBaseRequest 类型集合。 + /// + public override void OnApplicationInitialization(ApplicationInitializationContext context) + { + context.ServiceProvider.GetRequiredService().Scan(); } } diff --git a/src/modules/scripting/Application/Services/ScriptTypeRegistry.cs b/src/modules/scripting/Application/Services/ScriptTypeRegistry.cs new file mode 100644 index 0000000..4f84795 --- /dev/null +++ b/src/modules/scripting/Application/Services/ScriptTypeRegistry.cs @@ -0,0 +1,243 @@ +using System.Collections.Concurrent; +using System.Reflection; +using MediatR; +using Scripting.Application.DTOs; + +namespace Scripting.Application.Services; + +/// +/// 脚本类型注册表:启动时反射扫描所有 BuildFlow.* 程序集中实现 的类型, +/// 缓存其元数据供 Monaco 方法面板使用。扫描结果缓存到内存,支持手动刷新。 +/// 单例,应用启动时由模块 OnApplicationInitialization 触发一次扫描。 +/// +public class ScriptTypeRegistry +{ + private readonly ConcurrentDictionary _types = new(); + + /// 执行(或刷新)扫描。幂等:重复调用会重建字典。 + public void Scan() + { + var found = new List(); + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + var name = asm.GetName().Name ?? string.Empty; + if (!name.StartsWith("BuildFlow", StringComparison.Ordinal)) continue; + // 排除测试与基础设施程序集:脚本沙箱内无法引用这些类型,列出它们只会误导用户 + if (name.EndsWith(".Tests", StringComparison.Ordinal)) continue; + if (name.EndsWith(".Test", StringComparison.Ordinal)) continue; + if (name.EndsWith(".Infrastructure", StringComparison.Ordinal)) continue; + + Type[] types; + try { types = asm.GetTypes(); } + catch (ReflectionTypeLoadException) { continue; } // 跳过无法加载的程序集 + + foreach (var t in types) + { + if (!t.IsClass) continue; + if (t.IsAbstract) continue; + if (t.IsGenericTypeDefinition) continue; + if (!typeof(IBaseRequest).IsAssignableFrom(t)) continue; + + found.Add(BuildMeta(t)); + } + } + + // 原子替换:清空后重新填充。Scan 非并发触发(仅启动 + 手动刷新),此处无需细粒度锁。 + _types.Clear(); + foreach (var m in found) _types[m.FullName] = m; + } + + private static ScriptTypeMeta BuildMeta(Type type) + { + var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && p.CanWrite) + .Select(p => new ScriptPropertyMeta(p.Name, FormatTypeName(p.PropertyType))) + .ToList(); + + var kind = InferKind(type); + return new ScriptTypeMeta( + type.FullName ?? type.Name, + type.Name, + type.Namespace ?? string.Empty, + type.Assembly.GetName().Name ?? string.Empty, + kind, + GetResponseType(type), // IRequest 的 T;null 表示返回 Unit + props, + GetXmlDocSummary(type)); + } + + /// + /// 推断类型是 Query 还是 Command。 + /// 启发式:类名以 Get/List/Search/Count/Query 开头或以 Query 结尾 → Query;否则 Command。 + /// 不要求精确——前端面板按 Kind 分组,误判只影响分组归类。 + /// + private static string InferKind(Type type) + { + var n = type.Name; + if (n.StartsWith("Get", StringComparison.Ordinal) + || n.StartsWith("List", StringComparison.Ordinal) + || n.StartsWith("Search", StringComparison.Ordinal) + || n.StartsWith("Count", StringComparison.Ordinal) + || n.StartsWith("Query", StringComparison.Ordinal) + || n.EndsWith("Query", StringComparison.Ordinal)) + { + return "Query"; + } + return "Command"; + } + + /// + /// 提取 MediatR 响应类型(IRequest<T> 的 T)。非泛型 IRequest 返回 null(语义为 Unit)。 + /// + private static string? GetResponseType(Type type) + { + foreach (var iface in type.GetInterfaces()) + { + if (iface.IsGenericType + && iface.GetGenericTypeDefinition() == typeof(IRequest<>)) + { + return FormatTypeName(iface.GetGenericArguments()[0]); + } + } + return null; // IRequest(非泛型)返回 Unit,统一记为 null + } + + /// + /// 将一个 格式化为脚本友好的可读字符串: + /// 基元类型用 C# 关键字(int/string/bool…);泛型用 List<T>/Dictionary<K,V>; + /// 数组用 T[];可空用 T?;其余用命名空间限定名。 + /// + internal static string FormatTypeName(Type? t) + { + if (t is null) return "?"; + + // Nullable → "T?" + var nullableUnderlying = Nullable.GetUnderlyingType(t); + if (nullableUnderlying is not null) + { + return FormatTypeName(nullableUnderlying) + "?"; + } + + // by-ref / pointer 不应出现在脚本签名里,退化为元素名 + if (t.IsByRef) return FormatTypeName(t.GetElementType()); + if (t.IsPointer) return FormatTypeName(t.GetElementType()); + + // 数组(含多维)→ "T[]"(多维退化为单维展示,避免 T[?,?] 这类对脚本无意义的记号) + if (t.IsArray) + { + var elem = t.GetElementType(); + return FormatTypeName(elem) + "[]"; + } + + // 基元/已知类型:用 C# 关键字或常用短名 + if (TryPrimitiveAlias(t, out var alias)) + { + return alias; + } + + // 泛型实例:List、Dictionary、IReadOnlyList 等 + if (t.IsGenericType) + { + var def = t.GetGenericTypeDefinition(); + var args = t.GetGenericArguments(); + var argsFormatted = string.Join(", ", args.Select(FormatTypeName)); + + // 值元组 (T1,T2,...) + if (def.FullName?.StartsWith("System.ValueTuple`", StringComparison.Ordinal) == true) + { + return "(" + argsFormatted + ")"; + } + // Nullable 理论上已被上面拦截,防御性处理 + if (def == typeof(Nullable<>)) + { + return argsFormatted + "?"; + } + + // 取泛型定义的短名,去掉 `N 后缀 + var name = def.Name; + var tick = name.IndexOf('`'); + if (tick >= 0) name = name[..tick]; + return $"{name}<{argsFormatted}>"; + } + + // 普通类型:优先用命名空间限定名,回退到短名 + return !string.IsNullOrEmpty(t.FullName) ? t.FullName : t.Name; + } + + /// 常见基元/系统类型到 C# 友好名的映射。命中返回 true。 + private static bool TryPrimitiveAlias(Type t, out string alias) + { + // 命中频率最高的内联快表 + switch (Type.GetTypeCode(t)) + { + case TypeCode.Boolean: alias = "bool"; return true; + case TypeCode.Byte: alias = "byte"; return true; + case TypeCode.SByte: alias = "sbyte"; return true; + case TypeCode.Int16: alias = "short"; return true; + case TypeCode.UInt16: alias = "ushort"; return true; + case TypeCode.Int32: alias = "int"; return true; + case TypeCode.UInt32: alias = "uint"; return true; + case TypeCode.Int64: alias = "long"; return true; + case TypeCode.UInt64: alias = "ulong"; return true; + case TypeCode.Single: alias = "float"; return true; + case TypeCode.Double: alias = "double"; return true; + case TypeCode.Decimal: alias = "decimal"; return true; + case TypeCode.Char: alias = "char"; return true; + case TypeCode.String: alias = "string"; return true; + case TypeCode.Object: + default: + break; + } + + switch (t.FullName) + { + case "System.Object": alias = "object"; return true; + case "System.IntPtr": alias = "IntPtr"; return true; + case "System.UIntPtr": alias = "UIntPtr"; return true; + case "System.DateTime": alias = "DateTime"; return true; + case "System.DateTimeOffset": alias = "DateTimeOffset"; return true; + case "System.TimeSpan": alias = "TimeSpan"; return true; + case "System.Guid": alias = "Guid"; return true; + case "System.DateOnly": alias = "DateOnly"; return true; + case "System.TimeOnly": alias = "TimeOnly"; return true; + default: + alias = string.Empty; + return false; + } + } + + /// XML 文档摘要(v1 不加载 doc XML,恒返回 null,预留扩展点)。 + private static string? GetXmlDocSummary(Type type) => null; + + /// 返回所有已扫描的类型元数据(无序快照)。 + public IReadOnlyCollection GetAll() => _types.Values.ToArray(); + + /// 带过滤 + 分页的搜索。匹配关键字(名/全名/命名空间)与 Kind。 + public IEnumerable Search(string? keyword, string? kind, int pageIndex, int pageSize) + { + var q = ApplyFilter(_types.Values, keyword, kind); + return q.OrderBy(m => m.Namespace).ThenBy(m => m.Name) + .Skip((pageIndex - 1) * pageSize) + .Take(pageSize); + } + + /// 当前过滤条件下的总数(用于分页)。 + public int Total(string? keyword, string? kind) + => ApplyFilter(_types.Values, keyword, kind).Count(); + + private static IEnumerable ApplyFilter( + IEnumerable source, string? keyword, string? kind) + { + var q = source; + if (!string.IsNullOrWhiteSpace(kind)) + q = q.Where(m => m.Kind.Equals(kind, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(keyword)) + { + var kw = keyword.Trim(); + q = q.Where(m => m.Name.Contains(kw, StringComparison.OrdinalIgnoreCase) + || m.FullName.Contains(kw, StringComparison.OrdinalIgnoreCase) + || m.Namespace.Contains(kw, StringComparison.OrdinalIgnoreCase)); + } + return q; + } +} diff --git a/tests/BuildFlow.Scripting.Tests/ScriptExecutionServiceParseVariablesTests.cs b/tests/BuildFlow.Scripting.Tests/ScriptExecutionServiceParseVariablesTests.cs index 6567e4e..80e11c5 100644 --- a/tests/BuildFlow.Scripting.Tests/ScriptExecutionServiceParseVariablesTests.cs +++ b/tests/BuildFlow.Scripting.Tests/ScriptExecutionServiceParseVariablesTests.cs @@ -97,6 +97,28 @@ public class ScriptExecutionServiceParseVariablesTests vars["ts"].Should().BeOfType(); } + /// + /// 年份串 "2026" 不应误判为 DateTime(会误伤客户/订单 ID 等数字样字符串)。 + /// + [Fact] + public void Year_Only_String_Remains_String() + { + var vars = Parse("{\"year\": \"2026\"}"); + + vars["year"].Should().BeOfType().Which.Should().Be("2026"); + } + + /// + /// 年月串 "2026-07" 不应误判为 DateTime(缺少日期分隔,非完整 ISO-8601)。 + /// + [Fact] + public void Year_Month_String_Remains_String() + { + var vars = Parse("{\"period\": \"2026-07\"}"); + + vars["period"].Should().BeOfType().Which.Should().Be("2026-07"); + } + [Fact] public void Object_Maps_To_JsonElement() { diff --git a/tests/BuildFlow.Scripting.Tests/ScriptTypeRegistryTests.cs b/tests/BuildFlow.Scripting.Tests/ScriptTypeRegistryTests.cs new file mode 100644 index 0000000..9a37f6f --- /dev/null +++ b/tests/BuildFlow.Scripting.Tests/ScriptTypeRegistryTests.cs @@ -0,0 +1,182 @@ +using System.Collections.Generic; +using FluentAssertions; +using Scripting.Application.DTOs; +using Scripting.Application.Services; +using Xunit; + +namespace BuildFlow.Scripting.Tests; + +/// +/// 单元测试:反射扫描、关键字/Kind 过滤、分页与类型格式化。 +/// 扫描依赖测试运行时加载的 BuildFlow.* 程序集;本测试项目通过 Scripting.Application +/// 传递引用了 Platform.Application,其 Query 类型应被扫描到。 +/// +public class ScriptTypeRegistryTests +{ + private readonly ScriptTypeRegistry _registry = new(); + + public ScriptTypeRegistryTests() + { + // 测试构造时扫描一次,保证后续断言基于已填充状态 + _registry.Scan(); + } + + /// 扫描应至少发现一个 BuildFlow.* IBaseRequest 实现(来自 Platform.Application)。 + [Fact] + public void Scan_Populates_Known_MediatR_Types() + { + var all = _registry.GetAll(); + all.Should().NotBeEmpty("Platform.Application 含大量 IRequest 实现,扫描后应非空"); + + // 抽样:应能找到带 Query/Command 后缀的类型(启发式分类的输入) + all.Should().Contain(m => m.Kind == "Query" || m.Kind == "Command"); + } + + /// 关键字搜索应按 名/全名/命名空间 模糊过滤。 + [Fact] + public void Search_By_Keyword_Filters() + { + var all = _registry.GetAll(); + all.Should().NotBeEmpty(); + + // 任取一个类型的名字片段作为关键字,搜索结果必须全部命中该关键字 + var keyword = PickProbe(all); + var results = _registry.Search(keyword, kind: null, pageIndex: 1, pageSize: 100).ToList(); + + results.Should().NotBeEmpty($"关键字 '{keyword}' 来源于已扫描类型"); + foreach (var m in results) + { + var hit = m.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase) + || m.FullName.Contains(keyword, StringComparison.OrdinalIgnoreCase) + || m.Namespace.Contains(keyword, StringComparison.OrdinalIgnoreCase); + hit.Should().BeTrue($"结果 '{m.FullName}' 应匹配关键字 '{keyword}'"); + } + } + + /// Kind=Query 过滤后,结果应全部是 Query;Total 计数应与分页前一致。 + [Fact] + public void Search_By_Kind_Query_Filters() + { + if (_registry.GetAll().Count == 0) + { + // 没有类型可过滤时跳过(不应失败) + return; + } + + var queryTotal = _registry.Total(keyword: null, kind: "Query"); + var queryPage = _registry.Search(keyword: null, kind: "Query", pageIndex: 1, pageSize: 1000).ToList(); + + queryPage.Count.Should().Be(queryTotal, "Total 与 Search(不限页大小)计数应一致"); + queryPage.Should().AllSatisfy(m => m.Kind.Should().Be("Query")); + queryTotal.Should().BeGreaterThan(0, "Platform.Application 至少含一个 Query"); + } + + /// 分页:第二页应正确跳过第一页条数。 + [Fact] + public void Search_Paginates() + { + var total = _registry.Total(keyword: null, kind: null); + if (total < 2) return; + + var firstPageFirst = _registry.Search(null, null, pageIndex: 1, pageSize: 1).First(); + var secondPageFirst = _registry.Search(null, null, pageIndex: 2, pageSize: 1).First(); + firstPageFirst.FullName.Should().NotBe(secondPageFirst.FullName, "翻页后首页首项应不同于第二页首项"); + } + + /// ResponseType 字段:IRequest<T> 实现应能解析出 T 的友好名。 + [Fact] + public void BuildMeta_Extracts_Response_Type() + { + var all = _registry.GetAll(); + all.Should().Contain(m => m.ResponseType is not null, + "至少一个类型实现了 IRequest,应解析出非空 ResponseType"); + } + + /// 重复扫描是幂等的:再次扫描后总数不变。 + [Fact] + public void Scan_Is_Idempotent() + { + var before = _registry.GetAll().Count; + _registry.Scan(); + var after = _registry.GetAll().Count; + after.Should().Be(before, "重复扫描不应改变类型集合"); + } + + // -------- FormatTypeName(internal static,经 InternalsVisibleTo 暴露) -------- + + [Theory] + [InlineData(typeof(int), "int")] + [InlineData(typeof(string), "string")] + [InlineData(typeof(bool), "bool")] + [InlineData(typeof(long), "long")] + [InlineData(typeof(decimal), "decimal")] + [InlineData(typeof(double), "double")] + [InlineData(typeof(Guid), "Guid")] + [InlineData(typeof(DateTime), "DateTime")] + [InlineData(typeof(object), "object")] + public void FormatTypeName_Primitives_Use_CSharp_Alias(Type type, string expected) + { + ScriptTypeRegistry.FormatTypeName(type).Should().Be(expected); + } + + [Fact] + public void FormatTypeName_Null_Returns_Question_Mark() + { + ScriptTypeRegistry.FormatTypeName(null).Should().Be("?"); + } + + [Fact] + public void FormatTypeName_Nullable_Primitive_Appends_Question_Mark() + { + ScriptTypeRegistry.FormatTypeName(typeof(int?)).Should().Be("int?"); + ScriptTypeRegistry.FormatTypeName(typeof(Guid?)).Should().Be("Guid?"); + ScriptTypeRegistry.FormatTypeName(typeof(DateTime?)).Should().Be("DateTime?"); + } + + [Fact] + public void FormatTypeName_Generic_Collection_Formats_With_Angle_Brackets() + { + ScriptTypeRegistry.FormatTypeName(typeof(List)).Should().Be("List"); + ScriptTypeRegistry.FormatTypeName(typeof(IEnumerable)).Should().Be("IEnumerable"); + ScriptTypeRegistry.FormatTypeName(typeof(IReadOnlyList)).Should().Be("IReadOnlyList"); + } + + [Fact] + public void FormatTypeName_Dictionary_Formats_Two_Type_Arguments() + { + ScriptTypeRegistry.FormatTypeName(typeof(Dictionary)) + .Should().Be("Dictionary"); + } + + [Fact] + public void FormatTypeName_Nested_Generic_Formats_Recursively() + { + ScriptTypeRegistry.FormatTypeName(typeof(List>)) + .Should().Be("List>"); + } + + [Fact] + public void FormatTypeName_Array_Appends_Brackets() + { + ScriptTypeRegistry.FormatTypeName(typeof(int[])).Should().Be("int[]"); + ScriptTypeRegistry.FormatTypeName(typeof(string[])).Should().Be("string[]"); + ScriptTypeRegistry.FormatTypeName(typeof(List[])).Should().Be("List[]"); + } + + [Fact] + public void FormatTypeName_Complex_Type_Uses_FullName() + { + ScriptTypeRegistry.FormatTypeName(typeof(System.Text.Json.JsonElement)) + .Should().Be("System.Text.Json.JsonElement"); + } + + // -------- 辅助 -------- + + /// 从已扫描集合中挑一个适合做关键字探测的样本(取名字的子串,避免取到单字符)。 + private static string PickProbe(IReadOnlyCollection all) + { + // 选一个名字至少 4 字符的类型,截取前 4 字符作为关键字,保证可复现命中 + var pick = all.First(m => m.Name.Length >= 4); + return pick.Name[..4]; + } +}