Spring MVC + MyBatis + Vue3食品商城系统:从架构设计到演示视频全流程实战
2026/9/9 9:08:26 网站建设 项目流程

这个项目标题一出来,我第一反应就是“标准的Java全栈毕设/实训项目”——Spring MVC做后端控制层,MyBatis管数据库操作,前端用Vue3搭商城界面,最后再录个演示视频。看起来是技术栈比较老派的组合,但仔细一琢磨,这套东西反而是现在Java岗求职和课设选题里最稳的搭配:Spring MVC让你把路由、拦截器、参数绑定这些基础吃透,MyBatis让你把SQL能力练扎实,Vue3又能证明你不是只会写后端接口的“半吊子”。

标题里最值得琢磨的是“视频”两个字。我做过不少类似的项目演示,很多人代码写完了,一到录视频就翻车:要么对着屏幕干讲十分钟没人愿意看,要么演示到一半页面报错就慌了。后面我会专门拿出一整章说清楚录制演示视频的脚本设计、演示节奏和“翻车急救方案”,这部分网上很少有人讲。

这篇文章我会把整个项目的搭建思路、核心表结构、Spring MVC和MyBatis的整合细节、Vue3前台页面的实现方式、MyBatis缓存机制,以及面试官最常追问的问题一次性讲透。适合正在做毕设选题、想进Java岗但项目经验薄弱、或者想从SSM老项目过渡到前后端分离架构的同学参考。整套代码结构我会按照“后端三层架构 + 前端单页应用”来设计,既保留了Spring MVC的教学价值,又能体现Vue3的现代前端开发思路。

1. 项目定位与技术选型思路

1.1 为什么是Spring MVC + MyBatis,而不是直接SpringBoot

很多新手一上来就质疑:都什么年代了,还在用Spring MVC,直接SpringBoot整一个不香吗?我一开始也有这个疑问,但真正把这个项目从头到尾过了一遍之后,我发现了这套组合的独特价值。

SpringBoot确实是当下Java后端的主流脚手架,但Spring MVC作为Spring家族里最核心的Web框架,很多原理性的东西在SpringBoot里被“自动配置”给掩盖了。比如DispatcherServlet是怎么分发请求的、HandlerMapping怎么找到对应的@RequestMapping方法、ViewResolver如何解析视图,这些在SpringBoot里你几乎感知不到,但在Spring MVC项目里你必须手动配出来。

从学习角度来说,自己手动引入spring-webmvc依赖,在web.xml里配置DispatcherServlet,再写一个spring-mvc.xml开启注解驱动和包扫描,你对整个请求流转过程的理解会比直接点开SpringBoot的自动配置类要深刻得多。面试的时候,面试官问“一次HTTP请求从进入到返回,Spring MVC经历哪些环节”,你按这个顺序答:前端控制器DispatcherServlet接收请求、查询HandlerMapping找到Controller、调用HandlerAdapter执行方法、Controller返回ModelAndView、ViewResolver解析视图——这一套答下来,比背SpringBoot源码有说服力。

MyBatis的选择逻辑也是一样的。如果你用MyBatis-Plus,写Mapper接口都有现成的selectPageselectList,SQL都不用手写,但面试官问“#{}${}有什么区别”的时候,你脑子里是没有画面感的。而这个项目里大量用到动态SQL——零食分类的多条件筛选、商品模糊搜索、订单状态统计,你被迫去写<if><where><foreach>这些标签,写完之后再去看那些MyBatis面试题,会发现全是实操中踩过的坑。

1.2 “视频”这个关键词对项目形态的影响

标题里的“视频”不要理解成“视频网站项目”,它讲的是“交付形态”——系统开发完成后需要用视频演示。这个细节影响了我对整个项目架构的判断。

既然要演示,就要保证项目能稳定跑起来,不能一会儿报个错一会儿白屏。所以我选择了前后端分离但部署在同一台机器上的结构:后端打war包丢进Tomcat,前端构建后用Nginx托管静态资源,这两个服务在本机端口互不冲突,演示的时候打开浏览器就能玩,不需要现场切来切去。如果你愣要在演示现场启动IdEA、等待Maven下载依赖、再查数据库密码,那视频录出来基本没法看。

