一到毕业季,“Java springboot基于Android的旅游攻略系统”这类题目几乎是计算机专业选题里的常青树。我之前帮不少同学看过这套类似的代码,也踩过不少坑,发现一个很普遍的现象:代码能跑、App能打开、录个演示视频没问题,但只要答辩老师多问一句“数据库表是怎么设计的”“接口返回结构是什么”“为什么模拟器能联网、真机却连不上”,很多人就开始支支吾吾。
这篇文章不打算复读某个开源仓库里的README,而是把“Spring Boot 3.x + Android 原生”这套旅游攻略系统当成一个真正的前后端分离项目来拆。从选题定位、数据库设计、后端接口实现,到Android端请求封装、图片上传、真机联调,再到毕设答辩前最该检查的那几个坑,我会把涉及到的关键代码和判断逻辑全部写出来。不管你是准备拿现成源码改成自己的毕设,还是从零开始自己写一个,按这套思路走完,你对整个系统的理解会明显不一样。
1. 项目整体设计与技术选型思路
1.1 选题定位:毕设场景下如何把“旅游攻略”做出区分度
旅游攻略系统这个方向,本质上是一个带内容管理属性的信息展示类项目。用户注册登录后可以浏览景点、查看攻略、收藏内容,管理员在后台维护景点和攻略数据。功能点看着很常规,但它非常适合用来展示一个学生完整的工程能力,原因有三个:
第一,业务链路完整。登录、列表、详情、发布、收藏、评论,每个环节都有对应的HTTP请求和数据库操作,能覆盖Java Web开发的主要知识点。
第二,前后端天然分离。服务端用Spring Boot提供RESTful接口,Android端通过网络请求消费这些接口,这就比单纯搞一个Thymeleaf或JSP的SSM项目更有说头,也更能体现“接口设计能力”。
第三,可扩展空间大。答辩时如果老师问“你的项目有什么改进方向”,你可以说加路线规划、加LBS定位推荐、加Redis缓存热门景点、加ElasticSearch搜索攻略,这些都属于安全且常见的扩展点。
不过也要提醒一句,正因为这个题目太多人做,想把分数拉上去,关键不是堆功能,而是把基础模块做扎实。我见过不少人的项目里景点表和攻略表就两三个字段,接口直接返回整个List不分页,Android端也没有统一的网络层封装。这种代码即使能跑,答辩时老师拿眼睛扫一眼项目结构,也知道你只是把别人代码改了个名字。所以下面讲的很多细节,其实都是围绕“让这个普通的选题看起来像正经工程项目”来展开的。
1.2 技术栈选择:为什么是Spring Boot + Android原生
后端选择Spring Boot,基本不需要纠结。Spring Boot的自动配置和起步依赖能极大减少搭建成本,内嵌Tomcat让部署演示变得简单,而这些正好命中毕业设计最在意的“快速跑通”。但要注意版本选择,我强烈建议不要一上来就选Spring Boot 3.x,而是优先考虑2.7.x。很多网上的项目资料、教程、视频都停留在2.x时代,如果用3.x,原本的javax.servlet包会变成jakarta.servlet,部分第三方starter也会出现兼容问题。除非你对依赖冲突有足够的排查经验,否则老老实实选2.7.18这类稳定版本,能帮你省下大量折腾时间。
Android端选原生开发而不是Uniapp或Flutter,核心原因是这门课通常教的就是Java或Kotlin原生开发。使用RecyclerView、CardView、SharedPreferences这些系统组件写出来的项目,在答辩演示时更符合课程要求。更重要的是,很多高校评委对跨平台框架并不熟悉,你解释不清楚反而容易减分。原生Android配上Retrofit + OkHttp + Glide这套组合,既成熟又够用,也是搜索相关教程时最容易找到答案的技术栈。
1.3 功能模块划分:前后端各需要做哪些事
一个能够正常演示的旅游攻略系统,从用户视角出发至少需要六个核心功能:注册登录、景点列表、景点详情、攻略列表与发布、收藏、个人中心。服务端对应的是用户表、景点表、攻略表、收藏表、评论表这几张核心表的数据读写。Android端则负责页面展示、用户交互、Token保存与图片上传。
从开发顺序上,我建议先完成后端接口,再用Postman或Apifox把每个接口调试通,然后才动手写Android端的页面。原因是,如果你先把页面画好了再联调接口,一旦后端字段名和前端对应不上,改起来就非常痛苦。作为过来人,这个顺序我踩过很多次坑,先定契约、再填页面,是效率最高的方式。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 服务端核心模块实现:从建表到接口一次讲透
2.1 数据库设计:五张表的字段与关系
数据库设计是答辩时老师最常追问的部分。这套系统的核心关系不复杂,设计上遵循两个原则:能拆的表尽量拆开,避免一张大表装所有内容;关联字段尽量使用逻辑外键,不一定要在数据库层面建物理外键,但Java实体里要能体现关系。
用户表、景点表、攻略表各是一个独立实体。收藏表和评论表都依赖于用户与其他表之间的关联。这里把最关键的几张表列出来供参考:
| 表名 | 关键字段 | 说明 |
|---|---|---|
| t_user | id, username, password, nickname, avatar | 用户信息,密码用MD5或BCrypt存储 |
| t_scenic | id, name, cover, location, price, description, detail | 景点基础信息,图片存URL路径 |
| t_guide | id, user_id, scenic_id, title, content, cover, create_time | 攻略内容,与用户和景点关联 |
| t_favorite | id, user_id, scenic_id, create_time | 收藏关系表,联合唯一索引 |
| t_comment | id, user_id, guide_id, content, create_time | 评论表,按攻略维度组织 |
这里有一个容易忽略的设计点:攻略表的scenic_id要不要设置必填?很多真实场景下,用户发攻略未必绑定具体景点,但毕业设计为了展示关联查询,建议必填。这样在Android端“景点详情”页面里可以直接展示“该景点相关的攻略”,答辩时也可以主动说“我做了多表关联查询”,这是一个加分点。
建表SQL直接使用Navicat执行即可,注意utf8mb4字符集。比如景点表可以写成:
sql复制CREATE TABLE `t_scenic` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL COMMENT '景点名称',
`cover` varchar(255) DEFAULT NULL COMMENT '封面图地址',
`location` varchar(255) DEFAULT NULL COMMENT '所在位置',
`price` decimal(10,2) DEFAULT '0.00' COMMENT '门票价格',
`description` varchar(500) DEFAULT NULL COMMENT '简介',
`detail` text COMMENT '详细介绍',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='景点表';
在写SQL时有两处细节容易踩坑。一处是表名不要直接叫user,因为user在某些版本MySQL里是保留字,虽然加反引号也能用,但为了避免后续MyBatis生成SQL时报奇怪的语法错误,统一加个t_前缀更省心。另一处是价格字段建议用decimal而不是float,景点门票精确到分,用float会出现0.1+0.2不等于0.3这类问题,答辩现场被问到会很尴尬。
2.2 工程结构分层与统一返回结构
后端工程结构建议按“entity / mapper / service / controller / common”分包。很多同学喜欢把业务代码全部塞Controller里,上百行代码堆在一起,虽然功能能实现,但是项目结构非常扣分。按标准分层写其实并不复杂,Controller只做参数接收和结果包装,真正业务逻辑放在Service层,数据访问统一走Mapper。
为了让Android端解析响应时更方便,后端最好定义一个统一的Result返回体。这个类建议放在common包下面,结构如下:
java复制@Data
public class Result<T> {
private Integer code;
private String msg;
private T data;
public static <T> Result<T> ok(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMsg("success");
result.setData(data);
return result;
}
public static <T> Result<T> fail(String msg) {
Result<T> result = new Result<>();
result.setCode(500);
result.setMsg(msg);
return result;
}
}
为什么要做这一层包装,而不是直接把实体对象返回给前端?因为除了正常数据之外,你还需要告诉前端“这次请求到底成没成功”。比如Android端要根据code来判断是否弹出Toast,遇到token过期时要跳回登录页。如果把code和msg跟业务数据混在一起返回,前端解析起来就会非常难受。这一套统一返回结构,也是很多企业级项目的标准做法,放在简历项目里能算一个亮点。
2.3 景点列表接口的分页与关键字搜索
景点列表是所有列表页的基础,这里以它为例完整走一遍后端实现。查询条件一般包括:页码pageNum、每页数量pageSize、关键字keyword(用于模糊搜索景点名称)。如果是个人项目,手动用PageHelper也可以,但MyBatis-Plus的IPage用起来更简洁。
Service层代码大致如下:
java复制public IPage<Scenic> pageQuery(Integer pageNum, Integer pageSize, String keyword) {
Page<Scenic> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<Scenic> wrapper = new LambdaQueryWrapper<>();
if (StringUtils.hasText(keyword)) {
wrapper.like(Scenic::getName, keyword).or().like(Scenic::getLocation, keyword);
}
wrapper.orderByDesc(Scenic::getId);
return scenicMapper.selectPage(page, wrapper);
}
Controller层只需要接收参数并返回Result:
java复制@GetMapping("/page")
public Result<IPage<Scenic>> page(
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(required = false) String keyword) {
return Result.ok(scenicService.pageQuery(pageNum, pageSize, keyword));
}
接口路径建议统一加/api前缀,例如/api/scenic/page,这样在nginx或后端配置上容易区分动态请求。分页响应里会包含total、records、current、pages这些字段,Android端拿到后可以计算“总页数”来控制首页底部是否继续加载,这种方式比一次性把几百条数据全部返回要专业得多。
2.4 登录注册与Token方案
登录接口是几乎所有功能的前提。用户提交username和password后,后端先去t_user表查询用户名,再用MD5或BCrypt校验密码。个人项目用MD5加盐已经够用,但如果想显得专业一点,可以引入Spring Security的BCryptPasswordEncoder,不过这样会引入较多安全配置,对基础薄弱的同学来说反而增加负担。我通常的做法是使用一个简单的MD5工具类做密码散列,然后在答辩时说清楚“密码不采用明文存储,而是经过MD5散列”,这个表达已经足够过关。
用户登录成功后,需要把当前用户身份传给后续请求。不是每个接口都重新查一遍用户表,而是采用Token方案。最简单的做法是登录成功后用UUID生成一个随机字符串,存入Redis并设置过期时间,然后把Token返回给Android端。但如果项目没引入Redis,退而求其次可以把用户ID和时间戳加密后返回。最方便落地的方案是整合JWT,下面这段代码在Java 8 + Spring Boot 2.7环境里可以直接使用:
java复制// 生成Token
String token = Jwts.builder()
.setSubject(String.valueOf(user.getId()))
.setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 3600 * 1000))
.signWith(SignatureAlgorithm.HS256, "your-secret-key")
.compact();
Android端拿到Token后,存在SharedPreferences里,之后每次请求都在Header中携带Authorization字段。后端通过一个拦截器统一解析Token,并利用ThreadLocal保存当前登录用户。实现拦截器不复杂,但要注意放行登录、注册、景点列表这些无需登录的接口,否则会把自己绕进去。
3. Android端实操:从首页加载到发布攻略
3.1 基础工程搭建与依赖配置
Android端我推荐使用Java而不是Kotlin。不是说Kotlin不好,而是毕设项目里大多数参考代码、视频讲解都是Java写的,遇到问题搜起来更容易。网络请求框架优先选Retrofit2,图片加载用Glide,JSON解析用Gson。模块结构建议分成这样几层:
- entity:对应后端返回的JavaBean,例如Scenic、Guide、User
- api:定义Retrofit接口
- ui:存放Activity/Fragment和Adapter
- utils:SharedPreferences工具类等
在app/build.gradle中需要添加依赖:
gradle复制implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.github.bumptech.glide:glide:4.15.1'
implementation 'androidx.recyclerview:recyclerview:1.3.0'
implementation 'androidx.cardview:cardview:1.0.0'
这里有一个真实高频问题:Android 9(API 28)之后系统默认禁止明文HTTP请求。如果你的后端地址是http://192.168.x.x:8080,不是https,那么直接请求会报“Cleartext HTTP traffic not permitted”。解决办法有两个,二选一即可:
一是在AndroidManifest.xml的application节点里加:
xml复制android:usesCleartextTraffic="true"
二是更规范的做法,创建network_security_config.xml只允许特定域名走明文。但为了省事,毕设项目直接加usesCleartextTraffic就行。如果加了这个还连不上,就要看第二类问题:模拟器访问宿主机要用10.0.2.2而不是localhost。
3.2 封装Retrofit请求层
很多新手写的网络请求是每个Activity里都new一个Retrofit对象,代码重复量很大。正确做法是封装一个ApiClient单例,把baseUrl和OkHttpClient统一配置好。代码参考如下:
java复制public class ApiClient {
private static final String BASE_URL = "http://10.0.2.2:8080/";
private static Retrofit retrofit;
public static Retrofit getClient() {
if (retrofit == null) {
Retrofit.Builder builder = new Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create());
// 可以在这里配置OkHttp拦截器,统一添加Token
retrofit = builder.build();
}
return retrofit;
}
}
接口定义时按后端路径来写。例如获取景点分页数据:
java复制public interface ApiService {
@GET("api/scenic/page")
Call<Result<PageBean<Scenic>>> getScenicPage(
@Query("pageNum") int pageNum,
@Query("pageSize") int pageSize,
@Query("keyword") String keyword
);
}
这些请求都是异步执行的,Retrofit的enqueue回调会回到主线程,本质上是用Callback机制来规避在UI线程里直接访问网络的问题。实际写代码时要注意:不要在回调里直接使用外部Activity的this去更新UI,因为页面可能已经销毁,建议用mVieW.getContext()或弱引用方式持有。虽然毕设项目一般不会出现极端的内存泄漏,但如果你在Fragment中发起请求而Fragment已经销毁时收到回调,还是容易出现闪退。
3.3 首页景点列表:RecyclerView + CardView + Glide
首页通常是“推荐景点”列表,效果实现不算难。先用RecyclerView配上LinearLayoutManager,每个item使用CardView包裹,里面放ImageView和两个TextView。请求成功后,把records集合设置给Adapter,再调用notifyDataSetChanged刷新列表。
Adapter的关键点在于图片加载。不要自己写BitmapFactory去解码网络图片,那个很容易因为图片太大导致OOM。使用Glide一行代码即可:
java复制Glide.with(context)
.load(scenic.getCover())
.placeholder(R.drawable.ic_default)
.into(holder.imageView);
加载更多功能建议使用RecyclerView的addOnScrollListener,在滚动到底部时判断当前页是否小于总页数,是则pageNum加1继续请求。这样做的好处是列表数据量再大也不会一次性加载卡顿,另一方面也向答辩老师展示了分页思想在前端的落地。
3.4 攻略发布与图片上传
攻略发布页面包含标题、内容、景点选择和封面图四个部分。用户从相册选择图片后,需要把图片上传到服务端,服务端保存后返回图片URL,再把这个URL作为攻略封面字段提交。这个过程涉及两个接口,如果混在一起写容易乱,建议拆成两步:第一步选择图片后立即上传,第二部点击发布时提交攻略表单。
相册选择使用系统Intent,在onActivityResult中拿到图片Uri。如果只需要上传到后端,把Uri转成File时要注意:直接使用getPath在Android 10之后已经不可靠,很多机型会得到空指针。建议使用MediaStore查询真实路径,或者直接用ContentResolver打开输入流读取字节。比较简单的方式是把图片复制到应用缓存目录,再拿缓存文件上传:
java复制File file = new File(getCacheDir(), System.currentTimeMillis() + ".jpg");
InputStream is = getContentResolver().openInputStream(uri);
FileOutputStream fos = new FileOutputStream(file);
byte[] buffer = new byte[1024 * 8];
int len;
while ((len = is.read(buffer)) != -1) {
fos.write(buffer, 0, len);
}
fos.flush();
fos.close();
is.close();
上传时使用Retrofit的Multipart注解。在ApiService中定义:
java复制@Multipart
@POST("api/file/upload")
Call<Result<Map<String,String>>> upload(@Part MultipartBody.Part file);
接收图片的后端接口需要配置MultipartFile参数,同时在application.yml里设置上传大小限制:
yaml复制spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 50MB
不配置这个项,默认1MB文件就会报MaxUploadSizeExceededException,这是很多同学上传图片失败的根本原因。
4. 联调演示与高频问题排查实录
4.1 Android模拟器连不上本机后端怎么办
这是联调阶段排第一的问题。后端在电脑上启动,Android模拟器是一个独立的Linux虚拟机,如果用手机浏览器或模拟器浏览器去访问http://localhost:8080,访问的其实是模拟器自己,而不是电脑上的Spring Boot服务。正确地址是10.0.2.2,这是Android模拟器专门为宿主机预留的特殊IP。真机调试则没有这个问题,手机和电脑连同一个WiFi后,后端地址直接填电脑的局域网IP,比如192.168.1.5。
但真机调试另有三个容易踩的坑:
- 后端如果配置了server.address=127.0.0.1,外部设备访问不到,需要去掉或者改成0.0.0.0
- 电脑防火墙会拦截8080端口入站请求,需要在防火墙中放行,或者干脆把“Windows Defender防火墙”对Java的入站规则打开
- 手机和电脑必须处在同一网段,公司WiFi或校园网如果开了AP隔离,照样连不上
定位这个问题的方法是:先用手机浏览器访问http://电脑IP:8080/api/scenic/page?pageNum=1&pageSize=5,如果浏览器能返回JSON但App不行,说明问题在App的明文配置;如果浏览器也不通,问题在防火墙或后端监听地址。
4.2 图片上传后访问不到或显示404
图片上传成功后,后端通常会把文件保存到本地磁盘某个目录,比如项目根目录下的upload文件夹,并把这个路径保存到数据库。但Spring Boot默认不会把磁盘目录映射成静态资源,也就是说虽然文件确实存在,但Android端直接加载http://ip:8080/upload/xxx.jpg会得到404。
解决办法是实现WebMvcConfigurer,添加资源映射:
java复制@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/upload/**")
.addResourceHandler("file:" + System.getProperty("user.dir") + "/upload/");
}
}
这里我给一个经验判断:如果你在浏览器中能打开图片,但在Android端加载不出来,优先检查URL中为什么出现file://或者图片地址是不是相对路径。很多同学把数据库里存的地址写成了upload/xxx.jpg,没有开头的斜杠,加载时就会相对于当前页面去解析,最终指向错误地址。存储时统一存成/upload/20240101/xxx.jpg这种“根路径风格”,能少很多麻烦。
4.3 Spring Boot版本过高引发的兼容问题汇总
如果你是按照最新教程创建项目,很可能拿到的是Spring Boot 3.x。3.x本身没有错,但当你去网上找一个2.x写的攻略项目时,会遇到三类非常典型的问题:
第一,javax与jakarta包名冲突。2.x的代码导入javax.servlet.http.HttpServletRequest,在3.x中已迁移为jakarta.servlet.http.HttpServletRequest。这意味着网上很多现成的拦截器或文件上传代码不能直接复制。
第二,部分第三方starter未适配。比如一些旧版身份证校验工具、国密算法库、代码生成器,底层仍依赖javax,引入后会出现ClassNotFoundException。
第三,Java版本要求变化。Spring Boot 3.x要求JDK 17及以上,很多同学的机器装的是JDK 8,启动时直接报非法版本错误。
如果不是对Spring Boot生态更新非常敏感的人,毕设阶段我还是建议把版本锁在2.7.x。如果项目已经使用3.x,所有网上代码的导入语句需要手动从javax全局替换成jakarta,替换后大部分功能可以恢复。
4.4 评论区与收藏列表出现重复数据
评论表和收藏表是典型的“多对一”关系,当你要查询某条攻略评论时,通常需要把评论人昵称、头像一起返回给前端。如果使用嵌套查询,代码写分离了,就容易出现同一评论多次出现的问题,原因是表连接条件没有去重,或者一对多查询时Mapper结果映射写错。
在MyBatis-Plus中,处理这种场景最好用简单的两步查询,先查评论列表,再根据评论的user_id集合一次性查询用户信息,最后在内存中组装。虽然多了一次查询,但对毕设级别的并发量来讲完全没性能压力,代码逻辑还清晰。如果你在答辩时想显得更有含金量,可以主动说“小数据量场景下,我倾向于避免复杂的多表JOIN,而是用两步查询保证查询结果可控,这也是互联网大厂常见的拆分思路”。这话一出来,效果往往不错。
5. 给项目加分的小技巧与后续扩展方向
5.1 接口文档与演示脚本的准备
答辩时老师经常不按预设路径使用你的系统,所以除了把功能做完,建议做两件事。第一,在项目根目录放一个README.md,写明项目运行环境、数据库初始化脚本位置、测试账号密码。以后不管是答辩老师运行,还是自己隔了一周再回来跑项目,都能省很多事。第二,准备一份5分钟演示脚本。先走游客流程浏览景点和攻略,再登录,然后收藏景点,最后发一条带图片的攻略。按照这个顺序演示,基本能覆盖系统的核心链路。
5.2 热门景点统计与排序说明
这个系统如果要加亮点,最不需要改大结构的方案是“热门景点排序”。在景点表增加一个view_count字段,每次调用景点详情接口时加1,列表接口默认按view_count降序排列。这个改动体量很小,但效果直观。也可以增加一个top接口返回前5条数据,放到Android首页顶部作为Banner或“热门推荐”模块。这个小功能可以用来讨论“如何设计缓存”,答辩时你可以说“如果并发量很大,可以考虑把热门景点数据缓存到Redis,设置5分钟过期时间”,不必真的实现,但能体现思考深度。
5.3 个人实际维护建议
我在改这套项目的时候最大的感受是:旅游攻略系统真正费时间的不是业务代码本身,而是图片资源路径管理。开发阶段上传的图片会在本地磁盘越积越多,建议启动时把上传目录配置成可配置的,例如通过application.yml的custom.upload-path来自定义存储位置。这样后续如果把项目部署到云服务器,可以把图片目录挂载到数据盘,也不会因为项目重新打包导致图片被覆盖。这个经验是我自己跑过三四次项目后总结出来的,不写进去的话,等你换了一台电脑再跑这个项目,数据库里那些图片地址基本全都会失效。
另外,代码里尽量多写日志。不要只在报错时System.out.println,建议使用lombok的@Slf4j并输出关键请求参数和异常堆栈。做演示的时候后端控制台的日志可以帮助你快速定位问题,被老师问到某个请求流程时,你也可以指着日志边说边解释,效果比空口描述好得多。
