cents/docs/多币种与汇率.md

446 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 多币种与汇率
## 状态
- 设计状态:待实现。
- 记账本位币固定为 `CNY`
- 本文描述多币种记账、汇率缓存、离线降级及人民币汇总的首版方案。
## 目标
1. 每个账本可以设置默认币种,默认值为 `CNY`
2. 每笔账目保存原币金额,并保存按记账当日汇率计算的人民币金额。
3. 流水和统计可以统一按人民币展示,不在查询报表时实时调用汇率服务。
4. 已查询成功的历史汇率持久化到数据库,同一币种、同一日期不重复依赖外部 API。
5. 汇率查询失败或设备离线时,不能阻止用户记账。
6. 恢复网络后,可以将临时使用的旧汇率或尚未换算的账目回补为记账当日汇率。
7. 汇率查询和人民币金额计算全部异步执行,不能增加确认记账的等待时间。
## 非目标
- 不支持用户自定义本位币,系统本位币固定为 `CNY`
- 不提供外汇交易、资产账户和汇兑损益核算。
- 不允许用户手工修改系统汇率。手工汇率可作为后续能力。
- 首版不按照账本分别复制账目或人民币金额。一笔账目仍是一个数据库记录,可以关联多个账本。
## 币种范围
首版常用币种:
| 代码 | 名称 | 符号 | 小数位 |
| --- | --- | --- | --- |
| `CNY` | 人民币 | `¥` | 2 |
| `USD` | 美元 | `$` | 2 |
| `EUR` | 欧元 | `€` | 2 |
| `JPY` | 日元 | `¥` | 0 |
| `THB` | 泰铢 | `฿` | 2 |
现有 `HKD` 可以继续作为非置顶币种保留,避免已有数据失去支持。币种选择器中必须同时显示币种名称和代码,不能只依赖符号区分人民币、日元等币种。
金额继续使用最小货币单位的整数存储,例如:
- `CNY 12.34` 存为 `1234`
- `USD 12.34` 存为 `1234`
- `JPY 1234` 存为 `1234`
所有金额格式化和输入精度必须由币种元数据决定,不能统一假设为两位小数。
## 账本默认币种
`ledgers.default_currency` 保留为账本默认币种,数据库默认值为 `CNY`
- 新建账本默认使用 `CNY`
- 账本设置中可以修改默认币种。
- 修改默认币种只影响之后打开的新记账草稿,不修改历史账目。
- 从某个账本发起快速记账时,初始币种取当前账本的默认币种。
- 一笔账目关联多个账本时,币种仍只有一个;其他关联账本的默认币种不参与计算。
- 个人账本自动关联规则不改变当前记账币种。
## 记账交互
金额输入框左侧显示当前币种符号。点击符号打开移动端底部模态框:
1. 顶部固定展示人民币、美元、欧元、日元和泰铢。
2. 每项展示符号、中文名称和 ISO 代码。
3. 其他已支持币种放在“更多”区域。
4. 选择币种后关闭模态框,金额输入精度立即按新币种更新。
5. 切换币种时保留金额数字本身,不自动换算用户正在输入的金额。
6. 当前草稿一旦由用户手动切换币种,后续切换关联账本不覆盖该选择。
外币金额下方可以非阻塞地展示换算结果和状态:
- 精确历史汇率:`约 ¥123.45 · 2026-07-25 汇率`
- 使用历史缓存降级:`约 ¥123.45 · 使用最近汇率`
- 尚无可用汇率:`人民币金额将在联网后补充`
汇率状态只作提示,不能禁用“确认”按钮。
点击“确认”后的主流程只有:
1. 校验并保存原币金额、币种、发生时间、分类和关联账本。
2. 将外币账目标记为 `pending`,写入一条待换算任务。
3. 立即关闭记账界面并反馈记账成功。
主流程不能等待 IndexedDB 汇率缓存查询、后端汇率接口、第三方 API 或人民币金额计算。草稿中的换算结果只能作为异步预览;没有预览结果也不影响提交。
## 汇率定义与计算
系统内部统一使用“1 单位外币等于多少 CNY”的直接汇率
```text
cny_per_unit = CNY / 1 source currency
```
例如 `1 USD = 7.210000 CNY` 时,`cny_per_unit = 7.210000`。
人民币最小单位金额按以下规则计算:
```text
cny_amount =
round(
amount_minor
/ 10 ^ source_currency_minor_units
* cny_per_unit
* 100
)
```
- `cny_amount` 始终是人民币分的整数。
- 舍入规则固定为四舍五入到人民币分。
- `CNY` 账目汇率固定为 `1``cny_amount = amount`,不查询汇率服务。
- 汇率使用高精度定点数,数据库建议使用 `numeric(24, 12)`,禁止使用浮点数参与持久化计算。
## 汇率日期
汇率日期根据账目的发生时间和创建账目时的用户时区确定,并作为日期单独固化。
- 新增账目时,将 `occurred_at` 转换为用户本地日历日期,写入 `exchange_rate_date`
- 修改金额不会重新查询汇率,但人民币金额要在后台使用账目已有的汇率快照重新计算。
- 修改币种或将发生时间改到另一天时,重新解析汇率。
- 修改备注、分类或关联账本不触发汇率变化。
- 后续改变用户时区不改变历史账目的汇率日期。
## 数据模型
### 账目
现有账目已经包含 `amount`、`currency`、`base_currency`、`base_amount`、`exchange_rate` 和 `exchange_rate_source`。首版沿用这些字段,明确其语义,并补充状态字段:
| 字段 | 语义 |
| --- | --- |
| `amount` | 原币最小单位整数 |
| `currency` | 原币 ISO 代码 |
| `base_currency` | 固定为 `CNY` |
| `base_amount` | 人民币分;没有任何可用汇率时允许暂时为空 |
| `exchange_rate` | `cny_per_unit` 汇率快照;待回补时为空 |
| `exchange_rate_date` | 账目应使用的汇率日期 |
| `exchange_rate_effective_date` | 实际采用的汇率日期;降级时可能早于目标日期 |
| `exchange_rate_source` | `system``manual`,首版只写 `system` |
| `conversion_status` | `exact`、`fallback` 或 `pending` |
`base_amount` 是后台换算任务生成的快照。流水和统计直接读取它,不在每次查询时重新计算。
`conversion_status` 的含义:
- `exact`:采用记账日期对应的历史汇率。
- `fallback`:历史汇率暂时查询失败,采用数据库或本地缓存中的最近可用汇率。
- `pending`:服务器和当前设备都没有可用汇率,原币账目已保存,等待联网回补。
### 历史汇率
新增 `exchange_rates` 表:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `currency` | `varchar(3)` | 外币代码 |
| `rate_date` | `date` | 汇率生效日期 |
| `cny_per_unit` | `numeric(24,12)` | 1 单位外币对应的 CNY |
| `provider` | `varchar(64)` | 数据提供方标识 |
| `provider_rate_date` | `date` | 提供方实际返回的日期 |
| `fetched_at` | `timestamptz` | 在线查询完成时间 |
| `created_at` | `timestamptz` | 入库时间 |
| `updated_at` | `timestamptz` | 更新时间 |
主键为 `(currency, rate_date)`。本位币固定为 CNY因此表中不需要重复保存 `base_currency`
周末或节假日若提供方返回最近交易日汇率:
- 以请求的日历日期作为 `rate_date`,使后续同日查询命中数据库。
- 以真实交易日作为 `provider_rate_date`,保留来源信息。
## 汇率解析流程
后端提供统一的汇率解析服务,客户端不直接调用第三方 API。该服务由后台换算任务调用不处在新增或编辑账目的请求链路中
1. `CNY` 直接返回汇率 `1`
2. 查询服务进程的内存缓存。
3. 内存没有可用精确值时查询 `exchange_rates` 数据库表。
4. 数据库精确命中后写入内存缓存并直接返回,不请求外部服务。
5. 数据库没有目标日期时,保留查询到的最近历史汇率作为降级候选,再调用外部汇率 API。
6. 外部查询成功后用 upsert 写入 `exchange_rates`,写入内存缓存,再返回 `exact` 结果。
7. 外部查询失败时返回第 5 步保留的数据库最近值,状态为 `fallback`
8. 数据库不存在更早记录时,降级候选取日期距离目标日最近的记录。
9. 内存、数据库和外部 API 均无可用结果时返回 `pending`,不能让记账请求失败。
查询顺序固定为:
```text
进程内存缓存 -> PostgreSQL -> 外部 API
```
数据库唯一约束负责处理并发查询同一汇率时的重复写入。外部请求必须设置短超时,汇率服务不可用时快速进入降级流程。
### 内存缓存
内存缓存是单个 API/worker 进程内的 LRU 缓存,不作为持久化来源。多实例各自维护缓存,不要求实例间同步。
- 缓存键:`provider + currency + requested_date`。
- 缓存值:汇率、请求日期、实际生效日期、提供方和换算状态。
- 历史精确汇率可以缓存到进程退出,仍受 LRU 容量限制。
- 当日汇率使用较短 TTL避免提供方尚未发布当日数据时长期保留上一工作日结果。
- `fallback` 使用短 TTL过期后允许后台任务重新尝试精确汇率。
- `pending` 可以做数十秒的负缓存,避免外部服务故障时形成请求风暴。
- 相同缓存键的并发未命中必须合并为一个查询任务,即 single-flight。
- 建议初始上限为 2,000 个键;淘汰不会影响正确性,下一次会回查数据库。
内存命中只用于减少数据库和外部请求。历史汇率的可靠来源仍是 PostgreSQL服务重启不能造成历史数据丢失。
## 外部汇率提供方
首版使用 [Frankfurter v2](https://frankfurter.dev/) 公共 API并固定筛选 `ECB` 数据源:
- 公共地址:`https://api.frankfurter.dev`
- 无需 API Key。
- 支持指定日期的历史汇率。
- CNY、USD、EUR、JPY、THB 和 HKD 均在 ECB 参考汇率覆盖范围内。
- API 是开源项目,后续可以自行部署,业务接口不需要变化。
查询某日外币兑人民币的接口:
```text
GET https://api.frankfurter.dev/v2/rate/{SOURCE}/CNY
?date={YYYY-MM-DD}
&providers=ECB
```
例如:
```text
GET https://api.frankfurter.dev/v2/rate/USD/CNY
?date=2024-07-25
&providers=ECB
```
响应:
```json
{
"date": "2024-07-25",
"base": "USD",
"quote": "CNY",
"rate": 7.2188
}
```
返回值方向与系统定义一致,即 `1 USD = 7.2188 CNY`,不需要取倒数。必须校验响应中的 `base`、`quote`、`date` 和正数汇率,不能只读取 `rate`
周末或节假日请求可能返回最近工作日。例如请求 2024-07-27 时可能返回 `date = 2024-07-26`。数据库仍以账目请求日期作为 `rate_date`,以响应日期作为 `provider_rate_date`
当日汇率也通过同一接口查询。ECB 通常在欧洲工作日下午发布参考汇率发布前查询当日、周末或节假日查询当日Frankfurter 会返回最近一个已有数据的工作日。系统不把这种情况视为查询失败:
- `rate_date` 保存账目对应的本地日历日期。
- `provider_rate_date` 保存 API 实际返回的工作日。
- 当日缓存使用短 TTL在提供方发布新数据后允许重新查询并回补。
ECB 参考汇率适合个人账本的统一估值但不是银行实际结算价。首版不自动切换到其他提供方Frankfurter 不可用时使用已有数据库汇率或保持 `pending`,避免不同来源的历史汇率口径混杂。未来如增加备用源,必须把提供方优先级和来源记录在汇率表中。
建议接口:
```text
GET /api/exchange-rates/resolve?currency=USD&date=2026-07-25
```
返回:
```json
{
"currency": "USD",
"requestedDate": "2026-07-25",
"effectiveDate": "2026-07-25",
"cnyPerUnit": "7.210000000000",
"status": "exact"
}
```
汇率提供方通过后端适配器隔离,业务层只依赖统一的解析结果。以后更换提供方不应修改账目和报表逻辑。
## 异步换算任务
### 客户端
客户端保存外币账目后,将账目 ID 放入本地待换算队列,随后立即结束记账交互。后台任务按以下顺序处理:
1. 查询 IndexedDB 中的精确日期汇率。
2. 命中后计算本地人民币金额并更新页面。
3. 未命中时尝试请求后端汇率解析接口。
4. API 不可用时使用本地最近汇率,或保持 `pending`
5. 将原币账目和当前换算状态交给正常同步流程。
本地换算只用于离线展示和提前提供结果。服务端异步换算结果仍是最终值。
### 服务端
服务端接收新增或编辑账目时:
1. 先保存原币账目。
2. 在同一数据库事务中写入待换算任务。
3. 立即返回账目保存成功,不在线查询汇率,也不在请求内计算人民币金额。
4. 后台 worker 领取任务,解析汇率、计算人民币金额并更新账目。
5. 通过现有同步版本机制把换算结果发送给客户端。
待换算任务应持久化在数据库中,不能只放在进程内存。可以新增 `entry_conversion_jobs` 表,并以账目 ID 去重worker 使用 `FOR UPDATE SKIP LOCKED` 等数据库机制安全领取任务。进程重启后未完成任务必须能够继续执行。
任务写回前必须重新读取账目,并校验任务对应的金额、币种、汇率日期或账目版本:
- 账目已删除时直接结束任务。
- 金额已变化时,使用当前金额重新计算。
- 币种或汇率日期已变化时,废弃旧解析结果并按新值重新排队。
- 仅备注、分类或关联账本变化时,可以继续写入换算结果。
任务必须幂等。同一账目被重复排队或 worker 重试不能产生重复账目,也不能让旧任务覆盖较新的账目内容。
## 离线与失败降级
Web 端 IndexedDB 需要保存服务端最近同步下来的汇率缓存。进入记账页或成功同步后,后台刷新常用币种的近期汇率,该刷新同样不能阻塞记账界面。
新增外币账目的顺序:
1. 先保存原币账目,状态写为 `pending`,人民币金额暂时为空。
2. 客户端后台任务优先查找本地精确日期汇率。
3. 本地未命中且后端可用时,异步请求后端解析。
4. 精确查询失败时,使用服务端数据库中的最近历史汇率。
5. API 不可访问时,客户端优先使用本地缓存中不晚于记账日期的最近汇率;没有更早记录时使用日期距离目标日最近的缓存。
6. 客户端也从未缓存过该币种时保持 `pending`,等待后续同步和后台回补。
7. 即使第三方 API 仍失败,服务端也必须接受账目并保留 `pending` 状态。
不允许用汇率 `1` 代替未知外币汇率。这样虽然能得到一个非空人民币金额,但会污染流水和统计。
`pending` 账目在人民币汇总中暂不计入合计,并在页面显示“有 N 条外币账目等待换算”。原币模式下仍可正常查看和编辑。
## 后台刷新与回补
后台任务每天执行以下工作:
1. 预取常用币种当日汇率,降低用户记账时的等待和失败概率。
2. 重试 `conversion_status = pending` 的账目。
3. 重试 `conversion_status = fallback` 的账目,查询其目标日期精确汇率。
4. 获得精确汇率后,原子更新账目的 `exchange_rate`、`base_amount`、生效日期和状态。
5. 通过现有同步版本机制让客户端收到更新。
`fallback` 是可回补的临时快照,而不是永久锁定的估值。回补可能使历史人民币合计发生小幅变化,原币金额不变。
已是 `exact` 的账目不因提供方后续修订自动重算,避免历史统计在没有用户操作时持续漂移。如未来需要重估,应另做显式管理操作和审计记录。
## 流水与统计
流水页和统计页增加显示币种模式:
- `原币`:单条账目显示原始金额和币种。混合币种不能直接相加,汇总按币种分组。
- `人民币`:单条账目显示 `base_amount`,并可用弱化文本保留原币金额;页头和图表统一汇总人民币金额。
默认采用人民币模式,以保持跨账本、跨币种统计可加总。显示模式属于用户界面偏好,不写入账目。
所有人民币统计只读取 `base_amount`
- 不查询外部 API。
- 不逐条重新计算汇率。
- 同一账目即使关联多个账本,在“个人收支”或跨账本统计中仍只按账目 ID 计算一次。
- 在单个共享账本中只统计关联到该账本的账目。
- `pending` 账目不计入人民币合计,并明确展示缺失数量,不能静默当作零元。
## 同步与服务端校验
客户端可以随离线账目提交本地异步计算的汇率快照,但服务端保存账目的请求不能为了校验而等待外部汇率。服务端先接受账目和任务,再由后台任务完成以下校验:
- 币种在允许列表中。
- 金额为符合该币种小数位的整数。
- `base_currency` 只能是 `CNY`
- 汇率和人民币金额的计算结果在允许的舍入误差内一致。
- 客户端声明 `exact` 时,服务端数据库必须存在对应日期和数值的汇率,否则降级为服务端解析结果。
- 客户端使用本地旧汇率时写为 `fallback`,不能伪装为 `exact`
服务端后台任务写回的状态和金额为最终值。同步冲突继续沿用账目现有版本规则,汇率回补视为服务端对同一账目的更新。
## 数据迁移
1. 将支持币种列表加入 `THB`,保留已有 `HKD`
2. 新增 `exchange_rates` 表。
3. 新增持久化的 `entry_conversion_jobs` 待换算任务表。
4. 为账目增加汇率目标日期、实际生效日期和换算状态。
5. 允许 `base_amount``exchange_rate``pending` 状态下为空,并由数据库约束保证其他状态下两者非空。
6.`exchange_rate` 迁移为定点数;若暂不迁移类型,所有读写也必须通过十进制定点库处理。
7. `CNY` 历史账目设置汇率 `1`、`base_amount = amount`、状态 `exact`
8. 审计非 CNY 历史账目。当前实现曾将外币汇率写为 `1`,因此不能信任这些记录的 `base_amount`;应按发生日期重新查询并标记为 `exact`、`fallback` 或 `pending`
9. 在回填完成前,统计必须按换算状态处理,不展示错误的人民币合计。
## 可观测性
至少记录以下指标和结构化日志:
- 按提供方和币种统计的查询成功率、超时率及耗时。
- 数据库精确命中、在线查询、历史降级和待回补的数量。
- 当前 `pending`、`fallback` 账目数量及最老账龄。
- 后台回补成功和失败数量。
- 汇率计算校验失败的同步请求。
日志不得记录用户密码或完整账目备注。
## 测试重点
### 单元测试
- 各币种最小单位和格式化规则,尤其是 JPY 无小数。
- 汇率方向、定点计算和四舍五入。
- CNY 不查询外部服务。
- 精确命中、在线查询、最近历史降级和完全无缓存四条路径。
- 修改日期或币种触发重算,修改备注或分类不触发。
### API 与数据库测试
- 内存命中时不查询数据库和外部 API。
- 内存未命中、数据库精确命中时不查询外部 API。
- 两级缓存均未命中时查询 Frankfurter并同时回写数据库和内存。
- 同一币种和日期并发未命中时只发起一次外部请求。
- 服务重启清空内存后仍能从数据库恢复历史汇率。
- 同一币种、日期并发查询只保留一条汇率。
- 外部 API 超时不阻止账目写入。
- 人为延迟汇率接口时,新增和编辑账目的响应时间不随该延迟增加。
- 保存账目与创建待换算任务具有事务一致性。
- worker 重启、重复领取任务和账目并发编辑时保持幂等。
- `pending``fallback` 后台回补正确更新账目及同步版本。
- 服务端拒绝非法币种、错误汇率方向和不一致的人民币金额。
- 报表读取过程不调用外部汇率服务。
### 前端测试
- 点击金额符号打开币种模态框,常用币种顺序正确。
- 切换 JPY 后金额输入不允许小数。
- 完全离线时可以创建、编辑和删除外币账目。
- 点击确认后不等待汇率查询或人民币金额计算,记账界面立即关闭。
- 人民币模式、原币模式及混合币种汇总显示正确。
- 待换算提示不遮挡记账主流程,且不会把缺失金额当作零。
## 验收标准
1. 新账本和已有未设置币种的账本默认使用 `CNY`
2. 用户可以从金额左侧符号切换常用币种并成功记账。
3. 同一笔多账本关联账目只保存一份原币金额和人民币金额。
4. 流水和统计的人民币合计使用记账日期对应的汇率快照。
5. 查询过的历史汇率可以在外部服务不可用时从数据库读取。
6. 外部服务和网络都不可用时仍可完成记账。
7. 外币账目不存在被静默按 `1:1` 换算的情况。
8. `pending``fallback` 账目可以在后台恢复为精确汇率。
9. 报表查询不依赖外部汇率 API且不会为每次展示重复计算历史账目。
10. 汇率接口无论成功、失败还是超时,都不延长新增或编辑账目的主流程。
11. 汇率解析严格按照内存缓存、数据库、Frankfurter 的顺序执行。