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
+195
View File
@@ -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 };
+487
View File
@@ -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
};
+227
View File
@@ -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
};
+71
View File
@@ -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 };
+165
View File
@@ -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
};
+609
View File
@@ -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
};
+91
View File
@@ -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
View File
@@ -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;
}
/**
+123
View File
@@ -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 }
+297
View File
@@ -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
};