From b13f03fdd6bbc03fd525d8e40f111739dca9ff89 Mon Sep 17 00:00:00 2001 From: mike Date: Fri, 24 Jul 2026 16:46:52 +0800 Subject: [PATCH] Upload files to "/" --- server.js | 639 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 639 insertions(+) create mode 100644 server.js diff --git a/server.js b/server.js new file mode 100644 index 0000000..2642e5c --- /dev/null +++ b/server.js @@ -0,0 +1,639 @@ +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; +});