ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+Vue3电商系统实战:从数据库设计到部署上线全解析

SpringBoot+Vue3电商系统实战:从数据库设计到部署上线全解析

写这套“SpringBoot + Vue3 + MyBatis + MySQL”前后端分离电商系统,是很多 Java 学习者迈入企业级项目的一道门槛。它既不像简单的 CRUD 那样浮于表面,也不像高并发大厂架构那样遥不可及,刚好卡在“能跑通完整业务”和“有真实工程味道”之间的位置。这篇文章我会按照一套实际可落地的电商应用系统为线索,把项目拆解、技术选型、数据库设计、鉴权流程、订单与库存处理、前后端交互乃至部署上线整个链路讲清楚。无论你是毕业设计要交差,还是准备拿一个像样的项目写进简历,亦或是中小团队想快速搭一版电商业务原型,这套东西都有直接的参考价值。

1. 项目定位与整体设计思路

1.1 电商系统到底包含哪些核心业务

很多人一提“电商系统”就想着把某宝某东的功能全塞进去,结果项目做到一半直接烂尾。真实的电商应用系统,功能边界应该是清晰的:管理端管货、管单、管数据,用户端看货、下单、付款。基于这个边界,我把功能模块拆成两条主线和一条支撑线。

  • 用户端(前台商城):注册登录、商品分类浏览、商品检索、商品详情、购物车管理、订单提交、在线支付、个人中心、订单查询、收货地址管理。
  • 管理端(后台管理):管理员登录、商品分类维护、商品上下架与库存管理、订单处理(发货、关闭)、轮播图/公告配置、用户管理、销售数据统计。
  • 公共支撑:统一认证鉴权、全局异常处理、统一响应封装、文件上传与静态资源映射。

这个划分就是把系统做成两个独立前端项目加一个聚合后端。管理端不走商城页面,用户端不接触管理接口,两边共用同一套用户体系,但角色权限不同。这样划分还有一个好处:分工清晰,哪怕你是自己一个人写,也不会写着写着把代码揉成一团。

1.2 为什么选前后端分离架构

这套系统之所以采用前后端分离,不是赶时髦,而是因为这个模式在真实业务里确实更舒服。前端通过 HTTP 接口消费数据,后端只负责业务逻辑与数据持久化,两者通过 JSON 进行交互。部署时各自独立,前端构建产物扔到 Nginx,后端打成 Jar 包跑在服务器上,互不阻塞。

前后端分离带来的直接收益有三个。第一,团队协作不必互相等,前端可以基于接口文档先 Mock 数据开发,后端专注业务接口;第二,接口可以被多个端复用,管理后台和用户端都能访问同一套商品服务;第三,项目结构清晰,每个模块的边界由接口定义,后续扩展小程序端只是“再加一个前端项目”的事。

但分离也有代价,最典型的就是跨域和鉴权。跨域在开发环境下通过后端配置 CORS 解决,生产环境用 Nginx 反向代理同域部署;鉴权通过 JWT 令牌机制完成,后端负责签发和校验,前端负责存储和携带。这两个问题我会在后面的章节专门展开。

1.3 这一套组合拳的优势在哪里

SpringBoot 负责把后端的各种配置自动化处理掉,内嵌 Tomcat,项目启动只跑一个 Main 方法;Vue3 负责前端组件化开发,配合组合式 API 和路由懒加载,页面再多也不怕首屏卡顿;MyBatis 负责持久层灵活 SQL,复杂查询不绕弯;MySQL 承担数据存储,稳定且成本低。这个组合说不上“多先进”,但胜在生态成熟、文档齐全、踩坑的人多,任何报错几乎一搜就有答案。

我见过不少项目一上来就上微服务、上 Elasticsearch、上消息队列,结果部署环境资源吃紧,代码复杂到连作者自己都理不清。这套电商系统不需要那些重型武器的原因是:单机 MySQL 扛住万级用户没有问题,图片存储用本地目录或者云存储都行,订单量上来了再考虑 Redis 缓存和 MQ 削峰也不迟。技术选型要匹配业务阶段,这才是务实的做法。

2. 核心技术栈与工程结构

2.1 后端主要依赖与版本选择

这套系统的核心依赖,我建议以稳定为首要原则,不用盲目追求最新版本。

依赖项版本建议说明
JDK8 或 11兼容性最好,大多数生产服务器仍以这两个版本为主
SpringBoot2.7.x稳定且资料多,避免直接用 3.x 带来的 javax 到 jakarta 迁移问题
MyBatis3.5.x配合 spring-boot-starter 使用
MyBatis-Plus3.5.x个人项目强烈建议引入,节省大量单表 CRUD 时间
MySQL5.7 或 8.0生产环境 5.7 稳定,8.0 功能更新,根据服务器内存选择
JWTjjwt 0.9.x轻量无状态认证方案
Lombok最新稳定版减少 Getter/Setter 样板代码

