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
1753531708
乐观锁与单条 SQL 原子回滚。 - ⚡ 双层缓存:本地
IMemoryCache
+ 分布式IDistributedCache
,滑动 & 绝对过期。 - 🔄 回退安全:利用
ITemplateDefinitionManager
加明确定义,捕获异常并友好报错。 - 🔥 预编译 & 预热:在发布时手动调用一次
RenderAsync
,避免首次高并发编译。 - ✅ 完善测试:覆盖多租户隔离、并发冲突、缓存失效、多级回退与异常场景。
📈 二、系统流程图
#mermaid-svg-eahLG6P0hHRogqsa {font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-eahLG6P0hHRogqsa .error-icon{fill:#552222;}#mermaid-svg-eahLG6P0hHRogqsa .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-eahLG6P0hHRogqsa .edge-thickness-normal{stroke-width:2px;}#mermaid-svg-eahLG6P0hHRogqsa .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-eahLG6P0hHRogqsa .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-eahLG6P0hHRogqsa .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-eahLG6P0hHRogqsa .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-eahLG6P0hHRogqsa .marker{fill:#333333;stroke:#333333;}#mermaid-svg-eahLG6P0hHRogqsa .marker.cross{stroke:#333333;}#mermaid-svg-eahLG6P0hHRogqsa svg{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-eahLG6P0hHRogqsa .label{font-family:\"trebuchet ms\",verdana,arial,sans-serif;color:#333;}#mermaid-svg-eahLG6P0hHRogqsa .cluster-label text{fill:#333;}#mermaid-svg-eahLG6P0hHRogqsa .cluster-label span{color:#333;}#mermaid-svg-eahLG6P0hHRogqsa .label text,#mermaid-svg-eahLG6P0hHRogqsa span{fill:#333;color:#333;}#mermaid-svg-eahLG6P0hHRogqsa .node rect,#mermaid-svg-eahLG6P0hHRogqsa .node circle,#mermaid-svg-eahLG6P0hHRogqsa .node ellipse,#mermaid-svg-eahLG6P0hHRogqsa .node polygon,#mermaid-svg-eahLG6P0hHRogqsa .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-eahLG6P0hHRogqsa .node .label{text-align:center;}#mermaid-svg-eahLG6P0hHRogqsa .node.clickable{cursor:pointer;}#mermaid-svg-eahLG6P0hHRogqsa .arrowheadPath{fill:#333333;}#mermaid-svg-eahLG6P0hHRogqsa .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-eahLG6P0hHRogqsa .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-eahLG6P0hHRogqsa .edgeLabel{background-color:#e8e8e8;text-align:center;}#mermaid-svg-eahLG6P0hHRogqsa .edgeLabel rect{opacity:0.5;background-color:#e8e8e8;fill:#e8e8e8;}#mermaid-svg-eahLG6P0hHRogqsa .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-eahLG6P0hHRogqsa .cluster text{fill:#333;}#mermaid-svg-eahLG6P0hHRogqsa .cluster span{color:#333;}#mermaid-svg-eahLG6P0hHRogqsa div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-eahLG6P0hHRogqsa :root{--mermaid-font-family:\"trebuchet ms\",verdana,arial,sans-serif;} 若无 DB 模板 💾 模板存储与版本管理 🔥 预编译/预热 🏷 缓存 (本地/分布式) 🖥️ 模板渲染 📨 统一发送接口 🔄 Outbox & 重试 📬 邮件投递 🛠️ 在线管理 UI 📦 内置资源回退
🛠 三、环境与依赖
-
.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); } // 🔥 预编译/预热:调用一次 RenderAsync await _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 EmailTemplates SET IsActive = CASE WHEN Version = {0} THEN 1 ELSE 0 END WHERE 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
,支持语法校验 -
预览:输入 JSON 调用 Preview API 实时渲染
-
回滚:一键触发原子回滚
-
权限:所有管理接口与页面标注
[Authorize(NotificationPermissions.EmailTemplate.Manage)]
✅ 十一、自动测试与异常场景覆盖
- 多租户隔离:不同租户同名模板互不干扰
- 并发冲突:重复提交抛
AbpConcurrencyException
- 缓存失效:更新/回滚后渲染内容正确
- 多级回退:DB 无模板使用内置资源,否则友好抛错
🔍 十二、日志、监控与运维
- 日志:记录发送失败上下文(收件人、模板、租户)
- 审计:ABP 审计日志记录增删改、回滚操作
- 性能指标:Prometheus 埋点——渲染耗时、发送耗时、失败率
- 报警:Quartz Dashboard / Grafana 对重复失败 Outbox 任务告警