const express = require('express'); const session = require('express-session'); const bcrypt = require('bcryptjs'); const multer = require('multer'); const rateLimit = require('express-rate-limit'); const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const PORT = process.env.PORT || 3210; // 127.0.0.1 by default: on a VPS, Nginx fronts this with TLS and is the only // thing that should be reachable from outside. On the old local-Windows setup // this was 0.0.0.0 for LAN access; set HOST=0.0.0.0 explicitly if you still // need that. COOKIE_SECURE should be "true" once you're behind real HTTPS. const HOST = process.env.HOST || '127.0.0.1'; const COOKIE_SECURE = process.env.COOKIE_SECURE === 'true'; const DATA_FILE = path.join(__dirname, 'data.json'); const PID_FILE = path.join(__dirname, 'server.pid'); const FRONTEND_DIST = path.join(__dirname, '..', 'frontend', 'dist'); const UPLOADS_DIR = path.join(__dirname, 'uploads'); const AUTH_FILE = path.join(__dirname, 'auth.json'); const DEFAULT_USERNAME = 'admin'; const DEFAULT_PASSWORD = 'admin123'; // AI批量导入解析时,PDF渲染成图片/OCR用的临时文件放在这里,而不是系统的 os.tmpdir()(一般是 // /tmp)——有些VPS/容器环境会把 /tmp 挂载成只读或者单独的受限分区,直接用系统临时目录在这类 // 环境下会导致 mkdtempSync 抛出 EROFS 之类的错误,而且这个调用当时没被 try/catch 包住,会 // 直接让整个Node进程崩溃。用应用自己目录下这个文件夹,保证和 uploads/ 一样一定可读写。 const OCR_TMP_ROOT = path.join(__dirname, 'tmp-ocr'); if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true }); if (!fs.existsSync(OCR_TMP_ROOT)) fs.mkdirSync(OCR_TMP_ROOT, { recursive: true }); /* --------------------------------- auth ---------------------------------- */ function loadAuth() { if (!fs.existsSync(AUTH_FILE)) { const auth = { username: DEFAULT_USERNAME, passwordHash: bcrypt.hashSync(DEFAULT_PASSWORD, 10) }; fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf8'); return auth; } try { return JSON.parse(fs.readFileSync(AUTH_FILE, 'utf8')); } catch (e) { const auth = { username: DEFAULT_USERNAME, passwordHash: bcrypt.hashSync(DEFAULT_PASSWORD, 10) }; fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf8'); return auth; } } function saveAuth(auth) { fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf8'); } /* ------------------------------ secret / crypto -------------------------- */ // Used to encrypt the DeepSeek API key (and anything else sensitive) at // rest, so it isn't sitting around the filesystem as plain text. const SECRET_KEY_FILE = path.join(__dirname, '.secret-key'); function getSecretKey() { if (!fs.existsSync(SECRET_KEY_FILE)) { const key = crypto.randomBytes(32); fs.writeFileSync(SECRET_KEY_FILE, key.toString('base64'), { encoding: 'utf8', mode: 0o600 }); return key; } return Buffer.from(fs.readFileSync(SECRET_KEY_FILE, 'utf8').trim(), 'base64'); } const SECRET_KEY = getSecretKey(); function encrypt(text) { if (!text) return ''; const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', SECRET_KEY, iv); const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); return Buffer.concat([iv, tag, encrypted]).toString('base64'); } function decrypt(b64) { if (!b64) return ''; try { const buf = Buffer.from(b64, 'base64'); const iv = buf.subarray(0, 12); const tag = buf.subarray(12, 28); const encrypted = buf.subarray(28); const decipher = crypto.createDecipheriv('aes-256-gcm', SECRET_KEY, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8'); } catch (e) { return ''; } } /* --------------------------------- AI config ------------------------------ */ const AI_CONFIG_FILE = path.join(__dirname, 'ai-config.json'); // Lets you point this at a different OpenAI-compatible endpoint if needed // (e.g. testing against a mock server); defaults to the real DeepSeek API. const DEEPSEEK_API_BASE = process.env.DEEPSEEK_API_BASE || 'https://api.deepseek.com'; function loadAiConfig() { try { const raw = JSON.parse(fs.readFileSync(AI_CONFIG_FILE, 'utf8')); return { apiKey: decrypt(raw.apiKeyEnc || '') }; } catch (e) { return { apiKey: '' }; } } function saveAiConfig(apiKey) { fs.writeFileSync(AI_CONFIG_FILE, JSON.stringify({ apiKeyEnc: encrypt(apiKey) }, null, 2), 'utf8'); } const REGION_LABELS = { africa: 'Africa', middle_east: 'Middle East', australia: 'Australia/Oceania', europe: 'Europe', sea: 'Southeast Asia', other: 'Other', }; function buildCustomerContext(c) { const lines = []; lines.push(`Company: ${c.company || '(unknown / not on file)'}`); if (c.contact) lines.push(`Contact person: ${c.contact}`); const place = [c.country, REGION_LABELS[c.region]].filter(Boolean).join(', '); if (place) lines.push(`Location: ${place}`); if (c.productLines && c.productLines.length) lines.push(`Product interest: ${c.productLines.join(', ')}`); if (c.intendedModel || c.intendedQty) { lines.push(`Specific model interest: ${c.intendedModel || '(model unspecified)'}${c.intendedQty ? `, quantity: ${c.intendedQty}` : ''}`); } if (c.lastContact) lines.push(`Last contact date: ${c.lastContact}`); if (c.notes) lines.push(`Background research on this customer: ${c.notes}`); if (c.tags) lines.push(`Additional remarks: ${c.tags}`); if (c.isVip) lines.push('This is a VIP / priority customer — extra attentive tone.'); if (c.hasOrdered) lines.push('This customer has already placed an order before — this follow-up is with an existing buyer, not a cold lead.'); return lines.join('\n'); } async function callDeepSeek(apiKey, messages, opts) { const { maxTokens = 600, temperature = 0.7, model = 'deepseek-v4-flash' } = opts || {}; const resp = await fetch(`${DEEPSEEK_API_BASE}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ model, messages, temperature, max_tokens: maxTokens, }), }); if (!resp.ok) { const errText = await resp.text().catch(() => ''); throw new Error(`DeepSeek API error ${resp.status}: ${errText.slice(0, 200)}`); } const data = await resp.json(); const choice = data.choices && data.choices[0]; const content = choice && choice.message && choice.message.content; if (!content) { const reason = choice && choice.finish_reason ? `(finish_reason: ${choice.finish_reason})` : ''; throw new Error(`DeepSeek API returned an empty response${reason}`); } return content; } // 空响应这种问题偶发性比较大(有时候纯粹是API抽风),重试一次往往就好了; // 其他类型的错误(比如key不对、额度用完)重试也没用,直接抛出不浪费时间 async function callDeepSeekWithRetry(apiKey, messages, opts, retries = 1) { let lastErr; for (let attempt = 0; attempt <= retries; attempt++) { try { return await callDeepSeek(apiKey, messages, opts); } catch (e) { lastErr = e; if (!/empty response/i.test(e.message) || attempt === retries) throw e; } } throw lastErr; } // 从DeepSeek返回的内容里提取JSON——模型有时会用```json代码块包一层,或者前后带几句解释文字, // 这里做宽松解析:优先找代码块,找不到就找第一个 { 或 [ 开始的内容。 // 返回 { data, truncated }:如果完整JSON解析失败(常见原因是撞到了max_tokens,数组还没写完 // 就被截断了),会尝试抢救出前面已经完整生成的那些顶层对象,丢弃最后一个写了一半的,这样至少 // 能拿到部分结果,而不是整批因为最后一个字符缺失就全部作废。 function extractJson(content) { const fenced = content.match(/```(?:json)?\s*([\s\S]*?)```/i); const raw = fenced ? fenced[1] : content; const start = raw.search(/[\[{]/); if (start === -1) throw new Error('AI返回内容里没有找到JSON'); const trimmed = raw.slice(start); try { return { data: JSON.parse(trimmed), truncated: false }; } catch (e) { const salvaged = salvageTruncatedJsonArray(trimmed); if (salvaged !== null) return { data: salvaged, truncated: true }; throw new Error(`AI返回的内容不是完整的JSON,解析失败:${e.message}`); } } // 只处理"最外层是数组"的截断抢救:逐字符扫描(跳过字符串内部的引号/转义),每次遇到顶层元素 // 完整闭合(深度归零)就记一下位置,最后从抢救到的最后一个完整位置那里把数组截断重新拼上 ]。 function salvageTruncatedJsonArray(text) { const t = text.trim(); if (!t.startsWith('[')) return null; let depth = 0; let lastCompleteEnd = -1; let inString = false; let escapeNext = false; for (let i = 1; i < t.length; i++) { const ch = t[i]; if (inString) { if (escapeNext) escapeNext = false; else if (ch === '\\') escapeNext = true; else if (ch === '"') inString = false; continue; } if (ch === '"') { inString = true; continue; } if (ch === '{' || ch === '[') depth++; else if (ch === '}' || ch === ']') { depth--; if (depth === 0 && ch === '}') lastCompleteEnd = i; // 顶层数组里某个对象完整闭合了 } } if (lastCompleteEnd === -1) return null; try { return JSON.parse(t.slice(0, lastCompleteEnd + 1) + ']'); } catch (e) { return null; } } /* ------------------------------ data file ------------------------------ */ function readData() { try { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); } catch (e) { return {}; } } function writeData(data) { // write to a temp file then rename, avoids corrupting data.json if the // process is killed mid-write const tmp = DATA_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8'); fs.renameSync(tmp, DATA_FILE); } // only allow simple ids (our customer ids are like "c_169..._ab12c"), // this keeps the upload path from ever escaping the uploads/ folder function safeId(id) { return /^[A-Za-z0-9_-]+$/.test(id) ? id : null; } /* --------------------------------- app ---------------------------------- */ const app = express(); // behind Nginx, Express needs this to read X-Forwarded-* correctly — // otherwise req.ip is always Nginx's own address (breaks rate limiting) // and the "secure" cookie flag can't tell the request was HTTPS. app.set('trust proxy', 1); app.use(express.json({ limit: '10mb' })); app.use(session({ secret: crypto.randomBytes(32).toString('hex'), resave: false, saveUninitialized: false, cookie: { httpOnly: true, sameSite: 'lax', secure: COOKIE_SECURE, maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days }, })); function requireAuth(req, res, next) { if (req.session && req.session.authed) return next(); res.status(401).json({ error: 'not authenticated' }); } const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, standardHeaders: true, legacyHeaders: false, message: { error: 'too many login attempts, please try again in a few minutes' }, }); /* -------------------------------- auth api ------------------------------- */ app.post('/api/auth/login', loginLimiter, (req, res) => { const body = req.body || {}; const username = body.username || ''; const password = body.password || ''; const auth = loadAuth(); if (username === auth.username && bcrypt.compareSync(password, auth.passwordHash)) { req.session.authed = true; req.session.username = username; return res.json({ ok: true, username }); } res.status(401).json({ error: 'invalid credentials' }); }); app.post('/api/auth/logout', (req, res) => { if (req.session) { req.session.destroy(() => res.json({ ok: true })); } else { res.json({ ok: true }); } }); app.get('/api/auth/check', (req, res) => { const authed = !!(req.session && req.session.authed); res.json({ authed, username: authed ? req.session.username : null }); }); app.post('/api/auth/change-password', requireAuth, (req, res) => { const body = req.body || {}; const currentPassword = body.currentPassword || ''; const newPassword = body.newPassword || ''; const auth = loadAuth(); if (!bcrypt.compareSync(currentPassword, auth.passwordHash)) { return res.status(400).json({ error: 'current password is incorrect' }); } if (newPassword.length < 4) { return res.status(400).json({ error: 'new password is too short' }); } auth.passwordHash = bcrypt.hashSync(newPassword, 10); saveAuth(auth); res.json({ ok: true }); }); /* ------------------------------ storage api ------------------------------ */ app.get('/api/storage/:key', requireAuth, (req, res) => { const data = readData(); const key = req.params.key; if (!(key in data)) return res.status(404).json({ error: 'not found' }); res.json({ value: data[key] }); }); app.put('/api/storage/:key', requireAuth, (req, res) => { const data = readData(); data[req.params.key] = req.body.value; writeData(data); res.json({ ok: true }); }); /* --------------------------------- ai api --------------------------------- */ app.get('/api/ai-config', requireAuth, (req, res) => { const cfg = loadAiConfig(); res.json({ hasKey: !!cfg.apiKey }); }); app.put('/api/ai-config', requireAuth, (req, res) => { const apiKey = ((req.body && req.body.apiKey) || '').trim(); if (!apiKey) return res.status(400).json({ error: 'apiKey is required' }); saveAiConfig(apiKey); res.json({ ok: true }); }); app.delete('/api/ai-config', requireAuth, (req, res) => { try { fs.unlinkSync(AI_CONFIG_FILE); } catch (e) {} res.json({ ok: true }); }); app.post('/api/ai-config/test', requireAuth, async (req, res) => { const cfg = loadAiConfig(); if (!cfg.apiKey) return res.status(400).json({ error: 'no API key configured' }); try { const content = await callDeepSeek(cfg.apiKey, [{ role: 'user', content: 'Reply with exactly: OK' }]); res.json({ ok: true, reply: content.trim() }); } catch (e) { res.status(400).json({ error: e.message }); } }); const aiLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false, message: { error: 'too many AI requests, please slow down' }, }); app.post('/api/ai/draft', requireAuth, aiLimiter, async (req, res) => { const body = req.body || {}; const customerId = body.customerId; const channel = body.channel; if (!customerId || !['email', 'whatsapp'].includes(channel)) { return res.status(400).json({ error: 'invalid request' }); } const cfg = loadAiConfig(); if (!cfg.apiKey) return res.status(400).json({ error: 'DeepSeek API key not configured' }); const data = readData(); let customers = []; try { customers = JSON.parse(data['crm:customers'] || '[]'); } catch (e) {} const customer = customers.find((c) => c.id === customerId); if (!customer) return res.status(404).json({ error: 'customer not found' }); const context = buildCustomerContext(customer); const systemPrompt = 'You are a sales assistant helping a B2B export salesperson at a Chinese power-equipment company (UPS systems, rectifiers, solar inverters, battery storage) write natural, professional follow-up outreach to overseas customers. Always write in English. Never sound like a generic template, reference specifics from the customer info given. Output only the requested content, with no meta-commentary, explanations, or markdown formatting.'; const userPrompt = channel === 'email' ? `Customer info:\n${context}\n\nWrite a short, warm but professional follow-up email to this customer. Respond in EXACTLY this format with no extra text:\nSUBJECT: \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输出一个数组,每个元素是一个产品对象: 【特别注意"系列名"和"具体型号"的区别】很多规格表会在标题里写"XX系列智能在线式UPS"这种大标题(这是整个系列的统称, 不是某个产品的型号),真正的具体型号一定在表格里"型号"那一行/列里能找到(比如ATP-10K/CRT、ATP-20K/CRT这种)。 绝对不能把标题里的系列泛称当成name字段的值去输出一条"合并"的记录——表格里"型号"那一行有几个不同的型号名称, 就必须输出几条独立的产品记录,哪怕它们之间只有额定功率/尺寸/净重这几个数字不一样、其他参数全部相同,也不能因为 "看起来很像"就合并成一条、更不能因为拿不准就用系列名代替。如果OCR识别出来的文字比较模糊、型号名称对不齐对应的 数值,宁可把能确定的字段填全、拿不准的字段留空,也不要瞎猜合并。 【特别注意"型号(L)"这种带括号的写法】规格表的型号列里如果出现类似"MS1000(L)"这种写法,代表这里其实是两个型号, 要拆成两条独立的产品记录输出,不能只输出一条: - 不带L后缀的(如MS1000)是标准机型:machineVariant填"标准机型" - 带L后缀的(如MS1000L)是长延时机型:machineVariant填"长延时机型" 这两条记录的输入/输出/保护/警告/接口/环境这些参数完全共用同一份数据,只有 batterySpec(电池型号和数量)、 chargeCurrent(充电电流)、dimensions(尺寸)、netWeight(净重)这几项不同——严格按表格里"Standard Model/标准机型" 和"Long-run Model/长延机型"这两个子分类去分别取值,标准机型用Standard Model那部分数据,长延时机型用Long-run Model那部分数据。 如果某个型号在"Standard Model"部分对应列是NA或空、只有"Long-run Model"部分有数据,说明这个型号本身就是长延机型 (哪怕名字里没有明显的"(L)"标记也一样),直接当长延时机型处理,不要再凭空拆出一个去掉后缀的"标准版";反过来如果只有 "Standard Model"部分有数据、"Long-run Model"部分是NA或空,说明这个型号只有标准机型,也不要凭空造一个带L的长延版本。 【特别注意多型号规格对比表】有些规格表把好几个型号(比如1200/1600/2200/3000)放在同一张表里, 每一行参数对应多个型号列,但不是每个参数都是"一型号一个值"——有些参数(比如输入电压、频率范围) 全系列共用同一个值,直接抄给每个型号;但有些参数(比如尺寸、净重、电池型号和数量)会因为型号不同 分成几组不同的值(比如1200/1600/2200共用一组尺寸,3000单独一组),这种情况必须先看清楚该行数值 和哪几个型号对齐,再把正确的那组数值填到对应型号的记录里,不能所有型号都填同一个值、也不能填错组。 【特别注意不要丢弃"认不出属于哪个字段"的参数】上面这个JSON schema列出的都是已知产品类型常见的具名字段,但不同品牌、不同版本的规格表经常会出现一些没有被收录进这个schema的参数 (比如"浮充电压"这种字段)。只要原文规格表里出现了某一行参数、但左侧标签在下面schema里找不到任何一个匹配的具名字段(也不属于机型拆分/两段式结构那些已经处理过的特殊情况), 绝对不能因为"没地方放"就把这行数据丢弃——把这一行的左侧原始标签和右侧数值原样填进 extraSpecs 数组里,格式为 {"label":"浮充电压","value":"13.7VDC/节","afterLabel":"充电时间"}, 表格里有几行匹配不上具名字段,就填几条。其中 afterLabel 是这条参数在原文表格里紧挨着的上一行对应的**已知具名字段的中文标签**(比如这条"浮充电压"在原文里排在"充电时间"上面, 就填"充电时间";如果紧挨着的上一行同样是个不认识的参数,就沿着原文往上找,直到找到一个能对应上schema里具名字段的中文标签为止;实在找不到(比如这条参数在原文表格最开头)就填空字符串, 这种情况显示时会自动排在最后)。注意:如果某一行明明能对应上面某个具名字段(比如"20尺柜"对应container20ft),必须优先填那个具名字段, 不要重复放进extraSpecs;只有真的找不到对应具名字段的参数才放进extraSpecs。 【特别注意装箱数量容易被漏填】规格表如果出现"20尺柜/40尺柜/40高柜"这几行(通常在表格最下方,标注每种柜型能装多少台,如"1000PCS(无栈板)"), 必须提取到 container20ft / container40ft / container40hq 这三个字段里,不能因为在整个schema里排得靠后就跳过—— 提取完所有字段后,回头检查一遍原文里是否出现过"尺柜"或"PCS"这类字样,如果出现了但container20ft/container40ft/container40hq 还是空的,说明漏填了,要补上。 【特别注意"UPS配套锂电池系统"这类产品的两段式结构】这类产品(储能电池大类下)的规格表通常明确分成两个区域, 一个是"模块/Module"(单个电池模块本身的参数),一个是"锂电池系统柜/电池系统柜"(整个机柜/整套系统的参数), 跟模块化UPS"功率模块 + UPS机柜"是同样的两段式逻辑。这两个区域各自包含好几项参数,请务必把两个区域的每一项 都完整提取,绝对不能只提取其中一个区域、漏掉另一个区域——常见的漏提取情况是:只填了型号/循环寿命/通信接口/ 保护功能这种"看起来一目了然"的字段,却漏掉了电芯类型、放电倍率、充电倍率、模块最大能量、模块输出功率、 模块尺寸、模块重量这些"模块"区域的参数,或者漏掉了电池最大能量、运行温度、相对湿度、颜色、系统柜尺寸、 系统柜重量、海拔、自放电率这些"锂电池系统柜"区域的参数。提取完之后自己检查一遍:below schema里所有标注了 "UPS配套锂电池系统专属"的字段,只要OCR文字里出现了对应的数值,就必须填上,不能遗漏。 { "name": "型号,比如 POE-532E", "brand": "品牌中文名,从厂家名称/logo文字判断;看不出就填空字符串", "category": "产品大类中文名,从这些里选最接近的:UPS不间断电源 / 光伏逆变器 / 储能电池;不确定就填 UPS不间断电源", "type": "细分品类中文名。如果是这种带USB/DC/POE多路直流输出口的小型UPS,填 迷你UPS;如果是没有LCD/LED显示、简单的家用小型UPS(型号通常带Offline/后备式字样),填 离线式UPS;如果标注了Line-Interactive/互动式,填 在线互动式UPS;如果是True double-conversion/真双变换在线式且明确标注了机架安装、2U/3U/4U这种机架单位、rack-mount字样,填 机架式UPS;如果同样是True double-conversion/真双变换在线式,但看起来是塔式/立式外观(没有明确的机架安装标注),填 在线式高频UPS;这两个类型区分不确定的话,优先填 在线式高频UPS,用户在确认预览阶段还可以手动改;如果产品大类是储能电池,且规格表明确分成"模块"和"电池系统柜/电池柜"两级、给UPS配套用的中大功率锂电池系统,填 UPS配套锂电池系统;如果产品大类是光伏逆变器,且明确标注了Hybrid/On-Off-Grid/混网/既能并网又能离网这种既能接市电又能脱网独立供电的能力(通常还带电池输入参数),填 太阳能混网逆变器;储能电池大类下其他常见值:铅酸电池、磷酸铁锂电池;光伏逆变器大类下其他常见值:并网/离网逆变器;UPS不间断电源大类下其他常见值:高频塔式UPS、工频UPS、模块化UPS;不确定就留空字符串", "machineVariant": "标准机型 或 长延时机型:按前面【型号(L)拆分规则】判断,标准机型(内置电池)填\"标准机型\",长延时机型(外接电池,通常型号带L后缀)填\"长延时机型\";不属于这种拆分场景的常规产品,不确定就填标准机型", "powerOrCapacity": "非UPS大类的功率/容量;如果是迷你UPS,这里填输出功率(如 17W);如果是离线式UPS/在线互动式UPS/机架式UPS,这里填Capacity/容量(如 400VA/240W);其他情况不确定就填空字符串", "voltage": "如果是迷你UPS,这里填输入电压,如 100~240Vac/50-60Hz;如果是离线式UPS/在线互动式UPS/机架式UPS,这个字段不用填(用下面的inputVoltage/outputVoltage代替);其他情况填常规电压参数,不确定填空字符串", "ratedPower": "", "ratedPowerUnit": "VA", "powerFactor": "", "outputPortCount": "迷你UPS专属:这个型号有几路输出口,数字字符串,如 4,不适用就填空字符串", "outputPortTypes": "迷你UPS专属:输出口类型数组,只能用这些token:USB、DC、DC1、DC2、DC3、POE、Type-C、DC interface、POE interface;不适用就填空数组", "outputPorts": "迷你UPS专属:数组,每项 {\\"type\\":\\"USB\\",\\"voltage\\":\\"5Vdc\\",\\"current\\":\\"3.0A\\"},和outputPortTypes一一对应;不适用就填空数组", "inputVoltage": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属:Input Voltage/输入电压(或Nominal Voltage/标称电压),如 110V/120Vac or 220V/230Vac;不适用就填空字符串", "inputVoltageRange": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属:Voltage range/输入电压范围,如 162-268Vac/170-280Vac;不适用就填空字符串", "inputFrequency": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属:Frequency/输入频率(范围),如 50/60Hz(Auto sensing)或 40Hz~70Hz;不适用就填空字符串", "outputVoltage": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS/太阳能混网逆变器专属:Output Voltage/输出电压,如 110V/120Vac or 220V/230Vac;不适用就填空字符串", "outputVoltageRange": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属:输出电压范围(或Voltage Regulation/电压调整率),如 ±10% 或 ±1%;不适用就填空字符串", "transferTime": "离线式UPS/在线互动式UPS专属:Transfer time/转换时间,如 Typical 2-8ms, 13ms max;机架式UPS这里填Inverter to Bypass那个转换时间(AC to Battery那个用下面的transferTimeAcToBattery);不适用就填空字符串", "waveform": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属:Wave form/波形,如 Simulated Sine Wave 或 pure sine wave;不适用就填空字符串", "batteryVoltage": "离线式UPS/在线互动式UPS/太阳能混网逆变器专属:Battery Voltage/电池电压,如 12V 或 24V;不适用就填空字符串", "chargeCurrent": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属:Charge current/充电电流,如 1A;如果涉及【型号(L)拆分规则】,要按标准机型/长延时机型分别取Standard Model或Long-run Model那部分的数据;不适用就填空字符串", "frequencyRangeBatteryMode": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属:Frequency range(battery mode)/频率范围(电池模式),如 50/60Hz±1Hz;不适用就填空字符串", "chargeTime": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属:Charge time/充电时间(或Typical Recharge Time),如 8 hours recover to 90% capacity;不适用就填空字符串", "lcdDisplay": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属:LCD display/LCD显示(或LCD Panel)能展示哪些信息,把原文整段抄下来,如 UPS status, Load level, Battery level, Input/Output voltage, Discharge timer, and Fault conditions;不适用就填空字符串", "ledDisplay": "离线式UPS/在线互动式UPS专属:LED display/LED显示,把各个模式对应的灯光颜色都列出来(一行一条,用\\n分隔),如 AC mode: Green lighting\\nBattery mode: Yellow lighting\\nFault: Red lighting;如果原文是中文且几个模式和颜色紧挨着写在一起、没有明显换行分隔(比如\"市电模式 绿灯常亮 电池模式 黄灯常亮 故障 红灯常亮\"),要把每个模式和它后面紧跟着的颜色状态配对拆开,同样按一行一条的格式输出,如 市电模式:绿灯常亮\\n电池模式:黄灯常亮\\n故障:红灯常亮;不适用就填空字符串", "fullProtection": "离线式UPS/在线互动式UPS专属:Full Protection/全面保护,如 Discharge, Short circuit and overload protection;不适用就填空字符串", "alarmInfo": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属:Alarm/警告提示,把电池模式/低电量/过载/故障这几种蜂鸣提示规律都列出来(一行一条,用\\n分隔),如 Battery mode: Sounding every 10 seconds\\nLow battery: Sounding every second\\nOverload: Sounding every 0.5 seconds\\nFault: Continuously sounding;不适用就填空字符串", "interfacePort": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS专属:Interface/控制管理接口说明,如 USB/RS232 port(optional), Support windows xp/vista, windows 7/8/11 and MAC,或 Smart RS-232/USB, Supports Windows 2000/2003/XP/Vista/2008, Windows 7/8, Linux, Unix, and MAC;不适用就填空字符串", "humidity": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS/UPS配套锂电池系统/太阳能混网逆变器专属:Humidity/湿度(或相对湿度),如 0-90% RH@0-40°C(Non-condensing) 或 5%~95%(无凝露);不适用就填空字符串", "noiseLevel": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS/太阳能混网逆变器专属:Noise Level/噪音,如 Less than 40dB(1m spacing);不适用就填空字符串", "phase": "机架式UPS/在线式高频UPS专属:Phase/相数,如 1-phase input/1-phase output 或 3-phase input/1-phase output;不适用就填空字符串", "inputPowerFactor": "机架式UPS/在线式高频UPS专属:输入侧Power Factor/功率因数,如 ≥0.99@100% load;不适用就填空字符串", "transferTimeAcToBattery": "机架式UPS/在线式高频UPS专属:Transfer Time - AC Mode to Battery Mode/转换时间(市电到电池),如 0ms;不适用就填空字符串", "currentCrestRatio": "机架式UPS/在线式高频UPS专属:Current Crest Ratio/峰值电流比,如 3:1;不适用就填空字符串", "harmonicDistortion": "机架式UPS/在线式高频UPS专属:Harmonic Distortion/谐波失真,如 ≤3% THD (Linear Load), ≤5% THD (Non-linear Load);不适用就填空字符串", "efficiencyAcMode": "机架式UPS/在线式高频UPS专属:Efficiency - AC Mode/效率(市电模式),如 88%;不适用就填空字符串", "efficiencyBatteryMode": "机架式UPS/在线式高频UPS专属:Efficiency - Battery Mode/效率(电池模式),如 83%;不适用就填空字符串", "chargingVoltage": "机架式UPS/在线式高频UPS专属:Charging Voltage/充电电压,如 27.4VDC±1%;如果涉及【型号(L)拆分规则】,要按标准机型/长延时机型分别取Standard Model或Long-run Model那部分的数据;不适用就填空字符串", "optionalSnmp": "机架式UPS/在线式高频UPS专属:Optional SNMP/可选SNMP,如 Power management from SNMP manager and web browser;不适用就填空字符串", "outputPowerFactor": "在线式高频UPS/太阳能混网逆变器专属:Output Power Factor/输出功率因数,如 0.9 或 1;不适用就填空字符串", "parallelUnits": "在线式高频UPS专属:Number of Parallel Units/可并联台数,如 Not supported,或具体可并联数量;不适用就填空字符串", "overload": "在线式高频UPS专属:Overload/过载保护,如 105%-125% 6-25s; >150% 200ms;不适用就填空字符串", "moduleType": "UPS配套锂电池系统专属:模块类型,如 长延时型;不适用就填空字符串", "moduleCapacity": "UPS配套锂电池系统专属:模块额定容量(Ah),如 100;不适用就填空字符串", "cellType": "UPS配套锂电池系统专属:电芯类型,如 磷酸铁锂电池(LFP);不适用就填空字符串", "moduleVoltage": "UPS配套锂电池系统专属:模块额定电压(V),如 51.2;不适用就填空字符串", "dischargeRate": "UPS配套锂电池系统专属:放电倍率(C),如 2;不适用就填空字符串", "chargeRate": "UPS配套锂电池系统专属:充电倍率(C),如 1;不适用就填空字符串", "moduleMaxEnergy": "UPS配套锂电池系统专属:模块最大能量(kWh),如 5.12;不适用就填空字符串", "moduleOutputPower": "UPS配套锂电池系统专属:模块输出功率(kW),如 10.24;不适用就填空字符串", "moduleDimensions": "UPS配套锂电池系统专属:模块尺寸(mm),如 440*440*131;不适用就填空字符串", "moduleWeight": "UPS配套锂电池系统专属:模块重量(kg),如 43±2;不适用就填空字符串", "cabinetMaxEnergy": "UPS配套锂电池系统专属:电池系统柜最大能量(kWh),如 40.96;不适用就填空字符串", "cabinetRatedVoltage": "UPS配套锂电池系统专属:额定输出电压(V),如 409.6/±204.8/460.8/512/±256/563.2/614.4/±307.2;不适用就填空字符串", "cycleLife": "UPS配套锂电池系统专属:循环寿命,如 5000次@50%DOD;不适用就填空字符串", "commInterface": "UPS配套锂电池系统专属:通信接口,如 LAN/CAN/RS485/干接点;不适用就填空字符串", "protectionFunctions": "UPS配套锂电池系统/太阳能混网逆变器专属:保护功能,如 过充、过放、过温、过流、短路等;不适用就填空字符串", "operatingTemp": "UPS配套锂电池系统/太阳能混网逆变器专属:运行温度,如 0~40℃(推荐20~25℃运行);不适用就填空字符串", "color": "UPS配套锂电池系统专属:颜色,如 RAL9004;不适用就填空字符串", "cabinetDimensions": "UPS配套锂电池系统专属:电池系统柜尺寸(mm),如 600×850×2000;不适用就填空字符串", "cabinetWeight": "UPS配套锂电池系统专属:电池系统柜重量(kg),如 544±10(8模组配置);不适用就填空字符串", "altitude": "UPS配套锂电池系统/太阳能混网逆变器专属:海拔(m),如 ≤4000(>2000降额使用);不适用就填空字符串", "selfDischargeRate": "UPS配套锂电池系统专属:自放电率,如 <5%(环境温度0~30℃/30天);不适用就填空字符串", "batterySpec": "电池数量和容量/电池型号和数量,如 2200mAh*4 或 12V4.5AH*1;机架式UPS这里可以把Battery Type和Numbers合并成一句话,如 12V/9AH x2;如果这个型号涉及【型号(L)拆分规则】,要按这条记录是标准机型还是长延时机型,分别取Standard Model或Long-run Model那部分的数据,不确定填空字符串", "dimensions": "尺寸(mm),如 175*105*30;如果涉及【型号(L)拆分规则】,同样要按标准机型/长延时机型分别取对应子分类的数据,不确定填空字符串", "netWeight": "净重(kg),如 0.4;如果涉及【型号(L)拆分规则】,同样要按标准机型/长延时机型分别取对应子分类的数据,不确定填空字符串", "batteryType": "太阳能混网逆变器专属:电池类型,如 Lithium or lead acid battery;不适用就填空字符串", "maxChargingVoltage": "太阳能混网逆变器专属:最大充电电压,如 ≤60 (Configurable);不适用就填空字符串", "maxChargeDischargeCurrent": "太阳能混网逆变器专属:最大充放电电流,如 190A;不适用就填空字符串", "maxDcInputPower": "太阳能混网逆变器专属:最大直流输入功率,如 16000W;不适用就填空字符串", "maxDcInputVoltage": "太阳能混网逆变器专属:最大直流输入电压,如 1000V;不适用就填空字符串", "mpptVoltageRange": "太阳能混网逆变器专属:MPPT工作电压范围,如 200~800V;不适用就填空字符串", "startingVoltage": "太阳能混网逆变器专属:启动电压,如 150V;不适用就填空字符串", "maxInputCurrent": "太阳能混网逆变器专属:最大输入电流,如 18/18A;不适用就填空字符串", "mpptNumber": "太阳能混网逆变器专属:MPPT路数,如 2;不适用就填空字符串", "onGridMaxApparentPower": "太阳能混网逆变器专属:并网最大输出视在功率,如 8800W;不适用就填空字符串", "ratedOutputFrequency": "太阳能混网逆变器专属:额定输出频率,如 50/60Hz;不适用就填空字符串", "onGridMaxOutputCurrent": "太阳能混网逆变器专属:并网最大输出电流,如 13.3A;不适用就填空字符串", "offGridRatedApparentPower": "太阳能混网逆变器专属:离网额定输出视在功率,如 8000W;不适用就填空字符串", "offGridMaxApparentPower": "太阳能混网逆变器专属:离网最大输出视在功率,如 >200%,15sec;不适用就填空字符串", "offGridMaxOutputCurrent": "太阳能混网逆变器专属:离网最大输出电流,如 13.3A;不适用就填空字符串", "efficiencyMppt": "太阳能混网逆变器专属:最大效率(MPPT),如 98%;不适用就填空字符串", "efficiencyMax": "太阳能混网逆变器专属:最大效率,如 94.5%;不适用就填空字符串", "efficiencyEurope": "太阳能混网逆变器专属:欧洲效率,如 97.5%;不适用就填空字符串", "storageTemp": "太阳能混网逆变器专属:存储温度,如 -30~65°C;不适用就填空字符串", "cooling": "太阳能混网逆变器专属:散热方式,如 Intelligent forced air cooling;不适用就填空字符串", "protectionClass": "太阳能混网逆变器专属:防护等级,如 IP66;不适用就填空字符串", "topology": "太阳能混网逆变器专属:拓扑结构,如 HF isolation(Battery side);不适用就填空字符串", "safetyStandard": "太阳能混网逆变器专属:安规/EMC标准,把原文列出来的标准编号都抄下来,如 NB/T32004-2018, IEC62109, IEC61000;不适用就填空字符串", "gridStandard": "太阳能混网逆变器专属:并网标准,如 IEC61727, EN50549-1, VDE-4105;不适用就填空字符串", "otherStandard": "太阳能混网逆变器专属:其他标准,如 IEC61683, IEC62116, EN50530;不适用就填空字符串", "applicationScenarios": "应用场景:原文里如果有\"应用/Applications\"这类板块,用图标+文字标注了这个产品适合哪些应用场景(如电脑、投影仪、打印机、摄像机、收银机),把图标下面配的文字标签提取出来,用\"、\"分隔拼成一句话,如 电脑、投影仪、打印机、摄像机、收银机;只提取文字标签,不用管图标本身;原文没有这个板块就填空字符串", "container20ft": "装箱数量:20尺柜能装多少台(无栈板),如 1000PCS;不适用/未提供就填空字符串", "container40ft": "装箱数量:40尺柜能装多少台(无栈板),如 2080PCS;不适用/未提供就填空字符串", "container40hq": "装箱数量:40高柜能装多少台(无栈板),如 2340PCS;不适用/未提供就填空字符串", "extraSpecs": "数组,装那些在上面所有具名字段里都找不到匹配、但原文规格表里确实出现的参数行,每项格式 {\\"label\\":\\"原文左侧参数名\\",\\"value\\":\\"原文右侧数值\\",\\"afterLabel\\":\\"这条参数在原文里紧挨着的上一个已知具名字段的中文标签,找不到就填空字符串\\"};没有这种情况就填空数组[],不要为了凑数编造内容", "sellingPoints": "如果原文里除了技术规格表之外,还有一些营销性质的卖点/主要特点描述(通常是几条带图标或项目符号的短句,比如\"Up-to-36A maximum input current\"、\"IP66 design\"这种面向客户宣传的亮点,不是纯技术参数表格里的数值),把这些提取出来,每条一行,用\\n分隔;如果原文只有技术规格表、没有这种营销卖点描述,就填空字符串,不要自己瞎编凑数", "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 = []; const htmlTextParts = []; // HTML文件不用OCR,直接读文字内容(保留表格标签结构,AI能看懂rowspan这种分组关系) for (const file of req.files) { const isPdf = (file.mimetype || '').includes('pdf') || file.originalname.toLowerCase().endsWith('.pdf'); const isImage = (file.mimetype || '').startsWith('image/'); const isHtml = (file.mimetype || '').includes('html') || /\.html?$/i.test(file.originalname); 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 if (isHtml) { const raw = file.buffer.toString('utf8'); // 只去掉