xiangning fb40c47d5b refactor(monitoring): 重构监控模块数据模型为文档模式
- 将 AlertRule 和 ExternalSystem 实体改为使用 JsonData 存储业务字段
- 移除独立的 ExternalSystemCredential 表,凭证信息合并到 ExternalSystem.JsonData
- 添加强类型视图 Data 属性用于访问 JsonData 中的业务数据
- 更新数据库配置将字段存储改为 jsonb 类型并调整索引策略
- 重构告警评估器使用 JsonData 强类型视图读取规则数据
- 更新连接器凭证解析器从 JsonData 中获取凭证信息
- 在 appsettings.json 中添加 monitoring 模块配置
- 将 monitoring 模块集成到主应用程序中并注册服务
- 更新相关处理器使用新的数据模型结构
2026-07-17 12:19:24 +08:00

178 lines
7.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Primitives;
using BuildFlow.Platform.Domain.Entities;
using BuildFlow.Platform.Infrastructure.Persistence;
namespace BuildFlow.Platform.Tests;
/// <summary>
/// Platform 模块测试基础设施:InMemory PlatformDbContext 工厂 + JWT 配置构造 + 审计字段填充 helper。
/// </summary>
/// <remarks>
/// 照抄 <c>MenuSortTests.BuildInMemoryDb</c> 的 DI 方式(不挂 AuditInterceptor,手动填审计字段)。
/// 不挂拦截器是因为拦截器依赖 ICurrentUserContext/sp.GetRequiredService,测试上下文里没有;
/// 故约定:测试 seed 实体时调用 <see cref="WithAudit"/> 填充必填审计字段。
/// </remarks>
internal static class PlatformTestDb
{
/// <summary>构造一个独立的 InMemory PlatformDbContext(每个测试唯一库名,避免串扰)。</summary>
/// <remarks>
/// 挂载 SavingChanges 回调自动填充 IAuditable/IHasOperatorIP 字段(替代生产环境的 AuditInterceptor),
/// 这样 handler 内部 new 出来的 RefreshToken/UserRole/RolePermission 等实体也能通过非空校验。
/// </remarks>
public static PlatformDbContext CreateDbContext([System.Runtime.CompilerServices.CallerMemberName] string testName = "")
{
var services = new ServiceCollection();
services.AddDbContext<PlatformDbContext>(o => o.UseInMemoryDatabase($"platform-{testName}-{Guid.NewGuid()}"));
var db = services.BuildServiceProvider().GetRequiredService<PlatformDbContext>();
db.SavingChanges += AutoFillAuditFields;
return db;
}
/// <summary>SavingChanges 回调:自动填充审计字段,模拟生产 AuditInterceptor 行为。</summary>
private static void AutoFillAuditFields(object? sender, EventArgs _)
{
if (sender is not Microsoft.EntityFrameworkCore.DbContext ctx) return;
foreach (var entry in ctx.ChangeTracker.Entries())
{
if (entry.Entity is BuildFlow.Shared.Domain.Common.IAuditable a)
{
a.CreatedBy ??= "test";
a.UpdatedBy ??= "test";
if (a.CreatedAt == default) a.CreatedAt = DateTime.UtcNow;
if (a.UpdatedAt == default) a.UpdatedAt = DateTime.UtcNow;
}
if (entry.Entity is BuildFlow.Shared.Domain.Common.IHasOperatorIP o)
{
o.OperatorIP ??= "127.0.0.1";
}
}
}
/// <summary>构造测试用 IConfiguration(提供 JWT 签发所需的 SigningKey/Issuer/Audience)。</summary>
/// <remarks>
/// 用字典实现而非 NSubstitute mock:因为 <c>GetValue&lt;T&gt;</c> 是扩展方法,
/// 内部走 ConfigurationBinder 对 this[key] 做类型转换,mock 对未设置的 key 返回空串会导致 Int32 解析失败。
/// 字典实现让未设置的 key 返回 null,使扩展方法回退到默认值。
/// </remarks>
public static IConfiguration CreateConfig() => new DictionaryConfiguration
{
["Jwt:SigningKey"] = "TestSigningKeyMustBeAtLeast32CharsLong!!",
["Jwt:Issuer"] = "test-issuer",
["Jwt:Audience"] = "test-audience",
["Jwt:AccessTokenMinutes"] = "240",
["Jwt:RefreshTokenDays"] = "7",
};
/// <summary>填充 IFullAudit 实体的必填审计字段,避免 EF 保存时非空校验失败。</summary>
public static T WithAudit<T>(T entity) where T : class
{
var createdBy = entity.GetType().GetProperty("CreatedBy");
var updatedBy = entity.GetType().GetProperty("UpdatedBy");
var operatorIP = entity.GetType().GetProperty("OperatorIP");
var createdAt = entity.GetType().GetProperty("CreatedAt");
var updatedAt = entity.GetType().GetProperty("UpdatedAt");
if (createdBy is not null && createdBy.GetValue(entity) is null or "")
createdBy.SetValue(entity, "test");
if (updatedBy is not null && updatedBy.GetValue(entity) is null or "")
updatedBy.SetValue(entity, "test");
if (operatorIP is not null && operatorIP.GetValue(entity) is null or "")
operatorIP.SetValue(entity, "127.0.0.1");
if (createdAt is not null && (DateTime)createdAt.GetValue(entity)! == default)
createdAt.SetValue(entity, DateTime.UtcNow);
if (updatedAt is not null && (DateTime)updatedAt.GetValue(entity)! == default)
updatedAt.SetValue(entity, DateTime.UtcNow);
return entity;
}
/// <summary>构造一个已持久化的测试用户(含 BCrypt 密码哈希)。</summary>
public static async Task<User> SeedUserAsync(
PlatformDbContext db,
Guid id,
string username,
string password = "Admin@123",
bool isActive = true,
int permissionVersion = 0)
{
var user = WithAudit(new User
{
Id = id,
Username = username,
Email = $"{username}@test.local",
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
IsActive = isActive,
PermissionVersion = permissionVersion,
});
db.Users.Add(user);
await db.SaveChangesAsync();
return user;
}
}
/// <summary>
/// 极简字典实现的 IConfiguration,仅供测试使用。
/// </summary>
/// <remarks>
/// 完整实现 GetSection/GetChildren,让 GetValue&lt;T&gt; 扩展方法(走 ConfigurationBinder)能正确工作。
/// 注意:ConfigurationBinder.GetValue 内部调用 GetSection(key).Value,故必须返回有效 section。
/// </remarks>
internal sealed class DictionaryConfiguration : IConfiguration
{
private readonly Dictionary<string, string?> _values = [];
public string? this[string key]
{
get => _values.TryGetValue(key, out var v) ? v : null;
set => _values[key] = value;
}
// 支持集合初始化器语法:new DictionaryConfiguration { ["k"] = "v" }
public void Add(string key, string? value) => _values[key] = value;
IEnumerable<IConfigurationSection> IConfiguration.GetChildren() =>
_values.Select(kv => (IConfigurationSection)new LeafSection(kv.Key, kv.Value)).ToList();
IChangeToken IConfiguration.GetReloadToken() => EmptyChangeToken.Instance;
IConfigurationSection IConfiguration.GetSection(string key) =>
new LeafSection(key, _values.TryGetValue(key, out var v) ? v : null);
private sealed class LeafSection(string key, string? value) : IConfigurationSection
{
public string Key => key;
public string Path => key;
public string? Value { get; set; } = value;
string? IConfiguration.this[string childKey]
{
get => null;
set { }
}
IConfigurationSection IConfiguration.GetSection(string childKey) =>
new LeafSection($"{key}:{childKey}", null);
IEnumerable<IConfigurationSection> IConfiguration.GetChildren() => [];
IChangeToken IConfiguration.GetReloadToken() => EmptyChangeToken.Instance;
}
/// <summary>永不变更的占位 IChangeToken,避免引入额外包依赖。</summary>
private sealed class EmptyChangeToken : IChangeToken
{
public static readonly EmptyChangeToken Instance = new();
public bool ActiveChangeCallbacks => false;
public bool HasChanged => false;
public IDisposable RegisterChangeCallback(Action<object> callback, object? state) =>
new NoopDisposable();
private sealed class NoopDisposable : IDisposable
{
public void Dispose() { }
}
}
}