☰
SpringBoot+uni-app旅游App毕设实战:架构、鉴权与部署全解析
2026/10/9 3:26:56 网站建设 项目流程

每年一到开题季,“基于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用JDK8,3.x必须JDK17+
SpringBoot2.7.18 或 3.1.x2.7老教程多,3.x新特性多
MyBatis-Plus3.5.x选匹配SpringBoot的starter版本
MySQL5.7 或 8.08.0注意驱动名称变化
HBuilderX最新稳定版uni-app官方IDE
uni-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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>4.4.0</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency>

JWT用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 Page<Scenic> searchScenic(int page, int size, String keyword, Long categoryId) { LambdaQueryWrapper<Scenic> 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 :latitude="scenic.latitude" :longitude="scenic.longitude" :markers="markers" style="width: 100%; height: 300px;"></map>

markers的数据格式是[{ 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_time
  • scenic:景点表,id、name、area、category_id、description、cover、images、price、longitude、latitude、status
  • hotel:酒店表,id、name、address、star_level、price、cover、description
  • route:旅游线路表,id、name、days、price、detail、cover
  • order:订单表,id、order_no、user_id、product_type、product_id、quantity、amount、status、create_time、pay_time
  • collection:收藏表,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里这样写:

LambdaUpdateWrapper<Order> 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 List<Scenic> 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和refreshToken,accessToken过期后用refreshToken去换取新的。但这对毕设来说过于复杂,我一般不建议做,除非你有余力,并且能在答辩里把“为什么要refreshToken”讲清楚。为了一个加分项拖慢整个项目进度,不划算。

5.3 真机和模拟器连不上后端接口

现象很典型:浏览器里一切正常,换到安卓真机上一刷新就“网络异常”。原因基本就是BASE_URL写死了localhost或127.0.0.1。真机要访问你电脑上的SpringBoot服务,必须用电脑在局域网里的IP,比如http://192.168.1.100:8080。查IP的方法很简单,Windows在cmd里敲ipconfig,Mac在终端里敲ifconfig,找到无线局域网那段的IPv4地址。

还有两个隐藏条件:手机和电脑必须在同一个WiFi下,且电脑的防火墙要把8080端口放行,否则真机还是连不上。Windows防火墙里添加入站规则,允许TCP 8080端口。校园网或公司网络经常有二次认证,把同一路由下的设备隔离开,这种情况就直接用H5演示,别在真机上死磕。

5.4 问题速查表

问题原因解决办法
接口返回404路径拼写不一致检查Controller的RequestMapping和前端URL,注意有没有/api前缀
数据库连不上连接配置错或服务没启动核对url、用户名、密码,确认mysql服务已启动
中文乱码编码不一致后端连接串末尾加characterEncoding=UTF-8,前端header指定编码
图片上传后不显示静态资源映射没配配置addResourceLocations映射到磁盘目录
token校验失败密钥不一致或已过期检查SECRET是否一致,重新登录获取新token
uni-app编译报错依赖或插件版本问题清理uni_modules目录重新下载,看控制台具体报错行

这张表是我根据自己动手踩坑和带学生时的高频问题整理的,基本覆盖了90%的突发情况。把这张表贴在你的开发笔记里,至少省出三天排查时间。

6. 部署与答辩前的最后冲刺

6.1 后端打包与前端部署

SpringBoot打包很成熟,Maven里执行mvn clean package -DskipTests,生成一个jar包,运行java -jar travel.jar就行。如果要换端口,启动命令后面加--server.port=9090。部署到服务器时,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的文档里,哪怕只写三行字。答辩前翻一遍,你会发现很多细节其实你已经解决了,只是当时没记录,后来忘了。这个习惯也是我从做这个项目起一直保持到现在的。祝你的毕设答辩顺利,一次通过。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询