新增官网咨询企业微信通知
This commit is contained in:
@@ -13,3 +13,5 @@ CONTENT_INGEST_RATE_LIMIT=
|
|||||||
PUBLIC_SITE_URL=
|
PUBLIC_SITE_URL=
|
||||||
CORS_ORIGIN=
|
CORS_ORIGIN=
|
||||||
UPLOAD_DIR=
|
UPLOAD_DIR=
|
||||||
|
WECOM_BOT_WEBHOOK=
|
||||||
|
WECOM_BOT_TIMEOUT_MS=5000
|
||||||
|
|||||||
+16
-2
@@ -11,6 +11,7 @@ import PDFDocument from 'pdfkit';
|
|||||||
import { initDB, getDB, UPLOAD_DIR } from './db.js';
|
import { initDB, getDB, UPLOAD_DIR } from './db.js';
|
||||||
import { signToken, verifyToken, authMiddleware } from './middleware/auth.js';
|
import { signToken, verifyToken, authMiddleware } from './middleware/auth.js';
|
||||||
import { createContentIngestRouter } from './content-ingest.js';
|
import { createContentIngestRouter } from './content-ingest.js';
|
||||||
|
import { sendContactLeadToWeCom } from './wecom-notifier.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -112,7 +113,7 @@ const requestBuckets = new Map();
|
|||||||
function rateLimit({ windowMs = 60_000, max = 30 } = {}) {
|
function rateLimit({ windowMs = 60_000, max = 30 } = {}) {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
const forwarded = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
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 now = Date.now();
|
||||||
const old = requestBuckets.get(key);
|
const old = requestBuckets.get(key);
|
||||||
if (!old || now - old.startedAt >= windowMs) requestBuckets.set(key, { startedAt: now, count: 1 });
|
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 publicRateLimit = rateLimit({ windowMs: 60_000, max: 60 });
|
||||||
|
const contactLeadRateLimit = rateLimit({ windowMs: 60_000, max: 10 });
|
||||||
app.use('/api/diagnosis', publicRateLimit);
|
app.use('/api/diagnosis', publicRateLimit);
|
||||||
app.use('/api/internal/content/v1', createContentIngestRouter());
|
app.use('/api/internal/content/v1', createContentIngestRouter());
|
||||||
|
app.use('/api/contact/leads', contactLeadRateLimit);
|
||||||
|
|
||||||
app.post('/api/contact/leads', (req, res) => {
|
app.post('/api/contact/leads', (req, res) => {
|
||||||
const { name, company = '', phone, need = '' } = req.body || {};
|
const { name, company = '', phone, need = '' } = req.body || {};
|
||||||
if (!name || !phone) return res.status(400).json({ code: 400, msg: '姓名和手机号必填' });
|
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: '手机号格式错误' });
|
if (!/^1[3-9]\d{9}$/.test(String(phone))) return res.status(400).json({ code: 400, msg: '手机号格式错误' });
|
||||||
const id = genId('contact');
|
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 (?,?,?,?,?,?)`,
|
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: '提交失败,请稍后再试' });
|
if (err) return res.status(500).json({ code: 500, msg: '提交失败,请稍后再试' });
|
||||||
res.json({ code: 0, msg: '提交成功' });
|
res.json({ code: 0, msg: '提交成功' });
|
||||||
|
sendContactLeadToWeCom(lead).catch(error => {
|
||||||
|
console.error('[企业微信通知失败]', error.message);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user