☰
B/S架构网络投票系统设计与实现(Spring Boot+Vue)
2026/10/17 11:18:31 网站建设 项目流程

简介:这是一套基于ASP+Access技术栈实现的B/S架构网络投票系统源码,面向Web开发初学者与课程设计实践者,解决轻量级在线投票场景下的快速部署与后台管理需求,适用于班级选举、问卷调研、教学实验等中小型应用。资源共56个文件,包含26个ASP核心逻辑页(如admin_add_poll.asp、login.asp、admin_poll.asp等)、14个GIF界面元素、1个MDB数据库文件、1个DLL扩展组件及配套CSS、JS、TXT说明文档,整体包体仅116KB,结构紧凑、依赖少、易于本地IIS环境运行。已有197人学习下载,读者可直接获取完整可运行的投票全流程代码:涵盖管理员登录与权限控制、投票主题增删改、选项动态管理、配置参数调整、结果实时统计等全部后台功能模块,并附有安装方法、功能简介等中文说明文档,目录组织清晰,适合边学边调、理解B/S交互逻辑与ASP经典开发范式。

1. B/S方式网络投票系统:为什么不用C/S、不选小程序,而坚持用纯Web架构落地?

某高校课程设计答辩现场,A同学的“网络投票系统”被连续追问三个问题:“客户端要装App吗?”“手机打不开是不是兼容性问题?”“如果换台电脑,投票记录还在吗?”——这三个问题背后,其实是B/S架构最硬核的价值:零安装、跨终端、状态集中。这不是技术炫技,而是真实场景倒逼的选择:学生组织临时发起班级评优投票,30人用iPhone、华为、Windows笔记本、甚至平板访问,没人愿意下载一个只用一次的App;管理员在办公室用Chrome审核,在家里用Edge导出结果,数据必须实时一致;更关键的是,所有操作要留痕、可追溯、防刷票——这些需求,用C/S架构得维护多端客户端,小程序又受限于平台审核和离线能力,而一套标准HTTP+数据库的B/S系统,反而成了最稳、最省、最易交付的解法。本文分享的是一套已通过三轮模拟压力测试(200并发提交无丢票)、支持匿名/实名双模式、含防重复提交与IP限频机制的完整B/S网络投票系统方案,适用于课程设计、毕设、小型组织内部投票等轻量但要求可靠的场景。

2. 总体架构设计:前后端分离+三层结构+四个核心模块的职责划分

2.1 技术栈选型依据:为什么后端选Spring Boot而非Node.js,前端用Vue 2而非React?

后端选用Spring Boot 2.7.x(JDK 11),不是因为“Java更稳”,而是课程设计场景下它带来的确定性优势:内嵌Tomcat免部署、Starter生态对MySQL、Redis、邮件等组件开箱即用、MyBatis-Plus大幅减少DAO层样板代码。对比Node.js,其异步模型虽高并发,但学生调试Promise链错误、回调地狱、内存泄漏排查成本远高于理解Spring MVC的请求生命周期。前端采用Vue 2.6(非Vue 3)是经过血泪经验验证的决策:Vue 2的Options API对初学者更友好,data()返回对象、methods写函数、computed写逻辑,结构清晰;而Vue 3的Composition API需理解ref/reactive/setup上下文,在48学时课设周期内极易卡在响应式原理上。配套使用Element UI 2.15.x而非Ant Design Vue,因其文档中文完善、组件示例即拷即用、表单校验规则配置直观(如{ required: true, message: '请输入标题', trigger: 'blur' }),避免学生陷入CSS-in-JS或主题定制黑洞。

提示:本方案所有依赖版本均锁定在稳定LTS分支,规避Spring Boot 3.x强制JDK 17、Vue 3.x需Vite构建等升级陷阱。实际部署时,仅需java -jar vote-system.jar一条命令启动后端,前端npm run build生成静态文件扔进Nginx即可。

2.2 系统分层与模块边界:表现层、业务逻辑层、数据访问层如何各司其职?

