diff --git a/server.js b/server.js deleted file mode 100644 index 8c73f8c..0000000 --- a/server.js +++ /dev/null @@ -1,633 +0,0 @@ -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: \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 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-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(); - - 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; -});