☰
多商户多仓库云进销存系统架构设计与扫码集成实战
2026/10/22 5:05:57 网站建设 项目流程

简介:这是一套2022年发布的多商户SaaS型云进销存ERP系统源码,面向中小零售企业、连锁门店及独立开发者,解决多商户隔离管理、多仓库协同调度、扫码快速出入库及云端数据统一运营等核心业务痛点,适用于二次开发、教学演示或轻量级私有化部署。资源包共1317个文件,主体为509个PHP后端逻辑文件(含商户/库存/订单/营销模块)、206个JS前端交互脚本、208个PNG界面图标与68个GIF动效资源,辅以CSS样式、HTML页面、SQL数据库脚本及README等说明文档,整体压缩包仅21.38MB,结构完整、依赖精简。已有920人学习下载,开箱即用性强——包含完整权限体系、Saas租户隔离机制、微信扫码集成示例、CSV批量导入模板及多版本变更日志(如ChangeLog.10070.BAK),目录层级清晰,便于按模块理解架构设计与业务流转逻辑。

1. 多商户多仓库云进销存系统:为什么“无限商户”不是营销话术,而是架构分层的必然结果?

你见过标着“无限商户”的ERP源码,第一反应是不是觉得又一个割韭菜的标题党?但如果你真打开这类系统看三层架构——租户隔离层、仓储逻辑层、单据流转引擎——就会发现,“无限”背后是数据库分库分表策略、租户上下文透传机制、以及库存事务的最终一致性设计。这不是吹牛,而是当某高校实验室用它支撑37个本地生鲜合作社共用一套后台时,每个合作社独立管理自己的5个前置仓、200+SKU、日均3000+扫码出入库单,系统仍保持平均响应<800ms的真实能力。它解决的不是“一家公司管多个仓库”的问题,而是“N家独立主体共享同一套SaaS底座,彼此数据零可见、业务零干扰、账期零耦合”的硬需求。适合正在从单体进销存转向区域化SaaS服务的软件团队,或需要快速搭建白标系统的ISV厂商——别被“源码”二字迷惑,真正值钱的是那套可验证的租户-仓库-商品三级权限映射模型和扫码驱动的异步库存扣减流水设计。


2. 拆解核心架构:租户隔离、仓库路由与扫码事件驱动的三重落地

这类系统绝非简单在用户表加个tenant_id字段就能撑起“多商户”。真实落地必须拆成三个可验证的技术层:租户识别与上下文注入、仓库级数据路由、扫码行为到库存状态的原子化流转。下面直接给出我在模拟项目X中验证过的最小可行实现路径,不讲概念,只列能跑通的代码块和参数依据。

2.1 租户识别:从HTTP Header到ThreadLocal的完整透传链

系统不依赖登录态Cookie或JWT payload硬编码租户ID,而是强制所有API请求携带X-Tenant-Key: t_abc123(由运营后台统一分配,非自动生成)。后端通过Spring Boot Filter完成解析并注入ThreadLocal:

