TREK 多币种体系深度解析:Trip / Expense / Display 三种货币的存储、冻结汇率与结算原理
【免费下载链接】TREKA self-hosted travel/trip planner with real-time collaboration, interactive maps, PWA support, SSO, budgets, packing lists, and more.项目地址: https://gitcode.com/GitHub_Trending/nomad22/TREK
导读
TREK(自托管旅行规划器)的 Costs(预算)模块支持多币种记账,但很多用户对「余额为何忽大忽小」「汇率为何不实时更新」「切换旅行货币后金额为何不变」感到困惑。本文以 wiki/Currencies.md 为骨架,结合 server/src/services/budgetService.ts 与 server/src/services/exchangeRateService.ts 等源码实现,系统拆解 TREK 的三种货币设置——Trip currency(记账基准)、Expense currency(单据币种)、Display currency(阅读币种)——及其背后的存储模型、汇率冻结策略与余额结算算法,帮助你彻底掌握多币种旅行记账的正确姿势,并能读懂「余额异常」「总额每天微动」等现象背后的设计意图。
一、三种货币,回答三个不同的问题
TREK 共有三种货币设置,它们各自回答一个完全不同的问题。大多数关于 Costs 标签页的困惑,都源于把三者混为一谈。官方文档用一张表把它们一次性定义清楚:
| 设置项 | 位置 | 回答的问题 | 影响范围 |
|---|---|---|---|
| Trip currency(旅行货币) | Trip → 编辑旅行 | 这笔旅行的钱以什么计? | 存储数据——所有余额计算的基础 |
| Expense currency(支出货币) | Costs → 支出弹窗 | 我实际是以什么货币付款的? | 存储数据——仅该笔支出 |
| Display currency(显示货币) | 设置 → 常规 | 我想以什么货币阅读? | 仅展示——绝不改动存储数据 |
一句话版本:旅行货币是记账基准,支出货币是收据,显示货币是你的「阅读眼镜」。
从数据结构上可以印证这一划分:shared/src/trip/trip.schema.ts中的tripSchema将currency定义为非空字符串(currency: z.string()),且tripCreateRequestSchema/tripUpdateRequestSchema均携带可选的currency字段;而shared/src/budget/budget.schema.ts中的budgetItemSchema则把currency定义为可空(currency: z.string().nullable().optional()),并额外携带exchange_rate(冻结汇率)字段。这一「旅行上必有币种、支出上可选币种」的类型设计,正是三种货币存储语义在 API 契约层面的直接体现。
二、Trip currency:整笔旅行的账务基准
每个旅行有且仅有一个货币。它在创建旅行时设置,之后可以在旅行编辑对话框中修改(需要trip_edit权限),默认值为EUR。
它不是一个装饰性标签,而是旅行的账务基准(accounting base):
- 所有余额、债务和结算建议(settle-up suggestion)都以此计算;
- 所有其他币种的支出都会换算成它,并连同换算时的汇率一起存储;
- 当没有任何人表达显示偏好时,Costs 标签页就回落到它。
因此官方建议:选择你旅行目的地所在国的货币(或你实际会用其结算的货币),之后几乎无需再操心币种问题。
从源码看,权限校验确实绑定在trip_edit上——例如 server/src/mcp/tools/trips.ts 中更新旅行前会先执行hasTripPermission('trip_edit', ...)检查;而 MCP 工具层的 server/src/mcp/tools/budget.ts 对新增/编辑支出则统一校验budget_edit权限(对应文档中「添加或编辑支出需要budget_edit」的说明)。
2.1 变更旅行货币 = 重定基准,而非重新贴标签
修改旅行货币不是重贴标签,而是一次重定基准(re-basing),TREK 会替你完成全部换算,保证没有一分钱发生位移:
- 没有自己币种的支出(即此前只是隐式继承了旅行货币)会先被钉死(pin)到旧货币。例如一趟从 RUB 切换到 EUR 的旅行上有一笔 9 000 ₽ 的支出,它仍保持9 000 ₽,而不会静默变成 9 000 €;
- 每一条冻结汇率都会被重新锚定(re-anchor)到新基准,因为冻结汇率是相对旅行货币存储的(见下文);
- 地点价格(Place prices)以同样的方式被钉死。地点价格同样默认继承旅行货币(除非你单独指定),因此一趟切到 JPY 的旅行上 €15 的博物馆会被盖上 EUR 戳记,而不是开始显示成 ¥15。
你输入的每一个数字都不会被重写。每笔支出保留其原始金额、原始币种,其真实世界价值在切换中得以保全——变化的只是余额所表达的基准。
2.2 源码实现:rebaseTripCurrency的工作方式
上述行为对应 server/src/services/budgetService.ts 中的rebaseTripCurrency函数。其注释直接解释了为什么必须这样做:
每条冻结的
exchange_rate都是「该行币种相对 1 单位旅行币种的汇率」,而currency = NULL表示「即旅行自身币种」——两者都是相对旅行货币的,如果在它们脚下偷换基准,结算就会静默损坏:NULL 行会被重新计价(9 000 RUB 变成 9 000 EUR),冻结汇率会继续指向旧基准——这正是 #1543 大约放大 27 倍余额不匹配的根源。
实现要点:
- 钉死隐式行:对
budget_items与budget_settlements两张表执行UPDATE ... SET currency = ? WHERE trip_id = ? AND (currency IS NULL OR currency = ''),把此前继承旅行币种的行显式钉到旧币种; - 重锚冻结汇率:对表中所有
DISTINCT currency,用新基准下的实时汇率重写exchange_rate;已在新基准中的币种置为 1(表示「未冻结」),取不到实时汇率时也存 1 而非过期值——因为 1 意味着「未冻结」,结算会回退到实时汇率,而不是信任一个锚定在已废弃币种上的数字; - 钉死地点价格:仅对
price IS NOT NULL的地点执行钉币种,同时用updated_at = CURRENT_TIMESTAMP充当乐观并发令牌(#1135),防止持有切换前旧行的客户端把钉死的币种写回去。
函数必须在(同步的)旅行更新之前运行,此时旧币种仍在trips表中;当币种实际未变化时它是空操作。
三、Expense currency:以收据为准的单笔币种
Costs 标签页中的每一笔支出都带有自己的币种,在支出弹窗中选择。按收据原样录入即可:在卢布旅行中一顿 $100 的晚餐,应录入为100 USD,而不是它的卢布等价金额。
当一笔支出的币种与旅行货币不同时,TREK 会在保存的那一刻查找一次实时汇率并冻结在这笔支出上。此后这笔支出换算进旅行货币,永远使用这个冻结汇率。
3.1 为什么必须冻结汇率?
因为「今天结清的债,不应该明天又被打开」。如果余额总是按实时汇率重算,那么一笔已经结清的旅行,每当外汇市场波动,就会重新冒出几分钱的债务。你成交时的汇率,就是你欠下的汇率。
在 server/src/services/budgetService.ts 的freezeForeignRate中可以看到完整的冻结规则:
- 调用方显式传入的
exchange_rate优先(if (data.exchange_rate != null) return); - 请求未携带币种则跳过;
- 若币种没有真正变化(与
budget_items中既有币种一致)则跳过重新冻结——无关的编辑绝不会移动一分钱; - 币种与旅行货币相同则跳过(无需换算);
- 其余情况向
getRates(tripCur)取实时汇率并写入exchange_rate。
3.2 汇率数据源:Frankfurter 与 165 种货币
汇率来自 Frankfurter(欧洲央行数据,无需 API key),支持165 种货币。其客户端实现在 server/src/services/exchangeRateService.ts:
- 请求
https://api.frankfurter.dev/v2/rates?base=...(第 20 行); - 对每个基准币种做6 小时 TTL 的内存缓存(
TTL_MS = 6 * 60 * 60 * 1000),避免结算请求频繁击穿上游; - 用
inflightMap合并并发请求,同一基准的并发查询共享同一个 Promise; - 若拉取失败(实例离线或上游宕机),返回
null或回退到上次缓存值——TREK 绝不凭空发明一个汇率。
汇率语义为「1 单位基准币种兑换 X 单位其他币种」,因此金额换算用amount / rates[C](convertWithRates,第 63-75 行);当币种相同、汇率缺失或非正时退化为恒等转换。
离线降级路径:如果汇率查询失败(实例离线或上游宕机),支出会不带冻结汇率地存储,下次读取时回退到实时换算——总之,TREK 不会伪造汇率。
3.3 结算(Settle Up)同样冻结
在Settle Up中记录的转账也携带自己的币种,原因相同:用欧元转账去结清一笔卢布债务是完全正常的。它的汇率在记录那一刻被冻结,与支出的冻结完全一致(budget_settlements表同样有currency与exchange_rate字段,见 shared/src/budget/budget.schema.ts 的budgetSettlementSchema),因此这笔支付会持续抵消它本应抵消的债务。
从 server/src/services/budgetService.ts 的插入/更新 SQL 可见,currency会被统一toUpperCase()后存储,exchange_rate默认 1。
四、Display currency:纯展示的「阅读眼镜」
设置 → 常规 → Currency是一个按用户设置、仅影响展示的偏好。它把你在 Costs 标签页中读到的一切——总额、分类图表、余额、结算金额——换算成单一币种,让一笔混有美元、日元和卢布的旅行仍然能加总成一个你能看懂的数字。
它永远不会改变存储内容。两个人看同一趟旅行,可以分别用不同货币阅读,并且看到的余额都是一致且正确的。
它有两种模式:
| 取值 | 行为 |
|---|---|
| Trip currency(默认) | 每趟旅行用各自的币种展示:东京之旅读日元,莫斯科之旅读卢布 |
指定币种(如USD) | 每一趟旅行都为你换算成该币种,无论其自身币种是什么 |
除非你明确希望「不管去哪都用本币阅读」,否则保持Trip currency即可。管理员可以在Admin → Default User Settings中为所有新用户设置实例级默认值;你自己选择Trip currency会覆盖它,因为这是主动选择而非「未选择」。
展示转换使用实时汇率,而非冻结汇率——展示是一种视图,视图应该反映今天。这就是为什么换算后的总额可能逐日微变,而底层余额稳如磐石。
对应的服务端实现可参考 server/src/services/settingsService.ts(用户偏好存储),实例级默认值则由管理端默认用户设置提供。
五、三者如何协作:一笔支出的完整旅程
一笔支出会依次流经全部三个层级。官方文档给出的流程图清晰展示了这一管道:
100 USD ≈ 7 668 ₽ ≈ 87 € ┌───────────┐ frozen ┌───────────────┐ live ┌───────────────┐ │ expense │ ─────────► │ trip │ ─────────► │ display │ │ currency │ rate │ currency │ rate │ currency │ └───────────┘ (at entry)└───────────────┘ (at read) └───────────────┘ what you what the trip is what you read, actually paid settled in ← balances if you asked for live here a display currency- 录入时(frozen rate):$100 的支出在保存瞬间按冻结汇率换算进旅行货币(卢布),冻结汇率随行存储;
- 结算时(live here):所有余额、债务都在旅行货币中净额结算;
- 阅读时(live rate):若你设置了显示币种,最终净额一次性按实时汇率换算成显示货币。
关键在于计算顺序:余额总是在旅行货币中轧差,然后在最后一次性换算到你的显示币种——绝不做逐笔换算。这一顺序是刻意的:如果在漂移的显示币种中逐笔轧差,舍入漂移会把一笔已结清的旅行搅出幽灵般的分币债务。
这一点在 server/src/services/budgetService.ts 的结算计算中有直接体现——注释明确写道:在旅行规范币种中净额结算整个 settlement,再把最终总额一次性换算到显示币种,而非在(不断漂移的)显示币种中轧差;否则逐笔舍入会随着实时汇率漂移而变动,贪婪算法甚至会得出不该发生的结算建议。对每笔支出,先通过冻结汇率(若存在)换算到旅行币种,再对无冻结汇率的行(currency = NULL,前身遗留行)用实时汇率换算。
六、公开分享链接:访客读的是谁的币种?
公开分享页面没有登录的访客,因此无法使用「你的」显示币种。它使用的是分享者的显示币种,回退到旅行自身币种——也就是说,访客看到的是分享者眼中的那趟旅行。如果分享者把显示币种留在Trip currency,访客就按旅行币种阅读。相关机制可参见 wiki/Public-Share-Links.md。
七、与 Costs 插件(addon)的关系
旅行货币住在旅行对象本身上,不在 Costs 插件里——它在旅行对话框中设置,即使 Costs 被禁用也依然保留。而支出币种、冻结汇率与结算功能全部属于Costs(addon idbudget),管理员可以在 wiki/Admin-Addons.md 中开关它。关闭 Costs 只是隐藏金钱功能,不会清除旅行的货币。
权限边界同样清晰:
- 修改旅行货币需要
trip_edit; - 添加或编辑支出(及其币种)需要
budget_edit。
详见 wiki/Admin-Permissions.md。从 server/src/nest/plugins/host/create-rpc-host.ts 可以看到,插件宿主层在访问预算能力时同样会做budget_edit权限校验——权限模型贯穿 Web API、MCP 工具与插件 RPC 三层。
八、故障排查:三个高频疑问
「含外币支出的旅行,余额巨大/离谱。」已在v3.4.0(#1543)修复。旧版结算逻辑读错了旅行币种,把每趟旅行都当成 EUR,导致任何非 EUR 旅行上带外币支出的余额被虚增。升级即可,数据没有损坏,无需手工修复。其修复实现正是上文分析的rebaseTripCurrency(server/src/services/budgetService.ts),它从根本上杜绝了「NULL = 旅行币种」行在切换基准后被错误重新计价的问题。
「总额每天微微变动。」如果你设置了与旅行币种不同的显示币种,这是预期行为:显示层转换使用实时汇率。底层的余额和债务纹丝不动。
「某笔支出显示出一个奇怪的换算值。」它的汇率是在录入时被冻结的,而市场已经变化。这是设计使然——见上文「为什么必须冻结汇率」。
九、延伸阅读
- wiki/Budget-Tracking.md — Costs 标签页完整功能
- wiki/Creating-a-Trip.md — 在创建旅行时设置旅行币种
- wiki/Display-Settings.md — 显示币种所在的位置
- wiki/Public-Share-Links.md — 公开分享的展示语义
- wiki/Admin-Addons.md — Costs 插件开关
- wiki/Admin-Permissions.md —
trip_edit与budget_edit权限明细 - 核心实现:server/src/services/budgetService.ts、server/src/services/exchangeRateService.ts
- API 契约:shared/src/budget/budget.schema.ts、shared/src/trip/trip.schema.ts
【免费下载链接】TREKA self-hosted travel/trip planner with real-time collaboration, interactive maps, PWA support, SSO, budgets, packing lists, and more.项目地址: https://gitcode.com/GitHub_Trending/nomad22/TREK
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考