client/server.js

634 lines
27 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';
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { 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 content = data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content;
if (!content) throw new Error('DeepSeek API returned an empty response');
return content;
}
// 从DeepSeek返回的内容里提取JSON——模型有时会用```json代码块包一层或者前后带几句解释文字
// 这里做宽松解析:优先找代码块,找不到就找第一个 { 或 [ 到最后一个 } 或 ] 之间的内容。
function extractJson(content) {
const fenced = content.match(/```(?:json)?\s*([\s\S]*?)```/i);
const raw = fenced ? fenced[1] : content;
const start = raw.search(/[\[{]/);
const endBrace = raw.lastIndexOf('}');
const endBracket = raw.lastIndexOf(']');
const end = Math.max(endBrace, endBracket);
if (start === -1 || end === -1 || end < start) throw new Error('AI返回内容里没有找到JSON');
return JSON.parse(raw.slice(start, end + 1));
}
/* ------------------------------ 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 os = require('os');
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输出一个数组每个元素是一个产品对象
{
"name": "型号,比如 POE-532E",
"brand": "品牌中文名,从厂家名称/logo文字判断看不出就填空字符串",
"category": "产品大类中文名从这些里选最接近的UPS不间断电源 / 光伏逆变器 / 储能电池;不确定就填 UPS不间断电源",
"type": "细分品类中文名。如果是这种带USB/DC/POE多路直流输出口的小型UPS填 迷你UPS其他常见值机架式UPS、高频塔式UPS、工频UPS、模块化UPS、在线互动式UPS、离线式UPS不确定就留空字符串",
"machineVariant": "标机 或 长机标准电池容量版本填标机型号带L后缀或电池容量明显更大的加长版填长机不确定就填标机",
"powerOrCapacity": "非UPS大类的功率/容量如果是迷你UPS这里填输出功率如 17W其他情况不确定就填空字符串",
"voltage": "如果是迷你UPS这里填输入电压如 100~240Vac/50-60Hz其他情况填常规电压参数不确定填空字符串",
"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一一对应不适用就填空数组",
"batterySpec": "电池数量和容量,如 2200mAh*4不确定填空字符串",
"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();
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'catalog-ocr-'));
try {
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 callDeepSeek(cfg.apiKey, [
{ role: 'system', content: CATALOG_PARSE_SYSTEM_PROMPT },
{ role: 'user', content: userContent },
], { maxTokens: 4000, temperature: 0.2 });
const products = 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 });
} catch (e) {
res.status(502).json({ error: e.message });
} 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 callDeepSeek(cfg.apiKey, [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
], { maxTokens: 2000, temperature: 0.3 });
const 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;
});