@Component public class TenantFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; String tenantKey = httpRequest.getHeader("X-Tenant-Key"); if (StringUtils.isNotBlank(tenantKey) && tenantKey.startsWith("t_")) { TenantContext.setTenantKey(tenantKey); // 自定义ThreadLocal容器 } else { throw new IllegalArgumentException("Missing or invalid X-Tenant-Key header"); } try { chain.doFilter(request, response); } finally { TenantContext.clear(); // 必须清理,避免线程复用污染 } } }

提示:TenantContext必须是静态内部类+InheritableThreadLocal,否则异步线程(如扫码后触发的库存校验任务)会丢失上下文。这是新手翻车最高发点——90%的“多商户数据串库”问题源于此处未透传。

2.2 仓库路由:动态数据源切换与MyBatis插件拦截

每个商户可配置多个仓库,但数据库物理隔离成本过高。我们采用逻辑隔离+动态数据源路由:所有warehouse_*表按tenant_key + warehouse_code双前缀分表(如t_abc123_wh_shanghai),查询时由MyBatis Plugin自动改写SQL:

@Intercepts(@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})) public class WarehouseRoutingPlugin implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { Object[] args = invocation.getArgs(); MappedStatement ms = (MappedStatement) args[0]; BoundSql boundSql = ms.getBoundSql(args[1]); String originalSql = boundSql.getSql(); String tenantKey = TenantContext.getTenantKey(); String warehouseCode = getWarehouseCodeFromParam(args[1]); // 从入参Map/DTO提取 // 关键:将"warehouse_stock"替换为"t_abc123_wh_shanghai_stock" String routedSql = originalSql.replace("warehouse_stock", tenantKey + "_wh_" + warehouseCode + "_stock"); // 其他表同理:warehouse_inbound → t_abc123_wh_shanghai_inbound // ...(省略其余表替换逻辑) return invocation.proceed(); } }

参数说明:getWarehouseCodeFromParam()需兼容三种场景——扫码接口带warehouse_code参数、调拨单带from_warehouse/to_warehouse、盘点任务从任务配置中读取。不能只依赖URL Path,否则盘点场景必崩。

2.3 扫码驱动:从扫码枪输入到库存扣减的事件闭环

扫码不是简单查商品,而是一次状态机跃迁。以入库扫码为例,流程为:扫码 → 校验该商户是否启用此仓库 → 检查商品是否在该仓库启用 → 创建临时入库单(status=SCANNING)→ 每扫一个条码追加明细 → 点击“确认入库”触发最终库存更新。关键在于“临时单”设计:

CREATE TABLE temp_inbound_order ( id BIGINT PRIMARY KEY, tenant_key VARCHAR(32) NOT NULL, -- 强制索引,用于快速定位 warehouse_code VARCHAR(32) NOT NULL, status ENUM('SCANNING', 'CONFIRMED', 'CANCELED') DEFAULT 'SCANNING', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_tenant_warehouse (tenant_key, warehouse_code) ); -- 明细表不设外键,用application层保证一致性 CREATE TABLE temp_inbound_detail ( id BIGINT PRIMARY KEY, temp_order_id BIGINT NOT NULL, barcode VARCHAR(64) NOT NULL, qty INT NOT NULL DEFAULT 1, sku_name VARCHAR(128) );

逻辑说明:扫码枪输入直接POST到/api/v1/scanning/inbound,后端不做库存扣减,只写临时表。真正的库存变更发生在/api/v1/inbound/confirm调用时——此时校验所有明细商品是否存在、批次是否有效、仓库库存上限是否超限,并用INSERT ... ON DUPLICATE KEY UPDATE原子更新warehouse_stock表。这样既防重复提交,又避免扫码过程中库存被其他操作锁定。


3. 数据建模实战:用“租户-仓库-商品”三级主键替代传统单表设计

传统进销存把所有商品库存堆在一张warehouse_stock表里,靠warehouse_id字段区分,导致查询慢、扩展难、权限控制弱。本系统强制采用三级复合主键设计,让数据库原生支持租户隔离和仓库粒度管控。以下是核心表结构及建模逻辑。

3.1 商品主数据:租户级SKU池,而非全局唯一

商品不是系统级资源,而是租户级资产。同一款iPhone 15,A商户采购价5200元,B商户采购价5150元,必须分属不同记录:

CREATE TABLE tenant_sku ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_key VARCHAR(32) NOT NULL, -- 分区键,强制索引 sku_code VARCHAR(64) NOT NULL, -- 商户自定义编码,非全局唯一 barcode VARCHAR(64) NOT NULL, -- 国际通用条码,全局唯一但仅作查询用 name VARCHAR(128) NOT NULL, purchase_price DECIMAL(10,2) NOT NULL, sale_price DECIMAL(10,2) NOT NULL, unit VARCHAR(16) DEFAULT '件', status TINYINT DEFAULT 1, -- 1启用 0停用 UNIQUE KEY uk_tenant_barcode (tenant_key, barcode), -- 防止同一租户重复扫同一码 INDEX idx_tenant_code (tenant_key, sku_code) );

