feat(scripting): 添加脚本类型元数据服务与按钮动作链功能
- 扩展 CreateBuildFlowButtonDefinitionRequest 添加 BeforeActionsJson 和 AfterActionsJson 字段 - 为自定义函数请求添加 Type、HttpMethod、HttpUrl、HttpHeaders、HttpBodyTemplate 字段 - 添加 ScriptTypeRegistry 服务用于反射扫描 BuildFlow.* 程序集中的 MediatR 类型 - 实现 GetScriptAssemblyListEndpoint 和 GetScriptTypeMetaEndpoint 两个新端点 - 添加 ScriptTypeMeta 相关 DTO 和 GetScriptTypeMetaQuery 查询处理器 - 为年份和年月字符串添加单元测试避免误判为 DateTime 类型 - 在应用启动时自动扫描并缓存脚本可用类型元数据
This commit is contained in:
parent
bec5bfd0dd
commit
505c46ec05
@ -0,0 +1,24 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Scripting.Application.Queries;
|
||||||
|
|
||||||
|
namespace BuildFlow.Host.Endpoints.Scripting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取脚本可访问的程序集白名单(调试沙箱用)。
|
||||||
|
/// GET /scripts/meta/assemblies。需 scripts.read 权限(由 T7 种子数据授权)。
|
||||||
|
/// </summary>
|
||||||
|
public class GetScriptAssemblyListEndpoint(IMediator mediator)
|
||||||
|
: EndpointWithoutRequest<List<string>>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Scripting.Application.DTOs;
|
||||||
|
using Scripting.Application.Queries;
|
||||||
|
|
||||||
|
namespace BuildFlow.Host.Endpoints.Scripting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取脚本可调用的 MediatR 类型元数据(Monaco 方法面板用)。
|
||||||
|
/// GET /scripts/meta/types?keyword=&kind=Query&pageIndex=1&pageSize=50。
|
||||||
|
/// 需 scripts.read 权限(由 T7 种子数据授权)。
|
||||||
|
/// </summary>
|
||||||
|
public class GetScriptTypeMetaEndpoint(IMediator mediator)
|
||||||
|
: Endpoint<GetScriptTypeMetaRequest, ScriptTypeMetaPage>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
namespace BuildFlow.Host.Endpoints.Scripting;
|
||||||
|
|
||||||
|
/// <summary>脚本类型元数据查询请求。</summary>
|
||||||
|
public class GetScriptTypeMetaRequest
|
||||||
|
{
|
||||||
|
/// <summary>关键字(按类名/全名/命名空间模糊匹配,可选)。</summary>
|
||||||
|
public string? Keyword { get; set; }
|
||||||
|
|
||||||
|
/// <summary>类型分类过滤:"Query" 或 "Command"(可选)。</summary>
|
||||||
|
public string? Kind { get; set; }
|
||||||
|
|
||||||
|
/// <summary>页码(从 1 开始)。</summary>
|
||||||
|
public int PageIndex { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>每页条数。</summary>
|
||||||
|
public int PageSize { get; set; } = 50;
|
||||||
|
}
|
||||||
@ -30,7 +30,9 @@ public class CreateBuildFlowButtonDefinitionEndpoint(IMediator mediator)
|
|||||||
req.VisibleWhenJson,
|
req.VisibleWhenJson,
|
||||||
req.DisabledWhenJson,
|
req.DisabledWhenJson,
|
||||||
req.ConfirmJson,
|
req.ConfirmJson,
|
||||||
|
req.BeforeActionsJson,
|
||||||
req.ActionsJson,
|
req.ActionsJson,
|
||||||
|
req.AfterActionsJson,
|
||||||
req.SortOrder),
|
req.SortOrder),
|
||||||
ct);
|
ct);
|
||||||
await Send.ResponseAsync(result, 201, ct);
|
await Send.ResponseAsync(result, 201, ct);
|
||||||
|
|||||||
@ -2,7 +2,7 @@ using Workflow.Domain.Enums;
|
|||||||
|
|
||||||
namespace BuildFlow.Host.Endpoints.Workflow.BuildFlow.Buttons;
|
namespace BuildFlow.Host.Endpoints.Workflow.BuildFlow.Buttons;
|
||||||
|
|
||||||
/// <summary>创建 BuildFlow 按钮定义请求。</summary>
|
/// <summary>创建 BuildFlow 按钮定义请求。含三阶段动作链(before/main/after)。</summary>
|
||||||
public sealed record CreateBuildFlowButtonDefinitionRequest(
|
public sealed record CreateBuildFlowButtonDefinitionRequest(
|
||||||
Guid? ApplicationId,
|
Guid? ApplicationId,
|
||||||
Guid? PageDefinitionId,
|
Guid? PageDefinitionId,
|
||||||
@ -16,5 +16,7 @@ public sealed record CreateBuildFlowButtonDefinitionRequest(
|
|||||||
string? VisibleWhenJson,
|
string? VisibleWhenJson,
|
||||||
string? DisabledWhenJson,
|
string? DisabledWhenJson,
|
||||||
string? ConfirmJson,
|
string? ConfirmJson,
|
||||||
|
string? BeforeActionsJson,
|
||||||
string ActionsJson,
|
string ActionsJson,
|
||||||
|
string? AfterActionsJson,
|
||||||
int SortOrder);
|
int SortOrder);
|
||||||
|
|||||||
@ -21,7 +21,12 @@ public class CreateCustomFunctionEndpoint(IMediator mediator)
|
|||||||
req.Name,
|
req.Name,
|
||||||
req.DisplayName,
|
req.DisplayName,
|
||||||
req.Description,
|
req.Description,
|
||||||
|
req.Type,
|
||||||
req.Code,
|
req.Code,
|
||||||
|
req.HttpMethod,
|
||||||
|
req.HttpUrl,
|
||||||
|
req.HttpHeaders,
|
||||||
|
req.HttpBodyTemplate,
|
||||||
req.InputSchema,
|
req.InputSchema,
|
||||||
req.OutputSchema,
|
req.OutputSchema,
|
||||||
req.TimeoutMs,
|
req.TimeoutMs,
|
||||||
|
|||||||
@ -1,12 +1,19 @@
|
|||||||
|
using Workflow.Domain.Enums;
|
||||||
|
|
||||||
namespace BuildFlow.Host.Endpoints.Workflow.BuildFlow.CustomFunctions;
|
namespace BuildFlow.Host.Endpoints.Workflow.BuildFlow.CustomFunctions;
|
||||||
|
|
||||||
/// <summary>创建自定义函数请求。</summary>
|
/// <summary>创建自定义函数请求。Type=Script 使用 Code;Type=Http 使用 HttpMethod/Url/Headers/BodyTemplate。</summary>
|
||||||
public sealed record CreateCustomFunctionRequest(
|
public sealed record CreateCustomFunctionRequest(
|
||||||
Guid? FormDefinitionId,
|
Guid? FormDefinitionId,
|
||||||
string Name,
|
string Name,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string? Description,
|
string? Description,
|
||||||
|
CustomFunctionType Type,
|
||||||
string Code,
|
string Code,
|
||||||
|
string? HttpMethod,
|
||||||
|
string? HttpUrl,
|
||||||
|
string? HttpHeaders,
|
||||||
|
string? HttpBodyTemplate,
|
||||||
string? InputSchema,
|
string? InputSchema,
|
||||||
string? OutputSchema,
|
string? OutputSchema,
|
||||||
int TimeoutMs);
|
int TimeoutMs);
|
||||||
|
|||||||
15
src/modules/scripting/Application/DTOs/ScriptTypeMeta.cs
Normal file
15
src/modules/scripting/Application/DTOs/ScriptTypeMeta.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
namespace Scripting.Application.DTOs;
|
||||||
|
|
||||||
|
/// <summary>一个可被脚本调用的 MediatR request 类型元数据。供 Monaco 方法面板展示。</summary>
|
||||||
|
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<ScriptPropertyMeta> Properties,
|
||||||
|
string? Description); // XML doc summary(v1 恒为 null,预留)
|
||||||
|
|
||||||
|
/// <summary>脚本类型属性元数据:属性名 + 友好类型名。</summary>
|
||||||
|
public sealed record ScriptPropertyMeta(string Name, string Type);
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
namespace Scripting.Application.DTOs;
|
||||||
|
|
||||||
|
/// <summary>脚本类型元数据分页结果。</summary>
|
||||||
|
public sealed record ScriptTypeMetaPage(
|
||||||
|
List<ScriptTypeMeta> Items,
|
||||||
|
int Total,
|
||||||
|
int PageIndex,
|
||||||
|
int PageSize);
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Scripting.Application.Services;
|
||||||
|
|
||||||
|
namespace Scripting.Application.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询脚本可访问的程序集白名单(用于调试沙箱:脚本里 using 的类型必须来自这些程序集)。
|
||||||
|
/// 返回已扫描到的所有 BuildFlow.*(非 Tests/Infrastructure)程序集名的去重列表。
|
||||||
|
/// </summary>
|
||||||
|
public record GetScriptAssemblyListQuery : IRequest<List<string>>;
|
||||||
|
|
||||||
|
public class GetScriptAssemblyListQueryHandler(ScriptTypeRegistry registry)
|
||||||
|
: IRequestHandler<GetScriptAssemblyListQuery, List<string>>
|
||||||
|
{
|
||||||
|
public Task<List<string>> Handle(GetScriptAssemblyListQuery req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var assemblies = registry.GetAll()
|
||||||
|
.Select(m => m.Assembly)
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(a => a)
|
||||||
|
.ToList();
|
||||||
|
return Task.FromResult(assemblies);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Scripting.Application.DTOs;
|
||||||
|
using Scripting.Application.Services;
|
||||||
|
|
||||||
|
namespace Scripting.Application.Queries;
|
||||||
|
|
||||||
|
/// <summary>查询可用脚本类型元数据(Monaco 方法面板用)。支持关键字、Kind 过滤与分页。</summary>
|
||||||
|
public record GetScriptTypeMetaQuery(
|
||||||
|
string? Keyword,
|
||||||
|
string? Kind,
|
||||||
|
int PageIndex = 1,
|
||||||
|
int PageSize = 50) : IRequest<ScriptTypeMetaPage>;
|
||||||
|
|
||||||
|
/// <summary>查询处理器:委托 <see cref="ScriptTypeRegistry"/> 做过滤/分页。</summary>
|
||||||
|
public class GetScriptTypeMetaQueryHandler(ScriptTypeRegistry registry)
|
||||||
|
: IRequestHandler<GetScriptTypeMetaQuery, ScriptTypeMetaPage>
|
||||||
|
{
|
||||||
|
public Task<ScriptTypeMetaPage> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,9 @@
|
|||||||
using BuildFlow.Platform.Application;
|
using BuildFlow.Platform.Application;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Scripting.Application.Services;
|
using Scripting.Application.Services;
|
||||||
using Scripting.Infrastructure;
|
using Scripting.Infrastructure;
|
||||||
|
using Volo.Abp;
|
||||||
using Volo.Abp.Modularity;
|
using Volo.Abp.Modularity;
|
||||||
|
|
||||||
namespace Scripting.Application;
|
namespace Scripting.Application;
|
||||||
@ -19,5 +21,16 @@ public class ScriptingApplicationModule : AbpModule
|
|||||||
|
|
||||||
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
|
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
|
||||||
services.AddScoped<IScriptExecutionService, ScriptExecutionService>();
|
services.AddScoped<IScriptExecutionService, ScriptExecutionService>();
|
||||||
|
// 脚本类型注册表:内存缓存,单例。启动时扫描一次(见 OnApplicationInitialization)。
|
||||||
|
services.TryAddSingleton<ScriptTypeRegistry>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 应用启动后触发一次反射扫描,填充 <see cref="ScriptTypeRegistry"/> 缓存。
|
||||||
|
/// 此时所有业务模块的程序集已加载,扫描可覆盖完整的 BuildFlow.* IBaseRequest 类型集合。
|
||||||
|
/// </summary>
|
||||||
|
public override void OnApplicationInitialization(ApplicationInitializationContext context)
|
||||||
|
{
|
||||||
|
context.ServiceProvider.GetRequiredService<ScriptTypeRegistry>().Scan();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
243
src/modules/scripting/Application/Services/ScriptTypeRegistry.cs
Normal file
243
src/modules/scripting/Application/Services/ScriptTypeRegistry.cs
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Reflection;
|
||||||
|
using MediatR;
|
||||||
|
using Scripting.Application.DTOs;
|
||||||
|
|
||||||
|
namespace Scripting.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 脚本类型注册表:启动时反射扫描所有 BuildFlow.* 程序集中实现 <see cref="IBaseRequest"/> 的类型,
|
||||||
|
/// 缓存其元数据供 Monaco 方法面板使用。扫描结果缓存到内存,支持手动刷新。
|
||||||
|
/// 单例,应用启动时由模块 <c>OnApplicationInitialization</c> 触发一次扫描。
|
||||||
|
/// </summary>
|
||||||
|
public class ScriptTypeRegistry
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, ScriptTypeMeta> _types = new();
|
||||||
|
|
||||||
|
/// <summary>执行(或刷新)扫描。幂等:重复调用会重建字典。</summary>
|
||||||
|
public void Scan()
|
||||||
|
{
|
||||||
|
var found = new List<ScriptTypeMeta>();
|
||||||
|
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> 的 T;null 表示返回 Unit
|
||||||
|
props,
|
||||||
|
GetXmlDocSummary(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 推断类型是 Query 还是 Command。
|
||||||
|
/// 启发式:类名以 Get/List/Search/Count/Query 开头或以 Query 结尾 → Query;否则 Command。
|
||||||
|
/// 不要求精确——前端面板按 Kind 分组,误判只影响分组归类。
|
||||||
|
/// </summary>
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提取 MediatR 响应类型(IRequest<T> 的 T)。非泛型 IRequest 返回 null(语义为 Unit)。
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将一个 <see cref="Type"/> 格式化为脚本友好的可读字符串:
|
||||||
|
/// 基元类型用 C# 关键字(int/string/bool…);泛型用 List<T>/Dictionary<K,V>;
|
||||||
|
/// 数组用 T[];可空用 T?;其余用命名空间限定名。
|
||||||
|
/// </summary>
|
||||||
|
internal static string FormatTypeName(Type? t)
|
||||||
|
{
|
||||||
|
if (t is null) return "?";
|
||||||
|
|
||||||
|
// Nullable<T> → "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<T>、Dictionary<K,V>、IReadOnlyList<T> 等
|
||||||
|
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<T> 理论上已被上面拦截,防御性处理
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>常见基元/系统类型到 C# 友好名的映射。命中返回 true。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>XML 文档摘要(v1 不加载 doc XML,恒返回 null,预留扩展点)。</summary>
|
||||||
|
private static string? GetXmlDocSummary(Type type) => null;
|
||||||
|
|
||||||
|
/// <summary>返回所有已扫描的类型元数据(无序快照)。</summary>
|
||||||
|
public IReadOnlyCollection<ScriptTypeMeta> GetAll() => _types.Values.ToArray();
|
||||||
|
|
||||||
|
/// <summary>带过滤 + 分页的搜索。匹配关键字(名/全名/命名空间)与 Kind。</summary>
|
||||||
|
public IEnumerable<ScriptTypeMeta> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>当前过滤条件下的总数(用于分页)。</summary>
|
||||||
|
public int Total(string? keyword, string? kind)
|
||||||
|
=> ApplyFilter(_types.Values, keyword, kind).Count();
|
||||||
|
|
||||||
|
private static IEnumerable<ScriptTypeMeta> ApplyFilter(
|
||||||
|
IEnumerable<ScriptTypeMeta> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -97,6 +97,28 @@ public class ScriptExecutionServiceParseVariablesTests
|
|||||||
vars["ts"].Should().BeOfType<DateTime>();
|
vars["ts"].Should().BeOfType<DateTime>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 年份串 "2026" 不应误判为 DateTime(会误伤客户/订单 ID 等数字样字符串)。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Year_Only_String_Remains_String()
|
||||||
|
{
|
||||||
|
var vars = Parse("{\"year\": \"2026\"}");
|
||||||
|
|
||||||
|
vars["year"].Should().BeOfType<string>().Which.Should().Be("2026");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 年月串 "2026-07" 不应误判为 DateTime(缺少日期分隔,非完整 ISO-8601)。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Year_Month_String_Remains_String()
|
||||||
|
{
|
||||||
|
var vars = Parse("{\"period\": \"2026-07\"}");
|
||||||
|
|
||||||
|
vars["period"].Should().BeOfType<string>().Which.Should().Be("2026-07");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Object_Maps_To_JsonElement()
|
public void Object_Maps_To_JsonElement()
|
||||||
{
|
{
|
||||||
|
|||||||
182
tests/BuildFlow.Scripting.Tests/ScriptTypeRegistryTests.cs
Normal file
182
tests/BuildFlow.Scripting.Tests/ScriptTypeRegistryTests.cs
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Scripting.Application.DTOs;
|
||||||
|
using Scripting.Application.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace BuildFlow.Scripting.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="ScriptTypeRegistry"/> 单元测试:反射扫描、关键字/Kind 过滤、分页与类型格式化。
|
||||||
|
/// 扫描依赖测试运行时加载的 BuildFlow.* 程序集;本测试项目通过 Scripting.Application
|
||||||
|
/// 传递引用了 Platform.Application,其 Query 类型应被扫描到。
|
||||||
|
/// </summary>
|
||||||
|
public class ScriptTypeRegistryTests
|
||||||
|
{
|
||||||
|
private readonly ScriptTypeRegistry _registry = new();
|
||||||
|
|
||||||
|
public ScriptTypeRegistryTests()
|
||||||
|
{
|
||||||
|
// 测试构造时扫描一次,保证后续断言基于已填充状态
|
||||||
|
_registry.Scan();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>扫描应至少发现一个 BuildFlow.* IBaseRequest 实现(来自 Platform.Application)。</summary>
|
||||||
|
[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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>关键字搜索应按 名/全名/命名空间 模糊过滤。</summary>
|
||||||
|
[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}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Kind=Query 过滤后,结果应全部是 Query;Total 计数应与分页前一致。</summary>
|
||||||
|
[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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>分页:第二页应正确跳过第一页条数。</summary>
|
||||||
|
[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, "翻页后首页首项应不同于第二页首项");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ResponseType 字段:IRequest<T> 实现应能解析出 T 的友好名。</summary>
|
||||||
|
[Fact]
|
||||||
|
public void BuildMeta_Extracts_Response_Type()
|
||||||
|
{
|
||||||
|
var all = _registry.GetAll();
|
||||||
|
all.Should().Contain(m => m.ResponseType is not null,
|
||||||
|
"至少一个类型实现了 IRequest<T>,应解析出非空 ResponseType");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>重复扫描是幂等的:再次扫描后总数不变。</summary>
|
||||||
|
[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<int>)).Should().Be("List<int>");
|
||||||
|
ScriptTypeRegistry.FormatTypeName(typeof(IEnumerable<string>)).Should().Be("IEnumerable<string>");
|
||||||
|
ScriptTypeRegistry.FormatTypeName(typeof(IReadOnlyList<Guid>)).Should().Be("IReadOnlyList<Guid>");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatTypeName_Dictionary_Formats_Two_Type_Arguments()
|
||||||
|
{
|
||||||
|
ScriptTypeRegistry.FormatTypeName(typeof(Dictionary<string, int>))
|
||||||
|
.Should().Be("Dictionary<string, int>");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatTypeName_Nested_Generic_Formats_Recursively()
|
||||||
|
{
|
||||||
|
ScriptTypeRegistry.FormatTypeName(typeof(List<List<string>>))
|
||||||
|
.Should().Be("List<List<string>>");
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<int>[])).Should().Be("List<int>[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatTypeName_Complex_Type_Uses_FullName()
|
||||||
|
{
|
||||||
|
ScriptTypeRegistry.FormatTypeName(typeof(System.Text.Json.JsonElement))
|
||||||
|
.Should().Be("System.Text.Json.JsonElement");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- 辅助 --------
|
||||||
|
|
||||||
|
/// <summary>从已扫描集合中挑一个适合做关键字探测的样本(取名字的子串,避免取到单字符)。</summary>
|
||||||
|
private static string PickProbe(IReadOnlyCollection<ScriptTypeMeta> all)
|
||||||
|
{
|
||||||
|
// 选一个名字至少 4 字符的类型,截取前 4 字符作为关键字,保证可复现命中
|
||||||
|
var pick = all.First(m => m.Name.Length >= 4);
|
||||||
|
return pick.Name[..4];
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user