拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JavaWeb毕业论文选题系统实战:从数据库设计到Servlet抢题避坑

JavaWeb毕业论文选题系统实战:从数据库设计到Servlet抢题避坑

简介:这是一套面向高校计算机相关专业毕业设计场景的JavaWeb毕业论文选题系统完整源码,适合正在准备毕设的学生、需要课程设计参考的开发者以及JavaWeb入门到进阶的学习者,用于解决选题流程管理、师生双向选择与论文资料归档等实际问题。压缩包共795个文件,约58.68MB,以js、css、png等前端静态资源为主,配合62个jsp页面、22个java源文件、66个class编译文件与37个jar依赖包,另有sql建库脚本、json配置及字体图标资源,构成可直接导入IDEA、基于MySQL5.7与JDK1.8运行的前后端一体项目。系统按角色划分权限:管理员负责公告、教师、学生与论文管理;教师可添加论文、查看我的论文与学生信息、处理消息;学生端提供个人资料、论文列表、论文动态与导师列表。目前已有2511人学习下载,读者可据此快速理解JSP+Servlet分层结构、数据库表设计与角色权限控制思路,并在此基础上完成二次开发或功能扩展。

1. 从零手搓一个毕业论文选题系统:JavaWeb 课设到底该怎么做才不会翻车

每年到了毕设季,计算机专业的学生都会面临同一个灵魂拷问:选题系统到底选什么题目、用什么技术栈、怎么在两周内跑通一个能演示、能答辩、还能写进论文的 JavaWeb 项目。我见过太多人一上来就冲 SpringBoot + Vue 前后端分离,结果环境配了三天,代码没写几行,最后答辩时连数据库都连不上。其实对于「基于 JavaWeb 的毕业论文选题系统」这个题目,最稳的路线是先用 Servlet + JSP + MySQL 把核心业务跑通,再考虑要不要套 SpringBoot 做加分项。这个系统本质上解决的是三个角色之间的信息匹配问题:学生要选到心仪的题目,教师要把控题目质量和名额,管理员要统筹整个选题流程。它不复杂,但涉及用户权限、并发抢题、状态流转这些经典 Web 开发场景,非常适合作为课设或毕设来展示你对 JavaWeb 全栈的理解。如果你正在找 javaweb 项目完整案例、想搞清楚 idea 运行 javaweb 项目配置,或者需要一份能直接抄作业的实现路径,接下来的内容会按「建表 → 搭框架 → 写业务 → 避坑 → 进阶」的顺序讲透。

2. 选题系统的数据库设计与环境搭建:从 ER 图到能跑的 Tomcat

2.1 三张核心表撑起整个选题流程

很多同学拿到题目第一反应是打开 IDEA 新建项目,这是典型的顺序错误。选题系统的业务逻辑全部围绕「谁选了哪个题、这个题还剩几个名额、选完之后状态怎么变」展开,如果表结构没设计好,后面写代码时会在各种 JOIN 和状态判断里反复翻车。我一般会先画 ER 图,把实体和关系理清楚再动手建表。

核心实体只有四个:用户(学生/教师/管理员)、课题、选题记录、公告。其中用户表用角色字段区分身份,课题表关联教师 ID,选题记录表关联学生 ID 和课题 ID。这里有一个关键设计决策:名额扣减是放在课题表里用字段控制,还是通过选题记录表实时统计?两种做法各有适用场景,我后面会详细对比。

先看建表 SQL,这是整个系统的地基:

