
简介面向电商入门开发者的购物商城项目压缩包源自开发者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_typeauthorization_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 的购物车 keycart: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. 写 Redisincr 保证并发下数量不丢失 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. 取当前用户勾选的商品 ListCartItem 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 { MapString, 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 只要密钥泄露任何人都能伪造一个 userId1 的 token。用户封禁后 token 依然有效是因为服务端没有在每次请求时校验用户状态。解决密钥从环境变量读取长度至少 32 字节。拦截器里通过 userId 再查一次用户状态发现 status0 直接返回 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 支付回调与关单并发库存被释放两次现象用户刚完成支付订单状态还没来得及更新超时关单任务扫到了这条记录把订单关掉并释放了预占库存回调随后又把订单置为已支付。最终结果是订单状态混乱、库存被重复加回。原因回调线程和关单线程同时读到 status0两个线程都认为订单还处于待支付状态各自执行了后续逻辑。这是典型的并发竞态。解决所有订单状态流转都走条件更新谁先把 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?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai注意是 utf8mb4 而不是 utf8。MySQL 里 utf8 最多支持 3 字节表情符号和部分生僻字存不进去插入会直接报错。建表语句也要显式指定 DEFAULT CHARSETutf8mb4。时区和字符集这两个问题单独看都不大但两个叠加出现时很容易让人误以为是代码逻辑写错了排查时间往往超过一小时。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.activedev # 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:demoexample.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。从那以后我每次接手商城项目都强制自己先跑一遍完整下单链条再动手改业务。环境自洽了后面定位问题才能分清到底是代码逻辑的问题还是环境配置的问题。希望帮到你。本文还有配套的精品资源点击获取