720 lines
34 KiB
JavaScript
720 lines
34 KiB
JavaScript
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-utils(SSH执行: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输出一个数组,每个元素是一个产品对象:
|
||
|
||
{
|
||
"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后缀或电池容量明显更大的加长版填长机,不确定就填标机",
|
||
"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专属:输出口类型数组,只能用这些token:USB、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/60Hz(Auto 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;不适用就填空字符串",
|
||
"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,不确定填空字符串",
|
||
"dimensions": "尺寸(mm),如 175*105*30,不确定填空字符串",
|
||
"netWeight": "净重(kg),如 0.4,不确定填空字符串",
|
||
"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: 8000, 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;
|
||
});
|