-- 用户表:用 role 字段区分三种身份,避免建三张表 CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '学号/工号', `password` VARCHAR(64) NOT NULL COMMENT 'MD5加密存储', `real_name` VARCHAR(50) NOT NULL, `role` TINYINT NOT NULL DEFAULT 0 COMMENT '0学生 1教师 2管理员', `college` VARCHAR(100) DEFAULT NULL COMMENT '学院', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 课题表:max_count 控制名额上限,current_count 记录已选人数 CREATE TABLE `topic` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `title` VARCHAR(200) NOT NULL, `description` TEXT COMMENT '课题描述', `teacher_id` INT NOT NULL COMMENT '出题教师', `max_count` INT NOT NULL DEFAULT 1 COMMENT '最大可选人数', `current_count` INT NOT NULL DEFAULT 0 COMMENT '当前已选人数', `status` TINYINT DEFAULT 0 COMMENT '0待审核 1已通过 2已驳回 3已满', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`teacher_id`) REFERENCES `user`(`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 选题记录表:记录学生和课题的绑定关系 CREATE TABLE `selection` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `student_id` INT NOT NULL, `topic_id` INT NOT NULL, `status` TINYINT DEFAULT 0 COMMENT '0待确认 1已确认 2已拒绝', `select_time` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_student` (`student_id`) COMMENT '一个学生只能选一个题', FOREIGN KEY (`student_id`) REFERENCES `user`(`id`), FOREIGN KEY (`topic_id`) REFERENCES `topic`(`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这三张表的逻辑关系是:教师创建课题时current_count为 0,学生选题成功后该字段加 1,当current_count >= max_count时课题状态自动变为「已满」。selection表上的唯一索引uk_student是防止一个学生重复选题的最后一道防线,这个约束比在 Java 代码里写 if-else 判断可靠得多。

参数说明方面,password字段长度设为 64 是为了容纳 MD5 加密后的十六进制字符串(32 位)或 SHA-256(64 位),如果你打算用 BCrypt 需要调整到 60 以上。role用 TINYINT 而不是 ENUM 是为了后续扩展角色时不用改表结构。字符集统一用utf8mb4而不是utf8,因为课题标题里可能出现生僻字或特殊符号。

2.2 IDEA 里跑通第一个 Servlet 的最小配置

环境搭建是新手最容易卡住的地方。我见过太多人因为 Tomcat 版本和 Servlet API 版本不匹配,在ClassNotFoundException上耗掉一整天。这里给出一条经过验证的最小路径:JDK 8 或 11 + Tomcat 8.5 或 9.0 + Servlet 3.1 + MySQL 5.7 或 8.0。不要一上来就追 JDK 17 和 Tomcat 10,后者把javax.servlet包名改成了jakarta.servlet,网上大部分教程和代码片段直接不能用。

在 IDEA 里创建项目时选「Java Enterprise」,勾选「Web Application」,然后配置 Tomcat 服务器。关键步骤是:File → Project Structure → Modules → 确认 Web 资源目录指向src/main/webapp,然后在 Artifacts 里添加 Web Application: Exploded。很多人项目能编译但访问 404,就是因为 Artifacts 没配好。

下面是一个最小的 Servlet 示例,用来验证环境是否跑通:

// 访问 http://localhost:8080/hello 应返回一行文字 @WebServlet("/hello") public class HelloServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType("text/html;charset=UTF-8"); resp.getWriter().write("选题系统环境正常"); } }

这段代码用了 Servlet 3.0 的注解方式注册,不需要在web.xml里配置<servlet-mapping>。如果你用的是 Servlet 2.5 或更早版本,注解不生效,必须在web.xml里手动声明。判断方法很简单:看web.xml的version属性,3.0 以上才支持注解。resp.setContentType里的charset=UTF-8必须加,否则中文会变成乱码,这是血泪经验。

数据库连接方面,我建议在src/main/resources下放一个db.properties,用 JDBC 原生方式连接,而不是一上来就上连接池。原因很简单:课设阶段并发量极低,连接池带来的复杂度(配置参数、依赖引入、连接泄漏排查)远大于收益。等你把业务跑通了,再换成 Druid 或 HikariCP 作为优化点写进论文。

# db.properties jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/topic_system?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 jdbc.username=root jdbc.password=你的密码

注意serverTimezone参数,MySQL 8.0 的驱动如果不指定时区,会报The server time zone value '...' is unrecognized错误。useSSL=false在本地开发时加上可以避免控制台一堆警告。这两个参数是新手翻车的高频点。

3. 用 Servlet + JSP 实现选题核心业务:登录、出题、抢题、审核

3.1 登录与权限拦截:一个 Filter 搞定三种角色

选题系统的第一个业务闭环是登录。三种角色登录后看到的菜单和能执行的操作完全不同,如果每个 Servlet 里都写一遍权限判断,代码会变得又臭又长。标准做法是用一个AuthFilter统一拦截,根据 session 中的角色信息决定放行还是跳转。

@WebFilter("/*") public class AuthFilter implements Filter { // 白名单:登录页、登录接口、静态资源不需要拦截 private static final List<String> WHITE_LIST = Arrays.asList( "/login.jsp", "/login", "/css/", "/js/", "/images/" ); @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; String uri = request.getRequestURI(); String path = uri.substring(request.getContextPath().length()); // 白名单直接放行 for (String white : WHITE_LIST) { if (path.startsWith(white)) { chain.doFilter(req, resp); return; } } // 检查 session 中是否有登录用户 HttpSession session = request.getSession(false); if (session == null || session.getAttribute("user") == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } // 角色权限校验:管理员页面只允许 role=2 访问 User user = (User) session.getAttribute("user"); if (path.startsWith("/admin/") && user.getRole() != 2) { response.sendError(403, "无权访问"); return; } if (path.startsWith("/teacher/") && user.getRole() != 1) { response.sendError(403, "无权访问"); return; } chain.doFilter(req, resp); } }

这段 Filter 的逻辑分三层:白名单放行、登录校验、角色校验。request.getSession(false)表示如果 session 不存在就返回 null,而不是自动创建一个新的,这样可以避免为未登录用户创建无用 session。角色校验用路径前缀判断,/admin/下的所有资源只有管理员能访问,/teacher/同理。学生页面没有单独前缀,因为学生是默认角色,登录后即可访问。

参数方面,白名单列表需要根据你的项目实际目录结构调整。如果你把 JSP 放在/WEB-INF/下(推荐做法,防止直接 URL 访问),那么白名单里不需要加 JSP 路径,因为/WEB-INF/下的资源本来就不能通过 URL 直接访问。登录接口的路径也要根据@WebServlet的配置来写。

登录成功后的 session 存储很关键。不要把整个 User 对象(包含密码)塞进 session,应该只存 id、username、realName、role 这四个字段。我一般会新建一个SessionUser类专门用于 session 存储,和数据库实体类分开,避免不小心把密码字段序列化到前端。

3.2 学生抢题的高并发陷阱:乐观锁与唯一索引双保险

选题系统最核心也最容易出问题的环节是「抢题」。假设一个课题max_count=3,当前current_count=2,此时两个学生同时点击「选择该课题」,如果不做并发控制,两个请求都读到current_count=2,都判断「未满」,然后都执行UPDATE topic SET current_count=3,最终结果是 3 个名额选了 4 个人,数据不一致。

这个问题的标准解法有两种:悲观锁(SELECT ... FOR UPDATE)和乐观锁(版本号或条件更新)。在课设场景下,我推荐乐观锁,因为实现简单且不需要事务长时间持有锁。具体做法是在 UPDATE 语句的 WHERE 条件里加上current_count < max_count:

public boolean selectTopic(int studentId, int topicId) { Connection conn = null; PreparedStatement ps = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 第一步:检查学生是否已选过题 ps = conn.prepareStatement( "SELECT id FROM selection WHERE student_id = ?"); ps.setInt(1, studentId); if (ps.executeQuery().next()) { conn.rollback(); return false; // 已选过,直接返回 } // 第二步:乐观锁更新名额,WHERE 条件保证不会超卖 ps = conn.prepareStatement( "UPDATE topic SET current_count = current_count + 1 " + "WHERE id = ? AND current_count < max_count AND status = 1"); ps.setInt(1, topicId); int affected = ps.executeUpdate(); if (affected == 0) { conn.rollback(); return false; // 名额已满或课题未通过审核 } // 第三步:插入选题记录 ps = conn.prepareStatement( "INSERT INTO selection(student_id, topic_id, status) VALUES(?, ?, 0)"); ps.setInt(1, studentId); ps.setInt(2, topicId); ps.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { if (conn != null) try { conn.rollback(); } catch (SQLException ex) {} e.printStackTrace(); return false; } finally { DBUtil.close(conn, ps, null); } }

这段代码的关键在于第二步的UPDATE ... WHERE current_count < max_count。数据库在执行 UPDATE 时会自动加行锁,两个并发请求只有一个能成功更新(affected=1),另一个会得到 affected=0。这就是乐观锁的核心思想:不提前锁数据,而是在更新时检查条件是否仍然满足。

参数说明:status = 1这个条件确保只有审核通过的课题才能被选。conn.setAutoCommit(false)开启事务后,三步操作要么全部成功要么全部回滚。DBUtil.close需要在 finally 块中调用,否则连接泄漏跑几十次就会报Too many connections。

还有一个隐藏的坑:selection表上的唯一索引uk_student会在并发插入时抛出DuplicateKeyException。这其实是好事,它是防止重复选题的最后一道防线。在代码里 catch 这个异常并返回 false 即可,不需要额外处理。

3.3 教师出题与管理员审核的状态流转

课题从创建到可被选择,中间要经过「待审核 → 已通过 / 已驳回」的状态流转。这个流程看似简单,但如果不把状态机理清楚,后面会出现「已驳回的课题还能被学生看到」或者「已满的课题教师还能修改名额」这类逻辑漏洞。

我一般用一张状态流转表来约束所有操作:

当前状态允许操作操作后状态执行角色
待审核(0)管理员通过已通过(1)管理员
待审核(0)管理员驳回已驳回(2)管理员
已通过(1)学生选题已满(3)学生
已通过(1)教师修改名额已通过(1)教师
已驳回(2)教师重新编辑提交待审核(0)教师

这张表的价值在于:每次写业务代码前先查表,确认当前状态是否允许该操作。比如教师想修改一个「已满」课题的名额,按照状态机是不允许的,因为已经有学生选了,改名额会影响已选学生的权益。如果业务上确实需要,应该先让管理员介入处理。

教师出题的 Servlet 核心逻辑:

@WebServlet("/teacher/addTopic") public class AddTopicServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); SessionUser user = (SessionUser) req.getSession().getAttribute("user"); String title = req.getParameter("title"); String description = req.getParameter("description"); int maxCount = Integer.parseInt(req.getParameter("maxCount")); // 参数校验:标题不能为空,名额至少为1 if (title == null || title.trim().isEmpty() || maxCount < 1) { req.setAttribute("error", "课题标题不能为空,名额至少为1"); req.getRequestDispatcher("/teacher/addTopic.jsp").forward(req, resp); return; } Topic topic = new Topic(); topic.setTitle(title.trim()); topic.setDescription(description); topic.setTeacherId(user.getId()); topic.setMaxCount(maxCount); topic.setStatus(0); // 待审核 new TopicDao().insert(topic); resp.sendRedirect(req.getContextPath() + "/teacher/topicList"); } }

req.setCharacterEncoding("UTF-8")必须放在获取参数之前,否则 POST 请求中的中文会乱码。maxCount从字符串转 int 时如果用户输入了非数字字符会抛NumberFormatException,生产环境应该用 try-catch 包裹并给出友好提示。status硬编码为 0 表示新创建的课题一律待审核,这个规则不能由前端传入,否则教师可以绕过审核直接发布课题。

管理员审核的代码更简单,就是一个 UPDATE 语句改状态,但要注意审核通过后要检查课题的max_count是否合理(比如不能超过某个上限),以及同一教师是否有重复标题的课题。这些校验规则可以根据学校实际要求调整。

4. 选题系统开发中最容易踩的五个坑:从乱码到事务失效

4.1 中文乱码:GET 和 POST 的处理方式不一样

现象:学生姓名、课题标题在页面上显示为「??????」或「推è�」这类乱码。

原因:Tomcat 8 及以上版本默认 URI 编码是 UTF-8,但 GET 请求的 query string 解码和 POST 请求的 body 解码走的是不同配置。很多人只在 Servlet 里写了req.setCharacterEncoding("UTF-8"),这只对 POST 生效,GET 请求中的中文参数仍然会乱码。

解决:分两处处理。POST 请求在 Servlet 第一行调用req.setCharacterEncoding("UTF-8")。GET 请求需要在 Tomcat 的server.xml中给 Connector 添加URIEncoding="UTF-8",或者在代码里手动对参数做new String(param.getBytes("ISO-8859-1"), "UTF-8")转换。更彻底的做法是统一用 POST 提交表单,避免 GET 传中文。JSP 页面头部也要加<%@ page contentType="text/html;charset=UTF-8" language="java" %>,响应头才会带正确的字符集。

4.2 事务失效:自动提交没关,回滚成了摆设

现象:抢题代码里明明写了conn.rollback(),但名额还是被扣了,或者选题记录插入失败后名额没有恢复。

原因:MySQL 的 JDBC 驱动默认autoCommit=true,每条 SQL 执行后自动提交。如果你没有显式调用conn.setAutoCommit(false),那么rollback()不会生效,因为前面的 UPDATE 已经提交了。

解决:在获取连接后、执行任何 SQL 之前,第一件事就是conn.setAutoCommit(false)。同时确保commit()和rollback()在正确的分支里调用,finally块里只负责关闭资源。还有一个容易忽略的点:如果用了连接池,连接归还时 autoCommit 状态可能被重置,所以每次从池里取连接都要重新设置。

4.3 JSP 里写 Java 代码:后期维护的噩梦

现象:项目初期为了图快,在 JSP 里直接写<% Class.forName("com.mysql.jdbc.Driver"); %>和数据库查询逻辑,结果页面加载慢、报错信息不明确、改一个字段要翻三个文件。

原因:JSP 本质是 Servlet,在里面写 Java 代码虽然能跑,但违反了 MVC 分层原则。数据库连接、业务逻辑、页面渲染混在一起,调试时根本分不清是 SQL 错了还是 HTML 标签没闭合。

解决:JSP 只负责展示,用 EL 表达式${user.realName}和 JSTL 标签<c:forEach>替代<% %>脚本片段。数据由 Servlet 通过req.setAttribute传入,JSP 只做渲染。如果时间充裕,可以进一步用 Servlet + JSON + AJAX 的方式做前后端分离,但课设阶段 EL + JSTL 已经足够。

4.4 数据库连接泄漏:跑几十次就报 Too many connections

现象:系统刚启动时正常,操作十几次后开始报Communications link failure或Too many connections。

原因:每次数据库操作都DriverManager.getConnection()新建连接,但用完没有close()。MySQL 默认最大连接数是 151,每个连接占用一个线程和内存,泄漏几十个后数据库就拒绝新连接了。

解决:在finally块中按「ResultSet → PreparedStatement → Connection」的顺序关闭,每个 close 都要单独 try-catch,因为前一个关闭失败不能影响后一个。更好的做法是引入 Druid 连接池,它自带泄漏检测功能,可以配置removeAbandoned=true和removeAbandonedTimeout=180,超过 3 分钟未归还的连接会被强制回收并打印堆栈,方便定位泄漏点。

4.5 选题状态不同步:学生看到的和数据库不一致

现象:学生 A 选了课题 X,页面显示「已选」,但教师端看到的课题 X 的已选人数还是 0。

原因:更新topic.current_count和插入selection记录不在同一个事务里,或者 JSP 页面用了缓存数据。还有一种情况是学生选题成功后没有刷新教师端的页面,教师看到的是旧数据。

解决:确保两个操作在同一个事务中,要么都成功要么都回滚。教师端查询课题列表时,已选人数应该实时从selection表 COUNT 出来,而不是直接读topic.current_count字段(虽然这个字段在事务保证下是一致的,但多一层校验更保险)。如果用了 AJAX 轮询,注意设置cache: false避免浏览器缓存 GET 请求结果。

5. 从课设到毕设加分项:用 SpringBoot 重构选题系统的三个切入点

如果你已经把 Servlet + JSP 版本跑通了,答辩时想展示更多技术深度,可以考虑用 SpringBoot 重构。但不要全部推倒重来,时间不允许,而且风险极高。我的建议是选三个切入点做增量改造,既能体现技术栈升级,又不影响核心业务稳定性。

第一个切入点是数据访问层。把原生的 JDBC 替换成 MyBatis 或 Spring Data JPA,用注解或 XML 映射 SQL。改造后TopicDao里的selectTopic方法可以简化为一个@Update注解:

@Mapper public interface TopicMapper { @Update("UPDATE topic SET current_count = current_count + 1 " + "WHERE id = #{topicId} AND current_count < max_count AND status = 1") int incrementCount(@Param("topicId") int topicId); }

这段代码和之前的 JDBC 版本逻辑完全一致,但省去了连接获取、事务管理、资源关闭的样板代码。SpringBoot 的@Transactional注解可以替代手动setAutoCommit,声明式事务让代码更干净。参数说明:#{topicId}是 MyBatis 的占位符,对应@Param注解声明的参数名。返回值int表示受影响行数,和 JDBC 的executeUpdate()一致。

第二个切入点是接口层。把原来的 Servlet 替换成@RestController,返回 JSON 数据而不是 JSP 页面。前端可以用 Vue 或 React 单独开发,也可以继续用 JSP 但通过 AJAX 调用接口。这样改造后,同一个后端可以同时支持网页端和移动端,答辩时是一个很好的亮点。

第三个切入点是权限控制。用 Spring Security 或 Shiro 替代自定义的AuthFilter,支持注解式权限声明:

@PreAuthorize("hasRole('ADMIN')") @PostMapping("/admin/approve/{topicId}") public Result approveTopic(@PathVariable int topicId) { topicService.approve(topicId); return Result.success(); }

@PreAuthorize注解在方法执行前检查当前用户是否有 ADMIN 角色,没有则抛出 403 异常。这比在 Filter 里用路径前缀判断更精细,可以控制到具体方法级别。

改造顺序建议:先换数据访问层,跑通所有单元测试;再换接口层,保持前端不变;最后换权限框架。每换一层都要回归测试,确保选题、审核、查询这些核心流程不受影响。如果时间只够做一件事,我建议优先换数据访问层,因为 MyBatis 的 SQL 映射和 JDBC 最接近,学习成本最低,而且论文里可以写「从原生 JDBC 到 ORM 框架的演进」这个对比分析。

最后说一个我自己的习惯:每次改完一个模块,我都会用 Postman 或 curl 把相关接口全部跑一遍,确认返回值和数据库状态都符合预期,再进入下一个模块。这个习惯帮我省掉了无数次「改 A 坏 B」的后悔药。选题系统虽然不复杂,但状态流转和权限交叉的地方特别多,手动回归测试比写自动化测试脚本更快更直接。希望帮到你。

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

返回列表