🚀 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.pswp.cn/news/916164.shtml
繁体地址,请注明出处:http://hk.pswp.cn/news/916164.shtml
英文地址,请注明出处:http://en.pswp.cn/news/916164.shtml

如若内容造成侵权/违法违规/事实不符,请联系英文站点网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

疯狂星期四第19天运营日记

网站运营第19天&#xff0c;点击观站&#xff1a; 疯狂星期四 crazy-thursday.com 全网最全的疯狂星期四文案网站 运营报告 今日访问量 今日访问量42&#xff0c;疯狂之后的冷静&#xff0c;落差太大~~ 今日搜索引擎收录情况 必应仍然是24条记录&#xff0c;无变化 百度0收…

康养休闲旅游服务虚拟仿真实训室:赋能人才培养的创新路径

在康养休闲旅游行业数字化转型与职业教育改革的双重驱动下&#xff0c;康养休闲旅游服务虚拟仿真实训室已成为连接课堂教学与岗位实践的关键枢纽。它通过虚拟仿真技术重构康养服务场景&#xff0c;为学生打造沉浸式实践平台&#xff0c;在人才培养模式创新中发挥着不可替代的作…

python办自动化--读取邮箱中特定的邮件,并下载特定的附件

系列文章目录 python办公自动化–数据可视化&#xff08;pandasmatplotlib&#xff09;–生成条形图和饼状图 python办公自动化–数据可视化&#xff08;pandasmatplotlib&#xff09;–生成折线图 python办公自动化–数据可视化&#xff08;pandas读取excel文件&#xff0c;m…

清理DNS缓存

Cloudflarehttps://1.1.1.1/purge-cacheGooglehttps://dns.google/cacheOpenDNShttps://cachecheck.opendns.comLinux DNS缓存sudo systemd-resolve --flush-caches 或 sudo /etc/init.d/nscd restartWindows DNS缓存ipconfig /flushdnsmacOS DNS缓存sudo dscacheutil -flushca…

用 Python 写你的第一个爬虫:小白也能轻松搞定数据抓取(超详细包含最新所有Python爬虫库的教程)

用 Python 写你的第一个爬虫&#xff1a;小白也能轻松搞定数据抓取&#xff08;超详细包含最新所有Python爬虫库的教程&#xff09; 摘要 本文是一篇面向爬虫爱好者的超详细 Python 爬虫入门教程&#xff0c;涵盖了从基础到进阶的所有关键技术点&#xff1a;使用 Requests 与…

openmv识别数字

Lenet是一种卷积识别网络,可以用来识别打印的&#xff0c;或者是手写的数字利用NCC的模板匹配算法来进行数字识别&#xff0c;模板匹配需要我们事先保存需要匹配的数字以及字母的模板图片,模板匹配对于模板的大小和角度&#xff0c;有一定的要求如果数字的大小和角度有所变换&a…

一款功能全面的文体场所预约小程序

大家好👋 ,我是 阿问学长!专注于分享优质开源项目解析、计算机学习资料推荐,并为同学们提供毕业设计项目指导支持,欢迎关注交流!🚀 项目概述 随着全民健身的普及,各地新建了大批体育、健身、文化娱乐场所,中小学校园的运动设施也开始对市民开放。为了合理安排主办…

PyTorch中实现早停机制(EarlyStopping)附代码

1. 核心目的 当模型在验证集上的性能不再提升时&#xff0c;提前终止训练防止过拟合&#xff0c;节省计算资源 2. 实现方法 监控验证集指标&#xff08;如损失、准确率&#xff09;&#xff0c;设置耐心值&#xff08;Patience&#xff09; 3. 代码&#xff1a; class EarlySto…

Nacos-服务注册,服务发现(一)

nacos快速入手 Nacos是Spring Cloud Alibaba的组件, Spring Cloud Alibaba遵循Spring Cloud中定义的服务注册, 服 务发现规范. 因此使⽤Nacos和使⽤Eureka对于微服务来说&#xff0c;并没有太⼤区别. 主要差异在于&#xff1a; Eureka需要⾃⼰搭建⼀个服务, Nacos不⽤⾃⼰搭…

单片机(STM32-ADC模数转换器)

