xiangning 51eea796e2 refactor(rag): 重构AI模型相关代码结构和数据库迁移逻辑
- 将AI模型相关端点、命令、处理器移动到模块内部统一管理
- 优化数据库迁移脚本,添加对不同环境表状态的兼容处理逻辑
- 为异常处理添加错误码枚举参数,统一错误处理方式
- 重构多处业务逻辑中的异常抛出方式,增强代码一致性
- 移除独立的组件清单分组和项目定义,简化前端物料面板数据结构
- 修复测试代码中实体引用路径错误问题
- 添加新的监控模块测试项目到解决方案文件中
2026-07-20 08:25:18 +08:00

169 lines
7.3 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 BuildFlow.Platform.Domain.Entities.User;
using BuildFlow.Platform.Infrastructure.Persistence;
using BuildFlow.Shared.Domain.Common;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Primitives;
using Microsoft.Extensions.DependencyInjection;
namespace BuildFlow.Platform.Tests;
/// <summary>
/// Platform 模块测试基础设施:InMemory PlatformDbContext 工厂 + JWT 配置构造 + 审计字段填充 helper。
/// </summary>
/// <remarks>
/// 不挂 AuditInterceptor(拦截器依赖 ICurrentUserContext,测试上下文里没有);
/// 约定:测试 seed 实体时调用 <see cref="WithAudit"/> 填充必填审计字段。
/// </remarks>
internal static class PlatformTestDb
{
/// <summary>构造一个独立的 InMemory PlatformDbContext(每个测试唯一库名,避免串扰)。</summary>
/// <remarks>
/// 挂载 SavingChanges 回调自动填充 IAuditable/IHasOperatorIP 字段(替代生产环境的 AuditInterceptor),
/// 这样 handler 内部 new 出来的 RefreshTokenEntity/UserRoleEntity/RolePermissionEntity 等实体也能通过非空校验。
/// </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 DbContext ctx) return;
foreach (var entry in ctx.ChangeTracker.Entries())
{
if (entry.Entity is 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 IHasOperatorIP o)
o.OperatorIP ??= "127.0.0.1";
}
}
/// <summary>构造测试用 IConfiguration(提供 JWT 签发所需的 SigningKey/Issuer/Audience)。</summary>
/// 用字典实现而非 NSubstitute mock:因为 <c>GetValue&lt;T&gt;</c> 是扩展方法,
/// 内部走 ConfigurationBinder 对 this[key] 做类型转换,mock 对未设置的 key 返回空串会导致 Int32 解析失败。
/// 字典实现让未设置的 key 返回 null,使扩展方法回退到默认值。
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<UserEntity> SeedUserAsync(
PlatformDbContext db,
Guid id,
string username,
string password = "Admin@123",
bool isActive = true,
int permissionVersion = 0)
{
var user = WithAudit(new UserEntity
{
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;
}
}
/// 极简字典实现的 IConfiguration,仅供测试使用。
/// 完整实现 GetSection/GetChildren,让 GetValue&lt;T&gt; 扩展方法(走 ConfigurationBinder)能正确工作。
/// 注意:ConfigurationBinder.GetValue 内部调用 GetSection(key).Value,故必须返回有效 section。
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() { }
}
}
}