“东方红食品公司采购管理系统”这个名字,听起来挺像学生在毕业设计里会选的项目,但实际上它的业务骨架非常典型。食品行业做采购,跟普通贸易公司完全不一样,原材料保质期短、供应商资质要按批次核验、价格波动大、采购审批链条长,这些痛点都会直接体现在系统设计里。Spring Boot + Vue 这套组合做这类管理系统,属于当前的主流方案,后端负责业务逻辑和数据,前端负责交互和展示,前后端分离之后,采购、库存、财务几块业务可以并行开发,后续接 ERP 或财务软件也方便。这篇博客我会把这个项目从设计思路、核心代码、本地部署到生产环境上线,完整拆一遍,最后再分享一些我实际调试这类系统时踩过的坑。如果你想拿这个项目做毕业设计或者学习全栈开发,这篇内容可以直接照着复现。
1. 项目整体设计与需求拆解
1.1 食品公司采购业务的核心痛点
很多同学拿到“XX管理系统”的题目,第一反应就是做增删改查。但食品公司采购管理系统的难点不在 CRUD,而在业务流程的连贯性。食品企业采购原材料的场景,往往是采购员根据生产计划提出申请,部门主管审批,采购经理复核,然后采购员下单给供应商,供应商送货后仓管员验收入库,最后财务根据入库单和采购合同做应付账款。整个过程是典型的多角色、多状态流程。
另一个痛点是供应商管理。食品行业对供应商资质要求很高,营业执照、食品生产许可证、质检报告这些证件都有有效期,系统里得有记录和到期提醒。原材料批次信息也需要留痕,一旦出质量问题,要能追溯到具体供应商和采购批次。这些需求就不是单纯一张表能搞定的,需要合理设计状态字段和关联关系。
1.2 系统模块拆解
这个项目按照采购业务流,我建议划分为这样几个模块:
| 模块 | 核心功能 | 涉及角色 |
|---|---|---|
| 供应商管理 | 供应商档案、资质证件、评级、历史合作记录 | 采购员、采购经理 |
| 采购申请管理 | 创建申请单、多级审批、状态流转 | 采购员、部门主管、采购经理 |
| 采购订单管理 | 订单生成、确认、发送供应商、收货确认 | 采购员、仓管员 |
| 入库管理 | 到货验收、批次登记、库存联动 | 仓管员 |
| 应付款管理 | 对账、付款登记、账期管理 | 财务人员 |
| 系统管理 | 用户、角色、菜单权限 | 管理员 |
其中采购申请到采购订单这两个环节最核心。申请单通过审批后,系统自动生成采购订单,这个设计能有效防止采购员跳过审批直接下单。入库时库存表联动增加,同时生成采购入库流水,财务对账时直接按流水来,避免月底手工对账。
1.3 为什么选 Spring Boot + Vue 前后端分离
前后端分离是当前做企业级管理系统的标配方案。后端专注提供 RESTful API,前端专注页面渲染和数据展示,两者通过 JSON 交互,团队可以并行开发。对于学习项目,分离架构还有一个好处:前端只需要一个静态服务器就能跑,后端接口单独调试可以用 Postman 或 Apifox,问题定位快。
Spring Boot 的优势在于自动配置和生态成熟。Java 做管理系统,事务管理、权限框架、代码生成的支撑都非常完善。Vue 这边,用 Element UI 或者 Element Plus 做后台管理界面效率极高,表格、表单、弹窗这些组件都是现成的,配合 Vue Router 做路由控制,Vuex 或 Pinia 做状态管理,几天就能把管理后台的界面框架搭起来。
2. 核心技术栈与关键代码解析
2.1 后端技术选型与项目结构
这个项目的后端,我建议采用 Spring Boot 2.7.x 版本搭配 MyBatis-Plus。Spring Boot 3.x 虽然已经出了很久,但很多企业项目还在用 2.x,原因很现实:生态兼容性问题。比如老牌的 Shiro、部分数据库驱动和生成器插件,在 3.x 上需要额外适配。做管理类系统,稳定性优先,2.7 是我个人推荐的选择。
数据库用 MySQL 8.0,关系型数据库处理这类结构化业务数据最合适。ORM 层选 MyBatis-Plus,它既保留了 MyBatis 的 SQL 灵活性,又提供了 BaseMapper 通用接口,单表 CRUD 不用写 SQL,多表查询自己定义 XML 或注解 SQL。代码生成器可以用 MyBatis-Plus 自带的 Generator,一键生成 entity、mapper、service、controller 四层代码,省去大量重复劳动。
项目标准结构建议这样分,参考了阿里巴巴 Java 开发手册的约定:
src/main/java/com/dfh/purchase/ ├── config/ // 配置类:跨域、拦截器、Swagger等 ├── controller/ // 接口层:接收请求,返回结果 ├── service/ // 业务层:事务、业务逻辑 ├── mapper/ // 数据访问层:MyBatis-Plus接口 ├── entity/ // 数据库实体类 ├── dto/ // 数据传输对象:接收前端参数、返回前端数据 ├── vo/ // 视图对象:聚合查询结果 ├── common/ // 公共类:返回结果封装、异常处理、工具类 └── security/ // 权限认证组件每个实体对应一张表,表结构设计时注意几个关键字段:每个业务表都保留create_time、update_time、create_by、update_by这几个审计字段,排查问题时候特别有用。状态字段用status或state,用数字表示不同状态,比字符串省空间,也方便条件查询。
2.2 前端核心设计与接口封装
前端使用 Vue 2 + Element UI 还是 Vue 3 + Element Plus,取决于项目实际情况。如果参考的模板是 Vue 2 的,直接沿用 Vue 2 是性价比最高的选择。Vue 3 的 Composition API 写起来更现代,但很多现成的后台模板、组件库生态还需要时间验证。我这里按 Vue 2 + Element UI 讲,因为大多数课程设计和毕业设计用的还是这套组合。
前端目录结构同样关键:
src/ ├── api/ // 接口请求模块,按业务拆文件 ├── assets/ // 静态资源 ├── components/ // 公共组件 ├── router/ // 路由配置,含动态路由、路由守卫 ├── store/ // 状态管理,Vuex的modules ├── views/ // 页面组件,按模块建文件夹 └── utils/ // 工具函数,axios封装等Axios 必须做统一封装。我见过不少项目每个页面单独写 axios 请求,token 处理、错误提示、响应拦截全乱套。统一封装的核心逻辑是:请求拦截器里从 localStorage 取 token,放到请求头;响应拦截器里根据后端返回的 code 字段统一处理成功和失败,401 状态直接跳登录页。一个比较精简的 axios 封装是这样子的:
import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 10000 }) // 请求拦截器 service.interceptors.request.use( config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = token } return config }, error => Promise.reject(error) ) // 响应拦截器 service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { Message.error(res.message || '请求失败') if (res.code === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(new Error(res.message)) } return res }, error => { Message.error(error.message || '网络异常') return Promise.reject(error) } ) export default service2.3 采购单状态流转的设计与实现
采购申请单的状态流转,是业务系统的核心逻辑。我建议用数字枚举来控制,每个状态对应数字标识,业务方法里通过状态判断来保证流程合法:
| 状态值 | 含义 |
|---|---|
| 0 | 草稿 |
| 1 | 待部门主管审批 |
| 2 | 待采购经理审批 |
| 3 | 审批通过,待生成订单 |
| 4 | 已生成订单 |
| 5 | 已驳回 |
这个流程在 Service 层实现,关键点在于状态变更必须和品目行项目在同一个事务里完成。比如“提交审批”方法,需要更新申请单状态为 1,同时记录审批日志。日志表单独建一张,字段包括申请单 ID、操作人、操作动作、操作时间、备注。这方便追溯整个采购申请的审批历史,出了问题能查到是哪一环、哪个人、什么时候处理的。
具体代码层面,Service 层这样处理状态流转:
@Service @Transactional(rollbackFor = Exception.class) public class PurchaseApplyServiceImpl extends ServiceImpl<PurchaseApplyMapper, PurchaseApply> implements PurchaseApplyService { @Override public boolean submitApply(PurchaseApplyDTO dto) { // 幂等校验:只有草稿状态才能提交审批 PurchaseApply apply = this.getById(dto.getId()); if (apply == null || apply.getStatus() != 0) { throw new BusinessException("当前状态不可提交"); } apply.setStatus(1); apply.setSubmitTime(new Date()); this.updateById(apply); // 记录审批日志 ApprovalLog log = new ApprovalLog(); log.setApplyId(apply.getId()); log.setAction("提交"); log.setOperator(SecurityUtils.getCurrentUserId()); approvalLogService.save(log); return true; } }这里用了@Transactional注解,确保状态更新和日志写入要么都成功要么都失败,不会出现状态变了但日志没记录的情况。SecurityUtils.getCurrentUserId()从当前请求上下文中获取操作人信息,这在后边的权限管理模块会讲到。
2.4 权限设计与拦截器实现
管理系统必须考虑权限,否则任何普通员工都能审批采购单就麻烦了。整体思路用 RBAC(基于角色的访问控制)模型:用户属于角色,角色拥有菜单权限。后端在登录成功后返回该用户的角色和权限标识,前端根据权限标识控制菜单显示和按钮可见性。
后端拦截器实现认证,建议用 JWT 无状态方案。用户登录成功后生成 token 返回前端,前端后续请求带上 token,后端通过拦截器解析 token 拿到用户身份。Spring Boot 中可以用 HandlerInterceptor:
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口 if (request.getRequestURI().contains("/login")) { return true; } String token = request.getHeader("Authorization"); // 解析token,失败则返回401 try { Claims claims = JwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); return false; } } }拦截器配置在 WebMvcConfigurer 中,指定放行路径和拦截路径。前端路由守卫则是另一层控制,没登录访问受限页面,直接重定向到登录页。前端路由守卫:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login') { next() } else { if (!token) { next('/login') } else if (to.meta.roles && to.meta.roles.indexOf(store.getters.role) === -1) { next('/403') } else { next() } } })前后端双重校验,后端保证数据安全,前端保证用户体验,这个设计思路一定要理解透。
2.5 Excel 导入导出与批量操作
采购管理系统里,供应商清单、采购物品明细这些数据,用户往往手里有现成的 Excel 表格,如果入库靠手工录入,效率极低。用 EasyExcel 工具库处理后端导入导出功能,是目前的主流方案,稳定性比 Apache POI 直接操作高很多,内存占用也低。
批量导入供应商的代码思路是这样的:
@ApiOperation("导入供应商Excel") @PostMapping("/import") public R<String> importSupplier(@RequestParam("file") MultipartFile file) throws IOException { List<SupplierImportRow> list = EasyExcel.read(file.getInputStream()) .head(SupplierImportRow.class) .sheet() .doReadSync(); List<Supplier> suppliers = list.stream().map(row -> { Supplier s = new Supplier(); BeanUtils.copyProperties(row, s); return s; }).collect(Collectors.toList()); supplierService.saveBatch(suppliers); return R.ok("成功导入" + suppliers.size() + "条数据"); }这里注意一个细节:导入前一定要做数据校验。Excel 里可能有多余空行、重复数据、日期格式不对等,建议在读取之后先校验再批量写入。实现校验时可以自定义一个导入模板,在实体类的 ExcelProperty 注解上加上下拉校验,比如状态列限制只能填“启用/停用”、日期列设置固定格式,这样用户填错时 Excel 直接提示,比后端做一堆校验判断更省事。
3. 本地部署流程与运行指南
3.1 环境准备清单
在本地把项目跑起来,并不复杂,但环境版本一定要选对,版本不对会折腾你大半天。
| 依赖项 | 推荐版本 | 注意事项 |
|---|---|---|
| JDK | JDK 1.8 或 JDK 11 | Spring Boot 2.7 兼容 |
| Maven | 3.6.3 及以上 | 配好阿里云镜像加速 |
| Node.js | 14.x 或 16.x | Vue 2 + Element UI 建议 14/16,不要用 18 以上 |
| MySQL | 5.7 或 8.0 | 8.0 注意驱动和时区配置 |
| IDE | IntelliJ IDEA | 安装 Lombok 插件 |
Node.js 版本这个坑要单独说。如果你用的是 Vue 2 的老项目,Node 18 以上运行npm install常常会报错,要么 node-sass 编译失败,要么依赖版本不兼容。最稳妥的办法是用 nvm 切换 Node 版本,项目根目录放一个.nvmrc文件,内容写lts/fermium,大家切换到 Node 14 环境,基本不会踩到 node-sass 的坑。
3.2 后端启动步骤
拿到源码后首先修改数据库配置。打开src/main/resources/application.yml,核心配置如下:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/dfh_purchase?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: autoserverTimezone=Asia/Shanghai这个是 MySQL 8.0 必须加的参数,不加会报时区错误。map-underscore-to-camel-case保证数据库字段create_time能自动映射到 Java 属性createTime。
接下来执行src/main/resources/sql/init.sql初始化数据库,这一步创建表结构和初始数据。初始数据很重要,否则系统连登录账号都没有。一般会内置一个 admin 用户。启动类直接运行 main 方法即可。
3.3 前端启动步骤
后端跑起来之后,进入前端目录执行命令:
# 安装依赖,建议用cnpm或者配置镜像 npm install # 启动开发服务器,默认端口8081 npm run dev前端开发服务器的跨域问题必须在前端配置里处理。项目根目录创建vue.config.js:
module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '/api' } } } } }开发环境通过代理解决跨域,生产环境则用 Nginx 反向代理。这里有个细节:如果前端请求路径是/api/user/login,后端接口路径是/api/user/login,proxy 不用重写;如果后端接口没有/api前缀,才需要 pathRewrite。配置代理时先搞清楚需求路径规则,别上来就抄网上配置,路径不对排查半天。
3.4 前后端联调检查清单
本地跑起来之后,按照这个清单逐项验证,能节省大量排错时间:
- 浏览器输入
http://localhost:8081/login,页面能打开 - 输入初始账号密码,登录请求能在 Network 面板看到
- 后端 console 有 SQL 输出,说明数据库连接正常
- 登录成功后能跳到首页,菜单权限按角色渲染
- 打开供应商管理页,列表能加载出数据
- 新建一条采购申请单,提交审批,状态流转符合预期
如果你按这个清单走完,发现第 2 步登录接口返回 500,多半是数据库没初始化成功,仔细检查 Mapper 对应的表是否存在。如果返回 401,说明 token 解析有问题,重点看拦截器代码。
4. 生产环境部署方案
4.1 后端打包与 Linux 部署
本地开发验证通过之后,上生产环境又是另外一回事。后端打包很简单:
# 跳过测试,打包jar mvn clean package -DskipTests打出来的 jar 包在target/dfh-purchase.jar。Linux 服务器上必须有 JDK 环境,用java -jar dfh-purchase.jar前台运行只适合临时验证,正式环境建议用 systemd 做成守护进程,这样kill之后进程能自动重启:
# /etc/systemd/system/dfh-purchase.service [Unit] Description=DFH Purchase Management System After=network.target mysqld.service [Service] Type=simple WorkingDirectory=/opt/dfh-purchase ExecStart=/usr/bin/java -Xms512m -Xmx1024m -jar /opt/dfh-purchase/dfh-purchase.jar Restart=always RestartSec=10 User=root [Install] WantedBy=multi-user.target启动命令:
systemctl daemon-reload systemctl enable dfh-purchase systemctl start dfh-purchase systemctl status dfh-purchase生产环境的 MySQL 密码不要明文写在 application.yml 里提交到 Git。用环境变量方式覆盖配置,application.yml里写${MYSQL_PASSWORD:},然后在 systemd 配置里通过 Environment 注入,或者使用 Spring Boot 的--spring.datasource.password=xxx启动参数。这是一个很小但很重要的安全意识。
4.2 前端打包与 Nginx 配置
前端打包命令:
npm run build打包产物在dist目录下。把这个目录复制到服务器/opt/dfh-purchase-web,然后配置 Nginx:
server { listen 80; server_name buy.dfh.com; # 前端静态资源 root /opt/dfh-purchase-web; index 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; } # Vue Router history模式需要配置 location / { try_files $uri $uri/ /index.html; } }try_files $uri $uri/ /index.html;这一行非常关键。Vue Router 默认使用 history 模式,直接访问http://buy.dfh.com/supplier时服务器找不到对应的物理文件,如果没有 try_files 会返回 404,加上这行配置就会回退到 index.html,然后由前端路由接手处理。
4.3 数据库初始化与定期备份
生产环境数据库初始化不能直接用本地开发的 init.sql 盲目执行。先在生产 MySQL 上创建独立账号,分配最小权限,只给该账号当前库的增删改查权限。然后用 mysql 命令导入表结构:
mysql -u dfh_admin -p -h 127.0.0.1 dfh_purchase < init.sql定期备份是管理系统的最后一道防线。每天凌晨 2 点执行 mysqldump 备份,保留最近 7 天备份文件,写到 crontab 里:
0 2 * * * mysqldump -u dfh_admin -p'密码' dfh_purchase > /backup/dfh_purchase_$(date +\%Y\%m\%d).sql备份文件建议保留在独立磁盘或者对象存储中,不要和数据库放在同一个机器上,防止磁盘损坏导致备份一起丢失。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
下面这些异常情况,是我在部署和调试多个同类项目时反复遇到的问题,整理成表格,你可以直接对照排查。
| 异常现象 | 可能原因 | 解决方案 |
|---|---|---|
| 登录接口返回 500 | 数据库连接失败或表不存在 | 检查 MySQL 账号密码、IP 白名单、serverTimezone |
| 登录成功但页面空白 | 前端路由守卫跳转逻辑问题 | 看浏览器控制台报错,检查 permission 相关逻辑 |
| 列表数据加载缓慢 | SQL 未走索引 | 检查 where 条件的字段是否建立索引 |
| 上传的 Excel 文件解析中文乱码 | 文件编码不是 UTF-8 | 指定读取编码,EasyExcel 增加编码参数 |
| 跨域报错 No 'Access-Control-Allow-Origin' | 生产环境没配置代理 | 用 Nginx 反向代理解决,不要在代码里放开所有跨域 |
| Vue 页面刷新 404 | Nginx 缺少 try_files | 添加try_files $uri $uri/ /index.html; |
5.2 排查思路与调试工具
遇到问题别急着改代码,先定位问题在哪一层。后端问题用 Postman 或 Apifox 直接调接口,看返回 JSON 状态码和错误信息,排除前端干扰。如果是接口返回数据格式不对,优先看后端代码;如果是接口正常但页面渲染不出来,看前端 Console 报错和 Network 面板的响应。
MyBatis-Plus 的 SQL 输出习惯很重要。开发环境把log-impl配置成 StdOutImpl,控制台能看到执行的 SQL 和参数,这比瞎猜 SQL 错误快得多。生产环境就关掉日志打印,避免 SQL 里的敏感数据刷屏。
5.3 我可以给到的几条经验心得
做这种管理系统项目,我最大的体会是状态设计和日志设计一定要提前做。很多同学原型都没画完就急着写代码,写着写着发现采购申请单没有“撤销”操作,又回头改表结构。表结构一改,前端组件、接口文档、测试用例全都要跟着改,代价成倍增长。我的建议是,无论如何先用思维导图把每个模块的状态机画清楚,再设计数据库,最后才动代码。
另一个经验是安全细节不要忽略。JWT 的密钥不要硬编码在代码里,数据库密码不要提交到 Git,这些习惯在工作之后非常重要。还有,接口返回的信息不要太详细,避免直接把 SQL 异常堆栈抛给前端,这既是安全考虑,也是用户体验考虑。
最后说一句跟项目本身关系不大但每个开发都会遇到的问题:代码注释。这个项目的源码讲解文档里,我特别建议大家把每个状态值用枚举或常量类维护起来,而不是散落在业务代码里写魔法数字。如果哪天审批流程加了一个“财务复核”节点,聪明的注释和常量定义能让你的改动成本降低一半以上。