SpringBoot+Vue渔具商城系统从零到上线全解析:数据库设计、JWT认证与部署实战
2026/9/10 18:15:59 网站建设 项目流程

做过“基于SpringBoot+Vue的XX管理系统”这类课题的同学,应该都有同感:题目拿到手,最怕的不是不会写代码,而是不知道从哪下手。今天写的这个“垂钓渔具店网上商城购物管理系统”,乍看是常见的电商CRUD,但真正落地时会发现,从渔具商品的规格参数设计,到下单时的库存扣减,再到Vue前端的状态管理,每一环都藏着不少细节。

如果你正在做类似的毕设或全栈练习项目,这篇文章可以当作一份“从零到上线”的完整参考。我会把系统的核心设计思路、数据库表结构、后端接口实现、前端Vue业务开发,以及前后端联调和部署过程中踩过的坑,按模块拆开讲清楚。项目使用的技术栈是SpringBoot + Vue + MySQL + MyBatis-Plus,这也是目前中小型管理系统非常主流的组合,做完这一个项目,你对前后端分离开发的整套流程基本就有底了。

1. 项目概览与技术选型:为什么是SpringBoot + Vue

1.1 垂钓渔具垂直电商的业务特殊性

很多人一听到“网上商城”,第一反应就是照着淘宝京东抄一套。但真去做了会发现,通用商城的模板放到渔具行业里,很多地方是水土不服的。

渔具这个品类,SKU比较复杂。一根鱼竿可能有“长度2.7米/3.6米/4.5米”、“调性28调/37调”、“材质碳素/玻璃钢”这些维度,一套鱼线还分线号、长度、颜色;鱼饵更是品牌、味型、重量五花八门。所以在设计商品表时,不能只放一个简单的price和stock字段,还要考虑商品规格(或者说SKU)的存法。同时渔具消费有明显的季节性和地域性,春季野钓旺季、夏季夜钓热门,这些都是业务层面要留意的点,但在系统设计上,核心还是先把商品、购物车、订单这条电商主链路跑通。

因此,这个系统的定位就很明确了:面向渔具店经营者,提供商品管理、订单管理、会员管理、数据统计等后台功能;面向钓鱼爱好者,提供商品浏览、搜索、购物车、下单、订单查询等前台功能。目标用户是两类角色,系统天然要拆成“前台商城”和“后台管理”两部分,这也决定了前后端分离的架构方向。

1.2 技术栈选型的逻辑与版本取舍

先列一下我用到的技术栈:

层次技术选型说明
后端框架SpringBoot 2.7.x稳定、生态成熟,资料多,适合快速搭建
ORMMyBatis-Plus单表CRUD几乎不用写SQL,分页插件好用
数据库MySQL 5.7 / 8.0关系型数据,满足商城事务一致性
鉴权JWT无状态登录,前后端分离场景标配
前端框架Vue 3 + Vite组合式API写业务更顺手,Vite冷启动快
UI组件库Element Plus中后台管理界面的利器
状态管理PiniaVue 3官方推荐,比Vuex更简洁
HTTP工具Axios统一请求,拦截器处理token和错误码

为什么后端选SpringBoot?因为对于这类管理系统,SpringBoot的自动配置让项目初始化成本极低,内嵌Tomcat免去单独部署容器,配合Maven依赖管理,一个web项目几分钟就能跑起来。对比SSH(Spring MVC + Spring + Hibernate)那套老古董,SpringBoot在开发效率上的提升是肉眼可见的。

为什么前端选Vue而不是React?如果你是学生或者刚接触前后端分离,Vue的学习曲线相对平缓,模板语法直观,而且Element Plus组件库把表格、表单、弹窗、分页这些后台管理系统高频组件都封装好了。Vue 3的setup语法糖写起来也非常接近原生JavaScript,没有Vue 2那种繁琐的OptionAPI。

版本这里多说一句,SpringBoot别一上来就追最新版。我见过不少同学用SpringBoot 3.x配MyBatis-Plus,结果遇到javax改成jakarta包名、部分第三方starter没适配的问题,排查半天才发现是版本兼容性的锅。建议大家用2.7.x这个成熟版本,前端Node也别装太新的LTS,避免Vite和Node版本冲突。

