ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Spring Boot+Vue体育购物商城:从SPU/SKU建模到订单库存闭环

Spring Boot+Vue体育购物商城:从SPU/SKU建模到订单库存闭环 前后端分离的商城项目多到烂大街但“基于Spring Boot Vue的体育购物商城”真能做到完整闭环的不多。我说的完整闭环是用户能注册登录、能浏览商品、能加购物车、能下单、能模拟支付、能看到订单状态一路变化后台还能管商品和库存。这不是一个只跑通列表查询的 Demo而是可以直接拿来当毕业设计底稿或者从 CRUD 水平往全栈方向靠拢的完整项目。我花了不少精力把前后端彻底串了一遍后端用 Spring Boot 出 REST API前端用 Vue 搭单页应用中间的消息传递、状态管理、权限控制都按真实商城的标准做。今天这篇文章就把整个项目的骨架、核心思路、关键实现和部署时最容易踩的坑一次性讲清楚。1. 为什么“体育购物商城”比一个普通商城项目更值得做1.1 体育用品这个场景逼你把业务细节做实这里其实存在一个一般项目里察觉不到的业务差异。普通电商项目里商品就是一个“名字价格库存”但放到体育运动品类商品属性立刻复杂了好几倍。仔细想想同一款跑步鞋从42码到44码各是一个独立库存单位颜色、版本、预售状态都要拆分一款篮球又有不同尺码和材质版本健身器材更是带着规格参数和包邮策略。如果只是按“商品表库存字段”的常规做法去建表后面做订单、做库存扣减的时候一定会被逼到返工。这个项目最大的价值就在这里它逼你认识SPUStandard Product Unit标准产品单元和SKUStock Keeping Unit库存量单位的建模区别。熟悉了这种模式往后做任何带规格属性的业务都能直接复用这套思路。1.2 最终敲定的技术栈组合后端选的是 Spring Boot 2.7 系 JDK 1.8。很多同学一上来就用最新的 Spring Boot 3.x结果发现 JDK 版本直接不兼容IDEA 里新建项目都报错。这里我明确选 2.7.14最大的原因是它依旧支持 JDK 1.8而 Spring Boot 3.x 强制要求 JDK 17对大部分还在用老环境的人非常不友好。配套组件选了这些模块选型理由ORMMyBatis-Plus 3.5.x单表 CRUD 基本不用写 SQL分页插件也顺手缓存Redis 5购物车、Token、验证码、热门商品都能覆盖认证方案JWT Spring Boot 拦截器无状态适合前后端分离数据库MySQL 8.x支持 JSON 字段适合存商品规格明细前端框架Vue 3 Vite组合式 API 更适合逻辑复用Vite 冷启动速度快前端状态PiniaVue 3 官方推荐比 Vuex 的 TypeScript 体验更好UI 组件Element Plus做后台管理页面效率极高组件完整这套组合在国内的项目里特别常见网上资料多哪怕遇到问题也容易搜到答案。1.3 这个项目跑通的功能清单项目不是做一半就扔的半成品核心电商流程都走完了用户注册、登录、退出密码存的是 BCrypt 加密后的密文首页商品列表、商品详情、按运动分类筛选购物车加入商品、修改数量、删除商品购物车数据存 Redis订单确认页从购物车勾选商品生成订单生成订单时锁定库存防止超卖模拟支付宝沙箱支付支付成功后异步回调更新订单状态订单列表、取消订单、删除订单、查看订单详情简单的后台管理商品上架下架、SKU 库存编辑后面我会针对这些功能里面最容易出问题的几个点展开说尤其是库存和订单的状态流转这两块是电商项目里最容易体现开发经验的分水岭。2. 后端工程结构与数据模型先想清楚表设计再谈功能实现2.1 后端工程包结构怎么划分我用的是标准单模块结构没有一上来就拆多模块。对个人开发者和毕业设计来说单模块反而更好维护因为你的核心诉求是“快速理清业务”不是“团队协作下的模块解耦”。典型的包结构是这样的com.sportshop ├── common # 通用返回类、异常处理、工具类 ├── config # Redis、MyBatis-Plus、拦截器注册配置 ├── controller # 接口层只做参数接收和返回 ├── service # 业务逻辑层 ├── mapper # MyBatis-Plus 的 Mapper 层 ├── entity # 数据库实体类 ├── dto # 接口入参和出参对象 ├── vo # 视图对象组合多个表的数据 └── interceptor # JWT 登录拦截器一个重点提醒controller 里不要写任何业务逻辑。我之前见过不少人把库存判断、订单金额计算全写在 controller 里一长串代码非常难维护。正确的做法是 controller 只干三件事接收参数、调 service、返回统一结果。这样后面加接口、改逻辑都很轻松。2.2 SPU 与 SKU体育商品建模的核心运动商品的规格属性是我特意没有偷懒的地方。拿跑步鞋举例它有颜色、尺码两个规格维度每组合一个规格就是一个 SKU。如果只建一张商品表整双鞋的库存根本没法管理客户买个 43 码的但你的库存只有现价字段上面的钱是另一个尺码的这不荒诞吗所以我拆成了三张表商品表spu存储商品公共信息商品名、描述、主图、分类 ID、原价、促销价、上下架状态。商品规格表sku每个 SKU 记录一条SKU 名称、规格值JSON 格式存比如{color: 黑, size: 42}、单价、库存、SKU 图片、销量等。规格值用 JSON 字段存是个很实用的技巧因为不同品类的规格维度完全不同——鞋有尺码球衣有衣服尺码瑜伽垫几乎没有规格。如果为每种品类建规格字段表会让设计臃肿到不可维护用 JSON 字段反而灵活查询时如果需要按规格筛选可以配合 MySQL 8 的 JSON 函数处理大部分业务场景完全够用。2.3 SKU 库存与订单的关系库存字段直接挂在 sku 表上每次下单扣减对应 SKU 的库存。这张表是整个库存链路的核心下面体现一下字段设计思路字段含义说明idSKU ID主键订单明细里直接引用spu_id所属商品 ID关联 spu 表sku_name规格名例如“黑色/42码”spec规格 JSON存颜色、尺码等维度price单价下单时以这个价格为准stock当前库存扣减是直接在 SQL 里做条件更新sales销量下单成功后 1库存扣减是并发安全的重灾区后面我会专门讲怎么用乐观锁防止超卖。2.4 订单表必须冗余商品快照不能只存 SKU ID这是很多新手容易忽略的细节。订单表里除了订单号、用户 ID、总金额、状态、收货地址这些基础字段订单明细表必须冗余一份当时的商品名称、规格描述、单价、图片而不是下单时只记录 SKU ID需要显示的时候再去查商品表。原因很简单商品改了价格、改了名称甚至下架了你总不能把历史订单也变了吧。真实商城要求下单那一刻的商品信息是“快照”永久锁定在订单明细里。这样也极大减轻了后续订单查询的压力一次查明细表就能拿全展示数据不用再去 join 商品表拆 N 次查询。订单状态这块我也没有把状态轱辘转当成简单数字字段处理而是封装了状态枚举状态值含义允许流向0待支付1、51已支付待发货22已发货待收货33已完成无4已退款无5已取消无状态流转全部放在 service 层校验不允许从“待支付”直接跳到“已完成”也从源头避免了状态被篡改的隐患。3. 登录鉴权JWT 拦截器 Redis 的落地细节3.1 为什么商城项目选择 JWT 而不是 Session商城项目是典型的前后端分离架构前端部署在一台服务器后端接口部署在另一台服务器甚至直接是云端 API。Session 依赖服务端保存登录状态跨域、集群部署、移动端接入都会遇到麻烦JWT 直接把用户标识写到客户端后端通过验签就能确认身份天然适合这种场景。整个登录流程是这样的用户提交用户名密码后端验证通过后用 jjwt 库生成一个 tokenexpire 时间设 7 天过期token 里放三个信息用户 ID、用户名、token 类型把 userId 作为 key、生成的 token 作为 value 存进 RedisTTL 就是登录有效期返回给前端{ token: xxx, userInfo: {...} }有人会问JWT 本身已经无状态了为什么还要丢一份到 Redis这里涉及到“退出登录”和“账号被顶下线”这两个真实业务需求。只靠 JWT 服务端是没法主动让它失效的token 一旦签发在到期之前永远有效。把 token 存 Redis 后退出登录只需要删除 Redis 里那个 key后续请求就算带了旧 token 也校验不过去。3.2 拦截器正确的实现方式Spring Boot 里用拦截器做登录校验最简洁。核心逻辑是对白名单外的请求校验 token 是否存在、是否能解析、Redis 里是否还有效。public class LoginInterceptor implements HandlerInterceptor { Autowired private StringRedisTemplate redisTemplate; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (StringUtils.isBlank(token)) { throw new BusinessException(401, 请先登录); } Claims claims; try { claims JwtUtil.parseToken(token); } catch (Exception e) { throw new BusinessException(401, 登录状态已失效); } Long userId claims.get(userId, Long.class); String redisToken redisTemplate.opsForValue().get(LOGIN_TOKEN_KEY userId); if (!token.equals(redisToken)) { throw new BusinessException(401, 登录状态已过期请重新登录); } // 保存当前登录用户供后续使用 UserContext.set(userId); return true; } }有两点容易踩坑我非常建议留意。第一前端发的 token 要带Bearer前缀还是直接裸 token一定要前后端约定好我这里约定的是裸 token但很多人习惯加 Bearer如果拦截器没去掉前缀验签会一直报错。第二一定不要忘了给 OPTIONS 请求放行否则前端跨域预检请求会在拦截器就被拦下来浏览器控制台长期报跨域错误特别容易误导排查方向。然后实现WebMvcConfigurer把拦截器注册上去Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/**) .excludePathPatterns( /api/user/login, /api/user/register, /api/product/**, /api/category/** ); }商品和分类是匿名可访问的所以放行其余接口都在拦截范围内。这里再补充一点如果你用了 Knife4j 或者 SpringDoc 做接口文档文档路径也要加到白名单里要不然调试接口还得先登录烦得很。3.3 密码安全与接口防刷密码存储用了 BCrypt 加密。很多教学项目还在用 MD5 加盐但 MD5 的运算速度太快暴力破解成本低BCrypt 自带随机盐每次加密结果都不同是当前比较稳妥的密码散列方案。String encoded BCrypt.hashpw(rawPassword, BCrypt.gensalt()); boolean matched BCrypt.checkpw(rawPassword, encoded);另外我给登录和注册接口加了一个简单的防刷同一个 IP 一分钟内最多请求 5 次登录。实现也不复杂用 Redis 的INCR计数第一次请求时设置 60 秒过期超过 5 次直接拒绝。这算是个常规保护虽然无法完全防御恶意攻击但能挡住很多脚本扫接口的行为。4. 购物链路里最容易翻车的三件事购物车、库存扣减、订单超时4.1 购物车存 Redis 为什么更合理购物车有两种主流实现方案存数据库表和存 Redis。这个项目选的是 Redis原因很实际购物车的操作频率高用户往购物车加商品、改数量、删除都是高频轻量操作Redis 的读写性能远高于 MySQL购物车数据对持久性的要求没那么高用户流失或清空购物车完全可接受就算 Redis 宕机丢了一部分也不影响核心交易链路按用户维度存储天然分散了数据库压力Redis 里的数据结构我用的是 Hash一个用户一个 key字段为 SKU ID值为商品数量cart_key:userId:{userId} - { skuId1: 2, skuId2: 1 }为什么用 Hash 而不是 String 存整个 JSON因为 Hash 可以精确控制某个 SKU 的数量增减Long qty redisTemplate.opsForHash().increment(cartKey, skuId, 1);但是购物车页面要展示的信息很多光有 SKU ID 和数量不够得显示商品名称、价格、图片。这里我的做法是页面加载时拿购物车里的 SKU ID 列表批量去查 SKU 表和商品表组装成购物车视图对象。数量在 Redis明细在 MySQL两者通过 SKU ID 拼接。要把关注点放在价格显示的问题上。购物车列表展示价格时到底以哪一方的价格为准前端一开始可能把后端返回的价格静态展示出来用户改数量后直接用前端单价乘数量。而正确做法是结算时价格读取数据库当前价格购物车显示价格只是参考。促销时段价格一夜之间变了用户看到的实时价格应该来自后端。所以下单时我在 service 层重新查了一遍 SKU 价格而不是把购物车快照拿过来就用。4.2 库存扣减如何避免超卖商品库存扣减是并发场景的经典问题。假设商品只剩 1 件10 个用户同时下单如果代码先查库存、再判断、再更新这中间只要发生线程交错最终就能出现“库存明明只有 1订单却成功了两单”。我选的方案是乐观锁 条件更新不额外引入分布式锁。扣减库存的 SQL 长这样boolean update skuMapper.update(null, new LambdaUpdateWrapperSku() .eq(Sku::getId, skuId) .gt(Sku::getStock, 0) // 库存必须大于0 .setSql(stock stock - 1) // 原子扣减 .setSql(sales sales 1));MyBatis-Plus 的setSql可以直接把自减逻辑带到数据库层面执行这样整个 check-and-set 过程变成了一条原子 SQL数据库的行锁保证同一时刻只有一个事务能真正扣到这个 SKU 的库存。update返回影响行数为 0 就说明库存不足或商品异常直接终止下单。这个方法虽然简单但有个前提必须用条件更新把判断和写入放在同一条 SQL 里不能先查再改。如果项目用了“先查库存 - 内存判断 - 再 update stock #{newStock} 这种模式哪怕 update 语句本身是正确的高并发下照样会超卖因为没有把判断条件放进数据库执行层面。4.3 订单超时未支付自动取消有些商品属于限量发售用户下单后 30 分钟不支付系统必须自动把占用的库存归还。实现方案我选了最稳妥的延迟队列 定时扫描兜底双保险。真正生效的执行顺序是用户下单成功后把订单号和取消时间推入 RocketMQ 延迟队列延迟消息到达时检查订单状态如果还是待支付就取消订单并释放库存。但消息队列存在消息丢失的微小可能所以我还加了 Scheduled 定时任务每 1 分钟扫一次待支付订单把超过 30 分钟未支付的订单捞出来统一取消。代码大致是这样Scheduled(fixedDelay 60000) public void autoCancelExpiredOrders() { LocalDateTime deadline LocalDateTime.now().minusMinutes(30); ListOrder orders orderMapper.selectList(new LambdaQueryWrapperOrder() .eq(Order::getStatus, OrderStatus.UNPAID) .lt(Order::getCreateTime, deadline)); for (Order order : orders) { cancelOrder(order.getId(), CancelReason.AUTO_CANCEL); } }cancelOrder方法里做的事有讲究先更新订单状态为已取消然后回滚库存。库存回滚也走乐观锁setSql(stock stock 1)把扣减和释放都保持原子性。有同学问我为什么用定时任务不用 Redis 过期 key 监听Redis 的过期事件并不可靠key 过期后事件不一定即时推送而且 Redis 6.0 之前并不保证消息不丢拿它做核心业务是给自己找麻烦。4.4 支付回调的幂等处理支付环节用的是支付宝沙箱前端跳转支付页后支付宝异步通知发到后端接口。这里最常见的坑是回调可能重复触发支付宝在没收到确认响应时会多次重发通知如果回调逻辑不幂等同一个订单金额就被多记一次。我的回调处理逻辑先查订单如果状态已经是“已支付”直接返回 success 给支付宝不做任何重复操作。只有当订单是待支付状态时才去更新订单、扣减库存、加销量保证同一订单只会真正处理一次。if (!OrderStatus.UNPAID.equals(order.getStatus())) { return success; } // 更新订单状态为已支付 // 更新SKU销量 // 记录支付流水 return success;这个幂等判断看起来简单但很重要。支付成功后回调里的资金操作出现重复执行后果不是功能 bug 而是资损。5. Vue 前端把购物流程从页面完整串起来5.1 工程初始化与目录规划前端我用的 Vue 3 Vite Pinia Vue Router Element Plus。初始化命令npm create vitelatest sportshop-web -- --template vue进入工程后按模块把目录规划成下面这样src ├── api/ # 按模块放请求方法 ├── assets/ # 静态资源 ├── components/ # 通用组件商品卡片、数量选择器 ├── router/ # 路由配置 ├── stores/ # Pinia 状态用户、购物车 ├── views/ # 页面首页、详情、购物车、结算、订单列表 └── utils/ # axios 封装、工具函数工程规划上有必要分清views和components的责任边界。views 是一个路由对应的页面外壳里面尽量只负责组合组件和获取数据不要写太多逻辑components 是可以在不同页面复用的模块。这样拆分的收益到后面功能越来越多时会越来越明显。5.2 路由规划和登录守卫项目分成用户端和后台管理两个区域用路由的嵌套和 meta 区分{ path: /product/:id, name: ProductDetail, component: () import(/views/product/ProductDetail.vue), meta: { requiresAuth: false } }, { path: /cart, name: Cart, component: () import(/views/cart/Cart.vue), meta: { requiresAuth: true } }, { path: /order/confirm, name: OrderConfirm, component: () import(/views/order/OrderConfirm.vue), meta: { requiresAuth: true } }路由守卫需要应用在这几个关键点上。用户没登录直接访问购物车、结算、订单页会被router.beforeEach拦下来。不过这里有个大坑Vue Router 的守卫里如果只判断逻辑上“有没有 token”当登录过期后重新登录刷新页面时 Pinia 里还没有用户信息路由守卫可能会因为读取userStore.isLogin时数据还没准备好而放行失败。我的做法是在main.js里先调用fetchUserInfo()初始化用户状态再app.mount(#app)。这样应用启动时把登录态先拉回来再走路由逻辑就不会出现刚刷新就跳登录页的误判。5.3 axios 封装每个接口自动带 token 和统一报错axios 封装是这个项目前端部分很值得参考的一层每个请求自动带上 token遇到 401 统一跳登录业务错误码统一弹出提示。const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000 }) // 请求拦截器注入 token service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization token } return config }) // 响应拦截器统一处理错误 service.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.msg) return Promise.reject(new Error(res.msg)) } return res.data }, error { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(error.response?.data?.msg || 网络异常) return Promise.reject(error) } )这里有一件事值得强调后端返回的数据结构必须统一本项目统一是{ code, msg, data }code 200 表示成功401 表示未登录500 表示业务异常。响应拦截器在这一层把所有错误提示全部处理完页面里只需要关心 data不需要每个接口调完再判断一堆 if。Vite 的环境变量也有讲究开发环境直接把接口代理到本地后端避免跨域生产环境VITE_API_BASE_URL指向后端域名。.env.development和.env.production两个文件各配一份是环境部署时的关键配置项。5.4 购物车页面的组件配合购物车页面我拆成了几个核心组件购物车列表组件、数量选择组件、结算栏组件。这里要强调的是购物车数量变动不能只改前端必须同步调后端接口更新 Redis 里的数量这样才能保证下次换设备登录购物车数据还是完整的。数量选择器组件有一个细节用户点击加号/减号时按钮要先禁用等后端接口返回成功后再更新本地展示数量。如果接口失败了数量要回滚成旧值避免出现“前端看着是 3 件后端库存里只扣了 2 件”这种不一致。很多项目不留意这个交互细节最后前端数据和后端数据相差甚远。结算页拿到购物车勾选的 SKU 后先展示订单信息用户点“提交订单”后端创建订单并返回订单号和支付金额然后前端带上这些参数跳转支付宝沙箱支付页。支付完成页面跳回商城订单列表等支付宝异步回调把订单状态更新为已支付前端刷新订单列表后就能看到状态变为待发货。6. 前后端联调和部署时真正卡住人的几个配置问题6.1 跨域问题先搞清楚报错来自哪一层前后端分离项目第一次联调十个里有八个会先遇到跨域错误。浏览器控制台报一堆Access-Control-Allow-Origin第一反应往往是后端加CrossOrigin。但要区分跨域报错分成两种情况。一种是真的跨域后端没配置 CORS 响应头浏览器把请求挡下来了另一种是后端配置了跨域但请求在进入 Controller 之前就被拦截器挡了前端看到 401 后浏览器顺手把它渲染成了跨域报错。我在部署时就是这么排查的先用 Postman 直接请求接口发现接口完全正常那问题锁定在浏览器跨域后端配置好 CORS 就解决。如果 Postman 返回 401 或 500那就不是跨域问题而是拦截器或业务逻辑的问题不要被浏览器报错带偏方向。后端 CORS 配置用WebMvcConfigurer统一设置registry.addCorsMappings(new CorsRegistry() { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); });但生产环境上线后我建议把allowedOriginPatterns从*改成实际的前端域名。因为带着allowCredentials(true)时所有来源都允许访问接口这在生产环境是安全风险。6.2 Spring Boot 版本跟 JDK 的对应关系热搜里有人问“springboot版本太高”“idea不能创建springboot项目不能使用jdk1.8”几乎是通病。我刚开工时在 IDEA 里 New Project默认拉的最新 Spring Boot 3.3创建完编译直接报java: invalid source release: 17。原因很明确Spring Boot 3.x 从基线要求上移除了 JDK 8编译最低需要 JDK 17。你的机器只装了 JDK 1.8自然创建不了项目。如果想舒服一点建议直接装 JDK 17 或 JDK 21然后 Spring Boot 就用 3.x 最新版。但我这个项目为了照顾更多还在用 JDK 1.8 的场景整体锁定 Spring Boot 2.7.14。一个小提醒是千万不要把 Spring Boot 3.5 的依赖硬配到 JDK 1.8 工程里强行降级使用编译期各种不兼容的报错会让你头大。用 IDEA 新建 Spring Boot 项目时如果默认给的依赖版本都很新可以直接在 pom.xml 里把 parent 版本改成 2.7.14然后刷新 Maven。6.3 文件上传路径和静态资源映射商城后台要传商品图片图片上传后的访问路径也容易出幺蛾子。后端把文件保存到了本地磁盘某个目录但前端的img标签访问不到报 404。原因是后端没有做静态资源映射。你保存文件的位置不在 Spring Boot 默认的 classpath 静态目录下所以需要把本地磁盘目录手动映射成 URL 路径Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String uploadPath file: System.getProperty(user.dir) /upload/; registry.addResourceHandler(/files/**) .addResourceLocations(uploadPath); }这样前端访问/files/xxx.jpg就能直接拿到磁盘上的图片了。我项目里图片 URL 存在数据库时存的是相对路径/files/xxx.jpg前端展示时拼上后端域名就够了。不建议在数据库里存绝对完整路径后期换服务器域名就全乱了。6.4 Docker Compose 一键部署最后把工程容器化部署我用的是 Docker Compose 编排把 MySQL、Redis、后端、前端一起拉起来。docker-compose.yml关键内容如下services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: sportshop ports: - 3306:3306 volumes: - ./mysql-data:/var/lib/mysql redis: image: redis:7 ports: - 6379:6379 backend: build: ./backend depends_on: - mysql - redis ports: - 8080:8080 frontend: build: ./frontend ports: - 80:80后端 Dockerfile 里有个关键点 Spring Boot 的 JAR 包运行时不光要看端口还要等 MySQL 和 Redis 就绪后再启动。因为depends_on只是控制启动顺序MySQL 容器启动了不代表数据库已经初始化完成后端一启动就连不上的情况很常见。我在启动脚本里加了一段等待逻辑循环尝试连接 MySQL 端口通了再执行java -jar。前端用 Nginx 托管打包后的静态文件同时把/api路径反向代理到后端服务location /api/ { proxy_pass http://backend:8080/; }这里注意proxy_pass的斜杠http://backend:8080/带不带结尾斜杠结果完全不同。带斜杠会把/api前缀去掉后端接口路径本来就有/api/user/login被去掉后变成/user/login就 404 了。我踩过这个坑最后确认不带斜杠反代让后端接口路径原样透传。7. 整个项目整理下来我最有体会的地方这个体育购物商城前后端全部跑通后我最大的感受是真正花时间的不是写 CRUD而是把边界条件和状态流转想清楚。商品 SKU 怎么拆、库存怎么扣、订单超时怎么办、支付回调怎么防重这些细节才是商城项目区别于普通管理系统的核心分水岭。如果照着这个思路自己从零写一遍我建议按这个顺序推进先做后端订单和库存两个核心模块再补商品和购物车最后搭前端页面。订单和库存是整个业务的命门从一开始就把模式定好后面的工作会极其顺手。另外一个小技巧真再遇到前端页面显示的数据跟后端对不上先查两边的时间格式和金额单位是不是一致我在这两个问题上来回折腾过不少次。接口文档或者代码注释里最好一开始就定好字段规格省得到联调阶段再来回猜测。
返回列表