好,接到一个非常多见的JavaWeb课设题目——在线电影票购买系统。这个题目我过去几年里见过不少同学做,有的拿来当毕业设计,有的用来做课程设计,也有单纯为了巩固Servlet、JSP、数据库这套老牌技术栈而练手。往往看起来简单,但要真正把购票流程、座位状态、订单状态这些逻辑理清楚,里面坑并不少。这篇就围绕这个系统的设计与实现,把从业务梳理、数据库建模、接口设计到具体编码、部署排错的全过程拆开讲。
1. 项目到底做什么:业务范围与用户场景
1.1 这个系统解决什么问题
先说清楚这个系统是干嘛的。传统购票方式需要到影院柜台排队,选片、选场次、选座位全靠人工沟通,效率低且容易出错。在线电影票购买系统要解决的就是把这些流程搬到网上:用户打开页面,能看到正在上映的电影列表,点进去查看某一部电影的排片场次,选择一个场次后进入选座界面,点击空闲座位完成购票,最后在“我的订单”里看到自己买过的票。整个链路看似简单,但它是典型的“数据一致性敏感”业务——座位不能卖重,订单不能丢,扣款和锁座必须同步完成。
从项目分级来看,这类系统通常划分成两个端:前台用户端和后台管理端。前台面向普通用户,提供注册、登录、浏览电影、查看场次、选座、下单支付(多数课设里用模拟支付)、查看订单这几大功能。后台面向影院运营人员,提供电影信息管理、场次排片、订单查询与退款处理、基础数据统计等功能。
1.2 两类角色与核心流程
角色拆解清楚,后续的表设计和代码结构才不会有歧义。
- 用户端:不登录只能浏览。到选座下单那一步必须登录。登录后生成订单,订单关联到某个场次、某些座位、某个用户。
- 管理端:管理员登录后进入后台。可以新增、上下架电影,为电影添加场次(绑定影院厅、时间、票价),查看所有订单、处理退款。
核心业务流程用一句话串起来:用户选中某场次 -> 系统加载该场次座位图 -> 用户点选空闲座位 -> 提交订单 -> 后台锁定座位并生成订单记录 -> 模拟支付成功 -> 更新订单状态 -> 用户可在订单列表查看购票详情。
这里的关键点在于“锁定座位”和“生成订单”必须是原子操作。在我见过的很多同学实现里,这两个动作被拆成两条SQL分开执行,结果就埋下了超卖和状态不一致的隐患。后面我会专门讲这个问题。
这个项目适合谁来参考?如果你正在做JavaWeb课设、毕业设计,或者想巩固Servlet、JSP、JDBC、MySQL这套组合,我非常建议跟着做一遍。它麻雀虽小五脏俱全,覆盖了JavaWeb最常见的知识点:三层架构、Session管理、过滤器、分页、事务、SQL关联查询、文件上传(海报)等。
2. 技术选型与架构设计:为什么用这套JavaWeb组合
2.1 技术栈构成与选择理由
先列一下我推荐的标准技术组合:
- 后端语言与运行环境:Java 8 + Tomcat 8.5
- 核心组件:Servlet + JSP + EL + JSTL
- 数据库:MySQL 5.7(或8.0,注意驱动差异)
- 持久层:原生JDBC(也可引入DBUtils或MyBatis)
- 前端:JSP页面 + CSS + JavaScript / JQuery + Ajax
- 构建工具:Maven(如果学校不允许,直接手动放jar包也可以)
这个组合的核心逻辑是角色分工明确。Servlet充当控制器,接收请求、调用业务逻辑、跳转页面;JSP充当视图,负责渲染数据;JDBC负责数据库读写。三层架构(表现层、业务层、持久层)各司其职,代码维护起来不混乱。
有些同学会问,现在Spring Boot这么流行,为什么课设还要求用JavaWeb?两个原因。第一,很多高校课程大纲还停留在传统JavaWeb阶段,考核点就是Servlet和JSP,直接上Spring Boot会被认为偏离题目要求。第二,从学习角度讲,Servlet是理解HTTP请求处理流程的最小载体,直接写Servlet能让你真正明白请求从浏览器到服务器再到数据库的完整链路,之后再迁移到框架会容易很多。
2.2 三层架构与项目目录结构
网上不少代码把业务逻辑直接写在Servlet里,注册、登录、下单全堆在一个类里,功能能跑但几乎没法扩展。我的建议是严格按照包结构分层。
src/main/java ├── com.xxx.entity // 实体类:User、Movie、Session、Order、Seat ├── com.xxx.dao // 数据访问层:接口 + 实现类 ├── com.xxx.service // 业务逻辑层:接口 + 实现类 ├── com.xxx.web // 控制层:Servlet类 ├── com.xxx.filter // 过滤器:编码、登录校验 ├── com.xxx.util // 工具类:DBUtil、StringUtil等分层不是形式主义。举个例子,下单操作在service层里编排“查用户、查场次、查座位、锁座、生成订单、扣库存”这一序列,如果后续要加优惠券逻辑,只需要在service层改,不用动Servlet。同样,如果将来把JDBC换成MyBatis,受影响面也仅限dao层。
3. 数据库设计:五到六张核心表怎么建才能不踩坑
3.1 表结构设计思路
表结构是整个系统的地基。地基打歪了,后面写代码时处处别扭。我这里给出一个经过多人实践检验的五表方案:
- t_user(用户表):user_id主键自增,username唯一索引,password(建议MD5存储),phone,create_time。
- t_movie(电影表):movie_id,title(片名),director(导演),actors(主演),genre(类型),duration(时长/分钟),release_date(上映日期),poster(海报图片路径),description(简介),status(1上架 / 0下架)。
- t_session(场次表):session_id,movie_id外键,hall(厅号),show_time(放映时间),price(票价),status(1有效 / 0已取消)。
- t_seat(座位表):seat_id,session_id外键,seat_row(排号),seat_col(列号),seat_status(0空闲 / 1已锁定 / 2已售出)。
- t_order(订单表):order_id(业务订单号),user_id外键,session_id外键,total_amount(总价),status(0待支付 / 1已支付 / 2已退款/已取消),create_time。
为什么把座位单独拆成一张表,而不是在订单表里用字符串存“3排5座、3排6座”?原因有两点。第一,拆成座位表后,选座界面可以直接根据该场次的座位记录渲染座位图,不用再单独维护“哪排哪列”的静态数据。第二,座位状态和订单解耦,订单只要记录“seat_ids”(如“3-5,3-6”)或者通过中间表关联即可,状态流转更清晰。
3.2 订单号生成与座位状态设计
订单号不要用自增主键直接暴露给用户,业务订单号需要唯一且有规则。最简单的做法:时间戳 + 4位随机数,例如202505071530001234。再加一层保障的话,可以接上用户ID后四位,进一步降低碰撞概率。生成时注意用StringBuilder拼接,不要频繁new字符串。
座位状态的粒度设计要考量。我建议seat_status用三个状态而不是两个。因为从用户点选座位到最终支付完成之间有时间差,如果把选中瞬间直接改成“已售出”,一旦用户放弃支付,座位释放逻辑会很别扭;先置为“已锁定”,支付成功再置为“已售出”,取消则改回“空闲”,流程就顺了。
注意:数据库字段命名统一用小写下划线风格,Java实体类用驼峰命名,写自动映射工具时省事不少。比如数据库字段
seat_status对应实体字段seatStatus。
3.3 外键与索引设计
课设级别的系统可以不强制建物理外键,但关联字段的索引必须建。t_order表的user_id、session_id一定要加索引,因为订单查询往往是“查某个用户的所有订单”,没有索引数据量稍大就全表扫描,页面响应明显变慢。t_seat表的session_id同样加索引,选座页面加载时按session_id一次捞出所有座位。
4. 核心功能模块的实现细节与实操要点
4.1 用户注册登录与状态保持
登录功能不是什么高端逻辑,但很多同学的实现有两个典型问题:密码明文存储、未校验登录状态。
密码明文存储在课设评审时是硬伤,稍微严格一点的答辩老师都会问。用MD5或SHA-256做单向散列存储,前端提交密码后,后端拿到再加密入库。比较时不对明文,而是对密文。加不加盐都可以,课设层面做一下MD5就够说明你对安全有意识。
登录状态保持用Session。用户登录成功后,把user对象放进session,session.setAttribute("user", user)。需要拦截未登录访问的地方(下单、订单查询、后台管理),用Filter统一处理。
// 登录校验过滤器核心逻辑 public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; HttpSession session = request.getSession(false); // 判断请求路径是否是需要登录的路径 if (session != null && session.getAttribute("user") != null) { chain.doFilter(request, response); } else { response.sendRedirect(request.getContextPath() + "/login.jsp"); } }无论注册还是登录,前端都建议做一遍非空校验,后端再用StringUtil判断一次,前后端双重校验。后端校验是底线,前端校验只是体验优化。
4.2 电影列表与场次查询:分页的完整实现
电影列表和场次列表都涉及分页。分页实现三要素:当前页码pageNum、每页条数pageSize、总记录数totalCount。前两个由前端请求参数传入,第三个需要一条count查询。
用JDBC实现分页查询时,MySQL用LIMIT语句:
SELECT * FROM t_movie WHERE status = 1 ORDER BY release_date DESC LIMIT ?, ?第一问是偏移量(pageNum - 1) * pageSize,第二问是pageSize。可以封装一个PageBean对象来统一承载分页数据:
public class PageBean<T> { private int pageNum; // 当前页码 private int pageSize; // 每页条数 private int totalCount; // 总记录数 private int totalPages; // 总页数 private List<T> data; // 当前页数据 }计算总页数有个小坑,用整数除法时要向上取整。推荐写法:totalPages = (int) Math.ceil((double) totalCount / pageSize);,或者(totalCount + pageSize - 1) / pageSize,后者纯整数运算更快。
实操心得:JSP页面渲染分页导航条时,用EL表达式从PageBean取值。翻页链接带上pageNum往前翻往后翻,注意第一页和最后一页的禁用状态处理。我见过很多同学在这里忘记处理边界,导致点“上一页”回到pageNum=0的非法状态。
4.3 选座与下单:事务处理的完整实现
这一步是整个系统的核心难点。先画一下正常流程:
- 用户点击“选座购票”,进入
seat_select.jsp?sessionId=xx - JSP页面加载后,Ajax请求后端获取座位数据,已售出和已锁定的座位置灰
- 用户点击空闲座位,座位高亮,同时在页面上记录选中的座位id
- 点击“提交订单”,后端执行下单逻辑
后端下单逻辑必须在一个事务里完成。核心代码序列:
Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 查询场次信息,校验场次是否有效 Session sessionInfo = sessionDao.findById(sessionId); if (sessionInfo == null || sessionInfo.getStatus() != 1) { throw new RuntimeException("场次不存在或已取消"); } // 2. 锁定座位:更新座位状态为已锁定(关键防并发) // 必须是先查再判,还是直接更新?这里用原子更新 for (Integer seatId : selectedSeatIds) { int rows = seatDao.lockSeat(seatId); // UPDATE t_seat SET seat_status=1 WHERE seat_id=? AND seat_status=0 if (rows == 0) { throw new RuntimeException("座位已被他人锁定,请重新选择"); } } // 3. 生成订单 String orderId = OrderIdGenerator.generate(); Order order = new Order(orderId, userId, sessionId, selectedSeatIds, totalAmount, 0); orderDao.insert(order); conn.commit(); // 全部成功才提交 } catch (Exception e) { conn.rollback(); // 任何一个步骤失败,回滚 throw e; } finally { conn.setAutoCommit(true); DBUtil.close(conn); }这段代码里最重要的是第2步的SQL写法。很多人写成“先查状态,如果空闲再update”,这在单线程下没问题,并发下就会出事故。假设两个用户同时看到座位空闲,同时查询都返回status=0,然后都执行update,座位就被卖了两次。正确做法是用条件更新让数据库来兜底:
UPDATE t_seat SET seat_status = 1 WHERE seat_id = ? AND seat_status = 0影响行数为0,说明座位已经被别人抢走,直接拒绝。这个方案比“先查再改”靠谱得多,这也是为什么我在第2步里特意注明要用原子更新而不是查询后再判断。
4.4 模拟支付与订单状态流转
课设一般不做真实支付对接,但模拟支付的流程必须完整:订单状态从0(待支付)到1(已支付)。用户提交订单后跳转到支付确认页,展示订单信息和金额,点击“确认支付”,后端更新订单状态为已支付,同时把对应座位状态从“已锁定”改为“已售出”。
这里有个细节:订单生成后用户如果直接关掉浏览器不支付,锁定座位不能永远锁着。正常商业系统会做超时释放,课设也可以做个简化处理:订单超过15分钟未支付,视为取消,释放座位。实现方式有两种:
- 每次加载选座页时,扫描当前场次是否存在超时未支付订单,有则批量改状态、释放座位。优点是不需要额外线程。
- 用一个定时任务,每分钟执行SQL清理超时订单。代码稍复杂但对真实场景还原度高。
我推荐第一种,能少碰并发问题,也能在答辩时讲清楚思路。
5. 后台管理模块的实现要点
5.1 电影与场次管理
后台管理可以统一放在admin/前缀路径下,用Filter做管理员登录校验(区别于用户端)。电影管理就是一个标准的CRUD:列表、新增、修改、上下架。新增电影要上传海报,用Servlet接收multipart/form-data请求,图片保存在服务器某个固定目录下(例如/upload/posters),数据库里只存访问相对路径。这部分要注意文件名唯一化处理,避免不同用户上传同名文件互相覆盖。建议用UUID重命名:
String ext = fileName.substring(fileName.lastIndexOf(".")); String newName = UUID.randomUUID().toString().replace("-", "") + ext;场次管理要校验时间逻辑。同一影厅同一时间不能排两场电影,虽然课设可能不要求做冲突检测,但如果做了,绝对是个加分项。实现也不复杂:新增场次时查同厅同时段是否存在交叉记录。
5.2 订单管理
订单管理主要做两件事:按条件查询和退款处理。查询条件通常包括用户账号、订单号、订单状态。SQL可用动态拼接,但一定要注意SQL注入问题,使用PreparedStatement的参数占位符,不要直接字符串拼接条件值。
退款处理的逻辑要谨慎。订单状态从已支付(1)改成已退款(2),同时把对应座位状态改回空闲。这两步同样要在事务里完成。退款前先判断订单状态,如果已经是退款状态要给出提示,防止重复退款。
6. 项目中的关键难点与解决方案
6.1 并发超卖问题的正确打开方式
超卖问题是这类系统的灵魂考点,几乎每个答辩老师都会问。前面提到底层方案是条件更新,现在从更高层面看这个问题,其实有三个层次的防线:
- 数据库层面:条件更新(
WHERE seat_status = 0)保证单次更新操作原子性。 - 事务层面:整体流程在一个事务中,所有座位锁定和订单写入要么全部成功、要么全部失败。
- 业务层面:前端座位实时状态灰化,降低用户点选已售座位的概率。但这一步只是体验优化,不能作为并发防线。
我在指导别人的代码时发现,很多人最担心的并发问题其实不是“数据库扛不住”,而是“同一个座位的状态竞争没处理”。你用条件更新把住这一关,并发就出不了卖重的问题。
6.2 会话状态管理与登录拦截
用户和管理员的会话要分开处理。最简单的方案是session里存两个不同的key,比如用户登录存session.setAttribute("user", ...),管理员登录存session.setAttribute("admin", ...)。Filter拦截时按路径判断拦截目标。
再补充一个容易被忽略的问题:Session的过期时间。Tomcat默认30分钟,对于购票这种业务偏短,可以把/admin后台的session超时时间设置短一些(比如15分钟),降低管理端被他人操作的风险。通过修改web.xml或代码调用session.setMaxInactiveInterval(900)都可以。
6.3 文件上传与路径处理的坑
文件上传后,图片要能被前端实时访问,必须保证图片目录在Tomcat的可访问路径下。放在项目根目录下的upload文件夹里通常没问题(需要看IDEA和Eclipse的部署配置差异),放到WEB-INF下面则外部访问不到。完整路径拼接时,用request.getServletContext().getRealPath("/")获取部署根目录,再拼上传子目录。
注意:部署后目录并不一定是你源码里的目录。用IDE开发时,Tomcat实际运行的是部署后的target目录或webapps下的副本。调试图片不显示时,先去看部署目录里有没有这个文件,不要一直盯着源码目录看。
7. 部署与环境配置实战
7.1 本地环境搭建(3分钟快速跑通)
不管你在IDE里开发得多欢,最终要能在浏览器里跑起来。用IDEA做示例,标准流程如下:
- 配置Tomcat:Run -> Edit Configurations -> 添加Tomcat Server -> Local,选择本地Tomcat安装目录。
- 部署工件:Deployment选项卡里添加
war exploded。
这里有个常见的“坑”:很多同学在IDEA里启动Tomcat后,访问页面404或403,十有八九是没有设置“Application context”。部署配置里把Application context设成/movie,访问路径就是http://localhost:8080/movie/index.jsp。
数据库侧,执行建库建表脚本,然后修改JDBC配置文件里的数据库名、用户名、密码。注意MySQL 8.0的驱动类和JDBC URL与5.7有所不同:
# MySQL 5.7 jdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/movie_db?useUnicode=true&characterEncoding=utf8 # MySQL 8.0 jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/movie_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai如果用MySQL 8.0但驱动jar版本过旧,控制台会报ClassNotFoundException或Public Key Retrieval is not allowed之类错误。注意对应升级驱动版本。
7.2 常见问题排查速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 页面中文乱码 | request/respose编码未设置或顺序不对 | 统一Filter设置UTF-8,JSP页面加<%@ page contentType="text/html;charset=UTF-8" %> |
| 注册登录报NullPointerException | 请求参数名与Servlet中getParameter不一致 | 核对JSP表单控件的name属性和后端代码参数名 |
| 数据库连接失败 | URL、用户名、密码错误,或驱动jar缺失 | 逐个检查,先确保Navicat能连上库再怀疑代码 |
| 图片上传后访问404 | 上传目录和访问路径不一致 | 检查Tomcat部署目录下是否有文件,访问相对路径是否正确 |
| 8080端口被占用 | 其他服务占用 | 换端口或用netstat -ano定位进程 |
| 每次查询都很慢 | 全表扫描,缺索引 | 给外键字段和查询条件字段添加索引 |
| 部署后请求404/500 | Application context设错或Web.xmlServlet映射错误 | 核对 context 路径和 web.xml 中url-pattern |
7.3 答辩前一定要自测的十个功能点
系统开发完不要急着提交,按下面这个清单逐项自测,能堵住80%的常见评审问题:
- 未登录状态下直接访问下单页面,必须被拦截跳转到登录页。
- 注册时重复用户名,必须给出友好提示,不能直接白屏或报500。
- 电影列表翻页,第一页上一页不可点,最后一页下一页不可点。
- 选座页面,已售出座位不可点击,已选座位的金额合计正确。
- 下单后没支付,再次进入选座,之前锁定的座位仍然处于锁定状态(因为超时未取消,此时状态为锁定)。
- 管理员上下架电影后,用户端列表实时变化。
- 退款操作后,座位重新变为可选,订单状态变为已退款。
- 所有删除操作前有确认提示。
- 字段超长输入导致页面异常,后端入库前做长度校验。
- 浏览器返回上一页再次提交同一操作,系统不会产生重复订单(关键在防重复点击+幂等校验)。
第10条特别提一下。很多系统没有做按钮防重复点击,用户下单时若连点两次“确认支付”,由于事务第二次执行会因座位状态已改变而失败,这个方案在底层已经兜住了。但更好的体验是前端在提交后立刻给按钮加disabled属性,从源头避免重复请求。
8. 从课设到加分:可以扩展的几个方向
如果你的时间允许,在原系统上加一两项扩展,答辩效果会明显不同。我这里列几个容易实现又有讲头的方向:
- 接入真实支付模拟服务:用支付宝沙箱或微信支付沙箱环境,在答辩时演示支付回调流程。这能展示你对真实支付链路的理解。
- 增加Redis缓存热点场次信息:将选座页高频访问的场次和座位数据缓存到Redis,减少数据库压力。
- 用WebSocket做“座位实时同步”:多人在线选座时,某个人锁定的座位能主动推送到其他用户的页面上,替代Ajax轮询。
- 增加电影院多门店支持:把系统从单影院扩展成多影院,表结构上增加影院表,场次关联影院。这个扩展点在逻辑上非常自然,工作量可控。
我个人对第4个扩展比较推荐,因为它基本不引入新技术,却把系统的业务格局从“单店系统”提升到“平台系统”,理解深度和设计能力在答辩时很容易被体现。
最后再分享一个小经验:这类JavaWeb项目,评审老师最看重的不是技术多新、页面多炫,而是业务逻辑是否严谨、基础概念是否扎实。把三层架构分清楚,把事务边界划明白,把表关系设计合理,即使没用任何框架,依然能做出一个能说服人的系统。动手做的过程中,遇到报错先看控制台堆栈,不要急着百度,很多问题的答案其实你已经知道了。祝顺利。