ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

yshop多租户点餐系统实战:多门店SaaS架构与租户隔离设计

yshop多租户点餐系统实战:多门店SaaS架构与租户隔离设计 简介这是一套面向企业及个人二次开发的意象点餐扫码点餐系统源码覆盖在线点餐、外卖与自取、多门店及SaaS多租户等业务场景采用Java与uniappVue3前后端分离架构可同时输出H5与微信小程序。后端基于SpringBoot、Spring Security OAuth2、MybatisPlus、JWT与Redis前端使用Vue3适合有一定Java与前端基础、希望快速搭建点餐平台的开发者。压缩包共2005个文件约17.63MB其中1324个java文件承载核心业务逻辑257个vue与161个js文件构成管理端与移动端界面另有xml、html、css、sql、yaml等配置与脚本文件结构完整。功能上包含商品多规格SKU、店铺管理、云小票打印、图片素材库、订单管理、积分兑换、充值、优惠券、微信公众号、门店移动端、分销商、桌面扫码点餐、拼团、邀请有礼与会员卡等模块可直接用于二次开发或学习参考。目前已有149人学习下载。1. 扫码点餐系统选型为什么 yshop 的多门店 SaaS 模式值得动手上个月帮一个做连锁轻食的朋友看系统他手里有 7 家店之前用某平台的标准版点餐小程序结果每开一家新店就要重新走一遍入驻审核会员储值还不能跨店通用运营数据散在七个后台里对账对到怀疑人生。这就是典型的单门店思维撞上连锁扩张的墙。yshop 意象点餐系统解决的正是这个问题它把扫码点餐、外卖自取、多门店管理、SaaS 多租户这几件事放在同一套小程序架构里让总部管规则、门店管出餐、用户端只认一个品牌入口。如果你正在评估自建点餐系统或者手头有多个门店需要统一收银和会员体系这套方案的租户隔离设计和门店数据模型值得花时间跑一遍。它适合有技术团队的中小连锁品牌也适合想给客户交付点餐系统的外包团队前提是你得接受它是一套需要自己部署和维护的源码工程而不是开箱即用的 SaaS 账号。2. yshop 多租户点餐系统的数据模型与租户隔离怎么落地2.1 多门店与多租户在数据库里到底怎么分很多刚接触 SaaS 点餐系统的开发者会把“多门店”和“多租户”混成一件事结果设计出来的表结构要么门店之间数据串了要么每个租户都得单独部署一套库运维成本直接爆炸。yshop 意象点餐系统的做法是两层分离租户层对应一个品牌或一个 SaaS 客户门店层对应租户下的具体经营单元。租户隔离靠tenant_id字段贯穿所有业务表门店归属靠store_id做二级过滤。用户在小程序端扫码时二维码里携带的是门店标识后端根据门店反查租户再把请求路由到对应租户的数据空间。这种设计的好处是同一个数据库实例可以承载多个租户每个租户的门店数量不设上限会员、订单、菜品这些核心数据既不会跨租户泄露也不会跨门店错乱。代价是每一条查询都必须带上租户条件漏一个条件就是血泪事故。我一般会在 MyBatis 的拦截器里做统一注入避免手写 SQL 时忘记加tenant_id。下面是一个典型的租户与门店关联表结构用 SQL 建表语句说明字段含义-- 租户表一个租户对应一个品牌或一个 SaaS 客户 CREATE TABLE sys_tenant ( id bigint NOT NULL AUTO_INCREMENT COMMENT 租户ID, tenant_name varchar(64) NOT NULL COMMENT 租户名称, contact_phone varchar(20) DEFAULT NULL COMMENT 联系人电话, expire_time datetime DEFAULT NULL COMMENT 租户到期时间, status tinyint DEFAULT 1 COMMENT 状态 1正常 0停用, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT租户表; -- 门店表门店挂在租户下一个租户可以有多个门店 CREATE TABLE sys_store ( id bigint NOT NULL AUTO_INCREMENT COMMENT 门店ID, tenant_id bigint NOT NULL COMMENT 所属租户ID, store_name varchar(128) NOT NULL COMMENT 门店名称, address varchar(255) DEFAULT NULL COMMENT 门店地址, business_status tinyint DEFAULT 1 COMMENT 营业状态 1营业 0休息, qr_code_url varchar(255) DEFAULT NULL COMMENT 门店点餐码地址, PRIMARY KEY (id), KEY idx_tenant_id (tenant_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT门店表;建表时sys_store上的idx_tenant_id索引不能省否则租户下门店一多后台门店列表查询就会明显变慢。qr_code_url字段存的是门店专属点餐码用户扫码后小程序解析出store_id再请求后端换取门店信息和菜单。这里有个容易翻车的地方二维码里不要直接暴露自增主键常见做法是生成一个 UUID 或短码做映射防止有人遍历门店 ID 爬菜单。2.2 扫码点餐小程序的桌台与订单流转扫码点餐的核心链路是用户扫桌台码 → 小程序获取桌台信息 → 展示菜单 → 加购下单 → 订单进入后厨 → 用户支付 → 订单完成。yshop 在这条链路上把外卖和自取也统一进来了区别在于下单时选择的就餐方式不同桌台码对应堂食首页入口对应外卖或自取。订单表里用order_type区分用store_id和tenant_id做数据隔离。桌台码和门店码要分开。桌台码携带table_id用户扫码后直接进入点餐页并绑定桌号门店码只携带store_id用户进入后需要选择堂食、外卖还是自取。我见过有人把两种码混用结果外卖订单里出现了桌号字段后厨打印小票时直接懵了。订单状态流转建议用状态机管理不要散落在各个 Service 里用 if-else 硬编码。下面是一个简化的订单状态枚举和流转说明public enum OrderStatus { WAIT_PAY(0, 待支付), PAID(1, 已支付), PREPARING(2, 制作中), READY(3, 待取餐), DELIVERING(4, 配送中), COMPLETED(5, 已完成), CANCELLED(6, 已取消); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } // 判断当前状态是否允许取消 public boolean canCancel() { return this WAIT_PAY || this PAID; } // 判断是否允许进入制作环节 public boolean canPrepare() { return this PAID; } }状态机的意义在于当门店操作员在后台点“开始制作”时后端先校验当前订单是否处于PAID状态避免待支付订单被误操作进入后厨。canCancel方法限制了已进入制作环节的订单不能直接取消需要走退款流程。这些校验看起来简单但少了它们上线后一定会遇到订单状态错乱、后厨重复出餐的问题。2.3 多租户下的菜单与库存隔离菜单是点餐系统里最容易被忽视的隔离点。同一个租户下不同门店的菜单可以不同价格也可以不同库存更是必须按门店独立扣减。yshop 的菜品表设计里菜品基础信息挂在租户层门店通过关联表决定上架哪些菜品、设置什么价格、分配多少库存。这样总部可以统一维护菜品图片和描述门店只调价格和库存减少重复录入。库存扣减要特别注意并发。扫码点餐的高峰期同一道菜可能同时被几十个订单扣减如果用“查询库存 → 判断是否足够 → 更新库存”这种三步走超卖几乎必然发生。常见做法是用数据库的乐观锁或 Redis 原子操作。下面是一个基于 Redis 的库存预扣减示例import redis r redis.Redis(hostlocalhost, port6379, db0) def deduct_stock(store_id, dish_id, quantity): 基于 Redis 的库存预扣减 key 格式stock:{store_id}:{dish_id} 返回 True 表示扣减成功False 表示库存不足 key fstock:{store_id}:{dish_id} # 先判断当前库存是否足够 current r.get(key) if current is None: # 缓存未命中时从数据库加载这里省略数据库查询逻辑 return False if int(current) quantity: return False # 使用 decrby 原子扣减避免并发超卖 remaining r.decrby(key, quantity) if remaining 0: # 扣减后为负数说明并发时被其他请求抢先回补并返回失败 r.incrby(key, quantity) return False return True这段代码的关键在于decrby是原子操作多个请求同时到达时 Redis 会串行执行不会出现两个请求都读到足够库存然后同时扣减的情况。扣减后如果remaining小于 0说明在判断和扣减之间库存被其他请求消耗了此时回补并返回失败。参数store_id和dish_id组合成 Redis key保证不同门店的同一菜品库存互不影响。实际生产环境还要考虑缓存与数据库的一致性通常会在订单支付成功后异步落库并设置定时任务对账。3. 从零跑通 yshop 点餐小程序环境、配置与最小验证3.1 后端服务与数据库的启动顺序拿到一套 yshop 源码后不要急着改代码先把最小可运行环境跑起来。我一般按“数据库 → Redis → 后端服务 → 管理后台 → 小程序端”的顺序推进每一步验证通过再走下一步。后端通常是 Spring Boot 项目依赖 MySQL 和 Redis配置文件里需要改数据库连接、Redis 地址和租户相关参数。下面是一个典型的application.yml配置片段重点看多租户和门店相关的配置项spring: datasource: url: jdbc:mysql://127.0.0.1:3306/yshop_meal?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver redis: host: 127.0.0.1 port: 6379 database: 0 timeout: 5000 # 多租户配置 tenant: enable: true column: tenant_id ignore-tables: - sys_tenant - sys_user # 门店点餐相关 meal: order: auto-cancel-minutes: 15 # 待支付订单超时自动取消时间 print-enabled: true # 是否开启后厨打印 qr: table-prefix: table_ # 桌台码前缀 store-prefix: store_ # 门店码前缀tenant.ignore-tables里列出的表不参与租户过滤比如租户表本身和系统用户表。这个配置漏了会导致登录时查不到用户因为拦截器给sys_user也加上了tenant_id条件。auto-cancel-minutes控制待支付订单的超时时间设得太短用户还没付就被取消设得太长又会占用桌台资源一般 15 到 30 分钟比较合理。print-enabled在开发环境可以先关掉避免没有接打印机时一直报错。启动顺序上先执行数据库初始化脚本再启动 Redis然后启动后端。后端启动日志里看到租户拦截器加载和门店缓存预热完成后再打开管理后台。管理后台默认账号通常在初始化 SQL 里登录后第一件事是创建一个测试租户和一个测试门店拿到门店 ID 和租户 ID后面调试小程序端会用到。3.2 小程序端扫码点餐的调试入口小程序端调试最麻烦的是扫码环节。在微信开发者工具里你可以手动在编译模式里添加启动参数模拟扫码进入的场景。常见做法是配置一个scene参数值为门店 ID 或桌台 ID小程序启动时在onLoad里解析。下面是一个小程序端解析扫码参数的示例// pages/index/index.js Page({ onLoad(options) { // options.scene 是扫码进入时携带的参数 // 例如门店码 scenestore_1001桌台码 scenetable_2001 const scene options.scene || ; if (scene.startsWith(table_)) { const tableId scene.replace(table_, ); this.setData({ tableId, orderType: dine_in }); this.loadMenuByTable(tableId); } else if (scene.startsWith(store_)) { const storeId scene.replace(store_, ); this.setData({ storeId, orderType: }); this.loadStoreInfo(storeId); } else { // 普通进入展示附近门店或默认门店 this.loadDefaultStore(); } }, loadMenuByTable(tableId) { // 请求后端获取桌台对应的门店和菜单 wx.request({ url: ${app.globalData.baseUrl}/api/meal/table/${tableId}/menu, success: (res) { if (res.data.code 200) { this.setData({ menu: res.data.data.menu, storeId: res.data.data.storeId }); } } }); } });options.scene是微信小程序扫码进入时特有的参数普通编译模式下不会自动带上所以需要在开发者工具的“编译模式”里手动添加。table_和store_前缀要和后端配置里的meal.qr.table-prefix保持一致否则解析会失败。loadMenuByTable拿到桌台 ID 后请求后端后端根据桌台反查门店和租户再返回该门店的菜单。这里要注意桌台码对应的菜单应该是该门店已上架的菜品不能把租户下所有菜品都返回。调试外卖和自取时不需要扫码直接在首页选择就餐方式然后走正常的门店选择和菜单加载流程。外卖需要额外处理收货地址自取需要处理预计取餐时间。这两个流程和堂食共用菜单和订单模块区别只在订单类型和配送信息上。3.3 多门店切换与租户上下文传递在管理后台里总部管理员可以切换查看不同门店的数据门店管理员只能看到自己门店的数据。这个权限控制靠的是登录时绑定的租户 ID 和门店 ID以及后端接口上的数据权限注解。小程序端用户不需要感知租户但后端每次请求都要从 Token 或请求头里解析出租户上下文。下面是一个后端解析租户上下文的拦截器示例Component public class TenantInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 从请求头获取租户ID小程序端登录后由网关或过滤器写入 String tenantId request.getHeader(X-Tenant-Id); String storeId request.getHeader(X-Store-Id); if (tenantId ! null) { TenantContext.setTenantId(Long.valueOf(tenantId)); } if (storeId ! null) { TenantContext.setStoreId(Long.valueOf(storeId)); } return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 请求结束后清理 ThreadLocal防止线程复用导致租户串数据 TenantContext.clear(); } }TenantContext用 ThreadLocal 存储当前请求的租户和门店信息MyBatis 拦截器在拼接 SQL 时从这里取值。afterCompletion里的清理动作绝对不能省Tomcat 的线程池会复用线程如果不清理下一个请求可能拿到上一个租户的上下文造成数据泄露。这个坑我在早期项目里踩过表现为偶尔有用户看到别的门店订单排查了半天才定位到 ThreadLocal 没清理。小程序端登录后后端返回的 Token 里可以携带租户 ID后续请求由网关统一解析并写入请求头。门店 ID 则根据用户当前选择的门店动态传递比如切换门店时前端更新请求头里的X-Store-Id。这样后端不需要在每个接口里手动传租户和门店参数拦截器统一处理。4. yshop 点餐系统避坑排查租户串数据、库存超卖与扫码翻车4.1 租户数据串了现象、原因与解决现象A 租户的管理员在后台看到了 B 租户的订单或会员数据或者小程序端用户点餐时菜单里出现了其他门店的菜品。原因最常见的是 MyBatis 拦截器没有对所有查询生效比如手写的 XML SQL 里用了SELECT * FROM order而没有加tenant_id条件拦截器解析不到表名就跳过了。另一种情况是 ThreadLocal 上下文在异步线程里丢失比如订单导出用了Async子线程拿不到父线程的租户 ID。解决统一用 MyBatis-Plus 的租户插件避免手写 SQL 绕过拦截。异步任务里手动传递租户上下文或者在异步方法入口重新从请求头解析。上线前写一个租户隔离测试用例用两个租户的数据交叉查询确认查不到对方数据。4.2 库存超卖现象、原因与解决现象某道菜实际库存只有 10 份但高峰期卖出了 15 份后厨做不出来用户投诉。原因库存扣减没有做原子操作或者扣减和订单创建不在同一个事务里。常见错误是先查库存、再创建订单、最后扣库存中间有时间窗口被其他请求插入。解决用 Redis 原子扣减做预占订单支付成功后再落库。如果支付失败或超时取消回补 Redis 库存。数据库层面加乐观锁版本号更新时校验版本。对账任务定期比对 Redis 库存和数据库库存发现不一致时以数据库为准修正。4.3 扫码进入白屏或参数解析失败现象用户扫门店码后小程序白屏或者提示“门店不存在”。原因二维码里的scene参数超过了微信限制的长度或者参数里包含了特殊字符没有做 URL 编码。另一种情况是门店 ID 在数据库里被删除了但二维码还在流通。解决scene参数控制在 32 个字符以内用短码映射代替长 ID。生成二维码时对参数做 URL 编码小程序端解析后解码。门店删除时同步失效对应的二维码或者在后端做软删除保留门店记录但标记为停用扫码时提示“门店已停业”。4.4 后厨打印重复或漏单现象同一笔订单后厨打印了两张小票或者订单已支付但后厨没收到。原因打印逻辑放在了订单状态变更的监听器里而状态变更可能被多次触发比如支付回调重试。漏单则可能是打印服务异常没有重试机制。解决打印任务加唯一键比如订单 ID 加打印类型插入打印队列表时做唯一约束重复插入直接忽略。打印失败进入重试队列重试三次后告警通知门店管理员。支付回调要做幂等同一笔订单多次回调只处理一次。4.5 小程序端苹果手机防截屏导致点餐码无法识别现象部分苹果手机用户反馈扫码后无法进入点餐页安卓手机正常。原因微信小程序在苹果系统上对截屏和相机权限有额外限制如果点餐码是在小程序内部生成的图片用户长按识别时可能被拦截。解决点餐码尽量用实体二维码贴纸避免让用户在小程序内长按识别。如果必须在小程序内展示引导用户使用“扫一扫”功能直接扫描而不是长按识别。测试阶段覆盖 iOS 和安卓主流机型别只在自己的安卓机上跑通就上线。5. 多租户点餐系统的进阶技巧租户初始化与数据迁移租户初始化是 SaaS 点餐系统里最容易被低估的环节。每开通一个新租户需要创建租户记录、初始化默认门店、导入基础菜单、配置支付参数、生成门店二维码。手动操作不仅慢还容易漏步骤。我一般会写一个租户初始化脚本把这一串动作串起来新租户开通从半小时缩短到两分钟。下面是一个租户初始化的伪代码流程用 Python 描述def init_tenant(tenant_name, contact_phone, store_name): 租户初始化流程 1. 创建租户记录 2. 创建默认门店 3. 导入基础菜单模板 4. 生成门店点餐码 5. 初始化租户管理员账号 # 第一步创建租户 tenant_id create_tenant(tenant_name, contact_phone) # 第二步创建默认门店绑定租户 store_id create_store(tenant_id, store_name) # 第三步从模板复制基础菜单到该租户 # 模板菜单存在 sys_tenant_id0 的公共租户下 copy_menu_template(source_tenant_id0, target_tenant_idtenant_id) # 第四步生成门店点餐码写入 qr_code_url qr_url generate_qr_code(store_id, prefixstore_) update_store_qr(store_id, qr_url) # 第五步创建租户管理员绑定租户和门店 create_tenant_admin(tenant_id, store_id, contact_phone) return {tenant_id: tenant_id, store_id: store_id, qr_url: qr_url}这个流程里菜单模板复制是关键。模板菜单挂在公共租户下新租户初始化时复制一份后续租户可以自由修改自己的菜单不影响模板和其他租户。复制时要注意菜品图片的路径如果图片存在本地文件系统复制菜单记录后图片文件不需要复制多个租户可以共用同一份图片资源如果图片存在对象存储复制时只需要复制 URL 引用。数据迁移是另一个进阶话题。当租户从试用转为正式或者从单门店升级为多门店时可能需要把测试数据迁移到生产库。我一般用租户 ID 做数据导出和导入的过滤条件导出时只导指定租户的数据导入时重新映射租户 ID 和门店 ID。迁移前先在测试环境跑一遍确认关联关系没有断裂。迁移脚本里要加事务中途失败可以回滚避免迁一半留下脏数据。验证租户隔离是否彻底我习惯用两个租户交叉测试用租户 A 的账号登录尝试通过修改请求参数访问租户 B 的订单 ID看后端是否返回 403 或空数据。这个测试用例每次上线前都跑一遍比事后排查便宜得多。多租户系统里隔离性不是功能是底线。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进