1. 为什么这么老的组合到现在还能打:项目定位与技术选型剖析
先聊一个很多同学问过我的问题:现在微服务、分布式、中台概念满天飞,为什么还要做一个前后端分离的单体应用项目?我的答案很直接——因为绝大多数刚入门或准备找工作的开发者,根本驾驭不了微服务那套复杂体系。你让他讲清楚一个订单在多个服务之间怎么流转,他大概率答不上来;但你要是让他把一个单体项目从数据库表设计到前端页面渲染完整讲明白,他反而能讲得头头是道。这就是"网上超市系统"这类项目经久不衰的核心原因:它规模不大不小,刚好能把一套完整的技术栈串起来,又不会因为业务复杂度太高而让新手陷入泥潭。
我选择SpringBoot + Vue + MyBatis + MySQL这套组合,也是权衡之后的结果。SpringBoot不用多解释,它就是当前Java后端事实上的标准入门框架,几乎所有的企业级项目都在用它;Vue作为前端框架,在国内的普及率不用我多说,很多公司甚至直接把Vue写进了JD里;MyBatis作为持久层框架,它的SQL可控性在中小型项目中体现出明显的优势,不像JPA那样在复杂查询时让人抓狂;MySQL更是基础中的基础。这套组合放在2024年的今天,依然能打,原因就是它的学习曲线相对平缓,生态成熟,找工作面试时也足够应对大多数问题。
网上超市这个业务场景,选得也很巧。它不是一个纯CRUD的玩具项目,而是包含了商品分类、购物车、订单、库存这些彼此关联的业务模块,数据表之间有关系,前端页面之间有跳转逻辑,这就逼着你必须去思考表结构怎么设计、接口怎么划分、状态怎么流转。同时它的业务又足够直观——你在网上买东西,把东西加进购物车,下单,付款,这些流程每个人都体验过,理解成本几乎为零。你不需要先去学习什么"供应链金融"或者"医疗合规"这种行业背景知识才能上手写代码,业务理解的门槛低,你就可以把精力全部放在技术实现上。
这个项目适合谁来写?我总结下来主要是三类人。第一类是临近毕业准备找工作的学生,需要一个拿得出手的项目来丰富简历;第二类是在职但想转行Java开发的人,需要用项目来建立自己的作品集;第三类是已经工作但想系统梳理前后端分离开发流程的同学,通过这种"麻雀虽小五脏俱全"的项目来补齐自己的知识短板。如果你是这三类人之一,这篇博客就是为你准备的。
2. 从一张表开始:数据库设计的完整思路
很多人做项目喜欢上来就建表,建到一半发现字段不够又去加,加的时候发现关联关系又乱了,最后表结构改得一塌糊涂。我在这个项目里换了个思路:先把用户下单的场景从头到尾走一遍,把涉及的实体全部列出来,再根据实体之间的关系去设计表。
2.1 核心业务实体的梳理
网上超市的核心流程是这样的:用户登录,浏览商品,把商品加入购物车,生成订单,完成支付,查看订单状态。顺着这个流程,我们可以很容易地提炼出以下实体:
- 用户(User):包含账号、密码、昵称、联系方式、收货地址等信息
- 商品分类(Category):用于对商品进行归类管理
- 商品(Product):包含商品名称、描述、价格、库存、图片等信息
- 购物车(Cart):记录用户选中的商品及数量
- 订单(Order):记录用户的一次下单行为
- 订单明细(OrderItem):记录订单中包含的具体商品项
这些实体之间的关系也很清晰:用户与订单是一对多,订单与订单明细是一对多,商品与订单明细是一对多,商品与分类是多对一。
2.2 关键表结构的设计细节
我把几张核心表的建表SQL拿出来说。首先是用户表:
CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL COMMENT '用户名', `password` VARCHAR(100) NOT NULL COMMENT '密码(MD5加密存储)', `nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称', `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', `address` VARCHAR(255) DEFAULT NULL COMMENT '收货地址', `role` TINYINT NOT NULL DEFAULT 0 COMMENT '角色:0-用户,1-管理员', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有一个容易被忽视的点:密码一定要加密存储,绝不可以用明文。这个项目里我用了MD5加盐的方式,虽然MD5在安全级别上不算顶尖,但对于学习项目来说已经足够,更重要的是它让你建立"密码不可以明文存数据库"这个意识。你在面试时可以提到这个点,会加分不少。
然后是商品表和订单相关表,我把重点放在订单表上:
CREATE TABLE `order` ( `id` INT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '订单号', `user_id` INT NOT NULL COMMENT '用户ID', `total_price` DECIMAL(10,2) NOT NULL COMMENT '订单总金额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-待付款,1-已付款,2-已发货,3-已完成,4-已取消', `receiver_name` VARCHAR(50) NOT NULL COMMENT '收货人姓名', `receiver_phone` VARCHAR(20) NOT NULL COMMENT '收货人电话', `receiver_address` VARCHAR(255) NOT NULL COMMENT '收货地址', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意两个关键设计。第一,order_no订单号必须唯一,而且我建议在生成策略上做文章,比如用时间戳加随机数来生成,避免使用自增ID直接暴露订单数量这个业务数据。第二,订单表里直接冗余了收货人信息,而不是只存user_id然后去用户表里查。为什么这么做?因为下单人的收货地址可能会变,如果订单只关联用户ID,下单之后再改地址就说不清了。冗余存储本质上是业务的快照,这是电商系统里很经典的设计思路。
2.3 数据库设计的几个避坑经验
我在给很多同学review表结构的时候,发现大家最容易犯的错误有三个。一是字段类型选用不当,比如存储价格用FLOAT,结果算金额的时候出现精度丢失。金额一定用DECIMAL,这是底线。二是缺乏统一的命名规范,有的表用下划线,有的表用驼峰,关联字段命名不一致,写SQL的时候总是在猜。三是缺少必要的索引,很多同学建表只建了主键,然后查询的时候发现慢得要死,却不知道要给外键字段、状态字段加普通索引。
提示:表名不要用
order、user这种MySQL的保留字,如果非要用,得写成\order`这种带反引号的形式。我建议在真实项目中给核心业务表加个业务前缀,比如mall_user、mall_order`,既避免关键字冲突,也方便多业务模块扩展。
3. 环境准备与部署:一套能跑通的才是好项目
我给这套代码写部署教程的时候,最深的一个体会是:"代码能跑"和"教程能让别人也跑起来"是两码事。很多人在自己的电脑上开发时依赖的是本地的各种隐式环境,换一台机器就抓瞎。所以我把环境准备的部分写得很细,甚至细到了"下载哪个版本的JDK"这种程度。
3.1 本地开发环境的搭建清单
按照我实际测试过的组合,这里列出推荐的版本和安装注意事项:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 1.8 或 11 | 建议使用1.8,兼容性最好,很多公司生产环境仍以JDK8为主 |
| Maven | 3.6.3 | 用于后端依赖管理和构建 |
| Node.js | 14.16.0 + | 用于前端Vue项目的运行与构建 |
| npm | 6.x 或 7.x | 与Node.js配套,建议使用nrm管理镜像源 |
| MySQL | 5.7 或 8.0 | 5.7资料多、坑少;8.0性能更好但不兼容老驱动的连接配置 |
| IDE | IntelliJ IDEA + VSCode | 后端用IDEA,前端用VSCode,各司其职 |
环境这块容易踩的坑有三个。第一,Java和Maven的环境变量配了之后要重启终端才能生效,很多同学配置完直接在原来的窗口里测java -version,发现还是旧版本就以为自己配错了。第二,MySQL 8.0的驱动类变成了com.mysql.cj.jdbc.Driver,而且连接URL里面必须加serverTimezone=UTC之类的时区参数,否则会报时区错误。第三,Vue项目拉取依赖的时候,淘宝镜像源是默认的http://registry.npm.taobao.org,但有些地区的网络访问它反而比官方源更慢,建议根据自己实际网速选择源。
3.2 后端项目的启动步骤
后端是标准的SpringBoot项目,启动流程如下:
- 修改
application.yml中的数据库连接信息,包括URL、用户名、密码 - 执行项目根目录下的
sql/init.sql初始化数据库表结构和测试数据 - 在IDEA中打开项目,等待Maven自动下载依赖完成
- 运行
MallApplication.java中的main方法 - 浏览器访问
http://localhost:8080/api/product/list,如果能返回JSON数据说明启动成功
这里我特别说一下初始化SQL这个环节。我提供了完整的建表和测试数据脚本,但很多同学习惯手动在Navicat里一条条执行,容易漏掉某条语句导致后面报错。正确做法是使用命令行或者IDE的数据库工具来执行整个SQL文件,确保表和数据的完整性。
有个细节值得注意:执行SQL脚本之前,务必确认数据库的字符集设置。我默认建库语句写的是UTF8MB4,如果你用的客户端工具默认字符集是latin1,可能导致中文乱码。遇到乱码先检查两处:一是库表的字符集是不是utf8mb4,二是JDBC连接URL里有没有指定characterEncoding=utf8。
3.3 前端项目的启动步骤
前端部分是基于Vue 2 + Element UI搭建的,启动流程也很直接:
# 进入前端项目目录 cd mall-web # 安装依赖 npm install # 启动开发服务 npm run serve启动完成后访问http://localhost:8082,如果页面正常打开,然后登录、浏览商品这些功能都能用,说明前后端打通了。
重点要说的是跨域问题。前端的开发服务器默认跑在8082端口,后端接口在8080端口,两者的端口不同,浏览器就会发起跨域请求。我在后端配置了全局的CORS策略,允许跨域请求。如果你是自己从零开发,忘了配CORS,浏览器控制台会报Access-Control-Allow-Origin相关的错误,页面显示"请求失败"但后端日志里却能看到请求进来了——这个现象非常典型。
4. 后端代码的骨架与核心业务实现
整个后端项目的代码结构,是按经典的分层架构来组织的。我列出目录结构,让你先有个整体印象:
src/main/java/com/mall/ ├── controller # 控制器层,接收请求,返回JSON数据 ├── service # 业务逻辑层,处理核心业务规则 ├── mapper # MyBatis的Mapper接口 ├── entity # 实体类,对应数据库表结构 ├── config # 配置类,如CORS配置、拦截器配置 ├── common # 公共类,如统一返回结果、异常处理等 └── MallApplication.java # 启动类4.1 统一返回结果与全局异常处理
很多初学者写接口的时候,喜欢直接从Controller返回一个Map或者干脆返回一个List,结果就是前端拿到的数据结构五花八门,有些接口是{code:0, data:{...}},有些接口又是[{...}],对接的时候简直想死。
我在项目里定义了一个统一的响应结构:
public class Result<T> { private Integer code; // 状态码:200成功,500失败 private String message; // 提示信息 private T data; // 返回数据 public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 200; result.message = "success"; result.data = data; return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.code = 500; result.message = message; return result; } }所有Controller的返回值都是Result<T>类型。做这一步看起来只是多了几个泛型类,但它带来的收益非常大:首先是前端可以统一处理成功和失败的判断逻辑,不用为每个接口定制解析方式;其次是你可以在Result里方便地扩展分页信息、时间戳等字段,而不需要改动调用方代码。
配合统一返回结果,我还加了全局异常处理器。所有业务抛出的异常,最终都通过@RestControllerAdvice捕获,转成统一的Result.error()返回。这样做的一个好处是:接口层不会因为未捕获的异常而裸奔出5xx状态码加一堆堆栈信息的HTTP响应,你可以在日志里记录详细堆栈,但返回给前端的是友好的错误提示。
4.2 用户登录与JWT鉴权流程
网上超市的用户模块除了注册登录之外,还要区分普通用户和管理员两种角色。我用的是JWT(JSON Web Token)来做身份认证。登录流程是这样的:
- 用户提交用户名和密码
- 后端正向比对密码是否匹配(存储时是MD5加盐后的密文)
- 验证通过后生成一个JWT令牌,其中包含用户ID和角色信息
- 前端把JWT存进
localStorage,之后的每次请求都在Authorization头里带上这个令牌 - 后端通过拦截器验证令牌的有效性,并从中提取用户信息
生成JWT的核心代码我贴一下:
String token = Jwts.builder() .setSubject(String.valueOf(user.getId())) // 以用户ID为主题 .claim("role", user.getRole()) // 角色信息放在claim里 .setExpiration(new Date(System.currentTimeMillis() + 3600 * 1000)) // 1小时过期 .signWith(SignatureAlgorithm.HS256, secretKey) .compact();这里有个很容易踩的坑:JWT密钥不要硬编码在代码里,而且长度不能太短。很多教程直接写String secretKey = "abc";,结果用HS256算法时直接报错"signing key cannot be less than 256 bits for HS256"。我项目里用的是从配置文件读取的方式,并且提供一个比较长的密钥字符串。这个细节看起来不起眼,但能帮你省掉不少排查时间。
4.3 购物车模块:状态与计算逻辑的实现
购物车是网上超市的核心交互之一。后端接口主要做了三件事:添加到购物车、修改购物车商品数量、删除购物车商品。这里面比较值得说的是购物车中商品价格的计算逻辑。
我设计购物车表cart时,字段包括id、user_id、product_id、quantity。每次前端请求"我的购物车"时,后端并不是直接查询cart表返回数据,而是做一次关联查询,从商品表里取出最新的单价,与数量相乘后得出小计金额。为什么要这么做?因为商品的单价是浮动的,可能管理员调整了价格,但购物车里存的是用户加购那一刻的单价快照。如果直接返回购物车的旧价格,用户结算时看到的金额可能和付款时不一致,容易引发纠纷。
业务逻辑落到代码上,大致是这样的过程:
// 获取购物车列表(关联商品表查询最新价格) List<CartVO> cartList = cartMapper.selectCartListWithProduct(userId); // 计算总价 BigDecimal totalPrice = cartList.stream() .map(item -> item.getPrice().multiply(new BigDecimal(item.getQuantity()))) .reduce(BigDecimal.ZERO, BigDecimal::add);在实际做这个功能的时候,我觉得最需要注意的其实是修改数量时对库存的判断。用户在购物车里连续点"+号"把数量加到1000,后端不能照单全收,要实时去校验商品表的库存字段。库存不足时,应该直接返回"库存不足"的错误提示。这个逻辑放在后端而不是前端来做,是因为前端校验可以被绕过(比如直接模拟请求),而库存扣减的真实约束必须由后端保证。
4.4 订单生成的业务流程与事务管理
订单模块是这个项目里事务性最强的部分。一次完整的下单操作,需要同时完成以下几件事:
- 校验购物车中的商品是否存在、是否还有库存
- 生成订单主表记录,写入订单状态为"待付款"
- 根据购物车内的商品列表生成订单明细记录
- 扣减商品库存
- 清空已下单的购物车
这五步操作必须做到"要么全部成功,要么全部失败",否则就会出现"订单生成了但库存没扣"或者"购物车清了但订单没生成"这种数据不一致问题。所以我在Service层的下单方法上加上了@Transactional注解。
关于事务,我多说一句。很多人在学习阶段只知道加注解,却不知道为什么加、什么时候去掉。在这个项目里我觉得值得关注的地方是:@Transactional失效的几个典型场景。比如:方法被private修饰导致AOP代理不生效;在同一个类的内部调用自带事务的方法,绕过了代理对象;异常被try-catch吞掉,事务感知不到异常就不会回滚。这些都是我在帮人查"为什么下单后库存没扣减"这类问题时反复遇到的情况。
下单方法的核心逻辑大致如下:
@Transactional(rollbackFor = Exception.class) public boolean createOrder(Long userId) { // 1. 查询当前用户的购物车数据(关联商品表) // 2. 依次校验商品状态及库存 // 3. 生成订单号,写入订单表 // 4. 遍历购物车,生成订单明细表记录 // 5. 扣减库存:UPDATE product SET stock = stock - #{count} WHERE id = #{productId} AND stock >= #{count} // 6. 清空当前用户的购物车 // 7. 返回订单ID }注意到第5步的SQL没有用"先查库存再判断"的两步操作,而是用一条UPDATE ... WHERE stock >= #{count}的原子操作,返回的受影响行数如果是0则说明库存不足。这种写法可以避免在并发场景下超卖。虽然说单体项目并发量不会太大,但从一开始就养成"用条件更新代替查改分离"的习惯,对你未来接触高并发场景会很有帮助。
5. 前端页面的实现逻辑与关键交互
前端部分我用的是Vue 2 + Vue Router + Element UI + Axios。选择Vue 2而不是Vue 3,一个很现实的原因是:很多公司的存量项目仍然是Vue 2,面试时你写出Vue 2经验更容易匹配岗位需求;另外一个原因是网上的Vue 2 + Element UI生态资料极其丰富,遇到问题搜索起来效率高。
5.1 前端路由与页面结构
页面结构按角色分为两部分:用户端和管理员端。
用户端的页面有:
- 首页:商品列表展示,支持按分类筛选和关键词搜索
- 商品详情页:展示商品完整信息,支持加入购物车
- 购物车页:展示购物车商品,修改数量,删除,结算
- 订单列表页:查看当前用户的历史订单和状态
- 个人中心页:修改个人信息和收货地址
管理员端的页面有:
- 后台首页:统计订单数量、商品数量等基础数据
- 商品管理页:商品的增删改查
- 分类管理页:商品分类的维护
- 订单管理页:处理订单状态的流转(发货、取消等)
这样的页面规划,前端路由可以设计成嵌套路由加权限控制的方式。用户在路由守卫里根据localStorage中解析出的角色信息进行跳转拦截:普通用户访问/admin路径时会被强制重定向到首页。这个逻辑虽然简单,但却是很多人容易忽略的"权限校验"雏形。
5.2 Axios封装与Token的统一携带
如果你看过很多烂代码,你会发现每个页面里的Ajax请求都是各写各的:有的拿到数据后console.log一下就不管了,没有错误处理;有的请求成功时要显示一个loading,失败的又要隐藏,代码重复严重。所以我在项目里统一封装了一个request.js的Axios实例。
封装的核心点有两个。一个是请求拦截器里自动携带Token,你不需要在每个请求里手动拼Authorization头,而是拦截器统一从localStorage读取并写入headers。另一个是响应拦截器里统一处理HTTP 401状态,一旦发现Token过期或无效,自动清空本地登录状态并跳转到登录页,让用户重新登录。这个机制看似简单,但它是前后端分离应用中"会话维持"的标准打法。
Axios封装的骨架大致如下:
import axios from 'axios' const request = axios.create({ baseURL: process.env.VUE_APP_BASE_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) { this.$message.error(res.message) return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') window.location.href = '/login' } return Promise.reject(error) } ) export default request5.3 商品列表页的动态渲染思路
商品列表页是整个前端最典型的"列表 + 搜索 + 分页"场景,这段代码几乎可以复用到所有后台管理页面。页面用的是Element UI的el-table组件,搜索区绑定了商品名称关键词和分类ID,点击"搜索"按钮时重新拉取第一页数据。
这里有一个容易被忽略的细节:商品图片的处理。数据库里存的是图片的相对路径或者外链,前端渲染<el-image>时,如果路径是/static/image/xxx.jpg,需要拼上服务器的访问前缀。我在项目里统一用Vue的全局过滤器或者计算属性处理了这个拼接,避免每个页面各写一遍。
分页的逻辑我也强调一下。前端传给后端的参数是pageNum和pageSize,后端返回的数据结构是包含list、total、pages的分页对象。这里前端拿到total之后,还要自己维护当前页数(currentPage),改页数或改每页条数时都要重新调用接口。很多新手在这一步容易出bug,比如"点下一页数据没刷新"、"切换每页条数还是从第一页开始"——其实问题就出在忘了把pageNum重置为1。
5.4 购物车交互的体验细节
购物车页面是我自己在前端部分花心思最多的地方。因为是典型的"选择商品 -> 结算"流程,所以交互要考虑下面几个点:
- 每个购物车条目前面有复选框,需要计算"已选中的商品总金额"
- 商品数量有"加减号"和"手动输入"两种修改方式,输入非法值时要自动回退到原值
- 商品下架(非上架状态)时,前端显示"已失效"并禁用结算
这些交互看起来琐碎,但它们非常贴近真实电商项目的要求。也是你在面试时可以展示的"项目亮点":你可以说,我在做购物车时并没有简单地一股脑把购物车里的所有商品都去结算,而是支持了勾选部分商品进行结算,并且后端也提供了对应的参数设计(传入选中的购物车项ID列表)。这一点,已经可以说明你在流程设计上有自己的思考。
6. 后端Controller与前端对接的协作方式
前后端分离的项目最容易出现的痛点,就是接口定义不统一,前端联调时反复扯皮。我在做这个项目的过程中总结了一套还算高效的协作方式,分享出来供参考。
6.1 接口契约先行
在写代码之前,我会先把接口文档以表格的形式整理出来,字段名、数据类型、是否必填都写清楚。比如商品列表接口:
| 接口路径 | GET /api/product/list |
|---|---|
| 请求参数 | pageNum(页码)、pageSize(每页条数)、keyword(关键词,可选)、categoryId(分类ID,可选) |
| 返回结构 | { code, message, data: { list, total } } |
| 字段说明 | list为商品列表,total为总数 |
在接口文档里,特别要注明字段命名风格。很多前后端对接问题,根源就在于后端使用了user_name这种下划线命名,而前端定义的参数叫userName,结果传递过去全是undefined。为了省事,我在后端返回给前端的VO(视图对象)里把所有字段都定义成驼峰命名(如productName、categoryId),与前端保持一致。如果你用的是MySQL的下划线物理表结构,也不需要慌,MyBatis里可以直接配置map-underscore-to-camel-case: true来自动映射。
6.2 联调阶段最容易出现的三类问题
第一类是数据类型不一致。MySQL的INT对应Java的Integer没问题,但DECIMAL映射到Java后,如果直接返回给前端,JSON序列化时可能会出现精度丢失或者变成字符串的尴尬情况。我处理金额统一用BigDecimal,并在字段上加上@JsonSerialize(using = ToStringSerializer.class)注解,保证前端不丢精度。
第二类是日期格式不一致。后端默认返回的Date类型序列化成JSON是时间戳格式,前端拿到的是一个长数字,直观看不出是哪一天。解决方案是在配置里统一设定日期格式化,如spring.jackson.date-format=yyyy-MM-dd HH:mm:ss,或者使用LocalDateTime配合@JsonFormat注解。
第三类是接口路径不对。比如后端的@RequestMapping("/api/product")下有一个@GetMapping("/list"),实际访问路径是/api/product/list——但这个规则新手经常记混,一会儿以为没有/api前缀,一会儿又多写一层。这要求双方都要遵守写好的接口文档,不要自己脑补路径。
6.3 用Postman先自测,再交给前端
我在把接口交给前端联调之前,习惯先用Postman把主要接口全部过一遍。这个习惯帮我拦下了不少低级失误,比如某个接口忘了传某个必填参数会报500、某个接口在没有Token时会直接返回未授权等。自测通过后,再让前端同学用真实的页面流程去操作。
调试期间另一个实用的工具是浏览器的开发者工具Network面板。当前端同学反馈"页面请求失败"时,第一步不是去翻后端日志,而是先看Network面板里这个请求的响应码和响应体。如果是401,说明Token问题;如果是500,再去后端日志排查;如果是200但页面数据不对,再检查数据处理逻辑。这个排查顺序,能节约你一半以上的联调时间。
7. 多角色权限控制与后台管理的实现要点
网上超市系统不是一个只有用户端的前台页面,它还包含了一个功能完整的管理后台。我在做后台管理时,有两个地方值得展开说说。
7.1 管理员与普通用户的权限区分
后端侧的权限控制,我是通过拦截器加角色判断来实现的。拦截器统一拦截/api/admin/**的请求,在JWT解析后查看角色是否为管理员,如果不是则直接返回403错误。
这里有个细节:JWT里的角色信息是签发时写入的,如果用户角色在Token存活期内发生了变化(比如管理员被封禁),本次Token里带的信息仍然显示为管理员。对于学习项目来说,这种处理方式可以接受,但如果你在做真实项目,就要考虑引入更完善的服务端Session控制或权限框架,比如Spring Security + Redis的思路。
前端的权限控制,我前面提到了路由守卫。但我再补充一个重要细节:前端做权限控制的初衷只是为了优化用户体验,后端才是安全屏障。也就是说,哪怕前端可以随便隐藏或显示某个菜单,后端也必须对每个受保护接口做严格的权限验证。我在项目里特意在前端隐藏了"管理入口"这个链接,但即使有人手动在地址栏输入/admin路径,后端也会拦截他。这个安全和体验分离的原则,无论项目大小都适用。
7.2 商品与分类管理的CRUD套路
后台管理的商品模块,本质上是"一张表的增删改查"。但我在这个模块里还嵌入了一个有价值的思路:分类和商品之间使用外键约束时,删除分类前要检查该分类下是否还有商品。如果直接删除分类,商品表里的categoryId就会变成悬空引用,导致商品在前端无法正确展示分类名称。
我在Service层添加了一个校验逻辑:
if (productMapper.countByCategoryId(categoryId) > 0) { throw new BusinessException("该分类下还有商品,无法删除"); }这一段虽然只有几行,但它是典型的数据完整性业务约束,面试时你可以顺势讲出"为什么分类删除不能一刀切"这个业务层面的思考。
7.3 后台首页统计数据的简易实现
后台首页要展示一些核心统计数据,比如商品总数、订单总数、用户总数、待发货订单数。这个功能放在别的项目里可能会被做成复杂的报表中心,但我用最简单的方式实现:在DashboardController里写了几个count查询,一次性汇总返回给前端。
这类统计SQL虽然简单,但有一个实际生产中非常值得注意的原则:不要在主业务表上频繁执行全表COUNT统计,数据量大了会拖垮数据库。你可以考虑用汇总表或者缓存来应对。但在学习项目中,直接COUNT是没有问题的,只要你别无脑地在高并发场景下也这么干就行。这个知识点可以作为一个扩展点写在简历的项目描述里,面试官常会顺着问。
8. 踩坑实录:从零部署到项目跑通的完整排查链路
很多同学拿着教程部署项目,总是卡在一些奇奇怪怪的问题上。我把自己在绿豆超市项目上传到Gitee、发布到博客、再提供给读者下载后,遇到的高频问题的排查过程整理成一个"链路",希望能给你一些参考。
8.1 MyBatis XML文件编译后找不到的经典报错
很多同学第一次跑SpringBoot + MyBatis项目时,会报这样一个错误:
Invalid bound statement (not found): com.mall.mapper.ProductMapper.selectProductList排查思路是这样的:先看ProductMapper.java接口上的方法名是否和XML文件中的id完全一致(包括大小写),再看接口的全限定名是否和XML中的namespace一致。两个都对了,还报错,那么大概率是XML文件没有被Maven复制到target目录——根因是Maven默认只会把src/main/resources下的文件拷贝到classpath,而你把XML文件放在了src/main/java的Mapper接口旁边,Maven默认不会把它当作资源处理。
解决方案也很简单,在pom.xml里增加资源配置指定:
<build> <resources> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> </includes> </resource> </resources> </build>或者更推荐的做法:把XML文件统一放在src/main/resources/mapper/目录下,并在application.yml里指定:
mybatis: mapper-locations: classpath:mapper/*.xml我遇到不少读者在评论区贴日志问这个问题,他们几乎都犯了这个"把XML放在Java目录下却不知道需要额外配置"的错误。
8.2 前端npm run serve之后白屏或接口404
这个问题我至少被问了二十次。场景是这样的:前端通过npm run serve启动成功了,控制台也没报错,但打开页面发现白屏,或者接口全部显示404。
白屏问题的排查链路是这样的:先打开浏览器控制台的Console面板看有没有JavaScript报错。最常见的报错是Cannot read property 'prototype' of undefined——这是Element UI版本和Vue版本不匹配导致的。我项目里用的是Element UI 2.15.x配Vue 2.x,如果你强行装了Element UI 3.x(那是给Vue 3用的),就会白屏。
接口404问题的排查链路:先看控制台请求的URL是什么。如果请求的是http://localhost:8082/api/product/list而实际后端是http://localhost:8080/api/product/list,那就是前端代理没配好。在vue.config.js里配置代理是解决这个问题的标准方式:
module.exports = { devServer: { port: 8082, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }配完代理后要重启npm run serve,不是刷新页面就行。这又是个容易卡住新手的地方:改完配置文件不重启开发服务,配置压根不会生效。
8.3 登录成功但跳转后用户信息丢失
有读者反馈:登录成功后页面明明显示用户名了,但刷新一下又变成未登录,需要重新登录。问题出在登录成功后的前端处理逻辑上。
正确的流程应该是:登录接口返回JavaWebToken令牌,前端保存到localStorage,同时把用户基本信息(昵称、头像等)保存在内存中的Vuex store里。刷新时Vuex里的数据会清空,此时要有一个地方根据Token去请求"获取当前用户信息"的接口,把用户信息重新拉回来填补空白。
如果刷新后丢失用户信息,要么是没有在应用启动时调用获取用户信息接口,要么是后端"获取当前用户信息"这个接口没处理好从Token解析用户ID的逻辑。排查时打开Network面板,看刷新后有没有发一个/api/user/info的请求,如果没有,就去检查main.js或路由守卫里的初始化逻辑。
8.4 部署到服务器后的静态资源路径问题
本地开发时图片能显示,部署到服务器后图片全裂了。这个问题的排查重点在于判断图片地址是相对路径还是绝对路径。如果数据库里存的是/static/img/1.jpg这种相对路径,而你的前端打包后部署在Nginx的/usr/share/nginx/html下,那么浏览器访问http://ip/static/img/1.jpg时就会404。
解决思路有两种。一种是把图片路径改成完整URL(比如http://ip:8080/static/img/1.jpg),另一种是把静态资源目录和Nginx的根目录对齐。考虑到可移植性,我项目里采用的是存储相对路径 + 前端拼接服务器地址的方式,这样换服务器只需要改一个地方。
注意:不要直接把图片上传到SpringBoot项目的classpath静态资源目录里,因为重新打包覆盖文件时,上传的图片会被清空。更好的做法是上传到独立的
/data/upload目录,再通过Nginx配置一个/upload的路径映射到那里。这已经是一个接近生产级的设计思路了。
9. 让简历项目变成面试谈资:还差哪些加分项
代码跑通、部署成功、能演示,这只是第一步。如果你准备用这个项目找工作,那我建议你在下面几个方向上做一次深度复盘,把"会写代码"变成"能讲清楚问题"。
9.1 缓存与并发场景的预演
面试官非常喜欢问:"如果你这个系统的商品详情页被大量访问,你会怎么优化?"这个问题是引导你往缓存的方向走。你可以这样回答:先用Redis缓存热点商品信息,减少对数据库的查询压力;缓存空值要设置较短的过期时间,防止缓存穿透;商品更新时主动删除缓存,而不是等它自己过期,这样可以保证数据的一致性。
另外还有一个经典问题:"下单时库存超卖怎么办?"我前面提到的UPDATE product SET stock = stock - #{count} WHERE id = #{id} AND stock >= #{count}就是回答的核武器。你要把这个方案的原理说清楚:数据库行锁在更新时会给这行数据加锁,后面的更新操作会阻塞等待,直到前一个事务提交或回滚。这个机制天然防止了超卖。如果能再补充"对于极高并发场景,可以用Redis预扣库存,异步落库"这种进阶方案,就更有说服力了。
9.2 文件上传与富文本的扩展思路
网上超市的商品描述如果只是纯文本,显得有点单薄。我有一个很自然的扩展思路:可以引入一个富文本编辑器(比如WangEditor或UEditor),让管理员在商品编辑页输入带格式的描述,图片也能插入。这就需要后端提供一个通用的文件上传接口。
这个扩展方向看起来小,但它能串联起一系列知识点:文件上传的MultipartFile接收、存储路径规划、静态资源映射或Nginx访问、大小限制配置、类型校验(只允许jpg/png等,防止上传恶意脚本)。如果你能在简历里写"实现了商品图片上传与管理功能",这本身就是一块完整的业务闭环。
9.3 单元测试与日志规范化的习惯
说实话,大部分学习项目是没有单元测试的,甚至是连日志打印都很少。如果你能在订单生成方法里加上"关键步骤日志"(开始下单、扣库存成功、订单生成成功、购物车清空完成),再给核心业务比如下单逻辑写几个单元测试用例,你和其他候选人的差距就体现出来了。
我比较推荐用@SpringBootTest做集成测试,最核心的一层意义是:它能帮你在改造代码后快速验证核心流程没有跑偏。而且这个习惯一旦培养起来,在真实工作中价值极大。
9.4 代码规范:命名、注释与提交信息
有几个不太好量化但一眼就能看出工程素养的习惯:包名统一用com.mall;类名、方法名用驼峰;常量用UPPER_CASE;Controller只做参数校验和转发,不写业务逻辑;接口注释写清楚参数和返回值的含义;Git提交信息写清楚"做了什么"而不是"修改了代码"。这些看起来是小细节,但干净代码和不干净代码的差异,在面试官浏览你项目的那一刻就已经决定了印象分。
10. 部署上线:从本地到服务器的一种完整实践
我对"部署教程"的定义是:拿到手跟着敲,能跑起来。上面已经讲了很多本地启动的过程,最后我再补一段服务器部署的真实经验,这部分我踩过不少坑。
10.1 后端打包与运行
在本地把后端项目打成可执行Jar包:
mvn clean package -DskipTests打包产物在target/mall.jar。上传到服务器后,用java -jar mall.jar启动。注意服务器上要有JDK8环境。如果你想让日志输出到文件里,可以这样启动:
nohup java -jar mall.jar > /data/logs/mall.log 2>&1 &这里最容易遇到的问题是端口占用和内存不够。SpringBoot默认端口是8080,如果服务器上已经有程序占了8080,你可以用--server.port=8090来临时指定。内存方面,云服务器如果是2G内存,默认JVM参数可能分配得过大导致启动失败,可以用-Xms256m -Xmx512m来限制堆内存。
10.2 前端打包与Nginx配置
前端打包:
npm run build构建产物在dist目录。把dist目录里的文件上传到服务器的/usr/share/nginx/html。然后在Nginx的配置里做两件事:一是把/api路径反向代理到后端的http://127.0.0.1:8080,二是把路由历史模式(mode: 'history')下的刷新404问题用try_files来解决。
Nginx关键配置片段:
server { listen 80; server_name your_server_ip; 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; } }try_files $uri $uri/ /index.html;这一行解决的是Vue Router的history模式下的刷新页面404问题。如果你用的是hash模式(URL里有#),就不会有这个问题,但URL不美观。我这里选择的是history模式,所以必须配这一步。
10.3 备份与版本管理
部署上线还有一个经常会漏掉的东西:数据库备份。我分享一个简单的定时备份脚本思路,用mysqldump把数据导出到压缩文件,并保留最近N天的备份。这个脚本虽小,但能救命。
在真实使用这个部署方案时,务必要把配置文件里的数据库密码改成服务器上实际数据库的密码,不要因为赶时间把开发环境的密码直接复制上去。另外,服务器数据库要提前设置好远程访问的权限策略,避免把端口裸暴露在公网上。
11. 写在代码之外的几点体会
代码层面的内容讲得差不多了,最后分享几个我在带人做这个项目过程中的真实感受。
第一,不要总是想着"找一个大项目"。很多初学者觉得网上超市这种项目太小、太简单了,转身去找"秒杀系统"、"仿淘宝全栈项目",结果因为业务复杂度太高,学了两个月还在环境配置阶段打转,最后连一个完整功能都跑不通。项目是为了服务你的成长阶段,而不是反过来让你被项目压垮。
第二,能跑通第一步永远比追求完美重要。我在教程里见过有同学一上来就纠结"要不要用Redis?要不要引入消息队列?"其实对一个单体学习项目来说,这些都属于过度设计。先把基本功能完整地走通,再考虑在哪个点上去做微创新,反而更容易积累成就感。
第三,做项目过程中最重要的是沉淀出"为什么"。我在这个项目里反复强调的事项,比如金额用DECIMAL、密码要加盐、事务要处理库存一致性、接口要统一返回结构,这些都不是死记硬背的教条,而是从真实业务和失败中提炼出来的经验。你如果能用自己的语言把每个"为什么"讲清楚,那么在面试中你就已经打败了九成只会照抄代码的同学。
我建议你拿到这个项目源码之后,不要急着下载、解压、运行、截图,然后丢进简历。给自己一个下午的时间,把数据库表和模块划分搞清楚,然后亲自动手去写一个模块,哪怕是很简单的商品详情接口,也比跑通一个完整项目更有价值。做项目真正练的,不是"跟随步骤",而是独立解决问题的能力和对业务向前推导的敏锐度。