Sfoglia il codice sorgente

移动端添加点餐时间限制,pc端功能优化,订单信息功能处理

徐滕 2 settimane fa
parent
commit
710d3e03f8
52 ha cambiato i file con 1895 aggiunte e 399 eliminazioni
  1. 5 0
      .gitignore
  2. 12 0
      forge-admin-ui/src/api/hotel.js
  3. 104 21
      forge-admin-ui/src/views/hotel/order.vue
  4. 7 0
      forge-h5-ui/src/api/index.js
  5. 106 6
      forge-h5-ui/src/pages/hotel/customer/cart.vue
  6. 76 5
      forge-h5-ui/src/pages/hotel/customer/dish-detail.vue
  7. 72 6
      forge-h5-ui/src/pages/hotel/customer/menu.vue
  8. 91 7
      forge-h5-ui/src/pages/hotel/customer/order-confirm.vue
  9. 101 13
      forge-h5-ui/src/pages/hotel/customer/order-status.vue
  10. 57 6
      forge-h5-ui/src/pages/hotel/customer/orders.vue
  11. 53 0
      forge-h5-ui/src/pages/hotel/customer/room-confirm.vue
  12. 28 0
      forge-server/db/migration/V1.0.109__add_hotel_order_item_spec_addition_ids.sql
  13. 26 0
      forge-server/db/migration/V1.0.110__add_hotel_config_table.sql
  14. 108 0
      forge-server/db/migration/V1.0.111__fix_hotel_order_cancel_semantics.sql
  15. 31 0
      forge-server/db/migration/V1.0.112__seed_hotel_config_delivery_fee.sql
  16. 1 1
      forge-server/forge-admin-server/src/main/resources/application-dev.yml
  17. 37 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/constant/HotelConfigKeys.java
  18. 124 11
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/open/HotelCustomerController.java
  19. 37 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelConfig.java
  20. 56 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelConfigMapper.java
  21. 27 2
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/constant/HotelOrderConstants.java
  22. 35 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/controller/HotelOrderController.java
  23. 6 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/domain/HotelOrderItem.java
  24. 4 4
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/dto/OrderCreateDTO.java
  25. 216 58
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/service/impl/HotelOrderServiceImpl.java
  26. 226 24
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/impl/HotelPayServiceImpl.java
  27. 30 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelOrderPauseService.java
  28. 82 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelOrderPauseServiceImpl.java
  29. 31 0
      forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelConfigMapper.xml
  30. 8 3
      forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/order/HotelOrderItemMapper.xml
  31. 6 6
      forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/order/HotelOrderMapper.xml
  32. 92 29
      forge-server/forge-business/forge-hotel/酒店模块需求缺口清单.md
  33. BIN
      var/tmp/AgentLoader.class
  34. 0 8
      var/tmp/AgentLoader.java
  35. BIN
      var/tmp/DiagAgent.class
  36. 0 56
      var/tmp/DiagAgent.java
  37. BIN
      var/tmp/DiagAgent2.class
  38. 0 44
      var/tmp/DiagAgent2.java
  39. BIN
      var/tmp/JmxProbe.class
  40. 0 31
      var/tmp/JmxProbe.java
  41. BIN
      var/tmp/JmxProbe2.class
  42. 0 23
      var/tmp/JmxProbe2.java
  43. 0 1
      var/tmp/check_migration.sql
  44. BIN
      var/tmp/diag-agent.jar
  45. BIN
      var/tmp/diag-agent2.jar
  46. 0 1
      var/tmp/manifest.txt
  47. 0 2
      var/tmp/manifest2.txt
  48. 0 1
      var/tmp/repair_migration.sql
  49. 0 29
      var/tmp/scan_fpc1_keyids.sql
  50. 0 1
      var/tmp/scan_result.txt
  51. BIN
      var/tmp/template-modal-verify.png
  52. BIN
      var/tmp/template-preview-verify.png

+ 5 - 0
.gitignore

@@ -56,6 +56,11 @@ yarn-debug.log*
 yarn-error.log*
 **/*.log
 
+# 运行时产物,一律不进版本库:
+#   var/logs  服务日志(含将来可能出现的 .gz 滚动日志、heapdump 等非 .log 后缀文件)
+#   var/tmp   排查诊断临时文件(SDK 反编译输出、Java Agent、一次性验证脚本与截图等)
+var/
+
 tests/**/coverage/
 tests/e2e/reports
 selenium-debug.log

+ 12 - 0
forge-admin-ui/src/api/hotel.js