SpringBoot 2.7 配合 JDK 8 是我认为最稳的搭配。很多旧教程里的 Maven 仓库依赖、配置文件写法都能直接复用,遇到问题也好排查。数据库连接池用默认的 HikariCP 就行,不需要额外引入 Druid 除非你特别需要监控面板。

MyBatis-Plus 在这个项目里属于“用了就回不去”的工具。它提供的 BaseMapper 让单表 CRUD 无需写一行 SQL,分页插件、条件构造器、代码生成器又大幅提升了开发速度。但要注意一点:复杂多表关联查询仍然需要手写 XML,不能偷懒全部依赖 MP,否则迟早被多表 JOIN 教育。

2.2 前端依赖与构建工具

前端基于 Vue3 生态,核心依赖需要明确。构建工具选择 Vite,开发时冷启动速度极快,改动保存到浏览器刷新几乎感知不到延迟。Vue Router 负责路由,Pinia 取代 Vuex 成为状态管理方案,Axios 统一处理 HTTP 请求,Element Plus 提供后台管理界面组件,移动端适配则可以结合自己的需求引入 Vant。

这里要说一个实际体会:前端包体积膨胀很快,Element Plus 如果全量引入会让首屏加载变慢。建议按需引入组件,并用 Vite 的代码分包策略,让路由级组件懒加载。商城首页这种需要快速打开的场景,首屏资源越小越好。

