简介:面向Java Web入门与课程设计场景的JSP企业电子投票系统源码包,基于JSP、Tomcat与JDK构建,适合需要完成投票类项目实训、毕业设计原型或后台管理系统练习的开发者参考。系统分为普通用户与管理员两类角色:用户登录后可对开放主题投票并查看已公布结果;管理员可撤销或恢复主题、永久删除主题、公布或隐藏结果、编辑与发布主题,以及新增或删除用户,前台与后台入口划分清晰。压缩包共64个文件,以37个JSP页面为核心,辅以CSS、JS、XML配置、SQL脚本、JAR依赖、图片截图和说明文档,整体约1003KB,便于快速部署与二次修改。已有393人学习,可作为理解JSP请求流转、会话控制、数据库读写和后台权限分层的完整案例。
1. 一个投票链接被刷 37 票之后,我拆了这套 JSP 电子投票系统
某公司行政把投票链接丢进群里,第二天统计结果比名单多了 37 票,查访问日志才发现有人刷新页面重复提交。这类内网投票的需求不复杂,难的是把“一人一票”真正落进数据库里。这套 JSP 电子投票系统就是典型的 Java Web 老技术栈:JSP 做页面、Servlet 接请求、JDBC 直连数据库,跑在 Tomcat 上,JDK 装好就能起。它适合三类人:行政或人事要临时搭一个内网投票;做课程设计需要一个完整链路;后端想找一个不依赖 Spring 的小项目练手。源码包不大,但登录、候选人管理、投票、结果统计这几条链路是完整的,拿来改比从零写快。
2. 架构与目录:JSP + Servlet + JDBC 的分层和文件落点
2.1 技术栈选型:为什么这套系统还在用 JSP 和 Servlet
这套系统的核心不是“新技术”,而是“少依赖”。JSP 负责服务端渲染,浏览器拿到的是已经拼好的 HTML;Servlet 当控制器,接表单、取参数、调 DAO、决定跳哪个页面;JavaBean 当模型,User、Candidate、VoteRecord 这些类只装数据;JDBC 直连数据库,没有 ORM 中间层。对于内网投票这种表结构简单、并发量不高、部署环境可能只有一台老服务器的场景,这种组合反而省事:不用配 Spring 上下文,不用管 Maven 依赖冲突,把 JDBC 驱动丢进WEB-INF/lib就能跑。
我一般会把 JSP 里的 Java 代码清到 Servlet 或 DAO 里,页面只留 JSTL 和 EL。老项目常见的问题是login.jsp顶部写一堆<% ... %>,查库、校验、跳转全混在页面里,改一个字段要翻三个文件。比较稳的分法是:JSP 只负责展示和表单提交,Servlet 负责参数校验和流程跳转,DAO 负责 SQL 和事务。这样后面加“投票截止时间”或者“按部门筛选”时,不用动页面结构。
JDK 和 Tomcat 的关系也要先理清:JDK 负责编译 Java 源码,Tomcat 负责跑编译后的 class 和 JSP。Servlet API 由 Tomcat 提供,不要把它打进WEB-INF/lib,否则容易出现类加载冲突。源码包里如果带的是javax.servlet命名空间的web.xml,就选对应大版本的 Tomcat;Tomcat 10 以后改成了jakarta.servlet,老项目直接扔进去会在启动时找不到类。这个坑后面第 5 章会展开。
<!-- 以源码包里的 web.xml 根节点为准,这里只列关键配置 --> <welcome-file-list> <welcome-file>login.jsp</welcome-file> </welcome-file-list> <session-config> <session-timeout>30</session-timeout> </session-config>welcome-file决定访问项目根路径时先打开哪个页面,投票系统一般放login.jsp,没登录的人不会直接看到投票页。session-timeout单位是分钟,30 表示半小时不操作就失效,内网投票可以短一点,比如 15 分钟,减少共用电脑带来的串号风险。如果项目用注解@WebServlet配了映射,就不要再在web.xml里重复配同一个 URL,重复映射在部分容器里会直接报启动错误。
2.2 目录结构:src、WebContent、WEB-INF 各放什么
拿到压缩包后先别急着导入 IDE,先看目录。JSP 项目常见的是 Eclipse 风格:src放 Java 源码,WebContent放页面和静态资源,WEB-INF放配置和依赖。结构大概是这样:
vote-system/ ├── src/ │ └── com/example/vote/ │ ├── bean/ User.java、Candidate.java、VoteRecord.java │ ├── dao/ UserDao.java、CandidateDao.java、VoteDao.java │ ├── servlet/ LoginServlet.java、VoteServlet.java、ResultServlet.java │ ├── filter/ EncodingFilter.java、AuthFilter.java │ └── util/ DBUtil.java ├── WebContent/ │ ├── WEB-INF/ │ │ ├── web.xml │ │ └── lib/ mysql-connector-java.jar、jstl.jar │ ├── css/ │ ├── js/ │ ├── login.jsp │ ├── vote.jsp │ ├── result.jsp │ └── admin/ │ ├── candidate_list.jsp │ └── user_list.jsp └── sql/ └── vote.sqlsrc下的包名不要随便改,Servlet 里的@WebServlet、JSP 里的useBean、web.xml里的<servlet-class>都靠包名定位。WebContent是 Web 根目录,浏览器能直接访问这里的 JSP、CSS、JS;WEB-INF是保护目录,浏览器访问不到,web.xml和 JDBC 驱动放这里最安全。sql/vote.sql是建库建表脚本,部署前先执行,不要等启动报错才去找。
常见翻车点是把 JSP 放进WEB-INF下,然后直接访问/WEB-INF/vote.jsp,容器会返回 404。WEB-INF下的 JSP 只能通过 Servlet 转发访问,不能直接敲 URL。另一个点是编译输出路径:Eclipse 默认把 class 输出到build/classes,部署时要把这些 class 放到WEB-INF/classes下;如果只复制了src,Tomcat 启动后访问 Servlet 会报ClassNotFoundException。
2.3 请求链路:从 login.jsp 到 VoteServlet 再到 result.jsp
一条完整的投票链路大概是:浏览器打开login.jsp,提交用户名密码到/login;LoginServlet查t_user,校验通过后把User对象塞进HttpSession,重定向到vote.jsp;用户在vote.jsp选候选人,提交到/vote;VoteServlet从 session 取用户,调VoteDao.castVote()写投票记录并更新候选人票数;投票成功后重定向到/result;ResultServlet查统计 SQL,转发到result.jsp渲染。
这里用重定向而不是转发,是为了避免刷新页面重复提交表单。登录成功后sendRedirect到投票页,浏览器地址栏会变,用户按 F5 不会再次提交登录表单。投票成功后也一样,先写库再重定向到结果页,结果页只读不写。这个细节看起来小,但投票系统里“刷新一次多一票”的玄学问题,一半以上是提交后直接forward造成的。
@WebServlet("/vote") public class VoteServlet extends HttpServlet { // 只保留映射,具体 doPost 在第 4 章展开 }@WebServlet("/vote")表示这个 Servlet 处理项目上下文下的/vote请求。如果项目上下文是/vote-system,完整访问路径就是http://localhost:8080/vote-system/vote。改路径时要么改注解,要么改web.xml的<url-pattern>,两边只留一处。JSP 表单的action最好用${pageContext.request.contextPath}/vote,不要写死/vote,否则项目改名后表单提交到根路径,直接 404。
3. 数据库与实体:三张表撑起用户、候选人和投票记录
3.1 表结构设计:t_user、t_candidate、t_vote_record
投票系统的表不用多,三张核心表就够:用户表、候选人表、投票记录表。用户表管登录和角色,候选人表管被投票的对象,投票记录表管“谁投了谁”。候选人表里会冗余一个vote_count字段,结果页直接读这个字段,不用每次对记录表做count(*)。记录表是明细,候选人表是汇总,两边靠事务保持一致。
CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, real_name VARCHAR(50), role TINYINT NOT NULL DEFAULT 0 COMMENT '0普通用户 1管理员', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0停用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_candidate ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, dept VARCHAR(100), intro VARCHAR(500), vote_count INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT '1可投 0停用' ); CREATE TABLE t_vote_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, candidate_id INT NOT NULL, vote_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_candidate (user_id, candidate_id) );t_user.password留 64 位,是为了后面改成 SHA-256 十六进制串;如果项目里现在是明文,先别急着上线,二次开发章节会给改造方向。role用 0/1 区分普通用户和管理员,管理员的候选人增删改入口放在/admin/下,普通用户只能投票和看结果。t_candidate.vote_count默认 0,更新时用vote_count = vote_count + 1,不要先查再写,否则并发下会丢票。
t_vote_record的唯一索引uk_user_candidate是防重复投票的最后一道锁。有人可能想“我在 Servlet 里先查一次记录,没有才插入”,这个逻辑在单线程下没问题,两个请求同时进来就会都查到“没投过”,然后都插入。唯一索引让第二次插入直接失败,程序捕获异常返回“已投过”,比在 Java 里做分布式锁简单得多。
3.2 唯一索引与事务:防重复投票的底线
投票写库要跨两张表:t_vote_record插一条明细,t_candidate把票数加一。这两步必须在一个事务里,否则插入成功、更新失败,记录表有票但候选人票数没涨,结果页对不上。JDBC 里把autoCommit关掉,先插记录,再更新候选人,更新影响行数不是 1 就回滚,两步都成功才提交。
-- 如果建表时漏了唯一索引,用这句补上 ALTER TABLE t_vote_record ADD UNIQUE KEY uk_user_candidate (user_id, candidate_id);唯一索引冲突时,不同数据库驱动抛出的异常不完全一样。MySQL 驱动常见的是SQLIntegrityConstraintViolationException,捕获它并返回false,业务层提示“你已经投过这位候选人了”。不要把所有SQLException都当成重复投票,连接断开、SQL 语法错误也走这个父类,全部吞掉会把真正的故障藏起来。我的习惯是只捕获唯一键冲突,其他异常继续往上抛,让 Servlet 记日志并给用户一个“系统繁忙”的兜底提示。
事务里还有一个顺序问题:先插记录再更新票数,还是先更新票数再插记录?我一般先插记录。因为唯一索引在记录表上,重复投票会在第一步就失败,不会走到更新票数;如果先更新票数,重复投票会先把候选人票数加一,再在插记录时失败回滚,虽然事务能兜住,但锁持有时间更长。内网投票并发不高,这个差别不明显,但养成习惯没坏处。
3.3 统计 SQL:结果页的百分比怎么算才不除零
结果页要显示每个候选人的票数和得票率。得票率不能在后端用 Java 循环算,直接让数据库算完更省事,也避免 JSP 里写一堆<% %>。统计 SQL 里用NULLIF处理总票数为 0 的情况,否则除以 0 会返回NULL或者报错,页面显示会很难看。
SELECT c.id, c.name, c.dept, c.vote_count, ROUND( c.vote_count * 100.0 / NULLIF((SELECT SUM(vote_count) FROM t_candidate WHERE status = 1), 0), 2 ) AS percent FROM t_candidate c WHERE c.status = 1 ORDER BY c.vote_count DESC, c.id ASC;c.vote_count * 100.0里的100.0是浮点数,不是整数 100,这样除法结果带小数,ROUND(..., 2)才能保留两位。NULLIF(总票数, 0)的意思是:总票数等于 0 时返回NULL,任何数除以NULL结果还是NULL,不会触发除零错误。JSP 里用<fmt:formatNumber value="${c.percent}" pattern="0.00"/>渲染,NULL会显示成空或者 0.00,看具体实现,最好在 SQL 外层再套一层COALESCE(..., 0)。
如果后面要加“按部门汇总”,可以在这个 SQL 外面再包一层GROUP BY dept,把候选人票数按部门求和。注意别在同一个查询里既算个人得票率又算部门得票率,口径容易混。结果页想展示两种维度,就拆成两个 DAO 方法,一个查候选人明细,一个查部门汇总,Servlet 分别塞进request,JSP 分两块表格渲染。
4. 核心代码拆解:登录、投票、结果页三段链路
4.1 登录与 Session:LoginServlet 的校验和跳转
登录是投票系统的入口,逻辑不复杂,但参数校验和跳转方式容易写错。下面这段是常见的 Servlet 写法:取参数、判空、查用户、判状态、写 session、重定向。密码比较这里先按明文写,是为了跟老项目对齐;二次开发时要把findByUsernameAndPassword改成“按用户名查用户,再在 Java 里做哈希比对”,不要继续把明文密码拼进 SQL。
@WebServlet("/login") public class LoginServlet extends HttpServlet { private final UserDao userDao = new UserDao(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); if (username == null || username.trim().isEmpty() || password == null || password.isEmpty()) { req.setAttribute("msg", "用户名和密码不能为空"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } User user = userDao.findByUsernameAndPassword(username.trim(), password); if (user == null) { req.setAttribute("msg", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } if (user.getStatus() != 1) { req.setAttribute("msg", "账号已被停用"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } HttpSession session = req.getSession(true); session.setAttribute("loginUser", user); session.setMaxInactiveInterval(30 * 60); resp.sendRedirect(req.getContextPath() + "/vote.jsp"); } }req.getParameter("username")取的是表单里name="username"的值,前后空格用trim()去掉,避免用户复制粘贴带空格登录失败。校验失败用forward回login.jsp,这样request里的msg能在页面显示;登录成功用sendRedirect,地址栏变成投票页,刷新不会重复提交。session.setMaxInactiveInterval(30 * 60)单位是秒,30 分钟失效,比web.xml里的分钟配置更直观。
UserDao.findByUsernameAndPassword里如果直接拼 SQL,会有注入风险。老项目常见写法是"select * from t_user where username='" + username + "' and password='" + password + "'",这种代码在课程设计里能跑,放到内网也不安全。改成PreparedStatement不费事,顺便把密码校验挪到 Java 层,后面换哈希算法时不用改 SQL。
4.2 投票事务:VoteDao.castVote 的插入、更新与回滚
投票的核心在 DAO,不在 Servlet。Servlet 只负责取当前登录用户和候选人 ID,真正的“防重复 + 票数加一”靠下面这段事务。代码里先关自动提交,插记录,更新候选人票数,更新影响行数不等于 1 就回滚,唯一键冲突返回false,其他 SQL 异常回滚后继续抛。
public boolean castVote(int userId, int candidateId) throws SQLException { Connection conn = null; PreparedStatement insertPs = null; PreparedStatement updatePs = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); insertPs = conn.prepareStatement( "INSERT INTO t_vote_record(user_id, candidate_id) VALUES(?, ?)"); insertPs.setInt(1, userId); insertPs.setInt(2, candidateId); insertPs.executeUpdate(); updatePs = conn.prepareStatement( "UPDATE t_candidate SET vote_count = vote_count + 1 " + "WHERE id = ? AND status = 1"); updatePs.setInt(1, candidateId); if (updatePs.executeUpdate() != 1) { conn.rollback(); return false; } conn.commit(); return true; } catch (SQLIntegrityConstraintViolationException dup) { if (conn != null) conn.rollback(); return false; } catch (SQLException e) { if (conn != null) conn.rollback(); throw e; } finally { DBUtil.close(updatePs, insertPs, conn); } }userId来自 session 里的loginUser.getId(),不要从表单隐藏域取,隐藏域能改,改了就能替别人投票。candidateId从表单取,必须做数字转换和存在性校验;如果候选人被停用,UPDATE ... WHERE status = 1影响行数是 0,事务回滚,记录表也不会留下脏数据。conn.setAutoCommit(false)之后,任何一步失败都要rollback(),否则连接归还到池里可能带着未提交事务。
DBUtil.close(updatePs, insertPs, conn)的关闭顺序是先关PreparedStatement再关Connection,参数用可变参数传进去,内部判空后逐个close()。不要用conn.close()之后还去关PreparedStatement,顺序反了会抛“连接已关闭”。连接池环境下close()是归还连接,不是物理关闭,但顺序仍然要遵守。
数据库连接串里有两个参数容易漏:useUnicode=true&characterEncoding=UTF-8管字符集,serverTimezone=Asia/Shanghai管时区。MySQL 8 的驱动不配时区可能报The server time zone value ... is unrecognized,投票时间会差几个小时。驱动类名也要跟 jar 对上:老驱动是com.mysql.jdbc.Driver,新驱动是com.mysql.cj.jdbc.Driver,写错就是ClassNotFoundException。
4.3 结果页渲染:JSTL 遍历候选人和格式化百分比
结果页只读,不再写库,JSP 里用 JSTL 遍历candidateList,用fmt格式化百分比。页头先把contentType和pageEncoding设成 UTF-8,不然中文候选人名字会变成问号。表格里不要写<% for(...) %>,用<c:forEach>更干净,后面加“按部门分组”时也好改。
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <table> <tr> <th>候选人</th> <th>部门</th> <th>票数</th> <th>得票率</th> </tr> <c:forEach items="${candidateList}" var="c"> <tr> <td>${c.name}</td> <td>${c.dept}</td> <td>${c.voteCount}</td> <td><fmt:formatNumber value="${c.percent}" pattern="0.00"/>%</td> </tr> </c:forEach> </table>${candidateList}是ResultServlet里request.setAttribute("candidateList", list)塞进去的,名字要对上。${c.name}对应Candidate类的getName(),EL 会自动找 getter,字段名大小写别写错。fmt:formatNumber的pattern="0.00"表示保留两位小数,没有小数就补零;如果percent是null,页面会显示空,最好在 SQL 里用COALESCE兜底。
JSTL 需要jstl.jar和standard.jar(老版本)或者合并后的jstl.jar,具体看源码包里的WEB-INF/lib。只放一个不对应的 jar,页面会报The absolute uri: http://java.sun.com/jsp/jstl/core cannot be resolved。这个报错不是 JSP 语法问题,是标签库没找到,先看 lib 目录,再看web.xml里有没有配错 taglib。
5. 避坑与排查:部署 JSP 投票系统时最容易翻车的 5 个地方
5.1 部署前的三件套核对:JDK、Tomcat、JDBC 驱动
启动之前先核对三样东西:JDK 能不能编译、Tomcat 能不能跑、JDBC 驱动能不能连库。先在命令行看 Java 版本和环境变量,别只信 IDE 里配的 JDK,Tomcat 启动用的是JAVA_HOME指向的那个。
java -version javac -version echo $JAVA_HOMEWindows 下把echo $JAVA_HOME换成echo %JAVA_HOME%。java -version和javac -version版本差太多,说明 PATH 里混了别的 JRE,编译能过、运行报UnsupportedClassVersionError。Tomcat 启动脚本会读JAVA_HOME,这个变量没配或者指向 JRE 而不是 JDK,JSP 编译会失败。
数据库这边先单独用客户端连一次,确认库存在、账号能登录、表已建。然后把 JDBC 驱动放进WEB-INF/lib,不要放在 Tomcat 的lib下,除非多个项目共用同一个驱动。驱动 jar 的名字和类名要匹配,连接串里的库名、用户名、密码跟DBUtil里保持一致。改完DBUtil记得重新编译,只改.java不重新编译,Tomcat 跑的还是旧 class。
5.2 五个高频踩坑记录:现象、原因、解决
现象一:访问项目根路径 404,但 Tomcat 首页能打开。原因通常是 context path 不对,或者项目没放进webapps。源码包解压后目录叫vote-system,访问路径就是http://localhost:8080/vote-system/,少写一段就 404。解决:看 Tomcat 的logs/localhost.*.log,里面会打印实际部署的 context;IDE 里部署时检查 Deployment Assembly 有没有把WebContent映射到/。
现象二:启动报ClassNotFoundException: com.mysql.cj.jdbc.Driver。原因有两个:驱动 jar 没放进WEB-INF/lib,或者驱动版本和类名不匹配。老驱动用com.mysql.jdbc.Driver,新驱动用com.mysql.cj.jdbc.Driver,写错就找不到。解决:看WEB-INF/lib里 jar 的实际版本,改DBUtil的Class.forName;连接串加上serverTimezone=Asia/Shanghai,否则 MySQL 8 可能报时区错误。
现象三:登录页中文正常,投票页候选人名字乱码。原因可能是 POST 请求没设编码、JSP 页面pageEncoding不对、数据库连接串没带字符集。解决:加一个EncodingFilter统一设req.setCharacterEncoding("UTF-8")和resp.setContentType("text/html;charset=UTF-8");JSP 页头写contentType="text/html;charset=UTF-8" pageEncoding="UTF-8";JDBC URL 带useUnicode=true&characterEncoding=UTF-8。老 Tomcat 的 GET 请求还要在server.xml的 Connector 上加URIEncoding="UTF-8"。
现象四:同一个人换浏览器还能再投一票。原因通常是只在 JSP 里用 JavaScript 禁用按钮,或者 Servlet 里“先查再插”有并发窗口。解决:数据库唯一索引必须建,DAO 捕获唯一键冲突返回“已投过”;不要用 session 里存一个voted=true当唯一依据,换浏览器或清 session 就绕过去了。投票按钮的disabled只是体验优化,不是安全边界。
现象五:Tomcat 10 启动报javax.servlet相关类找不到。原因是 Tomcat 10 以后把 Servlet API 的包名从javax.servlet改成了jakarta.servlet,老 JSP 项目里的import javax.servlet.*和web.xml命名空间对不上。解决:换对应大版本的 Tomcat 跑老项目,或者把源码里的javax.servlet全量替换成jakarta.servlet并调整依赖。这个改动不是改一个 import 就完事,过滤器、监听器、JSP 标签库都可能受影响,先备份再动。
5.3 日志与断点:出问题先看哪几个文件
Tomcat 排错先看logs/catalina.out和logs/localhost.*.log。catalina.out是标准输出,启动异常、JSP 编译错误、未捕获异常都会进去;localhost.*.log更偏向应用级日志,Servlet 里log()打的内容常在这里。看异常不要从最后一行倒着读,先找第一个Caused by,那才是根因,后面的往往是连锁反应。
数据库问题看SQLException的 message 和 SQLState。表不存在、字段不存在、唯一键冲突、连接超时,报错信息不一样。投票写库失败时,先在 DAO 的catch里把userId、candidateId和 SQL 打出来,确认参数是不是0或者负数。JSP 报错看org.apache.jasper开头的行,通常是标签库没引对或者 EL 属性名写错。
断点调试时,Servlet 的doPost第一行打个断点,看request.getParameterMap()里有没有值。如果参数是null,先检查表单name和getParameter的字符串是否一致,再看表单enctype是不是multipart/form-data。投票系统不需要上传文件,enctype保持默认的application/x-www-form-urlencoded,改成 multipart 后getParameter取不到普通字段,这个坑很隐蔽。
6. 二次开发:把单议题投票改成多议题投票的改造路径
单议题投票跑通之后,最常见的改造是“一次投多个议题”,比如优秀员工评选和部门满意度调查同时进行。改法不用推倒重来,在现有三张表上再加两张:议题表和选项表,投票记录表加topic_id,唯一索引从“用户 + 候选人”改成“用户 + 议题”。这样同一个人可以在不同议题各投一票,同一个议题只能投一次。
CREATE TABLE t_topic ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, start_time DATETIME, end_time DATETIME, status TINYINT NOT NULL DEFAULT 1 ); ALTER TABLE t_vote_record ADD COLUMN topic_id INT NOT NULL DEFAULT 0; ALTER TABLE t_vote_record DROP INDEX uk_user_candidate; ALTER TABLE t_vote_record ADD UNIQUE KEY uk_user_topic (user_id, topic_id);改造点集中在三处:VoteServlet多接一个topicId,投票前校验议题在有效期内;VoteDao.castVote的插入语句多
本文还有配套的精品资源,点击获取