xiangning 3b13099378 refactor(domain): 重构领域实体结构并更新枚举命名
- 将通用 Entities 命名空间拆分为具体业务实体子目录
- 更新所有实体类型引用为具体的 Entity 后缀类型
- 将 ModelType、ModelProvider、GroupMemberRole 等枚举重命名为 Enum 后缀
- 重构 AiModel 相关配置和映射逻辑适配新的实体结构
- 更新测试代码中的实体类型引用和枚举使用
- 调整应用层处理器中的实体创建和查询逻辑
- 修改 appsettings.json 中的基础设施主机配置为动态占位符模式
2026-07-19 14:02:32 +08:00

186 lines
8.0 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 Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Primitives;
using BuildFlow.Platform.Domain.Entities.Menu;
using BuildFlow.Platform.Domain.Entities.Notification;
using BuildFlow.Platform.Domain.Entities.Permission;
using BuildFlow.Platform.Domain.Entities.RefreshToken;
using BuildFlow.Platform.Domain.Entities.Role;
using BuildFlow.Platform.Domain.Entities.RolePermission;
using BuildFlow.Platform.Domain.Entities.User;
using BuildFlow.Platform.Domain.Entities.UserRole;
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 出来的 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 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<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;
}
}
/// <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() { }
}
}
}