Spring Boot简易电商平台实战:从设计到部署
2026/9/23 2:51:58 网站建设 项目流程

前阵子一个做运营的朋友找我,说想搞一个卖手作文具的小商城,初期用户量就几百人,预算不高,找外包不划算,问我自己拿 Spring Boot 能不能搭。我花了一个周末把骨架拉起来,又把下单、购物车、库存这些核心流程从头到尾过了一遍。这篇就把当时从零搭建简易电商平台的完整思路、代码结构和踩坑记录整理出来,给准备动手做类似项目的朋友一个可以直接参考的路线图。

这个项目适合两类人:一是刚学完 Java Web 想找个完整项目练手的学生,二是非技术出身想低成本验证业务想法的小团队。我会把"为什么这么设计"也讲清楚,而不是只丢一堆 CRUD 代码。

1. 先想清楚:什么才算"简易"电商平台

1.1 需求边界怎么划

很多人一上来就想着要秒杀、优惠券、分销、直播带货,这是典型的过度设计。简易电商平台的核心闭环其实就一句话:用户能浏览商品、加入购物车、下单付款、查看订单

我当时和朋友敲定的功能清单是这四个模块:

  • 商品模块:商品列表(分页)、商品详情、按分类筛选、简单的关键词搜索
  • 用户模块:注册、登录、退出,登录态用 JWT 维持
  • 购物车模块:加入购物车、修改数量、删除、勾选结算
  • 订单模块:提交订单、订单列表、订单详情、模拟支付、取消订单

砍掉的东西也明确列出来:不做秒杀、不做复杂的营销活动、不做多级分销、不做售后工单、不做优惠券引擎。支付也只做模拟支付,对接微信支付宝需要商户资质,个人项目可以先留接口后实现。

这个边界划清楚之后,工作量一下子从"两个月"变成"一周"。很多项目烂尾,不是因为技术难度大,而是因为需求像滚雪球一样越滚越大。做简易平台的第一原则就是学会说"不",把非核心功能全部推后。

1.2 技术栈怎么选

选技术栈不是越新越好,也不是越轻越好,而是让你能最快跑通闭环、后面又能平滑扩展。我当时选的是这套:

层级选型选择理由
后端框架Spring Boot 2.7生态成熟,资料多,遇到问题好搜
ORMMyBatis-Plus 3.5单表 CRUD 不用写 SQL,复杂查询再手写
数据库MySQL 8.0免费、通用、部署方便
缓存Redis(可选)购物车和会话缓存的加速方案
前端Vue 3 + Element Plus + Pinia组件现成,后台管理类页面开发效率高
鉴权JWT无状态,前后端分离友好

为什么不用微服务?因为几百个用户、几个核心模块,单体应用完全扛得住。微服务带来的服务拆分、分布式事务、链路追踪问题,在这个体量下全是负担。我见过太多人为了简历好看,硬把一个小商城拆成三个微服务,结果连订单和库存的一致性都没搞定。

为什么用 JWT 而不是 Session?前后端分离之后,如果用 Session 就得处理跨域携带 Cookie、CSRF 防护等一系列问题,JWT 把用户信息加密放在令牌里,后端只需要验签就能拿到用户身份,省事不少。当然 JWT 也有缺陷,比如无法主动失效,这在简易平台里可以接受,真到要踢人下线的程度再说。

2. 数据库设计:一张表也别乱建

2.1 核心表结构

数据库是电商的地基,表结构设计得不好,后面写代码会非常痛苦。我当时建了五张核心表,建表语句大致如下:

-- 用户表 CREATE TABLE `user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密码', `nickname` VARCHAR(50) DEFAULT '', `phone` VARCHAR(20) DEFAULT '', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 商品表 CREATE TABLE `product` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(100) NOT NULL, `category_id` BIGINT NOT NULL, `price` DECIMAL(10,2) NOT NULL COMMENT '单位:元', `stock` INT NOT NULL DEFAULT 0, `cover_url` VARCHAR(255) DEFAULT '', `detail` TEXT, `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 购物车表(如果用Redis这表可以不要,详见3.3) CREATE TABLE `cart_item` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `product_id` BIGINT NOT NULL, `quantity` INT NOT NULL DEFAULT 1, `checked` TINYINT DEFAULT 1, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_user_product` (`user_id`, `product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表 CREATE TABLE `orders` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL UNIQUE COMMENT '业务订单号', `user_id` BIGINT NOT NULL, `total_amount` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消', `pay_time` DATETIME DEFAULT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单明细表 CREATE TABLE `order_item` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_id` BIGINT NOT NULL, `product_id` BIGINT NOT NULL, `product_name` VARCHAR(100) NOT NULL COMMENT '下单时的商品快照', `product_price` DECIMAL(10,2) NOT NULL, `quantity` INT NOT NULL, KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

