Midday 循环发票系统全解:调度生成、幂等保障与时区一致的实现
2026/9/14 13:40:50 网站建设 项目流程

Midday 循环发票系统全解:调度生成、幂等保障与时区一致的实现

【免费下载链接】middayInvoicing, Time tracking, File reconciliation, Storage, Financial Overview & your own Assistant made for Freelancers项目地址: https://gitcode.com/GitHub_Trending/mi/midday

本文以 Midday 仓库中的循环发票(Recurring Invoice)系统文档为主线,完整拆解该系统的架构、数据模型、状态机与生成流程,并结合apps/workerapps/apipackages/dbpackages/invoice中的真实源码,说明调度器如何批量生成发票、如何保证不重复开票、以及日期在 UTC 与用户时区之间如何保持一致,帮助读者理解一套可落地的周期账单系统的设计细节。

核心概念与系统架构

Midday 的循环发票系统用于按固定频率自动生成并发送发票。它与另外两类发票有本质区别:一次性发票是手动创建的;计划发票(scheduled invoice)只是在未来某个时间点发送一次;而循环发票代表一个持续性的系列(Recurring Series),系统按定义的频率不断生成新发票,直到满足结束条件为止。

三个核心概念:

  • Recurring Series:定义发票"何时、如何"生成的配置记录,存储于invoice_recurring表;
  • Generated Invoices:由系列生成的每一张具体发票,通过invoiceRecurringId字段反向关联到系列;
  • Sequence Number:每张生成的发票在所属系列内拥有唯一序号(1, 2, 3...),这是幂等性的关键。

从源码结构看,该架构落在四个应用中:Dashboard(recurring-config.tsx 配置面板)发起 tRPC 请求,API 层(invoice-recurring 路由)完成校验与写库,Worker 层(generate-recurring.ts)定时扫描到期系列并生成发票,PDF 渲染与邮件发送由 BullMQ 队列中的generate-invoicesend-invoice-email任务接力完成。

数据模型

invoice_recurring 表

该表存储每个循环系列的配置与运行状态:

这些枚举值并非文档约定,而是由 packages/invoice/src/utils/recurring.ts 中的常量数组统一声明(RECURRING_FREQUENCIESRECURRING_STATUSESRECURRING_END_TYPES),并派生出 TypeScript 类型与 Zod schema,确保前后端共享同一套取值来源。

频率选项(Frequency Options)

Frequency说明使用字段
weekly每周同一天frequencyDay(0=周日, 6=周六)
biweekly每 2 周frequencyDay
monthly_date每月固定日期frequencyDay(1-31)
monthly_weekday每月第 N 个星期几frequencyDay+frequencyWeek
monthly_last_day每月最后一天-
quarterly每 3 个月frequencyDay
semi_annual每 6 个月frequencyDay
annual每年一次frequencyDay
custom每 X 天frequencyInterval

各频率的取值范围由客户端校验函数validateRecurringConfig()(packages/invoice/src/utils/recurring.ts 第 576 行起)与 API 层 Zod schema 双重把关:周系频率要求frequencyDay在 0-6;月/季/半年/年频率要求frequencyDay在 1-31;monthly_weekday额外要求frequencyWeek在 1-5;custom要求frequencyInterval >= 1

结束条件(End Conditions)

End Type说明使用字段
never无限期运行-
on_date到达指定日期后停止endDate
after_count生成 N 张发票后停止endCount

状态机

循环系列的生命周期是一个四状态机:

状态转换表

FromTo触发条件说明
-active创建系列初始状态,计算nextScheduledAt
activeactive发票生成成功计数器 +1,计算下次日期
activepaused用户操作通过 API 手动暂停
activepaused连续 3 次失败自动暂停
activecompleted结束条件满足日期已过或数量达到
activecanceled用户删除软删除,保留已生成发票
pausedactive用户恢复下次日期从当前时间重新计算
pausedcompleted恢复时发现已结束暂停期间结束条件已满足
pausedcanceled用户删除软删除

DB 层的实现与状态机一一对应,位于 packages/db/src/queries/invoice-recurring.ts:

  • 暂停pauseInvoiceRecurring()仅更新status='paused'nextScheduledAt被保留;
  • 恢复resumeInvoiceRecurring()(第 665 行起)先从当前时间重新计算下次日期,再检查结束条件——若暂停期间结束条件已满足,直接置为completednextScheduledAt=null,否则恢复为active并将consecutiveFailures归零;
  • 删除deleteInvoiceRecurring()(第 741 行起)不做物理删除,而是设置status='canceled'nextScheduledAt=null,已生成的发票全部保留。

生成流程

