☰
购物商城源码拆解:从JWT登录到支付回调的电商闭环实践
2026/10/6 12:43:24 网站建设 项目流程

简介:面向电商入门开发者的购物商城项目压缩包,源自开发者liezu7的实践,聚焦在线购物平台的核心链路。资源围绕用户注册与登录、购物车管理、商品浏览与支付流程等核心功能展开,完整呈现电子商务网站的基础组件。压缩包大小约3.29MB,已有219人浏览学习。

此压缩包适合希望掌握全栈开发细节的学习者,通过阅读和运行其中的代码与配置,可了解前端界面构建、后端业务逻辑以及数据库表设计,并接触HTTPS协议、OAuth认证、JWT令牌等安全保障手段。对于正在搭建课程设计或毕业设计中的商城系统,或想梳理电商业务闭环的开发者,具有直接参考价值。资源将用户注册、商品选购、购物车管理、订单结算等环节串联起来,帮助读者形成从需求分析到技术落地的整体认知。

1. 购物商城_liezu7:从注册到支付,这一包电商源码值不值得拆

这套名为购物商城_liezu7 的项目,把购物链路里最容易被轻视的三个模块——用户注册登录、购物车、支付结算——串成了一整套能跑通的电商闭环。我拿到压缩包时以为它只是又一个 CRUD 练手项目,真正拆完才发现,光订单状态和库存扣减的先后顺序,就藏着好几个值得反复推敲的决策点。适合三类人读:准备做电商毕业设计或课设的学生、刚接手商城项目想摸底的后端新人、以及想抄一份能落地的登录与支付代码的开发者。它不完美,配置散落、异常处理不彻底,但恰恰是这些不完美,让复现的人能提前踩到真实的坑。

2. 用户注册与登录:JWT 认证、密码加密与会话失效的边界

2.1 用户表设计与注册接口的落地方式

电商系统的用户表是整条链路的起点,后面购物车、订单、优惠券都要外键关联到它。常见的做法是把 id、username、password、email、phone、status、role 这些字段落到一张 user 表里,密码绝对不能明文存放。status 字段用来标记账户是否被禁用,role 字段留给后台管理员和普通用户区分权限。

Spring Boot 里有现成的 BCryptPasswordEncoder 可以直接用,加密时每次都会生成带随机盐的密文,所以即使两个用户密码相同,落库的密文也完全不一样。注册接口要做两件事:唯一性校验和密码加密。

// UserController:注册接口 @PostMapping("/api/user/register") public Result register(@RequestBody RegisterDTO dto) { // 1. 先做唯一性校验,避免用户名和邮箱被重复注册 User existing = userMapper.selectByUsername(dto.getUsername()); if (existing != null) { return Result.error(400, "用户名已被占用"); } if (!StringUtils.hasText(dto.getPassword()) || dto.getPassword().length() < 6) { return Result.error(400, "密码不能为空且长度不能小于 6 位"); } // 2. 密码加密后再入库,禁止存明文 User user = new User(); user.setUsername(dto.getUsername()); user.setPassword(passwordEncoder.encode(dto.getPassword())); user.setEmail(dto.getEmail()); user.setStatus(1); userMapper.insert(user); // 3. 注册成功只返回提示,不直接签发 token return Result.success("注册成功,请登录"); }

这个接口里最容易犯的错是注册完立刻生成 JWT 返回给前端。看起来省了一次登录请求,实际是把“注册建号”和“登录发令牌”两个动作强行耦合在一起。短信验证码激活、管理员审核、注册后补全资料这些场景都会被这个设计堵死。

参数说明:passwordEncoder 在 Spring 项目里就是 BCryptPasswordEncoder 的实例;RegisterDTO 里的字段建议加上 javax.validation 注解,比如 @NotBlank、@Email,把参数校验挡在 Controller 层之前,而不是在代码里手动判空。

2.2 JWT 登录:签发、校验与失效策略

