Actual Budget 26.5.0 技术解析:Age of Money 与 Sankey 桑基图报告背后的实现原理
【免费下载链接】actualA local-first personal finance app项目地址: https://gitcode.com/GitHub_Trending/ac/actual
Actual Budget 26.5.0 是 2026 年 5 月发布的一个以「报告能力」为核心的版本,首次引入实验性的 Age of Money(货币账龄)与 Sankey(桑基图)两大新报告,同时带来拆分交易税务式比例分配、BALANCE_OF规则公式、五个全新社区主题以及认证限流等 70 余项特性、修复与工程改进。本文以 26.5.0 发布说明 为主线,结合仓库源码深入剖析这两大新报告的算法与数据流实现,并梳理其余值得关注的增强项,帮助你在升级到 26.5.0 后快速上手新功能,同时理解它们是如何被构建出来的。
版本概览与升级方式
26.5.0 的发布说明将本次更新概括为「强大的新报告能力以及大量修复」,三大核心亮点分别是:
- 实验性:Age of Money 与 Sankey Diagram 报告;
- 实验性:自定义主题目录新增五个社区主题(Nord、Ilavenil、Gruvbox Light/Dark、You Need A Theme Light/Dark);
- 拆分交易中的税务式(比例)分配。
官方推荐的部署方式是使用 Docker 镜像,镜像标签为26.5.0:
docker pull actualbudget/actual:26.5.0 docker run -d -p 5006:5006 -v actual-data:/data actualbudget/actual:26.5.0随后发布的 26.5.1 / 26.5.2 为同内容的补丁版本(Docker Tag 26.5.1 / 26.5.2),仅针对认证限流计数、自签名证书处理与 UUID 生成兼容性做了修复,详见后文「补丁版本说明」一节。
需要特别注意的是,Age of Money 与 Sankey 报告目前属于实验性功能:Sankey 需要在「设置 → 实验性功能」中打开Sankey report开关(Experimental.tsx 中的flag="sankeyReport"),Age of Money 同样作为实验性报告进入 Dashboard 组件体系。实验性功能意味着其接口、数据模型与行为仍可能随版本演进发生变化。
亮点一:Age of Money(货币账龄)报告
Age of Money 是预算社区中非常经典的「财务健康度」指标,用于衡量你的钱在进入预算后平均闲置多少天才被花掉。界面中(AgeOfMoney.tsx)给出的官方定义是:
Age of Money 显示你的钱平均在预算中停留多少天后才被消费,它衡量的是「赚到钱」与「花掉钱」之间的时间差。数值越高,说明你在花更早的钱,即「靠上个月的收入生活」而不是「月光」。30 天及以上被认为是理想状态。
计算原理:FIFO 收入桶模型
与许多按"当前余额 / 日均支出"估算的简易算法不同,Actual 的实现采用了严格的FIFO(First In, First Out)模拟:收入按时间排序后形成一个个"收入桶"(income bucket),每次支出按时间顺序从最老的桶里扣减,该笔支出的账龄即「支出日期 − 收入桶日期」。最终展示的数值是最近 10 笔支出的平均账龄。
核心逻辑位于 age-of-money-spreadsheet.ts,主要由四个可独立测试的函数组成:
| 函数 | 职责 | 关键实现细节 |
|---|---|---|
classifyTransactions | 收入/支出分类 | 按金额符号分类:正数(含退款)进入收入池,负数视为支出 |
calculateAgeOfMoney | FIFO 计算 | 收入排序成桶,支出从最老桶扣减;桶被耗尽则移动到下一个桶;若支出无法被完全覆盖则置insufficientData = true |
calculateAverageAge | 平均账龄 | 取最近 N 笔(默认 10)的年龄求平均并四舍五入 |
calculateTrend | 趋势判定 | 比较最近两个数据点,差值超过 ±2 天判定为 ↑/↓,否则 → 稳定 |
这套实现有几个值得注意的设计决策:
- 基于金额而非类别分类:注释明确指出,退款(正数但无收入类别)也应进入资金池,因此采用金额符号判定,而不是依赖类别标记(
categoryIsIncome字段被保留备用)。 - 全量历史参与 FIFO:收入与支出查询都要求「截至结束日期」的完整历史(
amount: { $gt: 0 }/amount: { $lt: 0 }),因为 FIFO 需要完整的收入历史才能正确消耗收入桶——只取报告区间内的数据会算错账龄。 - 转账的特殊处理:
buildTransferInclusionFilter默认排除「预算内账户 ↔ 预算内账户」的转账(它们只是在预算池内移动资金);但当用户对报告施加了账户过滤时,会通过反转账户条件(invertAccountOp)把「对端账户被过滤掉」的预算内转账也纳入收支——例如只筛选支票账户时,信用卡还款应被视为真实支出,报告才能反映"现金账龄"而非全预算账龄。 - 分桶粒度:支持
daily/weekly/monthly三种粒度(getPeriodKey中周粒度以周一开始),月度粒度按YYYY-MM聚合;日/周粒度下不生成未来日期段,避免滚动均值被"拉平"成水平线。
可视化与状态色
报告页顶部实时展示当前账龄天数与趋势箭头,状态色由 AgeOfMoneyCard.tsx 的getAgeColor定义:
- 账龄 ≥ 30 天:绿色(理想,
reportsNumberPositive); - 账龄 ≥ 14 天:黄色(
warningText,正在改善); - 账龄 < 14 天:红色(
reportsNumberNegative,需要努力); - 无数据:中性色,显示
N/A。
趋势箭头基于calculateTrend(±2 天阈值),同时当insufficientData为真时会显示「支出超过同期收入,部分支出无法匹配到收入」的警告。作为 Dashboard 组件,它既可单独进入/reports/age-of-money/:id全屏页面,也可作为卡片(AgeOfMoneyCard)挂载到仪表盘,支持条件过滤(useRuleConditionFilters)、时间范围(滑窗/固定区间)与粒度切换,并可保存为 widget。测试用例见 age-of-money-spreadsheet.test.ts。
亮点二:Sankey 桑基图报告
Sankey 图用「节点 + 流动宽度」直观展示资金的流向:钱从收入端流入,经过账户、类别组,最终流向具体类别。26.5.0 引入的 Sankey 报告(Sankey.tsx、sankey-spreadsheet.ts)是本次版本中工程量最大的新功能,经过 #7582 的数据模型优化后,支持收入来源、图层过滤与更完善的预算处理。
两种视图模式:Spent 与 Budgeted
报告顶部通过GraphModeSelector在两种模式间切换:
- Spent(已支出):基于真实交易流水,展示「收入来源 → 收入类别 → 账户 → 类别组 → 类别」的资金消耗路径,默认图层范围为 Payee → Category;
- Budgeted(已预算):基于
api/budget-month的预算数据,展示「收入类别 → 可用资金 → 已预算 → 类别组 → 类别」的预算分配路径,默认图层范围为 Income Category → Category。
两种模式的数据来源不同:Spent 模式通过 AQL 查询transactions并按账户、类别分组聚合(fetchCategoryData);Budgeted 模式则逐月请求预算月数据并累加(createBudgetSpreadsheet,对跨月区间逐月求和budgeted/spent/balance/received)。
六层数据模型与图层过滤
整张图由 6 个语义图层(GraphLayers)构成,按固定顺序排列(GRAPH_LAYER_ORDER):
Payee(收入收款方) → IncomeCategory(收入类别) → Account(账户) → Budget(预算) → CategoryGroup(类别组) → Category(类别)- Budgeted 模式下不包含 Payee 层;
- Spent 模式下不包含 Budget 层。
用户可通过工具栏的两个LayerSelector自由选择「From 层」与「To 层」来裁剪视图(如只看 账户 → 类别),normalizeLayerRange保证 from 严格在 to 之前;点击刷新按钮可恢复默认图层范围。
数据构建流水线
Spent 模式的核心查询在fetchCategoryData中按「类别 × 账户(× 收款方)」执行 AQLgroupBy聚合,收入类别的支出还会拆分为正/负两个方向(__NEGATIVE后缀节点处理退款等反向流动)。转账数据(fetchTransferData+aggregateTransferPairs)按transfer_id配对并合并净额。随后buildSankeyData依次执行完整的可视化预处理管线:
cloneGraph深拷贝基础图(避免污染缓存);groupOtherCategories将 Top N 之外的类别合并进__OTHER_BUCKET节点;sortGraph按三种排序策略重排节点;cleanUpNodes清理孤立节点;addHiddenNodes补齐隐藏节点;addPercentageLabels计算百分比标签;addColors基于图表主题着色;filterGraphByLayers按用户选择的图层区间裁剪;convertToSankeyData转换为 recharts 的nodes+links数据结构。
预算模式还包含几个特殊节点(SpecialNodeKeys):To budget(待分配资金)、Budgeted、Overspent(上月超支)、For next month(预留下月)、From previous month(上月结转)与Available income(可用资金),完整还原预算工作流的资金语义;超额预算(toBudget < 0)时显示为「Overbudgeted」反向流。
交互与可配置项
Sankey 报告的工具栏提供了相当完整的配置能力:
| 配置项 | 选项 / 默认值 | 说明 |
|---|---|---|
| 视图模式 | Spent / Budgeted,默认 Spent | 切换交易流与预算流 |
| Top N 类别 | All(1e5 哨兵值)/10/15/20/25/30,默认 15 | 超过上限的类别并入 Other 桶;同时受卡片高度限制topNNodes = max(2, floor(cardHeight/35px)) |
| 类别排序 | Sort per group / Sort all / Sort as budget | 三种排序策略 |
| 图层范围 | From/To 六层自由组合 | 见上文 |
| Options 菜单 | 百分比显示 / Spent 视图分组账户 / Spent 视图显示转账 | 均为布尔开关(showPercentages、groupAccounts、showTransfers) |
groupAccounts开启后所有账户聚合为单个「Income」节点(SpecialNodeKeys.AllAccounts);showTransfers则会在账户节点之间绘制转账连线。该报告同样支持条件过滤器与时间范围(含 1 个月快捷选项),并可作为 Dashboard widget 保存(meta 中持久化mode、topNcategories、categorySort、layerFrom、layerTo等全部配置)。后续修复还保证了 #7619「Sankey 卡片遵循报告设置」与 #7632「收款方为空时收入不被误判为支出」。
亮点三:拆分交易的税务式比例分配
26.5.0 为拆分交易新增了「按比例分配剩余金额」的选项(#7257)。此前拆分交易只能把差额平均分配给空白的子分录,而现在支持按各子分录的既有金额比例分摊差额——这正是税务场景(如按比例分摊税费)所需要的分配方式。
实现位于 TransactionsTable.tsx 的分配处理器中,分两条路径:
路径一:存在空白子分录(amount === 0)时等额分配先计算差额remainingAmount = parentAmount - Σ(sibling amounts),再Math.floor(remaining / 空子分录数)得到基础份额,多余的"分"(remainingCents)逐笔 +1 分配,保证金额总和精确等于父交易金额,不产生一分钱误差。
路径二:无空白子分录时按比例分配按现有子分录的占比计算份额:newAmount = floor(siblingAmount / ΣsiblingAmounts × remainingAmount) + siblingAmount,随后用一个循环把所有子分录之和修正到恰好等于父交易金额(多则逐笔 -1、少则逐笔 +1,循环取模分配)。
这种「先比例、后余数修正」的策略确保了任意金额组合下拆分总和都与父交易严格相等,且分配的优先级是等额优先于比例。
其他值得关注的增强项
规则公式新增BALANCE_OF
#7335 为规则与报告公式新增BALANCE_OF(account_id_or_name)函数,用于读取其他账户的余额。根据 formulaCatalog.ts 的注册信息:
- 支持
query与transaction两种模式; - 在规则公式中,返回该笔交易发生时刻的运行余额(单位:分),与当前账户的
balance变量口径一致; - 在报告/查询公式中,返回该账户的当前余额(货币显示单位);
- 参数为带引号的账户 ID(确定性匹配)或精确账户名。
测试见 useFormulaExecution.test.ts:按 ID 解析并查询余额、按精确名称解析、未知账户返回 0 且不发查询。实现层面通过balanceOfNames预取映射(useFormulaExecution.ts)批量解析公式中的账户引用。
自定义主题:五个新社区主题与持久化 CSS 覆盖
自定义主题目录(Custom Themes)在 26.5.0 一次性新增五个社区主题:#7513 Nord、#7543 Ilavenil、#7571 Gruvbox Light/Dark、#7441/#7447 You Need A Theme Light/Dark(后者基于 2026 nYNAB 配色)。同时 #7495 修复了一个长期痛点:自定义 CSS 覆盖现在可以在主题切换后持久保留,且在主题下拉菜单旁边会显示可见的激活指示器;#7253 改善了主题目录的响应式表现。工程侧还增加了 #7566「每晚自动扫描主题目录以发现损坏主题」的 CI 保障。
认证端点限流(防暴力破解)
#7432 为认证端点引入了速率限制,防止暴力破解攻击。值得留意的是补丁版本 #7707 将其修正为「仅统计失败的登录尝试」计入限流,避免合法用户因多次输错密码被误锁的同时,也避免正常登录流量触发误伤——这正是发布说明中强调的「认证限流」修复项。
其他增强速览
- 预算分析报告支持类别组(#7116);
- 自定义报告 Live 模式新增 "Last 30 days" 时间范围(#7217),对应 ReportOptions.ts 中的
last30Days选项; - 每笔计划单独覆盖 "upcoming" 通知窗口(#7434):允许每个排程(schedule)拥有自己的提醒时间窗,而不必受全局设置约束;
- Dashboard 组件错误边界(#7382)与规则页 scoped ErrorBoundary(#7437):组件级渲染崩溃不再拖垮整个应用;
- API 类型导出(#7581):可通过
@actual-app/api/models直接导入模型类型;#7468 同步为@actual-app/core发布.d.ts声明文件,修复严格 TypeScript 下游的类型错误; - 双击 Ctrl-f 触发浏览器查找(#7605);
- Reimport Deleted 默认开启并在导入间保持状态(#7610);
- 智利比索 CLP 货币支持(#7346);
- 对账表单排版重构(#7423),提升清晰度与易用性;
- CLI 改进(#7378):账户排序、更完善的 Agent 使用说明与类型修复;
- 端到端加密说明澄清(#7392):E2E 加密仅保护预算数据,不包含银行同步令牌;同时 #7368 允许桌面端对预算文件启用端到端加密。
关键 Bugfix 盘点
26.5.0 修复了一批影响日常使用的缺陷,按主题可归纳为:
- API 与数据完整性:#7242 修复
api.updateTransaction()部分更新时损坏拆分父交易的问题;#7453 修复子拆分交易的解锁异常。 - 搜索与过滤:#7270 修复快捷搜索误将
?、%当作通配符导致返回全部交易的问题;#7324 修复以$开头的标签查询失败;#7304 修复切换过滤操作符时显示 UUID。 - 安全:#7428 修复文件上传净化中的路径遍历漏洞;#7608 禁止初始化后重新配置 OpenID。
- 报告:#7296 修复净资产图时间间隔小于设定值;#7356 修复自定义报告编辑器在路由切换间保留未保存设置的问题;#7619/#7632 见上文 Sankey 修复。
- 稳定性:#7381 修复过期循环排程导致账本页崩溃;#7532 修复清空交易收款方报错;#7623 修复余额模板金额不可整除时的无限循环;#7564 修复 Docker 因未解析的工作区依赖而无法启动;#7656 修复标签页挂起后共享 Worker 恢复。
- 一致性:#7496 修复 Web 与移动端在未分类交易菜单上「转账」入口不一致的问题;#7572 修复交易行拖拽干扰行内文本编辑。
维护与工程化改进
发布说明中的 Maintenance 条目反映了这个版本在工程质量上的投入,值得工程团队借鉴:
- 发布自动化升级(#7407/#7408/#7418):发布说明直接生成文档页面,并取消每月合并冻结的流程;#7635/#7640 修复冲突与 cherry-pick 提交下的说明生成脚本;
- 模块化改造(#7429/#7446/#7462):loot-core 内部导入迁移至 Node.js subpath imports(
#server/*、#shared/*),所有包统一模块导入规范,并新增 #7467 架构边界 ESLint 规则; - CI 加固(#7433/#7448/#7465/#7533):修复 GitHub Actions 中的脚本注入模式、引入
zizmor检查与自动修复,减少 stale 工作流权限; - 发布安全(#7556/#7579/#7583):nightly 与正式 npm 包启用 Trusted Publishing,并统一发布工作流;
- 构建效率(#7503/#7551):e2e 测试改用预构建包、共享 CI 依赖安装步骤以削减
yarn install次数; - 工具链升级(#7463/#7508):oxlint/oxfmt 升级、ESLint v10;#7538 关闭产物压缩以保留可读的生产报错堆栈;#7547 关闭 Electron 构建矩阵 fail-fast。
补丁版本说明:26.5.1 / 26.5.2
紧随其后的 26.5.1 与 26.5.2 为功能完全一致的补丁版本(多出的 26.5.2 仅为解决 Windows Store 发布问题),核心修复见 26.5.1 发布说明:
- #7707认证限流仅统计失败登录尝试(见上文);
- #7713修复桌面应用自签名证书功能;
- #7734UUID 生成回退到
uuid库(而非crypto.randomUUID()),以兼容 HTTP 非安全上下文环境。
如果你的部署环境使用了自签名证书,或通过 HTTP 访问(非 localhost),建议升级到 26.5.1 及以上版本。
结语
从实现层面看,26.5.0 的两个新报告体现了 Actual 报告体系的两个方向:Age of Money 用可验证的 FIFO 算法提供「财务健康度」的量化指标,Sankey 则以六层数据模型 + 可配置管线提供资金流向的可视化洞察。二者都深度复用了规则条件过滤(useRuleConditionFilters)、时间范围与 Dashboard widget 机制,使得报告不再是孤立的页面,而是可以组合、过滤、保存的"一等公民"。配合认证限流、E2E 加密澄清、主题持久化等增强,26.5.0 是升级价值相当高的一次发布——如果对上述源码细节感兴趣,建议继续阅读 age-of-money-spreadsheet.ts 与 sankey-spreadsheet.ts 的完整实现及配套测试。
【免费下载链接】actualA local-first personal finance app项目地址: https://gitcode.com/GitHub_Trending/ac/actual
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考