ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot2+Vue3+MyBatis-Plus物资管理系统开发实战

SpringBoot2+Vue3+MyBatis-Plus物资管理系统开发实战 去年接到一个仓库管理项目时客户那边还靠 Excel 表格和微信群消息来记账谁领了什么、入库单在哪、哪个供应商发过什么货全靠人工翻记录。需求聊到一半我就确定了要做的事——不是一个算法多复杂的系统而是一个能跑起来、能维护、上线后不出幺蛾子的 Java Web 物资综合管理系统。技术栈最终定为 SpringBoot2 Vue3 MyBatis-Plus MySQL8.0。这套组合不是最新的也不是最“炫”的但对要快速交付、还要把源码和文档一起交出去的场景来说它是最稳的。这篇内容我会把整个系统的选型逻辑、数据库设计、后端落地、Vue3 前端实现、MySQL 8.0 部署踩坑以及配套文档的整理方式完整过一遍。适合正在做毕业设计、接外包项目、或者想把公司内部 Excel 流程系统化的人参考。想直接抄作业的照着后面的步骤和代码搭一套完全没问题。1. 为什么是这套组合SpringBoot2 Vue3 MyBatis-Plus MySQL8.0 的选型复盘1.1 这个系统到底要解决什么问题物资管理这类系统业务边界其实比多数人想象得清晰。核心就四件事物资台账、入库、出库、盘点。稍微复杂一点的公司会再叠加供应商管理、部门领用统计、报废处理、库存预警。它不需要分布式事务、不需要高并发削峰但需要保证一件事账实一致。接手这类项目最常见的失败做法是一上来就堆技术。微服务、Redis、消息队列全上最后代码写了一大堆客户却连一个入库单都录不进去。我的习惯是先把流程画出来谁来创建入库单、谁来审批、谁有权限查看所有部门领用记录、盘点差异怎么处理。角色和流程定了技术选型才有依据。1.2 备选方案对比为什么不选 SpringBoot3、MyBatis XML、Vue2给中小型内部系统做选型我会同时关注三个维度交付速度、维护成本、新手能否接手。下面是当时对比过的主流方案。选型维度最终选择备选方案决策原因后端框架SpringBoot 2.7.18SpringBoot 3.xBoot 3 强制 JDK 17很多客户服务器还是 JDK 8Boot 2.7 是 2.x 收官版本兼容性最好ORM 框架MyBatis-Plus 3.5.3JPA / 原生 MyBatis XMLJPA 联表复杂查询难控 SQL原生 XML 写增删改查太啰嗦MP 的 CRUD 和分页开箱即用前端框架Vue3 Element PlusVue2 Element UIVue3 是长期演进方向Composition API 对中后台复杂表单更友好数据库MySQL 8.0MySQL 5.78.0 的窗口函数、JSON 类型、utf8mb4 默认支持新项目没必要再选 5.7这里必须说一句SpringBoot2 和 JDK8 的组合在 2024 年之后看起来“老”但它恰恰是网上资料最丰富、第三方兼容性最稳定的组合。如果你的团队全员 JDK17选 Boot3 没问题如果交付对象是学校机房的电脑、小公司的旧服务器Boot2 JDK8 会让你少处理无数个环境报错。1.3 版本选型里容易被忽略的坑JDK、Node、依赖版本要锁死版本“锁死”是这类项目复现的关键。系统源码交到别人手里最怕的就是他用的 JDK 和你不一致。我的推荐组合JDK 1.8要求 8u201 以上Maven 3.6.3Node.js 16 或 18Vue3 Vite 5 在 Node 18 下运行最稳SpringBoot 2.7.18MyBatis-Plus 3.5.3.2mysql-connector-j 8.0.33注意它已经改名为com.mysql:mysql-connector-j不再是老的mysql-connector-javaVue 3.3 Element Plus 2.4 Pinia Vue Router 4把这些版本写进 README而不是让使用者自己猜。很多“源码跑不起来”的问题90% 都是版本漂移导致的。2. 先把业务盘清楚物资管理系统的表结构和数据模型2.1 核心业务链路与角色划分我这次做的是标准物资管理流程采购/入库 → 库存更新 → 部门领用出库 → 定期盘点。角色至少要分三类管理员维护物资分类、供应商、用户账号查看全部单据仓库保管员录入入库单、出库单执行盘点部门用户/审批人提交领用申请或审批领用单权限模型用最简单的 RBAC 就行不需要引入复杂的 Shiro、Spring Security 配置。一张sys_user表、一张sys_role表、一张sys_user_role关联表再加上前端路由的 meta 角色判断足够覆盖 90% 的物资管理场景。2.2 主表、明细表、库存表怎么拆数据模型是整个系统的地基。我设计的核心表如下表名用途关键字段sys_user / sys_role / sys_user_role用户与角色username, password, role_codematerial_category物资分类支持树形层级parent_id, name, codematerial_info物资基本信息category_id, material_code, material_name, spec, unit, low_stock_warningsupplier供应商supplier_name, contact, phone, addressinbound_order / inbound_order_item入库单主表 明细order_no, supplier_id, statusmaterial_id, qty, unit_priceoutbound_order / outbound_order_item出库单主表 明细order_no, apply_dept, statusmaterial_id, qtystock实时库存material_id, total_qty, locked_qty, versionstock_check / stock_check_item盘点单及明细check_no, check_timebook_qty, real_qty, diff_qty这里最核心的设计习惯是单据主表和明细表一定要拆开。一张入库单有 5 种物资我不会在入库单表里放 5 个数量字段而是用主表存“这张单是谁建的、状态是什么、总金额多少”用明细表存“这张单具体收了哪几个物资、每个多少”。这样做的好处有三点数据不冗余修改明细不影响主表统计方便SUM明细就能得到订单汇总打印单据时主表明细天然就是一对多的结构库存表则不建议直接在material_info里加一个qty字段。库存是高频变更数据应该独立成表用material_id做唯一键这样盘点、锁定、预警都更容易写 SQL。2.3 库存扣减的并发问题为什么不能先查再改这是我在代码评审里最常纠正的问题。很多人写扣库存是这样Stock stock stockMapper.selectByMaterialId(materialId); if (stock.getTotalQty() needQty) { throw new RuntimeException(库存不足); } stock.setTotalQty(stock.getTotalQty() - needQty); stockMapper.updateById(stock);看起来没问题但两个人同时领用最后一个库存时两边都查到了总库存是 10都判断“够”然后各自扣减最终库存变成负数或错账。正确做法是直接把“判断库存是否充足”和“扣减”合并成一条原子 SQLUPDATE stock SET total_qty total_qty - #{qty}, version version 1 WHERE material_id #{materialId} AND total_qty - #{qty} 0;Mapper 方法返回受影响行数返回 0 就说明库存不足直接抛异常。这样再高的并发数据库也会帮你串行化处理不需要锁表也不需要引入 Redis 分布式锁。同理入库增加库存用total_qty total_qty #{qty}即可然后用INSERT ... ON DUPLICATE KEY UPDATE处理首次入库时库存记录不存在的情况。3. 后端落地MyBatis-Plus 通用 CRUD 和真正的业务逻辑3.1 工程结构与通用 CRUD 的基础设施后端工程建议按模块分包不要把所有类塞进一个包com.example.materials ├── controller // 接收参数返回结果 ├── service // 业务逻辑事务边界 ├── mapper // MyBatis-Plus 的 Mapper 接口 自定义 XML ├── entity // 数据库实体 ├── dto // 前端入参对象 ├── vo // 返回给前端的视图对象 └── config // 配置类pom.xml 里这样引入核心依赖parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /dependency /dependenciesMyBatis-Plus 的全局配置我建议这样写mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: id-type: assign_id logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 mapper-locations: classpath:/mapper/*.xml这里有个容易被忽略的点logic-delete-field开启后所有实体的逻辑删除字段都会被 MP 自动处理删除操作变成UPDATE ... SET deleted 1查询自动追加deleted 0。物资进出单据这种需要审计的数据一定不能物理删除所以全局逻辑删除非常合适。3.2 用 Db 工具类实现无状态增删改查MyBatis-Plus 从 3.5.3 开始提供了Db工具类可以摆脱繁琐的 Service、Mapper 注入直接实现无状态的增删改查。所谓“无状态”就是不需要在当前类里持有任何 Spring 容器中的对象写起来和直接用工具类一样。// 新增一条物资分类 Category category new Category(); category.setName(紧固件); category.setCode(GDJ); Db.save(category); // 按条件查询 ListMaterialInfo list Db.lambdaQuery(MaterialInfo.class) .eq(MaterialInfo::getCategoryId, categoryId) .orderByDesc(MaterialInfo::getCreateTime) .list(); // 根据 ID 查询后修改 MaterialInfo material Db.getById(MaterialInfo.class, id); material.setSpec(M6*20); Db.updateById(material); // 分页查询 IPageMaterialInfo page Db.page(new Page(1, 10), MaterialInfo.class, new LambdaQueryWrapperMaterialInfo() .like(MaterialInfo::getMaterialName, keyword));用Db工具类最大的好处是简单接口不用再写一整套 Controller → Service → Mapper 三件套代码量直接砍半。但它的边界也很清楚一旦 SQL 涉及多表联查、复杂统计、报表聚合就要回到自定义 Mapper XML别硬用 LambdaQueryWrapper 拼。3.3 入库单创建的完整链路事务、库存更新、日志CRUD 只是脚手架真正的业务逻辑在“创建入库单”这种关联操作上。我的标准写法是把所有库存变更和单据落库放在一个事务里Transactional(rollbackFor Exception.class) public void createInboundOrder(InboundOrderDTO dto) { // 1. 保存入库单主表 InboundOrder order new InboundOrder(); order.setOrderNo(generateOrderNo(RK)); order.setSupplierId(dto.getSupplierId()); order.setTotalAmount(dto.getItems().stream() .mapToLong(item - item.getQty() * item.getUnitPrice()).sum()); Db.save(order); // 2. 保存明细 for (InboundOrderItemDTO itemDTO : dto.getItems()) { InboundOrderItem item new InboundOrderItem(); item.setOrderId(order.getId()); item.setMaterialId(itemDTO.getMaterialId()); item.setQty(itemDTO.getQty()); item.setUnitPrice(itemDTO.getUnitPrice()); Db.save(item); // 3. 更新库存首次入库时自动创建库存记录 stockService.increaseStock(itemDTO.getMaterialId(), itemDTO.getQty()); } // 4. 写入操作日志 operationLogService.record(CREATE_INBOUND, order.getId(), dto.getRemark()); }increaseStock的 SQL 写成INSERT INTO stock (material_id, total_qty, locked_qty, version) VALUES (#{materialId}, #{qty}, 0, 1) ON DUPLICATE KEY UPDATE total_qty total_qty #{qty}, version version 1;出库则调用前面那条带库存判断的UPDATE ... AND total_qty - #{qty} 0受影响行数为 0 时抛出业务异常整个事务回滚订单不会保存成功。这是整个后端最值得注意的设计比其他任何炫技都重要。4. Vue3 前端中后台管理的组合式 API 实践4.1 Vue3 与 Vue2 最大差别Composition API 到底改变了什么前端我用了 Vue3 Element Plus Pinia Vue Router。Vue3 对中后台开发最直接的影响是 Composition API它把“同一业务的数据和方法”从一堆 Options 中抽出来。Vue2 时代列表页要写data、created、methods代码一长同一个物料的功能散落在文件各处export default { data() { return { list: [], loading: false } }, created() { this.loadList() }, methods: { async loadList() {} } }Vue3 的script setup写法更接近按业务组织script setup import { ref, onMounted } from vue import { listMaterialApi } from /api/material const list ref([]) const loading ref(false) const loadList async () { loading.value true try { const data await listMaterialApi() list.value data } finally { loading.value false } } onMounted(loadList) /script还有一个高频误区ref和reactive到底怎么选。我的建议是普通变量状态一律用ref因为赋值、传给函数都不用担心丢失响应性只有复杂嵌套对象才考虑reactive。而且reactive对象一旦被解构响应性就断了这是很多新手必踩的坑。4.2 动态增删表单行header/detail 编辑最常用的写法物资入库单、出库单的前端页面天然需要“动态添加删除 form 表单一行数据”。Element Plus 里动态表单校验的关键点是prop必须写成字符串路径。el-form :modelform refformRef div v-for(item, index) in form.items :keyindex classorder-row el-form-item :propitems. index .materialId :rules{ required: true, message: 请选择物资, trigger: change } el-select v-modelitem.materialId filterable placeholder选择物资 el-option v-form in materialOptions :keym.id :labelm.materialName :valuem.id / /el-select /el-form-item el-form-item :propitems. index .qty :rules{ required: true, message: 请输入数量, trigger: blur } el-input-number v-modelitem.qty :min1 / /el-form-item el-button typedanger clickremoveRow(index)删除/el-button /div el-button typeprimary plain clickaddRow添加一行/el-button /el-form添加一行就是form.items.push({ materialId: null, qty: 1 })删除一行就是form.items.splice(index, 1)。要注意的是删除中间行后后面的行索引会变化prop里的路径会跟着变Element Plus 的校验会自动刷新所以不需要手动清校验。真正要小心的是提交之前先formRef.validate()并且把form.items里空行过滤掉避免后端收到materialId为 null 的脏数据。4.3 请求封装、路由权限和几个样式细节axios 实例统一封装是最基本的工程化动作。我在src/api/request.js里做了请求拦截和响应拦截import axios from axios const request axios.create({ baseURL: /api, timeout: 15000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) config.headers.Authorization Bearer token return config }) request.interceptors.response.use( res { // 后端统一返回 { code, msg, data } if (res.data.code ! 200) { alert(res.data.msg) return Promise.reject(new Error(res.data.msg)) } return res.data.data }, err { if (err.response err.response.status 401) { localStorage.removeItem(token) window.location.href /login } return Promise.reject(err) } )路由守卫里做登录态和角色判断router.beforeEach((to) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) return /login const user JSON.parse(localStorage.getItem(user) || {}) if (to.meta.roles !to.meta.roles.includes(user.role)) return /403 return true })样式方面Element Plus 的 Tabs、Dialog 这些组件在用 scoped 样式覆盖时记得加:deep():deep(.el-tabs__item.is-active) { font-weight: 600; color: #409eff; }另外有个容易被验收卡住的需求是 OFD 文件预览。如果业务里有入库回执、电子发票预览PDF 可以直接用 iframeOFD 在浏览器的原生支持还不行可以引入社区封装好的 Vue3 OFD 组件或者让后端把 OFD 转成 PDF 再预览。这块不必自己造轮子。5. MySQL 8.0安装、连接和编码这些绕不开的问题5.1 Docker 方式安装 MySQL 8.0 并初始化项目库开发环境和测试环境我习惯用 Docker 跑 MySQL 8.0省去本机装服务的麻烦。一条命令就能起来docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDroot123 \ -e MYSQL_DATABASEmaterials \ -e MYSQL_USERapp \ -e MYSQL_PASSWORDapp123 \ -v $PWD/mysql-data:/var/lib/mysql \ -v $PWD/mysql-init:/docker-entrypoint-initdb.d \ mysql:8.0 \ --character-set-serverutf8mb4 \ --collation-serverutf8mb4_unicode_ci这里有一个非常容易踩的坑/docker-entrypoint-initdb.d里的初始化 SQL 只在数据目录为空、即容器第一次创建时执行。如果你先启动了一次容器再把init.sql丢进mysql-init目录重启容器是没用的SQL 不会自动执行。正确做法是先放 SQL再跑容器或者手动执行docker exec -i mysql8 mysql -uroot -p init.sql。5.2 JDBC 连接和 ODBC 工具的经典报错MySQL 8.0 和旧版本最大的区别是默认认证插件改成了caching_sha2_password。JDBC 连接串如果少了参数最常见的报错是Public Key Retrieval is not allowed。解决方案是在 JDBC URL 里加jdbc:mysql://localhost:3306/materials?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8allowPublicKeyRetrievaltrue三个参数要记牢allowPublicKeyRetrievaltrue允许客户端从服务器获取公钥解决Public Key Retrieval报错serverTimezoneAsia/Shanghai解决时区报错比如The server time zone value is unrecognizeduseSSLfalse内网开发环境不需要 SSL避免不必要的握手警告还有个 Windows 下的经典问题装 MySQL 8.0 的 ODBC 驱动后用 Excel、Power BI 连不上控制面板里查看 ODBC 驱动无效。这通常不是驱动本身的问题而是缺少 Microsoft Visual C 2015-2022 Redistributable 运行库。装完 VC 2015 以上版本运行库再装驱动问题就消失了。很多项目文档不会提这个嵌入到部署文档里能帮使用者省一晚上的时间。5.3 utf8mb4、排序规则和索引设计建议MySQL 8.0 默认字符集就是 utf8mb4比 5.7 的utf8其实是 utf8mb3更稳。建表时我仍然建议显式指定CREATE TABLE material_info ( id BIGINT NOT NULL COMMENT 主键ID, material_code VARCHAR(64) NOT NULL COMMENT 物资编码, material_name VARCHAR(128) NOT NULL COMMENT 物资名称, PRIMARY KEY (id), UNIQUE KEY uk_material_code (material_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci COMMENT物资信息表;排序规则建议根据版本选utf8mb4_unicode_ci或 MySQL 8.0 默认的utf8mb4_0900_ai_ci。像物资名称这种方式比较少ci结尾的排序规则能做到大小写不敏感够用且稳定。索引方面记住三条唯一性字段建唯一索引物资编码、单号明细表的外键字段建普通索引order_id、material_id不要对material_name这种字段直接建索引然后指望LIKE %关键词%能走索引前导模糊查询是索引失效重灾区。真正有全文检索需求直接接 ES 或者用 MySQL 全文索引不要在业务代码里自己遍历。6. 源码配套文档怎么写从部署说明到接口清单6.1 文档目录怎么设计标题里写着“含文档”如果文档只是把代码贴一遍那还不如不写。我交付源码时的文档目录是这样设计的/README.md # 项目简介、技术栈、快速启动、默认账号 /docs/ deploy.md # 环境要求 数据库初始化 前后端启动步骤 database.md # 数据字典、表结构说明、初始化数据说明 api.md # 接口清单、请求示例、返回结构说明 screenshots.md # 关键页面截图 操作流程说明 /sql/ init.sql # 建库建表语句 演示数据 /postman/ materials.postman_collection.json # 可直接导入 Postman 的接口集合README 开篇就写清楚三件事这个系统能做什么、技术栈是什么、怎么让它快速跑起来。切忌一上来就贴架构图。6.2 “能把项目跑起来”的文档才算合格写部署文档时我要求自己达到这个标准一个没看过代码的人照着文档从头到尾操作一遍能在 30 分钟内把项目跑起来。所以文档里必须包含精确的 JDK、Node、Maven 版本号而不是“JDK 8 以上”数据库初始化命令或 Docker 启动命令后端配置文件里需要改哪些参数比如数据库密码、端口前端启动命令以及接口代理配置举个例子Vite 前端代理要写明// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }不写这一条前端发请求到/api时会代理到 8080 后端错过的人会卡在跨域上半天。写文档这件事有一个很现实的收益源码交付后使用者最多的提问就是“怎么跑不起来”。把这些答案提前写进文档后面至少能少接一半的售后提问。我的习惯是每写完一个接口顺手在api.md里加一行请求示例不要攒到最后再补最后补文档一定会偷工减料。最后分享一个我个人的写文档技巧默认账号和默认密码一定要醒目写清楚。很多系统交付后使用者第一件事就是登录如果账号密码藏在代码注释里体验会打很大折扣。把这些跑通没跑通的经验沉淀进文档比任何花哨的架构设计都更“值钱”。
返回列表