做这个“家乡旅游平台展示及特产购物平台”的时候,我给自己定了一个很实际的目标:把老家那些藏在手机相册里的景点、堆在仓库里的土特产,用一套基于 SpringBoot + Vue 的前后端分离系统做成线上展示和购物入口。游客不需要打电话问路,也不用托人代购,直接打开网页看攻略、查景点、下单买特产。系统技术栈选得比较克制:后端 SpringBoot,前端 Vue,数据库 MySQL,图片走本地磁盘存储。这套方案适合做毕业设计、课堂项目展示,也适合小成本创业团队快速落地一个区域旅游电商平台。接下来我把整个项目的设计思路、核心实现、踩坑实录一起写出来,希望能给准备做类似平台的人一点参考。
1. 项目整体设计与技术选型思路
1.1 这个项目解决什么问题
这类地方性旅游电商平台,市面上不缺大而全的方案,缺的是贴合小城实际业务的轻量系统。很多景区信息分散在公众号、短视频、个人朋友圈里,用户查攻略要来回切换好几个渠道;特产销售又往往依赖线下门店或熟人介绍,游客离开之后想复购根本没有入口。
这个项目要解决的核心问题有三个:第一,把家乡的旅游景点做成结构化展示页面,让用户能按照区域、类型、热度去浏览;第二,把特产商品和景点、攻略放在同一个体系里,用户看完某个景点,顺手就能看到当地特产推荐;第三,提供完整的注册、登录、购物车、下单、订单管理闭环,让特产买卖真正跑通。
所以在做功能模块拆分时,我没有上来就铺大平台。用户端只保留首页、景点列表、景点详情、特产列表、特产详情、购物车、订单确认、个人中心这些必要页面;管理端则拆成景点管理、特产管理、轮播图管理、订单管理、用户管理。后台不做复杂权限树,只做管理员和普通用户两种角色,用最简单的方式满足实际运营需求。
1.2 为什么是 SpringBoot + Vue,而不是其他组合
我见过很多同类项目用纯 JSP 或者 Thymeleaf 做服务端渲染,也有用 PHP 一套代码写完的。对我而言,SpringBoot + Vue 前后端分离不是追热点,而是因为这个组合在资源有限的情况下,把开发边界切得最清楚。
后端 SpringBoot 负责提供纯 JSON 接口,开发者只需要关注业务逻辑和数据库操作。前端 Vue 负责页面渲染、路由跳转、状态管理,交互体验比传统模板引擎好很多。前后端分离之后,我写好接口文档,前端可以同步开发,不用等后端页面模板;后期改页面样式,也完全不需要动后端代码。
技术栈再细化一下:后端用 SpringBoot 2.7.18 + MyBatis-Plus + MySQL 8.0,JDK 8。前端用 Vue 3 + Vite + Vue Router + Pinia + Element Plus。选择 MyBatis-Plus 是因为单表 CRUD 可以直接用内置方法,少写大量 XML;选择 Vue 3 主要是因为组合式 API 在组件复用上更方便,而且 Vite 启动速度确实比 Webpack 舒服很多。
有人可能会问,SpringBoot 3 + JDK 17 不是更新吗?确实可以,但要注意包名从 javax 变成了 jakarta,很多老项目代码不能直接迁移。如果你的服务器已经装好 JDK 8,或者团队里都是 JDK 8 的习惯,那就老老实实用 2.7.x,稳定压倒一切。
1.3 功能模块怎么拆
我习惯先画一张粗糙的模块图,把“谁能用什么功能”定下来。
游客可以浏览景点和特产、查看景点详情、搜索商品、加入购物车,但是提交订单前必须登录。登录后用户可以维护收货地址、查看订单状态、确认收货。管理员登录后进入独立后台,维护景点信息、上传轮播图、上下架特产、处理订单发货、查看用户列表。
购物流程我设计成:商品列表 -> 商品详情 -> 加入购物车 -> 购物车勾选 -> 确认订单 -> 填写/选择地址 -> 提交订单 -> 支付(模拟) -> 查看订单。因为是展示类项目,支付环节通常对接模拟支付或者直接标记为“货到付款”,避免真的接入微信支付宝带来资质和审核成本。这个设计在毕设和演示场景里非常实用。
模块拆分时我特别注意“景点”和“特产”之间的关联。景点表里可以关联一个特产分类或者推荐特产 ID 列表,特产表里也可以标注“所属景点”。这样用户浏览景点详情页时,可以直接展示“附近特产”,形成旅游和购物的联动,而不是两个孤立的模块。
2. 后端核心细节与接口设计
2.1 数据库表结构设计:景点、特产、订单、用户
数据库是整个项目的地基,表结构设计不合理,后面写接口会非常痛苦。我用的是 MySQL 8.0,字符集 utf8mb4,避免遇到生僻地名或特殊表情符号时乱码。
用户表我命名为 t_user,核心字段包括 id、username、password、nickname、phone、avatar、role、create_time。密码存储一定不能用明文,我用 BCrypt 加密,Spring Security 或者 hutool 里都有现成实现。管理员和普通用户共用一张表,通过 role 字段区分,简单够用。
景点表 t_scenic 的字段包括 id、name、summary、content、cover、images、region、ticket_price、open_time、address、status、sort。content 用来存富文本详情,images 用逗号分隔的图片路径,前端按逗号拆开渲染。这里没有分太多冗余表,如果后面要扩展评论区,再单独建一个 scenic_comment 表也不迟。
特产表 t_product 的字段包括 id、name、scenic_id、category、price、stock、sales、cover、images、detail、status。scenic_id 是景点表的外键,但我在实际查询时没有用数据库物理外键,而是在代码层维护。原因是物理外键在分页、批量导入、删除时会带来很多约束问题,开发阶段尽量少用。
订单相关表我拆成三张:t_order 保存订单主信息,字段包括 id、order_no、user_id、total_price、status、address、create_time、pay_time;t_order_item 保存订单明细,包括 product_id、product_name、product_image、price、count;t_address 保存用户地址簿。这样订单详情页可以非常快速地展示“买了什么”“买了几件”“收货人是谁”。
2.2 统一返回格式与异常处理
一个前后端分离项目,最怕接口返回格式各写各的。有的接口返回对象,有的接口只返回 boolean,前端拿到数据后不知道该取哪个字段。我从一开始就定了一个统一的返回类 Result ,结构固定为 code、message、data。code 为 0 表示成功,其他为业务异常码;message 放提示文案;data 放业务数据。
实际操作中,我会把所有接口的返回值都包成 Result ,然后建一个全局异常处理器,用 @RestControllerAdvice 统一拦截。业务异常直接抛自定义 BizException,参数校验失败抛 MethodArgumentNotValidException,数据库异常就包装成“系统繁忙,请稍后重试”。这样前端 axios 拦截器只需要判断 code 是否为 0,再决定是进入正常流程还是弹出 error 提示。
全局异常处理听起来很小,实际体验差异很大。没有它之前,后端一报错前端就是一整屏英文堆栈;有了之后,前端永远是格式化的 JSON,定位问题非常快。特别是联调阶段,我经常在后端日志里看到空指针,但用户页面却显示“操作失败”,这种问题靠统一异常日志很快就能定位到具体 Controller 方法和行号。
2.3 用户认证与下单流程
用户认证我选用了 JWT,而不是传统的 Session。原因很简单:前后端分离之后,前端可能部署在 Nginx,后端跑在另一台服务器,Session 默认靠 Cookie 传递,跨域场景下处理麻烦。JWT 把用户信息加密放在 token 里,前端每次请求在请求头带上 Authorization,后端解析校验即可。
具体流程是:用户登录成功后,后端生成一个有效期为 24 小时的 JWT,返回给前端。前端把 token 存到 localStorage,在 axios 请求拦截器中统一设置 Authorization 头。后端用一个拦截器解析 token,把 userId 放到 ThreadLocal 或者请求属性中,供后续下单接口使用。
下单接口是重点,尤其要注意库存超卖。我这里是单机部署,用 synchronized 关键字对同一个商品 ID 加锁就能解决大部分问题;但如果在集群环境,就要考虑 Redis 分布式锁。代码逻辑上先查库存,如果少于购买数量直接抛异常;足够的话,执行扣减库存、生成订单号和订单明细,最后返回订单 ID。扣减库存和生成订单必须放在同一个事务里,否则可能出现订单生成了库存没扣,或者库存扣了订单失败。
2.4 图片上传与本地存储
景点和特产图片管理,我一开始考虑过接入 OSS 对象存储,但这类项目往往是演示用,每月没有多少流量,用 OSS 反而要额外申请 accessKey 和配置 bucket。最后采用了最简单的本地磁盘存储:上传接口接收 MultipartFile,用 UUID 重新命名文件,按日期分目录保存到服务器的 upload 文件夹下,然后把可访问的相对路径返回给前端。
代码上用 Spring Boot 自带的上传能力就行,关键是配置文件里要限制大小。我设置的 max-file-size 为 10MB,max-request-size 为 30MB。图片过大不仅占用磁盘,也会让前端首屏加载变慢。上传成功后,图片路径存在数据库里,比如 /files/2025/01/12/uuid.jpg,前端直接用 Nginx 把 /files/ 路径映射到磁盘目录即可。
这里我踩过一个坑:本地存储的文件路径不能存绝对路径,否则换一台服务器部署,所有图片路径全部失效。正确做法是只存 URL 相对路径,部署时通过 Nginx 或网关统一映射。另外,删除图片时不要只删数据库记录,还要把服务器上的文件一起删掉,否则磁盘会慢慢被无用图片占满。我专门写了一个工具方法,在更新或删除景点、商品时先读取老图片路径,再调用文件删除逻辑。
3. 前端关键页面与交互实现
3.1 前端工程化:Vite 创建项目和路由设计
前端我用 Vite 初始化了一个 Vue 3 项目,目录结构大致分为 views、components、router、store、api、utils。apis 目录按照后端接口模块拆成 scenic.js、product.js、order.js、user.js,每个文件导出对应请求方法,页面组件里只调用这些方法,不直接写 axios 请求。这样后端一旦改了接口路径,我只需要改 api 目录下的一个文件,不用满项目找请求代码。
路由设计上,我做了动静分离。公共页面包括首页、景点列表、景点详情、特产列表、特产详情、购物车、登录注册;需要登录的页面包括订单确认、订单列表、个人中心。管理后台单独设一个 /admin 前缀,用嵌套路由把侧边栏和内容区包起来。
使用 Vue Router 的懒加载之后,首屏只加载首页需要的组件,其他页面进入时才动态加载对应 JS。我这个项目功能不多,但景点详情页包含富文本内容,商品列表页图片很多,懒加载对首屏速度提升还是很明显。
import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/', name: 'Home', component: () => import('@/views/Home.vue') }, { path: '/scenic/:id', name: 'ScenicDetail', component: () => import('@/views/ScenicDetail.vue') }, { path: '/product/:id', name: 'ProductDetail', component: () => import('@/views/ProductDetail.vue') }, { path: '/cart', name: 'Cart', component: () => import('@/views/Cart.vue'), meta: { requiresAuth: true } } ]3.2 首页旅游展示模块怎么做
首页是用户对整个平台的第一印象,我重点做了三块区域:顶部轮播图、热门景点推荐、特产分类导航。轮播图数据从后台 banner 接口读取,管理员在管理端上传图片、填写跳转链接,前端用 Element Plus 的 carousel 组件渲染。这里的后台配置化思路很重要,否则每次换图都要改前端代码重新部署。
热门景点推荐接口我做了两层逻辑:默认查询 status 为正常的景点,按浏览量倒序取前六条。浏览量不是每次打开详情页就加一,那样刷新一次就加一次很不真实。我采用“同一用户十分钟内重复访问不计入”的策略,在内存里简单维护一个 map 记录 userId 和上次访问时间,避免浏览量短时间内被刷爆。这个方案在单机场景完全够用。
景点详情页的富文本内容我用 v-html 渲染,但后端一定要做 XSS 过滤,否则管理员在内容里插入一段恶意脚本就能攻击其他访问者。我后端写了一个 HtmlUtil,把 script、iframe、onerror 这些危险内容先剔除再存储。前端渲染时再关掉富文本编辑器源码编辑功能,双重保险。
特产模块在首页不用铺全部商品,展示四个分类入口和六个推荐商品就够了。推荐商品我直接用商品销量排序,销量相同再看库存。这样管理员不用手动设置“推荐位”,减轻运营负担。
3.3 特产购物车与订单结算
购物车我用 Pinia 管理,同时把购物车数据同步到本地 localStorage。为什么要同步?因为用户加购后可能不结算直接关掉浏览器,下次打开购物车还要保留之前加的内容。用 localStorage 可以做到无需登录也能暂存购物车,等提交订单时再把商品一次性传给后端生成订单。
购物车页面支持勾选商品、修改数量、删除商品、清空失效商品。计算总金额的公式是:总价 = 每个商品单价 × 数量 的总和。这里要注意浮点数精度问题,JavaScript 里 0.1+0.7 这种计算会得到 8.99999,所以金额计算我统一用“分”作为单位存储和运算。也就是后端返回的 price 单位是分,前端展示时再转成元,或者后端直接返回字符串类型的价格,避免 JS 精度丢失。
进入订单确认页后,前端会从购物车接口重新拉取一遍商品最新价格和库存,而不是直接用购物车里的旧数据。因为用户在购物车页面停留很久后,后台商品价格可能已经调整,库存也可能已经被别人买光。重新拉取可以在提交订单前拦截价格变动,减少纠纷。
3.4 管理员后台的实现
管理员后台我单独做了一套布局,左边是菜单,右边是内容区。菜单包括数据概览、景点管理、特产管理、轮播图管理、订单管理、用户管理。整体用 Element Plus 的 el-table 和 el-dialog 完成增删改查,弹窗内的表单用 el-form 做校验。
景点管理要能上传封面图、录入富文本详情、设置区域和价格。上传组件我封装了一个通用组件,内部处理 file 文件转 FormData、请求上传接口、回填图片 url 三个步骤。这样景点、商品、轮播图三处都能复用同一个上传组件。
订单管理模块只需要展示订单列表和更新订单状态。订单状态我定义成 0待付款、1待发货、2待收货、3已完成、4已取消。管理员点击发货时,后端要校验订单状态必须为待发货,然后更新状态为待收货,同时记录发货时间。状态流转一定要放在后端校验,不能只靠前端按钮控制,否则用户直接调用接口就能跳过步骤。
4. 联调部署与避坑经验
4.1 前后端联调中常见的跨域与 token 问题
联调阶段最大的问题就是跨域。前端开发服务器默认跑在 5173 端口,后端接口跑在 8080 端口,浏览器会拦截跨域请求。我在后端写了一个全局跨域配置类,允许本地开发环境的 origin,并且开放 GET、POST、PUT、DELETE、OPTIONS 方法。生产环境则不开放所有来源,用 Nginx 反向代理同源,从根上避免跨域。
token 问题是第二个高频坑。很多同学把 token 放到 localStorage 后,以为后端就能自动识别,其实后端什么都不知道。前端必须在 axios 请求拦截器里手动带上 Authorization 头:
service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config })后端拦截器解析到 token 无效或者过期,不能返回 200 的“未登录”消息,因为 200 会被 axios 当作成功请求处理,页面逻辑容易混乱。我统一让拦截器返回 401 状态码,前端响应拦截器判断到 401 后,清除本地登录状态,跳回登录页。这个约定前后端必须一致,否则会出现“明明没登录但页面还能继续操作”的诡异问题。
4.2 Nginx 部署与反向代理配置
生产环境部署我用了最经典的方式:前端打包成静态文件,由 Nginx 托管;后端打成 jar 包,通过 systemd 或直接 java -jar 运行;MySQL 和上传目录都放在同一台服务器上。虽然这台服务器配置一般,但应付日活几百人的场景完全没问题。
关键配置在 Nginx。前端静态文件放在 /www/hometown/dist,后端接口地址统一以 /api/ 开头,Nginx 把 /api/ 请求反向代理到 127.0.0.1:8080。图片的访问路径 /files/ 映射到磁盘的 /www/hometown/upload 目录。这样浏览器里所有请求都属于同一个域名,不涉及跨域,Cookie 存 JWT 的模式也能正常工作。
server { listen 80; server_name localhost; root /www/hometown/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/api/; 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 /files/ { alias /www/hometown/upload/; } location / { try_files $uri $uri/ /index.html; } }这里有一个容易忽略的点:前端使用 history 路由模式后,用户在景点详情页按 F5 刷新,Nginx 如果找不到对应路径,就会返回 404。所以必须加 try_files $uri $uri/ /index.html,把请求全部交给前端路由处理。如果你不想配置这个,可以用 hash 模式,但 URL 会多一个 #,不太美观。
4.3 避坑清单:图片打不开、订单重复提交、样式错乱
图片打不开是部署阶段最常见问题,排查思路三步走:先用浏览器直接访问图片 URL,看返回的是 200 还是 404;再检查 Nginx 的 alias 路径和磁盘实际路径是否一致;最后检查数据库里存的路径是否带了多余端口。如果数据库存的是 http://localhost:8080/files/123.jpg,部署到正式环境后域名变了,图片必然加载失败。所以我在存储时就统一使用 /files/xxx.jpg 这种相对路径,前端页面拼上当前域名即可。
订单重复提交属于并发问题。用户快速点击两次“提交订单”按钮,后端可能同时收到两个请求,生成两笔相同订单。前端解决方式是提交按钮加 loading 状态并 disabled;后端解决方式是生成唯一订单号,在数据库中给 order_no 加唯一索引。如果第二次提交使用了同一个 order_no,数据库直接报唯一键冲突,事务回滚,从根上避免重复订单。
样式错乱一般发生在项目迁移时。Element Plus 是按需引入还是全量引入,在环境切换后行为可能不一样。我最终选择了全量引入,项目体积虽然大一些,但团队协作时不会因为漏引入样式导致弹窗按钮没有边框。如果追求性能,再考虑按需自动导入也不迟。
4.4 我印象最深的几个坑和调试思路
第一个坑是登录后刷新页面,用户信息丢失。因为我只把 token 存了 localStorage,用户详情没有持久化。刷新页面后,前端只拿到 token,不知道用户昵称和头像。解决办法是前端在路由拦截时,如果发现本地没有用户信息但 token 存在,就调用 /api/user/info 重新拉取一次用户信息,然后放进 Pinia。
第二个坑是富文本图片显示尺寸超出详情页宽度。管理员在后台粘贴的图片可能是原始大图,前端 v-html 渲染后直接溢出。我后来在后端富文本清洗时,给图片加上了 max-width:100% 的样式,同时前端在 CSS 里对富文本容器内的 img 也做了限制:
.detail-content img { max-width: 100%; height: auto; }第三个坑是上传文件重名覆盖。如果上传的文件名都叫 1.jpg,后保存的会覆盖先保存的。我的方案是上传时用 UUID.randomUUID().toString() 重新生成文件名,再把原文件名保存到数据库的文件名映射表里,前端下载时取回原名。这样图片不会覆盖,文件名也不会乱码。
这些坑单独拿出来都不大,但每个都能耗掉半天时间。所以我把排查经验按“优先看后端日志、再看网络请求、最后看数据库记录”的顺序整理在项目文档里,团队里新同学接手项目时,遇到问题先按这个顺序排查,效率提升非常明显。
做这类家乡旅游加特产购物平台,我最深的体会是:技术难点其实没有那么多,真正花时间的是把业务流程理清楚,把边界情况考虑周全。只要数据库表设计合理,前后端接口规范统一,再做好 Nginx 部署和常见异常兜底,整个项目就能跑得很稳。如果后面想继续扩展,可以加入地图定位、景点评论、优惠券、在线支付对接这些功能,整体架构不需要大改,只要在现有模块上继续加接口和页面就行。