接到一个线下家具经销商的数字化需求时,我第一反应并不是直接动手写代码,而是把"企业级"三个字反复看了几遍。对方想要的不只是一个能在网上展示商品的页面,而是一套能支撑门店、仓管、销售、运营多角色协同的在线商城系统。技术栈范围很明确:SpringBoot + Vue + MyBatis + MySQL,但"用什么框架"和"怎么把系统做成企业级"之间,隔着大量的设计决策和工程细节。这篇文章就来聊聊我实现这套家具商城完整源码时的全过程——从架构拆分、数据库建模、后端接口设计、前端组件编排,到上线前必做的性能优化和踩坑实录。无论你是打算拿它做课程设计、毕业设计,还是想真正落地一套可维护的业务系统,这篇文章都会给你一条值得参考的路线。
1. 项目背景:一个真实的企业级需求,到底和课程设计差在哪
很多同学看到"企业级在线家具商城"几个字,脑子里浮现的可能是某个大平台的简化版——商品列表、购物车、下单,能跑通就行。但真实的企业级系统和课设项目之间,差的不是功能数量,而是对业务边界的理解和对异常情况的处理能力。
1.1 家具行业的业务特点决定了系统设计的走向
家具不是标品。一张实木床可能有不同尺寸、不同颜色、不同材质,同一款沙发有三人位和贵妃位之分。这种"SPU + SKU"的商品结构,直接决定了数据库的商品表不能像普通电商那样只设计一张简单的产品表。我最终采用了商品主表 + 规格属性表的拆分方式,主表存通用信息(名称、主图、分类、品牌、上下架状态),规格表存具体的SKU(颜色、尺寸、材质、价格、库存、SKU编码)。这样既保证了商品展示的灵活性,又为后端的库存扣减和订单明细提供了准确的数据来源。
家具行业的第二个特点是客单价高、决策周期长。用户不太可能像买日用品一样直接下单,往往会反复比较、加入购物车后过几天再回来。所以系统里除了基本的下单流程,还必须有完整的购物车管理和用户地址管理。我在设计时给购物车表加了勾选状态字段,下单时只结算选中的商品,而不是一股脑全部结算——这是非常贴近真实使用场景的细节。
第三个特点是订单履约链路长。家具通常不是即时发货,涉及预约配送、安装服务。订单提交后要经过"待付款 -> 待发货 -> 待收货 -> 已完成"的状态流转,同时每个状态都要有操作人记录和时间戳。为此我在订单主表中增加了一个状态字段,配合订单操作日志表,可以完整追溯每一笔订单的流转历史。
1.2 多角色权限模型:普通用户和后台管理员的边界必须清晰
企业级系统的核心标志之一是权限控制。用户端和管理端根本不应该混在一起。我的做法是设计了两套独立的接口模块:
- 用户端接口(
/api前缀):负责商品浏览、搜索、购物车、下单、个人订单查询等操作。用户通过 JWT Token 鉴权,token 中只包含用户ID和角色标识(ROLE_USER)。 - 管理端接口(
/admin前缀):负责商品管理、分类管理、订单处理、用户管理等操作。管理员登录后获取的 token 角色是ROLE_ADMIN,后端通过拦截器直接拦截所有/admin/**请求并校验角色。
前后端也做了相应的路由切割。Vue 项目里,用户端页面走/路径下的页面组件(首页、商品详情、购物车、结算页、个人中心),管理端走/admin下的独立布局(后台面板、商品列表、订单审核)。这样划分从源头上杜绝了用户访问管理接口的可能性,比单纯在前端隐藏按钮要安全得多。
2. 技术选型:SpringBoot + Vue + MyBatis + MySQL 这套组合为什么能打
技术选型不是炫技,而是选最合适、团队最容易维护、生态最成熟的那套。这套组合到今天依然是 Java 全栈开发的主流配置,不是没有原因的。
2.1 后端:SpringBoot 的低配置策略大幅降低落地成本
SpringBoot 的价值不需要我多赘述。它对 Spring 生态做了自动配置,原来 SSM 时代需要手写的一大堆 XML 配置基本消失了。做这个项目时我用的 SpringBoot 2.7.x + JDK 1.8,这两个版本组合在稳定性和周边依赖兼容性上经过了充分验证。SpringBoot 3.x 虽然已经发布,但部分第三方组件适配还不完善,对于企业级交付项目来说,稳定优先永远是对的。
Spring 家族里我还用到了 Spring Validation 做参数校验,Spring AOP 做操作日志切面。AOP 那块尤其值得说:我在后台管理员的商品上下架、价格修改等敏感操作上统一加了切面,自动记录操作人、操作时间、操作类型和请求参数,落库到操作日志表。这个功能在课程设计里基本不会出现,但企业交付时运营方一定会问"这个商品被谁下架的"——有日志可查和没日志可查,完全是两个交付水准。
2.2 为什么是 MyBatis 而不是 JPA
JPA 在国内企业项目中的口碑一直两极分化。我不否认 JPA 在简单 CRUD 场景下的开发效率,但家具商城这种业务中,复杂的多表关联查询、动态条件拼接、模糊搜索、分页统计特别多。MyBatis 允许我直接手写 SQL,复杂查询的语义一目了然,优化起来也更直接。
比如商品列表页的筛选场景——分类ID可能为空、价格区间可能为空、关键词可能为空、排序方式可能不同。用 MyBatis 的动态 SQL<where>+<if>标签可以非常优雅地拼出不同组合的查询语句,这种灵活性是固定方法签名难以快速覆盖的。另外 MyBatis 自带的一级缓存和二级缓存,对于商品分类这种变更频率极低的数据,能直接带来性能收益。
2.3 前端:Vue 的渐进式特性让单页应用开发节奏很舒服
Vue 这套框架在前端开发中的体验,说它"对开发者友好"是公正的。模板语法直观,响应式数据绑定机制让 DOM 操作从开发脑回路里消退了。做商城这种交互密集的项目,Vue 的组件化开发天然适合页面复用——商品卡片、分页组件、弹窗确认框、表单验证,全部抽成公共组件后,后期的维护成本会低很多。
我用的是 Vue 2 + Element UI 的组合(如果你用 Vue 3,可以换成 Element Plus,逻辑大同小异)。Vuex 负责全局状态管理,比如购物车数量、用户登录状态这些需要多组件共享的数据。Vue Router 配合路由守卫实现页面访问控制——未登录用户访问个人中心时自动跳转到登录页,非管理员访问后台时直接阻断并跳转到首页提示"无权限"。这套交互逻辑在用户端的感知就是"该登录时登录,不该进的地方进不去",很顺滑。
3. 整体架构设计:从浏览器请求到数据库查询的完整链路
架构设计这件事,真正的约束不在"能用",而在"清晰"。我按经典的前后端分离 + 分层架构来组织这套系统,每一层各司其职,改动可以控制在一个范围内。
3.1 前后端分离的物理部署结构
前后端分离不是简单的"代码分开放",而是部署也要分开。前端 Vue 项目打包后生成静态资源文件(HTML、JS、CSS),部署到 Nginx 上;后端 SpringBoot 项目打包成可执行的 Jar 包,部署在服务器上通过java -jar启动。前后端之间通过 HTTP API 通信,前端请求统一走/api前缀(用户端)或/admin前缀(管理端),Nginx 配置反向代理把这些请求转发到后端服务的端口上。
这样做带来的直接好处是横向拓展容易——如果后端负载高了,可以多部署几个实例放在 Nginx 后面做负载均衡;前端静态资源也可以放在 CDN 上加速访问。虽然小型项目用不上这些能力,但架构的"可演进性"是企业级系统的基本素养。
3.2 后端分层:Controller、Service、Mapper 各司其职
后端我按照经典的三层架构组织包结构:
- Controller 层:负责接收请求、参数校验、调用 Service、返回统一的结果结构。Controller 里不写业务逻辑,事务也不开在这一层。
- Service 层:承担所有业务逻辑,比如下单时验证库存、计算总价、生成订单号、扣减库存、清空购物车——这些操作必须在同一个事务里完成,否则会出现"订单创建了但库存没扣"的数据不一致问题。
- Mapper 层:MyBatis 的接口层,定义数据库操作方法,SQL 写在对应的 XML 文件中。复杂的多表查询、统计报表查询都在这里实现。
我额外加了一个DTO(数据传输对象)层,接收前端参数时不直接用实体类,而是用 DTO 接收,再在 Service 里转成实体。原因是前端传入的参数结构和数据库表结构通常不完全对应——比如商品搜索时传的关键词、价格区间、排序方式,在表中根本没有对应列。直接用实体接收会非常别扭,DTO 把接口的入参语义定义清楚了。
3.3 前端目录结构:按业务模块而非技术类型划分
Vue 项目的目录划分方式直接影响团队协作效率。我看到过很多项目把 components 文件夹堆了几十个组件文件,找东西全靠滚动。我的习惯是按业务模块划分:
src ├── api // 接口请求封装(按模块拆:product.js/order.js/user.js) ├── assets // 静态资源(图片、全局样式) ├── components // 公共组件(分页、弹窗、图片上传、空状态) ├── router // 路由配置(含路由守卫) ├── store // Vuex 模块化(user.js/cart.js) ├── views // 页面组件(按模块分目录:home/product/cart/order/admin) │ ├── home │ ├── product │ ├── cart │ ├── order │ └── admin ├── utils // 工具函数(request.js/format.js) └── App.vue约定优于配置,后续任何人接手项目,看到目录结构就能大致猜到功能放在哪里。这种隐性约定,比写十页开发文档都有效。
4. 数据库设计:商品、订单、库存的表结构是怎么一步步定下来的
数据库是整套系统的地基。表结构设计得不好,后面写接口时会被各种别扭的查询折磨到怀疑人生。我花了两天时间梳理表结构,最终确定下来这些核心表:用户表、商品分类表、商品表、商品SKU表、购物车表、订单主表、订单明细表、收货地址表、操作日志表。
4.1 核心表设计与设计理由
| 表名 | 核心字段 | 设计说明 |
|---|---|---|
| user | id, username, password(BCrypt加密), phone, avatar, role, status | 用 username 做唯一索引,role 区分普通用户和管理员,status 控制账号是否被封禁 |
| category | id, parent_id, name, level, sort | 分类支持两级结构,parent_id 为 0 表示一级分类 |
| product | id, category_id, name, main_image, detail, status, sales, create_time | status 为 0 或 1,控制上下架;detail 存富文本详情 |
| product_sku | id, product_id, name, attr_values, price, stock, sku_code | price 使用 decimal(10,2),stock 使用 int,attr_values 存如"原木色/1800mm*2000mm"的规格文本 |
| cart | id, user_id, sku_id, quantity, checked | 唯一索引 (user_id, sku_id),防止同一商品重复加购物车 |
| orders | id, order_no, user_id, total_amount, status, receiver_name, receiver_phone, receiver_address, create_time, pay_time, deliver_time, finish_time | 订单快照设计:收货人信息冗余存储,防止用户修改地址后影响历史订单 |
| order_item | id, order_id, sku_id, product_name, sku_name, price, quantity, image | 商品名称、SKU 名称、价格全部冗余一份,订单生成后即使商品改价或删除,历史订单依然可追溯 |
| address | id, user_id, receiver_name, receiver_phone, province, city, district, detail, is_default | is_default 为 1 的地址是默认收货地址,下单时自动带出 |
4.2 订单表的"数据冗余"设计思路
很多做课设的同学设计订单表时会纠结一个问题:订单里到底要不要存商品名称和价格快照?我的答案是一定要存。
假设用户下单后,管理员把商品价格从 2999 改成了 3499,如果订单明细表里没有冗余快照,用户查看历史订单时看到的是"当前价格"而非"下单时价格",这会造成纠纷。同理,用户下单后删除了收货地址,订单主表里如果只存了地址ID,查询订单时地址为空了。
订单表和商品表之间不是简单的引用关系,而是业务发生时刻的快照记录。这是企业级系统和课设项目之间最本质的区别之一——课设只关注"当前数据长什么样",企业级系统必须回答"历史某一时刻数据长什么样"。
4.3 索引设计与分页查询性能保障
数据库的数据量上去之后,索引就是生命线。我在核心查询字段上建立了索引:
- product 表的 category_id、status、sales 分别加索引。商品列表按销量排序时,
ORDER BY sales DESC配合索引可以避免文件排序。 - orders 表的 user_id 加索引,支撑"个人订单列表"查询;order_no 加唯一索引,保障订单号唯一性的同时,也支持按订单号精确查询。
- order_item 表的 order_id 加索引,查询订单明细时避免全表扫描。
分页查询是另一个必须提前想好的点。商品列表页如果用 MySQL 的LIMIT offset, size做深度分页,比如跳到第 100 页时offset变成 990,MySQL 会先扫描前 990 条记录再丢弃,性能很差。我最终采用了两层优化:首页和热门商品接口用 Redis 缓存前几页的数据,后台管理列表直接用 MyBatis 分页插件 PageHelper,它在底层会自动改写 SQL 生成 count 查询和 limit 参数,开发时只需要在 Service 层调用一下PageHelper.startPage(pageNum, pageSize)就行。
5. 后端实现细节:从用户登录到订单提交,SpringBoot + MyBatis 的核心代码技巧
5.1 统一返回结果和全局异常处理
前后端分离模式下,接口的返回结构如果五花八门,前端 axios 封装会痛苦死。我设计了一个统一的返回体Result<T>,所有接口都返回这个结构:
{ "code": 200, "message": "操作成功", "data": {} }code 为 200 表示成功,401 表示未登录或 token 过期,403 表示无权限,500 表示服务器异常。前端 axios 响应拦截器统一处理 HTTP 状态码和业务状态码:拿到 200 且 code 为 200 时正常返回数据,code 为 401 时自动清除本地登录信息并跳转到登录页。
全局异常处理用@RestControllerAdvice+@ExceptionHandler实现。业务异常(比如库存不足、购物车为空)直接抛出自定义的BizException,异常处理器统一返回错误信息和错误码。代码里最忌讳满屏的 try-catch,把异常处理逻辑收敛到一处,业务代码才能保持干净。
登录鉴权用 JWT 方案。用户登录成功后,后端生成一个 token,里面存了用户ID和角色,有效期设为 24 小时。前端拿到 token 后存到 localStorage,每次请求在请求头里带上Authorization: Bearer <token>。后端写了一个拦截器专门解析 token,把用户信息放到ThreadLocal中,后续 Service 层可以通过工具类获取当前登录用户,不需要在接口参数里来回传用户ID。
5.2 商品列表的动态 SQL 查询
商品列表页是整个系统访问频率最高的接口之一,而且筛选条件组合非常多。我截取一段 MyBatis 的动态 SQL 看一下:
<select id="selectProductPage" resultType="com.example.entity.ProductVO"> SELECT p.id, p.name, p.main_image, p.sales, p.status, (SELECT MIN(s.price) FROM product_sku s WHERE s.product_id = p.id) AS min_price FROM product p <where> p.status = 1 <if test="categoryId != null"> AND p.category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND p.name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="minPrice != null"> AND EXISTS ( SELECT 1 FROM product_sku s WHERE s.product_id = p.id AND s.price >= #{minPrice} ) </if> </where> ORDER BY <choose> <when test="sort == 'sales'">p.sales DESC</when> <when test="sort == 'price_asc'">min_price ASC</when> <when test="sort == 'price_desc'">min_price DESC</when> <otherwise>p.create_time DESC</otherwise> </choose> </select>这里的思路是:价格字段不在 product 表,而在 sku 表,所以用子查询查出 SKU 的最低价格作为商品的展示价格。价格筛选用EXISTS子查询判断是否存在符合价格范围的 SKU。排序字段用<choose>分支来限定白名单,防止 SQL 注入——排序字段不能直接用${}拼接用户参数,只要没在白名单里就直接走默认时间排序。
5.3 订单提交的事务处理
订单提交是最典型的"多表操作必须开事务"场景。我贴一下核心代码逻辑:
@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(OrderCreateDTO dto) { // 1. 校验收货地址归属 Address address = addressMapper.selectById(dto.getAddressId()); if (address == null || !address.getUserId().equals(currentUserId())) { throw new BizException("收货地址不存在"); } // 2. 查询购物车选中的商品 List<CartItem> cartItems = cartMapper.selectCheckedItems(currentUserId()); if (cartItems.isEmpty()) { throw new BizException("请先选择要下单的商品"); } // 3. 锁定库存(后续详解) for (CartItem item : cartItems) { int rows = skuMapper.lockStock(item.getSkuId(), item.getQuantity()); if (rows == 0) { throw new BizException("商品库存不足:" + item.getSkuName()); } } // 4. 创建订单主表和明细表 // 5. 清空购物车中已结算的商品 }@Transactional注解保证这些操作要么全部成功,要么全部回滚。这里有个细节:步骤 3 的"锁定库存"不是简单的UPDATE stock = stock - #{quantity},而是用条件更新:
UPDATE product_sku SET stock = stock - #{quantity} WHERE id = #{skuId} AND stock >= #{quantity}影响行数为 0 表示库存不足,同时这个操作会锁住当前行,防止并发下超卖。这就是乐观锁思路在库存场景的变体——用条件语句保证原子性,比先查再改安全得多。
5.4 MyBatis 多表查询的 ResultMap 映射
查询订单详情时,需要同时查出订单明细列表,我用了嵌套的 ResultMap:
<resultMap id="OrderDetailMap" type="com.example.entity.OrderDetailVO"> <id property="id" column="id"/> <result property="orderNo" column="order_no"/> <result property="totalAmount" column="total_amount"/> <collection property="items" column="id" select="selectItemsByOrderId" ofType="com.example.entity.OrderItemVO"/> </resultMap><collection>标签会在查询订单主表后,自动根据订单ID再查一次明细表。这种"嵌套查询"写法简单,但如果订单量很大会有 N+1 查询问题(查一次订单做 N 次明细查询)。我在订单详情页直接用,因为单次请求的订单数量有限,不会造成性能瓶颈;如果要做订单批量列表查询,就会改用外连接一次查出详情的写法,平衡性能和代码复杂度。
6. 前端实现:Vue 项目中组件拆分、路由守卫与接口对接
6.1 路由设计:用户端和管理端的隔离
Vue Router 我采用了"两套布局 + 一套守卫"的设计。路由表的 meta 字段里标注了每个路由的访问角色:
{ path: '/admin', component: AdminLayout, meta: { role: 'admin' }, children: [ { path: 'products', component: AdminProductList, meta: { role: 'admin' } }, { path: 'orders', component: AdminOrderList, meta: { role: 'admin' } } ] }, { path: '/user/orders', component: UserOrderList, meta: { role: 'user' } }全局前置守卫统一检查:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}') if (to.meta.role === 'admin' && userInfo.role !== 'ADMIN') { next('/403') return } if (to.meta.role === 'user' && !token) { next('/login') return } next() })路由守卫单独抽到permission.js文件里,入口文件 main.js 引入它。这样业务代码切面清晰,后续做权限扩展也很方便。
6.2 axios 封装:请求拦截和响应拦截的完整逻辑
axios 封装是所有前端接口调用的统一出口。我在 utils/request.js 里做了三层处理:
第一层创建 axios 实例,设置baseURL: '/api',这样代码里请求/product/list时最终会发到/api/product/list,由 Nginx 反向代理转发到后端。这样做的好处是开发环境和生产环境只需要改 Nginx 配置,前端代码不用动。
第二层是请求拦截器,每次请求前从 localStorage 取 token,加到请求头:
service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config })第三层是响应拦截器,统一处理返回结构和错误:
service.interceptors.response.use( response => { const res = response.data if (res.code === 401) { localStorage.removeItem('token') router.push('/login') return Promise.reject(new Error('登录已过期')) } if (res.code !== 200) { Message.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { Message.error(error.message || '网络异常') return Promise.reject(error) } )到这里,业务页面里调接口就变成一行代码的事,不需要每次处理错误分支。
6.3 商品列表页:组件拆分的实践范本
商品列表页是整个前端最复杂的页面之一,我拆成了六个组件:
SearchBar负责搜索和筛选表单;CategoryTree负责左侧分类树;ProductCard负责单个商品卡片展示;ProductGrid负责商品网格布局与加载状态;Pagination负责分页;SortPanel负责排序切换。父子组件之间通过 props 和 emit 通信,商品列表的数据存在父组件的 data 里,子组件只负责事件分发,做到"展示组件无状态,状态管理在父级"。
筛选条件的联动是一个常见的坑:选择分类、输入关键词、切换排序,这三个条件任意变化都要重新请求商品列表。我在父组件里写了一个fetchList()方法,把筛选条件统一放到一个 query 对象里,任何子组件触发筛选变化时,父组件更新 query 并重新调用 fetchList。组件间不做数据深拷贝,直接替换整个 query 对象来触发 Vue 的响应式更新。
6.4 管理端的表格页怎么做才高效
后台管理的商品列表和订单列表,我用的是 Element UI 的el-table+el-pagination组合。表格每一行都有操作按钮(编辑、上下架、删除),删除操作统一弹MessageBox.confirm确认框,防止误操作。图片上传用的 Element UI 的el-upload组件,action指向后端的文件上传接口,on-success回调里把返回的图片 URL 赋值给表单字段。
后台页面最容易被忽略的是"操作反馈"。我的原则是:任何写操作都必须有 Loading 状态和结果提示。保存商品时按钮变 Loading 并禁用重复提交,提交成功后Message.success('保存成功'),失败时Message.error('保存失败:xxx')。这些细节决定了一套系统是"demo"还是"能用的系统"。
7. 开发三个月踩过的那些坑:MyBatis、Vue、MySQL 的实际事故复盘
7.1 MyBatis 的 if 判断字符串相等,差点让我怀疑人生
在商品分类查询中,我需要判断某个字段是否等于一个字符串值,起初这样写:
<if test="status == '1'"> AND status = 1 </if>结果怎么都不生效。后来才反应过来,status是 String 类型,在 OGNL 表达式里'1'会被解析成字符而不是字符串,需要用双引号包裹:
<if test='status == "1"'> AND status = 1 </if>这个坑太隐蔽了,诊断花了半小时。之后我给自己立了一个规矩:MyBatis 动态 SQL 里字符串比较,一律用双引号或者.toString(),避免踩雷。
7.2 金额计算精度问题:double 是个定时炸弹
购物车结算时计算总金额,一开始我用 double 类型算,结果出现 0.1 + 0.2 = 0.30000000000000004 这种经典问题。用户看到"待支付金额:3499.8000000000002 元"直接会退款走人。所有金额计算必须用BigDecimal,构造时用new BigDecimal(String)而不是new BigDecimal(double):
BigDecimal totalAmount = skuPrice.multiply(new BigDecimal(String.valueOf(quantity)))在数据库层面金额字段用decimal(10, 2),Java 对应 BigDecimal,前端展示用toFixed(2)格式化。三层都保证精度,才能不让精度问题有露头的机会。
7.3 跨域配置的坑:允许了来源却被浏览器拦截
开发时前后端分离跑在不同端口(前端 8080,后端 8081),必然遇到跨域。SpringBoot 里加了个跨域配置类:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowCredentials(true) .maxAge(3600); } }重点说一下allowedOriginPatterns和allowedOrigins的区别:当allowCredentials(true)时,allowedOrigins不能写*,否则浏览器会因为凭证请求而不允许通配符来源,直接用allowedOriginPatterns("*")可以正确处理。这个配置花了我一下午。
7.4 购物车重复提交:防抖背后的并发隐患
用户在下单页快速点击"提交订单"两次,如果代码不防护,会出现两笔一模一样甚至库存扣了两次的订单。我在前端按钮上加了:loading="submitting"防止重复点击,但这只挡住了正常人,挡不住网络波动导致的重复请求。真正的防线在后端——下单接口入口处加了一个"模拟幂等"机制:前端生成一个随机的幂等键放在请求头里,后端 Redis 里以幂等键为 key 存储处理状态,相同 key 的请求直接返回上次处理结果。这样即使前端重复提交或用户刷新后在浏览器里重新执行了上一次请求,后端也只会真正创建一笔订单。
7.5 图片上传大小限制:前端和后端都要设置
上传商品主图时,一次上传超过 1MB 就被 Nginx 拦截,返回 413 Request Entity Too Large。排查后发现是 Nginx 默认的client_max_body_size只有 1MB。解决办法是在 Nginx 配置里加上:
client_max_body_size 20M;同时 SpringBoot 的配置也要同步调整:
spring.servlet.multipart.max-file-size=20MB spring.servlet.multipart.max-request-size=20MB前端 el-upload 的before-upload里也校验文件大小,超过 5MB 直接提示"图片过大"。三层限制,保证不会有超大文件打到服务器。我刚开发时只在后端限了大小,前端没有任何提示,传一个大图被 Nginx 拦截后返回的是看不懂的 413 页面,体验极差。后来把前端校验补上,问题才算根治。
7.6 商品上下架与购物车状态的联动问题
这是业务逻辑层面容易遗漏的坑。用户把商品加入购物车后,管理员可能把该商品下架了,或者 SKU 的库存降到 0。用户去购物车结算时,如果系统没有处理这种情况,用户点"去结算"进入结算页,到了提交订单时才发现"库存不足"被弹回,体验极差。我的处理是:购物车列表接口里实时关联 SKU 的上下架状态和库存,下架或库存为零的商品在列表里置灰并显示"失效商品",结算时在校验库存之前先校验商品状态,并把失效商品自动从结算列表中排除。这个逻辑在第一次联调时没做,测试时发现了这个漏洞——用户结算时报"很抱歉,商品已下架"但不知道为什么。后来把状态标记做进列表页,用户还没点结算就能看到哪些商品失效了,体验就顺了。
8. 性能优化与生产部署:上线时我会特别注意的几件事
8.1 商品列表的缓存策略
商品列表是访问压力最大的接口,尤其是首页的推荐家具和热门单品。我把这些接口的数据做了 Redis 缓存,key 设计为product:list:hot,过期时间设为 10 分钟。后台工具执行"商品上架"或"编辑商品"操作时,主动删除相关缓存 key,这样下次请求就会重新从数据库加载最新数据。"缓存 + 主动失效"比"缓存 + 固定过期时间"更可控,既能保证数据不太旧,又不会在数据变更后继续展示旧内容。
8.2 数据库连接池和批量插入优化
数据库连接池我用的是 HikariCP(SpringBoot 2.x 默认集成)。生产环境配了maximum-pool-size: 20,minimum-idle: 5,连接超时设 30 秒。这些参数不是越大越好,过度申请数据库连接反而会给 MySQL 带来压力。
下单时如果有多个 SKU,订单明细是批量插入,不能一条一条 insert 循环调用(性能差且增加网络 IO)。MyBatis 可以用<foreach>标签实现批量插入:
<insert id="batchInsert"> INSERT INTO order_item (order_id, sku_id, product_name, sku_name, price, quantity, image) VALUES <foreach collection="list" item="item" separator=","> (#{item.orderId}, #{item.skuId}, #{item.productName}, #{item.skuName}, #{item.price}, #{item.quantity}, #{item.image}) </foreach> </insert>一次批量插入几百条明细也就是一条 SQL 的成本,性能差距一个数量级。
8.3 前端打包优化:路由懒加载和静态资源压缩
Vue 项目打包后常见的痛点是首屏加载慢。我用 vue-router 的路由懒加载把页面组件按需加载——只有访问到对应路由时才加载对应 JS 文件,而不是把所有页面打进一个巨大的 bundle:
const ProductDetail = () => import('@/views/product/ProductDetail.vue')打包命令用npm run build,出来的 dist 目录部署到 Nginx。Nginx 再开一下 gzip 压缩,把 JS 和 CSS 压缩传输,配合后端接口的缓存策略,首屏加载速度能提升不少。
8.4 生产部署的完整流程参考
我的部署方案是:一台云服务器(4核8G),Nginx 监听 80/443 端口,后端 Jar 包通过 systemd 守护进程运行。对 Java 系统我从来不直接java -jar启动扔在后台,就算用了nohup也是一样,万一进程崩了没人拉起,或者服务器重启以后服务不会自动恢复,系统就"失联"了。systemd 配置成服务,systemctl restart管理起来非常顺手:
[Unit] Description=furniture-mall-server After=network.target [Service] Type=simple User=root ExecStart=/usr/bin/java -Xms512m -Xmx1024m -jar /opt/app/furniture-mall.jar SuccessExitStatus=143 Restart=always RestartSec=5 LimitNOFILE=65535 [Install] WantedBy=multi-user.target数据库每天凌晨用 crontab 做一次全量备份,备份文件保留 7 天:
mysqldump -uroot -p*** furniture_mall > /backup/furniture_mall_$(date +%Y%m%d).sql find /backup -name "*.sql" -mtime +7 -delete这套流程跑下来,我从"能写代码"的水准提升到了"能交付系统"的水准。中间踩过数据库事务没生效的坑(原来是类内部方法调用导致@Transactional失效)、踩过 Element UI 表格数据更新后不刷新的坑(数组直接改索引要换成 Vue.set 或重新赋值)、踩过打包后路由 history 模式刷新 404 的坑(Nginx 需要配try_files回退到 index.html)。每个坑解决后,我对这套技术栈的理解就加深一层。
做这类系统,最核心的收获就是:代码能跑只是最后一步,前面那些设计决策——表结构怎么建、事务边界画在哪、前端组件怎么切、缓存怎么用——才是真正决定一个系统好坏的分水岭。这套 SpringBoot + Vue + MyBatis + MySQL 的架构组合非常适合一个人从零到一搭建可交付的商用系统,过程中被迫去考虑并发、事务、权限、缓存这些课设里不会出现的问题,成长速度比刷十套题目都快。如果你正在规划类似的商城系统,把上面的细节一个个啃下来,相信你也会有一段值得记录的开发经历。