为了演示效果,我在设计商品数据时也特意下了功夫:宁可用真实感强的“三只松鼠坚果礼盒”“辣条大礼包”“无糖气泡水”这类商品,也不要写“零食1”“零食2”这种占位数据。列表页的商品卡片的图片、价格、标签、销量字段都要真实,这样录屏的时候页面才饱满。这一点做项目的人常常忽略,但恰恰是展示环节成败的关键——观众第一眼看的是画面,而不是你的代码。

1.3 核心业务模块拆解

网上食品零食商城系统,核心业务脱离不了“用户-商品-购物车-订单”这条主线。我最终敲定了6个模块:

  • 用户模块:注册、登录、个人信息查看与修改,密码用MD5加盐存储,登录状态用Session管理
  • 商品分类模块:一级分类加二级分类,前台导航按分类展示零食列表
  • 商品模块:零食的分页列表、综合排序/销量排序/价格排序、按名称模糊搜索、商品详情
  • 购物车模块:加入购物车、修改数量、删除购物项、批量结算
  • 订单模块:订单提交、订单列表、订单详情、取消订单、模拟支付
  • 后台管理模块:管理员登录、商品上架下架、编辑库存、查看所有订单

前台用户走商店首页→分类浏览→商品详情→加入购物车→提交订单→支付;后台管理员走商品管理→订单管理。这两条用户路径刚好是演示视频里的主线脚本,后面讲视频录制时我会再展开。

2. 数据库设计与后端核心实现

2.1 表结构设计:从业务推导字段

数据库设计我建议不要直接用网上别人的建表SQL,而是自己先理一遍业务关系再画表。画出5张核心表就够了:

用户表 user - id BIGINT 主键自增 - username VARCHAR(50) 唯一 - password VARCHAR(64) 加盐后的MD5 - nickname VARCHAR(50) - phone VARCHAR(20) - avatar VARCHAR(255) - created_time DATETIME 商品分类表 category - id BIGINT 主键 - name VARCHAR(50) - parent_id BIGINT 父分类ID,一级分类为0 - sort INT 排序权重 商品表 product - id BIGINT 主键 - name VARCHAR(100) - description VARCHAR(500) - price DECIMAL(10,2) - stock INT 库存 - sales INT 销量 - image VARCHAR(255) 封面图URL - category_id BIGINT 关联分类 - status TINYINT 1上架 0下架 - created_time DATETIME 购物车表 cart_item - id BIGINT 主键 - user_id BIGINT - product_id BIGINT - quantity INT - checked TINYINT 是否勾选结算 - UNIQUE KEY(user_id, product_id) 订单表 orders - id BIGINT 主键 - order_no VARCHAR(32) 订单编号 - user_id BIGINT - total_amount DECIMAL(10,2) - status TINYINT 状态码:0待付款 1待发货 2已发货 3已完成 4已取消 - receiver_name VARCHAR(50) - receiver_phone VARCHAR(20) - receiver_address VARCHAR(255) - created_time DATETIME

这里有两个字段要特别说明。第一个是orders表的order_no订单编号,不要直接用主键id展示给用户,我用的规则是“时间戳 + 三位随机数”,比如20250115153000123,长度20位左右,既看起来真实又方便排查。第二个是cart_item表的联合唯一键(user_id, product_id),这一个约束能直接省掉重复加入购物车的问题,在业务代码里就不用先查一遍有没有重复数据再决定insert还是update,直接靠唯一键冲突后走ON DUPLICATE KEY UPDATE搞定,少写不少判断逻辑。

2.2 Spring MVC分层搭建过程

搭建项目的第一步是在IDEA里创建一个Maven webapp工程,然后手动引入依赖。我习惯把依赖一次性配全,避免代码写一半才想起缺东缺西。食品商城项目里最核心的依赖就这13个坐标,放pom.xml的<dependencies>里:

<!-- Spring核心 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.3.39</version> </dependency> <!-- Spring MVC --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.39</version> </dependency> <!-- MyBatis --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.16</version> </dependency> <!-- MyBatis与Spring整合 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.1.2</version> </dependency> <!-- MySQL驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency> <!-- 数据库连接池 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.23</version> </dependency> <!-- Jackson,用于JSON序列化 --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.17.1</version> </dependency> <!-- Servlet API --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- JSTL --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <!-- 文件上传,用于后台管理商品图片 --> <dependency> <groupId>commons-fileupload</groupId> <artifactId>commons-fileupload</artifactId> <version>1.5</version> </dependency> <!-- Lombok,简化实体类 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.32</version> <optional>true</optional> </dependency>