2.2 字段设计的几个关键细节

这些表看起来简单,但有几个细节是踩过坑才知道的。

第一个细节:金额一定要用 DECIMAL,不要用 FLOAT 或 DOUBLE。浮点数在二进制中无法精确表示,0.1 + 0.2 在很多语言里会得到 0.30000000000000004,涉及钱的系统出这种精度问题是要出事的。DECIMAL(10,2) 表示最长 10 位数字、小数点后保留 2 位,完全够用。

第二个细节:订单明细里冗余了 product_name 和 product_price。这叫"商品快照"。因为商品名称和价格是会被改的,如果订单明细只存 product_id,将来商品改名了、涨价了,历史订单显示出来的信息就全变了,这是电商系统的大忌。宁可冗余存储,也要保证历史订单的准确性。

第三个细节:订单号不能直接用数据库自增 ID。自增 ID 会暴露每天的订单量,而且多表合并、后续分库分表时会有问题。我当时用"时间戳 + 用户ID后四位 + 随机数"拼了一个 32 位的订单号,并在数据库层加了唯一索引,保证并发下也不重复。

第四个细节:所有表都加了 created_at,order 表还专门给 user_id 建了索引。电商系统最频繁的查询就是"查某个用户的订单列表",没有索引的话,数据量到几万条的时候全表扫描就会明显变慢。

提示:正式项目里,商品分类会单独建一张 category 表,订单状态也会用更细的状态机(待支付/已支付/已发货/已完成/已关闭)。简易版本可以先省掉分类表、直接放 category_id 字段,等业务需要再拆,但状态机建议一开始就设计好,不然后面改起来牵扯面很大。

3. 后端核心流程实现

3.1 用户登录与 JWT 鉴权

用户模块是整个平台的基础,没有登录态,购物车和订单都不知道该挂到谁名下。我用的方案是 Spring Boot + JWT,核心依赖很简单:

<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>

密码加密用 Spring Security 自带的 BCryptPasswordEncoder,这个比 MD5 加盐靠谱得多。MD5 现在用彩虹表轻松爆破,BCrypt 是专门为口令哈希设计的算法,自带随机盐、计算慢,暴力破解成本极高。记住一个原则:用户的密码绝不能明文存储,也不要自己发明加密算法

写一个 JWT 工具类,负责生成令牌和解析令牌:

public class JwtUtil { private static final String SECRET = "your-256-bit-secret-key"; private static final long EXPIRE = 7 * 24 * 3600 * 1000L; // 7天有效期 public static String generateToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } }

然后写一个拦截器,拦截所有需要登录的接口(除了登录注册),从请求头的 Authorization 里取出 token 校验。这里有个容易被忽略的点:前端传 token 时要加 Bearer 前缀,拦截器解析时要去掉,这个约定前后端要提前讲好。

我用 Spring Security 的时候,直接重写了 OncePerRequestFilter 来做鉴权。如果你觉得 Spring Security 配置繁琐,也可以只用拦截器加自定义注解实现,效果是一样的。简易项目的原则是怎么顺手怎么来,别为了"规范"引入过多学习成本

3.2 商品模块与商品列表查询

商品列表和详情是纯查询操作,用 MyBatis-Plus 的 LambdaQueryWrapper 就能搞定。分页查询是最常用的接口,代码大致长这样:

public Page<Product> getProductPage(int pageNum, int pageSize, Long categoryId, String keyword) { Page<Product> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Product::getStatus, 1); if (categoryId != null) { wrapper.eq(Product::getCategoryId, categoryId); } if (StringUtils.hasText(keyword)) { wrapper.like(Product::getName, keyword); } wrapper.orderByDesc(Product::getCreatedAt); return productMapper.selectPage(page, wrapper); }

