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,195 @@
|
||||
/**
|
||||
* 常用大模型接口预设(单一事实来源)。
|
||||
*
|
||||
* 口径(Wayne 2026-09-15):
|
||||
* - 别人部署这套系统后,应该「选一个预设 → 填 key → 测通 → 拉模型 → 分配用途」四步搞定,
|
||||
* 而不是让他自己去查 base_url 和兼容模式;
|
||||
* - 预设只是**便利清单**,不是白名单:所有字段都能手改(自定义预设就是空模板);
|
||||
* - 模型清单同理:能拉就拉真实的,拉不到就用这里的推荐值兜底。
|
||||
*
|
||||
* mode 的含义(= 界面上的「接口兼容模式」):
|
||||
* openai 走 OpenAI 风格 `POST {base}/chat/completions` + `GET {base}/models`
|
||||
* anthropic 走 Claude Messages API `POST {base}/messages` + `GET {base}/models`
|
||||
*/
|
||||
|
||||
/**
|
||||
* 两种协议的 Base URL 写法**不一样**,界面上的示例要分别给(Wayne 2026-09-15):
|
||||
* 本系统是「Base URL + 固定后缀」的拼法,所以用户填的东西必须写到能接后缀的那一层:
|
||||
* openai → {base}/chat/completions、{base}/models
|
||||
* anthropic → {base}/messages、{base}/models
|
||||
* 最容易踩的坑是照抄 Anthropic SDK 文档里的 base_url —— SDK 会自己补 /v1,本系统不补。
|
||||
*/
|
||||
const MODES = [
|
||||
{
|
||||
value: 'openai',
|
||||
label: 'OpenAI 兼容',
|
||||
hint: '大多数国内厂商与本地推理服务都兼容这套路由',
|
||||
base_placeholder: 'https://api.deepseek.com/v1',
|
||||
base_suffix: '/chat/completions',
|
||||
/* 界面上的示例说明:写清楚「填到哪一层」+「结尾长什么样因厂商而异」 */
|
||||
base_hint: '只填到服务根路径,系统会在后面自动拼 /chat/completions 与 /models。'
|
||||
+ '结尾不一定是 /v1 —— DeepSeek、Kimi 是 /v1,火山方舟是 /api/v3,智谱是 /api/paas/v4,照服务商文档填即可。',
|
||||
base_examples: [
|
||||
{ label: 'DeepSeek', url: 'https://api.deepseek.com/v1' },
|
||||
{ label: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1' },
|
||||
{ label: '火山方舟', url: 'https://ark.cn-beijing.volces.com/api/v3' }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: 'anthropic',
|
||||
label: 'Anthropic Messages',
|
||||
hint: 'Claude 原生协议的 /messages 路由',
|
||||
base_placeholder: 'https://api.anthropic.com/v1',
|
||||
base_suffix: '/messages',
|
||||
/* ⚠️ 这条是本轮要讲清楚的区别:SDK 文档的 base_url 少了 /v1,直接抄会 404 */
|
||||
base_hint: '填的这一层后面会拼 /messages(不是 /chat/completions),所以必须带上 /v1:'
|
||||
+ '官方是 https://api.anthropic.com/v1,Kimi 是 https://api.moonshot.cn/anthropic/v1,'
|
||||
+ '智谱是 https://open.bigmodel.cn/api/anthropic/v1。'
|
||||
+ '注意各家 SDK 文档里的 base_url 通常写到 /anthropic 为止(由 SDK 补 /v1),本系统不补,别直接照抄。',
|
||||
base_examples: [
|
||||
{ label: 'Claude 官方', url: 'https://api.anthropic.com/v1' },
|
||||
{ label: 'Kimi', url: 'https://api.moonshot.cn/anthropic/v1' },
|
||||
{ label: '智谱 GLM', url: 'https://open.bigmodel.cn/api/anthropic/v1' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
/** 模型用途:一个用途 = 系统里一处需要智能能力的地方,各自可挂不同模型 */
|
||||
const PURPOSES = [
|
||||
{ key: 'assistant_chat', label: '智能助手对话', hint: '右上角小皮的问答与指令理解', vision: false },
|
||||
{ key: 'contract_parse', label: '合同智能解析', hint: '合同清洗中心抽取品名、价格、账期等字段', vision: false },
|
||||
{ key: 'ocr', label: '图片 / 扫描件识别', hint: 'PDF 扫描件与图片资料,需要多模态模型', vision: true },
|
||||
{ key: 'customer_profile', label: '客户画像分析', hint: '辅助判断客户跟进优先级', vision: false },
|
||||
{ key: 'quote_suggest', label: '报价建议', hint: '基于历史成交价给出报价参考', vision: false }
|
||||
]
|
||||
|
||||
const PRESETS = [
|
||||
{
|
||||
key: 'openai',
|
||||
label: 'OpenAI',
|
||||
base_url: 'https://api.openai.com/v1',
|
||||
mode: 'openai',
|
||||
key_hint: 'sk-...',
|
||||
docs: 'https://platform.openai.com/api-keys',
|
||||
note: '官方直连,国内网络通常需要自备代理',
|
||||
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini', 'o4-mini']
|
||||
},
|
||||
{
|
||||
key: 'anthropic',
|
||||
label: 'Anthropic Claude',
|
||||
base_url: 'https://api.anthropic.com/v1',
|
||||
mode: 'anthropic',
|
||||
key_hint: 'sk-ant-...',
|
||||
docs: 'https://console.anthropic.com/settings/keys',
|
||||
note: '长文档与合同条款理解表现好',
|
||||
models: ['claude-sonnet-4-5', 'claude-opus-4-1', 'claude-3-5-haiku-latest']
|
||||
},
|
||||
{
|
||||
/* 和下面的 Kimi(OpenAI 兼容)是两个不同的地址,放一起就是为了让对比一眼可见 */
|
||||
key: 'moonshot-anthropic',
|
||||
label: '月之暗面 Kimi · Anthropic 协议',
|
||||
base_url: 'https://api.moonshot.cn/anthropic/v1',
|
||||
mode: 'anthropic',
|
||||
key_hint: 'sk-...',
|
||||
docs: 'https://platform.moonshot.cn/console/api-keys',
|
||||
note: '同一个 Kimi key,但地址与下面那条「Kimi」不一样:这条是 /anthropic/v1(Claude 协议),'
|
||||
+ '那条是 /v1(OpenAI 协议),填错会 404',
|
||||
models: ['kimi-k2-0905-preview', 'moonshot-v1-128k']
|
||||
},
|
||||
{
|
||||
key: 'zhipu-anthropic',
|
||||
label: '智谱 GLM · Anthropic 协议',
|
||||
base_url: 'https://open.bigmodel.cn/api/anthropic/v1',
|
||||
mode: 'anthropic',
|
||||
key_hint: 'xxxxx.xxxxx',
|
||||
docs: 'https://open.bigmodel.cn/usercenter/apikeys',
|
||||
note: '同一家的 Anthropic 兼容入口是 /api/anthropic/v1,OpenAI 兼容是 /api/paas/v4',
|
||||
models: ['glm-4-plus', 'glm-4-air']
|
||||
},
|
||||
{
|
||||
key: 'deepseek',
|
||||
label: 'DeepSeek 深度求索',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
mode: 'openai',
|
||||
key_hint: 'sk-...',
|
||||
docs: 'https://platform.deepseek.com/api_keys',
|
||||
note: '性价比高,中文业务理解稳定',
|
||||
models: ['deepseek-chat', 'deepseek-reasoner']
|
||||
},
|
||||
{
|
||||
key: 'moonshot',
|
||||
label: '月之暗面 Kimi',
|
||||
base_url: 'https://api.moonshot.cn/v1',
|
||||
mode: 'openai',
|
||||
key_hint: 'sk-...',
|
||||
docs: 'https://platform.moonshot.cn/console/api-keys',
|
||||
note: '长上下文,适合整份合同通读',
|
||||
models: ['kimi-k2-0905-preview', 'moonshot-v1-32k', 'moonshot-v1-128k']
|
||||
},
|
||||
{
|
||||
key: 'qwen',
|
||||
label: '阿里通义千问(DashScope 兼容模式)',
|
||||
base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
mode: 'openai',
|
||||
key_hint: 'sk-...',
|
||||
docs: 'https://bailian.console.aliyun.com/',
|
||||
note: 'qwen-vl 系列可用于图片识别',
|
||||
models: ['qwen-max', 'qwen-plus', 'qwen-turbo', 'qwen-vl-max']
|
||||
},
|
||||
{
|
||||
key: 'zhipu',
|
||||
label: '智谱 GLM',
|
||||
base_url: 'https://open.bigmodel.cn/api/paas/v4',
|
||||
mode: 'openai',
|
||||
key_hint: 'xxxxx.xxxxx',
|
||||
docs: 'https://open.bigmodel.cn/usercenter/apikeys',
|
||||
note: 'glm-4v 支持图片输入',
|
||||
models: ['glm-4-plus', 'glm-4-air', 'glm-4-flash', 'glm-4v-plus']
|
||||
},
|
||||
{
|
||||
key: 'doubao',
|
||||
label: '火山方舟(豆包)',
|
||||
base_url: 'https://ark.cn-beijing.volces.com/api/v3',
|
||||
mode: 'openai',
|
||||
key_hint: '火山方舟 API Key',
|
||||
docs: 'https://console.volcengine.com/ark',
|
||||
note: '模型名要填方舟上的「推理接入点 ID」(ep-开头),或直接填模型名',
|
||||
models: []
|
||||
},
|
||||
{
|
||||
key: 'openrouter',
|
||||
label: 'OpenRouter(聚合网关)',
|
||||
base_url: 'https://openrouter.ai/api/v1',
|
||||
mode: 'openai',
|
||||
key_hint: 'sk-or-...',
|
||||
docs: 'https://openrouter.ai/keys',
|
||||
note: '一个 key 打通多家模型,适合先试后定',
|
||||
models: []
|
||||
},
|
||||
{
|
||||
key: 'ollama',
|
||||
label: '本地 Ollama',
|
||||
base_url: 'http://127.0.0.1:11434/v1',
|
||||
mode: 'openai',
|
||||
key_hint: '本地服务通常留空',
|
||||
docs: 'https://ollama.com/download',
|
||||
note: '完全离线,数据不出本机;密钥可留空',
|
||||
needs_key: false,
|
||||
models: ['qwen2.5:7b', 'qwen2.5:14b', 'llama3.1:8b', 'deepseek-r1:7b']
|
||||
},
|
||||
{
|
||||
key: 'custom',
|
||||
label: '自定义 / 私有部署',
|
||||
base_url: '',
|
||||
mode: 'openai',
|
||||
key_hint: '按你的网关要求填写',
|
||||
docs: '',
|
||||
note: '任何兼容 OpenAI 或 Anthropic 协议的网关都可以接',
|
||||
models: []
|
||||
}
|
||||
]
|
||||
|
||||
const PRESET_MAP = new Map(PRESETS.map(item => [item.key, item]));
|
||||
const PURPOSE_KEYS = PURPOSES.map(item => item.key);
|
||||
|
||||
module.exports = { MODES, PRESETS, PRESET_MAP, PURPOSES, PURPOSE_KEYS };
|
||||
@@ -0,0 +1,487 @@
|
||||
/**
|
||||
* 多模型接入:模型服务商的存取、连通性测试、模型清单拉取与按用途调用。
|
||||
*
|
||||
* 存储(settings 键值表,跟权限点台账同一张表):
|
||||
* ai.providers JSON 数组,一家服务商一条
|
||||
* ai.assignments JSON 对象,用途 → { provider_id, model }
|
||||
*
|
||||
* 安全口径:
|
||||
* - api_key 明文存在本机 SQLite 里,**永远不出现在任何 HTTP 响应中**(一律打码);
|
||||
* - 上游返回的错误文本会先过一遍脱敏,避免把 key 回显给浏览器。
|
||||
*
|
||||
* 这里只依赖 Node 内置 fetch(Node 18+),不引第三方 SDK —— 各家协议就两种,
|
||||
* 引 SDK 反而会让「换个私有网关」变得麻烦。
|
||||
*/
|
||||
|
||||
const db = require('../db/db');
|
||||
const { PRESET_MAP, PURPOSE_KEYS } = require('./aiPresets');
|
||||
|
||||
const KEY_PROVIDERS = 'ai.providers';
|
||||
const KEY_ASSIGNMENTS = 'ai.assignments';
|
||||
|
||||
const TEST_TIMEOUT_MS = 12000;
|
||||
const CHAT_TIMEOUT_MS = 90000;
|
||||
|
||||
/* ------------------------------------------------------------------ 存取 */
|
||||
|
||||
function readJson(key, fallback) {
|
||||
try {
|
||||
const raw = db.prepare('SELECT value FROM settings WHERE key = ?').get(key)?.value;
|
||||
if (!raw) return fallback;
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(key, value) {
|
||||
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
function listProviders() {
|
||||
const list = readJson(KEY_PROVIDERS, []);
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
function saveProviders(list) {
|
||||
writeJson(KEY_PROVIDERS, list);
|
||||
}
|
||||
|
||||
function getProvider(id) {
|
||||
return listProviders().find(item => item.id === String(id)) || null;
|
||||
}
|
||||
|
||||
function listAssignments() {
|
||||
const map = readJson(KEY_ASSIGNMENTS, {});
|
||||
return map && typeof map === 'object' && !Array.isArray(map) ? map : {};
|
||||
}
|
||||
|
||||
function saveAssignments(map) {
|
||||
writeJson(KEY_ASSIGNMENTS, map);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 打码 */
|
||||
|
||||
function maskKey(key) {
|
||||
const value = String(key || '');
|
||||
if (!value) return '';
|
||||
if (value.length <= 8) return '••••';
|
||||
return `${value.slice(0, 4)}••••${value.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** 把上游报错里的密钥抹掉再往外抛 —— 有些网关会把请求头原样回显 */
|
||||
function sanitize(text, key) {
|
||||
let out = String(text || '');
|
||||
if (key) out = out.split(String(key)).join('••••');
|
||||
return out.slice(0, 600);
|
||||
}
|
||||
|
||||
/** 对外输出:密钥只给「有没有」和「长什么样」,绝不给原值 */
|
||||
function publicProvider(provider) {
|
||||
const preset = PRESET_MAP.get(provider.preset) || null;
|
||||
return {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
preset: provider.preset,
|
||||
preset_label: preset?.label || '自定义',
|
||||
base_url: provider.base_url,
|
||||
mode: provider.mode,
|
||||
models: Array.isArray(provider.models) ? provider.models : [],
|
||||
enabled: provider.enabled !== false,
|
||||
note: provider.note || '',
|
||||
has_key: Boolean(provider.api_key),
|
||||
key_masked: maskKey(provider.api_key),
|
||||
last_test: provider.last_test || null,
|
||||
created_at: provider.created_at || null,
|
||||
updated_at: provider.updated_at || null
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 写入 */
|
||||
|
||||
function badRequest(message) {
|
||||
return Object.assign(new Error(message), { statusCode: 400 });
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(input) {
|
||||
const value = String(input || '').trim().replace(/\/+$/, '');
|
||||
if (!value) throw badRequest('接口地址不能为空');
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw badRequest('接口地址格式不正确,需要以 http:// 或 https:// 开头');
|
||||
}
|
||||
if (!/^https?:$/.test(url.protocol)) throw badRequest('接口地址只支持 http 与 https');
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeMode(input) {
|
||||
const value = String(input || 'openai').trim();
|
||||
if (!['openai', 'anthropic'].includes(value)) throw badRequest('接口兼容模式只能是 openai 或 anthropic');
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeModels(input) {
|
||||
if (input === undefined) return undefined;
|
||||
if (!Array.isArray(input)) throw badRequest('模型清单格式不正确');
|
||||
return [...new Set(input.map(item => String(item).trim()).filter(Boolean))].slice(0, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建 / 更新一家服务商。
|
||||
* api_key 的三种输入:
|
||||
* 省略或传 undefined → 保持原值(编辑时前端不回传明文)
|
||||
* 传空串 → 清空密钥(本地 Ollama 这类不需要密钥)
|
||||
* 传新值 → 覆盖
|
||||
*/
|
||||
function upsertProvider(input, id) {
|
||||
const list = listProviders();
|
||||
const now = new Date().toISOString();
|
||||
const existing = id ? list.find(item => item.id === String(id)) : null;
|
||||
if (id && !existing) throw badRequest('该模型接入不存在');
|
||||
|
||||
const presetKey = String(input.preset || existing?.preset || 'custom').trim();
|
||||
const preset = PRESET_MAP.get(presetKey) || null;
|
||||
const mode = normalizeMode(input.mode || existing?.mode || preset?.mode || 'openai');
|
||||
const baseUrl = input.base_url === undefined && !existing
|
||||
? normalizeBaseUrl(preset?.base_url)
|
||||
: normalizeBaseUrl(input.base_url === undefined ? existing.base_url : input.base_url);
|
||||
|
||||
const name = String(input.name ?? existing?.name ?? preset?.label ?? '未命名接入').trim();
|
||||
if (!name) throw badRequest('接入名称不能为空');
|
||||
if (name.length > 40) throw badRequest('接入名称不能超过 40 个字');
|
||||
|
||||
let apiKey = existing?.api_key || '';
|
||||
if (input.api_key !== undefined) {
|
||||
const value = String(input.api_key ?? '').trim();
|
||||
/* 前端拿到的是打码值,万一被原样回传就当没改 */
|
||||
apiKey = value.includes('••••') ? apiKey : value;
|
||||
}
|
||||
|
||||
const models = normalizeModels(input.models);
|
||||
|
||||
const next = {
|
||||
id: existing?.id || `p_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
|
||||
name,
|
||||
preset: preset?.key || 'custom',
|
||||
base_url: baseUrl,
|
||||
mode,
|
||||
api_key: apiKey,
|
||||
models: models === undefined ? (existing?.models || preset?.models || []) : models,
|
||||
enabled: input.enabled === undefined ? (existing?.enabled !== false) : Boolean(input.enabled),
|
||||
note: String(input.note ?? existing?.note ?? '').trim().slice(0, 200),
|
||||
last_test: existing?.last_test || null,
|
||||
created_at: existing?.created_at || now,
|
||||
updated_at: now
|
||||
};
|
||||
|
||||
const nextList = existing
|
||||
? list.map(item => (item.id === existing.id ? next : item))
|
||||
: [...list, next];
|
||||
saveProviders(nextList);
|
||||
return next;
|
||||
}
|
||||
|
||||
function removeProvider(id) {
|
||||
const list = listProviders();
|
||||
const hit = list.find(item => item.id === String(id));
|
||||
if (!hit) throw badRequest('该模型接入不存在');
|
||||
saveProviders(list.filter(item => item.id !== hit.id));
|
||||
/* 引用它的用途分配一并清掉,避免留下指向空气的配置 */
|
||||
const assignments = listAssignments();
|
||||
let touched = 0;
|
||||
for (const purpose of Object.keys(assignments)) {
|
||||
if (assignments[purpose]?.provider_id === hit.id) {
|
||||
delete assignments[purpose];
|
||||
touched += 1;
|
||||
}
|
||||
}
|
||||
if (touched) saveAssignments(assignments);
|
||||
return { removed: publicProvider(hit), cleared_assignments: touched };
|
||||
}
|
||||
|
||||
function rememberTest(id, result) {
|
||||
const list = listProviders();
|
||||
const next = list.map(item => (item.id === String(id)
|
||||
? { ...item, last_test: result, updated_at: new Date().toISOString() }
|
||||
: item));
|
||||
saveProviders(next);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 调用 */
|
||||
|
||||
/** 统一的 HTTP 出口:超时、错误文本脱敏、JSON 解析兜底都在这里 */
|
||||
async function requestJson(url, { method = 'POST', headers = {}, body, timeoutMs = TEST_TIMEOUT_MS, apiKey = '' } = {}) {
|
||||
const started = Date.now();
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeoutMs)
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = error?.name === 'TimeoutError'
|
||||
? `请求超时(${Math.round(timeoutMs / 1000)} 秒)`
|
||||
: `无法连接:${sanitize(error?.cause?.code || error?.message || '网络错误', apiKey)}`;
|
||||
return { ok: false, status: 0, latencyMs: Date.now() - started, error: reason };
|
||||
}
|
||||
|
||||
const latencyMs = Date.now() - started;
|
||||
const text = await response.text().catch(() => '');
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const upstream = data?.error?.message || data?.message || data?.error || text || `HTTP ${response.status}`;
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
latencyMs,
|
||||
error: sanitize(typeof upstream === 'string' ? upstream : JSON.stringify(upstream), apiKey),
|
||||
data
|
||||
};
|
||||
}
|
||||
return { ok: true, status: response.status, latencyMs, data, text };
|
||||
}
|
||||
|
||||
function buildHeaders(provider, extra = {}) {
|
||||
const headers = { 'content-type': 'application/json', ...extra };
|
||||
const key = provider.api_key;
|
||||
if (provider.mode === 'anthropic') {
|
||||
headers['anthropic-version'] = '2023-06-01';
|
||||
if (key) headers['x-api-key'] = key;
|
||||
} else if (key) {
|
||||
headers.authorization = `Bearer ${key}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** 探测用的最小对话请求:一条 user 消息、只要 1 个 token */
|
||||
function probeBody(provider, model) {
|
||||
if (provider.mode === 'anthropic') {
|
||||
return { model, max_tokens: 16, messages: [{ role: 'user', content: 'ping' }] };
|
||||
}
|
||||
return { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 16, temperature: 0, stream: false };
|
||||
}
|
||||
|
||||
function chatPath(provider) {
|
||||
return provider.mode === 'anthropic'
|
||||
? `${provider.base_url}/messages`
|
||||
: `${provider.base_url}/chat/completions`;
|
||||
}
|
||||
|
||||
/** 从各家返回体里把正文抠出来 */
|
||||
function extractChatText(provider, data) {
|
||||
if (!data) return '';
|
||||
if (provider.mode === 'anthropic') {
|
||||
const blocks = Array.isArray(data.content) ? data.content : [];
|
||||
return blocks.filter(block => block?.type === 'text').map(block => block.text).join('').trim();
|
||||
}
|
||||
const message = data.choices?.[0]?.message;
|
||||
if (!message) return '';
|
||||
if (typeof message.content === 'string') return message.content.trim();
|
||||
/* 有些网关把 content 拆成数组(含 reasoning 之类),这里只取文本段 */
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content.filter(part => part?.type === 'text' || typeof part === 'string')
|
||||
.map(part => (typeof part === 'string' ? part : part.text)).join('').trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 连通性测试。
|
||||
* 策略:优先发一条最小对话(最能说明「这个 key 能不能真的用」);
|
||||
* 若该模型不可用再退一步只拉模型清单。
|
||||
*/
|
||||
async function testProvider(provider) {
|
||||
const model = (provider.models || [])[0];
|
||||
if (!model) {
|
||||
const models = await fetchModels(provider);
|
||||
if (!models.ok) return { ok: false, at: new Date().toISOString(), latency_ms: models.latencyMs, detail: models.error, mode: provider.mode };
|
||||
return {
|
||||
ok: true,
|
||||
at: new Date().toISOString(),
|
||||
latency_ms: models.latencyMs,
|
||||
detail: `接口可访问,读到 ${models.models.length} 个模型(未配置模型名,跳过对话测试)`,
|
||||
mode: provider.mode
|
||||
};
|
||||
}
|
||||
|
||||
const result = await requestJson(chatPath(provider), {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(provider),
|
||||
body: probeBody(provider, model),
|
||||
timeoutMs: TEST_TIMEOUT_MS,
|
||||
apiKey: provider.api_key
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
at: new Date().toISOString(),
|
||||
latency_ms: result.latencyMs,
|
||||
detail: `模型 ${model} 调用失败:${result.error}`,
|
||||
mode: provider.mode,
|
||||
status: result.status
|
||||
};
|
||||
}
|
||||
const text = extractChatText(provider, result.data);
|
||||
return {
|
||||
ok: true,
|
||||
at: new Date().toISOString(),
|
||||
latency_ms: result.latencyMs,
|
||||
detail: `模型 ${model} 响应正常${text ? `(返回 ${text.slice(0, 20)})` : ''}`,
|
||||
mode: provider.mode,
|
||||
model
|
||||
};
|
||||
}
|
||||
|
||||
/** 拉模型清单;协议不支持或没权限时回退到预设里的推荐清单 */
|
||||
async function fetchModels(provider) {
|
||||
const result = await requestJson(`${provider.base_url}/models`, {
|
||||
method: 'GET',
|
||||
headers: buildHeaders(provider),
|
||||
timeoutMs: TEST_TIMEOUT_MS,
|
||||
apiKey: provider.api_key
|
||||
});
|
||||
|
||||
const preset = PRESET_MAP.get(provider.preset);
|
||||
if (!result.ok) {
|
||||
const fallback = preset?.models || [];
|
||||
if (fallback.length) {
|
||||
return {
|
||||
ok: true,
|
||||
source: 'preset',
|
||||
latencyMs: result.latencyMs,
|
||||
models: fallback,
|
||||
detail: `该服务未开放模型列表接口(${result.error}),已回退到预设推荐清单`
|
||||
};
|
||||
}
|
||||
return { ok: false, source: 'none', latencyMs: result.latencyMs, models: [], error: result.error };
|
||||
}
|
||||
|
||||
const rows = Array.isArray(result.data?.data) ? result.data.data
|
||||
: Array.isArray(result.data?.models) ? result.data.models
|
||||
: Array.isArray(result.data) ? result.data : [];
|
||||
const models = [...new Set(rows.map(row => String(row?.id || row?.name || row || '').trim()).filter(Boolean))];
|
||||
if (models.length) return { ok: true, source: 'api', latencyMs: result.latencyMs, models, detail: `读取到 ${models.length} 个模型` };
|
||||
|
||||
const fallback = preset?.models || [];
|
||||
return {
|
||||
ok: fallback.length > 0,
|
||||
source: fallback.length ? 'preset' : 'none',
|
||||
latencyMs: result.latencyMs,
|
||||
models: fallback,
|
||||
detail: fallback.length ? '接口未返回模型清单,已回退到预设推荐' : '接口未返回任何模型',
|
||||
error: fallback.length ? undefined : '接口未返回任何模型'
|
||||
};
|
||||
}
|
||||
|
||||
/** 按用途找「用哪家、哪个模型」 */
|
||||
function resolveAssignment(purpose) {
|
||||
const assignment = listAssignments()[purpose];
|
||||
if (!assignment?.provider_id || !assignment?.model) return null;
|
||||
const provider = getProvider(assignment.provider_id);
|
||||
if (!provider || provider.enabled === false) return null;
|
||||
return { provider, model: assignment.model };
|
||||
}
|
||||
|
||||
/**
|
||||
* 真正的对话调用(按用途自动选模型)。
|
||||
* 未配置时返回 null,调用方据此回退到本地规则逻辑。
|
||||
*/
|
||||
async function chatByPurpose(purpose, messages, options = {}) {
|
||||
const resolved = resolveAssignment(purpose);
|
||||
if (!resolved) return null;
|
||||
const { provider, model } = resolved;
|
||||
|
||||
const result = await requestJson(chatPath(provider), {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(provider),
|
||||
body: {
|
||||
model,
|
||||
messages: provider.mode === 'anthropic'
|
||||
? messages.filter(message => message.role !== 'system')
|
||||
: messages,
|
||||
...(provider.mode === 'anthropic'
|
||||
? {
|
||||
max_tokens: options.maxTokens || 1024,
|
||||
...(messages.find(message => message.role === 'system')
|
||||
? { system: messages.find(message => message.role === 'system').content }
|
||||
: {})
|
||||
}
|
||||
: {
|
||||
max_tokens: options.maxTokens || 1024,
|
||||
temperature: options.temperature === undefined ? 0.3 : options.temperature,
|
||||
stream: false
|
||||
})
|
||||
},
|
||||
timeoutMs: options.timeoutMs || CHAT_TIMEOUT_MS,
|
||||
apiKey: provider.api_key
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
provider: provider.name,
|
||||
provider_id: provider.id,
|
||||
model,
|
||||
error: result.error,
|
||||
status: result.status
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
provider: provider.name,
|
||||
provider_id: provider.id,
|
||||
model,
|
||||
text: extractChatText(provider, result.data),
|
||||
latency_ms: result.latencyMs,
|
||||
usage: result.data?.usage || null
|
||||
};
|
||||
}
|
||||
|
||||
/** 概览:给界面显示「接了几家、分配了几个用途」 */
|
||||
function overview() {
|
||||
const providers = listProviders();
|
||||
const assignments = listAssignments();
|
||||
const assigned = PURPOSE_KEYS.filter(key => {
|
||||
const item = assignments[key];
|
||||
return item?.provider_id && item?.model && providers.some(p => p.id === item.provider_id && p.enabled !== false);
|
||||
});
|
||||
const healthy = providers.filter(p => p.last_test?.ok);
|
||||
return {
|
||||
provider_count: providers.length,
|
||||
enabled_count: providers.filter(p => p.enabled !== false).length,
|
||||
healthy_count: healthy.length,
|
||||
assigned_purposes: assigned,
|
||||
last_test_at: healthy.map(p => p.last_test?.at).sort().pop() || null
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MASK_PLACEHOLDER: '••••',
|
||||
listProviders,
|
||||
getProvider,
|
||||
publicProvider,
|
||||
upsertProvider,
|
||||
removeProvider,
|
||||
listAssignments,
|
||||
saveAssignments,
|
||||
resolveAssignment,
|
||||
testProvider,
|
||||
fetchModels,
|
||||
chatByPurpose,
|
||||
rememberTest,
|
||||
maskKey,
|
||||
overview,
|
||||
PURPOSE_KEYS
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 鉴权与会话(多用户阶段)。
|
||||
*
|
||||
* 设计取舍:
|
||||
* - 密码哈希沿用 db.js 建默认管理员时的格式 `scrypt$salt$hash`,不引第三方依赖
|
||||
* (本机没有 bcrypt,也不想去碰原生模块编译),Node 内置 crypto.scryptSync 足够。
|
||||
* - 会话复用已有的 sessions 表,浏览器侧只放一个 HttpOnly cookie(happy_sid),
|
||||
* 不把用户信息塞进 token,避免改资料后前端还拿着旧值。
|
||||
* - 权限一律「角色 → 权限组 → 权限点」三级推导,用户表不直接存权限。
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const db = require('../db/db');
|
||||
const { PERMISSION_KEYS } = require('./permissions');
|
||||
|
||||
const COOKIE_NAME = 'happy_sid';
|
||||
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const MAX_FAILED = 5;
|
||||
const LOCK_MS = 15 * 60 * 1000;
|
||||
|
||||
/* ---------------------------------------------------------------- 密码 */
|
||||
|
||||
function hashPassword(plain) {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto.scryptSync(String(plain), salt, 64).toString('hex');
|
||||
return `scrypt$${salt}$${hash}`;
|
||||
}
|
||||
|
||||
function verifyPassword(plain, stored) {
|
||||
const parts = String(stored || '').split('$');
|
||||
if (parts.length !== 3 || parts[0] !== 'scrypt') return false;
|
||||
const [, salt, expected] = parts;
|
||||
let actual;
|
||||
try {
|
||||
actual = crypto.scryptSync(String(plain), salt, 64).toString('hex');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const a = Buffer.from(actual, 'hex');
|
||||
const b = Buffer.from(expected, 'hex');
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- cookie */
|
||||
|
||||
function parseCookies(header) {
|
||||
const out = {};
|
||||
if (!header) return out;
|
||||
for (const piece of String(header).split(';')) {
|
||||
const index = piece.indexOf('=');
|
||||
if (index < 0) continue;
|
||||
const key = piece.slice(0, index).trim();
|
||||
if (!key) continue;
|
||||
try {
|
||||
out[key] = decodeURIComponent(piece.slice(index + 1).trim());
|
||||
} catch {
|
||||
out[key] = piece.slice(index + 1).trim();
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function setSessionCookie(res, id, maxAgeMs = SESSION_TTL_MS) {
|
||||
res.append('Set-Cookie', [
|
||||
`${COOKIE_NAME}=${id}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${Math.floor(maxAgeMs / 1000)}`
|
||||
].join('; '));
|
||||
}
|
||||
|
||||
function clearSessionCookie(res) {
|
||||
res.append('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
|
||||
}
|
||||
|
||||
function clientIp(req) {
|
||||
const raw = req?.headers?.['x-forwarded-for'] || req?.socket?.remoteAddress || '';
|
||||
return String(raw).split(',')[0].trim().replace(/^::ffff:/, '') || null;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 会话 */
|
||||
|
||||
function createSession(userId, req) {
|
||||
const now = new Date();
|
||||
const id = crypto.randomBytes(32).toString('hex');
|
||||
db.prepare(`INSERT INTO sessions (id, user_id, created_at, last_seen_at, expires_at, user_agent, ip)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
id, userId, now.toISOString(), now.toISOString(),
|
||||
new Date(now.getTime() + SESSION_TTL_MS).toISOString(),
|
||||
String(req?.headers?.['user-agent'] || '').slice(0, 200), clientIp(req)
|
||||
);
|
||||
/* 顺手清掉这把用户的过期会话,避免 sessions 表无限增长 */
|
||||
db.prepare('DELETE FROM sessions WHERE user_id = ? AND expires_at < ?').run(userId, now.toISOString());
|
||||
return id;
|
||||
}
|
||||
|
||||
function destroySession(sessionId) {
|
||||
if (sessionId) db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId);
|
||||
}
|
||||
|
||||
/** 从请求里解出当前用户(含 session id),未登录返回 null */
|
||||
function sessionUser(req) {
|
||||
const sessionId = parseCookies(req?.headers?.cookie)[COOKIE_NAME];
|
||||
if (!sessionId) return null;
|
||||
const row = db.prepare(`
|
||||
SELECT u.*, s.id AS sid, s.expires_at AS session_expires_at
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.id = ?
|
||||
`).get(sessionId);
|
||||
if (!row) return null;
|
||||
if (new Date(row.session_expires_at).getTime() < Date.now()) {
|
||||
destroySession(sessionId);
|
||||
return null;
|
||||
}
|
||||
if (!row.is_active) return null;
|
||||
return row;
|
||||
}
|
||||
|
||||
function touchSession(sessionId) {
|
||||
/* 每小时落一次 last_seen_at 就够,避免每个请求都写库 */
|
||||
try {
|
||||
db.prepare('UPDATE sessions SET last_seen_at = ? WHERE id = ? AND last_seen_at < ?')
|
||||
.run(new Date().toISOString(), sessionId, new Date(Date.now() - 3600 * 1000).toISOString());
|
||||
} catch { /* 非关键路径 */ }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 权限 */
|
||||
|
||||
function permissionsOfRole(role) {
|
||||
if (!role) return [];
|
||||
if (role === 'admin') return PERMISSION_KEYS.slice();
|
||||
return db.prepare(`
|
||||
SELECT p.permission_key AS key
|
||||
FROM role_permission_groups r
|
||||
JOIN permission_group_permissions p ON p.group_id = r.group_id
|
||||
WHERE r.role = ?
|
||||
`).all(role).map(row => row.key);
|
||||
}
|
||||
|
||||
function permissionGroupOfRole(role) {
|
||||
if (!role) return null;
|
||||
return db.prepare(`
|
||||
SELECT g.id, g.name, g.description
|
||||
FROM role_permission_groups r JOIN permission_groups g ON g.id = r.group_id
|
||||
WHERE r.role = ?
|
||||
`).get(role) || null;
|
||||
}
|
||||
|
||||
/** 输出给前端的用户对象:绝不带 password_hash */
|
||||
function publicUser(row) {
|
||||
if (!row) return null;
|
||||
const org = row.org_id
|
||||
? db.prepare('SELECT id, name, code FROM organizations WHERE id = ?').get(row.org_id)
|
||||
: null;
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
display_name: row.display_name || row.username,
|
||||
email: row.email || '',
|
||||
role: row.role || '',
|
||||
position: row.position || '',
|
||||
phone: row.phone || '',
|
||||
address: row.address || '',
|
||||
avatar: row.avatar || '',
|
||||
is_active: !!row.is_active,
|
||||
created_at: row.created_at,
|
||||
last_login_at: row.last_login_at,
|
||||
org: org || null,
|
||||
permission_group: permissionGroupOfRole(row.role),
|
||||
permissions: permissionsOfRole(row.role)
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 中间件 */
|
||||
|
||||
function attachUser(req, res, next) {
|
||||
const user = sessionUser(req);
|
||||
req.user = user;
|
||||
if (user) touchSession(user.sid);
|
||||
next();
|
||||
}
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (!req.user) return res.status(401).json({ error: '请先登录' });
|
||||
next();
|
||||
}
|
||||
|
||||
function requirePermission(key) {
|
||||
return (req, res, next) => {
|
||||
if (!req.user) return res.status(401).json({ error: '请先登录' });
|
||||
const granted = req.user.role === 'admin' || permissionsOfRole(req.user.role).includes(key);
|
||||
if (!granted) return res.status(403).json({ error: '当前账号没有该操作权限' });
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 审计 */
|
||||
|
||||
function audit({ username, action, detail, req, ok = 1 }) {
|
||||
try {
|
||||
db.prepare(`INSERT INTO audit_log (at, username, action, detail, ip, user_agent, ok)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
new Date().toISOString(), username || null, action, detail || null,
|
||||
clientIp(req), String(req?.headers?.['user-agent'] || '').slice(0, 200), ok ? 1 : 0
|
||||
);
|
||||
} catch { /* 审计是旁路,失败不能影响业务 */ }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COOKIE_NAME,
|
||||
SESSION_TTL_MS,
|
||||
MAX_FAILED,
|
||||
LOCK_MS,
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
parseCookies,
|
||||
setSessionCookie,
|
||||
clearSessionCookie,
|
||||
clientIp,
|
||||
createSession,
|
||||
destroySession,
|
||||
sessionUser,
|
||||
permissionsOfRole,
|
||||
permissionGroupOfRole,
|
||||
publicUser,
|
||||
attachUser,
|
||||
requireAuth,
|
||||
requirePermission,
|
||||
audit
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 系统标识与组织名称(单一事实来源)。
|
||||
*
|
||||
* 口径(Wayne 2026-09-15 定,别再改回去):
|
||||
* - **系统名是作者固定署名,不可修改**:登录页、顶栏、浏览器标题一律显示
|
||||
* SYSTEM.name,且**不跟随组织名** —— 这套系统会被别人拿去部署,
|
||||
* 把牌子固定在作者名下是刻意保留的来源标识(引流)。
|
||||
* - **组织名(公司名)可以改**:它只是业务字段,决定「同组织」的资源边界、
|
||||
* 以及权限组/用户下拉里的显示;改组织名**不会**影响界面上的系统名。
|
||||
* - 之前那套 `settings['brand.name'] / brand.subtitle` 覆盖已作废,
|
||||
* 历史遗留的这两个键即使还在库里也不再被读取。
|
||||
*/
|
||||
|
||||
const db = require('../db/db');
|
||||
|
||||
/** 系统标识:作者固定署名,界面一律用它;不读 settings,不跟随组织名 */
|
||||
const SYSTEM = {
|
||||
name: 'happy出口通',
|
||||
subtitle: 'Ai Export System'
|
||||
};
|
||||
|
||||
/** 第一个组织视为「主组织」:开箱数据只有一个,组织名以它为准 */
|
||||
function primaryOrg() {
|
||||
try {
|
||||
return db.prepare('SELECT id, name, code, description FROM organizations ORDER BY id LIMIT 1').get() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 给前端用的标识信息。
|
||||
* 故意做得轻:登录页、顶栏、浏览器标题都会调它。
|
||||
* name / subtitle 恒为固定值;org_* 只是顺带给设置页展示「当前公司名」。
|
||||
*/
|
||||
function getBranding() {
|
||||
const org = primaryOrg();
|
||||
return {
|
||||
/* 界面牌子:固定 */
|
||||
name: SYSTEM.name,
|
||||
subtitle: SYSTEM.subtitle,
|
||||
/* 前端据此把「系统标识」渲染成只读、不提供输入框 */
|
||||
system_locked: true,
|
||||
/* 公司/组织名单独给出:设置页要能看见它当前是什么(这个是可改的) */
|
||||
org_name: org?.name || '',
|
||||
org_id: org?.id ?? null
|
||||
};
|
||||
}
|
||||
|
||||
/** 改组织名(顺带把 code / 描述也支持了,组织与品牌设置页要用) */
|
||||
function updateOrg(id, { name, code, description } = {}) {
|
||||
const row = db.prepare('SELECT * FROM organizations WHERE id = ?').get(Number(id));
|
||||
if (!row) throw Object.assign(new Error('组织不存在'), { statusCode: 400 });
|
||||
|
||||
const nextName = name === undefined ? row.name : String(name || '').trim();
|
||||
if (!nextName) throw Object.assign(new Error('组织名称不能为空'), { statusCode: 400 });
|
||||
if (nextName.length > 40) throw Object.assign(new Error('组织名称不能超过 40 个字'), { statusCode: 400 });
|
||||
|
||||
const clash = db.prepare('SELECT id FROM organizations WHERE name = ? AND id <> ?').get(nextName, row.id);
|
||||
if (clash) throw Object.assign(new Error('已存在同名组织'), { statusCode: 400 });
|
||||
|
||||
db.prepare('UPDATE organizations SET name = ?, code = ?, description = ? WHERE id = ?').run(
|
||||
nextName,
|
||||
code === undefined ? row.code : (String(code || '').trim() || null),
|
||||
description === undefined ? row.description : (String(description || '').trim() || null),
|
||||
row.id
|
||||
);
|
||||
return db.prepare('SELECT id, name, code, description FROM organizations WHERE id = ?').get(row.id);
|
||||
}
|
||||
|
||||
module.exports = { SYSTEM, getBranding, updateOrg, primaryOrg };
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 合同原件的磁盘留存 + 到期清理。
|
||||
*
|
||||
* 为什么放磁盘而不是继续用 `contract_files` 的 BLOB:
|
||||
* 留原件的唯一目的是「复核那几天能回去核对一眼」,7 天后就要删掉省 VPS 空间。
|
||||
* 而 SQLite 删掉 BLOB 后空间并不会还给系统(要 VACUUM 整库重写才还),
|
||||
* 与「省空间」的目标正好相反。磁盘文件删掉就是删掉。
|
||||
*
|
||||
* 目录结构:<DATA_DIR>/contracts/<job_no>/<清洗过的原文件名>
|
||||
* —— 用 job_no 分目录,清理时按目录整删即可,不需要额外记路径(也就不需要加字段)。
|
||||
*
|
||||
* ⚠️ 安全:目录名与文件名都来自用户,必须防越权路径。
|
||||
* job_no 由服务端生成(CIMP-数字-数字),文件名统一过 sanitizeFileName,
|
||||
* 拼好之后再校验 resolved 路径确实落在 contracts 根目录之内。
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const RETENTION_DAYS = Number(process.env.CONTRACT_RETENTION_DAYS || 7);
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../data');
|
||||
const ROOT = path.join(DATA_DIR, 'contracts');
|
||||
|
||||
/** 清洗文件名:去掉路径分隔与控制字符,压掉长度,保留中文 */
|
||||
function sanitizeFileName(name) {
|
||||
const base = path.basename(String(name || '')).replace(/[\u0000-\u001f\u007f]/g, '').trim();
|
||||
const safe = base.replace(/[\\/:*?"<>|]/g, '_').replace(/^\.+/, '');
|
||||
const trimmed = safe.slice(0, 120) || 'contract';
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** 防越权:任何对外暴露的路径都要过这一关 */
|
||||
function resolveInside(jobNo, fileName) {
|
||||
const dir = path.join(ROOT, String(jobNo || '').replace(/[^\w-]/g, ''));
|
||||
const target = path.join(dir, sanitizeFileName(fileName));
|
||||
const rootResolved = path.resolve(ROOT);
|
||||
const targetResolved = path.resolve(target);
|
||||
if (targetResolved !== rootResolved && !targetResolved.startsWith(rootResolved + path.sep)) return null;
|
||||
return targetResolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存一份原件。失败不抛 —— 原件只是辅助材料,解析结果才是主产物,
|
||||
* 不能因为磁盘满了就把整次导入判失败。
|
||||
*/
|
||||
function saveOriginal(jobNo, file) {
|
||||
try {
|
||||
if (!jobNo || !file?.buffer?.length) return null;
|
||||
const dir = path.join(ROOT, String(jobNo).replace(/[^\w-]/g, ''));
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
// 同名文件加序号后缀,避免后来者覆盖前一个
|
||||
const wanted = sanitizeFileName(file.originalname);
|
||||
let target = path.join(dir, wanted);
|
||||
let seq = 1;
|
||||
const ext = path.extname(wanted);
|
||||
const stem = path.basename(wanted, ext);
|
||||
while (fs.existsSync(target)) {
|
||||
seq += 1;
|
||||
target = path.join(dir, `${stem}-${seq}${ext}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(target, file.buffer);
|
||||
return { name: path.basename(target), size: file.buffer.length };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 列出某个任务还留着的原件 */
|
||||
function listOriginals(jobNo) {
|
||||
try {
|
||||
const dir = path.join(ROOT, String(jobNo).replace(/[^\w-]/g, ''));
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir).map(name => {
|
||||
const stat = fs.statSync(path.join(dir, name));
|
||||
return { name, size: stat.size, stored_at: stat.mtime.toISOString() };
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 取某一份原件(下载用)。不存在返回 null —— 界面据此显示「已过期清理」。 */
|
||||
function readOriginal(jobNo, fileName) {
|
||||
const target = resolveInside(jobNo, fileName);
|
||||
if (!target) return null;
|
||||
try {
|
||||
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) return null;
|
||||
return { path: target, name: path.basename(target), buffer: fs.readFileSync(target) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期原件。
|
||||
*
|
||||
* 到期判定用**任务创建时间**(库里查得到的权威时间),不用目录 mtime ——
|
||||
* mtime 会被「同 job 再传一次文件」刷新,导致该清的清不掉。
|
||||
* 库里查不到对应任务的孤儿目录,按目录 mtime 兜底删。
|
||||
*/
|
||||
function purgeExpired(db, retentionDays = RETENTION_DAYS) {
|
||||
const removed = [];
|
||||
const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
||||
let dirs = [];
|
||||
try {
|
||||
if (!fs.existsSync(ROOT)) return { removed, scanned: 0 };
|
||||
dirs = fs.readdirSync(ROOT).filter(name => {
|
||||
try { return fs.statSync(path.join(ROOT, name)).isDirectory(); } catch { return false; }
|
||||
});
|
||||
} catch {
|
||||
return { removed, scanned: 0 };
|
||||
}
|
||||
|
||||
for (const jobNo of dirs) {
|
||||
const dir = path.join(ROOT, jobNo);
|
||||
let expired = false;
|
||||
try {
|
||||
const job = db.prepare('SELECT created_at FROM contract_import_jobs WHERE job_no = ?').get(jobNo);
|
||||
const stamp = job?.created_at ? Date.parse(job.created_at) : fs.statSync(dir).mtimeMs;
|
||||
expired = Number.isFinite(stamp) && stamp < cutoffMs;
|
||||
} catch {
|
||||
expired = false;
|
||||
}
|
||||
if (!expired) continue;
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
removed.push(jobNo);
|
||||
} catch {
|
||||
/* 删不掉(占着句柄等)下一轮再试,不影响服务 */
|
||||
}
|
||||
}
|
||||
return { removed, scanned: dirs.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动时清一次 + 之后每 6 小时一次。
|
||||
* 用 unref() 让定时器不阻止进程退出(否则 nodemon 重启会挂着)。
|
||||
*/
|
||||
function startPurgeSchedule(db, logger = console) {
|
||||
const run = () => {
|
||||
try {
|
||||
const { removed } = purgeExpired(db);
|
||||
if (removed.length) logger.log(`[contracts] 已清理 ${removed.length} 个到期的合同原件(保留 ${RETENTION_DAYS} 天)`);
|
||||
} catch (error) {
|
||||
logger.error?.('[contracts] 清理合同原件失败:', error.message);
|
||||
}
|
||||
};
|
||||
run();
|
||||
const timer = setInterval(run, 6 * 60 * 60 * 1000);
|
||||
timer.unref?.();
|
||||
return timer;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RETENTION_DAYS,
|
||||
ROOT,
|
||||
sanitizeFileName,
|
||||
saveOriginal,
|
||||
listOriginals,
|
||||
readOriginal,
|
||||
purgeExpired,
|
||||
startPurgeSchedule
|
||||
};
|
||||
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* 通用表格解析引擎:把「供应商合同 / 报价单」这类文件认成结构化的行。
|
||||
*
|
||||
* 设计目标(三条,缺一不可):
|
||||
*
|
||||
* 1. **按字符认列,不按行列位置。**
|
||||
* 规则只描述「这一列的标题大概长什么样」(关键词 / 正则),不写死「第 3 列是价格」。
|
||||
* 同一份表加一列、调一下顺序,识别结果不变。
|
||||
*
|
||||
* 2. **只采纳「标题对得上、且值确实是数值」的列。**
|
||||
* 数值类字段(采购价 / 数量 / 起订量)在认列之后还要过一道**列级体检**:
|
||||
* 这一列的非空值里能解析成数字的比例达标,才允许采纳。
|
||||
* 这一条是「通用」的关键 —— 它挡住的不是错别字,而是「品名里带个『价』字」
|
||||
* 这类只看标题一定会踩的坑。
|
||||
*
|
||||
* 3. **通用、不绑定行业。**
|
||||
* 默认真名词典按「语义」组织(中/英 + 常见缩写),词典里**不允许出现任何行业专有词**
|
||||
* (五金、灯具、化工……一概不写)。换一个行业只是换个文件,不是改代码。
|
||||
* 词典本身也写成**数据**:可在 `settings['contract.rules']` 或
|
||||
* `DATA_DIR/contract-rules.json` 里增删关键词、排除词、阈值,覆盖默认值。
|
||||
*
|
||||
* 表头不在第一行也能认:标题行 / 公司抬头 / 日期这些常常占掉前几行,
|
||||
* 所以会**在前若干行里挑「规则命中最多」的那一行当表头**,而不是无脑取第 1 行。
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const db = require('../db/db');
|
||||
|
||||
let XLSX = null;
|
||||
try {
|
||||
// 读 xlsx/xls 用;放在 try 里,没装也不至于整个服务起不来
|
||||
XLSX = require('xlsx');
|
||||
} catch {
|
||||
XLSX = null;
|
||||
}
|
||||
|
||||
const SETTINGS_KEY = 'contract.rules';
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
/** 表头最多往下找几行 */
|
||||
header_search_rows: 12,
|
||||
/** 认定「这行是表头」至少要有几个字段命中 */
|
||||
header_min_hits: 2,
|
||||
/** 认列的最低分(低于此分视为没认出来) */
|
||||
min_score: 45,
|
||||
/** 数值列体检:能解析成数字的比例下限 */
|
||||
min_numeric_ratio: 0.6,
|
||||
/** 数值列体检最多抽几行 */
|
||||
numeric_sample: 40
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ 词典 */
|
||||
|
||||
/**
|
||||
* 默认规则。
|
||||
*
|
||||
* 约定:
|
||||
* type text / number —— number 的列要过数值体检
|
||||
* keys 命中即得分的关键词(中英混排,不分大小写)
|
||||
* exclude 硬排除:命中就直接判定「这一列不是这个字段」
|
||||
* regex 额外的正则特征(写字符串,用 /.../i 形式)
|
||||
*
|
||||
* ⚠️ 这里的词必须是**通用词**。加词前先问:换个行业还成立吗?
|
||||
*/
|
||||
const DEFAULT_FIELDS = {
|
||||
sku: {
|
||||
label: '货号',
|
||||
type: 'text',
|
||||
keys: [
|
||||
'sku', 'item code', 'item no', 'item number', 'product code', 'product no', 'product number',
|
||||
'part no', 'part number', 'model no', 'model number', 'article no', 'article number',
|
||||
'style no', 'style number', 'catalog no', 'cat no', 'ref no', 'reference',
|
||||
'code', 'model', 'article', 'product id', 'item id',
|
||||
'货号', '产品编号', '产品编码', '产品货号', '商品编号', '物料编号', '物料编码', '物料号',
|
||||
'型号', '型号规格', '编号', '款号', '图号', '零件号', '存货编码', '存货编号'
|
||||
],
|
||||
exclude: ['数量', '价', 'price', 'cost', 'amount', '名称', 'name', 'qty', 'quantity']
|
||||
},
|
||||
product_name: {
|
||||
label: '品名',
|
||||
type: 'text',
|
||||
keys: [
|
||||
'product name', 'item name', 'goods name', 'commodity', 'product description',
|
||||
'item description', 'description', 'product', 'item', 'title', 'goods',
|
||||
'品名', '产品名称', '商品名称', '货物名称', '产品描述', '商品描述',
|
||||
'品名规格', '名称', '描述', '品名及规格', '产品'
|
||||
],
|
||||
exclude: ['货号', '编号', '编码', 'code', 'no.', '数量', '价', 'price', 'amount', 'qty', '规格型号']
|
||||
},
|
||||
spec: {
|
||||
label: '规格',
|
||||
type: 'text',
|
||||
keys: [
|
||||
'spec', 'specification', 'specifications', 'size', 'dimension', 'dimensions',
|
||||
'material', 'model spec', 'type',
|
||||
'规格', '规格型号', '尺寸', '材质', '参数', '规格参数', '规格描述'
|
||||
],
|
||||
exclude: ['货号', '编号', '价', 'price', '数量', 'qty', 'amount', '名称']
|
||||
},
|
||||
purchase_price: {
|
||||
label: '采购价',
|
||||
type: 'number',
|
||||
keys: [
|
||||
'purchase price', 'buying price', 'cost price', 'factory price', 'unit price',
|
||||
'net price', 'exw price', 'fob price', 'ex works', 'exw', 'fob', 'cost', 'price',
|
||||
'采购价', '采购单价', '采购价格', '供货价', '供货单价', '出厂价', '出厂单价',
|
||||
'成本价', '成本单价', '未税价', '不含税价', '含税价', '工厂价', '底价', '单价', '价格', '报价'
|
||||
],
|
||||
exclude: [
|
||||
'总价', '合计', '金额', '总额', '小计', 'amount', 'total', 'sum',
|
||||
'售价', '零售', '建议零售', 'retail', 'msrp', 'selling', 'sell price',
|
||||
'折扣', 'discount', '毛利', '利润', 'profit', 'margin', '汇率', 'rate', '税点', '运费', 'freight'
|
||||
]
|
||||
},
|
||||
currency: {
|
||||
label: '币种',
|
||||
type: 'text',
|
||||
keys: ['currency', 'cur', 'unit', '币种', '货币', '币别', '结算币种', '单价币种', '计价币种'],
|
||||
exclude: ['数量', 'qty', '价', 'price', '货号', '编号']
|
||||
},
|
||||
quantity: {
|
||||
label: '数量',
|
||||
type: 'number',
|
||||
keys: [
|
||||
'quantity', 'qty', 'order qty', 'order quantity', 'purchase qty', 'purchase quantity',
|
||||
'ordered qty', 'amount of order', 'pcs', 'sets', 'qty(pcs)',
|
||||
'数量', '订购量', '订购数量', '采购数量', '采购量', '订货量', '订货数量', '数量(pcs)', '件数', '个数'
|
||||
],
|
||||
exclude: ['价', 'price', 'cost', 'amount', '金额', '总', '合计', 'moq', '起订', 'min', '最少']
|
||||
},
|
||||
moq: {
|
||||
label: '起订量',
|
||||
type: 'number',
|
||||
keys: [
|
||||
'moq', 'minimum order quantity', 'min order qty', 'min order', 'minimum qty', 'min qty',
|
||||
'起订量', '最小起订量', '起订数量', '最少订量', '最小订量', '最小起订'
|
||||
],
|
||||
exclude: ['价', 'price', 'cost', 'amount', '金额']
|
||||
}
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------- 配置读取 */
|
||||
|
||||
function readJsonFile(file) {
|
||||
try {
|
||||
if (!fs.existsSync(file)) return null;
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 用户覆盖文件放哪:与数据库同目录,便于随库一起备份 */
|
||||
function overrideFile() {
|
||||
const dir = process.env.DATA_DIR || path.join(__dirname, '../data');
|
||||
return path.join(dir, 'contract-rules.json');
|
||||
}
|
||||
|
||||
function readOverride() {
|
||||
/* 优先级:DATA_DIR/contract-rules.json(真正意义的「配置文件」) > settings 表。
|
||||
前者适合开源用户直接改文件、连界面都不进;后者留给以后的界面化编辑。 */
|
||||
const fromFile = readJsonFile(overrideFile());
|
||||
if (fromFile && typeof fromFile === 'object') return { source: 'file', data: fromFile };
|
||||
try {
|
||||
const raw = db.prepare('SELECT value FROM settings WHERE key = ?').get(SETTINGS_KEY)?.value;
|
||||
if (raw) return { source: 'settings', data: JSON.parse(raw) };
|
||||
} catch {
|
||||
/* 表还没建或值是脏的,当没有覆盖处理 */
|
||||
}
|
||||
return { source: null, data: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并默认规则与用户覆盖。
|
||||
* 覆盖写法(只写要改的部分即可):
|
||||
* {
|
||||
* "fields": { "purchase_price": { "add": ["供货价"], "exclude": ["吊牌价"] } },
|
||||
* "options": { "min_numeric_ratio": 0.5 },
|
||||
* "disable": ["spec"]
|
||||
* }
|
||||
*/
|
||||
function loadRules() {
|
||||
const { source, data } = readOverride();
|
||||
const options = { ...DEFAULT_OPTIONS, ...(data.options || {}) };
|
||||
const disabled = new Set(Array.isArray(data.disable) ? data.disable : []);
|
||||
const fields = {};
|
||||
|
||||
for (const [key, def] of Object.entries(DEFAULT_FIELDS)) {
|
||||
if (disabled.has(key)) continue;
|
||||
const patch = data.fields?.[key] || {};
|
||||
const keys = [...def.keys, ...(Array.isArray(patch.add) ? patch.add : [])]
|
||||
.filter(item => !(Array.isArray(patch.remove) && patch.remove.includes(item)));
|
||||
const exclude = [...def.exclude, ...(Array.isArray(patch.exclude) ? patch.exclude : [])];
|
||||
fields[key] = {
|
||||
...def,
|
||||
keys,
|
||||
exclude,
|
||||
// type 允许被覆盖(有人想把「规格」当数值列 тоже可以)
|
||||
type: patch.type || def.type,
|
||||
regex: Array.isArray(patch.regex) ? patch.regex : []
|
||||
};
|
||||
}
|
||||
return { fields, options, override_source: source };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 文本与数字 */
|
||||
|
||||
/** 全角 → 半角、统一括号、去零宽字符。中文表格里这三样最常见。 */
|
||||
function normalizeText(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/[\uFF01-\uFF5E]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0xfee0))
|
||||
.replace(/\u3000/g, ' ')
|
||||
.replace(/[\u200B-\u200D\uFEFF]/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** 表头归一:小写、去空白、去尾部标点,让「采购价 (元)」和「采购价」能对上 */
|
||||
function normalizeHeader(value) {
|
||||
return normalizeText(value)
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[::、,,.。**·\-—]+$/g, '')
|
||||
.replace(/[((].*?[))]$/g, ''); // 去掉尾部的单位括号
|
||||
}
|
||||
|
||||
const hasCJK = text => /[\u4E00-\u9FFF]/.test(text);
|
||||
|
||||
/**
|
||||
* 把单元格里的值转成数字。
|
||||
* 处理:全角数字、千分位(含欧洲写法 1.234,56)、币种符号、单位后缀、
|
||||
* 括号负数(会计写法)、前后空白与不可见字符。
|
||||
* 返回 null 表示「这不是一个数字」——调用方据此做列级体检。
|
||||
*/
|
||||
function toNumber(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
||||
|
||||
let text = normalizeText(value).replace(/\s/g, '');
|
||||
if (!text) return null;
|
||||
|
||||
// 括号负数:(12.5) → -12.5
|
||||
let negative = false;
|
||||
if (/^\(.*\)$/.test(text)) {
|
||||
negative = true;
|
||||
text = text.slice(1, -1);
|
||||
}
|
||||
|
||||
// 欧洲写法:1.234,56 —— 逗号是小数点、点是千分位。必须先判,否则会被当千分位剥掉
|
||||
if (/^\d{1,3}(\.\d{3})+(,\d+)?$/.test(text)) {
|
||||
text = text.replace(/\./g, '').replace(',', '.');
|
||||
} else {
|
||||
text = text.replace(/,/g, '');
|
||||
}
|
||||
|
||||
// 剥掉一切非数字字符(币种符号、单位、文字都在这步掉)
|
||||
text = text.replace(/[^0-9.\-+]/g, '');
|
||||
if (!text) return null;
|
||||
|
||||
/* 符号只允许出现在最前面。
|
||||
⚠️ 不能「把 - 全省掉」—— 那样「12-15」这种区间会被拼成 1215,
|
||||
比留空更糟:它会当成真价继续往产品库里走。区间一律判为非数字。 */
|
||||
let sign = 1;
|
||||
if (/^[-+]/.test(text)) {
|
||||
if (text[0] === '-') sign = -1;
|
||||
text = text.slice(1);
|
||||
}
|
||||
if (/[-+]/.test(text)) return null;
|
||||
if (!/^\d*\.?\d+$/.test(text)) return null;
|
||||
|
||||
const num = Number(text) * sign * (negative ? -1 : 1);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
/** 从整行的内容里猜币种 —— 没有币种列时用 */
|
||||
const CURRENCY_HINTS = [
|
||||
[/¥|¥|人民币|rmb|cny|元/i, 'CNY'],
|
||||
[/\$|usd|美元|美金/i, 'USD'],
|
||||
[/€|eur|欧元/i, 'EUR'],
|
||||
[/£|gbp|英镑/i, 'GBP'],
|
||||
[/jpy|日元|円/i, 'JPY'],
|
||||
[/krw|韩元|₩/i, 'KRW'],
|
||||
[/aud|澳元/i, 'AUD'],
|
||||
[/cad|加元/i, 'CAD'],
|
||||
[/hkd|港币|港元/i, 'HKD'],
|
||||
[/inr|卢比/i, 'INR'],
|
||||
[/vnd|越南盾|đ/i, 'VND'],
|
||||
[/thb|泰铢|฿/i, 'THB']
|
||||
];
|
||||
|
||||
function guessCurrency(...texts) {
|
||||
const joined = texts.map(item => String(item ?? '')).join(' ');
|
||||
if (!joined.trim()) return null;
|
||||
for (const [pattern, code] of CURRENCY_HINTS) {
|
||||
if (pattern.test(joined)) return code;
|
||||
}
|
||||
// 三字母代码(USD / CNY…)单独兜一次,避免被上面的贪婪匹配漏掉
|
||||
const code = joined.match(/\b([A-Z]{3})\b/);
|
||||
return code ? code[1] : null;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 列匹配 */
|
||||
|
||||
/** 单个「表头 × 字段」打分。返回 0 = 不匹配;exclude 命中直接 0。 */
|
||||
function scoreHeader(header, field) {
|
||||
const head = normalizeHeader(header);
|
||||
if (!head) return 0;
|
||||
|
||||
for (const bad of field.exclude) {
|
||||
const token = normalizeHeader(bad);
|
||||
if (token && head.includes(token)) return 0;
|
||||
}
|
||||
|
||||
let best = 0;
|
||||
for (const rawKey of field.keys) {
|
||||
const key = normalizeHeader(rawKey);
|
||||
if (!key) continue;
|
||||
|
||||
if (head === key) return 100;
|
||||
|
||||
if (hasCJK(key)) {
|
||||
// 中文:包含即可,但关键词越长越可信(「采购单价」远比「价」可信)
|
||||
if (head.includes(key)) best = Math.max(best, 50 + key.length * 8);
|
||||
} else {
|
||||
// 拉丁:短词(no / moq / ref…)必须落在词边界上,否则 notes 会被当成 no
|
||||
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const boundary = new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`);
|
||||
if (boundary.test(head)) best = Math.max(best, 55 + Math.min(key.length, 12) * 3);
|
||||
}
|
||||
}
|
||||
|
||||
for (const pattern of field.regex) {
|
||||
try {
|
||||
const [source, flags] = pattern.split(/\/([a-z]*)$/);
|
||||
if (new RegExp(source.replace(/^\//, ''), flags || 'i').test(header)) best = Math.max(best, 85);
|
||||
} catch {
|
||||
/* 用户写的正则不合法就当没写,不能让一份配置文件把整条解析链路打挂 */
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* 认列:把「表头行」映射成「字段 → 列号」。
|
||||
*
|
||||
* 两步走:
|
||||
* ① 每个数值字段先过**列级体检** —— 候选列的数字占比不达标直接淘汰(拦「品名带价字」这类误判)
|
||||
* ② 所有候选按分排序,贪心分配:字段没被占、列也没被占才落座
|
||||
* (贪心而不是「每个字段各取最高分」,是为了避免两列抢同一个字段时出现重复占用)
|
||||
*/
|
||||
function matchColumns(headers, dataRows, rules) {
|
||||
const { fields, options } = rules;
|
||||
const candidates = [];
|
||||
const rejected = [];
|
||||
|
||||
for (const [fieldKey, field] of Object.entries(fields)) {
|
||||
for (let col = 0; col < headers.length; col += 1) {
|
||||
const score = scoreHeader(headers[col], field);
|
||||
if (score < options.min_score) continue;
|
||||
|
||||
if (field.type === 'number') {
|
||||
const sample = dataRows
|
||||
.map(row => row[col])
|
||||
.filter(value => String(value ?? '').trim() !== '')
|
||||
.slice(0, options.numeric_sample);
|
||||
if (!sample.length) {
|
||||
rejected.push({ field: fieldKey, col, header: headers[col], reason: '整列为空' });
|
||||
continue;
|
||||
}
|
||||
const ok = sample.filter(value => toNumber(value) !== null).length;
|
||||
const ratio = ok / sample.length;
|
||||
if (ratio < options.min_numeric_ratio) {
|
||||
rejected.push({
|
||||
field: fieldKey, col, header: headers[col],
|
||||
reason: `数值占比 ${Math.round(ratio * 100)}% 低于 ${Math.round(options.min_numeric_ratio * 100)}%`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
candidates.push({ field: fieldKey, col, header: String(headers[col] ?? '').trim(), score });
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => b.score - a.score || a.col - b.col);
|
||||
const usedField = new Set();
|
||||
const usedCol = new Set();
|
||||
const map = {};
|
||||
for (const item of candidates) {
|
||||
if (usedField.has(item.field) || usedCol.has(item.col)) continue;
|
||||
usedField.add(item.field);
|
||||
usedCol.add(item.col);
|
||||
map[item.field] = item;
|
||||
}
|
||||
return { map, rejected };
|
||||
}
|
||||
|
||||
/**
|
||||
* 挑表头行。
|
||||
* 真实文件里表头常常不在第 1 行(上面还有公司抬头、报价日期、说明……),
|
||||
* 所以在前若干行里找「字段命中最多」的那一行,而不是无脑取第 1 行。
|
||||
*/
|
||||
function detectHeaderRow(matrix, rules) {
|
||||
const limit = Math.min(matrix.length, rules.options.header_search_rows);
|
||||
let best = { index: 0, hits: 0, matched: null };
|
||||
for (let i = 0; i < limit; i += 1) {
|
||||
const row = matrix[i] || [];
|
||||
const { map } = matchColumns(row, matrix.slice(i + 1), rules);
|
||||
const hits = Object.keys(map).length;
|
||||
// 命中更多者胜;打平取靠前的(抬头行通常在上面)
|
||||
if (hits > best.hits) best = { index: i, hits, matched: map };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 读文件 */
|
||||
|
||||
/** CSV / TSV / TXT:按首行含不含 Tab 决定分隔符(沿用原有口径) */
|
||||
function readDelimited(buffer, filename) {
|
||||
const text = buffer.toString('utf8').replace(/^\uFEFF/, '');
|
||||
const lines = text.split(/\r?\n/);
|
||||
const delimiter = /\.tsv$/i.test(filename) || (lines[0] || '').includes('\t') ? '\t'
|
||||
: (lines[0] || '').includes(';') && !(lines[0] || '').includes(',') ? ';'
|
||||
: ',';
|
||||
return lines.map(line => splitDelimited(line, delimiter));
|
||||
}
|
||||
|
||||
/** 按分隔符切一行,但引号里的分隔符不算("A,B" 是一格) */
|
||||
function splitDelimited(line, delimiter) {
|
||||
const cells = [];
|
||||
let current = '';
|
||||
let quoted = false;
|
||||
for (let i = 0; i < line.length; i += 1) {
|
||||
const ch = line[i];
|
||||
if (ch === '"') {
|
||||
if (quoted && line[i + 1] === '"') { current += '"'; i += 1; } else quoted = !quoted;
|
||||
} else if (ch === delimiter && !quoted) {
|
||||
cells.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
cells.push(current);
|
||||
return cells.map(cell => cell.trim());
|
||||
}
|
||||
|
||||
/** xlsx / xls:交给 SheetJS;多 sheet 时取「非空单元格最多」的那个(通常就是明细表) */
|
||||
function readWorkbook(buffer) {
|
||||
if (!XLSX) throw new Error('未安装 xlsx 解析库,无法读取 Excel 文件');
|
||||
const book = XLSX.read(buffer, { type: 'buffer' });
|
||||
let best = { matrix: [], cells: -1, sheet: null };
|
||||
for (const name of book.SheetNames || []) {
|
||||
const sheet = book.Sheets[name];
|
||||
if (!sheet) continue;
|
||||
const matrix = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: '', blankrows: true });
|
||||
const cells = matrix.reduce((sum, row) => sum + row.filter(cell => String(cell ?? '').trim() !== '').length, 0);
|
||||
if (cells > best.cells) best = { matrix, cells, sheet: name };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function parseMatrixToRows(matrix, headerIndex, columnMap, fallbackCurrency = null) {
|
||||
const fields = Object.keys(columnMap);
|
||||
const rows = [];
|
||||
for (let i = headerIndex + 1; i < matrix.length; i += 1) {
|
||||
const raw = matrix[i] || [];
|
||||
const pick = field => {
|
||||
const hit = columnMap[field];
|
||||
if (!hit) return null;
|
||||
const value = normalizeText(raw[hit.col]);
|
||||
return value === '' ? null : value;
|
||||
};
|
||||
const row = {
|
||||
// row_no 用「文件里的真实行号」(1 起),方便人回去对着原文件找
|
||||
row_no: i + 1,
|
||||
raw_text: raw.map(cell => String(cell ?? '').trim()).filter(Boolean).join(' | ').slice(0, 500),
|
||||
sku: pick('sku'),
|
||||
product_name: pick('product_name'),
|
||||
spec: pick('spec'),
|
||||
currency: pick('currency'),
|
||||
quantity: toNumber(pick('quantity')),
|
||||
moq: toNumber(pick('moq')),
|
||||
purchase_price: toNumber(pick('purchase_price'))
|
||||
};
|
||||
/* 币种三级兜底:本行的币种列(上面 pick 过了) → 本行里的符号(¥ / USD / 元)
|
||||
→ 整份文件的线索(表头常写「单价(元)」「Unit Price(USD)」)。
|
||||
只按单行猜会出现「同一列有的行有币种、有的行没有」这种不一致。 */
|
||||
if (!row.currency) row.currency = guessCurrency(raw.join(' ')) || fallbackCurrency;
|
||||
// 整行该抓的全空 —— 大概率是空行或小计行,丢掉
|
||||
const meaningful = [row.sku, row.product_name, row.spec, row.purchase_price, row.quantity, row.moq]
|
||||
.some(value => value !== null && value !== undefined && value !== '');
|
||||
if (meaningful) rows.push(row);
|
||||
}
|
||||
return { rows, fields };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析一个上传文件 → { rows, headers, header_row, column_map, rejected, notes }
|
||||
* 这是对外的唯一入口。
|
||||
*/
|
||||
function parseUpload(file, rules = loadRules()) {
|
||||
const filename = String(file.originalname || '');
|
||||
const isExcel = /\.(xlsx|xlsm|xls)$/i.test(filename);
|
||||
let matrix;
|
||||
let sheetName = null;
|
||||
|
||||
if (isExcel) {
|
||||
const book = readWorkbook(file.buffer);
|
||||
matrix = book.matrix;
|
||||
sheetName = book.sheet;
|
||||
} else {
|
||||
matrix = readDelimited(file.buffer, filename);
|
||||
}
|
||||
|
||||
// 去掉整行全空的(xlsx 里尾部常有大量空行)
|
||||
matrix = matrix.filter(row => row.some(cell => String(cell ?? '').trim() !== ''));
|
||||
if (!matrix.length) {
|
||||
return { rows: [], headers: [], header_row: null, column_map: {}, rejected: [], notes: '文件里没有读到任何内容' };
|
||||
}
|
||||
|
||||
const header = detectHeaderRow(matrix, rules);
|
||||
const headers = matrix[header.index] || [];
|
||||
const { map, rejected } = matchColumns(headers, matrix.slice(header.index + 1), rules);
|
||||
/* 文件级币种线索:表头 + 表头以上的抬头行(「报价单(美元)」「单价(元)」都在这)
|
||||
—— 没有币种列时靠它,保证整列币种一致 */
|
||||
const titleRows = matrix.slice(0, header.index).flat();
|
||||
const fileCurrency = guessCurrency(headers.join(' '), titleRows.join(' '));
|
||||
const { rows } = parseMatrixToRows(matrix, header.index, map, fileCurrency);
|
||||
|
||||
const notes = rows.length
|
||||
? `表头在第 ${header.index + 1} 行,认出 ${Object.keys(map).length} 个字段${sheetName ? `(工作表 ${sheetName})` : ''}`
|
||||
: '表头行没有认出任何字段,建议人工补录或用 AI 解析';
|
||||
|
||||
return {
|
||||
rows,
|
||||
headers: headers.map(cell => String(cell ?? '').trim()),
|
||||
header_row: header.index + 1,
|
||||
column_map: map,
|
||||
rejected,
|
||||
currency: fileCurrency,
|
||||
notes
|
||||
};
|
||||
}
|
||||
|
||||
/** 读成二维数组(xlsx / csv / tsv / txt 统一入口),AI 解析与规则解析共用 */
|
||||
function readMatrix(buffer, filename) {
|
||||
const isExcel = /\.(xlsx|xlsm|xls)$/i.test(String(filename || ''));
|
||||
const matrix = isExcel ? readWorkbook(buffer).matrix : readDelimited(buffer, filename);
|
||||
return matrix.filter(row => row.some(cell => String(cell ?? '').trim() !== ''));
|
||||
}
|
||||
|
||||
/** 二维数组 → CSV 文本,喂给模型用 */
|
||||
function matrixToText(matrix, { maxRows = 200, maxChars = 12000 } = {}) {
|
||||
const lines = [];
|
||||
let chars = 0;
|
||||
for (const row of matrix) {
|
||||
const line = row.map(cell => {
|
||||
const text = String(cell ?? '').replace(/\s+/g, ' ').trim();
|
||||
return /[",]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||
}).join(',').replace(/(?:,)+$/, '');
|
||||
if (!line.trim()) continue;
|
||||
if (lines.length >= maxRows || chars + line.length > maxChars) {
|
||||
lines.push('…(余下内容已截断)');
|
||||
break;
|
||||
}
|
||||
lines.push(line);
|
||||
chars += line.length;
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 把上传的原件转成「给模型看的文本」。
|
||||
* ⚠️ 只做格式转换,不做任何猜测 —— 猜是模型的事,转换是我们的事。
|
||||
*/
|
||||
function describeForAi(buffer, filename, options) {
|
||||
const matrix = readMatrix(buffer, filename);
|
||||
return { text: matrixToText(matrix, options), row_count: matrix.length };
|
||||
}
|
||||
|
||||
/** 给界面用:把认列结果翻译成人能读的一段话 */
|
||||
function describeMatch(columnMap, rejected = []) {
|
||||
const picked = Object.entries(columnMap)
|
||||
.map(([field, hit]) => `${DEFAULT_FIELDS[field]?.label || field}←「${hit.header}」(${hit.score}分)`)
|
||||
.join(',');
|
||||
const miss = rejected.length
|
||||
? `;被否掉:${rejected.map(item => `${item.header}(${item.reason})`).join(',')}`
|
||||
: '';
|
||||
return (picked || '没有认出任一字段') + miss;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_FIELDS,
|
||||
DEFAULT_OPTIONS,
|
||||
loadRules,
|
||||
overrideFile,
|
||||
parseUpload,
|
||||
readMatrix,
|
||||
matrixToText,
|
||||
describeForAi,
|
||||
matchColumns,
|
||||
detectHeaderRow,
|
||||
describeMatch,
|
||||
toNumber,
|
||||
normalizeHeader,
|
||||
guessCurrency,
|
||||
scoreHeader
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 客户归属与可见性口径(唯一事实来源)。
|
||||
*
|
||||
* Wayne 2026-09-14 定的规矩:
|
||||
* ① 客户有「所属」:谁建的归谁,默认只有归属人自己看得见;
|
||||
* ② 创建人主动丢进公海(pool_status = 'public')后,同组织成员都能看见、都能领取;
|
||||
* ③ 其余情况只有拿到 `customer.view_all` 的最高级别管理账号能查看。
|
||||
*
|
||||
* 「能看」与「能改」是两档:
|
||||
* 公海里的客户同组织成员都能看见(不然没法判断值不值得领),
|
||||
* 但只有归属人或管理账号能改 —— 别人想改得先「领取」。
|
||||
*
|
||||
* ⚠️ 注意 req.user 是 users 表的原始行(attachUser 直接塞的是 sessionUser 的结果),
|
||||
* 上面**没有** permissions 字段,权限必须用 auth.permissionsOfRole(role) 现推。
|
||||
*/
|
||||
const auth = require('./auth');
|
||||
|
||||
/** 越过归属看全部客户的权限点 */
|
||||
const VIEW_ALL = 'customer.view_all';
|
||||
|
||||
/** 是否属于「最高级别系统管理账号」 */
|
||||
function seesAll(user) {
|
||||
if (!user || !user.role) return false;
|
||||
return auth.permissionsOfRole(user.role).includes(VIEW_ALL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表可见性 → 可直接拼进 WHERE 的条件数组 + 参数。
|
||||
*
|
||||
* scope:
|
||||
* 'mine' 我的客户(默认)—— owner_id = 我
|
||||
* 'all' 全部客户 —— 需 customer.view_all,无权限时退回 'mine' 而不是报错
|
||||
* 'pool' 客户公海 —— pool_status = 'public',且限定本组织
|
||||
*
|
||||
* 调用方拼 SQL 时,customers 表必须别名为 **c**。
|
||||
*/
|
||||
function scopeFilter(user, scope = 'mine') {
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
|
||||
if (scope === 'pool') {
|
||||
conditions.push("c.pool_status = 'public'");
|
||||
if (user && user.org_id) {
|
||||
/* 公海是「组织(团队)」的资源,只放本组织的进来。
|
||||
存量没 org_id 的老数据不设门槛,免得刚升级就凭空少一批。 */
|
||||
conditions.push('(c.org_id IS NULL OR c.org_id = ?)');
|
||||
params.push(user.org_id);
|
||||
}
|
||||
return { conditions, params };
|
||||
}
|
||||
|
||||
if (scope === 'all' && seesAll(user)) {
|
||||
return { conditions, params };
|
||||
}
|
||||
|
||||
/* 「我的客户」= 归我 + 还没交出去。
|
||||
丢进公海后这条就从我的列表里移走,只留在「客户公海」——
|
||||
否则用户会疑惑「我都丢出去了怎么还在我这儿」。 */
|
||||
conditions.push('c.owner_id = ?');
|
||||
conditions.push("COALESCE(c.pool_status, 'owned') <> 'public'");
|
||||
params.push(user ? user.id : -1);
|
||||
return { conditions, params };
|
||||
}
|
||||
|
||||
/** 公海是「组织(团队)」的资源:两边都记了组织且不一致,就是外人 */
|
||||
function inSameOrg(user, customer) {
|
||||
const mine = user?.org_id;
|
||||
const theirs = customer?.org_id;
|
||||
/* 存量数据可能没组织,不设门槛 —— 与 scopeFilter 里 pool 的写法保持一致 */
|
||||
if (!mine || !theirs) return true;
|
||||
return Number(mine) === Number(theirs);
|
||||
}
|
||||
|
||||
/** 能不能看这条客户(详情页、跟进时间线) */
|
||||
function canView(user, customer) {
|
||||
if (!customer) return false;
|
||||
if (seesAll(user)) return true;
|
||||
if (user && Number(customer.owner_id) === Number(user.id)) return true;
|
||||
/* 公海客户同组织可见;别组织的公海客户连详情都不该看到 */
|
||||
if (customer.pool_status === 'public' && inSameOrg(user, customer)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 能不能改这条客户(基本信息、联系人、跟进记录) */
|
||||
function canEdit(user, customer) {
|
||||
if (!customer) return false;
|
||||
if (seesAll(user)) return true;
|
||||
return !!user && Number(customer.owner_id) === Number(user.id);
|
||||
}
|
||||
|
||||
module.exports = { VIEW_ALL, seesAll, scopeFilter, inSameOrg, canView, canEdit };
|
||||
+32
-16
@@ -23,8 +23,8 @@ const CACHE_FILE = path.join(DATA_DIR, 'fx-cache.json');
|
||||
|
||||
/** 内存缓存的保鲜期:1 小时后下次访问会去拉新数据。 */
|
||||
const TTL_MS = 60 * 60 * 1000;
|
||||
/** 单个数据源的超时。 */
|
||||
const TIMEOUT_MS = 8000;
|
||||
/** 单个数据源的超时。多个源是并行竞速的,所以这基本等于整体最坏耗时。 */
|
||||
const TIMEOUT_MS = 5000;
|
||||
const USER_AGENT = 'happy-export-fx';
|
||||
|
||||
/* ------------------------------ 数据源 ------------------------------ */
|
||||
@@ -149,25 +149,41 @@ let memory = readDiskCache();
|
||||
/** 同一个进程里并发调用只发一次网络请求。 */
|
||||
let inflight = null;
|
||||
|
||||
/**
|
||||
* 刷新汇率表。
|
||||
*
|
||||
* 所有数据源**并行竞速**,第一个拿到完整数据的胜出。
|
||||
* 早先这里是串行 for 循环,一次网络抖动要等 8s × 2 = 16s 才回退,
|
||||
* 用户看到的是「刷新失败 + 3 天前的旧数据」——其实另一个源可能早就成功了。
|
||||
* 并行之后最坏情况只等一个超时周期。
|
||||
*/
|
||||
async function refresh() {
|
||||
for (const provider of PROVIDERS) {
|
||||
const attempts = PROVIDERS.map(async provider => {
|
||||
const json = await fetchJson(provider.url);
|
||||
const picked = json && provider.pick(json);
|
||||
if (!picked) continue;
|
||||
if (!picked) throw new Error(`${provider.id} 无数据`);
|
||||
const rates = sanitizeRates(picked.rates);
|
||||
if (Object.keys(rates).length < 10) continue; // 明显不完整,换下一个源
|
||||
const payload = {
|
||||
rates,
|
||||
updatedAt: picked.updatedAt,
|
||||
fetchedAt: Date.now(),
|
||||
source: provider.id,
|
||||
sourceLabel: provider.label
|
||||
};
|
||||
memory = payload;
|
||||
writeDiskCache(payload);
|
||||
return payload;
|
||||
if (Object.keys(rates).length < 10) throw new Error(`${provider.id} 数据不完整`);
|
||||
return { provider, rates, updatedAt: picked.updatedAt };
|
||||
});
|
||||
|
||||
let winner;
|
||||
try {
|
||||
winner = await Promise.any(attempts);
|
||||
} catch {
|
||||
return null; // 所有源都失败,交给 getRates 走缓存 / 离线兜底
|
||||
}
|
||||
return null;
|
||||
|
||||
const payload = {
|
||||
rates: winner.rates,
|
||||
updatedAt: winner.updatedAt,
|
||||
fetchedAt: Date.now(),
|
||||
source: winner.provider.id,
|
||||
sourceLabel: winner.provider.label
|
||||
};
|
||||
memory = payload;
|
||||
writeDiskCache(payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 系统权限点清单(单一事实来源)。
|
||||
*
|
||||
* 设计口径(Wayne 2026-09-14 定):
|
||||
* - 「角色」是职位显示(董事长 / 总经理 / 销售经理 / 销售业务员 …),本身不带权限;
|
||||
* - 权限全部收敛到「权限组」:一个组勾选一批权限点(即下面的 key);
|
||||
* - 把角色指派进某个组(role_permission_groups),该角色下的所有用户即获得这组权限。
|
||||
*
|
||||
* 命名约定:`模块.动作`。前端菜单过滤、路由守卫、后端 requirePermission 都用同一批 key。
|
||||
*/
|
||||
|
||||
const PERMISSIONS = [
|
||||
/* 工作台 */
|
||||
{ key: 'dashboard.view', label: '查看工作台', group: '工作台' },
|
||||
|
||||
/* 业务管理 */
|
||||
{ key: 'inquiry.view', label: '查看询盘管理', group: '业务管理' },
|
||||
{ key: 'inquiry.edit', label: '编辑询盘管理', group: '业务管理' },
|
||||
{ key: 'quote.view', label: '查看客户报价单', group: '业务管理' },
|
||||
{ key: 'quote.edit', label: '编辑客户报价单', group: '业务管理' },
|
||||
{ key: 'contract.view', label: '查看 PI 合同', group: '业务管理' },
|
||||
{ key: 'contract.edit', label: '编辑 PI 合同', group: '业务管理' },
|
||||
{ key: 'purchase.view', label: '查看采购单', group: '业务管理' },
|
||||
{ key: 'purchase.edit', label: '编辑采购单', group: '业务管理' },
|
||||
{ key: 'order.view', label: '查看订单管理', group: '业务管理' },
|
||||
{ key: 'order.edit', label: '编辑订单管理', group: '业务管理' },
|
||||
|
||||
/* 客户管理 */
|
||||
{ key: 'customer.view', label: '查看我的客户', group: '客户管理' },
|
||||
{ key: 'customer.edit', label: '编辑我的客户', group: '客户管理' },
|
||||
/* 客户默认「谁建的谁看得见」,丢进公海才对同组织开放。
|
||||
拿到这个点才越过归属看全部客户 —— 按 Wayne 的口径只给最高级别的系统管理组。 */
|
||||
{ key: 'customer.view_all', label: '查看全部客户(含他人归属)', group: '客户管理' },
|
||||
{ key: 'prospect.view', label: '新客开发', group: '客户管理' },
|
||||
{ key: 'pool.view', label: '客户公海', group: '客户管理' },
|
||||
{ key: 'pool.claim', label: '客户公海领取', group: '客户管理' },
|
||||
|
||||
/* 采购管理 */
|
||||
{ key: 'product.view', label: '查看产品管理', group: '采购管理' },
|
||||
{ key: 'product.edit', label: '编辑产品管理', group: '采购管理' },
|
||||
{ key: 'supplier.view', label: '查看供应商管理', group: '采购管理' },
|
||||
{ key: 'supplier.edit', label: '编辑供应商管理', group: '采购管理' },
|
||||
{ key: 'cleanup.view', label: '合同清洗中心', group: '采购管理' },
|
||||
|
||||
/* 系统管理 */
|
||||
{ key: 'org.manage', label: '组织管理', group: '系统管理' },
|
||||
/* 组织与品牌(改公司名 / 组织代码 / 描述)单独成点,与「组织管理」解耦 ——
|
||||
Wayne 2026-09-15 的口径:这块**只允许高管改**,所以它得能单独勾给「公司高管」组,
|
||||
而不是搭在系统管理权限上。前端设置页分类、PATCH /api/organizations/:id 与
|
||||
PUT /api/branding 都认这一个点。 */
|
||||
{ key: 'branding.manage', label: '组织与品牌(公司信息)', group: '系统管理' },
|
||||
{ key: 'user.manage', label: '用户管理', group: '系统管理' },
|
||||
{ key: 'perm.manage', label: '权限组管理', group: '系统管理' },
|
||||
{ key: 'ai.manage', label: '智能配置(大模型接入与用途分配)', group: '系统管理' },
|
||||
{ key: 'audit.view', label: '审计日志', group: '系统管理' }
|
||||
]
|
||||
|
||||
const PERMISSION_KEYS = PERMISSIONS.map(item => item.key)
|
||||
|
||||
/* 角色清单:Wayne 列举的四个业务角色 + 系统管理员。权限组管理页的「角色指派」用它做候选。 */
|
||||
const ROLES = [
|
||||
{ value: 'admin', label: '系统管理员' },
|
||||
{ value: 'chairman', label: '董事长' },
|
||||
{ value: 'general_manager', label: '总经理' },
|
||||
{ value: 'sales_manager', label: '销售经理' },
|
||||
{ value: 'sales_rep', label: '销售业务员' }
|
||||
]
|
||||
|
||||
/* 开箱默认权限组。仅在首次建库/首次运行时写入,之后以库里的为准。 */
|
||||
const DEFAULT_PERMISSION_GROUPS = [
|
||||
{
|
||||
name: '超级管理员组',
|
||||
description: '全部权限,含组织、用户与权限组管理',
|
||||
is_system: 1,
|
||||
permissions: PERMISSION_KEYS,
|
||||
/* 只挂 admin。董事长 / 总经理改走下面的「公司高管」组 ——
|
||||
一个角色同时只能属于一个组(role_permission_groups.role 是主键),
|
||||
让他们留在超管组就等于「高管 = 系统管理员」,与 Wayne 2026-09-15 的分权口径冲突。 */
|
||||
roles: ['admin']
|
||||
},
|
||||
{
|
||||
name: '销售管理组',
|
||||
description: '业务全流程可读可写,可维护客户、产品与供应商,无系统管理权限',
|
||||
is_system: 0,
|
||||
/* 注意:`customer.view_all` 必须显式排除 —— 它不含 .manage 后缀,
|
||||
否则会被这个过滤器顺带带上,销售经理就能看到全公司的客户了。
|
||||
`branding.manage` 反过来说:它**靠** .manage 后缀被自动排除,
|
||||
销售经理永远拿不到改公司信息的权限(Wayne 口径:只有高管能改)。 */
|
||||
permissions: PERMISSION_KEYS.filter(key =>
|
||||
!key.endsWith('.manage') && key !== 'audit.view' && key !== 'customer.view_all'),
|
||||
roles: ['sales_manager']
|
||||
},
|
||||
{
|
||||
name: '销售业务组',
|
||||
description: '基础业务操作:维护自己名下的客户与询盘报价,产品只读',
|
||||
is_system: 0,
|
||||
permissions: [
|
||||
'dashboard.view',
|
||||
'inquiry.view', 'inquiry.edit',
|
||||
'quote.view', 'quote.edit',
|
||||
'contract.view',
|
||||
'purchase.view',
|
||||
'order.view',
|
||||
'customer.view', 'customer.edit',
|
||||
'prospect.view', 'pool.view', 'pool.claim',
|
||||
'product.view', 'supplier.view', 'cleanup.view'
|
||||
],
|
||||
roles: ['sales_rep']
|
||||
},
|
||||
{
|
||||
name: '公司高管',
|
||||
description: '董事长 / 总经理:除用户、权限组、智能配置与系统日志外的全部权限,含改公司信息',
|
||||
is_system: 0,
|
||||
/* 与线上库(2026-09-15)一致:全量权限里摘掉四个「系统管理员专属」的点。
|
||||
user.manage / perm.manage 是账号治理权,ai.manage 会看到所有模型密钥,
|
||||
audit.view 能看到全公司的操作轨迹 —— Wayne 口径是「系统日志仅系统管理员可见」。 */
|
||||
permissions: PERMISSION_KEYS.filter(key =>
|
||||
!['user.manage', 'perm.manage', 'ai.manage', 'audit.view'].includes(key)),
|
||||
roles: ['chairman', 'general_manager']
|
||||
}
|
||||
]
|
||||
|
||||
module.exports = { PERMISSIONS, PERMISSION_KEYS, ROLES, DEFAULT_PERMISSION_GROUPS }
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* 产品目录(SKU 模块)的本地数据层。
|
||||
*
|
||||
* 这块原先是独立的 sku-manager 服务(3300),happy 通过 /api/sku 代理访问它。
|
||||
* 合并后数据直接落在 trade.db:少一个进程、少一个端口,开源部署只跑 4300 + 5300。
|
||||
*
|
||||
* 表结构沿用 sku-manager 那套(products / product_groups / suppliers / item_attributes /
|
||||
* product_images / price_history / cart_items),列名保持一致 —— 迁移是按原 id 整表搬过来的,
|
||||
* 所以这里的外键关系与历史数据完全对得上,不需要任何 id 映射。
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const db = require('../db/db');
|
||||
|
||||
/* --------------------------------- 规范化 --------------------------------- */
|
||||
|
||||
/**
|
||||
* 货号规范化:去掉空格、连字符、下划线、斜杠、点,再转大写。
|
||||
* 合同里的「GD-100F」和产品库里的「GD100F」必须归到同一条。
|
||||
*/
|
||||
function normalizeSku(raw) {
|
||||
if (!raw) return null;
|
||||
return String(raw).toUpperCase().replace(/[\s\-_./]/g, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品归组键:同一单品的不同尺寸归为一个产品。
|
||||
* 只剥「尺寸/规格」类字样(8x10、200mm、1/2"、6寸),件数(6件套)保留 ——
|
||||
* 套装件数不同是不同的产品。
|
||||
*/
|
||||
function groupKey(name) {
|
||||
if (!name) return null;
|
||||
let s = String(name).replace(/\s+/g, '');
|
||||
s = s.replace(/\d+(\.\d+)?[x×*]\d+(\.\d+)?(mm|cm)?/gi, '');
|
||||
s = s.replace(/\d+\/\d+(["″”寸]|英寸)?/g, '');
|
||||
s = s.replace(/\d+(\.\d+)?(mm|cm|寸|英寸|inch|in|"|″|”|米)/gi, '');
|
||||
s = s.replace(/[-—–()()]/g, '');
|
||||
const key = s.toUpperCase();
|
||||
return key || String(name).trim().toUpperCase();
|
||||
}
|
||||
|
||||
/* ------------------------------ 二进制存储 ------------------------------ */
|
||||
|
||||
const MIME_BY_EXT = {
|
||||
'.pdf': 'application/pdf',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.xls': 'application/vnd.ms-excel',
|
||||
'.csv': 'text/csv',
|
||||
'.txt': 'text/plain',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.gif': 'image/gif',
|
||||
'.bmp': 'image/bmp'
|
||||
};
|
||||
|
||||
function mimeFor(name) {
|
||||
const i = String(name).lastIndexOf('.');
|
||||
return (i >= 0 && MIME_BY_EXT[String(name).slice(i).toLowerCase()]) || 'application/octet-stream';
|
||||
}
|
||||
|
||||
const syncImageStmt = db.prepare(
|
||||
`INSERT INTO product_image_files (image_id, bytes, size, stored_at)
|
||||
VALUES (@image_id, @bytes, @size, @stored_at)
|
||||
ON CONFLICT(image_id) DO UPDATE SET bytes = excluded.bytes, size = excluded.size, stored_at = excluded.stored_at`
|
||||
);
|
||||
const getImageStmt = db.prepare('SELECT bytes, size FROM product_image_files WHERE image_id = ?');
|
||||
const delImageStmt = db.prepare('DELETE FROM product_image_files WHERE image_id = ?');
|
||||
|
||||
const storeImage = (imageId, buf) =>
|
||||
syncImageStmt.run({ image_id: imageId, bytes: buf, size: buf.length, stored_at: new Date().toISOString() });
|
||||
|
||||
const getImage = (imageId) => getImageStmt.get(imageId);
|
||||
const deleteImage = (imageId) => delImageStmt.run(imageId);
|
||||
|
||||
/** 批量删图(批量删产品时用),避免逐条 delete。 */
|
||||
function deleteImagesFor(imageIds) {
|
||||
if (!imageIds.length) return;
|
||||
db.prepare(`DELETE FROM product_image_files WHERE image_id IN (${imageIds.map(() => '?').join(',')})`)
|
||||
.run(...imageIds);
|
||||
}
|
||||
|
||||
/* ------------------------------- 合同原件 ------------------------------- */
|
||||
|
||||
const putContractStmt = db.prepare(
|
||||
`INSERT INTO contract_files (contract_id, filename, mime, size, sha256, bytes, stored_at)
|
||||
VALUES (@contract_id, @filename, @mime, @size, @sha256, @bytes, @stored_at)
|
||||
ON CONFLICT(contract_id) DO UPDATE SET
|
||||
filename = excluded.filename, mime = excluded.mime, size = excluded.size,
|
||||
sha256 = excluded.sha256, bytes = excluded.bytes, stored_at = excluded.stored_at`
|
||||
);
|
||||
|
||||
/** 把一份合同原件收进库。返回 null 表示没收到(不算致命,解析结果才是主产物)。 */
|
||||
function storeContractFile(contractId, bytes, displayName) {
|
||||
if (!bytes || !bytes.length) return null;
|
||||
const filename = displayName || `contract-${contractId}`;
|
||||
putContractStmt.run({
|
||||
contract_id: contractId,
|
||||
filename,
|
||||
mime: mimeFor(filename),
|
||||
size: bytes.length,
|
||||
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
|
||||
bytes,
|
||||
stored_at: new Date().toISOString()
|
||||
});
|
||||
return { size: bytes.length };
|
||||
}
|
||||
|
||||
const getContractFile = (contractId) =>
|
||||
db.prepare('SELECT filename, mime, size, bytes FROM contract_files WHERE contract_id = ?').get(contractId);
|
||||
|
||||
/* --------------------------------- 参数 --------------------------------- */
|
||||
|
||||
const getItemAttrs = db.prepare(
|
||||
'SELECT id, name, value, sort FROM item_attributes WHERE sku = ? ORDER BY sort, id'
|
||||
);
|
||||
const getGroupAttrs = db.prepare(
|
||||
'SELECT id, name, value, sort FROM item_attributes WHERE group_id = ? AND sku IS NULL ORDER BY sort, id'
|
||||
);
|
||||
|
||||
/** 整组替换参数(货号级或产品级)。传入 [{name, value}],空名跳过。 */
|
||||
function replaceAttrs({ sku = null, groupId = null }, attrs) {
|
||||
if (!Array.isArray(attrs)) return;
|
||||
db.transaction(() => {
|
||||
if (sku) db.prepare('DELETE FROM item_attributes WHERE sku = ?').run(sku);
|
||||
else db.prepare('DELETE FROM item_attributes WHERE group_id = ? AND sku IS NULL').run(groupId);
|
||||
const ins = db.prepare(
|
||||
'INSERT INTO item_attributes (group_id, sku, name, value, sort) VALUES (@group_id, @sku, @name, @value, @sort)'
|
||||
);
|
||||
attrs.forEach((a, i) => {
|
||||
const name = String(a?.name || '').trim();
|
||||
if (!name) return;
|
||||
ins.run({ group_id: sku ? null : groupId, sku, name, value: String(a?.value ?? '').trim() || null, sort: i });
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
/** 产品组变空后清理掉,避免列表里留下没有货号的壳(有图片的保留)。 */
|
||||
function cleanupGroupIfEmpty(groupId) {
|
||||
if (!groupId) return;
|
||||
const hasItems = db.prepare('SELECT 1 FROM products WHERE group_id = ? LIMIT 1').get(groupId);
|
||||
const hasImages = db.prepare('SELECT 1 FROM product_images WHERE group_id = ? LIMIT 1').get(groupId);
|
||||
if (!hasItems && !hasImages) {
|
||||
db.prepare('DELETE FROM item_attributes WHERE group_id = ?').run(groupId);
|
||||
db.prepare('DELETE FROM product_groups WHERE id = ?').run(groupId);
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------- 货号 --------------------------------- */
|
||||
|
||||
function validatePriceMoq(b) {
|
||||
const price = b.price === '' || b.price == null ? null : Number(b.price);
|
||||
if (price !== null && (!Number.isFinite(price) || price < 0)) return { error: '采购价必须是非负数字' };
|
||||
const moq = b.moq === '' || b.moq == null ? null : Number(b.moq);
|
||||
if (moq !== null && (!Number.isFinite(moq) || moq < 0)) return { error: '起订量必须是非负数字' };
|
||||
return { price, moq };
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增货号。
|
||||
*
|
||||
* 货号重复**不当错误** —— 产品库是组织共享的,谁都能上传自己那一版:
|
||||
* 撞号时落成兄弟副本 `<SKU>-COPY2 / -COPY3 …`,display_sku 加「-副本N」,
|
||||
* 并用 duplicated_from 记住它复制自哪个货号,界面上标出来,避免两条同名货号看着像脏数据。
|
||||
* 无论是否重复都记 uploader / org_id;手工价同时写 changed_by,保证「谁传的、谁改的价」可追。
|
||||
*/
|
||||
function insertItem(b, groupId, supplierId, actor = null, actorOrg = null, options = {}) {
|
||||
const displaySku = String(b.sku || '').trim();
|
||||
const base = normalizeSku(displaySku);
|
||||
if (!base) return { error: '货号不能为空' };
|
||||
const v = validatePriceMoq(b);
|
||||
if (v.error) return v;
|
||||
|
||||
const taken = (candidate) => !!db.prepare('SELECT sku FROM products WHERE sku = ?').get(candidate);
|
||||
let sku = base;
|
||||
let display = displaySku;
|
||||
let duplicatedFrom = null;
|
||||
if (taken(base)) {
|
||||
duplicatedFrom = base;
|
||||
let n = 2;
|
||||
while (taken(`${base}-COPY${n}`)) n += 1;
|
||||
sku = `${base}-COPY${n}`;
|
||||
display = `${displaySku}-副本${n}`;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
/* options 只在「带来源的批量导入」里传(目前是合同清洗中心的入库):
|
||||
sourceContract —— 价格出处,只记合同**文件名**(Wayne 2026-09-16:不记路径、不记任务号)
|
||||
parseMethod —— 'manual' | 'rules' | 'ai',用来区分这条货号是手录还是合同带进来的
|
||||
currency —— 新建时按合同的币种落库;不传仍然是 CNY(产品库原有口径)
|
||||
⚠️ 更新已有货号时**不动 currency** —— 汇率口径牵一发动全身,不在这里顺手改。 */
|
||||
const sourceContract = options.sourceContract || null;
|
||||
const parseMethod = options.parseMethod || 'manual';
|
||||
const currency = String(options.currency || 'CNY').toUpperCase();
|
||||
db.transaction(() => {
|
||||
db.prepare(
|
||||
`INSERT INTO products (sku, display_sku, name, spec, price, currency, moq, description,
|
||||
supplier_id, contract_id, group_id, parse_method,
|
||||
last_updated, source_contract, confidence,
|
||||
uploader, org_id, duplicated_from, created_at, updated_at)
|
||||
VALUES (@sku, @display_sku, @name, @spec, @price, @currency, @moq, @description,
|
||||
@supplier_id, NULL, @group_id, @parse_method, @now, @source_contract, 'high',
|
||||
@uploader, @org_id, @duplicated_from, @now, @now)`
|
||||
).run({
|
||||
sku,
|
||||
display_sku: display,
|
||||
name: b.name?.trim() || null,
|
||||
spec: b.spec?.trim() || null,
|
||||
price: v.price,
|
||||
moq: v.moq,
|
||||
currency,
|
||||
description: b.description?.trim() || null,
|
||||
supplier_id: supplierId,
|
||||
group_id: groupId,
|
||||
parse_method: parseMethod,
|
||||
source_contract: sourceContract,
|
||||
now,
|
||||
uploader: actor,
|
||||
org_id: actorOrg,
|
||||
duplicated_from: duplicatedFrom
|
||||
});
|
||||
/* 手工价格同样留痕,保证「每个价格都能追到出处」这条不破功。
|
||||
带来源时(合同入库)把合同文件名当出处写进去,手工录入才写「手工录入」。 */
|
||||
if (v.price !== null) {
|
||||
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, NULL, @price, NULL, @moq, @currency, @supplier_id, @source_contract, 'high', @now, @changed_by)`
|
||||
).run({
|
||||
sku,
|
||||
price: v.price,
|
||||
moq: v.moq,
|
||||
currency,
|
||||
supplier_id: supplierId,
|
||||
source_contract: sourceContract || '手工录入',
|
||||
now,
|
||||
changed_by: actor
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
if (Array.isArray(b.attributes)) replaceAttrs({ sku }, b.attributes);
|
||||
return { sku, displaySku: display, duplicatedFrom };
|
||||
}
|
||||
|
||||
/* ------------------------------- 产品解析 ------------------------------- */
|
||||
|
||||
/**
|
||||
* 解析货号,返回统一的 { product, packaging } 视图。
|
||||
*
|
||||
* 合并前这里要先打 SKU 服务、失败再回退本地镜像(镜像只兜底,绝不拿过期价去报价)。
|
||||
* 现在数据就在本库,直接查即可 —— 单表主键命中,比原来走一趟 HTTP 快得多。
|
||||
* packaging 始终取自本地 product_packaging(询盘/报价用的包装快照来源)。
|
||||
*/
|
||||
function resolveProduct(sku) {
|
||||
const raw = String(sku || '').trim();
|
||||
if (!raw) throw new Error('SKU 不能为空');
|
||||
|
||||
const key = normalizeSku(raw);
|
||||
const row = db.prepare(
|
||||
`SELECT p.*, s.name AS supplier_name FROM products p
|
||||
LEFT JOIN suppliers s ON s.id = p.supplier_id WHERE p.sku = ?`
|
||||
).get(key);
|
||||
if (!row) throw new Error(`产品 SKU 不存在:${raw}`);
|
||||
|
||||
const product = {
|
||||
...row,
|
||||
sku: row.sku,
|
||||
display_sku: row.display_sku || row.sku,
|
||||
name: row.name || row.name_en || row.sku,
|
||||
name_en: row.name_en || null,
|
||||
price: row.price,
|
||||
currency: row.currency || 'CNY',
|
||||
moq: row.moq === undefined ? null : row.moq
|
||||
};
|
||||
const packaging = db.prepare('SELECT * FROM product_packaging WHERE sku = ?').get(row.sku) || null;
|
||||
return { product, packaging };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeSku,
|
||||
groupKey,
|
||||
mimeFor,
|
||||
storeImage,
|
||||
getImage,
|
||||
deleteImage,
|
||||
deleteImagesFor,
|
||||
storeContractFile,
|
||||
getContractFile,
|
||||
getItemAttrs,
|
||||
getGroupAttrs,
|
||||
replaceAttrs,
|
||||
cleanupGroupIfEmpty,
|
||||
validatePriceMoq,
|
||||
insertItem,
|
||||
resolveProduct
|
||||
};
|
||||
Reference in New Issue
Block a user