1. 项目骨架与技术选型:先把“为什么这么搭”想明白
1.1 基于 JavaWeb 的酒店管理系统到底解决什么问题
先把这个系统的边界划清楚。所谓“基于 JavaWeb 的酒店管理系统”,本质是给酒店前台和后台管理员用的一套内部业务工具,核心职责只有三件事:把房间资源管住、把入住和退房的账算清、把订单流转的每一步留痕。它不面向普通消费者做在线预订门户,也不做 OTA 渠道对接,就是一个能在局域网页面上跑起来、前台能点、数据能存、报表能查的实用型系统。
这套东西典型的使用者是三类角色:前台接待(办理入住、退房、换房)、客房管理员(维护房间清洁状态、维修状态)、系统管理员(管理员工账号、房型价格、查看统计)。功能上大概覆盖:登录鉴权、房型管理、房间管理、预订与入住、退房结算、订单查询、客户信息维护、简单营业统计。技术栈上,这类项目最常见的组合是Servlet + JSP + JDBC + MySQL + Tomcat,稍进一步会引入 Spring / SpringMVC / MyBatis 的轻量组合,或者干脆用 SpringBoot 把容器内嵌掉。
适合谁参考?如果你是刚接触 Web 开发、需要一个完整闭环的业务项目来练手,或者需要交一份能跑起来、能演示、逻辑说得通的课程项目,这套结构是最稳妥的:入门门槛低、调试路径短、每一层都能亲手摸到,不会像全栈框架那样把细节全藏起来。反过来说,如果你已经在做微服务、做高并发票务,这个系统的架构对你就没什么参考价值了——它的价值恰恰在于“简单、透明、可复现”。
我见过不少同类项目,代码能跑但一碰就塌:改个房型价格报错、两个人同时点预订就超卖、退房之后房态不刷新。这些问题不是技术难度大,而是设计阶段没把状态流转和数据边界想清楚。下面我按实际搭建顺序,把每个关键决策背后的理由掰开讲。
1.2 为什么这个场景适合 Servlet + JSP 打底,而不是一上来就前后端分离
很多人的第一反应是“现在都前后端分离了,还写 JSP 是不是太老”。这里要区分两个问题:教学/练习价值和工程现状。对于酒店管理这类内部系统,页面的交互复杂度其实很低——大部分是表单提交、列表分页、状态按钮。用 JSP 直接渲染,请求-响应路径短,一个 Servlet 拿完数据 setAttribute 转发到页面就结束了,中间没有跨域、没有 token 刷新、没有接口契约对不齐的问题。
我实际对比过两种做法。前后端分离方案里,前端要维护一套请求封装,后端要维护一套统一返回体,联调时字段名对不上、时间格式不一致、分页参数位置不同,这些琐碎问题能占掉一半开发时间。而这个系统的业务复杂度撑不起这些成本。所以更合理的选型是:以 Servlet + JSP 为主干,把 Controller 层写得薄,Service 层承载业务判断,DAO 层只负责 SQL。等到后面确实需要对接移动端,再把 Service 层原封不动暴露成 JSON 接口,改造成本很低。
选型时需要明确几个“不用什么”:
- 不用 EJB:容器依赖重,部署麻烦,对这个体量纯属负担。
- 不用重量级 ORM 的复杂映射:订单与房间的关联查询多数是两三条 SQL 能解决的,写清楚反而更好维护。用 MyBatis 是可以的,但 XML 里别塞动态 SQL 地狱。
- 不引入前端构建链:JSP 页面配一套现成的表格组件和少量 jQuery 就够,省掉 Node 环境和打包步骤。
提示:选型时先问自己“这个技术是为了解决我当前遇到的哪个具体问题”,答不上来就别引入。项目里每多一个依赖,后来接手的人就多一份理解成本。
1.3 三层包结构与职责边界,决定了后期改需求疼不疼
包结构看着是小事,实际上是后期维护的分水岭。我习惯按下面这样切:
com.hotel ├── controller // Servlet,只做参数接收、校验、跳转 ├── service // 业务规则,事务边界在这一层 ├── service.impl ├── dao // 只放 SQL 与结果集映射 ├── dao.impl ├── entity // 与表对应的实体 ├── vo // 页面展示用的组合对象 ├── util // 连接池、日期、加密、分页工具 └── filter // 编码过滤、登录校验这个划分的关键在于:事务必须落在 Service 层。我见过把事务写在 DAO 里的写法,一个“入住”操作要同时更新订单、房间状态、客户记录,事务开在 DAO 里就变成三个独立事务,中间任何一步失败都会留下脏数据。正确做法是 Service 层开一次连接、关自动提交、三个 DAO 共用同一个 Connection,全部成功再 commit,catch 到异常 rollback。这条如果一开始没设计好,后面补起来要动几乎所有业务方法。
另一个常被忽略的是VO 和 Entity 分离。订单列表页往往要显示房号、房型名、客户姓名,这些字段散在三张表里。如果直接拿 Entity 拼,页面上的${order.roomNo}根本取不到值。用 VO 承载查询结果,DAO 的 SQL 里直接 join 出来,页面渲染时字段一一对应,逻辑最清晰。
2. 核心业务建模:房态、订单、权限这三件事定生死
2.1 房型与房间必须拆成两张表,这是整个系统的地基
新手最容易犯的错是把“豪华大床房”和“301 房间”混成一条记录。这样一来,房型价格改动要逐条更新几十行,统计入住率也没法按房型聚合。正确的模型是:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| room_type | 房型定义,描述“什么样的房间” | id、type_name、price、capacity、bed_type、area |
| room | 具体房间,描述“哪一间” | id、room_no、type_id、floor、status |
| customer | 客户信息 | id、name、id_card、phone |
| orders | 订单主表 | id、order_no、customer_id、room_id、check_in、check_out、status、total_amount |
| order_log | 订单状态变更留痕 | id、order_id、from_status、to_status、oper_time、operator |
拆开之后,改价只动 room_type 一行;房间状态独立维护,前台看房态图时直接查 room 表;订单只引用 room_id,历史价格靠订单里的快照字段(比如当时成交单价)保存,避免改价把老订单金额也改了。这个“价格快照”细节非常关键,我踩过坑:早期订单总额是实时按房型现价算的,房型一调价,三个月前的历史订单金额全变了,月底对账直接对不上。后来在 orders 表里加了 unit_price 和 total_amount 两个字段,下单时一次性写入,历史数据才稳定下来。
room 表的状态设计也有讲究。至少需要这几个值:空闲可住、已入住、待清洁、维修中、预订锁定。不要用布尔值is_free糊弄,因为“待清洁”和“维修中”在业务上都是不可售,但原因完全不同,报表要分开统计。用tinyint存状态码,配合常量类或枚举在 Java 侧做映射,页面渲染时统一转成中文。
2.2 订单状态机:把“能做什么”写死在代码里,而不是靠人记
订单流转是这个系统里最容易出 bug 的地方。一个订单从创建到结束,典型状态是:待确认 → 已预订 → 已入住 → 已退房,中途可能分支到“已取消”和“已违约”。如果没有明确的状态机约束,就会出现“已退房的订单又被点了入住”这种荒唐数据。
我的做法是在 Service 层写一个状态流转检查方法,把允许的迁移列成一张静态表:
| 当前状态 | 允许的下一步 | 触发动作 |
|---|---|---|
| 待确认 | 已预订、已取消 | 确认预订 / 取消 |
| 已预订 | 已入住、已取消、已违约 | 办理入住 / 客户取消 / 超时未到 |
| 已入住 | 已退房 | 退房结算 |
| 已退房 | 无 | 终态 |
| 已取消 | 无 | 终态 |
| 已违约 | 无 | 终态 |
每个业务方法进来第一件事就是assertCanTransfer(order.getStatus(), targetStatus),不通过直接抛业务异常,前端提示“当前订单状态不允许该操作”。这段代码只有二十来行,但能挡掉后续八成的数据错乱问题。
还有一个容易被忽略的点:房态和订单状态必须同步更新,且在同一事务里。办理入住时,既要改订单状态为“已入住”,也要把 room.status 改成“已入住”;退房时反过来,订单变“已退房”,房间变“待清洁”而不是直接变“空闲”——因为退房后房间需要打扫。这个“待清洁”中间态是很多同类项目里没有的,但它恰恰是真实酒店流程里必须有的一环,保洁完成后由客房管理员点一下“清洁完成”,房间才回到可售状态。
2.3 权限控制别做太细,粗粒度角色够用且不容易出错
酒店管理系统的权限通常是角色级的,不需要做到按钮级。三个角色:管理员、前台、客房,功能菜单分开就够了。实现上有两条路:Filter 拦截 + Session 判断,或者用拦截器配注解。对这个体量,Filter 更直接。
关键是要防住“直接用 URL 访问”这种低级绕过。很多人只在页面上把菜单藏起来,然后以为安全了,实际用户在地址栏敲一个roomList.jsp照样能进。正确做法是在 Filter 里维护一份“路径前缀 → 允许角色”的映射,请求进来先匹配路径,再比对 Session 里的 roleCode,不匹配直接跳 403 页面。这里有个细节:登录页、静态资源、登录接口本身必须放行,否则会陷入死循环重定向。
Session 的管理也别偷懒。用户登录成功后session.setAttribute("loginUser", user),同时设置 30 分钟超时;登出时session.invalidate()。我见过只清空属性不销毁 Session 的写法,在共享电脑上换个人登录,旧数据还在 session 里挂着,很容易串号。另外,密码存储绝对不能明文,至少用 MD5 加盐或者 BCrypt 哈希,登录时哈希比对。这一条在很多练习项目里被忽略,但只要这个系统有一点真实使用场景,就是硬伤。
3. 从零跑通的实操步骤与关键代码
3.1 环境与依赖清单,版本对齐能省掉半天折腾
先把环境列清楚,版本不匹配是新手上路最大的时间黑洞。我实际用下来最稳的组合是:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 8 或 11 | 8 兼容性最好,11 也稳,别上太新的 |
| Tomcat | 8.5 / 9.0 | 对应 Servlet 3.1 / 4.0,别用 10(包名变成 jakarta) |
| MySQL | 5.7 / 8.0 | 8.0 注意驱动包和时区参数 |
| 驱动 | mysql-connector-java 8.0.x | 5.7 用 5.1.x 也行,但统一用 8.0 驱动更省心 |
| 连接池 | Druid 或 HikariCP | Druid 自带监控页,调试方便 |
| 前端 | JSP + jQuery + Bootstrap | 表格和弹窗直接用现成样式 |
依赖的 Maven 坐标大致是 servlet-api(scope 设为 provided,因为 Tomcat 自带)、jstl、mysql-connector-java、druid、fastjson 或 gson、commons-lang3、log4j 或 slf4j。这里有个坑必须提:servlet-api 的 scope 一定写 provided,否则打 war 包时会把 servlet 类一起打进去,跟容器自带的冲突,启动就报 ClassCastException 或者找不到方法。
部署方式上,用 IDEA 配置 Tomcat 本地运行最方便,注意两件事:一是 artifact 要选war exploded,二是应用上下文路径设成/hotel,这样访问路径就是http://localhost:8080/hotel/login.jsp。如果想直接丢服务器,打成 war 扔进 webapps 目录也行,但要记得改数据库连接配置。
3.2 建表脚本与连接池参数,先把数据底座夯实
建表时几个类型选择直接影响后期体验,我把关键部分贴出来:
CREATE TABLE room_type ( id INT PRIMARY KEY AUTO_INCREMENT, type_name VARCHAR(50) NOT NULL, price DECIMAL(10,2) NOT NULL COMMENT '标准价,下单时快照到订单', capacity TINYINT DEFAULT 2, bed_type VARCHAR(20), status TINYINT DEFAULT 1 COMMENT '1上架 0下架' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE room ( id INT PRIMARY KEY AUTO_INCREMENT, room_no VARCHAR(20) NOT NULL UNIQUE, type_id INT NOT NULL, floor TINYINT, status TINYINT DEFAULT 1 COMMENT '1空闲 2已入住 3待清洁 4维修', KEY idx_type (type_id), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, customer_id INT NOT NULL, room_id INT NOT NULL, check_in DATE NOT NULL, check_out DATE NOT NULL, unit_price DECIMAL(10,2) NOT NULL COMMENT '成交单价快照', total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_room_date (room_id, check_in, check_out), KEY idx_customer (customer_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字符集统一用utf8mb4,别用 utf8,后者在 MySQL 里其实是 utf8mb3,存不了某些生僻字和特殊符号。金额字段用DECIMAL而不是FLOAT,浮点累加会出现0.1 + 0.2 = 0.30000000000000004这种事,金额计算错几分钱,对账时够你查一下午。
连接池用 Druid 的配置大概这样,重点在maxActive、maxWait和validationQuery:
driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/hotel?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false username=root password=your_password initialSize=5 maxActive=20 maxWait=3000 minIdle=5 validationQuery=SELECT 1 testWhileIdle=trueserverTimezone是 MySQL 8 驱动必带的,不写会在启动时报时区错误;maxWait设 3000 毫秒,连接池打满时最多等 3 秒就抛异常,而不是无限期挂着把线程拖死。这两个参数我都是被坑过才记住的——早期maxWait默认是无限,一次批量导入数据把连接全占了,页面直接卡死,没有任何报错,排查了半天。
3.3 房态查询与订单创建的核心实现
房态查询是使用频率最高的接口。前台最关心的不是“有哪些房间”,而是“某个日期段内哪些房间可订”。所以查询逻辑必须以订单为准:给定入住日期和离店日期,从 room 表里排除掉那些在这段时间内已有有效订单的房间。
SELECT r.id, r.room_no, r.floor, rt.type_name, rt.price FROM room r JOIN room_type rt ON r.type_id = rt.id WHERE r.status <> 4 AND rt.status = 1 AND r.id NOT IN ( SELECT room_id FROM orders WHERE status IN (2, 3) -- 已预订、已入住 AND check_in < #{checkOut} -- 区间重叠判断 AND check_out > #{checkIn} ) ORDER BY r.floor, r.room_no区间重叠的判断条件是已有的入住 < 新查询的离店 AND 已有的离店 > 新查询的入住,这个写法比“日期包含”更严谨,能覆盖交叉、包含、相邻等各种情况。写错这一句,就会出现“房间明明被占了还显示可订”的经典 bug。
下单环节必须加锁防并发。两个人同一秒钟抢同一间房,光靠先查后插是挡不住的。做法是在事务里对房间行加锁:
public String createOrder(OrderForm form, User operator) { Connection conn = null; try { conn = DataSourceUtil.getConnection(); conn.setAutoCommit(false); // 1. 行锁住房间,后续查询与插入在同一事务内完成 Room room = roomDao.selectForUpdate(conn, form.getRoomId()); if (room == null || room.getStatus() == STATUS_REPAIR) { throw new BizException("该房间当前不可预订"); } // 2. 冲突检测:同房间同区间是否已有有效订单 int conflict = orderDao.countConflict(conn, form.getRoomId(), form.getCheckIn(), form.getCheckOut()); if (conflict > 0) { throw new BizException("该房间在所选日期已被预订"); } // 3. 金额计算,注意按夜数而不是天数差 long nights = ChronoUnit.DAYS.between(form.getCheckIn(), form.getCheckOut()); if (nights <= 0) throw new BizException("离店日期必须晚于入住日期"); BigDecimal total = room.getPrice().multiply(BigDecimal.valueOf(nights)); // 4. 落单 + 改房态,同一事务 orderDao.insert(conn, buildOrder(form, room, total)); roomDao.updateStatus(conn, room.getId(), STATUS_BOOKED); conn.commit(); return orderNo; } catch (Exception e) { rollback(conn); throw new BizException(e.getMessage()); } finally { closeQuietly(conn); } }SELECT ... FOR UPDATE的作用是在事务期间锁住这一行,其他事务要改同一行会排队等待。这里锁的粒度是房间,不会影响其他房间的下单,代价可控。有一个细节:锁必须在同一个事务、同一个连接里,如果selectForUpdate和insert用了不同连接,锁就白加了。
金额计算那一行也值得说。很多项目直接用(checkOut - checkIn)的天数差,但住宿是按“夜”算的,1 号入住 3 号离店是两晚,ChronoUnit.DAYS算出来正好是 2,逻辑对得上;但如果用Date.getTime()相减再除 86400000,遇到跨夏令时或者时间带时分秒就会少算或多算。用LocalDate加ChronoUnit是最稳的。
3.4 分页与条件查询,别在页面层做循环过滤
列表页的分页必须落在 SQL 层,LIMIT offset, size配合一个COUNT(*)。我见过把全表查出来再在 JSP 里for循环截取的写法,数据量一过千,页面加载就要好几秒,而且翻页逻辑全是错位风险。
封装一个通用的分页对象:
public class PageResult<T> { private List<T> rows; private long total; private int pageNo; private int pageSize; public int getTotalPages() { return (int) Math.ceil((double) total / pageSize); } }DAO 里接收pageNo和pageSize,计算offset = (pageNo - 1) * pageSize。注意pageNo 要做下界保护,前端传个 0 或负数,offset会变成负值直接 SQL 报错,所以入口处统一pageNo = Math.max(1, pageNo)。
条件查询用动态拼 SQL 时,务必用PreparedStatement的占位符,别字符串拼接。房号、客户姓名、订单号、日期范围这些条件都是可选的,拼的时候按“是否非空”逐段追加,但值永远走setXxx。这既是防注入的基本功,也避免姓名里有单引号导致 SQL 语法错误——姓“欧阳”还好,名字里带个英文单引号的情况真的会遇到。
4. 踩坑实录与问题速查
4.1 中文乱码,三种表现对应三种修法
乱码是这个项目里出现频率最高的问题,而且往往不是一处,得按链路排查。我归纳成三种典型表现:
| 表现 | 根因 | 修法 |
|---|---|---|
| 表单提交后数据库里是问号 | 请求编码未设或连接串没写 characterEncoding | Filter 里request.setCharacterEncoding("UTF-8"),JDBC URL 补characterEncoding=utf8mb4 |
| 页面显示正常,查询条件带中文就查不到 | GET 请求参数编码问题 | Tomcat 8 以上默认 UTF-8,若仍异常,检查 server.xml 的 Connector 是否加了 URIEncoding |
| 响应内容中文变乱码 | 响应头未指定编码 | response.setContentType("text/html;charset=UTF-8"),JSP 页头<%@ page pageEncoding="UTF-8" %> |
统一的做法是写一个 EncodingFilter,@WebFilter("/*")拦截所有请求,进来先设请求编码,再chain.doFilter。注意 Filter 的注册顺序会影响结果,编码 Filter 必须排在最前面。另外setCharacterEncoding只对 POST 请求体有效,对 GET 的 query string 无效,这也是为什么有时候 POST 正常、GET 乱码——不是 Filter 写错了,是它的作用范围就到这。
4.2 日期类型在 Java 和 MySQL 之间来回转换的那些事
java.util.Date和java.sql.Date的混用是另一个高频坑。实体里图省事用java.util.Date,插库时ps.setDate(i, new java.sql.Date(date.getTime())),看着能跑,但两个问题躲不掉:
一是时分秒丢失。java.sql.Date只保留日期,入住时间里的时分秒会被截断。如果业务需要记录具体入住时刻,得用java.sql.Timestamp或者直接把字段设计成DATETIME。
二是时区偏移。MySQL 8 驱动如果不配serverTimezone,Date转字符串时可能按服务器默认时区处理,出现“存进去 2024-05-01,查出来 2024-04-30”的诡异现象。统一配serverTimezone=Asia/Shanghai之后这类问题基本消失。
新项目我一般直接用LocalDate/LocalDateTime,MyBatis 3.5 以上原生支持,JDBC 4.2 也有对应方法,省掉转换代码。如果还在用老驱动,那就在 DAO 层统一做一次转换,别让转换逻辑散落在各处,否则改起来要全项目搜。
4.3 重复预订和“幽灵房态”,并发场景必须实测
单机开发时几乎测不出并发问题,等到演示时两个人同时点,数据就花了。除了前面说的行锁,还要处理两个衍生情况:
第一种是订单取消后房态没回滚。客户取消了预订,订单状态变“已取消”,但 room.status 还停在“已预订”,这间房就永远查不出来了。解决办法是把“改订单状态”和“改房态”绑成一个原子操作,取消时判断这间房当天是否还有其他有效订单,没有才把房间放回空闲。
第二种是超时未入住的订单占着房。预订了但客人没来,系统里房间一直锁着。常规做法是加一个定时任务,每天凌晨扫一遍“已预订且入住日期已过”的订单,自动置为“已违约”并释放房态。这个任务用Timer或者ScheduledExecutorService都行,简单的一条 UPDATE 加一条 UPDATE,注意放在事务里。
提示:并发问题不要靠“我觉得应该没问题”来判断,写个简单的压测脚本,用 20 个线程同时请求同一个下单接口,看最终数据库里有几条有效订单。一次实测胜过十次推理。
4.4 部署与连接泄漏,上线前必须过一遍
本地跑得好好的,部署到服务器就报Too many connections,八成是连接没关。JDBC 的资源关闭有严格顺序:ResultSet → Statement → Connection,而且每个都要在 finally 里判空关闭。用连接池时,close()不是真关,是归还,但如果忘了调,连接就一直被占着,池子很快见底。
更省心的写法是用 try-with-resources:
try (Connection conn = ds.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, roomId); try (ResultSet rs = ps.executeQuery()) { // 处理结果 } }另外,war 包部署后如果改了 JSP 不生效,检查一下是不是用了war而不是war exploded——前者是打包后的静态副本,改源码不会自动同步。这个问题困扰过不少人,明明代码改了,页面上还是老样子,最后发现是部署方式选错了。
4.5 问题速查表
| 现象 | 优先排查点 | 处理方式 |
|---|---|---|
| 启动报驱动类找不到 | 依赖 scope 是否是 provided | 改成 compile 或确认 war 里有驱动 jar |
| 页面 404 | 上下文路径、Servlet 映射 | 检查 web.xml 或 @WebServlet 的 urlPatterns |
| 数据查出来是 null | 列名与实体属性名不一致 | 检查 resultMap 或开启驼峰映射 |
| 金额算出小数位异常 | 用了 float/double | 全链路改 BigDecimal |
| 退房后房态不更新 | 事务未提交或分属不同连接 | 合并到同一事务,检查 commit |
| 登录后刷新又回登录页 | Session 未正确写入或 Cookie 被禁 | 检查 setAttribute 与浏览器设置 |
5. 性能与安全的收尾打磨
5.1 索引不是越多越好,按查询模式来加
这个系统的数据量通常不大,但索引加对地方,列表页的响应能从几百毫秒降到几十毫秒。按实际查询频率,我一般加这几类:
room(room_no)唯一索引,房号查询和防重必备。room(type_id)、room(status),房态筛选常用。orders(room_id, check_in, check_out)联合索引,正好覆盖冲突检测那条 SQL 的 WHERE 条件。orders(customer_id),按客户查历史订单。
要注意的是联合索引的字段顺序。冲突检测 SQL 的条件是room_id = ? AND check_in < ? AND check_out > ?,把等值条件放最左,范围条件放后面,才能用上索引。如果把 check_in 放第一位,room_id 放第二位,这个索引在多数场景下就用不上了。
另外,SELECT *在列表页要尽量避免,只取页面需要的列。虽然在这个数据量下差别不明显,但养成习惯之后,迁移到大表时不会突然踩雷。翻页深度大的时候,LIMIT 10000, 20会扫过前一万行,可以考虑“记录上次最大 id”的游标分页,不过对这个系统属于过度设计,知道有这回事就行。
5.2 防注入和 XSS,两条线都得守
SQL 注入的防线就是 PreparedStatement,这一点前面提过,不再重复。但还有一种容易被忽略的情况:ORDER BY 后面的字段名不能用占位符。如果页面允许按“价格”“房号”排序,参数直接拼进 SQL,那就是一个现成的注入点。正确做法是维护一份允许排序字段的白名单,参数只用于选白名单里的值,不直接进 SQL。
XSS 则是另一个方向。这个系统的输入里包含客户姓名、备注、特殊要求,如果原样输出到页面,别人填一段<script>就能在你的后台执行。JSP 里输出用<c:out value="${...}" />或者${fn:escapeXml(...)},会自动转义尖括号和引号。用 EL 直接输出${name}是不转义的,这点要分清。
会话安全上还有两个小设置值得加:Cookie 加HttpOnly,防止脚本读取;关键操作(删除、退房结算)用 POST 而不是 GET,避免被链接预取或者误触发。这些细节单看都不起眼,凑在一起就是“这个系统能不能放心用”的分界线。
5.3 一个真实的小优化:把房态图查询合并成一次
前台最常用的页面是房态总览,一开始我写的是先查所有房间,再对每个房间查一次订单,典型 N+1。30 个房间就是 31 条 SQL,页面加载肉眼可见地慢。后来改成一条 SQL 用 LEFT JOIN 带出当前在住信息:
SELECT r.id, r.room_no, r.floor, r.status, rt.type_name, rt.price, o.order_no, o.check_out, c.name AS guest_name FROM room r JOIN room_type rt ON r.type_id = rt.id LEFT JOIN orders o ON o.room_id = r.id AND o.status = 3 LEFT JOIN customer c ON o.customer_id = c.id ORDER BY r.floor, r.room_no一条 SQL 出全部数据,Java 侧组装成 VO 列表直接渲染。改完之后首屏从一秒多降到一百毫秒以内。这个改动的价值不在于性能数字,而在于它说明了先想清楚“数据从哪来、一次要多少”,比事后调优有效得多。
我在实际做这类系统的体会是,业务逻辑本身不难,难的是把状态流转的边界、事务的范围、异常的处理想周全。很多人写完之后发现改一个需求要动五个文件,根子往往不在代码写得丑,而在最初建表和分层的时候没把职责切干净。真要做扎实,建议把“订单状态迁移表”和“房态流转图”先画在纸上,再动手写第一行 SQL,返工率会低很多。