电商平台系统答辩PPT:SpringBoot+Vue库存超卖与缓存实战
2026/9/17 14:19:40 网站建设 项目流程

简介:面向计算机相关专业毕业生与Java Web项目开发者,这是一份基于Java技术栈、SpringBoot与Vue的电商平台系统毕业设计答辩PPT,可用于课程设计、毕业答辩汇报及项目方案梳理。整包仅含1个pptx文件,大小约6.99MB,单文件即可打开,涵盖课题背景、需求分析、功能结构、ER概念设计、主页面实现与用户模块、商品模块、购买记录、订单模块等前台后台模块,并涉及数据安全、访问控制、商品推荐、搜索评论、Ajax无刷新更新与登录测试等要点。已有83人学习浏览,适合需要快速搭建答辩框架、整理系统功能说明与测试用例的读者参考。通过它可了解电商系统的整体设计脉络、模块划分方式、测试情景与期望结果,以及答辩总结和致谢页面,为制作自己的答辩材料提供结构参照。

1. 从"电商平台系统答辩PPT"这个标题里,拆出评委真正想听的三层

答辩现场最尴尬的不是功能少,而是 PPT 上写着 SpringBoot 三层架构、Vue 组件化开发,评委追问一句"下单那一刻库存怎么保证不超卖",人就卡住了。这个标题其实压着两件事:一套用 Java 技术栈真正跑得起来的电商平台,以及一份能把它讲清楚、并且守得住追问的答辩材料。前者决定系统能不能被认可,后者决定你在台上能不能把主动权拿回来。它适合正在做课程设计、毕业设计的同学,也适合工作几年后被临时抓去做技术汇报的工程师。往下走,先把后端链路梳成能复现的代码,再把这些代码压缩成 PPT 里经得起细看的那几页,最后落到几个能当场演示的技巧上。

2. SpringBoot 后端分层:商品、购物车、订单三个模块怎么切才讲得清

电商平台的功能表能列几十条,但答辩 PPT 里放不下,评委也没耐心看。真正需要讲透的是"一次下单"这条链路上,数据从哪儿来、经过谁、落到哪儿。模块切得越碎,讲的时候越乱;把 Controller、Service、Mapper 三层的职责边界说清楚,再挑订单和库存这两个有技术含量的点深挖,比铺开十个 CRUD 模块有效得多。

2.1 包结构与依赖选型:先把 JDK 版本这个坑填掉

常见的包结构是controllerserviceservice.implmapperentitydtoconfigcommoninterceptor。这套结构不是官方规定,而是社区里最容易被评委一眼认出来的组织方式。分层职责可以用一张表说清楚,这张表直接抄进 PPT 也成立。

职责不该做的事PPT 里怎么讲
Controller参数校验、鉴权、返回统一结构写业务判断、直接调 Mapper接口契约层,对外只暴露 JSON
Service事务、库存扣减、订单状态流转处理 HttpServletRequest业务规则唯一入口
Mapper单表 SQL 与映射跨表复杂拼装与数据库一一对应
entity/dto数据库映射与传输对象分离把密码字段直接返回前端避免敏感字段泄漏

依赖这块有个高频问题:网上教程多为 JDK 8 环境,直接套到 SpringBoot 3.x 上会报javax.servlet找不到,因为 3.x 全面换成了jakarta.*命名空间,同时强制 JDK 17 起步。选版本的原则是"跟着 JDK 走":机器上是 JDK 8 就别硬上 3.x,用 2.7.x 更省事;JDK 17 环境再考虑 3.x。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <!-- Web 层:内嵌 Tomcat + SpringMVC --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 参数校验,@NotNull / @Min 才生效 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- 持久层,省掉大量单表 XML --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.2</version> </dependency> <!-- 缓存与库存预扣减 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

父工程的spring-boot-starter-parent统一托管了绝大多数依赖版本,所以下面几个 starter 不写<version>,只有 MyBatis-Plus 这类不在托管清单里的才需要显式声明。scoperuntime表示编译期用不到、运行期才加载;Lombok 标记optional是为了不让它传递给引入本模块的其他工程。

2.2 商品、订单、订单明细的表设计

电商的表不用多,四张就能撑起完整链路。字段设计里最容易在答辩上被挑的是金额类型——用DOUBLE存钱迟早出现0.30000000000000004,必须用DECIMAL

