
简介电商后端开发中交易系统的数据一致性一直是工程实践的核心挑战。SpringBoot作为Java生态主流的微服务开发框架以其自动化配置和快速部署能力成为构建中小型交易系统的首选。在抢购、限量商品等场景下库存超卖、重复支付等问题需要从数据库原子扣减、缓存中间件和幂等设计等原理层面入手解决。本文以潮玩交易系统为例结合SpringBoot、MyBatis-Plus、Redis与Lua脚本讲解订单模块拆分、数据库表设计、JWT鉴权、防超卖扣减及模拟支付回调等完整实现路径帮助开发者快速掌握一套可落地的电商后端解决方案。1. 潮玩交易系统为什么选这个题以及它到底在解决什么潮玩盲盒、限量手办、积木熊交易里最痛的一件事是信息不对称同款在不同平台差价能到一倍卖家怕遇到到手刀买家怕买到假货。SpringBoot 基于 Java 的潮玩交易系统这个选题本质上就是做一个带库存、订单、支付、验货流程的二手/新品电商后端用 SpringBoot 框架解决「配置地狱」用 Java 生态解决「毕业设计/答辩对完整性的要求」。这篇写给正在做这个题目的毕业生也写给想用一个完整项目理解 SpringBoot 分层设计的新手。我会从模块拆分、表设计、下单链路、防超卖一路讲到论文写作和答辩踩坑目标是让你照着这条路能把系统跑起来也能把论文写得有底气。2. 用 SpringBoot 搭建潮玩交易后端模块拆分与数据库表设计2.1 为什么选 SpringBoot Java以及它的边界SpringBoot 框架在毕设和中小型项目里的地位不用多说。它内嵌 Tomcat打一个 jar 就能启动不用像 SSM 时代那样先装 Tomcat、再配 web.xmlMaven 管依赖MyBatis-Plus 管 CRUD开发效率比传统 SSH 高一大截。而 Java 本身在高校课程和面试题里出现频率最高选它意味着你复习八股和答辩被提问时网上能查到的资料最多。这个选择题其实不是技术题是风险题。但 SpringBoot 也有边界。它本质上是一个「粘合层」本身不解决业务问题。潮玩交易系统的核心难点不在框架而在交易状态、库存扣减和验货流程。我一般把这个系统分成三块用户与权限、商品与验货、订单与支付。框架负责把这三块快速串起来业务逻辑还是得自己一行行写。下面这套模块划分是我按这个思路做过多次的方案。2.2 模块怎么拆按业务边界别按三层架构堆很多同学的 SpringBoot 项目结构是 controller / service / mapper 各建一个包然后所有类全扔进去。我可以直接说单表 CRUD 这样写没问题但一旦写到订单状态流转和支付回调这种结构就会乱套。常见做法是按业务域拆包让「改一个功能只动一个包」成为可能。common统一返回结果 Result、全局异常处理、工具类。所有接口都返回统一的 code/message/data 结构前端才好做拦截。user登录注册、地址管理。潮玩交易里有「卖家」和「买家」两种身份最好在 user 表里加一个 role 字段而不是拆两张表。product商品上架、商品详情、验货报告。潮玩商品比普通商品多几个属性品牌、系列、是否隐藏款、限量编号、尺寸。order购物车、下单、订单状态流转。状态机至少要有待支付、已支付、验货中、已发货、已签收、已取消。pay模拟支付与支付回调。毕设不要接真实支付但要实现「生成支付单 - 回调 - 修改订单状态」的完整流程不要只在 controller 里 new 一个订单。file图片与验货报告上传。单独拆出来是为了统一存储路径和 URL 映射。2.3 数据库表设计核心表与字段清单表不要一次建十几张先保证四张核心表user、product、order、order_item再加一张 cart 购物车是为了演示方便。user 表字段类型说明idbigint主键自增usernamevarchar(32)登录名唯一passwordvarchar(64)BCrypt 加密不要明文roletinyint1 买家 2 卖家balancedecimal(10,2)账户余额模拟支付用created_atdatetime注册时间product 表字段类型说明idbigint主键seller_idbigint卖家 idnamevarchar(128)商品标题brandvarchar(32)品牌如泡泡玛特、52TOYSseriesvarchar(32)系列名is_hiddentinyint是否隐藏款pricedecimal(10,2)售价stockint库存限量款可能只有 1 到 50report_urlvarchar(255)验货报告图片路径statustinyint0 下架 1 在售 2 售罄versionint乐观锁版本号防超卖用order 表字段类型说明idbigint主键order_novarchar(32)业务订单号唯一不能直接用自增 iduser_idbigint买家 idproduct_idbigint商品 id一个订单只买一个潮玩quantityint数量total_amountdecimal(10,2)总金额statustinyint0 待支付 1 已支付 2 验货中 3 已发货 4 已签收 5 已取消addressvarchar(255)收货地址快照created_atdatetime创建时间三个注意点。第一订单号不要用自增 id答辩时这是经典扣分项因为自增 id 会把系统订单量直接暴露给别人而且在幂等和分布式环境下不可靠。建议生成规则日期 用户 id 后四位 随机数。第二address 要做「快照」也就是下单那一刻把地址拷贝进订单而不是关联地址表否则用户改地址会影响历史订单。第三balance 字段只在模拟支付阶段用真实项目不会把金额直接存 user 表但毕设里这样最直观。2.4 Maven 项目结构与配置文件开发环境一次跑通pom.xml 里最关键的几个依赖parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency /dependencies这里我故意锁了 SpringBoot 2.7.18而不是最新的 3.x。原因是很多同学跟着 2024 年之后的新视频装 SpringBoot 3.x结果发现 javax.servlet 变成了 jakarta.servlet、MyBatis-Plus 版本不兼容第一周就卡在启动上。毕设求稳2.7.18 是 2.x 最后一个版本资料最全网上能搜到的答案最多。Maven 构建方法也最成熟按 standard 目录布局放 src/main/java 和 src/main/resources 就行。application.yml 配套配置server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/toy_trade ?useUnicodetrue characterEncodingutf8 serverTimezoneAsia/Shanghai username: root password: 你的数据库密码 redis: host: localhost port: 6379 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImplurl 里的三个参数分别解决中文字符集乱码、MySQL 8.0 驱动类名变更、时区导致的 datetime 偏差。driver-class-name 用 com.mysql.cj.jdbc.Driver不要用老教程里的 com.mysql.jdbc.Driver后者在 MySQL 8.x 下直接抛异常。mybatis-plus 的 log-impl 打开 SQL 日志调试时能直接看到每条实际执行的语句我开发环境开着、上线前注释掉。3. 交易链路代码落地登录、验货报告上传、购物车与下单3.1 用户登录与 JWT 鉴权拦截器这样配才不会漏接口登录接口返回一个 token前端每次请求都带上后端用拦截器统一校验。先写一个 JwtUtilpublic class JwtUtil { private static final SecretKey KEY Keys.hmacShaKeyFor( toy-trade-secret-key-please-change-32bytes.getBytes(StandardCharsets.UTF_8)); public static String createToken(Long userId, String username) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 1000L * 60 * 60 * 24)) .signWith(KEY, SignatureAlgorithm.HS256) .compact(); } public static Claims parseToken(String token) { return Jwts.parserBuilder().setSigningKey(KEY).build() .parseClaimsJws(token).getBody(); } }逻辑说明createToken 把用户 id 放进 subject把用户名放进 claim过期时间设 24 小时。parseToken 用于拦截器里解析并校验签名解析失败会抛 JwtException由全局异常处理器统一返回 401。密钥我写的是 42 字节的占位字符串实际项目里应该放到配置文件用 Value 注入别写死在类里。有了工具类再写拦截器注册。常见做法是定义 WebMvcConfigurer 的 addInterceptorsConfiguration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new JwtInterceptor()) .addPathPatterns(/api/**) .excludePathPatterns(/api/user/login, /api/user/register, /api/product/list, /api/product/detail/**); } }参数说明addPathPatterns 拦截所有 /api 下的接口登录、注册、商品列表和详情放行。一个最容易犯的错是忘了排除预检请求前端如果做的是前后端分离跨域 OPTIONS 请求会被拦截器卡掉表现为「登录成功了但所有业务接口都报 401」。我在 JwtInterceptor 里第一个判断就是if (OPTIONS.equals(request.getMethod())) return true;。3.2 商品上架与验货报告上传本地存储别踩绝对路径的坑商品上架接口接收一个 multipart 表单包含商品信息和上传的图片文件PostMapping(/api/product/add) RequireRole(SELLER) public ResultProduct addProduct(RequestParam(file) MultipartFile file, ModelAttribute Product product) { String imageUrl fileService.save(file); product.setImageUrl(imageUrl); productService.save(product); return Result.success(product); }fileService.save 的细节public String save(MultipartFile file) { String original file.getOriginalFilename(); String ext original ! null ? original.substring(original.lastIndexOf(.)) : .jpg; String filename System.currentTimeMillis() - UUID.randomUUID().toString().replace(-, ) ext; String dateDir new SimpleDateFormat(yyyyMMdd).format(new Date()); File dir new File(UPLOAD_DIR dateDir); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir, filename)); return /uploads/ dateDir / filename; }说明两个参数坑。第一文件名不能用原始文件名否则不同用户传同名图片会互相覆盖这里用时间戳加 UUID 生成唯一名。第二UPLOAD_DIR 必须用配置项注入不能写死成 D:/xxx 或 /root/xxx因为本机 Windows 能跑、部署到 Linux 服务器就 404。我一般写成file.upload-dir/data/toy-trade/uploads然后用 Value 读取。配合一个静态资源映射把 /uploads/** 指到磁盘目录浏览器才能访问到图片Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/uploads/**) .addResourceLocations(file: uploadDir); }addResourceLocations 的末尾必须带斜杠file:前缀不能省略这两个地方写错任何一个图片都会 404而且日志里看不到任何报错是典型的「显示正常但访问不了」的玄学问题。3.3 购物车与下单事务边界和库存扣减一起想购物车只是一个中间状态真正的业务是下单。下单接口要做三件事往 order 表插一条数据、扣减库存、清理购物车。这三件事必须在一个事务里否则用户下单成功但库存没扣或者库存扣了但订单没生成全都会出大事故。Service public class OrderService { Resource private ProductMapper productMapper; Resource private OrderMapper orderMapper; Transactional(rollbackFor Exception.class) public Order createOrder(Long userId, Long productId, Integer quantity, String address) { Product product productMapper.selectById(productId); if (product null || product.getStatus() ! 1) { throw new BizException(商品不存在或已下架); } int rows productMapper.deductStock(productId, quantity); if (rows 0) { throw new BizException(库存不足); } Order order new Order(); order.setOrderNo(generateOrderNo(userId)); order.setUserId(userId); order.setProductId(productId); order.setQuantity(quantity); order.setTotalAmount(product.getPrice().multiply(BigDecimal.valueOf(quantity))); order.setStatus(0); order.setAddress(address); orderMapper.insert(order); return order; } }对应的 SQL 写在 ProductMapper 的注解里Update(UPDATE product SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity}) int deductStock(Param(id) Long id, Param(quantity) Integer quantity);这里最关键的是 deductStock 的 WHERE 条件里带了stock #{quantity}。数据库的行锁会保证同一时刻只有一个事务能更新这一行所以不会超卖。返回值为 0 说明影响行数为 0也就是库存不够直接抛异常让事务回滚。很多新手先 select 查库存、再 update 扣减两个操作之间被别人插一脚库存就错了一定要改成一条原子 SQL。Transactional(rollbackFor Exception.class) 里 rollbackFor 必须写因为 Spring 默认只在遇到 RuntimeException 时回滚而业务里常抛的是自定义 BizException它可能继承自 Exception不写 rollbackFor 的话事务不会回滚库存照样被扣。4. 限量潮玩集中抢购防超卖与支付回调的四种写法4.1 为什么一次性抢购最容易翻车潮玩交易系统和普通电商最大的区别在「限量」两个字。一款限量 30 体的手办开售时间一到几百人同时点购买。一旦遇到这种情况上一章的原子 SQL 虽然不会超卖但会有两个新问题。第一MySQL 行锁把同一行商品的更新串行化所有请求排队等锁响应时间会涨到秒级用户那边表现就是「转圈圈」然后超时重试。第二重试会叠加到原来的请求后面库存没超卖但数据库连接池被拖垮。很多人的第一反应是加缓存、加队列但缓存的第一个坑就是缓存和数据库的数据一致性。我的建议是分两层数据库扣减作为最终保底方案必须保留在它前面加一层 Redis 扣减让热点商品的库存校验和扣减不再打到 MySQL把数据库的压力从「每一次请求」降到「每成交一单」。4.2 数据库原子扣减保底方案不能丢上一章写的 deductStock 就是保底方案UPDATE product SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity}。这一条 SQL 可以应对低并发下的正确性。如果前后端分离的毕设只有几十个人用、答辩时现场演示也就几个人点那用一个缓存是「成本大于收益」。我的判断标准是单款商品预估同时在线抢购人数超过 50就值得上 Redis毕设演示用原子 SQL 已经足够了。但注意保底方案虽然不超卖却存在「重试风暴」用户点一次下单前端有可能因为超时就自动重发。Redis 扣减的另一个作用是挡掉大部分重试流量让数据库只在真正成交时才被访问。4.3 Redis Lua 扣减把热点商品挡在缓存层先把商品库存预热到 Rediskey 设计为product:stock:商品idvalue 就是剩余库存。扣减用 Lua 脚本来做保证取数和扣减是原子的local key KEYS[1] local quantity tonumber(ARGV[1]) local stock tonumber(redis.call(GET, key)) if not stock then return -1 end if stock quantity then return 0 end redis.call(DECRBY, key, quantity) return 1Java 侧的执行代码private static final DefaultRedisScriptLong DEDUCT_SCRIPT new DefaultRedisScript( local key KEYS[1] local qty tonumber(ARGV[1]) local stock tonumber(redis.call(GET, key)) if not stock then return -1 end if stock qty then return 0 end redis.call(DECRBY, key, qty) return 1, Long.class); public boolean deductStockCache(Long productId, Integer quantity) { Long result redisTemplate.execute(DEDUCT_SCRIPT, Collections.singletonList(product:stock: productId), quantity.toString()); if (result null || result -1L) { log.warn(缓存库存不存在或已过期productId{}, productId); return false; } if (result 0L) { log.warn(缓存库存不足productId{}, productId); return false; } return true; }三个返回值对应三种情况-1 表示 Redis 里的 key 不存在可能是没做预热或者预热过期此时应该直接返回失败而不是去数据库兜底否则反而会把流量放回数据库0 表示库存不足不允许下单1 表示扣减成功。预热和回补的细节Redis 每扣减一次库存就少一件但数据库的库存扣减要等下单成功后才执行。如果用户扣减完 Redis 库存后没有完成下单比如支付失败或取消需要把 Redis 里的值加回去。我一般做法是订单从「已支付」变「已取消」时先回补 Redis 库存再异步回补数据库库存。回补操作一定要做否则「限量 30 体」最后只卖出去 28 体剩下 2 体在缓存里丢失了运营会找上门。4.4 模拟支付回调幂等是支付系统的底线毕设里的支付都是模拟但模拟也要按真实支付回调的套路来写。前端拿到订单号后调 /api/pay/mock后端生成一个支付单并等待 3 到 5 秒模拟银行处理然后回调修改订单状态。常见的错误是用户多点了几次支付订单状态被反反复复改甚至同一笔支付回调被处理两次金额重复入账。解决办法是在订单表上加一个 pay_id 唯一索引或单独建一张 payment 表处理回调时先 insert 再 update 订单状态。支付回调的处理器必须是幂等的同一个回调事件处理一次和处理十次结果都相同。最简单的方式是在回调里先查订单状态只有「待支付」才允许改成「已支付」否则直接返回成功。再用一张 payment_callback 表记录每个回调的唯一标识比如支付渠道的 transaction_id唯一索引兜底重复回调直接忽略。5. 论文写作与答辩避坑四类最常见的翻车记录5.1 现象项目启动报错SpringBoot 版本太高现象把 pom.xml 里 SpringBoot 版本改成 3.2.x 后项目启动直接报 ClassNotFoundException: javax.servlet.Filter或者 MyBatis-Plus 的 BaseMapper 方法全部报错。原因SpringBoot 3.x 从 JavaEE 的 javax 命名空间迁移到了 Jakarta EE 的 jakarta 命名空间所有 javax.servlet、javax.validation 都变成了 jakarta.servlet、jakarta.validation。代码和依赖里如果还在引 javax运行时就会找不到类。同时 MyBatis-Plus 官方适配 SpringBoot 3.x 的版本要到 3.5.3 之后才稳定老版本一启动就翻车。解决毕设直接把 SpringBoot 锁在 2.7.18Java 用 8 或 11MyBatis-Plus 用 3.5.5。如果老师指定必须用 3.x那 pom 里的 starter 全部换成 jakarta 兼容版本并把代码里的 javax.servlet 依赖手动替换为 jakarta.servlet-api。网上大量教程只说前半部分卡在启动阶段非常正常这不是你代码的问题是版本矩阵的问题。5.2 现象前端 Vue 打包后放进 SpringBoot 静态目录刷新页面就 404现象npm run build 生成的 dist 目录复制到 src/main/resources/static 下启动后访问首页能打开但点击「订单详情」等路由后刷新直接 404。后端接口 /api 下都正常只有前端路由刷新挂。原因前端用了 Vue Router 的 history 模式地址栏里是真实的路径比如 /order/detail/1但后端没有这个路径对应的 controller。刷新时后端找不到路由就按 404 处理。解决加一个过滤器把不是 /api、不是 /uploads、不带文件后缀的请求全部转发到 index.html。我常用的写法Component public class VueHistoryFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; String uri req.getRequestURI(); if (uri.startsWith(/api) || uri.startsWith(/uploads) || uri.contains(.)) { chain.doFilter(request, response); return; } request.getRequestDispatcher(/index.html).forward(request, response); } }逻辑说明uri.contains(.) 是为了过滤 js/css/图片等静态资源它们必须走真实文件/api 和 /uploads 分别给后端接口和上传文件让路其余路径全部转发到 index.html交回前端路由处理。这个 Filter 在本地联调和部署到 Linux 上都有效是处理 history 模式最省事的方式。5.3 现象论文查重一片红删了又怕没字数现象把「系统采用 SpringBoot 框架使用 MyBatis 作为持久层框架」这类描述性文字复制到论文里查重报告上一大片红色改来改去字数越来越少。原因这类句子是全网所有同类毕设的公共模板——搜「基于 SpringBoot 的图书借阅系统」「商品管理系统」翻十篇就能撞上同一句话。你没有错错在全世界的同学都写过一样的话。解决三个办法。第一功能性的描述不写长句改成表格一列模块名、一列功能、一列对应接口路径既清晰又不容易被判重。第二数据库设计部分全部用字段表和 SQL 建表语句这部分属于规范描述查重占比极低。第三用「自己项目的真实细节」替换通用描述不要写「本系统采用 Redis 缓存热点数据」而是写「本系统对限量商品使用 Redis 的 String 结构缓存库存key 设计为 product:stock:商品id扣减通过 Lua 脚本保证原子性实测单次扣减耗时约 0.3ms」。带数字和具体设计的句子查重很难命中。5.4 现象答辩时被追问「事务怎么保证」「并发多少」答不上现象PPT 上放着登录、商品管理、订单管理几张截图讲完 CRUD 后老师说「您这个系统如果被几十个人同时下单库存怎么保证不出错」当场卡住。原因只做了增删改查没有把任何一处技术难点落到「设计亮点」。答辩老师的问题永远比演示功能高一层他不在乎你会不会 new 一个 Controller而是想知道你有没有考虑过数据一致性、并发、安全这些工程问题。解决论文里要有专门的一节写「系统关键问题与解决方案」把第四章的防超卖内容提炼成三句话原子 SQL 作为保底、Redis Lua 扣减缓存库存、模拟支付回调幂等。答辩时被问并发问题先承认「毕设环境没做大规模压测」再补一句「但库存扣减在设计和实现时做了原子性保障」然后把那条 UPDATE SQL 背出来。只要你能把一条 SQL 的行锁原理讲清楚老师基本不会继续深挖。6. 上线前最后一轮验证以及我养成的一个习惯6.1 最少要过的验证清单项目临近提交时按下面这个表过一遍比反复改前端样式有价值验证项检查内容常见失败点功能链路注册-登录-上架-下单-支付-发货-签收订单状态跳不过去权限安全买家能否访问卖家接口、能否改别人的订单拦截器只配了登录没配角色数据一致性扣库存后取消订单库存是否回补只扣不回补部署复现服务器上 jar 包能否直接启动数据库连接、上传目录权限6.2 我的教训把配置和版本号当成代码一样管我做过不止一个 SpringBoot 项目最惨的一次是重装系统后代码在 Git 上有但 application.yml 里数据库密码、上传目录全部丢失重新配的时候发现 MyBatis-Plus 版本也和原来对不上折腾了两天。从那以后我就养成了一个习惯项目根目录建一个 docs 文件夹里面放三样东西——application-local.yml 的样例密码写成占位符、数据库建表 SQL 脚本、README 里写清 JDK 和 SpringBoot 版本号。这三样东西跟着代码一起提交换电脑、换环境、答辩前突击部署都不会翻车。如果你正在做这个 SpringBoot 的潮玩交易系统我建议你也把版本号锁死在文本里别用「最新版」任何一次依赖版本升级都可能带来无意义的排错时间。项目做到能跑通全链路不是终点能把防超卖、幂等、状态机这些点讲出自己的实现细节才是答辩时真正有底气的部分。毕竟毕业设计不是做一个完美产品而是证明你理解了一个系统从拆解到落地的全过程。希望上面的路径和踩坑能帮到你祝你顺利交出这份 SpringBoot 系统设计与实现论文。本文还有配套的精品资源点击获取