依赖配好之后,最关键的步骤是在web.xml里注册DispatcherServlet。新手容易在这里被绕晕,我一句话说清楚:DispatcherServlet就是整个Spring MVC的大门,所有请求都要先进这个门,所以web.xml里肯定有一个<servlet>标签指向它。再配合ContextLoaderListener加载Spring容器,让Service、Mapper这些Bean也一并创建出来。

<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0"> <!-- 全局上下文配置 --> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-context.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <!-- 前端控制器 --> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> </web-app>

2.3 MyBatis配置:借助插件定位SQL执行问题

spring-mvc.xml里面开启注解驱动并配置视图解析器,spring-context.xml里面配置数据源、SqlSessionFactoryBeanMapperScannerConfigurer,这两段配置是项目能跑起来的命根子。由于代码比较长,我把最关键的一段数据源和MyBatis整合配置贴出来:

<!-- 配置druid数据库连接池 --> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/snack_mall?useUnicode=true&amp;characterEncoding=utf8&amp;serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="你的密码"/> </bean> <!-- 配置MyBatis的SqlSessionFactory --> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="configLocation" value="classpath:mybatis-config.xml"/> <!-- 支持给Mapper接口中的方法起别名等配置 --> <property name="typeAliasesPackage" value="com.snack.entity"/> </bean> <!-- 扫描Mapper接口 --> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.snack.mapper"/> </bean>

MapperScannerConfigurer这个配置让MyBatis在Spring容器启动时自动扫描com.snack.mapper包下的所有接口,并且动态生成代理对象。所以你在Service里使用@Autowired注入一个Mapper接口时,实际注入的是MyBatis生成的一个代理对象,它负责找到XML里对应的SQL并执行。想明白这一点,你就能理解为什么Mapper接口里不能有任何自己想实现的方法,只能写方法签名。

在实际调试过程中,我强烈安利IDEA插件MyBatis Log Free。装上之后,它会自动抓取MyBatis执行SQL时输出的日志,并且把参数值直接拼进SQL里展示出来。以前排查SQL问题,看到控制台输出的???占位符还得自己拿参数值去替换,用了这个插件直接看到完整可执行SQL,一行就能看出来是WHERE条件拼错了还是参数传错了。智能提示的SQL能直接复制到Navicat里去跑,五分钟解决一个排查半小时的bug不是开玩笑。

mybatis-config.xml全局配置文件里,我开了两个核心设置。第一个是<setting name="mapUnderscoreToCamelCase" value="true"/>,打开驼峰命名自动转换,数据库字段created_time就能自动映射到实体类的createdTime属性,省去一堆resultMap的书写量。第二个是<setting name="logImpl" value="STDOUT_LOGGING"/>,让MyBatis在控制台输出完整的SQL日志,方便边跑边看执行过程。

<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "https://mybatis.org/dtd/mybatis-3-config.dtd"> <configuration> <settings> <!-- 开启驼峰命名自动映射 --> <setting name="mapUnderscoreToCamelCase" value="true"/> <!-- 打印SQL到控制台 --> <setting name="logImpl" value="STDOUT_LOGGING"/> </settings> </configuration>

2.4 商品搜索和分页:一个典型的动态SQL案例

商城的商品列表页是动态SQL需求量最大的地方。用户可能按分类选、可能按关键词搜、可能要求按价格升序、还可能组合起来做多条件筛选。如果把每种可能性都写一个SQL方法,那Mapper接口会膨胀得没法看。MyBatis的<if>标签就是专门解决这个问题的。

<select id="selectByCondition" parameterType="map" resultType="com.snack.entity.Product"> SELECT id, name, description, price, stock, sales, image, category_id, status, created_time FROM product <where> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="minPrice != null"> AND price &gt;= #{minPrice} </if> <if test="maxPrice != null"> AND price &lt;= #{maxPrice} </if> AND status = 1 </where> <choose> <when test="orderBy == 'priceAsc'"> ORDER BY price ASC </when> <when test="orderBy == 'priceDesc'"> ORDER BY price DESC </when> <when test="orderBy == 'sales'"> ORDER BY sales DESC </when> <otherwise> ORDER BY id DESC </otherwise> </choose> LIMIT #{offset}, #{pageSize} </select>

