本地长期开发成果的整体入库快照,包含后端鉴权/权限、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
228 lines
7.3 KiB
JavaScript
228 lines
7.3 KiB
JavaScript
/**
|
||
* 鉴权与会话(多用户阶段)。
|
||
*
|
||
* 设计取舍:
|
||
* - 密码哈希沿用 db.js 建默认管理员时的格式 `scrypt$salt$hash`,不引第三方依赖
|
||
* (本机没有 bcrypt,也不想去碰原生模块编译),Node 内置 crypto.scryptSync 足够。
|
||
* - 会话复用已有的 sessions 表,浏览器侧只放一个 HttpOnly cookie(happy_sid),
|
||
* 不把用户信息塞进 token,避免改资料后前端还拿着旧值。
|
||
* - 权限一律「角色 → 权限组 → 权限点」三级推导,用户表不直接存权限。
|
||
*/
|
||
const crypto = require('crypto');
|
||
const db = require('../db/db');
|
||
const { PERMISSION_KEYS } = require('./permissions');
|
||
|
||
const COOKIE_NAME = 'happy_sid';
|
||
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||
const MAX_FAILED = 5;
|
||
const LOCK_MS = 15 * 60 * 1000;
|
||
|
||
/* ---------------------------------------------------------------- 密码 */
|
||
|
||
function hashPassword(plain) {
|
||
const salt = crypto.randomBytes(16).toString('hex');
|
||
const hash = crypto.scryptSync(String(plain), salt, 64).toString('hex');
|
||
return `scrypt$${salt}$${hash}`;
|
||
}
|
||
|
||
function verifyPassword(plain, stored) {
|
||
const parts = String(stored || '').split('$');
|
||
if (parts.length !== 3 || parts[0] !== 'scrypt') return false;
|
||
const [, salt, expected] = parts;
|
||
let actual;
|
||
try {
|
||
actual = crypto.scryptSync(String(plain), salt, 64).toString('hex');
|
||
} catch {
|
||
return false;
|
||
}
|
||
const a = Buffer.from(actual, 'hex');
|
||
const b = Buffer.from(expected, 'hex');
|
||
if (a.length !== b.length) return false;
|
||
return crypto.timingSafeEqual(a, b);
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- cookie */
|
||
|
||
function parseCookies(header) {
|
||
const out = {};
|
||
if (!header) return out;
|
||
for (const piece of String(header).split(';')) {
|
||
const index = piece.indexOf('=');
|
||
if (index < 0) continue;
|
||
const key = piece.slice(0, index).trim();
|
||
if (!key) continue;
|
||
try {
|
||
out[key] = decodeURIComponent(piece.slice(index + 1).trim());
|
||
} catch {
|
||
out[key] = piece.slice(index + 1).trim();
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function setSessionCookie(res, id, maxAgeMs = SESSION_TTL_MS) {
|
||
res.append('Set-Cookie', [
|
||
`${COOKIE_NAME}=${id}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${Math.floor(maxAgeMs / 1000)}`
|
||
].join('; '));
|
||
}
|
||
|
||
function clearSessionCookie(res) {
|
||
res.append('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
|
||
}
|
||
|
||
function clientIp(req) {
|
||
const raw = req?.headers?.['x-forwarded-for'] || req?.socket?.remoteAddress || '';
|
||
return String(raw).split(',')[0].trim().replace(/^::ffff:/, '') || null;
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- 会话 */
|
||
|
||
function createSession(userId, req) {
|
||
const now = new Date();
|
||
const id = crypto.randomBytes(32).toString('hex');
|
||
db.prepare(`INSERT INTO sessions (id, user_id, created_at, last_seen_at, expires_at, user_agent, ip)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
|
||
id, userId, now.toISOString(), now.toISOString(),
|
||
new Date(now.getTime() + SESSION_TTL_MS).toISOString(),
|
||
String(req?.headers?.['user-agent'] || '').slice(0, 200), clientIp(req)
|
||
);
|
||
/* 顺手清掉这把用户的过期会话,避免 sessions 表无限增长 */
|
||
db.prepare('DELETE FROM sessions WHERE user_id = ? AND expires_at < ?').run(userId, now.toISOString());
|
||
return id;
|
||
}
|
||
|
||
function destroySession(sessionId) {
|
||
if (sessionId) db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId);
|
||
}
|
||
|
||
/** 从请求里解出当前用户(含 session id),未登录返回 null */
|
||
function sessionUser(req) {
|
||
const sessionId = parseCookies(req?.headers?.cookie)[COOKIE_NAME];
|
||
if (!sessionId) return null;
|
||
const row = db.prepare(`
|
||
SELECT u.*, s.id AS sid, s.expires_at AS session_expires_at
|
||
FROM sessions s JOIN users u ON u.id = s.user_id
|
||
WHERE s.id = ?
|
||
`).get(sessionId);
|
||
if (!row) return null;
|
||
if (new Date(row.session_expires_at).getTime() < Date.now()) {
|
||
destroySession(sessionId);
|
||
return null;
|
||
}
|
||
if (!row.is_active) return null;
|
||
return row;
|
||
}
|
||
|
||
function touchSession(sessionId) {
|
||
/* 每小时落一次 last_seen_at 就够,避免每个请求都写库 */
|
||
try {
|
||
db.prepare('UPDATE sessions SET last_seen_at = ? WHERE id = ? AND last_seen_at < ?')
|
||
.run(new Date().toISOString(), sessionId, new Date(Date.now() - 3600 * 1000).toISOString());
|
||
} catch { /* 非关键路径 */ }
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- 权限 */
|
||
|
||
function permissionsOfRole(role) {
|
||
if (!role) return [];
|
||
if (role === 'admin') return PERMISSION_KEYS.slice();
|
||
return db.prepare(`
|
||
SELECT p.permission_key AS key
|
||
FROM role_permission_groups r
|
||
JOIN permission_group_permissions p ON p.group_id = r.group_id
|
||
WHERE r.role = ?
|
||
`).all(role).map(row => row.key);
|
||
}
|
||
|
||
function permissionGroupOfRole(role) {
|
||
if (!role) return null;
|
||
return db.prepare(`
|
||
SELECT g.id, g.name, g.description
|
||
FROM role_permission_groups r JOIN permission_groups g ON g.id = r.group_id
|
||
WHERE r.role = ?
|
||
`).get(role) || null;
|
||
}
|
||
|
||
/** 输出给前端的用户对象:绝不带 password_hash */
|
||
function publicUser(row) {
|
||
if (!row) return null;
|
||
const org = row.org_id
|
||
? db.prepare('SELECT id, name, code FROM organizations WHERE id = ?').get(row.org_id)
|
||
: null;
|
||
return {
|
||
id: row.id,
|
||
username: row.username,
|
||
display_name: row.display_name || row.username,
|
||
email: row.email || '',
|
||
role: row.role || '',
|
||
position: row.position || '',
|
||
phone: row.phone || '',
|
||
address: row.address || '',
|
||
avatar: row.avatar || '',
|
||
is_active: !!row.is_active,
|
||
created_at: row.created_at,
|
||
last_login_at: row.last_login_at,
|
||
org: org || null,
|
||
permission_group: permissionGroupOfRole(row.role),
|
||
permissions: permissionsOfRole(row.role)
|
||
};
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- 中间件 */
|
||
|
||
function attachUser(req, res, next) {
|
||
const user = sessionUser(req);
|
||
req.user = user;
|
||
if (user) touchSession(user.sid);
|
||
next();
|
||
}
|
||
|
||
function requireAuth(req, res, next) {
|
||
if (!req.user) return res.status(401).json({ error: '请先登录' });
|
||
next();
|
||
}
|
||
|
||
function requirePermission(key) {
|
||
return (req, res, next) => {
|
||
if (!req.user) return res.status(401).json({ error: '请先登录' });
|
||
const granted = req.user.role === 'admin' || permissionsOfRole(req.user.role).includes(key);
|
||
if (!granted) return res.status(403).json({ error: '当前账号没有该操作权限' });
|
||
next();
|
||
};
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- 审计 */
|
||
|
||
function audit({ username, action, detail, req, ok = 1 }) {
|
||
try {
|
||
db.prepare(`INSERT INTO audit_log (at, username, action, detail, ip, user_agent, ok)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
|
||
new Date().toISOString(), username || null, action, detail || null,
|
||
clientIp(req), String(req?.headers?.['user-agent'] || '').slice(0, 200), ok ? 1 : 0
|
||
);
|
||
} catch { /* 审计是旁路,失败不能影响业务 */ }
|
||
}
|
||
|
||
module.exports = {
|
||
COOKIE_NAME,
|
||
SESSION_TTL_MS,
|
||
MAX_FAILED,
|
||
LOCK_MS,
|
||
hashPassword,
|
||
verifyPassword,
|
||
parseCookies,
|
||
setSessionCookie,
|
||
clearSessionCookie,
|
||
clientIp,
|
||
createSession,
|
||
destroySession,
|
||
sessionUser,
|
||
permissionsOfRole,
|
||
permissionGroupOfRole,
|
||
publicUser,
|
||
attachUser,
|
||
requireAuth,
|
||
requirePermission,
|
||
audit
|
||
};
|