调度入口是 apps/worker/src/processors/invoices/generate-recurring.ts 中的InvoiceRecurringSchedulerProcessor。调度任务由 Worker 启动时以静态 cron 配置注册(见 invoices.config.ts),主生成任务为invoice-recurring-scheduler。需要注意的一处细节:文档原文描述调度周期为"每 2 小时",而当前仓库中的注册配置为cron: "0 * * * *"(每小时整点),配套提醒任务为cron: "30 * * * *"(每小时半点),处理器代码注释仍写着 "Runs every 2 hours"——从源码结构看,以 invoices.config.ts 的 cron 配置为准,周期可随部署调整。

到期查询与批量处理

getDueInvoiceRecurring()(queries/invoice-recurring.ts 第 447 行起)是调度器每次运行的第一步:

// 默认批大小,防止大量到期系列一次压垮系统 const DEFAULT_BATCH_SIZE = 50; // WHERE status = 'active' AND next_scheduled_at <= now // ORDER BY nextScheduledAt(最先到期的先处理,保证公平性) // LIMIT limit + 1(多取一条用于判断 hasMore)

实现上有三个值得注意的设计:

  1. nextScheduledAt升序排序——最先到期的系列优先处理,避免长期饥饿;
  2. limit + 1探测法——多取一条记录判断是否还有剩余(hasMore),任务返回hasMore: true表示未处理完的部分留到下一轮;
  3. staging 干跑模式——在 staging 环境中处理器只查询并打日志("[STAGING MODE] ... logging only, no execution"),返回模拟结果而不写库,方便上线前验证待处理列表。

幂等性保障

系统通过三层机制防止重复生成发票:

  1. 调度层:BullMQ 的upsertJobScheduler确保全局只有一个调度器任务实例在运行;
  2. 发票层:创建前调用checkInvoiceExists(recurringId, sequence)检查该序列号是否已有发票;
  3. 事务层:发票创建与系列计数器更新在同一个数据库事务中完成,原子提交。

源码中的处理比"存在即跳过"更细致(generate-recurring.ts 第 176-271 行):

  • 若已存在发票且状态为draftscheduled(例如用户创建的未来日期循环发票),调度器不新建发票,而是复用该发票并入队发送,同时调用markInvoiceGenerated()推进系列;
  • 若已存在发票且已sent/paid(用户在计划日期前手动发过),只推进系列计数器(skipped++),不重发;
  • 否则走新建路径:生成发票 ID、取下一个发票号(getNextInvoiceNumber)、按dueDateOffset计算到期日,然后在事务内依次执行draftInvoice()updateInvoice(invoiceRecurringId, recurringSequence)markInvoiceGenerated()

markInvoiceGenerated()(queries/invoice-recurring.ts 第 517 行起)在事务内完成四件事:计数器 +1、consecutiveFailures归零、记录lastGeneratedAt、计算并写入新的nextScheduledAt(若结束条件满足则置null并将状态改为completed)。

事务提交之后,处理器才向 BullMQ 投递generate-invoicedeliveryType: "create_and_send")与invoice_recurring_generated通知任务。这里有一个刻意的容错设计:如果入队失败,代码不会调用recordInvoiceGenerationFailure()——因为发票已存在,重试时会被幂等检查接住,或者用户可以在 Dashboard 手动补发。此外,若系列因本次生成而完成(updatedRecurring.status === "completed"),还会额外投递recurring_series_completed通知。

失败处理与自动暂停

DB 层的recordInvoiceGenerationFailure()(queries/invoice-recurring.ts 第 599 行起)定义了阈值:

const MAX_CONSECUTIVE_FAILURES = 3; // 累计失败 >= 3 时,status 置为 "paused"(autoPaused = true)

处理器捕获到失败后(失败原因被抽象为带错误码的RecurringInvoiceError,如客户被删除、客户无邮箱、模板数据不合法等),若返回autoPaused为真,则向通知队列投递recurring_series_paused事件,提醒团队修复后手动恢复。

Kill Switch

调度器支持通过环境变量紧急停用,无需重新部署:

DISABLE_RECURRING_INVOICES=true

在 generate-recurring.ts 第 67-79 行,处理器入口第一时间检查该变量,命中即记录 warn 日志并直接返回空结果,不处理任何系列。

批量限制

为防止大量发票同时到期时压垮系统,处理按批次进行:

处理器批大小说明
循环发票生成50每次调度运行最多生成 50 张(DEFAULT_BATCH_SIZE,见 DB 查询层)
到期提醒通知100每次调度运行最多发送 100 条

设计理由:

  • 避免一次处理上千个系列造成的内存压力;
  • 将负载分散到多次调度运行中;
  • 最先到期的发票优先处理(按nextScheduledAt排序);
  • 仍有剩余时任务返回hasMore: true,下一轮继续。

