🚀 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 任務告警