← 返回博客

Hangfire Recurring 事务邮件 + Rovela(2026):RecurringJob.AddOrUpdate 与按窗口幂等键

用 Hangfire RecurringJob.AddOrUpdate(cron / Cron.* + RecurringJobOptions)对接 Rovela POST /emails:服务端 sending_access,按调度窗口 Idempotency-Key,适合摘要与运维报告。

更新于 2026年11月5日
Hangfire Recurring 事务邮件 + Rovela(2026):RecurringJob.AddOrUpdate 与按窗口幂等键

Hangfire 负责调度 — Rovela 负责投递

Hangfire 是 .NET / ASP.NET Core 常用的后台任务库。周期任务保存在持久化存储(SQL Server、Redis、PostgreSQL 等)。Hangfire Server 大约按分钟粒度检查 recurring jobs,到期后入队为普通即时后台任务,可在 Dashboard 跟踪(Performing Recurrent Tasks)。

用 RecurringJob.AddOrUpdate 注册或更新:唯一 recurring job id、方法表达式,以及 Cron.* 或 CRON 字符串。Hangfire 1.8 推荐显式 id,时区用 RecurringJobOptions(队列名作为独立参数)。适合摘要、运维报告、试用到期提醒等——正文依赖触发时的应用/数据库实时状态(注册数、未付发票、失败任务),而不是能提前用 Rovela scheduled_at 冻住的内容。

你的 Hangfire 任务方法调用 POST https://api.rovela.dev/emails 时,Rovela 负责排队、DKIM 签名与投递(Send Email API、Quick Start、Authentication)。Hangfire 管节奏与「是否到期」;任务方法管聚合、sending_access 密钥,以及按调度窗口稳定的 Idempotency-Key。Rovela 只负责投递。

本指南串联 Hangfire recurring → 任务方法 → Rovela:

  1. 声明 RecurringJob.AddOrUpdate("daily-digest-email", …, Cron.Daily / "0 9 * * *", options)
  2. 将 ROVELA_API_KEY(sending_access)、MAIL_FROM、存储连接串、收件人放在仅服务端配置——绝不进浏览器、SPA 或公开 Git
  3. 在任务方法内按当前窗口聚合摘要/报告
  4. 用窗口级 Idempotency-Key 调用 Rovela
  5. 在 AutomaticRetry、进程重启、多 Server 集群下避免重复发信

Rovela 不会创建 Hangfire recurring、SQL/Redis 存储或 Background Job Server。 Hangfire 管 schedule → enqueue → execute;你的方法管查询 → 映射字段 → POST /emails;Rovela 管投递。

与下列文章角度不同:

也不同于一次性的 BackgroundJob.Enqueue / Schedule——本文只讲 recurring(RecurringJob.AddOrUpdate / cron)。

Hangfire 调度什么,Rovela 发送什么

关注点归属
Recurring id、CRON / Cron.*、方法表达式、队列、时区Hangfire RecurringJob.AddOrUpdate
持久化存储你的 Hangfire storage 包
分钟检查 → 入队即时任务Hangfire Server
重试(AutomaticRetry)、Worker、DashboardHangfire
ROVELA_API_KEY(sending_access)ASP.NET Core / Worker 环境或密钥库
按窗口查库 / 调 API你的任务类方法
渲染 HTML + text你的任务类方法
POST /emails、DKIM、排队、退信Rovela
Idempotency-Key = hangfire-daily-digest:{yyyy-mm-dd}你的任务方法

Rovela 返回 200 表示已接受并入队,不是「已进收件箱」(Quick Start)。

影响发信任务的 Hangfire 约束

  • Hangfire Server 必须常驻。 调度与执行都在 Background Job Server 内;若在 ASP.NET 内处理,需保持应用常开(ASP.NET Core Applications)。
  • 使用显式 recurring job id。 1.8 弃用隐式 id——调用 AddOrUpdate("daily-digest-email", …),避免多计划撞到同一方法名。
  • 时区用 RecurringJobOptions。 旧的可选 timeZone: / queue: 参数已弃用。
  • 内置 CRON 一般为五段;Server 约每分钟检查一次,不适合亚分钟发信。
  • id 需唯一(部分存储可能区分大小写)。
  • RemoveIfExists / Trigger 管理生命周期;手动 Trigger 不会改写下次计划时间。
  • sending_access 仅服务端。 放环境变量 / Key Vault,勿进客户端、可进存储的方法参数或 Git(Authentication)。
  • 优先 HTTPS POST /emails。 Worker 直连 SMTP 通常更差(Email API vs SMTP)。

何时用 Hangfire + POST /emails(而不是 scheduled_at)