注意<where>标签会自动去掉第一个AND,所以要保证每个条件前面都带AND,这是MyBatis动态SQL使用中非常容易犯的错。<choose>则对应Java里的switch,用来做排序方式的单选。价格比较要用&gt;=&lt;=,因为XML里裸的><会被解析器当成标签符号直接报错。

分页这里我没有引入PageHelper插件,而是手动计算offset = (currentPage - 1) * pageSize,再写一个countByCondition查询总数。两个SQL执行,一个取总数、一个取列表数据,总共不到20行代码。PageHelper的原理本质上也是拦截器帮你自动拼LIMIT,但自己手写一次之后你会彻底理解分页的数学逻辑——先算偏移量,再拼SQL,最后把总记录数、总页数、当前页数封装成一个PageResult返回给前端。

3. 前端Vue3商城页面实现

3.1 工程创建与核心配置

后端接口写完,用Postman测过了,再开始写前端。Vue3项目的创建我用的是Vite,命令很简单:

npm create vite@latest snack-mall-web # 选择vue,选择JavaScript(也可以选TypeScript,不过这个项目用JS更省事) cd snack-mall-web npm install npm install vue-router@4 axios element-plus

依赖装完后,我先过一遍main.js,注册路由、挂载Vue实例、注册Element Plus:

import { createApp } from 'vue' import App from './App.vue' import router from './router' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' const app = createApp(App) app.use(router) app.use(ElementPlus) app.mount('#app')

项目里我特意用到了Vue Router,因为商城需要多个页面互跳:首页、分类页、商品详情页、购物车页、订单页面。在路由文件里给页面路径一一配好:

import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/', name: 'Home', component: () => import('../views/Home.vue') }, { path: '/category/:id', name: 'Category', component: () => import('../views/Category.vue') }, { path: '/detail/:id', name: 'ProductDetail', component: () => import('../views/ProductDetail.vue') }, { path: '/cart', name: 'Cart', component: () => import('../views/Cart.vue') }, { path: '/orders', name: 'Orders', component: () => import('../views/Orders.vue') }, { path: '/login', name: 'Login', component: () => import('../views/Login.vue') }, ] const router = createRouter({ history: createWebHistory(), routes, }) export default router

3.2 用Composition API封装商品列表逻辑

Vue3里最核心的思维转变是从Option API到Composition API。同样的商品列表功能,如果还用Vue2的写法,数据、计算属性、方法分散在datacomputedmethods三块;而Vue3把逻辑写在一起,像写普通函数一样。我在商品分类页就是直接复用卡片组件,数据请求通过fetchProductList这个函数根据当前路由参数区分加载逻辑。

<script setup> import { ref, reactive, onMounted, watch } from 'vue' import { useRoute } from 'vue-router' import axios from 'axios' const route = useRoute() const productList = ref([]) const loading = ref(false) const currentPage = ref(1) const total = ref(0) const keyword = ref('') const sortType = ref('default') async function loadProducts() { loading.value = true const categoryId = route.params.id const params = { page: currentPage.value, pageSize: 12, categoryId: categoryId, keyword: keyword.value, orderBy: sortType.value, } const { data } = await axios.get('/api/product/list', { params }) productList.value = data.list total.value = data.total loading.value = false } onMounted(loadProducts) watch([currentPage, () => route.params.id, keyword, sortType], loadProducts) </script>

这段代码常见的一个坑是watch监听的是route.params.id,如果直接监听route对象,任何路由变化都会触发加载,不符合分类切换的实际需求。另一个坑是每次切分类时要把keyword清空,sortType复位,否则查询条件会串味。我后来加了一个watch回调用currentPage.value = 1的方式做了兜底,确保新查询从第一页开始。

3.3 Axios封装与接口联调

商城项目里所有涉及后端接口的地方都要发HTTP请求。如果每个组件都直接import axios,改个baseURL得翻遍所有文件。最佳实践是封装一个request.js

