client/server.js

792 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const express = require('express');
const session = require('express-session');
const bcrypt = require('bcryptjs');
const multer = require('multer');
const rateLimit = require('express-rate-limit');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const PORT = process.env.PORT || 3210;
// 127.0.0.1 by default: on a VPS, Nginx fronts this with TLS and is the only
// thing that should be reachable from outside. On the old local-Windows setup
// this was 0.0.0.0 for LAN access; set HOST=0.0.0.0 explicitly if you still
// need that. COOKIE_SECURE should be "true" once you're behind real HTTPS.
const HOST = process.env.HOST || '127.0.0.1';
const COOKIE_SECURE = process.env.COOKIE_SECURE === 'true';
const DATA_FILE = path.join(__dirname, 'data.json');
const PID_FILE = path.join(__dirname, 'server.pid');
const FRONTEND_DIST = path.join(__dirname, '..', 'frontend', 'dist');
const UPLOADS_DIR = path.join(__dirname, 'uploads');
const AUTH_FILE = path.join(__dirname, 'auth.json');
const DEFAULT_USERNAME = 'admin';
const DEFAULT_PASSWORD = 'admin123';
// 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');
}
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,
// deepseek-v4-flash 默认开启thinking mode复杂输入会把整个max_tokens预算都花在推理链上
// content留空、finish_reason变成length就是"AI返回空响应"这个报错的根因)。这里都是纯
// 结构化提取/翻译任务不需要推理显式关掉thinking让token预算全部用来输出实际内容。
thinking: { type: 'disabled' },
}),
});
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' },
});
/* --------------------------- 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: { files: 30 }, // 不限制单文件大小最多30个文件AI批量解析成本考虑数量上限保留
});
// 把一份PDF的每一页渲染成PNG图片用poppler-utils的pdftoppm返回图片路径数组
function renderPdfToImages(pdfBuffer, tmpDir) {
return new Promise((resolve, reject) => {
const pdfPath = path.join(tmpDir, 'source.pdf');
fs.writeFileSync(pdfPath, pdfBuffer);
const outPrefix = path.join(tmpDir, 'page');
execFile('pdftoppm', ['-png', '-r', '200', pdfPath, outPrefix], (err) => {
if (err) {
return reject(new Error(
`PDF转图片失败请确认服务器已安装 poppler-utilsSSH执行apt install -y poppler-utils${err.message}`
));
}
const files = fs.readdirSync(tmpDir)
.filter((f) => f.startsWith('page') && f.endsWith('.png'))
.sort()
.map((f) => path.join(tmpDir, f));
resolve(files);
});
});
}
// 复用同一个OCR worker避免每次请求都重新初始化/重新下载语言包
let ocrWorkerPromise = null;
function getOcrWorker() {
if (!ocrWorkerPromise) {
ocrWorkerPromise = Tesseract.createWorker('eng+chi_sim').catch((e) => {
ocrWorkerPromise = null; // 初始化失败下次重试,不要把失败结果缓存住
throw e;
});
}
return ocrWorkerPromise;
}
async function ocrImages(imagePaths) {
const worker = await getOcrWorker();
const parts = [];
for (const p of imagePaths) {
const { data } = await worker.recognize(p);
if (data && data.text) parts.push(data.text.trim());
}
return parts.join('\n\n--- 下一页 ---\n\n').trim();
}
const CATALOG_PARSE_SYSTEM_PROMPT = `你是一个电力设备UPS/逆变器/储能电池等)产品资料的结构化提取助手。
用户会给你一份产品目录/规格书经OCR识别出的文字内容可能包含多个不同型号的产品每个产品通常有自己的Specifications表格
OCR识别可能有少量错字或格式错乱请结合上下文合理判断
你的任务识别文本里有几个不同型号的产品为每一个产品提取字段严格按下面的JSON schema输出一个数组每个元素是一个产品对象
【特别注意"系列名"和"具体型号"的区别】很多规格表会在标题里写"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专属输出口类型数组只能用这些tokenUSB、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/工频UPS/高频塔式UPS/模块化UPS专属Input Voltage/输入电压(或Nominal Voltage/标称电压),如 110V/120Vac or 220V/230Vac不适用就填空字符串",
"inputVoltageRange": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS专属Voltage range/输入电压范围,如 162-268Vac/170-280Vac不适用就填空字符串",
"inputFrequency": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS专属Frequency/输入频率(范围),如 50/60HzAuto sensing或 40Hz~70Hz不适用就填空字符串",
"outputVoltage": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS/太阳能混网逆变器专属Output Voltage/输出电压,如 110V/120Vac or 220V/230Vac不适用就填空字符串",
"outputVoltageRange": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS专属输出电压范围(或Voltage Regulation/电压调整率),如 ±10% 或 ±1%;不适用就填空字符串",
"transferTime": "离线式UPS/在线互动式UPS专属Transfer time/转换时间,如 Typical 2-8ms, 13ms max机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS这里填Inverter to Bypass那个转换时间AC to Battery那个用下面的transferTimeAcToBattery不适用就填空字符串",
"waveform": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS专属Wave form/波形,如 Simulated Sine Wave 或 pure sine wave不适用就填空字符串",
"batteryVoltage": "离线式UPS/在线互动式UPS/太阳能混网逆变器专属Battery Voltage/电池电压,如 12V 或 24V不适用就填空字符串",
"chargeCurrent": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS专属Charge current/充电电流,如 1A如果涉及【型号(L)拆分规则】,要按标准机型/长延时机型分别取Standard Model或Long-run Model那部分的数据不适用就填空字符串",
"frequencyRangeBatteryMode": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS专属Frequency range(battery mode)/频率范围(电池模式),如 50/60Hz±1Hz不适用就填空字符串",
"chargeTime": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS专属Charge time/充电时间(或Typical Recharge Time),如 8 hours recover to 90% capacity不适用就填空字符串",
"lcdDisplay": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频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/工频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/工频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/高频塔式UPS/模块化UPS/UPS配套锂电池系统/太阳能混网逆变器专属Humidity/湿度(或相对湿度),如 0-90% RH@0-40°C(Non-condensing) 或 5%~95%(无凝露);不适用就填空字符串",
"noiseLevel": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS/太阳能混网逆变器专属Noise Level/噪音,如 Less than 40dB(1m spacing);不适用就填空字符串",
"phase": "机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS专属Phase/相数,如 1-phase input/1-phase output 或 3-phase input/1-phase output不适用就填空字符串",
"inputPowerFactor": "机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS专属输入侧Power Factor/功率因数,如 ≥0.99@100% load不适用就填空字符串",
"transferTimeAcToBattery": "机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS专属Transfer Time - AC Mode to Battery Mode/转换时间(市电到电池),如 0ms不适用就填空字符串",
"currentCrestRatio": "机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS专属Current Crest Ratio/峰值电流比,如 3:1不适用就填空字符串",
"harmonicDistortion": "机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS专属Harmonic Distortion/谐波失真,如 ≤3% THD (Linear Load), ≤5% THD (Non-linear Load);不适用就填空字符串",
"efficiencyAcMode": "机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS专属Efficiency - AC Mode/效率(市电模式),如 88%;不适用就填空字符串",
"efficiencyBatteryMode": "机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS专属Efficiency - Battery Mode/效率(电池模式),如 83%;不适用就填空字符串",
"chargingVoltage": "机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS专属Charging Voltage/充电电压,如 27.4VDC±1%;如果涉及【型号(L)拆分规则】,要按标准机型/长延时机型分别取Standard Model或Long-run Model那部分的数据不适用就填空字符串",
"optionalSnmp": "机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS专属Optional SNMP/可选SNMP如 Power management from SNMP manager and web browser不适用就填空字符串",
"outputPowerFactor": "在线式高频UPS/机架式UPS/工频UPS/高频塔式UPS/模块化UPS/太阳能混网逆变器专属Output Power Factor/输出功率因数,如 0.9 或 1不适用就填空字符串",
"parallelUnits": "在线式高频UPS/机架式UPS/工频UPS/高频塔式UPS/模块化UPS专属Number of Parallel Units/可并联台数,如 Not supported或具体可并联数量不适用就填空字符串",
"overload": "在线式高频UPS/机架式UPS/工频UPS/高频塔式UPS/模块化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/在线互动式UPS/机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS/UPS配套锂电池系统/太阳能混网逆变器专属:运行温度(或工作温度/工作温度范围),如 0~40℃(推荐20~25℃运行);不适用就填空字符串",
"color": "UPS配套锂电池系统专属颜色如 RAL9004不适用就填空字符串",
"cabinetDimensions": "UPS配套锂电池系统专属电池系统柜尺寸(mm),如 600×850×2000不适用就填空字符串",
"cabinetWeight": "UPS配套锂电池系统专属电池系统柜重量(kg),如 544±108模组配置不适用就填空字符串",
"altitude": "UPS配套锂电池系统/太阳能混网逆变器专属:海拔(m),如 ≤4000>2000降额使用不适用就填空字符串",
"selfDischargeRate": "UPS配套锂电池系统专属自放电率如 <5%环境温度030℃/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": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS/UPS配套锂电池系统/太阳能混网逆变器专属:存储温度,如 -30~65°C不含电池不适用就填空字符串",
"cooling": "太阳能混网逆变器专属:散热方式,如 Intelligent forced air cooling不适用就填空字符串",
"protectionClass": "离线式UPS/在线互动式UPS/机架式UPS/在线式高频UPS/工频UPS/高频塔式UPS/模块化UPS/UPS配套锂电池系统/太阳能混网逆变器专属:防护等级(或IP等级),如 IP66 或 IP20不适用就填空字符串",
"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');
// 只去掉 <style>/<script> 这种纯样式/脚本内容对AI提取参数没用、白白占token
// 保留其余全部HTML标签结构——比如 rowspan 属性能直接告诉AI"这几行同属一个分组"
// 这比OCR识别出来的一堆无结构纯文字准确得多。
const cleaned = raw
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
.trim();
if (cleaned) htmlTextParts.push(cleaned);
} else {
return res.status(400).json({ error: `不支持的文件类型:${file.originalname}只支持PDF、图片或HTML` });
}
}
const ocrText = imagePaths.length ? await ocrImages(imagePaths) : '';
const text = [ocrText, ...htmlTextParts].filter(Boolean).join('\n\n--- 下一份文件 ---\n\n');
console.log('[AI导入] OCR/HTML文本长度:', text.length, '| 含"应用":', text.includes('应用'), '| 含"尺柜":', text.includes('尺柜'));
if (!text) return res.status(400).json({ error: '没有识别/读取到任何文字内容,请确认文件清晰、内容完整' });
// 识别出来的文本太长就截断,避免超出模型输出预算;产品目录类文档核心信息通常靠前
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识别结果、也可能是直接提取的HTML源码——HTML里的表格标签结构比如rowspan请重点利用能帮助你判断字段的分组归属】\n${clippedText}`
: clippedText;
const content = await callDeepSeekWithRetry(cfg.apiKey, [
{ role: 'system', content: CATALOG_PARSE_SYSTEM_PROMPT },
{ role: 'user', content: userContent },
], { maxTokens: 64000, temperature: 0.2 });
const { data: products, truncated } = 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,
}));
finalProducts.forEach(fp => {
if (Array.isArray(fp.extraSpecs) && fp.extraSpecs.length) {
console.log('[AI导入] extraSpecs for', fp.name, ':', JSON.stringify(fp.extraSpecs));
}
});
res.json({ products: finalProducts, ocrTextLength: text.length, truncated });
} catch (e) {
const hint = /empty response/i.test(e.message)
? '重试后仍然失败可能是一次上传的文件太多导致AI输出内容超长建议减少单次上传的数量比如分成2-3批每批3-5张'
: '';
res.status(502).json({ error: `${e.message}${hint}` });
} 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]}
这些词条不一定是中文——有些是从英文(或其他语言)原始资料里原样提取的参数标签/数值,本身可能已经就是${LANG_NAMES[targetLang]}或者是国际通用的表述(比如型号、单位符号、专有名词)。
如果某个词条已经是${LANG_NAMES[targetLang]}、或者是不需要翻译的国际通用内容,直接原样返回这个词条本身,绝对不要把已经正确的内容翻译成别的语言(尤其不要把已经是英文/其他语言的词条"翻译"成中文)。
需要翻译的词条要符合这个行业规范书面用词不要逐字直译单位符号如Vdc、A、kg、mm保持原样不翻译。
严格按JSON对象格式返回key是原始词条value是翻译结果或原样返回的词条本身不要有多余文字、不要用markdown代码块包裹每个词都必须有对应的返回值。`;
const userPrompt = JSON.stringify(terms);
try {
const content = await callDeepSeekWithRetry(cfg.apiKey, [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
], { maxTokens: 2000, temperature: 0.3 });
const { data: 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}`);
},
}),
// 不设fileSize上限——大文件原版彩页PDF、高清产品图等不受multer这层限制。
// 注意Nginx反向代理默认client_max_body_size是1MB比这里更早拦截超大请求
// 如果还是传不了大文件去Nginx该站点的server块里加一行 client_max_body_size 0;(不限制)
// 或设成足够大的值(比如 200m改完 nginx -t 测试再 systemctl reload nginx。
});
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;
});