在下列情况用 Hangfire recurring → 即时发送:

  • HTML 必须在触发时包含新鲜指标
  • 已在 .NET 跑 Hangfire,希望调度与 Worker / Dashboard 同处
  • 需要类 cron 摘要,但不想另上 SaaS 调度器
  • 运行时查库得到的少量运维/创始人/客户地址

正文已知、只需「稍后投递」且不必跑 Hangfire Server 时,用 Rovela scheduled_at(定时发送邮件)。两者问题不同;本文是 Hangfire 路径。

若要 Sidekiq-Cron / Celery Beat / BullMQ / Agenda / Temporal / Trigger.dev / Inngest / QStash 或平台 cron,见上文链接。

前置条件

  1. Rovela 已验证发信域名;未验证 from 会 400。
  2. re_… API Key,Worker 优先 sending_access。
  3. ASP.NET Core(或其他 .NET 宿主)安装 Hangfire(如 Hangfire.AspNetCore + Hangfire.SqlServer),并调用 AddHangfireServer()。
  4. 配置:ROVELA_API_KEY、MAIL_FROM、DIGEST_TO、Hangfire 存储连接串。
  5. Hangfire 可解析的任务类(示例如下,含 HttpClient)。

注册 Hangfire 与 recurring(ASP.NET Core)

<ItemGroup>
  <PackageReference Include="Hangfire.Core" Version="1.8.*" />
  <PackageReference Include="Hangfire.SqlServer" Version="1.8.*" />
  <PackageReference Include="Hangfire.AspNetCore" Version="1.8.*" />
  <PackageReference Include="Microsoft.Data.SqlClient" Version="*" />
</ItemGroup>
using Hangfire;
using Hangfire.SqlServer;

builder.Services.AddHangfire(configuration => configuration
    .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
    .UseSimpleAssemblyNameTypeSerializer()
    .UseRecommendedSerializerSettings()
    .UseSqlServerStorage(builder.Configuration.GetConnectionString("HangfireConnection")));

builder.Services.AddHangfireServer();
builder.Services.AddHttpClient();
builder.Services.AddTransient<DailyDigestEmailJob>();

var app = builder.Build();
app.UseHangfireDashboard(); // 生产环境需为非本机访问配置授权

RecurringJob.AddOrUpdate<DailyDigestEmailJob>(
    "daily-digest-email",
    job => job.ExecuteAsync(),
    Cron.Daily(hour: 9, minute: 0), // 或 "0 9 * * *"
    new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });

app.Run();

带队列名(Hangfire 1.8 显式 queue 参数):

RecurringJob.AddOrUpdate<DailyDigestEmailJob>(
    "daily-digest-email",
    "mailers",
    job => job.ExecuteAsync(),
    "0 9 * * *",
    new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });

本地时区晨间摘要:

RecurringJob.AddOrUpdate<DailyDigestEmailJob>(
    "tokyo-morning-ops-email",
    job => job.ExecuteAsync(),
    "0 5 * * *",
    new RecurringJobOptions
    {
        // Linux 用 IANA;Windows 可能是 "Tokyo Standard Time"
        TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo")
    });

调用 Rovela 的任务方法

using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Hangfire;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;

public sealed class DailyDigestEmailJob
{
    private readonly IHttpClientFactory _httpClientFactory;
    private readonly IConfiguration _config;
    private readonly ILogger<DailyDigestEmailJob> _logger;

    public DailyDigestEmailJob(
        IHttpClientFactory httpClientFactory,
        IConfiguration config,
        ILogger<DailyDigestEmailJob> logger)
    {
        _httpClientFactory = httpClientFactory;
        _config = config;
        _logger = logger;
    }

    [AutomaticRetry(Attempts = 5)]
    public async Task ExecuteAsync()
    {
        var day = DateTime.UtcNow.ToString("yyyy-MM-dd");
        var idempotencyKey = $"hangfire-daily-digest:{day}";

        var digest = BuildDailyDigest(day);
        var to = (_config["DIGEST_TO"] ?? throw new InvalidOperationException("missing DIGEST_TO"))
            .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);

        // 切勿记录 ROVELA_API_KEY
        _logger.LogInformation(
            "hangfire digest day={Day} idempotencyKey={Key}",
            day, idempotencyKey);

        var result = await SendTransactionalAsync(
            to: to,
            subject: digest.Subject,
            html: digest.Html,
            text: digest.Text,
            idempotencyKey: idempotencyKey,
            tags: new Dictionary<string, string>
            {
                ["kind"] = "hangfire-daily-digest",
                ["day"] = day,
                ["platform"] = "hangfire",
                ["job"] = nameof(DailyDigestEmailJob),
            });

