# 跨租户操作模式(Cross-Tenant Operation Pattern) > 来源:酒店-餐厅双租户拆分 > 时间:2026-09-30 ## 业务背景 酒店点餐模块拆分为两个租户: - **酒店租户**(如 tenant_id=8):管理客房、订单、支付、二维码、对账 - **餐厅租户**(如 tenant_id=9):管理菜品、分类、规格、营业时段、暂停接单 顾客通过二维码扫码下单,订单归属酒店租户(tenant_id=8)。但后续订单操作(接单、备餐、出餐、配送、完成、退单等)可能由餐厅员工在餐厅租户下执行。 ## 核心问题 ### TenantLineInnerInterceptor 的行为 MyBatis-Plus 的 `TenantLineInnerInterceptor` 会根据**当前登录上下文**自动追加 `tenant_id = 当前租户ID`: - **SELECT**:`WHERE ... AND tenant_id = 当前上下文` - **UPDATE**:`WHERE ... AND tenant_id = 当前上下文` - **DELETE**:`WHERE ... AND tenant_id = 当前上下文` ### 关键陷阱 **传参数不能覆盖拦截器行为!** ```java // ❌ 错误:以为传了 tenantId=8 就能查 tenant_id=8 的数据 orderMapper.selectKitchenOrderList(8, queryDate); // 实际生成的 SQL: SELECT ... FROM hotel_order WHERE tenant_id = 8 -- XML 显式条件(参数传入) AND tenant_id = 9 -- 拦截器自动追加(当前登录上下文) -- 两个条件矛盾 → 查不到数据! ``` ## 解决方案 ### 方案一:executeWithTenant — 临时切换租户上下文 用于**查询/操作另一个租户的数据**。 ```java // ✅ 正确:用 executeWithTenant 切换上下文 final Long hotelTenantId = 8L; // 从映射表解析 TenantContextHolder.executeWithTenant(hotelTenantId, new Runnable() { @Override public void run() { // 拦截器现在追加 tenant_id = 8 orderMapper.selectKitchenOrderList(hotelTenantId, queryDate); orderMapper.updateById(order); // WHERE tenant_id = 8 } }); ``` **关键点**: - `executeWithTenant` 切换的是**整个上下文**,拦截器和 XML 都会使用新值 - 适用于有返回值的场景用 `Supplier` 重载 - 适用于无返回值的场景用 `Runnable` 重载 ### 方案二:executeIgnore — 绕过租户过滤 用于**系统级配置表**(如映射表),所有租户都需要访问。 ```java // ✅ 正确:映射表是系统级配置,用 executeIgnore 绕过 default HotelTenantMapping selectByRestaurantTenantId(Long restaurantTenantId) { return TenantContextHolder.executeIgnore(() -> _selectByRestaurantTenantId(restaurantTenantId)); } ``` **关键点**: - 映射表的 `tenant_id` 应设为 `1`(系统级) - XML 中不要写 `tenant_id = ?` 条件 - 用 `default` 方法 + `executeIgnore` 包裹实际查询方法 ## 双租户映射表设计 ### 表结构 ```sql CREATE TABLE hotel_tenant_mapping ( id BIGINT NOT NULL, tenant_id BIGINT NOT NULL DEFAULT 1 COMMENT '系统级配置,固定为1', hotel_tenant_id BIGINT NOT NULL COMMENT '酒店租户ID', restaurant_tenant_id BIGINT NOT NULL COMMENT '餐厅租户ID', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', ... ); ``` ### 查询模式 ```java // Mapper 接口:用 default 方法 + executeIgnore default Long selectRestaurantTenantId(Long hotelTenantId) { return TenantContextHolder.executeIgnore(() -> _selectRestaurantTenantId(hotelTenantId)); } // XML:不写 tenant_id 条件 ``` ## Service 层租户解析模式 ### 解析酒店租户(管理端查询/操作订单) ```java private Long resolveHotelTenantForAdmin() { Long currentTenantId = resolveTenantId(); // 尝试从映射表反查:restaurant_tenant_id -> hotel_tenant_id HotelTenantMapping mapping = tenantMappingMapper.selectByRestaurantTenantId(currentTenantId); if (mapping != null && mapping.getHotelTenantId() != null) { return mapping.getHotelTenantId(); } // 未找到映射,说明当前就是酒店租户或未配置双租户 return currentTenantId; } ``` ### 解析餐厅租户(顾客端查菜品) ```java private Long resolveRestaurantTenantId(Long hotelTenantId) { Long restaurantTenantId = tenantMappingMapper.selectRestaurantTenantId(hotelTenantId); return restaurantTenantId != null ? restaurantTenantId : hotelTenantId; } ``` ## 完整方法包裹模式 ### 查询方法 ```java @Override public List kitchenOrderList(final Date queryDate) { final Long hotelTenantId = resolveHotelTenantForAdmin(); return TenantContextHolder.executeWithTenant(hotelTenantId, new java.util.function.Supplier>() { @Override public List get() { List list = orderMapper.selectKitchenOrderList(hotelTenantId, queryDate); for (HotelOrderVO vo : list) { fillOrderItems(vo); } return list; } }); } ``` ### 操作方法 ```java @Override @Transactional(rollbackFor = Exception.class) public void orderAccept(final Long id) { final Long hotelTenantId = resolveHotelTenantForAdmin(); TenantContextHolder.executeWithTenant(hotelTenantId, new Runnable() { @Override public void run() { HotelOrder order = requireOrder(id); if (order.getStatus() != HotelOrderConstants.STATUS_PLACED) { throw new BusinessException("只有待接单的订单才能接单"); } order.setStatus(HotelOrderConstants.STATUS_ACCEPTED); order.setAcceptTime(new Date()); orderMapper.updateById(order); // WHERE tenant_id = hotelTenantId } }); } ``` ### 混合租户上下文(订单 + 营业时段) ```java @Override public DashboardVO dashboard() { final Long hotelTenantId = resolveHotelTenantForAdmin(); final DashboardVO vo = new DashboardVO(); // 订单查询:切换到酒店租户 TenantContextHolder.executeWithTenant(hotelTenantId, new Runnable() { @Override public void run() { // 订单相关查询... } }); // 营业时段:用当前上下文(餐厅租户) List hoursList = businessHoursMapper.selectEnabled(resolveTenantId()); // 餐段订单统计:切回酒店租户 TenantContextHolder.executeWithTenant(hotelTenantId, new Runnable() { @Override public void run() { // 订单统计查询... } }); return vo; } ``` ## 跨租户方法调用注意事项 ### 问题场景 ```java // orderComplete 在 executeWithTenant(8, ...) 内调用 incrementDishSales public void orderComplete(Long id) { executeWithTenant(8, () -> { // ... 订单操作 ... incrementDishSales(id); // ❌ 内部 resolveTenantId() 返回 8(酒店租户) }); } private void incrementDishSales(Long orderId) { // resolveTenantId() 返回 8(因为外层已切换到酒店租户) Long restaurantTenantId = resolveRestaurantTenantId(resolveTenantId()); // resolveRestaurantTenantId(8) → 查映射表 → 返回 9 ✅ 碰巧正确 // 但如果映射表查询失败,回退返回 8 → executeWithTenant(8) → 查不到菜品数据 ❌ } ``` ### 正确做法:显式传递租户 ID ```java // 在切换租户前保存原始餐厅租户ID public void orderComplete(final Long id) { final Long hotelTenantId = resolveHotelTenantForAdmin(); final Long originalRestaurantTenantId = resolveRestaurantTenantId(resolveTenantId()); TenantContextHolder.executeWithTenant(hotelTenantId, new Runnable() { @Override public void run() { // ... 订单操作 ... incrementDishSales(id, originalRestaurantTenantId); // ✅ 显式传递 } }); } private void incrementDishSales(Long orderId, Long restaurantTenantId) { List items = orderItemMapper.selectByOrderId(orderId); TenantContextHolder.executeWithTenant(restaurantTenantId, new Runnable() { @Override public void run() { for (HotelOrderItem item : items) { dishMapper.incrementSales(item.getDishId(), item.getQuantity()); } } }); } ``` ## tenant_id 是否会被意外修改? ### updateById(entity) ```sql UPDATE hotel_order SET status = 2, ..., tenant_id = 8 -- 实体内存中的值(加载时是 8,未改动) WHERE id = ? AND tenant_id = 8 -- 拦截器追加 ``` **结论**:`tenant_id` 保持原值,不会变。 ### UpdateWrapper ```sql UPDATE hotel_order SET status = 7, reject_reason = ? -- 只 SET 显式指定的字段 WHERE id = ? AND tenant_id = 8 -- 拦截器追加 ``` **结论**:没有 `.set("tenant_id", ...)` → `tenant_id` 不会被修改。 ### deleteById ```sql UPDATE hotel_order SET del_flag = id WHERE id = ? AND tenant_id = 8 ``` **结论**:逻辑删除只改 `del_flag`,`tenant_id` 不变。 ## 影响范围分析 ### 对酒店租户(8)登录无影响 - `resolveHotelTenantForAdmin()` 查映射表 → 未找到(8 是 hotel_tenant_id,不是 restaurant_tenant_id)→ 回退返回 8 - `executeWithTenant(8, ...)` → 当前上下文已经是 8 → **no-op,无变化** ### 对餐厅租户(9)登录的影响 - `resolveHotelTenantForAdmin()` 查映射表 → 找到 `hotel_tenant_id = 8` → 返回 8 - `executeWithTenant(8, ...)` → 从 9 切换到 8 → **订单相关操作正确执行** ### 不受影响的模块 菜品管理、菜品分类、规格管理、营业时段、房间管理、入住管理、二维码管理、支付回调、对账管理等模块完全不涉及跨租户,不受影响。 ## 最佳实践总结 1. **传参数不能覆盖拦截器**:必须用 `executeWithTenant` 切换上下文 2. **系统级配置表用 `executeIgnore`**:映射表等跨租户共享数据,`tenant_id = 1` 3. **整个操作包裹**:`requireOrder` + `updateById` 必须在同一个 `executeWithTenant` 内 4. **显式传递租户 ID**:跨租户方法调用时,在切换前保存原始租户 ID,显式传递 5. **对当前租户无影响**:`executeWithTenant(当前租户, ...)` 是 no-op,不会改变现有行为 6. **tenant_id 不会被意外修改**:`updateById` 的 SET 来自实体内存值,`UpdateWrapper` 只 SET 显式字段 ## 相关文件 - 映射表:`hotel_tenant_mapping`(Flyway V1.0.144) - Mapper:`HotelTenantMappingMapper.java` / `HotelTenantMappingMapper.xml` - Service:`HotelOrderServiceImpl.java`(所有订单操作方法) - 租户上下文:`TenantContextHolder.executeWithTenant()` / `executeIgnore()`