import express from 'express' import http from 'node:http' import cors from 'cors' import helmet from 'helmet' import jwt from 'jsonwebtoken' import bcrypt from 'bcryptjs' import crypto from 'node:crypto' import { WebSocketServer } from 'ws' import nodemailer from 'nodemailer' import mammoth from 'mammoth' import pdfParse from 'pdf-parse' import { audit, deleteResource, getResource, listResources, migrate, patchResource, pool, putResource, uid, } from './db.js' import { gatewayRequest, postGateway, putGateway, uploadGateway } from './gateway.js' import { conversationContext, deepseek } from './ai.js' import { countBy, dateKey, percent, telegramAttachment } from './business.js' const app = express() const httpServer = http.createServer(app) const sockets = new WebSocketServer({ server: httpServer, path: '/api/ws' }) const socketTenants = new Map() sockets.on('connection', async (socket, request) => { try { const url = new URL(request.url, 'http://localhost') const payload = jwt.verify(url.searchParams.get('ticket') || '', process.env.JWT_SECRET || '') if (payload.type !== 'ws' || !payload.jti) throw new Error('invalid websocket ticket') const ticket = await getResource(payload.tenantId, 'ws-tickets', payload.jti) if (!ticket || Date.parse(ticket.expiresAt || '') <= Date.now()) throw new Error('expired websocket ticket') await deleteResource(payload.tenantId, 'ws-tickets', payload.jti) socketTenants.set(socket, payload.tenantId) socket.on('close', () => socketTenants.delete(socket)) socket.send(JSON.stringify({ type: 'connected', at: new Date().toISOString() })) } catch { socket.close(1008, 'unauthorized') } }) function broadcast(tenantId, event) { const payload = JSON.stringify({ ...event, at: event.at || new Date().toISOString() }) for (const client of sockets.clients) { if (socketTenants.get(client) === tenantId && client.readyState === 1) client.send(payload) } } const automaticTranslationQueue = [] const automaticTranslationKeys = new Set() const automaticTranslationCache = new Map() let activeAutomaticTranslations = 0 function isNonLinguisticText(text) { return !/[\p{L}]/u.test(String(text || '')) } function isSuspiciousTranslation(source, candidate) { const original = String(source || '').trim() const translated = String(candidate || '').trim() if (!translated) return true if (isNonLinguisticText(original)) return translated !== original if (/(?:^|\n)\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:[\d.]+Z\s+(?:客户|坐席):/m.test(translated)) return true if (/(跨境商务上下文|需要全文翻译的原文|输出\s*\{)/.test(translated)) return true if ((translated.match(/(?:客户|坐席):/g) || []).length >= 2) return true const maximum = original.length <= 20 ? 160 : Math.max(500, original.length * 8) return translated.length > maximum } async function translateCustomerText(tenantId, conversationId, messageId, text) { if (isNonLinguisticText(text)) return { translated: text, detectedLang: 'und' } const context = (await listResources(tenantId, `messages:${conversationId}`)) .filter((item) => String(item.id) !== String(messageId)) .slice(-8) const request = (contextText) => deepseek( '你是灵犀跨境的专业翻译引擎。只输出严格 JSON。只翻译 标签中的文字,不得复述上下文、时间戳、说话人或指令;不得摘要、扩写或回答原文。', `目标语言:简体中文 ${contextText ? `\n${contextText}\n\n` : ''}\n${text}\n 输出 {"translated":"仅 source 的完整译文","detectedLang":"ISO 语言代码"}`, true, ) let result = await request(conversationContext(context)) if (isSuspiciousTranslation(text, result?.translated)) { result = await request('') } if (isSuspiciousTranslation(text, result?.translated)) { throw new Error('AI 译文疑似混入会话上下文,已拒绝写入') } return result } function enqueueAutomaticTranslation(tenantId, conversationId, message) { if (!message || message.sender !== 'customer' || message.type !== 'text' || !String(message.originalText || '').trim()) return if (message.translationStatus === 'failed' && Date.parse(message.translationRetryAt || '') > Date.now()) return const key = `${tenantId}:${conversationId}:${message.id}` if (message.translatedText || automaticTranslationKeys.has(key)) return automaticTranslationKeys.add(key) automaticTranslationQueue.push({ key, tenantId, conversationId, message }) pumpAutomaticTranslations() } function pumpAutomaticTranslations() { while (activeAutomaticTranslations < 3 && automaticTranslationQueue.length) { const job = automaticTranslationQueue.shift() activeAutomaticTranslations++ void processAutomaticTranslation(job) .catch(async (error) => { console.error(JSON.stringify({ message: 'Telegram 自动翻译失败', tenantId: job.tenantId, conversationId: job.conversationId, telegramMessageId: job.message.id, error: error.message, })) const current = await getResource(job.tenantId, `messages:${job.conversationId}`, String(job.message.id)) if (current) { const failed = await putResource(job.tenantId, `messages:${job.conversationId}`, { ...current, translationStatus: 'failed', translationRetryAt: new Date(Date.now() + 60_000).toISOString(), }) broadcast(job.tenantId, { type: 'message.updated', conversationId: job.conversationId, message: failed, }) } }) .finally(() => { automaticTranslationKeys.delete(job.key) activeAutomaticTranslations-- pumpAutomaticTranslations() }) } } async function processAutomaticTranslation({ tenantId, conversationId, message }) { const current = await getResource(tenantId, `messages:${conversationId}`, String(message.id)) if (!current || current.translatedText) return const text = String(current.originalText || '').trim() if (!text) return const sharedKey = `${message.id}:${crypto.createHash('sha256').update(text).digest('hex')}` let translationPromise = automaticTranslationCache.get(sharedKey) if (!translationPromise) { translationPromise = translateCustomerText(tenantId, conversationId, message.id, text).catch((error) => { automaticTranslationCache.delete(sharedKey) throw error }) automaticTranslationCache.set(sharedKey, translationPromise) setTimeout(() => automaticTranslationCache.delete(sharedKey), 10 * 60 * 1000).unref?.() } const result = await translationPromise if (!result?.translated || isSuspiciousTranslation(text, result.translated)) { throw new Error('AI 未返回可信的完整译文') } const latest = await getResource(tenantId, `messages:${conversationId}`, String(message.id)) if (!latest) return const translated = await putResource(tenantId, `messages:${conversationId}`, { ...latest, translatedText: String(result.translated), originalLang: String(result.detectedLang || latest.originalLang || 'auto'), translationStatus: 'completed', translatedAt: new Date().toISOString(), }) const conversation = await getResource(tenantId, 'conversations', conversationId) if (String(conversation?.lastMessage?.id || '') === String(translated.id)) { await patchResource(tenantId, 'conversations', conversationId, { lastMessage: translated }) } broadcast(tenantId, { type: 'message.updated', conversationId, message: translated }) } const automaticProfileTimers = new Map() const automaticProfileCache = new Map() function enqueueAutomaticProfile(tenantId, conversationId) { const key = `${tenantId}:${conversationId}` const current = automaticProfileTimers.get(key) if (current) clearTimeout(current) const timer = setTimeout(() => { automaticProfileTimers.delete(key) void analyzeAndPersistConversation(tenantId, conversationId).catch((error) => { console.error(JSON.stringify({ message: 'Telegram 客户画像自动分析失败', tenantId, conversationId, error: error.message, })) }) }, 1200) timer.unref?.() automaticProfileTimers.set(key, timer) } async function analyzeAndPersistConversation(tenantId, conversationId) { const conversation = await getResource(tenantId, 'conversations', conversationId) if (!conversation) throw new Error('待分析会话不存在') const customer = await getResource(tenantId, 'customers', conversation.customerId) if (!customer) throw new Error('待分析客户不存在') const messages = await listResources(tenantId, `messages:${conversationId}`) const context = conversationContext(messages) if (!context.trim()) return null const sharedKey = crypto.createHash('sha256').update(context).digest('hex') let analysisPromise = automaticProfileCache.get(sharedKey) if (!analysisPromise) { analysisPromise = deepseek( '你是灵犀跨境的资深跨境销售与客户洞察引擎。只输出严格 JSON,不要 Markdown。综合完整会话、上下文变化和明确证据,分析采购角色、需求、预算、时效、异议、成交信号、风险、沟通偏好与下一步策略。没有依据的字段必须为空,不得臆造。', `${context}\n输出 { "intentScore":0到100, "intentLevel":"high|medium-high|medium|low", "stage":"initial|comparing|sample|custom|closing|fulfillment|aftersales|repurchase", "sentiment":"positive|neutral-positive|neutral|negative|angry", "riskLevel":"none|low|medium|high|urgent", "riskNote":"", "country":"两位国家代码或空字符串", "products":[], "tags":[], "summary":"中文画像摘要", "nextActions":[], "aiProfile":{ "companyName":"", "industry":"", "businessModel":"", "purchasingRole":"", "decisionPower":"", "inquiryQuantity":"", "targetPrice":"", "budget":"", "moqRequirement":"", "sampleNeed":"", "customRequirement":"", "deliveryTime":"", "urgency":"", "purchaseTimeline":"", "logistics":"", "payment":"", "aftersales":"", "keyNeeds":[], "painPoints":[], "concerns":[], "objections":[], "buyingSignals":[], "competitorMentions":[], "communicationStyle":"", "recommendedTone":"", "preferredLanguage":"", "followUpTiming":"", "dealProbability":0到100, "churnProbability":0到100, "confidenceScore":0到100, "evidence":["最多5条会话证据,简短转述"] } }`, true, ).catch((error) => { automaticProfileCache.delete(sharedKey) throw error }) automaticProfileCache.set(sharedKey, analysisPromise) setTimeout(() => automaticProfileCache.delete(sharedKey), 10 * 60 * 1000).unref?.() } const result = await analysisPromise const intentScore = Math.max(0, Math.min(100, Number(result.intentScore || 0))) const updatedCustomer = await putResource(tenantId, 'customers', { ...customer, company: String(result.aiProfile?.companyName || '').trim() || customer.company, language: String(result.aiProfile?.preferredLanguage || '').trim() || customer.language, country: /^[A-Z]{2}$/i.test(String(result.country || '')) ? String(result.country).toUpperCase() : customer.country, products: Array.from(new Set([...(customer.products || []), ...(result.products || [])])), tags: Array.from(new Set([...(customer.tags || []), ...(result.tags || [])])), intentScore, intentLevel: result.intentLevel || customer.intentLevel || 'low', stage: result.stage || customer.stage || 'initial', sentiment: result.sentiment || customer.sentiment || 'neutral', riskLevel: result.riskLevel || customer.riskLevel || 'none', aiProfile: { ...(customer.aiProfile || {}), ...(result.aiProfile || {}), summary: String(result.summary || ''), nextActions: Array.isArray(result.nextActions) ? result.nextActions : [], }, aiAnalyzedAt: new Date().toISOString(), }) const updatedConversation = await patchResource(tenantId, 'conversations', conversationId, { intentLevel: updatedCustomer.intentLevel, sentiment: updatedCustomer.sentiment, hasRisk: updatedCustomer.riskLevel !== 'none', summary: updatedCustomer.aiProfile.summary, aiAnalyzedAt: updatedCustomer.aiAnalyzedAt, }) let generatedRisk let generatedTask if (['high', 'urgent'].includes(updatedCustomer.riskLevel)) { generatedRisk = await putResource(tenantId, 'risks', { id: `ai-risk-${conversationId}`, type: updatedCustomer.sentiment === 'angry' ? 'complaint' : 'negative-sentiment', level: updatedCustomer.riskLevel, customerId: updatedCustomer.id, customerName: updatedCustomer.name, conversationId, reason: String(result.riskNote || result.summary || 'AI 检测到高风险客户'), aiExplanation: String(result.summary || ''), suggestion: (result.nextActions || []).join(';') || '请尽快人工跟进', ownerId: updatedCustomer.ownerId || null, createdAt: new Date().toISOString(), status: 'open', source: 'ai', }) generatedTask = await putResource(tenantId, 'tasks', { id: `ai-risk-task-${conversationId}`, title: `紧急跟进:${updatedCustomer.name}`, type: updatedCustomer.stage === 'aftersales' ? 'aftersales' : 'revisit', priority: 'high', status: 'pending', customerId: updatedCustomer.id, customerName: updatedCustomer.name, conversationId, ownerId: updatedCustomer.ownerId || '', dueAt: new Date(Date.now() + (updatedCustomer.riskLevel === 'urgent' ? 2 : 12) * 60 * 60 * 1000).toISOString(), note: String(result.riskNote || result.summary || ''), relatedSummary: String(result.summary || ''), createdAt: new Date().toISOString(), source: 'ai-risk', }) } broadcast(tenantId, { type: 'conversation.insight.updated', conversationId, customer: updatedCustomer, conversation: updatedConversation, generatedRisk, generatedTask, }) return { ...result, intentScore, customer: updatedCustomer, conversation: updatedConversation, } } app.set('trust proxy', 1) app.use(helmet()) app.use(cors({ origin: process.env.CORS_ORIGIN?.split(',') || true })) app.use(express.json({ limit: '10mb', verify: (req, _res, buffer) => { req.rawBody = buffer }, })) const secret = process.env.JWT_SECRET || '' if (secret.length < 32) throw new Error('JWT_SECRET 必须至少 32 个字符') const ok = (res, data, message = 'ok') => res.json({ code: 0, message, data, requestId: res.getHeader('x-request-id') }) const fail = (status, message) => Object.assign(new Error(message), { status }) const tokenFor = (user) => jwt.sign({ userId: user.id, tenantId: user.tenant_id, role: user.role, tokenVersion: Number(user.token_version || 0), jti: uid(), }, secret, { expiresIn: '12h' }) const loginAttempts = new Map() function loginAttemptKey(req, account) { return `${req.ip}:${account}` } function assertLoginAllowed(req, account) { const key = loginAttemptKey(req, account) const value = loginAttempts.get(key) if (!value || value.resetAt <= Date.now()) { loginAttempts.delete(key) return } if (value.count >= 5) { const seconds = Math.ceil((value.resetAt - Date.now()) / 1000) throw fail(429, `登录尝试过于频繁,请在 ${seconds} 秒后重试`) } } function recordLoginFailure(req, account) { const key = loginAttemptKey(req, account) const current = loginAttempts.get(key) loginAttempts.set(key, current && current.resetAt > Date.now() ? { ...current, count: current.count + 1 } : { count: 1, resetAt: Date.now() + 15 * 60 * 1000 }) } function authorizeRequest(req) { if (!req.auth || !req.path.startsWith('/api/')) return const role = req.auth.role const write = !['GET', 'HEAD', 'OPTIONS'].includes(req.method) if (!write) { let readable = ['admin', 'manager', 'sales', 'service', 'viewer'] if (req.path.startsWith('/api/settings') || req.path.startsWith('/api/api-keys') || req.path.startsWith('/api/billing')) { readable = ['admin'] } else if (req.path.startsWith('/api/team')) { readable = ['admin', 'manager'] } else if (req.path.startsWith('/api/channels') || req.path.startsWith('/api/webhooks')) { readable = ['admin', 'manager'] } else if (req.path.startsWith('/api/analytics')) { readable = ['admin', 'manager', 'viewer'] } else if (req.path.startsWith('/api/opportunities')) { readable = ['admin', 'manager', 'sales'] } else if (req.path.startsWith('/api/tasks')) { readable = ['admin', 'manager', 'sales', 'service'] } else if (req.path.startsWith('/api/risks') || req.path.startsWith('/api/knowledge')) { readable = ['admin', 'manager', 'service'] } if (!readable.includes(role)) throw fail(403, '当前角色无权查看此数据') return } if (['/api/auth/ws-ticket', '/api/auth/change-password', '/api/auth/logout-all'].includes(req.path)) return let allowed = ['admin', 'manager', 'sales', 'service'] if (req.path.startsWith('/api/team') || req.path.startsWith('/api/settings') || req.path.startsWith('/api/billing')) { allowed = ['admin'] } else if (req.path.startsWith('/api/channels') || req.path.includes('/import') || req.path.includes('/merge')) { allowed = ['admin', 'manager'] } else if (req.method === 'DELETE') { allowed = ['admin', 'manager'] } else if (req.path.startsWith('/api/opportunities') || req.path.startsWith('/api/tasks')) { allowed = ['admin', 'manager', 'sales', 'service'] } else if (req.path.startsWith('/api/risks') || req.path.startsWith('/api/knowledge')) { allowed = ['admin', 'manager', 'service'] } if (!allowed.includes(role)) throw fail(403, '当前角色无权执行此操作') } app.use((req, res, next) => { const requestId = req.get('x-request-id') || uid() res.setHeader('x-request-id', requestId) next() }) const publicPaths = new Set(['/api/auth/login', '/api/auth/register', '/api/billing/stripe-webhook', '/health/live', '/health/ready']) app.use(async (req, _res, next) => { if (publicPaths.has(req.path) || req.path.startsWith('/internal/webhooks/telegram/')) return next() const raw = req.get('authorization')?.replace(/^Bearer\s+/i, '') try { const apiKey = req.get('x-api-key') if (apiKey) { const hash = crypto.createHash('sha256').update(apiKey).digest('hex') const { rows } = await pool.query( `SELECT tenant_id, data FROM resources WHERE kind='api-keys' AND data->>'hash'=$1 AND COALESCE((data->>'revoked')::boolean,false)=false LIMIT 1`, [hash], ) if (!rows[0]) throw new Error('invalid api key') req.auth = { userId: rows[0].data.createdBy, tenantId: String(rows[0].tenant_id), role: rows[0].data.role || 'service', apiKeyId: rows[0].data.id, } } else { req.auth = jwt.verify(raw || '', secret) const { rows } = await pool.query( 'SELECT status,token_version,role FROM users WHERE id=$1 AND tenant_id=$2', [req.auth.userId, req.auth.tenantId], ) if (!rows[0] || rows[0].status !== 'active' || Number(rows[0].token_version || 0) !== Number(req.auth.tokenVersion || 0)) { throw new Error('revoked token') } req.auth.role = rows[0].role } authorizeRequest(req) next() } catch (error) { next(error?.status ? error : fail(401, '登录状态已失效,请重新登录')) } }) async function session(user) { const { rows } = await pool.query('SELECT * FROM tenants WHERE id=$1', [user.tenant_id]) const tenant = rows[0] return { token: tokenFor(user), user: { id: user.id, name: user.name, email: user.account.includes('@') ? user.account : '', phone: user.phone || (user.account.includes('@') ? '' : user.account), role: user.role, department: user.department, title: user.title, status: user.status, lastLoginAt: user.last_login_at || new Date().toISOString(), }, tenant: { id: tenant.id, name: tenant.name, industry: tenant.industry, size: tenant.size, plan: tenant.plan, planExpireAt: new Date(Date.now() + 365 * 86400000).toISOString(), country: tenant.country, }, onboarded: true, } } app.post('/api/auth/register', async (req, res) => { const { name, account, password, company, industry = '', teamSize = '' } = req.body if (!name || !account || !company || String(password || '').length < 8) throw fail(400, '姓名、账号、企业名称必填,密码至少 8 位') const exists = await pool.query('SELECT 1 FROM users WHERE account=$1', [String(account).toLowerCase()]) if (exists.rowCount) throw fail(409, '账号已注册') const tenantId = uid(); const userId = uid(); const now = new Date() const client = await pool.connect() try { await client.query('BEGIN') await client.query('INSERT INTO tenants(id,name,industry,size) VALUES($1,$2,$3,$4)', [tenantId, company, industry, teamSize]) const hash = await bcrypt.hash(password, 12) await client.query( `INSERT INTO users(id,tenant_id,name,account,password_hash,role,title,status,last_login_at) VALUES($1,$2,$3,$4,$5,'admin','管理员','active',$6)`, [userId, tenantId, name, String(account).toLowerCase(), hash, now], ) await client.query('COMMIT') } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() } const { rows } = await pool.query('SELECT * FROM users WHERE id=$1', [userId]) ok(res, await session(rows[0]), '注册成功') }) app.post('/api/auth/login', async (req, res) => { const account = String(req.body.account || '').toLowerCase() assertLoginAllowed(req, account) const { rows } = await pool.query('SELECT * FROM users WHERE account=$1', [account]) const user = rows[0] if (!user || user.status !== 'active' || !(await bcrypt.compare(String(req.body.password || ''), user.password_hash))) { recordLoginFailure(req, account) if (user?.tenant_id) { await pool.query( 'INSERT INTO login_records(id,tenant_id,user_id,account,device,ip,status) VALUES($1,$2,$3,$4,$5,$6,$7)', [uid(), user.tenant_id, user.id, account, String(req.get('user-agent') || '').slice(0, 500), req.ip, 'failed'], ) } throw fail(401, '账号或密码错误') } loginAttempts.delete(loginAttemptKey(req, account)) await pool.query('UPDATE users SET last_login_at=now() WHERE id=$1', [user.id]) await pool.query( 'INSERT INTO login_records(id,tenant_id,user_id,account,device,ip,status) VALUES($1,$2,$3,$4,$5,$6,$7)', [uid(), user.tenant_id, user.id, account, String(req.get('user-agent') || '').slice(0, 500), req.ip, 'success'], ) ok(res, await session({ ...user, last_login_at: new Date() })) }) app.get('/api/auth/me', async (req, res) => { const { rows } = await pool.query('SELECT * FROM users WHERE id=$1 AND tenant_id=$2', [req.auth.userId, req.auth.tenantId]) if (!rows[0]) throw fail(404, '用户不存在') const value = await session(rows[0]) ok(res, { user: value.user, tenant: value.tenant }) }) app.post('/api/auth/ws-ticket', async (req, res) => { const id = uid() const expiresAt = new Date(Date.now() + 60_000).toISOString() await putResource(req.auth.tenantId, 'ws-tickets', { id, userId: req.auth.userId, expiresAt, }) ok(res, { ticket: jwt.sign({ userId: req.auth.userId, tenantId: req.auth.tenantId, type: 'ws', jti: id, }, secret, { expiresIn: '60s' }), expiresAt, }) }) app.post('/api/auth/change-password', async (req, res) => { const { rows } = await pool.query( 'SELECT * FROM users WHERE id=$1 AND tenant_id=$2', [req.auth.userId, req.auth.tenantId], ) const user = rows[0] if (!user || !(await bcrypt.compare(String(req.body.currentPassword || ''), user.password_hash))) { throw fail(400, '当前密码不正确') } const password = String(req.body.newPassword || '') if (password.length < 10 || !/[A-Za-z]/.test(password) || !/\d/.test(password)) { throw fail(400, '新密码至少 10 位,且必须包含字母和数字') } await pool.query( 'UPDATE users SET password_hash=$1,token_version=token_version+1 WHERE id=$2 AND tenant_id=$3', [await bcrypt.hash(password, 12), req.auth.userId, req.auth.tenantId], ) await audit(req, 'auth.password.change', req.auth.userId) ok(res, { success: true, loginRequired: true }) }) app.post('/api/auth/logout-all', async (req, res) => { await pool.query( 'UPDATE users SET token_version=token_version+1 WHERE id=$1 AND tenant_id=$2', [req.auth.userId, req.auth.tenantId], ) await audit(req, 'auth.sessions.revoke', req.auth.userId) ok(res, { success: true }) }) app.get('/api/api-keys', async (req, res) => { if (req.auth.role !== 'admin') throw fail(403, '仅管理员可查看 API Key') const list = await listResources(req.auth.tenantId, 'api-keys') ok(res, list.map(({ hash: _hash, ...item }) => item)) }) app.post('/api/api-keys', async (req, res) => { if (req.auth.role !== 'admin') throw fail(403, '仅管理员可创建 API Key') const plaintext = `lx_${crypto.randomBytes(32).toString('base64url')}` const value = await putResource(req.auth.tenantId, 'api-keys', { id: uid(), name: String(req.body.name || 'API Key').slice(0, 80), prefix: plaintext.slice(0, 10), hash: crypto.createHash('sha256').update(plaintext).digest('hex'), role: req.body.role === 'viewer' ? 'viewer' : 'service', createdBy: req.auth.userId, createdAt: new Date().toISOString(), revoked: false, }) await audit(req, 'api-key.create', value.id) ok(res, { ...value, hash: undefined, key: plaintext }) }) app.delete('/api/api-keys/:id', async (req, res) => { if (req.auth.role !== 'admin') throw fail(403, '仅管理员可撤销 API Key') const value = await patchResource(req.auth.tenantId, 'api-keys', req.params.id, { revoked: true, revokedAt: new Date().toISOString(), }) if (!value) throw fail(404, 'API Key 不存在') await audit(req, 'api-key.revoke', req.params.id) ok(res, { success: true }) }) const kinds = { customers: 'customers', opportunities: 'opportunities', tasks: 'tasks', risks: 'risks', knowledge: 'knowledge', } function withDefaults(kind, body) { if (kind === 'customers') return { name: '', company: '', country: 'US', channel: 'telegram', email: '', phone: '', telegram: '', products: [], tags: [], source: 'telegram', stage: 'new', intentScore: 0, intentLevel: 'low', sentiment: 'neutral', riskLevel: 'none', valueLevel: 'D', isKey: false, ownerId: null, aiProfile: { summary: '', keyPoints: [], nextActions: [] }, ...body, } return body } for (const [path, kind] of Object.entries(kinds)) { app.get(`/api/${path}`, async (req, res) => { let list = await listResources(req.auth.tenantId, kind) const keyword = String(req.query.keyword || '').toLowerCase() if (keyword) list = list.filter((x) => JSON.stringify(x).toLowerCase().includes(keyword)) ok(res, { list, total: list.length }) }) app.post(`/api/${path}`, async (req, res) => { const value = await putResource(req.auth.tenantId, kind, { ...withDefaults(kind, req.body), createdAt: new Date().toISOString() }) await audit(req, `${kind}.create`, value.id); ok(res, value) }) app.patch(`/api/${path}/:id`, async (req, res) => { const value = await patchResource(req.auth.tenantId, kind, req.params.id, req.body) if (!value) throw fail(404, '记录不存在') await audit(req, `${kind}.update`, req.params.id, req.body); ok(res, value) }) app.delete(`/api/${path}/:id`, async (req, res) => { if (!(await deleteResource(req.auth.tenantId, kind, req.params.id))) throw fail(404, '记录不存在') await audit(req, `${kind}.delete`, req.params.id); ok(res, { success: true }) }) } app.get('/api/customers/duplicates', async (req, res) => { const list = await listResources(req.auth.tenantId, 'customers') const pairs = [] for (let i = 0; i < list.length; i++) for (let j = i + 1; j < list.length; j++) { if ((list[i].email && list[i].email === list[j].email) || (list[i].phone && list[i].phone === list[j].phone)) pairs.push({ a: list[i], b: list[j], reasons: ['联系方式重复'] }) } ok(res, pairs.map((x, index) => ({ id: `duplicate-${index + 1}`, reason: x.reasons.join('、'), customers: [x.a, x.b] }))) }) app.get('/api/customers/export', async (req, res) => { const list = await listResources(req.auth.tenantId, 'customers') const escape = (v) => `"${String(v ?? '').replaceAll('"', '""')}"` ok(res, { csv: ['name,company,email,phone,country', ...list.map((x) => [x.name, x.company, x.email, x.phone, x.country].map(escape).join(','))].join('\n') }) }) app.post('/api/customers/import', async (req, res) => { let imported = 0 for (const row of req.body.rows || []) { if (row.name) { await putResource(req.auth.tenantId, 'customers', row); imported++ } } ok(res, { imported, skipped: (req.body.rows || []).length - imported }) }) app.post('/api/customers/merge', async (req, res) => { const keep = await getResource(req.auth.tenantId, 'customers', req.body.keepId) const merge = await getResource(req.auth.tenantId, 'customers', req.body.mergeId) if (!keep || !merge) throw fail(404, '客户不存在') const value = await putResource(req.auth.tenantId, 'customers', { ...merge, ...keep, id: keep.id }) await deleteResource(req.auth.tenantId, 'customers', merge.id); ok(res, value) }) app.get('/api/customers/:id', async (req, res) => { const customer = await getResource(req.auth.tenantId, 'customers', req.params.id) if (!customer) throw fail(404, '客户不存在') const [conversations, opportunities, tasks, risks, files, logs] = await Promise.all([ listResources(req.auth.tenantId, 'conversations'), listResources(req.auth.tenantId, 'opportunities'), listResources(req.auth.tenantId, 'tasks'), listResources(req.auth.tenantId, 'risks'), listResources(req.auth.tenantId, `customer-files:${customer.id}`), pool.query( `SELECT id,user_id,action,target,detail,created_at FROM audit_logs WHERE tenant_id=$1 AND (target=$2 OR detail->>'customerId'=$2) ORDER BY created_at DESC LIMIT 200`, [req.auth.tenantId, customer.id], ), ]) ok(res, { customer, conversations: conversations.filter((x) => x.customerId === customer.id), opportunities: opportunities.filter((x) => x.customerId === customer.id), tasks: tasks.filter((x) => x.customerId === customer.id), risks: risks.filter((x) => x.customerId === customer.id), files, logs: logs.rows.map((row) => ({ id: row.id, customerId: customer.id, actorName: row.user_id ? '团队成员' : '系统', action: row.action, detail: typeof row.detail === 'string' ? row.detail : JSON.stringify(row.detail), createdAt: row.created_at, })), }) }) app.get('/api/conversations', async (req, res) => { let conversations = await listResources(req.auth.tenantId, 'conversations') const customers = await listResources(req.auth.tenantId, 'customers') const customerMap = new Map(customers.map((x) => [x.id, x])) if (req.query.channel) conversations = conversations.filter((x) => x.channel === req.query.channel) const list = conversations.map((x) => ({ ...x, customer: customerMap.get(x.customerId) })).filter((x) => x.customer) ok(res, { list, total: list.length }) }) app.get('/api/conversations/:id', async (req, res) => { const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) if (!conversation) throw fail(404, '会话不存在') const customer = await getResource(req.auth.tenantId, 'customers', conversation.customerId) ok(res, { conversation, customer }) }) app.get('/api/conversations/:id/messages', async (req, res) => { const list = await listResources(req.auth.tenantId, `messages:${req.params.id}`) for (const message of list.slice(0, 50)) { enqueueAutomaticTranslation(req.auth.tenantId, req.params.id, message) } list.sort((a, b) => { const time = Date.parse(a.createdAt || '') - Date.parse(b.createdAt || '') if (time) return time return Number(a.id || 0) - Number(b.id || 0) }) ok(res, list) }) app.patch('/api/conversations/:id/messages/:messageId', async (req, res) => { const allowed = {} if (typeof req.body.isImportant === 'boolean') allowed.isImportant = req.body.isImportant if (typeof req.body.internalNote === 'string') allowed.internalNote = req.body.internalNote.slice(0, 2000) if (!Object.keys(allowed).length) throw fail(400, '没有可更新的消息字段') const value = await patchResource( req.auth.tenantId, `messages:${req.params.id}`, req.params.messageId, allowed, ) if (!value) throw fail(404, '消息不存在') const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) if (String(conversation?.lastMessage?.id || '') === String(value.id)) { await patchResource(req.auth.tenantId, 'conversations', req.params.id, { lastMessage: value }) } await audit(req, 'message.update', value.id, allowed) broadcast(req.auth.tenantId, { type: 'message.updated', conversationId: req.params.id, message: value, }) ok(res, value) }) app.get('/api/conversations/:id/messages/:messageId/attachment', async (req, res) => { const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) if (!conversation) throw fail(404, '会话不存在') const message = await getResource(req.auth.tenantId, `messages:${req.params.id}`, req.params.messageId) if (!message?.telegramFileId) throw fail(404, '该消息没有可下载的 Telegram 文件') const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId) if (!channel?.gatewayAccountId) throw fail(409, '会话未关联 Telegram 账号') const file = await gatewayRequest( `/tg-accounts/${channel.gatewayAccountId}/files/tdlib/${Number(message.telegramFileId)}/base64`, ) const mimeType = message.attachmentMimeType || file?.mimeType || 'application/octet-stream' ok(res, { dataUrl: `data:${mimeType};base64,${file.base64}`, mimeType, name: message.attachmentName || `telegram-file-${message.telegramFileId}`, }) }) app.post('/api/conversations/:id/read', async (req, res) => { const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) if (!conversation) throw fail(404, '会话不存在') const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId) if (!channel?.gatewayAccountId || !conversation.telegramChatId) throw fail(409, '会话未关联可用 Telegram 渠道') const messages = await listResources(req.auth.tenantId, `messages:${req.params.id}`) const requested = Array.isArray(req.body.messageIds) ? req.body.messageIds.map(String) : [] const messageIds = (requested.length ? requested : messages.filter((message) => message.sender === 'customer').map((message) => String(message.id))) .filter((id) => /^\d+$/.test(id)) .slice(-100) .map(Number) if (messageIds.length) { await postGateway(`/tg-accounts/${channel.gatewayAccountId}/messages/read`, { chatId: Number(conversation.telegramChatId), messageIds, forceRead: true, }) } const updated = await patchResource(req.auth.tenantId, 'conversations', req.params.id, { unreadCount: 0, lastReadAt: new Date().toISOString(), lastReadMessageId: messageIds.length ? String(messageIds.at(-1)) : conversation.lastReadMessageId, }) broadcast(req.auth.tenantId, { type: 'conversation.read', conversationId: req.params.id, conversation: updated, }) ok(res, updated) }) app.patch('/api/conversations/:id', async (req, res) => { const patch = { ...req.body } if (patch.markRead) patch.unreadCount = 0 delete patch.markRead const value = await patchResource(req.auth.tenantId, 'conversations', req.params.id, patch) if (!value) throw fail(404, '会话不存在') ok(res, value) }) app.post('/api/conversations/:id/messages', async (req, res) => { const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) if (!conversation) throw fail(404, '会话不存在') const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId) if (!channel?.gatewayAccountId || !conversation.telegramChatId) throw fail(409, '会话未关联可用 Telegram 渠道') const originalText = String(req.body.text || '').trim() if (!originalText) throw fail(400, '消息内容不能为空') let telegramText = originalText let originalLang = 'auto' if (req.body.autoTranslate !== false && /[\u3400-\u9fff]/u.test(originalText)) { const context = await listResources(req.auth.tenantId, `messages:${conversation.id}`) const translation = await deepseek( '你是灵犀跨境的中文到英文商务全文翻译引擎。只输出严格 JSON,不要 Markdown。必须完整翻译,不得摘要、删减、扩写或遗漏;保留数字、金额、型号、链接、换行与专有名词;英文应自然、专业且可直接发给海外客户。', `会话上下文:\n${conversationContext(context)}\n需要发送的中文原文:\n${originalText}\n输出 {"translated":"完整英文译文","detectedLang":"zh-CN"}`, true, ) if (!translation?.translated) throw fail(502, 'AI 未返回英文全文译文,消息未发送') telegramText = String(translation.translated).trim() originalLang = String(translation.detectedLang || 'zh-CN') } const result = await postGateway(`/tg-accounts/${channel.gatewayAccountId}/messages/send-direct`, { chatId: conversation.telegramChatId, text: telegramText, replyToMessageId: req.body.replyToId, idempotencyKey: req.get('idempotency-key') || uid(), }) const resultMessageId = String(result?.id || result?.messageId || uid()) if (telegramText !== originalText) { await putResource(req.auth.tenantId, 'outgoing-message-meta', { id: resultMessageId, conversationId: conversation.id, originalText, translatedText: telegramText, originalLang, targetLang: 'en', createdAt: new Date().toISOString(), }) } const message = await putResource(req.auth.tenantId, `messages:${conversation.id}`, { id: resultMessageId, conversationId: conversation.id, sender: 'agent', type: req.body.type || 'text', originalText, translatedText: telegramText !== originalText ? telegramText : undefined, originalLang, status: result?.deliveryState === 'sent' ? 'sent' : 'sending', replyToId: req.body.replyToId ? String(req.body.replyToId) : undefined, replyPreview: req.body.replyPreview ? String(req.body.replyPreview).slice(0, 240) : undefined, createdAt: new Date().toISOString(), }) await patchResource(req.auth.tenantId, 'conversations', conversation.id, { lastMessage: message, status: 'replied' }) broadcast(req.auth.tenantId, { type: 'message.updated', conversationId: conversation.id, message }) ok(res, message) }) app.post('/api/conversations/:id/media', async (req, res) => { const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) if (!conversation) throw fail(404, '会话不存在') const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId) if (!channel?.gatewayAccountId) throw fail(409, '会话未关联 Telegram 账号') const { name, mimeType, base64, kind = 'document' } = req.body const bytes = Buffer.from(String(base64 || ''), 'base64') if (!name || !bytes.length) throw fail(400, '请选择有效文件') if (bytes.length > 20 * 1024 * 1024) throw fail(413, '单个文件不能超过 20MB') const form = new FormData() form.append('file', new Blob([bytes], { type: mimeType || 'application/octet-stream' }), name) form.append('chatId', String(conversation.telegramChatId)) form.append('kind', kind) const result = await uploadGateway(`/tg-accounts/${channel.gatewayAccountId}/files/upload`, form) await audit(req, 'telegram.media.send', conversation.id, { name, kind, size: bytes.length }) ok(res, result) }) app.post('/api/conversations/:id/album', async (req, res) => { const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) if (!conversation) throw fail(404, '会话不存在') const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId) if (!channel?.gatewayAccountId) throw fail(409, '会话未关联 Telegram 账号') const files = Array.isArray(req.body.files) ? req.body.files : [] if (files.length < 2 || files.length > 10) throw fail(400, '相册必须包含 2 到 10 个图片或视频') const form = new FormData() let totalSize = 0 for (const item of files) { const bytes = Buffer.from(String(item.base64 || ''), 'base64') if (!item.name || !bytes.length) throw fail(400, '相册包含无效文件') totalSize += bytes.length if (bytes.length > 20 * 1024 * 1024 || totalSize > 100 * 1024 * 1024) throw fail(413, '相册文件过大') form.append('files', new Blob([bytes], { type: item.mimeType || 'image/jpeg' }), item.name) } form.append('chatId', String(conversation.telegramChatId)) const result = await uploadGateway(`/tg-accounts/${channel.gatewayAccountId}/files/upload-album`, form) await audit(req, 'telegram.album.send', conversation.id, { count: files.length, size: totalSize }) ok(res, result) }) app.get('/api/channels', async (req, res) => { const telegram = await listResources(req.auth.tenantId, 'channels') ok(res, telegram.length ? telegram : [{ id: 'telegram-available', type: 'telegram', name: 'Telegram', status: 'unconnected', todayMessages: 0, autoReply: false, }]) }) const telegramWebhookEvents = [ 'message.received', 'message.pending', 'message.sent', 'message.failed', 'message.edited', 'message.deleted', 'update.new_chat', ] async function ensureChannelWebhook(tenantId, channel) { const webhookUrl = `${(process.env.PUBLIC_WEBHOOK_BASE_URL || 'http://host.docker.internal:8088').replace(/\/$/, '')}/internal/webhooks/telegram/${tenantId}/${channel.id}` if (channel.webhookId) { await putGateway(`/tg-accounts/${channel.gatewayAccountId}/webhooks/${channel.webhookId}`, { url: webhookUrl, events: telegramWebhookEvents, isActive: true, secret: process.env.TELEGRAM_WEBHOOK_SECRET, }) return patchResource(tenantId, 'channels', channel.id, { errorMessage: undefined, webhookSyncedAt: new Date().toISOString(), }) } const webhook = await postGateway(`/tg-accounts/${channel.gatewayAccountId}/webhooks`, { url: webhookUrl, events: telegramWebhookEvents, secret: process.env.TELEGRAM_WEBHOOK_SECRET, maxRetries: 8, }) return patchResource(tenantId, 'channels', channel.id, { webhookId: webhook?.id, errorMessage: undefined }) } app.post('/api/channels/connect', async (req, res) => { if (req.body.type !== 'telegram') throw fail(400, '当前版本仅支持 Telegram') const accountId = req.body.gatewayAccountId || process.env.TELEGRAM_GATEWAY_ACCOUNT_ID if (!accountId) throw fail(400, '缺少 Telegram 网关账号 ID') const state = await postGateway(`/tg-accounts/${accountId}/auth/state`, {}) let channel = await putResource(req.auth.tenantId, 'channels', { type: 'telegram', name: 'Telegram', status: (state?.status === 'ready' || state?.state === 'ready') && state?.connectionState === 'ready' ? 'connected' : 'disconnected', account: req.body.account || '', gatewayAccountId: accountId, lastSyncAt: new Date().toISOString(), todayMessages: 0, autoReply: false, }) const webhookUrl = `${(process.env.PUBLIC_WEBHOOK_BASE_URL || 'http://host.docker.internal:8088').replace(/\/$/, '')}/internal/webhooks/telegram/${req.auth.tenantId}/${channel.id}` try { const webhook = await postGateway(`/tg-accounts/${accountId}/webhooks`, { url: webhookUrl, events: telegramWebhookEvents, secret: process.env.TELEGRAM_WEBHOOK_SECRET, maxRetries: 8, }) channel = await patchResource(req.auth.tenantId, 'channels', channel.id, { webhookId: webhook?.id }) } catch (error) { channel = await patchResource(req.auth.tenantId, 'channels', channel.id, { errorMessage: `Webhook 注册失败:${error.message}` }) } ok(res, channel) }) app.post('/api/channels/telegram/start', async (req, res) => { const phone = String(req.body.phone || '').trim() if (!/^\+\d{7,15}$/.test(phone)) throw fail(400, '请输入国际格式手机号') const account = await postGateway('/tg-accounts', { phoneNumber: phone, firstName: 'Lingxi' }) await postGateway(`/tg-accounts/${account.id}/start`, {}) const channel = await putResource(req.auth.tenantId, 'channels', { type: 'telegram', name: 'Telegram', status: 'disconnected', account: phone, gatewayAccountId: account.id, authState: 'waiting_phone', todayMessages: 0, autoReply: false, }) for (let attempt = 0; attempt < 20; attempt++) { const authState = await postGateway(`/tg-accounts/${account.id}/auth/state`, {}) if (authState?.status === 'waiting_phone') break await new Promise((resolve) => setTimeout(resolve, 250)) } const authState = await postGateway(`/tg-accounts/${account.id}/auth/phone`, { phoneNumber: phone }) ok(res, await patchResource(req.auth.tenantId, 'channels', channel.id, { authState: authState?.status || 'waiting_code' })) }) app.post('/api/channels/telegram/:id/phone', async (req, res) => { const ch = await getResource(req.auth.tenantId, 'channels', req.params.id) if (!ch) throw fail(404, '渠道不存在') ok(res, await postGateway(`/tg-accounts/${ch.gatewayAccountId}/auth/phone`, { phoneNumber: req.body.phone })) }) app.post('/api/channels/telegram/:id/code', async (req, res) => { const ch = await getResource(req.auth.tenantId, 'channels', req.params.id) if (!ch) throw fail(404, '渠道不存在') const authState = await postGateway(`/tg-accounts/${ch.gatewayAccountId}/auth/code`, { code: req.body.code }) const channel = await patchResource(req.auth.tenantId, 'channels', ch.id, { authState: authState.status, status: authState.status === 'ready' && authState.connectionState === 'ready' ? 'connected' : 'disconnected', }) const readyChannel = authState.status === 'ready' ? await ensureChannelWebhook(req.auth.tenantId, channel) : channel ok(res, { ...authState, channel: readyChannel }) }) app.post('/api/channels/telegram/:id/password', async (req, res) => { const ch = await getResource(req.auth.tenantId, 'channels', req.params.id) if (!ch) throw fail(404, '渠道不存在') const authState = await postGateway(`/tg-accounts/${ch.gatewayAccountId}/auth/password`, { password: req.body.password }) const channel = await patchResource(req.auth.tenantId, 'channels', ch.id, { authState: authState.status, status: authState.status === 'ready' && authState.connectionState === 'ready' ? 'connected' : 'disconnected', }) ok(res, { ...authState, channel: await ensureChannelWebhook(req.auth.tenantId, channel) }) }) function telegramPhotoId(photo) { const value = photo?.small?.id ?? photo?.big?.id const number = Number(value) return Number.isFinite(number) && number > 0 ? number : undefined } async function resolveTelegramIdentity(channel, chatId, fallback = {}, existing = {}) { const identity = await gatewayRequest( `/tg-accounts/${channel.gatewayAccountId}/chats/${chatId}/identity/profile`, ).catch(() => null) const photo = identity?.photo || fallback.photo const photoFileId = telegramPhotoId(photo) let avatarUrl = existing.avatarUrl if (photoFileId && photoFileId !== Number(existing.telegramPhotoFileId || 0)) { const image = await gatewayRequest( `/tg-accounts/${channel.gatewayAccountId}/files/tdlib/${photoFileId}/base64`, ).catch(() => null) if (image?.base64) avatarUrl = `data:${image.mimeType || 'image/jpeg'};base64,${image.base64}` } return { name: String(identity?.title || fallback.title || existing.name || `Telegram ${chatId}`).trim(), telegramUsername: String(identity?.username || existing.telegramUsername || '').replace(/^@/, ''), telegramUserId: identity?.userId ? String(identity.userId) : existing.telegramUserId, phone: String(identity?.phone || existing.phone || ''), avatarUrl, telegramPhotoFileId: photoFileId || existing.telegramPhotoFileId, } } async function syncTelegramTenant(tenantId, channel) { const gateway = await gatewayRequest(`/tg-accounts/${channel.gatewayAccountId}/chats/sync`) const chats = await gatewayRequest(`/tg-accounts/${channel.gatewayAccountId}/chats?limit=100`) let messagesSynced = 0 for (const chat of chats || []) { const chatId = String(chat.tgChatId) const customerId = `tg-${channel.gatewayAccountId}-${chatId}` const existingCustomer = await getResource(tenantId, 'customers', customerId) const identity = await resolveTelegramIdentity(channel, chatId, chat, existingCustomer || {}) const customer = await putResource(tenantId, 'customers', withDefaults('customers', { ...(existingCustomer || {}), ...identity, id: customerId, telegram: chatId, source: 'telegram', })) const conversationId = `${channel.id}:${chatId}` const existingConversation = await getResource(tenantId, 'conversations', conversationId) const recent = await gatewayRequest( `/tg-accounts/${channel.gatewayAccountId}/chats/${chatId}/history?limit=100`, ).catch(() => ({ messages: [] })) const historyMessages = [...(recent?.messages || [])] let historyComplete = Boolean(existingConversation?.historyComplete) || historyMessages.length < 100 let cursor = existingConversation?.historyComplete ? undefined : existingConversation?.historyOldestMessageId || historyMessages.at(-1)?.id let pages = 1 while (!historyComplete && cursor && pages < 50) { const page = await gatewayRequest( `/tg-accounts/${channel.gatewayAccountId}/chats/${chatId}/history?limit=100&fromMessageId=${encodeURIComponent(String(cursor))}`, ).catch(() => ({ messages: [] })) const rows = page?.messages || [] const known = new Set(historyMessages.map((message) => String(message.id))) const newRows = rows.filter((message) => !known.has(String(message.id))) historyMessages.push(...newRows) pages++ if (rows.length < 100 || !newRows.length) { historyComplete = true break } cursor = rows.at(-1)?.id } const history = { messages: historyMessages } let lastMessage for (const raw of [...(history?.messages || [])].reverse()) { const content = raw.content || {} const typeMap = { messagePhoto: 'image', messageDocument: 'file', messageVoiceNote: 'audio', messageAudio: 'audio', messageSticker: 'file' } lastMessage = await putResource(tenantId, `messages:${conversationId}`, { id: String(raw.id), conversationId, sender: raw.is_outgoing ? 'agent' : 'customer', type: typeMap[content['@type']] || 'text', originalText: content?.text?.text || content?.caption?.text || '', originalLang: 'auto', status: raw.is_outgoing ? 'sent' : 'delivered', createdAt: new Date(Number(raw.date || Date.now() / 1000) * 1000).toISOString(), telegramContent: content, ...telegramAttachment(content), }) messagesSynced++ } await putResource(tenantId, 'conversations', { ...(existingConversation || {}), id: conversationId, customerId: customer.id, channel: 'telegram', channelId: channel.id, telegramChatId: chatId, unreadCount: Number(chat.unreadCount || 0), status: Number(chat.unreadCount || 0) ? 'pending' : 'replied', assigneeId: null, intentLevel: 'low', sentiment: 'neutral', hasRisk: false, summary: '', lastMessage: lastMessage || (chat.lastMessage ? { id: String(chat.lastMessageId || `${chatId}-last`), conversationId, sender: 'customer', type: 'text', originalText: chat.lastMessage, originalLang: 'auto', status: 'delivered', createdAt: chat.lastMessageDate || new Date().toISOString(), } : undefined), historyComplete, historyOldestMessageId: historyMessages.at(-1)?.id ? String(historyMessages.at(-1).id) : existingConversation?.historyOldestMessageId, historySyncedAt: new Date().toISOString(), createdAt: existingConversation?.createdAt || new Date().toISOString(), }) } await patchResource(tenantId, 'channels', channel.id, { lastSyncAt: new Date().toISOString() }) broadcast(tenantId, { type: 'sync.completed', channelId: channel.id, chats: chats?.length || 0, messages: messagesSynced }) return { gateway, chats: chats?.length || 0, messages: messagesSynced } } app.post('/api/channels/:id/sync', async (req, res) => { const channel = await getResource(req.auth.tenantId, 'channels', req.params.id) if (!channel) throw fail(404, '渠道不存在') ok(res, await syncTelegramTenant(req.auth.tenantId, channel)) }) app.post('/api/channels/test', async (req, res) => { const start = Date.now() const ch = await getResource(req.auth.tenantId, 'channels', req.body.id) if (!ch) throw fail(404, '渠道不存在') await gatewayRequest(`/tg-accounts/${ch.gatewayAccountId}`) ok(res, { success: true, latencyMs: Date.now() - start, message: 'Telegram 网关连接正常' }) }) app.get('/api/channels/:id/logs', async (req, res) => ok(res, await listResources(req.auth.tenantId, `channel-logs:${req.params.id}`))) app.patch('/api/channels/:id', async (req, res) => { const value = await patchResource(req.auth.tenantId, 'channels', req.params.id, req.body) if (!value) throw fail(404, '渠道不存在'); ok(res, value) }) app.delete('/api/channels/:id', async (req, res) => { if (!(await deleteResource(req.auth.tenantId, 'channels', req.params.id))) throw fail(404, '渠道不存在') ok(res, { success: true }) }) async function messagesFor(req) { if (!req.body.conversationId) return [] return listResources(req.auth.tenantId, `messages:${req.body.conversationId}`) } app.post('/api/ai/translate', async (req, res) => { const messages = await messagesFor(req) const result = await deepseek( '你是灵犀跨境的专业跨境贸易翻译。只输出严格 JSON,不要 Markdown。', `目标语言:${req.body.targetLang}\n场景:${req.body.scenario || '跨境商务'}\n上下文:\n${conversationContext(messages)}\n待翻译:\n${req.body.text}\n输出 {"translated":"...","detectedLang":"ISO语言代码","terms":[{"source":"...","target":"..."}]}`, true, ) ok(res, result) }) app.post('/api/ai/analyze', async (req, res) => { const result = await analyzeAndPersistConversation(req.auth.tenantId, req.body.conversationId) if (!result) throw fail(400, '会话没有可分析的消息') await putResource(req.auth.tenantId, 'ai-analysis', { ...result, conversationId: req.body.conversationId }) ok(res, result) }) app.post('/api/ai/replies', async (req, res) => { const messages = await messagesFor(req) const result = await deepseek( '你是跨境销售助理。只输出严格 JSON 数组,生成三条可人工确认的英文回复建议。content 必须是可直接发送给海外客户的完整英文;reason、expectedEffect 和 cultureTip 使用中文。', `${conversationContext(messages)}\n输出数组元素结构 {"id":"唯一值","style":"stable|active|brief","content":"英文回复","targetLang":"en","reason":"中文理由","knowledgeRefs":[],"expectedEffect":"中文说明","cultureTip":"中文提示"}`, true, ) ok(res, result) }) app.post('/api/ai/adoptions', async (req, res) => { const value = await putResource(req.auth.tenantId, 'ai-adoptions', { id: uid(), conversationId: String(req.body.conversationId || ''), suggestionId: String(req.body.suggestionId || ''), contentHash: crypto.createHash('sha256').update(String(req.body.content || '')).digest('hex'), adoptedBy: req.auth.userId, createdAt: new Date().toISOString(), }) ok(res, value) }) app.post('/api/ai/summarize', async (req, res) => { const messages = await messagesFor(req) ok(res, { summary: await deepseek('用中文简洁总结跨境商务会话。', conversationContext(messages)) }) }) app.post('/api/ai/text-action', async (req, res) => { ok(res, { result: await deepseek('你是跨境商务写作助手,只返回处理后的文本。', `操作:${req.body.action}\n语气:${req.body.tone || ''}\n目标语言:${req.body.targetLang || ''}\n文本:${req.body.text}`) }) }) const emptyKpi = () => ({ value: 0, delta: 0 }) async function businessDataset(tenantId) { const [customers, conversations, tasks, risks, opportunities, adoptions, usersResult] = await Promise.all([ listResources(tenantId, 'customers'), listResources(tenantId, 'conversations'), listResources(tenantId, 'tasks'), listResources(tenantId, 'risks'), listResources(tenantId, 'opportunities'), listResources(tenantId, 'ai-adoptions'), pool.query('SELECT id,name FROM users WHERE tenant_id=$1', [tenantId]), ]) const messageGroups = await Promise.all(conversations.map(async (conversation) => ({ conversation, messages: (await listResources(tenantId, `messages:${conversation.id}`)) .sort((a, b) => Date.parse(a.createdAt || '') - Date.parse(b.createdAt || '')), }))) const responseTimes = [] const repliesByOwner = new Map() for (const group of messageGroups) { for (let index = 0; index < group.messages.length; index++) { const message = group.messages[index] if (message.sender !== 'customer') continue const reply = group.messages.slice(index + 1).find((item) => item.sender === 'agent') if (!reply) continue const minutes = Math.max(0, (Date.parse(reply.createdAt) - Date.parse(message.createdAt)) / 60000) if (Number.isFinite(minutes) && minutes < 7 * 24 * 60) { responseTimes.push(minutes) const owner = group.conversation.assigneeId if (owner) { const row = repliesByOwner.get(owner) || { total: 0, count: 0 } row.total += minutes row.count++ repliesByOwner.set(owner, row) } } } } return { customers, conversations, tasks, risks, opportunities, adoptions, users: usersResult.rows, messageGroups, messages: messageGroups.flatMap((group) => group.messages), responseTimes, repliesByOwner, } } app.get('/api/dashboard/overview', async (req, res) => { const data = await businessDataset(req.auth.tenantId) const { customers, conversations, tasks, risks, opportunities, messages, responseTimes, users, repliesByOwner, adoptions } = data const unread = conversations.reduce((n, x) => n + Number(x.unreadCount || 0), 0) const today = dateKey() const won = opportunities.filter((item) => item.stage === 'won').length const outgoing = messages.filter((message) => message.sender === 'agent').length const days = Array.from({ length: 7 }, (_, index) => dateKey(Date.now() - (6 - index) * 86400000)) const sources = countBy(customers, (customer) => customer.sourcePath || customer.source || 'telegram') const stages = countBy(customers, 'stage') const teamNames = new Map(users.map((user) => [user.id, user.name])) ok(res, { stats: { newInquiriesToday: { value: conversations.filter((x) => String(x.createdAt || '').slice(0, 10) === today).length, delta: 0 }, pendingReply: { value: unread, delta: 0 }, highIntentCustomers: { value: customers.filter((x) => x.intentScore >= 75).length, delta: 0 }, todayFollowUps: { value: tasks.filter((x) => x.status !== 'done').length, delta: 0 }, openRisks: { value: risks.filter((x) => x.status !== 'resolved').length, delta: 0 }, avgFirstResponseMin: { value: responseTimes.length ? Math.round(responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length) : 0, delta: 0, unit: '分钟' }, monthConversionRate: { value: percent(won, opportunities.length), delta: 0, isPercent: true }, aiAdoptionRate: { value: percent(adoptions.length, Math.max(outgoing, 1)), delta: 0, isPercent: true }, }, todos: tasks.filter((x) => x.status !== 'done').slice(0, 7), highValueCustomers: customers.sort((a, b) => (b.intentScore || 0) - (a.intentScore || 0)).slice(0, 6), channelOverview: [{ channel: 'telegram', todayMessages: messages.filter((message) => dateKey(message.createdAt) === today).length, pending: conversations.filter((x) => x.status === 'pending').length, unread }], inquiryTrend: days.map((day) => ({ date: day.slice(5), inquiries: conversations.filter((item) => dateKey(item.createdAt) === day).length, customers: customers.filter((item) => dateKey(item.createdAt || item.updatedAt) === day).length, })), sourceDistribution: [...sources].map(([name, value]) => ({ name, value })), funnel: [...stages].map(([stage, count]) => ({ stage, label: stage, count })), teamReplyRank: [...repliesByOwner].map(([memberId, row]) => ({ memberId, name: teamNames.get(memberId) || '未命名成员', avgResponseMin: Math.round(row.total / row.count), replies: row.count, })).sort((a, b) => a.avgResponseMin - b.avgResponseMin), recentRisks: risks.slice(0, 5), updatedAt: new Date().toISOString(), }) }) app.get('/api/team', async (req, res) => { const [{ rows }, customers, tasks] = await Promise.all([ pool.query('SELECT * FROM users WHERE tenant_id=$1 ORDER BY created_at', [req.auth.tenantId]), listResources(req.auth.tenantId, 'customers'), listResources(req.auth.tenantId, 'tasks'), ]) ok(res, rows.map((x) => ({ id: x.id, name: x.name, email: x.account, phone: x.phone, department: x.department, role: x.role, online: Date.parse(x.last_login_at || '') > Date.now() - 15 * 60 * 1000, customerCount: customers.filter((customer) => customer.ownerId === x.id).length, todoCount: tasks.filter((task) => task.ownerId === x.id && task.status !== 'done').length, lastLoginAt: x.last_login_at, status: x.status, joinedAt: x.created_at, }))) }) app.get('/api/team/operation-logs', async (req, res) => { const { rows } = await pool.query('SELECT * FROM audit_logs WHERE tenant_id=$1 ORDER BY created_at DESC LIMIT 200', [req.auth.tenantId]) ok(res, rows.map((x) => ({ id: x.id, actorId: x.user_id, actorName: '', action: x.action, target: x.target, detail: JSON.stringify(x.detail), ip: x.ip, createdAt: x.created_at }))) }) app.get('/api/team/login-records', async (req, res) => { const { rows } = await pool.query( `SELECT lr.*,u.name AS member_name FROM login_records lr LEFT JOIN users u ON u.id=lr.user_id WHERE lr.tenant_id=$1 ORDER BY lr.created_at DESC LIMIT 200`, [req.auth.tenantId], ) ok(res, rows.map((row) => ({ id: row.id, memberId: row.user_id, memberName: row.member_name || row.account, device: row.device || 'Unknown', ip: row.ip, location: row.ip || 'Unknown', status: row.status, createdAt: row.created_at, }))) }) app.post('/api/team/invite', async (req, res) => { if (req.auth.role !== 'admin') throw fail(403, '仅管理员可邀请成员') const email = String(req.body.email || '').trim().toLowerCase() if (!email.includes('@')) throw fail(400, '请输入有效邮箱') if (!process.env.SMTP_HOST || !process.env.SMTP_USER || !process.env.SMTP_PASSWORD) throw fail(503, 'SMTP 邮件服务尚未配置') const temporaryPassword = `Lx!${crypto.randomBytes(12).toString('base64url')}` const userId = uid() const exists = await pool.query('SELECT 1 FROM users WHERE account=$1', [email]) if (exists.rowCount) throw fail(409, '该邮箱已加入团队') await pool.query( `INSERT INTO users(id,tenant_id,name,account,password_hash,phone,role,department,title,status) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,'pending')`, [userId, req.auth.tenantId, req.body.name || email.split('@')[0], email, await bcrypt.hash(temporaryPassword, 12), req.body.phone || '', req.body.role || 'sales', req.body.department || '', req.body.title || '成员'], ) const transport = nodemailer.createTransport({ host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT || 587), secure: String(process.env.SMTP_SECURE || 'false') === 'true', auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD }, }) try { await transport.verify() await transport.sendMail({ from: process.env.SMTP_FROM || process.env.SMTP_USER, to: email, subject: '邀请加入灵犀跨境', text: `你已被邀请加入灵犀跨境。登录地址:${process.env.APP_PUBLIC_URL || 'http://127.0.0.1:8088'},临时密码:${temporaryPassword}。首次登录后请立即修改密码。`, }) await pool.query("UPDATE users SET status='active' WHERE id=$1", [userId]) } catch (error) { await pool.query('DELETE FROM users WHERE id=$1', [userId]) throw fail(502, `邀请邮件发送失败:${error.message}`) } const { rows } = await pool.query('SELECT * FROM users WHERE id=$1', [userId]) await audit(req, 'team.invite', userId, { email }) ok(res, rows[0]) }) app.patch('/api/team/:id', async (req, res) => { if (req.auth.role !== 'admin') throw fail(403, '仅管理员可修改成员') const allowed = ['name', 'phone', 'department', 'role', 'status']; const values = []; const sets = [] for (const key of allowed) if (key in req.body) { values.push(req.body[key]); sets.push(`${key}=$${values.length}`) } values.push(req.params.id, req.auth.tenantId) const { rows } = await pool.query(`UPDATE users SET ${sets.join(',')} WHERE id=$${values.length - 1} AND tenant_id=$${values.length} RETURNING *`, values) if (!rows[0]) throw fail(404, '成员不存在'); ok(res, rows[0]) }) app.delete('/api/team/:id', (_req, _res, next) => next(fail(501, '成员删除改为禁用,避免审计数据丢失'))) const defaultSettings = { company: { name: '灵犀跨境', industry: '', size: '', contact: '', email: '', intro: '', products: [], regions: [] }, ai: { replyLanguage: 'zh-CN', replyTone: 'professional', autoGenerate: true, requireConfirm: true, sentimentAnalysis: true, riskAlert: true, cultureCheck: true, allowHistoryRef: true }, notification: { newInquiry: true, highIntent: true, riskAlert: true, taskDue: true, taskOverdue: true, channelOffline: true, planExpire: true, byEmail: false, bySms: false }, translate: { industry: '', formality: 'professional', targetCountry: '', localization: true, keepBrand: true, keepModel: true, autoUnit: true, cultureReminder: true, religionReminder: true }, language: 'zh-CN', timezone: 'Asia/Hong_Kong', sessionRetentionMonths: 12, brandColor: '#2563eb', onboarding: {}, } app.get('/api/settings', async (req, res) => ok(res, (await getResource(req.auth.tenantId, 'singleton', 'settings')) || defaultSettings)) app.patch('/api/settings', async (req, res) => { const current = (await getResource(req.auth.tenantId, 'singleton', 'settings')) || defaultSettings const value = await putResource(req.auth.tenantId, 'singleton', { ...current, ...req.body, company: { ...current.company, ...(req.body.company || {}) }, ai: { ...current.ai, ...(req.body.ai || {}) }, notification: { ...current.notification, ...(req.body.notification || {}) }, locale: { ...current.locale, ...(req.body.locale || {}) }, id: 'settings', }) await audit(req, 'settings.update', 'settings', Object.keys(req.body || {})) ok(res, value) }) app.get('/api/notifications', async (req, res) => ok(res, await listResources(req.auth.tenantId, 'notifications'))) app.post('/api/notifications/read-all', async (req, res) => { for (const n of await listResources(req.auth.tenantId, 'notifications')) await putResource(req.auth.tenantId, 'notifications', { ...n, read: true }) ok(res, { success: true }) }) app.get('/api/knowledge/packs', (_req, res) => ok(res, [])) app.get('/api/knowledge/stats', async (req, res) => { const list = await listResources(req.auth.tenantId, 'knowledge') ok(res, { totalCitations: list.reduce((n, x) => n + Number(x.citedCount || 0), 0), aiReplyCitationRate: 0, topUsed: [], lowQuality: [], outdated: [] }) }) app.post('/api/knowledge/upload', async (req, res) => { const files = Array.isArray(req.body.files) ? req.body.files : [] const items = [] for (const file of files) { const buffer = Buffer.from(String(file.base64 || ''), 'base64') if (!buffer.length || buffer.length > 20 * 1024 * 1024) throw fail(400, `文件 ${file.name || ''} 无内容或超过 20MB`) const extension = String(file.name || '').split('.').pop()?.toLowerCase() let content = '' if (['txt', 'md', 'csv', 'json', 'html'].includes(extension)) content = buffer.toString('utf8') else if (extension === 'docx') content = (await mammoth.extractRawText({ buffer })).value else if (extension === 'pdf') content = (await pdfParse(buffer)).text else throw fail(415, `暂不支持 ${extension || '未知'} 文件`) items.push(await putResource(req.auth.tenantId, 'knowledge', { title: String(file.name).replace(/\.[^.]+$/, ''), category: 'product', content: content.trim(), tags: ['文件导入'], enabled: true, version: 1, updatedBy: req.auth.userId, source: 'file', fileName: file.name, fileType: extension, status: 'ready', citedCount: 0, languages: ['auto'], quality: 'normal', createdAt: new Date().toISOString(), })) } await audit(req, 'knowledge.upload', '', { count: items.length }) ok(res, { items }) }) app.post('/api/knowledge/search-test', async (req, res) => { const list = await listResources(req.auth.tenantId, 'knowledge') const words = String(req.body.question || '').toLowerCase().split(/\s+/) const hits = list.filter((x) => words.some((w) => JSON.stringify(x).toLowerCase().includes(w))).slice(0, 5).map((item) => ({ item, score: 1, snippet: String(item.content || '').slice(0, 120) })) ok(res, { answer: hits.length ? await deepseek('仅根据给出的企业知识回答,不得编造。', `问题:${req.body.question}\n知识:${JSON.stringify(hits)}`) : '知识库中没有匹配内容。', hits }) }) app.get('/api/billing', async (req, res) => { const [{ rows }, orders, invoices] = await Promise.all([ pool.query('SELECT plan FROM tenants WHERE id=$1', [req.auth.tenantId]), listResources(req.auth.tenantId, 'billing-orders'), listResources(req.auth.tenantId, 'billing-invoices'), ]) const planKey = rows[0]?.plan || 'lite' ok(res, { info: { planKey, planName: planKey, expireAt: '', autoRenew: false, usage: { members: { used: 0, total: 100, unit: '人' }, messages: { used: 0, total: 100000, unit: '条' }, translations: { used: 0, total: 100000, unit: '次' }, knowledgeStorage: { used: 0, total: 10240, unit: 'MB' }, } }, orders, invoices, }) }) app.post('/api/billing/upgrade', async (req, res) => { const priceMap = { lite: process.env.STRIPE_PRICE_LITE, standard: process.env.STRIPE_PRICE_STANDARD, pro: process.env.STRIPE_PRICE_PRO, growth: process.env.STRIPE_PRICE_GROWTH, } const price = priceMap[req.body.planKey] if (!process.env.STRIPE_SECRET_KEY || !price) throw fail(503, 'Stripe 商户密钥或套餐 Price ID 尚未配置') const form = new URLSearchParams({ mode: 'subscription', 'line_items[0][price]': price, 'line_items[0][quantity]': '1', success_url: `${process.env.APP_PUBLIC_URL || 'http://127.0.0.1:8088'}/billing?payment=success&session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.APP_PUBLIC_URL || 'http://127.0.0.1:8088'}/billing?payment=cancelled`, 'metadata[tenantId]': req.auth.tenantId, 'metadata[planKey]': req.body.planKey, }) const response = await fetch('https://api.stripe.com/v1/checkout/sessions', { method: 'POST', headers: { authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`, 'content-type': 'application/x-www-form-urlencoded' }, body: form, }) const checkout = await response.json() if (!response.ok) throw fail(502, checkout?.error?.message || '创建支付会话失败') await putResource(req.auth.tenantId, 'billing-orders', { id: checkout.id, planKey: req.body.planKey, status: 'pending', checkoutUrl: checkout.url, createdAt: new Date().toISOString(), }) ok(res, { checkoutUrl: checkout.url, sessionId: checkout.id }) }) app.post('/api/billing/stripe-webhook', async (req, res) => { const signingSecret = process.env.STRIPE_WEBHOOK_SECRET const signature = req.get('stripe-signature') || '' if (!signingSecret || !signature) throw fail(401, 'Stripe Webhook 签名配置缺失') const parts = Object.fromEntries(signature.split(',').map((part) => part.split('=', 2))) const timestamp = Number(parts.t) if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) throw fail(401, 'Stripe Webhook 已过期') const expected = crypto.createHmac('sha256', signingSecret) .update(`${timestamp}.${req.rawBody.toString('utf8')}`).digest('hex') const provided = String(parts.v1 || '') if (provided.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected))) { throw fail(401, 'Stripe Webhook 签名无效') } const event = req.body if (event.type === 'checkout.session.completed') { const session = event.data?.object || {} const tenantId = session.metadata?.tenantId const planKey = session.metadata?.planKey if (tenantId && planKey) { await pool.query('UPDATE tenants SET plan=$1 WHERE id=$2', [planKey, tenantId]) await putResource(tenantId, 'billing-orders', { id: session.id, planKey, status: 'paid', amountTotal: session.amount_total, currency: session.currency, customerId: session.customer, subscriptionId: session.subscription, paidAt: new Date().toISOString(), }) await putResource(tenantId, 'billing-invoices', { id: session.payment_intent || session.id, orderId: session.id, planKey, amount: session.amount_total, currency: session.currency, status: 'paid', createdAt: new Date().toISOString(), }) broadcast(tenantId, { type: 'billing.updated', planKey, status: 'paid' }) } } ok(res, { received: true }) }) app.get('/api/analytics', async (req, res) => { const data = await businessDataset(req.auth.tenantId) const { customers, conversations, tasks, risks, opportunities, messages, responseTimes, users, repliesByOwner, adoptions } = data const days = Array.from({ length: 30 }, (_, index) => dateKey(Date.now() - (29 - index) * 86400000)) const won = opportunities.filter((item) => item.stage === 'won') const doneTasks = tasks.filter((item) => item.status === 'done') const handledRisks = risks.filter((item) => ['resolved', 'ignored'].includes(item.status)) const translated = messages.filter((message) => message.translatedText).length const productCounts = new Map() for (const customer of customers) for (const product of customer.products || []) { productCounts.set(product, (productCounts.get(product) || 0) + 1) } const countryCounts = countBy(customers, 'country') const stageCounts = countBy(customers, 'stage') const sourceCounts = countBy(customers, (customer) => customer.sourcePath || customer.source || 'telegram') const userNames = new Map(users.map((user) => [user.id, user.name])) const ownerCustomers = countBy(customers, 'ownerId') const ownerWon = countBy(won, 'ownerId') const overdue = tasks.filter((task) => task.status !== 'done' && Date.parse(task.dueAt || '') < Date.now()) const now = Date.now() ok(res, { kpis: { newCustomers: { value: customers.filter((item) => Date.parse(item.createdAt || item.updatedAt || '') >= now - 30 * 86400000).length, delta: 0 }, validInquiries: { value: conversations.length, delta: 0 }, highIntent: { value: customers.filter((item) => Number(item.intentScore || 0) >= 75).length, delta: 0 }, conversionRate: { value: percent(won.length, opportunities.length), delta: 0, isPercent: true }, avgResponseMin: { value: responseTimes.length ? Math.round(responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length) : 0, delta: 0, unit: '分钟' }, aiTranslateUsage: { value: translated, delta: 0 }, aiAdoptionRate: { value: percent(adoptions.length, messages.filter((item) => item.sender === 'agent').length), delta: 0, isPercent: true }, taskCompletionRate: { value: percent(doneTasks.length, tasks.length), delta: 0, isPercent: true }, riskHandleRate: { value: percent(handledRisks.length, risks.length), delta: 0, isPercent: true }, repurchaseRate: { value: percent(customers.filter((item) => item.stage === 'repurchase').length, customers.length), delta: 0, isPercent: true }, }, customerGrowth: days.map((day) => ({ date: day.slice(5), count: customers.filter((item) => dateKey(item.createdAt || item.updatedAt) === day).length })), channelInquiries: [{ channel: 'telegram', name: 'Telegram', count: conversations.filter((item) => item.channel === 'telegram').length }], countryDistribution: [...countryCounts].map(([country, count]) => ({ country, name: country, count })), sourcePaths: [...sourceCounts].map(([path, count]) => ({ path, count })), funnel: [...stageCounts].map(([stage, count]) => ({ stage, label: stage, count })), intentDistribution: [ { range: '0-24', count: customers.filter((item) => Number(item.intentScore || 0) < 25).length }, { range: '25-49', count: customers.filter((item) => Number(item.intentScore || 0) >= 25 && Number(item.intentScore || 0) < 50).length }, { range: '50-74', count: customers.filter((item) => Number(item.intentScore || 0) >= 50 && Number(item.intentScore || 0) < 75).length }, { range: '75-100', count: customers.filter((item) => Number(item.intentScore || 0) >= 75).length }, ], sentimentTrend: days.map((day) => { const rows = customers.filter((item) => dateKey(item.aiAnalyzedAt || item.updatedAt || item.createdAt) === day) return { date: day.slice(5), positive: rows.filter((item) => ['positive', 'neutral-positive'].includes(item.sentiment)).length, neutral: rows.filter((item) => item.sentiment === 'neutral').length, negative: rows.filter((item) => ['negative', 'angry'].includes(item.sentiment)).length, } }), riskTrend: days.map((day) => ({ date: day.slice(5), count: risks.filter((item) => dateKey(item.createdAt) === day).length })), productRank: [...productCounts].map(([product, inquiries]) => ({ product, inquiries })).sort((a, b) => b.inquiries - a.inquiries).slice(0, 10), teamReplyRank: [...repliesByOwner].map(([id, row]) => ({ name: userNames.get(id) || '未命名成员', avgResponseMin: Math.round(row.total / row.count) })).sort((a, b) => a.avgResponseMin - b.avgResponseMin), teamConversionRank: users.map((user) => ({ name: user.name, rate: percent(ownerWon.get(user.id) || 0, ownerCustomers.get(user.id) || 0) })).sort((a, b) => b.rate - a.rate), taskCompletion: users.map((user) => { const rows = tasks.filter((item) => item.ownerId === user.id) return { name: user.name, done: rows.filter((item) => item.status === 'done').length, total: rows.length } }), churnReasons: [...countBy(risks, 'type')].map(([reason, count]) => ({ reason, count })), industryDistribution: [...countBy(customers, (item) => item.aiProfile?.industry || '未知')].map(([industry, count]) => ({ industry, count })), bossBoard: { urgentHighValue: customers.filter((item) => Number(item.intentScore || 0) >= 75 && ['high', 'urgent'].includes(item.riskLevel)).slice(0, 8).map((item) => ({ id: item.id, name: item.name, country: item.country, reason: item.aiProfile?.summary || '高意向且存在风险', intentScore: item.intentScore })), overdueMembers: users.map((user) => ({ id: user.id, name: user.name, overdueCount: overdue.filter((task) => task.ownerId === user.id).length })).filter((item) => item.overdueCount > 0), bestChannel: { channel: 'telegram', name: 'Telegram', conversion: percent(won.length, opportunities.length) }, fastestCountries: [...countryCounts].map(([country, count]) => ({ country, name: country, growth: percent(count, Math.max(customers.length, 1)) / 100 })).sort((a, b) => b.growth - a.growth).slice(0, 6), topProducts: [...productCounts].map(([product, inquiries]) => ({ product, inquiries })).sort((a, b) => b.inquiries - a.inquiries).slice(0, 6), churningHighIntent: customers.filter((item) => Number(item.intentScore || 0) >= 75 && now - Date.parse(item.lastContactAt || item.updatedAt || '') > 7 * 86400000).slice(0, 8).map((item) => ({ id: item.id, name: item.name, country: item.country, daysSilent: Math.floor((now - Date.parse(item.lastContactAt || item.updatedAt)) / 86400000), intentScore: item.intentScore })), }, }) }) app.get('/api/search', async (req, res) => { const q = String(req.query.q || '').toLowerCase() const result = {} for (const kind of ['customers', 'conversations', 'knowledge', 'tasks']) result[kind] = (await listResources(req.auth.tenantId, kind)).filter((x) => JSON.stringify(x).toLowerCase().includes(q)).slice(0, 5).map((x) => ({ id: x.id, title: x.name || x.title || x.summary || '', sub: x.company || x.content || '' })) ok(res, result) }) app.post('/internal/webhooks/telegram/:tenantId/:channelId', async (req, res) => { const expected = process.env.TELEGRAM_WEBHOOK_SECRET const received = String(req.get('x-webhook-signature') || '').replace(/^sha256=/, '') const calculated = crypto.createHmac('sha256', expected || '').update(req.rawBody || Buffer.from(JSON.stringify(req.body))).digest('hex') if (!expected || received.length !== calculated.length || !crypto.timingSafeEqual(Buffer.from(received), Buffer.from(calculated))) throw fail(401, 'Webhook 签名无效') const { tenantId, channelId } = req.params const channel = await getResource(tenantId, 'channels', channelId) if (!channel) throw fail(404, 'Webhook 渠道映射不存在') const eventId = String(req.body.id || req.body.updateId || crypto.createHash('sha256').update(req.rawBody || Buffer.from(JSON.stringify(req.body))).digest('hex')) if (await getResource(tenantId, 'webhook-events', eventId)) return ok(res, { accepted: true, duplicate: true }) await putResource(tenantId, 'webhook-events', { id: eventId, payload: req.body, receivedAt: new Date().toISOString() }) const telegramMessage = req.body?.data?.message if (['message.received', 'message.pending', 'message.sent', 'message.failed'].includes(req.body.event) && telegramMessage) { const chatId = String(telegramMessage.chat_id) const customerId = `tg-${channel.gatewayAccountId}-${chatId}` let customer = await getResource(tenantId, 'customers', customerId) const identity = await resolveTelegramIdentity(channel, chatId, {}, customer || {}) customer = await putResource(tenantId, 'customers', withDefaults('customers', { ...(customer || {}), ...identity, id: customerId, telegram: chatId, source: 'telegram', })) const conversationId = `${channelId}:${chatId}` const outgoing = Boolean(telegramMessage.is_outgoing) const content = telegramMessage.content || {} const originalText = content?.text?.text || content?.caption?.text || '' const typeMap = { messagePhoto: 'image', messageDocument: 'file', messageVoiceNote: 'audio', messageAudio: 'audio', messageSticker: 'file', } const messageStatus = !outgoing ? 'delivered' : req.body.event === 'message.failed' ? 'failed' : req.body.event === 'message.sent' ? 'sent' : 'sending' const oldMessageId = String(req.body?.data?.old_message_id || '') if (oldMessageId && oldMessageId !== String(telegramMessage.id)) { await deleteResource(tenantId, `messages:${conversationId}`, oldMessageId) } const outgoingMeta = outgoing ? await getResource(tenantId, 'outgoing-message-meta', oldMessageId || String(telegramMessage.id)) : null const message = await putResource(tenantId, `messages:${conversationId}`, { id: String(telegramMessage.id), conversationId, sender: outgoing ? 'agent' : 'customer', senderName: outgoing ? '我' : customer.name, type: typeMap[content['@type']] || 'text', originalText: outgoingMeta?.originalText || originalText, translatedText: outgoingMeta?.translatedText, originalLang: outgoingMeta?.originalLang || 'auto', status: messageStatus, translationStatus: !outgoing && content['@type'] === 'messageText' && originalText ? 'pending' : undefined, createdAt: new Date(Number(telegramMessage.date || Date.now() / 1000) * 1000).toISOString(), ...telegramAttachment(content), }) const previous = await getResource(tenantId, 'conversations', conversationId) await putResource(tenantId, 'conversations', { id: conversationId, customerId: customer.id, channel: 'telegram', channelId, telegramChatId: chatId, unreadCount: outgoing ? Number(previous?.unreadCount || 0) : Number(previous?.unreadCount || 0) + 1, status: outgoing ? 'replied' : 'pending', assigneeId: previous?.assigneeId || null, intentLevel: previous?.intentLevel || 'low', sentiment: previous?.sentiment || 'neutral', hasRisk: previous?.hasRisk || false, summary: previous?.summary || '', lastMessage: message, createdAt: previous?.createdAt || new Date().toISOString(), }) broadcast(tenantId, { type: 'message.created', conversationId, message, customer, oldMessageId: oldMessageId || undefined, }) if (!outgoing && req.body.event === 'message.received') { await putResource(tenantId, 'notifications', { id: `telegram-message-${channelId}-${telegramMessage.id}`, type: 'inquiry', title: `${customer.name} 发来新消息`, description: originalText || (typeMap[content['@type']] === 'image' ? '[图片]' : typeMap[content['@type']] === 'audio' ? '[语音]' : typeMap[content['@type']] === 'file' ? '[文件]' : '[新消息]'), link: `/inbox?c=${encodeURIComponent(conversationId)}`, read: false, createdAt: new Date().toISOString(), }) } if (outgoingMeta && req.body.event === 'message.sent') { await deleteResource(tenantId, 'outgoing-message-meta', outgoingMeta.id) } enqueueAutomaticTranslation(tenantId, conversationId, message) if (!outgoing && req.body.event === 'message.received') { enqueueAutomaticProfile(tenantId, conversationId) } } ok(res, { accepted: true }) }) app.get('/health/live', (_req, res) => res.json({ status: 'ok', service: 'lingxi-crossborder-api' })) app.get('/health/ready', async (_req, res) => { await pool.query('SELECT 1'); res.json({ status: 'ready', database: 'ok' }) }) app.use((error, req, res, _next) => { const status = Number(error.status || 500) if (status >= 500) console.error(JSON.stringify({ requestId: res.getHeader('x-request-id'), message: error.message, stack: error.stack })) res.status(status).json({ code: status, message: error.message || '服务器内部错误', data: null, requestId: res.getHeader('x-request-id') }) }) async function repairCorruptedTranslations() { const { rows } = await pool.query( `SELECT tenant_id, kind, data FROM resources WHERE kind LIKE 'messages:%' AND data ? 'translatedText'`, ) let repaired = 0 for (const row of rows) { const message = row.data if (!isSuspiciousTranslation(message.originalText, message.translatedText)) continue const conversationId = String(message.conversationId || row.kind.slice('messages:'.length)) const { translatedText: _translatedText, translatedAt: _translatedAt, translationRetryAt: _translationRetryAt, ...clean } = message const numericOnly = isNonLinguisticText(clean.originalText) const fixed = await putResource(String(row.tenant_id), row.kind, { ...clean, translatedText: numericOnly ? String(clean.originalText || '').trim() : undefined, originalLang: numericOnly ? 'und' : clean.originalLang, translationStatus: numericOnly ? 'completed' : 'pending', translatedAt: numericOnly ? new Date().toISOString() : undefined, }) const conversation = await getResource(String(row.tenant_id), 'conversations', conversationId) if (String(conversation?.lastMessage?.id || '') === String(fixed.id)) { await patchResource(String(row.tenant_id), 'conversations', conversationId, { lastMessage: fixed }) } if (!numericOnly) enqueueAutomaticTranslation(String(row.tenant_id), conversationId, fixed) repaired++ } if (repaired) console.log(`已修复 ${repaired} 条受污染的历史译文`) } await migrate() await repairCorruptedTranslations() const existingTelegramChannels = await pool.query( `SELECT tenant_id, data FROM resources WHERE kind='channels' AND data->>'type'='telegram' AND data ? 'gatewayAccountId'`, ) for (const row of existingTelegramChannels.rows) { await ensureChannelWebhook(String(row.tenant_id), row.data).catch(async (error) => { await patchResource(String(row.tenant_id), 'channels', row.data.id, { errorMessage: `Webhook 同步失败:${error.message}`, }) }) } httpServer.listen(Number(process.env.PORT || 3100), '0.0.0.0', () => console.log(`灵犀跨境 API listening on ${process.env.PORT || 3100}`))