
从官网“联系我们”这个表单正式上线那天起我手机上的新邮件提醒就没安静过。刚开始以为是有客户咨询打开一看全是“长期办理业务”“低价出售资料”这类垃圾提交一天能收到七八条最离谱的时候表单后台里挤了四十多条假留言把真正想咨询的客户信息都淹没了。后来我意识到一个扎心的事实官网表单如果不做任何防护本质上就是给爬虫和群发工具留了一扇全敞开的后门。这篇文章想解决的就是这件事。我会用 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_URLfile:./dev.db SMTP_HOSTsmtp.example.com SMTP_PORT465 SMTP_USERnoreplyexample.com SMTP_PASSyour-auth-code MAIL_FROMnoreplyexample.com NOTIFY_TOopsexample.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不需要引入 axiosform idcontactForm input typetext namename placeholder姓名 required / input typeemail nameemail placeholder邮箱 required / input typetext namephone placeholder电话 / input typetext namecompany placeholder公司 / textarea namecontent placeholder留言内容 required/textarea !-- 蜜罐字段CSS隐藏用户不可见 -- input typetext namewebsite styleposition:absolute;left:-9999px; tabindex-1 autocompleteoff / !-- 时间戳用来判断是不是机器人 -- input typehidden nameform_started_at idformStartedAt value / button typesubmit提交/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直接挂后台而是用进程守护工具来管。最常用的是pm2npm install -g pm2 pm2 start src/server.js --name contact-form-backend pm2 save这样做的好处有三个进程崩溃后自动重启、开机后自动拉起、日志统一管理。日志这件事尤其重要因为邮件发送失败、异常请求拦截这类信息都会打在console里没有日志的话远程排查问题只能抓瞎。5. 踩坑实录垃圾箱、重复提交和还原不出来的 Bug5.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 维度部署后接口 404public目录静态资源路径不对或路由前缀不一致用 curl 分别测/api/contact和静态文件路径这个表格越往后越接近生产环境真实问题建议你把这篇文章收藏起来等部署之后遇到对应现象再回来对照。最后分享一个我个人的习惯项目拆到这种粒度已经足够承担官网表单业务就不要再继续往上叠东西了。先用两周观察真实提交量、垃圾流量占比和邮件送达率如果每天的有效留言不超过三十条SQLite 加内存频率限制的方案可以一直用下去。真到了量大的那天把 Prisma 的 provider 换成 PostgreSQL、频率限制换到 Redis代码改动幅度都在可接受范围内。最怕的不是方案不够“先进”而是方案复杂到上线一个月之后没人能维护。表单这件事稳定、简单、可观测就是最好的方案。