说出来可能有点夸张,但前端展示万人同时在线的商品秒杀页面,后端管理端可能寒酸到只能录商品改库存。这套基于SpringBoot的机器人商城管理系统,本质上是把机器人这种高客单、参数复杂的“大件商品”从手动Excel表格管理,拉扯到正经电商轨道上的过程。它解决的不只是“能搜能下单”的问题,而是如何通过一套后台,让运营、客服、仓库都能对得上货和单。适合正在搞毕业设计、刚接手智能硬件项目,或者想快速搭一套稳定后台练手的开发者参考。
1. 项目整体设计与技术选型思路
1.1 这个项目到底在解决什么问题
机器人商城和卖衣服、卖零食的商城,表面看都是拿SpringBoot搭一套CRUD,实际上差别很大。机器人商品有几个让新手很头疼的特征:SKU维度复杂,同一型号能分标准版、旗舰版、加配版,每个版本的续航和传感器配置还不一样;商品详情参数多,光电池容量、最大负重、防护等级这些字段就能列一屏;订单履约节点长,用户下单后要经历出厂检测、物流发货、上门安装调试等多个环节。
如果照着衣服商城的表结构硬套,最常见的下场就是:商品参数塞进一个长长的JSON字段里,运营录入时眼睛看花;订单状态表里只有“待发货”“已发货”,机器人到了客户楼下要不要拆箱验收,系统根本管不到。这个项目在设计之初,就得奔着“机器人这个类目”去倒推数据结构和流程,这才是核心价值所在。
1.2 技术选型:为什么是SpringBoot而非SSH或微服务
先说结论:单机单体加SpringBoot,是这个量级项目的最优解。很多人一上来就想上SpringCloud那套注册中心、网关、配置中心,但真做起来,光搭建环境就能耗掉你一周时间,而且对本地调试极其不友好。反过来,SSH(Spring + Struts + Hibernate)已经是老古董,社区资料少,遇到问题连搜索都不好搜。
SpringBoot的优势在于自动配置和生态成熟。以数据库访问层为例,我实测下来,集成MyBatis-Plus比JPA更稳。JPA(Hibernate)虽然开发时很省代码,但针对商品参数多、查询条件碎片化的场景,它的动态条件和自定义分页SQL写起来反而别扭。而MyBatis-Plus提供了现成的分页插件、逻辑删除和代码生成器,对一个要求快上手的校园项目或者中小型管理系统,刚好够用,还能帮开发省掉大量样板代码。
补充一下数据存储的选择:MySQL 8.0兜底关系数据,Redis只用于验证码和登录令牌这类缓存场景。机器人商城毕竟涉及金额和库存,数据一致性排在第一位,Redis不参与核心交易链路,这点很关键。
1.3 整体架构:前后端分离还是服务端渲染
这部分我特别想多说一句:大多数新手栽跟头不是栽在代码上,而是栽在技术方案选型上。如果你正在做毕业设计或写简历项目,我强烈建议使用前后端分离的架构,因为企业里招人的时候看到Vue + SpringBoot,比看到Thymeleaf + jQuery更能让他给你开口聊的机会。
整套系统分为两层:
- 后台管理端(Admin):给运营、客服和仓库用的,包含商品管理、订单管理、用户管理、分类管理等模块,使用的技术方案是Vue 3 + Element-Plus + Axios。
- 前台商城端(Portal):面向C端用户的,提供商品浏览、搜索、购物车、下单、支付模拟和个人中心,可以复用同一套接口。
两层共用同一个后端,通过Spring Boot暴露RESTful API。这方便你后续做接口权限控制(用拦截器加上JWT校验),也更接近实际企业开发的协作模式。
2. 核心业务模块与数据库设计
2.1 模块划分:从商品到订单的完整链路
一个机器人商城管理系统,我建议分成六个模块来设计:
- 用户模块:包含用户注册、登录、角色管理。系统中内置admin和user两种角色,用户表和角色表分离比较合理,但如果你不想搞得太复杂,单独用一个role字段就够了。
- 商品模块:机器人分类、商品基本信息、商品详情参数、库存管理。
- 购物车模块:基于Redis临时存储的商城购物车,也支持离线登录时合并。
- 订单模块:用户下单、订单详情、订单状态流转。
- 支付模块:真实对接支付接口来不及的话,至少做模拟支付,让订单从未支付流转到待发货。
- 物流与售后模块:物流单号回填、签收确认,以及机器人常见的安装报告、维修工单登记。
其中订单模块是核心中的核心。一台机器人可能两三万,用户下单后反悔率其实不低,所以订单状态流转设计得越清晰,后续退款时才不会手忙脚乱。
2.2 数据库表结构设计要点
这是整个项目里最能体现“机器人商城”特点的地方。我来贴几张比较核心的表结构(缩略设计),大家可以顺着这个思路根据自己的实际需求做增减。
商品表(product)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| category_id | bigint | 分类ID |
| product_name | varchar(128) | 商品名称 |
| product_subtitle | varchar(255) | 副标题/卖点 |
| main_image | varchar(255) | 主图URL |
| detail_html | text | 图文详情(富文本) |
| price | decimal(10,2) | 售价 |
| stock | int | 库存 |
| brand | varchar(64) | 品牌 |
| parameters_json | text | 产品参数JSON(如续航、负载、充电时间) |
| status | tinyint | 上下架状态:1上架,0下架 |
| create_time | datetime | 创建时间 |
| update_time | datetime | 更新时间 |
注意这个parameters_json字段,我见到不少人在这一步忍不住要把“续航”“防水等级”“机身重量”拆成一个个单独的列,其实完全没必要。这种带强展示属性、弱检索要求的规格参数,最适合以JSON整体存取。运营录入时填一次,前端拿到直接按表格渲染即可。如果以后真的需要按“负载重量大于10kg”这种条件筛商品,再考虑把关键参数字段提出来单独建索引,过早上手设计是过度设计。
订单表(orders)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| order_no | varchar(32) | 业务订单号 |
| user_id | bigint | 下单用户 |
| total_amount | decimal(10,2) | 订单总金额 |
| pay_amount | decimal(10,2) | 实付金额 |
| pay_type | tinyint | 支付方式 |
| status | tinyint | 状态:0待支付、1待发货、2待收货、3已完成、4售后中 |
| receiver_name | varchar(32) | 收货人 |
| receiver_phone | varchar(20) | 联系电话 |
| receiver_address | varchar(255) | 收货地址 |
| logistics_company | varchar(32) | 物流公司 |
| logistics_no | varchar(64) | 物流单号 |
| create_time | datetime | 创建时间 |
| pay_time | datetime | 支付时间 |
| deliver_time | datetime | 发货时间 |
| finish_time | datetime | 完成时间 |
这里有个容易忽略的设计点:订单金额永远不要被商品价格反向计算。下单那一刻就要把商品单价、优惠金额、实付金额都存进订单表,事后商品改价不会影响历史订单。这是电商系统的不成文规定,体现了你对业务的深入理解,面试官问到时这是一个很加分的地方。
订单明细表(order_item)
包含订单ID、商品ID、商品名称快照、商品主图快照、购买数量、成交单价。快照是重中之重。还是那句话:商品可能改名、改价、上下架,但用户的订单必须保留下单那一刻的真实信息。快速领会这个逻辑:去某宝买了一件衣服,三个月后你点进订单看到的是你当时的尺码和价格,而不是它现在的营销文案。
2.3 角色权限和公共字段的工程化处理
这个系统里有管理端和商城端两个前端入口,但后端共用一套API。为了防止有人直接调接口越权操作,最简单有效的方式是:登录接口返回JWT字符串,前端把令牌存到本地,之后每个请求都在Header里带Authorization: Bearer <token>。
后端写一个AuthInterceptor拦截器,对所有非白名单接口校验JWT,同时解析出用户ID和角色放到请求上下文里。管理端接口再通过声明注解判断管理员身份,比如自定义一个@RequireAdmin注解,配合拦截器或AOP实现。
公共字段上,使用MyBatis-Plus的自动填充功能来维护create_time和update_time,在插入和更新时自动写入当前时间,省得每张表都手动setCreateTime。逻辑删除同理,配置逻辑删除字段后,执行删除操作会自动变成UPDATE标记,而不是物理DELETE。
3. 实操过程与核心功能实现
3.1 环境准备与项目初始化
以下版本组合是我多次实测下来最省心的一套:
- JDK 8(或11,这两个版本在SpringBoot 2.7.x下支持最好)
- Maven 3.6+
- MySQL 8.0+
- Redis 6.x
- SpringBoot 2.7.x
- MyBatis-Plus 3.5.x
- JWT(推荐
jjwt库,0.9.1或0.11.5)
项目结构我这里直接给出来,照抄就能用:
robot-mall ├── src/main/java/com/example/robotmall │ ├── common // 统一返回结果、常量、自定义异常 │ ├── config // 跨域、MyBatis-Plus分页、Redis配置 │ ├── controller // 控制层:admin和portal分目录 │ ├── service // 业务层:接口 + 实现类 │ ├── mapper // MyBatis-Plus数据访问层 │ ├── entity // 实体类 │ ├── dto // 前端传入参数对象 │ ├── vo // 后端返回视图对象 │ ├── interceptor // JWT拦截器 │ ├── utils // 工具类:JwtUtil、RedisUtil等 │ └── RobotMallApplication.java ├── src/main/resources │ ├── mapper // 自定义SQLXML │ └── application.yml └── pom.xml第一次跑通的时候,有几个配置细节容易被绊住。第一个是MySQL连接串,8.0版本必须带着时区参数:
spring: datasource: url: jdbc:mysql://localhost:3306/robot_mall?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver第二个是MyBatis-Plus的SQL打印,开发阶段建议开一下日志,方便排查:
mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl3.2 统一返回结构与全局异常处理
后端接口如果五花八门地返回Map、返回ModelAndView、又返回JsonObject,前端联调时想哭的心都有。从第一天就统一返回结构,可以省掉后面至少两天联调时间。
@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("success"); 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; } }配合@RestControllerAdvice做全局异常处理,把自定义业务异常(比如库存不足、订单状态非法)和系统兜底异常分开处理。实际开发中,我通常在代码里直接throw自定义异常,而不是到处写返回Result.error(),这样代码可读性更强,也不会因为漏写return类型导致参数不匹配。
3.3 商品分页查询:条件构造器的正确用法
商品列表是商城门户的主页核心,也是后台管理端右侧列表的核心。MyBatis-Plus的分页插件配置好之后,代码非常简洁:
@GetMapping("/list") public Result<IPage<ProductVO>> list(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "8") Integer size, @RequestParam(required = false) String keyword, @RequestParam(required = false) Long categoryId, @RequestParam(required = false) Integer status) { LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(keyword), Product::getProductName, keyword) .eq(categoryId != null, Product::getCategoryId, categoryId) .eq(status != null, Product::getStatus, status) .orderByDesc(Product::getUpdateTime); IPage<Product> productPage = productService.page(new Page<>(page, size), wrapper); return Result.success(productPage); }这段有个逻辑上的先后顺序:条件构造器的condition为false时,该条件不会拼进SQL,所以模糊搜索为空时不干扰,状态为null时不干扰。这种写法比手写一大堆if判断加StringBuffer拼接SQL优雅得多,也安全得多,完全规避了SQL注入的隐患。
分页返回的IPage<Product>自带records、total、current、size,前端拿数据直接渲染即可。如果你想避免一些敏感字段(比如成本价)泄露到前端,记得把实体类和VO类分离,实体类字段用@TableField(exist = false)排除非表字段,VO类上只暴露需要的属性。这个习惯在简历上写“熟悉企业级分层设计”时是有底气的,因为很多培训班学员根本不会意识到实体不能直接做返回对象。
3.4 订单创建:事务与库存扣减的经典配合
订单创建是整个系统里逻辑最密集、最容易出bug的地方,牵涉多张表联动。核心代码如下:
@Transactional(rollbackFor = Exception.class) public Long createOrder(CreateOrderDTO dto) { // 1. 校验用户地址,生成收货信息快照 UserAddress address = addressService.getById(dto.getAddressId()); // 2. 根据购物车或直接选择商品,构建订单项列表 List<CartItemVO> cartItems = cartService.getCheckedItems(dto.getUserId()); if (CollectionUtils.isEmpty(cartItems)) { throw new BusinessException("未选择任何商品"); } // 3. 计算总金额、判库存 BigDecimal totalAmount = BigDecimal.ZERO; List<OrderItem> orderItems = new ArrayList<>(); for (CartItemVO item : cartItems) { Product product = productService.getById(item.getProductId()); // 乐观锁扣减库存,SQL层面执行 update ... set stock = stock - ? where id = ? and stock >= ? boolean success = productService.deductStock(product.getId(), item.getQuantity()); if (!success) { throw new BusinessException("商品【" + product.getProductName() + "】库存不足"); } OrderItem orderItem = new OrderItem(); orderItem.setProductId(product.getId()); orderItem.setProductName(product.getProductName()); orderItem.setProductImage(product.getMainImage()); orderItem.setProductPrice(product.getPrice()); orderItem.setQuantity(item.getQuantity()); orderItems.add(orderItem); totalAmount = totalAmount.add(product.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); } // 4. 生成订单主记录 Orders order = new Orders(); order.setOrderNo(generateOrderNo()); order.setUserId(dto.getUserId()); order.setTotalAmount(totalAmount); order.setPayAmount(totalAmount); order.setStatus(0); order.setReceiverName(address.getReceiverName()); order.setReceiverPhone(address.getReceiverPhone()); order.setReceiverAddress(address.getDetailAddress()); orderService.save(order); // 5. 批量保存订单明细 orderItems.forEach(item -> item.setOrderId(order.getId())); orderItemService.saveBatch(orderItems); return order.getId(); }@Transactional保证了这个方法里所有数据库操作要么全部成功、要么全部回滚。比如第四步订单主记录保存成功了,但第五步商品明细保存失败,这个订单残留就成了脏数据——有了事务就不会有问题。
库存扣减单拎出来说一下,我专门写了一个方法:
@Update("UPDATE product SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity}") int deductStock(@Param("id") Long id, @Param("quantity") Integer quantity);这种写法用数据库的行锁原子更新天然避免了超卖问题。两个用户同时抢最后一台机器人时,数据库会串行化这条SQL,后执行的那个人更新行数为0,我们据此判断库存不足即可,比先查库存再在Java层判断安全得多。
3.5 JWT登录与拦截器配置,以及跨域问题
登录流程很多同类教程都写了,这里挑容易被卡住的细节。用户校验通过后,用jjwt生成token:
String token = Jwts.builder() .setSubject(String.valueOf(user.getId())) .claim("username", user.getUsername()) .claim("role", user.getRole()) .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 3600 * 1000L)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();我实际测试时踩过的坑是:secretKey如果长度太短,某些版本的jjwt在解析时会直接抛异常报密码强度不足WeakKeyException。所以强烈建议用一个至少32位的字符串作为密钥,不要随便写个"abc"上去。
拦截器配置中需要有一个放行清单:用户登录、注册接口、商品列表页接口、商品详情接口以及首页的公开数据都可以不用鉴权,剩下的购物车、订单、后台管理等接口都要校验。跨域配置放在WebMvcConfigurer中实现,设置允许来源、允许方法等。开发环境我用allowedOriginPatterns("*")方便联调,上线前再收紧。
4. 常见问题与排查技巧实录
这里记录几个我在开发过程中真实遇到的坑,基本都是网上搜不到现成答案或者答案比较零散的,希望帮后来人避坑。
4.1 日期序列化成了时间戳的数字串
排查场景:前端显示订单列表时,发现下单时间是一串数字1693000000000,不是2023-08-25 12:00:00。
原因分析:JSON序列化时,Jackson默认对java.util.Date和java.time.LocalDateTime的处理方式不同。java.util.Date会被序列化成时间戳。
解决办法有两种:
- 在实体类的日期字段上加注解:
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private LocalDateTime createTime;- 在全局配置里统一设置日期格式,避免每个字段都加。
4.2 BigDecimal和Double的资金精度问题
我见过有些同学图省事,金额字段直接用Double,最后算出来的总金额出现158.99999999997这种诡异数字,一排查就是浮点误差。
解决办法只有一条路:涉及金额的字段就用BigDecimal,而且建议用字符串构造器new BigDecimal("99.90"),不要用new BigDecimal(99.9),否则会得到99.90000000000000568这个经典的反例。
4.3 逻辑删除与唯一索引冲突
如果给商品表的某个字段加了唯一索引(比如机型编号model_code),打开逻辑删除后,删除某款机型再次录入同型号时,发现提示“Duplicate entry”。原因很简单,逻辑删除只是改了deleted字段不是真正删掉行,所以唯一索引仍然被占用。
解决思路:
- 唯一索引改为联合索引,把
deleted字段包含进去。 - 或者删除时把
model_code加上删除时间戳后缀。
两种方案都有取舍,第一种更接近业务直觉,但实现时需要删掉原来的单列索引、重新建联合索引。
4.4 分页总数不对或内存溢出
排查场景:后台订单列表一页查不到数据,但库里明明有几十条。
这个问题90%是配置了MyBatis-Plus分页插件但没有配置PaginationInnerInterceptor。注意这个拦截器必须声明为@Bean并且在配置类中生效,否则Page对象无法正常执行COUNT查询。
另一类分页问题是在自定义SQL中,如果SQL里带多表关联、又需要回表查明细,COUNT语句会被自动生成成嵌套查询,导致COUNT很慢。这种场景建议自定义单独的COUNT SQL,或者尽量拆分查询再用Java内存合并。分页这块用的数据量不大就可以先不管,但脑子里要有这根弦。
4.5 前端调用接口时遇到了跨域报错No ‘Access-Control-Allow-Origin’ header
排查场景:前端Vue开发服务器跑在http://localhost:5173,后端SpringBoot跑在http://localhost:8080,AJAX请求直接报跨域错误。
解决办法:在SpringBoot后端配置全局CORS。注意要对应前端实际运行的端口,不能盲目用*,因为带Authorization头时浏览器不支持通配。
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("http://localhost:*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }5. 一点个人体会
做这个项目时,我最强烈的感受是:技术难点从来不是SpringBoot的注解怎么用,而是你是否愿意把“机器人商城”当成一个真正有人用的系统来设计。
比如你会不会为订单里的商品存快照?你会不会在订单状态上设计出一个“售后中”?你会不会为了防超卖去写一条带条件的UPDATE语句?这些细节才是让一个CRUD系统变成“商城管理系统”的分水岭。
如果你打算把这个项目作为毕业设计或简历项目,我建议在答辩或面试时可以有意识地讲清楚两点:一是你如何做技术选型的,为什么用MyBatis-Plus而不是JPA;二是你在库存扣减和订单事务上是如何保证数据一致性的。这两点覆盖了业务理解和技术深度,比罗列“我用了Redis、用了JWT、用了Vue”之类的关键词更有说服力。
最后说个具体的体验:整个项目从初始化骨架到核心模块跑通,熟悉SpringBoot的开发者大概需要一周左右的课余时间。中间不要贪多,先跑通商品展示和下单主流程,再逐步加后台管理、优惠券、下单统计这类延展功能。复现这个项目的意义不在于照搬代码,而在于把“商城系统”背后的一整套数据设计逻辑吃透,今后换任何行业类型的后台系统,你都能快速举一反三。