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:
Drsakura
2026-09-17 14:53:02 +08:00
parent 436519e0a6
commit 13d67c804f
77 changed files with 18407 additions and 955 deletions
+32 -16
View File
@@ -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;
}
/**