Files
happy-export/backend/lib/fx.js
T
Drsakura 13d67c804f feat: 多用户权限体系、AI 助手、SKU 目录与合同清洗(开源准备)
本地长期开发成果的整体入库快照,包含后端鉴权/权限、AI 接入、合同解析、
SKU 目录等模块,以及前端相应页面与样式改版。

后端
- 新增鉴权与会话:scrypt 密码哈希、HttpOnly cookie(happy_sid)、登录失败锁定
- 新增「角色 → 权限组 → 权限点」三级权限模型与审计日志
- 新增 AI 提供商接入与预设(兼容 OpenAI 协议,内置 DeepSeek / 通义千问等)
- 新增合同解析、合同文件保留策略、客户归属与访问控制
- 新增 SKU 目录、组织品牌、新客开发、系统日志等路由

前端
- 新增登录页与用户 / 权限组 / 系统日志管理页
- 新增客户详情、客户公海、询盘详情、报价编辑器、合同清洗中心
- 新增 AI 配置、组织品牌、包装参数、SKU 选择器等组件
- 新增英文大写金额、未保存改动拦截、抽屉与骨架屏
- 新增 stage1 样式层;产品、供应商、订单等页面改版
- EmbeddedSku 页面并入现有产品体系后删除

开源准备
- 新增 AGPL-3.0 LICENSE
- README:移除未启用的 SQLCipher 加密声明(改为如实的「已知限制」)、
  补充环境变量说明、许可证由「私有项目」改为 AGPL-3.0
- 移除历史遗留的空文件 _index_backup.txt
- 文档中的项目路径更新为 happy-export
2026-09-17 14:53:02 +08:00

