Hangfire Recurring 事务邮件 + Rovela(2026):RecurringJob.AddOrUpdate 与按窗口幂等键
用 Hangfire RecurringJob.AddOrUpdate(cron / Cron.* + RecurringJobOptions)对接 Rovela POST /emails:服务端 sending_access,按调度窗口 Idempotency-Key,适合摘要与运维报告。
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:
- 声明
RecurringJob.AddOrUpdate("daily-digest-email", …, Cron.Daily / "0 9 * * *", options) - 将
ROVELA_API_KEY(sending_access)、MAIL_FROM、存储连接串、收件人放在仅服务端配置——绝不进浏览器、SPA 或公开 Git - 在任务方法内按当前窗口聚合摘要/报告
- 用窗口级
Idempotency-Key调用 Rovela - 在 AutomaticRetry、进程重启、多 Server 集群下避免重复发信
Rovela 不会创建 Hangfire recurring、SQL/Redis 存储或 Background Job Server。 Hangfire 管 schedule → enqueue → execute;你的方法管查询 → 映射字段 → POST /emails;Rovela 管投递。
与下列文章角度不同:
- Sidekiq-Cron 事务邮件 — Ruby Sidekiq-Cron
schedule.yml/Job.create - Celery Beat 事务邮件 — Python Celery
beat_schedule/crontab - Agenda.js 定时任务邮件 — Mongo
agendadefine/every - Temporal Schedule 邮件 — Temporal Schedule Spec + Activity
- BullMQ 可重复任务邮件 — Redis BullMQ
upsertJobScheduler - Trigger.dev Cron 邮件 — Trigger.dev
schedules.task - Inngest Cron 事务邮件 — Inngest
cron+step.run - Upstash QStash Schedules 邮件 — QStash HTTPS schedule
- Supabase pg_cron 邮件 — Postgres
pg_cron+pg_net - Kubernetes CronJob 邮件 —
batch/v1CronJob - DigitalOcean App Platform Jobs 邮件 —
kind: SCHEDULED - Heroku Scheduler 邮件 — Scheduler dyno
- Cloudflare Workers Cron 邮件 — Workers
triggers.crons - Azure Functions Timer 邮件 — Azure NCRONTAB
- GCP Cloud Scheduler 邮件 — Cloud Scheduler
- AWS EventBridge Scheduler 邮件 — EventBridge → Lambda
- Netlify Scheduled Functions 邮件 — Netlify UTC cron
- Fly Scheduled Machines 邮件 — Fly Machine schedules
- Render Cron 事务邮件 — Render Cron Job
- Railway Cron 事务邮件 — Railway cron
- Vercel Cron 摘要邮件 — 平台 cron
GET - GitHub Actions 事务邮件 — workflow / CI
- 定时发送邮件 — 正文已知时用 Rovela
scheduled_at
也不同于一次性的 BackgroundJob.Enqueue / Schedule——本文只讲 recurring(RecurringJob.AddOrUpdate / cron)。
Hangfire 调度什么,Rovela 发送什么
| 关注点 | 归属 |
|---|---|
Recurring id、CRON / Cron.*、方法表达式、队列、时区 | Hangfire RecurringJob.AddOrUpdate |
| 持久化存储 | 你的 Hangfire storage 包 |
| 分钟检查 → 入队即时任务 | Hangfire Server |
重试(AutomaticRetry)、Worker、Dashboard | Hangfire |
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,见上文链接。
前置条件
- Rovela 已验证发信域名;未验证
from会400。 re_…API Key,Worker 优先sending_access。- ASP.NET Core(或其他 .NET 宿主)安装 Hangfire(如
Hangfire.AspNetCore+Hangfire.SqlServer),并调用AddHangfireServer()。 - 配置:
ROVELA_API_KEY、MAIL_FROM、DIGEST_TO、Hangfire 存储连接串。 - 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)。
配置密钥并验证调度
- 在运行
AddHangfireServer的进程设置ROVELA_API_KEY(sending_access)、MAIL_FROM、DIGEST_TO、Hangfire 存储连接。 - Dashboard → Recurring Jobs 确认
daily-digest-email的 cron 与时区。 - 确认至少一个 Hangfire Server 在线(日志有 “Starting Hangfire Server…”)。
- 等下一次触发,或在非生产调用
TriggerJob,同时保持窗口 key 稳定。 - 日志核对
idempotencyKey与 RovelaemailId;可选再用 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 401 | Key 错误/缺失 | 修服务端环境;确认 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 投递事务邮件。