JavaEE酒店管理系统毕业设计:数据库、状态流转与联调部署实战
简介:面向JavaEE毕业设计与SSH框架入门实践的小型酒店管理系统,适合需要完整项目案例的高校学生、初级开发者及课程设计选题者参考。资源包共262个文件,以49个Java源码、54个class编译文件、54个jar依赖包和25个JSP页面为主,另含XML配置、properties及图片等静态资源,压缩包约18.69MB。项目围绕酒店业务组织用户与员工权限、客房类型与状态、预订入住退房、收费账单和报表统计等模块,可帮助读者梳理Struts请求流转、Spring依赖注入与事务管理、Hibernate实体映射的协作方式。包内还保留测试类与配置文件,便于对照实体类、DAO、Service、Action各层职责,理解从JSP表单到数据库的完整链路。已有1695人学习下载,适合用于课程设计选题、代码结构参考和SSH分层实现思路的对照学习。
1. 从答辩现场倒推:JavaEE 小型酒店管理系统到底该先跑哪条链路
答辩前一周还在改登录页,是 JavaEE 小型酒店管理系统毕业设计里很常见的翻车现场。这套系统本质是把“房型—房间—预订—入住—退房—账单”串成一条可演示的业务链路,再用 JSP/SSM 或 Spring Boot 把权限、分页、状态流转撑起来。它适合拿来交毕业设计,也适合刚接触 JavaWeb 的开发者练手,但不适合直接当生产级酒店系统。真正决定能不能跑通的,不是页面多漂亮,而是数据库表关系、登录会话、订单状态和部署路径这四件事。先把启动链路和核心表跑通,再回头补页面细节,比一上来改 UI 稳得多。
2. 数据库与实体映射:七张核心表怎么拆,字段类型别乱省
小型酒店管理系统的数据库不算大,但特别容易在“表拆得不够”上吃亏。常见做法是先定业务状态,再拆表:房型管价格和容量,房间管具体房号,预订管占房日期,入住管实际到店,账单管结算金额,用户表管登录和角色。把房型和房间合成一张表,换房时就要改历史数据;把预订和入住合成一张表,前台演示“先预订、后入住、再退房”时状态会绕成一团。这一章按可复现的顺序,把核心表、建表 SQL、实体映射和字段参数拆开讲。
2.1 先定状态再拆表:房型、房间、预订、入住、账单的关系
房型和房间是一对多:一个房型可以有多个具体房间,价格放在房型上,房号放在房间上。房间和预订是一对多:同一房间在不同日期可以有多条预订记录,但同一日期段不能重叠。预订和入住常见做法是一对一:预订确认后到店办理入住,生成入住记录,退房时再生成账单。用户表和预订、入住之间通过操作人字段关联,方便答辩时讲“谁办的入住、谁结的账”。
状态字段要提前定好,不然后面接口会互相打架。房间状态我一般用四个值:空闲、已预订、在住、维修。预订状态用五个值:待入住、已入住、已退房、已取消、已过期。账单状态用未结、已结、已退款。状态值不要用中文直接存,用TINYINT存数字,前端用字典翻译。这样查询快,后面加状态也不用改表结构。常见错误是把状态写成VARCHAR再存“已入住”这种中文,联调时大小写、空格、编码都能让人排查半天。
表关系定完后,索引要跟着查询走。房间表按room_no建唯一索引,预订表按room_id + check_in_date + check_out_date建普通索引或唯一索引,账单表按checkin_id建索引。外键约束在毕业设计环境里不是必须,常见做法是加索引不做强外键,少一层级联报错;如果导师明确要求外键,再补上也不迟。这里没有绝对标准,关键是答辩时能说清为什么这样选。
2.2 建表 SQL 与字段参数:金额、时间、状态别省
下面这段 SQL 是七张核心表的精简版,字段够用,命名统一,方便后面实体类和 Mapper 对上。金额统一用DECIMAL(10,2),日期用DATE,操作时间用DATETIME,状态用TINYINT。表名和字段名都用了小写下划线,后面靠 MyBatis 的下划线转驼峰配置映射到 Java 属性。
-- 用户表:登录、角色、状态 CREATE TABLE hotel_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, login_name VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL, real_name VARCHAR(50), role_type TINYINT NOT NULL DEFAULT 1 COMMENT '1前台 2管理员', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0禁用', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_login_name (login_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 房型表:价格、容量、上下架 CREATE TABLE room_type ( id BIGINT PRIMARY KEY AUTO_INCREMENT, type_name VARCHAR(50) NOT NULL, price DECIMAL(10,2) NOT NULL, capacity INT NOT NULL DEFAULT 2, status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 房间表:房号、所属房型、房态 CREATE TABLE room ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_no VARCHAR(20) NOT NULL, type_id BIGINT NOT NULL, floor_no INT, status TINYINT NOT NULL DEFAULT 1 COMMENT '1空闲 2已预订 3在住 4维修', UNIQUE KEY uk_room_no (room_no), KEY idx_type_id (type_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 客人表:入住人信息 CREATE TABLE guest ( id BIGINT PRIMARY KEY AUTO_INCREMENT, guest_name VARCHAR(50) NOT NULL, id_card VARCHAR(30), phone VARCHAR(20), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 预订表:占房日期、状态、操作人 CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_id BIGINT NOT NULL, guest_id BIGINT NOT NULL, check_in_date DATE NOT NULL, check_out_date DATE NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1待入住 2已入住 3已退房 4已取消', created_by BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_room_date (room_id, check_in_date, check_out_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 入住表:实际到店、押金 CREATE TABLE checkin ( id BIGINT PRIMARY KEY AUTO_INCREMENT, reservation_id BIGINT NOT NULL, room_id BIGINT NOT NULL, guest_id BIGINT NOT NULL, check_in_time DATETIME NOT NULL, check_out_time DATETIME, deposit DECIMAL(10,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 1 COMMENT '1在住 2已退房', KEY idx_reservation_id (reservation_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 账单表:结算金额、支付方式 CREATE TABLE bill ( id BIGINT PRIMARY KEY AUTO_INCREMENT, checkin_id BIGINT NOT NULL, room_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, other_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, pay_type TINYINT COMMENT '1现金 2扫码 3挂账', status TINYINT NOT NULL DEFAULT 1 COMMENT '1未结 2已结 3已退款', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_checkin_id (checkin_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这段 SQL 的逻辑是先把“静态资源”和“动态流水”分开:房型、房间、用户属于静态或半静态数据,预订、入住、账单属于流水数据。参数上,DECIMAL(10,2)能覆盖酒店房价和押金,不用FLOAT是血泪经验,浮点数在金额累加时会出现199.999999这种显示问题;DATE和DATETIME分开,是为了让预订按天比较、入住按时间点记录;TINYINT状态字段给前端字典留了空间。索引先按查询条件建,不追求大而全。
提示:建表脚本跑完后先插一条房型、一条房间、一条用户,不要等页面写完再补基础数据,否则联调时列表页空白会误判成接口坏了。
2.3 实体映射与结果集:字段名对不上时先查三处
实体类不用追求和表字段完全同名,但映射规则要统一。常见做法是开启下划线转驼峰,把room_no映射到roomNo,把type_name映射到typeName。如果开了配置仍然查出来是null,先查三处:实体属性名是否写错、SQL 是否用了别名、resultMap是否覆盖了自动映射。下面这段 Mapper 查询把房间和房型联起来,返回RoomVO,字段别名写得很直白,减少猜的成本。
<!-- RoomMapper.xml:分页查询房间,联表取房型名和价格 --> <select id="pageRoom" resultType="com.example.hotel.vo.RoomVO"> SELECT r.id, r.room_no AS roomNo, r.floor_no AS floorNo, r.status, t.type_name AS typeName, t.price FROM room r JOIN room_type t ON t.id = r.type_id WHERE 1 = 1 <if test="keyword != null and keyword != ''"> AND (r.room_no LIKE CONCAT('%', #{keyword}, '%') OR t.type_name LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="status != null"> AND r.status = #{status} </if> ORDER BY r.room_no LIMIT #{offset}, #{limit} </select>这段查询的逻辑是列表页只取展示需要的字段,不把整张房间表和房型表都查出来。参数上,keyword支持房号和房型名模糊搜,status用来筛房态,offset和limit由分页参数换算,offset = (page - 1) * limit。如果实体类用BigDecimal接price,就不要用Double,否则前端toFixed(2)之前可能已经丢精度。日期字段用LocalDate接DATE,用LocalDateTime接DATETIME,别混用。
注意:
resultType自动映射依赖列名和属性名规则,联表查询出现同名字段时一定要起别名,比如两张表都有status,不写别名就可能把房型状态映射到房间状态上。
3. 后端分层与接口:从登录拦截到退房结算的四个可抄步骤
JavaEE 毕业设计最容易写成“Controller 里塞所有逻辑”,页面能点,但答辩时一问事务边界和状态流转就露馅。合理的分层不是摆样子:Controller 负责接参、登录态和返回格式,Service 负责业务规则和事务,Mapper 负责 SQL。小型酒店管理系统的接口顺序也有讲究,先登录,再房态列表,再预订,再入住,再退房结算。这一章按这个顺序把可抄的代码和参数说明拆开。
3.1 分层不是摆样子:Controller、Service、Mapper 的边界
Controller 不要写业务判断,尤其不要把“房间是否可订”写在 Controller 里。Controller 只做三件事:从HttpSession取当前用户、把前端参数转成 DTO、调用 Service 并包装返回结果。Service 承担校验、事务和状态变更。Mapper 只做数据访问,不写业务分支。这样后面加“取消预订”或“换房”时,不用在多个 Controller 里找重复代码。
@RestController @RequestMapping("/api/reservation") public class ReservationController { @Resource private ReservationService reservationService; // 创建预订:登录态从 Session 取,业务参数从 DTO 取 @PostMapping("/create") public Result create(@RequestBody ReservationDTO dto, HttpSession session) { Long userId = (Long) session.getAttribute("LOGIN_USER_ID"); if (userId == null) { return Result.fail("登录已失效,请重新登录"); } return reservationService.createReservation(dto, userId); } }这段代码的逻辑是把“登录态”和“业务参数”分开:session里只取LOGIN_USER_ID,不信任前端传的操作人 ID;ReservationDTO只接roomId、guestId、checkInDate、checkOutDate、deposit这类业务字段。参数上,@RequestBody要求前端发application/json,如果页面用表单提交,要改成@ModelAttribute或@RequestParam。返回统一用Result,前端判断code字段,不要直接返回字符串。
Service 里再写事务。事务边界放在 Service 方法上,是因为一次预订可能同时写预订表、改房态,甚至写操作日志,放在 Controller 里太散,放在 Mapper 里又太细。下面这段是预订创建的核心逻辑,省略了日志和消息,但保留了日期校验、冲突校验、房态校验和写入顺序。
@Service public class ReservationService { @Resource private ReservationMapper reservationMapper; @Resource private RoomMapper roomMapper; @Transactional(rollbackFor = Exception.class) public Result createReservation(ReservationDTO dto, Long userId) { // 1. 日期合法性:离店必须晚于入住 if (!dto.getCheckOutDate().isAfter(dto.getCheckInDate())) { return Result.fail("离店日期必须晚于入住日期"); } // 2. 同一房间日期不能重叠 int overlap = reservationMapper.countOverlap( dto.getRoomId(), dto.getCheckInDate(), dto.getCheckOutDate()); if (overlap > 0) { return Result.fail("该房间在所选日期已被预订"); } // 3. 房间必须存在且不是维修状态 Room room = roomMapper.selectById(dto.getRoomId()); if (room == null || room.getStatus() == 4) { return Result.fail("房间不可用"); } // 4. 写预订,再把房态改成已预订 reservationMapper.insert(dto, userId); roomMapper.updateStatus(dto.getRoomId(), 2); return Result.ok("预订成功"); } }这段逻辑的关键参数是checkInDate和checkOutDate,用LocalDate比较,不要用字符串比较。countOverlap的 SQL 条件常见写法是check_in_date < #{checkOutDate} AND check_out_date > #{checkInDate},这样边界日期不会误判。roomMapper.updateStatus放在同一个事务里,预订写失败时房态回滚。并发场景下,先查冲突再插入仍有竞态,常见补救是给房间行加FOR UPDATE锁,或者在预订表上建唯一索引加捕获重复键异常,毕业设计里至少要在答辩时能说出这个边界。
3.2 登录与会话拦截:用户名、密码、Session 的三个参数
登录接口不要直接把密码明文比对,哪怕是小系统,也建议存一次哈希。毕业设计里常见做法是用MD5加盐或BCrypt,前者好讲,后者更稳。下面这段登录代码只保留核心参数:loginName、password、session。登录成功后把用户 ID 和用户名放进 Session,并设置过期时间,避免答辩演示到一半会话失效。
@PostMapping("/login") public Result login(@RequestParam String loginName, @RequestParam String password, HttpSession session) { UserVO user = userService.checkPassword(loginName, password); if (user == null) { return Result.fail("账号或密码错误"); } session.setAttribute("LOGIN_USER_ID", user.getId()); session.setAttribute("LOGIN_USER_NAME", user.getLoginName()); // 30 分钟无操作失效,答辩演示够用 session.setMaxInactiveInterval(30 * 60); return Result.ok(user); }参数上,loginName要做去空格,password不要回显到日志里。setMaxInactiveInterval的单位是秒,写30 * 60比写1800可读。拦截器里要区分页面请求和 AJAX 请求:页面请求可以重定向到登录页,AJAX 请求应返回401或统一 JSON,否则前端表格会把登录页 HTML 当数据解析,报一个很难懂的解析错误。
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { Object uid = req.getSession().getAttribute("LOGIN_USER_ID"); if (uid == null) { // 判断是否为 AJAX:是则返回 JSON,不是则跳登录页 String requestedWith = req.getHeader("X-Requested-With"); if ("XMLHttpRequest".equals(requestedWith)) { resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write("{\"code\":401,\"msg\":\"登录已失效\"}"); } else { resp.sendRedirect(req.getContextPath() + "/login.jsp"); } return false; } return true; }这段拦截器的逻辑是先看 Session 里有没有LOGIN_USER_ID,没有就按请求类型分流。参数上,req.getContextPath()会带上部署上下文,比如/hotel,不写的话重定向可能落到根路径。X-Requested-With不是所有前端库都带,Layui 的$.ajax默认会带,原生fetch不一定,所以前端统一封装请求头更稳。
3.3 预订到退房的状态流转:接口顺序和事务边界
退房结算不要在一个接口里既改入住状态、又改房间状态、又生成账单,逻辑会变得很难排。常见做法是拆成三个动作:办理入住、退房、结算。入住时把预订状态改成已入住,房间状态改成在住;退房时写入离店时间,房间状态改成空闲;结算时生成账单并更新账单状态。每一步都有事务,接口顺序固定,答辩演示就不会乱。
@Transactional(rollbackFor = Exception.class) public Result checkout(Long checkinId, LocalDateTime checkoutTime) { Checkin checkin = checkinMapper.selectById(checkinId); if (checkin == null || checkin.getStatus() == 2) { return Result.fail("入住记录不存在或已退房"); } // 1. 更新入住记录:离店时间、状态 checkinMapper.checkout(checkinId, checkoutTime); // 2. 释放房间 roomMapper.updateStatus(checkin.getRoomId(), 1); // 3. 生成账单,金额由入住天数和房型价格计算 billService.createBill(checkinId); return Result.ok("退房成功"); }这段逻辑的参数是checkinId和checkoutTime,checkoutTime用服务端时间更稳,前端传时间只做展示。事务边界覆盖入住更新、房态释放、账单生成三步,任何一步失败都回滚。注意账单金额不要用前端传的总价,后端按check_in_time和check_out_time计算天数,再乘房型价格,押金和其他消费单独累加。这样答辩时被问“金额怎么来的”能直接指到 Service。
提示:状态流转接口先画一张状态表再写代码,把“待入住→已入住→已退房”和“待入住→已取消”写清楚,后面加“换房”时不容易把房态改错。
4. 前端页面与联调:JSP/Layui 表格请求怎么对上后端分页
页面能打开不等于联调成功。小型酒店管理系统的前端常见组合是 JSP + Layui 或 JSP + Bootstrap,表格分页、表单提交、日期选择器是最容易对不上的三块。联调时先看浏览器 Network 里的请求参数,再看后端接收到的参数名,最后看返回 JSON 的字段名。这一章按“表格分页—上下文路径—表单日期”的顺序,把前端请求和后端接收对齐。
4.1 JSP 页面怎么拿到后端分页数据:参数名先对齐
Layui 表格默认用page和limit传分页,后端如果写成current和size,就会出现“第一页正常、点第二页还是第一页”的假分页。常见做法是前端不改,后端用@RequestParam(defaultValue = "1") Integer page和@RequestParam(defaultValue = "10") Integer limit接。下面这段前端代码把ctx拼在接口前面,where里放筛选条件,templet处理金额显示。
layui.use(['table', 'form'], function () { var table = layui.table; table.render({ elem: '#roomTable', url: ctx + '/api/room/page', // ctx 由 JSP 注入,避免上下文路径写死 method: 'get', where: { keyword: '', status: 1 }, page: true, limit: 10, cols: [[ { field: 'roomNo', title: '房间号', width: 100 }, { field: 'typeName', title: '房型', width: 140 }, { field: 'price', title: '价格', width: 100, templet: function (d) { return Number(d.price).toFixed(2); }}, { field: 'status', title: '房态', width: 100, templet: function (d) { return d.status === 1 ? '空闲' : (d.status === 2 ? '已预订' : (d.status === 3 ? '在住' : '维修')); }} ]] }); });这段请求的逻辑是:url带上上下文路径,where里的keyword和status会作为查询参数拼到 URL 上,page和limit由 Layui 自动带。参数上,limit要和后端分页大小一致,templet里用Number(d.price).toFixed(2)防止后端返回字符串时出现199.0这种显示。后端接收分页的代码要返回 Layui 认识的格式,常见是code、msg、count、data四个字段,count是总条数,不是当前页条数。
@GetMapping("/page") public Result page(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer limit, @RequestParam(defaultValue = "") String keyword, @RequestParam(required = false) Integer status) { int offset = (page - 1) * limit; List<RoomVO> rows = roomMapper.pageRoom(offset, limit, keyword, status); long count = roomMapper.countRoom(keyword, status); return Result.table(count, rows); }这段后端代码的逻辑是把page换算成offset,再查列表和总数。参数上,page从 1 开始,limit不要允许前端传太大,常见做法是后端限制最大 100 条,防止一次拉全表。status用required = false,不传就查全部房态。Result.table里把count和data对上,前端表格才能正确显示分页条。
4.2 静态资源与上下文路径:/hotel 和 / 的差别
JSP 项目部署到 Tomcat 后,访问路径常常带项目名,比如http://127.0.0.1:8080/hotel/login.jsp。页面里如果写死/static/layui/layui.js,浏览器会去http://127.0.0.1:8080/static/layui/layui.js找,结果 404。正确做法是用pageContext.request.contextPath拼上下文,或者在一个全局 script 里定义ctx。下面这段 JSP 把 CSS、JS 和全局ctx放在一起,后面所有接口都以ctx开头。
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <link rel="stylesheet" href="${pageContext.request.contextPath}/static/layui/css/layui.css"> <script> // 全局上下文路径,页面内所有接口和静态资源都用它拼 var ctx = "${pageContext.request.contextPath}"; </script> <script src="${pageContext.request.contextPath}/static/layui/layui.js"></script> </head> <body> <table id="roomTable" lay-filter="roomTable"></table> </body> </html>这段 JSP 的逻辑是把上下文路径在页面加载时注入成 JS 变量,静态资源用 EL 表达式拼。参数上,contentType要写text/html;charset=UTF-8,否则中文页面可能乱码;ctx在 JSP 里是字符串,到了 JS 里就是普通变量。如果项目部署在根路径,ctx是空字符串,拼出来是/api/...,也不会多一个斜杠。
注意:静态资源 404 时先看 Tomcat 部署的 Application Context,再看 Spring MVC 有没有拦截
/static/**。拦截器配了addPathPatterns("/**")却忘了排除静态资源,CSS 和 JS 会被登录拦截器挡住,页面样式全丢。
4.3 表单校验与日期格式:前端传 yyyy-MM-dd,后端别收成带 T 的字符串
日期是联调里最容易出后悔药的地方。Layui 日期选择器默认返回yyyy-MM-dd,后端用LocalDate接一般没问题;如果前端用了new Date().toISOString(),会变成2025-01-01T00:00:00.000Z,后端按yyyy-MM-dd解析直接报错。常见做法是前端统一格式化,后端用@DateTimeFormat指定格式。金额输入框限制两位小数,提交前parseFloat再toFixed(2),不要直接把带货币符号的字符串传到后端。
// 表单提交前统一处理日期和金额 form.on('submit(saveReservation)', function (data) { var formData = data.field; formData.roomId = Number(formData.roomId); formData.guestId = Number(formData.guestId); formData.deposit = Number(formData.deposit || 0).toFixed(2); // checkInDate/checkOutDate 由日期选择器产出 yyyy-MM-dd $.ajax({ url: ctx + '/api/reservation/create', type: 'post', contentType: 'application/json;charset=UTF-8', data: JSON.stringify(formData), success: function (res) { if (res.code === 200) { layer.msg('预订成功'); table.reload('roomTable'); } else { layer.msg(res.msg); } } }); return false; });这段提交逻辑的关键是contentType用application/json,并且data用JSON.stringify,因为后端 Controller 用了@RequestBody。参数上,roomId和guestId转成数字,deposit保留两位小数字符串或数字,具体看 DTO 类型。日期字段不要在前端做时区转换,yyyy-MM-dd原样传,后端用@DateTimeFormat(pattern = "yyyy-MM-dd")接LocalDate。
public class ReservationDTO { private Long roomId; private Long guestId; @DateTimeFormat(pattern = "yyyy-MM-dd") private LocalDate checkInDate; @DateTimeFormat(pattern = "yyyy-MM-dd") private LocalDate checkOutDate; private BigDecimal deposit; }这段 DTO 的逻辑是把前端字段一对一接住,日期格式显式声明,金额用BigDecimal。参数上,@DateTimeFormat对表单和查询参数生效,如果请求体是 JSON,常见做法是用@JsonFormat(pattern = "yyyy-MM-dd")。两个注解别混用,混用后会出现“表单能传、JSON 不能传”的怪问题。
5. 联调与部署排查:五个高频翻车点的现象、原因和解决
这套系统在本地跑通、换台机器就翻车,通常不是代码逻辑突然坏了,而是环境、路径、编码、连接串这些“黑匣子”参数没对齐。排查顺序我一般从启动日志开始,再看数据库连接,再看浏览器 Network,最后才改业务代码。下面五个点按出现频率排,基本覆盖小型酒店管理系统从启动到演示的主要翻车场景。
5.1 启动与连接类排查:端口、库账号、编码和时区
现象一:启动报端口占用,或者访问 404。原因常见是 Tomcat 的 8080 被别的进程占了,或者项目部署的上下文不是根路径,访问地址少了项目名。解决是先看启动日志里的Port already in use,换端口或结束占用进程;再看 Tomcat 部署后的 Application Context,访问时带上/hotel这类前缀。不要一看到 404 就改 Controller 映射,先确认请求有没有打到项目里。
现象二:数据库连接失败,日志里是Access denied或Unknown database。原因一般是连接串里的库名、用户名、密码和本机 MySQL 不一致,或者 MySQL 没启动。解决是把连接串单独拿出来,用客户端先连一次,确认库存在、账号有权限。下面这段连接串是常见写法,useUnicode和characterEncoding解决中文,serverTimezone解决时间差 8 小时。
# 连接串示例:库名、账号、密码、编码、时区逐项对齐 jdbc.url=jdbc:mysql://127.0.0.1:3306/hotel?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai jdbc.username=hotel_user jdbc.password=hotel_pass这段配置的逻辑是把连接参数集中管理,不在 Java 代码里散落。参数上,hotel是库名,hotel_user和hotel_pass是本机账号,serverTimezone要和数据库服务器时区一致。如果启动日志里时间比实际少 8 小时,先查这里,再查实体类时间字段类型。
现象三:页面中文乱码,或者保存后数据库里是问号。原因可能是 JSP 页面没写charset=UTF-8、请求过滤器没设编码、数据库表字符集不是utf8mb4、连接串没带characterEncoding。解决是按“页面—请求—连接—表”四层排查:JSP 加contentType,web 层加编码过滤器,连接串带characterEncoding=utf8,建表用utf8mb4。四层缺一层都可能乱码,不要只改一处就重启。
// 编码过滤器:放在过滤器链最前面 public class EncodingFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); resp.setCharacterEncoding("UTF-8"); chain.doFilter(req, resp); } }这段过滤器的逻辑是统一请求和响应编码,参数写死UTF-8即可。注意setCharacterEncoding要在读取参数之前调用,过滤器顺序要配在登录拦截器前面。如果用了 Spring 的CharacterEncodingFilter,就不要重复造轮子,确认forceEncoding打开。
5.2 业务与页面类排查:重复预订、金额精度和静态资源
现象四:同一房间同一日期出现两条预订,演示时被问住。原因常见是先查冲突再插入,两个请求几乎同时进来,都查到 0 条冲突,然后都插入成功。解决分两层:业务层用SELECT ... FOR UPDATE锁住房间行,或者在预订表上建唯一索引兜底。唯一索引不能完全表达日期区间重叠,但能防完全相同的日期段重复插入,答辩时能讲清“数据库兜底 + 业务校验”的组合。
-- 兜底索引:防完全相同的房间和日期段重复插入 ALTER TABLE reservation ADD UNIQUE KEY uk_room_date (room_id, check_in_date, check_out_date);这段 SQL 的逻辑是给预订表加一个唯一约束,参数是房间 ID 和入住、离店日期。注意这个索引只能防“三段完全相同”的重复,防不住1-3和2-4这种交叉重叠。交叉重叠仍要靠业务查询check_in_date < 离店 AND check_out_date > 入住判断,必要时在事务里锁房间行。演示前把这条边界讲清楚,比只贴代码更加分。
现象五:金额显示199.999999,或者退房结算多出几分钱。原因多是金额用了double或float,或者在 Java 里做了浮点累加。解决是数据库用DECIMAL(10,2),实体用BigDecimal,加法用add(),乘法用multiply(),最后setScale(2, RoundingMode.HALF_UP)。前端展示用toFixed(2)只解决显示,不解决计算精度,计算必须放在后端。
// 账单金额计算:天数 * 房价 + 其他消费,最后保留两位 BigDecimal roomAmount = roomPrice.multiply(new BigDecimal(days)); BigDecimal total = roomAmount.add(otherAmount) .setScale(2, RoundingMode.HALF_UP);这段计算的逻辑是先算房费,再加其他消费,最后统一保留两位。参数上,days用ChronoUnit.DAYS.between(checkIn, checkOut)算,至少为 1;RoundingMode.HALF_UP是常见四舍五入规则。不要在前端算总价再传给后端,后端要按自己的数据重算,防止演示时改前端数字对不上账。
现象六:静态资源 404,页面样式全丢。原因常见是上下文路径写死、拦截器拦截了/static/**、资源目录不在webapp下。解决是先看浏览器请求的完整 URL,再对比项目部署路径,最后看拦截器排除规则。JSP 里用pageContext.request.contextPath拼,Spring MVC 配置里排除静态资源,Tomcat 部署时确认webapp/static或 `resources
本文还有配套的精品资源,点击获取