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 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: \nBODY:\n` : `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;其他常见值:机架式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专属:输出口类型数组,只能用这些token:USB、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(); 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 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; });