尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

SpringBoot+Vue3+MyBatis明星周边商城系统全栈开发实战

发布时间:2026/9/24 18:39:31

资讯中心
01
ARTICLE

SpringBoot+Vue3+MyBatis明星周边商城系统全栈开发实战

SpringBoot+Vue3+MyBatis明星周边商城系统全栈开发实战
做明星周边产品这类垂直电商网站最怕的就是技术选型太重、开发周期太长。市面上的商城系统要么是全栈耦合的老框架要么是前后端不分离的JSP那一套改起来确实头疼。我之前在实际项目中反复对比过几种方案最终确定用 SpringBoot Vue3 MyBatis 这套前后端分离的组合来落地“星之语明星周边产品销售网站”。今天把这套系统的整体设计、核心代码、数据库建模、踩坑记录完整梳理一遍想自己搭一个类似线上销售系统的朋友可以直接参考。这个项目定位很明确一个既能展示商品、又能走通完整交易流程的明星周边垂直电商网站。用户端围绕浏览、搜索、加购、结算、订单管理展开管理端则要搞定商品上下架、分类维护、订单处理、公告发布这些后台操作。适合正在学Java全栈开发的在校学生、准备毕业设计的同学也想给刚转SpringBootVue3的技术人员一个能落地的完整范式。1. 系统设计思路与关键技术选型1.1 为什么选这套技术栈而不是传统单体 JSP 方案先说说技术选型背后最直接的考量。明星周边产品具有明显的时效性和粉丝群体垂直性这意味着系统要应对两类流量一是日常的浏览和下单二是新品首发、偶像生日这类节点带来的瞬时访问高峰。这就要求系统必须具备良好的横向扩展能力前后端分离是绕不开的。以前用 JSP Servlet 做商城页面渲染和后端逻辑强耦合静态资源和Java代码混在一个工程里。到了高并发场景想加一台服务器做负载均衡session同步就是一道坎。前后端分离之后后端只用 SpringBoot 暴露无状态接口配合 JWTJSON Web Token做身份认证服务层天然支持水平扩容前端用 Vue3 打包成纯静态文件扔到 Nginx 里就行动静分离之后静态资源访问根本不占后端资源。再结合团队协作的实际情况Vue3 组件化开发前端工程师和后端工程师可以并行推进接口定义清楚之后两边同时开工开发效率能提升不少。而且从 2025 年这个时间节点往回看Vue3 已经是国内中小型前端项目事实上的标准生态成熟、招聘市场上认这个技术栈的人也多选择它不会踩坑。1.2 核心业务模块划分“星之语”系统从功能边界上划分成六个核心模块每个模块都对应一个独立的业务域用户模块注册、登录、个人信息维护、收货地址管理。这里涉及 JWT 签发与校验密码加密存储。商品模块明星分类内地、港台、日韩、欧美等、商品列表展示、商品详情、热门商品推荐。MyBatis 动态 SQL 主要在这里发挥作用因为商品筛选条件组合非常灵活。购物车模块加入购物车、修改数量、删除条目、购物车列表统计。设计时需要考虑 cookie 购物车和登录后购物车合并的问题。订单模块订单创建、订单列表、订单详情、取消订单、确认收货。这是整个系统中最复杂的模块涉及事务一致性。支付模块考虑到学习项目的定位这里采用模拟支付的方式不直接对接支付宝或微信的支付网关但保留了完整的支付回调接口方便以后扩展真实支付。管理模块商品管理增删改查、上下架、分类管理、订单处理发货、公告发布、用户管理。订单模块为什么复杂因为一次下单操作同时涉及库存扣减、订单主表插入、订单明细表批量插入、购物车条目清除、支付记录创建这五件事必须要么全部成功、要么全部回滚任何一个环节出问题都会造成数据不一致。所以我在订单服务中使用了Transactional注解来管理事务边界。1.3 目录结构设计按业务模块分包而不是按技术类型分包很多人初学 SpringBoot 时习惯按照 controller、service、mapper、entity、config 这样按技术层分包。小项目这么干没什么问题但项目一旦膨胀起来找代码会非常痛苦——一个订单相关的功能你要在 controller 包里翻订单控制器在 service 包里翻订单服务在 mapper 包里翻订单映射接口光跳转就够累的。“星之语”项目我采用按业务模块分包com.xingzhiyu ├── common # 通用工具类、统一返回结果、异常处理 ├── config # 全局配置跨域、拦截器、MyBatis配置 ├── controller # 控制层按业务模块划分 │ ├── user │ ├── product │ ├── cart │ ├── order │ ├── admin ├── service # 业务逻辑层 │ ├── impl ├── mapper # MyBatis数据访问层 ├── entity # 数据库实体类 ├── dto # 前端传入的数据传输对象 ├── vo # 返回给前端的视图对象 └── interceptor # 拦截器JWT校验、管理员鉴权这样分包的好处是当你想改商品相关的业务时直接在 product 这个业务包下找相关文件都在一起。另外我单独拆出一个 dto 和 vo 层是为了避免直接把数据库实体暴露给前端接口契约更清晰。比如前端注册时传过来的可能是RegisterDTO包含用户名、密码、确认密码而后端实体User可能还有盐值、创建时间等字段直接用实体接参既不安全也不灵活。2. 数据库设计与核心表结构拆解2.1 明星周边商品的数据特点明星周边产品和普通商品最大的区别在于其属性维度。一件T恤有尺码、颜色、材质一张专辑有版本普通版、典藏版、是否有小卡、是否有海报。如果用通用的商品表加规格属性表查询时要多表关联对于这个规模的项目得不偿失。我的做法是采用基础商品表 商品规格表的简化模式。-- 商品表 CREATE TABLE product ( id int(11) NOT NULL AUTO_INCREMENT, category_id int(11) NOT NULL COMMENT 分类ID, name varchar(200) NOT NULL COMMENT 商品名称, subtitle varchar(500) DEFAULT NULL COMMENT 副标题, main_image varchar(500) NOT NULL COMMENT 主图, detail text COMMENT 商品详情, star_name varchar(100) DEFAULT NULL COMMENT 关联明星姓名, price decimal(10,2) NOT NULL COMMENT 商品价格, stock int(11) NOT NULL DEFAULT 0 COMMENT 库存, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态1在售 2下架 3删除, create_time datetime NOT NULL, update_time datetime NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4; -- 商品规格表 CREATE TABLE product_sku ( id int(11) NOT NULL AUTO_INCREMENT, product_id int(11) NOT NULL, sku_name varchar(100) NOT NULL COMMENT 规格名称如S码/黑, sku_price decimal(10,2) NOT NULL, sku_stock int(11) NOT NULL DEFAULT 0, PRIMARY KEY (id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4;主表中的stock是一个总库存的快照真正的库存扣减在product_sku表上做。当用户购买具体规格时扣的是 sku 的库存下单成功后立刻同步商品总库存。这种设计在查询列表页时只需要查 product 表不用统计每个 sku 的库存性能好很多。2.2 订单和购物车表设计为什么需要冗余字段购物车表相对简单就是用户ID加商品ID加规格ID加数量但订单相关的表设计要特别小心。订单表我把关键信息都做了冗余。CREATE TABLE order_main ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(64) NOT NULL COMMENT 订单号, user_id int(11) NOT NULL, total_price decimal(10,2) NOT NULL COMMENT 商品总金额, pay_price decimal(10,2) DEFAULT NULL COMMENT 实付金额, receiver_name varchar(50) NOT NULL COMMENT 收货人姓名, receiver_phone varchar(20) NOT NULL, receiver_address varchar(500) NOT NULL, status tinyint(4) NOT NULL DEFAULT 0, create_time datetime NOT NULL, pay_time datetime DEFAULT NULL, ship_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4; CREATE TABLE order_item ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(64) NOT NULL, product_id int(11) NOT NULL, product_name varchar(200) NOT NULL COMMENT 商品名称快照, product_image varchar(500) NOT NULL COMMENT 商品图片快照, sku_name varchar(100) DEFAULT NULL, price decimal(10,2) NOT NULL COMMENT 成交单价快照, quantity int(11) NOT NULL COMMENT 购买数量, PRIMARY KEY (id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4;冗余商品名称、图片、价格到订单明细表是一个很重要的设计决策。因为商品信息是可变的卖家可能在用户下单后修改了商品名称或价格。订单作为交易凭证必须还原下单那一刻的真实信息。如果不做快照以后对账、售后、开发票都会出问题。订单状态我用一个 int 字段表示定义规则0待付款、1已付款待发货、2已发货、3已确认收货、4已取消、5已完成。为什么不直接用字符串枚举因为 int 比较效率高数据库索引也更小。状态迁移逻辑放在 Service 层管理不在 Controller 里散落。2.3 明星分类表与首页轮播图设计分类表能满足商品导航需求但明星周边网站通常还需要一个“明星专区”的概念。我在分类表中增加了一个type字段0 表示商品分类1 表示明星专区这样一张表解决两个维度的问题。首页轮播图我单独建了一张banner表包含图片地址、跳转链接、排序权重、状态。做项目的过程中你会发现与其在代码里写死首页图片不如做成一个可配置的功能因为运营同学随时可能要换图每次都发版改代码既不安全也不高效。管理后台做一张轮播图表的管理页面几分钟就能搞定。3. SpringBoot 后端核心实现与难点攻破3.1 SpringBoot 统一返回结果与全局异常处理前后端分离项目中接口返回的数据结构一定要统一。我定义了一个ResultT泛型类所有接口都返回这个结构public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }如果每个 Controller 都自己 try-catch 再手动赋值代码会非常冗余。所以配合一个RestControllerAdvice全局异常处理器统一捕获业务异常、参数校验异常、系统异常转成对应的 Result 返回给前端。这样前端 Axios 拦截器只需要判断 code 字段是否为 200就能决定走成功回调还是错误提示极大地简化了前端的错误处理逻辑。3.2 JWT 登录认证与拦截器鉴权实战用户模块的登录认证我采用 JWT 方案。每次登录成功后端生成一个 token 返回给前端。前端将 token 存储在 localStorage 中并在每次请求的请求头中携带Authorization: Bearer token。后端通过拦截器统一解析 token将用户信息放入 ThreadLocal。Component public class AuthInterceptor implements HandlerInterceptor { private final JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equals(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { throw new BusinessException(401, 用户未登录); } String uid jwtUtil.getUserId(token.substring(7)); if (uid null) { throw new BusinessException(401, 登录状态已过期); } UserContext.set(Long.parseLong(uid)); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }这里有个关键点UserContext使用 ThreadLocal 存储必须在请求结束后的afterCompletion方法中显式清理否则由于 Tomcat 线程池复用线程会出现一个请求的用户信息污染另一个请求的情况。我在实际排查过一个 bug就是没有清理 ThreadLocal 导致用户 A 看到了用户 B 的订单数据非常典型。JWT 的有效期我设置的是 2 小时过期后前端收到 401 状态码会自动跳转登录页。对于购物车、订单这类敏感操作这个有效期是合理的如果你希望用户“记住登录状态”可以再增加一个 refresh_token 机制但会增加复杂度适合后续扩展。3.3 MyBatis 动态 SQL 搞定商品多条件筛选商品列表页的筛选条件可以说是动态组合的明星分类、商品类别、价格区间、排序方式、关键词。如果每种组合写一个 SQL那查询方法会爆炸。MyBatis 的where和if动态 SQL 完美解决这个问题。select idselectProductList resultTypecom.xingzhiyu.vo.ProductVO SELECT p.id, p.name, p.subtitle, p.main_image, p.price, p.star_name, c.category_name FROM product p LEFT JOIN category c ON p.category_id c.id WHERE p.status 1 if testcategoryId ! null AND p.category_id #{categoryId} /if if teststarName ! null and starName ! AND p.star_name LIKE CONCAT(%, #{starName}, %) /if if testminPrice ! null AND p.price gt; #{minPrice} /if if testmaxPrice ! null AND p.price lt; #{maxPrice} /if if testkeyword ! null and keyword ! AND (p.name LIKE CONCAT(%, #{keyword}, %) OR p.star_name LIKE CONCAT(%, #{keyword}, %)) /if choose when testsort price_asc ORDER BY p.price ASC /when when testsort price_desc ORDER BY p.price DESC /when when testsort newest ORDER BY p.create_time DESC /when otherwise ORDER BY p.create_time DESC /otherwise /choose /select这段 SQL 在日常开发中非常典型。注意gt;是 XML 中大于号的转义写法直接从 XML 文件里粘贴大于号会报错。首次接触 MyBatis 的同学经常在这里卡壳。3.4 分页插件 PageHelper 的正确使用姿势商品列表、订单列表这类数据量较大的查询必须做分页。直接用 LIMIT 手动计算起始偏移量容易出错也容易在接口之间重复写分页逻辑。我在这里用的是 MyBatis 分页插件 PageHelper它对业务代码无侵入只需要在查询前设置页码和每页条数。Override public PageInfoProductVO getProductList(ProductQueryDTO queryDTO) { PageHelper.startPage(queryDTO.getPageNum(), queryDTO.getPageSize()); ListProductVO list productMapper.selectProductList(queryDTO); return new PageInfo(list); }用 PageHelper 有几个注意事项特别值得提醒第一PageHelper.startPage()之后必须紧跟第一个 MyBatis 查询中间不要插入其他数据库操作否则插件会拦截到错误的 SQL导致分页失效。第二查询结果返回后要立即封装成 PageInfo因为 PageHelper 的 ThreadLocal 机制会在第一次查询后自动清理分页参数。第三如果你在 Service 层做循环查询千万别在循环内调用startPage()否则分页参数会被循环内多次查询覆盖最终结果难以预料。提示PageHelper 分页插件还有一个隐藏问题——它会自动生成 COUNT 查询。如果列表查询涉及多表 JOIN 且数据量很大COUNT 语句可能非常慢。遇到这种情况可以在 mapper 中提供一个不带 JOIN 的 COUNT 方法并利用插件提供的countSuffix参数指定或者简化查询确保 COUNT 不会拖慢性能。3.5 订单事务与防止超卖明星周边产品经常有“限量版”的概念库存可能就只有几十件一旦超过库存还要继续卖就会产生严重的客诉。我在扣减库存时使用了带条件的 UPDATE 语句来防止超卖。UPDATE product_sku SET sku_stock sku_stock - #{quantity} WHERE id #{skuId} AND sku_stock #{quantity}这个 UPDATE 语句利用数据库行锁和条件判断天生是原子性的。如果影响行数为 0说明库存不足直接抛出业务异常整个事务回滚。这比先 SELECT 再判断库存然后 UPDATE 的三步操作要可靠得多因为后一种做法在并发情况下会产生竞态条件。整个下单流程的 Service 方法加上Transactional(rollbackFor Exception.class)任何一步抛异常前面插入的订单主表记录、删除的购物车记录都会自动回滚。为什么特别强调rollbackFor Exception.class因为 Spring 默认只在遇到运行时异常时才回滚事务如果代码抛的是受检异常事务不会回滚数据就会出现部分写入的脏状态。这个细节是事务配置中最容易被忽视的坑。3.6 管理员接口权限控制前端路由做了权限区分后端接口也要跟着控制。管理员接口和普通用户接口必须隔离。我的方案是配置两个不同的拦截器匹配规则/api/admin/**走 AdminInterceptor/api/**走 AuthInterceptor。AdminInterceptor 除了解析 token还会额外校验用户角色是否为管理员。这个方案在前期快速开发中够用。如果项目继续迭代引入 Spring Security 或 Sa-Token 这样的权限框架会让权限控制的粒度更细比如对不同管理员角色分配不同的操作权限。4. Vue3 前端核心实现与前后端联调4.1 前端脚手架搭建与目录规划前端用 Vite 构建比 Webpack 启动速度快很多开发体验更舒服。安装依赖时Vue3 对应的状态管理库我用 Pinia 而不是 Vuex因为 Pinia 的 API 更简洁天然支持 Composition API在 Vue3 中才是主流。路由用 Vue Router 4。前端目录结构按照“视图 组件 状态 工具”来组织src ├── api # 接口请求封装按模块分文件 ├── assets # 静态资源 ├── components # 通用组件分页、弹窗、上传等 ├── router # 路由配置 ├── stores # Pinia 状态模块user、cart ├── utils # 工具函数Axios实例、token管理 ├── views # 页面级组件 │ ├── home │ ├── product │ ├── cart │ ├── order │ ├── user │ └── admin └── App.vue4.2 Axios 拦截器统一处理认证与错误Axios 拦截器是整个前端请求管道的核心。我在请求拦截器中统一从 localStorage 取出 token 并塞进请求头在响应拦截器中统一处理业务错误码和 HTTP 状态码。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 200) { return res } if (res.code 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(res.message) return Promise.reject(new Error(res.message)) }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(网络异常请稍后重试) return Promise.reject(error) } )这里有一个容易被新手忽略的点在响应拦截器里直接调用ElMessage.error会把错误信息统一弹出来但是有些业务场景如表单提交我们希望自己控制错误提示的位置和样式所以拦截器里做了一个判断只有 code 不为 401 时才统一弹错对于特殊业务错误可以在调用处设置一个silent参数跳过。组件库里像 Element Plus 的 Message 是全局单例的在非组件文件中也能正常调用。4.3 基于 Vue Router 的动态路由与权限控制前端路由分为三个部分公共页面首页、商品列表、商品详情、用户页面购物车、订单中心、管理员页面商品管理、订单管理、用户管理。为了不让普通用户通过地址栏直接访问管理页面我在路由配置中为管理员页面添加了meta: { requiresAdmin: true }并通过全局前置守卫做拦截。router.beforeEach((to, from, next) { const token localStorage.getItem(token) const userInfo JSON.parse(localStorage.getItem(userInfo) || {}) if (to.meta.requiresAdmin) { if (userInfo.role ! ADMIN) { next(/403) return } } if (to.meta.requiresAuth) { if (!token) { next(/login) return } } next() })这只是前端的防君子不防小人的方案真正权限校验必须后端配合因为接口是可以被外部工具直接调用的。前端做路由拦截是为了用户体验遇到没有权限的情况提示“无权限访问”而不是跳转到一个空白页后端做接口鉴权才是真正的安全防线。购物车状态我用 Pinia 管理。为什么不用组件本地状态因为购物车图标上的数量角标在导航栏中而购物车列表在另一个页面两者需要共享数据。如果靠组件通信或 localStorage 事件监听每次修改购物车都要手动同步极易出差错。Pinia 中定义一个cartStore提供addToCart、removeFromCart、updateQuantity等 action任何组件调用后角标数量自动变化。4.4 商品详情页与图片放大效果商品图片和详情在 Vue3 中实现时最实用的是图片预览功能。Element Plus 的el-image组件提供了preview-src-list属性可以实现点击图片全屏预览并支持多图切换。这个组件底层用的是 photoswipe 类似的机制已经封装好了不需要额外引入第三方图片查看库。商品规格选择是详情页交互的一大重点。我在前端维护一个选中的 SKU 对象当用户点击某个规格按钮时按钮的class动态绑定激活状态同时更新价格展示和“库存是否充足”的提示。每次选中规格后组件需要自动调用后端查询该 SKU 的最新库存因为限量周边的库存可能在几秒钟内就变化了。4.5 购物车与订单结算流程实现购物车结算流程的交互细节很多用户修改某个商品数量时购物车的水波效果要不要同步更新全选按钮和单选按钮之间的联动清空已选商品的二次确认弹窗订单确认页展示收货地址和商品清单。订单提交的表单我做了一个防重复提交处理点击提交按钮后立即禁用按钮并显示 loading 状态直到接口返回或者超时。这个优化非常必要网络慢的时候用户很容易连续点两三次提交后端即使做了事务保护也会查出重复订单号。地址管理模块使用 Element Plus 的el-dialog弹窗嵌套表单实现。弹窗打开时初始化为空表单编辑时回填当前数据。我遇到过一个经典问题编辑完地址之后再次打开新增地址弹窗表单里还是上一次的数据。原因是弹窗关闭时表单组件没有销毁value 还留在内存里。解决办法是在el-dialog上使用destroy-on-close属性确保每次打开都是全新的表单实例。5. 数据库配置与项目部署上线5.1 MySQL 数据库初始化与环境准备项目使用 MySQL 8.0 作为数据库字符集固定为 utf8mb4排序规则使用 utf8mb4_general_ci 或 utf8mb4_unicode_ci。utf8mb4 与 utf8 的区别这里要再重点说一句utf8 在 MySQL 中最多支持 3 字节的字符emoji 表情是 4 字节存进去会直接报错utf8mb4 是完整的 UTF-8 实现。明星周边商品的名称、粉丝留言评论中完全可能出现表情符号所以必须使用 utf8mb4。spring: datasource: url: jdbc:mysql://localhost:3306/xingzhiyu?useUnicodetruecharacterEncodingutf8mb4useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver连接串中的serverTimezoneAsia/Shanghai很关键。MySQL 8.0 默认时区与 JVM 时区可能不一致导致时间字段插入后相差 8 小时。遇到时间不准的问题优先检查这里。allowPublicKeyRetrievaltrue是为了解决 MySQL 8.0 的 caching_sha2_password 认证插件在非 SSL 连接下报错的问题。5.2 MyBatis 配置与 SQL 日志打印开发阶段需要打印 SQL 日志来排查问题MyBatis 的配置我分三级application.yml中设置mybatis.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl可以输出完整 SQL更精细的控制是通过日志级别将特定 mapper 包的日志级别设为 DEBUG生产环境保持 INFO。mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.xingzhiyu.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl logging: level: com.xingzhiyu.mapper: debugmap-underscore-to-camel-case: true自动将数据库的下划线字段映射为 Java 的驼峰属性例如数据库字段create_time自动对应实体类createTime。这个配置能减少大量无价值的 resultMap 映射编写。需要注意的是如果实体类中出现了 SQL 别名例如SELECT create_time AS createTime那么驼峰映射反而可能不起作用因为别名已经覆盖了原始字段名。5.3 前端打包与 Nginx 部署前端开发完成后执行npm run build生成 dist 目录将静态文件部署到 Nginx。前后端分离项目的 Nginx 配置有一个核心逻辑静态资源请求直接返回文件API 请求反向代理到 SpringBoot 后端。server { listen 80; server_name xingzhiyu.example.com; root /usr/share/nginx/html/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files $uri $uri/ /index.html这行几乎是 Vue Router history 模式部署的标配。因为前端路由是没有真实文件存在的直接访问http://域名/product/detail/1001这样的地址刷新页面时Nginx 找不到对应文件会返回 404。try_files 将所有请求先映射到 index.html再由前端路由接管。如果部署时发现除了首页、其他页面刷新都报 404问题基本就出在这里。5.4 跨域问题怎么处理开发环境前后端分别跑在不同端口Vite 默认端口是 5173SpringBoot 是 8080这时必然产生跨域。我的处理方案分两层后端层面在 SpringBoot 中配置全局 CORSConfiguration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }前端层面Vite 在开发模式下配置代理// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这两种方式在一个项目中同时使用时要注意IllegalArgumentException 这个报错很典型就是同时设置了允许所有来源和携带凭证。用allowedOriginPatterns(*)搭配allowCredentials(true)是向后兼容的写法直接用allowedOrigins(*)会导致报错。6. 开发避坑指南与常见问题自查6.1 代码层面高频问题MyBatis 的 XML 文件不生效检查mapper-locations配置是否正确XML 文件是否放置在 resources 目录下。使用 IDEA 时经常出现 XML 文件在 src/main/java 目录下代码编译时没有被复制到 target 目录。解决方案是将 XML 文件统一放到src/main/resources/mapper/下。另一个检测技巧在接口方法上加了Select注解又在 XML 中定义了相同的 SQLMyBatis 会优先执行 XML 内容注解失效这种重复定义容易造成困惑。SpringBoot 项目启动报端口被占用Linux 或 macOS 执行lsof -i:8080找到占用进程Windows 执行netstat -ano | findstr 8080。更彻底的方式是在application.yml中指定一个相对少用的端口比如 8081、8090避免和本机其他 Java 服务的默认端口冲突。日期类型 JSON 序列化格式不对后端 LocalDateTime 返回给前端默认是数组格式。在application.yml中配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8Vue3 中拿不到后端返回的数据Vue3 的响应式机制是 Proxy 代理解构操作会破坏响应式。在setup中直接解构ref或reactive对象然后赋值给一个新的局部变量模板中引用这个局部变量就丢失了响应性。用toRefs包裹或者直接使用.value访问。6.2 业务逻辑层面高频问题下单时库存被扣减但订单未生成这个问题的根源在于库存扣减和订单创建不在同一个事务中。SpringBoot 中默认事务只对 public 方法生效并且要确保事务方法是通过 Spring 代理调用的同类内部方法直接调用事务是不生效的因为自调用不会经过代理对象。解决方法是把库存扣减逻辑拆分到独立 Service或者用AopContext.currentProxy()显式获取代理对象。用户登录状态断电后失效JWT 存在 localStorage 或 sessionStorage 中浏览器关闭再打开后 sessionStorage 会清空localStorage 则保留。为了让用户登录状态持久我统一将 token 和用户基础信息存在 localStorage 中。如果是安全敏感度更高的系统可以改为 httpOnly Cookie CSRF Token 的方案但学习项目用 localStorage 足够。数据库连接过多导致数据库崩溃如果项目并发量变大应用会报告Too many connections。原因通常是系统未配置连接池上限。我使用的是 HikariCPSpringBoot 默认内置需要显式设置maximum-pool-size: 20。同时要限制 MySQL 服务端的max_connections并建立慢查询日志排查是否有接口忘记释放连接。6.3 MyBatis 缓存相关心得MyBatis 默认开启一级缓存即 SqlSession 级别的缓存同一个 SqlSession 中执行两次相同的查询只会查一次数据库。但在 Spring 整合环境下每次 Mapper 调用通常是独立的 SqlSession一级缓存的效果有限。二级缓存默认关闭需要手动开启并且存在分布式环境下的脏读问题——多个实例同时更新同一张表缓存无法感知其他实例的数据变化。对于这个项目我没有开启二级缓存商品列表页的数据变化频率低且查询量大我用 Redis 做了单品维度的缓存而不是用 MyBatis 的缓存因为可以控制缓存的失效时机在后台修改商品后主动删除对应 key。6.4 数据迁移与初始化开发过程中经常要重置数据库。我把初始化数据整理成schema.sql和data.sql利用 SpringBoot 的spring.sql.init.modealways让项目启动时自动执行。但要注意正式上线时要将此配置改为never否则每次重启都会清空数据或报主键冲突。如果项目要迁移到服务器可以用mysqldump -u root -p xingzhiyu backup.sql导出目标机器用mysql -u root -p xingzhiyu backup.sql导入。导入时优先检查目标库是否已创建以及字符集是否一致。7. 项目效果与可扩展方向整个“星之语”系统开发完成后功能上已经覆盖了明星周边商品销售的完整业务闭环用户浏览首页看到热门明星和推荐商品点击商品分类进入列表通过关键词搜索和筛选快速定位心仪商品商品详情页展示多图、价格、规格、库存加入购物车后可以调整数量结算时选择收货地址、提交订单、模拟支付订单中心实时查看待付款、待发货、已发货、已完成的订单状态并支持确认收货。管理端完成了商品上架与编辑、库存调整、分类管理、订单发货、公告维护等日常运营功能。如果继续迭代扩展有几条明确的技术路线接入真实支付在当前模拟支付回调接口的基础上对接支付宝当面付或微信 Native 支付核心是把模拟支付接口替换为真实支付 SDK 调用同时完善支付状态的异步回调处理。引入 Redis 缓存将首页轮播、热门商品列表等读取量大的数据缓存到 Redis配合 Spring Cache 注解使用可以显著提升接口响应速度。搜索层面升级当前商品搜索使用 MySQL LIKE 模糊查询当数据量达到十万级以上建议引入 Elasticsearch 做商品搜索支持更复杂的评分排序和分词匹配。秒杀场景支持明星周边经常做限时秒杀可以通过 Redis 预扣库存 MQ 异步创建订单的模式应对瞬时高并发但这部分需要单独设计一套秒杀接口与常规购物流程隔离。一个值得坦诚说明的点是这个项目的定位是“完整可运行、可学习、可二次开发”并非高并发级别的生产系统。真要部署到线上服务上万真实用户还需要在监控、限流、日志采集、容器化部署等方面做大量加固。但从学习和面试准备的视角来看这套系统的功能完整度、代码可读性、技术栈主流程度已经足够了——把核心业务逻辑吃透面试官问到 SpringBoot 事务、MyBatis 动态 SQL、JWT 认证、前后端分离部署这类问题时你都能拿出真实的项目经验来回答这比背八股文有意义得多。我在实际开发这个项目的过程中最大的感受是技术选型上的稳定和克制非常重要。SpringBoot、Vue3、MyBatis、MySQL 这套组合虽然“常规”但正是因为大量生产项目都在使用网上能查到的资料、能避开的坑都非常充分。把一套常规技术栈用得足够扎实比一门心思追新框架、把项目堆得花里胡哨要有价值得多。希望这篇拆解能帮到正在做类似项目的朋友有问题欢迎交流。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。