|
|
@@ -0,0 +1,323 @@
|
|
|
+# 跨租户操作模式(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<T>` 重载
|
|
|
+- 适用于无返回值的场景用 `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 条件
|
|
|
+<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 层租户解析模式
|
|
|
+
|
|
|
+### 解析酒店租户(管理端查询/操作订单)
|
|
|
+
|
|
|
+```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<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;
|
|
|
+ }
|
|
|
+ });
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 操作方法
|
|
|
+
|
|
|
+```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<HotelBusinessHours> 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<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)
|
|
|
+
|
|
|
+```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()`
|