表名关键字段说明
t_userid, username, password, phone, create_time密码存 BCrypt 哈希,不存明文
t_productid, name, price DECIMAL(10,2), stock, version, statusversion 是乐观锁版本号
t_orderid, order_no, user_id, total_amount, status, create_timeorder_no 建唯一索引做幂等
t_order_itemid, order_id, product_id, price, num下单时快照商品价格,不关联查现价

t_order_no上的唯一索引是很多人忽略的细节:前端因为网络抖动重复提交两次,第二次插入会因为唯一键冲突失败,业务层捕获后直接返回第一次的订单号,比在 Service 里加分布式锁轻得多。

@Data @TableName("t_product") public class Product { @TableId(type = IdType.AUTO) private Long id; private String name; private BigDecimal price; // 金额一律 BigDecimal,禁止 double private Integer stock; @Version // MyBatis-Plus 乐观锁注解 private Integer version; private Integer status; // 1 上架 0 下架 }

@Version只是让 MyBatis-Plus 的updateById自动带上版本条件,自定义 SQL 需要手写版本判断,不能指望注解生效。价格字段用BigDecimal,前端传过来的是字符串,反序列化时让 Jackson 直接映射成BigDecimal,不要中途经过Double

2.3 下单接口的事务边界与库存扣减写法

下单是典型的多表写操作:扣库存、插订单、插明细,三步必须同生共死。事务注解写在 Service 实现类的方法上,而不是 Controller 上——写在 Controller 上会让整个请求处理过程都占用数据库连接。

@Override @Transactional(rollbackFor = Exception.class) // 默认只回滚 RuntimeException,checked 异常要显式声明 public String createOrder(Long userId, Long productId, Integer num) { // 1. 校验商品状态 Product product = productMapper.selectById(productId); if (product == null || product.getStatus() == 0) { throw new BizException("商品不存在或已下架"); } // 2. 带库存与版本条件的扣减,影响行数为 0 说明被别人抢先 int rows = productMapper.deductStock(productId, num, product.getVersion()); if (rows == 0) { throw new BizException("库存不足,请稍后重试"); } // 3. 插入订单主表,order_no 唯一索引兜住重复提交 String orderNo = OrderNoUtil.next(); Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setTotalAmount(product.getPrice().multiply(BigDecimal.valueOf(num))); order.setStatus(1); // 1 待支付 orderMapper.insert(order); // 4. 插入明细,价格做快照 OrderItem item = new OrderItem(); item.setOrderId(order.getId()); item.setProductId(productId); item.setPrice(product.getPrice()); item.setNum(num); itemOrderMapper.insert(item); return orderNo; }

对应的 Mapper 方法要手写,条件里同时带上stock >= numversion两个约束:

@Update("UPDATE t_product SET stock = stock - #{num}, version = version + 1 " + "WHERE id = #{productId} AND stock >= #{num} AND version = #{version}") int deductStock(@Param("productId") Long productId, @Param("num") Integer num, @Param("version") Integer version);

stock >= num保证库存不会被扣成负数,version = #{version}保证只有读到旧版本号的那个线程能扣成功。影响行数为 0 时抛异常触发回滚,此时订单和明细都还没写入,数据是干净的。

注意:@Transactional有自调用失效问题。同一个类里 A 方法调 B 方法,B 上的事务注解不生效,因为走的是this引用而非代理对象。下单逻辑要对外调用,别在内部互相调。

多商品结算时,购物车里每个商品都要扣库存,写法是把商品 ID 排序后再依次扣减,避免两个请求以相反顺序锁行导致死锁。这个细节在答辩上讲出来,技术含量立刻不一样。

3. Vue 前端工程:路由、状态管理和打包这三件事

后端接口跑通之后,前端要解决的是"页面怎么组织、购物车数据存哪儿、上线后为什么白屏或错位"。Vue 生态里这三件事分别对应路由表、Pinia(或 Vuex)和构建配置,答辩 PPT 里每个写一页,配上真实代码截图,比放十个页面截图有说服力。

3.1 从依赖安装到路由表拆分

初始化工程有两条路:npm create vite@latest起 Vite 模板,或者用 Vue CLI。Vite 启动快,构建配置更直观,现在新项目基本都用它。依赖装不上时先别急着换源,多数情况是 Node 版本过低,Vite 需要 Node 16 以上。

# 创建工程并安装依赖 npm create vite@latest shop-web -- --template vue cd shop-web npm install npm install vue-router@4 pinia axios element-plus # 启动开发服务器 npm run dev

路由表按业务域拆,商品详情页用动态参数,购物车和订单页挂上鉴权标记,由全局前置守卫统一拦截:

// src/router/index.js import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/', redirect: '/home' }, { path: '/home', component: () => import('@/views/Home.vue') }, // 动态路由参数,通过 route.params.id 取值 { path: '/product/:id', name: 'ProductDetail', component: () => import('@/views/ProductDetail.vue'), props: true }, { path: '/cart', component: () => import('@/views/Cart.vue'), meta: { requiresAuth: true } }, { path: '/order/confirm', component: () => import('@/views/OrderConfirm.vue'), meta: { requiresAuth: true } }, { path: '/:pathMatch(.*)*', component: () => import('@/views/NotFound.vue') } ] const router = createRouter({ history: createWebHistory(), routes }) router.beforeEach((to) => { if (to.meta.requiresAuth && !localStorage.getItem('token')) { // 未登录跳转登录页,并记录来源用于登录后回跳 return { path: '/login', query: { redirect: to.fullPath } } } }) export default router

route.params.id取的是路径里的占位符,route.query.keyword取的是?keyword=xxx,两者混用是新手常见错误,动态路由传值是params,筛选条件传值是query。组件上用props: true可以把路径参数直接变成 props,组件内部就不用强依赖useRoute

3.2 Pinia 购物车状态与 axios 请求封装

购物车数据既要跨页面共享,又要在刷新后还在,所以状态放 Pinia、持久化落到 localStorage。金额计算放在 getter 里,避免在每个页面各算一遍。

// src/stores/cart.js import { defineStore } from 'pinia' export const useCartStore = defineStore('cart', { state: () => ({ // 初始化时从本地存储恢复,刷新不丢 items: JSON.parse(localStorage.getItem('cart') || '[]') }), getters: { totalCount: (s) => s.items.reduce((n, i) => n + i.num, 0), totalPrice: (s) => s.items.reduce((n, i) => n + i.price * i.num, 0).toFixed(2) }, actions: { add(product, num = 1) { const hit = this.items.find((i) => i.id === product.id) if (hit) hit.num += num else this.items.push({ id: product.id, name: product.name, price: product.price, num }) this.persist() }, remove(id) { this.items = this.items.filter((i) => i.id !== id) this.persist() }, persist() { localStorage.setItem('cart', JSON.stringify(this.items)) } } })

axios 用拦截器统一做两件事:请求头带上 token,响应体剥掉统一包装层。后端返回{ code, msg, data },业务代码只拿到data,失败时统一 reject,页面里try/catch处理即可。

// src/utils/request.js import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 8000 }) request.interceptors.request.use((config) => { const token = localStorage.getItem('token') if (token) config.headers.Authorization = `Bearer ${token}` return config }) request.interceptors.response.use( (res) => { const body = res.data if (body.code !== 200) { ElMessage.error(body.msg || '请求失败') return Promise.reject(new Error(body.msg)) } return body.data // 业务层直接拿 data }, (err) => { if (err.response?.status === 401) { localStorage.removeItem('token') location.href = '/login' } return Promise.reject(err) } ) export default request

超时设 8 秒是有意的:下单接口一旦因为慢 SQL 卡住,前端不该无限等待。baseURL里的/api是开发期由代理转发的虚拟前缀,后端本身没有这个 context-path。

3.3 打包后布局异常和刷新 404 的排查顺序

Vite 开发期代理配置决定本地联调能不能通:

// vite.config.js export default defineConfig({ base: './', // 相对路径,部署到子目录不会白屏 server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, // 后端没有 /api 前缀,转发时剥掉 rewrite: (p) => p.replace(/^\/api/, '') } } } })

changeOrigin: true让代理请求的 Host 头变成目标地址,否则后端做域名校验时会拒掉。rewrite负责前缀剥离,如果后端 Controller 本身就带/api,这一行要删掉,否则 404。

打包后布局异常基本集中在三个原因,按这个顺序排查最快:第一,base配成/而实际部署在子目录,静态资源 404 导致样式全丢,Network 面板里 CSS 请求是红的就属于这一类;第二,本地开发窗口宽、线上演示用投影仪分辨率不同,Element Plus 的栅格在窄屏下换行错位,检查el-row是否给了:gutter和响应式断点;第三,写成固定width: 1200px的容器在小屏上出现横向滚动条,改成max-widthmargin: 0 auto即可。

history 模式下打包后直接访问/cart会 404,因为服务器找不到这个物理路径,需要 Nginx 回退到 index.html:

location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }

try_files的含义是依次尝试真实文件、目录,都不存在就返回 index.html,交给前端路由自己解析路径。

4. 库存一致性与缓存:答辩最容易被追问的两个技术点

电商平台的答辩问题往往不会停在"你用了什么框架",而是往数据一致性上压。库存有没有可能超卖、Redis 和 MySQL 数据不一致怎么办、缓存穿透怎么防,这三问问出来的时候,答不上来的项目会被直接判定为"只做了增删改查"。这一章把答案落到能跑的代码上。

4.1 超卖是怎么发生的:乐观锁与 Redis 预扣减对比

超卖的根源是"读—改—写"之间没有原子性:两个请求同时读到库存 1,都判断够,都写回 0,结果卖出两件。解决办法有三档,选哪一档取决于你 PPT 想强调什么。

方案实现方式一致性吞吐适用与表述难度
数据库乐观锁where stock>=n and version=v实现简单,答辩好讲,推荐作为主方案
悲观锁select ... for update适合库存极少的热点商品,容易讲成"锁表"被追问
Redis 预扣减Lua 脚本原子扣减最终一致技术亮点,但必须补异步落库和补偿,讲不清会扣分

主方案用乐观锁,理由要说得出口:电商的库存竞争是"同一商品短时间高并发",乐观锁在冲突时让请求失败重试,而不是让线程排队阻塞,对数据库连接更友好。

