ABP VNext + Twilio:全渠道通知服务(SMS/Email/WhatsApp)

发布于:2025-06-30 ⋅ 阅读:(13) ⋅ 点赞:(0)

ABP VNext + Twilio:全渠道通知服务(SMS/Email/WhatsApp) 🚀



一、引言 ✨

  • 📝 TL;DR

    1. 📡 使用 Twilio SDK + SendGrid 在 ABP VNext 中统一封装 SMS、Email、WhatsApp 通道
    2. ⚙️ 单例 RazorLight 引擎模板渲染,一行代码推送多渠道
    3. 🔄 Outbox + Quartz + ABP Unit of Work 保证事务一致与高可用异步投递
    4. 🔑 Azure Key Vault 管理密钥;🐇 RabbitMQ 死信;📈 Prometheus 指标;❤️‍🩹 Health Checks;🗑 Quartz 清理

现代应用需在用户注册、订单状态、营销推送等场景并发多渠道通知。原生调用缺少模板化、事务保障、限流重试、安全存储和监控。


二、环境与依赖 🛠️

  • 平台:.NET 6 + ABP VNext 6.x

  • NuGet 包

    <PackageReference Include="Twilio" Version="5.*" />
    <PackageReference Include="SendGrid" Version="10.*" />
    <PackageReference Include="RazorLight" Version="2.*" />
    <PackageReference Include="Volo.Abp.BackgroundJobs" Version="6.*" />
    <PackageReference Include="Quartz.Extensions.Hosting" Version="3.*" />
    <PackageReference Include="Polly" Version="7.*" />
    <PackageReference Include="DistributedLock.Core" Version="6.*" />
    <PackageReference Include="Microsoft.Extensions.HealthChecks" Version="7.*" />
    <PackageReference Include="prometheus-net.AspNetCore" Version="7.*" />
    <PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.*" />
    <PackageReference Include="Microsoft.Azure.KeyVault" Version="4.*" />
    <PackageReference Include="RabbitMQ.Client" Version="7.*" />
    <PackageReference Include="Swashbuckle.AspNetCore" Version="6.*" />
    
  • appsettings.json 示例

    {
      "KeyVault": {
        "Enabled": true,
        "VaultUri": "https://your-vault.vault.azure.net/"
      },
      "Twilio": { /*…*/ },
      "SendGrid": { /*…*/ },
      "Notification": {
        "MaxParallelism": 5,
        "QuartzCron": "0/30 * * * * ?",
        "DeadLetterQueue": "notification.dlx"
      }
    }
    

三、系统架构概览 🏗️

平台与运维
调度层
核心模块
应用层
调用
Quartz Timer
限流/重试
失败后
指标
SMS/Email/WhatsApp
Webhook 回调
CallbackController
Metrics Endpoint
/health
Health Checks
OutboxJobScheduler
OutboxJobProcessor
Twilio/SendGridClient
RabbitMQ DeadLetter
Prometheus
NotificationService
NotificationOutbox
INotificationService
ApplicationService

四、Secrets 管理 🔑

public override void ConfigureServices(ServiceConfigurationContext context)
{
    var cfg = context.Services.GetConfiguration();
    if (cfg.GetValue<bool>("KeyVault:Enabled"))
    {
        context.ConfigurationBuilder
            .AddAzureKeyVault(
              new Uri(cfg["KeyVault:VaultUri"]),
              new DefaultAzureCredential());
    }
    context.Services.Configure<TwilioOptions>(cfg.GetSection("Twilio"));
    context.Services.Configure<SendGridOptions>(cfg.GetSection("SendGrid"));
    // …
}

Tip:生产环境通过 Key Vault 托管密钥,安全可靠。


五、本地开发 Secrets 模拟 🏠

# 使用 .NET 用户机密存储
dotnet user-secrets init
dotnet user-secrets set "Twilio:AccountSid" "local-sid"
dotnet user-secrets set "Twilio:AuthToken"  "local-token"
dotnet user-secrets set "SendGrid:ApiKey"    "local-sendgrid-key"

