简介:基于SpringBoot的电商平台项目,面向计算机相关专业毕业设计、课程设计与Vue期末大作业场景,适合需要快速搭建前后端分离电商系统的开发者,也可作为入门级企业电商项目范本。项目整合Spring Data JPA、Spring Security与Vue.js,覆盖商品、用户、订单等核心业务模块,并包含Maven多环境配置、日志与性能优化处理。压缩包共81个文件,以jsp页面、Java源码、SQL脚本、properties配置、XML配置为主,另有Eclipse项目文件和前端js/css资源,其中SQL脚本用于初始化数据表,配置文件支撑后端环境,整体仅1.27MB,便于下载与本地部署。已有52人学习,适合据此理解SpringBoot自动配置、RESTful接口设计及电商业务实现路径。内容包含初始化数据库脚本、构建配置文件、部署说明以及前后端分离的目录结构,读者可对照源码熟悉订单与商品模块的实现细节,从中获得一套可运行、可扩展的电商平台基础工程与排错参考。
1. 拿到这个标题,先想清楚它到底要你做什么
如果你正对着“基于SpringBoot的电商平台”这个项目标题准备开题或者开始搭代码,我猜你手上拿到的大概率是一个毕设题目、课程设计大纲,或者公司内网某份被翻烂了的参考文档。我的建议是先别急着找现成代码包,因为这一类项目表面看是商品展示加购物车下单,实际上真正决定你能不能过评审、能不能上线跑稳的,是订单状态流转、库存扣减和支付回调这三个深水区。
这套东西我前后搭过三版,最早的版本连购物车都没做,商品表一张表打天下,结果订单和库存对不上,对账时直接翻车。后来按一套固定的结构重新拆,才把复杂度压住。这篇文章我就按SpringBoot电商平台这个标题,把一个单体项目从表结构到订单状态机完整拆给你,包含可以直接抄的代码、每一条SQL的设计理由,以及我踩过的那些坑。适合三类人:正在做毕设的学生、刚接手电商后端的新人、以及想快速搭一个演示系统的开发者。
2. 数据库设计与工程骨架:先把地基夯死
2.1 商品和订单的三张核心表,DDL 直接抄
电商平台不管前端页面怎么花哨,后端落到数据库里,有几个实体是绕不开的:用户、商品、订单。我见过很多项目把商品属性和库存全塞在一张表里,SKU(库存量单位)和SPU(标准化产品单元)完全不分,当时能用,等加上规格和促销就难受了。我一般会拆成商品表、商品规格表、订单表三张核心表,先不碰用户表和营销表,把主链路先跑通。
下面这份DDL是我常用的最小可用版本,去掉了营销、优惠券、物流等周边字段,集中解决“卖什么、以什么价格卖、卖出后怎么记”这一条主链路。
-- 商品表:一个商品对应一条记录,是商品的公共属性 CREATE TABLE `goods` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '商品ID', `goods_sn` varchar(32) NOT NULL COMMENT '商品编号,对外展示用', `title` varchar(120) NOT NULL COMMENT '商品标题', `category_id` bigint(20) DEFAULT NULL COMMENT '分类ID', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1上架 0下架', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_goods_sn` (`goods_sn`), KEY `idx_category` (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表'; -- 商品规格表:同一个商品可以有多条规格,价格和库存挂在规格上 CREATE TABLE `goods_sku` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'SKU ID', `goods_id` bigint(20) NOT NULL COMMENT '商品ID', `sku_code` varchar(64) NOT NULL 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(4) NOT NULL DEFAULT '1' COMMENT '状态:1启用 0停用', PRIMARY KEY (`id`), KEY `idx_goods_id` (`goods_id`), UNIQUE KEY `uk_sku_code` (`sku_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品规格表'; -- 订单表:一个订单包含一个收货人和一组商品快照 CREATE TABLE `orders` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '订单ID', `order_sn` varchar(32) NOT NULL COMMENT '订单号,业务维度唯一', `user_id` bigint(20) NOT NULL COMMENT '下单用户ID', `total_amount` decimal(10,2) NOT NULL COMMENT '订单总金额', `pay_amount` decimal(10,2) NOT NULL COMMENT '实付金额,预留优惠抵扣位', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0待支付 1已支付 2已发货 3已完成 4已取消 5已退款', `receiver_name` varchar(30) NOT NULL COMMENT '收货人姓名', `receiver_phone` varchar(20) NOT NULL COMMENT '收货人电话', `receiver_address` varchar(200) NOT NULL COMMENT '收货地址', `remark` varchar(200) DEFAULT NULL COMMENT '订单备注', `pay_time` datetime DEFAULT NULL COMMENT '支付时间', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_sn` (`order_sn`), KEY `idx_user_id` (`user_id`), KEY `idx_status_create` (`status`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';三个设计点提醒你注意。第一,价格和小数相关的字段一律用decimal(10,2),不要用double或者float,否则金额对账会出现玄学误差。第二,订单表里存的是商品快照字段(比如收货人、商品标题、价格),而不是下单后实时去查商品表,这样做是为了防止商品下架或改名之后订单数据跟着变,这是电商订单表的一个基本原则。第三,订单号独立于ID单独建唯一索引,对外查询、对账、客服沟通都用order_sn,自增ID只做内部关联用。
2.2 分层结构:controller、service、mapper 之间别跳层
工程结构方面,SpringBoot电商平台最常见的骨架是四层:controller 接参数、service 写业务、mapper 操作数据库、domain 放实体。很多人为了省事直接在 controller 里写查询逻辑,当时觉得代码少,后面每加一个接口都要翻一遍,维护成本直线上升。我习惯用下面这套包结构:
com.example.mall ├── controller // HTTP 接口层,只做参数接收和结果包装 ├── service // 业务层,事务边界都在这层 ├── mapper // 数据访问层,对应 MyBatis 的 Mapper 接口 ├── domain // 实体类、枚举、DTO ├── config // 配置类,比如拦截器、跨域、线程池 ├── common // 统一响应、异常处理、工具类 └── MallApplication.java我在服务层单独划了一个service接口加实现类的结构,Controller 只依赖接口。这不算过度设计,因为电商的下单流程涉及库存、订单、支付回调等多个数据操作,接口隔离能让你在不改调用方的前提下替换实现,测试时也方便Mock。
2.3 统一响应体与全局异常,越早做越省事
接口返回值如果不统一,前端联调时每个接口都要单独适配,错误提示也没法做全局拦截。这个项目里我定义了一个Result<T>类,所有接口都返回这个结构:code 表示业务状态码,data 放业务数据,message 放提示信息。配合@RestControllerAdvice做全局异常处理,业务代码里只需要抛业务异常,HTTP层会自动包装成统一的JSON结构返回。
public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.setCode(0); r.setMessage("ok"); r.setData(data); return r; } public static <T> Result<T> fail(String message) { Result<T> r = new Result<>(); r.setCode(-1); r.setMessage(message); return r; } // getter/setter 省略 }这段代码没什么玄机,核心是用静态工厂方法统一创建成功和失败的结果,避免每个Controller里都手动堆对象。code字段我建议保留0作为成功,非0全部视为失败,前端判断逻辑只要写if(res.code === 0)就够了。等到后面接支付回调、对接管理后台时,这套统一结构能省下大量联调时间。
3. 登录与鉴权:用 JWT 把会话状态从服务端请走
3.1 为什么电商单体项目更推荐 JWT 而不是 Session
很多第一次做电商项目的人会直接用 Session 保存登录态,但在没有专门会话服务的情况下,Session 有两点不方便:一是服务端要维护会话存储,项目多实例部署时得额外引入共享存储;二是前后端分离时 Session 的 Cookie 机制和跨域配置经常打架。JWT(JSON Web Token)把用户标识和过期时间放在令牌里,服务端不存状态,校验时只验签名和有效期,这在单体电商项目里是更轻量的做法。
要注意的是,JWT 本身不能主动失效——你没法在服务端直接把某个用户的令牌作废,所以它的过期时间不能设置太长。我的习惯是登录令牌有效期设为2小时,同时要求前端在令牌过期前刷新,配合一个活跃度过滤。管理后台的令牌可以单独放宽到12小时,但要加上操作日志,这一点后面在避坑章节会细说。
3.2 登录接口与 JWT 签发逻辑,40 行代码跑通
先看登录接口。为了保持示例简洁,这里省去了验证码和密码加密的部分,生产环境密码一定要用BCrypt加密,不要用MD5加盐这种已经不被推荐的做法。
@Service public class AuthService { private final UserMapper userMapper; private final TokenManager tokenManager; public AuthService(UserMapper userMapper, TokenManager tokenManager) { this.userMapper = userMapper; this.tokenManager = tokenManager; } public String login(String username, String password) { User user = userMapper.selectByUsername(username); if (user == null) { throw new BizException("用户不存在"); } if (!passwordEncoder.matches(password, user.getPassword())) { throw new BizException("密码错误"); } if (user.getStatus() != 1) { throw new BizException("账号已被禁用"); } // 签发令牌:传入用户ID和角色 return tokenManager.createToken(user.getId(), user.getRole()); } } @Component public class TokenManager { private final SecretKey key; public TokenManager() { this.key = Keys.hmacShaKeyFor("your-secret-key".getBytes(StandardCharsets.UTF_8)); } public String createToken(Long userId, String role) { Date now = new Date(); Date expiry = new Date(now.getTime() + 2 * 60 * 60 * 1000); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("role", role) .setIssuedAt(now) .setExpiration(expiry) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); } }这段代码里有两个关键参数要说明。第一个是密钥,上面代码里的"your-secret-key"只是示例,实际项目必须放在配置文件或环境变量里,长度至少32字节,否则HS256算法会报错。第二个是过期时间2小时,时间单位是毫秒,2 * 60 * 60 * 1000就是7200000毫秒。如果业务需要用户长时间免登录,可以把过期时间放到7天,但一定要配合操作敏感操作时二次校验密码。
3.3 拦截器配置:哪些接口放行,哪些必须带令牌
登录校验我一般用Spring的HandlerInterceptor实现,而不是在每个Controller方法里手写判断。下面这段配置直接决定了接口的访问策略。
@Configuration public class WebConfig implements WebMvcConfigurer { private final AuthInterceptor authInterceptor; public WebConfig(AuthInterceptor authInterceptor) { this.authInterceptor = authInterceptor; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/api/**") .excludePathPatterns( "/api/auth/login", "/api/goods/list", "/api/goods/detail/**" ); } }拦截器的核心要点:如果用addPathPatterns("/api/**")拦截全部业务接口,那么注册、登录、商品分页这种不需要登录就能访问的接口,必须显式放进excludePathPatterns里,否则前端一进来就被拦。另外一个典型坑是静态资源的放行,如果/api/**不包含静态资源路径,通常问题不大,但如果把拦截范围写成了/**,就必须把/static/**、/templates/**、/error都排除掉。
拦截器里拿到用户信息后,用request.setAttribute("userId", userId)的方式往下传,Controller才能从请求对象里安全的拿到当前登录人。不要图省事把用户信息放到ThreadLocal里然后忘了清理,在线程池场景下这是经典的内存泄漏来源。
4. 商品查询与购物车:把“加购扣库存“这个核心链路先跑通
4.1 商品分页查询:where 条件要防注入,排序要防慢查询
商品列表是电商平台的流量入口,这个接口几乎所有页面都会调。最常见的实现方式是分页加条件筛选,前端传page、size、categoryId、keyword,后端返回商品列表和总数。这里值得注意的不只是MyBatis的<if>动态SQL,更关键的是排序和索引的匹配。
<select id="selectGoodsPage" resultType="com.example.mall.domain.Goods"> SELECT g.id, g.goods_sn, g.title, g.category_id, g.status, MIN(s.price) AS min_price FROM goods g INNER JOIN goods_sku s ON g.id = s.goods_id <where> g.status = 1 <if test="categoryId != null"> AND g.category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND g.title LIKE CONCAT('%', #{keyword}, '%') </if> </where> GROUP BY g.id, g.goods_sn, g.title, g.category_id, g.status ORDER BY g.id DESC LIMIT #{offset}, #{size} </select>LIKE查询走不上索引,这个SQL里g.title LIKE CONCAT('%', #{keyword}, '%')是最容易造成慢查询的地方。数据量在几万条以内问题不大,超过十万条之后,这段条件查询的响应时间会明显爬升。我建议如果项目只是演示用,保持这个写法没问题;如果要去生产环境,就接上Elasticsearch做商品搜索,MySQL这条路就只扛基础的分类筛选。
另外要注意GROUP BY g.id, ...这行,因为子查询里没有其它聚合列,所以分组后取MIN(s.price)是安全的写法。MySQL默认开启了ONLY_FULL_GROUP_BY模式,如果只GROUP BY g.id而select里还带着g.title,高版本MySQL会直接报错,这一点在SQL编写时就要留意。
4.2 加入购物车:库存校验在写库前做一次,提交订单时再做一次
购物车本身没有太深的技术含量,核心就是一个cart_item表,记录用户、SKU和数量。我把它放到和订单同一个数据库里,不单独建库,因为在单体项目里业务量还远没到需要拆库的程度。
public void addToCart(Long userId, Long skuId, Integer num) { if (num == null || num <= 0 || num > 99) { throw new BizException("购买数量必须在1到99之间"); } SkuStockDTO sku = skuMapper.selectBySkuId(skuId); if (sku == null || sku.getStatus() != 1) { throw new BizException("商品规格不存在或已下架"); } if (sku.getStock() < num) { throw new BizException("库存不足"); } CartItem existing = cartMapper.selectByUserAndSku(userId, skuId); if (existing != null) { cartMapper.increaseNum(userId, skuId, num); } else { CartItem item = new CartItem(); item.setUserId(userId); item.setSkuId(skuId); item.setNum(num); item.setChecked(true); cartMapper.insert(item); } }这段逻辑是标准的“先查后写”,注意这只是把商品放进购物车,不涉及真正的库存扣减,所以这里的库存判断只需要给出友好提示,不用为了并发去加锁。真正的并发控制发生在提交订单那一刻,下面第三节讲的扣减库存SQL才是重头戏。
4.3 扣减库存的正确姿势:把“先查后改”换成“条件更新”
这是整个电商后端里最容易出问题的代码。如果按直觉写“先select查库存,再update减库存”,在高并发下两个请求同时读到库存为1,然后都做了减1操作,最终数据库里库存变成-1,这就是超卖。SpringBoot单体项目里,解决超卖最常见也最可靠的做法是让数据库自己来判断库存是否够用:
UPDATE goods_sku SET stock = stock - #{num} WHERE sku_id = #{skuId} AND stock >= #{num}这条SQL的精髓在于把“查询库存是否足够”和“扣减库存”两个动作合并成一个原子操作。stock >= #{num}是数据库层面的条件判断,影响行数为0就说明库存不足,这一步执行成功后库存已经减掉了,不需要再select一次。配合方法上的@Transactional,就能保证同一事务范围内订单创建和库存扣减要么都成功,要么都回滚。
我见过有人在这条SQL前面加SELECT ... FOR UPDATE,把SKU行锁住再扣减。在单体项目里这个做法也能解决超卖,但代价是并发能力下降,因为同一SKU的扣减请求被串行化了。用上述条件更新则不同,InnoDB的行锁只用在真正更新的那一瞬间,吞吐量更高。至于Redis预扣库存那一套,是千万级流量的玩法,这个项目用不上,别提前上复杂度。
5. 订单与支付回调:状态机设计和这 5 个必踩坑
5.1 订单状态机:用枚举限定流转方向,拒绝 if else 乱跳
订单状态是整个电商项目里业务规则最密集的地方。我建议先把状态流转画清楚,再用代码实现。最简单可靠的版本是:待支付(0)可以变成已支付(1)、已取消(4);已支付(1)可以变成已发货(2)、已退款(5)、已完成(3);已发货(2)只能变成已完成(3)。所有非法流转,比如从待支付直接跳已完成,直接抛异常。
public enum OrderStatusEnum { UNPAID(0, "待支付"), PAID(1, "已支付"), SHIPPED(2, "已发货"), COMPLETED(3, "已完成"), CANCELLED(4, "已取消"), REFUNDED(5, "已退款"); private final int code; private final String desc; OrderStatusEnum(int code, String desc) { this.code = code; this.desc = desc; } public boolean canChangeTo(OrderStatusEnum target) { switch (this) { case UNPAID: return target == PAID || target == CANCELLED; case PAID: return target == SHIPPED || target == COMPLETED || target == REFUNDED; case SHIPPED: return target == COMPLETED; default: return false; } } public int getCode() { return code; } }有了这个枚举,订单状态更新就变成一句安全的带乐观锁条件的SQL:UPDATE orders SET status = #{target} WHERE order_sn = #{orderSn} AND status = #{expect}。这里status = #{expect}这个条件的价值在于,即使两个请求同时对同一订单做了状态变更,数据库也会只让其中一个成功,另一个影响行数为0,事务回滚。用代码去管理状态流转范围,再用SQL去兜底并发,双保险。
5.2 支付回调接口:幂等设计比信任前端通知更可靠
在毕设或演示项目里无法对接真实的支付渠道,我一般会自己写一个模拟的支付回调HTTP接口,方便前后端联调和演示。这个模拟回调会接收订单号、支付金额、支付流水号三个参数,处理逻辑和真实回调保持一致。
@PostMapping("/mock/pay/notify") public Result<String> payNotify(@RequestBody PayNotifyDTO dto) { // 1. 校验订单是否存在 Order order = orderMapper.selectByOrderSn(dto.getOrderSn()); if (order == null) { throw new BizException("订单不存在"); } // 2. 幂等判断:订单已经是已支付状态,直接返回成功 if (order.getStatus() == OrderStatusEnum.PAID.getCode()) { return Result.ok("重复通知已处理"); } // 3. 校验支付金额和订单应付金额一致 if (order.getPayAmount().compareTo(dto.getPayAmount()) != 0) { throw new BizException("支付金额与订单金额不匹配"); } // 4. 更新订单为已支付 int rows = orderMapper.updateStatusByOrderSn(dto.getOrderSn(), OrderStatusEnum.PAID.getCode(), OrderStatusEnum.UNPAID.getCode()); if (rows == 0) { throw new BizException("订单状态更新失败"); } return Result.ok("支付成功"); }这段代码最重要的不是更新订单,而是第二步的幂等判断。真实场景中支付平台的通知可能重复发送,本地代码也可能在超时后重试,如果没有这个判断,同一个订单会被处理两次,库存被扣两次,对账就全乱了。幂等判断的可靠做法是先查状态,再按UNPAID -> PAID的条件更新,这样即使两个通知同时到达,数据库层面也只会成功一个。
5.3 避坑:单测靠运气发现的 5 个常见问题
以下是这个项目从开发到联调过程中最有代表性的 5 个坑,每个都是先出现现象,再查根因,最后才定位到问题点。
坑一:支付金额对不上,浮点类型惹的祸。现象是商品单价9.9,订单总金额计算出来却是9.899999。原因是订单金额在计算时用了double类型的局部变量,浮点运算的精度问题在金额这种连续累加的场景下必然露馅。解决方式很直接,金额一律用BigDecimal运算,或者干脆用整数分作为金额单位,前端展示时再转元。这个坑属于典型的“看着跑通了,一算钱就出事”。
坑二:商品详情接口可以查到别人的订单。现象是A用户用浏览器手动访问/api/order/detail/10001,居然能查到B用户的订单信息。原因是为了省事,订单查询接口只按订单ID查询,完全没有校验订单归属。解决方式是所有订单详情查询都要带上userId条件:WHERE order_sn = ? AND user_id = ?,不满足就直接返回“订单不存在”。这一步不是可选项,是安全底线。
坑三:取消订单后库存没有归还。现象是用户下单锁了库存,然后主动取消订单,再去查商品库存发现数量没变。原因是当初只在创建订单时做了库存扣减,没有做取消订单的库存回补逻辑。解决方式是在订单状态更新到已取消时,同一个事务内执行库存返还:UPDATE goods_sku SET stock = stock + #{num} WHERE sku_id = #{skuId}。注意返还库存和更新订单状态必须在同一个@Transactional方法里,否则中途报错会出现状态改了库存没回的脏数据。
坑四:支付回调里更新订单用了“先查再改”导致状态覆盖。现象是支付回调处理期间,用户主动取消了订单,结果支付回调还是把订单更新成了已支付。原因是回调逻辑先查了订单状态,然后不管当前状态,直接把状态字段更新为目标值。解决方式就是前面代码里展示的乐观锁写法,更新SQL强制带上当前期望状态,影响行数为0就说明状态已经变化,要重新处理。
坑五:启动项目时提示“无效的绑定字段”或者接口直接400。现象是请求参数总是解析失败,或者运行时报Failed to convert property value of type 'java.lang.String' to required type 'java.lang.Integer'。原因多数是DTO里出现了JSON格式错误、前端传了空字符串而后端用了Integer接收,或者对象名的setter写法不规范。解决方式是统一用@RequestBody接收JSON,所有接口参数都定义为DTO而不是散装参数,前端联调时开浏览器DevTools看Network的Payload是否符合约定。这个坑不涉及高深技术,但遇到的频率最高。
6. 打包部署与验收:让项目能跑起来还不够,要能稳定演示
6.1 跳过测试打包并用 nohup 启动:演示前最重要的命令
项目开发完,离真正能演示还差一步:打包、启动、冒烟。我最推荐的方式是用Maven打成可执行Jar包,然后用nohup脱离终端运行,这样即使SSH断开,服务也不会跟着断。
# 跳过测试,把项目打成可执行Jar包 mvn clean package -DskipTests # 后台启动,日志输出到指定文件 nohup java -jar target/demo-mall-0.0.1-SNAPSHOT.jar --server.port=8080 > logs/app.log 2>&1 & # 确认端口在监听 ss -tlnp | grep 8080-DskipTests的意思是跳过单元测试直接打包,在赶演示版本时可以加速编译;如果团队有强制测试要求,应该用-Dmaven.test.skip=true,它连测试代码都不会编译。2>&1把标准错误重定向到标准输出,保证异常栈也会写进日志文件。排错时最常用的命令是tail -f logs/app.log,看到滚动日志基本说明启动正常。
如果你是在某个集成开发环境里直接点绿色三角运行的,那只适合开发和断点调试,演示时弹窗或接口地址不稳定会很尴尬。提前跑通这个打包命令,能给自己留出充足时间处理环境问题。
6.2 用一份冒烟测试清单代替“觉得没问题”
演示翻车大多不是功能缺失,而是某个接口在特定参数下没验证过。我习惯在答辩或演示前,用命令行工具按下面这份清单把接口全过一遍。如果项目里没有现成的API调试工具环境,用curl就够。
# 1. 登录获取令牌 curl -X POST http://localhost:8080/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"demo","password":"123456"}' # 2. 带令牌查询商品列表 curl http://localhost:8080/api/goods/list?page=1\&size=10 \ -H "Authorization: Bearer <上面返回的token>" # 3. 创建订单(先确认skuId和库存数量) curl -X POST http://localhost:8080/api/order/create \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <上面的token>" \ -d '{"skuId":1,"num":2,"receiverName":"张三","receiverPhone":"13800000000","receiverAddress":"某市某区某路1号"}'第一轮按照正常流程跑通,第二轮马上测试异常场景:库存不足时下单看是否返回友好错误;重复请求同一个支付回调看幂等是否生效;把token改成一段乱码看是否被拦截器拒绝。一个稳定可靠的电商演示,不是“正常流程能走通”,而是“关键异常都有提示,不会直接抛500”。
6.3 给订单号加一个全局唯一前缀:一个受用于整个项目的细节
这个技巧是我在第三次做电商项目时才养成的习惯:每个订单号在前缀里直接体现业务和日期,比如MALL202409081213456789。它有两层意义。第一层是排查问题方便,客服报来一个订单号,不用查数据库就能看出是哪天下单、哪个渠道产生的;第二层是避免多套环境的数据混淆,如果同时跑本地环境和测试环境,前缀能立刻区分订单来源。
public static String generateOrderSn() { return "MALL" + DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(LocalDateTime.now()) + RandomUtil.randomNumbers(6); }前缀拼时间再加六位随机数,每天并发量有限时几乎不会重复。有人会担心随机数撞车,所以订单表里还是保留了uk_order_sn唯一索引,真撞了数据库会报错,重试一次即可。这个做法不依赖任何中间件,却能在查日志、对账、客服沟通时省下大量时间。我在每个电商项目的订单服务里都会保留这个习惯,希望你用上后也能体会到它的价值。今天的分享就到这里,希望帮到你。
本文还有配套的精品资源,点击获取