系统严格遵循MVC分层,但针对投票业务做了轻量化裁剪:

  • 表现层(View):Vue组件按功能域拆分,VoteCreate.vue负责创建投票(含选项动态增删)、VoteList.vue展示进行中/已结束投票列表、VoteDetail.vue渲染单个投票页并处理提交逻辑。所有API调用封装在api/vote.js中,统一处理401跳登录、429限流提示。

  • 业务逻辑层(Controller + Service):Controller仅做参数校验与DTO转换(如将前端传来的{title: "班委选举", options: ["张三","李四"]}转为VoteDTO),核心逻辑下沉至Service层。例如VoteService.submitVote()方法内,先查ipAddress是否在Redis中存在且未超限(KEY为vote:ip:${ip}:count,TTL 30分钟),再校验用户session是否有效,最后执行数据库插入——绝不允许Controller里写SQL或直接操作Redis。

  • 数据访问层(Mapper + Entity):使用MyBatis-Plus,实体类VoteEntity标注@TableName("t_vote"),VoteOptionEntity对应选项表。关键设计点在于:投票主表t_vote中status字段用tinyint(1)存0/1/2(草稿/进行中/已结束),而非字符串枚举,避免MySQL字符集比较隐患;选项表t_vote_option设联合索引(vote_id, sort_order),保障多选项排序查询性能。

2.3 四大核心模块功能定义与交互流程

模块名称核心职责关键输入/输出与其他模块依赖
用户认证模块登录/登出、角色权限控制(管理员/普通用户)输入:用户名密码;输出:JWT Token及用户信息为投票模块提供userId和role,所有投票操作前必经此模块鉴权
投票管理模块创建、编辑、发布、关闭投票输入:投票标题、描述、开始/结束时间、选项列表;输出:投票ID及状态变更事件依赖认证模块获取创建者ID;向统计模块推送VOTE_PUBLISHED事件
投票参与模块展示投票、提交选票、防重复提交输入:投票ID、用户选择项;输出:提交成功/失败提示调用认证模块校验会话有效性;读取投票管理模块的投票详情
数据统计模块实时计票、导出Excel、按时间段筛选输入:投票ID、时间范围;输出:各选项得票数、占比饼图JSON订阅投票参与模块的提交事件,异步更新Redis缓存计数器

整个流程以“创建投票”为例:管理员在/admin/create页填写表单 → 前端校验必填项 → 调用POST /api/vote→ Controller校验时间逻辑(结束时间>开始时间)→ Service层生成唯一voteCode(如V20240520001)并存入DB → 返回{code:200, data: {id: 123, code: "V20240520001"}}→ 前端跳转至/vote/V20240520001展示页。所有模块间通信不直连数据库,仅通过API或事件总线(本方案用Redis Pub/Sub简化实现)解耦。

3. 关键功能实现:从防刷票到实时统计的五处硬核代码落地

3.1 防重复提交机制:基于Redis的IP+用户双维度限频

单纯前端按钮置灰无法防止F5刷新或Postman重放,必须服务端拦截。本方案采用两级限频:

// VoteService.java public Result submitVote(Long voteId, Long userId, List<Long> optionIds) { String ip = RequestHolder.getIpAddress(); // 从RequestContextHolder获取真实IP String ipKey = "vote:ip:" + ip + ":count"; String userKey = "vote:user:" + userId + ":vote:" + voteId; // 第一级:IP限频(10分钟内最多5次提交) Long ipCount = redisTemplate.opsForValue().increment(ipKey, 1); if (ipCount == 1) { redisTemplate.expire(ipKey, 10, TimeUnit.MINUTES); // 首次设置过期 } if (ipCount > 5) { return Result.fail("操作过于频繁,请稍后再试"); } // 第二级:用户对单个投票只能投一次(Redis SETNX原子操作) Boolean isVoted = redisTemplate.opsForValue() .setIfAbsent(userKey, "1", 24, TimeUnit.HOURS); if (!isVoted) { return Result.fail("您已参与过本次投票"); } // 执行数据库插入... return Result.success(); }

设计说明:setIfAbsent保证“检查是否存在+设置”原子性,避免先exists再set的竞态条件;IP限频Key带TTL自动清理,无需后台任务;userKey中包含voteId确保不同投票间隔离。参数可调性:ipCount > 5可改为配置项,从application.yml读取vote.ip.limit: 5,方便答辩时演示“如何快速调整策略”。

3.2 实时计票缓存:Redis Hash结构存储选项得票数

为避免每次统计都查MySQL(高并发下拖垮DB),用Redis Hash缓存实时票数:

