
简介这是一套基于SpringBoot与Vue的前后端分离ERP管理系统源码面向需要学习企业级Web开发、或希望快速搭建进销存与权限管理系统的Java工程师。系统运行在Java8、MySQL5.7环境下使用SpringBoot2.1.0整合JPA处理数据持久层前端以Vue和ElementUI构建页面涵盖销售开单、销货历史、进货开单、供应商管理、仓库管理、商品管理、客户管理、用户角色与菜单管理等模块能够完整体现从商品入库到销售出库、供应商对账、客户欠款追踪及后台权限分配的核心业务链路。压缩包内共计438个文件包含119个xml配置文件、73个java后端源码、39个js与23个vue前端页面文件以及少量sql数据库脚本、yml环境配置和依赖管理文件整体大小14.4MB目录结构清晰便于对照学习。该资源已有6260人浏览学习适合作为毕业设计选题、课程综合实践或企业二次开发的参考基础有助于快速理解SpringBoot与Vue整合开发的全过程。1. 为什么 ERP 管理系统要用 SpringBoot Vue 这套组合ERP 系统的核心业务流程并没有那么玄但很多开发者是栽在“改一处牵全身”上。早年的 JSP 单体项目销售开单、进货、仓库、客户这些模块混在一个 war 包里前端页面和后端 SQL 互相咬着一个字段变化要在一堆 JSP 里找。后来我在一个内部管理项目里改用 SpringBoot 2.1 Vue ElementUI 重写发现关键是前后端彻底拆开SpringBoot 只负责对外暴露 APIVue 负责页面和路由MySQL 5.7 存业务数据JPA 负责加速 CRUD。这套基于 SpringBootVue 的 ERP 管理系统就是按这个思路实现的销售开单、销货历史、进货开单、供应商、仓库、商品、客户、用户、角色、菜单这些模块已经打通适合想快速拿到一套可运行后台并继续改造成生产项目的团队。2. SpringBoot 2.1 后端骨架与 JPA 实体映射设计2.1 依赖选型JAVA8、SpringBoot 2.1.0 与 MySQL 5.7 的配合这个系统的后端骨架非常典型JAVA8 SpringBoot 2.1.0 Spring Data JPA MySQL 5.7。选 SpringBoot 2.1.0 有现实原因它对应 Spring Framework 5.1对 JDK8 的兼容最稳如果升到 SpringBoot 2.4 以上spring.datasource.driver-class-name不需要显式声明但很多老项目中的配置和第三方库会出现奇怪的兼容问题例如 Jackson 时间格式默认值改变导致LocalDate序列化结果变数组。下面这张依赖表基本就是这套 ERP 管理系统的核心依赖组合依赖作用建议版本spring-boot-starter-web提供 REST API 与内嵌 Tomcat2.1.0.RELEASEspring-boot-starter-data-jpa集成 Hibernate 与 JPA 自动建表2.1.0.RELEASEmysql-connector-java连接 MySQL 5.7 的 JDBC 驱动5.1.47在 pom.xml 中需要显式指定 mysql 驱动版本因为 SpringBoot 2.1.0 默认管理的驱动版本是 5.1.46换成 5.1.47 是为了兼容较新的 MySQL 5.7 小版本。如果直接用 Maven 里最新驱动 8.x又需要额外配置com.mysql.cj.jdbc.Driver反而会偏离原本的部署环境。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.1.0.RELEASE/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version5.1.47/version scoperuntime/scope /dependency /dependencies这段配置的关键在于 parent 的version锁定了整个 SpringBoot 生态版本子依赖不需要再写版本号mysql 驱动因为在 parent 的依赖管理中版本为 5.1.46所以这里显式覆盖成 5.1.47。scope 用runtime是因为驱动只在运行期使用编译代码时不需要引用它的类。对应的 application.yml 我倾向于这样配置spring: datasource: url: jdbc:mysql://localhost:3306/erp?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.jdbc.Driver jpa: database: mysql hibernate: ddl-auto: update show-sql: trueddl-auto: update在开发期会自动为实体类建表仓库、商品、客户这些表第一次启动就能生成。生产环境要把这个值改成validate或none避免高权限账号自动改动表结构。show-sql: true便于查看实际 SQL但高并发环境建议关掉因为它会把每一条执行的 SQL 都打日志拖慢 IO。2.2 实体建模商品、仓库与关联关系ERP 里的商品管理不能只放一个商品名它要和仓库关联。一个仓库可以有很多商品商品在某个仓库里有独立库存和定价。在 JPA 里这就是典型的ManyToOne关系先看仓库实体Entity Table(name warehouse) public class Warehouse { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false, length 50) private String name; private String manager; private String phone; }仓库字段和摘要中“仓库名称、负责人、联系方式”一一对应。主键id使用GenerationType.IDENTITY依赖 MySQL 自增插入后能立刻拿到主键值这在创建仓库后继续新增商品时特别方便。商品实体关联仓库Entity Table(name product) public class Product { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false, length 100) private String name; private Integer stock; Column(precision 10, scale 2) private BigDecimal price; private String status; ManyToOne(fetch FetchType.LAZY) JoinColumn(name warehouse_id) private Warehouse warehouse; }JoinColumn(name warehouse_id)会在 product 表生成warehouse_id外键列前端传参时只要传warehouseId即可。注意ManyToOne的fetch FetchType.LAZY查询商品列表时不会立刻查仓库表避免 N1 查询需要仓库名称时再通过关联对象延迟加载。如果不在乎这点性能改成默认EAGER也可以但商品列表中 n 条记录会触发 n 次仓库查询建议保留 LAZY。2.3 Repository 层先定查询方法再写业务Spring Data JPA 的核心是 Repository 接口的命名解析。商品模块的 Repository 放在这里public interface ProductRepository extends JpaRepositoryProduct, Long { ListProduct findByNameContaining(String name); boolean existsByWarehouseId(Long warehouseId); Modifying Query(update Product p set p.stock p.stock - :qty where p.id :id and p.stock :qty) int deductStock(Param(id) Long id, Param(qty) Integer qty); }findByNameContaining会自动生成where name like concat(%, :name, %)专门给商品管理的搜索框用。existsByWarehouseId可以在删除仓库前判断是否有商品仍挂在仓库下有就不允许删避免程序出现孤儿数据。deductStock是关键方法它把“扣库存”和“判断库存足够”合到一条 update 语句里数据库层面保证扣减不会超发。Modifying注解必须被事务包围否则调用时会报TransactionRequiredException。返回的int表示受影响行数如果库存不足where 条件p.stock :qty不成立影响行数为 0业务层看到 0 就知道要抛业务异常。各模块对应的 Repository 可以按界面划分下面的表格在搭建时可以直接用业务模块EntityRepository仓库管理WarehouseWarehouseRepository商品管理ProductProductRepository客户管理CustomerCustomerRepository供应商管理SupplierSupplierRepository用户管理UserUserRepository角色管理RoleRoleRepository菜单管理MenuMenuRepository这些 Repository 大多是空接口只继承JpaRepository基础增删改查就不用写了。真正需要优化的查询再按 2.3 的方式扩展方法。3. 销售开单到库存扣减后端事务与 ElementUI 表单联动3.1 销售开单表结构主表加明细表销售开单是 ERP 系统业务流程里最典型的“录入-校验-落库”场景。一笔销售单可能有多个商品如果用一张平表保存每次改明细都要删掉重插。常见做法是拆成sales_order表头和sales_order_item明细表两张表通过sales_order_id关联。CREATE TABLE sales_order ( id bigint NOT NULL AUTO_INCREMENT, customer_name varchar(64) NOT NULL, warehouse_id bigint DEFAULT NULL, order_date date DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE sales_order_item ( id bigint NOT NULL AUTO_INCREMENT, sales_order_id bigint DEFAULT NULL, product_id bigint DEFAULT NULL, quantity int DEFAULT NULL, price decimal(10,2) DEFAULT NULL, PRIMARY KEY (id), KEY idx_sales_order_id (sales_order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;表头里的customer_name是从客户表冗余过来的好处是销货历史页面只查销售单就能显示客户名称不需要每次 join 客户表。warehouse_id放在表头表示整单从一个仓库出库如果业务允许一张单跨仓库发货这个字段就要下放到明细表。quantity与price放在明细表同一订单不同商品可以有不同的价格。进货开单的表结构与销售单完全对称把customer_name换成supplier_name入库时把库存扣减方法换成增加库存事务同样包住采购单和商品库存两条更新。前端页面要支撑开单需要下面几个接口接口方法请求参数返回/api/salesOrderPOSTSalesOrderForm生成的订单 id/api/salesOrder/historyGETcustomerName、warehouseId、startDate、endDate订单列表/api/product/searchGETname、warehouseId商品列表/api/customer/optionGET无客户下拉列表3.2 Service 层事务扣库存与保存订单必须同生共死销售开单最怕出现“订单存上了库存也扣了但中间报错导致一边成功一边失败”。解决方式是把库存扣减和订单保存放进同一个事务方法任意一步异常全部回滚。Service 里可以这样写Service public class SalesOrderService { private final SalesOrderRepository orderRepository; private final ProductRepository productRepository; public SalesOrderService(SalesOrderRepository orderRepository, ProductRepository productRepository) { this.orderRepository orderRepository; this.productRepository productRepository; } Transactional(rollbackFor Exception.class) public Long createSalesOrder(SalesOrderForm form) { SalesOrder order new SalesOrder(); order.setCustomerName(form.getCustomerName()); order.setWarehouseId(form.getWarehouseId()); order.setOrderDate(form.getOrderDate()); for (SalesOrderItemForm item : form.getItems()) { int rows productRepository.deductStock(item.getProductId(), item.getQuantity()); if (rows 0) { throw new BusinessException(商品库存不足或扣减失败); } order.addItem(item); } return orderRepository.save(order).getId(); } }rollbackFor Exception.class的作用是让整个方法只要抛出任何异常就回滚包括自定义的BusinessException。如果不写这个参数Spring 默认只对RuntimeException回滚自定义异常如果是受检异常事务不会自动回滚库存就白扣了。productRepository.deductStock(item.getProductId(), item.getQuantity())对应的 update 语句之前在 2.3 里定义过where条件包含p.stock :qty所以它在业务层执行前已经把“库存是否足够”这个判断放进数据库了。这里没有先查询再判断是为了避免两个人同时下单时库存被超卖。前端传入的form.getItems()是商品明细列表循环里每扣减一个商品就往order对象里添加一行明细。等所有明细都处理完再统一保存保证主表和明细表一次落库。任何一件商品扣库存失败前面几件已经扣掉的库存会随事务回滚恢复。3.3 前端开单页面ElementUI 表单与表格联合Vue 端的开单页面用 ElementUI 的el-form做表头用el-table编辑商品明细。一个常见做法是把表头字段和明细行数据全部放在同一个form对象里提交时整体交给后端。template el-form :modelform label-width90px el-row el-col :span6 el-form-item label客户名称 el-input v-modelform.customerName placeholder请输入客户名称/el-input /el-form-item /el-col el-col :span6 el-form-item label仓库 el-select v-modelform.warehouseId placeholder请选择仓库 el-option v-forw in warehouses :keyw.id :labelw.name :valuew.id/el-option /el-select /el-form-item /el-col el-col :span6 el-form-item label开单日期 el-date-picker v-modelform.orderDate typedate value-formatyyyy-MM-dd/el-date-picker /el-form-item /el-col /el-row el-table :dataform.items el-table-column label商品名称 template slot-scopescope el-select v-modelscope.row.productId filterable placeholder选择商品 el-option v-forp in products :keyp.id :labelp.name :valuep.id/el-option /el-select /template /el-table-column el-table-column label数量 width160 template slot-scopescope el-input-number v-modelscope.row.quantity :min1/el-input-number /template /el-table-column /el-table el-button typeprimary clicksubmit保存订单/el-button /el-form /templateel-select的v-model绑定的是数字类型的warehouseId传给后端的是数字而不是字符串后端用Long接收不需要转换。el-date-picker上的value-formatyyyy-MM-dd解决了日期格式兼容问题否则默认输出 Date 对象JSON 序列化时容易变成时间戳。表格里的el-input-number的:min1保证用户不会输入 0 或负数。提交时如果没有校验商品明细是否为空后端会收到一个items为空的数组保存后没有任何商品显示给用户的是空白页面。所以前端在submit方法里应先判断this.form.items.length 0再发送请求。后端同样要在createSalesOrder开头校验items非空前后端双重校验更稳。3.4 销货历史查询条件为空就跳过销货历史是销售开单的逆向入口产品要求能按客户名称、仓库、日期筛选。这种不确定条件的查询适合用 JPA 的 Specification 动态拼 SQL。public ListSalesOrder searchHistory(String customerName, Long warehouseId, Date startDate, Date endDate) { SpecificationSalesOrder spec (root, query, cb) - { ListPredicate predicates new ArrayList(); if (customerName ! null !customerName.isEmpty()) { predicates.add(cb.like(root.get(customerName), % customerName %)); } if (warehouseId ! null) { predicates.add(cb.equal(root.get(warehouseId), warehouseId)); } if (startDate ! null) { predicates.add(cb.greaterThanOrEqualTo(root.get(orderDate), startDate)); } if (endDate ! null) { predicates.add(cb.lessThanOrEqualTo(root.get(orderDate), endDate)); } return cb.and(predicates.toArray(new Predicate[0])); }; return orderRepository.findAll(spec); }每个条件都是先判空再拼接用户不传就不参与筛选。日期区间用greaterThanOrEqualTo和lessThanOrEqualTo分别处理开始和结束这样开始时间使用2024-01-01时能覆盖2024-01-01 00:00:00结束时间用2024-01-31能覆盖到当天最后一秒。4. 用户角色与动态菜单权限数据怎么驱动 Vue Router4.1 菜单表与权限标识ERP 系统的用户管理、角色管理、菜单管理最后都指向一张菜单表。摘要里写的“父级标题、标题名称、排序、创建时间”就是这张表的基础字段还要补充path和perms才能驱动前端路由。CREATE TABLE menu ( id bigint NOT NULL AUTO_INCREMENT, parent_id bigint DEFAULT 0, title varchar(50) DEFAULT , path varchar(100) DEFAULT , component varchar(100) DEFAULT , perms varchar(100) DEFAULT , sort int DEFAULT 0, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;parent_id为 0 表示顶级菜单比如“销售管理”子菜单的parent_id指向它的父级 id。path对应 Vue Router 的路径component对应 views 目录下组件路径perms是后端接口和前端按钮的权限标识比如sale:order:create。角色表在中间通过role_menu关联这张菜单表一个角色可以拥有多个菜单项。4.2 登录接口返回用户权限列表用户登录后前端需要知道当前用户能看哪些页面、哪些按钮。这个资源中的用户管理有“用户名、密码、状态、创建时间、登录时间、角色”角色和菜单关联后后端登录接口可以直接把权限列表打回去。PostMapping(/api/login) public Result login(RequestBody LoginRequest login) { User user userRepository.findByUsernameAndStatus(login.getUsername(), 1); if (user ! null user.getPassword().equals(login.getPassword())) { ListString perms menuRepository.findPermsByRoleId(user.getRole().getId()); LoginResponse resp new LoginResponse(); resp.setToken(UUID.randomUUID().toString()); resp.setUsername(user.getUsername()); resp.setPerms(perms); return Result.ok(resp); } return Result.fail(用户名或密码错误); }menuRepository.findPermsByRoleId通常用原生 SQL 实现SELECT m.perms FROM menu m JOIN role_menu rm ON m.id rm.menu_id JOIN user_role ur ON ur.role_id rm.role_id WHERE ur.user_id :userId AND m.perms IS NOT NULL。把用户、角色、菜单三张表关联起来一次查出所有权限标识。这里为了项目演示密码是明文比较生产环境至少要换成 BCryptPasswordEncoder。登录成功返回token字段实际项目可以换成 JWT 或自定义 token 缓存。前端拿到 token 后存到 localStorage后续请求在 axios 拦截器里加上 Authorization 头。4.3 Vue Router 动态注册与刷新后恢复菜单管理在前端最常见的坑是菜单是后端返回的但 Vue Router 路由需要先定义才能跳转。通常的做法是用router.addRoutes动态添加路由。在 Vue Router 3.x 中登录后执行router.beforeEach((to, from, next) { const perms store.state.user.perms; if (!perms) { store.dispatch(fetchUserInfo).then(() { const routes buildRoutes(store.state.user.menus); router.addRoutes(routes); next({ ...to, replace: true }); }); } else { next(); } });buildRoutes把后端菜单列表转换成 route 对象核心映射是parent_id决定嵌套关系path作为路由路径component通过() import(/views/ componentPath)实现按需加载。router.addRoutes让新路由在运行时加入用户刷新后 Vue Router 实例重建异步组件会重新加载所以要在 beforeEach 里重新请求用户信息。参数{ ...to, replace: true }很关键首次进入页面时to是用户点击的目标路由但因为路由未注册直接next()会匹配不到组件。先重新导航到目标替换当前记录页面才能正常渲染。注意 Vue Router 4.0 已经将addRoutes改为addRoute当前项目基于 Vue2所以使用 3.x 的 API。4.4 按钮级权限用自定义指令控制操作按钮路由权限只能控制页面用户通过拼接接口一样能触发操作。菜单表里perms字段就是给按钮用的。在 Vue 里注册一个v-has-perm指令可以过滤当前用户没有权限的按钮。Vue.directive(has-perm, { inserted(el, binding) { const required binding.value; const perms store.state.user.perms; if (!perms.includes(required)) { el.parentNode el.parentNode.removeChild(el); } } });使用时写成el-button v-has-permsale:order:create typeprimary新增销售单/el-button指令里的binding.value是字符串sale:order:create如果当前用户的perms不包含这个值按钮就直接从 DOM 移除。注意这种控制只是前端体验后端接口仍需在PreAuthorize或 Controller 层做同样的权限拦截否则调用接口绕过按钮就可以执行操作。这个资源中的菜单管理模块本质上就是维护这些perms字符串与菜单的映射。5. 部署时最容易踩的 MySQL 时区、保留字与 Vue history 模式5.1 MySQL 5.7 连接串与时区SpringBoot 2.1.0 默认使用旧驱动连接 MySQL启动时如果报The server time zone value ???ú±ê׼ʱ¼ä is unrecognized说明连接串里少了serverTimezone。本地开发通常在 application.yml 里这样处理spring: datasource: url: jdbc:mysql://localhost:3306/erp?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseserverTimezoneAsia/Shanghai让 JDBC 驱动按东八区读取时间戳characterEncodingutf8配合useUnicodetrue保证中文不乱码。useSSLfalse是为了跳过 MySQL 5.7 在本地环境没有启用 SSL 的警告。如果换成 MySQL 8.0驱动类要改成com.mysql.cj.jdbc.Driver但本资源基于 MySQL 5.7不要随手调整这个配置。5.2 JPA 自动建表与保留字冲突JPA 的ddl-auto: update会让实体自动建表但实体表名如果叫orderMySQL 5.7 会直接报语法错误因为 ORDER 是保留字。ERP 项目里的订单表通常都是拆成sales_order和purchase_order避免使用order这个单词。字段名同样要小心比如desc、level、condition都容易在自动建表时撞保留字命名时建议统一加业务前缀。Entity Table(name sales_order) public class SalesOrder { // ... }如果某些字段确实是保留字可以在实体里使用Column(name desc)强制转义但这只是治标。更稳妥的做法是给表名加业务前缀比如sales_order、sys_user、sys_role既能避免保留字冲突也方便在同一个库里与业务系统、中间表区分。5.3 Vue history 路由刷新 404 的转发处理前端的销售开单、供应商管理这些页面通常是 Vue Router 的 history 模式打包后部署到 SpringBoot 的src/main/resources/static目录下。点击页面上跳转没事直接刷新/sales时 SpringBoot 没有对应的 controller会返回 404。常见做法是加一个 ViewController把非 API 的前端路由转发回index.htmlController public class ForwardController { RequestMapping(value { /sales/**, /purchase/**, /supplier/**, /warehouse/**, /customer/**, /dashboard/**, /system/** }) public String forward() { return forward:/index.html; } }注意这个类的注解必须是Controller而不是RestController因为forward返回的是视图转发名不是 JSON。value数组里列出的前缀要覆盖 Vue Router 的顶层 path不能把/**写进去否则/api/salesOrder/history这类接口也会被转发到index.html前端就收不到 JSON 了。部署后可以用curl -I http://localhost:8080/sales验证响应是不是 200 且 Content-Type 为 text/html。本文还有配套的精品资源点击获取