做毕业设计那会儿我抽到的题目是一个JavaWeb方向的经典项目——博物馆售票管理系统。拿到题目的第一反应是“这有什么难的”真正动手才发现光是一个在线选票和库存扣减的逻辑就能让新手纠结一整天。如果你也正在做类似的SSM项目或者准备用SpringSpringMVCMyBatis这套组合练手这篇博文应该能帮你省下不少查资料的功夫。我会从技术栈选型、数据库设计、核心代码实现、常见坑排查到部署上线把整个项目的骨架和关键细节都过一遍保证你看完能直接开写。这个系统本质上是一个典型的“前台展示后台管理”双层Web应用游客在门户页浏览展览信息、选票下单管理员在后台维护展览数据、查看销售统计。技术栈是Java SSMSpring、SpringMVC、MyBatis JSP jQuery AJAX MySQL听起来东西很多但每一层都有明确的分工。适合的人群很明确正在做毕设或课设的计算机专业学生想快速熟悉SSM开发的初级Java工程师以及想拿一个完整项目练手但不想从零造轮子的学习者。1. 项目选题与技术栈拆解1.1 为什么“SSMJSPjQuery”这套组合依然是经典选择很多人会问现在不都流行SpringBootVue了吗为什么还要用SSMJSP我想说的是毕设和课设考察的重点从来不是“最新技术”而是你对Web开发整个链路是否理解透彻。SSMJSP强在层次分明——JSP负责页面渲染SpringMVC管理请求分发Spring统筹Service层MyBatis把SQL写到Mapper里每一层都能被单独拿出来问、单独考察。面试官问你“SpringMVC的执行流程是什么”你在这个项目里就亲手写过Controller和视图解析器答起来比背八股文要扎实得多。另外这套技术栈对电脑配置的要求很低。我的笔记本8G内存跑IDEATomcatMySQL完全没压力SpringBoot那套依赖打包方式虽然更方便但排查底层问题时反而多了一层抽象对新手来说不如SSM直观。如果你以后想转SpringBootSSM的知识也能平滑迁移控制器、服务、Mapper的结构几乎不变变的主要是配置方式。1.2 系统功能模块与角色分配在设计功能时我先理清楚了系统里会有几类人游客未登录、注册用户、管理员。三类角色的需求差别很大不能混在一起做。游客可以浏览展览列表、查看展览详情但只有登录后才能买票。用户登录后能选票、下单、查看自己的订单记录。管理员则拥有独立的后台维护展览信息新增、编辑、下架、设置票价和票量、查看所有订单、统计各展览的售票情况。这个权限模型虽然简单但覆盖了典型的RBAC基于角色的访问控制思想答辩时能说出“为什么游客不登录也能看列表”这类设计理由会显得你考虑问题很全面。1.3 核心流程梳理从选展到出票我习惯先画清楚业务流程再动手写代码。这个系统的核心流程是用户进入展览详情页→选择票型成人票/学生票/特惠票→前端通过AJAX查询实时库存→提交订单→后端校验库存并扣减→生成订单记录→跳转支付模拟页面→支付完成模拟→订单状态置为已支付。这个流程里有一个非常关键的细节库存扣减不能靠前端传过来的数字必须由后端在事务里完成。比如用户A和用户B同时买最后一张票如果前端先显示有1张两个人都点了提交后端不做并发控制就会出现超卖。这个问题后面我会专门讲解决方案。2. 数据库设计与MySQL环境准备2.1 五张核心表的字段设计数据库是这类管理系统的地基。我的库名取为museum_db字符集用了utf8mb4因为要存中文展览简介如果用utf8会碰到一些生僻字或emoji插入失败的情况。核心表一共有五张用户表t_user、展览表t_exhibition、票型表t_ticket_type、订单表t_order、订单明细表t_order_item。t_user表的字段包括id、username、password、phone、create_time。password我存的是MD5加密后的值虽然现在业界推荐BCrypt但SSM项目里用MD5盐的方式也很常见关键是不能明文存储。t_exhibition表包含id、name、description、cover_url、start_date、end_date、statusstatus字段用来标记该展览是否上架0为下架1为上架。t_ticket_type表设计了一个外键exhibition_id关联展览字段有type_name、price、stock。为什么票价和库存单独拆一张表因为同一个展览通常有成人票、学生票、团体票等多种票型直接在展览表里加“票价”字段的话就没法支持一展多价了。t_order表和t_order_item表的拆分则遵循了经典的主从表设计原则订单主表存一次购票行为的整体信息订单编号、用户id、总金额、状态、创建时间订单明细表存具体买了几张什么票。这样设计的好处是后续做报表统计时可以按订单主表的时间维度、票型表的分类维度灵活查询。2.2 建表SQL与索引优化这里贴一下订单明细表的建表语句你可以看看字段类型的取舍CREATE TABLE t_order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, ticket_type_id INT NOT NULL, ticket_name VARCHAR(50) NOT NULL, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, INDEX idx_order_id (order_id), CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES t_order(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几点经验说明金额字段用DECIMAL而不是FLOAT或DOUBLE浮点类型在计算总价时会出现精度丢失比如9.990.01得到10.000000001外键约束一定要加别觉得“反正代码里控制”就省略真到数据不一致时你就后悔了给order_id建了索引因为查询“某订单有哪些明细”的操作会很频繁。2.3 MySQL版本选择与安装避坑指南项目我用了MySQL 5.7.44原因是成熟稳定、网上资料最多。最近很多人问“MySQL 5.7.44之后怎么官方直接跳到8.0”“8.4.11 LTS能不能用”我的建议是如果做毕设求稳直接选5.7系列的最后一个版本5.7.44安装过程几乎是下一步下一步就结束。如果你非要赶新潮8.0的大版本也行但要注意两点一是8.0的默认认证插件是caching_sha2_password老版JDBC驱动连不上必须用mysql-connector-java 8.0以上的驱动并在JDBC URL后面加上useSSLfalse和serverTimezoneAsia/Shanghai这两项二是8.0解压版比如你下载的D:\tool\mysql-8.0.46-winx64目录初始化方式跟安装版不同需要手动执行mysqld --initialize-insecure。当年的新手最容易踩的一个大坑是服务启动不了。用net start mysql报错时八成是my.ini里的basedir和datadir路径写得不规范路径里不能有中文斜杠最好统一用正斜杠。改完配置后记得用管理员身份运行CMD否则连启动服务都做不到。3. 后端框架搭建与核心代码实现3.1 Maven工程结构与配置文件的组织方式我创建的Maven工程分了四个包controller、service、mapper、entity或pojo另外还有common包放通用工具类。resources目录下放了spring-mybatis.xml、spring-mvc.xml和mybatis-config.xml加上web.xml总共四个配置文件。用这种方式分离配置而不是全部塞进applicationContext.xml里看起来更清爽出了问题也更容易定位。pom.xml里需要引入的核心依赖包括spring-context、spring-webmvc、mybatis、mybatis-spring、mysql-connector-java、jackson-databindJSON序列化用、jstlJSP标签库。这里有个很容易搞错的地方jsp和jstl的两个依赖必须加否则JSP页面里的c:forEach标签解析不了页面会直接报500错误。另一个坑是依赖版本兼容性Spring 5.x和MyBatis 3.5.x是匹配的千万别用Spring 6去配老版MyBatis那你会被一堆“NoSuchMethodError”折磨一下午。3.2 登录模块的实现思路与Token处理登录模块虽然简单但涉及到Session管理是JavaWeb面试里的高频题。我的做法是用户在JSP页面输入用户名密码jQuery用AJAX把表单数据POST到/login接口Controller层调用Service层去数据库校验成功就把用户对象塞进Session返回JSON给前端前端再跳转首页。为什么用AJAX而不是直接表单提交因为表单提交会整个页面跳转用户体验差而且后端校验失败时错误信息不好回显。用AJAX后前端可以在不刷新页面的情况下提示“用户名或密码错误”。这段JS很典型$.ajax({ url: /login, type: POST, data: $(#loginForm).serialize(), dataType: json, success: function(result) { if (result.code 200) { window.location.href /index; } else { $(#errorMsg).text(result.msg); } } });这里要特别注意Controller返回的对象必须能被Jackson转成JSON。有的同学会踩一个低级坑实体类的getter方法被误删了Jackson看不到属性返回的JSON就是一个空对象{}。所以如果遇到AJAX拿到空的data先检查实体类的getter和setter是否完整。3.3 购票下单事务与防超卖购票是整个系统最核心、也最能体现后端水平的功能。请求参数是票型id和购买数量后端Service层要做的事按顺序是查出该票型的单价、检查库存是否充足、计算总价、插入订单主表、插入订单明细表、扣减库存。这四步必须在一个事务里执行任何一步失败都要回滚否则可能出现“订单生成了但库存没扣”的脏数据。我在Service实现类上加了Transactional注解同时把隔离级别设为DEFAULT。提交订单前先用select ... for update锁定该票型记录这是MySQL的行级锁确保同一时刻只有一个请求能读到最新库存。解释一下为什么必须要锁假设库存只剩1张两个线程同时读到stock1都通过校验然后都执行UPDATE stock stock - 1最终库存变成-1这就是所谓的超卖。加了for update之后第二个线程会阻塞在查询那里等第一个事务提交后它读到的库存已经是0了自然就不会通过校验。代码大致是这样Transactional public boolean createOrder(Integer userId, Integer ticketTypeId, Integer quantity) { TicketType tt ticketTypeMapper.selectByIdForUpdate(ticketTypeId); if (tt.getStock() quantity) { return false; } // 生成订单主表记录 // 生成订单明细记录 // ticketTypeMapper.decreaseStock(ticketTypeId, quantity); }对应的Mapper SQL里写的是SELECT * FROM t_ticket_type WHERE id #{id} FOR UPDATE。这个for update是这个方案的核心答不上来原理的话面试官会觉得你只是套了个注解完全没理解事务到底在做什么。3.4 订单列表的分页查询与多表关联用户中心需要查看“我的订单”这里涉及到订单表、订单明细表、票型表、展览表四张表的关联。我的做法是在Mapper XML里写一个多表联查的resultMap把订单主表信息映射到OrderVO对象再在OrderVO里放一个List 字段用MyBatis的collection标签把同一订单的多条明细组装进去。这种“一对多”映射是MyBatis的核心考点之一强烈建议你亲手写一遍而不是光用注解。分页我用了PageHelper插件引入依赖后只需在Service层查询前调用PageHelper.startPage(pageNum, pageSize)后面紧跟的查询就会被自动加上LIMIT。这里有一个PageHelper新手必踩的坑startPage方法必须写在紧接着的查询语句前中间不能有任何其他数据库操作否则分页会失效甚至会把SQL拼错返回全表数据。4. 前端页面设计与AJAX交互细节4.1 JSP布局与公共页面复用JSP页面我放在WEB-INF/views目录下浏览器不能直接访问只能通过Controller跳转这种设计既安全又方便做视图管理。每个页面顶部都包含了header.jsp和footer.jsp里面是导航栏和版权信息。用JSP的include指令可以大幅减少重复代码改一次公共导航所有页面都生效这也是“不要重复自己”原则在视图层的最小落地。首页index.jsp展示所有已上架展览的卡片列表用了Bootstrap的栅格系统和c:forEach循环。每张卡片上有展览封面、名称、时间和一个“查看详情”按钮。封面图我放在本地工程的上传目录数据库里存的是相对于项目根路径的URL比如/upload/exhibition01.jpg。这种存相对路径而不是绝对路径的做法方便以后迁移服务器不用改数据库。4.2 详情页的AJAX实时库存与票型切换展览详情页是前端交互最复杂的页面。用户点开详情后页面先通过AJAX请求该展览下的所有票型$.getJSON(/ticketTypes?exhibitionId id, function(data) { // 渲染票型下拉框或单选卡片 });选择不同票型时下方的价格和剩余库存会跟着变化这个效果是通过jQuery的change事件监听下拉框再从刚才拿到的数据里取对应票型的字段来更新DOM。整个过程中页面没有一次整体刷新这就是AJAX局部刷新的实际体现。这里有一个容易被忽略的小细节如果票型库存为0按钮应置为禁用状态并显示“已售罄”前端要实时处理这种状态不然用户填了数量提交后才发现库存不足体验很差。4.3 下单页的jQuery表单校验用户点了“立即购买”后跳到下单页。下单页针对数量字段做了前端校验必须为正整数、不能超过该票型的最大剩余库存、不能超过单笔限购5张。这个过程我全部在jQuery里完成var quantity parseInt($(#quantity).val()); var max parseInt($(#maxStock).val()); if (isNaN(quantity) || quantity 0) { alert(请输入正确的数量); return; } if (quantity max) { alert(库存不足当前剩余 max 张); return; }为什么前端校验做了后端还要再做一遍这是一个很经典的安全意识问题——前端的校验只是给正常用户看的懂技术的人完全可以通过F12修改请求参数把数量改到999直接提交。所以后端Service层的库存检查才是真正的安全边界。这部分对应的就是“前后端双重校验”的设计思想项目答辩的时候主动说出这个理由很加分。4.4 JSP中的特殊场景处理热搜词里有个“jsp图片如何对坐标定位”这个在我们的项目里也有应用场景——展厅平面图。我在展览详情页放了一张展厅平面图图上标注了入口、展区和出口。实现方式是设置一个div作为图片容器图片用position:relative定位坐标点用绝对定位的span标签放到图片对应位置.plane-map { position: relative; display: inline-block; } .plane-map .marker { position: absolute; }这种做法本质上是CSS的定位和JSP本身没多大关系只不过这种交互放在JSP页面里写特别方便因为可以结合JSTL从后端数据动态渲染标记点。如果你的项目里也需要类似“图上标点”的需求可以参考这个思路——前端定位永远用CSS后端只负责数据。5. 常见坑与排查技巧实录5.1 中文乱码问题全家桶做JavaWeb项目乱码问题的根源就三类页面编码、请求编码、数据库编码。项目里我统一在所有JSP页面顶部写pageEncodingUTF-8然后在web.xml里配置了CharacterEncodingFilter强制请求和响应都使用UTF-8编码。数据库连接URL里也加了characterEncodingutf8。这三处全设置好基本就不会乱码。还有一个容易被忽视的地方如果IDEA控制台里看SQL日志中文是正常的但存进数据库乱码那问题多半在MySQL的连接参数或者表本身字符集上。排查方法很简单在MySQL命令行执行SHOW CREATE TABLE t_exhibition看表的DEFAULT CHARSET是不是utf8mb4。5.2 404错误与SpringMVC路径匹配问题开发中最常见的报错是404。我踩过最典型的坑是在JSP里跳转路径写的是绝对路径“/exhibition/detail”但在部署时项目名是museum所以请求路径应该是“/museum/exhibition/detail”。解决方式是所有跳转都用JSTL的${pageContext.request.contextPath}拼路径或者可以在页面上用base标签统一处理。于是我在每个JSP头部加了一行base href${pageContext.request.scheme}://${pageContext.request.serverName}:${pageContext.request.serverPort}${pageContext.request.contextPath}/这样页面里的所有相对路径都自动带上了项目上下文不用一个个手改。5.3 前端AJAX拿到JSON却解析失败当AJAX的success回调里打印data发现是一个字符串而不是对象时大概率是后端返回的Content-Type不对。SpringMVC的ResponseBody注解默认会根据方法返回类型自动选择转换器但如果pom.xml缺了jackson依赖转换器找不到就会把对象转成字符串“com.example.OrderVO123abc”这种结果。排查手段是打开浏览器F12的Network面板看响应头的Content-Type是不是application/json;charsetUTF-8。如果不是先检查Jackson依赖是否完整再看spring-mvc.xml里的注解驱动是否配置了mvc:annotation-driven /。这两个都对了问题基本就能解决。5.4 Tomcat端口占用与热部署失效每次用IDEA启动项目报“Port 8080 was already in use”说明Tomcat端口被上一个残留进程占用了。Windows下最简单的处理方法是打开CMD输入netstat -ano | findstr :8080找到对应PID后执行taskkill /PID xxx /F。不要一上来就重启电脑或者去改Tomcat的server.xml端口那样会引来更多路径问题的连锁反应。另外提一下热部署在IDEA里配置Tomcat时选“Update classes and resources”模式修改了Java代码后按CtrlF10可以自动热部署不用每次重启等待几十秒。这个方法只适用于开发阶段生产环境千万别这么搞。6. 项目自测清单与答辩准备建议6.1 功能自测 Checklist写完了项目不等于做完了项目功能全部走一遍自测流程很重要。我把自己当时的测试步骤整理成一张清单你可以直接参考测试模块测试内容与预期结果注册登录重复用户名注册时提示失败错误密码登录提示失败正确账号能跳转首页展览列表已上架展览正常显示已下架展览不出现在列表票型展示随机修改某票型库存后刷新页面库存数字实时更新下单流程库存为0的票无法下单购买超过库存时后端校验拦截订单生成后库存减少并发测试开两个浏览器窗口同时购买同一票型最后一张只有一个能成功订单中心用户的订单列表正确显示展览名、票型、数量、总金额和状态管理员后台新增展览后前台立即能看到编辑票价后详情页价格同步更新非法访问未登录状态下直接访问/orderList接口被过滤器拦截并重定向到登录页6.2 答辩时的高频问题与回答思路项目做完后答辩环节往往比写代码更紧张。我这里列几个高频问题提前准备好答起来会从容很多“为什么用MyBatis而不是Hibernate”我的回答思路是MyBatis的SQL都由开发者自己控制性能更好掌控适合复杂查询Hibernate自动生成SQL虽然省事但遇到多表关联和动态查询时反而约束多调优起来更头疼。“如何防止库存超卖”结合项目代码回答使用数据库行锁SELECT ... FOR UPDATE在事务中锁住票型记录确保同一时刻只有一个事务能修改库存。同时配合事务的原子性保证扣库存和生成订单要么都成功要么都回滚。“项目里哪部分是你觉得最难的”这个问题千万别回答“都不难”。我建议说订单模块的并发控制和多表联查分页讲讲自己是怎么一步步排查超卖问题、怎么设计resultMap的有细节有过程考官想追问也有空间。“系统如何保证数据一致性”可以从订单主从表的外键约束、事务回滚、状态字段的流转控制这几个维度来说大致思路是下单时订单状态为待支付只有支付成功后才变成已支付所有状态变更都在同一个事务内完成。6.3 项目后续可以扩展的方向如果你做完基础版本还想加亮点我推荐两个方向一是引入Redis做展览热门榜单缓存把高频访问的展览详情页数据放到缓存里减少数据库压力二是增加ECharts销量统计报表用后端接口返回近30天售票数据前端用柱状图和折线图展示视觉冲击力很强也显得项目有数据分析的味道。我个人做这个项目最大的体会是SSM就像一辆手动挡汽车虽然操作步骤比自动挡多但正因为每一个环节都需要亲手配置你对整条链路才会成竹在胸。后来切换SpringBoot时我明显感觉到基础扎实带来的转化速度是飞快的配置就能跑的那种爽快感反而掩盖了底层原理的细节。最后再分享一个小技巧写这类管理系统时先把接口路径和返回JSON格式在纸上列好比如登录是POST /login、票型列表是GET /ticketTypes再动手写代码能少走一大半弯路。这个习惯我一直用到现在非常管用。