管理端和用户端是独立的 Vue 项目还是集成在一个项目里?两种方案都可行。我的做法是直接建两个独立工程,shop-admin和shop-front,后端接口前缀也已经区分了/api/admin/**和/api/user/**。这样做防止前后端权限逻辑耦合,也给团队成员分配开发任务时更自然。

2.3 后端工程结构参考

不搞复杂的微服务拆分,就用经典的单体分层架构,但包结构要清晰,包名要体现职责。

com.example.mall ├── common # 通用类:统一响应、异常处理、工具类 │ ├── result │ ├── exception │ └── utils ├── config # 配置类:跨域、MyBatis-Plus、WebMvc、JWT拦截器 ├── controller # 控制层:请求入口 │ ├── admin │ └── user ├── service # 业务层:核心逻辑 │ ├── impl ├── mapper # 数据访问层:接口 ├── entity # 数据库实体 ├── dto # 数据传输对象:接收前端参数 ├── vo # 视图对象:返回给前端的数据 └── interceptor # 拦截器:JWT 认证

Controller 层只做参数接收、调用 Service、返回统一结果,所有业务判断下沉到 Service。Mapper 层只负责数据操作。DTO 和 VO 分开是很多新手容易忽略的点,直接把 Entity 返回给前端,会泄露密码、状态字段等内部信息,还容易导致接口数据结构和数据库表改造成联动灾难。

2.4 前端工程结构参考

用户端和管理端都按 Vue3 的标准目录组织,核心差异在页面和路由守卫。

src/ ├── api/ # 接口封装,按业务模块拆分文件 ├── assets/ ├── components/ # 公共组件 ├── router/ # 路由配置 + 前置守卫 ├── store/ # Pinia 状态存储 ├── views/ # 页面组件 │ ├── home/ │ ├── product/ │ ├── cart/ │ ├── order/ │ └── user/ ├── utils/ # 请求封装、token 管理 ├── App.vue └── main.js

api/目录按照后端接口模块一一对应,比如product.js里打包商品的列表、详情、搜索接口。utils/request.js封装 Axios 实例,统一设置 BaseURL、请求头携带 Token、统一处理响应码和超时提示。路由守卫检查本地是否有 Token,未登录跳转到登录页。这些约定一旦定好,新增页面只需要照着既有模式复制粘贴改一改,开发效率提升非常明显。

3. 数据库设计与核心表结构

3.1 核心表清单与关系梳理

电商系统数据库设计是整个项目的根基,表设计合理,后面写业务逻辑就是顺畅的;表设计稀烂,查一个订单可能要把五六个表 JOIN 起来,性能惨不忍睹。

我在这套系统里设计了这些核心表:

表名用途核心字段
user用户表id、username、password、nickname、avatar、phone、status
admin_user管理员表id、username、password、role
category商品分类表id、name、parent_id、sort、icon
product商品表id、category_id、name、sub_title、main_image、detail、price、stock、sales、status
cart_item购物车表id、user_id、product_id、quantity、checked
order订单表id、order_no、user_id、total_amount、status、address_snapshot、create_time、pay_time
order_item订单项表id、order_id、product_id、product_name、product_image、price、quantity、total_price
shipping_address收货地址表id、user_id、receiver_name、receiver_phone、province、city、detail_addr、is_default
payment_record支付流水表id、order_no、pay_no、pay_amount、pay_status、pay_time
banner轮播图表id、image_url、link_url、sort、status

订单表和订单项表必须分成两张表。订单表负责记录订单的整体信息,比如总金额、状态、支付时间;订单项表记录具体买了哪些商品、什么价格、多少个。为什么不能合并成一张表?假如一个订单买了三种商品,合并的话就要拆成三条记录,每一条都要重复存储用户 id、订单号、总金额、地址快照,数据冗余严重,统计订单数还会被重复计算。

3.2 为什么订单表要冗余一份商品快照

一个很容易被新手忽略的设计细节是地址快照和商品快照。地址快照在order表里用一个字段存下单时的完整地址字符串,商品快照在order_item表里冗余了product_name、product_image、price这些字段。为什么要这么做?

因为商品会改,价格会调,地址会换。用户在 6 月 1 日下单,订单金额 199 元,结果 6 月 2 日商家把价格改成了 149 元,总不能要求历史订单的价格跟着变吧?所以订单一旦生成,就把当时的商品名称、图片、价格、收货人信息全部冗余固化到订单相关表里。查询历史订单时,读到的是快照数据,而不是去联查当时可能已经下架的商品表。这是电商系统设计的基本共识。

3.3 关键索引与字段约束建议

表结构设计不仅仅是字段对,索引也是性能的生命线。订单表最常见的查询条件是按用户查看订单列表,所以(user_id, create_time)组合索引是必须的;订单号order_no要建立唯一索引,它既是业务标识,也是支付回调定位订单的关键。商品表按分类浏览是高频场景,category_id建普通索引;商品列表按销量排序是高频需求,(sales, status)组合索引保证排序查询不慢。

库存字段stock定义成 int 而不是 bigint,因为电商单品的库存量撑死几万件。价格字段用 decimal(10,2) 而不是 float/double,这一点极其重要。浮点数在 MySQL 中计算会出现精度丢失,比如 0.1 + 0.2 不等于 0.3,涉及金额的字段用浮点类型等于给财务埋雷。

逻辑删除的设计也值得说一下。商品、分类这类数据,我建议加deleted字段用逻辑删除,而不是物理删除。理由是:商品删除后,用户的历史订单还引用了商品名称和图片快照,虽然订单表里已经有冗余快照,但如果运营误删商品导致管理后台查询不到关联信息,排查起来非常麻烦。MyBatis-Plus 提供了@TableLogic注解,配置之后调用删除接口实际执行的是更新deleted字段,查询自动过滤,完全无感知。

4. 后端核心模块实现与重难点拆解

4.1 统一响应结构与全局异常处理

前后端交互的第一件事是定通信协议。如果每个接口返回结构不统一,前端 Axios 拦截器就没法统一处理。我这里直接给出一套标准结构:

{ "code": 200, "message": "操作成功", "data": { ... } }

code是业务状态码,200代表成功,401代表未登录或 Token 失效,500代表服务器内部错误,业务参数错误可以自定义如1001。message是人可读的提示信息,data是真正的业务数据。

后端写一个Result类,提供静态方法Result.ok(data)和Result.fail(code, message),所有 Controller 返回类型都是Result。然后写一个@RestControllerAdvice全局异常处理器,捕获业务异常、参数校验异常、兜底异常,统一封装为Result返回。这样做的好处是前端拿到非 200 码直接弹出错误提示,不用每个接口单独处理异常状态。

4.2 JWT 认证与登录鉴权流程

用户和管理员共用一套 JWT 机制。用户登录成功后,后端生成 Token 返回给前端,前端存在本地存储,后续每个请求在 Header 里携带Authorization: Bearer <token>。后端通过拦截器解析 Token,确认用户身份后放行。

为什么不用 Session?因为前后端分离后,前端可能部署在 A 域名,后端部署在 B 域名,Session 依赖 Cookie,跨域场景下 Cookie 的管理变得很麻烦。JWT 是自包含的,服务器不需要保存会话状态,天然适合接口鉴权。

JWT 令牌的典型载荷包含用户 id、用户名、角色、过期时间。登录接口签发 Token 时,我会把用户 id 放进 claims,后续获取当前登录用户时,从 Token 解析出 id,再去数据库查最新用户信息。注意不要在 Token 里塞密码、手机号等敏感信息,因为 JWT 的 payload 只是 Base64 编码,不是加密的,任何人都能解开看。

拦截器实现上,需要区分哪些接口需要登录、哪些接口放行。用 SpringBoot 拦截器注册时可以直接指定排除路径,比如用户端的/api/user/login/、商品列表/api/user/product/**放行,管理端的/api/admin/login/放行,其余的/api/admin/**必须校验管理员 Token。

代码层面关键点在拦截器里:

public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String authHeader = request.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { return reject(response, "未登录"); } String token = authHeader.substring(7); try { Claims claims = JwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { return reject(response, "登录状态已失效"); } }

4.3 商品管理端的实现要点

管理端商品管理是典型的 CRUD 页面,但有几个细节值得注意。

商品新增和编辑走同一个页面,后端用一个ProductSaveDTO接收参数,通过 id 是否为空判断是新增还是更新。商品主图上传走文件上传接口,文件保存到服务器本地目录,然后返回可访问的 URL。这里要配置虚拟路径映射,否则前端拿到/upload/xx.jpg根本访问不到文件。SpringBoot 里用 WebMvcConfigurer 的addResourceHandlers把磁盘目录映射为/upload/**访问路径。

商品列表页要注意分页和关键字搜索。搜索条件是商品名称模糊查询和分类筛选,分页用 MyBatis-Plus 的分页插件。这里我建议列表接口返回的数据封装成ProductAdminVO,只包含管理端需要的字段,比如 id、名称、价格、库存、销量、上下架状态。详情接口再返回完整信息,避免列表页一次性查询大字段(比如商品详情的富文本大段文字)拖垮前端渲染。

上下架操作是一个独立接口,只修改status字段。库存调整也是独立接口,方便运营在后台手动修正库存。把状态变更从编辑接口分离出来,是为了前端操作更简洁,后台业务日志也好记录。

4.4 商品分类与树形结构处理

商品分类是树形结构,比如“手机数码”下面有“手机通讯”“摄影摄像”,再往下可以继续细分。数据库表通过parent_id字段实现父子关系,顶层分类的parent_id为 0。

前端展示分类时,通常需要一次性拿到完整的分类树。后端的做法是从数据库查出全部有效分类,按parent_id组装成树形结构返回。代码不难,核心是递归或者两层循环配合 Map 组装,但要注意两个细节:一是排好序,同类目下兄弟节点的顺序根据sort字段决定;二是只组装状态正常的分类,下架的分类连叶子都不要出现,否则前端会渲染出空白节点。

4.5 用户端商品列表、详情与搜索

用户端商品列表接口要考虑三个参数:分类 id、关键字、分页。分类 id 存在时,需要查询该分类及其所有子分类下的商品。SQL 上可以使用子查询先把子分类 id 集合求出来:

SELECT * FROM product WHERE category_id IN ( SELECT id FROM category WHERE id = #{categoryId} OR parent_id = #{categoryId} ) AND status = 1 ORDER BY sales DESC

商品详情页除了基础信息,还需要展示轮播图。轮播图如果直接存在商品表的一个字段里,用逗号分隔多个图片地址,后端返回时再拆分处理,简单直接。商品详情接口还需要增加浏览量的自增逻辑,但这里要注意:刷量问题可以不做过多防御,给一个简单views = views + 1的 SQL 更新即可。

商品搜索建议直接用数据库的LIKE模糊查询,电商项目数据量在百万以内完全够用。更复杂的搜索需求可以留到项目后期引入搜索服务,但当前阶段不要过度设计。

4.6 购物车模块的实现方案

购物车有两种设计:一种是基于 Redis 存储,性能好但实现复杂;一种是存 MySQL 表,简单可靠。这套系统我采用 MySQL 表方案。购物车表cart_item记录用户、商品、数量和选中状态。

加购接口的逻辑是:先查用户是否已经对该商品加过购物车,如果存在则数量累加,否则新增一条记录。前端页面展示购物车时,后端接口要联查商品表,返回商品的实时价格、封面图、库存和上下架状态。这样设计的好处是购物车列表直接展示商品最新信息,不需要在购物车表里冗余商品价格,价格以商品表为准。

购物车还有一个批量删除和修改数量的接口。修改数量时后端要校验不能超过库存上限,并且数量最小为 1,最大可以设一个限制比如 99,防止客户端异常数据。

购物车选中状态用checked字段标识,下单时后端根据选中的购物车记录生成订单。注意这里有个坑:前端把选中的购物车 id 列表传给后端,后端要根据这些 id 查出记录,再校验归属权。不能只传商品 id 就下单,否则用户可以在前端伪造请求,用别人的购物车记录给自己下单,虽然实际场景下别人不买账,但归属校验是基本安全要求。

4.7 下单流程与订单状态机

下单是整个系统最核心的流程,涉及事务和状态流转,不能有一点马虎。我的下单流程是这样设计的:

  1. 用户传入选中的购物车记录 id 列表和收货地址 id。
  2. 后端校验购物车记录归属当前用户,查出所有商品信息。
  3. 遍历校验商品状态是否上架、库存是否足够。校验库存时要查实时库存,而不是前端传入的库存。
  4. 订单金额计算:单价乘以数量累加,这是服务端权威计算,不能信任前端的任何金额数据。
  5. 生成订单号和订单项,扣减库存,清空选中购物车记录,这一步要走数据库事务。
  6. 返回订单号,前端跳转到支付页面。

订单状态我定义了五个:待支付、已支付/待发货、已发货、已完成、已取消。状态流转不能让用户任意跳,后端要校验状态的前置条件。比如只有待支付订单能取消,只有已支付订单能发货,发货后用户才能确认收货。这些规则在 Service 层通过枚举或常量判断即可。

数据库事务用@Transactional标注下单方法。一个典型的事务问题:库存扣减失败时,前面插入的订单数据必须回滚。试想一下订单已经生成了,但库存没扣成功,用户付款后实际没货可发,这绝对属于严重 bug。

4.8 库存扣减与超卖问题

电商项目面试必问题:如何避免库存超卖。数据库层面最简单的方案是扣库存 SQL 带上库存条件:

UPDATE product SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity}

返回受影响行数为 0,说明库存不足,业务层抛异常回滚事务。这种方式利用数据库行锁保证了原子性,单机 MySQL 下完全够用。

为什么不先查库存再 update?因为并发场景下两个请求同时读到库存是 5 件,各扣 5 件,最后库存变成 0 而不是负数,看起来没问题,但如果库存是 3 件呢?两个请求都读到 3 件,都认为有货,各扣 3 件,最终库存就是 -3,这就是超卖。带条件的原子更新从原理上杜绝了这个问题。

4.9 支付模块与回调处理

支付模块真实的落地方式一般对接第三方支付平台的网关接口。前端拿到后端生成的支付参数后弹起支付,支付平台完成扣款后通过异步通知接口回调后端,后端根据支付结果更新订单状态。

如果只是学习和演示,可以做一个模拟支付的接口:用户点击确认支付,后端直接扣订单金额并更新订单状态为已支付。演示场景不需要真实资金流动,但支付回调的“处理顺序”要理解清楚:支付回调更新订单状态时必须校验支付的订单号是否存在、金额是否匹配、订单是否处于待支付状态,然后更新支付时间和订单状态,记录支付流水。这三个动作放在同一个事务里,防止回调重复到达导致重复更新。

订单支付成功后的处理逻辑中,还要注意一件事:支付回调更新状态是幂等操作。也就是说同一条支付通知来了两次甚至三次,后端的处理结果是一样的,不能出现支付成功后又把订单改成待支付这种活见鬼的状态。

5. 前端核心交互与接口对接

5.1 请求封装与拦截器设计

前端所有请求通过 Axios 发出,封装一个request.js是标准操作。实例创建时设置基础路径和超时时间,请求拦截器统一加 Token,响应拦截器统一处理业务码。

import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) 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 === 200) { return res.data } if (res.code === 401) { localStorage.removeItem('token') router.push('/login') ElMessage.error('登录状态已过期,请重新登录') } ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) }, error => { ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } )

这里的核心思想是:成功的业务请求直接返回data部分,业务失败统一弹提示并 reject。页面里调用接口就不需要再写一堆 if 判断错误分支。

5.2 路由与页面权限控制

路由配置要区分静态路由和动态路由。登录页、注册页、商品列表页、详情页等公开页面直接静态配置;个人中心、订单管理等需要登录的页面,用路由守卫控制。

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } })

管理端路由额外加一层角色判断,本地存储里存一个用户角色字段,不是管理员角色直接跳转到 403 页面。路由懒加载写法:component: () => import('@/views/admin/ProductManage.vue'),切到哪个页面才加载哪个页面的代码。

5.3 商品列表页的筛选交互与状态管理

商品列表页一般需要支持分类导航、关键字搜索、排序切换和分页加载。用户点击左边分类、顶部搜索框输入、切换“最新/销量/价格”排序时,都会重新请求列表接口。为了不在多个组件之间传参传得头大,用 Pinia 定义一个productQueryStore保存当前查询条件,列表组件和分类组件都从 store 里读取,改动时统一触发列表刷新。

商品列表数据结构定好了,前端页面不会乱:

{ "list": [ { "id": 1, "name": "无线蓝牙耳机", "mainImage": "http://...", "price": 199.00, "sales": 1200, "stock": 88 } ], "total": 45, "pageNum": 1, "pageSize": 10 }

价格格式化在前端做,用toFixed(2)保证显示两位小数。商品卡片组件只负责展示,不负责拉数据,这样列表页可以继续用同一套组件复用。

5.4 购物车与订单结算页的状态流转

购物车页面勾选商品,选中状态存储在购物车表里,也可以通过本地状态暂存,下单时把选中的购物车 id 传给后端。结算页会显示商品清单、总金额、收货地址。进入结算页时,前端最好先调用一个接口获取购物车提交预览数据,包含商品列表和服务端计算的总金额。拿到后端数据渲染,总金额永远以服务端返回为准。

提交订单之后跳转收银台页面。如果当前系统对接了真实支付,就展示支付二维码或拉起支付插件;模拟支付就直接调支付模拟接口,支付成功后跳转订单详情页。订单列表页要支持按状态筛选标签,全部、待支付、待收货、已完成,后端接口接受status参数自动过滤。

5.5 管理端数据统计页面的实现

管理端通常需要一块数据看板,展示今日订单数、今日销售额、商品总数、总用户数。用 SQL 查询即可:订单数和销售额用COUNT和SUM查当天订单;商品和用户数直接COUNT(*)。如果还需要近七天的销售趋势,写一条 group by 日期的查询,返回日期和金额列表,前端用图表库折线图展示。

这类统计接口不追求毫秒级性能,数据量小时查询速度可以忽略。但要注意 SQL 的日期过滤写法,别不小心把昨天数据也算进来。用DATE_FORMAT(create_time, '%Y-%m-%d') = CURDATE()直接按当天过滤清楚可靠。

6. 前后端联调中的接口规范与关键细节

6.1 接口文档先行,联调事半功倍

前后端分离项目最大的沟通成本在于接口约定,谁先动工谁吃亏。在实际开发流程里,我强烈建议先定接口文档,哪怕只是用 Markdown 或在线接口文档工具记录下来,约定好路径、请求参数、返回结构,再分头开发。

接口路径命名要有规律。管理端走/api/admin/product/list,用户端走/api/user/product/list,模块名放在中间,动词明确表示动作。分页参数统一叫pageNum和pageSize,返回格式统一携带total字段。命名一旦随意,前端对接的时候每个接口都要猜,效率极低。

6.2 参数校验不能只靠前端

前端做表单校验只是用户体验层面的保障,后端必须独立做参数校验。登录接口校验用户名密码非空,注册接口校验手机号格式,下单接口校验地址 id 存在的。SpringBoot 可以用@Validated加注解校验 DTO 字段,也可以直接手写判断抛业务异常。个人习惯手写判断多一些,因为能灵活控制错误提示文案,但项目里为了统一也可以用校验注解。

后端不校验参数会带来严重后果:用户传一个负数作为购买数量,后端不加判断直接扣库存,实际是给库存加回去,订单金额也可能变成负数。虽然正常用户不会这么干,但接口一旦暴露,谁都能构造任意请求。安全的第一道防线就是后端参数校验。

6.3 时间、金额、枚举值的传输约定

数据库 datetime 类型返回给前端,Jackson 默认序列化成本地时间格式2025-01-01 12:00:00字符串是可以直接显示的。建议在配置里统一全局时间格式,避免前端拿到时间戳还要自己做转换。

金额类型前端展示用字符串或数字均可,但要保证不丢失精度。如果后端返回 decimal 类型,Jackson 默认可能转成数字,前端运算时可能出现精度问题。项目里把金额字段在 VO 中直接定义为 String 类型返回,显示就走字符串,运算时后端完成,前端只负责展示,从根上规避精度问题。

用户角色的枚举值不要直接返回英文或数字就完事。比如订单状态0 1 2 3 4,前端拿到 0 也不知道是哪个状态。后端返回订单状态码时同时返回一个状态描述文字,前端直接展示描述,排序筛选用状态码。

7. 部署上线与环境搭建

7.1 后端打包与配置分离

后端打包执行mvn clean package -DskipTests,生成可执行 Jar。生产环境配置通过application-prod.yml读取环境变量里的数据库口令、文件上传路径,不能把数据库密码硬编码传到代码仓库里。可以用 SpringBoot 的${MYSQL_PASSWORD}占位符,部署时在系统环境变量里配置。

Jar 包放服务器上后,用nohup java -jar mall.jar --spring.profiles.active=prod > mall.log 2>&1 &启动。如果需要开机自启,写一个 systemd 服务文件,比直接扔rc.local规范得多。

7.2 前端构建与 Nginx 部署

前端两个项目分别执行npm run build,生成 dist 目录。Nginx 配置两个 server 块,分别代理用户端和管理端,同时将/api/路径反向代理到后端服务。

Nginx 部署的核心配置片段:

server { listen 80; server_name shop.example.com; location / { root /var/www/shop-front/dist; index index.html; 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; } }

try_files配置必须写,否则 Vue Router 使用 history 模式时,用户刷新/product/1这种页面会 404。此配置让刷新请求回退到 index.html,由前端路由接管。

管理端单独配置一个 server 块监听不同端口或不同域名,两者都指向同一个后端/api/,跨域问题在生产环境因为同源部署而彻底消失。图片上传后生成的 URL 需要是浏览器能直接访问的路径,如果图片存在后端服务器本地,需要给 Nginx 也配一个/upload/的静态资源代理。

7.3 MySQL 初始化与数据导入

数据库初始化用项目里的db/mall.sql文件,在服务器上执行source mall.sql完成建库建表。生产数据库账号要单独创建,不给 root 权限,账号只需要该库的增删改查权限即可。MySQL 5.7 默认是区分表名大小写的,Linux 下如果建表用的Product,查询写product会报表不存在,所以统一用小写表名,避免环境差异踩坑。

7.4 服务器部署清单速查

服务器环节我总结一份自查清单,每一条都是真实踩过的坑:

  • 后端端口确认未被占用,防火墙放行。
  • MySQL 允许远程连接时,注意不直接用 root,单独建账号并授权库级权限。
  • 图片上传目录要存在并且应用有写权限,用chmod授权不该默认给 777,0755 配合目录属主管理更安全。
  • 前端request.js的 baseURL 在构建时要用相对路径/api,不要写局域网 IP,部署环境变化时不用重新构建。
  • Vite 构建产物如果出现资源 404,检查base配置是否设置为'/'。
  • 修改前端代码后重新构建部署,浏览器缓存不更新时,Nginx 配置添加Cache-Control: no-store或者给静态资源加 hash 文件名。

8. 常见问题与排查技巧实录

8.1 接口 404 排查路径

后端接口前端访问 404,排查顺序是:先确认后端服务启动成功,再确认 Nginx 代理地址配置正确,然后查看后端控制台有没有拦截器把请求拦掉,最后用 Postman 直接请求后端接口测试。多数 404 的原因是前端请求路径和后端类名上的@RequestMapping拼写不一致,或者拦截器注册时把接口路径排除错了一批。优先级最高的定位手段是 Postman 直连后端接口,能通说明后端没问题,问题在前端地址或者代理。

8.2 JWT 过期后前端没有跳转登录

Axios 响应拦截器里判断了code === 401,但实际运行时发现过期后页面没有跳转。原因多半是后端返回的 HTTP 状态码不是 200,而是在拦截器里直接设置了 401 的 HTTP 状态码。Ajax 进入的是响应拦截器的 error 分支而不是成功分支,后端返回 JSON 但前端 error 分支里没有处理业务码。解决方案:统一策略让后端鉴权失败时返回 HTTP 200 但业务码为 401,前端在成功分支统一处理;或者前端在 error 分支里读取返回体判断 code。采用前一种方案最简单,后端返回结构保持一致性,前端只用一个拦截器逻辑。

8.3 事务不生效的常见原因

下单方法加了@Transactional,但库存扣减异常时订单数据没有回滚。排查点主要有三个:方法是否被同类内部调用,Spring 事务代理基于 AOP,同类内部this调用不会经过代理,事务失效;方法是否是 private;事务方法是否在 Controller 层直接加注解而没有经过 Service。项目中统一规则就是事务只加载 Service 实现类的 public 方法上,外部调用只走接口,排查成本立刻降低。

8.4 库存扣减为负数的场景复现

测试环境出现过即使写了stock >= #{quantity}的更新条件,库存还是变成负数的情况。复盘后发现问题不在 SQL,而在业务代码执行顺序不对:代码先执行了商品库存查询判断,又执行了库存更新,但两者之间没有事务包裹。两个并发请求都通过第一步查询判断库存足够,再执行更新时条件无法阻止库存扣成负数吗?其实可以阻止,但前提是 SQL 更新直接条件拦截。如果更新语句写成先查出当前库存再在 Java 里计算出新值再更新,就会发生并发覆盖。根本解决办法就是那个带条件的原子更新 SQL,别把计算逻辑拆到 Java 层。

8.5 前端打包后页面白屏

npm run build构建成功,Nginx 打开首页一片空白。控制台报 JavaScript 资源 404,原因基本可以锁定为打包后静态资源路径不对,Vite 构建的默认base是/,如果项目部署在子路径下就要配置成子路径。多数场景部署在根路径,把 Nginx 的 server 块location /指向 dist 目录即可解决。还有第二种可能,前端代码里某个直接引用的动态图片路径在打包后被解析成了 undefined,这种通常是代码问题不是配置问题,重点查图片引用路径以/开头还是相对路径。

8.6 常见问题速查表

问题现象可能原因处理建议
登录后请求接口 401Token 没过期,但拦截器解析失败检查 JWT 生成的密文与解析密钥是否一致
商品列表能打开,详情 404详情接口地址与网关代理规则冲突核对 Vue 路由路径和后端接口地址,排除/结尾差异
下单成功但库存没变化事务未提交或执行了缓存库存检查 Service 方法是否有@Transactional,库存更新是否作用于正确商品
图片上传成功但页面打不开静态资源映射未配置或 Nginx 未代理上传目录检查本地访问http://ip:8080/upload/xx.jpg是否返回文件
管理端接口对外开放拦截器放行路径配置过宽确认/api/admin/**全部走认证拦截,不能只依赖前端路由隐藏

8.7 一个排查数据库连接池异常的经验

上线初期遇到过数据库偶尔连接超时,后台日志出现Connection is not available, request timed out。图省事的办法是把连接池最大连接数调大,但根源是代码里有地方忘记释放连接。HikariCP 默认自动回收确实不会彻底让连接泄漏,但高频调用下最终撑满连接池。后来逐个检查 DAO 操作,发现某处 XML 里写的是select * from product where id = #{id},这个不算泄漏,但真正的问题出在有人把大文件上传写进了业务事务里,上传耗时十几秒占用了事务连接。解决办法是事务方法保持轻量,文件上传放到 Controller 层先完成再进入事务;调用连接池配置连接最大生命周期,定期回收陈旧连接。

9. 项目扩展方向与后续演进建议

这套系统跑通之后,往下的扩展路线可以踩很多真实的业务痛点。我个人体会是,与其把初始版本堆得无比复杂,不如先把闭环打通、把质量做稳,再根据真实使用反馈逐层扩展。

第一个建议方向是引入 Redis 承担缓存。商品详情页和热门商品列表是典型的读多写少场景,首次查询后缓存到 Redis,设置合理的过期时间,能够显著减轻数据库压力。购物车数据如果想做到跨设备同步,也可以迁移到 Redis 结构化存储中。此时要注意缓存一致性,更新商品时先更新数据库,再删除缓存,下次查询再回填,坚持后台线程或定时任务做补偿。

第二个方向是引入消息队列处理订单状态变更。当高并发下单场景出现时,订单创建、支付回调、短信通知、发货通知这些流程可以通过消息队列异步化。支付回调先写消息,订单服务异步消费修改状态,库存扣减也可以在 Redis 中先预扣再异步落库,这样单体应用变成“可演进”的架构。

第三个方向是引入定时任务处理超时订单。订单生成但用户没有支付,常规做法是定时扫描超过 30 分钟未支付的订单,自动取消并回补库存。Spring 的@Scheduled就可以实现,但要注意任务执行加锁,集群部署时多台机器同时跑定时任务会导致重复回补,真实做法是用 Redis 分布式锁保证同一时刻只有一台机器执行。

第四个方向是权限框架替换。管理端的角色权限目前靠拦截器和注解简单控制,如果需要更细粒度的菜单权限、数据权限,可以引入成熟的权限框架。这样管理员只能看到自己有权限的菜单,业务数据的范围也能通过数据权限配置约束。

10. 个人实操体会总结

从零到一把这套电商应用系统完整写下来,我最大的感受是:真正难的不是写代码,而是做决策。技术选型克制、模块边界清晰、接口规范统一、数据库设计严谨,这些看似“软”的功夫决定项目的下限。代码写得多快多花哨,只能决定上限。

有几个细节我建议所有做同类项目的朋友一定不要省。第一个是全局异常处理,没有它,前端收到的错误信息五花八门,联调效率低到让人想砸键盘。第二个是统一响应结构,前端拦截器只要写一次就能应对所有接口,后期加接口零成本。第三个是订单金额与库存操作的服务端权威计算,绝不信任前端传参。这三个点看着基础,但在实际业务中每次出问题都和它们脱不了干系。

如果还有余力,建议把系统的日志做得再完善一些。操作日志记录谁在什么时间修改了什么商品、调整了多少库存,业务表里加create_time和update_time字段是底线。排查线上问题没日志,就像黑灯瞎火走夜路,摔跟头都找不到原因。

最后说一句可能会被当成“保守”的话:参考别人的优秀开源项目没问题,但每个模块一定要自己动手敲一遍。只有自己踩过购物车归属校验的坑、调过跨域头的错、体会过事务回滚的救场时刻,这套技术栈才真正变成你自己的东西。拿一份源码改改名字交差,看起来省了时间,亏掉的是对系统全貌的把控能力。这套电商系统最大的价值不在源码本身,而在它逼你把前端、后端、数据库、部署整个链路串起来的完整过程。把这套链路吃透了,以后不管换成什么业务,骨架都是通的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进