☰
SpringBoot+Vue+MySQL动漫商城管理系统设计与部署实战
2026/10/20 12:22:35 网站建设 项目流程

先说结论:一套能跑通完整交易闭环的商城管理系统,拿到手之后最快能怎么改造成自己的项目?我最近的答案是:两个晚上跑通,一周改完核心业务。这套基于 SpringBoot、Vue 和 MySQL 的动漫商城管理系统,是我目前维护的项目里最实用的一版——前台是 Vue 单页应用,用户能逛商品、加购物车、下单、查订单;后台用 Layui 做了一套轻量管理界面,管理员可以管商品、管分类、管订单、管用户和轮播图。无论你是要做课程设计、毕业设计,还是想给线下的动漫周边店做个线上商城,这套源码都能直接运行,而且结构足够清晰,适合在此基础上二次开发。这篇文章我会把设计和实现时的核心决策、数据库细节、接口设计,以及第一次运行会遇到的各种坑,一次性讲清楚。

1. 选型复盘:为什么前台用 Vue,后台却选了 Layui

1.1 这个系统到底要解决什么问题

动漫周边商城说白了就是卖手办、徽章、海报、CD、服装这类商品。和普通电商比,它的商品规格没那么复杂,但品类标签、上新频率、限定款这些概念特别重要。所以系统的核心诉求是:前台给用户一个浏览和下单的入口,后台给运营人员一套能高效管理商品、处理订单的工具。

我梳理下来的功能清单大致是:

  • 用户端:轮播图、商品分类浏览、商品搜索、商品详情、购物车、下单结算、收货地址管理、订单列表和状态查看。
  • 管理端:仪表盘统计、分类管理、商品上下架、库存修改、订单发货、用户状态管理、轮播图维护。

这个功能体量决定了它不需要特别复杂的分布式架构,一个单体 SpringBoot 后端加一个 MySQL 数据库就能扛住绝大部分场景。真正的难点只在于两端页面的交互组织、订单状态的一致性,以及拿到代码后能不能顺利跑起来。

1.2 技术栈分工与选型理由

主技术栈是 SpringBoot 2.x + Vue 2.x + MySQL 5.7/8.0,管理后台用了 Layui。这个组合看起来有点"混搭",但每个选择都有明确理由:

  • SpringBoot:自带嵌入式容器,一个 main 方法就能启动服务;配合 MyBatis-Plus,连 mapper 里的基础 CRUD 都不用手写,适合快速把业务接口堆出来。
  • MySQL:商城业务天然是强事务场景,下单要同时扣库存、生成订单、清购物车,MySQL 的 ACID 特性在这里比文档型数据库可靠得多。
  • Vue:用户端需要流畅的页面切换和交互,购物车加减、结算金额联动这些都是典型的响应式场景,Vue 写起来最顺手。
  • Layui:管理后台的使用者是运营而非终端消费者,他们需要的是"打开页面就能看到表格、点按钮就弹窗编辑"这种极低学习成本的操作方式。Layui 的 table 组件和 layui-form 弹窗本身就是为这种后台 CRUD 场景设计的,不需要引入整套 SPA 状态管理,开发效率反而更高。

如果管理后台也用 Vue 全家桶,老实说也不是不行,但等于要维护两套工程、两套构建链路,换来的是用户根本感知不到的体验提升。我一直觉得技术选型的第一原则不是"哪个新用哪个",而是"哪套方案在这个场景下总成本最低"。

1.3 关于"半分离"架构的个人看法

这套系统不是严格意义上的前后端完全分离,而是带了一点"半分离"的味道:用户端 Vue 是独立工程,通过 axios 调后端 REST 接口;管理后台则是放在静态资源目录里的 Layui 多页面结构,页面本身也是通过 fetch 调同一套接口。

