做管理系统这类Java项目很多人上来就纠结“框架最新版”、“代码生成器一把梭”结果真到联调阶段不是被需求边界不清坑哭就是被一个跨域问题卡一下午。这篇是我实际完成一个海滨体育馆管理系统的完整回顾后端用JavaSpringBootMyBatis操作MySQL前端用Vue做页面和交互从需求设计、数据库建模、接口开发到前端联调、本地部署整个流程里踩过的坑和沉淀下来的方法都会讲到。如果你正在准备Java方向的项目、课程设计或者想拿一个典型业务系统当面试作品这篇值得看完再动手。1. 项目定位与整体设计思路1.1 这个系统真正要解决的核心问题体育馆管理系统听名字感觉是个“增删改查”项目但真把业务拆开难点比想象中多。核心要管好的就三样东西人、场地、订单。人指的是会员和管理员。会员要注册、登录、维护个人资料管理员要能查看用户列表、锁定异常账号。场地是体育馆的物理资源篮球馆、羽毛球馆、游泳馆、健身房这些场馆各自有不同营业时间、不同收费标准有的场馆还有多片场地同时可订。订单则是整个系统最核心的数据它把人和场地关联起来牵扯到预订、取消、支付状态、时间冲突校验这些逻辑。我建议在写第一行代码之前先想清楚上面这三类实体之间的关系。很多人数据库表建得乱就是没搞明白订单表是核心枢纽导致后面查询要一路拼接越写越痛苦。1.2 用户端与管理端的功能边界实际设计时我把功能划分成两个视角这样后面写接口才不会有“这个接口到底给谁用”的困惑。用户端侧重“快速完成预订”。用户打开系统能看到所有可预订的场馆点进详情页后能看到场地类别、价格、时间段列表选择合适的时间段下单。下单之后能在“我的订单”里看到预订记录也可以取消还未开始的订单。公告栏能看到馆方发布的最新通知。管理端侧重“运营管理”。管理员进入后台后可以做场馆资源的新增、编辑、上架下架可以配置价格和营业时间可以查看所有用户的订单、帮助用户确认订单或取消订单可以发布公告还能通过统计页面看营收汇总和热门场馆排行。这个边界一旦梳理清楚后面建表、定接口、写前端路由基本就是按图索骥。1.3 技术选型为什么是SpringBootVueMyBatis这个组合放到现在仍然很能打。SpringBoot胜在自动配置和生态完善一个starter依赖就能把Web项目跑起来不需要像传统SSH那样写一堆XML配置。MyBatis选它一个很实际的理由SQL完全可控遇到统计报表这类复杂查询直接用SQL写清楚比JPA这种全自动ORM一层层映射容易调优。MySQL则是免费、稳定、上手快中小型场馆管理系统完全够用。前端用Vue是因为它的组件化开发模式非常适合这种多页面管理后台。配合Element UI组件库表格、表单、弹出框、日期选择器等现成组件拿来就用省去了大量手写样式的时间。前后端分离之后部署时前端打包成静态文件后端打成jar包互不干扰维护起来也轻松。2. 数据库设计与建表要点2.1 高扩展性的表结构设计思路数据库是管理系统的地基地基没打好后面改表结构会让你崩溃。我的习惯是“先主链路、后辅助模块”。主链路是用户-场馆-订单这三张核心表辅助模块是公告、支付记录、操作日志。设计原则有三条。第一不搞物理外键只保留逻辑关联字段。数据库层面用外键在插入和删除时会加锁高并发场景下容易产生性能瓶颈业务层面通过代码保证数据一致性就够了。第二状态字段用数字枚举不用字符串。比如订单状态用0待支付、1已支付、2已取消、3已完成配合一个注释文档说清楚比直接存“已支付”这种中文值要规范得多。第三时间字段统一用datetime类型金额字段用decimal(10,2)避免浮点数精度问题。2.2 用户与场馆表字段设计用户表是基础表字段不需要太多够用就好。我最后落地的表结构大致是CREATE TABLE user ( id int(11) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 用户名, password varchar(255) NOT NULL COMMENT 密码MD5加密后存储, real_name varchar(50) DEFAULT NULL COMMENT 真实姓名, phone varchar(20) DEFAULT NULL COMMENT 手机号, id_card varchar(20) DEFAULT NULL COMMENT 身份证号, role tinyint(1) DEFAULT 0 COMMENT 角色0会员 1管理员, status tinyint(1) DEFAULT 1 COMMENT 状态1正常 0禁用, create_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4;密码字段我用了MD5加密存储虽然现在更推荐BCrypt但MD5做演示项目足够关键是不能明文存。注意用户名一定要加唯一索引这是注册接口防重复的第一道关卡。场馆表根据体育馆的真实场景来设计。海滨体育馆有别于普通社区场馆的地方在于它会同时包含室内场馆和露天场地比如户外网球场、海边沙滩排球场价格体系和开放时间不一样所以我单独加了一个venue_type字段做区分。CREATE TABLE venue ( id int(11) NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL COMMENT 场馆名称, venue_type tinyint(1) DEFAULT 1 COMMENT 类型1室内 2露天, category varchar(50) DEFAULT NULL COMMENT 运动分类篮球/羽毛球/游泳/网球等, price_per_hour decimal(10,2) DEFAULT 0.00 COMMENT 每小时价格, open_time varchar(10) DEFAULT 08:00 COMMENT 开始营业时间, close_time varchar(10) DEFAULT 22:00 COMMENT 结束营业时间, description text COMMENT 场馆描述, status tinyint(1) DEFAULT 1 COMMENT 状态1可预订 0停用, PRIMARY KEY (id), KEY idx_category (category) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;2.3 订单表与时间冲突校验订单表是整个系统设计的关键时间冲突校验的字段全靠它。我设计的订单表包含以下重要字段预订日期、开始时间、结束时间、订单金额、下单用户、场馆ID、订单状态。CREATE TABLE reservation ( id int(11) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单编号, user_id int(11) NOT NULL COMMENT 下单用户ID, venue_id int(11) NOT NULL COMMENT 场馆ID, book_date date NOT NULL COMMENT 预订日期, start_time varchar(10) NOT NULL COMMENT 开始时间 如14:00, end_time varchar(10) NOT NULL COMMENT 结束时间 如16:00, total_price decimal(10,2) DEFAULT 0.00 COMMENT 订单金额, status tinyint(1) DEFAULT 0 COMMENT 状态0待支付 1已支付 2已取消 3已完成, create_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT 下单时间, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_venue (user_id, venue_id), KEY idx_venue_date (venue_id, book_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;时间冲突校验是预订模块最容易出bug的地方。两个订单是否冲突判断逻辑是“新订单开始时间小于已有订单结束时间且新订单结束时间大于已有订单开始时间”。这个条件可以用一条SQL查出来SELECT COUNT(*) FROM reservation WHERE venue_id #{venueId} AND book_date #{bookDate} AND status IN (0, 1) AND ( (#{startTime} end_time AND #{endTime} start_time) )这个查询我做了不下十次测试各种边界场景都覆盖了才算放心。作为参考我把这条SQL写进了Mapper的XML里后续在Service里根据返回的count判断是否允许下单。2.4 辅助表公告与支付记录公告表结构简单就是标题、内容、发布时间、发布人。支付记录表我用了比较轻量的设计只记录订单号、支付方式、支付金额、支付时间。如果做演示项目支付环节可以只改订单状态不一定真正对接微信或支付宝。关键点是订单表里的order_no字段。这个编号我建议用“时间戳随机数”的方式生成比如ORD20250615143000001保证全局唯一同时方便按时间排查问题。直接在Java里生成就行不需要数据库自增这样在并发环境不容易撞号。3. 后端接口与服务层核心实现3.1 项目分层与目录结构后端项目我用了标准的四层结构Controller负责接口接收请求Service负责业务逻辑Mapper负责数据库操作Entity是实体类。放在SpringBoot里目录结构大概长这样com.haiyun.gym ├── GymApplication.java ├── common // 通用返回结果、异常处理、拦截器 ├── config // 全局配置类 ├── controller // 接口层 ├── service // 业务层 │ └── impl ├── mapper // MyBatis Mapper接口 ├── entity // 数据库实体 └── vo // 视图对象通用返回结果类ResultT是前后端联调的重要约定我封装了code、message、data三个字段前端根据code判断请求是否成功。统一返回结构能让你少写很多重复代码。3.2 用户登录与权限控制实现登录这块我没有引入Shiro或Spring Security一是项目复杂度不需要二是自己动手写一遍Token校验更能理解原理。我用的方案是登录成功后生成一个Token字符串存到Redis里也可以用Map代替设置过期时间返回给前端。前端每次请求都在Header里带token字段后端用拦截器统一校验。拦截器实现核心代码public class LoginInterceptor implements HandlerInterceptor { Autowired private TokenService tokenService; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(token); if (!tokenService.checkToken(token)) { response.setStatus(401); response.getWriter().write(未登录或登录已过期); return false; } request.setAttribute(userId, tokenService.getUserId(token)); return true; } }注册一个WebMvcConfigurer把拦截器挂到指定路径上放行登录、注册等接口其余接口全部校验。这里有个细节建议放行接口用路径匹配比如/api/user/login、/api/user/register其他接口都受拦截保护。3.3 场馆预订与时间冲突判断场馆预订是最核心的接口逻辑稍微复杂一些。这个接口要做的步骤是校验场馆是否存在且状态可预订。校验时间合法性开始时间不能早于当天、结束时间要晚于开始时间。查询同一场馆同一日期的冲突订单。计算订单金额时长乘以每小时价格。生成订单号插入订单表。计算金额有个小坑就是时间格式的处理。前端传的是09:00这种字符串后端不能用字符串比较大小应该把时分转成分钟数再对比。不然遇到9:00和14:00这种字符串比较结果完全不可控。我写了一个公共方法public static int timeToMinute(String time) { String[] parts time.split(:); return Integer.parseInt(parts[0]) * 60 Integer.parseInt(parts[1]); }然后再计算时长、单价和总价。订单状态初始为待支付前端用户点击“确认预订”时实际是调用了这个创建订单的接口管理员则可以在后台看到所有待支付状态订单。3.4 MyBatis动态SQL与条件查询场馆管理需要支持按类型、按名称模糊查询这种多条件组合查询非常适合用MyBatis的动态SQL。Mapper XML里写一个where标签里面用if判断传入参数是否为空不为空才拼接条件。select idselectVenueList resultTypecom.haiyun.gym.entity.Venue SELECT * FROM venue where if testcategory ! null and category ! AND category #{category} /if if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if teststatus ! null AND status #{status} /if /where ORDER BY create_time DESC /select这种写法最大的好处是调用方传入什么条件就拼什么条件不用专门写三个不同的SQL。但要注意if里判断字符串是否为空时status这种数字字段用! null就够了不要加! 否则传入0时会被过滤掉出现查不出数据的bug。3.5 数据统计营收与热门场馆统计功能是管理端的一大亮点。我用SQL聚合函数实现了两个报表按月统计营收按场馆统计预订次数排行。-- 营收统计 SELECT DATE_FORMAT(book_date, %Y-%m) AS month, SUM(total_price) AS income FROM reservation WHERE status IN (1, 3) GROUP BY DATE_FORMAT(book_date, %Y-%m) ORDER BY month DESC; -- 热门场馆排行 SELECT v.name, COUNT(*) AS order_count, SUM(r.total_price) AS total_income FROM reservation r LEFT JOIN venue v ON r.venue_id v.id WHERE r.status IN (1, 3) GROUP BY r.venue_id ORDER BY order_count DESC LIMIT 10;聚合查询完的结果建议封装成一个VO类不要直接返回Map或ListObject[]不然前端拿到的数据结构很别扭。这里MyBatis会自动做结果映射只要字段名对上就能直接封装进VO。4. Vue前端实现与联调4.1 前端环境与项目创建前端我用的Vue 2 Element UI的组合虽然Vue 3已经很成熟但Element UI在Vue 2生态下非常稳定网上的资料也多作为管理后台开发很顺。创建工程用的是Vue CLInpm install -g vue/cli vue create gym-web cd gym-web npm install element-ui axios vue-router装完依赖后在main.js里引入Element UI和全局样式。创建工程时路由建议选history模式但要注意后端接口路径里如果有特殊字符需要做URL转义。4.2 路由与登录态处理路由设计上我分了三个层级登录页、用户端主框架、管理端主框架。未登录用户访问受保护页面时用导航守卫做拦截router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (to.path /login) { next(); } else if (!token) { next(/login); } else { next(); } });页面刷新后路由守卫会重新执行一次这时Token是在本地存储里的所以登录成功后要把Token存进localStorage。还要把用户角色信息也存一份因为要根据role字段判断渲染用户端菜单还是管理端菜单。4.3 场馆列表与预订表单组件前端最复杂的部分是场馆列表页和预订表单。场馆列表用Element UI的el-card网格布局每个卡片显示场馆名称、类型、价格、描述点击“立即预订”跳转到详情页。详情页里展示场馆信息和场地时段列表。我预先定义了一个时间段选择器把场馆的营业时间切分成一段段表单提交时把选中的时间段传给后端。这一块要注意时间数据的格式统一前后端都按HH:mm格式传递避免出现9:00和09:00这种不匹配导致冲突判断失效的情况。4.4 Axios封装与跨域处理所有接口请求统一封装到request.js这样拦截器统一处理Token和错误提示代码会清爽很多import axios from axios; import { Message } from element-ui; import router from ../router; const service axios.create({ baseURL: /api, timeout: 10000 }); service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[token] token; } return config; }); service.interceptors.response.use( response { const res response.data; if (res.code ! 200) { Message.error(res.message || 请求失败); if (res.code 401) { router.push(/login); } return Promise.reject(new Error(res.message)); } return res; }, error { Message.error(网络异常请稍后再试); return Promise.reject(error); } ); export default service;前后端分离开发时跨域问题是必踩的坑。我用的是后端配置CORS的方式解决Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(http://localhost:8081) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true); } }注意 allowedOrigins 配置的是前端的地址如果前端是8081端口后端是8080端口跨域配置没写对或者漏了OPTIONS请求前端一定会报CORS错误。5. 部署流程与高频问题排查5.1 本地从零启动完整流程先启动MySQL创建数据库并导入脚本。然后启动后端SpringBoot项目启动成功后在浏览器访问Swagger或直接调用登录接口测试。最后启动前端项目执行npm run serve浏览器打开前端地址整个系统就能正常使用了。需要留意的是MySQL的账号密码要和application.yml里的配置保持一致。我遇到很多次系统起不来的原因就是数据库密码不对或者没创建库这种问题会浪费很多时间。5.2 高频踩坑与解决方案我把开发过程中遇到的问题整理成一个速查表方便大家对照排查问题现象可能原因解决方法前端请求接口报404后端接口路径写错或Controller没有加 RequestMapping检查后端接口路径和前端请求地址是否完全一致数据库中文数据乱码MySQL连接URL缺少 characterEncoding 参数URL末尾加上 useUnicodetruecharacterEncodingutf8登录接口返回500密码加密方式不一致或表字段长度不够检查数据库表和查询条件密码字段至少保留64长度预订提示“时间冲突”但是没冲突数据时间比较逻辑写错字符串直接对比了统一按分钟数计算后比较前端页面白屏路由配置错误或组件没正常引入打开浏览器控制台查看报错信息优先排查router配置后端接口能通但数据查不到SQL语句参数没传对或Mapper XML里的参数类型不对开启MyBatis日志打印在XML里加log-impl: org.apache.ibatis.logging.stdout.StdOutImpl5.3 MyBatis日志与SQL调试技巧MyBatis的SQL调试是排查数据问题的利器。在application.yml里配置如下控制台就会打印每条SQL和传入参数mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这样每次调试时你能直接看到SQL拼成什么样、参数有没有传进去。我调试动态SQL时候经常开着这个日志省去猜来猜去的时间。5.4 并发场景下的预订冲突优化如果有多用户同时抢订同一个场馆同一时段仅靠Service层的“先查询再插入”逻辑会存在并发风险。两个请求同时查到没有冲突然后同时插入就会产生超卖。有两种补救方式一是给reservation表加数据库唯一约束字段组合为venue_id book_date start_time冲突插入时数据库会报错业务层捕获异常后返回“该时段已被预订”二是用SELECT ... FOR UPDATE对场馆记录加行锁让同一场馆的预订串行执行。我实际采用的是“先查再插 唯一索引兜底”方案开发和维护成本最低。真实场景下体育馆预订量不会像秒杀那么高数据库唯一索引能挡住绝大多数并发冲突。6. 从源码到可交付项目的经验6.1 文档与注释的作用项目管理类项目特别强调可读性。我不仅给核心表写了表结构说明还在每个Controller方法上加了两行注释注明接口用途和参数含义。这样做的好处是代码写到后期自己都能快速找到“这个接口是干嘛的”更不用说老师或面试官翻阅代码时的第一印象了。接口文档方面如果不想手动写可以集成Springfox或者 knife4j自动生成Swagger文档。集成后将SwaggerUI打开前端和后端联调时不用手动去代码里翻请求路径。6.2 扩展空间与后续优化方向这个系统做完满足基本需求不是终点。如果想做得更完整可以从三个方向扩展对接真实支付服务、增加场馆多时段批量导入功能、用Redis做热门场馆缓存。支付这块对接微信支付或支付宝沙箱环境都行支付回调里更新订单状态。时段批量导入则能解决管理员逐条录入场地的痛苦用Excel上传批量解析即可。缓存主要是针对场馆列表这种热点数据查询时先查Redis缓存没命中再走数据库。6.3 完整源码的使用建议拿到完整源码或者自己写完一套源码之后千万别急着到处提交。建议按照“本地跑通-理解核心逻辑-尝试改一个功能-再提交”的顺序逐步消化。如果直接复制粘贴交作业答辩或面试一问细节就会露馅。最好在动手编码之前把表结构和接口清单画出来做到心里有数再写代码这类项目在这个框架下写起来还是很快的。我个人做下来最大的体会是这种管理系统项目难点从来不在某个单一技术点而是整个业务闭环的数据流转。一个订单从创建到支付会经过前端表单校验、后端参数校验、数据库冲突检测、金额计算、状态更新这么多环节任何一环出问题全局都跑不通。正是这个完整度让它成为很好的练手项目——能逼着你去想清楚表关系、业务边界和异常处理这些才是比框架版本更值钱的经验。
