const https = require('https'); const http = require('http'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const DATA_DIR = path.join(__dirname, 'data'); const MESSAGE_FILE = path.join(DATA_DIR, 'contact-messages.json'); const PORT = Number(process.env.PORT || 8080); const CORS_ORIGIN = process.env.CORS_ORIGIN || 'https://potaser.github.io'; const RESEND_KEY = process.env.RESEND_KEY || ''; const MAIL_TO = process.env.MAIL_TO || ''; const SUMMARY_INTERVAL = Number(process.env.SUMMARY_INTERVAL || 15) * 60 * 1000; function ensureStore() { fs.mkdirSync(DATA_DIR, { recursive: true }); if (!fs.existsSync(MESSAGE_FILE)) fs.writeFileSync(MESSAGE_FILE, '[]\n', 'utf8'); } function readMessages() { ensureStore(); try { const d = JSON.parse(fs.readFileSync(MESSAGE_FILE, 'utf8')); return Array.isArray(d) ? d : []; } catch (_) { return []; } } function writeMessages(messages) { ensureStore(); fs.writeFileSync(MESSAGE_FILE, JSON.stringify(messages, null, 2) + '\n', 'utf8'); } function sendJson(res, status, payload) { const body = JSON.stringify(payload); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Access-Control-Allow-Origin': CORS_ORIGIN, 'Access-Control-Allow-Headers': 'Content-Type', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Content-Length': Buffer.byteLength(body) }); res.end(body); } function collectBody(req, limit = 1024 * 1024) { return new Promise((resolve, reject) => { let body = ''; req.on('data', c => { body += c; if (Buffer.byteLength(body) > limit) { reject(new Error('请求内容过大')); req.destroy(); } }); req.on('end', () => resolve(body)); req.on('error', reject); }); } function normalizeText(v, max = 2000) { return String(v || '').trim().slice(0, max); } function validEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); } function validPhone(countryCode, phone) { const raw = String(phone || '').trim(); if (!raw) return true; const n = raw.replace(/[\s()-]/g, ''); const rules = { '+86': /^1[3-9]\d{9}$/, '+1': /^[2-9]\d{2}[2-9]\d{6}$/, '+44': /^7\d{9}$/, '+81': /^0?\d{9,10}$/, '+65': /^[689]\d{7}$/, other: /^\d{6,15}$/ }; return (rules[countryCode] || rules.other).test(n); } function validCountryCode(c) { return /^\+[1-9]\d{0,3}$/.test(c); } function validateContact(input) { const name = normalizeText(input.name, 100); const email = normalizeText(input.email, 200); const rawCC = normalizeText(input.countryCode || '+86', 20); const ccc = normalizeText(input.customCountryCode || '', 20); const countryCode = rawCC === 'other' ? ccc : rawCC; const phone = normalizeText(input.phone, 80); const itv = normalizeText(input.inquiryType, 100); const cit = normalizeText(input.customInquiryType || input.otherInquiryType, 100); const message = normalizeText(input.message, 4000); const inquiryType = itv === 'other' ? cit : itv; const errors = []; if (!name) errors.push('姓名必须填写'); if (!email && !phone) errors.push('邮箱和手机至少填写一项'); if (email && !validEmail(email)) errors.push('邮箱格式不正确'); if (phone && rawCC === 'other' && !validCountryCode(countryCode)) errors.push('国家/地区代码格式不正确'); if (phone && !validPhone(rawCC, phone)) errors.push('手机格式不正确'); if (!inquiryType) errors.push('咨询类型必须填写'); if (!message) errors.push('消息内容必须填写'); return { errors, data: { name, email, countryCode, phone, inquiryType, message } }; } async function handleContact(req, res) { try { const body = await collectBody(req); const input = JSON.parse(body || '{}'); const result = validateContact(input); if (result.errors.length) return sendJson(res, 400, { ok: false, errors: result.errors }); const messages = readMessages(); const now = new Date().toISOString(); const item = { id: crypto.randomUUID(), createdAt: now, notified: false, notifiedAt: null, ...result.data }; messages.push(item); writeMessages(messages); if (MAIL_TO && RESEND_KEY) scheduleSummary(); sendJson(res, 201, { ok: true, id: item.id }); } catch (error) { sendJson(res, 500, { ok: false, errors: [error.message || '提交失败'] }); } } function sendResendMail(to, subject, html) { return new Promise((resolve, reject) => { const data = JSON.stringify({ from: 'onboarding@resend.dev', to, subject, html }); const opts = { hostname: 'api.resend.com', path: '/emails', method: 'POST', headers: { 'Authorization': `Bearer ${RESEND_KEY}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } }; const req = https.request(opts, res => { let b = ''; res.on('data', c => b += c); res.on('end', () => { try { const j = JSON.parse(b); resolve(j); } catch (_) { resolve({ raw: b }); } }); }); req.on('error', reject); req.setTimeout(30000, () => { req.destroy(); reject(new Error('Resend API 超时')); }); req.write(data); req.end(); }); } let summaryTimer = null; function scheduleSummary() { if (summaryTimer) return; summaryTimer = setTimeout(() => { summaryTimer = null; if (!MAIL_TO || !RESEND_KEY) { return; } const messages = readMessages(); const pending = messages.filter(m => !m.notified); if (!pending.length) { return; } const lines = pending.map((m, i) => `
共有 ${pending.length} 条新消息:
| # | 姓名 | 邮箱 | 电话 | 咨询类型 | 消息内容 | 提交时间 |
|---|