“基于Spring Boot的水果购物管理系统”——这个题目我太熟了每年到毕业季、课程设计节点都会有人来问类似的问题。作为前后参与过好几个商城类项目的开发者我可以负责任地说水果购物是个很好的练手方向业务不算复杂却很完整从用户登录、商品浏览、购物车到下单、后台管理电商系统该有的链路都有而且生鲜水果本身又带来了一些特殊性。这篇就把我做这个系统时的完整思路、技术选型、表结构、核心代码和踩坑记录整理出来给准备动手做类似项目的同学一份可以照做的参考。1. 为什么选择水果购物需求边界比想象中更清晰很多人一上来就想做“通用商城系统”结果做着做着就失控了。用户、商品、分类、品牌、SKU、优惠券、秒杀、物流、售后……每加一个模块工作量都不是线性增长而是指数级膨胀。我的建议是如果你是为了练手或完成一个阶段性项目一定要把业务边界收窄水果购物就是个很典型的“小而完整”的题目。1.1 生鲜业务给系统带来的三个真实约束水果不是普通商品做数据库设计和功能规划时得考虑它的特点这正好能体现你“懂业务”而不是单纯的CRUD堆砌。第一是库存单位不统一。苹果可以按“个”卖也可以按“500g”卖西瓜按“个”车厘子按“盒”葡萄按“500g”。这意味着商品表里必须有一个“单位”字段前端展示、购物车计价、下单明细都要围绕这个字段来设计不能默认所有商品都是同一计量方式。第二是保质期与状态的概念。水果容易坏所以通常需要区分“上架”“下架”“售罄”这些状态。库存清零时能自动下架或者管理员手动下架这比普通日用品更符合实际运营逻辑。当然你不需要做临期提醒、冷链追踪这种太复杂的事情但状态流转是应该有的。第三是配送信息的定制化。买水果的人经常会备注“不要挤压”“送到联系我”之类的要求而且地址通常比普通快递更需要具体。所以订单表里最好加一个“备注”字段并且收货人、收货电话、收货地址这三个字段要单独存储不能只存一个user_id再去关联用户表——因为用户可能在不同订单里填不同的配送信息。1.2 梳理出来的角色与核心流程我最终把系统拆成两种角色前台用户和管理员。没有做复杂的RBAC权限模型因为系统里其实只有两类操作者用角色字段区分就好硬套Spring Security反而增加学习成本。用户端的核心流程是注册/登录浏览水果列表、按分类筛选、查看详情加入购物车修改数量确认订单填写收货信息提交订单查看我的订单列表和订单详情可选取消未付款订单管理员端的核心流程是登录后台商品管理新增、编辑、上下架、删除分类管理维护水果分类订单管理查看订单列表更新订单状态发货等这个范围画出来之后开发量就非常明确了。用户端4个模块管理端3个模块外加登录注册和数据库设计差不多就可以形成一个完整闭环。1.3 需要控制好的需求范围再强调一点不要往上堆功能。你可能会想加个“秒杀”“团购”“积分商城”我劝你先忍住。这类功能涉及高并发、库存扣减策略、活动配置对于一个练手项目来说性价比极低而且容易把核心链路搞乱。真正值得花心思的是这三件事登录态怎么维护、下单时库存怎么扣、订单状态怎么流转。这三个点能做干净这个项目就已经是及格往上的水平了。先把这些做完时间有富余再考虑加功能加的时候也优先加“水果热销排行榜”“搜索功能增强”这类和业务贴合度高的而不是又开一个复杂度天花板。2. 技术选型每个组件背后都有一个理由技术栈不需要追求新但每个选择都得说得清楚“为什么”。以这个系统来说最稳的组合是Spring Boot MyBatis Plus MySQL Vue或Thymeleaf再按需加Redis和JWT。2.1 框架版本怎么定Spring Boot 2.7 JDK 8 是最稳的组合这里有一个比较现实的建议如果这个项目要交到学校、或者要在别人的电脑上跑起来版本越保守越不容易出问题。我见过太多人一上来就上Spring Boot 3.x JDK 17结果引入旧依赖时报了一堆编译错误光排错就花了两天。我实际采用的是Spring Boot 2.7.18 JDK 8。为什么是2.7因为2.7是Spring Boot 2.x的最后一个版本既有Spring Boot 2生态下最全的资料又已经非常稳定。MyBatis Plus、PageHelper、Swagger这些常用组件的3.x版本都能很好地配合它网上遇到卡点能搜到的解决方案也多。如果你确实想用Spring Boot 3.x不是不行但要确认这些依赖都支持Jakarta命名空间javax.servlet要改成jakarta.servletjavax.validation也要改成jakarta.validation。新手很容易忽略这个细节。所以我最终用2.7省心。2.2 ORM选型MyBatis Plus 让代码量肉眼可见地变少ORM这一层我在原生MyBatis和MyBatis Plus之间纠结过。原生MyBatis写动态SQL确实灵活但对于这种增删改查占7成的管理系统要手写大量XML效率太低了。JPA的自动SQL又太“黑盒”一旦出现N1查询或者复杂联表调起来并不直观。最后选了MyBatis Plus。它的BaseMapper已经把单表CRUD做完了分页插件也很好用代码生成器还能直接生成实体、Mapper、Service、Controller——对一个以业务逻辑为主的项目来说非常合适。不过它有个点要注意默认开启的逻辑删除只对MyBatis Plus自带方法有效。如果你自己写了SQL比如select * from product where id #{id}逻辑删除字段是不生效的。最稳妥的办法是业务层里所有查询都走BaseMapper的方法或者自定义SQL时手动加上deleted 0条件。2.3 前端方案、权限方案与缓存取舍前端我见过两种做法。一种是服务端渲染用Thymeleaf Bootstrap前后端不分离一个Spring Boot工程搞定所有适合想聚焦后端的人部署非常省事。另一种是前后端分离前端Vue 2 Element UI后端只出JSON接口开发时用axios调用工作量会更大一些但更能体现“工程化”思路。我个人建议如果时间紧选Thymeleaf那套因为省掉了nginx部署、跨域配置这些事。如果项目周期比较长就选Vue前后端分离能展示的能力也更全面。下面我讲的代码逻辑在两种方案下是通用的区别只在控制器返回的是“页面”还是“JSON”。权限验证这块我没有引入完整的Spring Security理由是它的过滤器链和配置项对新手不够友好出了问题很难自己排查。我用的是JWT 拦截器登录成功签发一个token前端后续请求放在header里后端写一个拦截器校验token并解析出用户id放ThreadLocal。这套方案足够支撑该系统的登录态需求代码也完全可控。Redis用不用如果做了“水果热销排行”或者“验证码”功能Redis是合适的。但如果没有也不必强行加数据库本身就能完成列表查询。记住技术选型服务于业务不是越多越好。3. 数据库建模六张核心表把业务串起来数据库设计是整篇里最值得反复打磨的部分。表结构一旦定下来后端代码基本上就是顺着表走。我最终保留的表是用户表、商品表、分类表、购物车表、订单表、订单明细表一共六张。这个规模对管理系统来说刚刚好。3.1 用户表、商品表与分类表的字段设计用户表user的核心字段如下CREATE TABLE user ( id bigint unsigned NOT NULL AUTO_INCREMENT, username varchar(32) NOT NULL COMMENT 用户名, password varchar(64) NOT NULL COMMENT 密码(MD5加盐), phone varchar(20) DEFAULT NULL, address varchar(255) DEFAULT NULL, avatar varchar(255) DEFAULT NULL, role tinyint NOT NULL DEFAULT 0 COMMENT 0普通用户 1管理员, created_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;密码这一列我要多说一句。哪怕只是练手项目明文密码也是完全不能接受的。我当时用的是MD5(username salt password)这种简单加盐方式虽然不算强但比明文好了一个档次。如果你想更规范一点可以换成BCryptSpring Security里有现成的BCryptPasswordEncoder单独引出来也可以直接用。商品表的核心字段重点在一个“业务特色”上CREATE TABLE product ( id bigint unsigned NOT NULL AUTO_INCREMENT, category_id bigint unsigned NOT NULL COMMENT 所属分类, name varchar(100) NOT NULL, description text, image varchar(255) DEFAULT NULL, price decimal(10,2) NOT NULL COMMENT 单价, unit varchar(20) NOT NULL COMMENT 单位:个/500g/盒, stock int NOT NULL DEFAULT 0, sales int NOT NULL DEFAULT 0 COMMENT 销量, status tinyint NOT NULL DEFAULT 0 COMMENT 0下架 1上架, sort_order int DEFAULT 0, created_time datetime DEFAULT CURRENT_TIMESTAMP, deleted tinyint NOT NULL DEFAULT 0, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里的unit字段就是前面说的生鲜特色。价格用decimal而不是float/double是为了避免浮点误差——这个原理很简单但你去看很多学生项目甚至商业代码都在这上面翻过车。分类表更简单id、分类名、排序号、创建时间就够用了。3.2 购物车不落单独立的购物车表更灵活最开始有同学跟我说购物车数据放Redis不就行了我摇了摇头。如果你没有完善的Redis持久化策略用户购物车数据可能在服务重启后丢得干干净净这是完全不可接受的毕竟购物车里放的是用户的真实意图。所以购物车还是单独建表CREATE TABLE cart_item ( id bigint unsigned NOT NULL AUTO_INCREMENT, user_id bigint unsigned NOT NULL, product_id bigint unsigned NOT NULL, quantity int NOT NULL DEFAULT 1, checked tinyint NOT NULL DEFAULT 1 COMMENT 是否选中, created_time datetime DEFAULT CURRENT_TIMESTAMP, updated_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_product (user_id, product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;给user_id和product_id加了唯一联合索引这样同一用户同一商品在表里只有一行。加入购物车时用ON DUPLICATE KEY UPDATE或者在代码里先查后插都可以。我更建议在代码里先查再决定“新增”还是“数量1”因为这样更直观调试起来也方便。3.3 订单表与订单明细表为什么必须拆成两张表订单为什么要拆主表和明细表这是个经典的数据库设计问题。简单说订单表存的是这次交易的“头”明细表存的是这个订单里买了哪些东西。如果只做成一张表一单买三种水果就得插三行订单的收货地址、总金额、状态在每个商品行里都是重复的查询单个订单时会非常麻烦还会违反基础范式。订单主表orders主要字段CREATE TABLE orders ( id bigint unsigned NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, user_id bigint unsigned NOT NULL, total_amount decimal(10,2) NOT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付/待发货 2已发货 3已完成 4已取消, receiver_name varchar(32) NOT NULL, receiver_phone varchar(20) NOT NULL, receiver_address varchar(255) NOT NULL, remark varchar(255) DEFAULT NULL, created_time datetime DEFAULT CURRENT_TIMESTAMP, pay_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单明细表order_itemCREATE TABLE order_item ( id bigint unsigned NOT NULL AUTO_INCREMENT, order_id bigint unsigned NOT NULL, product_id bigint unsigned NOT NULL, product_name varchar(100) NOT NULL, product_image varchar(255) DEFAULT NULL, price decimal(10,2) NOT NULL COMMENT 购买时的单价, quantity int NOT NULL, subtotal decimal(10,2) NOT NULL, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意明细表里冗余了product_name和product_image这两个字段。这是刻意的下单之后用户看到的是“下单那一刻的商品信息”就算管理员之后改了商品名、换了图片订单历史里也应该保持原样。这叫快照设计。做商城订单模块的人如果没在明细表里存名称和图片后面必然出问题。3.4 逻辑删除与唯一约束的一个隐藏冲突这个小坑是我的亲身经历。商品表里我用了deleted字段做逻辑删除同时又给商品名建了唯一索引。第一次测试时我删除一个叫“海南贵妃芒”的商品再新增一个同名商品结果数据库直接报“Duplicate entry”原因很简单上一行只是被标记为deleted1物理上还在唯一索引照样生效。解决办法有几个一种是删除唯一索引靠代码去判断还有一种是把deleted字段做成可变的“删除标记时间戳”比如删除时更新deleted id而不是1这样结合唯一索引username, deleted就能实现“允许重复插入同名商品”。不过这样会让逻辑删除的语义变得有点歪。我最后选择的是商品名做成普通索引插入前在service层先查一次重名。这个场景下完全够用也避免了为了一点小概率问题把表结构搞复杂。4. 核心代码实现从登录到下单的完整链路这部分是系统的“发动机”也是面试官或答辩老师最喜欢追问的地方。我按链路的顺序讲登录态怎么传、购物车怎么操作、订单怎么落库、库存怎么扣减。4.1 登录态维护JWT 拦截器 ThreadLocalJWT的方案算是目前前后端分离项目的主流做法核心流程是用户登录 - 后端验证用户名密码 - 生成token返回前端 - 前端存到localStorage - 后续请求在header中带token - 后端拦截器解析token - 把用户id放到ThreadLocal里共业务层使用。生成token的代码我用的是jjwt这个库核心逻辑非常简单public String generateToken(Long userId, String username) { MapString, Object claims new HashMap(); claims.put(userId, userId); claims.put(username, username); String token Jwts.builder() .setClaims(claims) .setExpiration(new Date(System.currentTimeMillis() 7 * 24 * 3600 * 1000L)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); return token; }然后写一个拦截器继承HandlerInterceptorpublic class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (StringUtils.hasText(token)) { try { Claims claims Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token.replace(Bearer , )).getBody(); Long userId claims.get(userId, Long.class); UserContext.set(userId); return true; } catch (Exception e) { // token非法或过期 } } response.setStatus(401); return false; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.remove(); } }这里有两个地方要专门注意。一是ThreadLocal用完之后记得remove否则会有内存泄漏风险而且tomcat线程池是复用的不清掉会导致下一个请求拿到上一个用户的id这是很隐蔽的权限问题。二是拦截器要配置白名单比如/user/login、/user/register、/product/list这几个接口不能拦否则前端没token访问商品列表直接被拒。4.2 购物车操作库存校验放在哪一步购物车的几个接口我直接说最容易写错的地方——库存校验的时机。往购物车加商品的时候很多人只查一下商品存不存在忘了查库存。如果用户加购数量已经超过库存你让他加进去了到最后提交订单的时候还要去纠正体验很差。所以加入购物车时要判断如果购物车中已有该商品加购后的总数量必须小于当前库存如果没有本次添加的数量必须小于当前库存。// 伪代码只展示核心逻辑 CartItem existing cartItemService.findByUserIdAndProductId(userId, productId); Product product productService.getById(productId); if (product null || product.getStatus() ! 1) { throw new BusinessException(商品不存在或已下架); } int targetQuantity (existing null ? 0 : existing.getQuantity()) quantity; if (targetQuantity product.getStock()) { throw new BusinessException(库存不足当前库存仅剩 product.getStock() product.getUnit()); }还有个细节是购物车列表查询回来后最好用product_id批量查一次商品把“最新的商品名、图片、价格、库存状态”装配回去而不是直接用购物车表里冗余的商品快照大多数情况下购物车表甚至不该冗余商品信息。否则会出现用户加入购物车后商品改价/下架购物车页面展示信息不对的尴尬局面。4.3 提交订单订单号生成、明细快照与扣库存提交订单是整个系统的核心事务我的思路是按下面四步走根据购物车中勾选的商品读取商品当前价格和库存计算总价生成订单号插入订单主表状态为待支付插入所有订单明细同时扣减每个商品的库存、增加销量最后清空购物车对应记录。订单号生成不要用数据库自增id直接用因为太容易暴露订单量而且不好看。我用的是“时间戳 用户id 随机数”拼成32位的订单号再结合唯一索引防止冲突String orderNo System.currentTimeMillis() String.format(%04d, userId / 10000) String.format(%04d, (int) (Math.random() * 10000));如果哪天单量特别大可以把订单号上移到单独的“号段生成器”来做雪花算法或分段发号。练手场景不用这么复杂但你要知道订单号跟数据库主键是两回事。提交订单方法上必须加TransactionalTransactional(rollbackFor Exception.class) public Long submitOrder(OrderSubmitDTO dto, Long userId) { // 1. 校验购物车勾选商品 // 2. 计算总金额 // 3. 插入 orders // 4. 批量插入 order_item // 5. 扣库存 // 6. 清空购物车 }rollbackFor Exception.class这个参数别省。因为Transactional默认只在RuntimeException时回滚如果你代码里某个受检异常向外抛了不加这个参数的话事务不会回滚数据会处于半成品状态。4.4 Transactional的坑同类内部调用不生效这个坑杀伤力极大而且很难一眼看出来。我曾经在同一个Service里写了两个方法public Long submitOrder(OrderSubmitDTO dto, Long userId) { // 准备数据 this.insertOrderAndItems(dto, orderNo, userId); // 扣库存 this.reduceStock(dto, userId); // ... } Transactional public void insertOrderAndItems(OrderSubmitDTO dto, String orderNo, Long userId) { // 插入主表、明细表 }结果我在insertOrderAndItems里故意抛了一个运行时异常调试时发现订单主表居然插进去了。查了半天原因是同一个类里的this.insertOrderAndItems()调用并没有走Spring代理Transactional根本不生效。Spring的事务是通过AOP代理实现的只有从外部调用被代理的方法时事务注解才会被解析内部this调用相当于直接调用原始对象的方法注解被无视了。解决方案有三种把insertOrderAndItems放到另一个Service类里或者直接在submitOrder方法上加Transactional把子方法的所有逻辑都包进同一事务或者自己注入一个代理对象再调用。我最终选择的是在submitOrder这一个方法上加事务把所有步骤合并进去。写出来虽然方法体长了一点但事务边界清清楚楚一个事务覆盖整个下单链路这才是合理的做法。5. 联调与部署阶段的排坑实录项目代码写完后真正的“实战”才刚开始。联调、部署、测试阶段我遇到过好几个让新手抓狂的问题每一个都代表一类共性坑值得展开讲讲。5.1 时间字段序列化导致的前后不一致前端页面报错“级联选择器回显失败时间格式错误”排查发现后端返回的created_time是yyyy-MM-ddTHH:mm:ss这种带T的ISO格式而前端组件想要的是yyyy-MM-dd HH:mm:ss。我用Jackson全局配置解决spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8这个配置看着简单但有个前提如果你的实体里日期字段用的是LocalDateTime而不是java.util.Date那上面这个date-format是管不到它的LocalDateTime的序列化要走JavaTimeModule还需要在类上增加JsonFormat(pattern yyyy-MM-dd HH:mm:ss)注解。我的建议是统一在实体类的日期字段上显式加JsonFormat不要裸依赖全局配置这样每个字段的格式都一目了然不会换个接口就出问题。5.2 图片上传成功却访问不到后台管理系统上传水果图片我用的是最简单的本地文件存储方案把图片写到服务器目录下的/upload文件夹然后通过映射把该目录暴露成静态资源。本地开发一切正常但部署到服务器后图片404了。多数情况下问题出在路径上。代码里写的保存路径是D:/upload/这种本地绝对路径部署到了Linux环境当然找不到。我改成了相对项目目录的路径并用配置文件管理file: upload-dir: ./upload/然后配置映射Configuration public class WebConfig implements WebMvcConfigurer { Value(${file.upload-dir}) private String uploadDir; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadDir); } }注意addResourceLocations的参数最后必须带斜杠否则映射会失效。这也是一个容易踩的细节。另外补充一点本地存储只适合练手场景。如果将来要做正式项目图片该放对象存储那段代码一定写在FileService接口后面方便以后替换实现别在Controller里直接写文件IO。5.3 并发下单的验证方法多线程模拟加上数据库兜底“库存超卖”是购物系统里绕不开的话题。别人问你有没有考虑并发你不能只说“我加了事务”。事务能保证异常回滚但它不能阻止两个线程同时通过库存判断。经典的场景是库存只剩1个两个用户同时下单各自查到库存都是1各自通过校验最后都执行“库存减1”结果库存变成-1。当时我用一个CountDownLatch模拟了30个线程同时下单同一个商品复现了问题。解决办法采用的是“乐观扣减”int rows productMapper.reduceStock(productId, quantity); if (rows 0) { throw new BusinessException(库存不足); }对应的SQL是UPDATE product SET stock stock - #{quantity}, sales sales #{quantity} WHERE id #{productId} AND stock #{quantity}关键在这句AND stock #{quantity}。数据库的行锁会让多个更新请求串行执行先执行的那个把库存扣到0后续的更新因为stock quantity不满足而影响行数为0业务层拿到0就知道库存不足直接抛出异常。这样即使前面读到的是旧库存真正扣减时也被数据库挡住了。这才是安全的扣库存姿势。5.4 打包与部署的注意事项因为是标准Spring Boot项目部署无非就是打成jar包然后java -jar运行。这里提醒几个我踩过的小坑打包前确认application.yml里是不是本地的数据库连接、Redis地址等我中途就有一次把本地环境配置打进了生产包部署后启动报数据库连不上。服务器上执行java -jar时建议加-Xms256m -Xmx512m防止默认堆内存跨度过大触发其他问题。用nohup java -jar xxx.jar app.log 21 启动并把日志输出到文件里。不然一关终端进程就没了出事也没日志可以看。如果部署在云端服务器记得在安全组里放行对应端口这句话听起来是废话但每年都有人被这个卡住半天。6. 项目做完之后代码组织的几个个人习惯最后聊点项目经验之外的东西是关于代码本身的组织方式。这些东西不会直接体现在“功能是否跑通”上但对代码的可维护性影响很大。我习惯把Controller写薄Service写厚。Controller里只做参数接收和结果封装尽量不超过十行真正的业务判断、异常处理全放Service里。这样日后排查问题直接进Service找逻辑不用在一堆HTTP相关代码里翻来翻去。另一个习惯是自定义统一返回体。比如Result.success(data)和Result.error(msg)并且配合一个全局异常处理器RestControllerAdvice。这样业务里抛出BusinessException(库存不足)前端拿到的永远是结构一致的JSON而不是各种格式混乱的报错信息。这个习惯看起来是增加了一点工作量但一旦前后端对接能少吵好几架。数据库操作上我坚持所有“删除”尽量用逻辑删除。理由很简单用户误删了一个商品如果物理删掉了数据找不回来逻辑删除只是一个标记翻转。虽然代码里查询时需要多留意deleted条件但从数据安全的角度讲非常划算。如果你打算在这个系统上继续扩功能优先级最高的两个方向一个是接入真正的支付流程哪怕是模拟支付回调另一个是把后台管理做成可视化图表比如水果销量趋势。这两个方向都能让项目的完成度和可讲的故事上升一个等级而且技术上都站在已有代码的基础上不会推倒重来。我当时的体会是做完一个系统真正值钱的不在于“又写了一遍增删改查”而是在这个过程中把一个个坑填平、把每一个选择背后的理由想清楚。等你带着这套完整的思路去面对下一个项目时起点就完全不一样了。