☰
Spring Boot水果购物系统实战:从数据库设计到订单库存闭环
2026/10/22 3:32:05 网站建设 项目流程

“基于Spring Boot的水果购物管理系统”——这个题目我太熟了,每年到毕业季、课程设计节点,都会有人来问类似的问题。作为前后参与过好几个商城类项目的开发者,我可以负责任地说:水果购物是个很好的练手方向,业务不算复杂却很完整,从用户登录、商品浏览、购物车到下单、后台管理,电商系统该有的链路都有,而且生鲜水果本身又带来了一些特殊性。这篇就把我做这个系统时的完整思路、技术选型、表结构、核心代码和踩坑记录整理出来,给准备动手做类似项目的同学一份可以照做的参考。

1. 为什么选择水果购物:需求边界比想象中更清晰

很多人一上来就想做“通用商城系统”,结果做着做着就失控了。用户、商品、分类、品牌、SKU、优惠券、秒杀、物流、售后……每加一个模块,工作量都不是线性增长,而是指数级膨胀。我的建议是:如果你是为了练手或完成一个阶段性项目,一定要把业务边界收窄,水果购物就是个很典型的“小而完整”的题目。

1.1 生鲜业务给系统带来的三个真实约束

水果不是普通商品,做数据库设计和功能规划时得考虑它的特点,这正好能体现你“懂业务”,而不是单纯的CRUD堆砌。

第一是库存单位不统一。苹果可以按“个”卖,也可以按“500g”卖;西瓜按“个”,车厘子按“盒”,葡萄按“500g”。这意味着商品表里必须有一个“单位”字段,前端展示、购物车计价、下单明细都要围绕这个字段来设计,不能默认所有商品都是同一计量方式。

第二是保质期与状态的概念。水果容易坏,所以通常需要区分“上架”“下架”“售罄”这些状态。库存清零时能自动下架,或者管理员手动下架,这比普通日用品更符合实际运营逻辑。当然,你不需要做临期提醒、冷链追踪这种太复杂的事情,但状态流转是应该有的。

第三是配送信息的定制化。买水果的人经常会备注“不要挤压”“送到联系我”之类的要求,而且地址通常比普通快递更需要具体。所以订单表里最好加一个“备注”字段,并且收货人、收货电话、收货地址这三个字段要单独存储,不能只存一个user_id再去关联用户表——因为用户可能在不同订单里填不同的配送信息。

1.2 梳理出来的角色与核心流程

我最终把系统拆成两种角色:前台用户和管理员。没有做复杂的RBAC权限模型,因为系统里其实只有两类操作者,用角色字段区分就好,硬套Spring Security反而增加学习成本。

用户端的核心流程是:

  1. 注册/登录
  2. 浏览水果列表、按分类筛选、查看详情
  3. 加入购物车,修改数量
  4. 确认订单,填写收货信息,提交订单
  5. 查看我的订单列表和订单详情
  6. (可选)取消未付款订单

管理员端的核心流程是:

  1. 登录后台
  2. 商品管理:新增、编辑、上下架、删除
  3. 分类管理:维护水果分类
  4. 订单管理:查看订单列表,更新订单状态(发货等)

这个范围画出来之后,开发量就非常明确了。用户端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.servlet,javax.validation也要改成jakarta.validation。新手很容易忽略这个细节。所以我最终用2.7,省心。

2.2 ORM选型:MyBatis Plus 让代码量肉眼可见地变少

ORM这一层,我在原生MyBatis和MyBatis Plus之间纠结过。原生MyBatis写动态SQL确实灵活,但对于这种增删改查占7成的管理系统,要手写大量XML,效率太低了。JPA的自动SQL又太“黑盒”,一旦出现N+1查询或者复杂联表,调起来并不直观。

最后选了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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

密码这一列我要多说一句。哪怕只是练手项目,明文密码也是完全不能接受的。我当时用的是MD5(username + salt + password)这种简单加盐方式,虽然不算强,但比明文好了一个档次。如果你想更规范一点,可以换成BCrypt,Spring 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里的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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

给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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

订单明细表(order_item):

CREATE 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意明细表里冗余了product_name和product_image这两个字段。这是刻意的:下单之后用户看到的是“下单那一刻的商品信息”,就算管理员之后改了商品名、换了图片,订单历史里也应该保持原样。这叫快照设计。做商城订单模块的人如果没在明细表里存名称和图片,后面必然出问题。

3.4 逻辑删除与唯一约束的一个隐藏冲突

这个小坑是我的亲身经历。商品表里我用了deleted字段做逻辑删除,同时又给商品名建了唯一索引。第一次测试时,我删除一个叫“海南贵妃芒”的商品,再新增一个同名商品,结果数据库直接报“Duplicate entry”,原因很简单:上一行只是被标记为deleted=1,物理上还在,唯一索引照样生效。

