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
This commit is contained in:
+32
-16
@@ -23,8 +23,8 @@ const CACHE_FILE = path.join(DATA_DIR, 'fx-cache.json');
|
||||
|
||||
/** 内存缓存的保鲜期:1 小时后下次访问会去拉新数据。 */
|
||||
const TTL_MS = 60 * 60 * 1000;
|
||||
/** 单个数据源的超时。 */
|
||||
const TIMEOUT_MS = 8000;
|
||||
/** 单个数据源的超时。多个源是并行竞速的,所以这基本等于整体最坏耗时。 */
|
||||
const TIMEOUT_MS = 5000;
|
||||
const USER_AGENT = 'happy-export-fx';
|
||||
|
||||
/* ------------------------------ 数据源 ------------------------------ */
|
||||
@@ -149,25 +149,41 @@ let memory = readDiskCache();
|
||||
/** 同一个进程里并发调用只发一次网络请求。 */
|
||||
let inflight = null;
|
||||
|
||||
/**
|
||||
* 刷新汇率表。
|
||||
*
|
||||
* 所有数据源**并行竞速**,第一个拿到完整数据的胜出。
|
||||
* 早先这里是串行 for 循环,一次网络抖动要等 8s × 2 = 16s 才回退,
|
||||
* 用户看到的是「刷新失败 + 3 天前的旧数据」——其实另一个源可能早就成功了。
|
||||
* 并行之后最坏情况只等一个超时周期。
|
||||
*/
|
||||
async function refresh() {
|
||||
for (const provider of PROVIDERS) {
|
||||
const attempts = PROVIDERS.map(async provider => {
|
||||
const json = await fetchJson(provider.url);
|
||||
const picked = json && provider.pick(json);
|
||||
if (!picked) continue;
|
||||
if (!picked) throw new Error(`${provider.id} 无数据`);
|
||||
const rates = sanitizeRates(picked.rates);
|
||||
if (Object.keys(rates).length < 10) continue; // 明显不完整,换下一个源
|
||||
const payload = {
|
||||
rates,
|
||||
updatedAt: picked.updatedAt,
|
||||
fetchedAt: Date.now(),
|
||||
source: provider.id,
|
||||
sourceLabel: provider.label
|
||||
};
|
||||
memory = payload;
|
||||
writeDiskCache(payload);
|
||||
return payload;
|
||||
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 走缓存 / 离线兜底
|
||||
}
|
||||
return null;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user