client/server.js

731 lines
36 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输出一个数组每个元素是一个产品对象
【特别注意"型号(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的长延版本。
{
"name": "型号,比如 POE-532E",
"brand": "品牌中文名,从厂家名称/logo文字判断看不出就填空字符串",
"category": "产品大类中文名从这些里选最接近的UPS不间断电源 / 光伏逆变器 / 储能电池;不确定就填 UPS不间断电源",
"type": "细分品类中文名。如果是这种带USB/DC/POE多路直流输出口的小型UPS填 迷你UPS如果是没有LCD/LED显示、简单的家用小型UPS(型号通常带Offline/后备式字样),填 离线式UPS如果标注了Line-Interactive/互动式,填 在线互动式UPS其他常见值机架式UPS、高频塔式UPS、工频UPS、模块化UPS不确定就留空字符串",
"machineVariant": "标机 或 长机:按前面【型号(L)拆分规则】判断标准机型内置电池填标机长延机型外接电池通常型号带L后缀填长机不属于这种拆分场景的常规产品不确定就填标机",
"powerOrCapacity": "非UPS大类的功率/容量如果是迷你UPS这里填输出功率如 17W如果是离线式UPS/在线互动式UPS这里填Capacity/容量(如 400VA/240W其他情况不确定就填空字符串",
"voltage": "如果是迷你UPS这里填输入电压如 100~240Vac/50-60Hz如果是离线式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专属Input Voltage/输入电压,如 110V/120Vac or 220V/230Vac不适用就填空字符串",
"inputVoltageRange": "离线式UPS/在线互动式UPS专属Voltage range/输入电压范围,如 162-268Vac/170-280Vac不适用就填空字符串",
"inputFrequency": "离线式UPS/在线互动式UPS专属Frequency/输入频率,如 50/60HzAuto sensing不适用就填空字符串",
"outputVoltage": "离线式UPS/在线互动式UPS专属Output Voltage/输出电压,如 110V/120Vac or 220V/230Vac不适用就填空字符串",
"outputVoltageRange": "离线式UPS/在线互动式UPS专属输出电压范围如 ±10%;不适用就填空字符串",
"transferTime": "离线式UPS/在线互动式UPS专属Transfer time/转换时间,如 Typical 2-8ms, 13ms max不适用就填空字符串",
"waveform": "离线式UPS/在线互动式UPS专属Wave form/波形,如 Simulated Sine Wave 或 Sine Wave不适用就填空字符串",
"batteryVoltage": "离线式UPS/在线互动式UPS专属Battery Voltage/电池电压,如 12V 或 24V不适用就填空字符串",
"chargeCurrent": "离线式UPS/在线互动式UPS专属Charge current/充电电流,如 1A如果涉及【型号(L)拆分规则】,要按标机/长机分别取Standard Model或Long-run Model那部分的数据不适用就填空字符串",
"frequencyRangeBatteryMode": "离线式UPS/在线互动式UPS专属Frequency range(battery mode)/频率范围(电池模式),如 50/60Hz±1Hz不适用就填空字符串",
"chargeTime": "离线式UPS/在线互动式UPS专属Charge time/充电时间,如 8 hours recover to 90% capacity不适用就填空字符串",
"lcdDisplay": "离线式UPS/在线互动式UPS专属LCD display/LCD显示能展示哪些信息把原文整段抄下来如 AC mode, Battery mode, Load level, Battery level, Input voltage, Output voltage, Overload and low battery不适用就填空字符串",
"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专属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专属Interface/控制管理接口说明,如 USB/RS232 port(optional), Support windows xp/vista, windows 7/8/11 and MAC不适用就填空字符串",
"humidity": "离线式UPS/在线互动式UPS专属Humidity/湿度,如 0-90% RH@0-40°C(Non-condensing);不适用就填空字符串",
"noiseLevel": "离线式UPS/在线互动式UPS专属Noise Level/噪音,如 Less than 40dB(1m spacing);不适用就填空字符串",
"batterySpec": "电池数量和容量/电池型号和数量,如 2200mAh*4 或 12V4.5AH*1如果这个型号涉及【型号(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;
});