# 多币种与汇率

## 状态

- 设计状态：首版已实现，后续按本文验收项继续补齐测试和运营能力。
- 记账本位币固定为 `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 的顺序执行。
