徐滕 пре 1 недеља
родитељ
комит
968800dddc
19 измењених фајлова са 3479 додато и 1168 уклоњено
  1. 4 3
      forge-h5-ui/src/pages/hotel/customer/cart.vue
  2. 6 4
      forge-h5-ui/src/pages/hotel/customer/dish-detail.vue
  3. 10 7
      forge-h5-ui/src/pages/hotel/customer/menu.vue
  4. 9 7
      forge-h5-ui/src/pages/hotel/customer/order-confirm.vue
  5. 7 3
      forge-h5-ui/src/pages/hotel/customer/room-confirm.vue
  6. 8 2
      forge-h5-ui/src/pages/index/index.vue
  7. 11 3
      forge-h5-ui/src/store/modules/hotel-order.js
  8. 2353 684
      forge-server/db/forge_admin.sql
  9. 29 0
      forge-server/db/migration/V1.0.144__add_hotel_tenant_mapping.sql
  10. 15 0
      forge-server/db/migration/V1.0.145__add_h5_order_menu_permissions.sql
  11. 1 1
      forge-server/forge-admin-server/src/main/resources/application-dev.yml
  12. 1 1
      forge-server/forge-app-server/src/main/resources/application-dev.yml
  13. 44 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelTenantMapping.java
  14. 49 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelTenantMappingMapper.java
  15. 573 452
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/service/impl/HotelOrderServiceImpl.java
  16. 6 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelQrCodeServiceImpl.java
  17. 4 1
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/QrCodeScanVO.java
  18. 26 0
      forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelTenantMappingMapper.xml
  19. 323 0
      forge-server/forge-business/forge-hotel/跨租户处理数据文档.md

+ 4 - 3
forge-h5-ui/src/pages/hotel/customer/cart.vue

@@ -166,11 +166,12 @@ onMounted(() => {
   loadPauseStatus()
 })
 
