做翡翠贸易这行,最怕的不是行情波动,而是货和账对不上。原石、毛料、成品镯子摆了一仓库,谁经手、谁出库、卖了多少、还剩多少,靠Excel来回传,月底一核对全是窟窿。前阵子我帮朋友做了一套“基于SpringBoot的翡翠仓库进销存管理系统”,前端选了Vue,后端用SpringBoot,整体按四个业务角色来做权限和流程隔离,算是把这一摊事捋顺了。今天把这套系统的设计与实操过程完整拆出来,给正在研究SpringBoot+Vue进销存开发的朋友做个参考。
这套系统解决的核心问题很直接:仓库里每一件翡翠的入库、出库、调拨、盘点都有据可查,采购、销售、仓管、管理层各管各的环节,但所有数据最终都在同一个平台上汇合。对于有类似需求的中小规模珠宝贸易商、玉石加工厂、零售门店,或者拿这个当毕业设计和面试项目的开发者,这套方案都有很强的移植性。下面从架构设计、数据库、后端实现、前端实现到踩坑记录,按实际开发顺序从头讲。
1. 整体架构与四角色设计思路
1.1 为什么锁定SpringBoot + Vue这套组合
先说技术选型的事。市面上做管理系统,能用的组合很多,Python的Django、PHP的Laravel、Go的Gin都有人用,但回到“翡翠仓库进销存”这个具体场景,SpringBoot+Vue几乎是风险最低的选择。
原因不复杂。第一,SpringBoot在后端领域的基础设施太成熟了,Spring Security做认证权限、MyBatis-Plus操作数据库、Redis做缓存和会话管理,这些组件随便一拼就是一套完整的后端服务,开发效率很高。第二,Vue在前端生态里上手门槛低,组件化开发对进销存这种页面多、表单密、表格繁重的业务非常友好,Element UI或者Ant Design Vue一套组件库就能把后台管理界面搭得又快又整齐。第三,前后端分离这个模式在中小型项目里已经成了默认选项,后端只出接口,前端只管页面,团队协作或者一个人单干都好维护。
实际开发中我还有一个很实在的感受:SpringBoot的问题在网上几乎都能搜到答案。翡翠进销存系统虽然业务上有点行业特性,但底层仍然是标准的增删改查加库存计算,用这套组合遇到卡点,排查成本比冷门框架低一个量级。这一点在项目后期尤其重要,因为业务逻辑写完之后,真正的耗时大头都花在权限控制、数据一致性、并发处理这些细节上,框架本身的稳定性直接决定你要不要加班。
1.2 四个角色到底怎么划分权限
进销存系统的角色划分不能拍脑袋。很多项目上来就做“管理员”和“普通用户”两个角色,放到翡翠仓库这个场景里根本不够用。仓库管理、采购进货、门店销售、老板巡查,这四类人的需求互相冲突:销售只想看到货品信息和价格,不想也不能改库存;采购需要掌握补货节奏,但不该看到销售成本和利润细节;仓管负责实物的进出库,必须对数量负责,但无权调整售价;老板需要全局视角,又不能陷入具体单据的录入。
基于这个矛盾,我最终把系统拆成了四个角色:
| 角色 | 核心职责 | 菜单与操作范围 | 数据可见范围 |
|---|---|---|---|
| 系统管理员 | 系统配置、用户管理、数据维护 | 全部菜单,含角色权限配置、基础数据管理 | 全局数据 |
| 仓库管理员 | 入库、出库、调拨、盘点、库存查询 | 库存管理、入出库单据、盘点模块 | 全部货品与库存记录 |
| 采购人员 | 供应商管理、采购订单、补货建议 | 采购模块、供应商管理、采购入库单 | 供应商与采购相关数据 |
| 销售人员 | 客户管理、销售订单、销售出库 | 销售模块、客户管理、销售出库单 | 可售货品与售价,不含成本价 |
这个权限矩阵看起来简单,但落地时有一层容易忽视的细节:除了页面菜单级别的权限,还要做数据级别的隔离。比如销售人员能查到“库存可用数量”,但不能看到“库存成本单价”;采购人员能看到当前库存水位,但不能覆盖仓库的盘点权限。菜单权限用路由来控制,数据权限则要在后端接口的参数里带上角色条件,比如查询库存列表时,销售角色的接口只返回上架状态并且过滤掉利润敏感字段。这两层权限叠加起来,才是完整的角色隔离。
2. 数据库设计与进销存核心模型
2.1 围绕“一物一码”设计货品主数据
翡翠货品的管理和普通标准品不一样。一箱螺丝钉可以按统一规格建SKU,但两只同样标称“冰种飘花手镯”的翡翠,实际质量、尺寸、价格可能完全不同。所以这套系统的货品主数据必须采用“一物一码”的思路:每一件入仓的翡翠都要建立独立的货品档案,相当于给每件货一个唯一的身份证号。
货品档案表我命名为t_goods,关键字段包括货品编号(唯一编码)、类目(原石/毛料/成品)、品名、种水、颜色、重量、尺寸、图片路径、供应商ID、成本单价、销售单价、存放库位ID和当前状态。种水颜色这些字段在翡翠行业里是定价的核心依据,必须单独建字段而不是塞进备注里,因为后续要根据这些属性做库存统计和销售分析。
类目我这里刻意分成了三级:一级类目(原石、半成品、成品),二级类目(比如成品下分手镯、挂件、摆件、戒面),三级属性用标签字段来记录,比如“冰种”“糯种”“满绿”“飘花”。这种设计的好处是库存汇总时可以随时切换统计维度,想看“所有手镯一共多少件”还是“冰种手镯多少件”,一条SQL就能解决,不用改表结构。
2.2 用流水表加余额表解决库存准确性
进销存系统最核心的设计决策,在于库存数据不能只存一个“当前数量”。很多初学者把库存数量直接做成货品表里的一个字段,入库加、出库减,表面上看没问题,但一旦遇到单据作废、修改、重复提交,这个字段就会变得不可追溯。更稳妥的做法是拆成两张表:库存余额表和库存流水表。
库存余额表t_stock保存每个货品在每个库位的实时数量,只有入库、出库、调拨、盘点确认这四个操作能修改它,而且修改必须发生在同一个数据库事务里。库存流水表t_stock_log则记录每一次库存变动的明细,包含货品ID、变动类型(采购入库/销售出库/调拨入/调拨出/盘盈/盘亏)、变动数量、变动前后的库存快照、关联单据号、操作人和操作时间。
这两张表配合起来有个实际作用,就是任何一个时间点的库存数据都可以重放验证。比如月底发现某件货数量不对,不用去猜是哪一单出了问题,直接查流水表,按时间轴一条条核对,很快就能定位到具体操作和责任人。这也是翡翠这类高价值货品特别需要的能力——账实不符的时候,追责和复盘必须有效率。
2.3 采购、销售、盘点三大单据的主从表设计
进销存的业务动作最终都要落到单据上,单证分离是标准化管理的核心。我设计了采购订单、采购入库单、销售订单、销售出库单和盘点单五大类单据,全部采用“主表+明细表”的结构。
以采购入库单为例,主表t_purchase_in保存单据编号、供应商ID、入库仓库、入库时间、经办人、审核状态和备注;明细表t_purchase_in_item保存该单下每一件货品的信息,包括货品ID、数量、入库单价、货品类目等。为什么要拆主从表?因为一张入库单可以包含多件不同货品,如果只做一张大宽表,数据冗余会非常严重,而且后续扩展每件货品的独立属性时根本没法设计字段。
主从表在代码层面也对应了事务边界:保存采购入库单时,主表和明细表要么一起写入,要么一起回滚,绝不允许出现有头无尾的脏数据。这个过程我在后端统一封装在@Transactional事务方法里,后面会详细讲具体实现。
3. SpringBoot后端核心实现
3.1 项目结构划分与依赖配置
后端的工程结构直接决定了后续开发体验。我按照业务边界做了一个相对标准的划分,避免所有Controller堆在一起:
com.jade.stock ├── controller // 接口层 ├── service // 业务逻辑层 │ └── impl ├── mapper // MyBatis-Plus数据访问层 ├── entity // 数据库实体 ├── dto // 接口参数对象 ├── vo // 返回视图对象 ├── common // 通用工具、返回值封装、异常处理 ├── config // 配置类(安全、Redis、跨域等) └── utils // 工具类依赖方面我选的是SpringBoot 2.7.x,搭配MyBatis-Plus 3.5.x、Spring Security、Redis和MySQL 8.0。为什么不用SpringBoot 3.x?这个问题我在开发前也犹豫过,但考虑到很多生产环境依赖的组件对jakarta命名空间的兼容还需要踩坑,2.7.x在稳定性和资料丰富度上更有优势。如果你的项目是全新启动且没有历史包袱,直接上3.x也没问题,但最好确认一下MyBatis-Plus和Spring Security的版本配套。
3.2 登录认证与四角色权限控制
权限这块我直接用的Spring Security加JWT方案,没有引入更重的Shiro,也没有用Spring Cloud那种级别的微服务权限体系,因为这个项目的规模决定了简单就是最好的维护性。
登录认证的流程不复杂:用户提交账号密码,后端校验通过后生成JWT令牌,令牌里除了用户ID、用户名,还声明了一个关键字段roleCode,用来区分是管理员、仓库、采购还是销售。前端拿到令牌后存到本地,每次请求在请求头里带Authorization: Bearer xxx,后端通过过滤器解析令牌,把用户信息放到SecurityContext里供后续使用。
角色权限控制分了两层。第一层是接口级别的权限校验,使用Spring Security的@PreAuthorize("hasRole('ADMIN')")注解,不同角色的Controller方法上标注不同的访问要求。第二层是数据级别的过滤,比如查询销售出库单时,销售角色只能看到自己创建的订单:
// 销售角色查询订单列表,自动追加操作人条件 public PageResult<SaleOrderVO> querySaleOrder(SaleOrderQuery query) { // 从SecurityContext中取当前登录用户 LoginUser user = SecurityUtils.getLoginUser(); if (user.isSales()) { query.setCreateBy(user.getUserId()); } Page<SaleOrder> page = saleOrderMapper.selectPage( new Page<>(query.getPageNum(), query.getPageSize()), buildQueryWrapper(query) ); return PageResult.of(page); }这层逻辑放在Service层而不是Controller层,确保每个入口的权限过滤都统一收口,不会漏掉某个接口造成越权。数据权限的bug通常不是显性的安全性问题,而是销售看到了不该看的采购成本价,这种问题一旦发生,业务上的信任感很难修复。
3.3 采购入库、销售出库的事务实现
进销存业务里,事务管理是绝对的红线。采购入库和销售出库都涉及多张表的联动操作,任何一个环节出问题,库存数据就会变成一笔糊涂账。这里我直接贴一段采购入库的核心Service代码,把关键点拆开讲:
@Transactional(rollbackFor = Exception.class) public PurchaseInResult createPurchaseIn(PurchaseInDTO dto) { // 1. 校验供应商状态 Supplier supplier = supplierMapper.selectById(dto.getSupplierId()); if (supplier == null || supplier.getStatus() != 1) { throw new BusinessException("供应商不存在或已禁用"); } // 2. 生成单据编号并保存主表 PurchaseIn purchaseIn = new PurchaseIn(); purchaseIn.setOrderNo(generateOrderNo("PI")); purchaseIn.setSupplierId(dto.getSupplierId()); purchaseIn.setWarehouseId(dto.getWarehouseId()); purchaseIn.setStatus(0); // 待审核 purchaseInMapper.insert(purchaseIn); // 3. 循环保存明细,并同步库存余额和流水 for (PurchaseInItemDTO item : dto.getItems()) { PurchaseInItem detail = new PurchaseInItem(); detail.setPurchaseInId(purchaseIn.getId()); detail.setGoodsId(item.getGoodsId()); detail.setQuantity(item.getQuantity()); detail.setPrice(item.getPrice()); purchaseInItemMapper.insert(detail); // 锁定库存行,防止并发超卖 Stock stock = stockMapper.selectByGoodsIdAndWarehouseForUpdate( item.getGoodsId(), dto.getWarehouseId()); if (stock == null) { stock = new Stock(); stock.setGoodsId(item.getGoodsId()); stock.setWarehouseId(dto.getWarehouseId()); stock.setQuantity(item.getQuantity()); stockMapper.insert(stock); } else { stock.setQuantity(stock.getQuantity() + item.getQuantity()); stockMapper.updateById(stock); } // 写库存流水 insertStockLog(item.getGoodsId(), dto.getWarehouseId(), 1, item.getQuantity(), stock.getQuantity(), purchaseIn.getOrderNo()); } return new PurchaseInResult(purchaseIn.getId()); }这段代码里有两个关键点值得单独说。第一是@Transactional(rollbackFor = Exception.class),必须指定rollbackFor为Exception.class,因为Spring事务默认只回滚运行时异常RuntimeException,如果业务代码里抛的是受检异常,不指定的话事务不会回滚,库存就白白改了。第二是操作库存余额时用了selectByGoodsIdAndWarehouseForUpdate,也就是加了FOR UPDATE行级锁。这个锁的作用是防止两个人同时入库同一件货,导致库存叠加计算出现丢失更新。虽然翡翠仓库这种业务并发量一般不大,但作为一套要长期稳定运行的系统,这个保险必须加。
3.4 盘点流程的数据一致性处理
盘点这个功能在进销存里属于“纠偏”环节,逻辑上比普通出入库要复杂。实物数量经仓管清点后,系统里记录的账面数量可能不一致,这就需要盘盈盘亏的确认操作。
盘点单的流程我设计为:先创建盘点单并冻结当前库存快照,然后仓管录入实际盘点数量,系统自动对比账面数并计算盈亏,最后提交审核时一次性更新库存余额并生成盈亏流水。这个过程中最关键的一点是“冻结”动作,也就是创建盘点单的那一刻,要把货品的当前数量复制到盘点单明细里,后续所有对比都基于这个快照,而不是实时查库存表。这样做的好处是,如果盘点过程中有其他出库单在走,盘点结果不会被中途变化干扰。
盘点的库存更新同样放在事务中,而且我先检查盘点单的状态机,只有“已审核”状态的单据才允许修改库存,防止同一张盘点单被重复提交两次,造成库存凭空翻倍或归零。
4. Vue前端实现要点
4.1 前端工程结构与路由设计
前端这边我用的是Vue 3加Vite,UI库选择Ant Design Vue,状态管理用的Pinia。Vite的启动速度比Webpack时代的体验好太多,日常开发热更新几乎是毫秒级响应,对进销存这种大量表单联调的场景帮助很大。
工程结构上按模块拆成views、router、store、api、components几个目录。views下面按角色业务再分子目录:admin、warehouse、purchase、sales、system,每个模块下的页面只放自己相关的文件。api目录则对应后端的Controller接口,每个模块一个JS文件,统一封装axios请求。
路由设计是整个权限控制的前端入口。我在路由表里给每个路由声明了meta信息,包括roles数组,比如:
{ path: 'stock/purchase-in', name: 'PurchaseIn', component: () => import('@/views/purchase/PurchaseIn.vue'), meta: { title: '采购入库', roles: ['ADMIN', 'PURCHASE'] } }用户登录成功后,前端拿到当前用户的角色,用Router.beforeEach做一个前置守卫,遍历所有路由表,把不包含当前角色的路由全部过滤掉。这个过滤逻辑不仅实现了菜单的动态显示,还从根本上防止了用户直接在URL地址栏输入路径绕过菜单访问无权页面。
4.2 动态菜单与角色视图切换
动态菜单的实现思路不算复杂,但有不少细节。我的做法是:后端登录接口返回用户信息和角色编码,前端根据角色编码在前端本地维护一份角色-菜单映射表,渲染对应的侧边栏菜单。
这里有一个关键的选型取舍,就是菜单配置放前端还是后端。我最终选择了前端维护映射,原因是这个项目的菜单和角色是相对稳定的,不需要运营人员在后端动态配置权限菜单。把映射写在前端,省掉了一次查询菜单表的网络请求,页面加载更轻快。如果业务发展到需要管理员后台自定义菜单再放后端也不迟,前期完全没必要增加复杂度。
菜单渲染的核心代码如下:
const menuTree = computed(() => { const role = authStore.userInfo.roleCode return filterMenu(menuConfig, role) })filterMenu就是递归遍历菜单配置,根据每个菜单项的meta.roles判断是否保留。这个方案改起来也直观,要给某个角色加一个新菜单项,改一行配置就行,不用动页面代码。
4.3 核心页面的交互设计
进销存系统的核心页面绕不开三个:货品列表、入库单创建、出库单创建。
货品列表页我用的是Ant Design Vue的Table组件,配合SearchForm做筛选条件。翡翠货品筛选条件比较多,类目、种水、颜色、重量区间、库存状态都要支持,但搜索区域不能太挤,所以我把一级筛选条件做成顶栏下拉,高级条件放到“展开”面板里。表格列方面,货品图片使用缩略图,成本价和售价字段根据角色动态隐藏,这个逻辑对应后端返回的字段权限标识。
入库单创建页是整个系统里最容易让用户抱怨的页面。采购人员录入一单多件货品时,如果每件货都要开新页面录入,效率极低。所以我用了“明细表格+动态行编辑”的交互方式:主表信息在上方,中间是明细行,每行可以选择货品并填入数量、单价,底部有“添加一行”按钮,一次可以录入几十件货再统一提交。这个交互的实现要特别注意行数据的校验,漏填数量、选了重复货品都要在提交前拦截。
4.4 与后端的接口联调规范
联调阶段经常出现前端拿到的数据格式和后端不一致,或者时间字段类型没法直接渲染等问题。我把联调规范定成了几条硬性约定:后端返回对象统一包裹在{ code, message, data }结构里,分页数据固定用{ total, records }格式,日期时间字段统一返回字符串并指定yyyy-MM-dd HH:mm:ss格式。这样前端axios响应拦截器只需要处理一次结构解包,后面所有页面都不用关心数据是封装了多少层。
还有一个容易被忽略但实际很影响体验的问题:文件上传。翡翠货品需要上传图片,而且图片数量不少,我用了前端直传对象存储的方案,后端接口只负责接收上传后返回的URL。这样避免了后端Tomcat存储大图片再返回二进制流的性能问题,页面加载货品列表时的压力也小很多。
5. 常见问题与排查技巧实录
5.1 高频问题与快速排查表
前面把架构和实现都过了一遍,这段分享一下实际调试中最常遇到的一批问题,按症状、原因和解决办法整理成一张速查表,方便大家遇到类似情况时直接定位。
| 问题症状 | 常见原因 | 排查与解决 |
|---|---|---|
| 登录后前端反复重定向到登录页 | JWT过期时间设置太短,或前端axios拦截器误判响应码 | 检查Token有效期,确认后端未登录返回码统一为401,前端拦截器只在401时清除登录态 |
| 我明明配了接口权限,但另一个角色也能访问 | @PreAuthorize注解放在私有方法上,或配置类中放行了该接口 | Spring AOP对私有方法不生效,把注解放到Controller的public方法上,并检查SecurityConfig的permitAll列表 |
| 多行明细提交时,只有部分明细保存成功 | 主表和明细表没在同一个事务中,或者自增主键尚未回填 | 给Service方法加@Transactional(rollbackFor = Exception.class),入库后立即获取主表ID再写明细 |
| 两个人同时入库,库存数量比实际少了 | 库存更新语句没有加行级锁,两个会话同时读到同一旧值 | 使用SELECT FOR UPDATE锁行,或者用UPDATE ... SET quantity = quantity + #{num}的原子SQL写法 |
| 前端菜单和角色不匹配 | 路由表meta的roles写错,或后端登录接口没有返回角色编码 | 核对路由meta与后端角色枚举值一致,并在Vue Devtools里查看登录态存储的角色值 |
| 盘点单提交后发现库存被重置错乱 | 没有用盘点快照,而是实时读取库存;或者盘点单重复提交 | 创建盘点单时保存库存快照字段,审核时用快照对比实盘数;审核状态加幂等判断 |
| 图片上传后前端无法预览 | 文件对象存储的访问域名和业务域名跨域 | 在对象存储的Bucket权限里配置跨域规则,或者统一使用对象存储的默认域名 |
5.2 并发场景下如何验证库存正确性
进销存系统的并发问题平时不太容易暴露,但一到月底盘点就会现原形。我在联调时专门写过一个并发测试脚本,模拟多个线程同时提交入库和出库单,然后核对最终的库存余额是否与流水一致。方法是开启20个线程,每个线程随机执行入库或出库操作,最后用SQL把该货品的所有流水变动量累加,对比库存余额表。如果不一致,说明事务控制或锁实现存在问题。
这个验证脚本只花了一个下午的时间就发现了两个bug,一个是乐观锁版本号没有叠加,另一个是某些更新操作直接用实体对象更新时,把库存字段为空的记录覆盖成0了。写进销存的库存逻辑时,建议把“库存余额=所有流水累计变动”这个等式当成单元测试的硬性校验,每次调整业务代码后跑一遍,能省掉不少上线后的脏数据麻烦。
5.3 我的几个核心避坑心得
最后的避坑心得其实都是血泪教训换来的。第一条,权限设计一定要从第一天就做,不要觉得系统简单就先不做权限,等页面多了再补,到时候每个接口都要回头改,改动量是前期的十倍。第二条,翡翠货品的图片路径和唯一编号千万不要让用户手工输入,编号必须由系统自动生成,图片路径从上传接口返回,否则数据质量和录入效率都没谱。第三条,所有的删除操作都别用物理删除,一律用逻辑删除标记字段。这个原则在进销存里尤其重要,因为单据关联了库存流水,物理删除会直接把历史的审计链路撕断,后期查账和追责都会变成一件不可能完成的事。第四条,数据库表字段的注释一定要写清楚,特别是货品属性这一块,种水、颜色、尺寸这些字段每个团队叫法可能都不一样,你不写注释,三个月后自己来看也要猜半天。
6. 个人经验收尾
这套翡翠仓库进销存系统从需求梳理到上线,前后花了大概四周的时间。我最大的体会是,进销存系统的复杂程度不在于CRUD本身,而在于库存这种有状态的数据在多人协作、多角色并发的场景下如何保持一致性。做权限设计时多花点心思,后面能省掉大量扯皮和返工。技术选型上,SpringBoot加Vue这套组合对这类传统业务管理系统来说依然是最省心的选择,社区成熟、招人容易、解决问题快,不存在“技术太老”的问题。
如果你也要做类似项目,我建议先把四角色的权限矩阵和数据看板梳理清楚,再动手写第一行代码。单据、流水、余额这三层数据结构虽然前期要多建几张表,但上线之后你就知道这个设计有多值钱。最后再分享一个小工具上的细节,前端调试时我强烈建议装上Vue Devtools,配合路由白名单排查菜单权限问题,效率会比盲改代码高很多。