tech-cross-tenant-operation-pattern.md 11 KB

跨租户操作模式(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 = 当前上下文

关键陷阱

传参数不能覆盖拦截器行为!

// ❌ 错误:以为传了 tenantId=8 就能查 tenant_id=8 的数据
orderMapper.selectKitchenOrderList(8, queryDate);

// 实际生成的 SQL:
SELECT ... FROM hotel_order
WHERE tenant_id = 8          -- XML 显式条件(参数传入)
  AND tenant_id = 9          -- 拦截器自动追加(当前登录上下文)
-- 两个条件矛盾 → 查不到数据!

解决方案

方案一:executeWithTenant — 临时切换租户上下文

用于查询/操作另一个租户的数据。

// ✅ 正确:用 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<T> 重载
  • 适用于无返回值的场景用 Runnable 重载

方案二:executeIgnore — 绕过租户过滤

用于系统级配置表(如映射表),所有租户都需要访问。

// ✅ 正确:映射表是系统级配置,用 executeIgnore 绕过
default HotelTenantMapping selectByRestaurantTenantId(Long restaurantTenantId) {
    return TenantContextHolder.executeIgnore(() -> _selectByRestaurantTenantId(restaurantTenantId));
}

关键点:

  • 映射表的 tenant_id 应设为 1(系统级)
  • XML 中不要写 tenant_id = ? 条件
  • 用 default 方法 + executeIgnore 包裹实际查询方法

双租户映射表设计

表结构

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',
    ...
);

查询模式

// Mapper 接口:用 default 方法 + executeIgnore
default Long selectRestaurantTenantId(Long hotelTenantId) {
    return TenantContextHolder.executeIgnore(() -> _selectRestaurantTenantId(hotelTenantId));
}

// XML:不写 tenant_id 条件
<select id="_selectRestaurantTenantId" resultType="java.lang.Long">
    SELECT restaurant_tenant_id
    FROM hotel_tenant_mapping
    WHERE hotel_tenant_id = #{hotelTenantId}
      AND status = 'ACTIVE'
      AND del_flag = 0
    LIMIT 1
</select>

Service 层租户解析模式

解析酒店租户(管理端查询/操作订单)

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;
}

解析餐厅租户(顾客端查菜品)

private Long resolveRestaurantTenantId(Long hotelTenantId) {
    Long restaurantTenantId = tenantMappingMapper.selectRestaurantTenantId(hotelTenantId);
    return restaurantTenantId != null ? restaurantTenantId : hotelTenantId;
}

完整方法包裹模式

查询方法

@Override
public List<HotelOrderVO> kitchenOrderList(final Date queryDate) {
    final Long hotelTenantId = resolveHotelTenantForAdmin();
    return TenantContextHolder.executeWithTenant(hotelTenantId, new java.util.function.Supplier<List<HotelOrderVO>>() {
        @Override
        public List<HotelOrderVO> get() {
            List<HotelOrderVO> list = orderMapper.selectKitchenOrderList(hotelTenantId, queryDate);
            for (HotelOrderVO vo : list) {
                fillOrderItems(vo);
            }
            return list;
        }
    });
}

操作方法

@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
        }
    });
}

混合租户上下文(订单 + 营业时段)

@Override
public DashboardVO dashboard() {
    final Long hotelTenantId = resolveHotelTenantForAdmin();
    final DashboardVO vo = new DashboardVO();

    // 订单查询:切换到酒店租户
    TenantContextHolder.executeWithTenant(hotelTenantId, new Runnable() {
        @Override
        public void run() {
            // 订单相关查询...
        }
    });

    // 营业时段:用当前上下文(餐厅租户)
    List<HotelBusinessHours> hoursList = businessHoursMapper.selectEnabled(resolveTenantId());

    // 餐段订单统计:切回酒店租户
    TenantContextHolder.executeWithTenant(hotelTenantId, new Runnable() {
        @Override
        public void run() {
            // 订单统计查询...
        }
    });

    return vo;
}

跨租户方法调用注意事项

问题场景

// 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

// 在切换租户前保存原始餐厅租户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<HotelOrderItem> 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)

UPDATE hotel_order
SET status = 2, ..., tenant_id = 8  -- 实体内存中的值(加载时是 8,未改动)
WHERE id = ? AND tenant_id = 8      -- 拦截器追加

结论:tenant_id 保持原值,不会变。

UpdateWrapper

UPDATE hotel_order
SET status = 7, reject_reason = ?   -- 只 SET 显式指定的字段
WHERE id = ? AND tenant_id = 8      -- 拦截器追加

结论:没有 .set("tenant_id", ...) → tenant_id 不会被修改。

deleteById

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()