做进销存这类系统,最怕的不是业务复杂,而是所有环节看起来都“简单”,但真正落地的时候每个细节都在给你挖坑。我最近完整交付了一套面向中小型制造企业的ERP进销存管理系统,技术栈就是标题里写的这套:SpringBoot做后端服务,Vue做前端页面,前后端完全分离部署。这套系统跑通之后,从采购入库、销售出库到库存盘点、财务对账,整个链路都能在一个平台上完成闭环。这篇文章就把我从需求拆解、数据建模、后端并发控制、前端权限设计到最终部署上线的完整过程做个复盘,重点讲那些只看框架文档学不到的实际问题。1. 这个项目到底要解决什么:进销存业务的真实痛点拆解1.1 从Excel表格到业务系统的演进逻辑需求方是一家做电子元器件贸易的公司,有采购、仓储、销售三个核心部门。之前的数据流转是这样的:采购部做采购单用Excel,仓库入库用纸质单据,销售部的订单也是Excel,三个部门各存一份,月底对账的时候靠人工比对。这套模式最典型的三个问题,做业务系统的人应该都见过:库存数量经常对不上,超卖和欠料并存;采购、销售、仓库三边记录的口径不一致,说不清某一笔货到底是已入库还是在途;管理层想看实时库存完全没有入口,只能挨个问。项目开始前我花了一周时间泡在业务现场,跟着仓库员走了两遍实际出入库流程,才理清楚系统必须覆盖的最小闭环:采购订单生成后要有入库单承接,销售订单生成扣减库存,库存变化必须留下明细流水,所有的单据都要能反查到原始来源。这套系统说白了就是要把“人治”变成“流程治”。库存数量不是一个简单的数字,它是采购、销售、仓储三套流程共同作用的结果。所以系统设计的第一原则就是:所有库存变动必须由单据驱动,任何人不能直接改库存表里的数字,只能通过做采购入库单、销售出库单、盘点单来间接触发库存变化。这条定了,后面的所有设计都顺畅了。1.2 技术选型:为什么是SpringBoot加Vue后端选SpringBoot没有太多悬念,它依然是JavaWeb领域最适合中小型业务系统快速落地的框架。内置Tomcat、自动配置、生态成熟,配合MyBatis-Plus做单表CRUD非常快,复杂SQL又可以手写兜底,进销存这种“事务密集型+报表查询多”的场景非常匹配。前端用Vue的原因比较实际:适合做中后台管理系统,组件化开发效率高,生态里Element UI这类现成的组件库能覆盖绝大部分表单、表格、弹窗场景。前后端分离的架构下,后端只管提供JSON接口,前端只管渲染和交互,两个人并行开发互不阻塞。选型时也做了一些取舍。例如没有引入太重的微服务或消息中间件,因为业务规模确实没到那个阶段,单机事务加上行级锁就足够保证库存准确性,引入分布式事务反而增加落地成本。这个判断在后面并发控制的时候会体现出来。2. 库存数据模型设计:SKU、仓库与库存流水的关联关系2.1 商品表与SKU维度的拆分进销存系统的数据模型是所有功能的骨架,一开始设计错了后面返工成本极高。第一张核心表是商品表,但我没有做成简单的“商品名称+数量”,而是拆了商品与SKU两层。商品层存产品公共属性,名称、品牌、分类、单位这些;SKU层存可销售的规格变体,比如某个型号的电阻器,按封装尺寸、温度系数、阻值精度就能分出几十个SKU。SKU表持有可配置的规格键值对,每个SKU对应唯一的货品编码。这张表同时关联默认仓库、默认货位、安全库存阈值,为后面库存预警打下基础。我实际建模时用了六张基础表:商品分类表、商品表、SKU表、仓库表、货位表、往来单位表。往来单位这一张比较特别,它同时承担供应商和客户两种角色,用一个type字段区分,字段上预留了联系人、信用额度、结算方式等属性。2.2 采购、销售、库存三张主表如何联动采购和销售是进存销系统的两个驱动来源,我分别设计了采购订单、采购入库单、销售订单、销售出库单四张单据表,加上库存主数据表和库存流水表,总共六张业务表构成主干。采购侧的逻辑是:采购订单审核通过后生成待入库信息,仓库实际收货时做采购入库单,入库单保存后反向回写采购订单的入库状态。销售侧同理,销售订单审核后计算可用库存,出库单扣减库存并回写订单状态。关键的联动逻辑是“双单据结构”。每张入库单、出库单都拆成主表和明细表:主表存供应商或客户、仓库、单据日期、制单人、审核状态;明细表存SKU编码、数量、含税单价、税率、金额。双单据结构的好处是报表查询时一张主表可以关联多条明细,统计采购汇总、销售汇总都非常方便,不会出现为了算一笔单子去重复join十几条记录的情况。2.3 引入库存流水表的必要性很多初版进销存系统只维护一张库存余量表,每次出库入库直接修改当前库存数量。这种设计在数据量小的时候看不出问题,但一旦流程多了就会出现“库存数对不上总账数”的情况,而且根本没法追溯。我在库存余量表之外加了库存流水表,每个SKU每次数量变动都会插入一条不可变更的流水记录,记录类型、变动前数量、变动数量、变动后数量、关联单据编号、操作时间、操作人。余量表只是一个“视图结果”,流水表才是真正的“事实账本”。这样做的最直接好处:月底对账的时候,任何一段时间的期末库存都可以由流水表重算出来,误差核对变成了“跑数”而不是“猜数”。哪个SKU在哪里被改错了,翻流水一眼就能查到当时的场景和操作人。这套设计的代价是多了一张每交易增长的数据表,但对于严谨的企业使用场景,这笔成本是必须付的。3. SpringBoot后端:库存扣减的并发控制与事务边界3.1 为什么简单的update库存数量必然会踩坑进销存系统里最典型的高危操作就是“销售出库扣减库存”。很多初学者会直接写这样一句SQL:UPDATE stock SET quantity = quantity - ? WHERE sku_id = ?并发不高的时候看起来没问题,但真实业务里经常出现两个客服同时下单,都查到可用库存为10,然后同时扣减,最后库存只减了1而不是2的情况。原因是两个事务都先读到旧值,各自的update基于旧值计算,一个覆盖掉另一个的结果。更隐蔽的问题还在后面。如果扣减库存和生成出库单是分两步执行的,中间几步一旦抛异常,库存减了单据没生成,月底对账就永远差一笔。扣减库存必须和单据写入放在同一个事务里,二者要么同时成功,要么同时回滚。3.2 SELECT FOR UPDATE加事务:可靠扣减的锁方案最终我采用的方案是“先查后改”配合数据库行级锁,核心逻辑是:在事务内先对库存主数据记录加悲观锁,锁住之后再判断可用库存是否充足,最后执行扣减。@Transactional(rollbackFor = Exception.class) public void saleOutbound(OutboundOrderDTO dto) { for (OutboundItemDTO item : dto.getItems()) { Stock stock = stockMapper.selectBySkuIdForUpdate(item.getSkuId()); if (stock.getAvailableQty() < item.getQuantity()) { throw new BusinessException("商品库存不足:" + stock.getSkuCode()); } int rows = stockMapper.deductQuantity(item.getSkuId(), item.getQuantity()); if (rows == 0) { throw new BusinessException("扣减失败,请重试"); } // 插入库存流水、生成出库单明细... } }selectBySkuIdForUpdate里对应SQL是SELECT ... FOR UPDATE。在InnoDB引擎下这会给满足条件的行加排他锁,其他事务要更新同一行会被阻塞,直到当前事务提交或回滚。这里有一个很关键的细节:锁必须在业务代码里显式获取,不能用自动提交的查询,否则去查的时候就已经释放了。所以这个查询必须是在整个事务范围内的第一个数据库操作。另外一个教训是:多个明细必须按固定顺序处理,避免两个并发事务各自持有锁、又互相等对方释放,也就是死锁。3.3 幂等、状态机与失败回滚的兜底设计并发控制只能解决技术上的竞争问题,业务上还要防重复操作。前端用户双击提交按钮、网络重试、接口超时后再次点击,任何一个场景都可能让同一条出库单被执行两次。我的做法是给每张出库单分配一个全局唯一的业务单号,并在出库单主表上建唯一索引。写入出库单时如果单号已存在,后续操作会被数据库唯一约束拒绝,事务回滚,库存不会发生二次扣减。单据状态我也设计成了状态机:草稿、已审核、已出库、已作废。每个状态之间只有明确的转移路径,不允许状态跳跃。这个设计在财务对账阶段特别有用,未审核的单据不会参与库存计算,已作废的单据必须有作废原因和操作人记录。事务边界是排查问题的重灾区。Spring的@Transactional默认只回滚RuntimeException,自定义业务异常如果继承的是Exception,默认不会触发回滚。我在自定义异常基类上做了调整,新建了一个继承RuntimeException的BusinessException,所有业务校验失败都抛它,这样事务才能正常回滚。我在联调阶段真遇到过“库存减了但异常被吞掉”的问题,半天才定位到根因就是异常类型没对。4. Vue前端:动态权限菜单与列表性能优化实战4.1 前端路由如何跟随角色动态生成权限控制是ERP类系统的前端重头戏。我们的系统里有管理员、采购员、销售员、仓管员、财务五种角色,不同角色看到的菜单和能操作的按钮都不一样。如果路由和菜单直接写死在静态文件里,角色切换时用户能通过URL绕过菜单直接访问无权限页面,权限控制就成了摆设。我的方案是在前端维护一份与后端菜单表对应的路由配置常量,登录成功后调用获取用户信息的接口,后端根据角色权限返回菜单码列表。前端拿到菜单码后,使用Router的addRoute方法动态添加可访问的路由。async function loadRoutes() { const userInfo = await getUserInfo(); const accessibleMenuCodes = userInfo.menus; const accessableRoutes = filterRoutes(routeConfig, accessibleMenuCodes); accessableRoutes.forEach(route => router.addRoute(route)); router.replace('/dashboard'); }这里有一个坑要特别注意:动态添加的路由在页面刷新后会丢失,因为Vue应用会重新启动。必须在全局路由守卫里判断当前store中是否已经有路由记录,没有就在刷新时重新拉取用户信息并重新生成路由。不处理这个问题,用户一按F5就直接跳到空白页。4.2 按钮级权限的实现思路菜单级权限控制的是“能不能进这个页面”,按钮级权限控制的是“能在这个页面做什么”。比如采购员能看到采购订单列表,但只有采购主管才能审核采购订单。我封装了一个自定义指令v-permission,按钮渲染时判断当前用户权限码集合里是否有指定权限码,没有就把这个DOM节点移除:Vue.directive('permission', { inserted(el, binding) { const requiredCode = binding.value; const hasPermission = store.state.user.permissionCodes.includes(requiredCode); if (!hasPermission) { el.parentNode && el.parentNode.removeChild(el); } } });模板里使用就非常简洁:<el-button v-permission="'purchase:order:audit'" type="primary">审核</el-button>这套方案在需求频繁变化的阶段非常实用。新增一个按钮权限只需要后端权限码配置表加一条记录,前端加一个指令,不需要硬编码到处判断角色类型。4.3 千行级表格的渲染与加载优化进销存系统的核心页面,比如库存列表、单据明细列表,动辄几千行数据。刚开始直接用el-table渲染几千行,页面明显卡顿,滚动不流畅。优化从三个方向入手。第一是后端分页,默认每页20条,用户在表格翻页时按页请求;第二是大表格开启el-table的虚拟滚动模式,也就是只渲染可视区域内的行,这里的关键是给每行固定高度,否则虚拟滚动的估算高度会出错;第三是列字段按需展示,默认只加载核心列,描述、备注这些收起在详情弹窗里。还有一个容易被忽略的细节:表格里的枚举值,比如单据状态、仓库名称,在渲染时用字典映射做翻译,而不是让后端把中文名直接存在每行数据里。字典在前端缓存一份,进入页面时初始化,渲染性能比每次都透传中文文本高很多。5. 前后端联调规范:统一响应结构、跨域与错误处理5.1 统一响应体的格式约定前后端分离开发最大的摩擦点在于接口规范不统一。一开始如果大家各自定义自己的返回结构,前端调用的时候就要为每一个接口单独写解析逻辑,联调期必然吵架。我提前定义了统一响应结构:{ "code": 200, "message": "success", "data": {}, "requestId": "8f3a1c..." }code为200时业务成功,data是业务载荷;非200时message是面向用户的可读提示,requestId用于日志链路追踪。后端封装了Result<T>泛型类,所有Controller方法都返回这个类型,前端封装了一个统一的Axios拦截器,只处理这一个结构。这对前端拦截器来说非常友好,因为只需要在response拦截器里判断一次code,统一处理登录过期、权限不足、业务异常三种场景,其它代码里面完全不需要考虑错误分支。5.2 跨域问题的开发期与生产期处理前后端分离后,开发环境前端跑在localhost:8080,后端跑在localhost:8081,跨域是必然遭遇的问题。我在Vue开发环境用vue.config.js里配置devServer代理解决:module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true, pathRewrite: { '^/api': '' } } } } }这样前端请求/api/user/login,实际代理转发到后端的/user/login,浏览器角度看到的是同源请求,没有跨域报错。开发环境用代理的好处是调试跨域相关的cors配置时不必反复改后端。生产环境的跨域则完全交给Nginx处理。Nginx把/api前缀转发到后端服务,静态资源单独存放,前后端在同一域名下自然同源。一个注意点:如果上线后仍有跨域需求(比如多个管理系统共用一套后端API),后端可以通过@CrossOrigin或一个全局的CORS配置类来允许指定域名白名单,不要直接用allowedOrigins("*"),那样会把认证Cookie和自定义Token暴露给任意来源。5.3 异常拦截与前端提示的配合后端统一异常处理我用@RestControllerAdvice实现了全局拦截,分类处理:业务异常返回对应错误提示;参数校验失败返回字段级提示;未捕获异常记录完整堆栈,但对外只返回“系统繁忙,请稍后再试”。这里的重要原则是“后端对用户隐藏技术细节,但对前端开发不隐藏上下文”。我在异常响应里加了errorType字段,区分是business、validation还是system。前端的消息提示组件根据errorType决定展示方式:业务错误提示在表单上方,校验错误提示在对应字段下方,系统错误则弹一个独立对话框。另外日志追踪要落地。每个请求进来时生成requestId,放进MDC上下文,日志框架自动打印;异常响应里携带同一个requestId返回给前端。当用户反馈说“做了某个操作报错了”,前端把requestId发给开发,后端在日志里一搜就能定位整条调用链路,这比让用户复述操作路径高效得多。6. 部署上线与运维排错:从jar包到Nginx的避坑记录6.1 jar包构建与静态资源部署分离系统交付时我采用的部署方式是这样:后端在服务器上直接跑SpringBoot打包好的jar包,前端打包成静态文件交给Nginx。构建前我会检查几个关键点:配置文件使用外部application-prod.yml,打包时排除开发环境配置;数据源连接用环境变量注入,避免把密码写死在仓库;日志路径指向服务器固定目录,并配置滚动策略。Nginx配置需要注意一个经典坑:单页应用的路由是前端BrowserRouter管理的,在Nginx里如果不配置try_files,刷新某个子路由页面时会直接返回404。location / { root /opt/www/dist; index index.html; try_files $uri $uri/ /index.html; }另外要把/api单独转发到SpringBoot服务:location /api/ { proxy_pass http://127.0.0.1:8081/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }6.2 初始化SQL脚本与多环境配置管理数据库管理这块我踩过一次亏。早期直接把开发环境建好的表结构在生产执行,结果生产表引擎、字符集、索引都和预期不一致,性能出现明显差异。后来我把建表脚本全部整理成版本化SQL脚本,每张表都显式指定引擎、字符集、唯一索引,同一套初始化脚本在不同环境执行结果完全一致。版本化的做法是:根目录建一个sql目录,文件名按v1.0__init.sql、v1.1__add_stock_flow_index.sql这种规则命名,执行时记录当前版本号,保证重复执行可跳过。另一个容易被忽略的是时区设置。数据库连接串里如果没设置serverTimezone,SpringBoot默认可能和MySQL服务器时区不一致,导致时间字段凭空差8小时。我统一在JDBC连接参数中显式设置时区,并在MySQL初始化配置里也保持一致。6.3 线上高频问题的定位方法上线之后遇到最多的是三类问题:库存对不上、接口慢、以及环境差异导致的偶发异常。库存对不上的定位思路是反查流水。前端界面提供一个“库存明细追溯”入口,输入SKU编码和时间范围,展示这段期间的所有流水记录,再从异常流水倒查对应的单据,基本能快速定位是漏单还是重复操作。接口慢优先检查SQL执行计划。我在MyBatis-Plus配置里开启了慢SQL日志,超过2秒的查询会打印完整SQL及耗时,大部分慢SQL通过补索引就能解决。一个典型例子是销售出库单明细表按SKU查询时没走索引,全表扫描导致报表页接口耗时近10秒,加了一个复合索引后直接降到毫秒级。环境差异类问题最恶心。比如开发环境一切正常、生产环境偶发报错,最后定位到是生产机器上MySQL事务隔离级别和开发环境不一致。我在数据库初始化脚本里显式设置隔离级别为READ COMMITTED,确保多环境行为一致,这类问题从此减少很多。最后谈一点整体感受。做进销存ERP这类管理系统,真正的门槛不在框架的技术深度,而在于对业务链路的完整理解和对边界情况的事无巨细。库存扣减用行锁,前端权限用动态路由,这些单独拿出来都不算高深技术,但它们组合在一起并经过真实业务考验之后,才构成了一套能长期稳定运行的系统。这套架构目前跑了几万笔出入库单据没有出现过一笔库存差错,对我个人而言,比写出多花哨的代码更有成就感。如果后续要在这个基础上扩展,我建议优先考虑采购销售报表的数据仓库化改造,以及对接企业微信做审批流提醒,这会让系统从“好用”进一步变成“离不了”。
做进销存这类系统,最怕的不是业务复杂,而是所有环节看起来都“简单”,但真正落地的时候每个细节都在给你挖坑。我最近完整交付了一套面向中小型制造企业的ERP进销存管理系统,技术栈就是标题里写的这套:SpringBoot做后端服务,Vue做前端页面,前后端完全分离部署。这套系统跑通之后,从采购入库、销售出库到库存盘点、财务对账,整个链路都能在一个平台上完成闭环。这篇文章就把我从需求拆解、数据建模、后端并发控制、前端权限设计到最终部署上线的完整过程做个复盘,重点讲那些只看框架文档学不到的实际问题。1. 这个项目到底要解决什么:进销存业务的真实痛点拆解1.1 从Excel表格到业务系统的演进逻辑需求方是一家做电子元器件贸易的公司,有采购、仓储、销售三个核心部门。之前的数据流转是这样的:采购部做采购单用Excel,仓库入库用纸质单据,销售部的订单也是Excel,三个部门各存一份,月底对账的时候靠人工比对。这套模式最典型的三个问题,做业务系统的人应该都见过:库存数量经常对不上,超卖和欠料并存;采购、销售、仓库三边记录的口径不一致,说不清某一笔货到底是已入库还是在途;管理层想看实时库存完全没有入口,只能挨个问。项目开始前我花了一周时间泡在业务现场,跟着仓库员走了两遍实际出入库流程,才理清楚系统必须覆盖的最小闭环:采购订单生成后要有入库单承接,销售订单生成扣减库存,库存变化必须留下明细流水,所有的单据都要能反查到原始来源。这套系统说白了就是要把“人治”变成“流程治”。库存数量不是一个简单的数字,它是采购、销售、仓储三套流程共同作用的结果。所以系统设计的第一原则就是:所有库存变动必须由单据驱动,任何人不能直接改库存表里的数字,只能通过做采购入库单、销售出库单、盘点单来间接触发库存变化。这条定了,后面的所有设计都顺畅了。1.2 技术选型:为什么是SpringBoot加Vue后端选SpringBoot没有太多悬念,它依然是JavaWeb领域最适合中小型业务系统快速落地的框架。内置Tomcat、自动配置、生态成熟,配合MyBatis-Plus做单表CRUD非常快,复杂SQL又可以手写兜底,进销存这种“事务密集型+报表查询多”的场景非常匹配。前端用Vue的原因比较实际:适合做中后台管理系统,组件化开发效率高,生态里Element UI这类现成的组件库能覆盖绝大部分表单、表格、弹窗场景。前后端分离的架构下,后端只管提供JSON接口,前端只管渲染和交互,两个人并行开发互不阻塞。选型时也做了一些取舍。例如没有引入太重的微服务或消息中间件,因为业务规模确实没到那个阶段,单机事务加上行级锁就足够保证库存准确性,引入分布式事务反而增加落地成本。这个判断在后面并发控制的时候会体现出来。2. 库存数据模型设计:SKU、仓库与库存流水的关联关系2.1 商品表与SKU维度的拆分进销存系统的数据模型是所有功能的骨架,一开始设计错了后面返工成本极高。第一张核心表是商品表,但我没有做成简单的“商品名称+数量”,而是拆了商品与SKU两层。商品层存产品公共属性,名称、品牌、分类、单位这些;SKU层存可销售的规格变体,比如某个型号的电阻器,按封装尺寸、温度系数、阻值精度就能分出几十个SKU。SKU表持有可配置的规格键值对,每个SKU对应唯一的货品编码。这张表同时关联默认仓库、默认货位、安全库存阈值,为后面库存预警打下基础。我实际建模时用了六张基础表:商品分类表、商品表、SKU表、仓库表、货位表、往来单位表。往来单位这一张比较特别,它同时承担供应商和客户两种角色,用一个type字段区分,字段上预留了联系人、信用额度、结算方式等属性。2.2 采购、销售、库存三张主表如何联动采购和销售是进存销系统的两个驱动来源,我分别设计了采购订单、采购入库单、销售订单、销售出库单四张单据表,加上库存主数据表和库存流水表,总共六张业务表构成主干。采购侧的逻辑是:采购订单审核通过后生成待入库信息,仓库实际收货时做采购入库单,入库单保存后反向回写采购订单的入库状态。销售侧同理,销售订单审核后计算可用库存,出库单扣减库存并回写订单状态。关键的联动逻辑是“双单据结构”。每张入库单、出库单都拆成主表和明细表:主表存供应商或客户、仓库、单据日期、制单人、审核状态;明细表存SKU编码、数量、含税单价、税率、金额。双单据结构的好处是报表查询时一张主表可以关联多条明细,统计采购汇总、销售汇总都非常方便,不会出现为了算一笔单子去重复join十几条记录的情况。2.3 引入库存流水表的必要性很多初版进销存系统只维护一张库存余量表,每次出库入库直接修改当前库存数量。这种设计在数据量小的时候看不出问题,但一旦流程多了就会出现“库存数对不上总账数”的情况,而且根本没法追溯。我在库存余量表之外加了库存流水表,每个SKU每次数量变动都会插入一条不可变更的流水记录,记录类型、变动前数量、变动数量、变动后数量、关联单据编号、操作时间、操作人。余量表只是一个“视图结果”,流水表才是真正的“事实账本”。这样做的最直接好处:月底对账的时候,任何一段时间的期末库存都可以由流水表重算出来,误差核对变成了“跑数”而不是“猜数”。哪个SKU在哪里被改错了,翻流水一眼就能查到当时的场景和操作人。这套设计的代价是多了一张每交易增长的数据表,但对于严谨的企业使用场景,这笔成本是必须付的。3. SpringBoot后端:库存扣减的并发控制与事务边界3.1 为什么简单的update库存数量必然会踩坑进销存系统里最典型的高危操作就是“销售出库扣减库存”。很多初学者会直接写这样一句SQL:UPDATE stock SET quantity = quantity - ? WHERE sku_id = ?并发不高的时候看起来没问题,但真实业务里经常出现两个客服同时下单,都查到可用库存为10,然后同时扣减,最后库存只减了1而不是2的情况。原因是两个事务都先读到旧值,各自的update基于旧值计算,一个覆盖掉另一个的结果。更隐蔽的问题还在后面。如果扣减库存和生成出库单是分两步执行的,中间几步一旦抛异常,库存减了单据没生成,月底对账就永远差一笔。扣减库存必须和单据写入放在同一个事务里,二者要么同时成功,要么同时回滚。3.2 SELECT FOR UPDATE加事务:可靠扣减的锁方案最终我采用的方案是“先查后改”配合数据库行级锁,核心逻辑是:在事务内先对库存主数据记录加悲观锁,锁住之后再判断可用库存是否充足,最后执行扣减。@Transactional(rollbackFor = Exception.class) public void saleOutbound(OutboundOrderDTO dto) { for (OutboundItemDTO item : dto.getItems()) { Stock stock = stockMapper.selectBySkuIdForUpdate(item.getSkuId()); if (stock.getAvailableQty() < item.getQuantity()) { throw new BusinessException("商品库存不足:" + stock.getSkuCode()); } int rows = stockMapper.deductQuantity(item.getSkuId(), item.getQuantity()); if (rows == 0) { throw new BusinessException("扣减失败,请重试"); } // 插入库存流水、生成出库单明细... } }selectBySkuIdForUpdate里对应SQL是SELECT ... FOR UPDATE。在InnoDB引擎下这会给满足条件的行加排他锁,其他事务要更新同一行会被阻塞,直到当前事务提交或回滚。这里有一个很关键的细节:锁必须在业务代码里显式获取,不能用自动提交的查询,否则去查的时候就已经释放了。所以这个查询必须是在整个事务范围内的第一个数据库操作。另外一个教训是:多个明细必须按固定顺序处理,避免两个并发事务各自持有锁、又互相等对方释放,也就是死锁。3.3 幂等、状态机与失败回滚的兜底设计并发控制只能解决技术上的竞争问题,业务上还要防重复操作。前端用户双击提交按钮、网络重试、接口超时后再次点击,任何一个场景都可能让同一条出库单被执行两次。我的做法是给每张出库单分配一个全局唯一的业务单号,并在出库单主表上建唯一索引。写入出库单时如果单号已存在,后续操作会被数据库唯一约束拒绝,事务回滚,库存不会发生二次扣减。单据状态我也设计成了状态机:草稿、已审核、已出库、已作废。每个状态之间只有明确的转移路径,不允许状态跳跃。这个设计在财务对账阶段特别有用,未审核的单据不会参与库存计算,已作废的单据必须有作废原因和操作人记录。事务边界是排查问题的重灾区。Spring的@Transactional默认只回滚RuntimeException,自定义业务异常如果继承的是Exception,默认不会触发回滚。我在自定义异常基类上做了调整,新建了一个继承RuntimeException的BusinessException,所有业务校验失败都抛它,这样事务才能正常回滚。我在联调阶段真遇到过“库存减了但异常被吞掉”的问题,半天才定位到根因就是异常类型没对。4. Vue前端:动态权限菜单与列表性能优化实战4.1 前端路由如何跟随角色动态生成权限控制是ERP类系统的前端重头戏。我们的系统里有管理员、采购员、销售员、仓管员、财务五种角色,不同角色看到的菜单和能操作的按钮都不一样。如果路由和菜单直接写死在静态文件里,角色切换时用户能通过URL绕过菜单直接访问无权限页面,权限控制就成了摆设。我的方案是在前端维护一份与后端菜单表对应的路由配置常量,登录成功后调用获取用户信息的接口,后端根据角色权限返回菜单码列表。前端拿到菜单码后,使用Router的addRoute方法动态添加可访问的路由。async function loadRoutes() { const userInfo = await getUserInfo(); const accessibleMenuCodes = userInfo.menus; const accessableRoutes = filterRoutes(routeConfig, accessibleMenuCodes); accessableRoutes.forEach(route => router.addRoute(route)); router.replace('/dashboard'); }这里有一个坑要特别注意:动态添加的路由在页面刷新后会丢失,因为Vue应用会重新启动。必须在全局路由守卫里判断当前store中是否已经有路由记录,没有就在刷新时重新拉取用户信息并重新生成路由。不处理这个问题,用户一按F5就直接跳到空白页。4.2 按钮级权限的实现思路菜单级权限控制的是“能不能进这个页面”,按钮级权限控制的是“能在这个页面做什么”。比如采购员能看到采购订单列表,但只有采购主管才能审核采购订单。我封装了一个自定义指令v-permission,按钮渲染时判断当前用户权限码集合里是否有指定权限码,没有就把这个DOM节点移除:Vue.directive('permission', { inserted(el, binding) { const requiredCode = binding.value; const hasPermission = store.state.user.permissionCodes.includes(requiredCode); if (!hasPermission) { el.parentNode && el.parentNode.removeChild(el); } } });模板里使用就非常简洁:<el-button v-permission="'purchase:order:audit'" type="primary">审核</el-button>这套方案在需求频繁变化的阶段非常实用。新增一个按钮权限只需要后端权限码配置表加一条记录,前端加一个指令,不需要硬编码到处判断角色类型。4.3 千行级表格的渲染与加载优化进销存系统的核心页面,比如库存列表、单据明细列表,动辄几千行数据。刚开始直接用el-table渲染几千行,页面明显卡顿,滚动不流畅。优化从三个方向入手。第一是后端分页,默认每页20条,用户在表格翻页时按页请求;第二是大表格开启el-table的虚拟滚动模式,也就是只渲染可视区域内的行,这里的关键是给每行固定高度,否则虚拟滚动的估算高度会出错;第三是列字段按需展示,默认只加载核心列,描述、备注这些收起在详情弹窗里。还有一个容易被忽略的细节:表格里的枚举值,比如单据状态、仓库名称,在渲染时用字典映射做翻译,而不是让后端把中文名直接存在每行数据里。字典在前端缓存一份,进入页面时初始化,渲染性能比每次都透传中文文本高很多。5. 前后端联调规范:统一响应结构、跨域与错误处理5.1 统一响应体的格式约定前后端分离开发最大的摩擦点在于接口规范不统一。一开始如果大家各自定义自己的返回结构,前端调用的时候就要为每一个接口单独写解析逻辑,联调期必然吵架。我提前定义了统一响应结构:{ "code": 200, "message": "success", "data": {}, "requestId": "8f3a1c..." }code为200时业务成功,data是业务载荷;非200时message是面向用户的可读提示,requestId用于日志链路追踪。后端封装了Result<T>泛型类,所有Controller方法都返回这个类型,前端封装了一个统一的Axios拦截器,只处理这一个结构。这对前端拦截器来说非常友好,因为只需要在response拦截器里判断一次code,统一处理登录过期、权限不足、业务异常三种场景,其它代码里面完全不需要考虑错误分支。5.2 跨域问题的开发期与生产期处理前后端分离后,开发环境前端跑在localhost:8080,后端跑在localhost:8081,跨域是必然遭遇的问题。我在Vue开发环境用vue.config.js里配置devServer代理解决:module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true, pathRewrite: { '^/api': '' } } } } }这样前端请求/api/user/login,实际代理转发到后端的/user/login,浏览器角度看到的是同源请求,没有跨域报错。开发环境用代理的好处是调试跨域相关的cors配置时不必反复改后端。生产环境的跨域则完全交给Nginx处理。Nginx把/api前缀转发到后端服务,静态资源单独存放,前后端在同一域名下自然同源。一个注意点:如果上线后仍有跨域需求(比如多个管理系统共用一套后端API),后端可以通过@CrossOrigin或一个全局的CORS配置类来允许指定域名白名单,不要直接用allowedOrigins("*"),那样会把认证Cookie和自定义Token暴露给任意来源。5.3 异常拦截与前端提示的配合后端统一异常处理我用@RestControllerAdvice实现了全局拦截,分类处理:业务异常返回对应错误提示;参数校验失败返回字段级提示;未捕获异常记录完整堆栈,但对外只返回“系统繁忙,请稍后再试”。这里的重要原则是“后端对用户隐藏技术细节,但对前端开发不隐藏上下文”。我在异常响应里加了errorType字段,区分是business、validation还是system。前端的消息提示组件根据errorType决定展示方式:业务错误提示在表单上方,校验错误提示在对应字段下方,系统错误则弹一个独立对话框。另外日志追踪要落地。每个请求进来时生成requestId,放进MDC上下文,日志框架自动打印;异常响应里携带同一个requestId返回给前端。当用户反馈说“做了某个操作报错了”,前端把requestId发给开发,后端在日志里一搜就能定位整条调用链路,这比让用户复述操作路径高效得多。6. 部署上线与运维排错:从jar包到Nginx的避坑记录6.1 jar包构建与静态资源部署分离系统交付时我采用的部署方式是这样:后端在服务器上直接跑SpringBoot打包好的jar包,前端打包成静态文件交给Nginx。构建前我会检查几个关键点:配置文件使用外部application-prod.yml,打包时排除开发环境配置;数据源连接用环境变量注入,避免把密码写死在仓库;日志路径指向服务器固定目录,并配置滚动策略。Nginx配置需要注意一个经典坑:单页应用的路由是前端BrowserRouter管理的,在Nginx里如果不配置try_files,刷新某个子路由页面时会直接返回404。location / { root /opt/www/dist; index index.html; try_files $uri $uri/ /index.html; }另外要把/api单独转发到SpringBoot服务:location /api/ { proxy_pass http://127.0.0.1:8081/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }6.2 初始化SQL脚本与多环境配置管理数据库管理这块我踩过一次亏。早期直接把开发环境建好的表结构在生产执行,结果生产表引擎、字符集、索引都和预期不一致,性能出现明显差异。后来我把建表脚本全部整理成版本化SQL脚本,每张表都显式指定引擎、字符集、唯一索引,同一套初始化脚本在不同环境执行结果完全一致。版本化的做法是:根目录建一个sql目录,文件名按v1.0__init.sql、v1.1__add_stock_flow_index.sql这种规则命名,执行时记录当前版本号,保证重复执行可跳过。另一个容易被忽略的是时区设置。数据库连接串里如果没设置serverTimezone,SpringBoot默认可能和MySQL服务器时区不一致,导致时间字段凭空差8小时。我统一在JDBC连接参数中显式设置时区,并在MySQL初始化配置里也保持一致。6.3 线上高频问题的定位方法上线之后遇到最多的是三类问题:库存对不上、接口慢、以及环境差异导致的偶发异常。库存对不上的定位思路是反查流水。前端界面提供一个“库存明细追溯”入口,输入SKU编码和时间范围,展示这段期间的所有流水记录,再从异常流水倒查对应的单据,基本能快速定位是漏单还是重复操作。接口慢优先检查SQL执行计划。我在MyBatis-Plus配置里开启了慢SQL日志,超过2秒的查询会打印完整SQL及耗时,大部分慢SQL通过补索引就能解决。一个典型例子是销售出库单明细表按SKU查询时没走索引,全表扫描导致报表页接口耗时近10秒,加了一个复合索引后直接降到毫秒级。环境差异类问题最恶心。比如开发环境一切正常、生产环境偶发报错,最后定位到是生产机器上MySQL事务隔离级别和开发环境不一致。我在数据库初始化脚本里显式设置隔离级别为READ COMMITTED,确保多环境行为一致,这类问题从此减少很多。最后谈一点整体感受。做进销存ERP这类管理系统,真正的门槛不在框架的技术深度,而在于对业务链路的完整理解和对边界情况的事无巨细。库存扣减用行锁,前端权限用动态路由,这些单独拿出来都不算高深技术,但它们组合在一起并经过真实业务考验之后,才构成了一套能长期稳定运行的系统。这套架构目前跑了几万笔出入库单据没有出现过一笔库存差错,对我个人而言,比写出多花哨的代码更有成就感。如果后续要在这个基础上扩展,我建议优先考虑采购销售报表的数据仓库化改造,以及对接企业微信做审批流提醒,这会让系统从“好用”进一步变成“离不了”。