import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 10000, }) // 请求拦截器:带上session凭证 request.interceptors.request.use(config => { // 拿token或sessionId,加到header里 const token = sessionStorage.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.msg || '请求失败') return Promise.reject(new Error(res.msg)) } return res }, error => { ElMessage.error(error.message) return Promise.reject(error) } ) export default request

前后端联调时有一件事必须提前处理:跨域。我是用Vite的proxy代理解决的,在vite.config.js里配置:

import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080/', changeOrigin: true, rewrite: path => path.replace(/^\/api/, ''), }, }, }, })

这里的原理是:浏览器发的请求由Vite开发服务器先接住,再由它转发到后端的8080端口,因为这不是浏览器直接发出去的,所以不会触犯同源策略。开发完成后打包前端,生成的静态文件丢到Nginx里,再在Nginx配置一个反向代理转发/api就可以了。

3.4 购物车与结算流程的交互设计

购物车页面是整个商城交互逻辑最重的一个功能。用户能勾选商品、改数量、删除,每次操作都要同步更新小计和总价。Vue3的computed计算属性在这里用得最多:

<script setup> import { ref, computed } from 'vue' import request from '../utils/request' const cartItems = ref([]) // 已选中的商品 const checkedItems = computed(() => { return cartItems.value.filter(item => item.checked) }) // 总价格 const totalPrice = computed(() => { return checkedItems.value.reduce( (sum, item) => sum + item.price * item.quantity, 0 ).toFixed(2) }) // 数量加减 async function updateQuantity(item, delta) { if (item.quantity + delta <= 0) return item.quantity += delta await request.post('/cart/updateQuantity', { cartItemId: item.id, quantity: item.quantity, }) } // 提交订单 async function submitOrder() { const res = await request.post('/order/create', { cartItemIds: checkedItems.value.map(item => item.id), receiverName: receiverInfo.value.name, receiverPhone: receiverInfo.value.phone, receiverAddress: receiverInfo.value.address, }) if (res.code === 200) { ElMessage.success('下单成功') // 跳转到订单列表页 router.push('/orders') } } </script>

这里有三个细节值得说。第一,toFixed(2)返回的是字符串,后端的DECIMAL类型在JSON序列化后也可能是字符串,前端如果需要再参与计算要先parseFloat,不然0.2 + 0.1这种浮点数精度问题会让价格显示变成0.30000000000000004。第二,前端修改数量要和后端同步时,最好只传cartItemIdquantity,不要把整个购物车对象传过去,减少无效数据传递也能避免无意义的报错。第三,提交订单前需要把选中的商品id数组一起传给后端,后端拿到这批id再去查询价格、计算总价,千万不能直接信任前端传的金额。前端传totalAmount只作为一个展示参考,最终价格以后端计算为准,这也是电商系统的基本安全红线。

4. MyBatis进阶与常见坑

4.1 一级缓存、二级缓存到底在缓存什么

项目里我用到了MyBatis的查询缓存机制,这是面试官非常爱问的一块。MyBatis的缓存分两级:一级缓存默认开启,作用域是SqlSession,也就是说同一个SqlSession中执行两条相同的SQL(必须参数也相同),第二条直接走缓存,不会真正查数据库。二级缓存需要手动开启,作用域是Mapper的namespace,多个SqlSession之间可以共享。

但二级缓存在分布式场景下会带来问题。如果项目部署了多个实例,不同实例之间的缓存互不相通,一个实例改了数据,另一个实例的缓存还留着旧数据,就会出现“缓存雪崩前的黎明”——用户看到的数据一会儿新一会儿旧。而且MySQL的InnoDB自身有Buffer Pool,MyBatis的缓存机制更适合少量热点数据的场景。在商城系统里,我会对商品详情这种读多写少的数据开启二级缓存,对库存这类必须实时准确的数据绝不缓存。配置文件里就一句话:

<setting name="cacheEnabled" value="true"/>

再在对应的Mapper XML文件里加一行<cache/>。建议最多做到这一步,不要再追求更复杂的自定义缓存策略,项目资料里能把一级缓存、二级缓存、何时失效、脏数据问题讲明白,已经比大部分候选人强了。

4.2${}#{}的区别:一次SQL注入的教训