JWT 的优势是无状态,服务端不用维护 session,特别适合前后端分离的商城架构。登录接口验证用户名密码后签发一个 token,后续请求在 Header 里带 Authorization: Bearer token 即可。

签发的代码逻辑很直接,但有三个细节不能省:Subject 只放 userId,不放手机号和邮箱这类敏感信息;过期时间必须显式设置;签名密钥必须从配置读取,不能写死在类里。

// AuthService:登录核实并签发 token public LoginVO login(String username, String password) { User user = userMapper.selectByUsername(username); // 统一提示语,避免暴露用户是否存在 if (user == null || !passwordEncoder.matches(password, user.getPassword())) { throw new BusinessException("用户名或密码错误"); } if (user.getStatus() == 0) { throw new BusinessException("账号已被禁用"); } String token = Jwts.builder() .setSubject(String.valueOf(user.getId())) .claim("role", user.getRole()) .setExpiration(new Date(System.currentTimeMillis() + loginExpireHours * 3600 * 1000)) .signWith(secretKey, SignatureAlgorithm.HS256) .compact(); return LoginVO.builder().token(token).userInfo(convert(user)).build(); }

loginExpireHours 是外部配置项,普通用户端我一般设 24 小时,管理后台缩短到 2 小时。secretKey 如果低于 32 字节,HS256 的安全性就大打折扣。token 签发之后,校验统一放在拦截器里,每个需要登录的接口都会经过它。

// JwtInterceptor:请求头校验 public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) throws Exception { String auth = req.getHeader("Authorization"); if (auth != null && auth.startsWith("Bearer ")) { try { Claims claims = Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(auth.substring(7)) .getBody(); req.setAttribute("userId", claims.getSubject()); return true; } catch (ExpiredJwtException e) { res.setStatus(401); res.getWriter().write("token 已过期"); return false; } catch (Exception e) { res.setStatus(401); res.getWriter().write("token 非法"); return false; } } res.setStatus(401); return false; }

JWT 的失效问题要单独说,它本身是无状态的,服务端无法把已经签发的 token 主动置为无效。用户修改密码、管理员封号、用户退出登录,这些场景都需要配合黑名单或者 Redis 白名单处理。这个项目里采用的是最省事的方案:把 token 过期时间设置短一点,同时在拦截器里查一次用户状态。

2.3 OAuth 第三方登录与本地账号映射

商城项目基本绕不开微信登录或支付宝登录。常见做法是标准 OAuth2 授权码流程:前端跳转授权页,用户同意后拿到 code,后端用 code 换 access_token,再通过 token 调用户接口拿到 openid,最后在本地 user 表里映射账号。

// OAuthService:第三方登录回调处理 public LoginVO wxLogin(String code) { String url = "https://api.weixin.qq.com/sns/oauth2/access_token" + "?appid=" + wxAppId + "&secret=" + wxSecret + "&code=" + code + "&grant_type=authorization_code"; // 1. 用 code 换 openid,这一步必须后端去做,secret 不能暴露给前端 WxToken token = restTemplate.getForObject(url, WxToken.class); // 2. 查本地账号,没有就新建一条空账号 User user = userMapper.selectByOpenId(token.getOpenid()); if (user == null) { user = new User(); user.setOpenId(token.getOpenid()); user.setStatus(1); userMapper.insert(user); } // 3. 走和账号密码登录相同的 JWT 签发逻辑 return issueToken(user); }

很多新手把 access_token 直接当登录凭证用,这是错的。access_token 是访问第三方用户信息的临时凭证,会过期,而 openid 才是用户在某个应用下的唯一标识。第一次授权登录后用户往往没有手机号和邮箱,后续还需要补一个“绑定手机号”的接口,把 openid 关联到已有账号上。user 表里预留 unionId 字段也会省事很多,同一个用户在多个应用间的身份就靠它打通。

3. 购物车模块:Redis 缓存、库存扣减时机与价格快照

