先交代个背景:这套“二次元商品销售系统”是我去年帮人完成的一个毕设项目,编号 48547,需求就是做一个面向二次元爱好者的电商平台,商品以手办、扭蛋、周边、谷子、cos 服为主。技术栈以 Spring Boot 为核心,前端用的 Vue,数据库 MySQL,属于典型的“后端为主、前端辅助”的毕设结构。带过不少学生的经验是,这个题目非常适合用来做毕业设计:业务链条完整——用户、商品、购物车、订单、支付流程模拟、后台管理全都有;技术栈主流——Spring Boot 本身就占了热搜词半壁江山,部署起来又比分布式微服务简单太多;再加上二次元题材的页面效果好做,答辩演示时视觉冲击力强,老师印象分普遍不错。
这篇就按我开发这个项目时的实际过程来讲,从需求拆解、技术选型、数据库设计、核心功能实现到部署避坑,全部走一遍。如果你想拿这类题目做毕设,或者想从零熟悉一遍 Spring Boot 电商类项目的完整开发流程,这篇可以直接照着“抄作业”。
1. 项目需求拆解与技术选型:为什么是 Spring Boot + Vue
1.1 核心需求梳理:一个二次元商品销售系统要管哪些事
拿到这个题目的第一件事,不是急着写代码,而是把需求彻底理清。很多毕设翻车的根源就在于“需求是老师一句话,实现却是一整年”。我习惯用“前台 + 后台”的拆分方式去梳理。
前台就是买家能看到的全部内容,核心是两条链路:逛商品 → 下订单 → 模拟支付 → 查订单状态。逛商品这一步要支持分类筛选(手办、扭蛋、周边挂件、徽章盲盒等),支持搜索、分页、查看商品详情;下订单则需要有购物车介入,用户可以勾选多个商品批量结算,也可以直接购买单件;下单之后要生成订单,包含订单编号、商品快照、收货地址、总金额;支付环节虽然是模拟的,但订单状态要跟着变化——从待付款流转到待发货,再到待收货、已完成。前台还需要用户注册登录、个人信息管理、收货地址维护这几个基础功能。
后台是管理员使用的管理端,需求更直接:商品管理(增删改查、上下架、库存修改)、订单管理(查看订单列表、修改订单状态、发货操作)、分类管理(维护商品分类树)、轮播图管理(首页推荐位)、以及基础的数据统计功能,比如销量排行和商品总数看板。
我建议任何准备做同类型系统的人都先用一晚上把需求写成这样的清单,然后在边上标注“哪些功能必须做、哪些可以后期再补”。比如站内搜索、评价体系这类功能就可以先放一放,把订单主流程跑通才是地基。我的经验是:毕设的评分核心永远是“主流程完整度 + 技术含量”,而不是功能数量。
1.2 技术栈选型对比:为什么不是传统 JSP,而是前后端分离
毕设中做 Web 系统的主流方案大概有三个档位:传统 JSP + Servlet + JDBC、Spring Boot 单体 + Thymeleaf 或 JSP、以及 Spring Boot + Vue 的前后端分离架构。
我的选择是第三档,理由很现实:第一,Spring Boot 是整个 Java 后端招聘市场的事实标准,面试官几乎必问,题目里有 Spring Boot 这个词就天然扣住了技术热点;第二,前后端分离可以让代码结构更清晰,前端静态资源打包后可以直接塞进 Spring Boot 的静态目录,部署反而更简单;第三,Vue 的组件化开发在写购物车、商品列表这种交互密集页面时效率碾压服务端渲染,二次元商品卡片需要大量图片和效果展示,前端分离做出来的视觉质量也明显更高。
有同学会担心“我不会前端怎么办”——我实话实说,Vue 不需要学得多深,能写清楚组件、调通 axios 接口、会用 Element Plus 按钮和表格组件就足够了,毕设前端有一个“能看的水平”完全够用。
具体技术栈清单如下:
| 层次 | 技术选型 | 用途说明 |
|---|---|---|
| 后端框架 | Spring Boot 2.7.x | 项目核心框架,内嵌 Tomcat |
| ORM | MyBatis Plus | 简化单表 CRUD,自带分页插件 |
| 数据库 | MySQL 8.0 | 存储业务数据 |
| 缓存与登录态 | Redis + JWT | Redis 缓存热数据,JWT 做无状态登录 |
| 前端框架 | Vue 3 + Vite | SPA 单页应用 |
| UI 组件 | Element Plus | 后台管理界面 |
| 前后端交互 | Axios | HTTP 请求封装 |
| 工具类 | Hutool、Lombok | 简化开发 |
Spring Boot 版本选 2.7.x 而不是 3.x 是刻意为之的,原因我在后面“版本避坑”里细讲,这里先记住这个选择。
2. 数据库设计与核心表结构:从用户到订单的完整建模
2.1 业务建模:七张核心表把电商主链路撑起来
数据库设计是这类项目最容易暴露水平差的环节。我见过很多同学把订单明细表里存了一堆冗余字段,结果统计销量时没法用;也见过商品表里没有库存字段,导致下单减库存只能写死在代码里。好的表结构应该一眼能看出业务闭环。
我的设计是七张核心表:用户表(user)、商品分类表(category)、商品表(product)、购物车表(cart)、订单表(orders)、订单明细表(order_item)、收货地址表(address),另外可选一张轮播图表(banner)。
表关系不复杂:用户 1 对 N 购物车记录,1 对 N 收货地址,1 对 N 订单;订单 1 对 N 订单明细;分类 1 对 N 商品;商品 1 对 N 订单明细(实际上是通过订单明细里的商品快照字段来做冗余)。注意,订单明细不应该直接外键关联商品表,因为商品价格和名称都是会变的,下单那一刻的信息必须快照下来,否则用户查看历史订单时看到的是“当前价格”,这在业务上是错误的。
2.2 关键表字段设计:库存扣减与订单快照的思路
商品表是整个系统的核心,字段设计建议如下:
CREATE TABLE `product` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '商品ID', `category_id` bigint(20) DEFAULT NULL COMMENT '分类ID', `name` varchar(100) NOT NULL COMMENT '商品名称', `cover_image` varchar(255) DEFAULT NULL COMMENT '商品封面图', `images` text COMMENT '商品轮播图,逗号分隔', `description` text COMMENT '商品详情', `price` decimal(10,2) NOT NULL COMMENT '售价', `original_price` decimal(10,2) DEFAULT NULL COMMENT '原价(划线价)', `stock` int(11) NOT NULL DEFAULT 0 COMMENT '库存', `sales` int(11) NOT NULL DEFAULT 0 COMMENT '销量', `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '上架状态:1上架,0下架', `is_hot` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否热门推荐', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个字段的设计意图要理解到位:cover_image是列表页用的小图,分辨率低加载快;images是详情页轮播的大图,用逗号拼接多张;price和original_price分离,是为了做“划线价 + 折扣”的视觉促销效果,这是电商常见玩法;stock和sales分别代表剩余库存和累计销量,每次下单成功必须同时执行stock-1和sales+1,统计热门商品时直接按sales倒序即可,不需要连表聚合。
订单主表的核心在状态字段order_status。我约定:0 待付款、1 待发货、2 待收货、3 已完成、4 已取消。这个状态机在前后端都要保持一致,建议在前端用一个独立 JS 文件枚举映射,避免魔法数字散落各处。
订单编号不能直接交给数据库自增,否则太难看且容易暴露系统单量,业务上一般用“时间戳 + 随机数”或者 Snowflake 算法。毕设场景我用的是yyyyMMddHHmmss加三位随机数,格式类似20250615203015502,可读性强,也不会出并发重复。订单明细表里则必须冗余商品的名称、图片、单价、数量、小计金额五个快照字段,理由刚才说过——历史订单不能被商品后续改价影响。
购物车表要加一个checked字段,表示这条购物车记录是否被勾选结算。这个字段很多同学会忽略,导致“批量结算”功能做不了,前端只能一个商品一个商品下单。虽然是小字段,但用户在二次元周边购物场景里习惯一次性买多个扭蛋或吧唧,没这个字段体验很差。
3. 实操环节:从零跑通一个可交付的毕设项目
3.1 环境准备与项目初始化:JDK、Maven、MySQL 的版本坑
先说环境,这块是最没技术含量但最容易卡住人的环节。我的推荐组合是:JDK 8 或 JDK 11 + Maven 3.6.3 + MySQL 8.0 + Spring Boot 2.7.x + Redis 6.x,这个组合兼容性最稳。
为什么不推荐 JDK 17 甚至 JDK 21?因为 Spring Boot 2.7.x 本身最高支持到 JDK 17,但很多同学下载的 MyBatis Plus、MySQL 驱动、某些第三方工具类在 JDK 17 下的反射机制会出诡异问题,排查起来比毕设本身还费时间。毕设追求的是“稳定跑通”,不是“最新版本”,工具链的成熟度远比新奇感重要。如果你非要用 JDK 17,那就必须搭配 Spring Boot 3.x,但 3.x 的javax包全部换成了jakarta,网上资料和能找到的现成代码大部分还是 2.x 的写法,遇到问题很难搜到答案——这就是我选 2.7.x 的核心原因。
初始化工程建议直接用 IDEA 的 Spring Initializr,依赖选择 Spring Web、Spring Data Redis、MyBatis Framework、MySQL Driver、Lombok。注意 Spring Initializr 默认生成的 MyBatis 依赖是“Spring Data MyBatis”还是“MyBatis”,一定要选后者的mybatis-spring-boot-starter版本,否则配置@MapperScan时会出现完全找不到接口的怪问题。
工程结构建议采用经典四层分包:
com.second.shop ├── controller // 接口层 ├── service // 业务逻辑层 ├── mapper // 数据访问层 ├── entity // 实体类 ├── config // 配置类(跨域、拦截器、MyBatis Plus) ├── common // 通用返回结果、异常处理、常量 └── utils // JWT、订单号生成等工具这样分包的好处是代码职责清楚,答辩的时候被问“分层设计的优点”可以直接答出解耦、可维护性高、每一层可以单独做单元测试——标准答案,人人都会夸。
3.2 核心接口实现:商品列表、购物车、下单减库存的完整链路
商品分页列表是最基础也最常被问的接口,直接用 MyBatis Plus 的分页插件:
IPage<Product> page = new Page<>(current, size); LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Product::getStatus, 1) .eq(StringUtils.isNotBlank(categoryId), Product::getCategoryId, categoryId) .like(StringUtils.isNotBlank(keyword), Product::getName, keyword) .orderByDesc(Product::getSales); IPage<Product> result = productMapper.selectPage(page, wrapper);注意wrapper.eq(condition, column, value)这个重载的用法,condition为false时该条件不会拼接进 SQL,能省掉大量if判断,代码非常干净。分页插件要在配置类里注册:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }登录实现用的是 JWT 无状态方案,核心逻辑是:用户登录成功后生成 token,前端存在 localStorage 中,每次请求通过Authorization: Bearer <token>头带给后端,后端用一个拦截器解析 token 并从中取出 userId,然后存入ThreadLocal或直接放进 Controller 方法参数里。拦截器注册时注意排除登录注册接口:
registry.addInterceptor(jwtInterceptor) .addPathPatterns("/**") .excludePathPatterns("/api/user/login", "/api/user/register", "/api/product/**", "/api/category/**");下单是整个系统里最敏感的操作,涉及多张表的联动修改,必须加上事务。我的标准流程是:
@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(OrderCreateDTO dto) { // 1. 生成订单号 String orderNo = generateOrderNo(); BigDecimal totalAmount = BigDecimal.ZERO; // 2. 遍历勾选的购物车商品,校验库存并扣减 for (CartItem cartItem : checkedItems) { int rows = productMapper.reduceStock(cartItem.getProductId(), cartItem.getQuantity()); if (rows == 0) { throw new BusinessException("商品 [" + cartItem.getProductName() + "] 库存不足"); } // 3. 构建订单明细快照 // 4. 累计总金额 } // 5. 创建订单主记录,状态置为待付款 // 6. 清空购物车中已结算的记录 return orderVO; }注意两点:第一,reduceStock必须是一条条件更新的 SQL,而不是“先查库存再判断再更新”的三步操作,因为有并发时两步操作会超卖;第二,@Transactional要加在rollbackFor = Exception.class,因为 Spring 默认只对运行时异常回滚,如果你在业务里抛的是自定义的throws Exception就不会回滚,这是毕设里很隐蔽但致命的坑。
3.3 前端联调与上线部署:Vue 打包放进 Spring Boot 的两种方式
Vue 项目开发阶段是单独的localhost:5173端口,通过 Vite 的 proxy 代理转发/api请求到后端8080:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }但部署阶段不能要求每个看项目的人都再启一个前端服务,所以标准方案是把前端打包成静态文件交给 Spring Boot 统一托管。Vue 项目执行npm run build之后会生成dist目录,里面是index.html和一堆 JS/CSS 文件。
第一种做法(推荐):直接把dist目录里的所有文件拷贝到src/main/resources/static下,Spring Boot 启动后访问localhost:8080就是前端首页,接口和页面同源,完全不存在跨域问题。听起来简单,但有一个大坑:如果 Vue 路由用的是createWebHistory模式,直接刷新浏览器某个子路由(比如/orders)就会因为后端没有对应路径而报 404。解决办法有两个:要么路由改用createWebHashHistory模式(URL 带#,虽然不好看但最稳),要么在后端加一个WebMvcConfigurer重写addViewControllers,把“非/api开头的路径”全部转发到index.html。
第二种做法:前后端完全分离部署,后端 CORS 全开,前端独立跑在 nginx 上。这种方式对毕设来说过于复杂,而且答辩时老师如果用你的电脑演示,多一个 nginx 进程就多一份不稳定性。直接推荐第一种。
关于图片存储,我强烈建议不要往数据库里写 Base64 图片,而是把上传的图片存到本地磁盘目录,再通过虚拟路径映射对外提供访问:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String uploadPath = System.getProperty("user.dir") + File.separator + "upload" + File.separator; registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath); }商品表里的图片字段存/upload/hand-001.jpg这种相对路径,前端图片域名 + 路由拼完整 URL,这样换环境部署时只需要改一处配置。如果图省事把图片和代码打成一个大 jar 包,之后每次换电脑部署都要重新传一遍大文件,吃亏的是自己。
4. 毕设开发中的常见问题与避坑实录
4.1 前后端联调常见报错:跨域、接口 404 与刷新路由问题
前后端分离开发时,“接口调到怀疑人生”的基本全是这三类问题。第一,跨域报错——浏览器直接拦截请求,提示Access-Control-Allow-Origin。解决办法是在 Spring Boot 里加一个CorsFilter,不要用 Controller 注解式配置,因为全局过滤器才能覆盖所有路径:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }但注意,如果你采用“前端打进 Spring Boot 静态目录”的同源部署方式,跨域配置根本不会触发,所以这个类只在开发阶段有用,部署阶段也算是一种兜底。
第二,接口 404。先确认后端接口路径是/api/product/list,前端 axios 请求的也是/api/product/list,然后确认前端请求有没有经过代理或是不是打到了别的端口。排查方式是:打开浏览器 F12,看网络请求的实际 URL 和响应状态码,然后在后端控制台看有没有对应的请求日志,没有日志说明请求根本没到后端,问题在前端地址;有日志还 404,那问题就在后端路径或映射方法上。
第三,页面刷新 404。就是上一节说的 Vue history 路由问题,最省心的方案是直接用 hash 模式,虽然 URL 里多个#不太好看,但毕设追求的是稳定,不是好看。
4.2 部署阶段的大坑:端口被占用、Redis 没启动导致启动失败
Spring Boot 项目部署到别人电脑上时,最常见的问题是一启动就报端口被占用,用server.port=8080的配置去启动,但本机如果跑着别的服务占用了 8080,就会直接启动失败。排查命令是netstat -ano | findstr 8080(Windows)或者lsof -i:8080(Mac/Linux),找到占用进程后要么关掉,要么改端口。
另一个很容易忽视的问题:如果你的代码里配置了 Redis 连接,但本机没有启动 Redis 服务,Spring Boot 启动时会因为连接不上 Redis 而直接失败。最坑的是,有些代码是在过滤器里实例化 RedisTemplate 的时候才报错,控制台信息很乱,新手根本无法定位。我的建议是:毕设项目如果不想引入额外部署成本,Redis 用不用要想清楚。我最后实际交付的版本里只把 Redis 用来做“首页热门商品缓存”,即使 Redis 挂了,也有降级逻辑兜底——在查询商品列表时 try-catch,catch 到 Redis 异常直接走数据库查询。加了这个降级逻辑后,演示时即使忘开 Redis,系统也不会挂掉。
4.3 库存丢失与订单重复:并发环境下的三条铁律
电商类毕设最容易被老师追问的就是并发场景,“如果两个用户同时买最后一个手办,你会不会超卖?”你如果回答“不会,因为加了同步锁”,面试官再追问一句“多实例部署时同步锁还有效吗”你就会卡壳。标准的正确姿势是以下三条。
第一条,数据库层面做条件更新,不让超卖发生:
UPDATE product SET stock = stock - #{quantity}, sales = sales + #{quantity} WHERE id = #{productId} AND stock >= #{quantity}这条 SQL 利用行锁和原子更新保证并发安全,受影响行数为 0 就代表库存不足回滚业务。第二条,禁止在事务里做“查库存 → 判库存 → 更新”的三步操作,因为这是检查-更新竞态,一定会超卖。第三条,事务边界要小,只在涉及写操作的方法上加事务,不要在 Controller 层做事务控制,否则连接占用时间过长拖垮性能。
这种“先验库存再扣库存”问题如果不处理,答辩时讲出来后老师会觉得你的系统“没有考虑真实场景”,分数直接下降一个档位。我建议哪怕代码里已经写了,也要把这几句话背熟,这是电商标配知识点。
4.4 Spring Boot 版本冲突与 IDEA 启动配置问题速查
毕设群里每天都会有人问“为什么我这里启动就报错,别人那里就正常”,大部分情况都是版本踩坑。我把高频问题整理成表:
| 症状 | 根本原因 | 解决方式 |
|---|---|---|
java.lang.NoClassDefFoundError | JDK 版本过高,部分旧库不兼容 | 换回 JDK 8/11,调整 Project Structure 的 SDK |
Failed to configure a DataSource | 缺少数据源配置 | 检查application.yml的 url、username、password |
Consider defining a bean of type 'com.xx.Mapper' | @MapperScan没扫到包路径 | 检查 Mapper 接口的包路径与启动类上的@MapperScan是否一致 |
Invalid character found in method name | MySQL 连接 URL 编码问题 | url 加?useUnicode=true&characterEncoding=utf8 |
| Redis 启动即报连接失败 | Redis 服务未启动或端口不对 | 先带redis-cli ping验证,确认返回 PONG |
| IDEA 启动时端口明明是 8080 却进了另一个页面 | 浏览器缓存了旧页面 | Ctrl+F5 强刷或者开无痕窗口验证 |
JWT 工具类报jjwt签名错误 | jjwt 版本和 JDK 不匹配 | 统一用 jjwt 0.9.1,明确指定 JDK 8 |
其中一个非常容易混淆的点是:IDEA 的SpringBootApplication启动类右上角出现红色警告“Spring Boot configuration annotation processor not found”。这只是 IDEA 的注解处理器提示,不代表项目有问题,只要加了spring-boot-configuration-processor依赖就不会再出现。如果项目已经能正常跑,不用理会这个问题,别浪费时间。
另外 IDEA 里修改启动端口是在resources/application.yml中改server.port,改完重启即可生效,不要在 Edit Configurations 的“VM options”里乱加参数,除非你想临时覆盖端口。热部署可以加一个spring-boot-devtools依赖,改了代码会自动重启,毕设开发效率能提升很多,但这个依赖打包的时候不会打进 jar 包里(provided作用域),所以不太影响交付。
写在最后的几点实战体会
这类“Spring Boot 二次元商品销售”毕设项目,我前后带人做过三轮,每一轮都在踩坑和改进。最大的体会是:代码能不能跑通往往不是最大的问题,真正的分水岭在于对主流程的设计思考——库存怎么扣、订单状态怎么流转、前后端怎么约定接口、部署时哪些环节可能挂掉。
还有一个很容易被忽视的加分项,就是做好项目答辩前的“破坏性测试”:把 Redis 停掉跑一遍首页、把端口改掉重新启动、清空数据库重新初始数据、用两个浏览器账号同时下一个商品试试会不会超卖。把这些场景亲手走一遍,要么能发现隐藏 bug,要么能让你在答辩时对每个环节都心里有底。
最后一个小技巧:交付源码时,除了代码,建议写一份“环境要求 + 启动步骤”的 README,把 JDK 版本、MySQL 初始化脚本、Redis 是否必须启动、前端是否需要重新 build 都写清楚。这套东西平时看着不起眼,但到了别人打开你项目的时候,它就是帮你省下几十条聊天消息的保命符。