开发中踩过最疼的一个坑是排序字段的动态拼接。最开始为了图省事,把前端传的orderBy直接用${}拼到SQL里,比如:

ORDER BY ${orderBy}

前端传priceAsc还好,但如果被别有用心的人传个id; DROP TABLE product; --,那这条SQL就变成了多条语句直接执行,后果不堪设想。这就是典型的SQL注入。MyBatis中#{}是预编译的,参数会通过PreparedStatement的占位符传进去,值被MySQL驱动做了转义,天然防注入;而${}是字符串拼接,把内容原样拼到SQL里,有注入风险。

那为什么还要用${}?因为它可以拼表名、列名和关键字,比如ORDER BY后面这个列名没法用占位符替代。安全做法是做一个白名单校验:前端传orderBy,后端只允许它等于几个预设值之一,不在列表里就默认按id DESC

String orderBy = request.getParameter("orderBy"); if ("priceAsc".equals(orderBy) || "priceDesc".equals(orderBy) || "sales".equals(orderBy)) { // 允许传入,拼到SQL里 } else { orderBy = "id"; }

4.3 多条件批量插入与库存扣减

“加入购物车”和“下单”都有批量操作需求。批量插入购物车项,如果循环单条插入,一次加10件零食就要执行10遍数据库网络IO,性能很差。MyBatis的<foreach>标签可以把多条数据拼接成一条insert语句:

<insert id="batchInsertCartItems"> INSERT INTO cart_item (user_id, product_id, quantity, checked) VALUES <foreach collection="list" item="item" separator=","> (#{item.userId}, #{item.productId}, #{item.quantity}, 1) </foreach> </insert>

同时用ON DUPLICATE KEY UPDATE处理已存在记录的场景。SQL变成:

INSERT INTO cart_item (user_id, product_id, quantity) VALUES (...) ON DUPLICATE KEY UPDATE quantity = quantity + 1;

这样就不用先查再决定新增还是更新,一步到位。唯一索引已经建好了,所以重复的(user_id, product_id)会触发更新分支,把数量加1。这个写法我曾不小心踩过一次“数量翻倍”的坑:第一次执行时插入数量2,但ON DUPLICATE KEY UPDATE又加了一次2,结果购物车里变成了4件。后面改进为前端只传“要增加几个”而不是“现在应该有几个”,彻底规避了这个问题。

订单提交时的库存扣减也是一样的道理,用原子性SQL比先查再改更安全:

UPDATE product SET stock = stock - #{quantity}, sales = sales + #{quantity} WHERE id = #{productId} AND stock >= #{quantity}

WHERE stock >= #{quantity}这个条件保证不会在库存不足时仍然执行扣减,同时数据库的行锁保证了并发情况下不会超卖。如果update影响的行数为0,就说明库存不够,回滚整个订单。这种写法比在Java代码里先查库存再判断再更新的做法更可靠,因为从“查”到“改”之间可能有并发时间差,而数据库行锁则天然解决了问题。

5. 面试高频追问与项目复盘

5.1 项目介绍怎么说才不像背稿

面试的时候,在“请介绍一个你最熟悉的项目”环节,很多人的回答是“我开发了一个零食商城,前端用Vue3,后端用Spring MVC,数据库用MySQL”。这种介绍在面试官耳朵里等于什么都没说。我建议的讲述框架是:业务场景→技术难点→解决方案→你的思考。

对于这个项目,一段比较好的自述开头是:“我做的是一个面向C端用户的零食电商系统,核心流程是用户注册登录、浏览商品、加入购物车、下单支付。后端我用Spring MVC做分层架构,Controller负责接收参数和返回JSON,Service做业务编排,Mapper层用MyBatis处理数据库操作。前端用Vue3开发。这个项目里我遇到两个比较棘手的问题:第一个是商品列表的多条件动态SQL拼接,因为搜索条件可能是分类、关键词、价格区间任意组合,所以我在MyBatis的XML里用<where><if><choose>标签做了一个通用查询方案;第二个是下单时的库存扣减,我通过SQL语句加条件判定的方式,保证了高并发场景下不会超卖。”

这一段介绍里没有任何一句空话,每一句都指向具体的技术点和解决方案。面试官听完基本就能判断出这个项目是真实写过的,而不是照着网上视频敲了一遍。

5.2 MyBatis、Vue3、Spring MVC的高频提问清单

围绕这个项目,面试官最常问的问题我整理了一张速查表,每道题都可以从你自己项目的具体实现出发回答:

提问方向高频问题从本项目出发的回答要点
MyBatis#{}${}有什么区别本项目排序字段用${}但做了白名单校验,其他参数一律用#{},说清楚预编译和字符串拼接的区别
MyBatis一级缓存和二级缓存分别是什么本项目二级缓存开在商品详情这类读多写少的Mapper上,库存这种实时性要求高的不缓存
MyBatisMyBatis接口怎么找到XML里对应的SQLMapper接口的全限定名就是XML文件里namespace的值,方法名就是SQL语句的id
MyBatisresultTyperesultMap有什么区别本项目开了mapUnderscoreToCamelCase,所以大部分用resultType;但订单关联用户这类查询字段不对应时用了resultMap
Spring MVC一次请求从进入到Controller的过程DispatcherServlet→HandlerMapping→HandlerAdapter→Controller→返回值处理→视图解析(或@ResponseBody直接JSON)
Spring MVC拦截器和过滤器的区别本项目用拦截器校验后台管理员的登录状态,过滤器是Servlet层面,拦截器是Spring MVC层面
Vue3Composition API对比Option API本项目购物车页面用refcomputedwatch管理状态,对比Vue2的data/methods,逻辑内聚性更好
Vue3refreactive怎么选本项目简单类型和数组用ref,处理包含嵌套属性的复杂对象时才用reactive
Vue3Vue路由守卫做什么本项目用路由守卫让未登录用户访问购物车页时自动跳转登录页

表格里这些问题,如果你能顺着自己真实用过的场景回答,给面试官的画面感是最强的。最好的状态不是你背好了标准答案,而是面试官问到任何细节,你都能接上“这个点我在项目里确实处理过,是因为……”。

5.3 把“老技术栈”讲成“基础扎实”而不是“跟不上时代”

Spring MVC和MyBatis确实不是最新的技术,但面试官不会因为你用了老技术就否定你。关键在于你如何解释自己的选型。我的应对策略是:主动承认“我知道Spring Boot和MyBatis-Plus目前更主流,但我选择Spring MVC和原生MyBatis是有意为之,因为我希望把Spring MVC的请求分发链路、MyBatis的SQL与Java映射这些底层机制亲手验证一遍,而不是被自动配置挡住”。

这个说法既诚实又有分寸。面试官听得出来你不是不会Spring Boot,而是选择了先扎实底层再上手框架。如果能顺带说一句“我的项目里前后端交互已经完全是前后端分离的思路,Controller都返回JSON,和Spring Boot的接口写法几乎一样,Spring Boot对我是迁移成本而不是学习成本”,这波回答基本就稳了。

6. 演示视频录制与答辩展示技巧

6.1 视频脚本设计:90秒抓住注意力

不少同学录项目演示视频,开头就是打开IDEA,然后开始敲键盘,录了20分钟代码。这其实是演示视频最容易翻车的做法。作为一个看过大量同类视频的人,我强烈建议你按照“场景式演示脚本”来录。整段视频控制在8到10分钟,分四个阶段:

  • 第一阶段(0:00-1:30):项目概览。屏幕上先展示项目的核心页面——商城首页、商品列表、购物车、订单,配一句简短介绍:“这是一个基于Spring MVC和MyBatis的网上食品零食商城系统,前端使用Vue3开发,支持用户浏览商品、加入购物车、模拟支付下单,以及后台商品管理。”
  • 第二阶段(1:30-4:00):用户主流程演示。从首页进入商品详情页,加入购物车,修改数量,提交订单,到支付成功。这一步要一次走通,不要出岔子。
  • 第三阶段(4:00-6:30):后台管理演示。管理员登录,编辑商品、调整库存、查看订单状态。
  • 第四阶段(6:30-8:00):亮点展示。展示项目里最能体现技术含量的功能,比如商品搜索动态SQL、数据库表结构、核心代码片段。这里才是体现“你真实做过”的关键。

我录视频有个祖传习惯:录之前先自己从头到尾走一遍流程,把每一步的操作方式、页面变化都写下来。比如“点击分类‘坚果炒货’,页面列表出现8个商品,第一条是‘每日坚果’”,这个细节脚本能保证录的时候不迷路,也能让观众跟着你的节奏走。

6.2 实操演示:数据准备和“演员道具”

演示翻车,多半是数据问题。我建议在展示之前,专门准备一份“演示数据包”:10个一级分类,每个分类下3到5个商品,商品图片可以直接用真实零食品牌的官网图或者电商素材图。购物车里提前放两三件商品。订单列表里准备几个不同状态(待付款、已完成、已发货)的订单。这样演示的时候,无论点到哪个页面,界面都是丰满的,观众不用看你的“空壳”。

后台管理员的账号密码也要提前测好,不要到操作的时候才发现密码不对。如果演示过程担心网络加载图片太慢,可以提前把图片文件放到本地静态资源目录,或者用压缩后的图片,断网也不影响展示。

6.3 录屏软件选择与基本功

录屏工具我用过几种,最终固定用的是国内网络环境下比较稳定的OBS Studio,免费开源、无时长限制、画质也好。配置很简单:

设置 -> 输出 -> 录像格式 -> MP4 设置 -> 视频 -> 输出分辨率 -> 1920x1080 画面源 -> 显示器采集 -> 选择你要录制的屏幕

另外强烈建议同时录麦克风,这样你能边操作边讲解。如果出水不方便,宁愿后期配字幕也不要静默操作。观众看视频的注意力是很容易涣散的,你的讲解声是控制节奏的唯一抓手。录制前先在后台把无关的软件关掉,尤其是需要联网的通讯软件,避免录制过程中弹出消息通知,那画面在汇报场合相当尴尬。

6.4 演示过程中的“翻车急救方案”

即使准备工作再充分,也可能遇到临场意外。最常见的三种故障,对应的急救方案如下:

页面加载不出来——按F5刷新前,先确认前端静态资源服务器是否有异常。如果还不行,八成是后端服务挂了,赶紧去启动Tomcat(属于那种必须提前想好的事情)。如果演示中途真出问题了,千万不要在视频里长时间沉默处理。最稳妥的做法是关掉录制、修好之后重新录,而不是硬扛着把故障展示给观众。

数据库连不上——先在Navicat验证一下数据库连接是否正常。解决方法是把数据库服务设置为开机自启,演示前启动好。千万别在演示时现场改配置文件。

页面样式错乱——多数是因为本地开发模式下的资源加载问题。项目构建打包后的静态文件放到Nginx后,要先在浏览器里完整走一遍核心流程,确认一切正常再开始录。Nginx下404是因为路由用了createWebHistory模式,刷新子页面路径找不到对应的静态文件,需要在Nginx配置里加上try_files $uri $uri/ /index.html;,这个坑我踩过,对方说“刷新页面变白屏”问题的根源就在这里。

7. 认认真真总结一下

做这个项目的过程中,我最大的体会是:技术选型老不老不是最关键的,最关键的是你在这个项目里真正掌握了多少底层原理,能不能把每一个实现细节讲得清清楚楚。Spring MVC和MyBatis这两套技术虽然年纪不小,但它们的核心思想——请求分发、控制反转、ORM映射、动态SQL——是整个Java后端体系的基石。用它们做完一个完整的商城系统,再上一个Vue3做前后端分离的实践,这其实是性价比很高的学习路径。

最后再分享一个小技巧:做演示视频时,不要只录操作过程,最好在关键节点按下暂停,用箭头和文字框标注出当前这个操作背后的技术点。比如你在商品列表页输入关键词搜索,暂停一下,在旁边标注“这里调用了MyBatis的动态SQL,通过<if>标签完成了多条件模糊查询”。这样做出来的视频,既有操作展示又有技术讲解,比单纯录操作过程有价值得多。

这个项目后续能扩展的方向也不少:接入Redis做热点商品缓存和Session共享、用Spring Security做更完整的权限控制、把前端打造成真正的管理后台前端框架、引入消息队列做订单超时自动取消。但这些都是后话,先把当前的Spring MVC + MyBatis + Vue3整条链路跑通,把它变成你脑子里真正能复述、能改编、能应对面试追问的东西,再想别的。

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

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

立即咨询