前后端分离这套东西,现在几乎是中小型Web项目的标配了。我自己这两年做过几个类似的业务系统,从单体JSP一路折腾到现在的SpringBoot+Vue,最大的感受是:前后端分离真正难的不是技术本身,而是工程规范的建立。正好最近完整开发了一个景区民宿预约系统,从数据库设计到部署上线都走了一遍,把过程中比较关键的设计决策、核心代码实现、还有那些文档上查不到的坑,整理成这篇文章。项目整体用SpringBoot提供后端接口,Vue做前端页面,MyBatis负责数据持久层,MySQL存业务数据,典型的互联网应用分层方式,适合正在学JavaWeb或者准备做毕设、实习项目的朋友参考。
市面上民宿相关的管理系统不少,但大多要么只做了简单的CRUD,要么前后端耦合在一起后期改起来头疼。我这个系统的定位是:面向小型景区和民宿集群,提供房间管理、在线预约、订单跟踪、统计看板这几个核心闭环。业务不算复杂,但麻雀虽小五脏俱全,像JWT鉴权、状态机流转、跨域处理、多环境打包这类实际工作中天天要用的东西,全都覆盖到了。
1. 项目概述与需求分析
1.1 景区民宿行业的业务痛点
做系统之前,得先弄明白民宿老板和景区管理员到底在愁什么。我在前期调研时接触了几家景区周边的民宿集群,发现几个共性问题:
第一,房态管理混乱。旺季的时候电话不断,前台拿个小本子记订单,经常出现同一个房间被重复预定的情况。第二,价格和库存调整滞后。周末、节假日、淡旺季价格完全不一样,靠人工记忆很容易出错。第三,订单变更无法追溯。客人取消、改期、超时未到,这些状态变化没有记录,出了问题扯皮。
所以这套预约系统的核心价值,就是把"电话+本子"的流程改造成"在线可视化的动态房态管理"。系统必须做到三件事:实时展示可订房间、预约即锁房、订单状态变更全留痕。
1.2 角色权限与功能边界
在需求梳理阶段,我并没有一上来就堆功能,而是按角色把系统边界划清楚。系统最终划分了两端四个角色:
用户端角色比较简单,就是来订房的游客。游客可以浏览民宿列表、查看房间详情和价格日历、提交预约订单、支付定金(实际项目中我接的是模拟支付,方便演示)、查看自己的订单状态。
管理端这边,景区管理员和民宿老板共用一套后台,但权限范围不同。景区管理员能看到整个景区内所有民宿的数据汇总,民宿老板只能管理自己名下的房源。这样做的好处是未来扩展商户入驻时,不需要改表结构,只要给新老板开个账号绑定民宿ID就行。
实际编码阶段,后端用Spring Security + JWT做认证授权,用户在登录时返回的token里带上角色标识(ROLE_ADMIN、ROLE_BOSS、ROLE_USER),前端根据角色动态渲染菜单。这里有一点要注意:前端隐藏菜单只是体验上的处理,真正的权限控制必须落在后端接口上。比如查询所有民宿订单的接口,后端会先解析token里的用户ID,再通过民宿表中记录的owner_id来做数据隔离,而不是单纯靠前端传参决定能看什么数据。
1.3 核心功能清单
整理后的功能清单如下:
- 用户模块:注册、登录、个人信息维护、密码加密存储
- 民宿模块:民宿信息发布、房间类型管理、房间库存管理、房态日历
- 预约模块:在线预约、订单提交、取消申请、超时自动释放
- 后台管理:订单审核、房源上下架、数据统计看板
- 公共模块:图片上传、异常处理、操作日志
每个模块我都在后续章节中给出具体的表结构设计和核心代码实现,下面先聊技术选型。
2. 技术选型与架构设计
2.1 为什么选SpringBoot + Vue + MyBatis + MySQL
这套组合在中小型项目中已经算得上是"黄金搭档"了。先说SpringBoot,它对Spring生态做了大量自动配置,内嵌了Tomcat,打出来的jar包直接java -jar就能跑,部署成本极低。开发时不用再写一堆XML配置,这对个人开发和创业团队来说节省了巨量时间。
Vue这边,我选的是Vue 2.7版本(Vue 3也兼容,根据团队熟悉度选择)。为什么不用JSP或者Thymeleaf?因为前后端分离的核心诉求是并行开发和接口复用。前端同学专心搞页面交互,后端同学专心设计API,两边只通过JSON数据交互。而且Vue的组件化开发、响应式数据绑定、路由管理,在单页应用场景下比服务端模板渲染的开发体验好太多了。
MyBatis则胜在灵活。它不像JPA那样把SQL抽象得太厉害,对于查询条件复杂、需要手写SQL优化的场景非常友好。我们系统中的房态日历查询、订单统计报表,都有复杂的多表联查和条件拼接,用MyBatis的XML映射文件可以精准控制SQL语句。再加上它自带的缓存机制,一级缓存是SqlSession级别的,二级缓存是namespace级别的,后面我专门讲一下怎么配置二级缓存来扛住高并发查询。
MySQL作为存储层没啥争议,稳定、易维护、资料多。唯一要注意的是建表时字符集统一用utf8mb4,不然存emoji表情或者生僻字会报错或者变问号。数据库连接池我用了HikariCP,SpringBoot 2.x之后默认就是它,连接池参数在低并发场景下按默认就行,如果QPS上去了,再调整maximum-pool-size和max-lifetime。
2.2 工程结构设计与分层思想
后端工程用的是标准的Maven多模块思想,但实际为了简化,我建的是单模块但包结构分层清晰。整体如下:
com.example.homestay ├── controller // 接口层,只做参数校验和结果包装 ├── service // 业务逻辑层,事务边界在这里 ├── mapper // 数据访问层,MyBatis接口 ├── entity // 数据库实体类 ├── dto // 数据传输对象,用于接收前端参数 ├── vo // 视图对象,用于返回前端数据 ├── config // 配置类,JWT、CORS、拦截器等 ├── utils // 工具类 └── common // 统一返回结果、异常处理、常量分层的核心原则是:Controller层不写业务逻辑,Service层不碰HttpServletRequest。很多初学者常犯的错是在Controller里直接new一个OrderService然后写一堆JDBC代码,这会导致后期维护极其痛苦。我在这个项目中严格要求单向依赖:Controller -> Service -> Mapper。
前端工程同样按模块组织:
src ├── api // 接口请求封装,按模块拆文件 ├── assets // 静态资源 ├── components // 公共组件 ├── router // 路由配置 ├── store // 状态管理(Vuex) ├── views // 页面视图组件 └── utils // 请求封装、日期工具等这里要特别强调一下前后端的契约问题。项目启动前,我和同事花了半天时间统一了接口规范:所有接口返回格式统一为{ code: 200, message: "success", data: {...} }。前端axios请求拦截器里统一处理业务码,后端用统一返回类包装,省去了一堆if判断。这个习惯建议直接养成,在团队协作里能省掉无数沟通成本。
2.3 数据库表结构设计
数据库设计是整个系统最核心的环节。一张好的表结构能让业务逻辑清晰,一张烂的表结构能让你后期疯狂补代码。我先列出主要的几张表:
用户表 t_user
CREATE TABLE `t_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密码', `nickname` varchar(50) DEFAULT NULL, `phone` varchar(20) DEFAULT NULL, `role` varchar(20) NOT NULL DEFAULT 'ROLE_USER' COMMENT '角色', `avatar` varchar(255) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;密码加密我用了BCrypt,这是Spring Security内置的加密工具,同一密码每次加密结果都不同,内部自动加盐,比MD5之类的安全得多。角色字段直接用字符串存,查询方便不冗余。
民宿表 t_homestay
CREATE TABLE `t_homestay` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL COMMENT '民宿名称', `description` text COMMENT '民宿介绍', `cover_image` varchar(255) DEFAULT NULL, `province` varchar(50) DEFAULT NULL, `city` varchar(50) DEFAULT NULL, `district` varchar(50) DEFAULT NULL, `address` varchar(255) DEFAULT NULL, `owner_id` bigint(20) DEFAULT NULL COMMENT '所属管理员ID', `status` tinyint(4) DEFAULT '1' COMMENT '0下架 1上架', `level` tinyint(4) DEFAULT '3' COMMENT '星级', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;房间表 t_room
CREATE TABLE `t_room` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `homestay_id` bigint(20) NOT NULL COMMENT '所属民宿ID', `room_type` varchar(50) NOT NULL COMMENT '房型:大床房/双床房/套房', `room_number` varchar(20) NOT NULL COMMENT '房间编号,同一民宿内唯一', `price` decimal(10,2) NOT NULL COMMENT '挂牌价/晚', `area` varchar(50) DEFAULT NULL COMMENT '面积', `bed_info` varchar(100) DEFAULT NULL COMMENT '床型/张数', `max_people` int(11) DEFAULT '2' COMMENT '可住人数', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '0停用 1启用', PRIMARY KEY (`id`), KEY `idx_homestay_id` (`homestay_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;订单表 t_order
CREATE TABLE `t_order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单编号', `user_id` bigint(20) NOT NULL COMMENT '下单用户ID', `room_id` bigint(20) NOT NULL COMMENT '房间ID', `check_in_date` date NOT NULL COMMENT '入住日期', `check_out_date` date NOT NULL COMMENT '离店日期', `total_nights` int(11) NOT NULL COMMENT '入住晚数', `total_price` decimal(10,2) NOT NULL COMMENT '总价', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0待付款 1已付款 2已入住 3已离店 4已取消', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_room_id` (`room_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;订单编号我用的是时间戳+随机数生成,格式类似20250112102030123456,保证唯一。订单状态为什么不用varchar存字符串?因为数字状态配合枚举类,在Java里映射起来更干净,而且还省存储空间。
这里补充一张房间日历锁表 t_room_lock,用来记录哪些日期已经被占用:
CREATE TABLE `t_room_lock` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `room_id` bigint(20) NOT NULL, `lock_date` date NOT NULL COMMENT '被锁定的日期', `order_id` bigint(20) DEFAULT NULL COMMENT '关联订单ID', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_room_date` (`room_id`, `lock_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这张表是防止超卖的关键。预约下单时,先检查要预定的日期范围内是否有lock记录,没有的话批量插入锁定记录,再创建订单。用唯一索引uk_room_date做兜底,即使并发请求同时插入相同的房间和日期,数据库层也会拒绝重复,保证数据一致性。
3. 核心功能模块实现详解
3.1 基于JWT的用户认证与拦截器配置
用户认证这块,我用 JJWT 库来生成和解析Token。登录接口验证用户名密码通过后,生成一个包含用户ID、用户名、角色的JWT字符串返回给前端。前端把Token存在localStorage里,每次请求在header里带Authorization: Bearer xxx。
后端有一个JwtInterceptor拦截器,在配置类中注册,放行登录、注册、民宿列表查询这些公开接口,其他接口全部走拦截。拦截器里解析请求头里的Token,如果Token无效或过期,直接返回401状态码。
核心代码如下:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求(跨域时会先发OPTIONS) if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { try { token = token.substring(7); Claims claims = JwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { response.setStatus(401); response.getWriter().write("{\"code\":401,\"message\":\"token已过期\"}"); return false; } } response.setStatus(401); response.getWriter().write("{\"code\":401,\"message\":\"未登录\"}"); return false; } }这里有几个容易踩的坑要提醒:第一,Token过期时间我设置的是2小时,但实际发布后用户反馈经常掉线,后来改成7天,并加上Redis来维护用户登录状态。对于个人项目,Token时效可以根据业务灵活调整,不用死板地追求"安全"而牺牲体验。第二,拦截器里拿到Token中的用户ID后,用request.setAttribute往下传,在Controller里用(Long) request.getAttribute("userId")取,这比每个接口都让前端传userId参数靠谱得多,能防止恶意用户伪造ID操作他人订单。
前端Vue这边,路由守卫统一检查登录状态:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next('/login') } else if (to.meta.roles && !to.meta.roles.includes(store.state.role)) { next('/403') } else { next() } })路由meta里配置roles数组,例如后台管理页面就写上roles: ['ROLE_ADMIN', 'ROLE_BOSS'],前端先过滤一遍,后端接口再校验一遍,双保险。
3.2 民宿与房间管理的后端实现
民宿管理这块比较常规,就是一个标准的CRUD。但有几个细节值得说一说:民宿列表页需要支持分页、按城市筛选、按价格区间排序,这就要在MyBatis的XML里写动态SQL。
看一下Mapper的XML示例:
<select id="selectHomestayPage" resultType="com.example.homestay.vo.HomestayVO"> SELECT h.*, (SELECT MIN(r.price) FROM t_room r WHERE r.homestay_id = h.id) AS min_price, (SELECT COUNT(*) FROM t_room r WHERE r.homestay_id = h.id) AS room_count FROM t_homestay h <where> <if test="province != null and province != ''"> AND h.province = #{province} </if> <if test="city != null and city != ''"> AND h.city = #{city} </if> <if test="keyword != null and keyword != ''"> AND (h.name LIKE CONCAT('%', #{keyword}, '%') OR h.address LIKE CONCAT('%', #{keyword}, '%')) </if> AND h.status = 1 </where> ORDER BY h.create_time DESC LIMIT #{offset}, #{pageSize} </select>注意这里用到了关联子查询求最低价和房间数。接口返回给前端后,列表页可以直接展示"¥328起"这样的效果。这种写法比在Java代码里先查民宿再查房间要少一次网络往返,性能更好。
房间房态日历算是系统的一个亮点,也是容易出bug的地方。我的实现思路是:前端传入民宿ID和月份,后端生成这个月每天的房态数据。具体做法是先查出该民宿所有房间,再查这个月这些房间在t_room_lock表中的锁定记录,最后在Java代码里组装成一个二维结构:房间为行,日期为列,格子状态为可订/已订/停用。
但这里有个性能隐患:如果民宿有50个房间,查一个月的记录,最多要查1550条锁记录,在内存里处理还好。但如果是跨年的数据量大了,频繁查库扛不住,就得考虑加缓存了。于是就有了下面这个优化。
3.3 MyBatis缓存配置实战
网络热词里很多人搜MyBatis缓存,这个项目里正好用到了。MyBatis一级缓存是默认开启的,作用范围是同一个SqlSession,也就是通常一次请求会话。在Spring集成环境下,SqlSession是被Spring管理的,一级缓存的意义不大,因为每次Mapper方法调用都会新建或复用SqlSession,但跨方法之间一般不共享。
真正值得配置的是二级缓存。二级缓存作用范围是同一个namespace,也就是同一个Mapper接口。我把它配置在只读的字典表和民宿基础信息查询上:
<cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/>参数含义:淘汰策略LRU,缓存刷新间隔60秒,最多缓存512个对象,只读模式。为什么设只读?因为民宿基础信息基本不变,只读模式省去了反序列化拷贝的开销,性能更高。注意,只要Mapper里执行了增删改操作,MyBatis会自动清空该namespace的缓存,所以不用太担心脏数据。
另外,房间价格这种变化频率中等的查询,我用了Spring的@Cacheable注解 + 本地Caffeine缓存。Caffeine是新一代Java本地缓存库,比Guava Cache性能更好。虽然这里不是分布式部署,但本地缓存已经能挡住90%的重复查询压力。真正要做分布式缓存的时候,把注解换成Redis实现即可,代码侵入很小。
3.4 预约下单与订单状态流转
预约下单是系统里技术含量最高的部分,涉及到并发控制、事务管理等。我采用先锁房再下单的策略。核心Service代码如下:
@Override @Transactional(rollbackFor = Exception.class) public OrderVO createOrder(OrderCreateRequest request) { Long roomId = request.getRoomId(); LocalDate checkIn = request.getCheckInDate(); LocalDate checkOut = request.getCheckOutDate(); // 1. 计算入住晚数 long nights = ChronoUnit.DAYS.between(checkIn, checkOut); if (nights <= 0 || nights > 30) { throw new BusinessException("入住天数不合法"); } // 2. 查房及价格 Room room = roomMapper.selectById(roomId); if (room == null || room.getStatus() != 1) { throw new BusinessException("房间不存在或已停用"); } // 3. 校验日期范围内是否已被锁定 List<String> lockDates = roomLockMapper.selectDatesBetween(roomId, checkIn, checkOut.minusDays(1)); if (!lockDates.isEmpty()) { throw new BusinessException("部分日期已被预定,请更换时间"); } // 4. 锁定日期(抠库存) List<RoomLock> locks = new ArrayList<>(); for (LocalDate d = checkIn; d.isBefore(checkOut); d = d.plusDays(1)) { RoomLock lock = new RoomLock(); lock.setRoomId(roomId); lock.setLockDate(d); locks.add(lock); } roomLockMapper.batchInsert(locks); // 5. 创建订单 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(request.getUserId()); order.setRoomId(roomId); order.setCheckInDate(checkIn); order.setCheckOutDate(checkOut); order.setTotalNights((int) nights); order.setTotalPrice(room.getPrice().multiply(BigDecimal.valueOf(nights))); order.setStatus(0); orderMapper.insert(order); return orderMapper.selectDetailById(order.getId()); }@Transactional注解保证了方法里任何一步出错,整个事务回滚。也就是说,锁房数据插入成功但订单插入失败时,锁房记录也会一起撤销,不会出现"房间被锁但没订单"的脏数据。
这里必须强调一个并发问题:两个用户同时抢同一房间同一日期怎么办?因为t_room_lock表有(room_id, lock_date)唯一索引,即使事务都通过了预检查,批量插入时第二个事务会触发DuplicateKeyException,事务回滚,用户看到"手速慢了"的提示。这是整个系统防超卖的最后一道防线,也是数据库唯一索引的经典应用场景。实测压测了50个并发请求抢同一房源,最终只有1个成功,其余全部正确失败。
订单状态机的流转如下:待付款 -> 已付款 -> 已入住 -> 已离店,其中待付款和已付款都可以进入已取消。取消订单时,除了更新订单状态,还要删除对应的t_room_lock记录,把房间释放出来。为了避免用户下单后不付款一直占着房,我加了一个定时任务,每5分钟扫描一次超过30分钟未支付的订单,自动取消并释放房源。用的是Spring自带的@Scheduled注解,配置起来非常省事。
4. 前端页面与接口联调
4.1 Vue项目搭建与路由设计
Vue项目初始化我用的是Vue CLI,命令很简单:
npm install -g @vue/cli vue create homestay-web创建时选择Router、Vuex、Axios这些插件。Element UI作为后台管理系统的组件库,用户端部分我参考了移动端H5的交互风格,用Flex布局实现响应式,保证手机浏览器访问体验不差。
路由设计上,用户端和后台管理分成了两个Layout布局:
{ path: '/', component: HomeLayout, children: [ { path: '', name: 'Home', component: Home }, { path: 'homestay/:id', name: 'HomestayDetail', component: HomestayDetail, props: true }, { path: 'room/:id', name: 'RoomDetail', component: RoomDetail }, { path: 'my-orders', name: 'MyOrders', component: MyOrders, meta: { requiresAuth: true } } ] }, { path: '/admin', component: AdminLayout, redirect: '/admin/dashboard', meta: { roles: ['ROLE_ADMIN', 'ROLE_BOSS'] }, children: [ { path: 'dashboard', component: Dashboard }, { path: 'homestay-list', component: HomestayManage }, { path: 'order-list', component: OrderManage } ] }这里要注意路由的meta信息配置好之后,要和路由守卫配合使用,这个前面已经讲过了。另外一个实用细节是:列表页跳详情页时,用query传参数比用path传参数更灵活:
this.$router.push({ path: '/homestay/' + id })像日期、关键字这类筛选条件,建议放到query参数里,这样用户刷新页面之后筛选条件还在。比如:
this.$router.push({ path: '/room/list', query: { homestayId: id, checkIn: this.checkInDate, checkOut: this.checkOutDate } })在RoomDetail组件里通过this.$route.query读取参数并回显。这么做还有一个好处:不同房型的分享链接可以直接复制给别人,浏览器自动带上日期参数。
4.2 Axios请求封装与跨域处理
前端请求库我用Axios,封装的关键在于拦截器。请求拦截器统一加Token,响应拦截器统一处理错误码和401跳转:
import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { Message.error(res.message || '请求失败') if (res.code === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(new Error(res.message)) } return res.data }, error => { Message.error(error.message || '网络错误') return Promise.reject(error) } )开发环境下,前后端端口不同必然出现跨域问题。解决跨域的方案有两种:一种是在后端加CORS过滤器,我选择了这种方式,因为上线后前后端可能部署在不同域下,后端支持跨域是刚需。后端配置如下:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }很多新人搞不清楚为什么addAllowedOrigin("*")和setAllowCredentials(true)不能同时用,因为浏览器规范限制,携带Cookie的跨域请求必须有明确的Origin。我用addAllowedOriginPattern("*")绕过了这个限制,字符匹配更灵活。
前端开发环境里,我在vue.config.js里也配了webpack devServer的proxy,本地联调时让前端请求走代理避免跨域:
module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } } } } }这样前端所有请求都以/api开头,既能通过代理绕过跨域,后期切换到生产环境只需要改baseURL和nginx反向代理配置就行。
4.3 核心页面与交互细节
民宿详情页是整个用户端交互最复杂的页面。页面包括民宿相册轮播、基本信息、房型列表、预约日期选择、下单支付几个区块。日期选择用的是Element UI的DatePicker,配置了disabledDate函数来禁用过去的日期。
但光禁用过去日期还不够,还要禁用已经被预定的日期。做法是:进入详情页时,请求后端房态接口,拿到已锁日期集合,然后传给DatePicker的disabledDate:
const bookedDates = new Set(this.lockedDates) const disabledDate = (date) => { const dateStr = this.dateFormat(date) return date.getTime() < Date.now() - 86400000 || bookedDates.has(dateStr) }这个交互做完之后,用户在选日期阶段就已经规避了大部分不可订的日期,后端再校验一遍并发情况,体验就很顺滑了。
下单页面里,最重要的一个细节是金额计算。后端返回单价,前端根据入住晚数实时计算总价并展示给用户确认。但最终金额以后端计算为准,前端展示金额只是参考。因为前端如果被篡改,把单价改成0.01提交,后端必须重新查询数据库里的真实价格来计算,绝不能相信前端传过来的总价字段。很多人做电商系统会忽略这一点,导致"0元购"漏洞。
5. 完整部署教程
5.1 本地环境准备与版本选择
先说一下我开发时的环境。
- JDK:1.8(SpringBoot 2.7.x最后一个支持JDK8的版本,稳定)
- Maven:3.6.3
- Node.js:14.17.0(Vue CLI要求)
- MySQL:5.7(生产环境建议8.0,语法差异不大)
- IDE:IntelliJ IDEA + VSCode
MySQL安装后,第一步是创建数据库和账号。建议不要用root直接连应用,单独建一个账号给系统用,权限只开放业务数据库。
CREATE DATABASE homestay_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE USER 'homestay'@'%' IDENTIFIED BY 'HomesTay@2024'; GRANT ALL PRIVILEGES ON homestay_db.* TO 'homestay'@'%'; FLUSH PRIVILEGES;然后导入项目里的init.sql脚本,脚本里建表并插入几组演示数据。数据准备阶段,我建议至少准备5个以上房间、覆盖大床房/双床房/亲子房等不同房型,这样前端演示时效果更丰满。
5.2 后端配置与打包
后端配置文件application.yml重点看数据源和MyBatis部分:
server: port: 8080 servlet: context-path: / spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/homestay_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: homestay password: HomesTay@2024 hikari: maximum-pool-size: 10 minimum-idle: 5 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.homestay.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case这个配置很关键,它能自动把数据库的create_time字段映射到Java的createTime属性,省去大量resultMap手动映射的工作。log-impl配置成StdOutImpl,开发时会在控制台打印SQL语句,方便排查问题。生产环境记得把日志级别调高,不然SQL日志量太大会拖慢性能。
打包部署有两种方式。如果没有特殊需求,直接打成可执行jar:
mvn clean package -DskipTests然后:
java -jar target/homestay-backend-1.0.0.jar --spring.profiles.active=prod我在工程里配置了application-dev.yml和application-prod.yml两套profile,dev环境打印SQL、日志级别DEBUG,prod环境关闭SQL打印、日志输出级别INFO。
如果有人非要打包成War放到Tomcat里部署,也行,但SpringBoot官方推荐jar方式内嵌Tomcat。打成War需要继承SpringBootServletInitializer,额外配置外部Tomcat,维护成本更高,除非公司强制要求,否则没必要。
5.3 前端构建与nginx部署
前端开发完成后,执行构建命令:
npm run build构建产物默认在dist目录下,包括index.html和一系列静态资源js/css。生产部署时,把dist目录内容放到nginx的html目录下,并配一个反向代理,将API请求转发到后端服务。
nginx核心配置如下:
server { listen 80; server_name yourdomain.com; gzip on; gzip_types text/plain text/css application/javascript application/json image/svg+xml; root /usr/share/nginx/html/project; index index.html; location / { try_files $uri $uri/ /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; proxy_set_header X-Forwarded-Proto $scheme; } }两个要点:第一,try_files $uri $uri/ /index.html;是vue-router的history模式必需配置,否则刷新页面会出现404,这个坑我帮无数人排查过。如果你比较懒,直接把路由改成hash模式(URL里带#),就不用配这一条了。第二,location /api/里的proxy_pass末尾有个斜杠,表示把/api/前缀去掉后转发。前端baseURL配成/api,实际请求/api/homestay/list会被转发到http://127.0.0.1:8080/homestay/list,正好对应后端Controller的@RequestMapping路径。
启动nginx服务:
nginx -t # 检查配置语法 nginx # 启动 nginx -s reload # 重载配置5.4 云服务器环境部署完整流程
部署到云服务器最崩溃的一步往往是环境安装。我总结了在CentOS 7.9上的安装顺序,照着来一般不会出问题。
第一步装JDK。用OpenJDK还是Oracle JDK?我建议用OpenJDK 8,命令安装没有授权问题:
yum install -y java-1.8.0-openjdk第二步装MySQL。CentOS 7自带的mysql包是MariaDB,需要先卸载再装MySQL官方源:
wget https://dev.mysql.com/get/mysql57-community-release-el7-11.noarch.rpm rpm -ivh mysql57-community-release-el7-11.noarch.rpm yum install -y mysql-community-server systemctl start mysqld初始密码在日志里:
grep 'temporary password' /var/log/mysqld.log登录后强制改密码,否则无法进行任何操作。改完密码后别忘了设置远程访问权限。如果你用Navicat连接云数据库连不上,10个里面有8个是防火墙或安全组没放行3306端口。云服务商的安全组规则里加一条,入方向放行3306,这个操作在控制台就能完成。
后端上传jar包后,用nohup启动:
nohup java -jar homestay-backend-1.0.0.jar --spring.profiles.active=prod > app.log 2>&1 &查看日志用tail -f app.log,停止服务用jps找出PID后kill掉。前端dist目录上传后放到nginx配置的root路径下。全部部署完,浏览器访问公网IP,打开系统首页,整个项目就上线了。
6. 常见问题与排查技巧实录
6.1 跨域请求被拒绝
跨域问题在前后端分离项目中太常见了。典型报错是浏览器控制台出现Access to XMLHttpRequest at 'http://localhost:8080/api/...' from origin 'http://localhost:8081' has been blocked by CORS policy。
排查步骤我建议按顺序来:
第一,确认后端是否配置了CORS。我上面给出了CorsConfig配置,如果你是过滤器和拦截器混合使用,注意CorsFilter的注册顺序要在自定义拦截器之前,否则拦截器先拦截了预检请求,CORS配置不生效。
第二,确认前端有没有走代理。如果配置了devServer.proxy,浏览器看到的请求是发给自己同源的地址,就不存在跨域了。这种情况下proxy没生效,通常是vue.config.js改完没重启devServer(注意要重启,热更新不会重新加载这个配置)。
第三,检查是不是使用了credentials模式。axios里设置withCredentials: true同时前端cookie跨域,要求后端Origin不能是*,否则浏览器会拒绝。
6.2 前端页面打开是空白
vue项目部署后在服务器上访问index.html是白屏,拿到源码查看发现js加载路径不对。这是因为npm run build默认的publicPath是/,打包出来的index.html引用的是/js/app.js这种绝对路径。如果你部署在子目录下,请求就404了。
解决方法是修改vue.config.js:
module.exports = { publicPath: './' }改成相对路径之后,js访问路径变成./js/app.js,子目录部署就能找到了。当然如果域名根目录部署,保持默认的/就行。
另一个白屏原因是路由模式。部署后刷新页面404白屏,本质是nginx没配置try_files,前面已经解决。
6.3 数据库连接加密与性能问题
很多人上来就在application.yml里把数据库密码明文写死,这也太随意了。简单点,可以对密码做一下Jasypt加密:
<dependency> <groupId>com.github.ulisesbocchio</groupId> <artifactId>jasypt-spring-boot-starter</artifactId> <version>3.0.5</version> </dependency>然后在配置里这样写:
spring: datasource: password: ENC(加密后的密文) jasypt: encryptor: password: 加密盐值启动时通过环境变量传入盐值,避免盐值出现在配置文件里被提交到Git。这算是一个提升安全性的实操技巧。
还有连接超时和断连的问题。数据库如果因为在云上,连接被防火墙重置,HikariCP默认的max-lifetime是30分钟,MySQL默认wait_timeout是8小时,理论上不会冲突。但如果你在阿里云RDS上跑,经常会出现"Communications link failure",多半是minIdle配置过大导致连接池长期占用连接,被数据库服务端主动断开。解决办法:执行show processlist;看连接是否还在,调整Hikari的validation-timeout和connection-test-query为SELECT 1,或者引入Hikari的keepalive-time配置。
6.4 MySQL时区与SSL报错
连接MySQL时报Unable to load authentication plugin 'caching_sha2_password',这是MySQL 8.0默认认证插件和驱动版本不匹配导致的。两种解决办法:一是MySQL端把用户的认证插件改成mysql_native_password:
ALTER USER 'homestay'@'%' IDENTIFIED WITH mysql_native_password BY 'HomesTay@2024';二是JDBC驱动升级到8.0以上版本,我Maven里引入了mysql-connector-java8.0.33,配合allowPublicKeyRetrieval=true参数,轻松解决。
时区问题也很常见。连接串里serverTimezone=Asia/Shanghai是用来告诉驱动连接的服务器时区,而JSON返回日期格式化用time-zone: GMT+8,这两者要一致。否则你看到时间戳和数据库中差了8小时,那就是时区配置打架了。
6.5 SpringBoot版本太高的坑
有些朋友用SpringBoot 3.x版本,JDK要求17以上,很多老教程里的写法都变了。例如javax.servlet变成了jakarta.servlet,MyBatis的starter也需要用mybatis-spring-boot-starter的最新版本。如果新手用3.x遇到一堆奇怪报错,我建议直接退回2.7.x,等熟悉了再升级。技术选型不要追求最新,稳定能跑才是王道。
7. 项目扩展思路与实际心得
一个人开发这个项目,从数据库建模到前后端联调到最终上线,整体下来最大的收获不是学会了某个框架的API怎么调,而是建立了一套"从需求到架构再到实现"的思维方式。
先说说扩展方向。目前系统接的是模拟支付,真要上线运营,可以接入微信支付或支付宝沙箱环境,订单表增加支付流水号和支付回调时间字段。如果民宿数量多了,可以在后端引入Redis缓存热点数据和分布式锁,把预约下单的并发能力再提升一个量级。管理后台的统计看板,现在只做了简单的订单量、营收趋势图,后续可以增加入住率分析、客源来源分析、价格弹性分析,这些功能对民宿老板有实际决策价值。
踩过几次坑之后,我特别想强调的几个习惯:
第一,数据库表结构设计阶段,务必把外键约束的取舍想清楚。我习惯不用物理外键,只建立逻辑关联,通过应用程序保证数据一致性。这样做的好处是分库分表时不用处理外键的耦合,坏处是代码里要自觉维护关联数据。自己开发的系统心里有数,团队合作就得靠规范约束。
第二,接口设计一定要有版本意识。虽然这次项目的接口都是/api/v1/开头,后续如果字段变了,可以再开一个/api/v2/,旧接口平滑过渡,不至于前端联调到一半接口突然改了导致一团糟。
第三,日志必须从一开始就打好基础。我在common包下写了一个全局异常处理器,配合@Slf4j打印错误日志。生产环境出问题的时候,如果日志文件里只看到一堆NullPointerException和堆栈,却不知道是哪个用户、哪个订单触发的,排查效率会极低。建议在关键操作(下单、支付回调、取消订单)里把操作对象ID和业务主键放进日志,短期内看着啰嗦,后期是救命稻草。
最后再分享一个小技巧:做前后端分离项目,强烈建议提前设计好接口文档。我用的是Swagger(现在叫springdoc),后端写好注解,启动项目后访问/swagger-ui.html就能看到所有接口的在线文档,支持在线调试。前端照着文档联调,效率比两边对着聊天记录猜字段高得多。这个习惯我从这个项目开始一直保持到现在,每次做新系统都会先搭好Swagger,哪怕只是个人项目。