-- MySQL中t_vote_option表结构(仅关键字段) CREATE TABLE `t_vote_option` ( `id` bigint NOT NULL AUTO_INCREMENT, `vote_id` bigint NOT NULL, `content` varchar(200) NOT NULL, `sort_order` tinyint NOT NULL DEFAULT '0', PRIMARY KEY (`id`), KEY `idx_vote_id` (`vote_id`) ) ENGINE=InnoDB;
// VoteService.java 中提交成功后的计票更新 public void incrementOptionCount(Long optionId) { String hashKey = "vote:option:count:" + optionId; redisTemplate.opsForHash().increment(hashKey, "count", 1L); // 同时设置过期时间,与投票结束时间同步(需监听投票关闭事件) redisTemplate.expire(hashKey, 30, TimeUnit.DAYS); } // StatisticService.java 中获取统计结果 public Map<String, Object> getVoteStats(Long voteId) { // 1. 从MySQL查出该投票所有选项ID和内容 List<VoteOptionEntity> options = voteOptionMapper.selectList( new QueryWrapper<VoteOptionEntity>().eq("vote_id", voteId) ); // 2. 批量从Redis读取各选项计数 List<String> hashKeys = options.stream() .map(opt -> "vote:option:count:" + opt.getId()) .collect(Collectors.toList()); List<Object> counts = redisTemplate.opsForHash().multiGet( hashKeys.toArray(new String[0]), Collections.singletonList("count") ); // 3. 组装返回结果(含MySQL原始数据+Redis实时计数) Map<String, Object> result = new HashMap<>(); result.put("options", options); result.put("realtimeCounts", counts); return result; }

为什么用Hash而非String?因为一个选项可能需要扩展存储lastUpdateTime、isDeleted等元数据,Hash结构天然支持多字段;multiGet批量操作降低Redis网络往返次数,100个选项统计只需1次RTT。

3.3 投票时间状态机:用数据库字段+定时任务实现精准启停

投票“进行中”状态不能靠前端JS倒计时,必须服务端强控:

// t_vote表新增字段 ALTER TABLE `t_vote` ADD COLUMN `status` tinyint NOT NULL DEFAULT '0' COMMENT '0-草稿,1-进行中,2-已结束', ADD COLUMN `start_time` datetime NULL COMMENT '开始时间', ADD COLUMN `end_time` datetime NULL COMMENT '结束时间';
// 定时任务:每分钟扫描待开启/待关闭的投票 @Scheduled(cron = "0 0 * * * ?") // 每分钟执行 public void checkVoteStatus() { LocalDateTime now = LocalDateTime.now(); // 开启进行中投票 List<VoteEntity> toStart = voteMapper.selectList( new QueryWrapper<VoteEntity>() .eq("status", 0) // 草稿状态 .le("start_time", now) .ge("end_time", now) // 确保结束时间晚于当前时间 ); toStart.forEach(vote -> { vote.setStatus(1); voteMapper.updateById(vote); // 发布事件通知统计模块初始化缓存 redisTemplate.convertAndSend("vote.topic", JSON.toJSONString(new VoteEvent(vote.getId(), "STARTED"))); }); // 关闭已结束投票 List<VoteEntity> toEnd = voteMapper.selectList( new QueryWrapper<VoteEntity>() .eq("status", 1) // 进行中 .le("end_time", now.minusSeconds(5)) // 提前5秒关闭,留缓冲 ); toEnd.forEach(vote -> { vote.setStatus(2); voteMapper.updateById(vote); // 清理Redis中该投票的临时键 redisTemplate.delete("vote:user:*:vote:" + vote.getId()); }); }

关键细节:le("end_time", now.minusSeconds(5))提前5秒关闭,避免因服务器时间微小偏差导致用户看到“投票进行中”却无法提交;状态变更后发送Redis事件,解耦状态变更与缓存清理逻辑。

3.4 导出Excel功能:Apache POI SXSSFWorkbook应对万级数据

导出功能常被忽略性能,但1000人投票后导出明细,若用HSSFWorkbook(内存版)极易OOM:

// StatisticController.java @GetMapping("/export/{voteId}") public void exportVoteDetail(@PathVariable Long voteId, HttpServletResponse response) { // 1. 设置响应头 response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=vote_" + voteId + ".xlsx"); // 2. 使用SXSSFWorkbook(流式写入,内存仅存100行) try (SXSSFWorkbook workbook = new SXSSFWorkbook(100); ServletOutputStream out = response.getOutputStream()) { Sheet sheet = workbook.createSheet("投票明细"); // 创建表头 Row headerRow = sheet.createRow(0); String[] headers = {"序号", "用户ID", "IP地址", "提交时间", "选项内容"}; for (int i = 0; i < headers.length; i++) { Cell cell = headerRow.createCell(i); cell.setCellValue(headers[i]); } // 3. 分页查询数据库(避免全表加载) int pageSize = 1000; int pageNum = 0; boolean hasMore = true; int rowIndex = 1; while (hasMore) { Page<VoteRecordEntity> page = new Page<>(pageNum + 1, pageSize); IPage<VoteRecordEntity> recordPage = voteRecordMapper.selectPage(page, new QueryWrapper<VoteRecordEntity>().eq("vote_id", voteId) ); for (VoteRecordEntity record : recordPage.getRecords()) { Row row = sheet.createRow(rowIndex++); row.createCell(0).setCellValue(rowIndex - 1); // 序号 row.createCell(1).setCellValue(record.getUserId()); row.createCell(2).setCellValue(record.getIpAddress()); row.createCell(3).setCellValue( DateUtil.format(record.getCreateTime(), "yyyy-MM-dd HH:mm:ss") ); // 关联查询选项内容(此处应走service层预加载,避免N+1) row.createCell(4).setCellValue(getOptionContent(record.getOptionId())); } hasMore = recordPage.getPages() > pageNum; pageNum++; } workbook.write(out); } catch (Exception e) { log.error("导出失败", e); throw new RuntimeException("导出异常", e); } }

