这几年短途周边游的需求涨得很猛,一到周末和节假日,周边景点、民宿、徒步路线的搜索量直接翻倍。但问题也暴露得很明显:大部分游客还是靠零散攻略拼信息,景区和商家又缺一个统一的后台去管理房源、票务和订单。这个基于SpringBoot+Vue的周边游平台管理系统,就是冲着这个痛点去的——它把景点展示、线路推荐、在线预订、订单管理、用户管理和商家后台全部揉进一个前后端分离的项目里,Java做后端服务,Vue搭前端界面,MySQL存数据,MyBatis负责数据库操作,整套源码是可跑通的完整闭环。这篇文章我会按实际开发顺序把项目拆开讲,从业务设计、表结构、接口实现到部署上线的坑,适合正在做毕设、想找练手项目、或者准备转行做Java全栈的朋友参考。
1. 周边游平台的核心业务拆解与功能规划
1.1 业务场景与用户角色分析
做系统之前先得把"谁在用"这个事想清楚。周边游平台不像电商那样只有一个买家一个卖家,这里其实存在三类使用方:普通游客、景区/商家运营方、平台超级管理员。三类角色对应的诉求完全不同,如果一开始混在一起设计,后面写接口的时候就会到处打补丁。
游客侧的核心诉求是"找得到、订得上、查得清"。找得到指景点分类清楚、搜索和筛选好用,最好还有推荐线路;订得上指看中景点或民宿后能直接下单,不用跳转到别的平台;查得清指订单状态要一目了然——待支付、已支付、已取消、已完成,每一步都有明确的反馈。商家侧的核心诉求则是"管得动",自家的景点信息要能自己改,价格库存要能调,收到订单要有提醒和审核入口。超级管理员则要掌握全局视角,管理所有用户、审核内容、处理纠纷,同时能看到基本的数据统计,比如每日订单量、销售额走势。
这三个角色在权限上天然是层级的。游客本身可能就是商家,但默认以游客身份使用;商家账号由管理员审批开通;管理员则是系统内置的固定角色。我的建议是用户表里直接加一个role字段,用整数存,0是游客,1是商家,2是管理员,不要搞太复杂的RBAC表结构,这种体量的项目用角色字段判断就够了,简单直接,排查问题也好查。
1.2 功能模块划分与需求点提炼
我把整个系统拆成六个模块:用户模块、景点模块、订单模块、评论模块、收藏模块、统计模块。每个模块再切成前端页面和后端接口两部分来规划。
用户模块不是只做注册登录那么浅。注册要区分手机号注册还是邮箱注册,登录后要用Token做身份校验,个人中心里还要能查看自己的收藏列表、评论记录、订单历史。商家切换身份后还要能看到自己名下的景点和对应订单。所以用户模块背后其实牵扯到店铺维度的数据归属问题。
景点模块是内容核心,包含景点分类(自然风光、人文古迹、亲子乐园等)、景点列表分页查询、关键词模糊搜索、按价格或评分排序、景点详情展示。商家端则是景点的CRUD,包括上传封面图、维护门票价格、设置库存数量、上下架管理。这里有一个很容易忽视的点:图片上传。很多同学做这个功能时只存了一个URL字符串,但实际开发需要处理图片文件本身,要么用本地存储,要么用OSS。我当时的做法是存本地静态目录,配合Nginx映射访问,简单不花钱,后续文章里细讲配置方法。
订单模块是整个系统的业务核心。流程是用户选景点和日期,生成订单,模拟支付(对接真实支付需要企业资质,自己练手就用"模拟支付"按钮),支付后商家端能看到待接单提醒,点击接单后订单状态变更为已确认,行程结束后用户可以发起评论。这中间还要处理取消订单的规则:待支付状态下可以直接取消,已支付后取消需要走退款逻辑,模拟项目里可以简化成"取消即退"并更新景点库存。
2. 后端SpringBoot+MyBatis的实现要点
2.1 项目结构分层与包规划
后端我用的是标准的四层结构,controller、service、mapper、entity,再额外加一个vo包用来放接口返回给前端的数据对象。很多初学者搞不清楚entity和vo的区别,我打个比方:entity是数据库表的直接映射,表长什么样它长什么样;vo是页面显示时的"定制视图",比如景点表里存的是scenic_img字段,但前端需要的是一个图片数组,这时候就在vo里组装,而不是改实体类硬塞。
我的包结构大致是这样:
com.travel.platform ├── controller │ ├── UserController.java │ ├── ScenicController.java │ ├── OrderController.java │ ├── CommentController.java │ └── AdminController.java ├── service │ ├── UserService.java │ ├── ScenicService.java │ └── ... ├── mapper │ ├── UserMapper.java │ ├── ScenicMapper.java │ └── ... ├── entity │ ├── User.java │ ├── Scenic.java │ ├── Order.java │ └── ... ├── vo │ ├── LoginVO.java │ ├── ScenicVO.java │ └── ... ├── common │ ├── Result.java │ ├── JwtUtil.java │ └── GlobalExceptionHandler.java └── config ├── CorsConfig.java └── WebMvcConfig.javacontroller层只做参数接收和结果返回,不放业务逻辑。service层写核心业务,比如下单时要扣库存、生成订单号、设置初始状态,这些编排逻辑一定在service里做。mapper层就是接口加XML文件,复杂查询写在XML里,简单查询直接用注解,配合MyBatis的动态SQL处理条件搜索。
这层拆分的好处是调错的时候路径清晰。一个请求进来,从controller到service再到mapper,日志一打就知道卡在哪个环节。真出问题了,也方便定位是参数问题、业务问题还是SQL问题,不用从堆栈底层一点点往上翻。
2.2 核心接口设计与关键代码解读
先讲登录接口,这是整个后端权限的入口。我的做法是用户输入手机号和密码,后端先做一次MD5加密比对,比对通过后生成一个JWT Token返回给前端。Token里我塞了userId和role,有效期设24小时。后续前端请求需要登录的接口时,在请求头里带着Token,后端通过拦截器解析Token并放行。
@PostMapping("/api/user/login") public Result login(@RequestBody LoginDTO dto) { User user = userService.login(dto.getPhone(), dto.getPassword()); if (user == null) { return Result.error("手机号或密码错误"); } String token = JwtUtil.createToken(user.getId(), user.getRole()); return Result.success(new LoginVO(token, user)); }这里有个细节:密码存库之前必须加密,不能明文入库。MD5虽然不算安全,但在教学项目里还能接受,真要上线建议换成BCrypt,Spring Security里自带这个工具,几行代码就能接入。我见过不少人直接以明文存密码,然后就挂到网上了,这个属于最低级的错误。
再讲下单接口,这里是并发和事务的重点。用户下单时要做三件事:校验景点是否存在且上架、检查库存是否充足、扣减库存并生成订单。这三步必须放在同一个事务里,不然就会出现库存扣了订单没生成、或者订单生成了库存没扣的尴尬状态。
@Transactional(rollbackFor = Exception.class) public Order createOrder(OrderDTO dto) { Scenic scenic = scenicMapper.selectById(dto.getScenicId()); if (scenic == null || scenic.getStatus() != 1) { throw new RuntimeException("景点不存在或已下架"); } if (scenic.getStock() < dto.getCount()) { throw new RuntimeException("库存不足"); } scenicMapper.decrStock(dto.getScenicId(), dto.getCount()); Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(dto.getUserId()); order.setScenicId(dto.getScenicId()); order.setCount(dto.getCount()); order.setAmount(scenic.getPrice() * dto.getCount()); order.setStatus(0); // 待支付 orderMapper.insert(order); return order; }注意扣库存的SQL我用的是UPDATE t_scenic SET stock = stock - #{count} WHERE id = #{id} AND stock >= #{count}这种安全写法,它靠数据库的行锁和条件判断来避免超卖,而不是先在Java里select出来再判断库存够不够。业务体量不大时这套方案非常稳,基本可以让高并发下的恶意下单无处下手。
3. 前端Vue页面与后端接口的联调落地
3.1 前端工程结构与页面路由规划
前端用的Vue框架配合Vue Router做路由管理,状态管理用的Pinia,HTTP请求统一封装在axios里。整体页面分两大块:C端用户界面和B端管理后台。
C端页面有首页、景点列表页、景点详情页、购物车/下单页、订单列表页、个人中心页。B端管理后台则包含登录页、数据概览页、景点管理页、订单管理页、用户管理页、评论管理页。两个端在路由上分开,是因为权限差异很大:C端游客可看,B端必须登录且有管理员或商家权限才能进。
路由守卫这块我踩过一次坑。第一次做的时候只在前端做了路由拦截,觉得拦截一下就完事了,后来发现接口根本挡不住,别人直接调接口就能拿到数据。后来改成前端控制页面跳转,后端拦截器校验每个请求的Token和权限,两头都堵住才算真安全。
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else if (to.meta.role && !hasRole(to.meta.role)) { next('/403') } else { next() } })有个实用的小技巧:在路由配置里给每个页面加meta标签,标上requiresAuth和role,这样路由守卫的逻辑写起来就很清爽,不会在守卫回调里堆一堆if判断。页面多了以后这个方法的好处会越来越明显。
3.2 关键页面实现与接口对接
景点列表页是C端交互最多的页面,它要同时支持分类筛选、关键词搜索、价格/评分排序、分页加载。我把它做成了一个独立的搜索组件,每次筛选条件变化就重新请求接口。这里有个性能小建议:不要每次切换筛选条件都重新创建组件实例,用watch监听筛选条件,条件变了直接调用接口更新数据列表,这样页面不会闪烁,用户体验也更顺。
搜索接口返回的时候是符合条件的数据列表,同时把总数带回来。毕竟分页组件需要知道总共有多少条数据,才能正确显示页码。前端用的分页组件是Element Plus的el-pagination,配置好current-page和page-size,触发change事件就重新拉数据,交互就很完整。
下单页是另外一个容易出问题的地方。用户选好景点后要在页面上填出行日期和购买数量,前端要做三步校验:日期不能早于今天、数量不能超过商家设置的限购数、金额显示要和后端计算一致。校验通过后才把订单数据发给后端。这里有个防呆设计我在实际开发中验证过很管用:下单按钮提交时置灰并显示"提交中",防止用户手快连点两次,造成重复下单。后端下单接口前面也做过幂等处理的话,双保险就齐了。
前后端联调阶段最痛苦的是接口字段对不上。后端习惯用下划线命名,比如user_id,前端却习惯驼峰userId。这时候有两个方案:一是后端在配置里开启MyBatis驼峰映射,让user_id自动转成userId;二是前端在axios响应拦截器里做统一转驼峰处理。我的建议是用方案一,直接在MyBatis配置文件里加一行配置,省事且不容易出错。
4. 数据库设计与表关系梳理
4.1 核心数据表结构与字段设计
数据库设计决定了整个项目的骨架,后端的代码效率、扩展空间在表设计那一刻就基本定调了。我总共设计六张核心表:用户表、景点分类表、景点表、订单表、评论表、收藏表,外加一个为统计预留的登录日志表。
用户表字段如下:id(主键)、phone(手机号)、password(加密后密码)、nickname(昵称)、avatar(头像)、role(角色)、create_time(注册时间)。手机号要加唯一索引,这是登录的凭证,不能重复。role字段用tinyint就够了,0/1/2分别对应游客/商家/管理员。
景点表需要重点关注,它的字段最多:id、scenic_name(景点名称)、category_id(所属分类)、description(详细介绍)、price(门票价格)、stock(库存数量)、cover_img(封面图)、detail_imgs(详情图组,用JSON字符串存储)、province、city、address(位置信息)、score(综合评分)、status(上下架状态)、merchant_id(所属商家)、create_time、update_time。status这个字段很关键,0是下架,1是上架,商家修改景点时先下架再编辑,改完之后重新上架,这种流程能有效避免编辑过程中游客买到"半成品"信息。
订单表是业务汇总的核心,我给它设了order_no(订单号,唯一索引)、user_id、scenic_id、count(购买数量)、amount(总金额)、status(0待支付,1已支付,2已接单,3已完成,4已取消,5已退款)、pay_time、create_time、update_time。订单号的生成必须唯一,我是用时间戳加随机数拼的,格式大概是 20250101120000123456,够用。
评论表和收藏表相对简单。评论表包含id、user_id、scenic_id、score、content、comment_time,同时给scenic_id加索引,方便查看某景点下的所有评论和做平均分统计。收藏表则是id、user_id、scenic_id、create_time,加上user_id和scenic_id联合唯一索引,防止重复收藏。
4.2 表关联查询与性能优化思路
项目里最常用的联表查询是景点列表页的搜索,它要关联景点分类表和景点表,可能还要带出评论平均分。MyBatis里我推荐用XML写这种查询,SQL调优起来比注解直观很多。
SELECT s.id, s.scenic_name, s.price, s.stock, s.cover_img, c.category_name, IFNULL(AVG(cmt.score), 0) AS avg_score FROM t_scenic s LEFT JOIN t_category c ON s.category_id = c.id LEFT JOIN t_comment cmt ON s.id = cmt.scenic_id WHERE s.status = 1 AND (#{keyword} IS NULL OR s.scenic_name LIKE CONCAT('%', #{keyword}, '%')) AND (#{categoryId} IS NULL OR s.category_id = #{categoryId}) ORDER BY #{sortType} LIMIT #{offset}, #{pageSize}这段SQL里我用LEFT JOIN而不是INNER JOIN,是因为景点的评论可能一条都没有,如果用了INNER JOIN,没评论的景点就会从列表里消失,这明显不符合用户预期。LEFT JOIN加IFNULL函数,把没有评论的景点评分显示为0,就完美解决了。
性能优化方面,一开始表里数据少看不出问题,等景点数量过万、订单过十万后,该建的索引一个都不能少。景点表的status和category_id要建复合索引,订单表的user_id要建索引,这样按用户查历史订单时才不会全表扫描。还有一点,只要数据量上来,分页查询就别用LIMIT 100000, 10这种写法,跳过十万行再取十条会有明显的延迟。改成记录上一页最后一条数据的id,然后用WHERE id > #{lastId} LIMIT 10去查,走主键索引快很多,这就是大家常说的键集分页,实现成本不高但收益非常明显。
5. 部署上线与常见问题排查
5.1 本地环境搭建与项目启动步骤
本地把项目跑起来之前,需要先确认环境都装齐了。后端要求JDK 8或11、Maven 3.6+、MySQL 5.7或8.0;前端要求Node.js 16+,npm使用默认的就行。装MySQL的时候有个小提醒:5.7和8.0的密码认证方式不太一样,8.0默认是caching_sha2_password,老一些的JDBC驱动不认,如果连数据库时报密码相关错误,要么升级驱动,要么在MySQL里把认证方式换成mysql_native_password。
数据库准备分两步:第一步是创建一个空库,比如叫travel_platform;第二步是把项目里带的那份sql文件导入进去,里面已经建好了表结构和测试数据。导入完成之后改后端配置文件application.yml里的数据库账号密码,再在项目根目录执行mvn spring-boot:run启动后端,看到"Started TravelApplication"就说明后端起好了。
启动后端的端口默认是8080,前端这边要留意代理配置。Vue项目里的vite.config.js或vue.config.js,把/api请求代理到localhost:8080,这样前端发出的所有/api开头的请求都能自动转发到后端服务,避免了在前端代码里写死后端地址。
前端启动更简单,在项目目录执行npm install装依赖,然后npm run dev,默认跑在5173或8080这个开发端口。浏览器打开页面后先注册一个游客账号,随便浏览一下景点列表和详情页,再注册一个商家账号登录后台,尝试发布一个景点,走一遍完整流程确认数据都能持久化,项目在本地就跑通了。
5.2 实际踩过的坑与解决方案
部署和调试过程中,有些问题几乎每个人都会遇到,我这边整理几个最常见的,都是实际解决过的,直接抄作业就行。
跨域请求被拦截。前端跑在5173端口,后端在8080端口,浏览器出于同源策略会拦截跨域请求。解决办法是在后端加一个配置类,允许前端的域名访问:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }配置好后如果还报跨域错误,要确认是不是开启了非简单请求。比如前端带了自定义请求头Authorization,浏览器会先发一个OPTIONS预检请求,后端必须在响应里明确允许这个自定义头,浏览器才会放行真正的请求。
MyBatis映射文件里的SQL报错,提示找不到方法。这个问题常见于mapper接口和XML文件的namespace对不上,或者方法名不一致。检查点有三个:interface类的全限定名必须等于XML里namespace的值;XML里的id必须等于interface里的方法名;方法的参数类型和返回类型要匹配。这几个点都没问题还报错的话,就去target目录里查一下XML有没有被编译过去,有时候文件放错目录导致编译时没打包进去,这种情况最坑。
前端打包后刷新页面404。本地开发模式一切正常,npm run build之后把dist文件丢到服务器上,却发现访问首页正常,刷新其他路由就404。这其实是Vue Router的history模式导致的,单页应用的路由切换是前端在内存里完成的,但刷新时浏览器会向服务器请求这个路径,服务器找不到就返回404。解决办法是在Nginx配置里加入try_files $uri $uri/ /index.html;,让所有不存在的路径都指向index.html,由前端路由接管。
数据库时间格式返回给前端时多了个T或者变成了时间戳。这是Java时间类型和JSON序列化之间没协调好的典型表现。最简单的处理是给日期格式加统一配置:
@Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder -> builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss"); }或者在application.yml里配置spring.jackson.date-format=yyyy-MM-dd HH:mm:ss和spring.jackson.time-zone=GMT+8。配置好了之后,前端拿到的时间字符串就是"2025-06-01 14:30:00"这种规范格式,不用在前端再写一堆格式化函数。
模拟支付环节对不上库存。一次性下单了10张票,但支付时发现库存只扣了5张。这个问题排查后定位在库存扣减逻辑放在了创建订单环节而不是支付环节,用户在待支付状态下取消订单,还得手工去恢复库存,一不小心就漏了。我的建议是把这个流程改成两段式:创建订单时先锁定库存(从可用库存转为锁定库存),支付成功后真正扣减库存,取消订单时直接释放锁定库存。这虽然多了一个字段维护,但逻辑上严谨很多,也符合真实电商的做法。
写在最后
这个项目做下来,我最深的体会是:一个能跑通的完整项目,远比几十个零散的技术demo有价值。你在实现景点搜索、订单事务、前后端联调的过程中,实际上是把SpringBoot、MyBatis、Vue、MySQL这些技术真正拧成一股绳来用,很多知识点只有放在完整的业务链路里,才能理解它为什么要这样设计。比如事务注解为什么要加rollbackFor,路由守卫为什么不能替代后端鉴权,图片上传为什么要配静态资源映射,这些东西在单个demo里根本不会暴露出来。
如果你准备拿这套项目做毕设或者面试项目,我的建议是先把它完整跑通,然后挑一个模块深度优化。比如给订单模块加一个定时任务,自动关闭超过30分钟未支付的订单;或者把图片存储换成本地文件存储之外的对象存储方案;再或者给首页加一个简单的推荐算法,根据用户历史收藏推荐相似景点。每多走一步,你对这套项目的理解就深一层,面试聊起来也更有底气。最后再分享一个小技巧:做这种全栈项目,一定要养成边开发边写接口文档的习惯,不需要写得很正规,一张Excel表把接口地址、入参出参、状态码列清楚就好。等前端联调或者后期维护时,你会回来感谢自己的。