client/server.js

780 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const express = require('express');
const session = require('express-session');
const bcrypt = require('bcryptjs');
const multer = require('multer');
const rateLimit = require('express-rate-limit');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const PORT = process.env.PORT || 3210;
// 127.0.0.1 by default: on a VPS, Nginx fronts this with TLS and is the only
// thing that should be reachable from outside. On the old local-Windows setup
// this was 0.0.0.0 for LAN access; set HOST=0.0.0.0 explicitly if you still
// need that. COOKIE_SECURE should be "true" once you're behind real HTTPS.
const HOST = process.env.HOST || '127.0.0.1';
const COOKIE_SECURE = process.env.COOKIE_SECURE === 'true';
const DATA_FILE = path.join(__dirname, 'data.json');
const PID_FILE = path.join(__dirname, 'server.pid');
const FRONTEND_DIST = path.join(__dirname, '..', 'frontend', 'dist');
const UPLOADS_DIR = path.join(__dirname, 'uploads');
const AUTH_FILE = path.join(__dirname, 'auth.json');
const DEFAULT_USERNAME = 'admin';
const DEFAULT_PASSWORD = 'admin123';
// AI批量导入解析时PDF渲染成图片/OCR用的临时文件放在这里而不是系统的 os.tmpdir()(一般是
// /tmp——有些VPS/容器环境会把 /tmp 挂载成只读或者单独的受限分区,直接用系统临时目录在这类
// 环境下会导致 mkdtempSync 抛出 EROFS 之类的错误,而且这个调用当时没被 try/catch 包住,会
// 直接让整个Node进程崩溃。用应用自己目录下这个文件夹保证和 uploads/ 一样一定可读写。
const OCR_TMP_ROOT = path.join(__dirname, 'tmp-ocr');
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true });
if (!fs.existsSync(OCR_TMP_ROOT)) fs.mkdirSync(OCR_TMP_ROOT, { recursive: true });
/* --------------------------------- auth ---------------------------------- */
function loadAuth() {
if (!fs.existsSync(AUTH_FILE)) {
const auth = { username: DEFAULT_USERNAME, passwordHash: bcrypt.hashSync(DEFAULT_PASSWORD, 10) };
fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf8');
return auth;
}
try {
return JSON.parse(fs.readFileSync(AUTH_FILE, 'utf8'));
} catch (e) {
const auth = { username: DEFAULT_USERNAME, passwordHash: bcrypt.hashSync(DEFAULT_PASSWORD, 10) };
fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf8');
return auth;
}
}
function saveAuth(auth) {
fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf8');
}
/* ------------------------------ secret / crypto -------------------------- */
// Used to encrypt the DeepSeek API key (and anything else sensitive) at
// rest, so it isn't sitting around the filesystem as plain text.
const SECRET_KEY_FILE = path.join(__dirname, '.secret-key');
function getSecretKey() {
if (!fs.existsSync(SECRET_KEY_FILE)) {
const key = crypto.randomBytes(32);
fs.writeFileSync(SECRET_KEY_FILE, key.toString('base64'), { encoding: 'utf8', mode: 0o600 });
return key;
}
return Buffer.from(fs.readFileSync(SECRET_KEY_FILE, 'utf8').trim(), 'base64');
}
const SECRET_KEY = getSecretKey();
function encrypt(text) {
if (!text) return '';
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', SECRET_KEY, iv);
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString('base64');
}
function decrypt(b64) {
if (!b64) return '';
try {
const buf = Buffer.from(b64, 'base64');
const iv = buf.subarray(0, 12);
const tag = buf.subarray(12, 28);
const encrypted = buf.subarray(28);
const decipher = crypto.createDecipheriv('aes-256-gcm', SECRET_KEY, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
} catch (e) {
return '';
}
}
/* --------------------------------- AI config ------------------------------ */
const AI_CONFIG_FILE = path.join(__dirname, 'ai-config.json');
// Lets you point this at a different OpenAI-compatible endpoint if needed
// (e.g. testing against a mock server); defaults to the real DeepSeek API.
const DEEPSEEK_API_BASE = process.env.DEEPSEEK_API_BASE || 'https://api.deepseek.com';
function loadAiConfig() {
try {
const raw = JSON.parse(fs.readFileSync(AI_CONFIG_FILE, 'utf8'));
return { apiKey: decrypt(raw.apiKeyEnc || '') };
} catch (e) {
return { apiKey: '' };
}
}
function saveAiConfig(apiKey) {
fs.writeFileSync(AI_CONFIG_FILE, JSON.stringify({ apiKeyEnc: encrypt(apiKey) }, null, 2), 'utf8');
}
const REGION_LABELS = {
africa: 'Africa', middle_east: 'Middle East', australia: 'Australia/Oceania',
europe: 'Europe', sea: 'Southeast Asia', other: 'Other',
};
function buildCustomerContext(c) {
const lines = [];
lines.push(`Company: ${c.company || '(unknown / not on file)'}`);
if (c.contact) lines.push(`Contact person: ${c.contact}`);
const place = [c.country, REGION_LABELS[c.region]].filter(Boolean).join(', ');
if (place) lines.push(`Location: ${place}`);
if (c.productLines && c.productLines.length) lines.push(`Product interest: ${c.productLines.join(', ')}`);
if (c.intendedModel || c.intendedQty) {
lines.push(`Specific model interest: ${c.intendedModel || '(model unspecified)'}${c.intendedQty ? `, quantity: ${c.intendedQty}` : ''}`);
}
if (c.lastContact) lines.push(`Last contact date: ${c.lastContact}`);
if (c.notes) lines.push(`Background research on this customer: ${c.notes}`);
if (c.tags) lines.push(`Additional remarks: ${c.tags}`);
if (c.isVip) lines.push('This is a VIP / priority customer — extra attentive tone.');
if (c.hasOrdered) lines.push('This customer has already placed an order before — this follow-up is with an existing buyer, not a cold lead.');
return lines.join('\n');
}
async function callDeepSeek(apiKey, messages, opts) {
const { maxTokens = 600, temperature = 0.7, model = 'deepseek-v4-flash' } = opts || {};
const resp = await fetch(`${DEEPSEEK_API_BASE}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages,
temperature,
max_tokens: maxTokens,
}),
});
if (!resp.ok) {
const errText = await resp.text().catch(() => '');
throw new Error(`DeepSeek API error ${resp.status}: ${errText.slice(0, 200)}`);
}
const data = await resp.json();
const choice = data.choices && data.choices[0];
const content = choice && choice.message && choice.message.content;
if (!content) {
const reason = choice && choice.finish_reason ? `finish_reason: ${choice.finish_reason}` : '';
throw new Error(`DeepSeek API returned an empty response${reason}`);
}
return content;
}
// 空响应这种问题偶发性比较大有时候纯粹是API抽风重试一次往往就好了
// 其他类型的错误比如key不对、额度用完重试也没用直接抛出不浪费时间
async function callDeepSeekWithRetry(apiKey, messages, opts, retries = 1) {
let lastErr;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await callDeepSeek(apiKey, messages, opts);
} catch (e) {
lastErr = e;
if (!/empty response/i.test(e.message) || attempt === retries) throw e;
}
}
throw lastErr;
}
// 从DeepSeek返回的内容里提取JSON——模型有时会用```json代码块包一层或者前后带几句解释文字
// 这里做宽松解析:优先找代码块,找不到就找第一个 { 或 [ 开始的内容。
// 返回 { data, truncated }如果完整JSON解析失败常见原因是撞到了max_tokens数组还没写完
// 就被截断了),会尝试抢救出前面已经完整生成的那些顶层对象,丢弃最后一个写了一半的,这样至少
// 能拿到部分结果,而不是整批因为最后一个字符缺失就全部作废。
function extractJson(content) {
const fenced = content.match(/```(?:json)?\s*([\s\S]*?)```/i);
const raw = fenced ? fenced[1] : content;
const start = raw.search(/[\[{]/);
if (start === -1) throw new Error('AI返回内容里没有找到JSON');
const trimmed = raw.slice(start);
try {
return { data: JSON.parse(trimmed), truncated: false };
} catch (e) {
const salvaged = salvageTruncatedJsonArray(trimmed);
if (salvaged !== null) return { data: salvaged, truncated: true };
throw new Error(`AI返回的内容不是完整的JSON解析失败${e.message}`);
}
}
// 只处理"最外层是数组"的截断抢救:逐字符扫描(跳过字符串内部的引号/转义),每次遇到顶层元素
// 完整闭合(深度归零)就记一下位置,最后从抢救到的最后一个完整位置那里把数组截断重新拼上 ]。
function salvageTruncatedJsonArray(text) {
const t = text.trim();
if (!t.startsWith('[')) return null;
let depth = 0;
let lastCompleteEnd = -1;
let inString = false;
let escapeNext = false;
for (let i = 1; i < t.length; i++) {
const ch = t[i];
if (inString) {
if (escapeNext) escapeNext = false;
else if (ch === '\\') escapeNext = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') { inString = true; continue; }
if (ch === '{' || ch === '[') depth++;
else if (ch === '}' || ch === ']') {
depth--;
if (depth === 0 && ch === '}') lastCompleteEnd = i; // 顶层数组里某个对象完整闭合了
}
}
if (lastCompleteEnd === -1) return null;
try {
return JSON.parse(t.slice(0, lastCompleteEnd + 1) + ']');
} catch (e) {
return null;
}
}
/* ------------------------------ data file ------------------------------ */
function readData() {
try {
return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
} catch (e) {
return {};
}
}
function writeData(data) {
// write to a temp file then rename, avoids corrupting data.json if the
// process is killed mid-write
const tmp = DATA_FILE + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
fs.renameSync(tmp, DATA_FILE);
}
// only allow simple ids (our customer ids are like "c_169..._ab12c"),
// this keeps the upload path from ever escaping the uploads/ folder
function safeId(id) {
return /^[A-Za-z0-9_-]+$/.test(id) ? id : null;
}
/* --------------------------------- app ---------------------------------- */
const app = express();
// behind Nginx, Express needs this to read X-Forwarded-* correctly —
// otherwise req.ip is always Nginx's own address (breaks rate limiting)
// and the "secure" cookie flag can't tell the request was HTTPS.
app.set('trust proxy', 1);
app.use(express.json({ limit: '10mb' }));
app.use(session({
secret: crypto.randomBytes(32).toString('hex'),
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: COOKIE_SECURE,
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
},
}));
function requireAuth(req, res, next) {
if (req.session && req.session.authed) return next();
res.status(401).json({ error: 'not authenticated' });
}
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'too many login attempts, please try again in a few minutes' },
});
/* -------------------------------- auth api ------------------------------- */
app.post('/api/auth/login', loginLimiter, (req, res) => {
const body = req.body || {};
const username = body.username || '';
const password = body.password || '';
const auth = loadAuth();
if (username === auth.username && bcrypt.compareSync(password, auth.passwordHash)) {
req.session.authed = true;
req.session.username = username;
return res.json({ ok: true, username });
}
res.status(401).json({ error: 'invalid credentials' });
});
app.post('/api/auth/logout', (req, res) => {
if (req.session) {
req.session.destroy(() => res.json({ ok: true }));
} else {
res.json({ ok: true });
}
});
app.get('/api/auth/check', (req, res) => {
const authed = !!(req.session && req.session.authed);
res.json({ authed, username: authed ? req.session.username : null });
});
app.post('/api/auth/change-password', requireAuth, (req, res) => {
const body = req.body || {};
const currentPassword = body.currentPassword || '';
const newPassword = body.newPassword || '';
const auth = loadAuth();
if (!bcrypt.compareSync(currentPassword, auth.passwordHash)) {
return res.status(400).json({ error: 'current password is incorrect' });
}
if (newPassword.length < 4) {
return res.status(400).json({ error: 'new password is too short' });
}
auth.passwordHash = bcrypt.hashSync(newPassword, 10);
saveAuth(auth);
res.json({ ok: true });
});
/* ------------------------------ storage api ------------------------------ */
app.get('/api/storage/:key', requireAuth, (req, res) => {
const data = readData();
const key = req.params.key;
if (!(key in data)) return res.status(404).json({ error: 'not found' });
res.json({ value: data[key] });
});
app.put('/api/storage/:key', requireAuth, (req, res) => {
const data = readData();
data[req.params.key] = req.body.value;
writeData(data);
res.json({ ok: true });
});
/* --------------------------------- ai api --------------------------------- */
app.get('/api/ai-config', requireAuth, (req, res) => {
const cfg = loadAiConfig();
res.json({ hasKey: !!cfg.apiKey });
});
app.put('/api/ai-config', requireAuth, (req, res) => {
const apiKey = ((req.body && req.body.apiKey) || '').trim();
if (!apiKey) return res.status(400).json({ error: 'apiKey is required' });
saveAiConfig(apiKey);
res.json({ ok: true });
});
app.delete('/api/ai-config', requireAuth, (req, res) => {
try { fs.unlinkSync(AI_CONFIG_FILE); } catch (e) {}
res.json({ ok: true });
});
app.post('/api/ai-config/test', requireAuth, async (req, res) => {
const cfg = loadAiConfig();
if (!cfg.apiKey) return res.status(400).json({ error: 'no API key configured' });
try {
const content = await callDeepSeek(cfg.apiKey, [{ role: 'user', content: 'Reply with exactly: OK' }]);
res.json({ ok: true, reply: content.trim() });
} catch (e) {
res.status(400).json({ error: e.message });
}
});
const aiLimiter = rateLimit({
windowMs: 10 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'too many AI requests, please slow down' },
});
app.post('/api/ai/draft', requireAuth, aiLimiter, async (req, res) => {
const body = req.body || {};
const customerId = body.customerId;
const channel = body.channel;
if (!customerId || !['email', 'whatsapp'].includes(channel)) {
return res.status(400).json({ error: 'invalid request' });
}
const cfg = loadAiConfig();
if (!cfg.apiKey) return res.status(400).json({ error: 'DeepSeek API key not configured' });
const data = readData();
let customers = [];
try { customers = JSON.parse(data['crm:customers'] || '[]'); } catch (e) {}
const customer = customers.find((c) => c.id === customerId);
if (!customer) return res.status(404).json({ error: 'customer not found' });
const context = buildCustomerContext(customer);
const systemPrompt = 'You are a sales assistant helping a B2B export salesperson at a Chinese power-equipment company (UPS systems, rectifiers, solar inverters, battery storage) write natural, professional follow-up outreach to overseas customers. Always write in English. Never sound like a generic template, reference specifics from the customer info given. Output only the requested content, with no meta-commentary, explanations, or markdown formatting.';
const userPrompt = channel === 'email'
? `Customer info:\n${context}\n\nWrite a short, warm but professional follow-up email to this customer. Respond in EXACTLY this format with no extra text:\nSUBJECT: <subject line>\nBODY:\n<email body, 3-5 short paragraphs max, sign off with "[Your name]">`
: `Customer info:\n${context}\n\nWrite a short, casual WhatsApp message to check in with this customer (2-4 sentences, friendly tone, no formal greeting like "Dear", a light emoji is fine if it feels natural). Respond with ONLY the message text, nothing else.`;
try {
const content = await callDeepSeek(cfg.apiKey, [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
]);
if (channel === 'email') {
const subjectMatch = content.match(/SUBJECT:\s*(.+)/i);
const bodyMatch = content.match(/BODY:\s*([\s\S]*)/i);
res.json({
subject: subjectMatch ? subjectMatch[1].trim() : 'Following up',
body: bodyMatch ? bodyMatch[1].trim() : content.trim(),
});
} else {
res.json({ body: content.trim() });
}
} catch (e) {
res.status(502).json({ error: e.message });
}
});
/* --------------------------- ai catalog import --------------------------- */
// 上传一份/多份PDF或图片形式的产品目录可能包含多个型号做OCR文字识别后交给
// DeepSeek解析成结构化的产品字段数组前端拿到后先给用户一个预览/确认页面,
// 用户确认后再批量建档。
//
// 实测发现很多正式排版的产品目录PDF比如设计软件导出的宣传册整页都是图片/矢量
// 图形,没有可提取的文字层,所以这里统一走"渲染成图片 + OCR"这条路,而不是直接
// 提取PDF文字——这样PDF和图片两种输入方式可以复用同一套OCR逻辑。
//
// 依赖:
// 1. npm包 tesseract.js纯JS+WASM不需要装系统级OCR程序但第一次识别时会从
// jsdelivr CDN下载语言包VPS需要能访问外网
// 2. 系统程序 poppler-utils只有上传PDF时才需要用来把PDF每页渲染成图片
// apt install -y poppler-utils
const { execFile } = require('child_process');
const Tesseract = require('tesseract.js');
const uploadMemory = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 20 * 1024 * 1024, files: 30 }, // 20MB/文件最多30个文件
});
// 把一份PDF的每一页渲染成PNG图片用poppler-utils的pdftoppm返回图片路径数组
function renderPdfToImages(pdfBuffer, tmpDir) {
return new Promise((resolve, reject) => {
const pdfPath = path.join(tmpDir, 'source.pdf');
fs.writeFileSync(pdfPath, pdfBuffer);
const outPrefix = path.join(tmpDir, 'page');
execFile('pdftoppm', ['-png', '-r', '200', pdfPath, outPrefix], (err) => {
if (err) {
return reject(new Error(
`PDF转图片失败请确认服务器已安装 poppler-utilsSSH执行apt install -y poppler-utils${err.message}`
));
}
const files = fs.readdirSync(tmpDir)
.filter((f) => f.startsWith('page') && f.endsWith('.png'))
.sort()
.map((f) => path.join(tmpDir, f));
resolve(files);
});
});
}
// 复用同一个OCR worker避免每次请求都重新初始化/重新下载语言包
let ocrWorkerPromise = null;
function getOcrWorker() {
if (!ocrWorkerPromise) {
ocrWorkerPromise = Tesseract.createWorker('eng+chi_sim').catch((e) => {
ocrWorkerPromise = null; // 初始化失败下次重试,不要把失败结果缓存住
throw e;
});
}
return ocrWorkerPromise;
}
async function ocrImages(imagePaths) {
const worker = await getOcrWorker();
const parts = [];
for (const p of imagePaths) {
const { data } = await worker.recognize(p);
if (data && data.text) parts.push(data.text.trim());
}
return parts.join('\n\n--- 下一页 ---\n\n').trim();
}
const CATALOG_PARSE_SYSTEM_PROMPT = `你是一个电力设备UPS/逆变器/储能电池等)产品资料的结构化提取助手。
用户会给你一份产品目录/规格书经OCR识别出的文字内容可能包含多个不同型号的产品每个产品通常有自己的Specifications表格
OCR识别可能有少量错字或格式错乱请结合上下文合理判断
你的任务识别文本里有几个不同型号的产品为每一个产品提取字段严格按下面的JSON schema输出一个数组每个元素是一个产品对象
【特别注意"系列名"和"具体型号"的区别】很多规格表会在标题里写"XX系列智能在线式UPS"这种大标题(这是整个系列的统称,
不是某个产品的型号),真正的具体型号一定在表格里"型号"那一行/列里能找到比如ATP-10K/CRT、ATP-20K/CRT这种
绝对不能把标题里的系列泛称当成name字段的值去输出一条"合并"的记录——表格里"型号"那一行有几个不同的型号名称,
就必须输出几条独立的产品记录,哪怕它们之间只有额定功率/尺寸/净重这几个数字不一样、其他参数全部相同,也不能因为
"看起来很像"就合并成一条、更不能因为拿不准就用系列名代替。如果OCR识别出来的文字比较模糊、型号名称对不齐对应的
数值,宁可把能确定的字段填全、拿不准的字段留空,也不要瞎猜合并。
【特别注意"型号(L)"这种带括号的写法】规格表的型号列里如果出现类似"MS1000(L)"这种写法,代表这里其实是两个型号,
要拆成两条独立的产品记录输出,不能只输出一条:
- 不带L后缀的如MS1000是标准机型machineVariant填"标准机型"
- 带L后缀的如MS1000L是长延时机型machineVariant填"长延时机型"
这两条记录的输入/输出/保护/警告/接口/环境这些参数完全共用同一份数据,只有 batterySpec电池型号和数量
chargeCurrent充电电流、dimensions尺寸、netWeight净重这几项不同——严格按表格里"Standard Model/标准机型"
和"Long-run Model/长延机型"这两个子分类去分别取值标准机型用Standard Model那部分数据长延时机型用Long-run Model那部分数据。
如果某个型号在"Standard Model"部分对应列是NA或空、只有"Long-run Model"部分有数据,说明这个型号本身就是长延机型
(哪怕名字里没有明显的"(L)"标记也一样),直接当长延时机型处理,不要再凭空拆出一个去掉后缀的"标准版";反过来如果只有
"Standard Model"部分有数据、"Long-run Model"部分是NA或空说明这个型号只有标准机型也不要凭空造一个带L的长延版本。
【特别注意"UPS配套锂电池系统"这类产品的两段式结构】这类产品(储能电池大类下)的规格表通常明确分成两个区域,
一个是"模块/Module"(单个电池模块本身的参数),一个是"锂电池系统柜/电池系统柜"(整个机柜/整套系统的参数),
跟模块化UPS"功率模块 + UPS机柜"是同样的两段式逻辑。这两个区域各自包含好几项参数,请务必把两个区域的每一项
都完整提取,绝对不能只提取其中一个区域、漏掉另一个区域——常见的漏提取情况是:只填了型号/循环寿命/通信接口/
保护功能这种"看起来一目了然"的字段,却漏掉了电芯类型、放电倍率、充电倍率、模块最大能量、模块输出功率、
模块尺寸、模块重量这些"模块"区域的参数,或者漏掉了电池最大能量、运行温度、相对湿度、颜色、系统柜尺寸、
系统柜重量、海拔、自放电率这些"锂电池系统柜"区域的参数。提取完之后自己检查一遍below schema里所有标注了
"UPS配套锂电池系统专属"的字段只要OCR文字里出现了对应的数值就必须填上不能遗漏。
{
"name": "型号,比如 POE-532E",
"brand": "品牌中文名,从厂家名称/logo文字判断看不出就填空字符串",
"category": "产品大类中文名从这些里选最接近的UPS不间断电源 / 光伏逆变器 / 储能电池;不确定就填 UPS不间断电源",
"type": "细分品类中文名。如果是这种带USB/DC/POE多路直流输出口的小型UPS填 迷你UPS如果是没有LCD/LED显示、简单的家用小型UPS(型号通常带Offline/后备式字样),填 离线式UPS如果标注了Line-Interactive/互动式,填 在线互动式UPS如果是True double-conversion/真双变换在线式且明确标注了机架安装、2U/3U/4U这种机架单位、rack-mount字样填 机架式UPS如果同样是True double-conversion/真双变换在线式,但看起来是塔式/立式外观(没有明确的机架安装标注),填 在线式高频UPS这两个类型区分不确定的话优先填 在线式高频UPS用户在确认预览阶段还可以手动改如果产品大类是储能电池且规格表明确分成"模块"和"电池系统柜/电池柜"两级、给UPS配套用的中大功率锂电池系统填 UPS配套锂电池系统储能电池大类下其他常见值铅酸电池、磷酸铁锂电池UPS不间断电源大类下其他常见值高频塔式UPS、工频UPS、模块化UPS不确定就留空字符串",
"machineVariant": "标准机型 或 长延时机型:按前面【型号(L)拆分规则】判断,标准机型(内置电池)填\"标准机型\"长延时机型外接电池通常型号带L后缀\"长延时机型\";不属于这种拆分场景的常规产品,不确定就填标准机型",
"powerOrCapacity": "非UPS大类的功率/容量如果是迷你UPS这里填输出功率如 17W如果是离线式UPS/在线互动式UPS/机架式UPS这里填Capacity/容量(如 400VA/240W其他情况不确定就填空字符串",
"voltage": "如果是迷你UPS这里填输入电压如 100~240Vac/50-60Hz如果是离线式UPS/在线互动式UPS/机架式UPS这个字段不用填用下面的inputVoltage/outputVoltage代替其他情况填常规电压参数不确定填空字符串",
"ratedPower": "", "ratedPowerUnit": "VA", "powerFactor": "",
"outputPortCount": "迷你UPS专属这个型号有几路输出口数字字符串如 4不适用就填空字符串",
"outputPortTypes": "迷你UPS专属输出口类型数组只能用这些tokenUSB、DC、DC1、DC2、DC3、POE、Type-C、DC interface、POE interface不适用就填空数组",
"outputPorts": "迷你UPS专属数组每项 {\\"type\\":\\"USB\\",\\"voltage\\":\\"5Vdc\\",\\"current\\":\\"3.0A\\"}和outputPortTypes一一对应不适用就填空数组",
"inputVoltage": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属Input Voltage/输入电压(或Nominal Voltage/标称电压),如 110V/120Vac or 220V/230Vac不适用就填空字符串",
"inputVoltageRange": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属Voltage range/输入电压范围,如 162-268Vac/170-280Vac不适用就填空字符串",
"inputFrequency": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属Frequency/输入频率(范围),如 50/60HzAuto sensing或 40Hz~70Hz不适用就填空字符串",
"outputVoltage": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属Output Voltage/输出电压,如 110V/120Vac or 220V/230Vac不适用就填空字符串",
"outputVoltageRange": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属输出电压范围(或Voltage Regulation/电压调整率),如 ±10% 或 ±1%;不适用就填空字符串",
"transferTime": "离线式UPS/在线互动式UPS专属Transfer time/转换时间,如 Typical 2-8ms, 13ms max机架式UPS这里填Inverter to Bypass那个转换时间AC to Battery那个用下面的transferTimeAcToBattery不适用就填空字符串",
"waveform": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属Wave form/波形,如 Simulated Sine Wave 或 pure sine wave不适用就填空字符串",
"batteryVoltage": "离线式UPS/在线互动式UPS专属Battery Voltage/电池电压,如 12V 或 24V不适用就填空字符串",
"chargeCurrent": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属Charge current/充电电流,如 1A如果涉及【型号(L)拆分规则】,要按标准机型/长延时机型分别取Standard Model或Long-run Model那部分的数据不适用就填空字符串",
"frequencyRangeBatteryMode": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属Frequency range(battery mode)/频率范围(电池模式),如 50/60Hz±1Hz不适用就填空字符串",
"chargeTime": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属Charge time/充电时间(或Typical Recharge Time),如 8 hours recover to 90% capacity不适用就填空字符串",
"lcdDisplay": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属LCD display/LCD显示(或LCD Panel)能展示哪些信息,把原文整段抄下来,如 UPS status, Load level, Battery level, Input/Output voltage, Discharge timer, and Fault conditions不适用就填空字符串",
"ledDisplay": "离线式UPS/在线互动式UPS专属LED display/LED显示把各个模式对应的灯光颜色都列出来一行一条\\n分隔如 AC mode: Green lighting\\nBattery mode: Yellow lighting\\nFault: Red lighting不适用就填空字符串",
"fullProtection": "离线式UPS/在线互动式UPS专属Full Protection/全面保护,如 Discharge, Short circuit and overload protection不适用就填空字符串",
"alarmInfo": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属Alarm/警告提示,把电池模式/低电量/过载/故障这几种蜂鸣提示规律都列出来(一行一条,用\\n分隔如 Battery mode: Sounding every 10 seconds\\nLow battery: Sounding every second\\nOverload: Sounding every 0.5 seconds\\nFault: Continuously sounding不适用就填空字符串",
"interfacePort": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属Interface/控制管理接口说明,如 USB/RS232 port(optional), Support windows xp/vista, windows 7/8/11 and MAC或 Smart RS-232/USB, Supports Windows 2000/2003/XP/Vista/2008, Windows 7/8, Linux, Unix, and MAC不适用就填空字符串",
"humidity": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS/UPS配套锂电池系统专属Humidity/湿度(或相对湿度),如 0-90% RH@0-40°C(Non-condensing) 或 5%~95%(无凝露);不适用就填空字符串",
"noiseLevel": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属Noise Level/噪音,如 Less than 40dB(1m spacing);不适用就填空字符串",
"phase": "机架式UPS/在线式高频UPS专属Phase/相数,如 1-phase input/1-phase output 或 3-phase input/1-phase output不适用就填空字符串",
"inputPowerFactor": "机架式UPS/在线式高频UPS专属输入侧Power Factor/功率因数,如 ≥0.99@100% load不适用就填空字符串",
"transferTimeAcToBattery": "机架式UPS/在线式高频UPS专属Transfer Time - AC Mode to Battery Mode/转换时间(市电到电池),如 0ms不适用就填空字符串",
"currentCrestRatio": "机架式UPS/在线式高频UPS专属Current Crest Ratio/峰值电流比,如 3:1不适用就填空字符串",
"harmonicDistortion": "机架式UPS/在线式高频UPS专属Harmonic Distortion/谐波失真,如 ≤3% THD (Linear Load), ≤5% THD (Non-linear Load);不适用就填空字符串",
"efficiencyAcMode": "机架式UPS/在线式高频UPS专属Efficiency - AC Mode/效率(市电模式),如 88%;不适用就填空字符串",
"efficiencyBatteryMode": "机架式UPS/在线式高频UPS专属Efficiency - Battery Mode/效率(电池模式),如 83%;不适用就填空字符串",
"chargingVoltage": "机架式UPS/在线式高频UPS专属Charging Voltage/充电电压,如 27.4VDC±1%;如果涉及【型号(L)拆分规则】,要按标准机型/长延时机型分别取Standard Model或Long-run Model那部分的数据不适用就填空字符串",
"optionalSnmp": "机架式UPS/在线式高频UPS专属Optional SNMP/可选SNMP如 Power management from SNMP manager and web browser不适用就填空字符串",
"outputPowerFactor": "在线式高频UPS专属Output Power Factor/输出功率因数,如 0.9 或 1不适用就填空字符串",
"parallelUnits": "在线式高频UPS专属Number of Parallel Units/可并联台数,如 Not supported或具体可并联数量不适用就填空字符串",
"overload": "在线式高频UPS专属Overload/过载保护,如 105%-125% 6-25s; >150% 200ms不适用就填空字符串",
"moduleType": "UPS配套锂电池系统专属模块类型如 长延时型;不适用就填空字符串",
"moduleCapacity": "UPS配套锂电池系统专属模块额定容量(Ah),如 100不适用就填空字符串",
"cellType": "UPS配套锂电池系统专属电芯类型如 磷酸铁锂电池LFP不适用就填空字符串",
"moduleVoltage": "UPS配套锂电池系统专属模块额定电压(V),如 51.2;不适用就填空字符串",
"dischargeRate": "UPS配套锂电池系统专属放电倍率(C),如 2不适用就填空字符串",
"chargeRate": "UPS配套锂电池系统专属充电倍率(C),如 1不适用就填空字符串",
"moduleMaxEnergy": "UPS配套锂电池系统专属模块最大能量(kWh),如 5.12;不适用就填空字符串",
"moduleOutputPower": "UPS配套锂电池系统专属模块输出功率(kW),如 10.24;不适用就填空字符串",
"moduleDimensions": "UPS配套锂电池系统专属模块尺寸(mm),如 440*440*131不适用就填空字符串",
"moduleWeight": "UPS配套锂电池系统专属模块重量(kg),如 43±2不适用就填空字符串",
"cabinetMaxEnergy": "UPS配套锂电池系统专属电池系统柜最大能量(kWh),如 40.96;不适用就填空字符串",
"cabinetRatedVoltage": "UPS配套锂电池系统专属额定输出电压(V),如 409.6/±204.8/460.8/512/±256/563.2/614.4/±307.2;不适用就填空字符串",
"cycleLife": "UPS配套锂电池系统专属循环寿命如 5000次@50%DOD不适用就填空字符串",
"commInterface": "UPS配套锂电池系统专属通信接口如 LAN/CAN/RS485/干接点;不适用就填空字符串",
"protectionFunctions": "UPS配套锂电池系统专属保护功能如 过充、过放、过温、过流、短路等;不适用就填空字符串",
"operatingTemp": "UPS配套锂电池系统专属运行温度如 0~40℃(推荐20~25℃运行);不适用就填空字符串",
"color": "UPS配套锂电池系统专属颜色如 RAL9004不适用就填空字符串",
"cabinetDimensions": "UPS配套锂电池系统专属电池系统柜尺寸(mm),如 600×850×2000不适用就填空字符串",
"cabinetWeight": "UPS配套锂电池系统专属电池系统柜重量(kg),如 544±108模组配置不适用就填空字符串",
"altitude": "UPS配套锂电池系统专属海拔(m),如 ≤4000>2000降额使用不适用就填空字符串",
"selfDischargeRate": "UPS配套锂电池系统专属自放电率如 <5%环境温度030℃/30天不适用就填空字符串",
"batterySpec": "电池数量和容量/电池型号和数量,如 2200mAh*4 或 12V4.5AH*1机架式UPS这里可以把Battery Type和Numbers合并成一句话如 12V/9AH x2如果这个型号涉及【型号(L)拆分规则】要按这条记录是标准机型还是长延时机型分别取Standard Model或Long-run Model那部分的数据不确定填空字符串",
"dimensions": "尺寸(mm),如 175*105*30如果涉及【型号(L)拆分规则】,同样要按标准机型/长延时机型分别取对应子分类的数据,不确定填空字符串",
"netWeight": "净重(kg),如 0.4;如果涉及【型号(L)拆分规则】,同样要按标准机型/长延时机型分别取对应子分类的数据,不确定填空字符串",
"notes": "其他有用但上面字段装不下的信息,一两句话,没有就填空字符串"
}
只返回一个JSON数组不要有任何解释文字、不要用markdown代码块包裹、不要省略任何一个识别到的型号。`;
app.post('/api/ai/parse-catalog', requireAuth, aiLimiter, (req, res) => {
uploadMemory.array('files', 30)(req, res, async (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.files || !req.files.length) return res.status(400).json({ error: 'no file received' });
const cfg = loadAiConfig();
if (!cfg.apiKey) return res.status(400).json({ error: 'DeepSeek API key not configured' });
// 用户可以提前告诉我们这批文件大概是什么大类/品牌/细分品类这样AI不用自己瞎猜这几个字段
// 只需要专心提取每个型号的具体规格参数提供了的话最后会直接用这几个值覆盖AI的猜测结果。
const hintCategory = (req.body && req.body.hintCategory || '').trim();
const hintBrand = (req.body && req.body.hintBrand || '').trim();
const hintType = (req.body && req.body.hintType || '').trim();
let tmpDir;
try {
tmpDir = fs.mkdtempSync(path.join(OCR_TMP_ROOT, 'catalog-ocr-'));
const imagePaths = [];
for (const file of req.files) {
const isPdf = (file.mimetype || '').includes('pdf') || file.originalname.toLowerCase().endsWith('.pdf');
const isImage = (file.mimetype || '').startsWith('image/');
if (isPdf) {
const pdfSubDir = fs.mkdtempSync(path.join(tmpDir, 'pdf-'));
const pages = await renderPdfToImages(file.buffer, pdfSubDir);
imagePaths.push(...pages);
} else if (isImage) {
const ext = path.extname(file.originalname) || '.png';
const imgPath = path.join(tmpDir, `img_${imagePaths.length}${ext}`);
fs.writeFileSync(imgPath, file.buffer);
imagePaths.push(imgPath);
} else {
return res.status(400).json({ error: `不支持的文件类型:${file.originalname}只支持PDF或图片` });
}
}
const text = await ocrImages(imagePaths);
if (!text) return res.status(400).json({ error: 'OCR没有识别到任何文字请确认文件清晰、内容完整' });
// 识别出来的文本太长就截断,避免超出模型输出预算;产品目录类文档核心信息通常靠前
const MAX_CHARS = 24000;
const clippedText = text.length > MAX_CHARS ? text.slice(0, MAX_CHARS) : text;
const hintLines = [];
if (hintCategory) hintLines.push(`产品大类:${hintCategory}`);
if (hintBrand) hintLines.push(`品牌:${hintBrand}`);
if (hintType) hintLines.push(`细分品类:${hintType}`);
const userContent = hintLines.length
? `【用户已确认以下信息,直接使用,不用自己判断】\n${hintLines.join('\n')}\n\n【以下是OCR识别出的文档内容】\n${clippedText}`
: clippedText;
const content = await callDeepSeekWithRetry(cfg.apiKey, [
{ role: 'system', content: CATALOG_PARSE_SYSTEM_PROMPT },
{ role: 'user', content: userContent },
], { maxTokens: 16000, temperature: 0.2 });
const { data: products, truncated } = extractJson(content);
if (!Array.isArray(products)) throw new Error('AI没有返回预期的数组格式请重试');
// 双保险即使AI没完全听话这里直接强制覆盖保证和用户预选的一致
const finalProducts = products.map((p) => ({
...p,
category: hintCategory || p.category,
brand: hintBrand || p.brand,
type: hintType || p.type,
}));
res.json({ products: finalProducts, ocrTextLength: text.length, truncated });
} catch (e) {
const hint = /empty response/i.test(e.message)
? '重试后仍然失败可能是一次上传的文件太多导致AI输出内容超长建议减少单次上传的数量比如分成2-3批每批3-5张'
: '';
res.status(502).json({ error: `${e.message}${hint}` });
} finally {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (e) {}
}
});
});
/* ------------------------------- ai translate ------------------------------ */
// 给前端的翻译词条缓存(存在 crm:translationGlossary 里走通用storage接口兜底
// 前端自己先查缓存,只有命中不了的词才会调这个接口,这里只管翻译,不管缓存的存取。
const LANG_NAMES = { en: 'English', fr: 'French', ru: 'Russian', es: 'Spanish', ar: 'Arabic' };
app.post('/api/ai/translate-terms', requireAuth, aiLimiter, async (req, res) => {
const body = req.body || {};
const terms = Array.isArray(body.terms) ? body.terms.filter(t => typeof t === 'string' && t.trim()) : [];
const targetLang = body.targetLang;
if (!terms.length || !LANG_NAMES[targetLang]) return res.status(400).json({ error: 'invalid request' });
const cfg = loadAiConfig();
if (!cfg.apiKey) return res.status(400).json({ error: 'DeepSeek API key not configured' });
const systemPrompt = `你是专业的电力设备UPS/逆变器/储能电池)技术资料翻译。把用户给出的中文技术/业务词汇逐条翻译成${LANG_NAMES[targetLang]}
要符合这个行业规范书面用词不要逐字直译单位符号如Vdc、A、kg、mm保持原样不翻译。
严格按JSON对象格式返回key是原始中文词value是翻译结果不要有多余文字、不要用markdown代码块包裹每个词都必须有对应的翻译。`;
const userPrompt = JSON.stringify(terms);
try {
const content = await callDeepSeekWithRetry(cfg.apiKey, [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
], { maxTokens: 2000, temperature: 0.3 });
const { data: translations } = extractJson(content);
res.json({ translations });
} catch (e) {
res.status(502).json({ error: e.message });
}
});
/* ------------------------- communication attachments ------------------------- */
const upload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => {
const cid = safeId(req.params.customerId);
if (!cid) return cb(new Error('invalid customer id'));
const dir = path.join(UPLOADS_DIR, cid);
fs.mkdirSync(dir, { recursive: true });
cb(null, dir);
},
filename: (req, file, cb) => {
const ts = Date.now();
const rand = Math.random().toString(36).slice(2, 8);
const ext = path.extname(file.originalname).slice(0, 10);
cb(null, `${ts}_${rand}${ext}`);
},
}),
limits: { fileSize: 25 * 1024 * 1024 }, // 25MB per file
});
app.post('/api/uploads/:customerId', requireAuth, (req, res) => {
if (!safeId(req.params.customerId)) return res.status(400).json({ error: 'invalid customer id' });
upload.single('file')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.file) return res.status(400).json({ error: 'no file received' });
// multer/busboy 默认按 latin1 解析 multipart 里的文件名字段这是老版HTTP规范的行为
// 但浏览器实际发送的中文文件名是 UTF-8 字节,所以这里要把它从 latin1 转回 UTF-8
// 不然中文文件名会变成乱码存进 data.json。
const originalName = Buffer.from(req.file.originalname, 'latin1').toString('utf8');
res.json({ fileName: req.file.filename, originalName, size: req.file.size });
});
});
app.delete('/api/uploads/:customerId/:fileName', requireAuth, (req, res) => {
const cid = safeId(req.params.customerId);
const fname = req.params.fileName;
const isSafeFile = fname && !fname.includes('..') && !fname.includes('/') && !fname.includes('\\');
if (!cid || !isSafeFile) return res.status(400).json({ error: 'invalid request' });
fs.unlink(path.join(UPLOADS_DIR, cid, fname), () => {
// a missing file is also treated as success, so clicking delete twice never errors
res.json({ ok: true });
});
});
app.use('/uploads', requireAuth, express.static(UPLOADS_DIR));
app.use(express.static(FRONTEND_DIST));
app.get('*', (req, res) => {
res.sendFile(path.join(FRONTEND_DIST, 'index.html'));
});
/* ------------------------------- bootstrap ------------------------------ */
loadAuth(); // make sure auth.json exists with default credentials on first run
fs.writeFileSync(PID_FILE, String(process.pid), 'utf8');
process.on('exit', () => {
try { fs.unlinkSync(PID_FILE); } catch (e) {}
});
process.on('SIGINT', () => process.exit(0));
process.on('SIGTERM', () => process.exit(0));
const server = app.listen(PORT, HOST, () => {
console.log(`[customer-crm] running at http://${HOST}:${PORT}`);
});
server.on('error', (e) => {
if (e.code === 'EADDRINUSE') {
console.log('[customer-crm] another instance is already running on this port, exiting.');
try { fs.unlinkSync(PID_FILE); } catch (err) {}
process.exit(0);
}
throw e;
});