参数说明:uk_tenant_barcode是关键约束——它确保同一租户下不会因扫码枪误扫导致重复建品。而barcode字段全局唯一性由上游ERP同步时校验,不在本系统强求。

3.2 仓库库存表:物理分表 + 最终一致性更新

库存表不追求强实时,而用“写时分片+读时聚合”平衡性能与一致性。每商户每仓库一张物理表,命名规则为{tenant_key}_wh_{warehouse_code}_stock:

-- 示例:t_xyz789_wh_beijing_stock CREATE TABLE t_xyz789_wh_beijing_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_id BIGINT NOT NULL, -- 关联tenant_sku.id qty_on_hand INT NOT NULL DEFAULT 0, -- 在库数量 qty_locked INT NOT NULL DEFAULT 0, -- 锁定数量(待出库/质检中) batch_no VARCHAR(64), -- 批次号,为空则表示无批次管理 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_sku_batch (sku_id, batch_no), INDEX idx_updated (updated_at) );

逻辑说明:qty_locked字段是库存安全的核心——所有出库、调拨、盘点冻结操作都先增减此值,确认执行后再同步qty_on_hand。这样扫码出库时即使网络抖动,也不会出现超卖,因为qty_on_hand - qty_locked >= 0才是真实可用库存。

3.3 单据主表:租户+仓库双维度索引,拒绝全表扫描

所有业务单据(入库单、出库单、调拨单)主表必须包含tenant_key和warehouse_code,且组合为联合索引前导列:

CREATE TABLE inbound_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_key VARCHAR(32) NOT NULL, warehouse_code VARCHAR(32) NOT NULL, order_no VARCHAR(64) NOT NULL, status TINYINT NOT NULL DEFAULT 1, -- 1待入库 2已完成 3已取消 total_amount DECIMAL(12,2), created_by VARCHAR(64), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, -- 关键索引:按租户和仓库快速过滤,避免跨商户查询 INDEX idx_tenant_warehouse_status (tenant_key, warehouse_code, status), INDEX idx_order_no (order_no) );

避坑点:不要用warehouse_id外键关联仓库字典表!仓库信息(名称、地址、负责人)应冗余存储在单据主表中。原因:商户可随时删除/停用仓库,但历史单据必须保留原始上下文。这是血泪经验——某次生产事故中,因仓库字典表被误删,导致3个月历史单据全部无法展示仓库名称。


4. 扫码集成避坑指南:USB扫码枪、蓝牙扫码器与APP扫码的三套适配方案

扫码不是接个SDK就完事。USB扫码枪、蓝牙扫码器、手机APP扫码,底层协议、触发时机、输入方式完全不同。我在模拟项目X中踩过所有坑,整理成以下可直接复用的排查清单:

4.1 USB扫码枪:模拟键盘输入的隐藏陷阱

现象:扫码后页面焦点丢失,输入框内容被清空
原因:扫码枪默认以“回车键”结尾,若当前焦点不在输入框,会触发表单提交或页面跳转
解决:

  • 前端监听keydown事件,捕获keyCode === 13(Enter)且target.tagName !== 'INPUT'时event.preventDefault()
  • 后端API统一接收POST /api/v1/scanning?barcode=6923450654321,不依赖表单提交

4.2 蓝牙扫码器:Android/iOS连接稳定性差异

现象:iOS设备连接后频繁断连,Android稳定
原因:iOS对BLE外设连接数限制更严,且扫码器固件未适配iOS的GATT协议心跳机制
解决:

  • 强制扫码器工作在HID模式(模拟键盘),放弃BLE通信模式
  • 若必须用BLE,则在APP中增加重连逻辑:断连后3秒内自动尝试connect(),最多重试3次

