做民宿租赁系统这件事,我一开始是想省事的。去年有位做城市民宿的朋友找我,说市面上能找到的开源项目,要么太重,要么前后端还在一起,改一个页面要拖着整个模板引擎跑。我只想要一套“能管房态、能下单、能结算”的系统。于是我干脆用 Java SpringBoot 写后端,Vue3 写前端,MyBatis 做数据访问层,MySQL 做存储,前后端彻底分离,重新整理了一套可以直接上线的民宿租赁系统源码。这段时间陆续有人问这套系统的技术细节,所以写成这篇内容,讲清楚它从数据库设计到部署上线的完整逻辑,也把最容易踩的几个坑一次说透。如果你正打算做毕业设计、学前后端分离实战,或者想快速搭一个民宿管理平台,这篇内容应该能帮你省下不少时间。
1. 从一套可复用的民宿租赁源码说起:项目定位与选型时的取舍
1.1 民宿租赁系统的业务边界
民宿和连锁酒店最大的区别在哪里?酒店按标准间、大床房售卖,每天一个牌价,客人到店办入住。民宿租的是整套房子或独立房间,同一个房源在不同日期的价格完全可能不一样,周五和周一价差两倍都是常态。再加上农家乐、短租公寓往往只有几间房,老板要自己看房态、算价、收押金。所以民宿系统的业务重心压在三个地方:
- 房源与房态管理:房东维护房源、房间、日历价格、可订库存;
- 订单流转:客人浏览、选日期、创建订单、支付、入住、退房、取消;
- 经营数据:订单统计、房源收入、评价情况,房东后台要看基础报表。
想清楚业务边界后,我没有把系统设计成酒店 PMS 的样子。酒店 PMS 有房务中心、夜审、房价计划一堆概念,民宿业务根本用不上。最后功能收敛成三个模块:用户端负责搜索房源和下单,房东端负责维护房态和订单,管理端只做基础的用户和房源审核。一个系统能长期用下去,不是功能多,而是每个功能都刚好够用。
1.2 为什么最终锁定 SpringBoot + Vue3 + MyBatis + MySQL
这套技术栈是反复比较后才定的,不是因为它“生态最强”。
SpringBoot 解决的是 Java 后端启动和集成的成本。配置少、能直接跑,一个可执行 Jar 包就能把整个后端带起来,对民宿这种需要快速部署的场景非常重要。权限用 JWT,定时任务用 @Scheduled,接口安全用过滤器,全都在一个进程里处理完。
Vue3 和 Element Plus 的组合对中后台页面很友好。Vue3 的 Composition API 大大提升了逻辑复用能力。房源搜索页、订单列表页、统计面板都有大量状态需要维护,用 reactive 管理查询表单,用 ref 管理列表结果,比 Vue2 的 data 写法清晰很多。Element Plus 组件齐全,日历、表格、表单校验都能直接拿来用。
MyBatis 我坚持用 XML 方式写 SQL。民宿业务有不少报表类查询,比如“统计某个房源某个月的入住晚数”,这种 SQL 用 MyBatis 的 select 标签维护最直观。MyBatis-Plus 虽然能减少 CRUD 代码,但业务越复杂,它那层抽象反而越会成为阻碍。直接写 SQL,SQL 优化和排查问题的时候都更快。
MySQL 则是因为民宿项目的数据量,在中型平台几百个房源、几万订单这个范围内,完全够用。选 MySQL 还有一个实际原因:部署资料最多,出问题很容易搜到解决方案。比起学一个新的数据库体系,把精力留在业务本身更值。
1.3 版本选择是最不起眼但最关键的决定
看到很多人搜“springboot版本太高”,我太理解这个情况了。SpringBoot 3.x 默认要求 JDK17,包名也从 javax.servlet 换成了 jakarta.servlet,很多老教程的代码直接编译报错。这套源码最终选用 SpringBoot 2.7.18 + JDK8,理由很实际:绝大多数人的服务器和本机还是 JDK8,跑 Maven 打包不用额外折腾环境;2.7 版本支持 javax 命名空间,和网上绝大多数资料兼容。
Vue3 这边用 Vite + Node 18+,Element Plus 按官方文档安装即可。如果你是新项目、团队没有 JDK8 包袱,直接上 SpringBoot 3.2 + JDK17 也可以,只是源码不是同一套。技术选型不追求最新,追求的是能稳定跑起来、代码能被人看懂。
2. 数据库先行:民宿表的字段设计与索引背后的考量
2.1 七张核心表比想象中要少
很多初学者一上来就设计二十多张表,全是主数据。实际上民宿系统 90% 的查询都集中在少量表上。最终我保留了七张核心表:
| 表名 | 用途 | 核心字段 |
|---|---|---|
| sys_user | 用户/房东/管理员 | id, username, password, phone, role_type, create_time |
| house | 房源主表 | id, owner_id, title, city, address, cover_url, status |
| room | 房间表 | id, house_id, room_name, bed_type, area, max_guest |
| room_price_date | 日期价格库存表 | id, room_id, price_date, price, stock |
| booking_order | 订单主表 | id, order_no, user_id, room_id, check_in_date, check_out_date, total_amount, status |
| order_status_log | 订单状态日志表 | id, order_id, from_status, to_status, remark, create_time |
| review | 评价表 | id, order_id, user_id, room_id, rating, content, create_time |
设计细节上,sys_user 的 role_type 用 tinyint:0 普通用户,1 房东,2 管理员。密码存 BCrypt 加密串,绝不能存明文。house 和 room 分开拆,是因为有些民宿是整栋出租,有些是一栋楼里分多个房间。为了扩展,哪怕整栋出租时只有一个房间,也保留两张表的结构。room 表不带 daily_price,因为房价放在日期表里。
booking_order 没有用 order 做表名,order 在 SQL 里是关键词,所以命名加了前缀。订单状态变更日志表非常重要,任何状态变化都插入一条记录,客服查纠纷时能确切知道订单是怎么从“待支付”变成“已取消”的。review 表通过 order_id 唯一约束保证一个订单只能评价一次。
2.2 为什么价格和库存要单独放到日期表
这是整套民宿数据库设计里最重要的一点。民宿和酒店的最大不同,就是同一个房源在不同日期的价格不同。如果只在 room 表里放一个 daily_price,房东改周末价格就得改整张表,前端要问“周五到周日这间房多少钱”,也只能写临时逻辑。
把价格和库存抽到 room_price_date 表之后,每个日期的价格变成一行数据,房间价格就是一组“日期 + 价格 + 库存”的列表。前端日历组件展示可订日期和价格,直接查这张表。计算一笔订单金额,SQL 里按日期范围求和即可:
SELECT COALESCE(SUM(price), 0) AS total FROM room_price_date WHERE room_id = #{roomId} AND price_date BETWEEN #{checkInDate} AND DATE_SUB(#{checkOutDate}, INTERVAL 1 DAY)这里注意为什么用 DATE_SUB 而不是直接包含退房日。退房日期是最后一天的 12 点,房费只算从入住当夜到退房前一晚,所以扣款区间是[checkInDate, checkOutDate)。这个细节如果不注意,就会多算一晚的钱,上线后所有订单金额都差一宿。
2.3 索引和约束:少建外键,多建普通索引
物理外键这个项目里一条都没建。原因很现实:MySQL 物理外键写入时要检查父表,行锁范围会扩大,并发一高就容易死锁;而且以后想把订单表拆分出去,物理外键根本没法迁移。替代方案是逻辑外键 + 普通索引。
哪些索引最值得建?
- room_price_date(room_id, price_date) 唯一索引:保证同一个房间同一天只有一行价格记录,这是防重复数据的兜底。
- booking_order(order_no) 唯一索引:订单号唯一,支付回调时用订单号做幂等。
- booking_order(user_id) 普通索引:用户查自己订单的高频路径。
- booking_order(status, create_time) 联合索引:定时任务扫描待支付超时订单非常快。
- booking_order(check_in_date) 普通索引:统计某天入住订单用得上。
另外,price_date 字段统一用 DATE 类型,不要用 DATETIME。日期语义就是日,用 DATE 能省空间,也不会出现 00:00:00 这种歧义。订单里 create_time 才用 DATETIME,这是两个不同的时间语义。
3. 后端接口与MyBatis数据访问层的硬核细节
3.1 接口设计保持简单,但要有统一风格
后端不搞炫技,所有接口走 RESTful,统一前缀 /api。登录接口返回 JWT token,其余需要登录的接口从请求头 Authorization 里读取 token。
| 方法 | 路径 | 功能 |
|---|---|---|
| POST | /api/auth/login | 登录,返回 token |
| GET | /api/house/search | 分页搜索房源 |
| GET | /api/house/{id} | 房源详情 |
| GET | /api/room/{roomId}/price | 查询价格日历 |
| POST | /api/order | 创建订单 |
| POST | /api/order/{orderNo}/pay | 模拟支付 |
| POST | /api/order/{orderNo}/checkin | 入住 |
| POST | /api/order/{orderNo}/checkout | 退房 |
| POST | /api/review | 提交评价 |
分页接口统一用 pageNum、pageSize 参数。返回结构统一是 Result ,里面 code=0 表示成功,非 0 表示业务错误。这样前端 axios 拦截器只看 code 即可,不用判断 HTTP 状态码。
全局异常处理再补充一个细节:业务异常主动 throw BizException,比如“价格已变化,请刷新后重试”。TechnicalException 留给真正的系统错误。全局异常处理器只对业务异常做包装,其余异常打印日志后返回“服务器开小差了”,不要把 SQL 报错信息直接抛给前端。
3.2 MyBatis XML 动态 SQL 是查询灵活性的关键
核心文件是 HouseMapper.xml 和 OrderMapper.xml。房源搜索的典型场景:关键字、城市、入住日期、价格区间同时过滤。纯注解 SQL 拼接起来很痛苦,XML 的 where 和 if 则清晰很多:
<select id="searchHouses" resultType="com.homestay.domain.vo.HouseVO"> SELECT h.id, h.title, h.city, h.address, h.cover_url, IFNULL(MIN(rpd.price), 0) AS min_price FROM house h LEFT JOIN room r ON r.house_id = h.id LEFT JOIN room_price_date rpd ON rpd.room_id = r.id <where> h.status = 1 <if test="keyword != null and keyword != ''"> AND (h.title LIKE CONCAT('%', #{keyword}, '%') OR h.city LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="city != null and city != ''"> AND h.city = #{city} </if> <if test="startDate != null and endDate != null"> AND EXISTS ( SELECT 1 FROM room_price_date rpd2 WHERE rpd2.room_id = r.id AND rpd2.price_date BETWEEN #{startDate} AND DATE_SUB(#{endDate}, INTERVAL 1 DAY) AND rpd2.stock > 0 ) </if> </where> GROUP BY h.id, h.title, h.city, h.address, h.cover_url </select>这里有个容易错的地方:LEFT JOIN + GROUP BY。如果 LEFT JOIN 出多条价格记录,聚集函数要注意。民宿按“房源”维度搜索,所以 GROUP BY h.id,min_price 取最低价作为起价。
动态更新订单状态用 set 标签:
<update id="updateOrderStatus"> UPDATE booking_order <set> <if test="payTime != null">pay_time = #{payTime},</if> <if test="status != null">status = #{status},</if> </set> WHERE order_no = #{orderNo} </update>这样可以避免 NULL 覆盖原有值。实际开发中,很多数据不一致问题就出在“全字段更新”上。
3.3 MyBatis缓存的坑:民宿订单模块千万别开二级缓存
MyBatis 一级缓存是 SqlSession 级别。在 Spring 管理的 Mapper 里,一个事务内两次查询默认复用 SqlSession,所以一级缓存实际上跟着事务走。大多时候没问题,但如果你在一个大事务里先查订单,再等另一个逻辑更新了订单,第二次查同一个订单可能拿到旧数据。订单相关查询要么放在独立事务里,要么干脆在 SQL 上使用乐观锁版本号。
二级缓存更危险,它是 namespace 级的。如果 HouseMapper 开了二级缓存,而 RoomMapper 更新了房间信息,HouseMapper 的缓存不会感知,就会出现“房间已改名,房源列表还显示旧名”的脏数据。民宿的数据变化频率比字典表高得多,所以我在 mybatis-config.xml 里直接关闭二级缓存:
<settings> <setting name="cacheEnabled" value="false"/> <setting name="localCacheScope" value="STATEMENT"/> </settings>localCacheScope 设为 STATEMENT 是更严格的做法,每个 SQL 执行完就清一级缓存。虽然会损失少量性能,但可以避免大量事务内数据不一致的排查痛苦。对民宿这种中小并发系统,完全值得。
3.4 分页好不好用,只有写了才知道
分页用的是 PageHelper,但它有几个坑。
第一,startPage 之后必须紧跟一条 SQL,否则分页不生效。第二,startPage 使用 ThreadLocal,线程池复用时如果不 clearPage,下一个请求会串数据。第三,COUNT 查询会帮你在原 SQL 外面包一层,如果 SQL 里有 GROUP BY,COUNT 直接报错。
所以在代码里封装了一个 PaginationUtils,用 try-finally 包裹:
public <T> PageResult<T> pageQuery(PageParam param, Supplier<List<T>> query) { PageHelper.startPage(param.getPageNum(), param.getPageSize()); List<T> list; try { list = query.get(); } finally { PageHelper.clearPage(); } PageInfo<T> info = new PageInfo<>(list); return new PageResult<>(info.getTotal(), info.getList()); }这样调用方永远不会忘记 clearPage。如果不想依赖 PageHelper,数据量小的时候直接用 limit #{offset}, #{size} 也可以。
4. Vue3前端交互设计与前后端联调的关键处理
4.1 前端工程结构
前端工程也比较务实,Vite + Vue3 + Element Plus + Pinia + Axios。目录结构大致如下:
src/ api/ # 后端接口封装 assets/ # 静态资源 components/ # 通用组件,如房源卡片、价格日历 layout/ # 用户端/房东端布局 router/ # 路由与守卫 stores/ # Pinia 状态 views/ # 页面组件路由分三套:/user、/host、/admin,利用路由守卫拦截角色。这里有个小技巧:路由 meta 里存 role 数组,守卫里判断用户角色和页面允许角色是否有交集,比到处 if else 要整洁。
4.2 reactive 和 ref 用在哪,怎么用不糊涂
很多人学 Vue3 最先问 reactive 和 ref 的区别。我的经验是:对象类型的表单用 reactive,比如搜索条件、新增房源表单;基本类型和会被整体替换的数组用 ref,比如列表数据、分页信息。
搜索页的典型写法:
const searchForm = reactive({ keyword: '', city: '', priceMin: null, priceMax: null, startDate: '', endDate: '' }) const houseList = ref([]) const loading = ref(false) async function loadHouses() { loading.value = true try { const res = await searchHouses({ ...searchForm, pageNum: 1, pageSize: 10 }) houseList.value = res.data.list } finally { loading.value = false } }注意用展开运算符把 reactive 对象转成普通对象传给 axios,否则在传参过程中可能带出 Proxy 的响应式副作用,虽然大部分情况下没问题,但排查起来很费劲。
日期选择器用 Element Plus 的 el-date-picker,v-model 绑定一个长度为 2 的数组。提交时拆成 startDate、endDate 传给后端。后端的 LocalDate 接收使用 @JsonFormat(pattern = "yyyy-MM-dd"),返回时同理会自动转成字符串,前端就不用再做时间解析。
4.3 axios 封装:请求带 token 和统一业务码
const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('homestay_token') if (token) config.headers.Authorization = `Bearer ${token}` return config }) service.interceptors.response.use( res => { if (res.data.code !== 0) { if (res.data.code === 401) { router.push('/login') } ElMessage.error(res.data.message) return Promise.reject(new Error(res.data.message)) } return res.data }, err => { ElMessage.error(err.response?.data?.message || '网络异常') return Promise.reject(err) } )关键点是拦截器统一处理业务码,页面里写const res = await searchHouses(...)时,拿到 res.data.list 直接能用,不会有 data.data.data 的嵌套。
4.4 联调时最常见的三个错
第一,跨域配置。开发环境用 Vite proxy,不要开后端 CORS:
// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }如果同时打开后端 CORS 和前端 proxy,OPTIONS 预检请求会冲突,可能看到 Access-Control-Allow-Origin 重复报错。
第二,时间类型。后端 LocalDateTime 如果没有配 jackson 序列化,前端会收到类似[2025, 6, 12, 21, 30, 0]这种数组,完全没用。必须在 application.yml 里配:
spring: jackson: time-zone: GMT+8 date-format: yyyy-MM-dd HH:mm:ss然后用 @JsonFormat 对 LocalDate 单独注明格式。
第三,路由刷新 404。开发模式没感觉,部署到 Nginx 后,刷新 /house/detail/123 会 404,原因是前端是 history 路由,服务器没配 try_files。这个在第六节部署部分会详细写。
5. 订单并发与房态一致性:防超卖的防御性设计
5.1 没处理并发之前,库存确实会变成 -1
民宿房态和电商库存不一样:一个房间在某个日期就是 1 间,卖掉了就没有了。我用两个浏览器分别下单同一个房间同一天,结果两个订单都创建成功,room_price_date 的 stock 变成了 -1。为什么会这样?最初下单逻辑是先 select 查 stock,等于 1 就插入订单,再 update stock=0。两个请求同时 select,都看到 1,都认为可以卖。
解决思路可以抽象成三句话:
- 用一条 SQL 扣减,不要先查再减;
- 扣减时加条件 stock > 0;
- 如果扣减影响行数为 0,说明已经没了,事务回滚。
5.2 行锁 + 条件更新:下单事务的正确姿势
在订单创建这种短事务里,最终用了 SELECT ... FOR UPDATE 做悲观锁。先把订单涉及的所有日期价格记录锁住,其他人进不来,再计算价格、插入订单、扣减库存,提交事务时释放锁。
核心代码:
@Transactional(rollbackFor = Exception.class) public String createOrder(CreateOrderDTO dto) { // 1. 锁定该房间在入住日期区间内的价格记录 List<RoomPriceDate> priceRecords = roomPriceDateMapper.selectForUpdate( dto.getRoomId(), dto.getCheckInDate(), dto.getCheckOutDate()); if (priceRecords.size() != nights(dto.getCheckInDate(), dto.getCheckOutDate())) { throw new BizException("所选日期有未开放房价,请刷新"); } // 2. 检查每一条记录都有库存 long totalAmount = 0L; for (RoomPriceDate record : priceRecords) { if (record.getStock() <= 0) { throw new BizException(record.getPriceDate() + "已无房"); } totalAmount += record.getPrice(); } // 3. 生成订单号(日期+随机数+用户ID后四位) String orderNo = generateOrderNo(dto.getUserId()); // 4. 插入订单 bookingOrderMapper.insert(orderNo, dto.getUserId(), dto.getRoomId(), ...); // 5. 扣减每一天库存 roomPriceDateMapper.decreaseStock(dto.getRoomId(), dto.getCheckInDate(), dto.getCheckOutDate()); // 6. 记录状态日志 orderStatusLogMapper.insert(...); return orderNo; }对应的 XML:
<select id="selectForUpdate" resultType="com.homestay.domain.RoomPriceDate"> SELECT id, room_id, price_date, price, stock FROM room_price_date WHERE room_id = #{roomId} AND price_date >= #{startDate} AND price_date < #{endDate} FOR UPDATE </select> <update id="decreaseStock"> UPDATE room_price_date SET stock = stock - 1 WHERE room_id = #{roomId} AND price_date >= #{startDate} AND price_date < #{endDate} AND stock > 0 </update>几个关键点:
- 为什么不用 BETWEEN 直接写退房日?因为退房日不占用房间,不能锁。查询区间用 [checkIn, checkOut)。
- 为什么先 FOR UPDATE 再 decreaseStock?为了把多条价格记录一次性锁住,避免两个人各锁一天造成交叉死锁。FOR UPDATE 时,SQL 会按主键索引顺序加锁,短事务下死锁概率很低。
- decreaseStock 的条件里必须带上 stock > 0,即使前面已经锁住,这也是双重保障,防止逻辑漏判。
- 事务里如果插入订单失败,前面锁会在回滚时释放,库存不扣。
5.3 乐观锁可以吗?可以,但场景不一样
乐观锁版本号适合读多写少、并发冲突不太多的场景。民宿平台做到节假日抢房,两个用户同时抢最后一间房,乐观锁会有一方 update 影响行数为 0,只能重试,体验很差。悲观锁虽然会短暂阻塞另一个请求,但对于“抢一个日期库存”这种业务,阻塞一下反而是最自然的等待。
源码里我保留了两种方案,默认开启悲观锁,配置项order.use-pessimistic-lock: true可以切换成乐观锁。想学习并发控制的朋友,可以把配置改成 false,跑一遍并发测试,会看到冲突率明显上升。理解两种锁的差异,比背面试题有用得多。
5.4 状态机与超时自动取消
订单状态放在 status 字段里:
- 0 待支付
- 1 已支付
- 2 已入住
- 3 已退房
- 4 已取消
- 5 已退款
允许的状态流转:
- 待支付 -> 已支付(支付成功回调)
- 待支付 -> 已取消(用户手动取消/超时)
- 已支付 -> 已入住(到店办理)
- 已支付 -> 已退款(用户申请取消且通过)
- 已入住 -> 已退房
超时取消用 Spring 的 @Scheduled 定时任务执行:
UPDATE booking_order SET status = 4 WHERE status = 0 AND create_time < DATE_SUB(NOW(), INTERVAL 15 MINUTE)生产环境再给这个 SQL 加一个 limit 限制一次改多少,避免批量锁太多行。恢复库存靠另一个任务,把取消订单对应的日期段 stock 加回来。这里要注意幂等,每条订单都有唯一订单号,通过 order_no 判断是否已经处理过。
6. 部署环境搭建与上线排障:MySQL安装、打包、Nginx转发
6.1 本地先把 MySQL 跑起来
很多人卡在 MySQL 安装这一步。MySQL 8.0 安装包在 Windows 下最好选 Custom,把 Server 和 Command Line Client 都装上。安装时字符集选 utf8mb4,不要选默认的 latin1,否则中文会变问号。安装完成后检查服务:
mysql -uroot -p常见错误:
- 连接时提示 Public Key Retrieval is not allowed,需要在 JDBC 连接串加 allowPublicKeyRetrieval=true。MySQL8 默认 caching_sha2_password 插件会引发这个错。
- 报 Access denied for user 'root'@'localhost',多半是密码授权问题,用 root 登录后执行
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的密码'; - 端口被占用可以
netstat -ano | findstr :3306查。
初始化数据库脚本:
mysql -uroot -p -e "CREATE DATABASE homestay DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" mysql -uroot -p homestay < db/homestay.sql6.2 后端打包与环境变量
后端使用 Maven 打包:
mvn clean package -DskipTests生产环境配置文件 application-prod.yml 里不写死数据库密码,用环境变量占位:
spring: datasource: url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: ${DB_USER} password: ${DB_PASSWORD}启动:
export DB_HOST=127.0.0.1 export DB_USER=homestay export DB_PASSWORD=xxx nohup java -Xms256m -Xmx512m -jar homestay.jar --spring.profiles.active=prod --server.port=8080 > logs/app.log 2>&1 &为什么堆内存只给 256m 到 512m?民宿后端没有大对象缓存,512m 完全够。给太多了反而浪费服务器资源。后面如果加 Redis,再调大。
6.3 前端打包与 Nginx 代理
前端打包:
npm run builddist 目录上传到服务器 /home/www/homestay/。Nginx 配置:
server { listen 80; server_name homestay.example.com; root /home/www/homestay/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { try_files $uri $uri/ /index.html; } }这里有两个关键细节:
proxy_pass http://127.0.0.1:8080; 末尾没有斜杠,意思是把 /api/xxx 原样转发给后端。如果写成 http://127.0.0.1:8080/;,会在转发时去掉 /api 前缀,后端路由就找不到了。实际部署时最容易因为这个 404。
try_files $uri $uri/ /index.html; 是为了解决 Vue3 history 路由刷新 404。所有未匹配到物理文件的路径都回退到 index.html,由前端路由接管。
6.4 上线后我总结的三个易错点
第一,时间统一问题。MySQL 连接串、JVM 默认时区、前端 dayjs 解析,三者必须都是 Asia/Shanghai。尤其是 MySQL 连接串没写 serverTimezone,默认用 JVM 时区,会导致时间偏移八小时。
第二,日志滚动。SpringBoot 默认日志文件会一直涨,不加配置几个月就能占满磁盘。在 logback-spring.xml 里配按天滚动,保留三十天即可。
第三,备份。民宿系统最重要的数据就一张订单表,用 crontab 每天凌晨跑一次 mysqldump:
0 2 * * * mysqldump -uhomestay -p'xxx' homestay > /backup/homestay_$(date +\%Y\%m\%d).sql备份和恢复本身不复杂,但很多人到数据丢了才想起来。
最后再说一个我实际运维中的体会。这套源码最值得阅读的顺序不是从前端页面开始,而是先看数据库设计,再看 OrderMapper.xml 和 createOrder 方法。我见过很多朋友先把页面调得五颜六色,结果下单并发逻辑没想清楚,一测试库存就负数。民宿租赁系统的核心价值是“房态不会卖重、订单状态可追溯”,UI 永远排在后面。把这两条主线跑通,后面换任何前端框架、加任何营销功能都不会乱。