做这类“超市进销存管理系统”的计算机毕业设计,在国内高校里几乎算是“标准题”了,每年都有大量学生选它。但说实话,很多同学做完之后对系统的理解仅限于“能跑、能演示、能答辩”,一旦面试官深入问两句“库存扣减怎么保证不超卖”“采购单和入库单为什么分开设计”,立刻就卡壳。这篇文我打算用真实做过这个项目的视角,把SpringBoot + Vue这套前后端分离的进销存系统的设计思路、核心功能、数据库建模、关键代码实现、部署踩坑以及答辩高频问题完整串一遍,尽量讲透为什么这么做,而不是只给你一堆CRUD代码。适合正在做毕设、准备找Java后端实习,或者纯粹想拿一个完整项目练手的朋友参考。
1. 项目整体设计与技术选型
1.1 业务需求拆解
超市进销存,全称是“进货、销售、库存一体化管理系统”。别听着觉得只是一个“增删改查”练习,真正进到业务层面,它有三个核心流程是必须闭环的:
- 采购入库流程:创建采购订单 → 供应商发货 → 仓库验收入库 → 更新商品库存 → 生成入库流水。
- 销售出库流程:前台收银/销售开单 → 扣减库存 → 生成销售流水 → 统计营收和毛利。
- 库存管理流程:库存查询、库存预警、报损报溢、盘点单、调拨单。
围绕这三个流程,系统需要管理的基础数据就清楚了:商品信息、供应商、客户(会员)、仓库、员工账号与角色权限。所以,一个完整可用的进销存系统,至少要有商品管理、采购管理、销售管理、库存管理、报表统计、系统管理这六大模块。
做这个项目时最容易犯的错是“上来就写代码”,把商品、供应商、销售订单各做一张表,然后就开始堆页面。我建议先画业务流程图,把“一张商品从供应商进入超市,再到售出离店”的完整链路走一遍,再落数据库表。把流程梳理清楚,后续写代码会顺畅很多,而且这块内容本身也是论文和答辩的素材。
1.2 技术栈选型分析
这套系统采用SpringBoot + Vue的前后端分离结构,选型不是随手定的,而是基于几个实际考虑。
后端用SpringBoot,理由很直白:它内嵌Tomcat、自动配置、生态成熟,一个 java -jar 就能跑起来,部署和演示都方便。Java毕业设计里80%以上都选它,面试官也认这个技术栈。配套的持久层框架我建议用MyBatis-Plus,原因在于这类管理系统大量涉及单表CRUD和简单的多表查询,MP能免掉大量重复的Mapper XML编写,分页插件也现成,非常适合快速开发。
前端选Vue,目前主流是Vue 3 + Vite + Element Plus + Pinia + Vue Router,这套组合开发体验好,组件库颜值在线,做后台管理系统非常合适。很多学校课程还在教Vue 2,但答辩时用Vue 3.0是加分项,面试也能聊出新东西。如果时间紧或者不熟悉Vue,可以考虑后端直接渲染Template模板,但那样代码耦合度高,且和“前后端分离”的工程化趋势脱节,不建议。
另外,权限控制我选了JWT而不是传统Session,理由后面会展开说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计与核心表结构
2.1 核心表设计
数据库是整个进销存系统的地基,表设计决定了业务能否跑通。我按模块把核心表列一下:
- 用户相关:
sys_user(用户账号)、sys_role(角色)、sys_user_role(用户角色关联)。角色一般划分成管理员、采购员、收银员、仓管员,不同角色看到不同的菜单和操作按钮。 - 基础资料表:
product(商品表)、supplier(供应商表)、customer(客户表)、warehouse(仓库表)。 - 采购相关:
purchase_order(采购主表)、purchase_order_item(采购明细表)。 - 销售相关:
sale_order(销售主表)、sale_order_item(销售明细表)。 - 库存相关:
stock(库存表)、stock_record(出入库流水表)、stock_check(盘点单)、stock_check_item(盘点明细)。
我在设计时特别强调一点:订单相关的表必须分主表和明细表。原因很好理解,一张销售单可能包含多件商品,每件商品有自己的数量、单价、金额,如果塞在一张表里,一个字段存逗号拼接的字符串,那后续做统计汇总时会崩溃。主表存整单的统一信息,比如单号、总金额、状态、操作人、时间;明细表存每一件商品的详细信息,通过订单号外键关联。
2.2 商品表的关键字段设计
product 表有个细节值得说道说道。很多同学设计商品表时只放“商品名称、价格、库存数量”,但实际业务里,超市商品需要往下拆:商品分类、商品条码、品牌、规格、单位、进货价、零售价、会员价、库存预警值。
其中“条码”字段非常关键,因为超市前台扫描枪扫的就是它,做销售出库时按条码查商品速度会快很多,所以在条码字段上要建唯一索引。进价和售价必须分开存:进价是采购成本,售价是销售价格,报表里算毛利就是 sum(售价 - 进价) * 数量。
库存预警值容易漏。这个字段不是库存本身,而是一个业务阈值:当实时库存低于该值时,系统在首页给出预警提示,提醒采购员补货。它的存在能让项目演示时多出一个“库存预警”的亮点功能,成本却极低。
2.3 库存表建模的两个经验
库存最忌讳直接在每个商品上存一个“当前库存”字段完事。更合理的做法是单独建 stock 表,以“商品 + 仓库”为维度记录库存数量。为什么?因为超市可能有总仓和门店仓,同一件商品在不同仓库存量不同,如果只在一张 product 上放库存数字,仓库维度的数据就丢了。
同时,必须有 stock_record 流水表。这张表记录每一次库存变动:采购入库数量为正,销售出库数量为负,盘点调整也记为一条记录。库存表是“结果”,流水表是“过程”,所有库存数字都必须能通过流水对上账。面试官问我“怎么保证库存数据准确”,我的回答就是“一切变动皆流水,对不上就是bug”。这是进销存系统设计的核心思想。
另外,在 stock 表里要设计一个“版本号”字段或者用乐观锁机制,用于处理后面要讲的并发扣减库存问题,这是个高频面试考点。
3. 后端SpringBoot关键实现
3.1 项目分层与统一返回体
SpringBoot项目我按常见的四层结构组织:controller(接口层)、service(业务层)、mapper(数据访问层)、entity(实体类)。此外再加两个包:dto(接收前端参数)、vo(返回前端数据)。很多同学做毕设时entity、VO、DTO混用,前端需要什么就直接给实体类,这样图省事,但会产生两个问题:一是可能把密码等敏感字段泄露给前端,二是接口参数和表结构强耦合,改表就要改接口。
统一返回体我定义成 Result<T>:
java复制public class Result<T> {
private Integer code;
private String message;
private T data;
// 成功、失败静态方法...
}
所有接口都返回这个结构体,配合全局异常处理器 @RestControllerAdvice,把业务异常、参数校验异常兜底成一格式的JSON。这样前端拦截器只需要处理一次返回值格式,不用每个接口单独写try-catch。
3.2 JWT认证与角色权限控制
登录接口的逻辑:用户传入用户名密码,后端校验通过后生成一个JWT Token返回给前端。JWT里我放三个信息:用户ID、用户名、角色标识。前端拿到Token后存在localStorage,之后每次请求在Header里带上 Authorization: Bearer <token>。
后端用拦截器(HandlerInterceptor)统一校验Token,校验通过就把用户信息放入ThreadLocal供当前请求使用。角色权限我用一个简单方式处理:后端接口用自定义注解 @RequireRole("admin") 标记,拦截器里判断角色是否匹配。这样做不引入Spring Security那么重的框架,也能讲清楚权限控制的原理。
选JWT而不是Session的原因也要能说清楚:前后端分离架构下,前端可能部署在不同的域名/端口,Session依赖Cookie,天然有跨域限制;而JWT是无状态的,后端不存会话,扩展性好,适合微服务场景。当然JWT的缺点是Token失效控制麻烦,但对毕设场景完全够用。
3.3 库存扣减的并发控制
这个模块是进销存系统的灵魂,也是面试最容易深挖的问题。场景是这样的:两个收银员同时卖出同一件商品,如果代码是先查库存,再判断足够,再更新库存,假设库存只剩1件,两个请求同时都查到库存为1,都判定“库存够用”,都执行了扣减操作,结果库存变成-1,这就是典型的超卖。
解决方案我选了三种组合实施:
第一,数据库行锁。更新库存的SQL写成 UPDATE stock SET quantity = quantity - #{num} WHERE product_id = #{pid} AND quantity >= #{num},直接在SQL层面让数据库判断库存是否充足,返回受影响行数为0就说明库存不足,抛业务异常。这个方案简单可靠,是兜底方案。
第二,乐观锁。在 stock 表加 version 字段,更新时带上版本号,UPDATE stock SET quantity = quantity - #{num}, version = version + 1 WHERE product_id = #{pid} AND version = #{oldVersion},更新失败则重试或提示。这个方案能防止并发覆盖,但实现起来比方案一复杂。
第三,事务保证一致性。采购入库、销售出库涉及多张表(主表、明细表、库存表、流水表),必须整体放在一个事务里,任一步失败就回滚。为了让事务尽量短,我把库存校验、扣减、流水写入放在一个独立服务方法里,用 @Transactional(rollbackFor = Exception.class) 标注。
另外还有一个细节:在库存扣减之前要加分布式锁吗?毕设项目单机部署,用 synchronized 或者数据库行锁就能解决,不需要引入Redis分布式锁。如果论文里写“基于Redis的分布式锁解决并发问题”,但实际代码里根本没实现,答辩会翻车。所以项目要“有什么写什么”。
3.4 采购入库与销售出库的流程实现
这里以采购入库为例,我在Service层的实现分四步:
- 保存采购主表,生成采购单号,格式比如
PO20250625001,前端展示单号比自增ID好看得多。 - 循环保存采购明细表,同时校验商品ID是否存在、数量是否为正数。
- 更新库存表:如果该商品在该仓库的库存记录存在则增加数量,不存在则新增一条库存记录。
- 写入库存流水表,流水类型标记为“采购入库”。
销售出库逻辑类似,方向相反,但多两个细节:一是要判断客户(会员)信息,有会员价逻辑的按会员价结算;二是扣减库存时执行前面说的并发安全SQL。另外销售单支持“挂单”操作比较实用,前端可以用一个临时变量保存,不属于后端核心逻辑。
3.5 报表统计的SQL思路
报表统计主要三块:今日/本月销售额、销售趋势折线图、商品销售TOP10榜单。在MySQL里分别用 DATE_FORMAT(create_time, '%Y-%m-%d') 做日期分组、ORDER BY total_amount DESC LIMIT 10 排序实现。这些统计量建议在Service层直接查库,不引入额外的报表中间件。注意销售金额统计应该基于“已支付”的订单,别把“待支付”的草稿单也算进去,否则数字不准。
3.6 SpringBoot版本与依赖配置注意事项
做这个项目时,SpringBoot版本选择也踩过坑。建议直接用SpringBoot 2.7.x(对应JDK 8/11)或者SpringBoot 3.x(对应JDK 17+),但不要用最新刚发布的版本,因为很多第三方依赖还没有同步适配。新手容易犯一个错:在start.spring.io直接选最新版本,然后引入的依赖和教程不兼容,报一堆环境错误。我用的是SpringBoot 2.7.18 + MyBatis-Plus 3.5.3 + JJWT 0.11.5,这套组合亲测稳定。如果选SpringBoot 3.x,注意它基于Jakarta命名空间,很多老教程的 javax.persistence 要改成 jakarta.persistence,MyBatis-Plus也要用3.5.3以上的版本才支持。
application.yml 里几个关键配置列一下:
yaml复制server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/supermarket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 123456
servlet:
multipart:
max-file-size: 10MB
max-request-size: 100MB
mybatis-plus:
mapper-locations: classpath*:mapper/**/*.xml
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
jwt:
secret: your-secret-key-please-change-me-123456789
expire-hours: 24
这里说两个容易出问题的地方。数据库连接URL一定要加 serverTimezone=Asia/Shanghai,否则连库时报时区错误。max-file-size 和 max-request-size 除非你做了Excel导入导出的功能,否则用默认1MB也够,但如果做了批量导入,这两个配置得调大,不然上传文件直接报500。
4. 前端Vue核心实现
4.1 工程化搭建与目录结构
前端我用的Vite构建,比Webpack快得多。创建项目用 npm create vite@latest supermarket-web -- --template vue,然后安装依赖:
bash复制npm install vue-router@4 pinia element-plus axios echarts sass
目录结构按下面这样组织,养成好习惯:
code复制src/
├── api/ # 接口请求封装
├── assets/ # 静态资源
├── components/ # 公共组件
├── layout/ # 布局组件(侧边栏+顶栏+主体区)
├── router/ # 路由配置
├── store/ # Pinia状态管理
├── utils/ # 工具函数(axios封装等)
└── views/ # 页面视图
4.2 登录与路由守卫
登录页流程是:调用 /api/auth/login 接口拿到Token → 存到localStorage → 跳转首页。路由守卫用Vue Router的 beforeEach:
javascript复制router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.path === '/login') {
next()
} else if (!token) {
next('/login')
} else {
next()
}
})
这样一个简单逻辑就能保证未登录用户无法访问内部页面。但我建议不要只做“有无Token”判断,还要做“Token是否过期”的检查。实现方式是在 axios 响应拦截器里判断后端返回的 code,如果code是401,就清除本地Token并跳转登录页。这样做能及时处理Token过期,而不是等到接口报错才跳转。
4.3 Axios封装
Axios封装是整个前端项目的基础。我习惯新建 src/utils/request.js:
javascript复制import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
const request = axios.create({
baseURL: '/api',
timeout: 10000
})
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${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
},
error => {
if (error.response && error.response.status === 401) {
localStorage.removeItem('token')
router.push('/login')
}
ElMessage.error(error.message || '请求失败')
return Promise.reject(error)
}
)
export default request
这样的好处是业务代码里不用每个页面都写错误弹窗,统一处理、统一风格。跨域问题在开发环境下通过Vite的proxy配置解决:
javascript复制// vite.config.js
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
注意后端接口路径前面我都带 /api 前缀,这样前后端对接时的路径区分就靠它,不会搞混。
4.4 核心页面示例:商品管理
商品管理页面是典型的管理后台页面:搜索栏(名称、条码、分类) + 表格 + 分页 + 新增/编辑弹窗 + 删除。我用Element Plus的 el-table + el-pagination + el-dialog + el-form 组合完成。表格里商品图片用 el-image 展示,状态列用 el-tag 渲染。
一个细节是编辑弹窗打开后要做的三件事:重置表单校验状态 → 回填数据 → 打开Dialog。保存时先调用 this.$refs.form.validate() 做前端校验,通过再调接口。这些顺序看着不起眼,但没写过的人很容易漏,导致表单校验残留或数据回显不一致。
进价和售价输入框我建议用 el-input-number,而不是普通文本输入框,避免用户输入非数字字符。前端做好基本校验能减轻后端压力,但后端接口里的参数校验也不能省——前端校验可以绕过的,接口层面校验才是底线。
4.5 库存预警与首页可视化
首页放三个卡片:今日销售额、今日订单数、库存预警数。下面放一个销售趋势折线图和商品分类占比饼图,用ECharts实现。ECharts在Vue里的使用方式是先在 onMounted 里初始化图表实例,再调用 setOption 填充数据,数据来自后端接口。注意组件卸载时要 dispose 图表实例,否则切换路由时内存会泄,页面多了浏览器会卡。
库存预警页就是调一个后端接口,传阈值参数,返回库存低于阈值的商品列表。前端列表里加一个醒目的红色Tag标示“库存不足”,旁边放“生成采购单”按钮,点击后自动带出商品信息交给采购模块处理。这个功能串联了两个模块,演示效果很好,论文里也容易写出业务闭环。
5. 部署配置与常见问题排查
5.1 前端打包与部署
开发调试时前后端分开跑,部署时可以合并。前端执行 npm run build 生成 dist 目录,里面有静态文件。有两种部署方式:一是把 dist 丢给Nginx,反向代理 /api 到后端8080端口;二是把 dist 复制到SpringBoot的 src/main/resources/static 目录下,这样直接用8080端口访问,前后端就合并成一个服务了。
毕设演示和答辩,我用的是方式二,省去装Nginx的麻烦,一个 java -jar 跑起来就能展示。但如果是简历上写“前后端分离部署”,还是补一个Nginx配置会更有说服力:
nginx复制server {
listen 80;
server_name localhost;
location / {
root /opt/supermarket/dist;
index index.html;
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
}
}
这段配置有个关键点:try_files $uri $uri/ /index.html,因为Vue Router用的history模式,刷新某个子路由页面时Nginx要去重定向到index.html,否则会404。这个不加的话,刷新页面就报404,是部署时最容易踩的坑。
5.2 联调中的经典报错
整个项目开发中,有几类报错几乎每位新手都会遇到,我把排查思路和解决方式整理成一个速查表:
| 现象 | 原因 | 排查方向 |
|---|---|---|
| 前端请求后端接口报404 | 路径不对或跨域 | 看后端控制台是否收到请求;看代理配置target是否指对 |
| 上传图片报413 | Nginx或SpringBoot上传大小限制 | 改Nginx client_max_body_size,改SpringBoot配置 |
| 中文乱码 | 数据库连接URL没加编码参数 | URL加 characterEncoding=utf8;确认表字符集是utf8mb4 |
| 时间字段显示有8小时偏差 | 时区问题 | 应用层统一用 serverTimezone=Asia/Shanghai,前端格式化 |
| MySQL连接失败:Public Key Retrieval is not allowed | MySQL 8.0+认证策略 | JDBC URL加 allowPublicKeyRetrieval=true |
| 更新操作返回0但没报错 | 乐观锁版本号不匹配 | 检查更新SQL是否带了version条件 |
| Token过期后页面还在请求 | 没有做401统一拦截 | 在axios响应拦截器判断code=401,清理Token跳转登录 |
5.3 MyBatis-Plus分页失效问题
MyBatis-Plus的分页要配置分页插件,不是直接 new Page() 就行。我见过不少同学代码里写了分页但结果返回全量数据,就是因为漏配了 MybatisPlusInterceptor。配置方法:
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
配置了插件之后,分页查询返回的 IPage 对象里才有 total、pages、records 这些字段,前端才能正常渲染分页器。另外,如果写了自定义SQL做多表联查,记得分页参数也要传 IPage 才能分页。
5.4 答辩与面试高频问题汇总
做毕业设计不只要把代码写出来,更要能讲清楚。我把面试官和答辩老师问得最多的几个问题列一下,每个都给出参考答案思路:
- 为什么选前后端分离?答:前后端分离结构清晰,前端专注交互,后端专注数据接口,开发可并行、部署独立,适合团队协作,也符合企业主流开发模式。
- JWT和Session有什么区别?答:JWT无状态、服务端不存储会话、天然支持分布式;Session有状态、需要保存在服务端内存或Redis。JWT的主要问题是无法主动失效,但可以设置过期时间。
- 库存扣减怎么防超卖?答:三层防护,一是SQL条件判断库存,二是乐观锁version控制,三是事务保证多表操作一致性。
- 系统有哪些亮点?答:前后端分离、JWT权限控制、库存流水可追溯、库存预警自动关联采购单、ECharts多维统计报表。
- 遇到过什么难点?如何解决?答:可以说跨域与Token拦截问题、库存并发控制问题、前端刷新404问题,这些都能体现真实开发和排查能力。
5.5 数据库SQL优化经验
数据量小时看不太出来,一旦演示时往库存表插入几万条测试数据,查询速度就成问题。我在开发时加了三条索引:商品表条码字段唯一索引、流水表 (product_id, create_time) 联合索引、库存表 (product_id, warehouse_id) 唯一索引。加索引后查询明显快很多。另外,报表统计的SQL尽量只在当天数据上查,避免全表扫描。比如查询今日销售额,用 WHERE create_time >= CURDATE() 而不是 WHERE DATE(create_time) = CURDATE(),后者会导致索引失效,这一点面试时也能聊。
最后再分享一点个人体会
这个项目我前后大概写了三周,白天上班晚上抽空写。踩过最大的坑不是技术问题,而是“需求理解偏了”。最初我把重点放在界面漂亮上,花了很多时间调样式和动效,结果核心业务逻辑——库存流水追踪、订单状态流转——反而不够扎实。后来推倒重来,把所有精力放在业务闭环上,系统才算真正“能用”。所以做这类管理系统,最核心的衡量标准永远是:数据对不对、流程通不通、关键操作可不可追溯。
顺带说一句,这个项目的扩展空间很大。后续可以考虑接入Redis缓存热点商品信息和会话信息、用RabbitMQ处理订单创建后的库存异步入账、添加Excel批量导入商品、开发一个小程序商城端。把这些方向挑一个做深,简历的“项目亮点”就立起来了。如果你正在做同一选题,希望这篇从设计到落地的完整拆解能帮你少走点弯路,至少答辩时被问到“为什么”时,心里有底。
