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

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.xdnf.cn/news/16322.html

相关文章:

  • java面试题1
  • IOPaint 图像修复工具,学习笔记
  • openmv识别数字
  • 质数、因数、最大公约数经典问题整理
  • KNN 算法进阶:从基础到优化的深度解析
  • lesson24:Python的logging模块
  • 将文件移入回收站而不是直接删除
  • 7月25号打卡
  • 太极生两仪,两仪生四象,四象生八卦
  • 13.使用C连接mysql
  • Windows Server 2003 R2系统C盘扩容教程
  • 【深度学习新浪潮】Claude code是什么样的一款产品?
  • 【Linux系统】基础IO(下)
  • 常见问题三
  • linux 进程信号
  • 佳能iR-ADV C5560复印机如何扫描文件到电脑
  • Gorm教程 - 关联
  • 电厂液压执行器自动化升级:Modbus TCP与DeviceNet的协议贯通实践
  • 微观低代码
  • SpringBoot实战指南:从快速入门到生产级部署(2025最新版)
  • 【运维】ubuntu 安装图形化界面
  • Vue2下
  • SQLFluff
  • Hive-vscode-snippets
  • [特殊字符] 第9篇:《SQL高阶 SELECT 技巧:DISTINCT、ORDER BY、LIMIT 全家桶》
  • CN3798-2A 降压型单节锂电池充电芯片
  • Androidstudio 上传当前module 或本地jar包到maven服务器。
  • 二分查找----6.寻找两个正序数组的中位数
  • Python 数据分析(一):NumPy 基础知识
  • PI 思维升级 PI设计的典范转移:从阻抗思维到谐振控制