要说毕业设计选什么方向,电商系统真的是计算机专业里永远不会过时的选题。但正因为做的人多,老师的要求也水涨船高——光是个CRUD页面根本交不了差。我做了一个基于SpringBoot的眼镜销售网站,就是在线眼镜商城,从选题、设计到最终写完代码,完整走下来差不多两个月。这篇文章把整个项目的思路、核心流程、踩过的坑一次性讲清楚,给正在头疼毕设的同学一个能直接落地的参考。
这个项目能解决什么问题?简单说,它不是一个“玩具项目”,而是覆盖了电商业务主链路的一整套系统:商品展示、用户登录、购物车、订单、支付模拟、后台管理,再配合Redis缓存、JWT鉴权、拦截器这些在企业开发里真正用得上的技术点。无论是做毕设、准备校招项目经验,还是想学SpringBoot整合实战,这套东西都够用。
1. 项目整体设计与技术选型
1.1 为什么用SpringBoot而不是SpringMVC
这是很多同学会纠结的第一个问题。我在做之前也很犹豫,学校课程教的是SSM框架,XML配置写了一堆。但真到做项目的时候,SpringBoot的优势太明显了——它把配置简化到了极致,原来SSM里要配一大堆bean、扫描器、视图解析器,SpringBoot一个@SpringBootApplication注解全搞定。
我当时的选型思路是:
- 基础框架:SpringBoot 2.7.x(JDK 1.8兼容性最好,3.x需要JDK17,学校老师环境未必支持)
- 持久层:MyBatis-Plus(比原生MyBatis省太多事,BaseMapper自带CRUD,分页插件一行搞定)
- 前端:Vue2 + Element UI(前后端分离是加分项,答辩时能多说几句亮点)
- 数据库:MySQL 8.0(存储过程、事务支持都成熟)
- 缓存:Redis(做验证码存储、热数据缓存、购物车临时存储)
- 鉴权:JWT(无状态登录,适合前后端分离)
这个组合的好处是:技术栈没有特别偏门的东西,老师看着熟悉;但每一个都在实际项目里用得着,问到也能答得上来。
1.2 功能模块怎么拆分
我把系统拆成了两个大端:前台商城和后台管理。
前台商城面向普通用户,包含这七个模块:
- 用户模块:注册、登录、个人信息维护、收货地址管理
- 商品模块:眼镜商品分类浏览、关键字搜索、商品详情、库存展示
- 购物车模块:加入购物车、修改数量、删除、勾选结算
- 订单模块:确认订单、选择地址、提交订单、订单列表、取消订单
- 支付模块:模拟支付(对接真实支付需要资质,毕设用模拟流程即可)
- 评价模块:订单完成后对商品进行评价打分
- 优惠券模块:领取优惠券、下单抵用(这个模块属于锦上添花,但加上之后整个项目的完整度一下子拉高)
后台管理面向管理员,包含:
- 商品管理:商品上架下架、库存调整、价格修改、图片上传
- 订单管理:订单列表、订单状态流转(待付款→已付款→已发货→已完成/已取消)
- 用户管理:用户列表、状态禁用
- 数据统计:简单销售额统计、热门商品Top10
模块拆分的逻辑是按电商业务链路走的:商品→用户→购物车→订单→支付→评价。答辩的时候老师一定会问模块划分依据,用这条业务链路来回答最顺。
1.3 技术选型背后的成败点
说实话,选型这一步决定项目最终能做到什么程度。我见过太多人一上来就上Spring Cloud微服务,结果被各种分布式问题折磨到放弃。毕设项目的核心是“完整”和“能跑”,单机单体架构完全够用,重点精力应该放在业务逻辑的完整性和代码质量上。
另外,JDK版本千万别选太新的。我当时用JDK 17配SpringBoot 2.7遇到一堆兼容性问题,后来老实切回JDK 1.8,一路顺畅。毕设图的是稳定,不是新潮。
2. 数据库设计与核心实现细节
2.1 数据表怎么设计才不被老师挑毛病
数据库设计是答辩必问的重头戏。我当时做了9张核心数据表,每张表的字段都考虑了实际业务需要,而不是凭空定义。
用户表(user):id、username、password(MD5加密存储)、phone、email、avatar、status(0正常1禁用)、create_time。密码加密这一点必须做,如果密码明文存储,老师会直接判定安全意识不合格。
商品表(product):id、product_name、category_id(关联分类表)、description、price、stock、cover_image、sales_count(虚拟销量)、status(0下架1上架)。
分类表(category):id、category_name、parent_id(支持二级分类,比如“太阳镜”→“男士太阳镜”)、sort_order。
购物车表(cart_item):id、user_id、product_id、quantity、checked(是否勾选)、create_time。这里有个细节:购物车数据如果用数据库存,那Redis就少了一个发挥空间。我实际是数据库为主、Redis做缓存,这样既稳定又能在答辩时展示Redis使用场景。
订单表(order):id、order_no(唯一订单号)、user_id、total_amount、pay_amount(优惠后实际支付)、coupon_id、status、receiver_name、receiver_phone、receiver_address、create_time、pay_time、ship_time、finish_time。
订单明细表(order_item):id、order_id、product_id、product_name(快照字段,防止商品改名后订单记录跟着变)、product_image、price、quantity、total_price。
地址表(address):id、user_id、receiver_name、receiver_phone、province、city、district、detail_address、is_default。
优惠券表(coupon):id、coupon_name、amount(面额)、min_amount(满减门槛)、stock、start_time、end_time。
用户优惠券表(user_coupon):id、user_id、coupon_id、status(0未使用1已使用2过期)、receive_time、use_time、order_id。
这里最容易被忽略的就是“订单明细里要冗余商品快照字段”。如果是单纯关联查询商品表拿名称,一旦后台改了商品名,历史订单显示就会变,这是数据一致性的大坑。做过的项目越多,越觉得这种细节才体现水平。
2.2 JWT登录认证的前后端交互逻辑
登录认证这块,我第一版用的是Session,后来改成了JWT。改的原因很实际:前后端分离之后,Session跨域要配很多乱七八糟的CORS配置,Token方式跨域天然友好,还不用考虑集群环境下Session共享的问题。
JDK的JWT方案是加依赖jjwt,登录成功后生成Token返回给前端,前端存到localStorage里,每次请求在Header里带上Authorization: Bearer token。
后端我写了一个JwtInterceptor,实现HandlerInterceptor接口,在preHandle里做三件事:取Header里的Token、校验签名和过期时间、把用户信息放到ThreadLocal里方便后续业务方法直接拿。
关键代码如下:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行OPTIONS预检请求 if ("OPTIONS".equals(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (StringUtils.isEmpty(token)) { throw new BusinessException(401, "未登录或登录已过期"); } try { // 需要替换成自己项目里生成的secret Claims claims = Jwts.parser() .setSigningKey("projectSecretKey") .parseClaimsJws(token.replace("Bearer ", "")) .getBody(); UserContext.setUserId(Long.parseLong(claims.get("userId").toString())); UserContext.setUsername(claims.get("username").toString()); return true; } catch (ExpiredJwtException e) { throw new BusinessException(401, "登录已过期"); } catch (JwtException e) { throw new BusinessException(401, "无效的Token"); } } }注意这里有个实操细节:UserContext用的是ThreadLocal实现,但请求处理完必须remove,不然Tomcat线程池复用时数据会串。这个坑在线上项目里很常见,我在afterCompletion里专门做了清理。
拦截器注册时还要排除放行路径:登录接口、注册接口、商品列表、商品详情、图片访问这些不需要登录就能访问的路径。
2.3 商品搜索缓存与分页查询优化
商品模块是商城系统的门面,用户进来第一个看到的就是商品列表。如果每次请求都去数据库做模糊查询,数据量大了响应速度会很慢。我在实现时给首页推荐商品和热门分类做了Redis缓存:
# 缓存key设计 product:list:category:{categoryId}:page:{current} product:detail:{productId} product:hot:top10缓存更新的策略是:后台修改商品时同步删除对应缓存,等下次查询时重新回填。这个策略在技术上叫Cache Aside Pattern,比先更新数据库再更新缓存更靠谱,能避免并发情况下数据不一致。
分页用的是MyBatis-Plus的分页插件,配置一个PaginationInnerInterceptor:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }然后是商品查询的Service实现。这里要注意一个分层问题:Controller只接收参数和返回结果,具体业务逻辑放在Service层,不要写在Controller里。很多毕设代码被老师批评“逻辑全堆在Controller”,就是分层没做好。
2.4 秒杀与库存扣减的并发安全问题
库存扣减是商城系统的核心难点。最简单粗暴的写法是:先查库存、判断够不够、够就扣减——但高并发下这一定会超卖。我用了数据库层面的原子更新来解决:
// 使用条件更新,库存大于数量时才更新 int updatedRows = productMapper.deductStock(productId, quantity); if (updatedRows == 0) { throw new BusinessException(500, "库存不足"); }对应的SQL:
UPDATE product SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity}这个办法比Java层的同步锁靠谱得多,因为UPDATE语句本身在数据库层面就是原子操作,多台服务器同时来请求也一样安全。后面结合@Transactional事务注解保证订单数据和库存扣减在同一个事务里,做到要么全部成功要么全部回滚。
提交订单时还要注意:先扣库存,后生成订单。如果先建订单再扣库存,库存不够时订单已经落库了,要回滚整个事务,逻辑绕而且容易出问题。
3. 从零到一的实操搭建流程
3.1 项目初始化与依赖配置
用IDEA的Spring Initializr创建项目时,有一个版本选择容易踩坑。我建议直接选SpringBoot 2.7.x,这是2.x系列的最后一个维护版本,稳定、资料多、兼容性好。JDK选1.8,别犹豫。
记住选择依赖时不需要贪多,核心就这几个:spring-boot-starter-web、mybatis-plus-boot-starter(注意版本要用3.5.x,太老的3.4版本和SpringBoot 2.7有些隐性问题)、mysql-connector-java、lombok、jjwt、spring-boot-starter-data-redis。
application.yml里最关键的配置:
spring: datasource: url: jdbc:mysql://localhost:3306/glasses_mall?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你自己的密码 driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这里有个连接MySQL 8.0的细节:serverTimezone必须显式指定,不然会报时区错误。另外我还开了MyBatis-Plus的逻辑删除,商品、用户这些核心表都加了deleted字段,做软删除而不是物理删除,这样万一误删了数据还能恢复。
3.2 统一返回结构与全局异常处理
前后端分离项目的接口规范特别重要。我定义了一个统一的返回体,所有接口都返回这个格式,前端拿到之后统一处理,不用每个请求单独判断:
@Data 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.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }配合全局异常处理器,业务代码里抛异常就好,不用到处写try-catch:
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public Result<?> handleBusinessException(BusinessException e) { return Result.error(e.getCode(), e.getMessage()); } @ExceptionHandler(Exception.class) public Result<?> handleException(Exception e) { log.error("系统异常", e); return Result.error(500, "系统繁忙,请稍后再试"); } }这个设计的好处是:代码里遇到异常情况直接throw new BusinessException(500, "库存不足"),统一由异常处理器翻译成用户友好的JSON返回。既避免了每层手动处理异常的冗余代码,也让接口风格非常统一。
3.3 眼镜商品图片上传与存储方案
眼镜商城的商品图片很多,每个商品至少三五张图。如果直接把图片Base64存在数据库里,数据库体积会迅速膨胀,性能直线下降。我用的是本地文件存储方案:接口接收MultipartFile,保存到服务器指定目录,数据库只存路径。
public String upload(MultipartFile file) { // 生成唯一文件名,避免重名覆盖 String originalFilename = file.getOriginalFilename(); String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); String fileName = UUID.randomUUID().toString().replace("-", "") + suffix; // 按日期分目录存储 String datePath = new SimpleDateFormat("yyyyMMdd").format(new Date()); File dir = new File(UPLOAD_DIR + datePath); if (!dir.exists()) { dir.mkdirs(); } File dest = new File(dir, fileName); file.transferTo(dest); // 返回可访问的URL路径 return "/images/" + datePath + "/" + fileName; }前端访问图片时,需要配置静态资源映射。用WebMvcConfigurer的addResourceHandlers方法,把/images/**请求映射到本地磁盘目录。这个配置很容易漏,漏了图片就会404。
3.4 订单状态机与支付回调设计
订单模块是整个系统业务逻辑最复杂的部分。订单状态我用了一个整数来标记,并定义了清晰的状态流转路径:
| 状态值 | 含义 | 可触发操作 |
|---|---|---|
| 0 | 待付款 | 取消订单、支付 |
| 1 | 已付款/待发货 | 无(等待管理员发货) |
| 2 | 已发货/待收货 | 确认收货 |
| 3 | 已完成 | 评价 |
| 4 | 已取消 | 无 |
状态扭转都封装在OrderService里,通过方法名直接表达语义:createOrder、cancelOrder、payOrder、shipOrder、confirmOrder、commentOrder。每个方法都做了前置校验,比如待付款订单才能取消、待收货订单才能确认收货,防止非法状态跳转。
支付模块我没接支付宝微信的沙箱环境,因为个人账号申请麻烦而且审批周期长。用了最简单的模拟支付:订单详情页点击“模拟支付”,前端调payOrder接口,后端把订单状态从“待付款”置为“已付款”,同时更新pay_time。答辩时明确说明这是模拟流程,再对比说真实支付需要走第三方支付平台的回调接口,逻辑是通的。
4. 常见问题与避坑实录
4.1 前端跨域问题
前后端分离项目启动后,前端访问后端接口一定会遇到跨域问题。我在后端加了一个全局CORS配置来解决:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }这里有个隐藏的坑:如果用了JWT拦截器,拦截器必须在CORS配置的允许范围内,否则预检请求OPTIONS会被拦截器拦截返回401。我是在preHandle里单独放行了OPTIONS请求(前面代码里已经提到了),这是前后端分离项目非常容易忽略的前提操作。
4.2 MyBatis-Plus字段自动填充
创建时间、更新时间这种字段如果每次新加记录都要手动setCreateTime,浪费时间还容易漏。MyBatis-Plus提供了MetaObjectHandler接口,可以在插入和更新时自动填充:
@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }对应实体类的字段上需要标@TableField(fill = FieldFill.INSERT)或@TableField(fill = FieldFill.INSERT_UPDATE)。一次性配置好,后面所有表都能复用。
4.3 Maven依赖冲突与版本不兼容
做SpringBoot项目最常见的报错就是各种NoClassDefFoundError或者ClassNotFoundException,十有八九是依赖冲突。我在整合JWT时遇到过一次,后来排查发现是jjwt里传递依赖了老版本的jackson,和SpringBoot自带的冲突了。
排查方法很简单:mvn dependency:tree看依赖树,找到冲突的包,然后用exclusion排除:
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> <exclusions> <exclusion> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </exclusion> </exclusions> </dependency>MyBatis-Plus版本选择也要注意。SpringBoot 2.7配MyBatis-Plus 3.5.x基本没问题,但如果用3.4.3以下的老版本,分页插件可能要手动配置,网上教程参差不齐,容易卡壳。直接用新版省心。
4.4 订单号生成的几种实现方式
订单号生成要保证唯一,还得有点业务含义。我用的是:时间戳 + 用户ID后四位 + 随机数。代码实现:
public String generateOrderNo(Long userId) { SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); String timePart = sdf.format(new Date()); String userPart = String.format("%04d", userId % 10000); int randomPart = (int) ((Math.random() * 9 + 1) * 1000); return timePart + userPart + randomPart; }这个方案在单机项目里够了。如果你担心并发高了会重复,可以在数据库order_no字段加唯一索引,一旦重复就捕获异常重新生成。真实电商系统的订单号设计比这个复杂得多,但毕设级别这样做还能在答辩时说出设计思路,已经够了。
4.5 Redis缓存穿透与缓存雪崩的应对
这个知识点答辩时如果主动讲出来,老师印象分会明显不一样。我在商品详情查询里做了空值缓存来防缓存穿透:查不到数据时也在Redis里存一个空值,过期时间设短一点,防止恶意流量直接打到数据库。
public Product getProductDetail(Long productId) { String key = "product:detail:" + productId; Object cacheValue = redisUtil.get(key); if (cacheValue != null) { return (Product) cacheValue; } Product product = productMapper.selectById(productId); if (product != null) { redisUtil.set(key, product, 30, TimeUnit.MINUTES); } else { redisUtil.set(key, new Product(), 3, TimeUnit.MINUTES); } return product; }缓存雪崩的应对是给过期时间加一个随机值,比如30分钟加上0到5分钟随机数,防止同一时间大量key同时过期导致数据库被压垮。代码里直接30 + new Random().nextInt(5)分钟就行。
4.6 最容易翻车的答辩问题准备
项目做出来只是第一步,答辩才是定生死的环节。我把自己被问到的问题整理了一下,核心集中在几个方向:
- 为什么选SpringBoot不选SSM?答:自动配置简化开发、内嵌服务器方便部署、生态成熟。
- 表之间怎么关联的?答:通过外键逻辑关联,实际开发为了性能不建物理外键,靠Service层保证数据一致性。
- 密码怎么保证安全?答:MD5加盐存储,不能明文。更进一步可以提BCrypt,但MD5+盐在毕设里够用。
- 如果用户同时下单库存不够怎么办?答:UPDATE条件锁,数据库原子操作,保证不超卖。
- Token和Session差异?答:Token无状态、适合分布式与前后端分离;Session便捷但依赖服务端存储,集群环境要额外处理。
这些问题一定要提前准备,哪怕项目代码有瑕疵,只要核心原理讲清楚,老师一般不会为难你。
5. 项目扩展思路与二次开发方向
如果答辩时间富余,或者你想让项目更有竞争力,有几个低成本高收益的扩展方向可以尝试。
第一个是接入支付宝沙箱支付。蚂蚁沙箱环境提供完整的测试账号,不用真实商户资质,代码上只需要引入支付宝SDK,按照官方文档配置应用ID、私钥、支付宝公钥,把模拟支付替换成真实调起支付弹窗,整个项目会有质的提升。
第二个是增加后台数据报表。用ECharts做一个简单的销售额折线图和商品分类占比饼图,接口用SQL的DATE_FORMAT和GROUP BY做聚合查询,前台数据展示用Vue的v-for和ECharts的setOption绑定,整体工作量不大,但演示效果拉满,而且能体现你对数据可视化这个方向的掌握。
第三个是引入Elasticsearch做商品搜索。眼镜商城商品搜索的需求比较简单,MySQL的LIKE查询基本够用。但如果商品数据量上来了、搜索要支持分词和相关性排序,就需要ES出场了。这个扩展适合想把项目拔高一个维度的同学。
考虑到时间和精力,我最推荐第一个——接支付宝沙箱支付,因为支付是电商系统最核心的一环,而且面评效果格外好。
做了这个项目最大的感受是:毕设不是要把技术选得多花哨,而是要把一条业务链路完整走通。从用户注册到最后拿到眼镜、作出评价,其中每个环节都有真实企业在面对的问题——登录怎么鉴权、库存怎么扣、订单状态怎么流转、数据怎么缓存。把这些想明白,代码写出来就是水到渠成的事。
最后再分享一个小技巧:项目里的注释不要追求多,追求关键。每个方法上面写清楚“做什么、参数是什么、返回什么”,每个复杂逻辑里面加一行核心注释解释“为什么这么写”,自己回来看得懂,老师看了也舒服。如果你的代码结构清晰、关键点有注释、异常处理到位,答辩基本就稳了。