这里有个值得说的点:商品列表一定只返回"上架"的商品,防止用户通过改接口参数看到下架商品。这个状态字段的判断必须在后端做,不能指望前端隐藏入口。

商品详情的接口就别用刚才那个分页方法了,直接根据 id 查单条,同时把库存、价格、封面图、详情描述一起返回。图片存储前期直接用"路径字符串"存到数据库,图片文件放服务器某个目录或者对象存储里。不要把小图片存成 Base64 塞进数据库,数据库会膨胀得非常快,查询性能也会被拖垮。

3.3 购物车:用 Redis 还是 MySQL

购物车是电商项目里很有意思的一个模块,它有两种主流实现方式:登录状态存 MySQL,未登录存本地。简易项目我建议直接存 MySQL。

为什么?虽然 Redis 读写快,但购物车数据本质上是需要持久化的——用户今天加购,明天换个设备登录还得能看到。如果纯放 Redis,要额外处理缓存持久化、过期策略、数据迁移,复杂度上去了,而且 Redis 挂了购物车就全丢了。数据库存购物车,单表查询加上唯一索引,几百上千条数据查询根本感觉不到慢。

购物车表设计成"每个用户 + 每个商品"唯一一条记录,用户再次加购同一件商品时,直接累加数量而不是新增记录。这一步在插入前先查一遍,或者利用唯一索引做冲突更新:

// 加入购物车,若已存在则数量累加 CartItem cartItem = cartItemMapper.selectOne( new LambdaQueryWrapper<CartItem>() .eq(CartItem::getUserId, userId) .eq(CartItem::getProductId, productId) ); if (cartItem != null) { cartItem.setQuantity(cartItem.getQuantity() + quantity); cartItemMapper.updateById(cartItem); } else { CartItem newItem = new CartItem(); newItem.setUserId(userId); newItem.setProductId(productId); newItem.setQuantity(quantity); cartItemMapper.insert(newItem); }

并发场景下这种"先查再插"会有唯一索引冲突的风险,但简易项目先不用管,真到了并发加购的规模,再改成 ON DUPLICATE KEY UPDATE 也不迟。提前优化是万恶之源,这句话在做小项目时尤其要记住。

3.4 下单流程与库存扣减

下单是整个平台最核心、最容易出 bug 的环节。它涉及三个表:订单表、订单明细表、商品库存表。核心逻辑是:校验购物车选中项 → 校验库存 → 扣减库存 → 生成订单 → 清空购物车。

库存扣减是整个流程里最容易踩坑的地方。如果只是简单地把库存读出再减一写回,并发下会出现超卖问题:

// 错误示范:不是原子的 Product product = productMapper.selectById(productId); if (product.getStock() < quantity) { throw new BusinessException("库存不足"); } product.setStock(product.getStock() - quantity); productMapper.updateById(product); }

两个线程同时读到库存 1,都判断库存充足,都执行减一,最终库存变成 -1,但卖出了两件。解决超卖最经典的做法是用乐观锁 + 条件更新

// 正确示范:CAS 思想,扣减时带上库存条件 int rows = productMapper.deductStock(productId, quantity, currentStock); // 对应 SQL: UPDATE product SET stock = stock - #{quantity} // WHERE id = #{productId} AND stock >= #{quantity} if (rows == 0) { throw new BusinessException("库存不足,请刷新后重试"); }

这个 SQL 的关键在于stock >= #{quantity}这个条件,数据库会保证同一时间只有一个事务能更新成功,更新影响行数为 0 就说明库存不够。我的项目里还加了一个"乐观锁版本号字段"的变体,但实际用条件更新更直接,效果一样。

下单的整个流程需要事务保护——订单主表、订单明细、库存扣减任何一个失败,都要全部回滚。直接在 Service 方法上加@Transactional注解就行,但注意一个问题:事务要包在"扣库存 + 生成订单"这组操作外面,不要把清空购物车也包进去,购物车失败不应该影响订单生成。

还有订单超时未支付的问题。简易项目我用了最简单粗暴的方案:创建订单时记录过期时间,用户在"我的订单"列表里查询时,若订单超过 30 分钟未支付且状态是待支付,就自动置为已取消。这种"懒取消"在用户量不大的场景下完全够用,没必要为了准时取消引入消息队列和延迟任务。真要做得精准,后续可以加 Quartz 定时扫描或者 RabbitMQ 延迟队列,但那是后话。

