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:
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user