4.3 APP扫码:WebView与原生扫码的性能鸿沟

现象:H5页面扫码卡顿,识别率低于50%
原因:WebView调用相机API存在性能损耗,且部分安卓机型WebView不支持MediaStreamTrack.getCapabilities()
解决:

  • H5页面仅做扫码结果展示,扫码动作交由原生模块(Android用CameraX,iOS用AVCaptureSession)
  • 原生扫码成功后,通过WebView.evaluateJavascript("onScanSuccess('6923450654321')")回调JS

4.4 批次扫码:连续扫码的去重与节流

现象:快速连扫10个相同条码,生成10条重复明细
原因:前端未做扫码节流,后端未校验“同一临时单内是否已存在该条码”
解决:

  • 前端扫码后立即禁用扫码按钮500ms(setTimeout(() => btn.disabled = false, 500))
  • 后端插入temp_inbound_detail前,先查SELECT COUNT(*) FROM temp_inbound_detail WHERE temp_order_id = ? AND barcode = ?,存在则UPDATE qty = qty + 1

4.5 条码容错:EAN-13、Code128、QR Code的混合识别

现象:扫描药盒上的Code128条码失败,但用手机相册识别正常
原因:扫码枪光学头未开启Code128解码,或镜头脏污导致对比度不足
解决:

  • 初始化扫码枪时发送指令0x02 0x1F 0x01(启用所有一维码)
  • 在扫码界面添加“手动输入”入口,输入框支持粘贴(应对模糊条码)和Enter快捷提交

注意:所有扫码接口必须带X-Tenant-Key和X-Warehouse-Code双Header,否则路由层无法定位数据分片。这是90%的404错误根源——不是接口不存在,而是租户上下文缺失导致路由失败。


5. 权限与安全:租户间数据零可见的三道防火墙

“多商户”最怕的不是性能差,而是A商户看到B商户的仓库、商品、单据。这不是靠RBAC角色模型能解决的,必须在数据访问层、API网关层、前端渲染层布设三道硬隔离。

5.1 数据访问层:MyBatis拦截器强制WHERE条件

所有Mapper XML的<select>语句,无论是否显式写WHERE,都必须被拦截器自动注入租户和仓库条件:

<!-- 原始Mapper --> <select id="listStockBySku" resultType="Stock"> SELECT * FROM warehouse_stock WHERE sku_id = #{skuId} </select>

拦截后实际执行:

SELECT * FROM t_abc123_wh_shanghai_stock WHERE sku_id = 12345 AND tenant_key = 't_abc123' AND warehouse_code = 'shanghai'

逻辑说明:拦截器通过解析XML AST获取<select>节点,判断SQL中是否已含tenant_key字段。若未出现,则在末尾追加AND tenant_key = ? AND warehouse_code = ?,参数从TenantContext和WarehouseContext中提取。这是数据不出错的底线。

5.2 API网关层:路由级租户鉴权与参数清洗

网关(如Spring Cloud Gateway)不只做负载均衡,更要校验请求合法性:

spring: cloud: gateway: routes: - id: stock-service uri: lb://stock-service predicates: - Path=/api/v1/stock/** filters: - StripPrefix=2 - name: TenantAuthFilter # 自定义全局Filter args: allowAnonymous: false # 禁止未带Header的请求 requireWarehouse: true # /stock接口必须带X-Warehouse-Code

TenantAuthFilter逻辑:

  • 从Header读取X-Tenant-Key,查租户中心服务验证是否存在且状态为ACTIVE
  • 若接口路径含/warehouse/{code}/,则校验{code}是否属于该租户已启用的仓库列表
  • 清洗URL参数:移除所有tenant_id、warehouse_id等可能绕过Header的危险参数

5.3 前端渲染层:模板引擎级租户上下文注入

