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,464 @@
|
||||
/**
|
||||
* 组织与权限组(多用户阶段的权限中枢)。
|
||||
* 挂载点:/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;
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 智能配置:多模型接入、连通性测试、模型清单、用途分配。
|
||||
* 挂载点:/api/ai
|
||||
*
|
||||
* 权限:
|
||||
* - 接入配置的读与写都要 `ai.manage`(配置里有 base_url 与密钥打码,属系统管理面);
|
||||
* - `POST /api/ai/chat` 只要登录 —— 它是给所有用户用的对话能力,不是管理动作。
|
||||
*
|
||||
* 多模型并行(Wayne 2026-09-15):
|
||||
* 可以同时接多家、每家多个模型,然后按「用途」把不同模型派给不同活 ——
|
||||
* 合同解析用长文本强的,图片识别用多模态的,日常对话用便宜的。
|
||||
*/
|
||||
const express = require('express');
|
||||
const auth = require('../lib/auth');
|
||||
const ai = require('../lib/aiProviders');
|
||||
const { MODES, PRESETS, PURPOSES, PURPOSE_KEYS } = require('../lib/aiPresets');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function fail(res, error) {
|
||||
const message = error?.message || '请求处理失败';
|
||||
const status = error?.statusCode
|
||||
|| (/不能为空|不正确|不存在|无效|格式|只能|超过/.test(message) ? 400 : 500);
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
|
||||
/** GET /api/ai/config —— 一次拿全:预设、兼容模式、用途、已接入的服务商、用途分配 */
|
||||
router.get('/config', auth.requirePermission('ai.manage'), (req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
presets: PRESETS,
|
||||
modes: MODES,
|
||||
purposes: PURPOSES,
|
||||
providers: ai.listProviders().map(ai.publicProvider),
|
||||
assignments: ai.listAssignments(),
|
||||
overview: ai.overview()
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/ai/status —— 轻量状态:小皮这类普通用户界面用(不含任何配置细节) */
|
||||
router.get('/status', auth.requireAuth, (req, res) => {
|
||||
try {
|
||||
const resolved = ai.resolveAssignment('assistant_chat');
|
||||
res.json({
|
||||
configured: Boolean(resolved),
|
||||
assistant: resolved ? { provider: resolved.provider.name, model: resolved.model } : null
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/ai/providers —— 新增一家模型服务 */
|
||||
router.post('/providers', auth.requirePermission('ai.manage'), (req, res) => {
|
||||
try {
|
||||
const provider = ai.upsertProvider(req.body || {});
|
||||
auth.audit({
|
||||
username: req.user.username,
|
||||
action: 'ai_provider_create',
|
||||
detail: `接入模型服务「${provider.name}」(${provider.base_url} · ${provider.mode})`,
|
||||
req
|
||||
});
|
||||
res.status(201).json({ provider: ai.publicProvider(provider) });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
/** PATCH /api/ai/providers/:id —— 改配置(密钥不回传,省略即保持原值) */
|
||||
router.patch('/providers/:id', auth.requirePermission('ai.manage'), (req, res) => {
|
||||
try {
|
||||
const provider = ai.upsertProvider(req.body || {}, req.params.id);
|
||||
auth.audit({
|
||||
username: req.user.username,
|
||||
action: 'ai_provider_update',
|
||||
detail: `更新接入「${provider.name}」:${provider.enabled === false ? '停用' : '启用'} · 模型 ${provider.models.length} 个`,
|
||||
req
|
||||
});
|
||||
res.json({ provider: ai.publicProvider(provider) });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
/** DELETE /api/ai/providers/:id —— 移除接入(顺手清掉指向它的用途分配) */
|
||||
router.delete('/providers/:id', auth.requirePermission('ai.manage'), (req, res) => {
|
||||
try {
|
||||
const result = ai.removeProvider(req.params.id);
|
||||
auth.audit({
|
||||
username: req.user.username,
|
||||
action: 'ai_provider_delete',
|
||||
detail: `移除接入「${result.removed.name}」${result.cleared_assignments ? `(同时清掉 ${result.cleared_assignments} 处用途分配)` : ''}`,
|
||||
req
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/ai/providers/:id/test —— 连通性测试(真发请求) */
|
||||
router.post('/providers/:id/test', auth.requirePermission('ai.manage'), async (req, res) => {
|
||||
try {
|
||||
const provider = ai.getProvider(req.params.id);
|
||||
if (!provider) throw Object.assign(new Error('该模型接入不存在'), { statusCode: 400 });
|
||||
const result = await ai.testProvider(provider);
|
||||
ai.rememberTest(provider.id, {
|
||||
ok: result.ok,
|
||||
at: result.at,
|
||||
latency_ms: result.latency_ms,
|
||||
detail: result.detail
|
||||
});
|
||||
auth.audit({
|
||||
username: req.user.username,
|
||||
action: 'ai_provider_test',
|
||||
detail: `连通测试「${provider.name}」:${result.ok ? '通过' : '失败'} · ${result.detail}`,
|
||||
req,
|
||||
ok: result.ok ? 1 : 0
|
||||
});
|
||||
res.json({ result, provider: ai.publicProvider(ai.getProvider(provider.id)) });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/ai/providers/:id/models —— 拉取可用模型(save=1 时顺便存进该接入) */
|
||||
router.post('/providers/:id/models', auth.requirePermission('ai.manage'), async (req, res) => {
|
||||
try {
|
||||
const provider = ai.getProvider(req.params.id);
|
||||
if (!provider) throw Object.assign(new Error('该模型接入不存在'), { statusCode: 400 });
|
||||
const result = await ai.fetchModels(provider);
|
||||
if (!result.ok && !result.models.length) {
|
||||
return res.status(400).json({ error: result.error || '未能读取模型清单' });
|
||||
}
|
||||
let saved = null;
|
||||
if (String(req.body?.save || '') === '1' && result.models.length) {
|
||||
saved = ai.publicProvider(ai.upsertProvider({ models: result.models }, provider.id));
|
||||
}
|
||||
res.json({ models: result.models, source: result.source, detail: result.detail, provider: saved });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/ai/assignments —— 保存「用途 → 模型」分配。
|
||||
* 传 { assignments: { purpose: { provider_id, model } | null } },整体替换。
|
||||
*/
|
||||
router.put('/assignments', auth.requirePermission('ai.manage'), (req, res) => {
|
||||
try {
|
||||
const input = req.body?.assignments;
|
||||
if (input === undefined || input === null || typeof input !== 'object' || Array.isArray(input)) {
|
||||
throw Object.assign(new Error('分配内容格式不正确'), { statusCode: 400 });
|
||||
}
|
||||
const providers = ai.listProviders();
|
||||
const next = {};
|
||||
for (const [purpose, value] of Object.entries(input)) {
|
||||
if (!PURPOSE_KEYS.includes(purpose)) {
|
||||
throw Object.assign(new Error(`未知的用途:${purpose}`), { statusCode: 400 });
|
||||
}
|
||||
if (value === null || value === undefined || value === '') continue;
|
||||
const provider = providers.find(item => item.id === String(value.provider_id));
|
||||
if (!provider) throw Object.assign(new Error(`用途「${purpose}」指向的模型接入不存在`), { statusCode: 400 });
|
||||
const model = String(value.model || '').trim();
|
||||
if (!model) continue;
|
||||
next[purpose] = { provider_id: provider.id, model };
|
||||
}
|
||||
ai.saveAssignments(next);
|
||||
auth.audit({
|
||||
username: req.user.username,
|
||||
action: 'ai_assignment_update',
|
||||
detail: `用途分配:${Object.entries(next).map(([k, v]) => `${k}→${v.model}`).join(',') || '(全部清空)'}`,
|
||||
req
|
||||
});
|
||||
res.json({ assignments: next, overview: ai.overview() });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/ai/chat —— 按用途发起一次真实对话(试跑 / 小皮内部使用)。
|
||||
* body: { prompt | messages, purpose, temperature }
|
||||
*/
|
||||
router.post('/chat', auth.requireAuth, async (req, res) => {
|
||||
try {
|
||||
const purpose = String(req.body?.purpose || 'assistant_chat');
|
||||
if (!PURPOSE_KEYS.includes(purpose)) {
|
||||
throw Object.assign(new Error(`未知的用途:${purpose}`), { statusCode: 400 });
|
||||
}
|
||||
const messages = Array.isArray(req.body?.messages) && req.body.messages.length
|
||||
? req.body.messages.map(item => ({ role: item.role === 'assistant' ? 'assistant' : 'user', content: String(item.content || '') }))
|
||||
: [{ role: 'user', content: String(req.body?.prompt || '').trim() }];
|
||||
if (!messages[0].content) throw Object.assign(new Error('请输入内容'), { statusCode: 400 });
|
||||
|
||||
const result = await ai.chatByPurpose(purpose, messages, {
|
||||
temperature: req.body?.temperature
|
||||
});
|
||||
if (!result) {
|
||||
return res.status(409).json({ error: '还没有为这个用途分配模型,请先在「设置 → 智能配置」里接入并分配' });
|
||||
}
|
||||
if (!result.ok) return res.status(502).json({ error: `${result.provider} 调用失败:${result.error}`, ...result });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 账号与个人资料。
|
||||
* 挂载点:/api(路由内部自带 /auth/* 与 /me* 前缀)
|
||||
*
|
||||
* 免登录白名单在 server.js 的 /api 登录门里声明(health / auth\/ / system\/),
|
||||
* 所以这里 /auth/login、/auth/logout、/auth/me 都可以在没有会话时访问。
|
||||
*/
|
||||
const express = require('express');
|
||||
const db = require('../db/db');
|
||||
const auth = require('../lib/auth');
|
||||
const { ROLES } = require('../lib/permissions');
|
||||
|
||||
const router = express.Router();
|
||||
const ROLE_VALUES = ROLES.map(item => item.value);
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 登录相关 */
|
||||
|
||||
/** POST /api/auth/login */
|
||||
router.post('/auth/login', (req, res) => {
|
||||
try {
|
||||
const username = String(req.body?.username || '').trim();
|
||||
const password = String(req.body?.password || '');
|
||||
if (!username) throw badRequest('请输入用户名');
|
||||
if (!password) throw badRequest('请输入密码');
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||
if (!user) {
|
||||
auth.audit({ username, action: 'login_failed', detail: '用户名不存在', req, ok: 0 });
|
||||
throw badRequest('用户名或密码不正确');
|
||||
}
|
||||
if (user.locked_until && new Date(user.locked_until).getTime() > Date.now()) {
|
||||
const minutes = Math.max(1, Math.ceil((new Date(user.locked_until).getTime() - Date.now()) / 60000));
|
||||
auth.audit({ username, action: 'login_blocked', detail: '账号锁定中', req, ok: 0 });
|
||||
throw badRequest(`账号已锁定,请 ${minutes} 分钟后再试`);
|
||||
}
|
||||
if (!user.is_active) {
|
||||
auth.audit({ username, action: 'login_blocked', detail: '账号已停用', req, ok: 0 });
|
||||
throw badRequest('账号已停用,请联系管理员');
|
||||
}
|
||||
if (!auth.verifyPassword(password, user.password_hash)) {
|
||||
const failed = Number(user.failed_count || 0) + 1;
|
||||
const lockedUntil = failed >= auth.MAX_FAILED
|
||||
? new Date(Date.now() + auth.LOCK_MS).toISOString()
|
||||
: null;
|
||||
db.prepare('UPDATE users SET failed_count = ?, locked_until = ? WHERE id = ?').run(failed, lockedUntil, user.id);
|
||||
auth.audit({ username, action: 'login_failed', detail: `密码错误(第 ${failed} 次)`, req, ok: 0 });
|
||||
throw badRequest(lockedUntil
|
||||
? `密码连续错误 ${failed} 次,账号已锁定 15 分钟`
|
||||
: '用户名或密码不正确');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
db.prepare('UPDATE users SET failed_count = 0, locked_until = NULL, last_login_at = ? WHERE id = ?').run(now, user.id);
|
||||
const sessionId = auth.createSession(user.id, req);
|
||||
auth.setSessionCookie(res, sessionId);
|
||||
auth.audit({ username: user.username, action: 'login', detail: '登录成功', req });
|
||||
res.json({ user: auth.publicUser({ ...user, last_login_at: now }) });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/auth/logout */
|
||||
router.post('/auth/logout', (req, res) => {
|
||||
try {
|
||||
if (req.user) {
|
||||
auth.destroySession(req.user.sid);
|
||||
auth.audit({ username: req.user.username, action: 'logout', detail: '退出登录', req });
|
||||
}
|
||||
auth.clearSessionCookie(res);
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/auth/me —— 前端启动时用它判断是否已登录 */
|
||||
router.get('/auth/me', (req, res) => {
|
||||
if (!req.user) return res.status(401).json({ error: '请先登录' });
|
||||
res.json({ user: auth.publicUser(req.user) });
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------ 个人资料 */
|
||||
|
||||
const PROFILE_TEXT_FIELDS = [
|
||||
['display_name', '显示名'],
|
||||
['email', '联系邮箱'],
|
||||
['phone', '电话'],
|
||||
['address', '地址'],
|
||||
['position', '职位'],
|
||||
['avatar', '头像']
|
||||
];
|
||||
|
||||
/** PATCH /api/me —— 保存个人资料 */
|
||||
router.patch('/me', (req, res) => {
|
||||
try {
|
||||
if (!req.user) return res.status(401).json({ error: '请先登录' });
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
for (const [key, label] of PROFILE_TEXT_FIELDS) {
|
||||
if (req.body?.[key] === undefined) continue;
|
||||
const value = String(req.body[key] ?? '').trim();
|
||||
if (key === 'display_name' && !value) throw badRequest('显示名不能为空');
|
||||
if (key === 'email' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
throw badRequest('联系邮箱格式不正确');
|
||||
}
|
||||
fields.push(`${key} = ?`);
|
||||
values.push(value || null);
|
||||
}
|
||||
|
||||
if (req.body?.role !== undefined) {
|
||||
const role = String(req.body.role || '').trim();
|
||||
if (!ROLE_VALUES.includes(role)) throw badRequest('角色取值不在允许范围内');
|
||||
fields.push('role = ?');
|
||||
values.push(role);
|
||||
}
|
||||
if (req.body?.org_id !== undefined) {
|
||||
const orgId = Number(req.body.org_id);
|
||||
if (!Number.isInteger(orgId) || !db.prepare('SELECT id FROM organizations WHERE id = ?').get(orgId)) {
|
||||
throw badRequest('所属组织不存在');
|
||||
}
|
||||
fields.push('org_id = ?');
|
||||
values.push(orgId);
|
||||
}
|
||||
|
||||
if (!fields.length) throw badRequest('没有需要保存的内容');
|
||||
fields.push('updated_at = ?');
|
||||
values.push(new Date().toISOString());
|
||||
values.push(req.user.id);
|
||||
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
||||
auth.audit({ username: updated.username, action: 'profile_update', detail: '更新个人资料', req });
|
||||
res.json({ user: auth.publicUser(updated), message: '个人资料已保存' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
/** PATCH /api/me/password —— 改密码:三段校验全过才落库 */
|
||||
router.patch('/me/password', (req, res) => {
|
||||
try {
|
||||
if (!req.user) return res.status(401).json({ error: '请先登录' });
|
||||
const current = String(req.body?.current_password || '');
|
||||
const next = String(req.body?.new_password || '');
|
||||
const confirm = String(req.body?.confirm_password || '');
|
||||
|
||||
if (!current) throw badRequest('请输入当前密码');
|
||||
if (!next) throw badRequest('请输入新密码');
|
||||
if (next.length < 6) throw badRequest('新密码至少 6 位');
|
||||
if (next !== confirm) throw badRequest('两次输入的新密码不一致');
|
||||
if (next === current) throw badRequest('新密码不能与当前密码相同');
|
||||
if (!auth.verifyPassword(current, req.user.password_hash)) {
|
||||
auth.audit({ username: req.user.username, action: 'password_change_failed', detail: '当前密码不正确', req, ok: 0 });
|
||||
throw badRequest('当前密码不正确');
|
||||
}
|
||||
|
||||
db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?')
|
||||
.run(auth.hashPassword(next), new Date().toISOString(), req.user.id);
|
||||
/* 保住当前会话,踢掉其它设备上已登录的会话 */
|
||||
db.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?').run(req.user.id, req.user.sid);
|
||||
auth.audit({ username: req.user.username, action: 'password_change', detail: '修改密码成功', req });
|
||||
res.json({ ok: true, message: '密码已修改' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* 合同清洗中心:解析规则、AI 兜底解析、批量入库、原件下载。
|
||||
* 挂载点:/api/contracts(挂在登录门之后,见 server.js)
|
||||
*
|
||||
* 与 server.js 里那三个基础接口(上传 / 任务列表 / 单条复核)的分工:
|
||||
* 那边只管「把文件变成待复核的行」,这边管「行怎么认出来的、怎么进产品库」。
|
||||
*
|
||||
* 权限口径:仍然沿用 `cleanup.view` —— 系统里目前没有 `cleanup.edit` 这个点,
|
||||
* 复核与入库都属于「合同清洗」这一个能力块。等以后要拆细粒度再补点。
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
|
||||
const db = require('../db/db');
|
||||
const auth = require('../lib/auth');
|
||||
const catalog = require('../lib/skuCatalog');
|
||||
const parser = require('../lib/contractParser');
|
||||
const files = require('../lib/contractFiles');
|
||||
const aiProviders = require('../lib/aiProviders');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
function badRequest(message) {
|
||||
return Object.assign(new Error(message), { statusCode: 400 });
|
||||
}
|
||||
function fail(res, error) {
|
||||
const message = error?.message || '请求处理失败';
|
||||
const status = error?.statusCode || (/不存在|无效|格式|不能为空|请先|没有|已过期/.test(message) ? 400 : 500);
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
|
||||
const getJob = id => db.prepare('SELECT * FROM contract_import_jobs WHERE id = ?').get(Number(id));
|
||||
|
||||
/* ============================== 解析规则 ============================== */
|
||||
|
||||
/**
|
||||
* 当前生效的规则(内置默认 + 用户覆盖)。
|
||||
* 写这个接口是为了让「通用性」可验证:用户能一眼看到系统到底认哪些词,
|
||||
* 再照着 JSON 覆盖文件去加自己行业的说法。
|
||||
*/
|
||||
router.get('/parse-rules', (req, res) => {
|
||||
try {
|
||||
const rules = parser.loadRules();
|
||||
res.json({
|
||||
options: rules.options,
|
||||
fields: Object.entries(rules.fields).map(([key, field]) => ({
|
||||
key,
|
||||
label: field.label,
|
||||
type: field.type,
|
||||
keys: field.keys,
|
||||
exclude: field.exclude,
|
||||
regex: field.regex || []
|
||||
})),
|
||||
override_source: rules.override_source,
|
||||
override_file: parser.overrideFile,
|
||||
hint: '覆盖文件(可选):在数据目录放 contract-rules.json,或在设置表 settings["contract.rules"] 写同样的 JSON。'
|
||||
});
|
||||
} catch (error) { fail(res, error); }
|
||||
});
|
||||
|
||||
/** 保存覆盖(存 settings;文件形式的覆盖优先级更高,见 contractParser.readOverride) */
|
||||
router.put('/parse-rules', auth.requirePermission('cleanup.view'), (req, res) => {
|
||||
try {
|
||||
const body = req.body;
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) throw badRequest('规则必须是 JSON 对象');
|
||||
const allowed = ['fields', 'options', 'disable'];
|
||||
if (!Object.keys(body).some(key => allowed.includes(key))) {
|
||||
throw badRequest(`规则里至少要有一项:${allowed.join(' / ')}`);
|
||||
}
|
||||
if (body.fields && (typeof body.fields !== 'object' || Array.isArray(body.fields))) throw badRequest('fields 必须是对象');
|
||||
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
|
||||
.run('contract.rules', JSON.stringify(body));
|
||||
auth.audit({ username: req.user?.username, action: 'contract_rules_update', detail: '更新合同解析规则覆盖', req });
|
||||
res.json({ ok: true, rules: parser.loadRules(), message: '规则已保存,下次解析立即生效' });
|
||||
} catch (error) { fail(res, error); }
|
||||
});
|
||||
|
||||
/* ============================== AI 解析 ============================== */
|
||||
|
||||
/** 从模型返回里把 JSON 数组抠出来:容忍 ```json 代码块、前后废话 */
|
||||
function extractJsonArray(text) {
|
||||
let body = String(text || '').trim();
|
||||
body = body.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim();
|
||||
const start = body.indexOf('[');
|
||||
const end = body.lastIndexOf(']');
|
||||
if (start < 0 || end <= start) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(body.slice(start, end + 1));
|
||||
return Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const AI_FIELDS = ['sku', 'product_name', 'spec', 'purchase_price', 'currency', 'quantity', 'moq'];
|
||||
|
||||
const AI_SYSTEM_PROMPT = [
|
||||
'你是外贸采购数据抽取引擎。用户会给你一张供应商合同/报价单里的表格(CSV 文本,可能由 Excel 转出,列的顺序与命名都不固定)。',
|
||||
'请把每一行商品抽成 JSON。',
|
||||
'',
|
||||
'硬性要求:',
|
||||
'1) 只输出一个 JSON 数组,不要任何解释、不要 markdown 代码块、不要多余字段。',
|
||||
'2) purchase_price 必须是数值(采购价/供货价/出厂价/单价/EXW/FOB 都算);',
|
||||
' 金额合计、总价、小计、税额、运费、建议零售价一律**不要**当采购价。',
|
||||
'3) quantity 是数量,moq 是最小起订量,两者不要混。',
|
||||
'4) currency 用 ISO 三字码(CNY/USD/EUR…),没有线索就 null。',
|
||||
`5) 每个对象只允许这些键:${AI_FIELDS.join(', ')}。`,
|
||||
'6) 抽不到的字段填 null,**不要编造**。',
|
||||
'7) 忽略小计/合计/备注/页脚/公司抬头这类非商品行。'
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* 用模型重解析一个任务。
|
||||
*
|
||||
* 为什么用「原件」而不是「已解析的行」:规则解析失败时(正是需要 AI 的场景),
|
||||
* 队列里可能只有一行占位记录,把那些行喂给模型等于让它猜一份残缺的表。
|
||||
* 所以优先读 7 天内还留着的原件;原件过期了才退回用队列里的原文。
|
||||
*/
|
||||
router.post('/import-jobs/:id/ai-parse', auth.requirePermission('cleanup.view'), async (req, res) => {
|
||||
try {
|
||||
const job = getJob(req.params.id);
|
||||
if (!job) throw badRequest('导入任务不存在');
|
||||
|
||||
const resolved = aiProviders.resolveAssignment('contract_parse');
|
||||
if (!resolved) {
|
||||
throw badRequest('还没有给「合同智能解析」分配模型:请到 设置 → 智能配置 里,把某个接入分配给这个用途');
|
||||
}
|
||||
|
||||
// 拼素材:优先原件,其次队列里的 raw_text
|
||||
let sourceText = '';
|
||||
let sourceLabel = '';
|
||||
let sourceNames = [];
|
||||
try { sourceNames = JSON.parse(job.source_files || '[]'); } catch { sourceNames = []; }
|
||||
for (const name of sourceNames) {
|
||||
const original = files.readOriginal(job.job_no, name);
|
||||
if (!original) continue;
|
||||
const described = parser.describeForAi(original.buffer, original.name, { maxRows: 120, maxChars: 9000 });
|
||||
if (!described.text) continue;
|
||||
sourceText += `\n### 文件:${original.name}\n${described.text}\n`;
|
||||
sourceLabel = original.name;
|
||||
if (sourceText.length > 16000) break;
|
||||
}
|
||||
if (!sourceText) {
|
||||
const rows = db.prepare(
|
||||
'SELECT row_no, raw_text FROM contract_import_items WHERE job_id = ? AND raw_text IS NOT NULL ORDER BY row_no LIMIT 120'
|
||||
).all(job.id);
|
||||
if (rows.length >= 2) {
|
||||
sourceText = rows.map(row => row.raw_text).join('\n');
|
||||
sourceLabel = '队列内已解析的原文';
|
||||
}
|
||||
}
|
||||
if (!sourceText.trim()) {
|
||||
throw badRequest('拿不到可解析的内容:原件已过保留期(7 天)被清理,请把文件重新上传一次');
|
||||
}
|
||||
|
||||
const answer = await aiProviders.chatByPurpose('contract_parse', [
|
||||
{ role: 'system', content: AI_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: `请抽取下面表格里的商品行:\n\n${sourceText}` }
|
||||
], { maxTokens: 4000, temperature: 0 });
|
||||
|
||||
if (!answer) throw badRequest('没有可用的模型接入');
|
||||
if (!answer.ok) throw Object.assign(new Error(`模型调用失败:${answer.error}`), { statusCode: 502 });
|
||||
|
||||
const list = extractJsonArray(answer.text);
|
||||
if (!list || !list.length) throw badRequest('模型没有返回可用的 JSON 数组,建议稍后重试或改用手工补录');
|
||||
|
||||
const items = list.map((row, index) => ({
|
||||
sku: row?.sku ? String(row.sku).trim().slice(0, 120) : null,
|
||||
product_name: row?.product_name ? String(row.product_name).trim().slice(0, 200) : null,
|
||||
spec: row?.spec ? String(row.spec).trim().slice(0, 200) : null,
|
||||
purchase_price: parser.toNumber(row?.purchase_price),
|
||||
currency: row?.currency ? String(row.currency).trim().toUpperCase().slice(0, 8) : null,
|
||||
quantity: parser.toNumber(row?.quantity),
|
||||
moq: parser.toNumber(row?.moq),
|
||||
row_no: index + 1
|
||||
})).filter(row => AI_FIELDS.some(key => row[key] !== null));
|
||||
|
||||
if (!items.length) throw badRequest('模型返回的内容里没有可用的字段,建议改用手工补录');
|
||||
|
||||
const now = nowIso();
|
||||
let replaced = 0;
|
||||
db.transaction(() => {
|
||||
/* 只清掉「上一次机器解析出来的、还没被人处理过」的行。
|
||||
已经复核过/已入库的行必须留着 —— 用户可能在 AI 之前已经手工修过几条。 */
|
||||
const stale = db.prepare(
|
||||
`SELECT COUNT(*) AS count FROM contract_import_items
|
||||
WHERE job_id = ? AND status = 'pending_review' AND committed_at IS NULL
|
||||
AND COALESCE(parse_source, 'rules') = 'rules'`
|
||||
).get(job.id).count;
|
||||
db.prepare(
|
||||
`DELETE FROM contract_import_items
|
||||
WHERE job_id = ? AND status = 'pending_review' AND committed_at IS NULL
|
||||
AND COALESCE(parse_source, 'rules') = 'rules'`
|
||||
).run(job.id);
|
||||
replaced = stale;
|
||||
|
||||
const ins = db.prepare(
|
||||
`INSERT INTO contract_import_items (job_id, filename, row_no, raw_text, sku, product_name, spec,
|
||||
purchase_price, currency, quantity, moq, status, review_note,
|
||||
parse_source, created_at, updated_at)
|
||||
VALUES (@job_id, @filename, @row_no, @raw_text, @sku, @product_name, @spec,
|
||||
@purchase_price, @currency, @quantity, @moq, 'pending_review', @review_note,
|
||||
'ai', @now, @now)`
|
||||
);
|
||||
for (const item of items) {
|
||||
ins.run({
|
||||
job_id: job.id,
|
||||
filename: sourceLabel || (sourceNames[0] || 'AI 解析'),
|
||||
row_no: item.row_no,
|
||||
raw_text: JSON.stringify(item).slice(0, 500),
|
||||
sku: item.sku,
|
||||
product_name: item.product_name,
|
||||
spec: item.spec,
|
||||
purchase_price: item.purchase_price,
|
||||
currency: item.currency,
|
||||
quantity: item.quantity,
|
||||
moq: item.moq,
|
||||
review_note: 'AI 解析结果,提交前请复核',
|
||||
now
|
||||
});
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE contract_import_jobs SET status = 'pending_review', parse_source = 'ai',
|
||||
notes = ?, updated_at = ? WHERE id = ?`)
|
||||
.run(`AI 解析:${answer.provider} / ${answer.model} · 抽出 ${items.length} 行`, now, job.id);
|
||||
})();
|
||||
|
||||
auth.audit({
|
||||
username: req.user?.username,
|
||||
action: 'contract_ai_parse',
|
||||
detail: `任务 ${job.job_no} 用 AI 解析出 ${items.length} 行(替换未复核的规则行 ${replaced} 条)`,
|
||||
req
|
||||
});
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
job: getJob(job.id),
|
||||
extracted: items.length,
|
||||
replaced,
|
||||
provider: answer.provider,
|
||||
model: answer.model,
|
||||
message: `AI 解析出 ${items.length} 行,已进入待复核队列`
|
||||
});
|
||||
} catch (error) { fail(res, error); }
|
||||
});
|
||||
|
||||
/* ============================== 入库(批量) ============================== */
|
||||
|
||||
/**
|
||||
* 把勾选的明细写进产品库。
|
||||
*
|
||||
* 口径(Wayne 2026-09-16 定):
|
||||
* - 已存在的货号 → 更新采购价(并写一条价格历史,来源记合同文件名)
|
||||
* - 库里没有的货号 → 新建(沿用产品库原有的「撞号落 -COPY2」规则)
|
||||
* - 入库即视为复核通过,同时补上 reviewed_by / committed_at —— 这两项以前是空的
|
||||
*
|
||||
* 逐条一个事务:一次勾几百条时不能让写锁一直握着(同上传那里的取舍)。
|
||||
*/
|
||||
router.post('/import-items/commit', auth.requirePermission('cleanup.view'), (req, res) => {
|
||||
try {
|
||||
const rawIds = Array.isArray(req.body?.ids) ? req.body.ids : [];
|
||||
const ids = [...new Set(rawIds.map(Number).filter(Number.isInteger))].slice(0, 2000);
|
||||
if (!ids.length) throw badRequest('请先勾选要入库的明细');
|
||||
|
||||
const actor = req.user?.username || null;
|
||||
const orgId = req.user?.org_id ?? null;
|
||||
const now = nowIso();
|
||||
const summary = { updated: 0, created: 0, skipped: [], jobs: new Set(), contracts: new Set() };
|
||||
|
||||
for (const id of ids) {
|
||||
const item = db.prepare('SELECT * FROM contract_import_items WHERE id = ?').get(id);
|
||||
if (!item) { summary.skipped.push({ id, reason: '明细不存在' }); continue; }
|
||||
if (item.committed_at) {
|
||||
summary.skipped.push({ id, sku: item.product_sku, reason: '之前已经入库过了' });
|
||||
continue;
|
||||
}
|
||||
const skuText = String(item.sku || '').trim();
|
||||
if (!skuText) { summary.skipped.push({ id, reason: '没有货号,无法入库' }); continue; }
|
||||
const price = parser.toNumber(item.purchase_price);
|
||||
const moq = parser.toNumber(item.moq);
|
||||
const key = catalog.normalizeSku(skuText);
|
||||
const existing = db.prepare('SELECT * FROM products WHERE sku = ?').get(key);
|
||||
|
||||
try {
|
||||
let productSku = key;
|
||||
if (existing) {
|
||||
if (price === null) { summary.skipped.push({ id, sku: existing.display_sku || key, reason: '没有采购价,未更新' }); continue; }
|
||||
db.transaction(() => {
|
||||
/* 只补空字段,不覆盖已有的人工命名 —— 导入是来「补价」的,不是来改名的 */
|
||||
db.prepare(
|
||||
`UPDATE products SET
|
||||
price = @price,
|
||||
moq = COALESCE(@moq, moq),
|
||||
name = COALESCE(NULLIF(name, ''), @name),
|
||||
spec = COALESCE(NULLIF(spec, ''), @spec),
|
||||
source_contract = @source_contract,
|
||||
parse_method = @parse_method,
|
||||
confidence = 'high',
|
||||
last_updated = @now, updated_at = @now
|
||||
WHERE sku = @sku`
|
||||
).run({
|
||||
sku: key,
|
||||
price,
|
||||
moq,
|
||||
name: item.product_name?.trim() || null,
|
||||
spec: item.spec?.trim() || null,
|
||||
source_contract: item.filename,
|
||||
parse_method: item.parse_source === 'ai' ? 'ai' : 'rules',
|
||||
now
|
||||
});
|
||||
db.prepare(
|
||||
`INSERT INTO price_history (sku, old_price, new_price, old_moq, new_moq, currency,
|
||||
supplier_id, source_contract, confidence, changed_at, changed_by)
|
||||
VALUES (@sku, @old_price, @price, @old_moq, @moq, @currency, @supplier_id, @source, 'high', @now, @actor)`
|
||||
).run({
|
||||
sku: key,
|
||||
old_price: existing.price,
|
||||
price,
|
||||
old_moq: existing.moq,
|
||||
moq,
|
||||
currency: existing.currency || 'CNY',
|
||||
supplier_id: existing.supplier_id ?? null,
|
||||
// 价格历史里只记合同文件名(不记路径、不记任务号)
|
||||
source: item.filename,
|
||||
now,
|
||||
actor
|
||||
});
|
||||
})();
|
||||
summary.updated += 1;
|
||||
} else {
|
||||
const created = catalog.insertItem(
|
||||
{ sku: skuText, name: item.product_name, spec: item.spec, price, moq },
|
||||
null, null, actor, orgId,
|
||||
{
|
||||
sourceContract: item.filename,
|
||||
parseMethod: item.parse_source === 'ai' ? 'ai' : 'rules',
|
||||
currency: item.currency || 'CNY'
|
||||
}
|
||||
);
|
||||
if (created?.error) { summary.skipped.push({ id, reason: created.error }); continue; }
|
||||
productSku = created.sku;
|
||||
summary.created += 1;
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE contract_import_items
|
||||
SET status = 'approved', reviewed_by = ?, reviewed_at = ?,
|
||||
updated_at = ?, committed_at = ?, product_sku = ?
|
||||
WHERE id = ?`
|
||||
).run(req.user?.id ?? null, now, now, now, productSku, id);
|
||||
|
||||
summary.jobs.add(item.job_id);
|
||||
summary.contracts.add(item.filename);
|
||||
} catch (error) {
|
||||
summary.skipped.push({ id, reason: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 任务的「待复核」可能清空了,状态跟着走(和单条复核同一套派生规则)
|
||||
for (const jobId of summary.jobs) {
|
||||
const remaining = db.prepare(
|
||||
`SELECT COUNT(*) AS count FROM contract_import_items WHERE job_id = ? AND status = 'pending_review'`
|
||||
).get(jobId).count;
|
||||
db.prepare('UPDATE contract_import_jobs SET status = ?, updated_at = ?, completed_at = ? WHERE id = ?')
|
||||
.run(remaining ? 'pending_review' : 'reviewed', now, remaining ? null : now, jobId);
|
||||
}
|
||||
|
||||
auth.audit({
|
||||
username: actor,
|
||||
action: 'contract_commit',
|
||||
detail: `合同入库:更新 ${summary.updated} 条、新建 ${summary.created} 条、跳过 ${summary.skipped.length} 条`
|
||||
+ (summary.contracts.size ? `(来源:${[...summary.contracts].join('、')})` : ''),
|
||||
req
|
||||
});
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
updated: summary.updated,
|
||||
created: summary.created,
|
||||
skipped: summary.skipped,
|
||||
message: `入库完成:更新 ${summary.updated} 条、新建 ${summary.created} 条`
|
||||
+ (summary.skipped.length ? `、跳过 ${summary.skipped.length} 条` : '')
|
||||
});
|
||||
} catch (error) { fail(res, error); }
|
||||
});
|
||||
|
||||
/* ============================== 原件(7 天) ============================== */
|
||||
|
||||
router.get('/import-jobs/:id/files', (req, res) => {
|
||||
try {
|
||||
const job = getJob(req.params.id);
|
||||
if (!job) throw badRequest('导入任务不存在');
|
||||
const list = files.listOriginals(job.job_no);
|
||||
const created = Date.parse(job.created_at);
|
||||
const expires = Number.isFinite(created)
|
||||
? new Date(created + files.RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString()
|
||||
: null;
|
||||
res.json({
|
||||
files: list,
|
||||
retention_days: files.RETENTION_DAYS,
|
||||
expires_at: expires,
|
||||
expired: list.length === 0 && Number.isFinite(created) && Date.now() > created + files.RETENTION_DAYS * 24 * 60 * 60 * 1000
|
||||
});
|
||||
} catch (error) { fail(res, error); }
|
||||
});
|
||||
|
||||
/** 下载一份原件。过期清理后返回 410,让界面能说清「原件已清理」而不是报一个坏链接。 */
|
||||
router.get('/import-jobs/:id/file', (req, res) => {
|
||||
try {
|
||||
const job = getJob(req.params.id);
|
||||
if (!job) throw badRequest('导入任务不存在');
|
||||
const name = String(req.query.name || '').trim();
|
||||
if (!name) throw badRequest('缺少文件名参数 name');
|
||||
const file = files.readOriginal(job.job_no, name);
|
||||
if (!file) {
|
||||
return res.status(410).json({
|
||||
error: `原件已过保留期(${files.RETENTION_DAYS} 天)被自动清理,解析结果不受影响`
|
||||
});
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="contract"; filename*=UTF-8''${encodeURIComponent(path.basename(file.name))}`
|
||||
);
|
||||
res.end(file.buffer);
|
||||
} catch (error) { fail(res, error); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* 系统日志(审计轨迹)—— 读取 audit_log 并提供筛选、统计与导出。
|
||||
*
|
||||
* 为什么要有这一页:Wayne 2026-09-15 参照 GoodJob 的系统日志页提出 —— 登录、改密、
|
||||
* 用户/权限组/组织变更、客户归属流转、商机状态、AI 接入变更都会写 audit_log,
|
||||
* 但这些记录此前只能在库里看见,界面上没有任何入口。
|
||||
*
|
||||
* ⚠️ 挂载点刻意**不放在 /api/system/ 下** —— 那个前缀在免登录白名单里
|
||||
* (`/^\/api\/(health|auth\/|system\/)/`),放在那儿等于把全公司的操作日志公开。
|
||||
*
|
||||
* 权限:`audit.view`,按 Wayne 的口径**只授给系统管理员组**(见 lib/permissions.js)。
|
||||
* 界面入口同理:没这个点的人,设置里根本看不到「系统日志」这一项。
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const db = require('../db/db');
|
||||
const auth = require('../lib/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* 分类:按 action 前缀归组。新增 action 时如果这里没覆盖,会落到「系统」,
|
||||
* 不会丢记录 —— 宁可直接显示,也不要因为漏配而看不见。
|
||||
* ------------------------------------------------------------------------- */
|
||||
const CATEGORIES = [
|
||||
{ key: 'auth', label: '认证', match: /^(login|logout|password_|profile_)/ },
|
||||
{ key: 'user', label: '用户', match: /^user_/ },
|
||||
{ key: 'permission', label: '权限', match: /^permission_group_/ },
|
||||
{ key: 'org', label: '组织', match: /^(organization_|branding_)/ },
|
||||
{ key: 'customer', label: '客户', match: /^(customer_|pool_|prospect_)/ },
|
||||
{ key: 'contract', label: '合同', match: /^contract_/ },
|
||||
{ key: 'ai', label: '智能', match: /^ai_/ }
|
||||
]
|
||||
|
||||
const CATEGORY_LABELS = { system: '系统', ...Object.fromEntries(CATEGORIES.map(c => [c.key, c.label])) }
|
||||
|
||||
/* 动作的中文名。查不到就显示原始 action —— 便于新动作上线时立刻可读,不用等改代码。 */
|
||||
const ACTION_LABELS = {
|
||||
login: '登录成功',
|
||||
login_failed: '登录失败',
|
||||
login_blocked: '登录被拦截',
|
||||
logout: '退出登录',
|
||||
profile_update: '更新个人资料',
|
||||
password_change: '修改密码',
|
||||
password_change_failed: '修改密码失败',
|
||||
user_create: '新建用户',
|
||||
user_update: '更新用户',
|
||||
permission_group_create: '新建权限组',
|
||||
permission_group_update: '更新权限组',
|
||||
permission_group_delete: '删除权限组',
|
||||
organization_create: '新建组织',
|
||||
organization_update: '更新组织信息',
|
||||
branding_update: '更新系统标识',
|
||||
customer_create: '新建客户',
|
||||
customer_to_pool: '客户丢入公海',
|
||||
customer_claim: '公海领取客户',
|
||||
prospect_deal_status: '商机状态变更',
|
||||
ai_provider_create: '新增模型接入',
|
||||
ai_provider_update: '更新模型接入',
|
||||
ai_provider_delete: '移除模型接入',
|
||||
ai_provider_test: '测试模型连通性',
|
||||
ai_assignment_update: '调整模型用途分配',
|
||||
contract_import: '导入合同',
|
||||
contract_commit: '合同入库',
|
||||
contract_ai_parse: '合同 AI 解析',
|
||||
contract_rules_update: '更新合同解析规则'
|
||||
}
|
||||
|
||||
function categoryOf(action = '') {
|
||||
const hit = CATEGORIES.find(c => c.match.test(action))
|
||||
return hit ? hit.key : 'system'
|
||||
}
|
||||
|
||||
/* 等级:成功 / 信息 / 警告 / 错误。登录失败算「警告」——它通常是输错密码,
|
||||
而不是系统故障,混进错误里会把真正的故障淹掉。 */
|
||||
const WRITE_ACTION = /_(create|update|delete|change|claim)$/
|
||||
|
||||
function levelOf(row) {
|
||||
if (row.ok) {
|
||||
if (WRITE_ACTION.test(row.action) || row.action === 'customer_to_pool') return 'success'
|
||||
return 'info'
|
||||
}
|
||||
return /^(login_failed|login_blocked)$/.test(row.action) ? 'warning' : 'error'
|
||||
}
|
||||
|
||||
const LEVEL_LABELS = { info: '信息', success: '成功', warning: '警告', error: '错误' }
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* 过滤条件只在这里拼一次:列表、计数、统计、导出共用,避免四处走散。
|
||||
* 全部走占位参数,不拼字符串。
|
||||
* ------------------------------------------------------------------------- */
|
||||
function buildFilter(query) {
|
||||
const where = []
|
||||
const params = []
|
||||
|
||||
const level = String(query.level || '').trim()
|
||||
const category = String(query.category || '').trim()
|
||||
const keyword = String(query.q || '').trim()
|
||||
|
||||
if (keyword) {
|
||||
where.push('(username LIKE ? OR action LIKE ? OR detail LIKE ? OR ip LIKE ?)')
|
||||
const like = `%${keyword}%`
|
||||
params.push(like, like, like, like)
|
||||
}
|
||||
/* 分类与等级是「从 action / ok 算出来的」,SQL 里没有这两列,
|
||||
所以先选出候选行再在内存里筛 —— 日志量级(万行内)完全够用。 */
|
||||
const rows = db.prepare(
|
||||
`SELECT id, at, username, action, detail, ip, user_agent, ok FROM audit_log
|
||||
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
||||
ORDER BY id DESC LIMIT 20000`
|
||||
).all(...params)
|
||||
|
||||
const decorated = rows.map(row => ({
|
||||
...row,
|
||||
ok: row.ok ? 1 : 0,
|
||||
category: categoryOf(row.action),
|
||||
level: levelOf(row),
|
||||
action_label: ACTION_LABELS[row.action] || row.action
|
||||
}))
|
||||
|
||||
return decorated.filter(row =>
|
||||
(!level || row.level === level) && (!category || row.category === category))
|
||||
}
|
||||
|
||||
function dayStartIso() {
|
||||
const d = new Date()
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
function statsOf(rows) {
|
||||
/* 统计永远基于「当前筛选结果」,这样切了分类/等级之后四个数字才对得上列表。 */
|
||||
const today = dayStartIso()
|
||||
return {
|
||||
total: rows.length,
|
||||
today: rows.filter(r => r.at >= today).length,
|
||||
warning: rows.filter(r => r.level === 'warning').length,
|
||||
error: rows.filter(r => r.level === 'error').length
|
||||
}
|
||||
}
|
||||
|
||||
function csvCell(value) {
|
||||
const text = value === null || value === undefined ? '' : String(value)
|
||||
return `"${text.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
/** GET /api/logs —— 分页列表(带统计与可选分类) */
|
||||
router.get('/logs', auth.requirePermission('audit.view'), (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 200)
|
||||
const offset = Math.max(Number(req.query.offset) || 0, 0)
|
||||
const all = buildFilter(req.query)
|
||||
|
||||
const categoryCounts = {}
|
||||
for (const row of all) categoryCounts[row.category] = (categoryCounts[row.category] || 0) + 1
|
||||
|
||||
res.json({
|
||||
items: all.slice(offset, offset + limit),
|
||||
total: all.length,
|
||||
limit,
|
||||
offset,
|
||||
stats: statsOf(all),
|
||||
categories: Object.entries(CATEGORY_LABELS)
|
||||
.map(([key, label]) => ({ key, label, count: categoryCounts[key] || 0 }))
|
||||
.filter(item => item.count > 0),
|
||||
levels: Object.entries(LEVEL_LABELS).map(([key, label]) => ({
|
||||
key, label, count: all.filter(r => r.level === key).length
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: '读取日志失败:' + error.message })
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/logs/export —— 导出当前筛选结果为 CSV(带 BOM,Excel 直接打开不乱码) */
|
||||
router.get('/logs/export', auth.requirePermission('audit.view'), (req, res) => {
|
||||
try {
|
||||
const rows = buildFilter(req.query).slice(0, 5000)
|
||||
const header = ['时间', '等级', '分类', '操作人', '动作', '说明', 'IP', '结果']
|
||||
const lines = [header.map(csvCell).join(',')]
|
||||
for (const row of rows) {
|
||||
lines.push([
|
||||
row.at,
|
||||
LEVEL_LABELS[row.level] || row.level,
|
||||
CATEGORY_LABELS[row.category] || row.category,
|
||||
row.username || '—',
|
||||
row.action_label,
|
||||
row.detail || '',
|
||||
row.ip || '',
|
||||
row.ok ? '成功' : '失败'
|
||||
].map(csvCell).join(','))
|
||||
}
|
||||
|
||||
auth.audit({
|
||||
username: req.user.username,
|
||||
action: 'audit_export',
|
||||
detail: `导出系统日志 ${rows.length} 条`,
|
||||
req
|
||||
})
|
||||
|
||||
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="system-logs-${stamp}.csv"`)
|
||||
res.send('\uFEFF' + lines.join('\r\n'))
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: '导出日志失败:' + error.message })
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 新客开发的「开发结果管理」。
|
||||
* 挂载点:/api/prospects
|
||||
*
|
||||
* 口径:新客开发页只做两件事 —— ① 用线索池找到新客户;② 管理开发结果。
|
||||
* 开发结果就落在 customers.deal_status 上:pending 未成交 / won 已成交 / lost 已流失。
|
||||
* 这样「我的客户」和「新客开发」看到的是同一批客户,不会出现两套数据打架。
|
||||
*
|
||||
* 归属口径(Wayne 2026-09-14):客户「谁建的谁看得见」,所以本页也按归属过滤,
|
||||
* 否则业务员在「新客开发」里能直接看到并标记同事的客户。
|
||||
*/
|
||||
const express = require('express');
|
||||
const db = require('../db/db');
|
||||
const auth = require('../lib/auth');
|
||||
const access = require('../lib/customerAccess');
|
||||
|
||||
const router = express.Router();
|
||||
const DEAL_STATUSES = ['pending', 'won', 'lost'];
|
||||
|
||||
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 countBy(status, user) {
|
||||
const { conditions, params } = access.scopeFilter(user, 'all');
|
||||
const where = ["COALESCE(c.deal_status, 'pending') = ?", ...conditions];
|
||||
return db.prepare(`SELECT COUNT(*) AS c FROM customers c WHERE ${where.join(' AND ')}`)
|
||||
.get(status, ...params).c;
|
||||
}
|
||||
|
||||
/** GET /api/prospects?deal_status=won|lost|pending&q= */
|
||||
router.get('/', auth.requirePermission('prospect.view'), (req, res) => {
|
||||
try {
|
||||
const status = String(req.query.deal_status || '').trim();
|
||||
const keyword = String(req.query.q || '').trim();
|
||||
/* 归属过滤与本页自己的筛选条件合并 —— 归属条件务必放进来,它是硬边界 */
|
||||
const scope = access.scopeFilter(req.user, 'all');
|
||||
const conditions = [...scope.conditions];
|
||||
const params = [...scope.params];
|
||||
|
||||
if (DEAL_STATUSES.includes(status)) {
|
||||
conditions.push("COALESCE(c.deal_status, 'pending') = ?");
|
||||
params.push(status);
|
||||
}
|
||||
if (keyword) {
|
||||
conditions.push('(c.company LIKE ? OR c.company_en LIKE ? OR c.country LIKE ? OR c.email LIKE ?)');
|
||||
params.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`);
|
||||
}
|
||||
|
||||
const items = db.prepare(`
|
||||
SELECT c.*,
|
||||
u.display_name AS owner_name,
|
||||
(SELECT COUNT(*) FROM contacts ct WHERE ct.customer_id = c.id) AS contact_count,
|
||||
(SELECT COUNT(*) FROM customer_quotations q WHERE q.customer_id = c.id) AS quote_count,
|
||||
(SELECT MAX(f.created_at) FROM follow_up_records f WHERE f.customer_id = c.id) AS last_follow_at
|
||||
FROM customers c
|
||||
LEFT JOIN users u ON c.owner_id = u.id
|
||||
${conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''}
|
||||
ORDER BY COALESCE(c.updated_at, c.created_at) DESC
|
||||
LIMIT 300
|
||||
`).all(...params);
|
||||
|
||||
res.json({
|
||||
items,
|
||||
stats: {
|
||||
pending: countBy('pending', req.user),
|
||||
won: countBy('won', req.user),
|
||||
lost: countBy('lost', req.user)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
/** PATCH /api/prospects/:id —— 标记成交状态 */
|
||||
router.patch('/:id', auth.requirePermission('prospect.view'), (req, res) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const customer = db.prepare('SELECT * FROM customers WHERE id = ?').get(id);
|
||||
if (!customer) throw badRequest('客户不存在');
|
||||
/* 标记成交 = 改客户数据,别人的客户不能改 */
|
||||
if (!access.canEdit(req.user, customer)) {
|
||||
const error = new Error('这条客户不在你名下,请先到客户公海领取');
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const status = String(req.body?.deal_status || '').trim();
|
||||
if (!DEAL_STATUSES.includes(status)) throw badRequest('成交状态取值无效');
|
||||
|
||||
db.prepare('UPDATE customers SET deal_status = ?, updated_at = ? WHERE id = ?')
|
||||
.run(status, new Date().toISOString(), id);
|
||||
auth.audit({
|
||||
username: req.user.username,
|
||||
action: 'prospect_deal_status',
|
||||
detail: `客户「${customer.company}」标记为 ${status}`,
|
||||
req
|
||||
});
|
||||
res.json({ ok: true, item: db.prepare('SELECT * FROM customers WHERE id = ?').get(id) });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,721 @@
|
||||
/**
|
||||
* 产品目录(SKU)接口 —— 本地实现。
|
||||
*
|
||||
* 这些路径以前是 4300 原样转发给 sku-manager(3300)的,现在直接落在本库执行。
|
||||
* **路径一个字没改**(/api/sku/… 与 /product-images/…),所以前端零改动。
|
||||
*
|
||||
* 两处刻意偏离原实现,都是因为 happy 这边多了一张 product_packaging 表:
|
||||
* · 删除货号 / 批量删产品时,连带清掉它的包装参数 —— product_packaging.sku 有外键
|
||||
* 指向 products(sku),不清就删不动;产品都没了,包装参数留着也没意义。
|
||||
* · 其余照搬 sku-manager 的口径,包括「调价历史一律保留备查」「撞号落副本不报错」。
|
||||
*/
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const db = require('../db/db');
|
||||
const catalog = require('../lib/skuCatalog');
|
||||
|
||||
const {
|
||||
groupKey, normalizeSku, getItemAttrs, getGroupAttrs, replaceAttrs,
|
||||
cleanupGroupIfEmpty, insertItem, getContractFile
|
||||
} = catalog;
|
||||
|
||||
/* ------------------------------ 上传(图片) ------------------------------ */
|
||||
|
||||
const ALLOWED_IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp']);
|
||||
|
||||
/* 直接进内存 → 立刻写进库里,磁盘上不留明文副本(合约与产品图都属于机密数据) */
|
||||
const imageUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
defParamCharset: 'utf8',
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (!ALLOWED_IMAGE_EXT.has(path.extname(file.originalname).toLowerCase())) return cb(null, false);
|
||||
cb(null, true);
|
||||
},
|
||||
limits: { files: 20, fileSize: 15 * 1024 * 1024 }
|
||||
});
|
||||
|
||||
/* -------------------------------- 公共辅助 -------------------------------- */
|
||||
|
||||
/** 当前操作者。产品库要留「谁传的、谁改的价」,登录用户就是唯一来源。 */
|
||||
const actorOf = (req) => req.user?.display_name || req.user?.username || null;
|
||||
const actorOrgOf = (req) => req.user?.org_id ?? null;
|
||||
|
||||
const fail = (res, error, statusCode) =>
|
||||
res.status(statusCode || error?.statusCode || 500).json({ error: error?.message || '请求处理失败' });
|
||||
|
||||
const api = express.Router();
|
||||
const images = express.Router();
|
||||
|
||||
/* =============================== 查询与检索 =============================== */
|
||||
|
||||
/**
|
||||
* 产品级搜索:一行一个产品,聚合价格区间/货号数;命中的货号单独列出。
|
||||
*
|
||||
* 用 LIKE '%kw%' 全表扫 —— 前导通配符**用不上 B-tree 索引**,加索引也没用。
|
||||
* 当前规模(516 组 / 1574 货号)端到端 P50 才 8ms 出头,远不到要优化的地步。
|
||||
* 真要等到十万级货号再考虑 FTS5 虚拟表,而不是加索引。
|
||||
*/
|
||||
api.get('/search', (req, res) => {
|
||||
const q = (req.query.q || '').trim();
|
||||
const supplierId = req.query.supplier_id;
|
||||
const onlyLow = req.query.only_low === '1';
|
||||
|
||||
const where = [];
|
||||
const params = {};
|
||||
|
||||
if (q) {
|
||||
where.push(`g.id IN (
|
||||
SELECT DISTINCT g2.id FROM product_groups g2
|
||||
LEFT JOIN products p2 ON p2.group_id = g2.id
|
||||
LEFT JOIN item_attributes a2 ON (a2.group_id = g2.id OR a2.sku = p2.sku)
|
||||
WHERE g2.name LIKE @q OR g2.brand LIKE @q OR g2.category LIKE @q OR g2.description LIKE @q
|
||||
OR p2.sku LIKE @q OR p2.display_sku LIKE @q OR p2.name LIKE @q
|
||||
OR p2.spec LIKE @q OR p2.description LIKE @q
|
||||
OR a2.name LIKE @q OR a2.value LIKE @q
|
||||
)`);
|
||||
params.q = `%${q}%`;
|
||||
}
|
||||
if (supplierId === 'none') {
|
||||
where.push('g.supplier_id IS NULL');
|
||||
} else if (supplierId) {
|
||||
where.push('g.supplier_id = @supplier_id');
|
||||
params.supplier_id = Number(supplierId);
|
||||
}
|
||||
if (onlyLow) {
|
||||
where.push(`EXISTS (SELECT 1 FROM products pl WHERE pl.group_id = g.id AND pl.confidence = 'low')`);
|
||||
}
|
||||
|
||||
const groups = db.prepare(
|
||||
`SELECT g.id, g.name, g.brand, g.category, g.supplier_id,
|
||||
s.name AS supplier_name, s.short_name AS supplier_short,
|
||||
(SELECT COUNT(*) FROM products p WHERE p.group_id = g.id) AS item_count,
|
||||
(SELECT MIN(p.price) FROM products p WHERE p.group_id = g.id) AS price_min,
|
||||
(SELECT MAX(p.price) FROM products p WHERE p.group_id = g.id) AS price_max,
|
||||
(SELECT COUNT(*) FROM products p WHERE p.group_id = g.id AND p.confidence = 'low') AS low_count,
|
||||
(SELECT MAX(p.last_updated) FROM products p WHERE p.group_id = g.id) AS last_updated,
|
||||
(SELECT filename FROM product_images i WHERE i.group_id = g.id
|
||||
ORDER BY i.is_primary DESC, i.id ASC LIMIT 1) AS thumb,
|
||||
(SELECT COUNT(*) FROM product_images i WHERE i.group_id = g.id) AS image_count
|
||||
FROM product_groups g
|
||||
LEFT JOIN suppliers s ON s.id = g.supplier_id
|
||||
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
||||
ORDER BY last_updated DESC LIMIT 300`
|
||||
).all(params);
|
||||
|
||||
// 搜索词直接命中了哪些货号 —— 前端高亮用
|
||||
if (q && groups.length) {
|
||||
const ids = groups.map((g) => g.id);
|
||||
const hits = db.prepare(
|
||||
`SELECT p.group_id, p.sku, p.display_sku FROM products p
|
||||
WHERE p.group_id IN (${ids.map(() => '?').join(',')})
|
||||
AND (p.sku LIKE ? OR p.display_sku LIKE ? OR p.spec LIKE ? OR p.description LIKE ?
|
||||
OR p.sku IN (SELECT a.sku FROM item_attributes a WHERE a.sku IS NOT NULL AND (a.name LIKE ? OR a.value LIKE ?)))`
|
||||
).all(...ids, params.q, params.q, params.q, params.q, params.q, params.q);
|
||||
const byGroup = {};
|
||||
for (const h of hits) (byGroup[h.group_id] = byGroup[h.group_id] || []).push({ sku: h.sku, display_sku: h.display_sku });
|
||||
for (const g of groups) g.matched_items = byGroup[g.id] || [];
|
||||
}
|
||||
|
||||
res.json(groups);
|
||||
});
|
||||
|
||||
/** 产品详情:产品 + 全部货号(含各自参数)+ 产品级参数 + 图片。 */
|
||||
api.get('/group/:id', (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const group = db.prepare(
|
||||
`SELECT g.*, s.name AS supplier_name FROM product_groups g
|
||||
LEFT JOIN suppliers s ON s.id = g.supplier_id WHERE g.id = ?`
|
||||
).get(id);
|
||||
if (!group) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const items = db.prepare(
|
||||
`SELECT p.*, c.terms AS contract_terms,
|
||||
(SELECT pi.filename FROM product_images pi
|
||||
WHERE pi.group_id = p.group_id ORDER BY pi.is_primary DESC, pi.id ASC LIMIT 1) AS thumb
|
||||
FROM products p
|
||||
LEFT JOIN contracts c ON c.id = p.contract_id
|
||||
WHERE p.group_id = ? ORDER BY p.display_sku`
|
||||
).all(id);
|
||||
for (const it of items) it.attributes = getItemAttrs.all(it.sku);
|
||||
|
||||
res.json({
|
||||
group,
|
||||
groupAttributes: getGroupAttrs.all(id),
|
||||
items,
|
||||
images: db.prepare('SELECT * FROM product_images WHERE group_id = ? ORDER BY is_primary DESC, id ASC').all(id)
|
||||
});
|
||||
});
|
||||
|
||||
/** 编辑产品(名称/品牌/分类/供应商/说明/产品级参数)。 */
|
||||
api.put('/group/:id', (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const g = db.prepare('SELECT * FROM product_groups WHERE id = ?').get(id);
|
||||
if (!g) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const b = req.body || {};
|
||||
const name = b.name?.trim();
|
||||
if (!name) return res.status(400).json({ error: '产品名称不能为空' });
|
||||
|
||||
db.prepare(
|
||||
`UPDATE product_groups SET name=@name, group_key=@group_key, brand=@brand,
|
||||
category=@category, description=@description, supplier_id=@supplier_id, updated_at=@now WHERE id=@id`
|
||||
).run({
|
||||
id,
|
||||
name,
|
||||
group_key: groupKey(name),
|
||||
brand: b.brand?.trim() || null,
|
||||
category: b.category?.trim() || null,
|
||||
description: b.description?.trim() || null,
|
||||
supplier_id: b.supplier_id ? Number(b.supplier_id) : null,
|
||||
now: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (Array.isArray(b.attributes)) replaceAttrs({ groupId: id }, b.attributes);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/* ================================ 货号读写 ================================ */
|
||||
|
||||
/** 新建产品(必须带第一个货号)。 */
|
||||
api.post('/groups', (req, res) => {
|
||||
const b = req.body || {};
|
||||
const name = b.name?.trim();
|
||||
if (!name) return res.status(400).json({ error: '产品名称不能为空' });
|
||||
if (!b.item || !String(b.item.sku || '').trim()) return res.status(400).json({ error: '至少需要一个货号' });
|
||||
|
||||
const supplierId = b.supplier_id ? Number(b.supplier_id) : null;
|
||||
const groupId = db.prepare(
|
||||
`INSERT INTO product_groups (name, group_key, brand, category, description, supplier_id, created_at)
|
||||
VALUES (@name, @group_key, @brand, @category, @description, @supplier_id, @created_at)`
|
||||
).run({
|
||||
name,
|
||||
group_key: groupKey(name),
|
||||
brand: b.brand?.trim() || null,
|
||||
category: b.category?.trim() || null,
|
||||
description: b.description?.trim() || null,
|
||||
supplier_id: supplierId,
|
||||
created_at: new Date().toISOString()
|
||||
}).lastInsertRowid;
|
||||
|
||||
const r = insertItem({ ...b.item, name }, groupId, supplierId, actorOf(req), actorOrgOf(req));
|
||||
if (r.error) {
|
||||
cleanupGroupIfEmpty(groupId);
|
||||
return res.status(400).json(r);
|
||||
}
|
||||
res.json({
|
||||
group_id: groupId,
|
||||
sku: r.sku,
|
||||
display_sku: r.displaySku,
|
||||
duplicated_from: r.duplicatedFrom || null,
|
||||
uploader: actorOf(req)
|
||||
});
|
||||
});
|
||||
|
||||
/** 给已有产品加货号。 */
|
||||
api.post('/group/:id/items', (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const g = db.prepare('SELECT * FROM product_groups WHERE id = ?').get(id);
|
||||
if (!g) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const r = insertItem({ ...req.body, name: req.body?.name ?? g.name }, id, g.supplier_id, actorOf(req), actorOrgOf(req));
|
||||
if (r.error) return res.status(400).json(r);
|
||||
res.json({
|
||||
sku: r.sku,
|
||||
display_sku: r.displaySku,
|
||||
duplicated_from: r.duplicatedFrom || null,
|
||||
uploader: actorOf(req)
|
||||
});
|
||||
});
|
||||
|
||||
/** 货号详情(编辑器用):含参数与调价历史。 */
|
||||
api.get('/product/:sku', (req, res) => {
|
||||
const sku = req.params.sku.toUpperCase();
|
||||
const product = db.prepare(
|
||||
`SELECT p.*, s.name AS supplier_name, c.terms AS contract_terms
|
||||
FROM products p
|
||||
LEFT JOIN suppliers s ON s.id = p.supplier_id
|
||||
LEFT JOIN contracts c ON c.id = p.contract_id
|
||||
WHERE p.sku = ?`
|
||||
).get(sku);
|
||||
if (!product) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
product.attributes = getItemAttrs.all(sku);
|
||||
const history = db.prepare(
|
||||
`SELECT h.*, s.name AS supplier_name FROM price_history h
|
||||
LEFT JOIN suppliers s ON s.id = h.supplier_id
|
||||
WHERE h.sku = ? ORDER BY h.changed_at DESC LIMIT 50`
|
||||
).all(sku);
|
||||
res.json({ product, history });
|
||||
});
|
||||
|
||||
/** 手工修正货号 —— 表头千奇百怪,总有识别不准需要人工改的。 */
|
||||
api.put('/product/:sku', (req, res) => {
|
||||
const sku = req.params.sku.toUpperCase();
|
||||
const existing = db.prepare('SELECT * FROM products WHERE sku = ?').get(sku);
|
||||
if (!existing) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const b = req.body || {};
|
||||
const moq = b.moq === '' || b.moq === null || b.moq === undefined ? null : Number(b.moq);
|
||||
if (moq !== null && (!Number.isFinite(moq) || moq < 0)) return res.status(400).json({ error: '起订量必须是非负数字' });
|
||||
|
||||
/* 价格只有在没有合同来源时才允许手改 —— 合同解析出来的价格必须保持可追溯,
|
||||
否则下次导入同一份合同又被覆盖,反而让人以为改丢了。 */
|
||||
const priceEditable = !existing.contract_id;
|
||||
let price = existing.price;
|
||||
if (priceEditable && b.price !== undefined) {
|
||||
price = b.price === '' || b.price === null ? null : Number(b.price);
|
||||
if (price !== null && (!Number.isFinite(price) || price < 0)) {
|
||||
return res.status(400).json({ error: '采购价必须是非负数字' });
|
||||
}
|
||||
}
|
||||
|
||||
// 移动到其他产品 / 独立成新产品
|
||||
let targetGroup = existing.group_id;
|
||||
const oldGroup = existing.group_id;
|
||||
if (b.group_id === 'new') {
|
||||
targetGroup = db.prepare(
|
||||
`INSERT INTO product_groups (name, group_key, supplier_id, created_at)
|
||||
VALUES (@name, @group_key, @supplier_id, @created_at)`
|
||||
).run({
|
||||
name: b.name?.trim() || existing.name || existing.display_sku || sku,
|
||||
group_key: 'SKU:' + sku,
|
||||
supplier_id: existing.supplier_id ?? null,
|
||||
created_at: new Date().toISOString()
|
||||
}).lastInsertRowid;
|
||||
} else if (b.group_id !== undefined && b.group_id !== null && b.group_id !== '') {
|
||||
const g = db.prepare('SELECT id FROM product_groups WHERE id = ?').get(Number(b.group_id));
|
||||
if (!g) return res.status(400).json({ error: '目标产品不存在' });
|
||||
targetGroup = g.id;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
db.transaction(() => {
|
||||
db.prepare(
|
||||
`UPDATE products SET
|
||||
name = @name, spec = @spec, description = @description,
|
||||
price = @price, moq = @moq, group_id = @group_id,
|
||||
last_updated = @now, updated_at = @now
|
||||
WHERE sku = @sku`
|
||||
).run({
|
||||
sku,
|
||||
name: b.name?.trim() || null,
|
||||
spec: b.spec?.trim() || null,
|
||||
description: b.description?.trim() || null,
|
||||
price,
|
||||
moq,
|
||||
group_id: targetGroup,
|
||||
now
|
||||
});
|
||||
|
||||
if (priceEditable && (price !== existing.price || moq !== existing.moq)) {
|
||||
db.prepare(
|
||||
`INSERT INTO price_history (sku, old_price, new_price, old_moq, new_moq, currency,
|
||||
supplier_id, source_contract, confidence, changed_at, changed_by)
|
||||
VALUES (@sku, @old_price, @new_price, @old_moq, @new_moq, 'CNY',
|
||||
@supplier_id, '手工修改', 'high', @now, @changed_by)`
|
||||
).run({
|
||||
sku,
|
||||
old_price: existing.price,
|
||||
new_price: price,
|
||||
old_moq: existing.moq,
|
||||
new_moq: moq,
|
||||
supplier_id: existing.supplier_id ?? null,
|
||||
now,
|
||||
changed_by: actorOf(req)
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
if (Array.isArray(b.attributes)) replaceAttrs({ sku }, b.attributes);
|
||||
if (targetGroup !== oldGroup) cleanupGroupIfEmpty(oldGroup);
|
||||
|
||||
res.json({ ok: true, priceEditable, group_id: targetGroup });
|
||||
});
|
||||
|
||||
/** 删除货号(调价历史保留备查;所在产品变空则一并清理)。 */
|
||||
api.delete('/product/:sku', (req, res) => {
|
||||
const sku = req.params.sku.toUpperCase();
|
||||
const existing = db.prepare('SELECT * FROM products WHERE sku = ?').get(sku);
|
||||
if (!existing) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM item_attributes WHERE sku = ?').run(sku);
|
||||
db.prepare('DELETE FROM cart_items WHERE sku = ?').run(sku);
|
||||
// happy 特有:包装参数挂在外键上,产品没了必须先摘掉,否则删不动
|
||||
db.prepare('DELETE FROM product_packaging WHERE sku = ?').run(sku);
|
||||
db.prepare('DELETE FROM products WHERE sku = ?').run(sku);
|
||||
})();
|
||||
cleanupGroupIfEmpty(existing.group_id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 批量删除产品(整个 SPU 连同下挂货号)。 */
|
||||
api.post('/groups/delete', (req, res) => {
|
||||
const ids = (Array.isArray(req.body?.ids) ? req.body.ids : [])
|
||||
.map(Number)
|
||||
.filter((n) => Number.isInteger(n) && n > 0);
|
||||
if (!ids.length) return res.status(400).json({ error: '没有选中任何产品' });
|
||||
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
const imgIds = db.prepare(`SELECT id FROM product_images WHERE group_id IN (${ph})`).all(...ids).map((r) => r.id);
|
||||
|
||||
let groups = 0;
|
||||
let items = 0;
|
||||
db.transaction(() => {
|
||||
// 图片二进制跟着一起删,否则库里会积一堆没人引用的 BLOB,越攒越大
|
||||
catalog.deleteImagesFor(imgIds);
|
||||
const skus = db.prepare(`SELECT sku FROM products WHERE group_id IN (${ph})`).all(...ids);
|
||||
items = skus.length;
|
||||
if (skus.length) {
|
||||
const sph = skus.map(() => '?').join(',');
|
||||
const args = skus.map((r) => r.sku);
|
||||
db.prepare(`DELETE FROM item_attributes WHERE sku IN (${sph})`).run(...args);
|
||||
db.prepare(`DELETE FROM cart_items WHERE sku IN (${sph})`).run(...args);
|
||||
db.prepare(`DELETE FROM product_packaging WHERE sku IN (${sph})`).run(...args);
|
||||
db.prepare(`DELETE FROM products WHERE sku IN (${sph})`).run(...args);
|
||||
}
|
||||
db.prepare(`DELETE FROM item_attributes WHERE group_id IN (${ph})`).run(...ids);
|
||||
db.prepare(`DELETE FROM product_images WHERE group_id IN (${ph})`).run(...ids);
|
||||
groups = db.prepare(`DELETE FROM product_groups WHERE id IN (${ph})`).run(...ids).changes;
|
||||
})();
|
||||
|
||||
res.json({ ok: true, groups, items });
|
||||
});
|
||||
|
||||
/* ================================= 小推车 ================================= */
|
||||
|
||||
const CART_COLUMNS = `
|
||||
c.sku, c.qty, c.note, c.sort, c.added_at,
|
||||
p.display_sku, p.spec, p.price, p.currency, p.moq,
|
||||
p.description, p.confidence, p.last_updated,
|
||||
g.id AS group_id, g.name AS product_name, g.brand, g.category,
|
||||
s.name AS supplier_name, s.short_name AS supplier_short,
|
||||
(SELECT filename FROM product_images i WHERE i.group_id = g.id
|
||||
ORDER BY i.is_primary DESC, i.id ASC LIMIT 1) AS image`;
|
||||
|
||||
const cartRows = () => db.prepare(
|
||||
`SELECT ${CART_COLUMNS}
|
||||
FROM cart_items c
|
||||
JOIN products p ON p.sku = c.sku
|
||||
LEFT JOIN product_groups g ON g.id = p.group_id
|
||||
LEFT JOIN suppliers s ON s.id = p.supplier_id
|
||||
ORDER BY c.sort, c.id`
|
||||
).all();
|
||||
|
||||
api.get('/cart', (req, res) => res.json(cartRows()));
|
||||
|
||||
/**
|
||||
* 加入小推车。可以直接给货号,也可以给产品 id(该产品下所有货号一起进)。
|
||||
* 已在车里的不动 —— 重复点「加入」不该把用户排好的顺序打乱。
|
||||
*/
|
||||
api.post('/cart', (req, res) => {
|
||||
const skus = (Array.isArray(req.body?.skus) ? req.body.skus : []).map((s) => String(s).toUpperCase());
|
||||
const groupIds = (Array.isArray(req.body?.group_ids) ? req.body.group_ids : [])
|
||||
.map(Number)
|
||||
.filter((n) => Number.isInteger(n) && n > 0);
|
||||
|
||||
if (groupIds.length) {
|
||||
const ph = groupIds.map(() => '?').join(',');
|
||||
for (const r of db.prepare(`SELECT sku FROM products WHERE group_id IN (${ph}) ORDER BY display_sku`).all(...groupIds)) {
|
||||
skus.push(r.sku);
|
||||
}
|
||||
}
|
||||
if (!skus.length) return res.status(400).json({ error: '没有可加入的货号' });
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let added = 0;
|
||||
db.transaction(() => {
|
||||
let sort = db.prepare('SELECT COALESCE(MAX(sort), -1) AS m FROM cart_items').get().m;
|
||||
const exists = db.prepare('SELECT 1 FROM cart_items WHERE sku = ?');
|
||||
const real = db.prepare('SELECT 1 FROM products WHERE sku = ?');
|
||||
const ins = db.prepare('INSERT INTO cart_items (sku, sort, added_at) VALUES (@sku, @sort, @added_at)');
|
||||
for (const sku of skus) {
|
||||
if (exists.get(sku) || !real.get(sku)) continue;
|
||||
ins.run({ sku, sort: ++sort, added_at: now });
|
||||
added += 1;
|
||||
}
|
||||
})();
|
||||
|
||||
res.json({ ok: true, added, rows: cartRows() });
|
||||
});
|
||||
|
||||
api.patch('/cart/:sku', (req, res) => {
|
||||
const sku = req.params.sku.toUpperCase();
|
||||
if (!db.prepare('SELECT 1 FROM cart_items WHERE sku = ?').get(sku)) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const b = req.body || {};
|
||||
if ('qty' in b) {
|
||||
// 空/0 表示「没定数量」,出报价单时按未填处理,不要落成 0
|
||||
const n = Number(b.qty);
|
||||
db.prepare('UPDATE cart_items SET qty = ? WHERE sku = ?').run(Number.isFinite(n) && n > 0 ? Math.round(n) : null, sku);
|
||||
}
|
||||
if ('note' in b) {
|
||||
db.prepare('UPDATE cart_items SET note = ? WHERE sku = ?').run(String(b.note || '').trim() || null, sku);
|
||||
}
|
||||
res.json({ ok: true, rows: cartRows() });
|
||||
});
|
||||
|
||||
api.delete('/cart/:sku', (req, res) => {
|
||||
db.prepare('DELETE FROM cart_items WHERE sku = ?').run(req.params.sku.toUpperCase());
|
||||
res.json({ ok: true, rows: cartRows() });
|
||||
});
|
||||
|
||||
api.post('/cart/clear', (req, res) => {
|
||||
db.prepare('DELETE FROM cart_items').run();
|
||||
res.json({ ok: true, rows: [] });
|
||||
});
|
||||
|
||||
/** 拖拽排序落库。只认车里已有的货号,顺序按传入数组重排。 */
|
||||
api.put('/cart/order', (req, res) => {
|
||||
const skus = (Array.isArray(req.body?.skus) ? req.body.skus : []).map((s) => String(s).toUpperCase());
|
||||
db.transaction(() => {
|
||||
const upd = db.prepare('UPDATE cart_items SET sort = ? WHERE sku = ?');
|
||||
skus.forEach((sku, i) => upd.run(i, sku));
|
||||
})();
|
||||
res.json({ ok: true, rows: cartRows() });
|
||||
});
|
||||
|
||||
/* =============================== 参数与图片 =============================== */
|
||||
|
||||
/** 参数名自动补全 —— 输过一次的参数名,下次直接选。 */
|
||||
api.get('/attr-names', (req, res) => {
|
||||
const q = (req.query.q || '').trim();
|
||||
const rows = q
|
||||
? db.prepare('SELECT DISTINCT name FROM item_attributes WHERE name LIKE ? ORDER BY name LIMIT 30').all(`%${q}%`)
|
||||
: db.prepare('SELECT DISTINCT name FROM item_attributes ORDER BY name LIMIT 30').all();
|
||||
res.json(rows.map((r) => r.name));
|
||||
});
|
||||
|
||||
/**
|
||||
* 批量取货号缩略图。
|
||||
*
|
||||
* 「凡是出现货号的地方最前面都要有缩略图」,那些位置(询盘明细、报价明细、合同待复核、
|
||||
* 选品器)手里只有货号、没有图片字段 —— 逐个调 /api/sku/product/:sku 既慢又拿不到图,
|
||||
* 所以给一个一次问一批的入口。
|
||||
*
|
||||
* 图片挂在**产品组**上(product_images.group_id),先按货号找到组再取该组主图。
|
||||
* 大小写不敏感(客户端传来的货号大小写不一定规范)。单次上限 500,防止被塞超大数组。
|
||||
*/
|
||||
api.get('/thumbs', (req, res) => {
|
||||
const skus = [...new Set(String(req.query.skus || '').split(',').map((s) => s.trim()).filter(Boolean))].slice(0, 500);
|
||||
if (!skus.length) return res.json({ thumbs: {} });
|
||||
|
||||
const rows = db.prepare(
|
||||
`SELECT p.sku,
|
||||
(SELECT pi.filename FROM product_images pi
|
||||
WHERE pi.group_id = p.group_id AND pi.group_id IS NOT NULL
|
||||
ORDER BY pi.is_primary DESC, pi.id ASC LIMIT 1) AS thumb
|
||||
FROM products p
|
||||
WHERE p.sku IN (${skus.map(() => '?').join(',')}) COLLATE NOCASE`
|
||||
).all(...skus);
|
||||
|
||||
const thumbs = {};
|
||||
for (const row of rows) if (row.thumb) thumbs[row.sku] = row.thumb;
|
||||
res.json({ thumbs });
|
||||
});
|
||||
|
||||
api.post('/group/:id/images', imageUpload.array('images', 20), (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!db.prepare('SELECT id FROM product_groups WHERE id = ?').get(id)) return res.status(404).json({ error: 'not found' });
|
||||
if (!req.files || !req.files.length) {
|
||||
return res.status(400).json({ error: '没有收到图片(只支持 jpg/png/webp/gif/bmp)' });
|
||||
}
|
||||
|
||||
const existing = db.prepare('SELECT COUNT(*) AS n FROM product_images WHERE group_id = ?').get(id).n;
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO product_images (sku, group_id, filename, original_name, is_primary, created_at)
|
||||
VALUES ('', @group_id, @filename, @original_name, @is_primary, @created_at)`
|
||||
);
|
||||
|
||||
db.transaction(() => {
|
||||
req.files.forEach((f, i) => {
|
||||
const ext = path.extname(f.originalname).toLowerCase();
|
||||
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`;
|
||||
const info = insert.run({
|
||||
group_id: id,
|
||||
filename,
|
||||
original_name: f.originalname,
|
||||
is_primary: existing === 0 && i === 0 ? 1 : 0,
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
// 二进制直接进库;文件本来就在内存里,磁盘上不留明文副本
|
||||
catalog.storeImage(info.lastInsertRowid, f.buffer);
|
||||
});
|
||||
})();
|
||||
|
||||
res.json({
|
||||
images: db.prepare('SELECT * FROM product_images WHERE group_id = ? ORDER BY is_primary DESC, id ASC').all(id)
|
||||
});
|
||||
});
|
||||
|
||||
api.post('/images/:id/primary', (req, res) => {
|
||||
const img = db.prepare('SELECT * FROM product_images WHERE id = ?').get(Number(req.params.id));
|
||||
if (!img) return res.status(404).json({ error: 'not found' });
|
||||
db.transaction(() => {
|
||||
db.prepare('UPDATE product_images SET is_primary = 0 WHERE group_id = ?').run(img.group_id);
|
||||
db.prepare('UPDATE product_images SET is_primary = 1 WHERE id = ?').run(img.id);
|
||||
})();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
api.delete('/images/:id', (req, res) => {
|
||||
const img = db.prepare('SELECT * FROM product_images WHERE id = ?').get(Number(req.params.id));
|
||||
if (!img) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
db.prepare('DELETE FROM product_images WHERE id = ?').run(img.id);
|
||||
// 删掉记录后把封面顺延给剩下的第一张
|
||||
if (img.is_primary) {
|
||||
const next = db.prepare('SELECT id FROM product_images WHERE group_id = ? ORDER BY id ASC LIMIT 1').get(img.group_id);
|
||||
if (next) db.prepare('UPDATE product_images SET is_primary = 1 WHERE id = ?').run(next.id);
|
||||
}
|
||||
catalog.deleteImage(img.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 下载合同原件(原件只存在库里,磁盘上没有)。 */
|
||||
api.get('/contracts/:id/file', (req, res) => {
|
||||
const f = getContractFile(Number(req.params.id));
|
||||
if (!f) return res.status(404).json({ error: '库里没有这份合同的原件' });
|
||||
|
||||
res.setHeader('Content-Type', f.mime || 'application/octet-stream');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="contract"; filename*=UTF-8''${encodeURIComponent(f.filename)}`
|
||||
);
|
||||
res.end(f.bytes);
|
||||
});
|
||||
|
||||
/* ================================== 统计 ================================== */
|
||||
|
||||
api.get('/stats', (req, res) => {
|
||||
res.json(db.prepare(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM product_groups) AS total_groups,
|
||||
(SELECT COUNT(*) FROM products) AS total_skus,
|
||||
(SELECT COUNT(*) FROM products WHERE confidence = 'low') AS low_confidence,
|
||||
(SELECT COUNT(*) FROM suppliers) AS total_suppliers,
|
||||
(SELECT COUNT(*) FROM contracts) AS contracts_processed`
|
||||
).get());
|
||||
});
|
||||
|
||||
/* ================================= 供应商 ================================= */
|
||||
|
||||
api.get('/suppliers', (req, res) => {
|
||||
res.json(db.prepare(
|
||||
`SELECT s.*,
|
||||
(SELECT COUNT(*) FROM product_groups g WHERE g.supplier_id = s.id) AS group_count,
|
||||
(SELECT COUNT(*) FROM products p WHERE p.supplier_id = s.id) AS sku_count
|
||||
FROM suppliers s ORDER BY s.name`
|
||||
).all());
|
||||
});
|
||||
|
||||
/** 没归属供应商的产品数 —— 手工建的货常常一时定不下供应商。 */
|
||||
api.get('/suppliers/unassigned/count', (req, res) => {
|
||||
res.json(db.prepare('SELECT COUNT(*) AS count FROM product_groups WHERE supplier_id IS NULL').get());
|
||||
});
|
||||
|
||||
api.get('/suppliers/:id', (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const supplier = db.prepare('SELECT * FROM suppliers WHERE id = ?').get(id);
|
||||
if (!supplier) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
res.json({
|
||||
supplier,
|
||||
products: db.prepare(
|
||||
`SELECT p.*, g.name AS group_name,
|
||||
(SELECT filename FROM product_images i WHERE i.group_id = p.group_id
|
||||
ORDER BY i.is_primary DESC, i.id ASC LIMIT 1) AS thumb
|
||||
FROM products p LEFT JOIN product_groups g ON g.id = p.group_id
|
||||
WHERE p.supplier_id = ? ORDER BY p.last_updated DESC LIMIT 500`
|
||||
).all(id),
|
||||
contracts: db.prepare('SELECT * FROM contracts WHERE supplier_id = ? ORDER BY processed_at DESC LIMIT 50').all(id)
|
||||
});
|
||||
});
|
||||
|
||||
const SUPPLIER_FIELDS = [
|
||||
'name', 'short_name', 'contact_person', 'phone',
|
||||
'email', 'address', 'website', 'payment_terms', 'main_categories', 'notes'
|
||||
];
|
||||
|
||||
const supplierPayload = (body) => {
|
||||
const out = {};
|
||||
for (const f of SUPPLIER_FIELDS) out[f] = body?.[f] ?? null;
|
||||
return out;
|
||||
};
|
||||
|
||||
api.post('/suppliers', (req, res) => {
|
||||
const data = supplierPayload(req.body);
|
||||
if (!data.name) return res.status(400).json({ error: '供应商名称不能为空' });
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const info = db.prepare(
|
||||
`INSERT INTO suppliers (name, short_name, contact_person, phone, email, address, website,
|
||||
payment_terms, main_categories, notes, created_at, updated_at)
|
||||
VALUES (@name, @short_name, @contact_person, @phone, @email, @address, @website,
|
||||
@payment_terms, @main_categories, @notes, @created_at, @updated_at)`
|
||||
).run({ ...data, created_at: now, updated_at: now });
|
||||
res.json({ id: info.lastInsertRowid });
|
||||
} catch (err) {
|
||||
if (String(err.message).includes('UNIQUE')) return res.status(409).json({ error: '该供应商名称已存在' });
|
||||
fail(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
api.put('/suppliers/:id', (req, res) => {
|
||||
const data = supplierPayload(req.body);
|
||||
if (!data.name) return res.status(400).json({ error: '供应商名称不能为空' });
|
||||
try {
|
||||
db.prepare(
|
||||
`UPDATE suppliers SET name=@name, short_name=@short_name, contact_person=@contact_person,
|
||||
phone=@phone, email=@email, address=@address, website=@website,
|
||||
payment_terms=@payment_terms, main_categories=@main_categories, notes=@notes,
|
||||
updated_at=@updated_at WHERE id=@id`
|
||||
).run({ ...data, id: Number(req.params.id), updated_at: new Date().toISOString() });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (String(err.message).includes('UNIQUE')) return res.status(409).json({ error: '该供应商名称已存在' });
|
||||
fail(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
api.delete('/suppliers/:id', (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
// 产品保留原价,只是变成「未指派供应商」,而不是跟着被删
|
||||
db.transaction(() => {
|
||||
db.prepare('UPDATE products SET supplier_id = NULL WHERE supplier_id = ?').run(id);
|
||||
db.prepare('UPDATE product_groups SET supplier_id = NULL WHERE supplier_id = ?').run(id);
|
||||
db.prepare('DELETE FROM suppliers WHERE id = ?').run(id);
|
||||
})();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/* ============================== 产品图片文件 ============================== */
|
||||
|
||||
/**
|
||||
* /product-images/<文件名>。
|
||||
*
|
||||
* 图片存在库里(product_image_files),按文件名去查而不是拼磁盘路径 ——
|
||||
* 顺带也就没有路径穿越这回事了。
|
||||
*
|
||||
* 这里要求登录:产品图属于商业数据,原先挂在裸 static 上等于「猜到文件名就能拿图」。
|
||||
* 页面里的 <img> 是同源请求,会带上会话 cookie,所以登录后照常显示。
|
||||
*/
|
||||
images.get('/:filename', (req, res) => {
|
||||
if (!req.user) return res.status(401).end();
|
||||
|
||||
const name = req.params.filename;
|
||||
const img = db.prepare('SELECT id FROM product_images WHERE filename = ?').get(name);
|
||||
if (!img) return res.status(404).end();
|
||||
|
||||
const blob = catalog.getImage(img.id);
|
||||
if (!blob) return res.status(404).end();
|
||||
|
||||
res.setHeader('Content-Type', catalog.mimeFor(name));
|
||||
// 文件名带时间戳+随机串,内容不会变,可以让浏览器长缓存。
|
||||
// private:机密数据,不许中间代理留副本。
|
||||
res.setHeader('Cache-Control', 'private, max-age=604800');
|
||||
res.end(blob.bytes);
|
||||
});
|
||||
|
||||
module.exports = { api, images };
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
const express = require('express');
|
||||
const updater = require('../lib/updater');
|
||||
const branding = require('../lib/branding');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -45,4 +46,17 @@ router.get('/check-update', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/system/branding
|
||||
* 品牌与组织名。公开接口 —— 登录页在拿到会话之前就得知道系统叫什么。
|
||||
* 只回显示用的名字,不含任何业务数据。
|
||||
*/
|
||||
router.get('/branding', (req, res) => {
|
||||
try {
|
||||
res.json(branding.getBranding());
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user