
前阵子帮一位做民宿的朋友做了一套客房预订管理系统技术栈选了 Spring Boot Vue。他原本的运营方式是微信接单、Excel 记房态旺季一到预订信息和真实房态经常对不上最离谱的一次是同一间大床房同一天被订出去三次客人到店才发现没房。需求捋到最后我发现这个系统真正的难点不是常规的增删改查而是“同一间房在同一个晚上只能属于一个订单”这条业务规则如何从数据库到接口再到前端层层落实。这篇文章把我从业务建模、表结构设计到前后端实现再到并发处理的完整过程整理出来包含核心代码和实际踩坑记录希望能给正在做类似 springbootvue 管理系统的同学一些参考。1. 民宿预订系统的业务底子房间、日期与订单的关系动工写代码之前我花了两天时间泡在他店里看他怎么接单、怎么记房态。这个过程非常值得因为民宿和标准酒店的业务逻辑差异很大如果一上来就照着酒店管理系统抄后面会改到怀疑人生。1.1 民宿管理场景和酒店有什么不同连锁酒店的预订系统通常是“房型 库存”的思路比如标准间有 80 间卖到第 80 间就没了。民宿完全是另一套玩法房源分散、房型非标。可能是一栋别墅、一间 loft、一个带院子的单间每套房子的格局、楼层、朝向都不一样只能按“单房”管理不能按房型批量卖。日期粒度敏感。客人的订单通常按“晚”计算入住当晚到离店日期的前一天都是占用的。旺季和淡季的可用性天差地别周一可能整栋空着周六提前一周就没房。价格随日期波动大。平时 200 一晚节假日挂牌 600 起同一间房不同日期价格不同所以订单里必须保存“成交时的单价快照”否则后面改房价会把历史订单搞得一团糟。老板身兼多职。他既是前台、客房主管又是财务。系统要照顾单人运营的场景操作路径必须短能两步完成的下单流程绝不要做成五步。把这些业务特征翻译成技术需求核心就一句话系统必须能回答“某间房在某个日期区间是否可订”并且这个过程要抗住并发。1.2 系统角色、功能模块与订单状态机这套系统我设计了两种角色没有做复杂的三级权限管理对民宿场景来说两层足够角色权限范围普通用户游客/住客注册登录、浏览客房、查询可用房间、提交订单、支付、查看自己的订单、取消未入住订单、发表评价管理员民宿老板房型/房间管理、房价规则设置、订单管理确认/拒绝/办理入住/退房、房态日历、统计报表订单状态机是整个系统的骨架我最终定了 5 个状态待支付(0) - 已确认(1) - 已入住(2) - 已完成(3) | | | - 已取消(4) - 已取消(4)超时未支付这里有一个容易忽略的点待支付订单是否占用房间民宿的实际场景是客人预订后通常几小时内会支付如果不锁房支付期间房间可能被另一个人订走如果锁房又可能出现恶意占单。我的方案是下单后 15 分钟内未支付自动取消并释放房间待支付和已确认状态都视为“占用房间”。这个逻辑不仅影响订单模块所有查询可用房源的接口都要把它算进去。1.3 技术架构与工程目录规划技术选型上我用的是一套经典组合没有引入花哨的中间件因为它的目标是“好用、稳定、易维护”后端Spring Boot 3.x MyBatis-Plus MySQL 8.0JDK 17。前端Vue 3 Vite Element Plus Pinia Axios。认证方案JWT无状态方便后面如果做小程序端直接复用。部署方案开发环境前后端分离跑生产环境用 Nginx 托管前端静态文件反向代理后端 API。工程目录我按 DDD 的轻量思路划分没有强求分层但保证 controller / service / mapper 职责清晰├── backend │ ├── src/main/java/com/example/bnb │ │ ├── controller # 接口层 │ │ ├── service # 业务逻辑层 │ │ ├── mapper # 数据访问层 │ │ ├── entity # 实体类 │ │ ├── common # 统一返回、异常处理、JWT工具 │ │ └── config # Web配置、拦截器注册 │ └── src/main/resources └── frontend ├── src │ ├── api # 接口请求封装 │ ├── router # 路由 │ ├── stores # Pinia 状态 │ ├── views # 页面组件 │ └── utils # 工具函数 └── vite.config.js2. 数据库设计把房态和订单拆开的思路如果说业务逻辑决定了系统的上限表结构就决定了系统的下限。民宿预订系统的数据库设计核心不是把字段建全而是把“房间”“日期”“订单”三者的关系建对。2.1 六张核心表的结构定义我实际建了六张表用户表、房型表、房间表、订单表、房价规则表、评论表。这里重点说房间表和订单表因为它们承担了房态计算的主要职责。房间表CREATE TABLE room ( id bigint(20) NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL COMMENT 房间名称如01号山景大床房, type_id bigint(20) NOT NULL COMMENT 关联房型id, occupancy int(11) NOT NULL DEFAULT 2 COMMENT 可住人数, area decimal(8,2) DEFAULT NULL COMMENT 房间面积, cover_img varchar(255) DEFAULT NULL COMMENT 封面图地址, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1营业中 0停售, created_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单表是重中之重我列几个容易被新手忽略的字段CREATE TABLE reservation ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 业务订单号用户看得见, user_id bigint(20) NOT NULL COMMENT 下单用户, room_id bigint(20) NOT NULL COMMENT 房间id, check_in_date date NOT NULL COMMENT 入住日期, check_out_date date NOT NULL COMMENT 离店日期注意这是开区间, room_price decimal(10,2) NOT NULL COMMENT 每晚单价快照, night_count int(11) NOT NULL COMMENT 入住晚数, total_amount decimal(10,2) NOT NULL COMMENT 总金额, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待支付 1已确认 2已入住 3已完成 4已取消, created_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_room_date (room_id, check_in_date, check_out_date), KEY idx_user (user_id), PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有一个我反复强调的设计决策离店日期用的是开区间语义。也就是说订单是“入住当天的中午到离店当天的中午”所以一个check_in_date2024-05-01、check_out_date2024-05-03的订单实际占用的晚上是 5 月 1 日和 5 月 2 日两个晚上5 月 3 日中午退房。所有冲突计算都必须按这个语义来否则日期边界会出各种奇怪的 bug。2.2 日期冲突校验区间重叠判断的 SQL 写法预订冲突的本质是判断两个日期区间是否有交集。我有两个区间新订单区间[checkInDate, checkOutDate)其中 checkOutDate 是开区间。已订单区间某条有效订单的[check_in_date, check_out_date)。两个开区间有交集的条件是新订单的入住日期 已订单的离店日期 且 新订单的离店日期 已订单的入住日期。翻译成 SQL 就是SELECT COUNT(*) FROM reservation WHERE room_id #{roomId} AND status IN (0, 1, 2) -- 待支付、已确认、已入住都算占用 AND check_in_date #{checkOutDate} -- 已有订单的入住日期 早于 新订单离店日期 AND check_out_date #{checkInDate}; -- 已有订单的离店日期 晚于 新订单入住日期这个写法很多人一开始会写错常见的错误版本是只查check_in_date BETWEEN ? AND ?那只能查出入住日期落在区间内的订单拦不住“一个从昨天住到明天的订单”和新订单撞车的情况。区间重叠判断必须用上面这种双条件交叉写法屡试不爽。我在 MyBatis-Plus 里的实际用法long conflictCount reservationMapper.selectCount( new LambdaQueryWrapperReservation() .eq(Reservation::getRoomId, roomId) .in(Reservation::getStatus, Arrays.asList(0, 1, 2)) .lt(Reservation::getCheckInDate, request.getCheckOutDate()) .gt(Reservation::getCheckOutDate, request.getCheckInDate()) );2.3 价格与节假日动态房价表方案民宿的定价不是固定的。朋友的民宿淡旺季价差很大周末和工作日也不同我加了一张房价规则表CREATE TABLE price_rule ( id bigint(20) NOT NULL AUTO_INCREMENT, room_id bigint(20) NOT NULL, date date NOT NULL COMMENT 具体日期按天设置, price decimal(10,2) NOT NULL COMMENT 这一天的房价, note varchar(100) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_room_date (room_id, date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;按天存价格的好处是老板可以在管理后台点开日历逐天改价节假日挂高价、淡季挂特价逻辑非常直观。计算订单总价时只需要查入住到离店之间的所有日期价格求和SELECT date, price FROM price_rule WHERE room_id #{roomId} AND date #{checkInDate} AND date #{checkOutDate} ORDER BY date;这里有一个很多人都踩过的坑订单成交后价格一定要快照到订单表里。我当时在做订单详情展示时直接实时去查 price_rule结果老板改了某天的价格历史订单金额跟着变了账目对不上。后来我把room_price和total_amount落进订单表展示时优先读订单快照。3. 后端实现Spring Boot 3 的认证与预订主流程后端这块我挑三个最值得展开的部分讲工程初始化、JWT 认证、预订接口的事务与锁。这三部分搞定其他模块基本都是围绕单表进行增删改查。3.1 工程初始化与依赖配置Spring Boot 3.x 要求 JDK 17 起步配置上有一个重要的变化是javax.*包迁移到了jakarta.*网上很多老教程的代码直接复制过来会报编译错误。我的 pom.xml 核心依赖如下dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependenciesattention如果你之前用的是 MySQL 5.7驱动要选com.mysql.cj.jdbc.Driver连接串记得加时间参数否则会报时区错误spring: datasource: url: jdbc:mysql://localhost:3306/bnb?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver3.2 JWT 登录认证与拦截器设计民宿系统不做复杂权限集成JWT 的方案足够。我封装了一个工具类负责生成和解析 TokenComponent public class JwtUtil { private final SecretKey key Keys.hmacShaKeyFor( bnb-security-key-please-change-in-production-0123456789.getBytes()); public String generateToken(Long userId, String role) { Date now new Date(); return Jwts.builder() .claim(userId, userId) .claim(role, role) .setIssuedAt(now) .setExpiration(new Date(now.getTime() 24 * 60 * 60 * 1000L)) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); } }然后用一个拦截器统一校验拦截器里把 userId 和 role 放进 request attribute后续 controller 直接取不用每个接口重复写解析逻辑Component public class JwtInterceptor implements HandlerInterceptor { Resource private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录和静态资源接口 if (request.getRequestURI().startsWith(/api/auth/)) { return true; } String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { throw new BizException(ResultCode.UNAUTHORIZED, 未登录或登录已过期); } try { Claims claims jwtUtil.parseToken(token.replace(Bearer , )); request.setAttribute(userId, claims.get(userId, Long.class)); request.setAttribute(role, claims.get(role, String.class)); return true; } catch (Exception e) { throw new BizException(ResultCode.UNAUTHORIZED, Token无效); } } }注册拦截器时注意要排除/api/rooms/**、/api/rooms/{id}这些公开查询接口毕竟游客也应该能看房。3.3 预订接口事务、锁与状态流转预订是整个系统业务价值最高的一环也是并发风险最集中的地方。这是核心 Service 方法的骨架Transactional(rollbackFor Exception.class) public ReservationVO createOrder(CreateOrderRequest request, Long userId) { // 1. 基本参数校验日期合法性、房间是否存在 Room room roomMapper.selectById(request.getRoomId()); if (room null || room.getStatus() ! 1) { throw new BizException(房间不存在或已停售); } // 2. 日期区间校验离店日期必须晚于入住日期且入住日不能是过去 if (!request.getCheckOutDate().isAfter(request.getCheckInDate())) { throw new BizException(离店日期必须晚于入住日期); } // 2.5 加行锁防止并发下同一房间重复预订 Room lockedRoom roomMapper.selectByIdForUpdate(request.getRoomId()); if (lockedRoom null) { throw new BizException(房间不存在); } // 3. 重叠订单检查 long conflictCount reservationMapper.selectCount( new LambdaQueryWrapperReservation() .eq(Reservation::getRoomId, request.getRoomId()) .in(Reservation::getStatus, Arrays.asList(0, 1, 2)) .lt(Reservation::getCheckInDate, request.getCheckOutDate()) .gt(Reservation::getCheckOutDate, request.getCheckInDate()) ); if (conflictCount 0) { throw new BizException(该房间在所选日期区间已被预订请更换日期); } // 4. 计算金额从价格规则表取日期段价格 ListMapString, Object prices priceRuleMapper.selectPricesByRoomAndDateRange( request.getRoomId(), request.getCheckInDate(), request.getCheckOutDate()); // 如果某天没配价格可以用房型默认价兜底 BigDecimal total computeTotalPrice(room.getTypeId(), prices, request.getCheckInDate(), request.getCheckOutDate()); // 5. 生成订单号 入库 Reservation reservation new Reservation(); reservation.setOrderNo(generateOrderNo()); reservation.setUserId(userId); reservation.setRoomId(request.getRoomId()); reservation.setCheckInDate(request.getCheckInDate()); reservation.setCheckOutDate(request.getCheckOutDate()); reservation.setRoomPrice(computeAveragePrice(total, request.getCheckInDate(), request.getCheckOutDate())); reservation.setNightCount(request.getCheckOutDate().compareTo(request.getCheckInDate())); reservation.setTotalAmount(total); reservation.setStatus(0); // 待支付 reservationMapper.insert(reservation); return convertToVO(reservation); }那个selectByIdForUpdate是核心我用 XML 实现select idselectByIdForUpdate resultTypeRoom SELECT * FROM room WHERE id #{id} FOR UPDATE /selectFOR UPDATE会锁住这一行直到事务提交或回滚。只要所有创建订单的请求都走这个方法同一房间的并发下单就会被串行化从机制上杜绝了超卖。4. Vue 3 前端从搭建到可用的关键页面前端没有用 vue-element-admin 这种重框架而是从零搭了一个轻量工程。Vue 3 的组合式 API 写起来非常清爽配合 Vite 的热更新开发体验比 Vue 2 webpack 时代强太多。4.1 Vite Element Plus 工程搭建环境准备阶段最容易翻车的是 Node 版本。Vite 5 要求 Node 18 以上建议直接装最新的 LTS 版本装完用这些命令npm create vitelatest bnb-frontend -- --template vue cd bnb-frontend npm install npm install element-plus element-plus/icons-vue axios pinia vue-router npm run dev装完在 main.js 里引入 Element Plus 和样式import { createApp } from vue import { createPinia } from pinia import ElementPlus from element-plus import element-plus/dist/index.css import zhCn from element-plus/es/locale/lang/zh-cn import App from ./App.vue import router from ./router const app createApp(App) app.use(createPinia()) app.use(router) app.use(ElementPlus, { locale: zhCn }) app.mount(#app)注意一定要引入中文语言包不然日期组件默认显示英文。4.2 路由守卫与请求封装我在 router 里配置了基础路由并在全局前置守卫中做登录跳转const routes [ { path: /, component: HomeView, name: home }, { path: /room/:id, component: RoomDetailView, name: room-detail }, { path: /orders, component: OrderListView, name: orders, meta: { requiresAuth: true } }, { path: /login, component: LoginView, name: login }, { path: /admin, component: AdminView, name: admin, meta: { requiresAuth: true, requiresAdmin: true } }, ] router.beforeEach((to, from, next) { const authStore useAuthStore() if (to.meta.requiresAuth !authStore.token) { next({ name: login, query: { redirect: to.fullPath } }) } else { next() } })axios 封装我单独提出来原因是不想在每个组件里重复处理 token 和错误码。拦截器思想很直接const request axios.create({ baseURL: /api, timeout: 10000, }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push({ name: login }) } return Promise.reject(error) } )这里有一个本地开发时的关键配置。开发环境 Vite 跑在 5173后端跑在 8080跨域问题要在vite.config.js里配 proxy而不是在前端代码里写死http://localhost:8080// vite.config.js export default defineConfig({ plugins: [vue()], server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, }, }, }, })这样代码里所有接口都写成/api/...后面部署到生产环境时由 Nginx 统一转发前端代码一行不用改。4.3 客房列表与可用日期查询交互民宿预订前端的交互设计和标准酒店不太一样。我之前做过一个版本是先选房间再弹日历结果用户发现房间不可订又得退回去选别的房间体验很挫败。后来我改成首页顶部放一个日期范围选择器用户先选“入住-离店”日期。点击查询后调后端GET /api/rooms/available?checkIn...checkOut...后端返回这段时间内仍然可订的房间列表。用户从可订房间里挑再进详情页下单。日期选择组件用 Element Plus 的el-date-picker类型设为daterangeel-date-picker v-modeldateRange typedaterange start-placeholder入住日期 end-placeholder离店日期 :clearablefalse formatYYYY-MM-DD value-formatYYYY-MM-DD /查询可用房间的接口在前端对应这段逻辑const searchAvailableRooms async () { if (!dateRange.value || dateRange.value.length ! 2) { ElMessage.warning(请先选择入住和离店日期) return } const [checkIn, checkOut] dateRange.value const res await availableRoomApi(checkIn, checkOut) rooms.value res.data }后端available接口的核心 SQL 和订单冲突校验是同一套逻辑只不过角色反转查出所有营业中的房间再排除那些在目标区间内有冲突订单的房间SELECT r.* FROM room r WHERE r.status 1 AND r.id NOT IN ( SELECT res.room_id FROM reservation res WHERE res.status IN (0, 1, 2) AND res.check_in_date #{checkOut} AND res.check_out_date #{checkIn} );这个接口我会缓存五秒钟因为民宿的查询量不大没必要让每次鼠标操作都去打数据库。5. 并发场景下的预订防超卖一次线上事故复盘上线后第三天朋友打电话说“出事了”。我看了订单数据同一间房、同一个晚上居然生成了三笔“已确认”状态的订单。数据库的reservation表确实没有唯一约束能阻止这种数据出现因为一个订单横跨多个晚上无法用单列唯一索引直接限制。这个事故逼着我认真做并发防御。5.1 事故现场同一间房同一天被订了三次复盘后发现问题的根子在于我当时觉得民宿的并发量不大预订接口只做了“先查再插”的逻辑没有加任何锁。两个用户同时提交订单时请求 A 查询冲突发现没有重叠订单请求 B 查询冲突同样发现没有重叠订单请求 A 插入订单成功请求 B 插入订单也成功。问题就出在“查询”和“插入”之间存在一个时间窗口这个窗口在高并发下会被同时进入的多个请求钻空子。这个事故也给了我一个教训只要涉及“先检查后写入”的业务就必须考虑检查与写入之间的原子性不能赌系统并发量小。5.2 三种防御方案选型对比我评估了三种方案各自的优缺点列出来方案原理优点缺点适用场景乐观锁版本号更新时带 version 条件实现简单无锁阻塞冲突多时需要重试且无法直接应用于“查询插入”业务适用于更新已有记录悲观锁SELECT FOR UPDATE事务内锁行串行处理逻辑简单和现有业务契合并发高时锁等待可能影响吞吐低并发、单机部署很契合民宿场景唯一索引兜底每日房态表把每晚拆成独立记录加唯一索引数据库层面能直接拒绝重复插入需要额外维护房态表订单状态变化时要同步适合做第二道防线最终我选择了组合方案悲观锁为主 唯一索引兜底。主链路用SELECT ... FOR UPDATE把同一房间的预订请求串行化。因为一次预订最多锁 10 分钟而民宿的订单量级一天可能也就几十笔悲观锁完全不会成为瓶颈。代码就是第 3 章那段createOrder里的第 2.5 步。5.3 最终落地的预订锁实现为了把防线再做厚一层我加了一张每日房态表用来在数据库层面直接拒绝重复预订CREATE TABLE room_day_status ( id bigint(20) NOT NULL AUTO_INCREMENT, room_id bigint(20) NOT NULL, date date NOT NULL, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0可订 1占用, PRIMARY KEY (id), UNIQUE KEY uk_room_date (room_id, date, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意这个唯一索引的妙处(room_id, date, status)三个字段联合唯一。当status为 1 时同一房间同一天只能有一条占用记录当要把某天改成占用时执行INSERT IGNORE INTO room_day_status(room_id, date, status) VALUES (#{roomId}, #{date}, 1);返回影响行数为 0 就说明该日期已经被占事务直接回滚。这样即使悲观锁因为某种特殊情况失效数据库也会拦住重复插入。订单取消或完成后再把对应日期记录删除或改成 0。这套组合下来我再也没有遇到过超卖。用 JMeter 开 50 个线程同时订同一房间同一天最终生成有效订单数稳定为 1其余全部得到“该房间在所选日期区间已被预订”的提示。6. 打包部署与线上配置的实操要点系统本地跑通只是第一步真正考验人的是部署。这里把前后端打包、Nginx 配置和线上问题一次性讲透。6.1 前后端一体包与分离部署怎么选部署民宿管理系统有两种主流方式方案 A前后端合成一个 jar 包。把 Vue 构建产物放到 Spring Boot 的src/main/resources/static目录然后mvn package打出一个 fat jar。好处是部署简单一个进程搞定缺点是前端每次改动都要重新打后端包而且静态资源和接口混在一个端口中不利于未来扩展小程序或 App。方案 BNginx 托管前端 反向代理后端。前端npm run build生成 dist 目录放到服务器上后端独立跑在某个端口。前后端独立发布是我推荐的方案。我最终选了方案 B。前端构建命令npm run build # 产物在 dist/ 目录用 scp 把dist传到服务器Nginx 指向这个目录。后端打成 jar 包后用systemd守护运行下面是一份可用的 service 配置[Unit] Descriptionbnb-backend Afternetwork.target [Service] Userwww WorkingDirectory/opt/bnb/backend ExecStart/opt/jdk17/bin/java -jar /opt/bnb/backend/bnb.jar --spring.profiles.activeprod Restartalways RestartSec5 [Install] WantedBymulti-user.target6.2 Nginx 配置细节前后端分离部署后Nginx 承担两个职责托管静态文件和反向代理 API。我的一份核心配置server { listen 80; server_name your-domain.com; root /opt/bnb/frontend/dist; index index.html; # 前端路由Vue Router history 模式下刷新不 404 的关键 location / { try_files $uri $uri/ /index.html; } # API 反向代理 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; } # 图片等静态资源加缓存 location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ { expires 7d; add_header Cache-Control public, no-transform; } }try_files $uri $uri/ /index.html;这行是重中之重。Vue Router 如果用 history 模式刷新/orders路径时 Nginx 找不到对应的物理文件就会返回 404。加了这个配置后Nginx 会把所有不存在的路径都回退到 index.html让前端路由接管。如果你不想处理这个问题也可以直接用 hash 模式路由URL 上多个#但不好看。还有一个部署细节就是生产跨域。因为我把前端和后端放在同一个域下通过 Nginx 转发所以不存在跨域问题。如果你不得不把前后端分开在多个域需要在后端配置 CORSConfiguration 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); } }6.3 上线后遇到的几个实际坑部署上线不等于完事我把上线头两周遇到的坑整理成清单这些都是文档里不太会写、但实际特别容易踩的时区问题。MySQL 连接串没配serverTimezoneAsia/Shanghai时日期字段可能差 8 小时表现是用户 23 点多下单订单创建时间变成第二天。检查连接串和默认时区就行。连接池闲置断开。MySQL 默认wait_timeout是 8 小时长时间没有请求后连接池里的旧连接会被数据库断开应用不知道第一个请求就会报 “Connection is not available” 之类的错。我用了 HikariCP 后把配置改成maximumPoolSize: 10和connection-test-query: SELECT 1连接闲置自动回收。历史订单价格展示异常。因为订单表里有快照字段展示没问题但后台报表里“本月营收”和订单明细经常对不上。排查发现是统计口径问题——有的订单成交时间和入住时间跨月了。我的处理是让老板自己选统计口径默认按“成交时间”算。图片上传路径。开发时图片传到本地临时目录一重启就丢。后来改成配置化上传目录Nginx 单独开一个/uploads/路由静态映射到服务器目录图片不随着应用重启消失。民宿预订系统这个项目麻雀虽小五脏俱全从业务建模、表设计到前后端联调再到并发控制、部署上线把一套 web 应用的完整生命周期都走了一遍。我个人做完最大的体会是不要小看任何“简单”的 CRUD 系统真正的复杂度往往藏在业务规则里。拿并发预订来说如果没有那次线上事故的刺激我可能真会一直认为“这种小系统不用上锁”。后来我把这套思路也挪到了别的项目里凡是涉及“先检查后写入”的地方都会先问自己一句如果两个请求同时进来会发生什么想清楚这个问题很多线上事故其实都能提前避免。