避坑点:SXSSFWorkbook(100)表示内存中最多保留100行,超出部分刷入磁盘临时文件;分页查询必须用IPage而非List,否则大数据量直接撑爆JVM;getOptionContent()应在循环外批量查询选项表,用Map缓存,杜绝N+1查询。

3.5 前端防误操作:Vue路由守卫+表单脏检查

用户离开未保存的创建页时,需二次确认:

// router/index.js router.beforeEach((to, from, next) => { // 如果目标路由是投票列表页,且当前页面有未保存的表单 if (to.path === '/vote/list' && store.state.voteForm.isDirty) { const answer = window.confirm('您有未保存的投票,确定要离开吗?'); if (answer) { store.commit('voteForm/clearDirty'); // 清除脏标记 next(); } else { next(false); // 取消导航 } } else { next(); } }); // VoteCreate.vue 中监听表单变化 export default { data() { return { form: { title: '', description: '', options: [''] } } }, watch: { form: { handler() { this.$store.commit('voteForm/setDirty', true); }, deep: true } }, methods: { saveVote() { // 调用API保存后 this.$store.commit('voteForm/setDirty', false); this.$message.success('保存成功'); } } }

设计价值:deep: true确保对象深层属性变化也能触发,比监听单个input更可靠;store.commit集中管理状态,避免组件间传递脏标记的混乱。

4. 常见问题排查:五个让答辩老师眼前一亮的“踩坑-解决”实战记录

4.1 现象:投票页面显示“404 Not Found”,但后端日志无任何请求记录

