news 2026/9/15 11:06:50

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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中的tripSchemacurrency定义为非空字符串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 倍余额不匹配的根源。

实现要点:

  1. 钉死隐式行:对budget_itemsbudget_settlements两张表执行UPDATE ... SET currency = ? WHERE trip_id = ? AND (currency IS NULL OR currency = ''),把此前继承旅行币种的行显式钉到旧币种;
  2. 重锚冻结汇率:对表中所有DISTINCT currency,用新基准下的实时汇率重写exchange_rate;已在新基准中的币种置为 1(表示「未冻结」),取不到实时汇率时也存 1 而非过期值——因为 1 意味着「未冻结」,结算会回退到实时汇率,而不是信任一个锚定在已废弃币种上的数字;
  3. 钉死地点价格:仅对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表同样有currencyexchange_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_editbudget_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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 11:05:16

Python实现高效whois查询工具的技术解析

1. 为什么需要自己实现whois查询工具在网络安全和运维工作中,whois查询是最基础但也是最重要的信息收集手段之一。作为一名长期从事安全开发的技术人员,我经常需要快速获取域名的注册信息、过期时间、DNS服务器等关键数据。虽然网上有很多现成的whois查询…

作者头像 李华
网站建设 2026/9/15 11:01:57

iii 0.21:把任意函数变成 REST 端点的完整路径

iii 0.21:把任意函数变成 REST 端点的完整路径 【免费下载链接】iii Effortlessly compose, extend, and observe every service in real-time for the first time ever. 项目地址: https://gitcode.com/GitHub_Trending/mo/iii 这篇文章带你用 iii 0.21 内置…

作者头像 李华