通知机制

24 小时到期提醒

独立调度器invoice-upcoming-notification(处理器位于 upcoming-notification.ts)与主生成任务错峰运行(主任务整点、提醒任务半点),为即将到期的系列发送 24 小时提前提醒:

upcoming_notification_sent_at字段保证每个计费周期只提醒一次,且在生成新发票后重置。

站内活动通知

系统还创建 in-app 活动通知,覆盖关键事件:

事件活动类型优先级
系列创建recurring_series_started3
系列完成recurring_series_completed3
系列被自动暂停recurring_series_paused4(更高)
发票即将到期recurring_invoice_upcoming3

其中recurring_series_completedrecurring_series_paused两个通知任务在 generate-recurring.ts 中可以直接看到对应的入队代码。

API 端点

invoice-recurring tRPC 路由 暴露以下过程(配合 Zod 校验 schema):

Procedure类型说明
createMutation创建新的循环系列
updateMutation更新系列配置
deleteMutation取消系列(软删除)
getByIdQuery获取系列详情
getListQuery分页列出系列
pauseMutation暂停一个 active 系列
resumeMutation恢复一个 paused 系列
getUpcomingQuery预览即将生成的发票

getUpcoming背后的getUpcomingInvoices()查询会调用 DB 层的calculateUpcomingDates(),用与真实调度完全相同的时区感知算法推算未来若干期发票日期,因此预览与线上实际开票日期保持一致。

创建循环系列的流程

从一张草稿发票创建循环系列时(路由create过程 + submit-button.tsx 前端提交):

  1. 校验客户邮箱(必填,因为发票会自动邮件发送);
  2. 创建invoice_recurring记录;
  3. 将该草稿发票关联为系列的第 1 张(sequence #1);
  4. 基于发票开票日期计算nextScheduledAt
  5. 向团队发送创建通知。

已有系列可通过 edit-recurring-sheet.tsx 编辑频率、结束条件与金额等配置。

日期处理与时区一致性

这是该系统最容易踩坑的部分,文档给出了完整的设计。

存储格式

所有"仅日期"字段(开票日期、到期日期、结束日期)都存储为UTC 午夜时间戳TIMESTAMPTZ列)。例如 "2024 年 1 月 15 日" 存为2024-01-15T00:00:00.000Z。这是一种与时区无关的规范表示法,可直接沿用现有数据库 schema。

时区问题

若 UTC 时区以西的用户(如 EST = UTC-5)遇到以 UTC 午夜存储的日期:

存储值: 2024-01-15T00:00:00.000Z(UTC 时间 1 月 15 日午夜)

如果简单地按本地时区换算:

  • 在 EST(UTC-5)下变成2024-01-14T19:00:00(1 月 14 日!)
  • 日历组件会显示错误的日期

方案一:显示用 TZDate

使用@date-fns/tzTZDate按 UTC 解释存储值,保证日历正确显示:

import { TZDate } from "@date-fns/tz"; // 显示:将存储的 UTC 日期按 UTC 解释用于日历展示 const selectedDate = new TZDate(dueDate, "UTC"); // "2024-01-15T00:00:00.000Z" → 日历显示 1 月 15 日 ✓

方案二:存储用 localDateToUTCMidnight

用户在日历选择器中选中日期时,浏览器返回的是本地午夜Date对象。需要用本地日期分量转换为 UTC 午夜:

import { localDateToUTCMidnight } from "@midday/invoice/recurring"; // 存储:把本地选中的日期转换为 UTC 午夜 const handleSelect = (date: Date) => { setValue("dueDate", localDateToUTCMidnight(date)); };

实现(packages/invoice/src/utils/recurring.ts 第 54-58 行):

export function localDateToUTCMidnight(date: Date): string { return new Date( Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()), ).toISOString(); }

它提取的是本地年/月/日分量,再为这一天构造 UTC 午夜。

为什么不用 getStartOfDayUTC?

代码库中有两个看起来相似的函数,用途完全不同:

函数用途使用的分量方法
getStartOfDayUTC(date)将 UTC 日期归一到 UTC 午夜getUTCFullYear()/getUTCMonth()/getUTCDate()
localDateToUTCMidnight(date)将本地选择转换为 UTC 午夜getFullYear()/getMonth()/getDate()

差异示例:

UTC+14 用户选中 1 月 15 日 日历返回: new Date(2024, 0, 15) → 2024-01-14T10:00:00.000Z (UTC) getStartOfDayUTC(): 2024-01-14T00:00:00.000Z ✗(错误日期!) localDateToUTCMidnight(): 2024-01-15T00:00:00.000Z ✓(正确!)

