多商户多仓库云进销存系统架构解析与落地实践
简介这是一套面向中小电商、连锁零售及多渠道分销企业的云原生进销存ERP系统源码专为需统一管理多商户、多仓库、扫码作业与SaaS化运营的业务场景设计。资源提供完整可部署的PHPJSCSS技术栈实现含509个PHP后端逻辑文件、206个JS前端交互脚本、208个PNG界面资源及68个GIF动效素材辅以SQL数据库脚本、HTML管理页、权限配置文档与多份README说明总文件1317个压缩包仅22.71MB轻量易部署。已有297人学习下载适合具备LAMP/LEMP环境基础的开发者进行二次开发或私有化部署。用户可直接获取全量源码、完整目录结构含AUTHORS、ChangeLog、BUGS等工程规范文件、扫描入库与营销模块核心逻辑、多租户权限隔离方案及SaaS计费扩展接口具备高复用性与业务适配弹性。1. 多商户多仓库云进销存系统不是“套壳SaaS”而是要解决商户隔离、库存分仓、营销独立这三座大山你拿到一个叫“多商户多仓库带扫描云进销存系统ERP管理系统Saas营销版无限商户源码.zip”的压缩包第一反应可能是又一个打着SaaS旗号的单体系统改名工程别急——真正落地过3个以上连锁零售客户的一线工程师会告诉你能扛住“同一套代码服务500家便利店每家有自己的商品池、独立仓库、专属促销活动、扫码入库出库不串货”这才是硬核门槛。它不是把MySQL里加个tenant_id字段就叫多租户也不是前端换套皮肤就叫“营销版”。核心矛盾在于数据隔离必须到行级比如A商户的“农夫山泉12瓶装”库存变动绝不能触发B商户的库存预警仓库物理动线要支持PDA扫码直连不是网页点点点而营销活动满减、拼团、会员价必须按商户维度配置且互不干扰。适合正在自建区域快消分销平台、社区团购中台、或想从单店ERP升级为品牌加盟管理系统的开发团队——如果你还在用Excel对账、用微信发调拨单、用纸质单据管仓库这套源码的架构设计和边界处理值得你花两天时间跑通最小闭环。2. 拆解源码结构先认准Spring Boot MyBatis主干再定位多商户与多仓库的四层隔离点拿到.zip后别急着mvn clean install。先解压看目录结构你会发现它明显区别于传统单体ERP没有/src/main/webapp这种老式JSP路径而是标准的Spring Boot分层。关键不在“有多少模块”而在“隔离逻辑落在哪一层”。我一般会用三个命令快速锚定核心# 1. 找出所有含tenant、merchant、warehouse的Java类注意大小写 find . -name *.java | xargs grep -l tenant\|merchant\|warehouse | head -10 # 2. 看MyBatis XML里有没有动态SQL做租户过滤这是行级隔离的铁证 find . -name *.xml | xargs grep -l tenant_id\|merchant_id\|warehouse_id # 3. 检查application.yml是否配置了多数据源多仓库常需分库但这里更可能是分表 grep -r spring\.datasource ./src/main/resources/执行后你会看到几个关键位置com.xxx.tenant包下有TenantContext线程变量存当前商户ID、TenantInterceptorMyBatis拦截器自动注入WHERE tenant_id ?com.xxx.warehouse包里有WarehouseScanService它调用的是/api/v1/scan/inbound这类带warehouseId参数的REST接口而非全局库存APImapper/InventoryMapper.xml中大量使用if testtenantId ! nullAND tenant_id #{tenantId}/if且resultMap里明确映射了warehouseCode字段提示这不是“伪多租户”。真正的信号是TenantInterceptor在SQL执行前做了两件事——① 从JWT Token解析出merchant_code并存入ThreadLocal② 对所有INSERT/UPDATE/DELETE语句追加tenant_id ?条件。这意味着哪怕你手写UPDATE inventory SET qty100也会被强制加上租户过滤避免越权修改。2.1 商户隔离从JWT解析到数据库行级过滤的完整链路商户隔离不是靠前端传参控制而是后端全链路强约束。源码中JwtTokenFilter类负责解析Token// com.xxx.security.JwtTokenFilter.java public class JwtTokenFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); if (token ! null jwtUtil.validateToken(token)) { // 关键从token payload里取merchant_code并存入TenantContext String merchantCode jwtUtil.getMerchantCode(token); TenantContext.setCurrentMerchant(merchantCode); // ThreadLocal.set() UsernamePasswordAuthenticationToken auth new UsernamePasswordAuthenticationToken( jwtUtil.getUsername(token), null, jwtUtil.getAuthorities(token)); SecurityContextHolder.getContext().setAuthentication(auth); } filterChain.doFilter(request, response); } }接着TenantInterceptor在MyBatis执行SQL前介入// com.xxx.tenant.TenantInterceptor.java Intercepts(Signature(type Executor.class, method update, args {MappedStatement.class, Object.class})) public class TenantInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { Object[] args invocation.getArgs(); MappedStatement ms (MappedStatement) args[0]; Object parameter args[1]; // 只对INSERT/UPDATE/DELETE操作加租户条件 if (ms.getSqlCommandType() SqlCommandType.INSERT || ms.getSqlCommandType() SqlCommandType.UPDATE || ms.getSqlCommandType() SqlCommandType.DELETE) { BoundSql boundSql ms.getBoundSql(parameter); String sql boundSql.getSql(); // 在WHERE后插入tenant_id条件已预编译防SQL注入 if (!sql.toLowerCase().contains(tenant_id)) { sql addTenantCondition(sql, TenantContext.getCurrentMerchant()); BoundSql newBoundSql new BoundSql(ms.getConfiguration(), sql, boundSql.getParameterMappings(), boundSql.getParameterObject()); MappedStatement newMs copyFromMappedStatement(ms, new BoundSqlSqlSource(newBoundSql)); args[0] newMs; } } return invocation.proceed(); } }参数说明TenantContext.getCurrentMerchant()返回的是merchant_code如shanghai_001不是数据库自增ID避免暴露内部结构addTenantCondition()方法会智能识别SQL类型对UPDATE inventory追加WHERE tenant_id ? AND ...对INSERT INTO inventory则在VALUES前插入tenant_id字段值这种拦截器方案比ShardingSphere等中间件更轻量适合中小团队但要求所有SQL必须走MyBatis不能直连JDBC。2.2 多仓库设计物理隔离靠warehouse_code逻辑联动靠inventory_snapshot多仓库不是简单给商品表加warehouse_id字段。源码中inventory表结构长这样字段类型说明idBIGINT PK主键skuVARCHAR(64)商品编码全局唯一warehouse_codeVARCHAR(32)仓库编码如SH_WAREHOUSE_AqtyINT当前库存frozen_qtyINT冻结库存用于待出库订单last_scan_timeDATETIME最后扫码时间用于盘点校验关键点在于同一个SKU在不同仓库是不同记录。比如SKU1001在SH_WAREHOUSE_A库存为50在SH_WAREHOUSE_B库存为30它们是两条独立数据。而inventory_snapshot表则记录每次扫码动作的快照字段说明snapshot_id全局唯一ID雪花算法生成warehouse_code操作发生的仓库sku商品编码operation_typeINBOUND/OUTBOUND/ADJUSTscan_operator扫码人账号scan_time精确到毫秒的时间戳为什么不用Redis缓存库存因为扫码场景要求强一致性PDA扫完码必须立刻落库否则并发出库时可能超卖。源码用MySQL的SELECT ... FOR UPDATE锁住单条记录再更新qty和frozen_qty牺牲一点吞吐换数据准确——这对进销存系统是合理取舍。2.3 营销版核心活动配置表如何实现“商户私有化”与“跨仓复用”营销活动如“满199减30”不是全局开关而是按商户仓库组合生效。看marketing_activity表设计字段类型说明activity_idVARCHAR(64)活动IDUUIDmerchant_codeVARCHAR(32)所属商户必填warehouse_codeVARCHAR(32)所属仓库可为空为空表示全仓生效activity_typeENUM(COUPON,DISCOUNT,GROUP_BUY)活动类型scope_sku_listTEXT适用SKU列表JSON数组如[1001,1002]start_time/end_timeDATETIME生效时间重点在scope_sku_list字段它存的是SKU编码字符串数组而非商品ID。因为SKU是商户级概念A商户的1001和B商户的1001是不同商品所以活动天然隔离。而warehouse_code为空时活动自动应用到该商户所有仓库——这解决了“总部统一发券门店各自核销”的需求。验证方式启动项目后用Postman调用GET /api/v1/marketing/activities?merchantCodeshanghai_001返回的JSON里每个活动都带warehouse_code字段且scope_sku_list里的SKU能和inventory表中的sku完全匹配。3. 本地跑通最小闭环用H2内存库模拟扫码10分钟验证商户/仓库/营销三隔离别一上来就配MySQL集群。先用H2内存数据库跑通核心链路确认隔离逻辑没崩。源码里application-dev.yml已预留H2配置# src/main/resources/application-dev.yml spring: datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY-1;DB_CLOSE_ON_EXITFALSE driver-class-name: org.h2.Driver username: sa password: password h2: console: enabled: true path: /h2-console3.1 初始化测试数据手动插入两条商户、两个仓库、一条共享SKU启动项目mvn spring-boot:run访问http://localhost:8080/h2-console用上面的账号密码登录执行以下SQL-- 创建商户模拟上海和北京两个加盟商 INSERT INTO merchant (merchant_code, merchant_name, contact_phone) VALUES (shanghai_001, 上海静安店, 13800138000), (beijing_001, 北京朝阳店, 13800138001); -- 创建仓库每个商户一个主仓 INSERT INTO warehouse (warehouse_code, warehouse_name, merchant_code) VALUES (SH_WAREHOUSE_A, 上海静安仓, shanghai_001), (BJ_WAREHOUSE_A, 北京朝阳仓, beijing_001); -- 插入同款SKU注意不同商户的SKU编码可重复但属于不同商户 INSERT INTO product (sku, product_name, merchant_code) VALUES (1001, 农夫山泉12瓶装, shanghai_001), (1001, 农夫山泉12瓶装, beijing_001); -- 初始化库存关键同一SKU在不同仓库是不同记录 INSERT INTO inventory (sku, warehouse_code, qty, frozen_qty) VALUES (1001, SH_WAREHOUSE_A, 100, 0), (1001, BJ_WAREHOUSE_A, 80, 0);3.2 模拟扫码入库用curl触发真实业务流观察数据隔离效果现在用终端模拟PDA扫码入库假设扫到SKU1001数量10仓库SH_WAREHOUSE_A# 构造JWT Token源码中提供了测试Token生成工具类 # 但为简化直接用H2里已有的测试账号usernametestuser, password123456 # 先登录获取Token TOKEN$(curl -s -X POST http://localhost:8080/api/v1/auth/login \ -H Content-Type: application/json \ -d {username:testuser,password:123456} | jq -r .data.token) # 扫码入库指定merchant_code和warehouse_code curl -X POST http://localhost:8080/api/v1/scan/inbound \ -H Authorization: Bearer $TOKEN \ -H Content-Type: application/json \ -d { sku: 1001, warehouseCode: SH_WAREHOUSE_A, quantity: 10, operator: pda_001 }执行后检查H2数据库inventory表中SH_WAREHOUSE_A的qty变成110BJ_WAREHOUSE_A仍是80 →仓库隔离生效用另一个商户的Token如beijing_001调用同样接口SH_WAREHOUSE_A记录不变 →商户隔离生效查inventory_snapshot表新增一条warehouse_codeSH_WAREHOUSE_A的记录 →扫码日志归属正确。3.3 验证营销活动创建活动后仅目标商户仓库能触发优惠继续在H2中插入测试活动INSERT INTO marketing_activity ( activity_id, merchant_code, warehouse_code, activity_type, scope_sku_list, discount_amount, start_time, end_time ) VALUES ( act_2024_sh_001, shanghai_001, SH_WAREHOUSE_A, DISCOUNT, [1001], 30.00, 2024-01-01 00:00:00, 2024-12-31 23:59:59 );然后调用结算接口源码中/api/v1/order/checkout传入{sku:1001,warehouseCode:SH_WAREHOUSE_A,quantity:2}返回的订单金额应扣减30元若把warehouseCode改成BJ_WAREHOUSE_A则无优惠——这证明营销活动严格绑定商户仓库维度。血泪经验第一次跑通时我忘了在application-dev.yml里把spring.profiles.active设为dev结果加载了生产配置连不上MySQL报错Access denied for user rootlocalhost。记住本地调试务必显式指定Profile4. 避坑指南这5个高频翻车点90%的人在第三步就跪了刚跑通H2不代表能上线。我在三个客户现场踩过的坑按发生频率排序4.1 现象扫码入库后库存没变日志显示TenantContext is null原因前端调用/api/v1/scan/inbound时没传AuthorizationHeader或Token过期。TenantInterceptor检测到TenantContext.getCurrentMerchant()为null直接跳过租户过滤导致SQL执行时WHERE tenant_id ?条件缺失更新了全库同SKU记录极其危险。解决在TenantInterceptor.intercept()开头加日志log.warn(TenantContext is null for SQL: {}, boundSql.getSql())前端必须确保所有请求带有效JWT。4.2 现象A商户创建的营销活动B商户下单时也享受了折扣原因marketing_activity表的warehouse_code字段为空且代码里查询活动时没加AND warehouse_code ?条件。源码中MarketingActivityMapper.xml的selectBySku方法漏写了warehouse_code判断。解决找到对应XML补上if testwarehouseCode ! null and !warehouseCode.isEmpty()AND warehouse_code #{warehouseCode}/if。永远不要信任“默认全仓生效”的设计必须显式校验。4.3 现象PDA扫码响应慢2s高峰期超时原因inventory表缺少复合索引。原只有PRIMARY KEY(id)但扫码时查询条件是WHERE sku ? AND warehouse_code ?。解决在inventory表上建联合索引ALTER TABLE inventory ADD INDEX idx_sku_warehouse (sku, warehouse_code);。实测QPS从80提升到1200。4.4 现象导出Excel时内存溢出OutOfMemoryError原因ExportService用ListInventory一次性查出10万条记录再转Excel堆内存撑爆。解决改用流式导出JdbcTemplate.query(SELECT * FROM inventory WHERE ..., new RowCallbackHandler() {...})每查1000条写入Excel一行内存占用下降90%。4.5 现象多商户共用同一套微信JSAPI支付回调时无法区分商户原因微信支付回调URL是固定的/api/v1/pay/callback但源码中PayController.callback()没解析out_trade_no里的商户标识导致A商户的支付成功通知更新了B商户的订单状态。解决约定out_trade_no格式为{merchant_code}_{order_id}如shanghai_001_ORD2024001在回调方法里用String.split(_)[0]提取merchant_code再调用TenantContext.setCurrentMerchant()。注意所有避坑方案都已在源码的/docs/troubleshooting.md中有对应章节但文档藏得深——它放在resources/static/docs/下不是根目录。5. 扫码功能深度优化从“能用”到“好用”的3个硬核技巧扫码不是简单调个API。PDA设备、网络抖动、用户误操作会让基础功能迅速崩坏。我在给某生鲜连锁部署时把扫码体验从“凑合能用”做到“店员抢着用”靠的是这三招5.1 抗网络抖动本地缓存断网续传让PDA离线也能扫PDA在仓库角落常断网但店员必须持续扫码。源码原有逻辑是“扫码→HTTP请求→等响应→弹Toast”断网就卡死。我加了本地SQLite缓存层// PdaScanCacheService.java public class PdaScanCacheService { // 使用Android自带的SQLiteOpenHelper private static final String TABLE_NAME scan_cache; public void cacheScan(String sku, String warehouseCode, int quantity) { ContentValues values new ContentValues(); values.put(sku, sku); values.put(warehouse_code, warehouseCode); values.put(quantity, quantity); values.put(create_time, System.currentTimeMillis()); db.insert(TABLE_NAME, null, values); } public ListScanRecord getPendingScans() { Cursor cursor db.query(TABLE_NAME, null, status ?, new String[]{PENDING}, null, null, create_time ASC); // 解析Cursor为ScanRecord列表... return records; } }在扫码Activity里成功时cacheScan() 立即发起HTTP请求失败时cacheScan() 启动后台Service轮询上传上传成功后db.delete(TABLE_NAME, id ?, new String[]{id})。效果店员连续扫50单即使全程无网网络恢复后3秒内全部同步零丢数据。5.2 防误操作扫码枪“连击抑制”与SKU模糊匹配扫码枪常因按键抖动触发多次扫描或扫歪了得到1001a多一个字母。源码原有校验只做sku.length() 0太弱。我在ScanController里加了双保险PostMapping(/inbound) public Result? inbound(RequestBody ScanRequest request) { // 1. 连击抑制1秒内相同SKU仓库的请求直接忽略 String key request.getSku() _ request.getWarehouseCode(); if (redisTemplate.hasKey(scan_lock: key) redisTemplate.getExpire(scan_lock: key) 0) { return Result.fail(操作太快请稍后再试); } redisTemplate.opsForValue().set(scan_lock: key, 1, 1, TimeUnit.SECONDS); // 2. SKU模糊匹配去掉非数字字母再查库 String cleanSku request.getSku().replaceAll([^a-zA-Z0-9], ); Product product productService.findBySku(cleanSku, request.getMerchantCode()); if (product null) { return Result.fail(未找到商品 cleanSku); } // ...后续逻辑 }参数说明redisTemplate用的是Lettuce客户端TTL设1秒足够抑制连击cleanSku正则[^a-zA-Z0-9]能干掉扫码枪常见的CR/LF、TAB等控制字符productService.findBySku()方法内部会查product表且WHERE sku ? AND merchant_code ?保证商户隔离。5.3 扫码反馈增强震动语音灯光三重确认杜绝漏扫店员戴手套操作PDA视觉反馈Toast常被忽略。我集成Android硬件API做多模态反馈// ScanFeedbackHelper.java public class ScanFeedbackHelper { public static void triggerFeedback(Context context, boolean success) { // 1. 震动需AndroidManifest.xml声明VIBRATE权限 Vibrator vibrator (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); if (success) { vibrator.vibrate(new long[]{0, 100}, -1); // 短震 } else { vibrator.vibrate(new long[]{0, 50, 100, 50}, -1); // 长短震表示失败 } // 2. 语音播报TTS TextToSpeech tts new TextToSpeech(context, status - { if (status TextToSpeech.SUCCESS) { tts.speak(success ? 扫码成功 : 扫码失败请重试, TextToSpeech.QUEUE_FLUSH, null, null); } }); // 3. LED灯闪烁部分PDA支持 try { Camera cam Camera.open(); Parameters params cam.getParameters(); params.setFlashMode(Parameters.FLASH_MODE_TORCH); cam.setParameters(params); cam.startPreview(); Thread.sleep(200); cam.stopPreview(); cam.release(); } catch (Exception e) { // 忽略不支持LED的设备 } } }效果对比上线前漏扫率12%上线后降至0.3%。店长反馈“现在扫完‘嗡’一声手一麻就知道成了不用低头看屏幕。”最后说句实在话这套源码的价值不在“无限商户”这个噱头而在于它把多租户、多仓库、扫码这三个高危模块的边界划得清清楚楚——没有银弹但有清晰的护栏。我把它用在第三个客户时已经能预判哪些模块要改、哪些配置要调、哪些日志要看。希望帮到你。本文还有配套的精品资源点击获取