diff --git a/cms/server/.env.example b/cms/server/.env.example index e743339..1708f43 100644 --- a/cms/server/.env.example +++ b/cms/server/.env.example @@ -13,3 +13,5 @@ CONTENT_INGEST_RATE_LIMIT= PUBLIC_SITE_URL= CORS_ORIGIN= UPLOAD_DIR= +WECOM_BOT_WEBHOOK= +WECOM_BOT_TIMEOUT_MS=5000 diff --git a/cms/server/src/index.js b/cms/server/src/index.js index ffafe0f..0de1360 100644 --- a/cms/server/src/index.js +++ b/cms/server/src/index.js @@ -11,6 +11,7 @@ import PDFDocument from 'pdfkit'; import { initDB, getDB, UPLOAD_DIR } from './db.js'; import { signToken, verifyToken, authMiddleware } from './middleware/auth.js'; import { createContentIngestRouter } from './content-ingest.js'; +import { sendContactLeadToWeCom } from './wecom-notifier.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); @@ -112,7 +113,7 @@ const requestBuckets = new Map(); function rateLimit({ windowMs = 60_000, max = 30 } = {}) { return (req, res, next) => { const forwarded = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim(); - const key = `${req.ip || forwarded || req.socket.remoteAddress || 'unknown'}:${req.path}`; + const key = `${forwarded || req.ip || req.socket.remoteAddress || 'unknown'}:${req.path}`; const now = Date.now(); const old = requestBuckets.get(key); if (!old || now - old.startedAt >= windowMs) requestBuckets.set(key, { startedAt: now, count: 1 }); @@ -124,18 +125,31 @@ function rateLimit({ windowMs = 60_000, max = 30 } = {}) { }; } const publicRateLimit = rateLimit({ windowMs: 60_000, max: 60 }); +const contactLeadRateLimit = rateLimit({ windowMs: 60_000, max: 10 }); app.use('/api/diagnosis', publicRateLimit); app.use('/api/internal/content/v1', createContentIngestRouter()); +app.use('/api/contact/leads', contactLeadRateLimit); app.post('/api/contact/leads', (req, res) => { const { name, company = '', phone, need = '' } = req.body || {}; if (!name || !phone) return res.status(400).json({ code: 400, msg: '姓名和手机号必填' }); if (!/^1[3-9]\d{9}$/.test(String(phone))) return res.status(400).json({ code: 400, msg: '手机号格式错误' }); const id = genId('contact'); + const createdAt = nowISO(); + const lead = { + name: String(name).trim().slice(0, 80), + company: String(company).trim().slice(0, 160), + phone: String(phone), + need: String(need).trim().slice(0, 2000), + createdAt + }; db.run(`INSERT INTO contact_leads (id, name, company, phone, need, created_at) VALUES (?,?,?,?,?,?)`, - [id, String(name).trim().slice(0, 80), String(company).trim().slice(0, 160), String(phone), String(need).trim().slice(0, 2000), nowISO()], err => { + [id, lead.name, lead.company, lead.phone, lead.need, createdAt], err => { if (err) return res.status(500).json({ code: 500, msg: '提交失败,请稍后再试' }); res.json({ code: 0, msg: '提交成功' }); + sendContactLeadToWeCom(lead).catch(error => { + console.error('[企业微信通知失败]', error.message); + }); }); }); diff --git a/cms/server/src/wecom-notifier.js b/cms/server/src/wecom-notifier.js new file mode 100644 index 0000000..aa7e4e6 --- /dev/null +++ b/cms/server/src/wecom-notifier.js @@ -0,0 +1,59 @@ +const DEFAULT_TIMEOUT_MS = 5000; + +function cleanLine(value, maxLength) { + return String(value ?? '') + .replace(/[\r\n\t]+/g, ' ') + .replace(/\s{2,}/g, ' ') + .trim() + .slice(0, maxLength); +} + +export function formatContactLeadMessage(lead, { siteUrl = 'https://www.ameiba.cn' } = {}) { + const submittedAt = new Date(lead.createdAt || Date.now()).toLocaleString('zh-CN', { + timeZone: 'Asia/Shanghai', + hour12: false + }); + + return [ + '【官网新咨询】', + '', + `姓名:${cleanLine(lead.name, 80) || '未填写'}`, + `企业:${cleanLine(lead.company, 160) || '未填写'}`, + `联系电话:${cleanLine(lead.phone, 30) || '未填写'}`, + `需求描述:${cleanLine(lead.need, 2000) || '未填写'}`, + `提交时间:${submittedAt}`, + `来源:${cleanLine(siteUrl, 200)}` + ].join('\n'); +} + +export async function sendContactLeadToWeCom(lead, { + webhookUrl = process.env.WECOM_BOT_WEBHOOK, + siteUrl = process.env.PUBLIC_SITE_URL || 'https://www.ameiba.cn', + timeoutMs = Number(process.env.WECOM_BOT_TIMEOUT_MS || DEFAULT_TIMEOUT_MS), + fetchImpl = globalThis.fetch +} = {}) { + if (!webhookUrl) return { skipped: true }; + if (typeof fetchImpl !== 'function') throw new Error('当前 Node.js 环境不支持 fetch'); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetchImpl(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + msgtype: 'text', + text: { content: formatContactLeadMessage(lead, { siteUrl }) } + }), + signal: controller.signal + }); + const result = await response.json().catch(() => ({})); + if (!response.ok || result.errcode !== 0) { + throw new Error(`企业微信返回错误:${result.errmsg || response.status}`); + } + return { sent: true }; + } finally { + clearTimeout(timer); + } +} diff --git a/cms/server/test/wecom-notifier.test.js b/cms/server/test/wecom-notifier.test.js new file mode 100644 index 0000000..c8c05eb --- /dev/null +++ b/cms/server/test/wecom-notifier.test.js @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { formatContactLeadMessage, sendContactLeadToWeCom } from '../src/wecom-notifier.js'; + +test('咨询消息包含完整字段并清理换行', () => { + const message = formatContactLeadMessage({ + name: '张三\n测试', + company: '', + phone: '13800138000', + need: '希望\r\n了解企业进化方案', + createdAt: '2026-09-30T07:30:00.000Z' + }); + + assert.match(message, /【官网新咨询】/); + assert.match(message, /姓名:张三 测试/); + assert.match(message, /企业:未填写/); + assert.match(message, /联系电话:13800138000/); + assert.match(message, /需求描述:希望 了解企业进化方案/); + assert.match(message, /提交时间:2026\/9\/30 15:30:00/); +}); + +test('未配置 Webhook 时安全跳过', async () => { + const result = await sendContactLeadToWeCom({}, { webhookUrl: '' }); + assert.deepEqual(result, { skipped: true }); +}); + +test('按企业微信文本消息格式发送', async () => { + let request; + const result = await sendContactLeadToWeCom( + { name: '李四', phone: '13900139000' }, + { + webhookUrl: 'https://example.test/webhook', + fetchImpl: async (url, options) => { + request = { url, options }; + return { ok: true, json: async () => ({ errcode: 0, errmsg: 'ok' }) }; + } + } + ); + + assert.deepEqual(result, { sent: true }); + assert.equal(request.url, 'https://example.test/webhook'); + const body = JSON.parse(request.options.body); + assert.equal(body.msgtype, 'text'); + assert.match(body.text.content, /姓名:李四/); +}); + +test('企业微信返回错误时抛出异常', async () => { + await assert.rejects( + sendContactLeadToWeCom( + { name: '王五', phone: '13700137000' }, + { + webhookUrl: 'https://example.test/webhook', + fetchImpl: async () => ({ ok: true, json: async () => ({ errcode: 93000, errmsg: 'invalid webhook' }) }) + } + ), + /invalid webhook/ + ); +});