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];
+ }
+}