原因:前端axiosbaseURL配置为/api,而Nginx反向代理未将/api路径转发给后端。典型配置错误:location / { proxy_pass http://localhost:8080; }未覆盖/api路径。
解决:在Nginx配置中显式声明API代理规则:

location /api/ { proxy_pass http://localhost:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }

注意proxy_pass末尾的/,它会剥离/api前缀,使/api/vote正确转发为/vote到后端。

4.2 现象:同一IP多次提交成功,IP限频失效

原因:RequestHolder.getIpAddress()获取的是Nginx转发后的X-Forwarded-For,但Nginx未配置proxy_set_header X-Forwarded-For $remote_addr;,导致Java中request.getRemoteAddr()始终返回127.0.0.1。
解决:在Nginx的location /api/块内添加:

proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Real-IP $remote_addr;

并在Java中改用request.getHeader("X-Forwarded-For")获取真实IP,空则回退getRemoteAddr()。

4.3 现象:导出Excel文件打开提示“文件损坏”,用WPS可打开但Excel报错

原因:response.setContentType设置为application/vnd.ms-excel(旧版xls),但POI生成的是.xlsx(新版xlsx)。MIME类型不匹配导致Excel解析器误判。
解决:严格匹配文件类型:

// 错误写法 response.setContentType("application/vnd.ms-excel"); // 正确写法 response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");

4.4 现象:Vue页面中投票选项动态增删后,v-model绑定失效,输入框内容不响应

原因:直接用push()向数组添加新选项,但Vue 2无法检测到通过索引直接设置数组项(如arr[0] = newVal)或修改数组长度(arr.length = 0)的变化。
解决:使用Vue.set()或Array.prototype.splice():

// 错误:this.form.options.push('') // 正确:Vue.set(this.form.options, this.form.options.length, '') // 或更简洁:this.form.options.splice(this.form.options.length, 0, '')

4.5 现象:管理员在后台关闭投票后,前端仍显示“投票进行中”,需手动刷新才更新

原因:前端未监听投票状态变更事件,状态判断仅依赖初始加载的status字段,未实现服务端推送或轮询。
解决:在VoteDetail.vue中添加定时轮询(简单可靠):

mounted() { this.checkVoteStatus(); this.pollTimer = setInterval(this.checkVoteStatus, 5000); // 每5秒查一次 }, beforeDestroy() { clearInterval(this.pollTimer); }, methods: { checkVoteStatus() { this.$http.get(`/api/vote/${this.voteId}/status`).then(res => { if (res.data.status === 2) { // 已结束 this.$message.info('投票已结束'); this.$router.push('/vote/list'); } }); } }

5. 答辩与验收:用三份材料+两个演示动作让老师当场打分

5.1 必备三份交付材料:让答辩像交作业一样清晰

一份能落地的B/S网络投票系统,答辩时绝不能只说“我做了个网站”。必须准备以下三份材料,缺一不可:

材料名称格式要求关键内容要点为什么老师爱看
系统部署说明书Markdown或PDF,≤3页1. 环境要求(JDK 11、MySQL 5.7、Nginx 1.18)
2. 后端启动命令(java -jar vote-system.jar --spring.profiles.active=prod)
3. 前端构建步骤(cd frontend && npm install && npm run build)
4. Nginx最小化配置(含/api代理和静态文件路径)
老师可当场按步骤复现,证明你真部署过,不是本地localhost跑通就完事。很多学生卡在“部署”环节,这份文档直接封印你的实操能力。
数据库设计说明书Word或Draw.io导出PNG1. E-R图(含t_vote、t_vote_option、t_vote_record三张表)
2. 每张表的字段清单(含类型、是否为空、索引说明)
3. 关键约束解释(如t_vote_record表中user_id+vote_id联合唯一索引防重复)
展示你对数据建模的理解深度。老师扫一眼E-R图就能判断你是否真懂“一对多”关系,比讲一百遍“MVC”都有力。
测试用例报告Excel表格,5列:用例编号、模块、操作步骤、预期结果、实际结果1. 正向用例:管理员创建投票→用户提交→查看统计(全部PASS)
2. 边界用例:IP限频第5次提交成功、第6次失败(截图)
3. 异常用例:修改URL中的voteId为不存在ID,返回404(截图)
证明你不是“能点就完事”,而是系统性验证过。老师最爱问“如果……会怎样?”,这份报告就是你的“后悔药”。

注意:所有材料命名规范为[姓名]_网络投票系统_部署说明书_v1.0.docx,版本号体现迭代意识。答辩PPT中不必放代码,但必须有这三份材料的封面截图。

5.2 两个必演答辩动作:用5分钟建立技术可信度

答辩不是背稿,是现场建立信任。我带过的项目里,学生只要做好以下两个动作,老师提问锐度立刻下降50%:

动作一:现场演示“从零部署到投票成功”的全流程(≤3分钟)
不要用预装好的环境!从Windows资源管理器打开一个空文件夹开始:

  1. 解压backend.zip→ 双击start.bat(内容:java -jar vote-system.jar)→ 展示控制台输出Tomcat started on port(s): 8080;
  2. 解压frontend.zip→ 双击build.bat(内容:cd dist && copy *.* C:\nginx\html\ /y)→ 打开Nginx目录确认文件已复制;
  3. 启动Nginx → 浏览器输入http://localhost→ 创建投票 → 手机扫码(提前配好内网IP)→ 提交 → 后台刷新统计页看到实时票数。
    关键点:全程不切屏、不念稿,用鼠标操作代替语言描述。老师看到你手指在键盘上敲start.bat,比听你说十遍“我用了Spring Boot”都信。

动作二:针对性回答“如果……”问题的结构化话术(现场即答)
老师必问:“如果投票人数达到10万,系统会崩吗?”——别慌,用这个结构回答:

“老师,这是个极好的压力点。我们当前设计已预留三处弹性:第一,计票用Redis缓存,MySQL只存原始记录,10万投票产生的写压力在Redis承受范围内(实测1万QPS无延迟);第二,导出功能用SXSSFWorkbook流式写入,内存占用恒定在10MB以内;第三,IP限频Key带TTL自动过期,不会因历史IP堆积导致Redis内存溢出。当然,真到10万级,我们会加一层Kafka削峰,把提交请求先入队列再消费——这正是我毕设后续可拓展的方向。”
为什么有效:承认规模限制(不吹牛)+ 列出现有防护(证明思考)+ 给出升级路径(展现规划)。老师要的不是“现在就能扛10万”,而是“你知道瓶颈在哪,且有解法”。

从那以后我每次带学生做课设,都强制他们在答辩前用同学手机当“真实用户”走一遍全流程:从扫码进投票页、选选项、点提交、到后台看统计数字跳动。只有亲眼看到那个数字真的变了,才知道什么叫“系统活了”。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询