去年做一套服装商城系统的时候,我把自己关在房间里整整两周多,从数据库设计到前端联调,再把整套源码整理成带文档的项目包。技术栈是标题里写的那套:SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0,前后端分离,管理端和用户端都有。
这篇文章不讲虚的,直接把我从需求拆解、表设计、后端落地、前端对接、部署上线到踩坑复盘的全过程写出来。给刚学完JavaWeb准备做实战项目的人看,也给想拿商城源码做二次开发的同学一个参考。里面的细节和教训,基本都是常规教程里不会写的。
1. 拿到需求之后,我先想清楚了三件事
1.1 这个商城到底要解决什么问题
很多人一上来就建表写代码,结果做到一半发现商品、订单、权限到处打补丁。我接到需求后先花了一天时间把业务边界梳理清楚。服装商城和普通电商最大的区别在于商品维度多——一件衣服有颜色、尺码、款式,甚至同一款衣服不同颜色价格还不一样。这就导致“商品”这个概念不能简单用一张表来承载。
从用户视角看,商城需要做这些事:浏览首页、按分类和关键字搜索商品、进入详情页选择规格、加入购物车、结算下单、查订单、个人中心。从运营方视角看,后台需要维护商品上下架、分类、库存、订单发货、用户管理和基础数据统计。两边的需求合到一起,系统的模块边界就清晰了:
- 前台用户端:首页、商品列表、商品详情、购物车、订单中心、个人中心
- 后台管理端:商品管理(SPU/SKU)、分类管理、品牌管理、订单管理、用户管理、轮播图管理
我倾向于前台和后台放到同一个前端工程里,通过路由和权限区分,而不是拆成两个独立项目。这样开发和部署都省事,对中小型项目完全够用。
1.2 技术栈选型的真实权衡
技术栈是 SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0,这套组合我用了很长时间,选它不是因为“新”,而是因为“稳”。
SpringBoot 选择 2.x 而不是 3.x,核心原因是生态兼容。MyBatis-Plus 3.5.x 对 Boot2 的支持非常成熟,很多生成器和插件在 Boot3 下还要额外适配。再加上大多数人的开发环境还停留在 JDK8,SpringBoot2.7 + JDK8 的组合几乎不会有版本层面的幺蛾子。如果你团队已经全员 JDK17 以上,那可以直接考虑 SpringBoot3。
Vue3 配 Element Plus 是当前很主流的前端组合。Vue3 的 Composition API 在处理商城这种“多筛选条件联动、购物车状态共享、列表页与详情页参数传递”的场景时,逻辑组织比 Vue2 的 Options API 清爽得多。构建工具用 Vite,冷启动和热更新速度体验明显比 Webpack 好,开发阶段效率提升很大。
MyBatis-Plus 解决的问题是样板代码。商城这种系统,单表 CRUD 非常多,手写 Mapper 很枯燥。MyBatis-Plus 的代码生成器、Wrapper 条件构造器、分页插件能省下大量时间。但要注意,它只是工具,复杂多表查询还是得老老实实写 XML,后面踩坑记录里我会专门说这个问题。
MySQL8.0 相比 5.7,窗口函数、CTE、utf8mb4 默认支持都很实用。不过 8.0 有几个配置问题容易坑人:驱动类名变成了com.mysql.cj.jdbc.Driver,连接串如果不加时区参数,插入时间会比北京东八区少 8 小时。
1.3 这套组合的模块边界划分
我把后端工程按业务模块分包,而不是按三层架构分包。按 controller/service/mapper 分包的问题是,后面商品、订单、用户代码混在一起,改一个需求到处翻。我使用的结构是:
com.example.mall ├── common // 统一返回、异常处理、常量、工具类 ├── config // 配置类 ├── security // JWT 拦截器、登录上下文 ├── modules │ ├── user // 用户模块 │ ├── product // 商品、分类、品牌 │ ├── cart // 购物车 │ ├── order // 订单、订单明细 │ ├── payment // 支付记录(模拟) │ └── admin // 后台管理相关接口 └── utils前端的模块划分类似,src/api按后端模块建文件,src/views按页面拆目录。前后端模块对应,后面联调时找接口非常快。
2. 服装商品的数据建模:SPU/SKU是绕不开的核心
2.1 为什么服装必须拆 SPU 和 SKU
这件这件事是整个商城设计里最关键的决策。
拿一件 T 恤举例,它有三个颜色(白色、黑色、藏青),四个尺码(S/M/L/XL),组合起来有 12 个可购买的商品形态。如果不拆分,直接在商品表里塞 12 条记录,问题马上就来了:每条记录都要重复标题、主图、详情描述,运营改一次详情要改 12 条数据;商品列表页同一款衣服出现 12 次,用户体验非常差;库存也没法针对颜色尺码单独管理。
所以必须拆成两层。SPU(Standard Product Unit)代表“同一款衣服”,负责标题、主图、详情等通用信息;SKU(Stock Keeping Unit)代表“具体某个颜色尺码的衣服”,负责价格、库存、规格描述。可以这样理解:SPU 是商品目录里的“条目”,SKU 是仓库货架上“具体可卖的一件货”。顾客下单时买的永远是 SKU,不可能是 SPU。
2.2 核心表结构与字段细节
数据库我建了十几张表,最核心的是这几张。商品表拆成 SPU 和 SKU 两张:
CREATE TABLE product_spu ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', category_id BIGINT NOT NULL COMMENT '分类ID', brand_id BIGINT DEFAULT NULL COMMENT '品牌ID', title VARCHAR(200) NOT NULL COMMENT '商品标题', subtitle VARCHAR(500) DEFAULT NULL COMMENT '副标题/卖点', main_image VARCHAR(500) NOT NULL COMMENT '主图', images TEXT COMMENT '轮播图,JSON数组字符串', detail LONGTEXT COMMENT '富文本详情', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1上架 0下架', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除', create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, KEY idx_category_id (category_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服装SPU表';CREATE TABLE product_sku ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', spu_id BIGINT NOT NULL COMMENT 'SPU ID', spec_value VARCHAR(100) NOT NULL COMMENT '规格组合,如 白色/XL', price DECIMAL(10,2) NOT NULL COMMENT '售价', original_price DECIMAL(10,2) DEFAULT NULL COMMENT '原价,用于划线价展示', stock INT NOT NULL DEFAULT 0 COMMENT '库存', sale_count INT NOT NULL DEFAULT 0 COMMENT '销量', image VARCHAR(500) DEFAULT NULL COMMENT '该规格的图片', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态', deleted TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, KEY idx_spu_id (spu_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服装SKU表';订单表和订单明细表是电商的核心链路。订单表存一次交易的整体信息,明细表存每个 SKU 的快照。为什么叫“快照”?因为商品名称、价格、规格在用户下单后可能会变,如果直接关联商品表,过几个月查订单时看到的信息就不是用户当时买的东西了。所以明细表里要冗余商品标题、SKU 规格、成交单价、数量这些信息。
2.3 几个容易踩坑的建表细节
金额字段必须用DECIMAL(10,2),不能用 float 或 double。浮点数在计算机里是近似存储,金额计算时会出现 0.1 + 0.2 = 0.30000000000000004 这种问题,订单金额算错是电商大忌。
时间字段优先用DATETIME。MySQL8.0 的timestamp有 2038 年问题,虽然对商城来说很远,但datetime存储范围更大,而且不受时区转换干扰。更重要的是 JDBC 连接串里要把时区写清楚:serverTimezone=Asia/Shanghai,否则插入的数据时间对不上。
逻辑删除字段deleted看起来简单,实则埋了一颗大雷。比如用户表给手机号加了唯一索引,某用户被逻辑删除后,记录还在表里,手机号占着位置,新用户用同一个手机号注册时直接报Duplicate entry。我之前就在这个坑里折腾了两个小时,后面踩坑记录里会细说。
3. 后端骨架怎么搭:分层、鉴权、统一返回与事务
3.1 工程分层与通用组件的设计思路
后端骨架的核心思路是:开箱即用的公共组件 + 清晰的模块边界。
统一返回结构我定义成Result<T>,包含 code、msg、data 三个字段。code 为 0 表示成功,非 0 表示业务失败,比如库存不足、参数错误、未登录。有人习惯 HTTP 状态码包一层,我开始的方案更简单直接:HTTP 状态码只表示请求是否到达了后端,业务是否成功看 code。这样前后端约定的成本很低。
统一异常处理使用@RestControllerAdvice。业务异常统一抛BizException,参数校验异常单独处理,兜底异常记录日志后返回“系统繁忙”之类的通用提示。注意一点:异常信息不要直接返回到前端,尤其是数据库报错,那是安全漏洞。
分页方案用 MyBatis-Plus 的分页插件,配置一个PaginationInnerInterceptor就可以。分页对象用Page<T>,查询条件用 Wrapper 构造,简单列表页基本不用写 SQL。但涉及多表关联的分页,后面有性能坑,我会单独讲。
3.2 基于 JWT 的登录鉴权:拦截器还是 Spring Security
商城权限模型很简单,普通用户和管理员两种角色。我使用了一套轻量方案:JWT + 拦截器,没有引入 Spring Security。
登录流程是这样的:用户提交账号密码,后端校验通过后生成 JWT,JWT 里包含用户 ID 和角色,签名后返回给前端。前端每次请求带上Authorization: Bearer <token>,后端拦截器解析 token,把用户信息放进ThreadLocal的上下文对象里,接口层直接取用。
为什么不用 Spring Security?这个项目的权限模型只有“登录”和“管理员”两层,Spring Security 的过滤器链、认证管理器配置成本明显偏高,对团队不熟悉的人来说反而增加理解负担。如果以后要扩展到细粒度权限(比如运营只能管理商品不能看订单),再迁到 Spring Security 也不迟,JWT 本身的签发校验逻辑是可以复用的。
轻量方案的伪代码长这样:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization"); // 放行不需要登录的路径,比如 /api/user/login、/api/product/list if (isWhitelist(request.getRequestURI())) { return true; } // 解析token,失败则抛出未登录异常 Long userId = JwtUtil.parseToken(token); UserContext.set(userId, role); return true; } }这里有个细节很多人忽略:JWT 的 secret 绝对不能硬编码在代码里,要放到配置文件并通过环境变量注入。过期时间也要考虑清楚,商城用户通常打开一次要用很长时间,我设置的登录态是 24 小时,剩余时间小于 30 分钟时前端走“刷新 token”逻辑,避免用户用着用着突然被踢下线。
3.3 下单扣库存:事务与并发控制怎么同时保证
下单是商城最核心的写链路,也是并发问题最集中的地方。
先看正常流程:用户从前端提交订单,携带 SKU 列表、数量、收货地址。后端要做的事包括:校验地址、校验商品状态和库存、计算总金额、生成订单号、插入订单主表和明细表、删除购物车对应记录。
这个链路必须在同一个事务里完成,否则会出现“订单生成了但购物车没删”或“明细没插入”的数据不一致问题。我用@Transactional标在 service 方法上就解决了。要注意事务边界,下单逻辑里不能掺入外部接口调用,比如调支付回调、发短信,这些应该放到事务提交之后异步处理。
库存扣减是另一个关键点。第一次做的时候我用“先查库存,够再减”的逻辑,结果压测时发现超卖——两个请求同时读到库存是 1,同时通过校验,同时执行扣减,最后库存变成 -1。而且这个结果两个用户都以为自己购买成功了。
正确做法是用条件更新,把库存判断和扣减合成一条 SQL:
UPDATE product_sku SET stock = stock - #{num}, sale_count = sale_count + #{num} WHERE id = #{skuId} AND status = 1 AND stock >= #{num}用stock >= #{num}作为条件,数据库天然保证了“库存不足时不更新”。如果UPDATE返回的影响行数是 1,说明扣减成功;返回 0,说明库存不足,直接抛业务异常回滚事务。这套方案对服装商城这种量级完全够用,不需要引入 Redis 分布式锁。
4. Vue3前端不是简单套壳:状态、路由、接口层一个都不能少
4.1 Vite工程结构与接口层封装
前端工程用 Vite 初始化,目录结构大致是这样:
src ├── api // 接口定义,按模块拆文件 ├── assets // 静态资源 ├── components // 通用组件 ├── layouts // 布局组件(用户端、管理端) ├── router // 路由配置 ├── stores // Pinia 状态 ├── utils // 工具 └── views // 页面接口层封装是前后端协作最关键的环节。我习惯在utils/request.js里统一创建一个 axios 实例,配置 baseURL 和超时时间,然后在请求拦截器里注入 token,在响应拦截器里统一处理业务错误。
const request = axios.create({ baseURL: '/api', timeout: 15000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code === 0) { return res } if (res.code === 401) { // token 失效,清登录态并跳登录页 localStorage.removeItem('token') router.push('/login') } return Promise.reject(new Error(res.msg || '请求失败')) }, error => { return Promise.reject(error) } )每个页面不要直接到处写 axios,而是在src/api下按后端模块建文件,比如product.js、order.js,导出一个函数对应一个接口。这样前端页面代码干净,后端接口一览,后面接口改动时只要替换 api 文件里的函数即可。
4.2 登录态维持与路由守卫
前端登录态的维持逻辑很容易出问题。token 我存在 localStorage 里,因为后端返回的 token 本身就是给浏览器持有的凭证,配合无状态 JWT 比较自然。但要注意 localStorage 有被 XSS 脚本读取的风险,工程里要做好输入输出转义,不要用 v-html 渲染不可信数据。
路由守卫这块,我在router/index.js里配置全局前置守卫:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) return } if (to.meta.role === 'admin') { const role = localStorage.getItem('role') if (role !== 'admin') { next({ path: '/403' }) return } } next() })这里要留意一个联动问题:用户登录后刷新页面,localStorage里 token 还在,但用户信息可能没有持久化。我建议刷新后从后端拉一次用户信息,而不是只依赖本地数据。不然会出现“token 有效但前端认为没登录”的错觉,这个在踩坑部分会展开。
4.3 商品列表与购物车的状态管理
商品列表页的数据流是商城前端的典型代表。页面有分类筛选、关键字搜索、价格区间、排序方式,这些条件都要反映到查询参数上。我使用一个reactive对象来承载筛选条件,每次条件变化时调用后端列表接口。
购物车我用了 Pinia 管理,结构大致是这样:
export const useCartStore = defineStore('cart', { state: () => ({ items: [] // 每个元素:skuId、数量、商品信息快照 }), getters: { totalPrice: (state) => { return state.items.reduce((sum, item) => sum + item.price * item.quantity, 0) } }, actions: { addToCart(sku) { /* 加入或累加数量 */ }, removeFromCart(skuId) { /* 删除 */ }, updateQuantity(skuId, num) { /* 改数量 */ } } })为什么要用状态管理而不是每次操作都请求后端?因为购物车是用户连续操作的高频场景,加减数量、勾选、查看总价,如果每次都走接口,延迟和接口压力都很大。我的策略是:前端先维护购物车状态,结算下单时才把最终数据提交到后端。提交成功后,再和购物车接口做一次同步,保证两台设备之间数据一致。
5. 部署上线前,我重点检查的六个细节
5.1 图片上传方案与防盗链
商城大部分图片来自运营上传。开发阶段,图片上传接口把文件写到本地磁盘目录,然后通过一个静态资源映射暴露成 URL。比如:
registry.addResourceHandler("/uploads/**") .addResourceLocations("file:" + uploadPath);生产环境建议用对象存储,核心原因一是服务器重启后本地文件不会丢,二是对象存储自带 CDN 加速,图片加载快很多,三是上传权限和访问权限都容易控制。不管用哪种方案,都要处理图片上传的类型和大小校验。我在上传接口里限制只允许 jpg/png/webp,单张最大 5MB,防止有人直接传可执行文件上来。
5.2 订单超时未支付如何处理
商城经常出现用户下了单但不支付,库存被白白占用。我的处理方案是定时任务:每 5 分钟扫描一次超时订单,超过 30 分钟未支付的订单自动关闭,同时回补库存。
@Scheduled(cron = "0 */5 * * * ?") public void closeExpiredOrders() { List<Order> expiredOrders = orderMapper.selectExpiredUnpaid(30); for (Order order : expiredOrders) { // 关单 int updated = orderMapper.updateStatusIfNotPay(order.getId()); if (updated > 0) { // 回补库存 orderItemMapper.releaseStockByOrderId(order.getId()); } } }这里有个细节很关键:关单操作必须使用条件更新,update ... where id=? and status=0。为什么?因为如果定时任务恰好被部署了两份,两个任务同时执行,第一个把状态改成 4 后,第二个就无法更新,自然也不会重复回补库存。逻辑上就避免了库存被加两次。
5.3 MySQL 8.0 的时区与驱动配置
MySQL8.0 升级后,驱动类名变了,连接串也需要更完整的参数,否则很容易出现时间差 8 小时或者 SSL 告警。我最终的 JDBC 配置:
spring: datasource: url: jdbc:mysql://localhost:3306/mall?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true driver-class-name: com.mysql.cj.jdbc.DriverallowPublicKeyRetrieval=true这个是 MySQL8 新加的参数,不加的话用非 root 用户连接可能会有认证问题。useSSL=false本地开发关掉,生产环境如果有内部网络传输加密需求再打开。
5.4 接口文档与前后端联调
联调时最怕接口参数说不清。我集成的是 knife4j,一个 Swagger 增强工具,Controller 上加注解就能生成在线文档。配合@ApiModelProperty给实体字段写注释,前端同学可以直接在页面上查看参数示例,甚至发起调试请求。
这里有三个联调时的注意点:
- 接口路径规划要一眼分清前后台。我统一把后台管理接口放到
/api/admin/**,用户端接口放到/api/user/**,公共接口放到/api/common/**。 - 统一的时间格式。前端传时间参数时,我后端用
@DateTimeFormat接收字符串,返回时统一转成yyyy-MM-dd HH:mm:ss。前后端时间字段格式不一致是联调最常见的扯皮点。 - 所有新建接口开发完后立即把文档更新出来,代码写完忘更新文档等于白搭。
5.5 日志与异常追溯
上线前我把日志单独调了调。控制台输出在开发期重要,但生产上要有独立的文件配置。我使用 logback 配置了两个 appender,一个按天滚动写全部日志,一个单独写错误日志。每条日志里带上 traceId(一个请求唯一的 ID),前端报错时把这个 ID 发过来,后端就能精确找到对应的那串日志,不用靠时间猜。
5.6 Nginx 反向代理与跨域
开发环境用 Vite 的 proxy 转发,生产环境用 Nginx。前后端分离项目,Nginx 主要做两件事:托管前端静态文件,反向代理后端接口。
server { listen 80; server_name mall.example.com; root /var/www/mall/dist; index index.html; # 前端 history 路由支持 location / { try_files $uri $uri/ /index.html; } # 后端接口反代 location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }如果后端接口负着网,记得加超时配置proxy_read_timeout,避免大列表请求超时。前端路由如果使用 history 模式,务必配置 try_files 回退到 index.html,否则用户刷新详情页会直接 404。
6. 踩坑记录:印象最深的五个问题
6.1 逻辑删除与唯一索引冲突
这个问题前面建表时预告过。我给用户表手机号加了唯一索引,用户注销用的是逻辑删除,结果新用户注册同一个手机号时报错Duplicate entry '138xxxx' for key 'uk_phone'。
排查过程:先看注册接口,报错确实来自数据库唯一索引。再查数据,发现旧用户记录还在表里,只是deleted=1。逻辑删除本质上只是标记,记录仍然物理存在,唯一索引当然不允许第二个人用这个手机号。
解决方案有两种。第一种,把唯一索引改成(phone, deleted)联合索引,这是比较常用的做法,因为同一个人理论上也可能再次注册,物理删没法解决,联合索引允许同一手机号以不同 deleted 值存在多条。第二种,逻辑删除值不固定为 1,而是改成主键 ID 的相反数,保证每条删除记录的唯一索引值都不同。我更常用第一种,理解成本低。
6.2 MyBatis-Plus 分页 count 查询性能崩
商品列表页用 MyBatis-Plus 分页插件,一开始很爽,Page<ProductVO>传入 Mapper 自动分页。但商品列表需要关联分类名、品牌名,我在selectPage里写了多表 join,结果 MyBatis-Plus 自动生成的 count 语句也带着同样的 join,两个大表 join 再 count,数据量上万后响应直接到了一秒多。
解决思路:手写 SQL,count 语句和分页主语句分开。主语句用 join 拿展示字段,count 语句只统计主表行数,或者干脆用一个更轻的子查询写出 count 版本。这种“自动分页好用但别在复杂查询上依赖它”的教训,后来我只要看到列表 SQL 涉及两个表以上,就直接手写分页,不纠结。
6.3 JWT 过期后前端仍显示登录
商城运营后台的使用场景是一整天开着浏览器,token 过期后,用户还在页面停留,点任意操作,后端接口返回 401。如果前端没有统一拦截 401,就会出现“页面好像正常,但所有操作都没反应”的诡异状态。
我最初只在response拦截器里处理了code!==0的错误,没有专门处理 401。后来加了一段逻辑:遇到 401 时,清除本地 token 和用户信息,然后跳转登录页,并在登录页提示“登录已过期,请重新登录”。这里还有一个细节:用户正在编辑某个表单,跳登录页会把输入内容搞丢,所以跳转前可以用 sessionStorage 暂存当前路由和页面状态,登录成功后回跳。
6.4 Vue3 响应式丢失引发的列表不更新
这个坑可以说所有 Vue3 新手都会踩。我用reactive定义了一个列表list,页面加载后调接口,把返回的数据直接赋值给list:
const state = reactive({ list: [] }) async function fetchList() { const res = await getProductList(params) state.list = res.data.list // 页面不更新! }页面始终是空列表,因为reactive的响应式是基于属性代理的,直接整体替换一个属性,虽然赋值成功了,但某些情况下(尤其是深层结构)视图不会跟着变。
解决办法有两个:一是用ref定义列表,赋值时通过.value操作,ref 的赋值天然触发更新;二是用state.list.splice(0, state.list.length, ...res.data.list),原地修改数组内容。我后来统一使用 ref 管理列表和表格数据,不再用 reactive 包一层,省心很多。
6.5 库存并发扣减的超卖问题
这就是 3.3 节里说的那个问题,这里再复盘一下当时的排查链路。
现象:上线后第一次小规模秒杀测试,100 件库存卖出了 130 多单。第一时间怀疑是前端重复提交,加了节流按钮后发现没用。接着怀疑业务事务问题,但下单事务是完整的,回滚逻辑也没问题。最后把目光锁定到扣库存 SQL 上:
-- 错误写法 UPDATE product_sku SET stock = stock - 1 WHERE id = #{skuId}多个请求同时执行这条 SQL 时,数据库虽然会串行化更新同一行,但这条语句不判断库存是否够,逻辑上把“判断”和“扣减”拆开了。两个请求先后读到 stock=1,第一个执行变成 0,第二个执行变成 -1。
修法是加条件WHERE stock >= 1,让数据库在更新时做库存充足性判断。这个坑也让我养成了一个习惯:任何涉及库存扣减、余额加减的 UPDATE 语句,都要写成“条件更新 + 影响行数校验”,不要先查询再更新。
整套系统做下来,最大的体会是:一个商城项目的表面功能看起来不难,但每条核心链路——下单、登录、库存、订单状态流转,背后都有成体系的细节决策。如果拿到这套源码想快速上手,我建议先从“下单一整条链路”去读代码,从商品列表到购物车再到订单生成,再回头去看表设计,理解速度会快很多。
最后分享一个自己联调时的小习惯:后端开发环境把 MyBatis 的 SQL 日志打开,前端打开 Network 面板,两侧对照着看。绝大部分接口问题其实半小时内都能定位,真正难缠的往往是数据一致性、响应式和缓存这类“看不见”的问题。