        _logger.LogInformation("rovela accepted emailId={EmailId} day={Day}", result.Id, day);
    }

    private static Digest BuildDailyDigest(string day)
    {
        var stats = new { Signups = 12, Paid = 3, Incidents = 1, FailedJobs = 0 };
        var subject = $"Acme daily digest — {day}";
        var text =
            $"Signups: {stats.Signups}\nPaid: {stats.Paid}\nOpen incidents: {stats.Incidents}\nFailed jobs: {stats.FailedJobs}";
        var html =
            $"<h1>Daily digest — {day}</h1><ul>" +
            $"<li>Signups: <b>{stats.Signups}</b></li>" +
            $"<li>Paid: <b>{stats.Paid}</b></li>" +
            $"<li>Open incidents: <b>{stats.Incidents}</b></li>" +
            $"<li>Failed jobs: <b>{stats.FailedJobs}</b></li>" +
            $"</ul><p><a href=\"https://app.example.com/admin\">Open admin</a></p>";
        return new Digest(subject, html, text);
    }

    private async Task<RovelaEmailResponse> SendTransactionalAsync(
        string[] to, string subject, string html, string text,
        string idempotencyKey, Dictionary<string, string> tags)
    {
        var apiKey = _config["ROVELA_API_KEY"]
            ?? throw new InvalidOperationException("missing ROVELA_API_KEY");
        var from = _config["MAIL_FROM"]
            ?? throw new InvalidOperationException("missing MAIL_FROM");

        var client = _httpClientFactory.CreateClient();
        using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.rovela.dev/emails");
        req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
        req.Headers.TryAddWithoutValidation("Idempotency-Key", idempotencyKey);
        req.Content = JsonContent.Create(new { from, to, subject, html, text, tags });

        using var res = await client.SendAsync(req);
        var body = await res.Content.ReadAsStringAsync();
        if (!res.IsSuccessStatusCode)
            throw new InvalidOperationException($"Rovela {(int)res.StatusCode}: {body}");

        return JsonSerializer.Deserialize<RovelaEmailResponse>(body)
            ?? throw new InvalidOperationException("empty Rovela response");
    }

    private sealed record Digest(string Subject, string Html, string Text);
    private sealed class RovelaEmailResponse { public string Id { get; set; } = ""; }
}

每 15 分钟运维脉搏(幂等键按一刻钟窗口分桶,不要用秒级 now):

RecurringJob.AddOrUpdate<OpsPulseEmailJob>(
    "ops-pulse-email",
    "mailers",
    job => job.ExecuteAsync(),
    "*/15 * * * *",
    new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });

// ExecuteAsync 内:
// var bucket = DateTime.UtcNow;
// bucket = new DateTime(bucket.Year, bucket.Month, bucket.Day, bucket.Hour, bucket.Minute / 15 * 15, 0, DateTimeKind.Utc);
// var idempotencyKey = $"hangfire-ops-pulse:{bucket:yyyy-MM-dd'T'HH-mm}";

常用 CRON:

表达式含义
Cron.Daily() / 0 0 * * *每日零点(任务时区)
Cron.Daily(9) / 0 9 * * *每日 09:00
*/15 * * * *每 15 分钟
30 7 * * 1周一 07:30
0 */3 * * *每三小时
Cron.Hourly()每小时整点
RecurringJob.RemoveIfExists("daily-digest-email");
RecurringJob.TriggerJob("daily-digest-email"); // 立即跑一次;下次计划不变

(历史上也有 RecurringJob.Trigger;以你安装的 Hangfire 版本文档为准。)

窗口级 Idempotency-Key

Rovela 的 Idempotency-Key(最长 256 字符)让 24 小时内相同请求重试返回原响应、不再发第二封(Send Email API)。对 Hangfire 很重要,因为:

  • AutomaticRetry 在 5xx / 超时 / 抛错后会重入方法
  • Rovela 已 200 但任务尚未标成功时进程崩溃,可能再投递
  • 队列延迟会让 DateTime.UtcNow 漂移——应按业务窗口(UTC 日 / 时区日 / 小时)键控,而不是毫秒 now
  • 多租户任务需把 tenant id 放进 key

推荐键:

Key场景
hangfire-daily-digest:{yyyy-mm-dd}每天一封运维摘要
hangfire-ops-pulse:{yyyy-mm-dd}T{HH}-{MM}/15 或每小时(分桶到窗口)
hangfire-tokyo-ops:{yyyy-mm-dd}本地时区晨间摘要
hangfire-weekly-report:{yyyy}-W{ww}ISO 周报
hangfire-tenant-digest:{tenantId}:{yyyy-mm-dd}按租户参数