一、基础知识1. 模拟信号&#xff08;Analog Signal&#xff09;定义&#xff1a;模拟信号是连续变化的信号&#xff0c;可以取任意数值。特点&#xff1a;幅值和时间都是连续的&#xff0c;没有“跳变”。举例&#xff1a;声音&#xff08;麦克风采集到的电压&#xff09;温度…

side.cpp - OpenExo

side.cpp构造函数源代码run_side - 核心read_data()源代码FSR压力传感器读取与赋值步态事件检测&#xff1a;落地&#xff08;ground_strike&#xff09;步态周期自适应&#xff1a;期望步长更新Toe-Off/Toe-On事件检测与站立/摆动窗口更新步态百分比进度估算FSR阈值动态读取&a…

基于Java+MySQL实现(Web)文件共享管理系统(仿照百度文库)

文件共享管理系统的设计与实现摘要&#xff1a;本文件共享管理系统解决了用户在搜索文件不需要下载文件到本地硬盘后才能查看文件的详细内容的弊端&#xff1b;解决用户在搜索关键字不明确条件下无法搜索到自己需要的文件弊端&#xff1b;解决了系统用户并发量增加后服务器宕机…

go语言基础教程:1. Go 下载安装和设置

1. Go 下载安装和设置1. 安装Go 官网下载安装即可&#xff0c;注意要记住安装的位置&#xff0c;例如D:\Go cmd输入go 或者go env 会输出各种信息&#xff0c;代表安装成功 2. hello go &#xff08;1&#xff09;编写 hello.go go是以文件夹为最小单位管理程序的&#xff0c…

使用相机不同曝光时间测试灯光闪烁频率及Ai解释

1.背景坐地铁上&#xff0c;拨弄着手机拍照中的专业模式&#xff0c;偶然发现拍出了条纹&#xff0c;怀疑是灯光的缘故&#xff0c;但是随后在家里的LED等下就拍不出类似的效果了。好奇心❤让我又尝试多了解了一点和不断尝试&#xff0c;发现不同的曝光时间可以拍出不同明显程度…

力扣-416.分割等和子集

题目链接 416.分割等和子集 class Solution {public boolean canPartition(int[] nums) {int sum 0;for (int i 0; i < nums.length; i) {sum nums[i];}if (sum % 2 1)return false;int target sum / 2;// dp[i]表示&#xff1a;背包容量为i时&#xff0c;能装的最大…

http协议学习-body各种类型

1、概述使用postman工具和nc命令分析http协议中body各种类型的格式。2、分析环境准备虚拟机中用nc命令模仿服务器&#xff0c;启动监听状态。 windows机器安装postmannc -k -l 192.168.202.223 80821、params参数postman中params添加俩个key为m、n&#xff1b;value为1、2&…

C++中的塔尖算法(Tarjan算法)详解

C中的塔尖算法&#xff08;Tarjan算法&#xff09;详解——目录C中的塔尖算法&#xff08;Tarjan算法&#xff09;详解一、什么是Tarjan算法&#xff1f;二、算法原理与实现步骤1. 核心概念2. 主要逻辑3. C代码示例三、应用场景与扩展1. 典型应用2. 注意事项四、为什么选择Tarj…

Qt 数据库事务处理与数据安全

在 Qt 应用程序中&#xff0c;数据库事务处理是确保数据完整性和一致性的关键技术。通过事务&#xff0c;可以将多个数据库操作作为一个不可分割的单元执行&#xff0c;保证数据在并发访问和异常情况下的安全性。本文将详细介绍 Qt 中数据库事务的处理方法和数据安全策略。 一、…

Redis的事务和Lua之间的区别

Redis的事务和Lua之间的区别 Redis 提供了事务和 Lua 脚本两种实现原子性操作的方式。当需要以原子方式执行多个命令时,我们可以选择其中一种方案。 原子性保证 两者都确保操作的不可分割性 需要注意:不管是事务还是 Lua 脚本都不支持回滚机制 区别: 事务:某个命令失败不会…

腾讯云SDK

SDK的用途&#xff0c;现在显然是想更系统地了解它的产品定位和核心能力。 用户可能是开发者或者技术决策者&#xff0c;正在评估腾讯云的开发工具链。从ta连续追问云服务相关技术细节的习惯看&#xff0c;应该具备相当的技术背景&#xff0c;但需要避免过度使用术语。 需要突出…