前阵子一个做运营的朋友找我,说想搞一个卖手作文具的小商城,初期用户量就几百人,预算不高,找外包不划算,问我自己拿 Spring Boot 能不能搭。我花了一个周末把骨架拉起来,又把下单、购物车、库存这些核心流程从头到尾过了一遍。这篇就把当时从零搭建简易电商平台的完整思路、代码结构和踩坑记录整理出来,给准备动手做类似项目的朋友一个可以直接参考的路线图。
这个项目适合两类人:一是刚学完 Java Web 想找个完整项目练手的学生,二是非技术出身想低成本验证业务想法的小团队。我会把"为什么这么设计"也讲清楚,而不是只丢一堆 CRUD 代码。
1. 先想清楚:什么才算"简易"电商平台
1.1 需求边界怎么划
很多人一上来就想着要秒杀、优惠券、分销、直播带货,这是典型的过度设计。简易电商平台的核心闭环其实就一句话:用户能浏览商品、加入购物车、下单付款、查看订单。
我当时和朋友敲定的功能清单是这四个模块:
- 商品模块:商品列表(分页)、商品详情、按分类筛选、简单的关键词搜索
- 用户模块:注册、登录、退出,登录态用 JWT 维持
- 购物车模块:加入购物车、修改数量、删除、勾选结算
- 订单模块:提交订单、订单列表、订单详情、模拟支付、取消订单
砍掉的东西也明确列出来:不做秒杀、不做复杂的营销活动、不做多级分销、不做售后工单、不做优惠券引擎。支付也只做模拟支付,对接微信支付宝需要商户资质,个人项目可以先留接口后实现。
这个边界划清楚之后,工作量一下子从"两个月"变成"一周"。很多项目烂尾,不是因为技术难度大,而是因为需求像滚雪球一样越滚越大。做简易平台的第一原则就是学会说"不",把非核心功能全部推后。
1.2 技术栈怎么选
选技术栈不是越新越好,也不是越轻越好,而是让你能最快跑通闭环、后面又能平滑扩展。我当时选的是这套:
| 层级 | 选型 | 选择理由 |
|---|---|---|
| 后端框架 | Spring Boot 2.7 | 生态成熟,资料多,遇到问题好搜 |
| ORM | MyBatis-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 的云服务器就够了。打包部署流程是:
- 后端用 Maven 打包成 jar:
mvn clean package - 前端执行
npm run build,生成 dist 静态文件 - 用 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(末尾别漏反斜杠) |
| 刷新页面就 404 | Nginx 未配置try_files | 加上try_files $uri $uri/ /index.html; |
| 用户明明登录了,接口仍返回 401 | token 没传,或 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方法里调用了deductStock和insertOrder,但@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 缓存商品详情、加消息队列处理订单超时,再往后才是考虑拆服务,那条路是另一篇文章的事了。