一次任务发多封个性化摘要时,优先每收件人一把 key(或对最多 100 封非模板消息用 POST /emails/batch——batch 故意拒绝 template / topic_id)。

配置密钥并验证调度

  1. 在运行 AddHangfireServer 的进程设置 ROVELA_API_KEY(sending_access)、MAIL_FROM、DIGEST_TO、Hangfire 存储连接。
  2. Dashboard → Recurring Jobs 确认 daily-digest-email 的 cron 与时区。
  3. 确认至少一个 Hangfire Server 在线(日志有 “Starting Hangfire Server…”)。
  4. 等下一次触发,或在非生产调用 TriggerJob,同时保持窗口 key 稳定。
  5. 日志核对 idempotencyKey 与 Rovela emailId;可选再用 webhook 关联。

生产发信优先 sending_access,不要用 full_access。勿记录 API Key。轮换 Rovela 用 create-then-revoke。非本机访问需锁好 Hangfire Dashboard。

故障对照

现象可能原因处理
Cron 永不触发无 Hangfire Server / 应用被回收保持 AddHangfireServer 进程常开
已入队但不执行队列名不对 / 无 Worker 监听对齐 Server 监听的队列
墙钟时间不对默认 UTC / TimeZone 错误显式设置 RecurringJobOptions.TimeZone
重试后重复摘要不稳定 key / 用毫秒 now窗口级 key;24h 内幂等合并
两个计划只跑一个隐式或冲突的 recurring id使用唯一显式 id
Rovela 401Key 错误/缺失修服务端环境;确认 sending_access
Rovela 400 提到域名MAIL_FROM 未验证在 Rovela Domains 验证
Rovela 429限流抛错让 Hangfire 重试;保持同一 Idempotency-Key
密钥进 Git / 任务参数误提交或塞进 method args只放环境变量 / Key Vault
Dashboard 对公网开放未配置远程授权为远程访问加授权过滤器

上线清单

  • Rovela 发信域名已验证;MAIL_FROM 匹配
  • ROVELA_API_KEY 为 sending_access,仅在 Hangfire Server 环境/密钥库
  • Hangfire 存储 + AddHangfireServer 常驻
  • 显式 recurring id + 文档化 cron + RecurringJobOptions.TimeZone
  • 任务方法配置 [AutomaticRetry](或等价)
  • Rovela 发送使用窗口级 Idempotency-Key
  • Hangfire Dashboard 已做非本机授权
  • 日志有 emailId 且不打印密钥
  • 连续失败 / Server 宕机有告警
  • 可选:摘要收件人的 bounce/complaint webhook

FAQ

Hangfire 能不写任务方法直接调 Rovela 吗?没有实质意义——recurring 调度器只入队后台任务。聚合与 sending_access 必须在你的方法里。Rovela 不托管 Hangfire 或 SQL Server。

任务里还要用 scheduled_at 吗?通常不必。Hangfire 已在发送时刻触发且正文是实时的,直接 POST /emails。若现在受理、稍后再投且不想再唤醒进程,才用 scheduled_at(定时发送邮件)。

Hangfire vs Sidekiq-Cron vs Celery Beat vs BullMQ vs Agenda vs Temporal vs 平台 cron?.NET Worker + Dashboard 旁挂调度用 Hangfire;Ruby 用 Sidekiq-Cron;Python Celery 用 Beat;Node Redis/Mongo 队列旁用 BullMQ / Agenda;耐久 Schedule Spec 用 Temporal;SaaS 运行时用 Trigger.dev / Inngest / QStash;调度应贴着某云主机算力时用对应平台 cron。

Cron.Daily 还是 CRON 字符串?都可以。可读的日/小时计划用 helper;工作日/步进用字符串。务必传显式 recurring job id。

RecurringJobManager?是 RecurringJob 门面背后的底层 API,测试时可注入 IRecurringJobManager。Rovela 发送模式不变。

sending_access 能做什么?足够 POST /emails / batch 入队。读信、改期、取消需要 full_access 或 Dashboard session。Worker 保持 sending_access。

200 等于创始人已收到摘要?否——只表示 Rovela 已接受入队。投递状态看 GET /emails/:id、events 或 webhook。

为何既要 Hangfire 重试又要 Idempotency-Key?Hangfire 在代码失败时重试任务;Rovela 的 key 在同一窗口内 HTTP 成功两次(或超时但已接受)时合并重复发送。两层都要。

上线 recurring;让 Rovela 投递

用显式 id 与 cron 注册 RecurringJob.AddOrUpdate,在任务方法里做实时聚合与 POST /emails,把 Idempotency-Key 限定在调度窗口(及租户),并仅在服务端保存 sending_access。Hangfire 按时唤醒;Rovela 投递事务邮件。