3.5 模拟支付接口

对接真实支付需要企业资质、商户号、证书和一系列回调处理,个人开发者很难走完流程。我的做法是先做一个模拟支付接口:前端点击"立即支付",后端直接把订单状态从"待支付"改成"已支付",记录支付时间,同时把商品销量加上。

@PostMapping("/pay/{orderNo}") public Result pay(@PathVariable String orderNo) { Orders order = orderService.lambdaQuery() .eq(Orders::getOrderNo, orderNo) .eq(Orders::getUserId, currentUserId()) .one(); if (order == null) { return Result.error("订单不存在"); } if (order.getStatus() != OrderStatus.UNPAID) { return Result.error("订单状态异常"); } order.setStatus(OrderStatus.PAID); order.setPayTime(new Date()); orderService.updateById(order); return Result.success(); }

注意校验条件:只能支付自己的订单,这是基本的越权防护。很多初学者只校验订单号,不校验订单归属,导致 A 用户能支付 B 用户的订单。这个坑在订单详情接口、取消订单接口里同样存在,任何涉及用户数据的操作都要带上"当前登录用户 ID"这个条件。

4. 前端页面与交互

4.1 页面结构和路由设计

前端我用的是 Vue 3 + Vite + Element Plus,页面结构非常简单清晰:

src/ views/ Home.vue // 首页:商品列表 + 搜索栏 ProductDetail.vue // 商品详情 Login.vue // 登录 Register.vue // 注册 Cart.vue // 购物车 Checkout.vue // 结算页(确认订单信息) OrderList.vue // 我的订单 OrderDetail.vue // 订单详情 stores/ cart.js // 购物车状态(Pinia) user.js // 用户登录态(Pinia) api/ request.js // axios 封装 product.js order.js

路由配置里给需要登录的页面加一个全局前置守卫,判断 Pinia 里有没有 token,没有就跳登录页:

router.beforeEach((to, from, next) => { const userStore = useUserStore() if (to.meta.requiresAuth && !userStore.token) { next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } })

这里有个细节:token 要持久化到 localStorage,否则刷新页面后 Pinia 里的状态会丢,用户每刷新一次就要重新登录,体验很差。

4.2 axios 封装与状态管理

axios 封装的核心是两件事:统一在请求头里携带 token,统一处理后端返回的错误码。我当时写了一个很简洁的 request.js:

import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' import { useUserStore } from '@/stores/user' const request = axios.create({ baseURL: '/api' }) request.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } return config }) request.interceptors.response.use( res => { if (res.data.code === 200) return res.data.data ElMessage.error(res.data.msg) return Promise.reject(new Error(res.data.msg)) }, err => { if (err.response?.status === 401) { const userStore = useUserStore() userStore.logout() router.push('/login') } ElMessage.error(err.response?.data?.msg || '网络异常') return Promise.reject(err) } )

购物车状态用 Pinia 管理,维护一个 cartCount 数字和购物车商品列表,增删改操作后同步刷新这两个状态。这里有个容易踩的坑:多个页面可能同时改变购物车数量,比如在商品详情页加购、在购物车页删除、在结算页生成订单后清空购物车,如果每个页面各管各的状态,很快就乱了。统一放到 Pinia 的 actions 里处理,页面只调用 action,状态变更只有一处入口,出问题好排查。

5. 上线部署与常见问题

5.1 部署方案

简易项目的部署没必要上 Docker Compose、K8s 那套,一台 2 核 4G 的云服务器就够了。打包部署流程是:

  1. 后端用 Maven 打包成 jar:mvn clean package
  2. 前端执行npm run build,生成 dist 静态文件
  3. 用 Nginx 托管前端静态文件,并把/api路径反向代理到后端的 8080 端口

Nginx 的关键配置如下:

