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
+165
View File
@@ -0,0 +1,165 @@
/**
* 合同原件的磁盘留存 + 到期清理。
*
* 为什么放磁盘而不是继续用 `contract_files` 的 BLOB:
* 留原件的唯一目的是「复核那几天能回去核对一眼」,7 天后就要删掉省 VPS 空间。
* 而 SQLite 删掉 BLOB 后空间并不会还给系统(要 VACUUM 整库重写才还),
* 与「省空间」的目标正好相反。磁盘文件删掉就是删掉。
*
* 目录结构:<DATA_DIR>/contracts/<job_no>/<清洗过的原文件名>
* —— 用 job_no 分目录,清理时按目录整删即可,不需要额外记路径(也就不需要加字段)。
*
* ⚠️ 安全:目录名与文件名都来自用户,必须防越权路径。
* job_no 由服务端生成(CIMP-数字-数字),文件名统一过 sanitizeFileName,
* 拼好之后再校验 resolved 路径确实落在 contracts 根目录之内。
*/
const fs = require('fs');
const path = require('path');
const RETENTION_DAYS = Number(process.env.CONTRACT_RETENTION_DAYS || 7);
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../data');
const ROOT = path.join(DATA_DIR, 'contracts');
/** 清洗文件名:去掉路径分隔与控制字符,压掉长度,保留中文 */
function sanitizeFileName(name) {
const base = path.basename(String(name || '')).replace(/[\u0000-\u001f\u007f]/g, '').trim();
const safe = base.replace(/[\\/:*?"<>|]/g, '_').replace(/^\.+/, '');
const trimmed = safe.slice(0, 120) || 'contract';
return trimmed;
}
/** 防越权:任何对外暴露的路径都要过这一关 */
function resolveInside(jobNo, fileName) {
const dir = path.join(ROOT, String(jobNo || '').replace(/[^\w-]/g, ''));
const target = path.join(dir, sanitizeFileName(fileName));
const rootResolved = path.resolve(ROOT);
const targetResolved = path.resolve(target);
if (targetResolved !== rootResolved && !targetResolved.startsWith(rootResolved + path.sep)) return null;
return targetResolved;
}
/**
* 保存一份原件。失败不抛 —— 原件只是辅助材料,解析结果才是主产物,
* 不能因为磁盘满了就把整次导入判失败。
*/
function saveOriginal(jobNo, file) {
try {
if (!jobNo || !file?.buffer?.length) return null;
const dir = path.join(ROOT, String(jobNo).replace(/[^\w-]/g, ''));
fs.mkdirSync(dir, { recursive: true });
// 同名文件加序号后缀,避免后来者覆盖前一个
const wanted = sanitizeFileName(file.originalname);
let target = path.join(dir, wanted);
let seq = 1;
const ext = path.extname(wanted);
const stem = path.basename(wanted, ext);
while (fs.existsSync(target)) {
seq += 1;
target = path.join(dir, `${stem}-${seq}${ext}`);
}
fs.writeFileSync(target, file.buffer);
return { name: path.basename(target), size: file.buffer.length };
} catch {
return null;
}
}
/** 列出某个任务还留着的原件 */
function listOriginals(jobNo) {
try {
const dir = path.join(ROOT, String(jobNo).replace(/[^\w-]/g, ''));
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir).map(name => {
const stat = fs.statSync(path.join(dir, name));
return { name, size: stat.size, stored_at: stat.mtime.toISOString() };
});
} catch {
return [];
}
}
/** 取某一份原件(下载用)。不存在返回 null —— 界面据此显示「已过期清理」。 */
function readOriginal(jobNo, fileName) {
const target = resolveInside(jobNo, fileName);
if (!target) return null;
try {
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) return null;
return { path: target, name: path.basename(target), buffer: fs.readFileSync(target) };
} catch {
return null;
}
}
/**
* 清理过期原件。
*
* 到期判定用**任务创建时间**(库里查得到的权威时间),不用目录 mtime ——
* mtime 会被「同 job 再传一次文件」刷新,导致该清的清不掉。
* 库里查不到对应任务的孤儿目录,按目录 mtime 兜底删。
*/
function purgeExpired(db, retentionDays = RETENTION_DAYS) {
const removed = [];
const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
let dirs = [];
try {
if (!fs.existsSync(ROOT)) return { removed, scanned: 0 };
dirs = fs.readdirSync(ROOT).filter(name => {
try { return fs.statSync(path.join(ROOT, name)).isDirectory(); } catch { return false; }
});
} catch {
return { removed, scanned: 0 };
}
for (const jobNo of dirs) {
const dir = path.join(ROOT, jobNo);
let expired = false;
try {
const job = db.prepare('SELECT created_at FROM contract_import_jobs WHERE job_no = ?').get(jobNo);
const stamp = job?.created_at ? Date.parse(job.created_at) : fs.statSync(dir).mtimeMs;
expired = Number.isFinite(stamp) && stamp < cutoffMs;
} catch {
expired = false;
}
if (!expired) continue;
try {
fs.rmSync(dir, { recursive: true, force: true });
removed.push(jobNo);
} catch {
/* 删不掉(占着句柄等)下一轮再试,不影响服务 */
}
}
return { removed, scanned: dirs.length };
}
/**
* 启动时清一次 + 之后每 6 小时一次。
* 用 unref() 让定时器不阻止进程退出(否则 nodemon 重启会挂着)。
*/
function startPurgeSchedule(db, logger = console) {
const run = () => {
try {
const { removed } = purgeExpired(db);
if (removed.length) logger.log(`[contracts] 已清理 ${removed.length} 个到期的合同原件(保留 ${RETENTION_DAYS} 天)`);
} catch (error) {
logger.error?.('[contracts] 清理合同原件失败:', error.message);
}
};
run();
const timer = setInterval(run, 6 * 60 * 60 * 1000);
timer.unref?.();
return timer;
}
module.exports = {
RETENTION_DAYS,
ROOT,
sanitizeFileName,
saveOriginal,
listOriginals,
readOriginal,
purgeExpired,
startPurgeSchedule
};