-/** 加载暂停接单状态 */
+/** 加载暂停接单状态(归属餐厅租户) */
 async function loadPauseStatus() {
-  if (!orderStore.tenantId) return
+  const rTenantId = orderStore.restaurantTenantId || orderStore.tenantId
+  if (!rTenantId) return
   try {
-    var res = await api.hotelPauseStatus(orderStore.tenantId)
+    var res = await api.hotelPauseStatus(rTenantId)
     isPaused.value = res?.data ?? res ?? false
   } catch (e) {
     console.log('暂停状态查询失败:', e?.message || e)

+ 6 - 4
forge-h5-ui/src/pages/hotel/customer/dish-detail.vue

@@ -207,7 +207,8 @@ onMounted(async () => {
 
 async function loadDishDetail() {
   try {
-    const res = await api.hotelDishDetail(dishId, orderStore.tenantId)
+    const rTenantId = orderStore.restaurantTenantId || orderStore.tenantId
+    const res = await api.hotelDishDetail(dishId, rTenantId)
     const data = res?.data || res
     dish.value = data || {}
 
@@ -231,11 +232,12 @@ function getImageUrl(d) {
   return getFileDownloadUrl(d.image)
 }
 
-/** 加载暂停接单状态 */
+/** 加载暂停接单状态(归属餐厅租户) */
 async function loadPauseStatus() {
-  if (!orderStore.tenantId) return
+  const rTenantId = orderStore.restaurantTenantId || orderStore.tenantId
+  if (!rTenantId) return
   try {
-    var res = await api.hotelPauseStatus(orderStore.tenantId)
+    var res = await api.hotelPauseStatus(rTenantId)
     isPaused.value = res?.data ?? res ?? false
   } catch (e) {
     console.log('暂停状态查询失败:', e?.message || e)

+ 10 - 7
forge-h5-ui/src/pages/hotel/customer/menu.vue

@@ -171,12 +171,13 @@ watch(() => orderStore.currentMealType, (newVal, oldVal) => {
   }
 })
 
-/** 加载营业状态 */
+/** 加载营业状态(归属餐厅租户) */
 async function loadBusinessHours() {
-  if (!orderStore.tenantId) return
+  const rTenantId = orderStore.restaurantTenantId || orderStore.tenantId
+  if (!rTenantId) return
   hoursLoading.value = true
   try {
-    var res = await api.hotelBusinessHours(orderStore.tenantId)
+    var res = await api.hotelBusinessHours(rTenantId)
     var bh = res?.data || res
     if (bh) orderStore.setBusinessHours(bh)
     hoursError.value = false
@@ -188,11 +189,12 @@ async function loadBusinessHours() {
   }
 }
 
-/** 加载暂停接单状态 */
+/** 加载暂停接单状态(归属餐厅租户) */
 async function loadPauseStatus() {
-  if (!orderStore.tenantId) return
+  const rTenantId = orderStore.restaurantTenantId || orderStore.tenantId
+  if (!rTenantId) return
   try {
-    var res = await api.hotelPauseStatus(orderStore.tenantId)
+    var res = await api.hotelPauseStatus(rTenantId)
     isPaused.value = res?.data ?? res ?? false
   } catch (e) {
     console.log('暂停状态查询失败:', e?.message || e)
@@ -204,7 +206,8 @@ async function loadPauseStatus() {
 async function loadDishes() {
   dishLoading.value = true
   try {
-    const params = { tenantId: orderStore.tenantId, pageNum: 1, pageSize: 100, status: '1' }
+    const rTenantId = orderStore.restaurantTenantId || orderStore.tenantId
+    const params = { tenantId: rTenantId, pageNum: 1, pageSize: 100, status: '1' }
     // 根据当前营业时段过滤菜品
     if (orderStore.currentMealType) {
       params.mealType = orderStore.currentMealType

+ 9 - 7
forge-h5-ui/src/pages/hotel/customer/order-confirm.vue

@@ -170,11 +170,12 @@ onMounted(() => {
   loadGuestInfo()
 })
 
-/** 加载暂停接单状态 */
+/** 加载暂停接单状态(归属餐厅租户) */
 async function loadPauseStatus() {
-  if (!orderStore.tenantId) return
+  const rTenantId = orderStore.restaurantTenantId || orderStore.tenantId
+  if (!rTenantId) return
   try {
-    var res = await api.hotelPauseStatus(orderStore.tenantId)
+    var res = await api.hotelPauseStatus(rTenantId)
     isPaused.value = res?.data ?? res ?? false
   } catch (e) {
     console.log('暂停状态查询失败:', e?.message || e)
@@ -212,10 +213,11 @@ async function submitOrder() {
     return
   }
 
-  // 提交前校验暂停接单状态
+  // 提交前校验暂停接单状态(归属餐厅租户)
   var paused = false
+  const rTenantId = orderStore.restaurantTenantId || orderStore.tenantId
   try {
-    var pRes = await api.hotelPauseStatus(orderStore.tenantId)
+    var pRes = await api.hotelPauseStatus(rTenantId)
     paused = pRes?.data ?? pRes ?? false
     isPaused.value = paused
   } catch (e) {
@@ -230,10 +232,10 @@ async function submitOrder() {
     return
   }
 
-  // 提交前刷新营业状态(失败不阻断,由后端兜底)
+  // 提交前刷新营业状态(归属餐厅租户,失败不阻断,由后端兜底)
   var bh = null
   try {
-    var res = await api.hotelBusinessHours(orderStore.tenantId)
+    var res = await api.hotelBusinessHours(rTenantId)
     bh = res?.data || res
     if (bh) orderStore.setBusinessHours(bh)
   } catch (e) {

+ 7 - 3
forge-h5-ui/src/pages/hotel/customer/room-confirm.vue

@@ -273,11 +273,15 @@ async function loadRoomInfo() {
 
     // 存入 Store
     orderStore.setTenantId(data.tenantId)
+    // 餐厅租户ID:菜品/分类/营业时段/暂停接单使用;未配置映射时回退到酒店租户
+    orderStore.setRestaurantTenantId(data.restaurantTenantId || data.tenantId)
     orderStore.setRoomInfo(roomInfo.value)
     orderStore.setScanParams(shortCode, sign)
 
     // 校验营业状态(非营业时段整页拦截,不给进菜单)
-    const hoursState = await loadBusinessHours(data.tenantId)
+    // 营业时段和暂停接单归属餐厅租户
+    const rTenantId = data.restaurantTenantId || data.tenantId
+    const hoursState = await loadBusinessHours(rTenantId)
     if (hoursState === 'CLOSED') {
       loading.value = false
       return
@@ -288,8 +292,8 @@ async function loadRoomInfo() {
       return
     }
 
-    // 校验暂停接单状态
-    const pauseState = await loadPauseStatus(data.tenantId)
+    // 校验暂停接单状态(归属餐厅租户)
+    const pauseState = await loadPauseStatus(rTenantId)
     if (pauseState) {
       loading.value = false
       return

+ 8 - 2
forge-h5-ui/src/pages/index/index.vue

@@ -289,6 +289,8 @@ const menuToneList = [
 
 // #ifdef H5
 const SCAN_BIND_PERM = 'hotel:qrcode:scan'
+const STAFF_ORDER_PERM = 'hotel:h5:staff-order'
+const HOTEL_ORDER_PERM = 'hotel:h5:hotel-order'
 // #endif
 
 const menuItems = computed(() => {
@@ -299,9 +301,13 @@ const menuItems = computed(() => {
   // 否则点击会 navigateTo 到不存在的页面而报错
   let prefix = []
   // #ifdef H5
-  // 员工 H5 入口(扫码绑定 + 餐厅订单)
+  // 员工 H5 入口:按权限控制显隐
   const hasScanBind = perms.includes('*:*:*') || perms.includes(SCAN_BIND_PERM)
-  prefix = hasScanBind ? [scanBindItem, staffOrderItem, hotelOrderItem] : [staffOrderItem, hotelOrderItem]
+  const hasStaffOrder = perms.includes('*:*:*') || perms.includes(STAFF_ORDER_PERM)
+  const hasHotelOrder = perms.includes('*:*:*') || perms.includes(HOTEL_ORDER_PERM)
+  if (hasScanBind) prefix.push(scanBindItem)
+  if (hasStaffOrder) prefix.push(staffOrderItem)
+  if (hasHotelOrder) prefix.push(hotelOrderItem)
   // #endif
   return [...prefix, componentDemoItem, ...sourceItems].slice(0, 7).concat(moreMenuItem)
 })

+ 11 - 3
forge-h5-ui/src/store/modules/hotel-order.js

@@ -6,8 +6,11 @@ import { defineStore } from 'pinia'
  */
 export const useHotelOrderStore = defineStore('hotel-order', {
   state: () => ({
-    /** 租户 ID(扫码验证后存入,供后续顾客端接口使用) */
+    /** 租户 ID(酒店租户,扫码验证后存入,供订单/支付/房间等接口使用) */
     tenantId: null,
+
+    /** 餐厅租户 ID(扫码验证后存入,供菜品/分类/营业时段/暂停接单等接口使用) */
+    restaurantTenantId: null,
   
     /** 房间信息(扫码验证后存入,含在住会话用于隔离不同住客) */
     roomInfo: null, // { roomId, roomNo, roomTypeName, roomStatus, stayId }
@@ -94,10 +97,15 @@ export const useHotelOrderStore = defineStore('hotel-order', {
   },
 
   actions: {
-    /** 设置租户 ID */
+    /** 设置租户 ID(酒店租户) */
     setTenantId(id) {
       this.tenantId = id
     },
+
+    /** 设置餐厅租户 ID */
+    setRestaurantTenantId(id) {
+      this.restaurantTenantId = id
+    },
     
     /** 设置扫码参数(用于后续订单确认页刷新住客信息) */
     setScanParams(shortCode, sign) {
@@ -198,6 +206,6 @@ export const useHotelOrderStore = defineStore('hotel-order', {
 
   persist: {
     key: 'hotel-order',
-    pick: ['tenantId', 'roomInfo', 'scanParams', 'hotelConfig', 'cartItems', 'orderNote', 'contactName', 'contactPhone'],
+    pick: ['tenantId', 'restaurantTenantId', 'roomInfo', 'scanParams', 'hotelConfig', 'cartItems', 'orderNote', 'contactName', 'contactPhone'],
   },
 })

Разлика између датотеке није приказан због своје велике величине
+ 2353 - 684
forge-server/db/forge_admin.sql


+ 29 - 0
forge-server/db/migration/V1.0.144__add_hotel_tenant_mapping.sql

@@ -0,0 +1,29 @@
+-- =====================================================================
+-- V1.0.144  酒店-餐厅租户映射表
+-- 业务背景:酒店点餐模块拆分为两个租户(酒店管客房/收款,餐厅管菜品/出餐),
+-- 通过此映射表让系统知道某酒店租户对应哪个餐厅租户。
+-- =====================================================================
+
+-- 1. 建表
+CREATE TABLE IF NOT EXISTS hotel_tenant_mapping (
+    id              BIGINT          NOT NULL    COMMENT '主键ID(雪花算法)',
+    tenant_id       BIGINT          NOT NULL DEFAULT 1 COMMENT '租户ID(系统级配置,固定为1,查询时用 executeIgnore 绕过租户过滤)',
+    hotel_tenant_id BIGINT          NOT NULL    COMMENT '酒店租户ID',
+    restaurant_tenant_id BIGINT     NOT NULL    COMMENT '餐厅租户ID(菜品/分类/营业时段归属)',
+    status          VARCHAR(16)     NOT NULL DEFAULT 'ACTIVE' COMMENT '状态(ACTIVE=启用, INACTIVE=停用)',
+    create_by       BIGINT          NULL        COMMENT '创建人ID',
+    create_time     DATETIME        NULL        COMMENT '创建时间',
+    update_by       BIGINT          NULL        COMMENT '更新人ID',
+    update_time     DATETIME        NULL        COMMENT '更新时间',
+    del_flag        BIGINT          NOT NULL DEFAULT 0 COMMENT '删除标记(0=未删除,删除时写入主键ID)',
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_hotel_tenant (tenant_id, hotel_tenant_id, del_flag)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='酒店-餐厅租户映射';
+
+-- 2. 插入默认映射:酒店租户8 → 餐厅租户9
+--    开发阶段默认数据,生产环境由管理员在后台配置
+INSERT INTO hotel_tenant_mapping (id, tenant_id, hotel_tenant_id, restaurant_tenant_id, status, create_by, create_time, update_by, update_time, del_flag)
+SELECT 1, 1, 8, 9, 'ACTIVE', 1, NOW(), 1, NOW(), 0
+WHERE NOT EXISTS (
+    SELECT 1 FROM hotel_tenant_mapping WHERE id = 1
+);

+ 15 - 0
forge-server/db/migration/V1.0.145__add_h5_order_menu_permissions.sql

@@ -0,0 +1,15 @@
+-- V1.0.145: 添加 H5 端餐厅订单和酒店订单菜单权限
+-- 用于控制 H5 移动端"餐厅订单"和"酒店订单"入口的访问权限,仅授权人员可见
+
+-- 查找酒店管理目录
+SET @hotel_parent_id = (SELECT id FROM sys_resource WHERE resource_name = '酒店管理' AND tenant_id = 1 AND parent_id = 0 LIMIT 1);
+
+-- 餐厅订单 H5 入口权限(按钮级,resource_type=3)
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, 'H5餐厅订单', IFNULL(@hotel_parent_id, 1), 3, 30, 'hotel:h5:staff-order', 0, 0, 1, 1, 'H5移动端餐厅订单管理入口(员工专属)', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:h5:staff-order' AND tenant_id = 1);
+
+-- 酒店订单 H5 入口权限(按钮级,resource_type=3)
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, 'H5酒店订单', IFNULL(@hotel_parent_id, 1), 3, 31, 'hotel:h5:hotel-order', 0, 0, 1, 1, 'H5移动端酒店订单管理入口(员工专属)', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:h5:hotel-order' AND tenant_id = 1);

+ 1 - 1
forge-server/forge-admin-server/src/main/resources/application-dev.yml

@@ -121,6 +121,6 @@ hotel:
     # 沙箱网关地址
     gateway-url: https://openapi-sandbox.dl.alipaydev.com/gateway.do
     # 异步回调地址(内网穿透映射到本地8580端口)
-    notify-url: http://ka6caff8.natappfree.cc/hotel/open/pay/notify
+    notify-url: http://d3af9b6b.natappfree.cc/hotel/open/pay/notify
     # 是否沙箱环境
     sandbox: true

+ 1 - 1
forge-server/forge-app-server/src/main/resources/application-dev.yml

@@ -92,7 +92,7 @@ hotel:
     # 沙箱网关地址
     gateway-url: https://openapi-sandbox.dl.alipaydev.com/gateway.do
     # 异步回调地址(内网穿透映射到本地8583端口)
-    notify-url: http://ka6caff8.natappfree.cc/hotel/open/pay/notify
+    notify-url: http://d3af9b6b.natappfree.cc/hotel/open/pay/notify
     # 支付完成后跳转回 H5 页面(支付页会自动检测已支付状态并跳转订单详情页)
     return-url: http://192.168.10.7:3001/#/pages/hotel/customer/pay
     # 是否沙箱环境

+ 44 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelTenantMapping.java

@@ -0,0 +1,44 @@
+package com.mdframe.forge.business.core.hotel.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.mdframe.forge.starter.tenant.core.TenantEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serial;
+
+/**
+ * 酒店-餐厅租户映射实体。
+ * <p>
+ * 业务背景:酒店点餐模块拆分为两个租户——酒店管客房/收款,餐厅管菜品/出餐。
+ * 此表记录酒店租户与餐厅租户的对应关系,扫码时自动解析出餐厅租户ID,
+ * 供顾客端菜品查询、下单价格校验等跨租户场景使用。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("hotel_tenant_mapping")
+public class HotelTenantMapping extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID(雪花算法) */
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 酒店租户ID(冗余,与 tenant_id 相同) */
+    private Long hotelTenantId;
+
+    /** 餐厅租户ID(菜品/分类/营业时段归属) */
+    private Long restaurantTenantId;
+
+    /** 状态(ACTIVE=启用, INACTIVE=停用) */
+    private String status;
+
+    /** 删除标记(0=未删除,删除时写入主键ID) */
+    @TableLogic(value = "0", delval = "id")
+    private Long delFlag;
+}

+ 49 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelTenantMappingMapper.java

@@ -0,0 +1,49 @@
+package com.mdframe.forge.business.core.hotel.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.mdframe.forge.business.core.hotel.domain.HotelTenantMapping;
+import com.mdframe.forge.starter.tenant.context.TenantContextHolder;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 酒店-餐厅租户映射Mapper。
+ * <p>
+ * 映射表是系统级配置(tenant_id=1),查询时必须用 executeIgnore 绕过租户过滤,
+ * 否则餐厅租户登录时拦截器会追加 tenant_id=9 导致查不到 tenant_id=1 的映射记录。
+ */
+@Mapper
+public interface HotelTenantMappingMapper extends BaseMapper<HotelTenantMapping> {
+
+    /**
+     * 根据酒店租户ID查询对应的餐厅租户ID。
+     * 映射表是系统级配置,用 executeIgnore 绕过租户过滤。
+     *
+     * @param hotelTenantId 酒店租户ID
+     * @return 餐厅租户ID(未找到时返回null)
+     */
+    default Long selectRestaurantTenantId(Long hotelTenantId) {
+        return TenantContextHolder.executeIgnore(() -> _selectRestaurantTenantId(hotelTenantId));
+    }
+
+    /**
+     * 实际查询方法(由 default 方法在 executeIgnore 上下文中调用)。
+     */
+    Long _selectRestaurantTenantId(@Param("hotelTenantId") Long hotelTenantId);
+
+    /**
+     * 根据餐厅租户ID查询映射记录(用于管理端反查酒店租户)。
+     * 映射表是系统级配置,用 executeIgnore 绕过租户过滤。
+     *
+     * @param restaurantTenantId 餐厅租户ID
+     * @return 映射记录(未找到时返回null)
+     */
+    default HotelTenantMapping selectByRestaurantTenantId(Long restaurantTenantId) {
+        return TenantContextHolder.executeIgnore(() -> _selectByRestaurantTenantId(restaurantTenantId));
+    }
+
+    /**
+     * 实际查询方法(由 default 方法在 executeIgnore 上下文中调用)。
+     */
+    HotelTenantMapping _selectByRestaurantTenantId(@Param("restaurantTenantId") Long restaurantTenantId);
+}

Разлика између датотеке није приказан због своје велике величине
+ 573 - 452
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/service/impl/HotelOrderServiceImpl.java


+ 6 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelQrCodeServiceImpl.java

@@ -20,6 +20,7 @@ import com.mdframe.forge.business.core.hotel.mapper.HotelQrCodeMapper;
 import com.mdframe.forge.business.core.hotel.mapper.HotelRoomMapper;
 import com.mdframe.forge.business.core.hotel.mapper.HotelRoomStayMapper;
 import com.mdframe.forge.business.core.hotel.mapper.HotelRoomTypeMapper;
+import com.mdframe.forge.business.core.hotel.mapper.HotelTenantMappingMapper;
 import com.mdframe.forge.business.core.hotel.service.HotelQrCodeService;
 import com.mdframe.forge.business.core.hotel.utils.QrCodeUtils;
 import com.mdframe.forge.business.core.hotel.vo.HotelQrBindLogVO;
@@ -65,6 +66,8 @@ public class HotelQrCodeServiceImpl extends ServiceImpl<HotelQrCodeMapper, Hotel
     private HotelRoomStayMapper roomStayMapper;
     @Autowired
     private HotelRoomTypeMapper roomTypeMapper;
+    @Autowired
+    private HotelTenantMappingMapper tenantMappingMapper;
 
     @org.springframework.beans.factory.annotation.Value("${hotel.qr.base-url}")
     private String qrBaseUrl;
@@ -337,6 +340,9 @@ public class HotelQrCodeServiceImpl extends ServiceImpl<HotelQrCodeMapper, Hotel
                 vo.setShortCode(qrCode.getShortCode());
                 vo.setStatus(qrCode.getStatus());
                 vo.setTenantId(tenantId);
+                // 查询租户映射:获取对应的餐厅租户ID
+                Long restaurantTenantId = tenantMappingMapper.selectRestaurantTenantId(tenantId);
+                vo.setRestaurantTenantId(restaurantTenantId != null ? restaurantTenantId : tenantId);
                 vo.setBound(qrCode.getRoomId() != null);
 
                 if (qrCode.getRoomId() != null) {

+ 4 - 1
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/QrCodeScanVO.java

@@ -17,9 +17,12 @@ public class QrCodeScanVO {
     /** 状态 */
     private String status;
 
-    /** 租户ID(供顾客端后续接口使用) */
+    /** 租户ID(酒店租户,供订单/支付/房间等接口使用) */
     private Long tenantId;
 
+    /** 餐厅租户ID(供菜品/分类/营业时段/暂停接单等接口使用,来自租户映射表) */
+    private Long restaurantTenantId;
+
     /** 是否已绑定房间 */
     private Boolean bound;
 

+ 26 - 0
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelTenantMappingMapper.xml

@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.mdframe.forge.business.core.hotel.mapper.HotelTenantMappingMapper">
+
+    <!-- 根据酒店租户ID查询餐厅租户ID(由 default 方法在 executeIgnore 上下文中调用) -->
+    <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>
+
+    <!-- 根据餐厅租户ID反查映射记录(由 default 方法在 executeIgnore 上下文中调用) -->
+    <select id="_selectByRestaurantTenantId" resultType="com.mdframe.forge.business.core.hotel.domain.HotelTenantMapping">
+        SELECT id, tenant_id, hotel_tenant_id, restaurant_tenant_id, status,
+               create_by, create_time, update_by, update_time, del_flag
+        FROM hotel_tenant_mapping
+        WHERE restaurant_tenant_id = #{restaurantTenantId}
+          AND status = 'ACTIVE'
+          AND del_flag = 0
+        LIMIT 1
+    </select>
+
+</mapper>

+ 323 - 0
forge-server/forge-business/forge-hotel/跨租户处理数据文档.md

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