
做电商类项目的人都知道商城系统是最容易看起来简单、做起来全是细节的选题。我这次做的明星周边商城系统选型就定在 Java SpringBoot Vue MySQL 这套经典组合上前后端分离前端 Vue 负责页面和交互后端 SpringBoot 提供接口服务MySQL 管数据持久化。整个项目跑通之后我发现最有价值的不是又一个商城CRUD而是明星周边这个特殊业务场景带来的设计取舍——限量、预售、秒杀式抢购、粉丝向商品属性每一项都在挑战普通商城的常规套路。这篇实战指南就是把完整做一遍的过程、踩过的坑、关键代码的设计逻辑都整理出来给正在做类似全栈商城项目的同学一个可以直接参考的样本。1. 项目从哪来明星周边商城到底在解决什么问题1.1 粉丝经济下的业务诉求很多人一开始会把它当成普通电商项目来做但明星周边和卖衣服、卖数码完全是两码事。我是在和运营方聊完需求之后才意识到如果按照通用商城的模板去建表后面会返工到怀疑人生。明星周边业务的第一个特征是商品必然绑定艺人。周边商品的本质是偶像的衍生品用户逛商城的时候习惯按艺人逛而不是按品类逛。同一个艺人旗下可能有应援棒、T恤、手幅、徽章、写真集用户想看的不是T恤分类下有什么而是我家偶像有什么。这意味着商品模型里必须有一张独立的艺人表商品上挂艺人ID首页要有艺人专区入口筛选维度也要支持按艺人过滤。第二个特征是大量同款不同印的SKU组合。比如同一款白色T恤印不同艺人的照片或LOGO价格一样但它们是不同的商品库存要分开管下单不能串。这个在数据模型上就是标准的 SPU/SKU 分层后面建表的时候我会详细展开。第三个特征是限量、预售、定时开售。周边圈子的核心玩法就是限量发售开售瞬间流量集中库存必须扣得准不能超卖也不能因为并发把数据库搞挂。预售场景还涉及先下单、后发货用户对发货周期非常敏感。第四个特征是高复购和粉丝运营属性。粉丝用户不是买完就走他们会反复购买新出的周边所以个人中心、收藏、订单记录、收货地址这些基础能力一个都不能少而且体验要顺。这些业务规则直接决定了系统里有哪些表、接口怎么划分、库存怎么做并发控制。所以项目真正开始前不要急着敲代码先把业务规则理清楚比技术选型重要得多。1.2 系统功能边界前台与后台分别要做什么我最后定的功能范围前台和后台分开来看前台商城端用户注册/登录实际业务用手机号验证码演示项目放宽成用户名密码配合 JWT 做鉴权。首页轮播Banner、热销周边、艺人专区入口。商品列表按艺人、分类、价格区间筛选支持关键词搜索分页展示。商品详情多图展示、SKU 选择联动、库存提示、收藏按钮。购物车增删改查、勾选结算、数量修改。订单提交订单、订单列表、订单详情、取消订单、确认收货。个人中心个人信息、收货地址管理、我的收藏、我的订单。后台管理端艺人管理艺人信息的新增、修改、上下线。商品分类管理分类维护。商品与SKU管理商品录入、SKU规格设置、上下架、库存调整。订单管理查看订单、发货操作。用户管理查看用户列表、禁用/启用。简易数据看板订单量、销售额、热销商品排行。这套边界逻辑很清晰商城端管交易触点管理端管运营维护。我没有把前后台拆成两个工程而是保持一个后端工程用/api/portal/**和/api/admin/**两个前缀区分代码量可控部署也方便。前端是彻底分离的Vue 工程里用路由区分商城页面和管理后台页面管理后台嵌套在AdminLayout布局组件下面登录时通过角色字段判断能不能进入后台。2. 技术选型与整体架构为什么是 SpringBoot Vue MySQL2.1 这套技术栈的搭配逻辑先说说选型理由写进技术方案文档里也讲得通Java 是团队熟悉的语言生态成熟招人容易适合做长期维护的商用系统。SpringBoot 是目前 Java 服务端最主流的框架内嵌 Tomcat约定优于配置打成 jar 包就能跑部署极其省事。Vue 做商城前台效率非常高组件化、响应式、路由、状态管理都很顺手粉丝向的页面动效也多Vue 的模板语法写起来很快。MySQL 做关系型存储用户、商品、订单、购物车天然是强关系数据事务支持成熟用它最省心。如果非要用一句话总结这套组合不是最新潮的但一定是资料最全、踩坑成本最低的。我把具体版本列一下后面部署遇到问题也能对照着看组件版本建议备注JDK1.8稳定优先很多服务器环境默认就是它SpringBoot2.7.x别一上来就上 3.x原因下面说MySQL5.7.44经典稳定版8.0 也行但 5.7 坑更少MyBatis-Plus3.5.x配合 SpringBoot 2.x 版本兼容Vue3.x Vite构建速度快组合优于 Vue2 webpackElement Plus最新稳定版前后台共用组件全注意SpringBoot 3.x 要求 JDK 17如果团队、服务器、部署环境都是 JDK 8强行升 3.x 会带来一堆兼容问题。我见过有人在 2.x 项目里引了 3.x 的依赖启动直接报错。这个项目最终锁定 SpringBoot 2.7.x理由只有一个——稳。2.2 系统分层与请求流转架构上还是经典的前后端分离三层前端 Vue 发请求后端 Controller 接参数Service 处理业务Mapper 操作数据库。这个大家都会我重点讲一个容易被忽略的设计接口层必须区分管理端接口和商城端接口。接口前缀分类如下接口前缀使用方典型接口/api/portal/**前台用户商品查询、登录、购物车、下单/api/admin/**后台管理员商品管理、订单管理、数据看板这样设计的好处有三个一是鉴权拦截器可以按前缀写两套规则普通用户 Token 没法访问管理接口二是接口文档好分类前后端协作不会混三是将来如果要把后台拆成独立系统迁移成本很低。请求流转整体是浏览器访问 Vue 页面Vue 里通过 axios 发起请求请求先到达后端的拦截器做 Token 校验校验通过后进入 ControllerController 只做参数接收和结果封装业务逻辑都在 Service 层数据操作走 MyBatis-Plus 的 Mapper。这套流程职责单一新手照着写也不容易乱。3. 数据库设计明星周边商城的核心表结构3.1 基础表用户、艺人、商品、SKU数据库是整个系统的地基只要表设计不出大问题后面写代码基本就是按照业务逻辑堆实现。我梳理了核心表的设计思路重点看字段里的业务含义。第一张t_user用户表。字段有 id、username、password、nickname、phone、avatar、status、create_time。这里有个设计点必须强调密码必须加密存储我用的是 BCryptPasswordEncoder绝对不存明文。登录时把用户输入的密码和密文做比对Spring Security 自带的这个加密器每次生成的盐不同安全性和可用性都满足要求。第二张t_star艺人表。字段有 id、star_name、avatar、description、status、sort_order。这张表是明星周边商城区别于普通商城的最核心表。商品、首页专区、筛选条件、搜索联想都依赖它。艺人管理的增删改查在后台上是独立菜单运营可以直接调整艺人展示顺序。第三张t_category分类表。字段有 id、category_name、parent_id、sort_order。虽然按艺人逛是主流但按品类应援棒、T恤、手幅、徽章也是一个真实存在的高频路径分类表必须有。我做了二级分类parent_id0 表示顶级分类这样以后扩展子类目也不用改表结构。第四张t_product_spu商品表。字段有 id、star_id、category_id、product_name、main_image、detail_images、description、price、status、sales_count、create_time。SPU 是商品的抽象层对应前端商品详情页顶部的主展示对象。值得注意的字段是detail_images我存的是多张图片URL用逗号分隔前端页面切分渲染即可不需要单独建图片表。第五张t_product_sku商品规格表。字段有 id、product_id、sku_name、sku_code、price、stock、image、status。SKU 是真正卖的规格项库存和价格挂在 SKU 上不是挂在 SPU 上。这一点特别重要很多新手会把库存写到商品表结果一个商品有多种规格时库存完全没法精确控制。为什么这么设计举个例子一件应援T恤SPU 是某某演唱会纪念T恤SKU 就有白色S码白色M码黑色L码等好几条记录。用户选购时选的是 SKU下单时订单明细锁定的也是 SKU。如果只有一个总库存用户选白色S码看到有货实际上黑色L码已经断货了整个体验就崩了。3.2 交易表购物车、订单、订单明细第六张t_cart购物车表。字段有 id、user_id、product_id、sku_id、quantity、checked、create_time、update_time。购物车本质是用户想买但还没下单的临时清单表结构简单但要注意唯一约束同一个用户的同一个 SKU 只能有一条记录重复加入时数量累加。我加了(user_id, sku_id)的唯一索引然后先查后插或者用 INSERT...ON DUPLICATE KEY UPDATE 处理。第七张t_order订单表。关键字段有 id、order_no、user_id、total_amount、pay_amount、order_status、receiver_name、receiver_phone、receiver_address、pay_time、delivery_time、finish_time、create_time。订单号不能用自增ID因为会暴露订单量和并发情况我用的是yyyyMMddHHmmss 用户id后四位 随机四位规则生成比如2025012010300012345678。生成时用 synchronized 或数据库唯一索引保证不同秒不会重复。第八张t_order_item订单明细表。字段有 id、order_id、product_id、product_name、sku_id、sku_name、product_image、price、quantity、total_price。为什么要把商品名称、SKU名称、价格都冗余一份存到明细里因为商品信息是会变的如果以后商品改价、改名甚至下架已经产生的订单必须保留下单那一刻的快照。这就是典型的历史快照设计电商订单必须这么做不然售后和审计会乱套。交易表的整体关系可以理解成购物车是候选清单订单是成交凭证订单明细是凭证里的每一行商品。这三张表加上用户表、SKU表就构成了商城最核心的交易闭环。3.3 库存扣减与索引设计明星周边经常搞限量发售库存设计直接影响抢购体验。我这里的库存就放在t_product_sku.stock字段里通过 SQL 原子扣减来实现UPDATE t_product_sku SET stock stock - #{count} WHERE id #{skuId} AND stock #{count}这条 SQL 的巧妙之处在于stock #{count}这个条件让数据库在更新时自己做了库存校验。如果库存不够UPDATE 影响行数是 0程序收到 0 就知道库存不足返回已售罄。这种写法在高并发下比先查库存再改库存安全得多因为后者的查询和更新之间存在窗口期非常容易超卖。索引设计上我加了这些t_order.order_no建唯一索引保证订单号全局不重复。t_order.user_id建普通索引用户查自己订单列表时走索引。t_order_item.order_id建普通索引订单详情查明细时高效。t_product_spu.star_id和category_id建普通索引商城首页按艺人/分类筛选时很关键。t_product_sku.product_id建普通索引商品详情页查 SKU 列表时必用。这里有个反向经验不要在频繁更新的库存字段上建索引。库存字段每次下单都会 UPDATE索引维护成本会放大写压力而且查询库存永远按 SKU 主键来用不到这个索引。4. 后端 SpringBoot 实战从登录鉴权到下单链路4.1 JWT 登录与接口鉴权认证方案我选了 JWT不用 Session。原因很简单前后端分离后Session 要维护跨域 Cookie麻烦且不适合接口化JWT 本身携带用户信息后端无状态扩展性好。思路是用户登录时后端校验用户名和 BCrypt 密码。校验通过后生成 JWT Token里面带 userId、username、角色。前端把 Token 存到 localStorage每次请求放在Authorization头里。后端写一个拦截器每次请求先解析 Token解析通过就放行并把 userId 放到 ThreadLocal 里供后续业务使用。核心拦截器代码如下public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || token.isEmpty()) { throw new BusinessException(401, 未登录); } Long userId JwtUtil.parseToken(token); UserContext.set(userId); return true; } }然后在 WebMvcConfigurer 里注册拦截器按路径区分放行规则/api/portal/login、/api/portal/register、商品查询接口放行购物车、下单、管理端接口全部拦截。这里有个小技巧拦截器只拦截带 Token 的接口登录接口本身要放行否则没法登录。JWT 库我选的是 jjwt参考实现很简单。有个坑要提醒JWT 的 secret 不要硬编码在代码里放到配置文件将来换 key 不用重新编译。4.2 MyBatis-Plus 与商品模块持久层用了 MyBatis-PlusCRUD 不用手写 SQL分页插件自带代码量少很多。比如商品列表分页查询public IPageProductSkuVO pageProducts(int page, int size, Long starId, Long categoryId) { LambdaQueryWrapperProductSpu wrapper new LambdaQueryWrapper(); wrapper.eq(starId ! null, ProductSpu::getStarId, starId) .eq(categoryId ! null, ProductSpu::getCategoryId, categoryId) .eq(ProductSpu::getStatus, 1); return productSpuMapper.selectPage(new Page(page, size), wrapper); }这里要提醒一个细节商城端只查status 1的上架商品管理端能查到全部状态的商品。所以商品模块我写了两个查询入口前台一个后台一个避免后台把下架商品也当成可售商品暴露出去。商品详情页需要同时返回 SPU 信息和 SKU 列表我在 Service 层做了聚合先查 SPU再按 productId 查 SKU 列表最后组装成一个详情 VO 返回。前端一次接口调用就能渲染整页不用来回请求。聚合逻辑大致是public ProductDetailVO getDetail(Long productId) { ProductSpu spu productSpuMapper.selectById(productId); ListProductSku skuList productSkuMapper.selectList( new LambdaQueryWrapperProductSku().eq(ProductSku::getProductId, productId) ); ProductDetailVO vo new ProductDetailVO(); BeanUtils.copyProperties(spu, vo); vo.setSkuList(skuList); return vo; }4.3 下单流程与库存扣减的并发控制下单是整个项目里最容易出问题的环节明星周边又是典型的限时开售场景瞬时流量大。我的下单流程是校验登录状态。根据购物车勾选记录或者前端直接传的 SKU 列表组装订单明细。计算总金额。开启事务。扣减每个 SKU 的库存用上面那条原子 UPDATE影响行数判断库存是否够。生成订单主表和订单明细数据。清空购物车对应 SKU。提交事务。第5步是关键。我当时专门做了并发测试模拟 100 个线程同时抢同一个 SKU 的 50 件库存最后实际扣减的库存量刚好是 50一张订单都没有超卖。原理很简单那条带库存条件的 UPDATE 在数据库层面是行级锁同一时刻只允许一个事务更新这一行后到的更新会等前一个提交再判断库存条件。这就是最朴素的乐观锁思路对付中小型项目的抢购完全够用。事务方法上我加了Transactional(rollbackFor Exception.class)保证库存扣减和订单生成要么全成功要么全失败。如果中间任何一步异常库存会自动回滚不会出现订单没生成但库存没了的尴尬状态。4.4 订单状态机与定时任务订单状态用整数字段管理0待付款、1待发货、2待收货、3已完成、4已取消。状态流转有严格限制待付款可以取消或付款付款后变待发货后台发货后变待收货用户确认收货变已完成。这里做了一个很实用的功能订单超时未付款自动取消。用 Spring 自带的Scheduled定时任务每分钟扫一次待付款订单把创建超过30分钟的订单改为已取消同时把占用的库存加回去。Scheduled(fixedDelay 60_000) public void cancelTimeoutOrders() { LocalDateTime deadline LocalDateTime.now().minusMinutes(30); ListOrder orders orderMapper.selectTimeoutOrders(deadline); for (Order order : orders) { order.setOrderStatus(4); orderMapper.updateById(order); restoreStock(order); } }这个定时任务在中小项目阶段完全够用。但要留个心如果订单量大了每分钟扫全表会变慢优化方向是把待付款订单状态放到 Redis 延迟队列或者用消息中间件的延迟消息。演示项目不用上那套先跑通再谈优化。5. 前端 Vue 实现商城页面与交互细节5.1 页面路由与组件拆分前端用的 Vue3 Vite Element Plus路由分成前台和后台两块const routes [ { path: /, component: Home, meta: { title: 首页 } }, { path: /product/:id, component: ProductDetail, meta: { title: 商品详情 } }, { path: /cart, component: Cart, meta: { requiresAuth: true } }, { path: /order, component: OrderList, meta: { requiresAuth: true } }, { path: /login, component: Login }, { path: /admin, component: AdminLayout, children: [...] } ]路由守卫里做了登录判断访问购物车、订单这类页面时如果本地没有 Token就跳转登录页。这个体验非常关键不然用户下单下到一半才要求登录转化率会差很多。Element Plus 的导航栏、表单、表格、弹窗组件可以直接复用开发效率不是手写 CSS 能比的。组件拆分的经验商品卡片、SKU选择器、数量选择器、订单状态标签这四类高频复用组件必须拆。比如 SKU 选择器商品详情页和购物车编辑页都要用拆出来之后改联动逻辑只改一处。我一开始没拆结果同一个联动逻辑改了三次都漏掉一个页面后来老老实实抽成组件省心太多。5.2 购物车与订单状态管理前端状态管理选的 Pinia。购物车里维护了一个cartItems数组每个元素有 skuId、数量、勾选状态。购物车数据在后端本来就有持久化前端状态主要解决本次会话内响应式更新的问题所以没有搞得很复杂export const useCartStore defineStore(cart, { state: () ({ items: [] }), getters: { checkedItems: (state) state.items.filter(item item.checked), totalPrice: (state) state.items.reduce((sum, item) sum item.price * item.quantity, 0) }, actions: { loadCart() { ... }, addItem(sku) { ... }, removeItem(skuId) { ... } } })购物车勾选逻辑也做了前后端同步勾选/取消勾选会调用后端接口更新checked字段。这样即使用户刷新页面购物车勾选状态不会丢。这个细节看起来小实际体验差别很大很多商城项目图省事只在本地维护勾选状态用户一刷新全白选结算时就懵了。订单状态管理在前端主要是展示逻辑根据后端返回的 order_status 数字映射成待付款待发货待收货已完成已取消配合不同的操作按钮付款、取消、确认收货。我用一个 Vue 计算属性或者简单的映射函数处理不把状态逻辑散落在各个模板里。5.3 Axios 封装与接口对接接口这块统一封装了 axios 实例。请求拦截器把 Token 加到请求头响应拦截器统一处理错误码const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization token } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(error) } )这套封装的好处业务代码里不再出现localStorage.getItem(token)和错误弹窗逻辑写页面专注数据渲染就行。后端返回的 code 约定200成功、401未登录或登录过期、500业务失败。前后端按这个约定对接几乎不用为接口格式吵架。接口配置文件里我把所有 API 列成常量比如export const api { login: /portal/login, register: /portal/register, productList: /portal/products, productDetail: /portal/products/detail, cartList: /portal/cart/list, orderCreate: /portal/order/create, orderList: /portal/order/list }页面里直接import { api } from /api使用路径变更只改一处。6. 联调、打包与部署中的踩坑实录6.1 跨域问题从报错到配置修复前后端分离开发时Vite 跑在 5173 端口SpringBoot 跑在 8080 端口直连必然跨域。第一种解决方式是在 SpringBoot 后端加跨域配置Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowCredentials(true); } }注意两个坑一是allowedMethods必须加上OPTIONS因为浏览器跨域请求会先发预检请求不加的话联调阶段怎么调都是失败状态二是如果用了 JWT 的Authorization头allowedHeaders也要放行否则请求被浏览器拦截报错信息会非常隐晦。第二种方式是开发阶段用 Vite 的代理把/api直接代理到后端server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }开发期用代理内网部署后用跨域配置两种方式我都配过不同环境踩的坑不同这里一起写清楚。6.2 Vue 打包进 SpringBoot 的整合方式项目最终交付时我选择了把前端打包产物直接放进 SpringBoot形成一个 jar 包跑完整系统的部署方式。步骤前端执行npm run build生成dist目录。把dist下的文件复制到后端src/main/resources/static目录。重新打包 SpringBoot 的 jar。启动 jar浏览器直接访问http://服务器IP:8080就能看到首页。这样做的核心原因部署成本最低。不用单独装 Nginx不用处理前端跨域只要服务器有 JDK 环境就能跑。但有个前提后端接口统一走/api前缀Vue 路由用 history 模式下需要配置转发到index.html否则用户直接刷新某个非首页路由比如/product/12会报 404。SpringBoot 里加一个简单的路由转发Configuration public class WebConfig implements WebMvcConfigurer { Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController(/{path:[^\\.]*}).forwardTo(/index.html); } }这个配置只对非带点的路径生效静态资源js、css、图片不受影响。这是 SpringBoot 整合前端页面最常被忽略的一步不加这条刷新页面全 404加了就通。6.3 环境问题记录MySQL 版本、时区与 SSL把环境问题一次性说清楚都是真实遇到过的。第一MySQL 版本。如果服务器上装的是 MySQL 5.7.44JDBC 连接串里记得写useSSLfalse和serverTimezoneAsia/Shanghai不然会报 SSL 连接错误或时区错误。SSL 那个报错尤其容易吓到新手其实就是未校验的服务器证书问题内网项目直接关掉 SSL 连接即可不用去折腾证书。第二如果数据库是 MySQL 8.0密码加密规则默认是caching_sha2_password老版本驱动可能连不上。要么换新驱动要么把 MySQL 用户的加密方式改成mysql_native_password。这类环境兼容问题排查方向先分清是驱动、加密规则还是时区。第三中文乱码。连接串里一定要带characterEncodingutf8建库时指定字符集CREATE DATABASE star_mall DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;前后端交互再加一层 UTF-8 编码过滤基本不会乱码。环境配置汇总组件版本/配置建议常见坑JDK1.8SpringBoot 3.x 要求 JDK17别混用SpringBoot2.7.x版本太高会导致依赖不兼容MySQL5.7.44 或 8.0注意驱动、时区、SSL 报错Vue3.x Vite打包后刷新 404 要配 forward前端代理开发期 Vite proxy联调期跨域报错先看 OPTIONS最后补充一个部署细节jar 包启动时如果 MySQL 和应用不在同一台机器连接串里的 host 要写内网 IP不要写 localhost。写 localhost 会默认连应用所在机器一旦数据库在别的机器上启动连接直接失败。这个坑我踩过一次排查了大半天最后只是改个 IP 的事。做这个明星周边商城系统我最大的收获是理解了技术服务于业务这句话。明星周边的限量、预售、按艺人逛商品都不是什么高科技需求但它们决定了表结构怎么设计、库存怎么扣、页面怎么组织。如果只照搬普通电商模板系统也能跑但做出来的东西运营方会觉得不好用、不是那么回事。Java、SpringBoot、Vue、MySQL 这套组合资料多、坑少、上手快非常适合作为完整全栈项目来练手。如果你正在做类似的项目我建议从数据库设计开始慢一点把业务规则吃透后面写代码会顺很多。上面那几张表结构和环境配置表可以存下来遇到问题先对着查一遍能省下大把排错时间。