> 技术文档 > ABP VNext + Razor 邮件模板:动态、多租户隔离、可版本化的邮件与通知系统

ABP VNext + Razor 邮件模板:动态、多租户隔离、可版本化的邮件与通知系统


🚀 ABP VNext + Razor 邮件模板:动态、多租户隔离、可版本化的邮件与通知系统


📚 目录

  • 🚀 ABP VNext + Razor 邮件模板:动态、多租户隔离、可版本化的邮件与通知系统
    • 🌟 一、TL;DR
    • 📈 二、系统流程图
    • 🛠 三、环境与依赖
    • 🏗 四、项目骨架与模块注册
      • 4.1 目录结构
      • 4.2 模块依赖与注册
    • 🏷️ 五、模板定义提供者
    • 🏢 六、多租户隔离与实体设计
    • ⚙️ 七、应用服务:并发安全与原子回滚
    • 🖥️ 八、渲染服务:双层缓存 & 多级回退
    • 📨 九、邮件发送与附件支持(Outbox & 重试)
    • 🔒 十、在线管理界面与权限控制
    • ✅ 十一、自动测试与异常场景覆盖
    • 🔍 十二、日志、监控与运维

🌟 一、TL;DR

  1. 🎯 零依赖第三方:基于 Volo.Abp.TextTemplating.RazorVolo.Abp.MailKit 和内置 IEmailSender/Outbox。
  2. 🏢 多租户隔离:实体实现 IMultiTenant,自动启用租户过滤。
  3. 🔐 并发 & 原子操作:采用 EF Core 1753531708 乐观锁与单条 SQL 原子回滚。
  4. 双层缓存:本地 IMemoryCache + 分布式 IDistributedCache,滑动 & 绝对过期。
  5. 🔄 回退安全:利用 ITemplateDefinitionManager 加明确定义,捕获异常并友好报错。
  6. 🔥 预编译 & 预热:在发布时手动调用一次 RenderAsync,避免首次高并发编译。
  7. 完善测试:覆盖多租户隔离、并发冲突、缓存失效、多级回退与异常场景。

📈 二、系统流程图

#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) } ); }}

🔒 十、在线管理界面与权限控制

  • 多租户筛选:仅展示当前租户模板

  • 列表/版本NameLanguageVersionIsActive

  • 编辑:Monaco Editor,继承 RazorTemplatePageBase,支持语法校验

  • 预览:输入 JSON 调用 Preview API 实时渲染

  • 回滚:一键触发原子回滚

  • 权限:所有管理接口与页面标注

    [Authorize(NotificationPermissions.EmailTemplate.Manage)]

✅ 十一、自动测试与异常场景覆盖

  • 多租户隔离:不同租户同名模板互不干扰
  • 并发冲突:重复提交抛 AbpConcurrencyException
  • 缓存失效:更新/回滚后渲染内容正确
  • 多级回退:DB 无模板使用内置资源,否则友好抛错

🔍 十二、日志、监控与运维

  • 日志:记录发送失败上下文(收件人、模板、租户)
  • 审计:ABP 审计日志记录增删改、回滚操作
  • 性能指标:Prometheus 埋点——渲染耗时、发送耗时、失败率
  • 报警:Quartz Dashboard / Grafana 对重复失败 Outbox 任务告警