本地调试时,appsettings.json 不包含敏感信息,使用用户机密模拟 Key Vault。


六、客户端封装与插件化 🔌

  1. 通道抽象

    public interface IChannelSender
    {
      Channel Channel { get; }
      Task SendAsync(string payloadJson, CancellationToken token = default);
    }
    
  2. SmsSender

    public class SmsSender : IChannelSender, ITransientDependency
    {
        public Channel Channel => Channel.SMS;
        private readonly TwilioOptions _opts;
        public SmsSender(IOptions<TwilioOptions> opts) => _opts = opts.Value;
    
        public Task SendAsync(string payloadJson, CancellationToken token = default)
        {
            var p = JsonSerializer.Deserialize<SmsPayload>(payloadJson)!;
            var client = new TwilioRestClient(_opts.AccountSid, _opts.AuthToken);
            return client.Messages.CreateAsync(
                to: new PhoneNumber(p.To),
                from: new PhoneNumber(_opts.FromPhone),
                body: p.Body, cancellationToken: token);
        }
    }
    
  3. EmailSender、WhatsAppSender 类似,均注入 CancellationToken 支持。

  4. 核心服务

    public class NotificationService : INotificationService, ITransientDependency
    {
      public async Task SendAsync(Channel channel, string templateKey, object model, string to, string? subjectKey = null)
      {
        var body = await _tplMgr.RenderAsync($"{channel}/{templateKey}.cshtml", model);
        var subject = subjectKey == null 
          ? null 
          : await _tplMgr.RenderAsync($"Email/Subject_{subjectKey}.txt", model);
    
        var payload = JsonSerializer.Serialize(new { To = to, Subject = subject, Body = body });
        _db.NotificationOutboxes.Add(new NotificationOutbox(channel, payload));
        await _uow.SaveChangesAsync(); 
      }
    }
    

七、模板管理 📄

public class RazorLightTemplateManager : ITemplateManager, ISingletonDependency
{
    private readonly RazorLightEngine _engine;
    public RazorLightTemplateManager(IConfiguration config)
    {
        _engine = new RazorLightEngineBuilder()
            .UseFileSystemProject(Path.Combine(AppContext.BaseDirectory, "Templates"))
            .UseMemoryCachingProvider()
            .Build();
    }
    public Task<string> RenderAsync(string key, object model) =>
        _engine.CompileRenderAsync(key, model);
}
/Templates
 ├─ SMS/Sms_VerifyCode.cshtml
 ├─ Email/Subject_OrderShipped.txt
 ├─ Email/OrderShipped.cshtml
 └─ WhatsApp/Promotion.cshtml

八、事务与 Outbox 模式 🔄

public class NotificationOutbox : Entity<Guid>
{
    public Channel Channel { get; set; }
    public string Payload  { get; set; }
    public bool IsSent     { get; set; }
    public DateTime CreatedTime { get; set; }
    public int RetryCount  { get; set; }  // 死信限次
    public NotificationOutbox(Channel ch, string payload)
    {
        Id = Guid.NewGuid();
        Channel = ch; Payload = payload;
        IsSent = false; RetryCount = 0;
        CreatedTime = DateTime.UtcNow;
    }
}

Tip:新增 RetryCount 字段,避免死信循环。


九、Quartz 调度与清理作业 ⏰

9.1 投递调度

