资讯动态

B/S架构网络投票系统设计与实现(Spring Boot+Vue)

发布时间:2026/10/17 11:18:33 来源:尧图企业网站定制
简介这是一套基于ASPAccess技术栈实现的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.xJDK 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分层但针对投票业务做了轻量化裁剪表现层ViewVue组件按功能域拆分VoteCreate.vue负责创建投票含选项动态增删、VoteList.vue展示进行中/已结束投票列表、VoteDetail.vue渲染单个投票页并处理提交逻辑。所有API调用封装在api/vote.js中统一处理401跳登录、429限流提示。业务逻辑层Controller ServiceController仅做参数校验与DTO转换如将前端传来的{title: 班委选举, options: [张三,李四]}转为VoteDTO核心逻辑下沉至Service层。例如VoteService.submitVote()方法内先查ipAddress是否在Redis中存在且未超限KEY为vote:ip:${ip}:countTTL 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, ListLong 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) ) ENGINEInnoDB;// 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 MapString, Object getVoteStats(Long voteId) { // 1. 从MySQL查出该投票所有选项ID和内容 ListVoteOptionEntity options voteOptionMapper.selectList( new QueryWrapperVoteOptionEntity().eq(vote_id, voteId) ); // 2. 批量从Redis读取各选项计数 ListString hashKeys options.stream() .map(opt - vote:option:count: opt.getId()) .collect(Collectors.toList()); ListObject counts redisTemplate.opsForHash().multiGet( hashKeys.toArray(new String[0]), Collections.singletonList(count) ); // 3. 组装返回结果含MySQL原始数据Redis实时计数 MapString, 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(); // 开启进行中投票 ListVoteEntity toStart voteMapper.selectList( new QueryWrapperVoteEntity() .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))); }); // 关闭已结束投票 ListVoteEntity toEnd voteMapper.selectList( new QueryWrapperVoteEntity() .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; filenamevote_ 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) { PageVoteRecordEntity page new Page(pageNum 1, pageSize); IPageVoteRecordEntity recordPage voteRecordMapper.selectPage(page, new QueryWrapperVoteRecordEntity().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层预加载避免N1 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否则大数据量直接撑爆JVMgetOptionContent()应在循环外批量查询选项表用Map缓存杜绝N1查询。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.182. 后端启动命令java -jar vote-system.jar --spring.profiles.activeprod3. 前端构建步骤cd frontend npm install npm run build4. Nginx最小化配置含/api代理和静态文件路径老师可当场按步骤复现证明你真部署过不是本地localhost跑通就完事。很多学生卡在“部署”环节这份文档直接封印你的实操能力。数据库设计说明书Word或Draw.io导出PNG1. E-R图含t_vote、t_vote_option、t_vote_record三张表2. 每张表的字段清单含类型、是否为空、索引说明3. 关键约束解释如t_vote_record表中user_idvote_id联合唯一索引防重复展示你对数据建模的理解深度。老师扫一眼E-R图就能判断你是否真懂“一对多”关系比讲一百遍“MVC”都有力。测试用例报告Excel表格5列用例编号、模块、操作步骤、预期结果、实际结果1. 正向用例管理员创建投票→用户提交→查看统计全部PASS2. 边界用例IP限频第5次提交成功、第6次失败截图3. 异常用例修改URL中的voteId为不存在ID返回404截图证明你不是“能点就完事”而是系统性验证过。老师最爱问“如果……会怎样”这份报告就是你的“后悔药”。注意所有材料命名规范为[姓名]_网络投票系统_部署说明书_v1.0.docx版本号体现迭代意识。答辩PPT中不必放代码但必须有这三份材料的封面截图。5.2 两个必演答辩动作用5分钟建立技术可信度答辩不是背稿是现场建立信任。我带过的项目里学生只要做好以下两个动作老师提问锐度立刻下降50%动作一现场演示“从零部署到投票成功”的全流程≤3分钟不要用预装好的环境从Windows资源管理器打开一个空文件夹开始解压backend.zip→ 双击start.bat内容java -jar vote-system.jar→ 展示控制台输出Tomcat started on port(s): 8080解压frontend.zip→ 双击build.bat内容cd dist copy *.* C:\nginx\html\ /y→ 打开Nginx目录确认文件已复制启动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万”而是“你知道瓶颈在哪且有解法”。从那以后我每次带学生做课设都强制他们在答辩前用同学手机当“真实用户”走一遍全流程从扫码进投票页、选选项、点提交、到后台看统计数字跳动。只有亲眼看到那个数字真的变了才知道什么叫“系统活了”。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