Files
Drsakura 13d67c804f 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
2026-09-17 14:53:02 +08:00

465 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 组织与权限组(多用户阶段的权限中枢)。
* 挂载点:/api
*
* 口径(Wayne 2026-09-14 定):
* - 角色只是职位标签,本身不带权限;
* - 权限组 = 一批权限点;把角色指派进某个组,该角色下所有用户即获得这组权限。
* 所以「角色 → 组」是唯一的授权入口,permission_group_permissions 是唯一的权限范围来源。
*/
const express = require('express');
const db = require('../db/db');
const auth = require('../lib/auth');
const branding = require('../lib/branding');
const { PERMISSIONS, PERMISSION_KEYS, ROLES } = require('../lib/permissions');
const router = express.Router();
const ROLE_VALUES = ROLES.map(item => item.value);
const KEY_SET = new Set(PERMISSION_KEYS);
function badRequest(message) {
const error = new Error(message);
error.statusCode = 400;
return error;
}
function fail(res, error) {
const message = error?.message || '请求处理失败';
const status = error?.statusCode
|| (/必须|不能为空|不存在|无效|格式|已存在|不可|请先/.test(message) ? 400 : 500);
res.status(status).json({ error: message });
}
/** 校验并归一化权限点数组 */
function normalizePermissions(input) {
if (input === undefined) return undefined;
if (!Array.isArray(input)) throw badRequest('权限范围格式不正确');
const list = [...new Set(input.map(key => String(key).trim()).filter(Boolean))];
const unknown = list.filter(key => !KEY_SET.has(key));
if (unknown.length) throw badRequest(`未知的权限点:${unknown.join('、')}`);
return list;
}
/** 校验并归一化角色数组 */
function normalizeRoles(input) {
if (input === undefined) return undefined;
if (!Array.isArray(input)) throw badRequest('角色指派格式不正确');
const list = [...new Set(input.map(role => String(role).trim()).filter(Boolean))];
const unknown = list.filter(role => !ROLE_VALUES.includes(role));
if (unknown.length) throw badRequest(`未知的角色:${unknown.join('、')}`);
return list;
}
function groupDetail(row) {
const permissions = db.prepare('SELECT permission_key FROM permission_group_permissions WHERE group_id = ?')
.all(row.id).map(item => item.permission_key);
const roles = db.prepare('SELECT role FROM role_permission_groups WHERE group_id = ?')
.all(row.id).map(item => item.role);
return {
id: row.id,
name: row.name,
description: row.description || '',
is_system: !!row.is_system,
permissions,
roles,
created_at: row.created_at,
updated_at: row.updated_at
};
}
/** 写入某个组的权限范围(整组替换) */
function replacePermissions(groupId, permissions) {
db.prepare('DELETE FROM permission_group_permissions WHERE group_id = ?').run(groupId);
const insert = db.prepare('INSERT OR IGNORE INTO permission_group_permissions (group_id, permission_key) VALUES (?, ?)');
for (const key of permissions) insert.run(groupId, key);
}
/**
* 写入某个组的角色指派。
* 先把原本指向本组、但不在新名单里的角色解绑(role 表主键唯一,只能改指处),
* 再把新名单里的角色指到本组。
*/
function replaceRoles(groupId, roles, nowIso) {
const current = db.prepare('SELECT role FROM role_permission_groups WHERE group_id = ?').all(groupId).map(row => row.role);
const keep = new Set(roles);
for (const role of current) {
if (!keep.has(role)) db.prepare('DELETE FROM role_permission_groups WHERE role = ?').run(role);
}
const assign = db.prepare('INSERT OR REPLACE INTO role_permission_groups (role, group_id, updated_at) VALUES (?, ?, ?)');
for (const role of roles) assign.run(role, groupId, nowIso);
}
/* ------------------------------------------------------------ 权限清单 */
/** GET /api/permissions —— 权限点清单 + 可选角色(权限组管理页的左栏) */
router.get('/permissions', auth.requireAuth, (req, res) => {
try {
const groups = [];
for (const item of PERMISSIONS) {
let bucket = groups.find(group => group.title === item.group);
if (!bucket) { bucket = { title: item.group, items: [] }; groups.push(bucket); }
bucket.items.push({ key: item.key, label: item.label });
}
res.json({ permissions: PERMISSIONS, groups, roles: ROLES });
} catch (error) {
fail(res, error);
}
});
/** GET /api/roles —— 角色与各自所在的权限组(指派一览) */
router.get('/roles', auth.requireAuth, (req, res) => {
try {
const rows = db.prepare(`
SELECT r.role, r.group_id, g.name AS group_name
FROM role_permission_groups r JOIN permission_groups g ON g.id = r.group_id
`).all();
const map = new Map(rows.map(row => [row.role, row]));
res.json({
roles: ROLES.map(item => ({
...item,
group_id: map.get(item.value)?.group_id || null,
group_name: map.get(item.value)?.group_name || null
}))
});
} catch (error) {
fail(res, error);
}
});
/* ------------------------------------------------------------ 权限组 */
router.get('/permission-groups', auth.requirePermission('perm.manage'), (req, res) => {
try {
const rows = db.prepare('SELECT * FROM permission_groups ORDER BY is_system DESC, id ASC').all();
res.json({ groups: rows.map(groupDetail) });
} catch (error) {
fail(res, error);
}
});
router.post('/permission-groups', auth.requirePermission('perm.manage'), (req, res) => {
try {
const name = String(req.body?.name || '').trim();
if (!name) throw badRequest('权限组名称不能为空');
if (db.prepare('SELECT id FROM permission_groups WHERE name = ?').get(name)) throw badRequest('该权限组名称已存在');
const permissions = normalizePermissions(req.body?.permissions) || [];
const roles = normalizeRoles(req.body?.roles) || [];
const now = new Date().toISOString();
const result = db.prepare('INSERT INTO permission_groups (name, description, is_system, created_at) VALUES (?, ?, 0, ?)')
.run(name, String(req.body?.description || '').trim() || null, now);
replacePermissions(result.lastInsertRowid, permissions);
replaceRoles(result.lastInsertRowid, roles, now);
auth.audit({ username: req.user.username, action: 'permission_group_create', detail: `新建权限组「${name}」`, req });
const row = db.prepare('SELECT * FROM permission_groups WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ group: groupDetail(row), message: '权限组已创建' });
} catch (error) {
fail(res, error);
}
});
router.patch('/permission-groups/:id', auth.requirePermission('perm.manage'), (req, res) => {
try {
const id = Number(req.params.id);
const row = db.prepare('SELECT * FROM permission_groups WHERE id = ?').get(id);
if (!row) throw badRequest('权限组不存在');
const now = new Date().toISOString();
const fields = [];
const values = [];
if (req.body?.name !== undefined) {
const name = String(req.body.name || '').trim();
if (!name) throw badRequest('权限组名称不能为空');
const clash = db.prepare('SELECT id FROM permission_groups WHERE name = ? AND id != ?').get(name, id);
if (clash) throw badRequest('该权限组名称已存在');
fields.push('name = ?'); values.push(name);
}
if (req.body?.description !== undefined) {
fields.push('description = ?'); values.push(String(req.body.description || '').trim() || null);
}
if (fields.length) {
fields.push('updated_at = ?'); values.push(now); values.push(id);
db.prepare(`UPDATE permission_groups SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
const permissions = normalizePermissions(req.body?.permissions);
if (permissions) replacePermissions(id, permissions);
const roles = normalizeRoles(req.body?.roles);
if (roles) replaceRoles(id, roles, now);
auth.audit({ username: req.user.username, action: 'permission_group_update', detail: `更新权限组「${row.name}」`, req });
const updated = db.prepare('SELECT * FROM permission_groups WHERE id = ?').get(id);
res.json({ group: groupDetail(updated), message: '权限组已保存' });
} catch (error) {
fail(res, error);
}
});
router.delete('/permission-groups/:id', auth.requirePermission('perm.manage'), (req, res) => {
try {
const id = Number(req.params.id);
const row = db.prepare('SELECT * FROM permission_groups WHERE id = ?').get(id);
if (!row) throw badRequest('权限组不存在');
if (row.is_system) throw badRequest('系统内置权限组不可删除');
const occupied = db.prepare('SELECT COUNT(*) AS c FROM role_permission_groups WHERE group_id = ?').get(id).c;
if (occupied > 0) throw badRequest('该权限组下仍指派有角色,请先把角色改派到其它组');
db.prepare('DELETE FROM permission_group_permissions WHERE group_id = ?').run(id);
db.prepare('DELETE FROM permission_groups WHERE id = ?').run(id);
auth.audit({ username: req.user.username, action: 'permission_group_delete', detail: `删除权限组「${row.name}」`, req });
res.json({ ok: true, message: '权限组已删除' });
} catch (error) {
fail(res, error);
}
});
/* ------------------------------------------------------------ 组织 */
router.get('/organizations', auth.requireAuth, (req, res) => {
try {
const rows = db.prepare(`
SELECT o.*, (SELECT COUNT(*) FROM users u WHERE u.org_id = o.id) AS member_count
FROM organizations o ORDER BY o.id ASC
`).all();
res.json({ organizations: rows });
} catch (error) {
fail(res, error);
}
});
router.post('/organizations', auth.requirePermission('org.manage'), (req, res) => {
try {
const name = String(req.body?.name || '').trim();
if (!name) throw badRequest('组织名称不能为空');
if (db.prepare('SELECT id FROM organizations WHERE name = ?').get(name)) throw badRequest('该组织名称已存在');
const now = new Date().toISOString();
const result = db.prepare('INSERT INTO organizations (name, code, description, created_at) VALUES (?, ?, ?, ?)')
.run(name, String(req.body?.code || '').trim() || null, String(req.body?.description || '').trim() || null, now);
auth.audit({ username: req.user.username, action: 'organization_create', detail: `新建组织「${name}」`, req });
res.status(201).json({ organization: db.prepare('SELECT * FROM organizations WHERE id = ?').get(result.lastInsertRowid) });
} catch (error) {
fail(res, error);
}
});
/* ------------------------------------------------------------ 组织改名与系统标识 */
/**
* PATCH /api/organizations/:id —— 改组织名/代码/描述(branding.manage)
* 组织名 = 部署方自己的公司名,可自由改;它只作业务字段用(资源边界 + 下拉显示),
* **不会**影响界面上的系统名(系统名固定,见 lib/branding.js)。
*
* ⚠️ 权限点从 `org.manage` 换成 `branding.manage`(Wayne 2026-09-15):
* 这块**只允许高管改**,所以拆成独立权限点,只勾给「公司高管」组 + 超级管理员组,
* 销售经理/业务员永远拿不到。
*/
router.patch('/organizations/:id', auth.requirePermission('branding.manage'), (req, res) => {
try {
const before = db.prepare('SELECT id, name FROM organizations WHERE id = ?').get(Number(req.params.id));
if (!before) throw badRequest('组织不存在');
const organization = branding.updateOrg(req.params.id, req.body || {});
auth.audit({
username: req.user.username,
action: 'organization_update',
detail: `组织「${before.name}」→「${organization.name}」`,
req
});
res.json({ organization });
} catch (error) {
fail(res, error);
}
});
/** GET /api/branding —— 当前系统标识 + 公司名(登录即可读,设置页要用) */
router.get('/branding', auth.requireAuth, (req, res) => {
try {
res.json(branding.getBranding());
} catch (error) {
fail(res, error);
}
});
/**
* PUT /api/branding —— 已停用:系统名是作者固定署名,任何角色都改不了。
* 保留这条路由是为了给调用方一个明确的 403 说明,而不是含糊的 404。
* 想换公司门面的,请改组织名(PATCH /api/organizations/:id)。
*/
router.put('/branding', auth.requirePermission('branding.manage'), (req, res) => {
res.status(403).json({
error: `「${branding.SYSTEM.name}」是作者固定署名,不可修改;如需换成贵公司的名字,请修改组织名称。`
});
});
/* ------------------------------------------------------------ 用户 */
/** 用户行 → 输出对象(绝不带 password_hash),附带角色/权限组/组织的可读名 */
function userDetail(row) {
const roleMeta = ROLES.find(item => item.value === row.role);
const group = db.prepare(`
SELECT g.id, g.name FROM role_permission_groups r
JOIN permission_groups g ON g.id = r.group_id WHERE r.role = ?
`).get(row.role || '') || 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 || '',
phone: row.phone || '',
role: row.role || '',
role_label: roleMeta?.label || row.role || '—',
position: row.position || '',
org_id: row.org_id || null,
org_name: org?.name || '',
permission_group_name: group?.name || '',
is_active: !!row.is_active,
created_at: row.created_at,
last_login_at: row.last_login_at
};
}
function normalizeRole(value) {
const role = String(value || '').trim();
if (!ROLE_VALUES.includes(role)) throw badRequest(`角色不正确(可选:${ROLE_VALUES.join('、')})`);
return role;
}
function normalizeOrgId(value) {
if (value === null || value === undefined || value === '') return null;
const id = Number(value);
const org = db.prepare('SELECT id FROM organizations WHERE id = ?').get(id);
if (!org) throw badRequest('所属组织不存在');
return id;
}
/** GET /api/users —— 用户列表(user.manage) */
router.get('/users', auth.requirePermission('user.manage'), (req, res) => {
try {
const rows = db.prepare('SELECT * FROM users ORDER BY id ASC').all();
res.json({ users: rows.map(userDetail) });
} catch (error) {
fail(res, error);
}
});
/** POST /api/users —— 新建用户(user.manage) */
router.post('/users', auth.requirePermission('user.manage'), (req, res) => {
try {
const username = String(req.body?.username || '').trim();
if (!username) throw badRequest('登录账号不能为空');
if (!/^[a-zA-Z0-9_.-]{2,32}$/.test(username)) {
throw badRequest('账号只能是 2–32 位字母 / 数字 / 下划线 / 点 / 横线');
}
if (db.prepare('SELECT id FROM users WHERE username = ?').get(username)) throw badRequest('该账号已存在');
const password = String(req.body?.password || '');
if (password.length < 6) throw badRequest('密码至少 6 位');
const role = normalizeRole(req.body?.role);
const orgId = normalizeOrgId(req.body?.org_id);
const now = new Date().toISOString();
const result = db.prepare(`
INSERT INTO users (username, password_hash, display_name, email, phone, role, position, org_id, is_active, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
`).run(
username,
auth.hashPassword(password),
String(req.body?.display_name || '').trim() || null,
String(req.body?.email || '').trim() || null,
String(req.body?.phone || '').trim() || null,
role,
String(req.body?.position || '').trim() || null,
orgId,
now
);
auth.audit({ username: req.user.username, action: 'user_create', detail: `新建用户「${username}」(${role})`, req });
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ user: userDetail(row), message: '用户已创建' });
} catch (error) {
fail(res, error);
}
});
/** PATCH /api/users/:id —— 编辑用户 / 重置密码 / 停用(user.manage) */
router.patch('/users/:id', auth.requirePermission('user.manage'), (req, res) => {
try {
const id = Number(req.params.id);
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
if (!row) throw badRequest('用户不存在');
const body = req.body || {};
const isSelf = row.id === req.user.id;
/* 自己的账号:不许改角色(防止把自己锁在门外)、不许停用 */
if (isSelf && body.role !== undefined && String(body.role) !== row.role) {
throw badRequest('不能修改自己的角色');
}
if (isSelf && body.is_active !== undefined && !body.is_active) {
throw badRequest('不能停用自己的账号');
}
const fields = [];
const values = [];
if (body.display_name !== undefined) {
fields.push('display_name = ?'); values.push(String(body.display_name || '').trim() || null);
}
if (body.email !== undefined) {
fields.push('email = ?'); values.push(String(body.email || '').trim() || null);
}
if (body.phone !== undefined) {
fields.push('phone = ?'); values.push(String(body.phone || '').trim() || null);
}
if (body.position !== undefined) {
fields.push('position = ?'); values.push(String(body.position || '').trim() || null);
}
if (body.org_id !== undefined) {
fields.push('org_id = ?'); values.push(normalizeOrgId(body.org_id));
}
if (body.role !== undefined) {
const role = normalizeRole(body.role);
if (row.role === 'admin' && role !== 'admin') {
/* 防止把最后一个系统管理员改成普通角色 */
const adminCount = db.prepare("SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND is_active = 1").get().c;
if (adminCount <= 1) throw badRequest('系统至少要保留一名可用的系统管理员');
}
fields.push('role = ?'); values.push(role);
}
if (body.is_active !== undefined) {
fields.push('is_active = ?'); values.push(body.is_active ? 1 : 0);
}
if (body.password !== undefined) {
const password = String(body.password || '');
if (password.length < 6) throw badRequest('密码至少 6 位');
fields.push('password_hash = ?'); values.push(auth.hashPassword(password));
fields.push('failed_count = ?'); values.push(0);
fields.push('locked_until = ?'); values.push(null);
}
if (!fields.length) throw badRequest('没有要更新的内容');
const now = new Date().toISOString();
fields.push('updated_at = ?'); values.push(now); values.push(id);
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
/* 改密/停用后踢掉该用户除当前会话外的所有会话(当前管理员自己的会话只在做自改密码时保留) */
if (body.password !== undefined || body.is_active === false) {
const keep = isSelf ? req.user.sid : null;
db.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?').run(id, keep || '');
}
const summary = [
body.password !== undefined ? '重置密码' : null,
body.is_active !== undefined ? (body.is_active ? '启用' : '停用') : null,
body.role !== undefined && String(body.role) !== row.role ? `角色改为 ${body.role}` : null
].filter(Boolean).join('、') || '更新资料';
auth.audit({ username: req.user.username, action: 'user_update', detail: `${summary}:「${row.username}」`, req });
const updated = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
res.json({ user: userDetail(updated), message: '用户已保存' });
} catch (error) {
fail(res, error);
}
});
module.exports = router;