server { listen 80; server_name your-domain.com; # 前端静态文件 location / { root /opt/mall/dist; try_files $uri $uri/ /index.html; } # 后端接口转发 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

try_files $uri $uri/ /index.html这一行特别重要,Vue Router 默认是 history 模式,刷新某个子路由页面时 Nginx 找不到对应的文件就会 404,加上这行会把所有未知路径都交给 index.html,由前端路由接管。

后端启动我用了最简单的 nohup 方式:

nohup java -jar mall-server.jar --spring.profiles.active=prod > mall.log 2>&1 &

刚开始图省事没加--spring.profiles.active=prod,结果连的是本地数据库,白折腾了半小时。建议提前把application-prod.yml配置好,数据库连接、日志级别、上传路径都区分开,这算是我给自己记的一笔账。

5.2 高频问题排查速查表

做这个项目的过程中,我记录了一些典型问题和对应的排查思路,直接整理成表格方便查阅:

问题现象可能原因解决方案
前端请求接口报 404跨域配置缺失或 Nginx 代理路径不对检查后端 CORS 配置,检查 Nginxlocation /api/是否包含proxy_pass http://127.0.0.1:8080(末尾别漏反斜杠)
刷新页面就 404Nginx 未配置try_files加上try_files $uri $uri/ /index.html;
用户明明登录了,接口仍返回 401token 没传,或 token 过期检查 axios 拦截器是否取到 token;JWT 默认 7 天过期,过期就引导重新登录
下单提示库存不足,但库存明明够乐观锁条件更新失败,事务回滚检查WHERE stock >= #{quantity}条件是否生效;检查是否在同一事务内做了多次扣减
金额显示 0.30000000000000004数据库字段用了 FLOAT/DOUBLE改成 DECIMAL(10,2)
前端能访问页面但接口报跨域后端的 CORS 配置允许的域名不对在 Spring Boot 里配置CorsFilter@CrossOrigin,允许前端实际使用的域名和端口
两个用户同时买最后一个库存,只有一单成功说明超卖防护生效了,这是对的给用户提示"库存不足",让他重新下单
订单列表查不到刚下的单订单表和订单明细表不在同一事务;或 user_id 查询条件写错检查 Service 方法是否加了@Transactional;检查查询条件是否带了当前用户 ID

5.3 我自己踩过的几个坑

最后分享几个印象比较深的实战坑,都是文档里不会写的东西。

第一个坑:事务只加了一半。我第一次写下单接口的时候,createOrder方法里调用了deductStockinsertOrder,但@Transactional只加在了 Controller 调用的 Service 方法上,内部调用this.deductStock()时事务没有传播过去。排查半天发现库存扣减成功但订单插入失败时,库存没有回滚。后来把所有写操作收敛到同一个 Service 方法里,事务才真正生效。Spring 的@Transactional只在通过代理对象调用外部方法时生效,同类内部的this调用会绕过代理,这是初学者最容易掉进去的经典坑。

第二个坑:把整个购物车查出来做校验。结算页需要展示"选中的商品总金额",我一开始是把购物车所有商品查出来,然后在前端算总价。后来发现用户可以用浏览器开发者工具改前端计算逻辑,提交订单时把总金额改成 1 分钱。这个漏洞的修复方案是:后端拿到购物车商品后自己算总价,而不是信任前端传来的金额。订单金额必须后端计算,前端传来的金额只能作为展示参考。

第三个坑:图片路径写死导致部署后图片全挂。开发的时候图片地址写的是localhost:8080/uploads/xxx.jpg,部署到服务器后前端页面全部引用开发机地址,自然加载不出来。解决方案是 Nginx 加一个/uploads/的静态资源映射,图片资源统一走相对路径/uploads/xxx.jpg,换环境的时候只需要改 Nginx 配置,不用改代码。

第四个坑:数据库字符集没设 utf8mb4。商品名称里面如果有 emoji(比如"原创手作草莓🍓帆布袋"),插入数据库时直接报错。MySQL 的 utf8 字符集只能存 3 字节的字符,emoji 是 4 字节的,必须用utf8mb4才能存。建库的时候直接指定:

CREATE DATABASE mall DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

这个坑防不胜防,最好建库的瞬间就搞定,不然后面每个表都要改字符集,麻烦得很。

做完这个项目,我最大的体会是:简易不是简陋,而是把核心闭环做扎实。订单状态、库存扣减、权限校验这些关键点,无论项目多小都要认真对待,因为它们是电商系统的"命门";而秒杀、优惠券这些功能,等你的业务真的需要了,再一个一个加上去反而更踏实。给朋友的项目上线跑了一个多月,两百多个用户下单没有出过一次库存问题,这就够了。后面如果访问量上来,优先考虑加 Redis 缓存商品详情、加消息队列处理订单超时,再往后才是考虑拆服务,那条路是另一篇文章的事了。

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

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

立即咨询