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.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;但需要避免過度使用術語。 需要突出…