上半年我接了一个社区健身公园管理系统的活儿,客户的需求听起来不复杂:居民线上预约篮球场、羽毛球场,查看健身课程,管理员能维护设备、发公告、看预约数据。但这套基于Spring Boot的系统,真从0开始设计,涉及的后端逻辑、并发预约、权限控制、前后端联调,一点也不比一个中型电商项目少。项目最后按期交付,自己也踩了不少坑,今天把整个设计实现过程完整拆一遍。不管你是拿它当毕业设计,还是做实际项目参考,技术选型、表结构、难点处理都可以直接借鉴。
1. 项目拆解与整体设计思路
1.1 这系统到底要管什么
社区健身公园和传统体育馆不太一样,场地分散、免费为主但需要控制人流,课程以公益和低收费为主,设备维护依赖居民上报和管理员检修。把这些散落在微信群、纸质本子上的流程搬到线上,核心就是三个字:管起来。
系统拆下来主要有几大块:一是居民端,包含注册登录、公园场地实时查看、分时段预约、健身课程报名、个人预约记录、设备故障上报;二是管理端,包含场地信息维护、时段和名额配置、课程发布、预约核销、设备维修工单处理、公告推送;三是公共能力,包含文件上传、短信/站内信通知、统计报表。
功能看起来不多,但每一块背后都有业务规则。比如场地的可预约时段不是固定的,夏天和冬天开放时间不一样;课程报名有截止时间,满了要关闭;预约后爽约需要规则限制,否则场地资源就被浪费。这些规则必须在设计初期就定清楚,不然后期改起来非常痛苦。
1.2 技术栈选型与原因
后端我选的是Spring Boot 2.7版本。为什么不直接上Spring Boot 3?因为项目上线时团队里的一些依赖如MyBatis-Plus、部分旧版工具包对3.x的兼容性还不够稳,而且客户环境里JDK还是8,2.7配合JDK8是最稳妥的组合。Spring Boot的核心优势大家都知道,自动装配加starter生态,本来要配一堆XML的SSH项目,现在一个依赖加几行配置就能跑起来,能把精力全部放在业务代码上。
前端用了Vue 2 + Element UI。Vue 2虽然已经是老技术,但能看懂的人多,社区资料全,对于这类管理系统完全够用。Element UI的表格、表单、弹窗组件非常成熟,管理员端的CRUD界面基本是复制粘贴后改字段。如果换成Vue 3 + Element Plus也不是不行,但从稳定和交付速度考虑,老组合反而省心。
数据库用MySQL,缓存和分布式锁用Redis,ORM用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,因为要参与判断“当前时间是否在课程报名期内”这类比较运算。
布尔状态统一用tinyint,0表示停用/取消,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() { List<ReservationRecord> 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联调与包进JAR
4.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连接串一定要带上serverTimezone=Asia/Shanghai和characterEncoding=utf8,不然会出现日期差8小时和中文乱码。时区问题我在测试阶段踩过,表现为预约记录的时间在数据库里是对的,但接口返回给前端少8小时,原因是JDBC驱动默认用的是服务器时区,而服务器时区是UTC。
Redis配置里要注意连接池参数。系统上线初期请求量不大,默认配置没问题,但预约时段是整点放票,瞬时并发会很高。我把max-total调到了100,max-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连接默认参数问题 | 连接串加allowPublicKeyRetrieval=true |
| 上传图片访问403 | 磁盘目录没有读权限 | 修改目录权限,或者调整Nginx用户 |
| Redis连接失败 | 密码没配置或IP白名单限制 | 检查spring.redis.password,放通安全组 |
排查问题时我习惯先看日志,再定位SQL,最后再看前端network面板。这个顺序极少走弯路。很多人前端报错就去翻后端代码,其实很多问题在浏览器F12里就能看出来是哪个接口返回了什么内容。
6. 项目复盘与我的扩展思路
这个系统交付之后,我自己做了一轮复盘,认为最值得优化的点有两个。一是预约状态的变更路径,当时虽然上线了,但状态流转靠散落的if判断,后边加“爽约限制”功能时改起来很别扭。如果重来一次,我会在一开始就抽出状态机枚举,把可流转状态和触发动作集中管理。二是通知机制,当时所有预约结果都是前端同步提示,用户没刷新页面就不知道课程被取消了。后续我接了一个消息队列做异步通知,预约成功、课程取消、设备维修完成都自动推给用户,体感提升非常明显。
这套系统还能往好几个方向扩展。比如对接社区门禁和智能闸机,实现预约后人脸识别入场;统计模块可以做成数据大屏,让社区管理者直观看到每天的人流高峰和场地利用率;小程序端也不难,把H5的逻辑稍微改造就能复用。如果你也想做类似项目,我的建议是先严格梳理业务规则,再动手写代码。数据库设计时多想几个边界条件,后期联调会顺畅很多。