把整个响应视为一次观测
脱离元数据的汇率含义不完整:它可能是日内现货、每日参考值或推导交叉汇率。因此应保存完整对象,而不是只取出 rates.EUR。API参考定义了响应字段,方法说明解释了来源和市场状态标签的生成方式。
json · abbreviated latest responsecopy
{
"base": "USD",
"source": "live",
"market_session": "open",
"timestamp": "2026-08-23T09:42:06Z",
"data_updated_at": "2026-08-23T09:41:00Z",
"rates": { "EUR": 0.86124, "GBP": 0.74281 },
"sources": { "EUR": "live", "GBP": "live" },
"derived_symbols": []
}source 汇总多币种响应中最不及时的数据类别。单个币种应读取 sources。derived_symbols 标记通过其他币种路径计算出的汇率。
两个时间,两种含义
timestamp 是API组装响应的时间,data_updated_at 是底层观测值写入的时间。展示更新时间和判断新鲜度时应使用后者。
先定义刷新政策,再写定时器
不存在适合所有产品的间隔。商店估算价格可共享几分钟缓存,运营面板可在页面可见时刷新,日报可在所需定盘价发布后执行一次。每次渲染都请求不仅浪费调用,也可能得到同一个观测值。
用途应用政策界面显示
商店估算共享缓存;每几分钟刷新币种、指示性说明、观测时间
运营面板短缓存;可见时刷新汇率、来源、市场状态、时间
日报定盘后定时获取一次汇率与存储的观测时间
结算金额不使用指示性转换汇率支付或交易服务结果
market_session 的值为 open、weekend 或 interbank_closed。活跃货币可在交易周内变化,每日参考序列则按自己的发布计划更新。
在服务器端建立小型缓存
先对币种排序以生成稳定键,只缓存成功响应,并避免并发刷新重复发出。带密钥的请求必须留在服务器端。
typescript · latest-rates.tscopy
type RateSource = "live" | "ecb_daily" | "fred_daily";
type LatestRates = {
base: string;
source: RateSource;
sources: Record<string, RateSource>;
market_session: "open" | "weekend" | "interbank_closed";
timestamp: string;
data_updated_at: string;
rates: Record<string, number>;
derived_symbols: string[];
};
type CacheEntry = {
value: LatestRates;
fetchedAt: number;
};
const cache = new Map<string, CacheEntry>();
const pending = new Map<string, Promise<LatestRates>>();
function cacheKey(base: string, symbols: string[]): string {
return `${base}:${[...symbols].sort().join(",")}`;
}
async function fetchLatest(base: string, symbols: string[]): Promise<LatestRates> {
const url = new URL(`https://api.exchangerate.dev/v1/latest/${base}`);
url.searchParams.set("symbols", [...symbols].sort().join(","));
const response = await fetch(url, {
headers: process.env.EXCHANGERATE_API_KEY
? { Authorization: `Bearer ${process.env.EXCHANGERATE_API_KEY}` }
: {},
signal: AbortSignal.timeout(8000),
});
if (!response.ok) {
throw new Error(`FX refresh failed: ${response.status}`);
}
return response.json() as Promise<LatestRates>;
}
export async function getLatestRates(
base: string,
symbols: string[],
maxAgeMs = 60_000,
): Promise<LatestRates> {
const key = cacheKey(base, symbols);
const existing = cache.get(key);
if (existing && Date.now() - existing.fetchedAt < maxAgeMs) {
return existing.value;
}
const inFlight = pending.get(key);
if (inFlight) return inFlight;
const refresh = fetchLatest(base, symbols)
.then((value) => {
cache.set(key, { value, fetchedAt: Date.now() });
return value;
})
.finally(() => pending.delete(key));
pending.set(key, refresh);
return refresh;
}多实例部署应使用共享存储或框架请求缓存。原则不变:数值和观测元数据一起保存,只在成功后替换。
刷新错误不是新汇率
超时、429或上游错误并不代表市场有新观测。若产品允许使用最后有效值,应保留原始 data_updated_at 并显示回退状态;没有旧数据时直接报错。429应遵守 Retry-After,临时错误采用带随机抖动的指数退避,401则需要修正配置。
typescript · explicit stale fallbackcopy
type ConverterResult =
| { status: "fresh"; data: LatestRates }
| { status: "last_known_good"; data: LatestRates; reason: string };
async function getConverterRates(
base: string,
symbols: string[],
): Promise<ConverterResult> {
const key = cacheKey(base, symbols);
try {
return { status: "fresh", data: await getLatestRates(base, symbols) };
} catch (error) {
const previous = cache.get(key);
if (!previous) throw error;
return {
status: "last_known_good",
data: previous.value,
reason: error instanceof Error ? error.message : "Refresh failed",
};
}
}对于429响应,应在存在时遵守 Retry-After,并避免所有实例同时重试。临时故障可使用带随机抖动的指数退避。认证错误需要修正配置,而不是反复重试。
市场关闭是正常状态
周末响应的 market_session: weekend 可以合理地携带交易周最后一次观测。不要用新HTTP响应的时间覆盖观测时间,也不要制造周末报价。
市场关闭时,转换器可以延长应用缓存时间,但交易周恢复后仍应刷新。应根据返回的市场状态和产品要求作出决定,不要假设所有货币都遵循同一个时间表。
换算后只舍入一次
以USD为基础时,25 USD换算EUR为 25 × rates.EUR。保留汇率精度,只按目标币种规则舍入最终金额。
指示性,不可执行
转换汇率适合展示、估算和分析。实际扣款金额应由支付或交易服务给出,并包含点差、费用、舍入与执行时间。
上线检查清单
- 只请求界面需要的币种。
- 用基础币种与排序后的符号生成缓存键。
- 只缓存完整成功响应,不缓存HTTP错误。
- 合并并发刷新,避免缓存击穿。
- 需要显示数据年龄时使用
data_updated_at。 - 保留
sources、market_session 和 derived_symbols。 - API密钥仅放在服务器端。
- 使用最后有效值时保留原始时间并明确标记。