这个"客户端/服务器分工"在源码中有明确注释:generate-recurring.ts 第 331-338 行特意说明——服务器端生成发票时使用getStartOfDayUTC(因为nextScheduledAt本身就是 UTC 时间戳,需保留其 UTC 日期),而浏览器端表单存储时使用localDateToUTCMidnight

服务端:时区感知的调度日期计算

前端getNextDate()(packages/invoice/src/utils/recurring.ts 第 372 行起)仅用于 UI 预览,是不带时区支持的简化算法(其中monthly_date对超出当月天数的日期做了钳制,如 31 号在 30 天的月份回落到 30 号)。真正权威的服务端实现是 packages/db/src/utils/invoice-recurring.ts 中的calculateNextScheduledDate()(第 114 行起):

const createTZDate = tz(timezone); // 来自 @date-fns/tz const tzCurrentDate = createTZDate(currentDate); // 对 weekly / biweekly / monthly_date / monthly_weekday / monthly_last_day / // quarterly / semi_annual / annual / custom 分别计算, // 且 monthly_date、quarterly 等均对月末天数做 Math.min 钳制

该函数接收invoice_recurring.timezone中保存的用户 IANA 时区(如America/New_York),确保"每月 15 号""每两周"等语义在用户本地日历上成立。生成的发票日期最终仍存为 UTC 午夜,与手动创建的发票保持一致。

markInvoiceGenerated()中还有一处防"补票风暴"的细节:以原定nextScheduledAt为基准计算下一期(保持双周发票固定在同一个星期几),再经advanceToFutureDate()将日期推进到未来——即使调度器运行滞后,也不会连续补发多张"过期"发票。

到期状态比较

apps/dashboard/src/utils/format.ts 中的getDueDateStatus()按 UTC 天级比较到期日:

// 按 UTC 解析到期日(存储为 UTC 午夜) const due = new TZDate(dueDate, "UTC"); // 取当前 UTC 日期做一致比较 const now = new Date(); const nowUTC = new TZDate(now.toISOString(), "UTC"); // 在 UTC 天级比较 const diffDays = differenceInDays(startOfDay(due), startOfDay(nowUTC));

相应地,从开票日期推导循环模式时也应使用 UTC 分量方法:

// 开票日期存储为 UTC 午夜,故使用 UTC 方法 const dayOfWeek = issueDate.getUTCDay(); const dayOfMonth = issueDate.getUTCDate();

设计决策

为什么用软删除?

循环系列被"取消"(canceled)而非物理删除,以保留与已生成发票的关联,维护审计历史,并支持反向查询"某张发票来自哪个系列"。

为什么连续 3 次失败就自动暂停?

连续失败通常意味着系统性问题(客户邮箱失效、模板损坏等)。自动暂停避免:队列中堆积失败任务、错误通知轰炸、浪费处理资源。团队会收到通知,修复后可手动恢复。

为什么恢复时从当前时间重算下次日期?

paused 系列恢复时,下一张发票基于当前日期生成,而不是补发错过的那些期。这避免"补票洪水",同时保证后续计费周期可预期。

为什么强制要求客户邮箱?

循环发票会自动邮件发送。没有有效邮箱地址,发票虽然能生成但无法投递。提前校验能给用户即时反馈,而不是让系列在后台静默失败。

关键文件索引

文件用途
apps/dashboard/src/components/invoice/recurring-config.tsx频率、结束条件配置与预览的 UI 面板
apps/dashboard/src/components/invoice/submit-button.tsx带循环选项的发票表单提交
apps/dashboard/src/components/sheets/edit-recurring-sheet.tsx编辑已有循环系列的 Sheet
apps/api/src/trpc/routers/invoice-recurring.ts全部 API 端点的 tRPC 路由
apps/api/src/schemas/invoice-recurring.tsZod 校验 schema
apps/worker/src/processors/invoices/generate-recurring.ts生成发票的定时任务处理器
apps/worker/src/processors/invoices/upcoming-notification.ts24 小时到期提醒调度器
apps/worker/src/schedulers/invoices.config.ts调度任务 cron 注册配置
packages/db/src/queries/invoice-recurring.ts数据库查询(CRUD、状态转换、到期查询)
packages/db/src/utils/invoice-recurring.ts服务端时区感知的日期计算
packages/invoice/src/utils/recurring.ts共享工具(枚举常量、标签、预览计算、UTC 日期转换)
packages/invoice/src/index.tsx发票包入口(含@midday/invoice/recurring导出)

【免费下载链接】middayInvoicing, Time tracking, File reconciliation, Storage, Financial Overview & your own Assistant made for Freelancers项目地址: https://gitcode.com/GitHub_Trending/mi/midday

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询