这么做的好处是接口层只有一套,两端共用,不会出现"用户端接口和管理端接口各维护一套"的尴尬。坏处是管理后台的静态资源需要处理好位置,否则打包部署的时候容易 404。我在后面运行章节会专门讲这个问题怎么解决。

如果你的团队对 Layui 不熟,也可以把这个思路换成 Vue + Element UI 做管理后台,接口设计完全不冲突,替换成本主要在页面重写上。

2. 数据库设计:核心表结构与订单状态机的落地

2.1 11 张核心表分别承担什么职责

数据库我建了 11 张表,不算多,但每张表都对应一个明确的业务模块。先看整体概览:

表名职责关键字段
t_user用户账号username, password, nickname, status
t_admin后台管理员username, password, role, last_login_time
t_category商品分类name, parent_id, sort, status
t_product商品信息category_id, name, price, stock, sales, status
t_product_detail商品图文详情product_id, detail_text
t_banner首页轮播图image, link_type, link_value, sort, status
t_cart购物车user_id, product_id, quantity, checked
t_address收货地址user_id, receiver, phone, region, detail, is_default
t_order订单主表order_no, user_id, total_amount, status, address_snapshot
t_order_item订单明细order_id, product_id, product_name, product_image, price, quantity
t_system_config系统配置config_key, config_value

商品主表和详情表分开,是因为列表页只需要商品名、价格、主图这些轻量字段,详情页才需要大段的图文内容,拆开能让列表查询保持轻快。轮播图和系统配置虽然看起来不起眼,但运营后台一定会用到,宁可先建好。

2.2 订单与订单明细的建表细节

订单表是整个系统的核心,设计的时候要把状态、金额、地址快照都考虑进去。订单明细表则要冗余商品名称和下单时价格,不能只存 product_id——因为商品以后可能改名、改价甚至下架,订单的历史快照必须保留完整。

CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单编号', user_id BIGINT NOT NULL COMMENT '下单用户', total_amount DECIMAL(10,2) NOT NULL COMMENT '订单总金额', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已发货 3已完成 4已取消', address_snapshot VARCHAR(500) NOT NULL COMMENT '收货地址快照', pay_time DATETIME DEFAULT NULL, send_time DATETIME DEFAULT NULL, finish_time DATETIME DEFAULT NULL, create_time DATETIME NOT NULL, INDEX idx_user (user_id), INDEX idx_status_time (status, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE t_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, product_name VARCHAR(200) NOT NULL, product_image VARCHAR(255) DEFAULT NULL, price DECIMAL(10,2) NOT NULL COMMENT '下单时单价', quantity INT NOT NULL COMMENT '购买数量', total_price DECIMAL(10,2) NOT NULL COMMENT '小计金额', INDEX idx_order (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

金额字段统一用 DECIMAL(10,2),千万别用 float 或 double,否则算总价的时候会出现 0.1 + 0.2 = 0.30000000000000004 这种经典尴尬。订单编号用时间戳加随机数生成,并加上唯一索引,防止并发下生成重复单号。

2.3 订单状态流转与"地址快照"的设计逻辑

订单状态我用一个 TINYINT 字段表示,配合状态枚举类在 Java 侧做流转校验:

  • 0 待支付 → 1 已支付(用户付款后回调)
  • 0 待支付 → 4 已取消(用户主动取消或超时自动取消)
  • 1 已支付 → 2 已发货(管理员后台发货)
  • 2 已发货 → 3 已完成(用户确认收货或自动确认)

这个流转是不可逆的,代码里要用 switch 限制前置状态,而不是直接把任意状态改成任意状态。比如一个已经发货的订单,绝对不能因为接口被误调用就回到待支付。

地址快照是我特别想提的一点。t_order 里的 address_snapshot 字段保存的是下单那一刻用户地址的完整拼接字符串,而不是 address_id。原因很简单:用户下单后可能去修改收货地址,如果订单只存地址 ID,历史订单的收货信息就会被串改,发货时拿到错误的地址,这是电商系统的大忌。

2.4 索引设计和不做物理外键的理由

物理外键在这个项目里我全都没建。不是说外键没用,而是在业务系统里,外键约束反而容易成为性能瓶颈和维护负担——比如删除一个分类时,如果有外键约束,必须先处理掉该分类下所有商品,否则删不掉;而且高并发写入时外键检查会消耗额外资源。

我更倾向于在服务层保证数据一致性:删除分类前先检查商品表是否还有引用,下单时先校验库存再扣减。逻辑关系在业务代码里维护,数据库只负责存储和最基本的唯一性约束。

索引方面目前够用的三组:order_no 的唯一索引、t_order 的 user_id 索引、t_order 的 (status, create_time) 联合索引(这个专门为了后面超时订单扫描服务准备的)。商品表的 category_id 也建了普通索引,分类筛选时走索引。

3. SpringBoot 接口层:鉴权、事务与状态流转的一次性讲清

3.1 JWT 登录与统一拦截器的实现

用户端和管理端都需要登录鉴权,我统一用 JWT 方案。用户登录成功后,后端生成一个包含 userId 和 role 的 token,前端存在 localStorage 里,每次请求通过 Authorization 头带过来。

核心工具类大致长这样:

public class JwtUtil { private static final String SECRET = "mall-secret-key-change-me"; private static final long EXPIRE = 7 * 24 * 60 * 60 * 1000L; public static String createToken(Long userId, String role) { return Jwts.builder() .claim("userId", userId) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } }

然后写一个拦截器注册到 Spring MVC 里,对所有 /api/** 路径做校验,白名单放行 /api/user/login、/api/user/register、/api/product/** 这些公开接口。有人可能会觉得把 token 密钥直接写在代码里不安全,确实,生产环境应该放到配置中心或环境变量里,但项目跑本地方便起见,我明确在注释里标注了这是示例密钥。

密码存储用的是 BCrypt,不是 MD5。MD5 撞库太容易了,BCrypt 每次加密结果都带随机盐,即使两个用户密码相同,密文也不同。Spring Security 的 BCryptPasswordEncoder 可以直接拿来用,不需要引入整套 Security。

3.2 下单接口:事务、库存校验与并发扣减

下单接口是这套系统里最值得细看的逻辑,它同时涉及事务边界和并发安全两个问题。

@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(Long userId, List<CartItemDTO> items, Long addressId) { // 1. 查询收货地址,生成快照 Address addr = addressMapper.selectById(addressId); if (addr == null || !addr.getUserId().equals(userId)) { throw new BusinessException("收货地址不存在"); } // 2. 逐项校验商品、锁定库存 BigDecimal total = BigDecimal.ZERO; List<OrderItem> orderItems = new ArrayList<>(); for (CartItemDTO item : items) { Product product = productMapper.selectByIdForUpdate(item.getProductId()); if (product == null || product.getStatus() != 1) { throw new BusinessException("商品不存在或已下架"); } if (product.getStock() < item.getQuantity()) { throw new BusinessException("商品[" + product.getName() + "]库存不足"); } product.setStock(product.getStock() - item.getQuantity()); product.setSales(product.getSales() + item.getQuantity()); productMapper.updateById(product); // 计算金额... } // 3. 生成订单主表和明细 // 4. 清空购物车中已勾选的商品 return orderVO; }

几个关键点说明一下:库存扣减前用selectByIdForUpdate对商品行加锁,这就避免了超卖——两个请求同时读到库存为 1、同时扣成 0 的情况不会出现。@Transactional保证中间任何一步抛异常,前面扣掉的库存和生成的订单都会回滚。很多人一开始会漏掉购物车清理和订单生成之间的一致性,如果这两件事不在同一个事务里,就会出现订单生成了但购物车没清掉的脏数据。

3.3 超时未支付订单的自动取消方案

用户下单后如果一直不付钱,订单会永远停留在待支付状态,库存也被占着。最简单的可靠做法是定时任务扫描:每两分钟扫一次 t_order,把 status = 0 且 create_time 超过 30 分钟的记录改成 4,同时把订单明细里每个商品对应的库存加回去。

@Scheduled(fixedDelay = 120000) public void cancelTimeoutOrders() { List<Order> orders = orderMapper.selectTimeoutOrders(30); for (Order order : orders) { orderCancelService.cancel(order.getId(), "超时未支付自动取消"); } }

定时任务方案在中小型系统里完全够用,不需要上 Redis 延迟队列或者消息中间件,但要注意两点:一是任务执行时要判断订单当前状态仍然为待支付,防止和用户手动取消操作冲突;二是取消动作必须在事务里完成,避免库存回补了但订单状态没更新。

3.4 统一返回结构与全局异常处理

前端不管调哪个接口,拿到的 JSON 结构都是一样的,这是我做接口设计时的硬性约定:

{ "code": 200, "message": "success", "data": {} }

正常返回 code 为 200,业务异常返回 500,未登录返回 401。全局异常处理器把 BusinessException 和兜底的 Exception 分别处理,避免出现一大串堆栈直接吐给前端的情况。这个约定带来的好处是 Vue 端 axios 拦截器可以统一处理错误提示,Layui 后台也能直接用 table 组件要求的 {code, msg, count, data} 格式无缝对接分页数据——Layui 的 table 对返回结构是有固定要求的,统一返回设计帮了大忙。

4. Vue 门店端与 Layui 后台的页面实现细节

4.1 Vue 端页面结构与路由划分

Vue 端路由是按用户操作路径拆的,每个页面职责单一:

路由页面核心功能
/Home轮播图 + 热卖推荐
/product/listProductList分类筛选 + 分页 + 关键词搜索
/product/:idProductDetail商品详情 + 加入购物车
/cartCart购物车勾选、改数量、合计金额
/checkoutCheckout选择地址、生成订单
/order/listOrderList订单列表、取消、确认收货
/login、/register登录注册账号入口

首页和商品列表页都需要调分页接口,我在组件里把分页参数和分类 ID 用 watch 做了联动——点击左侧分类时重置页码并重新请求,这个逻辑用 Vue 的响应式特性写起来比 jQuery 时代顺手得多。

购物车页面的金额联动是另一个值得提的点。勾选、取消勾选、修改数量都要实时重算合计,我把它做成计算属性,源数据是购物车 items 数组里每个条目的 checked 和 quantity,计算属性自动依赖收集,不需要像传统方式那样手动调一个 updateTotal 方法。

4.2 axios 封装与 Token 注入

前端请求封装的核心是请求/响应拦截器。请求拦截器负责把 localStorage 里的 token 塞进 header,响应拦截器统一判断 code——不是 200 就弹出错误提示并跳转登录页。

const service = axios.create({ baseURL: '/api', timeout: 10000 }); 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) { if (res.code === 401) { router.push('/login'); } return Promise.reject(new Error(res.message)); } return res.data; });

这里有个跨域的小坑:开发环境下 Vue 跑在 8081 端口,后端跑在 8080 端口,直接请求会触发跨域。我的做法是在 vue.config.js 里配置 devServer 代理,把 /api 前缀的请求转发到 8080,这样前端代码里写相对路径,生产环境再通过 Nginx 统一转发。

4.3 Layui 后台的表格渲染与弹窗表单

Layui 后台的核心是 table 组件。它只要拿到符合格式的分页 JSON,就能自动渲染表格、排序、分页栏,写起来非常省事:

table.render({ elem: '#productTable', url: '/api/admin/product/list', method: 'get', where: { categoryId: $('#categoryId').val() }, page: true, cols: [[ { field: 'id', title: 'ID', width: 80 }, { field: 'name', title: '商品名称', minWidth: 180 }, { field: 'price', title: '价格', width: 100, templet: d => '¥' + d.price }, { field: 'stock', title: '库存', width: 100 }, { field: 'status', title: '状态', width: 90, templet: d => d.status === 1 ? '上架' : '下架' }, { title: '操作', width: 180, toolbar: '#productBar' } ]] });

新增和编辑统一用 layer.open 弹窗,里面嵌一个 form 表单,提交时用form.on('submit')监听,收集字段后调新增或编辑接口,成功就 table.reload 刷新列表。这套模式在分类管理、轮播图管理里完全可以复用,我封装成了一个通用的弹窗函数,后台页面之间大量代码是可以复制粘贴改字段名的。

商品图片上传我单独做了个接口,接收 multipart 文件后保存到本地磁盘 upload 目录,返回访问 URL。注意这个目录的静态资源映射要配好,否则图片请求会 404——这个坑我在第 5.5 节还会专门说。

4.4 联调时最容易对不上的四类字段

前后端联调时最容易出现的四类问题,我做成了一张自查表:

字段类型典型问题解决办法
金额后端返回 19.90,前端显示 19.9后端统一保留两位小数,前端不做 toFixed 以外的处理
时间后端返回 2024-01-01T12:00:00,前端显示英文时间使用 Jackson 配置统一格式为 yyyy-MM-dd HH:mm:ss
状态枚举前端写死"待支付=1",后端返回 0状态定义写进接口文档,前端建枚举常量映射表
图片地址后端返回相对路径 /upload/1.jpg,前端缺少域名接口返回完整 URL,或用相对路径通过代理访问

时间格式是最容易忽略的。SpringBoot 默认返回的 LocalDateTime 是 ISO 格式,Vue 直接显示很难看,我在 application.yml 里配置了全局的时间格式化,一次性解决。

5. 拿到源码之后:从零到直接运行的完整操作清单

5.1 环境准备:版本匹配是第一优先

这套源码能直接运行的前提是环境版本别太激进。我这边的推荐版本是:

工具推荐版本备注
JDK1.8 或 11SpringBoot 2.x 对 JDK 17 也能跑,但部分版本有兼容问题
Maven3.6+3.8+ 都行
MySQL5.7 或 8.08.0 需要配 driver 和时区
Node.js14 ~ 16太高的 Node 跑老版本 Vue 工程容易报错
IDEIntelliJ IDEA / VS Code后端前端分开打开即可

如果你本机 Node 版本已经到 18 以上,npm install 的时候大概率会遇到 ERESOLVE 错误,这个后面有专门解决办法。

5.2 数据库初始化与配置文件修改

源码包里通常会有 sql 目录,里面是完整的建库脚本。先用命令行或可视化工具执行建库,然后修改后端的 application.yml:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/anime_mall?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

第一次跑建议把 mybatis-plus 的日志打开,这样能在控制台看到 SQL,接口出问题时能快速定位是 SQL 写错还是参数没传对。如果连不上数据库,九成是密码错误、URL 里的库名写错或者没有执行建库脚本。

5.3 后端启动步骤

后端启动很简单:用 IDEA 打开后端根目录,等 Maven 依赖下载完,直接运行标注了 @SpringBootApplication 的主类。如果依赖下载慢,可以给 Maven 配国内镜像,这是常规操作里最提升幸福感的一步。

启动成功后,访问 http://localhost:8080/ 能看到接口在线提示。如果配置了在线文档,可以直接打开 /doc.html 页面查看所有接口的定义和参数示例,联调的时候比翻代码快得多。第一次启动如果报端口占用,改 yml 里的 server.port 就行。如果报数据库连接失败,按 5.2 里的配置逐项核对。

5.4 前端启动步骤

Vue 端进入目录后执行:

npm install npm run serve

正常情况下 Vue 会跑在 8081 端口。如果 npm install 报 ERESOLVE 错误,加上--legacy-peer-deps再装一次。

Layui 管理后台如果是静态页面,通常放在后端工程的 resources/static/admin 目录下,后端一启动,直接访问 http://localhost:8080/admin/ 就能打开。这种情况下不需要单独启动,只要保证走的也是 8080 那个后端就行。

5.5 我实测中遇到的 6 个高频问题

问题现象根本原因解决办法
启动报找不到驱动类MySQL 8 需要用 com.mysql.cj.jdbc.Driver改 driver-class-name,或降级 MySQL 驱动版本
报错 serverTimezone数据库连接 URL 缺少时区参数URL 后面加 serverTimezone=Asia/Shanghai
8080/8081 端口被占用本地已有服务占端口改 server.port 或 vue.config.js 里的 devServer.port
npm install 直接失败Node 版本过高触发依赖解析冲突使用 Node 16 或执行 npm install --legacy-peer-deps
前端请求接口 404开发环境未配置代理,请求落在前端服务器上在 vue.config.js 配置 /api 代理到后端
上传的图片 404后台上传成功,但访问 upload 目录没有静态映射在后端配置 addResourceHandlers 映射 /upload/** 到本地磁盘目录

最后这个图片 404 特别隐蔽,因为后端接口明明返回了 200 和一个 URL,但浏览器打开图片地址就是 404。解决办法是在 SpringBoot 里加一个 WebMvcConfigurer,把 /upload/** 映射到实际存储目录,代码量很少但不配就是不行。

6. 业务复盘:比写代码更重要的几个设计决策

6.1 价格金额永远以后端为准

这是我在整个项目里最强调的一条。前端传过来的商品 ID 和数量可以信任,但价格绝对不能信任——如果前端传来的 totalAmount 是 0.01,后端也照单全收,那这个商城很快就会变成慈善机构。

正确做法是我在 3.2 节展示的那样:后端根据商品 ID 重新查库,用数据库里的单价乘以数量算出总价,再用这个总价生成订单。前端展示的价格只是给用户看的参考,业务逻辑永远以服务端计算为准。

6.2 商品上下架状态与列表一致性问题

商品表里有一个 status 字段,0 表示下架,1 表示上架。下架的商品在前台列表和详情页都不能出现,但已经生成的订单不受影响——订单明细里已经冗余了商品名称和价格快照,所以商品下架不影响历史订单的展示和发货流程。

这是电商系统的标准做法,但我见过不少项目把"商品存在"和"商品可购买"混为一谈,下架商品直接从库里删掉,结果订单明细表变成了一堆悬空引用。只要订单明细做了快照,商品就可以安全地下架而不是删除。

6.3 这套系统后续可以扩展的方向

如果要把这套源码继续往生产级推进,我最建议的三个方向是:

  • 营销工具:优惠券、满减、积分体系,核心是在订单金额计算链路里增加促销折扣逻辑,这对现有架构是加法而非改造。
  • 秒杀场景:当前库存扣减用的是行锁,流量再大一点可以改成 Redis 预扣库存 + 消息队列异步下单,但这属于架构升级了。
  • 数据报表:管理后台接入图表组件,做销量趋势、商品排行、用户增长这些可视化,Layui 后台可以配合独立图表库实现。

这三个方向里,优惠券和报表对代码侵入最小,也最能提升项目的完整度,适合作为二次开发的切入点。

最后再分享一个实际体会:这套系统第一次跑通最让我头疼的不是代码,而是环境。MySQL 版本、Node 版本、端口冲突、静态资源映射,这些和业务无关的问题消耗的时间往往比写业务逻辑还多。我后来养成了一个习惯——把每个踩过的坑都补进项目的 README 和配置注释里。拿到源码先看 README 的人,大概率能避开我当年查了一整晚的坑。这个习惯也建议你保留下来,无论这套源码最后被你改成什么样,一份记录着"为什么这样设计、怎么规避已知问题"的文档,是代码之外最有价值的资产。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询