搭建一个在线民意调查平台,核心要解决三件事:调查的创建与管理、投票行为的可靠记录、结果统计的实时呈现。本文选用Node.js作为服务端运行时,搭配Express、MongoDB和Socket.IO来实现一个可运行的DePoll风格系统。整个项目从初始化到部署都保持轻量,不引入过度复杂的架构,适合需要快速上线投票功能的中小型团队。

一、项目初始化与依赖选型
先把开发环境准备起来。确保本机已安装Node.js 18或更高版本,然后创建项目目录并执行npm init。需要安装的依赖包括express用于构建HTTP服务,mongoose用于连接MongoDB,socket.io用于实现实时推送,redis用于缓存投票记录,以及dotenv管理环境变量。开发依赖则安装nodemon方便热重载。
目录结构可以采用分层模式,避免把所有逻辑堆在入口文件里。建议创建routes目录存放API路由,models目录放Mongoose模型,services目录写投票业务逻辑,middlewares目录处理公共校验。入口文件app.js只负责加载中间件和注册路由,这样后续扩展调查类型或增加管理后台时不会改动主流程。
// app.js 核心初始化代码
const express = require('express');
const mongoose = require('mongoose');
const http = require('http');
const { Server } = require('socket.io');
require('dotenv').config();
const app = express();
const server = http.createServer(app);
const io = new Server(server, { cors: { origin: '*' } });
app.use(express.json());
mongoose.connect(process.env.MONGO_URI, {
useNewUrlParser: true,
useUnifiedTopology: true
}).then(() => console.log('MongoDB connected'));
app.use('/api/surveys', require('./routes/surveyRoutes'));
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => console.log(`Server running on ${PORT}`));
module.exports = { app, io };
这里用http模块包裹express实例,是为了让Socket.IO复用同一个HTTP端口,避免单独开一个WebSocket端口造成部署麻烦。另外cors配置允许跨域,方便前后端分离开发。实际生产环境建议收紧origin白名单,不要使用*。
二、数据模型与RESTful API设计
民意调查的核心数据对象是调查本身、选项以及投票记录。在Mongoose中定义Survey模型时,可以设计title字段存储调查标题,options数组保存每个选项的文本和票数,createdAt记录创建时间。选项票数可以直接内嵌在选项对象里,这样读取调查详情时一次查询就能拿到全部数据,无需额外聚合。但如果调查可能包含大量选项或需要频繁更新,单独建一个Option集合会更灵活。
// models/Survey.js
const mongoose = require('mongoose');
const optionSchema = new mongoose.Schema({
text: { type: String, required: true },
votes: { type: Number, default: 0 }
}, { _id: true });
const surveySchema = new mongoose.Schema({
title: { type: String, required: true, trim: true },
options: { type: [optionSchema], validate: v => v.length >= 2 },
createdAt: { type: Date, default: Date.now }
});
module.exports = mongoose.model('Survey', surveySchema);
投票记录通常需要独立模型,用来判断某个用户是否已经投过票。Vote模型至少包含surveyId、optionId和voterKey三个字段。voterKey可以是根据IP地址、用户代理解析生成的哈希值,也可以由前端生成一个随机的匿名标识存到localStorage。为了保证不重复投票,在Vote模型上创建surveyId和voterKey的复合唯一索引,数据库层面直接拦截重复记录。
// models/Vote.js
const mongoose = require('mongoose');
const voteSchema = new mongoose.Schema({
surveyId: { type: mongoose.Schema.Types.ObjectId, ref: 'Survey', required: true },
optionId: { type: mongoose.Schema.Types.ObjectId, required: true },
voterKey: { type: String, required: true },
createdAt: { type: Date, default: Date.now }
});
voteSchema.index({ surveyId: 1, voterKey: 1 }, { unique: true });
module.exports = mongoose.model('Vote', voteSchema);
API设计遵循REST风格,创建调查使用POST /api/surveys,获取调查详情使用GET /api/surveys/:id,提交投票使用POST /api/surveys/:id/vote。获取详情时不需要把投票记录暴露出去,只返回标题、选项和当前票数统计即可。创建调查的请求体需要校验选项至少两个,标题不能为空,否则返回400状态码。
三、投票逻辑与幂等控制
投票接口是最容易出问题的部分。一个用户快速点击多次投票按钮,或者用脚本绕过前端限制直接请求接口,都可能导致票数虚高。所以后端必须实现幂等控制,即同一个用户对同一调查无论发起多少次请求,只有第一次有效,后续请求返回已投票的状态提示,而不是重复累加票数。
实现方案主要有两种:一种是在数据库层使用复合唯一索引配合插入操作,如果插入时触发了唯一键冲突,就说明已经投过票,回滚事务并返回提示;另一种是先用Redis的SETNX命令原子性地记录投票标记,成功设置标记后再更新数据库,失败则直接拒绝。第一种方案更简单可靠,不需要额外维护缓存一致性,适合投票量不大的场景。
// services/voteService.js 核心投票流程
const Vote = require('../models/Vote');
const Survey = require('../models/Survey');
async function castVote(surveyId, optionId, voterKey) {
// 先检查调查和选项是否存在
const survey = await Survey.findById(surveyId);
if (!survey) throw new Error('Survey not found');
const option = survey.options.id(optionId);
if (!option) throw new Error('Option not found');
// 尝试创建投票记录,如果已存在会触发唯一索引冲突
try {
const vote = new Vote({ surveyId, optionId, voterKey });
await vote.save();
} catch (err) {
if (err.code === 11000) {
throw new Error('Already voted');
}
throw err;
}
// 投票记录创建成功后原子更新选项票数
await Survey.updateOne(
{ _id: surveyId, 'options._id': optionId },
{ $inc: { 'options.$.votes': 1 } }
);
return { success: true, message: 'Vote accepted' };
}
上面的代码中,保存投票记录是第一步,只有在没有重复的情况下才会执行票数自增。唯一索引冲突的错误码是11000,通过捕获这个错误即可判断重复投票。更新票数时使用了$inc操作符,这是MongoDB的原子递增操作,不会出现读取再写入的并发覆盖问题。voterKey的生成可以结合IP和User-Agent,再加上一个服务端秘钥做HMAC,避免用户伪造。
四、实时统计结果与前端集成
投票结果需要实时展示才有民意调查的参与感。传统做法是前端定时轮询接口,但这样对服务器压力较大,而且延迟明显。使用Socket.IO可以让服务端在票数变化后主动推送最新统计结果,所有打开调查页面的客户端都能同步更新。推送时机放在票数自增成功之后,通过io.emit广播到对应房间即可。
// 在投票路由中推送实时结果
const { io } = require('../app');
router.post('/:id/vote', async (req, res) => {
try {
const voterKey = generateVoterKey(req);
await castVote(req.params.id, req.body.optionId, voterKey);
const survey = await Survey.findById(req.params.id);
io.to(req.params.id).emit('survey:update', {
surveyId: req.params.id,
options: survey.options.map(o => ({ id: o._id, text: o.text, votes: o.votes }))
});
res.status(200).json({ message: 'Vote accepted' });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
前端加入对应调查的房间,监听survey:update事件后更新界面。如果不想用Socket.IO,也可以采用Server-Sent Events(SSE)实现单向推送,代码更轻量但兼容性略差。另外对于历史调查和冷门数据,可以直接从MongoDB聚合查询获取每个选项的得票数,再计算百分比渲染成条形图。
统计聚合可以用MongoDB的aggregation框架,按surveyId分组后对options数组做unwind展开,再按optionId分组求和。不过由于我们已经在Survey文档中维护了votes字段,读取详情时直接使用即可,不必每次都做复杂聚合。如果调查选项数量很大或者需要跨调查统计,再考虑预聚合或物化视图方案。
整个平台还可以继续扩展用户身份认证、调查过期时间、分享链接、数据导出等功能。核心的投票可靠性已经通过复合唯一索引和原子自增保障,剩下的就是根据业务需求逐步迭代。部署时建议将Node.js应用通过PM2守护,MongoDB使用副本集保证数据安全,Redis可选用于高并发下的投票标记缓存。这样一套基于Node.js的民意调查平台就可以投入实际使用了。