feat: 多用户权限体系、AI 助手、SKU 目录与合同清洗(开源准备)

本地长期开发成果的整体入库快照,包含后端鉴权/权限、AI 接入、合同解析、
SKU 目录等模块,以及前端相应页面与样式改版。

后端
- 新增鉴权与会话:scrypt 密码哈希、HttpOnly cookie(happy_sid)、登录失败锁定
- 新增「角色 → 权限组 → 权限点」三级权限模型与审计日志
- 新增 AI 提供商接入与预设(兼容 OpenAI 协议,内置 DeepSeek / 通义千问等)
- 新增合同解析、合同文件保留策略、客户归属与访问控制
- 新增 SKU 目录、组织品牌、新客开发、系统日志等路由

前端
- 新增登录页与用户 / 权限组 / 系统日志管理页
- 新增客户详情、客户公海、询盘详情、报价编辑器、合同清洗中心
- 新增 AI 配置、组织品牌、包装参数、SKU 选择器等组件
- 新增英文大写金额、未保存改动拦截、抽屉与骨架屏
- 新增 stage1 样式层;产品、供应商、订单等页面改版
- EmbeddedSku 页面并入现有产品体系后删除

开源准备
- 新增 AGPL-3.0 LICENSE
- README:移除未启用的 SQLCipher 加密声明(改为如实的「已知限制」)、
  补充环境变量说明、许可证由「私有项目」改为 AGPL-3.0
- 移除历史遗留的空文件 _index_backup.txt
- 文档中的项目路径更新为 happy-export
This commit is contained in:
Drsakura
2026-09-17 14:53:02 +08:00
parent 436519e0a6
commit 13d67c804f
77 changed files with 18407 additions and 955 deletions
+464
View File
@@ -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;
+212
View File
@@ -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;
+182
View File
@@ -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;
+431
View File
@@ -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;
+210
View File
@@ -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;
+113
View File
@@ -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;
+721
View File
@@ -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 };
+14
View File
@@ -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;