当前位置: 首页 > news >正文

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 [Timestamp] 乐观锁与单条 SQL 原子回滚。
  4. 双层缓存:本地 IMemoryCache + 分布式 IDistributedCache,滑动 & 绝对过期。
  5. 🔄 回退安全:利用 ITemplateDefinitionManager 加明确定义,捕获异常并友好报错。
  6. 🔥 预编译 & 预热:在发布时手动调用一次 RenderAsync,避免首次高并发编译。
  7. 完善测试:覆盖多租户隔离、并发冲突、缓存失效、多级回退与异常场景。

📈 二、系统流程图

若无 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);}// 🔥 预编译/预热:调用一次 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) });}
}

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

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

  • 列表/版本NameLanguageVersionIsActive

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

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

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

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

    [Authorize(NotificationPermissions.EmailTemplate.Manage)]
    

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

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

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

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

http://www.dtcms.com/a/297662.html

相关文章:

  • 智能网关芯片:物联网连接的核心引擎
  • 酷暑来袭,科技如何让城市清凉又洁净?
  • 制造业低代码平台实战评测:简道云、钉钉宜搭、华为云Astro、金蝶云·苍穹、斑斑低代码,谁更值得选?
  • 使用 FFmpeg 实现 RTP 音频传输与播放
  • 【Redis】初识Redis(定义、特征、使用场景)
  • Spring框架
  • 认识编程(3)-语法背后的认知战争:类型声明的前世今生
  • vue3单页面连接多个websocket并实现断线重连功能
  • 机器学习笔记(三)——决策树、随机森林
  • Git指令
  • git将本地文件完和仓库文件目录完全替换-------还有将本地更新的文件放到仓库中,直接提交即可
  • C# WPF 实现读取文件夹中的PDF并显示其页数
  • STM32与ADS1220多通道采样数据
  • vscode 登录ssh记住密码直接登录设置
  • GPU 服务器ecc报错处理
  • 详谈OSI七层模型和TCP/IP四层模型以及tcp与udp为什么是4层,http与https为什么是7层
  • SQL 查询与自定义管理工具设计:释放数据底层价值
  • linux C — udp,tcp通信
  • Docker技术入门与实战(附电子书资料)
  • 第七章 愿景10 小杨的立项课
  • 【Practical Business English Oral Scene Interpretation】入职面试No.8~9
  • [NLP]UPF+RTL联合仿真的VCS命令及UPF-aware 波形工具的使用
  • 练习实践-基础设施-文件共享-windows和linux之间的文件共享-smb服务搭建
  • 开发笔记 | 优化对话管理器脚本与对话语音的实现
  • Day 21: 常见的降维算法
  • 5G基站信号加速器!AD8021ARZ-REEL7亚德诺 超低噪声高速电压放大器 专利失真消除技术!
  • Web前端:JavaScript Math对象应用 随机背景颜色生成器
  • 【STM32项目】智能家居(版本1)
  • 关于“PromptPilot”
  • 详解:YOLO 系列演进趋势