
前后端分离的海滨体育馆管理系统我前前后后搭过不少类似的场馆类项目这次这个是比较典型的SpringBootVueMyBatisMySQL组合。很多刚入门的朋友问我为什么这类管理系统这么爱用这一套其实答案很直白生态成熟、资料多、部署简单、学习曲线平缓而且前后端分离的开发模式对于团队协作和后续维护都友好得多。这篇文章我会从零开始把项目立项时的业务思考、数据库设计、后端接口实现、前端页面联调、最后的部署上线每个环节需要做什么、为什么这么做、踩过什么坑全部梳理一遍。这套系统适合谁看如果你正在做课程设计、毕业设计或者刚接触前后端分离开发想找一个完整项目来练手和参考这篇文章能帮你少走很多弯路。如果你已经在公司里写过一两年业务代码想看看别人是怎么组织场馆类管理系统的也有参考价值。需要提醒的是这里我讲的都是通用实现思路具体代码以你手上的项目为准但设计逻辑和排坑经验完全可以复用。1. 项目初始海滨体育馆管理系统到底要管什么做任何管理系统之前先别急着写代码。我见过太多人上来就建表、写接口做到一半发现业务没想清楚推到重来。体育馆管理系统看起来简单但把它拆开来看核心业务面其实覆盖了好几块。海滨体育馆的定位是综合性体育场馆通常包含羽毛球馆、篮球场、游泳馆、健身房、乒乓球室等不同场地。这就决定了系统需要有一个灵活的场地管理模块不只是单纯的增删改查。场地有不同类型、不同时段、不同价格还可能存在维修中、已预约、空闲中这些状态这些都要在数据结构里体现。除了场地另一个核心对象是用户。用户里有普通的散客、长期会员、管理员。散客可以直接按次预约场地会员可以绑定会员卡享受折扣管理员负责审核场地信息、发布公告、处理订单。如果做精细一点还应该有员工表的角色但一般管理系统做到管理员和普通用户两级就够了再细分就要考虑权限框架的复杂度。订单和预约是第三个核心模块。用户选好场地、选好时间、提交预约生成订单管理员确认或系统自动确认用户支付或到场核销。这套流程里要处理一个非常现实的问题场地冲突检测。同一片羽毛球场周五晚上7点到9点被A约了B再约同一时段就必须被拦截这是系统最容易出逻辑漏洞的地方。还有一个容易忽略的模块是公告和活动。体育馆经常会发临时闭馆通知、优惠活动、赛事安排这些内容需要一个简单的发布渠道。放在系统里就是一个公告表管理员维护用户在首页能看到。把这些业务拆完系统的功能清单就出来了用户注册、登录、个人信息维护场地信息浏览、按类型和时间筛选场地预约、订单生成、取消、支付记录管理员端场地管理、订单管理、用户管理、公告管理统计数据比如每日营收、场地使用率我建议你在动手前也这样做一次业务拆解把功能模块画在一张纸上再开始设计数据库后面的开发会顺畅很多。2. 技术选型SpringBootVueMyBatisMySQL这套组合为什么稳技术选型不是越新越好而是越合适越好。海滨体育馆管理系统属于典型的业务管理系统数据量不大、并发不高、逻辑集中在事务处理和权限控制上SpringBootVueMyBatisMySQL这套组合恰恰是这个场景下的黄金搭配。2.1 后端SpringBoot解决什么问题SpringBoot最大的价值是简化了Spring项目的配置。早期的SSM项目要写一堆XML配置文件数据源、事务、扫描路径都得手动声明光配置就能折腾半天。SpringBoot用自动配置把这个过程砍掉了大半内置Tomcat打包成jar直接跑连部署都省心。对于管理系统这种业务型项目SpringBoot提供的生态足够覆盖SpringMVC处理路由和请求参数Spring Security或拦截器做登录鉴权Spring事务管理保证订单生成过程的原子性再配合一系列开箱即用的starter依赖开发效率非常可观。2.2 前端Vue的核心优势Vue上手简单模板语法直观组件化开发让页面复用变得容易。体育馆管理系统前端主要就是列表页、表单页、详情页这些常规形态Vue加上Element UI这类组件库表格、弹窗、表单校验几乎不需要自己造轮子。很多人纠结用Vue 2还是Vue 3。说实话这个项目规模Vue 3的Composition API写起来更舒服生态也已经很成熟。但如果你的代码是Vue 2版本用Options API写也没问题。重要的不是版本多新而是你能把项目跑起来、改得动、讲得清。2.3 MyBatisSQL可控灵活度高MyBatis不像JPA那样完全自动化它把SQL语句交给你自己写。这意味着你既能享受ORM带来的便利又能对复杂查询做精细控制。比如场地预约冲突检测通常需要在SQL层面用时间范围条件去查重叠记录这种事用MyBatis写动态SQL非常顺手。动态SQL是MyBatis的一个核心亮点。条件查询、批量更新、多表关联用if、where、foreach标签就能拼出需要的SQL片段比在Java代码里拼字符串干净得多。2.4 MySQL轻量可靠完全够用MySQL在这个体量下性能绰绰有余。几万条订单记录、几百个场地任何索引优化技巧都用不上正常写SQL都不会慢。重要的是选对引擎和字符集。现代MySQL默认InnoDB支持事务适合订单相关表。字符集用utf8mb4能存emoji避免一些特殊字符报错。这套组合还有一个隐性优势遇到问题能找到海量参考。不管是B站教程、博客文章、还是各种开源项目用这套技术栈的都特别多。这意味着你卡住了搜索一下基本都能找到答案。做项目最怕的其实是踩到一个没人踩过的坑而这套技术栈的坑早被人踩平了。3. 数据库设计一张表一张表地拆给你看数据库是整个系统的地基。地基歪了后面写多少代码都是将就。体育馆管理系统的表结构我可以直接给出一套可落地的设计同时解释每个关键字段为什么这么设计。3.1 用户表userCREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 主键ID, username VARCHAR(50) NOT NULL UNIQUE COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT 密码MD5或BCrypt加密, real_name VARCHAR(50) DEFAULT NULL COMMENT 真实姓名, phone VARCHAR(20) DEFAULT NULL COMMENT 手机号, role TINYINT DEFAULT 0 COMMENT 角色0普通用户1管理员, member_level TINYINT DEFAULT 0 COMMENT 会员等级0非会员1普通会员2高级会员, status TINYINT DEFAULT 1 COMMENT 状态1正常0禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;用户名一定要加唯一约束这是登录的天然索引。密码存的是加密后的密文绝对不能明文存储。角色用数字表示比用字符串好维护前端根据数值判断按钮显隐。3.2 场地表venueCREATE TABLE venue ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 场地ID, name VARCHAR(100) NOT NULL COMMENT 场地名称如羽毛球1号场, type VARCHAR(50) NOT NULL COMMENT 场地类型羽毛球/篮球/游泳/健身, location VARCHAR(200) DEFAULT NULL COMMENT 具体位置, price DECIMAL(10,2) NOT NULL COMMENT 每小时价格, open_time VARCHAR(20) DEFAULT 09:00 COMMENT 开放时间, close_time VARCHAR(20) DEFAULT 22:00 COMMENT 关闭时间, status TINYINT DEFAULT 1 COMMENT 状态1可用0维修中2已停用, description TEXT COMMENT 场地描述, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT场地表;type字段虽然是字符串但值域是固定的几种。这里不要用外键关联一张场地类型表因为类型数量太少一张表反而增加join成本。直接用字符串可读性更好代码里定义成常量即可。price用DECIMAL不用FLOAT这是老生常谈金额涉及精度数据库层面就要严谨。open_time和close_time决定了场地可预约的时段范围后续做时间槽校验需要用到。3.3 预约订单表bookingCREATE TABLE booking ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 订单ID, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 订单编号, user_id INT NOT NULL COMMENT 预约用户ID, venue_id INT NOT NULL COMMENT 场地ID, booking_date DATE NOT NULL COMMENT 预约日期, start_time VARCHAR(20) NOT NULL COMMENT 开始时间如19:00, end_time VARCHAR(20) NOT NULL COMMENT 结束时间如21:00, total_price DECIMAL(10,2) NOT NULL COMMENT 订单总金额, status TINYINT DEFAULT 0 COMMENT 状态0待支付1已支付2已取消3已完成, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 下单时间, pay_time DATETIME DEFAULT NULL COMMENT 支付时间, KEY idx_venue_date (venue_id, booking_date), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预约订单表;order_no生成规则一般是时间戳加随机数前端展示和后续查询都用这个字段。venue_id和booking_date的组合索引非常关键因为场地冲突检测SQL就是按这两个字段去查的。status状态机要清楚用户下单生成待支付订单支付成功后变成已支付用户主动取消变成已取消时间走完管理员确认或自动变成已完成。这些状态流转在后端Service里要严格控制。3.4 公告表noticeCREATE TABLE notice ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 公告ID, title VARCHAR(100) NOT NULL COMMENT 公告标题, content TEXT NOT NULL COMMENT 公告内容, publisher_id INT NOT NULL COMMENT 发布人ID, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 发布时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT公告表;公告表结构简单但要注意content用TEXT类型因为公告内容往往不是一两句话可能是带格式的图文。publisher_id关联用户表前端展示公告时把发布人姓名查出来。3.5 表关系梳理user 与 booking一对多一个用户有多个订单venue 与 booking一对多一个场地被多个订单预约user 与 notice一对多一个管理员可以发布多条公告关系不复杂用MyBatis写关联查询时可以在业务层分步查询不一定非要在SQL里join。比如查询订单列表时先查出订单列表再根据user_id和venue_id分别查用户名和场地名这种做法的好处是SQL简单、可读性强数据量大时也不容易写出慢查询。3.6 一个容易被忽略的字段设计我在设计场地表时特意留下了description字段。很多初学的人做管理系统字段能省则省觉得列表页用不到就不要。但真正上线后你会发现运营人员需要在后台写“场地配备专业灯光”“可容纳50人”这类说明前端场地详情页也需要展示这些内容。宁可字段冗余一点也别做得太骨感。4. 后端实现从启动类到接口联调逐步过一遍后端是系统的核心所有业务规则和数据处理都在这里完成。我按照实际开发顺序来讲。4.1 项目初始化与目录结构SpringBoot项目用IDEA创建或者去Spring Initializr生成都可以。groupId填你自己的域名反写artifactId填项目名。选依赖的时候这几个必选Spring Web提供MVC能力MyBatis Starter数据库访问MySQL Driver数据库驱动Lombok简化实体类代码Spring Boot DevTools热更新开发效率提升明显项目创建后目录结构建议按照常见的分层模式组织src/main/java ├── com.example.gymmanagement │ ├── controller # Controller层接收请求 │ ├── service # Service层业务逻辑 │ │ └── impl │ ├── mapper # MyBatis的Mapper接口 │ ├── entity # 实体类 │ ├── common # 通用类返回结果、异常处理、工具类 │ │ └── Result.java │ └── config # 配置类跨域、拦截器等 src/main/resources ├── mapper # MyBatis XML文件 │ └── UserMapper.xml ├── application.yml # 全局配置文件不要追求包结构花哨清晰第一。controller和service都按业务模块拆分比如UserController、VenueController、BookingController、NoticeController。4.2 配置文件server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/gym_db?useUnicodetruecharacterEncodingutf-8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.gymmanagement.entity configuration: map-underscore-to-camel-case: true这里有几个细节值得注意。第一数据库URL一定要带serverTimezone参数不然本地时区与MySQL默认时区不一致时操作Date字段会莫名其妙报错。第二map-underscore-to-camel-casetrue这个配置能让数据库的booking_date自动映射成实体类的bookingDate不用写一堆resultMap。第三mybatis的mapper-locations指定你的XML文件放在哪里不配置的话XML根本加载不到。4.3 实体类和通用返回结构实体类用Lombok注解简化以Booking为例Data AllArgsConstructor NoArgsConstructor public class Booking { private Integer id; private String orderNo; private Integer userId; private Integer venueId; private String bookingDate; private String startTime; private String endTime; private BigDecimal totalPrice; private Integer status; private Date createTime; private Date payTime; // 非数据库字段联表查询时填充 private String userName; private String venueName; }注意后面两个字段userName和venueName它们不在booking表里但前端订单列表需要展示。我建议直接在实体类里加这两个字段MyBatis的resultType做多表查询的时候可以自动填充。这是MyBatis处理“实体类包含非本表字段”的常规做法。通用返回结构是接口规范化的基础Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }所有接口统一返回Result结构前端只用判断code是不是200就行。这个统一结构能避免前后端对接时各写各的格式减少联调痛苦。4.4 Mapper层核心SQL这样写BookingMapper接口Mapper public interface BookingMapper { ListBooking listBookings(Param(keyword) String keyword, Param(status) Integer status); int insert(Booking booking); int updateStatus(Param(id) Integer id, Param(status) Integer status); ListBooking findConflict(Param(venueId) Integer venueId, Param(bookingDate) String bookingDate, Param(startTime) String startTime, Param(endTime) String endTime, Param(excludeId) Integer excludeId); }findConflict是场地冲突检测的核心方法。对应的XML如下select idfindConflict resultTypecom.example.gymmanagement.entity.Booking SELECT * FROM booking WHERE venue_id #{venueId} AND booking_date #{bookingDate} AND status IN (0, 1) if testexcludeId ! null AND id ! #{excludeId} /if AND ( (start_time lt; #{endTime} AND end_time gt; #{startTime}) ) /select这个时间重叠判断条件是整段SQL的精髓。两个时间段重叠的判断逻辑是已有订单的开始时间小于新订单的结束时间并且已有订单的结束时间大于新订单的开始时间。这个条件能精确捕获所有重叠情况包括部分重叠、完全包含、首尾相接。业务层的调用逻辑public Result? createBooking(Booking booking) { // 生成订单号 booking.setOrderNo(generateOrderNo()); booking.setStatus(0); // 冲突检测 ListBooking conflicts bookingMapper.findConflict( booking.getVenueId(), booking.getBookingDate(), booking.getStartTime(), booking.getEndTime(), null); if (!conflicts.isEmpty()) { return Result.error(该时段已被预约请选择其他时间); } // 计算价格 Venue venue venueMapper.selectById(booking.getVenueId()); BigDecimal hourPrice venue.getPrice(); int hours computeHours(booking.getStartTime(), booking.getEndTime()); booking.setTotalPrice(hourPrice.multiply(BigDecimal.valueOf(hours))); bookingMapper.insert(booking); return Result.success(booking); }这里先检测冲突再计算价格最后插入。之所以把价格计算放在Service层而不是让前端传是为了防止用户篡改金额。前端传入的total_price后端必须无视一切以数据库的价格和用户预约时长重新计算。4.5 Service层事务和业务规则的集中地订单状态变更操作必须加事务。比如用户取消订单要先更新订单状态可能还要记录取消时间。这两个操作虽然是两次update但应该在一个事务里Spring的Transactional注解直接搞定。Transactional public void cancelBooking(Integer id, Integer userId) { Booking booking bookingMapper.selectById(id); if (booking null) { throw new RuntimeException(订单不存在); } if (!booking.getUserId().equals(userId)) { throw new RuntimeException(无权操作该订单); } if (booking.getStatus() ! 0 booking.getStatus() ! 1) { throw new RuntimeException(当前状态不可取消); } bookingMapper.updateStatus(id, 2); }这个方法的逻辑要点校验订单存在、校验当前用户是否是下单人、校验状态是否允许取消。每一步都是业务规则的落地。很多初学者容易只写最后一行update结果就是任何用户都能取消别人的订单这是绝对不允许的。4.6 Controller层接口设计规范Controller本身不应该写业务逻辑它的职责是接收请求、调用Service、返回结果。RestController RequestMapping(/api/booking) public class BookingController { Autowired private BookingService bookingService; PostMapping(/create) public Result? create(RequestBody Booking booking) { return bookingService.createBooking(booking); } GetMapping(/list) public Result? list(RequestParam(value keyword, required false) String keyword, RequestParam(value status, required false) Integer status) { return bookingService.list(keyword, status); } PostMapping(/cancel) public Result? cancel(RequestBody MapString, Object params) { Integer id (Integer) params.get(id); Integer userId (Integer) params.get(userId); return bookingService.cancelBooking(id, userId); } }接口路径的设计有个原则以资源命名用HTTP方法区分动作。/api/booking/create表示创建订单/api/booking/list表示查询列表。不要设计成/api/getBookingList这样的中英混合路径时间长了你自己都看不懂。登录鉴权我用的是拦截器加自定义注解的方式对不需要登录的接口放开对需要登录的接口校验token。如果团队规范要求使用Spring Security直接在框架里配置即可但表达的逻辑是一样的。4.7 跨域配置前后端分离项目后端跑在8080前端开发服务器跑在5173端口不一致必然产生跨域。后端需要配置全局跨域Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true); } }这里有个坑allowCredentials(true)的时候allowedOrigins不能写必须用allowedOriginPatterns()。我第一次配的时候用了allowedOrigins(*)加allowCredentials(true)结果前端请求一直报跨域错误排查了好久才发现是组合不合法。5. 前端实现Vue项目的页面搭建与接口对接前端看起来工作量比后端大但因为有组件库很多页面其实是重复劳动。重点在于把公共逻辑抽好、把接口封装好。5.1 项目创建和依赖安装npm create vitelatest gym-frontend -- --template vue cd gym-frontend npm install npm install vue-router4 axios element-plus piniaVite创建的项目比vue-cli快得多模板也是最新的Vue 3写法。Element Plus是UI组件库安装后需要全局注册// main.js import { createApp } from vue import App from ./App.vue import ElementPlus from element-plus import element-plus/dist/index.css import router from ./router const app createApp(App) app.use(ElementPlus) app.use(router) app.mount(#app)5.2 路由设计const routes [ { path: /, component: HomePage }, { path: /venues, component: VenueList }, { path: /booking, component: BookingList }, { path: /login, component: LoginPage }, { path: /admin, component: AdminLayout, children: [ { path: users, component: AdminUsers }, { path: venues, component: AdminVenues }, { path: orders, component: AdminOrders }, { path: notices, component: AdminNotices } ]} ]路由表要区分用户端和管理员端。管理员端统一放在AdminLayout下配合路由守卫实现权限控制router.beforeEach((to, from, next) { const user JSON.parse(localStorage.getItem(user) || null) if (to.path.startsWith(/admin) (!user || user.role ! 1)) { next(/login) } else { next() } })5.3 axios封装axios请求如果每个页面都写一遍很啰嗦封装一个统一的request工具// utils/request.js import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: http://localhost:8080/api, timeout: 10000 }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res }, error { ElMessage.error(网络异常请稍后重试) return Promise.reject(error) } ) export default request把这个封装放在一个文件里所有请求统一走它。好处是如果后端返回结构有变化只需要改这一处。登录后把token存到localStorage在axios请求拦截器里带上Authorization头后端接口就能识别当前用户身份。5.4 核心页面实现场地列表页是关键交互页面。卡片布局展示每个场地点击“预约”弹窗选择日期和时间段前端本地先做一次时间校验再提交到后端。时间段选择是我遇到的最复杂前端交互之一。体育馆通常是按小时预约但允许用户选择多小时连续时段。实现方式是用一个时间选择器选出开始时间和结束时间前端限制结束时间必须晚于开始时间最早可预约时间不能早于当前时间el-time-select v-modelstartTime :min-time09:00 :max-time21:00 start09:00 step01:00 end22:00 placeholder开始时间 /订单列表页要显示字段多表格列包括订单编号、场地名称、日期、时段、金额、状态、操作。操作按钮根据状态动态显示待支付订单显示“去支付”和“取消”已支付订单显示“取消预约”。这个逻辑不复杂但要注意状态对应的文案和颜色做到前端状态展示不重不漏。5.5 前后端联调的经验前后端联调最让人崩溃的往往是接口文档不一致。我习惯的做法是后端写完一个接口先用Apifox或Swagger测试通过再把接口路径、参数、返回结构同步给前端。前端拿到返回结果先确认code200再拿data字段里的数据渲染页面。一个常见问题是时间格式。前后端时区不一致会导致日期显示差8小时或者直接显示成UTC格式字符串。解决方法是后端统一返回时间字段前设置好格式前端用小工具函数格式化时间。我在项目里直接用dayjs库一行代码搞定import dayjs from dayjs const formatTime (time) { return time ? dayjs(time).format(YYYY-MM-DD HH:mm) : - }6. 部署上线从本地到服务器的完整操作项目开发完成之后部署上线是压轴戏。我把整个部署流程拆成四步每一步都给出实际可操作的命令。6.1 环境准备服务器需要一个JDK环境一个MySQL数据库一个Nginx。前端打包成静态文件放到Nginx目录后端打包成jar用systemd或直接java -jar运行。服务器上先装基础环境以Ubuntu系统为例# 安装JDK 17 sudo apt update sudo apt install openjdk-17-jdk # 安装MySQL sudo apt install mysql-server # 安装Nginx sudo apt install nginx # 安装Node.js curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - sudo apt install -y nodejs6.2 后端部署本地执行Maven打包跳过测试mvn clean package -DskipTests这一步在target目录下生成一个可执行的jar包通常命名是项目名加版本号。把这个jar上传到服务器的某个目录比如/home/admin/app/然后直接运行java -jar gym-management-0.0.1-SNAPSHOT.jar --spring.profiles.activeprod如果服务器配置不高担心内存不足可以显式指定JVM大小java -Xms256m -Xmx512m -jar gym-management-0.0.1-SNAPSHOT.jar 注意后端连接数据库的库名、账号密码、服务器地址这些信息不应该写在application.yml里写成死最好用环境变量动态注入的方式。Spring Boot支持在配置里写占位符spring: datasource: url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useUnicodetruecharacterEncodingutf-8useSSLfalseserverTimezoneAsia/Shanghai username: ${DB_USERNAME} password: ${DB_PASSWORD}启动命令变成export DB_HOSTlocalhost export DB_PORT3306 export DB_NAMEgym_db export DB_USERNAMEroot export DB_PASSWORD你的密码 java -jar gym-management-0.0.1-SNAPSHOT.jar 这样做的好处是配置文件里不出现真实密码换环境只需要改环境变量。6.3 前端部署前端构建npm run build构建完成后dist目录下生成的就是静态文件。把它们复制到Nginx的网站目录sudo cp -r dist/* /var/www/html/然后配置Nginx反向代理把/api请求转发到后端8080端口server { listen 80; server_name yourdomain.com; location / { root /var/www/html; index index.html; 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; } }这里的关键是取/代理必须写try_files转向index.html否则刷新页面或直接访问子路由会404。前后端分离项目最容易在这个地方踩坑。配置完成后重载Nginxsudo nginx -s reload6.4 一个生产环境的坑生产环境部署时前端请求地址不能用localhost。如果前端页面被浏览器打开浏览器所在的机器不是服务器axios请求的baseURL如果写成http://localhost:8080/api浏览器会请求用户自己电脑上的8080端口自然失败。解决方式是请求地址写成服务器域名或IP。更稳妥的做法是前端在请求时使用相对路径/api让Nginx做转发这样前端打包后不管部署到哪里都不用改代码。但要注意开发环境需要配置Vite代理// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })开发时前端通过/api访问后端部署时Nginx同样把/api转到后端前端代码一行不用改。7. 常见问题与排查实录这个项目我从搭建到部署踩了不少坑。有些坑属于常识性的有些则很隐蔽。我整理一个速查表都是实际遇到过的。问题现象可能原因解决方式启动报Failed to configure a DataSource数据库配置错误或驱动未引入检查application.yml的url、账号密码确保导入了mysql-connector-java接口一直404路径不匹配或Controller未加RestController检查Controller注解和RequestMapping路径是否和前端一致请求报405方法不匹配检查前端使用的GET/POST和后端定义是否一致中文乱码字符集配置不一致数据库URL加characterEncodingutf-8数据库表设置utf8mb4跨域报错CORS配置不当配置CorsConfigallowCredentials(true)时使用allowedOriginPatterns页面刷新404Nginx未配置try_files在server块加上try_files $uri $uri/ /index.html取消订单报错状态机判断顺序不对先校验状态再执行update确保状态流转逻辑完整7.1 数据库连接一直超时排查思路部署到服务器后后端启动报Communications link failure。我当时的排查顺序是服务器上直接执行mysql -u root -p看能不能连确认MySQL服务在运行查看端口监听状态netstat -tunlp | grep 3306发现MySQL只监听了127.0.0.1远程连接被拒修改MySQL配置文件bind-address0.0.0.0重启服务如果你是本地连远程数据库还要确认服务器防火墙有没有开放3306端口。7.2 时间类型序列化格式不对前端拿到的时间是数字时间戳或者带T的字符串排错方法是后端在配置文件里统一设置全局时间格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8这里特别要注意time-zone必须设置否则前后端时间可能差8个小时。7.3 线上内存不足导致服务被杀小内存服务器上跑jar包默认的JVM堆内存设置可能过大出现内存溢出后进程直接被系统杀死。解决方法是启动时显式限制堆内存java -Xms128m -Xmx256m -jar gym-management-0.0.1-SNAPSHOT.jar同时可以用swap分区补充物理内存。这个优化对单用户小项目完全够用。8. 我的一点实操心得项目做下来最大的体会是管理系统看起来是增删改查但真正的难点全在业务规则设计上。比如场地预约的冲突检测看似就是一个时间重叠判断但如果没有在数据库设计阶段预留好索引字段没有在SQL里写对重叠条件后面补起来会很痛苦。再比如订单状态流转每个状态的变化条件都要考虑清楚依赖前端或者依赖人脑去记迟早会出乱子。另外一个心得是部署环节别拖到最后才做。我见过不少人在本地跑得好好的一部署就各种问题数据库时区、字符集、路径映射、跨域请求一个个冒出来。建议项目开发早期就把后端打成jar包在本地跑一遍前端也build一次看看静态文件能不能正常访问。越早发现问题改起来越从容。还有一点想专门提醒代码规范从第一天就要重视。实体类命名、接口路径、状态码定义这些看起来是小问题但当一个项目积累到几千行代码命名混乱会让你改一个功能恨不得把所有文件翻一遍。我的习惯是所有状态码和常量统一放在常量类里接口返回全部走统一Result结构英文命名尽量表意清晰。这些习惯不会直接产生功能价值但长期维护的效率差别非常明显。后续如果要扩展这个系统还有不少可以做的方向。比如接入支付网关支持微信和支付宝在线付款增加消息推送用户预约前提醒场地开放时间加一个数据大屏把场馆使用率、营业额按图表展示出来。底子已经打好了往上加功能只是时间和精力的问题。做管理系统就是这样业务需求永远在变但好的架构和清晰的代码能让你面对变化时不至于推翻重来。