1. 人才公寓客房预订,为什么我最终选了Java Servlet
先说项目背景。我之前参与过一个人才公寓配套的信息化改造,几十间公寓分布在几栋楼里,日常预订、入住、退房全靠前台纸质台账。赶上集中分配或者换季调整的时候,台账翻来翻去容易出错,住户想看空房、想提前订房也完全依赖电话沟通。客户提出的需求很直接:要一个基于 Web 的客房预订系统,住户能查房、能下单,管理员能管房态、能看订单。当时项目没有专职运维,工期和预算都卡得很紧,我在技术选型上权衡了一圈,最终选了基于 Java Servlet 的方案——Servlet + JSP + JDBC,部署到 Tomcat,不引额外的微服务框架。
这个选择不是拍脑袋。人才公寓的预订场景特点很明确:并发量有限、业务规则清晰、页面数量也就十来个。这种规模下,Servlet 反而是最透明的技术路线,它能让你清楚地看到一次 HTTP 请求从进入容器到返回响应的完整生命周期,出问题排查起来非常直观。相比之下,用 Spring Boot 虽然写起来更爽,但要引入一堆起步依赖,容器内省机制、自动装配、事务代理这些概念对小团队来说反而增加了学习成本。
1.1 项目规模与技术栈
我把整个系统的模块按角色拆成了三块:住户端负责注册登录、查看房源、下单支付、查看我的订单;前台端负责处理入住登记、退房、修改房态;管理员端负责房间信息维护、订单管理、价格设置。页面模块各归各类,但没有做成前后端分离,JSP 页面加少量原生 JavaScript,够用就行。
具体的技术栈如下:
| 组件 | 选型 | 说明 |
|---|---|---|
| JDK | 1.8 | 稳定且环境普及率高 |
| Servlet | 4.0 | 使用 @WebServlet 注解,省去大量 web.xml 配置 |
| Web 容器 | Tomcat 9 | 轻量,适合中小型系统部署 |
| 数据库 | MySQL 5.7 | 关系型存储,事务支持好 |
| 数据访问 | JDBC + Druid 连接池 | 没有用 ORM 框架,SQL 可控性更高 |
| 前端 | JSP + Bootstrap | 快速搭建后台管理界面 |
有人可能会问,既然都 2024 年了,为什么不用 Spring Boot?我的回答是:这个项目本身就是一个“功能边界非常明确”的预订系统,没有复杂到需要依赖注入、切面编程的颗粒度。用原生 Servlet,意味着一共就几张 JSP、十几个 Servlet 类、三四个 Service、三四个 DAO,代码结构反而容易讲清楚。后续如果真需要引入框架,这套分层也能很平滑地迁移——DAO 和 Service 的业务逻辑不变,只换控制器那层。
1.2 三层架构如何划分
我采用的是经典的 MVC 变体,但更接近 Controller-Service-DAO 三层:Servlet 只做参数接收、会话校验、请求转发;Service 承载业务规则,比如判断房间状态、计算订单金额、维护订单状态机;DAO 层通过 JDBC 操作数据库,每张表配一个对应的操作类。
分层有一个非常实际的好处:当系统需要增加“前台代住户下单”的功能时,Servlet 层只需要多一个入口,Service 层的核心方法可以复用,DAO 不变。我在这个项目里一共写了三个 Service:UserService、RoomService、OrderService,后期加功能基本都只在这三个类上做加法,没有出现一改需求就要翻全项目的地步。
这种结构对新手也很友好。调试的时候,从浏览器请求出发,Servlet 里打一行日志能看到参数进来没有,Service 里打一行看到业务判断结果,DAO 里打一行看到 SQL 参数绑定正确与否,问题基本就能定位了。相比框架自动完成一切、报错信息要翻十层栈,这个体验对一线开发者和维护人员都非常有价值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计:房源、订单、租客三张核心表的字段取舍
数据库是整个预订系统的地基,字段设计直接决定后续业务代码怎么写。我第一版设计的时候把表拆得过细,房间类型单独建表、楼栋单独建表、价格策略单独建表,结果开发到一半发现很多表根本用不上,反而让连表查询变复杂。后来我砍掉了冗余设计,只保留三张主表:tb_user、tb_room、tb_order,外加一张系统配置表用于存储计费规则。
2.1 tb_user 用户/租户表
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键自增 |
| username | varchar(20) | 登录账号,唯一 |
| password | varchar(64) | 加盐哈希后的密码,不存明文 |
| real_name | varchar(20) | 真实姓名 |
| phone | varchar(11) | 手机号,唯一索引 |
| role | tinyint | 1 住户,2 前台,3 管理员 |
| status | tinyint | 1 正常,0 冻结 |
| create_time | datetime | 注册时间 |
role 和 status 都用 tinyint 而不是字符串,我是有意这么做的。字符串可读性好,但在 Java 里每次比较都得写 "ADMIN".equals(...),容易因为大小写不一致出问题;tinyint 配一个常量类,代码里就用变得很简单。字段注释写得清楚,查询看到数字也知道含义。
2.2 tb_room 房间表
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键自增 |
| room_no | varchar(10) | 房号,唯一 |
| room_type | tinyint | 1 单间,2 双人间,3 套间 |
| floor | tinyint | 所在楼层 |
| price | decimal(10,2) | 日租金 |
| status | tinyint | 1 空闲,2 已锁定,3 已入住,4 维修中 |
| remark | varchar(200) | 备注信息 |
房型没有单独建表,因为整个项目的房型就三种,在代码里用一个枚举就能管住。如果后面真的要扩展为“不同房型不同配置”,也能单独加一张字典表,而不需要改动现有订单逻辑。这个设计属于“可演进”而不是“一步到位”,实际项目中通常更实用。
2.3 tb_order 订单表
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键自增 |
| order_no | varchar(32) | 业务单号,唯一 |
| user_id | int | 下单住户 |
| room_id | int | 预订房间 |
| check_in_date | date | 计划入住日 |
| check_out_date | date | 计划退房日 |
| total_amount | decimal(10,2) | 订单总金额 |
| status | tinyint | 1 待支付,2 已确认,3 已入住,4 已完成,5 已取消 |
| create_time | datetime | 下单时间 |
| pay_time | datetime | 支付时间,可空 |
订单号单独用 order_no 而不是直接用自增 id,这一点很重要。业务单号要能在前台报修、对账、客服核查时快速沟通,比如 “DD2403151001” 这种格式一看就知道是 2024 年 3 月 15 日的订单。我在代码里生成单号时采用 “DD + yyyyMMdd + 四位随机数” 的规则,再配合唯一索引防止冲突。
索引设计上也做了一些取舍:
- tb_user 的 username、phone 建唯一索引,防止同一人重复注册。
- tb_room 的 room_no 建唯一索引,保证房号不重叠。
- tb_order 的 order_no 建唯一索引,避免业务单号重复。
- tb_order 上建了一个联合索引 (user_id, check_in_date),从数据库层面阻止同一住户同一天重复下单。
为什么不用外键?说实话,很多实际项目里外键往往是维护的负担。如果订单表外键关联房间表,那么改房号、删房间时都要考虑级联操作,开发期调试数据很不方便。我把一致性交给事务和业务代码去保证,数据库只保留索引层面的约束,这个方案在中小系统里完全是够用的。
3. 核心请求链路:从预订按钮到数据库落库的完整旅程
这一章我要把一次“用户点击预订按钮”的完整请求链路拆开讲。很多人知道 Servlet 能接收请求,但真正到动手写代码的时候,经常弄不清参数校验、会话判断、业务调用、事务管理各自该放在哪一层。我以实际代码为例展开。
3.1 前端表单提交与 Servlet 入口
预订页面是一个简单的 Bootstrap 表单,用户选好房间、入住日期、退房日期后点击提交。我用最普通的表单 POST 提交,没有用 AJAX——不用为了炫技把一个简单的表单交互搞复杂。前端只负责收集数据,提交给后端的 /order/create 地址。
对应的 Servlet 代码:
java复制@WebServlet(name = "CreateOrderServlet", urlPatterns = "/order/create")
public class CreateOrderServlet extends HttpServlet {
private final OrderService orderService = new OrderService();
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
request.setCharacterEncoding("UTF-8");
HttpSession session = request.getSession(false);
User loginUser = (User) session.getAttribute("loginUser");
if (loginUser == null) {
response.sendRedirect(request.getContextPath() + "/login.jsp");
return;
}
String roomIdParam = request.getParameter("roomId");
String checkIn = request.getParameter("checkInDate");
String checkOut = request.getParameter("checkOutDate");
// 基本非空校验
if (roomIdParam == null || checkIn == null || checkOut == null
|| roomIdParam.isEmpty() || checkIn.isEmpty() || checkOut.isEmpty()) {
request.setAttribute("message", "请完整填写预订信息");
request.getRequestDispatcher("/order/booking_form.jsp").forward(request, response);
return;
}
int roomId = Integer.parseInt(roomIdParam);
boolean success = orderService.createOrder(loginUser.getId(), roomId, checkIn, checkOut);
if (success) {
response.sendRedirect(request.getContextPath() + "/order/list.jsp");
} else {
request.setAttribute("message", "房间已被预订或存在未完成订单,请刷新后重试");
request.getRequestDispatcher("/order/booking_form.jsp").forward(request, response);
}
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws IOException {
response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
}
}
这里有一个小细节:doPost 才允许创建订单,doGet 直接返回 405。很多人图省事把创建订单写在 doGet 里,导致用户通过一个简单的 URL 链接就能改变系统状态,这是很危险的做法。我后面的权限章节还会专门展开聊。
request.setCharacterEncoding("UTF-8") 这行一定要放在第一次调用 getParameter 之前。我之前犯过放在后面的错误,结果中文参数全部乱码,排查了半天才发现是编码设置晚了。
3.2 Service 层的事务边界
Servlet 本身不该直接碰 SQL。我把创建订单的业务逻辑集中到了 OrderService.createOrder 方法里,这个方法是整个系统最核心的代码之一:
java复制public boolean createOrder(int userId, int roomId, String checkIn, String checkOut) {
Connection conn = null;
try {
conn = DBUtil.getConnection();
conn.setAutoCommit(false);
// 第 1 步:尝试锁定空闲房间
int rows = roomDao.updateStatus(conn, roomId, RoomStatus.LOCKED, RoomStatus.IDLE);
if (rows != 1) {
conn.rollback();
return false;
}
// 第 2 步:生成订单号并计算金额
double price = roomDao.getPrice(conn, roomId);
double total = calcTotalAmount(price, checkIn, checkOut);
String orderNo = generateOrderNo();
Order order = buildOrder(userId, roomId, orderNo, checkIn, checkOut, total);
int insertResult = orderDao.insert(conn, order);
if (insertResult != 1) {
conn.rollback();
return false;
}
conn.commit();
return true;
} catch (Exception e) {
try {
if (conn != null) conn.rollback();
} catch (SQLException ex) {
log.error("回滚失败", ex);
}
return false;
} finally {
try {
conn.setAutoCommit(true);
DBUtil.close(conn);
} catch (SQLException ignored) {
}
}
}
这段代码里有三个关键决策需要解释。
第一,房间锁定用条件 UPDATE,而不是先 SELECT 再 UPDATE。看过课件的朋友应该对这个有概念:两个用户同时请求同一间房,如果都先 SELECT 到 status=1,然后都执行 UPDATE,那么两个请求都会成功,导致超卖。用 UPDATE ... WHERE id=? AND status=1 的方式,数据库会在更新时判断当前状态,只有一行生效,另外一方受影响行数为 0,直接判定失败。这是防超卖最简单可靠的手段。
第二,事务边界明确。锁定房间和插入订单必须在一个事务里,否则可能出现“房间锁定了,订单没创建成功”的中间状态。我在 DAO 层把 Connection 作为参数传递,保证两条数据库操作走的是同一个连接。
第三,所有异常统一回滚。因为这里没有用 Spring 的声明式事务,所以必须手动管理 Connection 的自动提交开关。我在 finally 块里把 autoCommit 恢复为 true,避免连接归还到连接池后残留错误状态。这个细节很隐蔽,但坑起来是要命的。
3.3 响应与页面跳转方式
创建成功之后我用了 sendRedirect 重定向到订单列表,这是 PRG 模式(Post/Redirect/Get),避免用户刷新页面时重复提交表单。如果创建失败,则 forward 回到预订页面,同时携带错误提示。
有一点经验给新手:重定向和转发不要混用错了。重定向会丢失 request 域里的属性,所以错误提示信息必须放在 request.setAttribute 里配合 forward,而不是放在重定向之后取。这是初学者最容易踩的坑之一。
4. 预订状态机与并发控制:防超卖、防重复预订的落地方案
这个系统真正有含金量的地方,不在页面写了多少个样式,而在订单状态怎么流转、并发抢占怎么控制、超时订单怎么回收。我把这三块单独拿出来讲。
4.1 房间状态与订单状态的联动关系
我用的状态码不是魔法数字,而是定义在常量类里:
| 状态码 | 房间表含义 | 订单表含义 |
|---|---|---|
| 1 | 空闲 | 待支付 |
| 2 | 已锁定 | 已确认 |
| 3 | 已入住 | 已入住 |
| 4 | 维修中 | 已完成 |
| 5 | - | 已取消 |
房间状态和订单状态并不是严格一一对应,需要靠业务逻辑维护。比如订单处于“已确认”时,房间状态应该是“已锁定”;订单变为“已入住”时,房间状态要同步改成“已入住”。我在 OrderService 中封装了状态变更方法,每次修改订单状态,都同时更新房间状态,保证两边数据一致。
4.2 条件 UPDATE 与唯一索引双保险
前面已经演示了条件 UPDATE 的 SQL。这里补一下对应的 DAO 方法:
java复制public int updateStatus(Connection conn, int roomId, int newStatus, int expectedStatus) throws SQLException {
String sql = "UPDATE tb_room SET status = ? WHERE id = ? AND status = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, newStatus);
ps.setInt(2, roomId);
ps.setInt(3, expectedStatus);
return ps.executeUpdate();
}
}
executeUpdate 的返回值是受影响行数。如果返回 1,说明房间从“空闲”成功变成了“已锁定”,业务可以继续;如果返回 0,说明状态已经被其他人改了,直接判定订单失败。这个方案比数据库悲观锁、乐观锁版本号都简单,且不存在需要在代码里画 round of locking 的问题。
同一用户重复预订,我用了联合唯一索引兜底。订单插入时,如果 (user_id, check_in_date) 已经存在记录,数据库会报 DuplicateKeyException,我在 Service 层捕获这个异常后返回“您当天已有预订记录”。这比在应用层先查询再判断要可靠得多,因为应用层的“先查后插”同样存在竞态窗口。
4.3 超时未支付订单的自动释放
预订生成的订单,如果 30 分钟内没有支付,房间应该释放给其他人。我在 ServletContextListener 里启动了一个轻量定时任务,用 ScheduledExecutorService 实现,不需要引入额外框架。
java复制@WebListener
public class OrderTimeoutListener implements ServletContextListener {
private ScheduledExecutorService scheduler;
@Override
public void contextInitialized(ServletContextEvent sce) {
scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleWithFixedDelay(new OrderTimeoutTask(), 5, 30, TimeUnit.SECONDS);
}
@Override
public void contextDestroyed(ServletContextEvent sce) {
scheduler.shutdownNow();
}
}
定时任务的核心 SQL 需要同时做两件事:把超时订单置为“已取消”,把对应房间恢复为“空闲”。这里有个 MySQL 的语法坑:不能在 UPDATE 的子查询中直接引用要更新的同一张表。我当时的写法是先查出超时订单的 room_id 列表作为临时表,再做更新:
sql复制UPDATE tb_room
SET status = 1
WHERE status = 2
AND id IN (
SELECT room_id FROM (
SELECT o.room_id
FROM tb_order o
WHERE o.status = 1
AND o.create_time < DATE_SUB(NOW(), INTERVAL 30 MINUTE)
) tmp
);
UPDATE tb_order
SET status = 5
WHERE status = 1
AND create_time < DATE_SUB(NOW(), INTERVAL 30 MINUTE);
这两条 SQL 在同一个事务里执行,保证订单取消和房间释放的原子性。定时任务每 30 秒跑一次,将“待支付超过 30 分钟”的订单自动淘汰。
有人可能会说,30 秒一轮会不会频繁锁表?对于人才公寓这种规模,数据量几千单以内,这个频率完全没压力。我没有引入 Quartz,因为 Servlet 容器自带的启动监听加线程池就够了,少一个 Jar 少一个维护点。
5. 角色权限与数据隔离:三个角色各管一摊
系统不用做到像运营后台那么复杂,但角色权限边界必须清楚。我把使用者分成三类:住户、前台、管理员。他们能看的数据、能做的操作完全不一样。
5.1 三种角色的操作边界
| 功能 | 住户 | 前台 | 管理员 |
|---|---|---|---|
| 浏览房源 | 可见 | 可见 | 可见 |
| 创建订单 | 本人 | 可代住户下单 | 可代住户下单 |
| 查看订单 | 仅本人 | 全部订单 | 全部订单 |
| 处理入住/退房 | 无 | 可以 | 可以 |
| 运营房间状态 | 无 | 可标记维修 | 可新增/编辑房间 |
| 结算与价格配置 | 无 | 无 | 可以 |
数据隔离这块,我在 DAO 层强制区分查询方法。住户查询订单的接口只允许传 user_id,不允许先查出全部订单再在内存里过滤。前台和管理员则走另一个查询方法,不带 user_id 条件。这样从数据访问层面就杜绝了越权可能——哪怕 URL 改错了,SQL 也不会多返回记录。
5.2 LoginFilter 与角色校验的实现
我用一个过滤器统一做登录校验:
java复制@WebFilter(urlPatterns = {"/user/*", "/order/*", "/admin/*"})
public class LoginFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
HttpSession session = req.getSession(false);
User loginUser = (session == null) ? null : (User) session.getAttribute("loginUser");
if (loginUser == null) {
resp.sendRedirect(req.getContextPath() + "/login.jsp");
return;
}
if (req.getRequestURI().contains("/admin/")
&& loginUser.getRole() != Role.ADMIN
&& loginUser.getRole() != Role.FRONT_DESK) {
resp.sendError(HttpServletResponse.SC_FORBIDDEN, "无权访问后台");
return;
}
chain.doFilter(request, response);
}
}
这里的 URL 前缀规则是:凡是以 /user/、/order/、/admin/ 开头的路径,都必须登录;其中 /admin/ 下的操作还要求角色是管理员或前台。普通住户访问后台 URL 直接返回 403。
这个过滤器写在 WebFilter 里,省去了在 web.xml 中配置大量 filter-mapping 的麻烦。如果你用的是老版本 Servlet 规范,就要手动注册。
5.3 安全细节:密码哈希、SQL 注入与重放问题
用户表的密码字段在设计时就考虑到了,绝不能存明文。我在代码中用了加盐 SHA-256,虽然不如 BCrypt 专业,但在当时依赖受限的情况下已经是足够的底线。如果重新做这个项目,我会直接引入 jBCrypt,理由是它自带盐值、针对暴力破解有计算成本保护,写起来也更简单。
关于 SQL 注入,所有 DAO 方法一律使用 PreparedStatement,参数通过 setXxx 绑定,坚决不用字符串拼接 SQL。查询订单列表时,搜索条件、分页参数也都走同样方式。
还有一个容易被忽略的问题是“操作重放”。比如创建订单这个动作,我明确只允许 POST,并且使用「创建成功后重定向到列表页」的方式,让用户刷新浏览器时不会重复提交上一次的 POST 请求。更重要的是,订单编号里有日期和随机码,就算两个请求同时打进来,唯一索引也能拦下冲突订单。
6. 性能优化与部署实战:连接池、Tomcat 调优与常见异常
很多人觉得 Servlet 项目性能不行,其实 Servlet 本身非常快,性能高低更多取决于数据库连接、线程配置和索引设计。这个系统上线前,我做了几项针对性优化,实测效果很明显。
6.1 连接池配置与参数说明
项目里用的是 Druid 连接池,初始化代码放在一个 DbUtils 类中:
java复制public class DbUtils {
private static final DruidDataSource dataSource = new DruidDataSource();
static {
dataSource.setUrl("jdbc:mysql://localhost:3306/apartment_booking?useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai");
dataSource.setUsername("root");
dataSource.setPassword("******");
dataSource.setInitialSize(5);
dataSource.setMaxActive(20);
dataSource.setMinIdle(5);
dataSource.setMaxWait(3000);
}
public static Connection getConnection() throws SQLException {
return dataSource.getConnection();
}
}
几个关键参数:
- initialSize 5:启动时预先建立 5 个连接,避免首次请求时临时建连。
- maxActive 20:最大活跃连接数,这个数值要根据实际并发量估。公寓预订场景 20 个连接绰绰有余,再高的话 MySQL 也未必扛得住。
- maxWait 3000:获取连接最大等待时间 3 秒,超过就抛异常。这样即使连接池被占满,用户体验也是快速失败,而不是无限阻塞。
不用连接池会怎样?每个请求都建立一次物理连接,连接建立需要 TCP 握手,高并发时很容易达到 MySQL 的最大连接数,报 Too many connections 错误。用连接池之后,连接被复用,响应时间从几百毫秒降到了几十毫秒。
6.2 Tomcat 线程池与部署流程
Tomcat 默认的线程配置偏保守,我按系统的实际情况调整了 Connector:
xml复制<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
maxThreads="150"
acceptCount="100"
maxKeepAliveRequests="1"
URIEncoding="UTF-8" />
maxThreads 是处理请求的最大线程数,acceptCount 是队列容量。当并发请求超过处理能力时,多余的连接会先排队而不是直接拒绝。对预订系统来说,150 个线程完全够用,调太高反而增加上下文切换开销。
部署流程很简单:
bash复制mvn clean package
生成 WAR 包后,放到 Tomcat 的 webapps 目录,启动 Tomcat 时会自动解压部署。数据库连接参数我放在 src/main/resources/jdbc.properties 里,部署时根据环境修改,不写死在 Java 代码里。
6.3 我踩过的几个坑
第一,中文乱码。结果页面出现问号或者乱码时,要分两头排查:POST 请求看有没有在 Servlet 中调用 request.setCharacterEncoding("UTF-8"),并且必须在读取参数之前;响应看有没有设置 response.setContentType("text/html;charset=UTF-8")。Tomcat 9 默认对 GET 请求按 UTF-8 解码,但为了兼容老版本,我还是在 Connector 里显式加了 URIEncoding="UTF-8"。
第二,ClassNotFoundException: com.mysql.jdbc.Driver。Maven 打包时如果数据库驱动依赖是 provided 作用域,驱动 Jar 不会打进 WAR,那么运行时报错就非常干脆。我排查后把 mysql-connector-java 的作用域改成了 runtime,一切恢复正常。
第三,连接池无法连接。最常见的坑是 MySQL 没启动,或者防火墙挡住了 3306 端口。还有个隐蔽问题是连接空闲 8 小时之后被 MySQL 服务端断开,但连接池不知道,还在继续分配失效连接。解决办法是在 JDBC URL 上配置 autoReconnect=true,或者在连接池中开启 keepAlive 检查。
第四,Servlet 线程安全问题。Servlet 容器会为每个请求分配一个线程,且 Servlet 是单实例的。我在 Servlet 里只声明 final 的 Service 实例,或者干脆在方法内局部使用,避免用实例变量保存请求数据导致并发串数据。如果要在项目里用成员变量,必须做好同步,但这个项目里我没有这种需求。
7. 项目做完之后,我个人最想分享的几个体会
整个系统从设计到上线,我最大的感触是:技术选型没有绝对的好与坏,只有合不合适。用 Java Servlet 做人才公寓客房预订系统,我前前后后没写多少代码,但每一段代码都清楚地知道自己为什么要这么写。状态控制用条件 UPDATE、防重复用唯一索引、超时释放用定时任务,这些手段都不复杂,组合起来却构成了一个稳定可靠的核心。
如果再让我扩展这个项目,我会优先做两件事:一是把支付模块对接真正的支付渠道,现在只做到模拟支付和订单状态流转,没有处理支付回调、退款等细节;二是把对账报表加出来,比如按月份统计入住率、营收、房态分布,这些对管理者来说比在线预订本身更有实际价值。
最后分享一个很实用的小技巧:开发阶段在 Service 层的关键方法里加上简单日志,记录参数、操作结果、耗时。这个习惯能省下大量排查问题的时间。当时有一个关于“超时释放后房间状态没恢复”的诡异 bug,最后就是靠日志发现定时任务里的 SQL 更新了订单状态,却没有同步房间表状态,属于事务边界没包住。日志一打,几分钟就定位了。这个习惯,我一直留到现在。
