ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/9/14 13:40:54 拓冰建站 浏览量
Midday 循环发票系统全解:调度生成、幂等保障与时区一致的实现 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/worker、apps/api、packages/db、packages/invoice中的真实源码说明调度器如何批量生成发票、如何保证不重复开票、以及日期在 UTC 与用户时区之间如何保持一致帮助读者理解一套可落地的周期账单系统的设计细节。核心概念与系统架构Midday 的循环发票系统用于按固定频率自动生成并发送发票。它与另外两类发票有本质区别一次性发票是手动创建的计划发票scheduled invoice只是在未来某个时间点发送一次而循环发票代表一个持续性的系列Recurring Series系统按定义的频率不断生成新发票直到满足结束条件为止。三个核心概念Recurring Series定义发票何时、如何生成的配置记录存储于invoice_recurring表Generated Invoices由系列生成的每一张具体发票通过invoiceRecurringId字段反向关联到系列Sequence Number每张生成的发票在所属系列内拥有唯一序号1, 2, 3...这是幂等性的关键。从源码结构看该架构落在四个应用中Dashboardrecurring-config.tsx 配置面板发起 tRPC 请求API 层invoice-recurring 路由完成校验与写库Worker 层generate-recurring.ts定时扫描到期系列并生成发票PDF 渲染与邮件发送由 BullMQ 队列中的generate-invoice、send-invoice-email任务接力完成。数据模型invoice_recurring 表该表存储每个循环系列的配置与运行状态这些枚举值并非文档约定而是由 packages/invoice/src/utils/recurring.ts 中的常量数组统一声明RECURRING_FREQUENCIES、RECURRING_STATUSES、RECURRING_END_TYPES并派生出 TypeScript 类型与 Zod schema确保前后端共享同一套取值来源。频率选项Frequency OptionsFrequency说明使用字段weekly每周同一天frequencyDay0周日, 6周六biweekly每 2 周frequencyDaymonthly_date每月固定日期frequencyDay1-31monthly_weekday每月第 N 个星期几frequencyDayfrequencyWeekmonthly_last_day每月最后一天-quarterly每 3 个月frequencyDaysemi_annual每 6 个月frequencyDayannual每年一次frequencyDaycustom每 X 天frequencyInterval各频率的取值范围由客户端校验函数validateRecurringConfig()packages/invoice/src/utils/recurring.ts 第 576 行起与 API 层 Zod schema 双重把关周系频率要求frequencyDay在 0-6月/季/半年/年频率要求frequencyDay在 1-31monthly_weekday额外要求frequencyWeek在 1-5custom要求frequencyInterval 1。结束条件End ConditionsEnd Type说明使用字段never无限期运行-on_date到达指定日期后停止endDateafter_count生成 N 张发票后停止endCount状态机循环系列的生命周期是一个四状态机状态转换表FromTo触发条件说明-active创建系列初始状态计算nextScheduledAtactiveactive发票生成成功计数器 1计算下次日期activepaused用户操作通过 API 手动暂停activepaused连续 3 次失败自动暂停activecompleted结束条件满足日期已过或数量达到activecanceled用户删除软删除保留已生成发票pausedactive用户恢复下次日期从当前时间重新计算pausedcompleted恢复时发现已结束暂停期间结束条件已满足pausedcanceled用户删除软删除DB 层的实现与状态机一一对应位于 packages/db/src/queries/invoice-recurring.ts暂停pauseInvoiceRecurring()仅更新statuspausednextScheduledAt被保留恢复resumeInvoiceRecurring()第 665 行起先从当前时间重新计算下次日期再检查结束条件——若暂停期间结束条件已满足直接置为completed且nextScheduledAtnull否则恢复为active并将consecutiveFailures归零删除deleteInvoiceRecurring()第 741 行起不做物理删除而是设置statuscanceled、nextScheduledAtnull已生成的发票全部保留。生成流程调度入口是 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实现上有三个值得注意的设计按nextScheduledAt升序排序——最先到期的系列优先处理避免长期饥饿limit 1探测法——多取一条记录判断是否还有剩余hasMore任务返回hasMore: true表示未处理完的部分留到下一轮staging 干跑模式——在 staging 环境中处理器只查询并打日志[STAGING MODE] ... logging only, no execution返回模拟结果而不写库方便上线前验证待处理列表。幂等性保障系统通过三层机制防止重复生成发票调度层BullMQ 的upsertJobScheduler确保全局只有一个调度器任务实例在运行发票层创建前调用checkInvoiceExists(recurringId, sequence)检查该序列号是否已有发票事务层发票创建与系列计数器更新在同一个数据库事务中完成原子提交。源码中的处理比存在即跳过更细致generate-recurring.ts 第 176-271 行若已存在发票且状态为draft或scheduled例如用户创建的未来日期循环发票调度器不新建发票而是复用该发票并入队发送同时调用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 置为 pausedautoPaused true处理器捕获到失败后失败原因被抽象为带错误码的RecurringInvoiceError如客户被删除、客户无邮箱、模板数据不合法等若返回autoPaused为真则向通知队列投递recurring_series_paused事件提醒团队修复后手动恢复。Kill Switch调度器支持通过环境变量紧急停用无需重新部署DISABLE_RECURRING_INVOICEStrue在 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_completed与recurring_series_paused两个通知任务在 generate-recurring.ts 中可以直接看到对应的入队代码。API 端点invoice-recurring tRPC 路由 暴露以下过程配合 Zod 校验 schemaProcedure类型说明createMutation创建新的循环系列updateMutation更新系列配置deleteMutation取消系列软删除getByIdQuery获取系列详情getListQuery分页列出系列pauseMutation暂停一个 active 系列resumeMutation恢复一个 paused 系列getUpcomingQuery预览即将生成的发票getUpcoming背后的getUpcomingInvoices()查询会调用 DB 层的calculateUpcomingDates()用与真实调度完全相同的时区感知算法推算未来若干期发票日期因此预览与线上实际开票日期保持一致。创建循环系列的流程从一张草稿发票创建循环系列时路由create过程 submit-button.tsx 前端提交校验客户邮箱必填因为发票会自动邮件发送创建invoice_recurring记录将该草稿发票关联为系列的第 1 张sequence #1基于发票开票日期计算nextScheduledAt向团队发送创建通知。已有系列可通过 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.000ZUTC 时间 1 月 15 日午夜如果简单地按本地时区换算在 ESTUTC-5下变成2024-01-14T19:00:001 月 14 日日历组件会显示错误的日期方案一显示用 TZDate使用date-fns/tz的TZDate按 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()差异示例UTC14 用户选中 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编辑已有循环系列的 Sheetapps/api/src/trpc/routers/invoice-recurring.ts全部 API 端点的 tRPC 路由apps/api/src/schemas/invoice-recurring.tsZod 校验 schemaapps/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),仅供参考