MoneyTree.EFCore 1.0.4
MoneyTree.EFCore ORM 增强
📋 概述
MoneyTree.EFCore 是 EF Core 的深度集成增强模块,提供 IUnitOfWork 实现、审计自动填充、领域事件派发、多租户筛选与填充、向量存储抽象、分页扩展等开箱即用的能力。
| 属性 | 说明 |
|---|---|
| NuGet 包 | MoneyTree.EFCore |
| 外部依赖 | MoneyTree.Core、Microsoft.EntityFrameworkCore |
| 定位 | EF Core 深度集成,提供拦截器和向量存储 |
📦 包体系结构
MoneyTree.EFCore ← 核心包:DbContext 基类、工作单元、审计、领域事件
├── MoneyTree.EFCore.MultiTenancy ← 扩展包:多租户筛选/填充拦截器
├── MoneyTree.EFCore.VectorStore ← 扩展包:向量存储抽象 + 各数据库实现
├── MoneyTree.EFCore.SqlServer ← 扩展包:SQL Server 配置
├── MoneyTree.EFCore.PostgreSql ← 扩展包:PostgreSQL 配置
└── MoneyTree.EFCore.MySql ← 扩展包:MySQL 配置
依赖关系
┌─────────────────────────────────────────────────────┐
│ MoneyTree.Core │
│ Entity, AggregateRoot, IUnitOfWork │
└────────────────────────┬────────────────────────────┘
│
┌────────────────────────┴────────────────────────────┐
│ MoneyTree.EFCore │
│ MoneyTreeDbContext, UnitOfWork, AuditInterceptor │
│ DomainEventInterceptor, PageQueryExtensions │
└────┬───────────────┬───────────────┬─────────────────┘
│ │ │
▼ ▼ ▼
┌─────────┐ ┌───────────┐ ┌──────────┐
│ SqlServer│ │ PostgreSql │ │ MySQL │
│ 扩展包 │ │ 扩展包 │ │ 扩展包 │
└─────────┘ └───────────┘ └──────────┘
▼ ▼ ▼
┌─────────────────────────────────────────────────────┐
│ 可选扩展包 │
│ ├── MoneyTree.EFCore.MultiTenancy(多租户) │
│ │ └── 依赖 MoneyTree.MultiTenancy │
│ └── MoneyTree.EFCore.VectorStore(向量存储) │
└─────────────────────────────────────────────────────┘
🏗️ 核心包项目文件结构
MoneyTree.EFCore/
├── MoneyTree.EFCore.csproj
├── GlobalUsings.cs
├── Abstractions/
│ └── IDatabaseTimeConvention.cs # 数据库时间约定抽象(跨数据库 UTC 函数)
├── Core/
│ ├── MoneyTreeDbContext.cs # DbContext 基类(扫描 [TimeZoneAware] 特性)
│ ├── UnitOfWork.cs # IUnitOfWork 实现
│ ├── UtcDateTimeConverter.cs # UTC DateTime 值转换器(PG/SQL Server)
│ ├── CrossTimeZoneConverter.cs # 跨时区 DateTime 值转换器(MySQL)
├── Interceptors/
│ ├── AuditInterceptor.cs # 审计自动填充
│ └── DomainEventInterceptor.cs # 领域事件派发
└── Extensions/
├── EFCoreBuilder.cs # 流畅配置构造器
├── EFCoreExtensions.cs # DI 注册扩展
├── PageQueryExtensions.cs # 分页查询扩展(3 个重载)
├── UtcDateTimePropertyExtensions.cs # UseTimeZoneAware() Fluent API
└── DatabaseProviderExtensions.cs # 数据库提供者扩展
扩展包结构
MultiTenancy 多租户扩展包
MoneyTree.EFCore.MultiTenancy/
├── MoneyTree.EFCore.MultiTenancy.csproj
├── GlobalUsings.cs
├── MultiTenancyOptions.cs
├── MultiTenancyServiceExtensions.cs
├── Interceptors/
│ ├── TenantFilterInterceptor.cs # 查询时自动添加租户筛选
│ └── TenantSaveChangesInterceptor.cs # 新增时自动填充 TenantId
└── Extensions/
├── MultiTenancyBuilder.cs
└── MultiTenancyExtensions.cs
VectorStore 向量存储扩展包
MoneyTree.EFCore.VectorStore/
├── MoneyTree.EFCore.VectorStore.csproj
├── GlobalUsings.cs
├── IVectorStore.cs # 向量存储接口
├── VectorRecord.cs # 向量记录模型
├── VectorSearchResult.cs # 向量搜索结果
├── DistanceFunctions.cs # 距离函数
├── VectorStore.cs # 向量存储实现
└── Extensions/
└── VectorStoreExtensions.cs
🎯 核心能力
| 能力 | 实现 | 说明 |
|---|---|---|
| IUnitOfWork 实现 | UnitOfWork<TDbContext> |
SaveChangesAsync 自动事务 |
| 审计自动填充 | AuditInterceptor |
自动设置创建时间/人、修改时间/人 |
| 领域事件派发 | DomainEventInterceptor |
SaveChanges 后自动派发领域事件(需 UseMediatR 注册) |
| 多租户筛选 | TenantFilterInterceptor |
全局查询过滤 |
| 多租户填充 | MultiTenancySaveChangesInterceptor |
新增时自动设置 TenantId |
| 向量存储 | IVectorStore / VectorStore |
统一向量存储抽象 |
| 分页扩展 | ToPageResultAsync(3 个重载,详见下方说明) |
轻量分页 / 标准分页(锚点防漂移)/ 带汇总分页 |
| 时区保障 | UtcDateTimeConverter + CrossTimeZoneConverter + [TimeZoneAware] 特性 + UseTimeZoneAware() Fluent API |
按需配置,MySQL 跨时区转换,PG/SQL Server Kind=Utc 标记 |
⏰ UTC 时间字段策略
问题背景
MySQL 的 datetime 列不保存时区信息。EF Core 写入 DateTime.UtcNow(Kind=Utc)后,读取回来时 Kind 变为 Unspecified,导致时区信息丢失,可能引发时间计算错误。
解决方案(按需配置)
框架提供 UtcDateTimeConverter,写入时 ToUniversalTime(),读取时 SpecifyKind(Utc),防止时区丢失。
设计变更:框架早期对所有
DateTime属性全量应用转换器。但全量应用对于日期型字段(如Birthday、BusinessDate)和非 UTC 时间字段不友好。现已改为按需配置,提供两种方式:
方式一:[TimeZoneAware] 特性标记(声明式)
using MoneyTree.Core.Attributes;
public class User : SnowflakeEntity, IAuditable
{
// 审计字段标记时区感知转换
[TimeZoneAware]
public DateTime CreatedAt { get; set; }
[TimeZoneAware]
public DateTime UpdatedAt { get; set; }
[TimeZoneAware]
public DateTime? PublishedAt { get; set; } // 可空字段同样支持
// 生日字段仅含日期,不需要时区转换
public DateTime Birthday { get; set; }
}
MoneyTreeDbContext.OnModelCreating 自动扫描 [TimeZoneAware] 特性并应用转换器,无需额外配置。
转换器选择(根据 IDatabaseTimeConvention.UsesServerTimeZone 自动决策):
- MySQL(
UsesServerTimeZone=true):使用CrossTimeZoneConverter— 处理服务器本地时间与 UTC 的跨时区转换 - PostgreSQL / SQL Server(
UsesServerTimeZone=false):使用UtcDateTimeConverter— 仅标记 DateTime.Kind=Utc
Fluent API 的
UseTimeZoneAware()仍使用UtcDateTimeConverter。 MySQL 场景推荐使用[TimeZoneAware]特性(自动选择正确转换器), 或手动HasConversion(new CrossTimeZoneConverter())。
方式二:Fluent API(编程式)
在 IEntityTypeConfiguration<T> 中使用 UseTimeZoneAware() 扩展方法:
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.Property(o => o.CreatedAt).UseTimeZoneAware();
builder.Property(o => o.UpdatedAt).UseTimeZoneAware();
builder.Property(o => o.PublishedAt).UseTimeZoneAware(); // 可空 DateTime? 同样支持
// Birthday 不配置,保持原样
builder.Property(o => o.Birthday);
}
}
UtcDateTimeConverter 实现
/// <summary>
/// UTC DateTime 值转换器。
/// 写入时 ToUniversalTime(),读取时 SpecifyKind(Utc)。
/// </summary>
public class UtcDateTimeConverter : ValueConverter<DateTime, DateTime>
{
public UtcDateTimeConverter()
: base(
v => v.ToUniversalTime(), // 写入:转换为 UTC
v => DateTime.SpecifyKind(v, DateTimeKind.Utc) // 读取:标记为 UTC
)
{ }
}
数据库默认值
CreatedAt / UpdatedAt 字段配置数据库默认值(由各数据库扩展包通过 IDatabaseTimeConvention 提供),原生 SQL 插入时自动填充当前时间。
| 字段 | 类型 | MySQL 默认值 | PostgreSQL 默认值 | SQL Server 默认值 |
|---|---|---|---|---|
CreatedAt |
datetime |
CURRENT_TIMESTAMP |
NOW() AT TIME ZONE 'UTC' |
SYSUTCDATETIME() |
UpdatedAt |
datetime |
同上 | 同上 | 同上 |
MySQL 8.0+ 不支持
UTC_TIMESTAMP(6)语法,框架已改用CURRENT_TIMESTAMP。 MySQLCURRENT_TIMESTAMP返回数据库服务器本地时间,框架通过CrossTimeZoneConverter自动处理时区转换。 PostgreSQL/SQL Server 存储 UTC,使用UtcDateTimeConverter标记 Kind=Utc。
MySQL 时区配置(必需)
使用 MySQL 时需在应用启动时初始化 TimeZoneHelper,检测数据库时区并启用跨时区转换:
// Program.cs — 启动时检测 MySQL 时区
using (var scope = app.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
using var connection = context.Database.GetDbConnection();
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT TIMEDIFF(NOW(6), UTC_TIMESTAMP(6))";
var databaseOffset = TimeSpan.Parse(cmd.ExecuteScalar()!.ToString()!);
TimeZoneHelper.Initialize(databaseOffset);
}
未注册任何数据库扩展包时,跳过数据库默认值设置,由
AuditInterceptor在应用层填充CreatedAt。
UpdatedAt 乐观锁机制
UpdatedAt 字段在 MoneyTreeDbContext.OnModelCreating 中统一配置:
IsConcurrencyToken():EF Core 在 UPDATE 时自动在 WHERE 子句包含UpdatedAt原始值,并发冲突时抛DbUpdateConcurrencyExceptionValueGeneratedOnAddOrUpdate():告诉 EF Core 该值由数据库生成,不在 SET 子句显式发送
MySQL 需要通过迁移 SQL 配置列的 ON UPDATE 行为(EF Core 的 HasDefaultValueSql 仅生成 DEFAULT 约束,不生成 ON UPDATE):
-- MySQL 迁移:配置 UpdatedAt 列的 ON UPDATE 行为
ALTER TABLE your_table
MODIFY COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP;
关键规则:应用层不要手动设置
UpdatedAt。AuditInterceptor也不设置UpdatedAt,仅设置UpdatedBy。UpdatedAt完全由数据库管理,确保乐观锁机制正确工作。
注意:
UtcDateTimeConverter保障DateTime.Kind不丢失,无需改用DateTimeOffset。对于审计字段,建议标记[TimeZoneAware]以保障时区正确。MySQL 场景框架自动选择CrossTimeZoneConverter。
🗃️ 数据库兼容性
| 数据库 | 多租户 | 向量检索 | JSON 列 | 推荐版本 |
|---|---|---|---|---|
| SQL Server | ✅ | ✅ (SqlVector) | ✅ | 2022+ |
| MySQL | ✅ | ✅ (VECTOR) | ✅ | 9.0+ |
| PostgreSQL | ✅ | ✅ (pgvector) | ✅ | 15+ |
🚀 使用示例
示例一:单租户 PostgreSQL 项目
// Program.cs
using MoneyTree.EFCore.Extensions;
using MoneyTree.EFCore.PostgreSql.Extensions;
using MyApp.Infrastructure.Data;
var builder = WebApplication.CreateBuilder(args);
// EF Core 配置
builder.Services.AddMoneyTreeEFCore<AppDbContext>(db =>
{
db.UsePostgreSql<AppDbContext>(
builder.Configuration.GetConnectionString("Default")!);
db.UseMediatR(typeof(Program).Assembly); // 注册 MediatR,启用领域事件派发
});
var app = builder.Build();
app.Run();
领域事件派发:
DomainEventInterceptor依赖IMediator,未调用UseMediatR时降级为不派发(无异常)。 启用 Outbox 派发服务时必须先调用UseMediatR,否则MediatrEventDispatcher解析 IMediator 会抛异常。
示例二:多租户 SaaS 项目(PostgreSQL)
// AppDbContext.cs
using MoneyTree.EFCore.Core;
using MoneyTree.EFCore.MultiTenancy;
using MoneyTree.MultiTenancy.Abstractions;
using Microsoft.EntityFrameworkCore;
namespace MyApp.Infrastructure.Data;
public class AppDbContext : MoneyTreeDbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// 配置多租户全局筛选
modelBuilder.ConfigureTenantFilters(
() => this.GetService<ITenantContextAccessor>()?.TenantContext?.TenantId);
}
}
// Program.cs
using MoneyTree.EFCore.Extensions;
using MoneyTree.EFCore.MultiTenancy.Extensions;
using MoneyTree.EFCore.PostgreSql.Extensions;
using MoneyTree.MultiTenancy.Extensions;
var builder = WebApplication.CreateBuilder(args);
// 多租户
builder.Services.AddMoneyTreeMultiTenancy(mt =>
{
mt.UseHeader("X-Tenant-Id");
});
// EF Core + 多租户
builder.Services.AddEFCoreMultiTenancy(tenant =>
{
tenant.WithColumnName("TenantId")
.IgnoreTable("SystemConfigs");
});
// 数据库
builder.Services.AddMoneyTreeEFCore<AppDbContext>(db =>
{
db.UsePostgreSql<AppDbContext>(
builder.Configuration.GetConnectionString("Default")!);
});
var app = builder.Build();
app.UseMoneyTreeMultiTenancy();
app.Run();
服务层使用
分页扩展方法(ToPageResultAsync)
框架提供三个分页重载,按需选择:
| 重载 | 返回类型 | 时间戳锚点 | 适用场景 |
|---|---|---|---|
ToPageResultAsync(query, ct) |
IList<T> |
❌ 不应用 | 轻量分页,仅返回分页数据,适用于无限滚动等不需要总页数的场景 |
ToPageResultAsync(query, timestampProperty?, ct) |
PageResult<T> |
✅ 可选启用 | 标准分页,返回完整元数据(TotalCount/Timestamp),传入 timestampProperty 启用锚点防漂移。锚点通过 PageQuery.PageTime 获取(首页自动取当前时间,翻页回传还原) |
ToPageResultAsync<T, TSummary>(query, summarySelector, timestampProperty?, ct) |
PageResult<T, TSummary> |
✅ 可选启用 | 带汇总的标准分页,汇总与分页基于同一 PageTime 锚点视图 |
using MoneyTree.Core.Primitives;
using MoneyTree.EFCore.Extensions;
using MoneyTree.EFCore.MultiTenancy.Extensions;
using MyApp.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace MyApp.Application.Services;
/// <summary>
/// 完整的订单服务,展示所有 EF Core 特性组合使用。
/// </summary>
public class OrderService
{
[Inject] public DbSet<Order> Orders { get; private set; } = default!;
[Inject] public IUnitOfWork UnitOfWork { get; private set; } = default!;
[Inject] public ICurrentUser CurrentUser { get; private set; } = default!;
/// <summary>
/// 创建订单。
/// TenantId 由 TenantSaveChangesInterceptor 自动填充。
/// CreatedAt/UpdatedAt 由 AuditInterceptor 自动填充。
/// </summary>
public async Task<ApiResult<Order>> CreateAsync(Order order)
{
await Orders.AddAsync(order);
await UnitOfWork.SaveChangesAsync();
// 领域事件由 DomainEventInterceptor 自动派发
return ApiResult<Order>.Success(order);
}
/// <summary>
/// 查询订单(自动租户隔离)。
/// </summary>
public async Task<ApiResult<Order?>> GetAsync(string id)
{
var order = await Orders.FirstOrDefaultAsync(o => o.Id == id);
return ApiResult<Order?>.Success(order);
}
/// <summary>
/// 标准分页查询(返回完整元数据,含 TotalCount/Timestamp 锚点防漂移)。
/// 传入 timestampProperty 启用时间戳锚点筛选,防止翻页期间新增数据导致漂移。
/// </summary>
public async Task<PageResult<Order>> GetPagedAsync(PageQuery query)
{
return await Orders
.Where(o => o.TotalAmount > 0)
.OrderByDescending(o => o.TotalAmount)
.ToPageResultAsync(query, o => o.CreatedAt);
}
/// <summary>
/// 轻量分页查询(仅返回分页数据,不含元数据,不应用时间戳锚点)。
/// 适用于无限滚动等不需要总页数的场景。
/// </summary>
public async Task<IList<Order>> GetPagedListAsync(PageQuery query)
{
return await Orders
.Where(o => o.TotalAmount > 0)
.OrderByDescending(o => o.TotalAmount)
.ToPageResultAsync(query);
}
/// <summary>
/// 带汇总的分页查询(含 Timestamp 锚点防漂移)。
/// </summary>
public async Task<PageResult<Order, OrderSummary>> GetPagedWithSummaryAsync(PageQuery query)
{
return await Orders.ToPageResultAsync(
query,
summarySelector: q => Task.FromResult(new OrderSummary
{
TotalAmount = q.Sum(o => o.TotalAmount),
TotalCount = q.Count()
}),
timestampProperty: o => o.CreatedAt);
}
/// <summary>
/// 管理后台:跨租户查询。
/// </summary>
public async Task<List<Order>> GetAdminOrdersAsync()
{
return await Orders
.IgnoreTenantFilter()
.OrderByDescending(o => o.TotalAmount)
.Take(100)
.ToListAsync();
}
/// <summary>
/// 按指定租户查询。
/// </summary>
public async Task<List<Order>> GetByTenantAsync(string tenantId)
{
return await Orders
.IgnoreTenantFilter()
.Where(o => o.TenantId == tenantId)
.ToListAsync();
}
}
/// <summary>
/// 订单汇总模型。
/// </summary>
public class OrderSummary
{
public decimal TotalAmount { get; set; }
public int TotalCount { get; set; }
}
示例三:多租户 + 向量存储项目
using MoneyTree.EFCore.Extensions;
using MoneyTree.EFCore.MultiTenancy.Extensions;
using MoneyTree.EFCore.PostgreSql.Extensions;
using MoneyTree.EFCore.VectorStore.Extensions;
using MoneyTree.MultiTenancy.Extensions;
var builder = WebApplication.CreateBuilder(args);
// 多租户
builder.Services.AddMoneyTreeMultiTenancy(mt => mt.UseHeader("X-Tenant-Id"));
builder.Services.AddEFCoreMultiTenancy();
// 数据库
builder.Services.AddMoneyTreeEFCore<AppDbContext>(db =>
{
db.UsePostgreSql<AppDbContext>(
builder.Configuration.GetConnectionString("Default")!);
});
// 向量存储
builder.Services.AddEFCoreVectorStore<PostgreSqlVectorStore>();
var app = builder.Build();
app.UseMoneyTreeMultiTenancy();
app.Run();
AI 搜索服务
using MoneyTree.EFCore.VectorStore;
namespace MyApp.Application.Services;
public class SearchService
{
private readonly IVectorStore _vectorStore;
private readonly IEmbeddingGenerator _embeddingGenerator;
public SearchService(IVectorStore vectorStore, IEmbeddingGenerator embeddingGenerator)
{
_vectorStore = vectorStore;
_embeddingGenerator = embeddingGenerator;
}
/// <summary>
/// 语义搜索。
/// </summary>
public async Task<IReadOnlyList<VectorSearchResult>> SemanticSearchAsync(
string query, int topK = 10)
{
var queryVector = await _embeddingGenerator.GenerateAsync(query);
return await _vectorStore.SearchAsync(queryVector, topK);
}
}
🛡️ 拦截器说明
| 拦截器 | 触发时机 | 作用 |
|---|---|---|
AuditInterceptor |
SaveChanges | 自动设置 CreatedAt/UpdatedAt/CreatedBy/UpdatedBy |
DomainEventInterceptor |
SaveChanges 后 | 自动派发聚合根中收集的领域事件 |
TenantFilterInterceptor |
查询时 | 自动为所有查询添加 WHERE TenantId = @currentTenant |
TenantSaveChangesInterceptor |
SaveChanges | 新增实体时自动填充 TenantId |
Showing the top 20 packages that depend on MoneyTree.EFCore.
| Packages | Downloads |
|---|---|
|
MoneyTree.EFCore.MultiTenancy
MoneyTree Framework EF Core 多租户扩展包。提供租户筛选拦截器、租户自动填充拦截器。
|
1 |
|
MoneyTree.EFCore.MySql
MoneyTree Framework EF Core MySQL 扩展包。提供 UseMySql 配置方法。
|
1 |
|
MoneyTree.EFCore.PostgreSql
MoneyTree Framework EF Core PostgreSQL 扩展包。提供 UsePostgreSql 配置方法。
|
1 |
|
MoneyTree.EFCore.SqlServer
MoneyTree Framework EF Core SQL Server 扩展包。提供 UseSqlServer 配置方法。
|
1 |
|
MoneyTree.EFCore.VectorStore
MoneyTree Framework EF Core 向量存储扩展包。提供 IVectorStore 抽象和距离计算函数。
|
1 |
|
MoneyTree.Outbox
MoneyTree Framework 领域事件 Outbox 模块。提供后台派发服务,将事务内持久化的领域事件异步派发到 MediatR 或自定义处理器,保障最终一致性。
|
1 |
.NET 10.0
- MoneyTree.Core (>= 1.0.3)
- MediatR (>= 14.2.0)
- Microsoft.AspNetCore.Http.Abstractions (>= 2.3.11)
- Microsoft.EntityFrameworkCore (>= 10.0.9)
- Microsoft.EntityFrameworkCore.Abstractions (>= 10.0.9)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.9)