public class OutboxJobProcessor : IJob, ISingletonDependency
{
    private readonly CancellationTokenSource _cts = new();
    public async Task Execute(IJobExecutionContext _)
    {
        var batch = await _db.NotificationOutboxes
          .Where(x=>!x.IsSent && x.RetryCount < 5)
          .Take(NotificationConsts.MaxParallelism)
          .ToListAsync(_cts.Token);

        foreach (var e in batch)
        {
            try
            {
                using (_latency.NewTimer())
                using (await _lock.AcquireLockAsync($"lock-{e.Id}", TimeSpan.FromSeconds(30)))
                {
                    await Policy.WrapAsync(
                      Policy.BulkheadAsync(NotificationConsts.MaxParallelism, int.MaxValue),
                      Policy.RateLimitAsync(10, TimeSpan.FromSeconds(1)),
                      Policy.Handle<Exception>().WaitAndRetryAsync(
                        retryCount:3, sleepDurationProvider: i=>TimeSpan.FromSeconds(Math.Pow(2, i)),
                        onRetry: (ex, _, i, _) => {
                          e.RetryCount++;
                          _logger.LogWarning("Outbox {Id} 第 {Count} 次重试", e.Id, i);
                        })
                    ).ExecuteAsync(ct=> ProcessAsync(e, ct), _cts.Token);
                }
                _total.Inc();
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Outbox({Id}) 失败,转死信队列", e.Id);
                _rabbit.BasicPublish("", _opts.DeadLetterQueue, null, Encoding.UTF8.GetBytes(e.Payload));
            }
        }
    }
    public Task StopAsync() => Task.Run(() => _cts.Cancel());
    private async Task ProcessAsync(NotificationOutbox e, CancellationToken token)
    {
        var sender = _senders.First(s=>s.Channel==e.Channel);
        await sender.SendAsync(e.Payload, token);
        e.IsSent = true;
        await _db.SaveChangesAsync(token);
    }
}

9.2 清理作业

public class OutboxCleanupJob : IJob, ISingletonDependency
{
    public async Task Execute(IJobExecutionContext _)
    {
        var cutoff = DateTime.UtcNow.AddDays(-30);
        var toDel = await _db.NotificationOutboxes
          .Where(x=>x.IsSent && x.CreatedTime<cutoff)
          .Take(1000).ToListAsync();
        _db.RemoveRange(toDel);
        await _db.SaveChangesAsync();
    }
}

十、监控与健康检查 📊❤️‍🩹

// Startup.ConfigureServices
services.AddHealthChecks()
  .AddSqlServer(cfg.GetConnectionString("Default"), name:"sql")
  .AddRabbitMQ(cfg["Notification:DeadLetterQueue"], name:"rabbitmq")
  .AddUrlGroup("https://api.twilio.com", name:"twilio")
  .AddUrlGroup("https://api.sendgrid.com", name:"sendgrid");

app.UseHttpMetrics(); // Prometheus /metrics
app.UseEndpoints(e=>{
  e.MapHealthChecks("/health");
  e.MapMetrics();
});

十一、Twilio Webhook 与安全 🔒

[HttpPost("twilio-callback"), EnableRateLimiting("Default")]
public async Task<IActionResult> TwilioCallback(CancellationToken token)
{
    var req = Request; var form = await req.ReadFormAsync(token);
    var sig = req.Headers["X-Twilio-Signature"].FirstOrDefault()!;
    var url = $"{req.Scheme}://{req.Host}{req.Path}";

    var validator = new RequestValidator(_opts.AuthToken);
    if (!validator.Validate(url, form.ToDictionary(k=>k.Key, v=>v.Value.ToString()), sig))
        return Unauthorized();

    var sid = form["MessageSid"].ToString();
    var status = form["MessageStatus"].ToString();
    var e = await _db.NotificationOutboxes
      .Where(x=>x.Payload.Contains(sid)).FirstOrDefaultAsync(token);
    if (e!=null)
    {
        e.IsSent = status=="delivered";
        await _db.SaveChangesAsync(token);
    }
    return Ok();
}

十二、死信补偿 🎯

public class DeadLetterConsumer : BackgroundService
{
    protected override Task ExecuteAsync(CancellationToken ct)
    {
        var channel = _rabbit.CreateModel();
        channel.BasicConsume("notification.dlx", false, new EventingBasicConsumer(channel)
        {
            Received = async (_, ea) =>
            {
                if (ct.IsCancellationRequested) return;
                var payload = Encoding.UTF8.GetString(ea.Body.ToArray());
                await _db.NotificationOutboxes.AddAsync(new NotificationOutbox(Channel.SMS, payload), ct);
                await _db.SaveChangesAsync(ct);
                channel.BasicAck(ea.DeliveryTag, false);
            }
        });
        return Task.CompletedTask;
    }
}

参考文档 📄