花两周时间把一个前后端分离的旅游平台从零跑到部署上线,是什么体验?说实话,这和网上那些只教你某个局部功能的教程完全不同——你面对的是数据库建模、接口设计、Vue页面开发、前后端联调、服务器部署一整条链路,任何一个环节出问题,整个项目就卡住。我做的这个桂林旅游景点导游平台,用的是SpringBoot+Vue+MyBatis+MySQL这套经典组合,前后端完全分离,源码和部署流程都整理好了。如果你正在找毕业设计题目,或者想通过一个完整项目把前后端分离开发整个流程彻底打通,这篇可以当你的参考路线图,里面包含了我实际踩过的坑和调通的细节,不是那种只贴代码不解释的笔记。
1. 为什么拿"桂林旅游景点导游平台"做实战项目——选题逻辑与需求拆解
1.1 旅游类业务为什么适合练前后端分离
做项目练手或毕业设计,最怕两件事:一是业务太抽象,边界不清楚,做着做着不知道该写什么功能;二是业务太复杂,比如电商下单加库存加支付物流,一个人根本扛不住。旅游景点导游平台恰好处在一个非常合适的区间。
它的核心业务就是"展示景点、规划线路、下单预订、用户互动",天然分成游客端和管理端两块。游客端需要的是景点浏览、搜索筛选、查看详情、预订门票、收藏和评论;管理端需要的是维护景点数据、管理订单、审核内容。这种双端结构就是前后端分离开发最常见的业务形态,前端界面偏展示型,后端接口偏数据管理型,两边各自的复杂度都不算高,但加在一起能把整套开发流程完整覆盖。
从技术角度讲,它涵盖的知识点非常典型:列表查询、条件检索、分页、详情展示、表单提交、登录状态管理、文件上传(景点图片)、关联表查询、一对多数据组装。这些技能几乎是所有后端开发岗位的日常,做完这个项目,再去接触电商、外卖、预约类系统,你会发现业务变了,但套路完全一样,基本可以平移。
另外,桂林景区的数据模型有天然的可扩展性,比如漓江、阳朔、龙脊梯田这些景点可以挂多个分类、多条线路、若干图片,这为设计多对多关系和复杂的查询条件提供了真实场景,不用为了演示而硬造需求。
1.2 功能模块与页面流转:先画清楚再写代码
我动手之前先把功能边界列了一张表,避免开发中途加需求导致返工。这里分享我当时整理的核心模块清单:
- 游客端:景点列表(按分类筛选)、关键词搜索、景点详情(图片轮播、介绍、评论)、线路推荐(一日游/两日游)、门票预订下单、个人中心(我的订单、我的收藏)
- 管理端:登录校验、景点管理(增删改查+图片上传)、分类管理、线路管理、订单管理(查看和状态更新)、评论管理
页面流转是这样的:用户进入首页看到轮播图和推荐景点,点击景点进入详情页,可以收藏、评论、选择线路进行预订,下单后进入个人中心查看订单状态;管理员通过独立端口登录,进入后台操作维护数据,前端把操作结果通过API同步到数据库。整个流程走下来,前后端能覆盖到的交互模式基本都有了。
1.3 技术选型的实际取舍:没有微服务,也没有盲目上全家桶
技术选型上,我坚持一个原则:能用简单方案解决的问题,不引入额外复杂度。后端用SpringBoot做基础框架,MyBatis做持久层,MySQL存数据;前端用Vue写页面,配合Vue Router做路由、Axios做请求;管理端和游客端放在同一个前端工程里,用路由和权限去区分,而不是拆成两个独立应用。
没有引入微服务的原因很直接:这个体量的业务,单体应用完全够用,拆微服务只会增加服务注册、配置中心、网关这些和学习目标无关的负担。没用MyBatis-Plus而用原生的MyBatis,是我刻意的选择——原生MyBatis能让你理解Mapper接口、XML映射、动态SQL、结果映射这些底层机制,一旦换成MyBatis-Plus,这些细节会被封装掉大半,写起来爽了,但底层理解容易留下盲区。当然,项目后期我也尝试过换MyBatis-Plus来做对比,确实省事不少,这个放到后面扩展部分细说。
如果你参考过若依框架,会发现它的前后端分离版也是SpringBoot+Vue,但若依更像是一个快速开发脚手架,带了一整套代码生成器和管理系统模板,对于学习来说容易迷失在框架自带的功能里,自己从零搭一遍反而对每个环节的理解更扎实。
2. 后端落地:SpringBoot+MyBatis+MySQL的数据模型与接口设计
2.1 数据库表设计:九张表如何撑起整个业务
后端开发的第一步永远是数据库设计。表结构没想清楚,后面写接口会处处别扭。我最终设计了九张表,这里把核心表的结构和设计考虑讲一下。
| 表名 | 用途 | 关键字段 | 设计说明 |
|---|---|---|---|
| t_user | 用户信息 | id, username, password, nickname, phone, avatar, role, create_time | 角色字段区分普通用户和管理员,密码存的是BCrypt加密后的密文,不能明文存储 |
| t_category | 景点分类 | id, name, sort, status | status控制分类是否在前端展示,方便下架不删除 |
| t_scenic | 景点信息 | id, category_id, name, summary, detail, cover_image, images, price, address, status, create_time | images字段用逗号分隔存多张图片,简单场景下比建子表更实用 |
| t_route | 旅游线路 | id, name, days, description, cover_image, price, status | days表示游玩天数,用于前端展示"一日游/两日游" |
| t_route_scenic | 线路景点关联 | id, route_id, scenic_id, sort | 多对多关系表,给线路里的景点排顺序 |
| t_order | 订单表 | id, order_no, user_id, scenic_id, route_id, quantity, total_price, status, create_time | 订单号用时间戳加随机数生成,状态字段用数字表示待支付/已支付/已完成/已取消 |
| t_comment | 评论表 | id, scenic_id, user_id, content, score, create_time | 冗余了nickname字段避免联查用户表,这是常见的空间换时间优化 |
| t_favorite | 收藏表 | id, user_id, scenic_id, create_time | 加唯一索引(user_id, scenic_id)防止重复收藏 |
| t_banner | 首页轮播图 | id, image, url, sort, status | 首页运营位,后台可维护 |
这里说几个设计要点。第一,所有表都带create_time字段,而且由数据库默认值填充,这样插入数据时不用手动传;第二,景点表的status字段很重要,景点下架用的是状态而不是物理删除,避免订单、收藏等关联数据失效;第三,线路景点关联表里的sort字段很多人会省略,但实际线路中"先到哪个景点、后到哪个景点"是需要排序的,没有这个字段后期补数据会非常痛苦。
我当时花了一天时间建模和调整,实际开发中发现前期表设计多花的时间完全值得,后面写Mapper几乎没出现"字段不够用、要改表结构"的情况。
2.2 MyBatis的Mapper层设计:动态SQL才是效率关键
MyBatis的配置我用了最标准的XML方式,这也是面试里常被追问的写法。SpringBoot中需要两步:在application.yml配置数据源和MyBatis扫描路径,然后在启动类上加上@MapperScan注解,让MyBatis扫描到Mapper接口。
application.yml里的关键配置大概长这样:
spring: datasource: url: jdbc:mysql://localhost:3306/guilin_travel?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.guilin.travel.entity configuration: map-underscore-to-camel-case: true这里两个比较容易踩的细节。第一个是数据库连接串里的serverTimezone参数,MySQL 8默认时区不是中国时区,不配会报时间相关的错误;第二个是map-underscore-to-camel-case一定要开启,这样数据库的create_time才能自动映射到Java实体里的createTime,不用手写一堆ResultMap。
景点列表查询是典型的动态SQL场景,用户可能按分类过滤、按关键词搜索、按价格排序、还要分页。如果用MyBatis-Plus,直接QueryWrapper搞定,但原生MyBatis需要自己写动态标签,我实际写出来的Mapper XML片段如下:
<select id="findByCondition" resultType="com.guilin.travel.entity.Scenic"> SELECT * FROM t_scenic <where> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND (name LIKE CONCAT('%', #{keyword}, '%') OR summary LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="status != null"> AND status = #{status} </if> </where> <if test="sort != null and sort != ''"> ORDER BY ${sort} </if> </select>这里where标签会自动处理"第一个条件前面的AND"问题,这个细节是原生态MyBatis最常用的技巧。注意ORDER BY后面用的是${sort}而不是#{sort},因为排序字段是数据库列名,不能参数化,但这里有SQL注入风险,我在前端传参时做了白名单校验,只允许传price_asc、price_desc等固定值,而不是直接把前端传的字符串拼进去。
分页我用的是PageHelper分页插件,引入依赖后在查询前调用PageHelper.startPage(pageNum, pageSize),查询后拿到PageInfo,里面已经封装好了总记录数、总页数这些分页数据。返回给前端的数据结构我已经把列表和总数都包好,前端表格组件直接用total字段渲染分页器即可。
2.3 Controller统一返回结构和全局异常处理:少走半天弯路
写接口第二天我就发现一个问题:如果不统一返回结构,每个接口返回的JSON格式都不一样,前端Axios拦截器根本没办法做通用处理。于是我定义了一个Result类作为所有接口的统一返回体。
public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 200; result.message = "操作成功"; result.data = data; return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.code = 500; result.message = message; return result; } }与之配套的是一个@RestControllerAdvice全局异常处理器,把业务异常、参数校验异常、系统异常分别处理,统一包装成Result返回。这样做的好处是前端只需要判断code是不是200,其余情况直接弹出message即可,不会因为某个接口的返回格式不一致导致前端解析崩溃。实际开发中我发现很多新手项目里Controller直接返回实体类或者Map,表面上省了封装代码,但一旦前端需要区分"成功但数据为空"和"请求异常"这两种情况,就会非常被动。
再配合@Valid注解做参数校验,后端在入口处就拦截掉空参数、非法参数,不用在每个Service里写一堆if判断。
2.4 图片上传与存储:先本地后对象存储
景点图片的上传管理,我第一版用的是最简单的本地存储方案:后端接收MultipartFile文件,重命名为时间戳加随机数,保存到服务器指定的upload目录,再把访问路径存到数据库。SpringBoot里需要配置静态资源映射,让上传的图片可以通过URL直接访问:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceHandler("file:" + uploadPath + "/"); }这样前端只需要用"/upload/xxx.jpg"就能拿到图片。这个方案在本地开发和单机部署完全够用,代码量少,理解也直观。如果将来要上云,可以把上传逻辑替换为MinIO或OSS,前端访问路径完全不用变,因为后端返回给前端的始终是一个URL字符串。
3. Vue前端从零搭建:路由、API封装与页面编码
3.1 前端工程化:用Vite还是Vue CLI
前端这部分我纠结了一下是用Vue CLI还是Vite。考虑到Vite在开发环境下的启动速度明显更快,而且Vue官方已经将Vite作为默认推荐,我直接用Vite初始化了一个Vue 3项目,搭配Element Plus组件库做后台管理界面,游客端页面则用了手写的样式保持个性化。如果你更习惯Vue 2的写法,用Vue CLI脚手架也是一样的思路,核心技术点不冲突。
安装依赖和启动开发服务器这步,我在环境配置那篇笔记里踩过一次坑:Vite需要Node.js 16以上版本,版本太老会直接报错。建议先用node -v确认一下当前版本,再决定是否升级。
3.2 路由设计:游客端和管理端如何共存
一个工程里同时容纳游客端和管理端,最核心的是路由规划。游客端是公开访问的,管理端需要登录后才有权限,所以我拆了两套路由:一套是基础路由,包括首页、景点列表、景点详情、登录页;另一套是管理端路由,统一挂在/layout组件下,用路由守卫校验登录状态。核心路由表长这样:
const routes = [ { path: '/', component: Home }, { path: '/scenic', component: ScenicList }, { path: '/scenic/:id', component: ScenicDetail }, { path: '/login', component: Login }, { path: '/admin', component: AdminLayout, meta: { requiresAuth: true }, children: [ { path: 'scenic', component: AdminScenic }, { path: 'order', component: AdminOrder }, { path: 'comment', component: AdminComment } ] } ]; router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next('/login'); } else { next(); } });这里的关键点是meta里的requiresAuth标记。我一开始把权限判断写死在每个页面的created里,结果发现每个页面都要粘贴一遍判断代码,后来统一收敛到路由守卫里,代码干净很多。
3.3 Axios封装:请求拦截器和响应拦截器的分工
几乎所有前端请求都要做两件事:自动携带token、统一处理错误提示。我封装了一个request.js,核心逻辑如下:
const request = axios.create({ baseURL: '/api', timeout: 10000 }); request.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = 'Bearer ' + token; } return config; }); request.interceptors.response.use( response => { const result = response.data; if (result.code === 200) { return result.data; } if (result.code === 401) { localStorage.removeItem('token'); router.push('/login'); return Promise.reject(new Error('登录已过期')); } ElMessage.error(result.message); return Promise.reject(new Error(result.message)); }, error => { ElMessage.error('网络请求异常'); return Promise.reject(error); } );baseURL用的是/api,配合前端开发环境的代理配置,把请求转发到后端的localhost:8080。响应拦截器先判断Result.code,只有200才把data返回给页面,其他情况直接弹出错误提示,页面里的业务代码就不用再重复写错误处理了。这种方式让页面的请求代码从十几行缩减到几行,而且审查得很清楚。
3.4 核心页面实现:列表页、详情页和下单流程
景点列表页是典型的"查询条件+表格/卡片"布局。页面加载时调用getScenicList方法,传入分类筛选条件和搜索关键词:
const getList = async () => { loading.value = true; try { const result = await getScenicList({ categoryId: categoryId.value || null, keyword: keyword.value, pageNum: pageNum.value, pageSize: 10 }); scenicList.value = result.list; total.value = result.total; } finally { loading.value = false; } };景点详情页要展示的是基本信息+图片轮播+评论列表+收藏按钮+预订入口。数据来源分别是景点详情接口、评论接口和收藏接口。详情接口一次返回景点基本信息,评论和收藏分别在页面挂载时并行请求,用Promise.all控制并发请求结束状态,避免一个接口阻塞整个页面渲染。
下单流程我简化成了订单创建逻辑:前端先拉取景点和线路信息,用户选择游玩日期和数量,点击提交后调用createOrder接口,后端生成订单对象并写入数据库,前端带着返回的订单编号跳转到"我的订单"页面展示支付状态占位。整个流程虽然简化了,但订单表的状态机设计保留了完整逻辑,以后接支付网关可以平滑扩展。
3.5 后台管理页面:表格加弹窗是标配
管理端页面没有做太多创新设计,就是扎实的表格加弹窗表单:左侧导航固定,右侧内容区用Element Plus的el-table展示数据,顶部的工具栏放"新增"和"条件搜索",行内放"编辑"和"删除"按钮。新增和编辑共用一个弹窗表单,通过判断row参数是否存在来区分模式。
景点管理页是管理端里最复杂的,因为涉及图片上传。上传逻辑是先调upload接口把图片存到后端,拿到返回的URL后再把这个URL作为表单字段提交。如果先提交表单再上传图片,会出现图片还没传完表单已经提交的情况,处理起来很别扭。调整顺序之后,整个流程稳定多了。
4. 联调阶段值得记录的四个问题——CORS、JWT、日期格式与空值
前后端分离开发必然要面对联调,这一阶段我实际遇到的问题比开发阶段多得多,挑了四个典型记录在这,都是常规文档不太会细讲的内容。
4.1 跨域问题的完整排查链路
开发模式下,前端跑在5173端口,后端跑在8080端口,浏览器直接发请求必然触发跨域。我在后端项目里加了一个CorsConfig的Bean,用WebMvcConfigurer配置允许跨域,指定允许的路径、域名和方法。但如果你的前端请求带Authorization头,还需要单独考虑Header暴露,否则前端Axios拿不到响应头里的状态信息。我实际排查时发现了一个隐蔽问题:后端虽然配置了CORS,但Spring Security如果存在,它的拦截器优先级更高,跨域配置可能被Security的过滤器提前拦截掉。好在我的项目没有引入Spring Security,用自定义拦截器做鉴权,跨域配置直接生效。如果你的项目同时有Spring Security,需要额外配置Security的CorsConfigurationSource。
4.2 JWT登录鉴权的实现细节和放行规则
登录态管理我选了JWT,原因很简单,服务端不需要存session,适合前后端分离的分布式场景。后端在用户登录时生成token返回给前端,前端存储到localStorage,请求时通过Authorization头携带,后端写一个拦截器统一校验。
JWT的关键实现逻辑不算复杂:用jjwt依赖生成token,sub放用户ID,exp设置过期时间为24小时。真正容易出问题的是拦截器里哪些路径要放行。如果拦截器配置成"所有接口都要验证",那么登录接口自身也被拦截了,因为登录时根本没有token。我的配置方式是:登录接口、景点列表接口、景点详情接口这些公开接口放行,管理端接口全部要求登录,个人中心和订单相关接口必须登录。用拦截器的时候注意,放行规则应该写成白名单形式,而不是黑名单——只明确列出不需要登录的路径,其余全部拦截,这样更安全。
4.3 日期格式和null值的"隐形坑"
排查过两个坑。第一个是前端传"2025-06-01"给后端,后端用LocalDateTime接收,直接报格式错误。原因是Jackson默认只认识ISO格式,需要全局配置日期格式,我在application.yml里加了:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8第二个坑是数据库的date字段传到前端后,显示成"2025-06-01T00:00:00.000+00:00"这样的UTC格式,前端要再转换一次。后来我在实体类的日期字段上加了@JsonFormat注解,指定输出格式,前端直接拿到"2025-06-01 00:00:00"就省事了。
null值的问题体现在分页接口上:当列表为空时,后端返回的list是null还是空数组,直接影响前端v-for渲染。我统一处理为:只要数据库没查到数据,返回空数组而不是null,前端不用写一堆空值防御代码。
4.4 MyBatis动态SQL里"if判断等于0"的问题
这是我在写筛选功能时真正踩过的坑。定义用户的角色字段时,我一开始判断条件写了<if test="role != null and role != ''">,结果前端传role=0(管理员)时,条件不生效,因为MyBatis的OGNL表达式把数字0当成了false空值处理。排查半天才定位到,问题不是SQL逻辑,而是MyBatis对整数类型的if判断有个特殊规则:数值为0时,非空判断不成立。
解决方式有两种:一是改用String类型传参,传"0"而不是0;二是判断条件写全<if test='role != null and role != ""'>并在Service层把0转换成字符串。我最后选择在后端把筛选参数的类型统一调整,避免这类隐蔽问题再次出现。
5. 从开发机到云服务器:完整部署流程与配置清单
项目在本地跑通只是第一步,部署到服务器才是完整闭环。我用的是一台Linux云服务器,配置是2核4G,对这套系统来说完全够用。
5.1 服务器基础环境准备:JDK、MySQL8、Nginx
部署环境三件套:JDK、MySQL、Nginx。我的服务器是CentOS系统,JDK用tar包解压安装,MySQL用官方rpm源安装,Nginx用yum直接装。具体步骤不赘述,但有几个关键点必须提醒。
MySQL装完一定要执行mysql_secure_installation做基础安全配置,否则root空密码裸露在公网上,很快会被扫描爆破。另外MySQL 8的密码策略比5.7严格,设置密码时要满足长度和复杂度要求,否则会报错。数据库导入就是把本地导出的SQL文件用source命令执行一遍,注意在导入前先创建好数据库并指定默认字符集utf8mb4,否则中文会乱码。
5.2 后端打包发布:Maven打包与Systemd守护进程
后端工程是标准的Maven项目,打包命令非常简单:
mvn clean package -DskipTests打包后在target目录生成一个jar包。上传到服务器后,我一开始用java -jar前台启动,但关掉SSH窗口进程就死了。后来写了一个Systemd服务文件,让后端作为守护进程运行:
[Unit] Description=Guilin Travel Backend After=network.target [Service] User=root WorkingDirectory=/opt/guilin ExecStart=/usr/bin/java -Xms512m -Xmx512m -jar /opt/guilin/guilin-backend.jar SuccessExitStatus=143 Restart=always RestartSec=10 [Install] WantedBy=multi-user.target配置好之后执行systemctl enable设为开机自启,用systemctl start启动,用journalctl -u guilin-backend查看日志。启动参数里Xms和Xmx设置一样大,避免了JVM动态扩展内存带来的性能抖动,2G内存的服务器运行很平稳。
5.3 前端构建与Nginx反向代理配置
前端构建相对简单,npm run build会生成dist目录,里面是纯静态文件。把这个目录上传到服务器,配置Nginx指向它,同时把/api路径反向代理到后端8080端口,这是前后端分离项目部署最核心的一步。
server { listen 80; server_name your_domain_or_ip; root /opt/guilin/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /upload/ { proxy_pass http://127.0.0.1:8080; } }location /里的try_files配置很关键,它把前端路由的刷新请求都指向index.html,否则在页面里点路由刷新会出现404。如果不加这个,Vue Router的history模式部署后刷新就白屏,这是前后端分离部署最常见的坑之一。
5.4 部署上线后必须检查的性能隐患
上线后我做了几项检查。第一是MySQL连接串里的useSSL参数,部署环境如果MySQL配了SSL,但连接串是useSSL=false,会一直报SSL连接错误,排查方式是看后端日志里at com.mysql.cj.jdbc的报错堆栈,非常典型。第二是MySQL最大连接数,默认151,如果前端并发不高还好,但测试阶段用压测工具一跑就会把连接池打满,建议配置到300以上。第三是JVM的GC日志,观察Full GC频率,如果太高说明堆内存设置不合理。
我把这些检查项整理成了一个部署检查清单,每次部署新项目都按这个顺序过一遍:防火墙端口是否放行、数据库是否可远程访问、后端进程是否守护运行、Nginx是否配置反向代理、静态资源能否访问、接口能否通过域名请求。
6. 项目复盘:这套系统还能怎么扩展
6.1 换用MyBatis-Plus到底能省多少事
项目完成之后,我把Mapper层整体换成了MyBatis-Plus做了一个对比实验。同样的分页查询,用MyBatis-Plus的Page对象加LambdaQueryWrapper,代码量确实减少了大概四成,不再需要手写XML里的动态SQL标签,简单查询一句lambda表达式搞定。如果你追求开发效率,MyBatis-Plus确实值得用。但如果你还在学习阶段,我强烈建议先用原生MyBatis把动态SQL、ResultMap、多表查询这些底子打好,再切换效率方案,否则出了问题都不知道从哪排查。
6.2 进阶方向:地图、Redis和对象存储
这套系统继续扩展的方向很清晰。第一是接地图SDK,在景点详情页展示景点位置,在旅游线路页展示线路轨迹,这个只要申请地图开放平台的密钥,前端引入对应的JavaScript库就能实现,数据模型已经预留了经纬度字段。第二是引入Redis做缓存和热点数据加速,比如首页轮播图、景点列表热门数据,缓存起来后数据库压力会明显下降。第三是把本地图片存储替换为MinIO或云对象存储,解决单机磁盘容量和访问带宽的问题。如果要做视频类内容,也可以参考Vue播放m3u8这类视频流方案的集成方式,把景区宣传视频嵌进详情页。
6.3 给准备复用源码的朋友的建议
如果你打算在这个项目源码基础上做二次开发或改造,我有几条实际建议。第一,数据库SQL文件和Redis的配置项都在源码目录里有,导入后记得修改application.yml里的数据库密码和IP地址。第二,前端管理端的账号是初始化时手动写入数据库的,登录时密码经过BCrypt加密,不要直接用明文SQL插入,否则登录会失败。第三,运行项目前最好先按照我前面说的流程把环境确认一遍:JDK版本、Maven版本、Node版本、MySQL版本,每一步都用命令行验证,避免浪费一下午在环境配置上。
我在实际运行中发现,前后端分离项目真正花时间的不是"写代码",而是"调通链路":从浏览器发请求到Nginx,到SpringBoot,到MyBatis,到MySQL,数据再原路返回渲染,整个过程每个环节都可能出问题,而多数问题不在你自己的代码里,而在配置和环境的默认设置中。所以调试时一定要习惯看完整报错堆栈,一层层往下追,而不是只看最上面那段异常描述。多踩几次坑,把每一个问题的排查路径记下来,你的排错速度会随着项目数量增长得越来越快。