Redis 预扣减作为进阶亮点,核心是用 Lua 保证"判断加扣减"原子执行:

-- 返回值:-1 键不存在,0 库存不足,1 扣减成功 local stock = redis.call('GET', KEYS[1]) if stock == false then return -1 end if tonumber(stock) < tonumber(ARGV[1]) then return 0 end redis.call('DECRBY', KEYS[1], ARGV[1]) return 1
private static final DefaultRedisScript<Long> DEDUCT = new DefaultRedisScript<>( "local s = redis.call('GET', KEYS[1]) " + "if s == false then return -1 end " + "if tonumber(s) < tonumber(ARGV[1]) then return 0 end " + "redis.call('DECRBY', KEYS[1], ARGV[1]) return 1", Long.class); public boolean preDeduct(Long productId, Integer num) { Long r = redisTemplate.execute(DEDUCT, Collections.singletonList("seckill:stock:" + productId), num); return r != null && r == 1L; }

KEYS[1]是库存键,ARGV[1]是购买数量,Redis 单线程执行 Lua 期间不会被其他命令插入,所以判断和扣减之间不存在竞态。要注意的是这只解决了"Redis 层不超卖",数据库的库存还需要靠消息队列异步落库,或者定时对账补偿。答辩时如果只讲前半句不讲后半句,很容易被追问到卡壳,所以要么把它作为"优化方向"来讲,要么把补偿逻辑一起画进架构图。

4.2 Redis 在 SpringBoot 里的配置与缓存穿透处理

商品详情是典型的读多写少场景,缓存价值高。默认的JdkSerializationRedisSerializer会把 key 写成带二进制前缀的乱码,用 Redis 客户端看数据时很难受,所以自定义成字符串 key 加 JSON value。

@Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> tpl = new RedisTemplate<>(); tpl.setConnectionFactory(factory); tpl.setKeySerializer(new StringRedisSerializer()); ObjectMapper om = new ObjectMapper(); om.registerModule(new JavaTimeModule()); // 支持 LocalDateTime tpl.setValueSerializer(new GenericJackson2JsonRedisSerializer(om)); tpl.afterPropertiesSet(); return tpl; }

缓存逻辑里要做两件防护:空值占位防穿透,随机过期时间防雪崩。