219 lines
7.7 KiB
JavaScript
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.
/**
* happy 出口通 —— 汇率取数
*
* 目标:给顶栏的汇率计算器提供**任意两种货币之间**的换算汇率。
*
* 设计要点:
* 1. 所有汇率统一以 USD 为基准存取(rates[X] = 1 USD 值多少 X),
* 任意币种对 (from → to) 用 rates[to] / rates[from] 即可算出交叉汇率。
* 2. 两个公开数据源按顺序尝试,任一成功即用:
* - open.er-api.com 免费无需 key,约 160+ 币种,带更新日期
* - frankfurter.app 欧洲央行数据,约 30 币种,权威但覆盖面小
* 3. 结果写内存 + 落盘(backend/data/fx-cache.json,该目录已 gitignore)。
* 重启后仍能立刻给出上次的汇率,不必等网络。
* 4. 网络彻底不可用时回退内置离线汇率表,并在响应里标注 source='offline',
* 让前端能明确提示「这是离线参考值」。宁可给一个标清楚来源的估算,
* 也不要让计算器变成一块空白。
*/
const fs = require('fs');
const path = require('path');
const DATA_DIR = path.join(__dirname, '..', 'data');
const CACHE_FILE = path.join(DATA_DIR, 'fx-cache.json');
/** 内存缓存的保鲜期:1 小时后下次访问会去拉新数据。 */
const TTL_MS = 60 * 60 * 1000;
/** 单个数据源的超时。多个源是并行竞速的,所以这基本等于整体最坏耗时。 */
const TIMEOUT_MS = 5000;
const USER_AGENT = 'happy-export-fx';
/* ------------------------------ 数据源 ------------------------------ */
/**
* 每个数据源负责把自家返回体归一成 { rates, updatedAt }。
* rates 必须以 USD 为基准,键为大写三位币种码。
*/
const PROVIDERS = [
{
id: 'open.er-api.com',
label: 'ExchangeRate-API',
url: 'https://open.er-api.com/v6/latest/USD',
pick(json) {
if (!json || json.result === 'error' || !json.rates) return null;
const unix = Number(json.time_last_update_unix);
return {
rates: json.rates,
// 这个源会给到精确到秒的更新时间;没有就退回 next_update 或当前时间
updatedAt: Number.isFinite(unix) && unix > 0
? new Date(unix * 1000).toISOString()
: new Date().toISOString()
};
}
},
{
id: 'frankfurter.app',
label: 'Frankfurter(欧洲央行)',
url: 'https://api.frankfurter.app/latest?from=USD',
pick(json) {
if (!json || !json.rates) return null;
return {
// 这个源不返回基准货币自身,补上 USD: 1
rates: { USD: 1, ...json.rates },
updatedAt: json.date ? new Date(`${json.date}T00:00:00Z`).toISOString() : new Date().toISOString()
};
}
}
];
/**
* 离线兜底汇率(1 USD 值多少目标币)。
* 仅在两个数据源都拿不到、且本地也没有任何历史缓存时启用。
* 数值是写这份代码时的市场中间价量级,只适合粗估,响应里会明确标注。
*/
const OFFLINE_RATES = {
USD: 1,
CNY: 7.10, HKD: 7.80, TWD: 32.2, JPY: 152, KRW: 1360, SGD: 1.34,
EUR: 0.92, GBP: 0.78, CHF: 0.88, SEK: 10.5, NOK: 10.7, DKK: 6.88,
PLN: 3.95, CZK: 23.0, TRY: 34.0, RUB: 92.0,
AUD: 1.51, NZD: 1.64, CAD: 1.37, MXN: 18.5, BRL: 5.55, ARS: 950, CLP: 930,
INR: 83.5, PKR: 278, BDT: 118, LKR: 300, NPR: 134, VND: 24800,
THB: 34.5, MYR: 4.45, IDR: 15600, PHP: 56.5, KHR: 4100, MMK: 2100,
AED: 3.6725, SAR: 3.75, QAR: 3.64, KWD: 0.307, OMR: 0.385, ILS: 3.72,
EGP: 48.5, ZAR: 18.2, NGN: 1600, KES: 129, MAD: 9.85, TZS: 2700,
UAH: 41.0, RON: 4.58, HUF: 360, BGN: 1.80, ISK: 138, GEL: 2.72
};
/* ------------------------------ 工具 ------------------------------ */
/** 只保留「键是 3 位字母、值是正数」的条目,顺手统一成大写。 */
function sanitizeRates(raw) {
const out = {};
if (!raw || typeof raw !== 'object') return out;
for (const [key, value] of Object.entries(raw)) {
const code = String(key).trim().toUpperCase();
const num = Number(value);
if (/^[A-Z]{3}$/.test(code) && Number.isFinite(num) && num > 0) out[code] = num;
}
out.USD = 1;
return out;
}
function readDiskCache() {
try {
const parsed = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
const rates = sanitizeRates(parsed && parsed.rates);
if (Object.keys(rates).length > 1) {
return {
rates,
updatedAt: parsed.updatedAt || null,
fetchedAt: Number(parsed.fetchedAt) || 0,
source: parsed.source || 'cache'
};
}
} catch {
/* 没有缓存或文件损坏都走正常取数流程 */
}
return null;
}
function writeDiskCache(payload) {
try {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(CACHE_FILE, JSON.stringify(payload, null, 2), 'utf8');
} catch {
/* 写不进去不影响本次换算,只是下次重启享受不到缓存 */
}
}
/** 带超时的 GET,返回解析后的 JSON 对象或 null。 */
async function fetchJson(url) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(url, {
headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
signal: controller.signal
});
if (!response.ok) return null;
return await response.json();
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
/* ------------------------------ 主流程 ------------------------------ */
let memory = readDiskCache();
/** 同一个进程里并发调用只发一次网络请求。 */
let inflight = null;
/**
* 刷新汇率表。
*
* 所有数据源**并行竞速**,第一个拿到完整数据的胜出。
* 早先这里是串行 for 循环,一次网络抖动要等 8s × 2 = 16s 才回退,
* 用户看到的是「刷新失败 + 3 天前的旧数据」——其实另一个源可能早就成功了。
* 并行之后最坏情况只等一个超时周期。
*/
async function refresh() {
const attempts = PROVIDERS.map(async provider => {
const json = await fetchJson(provider.url);
const picked = json && provider.pick(json);
if (!picked) throw new Error(`${provider.id} 无数据`);
const rates = sanitizeRates(picked.rates);
if (Object.keys(rates).length < 10) throw new Error(`${provider.id} 数据不完整`);
return { provider, rates, updatedAt: picked.updatedAt };
});
let winner;
try {
winner = await Promise.any(attempts);
} catch {
return null; // 所有源都失败,交给 getRates 走缓存 / 离线兜底
}
const payload = {
rates: winner.rates,
updatedAt: winner.updatedAt,
fetchedAt: Date.now(),
source: winner.provider.id,
sourceLabel: winner.provider.label
};
memory = payload;
writeDiskCache(payload);
return payload;
}
/**
* 取汇率表。永远不抛异常。
* @param {boolean} force 为 true 时忽略缓存有效期,强制刷新
*/
async function getRates(force = false) {
const fresh = memory && Date.now() - memory.fetchedAt < TTL_MS;
if (fresh && !force) return { ...memory, stale: false, cached: true };
if (!inflight) {
inflight = refresh().finally(() => { inflight = null; });
}
const refreshed = await inflight;
if (refreshed) return { ...refreshed, stale: false, cached: false };
// 网络失败:有旧数据就先用旧数据,但要标出来
if (memory) return { ...memory, stale: true, cached: true, warning: '汇率刷新失败,显示的是上次成功获取的数据' };
return {
rates: OFFLINE_RATES,
updatedAt: null,
fetchedAt: Date.now(),
source: 'offline',
sourceLabel: '内置离线汇率',
stale: true,
cached: false,
warning: '当前无法连接汇率服务,显示的是内置离线参考值'
};
}
module.exports = { getRates, PROVIDERS, OFFLINE_RATES };