2. 数据库设计:商城系统的地基

2.1 核心表结构与字段设计

数据库设计是整个系统的地基。我见过很多代码写得还不错,结果一看表结构,商品表里塞了一个JSON字符串存全部规格,订单表里把商品名称价格全冗余进去——这不是不行,但维护起来会非常痛苦。按照常规电商系统,这套渔具商城至少需要这几张表:

  • 用户表(user):id、username、password、nickname、phone、avatar、role、status、create_time、update_time、deleted。role字段区分“管理员”和“普通用户”,这里用字符串或tinyint都可以,建议用tinyint配合枚举,方便扩展。
  • 商品分类表(category):id、name、parent_id、sort、icon、status。渔具分类可以做两级,比如一级分类“鱼竿”,二级分类“手竿/海竿/路亚竿”。
  • 商品表(goods):id、category_id、name、subtitle、main_image、detail_images、price、stock、sales、is_hot、status、create_time、update_time、deleted。price用decima(10,2),避免浮点精度问题。
  • 商品的规格参数(goods_param):id、goods_id、param_key、param_value。这一张表是渔具商品差异化的关键。鱼竿的“长度/调性/材质”,鱼线的“线号/线长”,都可以用参数键值对的方式存。查询详情时按goods_id取出来拼成规格列表。
  • 购物车表(cart):id、user_id、goods_id、goods_name、price、image、count、checked、create_time、update_time。购物车冗余商品名称、价格、图片,是为了列表页少一次联表查询。
  • 订单表(orders):id、order_no、user_id、total_amount、pay_amount、freight_amount、status、receiver_name、receiver_phone、receiver_address、pay_time、deliver_time、create_time、update_time。order_no要唯一,用于后续支付对账。
  • 订单项表(order_item):id、order_no、goods_id、goods_name、goods_image、price、count、total_price。订单生成时把商品快照写入,这样一来就算商品后续改价下架,历史订单里的数据仍然准确。

这里要特别提醒:订单表设计时一定要做商品快照,也就是在订单项表里冗余商品名称、图片、价格。后续打印快递单、处理售后纠纷、做经营数据分析,全靠这层冗余。否则哪天商品下架了,订单记录就变成一堆看不懂的ID了。

2.2 关键字段设计与索引优化

开发这个系统时,字段类型的选择看似不起眼,实际坑不少。我列几个容易踩坑的点。

价格字段:坚决不用float/double,用decimal(10,2)。浮点数在比较运算时会有精度丢失,订单金额这种敏感数据不能有半点误差。

状态字段:用tinyint,配合常量类或者枚举类管理状态值。比如商品状态0下架、1上架;订单状态0待付款、1待发货、2待收货、3已完成、4已取消。别直接存中文,否则后续扩展和统计都会很别扭。

逻辑删除:MyBatis-Plus支持逻辑删除,配置一个deleted字段,默认0,删除时自动更新为1。删除商品不真的把记录从表里干掉,这对于保留历史订单引用关系非常重要。

索引:主键索引是默认有的,额外再关注这几条高频查询路径——goods.category_id(按分类翻列表)、orders.user_id(用户查自己的订单)、orders.order_no(按单号精确查)、cart.user_id(用户查购物车)。加索引时用短字段优先,比如分类ID这种int类型就很合适。

2.3 MyBatis-Plus自动建表与字段映射

很多同学拿到项目后在本地建表建得挺开心,一换电脑或者部署到服务器,数据库表没了,又得手动重新执行SQL一遍。热搜词里有条“springboot + mybatis 当表不存在自动建表”,这个需求常见于部署环节。

最简单靠谱的做法是:在application.yml里配置SpringBoot启动时自动执行初始化SQL脚本:

