拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于Spring MVC与MyBatis的食品商城系统开发实践

## 1. 项目实战背景与技术选型1.1 为什么选择食品零食商城作为实战项目做这个基于Spring MVC和MyBatis的网上食品零食商城系统的最初动机说起来有点现实——课程设计和毕业设计这两座大山几乎把Java Web方向的经典题目包圆了。电商商城类项目经久不衰是因为它麻雀虽小五脏俱全前台要折腾商品展示、搜索、购物车、下单后台要处理商品管理、订单状态、库存扣减、用户管理基本上把Web开发里最常见的痛点全踩了一遍。而食品零食这个细分方向比做通用的XX商城更有辨识度答辩演示的时候也不会跟隔壁同学撞车。这个项目的定位很明确一个前后端分离的轻量级电商Demo。后端用Spring MVC提供RESTful接口MyBatis负责数据库操作前端用Vue3做页面渲染和交互。对于Java Web方向的学习者来说这套技术组合刚好卡在从SSH/Servlet旧时代迈向Spring Boot体系的中间地带兼顾了传统框架的理解和现代化前端的使用面试时也有的聊。1.2 技术选型背后的取舍逻辑先说一个很多人不理解的问题都2025年了为什么不直接用Spring Boot非要抱着Spring MVC老一套不撒手我的看法是这样如果你是为了做项目而做项目直接用Spring Boot没问题甚至效率高得多。但如果你是为了搞懂Web框架的底层逻辑、为了应付考试和面试里的Spring MVC工作流程那从Spring MVC手写配置走一遍价值非常大。把DispatcherServlet、HandlerMapping、视图解析器这些组件挨个在web.xml和配置类里配一遍之后再去看Spring Boot的自动配置那些魔法就全变成明牌了。至于MyBatis选择它而不是JPA/Hibernate核心原因是可控性和贴近业务。商城系统的SQL复杂度不算低——多表关联查询、动态条件拼接、批量插入订单项MyBatis的动态SQL把这类需求拿捏得很舒服。编写SQL的人能精确控制最终执行的语句这在排查性能问题、优化索引时优势明显。Hibernate虽然开发效率高但碰到复杂查询要调优时自动生成的SQL往往让人一头雾水。前端选Vue3也不是追新而是它确实适合这个场景渐进式框架从一个商品列表页开始按需引入路由、状态管理不会一上来就把人劝退Composition API处理商城这种逻辑聚合的场景代码组织比Options API清晰得多周边生态成熟Element Plus、Vant等组件库一套下来商城页面的搭建速度非常快这套技术栈的组合还有一个隐形好处——能覆盖的知识点足够广。Spring IoC/DI、Spring MVC请求流程、MyBatis动态SQL与缓存、Vue组件通信、Axios异步请求、跨域处理随便挑一个点都能在面试时展开聊上十分钟。1.3 整体架构与数据流向设计先画一个宏观的业务数据流向图来梳理思路不涉及任何画图工具直接用文字描述用户打开前端页面Vue3→ 通过Axios发送HTTP请求 → 后端DispatcherServlet接收 → HandlerMapping找到对应的Controller → Service层处理业务逻辑 → Mapper接口调用MyBatis → SQL操作MySQL数据库 → 结果逆向返回到页面渲染这个请求-响应链路是理解整个项目的一根主线。明确了这条线后面排查问题就有了基本方向前端报错先看网络请求是否发出、状态码是什么后端报错先看Controller是否收到请求、Service层是否执行成功数据库出问题则看SQL语句是否正确执行。项目在结构上做了前后端分离实际上部署是两个独立进程后端跑在Tomcat端口8080前端用Vite开发服务器端口5173或打包后由Nginx托管端口80。开发阶段需要解决跨域问题这个后面联调部分会详细说。2. 后端核心实现与MyBatis深度应用2.1 数据库设计与表结构规划商城系统的数据库设计核心围绕商品-用户-订单三个业务主体展开。我设计的这套零食商城库一共7张表结构如下user用户表id、username、passwordMD5加密存储、nickname、phone、address、create_timecategory商品分类表id、name、parent_id支持二级分类、sort_orderproduct商品表id、category_id、name、subtitle副标题、main_image、price、stock、status上下架状态、sales销量、create_timecart_item购物车表id、user_id、product_id、quantity、checked是否选中、create_timeorder订单表id、order_no订单编号用时间戳随机数生成、user_id、total_price、status订单状态待付款/已付款/已发货/已完成/已取消、create_timeorder_item订单明细表id、order_id、product_id、product_name、product_image、current_price下单时的价格快照、quantity、total_pricebanner轮播图表id、image_url、link_url、sort_order这里面有两个值得细说的设计决策。第一个是订单明细为什么要冗余商品名称和图片字段。商品的价格、名称随时可能修改如果订单明细直接关联商品表用户查看历史订单时显示的可能是改价之后的数据这会造成纠纷隐患。用快照方式把下单那一刻的商品信息存下来既保证订单数据稳定又简化了查询逻辑。这种冗余在电商设计里是标准操作面试官问起订单表怎么设计时可以主动提这点。第二个是购物车表和订单表分开设计。购物车是用户维度的临时状态随时可以增删改不需要事务保护订单是交易数据必须严格保证一致性。把两者分开事务边界就清晰了——购物车操作不需要开启事务下单操作则必须用Transactional包裹整个过程校验库存→扣减库存→创建订单→清空购物车。2.2 Spring MVC分层设计与开发要点后端代码采用经典的四层结构Controller层 → Service层 → Mapper层 → 数据库。以一个商品列表接口为例处理过程是这样的Controller接收前端请求参数分类ID、页码、每页条数调用Service层Service层组装分页查询条件调用Mapper接口Mapper映射到ProductMapper.xml中对应的SELECT语句返回结果再层层向上传递。在Spring MVC的配置上我建议即使你之后会用Spring Boot也至少手写一遍以下内容1. 依赖引入pom.xml核心部分dependencies dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.3.30/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.15/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.1.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.20/version /dependency dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.15.2/version /dependency /dependencies2. Spring MVC核心配置spring-mvc.xmlmvc:annotation-driven / mvc:default-servlet-handler / context:component-scan base-packagecom.snack.controller / bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views/ / property namesuffix value.jsp / /bean虽然前后端分离后JSP视图基本用不上但InternalResourceViewResolver还是要保留因为返回JSON时Spring会通过ResponseBody处理不依赖视图解析器。真正关键的是mvc:annotation-driven /它负责注册RequestMappingHandlerAdapter、RequestMappingHandlerMapping等核心组件没有它Controller和RequestMapping就是一堆废注解。3. web.xml配置部署描述文件servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping filter filter-nameencoding/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param /filter这里有个小坑字符编码过滤器必须放在其他过滤器之前而且url-pattern要用/*而不是/不然POST请求的中文参数照样乱码。Spring MVC的DispatcherServlet用的是/拦截的是所有请求但不会拦截JSP而编码过滤器必须/*全拦截。2.3 MyBatis避坑指南缓存、SQL打印与动态SQL说一句得罪框架的话MyBatis的坑十个有九个不在框架本身而在对它的理解不够。这一部分真心建议反复阅读。第一个话题MyBatis缓存。MyBatis内置了一级缓存和二级缓存。一级缓存是SqlSession级别的默认开启同一个SqlSession中执行相同的SQL会直接返回缓存结果。听着挺好但有个经典大坑如果你在一个SqlSession里先查询一条用户数据然后执行了任何一次增删改操作一级缓存就被清空了再次查询会重新查数据库。这是MyBatis自动处理事务一致性的机制本身没有问题问题在于很多人不知道这个机制误以为缓存是坏的失效了。二级缓存是Mapper级别的需要在Mapper XML中手动开启。但在大多数互联网业务里不建议开启。原因很简单二级缓存默认不感知多表关联的数据变更假如Product表和Category表关联查询后缓存了结果此时修改了Category表的名字Product的缓存并不会自动失效等你查询商品时拿到的是还是旧分类名。商城系统对这种数据一致性要求很高所以我的做法是——直接关闭二级缓存依赖MySQL自身的查询优化和合理的索引。你要知道MyBatis的缓存和Redis缓存是两码事别被面试题里的MyBatis缓存机制绕晕了。第二个话题SQL日志打印。调试MyBatis SQL最直接的工具是配置日志输出。在你的mybatis-config.xml里加上configuration settings setting namelogImpl valueSLF4J / /settings /configuration然后在log4j2.xml里把Mapper接口所在包的日志级别设为DEBUGLogger namecom.snack.mapper levelDEBUG/这样每执行一条SQL控制台就会打印完整信息包括Preparing: SELECT * FROM product WHERE category_id ?和Parameters: 3(String)。那个叫IDEA MyBatis Log Free的插件我知道很多人喜欢用。我个人的建议是在项目调试阶段学会看原始日志就够了插件只是一个锦上添花的工具它能帮你把SQL和参数拼成一条可直接执行的完整语句在某些时候排查确实方便但别产生依赖。关键还是建立看日志定位SQL问题的思维习惯。第三个话题动态SQLif标签的常见错误。MyBatis动态SQL的核心是那几个标签if、choose、where、set、foreach。以商品条件查询为例select idsearchProducts resultTypecom.snack.entity.Product SELECT * FROM product where if testcategoryId ! null and categoryId ! AND category_id #{categoryId} /if if testkeyword ! null and keyword ! AND name LIKE CONCAT(%, #{keyword}, %) /if if testminPrice ! null AND price #{minPrice} /if if testmaxPrice ! null AND price lt; #{maxPrice} /if /where ORDER BY choose when testsortBy salessales DESC/when when testsortBy priceAscprice ASC/when otherwisecreate_time DESC/otherwise /choose /select这里必须注意三个细节where标签能智能处理开头的AND但不要在if内部写SELECT语句的固定前缀比如WHERE 11那个写法虽然能用但不推荐where本来就是为了干掉这种情况设计的小于等于符号在XML里必须转义成lt;不转义直接写会报XML解析错误if test里用的是OGNL表达式字符串比较要写! 数值类型直接! null就行两者混着写容易出空指针第四个话题MyBatis和MyBatis-Plus的区别。项目做完了之后你大概率会接触到MyBatis-Plus面试也可能问。简单说一下MyBatis-Plus是MyBatis的增强工具它在不改变MyBatis底层机制的前提下提供了BaseMapper、条件构造器QueryWrapper、分页插件等能力。单表CRUD你不需要再写SQL一个继承BaseMapper的接口直接搞定。但它的底层还是MyBatis那套流程只是把通用的增删改查SQL帮你自动拼好了。理解MyBatis的动态SQL和SQL执行机制之后用MyBatis-Plus会顺手很多。不过在网上商城这种复杂业务场景多表查询依然要自己写SQLPlus的Wrapper再方便也替代不了SQL基本功。还有一个相关的冷知识点MySQL 8.0以上的版本MyBatis连接数据库时如果表不存在官方并不支持自动建表。你需要用schema.sql配合Spring的SQL_INIT脚本初始化或者在Java代码里启动时执行建表DDL。如果是MyBatis-Plus还可以配置ddl-auto但原生的MyBatis没有这个自动化能力。这个当表不存在自动建表的热搜词其实就是很多人在环境迁移时被坑了手动执行一遍SQL脚本的事别过于追求自动化。2.4 关键业务接口设计购物车与下单商城系统的订单流程是整个项目事务控制的重点。以下单接口为例我直接展示Controller和Service的核心代码RestController RequestMapping(/api/order) public class OrderController { Autowired private OrderService orderService; PostMapping(/create) public Result createOrder(RequestBody ListCartItemForOrder cartItems, RequestAttribute(userId) Integer userId) { try { Order order orderService.createOrder(userId, cartItems); return Result.success(order); } catch (BizException e) { return Result.error(e.getMessage()); } } }Service public class OrderServiceImpl implements OrderService { Autowired private ProductMapper productMapper; Autowired private OrderMapper orderMapper; Autowired private CartItemMapper cartItemMapper; Override Transactional(rollbackFor Exception.class) public Order createOrder(Integer userId, ListCartItemForOrder cartItems) { // 1. 闭环校验遍历购物车查询商品当前库存和价格 BigDecimal totalPrice BigDecimal.ZERO; ListOrderItem orderItems new ArrayList(); for (CartItemForOrder cartItem : cartItems) { Product product productMapper.selectById(cartItem.getProductId()); if (product null || product.getStatus() ! 1) { throw new BizException(商品【 cartItem.getProductName() 】已下架); } if (product.getStock() cartItem.getQuantity()) { throw new BizException(商品【 product.getName() 】库存不足剩余 product.getStock() 件); } // 2. 扣减库存必须使用条件更新防止超卖 int updated productMapper.deductStock(product.getId(), cartItem.getQuantity()); if (updated 0) { throw new BizException(商品【 product.getName() 】库存扣减失败); } // 3. 组装订单明细保存价格快照 OrderItem item new OrderItem(); item.setProductId(product.getId()); item.setProductName(product.getName()); item.setProductImage(product.getMainImage()); item.setCurrentPrice(product.getPrice()); item.setQuantity(cartItem.getQuantity()); item.setTotalPrice(product.getPrice().multiply(BigDecimal.valueOf(cartItem.getQuantity()))); orderItems.add(item); totalPrice totalPrice.add(item.getTotalPrice()); } // 4. 创建订单主表 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setTotalPrice(totalPrice); order.setStatus(0); // 待付款 orderMapper.insert(order); // 5. 批量插入订单明细 for (OrderItem item : orderItems) { item.setOrderId(order.getId()); orderItemMapper.insert(item); } // 6. 清空已购买的购物车项 cartItemMapper.deleteByIds(cartItems.stream().map(CartItemForOrder::getId).collect(Collectors.toList())); return order; } private String generateOrderNo() { return System.currentTimeMillis() (int)((Math.random() * 9 1) * 100000); } }这段代码里最关键的技巧就是扣库存的SQL写法。在ProductMapper.xml中update iddeductStock UPDATE product SET stock stock - #{quantity}, sales sales #{quantity} WHERE id #{productId} AND stock #{quantity} /update为什么要加AND stock #{quantity}这个条件因为在并发场景下两个用户同时下同一款零食的最后一件库存如果先SELECT库存再UPDATE有可能都读到库存1都执行扣减结果库存变成-1出现超卖。用条件更新的方式数据库在UPDATE时会自动加行锁只有库存足够时才会更新成功updated0就说明库存被抢完了。这个方案比先SELECT再UPDATE安全得多也比用同步锁高效得多。事务注解的坑也顺便说一句Transactional默认只对RuntimeException回滚受检异常Exception的子类默认不回滚。所以rollbackFor一定要写成Exception.class不然业务异常导致订单数据就处于创建一半的脏状态。我一开始没注意这个问题测试时手动抛了个BizException结果订单主表插入了明细表没插入数据直接对不上了。这个坑踩得值。3. 前端Vue3商城搭建与联调3.1 Vue3项目初始化与目录规划前端开发环境搭建我的推荐路径是Node.js18以上版本→ 使用Vite创建Vue3项目 → 安装Vue Router、Pinia、Axios、Element Plus。创建项目命令很简单npm create vitelatest snack-mall-frontend -- --template vue创建完成后进入目录安装依赖cd snack-mall-frontend npm install npm install vue-router4 pinia axios element-plus目录结构我习惯这样组织src/ ├── api/ # 接口请求封装按模块拆分 │ ├── product.js # 商品相关接口 │ ├── cart.js # 购物车接口 │ └── order.js # 订单接口 ├── assets/ # 静态资源 ├── components/ # 公共组件Header、Footer、ProductCard等 ├── router/ # vue-router配置 ├── stores/ # Pinia状态管理 ├── views/ # 页面组件 │ ├── Home.vue # 首页 │ ├── ProductList.vue # 商品列表页 │ ├── ProductDetail.vue# 商品详情页 │ ├── Cart.vue # 购物车页 │ ├── OrderConfirm.vue # 订单确认页 │ ├── OrderList.vue # 订单列表页 │ └── Login.vue # 登录/注册页 ├── utils/ # 工具函数如axios实例、时间格式化 └── App.vue3.2 商品列表与购物车的Vue3实现要点商品列表页是商城系统的门面也是最能体现Vue3基本功的部分。关键点有三个第一Composition API怎么组织商品筛选逻辑。用ref管理筛选条件用reactive管理商品数据再配合watch监听条件变化自动重新请求script setup import { ref, reactive, watch, onMounted } from vue import { getProducts } from ../api/product const loading ref(false) const categoryId ref(0) const keyword ref() const sortBy ref(default) const page ref(1) const pageSize ref(12) const products ref([]) const total ref(0) async function loadProducts() { loading.value true try { const res await getProducts({ categoryId: categoryId.value, keyword: keyword.value, sortBy: sortBy.value, page: page.value, pageSize: pageSize.value }) products.value res.list total.value res.total } finally { loading.value false } } watch([categoryId, keyword, sortBy, page], loadProducts) onMounted(loadProducts) /script第二Vue3子父组件通信例如ProductCard就是一个典型子组件。父组件传商品数据用props子组件通知父组件加入购物车则用emit// 子组件 ProductCard.vue script setup const props defineProps({ product: { type: Object, required: true } }) const emit defineEmits([add-to-cart]) function handleAddCart() { emit(add-to-cart, props.product) } /script// 父组件 ProductList.vue template ProductCard v-forproduct in products :keyproduct.id :productproduct add-to-carthandleAddToCart / /template第三Vue3的生命周期函数和Vue2的差异。Vue3的onMounted、onUpdated、onUnmounted对应Vue2的mounted、updated、beforeDestroy。created阶段的逻辑在Vue3的script setup里直接写就行了整个组件的顶层代码就是原来的created钩子体。还有beforeDestroy改名成了onBeforeUnmount这个在记忆上容易混淆建议自己做个表格理一遍。购物车状态用Pinia管理是最合适的选择。多个页面商品列表页、商品详情页、购物车页都要读取和修改购物车数据用Pinia把购物车数量和商品列表提升为全局状态避免每个页面各自存一份还要互相同步// stores/cart.js import { defineStore } from pinia export const useCartStore defineStore(cart, { state: () ({ items: JSON.parse(localStorage.getItem(cartItems) || []) }), getters: { totalCount: (state) state.items.reduce((sum, item) sum item.quantity, 0), totalPrice: (state) state.items.reduce((sum, item) sum item.price * item.quantity, 0) }, actions: { addItem(product, quantity 1) { const existing this.items.find(item item.productId product.id) if (existing) { existing.quantity quantity } else { this.items.push({ productId: product.id, name: product.name, price: product.price, image: product.main_image, quantity }) } localStorage.setItem(cartItems, JSON.stringify(this.items)) }, removeItem(productId) { this.items this.items.filter(item item.productId ! productId) localStorage.setItem(cartItems, JSON.stringify(this.items)) } } })购物车数据存localStorage的好处是用户关掉浏览器再打开数据还在同时又不需要真的调用后端接口加购物车静态购物车演示够用了。但注意如果你要把购物车保存到数据库方便跨设备同步那Cart API还是得完整做一遍。我这个项目的策略是未登录用户购物车存localStorage登录用户购物车数据从后端拉取并合并。3.3 前后端联调跨域、Axios封装与接口对接前后端分离开发最烦的就是跨域。开发阶段最简单的方案是后端在Spring MVC配置里加CORS全局配置Configuration public class WebConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:5173) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }这里有个细节allowedOrigins如果在配置了allowCredentials(true)的情况下不能写成*必须写明具体域名。不然登录态Cookie传不过去。另一个方案是在前端开发服务器上配置server.proxy代理把/api前缀的请求转发到后端8080这样浏览器看到的是同源请求也能绕开跨域生产环境再用Nginx做同样的反向代理// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })Axios封装是另一个重点。我习惯在utils/request.js里创建一个统一实例配置基础URL、超时时间、请求拦截器和响应拦截器import axios from axios import { ElMessage } from element-plus import router from ../router const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器自动携带token 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) { return res.data } else if (res.code 401) { localStorage.removeItem(token) router.push(/login) return Promise.reject(new Error(未登录)) } else { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } }, error { ElMessage.error(error.response?.data?.message || 网络异常) return Promise.reject(error) } ) export default request这个拦截器帮我省掉了80%的重复代码——每个页面直接调用API模块的方法然后处理数据就行不需要在每个页面里都判断后端返回的状态码。这个思路在任何前后端分离项目里都适用统一处理好错误业务代码只关注成功路径。前后端联调时经常会遇到Vue3在Edge浏览器里偶尔关不掉右上角最小化按钮的古怪问题这实际上是Vite开发服务器在Edge浏览器下的兼容性Bug多数情况下刷新页面就好不用过度紧张。更值得关注的是接口联调时的参数格式问题因为后端用了RequestBody接收JSON对象前端在POST请求里一定别把对象手动拼成查询字符串直接axios的data字段传对象就行。否则后端解析出来的字段全是null排查半天才发现是参数发送格式不对。4. 常见问题与排查技巧实录4.1 高频问题速查表项目做到后面踩坑记录越积越多。我把最经典的十几个问题整理成一张速查表方便遇到问题的时候直接翻症状可能原因排查方向与解法前端请求404接口路径没对上检查Controller的RequestMapping路径、方法上的路径、前端api模块的路径三者是否完全一致前端请求500后端代码抛异常看后端控制台完整堆栈重点看Caused by部分Ajax跨域报错未配置CORS后端WebConfig加CORS映射或用Vite proxy代理获取不到Session跨域配置allowCredentials没写CORS配置里必须allowCredentials(true)且匹配具体origin中文乱码编码过滤器配置不当确认CharacterEncodingFilter设置在DispatcherServlet之前且url-pattern为/*MyBatis查询结果为空SQL查出来就是空先在数据库工具里直接执行SQL验证再排查Mapper XML参数绑定传参为null前端JSON字段名与后端不一致检查前端驼峰命名vs后端下划线命名用JsonProperty或配置文件映射插入数据时间不对时区问题数据库连接URL加serverTimezoneAsia/Shanghai上传图片失败后端静态资源映射没配配置addResourceHandlers指向本地上传目录修改样式不生效Vite缓存硬刷新浏览器CtrlShiftR或重启dev server内存溢出OOM分页查询没带条数限制检查所有SELECT是否都有LIMIT特别注意联表查询4.2 我被卡过的三个真实问题第一个坑MyBatisif标签导致查询结果异常。商品列表页关键字搜索功能我一开始用的是LIKE %${keyword}%这种字符串拼接方式结果每次搜索薯片控制台里打印的SQL都对但数据库里明明有薯片相关的商品就是查不出来。排查了很久最后发现是中文编码问题——SQL语句里直接拼入了中文字符串但MySQL连接的characterEncoding参数没有被正确处理。后来改用#{keyword}参数占位符问题就消失了。这也是MyBatis推荐参数占位符而不是字符串拼接的原因之一防止SQL注入只是其一保持参数编码一致性同样重要。第二个坑Vue3父组件使用reactive包了一个数组子组件修改数据页面不更新。这个坑的根源是Vue3的响应式代理对数组的处理方式。直接通过索引赋值items[0] newItem不会触发视图更新必须用splice、push这些会触发拦截器的方法或者干脆用ref来管数组。我当时在购物车数量加一的逻辑里用了items[index].quantity页面上数字死活不动后来改成先复制再整体赋值才搞定。这个问题的背后是Vue3响应式原理的依赖收集机制建议花时间把Proxy的getter/setter拦截逻辑过一遍。第三个坑登录状态在F5刷新后丢失。前端的Pinia store保存在内存里刷新页面后状态清空。一开始没加持久化每次刷新都要重新登录演示的时候非常尴尬。解决方案有两种简单的是在store里实现上面代码里localStorage读写项目小够用复杂的是引入pinia-plugin-persistedstate插件自动持久化。我选择了手写localStorage方案顺便理解了状态持久化的原理再去看插件就容易多了。4.3 部署与演示的实战细节部署环节也很关键很多人的项目本地跑得飞起一打包部署就出各种幺蛾子。我的部署方案是后端用Maven打War包部署到Tomcat的webapps目录或者用Spring Boot的方式打Jar包如果你是Spring Boot版本java -jar启动。项目本身是Spring MVC项目所以标准流程是打包War放入Tomcat。注意数据库连接配置、图片上传路径这类环境相关的配置要外置不要写死在代码里不然每次部署都要重新编译。前端npm run build生成dist目录把dist目录下的文件复制到Nginx的html目录或放到后端项目的webapp下让Tomcat托管。我用Nginx托管前端同时配置反向代理将/api请求转发到Tomcat。核心配置server { listen 80; server_name localhost; location / { root /usr/share/nginx/html; index index.html; 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模式刷新某个子页面比如/product/12时Nginx在磁盘上找不到对应的物理文件不加这个配置会直接404加了之后会回退到index.html由Vue Router根据URL重新匹配路由。这个系统做完之后商品管理后台的页面我也用Vue3写了一份Element Plus的表格、表单、弹窗组合一下操作效率挺高。这也是Vue3做后台管理系统的优势——组件生态成熟你不需要从零造轮子。如果不想自己写后台GitHub上搜vue3 admin能翻出一堆开源框架拿来即用把接口改一改就能对接你的后端。说实话做完这套系统我最深的感受是一个课程设计级别的商城项目技术用不用新不是第一位的关键在于把每个环节的逻辑理顺、把每个踩过的坑记录下来。用Spring MVC加MyBatis这套旧技术反而逼着你把Java Web最本质的东西——请求是怎么分发到Controller的、SQL是怎么被MyBatis执行的、事务是怎么保证数据一致的——全部弄明白了。有了这个底子再去学Spring Boot、MyBatis-Plus就是水到渠成的事。最后再分享一个小技巧如果你也是做课设或毕设需要录制演示视频建议把代码注释写详细一点视频里讲不清的地方直接指着注释说。我当时就在每个Controller方法上都写了业务场景、参数说明、返回结果三段式注释录视频的时候基本是照着注释念的效率比临时组织语言高得多。项目做完之后也别急着删把它整理成自己的作品集——这是你从会写代码到会做项目之间最扎实的一块垫脚石。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门