每年三四月份,朋友圈里最热闹的永远是毕业设计互助现场。求选题、求源码、求答辩PPT的人络绎不绝。我当年做毕业设计时,选了基于SpringBoot的露营管理系统,理由很实在:露营这个主题不像图书馆、宿舍管理那么烂大街,又刚好能触发一个真正有难度的业务问题——营位预约的时间冲突判断。
这个系统从面上看是一套典型的管理系统:登录注册、营位维护、订单管理、设备租赁、公告发布、评论管理。但深一层看,它是一套带资源约束的订单系统。营位是稀缺资源,同一时间不能被两个人预订;设备是有限库存,租一件少一件。把这两块业务做扎实了,项目就已经具备答辩时“讲得深”的资本了。下面我按自己从零到一搭这套系统的经历,把整体思路、核心实现和踩过的坑完整串一遍。
1. 项目定位与功能拆解
1.1 这题表面上是个管理系统,核心其实是预约业务
做管理系统的毕业设计,最怕的就是做成增删改查的堆砌。图书馆管理、学生选课系统这些题目已经被做烂了,评委一眼扫过去就知道里面有没有内容。露营管理系统的不同之处在于——营位本身是一个“资源”,用户选择的不是一个“商品”,而是一个“时间段内的资源使用权”。
这句话听起来抽象,落到数据库上就是:商品的库存是数字,扣一件少一件;营位的状态却取决于日期区间。同一个营位,7月1日到7月3日被人订了,7月4日之后又可以被下一个人订。如果系统只维护一个“总库存”字段,那根本解决不了问题。这个天然的业务矛盾,逼着你去设计一套日期重叠检测逻辑,而这套逻辑恰恰是大多数管理系统毕设里没有的东西。
我在设计时给系统划定了几个核心目标:用户能在线注册登录、浏览露营营位、选择日期完成预约下单;管理员能维护营位信息、审核订单、管理设备库存、发布公告;用户还能对营地写评价、查看自己的历史订单。功能不一定多,但每一条链路必须完整。
1.2 功能模块划分与角色权限
系统里我设计了三种角色,权限等级从低到高是游客、营地管理员、系统管理员。游客对应前端普通用户的操作;营地管理员负责日常运营,比如修改营位价格、上下架设备;系统管理员则是总控,能看全部订单和用户数据。
| 模块 | 角色 | 主要功能 |
|---|---|---|
| 用户模块 | 游客 | 注册、登录、个人信息修改、我的订单 |
| 营位管理 | 管理员 | 营位列表、新增营位、修改价格、停用启用 |
| 预约订单 | 游客/管理员 | 用户下单,管理员审核或取消订单 |
| 设备租赁 | 游客/管理员 | 浏览租赁设备、提交租赁订单、库存管理 |
| 公告模块 | 管理员 | 发布公告、编辑公告、前台展示 |
| 评论模块 | 游客 | 对已完成的露营订单发表评价 |
| 数据统计 | 系统管理员 | 订单趋势、收入汇总、热门营位排行 |
每个模块都不算复杂,但组合在一起,正好覆盖了软件工程里的“用户体系、业务流、状态变更、统计报表”几个标准知识点。
1.3 主流程串一遍:从浏览营地到完成评价
我从一个用户视角把主流程走了一遍,逻辑大概是这样的:用户注册登录后进入首页,看到营位列表,点进详情页,选择入住日期和离营日期,系统实时校验该营位在这个时间段内是否空闲。如果空闲,就生成预订单,用户确认后完成支付(毕设里一般是模拟支付或者直接标为已支付)。营地管理员在后端看到待确认订单,确认后订单变成“已确认”状态,用户按计划去露营,离营后订单变成“已完成”,此时用户可以写评价。
管理端的流程是一条相反的主线:管理员先录入营位和设备,然后处理用户订单、管理设备库存、发布营地公告,最后在统计页看到经营状况。两条主线交织在一起,系统的边界就非常清晰了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与整体架构
2.1 后端:SpringBoot 2.7 而不是 3.x
很多同学一上来就追新,直接上SpringBoot 3.x。但我给这个项目选的是SpringBoot 2.7.18,原因很现实:毕业设计多数用的是JDK 8,SpringBoot 3.x要求JDK 17起步,第一次装环境就会劝退一波人。而且网上资料、课程视频、博客文章绝大多数都是基于2.x写的,出问题一搜就有答案。
SpringBoot本身解决的是“配置地狱”问题。以前做SSM项目,光配置web.xml、SpringMVC.xml、MyBatis.xml就能耗掉两天时间;用SpringBoot之后,我只写了一个application.yml,加上starter-web、starter-validation、mybatis-plus这几个依赖,项目就跑起来了。它内置了Tomcat,打包成可执行Jar,一句java -jar就能启动,部署演示都很方便。
2.2 前端:Vue 3 + Element Plus 推荐理由
我的前端选择了Vue 3 + Vite + Element Plus + Axios,前后端完全分离。这样做的第一个好处是项目结构清楚,后端只出接口,前端只管渲染,答辩时能分别讲清楚;第二个好处是Element Plus的表格、表单、日期选择器拿来即用,界面不会太难看。
如果你不想折腾Node环境,用Thymeleaf把页面直接放到后端模板里也不是不行。但从学习价值来看,我还是建议做前后端分离。因为面试和工作中这套玩法就是主流,毕设等于提前练手。
前端目录我按功能拆成了views、components、router、api、store几个部分,页面组件放在views里,接口请求统一放在api目录下,每个页面调接口时只需要import { getCampsiteList } from '@/api/campsite',维护起来非常清晰。
2.3 后端项目目录如何组织
后端包结构我按常见的分层方式组织,控制器只管接收参数和返回结果,业务逻辑全部下沉到Service层,数据访问交给Mapper。这样答辩时问你“业务逻辑在哪一层”,你能非常自信地回答“Service层”。
txt复制src/main/java/com/camp/system/
├── common/ // 统一返回Result、异常处理、常量
├── config/ // 拦截器、资源映射、CORS配置
├── controller/ // 接口入口
├── service/ // 业务接口 + 实现类
├── mapper/ // MyBatis-Plus Mapper接口
├── entity/ // 数据库实体
├── dto/ // 前端传入参数封装
├── vo/ // 返回给前端的视图对象
├── utils/ // JWT、日期、订单号生成工具
└── CampApplication.java
2.4 关键依赖版本清单
版本选择不能凭感觉,我把自己实际能跑通的版本列出来,照着这个组合基本不会出依赖冲突:
| 组件 | 版本 | 备注 |
|---|---|---|
| JDK | 1.8 | 稳定,兼容性好 |
| SpringBoot | 2.7.18 | 2.x 系列的最终版 |
| MyBatis-Plus | 3.5.3.1 | 自带分页插件 |
| MySQL | 8.0.33 | 默认 utf8mb4 |
| jjwt | 0.9.1 | 生成和解析JWT |
| Hutool | 5.8.22 | 工具库,非必需但很好用 |
| Lombok | 1.18.30 | 简化实体代码 |
| Vue | 3.4.x | 配合Vite |
| Element Plus | 2.7.x | 现成UI组件 |
3. 数据库设计:先想清楚状态和日期
3.1 表清单与职责
数据库是整个系统的心脏。我建了七张核心表,每张表负责一条清晰的业务线:用户表user、营地表campsite、预约订单表booking_order、设备表equipment、租赁订单表rental_order、公告表notice、评价表comment。另外还有一张收藏表favorite,用来做用户收藏营地的功能。
| 表名 | 作用 | 关键字段 |
|---|---|---|
| user | 用户信息与角色 | username, password, role |
| campsite | 营位信息 | name, price, capacity, status |
| booking_order | 营位预约订单 | user_id, site_id, start_date, end_date, status |
| equipment | 租赁设备 | name, rental_price, stock, status |
| rental_order | 设备租赁订单 | user_id, equipment_id, quantity, status |
| notice | 系统公告 | title, content, create_time |
| comment | 营地评价 | user_id, site_id, content, rating |
3.2 预约订单的状态机设计
订单状态字段最常见的是用一个整型数字表示,我定义了几种状态:0待支付、1已确认、2已完成、3已取消。在预约业务里,状态不是随意跳的,必须是一条固定流转路线。
待支付可以变成已确认,也可以变成已取消;已确认可以变成已完成,也可以被管理员操作取消。我特别在代码里做了状态校验,让用户不能把已取消的订单一键改成已完成,避免数据脏掉。这个状态机在答辩时非常加分,因为能体现你对业务理解是成体系而不是随手写的。
3.3 预约表索引设计
预约订单表是查询压力最大的表,因为每一次选择日期都要去查冲突。我给booking_order建了一个组合索引,顺序是site_id、status、start_date、end_date。这样查询某个营位在某个时间段内是否有有效订单时,MySQL能快速缩小数据范围,不需要全表扫描。
3.4 核心建表SQL
这里放两个最核心的建表语句,一个是营地表,一个是预约订单表。后面所有业务都依赖这两张表。
sql复制CREATE TABLE `campsite` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL COMMENT '营位名称',
`location` varchar(255) DEFAULT NULL COMMENT '位置描述',
`cover` varchar(255) DEFAULT NULL COMMENT '封面图片URL',
`price` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '每晚价格',
`capacity` int(11) DEFAULT '2' COMMENT '可容纳人数',
`status` tinyint(1) DEFAULT '1' COMMENT '1可用 0停用',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='露营营位表';
CREATE TABLE `booking_order` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`order_no` varchar(64) NOT NULL COMMENT '订单编号',
`user_id` int(11) NOT NULL COMMENT '下单用户ID',
`site_id` int(11) NOT NULL COMMENT '营位ID',
`start_date` date NOT NULL COMMENT '入住日期',
`end_date` date NOT NULL COMMENT '离营日期',
`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,
PRIMARY KEY (`id`),
KEY `idx_site_status_date` (`site_id`,`status`,`start_date`,`end_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='营位预约订单表';
价格用decimal而不是float,是为了避免浮点误差;日期用date类型而不是字符串,是为了能用MySQL的日期函数做区间比较;订单号用varchar而不是自增id,是为了保护业务主键。这些细节我都是踩过坑才总结出来的。
4. 核心功能实现
4.1 登录注册与JWT鉴权
认证这块我选择“JWT + 拦截器”而不是完整引入Spring Security。理由很简单:Spring Security虽然功能强大,但配置复杂,对毕设来说是杀鸡用牛刀。JWT的方式更直观,只需要登录成功后生成一个Token,后续请求带上Token,后端拦截器验一下就能拿到当前用户。
JWT工具类我用来生成和解析Token,里面放了用户ID和角色信息:
java复制public class JwtUtil {
private static final String SECRET = "camping-system-secret-2024";
private static final long EXPIRE = 24 * 60 * 60 * 1000L;
public static String createToken(Long userId, String role) {
return Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("role", role)
.setExpiration(new Date(System.currentTimeMillis() + EXPIRE))
.signWith(SignatureAlgorithm.HS256, SECRET)
.compact();
}
public static Claims parse(String token) {
return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
}
}
拦截器统一处理需要登录的接口:
java复制@Component
public class JwtInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 放行所有OPTIONS预检请求
if (HttpMethod.OPTIONS.matches(request.getMethod())) {
return true;
}
String token = request.getHeader("Authorization");
if (StringUtils.hasText(token) && token.startsWith("Bearer ")) {
token = token.substring(7);
}
try {
Claims claims = JwtUtil.parse(token);
request.setAttribute("userId", Long.valueOf(claims.getSubject()));
request.setAttribute("role", claims.get("role"));
return true;
} catch (Exception e) {
throw new BizException(401, "登录状态已过期,请重新登录");
}
}
}
注册拦截器并配置放行路径。比如登录、注册、营位列表这些公开接口不需要Token,而“提交订单”“查看订单”这些必须登录之后才能访问。
4.2 营位预约与日期冲突校验逻辑
预约是整套系统的灵魂,也是最值得在答辩时展开的技术点。时间冲突的判断难点在于:营位的预约是一个区间,新来的预约也是一个区间,两个区间只要有任何一天的交叉,就是冲突。
我刚开始想得很简单,以为只要检查“新开始日期是否大于已有结束日期”就行。后来发现,如果一个订单从7月3日住到7月6日,新订单从7月1日住到7月4日,两段日期交叉了,但按我这个简单判断是漏掉的。正确的做法是判断两个区间是否重叠:一段的开始日期小于另一段的结束日期,同时一段的结束日期大于另一段的开始日期。
核心实现用MyBatis-Plus的LambdaQueryWrapper来写:
java复制@Transactional
public BookingOrder createBooking(BookingDTO dto, Long userId) {
Campsite site = campsiteService.getById(dto.getSiteId());
if (site == null || site.getStatus() == 0) {
throw new BizException("营位不存在或已停用");
}
LocalDate startDate = dto.getStartDate();
LocalDate endDate = dto.getEndDate();
if (startDate == null || endDate == null
|| endDate.isBefore(startDate)
|| startDate.isBefore(LocalDate.now())) {
throw new BizException("请选择正确的日期范围");
}
// 查询是否有重叠时间的有效订单
LambdaQueryWrapper<BookingOrder> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BookingOrder::getSiteId, dto.getSiteId())
.notIn(BookingOrder::getStatus,
Arrays.asList(ORDER_STATUS_CANCELED, ORDER_STATUS_FINISHED))
.apply("start_date < {0} AND end_date > {1}", endDate, startDate);
Long conflictCount = this.baseMapper.selectCount(wrapper);
if (conflictCount > 0) {
throw new BizException("该时间段已有订单,请更换日期");
}
// 计算金额,天数为结束减开始
long days = endDate.toEpochDay() - startDate.toEpochDay();
BigDecimal totalPrice = site.getPrice().multiply(BigDecimal.valueOf(days));
BookingOrder order = new BookingOrder();
order.setOrderNo(generateOrderNo());
order.setUserId(userId);
order.setSiteId(site.getId());
order.setStartDate(startDate);
order.setEndDate(endDate);
order.setTotalPrice(totalPrice);
order.setStatus(ORDER_STATUS_UNPAID);
this.save(order);
return order;
}
那个apply片段是这段代码的关键,它最终拼出的SQL条件就是start_date < 新结束日期 AND end_date > 新开始日期,刚好覆盖了区间重叠的所有情况。
订单号生成我用时间戳加随机数,避免用户猜出订单规律:
java复制private String generateOrderNo() {
String time = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
int random = ThreadLocalRandom.current().nextInt(100000, 999999);
return "YL" + time + random;
}
4.3 设备租赁与库存防超卖
设备租赁相对预约要简单,但有一个经典问题——库存超卖。两个用户同时租同一个型号的设备,如果前端只判断“库存是否大于0”再生成订单,高并发下可能两个请求都通过,最后库存变成负数。
毕业设计虽然不会遇到真实的高并发流量,但代码里体现不体现防超卖意识,是区分“能跑”和“写得好”的一个标准。我采用的办法是把查询和扣减放在同一个事务里,并且用数据库的FOR UPDATE锁住这一行,保证同一时间只有一个请求能读到这条设备记录。
Mapper里自定义一个方法:
java复制@Select("SELECT * FROM equipment WHERE id = #{id} FOR UPDATE")
Equipment selectByIdForUpdate(@Param("id") Long id);
Service层实现:
java复制@Transactional
public void rentEquipment(Long userId, Long equipmentId, Integer count) {
Equipment equipment = equipmentMapper.selectByIdForUpdate(equipmentId);
if (equipment == null || equipment.getStatus() == 0) {
throw new BizException("设备已下架");
}
if (equipment.getStock() < count) {
throw new BizException("库存不足");
}
equipment.setStock(equipment.getStock() - count);
equipmentMapper.updateById(equipment);
RentalOrder order = new RentalOrder();
order.setUserId(userId);
order.setEquipmentId(equipmentId);
order.setCount(count);
order.setTotalPrice(equipment.getRentalPrice()
.multiply(BigDecimal.valueOf(count)));
order.setStatus(0);
rentalOrderMapper.insert(order);
}
讲解这块时,我给自己的建议是:不要只讲FOR UPDATE,可以主动提一句“如果并发量更大,可以考虑乐观锁或Redis分布式锁”,这会让评委觉得你有工程视野,而不是只会写CRUD。
4.4 图片上传与静态资源映射
营位要有封面图,设备也要有图片。我实现了一个通用上传接口,前端传MultipartFile,后端保存到本机磁盘,返回可访问的URL。
保存文件名我用UUID重命名,避免用户上传的文件名带中文或特殊字符导致访问出错。保存路径放在项目根目录下的upload文件夹:
java复制@PostMapping("/upload")
public Result<String> upload(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
throw new BizException("文件不能为空");
}
String originalFilename = file.getOriginalFilename();
String suffix = "";
if (originalFilename != null && originalFilename.contains(".")) {
suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
}
String fileName = UUID.randomUUID().toString().replace("-", "") + suffix;
String basePath = System.getProperty("user.dir") + "/upload/";
File dir = new File(basePath);
if (!dir.exists()) {
dir.mkdirs();
}
try {
file.transferTo(new File(basePath + fileName));
} catch (IOException e) {
throw new BizException("文件上传失败");
}
return Result.ok("/upload/" + fileName);
}
然后通过WebMvcConfig配置一个磁盘路径到URL的映射,让浏览器可以直接访问上传后的图片:
java复制@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/upload/**")
.addResourceHandler("file:" + System.getProperty("user.dir") + "/upload/");
}
}
这里有个我实际踩过的小坑:IDE里运行项目时user.dir是项目路径,但用java -jar部署后,user.dir变成了jar包所在目录。所以打包部署时,要把upload文件夹和jar放在同一个目录下,图片才能正常保存和读取。
4.5 统计模块与页面联动
统计模块是很多同学最后才加的功能,也是让我在答辩时多讲了三分钟的部分。我用一张SQL统计出每天订单量和收入,返回给前端,前端用ECharts画折线图。
Mapper里按天聚合查询:
java复制@Select("SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS day, " +
"COUNT(*) AS orderCount, " +
"SUM(total_price) AS amount " +
"FROM booking_order " +
"WHERE create_time >= #{startTime} " +
"GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d') " +
"ORDER BY day")
List<OrderStatsVO> selectDailyStats(@Param("startTime") String startTime);
这样后台管理首页放几张图,一个用折线图展示近七天的订单趋势,一个用柱状图展示热门营位排行,整个系统的完成度瞬间就上来了。前端记得在接口返回后处理空数据的情况,比如某一天没有订单,要补一个0,不然图表会断裂。
5. 部署运行与问题排查
5.1 从零跑通后端
后端跑起来的步骤其实非常简单,我总结了六个步骤,照着做基本不会卡住。
- 安装JDK 1.8,配置
JAVA_HOME环境变量。 - 安装MySQL 8.0,执行项目里的
sql/init.sql,创建数据库和所有表。 - 打开
application.yml,修改数据库用户名密码、Redis地址。 - 用Maven执行
clean package,或者直接在IDEA里运行CampApplication.java。 - 用Postman测试接口,比如登录接口能否返回Token。
- 如果使用了前端,在
frontend目录下执行npm install && npm run dev。
5.2 常见报错速查表
我把开发过程中真正遇到过的报错整理成一张速查表,你可以直接对照排查:
| 问题现象 | 原因 | 解决办法 |
|---|---|---|
| 启动报端口被占用 | 8080或3306端口被其他程序占用 | 用netstat -ano找到占用PID,结束进程,或修改server.port |
| 连接MySQL报时区错误 | JDBC连接串没配serverTimezone | 在url末尾加serverTimezone=Asia/Shanghai |
| 前端访问接口跨域 | 前后端分离导致 | 配置CorsFilter或Nginx反向代理 |
| Maven下载依赖很慢 | 默认中央仓库在境外 | 在settings.xml配置阿里云镜像 |
| MyBatis-Plus分页失效 | 没注册分页拦截器 | 添加MybatisPlusInterceptor并注入PaginationInnerInterceptor |
| 上传图片后刷新页面404 | 静态资源映射缺失 | 检查WebMvcConfig的addResourceHandlers配置 |
| 使用JDK17启动报错 | SpringBoot版本太低不支持 | 换成JDK1.8,或升级SpringBoot3.x |
5.3 打包发布注意事项
如果答辩现场不想开IDEA,直接把项目打成可执行Jar。
bash复制mvn clean package -DskipTests
java -jar target/camping-system-0.0.1-SNAPSHOT.jar
有三个容易忽略的点:第一,application.yml里的数据库账号密码最好是可配置的,如果现场数据库密码不同,至少要知道改哪里;第二,上传目录upload需要和jar包同级,否则上传的图片和之前的数据对不上;第三,如果使用前端和Nginx部署,记得配置一个proxy_pass把/api请求代理到后端端口,避免跨域问题。
6. 从课设到答辩的几点经验
6.1 代码亮点怎么“亮”给评委
答辩时间就那么几分钟,评委不可能把你几百个小时的代码全看一遍。你要做的,是把代码里最有含金量的几处主动讲出来。我的做法是在项目文档里列了一个“技术亮点”清单,答辩时讲完系统功能后,专门挑三块去讲:日期冲突校验的区间重叠算法、订单状态机的统一流转控制、库存操作的悲观锁处理。
这里的共同点不是炫技,而是每个点都能回答“为什么这样做”——为什么会区重叠,为什么状态不能乱跳,为什么查询要锁行。评委最烦的回答是“这个代码我从网上copy的”,最吃这一套的是“我遇到了XX问题,然后选择XX方案解决”。
6.2 演示环节容易被追问的三个点
我把评委很可能追问的问题提前想了一遍,发现最常被问的就是这三个。
第一个问题是“你如何防止两个用户同时订同一个营位”。这个问题后面藏着并发控制,你需要从数据库层面的重叠校验、事务隔离级别、甚至锁的概念去回答。
第二个问题是“一个订单从创建到完成经历了哪些状态”。这就是前面说的状态机问题,你最好画一张状态流转图,每一个状态变更对应什么接口操作都讲清楚。
第三个问题是“营位价格、订单金额如果发生变化怎么办”。这个问题考察的是你对价格快照的理解,简单说就是订单生成那刻要把当时的单价和总价都存下来,不能等订单完成后去查营位当前价格,否则用户看到的价格和结算价格可能对不上。
6.3 这块项目还能怎么往深处扩展
如果想让项目在做完之后还有继续演进的空间,有几个方向可以考虑。一个是给营位加上“一键地图选点”,让用户在地图上直接选址;另一个是接入微信小程序前端,把用户端做成小程序;还有在线支付模块如果接入微信或支付宝沙箱,会更有说服力。
最后分享一个我自己的小体会:毕设项目不在于功能堆得多,而在于把一两个核心点做深。露营管理系统里真正考验人的就是日期冲突和库存控制这两个点,你把它吃透了,不仅答辩能讲清楚,以后写实际业务代码时遇到类似场景,也会知道该从哪个角度去拆解。这种能力,才是毕设留给你的真正回报。