spring: sql: init: mode: always schema-locations: classpath:sql/schema.sql >-- schema.sql 中建表语句示例 CREATE TABLE IF NOT EXISTS `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(64) NOT NULL, `password` varchar(128) NOT NULL, `nickname` varchar(64) DEFAULT NULL, `avatar` varchar(255) DEFAULT NULL, `role` tinyint(4) DEFAULT 0 COMMENT '0普通用户 1管理员', `status` tinyint(4) DEFAULT 1 COMMENT '0禁用 1正常', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `deleted` tinyint(4) DEFAULT 0, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里有个关键词:IF NOT EXISTS。它会判断表是否存在,存在就跳过,不存在才创建,配合SpringBoot启动时自动执行,就实现了“自动建表”。不过要注意,这种方式只建表,不会帮你做字段变更,比如你给表加了新字段,旧的建表语句不会自动补上。此时最稳妥的办法是写一个版本化的升级SQL,或者删表重建(仅限开发环境)。

另一个高频坑是驼峰映射。Java实体类用驼峰命名goodsName,数据库字段一般用下划线goods_name,如果没配置映射规则,查出来的数据一直是null。在application.yml里加一行:

mybatis-plus: configuration: map-underscore-to-camel-case: true

这一行是MyBatis的经典配置,打开后自动完成goods_namegoodsName的映射,能省掉无数个@TableField注解。

3. 后端核心模块实现:从登录到下单

3.1 项目初始化与目录分层

后端项目的结构,直接决定这个系统后续能不能维护。我用了最经典的分层结构:

com.fishing.mall ├── controller // 接口层 ├── service // 业务逻辑层 │ └── impl ├── mapper // 数据访问层(MyBatis-Plus Mapper) ├── entity // 实体类 ├── dto // 入参对象 ├── vo // 出参对象 ├── common // 返回结果、异常处理、常量 ├── config // 配置类(拦截器、跨域、文件上传等) ├── utils // 工具类 └── exception // 自定义异常

controller层只做参数接收和结果返回,不写业务逻辑;service层写核心业务,比如下单、扣库存、购物车操作;mapper层连数据库。每一层各司其职,后面加功能改需求时,定位问题会非常快。

依赖方面,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>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.20</version> </dependency>

其中Hutool是一个特别好用的工具类库,生成订单号、ID、加密、日期处理这些都有现成方法,比自己在网上Copy一堆代码片段省心得多。当然,用它的前提是你了解内部实现,面试被问到了不至于答不上来。

3.2 用户登录与JWT认证

前后端分离后,Session这套方案用起来很别扭,因为前端和后端不同端口,跨域环境下服务端的Session不一定能种到浏览器。业界主流做法是用JWT做无状态认证。原理一句话总结:用户登录成功后,服务端生成一个带签名的token返回给前端,前端每次请求在请求头里带上Authorization: Bearer <token>,后端拦截器负责校验、放行或拦截。

登录接口的核心逻辑:

@Service public class UserServiceImpl implements UserService { @Override public String login(LoginDTO dto) { // 1. 根据用户名查用户 LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getUsername, dto.getUsername()); User user = userMapper.selectOne(wrapper); // 2. 校验用户是否存在、密码是否正确 if (user == null || !SecureUtil.md5(dto.getPassword()).equals(user.getPassword())) { throw new BizException("用户名或密码错误"); } // 3. 生成JWT Map<String, Object> claims = new HashMap<>(); claims.put("userId", user.getId()); claims.put("role", user.getRole()); String token = JwtUtil.createToken(claims, 7 * 24 * 3600 * 1000L); return token; } }

密码加密这里用MD5加盐或者BCrypt都可以,建议用BCrypt,spring-security-crypto包里有现成的BCryptPasswordEncoder,安全性比纯MD5高。如果项目里不想引入Spring Security全家桶,单独引这个工具包也行。

生成token之后,需要注册一个拦截器拦截所有需要登录才能访问的请求。拦截器里做两件事:第一,检查请求头里有没有token;第二,解析token,把userId放进ThreadLocal或者放到请求参数里,后面业务代码直接从ThreadLocal拿当前登录用户。

@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (StrUtil.isBlank(token) || !token.startsWith("Bearer ")) { throw new BizException(401, "未登录或登录已过期"); } Claims claims = JwtUtil.parseToken(token.substring(7)); // 把userId放入request,方便后续使用 request.setAttribute("userId", claims.get("userId")); return true; } }

然后在WebMvcConfig里注册拦截器,同时放行登录注册接口和前端静态资源,否则连登录页面都进不去:

@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/user/login", "/api/user/register", "/api/goods/**"); } }

这里/api/goods/**放行是因为商品浏览是公开的,游客也能看。

3.3 商品、购物车与下单接口实现

商品模块是系统最基础的模块。列表页需要一个分页条件查询接口,支持按分类筛选、按关键字搜索、按销量或价格排序。MyBatis-Plus的Page类配合LambdaQueryWrapper写起来非常简洁:

@GetMapping("/page") public Result<IPage<GoodsVO>> pageGoods(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, @RequestParam(required = false) Integer categoryId, @RequestParam(required = false) String keyword) { Page<Goods> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Goods> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(categoryId != null, Goods::getCategoryId, categoryId) .like(StrUtil.isNotBlank(keyword), Goods::getName, keyword) .eq(Goods::getStatus, 1) .orderByDesc(Goods::getSales); IPage<Goods> goodsPage = goodsService.page(page, wrapper); return Result.success(goodsPage); }

购物车模块相对简单,无非是增删改查。这里有一个设计取舍:购物车数据存前端还是存后端?如果你做的是纯演示系统,可以存前端本地,比如Pinia里维护一个数组;但更完整的方案是存后端数据库表,这样用户在手机和电脑上登录,购物车数据是同步的。我采用的是后端的方案,购物车接口按照userId来区分用户。

下单流程是整个系统的核心,也是面试官最爱追问的地方。步骤如下:

  1. 接收前端传过来的商品ID列表和数量。
  2. 循环校验每个商品的库存是否充足。
  3. 生成唯一订单号,规则一般是yyyyMMddHHmmss + 6位随机数,Hutool的IdUtil.getSnowflakeNextIdStr()也可以。
  4. 计算订单总金额,从购物车快照中取价格计算。
  5. 事务性扣减库存。
  6. 保存订单主表和订单明细表。
  7. 删除当前用户购物车中对应商品。

这里必须加上@Transactional注解,保证“扣库存”和“创建订单”是同生共死的原子操作。一旦中间任何一步抛异常,要么全成功,要么全回滚,绝对不能出现订单创建了但库存没扣,或者库存扣了但订单没影了的情况。

@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(Long userId, List<CartItemDTO> items) { // 1. 计算总价 BigDecimal totalAmount = BigDecimal.ZERO; List<OrderItem> orderItems = new ArrayList<>(); for (CartItemDTO item : items) { Goods goods = goodsService.getById(item.getGoodsId()); if (goods == null || goods.getStatus() != 1) { throw new BizException("商品不存在或已下架"); } if (goods.getStock() < item.getCount()) { throw new BizException("商品[" + goods.getName() + "]库存不足"); } totalAmount = totalAmount.add(goods.getPrice().multiply(new BigDecimal(item.getCount()))); // 构造订单项快照 OrderItem orderItem = new OrderItem(); orderItem.setGoodsId(goods.getId()); orderItem.setGoodsName(goods.getName()); orderItem.setGoodsImage(goods.getMainImage()); orderItem.setPrice(goods.getPrice()); orderItem.setCount(item.getCount()); orderItem.setTotalPrice(goods.getPrice().multiply(new BigDecimal(item.getCount()))); orderItems.add(orderItem); } // 2. 生成订单号 String orderNo = IdUtil.getSnowflakeNextIdStr(); // 3. 扣库存、存订单... }

订单状态的流转,我用一个枚举常量类管理,后端接口的职责就是保证状态的合法迁移。比如:已取消的订单不能再支付;已支付的订单不能重复支付;已发货的订单不能取消。这些状态机逻辑虽然在当前项目里不算复杂,但写规范一点,后续接支付、接售后功能时你会感谢自己。

3.4 后台管理模块与文件上传

后台管理是“管理系统”这半边天的重点。功能上主要有商品管理、分类管理、订单管理、用户管理、数据统计。

商品管理里最麻烦的是图片上传。开发阶段先在application.yml里配置本地上传路径:

file: upload-dir: /data/fishing-mall/upload/ access-path: /upload/**

然后写一个上传文件的Controller:

@PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { String originalFilename = file.getOriginalFilename(); String ext = StrUtil.subAfter(originalFilename, ".", true); String newFileName = IdUtil.simpleUUID() + "." + ext; File dest = new File(uploadDir, newFileName); file.transferTo(dest); return Result.success("/upload/" + newFileName); }

再把WebMvcConfig里加一个静态资源映射,让/upload/**的请求能找到本地真实文件:

registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadDir);

上传后的图片URL直接存到商品表的main_image字段里,前端拿到URL就能渲染。

订单管理就是查询订单列表,按状态筛选,管理员对“待发货”的订单执行发货操作,更新发货时间和状态。数据统计的话,后台首页一般要展示几个核心指标:用户总数、商品总数、今日订单数、今日销售额。用SQL聚合一下就能查到:

SELECT COUNT(*) FROM `user` WHERE role = 0; SELECT COUNT(*) FROM `goods` WHERE deleted = 0; SELECT COUNT(*) FROM `orders` WHERE DATE(create_time) = CURDATE() AND status = 2; SELECT IFNULL(SUM(pay_amount), 0) FROM `orders` WHERE DATE(create_time) = CURDATE();

4. 前端Vue实现:打造体验顺滑的商城页面

4.1 环境搭建与工程初始化

前端这块,Vue 3 + Vite是目前最舒服的开发组合。用Vite创建项目特别快:

npm create vite@latest fishing-mall-web -- --template vue cd fishing-mall-web npm install

然后安装核心依赖:

npm install vue-router@4 pinia axios element-plus @element-plus/icons-vue

Element Plus的引入有两种方式:全量引入和按需引入。全量引入适合项目小、图省事的情况,直接在main.jsapp.use(ElementPlus)就完事。但如果你对打包体积有要求,或者面试想秀一手,强烈建议用按需自动导入。安装两个插件:

npm install -D unplugin-auto-import unplugin-vue-components

然后在vite.config.js里配置:

import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig({ plugins: [ vue(), AutoImport({ resolvers: [ElementPlusResolver()] }), Components({ resolvers: [ElementPlusResolver()] }) ], resolve: { alias: { '@': '/src' } }, server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })

这里的server.proxy配置是前后端联调的关键。前端开发服务器跑在5173端口,后端接口跑在8080端口,跨域是必然的。配置代理后,前端请求/api/user/login会自动转发到http://localhost:8080/api/user/login,浏览器看到的请求同源,跨域问题在开发阶段直接规避。

关于按需导入,我要特别提一个经典坑:很多同学按网上的教程配了按需导入,发现组件能用,但ElMessageElMessageBox这种函数式组件一调用就提示ElMessage is not defined,页面直接白屏。原因很简单——ElMessage是函数,不是组件,按需导入插件只自动引入了组件,不会自动引入函数。解决办法是在main.js里手动导入样式,或者用下面这种明确导入的方式:

import { ElMessage } from 'element-plus' import 'element-plus/es/components/message/style/css' ElMessage.success('操作成功')

如果你不想在每一个页面都重复import,可以在main.js里把它挂到全局:

app.config.globalProperties.$message = ElMessage

然后在组件里用this.$message访问(组合式API里用getCurrentInstance()取)。不过说实话,这种全局挂载在Vue 3里不算最优雅,更主流的方式是封装一个工具文件,在需要的地方显式导入。

4.2 前端目录结构与商城页面拆解

前端工程的目录结构我习惯这样组织:

src ├── api // 接口请求封装 │ ├── user.js │ ├── goods.js │ ├── cart.js │ └── order.js ├── assets // 静态资源 ├── components // 公共组件 │ ├── NavBar.vue │ ├── GoodsCard.vue │ └── Pagination.vue ├── router // 路由配置 │ └── index.js ├── store // Pinia状态 │ ├── user.js │ └── cart.js ├── views // 页面组件 │ ├── home │ ├── goods │ ├── cart │ ├── order │ ├── user │ └── admin ├── utils // 工具 │ ├── request.js // axios封装 │ └── auth.js ├── App.vue └── main.js

商城前台页面,核心就那么几个:

  • 首页:轮播图 + 热门商品推荐 + 分类快捷入口。
  • 商品列表页:左边分类树,中间商品卡片列表,顶部搜索框。配合分页组件。
  • 商品详情页:展示商品主图、价格、销量、规格参数、详情图文,点击“加入购物车”或“立即购买”。
  • 购物车页:勾选商品、修改数量、删除、全选,底部实时计算选中商品总价。
  • 结算页:填写收货地址,选择配送方式,提交订单。
  • 订单列表页:展示订单状态,待付款的可以模拟支付,待收货的可以确认收货。
  • 登录/注册页:账号密码登录,注册后自动登录。
  • 后台管理页:商品管理表格、分类管理、订单管理,通常做成一个侧边栏布局,用router-view切换子页面。

首页和商品列表页的数据,直接调后端的公开接口。比如在src/api/goods.js里:

import request from '@/utils/request' export function getGoodsPage(params) { return request({ url: '/api/goods/page', method: 'get', params }) }

axios封装是前端工程质量的关键一环。统一处理baseURL、超时时间、请求头、错误码:

// utils/request.js import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/', timeout: 10000 }) // 请求拦截器:自动携带token 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 res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') if (res.code === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(new Error(res.message)) } return res }, error => { ElMessage.error(error.message || '网络异常') return Promise.reject(error) } ) export default request

4.3 商品筛选与购物车交互细节

商品筛选这块,前端要处理“分类选中状态”和“搜索关键字”这两个查询条件。我建议直接用路由参数来驱动,也就是把categoryIdkeyword放到URL的query里,比如/goods?categoryId=3&keyword=路亚。这样做好处非常明显:详情页跳回列表页时,条件还在;刷新页面时,条件不丢失;用户还能直接分享一个带筛选条件的链接。

组件里用watch监听路由变化,变化时重新拉取数据:

import { useRoute } from 'vue-router' import { ref, watch } from 'vue' const route = useRoute() const goodsList = ref([]) const total = ref(0) const loadGoods = async () => { const params = { pageNum: currentPage.value, pageSize: 12, categoryId: route.query.categoryId || undefined, keyword: route.query.keyword || undefined } const res = await getGoodsPage(params) goodsList.value = res.data.records total.value = res.data.total } watch(() => route.query, loadGoods, { immediate: true })

购物车页的面积相对复杂,因为涉及到“选中状态”和“总价计算”。我推荐把购物车数据存储在Pinia里,页面组件只负责渲染和派发action。核心的state结构大致如下:

// store/cart.js import { defineStore } from 'pinia' export const useCartStore = defineStore('cart', { state: () => ({ items: [], // 购物车商品列表 checkedIds: [] // 勾选的商品id集合 }), getters: { checkedItems: (state) => state.items.filter(item => state.checkedIds.includes(item.id)), totalPrice: (state) => { const checked = state.items.filter(item => state.checkedIds.includes(item.id)) return checked.reduce((sum, item) => sum + item.price * item.count, 0).toFixed(2) }, cartCount: (state) => state.items.length }, actions: { async fetchCart() { const res = await getCartList() this.items = res.data this.checkedIds = this.items.map(item => item.id) }, async removeItem(id) { await removeCartItem(id) this.items = this.items.filter(item => item.id !== id) this.checkedIds = this.checkedIds.filter(checkedId => checkedId !== id) } } })

“立即购买”和“从购物车结算”这两条购买路径,前端走到最后都是跳转到同一个结算页。下单接口收到的参数统一是“商品ID+数量”的数组。区别在于,购物车结算前会把对应的购物车记录查出来传过去,立即购买则用当前商品构造一条数据。下单成功后的回调,前端要做的第一件事就是清空本地购物车状态,然后跳转到“我的订单”页面。

4.4 Vue 3选项式与组合式API的取舍

Vue 3发布后,很多人纠结写选项式还是组合式。我的建议很明确:新项目直接写组合式(<script setup>),原因很简单——组合式API把“同一业务逻辑”的代码聚在一起,而不是按datamethodscomputed这些选项强行分段。比如一个商品列表页,跟筛选相关的状态、请求方法、监听器、计算属性,在组合式API里可以写在一块,阅读和维护都轻松。

举一个简单的对比:

选项式的写法:

export default { data() { return { goodsList: [], loading: false } }, methods: { async loadGoods() { this.loading = true const res = await getGoodsPage() this.goodsList = res.data.records this.loading = false } }, mounted() { this.loadGoods() } }

组合式的写法:

<script setup> import { ref, onMounted } from 'vue' import { getGoodsPage } from '@/api/goods' const goodsList = ref([]) const loading = ref(false) const loadGoods = async () => { loading.value = true try { const res = await getGoodsPage() goodsList.value = res.data.records } finally { loading.value = false } } onMounted(loadGoods) </script>

两者实现的功能一样,但组合式写法把相关逻辑聚合成一个loadGoods函数,一旦页面上有多个业务模块,比如“商品列表、分类筛选、购物车角标”,它们之间的代码分组会清晰很多。面试时如果被问到“选项式和组合式区别”,核心要答到两点:一是逻辑复用的方式(组合式可以用自定义hook,选项式依赖mixin),二是代码组织方式(按选项划分 vs 按业务逻辑划分)。

5. 前后端联调、部署上线与高频问题排查

5.1 跨域问题:开发与生产阶段的两种解法

跨域是前后端分离项目里第一个“见面礼”。开发阶段,用Vite的proxy代理解决就行,前面已经展示过。这里再强调一下:proxy只对开发服务器生效,打包成静态文件部署在Nginx上时,浏览器依然会面临跨域。

生产环境的解决方案是在Nginx里配置反向代理,把前端静态资源和后端接口放在同一个域名下:

server { listen 80; server_name your-domain.com; # 前端静态文件 location / { root /var/www/fishing-mall; index index.html; try_files $uri $uri/ /index.html; # 关键:解决history路由刷新404 } # 后端接口反向代理 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; } }

这里最关键的是try_files $uri $uri/ /index.html。Vue Router如果用了history模式(默认的createWebHistory),打包后直接放在服务器上,刷新页面时Nginx会去按路径找文件,结果找不到就报404。加上这行配置,所有找不到的路径都会回退到index.html,由前端路由接管,问题迎刃而解。

当然,如果你后端项目里配置了全局CORS(@CrossOriginWebMvcConfigurer里加CorsFilter),那从浏览器直连接口也是可以的。但生产环境走Nginx反代是更规范、更安全的做法,还方便统一做HTTPS证书、负载均衡。

5.2 打包部署的完整流程

部署这件事,听起来是最后一步,但非常考验实操能力。我给出一个前端后端分离部署的参考流程。

后端打包:

mvn clean package -DskipTests

打出来的jar包加上SpringBoot内嵌的Tomcat,直接扔到服务器上:

java -jar fishing-mall-0.0.1.jar --server.port=8080

也可以用nohup让进程常驻后台:

nohup java -jar fishing-mall-0.0.1.jar > logs/run.log 2>&1 &

前端打包:

npm run build

执行完会在项目根目录生成dist文件夹,把里面的所有文件上传到服务器上,放到/var/www/fishing-mall就行。整个部署架构就是:Nginx托管静态页面 → 浏览器发起请求 → Nginx把/api开头的请求转发给Java进程 → Java处理业务返回JSON → 前端渲染。

如果你只是想快速演示给老师看,不想买服务器,也可以前后端打成一个包。把前端dist目录里的文件复制到SpringBoot项目的src/main/resources/static目录下,重新打包后端。这样访问http://localhost:8080就能直接看到前端页面,接口走同源。这种方式省了Nginx,但每次前端更新都要重新打一次后端包,后续维护不太方便,适合终期演示用。

5.3 高频问题排查速查表

我把这个项目开发过程中,自己和身边同学遇到最多的问题整理了一下,做成一个速查表,方便你对照排查。

现象可能原因解决方法
前端访问接口报CORS错误开发环境没有配置proxy,或生产环境Nginx没有反代vite.config.js里配置server.proxy;生产用Nginx配置location /api
登录接口能通,业务接口401拦截器拦截了请求,token没带或已过期检查axios请求拦截器是否设置Authorization头;检查token过期时间
数据库连接失败/时区报错URL缺少时区参数或驱动版本不匹配URL加?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8
商品列表查出来字段全是null驼峰映射没开启application.yml配置map-underscore-to-camel-case: true
ElMessage报未定义Element Plus按需导入没处理函数式组件手动import { ElMessage } from 'element-plus'并引入对应样式
刷新页面404Vue Router用了history模式,服务器没配置回退Nginx加try_files $uri $uri/ /index.html
下单成功后购物车还在下单成功后前端没有清空本地购物车状态下单成功回调里调用cart store的clear方法
图片上传成功但前端不显示静态资源映射没配置WebMvcConfig里addResourceHandlers配置/upload/**映射到本地目录
SpringBoot启动时端口被占用8080被其他进程占用换端口或者杀掉占用进程,Windows上netstat -ano查PID
npm install超时npm源在国外,网络不稳定设置淘宝镜像npm config set registry https://registry.npmmirror.com
MySQL 8连接报Public Key Retrieval异常MySQL 8的caching_sha2_password认证问题JDBC URL加allowPublicKeyRetrieval=true&useSSL=false

这些问题里,数据库时区和ElMessage未定义是最常见的,差不多占了所有报错的三分之一。遇到问题别慌,先看报错信息里的关键提示,再对照表里的排查方向一步步走,90%都能自己解决。

5.4 上线前的安全与性能基础检查

系统做完能跑,不代表可以放心上线。几个基础的安全和性能检查,做完了心里才有底。

第一,密码不要明文存库。至少要做到BCrypt加密,或者用加盐MD5。如果数据库泄露了,明文密码等于把所有用户的账号拱手送人。这点在问老师演示的时候是加分项,在真实生产环境里是保命项。

第二,接口要做好参数校验。下单时商品数量不能是负数,不能是超大数字,购物车商品ID必须是真实存在的。可以用@Validated注解配合@NotNull@Min这些校验注解,在进入Service层之前就拦掉非法参数。

第三,数据库连接池别用默认值。SpringBoot默认的HikariCP参数适合开发环境,生产环境下建议显式配置最大连接数、连接超时时间。比如:

spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000

第四,后端接口不要在响应里返回日志敏感信息。异常处理类里,对外的异常信息要友好,但堆栈信息要记到日志文件里,尤其是SQL异常,有的会直接把表结构带出来。

第五,上传文件要做类型和大小校验。后端校验文件扩展名,限制大小(比如不超过5MB),存储文件名用UUID重命名,不要用原始文件名。这样能规避掉相当一部分上传漏洞。

这些内容不复杂,但能体现一个开发者的工程素养。答辩或者面试时主动说出这些点,比你背十道八股文都管用。

最后再分享一点我的体会

做完这个渔具商城项目,我个人最大的感受是:一个系统的难点,往往不在某个单一技术栈,而在各个模块之间的衔接。前端Vue写得再花哨,后端接口设计不合理,联调时照样寸步难行;后端业务逻辑再严谨,前端状态管理一团乱麻,用户操作时照样出现各种诡异bug。

比如购物车的“勾选状态”,看似是前端的小事,但实际上如果Pinia里的checkedIds和后端购物车表没有对应关系,结算时的“已选商品”就会出错。再比如订单状态,前后端各写一套状态常量,如果值对不上,就会出现前端显示“待发货”而后端实际是“待付款”的尴尬局面。所以做这类系统时,我强烈建议先把接口文档定好,状态的枚举值、请求参数的字段名、响应的结构,前后端统一约定,后面能省掉80%的扯皮时间。

后续如果你想在这个项目上继续扩展,方向还挺多的:可以对接真实的支付沙箱环境,把模拟支付替换成真正的扫码支付;可以给商品模块加一个库存预警功能,低于阈值自动提醒店主补货;也可以把Redis引入进来,做热门商品的缓存、购物车的临时存储。渔具垂直电商这个领域虽然不算大,但围绕钓鱼人群可以做会员体系、钓场预约、二手渔具交易,每一个方向都是很好的练手机会。先把这套基础打牢,后面怎么玩都有底气。

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

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

立即咨询