public Product getDetail(Long id) { String key = "product:detail:" + id; Object cache = redisTemplate.opsForValue().get(key); if (cache != null) { // 命中空值占位,说明数据库里确实没有,直接返回 return NullHolder.INSTANCE.equals(cache) ? null : (Product) cache; } Product p = productMapper.selectById(id); if (p == null) { // 空值缓存 5 分钟,挡住对不存在 ID 的反复查询 redisTemplate.opsForValue().set(key, NullHolder.INSTANCE, 5, TimeUnit.MINUTES); return null; } // 基础 30 分钟 + 随机 10 分钟,避免大批 key 同时失效 long ttl = 30 + ThreadLocalRandom.current().nextInt(10); redisTemplate.opsForValue().set(key, p, ttl, TimeUnit.MINUTES); return p; }

数据库层面的防护是给商品加status字段并在查询条件里带上,下架商品即使 ID 被猜到也查不出内容。缓存最后一道兜底是"更新商品时先更新数据库、再删除缓存",而不是更新缓存,避免两个并发写导致缓存里留下旧值。

4.3 用压测和 SQL 校验证明库存对得上

PPT 里放一句"已通过压测验证"没有分量,放两张对得上的数据截图才有。压测用 ab 就够,重点是看结果能不能和数据库对上。

# 200 并发、总共 2000 次请求,请求体来自 order.json ab -n 2000 -c 200 -p order.json -T application/json \ http://127.0.0.1:8080/api/order/create

压测前把商品初始库存设成一个整数,比如 100,跑完立刻用三条 SQL 交叉验证:

-- 1. 剩余库存必须大于等于 0,且等于 初始值 - 成功订单数 SELECT stock, version FROM t_product WHERE id = 1001; -- 2. 成功创建的订单条数 SELECT COUNT(*) FROM t_order WHERE product_id = 1001; -- 3. 订单号必须唯一,重复提交没有产生脏数据 SELECT COUNT(*) - COUNT(DISTINCT order_no) AS dup FROM t_order;

第一条 SQL 里version应该正好等于"成功扣减次数",因为每次成功扣减都会自增 1,这个数字和订单条数对不上就说明事务边界有问题。第三条的差值必须为 0,是唯一索引是否生效的直接证据。ab-c是并发数、-n是总请求数,-p指定 POST 数据文件、-T指定 Content-Type,缺了后者后端会因为拿不到 JSON 体直接返回 415。

5. 把系统搬进答辩 PPT:架构图、演示脚本与追问应答

代码写完之后,剩下的工作是把它们压成能讲的形式。答辩 PPT 的页数通常被限制在 15 到 20 页,技术部分能分到的也就 6 到 8 页,每一页都要承担一个明确任务。

5.1 一页架构图要覆盖的层次

架构图不要画成十几个方块的连线迷宫。按四层画,横向不超过五个框:接入层放 Nginx 与 Vue 打包后的静态资源,应用层放 Controller、Service、Mapper 三段,数据层放 MySQL 与 Redis,旁挂一条支撑能力带(统一异常处理、JWT 拦截器、日志)。用一条加粗的箭头把"用户下单"这条主链路单独标出来,其他箭头用细线。评委的目光会自动跟着加粗箭头走,你讲的时候也只需要讲这一条。

5.2 90 秒演示脚本与页面顺序

现场演示最怕临时出问题,脚本要写到"第几秒点哪里"。

时间操作口播重点
0-15s首页加载,展示商品列表前端 Vue 路由懒加载,首屏只加载首页 chunk
15-35s进入商品详情,说明缓存第一次查数据库,第二次命中 Redis,可切到控制台看日志
35-55s加入购物车,刷新页面购物车数据持久化在 localStorage,刷新不丢
55-80s提交订单,展示订单详情强调事务与乐观锁,订单号唯一
80-90s打开后台 SQL 窗口看库存与版本号库存与订单条数对得上

演示环境要提前把 Redis 里商品详情的 key 删掉,确保第一次查询走数据库、日志里有 SQL 打印,这样"缓存命中"的对比才看得出来。同一台机器上跑前后端,浏览器标签页提前开好,避免现场敲命令。

5.3 高频追问的应答口径

追问的应对原则是"先给结论,再给一句理由,最后给一个证据"。证据可以是代码片段、SQL 结果或日志截图,不要现编。

追问应答骨架别这么说
库存超卖怎么办结论:用带 version 的条件更新;理由:判断与扣减在同一条 SQL 里原子执行;证据:deductStock的影响行数判断"加了 synchronized",本地锁在集群下无效
Redis 挂了系统还能用吗结论:缓存只做读加速,降级后直接查库;理由:查库路径一直保留;证据:把缓存开关关掉再演示一次不要说"Redis 不会挂"
事务失败会不会扣了库存结论:不会,扣减和插入在同一事务里;理由:异常抛出触发回滚;证据:手动抛异常后查库存不变不要说"应该不会"
为什么不用微服务结论:单体分层足够支撑当前规模,拆分会带来分布式事务成本;理由:区分业务复杂度与技术复杂度不要贬低微服务,容易引起追问

一个能加分的小技巧:在 PPT 附录里放一页接口耗时统计。用@Around切面把每个接口的耗时打成日志,或者直接在控制台看 SpringBoot 的 SQL 执行时间,把商品详情"走库 12ms、走缓存 1ms"这两个数字放进表格。数字比形容词有说服力,而且它证明的正是你在第 4 章讲的那套缓存设计真的生效了。

本文还有配套的精品资源,点击获取

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

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

立即咨询