@@ -407,3 +407,15 @@ export function rejectRefund(id, reason) {
 export function orderRefund(id) {
   return request.post('/hotel/order/refund', null, { params: { id } })
 }
+
+// ==================== 暂停接单管理 ====================
+
+/** 查询当前暂停接单状态 */
+export function getPauseStatus() {
+  return request.get('/hotel/order/pause/status')
+}
+
+/** 切换暂停接单状态 */
+export function togglePause() {
+  return request.post('/hotel/order/pause/toggle')
+}

+ 104 - 21
forge-admin-ui/src/views/hotel/order.vue

@@ -41,6 +41,16 @@
             <span class="dashboard-value success">¥{{ dashboard.todayRevenue }}</span>
             <span class="dashboard-label">今日营收</span>
           </div>
+          <div class="dashboard-divider" />
+          <div class="dashboard-item pause-control">
+            <NButton
+              :type="isPaused ? 'warning' : 'success'"
+              size="small"
+              @click="handleTogglePause"
+            >
+              {{ isPaused ? '恢复接单' : '暂停接单' }}
+            </NButton>
+          </div>
         </div>
       </template>
     </AiCrudPage>
@@ -192,6 +202,7 @@ import {
   approveRefund,
   completeOrder,
   deliverOrder,
+  getPauseStatus,
   getOrderDashboard,
   getOrderDetail,
   getOrderPage,
@@ -201,6 +212,7 @@ import {
   readyOrder,
   rejectOrder,
   rejectRefund,
+  togglePause,
 } from '@/api/hotel'
 import { AiCrudPage } from '@/components/ai-form'
 import DictTag from '@/components/DictTag.vue'
@@ -215,6 +227,49 @@ const { dict } = useDict('hotel_order_status', 'hotel_pay_method', 'hotel_refund
 
 const crudRef = ref(null)
 
+// ==================== 暂停接单状态 ====================
+const isPaused = ref(false)
+
+async function loadPauseStatus() {
+  try {
+    const res = await getPauseStatus()
+    if (res.code === 200) {
+      isPaused.value = res.data
+    }
+  }
+  catch (err) {
+    console.error('[订单页面] 加载暂停状态失败:', err)
+  }
+}
+
+async function handleTogglePause() {
+  const willPause = !isPaused.value
+  const title = willPause ? '暂停接单' : '恢复接单'
+  const content = willPause
+    ? '暂停后顾客扫码将无法下单,确定要暂停接单吗?'
+    : '恢复后顾客可以正常扫码下单,确定要恢复接单吗?'
+
+  dialog.warning({
+    title,
+    content,
+    positiveText: '确定',
+    negativeText: '取消',
+    onPositiveClick: async () => {
+      try {
+        const res = await togglePause()
+        if (res.code === 200) {
+          isPaused.value = res.data
+          message.success(isPaused.value ? '已暂停接单' : '已恢复接单')
+        }
+      }
+      catch (err) {
+        message.error('操作失败')
+        console.error('[订单页面] 切换暂停状态失败:', err)
+      }
+    },
+  })
+}
+
 // ==================== SSE 实时通知 ====================
 const { connected, connect, disconnect, playSound } = useOrderNotification({
   onNewOrder: () => {
@@ -240,6 +295,7 @@ function handleHotelNotification(e) {
 
 onMounted(() => {
   loadDashboard()
+  loadPauseStatus()
   connect()
   window.addEventListener('hotel-notification', handleHotelNotification)
 })
@@ -451,12 +507,16 @@ const tableColumns = computed(() => [
         }, '驳回'))
       }
 
-      // 前台退单(除已完成、已退单、已拒单外)
-      if (s !== 6 && s !== 7 && s !== 9) {
+      // 前台退单/取消:终态订单一律不给入口
+      // 6已完成 7已拒单 9已退单 10超时取消 11已取消。
+      // 10/11 之前被漏掉:点进去后端会抛「当前订单状态不支持退单」,
+      // 更严重的是已取消订单一旦被改写成已退单,等于凭空多出一笔“已退款”账
+      if (s !== 6 && s !== 7 && s !== 9 && s !== 10 && s !== 11) {
         actions.push(h('a', {
           class: 'text-warning cursor-pointer hover:opacity-80',
           onClick: () => handleAction('hotelRefund', row),
-        }, '退单'))
+          // 待支付订单无资金流转,后端置 11已取消 而不是 9已退单,文案跟着区分
+        }, s === 0 ? '取消订单' : '退单'))
       }
 
       // 手动退款:已支付未退款的异常场景(退房竞态取消/退款失败重试)
@@ -500,8 +560,14 @@ function handleAction(action, row) {
       break
     case 'reject':
       openReasonModal('拒单', async (reason) => {
-        await rejectOrder(row.id, reason)
-        message.success('已拒单')
+        try {
+          await rejectOrder(row.id, reason)
+          message.success('已拒单')
+        }
+        catch {
+          // 拒单需先退款成功,失败原因已由请求拦截器统一弹窗提示,
+          // 这里只保证列表刷新,让操作员看到订单仍为待接单、退款状态可重试
+        }
         crudRef.value?.refresh()
         loadDashboard()
       })
@@ -562,14 +628,22 @@ function handleAction(action, row) {
         },
       })
       break
-    case 'hotelRefund':
-      openReasonModal('前台退单', async (reason) => {
-        await hotelRefund(row.id, reason)
-        message.success('退单成功')
+    case 'hotelRefund': {
+      // 待支付订单走的是「取消」而不是「退单」:无资金流转,后端置 11已取消
+      const isCancel = row.status === 0
+      openReasonModal(isCancel ? '取消订单' : '前台退单', async (reason) => {
+        try {
+          await hotelRefund(row.id, reason)
+          message.success(isCancel ? '订单已取消' : '退单成功')
+        }
+        catch {
+          // 退款失败时订单不会变为已退单,错误详情已由请求拦截器统一弹窗展示
+        }
         crudRef.value?.refresh()
         loadDashboard()
       })
       break
+    }
     case 'approveRefund':
       dialog.warning({
         title: '同意退单',
@@ -577,8 +651,13 @@ function handleAction(action, row) {
         positiveText: '同意退单',
         negativeText: '取消',
         onPositiveClick: async () => {
-          await approveRefund(row.id)
-          message.success('已同意退单')
+          try {
+            await approveRefund(row.id)
+            message.success('已同意退单')
+          }
+          catch {
+            // 退款失败时订单保持“退单待审核”,错误详情已由请求拦截器统一弹窗展示
+          }
           crudRef.value?.refresh()
           loadDashboard()
         },
@@ -586,8 +665,13 @@ function handleAction(action, row) {
       break
     case 'rejectRefund':
       openReasonModal('驳回退单', async (reason) => {
-        await rejectRefund(row.id, reason)
-        message.success('已驳回退单申请')
+        try {
+          await rejectRefund(row.id, reason)
+          message.success('已驳回退单申请')
+        }
+        catch {
+          // 错误详情已由请求拦截器统一弹窗展示
+        }
         crudRef.value?.refresh()
         loadDashboard()
       })
@@ -600,16 +684,11 @@ function handleAction(action, row) {
         negativeText: '取消',
         onPositiveClick: async () => {
           try {
-            const res = await orderRefund(row.id)
-            if (res.code === 200) {
-              message.success('退款成功')
-            }
-            else {
-              message.error(res.msg || '退款失败,可稍后重试')
-            }
+            await orderRefund(row.id)
+            message.success('退款成功')
           }
           catch {
-            message.error('退款失败,订单已标记为退款失败状态,可稍后重试')
+            // 失败原因已由拦截器弹窗展示,订单 refund_status 置为可重试
           }
           crudRef.value?.refresh()
           loadDashboard()
@@ -732,6 +811,10 @@ async function openDetail(row) {
   margin: 0 4px;
 }
 
+.pause-control {
+  min-width: auto;
+}
+
 /* ==================== SSE连接指示器 ==================== */
 .sse-indicator {
   display: flex;

+ 7 - 0
forge-h5-ui/src/api/index.js

@@ -337,6 +337,13 @@ export default {
     params: { tenantId },
     skipAuthRefresh: true,
   }),
+  /** 顾客端 - 查询暂停接单状态(免登录) */
+  hotelPauseStatus: tenantId => request({
+    url: '/hotel/open/customer/pauseStatus',
+    method: 'get',
+    params: { tenantId },
+    skipAuthRefresh: true,
+  }),
 
   // ==================== 酒店支付 ====================
   /** 发起支付 */

+ 106 - 6
forge-h5-ui/src/pages/hotel/customer/cart.vue

@@ -1,5 +1,11 @@
 <template>
   <view class="hotel-customer-theme cart-page">
+    <!-- 暂停接单提示横幅 -->
+    <view v-if="isPaused" class="c-pause-banner">
+      <text class="c-pause-icon">⏸️</text>
+      <text class="c-pause-text">餐厅已暂停接单,暂时无法结算</text>
+    </view>
+
     <!-- 送至信息 -->
     <view class="c-delivery-info">
       <text>🏨 送至:{{ roomNo }} {{ roomTypeName }}</text>
@@ -15,7 +21,13 @@
     <view v-else class="c-list">
       <view v-for="(item, index) in cartItems" :key="index" class="c-item">
         <view class="c-item-img">
-          <image v-if="item.image" :src="getImageUrl(item)" mode="aspectFill" class="c-item-img-real" />
+          <image
+            v-if="showImage(item)"
+            :src="getImageUrl(item)"
+            mode="aspectFill"
+            class="c-item-img-real"
+            @error="onImageError(item)"
+          />
           <text v-else class="c-item-img-placeholder">🍽️</text>
         </view>
         <view class="c-item-info">
@@ -64,7 +76,7 @@
       </view>
 
       <!-- 结算按钮 -->
-      <view class="c-checkout-btn" @click="goCheckout">去结算 ¥{{ grandTotal }}</view>
+      <view class="c-checkout-btn" :class="{ 'c-checkout-btn--disabled': isPaused }" @click="goCheckout">{{ isPaused ? '餐厅已暂停接单' : '去结算 ¥' + grandTotal }}</view>
     </view>
 
     <!-- 底部 TabBar -->
@@ -73,12 +85,20 @@
 </template>
 
 <script setup>
-import { computed } from 'vue'
+import { computed, onMounted, ref } from 'vue'
 import { useHotelOrderStore } from '@/store'
 import HotelTabBar from '@/components/hotel/HotelTabBar.vue'
+import api from '@/api'
+import { getFileDownloadUrl } from '@/utils/file'
 
 const orderStore = useHotelOrderStore()
 
+// 加载失败的菜品图片,回退到占位图标
+const failedImages = ref(new Set())
+
+// 暂停接单状态
+const isPaused = ref(false)
+
 const roomNo = computed(() => orderStore.roomInfo?.roomNo || '')
 const roomTypeName = computed(() => orderStore.roomInfo?.roomTypeName || '')
 const cartItems = computed(() => orderStore.cartItems)
@@ -90,10 +110,23 @@ const orderNote = computed({
   set: v => orderStore.setOrderNote(v),
 })
 
+/**
+ * 购物车里存的 image 是 fileId,不是可直接访问的地址,
+ * 必须经 getFileDownloadUrl 转成 /api/file/download/{fileId},否则图片加载不出来。
+ */
 function getImageUrl(item) {
   if (!item.image) return ''
-  if (item.image.startsWith('http')) return item.image
-  return item.image
+  return getFileDownloadUrl(item.image)
+}
+
+/** 图片是否可用(有值且未加载失败) */
+function showImage(item) {
+  return !!item.image && !failedImages.value.has(String(item.image))
+}
+
+/** 图片加载失败,标记后展示占位图标 */
+function onImageError(item) {
+  failedImages.value.add(String(item.image))
 }
 
 function formatSpecs(item) {
@@ -122,8 +155,28 @@ function goCheckout() {
     uni.showToast({ title: '购物车是空的', icon: 'none' })
     return
   }
+  if (isPaused.value) {
+    uni.showToast({ title: '餐厅已暂停接单,无法结算', icon: 'none' })
+    return
+  }
   uni.navigateTo({ url: '/pages/hotel/customer/order-confirm' })
 }
+
+onMounted(() => {
+  loadPauseStatus()
+})
+
+/** 加载暂停接单状态 */
+async function loadPauseStatus() {
+  if (!orderStore.tenantId) return
+  try {
+    var res = await api.hotelPauseStatus(orderStore.tenantId)
+    isPaused.value = res?.data ?? res ?? false
+  } catch (e) {
+    console.log('暂停状态查询失败:', e?.message || e)
+    isPaused.value = false
+  }
+}
 </script>
 
 <style lang="scss" scoped>
@@ -296,15 +349,40 @@ function goCheckout() {
 }
 
 .c-note-input {
+  display: block;
   width: 100%;
+  height: 140rpx;
+  /* H5 无全局 border-box 重置,width:100% + padding + border 会把备注框撑出卡片右侧 */
+  box-sizing: border-box;
   padding: 16rpx 20rpx;
   border: 1rpx solid var(--h-bdr, #eee);
   border-radius: 12rpx;
   font-size: 26rpx;
+  line-height: 40rpx;
   resize: none;
-  height: 100rpx;
+  overflow: hidden;
   background: var(--h-bg, #F7F7F7);
   color: var(--h-txt, #1A1A1A);
+
+  /* uni-app H5 的 textarea 会渲染成 uni-textarea > wrapper > textarea,
+     内层需要撑满外框,否则可输入区域和灰底高度不一致,文字看着被截断 */
+  :deep(.uni-textarea-wrapper),
+  :deep(.uni-textarea-textarea) {
+    width: 100%;
+    height: 100%;
+    box-sizing: border-box;
+    font-size: 26rpx;
+    line-height: 40rpx;
+    border: none;
+    background: transparent;
+    resize: none;
+  }
+
+  :deep(.uni-textarea-placeholder) {
+    font-size: 26rpx;
+    line-height: 40rpx;
+    color: var(--h-txt-light, #999);
+  }
 }
 
 /* 费用汇总 */
@@ -350,4 +428,26 @@ function goCheckout() {
 .c-checkout-btn:active {
   opacity: 0.85;
 }
+
+.c-checkout-btn--disabled {
+  opacity: 0.5;
+  background: var(--h-txt-sub, #999);
+}
+
+/* 暂停接单横幅 */
+.c-pause-banner {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8rpx;
+  padding: 20rpx 28rpx;
+  background: #FEF3C7;
+  color: #92400E;
+  font-size: 26rpx;
+  font-weight: 600;
+}
+
+.c-pause-icon {
+  font-size: 28rpx;
+}
 </style>

+ 76 - 5
forge-h5-ui/src/pages/hotel/customer/dish-detail.vue

@@ -1,5 +1,11 @@
 <template>
   <view class="hotel-customer-theme dd-page">
+    <!-- 暂停接单提示横幅 -->
+    <view v-if="isPaused" class="dd-pause-banner">
+      <text class="dd-pause-icon">⏸️</text>
+      <text class="dd-pause-text">餐厅已暂停接单,请稍后再试</text>
+    </view>
+
     <!-- 大图轮播 -->
     <view class="dd-hero">
       <swiper 
@@ -116,8 +122,8 @@
         <text class="dd-qty-num">{{ quantity }}</text>
         <view class="dd-qty-btn dd-qty-btn--add" @click="changeQty(1)">+</view>
       </view>
-      <view class="dd-add-btn" @click="addToCart">
-        加入购物车 ¥{{ totalPrice }}
+      <view class="dd-add-btn" :class="{ 'dd-add-btn--disabled': isPaused }" @click="addToCart">
+        {{ isPaused ? '暂停接单' : '加入购物车 ¥' + totalPrice }}
       </view>
     </view>
   </view>
@@ -137,6 +143,7 @@ const selectedAdditions = ref([]) // [additionId, ...]
 const quantity = ref(1)
 const itemNote = ref('')
 const currentImageIndex = ref(0) // 当前轮播图片索引
+const isPaused = ref(false) // 暂停接单状态
 
 let dishId = ''
 
@@ -195,6 +202,7 @@ onMounted(async () => {
   }
 
   await loadDishDetail()
+  loadPauseStatus()
 })
 
 async function loadDishDetail() {
@@ -219,10 +227,22 @@ async function loadDishDetail() {
 
 function getImageUrl(d) {
   if (!d.image) return ''
-  if (d.image.startsWith('http')) return d.image
+  // getFileDownloadUrl 已兼容 http/data/blob 直链与 fileId,无需再判前缀
   return getFileDownloadUrl(d.image)
 }
 
+/** 加载暂停接单状态 */
+async function loadPauseStatus() {
+  if (!orderStore.tenantId) return
+  try {
+    var res = await api.hotelPauseStatus(orderStore.tenantId)
+    isPaused.value = res?.data ?? res ?? false
+  } catch (e) {
+    console.log('暂停状态查询失败:', e?.message || e)
+    isPaused.value = false
+  }
+}
+
 function goBack() {
   uni.navigateBack()
 }
@@ -283,6 +303,10 @@ const totalPrice = computed(() => {
 
 /** 加入购物车 */
 function addToCart() {
+  if (isPaused.value) {
+    uni.showToast({ title: '餐厅已暂停接单', icon: 'none' })
+    return
+  }
   const specs = []
   const additions = []
 
@@ -294,6 +318,7 @@ function addToCart() {
         const opt = group.options.find(o => o.id === optId)
         if (opt) {
           specs.push({
+            optionId: opt.id,
             groupName: group.groupName,
             optionName: opt.optionName,
             priceExtra: Number(opt.priceExtra) || 0,
@@ -308,7 +333,7 @@ function addToCart() {
     selectedAdditions.value.forEach(addId => {
       const add = dish.value.additions.find(a => a.id === addId)
       if (add) {
-        additions.push({ name: add.name, extraPrice: Number(add.extraPrice) || 0 })
+        additions.push({ id: add.id, name: add.name, extraPrice: Number(add.extraPrice) || 0 })
       }
     })
   }
@@ -524,15 +549,39 @@ function addToCart() {
 
 /* 备注 */
 .dd-note {
+  display: block;
   width: 100%;
+  height: 140rpx;
+  /* H5 无全局 border-box 重置,width:100% + padding + border 会把备注框撑出卡片右侧 */
+  box-sizing: border-box;
   padding: 20rpx 24rpx;
   border: 1rpx solid var(--h-bdr, #eee);
   border-radius: 16rpx;
   font-size: 26rpx;
+  line-height: 40rpx;
   resize: none;
-  height: 120rpx;
+  overflow: hidden;
   background: #fff;
   color: var(--h-txt, #1A1A1A);
+
+  /* uni-app H5 的 textarea 会渲染成 uni-textarea > wrapper > textarea,内层需撑满外框 */
+  :deep(.uni-textarea-wrapper),
+  :deep(.uni-textarea-textarea) {
+    width: 100%;
+    height: 100%;
+    box-sizing: border-box;
+    font-size: 26rpx;
+    line-height: 40rpx;
+    border: none;
+    background: transparent;
+    resize: none;
+  }
+
+  :deep(.uni-textarea-placeholder) {
+    font-size: 26rpx;
+    line-height: 40rpx;
+    color: var(--h-txt-light, #999);
+  }
 }
 
 /* 底部操作栏 */
@@ -599,4 +648,26 @@ function addToCart() {
 .dd-add-btn:active {
   opacity: 0.85;
 }
+
+.dd-add-btn--disabled {
+  opacity: 0.5;
+  background: var(--h-txt-sub, #999);
+}
+
+/* 暂停接单横幅 */
+.dd-pause-banner {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8rpx;
+  padding: 20rpx 28rpx;
+  background: #FEF3C7;
+  color: #92400E;
+  font-size: 26rpx;
+  font-weight: 600;
+}
+
+.dd-pause-icon {
+  font-size: 28rpx;
+}
 </style>

+ 72 - 6
forge-h5-ui/src/pages/hotel/customer/menu.vue

@@ -1,5 +1,11 @@
 <template>
   <view class="hotel-customer-theme menu-page">
+    <!-- 暂停接单提示横幅 -->
+    <view v-if="isPaused" class="m-pause-banner">
+      <text class="m-pause-icon">⏸️</text>
+      <text class="m-pause-text">餐厅已暂停接单,请稍后再试</text>
+    </view>
+
     <!-- 送达信息栏 -->
     <view class="m-delivery-bar">
       <text class="m-delivery-room">🏨 {{ roomNo }}房间</text>
@@ -97,9 +103,9 @@
               >
                 <view class="m-qty-btn" @click.stop="onMinus(dish)">−</view>
                 <text class="m-qty-num">{{ getCartQty(dish.id) }}</text>
-                <view class="m-qty-btn m-qty-btn--add" @click.stop="onQuickAdd(dish)">+</view>
+                <view class="m-qty-btn m-qty-btn--add" :class="{ 'm-qty-btn--disabled': isPaused }" @click.stop="onQuickAdd(dish)">+</view>
               </view>
-              <view v-else class="m-dish-add-btn" @click.stop="goToDetail(dish)">+ 加购</view>
+              <view v-else class="m-dish-add-btn" :class="{ 'm-dish-add-btn--disabled': isPaused }" @click.stop="goToDetail(dish)">{{ isPaused ? '暂停接单' : '+ 加购' }}</view>
             </view>
           </view>
         </view>
@@ -110,14 +116,14 @@
 
     <!-- 购物车浮层 -->
     <view class="m-cart-float" v-if="cartCount > 0" @click="goToCart">
-      <view class="m-cart-inner">
+      <view class="m-cart-inner" :class="{ 'm-cart-inner--disabled': isPaused }">
         <view class="m-cart-icon-wrap">
           <text>🛒</text>
           <view class="m-cart-badge">{{ cartCount }}</view>
         </view>
         <text class="m-cart-count">已选 {{ cartCount }} 件</text>
         <text class="m-cart-total">¥{{ cartTotal }}</text>
-        <text class="m-cart-go">去结算 →</text>
+        <text class="m-cart-go">{{ isPaused ? '暂停中' : '去结算 →' }}</text>
       </view>
     </view>
 
@@ -156,6 +162,9 @@ const hoursError = ref(false)
 const isOpenNow = computed(() => orderStore.isOpenNow)
 const businessTip = computed(() => orderStore.businessHoursTip)
 
+// 暂停接单状态
+const isPaused = ref(false)
+
 // 购物车
 const cartCount = computed(() => orderStore.cartCount)
 const cartTotal = computed(() => orderStore.cartTotal)
@@ -173,6 +182,7 @@ onMounted(() => {
   loadCategories()
   loadDishes()
   loadBusinessHours()
+  loadPauseStatus()
 })
 
 /** 加载分类 */
@@ -202,6 +212,18 @@ async function loadBusinessHours() {
   }
 }
 
+/** 加载暂停接单状态 */
+async function loadPauseStatus() {
+  if (!orderStore.tenantId) return
+  try {
+    var res = await api.hotelPauseStatus(orderStore.tenantId)
+    isPaused.value = res?.data ?? res ?? false
+  } catch (e) {
+    console.log('暂停状态查询失败:', e?.message || e)
+    isPaused.value = false
+  }
+}
+
 /** 加载菜品 */
 async function loadDishes() {
   dishLoading.value = true
@@ -232,10 +254,9 @@ function searchDish() {
   loadDishes()
 }
 
-/** 获取图片 URL */
+/** 获取图片 URL(dish.image 存的是 fileId,需转下载地址) */
 function getImageUrl(dish) {
   if (!dish.image) return ''
-  if (dish.image.startsWith('http')) return dish.image
   return getFileDownloadUrl(dish.image)
 }
 
@@ -246,6 +267,10 @@ function getCartQty(dishId) {
 
 /** 快速加购(无规格,直接加入购物车) */
 function onQuickAdd(dish) {
+  if (isPaused.value) {
+    uni.showToast({ title: '餐厅已暂停接单', icon: 'none' })
+    return
+  }
   const item = buildCartItem(dish, [], [], 1)
   orderStore.addToCartItem(item)
 }
@@ -289,6 +314,10 @@ function buildCartItem(dish, specs, additions, qty) {
 /** 跳转菜品详情 */
 function goToDetail(dish) {
   if (dish.soldOut === 1 || dish.status !== 'ON_SALE') return
+  if (isPaused.value) {
+    uni.showToast({ title: '餐厅已暂停接单', icon: 'none' })
+    return
+  }
   uni.navigateTo({
     url: `/pages/hotel/customer/dish-detail?id=${dish.id}`,
   })
@@ -296,6 +325,10 @@ function goToDetail(dish) {
 
 /** 跳转购物车 */
 function goToCart() {
+  if (isPaused.value) {
+    uni.showToast({ title: '餐厅已暂停接单,无法结算', icon: 'none' })
+    return
+  }
   uni.redirectTo({ url: '/pages/hotel/customer/cart' })
 }
 </script>
@@ -677,4 +710,37 @@ function goToCart() {
   font-weight: 600;
   opacity: 0.9;
 }
+
+/* 暂停接单横幅 */
+.m-pause-banner {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8rpx;
+  padding: 20rpx 28rpx;
+  background: #FEF3C7;
+  color: #92400E;
+  font-size: 26rpx;
+  font-weight: 600;
+}
+
+.m-pause-icon {
+  font-size: 28rpx;
+}
+
+/* 暂停时禁用加购按钮 */
+.m-qty-btn--disabled {
+  opacity: 0.4;
+  pointer-events: none;
+}
+
+.m-dish-add-btn--disabled {
+  background: var(--h-txt-sub, #999);
+  opacity: 0.6;
+}
+
+.m-cart-inner--disabled {
+  opacity: 0.5;
+  background: var(--h-txt-sub, #999);
+}
 </style>

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

@@ -1,9 +1,17 @@
 <template>
   <view class="hotel-customer-theme oc-page">
+    <!-- 暂停接单提示横幅 -->
+    <view v-if="isPaused" class="oc-pause-banner">
+      <text class="oc-pause-icon">⏸️</text>
+      <text class="oc-pause-text">餐厅已暂停接单,暂时无法下单</text>
+    </view>
+
     <!-- 顶部返回栏 -->
     <view class="oc-nav">
       <view class="oc-nav-back" @click="goBack">
-        <text class="oc-nav-arrow">‹</text>
+        <view class="oc-nav-back-circle">
+          <text class="oc-nav-arrow">‹</text>
+        </view>
         <text class="oc-nav-title">确认订单</text>
       </view>
     </view>
@@ -72,7 +80,7 @@
     </view>
 
     <!-- 提交按钮 -->
-    <view class="oc-submit-btn" @click="submitOrder">确认支付 ¥{{ grandTotal }}</view>
+    <view class="oc-submit-btn" :class="{ 'oc-submit-btn--disabled': isPaused }" @click="submitOrder">{{ isPaused ? '餐厅已暂停接单' : '确认支付 ¥' + grandTotal }}</view>
   </view>
 </template>
 
@@ -94,6 +102,7 @@ const contactName = ref(orderStore.contactName || '')
 const contactPhone = ref(orderStore.contactPhone || '')
 const deliveryTimeType = ref('ASAP')
 const submitting = ref(false)
+const isPaused = ref(false)
 
 const timeOptions = [
   { label: '立即送达', value: 'ASAP' },
@@ -156,6 +165,22 @@ function onCustomTimeChange(e) {
   customDeliveryTime.value = selected
 }
 
+onMounted(() => {
+  loadPauseStatus()
+})
+
+/** 加载暂停接单状态 */
+async function loadPauseStatus() {
+  if (!orderStore.tenantId) return
+  try {
+    var res = await api.hotelPauseStatus(orderStore.tenantId)
+    isPaused.value = res?.data ?? res ?? false
+  } catch (e) {
+    console.log('暂停状态查询失败:', e?.message || e)
+    isPaused.value = false
+  }
+}
+
 async function submitOrder() {
   if (!contactName.value.trim()) {
     uni.showToast({ title: '请输入联系人姓名', icon: 'none' })
@@ -166,6 +191,24 @@ async function submitOrder() {
     return
   }
 
+  // 提交前校验暂停接单状态
+  var paused = false
+  try {
+    var pRes = await api.hotelPauseStatus(orderStore.tenantId)
+    paused = pRes?.data ?? pRes ?? false
+    isPaused.value = paused
+  } catch (e) {
+    console.log('暂停状态查询失败,交由后端校验:', e?.message || e)
+  }
+  if (paused) {
+    uni.showModal({
+      title: '暂停接单',
+      content: '餐厅已暂停接单,请稍后再试',
+      showCancel: false,
+    })
+    return
+  }
+
   // 提交前刷新营业状态(失败不阻断,由后端兜底)
   var bh = null
   try {
@@ -202,6 +245,8 @@ async function submitOrder() {
         dishName: item.dishName,
         specDesc: item.specs?.map(s => s.optionName).join('·') || '',
         additionsDesc: item.additions?.map(a => a.name).join('·') || '',
+        specOptionIds: item.specs?.map(s => s.optionId).filter(Boolean).join(',') || '',
+        additionIds: item.additions?.map(a => a.id).filter(Boolean).join(',') || '',
         quantity: item.quantity,
         unitPrice: item.unitPrice,
         subtotal: item.unitPrice * item.quantity,
@@ -237,25 +282,42 @@ async function submitOrder() {
 .oc-nav {
   display: flex;
   align-items: center;
-  padding: 20rpx 28rpx 16rpx;
+  padding: 16rpx 0 20rpx;
   background: var(--h-bg, #F7F7F7);
 }
 
 .oc-nav-back {
   display: flex;
   align-items: center;
-  gap: 8rpx;
+  gap: 12rpx;
+}
+
+.oc-nav-back-circle {
+  width: 60rpx;
+  height: 60rpx;
+  border-radius: 50%;
+  background: #fff;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.oc-nav-back-circle:active {
+  background: #f5f5f5;
+  transform: scale(0.95);
 }
 
 .oc-nav-arrow {
-  font-size: 48rpx;
-  font-weight: 300;
+  font-size: 40rpx;
+  font-weight: 400;
   color: var(--h-txt, #1A1A1A);
   line-height: 1;
+  margin-top: -4rpx;
 }
 
 .oc-nav-title {
-  font-size: 32rpx;
+  font-size: 30rpx;
   font-weight: 600;
   color: var(--h-txt, #1A1A1A);
 }
@@ -420,4 +482,26 @@ async function submitOrder() {
 .oc-submit-btn:active {
   opacity: 0.85;
 }
+
+.oc-submit-btn--disabled {
+  opacity: 0.5;
+  background: var(--h-txt-sub, #999);
+}
+
+/* 暂停接单横幅 */
+.oc-pause-banner {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8rpx;
+  padding: 20rpx 28rpx;
+  background: #FEF3C7;
+  color: #92400E;
+  font-size: 26rpx;
+  font-weight: 600;
+}
+
+.oc-pause-icon {
+  font-size: 28rpx;
+}
 </style>

+ 101 - 13
forge-h5-ui/src/pages/hotel/customer/order-status.vue

@@ -6,6 +6,16 @@
     </view>
 
     <template v-else-if="order">
+      <!-- 顶部返回栏 -->
+      <view class="os-nav">
+        <view class="os-nav-back" @click="goBack">
+          <view class="os-nav-back-circle">
+            <text class="os-nav-arrow">‹</text>
+          </view>
+          <text class="os-nav-title">订单详情</text>
+        </view>
+      </view>
+
       <!-- 状态大卡片 -->
       <view class="os-status-card" :class="'os-status--' + order.status">
         <text class="os-status-icon">{{ statusIcon(order.status) }}</text>
@@ -112,19 +122,19 @@
 
       <!-- 操作按钮 -->
       <view class="os-actions">
-        <view v-if="canRefund" class="os-btn os-btn--refund" @click="showRefundModal = true">申请退单</view>
+        <view v-if="canRefund" class="os-btn os-btn--refund" @click="showRefundModal = true">{{ isPendingPay ? '取消订单' : '申请退单' }}</view>
         <view class="os-btn os-btn--primary" @click="goMenu">返回点餐</view>
       </view>
 
       <!-- 退单确认弹窗 -->
       <view v-if="showRefundModal" class="os-modal-mask" @click="showRefundModal = false">
         <view class="os-modal" @click.stop>
-          <text class="os-modal-title">申请退单</text>
-          <text class="os-modal-desc">确定要申请退单吗?</text>
+          <text class="os-modal-title">{{ isPendingPay ? '取消订单' : '申请退单' }}</text>
+          <text class="os-modal-desc">{{ isPendingPay ? '订单尚未支付,确定要取消吗?' : '确定要申请退单吗?' }}</text>
           <textarea
             class="os-modal-input"
             v-model="refundReason"
-            placeholder="选填,请输入退单原因"
+            :placeholder="isPendingPay ? '选填,请输入取消原因' : '选填,请输入退单原因'"
             :maxlength="200"
           />
           <view class="os-modal-btns">
@@ -217,7 +227,8 @@ function stopPolling() {
 }
 
 function isFinished(status) {
-  return status === 6 || status === 7 || status === 9 || status === 10
+  // 11已取消 之前被漏掉,已取消的订单会一直轮询详情接口,白跑请求不停
+  return status === 6 || status === 7 || status === 9 || status === 10 || status === 11
 }
 
 const orderItems = computed(() => order.value?.items || [])
@@ -229,11 +240,15 @@ const canRefund = computed(() => {
   return s === 0 || s === 1 || s === 2
 })
 
+// 待支付订单没有资金流转,后端置 11已取消 而不是 9已退单,
+// 界面必须说「取消订单」,否则顾客会以为钱要退回
+const isPendingPay = computed(() => !!order.value && order.value.status === 0)
+
 const showProgress = computed(() => {
   if (!order.value) return false
   const s = order.value.status
-  // 待支付/拒单/退单/退单待审核/超时 不显示进度条
-  return s !== 0 && s !== 7 && s !== 8 && s !== 9 && s !== 10
+  // 待支付/拒单/退单待审核/退单/超时取消/已取消 显示配送进度条
+  return s !== 0 && s !== 7 && s !== 8 && s !== 9 && s !== 10 && s !== 11
 })
 
 const progressSteps = computed(() => {
@@ -248,7 +263,8 @@ const progressSteps = computed(() => {
 })
 
 function statusIcon(status) {
-  const map = { 0: '💳', 1: '📝', 2: '✅', 3: '👨‍🍳', 4: '🍱', 5: '🚀', 6: '🎉', 7: '❌', 8: '↩️', 9: '↩️', 10: '⏰', 11: '🚪' }
+  // 11 不再固定用🚪(退房):已取消可能是顾客自助或前台操作,改用中性的取消图标
+  const map = { 0: '💳', 1: '📝', 2: '✅', 3: '👨‍🍳', 4: '🍱', 5: '🚀', 6: '🎉', 7: '❌', 8: '↩️', 9: '↩️', 10: '⏰', 11: '🚫' }
   return map[status] || '📋'
 }
 
@@ -263,6 +279,18 @@ function statusText(status) {
 }
 
 function statusHint(status) {
+  // 11已取消 有三种成因,文案必须区分,否则顾客自助取消会看到「房间已退房」这种错误提示。
+  // 退房联动取消不写 refund_initiator,故为 NULL 时才是退房导致
+  if (status === 11) {
+    const initiator = order.value ? order.value.refundInitiator : null
+    if (initiator === 'CUSTOMER') {
+      return '您已取消该订单'
+    }
+    if (initiator === 'HOTEL_DESK') {
+      return '餐厅已为您取消该订单,如有疑问请联系前台'
+    }
+    return '房间已退房,该订单已自动取消'
+  }
   const map = {
     0: '请尽快完成支付,超时订单将自动取消',
     1: '餐厅正在处理您的订单,请稍候...',
@@ -275,26 +303,39 @@ function statusHint(status) {
     8: '您的退单申请正在等待审核',
     9: '退单成功,款项将原路返回',
     10: '订单因超时未支付已自动取消',
-    11: '房间已退房,该订单已自动取消',
   }
   return map[status] || ''
 }
 
 function confirmRefund() {
+  // 退单前记下当前状态:已接单(2)需前台审核,待支付(0)/待接单(1)即时生效,文案需区分
+  const currentStatus = order.value ? order.value.status : null
   showRefundModal.value = false
-  const reason = refundReason.value.trim() || '顾客主动退单'
+  const reason = refundReason.value.trim() || (currentStatus === 0 ? '顾客主动取消' : '顾客主动退单')
   refundReason.value = ''
   api.hotelCustomerRefund(orderId, orderStore.tenantId, reason).then(function() {
-    uni.showToast({ title: '退单申请已提交', icon: 'success' })
+    let tip = '退单成功,款项将原路返回'
+    if (currentStatus === 2) {
+      tip = '退单申请已提交,等待餐厅审核'
+    } else if (currentStatus === 0) {
+      tip = '订单已取消'
+    }
+    uni.showToast({ title: tip, icon: 'success' })
+    loadOrder()
+  }).catch(function() {
+    // 失败原因已由请求拦截器统一 toast(退款失败时订单状态不变,不会误显示已退单),
+    // 这里不再重复提示,仅刷新一次保证页面与服务端一致
     loadOrder()
-  }).catch(function(e) {
-    uni.showToast({ title: e?.message || '退单失败', icon: 'none' })
   })
 }
 
 function goMenu() {
   uni.redirectTo({ url: '/pages/hotel/customer/menu' })
 }
+
+function goBack() {
+  uni.navigateBack({ delta: 1 })
+}
 </script>
 
 <style lang="scss" scoped>
@@ -314,6 +355,49 @@ function goMenu() {
   font-size: 26rpx;
 }
 
+/* 顶部返回栏 */
+.os-nav {
+  display: flex;
+  align-items: center;
+  padding: 16rpx 0 20rpx;
+}
+
+.os-nav-back {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+}
+
+.os-nav-back-circle {
+  width: 60rpx;
+  height: 60rpx;
+  border-radius: 50%;
+  background: #fff;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.os-nav-back-circle:active {
+  background: #f5f5f5;
+  transform: scale(0.95);
+}
+
+.os-nav-arrow {
+  font-size: 40rpx;
+  font-weight: 400;
+  color: var(--h-txt, #1A1A1A);
+  line-height: 1;
+  margin-top: -4rpx;
+}
+
+.os-nav-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: var(--h-txt, #1A1A1A);
+}
+
 /* 状态大卡片 */
 .os-status-card {
   display: flex;
@@ -353,6 +437,9 @@ function goMenu() {
 .os-status--7 { background: linear-gradient(135deg, #FFEBEE, #fff); }
 .os-status--8 { background: linear-gradient(135deg, #FFF8E1, #fff); }
 .os-status--9 { background: linear-gradient(135deg, #FFF8E1, #fff); }
+/* 10超时取消 / 11已取消:之前缺样式,终态卡片会没有背景色 */
+.os-status--10 { background: linear-gradient(135deg, #F5F5F5, #fff); }
+.os-status--11 { background: linear-gradient(135deg, #F5F5F5, #fff); }
 
 /* 进度条 */
 .os-progress {
@@ -573,6 +660,7 @@ function goMenu() {
 
 .os-btn {
   width: 100%;
+  box-sizing: border-box;
   padding: 24rpx;
   border-radius: 44rpx;
   font-size: 28rpx;

+ 57 - 6
forge-h5-ui/src/pages/hotel/customer/orders.vue

@@ -56,6 +56,11 @@
           <view v-if="item.status === 9 && item.refundReason" class="o-card-refund">
             <text>↩️ {{ item.refundReason }}</text>
           </view>
+
+          <!-- 取消原因(11已取消:顾客自助/前台/退房联动,无资金流转) -->
+          <view v-if="item.status === 11 && item.refundReason" class="o-card-refund">
+            <text>🚫 {{ item.refundReason }}</text>
+          </view>
         </view>
       </view>
 
@@ -74,7 +79,8 @@
 </template>
 
 <script setup>
-import { computed, ref } from 'vue'
+import { computed, ref, watch, onMounted } from 'vue'
+import { onShow } from '@dcloudio/uni-app'
 import { useHotelOrderStore } from '@/store'
 import api from '@/api'
 import HotelTabBar from '@/components/hotel/HotelTabBar.vue'
@@ -89,27 +95,69 @@ const pageNum = ref(1)
 const pageSize = 10
 
 // 页面加载时获取订单
-import { onMounted } from 'vue'
 onMounted(() => {
   loadOrders()
 })
+// uni-app TabBar 模拟页切换时用 onShow 刷新,确保从其他页面返回时数据最新
+// 关键修复:每次显示页面时都重新加载,避免缓存导致的数据隔离失效
+onShow(() => {
+  // 只要有房间信息就重新加载,确保 stayId 变化后能立即生效
+  if (roomInfo.value?.stayId) {
+    resetAndLoad()
+  }
+})
+
+// 在住会话变化时自动清空并重新加载:退房后新客人不应看到旧订单
+// 关键修复:监听 stayId 的变化(包括从 null → 有值、旧值 → 新值)
+watch(
+  () => roomInfo.value?.stayId,
+  (newStayId, oldStayId) => {
+    // 只要 stayId 发生变化(排除首次初始化),就清空列表并重新加载
+    if (oldStayId !== undefined && newStayId !== oldStayId) {
+      console.log(`[Orders] stayId changed: ${oldStayId} -> ${newStayId}`)
+      resetAndLoad()
+    }
+  },
+  { immediate: false } // 不立即执行,避免页面初始化时重复请求
+)
+
+/** 重置分页并重新加载(会话切换、下拉刷新等场景) */
+function resetAndLoad() {
+  pageNum.value = 1
+  noMore.value = false
+  orderList.value = []
+  loadOrders()
+}
 
 async function loadOrders() {
   if (loading.value) return
   loading.value = true
 
   try {
+    // 关键修复:必须使用 stayId 过滤,禁止降级到 roomNo(避免看到旧订单)
+    const currentStayId = roomInfo.value?.stayId
+    if (!currentStayId) {
+      console.warn('[Orders] No stayId available, skipping order query')
+      orderList.value = []
+      noMore.value = true
+      loading.value = false
+      return
+    }
+
+    console.log(`[Orders] Loading orders for stayId: ${currentStayId}, pageNum: ${pageNum.value}`)
+
     const res = await api.hotelOrderPage({
       tenantId: orderStore.tenantId,
       pageNum: pageNum.value,
       pageSize: pageSize.value,
-      // 按在住会话过滤:同一住客的订单共享可见;退房后新住客的会话不同,互相隔离;
-      // 无会话时(未扫码/异常)退回按房间号过滤兼容存量数据。
-      ...(roomInfo.value?.stayId ? { stayId: roomInfo.value.stayId } : { roomNo: roomInfo.value?.roomNo }),
+      // 严格按在住会话过滤:同一住客的订单共享可见;退房后新住客的会话不同,互相隔离
+      stayId: currentStayId,
     })
     const data = res?.data || res
     const list = data?.records || (Array.isArray(data) ? data : [])
 
+    console.log(`[Orders] Loaded ${list.length} orders for stayId: ${currentStayId}`)
+
     if (pageNum.value === 1) {
       orderList.value = list
     } else {
@@ -160,7 +208,8 @@ function viewDetail(order) {
       url: `/pages/hotel/customer/pay?orderId=${order.id}&tenantId=${order.tenantId}&amount=${order.totalAmount}`,
     })
   } else {
-    uni.redirectTo({
+    // 订单详情用 navigateTo,保留导航栈,返回按钮可正常返回订单列表
+    uni.navigateTo({
       url: `/pages/hotel/customer/order-status?orderId=${order.id}`,
     })
   }
@@ -282,6 +331,8 @@ function goMenu() {
 .o-st--8 { background: #FEF9C3; color: #854D0E; }
 .o-st--9 { background: #FEF9C3; color: #854D0E; }
 .o-st--10 { background: #F3F4F6; color: #6B7280; }
+/* 11已取消:之前缺样式,标签会没有背景色与字色 */
+.o-st--11 { background: #F3F4F6; color: #6B7280; }
 
 /* 菜品摘要 */
 .o-card-body {

+ 53 - 0
forge-h5-ui/src/pages/hotel/customer/room-confirm.vue

@@ -62,6 +62,31 @@
       </view>
     </view>
 
+    <!-- 暂停接单拦截 -->
+    <view v-else-if="pausedInfo" class="rc-error-page">
+      <view class="rc-brand">
+        <view class="rc-brand-icon">
+          <text class="rc-brand-icon-text">🏨</text>
+        </view>
+        <text class="rc-brand-name">亚朵酒店</text>
+        <text class="rc-brand-slogan">ATOUR HOTEL · 客房送餐服务</text>
+      </view>
+
+      <view class="rc-error-card">
+        <view class="rc-error-icon-wrap">
+          <view class="rc-error-icon-inner">⏸️</view>
+        </view>
+        <text class="rc-error-title">餐厅暂停接单</text>
+        <view v-if="roomInfo.roomNo" class="rc-error-room">
+          <text class="rc-error-room-label">扫码房间</text>
+          <text class="rc-error-room-no">{{ roomInfo.roomNo }}</text>
+          <text v-if="roomInfo.roomTypeName" class="rc-error-room-type">{{ roomInfo.roomTypeName }}</text>
+        </view>
+        <text class="rc-error-msg">餐厅暂时无法接单,请稍后再试</text>
+        <view class="rc-error-btn" @click="retry">重新检查</view>
+      </view>
+    </view>
+
     <!-- 房间确认 -->
     <view v-else class="rc-content">
       <!-- 顶部品牌区 -->
@@ -131,6 +156,7 @@ const errorMsg = ref('')
 const roomInfo = ref({})
 const businessHoursText = computed(() => orderStore.businessHoursText)
 const closedInfo = ref(null)
+const pausedInfo = ref(null)
 const deliveryTime = ref('25-35')
 const deliveryFee = ref(0)
 
@@ -209,6 +235,13 @@ async function loadRoomInfo() {
       return
     }
 
+    // 校验暂停接单状态
+    const pauseState = await loadPauseStatus(data.tenantId)
+    if (pauseState) {
+      loading.value = false
+      return
+    }
+
     // 入住时前台已录入客人信息,自动回填到联系人(优先级高于支付宝授权)
     if (data.room?.guestName || data.room?.guestPhone) {
       orderStore.setContactInfo(
@@ -251,8 +284,28 @@ async function loadBusinessHours(tenantId) {
   }
 }
 
+/** 查询暂停接单状态,返回 true=暂停,false=正常 */
+async function loadPauseStatus(tenantId) {
+  try {
+    var res = await api.hotelPauseStatus(tenantId)
+    var paused = res?.data ?? res
+    if (paused) {
+      pausedInfo.value = true
+      return true
+    }
+    // 正常时重置为 null,避免恢复后仍显示拦截页
+    pausedInfo.value = null
+    return false
+  } catch (err) {
+    console.log('暂停状态查询失败:', err?.message || err)
+    pausedInfo.value = null
+    return false
+  }
+}
+
 function retry() {
   closedInfo.value = null
+  pausedInfo.value = null
   loadRoomInfo()
 }
 

+ 28 - 0
forge-server/db/migration/V1.0.109__add_hotel_order_item_spec_addition_ids.sql

@@ -0,0 +1,28 @@
+-- ============================================================
+-- 订单明细增加规格选项ID和加料ID字段,支持后端按ID重算加价
+-- 修复:orderCreate() 只取菜品基础价,规格/加料加价被丢弃
+-- ============================================================
+
+-- 规格选项ID列表(逗号分隔,如 "123456,789012")
+SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE()
+      AND TABLE_NAME = 'hotel_order_item'
+      AND COLUMN_NAME = 'spec_option_ids');
+SET @sql = IF(@col_exists = 0,
+    'ALTER TABLE `hotel_order_item` ADD COLUMN `spec_option_ids` varchar(500) DEFAULT NULL COMMENT ''规格选项ID列表(逗号分隔,用于重算加价)''',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 加料ID列表(逗号分隔,如 "111222,333444")
+SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE()
+      AND TABLE_NAME = 'hotel_order_item'
+      AND COLUMN_NAME = 'addition_ids');
+SET @sql = IF(@col_exists = 0,
+    'ALTER TABLE `hotel_order_item` ADD COLUMN `addition_ids` varchar(500) DEFAULT NULL COMMENT ''加料ID列表(逗号分隔,用于重算加价)''',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;

+ 26 - 0
forge-server/db/migration/V1.0.110__add_hotel_config_table.sql

@@ -0,0 +1,26 @@
+-- =====================================================
+-- 酒店通用配置表(键值对存储)
+-- 用途:暂停接单开关等运行时配置,支持持久化和跨重启保留
+-- =====================================================
+
+CREATE TABLE IF NOT EXISTS hotel_config (
+    id           BIGINT       NOT NULL COMMENT '主键ID',
+    tenant_id    BIGINT       NOT NULL DEFAULT 1 COMMENT '租户ID',
+    config_key   VARCHAR(64)  NOT NULL COMMENT '配置键(如 order_pause)',
+    config_value VARCHAR(255) NOT NULL DEFAULT '' COMMENT '配置值',
+    config_desc  VARCHAR(255) NULL     DEFAULT NULL COMMENT '配置说明',
+    create_by    BIGINT       NULL     DEFAULT NULL COMMENT '创建人ID',
+    create_time  DATETIME     NULL     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    create_dept  BIGINT       NULL     DEFAULT NULL COMMENT '创建部门ID',
+    update_by    BIGINT       NULL     DEFAULT NULL COMMENT '更新人ID',
+    update_time  DATETIME     NULL     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_tenant_key (tenant_id, config_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='酒店通用配置表';
+
+-- 初始化暂停接单配置(默认未暂停)
+INSERT INTO hotel_config (id, tenant_id, config_key, config_value, config_desc)
+SELECT 1, 1, 'order_pause', 'false', '是否暂停接单(true=暂停, false=正常)'
+WHERE NOT EXISTS (
+    SELECT 1 FROM hotel_config WHERE tenant_id = 1 AND config_key = 'order_pause'
+);

+ 108 - 0
forge-server/db/migration/V1.0.111__fix_hotel_order_cancel_semantics.sql

@@ -0,0 +1,108 @@
+-- ============================================================
+-- V1.0.111 酒店订单「取消」与「退单」语义分离:存量数据修复 + 字典备注同步
+-- ============================================================
+-- 背景
+--   旧实现把「待支付订单被人为取消」也写成退单终态,产生三类脏数据:
+--   1) 前台退单待支付订单 -> status=9已退单,但 pay_status 仍为 0(从未付款),
+--      顾客端显示「已退单 / 款项将原路返回」,实际没有任何资金流转;
+--   2) 顾客取消待支付订单 -> status=10支付超时已取消 + pay_status=3超时,
+--      但这是人为取消不是超时,顾客看到的文案与自己的操作不符;
+--   3) 已支付订单退单时退款失败 -> 旧代码先置终态再退款且吞掉异常,
+--      随后 updateById(order) 又用内存旧值把退款服务写入的 refund_status 覆盖回未退款,
+--      结果是「钱没退回、订单显示已退单」,且前台看不到手动退款按钮,无从重试。
+--
+--   代码已改为:人为取消一律写 status=11已取消,pay_status 保持 0,不写 refund_time;
+--   status=10 仅由超时任务 cancelTimeoutOrder 写入;退款失败时订单保持原状态并置
+--   refund_status=2 供重试。本脚本把存量数据对齐到新语义。
+--
+-- 判别依据(可靠)
+--   超时任务只写 status / pay_status,不写 refund_initiator、refund_reason、refund_time;
+--   人为取消一定会写 refund_initiator(CUSTOMER / HOTEL_DESK)。
+--
+-- 影响范围
+--   仅 hotel_order 的状态类字段与字典备注,不新增/删除任何行,不改表结构。
+--   第 1、2 步只命中「从未支付成功」的订单;第 3 步只命中「已支付但退款状态丢失」的订单。
+--   一律不触碰资金字段 refund_amount / refund_trade_no / paid_amount 的既有值。
+--
+-- 回滚方式
+--   三步均为幂等 UPDATE,重复执行结果一致。需要回滚时按下列条件反向更新:
+--   1) status=11 AND pay_status=0 AND refund_initiator='HOTEL_DESK' AND refund_status=0
+--      -> 原为 status=9(前台退单待支付订单);
+--   2) status=11 AND pay_status=0 AND refund_initiator='CUSTOMER' AND refund_status=0
+--      -> 原为 status=10、pay_status=3(顾客取消待支付订单);
+--   3) refund_status=2 AND refund_amount IS NULL AND refund_trade_no IS NULL
+--      -> 原为 refund_status=0/NULL。
+--   如需精确回滚,建议执行前先备份受影响行:
+--   CREATE TABLE hotel_order_bak_v1_0_111 AS SELECT * FROM hotel_order
+--     WHERE del_flag = 0 AND ((status = 9 AND (pay_status IS NULL OR pay_status = 0))
+--        OR (status = 10 AND refund_initiator IS NOT NULL)
+--        OR (pay_status = 1 AND status IN (7, 9, 10, 11)
+--            AND (refund_status IS NULL OR refund_status = 0)));
+-- ============================================================
+
+-- ------------------------------------------------------------
+-- 1. 未付款却被标记「已退单」的订单 -> 11已取消
+--    refund_status 非 0/NULL 说明发生过真实退款,一律不动
+-- ------------------------------------------------------------
+UPDATE hotel_order
+SET status        = 11,
+    pay_status    = 0,
+    refund_time   = NULL,
+    update_time   = NOW()
+WHERE del_flag = 0
+  AND status = 9
+  AND (pay_status IS NULL OR pay_status = 0)
+  AND (refund_status IS NULL OR refund_status = 0);
+
+-- ------------------------------------------------------------
+-- 2. 人为取消却被标记「支付超时已取消」的订单 -> 11已取消
+--    refund_initiator 非空即人为操作;超时任务不会写该字段
+--    pay_status 由 3超时 改回 0待支付:这笔订单从未支付成功
+-- ------------------------------------------------------------
+UPDATE hotel_order
+SET status        = 11,
+    pay_status    = 0,
+    refund_time   = NULL,
+    update_time   = NOW()
+WHERE del_flag = 0
+  AND status = 10
+  AND refund_initiator IS NOT NULL
+  AND (pay_status IS NULL OR pay_status IN (0, 3))
+  AND (refund_status IS NULL OR refund_status = 0);
+
+-- ------------------------------------------------------------
+-- 3. 已支付却查不到退款成功记录的终态订单 -> refund_status=2可重试
+--    只补回被覆盖丢失的退款状态标记,让前台的「退款」按钮重新出现,
+--    实际退款仍由人工核对支付宝后台后手动发起,本脚本不触发任何资金操作
+-- ------------------------------------------------------------
+UPDATE hotel_order
+SET refund_status = 2,
+    update_time   = NOW()
+WHERE del_flag = 0
+  AND pay_status = 1
+  AND status IN (7, 9, 10, 11)
+  AND (refund_status IS NULL OR refund_status = 0);
+
+-- ------------------------------------------------------------
+-- 4. 字典备注同步新语义(dict_label 与 list_class 不变,仅补充说明)
+-- ------------------------------------------------------------
+UPDATE sys_dict_data
+SET remark      = '人为取消待支付订单(顾客自助/前台/退房联动),无资金流转',
+    update_time = NOW()
+WHERE dict_type = 'hotel_order_status'
+  AND dict_value = '11'
+  AND tenant_id = 1;
+
+UPDATE sys_dict_data
+SET remark      = '仅由支付超时任务写入,人为取消请用 11已取消',
+    update_time = NOW()
+WHERE dict_type = 'hotel_order_status'
+  AND dict_value = '10'
+  AND tenant_id = 1;
+
+UPDATE sys_dict_data
+SET remark      = '已支付订单退款成功后置为该状态,未付款订单不得写入',
+    update_time = NOW()
+WHERE dict_type = 'hotel_order_status'
+  AND dict_value = '9'
+  AND tenant_id = 1;

+ 31 - 0
forge-server/db/migration/V1.0.112__seed_hotel_config_delivery_fee.sql

@@ -0,0 +1,31 @@
+-- =====================================================
+-- V1.0.112 播种酒店配送费配置(delivery_fee)
+-- =====================================================
+-- 背景
+--   订单创建走免登录的 /hotel/open/customer/orderCreate,此前 deliveryFee 由前端
+--   传值直接落库并计入订单总额,构成金额篡改面(《酒店模块需求缺口清单》3.2)。
+--   代码已改为服务端读取 hotel_config 的 delivery_fee,前端传值不再采信,
+--   本脚本补上该配置行,使其可运营调整。
+--
+-- 缺省值
+--   0.00:与改造前的实际行为一致(H5 从未传过非 0 配送费),升级后订单金额不变。
+--   需要收取配送费时直接改这一行的 config_value 即可,代码侧对负数归零、
+--   对超过 999.00 的值截断并告警,防误配置放大成异常订单金额。
+--
+-- 主键说明
+--   固定配置行使用确定性小整数主键(1=order_pause,2=delivery_fee),便于脚本幂等
+--   与跨环境比对;业务运行时通过 upsert 新增的行仍走雪花ID,两者取值区间不重叠。
+--
+-- 影响范围
+--   仅新增 hotel_config 一行配置数据,不改表结构,不触碰任何订单与资金字段。
+--
+-- 回滚方式
+--   DELETE FROM hotel_config WHERE tenant_id = 1 AND config_key = 'delivery_fee';
+--   删除后代码按缺省 0 处理并打 WARN,不影响下单。
+-- =====================================================
+
+INSERT INTO hotel_config (id, tenant_id, config_key, config_value, config_desc)
+SELECT 2, 1, 'delivery_fee', '0.00', '配送费(元),下单时服务端读取并计入订单总额'
+WHERE NOT EXISTS (
+    SELECT 1 FROM hotel_config WHERE tenant_id = 1 AND config_key = 'delivery_fee'
+);

File diff suppressed because it is too large
+ 1 - 1
forge-server/forge-admin-server/src/main/resources/application-dev.yml


+ 37 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/constant/HotelConfigKeys.java

@@ -0,0 +1,37 @@
+package com.mdframe.forge.business.core.hotel.constant;
+
+import java.math.BigDecimal;
+
+/**
+ * 酒店通用配置(hotel_config)键与默认值常量。
+ * <p>
+ * 配置值一律以服务端为准,免登录的顾客端接口不接受前端传入这些值,
+ * 否则构成金额篡改面(详见《酒店模块需求缺口清单》3.2)。
+ */
+public final class HotelConfigKeys {
+
+    private HotelConfigKeys() {}
+
+    /** 是否暂停接单:true=暂停,false=正常营业 */
+    public static final String ORDER_PAUSE = "order_pause";
+
+    /** 暂停接单配置说明 */
+    public static final String ORDER_PAUSE_DESC = "是否暂停接单(true=暂停, false=正常)";
+
+    /** 配送费(元):下单时由服务端读取,不采信前端传值 */
+    public static final String DELIVERY_FEE = "delivery_fee";
+
+    /** 配送费配置说明 */
+    public static final String DELIVERY_FEE_DESC = "配送费(元),下单时服务端读取并计入订单总额";
+
+    /** 配送费缺省值:配置行缺失或值非法时按 0 处理,与暂停接单前的既有行为保持一致 */
+    public static final BigDecimal DELIVERY_FEE_DEFAULT = BigDecimal.ZERO;
+
+    /**
+     * 配送费上限(元)。
+     * <p>
+     * 防御误配置:配置值超出上限时按上限截断并告警,避免一条写错的配置
+     * 直接放大成所有订单的异常金额。
+     */
+    public static final BigDecimal DELIVERY_FEE_MAX = new BigDecimal("999.00");
+}

+ 124 - 11
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/open/HotelCustomerController.java

@@ -2,6 +2,7 @@ package com.mdframe.forge.business.core.hotel.controller.open;
 
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.mdframe.forge.business.core.hotel.domain.HotelDish;
+import com.mdframe.forge.business.core.hotel.order.constant.HotelOrderConstants;
 import com.mdframe.forge.business.core.hotel.order.domain.HotelOrder;
 import com.mdframe.forge.business.core.hotel.order.dto.OrderCreateDTO;
 import com.mdframe.forge.business.core.hotel.order.service.HotelOrderService;
@@ -9,6 +10,7 @@ import com.mdframe.forge.business.core.hotel.order.vo.HotelOrderVO;
 import com.mdframe.forge.business.core.hotel.service.HotelBusinessHoursService;
 import com.mdframe.forge.business.core.hotel.service.HotelDishCategoryService;
 import com.mdframe.forge.business.core.hotel.service.HotelDishService;
+import com.mdframe.forge.business.core.hotel.service.HotelOrderPauseService;
 import com.mdframe.forge.business.core.hotel.vo.BusinessHoursStatusVO;
 import com.mdframe.forge.business.core.hotel.vo.HotelDishCategoryVO;
 import com.mdframe.forge.business.core.hotel.vo.HotelDishVO;
@@ -16,6 +18,7 @@ import com.mdframe.forge.starter.core.domain.PageQuery;
 import com.mdframe.forge.starter.core.domain.RespInfo;
 import com.mdframe.forge.starter.core.exception.BusinessException;
 import com.mdframe.forge.starter.tenant.context.TenantContextHolder;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -32,6 +35,7 @@ import java.util.List;
  * 路径 /hotel/open/** 已在 SaTokenConfig 中排除登录校验和 API 权限校验。
  * 所有接口通过 tenantId 参数设置租户上下文,确保多租户数据隔离。
  */
+@Slf4j
 @RestController
 @RequestMapping("/hotel/open/customer")
 public class HotelCustomerController {
@@ -48,6 +52,9 @@ public class HotelCustomerController {
     @Autowired
     private HotelBusinessHoursService businessHoursService;
 
+    @Autowired
+    private HotelOrderPauseService pauseService;
+
     // ==================== 营业时段 ====================
 
     /**
@@ -60,6 +67,7 @@ public class HotelCustomerController {
      */
     @GetMapping("/businessHours")
     public RespInfo<BusinessHoursStatusVO> businessHours(@RequestParam Long tenantId) {
+        requireTenantId(tenantId);
         BusinessHoursStatusVO result = TenantContextHolder.executeWithTenant(tenantId,
                 new java.util.function.Supplier<BusinessHoursStatusVO>() {
                     @Override
@@ -70,6 +78,28 @@ public class HotelCustomerController {
         return RespInfo.success(result);
     }
 
+    /**
+     * 顾客端查询暂停接单状态(免登录)。
+     * <p>
+     * 配置行缺失时服务端按「未暂停」放行并打 WARN(fail-open):
+     * 反向 fail-close 会把一次误删配置放大成整店无法下单。
+     *
+     * @param tenantId 租户ID(从扫码返回的 data.tenantId 获取,前端不应手动修改)
+     * @return true=暂停中,false=正常营业
+     */
+    @GetMapping("/pauseStatus")
+    public RespInfo<Boolean> pauseStatus(@RequestParam Long tenantId) {
+        requireTenantId(tenantId);
+        boolean paused = TenantContextHolder.executeWithTenant(tenantId,
+                new java.util.function.Supplier<Boolean>() {
+                    @Override
+                    public Boolean get() {
+                        return pauseService.isPaused();
+                    }
+                });
+        return RespInfo.success(paused);
+    }
+
     // ==================== 菜品分类 ====================
 
     /**
@@ -80,6 +110,7 @@ public class HotelCustomerController {
      */
     @GetMapping("/categoryEnabled")
     public RespInfo<List<HotelDishCategoryVO>> categoryEnabled(@RequestParam Long tenantId) {
+        requireTenantId(tenantId);
         List<HotelDishCategoryVO> result = TenantContextHolder.executeWithTenant(tenantId,
                 new java.util.function.Supplier<List<HotelDishCategoryVO>>() {
                     @Override
@@ -104,6 +135,7 @@ public class HotelCustomerController {
     public RespInfo<IPage<HotelDishVO>> dishPage(@RequestParam Long tenantId,
                                                   PageQuery pageQuery,
                                                   HotelDish query) {
+        requireTenantId(tenantId);
         IPage<HotelDishVO> result = TenantContextHolder.executeWithTenant(tenantId,
                 new java.util.function.Supplier<IPage<HotelDishVO>>() {
                     @Override
@@ -124,6 +156,7 @@ public class HotelCustomerController {
     @GetMapping("/dishDetail")
     public RespInfo<HotelDishVO> dishDetail(@RequestParam Long tenantId,
                                              @RequestParam Long id) {
+        requireTenantId(tenantId);
         HotelDishVO result = TenantContextHolder.executeWithTenant(tenantId,
                 new java.util.function.Supplier<HotelDishVO>() {
                     @Override
@@ -149,10 +182,15 @@ public class HotelCustomerController {
     @PostMapping("/orderCreate")
     public RespInfo<Long> orderCreate(@RequestParam Long tenantId,
                                        @RequestBody OrderCreateDTO dto) {
+        requireTenantId(tenantId);
         Long result = TenantContextHolder.executeWithTenant(tenantId,
                 new java.util.function.Supplier<Long>() {
                     @Override
                     public Long get() {
+                        // 暂停接单校验:暂停时拒绝顾客下单
+                        if (pauseService.isPaused()) {
+                            throw new BusinessException("餐厅已暂停接单,请稍后再试");
+                        }
                         // 营业时间校验:非营业时段禁止下单
                         BusinessHoursStatusVO hours = businessHoursService.currentStatus();
                         if (hours != null && Boolean.TRUE.equals(hours.getConfigured())
@@ -171,14 +209,25 @@ public class HotelCustomerController {
 
     /**
      * 顾客端订单详情(订单状态跟踪页使用)。
+     * <p>
+     * 安全增强:SQL 已按 tenantId 过滤,此处仅做防御性校验和日志记录。
      *
-     * @param tenantId 租户ID
+     * @param tenantId 租户ID(从扫码返回的 data.tenantId 获取,前端不应手动修改)
      * @param id       订单ID
      * @return 订单详情(含订单明细)
      */
     @GetMapping("/orderDetail")
     public RespInfo<HotelOrderVO> orderDetail(@RequestParam Long tenantId,
                                                @RequestParam Long id) {
+        // 参数校验
+        requireTenantId(tenantId);
+        if (id == null || id <= 0) {
+            throw new BusinessException("订单ID不能为空");
+        }
+
+        // 记录访问日志(用于审计和安全分析)
+        log.debug("[Customer] orderDetail: tenantId={}, orderId={}", tenantId, id);
+
         HotelOrderVO result = TenantContextHolder.executeWithTenant(tenantId,
                 new java.util.function.Supplier<HotelOrderVO>() {
                     @Override
@@ -186,13 +235,22 @@ public class HotelCustomerController {
                         return orderService.orderDetail(id);
                     }
                 });
+
+        // 防御性校验:确保查询结果不为空(SQL 已按 tenantId 过滤,理论上不会查到其他租户的订单)
+        if (result == null) {
+            log.warn("[Customer] orderDetail not found or cross-tenant access attempt: tenantId={}, orderId={}", tenantId, id);
+            throw new BusinessException("订单不存在或无权访问");
+        }
+
         return RespInfo.success(result);
     }
 
     /**
      * 顾客端订单列表(我的订单页使用)。
+     * <p>
+     * 安全增强:SQL 已按 tenantId 过滤,此处仅做防御性校验和日志记录。
      *
-     * @param tenantId  租户ID
+     * @param tenantId  租户ID(从扫码返回的 data.tenantId 获取,前端不应手动修改)
      * @param pageQuery 分页参数
      * @param query     查询条件
      * @return 订单分页数据
@@ -201,6 +259,13 @@ public class HotelCustomerController {
     public RespInfo<IPage<HotelOrderVO>> orderPage(@RequestParam Long tenantId,
                                                     PageQuery pageQuery,
                                                     HotelOrder query) {
+        // 参数校验
+        requireTenantId(tenantId);
+
+        // 记录访问日志
+        log.debug("[Customer] orderPage: tenantId={}, pageNum={}, pageSize={}",
+                tenantId, pageQuery.getPageNum(), pageQuery.getPageSize());
+
         IPage<HotelOrderVO> result = TenantContextHolder.executeWithTenant(tenantId,
                 new java.util.function.Supplier<IPage<HotelOrderVO>>() {
                     @Override
@@ -208,15 +273,17 @@ public class HotelCustomerController {
                         return orderService.orderPage(pageQuery, query);
                     }
                 });
+
         return RespInfo.success(result);
     }
 
     /**
      * 顾客端自助退单(订单状态跟踪页使用)。
      * <p>
+     * 安全增强:SQL 已按 tenantId 过滤,此处仅做防御性校验和日志记录。
      * 待接单(1):直接退单;已接单(2):进入退单审核(8);备餐中及之后:不允许退单。
      *
-     * @param tenantId 租户ID
+     * @param tenantId 租户ID(从扫码返回的 data.tenantId 获取,前端不应手动修改)
      * @param id       订单ID
      * @param reason   退单原因(选填)
      */
@@ -224,28 +291,58 @@ public class HotelCustomerController {
     public RespInfo<Void> customerRefund(@RequestParam Long tenantId,
                                           @RequestParam Long id,
                                           @RequestParam(required = false) String reason) {
-        TenantContextHolder.executeWithTenant(tenantId,
-                new Runnable() {
-                    @Override
-                    public void run() {
-                        orderService.customerRefund(id, reason);
-                    }
-                });
+        // 参数校验
+        requireTenantId(tenantId);
+        if (id == null || id <= 0) {
+            throw new BusinessException("订单ID不能为空");
+        }
+
+        // 记录操作日志
+        log.info("[Customer] customerRefund: tenantId={}, orderId={}, reason={}", tenantId, id, reason);
+
+        try {
+            TenantContextHolder.executeWithTenant(tenantId,
+                    new Runnable() {
+                        @Override
+                        public void run() {
+                            orderService.customerRefund(id, reason);
+                        }
+                    });
+        } catch (BusinessException e) {
+            String msg = e.getMessage();
+            // 退款失败时订单不会变为已退单,但失败原因带有支付渠道/密钥配置等技术细节,
+            // 不适合直接展示给住客;完整原因保留在服务端日志与管理端
+            if (msg != null && msg.startsWith(HotelOrderConstants.REFUND_FAILED_MSG_PREFIX)) {
+                log.error("[Customer] customerRefund 退款失败: tenantId={}, orderId={}", tenantId, id, e);
+                throw new BusinessException("退单未成功,款项尚未退回,请稍后重试或联系前台处理");
+            }
+            throw e;
+        }
         return RespInfo.success();
     }
 
     /**
      * 放弃待支付订单(物理删除)。
      * <p>
+     * 安全增强:SQL 已按 tenantId 过滤,此处仅做防御性校验和日志记录。
      * 适用场景:用户在支付页点击「返回修改」,此时订单尚未支付,
      * 直接删除不留记录,避免在订单列表中显示"超时取消"等无效数据。
      *
-     * @param tenantId 租户ID
+     * @param tenantId 租户ID(从扫码返回的 data.tenantId 获取,前端不应手动修改)
      * @param id       订单ID
      */
     @PostMapping("/abandonOrder")
     public RespInfo<Void> abandonOrder(@RequestParam Long tenantId,
                                         @RequestParam Long id) {
+        // 参数校验
+        requireTenantId(tenantId);
+        if (id == null || id <= 0) {
+            throw new BusinessException("订单ID不能为空");
+        }
+
+        // 记录操作日志
+        log.info("[Customer] abandonOrder: tenantId={}, orderId={}", tenantId, id);
+
         TenantContextHolder.executeWithTenant(tenantId,
                 new Runnable() {
                     @Override
@@ -255,4 +352,20 @@ public class HotelCustomerController {
                 });
         return RespInfo.success();
     }
+
+    /**
+     * 校验开放接口的租户ID入参。
+     * <p>
+     * /hotel/open/** 免登录,tenantId 完全来自请求参数,是本控制器唯一的租户边界。
+     * 缺参或非正数会让 executeWithTenant 把非法值写进租户上下文,因此所有开放接口
+     * 必须统一先过这道校验,不允许只加固部分接口。
+     *
+     * @param tenantId 请求传入的租户ID
+     */
+    private void requireTenantId(Long tenantId) {
+        if (tenantId == null || tenantId <= 0) {
+            log.warn("[Customer] 非法租户ID入参: tenantId={}", tenantId);
+            throw new BusinessException("租户ID不能为空");
+        }
+    }
 }

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

@@ -0,0 +1,37 @@
+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.TableName;
+import com.mdframe.forge.starter.tenant.core.TenantEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serial;
+
+/**
+ * 酒店通用配置实体。
+ * <p>
+ * 键值对存储,用于暂停接单开关等运行时配置。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("hotel_config")
+public class HotelConfig extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID */
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 配置键(如 order_pause) */
+    private String configKey;
+
+    /** 配置值 */
+    private String configValue;
+
+    /** 配置说明 */
+    private String configDesc;
+}

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

@@ -0,0 +1,56 @@
+package com.mdframe.forge.business.core.hotel.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.mdframe.forge.business.core.hotel.domain.HotelConfig;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 酒店通用配置 Mapper。
+ * <p>
+ * 查询与写入一律走 XML:一方面遵循「SQL 写在 Mapper XML」的项目约定,
+ * 另一方面把「读-改-写」压成单条原子语句,避免多个前台同时切换开关时丢失更新。
+ */
+@Mapper
+public interface HotelConfigMapper extends BaseMapper<HotelConfig> {
+
+    /**
+     * 按配置键查询配置值。
+     *
+     * @param tenantId  租户ID
+     * @param configKey 配置键
+     * @return 配置值;配置行不存在时返回 null(调用方需自行决定 fail-open / fail-close)
+     */
+    String selectValueByKey(@Param("tenantId") Long tenantId,
+                            @Param("configKey") String configKey);
+
+    /**
+     * 写入配置值,配置行不存在时自动补建。
+     * <p>
+     * 依赖唯一索引 uk_tenant_key 做幂等兜底,单条语句完成 upsert,无并发丢失更新问题。
+     *
+     * @param id          主键(新增时使用,命中已有行时忽略)
+     * @param tenantId    租户ID
+     * @param configKey   配置键
+     * @param configValue 配置值
+     * @param configDesc  配置说明
+     * @return 受影响行数
+     */
+    int upsertValue(@Param("id") Long id,
+                    @Param("tenantId") Long tenantId,
+                    @Param("configKey") String configKey,
+                    @Param("configValue") String configValue,
+                    @Param("configDesc") String configDesc);
+
+    /**
+     * 原子翻转布尔型配置值(true &lt;-&gt; false)。
+     * <p>
+     * 翻转在数据库内一次完成,不经过 Java 侧读-改-写,因此并发点击不会互相覆盖。
+     *
+     * @param tenantId  租户ID
+     * @param configKey 配置键
+     * @return 受影响行数;返回 0 表示配置行缺失,调用方需补建
+     */
+    int flipBooleanValue(@Param("tenantId") Long tenantId,
+                         @Param("configKey") String configKey);
+}

+ 27 - 2
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/constant/HotelOrderConstants.java

@@ -39,10 +39,24 @@ public final class HotelOrderConstants {
     /** 已退单 */
     public static final int STATUS_REFUNDED = 9;
 
-    /** 支付超时已取消 */
+    /**
+     * 支付超时已取消。
+     * <p>
+     * 仅由超时任务(HotelPayServiceImpl.cancelTimeoutOrder)写入;
+     * 人为取消待支付订单一律用 {@link #STATUS_CANCELLED},否则顾客会看到
+     * 「支付超时」文案,与实际操作不符。
+     */
     public static final int STATUS_PAY_TIMEOUT = 10;
 
-    /** 已取消(退房联动取消待支付订单,区别于10支付超时取消) */
+    /**
+     * 已取消(待支付订单被人为取消,无资金流转)。
+     * <p>
+     * 三种成因:顾客自助取消、前台取消、退房联动取消(cancelPendingByStayId),
+     * 可通过 refund_initiator 区分前两种,为 NULL 则是退房联动。
+     * <p>
+     * 区别于 {@link #STATUS_REFUNDED}:后者表示已支付订单退款成功,
+     * 未付过钱的订单绝不得置 9,否则会误示「款项已退回」。
+     */
     public static final int STATUS_CANCELLED = 11;
 
     // ==================== 支付状态 ====================
@@ -97,4 +111,15 @@ public final class HotelOrderConstants {
 
     /** 退款状态:退款失败(可重试) */
     public static final int REFUND_STATUS_FAILED = 2;
+
+    // ==================== 提示文案 ====================
+
+    /**
+     * 退款失败异常消息前缀。
+     * <p>
+     * 管理端(HotelOrderController)直接展示完整原因便于排障;
+     * 顾客端(HotelCustomerController)据此识别并替换为友好文案,
+     * 避免把支付渠道/密钥配置等技术细节暴露给住客。
+     */
+    public static final String REFUND_FAILED_MSG_PREFIX = "退款失败,订单未变更为已退单";
 }

+ 35 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/controller/HotelOrderController.java

@@ -7,6 +7,7 @@ import com.mdframe.forge.business.core.hotel.order.service.HotelOrderService;
 import com.mdframe.forge.business.core.hotel.order.vo.HotelOrderVO;
 import com.mdframe.forge.business.core.hotel.order.vo.OrderDashboardVO;
 import com.mdframe.forge.business.core.hotel.pay.service.HotelPayService;
+import com.mdframe.forge.business.core.hotel.service.HotelOrderPauseService;
 import com.mdframe.forge.starter.core.annotation.log.OperationLog;
 import com.mdframe.forge.starter.core.domain.OperationType;
 import com.mdframe.forge.starter.core.domain.PageQuery;
@@ -32,6 +33,9 @@ public class HotelOrderController {
     @Autowired
     private HotelPayService hotelPayService;
 
+    @Autowired
+    private HotelOrderPauseService pauseService;
+
     // ==================== 查询类接口 ====================
 
     /**
@@ -194,4 +198,35 @@ public class HotelOrderController {
         hotelPayService.refundOrder(id);
         return RespInfo.success();
     }
+
+    // ==================== 暂停接单管理 ====================
+    //
+    // 租户上下文由 HotelOrderPauseServiceImpl 统一解析(租户上下文 -> 登录态 -> 默认租户),
+    // 这里不得再用 TenantContextHolder.executeWithTenant(TenantContextHolder.getTenantId(), ...) 包装:
+    // 超级管理员登录时 TenantInterceptor 只置 ignore 标记、不写租户上下文,getTenantId() 返回 null,
+    // 把 null 写回上下文会让租户过滤整体失效,PC 端与 H5 顾客端因此读写到不同租户的配置行,
+    // 表现为「PC 显示已暂停、顾客端仍可下单」。配置 SQL 已在 XML 中显式带 tenant_id,
+    // 不再依赖租户拦截器补条件,超管的 ignore 标记也能正确隔离。
+
+    /**
+     * 查询当前暂停接单状态。
+     *
+     * @return true=暂停中,false=正常营业
+     */
+    @GetMapping("/pause/status")
+    @OperationLog(module = "酒店订单", type = OperationType.QUERY, desc = "查询暂停接单状态")
+    public RespInfo<Boolean> getPauseStatus() {
+        return RespInfo.success(pauseService.isPaused());
+    }
+
+    /**
+     * 切换暂停接单状态。
+     *
+     * @return 切换后的状态(true=已暂停,false=已恢复)
+     */
+    @PostMapping("/pause/toggle")
+    @OperationLog(module = "酒店订单", type = OperationType.UPDATE, desc = "切换暂停接单状态")
+    public RespInfo<Boolean> togglePause() {
+        return RespInfo.success(pauseService.togglePause());
+    }
 }

+ 6 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/domain/HotelOrderItem.java

@@ -41,6 +41,12 @@ public class HotelOrderItem extends TenantEntity {
     /** 加料描述(冗余) */
     private String additionsDesc;
 
+    /** 规格选项ID列表(逗号分隔,用于后端重算加价) */
+    private String specOptionIds;
+
+    /** 加料ID列表(逗号分隔,用于后端重算加价) */
+    private String additionIds;
+
     /** 数量 */
     private Integer quantity;
 

+ 4 - 4
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/dto/OrderCreateDTO.java

@@ -3,11 +3,14 @@ package com.mdframe.forge.business.core.hotel.order.dto;
 import com.mdframe.forge.business.core.hotel.order.domain.HotelOrderItem;
 import lombok.Data;
 
-import java.math.BigDecimal;
 import java.util.List;
 
 /**
  * 创建订单请求DTO。
+ * <p>
+ * 本 DTO 走免登录的 /hotel/open/** 接口,因此**不得包含任何金额类字段**:
+ * 菜品单价、规格/加料加价、配送费全部由服务端从数据库重算,
+ * 前端传值一律不采信(参见《酒店模块需求缺口清单》3.1 / 3.2)。
  */
 @Data
 public class OrderCreateDTO {
@@ -24,9 +27,6 @@ public class OrderCreateDTO {
     /** 联系电话 */
     private String contactPhone;
 
-    /** 配送费(元) */
-    private BigDecimal deliveryFee;
-
     /** 配送时间类型(ASAP=立即送达, CUSTOM=自定义) */
     private String deliveryTimeType;
 

+ 216 - 58
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/service/impl/HotelOrderServiceImpl.java

@@ -1,12 +1,20 @@
 package com.mdframe.forge.business.core.hotel.order.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.core.toolkit.IdWorker;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.mdframe.forge.business.core.hotel.constant.HotelConfigKeys;
 import com.mdframe.forge.business.core.hotel.domain.HotelDish;
+import com.mdframe.forge.business.core.hotel.domain.HotelDishAddition;
+import com.mdframe.forge.business.core.hotel.domain.HotelDishSpecOption;
 import com.mdframe.forge.business.core.hotel.domain.HotelRoom;
 import com.mdframe.forge.business.core.hotel.domain.HotelRoomStay;
+import com.mdframe.forge.business.core.hotel.mapper.HotelConfigMapper;
+import com.mdframe.forge.business.core.hotel.mapper.HotelDishAdditionMapper;
 import com.mdframe.forge.business.core.hotel.mapper.HotelDishMapper;
+import com.mdframe.forge.business.core.hotel.mapper.HotelDishSpecOptionMapper;
 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.order.constant.HotelOrderConstants;
@@ -33,10 +41,12 @@ import org.springframework.util.StringUtils;
 import java.math.BigDecimal;
 import java.text.SimpleDateFormat;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Calendar;
 import java.util.Date;
 import java.util.List;
 import java.util.Objects;
+import java.util.stream.Collectors;
 
 /**
  * 酒店订单服务实现。
@@ -55,11 +65,20 @@ public class HotelOrderServiceImpl implements HotelOrderService {
     private HotelDishMapper dishMapper;
 
     @Autowired
+    private HotelDishSpecOptionMapper specOptionMapper;
+
+    @Autowired
+    private HotelDishAdditionMapper additionMapper;
+
+    @Autowired
     private HotelRoomMapper roomMapper;
 
     @Autowired
     private HotelRoomStayMapper roomStayMapper;
 
+    @Autowired
+    private HotelConfigMapper configMapper;
+
     /**
      * 支付服务(单向依赖:HotelPayService 不注入本服务,不构成循环)。
      * 用于拒单/退单流转时自动发起退款。
@@ -146,7 +165,9 @@ public class HotelOrderServiceImpl implements HotelOrderService {
         }
         order.setContactName(dto.getContactName());
         order.setContactPhone(dto.getContactPhone());
-        order.setDeliveryFee(dto.getDeliveryFee() != null ? dto.getDeliveryFee() : BigDecimal.ZERO);
+        // 配送费一律取服务端配置,不接受前端传值:本接口免登录,
+        // 前端可传负数抵扣菜金或传巨额篡改订单金额
+        order.setDeliveryFee(resolveDeliveryFee(tenantId));
         order.setDeliveryTimeType(dto.getDeliveryTimeType());
         // 前端传 HH:mm 字符串(如"18:30"),拼接今天日期转为 Date 存入数据库
         if (StringUtils.hasText(dto.getCustomDeliveryTime())) {
@@ -163,7 +184,7 @@ public class HotelOrderServiceImpl implements HotelOrderService {
         order.setPayExpireTime(addMinutes(new Date(), HotelOrderConstants.PAY_TIMEOUT_MINUTES));
         order.setTenantId(tenantId);
 
-        // 计算总金额(从数据库查菜品单价,不信任前端传的价格)
+        // 计算总金额(从数据库查菜品单价+规格加价+加料加价,不信任前端传的价格)
         BigDecimal totalAmount = BigDecimal.ZERO;
         for (HotelOrderItem item : dto.getItems()) {
             BigDecimal unitPrice = BigDecimal.ZERO;
@@ -173,6 +194,29 @@ public class HotelOrderServiceImpl implements HotelOrderService {
                     unitPrice = dish.getPrice();
                 }
             }
+
+            // 加上规格选项加价(按ID从数据库查 priceExtra)
+            if (StringUtils.hasText(item.getSpecOptionIds())) {
+                List<Long> specOptionIdList = parseIdList(item.getSpecOptionIds());
+                for (Long optId : specOptionIdList) {
+                    HotelDishSpecOption opt = specOptionMapper.selectById(optId);
+                    if (opt != null && opt.getPriceExtra() != null) {
+                        unitPrice = unitPrice.add(opt.getPriceExtra());
+                    }
+                }
+            }
+
+            // 加上加料加价(按ID从数据库查 extraPrice)
+            if (StringUtils.hasText(item.getAdditionIds())) {
+                List<Long> additionIdList = parseIdList(item.getAdditionIds());
+                for (Long addId : additionIdList) {
+                    HotelDishAddition add = additionMapper.selectById(addId);
+                    if (add != null && add.getExtraPrice() != null) {
+                        unitPrice = unitPrice.add(add.getExtraPrice());
+                    }
+                }
+            }
+
             int quantity = item.getQuantity() != null ? item.getQuantity() : 0;
             BigDecimal subtotal = unitPrice.multiply(new BigDecimal(quantity));
             item.setUnitPrice(unitPrice);
@@ -185,9 +229,10 @@ public class HotelOrderServiceImpl implements HotelOrderService {
         orderMapper.insert(order);
         log.info("创建订单(待支付): {}, 房间: {}, 金额: {}, 超时: {}", order.getOrderNo(), order.getRoomNo(), totalAmount, order.getPayExpireTime());
 
-        // 保存订单明细
+        // 保存订单明细(自定义 XML batchInsert 不走 MP 的 ASSIGN_ID,需手动生成雪花 ID)
         List<HotelOrderItem> itemsToInsert = new ArrayList<>();
         for (HotelOrderItem item : dto.getItems()) {
+            item.setId(IdWorker.getId());
             item.setOrderId(order.getId());
             item.setTenantId(tenantId);
             item.setDelFlag(0L);
@@ -236,18 +281,23 @@ public class HotelOrderServiceImpl implements HotelOrderService {
     }
 
     @Override
-    @Transactional(rollbackFor = Exception.class)
     public void orderReject(Long id, String reason) {
         HotelOrder order = requireOrder(id);
         if (order.getStatus() != HotelOrderConstants.STATUS_PLACED) {
             throw new BusinessException("只有待接单的订单才能拒单");
         }
-        order.setStatus(HotelOrderConstants.STATUS_REJECTED);
-        order.setRejectReason(reason);
-        order.setRefundTime(new Date());
-        orderMapper.updateById(order);
-        // 拒单触发点:已支付订单自动全额退款(退款失败不影响拒单流转,标记重试)
-        tryAutoRefund(order);
+        // 先退款再落终态:退款失败会抛出异常,订单保持"待接单",前台可稍后重试
+        refundBeforeClose(order);
+        Date now = new Date();
+        // 退款成功后 refund_status/pay_status/refund_amount/refund_trade_no 已由退款服务写库,
+        // 此处必须用 UpdateWrapper 精确更新,禁止 updateById(order) 用内存旧值覆盖回去
+        UpdateWrapper<HotelOrder> updateWrapper = new UpdateWrapper<HotelOrder>();
+        updateWrapper.eq("id", order.getId())
+                .set("status", HotelOrderConstants.STATUS_REJECTED)
+                .set("reject_reason", reason)
+                .set("refund_time", now)
+                .set("update_time", now);
+        orderMapper.update(null, updateWrapper);
         log.info("订单拒单: {}", order.getOrderNo());
     }
 
@@ -310,29 +360,37 @@ public class HotelOrderServiceImpl implements HotelOrderService {
     // ==================== 退单相关 ====================
 
     @Override
-    @Transactional(rollbackFor = Exception.class)
     public void customerRefund(Long id, String reason) {
         HotelOrder order = requireOrder(id);
         int status = order.getStatus();
 
         if (status == HotelOrderConstants.STATUS_PENDING_PAY) {
-            // 待支付:直接取消订单
-            order.setStatus(HotelOrderConstants.STATUS_PAY_TIMEOUT);
-            order.setPayStatus(HotelOrderConstants.PAY_STATUS_TIMEOUT);
-            order.setRefundReason(reason);
-            order.setRefundInitiator(HotelOrderConstants.REFUND_INITIATOR_CUSTOMER);
-            order.setRefundTime(new Date());
-            orderMapper.updateById(order);
+            // 待支付:无款可退,语义是「取消订单」而不是「退单」。
+            // 置 11已取消(与前台取消、退房联动保持一致),pay_status 保持 0(从未支付),
+            // 不写 refund_time(未发生资金流转);
+            // 旧的 10超时取消 属于错误语义:10 专属于超时任务,人为取消用 10
+            // 会让顾客看到「支付超时已取消」,与前端提示的「订单已取消」不一致
+            UpdateWrapper<HotelOrder> cancelWrapper = new UpdateWrapper<HotelOrder>();
+            cancelWrapper.eq("id", order.getId())
+                    .set("status", HotelOrderConstants.STATUS_CANCELLED)
+                    .set("refund_reason", reason)
+                    .set("refund_initiator", HotelOrderConstants.REFUND_INITIATOR_CUSTOMER)
+                    .set("update_time", new Date());
+            orderMapper.update(null, cancelWrapper);
             log.info("客户取消待支付订单: {}", order.getOrderNo());
         } else if (status == HotelOrderConstants.STATUS_PLACED) {
-            // 未接单:直接退单(此时订单已支付,需同步退款)
-            order.setStatus(HotelOrderConstants.STATUS_REFUNDED);
-            order.setRefundReason(reason);
-            order.setRefundInitiator(HotelOrderConstants.REFUND_INITIATOR_CUSTOMER);
-            order.setRefundTime(new Date());
-            orderMapper.updateById(order);
-            // 顾客自助退单触发点:待接单订单已支付,自动全额退款
-            tryAutoRefund(order);
+            // 未接单:订单已支付,必须先退款成功才允许置为已退单;
+            // 退款失败抛出异常,订单保持"待接单",避免钱没退回却显示已退单
+            refundBeforeClose(order);
+            Date now = new Date();
+            UpdateWrapper<HotelOrder> updateWrapper = new UpdateWrapper<HotelOrder>();
+            updateWrapper.eq("id", order.getId())
+                    .set("status", HotelOrderConstants.STATUS_REFUNDED)
+                    .set("refund_reason", reason)
+                    .set("refund_initiator", HotelOrderConstants.REFUND_INITIATOR_CUSTOMER)
+                    .set("refund_time", now)
+                    .set("update_time", now);
+            orderMapper.update(null, updateWrapper);
             log.info("客户自助退单: {}", order.getOrderNo());
         } else if (status == HotelOrderConstants.STATUS_ACCEPTED) {
             // 已接单:进入退单审核
@@ -364,51 +422,82 @@ public class HotelOrderServiceImpl implements HotelOrderService {
     }
 
     @Override
-    @Transactional(rollbackFor = Exception.class)
     public void hotelRefund(Long id, String reason) {
         HotelOrder order = requireOrder(id);
-        if (order.getStatus() == HotelOrderConstants.STATUS_COMPLETED
-                || order.getStatus() == HotelOrderConstants.STATUS_REFUNDED) {
-            throw new BusinessException("已完成或已退单的订单不能退单");
-        }
-        order.setStatus(HotelOrderConstants.STATUS_REFUNDED);
-        order.setRefundReason(reason);
-        order.setRefundInitiator(HotelOrderConstants.REFUND_INITIATOR_HOTEL_DESK);
-        order.setRefundTime(new Date());
-        orderMapper.updateById(order);
-        // 前台退单触发点:已支付订单(接单~配送中)自动全额退款;
-        // 待支付订单无资金可退,tryAutoRefund 内部会跳过
-        tryAutoRefund(order);
+        int status = order.getStatus();
+        // 终态订单一律不得再退单:否则已拒单/已取消的订单会被错误改写成已退单
+        if (status == HotelOrderConstants.STATUS_COMPLETED
+                || status == HotelOrderConstants.STATUS_REJECTED
+                || status == HotelOrderConstants.STATUS_REFUNDED
+                || status == HotelOrderConstants.STATUS_PAY_TIMEOUT
+                || status == HotelOrderConstants.STATUS_CANCELLED) {
+            throw new BusinessException("当前订单状态不支持退单");
+        }
+
+        Date now = new Date();
+        if (status == HotelOrderConstants.STATUS_PENDING_PAY) {
+            // 待支付订单无款可退,语义是「取消订单」而不是「退单」:
+            // 置 11已取消,pay_status 保持 0(从未支付),不写 refund_time(未发生资金流转),
+            // 与退房联动 cancelPendingByStayId 的写法保持一致
+            UpdateWrapper<HotelOrder> cancelWrapper = new UpdateWrapper<HotelOrder>();
+            cancelWrapper.eq("id", order.getId())
+                    .set("status", HotelOrderConstants.STATUS_CANCELLED)
+                    .set("refund_reason", reason)
+                    .set("refund_initiator", HotelOrderConstants.REFUND_INITIATOR_HOTEL_DESK)
+                    .set("update_time", now);
+            orderMapper.update(null, cancelWrapper);
+            log.info("前台取消待支付订单: {}", order.getOrderNo());
+            return;
+        }
+
+        // 已支付订单(待接单~配送中、退单待审核)必须先退款成功才允许置为已退单;
+        // 退款失败会抛出异常,订单保持原状态,前台可用手动退款按钮重试
+        refundBeforeClose(order);
+        UpdateWrapper<HotelOrder> updateWrapper = new UpdateWrapper<HotelOrder>();
+        updateWrapper.eq("id", order.getId())
+                .set("status", HotelOrderConstants.STATUS_REFUNDED)
+                .set("refund_reason", reason)
+                .set("refund_initiator", HotelOrderConstants.REFUND_INITIATOR_HOTEL_DESK)
+                .set("refund_time", now)
+                .set("update_time", now);
+        orderMapper.update(null, updateWrapper);
         log.info("前台退单: {}", order.getOrderNo());
     }
 
     @Override
-    @Transactional(rollbackFor = Exception.class)
     public void approveRefund(Long id, String reason) {
         HotelOrder order = requireOrder(id);
         if (order.getStatus() != HotelOrderConstants.STATUS_REFUND_REQUESTED) {
             throw new BusinessException("只有退单待审核状态的订单才能审核");
         }
-        order.setStatus(HotelOrderConstants.STATUS_REFUNDED);
-        order.setRefundTime(new Date());
-        orderMapper.updateById(order);
-        // 退单审核通过触发点:顾客已支付的订单自动全额退款
-        tryAutoRefund(order);
+        // 同意退单必须先把钱退成功,否则订单保持"退单待审核",
+        // 前台可修正问题后重新同意,或用手动退款按钮重试
+        refundBeforeClose(order);
+        Date now = new Date();
+        UpdateWrapper<HotelOrder> updateWrapper = new UpdateWrapper<HotelOrder>();
+        updateWrapper.eq("id", order.getId())
+                .set("status", HotelOrderConstants.STATUS_REFUNDED)
+                .set("refund_time", now)
+                .set("update_time", now);
+        orderMapper.update(null, updateWrapper);
         log.info("退单审核通过: {}", order.getOrderNo());
     }
 
     @Override
-    @Transactional(rollbackFor = Exception.class)
     public void rejectRefund(Long id, String reason) {
         HotelOrder order = requireOrder(id);
         if (order.getStatus() != HotelOrderConstants.STATUS_REFUND_REQUESTED) {
             throw new BusinessException("只有退单待审核状态的订单才能审核");
         }
-        // 驳回后恢复为已接单状态
-        order.setStatus(HotelOrderConstants.STATUS_ACCEPTED);
-        order.setRefundReason(null);
-        order.setRefundInitiator(null);
-        orderMapper.updateById(order);
+        // 驳回后恢复为已接单状态;退单原因/发起方需要真正清空,
+        // updateById 的 NOT_NULL 策略会忽略 null 字段导致清不掉,改用 UpdateWrapper
+        UpdateWrapper<HotelOrder> updateWrapper = new UpdateWrapper<HotelOrder>();
+        updateWrapper.eq("id", order.getId())
+                .set("status", HotelOrderConstants.STATUS_ACCEPTED)
+                .set("refund_reason", null)
+                .set("refund_initiator", null)
+                .set("update_time", new Date());
+        orderMapper.update(null, updateWrapper);
         log.info("退单审核驳回: {}", order.getOrderNo());
     }
 
@@ -438,23 +527,33 @@ public class HotelOrderServiceImpl implements HotelOrderService {
     // ==================== 私有方法 ====================
 
     /**
-     * 已支付订单流转到拒单/退单终态时自动发起全额退款。
+     * 订单流转拒单/退单终态之前的退款动作。
+     * <p>
+     * 与旧的"先改状态再退款并吞掉异常"相反:退款失败必须抛出异常阻断状态流转,
+     * 让订单保持原状态(待接单/退单待审核等),前台可用手动退款按钮重试,
+     * 否则会出现"钱没退回,订单却显示已退单"的资损与客诉。
      * <p>
-     * 退款失败不阻断状态流转(退款服务内部已将 refund_status 置为2可重试),
-     * 前台可通过手动退款按钮重新发起。
+     * 本方法及其调用方均不加 {@code @Transactional}:退款服务需要把失败标记
+     * (refund_status=2)在抛异常后依然落库,外层事务会把它一起回滚掉
      *
-     * @param order 订单(状态已更新为拒单/退单
+     * @param order 订单(状态尚未变更
      */
-    private void tryAutoRefund(HotelOrder order) {
-        // 仅已支付订单需要退款;未支付/已退款的直接跳过
+    private void refundBeforeClose(HotelOrder order) {
+        // 仅已支付订单需要退款;未支付/支付超时的直接跳过
         if (order.getPayStatus() == null || order.getPayStatus() != HotelOrderConstants.PAY_STATUS_SUCCESS) {
             return;
         }
+        // 已退款成功(例如先前手动退款重试成功)不再重复发起,避免支付宝重复退款报错
+        if (order.getRefundStatus() != null
+                && order.getRefundStatus() == HotelOrderConstants.REFUND_STATUS_SUCCESS) {
+            return;
+        }
         try {
             payService.refundOrder(order.getId());
         } catch (Exception e) {
-            log.error("拒单/退单自动退款失败,请通过手动退款重试: orderId={}, orderNo={}",
+            log.error("退款失败,订单保持原状态待重试: orderId={}, orderNo={}",
                     order.getId(), order.getOrderNo(), e);
+            throw new BusinessException(HotelOrderConstants.REFUND_FAILED_MSG_PREFIX + ":" + e.getMessage());
         }
     }
 
@@ -571,4 +670,63 @@ public class HotelOrderServiceImpl implements HotelOrderService {
         cal.add(Calendar.MINUTE, minutes);
         return cal.getTime();
     }
+
+    /**
+     * 从服务端配置解析配送费。
+     * <p>
+     * 取值优先级:hotel_config 的 delivery_fee -> 缺省 0。配置行缺失或值非法时
+     * 按 0 处理并告警,与未引入配置前的实际行为(H5 恒传 0)保持一致,
+     * 不会因为一次误配置直接阻断下单。负数归零、超上限截断,防误配置放大成异常订单金额。
+     *
+     * @param tenantId 租户ID
+     * @return 非负配送费
+     */
+    private BigDecimal resolveDeliveryFee(Long tenantId) {
+        String raw = configMapper.selectValueByKey(tenantId, HotelConfigKeys.DELIVERY_FEE);
+        if (!StringUtils.hasText(raw)) {
+            log.warn("hotel_config 缺少 {} 配置(tenantId={}),本单配送费按 0 处理", HotelConfigKeys.DELIVERY_FEE, tenantId);
+            return HotelConfigKeys.DELIVERY_FEE_DEFAULT;
+        }
+        BigDecimal fee;
+        try {
+            fee = new BigDecimal(raw.trim());
+        } catch (NumberFormatException e) {
+            log.warn("hotel_config 的 {} 值非法: {}(tenantId={}),本单配送费按 0 处理",
+                    HotelConfigKeys.DELIVERY_FEE, raw, tenantId);
+            return HotelConfigKeys.DELIVERY_FEE_DEFAULT;
+        }
+        if (fee.signum() < 0) {
+            log.warn("hotel_config 的 {} 为负数: {}(tenantId={}),已归零", HotelConfigKeys.DELIVERY_FEE, raw, tenantId);
+            return HotelConfigKeys.DELIVERY_FEE_DEFAULT;
+        }
+        if (fee.compareTo(HotelConfigKeys.DELIVERY_FEE_MAX) > 0) {
+            log.warn("hotel_config 的 {} 超出上限: {}(tenantId={}),已按上限 {} 截断",
+                    HotelConfigKeys.DELIVERY_FEE, raw, tenantId, HotelConfigKeys.DELIVERY_FEE_MAX);
+            return HotelConfigKeys.DELIVERY_FEE_MAX;
+        }
+        return fee;
+    }
+
+    /**
+     * 解析逗号分隔的ID列表字符串为Long列表。
+     * 忽略空值和非法数字,防止前端传入脏数据导致异常。
+     */
+    private List<Long> parseIdList(String idStr) {
+        if (!StringUtils.hasText(idStr)) {
+            return List.of();
+        }
+        return Arrays.stream(idStr.split(","))
+                .map(String::trim)
+                .filter(s -> !s.isEmpty())
+                .map(s -> {
+                    try {
+                        return Long.parseLong(s);
+                    } catch (NumberFormatException e) {
+                        log.warn("解析ID列表跳过非法值: {}", s);
+                        return null;
+                    }
+                })
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+    }
 }

+ 226 - 24
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/impl/HotelPayServiceImpl.java

@@ -1,9 +1,11 @@
 package com.mdframe.forge.business.core.hotel.pay.service.impl;
 
 import com.alipay.api.AlipayClient;
+import com.alipay.api.request.AlipayTradeFastpayRefundQueryRequest;
 import com.alipay.api.request.AlipayTradeQueryRequest;
 import com.alipay.api.request.AlipayTradeRefundRequest;
 import com.alipay.api.request.AlipayTradeWapPayRequest;
+import com.alipay.api.response.AlipayTradeFastpayRefundQueryResponse;
 import com.alipay.api.response.AlipayTradeQueryResponse;
 import com.alipay.api.response.AlipayTradeRefundResponse;
 import com.alipay.api.response.AlipayTradeWapPayResponse;
@@ -62,6 +64,24 @@ public class HotelPayServiceImpl implements HotelPayService {
 
     private static final SimpleDateFormat FMT = new SimpleDateFormat("yyyyMMddHHmmss");
 
+    /**
+     * hotel_pay_log.pay_status 的「已退款」值。
+     * <p>
+     * 注意与 hotel_order.pay_status 语义不同:订单表 2=已退款、3=支付超时;
+     * 流水表 2=支付失败、3=已退款(见 {@link HotelPayLog} 字段注释)。禁止混用常量。
+     */
+    private static final int PAY_LOG_STATUS_REFUNDED = 3;
+
+    /**
+     * 全额退款的 out_request_no 固定后缀。
+     * <p>
+     * out_request_no 是支付宝退款的幂等键:同一值重复请求会直接返回首次结果(fund_change=N),
+     * 不同值去请求已全额退完的交易则返回 ACQ.TRADE_HAS_CLOSE「交易已经关闭」。
+     * 因此该值必须稳定可重放,禁止拼接时间戳 —— 否则重试永远命不中幂等,
+     * 钱其实早已退回,系统却会一直判定为退款失败。
+     */
+    private static final String REFUND_REQUEST_NO_SUFFIX = "RF";
+
     // ==================== 发起支付 ====================
 
     @Override
@@ -238,10 +258,10 @@ public class HotelPayServiceImpl implements HotelPayService {
 
         if (success) {
             // 安全校验 3:退房/取消与支付的竞态防护。
-            // 场景:客人在支付宝收银台付款期间,前台退房把待支付订单取消了
+            // 场景:客人在支付宝收银台付款期间,前台退房/取消把待支付订单置为终态
             // 随后支付成功回调到达;此时绝不能把已取消/已退单的订单"复活"成待接单,
             // 否则出现"钱已扣、订单却已取消、会话已关闭"的流程断裂。
-            // 处理:不更新订单状态,记录异常日志,由前台通过手动退款接口处理(极低频场景
+            // 处理:不复活订单,转为如实记账 + 自动退款(见 handleRaceConditionPayment
             HotelOrder callbackOrder = orderMapper.selectById(payLog.getOrderId());
             if (callbackOrder != null) {
                 int orderStatus = callbackOrder.getStatus() != null ? callbackOrder.getStatus() : -1;
@@ -249,14 +269,7 @@ public class HotelPayServiceImpl implements HotelPayService {
                         || orderStatus == HotelOrderConstants.STATUS_REFUNDED
                         || orderStatus == HotelOrderConstants.STATUS_PAY_TIMEOUT
                         || orderStatus == HotelOrderConstants.STATUS_CANCELLED) {
-                    log.error("支付回调竞态异常: 订单已处于终态仍收到支付成功回调,不复活订单,请人工退款处理。"
-                                    + " orderId={}, orderNo={}, orderStatus={}, outTradeNo={}, tradeNo={}",
-                            callbackOrder.getId(), callbackOrder.getOrderNo(), orderStatus,
-                            payLog.getOutTradeNo(), tradeNo);
-                    payLog.setPayStatus(2);
-                    payLog.setNotifyTime(new Date());
-                    payLog.setNotifyContent("[竞态异常-订单已取消] " + rawContent);
-                    payLogMapper.updateById(payLog);
+                    handleRaceConditionPayment(callbackOrder, payLog, tradeNo, rawContent);
                     return;
                 }
             }
@@ -319,6 +332,78 @@ public class HotelPayServiceImpl implements HotelPayService {
         }
     }
 
+    /**
+     * 支付回调竞态处理:订单已处于终态,但客人的钱已经扣了。
+     * <p>
+     * 旧实现只把流水记为「2支付失败」并打一条 error 日志等人工处理,存在三个问题:
+     * <ol>
+     *   <li>账务失真 —— 支付宝确实扣款成功,流水却记为支付失败,对账时这笔钱查不到;</li>
+     *   <li>订单无痕 —— 订单 pay_status 仍为 0,pay_trade_no/paid_amount 全空,而前台的
+     *       「退款」按钮要求 payStatus=1 才显示,导致这笔钱根本无从退起;</li>
+     *   <li>依赖人工 —— 没有自动退款,客人要等前台翻日志发现后手动操作。</li>
+     * </ol>
+     * 现改为:如实记账(流水=支付成功、订单=已支付但状态不复活)→ 先置 refund_status=2可重试
+     * → 立即尝试自动退款。退款成功即闭环;失败则保留可重试标记,前台能看到退款按钮接手。
+     * <p>
+     * 幂等:支付宝会重试通知,流水已是支付成功/已退款说明本次竞态已处理过,直接返回。
+     *
+     * @param order      已处于终态的订单
+     * @param payLog     支付流水
+     * @param tradeNo    第三方交易号
+     * @param rawContent 回调原文
+     */
+    private void handleRaceConditionPayment(HotelOrder order, HotelPayLog payLog,
+                                            String tradeNo, String rawContent) {
+        Integer logPayStatus = payLog.getPayStatus();
+        if (logPayStatus != null
+                && (logPayStatus == HotelOrderConstants.PAY_STATUS_SUCCESS
+                || logPayStatus == PAY_LOG_STATUS_REFUNDED)) {
+            log.info("支付回调竞态: 已处理过(幂等), outTradeNo={}", payLog.getOutTradeNo());
+            return;
+        }
+
+        int orderStatus = order.getStatus() != null ? order.getStatus() : -1;
+        BigDecimal paidAmount = payLog.getPayAmount();
+        log.error("支付回调竞态异常: 订单已处于终态仍收到支付成功回调,不复活订单,转为自动退款。"
+                        + " orderId={}, orderNo={}, orderStatus={}, outTradeNo={}, tradeNo={}, amount={}",
+                order.getId(), order.getOrderNo(), orderStatus,
+                payLog.getOutTradeNo(), tradeNo, paidAmount);
+
+        Date now = new Date();
+
+        // 1. 流水如实记账:钱确实收到了,必须记为支付成功,否则这笔款在账务上无从追踪
+        payLog.setPayStatus(HotelOrderConstants.PAY_STATUS_SUCCESS);
+        payLog.setTradeNo(tradeNo);
+        payLog.setPayTime(now);
+        payLog.setNotifyTime(now);
+        payLog.setNotifyContent("[竞态-订单已终态,转自动退款] " + rawContent);
+        payLogMapper.updateById(payLog);
+
+        // 2. 订单记录收款事实,但 status 保持终态不变(复活会让已取消订单重新进入履约流程);
+        //    refund_status 先置 2可重试,万一自动退款失败,前台的「退款」按钮才会出现
+        UpdateWrapper<HotelOrder> orderWrapper = new UpdateWrapper<HotelOrder>();
+        orderWrapper.eq("id", order.getId())
+                .set("pay_status", HotelOrderConstants.PAY_STATUS_SUCCESS)
+                .set("pay_time", now)
+                .set("pay_trade_no", tradeNo)
+                .set("paid_amount", paidAmount)
+                .set("pay_source", payLog.getPaySource())
+                .set("refund_status", HotelOrderConstants.REFUND_STATUS_FAILED)
+                .set("update_time", now);
+        orderMapper.update(null, orderWrapper);
+
+        // 3. 自动退款。异常必须吞掉:外层 handlePayCallback 带 @Transactional,
+        //    一旦异常传出会把上面两步记账一起回滚,退回到「钱扣了、系统无任何痕迹」的旧状态
+        try {
+            refundOrder(order.getId());
+            log.info("竞态支付已自动退款: orderId={}, orderNo={}, amount={}",
+                    order.getId(), order.getOrderNo(), paidAmount);
+        } catch (Exception e) {
+            log.error("竞态支付自动退款失败,已保留 refund_status=2 待前台手动重试: orderId={}, orderNo={}",
+                    order.getId(), order.getOrderNo(), e);
+        }
+    }
+
     // ==================== 查询支付状态 ====================
 
     @Override
@@ -440,6 +525,9 @@ public class HotelPayServiceImpl implements HotelPayService {
      * 2. 前台手动退款(退房竞态等异常场景,权限 hotel:order:refund)。
      * 注意:本方法不加事务,确保退款失败标记(refund_status=2)在抛出异常后仍被持久化,
      * 便于前台通过手动退款按钮重试。
+     * <p>
+     * 幂等与自愈:out_request_no 固定不变,重试可命中支付宝幂等;退款被拒或调用异常时
+     * 先走 {@link #reconcileRefund} 对账,避免「钱已退回、本地记为失败」的资损误判。
      *
      * @param orderId 订单ID
      */
@@ -469,17 +557,18 @@ public class HotelPayServiceImpl implements HotelPayService {
         if (HotelOrderConstants.PAY_SOURCE_MOCK.equals(order.getPaySource())
                 || payLog == null
                 || !"ALIPAY".equals(payLog.getPayChannel())) {
-            markRefundResult(order, refundAmount, "MOCK_REFUND_" + orderId, true);
+            markRefundSuccess(order, payLog, refundAmount, "MOCK_REFUND_" + orderId, new Date());
             log.info("MOCK退款成功(无真实资金): orderId={}, orderNo={}, amount={}",
                     orderId, order.getOrderNo(), refundAmount);
             return;
         }
 
+        // 退款请求号必须稳定:重试时才能命中支付宝幂等,直接拿回首次退款结果
+        String outRequestNo = payLog.getOutTradeNo() + REFUND_REQUEST_NO_SUFFIX;
+
         // 调用支付宝退款接口 alipay.trade.refund(全额退款)
         try {
             AlipayTradeRefundRequest request = new AlipayTradeRefundRequest();
-            // out_request_no 退款请求号:同一笔交易多次退款时必须唯一,用流水号+时间戳保证
-            String outRequestNo = payLog.getOutTradeNo() + "R" + System.currentTimeMillis();
             String bizContent = "{\"out_trade_no\":\"" + payLog.getOutTradeNo() + "\","
                     + "\"refund_amount\":\"" + refundAmount.toPlainString() + "\","
                     + "\"out_request_no\":\"" + outRequestNo + "\","
@@ -488,22 +577,34 @@ public class HotelPayServiceImpl implements HotelPayService {
             AlipayTradeRefundResponse response = alipayClient.execute(request);
 
             if (response.isSuccess()) {
-                // 退款成功:退款交易号取支付宝返回的 trade_no
-                markRefundResult(order, refundAmount, response.getTradeNo(), true);
-                log.info("支付宝退款成功: orderId={}, orderNo={}, amount={}, tradeNo={}",
-                        orderId, order.getOrderNo(), refundAmount, response.getTradeNo());
-            } else {
-                // 退款失败:标记可重试状态,前台可通过手动退款按钮重新发起
-                markRefundResult(order, refundAmount, null, false);
-                log.error("支付宝退款失败: orderId={}, orderNo={}, code={}, msg={}",
-                        orderId, order.getOrderNo(), response.getCode(), response.getSubMsg());
-                throw new BusinessException("退款失败:" + response.getSubMsg() + ",可稍后重试");
+                // code=10000:首次退款成功(fund_change=Y),或幂等重放(fund_change=N,此前已退成功)
+                Date refundTime = response.getGmtRefundPay() != null ? response.getGmtRefundPay() : new Date();
+                markRefundSuccess(order, payLog, refundAmount, response.getTradeNo(), refundTime);
+                log.info("支付宝退款成功: orderId={}, orderNo={}, amount={}, tradeNo={}, fundChange={}",
+                        orderId, order.getOrderNo(), refundAmount, response.getTradeNo(), response.getFundChange());
+                return;
             }
+
+            // 退款被拒(典型为 ACQ.TRADE_HAS_CLOSE 交易已关闭):已支付订单被关闭只可能是
+            // 此前已全额退款,必须先对账确认,不能直接判失败
+            log.error("支付宝退款失败: orderId={}, orderNo={}, code={}, subCode={}, subMsg={}",
+                    orderId, order.getOrderNo(), response.getCode(), response.getSubCode(), response.getSubMsg());
+            if (reconcileRefund(order, payLog, refundAmount, outRequestNo)) {
+                return;
+            }
+            // 已确认支付宝侧未退款:标记可重试状态,前台可通过手动退款按钮重新发起
+            markRefundResult(order, refundAmount, null, false);
+            throw new BusinessException("退款失败:" + response.getSubMsg() + ",可稍后重试");
         } catch (BusinessException e) {
             throw e;
         } catch (Exception e) {
-            markRefundResult(order, refundAmount, null, false);
+            // 网络超时、响应验签失败等异常:请求可能已到达支付宝并退款成功
+            // (历史上支付宝公钥配错即属此类),同样先对账再判失败
             log.error("调用支付宝退款接口异常: orderId={}, orderNo={}", orderId, order.getOrderNo(), e);
+            if (reconcileRefund(order, payLog, refundAmount, outRequestNo)) {
+                return;
+            }
+            markRefundResult(order, refundAmount, null, false);
             throw new BusinessException("调用支付宝退款异常:" + e.getMessage() + ",可稍后重试");
         }
     }
@@ -534,6 +635,107 @@ public class HotelPayServiceImpl implements HotelPayService {
         orderMapper.update(null, updateWrapper);
     }
 
+    /**
+     * 标记退款成功:订单与支付流水两侧同步落库,保证资金账务可对账审计。
+     *
+     * @param order         订单
+     * @param payLog        支付流水(MOCK 无流水场景可为 null)
+     * @param refundAmount  退款金额(元)
+     * @param refundTradeNo 退款交易号
+     * @param refundTime    退款时间
+     */
+    private void markRefundSuccess(HotelOrder order, HotelPayLog payLog, BigDecimal refundAmount,
+                                   String refundTradeNo, Date refundTime) {
+        markRefundResult(order, refundAmount, refundTradeNo, true);
+        if (payLog == null) {
+            return;
+        }
+        // 流水表的 refund_no/refund_time/refund_amount 此前从未写入,
+        // 导致资金流水与订单状态长期不一致、无法对账,退款成功必须同步回写
+        UpdateWrapper<HotelPayLog> logWrapper = new UpdateWrapper<HotelPayLog>();
+        logWrapper.eq("id", payLog.getId())
+                .set("refund_no", refundTradeNo)
+                .set("refund_amount", refundAmount)
+                .set("refund_time", refundTime != null ? refundTime : new Date())
+                .set("pay_status", PAY_LOG_STATUS_REFUNDED);
+        payLogMapper.update(null, logWrapper);
+    }
+
+    /**
+     * 退款结果对账:向支付宝确认这笔钱到底退了没有。
+     * <p>
+     * 用于自愈「支付宝侧实际已退款、本地却记为失败」的场景,两类成因:
+     * 1. 退款请求已成功但响应处理异常(网络超时、响应验签失败,如历史上的支付宝公钥配错);
+     * 2. 重试时支付宝返回 ACQ.TRADE_HAS_CLOSE —— 交易已全额退款并关闭。
+     * <p>
+     * 判定顺序:先按 out_request_no 查退款记录(精确,能拿到实退金额与退款时间);
+     * 查不到再查交易状态兜底 —— 支付宝 TRADE_CLOSED 只有「未付款交易超时关闭」和
+     * 「支付成功后全额退款」两种成因,而本方法仅在订单 pay_status=已支付 时才会被调用,
+     * 故可判定为已全额退款。
+     * <p>
+     * 本方法不抛异常:对账未能确认一律返回 false,由调用方按退款失败处理。
+     *
+     * @param order         订单
+     * @param payLog        支付流水
+     * @param refundAmount  应退金额(元)
+     * @param outRequestNo  退款请求号
+     * @return true=已确认退款成功并完成落库;false=未确认,调用方应标记失败
+     */
+    private boolean reconcileRefund(HotelOrder order, HotelPayLog payLog,
+                                    BigDecimal refundAmount, String outRequestNo) {
+        // 第一步:按退款请求号查退款记录
+        try {
+            AlipayTradeFastpayRefundQueryRequest request = new AlipayTradeFastpayRefundQueryRequest();
+            request.setBizContent("{\"out_trade_no\":\"" + payLog.getOutTradeNo() + "\","
+                    + "\"out_request_no\":\"" + outRequestNo + "\"}");
+            AlipayTradeFastpayRefundQueryResponse response = alipayClient.execute(request);
+            if (response.isSuccess() && response.getRefundAmount() != null
+                    && !response.getRefundAmount().isEmpty()) {
+                BigDecimal actualAmount = new BigDecimal(response.getRefundAmount());
+                if (actualAmount.compareTo(refundAmount) == 0) {
+                    Date refundTime = response.getGmtRefundPay() != null ? response.getGmtRefundPay() : new Date();
+                    markRefundSuccess(order, payLog, refundAmount, response.getTradeNo(), refundTime);
+                    log.warn("退款对账成功(按退款请求号): orderId={}, orderNo={}, outRequestNo={}, amount={}",
+                            order.getId(), order.getOrderNo(), outRequestNo, refundAmount);
+                    return true;
+                }
+                // 金额不一致属于严重账务异常,禁止自动置为成功,必须人工介入
+                log.error("退款对账金额不一致,需人工介入: orderId={}, orderNo={}, 应退={}, 实退={}",
+                        order.getId(), order.getOrderNo(), refundAmount, actualAmount);
+                return false;
+            }
+            log.info("退款对账: 支付宝无该退款请求号记录,转查交易状态, orderId={}, outRequestNo={}",
+                    order.getId(), outRequestNo);
+        } catch (Exception e) {
+            log.error("退款对账查询异常(按退款请求号): orderId={}, orderNo={}",
+                    order.getId(), order.getOrderNo(), e);
+        }
+
+        // 第二步兜底:查交易状态,已支付交易变为 TRADE_CLOSED 即为全额退款完成
+        try {
+            AlipayTradeQueryRequest request = new AlipayTradeQueryRequest();
+            request.setBizContent("{\"out_trade_no\":\"" + payLog.getOutTradeNo() + "\"}");
+            AlipayTradeQueryResponse response = alipayClient.execute(request);
+            if (response.isSuccess()) {
+                if ("TRADE_CLOSED".equals(response.getTradeStatus())) {
+                    markRefundSuccess(order, payLog, refundAmount, response.getTradeNo(), new Date());
+                    log.warn("退款对账成功(交易已关闭,判定为已全额退款): orderId={}, orderNo={}, tradeNo={}, amount={}",
+                            order.getId(), order.getOrderNo(), response.getTradeNo(), refundAmount);
+                    return true;
+                }
+                log.warn("退款对账未确认已退款: orderId={}, orderNo={}, tradeStatus={}",
+                        order.getId(), order.getOrderNo(), response.getTradeStatus());
+            } else {
+                log.warn("退款对账查询交易失败: orderId={}, orderNo={}, subCode={}, subMsg={}",
+                        order.getId(), order.getOrderNo(), response.getSubCode(), response.getSubMsg());
+            }
+        } catch (Exception e) {
+            log.error("退款对账查询异常(按交易状态): orderId={}, orderNo={}",
+                    order.getId(), order.getOrderNo(), e);
+        }
+        return false;
+    }
+
     // ==================== 私有方法 ====================
 
     /**

+ 30 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelOrderPauseService.java

@@ -0,0 +1,30 @@
+package com.mdframe.forge.business.core.hotel.service;
+
+/**
+ * 酒店订单暂停服务接口。
+ * <p>
+ * 用于临时暂停/恢复顾客端点餐功能。
+ */
+public interface HotelOrderPauseService {
+
+    /**
+     * 检查当前是否处于暂停接单状态。
+     *
+     * @return true=暂停中,false=正常营业
+     */
+    boolean isPaused();
+
+    /**
+     * 设置暂停接单状态。
+     *
+     * @param paused true=暂停,false=恢复
+     */
+    void setPaused(boolean paused);
+
+    /**
+     * 切换暂停状态。
+     *
+     * @return 切换后的状态(true=已暂停,false=已恢复)
+     */
+    boolean togglePause();
+}

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

@@ -0,0 +1,82 @@
+package com.mdframe.forge.business.core.hotel.service.impl;
+
+import com.baomidou.mybatisplus.core.toolkit.IdWorker;
+import com.mdframe.forge.business.core.hotel.constant.HotelConfigKeys;
+import com.mdframe.forge.business.core.hotel.mapper.HotelConfigMapper;
+import com.mdframe.forge.business.core.hotel.service.HotelOrderPauseService;
+import com.mdframe.forge.starter.core.session.SessionHelper;
+import com.mdframe.forge.starter.tenant.context.TenantContextHolder;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+/**
+ * 酒店订单暂停服务实现。
+ * <p>
+ * 使用 hotel_config 表持久化暂停状态,支持跨重启保留。
+ * 状态读写全部下沉到 Mapper XML,切换动作由数据库单条语句原子完成。
+ */
+@Slf4j
+@Service
+public class HotelOrderPauseServiceImpl implements HotelOrderPauseService {
+
+    @Autowired
+    private HotelConfigMapper configMapper;
+
+    @Override
+    public boolean isPaused() {
+        Long tenantId = resolveTenantId();
+        String value = configMapper.selectValueByKey(tenantId, HotelConfigKeys.ORDER_PAUSE);
+        if (value == null) {
+            // 配置行缺失时按「未暂停」放行(fail-open):反过来会把一次误删配置
+            // 放大成整店无法下单。缺失本身属于异常,必须告警让运维补回初始化数据。
+            log.warn("[HotelOrderPause] hotel_config 缺少 {} 配置行(tenantId={}),本次按未暂停处理,请检查初始化脚本",
+                    HotelConfigKeys.ORDER_PAUSE, tenantId);
+            return false;
+        }
+        return Boolean.parseBoolean(value.trim());
+    }
+
+    @Override
+    public void setPaused(boolean paused) {
+        Long tenantId = resolveTenantId();
+        // upsert 单条语句完成写入,行缺失时自动补建,并发调用不会丢失更新
+        configMapper.upsertValue(IdWorker.getId(), tenantId, HotelConfigKeys.ORDER_PAUSE,
+                String.valueOf(paused), HotelConfigKeys.ORDER_PAUSE_DESC);
+        log.info("[HotelOrderPause] 暂停接单状态已更新: tenantId={}, {}", tenantId, paused ? "已暂停" : "已恢复");
+    }
+
+    @Override
+    public boolean togglePause() {
+        Long tenantId = resolveTenantId();
+        int rows = configMapper.flipBooleanValue(tenantId, HotelConfigKeys.ORDER_PAUSE);
+        if (rows == 0) {
+            // 配置行缺失:等价于当前是 false,翻转结果为 true,并顺手补建该行
+            configMapper.upsertValue(IdWorker.getId(), tenantId, HotelConfigKeys.ORDER_PAUSE,
+                    Boolean.TRUE.toString(), HotelConfigKeys.ORDER_PAUSE_DESC);
+            log.warn("[HotelOrderPause] hotel_config 缺少 {} 配置行(tenantId={}),已补建并置为暂停",
+                    HotelConfigKeys.ORDER_PAUSE, tenantId);
+            return true;
+        }
+        // 翻转已在库内原子完成,此处仅回读结果用于回显;
+        // 若期间又有并发翻转,回读到的是更新后的合法状态,不会出现丢失更新
+        boolean paused = isPaused();
+        log.info("[HotelOrderPause] 暂停接单状态已切换: tenantId={}, {}", tenantId, paused ? "已暂停" : "已恢复");
+        return paused;
+    }
+
+    /**
+     * 解析当前租户ID。
+     * <p>
+     * 与 HotelOrderServiceImpl 保持一致的三级兜底;开放接口由 Controller 显式
+     * executeWithTenant 注入,管理端接口由登录态上下文提供。
+     */
+    private Long resolveTenantId() {
+        Long tenantId = TenantContextHolder.getTenantId();
+        if (tenantId != null) {
+            return tenantId;
+        }
+        tenantId = SessionHelper.getTenantId();
+        return tenantId == null ? 1L : tenantId;
+    }
+}

+ 31 - 0
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelConfigMapper.xml

@@ -0,0 +1,31 @@
+<?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.HotelConfigMapper">
+
+    <!-- 按配置键取值:命中 uk_tenant_key 唯一索引,最多一行,LIMIT 1 防御历史脏数据 -->
+    <select id="selectValueByKey" resultType="java.lang.String">
+        SELECT config_value
+        FROM hotel_config
+        WHERE tenant_id = #{tenantId}
+          AND config_key = #{configKey}
+        LIMIT 1
+    </select>
+
+    <!-- 写入配置值,行不存在则补建;靠唯一索引兜底幂等,单条语句无并发丢失更新 -->
+    <insert id="upsertValue">
+        INSERT INTO hotel_config (id, tenant_id, config_key, config_value, config_desc, create_time, update_time)
+        VALUES (#{id}, #{tenantId}, #{configKey}, #{configValue}, #{configDesc}, NOW(), NOW())
+        ON DUPLICATE KEY UPDATE config_value = VALUES(config_value),
+                                update_time  = NOW()
+    </insert>
+
+    <!-- 原子翻转布尔值:翻转在库内完成,避免 Java 侧读-改-写被并发覆盖 -->
+    <update id="flipBooleanValue">
+        UPDATE hotel_config
+        SET config_value = CASE WHEN LOWER(config_value) = 'true' THEN 'false' ELSE 'true' END,
+            update_time  = NOW()
+        WHERE tenant_id = #{tenantId}
+          AND config_key = #{configKey}
+    </update>
+
+</mapper>

+ 8 - 3
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/order/HotelOrderItemMapper.xml

@@ -9,6 +9,8 @@
         <result column="dish_name" property="dishName" />
         <result column="spec_desc" property="specDesc" />
         <result column="additions_desc" property="additionsDesc" />
+        <result column="spec_option_ids" property="specOptionIds" />
+        <result column="addition_ids" property="additionIds" />
         <result column="quantity" property="quantity" />
         <result column="unit_price" property="unitPrice" />
         <result column="subtotal" property="subtotal" />
@@ -19,6 +21,7 @@
     <!-- 根据订单ID查询明细 -->
     <select id="selectByOrderId" resultMap="OrderItemMap">
         SELECT id, order_id, dish_id, dish_name, spec_desc, additions_desc,
+               spec_option_ids, addition_ids,
                quantity, unit_price, subtotal, note, create_time
         FROM hotel_order_item
         WHERE del_flag = 0
@@ -26,15 +29,17 @@
         ORDER BY id ASC
     </select>
 
-    <!-- 批量插入订单明细 -->
+    <!-- 批量插入订单明细(id 由 Service 层通过 IdWorker 预生成,自定义 XML 不走 MP 的 ASSIGN_ID) -->
     <insert id="batchInsert">
         INSERT INTO hotel_order_item
-        (order_id, dish_id, dish_name, spec_desc, additions_desc,
+        (id, order_id, dish_id, dish_name, spec_desc, additions_desc,
+         spec_option_ids, addition_ids,
          quantity, unit_price, subtotal, note, tenant_id)
         VALUES
         <foreach collection="list" item="item" separator=",">
-            (#{item.orderId}, #{item.dishId}, #{item.dishName},
+            (#{item.id}, #{item.orderId}, #{item.dishId}, #{item.dishName},
              #{item.specDesc}, #{item.additionsDesc},
+             #{item.specOptionIds}, #{item.additionIds},
              #{item.quantity}, #{item.unitPrice}, #{item.subtotal}, #{item.note},
              #{item.tenantId})
         </foreach>

+ 6 - 6
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/order/HotelOrderMapper.xml

@@ -95,14 +95,14 @@
             SUM(CASE WHEN o.status = 1 THEN 1 ELSE 0 END) AS pendingCount,
             SUM(CASE WHEN o.status = 3 THEN 1 ELSE 0 END) AS preparingCount,
             SUM(CASE WHEN o.status = 5 THEN 1 ELSE 0 END) AS deliveringCount,
-            SUM(CASE WHEN DATE(o.create_time) = CURDATE() AND o.pay_status = 1 AND o.status NOT IN (7, 9, 10) THEN 1 ELSE 0 END) AS todayTotal,
-            IFNULL(SUM(CASE WHEN DATE(o.create_time) = CURDATE() AND o.pay_status = 1 AND o.status NOT IN (7, 9, 10) THEN o.total_amount ELSE 0 END), 0) AS todayRevenue
+            SUM(CASE WHEN DATE(o.create_time) = CURDATE() AND o.pay_status = 1 AND o.status NOT IN (7, 9, 10, 11) THEN 1 ELSE 0 END) AS todayTotal,
+            IFNULL(SUM(CASE WHEN DATE(o.create_time) = CURDATE() AND o.pay_status = 1 AND o.status NOT IN (7, 9, 10, 11) THEN o.total_amount ELSE 0 END), 0) AS todayRevenue
         FROM hotel_order o
         WHERE o.tenant_id = #{tenantId}
           AND o.del_flag = 0
     </select>
 
-    <!-- 统计今日有效订单数(已支付且排除拒单/退单/超时) -->
+    <!-- 统计今日有效订单数(已支付且排除 7已拒单/9已退单/10超时取消/11已取消) -->
     <select id="countTodayOrders" resultType="int">
         SELECT COUNT(*)
         FROM hotel_order
@@ -110,10 +110,10 @@
           AND del_flag = 0
           AND pay_status = 1
           AND DATE(create_time) = CURDATE()
-          AND status NOT IN (7, 9, 10)
+          AND status NOT IN (7, 9, 10, 11)
     </select>
 
-    <!-- 统计今日营业额(已支付且排除拒单/退单/超时) -->
+    <!-- 统计今日营业额(已支付且排除 7已拒单/9已退单/10超时取消/11已取消) -->
     <select id="sumTodayRevenue" resultType="java.math.BigDecimal">
         SELECT IFNULL(SUM(total_amount), 0)
         FROM hotel_order
@@ -121,7 +121,7 @@
           AND del_flag = 0
           AND pay_status = 1
           AND DATE(create_time) = CURDATE()
-          AND status NOT IN (7, 9, 10)
+          AND status NOT IN (7, 9, 10, 11)
     </select>
 
     <!-- 查询当天最大订单号 -->

File diff suppressed because it is too large
+ 92 - 29
forge-server/forge-business/forge-hotel/酒店模块需求缺口清单.md


BIN
var/tmp/AgentLoader.class


+ 0 - 8
var/tmp/AgentLoader.java

@@ -1,8 +0,0 @@
-import com.sun.tools.attach.VirtualMachine;
-public class AgentLoader {
-    public static void main(String[] args) throws Exception {
-        VirtualMachine vm = VirtualMachine.attach(args[0]);
-        try { vm.loadAgent(args[1]); } finally { vm.detach(); }
-        System.out.println("agent loaded");
-    }
-}

BIN
var/tmp/DiagAgent.class


+ 0 - 56
var/tmp/DiagAgent.java

@@ -1,56 +0,0 @@
-import java.io.FileWriter;
-import java.io.PrintWriter;
-import java.lang.instrument.Instrumentation;
-import java.lang.reflect.Field;
-import java.lang.reflect.Method;
-
-public class DiagAgent {
-    public static void agentmain(String args, Instrumentation inst) {
-        try (PrintWriter out = new PrintWriter(new FileWriter("/tmp/forge-diag.txt"))) {
-            try {
-                Class<?> transUtils = null;
-                for (Class<?> c : inst.getAllLoadedClasses()) {
-                    if ("com.mdframe.forge.starter.trans.util.TransUtils".equals(c.getName())) { transUtils = c; break; }
-                }
-                if (transUtils == null) { out.println("TransUtils not loaded"); return; }
-                Field tmField = transUtils.getDeclaredField("transManager");
-                tmField.setAccessible(true);
-                Object transManager = tmField.get(null);
-                if (transManager == null) { out.println("transManager null"); return; }
-                Field ctxField = transManager.getClass().getDeclaredField("applicationContext");
-                ctxField.setAccessible(true);
-                Object ctx = ctxField.get(transManager);
-                out.println("ctx = " + ctx.getClass().getName());
-                Method getEnv = ctx.getClass().getMethod("getEnvironment");
-                Object env = getEnv.invoke(ctx);
-                Method getProp = env.getClass().getMethod("getProperty", String.class);
-                for (String k : new String[]{"forge.crypto.enabled","forge.crypto.persistence.write-versioned","forge.crypto.persistence.enabled","forge.crypto.persistence.legacy-read-enabled"}) {
-                    out.println("ENV " + k + " = " + getProp.invoke(env, k));
-                }
-                Object sk = getProp.invoke(env, "forge.crypto.secret-key");
-                out.println("ENV secret-key.len = " + (sk == null ? "null" : String.valueOf(sk).length()));
-                Object ak = getProp.invoke(env, "forge.crypto.persistence.active-key");
-                out.println("ENV active-key.len = " + (ak == null ? "null" : String.valueOf(ak).length()));
-                out.println("ENV active-key-id = " + getProp.invoke(env, "forge.crypto.persistence.active-key-id"));
-                Method getBean = ctx.getClass().getMethod("getBean", String.class);
-                Object bean = getBean.invoke(ctx, "cryptoProperties");
-                out.println("bean = " + bean.getClass().getName() + " @" + System.identityHashCode(bean));
-                Object enabled = bean.getClass().getMethod("getEnabled").invoke(bean);
-                Object secretKey = bean.getClass().getMethod("getSecretKey").invoke(bean);
-                Object persistence = bean.getClass().getMethod("getPersistence").invoke(bean);
-                out.println("BEAN enabled = " + enabled);
-                out.println("BEAN secretKey.len = " + (secretKey == null ? "null" : String.valueOf(secretKey).length()));
-                out.println("BEAN persistence @" + System.identityHashCode(persistence));
-                Class<?> pc = persistence.getClass();
-                out.println("BEAN persistence.enabled = " + pc.getMethod("getEnabled").invoke(persistence));
-                out.println("BEAN persistence.writeVersioned = " + pc.getMethod("getWriteVersioned").invoke(persistence));
-                out.println("BEAN persistence.legacyReadEnabled = " + pc.getMethod("getLegacyReadEnabled").invoke(persistence));
-                out.println("BEAN persistence.activeKeyId = [" + pc.getMethod("getActiveKeyId").invoke(persistence) + "]");
-                Object akey = pc.getMethod("getActiveKey").invoke(persistence);
-                out.println("BEAN persistence.activeKey.len = " + (akey == null ? "null" : String.valueOf(akey).length()));
-                Object lkey = pc.getMethod("getLegacyKey").invoke(persistence);
-                out.println("BEAN persistence.legacyKey.len = " + (lkey == null ? "null" : String.valueOf(lkey).length()));
-            } catch (Throwable t) { t.printStackTrace(out); }
-        } catch (Exception ignore) {}
-    }
-}

BIN
var/tmp/DiagAgent2.class


+ 0 - 44
var/tmp/DiagAgent2.java

@@ -1,44 +0,0 @@
-import java.io.FileWriter;
-import java.io.PrintWriter;
-import java.lang.instrument.Instrumentation;
-import java.lang.reflect.Field;
-import java.lang.reflect.Method;
-import java.util.Map;
-
-public class DiagAgent2 {
-    public static void agentmain(String args, Instrumentation inst) {
-        try (PrintWriter out = new PrintWriter(new FileWriter("/tmp/forge-diag2.txt"))) {
-            try {
-                Class<?> transUtils = null;
-                for (Class<?> c : inst.getAllLoadedClasses()) {
-                    if ("com.mdframe.forge.starter.trans.util.TransUtils".equals(c.getName())) { transUtils = c; break; }
-                }
-                if (transUtils == null) { out.println("TransUtils not loaded"); return; }
-                Field tmField = transUtils.getDeclaredField("transManager");
-                tmField.setAccessible(true);
-                Object transManager = tmField.get(null);
-                Field ctxField = transManager.getClass().getDeclaredField("applicationContext");
-                ctxField.setAccessible(true);
-                Object ctx = ctxField.get(transManager);
-                Object env = ctx.getClass().getMethod("getEnvironment").invoke(ctx);
-                Object sources = env.getClass().getMethod("getPropertySources").invoke(env);
-                out.println("== main context env property sources ==");
-                for (Object ps : (Iterable<?>) sources) {
-                    String name = (String) ps.getClass().getMethod("getName").invoke(ps);
-                    out.println("  - " + name + " (" + ps.getClass().getSimpleName() + ") @" + System.identityHashCode(ps));
-                    if ("forgeCryptoBootstrap".equals(name) || "dbPropertySource".equals(name)) {
-                        Object src = ps.getClass().getMethod("getSource").invoke(ps);
-                        if (src instanceof Map<?, ?> m) {
-                            for (Map.Entry<?, ?> e : m.entrySet()) {
-                                String k = String.valueOf(e.getKey());
-                                String v = String.valueOf(e.getValue());
-                                boolean secret = k.contains("key") || k.contains("secret");
-                                out.println("      " + k + " = " + (secret ? ("<len=" + v.length() + ">") : v));
-                            }
-                        }
-                    }
-                }
-            } catch (Throwable t) { t.printStackTrace(out); }
-        } catch (Exception ignore) {}
-    }
-}

BIN
var/tmp/JmxProbe.class


+ 0 - 31
var/tmp/JmxProbe.java

@@ -1,31 +0,0 @@
-import com.sun.tools.attach.VirtualMachine;
-import javax.management.MBeanServerConnection;
-import javax.management.ObjectName;
-import javax.management.remote.JMXConnector;
-import javax.management.remote.JMXConnectorFactory;
-import javax.management.remote.JMXServiceURL;
-
-public class JmxProbe {
-    public static void main(String[] args) throws Exception {
-        String pid = args[0];
-        VirtualMachine vm = VirtualMachine.attach(pid);
-        String addr = vm.startLocalManagementAgent();
-        try (JMXConnector connector = JMXConnectorFactory.connect(new JMXServiceURL(addr))) {
-            MBeanServerConnection conn = connector.getMBeanServerConnection();
-            ObjectName env = new ObjectName("org.springframework.boot:type=Endpoint,name=Env");
-            for (String key : new String[]{
-                    "forge.crypto.persistence.write-versioned",
-                    "forge.crypto.secret-key",
-                    "forge.crypto.enabled",
-                    "forge.crypto.persistence.enabled"}) {
-                Object result = conn.invoke(env, "environmentEntry",
-                        new Object[]{key}, new String[]{String.class.getName()});
-                System.out.println("=== " + key + " ===");
-                System.out.println(String.valueOf(result));
-                System.out.println();
-            }
-        } finally {
-            vm.detach();
-        }
-    }
-}

BIN
var/tmp/JmxProbe2.class


+ 0 - 23
var/tmp/JmxProbe2.java

@@ -1,23 +0,0 @@
-import com.sun.tools.attach.VirtualMachine;
-import javax.management.MBeanServerConnection;
-import javax.management.ObjectName;
-import javax.management.remote.JMXConnector;
-import javax.management.remote.JMXConnectorFactory;
-import javax.management.remote.JMXServiceURL;
-
-public class JmxProbe2 {
-    public static void main(String[] args) throws Exception {
-        String pid = args[0];
-        VirtualMachine vm = VirtualMachine.attach(pid);
-        String addr = vm.startLocalManagementAgent();
-        try (JMXConnector connector = JMXConnectorFactory.connect(new JMXServiceURL(addr))) {
-            MBeanServerConnection conn = connector.getMBeanServerConnection();
-            ObjectName cp = new ObjectName("org.springframework.boot:type=Endpoint,name=Configprops");
-            Object result = conn.invoke(cp, "configurationPropertiesWithPrefix",
-                    new Object[]{"forge.crypto"}, new String[]{String.class.getName()});
-            System.out.println(String.valueOf(result));
-        } finally {
-            vm.detach();
-        }
-    }
-}

+ 0 - 1
var/tmp/check_migration.sql

@@ -1 +0,0 @@
-SELECT installed_rank, version, description, success FROM forge_schema_history WHERE version IN ('1.0.70','1.0.71') ORDER BY installed_rank;

BIN
var/tmp/diag-agent.jar


BIN
var/tmp/diag-agent2.jar


+ 0 - 1
var/tmp/manifest.txt

@@ -1 +0,0 @@
-Agent-Class: DiagAgent

+ 0 - 2
var/tmp/manifest2.txt

@@ -1,2 +0,0 @@
-Agent-Class: DiagAgent2
-Can-Redefine-Classes: false

+ 0 - 1
var/tmp/repair_migration.sql

@@ -1 +0,0 @@
-DELETE FROM forge_schema_history WHERE version IN ('1.0.70','1.0.71');

+ 0 - 29
var/tmp/scan_fpc1_keyids.sql

@@ -1,29 +0,0 @@
-SELECT 'ai_provider.api_key' AS src, SUBSTRING_INDEX(SUBSTRING_INDEX(api_key,':',3),':',-1) AS kid, COUNT(*) AS cnt
-FROM ai_provider WHERE api_key LIKE 'FPC1:%' GROUP BY kid
-UNION ALL
-SELECT 'ai_approval.payload', SUBSTRING_INDEX(SUBSTRING_INDEX(payload_ciphertext,':',3),':',-1), COUNT(*)
-FROM ai_capability_approval WHERE payload_ciphertext LIKE 'FPC1:%' GROUP BY 2
-UNION ALL
-SELECT 'report_conn.password', SUBSTRING_INDEX(SUBSTRING_INDEX(password_cipher,':',3),':',-1), COUNT(*)
-FROM ai_report_data_connection WHERE password_cipher LIKE 'FPC1:%' GROUP BY 2
-UNION ALL
-SELECT 'client.app_secret', SUBSTRING_INDEX(SUBSTRING_INDEX(app_secret,':',3),':',-1), COUNT(*)
-FROM sys_client WHERE app_secret LIKE 'FPC1:%' GROUP BY 2
-UNION ALL
-SELECT 'ext_sys.oauth2_secret', SUBSTRING_INDEX(SUBSTRING_INDEX(oauth2_client_secret,':',3),':',-1), COUNT(*)
-FROM sys_external_system WHERE oauth2_client_secret LIKE 'FPC1:%' GROUP BY 2
-UNION ALL
-SELECT 'ext_sys.token_value', SUBSTRING_INDEX(SUBSTRING_INDEX(token_value,':',3),':',-1), COUNT(*)
-FROM sys_external_system WHERE token_value LIKE 'FPC1:%' GROUP BY 2
-UNION ALL
-SELECT 'ext_sys.api_key_value', SUBSTRING_INDEX(SUBSTRING_INDEX(api_key_value,':',3),':',-1), COUNT(*)
-FROM sys_external_system WHERE api_key_value LIKE 'FPC1:%' GROUP BY 2
-UNION ALL
-SELECT 'file_storage.secret', SUBSTRING_INDEX(SUBSTRING_INDEX(secret_key,':',3),':',-1), COUNT(*)
-FROM sys_file_storage_config WHERE secret_key LIKE 'FPC1:%' GROUP BY 2
-UNION ALL
-SELECT 'sms.ak_secret', SUBSTRING_INDEX(SUBSTRING_INDEX(access_key_secret,':',3),':',-1), COUNT(*)
-FROM sys_sms_config WHERE access_key_secret LIKE 'FPC1:%' GROUP BY 2
-UNION ALL
-SELECT 'user_social.access_token', SUBSTRING_INDEX(SUBSTRING_INDEX(access_token,':',3),':',-1), COUNT(*)
-FROM sys_user_social WHERE access_token LIKE 'FPC1:%' GROUP BY 2;

+ 0 - 1
var/tmp/scan_result.txt

@@ -1 +0,0 @@
-mysql: [Warning] Using a password on the command line interface can be insecure.

BIN
var/tmp/template-modal-verify.png


BIN
var/tmp/template-preview-verify.png