3.1 购物车存储结构:为什么用 Redis hash 而不是 list

登录和注册解决了身份问题,购物车就是下单前最关键的高频交互。用户随时随地会改数量、勾选商品、批量结算,购物车要求读写快、不能丢。常见的实现是 Redis 和 MySQL 各存一份,Redis 负责读得快,MySQL 负责兜底。

Redis 里存购物车,我用的是 hash 结构,而不是 string 或 list。key 是 cart:{userId},字段是 skuId,值是数量。这样一次网络请求就能拿到整辆购物车的内容。

# 用户 1001 的购物车 key:cart:1001 # 字段是 skuId,值是加入数量 HSET cart:1001 101 2 # 加入商品 101,数量 2 HINCRBY cart:1001 101 1 # 商品 101 数量加 1 HGETALL cart:1001 # 取出该用户全部购物车内容 EXPIRE cart:1001 2592000 # 30 天不活跃自动过期

list 结构不推荐,要按 skuId 增量修改就得遍历,数据量大以后查询和更新都很别扭。TTL 设 30 天代表购物车不是永久存储,Redis 里过期了,就以 MySQL 里的购物车表为准重新加载。Redis 一旦宕机,从数据库全量回源。

3.2 加购:Redis 与 MySQL 双写的一致性

Redis 和 MySQL 双写,最怕两者数据不一致。我一般的策略是:Redis 做主读写缓存,MySQL 做主数据,写入时先写 Redis 再异步回写数据库,读购物车时先查 Redis,没有再去 MySQL 回源并回填 Redis。

加购接口在写库存之前,要先校验商品状态。商品已经下架、数量超过限购阈值、用户状态异常,这些都要在这一层挡住。

// CartService:加购 public void addToCart(Long userId, Long skuId, Integer quantity) { if (quantity == null || quantity <= 0 || quantity > 99) { throw new BusinessException("加购数量不合法"); } Sku sku = skuMapper.selectById(skuId); if (sku == null || sku.getStatus() != 1) { throw new BusinessException("商品不存在或已下架"); } String key = "cart:" + userId; // 1. 写 Redis,incr 保证并发下数量不丢失 redisTemplate.opsForHash().increment(key, skuId, quantity); redisTemplate.expire(key, Duration.ofDays(30)); // 2. 异步回写 MySQL,用 upsert 做增量合并 cartItemMapper.upsertIncrement(userId, skuId, quantity); }

这里最关键的细节是 DB 侧不要先 select 再 update,要用 upsert 增量写法。SQL 大概长这样:insert on duplicate key update quantity = quantity + values(quantity)。如果先查询再覆盖,两个请求同时加购同一商品,后写的一次会把前一次的量覆盖掉,购物车莫名其妙少东西。

注意:加购接口里的商品状态校验只是展示层约束,真正的库存锁控制必须在下单事务里重新做,加购时查到的库存数不能作为下单依据。

3.3 库存扣减时机和价格快照

购物车模块最容易翻车的设计是加购就扣库存。用户加购 10 件不付款,库存就被白占 10 件,高峰期一个恶意用户能把热门 SKU 全占空。正确的做法是把扣库存的时机往后推。

业务环节是否影响库存常见实现
加购否只记录数量,不触碰库存
提交订单预占冻结库存字段,支付超时释放
支付成功扣减冻结转扣减,状态同步
关单/取消释放冻结回补,保证幂等

价格这里也有一个很关键的设计:购物车展示用商品当前价格,下单时订单行里存的是价格快照。商品改价是商城常态,如果订单里不存商品标题、单价、SKU 规格这些快照,后续对账、售后、大促返场统计都会非常痛苦。

另外购物车勾选状态也要入库。用户可能加购了一堆商品,结算前只勾选其中几件,这个选择如果只存在前端内存里,换个设备就丢了。MySQL 的 cart_item 表加一个 checked 字段,下单选中的部分,下单完成后把已选商品从购物车清掉。

