TREK 多币种体系深度解析:Trip / Expense / Display 三种货币的存储、冻结汇率与结算原理

发布时间:2026/9/15 11:06:55
TREK 多币种体系深度解析:Trip / Expense / Display 三种货币的存储、冻结汇率与结算原理
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-basingTREK 会替你完成全部换算保证没有一分钱发生位移没有自己币种的支出即此前只是隐式继承了旅行货币会先被钉死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 被禁用也依然保留。而支出币种、冻结汇率与结算功能全部属于Costsaddon 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 旅行上带外币支出的余额被虚增。升级即可数据没有损坏无需手工修复。其修复实现正是上文分析的rebaseTripCurrencyserver/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.tsAPI 契约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),仅供参考