首页 / 资讯中心 / 文章详情

社区健身公园管理系统实战:Spring Boot预约与数据库设计全解析

社区健身公园管理系统实战:Spring Boot预约与数据库设计全解析 ★ FEATURED ARTICLE
上半年我接了一个社区健身公园管理系统的活儿客户的需求听起来不复杂居民线上预约篮球场、羽毛球场查看健身课程管理员能维护设备、发公告、看预约数据。但这套基于Spring Boot的系统真从0开始设计涉及的后端逻辑、并发预约、权限控制、前后端联调一点也不比一个中型电商项目少。项目最后按期交付自己也踩了不少坑今天把整个设计实现过程完整拆一遍。不管你是拿它当毕业设计还是做实际项目参考技术选型、表结构、难点处理都可以直接借鉴。1. 项目拆解与整体设计思路1.1 这系统到底要管什么社区健身公园和传统体育馆不太一样场地分散、免费为主但需要控制人流课程以公益和低收费为主设备维护依赖居民上报和管理员检修。把这些散落在微信群、纸质本子上的流程搬到线上核心就是三个字管起来。系统拆下来主要有几大块一是居民端包含注册登录、公园场地实时查看、分时段预约、健身课程报名、个人预约记录、设备故障上报二是管理端包含场地信息维护、时段和名额配置、课程发布、预约核销、设备维修工单处理、公告推送三是公共能力包含文件上传、短信/站内信通知、统计报表。功能看起来不多但每一块背后都有业务规则。比如场地的可预约时段不是固定的夏天和冬天开放时间不一样课程报名有截止时间满了要关闭预约后爽约需要规则限制否则场地资源就被浪费。这些规则必须在设计初期就定清楚不然后期改起来非常痛苦。1.2 技术栈选型与原因后端我选的是Spring Boot 2.7版本。为什么不直接上Spring Boot 3因为项目上线时团队里的一些依赖如MyBatis-Plus、部分旧版工具包对3.x的兼容性还不够稳而且客户环境里JDK还是82.7配合JDK8是最稳妥的组合。Spring Boot的核心优势大家都知道自动装配加starter生态本来要配一堆XML的SSH项目现在一个依赖加几行配置就能跑起来能把精力全部放在业务代码上。前端用了Vue 2 Element UI。Vue 2虽然已经是老技术但能看懂的人多社区资料全对于这类管理系统完全够用。Element UI的表格、表单、弹窗组件非常成熟管理员端的CRUD界面基本是复制粘贴后改字段。如果换成Vue 3 Element Plus也不是不行但从稳定和交付速度考虑老组合反而省心。数据库用MySQL缓存和分布式锁用RedisORM用MyBatis-Plus。MyBatis-Plus的亮点是单表CRUD不用写SQL自带分页插件和逻辑删除能减少大量样板代码。复杂统计查询还是自己写XML这样效率最高。1.3 前后端模块怎么切开发时采用前后端分离后端只提供RESTful API前端独立运行。目录上分成三个项目fitness-api后端、fitness-admin管理端、fitness-uniapp居民端后续可转小程序。居民端我没走Web页面直接做成了H5适配的界面原因是社区居民大部分用手机访问但又不愿意下载App。后端接口设计按照资源划分例如/api/venue、/api/course、/api/reservation、/api/repair。接口统一返回{code, message, data}结构前端根据code判断业务成功或失败而不是依赖HTTP状态码。这样做的原因是某些业务异常比如“该时段已被预约”本身不是系统错误但前端需要明确提示统一code更利于处理。2. 数据库建模把业务落成表2.1 核心表结构梳理数据库设计是这类系统能否稳定运行的地基。我把核心表分成四类用户权限类、场地预约类、课程类、运维通知类。用户权限类最简单sys_user存登录账号、密码密文、姓名、手机号、角色、状态。角色我用的是简单字段而非独立表因为实际只有居民、管理员、超级管理员三种太复杂的RBAC表结构反而增加维护成本。场地预约类是整个系统的重中之重核心表有park_venue和reservation_record。前者存场地基础信息比如名称、类型、位置、可预约时间区间、上限人数、封面图后者存每一次预约动作。两个表通过venue_id关联。课程相关是course_info和course_order一个课程对应多个报名记录。运维通知类包含repair_order、sys_notice。维修工单里必须有设备名称、上报人、问题描述、处理状态、处理人、处理时间方便管理员跟踪。公告表简单标题、内容、发布时间、发布人。2.2 预约场景的表设计要点场地预约最容易出问题的是“同一时段被多人抢占”。我在reservation_record里加了一个组合唯一索引uk_user_venue_slot(user_id, venue_id, reserve_date, time_slot)保证一个用户同一天不能重复预约同一个场地的同一个时段。这个约束单纯靠代码判断不可靠必须落到数据库层。场地不同时段的人数限制放在了venue_time_slot表里字段包括场地id、开始时间、结束时间、可预约人数、已预约人数。预约成功时直接update ... set booked_count booked_count 1 where id ? and booked_count max_count让数据库判断是否还能预约。这条更新语句自带行锁多个用户同时操作时不会超卖。2.3 索引和字段类型建议reserve_date用date类型time_slot用varchar保存如“09:00-10:00”这样的字符串。为什么不用datetime因为运营人员排时段时看到的就是一个文本区间拆成开始结束两个时间字段后来还要拼接查询反而不直观。课程表里的时间必须用datetime因为要参与判断“当前时间是否在课程报名期内”这类比较运算。布尔状态统一用tinyint0表示停用/取消1表示启用/正常。不要用varchar存true/false查询和索引效率都差。所有金额字段如果有就用decimal(10,2)避免浮点误差课程报名费这种小金额也要认真对待。索引不是越多越好。登录表只需要username唯一索引预约表按上述组合索引维修工单按status建普通索引。冗余索引会导致写入变慢这类管理系统并发不算高业务清晰最重要。3. 后端从0到1关键功能与代码级实现3.1 工程结构一眼看懂后端工程我是这样组织的fitness-api ├── common # 统一返回体、异常处理、常量 ├── config # 跨域、Redis、MyBatis-Plus、定时任务配置 ├── controller # 接口层 ├── service # 业务层 ├── mapper # 数据访问层 ├── entity # 表实体 ├── dto # 入参出参对象 ├── utils # JWT、日期工具等很多人会把所有代码塞进controller图省事但这种项目一扩功能就崩。我的原则是controller只做参数校验和结果包装业务逻辑全部放service事务也加在service层。这样单元测试好写排查问题时能快速定位。3.2 登录认证与权限控制登录用的是JWT Redis的黑名单机制。用户输入账号密码校验通过后生成JWT返回给前端前端之后每次请求在Authorization头里携带。JWT的好处是服务端无状态多实例部署时不需要做会话同步天然适合前后端分离架构。为什么还要配Redis因为用户修改密码、管理员封禁账号时需要让已签发的token立即失效。我在Redis里维护一个用户版本号每次修改密码就自增拦截器校验token时同时比较Redis里的版本号不一致就拒绝。这个设计比简单设置token过期时间安全得多。密码存的是BCrypt加密后的结果每个用户盐随机数据库泄露后也无法反向推出明文。这里千万别用MD5彩虹表攻击基本不设防。权限上最粗暴但有效的方式是拦截器判定请求路径。管理端接口统一以/api/admin/前缀开头居民端以/api/user/开头登录和公共文件访问走/api/auth/。配置一个WebMvcConfigurer拦截器根据请求前缀判断角色几行代码就能实现粗粒度权限。细到按钮权限再在前端做毕竟后端接口本身已经区分了角色。3.3 预约并发控制核心逻辑预约流程看着简单用户提交场地、日期、时段后端判断是否还有名额插入预约记录。如果只是“判断-插入”两步并发场景下必然出问题。我在实现时把整个流程包在一个事务里用数据库的select ... for update锁定时段记录然后再判断名额、插入预约单。核心代码结构如下Transactional public ReserveResult reserve(Long userId, ReserveRequest req) { // 1. 锁定时段记录防止并发超卖 VenueTimeSlot slot venueSlotMapper.selectForUpdate(req.getSlotId()); if (slot null) { throw new ServiceException(该时段不存在); } // 2. 判断是否停用/是否已满 if (slot.getStatus() 0) { throw new ServiceException(该时段不可预约); } if (slot.getBookedCount() slot.getMaxCount()) { throw new ServiceException(名额已满); } // 3. 创建预约记录 ReservationRecord record new ReservationRecord(); record.setUserId(userId); record.setSlotId(slot.getId()); reservationRecordMapper.insert(record); // 4. 已预约人数1使用条件更新保证原子性 int rows venueSlotMapper.increaseBookedCount(slot.getId(), slot.getMaxCount()); if (rows 0) { throw new ServiceException(操作过于频繁请重试); } return ReserveResult.success(); }锁的粒度越小越好。我锁的是“某场地某日某时段”这一条记录不是整个场地表所以多个时段可以并行预约不会互相阻塞。用Transactional时要注意锁持有到事务提交前接口耗时越长锁持有越久。所以这里面的操作只放和本次预约强相关的SQL不要把发通知这类动作也放进来。取消预约是反向操作。需要注意状态机只有“已预约”状态才能取消“已核销”不能取消。取消后要把booked_count减回去同时把这条记录状态改成“已取消”而不是物理删除。保留历史记录对后续统计很有用。3.4 定时任务与过期清理场地的预约时段到了当天如果用户没来也不取消会占用资源。我用Scheduled做了一个定时任务每天凌晨三点把所有“已预约”且预约日期已过的时间段标记为“爽约”。对爽约的用户加一个计数超过三次就限制预约两周。Component public class ReservationScheduleTask { Scheduled(cron 0 0 3 * * ?) public void markNoShow() { ListReservationRecord records reservationRecordMapper.selectExpiredReserved(); for (ReservationRecord record : records) { record.setStatus(ReservationStatus.NO_SHOW); reservationRecordMapper.updateById(record); userAccountMapper.increaseNoShowCount(record.getUserId()); } } }定时任务在单机部署没问题。如果以后扩到多实例Scheduled会导致同一个任务在每个实例上各跑一次必须引入分布式锁或ShedLock框架。当时系统就一台服务器我用Redis写了个简易锁执行任务前先setNx一个带过期时间的key抢到锁的实例才执行。3.5 文件上传与统一返回处理场地封面图、课程图片、用户头像都会用到上传功能。我实现的方式是接收MultipartFile保存到服务器磁盘的某个目录然后拼出访问URL返回给前端。开发环境直接映射本地路径生产环境用Nginx映射到同名目录。file: upload-dir: /data/fitness/files access-prefix: /files/Spring Boot里加一个资源映射配置把/files/**映射到磁盘目录。这样前端的img src/files/xxx.jpg就能直接显示。上传文件前要校验扩展名和大小防止有人传脚本文件导致安全问题。我用白名单校验只允许jpg、png、gif、mp4等常见类型文件大小上限10MB。全局异常处理用RestControllerAdvice统一接住ServiceException、参数校验异常和系统异常。系统异常响应统一message为“系统繁忙请稍后重试”避免把异常堆栈暴露给前端既不安全也没意义。4. 前端Vue联调与包进JAR4.1 前端页面与Api层管理端界面用了经典的三栏布局左侧菜单、顶部导航、主内容区。页面按业务模块分目录例如views/venue/下面是场地列表、时段配置views/course/下面是课程管理views/repair/下面是工单处理。前端所有请求都集中在src/api/目录按模块拆文件比如venue.js、course.js、reservation.js。每个模块只导出API调用函数不掺业务逻辑。这样后端接口变动时只需要改一个文件全局调用处不受影响。居民端H5我单独建了一个项目首屏是公园场地地图和公告栏点击场地进入详情页再选择日期和时段预约。界面参考了微信小程序常用的卡片流设计手机上看起来很清爽。4.2 axios拦截器和Token前端用的是axios请求前自动把JWT塞进请求头响应后统一处理业务code。关键代码service.interceptors.request.use(config { const token getToken() 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) return Promise.reject(new Error(res.message)) } return res }, error { if (error.response error.response.status 401) { removeToken() router.push(/login) } return Promise.reject(error) } )401处理一定要放在响应拦截器里。如果token过期后端返回401前端自动清掉本地token跳回登录页。实测这样体验很顺用户重新登录后继续操作不用手动刷新页面。4.3 路由守卫与菜单权限路由守卫的逻辑是未登录只能进登录页和公开页面已登录但访问了当前角色无权访问的页面直接重定向到首页。我在路由的meta里定义roles比如管理端主页roles: [admin]工具函数判断当前用户角色是否在允许列表里。菜单权限也走同样的数据源。用户登录后后端返回他的角色和权限标识前端根据权限过滤菜单项。不要试图把所有菜单都渲染出来再用CSS隐藏用户按F12还是能看到治标不治本。页面级的权限必须由后端接口兜底。4.4 打包到Spring Boot还是Nginx开发阶段前端跑在Vite的dev server下通过代理把/api转发到后端8080端口。上线时有两种常用部署方式。第一种是把前端构建产物dist直接复制到Spring Boot的resources/static目录打成jar包一体化部署。这种方式最简单但要注意路由模式。Vue Router如果用history模式刷新页面时会出现404。我当时的解决办法是在Spring Boot里加一个转发配置对不包含.的非/api路径统一转发到/index.html。Controller public class IndexForwardController { RequestMapping(value /{path:[^\\.]*}) public String forward() { return forward:/index.html; } }第二种是标准的前后端分离部署前端静态文件放Nginx后端jar包单独跑。Nginx配置里location /api反向代理到后端服务location /指向静态文件目录。这种方式更符合生产实践后续前端升级不需要重新打jar包。我最终线上用的是第二种现场演示和交付都更灵活。5. 联调部署与踩坑排查5.1 开发环境配置要点项目里的配置我分了三份application.yml公共配置、application-dev.yml开发环境、application-prod.yml生产环境。数据库地址、Redis地址、文件存储路径这些按环境区分用spring.profiles.active切换。MySQL连接串一定要带上serverTimezoneAsia/Shanghai和characterEncodingutf8不然会出现日期差8小时和中文乱码。时区问题我在测试阶段踩过表现为预约记录的时间在数据库里是对的但接口返回给前端少8小时原因是JDBC驱动默认用的是服务器时区而服务器时区是UTC。Redis配置里要注意连接池参数。系统上线初期请求量不大默认配置没问题但预约时段是整点放票瞬时并发会很高。我把max-total调到了100max-idle调到30实测放票高峰不再报连接超时。5.2 跨域问题与代理配置开发环境最常见的跨域报错“No Access-Control-Allow-Origin header is present on the requested resource”。解决办法有两个一个是在后端配置CORS另一个是前端dev环境用Vite代理。我两个都做了配置后端允许所有来源适合前后端分开部署的场景前端dev代理作为备选减少联调时对后端的依赖。上线后同源部署就没有跨域问题了。如果前端和后端域名不同后端CORS要严格限定域名白名单不能一直用*否则等于把接口开放给任意网站调用存在安全隐患。5.3 常见问题排查速查表问题现象原因解决办法前端请求接口404后端路由Prefix不一致检查server.servlet.context-path和前端/api前缀日期返回是一串数字Jackson默认序列化LocalDateTime方式不对在配置里统一yyyy-MM-dd HH:mm:ss格式打包后启动报端口被占用8080端口被其他进程占用netstat -ano查端口换端口或杀进程MySQL报Public Key Retrieval错误新版MySQL连接默认参数问题连接串加allowPublicKeyRetrievaltrue上传图片访问403磁盘目录没有读权限修改目录权限或者调整Nginx用户Redis连接失败密码没配置或IP白名单限制检查spring.redis.password放通安全组排查问题时我习惯先看日志再定位SQL最后再看前端network面板。这个顺序极少走弯路。很多人前端报错就去翻后端代码其实很多问题在浏览器F12里就能看出来是哪个接口返回了什么内容。6. 项目复盘与我的扩展思路这个系统交付之后我自己做了一轮复盘认为最值得优化的点有两个。一是预约状态的变更路径当时虽然上线了但状态流转靠散落的if判断后边加“爽约限制”功能时改起来很别扭。如果重来一次我会在一开始就抽出状态机枚举把可流转状态和触发动作集中管理。二是通知机制当时所有预约结果都是前端同步提示用户没刷新页面就不知道课程被取消了。后续我接了一个消息队列做异步通知预约成功、课程取消、设备维修完成都自动推给用户体感提升非常明显。这套系统还能往好几个方向扩展。比如对接社区门禁和智能闸机实现预约后人脸识别入场统计模块可以做成数据大屏让社区管理者直观看到每天的人流高峰和场地利用率小程序端也不难把H5的逻辑稍微改造就能复用。如果你也想做类似项目我的建议是先严格梳理业务规则再动手写代码。数据库设计时多想几个边界条件后期联调会顺畅很多。
阅读完成 · 觉得有帮助?
咨询建站