4. 支付流程:订单状态机、回调验签与对账补偿

4.1 从购物车到订单:下单接口和订单状态机

支付绝对不能由前端直接向支付平台发起支付请求,那样金额和商品都能被篡改。正确顺序是:前端把勾选商品提交给后端,后端在服务端确认订单有效,组装支付参数返回给前端,前端再唤起收银台。

下单接口要在一个事务里完成:生成订单号、写入订单主表、写入订单行快照、预占库存、清空购物车选中项。

// OrderService:创建订单 @Transactional(rollbackFor = Exception.class) public OrderVO createOrder(Long userId) { // 1. 取当前用户勾选的商品 List<CartItem> items = cartItemMapper.selectChecked(userId); if (items.isEmpty()) { throw new BusinessException("没有选中商品"); } // 2. 生成业务订单号,落订单主表 String orderNo = generateOrderNo(userId); Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setStatus(0); // 0待支付 1已支付 2已发货 3已完成 4已关闭 order.setTotalAmount(calculateAmount(items)); orderMapper.insert(order); // 3. 写订单行快照,价格以当前商品价格为准 for (CartItem item : items) { orderItemMapper.insert(buildOrderItem(order.getId(), item)); // 4. 预占库存 stockService.occupy(item.getSkuId(), item.getQuantity()); } // 5. 清空已下单的购物车商品 cartItemMapper.deleteChecked(userId); return buildPayOrder(order); }

订单状态我用整数存数据库里,排序和索引都方便,但对外接口要用字符串语义转换。orderNo 必须业务唯一,不能依赖数据库自增 id,支付回调拿订单号回来对账时,自增 id 可不好对。

有个坑值得提前说:@Transactional 事务内不要做远程支付请求。把组装支付宝/微信支付参数的调用放在事务外执行,否则整个事务会一直占着数据库连接,高峰期连接池很快被打满。

4.2 支付回调验签:签名、金额和订单号三重校验

支付平台不会通过前端告诉你支付结果,而是通过异步回调通知后端。回调接口有三重东西必须校验:签名、金额、订单号归属。

// PayNotifyController:支付平台异步回调 @PostMapping("/api/pay/notify") public String notify(HttpServletRequest request) throws Exception { Map<String, String> params = parseParams(request.getParameterMap()); // 1. 验签不通过直接拒绝,且不返回 success if (!alipayClient.rsaCheckV1(params)) { return "验签失败"; } // 2. 只处理支付成功状态 if (!"TRADE_SUCCESS".equals(params.get("trade_status"))) { return "success"; } // 3. 以回调里的订单号和金额与本地订单比对 String orderNo = params.get("out_trade_no"); String paidAmount = params.get("total_amount"); Order order = orderMapper.selectByOrderNo(orderNo); if (order == null || !order.getTotalAmount().equals(new BigDecimal(paidAmount))) { return "金额不一致"; } // 4. 用条件更新保证幂等,防止重复回调 int updated = orderMapper.updateStatusWithLock( orderNo, 0, 1, params.get("trade_no")); if (updated > 0) { stockService.confirmOccupy(orderNo); } // 5. 告诉支付平台不用再推了 return "success"; }

逻辑说明:验签用的是支付平台公钥,不是自己生成的那对 RSA 密钥里的私钥。配置公钥配错最常见的结果就是回调一直返回验签失败,支付平台一小时内重试十几次。

只校验订单号不校验金额是回调接口最致命的漏洞。攻击者伪造一个同订单号的回调,把金额传成 0.01,如果后端不比对 total_amount,订单就被错误标记成已支付。所以金额比对必须做,且要 BigDecimal 比较值相等,不能用 double。

成功响应要原样输出 success,不能带 JSON 壳子、不能带 HTML 标签,否则支付平台认为通知失败会一直重试。

提示:回调接口不要打大段的业务日志,回调频率高且可能被恶意刷,日志只记订单号和验签结果就够了。