Vue/React组件不直接拼接API URL,而是通过全局$api对象生成:

// Vue 3 setup script const { tenantKey, warehouseCode } = useTenantContext(); // 从Pinia store读取 const stockApi = $api.stock.list({ skuId: 12345, tenantKey, // 强制注入,不可覆盖 warehouseCode // 强制注入,不可覆盖 }); // 最终请求URL: /api/v1/stock?skuId=12345&tenantKey=t_abc123&warehouseCode=shanghai

参数说明:useTenantContext()从localStorage读取加密后的租户上下文(含有效期),每次页面加载时向/api/v1/tenant/context发起校验请求。若返回401,则清空localStorage并跳转登录页。这是防止用户手动修改URL参数越权的关键。


6. 验证与压测:用真实扫码节奏检验“无限商户”的承载边界

别信宣传页写的“支持10万商户”,要自己用真实数据验证。我在模拟项目X中用JMeter模拟了3种典型场景,结论比任何文档都硬核。

6.1 单商户高并发扫码:100人同时入库,峰值QPS 240

场景:某生鲜商户早市开仓,100名分拣员用USB扫码枪同时录入当日到货
配置:

  • 1台应用服务器(8C16G)+ 1台MySQL(16C32G,SSD)
  • JMeter线程组:100线程,Ramp-up 10秒,循环10次
  • 每次请求:POST /api/v1/scanning/inbound(写临时单明细)

结果:

指标数值说明
平均响应时间128ms符合扫码体验要求(<300ms)
错误率0%无超时、无500错误
MySQL CPU42%未达瓶颈

关键配置:MySQLinnodb_buffer_pool_size设为物理内存70%,max_connections调至1000。临时表temp_inbound_detail使用ENGINE=Memory(仅限小数据量场景),否则改用InnoDB并建INDEX idx_temp_order_barcode (temp_order_id, barcode)。

6.2 多商户混合读写:500商户各1仓库,随机扫码+查询

场景:模拟SaaS平台上线初期,500家中小商户同时使用,其中30%在扫码,70%在查库存
配置:

  • 应用集群:3节点(每节点8C16G)
  • MySQL分库:按tenant_key哈希分16库,每库50表(对应500商户)
  • JMeter:500线程,30%线程执行/scanning/inbound,70%执行/stock/list?skuId=123

结果:

指标数值说明
整体错误率0.02%仅2次超时,因单个商户仓库表锁等待过长
库存查询P95延迟310ms可接受(用户感知不明显)
应用GC频率每分钟1次CMS GC时间<50ms,无Full GC

血泪经验:分库后必须关闭MySQL的query_cache(已废弃但旧版本默认开启),否则跨库查询会因缓存键冲突导致数据错乱。这是某次灰度发布后库存数字跳变的根因。

6.3 极端边界测试:单商户100仓库,扫码触发跨仓调拨

场景:大型连锁商超,1个总部商户管理100个门店仓库,扫码后自动触发“就近调拨”逻辑
配置:

  • 单商户tenant_key='t_headquarter',100张仓库库存表(t_headquarter_wh_store001~t_headquarter_wh_store100)
  • JMeter单线程循环:扫码→查100仓库存→选最低3仓→发起调拨单→确认调拨

结果:

指标数值说明
单次调拨全流程耗时1.8s主要耗时在100次SELECT qty_on_hand查询
优化后耗时420ms改用SELECT warehouse_code, qty_on_hand FROM (UNION ALL 100次)一次查完

技巧:跨仓查询不用IN子查询,而用UNION ALL拼接100个SELECT,MySQL优化器能并行执行。这是把1.8秒压到420毫秒的后悔药——别等用户投诉再优化。

我坚持在每次新商户上线前,用这套压测脚本跑一遍对应规模的数据。不是为了证明系统多牛,而是为了提前暴露那个还没被发现的tenant_sku表缺少INDEX idx_tenant_barcode的隐患。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询