解决办法有几个:一种是删除唯一索引,靠代码去判断;还有一种是把deleted字段做成可变的“删除标记+时间戳”,比如删除时更新deleted = id(而不是1),这样结合唯一索引(username, deleted)就能实现“允许重复插入同名商品”。不过这样会让逻辑删除的语义变得有点歪。我最后选择的是:商品名做成普通索引,插入前在service层先查一次重名。这个场景下完全够用,也避免了为了一点小概率问题把表结构搞复杂。

4. 核心代码实现:从登录到下单的完整链路

这部分是系统的“发动机”,也是面试官或答辩老师最喜欢追问的地方。我按链路的顺序讲:登录态怎么传、购物车怎么操作、订单怎么落库、库存怎么扣减。

4.1 登录态维护:JWT + 拦截器 + ThreadLocal

JWT的方案算是目前前后端分离项目的主流做法,核心流程是:用户登录 -> 后端验证用户名密码 -> 生成token返回前端 -> 前端存到localStorage -> 后续请求在header中带token -> 后端拦截器解析token -> 把用户id放到ThreadLocal里共业务层使用。

生成token的代码,我用的是jjwt这个库,核心逻辑非常简单:

public String generateToken(Long userId, String username) { Map<String, 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; }

然后写一个拦截器继承HandlerInterceptor:

public 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 提交订单:订单号生成、明细快照与扣库存

提交订单是整个系统的核心事务,我的思路是按下面四步走:

  1. 根据购物车中勾选的商品,读取商品当前价格和库存;
  2. 计算总价,生成订单号;
  3. 插入订单主表(状态为待支付);
  4. 插入所有订单明细,同时扣减每个商品的库存、增加销量,最后清空购物车对应记录。

订单号生成不要用数据库自增id直接用,因为太容易暴露订单量,而且不好看。我用的是“时间戳 + 用户id + 随机数”拼成32位的订单号,再结合唯一索引防止冲突:

String orderNo = System.currentTimeMillis() + String.format("%04d", userId / 10000) + String.format("%04d", (int) (Math.random() * 10000));

如果哪天单量特别大,可以把订单号上移到单独的“号段生成器”来做雪花算法或分段发号。练手场景不用这么复杂,但你要知道订单号跟数据库主键是两回事。

提交订单方法上必须加@Transactional:

@Transactional(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-dd'T'HH:mm:ss这种带T的ISO格式,而前端组件想要的是yyyy-MM-dd HH:mm:ss。我用Jackson全局配置解决:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

这个配置看着简单,但有个前提:如果你的实体里日期字段用的是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运行。这里提醒几个我踩过的小坑:

  1. 打包前确认application.yml里是不是本地的数据库连接、Redis地址等;我中途就有一次把本地环境配置打进了生产包,部署后启动报数据库连不上。
  2. 服务器上执行java -jar时建议加-Xms256m -Xmx512m,防止默认堆内存跨度过大触发其他问题。
  3. 用nohup java -jar xxx.jar > app.log 2>&1 &启动,并把日志输出到文件里。不然一关终端,进程就没了,出事也没日志可以看。
  4. 如果部署在云端服务器,记得在安全组里放行对应端口,这句话听起来是废话,但每年都有人被这个卡住半天。

6. 项目做完之后:代码组织的几个个人习惯

最后聊点项目经验之外的东西,是关于代码本身的组织方式。这些东西不会直接体现在“功能是否跑通”上,但对代码的可维护性影响很大。

我习惯把Controller写薄,Service写厚。Controller里只做参数接收和结果封装,尽量不超过十行;真正的业务判断、异常处理全放Service里。这样日后排查问题,直接进Service找逻辑,不用在一堆HTTP相关代码里翻来翻去。

另一个习惯是自定义统一返回体。比如Result.success(data)和Result.error(msg),并且配合一个全局异常处理器@RestControllerAdvice。这样业务里抛出BusinessException("库存不足"),前端拿到的永远是结构一致的JSON,而不是各种格式混乱的报错信息。这个习惯看起来是增加了一点工作量,但一旦前后端对接,能少吵好几架。

数据库操作上,我坚持所有“删除”尽量用逻辑删除。理由很简单:用户误删了一个商品,如果物理删掉了,数据找不回来;逻辑删除只是一个标记翻转。虽然代码里查询时需要多留意deleted条件,但从数据安全的角度讲非常划算。

如果你打算在这个系统上继续扩功能,优先级最高的两个方向,一个是接入真正的支付流程(哪怕是模拟支付回调),另一个是把后台管理做成可视化图表,比如水果销量趋势。这两个方向都能让项目的完成度和可讲的故事上升一个等级,而且技术上都站在已有代码的基础上,不会推倒重来。

我当时的体会是:做完一个系统真正值钱的不在于“又写了一遍增删改查”,而是在这个过程中把一个个坑填平、把每一个选择背后的理由想清楚。等你带着这套完整的思路去面对下一个项目时,起点就完全不一样了。

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

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

立即咨询