4.3 掉单、重复回调与超时关单

掉单是支付对接里最常见的售后问题:用户确实付了钱,但回调没到,订单还停在待支付。应对掉单不能只等回调,要在订单创建后放一个定时任务主动查单。订单超过一定时间仍未收到回调,就调用支付平台的主动查询接口,把本地订单状态修正。

重复回调则要靠数据库层面的条件更新兜底。上面代码里 updateStatusWithLock 的本质是 update orders set status = 1 where order_no = ? and status = 0,受影响行数为 0 说明状态已经被其他线程改掉了,就不再重复处理。这样比在代码里加同步锁更可靠,因为它是数据库层保证的原子性。

超时关单的逻辑也依赖同一个状态机约束。

-- 定时任务:每分钟扫一次待支付订单,超过 15 分钟自动关闭 UPDATE orders SET status = 4, close_time = NOW() WHERE status = 0 AND create_time < DATE_SUB(NOW(), INTERVAL 15 MINUTE)

关单前还要判断订单当前状态,关单后要释放预占库存。这里最怕的是支付回调被阻塞、超时关单先跑了,用户钱付了订单却变成已关闭。所以关单和回调必须走同一个条件更新,先把订单状态抢到手的那个线程才允许操作库存。

5. 购物商城避坑实录:部署、联调与安全配置里的四个坑

如果说前面三章是顺着业务往下走,这一章是把项目从拿到手到稳定上线最容易翻车的四个点提前摆出来。压缩包里代码本身是能跑的,但直接扒下来就部署,下面四个坑几乎躲不掉。

5.1 前端调不通后端:跨域和 context-path 双重叠加

现象:前端 npm run dev 启动后,请求 /api/user/login 一直报 404,或者浏览器控制台提示 CORS blocked。

原因:这套代码如果后端配置了 server.servlet.context-path=/shop,接口实际地址是 /shop/api/user/login,前端代理只转发 /api 开头的请求,路径对不上自然 404。跨域则是前后端端口不一致而项目里没配跨域过滤器。

解决:我一般直接把 context-path 去掉,前后端统一访问 /api 前缀。前端代理和后端接口约定要对齐,这个约定要在写前两个接口的时候就定下来。

# application.yml server: port: 8080 servlet: context-path:
// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })

验证方法很简单:curl 直连后端接口返回 JSON,再从前端页面发一次相同请求,看是否通。

5.2 JWT 密钥硬编码和用户封禁后的 token 问题

现象:项目里的 JWT secret 写在 application.yml 里且值为默认的 secret123456;管理后台把某个用户封禁后,该用户拿着旧 token 依然能访问下单接口。

原因:JWT 只要密钥泄露,任何人都能伪造一个 userId=1 的 token。用户封禁后 token 依然有效是因为服务端没有在每次请求时校验用户状态。

解决:密钥从环境变量读取,长度至少 32 字节。拦截器里通过 userId 再查一次用户状态,发现 status=0 直接返回 401。虽然多一次 DB 查询,但在电商后台完全可以接受。

// JwtInterceptor:鉴权后再查一次用户状态 Long userId = Long.valueOf(claims.getSubject()); User user = userMapper.selectById(userId); if (user == null || user.getStatus() == 0) { res.setStatus(401); res.getWriter().write("账号无效或已禁用"); return false; }

每次请求都查库对性能有损,但这也暴露了 JWT 的一个固有边界:无状态 token 无法主动失效。高并发项目一般会在 Redis 里维护一个用户状态标记,拦截器只查 Redis 不查 MySQL,把这个损耗降到最低。

5.3 支付回调与关单并发:库存被释放两次

现象:用户刚完成支付,订单状态还没来得及更新,超时关单任务扫到了这条记录,把订单关掉并释放了预占库存;回调随后又把订单置为已支付。最终结果是订单状态混乱、库存被重复加回。

