从官网“联系我们”这个表单正式上线那天起,我手机上的新邮件提醒就没安静过。刚开始以为是有客户咨询,打开一看,全是“长期办理业务”“低价出售资料”这类垃圾提交,一天能收到七八条,最离谱的时候表单后台里挤了四十多条假留言,把真正想咨询的客户信息都淹没了。后来我意识到一个扎心的事实:官网表单如果不做任何防护,本质上就是给爬虫和群发工具留了一扇全敞开的后门。
这篇文章想解决的就是这件事。我会用 Express + Prisma + SQLite + Nodemailer 这一整套方案,从零搭一个能真正接住官网表单留言的 MVP 后台,既要有提交接口、数据存储、邮件通知这些基础能力,也必须把防垃圾的机制内置进去。适合准备给企业官网做改造、又不想一上来就上大后端框架的开发者参考。我把关键代码、设计思路和踩坑记录都放在后面,你可以直接照着搭,也可以把它当作自建表单服务的第一版骨架。
1. 官网表单为什么成了垃圾邮件的重灾区
1.1 垃圾流量到底从哪来
官网表单被刷,并不是什么高深攻击,更多是“无差别扫描”的结果。网上有很多批量工具会定时抓取网站页面,把页面里的<form>、input、textarea全部解析出来,然后自动填充邮箱、留言内容、手机号,再朝这些接口发请求。对工具作者来说,全世界有成千上万个网站表单,他根本不关心某个表单背后是谁,只要有一个表单没防护,他就算“捡到”了一个能投递垃圾内容的入口。
这种垃圾提交有几个很明显的特征:提交间隔极短、IP 固定、留言内容里大量出现推广词、邮箱地址格式看起来正常但域名来自临时邮箱服务。只要请求量稍微大一点,表单接口就会被这些内容灌满。这里要特别注意:一旦垃圾留言被转发到运营邮箱,企业邮箱频繁收到大量退信或举报邮件,还可能触发邮件服务商的发信限制,这个连带反应比丢几条留言麻烦得多。
1.2 MVP 到底要守住哪几条线
我把这个项目的目标拆成三条线。第一条是“接住有效留言”:表单提交以后,数据要进数据库,运营人员能收到一封包含完整留言内容的邮件。第二条是“挡住明显的机器流量”:不需要做得像风控系统那么复杂,但至少要挡住无脑爬虫和批量提交工具。第三条是“不给运维添负担”:整个后台要轻量,部署简单,目录结构清楚,后续接手的人能在半小时内看懂。
想要同时满足这三条,方案选型就不能太激进。有些团队一上来就用微服务、消息队列、容器编排,结果官网一天就那么几十条留言,维护成本比业务价值还高。MVP 的意义不是功能少,而是把每一层都做成“够用并且能扩展”的状态。表单场景非常适合先用单体服务跑起来,等留言量确实涨上去了,再逐步拆队列、加后台管理也不迟。
2. 技术选型:这套组合为什么够用
2.1 Express:没有魔法,但人人都会
Express 在国内外面试八股文里被写了太多次,以至于很多人忽略了它才是小项目的可靠底座。它足够简单,中间件机制成熟,路由写法清晰,社区里几乎所有问题都能搜到案例。对于表单提交这个场景,真正需要的不过是express.json()解析请求体、一个 POST 路由、几个小的校验函数,Express 做这些事非常顺手,不需要引入额外的框架约束。
有人会问,既然 NestJS 更“正规”,为什么不用?这个问题我实际对比过。NestJS 的依赖注入、模块系统、装饰器这套东西,对多人协作的大型项目很有价值,但在一个只有两三个主要文件的表单后台里,这些概念反而成了理解成本。再加上 NestJS 本身封装了很多层,出了问题,新手得先搞明白它的执行链路才能定位。我自己的习惯是:项目规模在五个路由以内,优先 Express;超过这个体量,再认真评估是否值得上更重的框架。
2.2 Prisma + SQLite:把“存数据”变成一件省心事
Prisma 最打动我的地方不是它生成的查询代码有多快,而是它的数据模型定义方式。你只需要在一个schema.prisma文件里描述表结构,然后运行迁移命令,它就会帮你把数据库表建好,并且在代码里生成类型安全的查询客户端。这意味着新开发者看一遍 schema,就能掌握整个数据层的全貌,比翻一堆 SQL 脚本直观得多。
SQLite 则是 MVP 阶段的“单文件数据库”最优解。不需要单独装数据库服务,不需要配置端口和账号密码,数据库就是一个本地文件,备份直接把文件拷走就行。对于官网表单这种日写入量几十条到几百条的小业务,SQLite 的性能完全够用。当然它也有短板:并发写能力弱,不适合多实例部署。所以我会在项目里留好切换的余地,Prisma 的数据源只要把provider从sqlite改成postgresql,再调整连接字符串,大部分代码不需要动。
2.3 Nodemailer:通知能力的关键一环
表单入库之后,怎么让运营人员第一时间知道有新留言,这里需要一个邮件发送模块。Nodemailer 是 Node 生态里最成熟的邮件发送库,支持 SMTP 协议,几乎所有邮件服务商都能接。它不需要你额外搭建邮件服务器,只需要准备好发件邮箱的 SMTP 地址、账号、密码或授权码,就能在代码里完成发信。
在 MVP 阶段,我的建议是直接使用现有企业邮箱或者运营邮箱作为发件账号,不要为了“专业感”去单独买邮件发送服务。原因是表单通知邮件的量很小,用现有的邮箱完全够用,配置也简单。真正的重点是把发件域名的 SPF、DKIM 记录配好,否则邮件很容易被扔进垃圾箱,具体内容我放到后面第五部分细讲。Nodemailer 这里承担的角色,本质上是“把系统事件翻译成人类能读到的通知”,它不需要负责投递成功率,那是邮箱服务商和 DNS 配置的范畴。
3. 手把手实现:从空目录到一个能接收留言的后台
3.1 项目初始化和目录设计
先在空目录里初始化项目并安装依赖。如果你本地已经安装了 Node.js 18 以上版本,可以直接执行:
mkdir contact-form-backend cd contact-form-backend npm init -y npm install express @prisma/client prisma nodemailer dotenv npm install -D nodemon这里有一个容易被忽略的决策点:我特意没有在这版引入 TypeScript。倒不是 TypeScript 不好,而是这个项目的核心逻辑很少,类型约束带来的收益不明显,反而会增加ts-node、类型声明这些配置成本。如果你所在团队已经全面 TS 化,那就在初始化时加上typescript相关依赖,代码结构本身不需要大改。
目录设计我推荐这样分:
├── .env ├── .env.example ├── package.json ├── prisma │ └── schema.prisma ├── src │ ├── server.js │ ├── mailer.js │ ├── spam-guard.js │ ├── validate.js │ └── routes │ └── contact.js └── public └── index.htmlprisma/schema.prisma单独放,因为 Prisma 命令默认会找这个路径;src/server.js只负责启动应用;路由、防垃圾逻辑、邮件发送各自拆文件。不要把所有代码堆在server.js里,否则后面加后台管理接口的时候会非常痛苦。
3.2 用 Prisma 定义数据模型并完成首次迁移
新建prisma/schema.prisma,内容如下:
generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model ContactMessage { id String @id @default(cuid()) name String email String phone String? company String? content String source String @default("website") ip String? status String @default("pending") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }然后创建.env文件:
DATABASE_URL="file:./dev.db" SMTP_HOST="smtp.example.com" SMTP_PORT=465 SMTP_USER="noreply@example.com" SMTP_PASS="your-auth-code" MAIL_FROM="noreply@example.com" NOTIFY_TO="ops@example.com"运行迁移命令:
npx prisma migrate dev --name init这里要说明一个细节:DATABASE_URL里的相对路径是相对于prisma目录的,所以最终生成的数据库文件会出现在prisma/dev.db,而不是项目根目录。这个路径很多人一开始容易找不到,我第一次用 Prisma 时也翻了一会儿才确认文件位置。如果你希望数据库文件放在根目录,可以写成file:../dev.db,但要注意备份脚本和.gitignore里的路径要同步调整。
3.3 防垃圾策略的三种具体落地
代码写到这里,重点来了。防垃圾逻辑我用三个互不冲突的机制叠加,每一层都有自己的职责:第一层是蜜罐字段,第二层是时间阈值,第三层是频率限制。它们都不依赖验证码服务,对用户几乎无感知。
先说蜜罐字段。在真实表单里放一个普通用户看不到的隐藏输入框,比如起名叫website,要求用户不要填写。真正的用户不会去动它,但爬虫会把页面上所有可见的输入框都自动填一遍,于是website就会带上内容。后端只要发现这个字段非空,就直接判定为机器人提交,返回一个假的成功响应,不要暴露任何“已被拦截”的痕迹:
// src/spam-guard.js function honeypotGuard(req, res, next) { const honeypotValue = req.body.website; if (honeypotValue) { return res.json({ ok: true, message: '提交成功' }); } next(); }再说时间阈值。前端在用户打开页面时记录一个时间戳,把它放到隐藏字段form_started_at里。用户填写一个正常的留言至少需要几秒,而批量脚本通常在页面加载后几百毫秒内就提交了。后端校验这个差值:
function timeGuard(req, res, next) { const startedAt = Number(req.body.form_started_at || 0); const elapsed = Date.now() - startedAt; if (startedAt && elapsed < 3000) { return res.status(400).json({ ok: false, message: '提交过快,请稍后再试' }); } next(); }这里要注意把阈值设在 3 秒左右。太短比如 1 秒,正常用户手速快一点也可能误伤;太长比如 10 秒,则会干扰那些提前填好再复制的用户。3 秒是我试下来平衡性比较好的值。
频率限制我用一个简单的内存 Map 实现,按 IP 维度统计最近 5 分钟的提交次数:
const submitRecords = new Map(); function rateLimitGuard(req, res, next) { const ip = req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress; const now = Date.now(); const recent = (submitRecords.get(ip) || []).filter((t) => now - t < 5 * 60 * 1000); if (recent.length >= 3) { return res.status(429).json({ ok: false, message: '提交过于频繁,请稍后再试' }); } recent.push(now); submitRecords.set(ip, recent); next(); }这个方案在单实例部署下完全够用。如果以后你做多实例部署,需要把计数器换到 Redis 里,但对 MVP 来说内存 Map 已经能挡掉绝大多数重复提交脚本。
3.4 实现提交接口和邮件通知
路由是核心入口。我把它单独放在src/routes/contact.js里:
// src/routes/contact.js const { Router } = require('express'); const { PrismaClient } = require('@prisma/client'); const { sendContactNotification } = require('../mailer'); const { honeypotGuard, timeGuard, rateLimitGuard } = require('../spam-guard'); const router = Router(); const prisma = new PrismaClient(); router.post( '/contact', honeypotGuard, timeGuard, rateLimitGuard, async (req, res) => { const { name, email, phone, company, content } = req.body; const ip = req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress; if (!name || !email || !content) { return res.status(400).json({ ok: false, message: '请填写姓名、邮箱和留言内容' }); } const saved = await prisma.contactMessage.create({ data: { name, email, phone, company, content, ip, source: 'website' } }); sendContactNotification(saved).catch((error) => { console.error('邮件发送失败,请检查SMTP配置', error); }); res.json({ ok: true, message: '提交成功' }); } ); module.exports = router;这里几个决策点要说清楚。第一,email虽然叫邮箱,但只是用来展示给运营人员的联系方式,不是让系统回信给用户的,所以不需要做严格的格式校验,只要它不是空且长度合理就行。第二,邮件发送我用.catch包了一层,不让发信失败拖垮主流程,因为表单确认是否成功不应该依赖于邮件通知是否送达到位。第三,保存数据和发送邮件不是事务关系,哪怕邮件发送失败,数据也已经落库了,运营人员在后台查得到,不会丢。
mailer.js里的核心逻辑如下:
// src/mailer.js const nodemailer = require('nodemailer'); const transporter = nodemailer.createTransport({ host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT || 465), secure: Number(process.env.SMTP_PORT || 465) === 465, auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, }, }); async function sendContactNotification(msg) { const text = [ `姓名:${msg.name}`, `邮箱:${msg.email}`, `电话:${msg.phone || '未填'}`, `公司:${msg.company || '未填'}`, `留言内容:`, msg.content, `提交时间:${msg.createdAt}`, ].join('\n'); return transporter.sendMail({ from: process.env.MAIL_FROM, to: process.env.NOTIFY_TO, subject: `[官网留言] ${msg.name} - ${new Date().toLocaleString('zh-CN')}`, text, }); } module.exports = { sendContactNotification };为什么不发 HTML 邮件?因为这里收件人是运营团队内部,纯文本信息密度更高、加载更快,也不容易触发邮件服务商的营销内容过滤。HTML 邮件留到以后做用户回访邮件时再考虑。
最后在src/server.js里把它们组装起来:
require('dotenv').config(); const express = require('express'); const path = require('path'); const contactRouter = require('./routes/contact'); const app = express(); app.use(express.json({ limit: '10kb' })); app.use(express.static(path.join(__dirname, '../public'))); app.use('/api', contactRouter); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`表单服务已启动: http://localhost:${PORT}`); });express.json({ limit: '10kb' })这个限制值得单独说明。表单留言再长也不可能超过几 KB,把 body 限制在 10KB 可以直接挡掉一批尝试提交超长垃圾内容的请求,同时也避免服务器解析超大请求体带来的内存压力。
3.5 前端表单怎么对接
后端接口写好了,前端页面对接起来其实很直白。官方网站通常是静态页,我推荐直接用原生fetch,不需要引入 axios:
<form id="contactForm"> <input type="text" name="name" placeholder="姓名" required /> <input type="email" name="email" placeholder="邮箱" required /> <input type="text" name="phone" placeholder="电话" /> <input type="text" name="company" placeholder="公司" /> <textarea name="content" placeholder="留言内容" required></textarea> <!-- 蜜罐字段,CSS隐藏,用户不可见 --> <input type="text" name="website" style="position:absolute;left:-9999px;" tabindex="-1" autocomplete="off" /> <!-- 时间戳,用来判断是不是机器人 --> <input type="hidden" name="form_started_at" id="formStartedAt" value="" /> <button type="submit">提交</button> </form> <script> document.getElementById('formStartedAt').value = Date.now(); document.getElementById('contactForm').addEventListener('submit', async (e) => { e.preventDefault(); const form = e.target; const data = Object.fromEntries(new FormData(form).entries()); const button = form.querySelector('button'); button.disabled = true; try { const res = await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); const result = await res.json(); alert(result.message || '提交成功'); } catch (err) { alert('提交失败,请稍后再试'); } finally { button.disabled = false; } }); </script>这里的前端交互有一个容易被忽略的细节:提交按钮在请求期间要禁用,否则用户连点两次,后端可能写入两条一模一样的留言。按钮禁用是前端层面的兜底,真正的防重复还要靠后端逻辑,我在第五部分会展开讲。
4. 上线前必须做好的几件事
4.1 环境变量和密钥管理
.env文件放在.gitignore里,确保不会把 SMTP 密码提交到代码仓库。我给团队准备了.env.example文件,里面只放空变量名和注释,新成员复制成.env之后自己填内容,这样就不会出现“为什么我跑起来一直报 SMTP 认证失败”的远程协助难题。
还有一件事提醒一下:如果发件邮箱开启了双重验证,SMTP 密码通常不是登录密码,而是平台生成的应用专用授权码。这个授权码一般只显示一次,配置的时候要原样复制,不要带空格,也不要带<>这类包裹符号。
4.2 CORS 和域名限制
默认情况下,Express 接口不校验请求来源,浏览器跨域请求会被同源策略拦下,但 curl、Postman、脚本等非浏览器客户端不受限制。如果你的官网和接口部署在不同域名,需要安装并配置cors中间件,并且把origin设置成白名单,而不是直接开放所有来源。万一后续接口被其他域名的页面恶意调用,至少 COR S 层面能挡掉一部分基于浏览器的攻击。
npm install cors然后在server.js里:
const cors = require('cors'); app.use(cors({ origin: ['https://www.example.com', 'https://example.com'], }));注意:如果你没有把握官网域名会固定在某个值上,也可以先用origin: true配合后端频率限制兜底,等域名稳定后再收紧。
4.3 邮件投递的三个前提条件
Nodemailer 的代码写完整不代表邮件能顺利到达收件箱。我吃过最大的亏在这里:本地测试时明明收到了邮件,部署到服务器后却一封都进不了运营邮箱,全躺在垃圾箱里。原因就是发件域名的 DNS 记录没有配置完整。
邮件服务商判断一封信是否为垃圾邮件,会看发件方域名有没有 SPF、DKIM、DMARC 记录。SPF 告诉收件方“哪些 IP 被允许用这个域名发信”,DKIM 提供一个签名密钥让收件方验证邮件未被篡改,DMARC 则定义了验证失败后怎么处理。如果你用的是现有企业邮箱的 SMTP 服务,去域名管理后台把服务商提供的三条 DNS 记录加上即可。这三条记录通常需要 5 分钟到几小时生效,可以用在线的邮件头分析工具检查是否已经生效。
另外一个容易被忽略的点:通知邮件的内容里不要出现过于营销化的字眼,比如“免费”“抢购”“立即下单”。企业内部通知邮件被服务商当作促销邮件过滤掉虽然不至于退信,但收件人长期在垃圾箱里找客户留言,这个体验非常糟糕。
4.4 用进程守护工具跑起来
上线阶段,我一般不用node src/server.js直接挂后台,而是用进程守护工具来管。最常用的是pm2:
npm install -g pm2 pm2 start src/server.js --name contact-form-backend pm2 save这样做的好处有三个:进程崩溃后自动重启、开机后自动拉起、日志统一管理。日志这件事尤其重要,因为邮件发送失败、异常请求拦截这类信息都会打在console里,没有日志的话远程排查问题只能抓瞎。
5. 踩坑实录:垃圾箱、重复提交和还原不出来的 Bug
5.1 邮件总是进垃圾箱怎么办
先做一个最小化验证:用同一个 SMTP 配置,通过命令行工具或者在线发送页面,给目标邮箱发一封纯文本测试邮件。如果它也进垃圾箱,说明问题出在域名信任度或 DNS 配置,而不是 Nodemailer 代码。如果测试邮件正常而程序发送的进垃圾箱,那就是邮件内容触发了过滤规则,把花哨模板去掉,尽量用纯文本,并在开头加上明确的发件人信息。
还有一个经验:不要让通知邮件的from和回复地址不一致,某些邮件服务商对此很敏感。设置发件人时,MAIL_FROM最好和SMTP_USER同域,邮箱地址也别带noreply这种天然带着“不要回”色彩的词,换成contact@或者info@反而更正常。
5.2 前端重复提交和后端防重的配合
前端禁用按钮可以帮助普通用户避免误触,但技术娴熟的用户或者脚本完全可以绕过。真正的防重要靠后端。我采用了两层策略:频率限制之外,再给ContactMessage表里的email + createdAt加一个短时间内的重复检查。比如在创建前先查询同一邮箱最近 10 分钟内有没有内容相似的留言,有就直接返回成功但不入库。
如果你希望做到更严格,可以在表里加一个clientToken字段,前端每次进入页面生成一个 UUID,提交时带上,数据库为这个字段建立唯一索引。同一页面只能提交一次,二次提交会被数据库层拒绝。这个方案的前端成本很低,但能彻底堵住重复提交。
5.3 Prisma 开发时连接不释放
本地开发时用了nodemon监听文件变化,每次保存都会重启服务,而 PrismaClient 在旧进程退出前后可能没有及时断开数据库连接。跑一段时间后,SQLite 会报“database is locked”的错误。原因其实很简单:SQLite 同一时刻只允许一个进程写数据库,开发时的热重启制造了多个残留连接。
解决办法有两个:一是给 PrismaClient 加$disconnect()的钩子,在进程退出前断开;二是不要频繁热重启,或者把 SQLite 数据库路径放到临时目录,避免锁文件残留。对于这个 MVP 项目,更推荐在server.js里捕获退出信号做清理:
process.on('SIGINT', async () => { await prisma.$disconnect(); process.exit(0); });5.4 常见问题速查表
| 现象 | 可能原因 | 排查顺序 |
|---|---|---|
| 提交后提示成功但没收到邮件 | SMTP 配置错误,或 DNS 记录未生效 | 先看进程日志有没有“邮件发送失败”,再检查 SMTP 账号授权码,最后查 DNS |
| 邮件进了垃圾箱 | 域名 SPF/DKIM 未配好,或邮件内容有营销词 | 用在线工具检测域名记录,简化邮件内容 |
| 数据库报 locked 错误 | 开发热重启残留连接 | 加进程退出清理逻辑,或删除dev.db后重新迁移 |
| 接口 429 频繁拦截 | 频率限制阈值过高,或同一 IP 下多人共用 | 调大阈值,或按邮箱维度限制而不是 IP 维度 |
| 部署后接口 404 | public目录静态资源路径不对,或路由前缀不一致 | 用 curl 分别测/api/contact和静态文件路径 |
这个表格越往后越接近生产环境真实问题,建议你把这篇文章收藏起来,等部署之后遇到对应现象再回来对照。
最后分享一个我个人的习惯:项目拆到这种粒度已经足够承担官网表单业务,就不要再继续往上叠东西了。先用两周观察真实提交量、垃圾流量占比和邮件送达率,如果每天的有效留言不超过三十条,SQLite 加内存频率限制的方案可以一直用下去。真到了量大的那天,把 Prisma 的 provider 换成 PostgreSQL、频率限制换到 Redis,代码改动幅度都在可接受范围内。最怕的不是方案不够“先进”,而是方案复杂到上线一个月之后没人能维护。表单这件事,稳定、简单、可观测,就是最好的方案。