
每年一到开题季“基于SpringBoot和uni-app的旅游App”这个组合就会出现在一堆毕设选题清单上。说实话这个选题能经久不衰是有道理的旅游业务场景足够丰富能从用户端一路做到管理端涉及登录、搜索、预订、支付、评价这些典型模块用来展示完整开发能力再合适不过。而SpringBoot加uni-app这个技术组合恰好把后端接口开发和跨端移动应用开发串在了一条线上既有深度又有广度。这篇文章我不打算写成那种“从入门到放弃”的教科书而是把我实际做过、也带学生做过的完整路径直接捋一遍。你如果正在准备毕业设计或者想独立做一个能跑通的旅游类App项目完全可以照着这个思路落地。老规矩先把框架搭对再聊细节最后把踩过的坑一个个列出来都是我真实的调试记录不是誊抄文档。1. 项目拆解旅游App到底在做什么1.1 功能清单先列清楚闭着眼睛拍脑袋写代码写到一半必乱。先做功能拆分这一步省下来的时间远超你的想象。旅游App的核心用户是游客你要解决的无非是几个问题去哪玩、怎么去、住哪里、怎么买票。所以最基础的功能模块可以拆成这几块用户模块注册、登录、个人资料、收藏、历史浏览景点模块景点列表、详情、搜索、分类筛选、评分评论旅游线路模块线路展示、行程详情、线路预订酒店模块酒店列表、房型展示、预订订单模块下单、模拟支付、取消订单、订单状态查询管理端景点、线路、酒店的维护订单管理用户管理这里有个很重要的经验毕设项目别贪多。你翻网上的参考论文经常看到“系统管理、内容管理、数据统计、智能推荐”写得满满当当但真正落地做起来全是工作量。我强烈建议只把核心业务链路做扎实——用户能登录、能浏览、能下单、能看订单这一条链路走通了答辩时讲出来就是一套完整系统。额外的功能可以放在“扩展模块”里简单实现比如做个按分类的热门推荐别一上来就堆Redis、消息队列那是自己给自己挖坑。1.2 为什么是SpringBoot加uni-app这个组合能被大家反复选一定有它的道理。后端用SpringBoot是因为它把Spring生态里最烦人的配置都自动处理掉了你只需要关注业务代码。旅游项目用到的无非是Web接口、数据访问、文件上传这些能力SpringBoot的起步依赖几分钟就能把工程跑起来对赶时间的毕业生非常友好。前端用uni-app的理由更直接一套代码可以同时编译成微信小程序、H5和Android、iOS的App。对于毕业设计来说你不需要真的有苹果开发者账号去上架App Store只需要在HBuilderX里打包一个安卓APK或者直接在浏览器里跑H5版本演示效果一样能打。多端适配这一个点到答辩时还是一个很自然的加分项。有人会问为什么不直接用原生开发或者用Flutter我的回答是这是毕业设计不是商业项目。你要在有限时间内证明自己掌握了一套完整的开发链路。SpringBoot加uni-app恰好覆盖Java后端和前端跨端生态最稳妥的两块参考资料多、报错好搜这两个优势在赶工时比什么都值钱。你去看近两年的毕设选题库这个组合的热度一直没下来过不是没有原因的。1.3 技术栈与版本搭配技术栈版本别乱选我踩过版本坑。SpringBoot 3.x现在比较成熟但如果你照着一堆旧教程写代码很多写法根本不兼容。我建议一个稳妥的搭配组件推荐版本说明JDK1.8 或 17SpringBoot 2.x用JDK83.x必须JDK17SpringBoot2.7.18 或 3.1.x2.7老教程多3.x新特性多MyBatis-Plus3.5.x选匹配SpringBoot的starter版本MySQL5.7 或 8.08.0注意驱动名称变化HBuilderX最新稳定版uni-app官方IDEuni-appvue2或vue3版本新手建议vue2语法教程更丰富我的个人建议是如果跟着教程走选SpringBoot 2.7加MyBatis-Plus 3.5这套组合资料最全、踩坑成本最低。如果你想要点差异化感觉用SpringBoot 3.x加JDK17也行但遇到问题搜到的解决方案可能不完全匹配要做心理准备。版本问题看似小事实际会影响你整个开发周期这里多说一句都不亏。2. 后端SpringBoot落地细节2.1 工程结构不要瞎分层很多同学一上来就建一堆目录最后自己都找不到类在哪。后端工程我推荐一个干净清晰的结构com.example.travel ├── controller // 接口层 │ ├── UserController │ ├── ScenicController │ └── OrderController ├── service // 业务逻辑层 │ └── impl ├── mapper // 数据访问层接口 ├── entity // 数据库实体 ├── dto // 请求响应对象 ├── config // 配置类跨域、拦截器 ├── utils // 工具类JWT、返回结果 └── common // 公共类统一返回、异常处理这就是经典的三层架构加了一些约定。controller只管接收参数和返回结果service写业务规则mapper管数据库操作。记住一句话controller要薄service要厚。别把业务代码写在controller里答辩时老师问你“业务逻辑在哪层”你要能脱口而出。项目结构本身就是论文里要画架构图的内容提前把它理清了后面写论文也省力。pom.xml里的核心依赖就这么几个别多引用不到的包dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version4.4.0/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /dependencyJWT用java-jwt这个库比nimbus那套简单直观适合毕设。如果你不想引JWT用SpringBoot自带的Session也能做登录但移动端App每次请求都靠Cookie维持会话比较别扭。JWT在前后端分离场景下更符合实际习惯也能让你在答辩时解释清楚“为什么不用Session”。2.2 用户登录JWT令牌怎么签登录是每个项目的标配但越简单的功能越能拉开差距。我见过太多学生把密码用明文存数据库这是答辩现场最容易翻车的地方。正确做法是密码加盐哈希用jbcrypt这个轻量库或者SpringSecurity里的BCryptPasswordEncoder千万别用MD5裸跑。我的密码存储方案是这样// 注册时对密码加密 String rawPassword user.getPassword(); String encoded BCrypt.hashpw(rawPassword, BCrypt.gensalt()); user.setPassword(encoded); // 登录时校验 if (!BCrypt.checkpw(rawPassword, user.getPassword())) { return Result.error(用户名或密码错误); }登录成功后的核心是发JWT。JWT分三段Header、Payload、Signature你可以简单理解成一张带签名和有效期的通行证。签发和校验的代码public class JwtUtil { private static final String SECRET your-secret-key-change-me; private static final long EXPIRE 7 * 24 * 3600 * 1000L; // 7天 public static String createToken(Long userId, String username) { Algorithm algorithm Algorithm.HMAC256(SECRET); return JWT.create() .withClaim(userId, userId) .withClaim(username, username) .withExpiresAt(new Date(System.currentTimeMillis() EXPIRE)) .sign(algorithm); } public static Long parseToken(String token) { DecodedJWT jwt JWT.require(Algorithm.HMAC256(SECRET)) .build() .verify(token); return jwt.getClaim(userId).asLong(); } }签发之后前端每次请求带上Authorization: Bearer xxx头后端用一个拦截器统一校验。这里有个坑是拦截器里必须放行登录、注册、景点列表这些不需要登录的接口不然前端连登录页都调不通。我习惯在WebMvcConfigurer里往excludePathPatterns加放行路径比如/api/user/login、/api/user/register、/api/scenic/**。2.3 核心业务接口景点、线路、订单景点模块本质是标准CRUD但要用MyBatis-Plus写出简洁代码。实体字段名用驼峰数据库字段用下划线MyBatis-Plus里确认开启驼峰映射很多同学没注意这个配置导致字段查不出来。在application.yml里加一行mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: id-type: auto分页查询用MyBatis-Plus的Page对象前端传页码和每页条数后端返回带总数的分页结果。搜索功能也别想复杂一个like查询足够满足毕设需求Override public PageScenic searchScenic(int page, int size, String keyword, Long categoryId) { LambdaQueryWrapperScenic wrapper new LambdaQueryWrapper(); if (StringUtils.hasText(keyword)) { wrapper.like(Scenic::getName, keyword) .or().like(Scenic::getArea, keyword); } if (categoryId ! null) { wrapper.eq(Scenic::getCategoryId, categoryId); } return this.page(new Page(page, size), wrapper); }订单模块是业务核心一定要体现“流程”意识。下单时先检查库存或余票然后生成订单号可以用时间戳加随机数初始状态置为待支付。这里我不建议引入真正的支付SDK但可以做一个“模拟支付”接口点击支付后把订单状态改成已支付。答辩时你要能说清楚真实项目里这里会接入微信或支付宝支付毕设里用模拟支付替代业务流程是完整的。这句话一说老师马上就知道你不只是会调接口。2.4 文件上传图片轮播图怎么实现旅游类App最不缺的就是图片景点封面、轮播图、酒店房间图都要上传和回显。SpringBoot接收文件上传很简单用MultipartFile就行PostMapping(/api/upload/image) public Result uploadImage(RequestParam(file) MultipartFile file) throws IOException { String originalName file.getOriginalFilename(); String ext originalName.substring(originalName.lastIndexOf(.)); String fileName UUID.randomUUID().toString().replace(-, ) ext; String datePath new SimpleDateFormat(yyyyMMdd).format(new Date()); String savePath uploadDir / datePath / fileName; File dest new File(savePath); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } file.transferTo(dest); String url /upload/ datePath / fileName; return Result.success(url); }这里有几个细节我反复跟学生强调第一文件名一定要用UUID重命名不然你传两张photo.jpg就互相覆盖第二按日期建目录存放方便后期清理和排查第三图片访问要配置静态资源映射把/upload/**映射到本地磁盘目录不然前端拿到URL根本打不开。配置类这样写Configuration public class WebConfig implements WebMvcConfigurer { Value(${file.upload-path}) private String uploadPath; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath /); } }上传路径别写死配置在application.yml里用Value注入。部署到服务器时只需要改一行配置路径不用改代码。这个“配置外置”的思路放到答辩里也是一个不错的谈资。3. uni-app前端从零搭起3.1 页面结构与tabBar配置uni-app的页面在pages.json里统一注册。旅游App底部的tabBar一般是首页、景点、订单、我的四个入口。pages.json对应配置{ pages: [ { path: pages/index/index, style: { navigationBarTitleText: 首页 } }, { path: pages/scenic/list, style: { navigationBarTitleText: 景点 } }, { path: pages/order/list, style: { navigationBarTitleText: 订单 } }, { path: pages/mine/mine, style: { navigationBarTitleText: 我的 } } ], tabBar: { color: #999999, selectedColor: #3F7EF7, list: [ { pagePath: pages/index/index, text: 首页 }, { pagePath: pages/scenic/list, text: 景点 }, { pagePath: pages/order/list, text: 订单 }, { pagePath: pages/mine/mine, text: 我的 } ] } }页面文件的生命周期和Vue很像但要注意uni-app里跳转用uni.navigateTo返回用uni.navigateBack这些API是跨端的在H5和小程序里都能跑。很多第一次接触的同学会习惯性写this.$router.push那是Vue Router的写法在uni-app里并不通用。这个区别越早意识到你少走的弯路越多。3.2 请求拦截器与登录态注入前端最重要的一层封装是请求。我在utils目录下写了一个request.js用Promise包装uni.request统一处理baseURL、token和错误提示const BASE_URL http://192.168.1.100:8080/api; export function request(options) { return new Promise((resolve, reject) { uni.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data || {}, header: { Authorization: uni.getStorageSync(token), Content-Type: application/json }, success: (res) { if (res.data.code 200) { resolve(res.data.data); } else if (res.data.code 401) { uni.removeStorageSync(token); uni.navigateTo({ url: /pages/login/login }); reject(res.data); } else { uni.showToast({ title: res.data.msg, icon: none }); reject(res.data); } }, fail: (err) { uni.showToast({ title: 网络异常, icon: none }); reject(err); } }); }); }BASE_URL千万不要写localhost因为你在真机或者HBuilderX内置浏览器调试时localhost指向的是你手机自己。H5端用http://localhost:8080没问题但打包到安卓真机就要改成电脑所在局域网的IP。这个坑几乎人人都会踩一次我就在后面的排查章节专门展开。登录页拿到token后用uni.setStorageSync(token, token)存起来之后所有请求自动带上。登出时清掉token并跳转登录页。这套登录态管理逻辑虽然简单但是完整的“前端会话控制”比只在单个页面里写判断要专业得多。3.3 首页轮播与地图组件的实战细节首页是门面旅游App一般做成顶部搜索框、轮播图、分类导航、推荐景点列表。轮播图用swiper组件加v-for循环图片数组就行布局用flex加上百分比宽度。底部推荐列表用scroll-view配合触底事件做上拉加载这个交互在移动端很常见实现了以后看起来像模像样。地图功能是旅游类App的加分项。uni-app里可以用map组件展示景点位置在景区详情页嵌入地图定位。给景点表加longitude和latitude字段前端拿到坐标后这样渲染map :latitudescenic.latitude :longitudescenic.longitude :markersmarkers stylewidth: 100%; height: 300px;/mapmarkers的数据格式是[{ id: 1, latitude: 31.23, longitude: 121.47, title: 景区名 }]在data里定义即可。注意真机调试时map组件需要在manifest.json里配置定位权限否则部分机型会白屏。这个问题藏得很深我第一次就被坑了半下午。3.4 多端打包与HBuilderX调试uni-app的发布入口在HBuilderX的“发行”菜单里可以打包成H5、微信小程序和安卓App。打包安卓App前先在manifest.json里配置图标和启动页否则会提示配置缺失。H5打包出来是一堆静态文件扔到nginx里就能访问。微信小程序打包需要注册小程序账号拿AppID如果只是毕设演示用测试号也能跑通。一个经验之谈演示时最稳的方式是跑H5版本用电脑浏览器打开大屏幕展示效果更好。安卓APK打包涉及证书签名虽然HBuilderX有默认云打包但第一次操作可能卡在证书制作上。我建议提前两天就把APK打出来别等到答辩前一天晚上才发现签名文件没准备好这种事每年都有发生。4. 数据库设计与订单状态流转4.1 核心表结构怎么定数据库设计是毕业论文的重要章节也是答辩老师喜欢深挖的地方。旅游App的核心表我建议至少六张user用户表字段包括id、username、password加密后、nickname、avatar、phone、create_timescenic景点表id、name、area、category_id、description、cover、images、price、longitude、latitude、statushotel酒店表id、name、address、star_level、price、cover、descriptionroute旅游线路表id、name、days、price、detail、coverorder订单表id、order_no、user_id、product_type、product_id、quantity、amount、status、create_time、pay_timecollection收藏表id、user_id、product_type、product_id、create_time用product_type加product_id的组合是为了让订单表能同时支持景点票、酒店、线路三种商品。你可以在答辩时说这种“多态关联”设计避免了为每种商品各建一套订单体系是一种可扩展的方案。不过也要诚实说明它的小问题——没法用外键约束需要在service层保证数据准确性。这个权衡说出来反而显得你认真考虑过真实架构。4.2 订单状态机从待支付到已完成订单状态用整数表示最方便0待支付、1已支付、2已完成、3已取消。每一笔订单都要有清晰的流转规则下单 - 0待支付模拟支付 - 1已支付用户确认或系统自动确认 - 2已完成未支付取消或支付后申请取消 - 3已取消在service层状态变更必须加判断条件。比如支付接口只允许把状态为0的订单改为1不能用update无条件覆盖。MyBatis-Plus里这样写LambdaUpdateWrapperOrder wrapper new LambdaUpdateWrapper(); wrapper.eq(Order::getId, orderId) .eq(Order::getStatus, 0) // 乐观锁的思想防止重复支付 .set(Order::getStatus, 1) .set(Order::getPayTime, new Date()); orderMapper.update(null, wrapper);eq条件放在where后面update返回受影响行数。如果返回0说明订单不存在或已经不是待支付状态后端就返回“订单状态异常”。这也是一个微小的并发安全意识答辩时提一句能加不少印象分。4.3 搜索和推荐的做法搜索用SQL的like已经够用但想让体验好一点可以做成联合搜索景点名称、地区、简介一起匹配。推荐功能别指望做协同过滤毕设里给一个“猜你喜欢”模块按当前用户收藏最多的分类推荐同分类的热门景点逻辑简单但效果直接public ListScenic recommendForUser(Long userId, int limit) { // 1. 查出用户收藏最多的分类 // 2. 查该分类下评分最高且用户未收藏的limit个景点 // 3. 如果用户没有收藏记录回退到热门景点 }很多同学从网上抄一个基于物品的协同过滤算法代码结果自己讲不清楚原理答辩反而减分。用真实业务逻辑做一个能解释清楚的小推荐比堆一个跑不明白的算法强多了。这个取舍你自己算一下哪个划算。5. 常见问题与排查实录5.1 跨域问题前端调不通后端接口这是SpringBoot前后端分离项目里出现频率最高的错误。浏览器控制台报CORS policy或者Access-Control-Allow-Origin的错就是跨域被拦了。解决办法是在后端加一个跨域配置Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.setAllowedOriginPatterns(Collections.singletonList(*)); config.setAllowedMethods(Collections.singletonList(*)); config.setAllowedHeaders(Collections.singletonList(*)); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }注意后端配置了跨域之后前端不要自己加Access-Control-Allow-Origin请求头那是服务器端才能设置的字段。前后端各加一遍反而容易出奇葩问题。遇到跨域报错优先检查后端是不是真的加了配置以及前端有没有重复设置。5.2 token过期最省事的处理方案JWT一旦签发在过期前很难主动作废除非你把token存到Redis里做黑名单。毕设项目有个简单做法把token有效期設长一点比如7天前端在请求拦截器里判断到401就清token跳登录页。这样普通使用场景下用户几乎遇不到过期问题。如果你想要一点高级感可以讲双token方案登录时同时发放accessToken和refreshTokenaccessToken过期后用refreshToken去换取新的。但这对毕设来说过于复杂我一般不建议做除非你有余力并且能在答辩里把“为什么要refreshToken”讲清楚。为了一个加分项拖慢整个项目进度不划算。5.3 真机和模拟器连不上后端接口现象很典型浏览器里一切正常换到安卓真机上一刷新就“网络异常”。原因基本就是BASE_URL写死了localhost或127.0.0.1。真机要访问你电脑上的SpringBoot服务必须用电脑在局域网里的IP比如http://192.168.1.100:8080。查IP的方法很简单Windows在cmd里敲ipconfigMac在终端里敲ifconfig找到无线局域网那段的IPv4地址。还有两个隐藏条件手机和电脑必须在同一个WiFi下且电脑的防火墙要把8080端口放行否则真机还是连不上。Windows防火墙里添加入站规则允许TCP 8080端口。校园网或公司网络经常有二次认证把同一路由下的设备隔离开这种情况就直接用H5演示别在真机上死磕。5.4 问题速查表问题原因解决办法接口返回404路径拼写不一致检查Controller的RequestMapping和前端URL注意有没有/api前缀数据库连不上连接配置错或服务没启动核对url、用户名、密码确认mysql服务已启动中文乱码编码不一致后端连接串末尾加characterEncodingUTF-8前端header指定编码图片上传后不显示静态资源映射没配配置addResourceLocations映射到磁盘目录token校验失败密钥不一致或已过期检查SECRET是否一致重新登录获取新tokenuni-app编译报错依赖或插件版本问题清理uni_modules目录重新下载看控制台具体报错行这张表是我根据自己动手踩坑和带学生时的高频问题整理的基本覆盖了90%的突发情况。把这张表贴在你的开发笔记里至少省出三天排查时间。6. 部署与答辩前的最后冲刺6.1 后端打包与前端部署SpringBoot打包很成熟Maven里执行mvn clean package -DskipTests生成一个jar包运行java -jar travel.jar就行。如果要换端口启动命令后面加--server.port9090。部署到服务器时MySQL地址要改成服务器地址初始化SQL先执行一遍。前端H5部署最简单在HBuilderX里点“发行-H5”生成unpackage/dist/build/h5目录把静态文件扔到nginx的html目录即可。如果不想折腾服务器演示时直接用HBuilderX内置浏览器看效果完全没问题。很多同学的最终成品就是本地演示这不丢人答辩看的是你是否真正实现了功能而不是看你用了多贵的服务器。6.2 答辩演示脚本和思路代码写得不错但演示手忙脚乱的例子我见过太多。提前准备一段五分钟的演示脚本非常有必要。我的建议是走一条完整链路注册新账号、搜索景点、查看详情、收藏、下单、模拟支付、在订单里看到已支付、取消订单。这条链路跑下来登录、搜索、详情、收藏、订单、支付全流程都串起来了也比零散点菜单有说服力得多。答辩时老师常问的几个问题我提前给你整理好思路为什么用JWT而不是Session答前后端分离场景下App端无法靠Cookie维护会话JWT无状态、可跨域适合移动端接口鉴权。订单并发问题怎么处理答更新订单状态时加了状态条件防止重复支付真实场景可引入分布式锁或Redis原子操作。项目有什么不足答支付用的是模拟接口真实场景需对接微信支付推荐算法比较简单后续可引入更复杂的模型。这几个问题答得从容哪怕功能有小瑕疵整体分也不会低。做完这个项目我最深的体会是先跑通再优化。很多同学一开始就惦记着上Redis、上搜索引擎结果连最基本的登录都没跑通。SpringBoot和uni-app这套组合最大的价值就是能让你快速形成一个完整闭环——从注册走到下单这个闭环带来的正反馈比任何技术花活都重要。后面还有精力再去加缓存、加地图、加推荐一步步把项目打磨成答辩时能讲两小时的成品。最后再分享一个小技巧把每天改动的关键点记在一个叫dev-notes.md的文档里哪怕只写三行字。答辩前翻一遍你会发现很多细节其实你已经解决了只是当时没记录后来忘了。这个习惯也是我从做这个项目起一直保持到现在的。祝你的毕设答辩顺利一次通过。