原因:回调线程和关单线程同时读到 status=0,两个线程都认为订单还处于待支付状态,各自执行了后续逻辑。这是典型的并发竞态。

解决:所有订单状态流转都走条件更新,谁先把 status 从 0 改成目标状态,谁才有权限操作库存。影响行数为 0 的线程直接返回并记录告警日志。

int rows = orderMapper.compareAndSetStatus(orderNo, expectedStatus, targetStatus); if (rows == 0) { // 状态已被其他线程改变,停止后续动作 alertService.save("订单状态冲突:" + orderNo); return; } // 只有抢到状态变更权的线程才允许改库存 stockService.release(orderNo);

这个模式同样适用于退款、取消订单、确认收货这些状态流转。电商系统的状态机一旦开了并发操作的口子,后续所有补偿逻辑都会变得不可控。

5.4 MySQL 时区少 8 小时和中文乱码

现象:订单创建时间写入数据库后,显示的时间比本地时间晚了 8 小时;商品标题包含中文可以在 Navicat 里正常显示,但通过代码插入后变成 ????。

原因:数据库连接 URL 没配 serverTimezone,驱动用了服务器默认时区;字符集没有指定 utf8mb4,或者建表时用了旧的 latin1/uft8。

解决:连接 URL 显式加参数。

spring: datasource: url: jdbc:mysql://localhost:3306/shop?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai

注意是 utf8mb4 而不是 utf8。MySQL 里 utf8 最多支持 3 字节,表情符号和部分生僻字存不进去,插入会直接报错。建表语句也要显式指定 DEFAULT CHARSET=utf8mb4。时区和字符集这两个问题单独看都不大,但两个叠加出现时很容易让人误以为是代码逻辑写错了,排查时间往往超过一小时。

6. 完整跑通一次下单:从空数据库到支付回调的验证清单

解压这份购物商城源码后,别急着改业务。我拿到手第一件事是拉一条最小链路验证环境自洽:建库 → 启动后端 → 启动前端 → 注册 → 登录 → 加购 → 下单 → 模拟支付回调 → 查订单状态。整条链路从头到尾跑通,再去读代码细节。

6.1 启动环境与接口自检

# 1. 建库导入初始化脚本 mysql -uroot -p < docs/shop.sql # 2. 构建并启动后端 cd backend mvn clean package -DskipTests java -jar target/shop-backend.jar --spring.profiles.active=dev # 3. 构建并启动前端 cd frontend npm install npm run dev
# 4. 注册账号 curl -X POST http://localhost:8080/api/user/register \ -H "Content-Type: application/json" \ -d '{"username":"demo","password":"123456","email":"demo@example.com"}' # 5. 登录拿 token curl -X POST http://localhost:8080/api/user/login \ -H "Content-Type: application/json" \ -d '{"username":"demo","password":"123456"}'

接口自检时可以对照下面这份清单,每通过一项就说明对应模块的核心链路没堵住。

验证步骤请求/操作期望结果
注册POST /api/user/register返回注册成功
登录POST /api/user/login返回 token 和用户信息
查看商品GET /api/product/list返回上架商品列表
加购POST /api/cart/add购物车数量变为期望值
下单POST /api/order/create订单状态为 0(待支付)
模拟支付回调POST /api/pay/notify订单状态变为 1(已支付)
查单GET /api/order/status订单状态为 1,库存已扣减

6.2 一条值得养成的交付习惯

我拆这套源码时最大的教训是:别一上来就死磕支付回调。先把注册、登录、购物车这些看似简单的前置模块跑通,后面订单和回调的很多问题其实是前面埋的雷。比如登录接口没有校验用户状态,购物车加购没有做数量上限,这些小问题最终都会在支付链路里集中爆发成所谓的大 bug。

从那以后我每次接手商城项目,都强制自己先跑一遍完整下单链条,再动手改业务。环境自洽了,后面定位问题才能分清到底是代码逻辑的问题,还是环境配置的问题。希望帮到你。

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

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

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

立即咨询