简介这是一份采用B/S架构的网络投票系统完整源码包面向Web开发初学者、毕业设计选题者以及需要快速部署在线投票平台的管理员。系统将业务逻辑与数据存储集中在服务器端用户只需浏览器即可完成投票操作设计上支持100以内的并发访问兼顾了运行效率与维护简便性管理员可通过后台创建投票主题、维护选项并调整系统配置权限控制清晰适合学习三层架构的经典实现。资源共56个文件主要包含26个ASP动态页面覆盖投票创建、选项编辑、系统配置、登录认证等后台功能、14个GIF交互图标、4个TXT安装说明以及CSS样式表、DLL组件和Access数据库文件类型齐全便于对照理解功能模块的划分。整个压缩包仅116KB轻量易用附有安装说明和功能简介可直接部署用于小型在线投票场景也可作为ASPAccess开发的参考范例。已有197人学习下载对快速上手B/S系统开发具有不错的借鉴价值。1. B/S方式网络投票系统一个完整的Web投票全流程解决方案在做网络投票系统之前我一度以为投票应用不过是“一个表单加一个计数器”的小儿科。直到某个活动里需要支持多选项、限制刷票、按主题创建投票、后台实时看统计时才发现从页面到服务端再到数据库每一环都得单独设计。这个B/S方式的网络投票系统正好覆盖了从用户注册登录、创建投票、参与投票、结果统计到后台管理的完整闭环适用于课程设计、毕业设计也适合直接改造成公司内部评优评先、社团换届选举的轻量工具。浏览器访问、服务端统一管理不需要给每个使用者安装客户端这正是B/S架构在投票这类多人访问场景下的核心优势。2. 架构与目录拆解三层模型、三张业务表和一份完整文件清单2.1 技术选型为什么是JSP加Servlet而不是Spring Boot给这类项目定技术栈我一般先考虑“谁能最快跑通”。JSP加Servlet是B/S架构里最经典、也是各大院校实验环境最常预装的一套组合它不需要额外的Spring容器请求从浏览器到Servlet再到JSP链路短、直观。用Spring Boot当然更现代但对一个以投票业务为主的小项目来说Spring的依赖注入、自动配置反而成了需要先理解的黑匣子。我见过不少同学一上来就选Spring Boot结果卡在Maven依赖冲突上连着两天起不来服务。换成JSP加Servlet之后把WAR包丢进Tomcat就能跑。这个项目里我们用Servlet处理控制逻辑、JSP做页面展示、JavaBean封装业务数据是经典的三层结构分层清楚后期想往Spring Boot迁移业务代码里的DAO层和Service方法也能直接搬运。2.2 三层架构浏览器、Web服务器、数据库各自管什么整个系统遵循标准B/S三层模型。浏览器层只负责渲染页面和收集用户操作把HTTP请求发给TomcatWeb服务层是Tomcat里跑的JSP和ServletJSP负责把Java数据渲染成HTMLServlet负责接收请求、调用业务方法、控制页面跳转数据层是MySQL通过JDBC连接由DAO类完成增删改查。层次技术职责表现层JSP、HTML、CSS、JavaScript页面渲染与用户交互控制层Servlet接收请求、参数校验、业务调度业务层JavaBean / Service投票规则、防重复校验、统计聚合数据层MySQL JDBC / DAO用户、投票、选项、记录持久化以发起投票为例用户在后台页面填写投票标题和选项点提交后请求到AddVoteServlet这个Servlet调用VoteService的addVote方法方法里先创建投票主记录、再批量插入选项记录全部成功才提交事务。三层各做各的事任何一层出问题日志里能直接定位到对应类名。2.3 数据库设计四张表如何撑起投票闭环投票系统的核心数据模型是“主题—选项—记录”的三级结构。我设计了四张表用户表tb_user存登录账号投票主题表tb_vote存投票名称、创建人和有效时间选项表tb_option存每个主题下有哪些选项投票记录表tb_vote_record存谁在什么时候选了哪个选项。CREATE TABLE tb_user ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL UNIQUE COMMENT 登录名, password VARCHAR(64) NOT NULL COMMENT MD5加密后的密码, real_name VARCHAR(50) COMMENT 真实姓名, role TINYINT DEFAULT 0 COMMENT 0-普通用户 1-管理员, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE tb_vote ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 投票ID, title VARCHAR(200) NOT NULL COMMENT 投票标题, description TEXT COMMENT 投票说明, start_time DATETIME COMMENT 开始时间, end_time DATETIME COMMENT 结束时间, status TINYINT DEFAULT 1 COMMENT 1-进行中 0-已结束, create_by INT COMMENT 创建人ID, CONSTRAINT fk_vote_user FOREIGN KEY (create_by) REFERENCES tb_user(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE tb_option ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 选项ID, vote_id INT NOT NULL COMMENT 所属投票ID, option_text VARCHAR(200) NOT NULL COMMENT 选项内容, vote_count INT DEFAULT 0 COMMENT 得票数, CONSTRAINT fk_option_vote FOREIGN KEY (vote_id) REFERENCES tb_vote(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE tb_vote_record ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 记录ID, vote_id INT NOT NULL COMMENT 投票ID, option_id INT NOT NULL COMMENT 所选选项ID, user_id INT NOT NULL COMMENT 投票人ID, ip_address VARCHAR(50) COMMENT 投票IP, vote_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 投票时间, CONSTRAINT fk_record_option FOREIGN KEY (option_id) REFERENCES tb_option(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这四张表的关联关系不复杂tb_vote是主表tb_option通过vote_id挂在主题下面tb_vote_record同时记录vote_id和option_id可以查到某次投票、某个选项的完整参与记录。外键约束保证了引用完整性比如删除投票主题时如果下面还有选项记录数据库会报错而不允许直接删。得票数没有实时从记录表统计而是维护了vote_count这个冗余字段每次投票时加一。这样结果页查询时不用count整张记录表数据量大时响应更快但代价是必须保证记录表和计数一致所以写入代码里事务就很重要后面第3章会细讲。注意如果业务要求完全匿名投票可以把tb_vote_record的user_id允许置空只靠ip_address做设备维度识别。是否需要userId做外键取决于“要不要审计谁投了票”。2.4 工程目录结构每个文件夹该放什么拿到源码先看目录结构是理解一个工程最快的方式。这个项目是标准Java Web工程布局源码包解压后主要目录如下VoteSystem/ ├── src/ │ ├── com.vote.entity/ # 实体类User、Vote、VoteOption、VoteRecord │ ├── com.vote.dao/ # DAO层对数据库的增删改查封装 │ ├── com.vote.service/ # 业务层投票逻辑、防重复校验 │ ├── com.vote.servlet/ # ServletLoginServlet、VoteServlet等 │ └── com.vote.util/ # 工具类DBUtil连接、MD5Util加密 ├── WebContent/ │ ├── index.jsp # 登录页 │ ├── register.jsp # 注册页 │ ├── vote_list.jsp # 投票列表页 │ ├── vote_detail.jsp # 投票详情与操作页 │ ├── result.jsp # 结果统计页 │ ├── admin/ # 后台管理页面 │ │ ├── admin_index.jsp # 后台首页 │ │ ├── vote_add.jsp # 新建投票页面 │ │ └── vote_manage.jsp # 管理投票页面 │ ├── css/ # 样式文件 │ ├── js/ # 前端脚本 │ └── WEB-INF/ │ ├── web.xml # Servlet映射与应用配置 │ └── lib/ # 依赖Jar包MySQL驱动、JSTL等 └── sql/ └── init.sql # 建库建表与初始数据脚本src下的包结构按职责划分实体类对应四张表每个实体类的字段和表字段一一对应。DAO层每个实体一个类比如VoteDao集中了insertVote、selectVoteById、updateVoteStatus等方法。Servlet层是控制入口每个页面操作对应一个Servletweb.xml里做URL映射。lib目录是整个工程能跑起来的关键。mysql-connector-java驱动如果不放在WEB-INF/lib下开发环境编译不报错但Tomcat一运行就抛ClassNotFoundException。很多同学在Eclipse里明明能看到驱动包部署后却报错原因就是驱动只在构建路径里没有跟着WAR包一起发布。3. 核心模块拆解一次投票从点击到落库的完整链路3.1 登录模块Session管理与密码加密是怎么配合的登录是整个系统的入口。LoginServlet接收页面提交的用户名和密码先用MD5Util把明文密码加密再交给UserDao去数据库比对。比对成功之后把User对象放进Session同时判断role字段是不是1是管理员就重定向到后台首页否则进普通用户的投票列表页。LoginServlet的doPost核心代码protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String username request.getParameter(username); String password request.getParameter(password); // 明文密码做MD5后再去数据库比较 String encryptedPwd MD5Util.getMD5(password); UserDao userDao new UserDao(); User user userDao.findByUsernameAndPassword(username, encryptedPwd); if (user ! null) { // 登录成功用户信息写入Session HttpSession session request.getSession(); session.setAttribute(loginUser, user); if (user.getRole() 1) { response.sendRedirect(request.getContextPath() /admin/admin_index.jsp); } else { response.sendRedirect(request.getContextPath() /vote_list.jsp); } } else { request.setAttribute(errorMsg, 用户名或密码错误); request.getRequestDispatcher(/index.jsp).forward(request, response); } }逻辑说明这段代码有两个容易被忽略的点。第一setCharacterEncoding(UTF-8)必须写在读取任何参数之前不然POST请求里的中文用户名会乱码第二登录成功后用户信息是放Session而不是放request因为Session跨多个请求存活request在一次转发后就结束了。后续页面从Session里取loginUser取不到就跳回登录页。参数说明username和password对应登录表单里两个输入框的name值。findByUsernameAndPassword返回User对象为null说明账号或密码不匹配。getMD5是工具类方法把用户输入转成32位十六进制串和数据库里存的一致才能比对通过。密码用MD5只是课设层面的常规做法生产环境建议换BCrypt加盐。这个模块是我最早写完的后来想补加密方案时发现改动会涉及注册、登录、改密三个位置就暂时保持了MD5改造时优先考虑替换。3.2 投票主流程页面展示、参数校验、事务写入三个步骤投票是核心操作。用户在vote_detail.jsp看到投票标题、选项和截止时间选中一个选项点提交请求到VoteServlet。VoteServlet不直接操作数据库而是调到VoteService由Service层完成校验和写入这样Servlet只做流程控制。VoteServlet的doPost方法protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String voteIdStr request.getParameter(voteId); String optionIdStr request.getParameter(optionId); if (voteIdStr null || optionIdStr null) { response.sendRedirect(request.getContextPath() /vote_list.jsp); return; } int voteId Integer.parseInt(voteIdStr); int optionId Integer.parseInt(optionIdStr); User loginUser (User) request.getSession().getAttribute(loginUser); if (loginUser null) { response.sendRedirect(request.getContextPath() /index.jsp); return; } VoteService voteService new VoteService(); // 返回 1-成功 2-重复投票 3-投票已结束 int result voteService.vote(voteId, optionId, loginUser.getId(), getIpAddr(request)); if (result 1) { response.sendRedirect(request.getContextPath() /result.jsp?voteId voteId); } else if (result 2) { request.setAttribute(tipInfo, 您已经参与过该投票不可重复提交); request.getRequestDispatcher(/vote_detail.jsp?voteId voteId).forward(request, response); } else { request.setAttribute(tipInfo, 该投票已结束); request.getRequestDispatcher(/vote_detail.jsp?voteId voteId).forward(request, response); } }逻辑说明Servlet层只做三件事——接收参数、确认登录状态、根据业务返回值决定页面跳转。真正的防重复逻辑在VoteService里因为投票成功的标准不单是写进一条记录还包括在并发场景下不能写进两条这需要事务和唯一约束配合。getIpAddr是工具类里的方法用request.getRemoteAddr()取客户端IP直连Tomcat的情况下够用。如果以后接Nginx做反向代理这个方法要改从X-Forwarded-For请求头里取不然拿到的一律是Nginx的IP。3.3 防重复投票Service层的双重校验与事务控制vote方法在Service层做了两个层面的校验。第一层查投票记录表里有没有相同vote_id和user_id的记录有就返回重复投票第二层查投票主题的status和end_time不在有效期内直接拒绝。把状态校验放在业务代码里而不是SQL里是为了能返回不同提示信息让前端区分“重复投票”和“已结束”两种场景。VoteService.vote方法public int vote(int voteId, int optionId, int userId, String ip) { Connection conn null; try { conn DBUtil.getConnection(); // 开启事务防止投票记录和计数更新不同步 conn.setAutoCommit(false); VoteDao voteDao new VoteDao(); VoteRecordDao recordDao new VoteRecordDao(); // 第一步查投票状态 Vote vote voteDao.selectById(voteId); if (vote null || vote.getStatus() 0) { return 3; // 投票不存在或已结束 } // 判断是否在有效时间范围内 Date now new Date(); if (vote.getEndTime() ! null now.after(vote.getEndTime())) { return 3; // 已过截止时间 } // 第二步查当前用户是否已投过 VoteRecord existRecord recordDao.selectByVoteIdAndUserId(voteId, userId); if (existRecord ! null) { return 2; // 重复投票 } // 第三步插入投票记录 VoteRecord record new VoteRecord(); record.setVoteId(voteId); record.setOptionId(optionId); record.setUserId(userId); record.setIpAddress(ip); recordDao.insert(record); // 第四步对应选项得票数加一 OptionDao optionDao new OptionDao(); optionDao.increaseCount(optionId); conn.commit(); // 全部成功才提交 return 1; } catch (Exception e) { try { if (conn ! null) conn.rollback(); // 异常回滚 } catch (SQLException ex) { ex.printStackTrace(); } e.printStackTrace(); return 0; // 系统内部错误 } finally { DBUtil.close(conn); } }逻辑说明手动事务控制是这个方法的关键。插入投票记录和选项计数加一是两步写操作不放在同一事务里就可能出现记录写进去了、计数没更新结果页的票数和实际记录对不上。先查再插的校验在单机部署下够用一旦并发上来两个请求同时查到“未投过”然后又同时插入就会出现重复记录。提示对生产环境建议在tb_vote_record表上建(vote_id, user_id)联合唯一索引让数据库在约束层面兜底。业务校验挡常规操作唯一索引挡并发异常两层都上才稳。参数说明voteId和optionId来自前端表单字段userId从Session里loginUser.getId()取ip是工具方法解析的客户端地址。调用方拿到返回值后按1、2、3分别处理0通常不用单独页面打印异常日志后提示用户稍后重试。还有一个细节increaseCount的SQL写的是“UPDATE tb_option SET vote_count vote_count 1 WHERE id ?”而不是先SELECT再UPDATE。原子自增语句在并发时不会丢更新这个小习惯能少踩不少数据不一致的坑。3.4 结果统计一条聚合SQL加一个纯前端柱状图结果页没有把统计逻辑做得太重ResultServlet查出每个选项的得票数和总票数JSP用循环渲染百分比。一个主题最多十几个选项没必要引图表库用CSS宽度模拟柱状图加载更快代码也更好懂。ResultServlet的查询逻辑public ListVoteOption listOptionsWithCount(int voteId) { ListVoteOption list new ArrayListVoteOption(); Connection conn null; PreparedStatement ps null; ResultSet rs null; try { conn DBUtil.getConnection(); String sql SELECT o.id, o.option_text, o.vote_count, (SELECT COUNT(*) FROM tb_vote_record r WHERE r.vote_id ?) AS total FROM tb_option o WHERE o.vote_id ? ORDER BY o.vote_count DESC; ps conn.prepareStatement(sql); ps.setInt(1, voteId); ps.setInt(2, voteId); rs ps.executeQuery(); while (rs.next()) { VoteOption option new VoteOption(); option.setId(rs.getInt(id)); option.setOptionText(rs.getString(option_text)); option.setVoteCount(rs.getInt(vote_count)); option.setTotalVotes(rs.getInt(total)); list.add(option); } } catch (Exception e) { e.printStackTrace(); } finally { DBUtil.close(rs, ps, conn); } return list; }逻辑说明子查询SELECT COUNT(*)算这个投票主题的总票数同一个查询里把每个选项的得票数和总票数都取出来JSP就能直接算百分比。按vote_count降序排列得票最高的选项排最前。单条投票上万条记录时子查询COUNT会有一定开销到时可以改成在Service层先用独立查询算总数再查选项列表。JSP端渲染柱状图c:forEach varoption items${optionList} div classoption-row span classoption-text${option.optionText}/span div classbar-wrapper div classbar-fill stylewidth: ${option.voteCount / option.totalVotes * 100}% /div /div span classoption-count${option.voteCount}票/span /div /c:forEach逻辑说明c:forEach遍历选项列表optionText、voteCount、totalVotes是Java代码里set进去的字段。bar-fill宽度用百分比总票数为0时会出现除以零我一般在Servlet里对totalVotes做最小值1的兜底。EL表达式的key必须和setAttribute传进来的key完全一致大小写不同页面上就是空白这个坑排查起来最费时间。4. 部署与避坑从JDK配置到Tomcat启动的全过程记录4.1 环境准备版本搭配建议与关键配置项这个项目基于Java 8开发配套Tomcat 8.5和MySQL 5.7是最省事的组合。JDK 8和Tomcat 8.5的兼容性非常成熟不会遇到类文件版本错误。MySQL 8.0也能用但要换8.x驱动连接串加serverTimezoneAsia/Shanghai参数否则报时区错误。组件推荐版本注意事项JDK1.8别用JDK 17以上Java EE类库兼容性风险大Tomcat8.5.x解压版不用安装设置CATALINA_HOME即可MySQL5.78.0需要换驱动并配置时区IDEEclipse Java EE版自带Web部署工具导入工程最省事环境变量方面JAVA_HOME指向JDK目录CATALINA_HOME指向Tomcat解压目录命令行执行java -version能正常输出就算过。如果本机装了多个JDK检查PATH里最先命中哪个版本。我有一次部署时PATH指向了JDK 17Tomcat 8.5直接启动失败报的是UnsupportedClassVersionError错误信息跟类名完全对不上排查半天才发现是JDK版本问题。这是血泪经验部署前先确认java -version再动手。4.2 数据库初始化导入SQL脚本与配置JDBC连接拿到源码后先找到sql/init.sql在MySQL命令行或Navicat里执行。脚本包含建库和建表如果重复执行会在建表那一步报Table already exists我习惯在脚本开头加DROP TABLE IF EXISTS多次执行不报错。源码包自带的脚本如果没有这句自己补上就行。接着在工程里找到数据库连接配置文件一般是src目录下db.propertiesjdbc.drivercom.mysql.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/vote_db?useUnicodetruecharacterEncodingutf8 jdbc.usernameroot jdbc.password123456参数说明数据库装在远程服务器时localhost改成服务器IPpassword改成你本机MySQL的root密码。characterEncodingutf8必须保留否则页面写入的中文到数据库全变问号。JDBC连接通过DBUtil工具类封装静态代码块里加载驱动、根据properties创建连接工程里所有DAO都走这个类。如果改了密码不生效先看编译输出目录里的properties是不是旧内容有时候IDE没同步刷新跑的还是上一份配置。4.3 部署到Tomcat运行一个完整的启动检查清单在Eclipse里导入工程时选Import → Existing Projects into Workspace确认WebContent被识别为Web根目录。右键工程选Run On Server选择配置好的Tomcat 8.5Eclipse会自动编译、部署到Tomcat的webapps目录并启动。启动成功后浏览器访问http://localhost:8080/VoteSystem/第一次进入是登录页。这里要提醒URL里的VoteSystem大小写必须和WAR包名或工程名完全一致Windows本地大小写不敏感一旦部署到Linux服务器大小写不一致就404这是个非常隐蔽的问题。启动后建议做两个验证。第一个是直接访问一个Servlet映射地址比如http://localhost:8080/VoteSystem/LoginServlet看请求能不能被web.xml正确路由第二个是注册一个测试账号并发起投票验证整个链路通没通。只打开首页不算跑通投票能走完一轮才是真的没问题。4.4 常见问题与避坑记录这一节列出的问题全是我在本地和服务器上实际踩过的每条都按现象、原因、解决的顺序写排错时直接对照。问题一启动时报ClassNotFoundException: com.mysql.jdbc.Driver现象Tomcat启动时或访问任意页面抛这个异常找不到MySQL驱动类。 原因mysql-connector-java.jar没放进WEB-INF/lib只在IDE构建路径里。Eclipse编译能过Tomcat运行时找不到。 解决把Jar包复制到WebContent/WEB-INF/lib下刷新工程重新部署。问题二页面中文乱码数据库里全是问号现象用户名、投票标题在浏览器正常数据库里显示为????。 原因连接串缺characterEncodingutf8或者数据库/表字符集不是utf8mb4。 解决检查db.properties是否带编码参数同时确认建库语句里有DEFAULT CHARSETutf8mb4。两处有一处不对中文都会变问号。问题三连接MySQL 8.0报CommunicationsException现象Java连不上数据库报Communications link failure或时区错误。 原因MySQL 8.0驱动类变成com.mysql.cj.jdbc.Driver连接串必须指定serverTimezone。 解决驱动换8.xdb.properties里jdbc.driver改成com.mysql.cj.jdbc.DriverURL加上serverTimezoneAsia/Shanghai。问题四投票成功后票数没变现象vote_detail提交后提示成功结果页数字还和之前一样。 原因vote方法没提交事务或increaseCount写成了先SELECT再UPDATE并发时丢更新。 解决业务方法里显式conn.commit()UPDATE语句写成vote_count vote_count 1提交后SELECT验证计数。问题五登录后跳转页面又被踢回登录页现象登录成功进了首页点其他页面马上回到登录页像session根本没生效。 原因登录成功时用了request.setAttribute而不是session.setAttribute或web.xml的session超时配得太短。 解决用request.getSession().setAttribute保存登录态检查session-config的timeout默认30分钟不活动清掉是正常的。5. 二次开发四个改造方向让投票系统从课设走向生产5.1 改造一投票时间限制从硬编码变成配置化现在的投票状态用status字段管理需要管理员手动改。第一个改造是让列表页自动判断end_time超过结束时间直接显示已结束。可以在列表查询SQL加一个动态条件end_time小于当前时间的记录不展示投票主题自动下架不用人工干预。5.2 改造二加验证码抵御脚本刷票防重复目前靠登录态加IP双重校验脚本注册大量账号就能绕过去。改造成本最低的是在登录和投票两个入口加验证码。用java.awt包画一个简单数字验证码图片CaptchaServlet生成后把答案存Session提交时比对脚本批量投的成本会高很多。5.3 改造三把Session从单机搬到Redis集群单机部署Session没问题如果用Nginx做负载均衡请求分散到多个节点Session就各自为政了。改造方向是引入Spring Session或自己写过滤器把Session存进Redis实现多节点共享。对课设来说这个改造偏重但做线上投票活动时基本绕不开。5.4 改造四投票结果导出Excel活动结束后主办方通常要的是一份能存档的统计表而不是网页截图。后台加一个导出按钮Servlet端用POI生成xls文件把投票主题、选项得票数、投票记录明细写进单元格设置Content-Type为application/vnd.ms-excel浏览器自动下载。功能不复杂但使用频率很高。我自己做第一次改造时上来就加Redis和消息队列结果部署环境根本撑不起来。后来和某导师聊过一次他提醒我说投票系统这类业务先分清核心功能和加分项把防重复、时间控制、数据导出这三件套做扎实就已经超过大多数课堂项目了。从那以后我拿到任何源码都会先列一个改造优先级清单强制自己只做与业务强相关的改动。希望这篇拆解对你跑通和改造这个B/S投票系统有帮助。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?