ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot2+Vue3民宿租赁系统:全栈源码、数据库设计与部署指南

SpringBoot2+Vue3民宿租赁系统:全栈源码、数据库设计与部署指南 懒人看源码最怕什么不是代码看不懂而是看完不知道往哪儿改。这套民宿租赁系统我整理出来的时候刻意把 SpringBoot2 Vue3 MyBatis-Plus MySQL8.0 全栈常踩的坑都留了注释同时把文档补全到照着文档就能把项目跑起来的程度。文章会从技术选型、数据库设计、核心业务流程、环境搭建、部署上线以及常见问题几个方面展开尽量把我在搭这套系统时的真实思路说清楚。民宿租赁这个场景其实很适合做全栈练手项目。它的业务闭环完整从房源发布、房间浏览、下单预订、支付回调到订单管理、评价体系、经营统计每一环都是真实的业务逻辑不是简单的增删改查。系统拆成前台用户端、民宿老板端、平台管理端三种角色权限边界清晰数据关系也够复杂。无论你是毕设选型、想学前后端分离项目还是准备用这套源码快速做自己的民宿 SaaS 原型拿它当底座都不会觉得虚。1. 项目定位与技术选型思考1.1 这套系统到底做了什么整个系统按角色分为三个入口游客或注册用户可以搜索房源、浏览房间详情、提交订单、在线支付、入住后退房评价民宿经营者可以管理自己的民宿信息、房型价格、每日库存、处理订单、查看经营统计平台管理员负责审核民宿、管理用户、处理投诉评价、系统运营。三端共用一套后端 API通过 JWT 做的用户身份标识来区分权限。业务上最核心的闭环是下单-支付-核销-评价这条链路。下单时系统要校验所选日期范围内房间是否有库存下单后锁定库存支付成功后减少可售库存到店入住后状态变为已入住退房后释放库存并进入评价环节。我见过很多人做民宿系统时把这一步跳过只在订单表里存一个房间ID不考虑日期冲突结果同一个房间同一晚被订出去两次这就是典型的库存设计没想清楚。1.2 后端为什么锁定 SpringBoot2 MyBatis-Plus现在 Spring Boot 3 已经出了很长时间我还是坚持用 SpringBoot2.7.x原因很实际国内大量线上环境和教程资料还停留在 JDK8 Spring Boot 2 的组合部署环境不用折腾网上报错也能搜到非常成熟的解决方案。SpringBoot2.7 属于 2.x 的最后一个稳定小版本既能享受多年迭代后的稳定性又保留了大家最熟悉的配置习惯拿来跑业务系统完全够用。MyBatis-Plus 在这个项目里承担了几乎所有单表 CRUD 的底层实现。它内置 BaseMapper像selectById、selectList、insert这类方法不需要手写 SQL条件构造器LambdaQueryWrapper能避免在 Java 代码里拼字符串 SQL配合TableLogic逻辑删除和TableField(fill FieldFill.INSERT)自动填充能让代码量减少一半以上。对于业务开发者来说重点应该放在库存、订单状态这些复杂逻辑上而不是花时间写大量重复的 mapper 方法。1.3 前端选择 Vue3 Vite 的真实理由前端选 Vue3 不是因为新是因为 Vue3 的组合式 APIComposition API确实更适合中后台系统。页面逻辑按业务功能组织比如房间筛选逻辑是一个函数订单状态管理是另一个函数不再像 Vue2 的 Options API 那样把数据、方法、生命周期硬拆成三块。配合script setup代码量能明显变少。构建工具用 Vite 替代 Webpack开发环境下冷启动基本在几百毫秒级热更新更快配置也比 Webpack 直观。状态管理选 Pinia 而不是 Vuex因为 Pinia 对 TypeScript 支持更好而且取消了 mutation 概念store 里直接改 state心智负担低很多。UI 组件库用的 Element Plus表单、表格、分页、对话框这些后台管理高频组件开箱即用不用自己拿 div 死磕样式。1.4 MySQL8.0 带来哪些便利和限制MySQL 8.0 相比 5.7默认字符集已经是utf8mb4不用再担心 emoji 存不进去。更重要的是它支持窗口函数、公用表表达式CTE做营收统计、环比计算这类需求时SQL 可以写得更简洁。窗口函数在我这个系统的经营统计模块里帮了大忙比如计算每个房源近三个月的订单量排名用ROW_NUMBER() OVER (PARTITION BY homestay_id ORDER BY ...)一下子就能搞定。但 MySQL 8.0 也埋了不少坑。8.0 默认身份认证插件是caching_sha2_password如果用的 JDBC 驱动还是 5.x启动后端会直接报Unable to load authentication plugin所以项目里必须把mysql-connector-java升到 8.0.3x 版本。此外 8.0 对时区更敏感连接串里忘了设置serverTimezone控制台就会蹦出比业务报错更吓人的一长串异常。这些细节在后续章节会专门展开。1.5 附带的文档里藏着哪些关键信息这个源码里的docs目录不是凑数的里面有四部分内容数据库初始化脚本homestay.sql、接口文档Postman 导出集合 Markdown 接口说明、环境搭建指南、部署上线手册。文档里还会写明初始账号和密码比如管理员账号admin、测试房东账号owner001、普通用户账号user001方便拿到代码后第一时间登录体验完整流程。我整理文档的习惯是按操作顺序写从安装 JDK8、Docker 装 MySQL、导入 SQL、启动后端、启动前端一直到 Nginx 部署每一步都对应具体命令和预期结果。这样的文档对新手很友好照着走一遍就能跑起来对有经验的开发者来说也能很快定位到项目里各个模块对应哪些文件不用靠猜。2. 数据库设计先把状态表和库存表想清楚2.1 核心表关系与字段设计系统主要表我整理成下面的关系每个表都加了逻辑删除字段deleted、创建时间create_time、更新时间update_time三个公共字段。表名核心作用关键字段sys_user用户、房东、管理员统一存储username, password, role, phone, statushomestay民宿基础信息owner_id, name, city, address, status, imageshomestay_room民宿下的房间/房型homestay_id, room_name, price_per_night, max_peoplehomestay_schedule房间每日库存与价格room_id, biz_date, inventory, locked_inventoryorder_info预订订单主表order_no, room_id, check_in_date, check_out_date, total_amount, statuscomment订单完成后的评价order_id, homestay_id, user_id, content, ratingsys_dict数据字典存城市、房型等dict_type, dict_label, dict_value主键我统一用的 BIGINT 雪花 ID由 MyBatis-Plus 的ASSIGN_ID策略自动生成。之所以不用数据库自增 ID是因为订单号、民宿ID会出现在前端的 URL 和接口参数里自增 ID 容易让竞争对手直接从订单号推断出平台每天的单量。金额字段必须用DECIMAL(10,2)严禁用double这是所有涉及钱的系统的铁律。状态字段用TINYINT通过数据字典维护含义避免在代码里散落一堆魔法数字。2.2 民宿房间库存为什么需要一张“每日库存表”很多初学做民宿系统的做法是在房间里放一个stock字段下单就stock - 1退房就stock 1。这在酒店场景勉强能用但民宿预订通常按入住日期-离店日期来选一套房源今天有库存不代表明天也有要支持连续入住 N 晚就必须知道每一天的库存情况。所以我在homestay_room和order_info之间加了一张homestay_schedule每日库存表每条记录对应某个房间某一天的可售数量。比如room_id101, biz_date2025-05-01, inventory3表示 101 号房在 5 月 1 日还剩 3 间可售。用户下单 5月1日到5月3日系统就把 5月1日、5月2日两天的记录都锁住。这样做带来的好处是以后想支持同一个房型拆成多间出售或者周末涨价都很方便只需在 schedule 表里按日期设置价格即可。下单锁定库存的关键操作是用一条 UPDATE 语句原子扣减而不是先在 Java 里查询再更新UPDATE homestay_schedule SET locked_inventory locked_inventory 2 WHERE room_id 101 AND biz_date BETWEEN 2025-05-01 AND 2025-05-02 AND (inventory - locked_inventory) 2如果影响行数不等于 2因为跨了两晚说明至少有一天库存不足直接在 Service 层抛异常回滚。这条 SQL 是在数据库层面完成的天然能做到并发安全比我加分布式锁简单可靠得多。2.3 订单状态机与金额精度控制订单状态我设计了六种分别对应一套明确的流转规则状态码含义进入条件允许流转到0待支付用户提交订单成功1、41已支付支付回调成功2、52已入住到店核销/房东操作33已退房到期退房/房东操作已结束4已取消用户取消/超时未支付已结束5退款中支付后退款申请66已退款退款处理完成已结束为什么要把状态机写清楚因为后续做接口时几乎所有业务校验都要基于当前状态判断。比如已取消的订单就不能再支付已入住的订单不能申请退款已退房的订单才能发起评价。如果不提前梳理状态流转后端 Service 每个方法里都散落一堆if (order.getStatus() 1)时间一长必然出现状态错乱。金额部分订单总价在创建时根据price_per_night * 入住晚数计算支付回调后只做金额比对绝不直接使用前端传过来的total_amount。支付表单独记录支付流水号、回调原始报文、支付状态保证后续对账时有据可查。3. 核心业务流程与后端实现要点3.1 登录认证BCrypt JWT 守卫接口密码存储用 BCrypt 加密不要用 MD5。MD5 加不加盐都挡不住现在的大字典跑表BCrypt 每次加密结果不同自带 salt破解成本高出几个量级。Spring Security 里抽出BCryptPasswordEncoder单独用即可不需要把整个 Spring Security 引进来避免学习成本和配置复杂度失控。用户登录成功后签发 JWT我把用户 ID 和角色放进 token 的 claim 里有效期设为 24 小时Algorithm algorithm Algorithm.HMAC256(homestay-secret); String token JWT.create() .withSubject(String.valueOf(user.getId())) .withClaim(role, user.getRole()) .withExpiresAt(new Date(System.currentTimeMillis() 24 * 60 * 60 * 1000)) .sign(algorithm);后端写一个 HandlerInterceptor在preHandle里从Authorization: Bearer xxx取出 token解析成功就把用户 ID 放入当前请求上下文解析失败直接返回 401。拦截器注册时配置白名单比如/api/auth/login、/api/homestay/list、/api/room/detail这些公开接口不需要登录也能访问但下单、支付回调、后台管理等接口必须校验身份。3.2 房源搜索条件构造器与日期冲突查询首页搜索逻辑是典型的多条件动态查询。用户可能只选了城市也可能选了城市入住日期人数价格区间还可能按价格排序、按评分筛选。这种场景用 MyBatis-Plus 的LambdaQueryWrapper最合适条件按需拼接LambdaQueryWrapperHomestay wrapper Wrappers.lambdaQuery(); wrapper.eq(StringUtils.hasText(query.getCity()), Homestay::getCity, query.getCity()) .ge(query.getMinPrice() ! null, Homestay::getPrice, query.getMinPrice()) .le(query.getMaxPrice() ! null, Homestay::getPrice, query.getMaxPrice());真正复杂的是搜索指定日期区间内有房的民宿。我的做法是先查出所有满足基础筛选的民宿 ID再把民宿 ID 和日期范围传给一个自定义 SQL查询这些民宿在目标日期区间内是否还有库存SELECT r.id FROM homestay_room r WHERE r.homestay_id IN (...) AND NOT EXISTS ( SELECT 1 FROM homestay_schedule s WHERE s.room_id r.id AND s.biz_date BETWEEN #{checkIn} AND DATE_SUB(#{checkOut}, INTERVAL 1 DAY) AND s.inventory - s.locked_inventory lt; 0 )用NOT EXISTS的含义是只要目标日期区间内有一天没库存这个房间就不能出现在搜索结果里。注意这里离店日期要减一天因为住 5月1日到5月3日实际占用的只是 1号、2号两晚。3.3 下单锁定库存事务、更新条件与并发下单接口是系统里并发压力最大、也最容易出 bug 的地方。正确流程分几步前端把房间ID、入住日期、离店日期、联系人信息POST到后端后端查到房间计算晚数、总价开事务先执行前文那条UPDATE homestay_schedule原子扣减库存更新影响行数不对则抛异常回滚插入order_info订单记录提交事务返回订单号。这里最关键的是第三步必须独立塞进事务里并且 UPDATE 语句的 WHERE 条件里带上inventory - locked_inventory #{nights}作为乐观判断。如果两个用户同时下单数据库的行锁会保证只有一个 UPDATE 先执行成功第二个执行时库存已经被扣掉影响行数为 0事务回滚请求直接失败。不需要引入 Redis 分布式锁也避免了锁过期导致的超卖。订单号生成我也顺便说下不要用时间戳随机数容易撞。我用日期 用户ID后四位 雪花算法后八位拼出来比如202505011012330123456看起来长但可读性好还能从订单号反推下单日期。3.4 后台管理模块的统计怎么实现后台管理端包含民宿审核、用户管理、订单管理、评价管理全做出来是标准的 CRUD 状态筛选这里不展开。重点想分享统计查询。经营统计里有一个需求民宿老板打开首页要看到三个数字——本月营收、本月订单量、当前待处理订单还要能看到最近七天订单趋势。本月营收用一条聚合 SQL 就能查出来SELECT IFNULL(SUM(total_amount), 0) FROM order_info WHERE status IN (1,2,3,5) AND pay_time DATE_FORMAT(CURDATE(), %Y-%m-01)订单趋势则按日期分组注意 MySQL8.0 可以直接用DATE(pay_time)分组配合DATE_FORMAT输出前端需要的YYYY-MM-DD格式。如果要做同比环比窗口函数LAG()很香它可以方便地取到上一周期的数据这些在 MySQL5.7 里都得靠自连接实现8.0 直接搞定。4. 从零跑通前后端环境搭建与配置实录4.1 Docker 一条命令装好 MySQL 8.0我推荐用 Docker 装 MySQL8.0省去安装 MySQL 服务的各种环境冲突。一条命令就能跑起来docker run -d --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDroot123 \ -e TZAsia/Shanghai \ -v /opt/mysql8/data:/var/lib/mysql \ mysql:8.0 \ --character-set-serverutf8mb4 \ --collation-serverutf8mb4_unicode_ci这里有两个细节一是-e TZAsia/Shanghai必须加不然容器默认 UTC数据库里的now()会跟北京时间差 8 小时二是字符集参数建议显式指定虽然 MySQL8.0 默认是 utf8mb4但collation如果不指定表默认的排序规则可能是utf8mb4_0900_ai_ci跟老库迁移出来的utf8mb4_general_ci不一致时 join 会报字符集冲突。启动完成后用docker exec -it mysql8 mysql -uroot -proot123验证连接是否正常。4.2 后端项目搭建与 MyBatis-Plus 配置后端用 Spring Initializr 创建注意打包方式选 jarJava 版本选 8。核心依赖如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version4.4.0/version /dependencyapplication.yml里三个地方容易写错。第一是数据库连接串必须带serverTimezoneAsia/Shanghai和useUnicodetruecharacterEncodingutf8第二是要写driver-class-name: com.mysql.cj.jdbc.Driver注意中间的cj老教程写的com.mysql.jdbc.Driver在 8.0 驱动下虽然能兼容但不推荐第三是配置好mapper-locations指向 XML 文件位置默认如果放在resources/mapper下就要显式声明mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true分页插件配置也是一个独立Configuration类Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这个配置容易漏掉漏掉后最典型的表现是调用page()方法不报错但返回的数据是全量的根本没有分页。4.3 Vue3 前端初始化与请求封装前端我用 Vite 创建npm create vitelatest homestay-web -- --template vue cd homestay-web npm install npm install vue-router4 pinia axios element-plus目录结构按业务拆模块不按文件类型堆一起src/ api/ // 接口封装按模块分文件 components/ // 公共组件 layout/ // 登录后主框架侧边栏顶栏 router/ // 路由配置和守卫 stores/ // Pinia 状态 views/ // 页面 auth/ // 登录、注册 home/ // 前台首页、民宿列表、详情 order/ // 订单流程 admin/ // 后台管理axios 封装是所有前后端项目的地基。请求拦截器里统一加 token响应拦截器里处理状态码service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) config.headers.Authorization Bearer ${token} return config }) service.interceptors.response.use( res { // 约定后端返回 { code: 0, data: ..., message: ok } if (res.data.code 0) return res.data.data ElMessage.error(res.data.message) return Promise.reject(res.data) }, err { if (err.response?.status 401) { localStorage.clear() router.push(/login) } return Promise.reject(err) } )这里的 401 跳登录逻辑必须配合路由守卫双保险因为 token 过期时接口先返回 401前端跳登录页而用户直接访问需要登录的页面时路由守卫在进入页面前就要拦截校验否则会闪一下页面。4.4 前后端联调跨域、Token 与接口规范联调阶段最大的坑是跨域。我在后端统一设置了server.servlet.context-path/api也就是所有接口地址都带/api前缀。前端开发环境只需要在 Vite 里做代理// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样前端请求/api/order/create会被代理到http://localhost:8080/api/order/create浏览器里没有跨域问题前端代码里也只用写相对路径/api/order/create生产环境部署后由 Nginx 做同样转发。后端的 CORS 配置可以不用加开发和线上全部靠代理解决。Token 传递规范建议统一登录接口返回 token 后存入 localStorage请求头统一用Authorization: Bearer token。不要图省事把 token 放在 URL 参数里否则日志系统会记到完整 token安全隐患很大。5. 打包部署上线Nginx Jar 的经典组合5.1 前端构建与 Nginx 路由配置前端执行npm run build产物是dist目录放到服务器/opt/homestay-web下。Nginx 配置里有两个关键点。第一要用 history 模式路由必须配try_files否则刷新页面就会出现 404server { listen 80; server_name your-domain.com; root /opt/homestay-web; 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; } }第二前端资源建议开启 gzipVite 生产构建默认会压缩 JS 体积但网络传输层再配一层 gzip 能让首屏加载快不少gzip on; gzip_min_length 1k; gzip_types text/plain text/css application/json application/javascript;5.2 后端 Jar 包运行与进程守护后端打包用mvn clean package -DskipTests产物在target/下。简单运行是java -jar homestay-system.jar但服务器重启或者 SSH 断开后进程就可能没了所以我建议配一个 systemd 服务[Unit] DescriptionHomestay Server Afternetwork.target [Service] ExecStart/usr/local/jdk8/bin/java -jar /opt/homestay/homestay-system.jar Restarton-failure Userhomestay [Install] WantedBymulti-user.target启用后systemctl enable homestay实现开机自启systemctl status homestay看运行状态日志统一交给 journald 管理。生产环境如果机器内存有限记得在启动命令上调过 JVM 参数比如-Xms256m -Xmx512m不要一把梭让 JVM 默认吃满内存。5.3 数据库导入与日常备份首次部署时执行mysql -uroot -p homestay /opt/homestay/docs/homestay.sql日常备份建议用计划任务每天凌晨把 MySQL 数据导出来并保留最近 7 天#!/bin/bash DATE$(date %Y%m%d) mysqldump -uroot -proot123 homestay /opt/backup/homestay_$DATE.sql find /opt/backup -type f -mtime 7 -delete备份脚本的密码写在命令行里会出现在进程列表里更稳妥的做法是把账号密码写进~/.my.cnf这样备份命令不用暴露密码这个问题在生产环境里很值得留意。6. 常见问题速查与避坑经验6.1 数据库与后端问题问题现象原因解决办法启动报Unable to load authentication plugin caching_sha2_passwordJDBC 驱动版本低于 8.0升级mysql-connector-java到 8.0.33连接报The server time zone value is unrecognized未设置 serverTimezoneJDBC URL 加serverTimezoneAsia/Shanghai报Public Key Retrieval is not allowedMySQL8 连接时未允许公钥检索JDBC URL 加allowPublicKeyRetrievaltrue分页查询返回全量数据缺少分页拦截器配置PaginationInnerInterceptorSQL 一直提示deleted字段不存在实体类加了TableLogic但表没加字段数据库表补deleted字段接口返回的 LocalDateTime 是数组没配置 JSON 序列化引入 jackson-datatype-jsr310 并配置格式这里重点说下allowPublicKeyRetrievaltrue。MySQL8.0 的caching_sha2_password在 SSL 未开启的情况下需要获取服务器公钥才能完成认证有的驱动出于安全考虑默认不允许自动获取于是报错。本地开发直接加这个参数最省事生产环境如果强制 SSL那这个参数就不需要了。6.2 前端与部署问题问题现象原因解决办法刷新路由页面 404前端用的是 history 模式但 Nginx 没配 try_files加try_files $uri $uri/ /index.html;接口 404但本地联调正常生产代理路径与后端 context-path 不一致确认location /api/的 proxy_pass 最终转发到http://127.0.0.1:8080/api/...前端请求跨域生产环境未通过 Nginx 代理直接访问后端统一走 Nginx 转发避免后端开全局 CORS上传的图片刷新后 404静态资源路径没做映射Nginx 增加location /upload/ { alias /opt/homestay/upload/; }订单重复提交生成两条前端连点按钮 后端未做幂等前端请求期间禁用按钮后端用订单号唯一索引兜底6.3 几个没写在文档里的坑有一部分问题接口文档里不会告诉你但是实际跑项目时一定会遇到。第一个是 HikariCP 连接池在 MySQLwait_timeout默认 8 小时的情况下如果项目半夜没请求第二天早上第一个请求经常会报连接已关闭。解决办法是配置spring.datasource.hikari.max-lifetime1800000让连接池在 MySQL 主动断开之前回收连接。第二个是数据库字段命名一定要全部用下划线风格比如check_in_date配合 MyBatis-Plus 的map-underscore-to-camel-case: true自动映射。我在初始化表的时候有一张表偷懒用了checkInDate结果查询结果里这个字段永远是 null排查了很久才发现是映射问题。第三个是把编译器和参考教程的版本统一。如果照着文档里 Spring Boot 2.7 的配置去搜网上的报错却搜到一篇 Spring Boot 3 的解决方案很容易把依赖坐标抄错。我建议遇到问题先看项目实际用的版本再定向搜索不要盲目复制。最后再多说一句源码拿到手建议先按文档把环境跑通然后用演示账号完整走一遍搜索-下单-支付-评价-后台统计这条链路。我在整理这套系统时的体会是业务代码本身不复杂真正花时间的是把库存、状态、金额这些边界条件想完整。二次开发时如果要做民宿拼团、会员折扣、多门店这些扩展底层的每日库存表和订单状态机已经预留了足够空间不会推倒重来。希望这套代码能帮你少走点弯路。
返回列表