feat(history): establish Telegram SaaS platform foundation

This commit is contained in:
wmq
2026-07-27 23:03:00 +08:00
parent cf0eed46c3
commit cd06d049fe
92 changed files with 5608 additions and 3749 deletions
+8
View File
@@ -0,0 +1,8 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
ENV NODE_ENV=production
EXPOSE 3100
CMD ["npm", "start"]
+1477
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "lingxi-crossborder-api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.js",
"test": "node --test test/business.test.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"express": "^5.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"mammoth": "^1.9.0",
"nodemailer": "^7.0.5",
"pdf-parse": "^1.1.1",
"pg": "^8.13.1"
,"ws": "^8.18.3"
}
}
+48
View File
@@ -0,0 +1,48 @@
function contentText(payload) {
return payload?.content?.map((part) => part.text || '').join('') || ''
}
function parseJson(text) {
const cleaned = text.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim()
return JSON.parse(cleaned)
}
export async function deepseek(system, user, json = false) {
const apiKey = process.env.DEEPSEEK_API_KEY
if (!apiKey) throw Object.assign(new Error('DEEPSEEK_API_KEY 尚未配置'), { status: 503 })
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), Number(process.env.AI_TIMEOUT_MS || 45000))
try {
const response = await fetch(`${(process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/anthropic').replace(/\/$/, '')}/v1/messages`, {
method: 'POST',
signal: controller.signal,
headers: {
'content-type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash',
max_tokens: Number(process.env.AI_MAX_TOKENS || 4096),
temperature: json ? 0.1 : 0.3,
system,
messages: [{ role: 'user', content: [{ type: 'text', text: user }] }],
}),
})
const body = await response.json()
if (!response.ok) throw Object.assign(new Error(body?.error?.message || `AI 请求失败 (${response.status})`), { status: 502 })
const text = contentText(body)
return json ? parseJson(text) : text
} finally {
clearTimeout(timeout)
}
}
export function conversationContext(messages) {
return messages.slice(-80).map((m) => {
const role = m.sender === 'agent' ? '坐席' : m.sender === 'system' ? '系统' : '客户'
const content = m.originalText || m.attachmentName || `[${m.type || '消息'}]`
const translation = m.translatedText && m.translatedText !== content ? `(译文:${m.translatedText})` : ''
return `${m.createdAt || ''} ${role}: ${content}${translation}`
}).join('\n')
}
+76
View File
@@ -0,0 +1,76 @@
export function telegramAttachment(content = {}) {
const type = content['@type']
if (type === 'messagePhoto') {
const sizes = Array.isArray(content.photo?.sizes) ? content.photo.sizes : []
const file = sizes.at(-1)?.photo
return file?.id ? {
telegramFileId: Number(file.id),
attachmentName: `telegram-photo-${file.id}.jpg`,
attachmentMimeType: 'image/jpeg',
attachmentSize: Number(file.size || file.expected_size || 0),
} : {}
}
if (type === 'messageDocument') {
const document = content.document || {}
const file = document.document
return file?.id ? {
telegramFileId: Number(file.id),
attachmentName: document.file_name || `document-${file.id}`,
attachmentMimeType: document.mime_type || 'application/octet-stream',
attachmentSize: Number(file.size || file.expected_size || 0),
} : {}
}
if (type === 'messageVoiceNote') {
const file = content.voice_note?.voice
return file?.id ? {
telegramFileId: Number(file.id),
attachmentName: `voice-${file.id}.ogg`,
attachmentMimeType: content.voice_note?.mime_type || 'audio/ogg',
attachmentSize: Number(file.size || file.expected_size || 0),
duration: Number(content.voice_note?.duration || 0),
} : {}
}
if (type === 'messageAudio') {
const audio = content.audio || {}
const file = audio.audio
return file?.id ? {
telegramFileId: Number(file.id),
attachmentName: audio.file_name || audio.title || `audio-${file.id}`,
attachmentMimeType: audio.mime_type || 'audio/mpeg',
attachmentSize: Number(file.size || file.expected_size || 0),
duration: Number(audio.duration || 0),
} : {}
}
if (type === 'messageSticker') {
const sticker = content.sticker || {}
const file = sticker.sticker
const format = sticker.format?.['@type']
const extension = format === 'stickerFormatTgs' ? 'tgs' : format === 'stickerFormatWebm' ? 'webm' : 'webp'
return file?.id ? {
telegramFileId: Number(file.id),
attachmentName: `sticker-${file.id}.${extension}`,
attachmentMimeType: extension === 'webp' ? 'image/webp' : extension === 'webm' ? 'video/webm' : 'application/x-tgsticker',
attachmentSize: Number(file.size || file.expected_size || 0),
stickerEmoji: sticker.emoji || '',
} : {}
}
return {}
}
export const dateKey = (value = Date.now()) => {
const date = new Date(value)
return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10)
}
export const percent = (numerator, denominator) =>
denominator ? Math.round((numerator / denominator) * 1000) / 10 : 0
export const countBy = (list, key) => {
const counts = new Map()
for (const item of list) {
const value = typeof key === 'function' ? key(item) : item[key]
if (!value) continue
counts.set(String(value), (counts.get(String(value)) || 0) + 1)
}
return counts
}
+97
View File
@@ -0,0 +1,97 @@
import pg from 'pg'
import crypto from 'node:crypto'
const { Pool } = pg
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
export async function migrate() {
await pool.query(`
CREATE TABLE IF NOT EXISTS tenants (
id uuid PRIMARY KEY, name text NOT NULL, industry text NOT NULL DEFAULT '',
size text NOT NULL DEFAULT '', country text NOT NULL DEFAULT 'CN',
plan text NOT NULL DEFAULT 'pro', created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS users (
id uuid PRIMARY KEY, tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name text NOT NULL, account text NOT NULL UNIQUE, password_hash text NOT NULL,
phone text NOT NULL DEFAULT '', role text NOT NULL DEFAULT 'admin',
department text NOT NULL DEFAULT '', title text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'active', last_login_at timestamptz, created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS resources (
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
kind text NOT NULL, id text NOT NULL, data jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, kind, id)
);
CREATE INDEX IF NOT EXISTS resources_kind_idx ON resources(tenant_id, kind, updated_at DESC);
CREATE TABLE IF NOT EXISTS telegram_bindings (
id uuid PRIMARY KEY, tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
gateway_account_id text, phone text, display_name text,
status text NOT NULL DEFAULT 'unconnected', auth_state text,
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS audit_logs (
id uuid PRIMARY KEY, tenant_id uuid, user_id uuid, action text NOT NULL,
target text NOT NULL DEFAULT '', detail jsonb NOT NULL DEFAULT '{}',
ip text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS login_records (
id uuid PRIMARY KEY, tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
user_id uuid REFERENCES users(id) ON DELETE SET NULL, account text NOT NULL DEFAULT '',
device text NOT NULL DEFAULT '', ip text NOT NULL DEFAULT '',
status text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS login_records_tenant_idx ON login_records(tenant_id, created_at DESC);
ALTER TABLE users ADD COLUMN IF NOT EXISTS token_version integer NOT NULL DEFAULT 0;
`)
}
export const uid = () => crypto.randomUUID()
export async function listResources(tenantId, kind) {
const { rows } = await pool.query(
'SELECT data FROM resources WHERE tenant_id=$1 AND kind=$2 ORDER BY updated_at DESC',
[tenantId, kind],
)
return rows.map((row) => row.data)
}
export async function getResource(tenantId, kind, id) {
const { rows } = await pool.query(
'SELECT data FROM resources WHERE tenant_id=$1 AND kind=$2 AND id=$3',
[tenantId, kind, id],
)
return rows[0]?.data ?? null
}
export async function putResource(tenantId, kind, value) {
const data = { ...value, id: value.id || uid(), updatedAt: new Date().toISOString() }
await pool.query(
`INSERT INTO resources(tenant_id,kind,id,data) VALUES($1,$2,$3,$4)
ON CONFLICT(tenant_id,kind,id) DO UPDATE SET data=$4,updated_at=now()`,
[tenantId, kind, data.id, data],
)
return data
}
export async function patchResource(tenantId, kind, id, patch) {
const current = await getResource(tenantId, kind, id)
if (!current) return null
return putResource(tenantId, kind, { ...current, ...patch, id })
}
export async function deleteResource(tenantId, kind, id) {
const result = await pool.query(
'DELETE FROM resources WHERE tenant_id=$1 AND kind=$2 AND id=$3',
[tenantId, kind, id],
)
return result.rowCount > 0
}
export async function audit(req, action, target, detail = {}) {
await pool.query(
'INSERT INTO audit_logs(id,tenant_id,user_id,action,target,detail,ip) VALUES($1,$2,$3,$4,$5,$6,$7)',
[uid(), req.auth?.tenantId ?? null, req.auth?.userId ?? null, action, target, detail, req.ip],
)
}
+79
View File
@@ -0,0 +1,79 @@
const base = () => (process.env.TELEGRAM_GATEWAY_URL || 'http://host.docker.internal:3000/api').replace(/\/$/, '')
let gatewayToken = process.env.TELEGRAM_GATEWAY_TOKEN || ''
let refreshPromise
function tokenExpiresSoon(token) {
try {
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'))
return !payload.exp || payload.exp * 1000 < Date.now() + 5 * 60 * 1000
} catch {
return true
}
}
async function refreshGatewayToken() {
if (refreshPromise) return refreshPromise
refreshPromise = (async () => {
const email = process.env.TELEGRAM_GATEWAY_SERVICE_EMAIL
const password = process.env.TELEGRAM_GATEWAY_SERVICE_PASSWORD
const tenantId = process.env.TELEGRAM_GATEWAY_TENANT_ID
if (!email || !password || !tenantId) {
throw Object.assign(new Error('Telegram 网关服务账号凭据尚未配置'), { status: 503 })
}
const response = await fetch(`${base()}/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password, tenantId }),
})
const raw = await response.text()
let result
try { result = raw ? JSON.parse(raw) : null } catch { result = raw }
const token = result?.data?.token || result?.token
if (!response.ok || !token) {
throw Object.assign(new Error(result?.message || 'Telegram 网关服务账号续签失败'), { status: response.status || 503 })
}
gatewayToken = token
return token
})().finally(() => { refreshPromise = undefined })
return refreshPromise
}
export async function gatewayRequest(path, options = {}) {
const tenantId = process.env.TELEGRAM_GATEWAY_TENANT_ID
if (!tenantId) throw Object.assign(new Error('Telegram 网关租户尚未配置'), { status: 503 })
if (!gatewayToken || tokenExpiresSoon(gatewayToken)) await refreshGatewayToken()
const send = () => fetch(`${base()}${path}`, {
...options,
headers: {
...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }),
authorization: `Bearer ${gatewayToken}`,
'x-tenant-id': tenantId,
...(options.headers || {}),
},
})
let response = await send()
if (response.status === 401) {
await refreshGatewayToken()
response = await send()
}
const raw = await response.text()
let result
try { result = raw ? JSON.parse(raw) : null } catch { result = raw }
if (!response.ok) {
throw Object.assign(new Error(result?.message || `Telegram 网关请求失败 (${response.status})`), { status: response.status })
}
return result?.data ?? result
}
export function postGateway(path, body) {
return gatewayRequest(path, { method: 'POST', body: JSON.stringify(body) })
}
export function putGateway(path, body) {
return gatewayRequest(path, { method: 'PUT', body: JSON.stringify(body) })
}
export function uploadGateway(path, formData) {
return gatewayRequest(path, { method: 'POST', body: formData })
}
+1754
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { countBy, dateKey, percent, telegramAttachment } from '../src/business.js'
test('Telegram 图片、文档、语音和贴纸映射为可下载文件', () => {
assert.deepEqual(
telegramAttachment({
'@type': 'messagePhoto',
photo: { sizes: [{ photo: { id: 11, size: 2048 } }] },
}),
{
telegramFileId: 11,
attachmentName: 'telegram-photo-11.jpg',
attachmentMimeType: 'image/jpeg',
attachmentSize: 2048,
},
)
assert.equal(telegramAttachment({
'@type': 'messageDocument',
document: { file_name: 'quote.pdf', mime_type: 'application/pdf', document: { id: 12, size: 4096 } },
}).attachmentMimeType, 'application/pdf')
assert.equal(telegramAttachment({
'@type': 'messageVoiceNote',
voice_note: { duration: 8, voice: { id: 13, expected_size: 1024 } },
}).duration, 8)
assert.equal(telegramAttachment({
'@type': 'messageSticker',
sticker: { emoji: '👍', format: { '@type': 'stickerFormatWebp' }, sticker: { id: 14, size: 512 } },
}).attachmentMimeType, 'image/webp')
})
test('业务指标工具处理百分比、分组和无效日期', () => {
assert.equal(percent(3, 8), 37.5)
assert.equal(percent(1, 0), 0)
assert.equal(dateKey('invalid'), '')
assert.equal(dateKey('2026-07-27T12:30:00Z'), '2026-07-27')
assert.deepEqual(
Object.fromEntries(countBy([{ stage: 'new' }, { stage: 'new' }, { stage: 'won' }], 'stage')),
{ new: 2, won: 1 },
)
})