ABP VNext + Razor 邮件模板:动态、多租户隔离、可版本化的邮件与通知系统
🚀 ABP VNext + Razor 邮件模板:动态、多租户隔离、可版本化的邮件与通知系统
📚 目录
- 🚀 ABP VNext + Razor 邮件模板:动态、多租户隔离、可版本化的邮件与通知系统
- 🌟 一、TL;DR
- 📈 二、系统流程图
- 🛠 三、环境与依赖
- 🏗 四、项目骨架与模块注册
- 4.1 目录结构
- 4.2 模块依赖与注册
- 🏷️ 五、模板定义提供者
- 🏢 六、多租户隔离与实体设计
- ⚙️ 七、应用服务:并发安全与原子回滚
- 🖥️ 八、渲染服务:双层缓存 & 多级回退
- 📨 九、邮件发送与附件支持(Outbox & 重试)
- 🔒 十、在线管理界面与权限控制
- ✅ 十一、自动测试与异常场景覆盖
- 🔍 十二、日志、监控与运维
🌟 一、TL;DR
- 🎯 零依赖第三方:基于
Volo.Abp.TextTemplating.Razor
、Volo.Abp.MailKit
和内置IEmailSender
/Outbox。 - 🏢 多租户隔离:实体实现
IMultiTenant
,自动启用租户过滤。 - 🔐 并发 & 原子操作:采用 EF Core
[Timestamp]
乐观锁与单条 SQL 原子回滚。 - ⚡ 双层缓存:本地
IMemoryCache
+ 分布式IDistributedCache
,滑动 & 绝对过期。 - 🔄 回退安全:利用
ITemplateDefinitionManager
加明确定义,捕获异常并友好报错。 - 🔥 预编译 & 预热:在发布时手动调用一次
RenderAsync
,避免首次高并发编译。 - ✅ 完善测试:覆盖多租户隔离、并发冲突、缓存失效、多级回退与异常场景。
📈 二、系统流程图
🛠 三、环境与依赖
-
.NET SDK:.NET 8 +
-
ABP 版本:ABP VNext 8.x +
-
NuGet 包:
Volo.Abp.TextTemplating.Razor
Volo.Abp.Emailing
Volo.Abp.MailKit
Volo.Abp.BackgroundJobs.Quartz
(Outbox 调度)
-
数据库:EF Core(SQL Server、PostgreSQL 等)
-
前端:Blazor Server / Razor Pages + Monaco/CodeMirror
🏗 四、项目骨架与模块注册
4.1 目录结构
src/
└─ Modules/└─ NotificationModule/├─ Application/│ ├─ Dtos/EmailTemplateDto.cs│ ├─ IEmailTemplateAppService.cs│ └─ EmailTemplateAppService.cs├─ Domain/EmailTemplate.cs├─ EntityFrameworkCore/NotificationDbContext.cs├─ Web/Pages/EmailTemplates/{Index,Edit}.cshtml├─ EmailTemplateDefinitionProvider.cs└─ NotificationModule.cs
4.2 模块依赖与注册
using Microsoft.CodeAnalysis;
using Volo.Abp.BackgroundJobs.Quartz;
using Volo.Abp.Emailing;
using Volo.Abp.MailKit;
using Volo.Abp.TextTemplating.Razor;
using Volo.Abp.VirtualFileSystem;[DependsOn(typeof(AbpTextTemplatingRazorModule),typeof(AbpEmailingModule),typeof(AbpMailKitModule),typeof(AbpBackgroundJobsQuartzModule)
)]
public class NotificationModule : AbpModule
{public override void ConfigureServices(ServiceConfigurationContext context){// 💾 虚拟文件系统:嵌入默认布局与自定义模板Configure<AbpVirtualFileSystemOptions>(opts =>opts.FileSets.AddEmbedded<NotificationModule>());// ⚙️ Razor 编译引用Configure<AbpRazorTemplateCSharpCompilerOptions>(opts =>opts.References.Add(MetadataReference.CreateFromFile(typeof(NotificationModule).Assembly.Location)));// 📧 MailKit SMTP 配置context.Services.Configure<MailKitSmtpOptions>(context.Services.GetConfiguration().GetSection("MailKitSmtp"));// 🔄 启用 Quartz 驱动的 Outbox 重试Configure<AbpBackgroundJobQuartzOptions>(opts =>opts.IsJobExecutionEnabled = true);}
}
🏷️ 五、模板定义提供者
在 EmailTemplateDefinitionProvider.cs
中,显式注册内置资源模板的 Subject 和 Body 路径:
using Volo.Abp.TextTemplating;
using Volo.Abp.TextTemplating.Razor;
using Volo.Abp.Emailing.Templates;public class EmailTemplateDefinitionProvider : TemplateDefinitionProvider
{public override void Define(ITemplateDefinitionContext context){// 欢迎邮件context.Add(new TemplateDefinition(name: "Email.Welcome.Subject",virtualFilePath: "/Volo/Abp/Emailing/Templates/Welcome.Subject.cshtml"));context.Add(new TemplateDefinition(name: "Email.Welcome.Body",virtualFilePath: "/Volo/Abp/Emailing/Templates/Welcome.cshtml"));// 可继续为其他邮件模板定义 Subject/Body...}
}
🏢 六、多租户隔离与实体设计
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Volo.Abp.Domain.Entities.Auditing;
using Volo.Abp.MultiTenancy;public class EmailTemplate : FullAuditedAggregateRoot<Guid>, IMultiTenant
{public Guid? TenantId { get; set; } // 🏷️ 多租户隔离[Timestamp]public byte[] RowVersion { get; set; } // 🔐 乐观并发public string Name { get; set; }public string Language { get; set; }public int Version { get; set; }public string Subject { get; set; }public string Body { get; set; }public bool IsActive { get; set; } = true;
}
⚙️ 七、应用服务:并发安全与原子回滚
using System.Data;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
using Volo.Abp;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.Uow;public class EmailTemplateAppService : ApplicationService, IEmailTemplateAppService
{private readonly IRepository<EmailTemplate, Guid> _repo;private readonly IMemoryCache _memCache;private readonly IDistributedCache<EmailTemplateCacheItem> _distCache;private readonly ITemplateRenderer _templateRenderer;private readonly IDbContextProvider<NotificationDbContext> _dbContextProvider;public EmailTemplateAppService(IRepository<EmailTemplate, Guid> repo,IMemoryCache memCache,IDistributedCache<EmailTemplateCacheItem> distCache,ITemplateRenderer templateRenderer,IDbContextProvider<NotificationDbContext> dbContextProvider){_repo = repo;_memCache = memCache;_distCache = distCache;_templateRenderer = templateRenderer;_dbContextProvider = dbContextProvider;}[UnitOfWork][Authorize(NotificationPermissions.EmailTemplate.Manage)]public async Task<EmailTemplateDto> CreateOrUpdateAsync(CreateOrUpdateDto input){var existing = await _repo.FindAsync(t => t.Name == input.Name &&t.Language == input.Language &&t.IsActive);if (existing != null){// 乐观并发检查if (!existing.RowVersion.SequenceEqual(input.RowVersion))throw new AbpConcurrencyException("模板已被其他人修改,请刷新后重试。");existing.Subject = input.Subject;existing.Body = input.Body;existing.Version++;await _repo.UpdateAsync(existing);}else{existing = new EmailTemplate(GuidGenerator.Create(),input.Name,input.Language,1,input.Subject,input.Body){ TenantId = CurrentTenant.Id };await _repo.InsertAsync(existing);}// 🔥 预编译/预热:调用一次 RenderAsyncawait _templateRenderer.RenderAsync(existing.Subject, new { });await _templateRenderer.RenderAsync(existing.Body, new { });// 🏷️ 清理缓存var key = CacheKey(input.Name, input.Language);_memCache.Remove(key);await _distCache.RemoveAsync(key);return ObjectMapper.Map<EmailTemplate, EmailTemplateDto>(existing);}[UnitOfWork][Authorize(NotificationPermissions.EmailTemplate.Manage)]public async Task RollbackAsync(RollbackDto input){var dbContext = await _dbContextProvider.GetDbContextAsync();// 原子批量回滚await dbContext.Database.ExecuteSqlRawAsync(@"UPDATE EmailTemplatesSET IsActive = CASE WHEN Version = {0} THEN 1 ELSE 0 ENDWHERE Name = {1} AND Language = {2} AND TenantId = {3}",input.Version, input.Name, input.Language, CurrentTenant.Id);// 🏷️ 清理缓存var key = CacheKey(input.Name, input.Language);_memCache.Remove(key);await _distCache.RemoveAsync(key);}private string CacheKey(string name, string lang) =>$"Tpl:{CurrentTenant.Id}:{name}:{lang}:active";
}
🖥️ 八、渲染服务:双层缓存 & 多级回退
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Volo.Abp.TextTemplating;
using Volo.Abp.Domain.Repositories;public class EmailTemplateRenderer : IEmailTemplateRenderer, ITransientDependency
{private const string DefaultLang = "en";private readonly IRepository<EmailTemplate, Guid> _repo;private readonly IMemoryCache _memCache;private readonly IDistributedCache<EmailTemplateCacheItem> _distCache;private readonly ITemplateRenderer _templateRenderer;private readonly ITemplateDefinitionManager _defManager;public EmailTemplateRenderer(IRepository<EmailTemplate, Guid> repo,IMemoryCache memCache,IDistributedCache<EmailTemplateCacheItem> distCache,ITemplateRenderer templateRenderer,ITemplateDefinitionManager defManager){_repo = repo;_memCache = memCache;_distCache = distCache;_templateRenderer = templateRenderer;_defManager = defManager;}public Task<string> RenderSubjectAsync(string name, string lang, object model)=> RenderAsync(name, lang, model, true);public Task<string> RenderBodyAsync(string name, string lang, object model)=> RenderAsync(name, lang, model, false);private async Task<string> RenderAsync(string name, string lang, object model, bool isSubject){var suffix = isSubject ? "Subject" : "Body";var key = $"Tpl:{CurrentTenant.Id}:{name}:{lang}:{suffix}";// 1⃣ 本地缓存if (_memCache.TryGetValue(key, out EmailTemplateCacheItem cacheItem))return isSubject ? cacheItem.Subject : cacheItem.Body;// 2⃣ 分布式缓存cacheItem = await _distCache.GetAsync(key, async () =>{// 3⃣ DB 指定语言 & 默认语言查找var tpl = await _repo.FindAsync(t =>t.TenantId == CurrentTenant.Id &&t.Name == name &&t.Language == lang &&t.IsActive) ?? await _repo.FindAsync(t =>t.TenantId == CurrentTenant.Id &&t.Name == name &&t.Language == DefaultLang &&t.IsActive);if (tpl != null)return new EmailTemplateCacheItem(tpl.Subject, tpl.Body);// 4⃣ 内置资源回退var defName = $"Email.{name}.{suffix}";var def = _defManager.GetOrNull(defName);if (def == null)throw new EntityNotFoundException(typeof(EmailTemplate), name);var text = await _templateRenderer.RenderAsync(def.VirtualFilePath, model);return isSubject? new EmailTemplateCacheItem(text, string.Empty): new EmailTemplateCacheItem(string.Empty, text);});// 5⃣ 本地缓存设置_memCache.Set(key, cacheItem, new MemoryCacheEntryOptions{SlidingExpiration = TimeSpan.FromMinutes(30),AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)});return isSubject ? cacheItem.Subject : cacheItem.Body;}
}[Serializable]
public class EmailTemplateCacheItem
{public string Subject { get; }public string Body { get; }public EmailTemplateCacheItem(string subject, string body) => (Subject, Body) = (subject, body);
}
📨 九、邮件发送与附件支持(Outbox & 重试)
public class NotificationManager : DomainService
{private readonly IEmailTemplateRenderer _renderer;private readonly IEmailSender _emailSender;private readonly ILogger<NotificationManager> _logger;public NotificationManager(IEmailTemplateRenderer renderer,IEmailSender emailSender,ILogger<NotificationManager> logger){_renderer = renderer;_emailSender = emailSender;_logger = logger;}public async Task SendWelcomeAsync(string to, object model){try{var subj = await _renderer.RenderSubjectAsync("Welcome", "zh-CN", model);var body = await _renderer.RenderBodyAsync("Welcome", "zh-CN", model);await _emailSender.SendAsync(new[] { to },subj,body,isBodyHtml: true,plainText: $"Hello, {(model as dynamic).UserName}!");}catch (Exception ex){_logger.LogError(ex, "发送 Welcome 邮件失败,收件人:{To}", to);throw;}}public async Task SendReportWithAttachmentAsync(string to, object model, byte[] attachment, string fileName){var subj = await _renderer.RenderSubjectAsync("MonthlyReport", "en", model);var body = await _renderer.RenderBodyAsync("MonthlyReport", "en", model);await _emailSender.SendWithAttachmentAsync(new[] { to },subj,body,true,attachments: new[] { new Attachment(fileName, attachment) });}
}
🔒 十、在线管理界面与权限控制
-
多租户筛选:仅展示当前租户模板
-
列表/版本:
Name
、Language
、Version
、IsActive
-
编辑:Monaco Editor,继承
RazorTemplatePageBase<TModel>
,支持语法校验 -
预览:输入 JSON 调用 Preview API 实时渲染
-
回滚:一键触发原子回滚
-
权限:所有管理接口与页面标注
[Authorize(NotificationPermissions.EmailTemplate.Manage)]
✅ 十一、自动测试与异常场景覆盖
- 多租户隔离:不同租户同名模板互不干扰
- 并发冲突:重复提交抛
AbpConcurrencyException
- 缓存失效:更新/回滚后渲染内容正确
- 多级回退:DB 无模板使用内置资源,否则友好抛错
🔍 十二、日志、监控与运维
- 日志:记录发送失败上下文(收件人、模板、租户)
- 审计:ABP 审计日志记录增删改、回滚操作
- 性能指标:Prometheus 埋点——渲染耗时、发送耗时、失败率
- 报警:Quartz Dashboard / Grafana 对重复失败 Outbox 任务告警