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 net = require('net');
const https = require('https');
const WebSocket = require('ws'); // 需要 npm install ws(版本^8即可)
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');
}
/* ------------------------------ Telegram config ---------------------------- */
// Just My Socks 线路异常报警用——bot token 用和AI Key一样的AES-256-GCM加密方式落盘,
// chat_id 不算敏感信息,明文存就行。
const TELEGRAM_CONFIG_FILE = path.join(__dirname, 'telegram-config.json');
function loadTelegramConfig() {
try {
const raw = JSON.parse(fs.readFileSync(TELEGRAM_CONFIG_FILE, 'utf8'));
return { botToken: decrypt(raw.botTokenEnc || ''), chatId: raw.chatId || '' };
} catch (e) {
return { botToken: '', chatId: '' };
}
}
function saveTelegramConfig(botToken, chatId) {
fs.writeFileSync(TELEGRAM_CONFIG_FILE, JSON.stringify({ botTokenEnc: encrypt(botToken), chatId }, null, 2), 'utf8');
}
async function sendTelegramMessage(text) {
const cfg = loadTelegramConfig();
if (!cfg.botToken || !cfg.chatId) throw new Error('Telegram未配置');
const res = await fetch(`https://api.telegram.org/bot${cfg.botToken}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: cfg.chatId, text, parse_mode: 'HTML' }),
});
const data = await res.json().catch(() => ({}));
if (!data.ok) throw new Error(data.description || 'Telegram发送失败');
return data;
}
/* ------------------------------ JMS 线路监控 ------------------------------- */
// 普通TCP连通性测试——SS / VMess / VLESS Reality 用这个就够,它们直连IP,没有CDN挡在前面。
function tcpCheck(host, port, timeoutMs = 6000) {
return new Promise((resolve) => {
const socket = new net.Socket();
let done = false;
const finish = (ok) => { if (!done) { done = true; socket.destroy(); resolve(ok); } };
socket.setTimeout(timeoutMs);
socket.once('connect', () => finish(true));
socket.once('timeout', () => finish(false));
socket.once('error', () => finish(false));
socket.connect(Number(port) || 443, host);
});
}
// VLESS+WS+TLS+CDN 专属深度检测:CDN边缘节点本身几乎永远在线,单纯TCP连通测不出"源站已经挂了",
// 所以这里改用两个更有信号量的信号——
// 1) Cloudflare的520/521/522/523/524是"源站不可达"专属状态码,能连上CDN但源站挂了会明确返回这几个码;
// 2) 再做一次真正的WebSocket握手,直接对应Xray/sing-box监听WS的那一层,比单看状态码更准。
// 两个都过才算正常。
function httpStatusCheck(host, wsPath, timeoutMs = 8000) {
return new Promise((resolve) => {
const req = https.request({
hostname: host, port: 443, path: wsPath || '/', method: 'GET', timeout: timeoutMs,
headers: { 'User-Agent': 'Mozilla/5.0' },
}, (res) => {
res.resume();
const badGateway = [520, 521, 522, 523, 524].includes(res.statusCode);
resolve(!badGateway);
});
req.on('timeout', () => { req.destroy(); resolve(false); });
req.on('error', () => resolve(false));
req.end();
});
}
function wsHandshakeCheck(host, wsPath, timeoutMs = 8000) {
return new Promise((resolve) => {
let done = false;
let ws;
const finish = (ok) => {
if (done) return;
done = true;
try { ws && ws.terminate(); } catch (e) {}
resolve(ok);
};
try {
ws = new WebSocket(`wss://${host}${wsPath || '/'}`, { handshakeTimeout: timeoutMs });
ws.on('open', () => finish(true));
ws.on('unexpected-response', () => finish(false));
ws.on('error', () => finish(false));
} catch (e) {
finish(false);
}
setTimeout(() => finish(false), timeoutMs + 500);
});
}
async function checkLineConnectivity(line) {
if (line.protocol === 'vless-cdn') {
const [httpOk, wsOk] = await Promise.all([
httpStatusCheck(line.addr, line.ws_path),
wsHandshakeCheck(line.addr, line.ws_path),
]);
return httpOk && wsOk;
}
return tcpCheck(line.addr, line.port);
}
// 后台定时巡检:每 JMS_CHECK_INTERVAL_MS 跑一轮,只在状态发生变化(正常↔异常)时才发Telegram,
// 不会每次检测都刷屏。直接读写 crm:jmsLines 这个通用存储key(前端也是存在这里)。
const JMS_CHECK_INTERVAL_MS = 10 * 60 * 1000; // 10分钟
async function runJmsMonitorCycle() {
const data = readData();
const raw = data['crm:jmsLines'];
if (!raw) return;
let lines;
try { lines = JSON.parse(raw); } catch (e) { return; }
if (!Array.isArray(lines) || !lines.length) return;
for (const line of lines) {
try {
const ok = await checkLineConnectivity(line);
const newStatus = ok ? 'normal' : 'abnormal';
if (line.monitorStatus && line.monitorStatus !== newStatus) {
const name = line.name || line.addr;
const msg = newStatus === 'abnormal'
? `⚠️ ${name} 检测异常,无法连接\n协议:${line.protocol}\n地址:${line.addr}:${line.port}`
: `✅ ${name} 已恢复正常\n协议:${line.protocol}\n地址:${line.addr}:${line.port}`;
sendTelegramMessage(msg).catch(() => {}); // Telegram没配置/发送失败都不影响检测本身
}
line.monitorStatus = newStatus;
line.lastChecked = Date.now();
} catch (e) {
// 检测本身出错(异常里的异常),跳过这条不写状态,下一轮再试
}
}
data['crm:jmsLines'] = JSON.stringify(lines);
writeData(data);
}
setInterval(() => { runJmsMonitorCycle().catch(() => {}); }, JMS_CHECK_INTERVAL_MS);
setTimeout(() => { runJmsMonitorCycle().catch(() => {}); }, 60 * 1000); // 启动1分钟后先跑一轮
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 });
}
});
/* ------------------------------ Telegram api ------------------------------- */
app.get('/api/telegram-config', requireAuth, (req, res) => {
const cfg = loadTelegramConfig();
res.json({ hasToken: !!cfg.botToken, chatId: cfg.chatId || '' });
});
app.put('/api/telegram-config', requireAuth, (req, res) => {
const botToken = ((req.body && req.body.botToken) || '').trim();
const chatId = ((req.body && req.body.chatId) || '').trim();
if (!botToken || !chatId) return res.status(400).json({ error: 'botToken和chatId都需要填写' });
saveTelegramConfig(botToken, chatId);
res.json({ ok: true });
});
app.delete('/api/telegram-config', requireAuth, (req, res) => {
try { fs.unlinkSync(TELEGRAM_CONFIG_FILE); } catch (e) {}
res.json({ ok: true });
});
app.post('/api/telegram-config/test', requireAuth, async (req, res) => {
try {
await sendTelegramMessage('✅ 客户档案系统:Telegram通知测试成功,配置没问题。');
res.json({ ok: true });
} catch (e) {
res.status(400).json({ error: e.message });
}
});
/* --------------------------------- JMS api --------------------------------- */
const jmsCheckLimiter = rateLimit({
windowMs: 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false,
message: { error: '检测太频繁了,请稍等一下再试' },
});
// 手动"立即检测"——对单条线路马上跑一次连通性检测;如果状态发生了变化,也会像后台巡检一样发Telegram
app.post('/api/jms/check-line', requireAuth, jmsCheckLimiter, async (req, res) => {
const line = req.body && req.body.line;
if (!line || !line.addr) return res.status(400).json({ error: '线路信息不完整' });
try {
const ok = await checkLineConnectivity(line);
const newStatus = ok ? 'normal' : 'abnormal';
if (line.monitorStatus && line.monitorStatus !== newStatus) {
const name = line.name || line.addr;
const msg = newStatus === 'abnormal'
? `⚠️ ${name} 检测异常,无法连接\n协议:${line.protocol}\n地址:${line.addr}:${line.port}`
: `✅ ${name} 已恢复正常\n协议:${line.protocol}\n地址:${line.addr}:${line.port}`;
sendTelegramMessage(msg).catch(() => {});
}
res.json({ ok: true, status: newStatus, checkedAt: Date.now() });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// 代为请求JMS订阅地址,解析 Subscription-Userinfo 响应头拿到流量信息——
// 浏览器直接fetch会被CORS挡住,服务器端发请求没有这个限制
app.post('/api/jms/fetch-traffic', requireAuth, async (req, res) => {
const url = ((req.body && req.body.subscriptionUrl) || '').trim();
if (!url) return res.status(400).json({ error: '请先填写标准订阅地址' });
let parsed;
try {
parsed = new URL(url);
if (!/^https?:$/.test(parsed.protocol)) throw new Error('invalid protocol');
} catch (e) {
return res.status(400).json({ error: '订阅地址格式不对' });
}
try {
const response = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
const header = response.headers.get('subscription-userinfo');
if (!header) return res.json({ ok: false, error: '这个订阅地址没有返回流量信息(Subscription-Userinfo),可能服务商不支持或者地址不对' });
const info = {};
header.split(';').forEach(part => {
const [k, v] = part.trim().split('=');
if (k && v !== undefined) info[k.trim()] = Number(v.trim());
});
res.json({
ok: true,
upload: info.upload || 0,
download: info.download || 0,
total: info.total || 0,
expire: info.expire || null,
});
} 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的长延版本。
【特别注意"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;不适用就填空字符串",
"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;不适用就填空字符串",
"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');
// 只去掉