Browse Source

支付宝沙箱支付功能提交

徐滕 20 hours ago
parent
commit
65508d9299
27 changed files with 1107 additions and 185 deletions
  1. 42 9
      forge-admin-ui/src/composables/useOrderNotification.js
  2. 2 2
      forge-admin-ui/src/views/hotel/businessHours.vue
  3. 2 2
      forge-admin-ui/src/views/hotel/dish.vue
  4. 2 2
      forge-admin-ui/src/views/hotel/dishCategory.vue
  5. 2 2
      forge-admin-ui/src/views/hotel/dishSpec.vue
  6. 9 3
      forge-admin-ui/src/views/hotel/order.vue
  7. 2 2
      forge-admin-ui/src/views/hotel/room.vue
  8. 2 2
      forge-admin-ui/src/views/hotel/roomType.vue
  9. 0 3
      forge-h5-ui/.env.production
  10. 0 3
      forge-h5-ui/src/App.vue
  11. 15 0
      forge-h5-ui/src/api/index.js
  12. 1 0
      forge-h5-ui/src/pages/hotel/customer/cart.vue
  13. 5 33
      forge-h5-ui/src/pages/hotel/customer/order-confirm.vue
  14. 182 67
      forge-h5-ui/src/pages/hotel/customer/pay.vue
  15. 129 0
      forge-h5-ui/src/pages/hotel/customer/room-confirm.vue
  16. 13 1
      forge-h5-ui/src/store/modules/hotel-order.js
  17. 0 3
      forge-h5-ui/vite.config.js
  18. 111 0
      forge-server/forge-admin-server/src/main/resources/application-dev.yml
  19. 92 0
      forge-server/forge-app-server/src/main/resources/application-dev.yml
  20. 7 0
      forge-server/forge-business/forge-hotel/pom.xml
  21. 90 10
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/open/HotelPayController.java
  22. 34 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/mapper/HotelPayLogMapper.java
  23. 20 5
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/HotelPayService.java
  24. 180 21
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/impl/HotelPayServiceImpl.java
  25. 27 0
      forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/pay/HotelPayLogMapper.xml
  26. 129 15
      forge-server/forge-business/forge-hotel/酒店模块迁移跟踪.md
  27. 9 0
      forge-server/forge-framework/forge-starter-parent/forge-starter-core/src/main/java/com/mdframe/forge/starter/core/exception/GlobalExceptionHandler.java

+ 42 - 9
forge-admin-ui/src/composables/useOrderNotification.js

@@ -81,22 +81,48 @@ export function useOrderNotification(options = {}) {
   // MP3 语音文件路径(放在 public 目录)
   const ORDER_ALERT_MP3 = '/女生-订单提醒.mp3'
   
+  // 用户交互状态:页面加载后首次用户交互前,音频播放会被 Chrome 阻止
+  let userInteracted = false
+  // 待播放队列:用户交互前尝试播放失败的声音,在首次交互后补播
+  const pendingSounds = []
+  
+  /**
+   * 注册首次用户交互监听器。
+   * 用户首次点击或按键后,播放队列中积压的声音。
+   */
+  function setupUserInteractionListener() {
+    if (userInteracted) return
+    const onInteract = () => {
+      userInteracted = true
+      console.warn('[SSE] 用户交互触发,开始播放积压的声音')
+      document.removeEventListener('click', onInteract)
+      document.removeEventListener('keydown', onInteract)
+      // 播放积压的声音
+      while (pendingSounds.length > 0) {
+        const type = pendingSounds.shift()
+        console.warn(`[SSE] 补播积压声音: type=${type}`)
+        playMp3Audio(ORDER_ALERT_MP3)
+      }
+    }
+    document.addEventListener('click', onInteract, { once: true })
+    document.addEventListener('keydown', onInteract, { once: true })
+  }
+  
   /**
    * 播放提示音(使用 MP3 文件)。
+   * 如果用户尚未交互(Chrome 自动播放策略),会延迟到首次交互后播放。
    * @param {string} type 类型:'new-order' 或 'refund'
    */
   function playSound(type) {
-    console.warn(`[SSE] 📢 开始播放声音: type=${type}`)
+    console.warn(`[SSE] 📢 开始播放声音: type=${type}, userInteracted=${userInteracted}`)
     try {
       if (type === 'new-order') {
-        // 新订单:播放 MP3 语音
         console.warn('[SSE] 播放订单提醒 MP3')
-        playMp3Audio(ORDER_ALERT_MP3)
+        playMp3Audio(ORDER_ALERT_MP3, type)
       }
       else if (type === 'refund') {
-        // 退单申请:也播放同样的 MP3(或者可以换成其他音效)
         console.warn('[SSE] 播放退单提醒 MP3')
-        playMp3Audio(ORDER_ALERT_MP3)
+        playMp3Audio(ORDER_ALERT_MP3, type)
       }
       console.warn('[SSE] ✅ playSound 函数执行完毕')
     }
@@ -108,13 +134,15 @@ export function useOrderNotification(options = {}) {
   
   /**
    * 播放 MP3 音频文件。
+   * 如果因 Chrome 自动播放策略失败,会加入待播放队列,等用户首次交互后补播。
    * @param {string} mp3Path MP3 文件路径
+   * @param {string} [soundType] 声音类型,用于日志
    */
-  function playMp3Audio(mp3Path) {
+  function playMp3Audio(mp3Path, soundType) {
     console.warn(`[SSE] 准备播放 MP3: ${mp3Path}`)
     const audio = new Audio(mp3Path)
     audio.volume = 0.8
-    console.warn(`[SSE] Audio 对象已创建, volume=${audio.volume}, duration=${audio.duration || 'unknown'}`)
+    console.warn(`[SSE] Audio 对象已创建, volume=${audio.volume}`)
   
     // 监听播放事件
     audio.addEventListener('canplaythrough', () => {
@@ -135,8 +163,13 @@ export function useOrderNotification(options = {}) {
       playPromise.then(() => {
         console.warn('[SSE] ✅ 音频播放成功!')
       }).catch((err) => {
-        console.error('[SSE] ❌ 音频播放失败:', err)
-        console.error('[SSE] 错误详情 - name:', err.name, ', message:', err.message)
+        console.error('[SSE] ❌ 音频播放失败:', err.name, '-', err.message)
+        // Chrome 自动播放策略:用户未交互时播放会被阻止
+        if (err.name === 'NotAllowedError') {
+          console.warn('[SSE] 用户未交互,加入待播放队列,等待首次点击/按键后补播')
+          pendingSounds.push(soundType || 'new-order')
+          setupUserInteractionListener()
+        }
       })
     }
   }

+ 2 - 2
forge-admin-ui/src/views/hotel/businessHours.vue

@@ -5,10 +5,10 @@
       api="/hotel/business-hours"
       :api-config="{
         list: 'get@/hotel/business-hours/page',
-        detail: 'get@/hotel/business-hours/:id',
+        detail: 'get@/hotel/business-hours/detail?id=:id',
         add: 'post@/hotel/business-hours',
         update: 'put@/hotel/business-hours',
-        delete: 'post@/hotel/business-hours/remove/:id',
+        delete: 'post@/hotel/business-hours/remove?id=:id',
       }"
       :load-detail-on-edit="true"
       :search-schema="searchSchema"

+ 2 - 2
forge-admin-ui/src/views/hotel/dish.vue

@@ -5,10 +5,10 @@
       api="/hotel/dish"
       :api-config="{
         list: 'get@/hotel/dish/page',
-        detail: 'get@/hotel/dish/:id',
+        detail: 'get@/hotel/dish/detail?id=:id',
         add: 'post@/hotel/dish',
         update: 'put@/hotel/dish',
-        delete: 'post@/hotel/dish/remove/:id',
+        delete: 'post@/hotel/dish/remove?id=:id',
       }"
       :load-detail-on-edit="true"
       :search-schema="searchSchema"

+ 2 - 2
forge-admin-ui/src/views/hotel/dishCategory.vue

@@ -5,10 +5,10 @@
       api="/hotel/dish-category"
       :api-config="{
         list: 'get@/hotel/dish-category/list',
-        detail: 'get@/hotel/dish-category/:id',
+        detail: 'get@/hotel/dish-category/detail?id=:id',
         add: 'post@/hotel/dish-category',
         update: 'put@/hotel/dish-category',
-        delete: 'post@/hotel/dish-category/remove/:id',
+        delete: 'post@/hotel/dish-category/remove?id=:id',
       }"
       :load-detail-on-edit="true"
       :columns="tableColumns"

+ 2 - 2
forge-admin-ui/src/views/hotel/dishSpec.vue

@@ -5,10 +5,10 @@
       api="/hotel/dish-spec"
       :api-config="{
         list: 'get@/hotel/dish-spec/page',
-        detail: 'get@/hotel/dish-spec/:id',
+        detail: 'get@/hotel/dish-spec/detail?id=:id',
         add: 'post@/hotel/dish-spec',
         update: 'put@/hotel/dish-spec',
-        delete: 'post@/hotel/dish-spec/remove/:id',
+        delete: 'post@/hotel/dish-spec/remove?id=:id',
       }"
       :load-detail-on-edit="true"
       :search-schema="searchSchema"

+ 9 - 3
forge-admin-ui/src/views/hotel/order.vue

@@ -186,7 +186,7 @@
 
 <script setup>
 import { NDataTable, NInput, NModal, NSpin, useDialog, useMessage } from 'naive-ui'
-import { computed, h, onMounted, onUnmounted, ref } from 'vue'
+import { computed, h, onMounted, onUnmounted, ref, watch } from 'vue'
 import {
   acceptOrder,
   approveRefund,
@@ -240,8 +240,6 @@ function handleHotelNotification(e) {
 onMounted(() => {
   loadDashboard()
   connect()
-  // 页面加载时,检查是否有待接单订单,如果有则触发语音通知
-  checkPendingOrders()
   window.addEventListener('hotel-notification', handleHotelNotification)
 })
 
@@ -250,6 +248,14 @@ onUnmounted(() => {
   window.removeEventListener('hotel-notification', handleHotelNotification)
 })
 
+// 监听 SSE 连接状态,连接成功后检查待处理订单(浏览器刷新和页面标签刷新都会触发)
+watch(connected, (newVal) => {
+  if (newVal) {
+    console.log('[订单页面] SSE 连接已建立,检查待处理订单')
+    checkPendingOrders()
+  }
+})
+
 // ==================== 看板统计 ====================
 const dashboard = ref({
   pendingCount: 0,

+ 2 - 2
forge-admin-ui/src/views/hotel/room.vue

@@ -5,10 +5,10 @@
       api="/hotel/room"
       :api-config="{
         list: 'get@/hotel/room/page',
-        detail: 'get@/hotel/room/:id',
+        detail: 'get@/hotel/room/detail?id=:id',
         add: 'post@/hotel/room',
         update: 'put@/hotel/room',
-        delete: 'post@/hotel/room/remove/:id',
+        delete: 'post@/hotel/room/remove?id=:id',
       }"
       :load-detail-on-edit="true"
       :search-schema="searchSchema"

+ 2 - 2
forge-admin-ui/src/views/hotel/roomType.vue

@@ -5,10 +5,10 @@
       api="/hotel/room-type"
       :api-config="{
         list: 'get@/hotel/room-type/list',
-        detail: 'get@/hotel/room-type/:id',
+        detail: 'get@/hotel/room-type/detail?id=:id',
         add: 'post@/hotel/room-type',
         update: 'put@/hotel/room-type',
-        delete: 'post@/hotel/room-type/remove/:id',
+        delete: 'post@/hotel/room-type/remove?id=:id',
       }"
       :load-detail-on-edit="true"
       :columns="tableColumns"

+ 0 - 3
forge-h5-ui/.env.production

@@ -17,6 +17,3 @@ VITE_USER_CLIENT=h5
 VITE_APP_ID=forge_h5
 
 # 企业微信工作台免登:已改为后端连接配置控制(sys_social_config.sso_workbench_enabled + connection_code),前端无需再配置
-
-# 【临时调试】页内 vConsole 调试面板,移动端免 URL 追加 query 即可看 console;联调完成后删除本行
-VITE_DEBUG_CONSOLE=1

+ 0 - 3
forge-h5-ui/src/App.vue

@@ -1,6 +1,5 @@
 <script>
 import { HOME_PAGE } from '@/utils/route'
-import { setupDebugConsole } from '@/utils/debug-console'
 import { loadRuntimeCryptoConfig } from '@/utils/crypto/crypto-config'
 import { isWeComBrowser, startWeComAutoLogin, consumeWeComLoginRedirect } from '@/utils/wecom'
 
@@ -36,8 +35,6 @@ function bootstrapWeComAutoLogin() {
 
 export default {
   onLaunch: async function () {
-    // 优先加载页内调试面板(?vdebug=1 开启),确保后续 console 可见
-    await setupDebugConsole()
     await loadRuntimeCryptoConfig()
     hideNativeTabBar()
     bootstrapWeComAutoLogin()

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

@@ -344,4 +344,19 @@ export default {
     method: 'get',
     params: { orderId, tenantId },
   }),
+  /** 主动查询支付宝订单状态(回调丢失时的底机制) */
+  hotelPayAlipayQuery: (tenantId, orderId) => request({
+    url: '/hotel/open/pay/alipayQuery',
+    method: 'get',
+    params: { tenantId, orderId },
+  }),
+
+  // ==================== 支付宝用户授权 ====================
+  /** 支付宝授权获取用户信息(姓名+手机号) */
+  hotelAlipayAuth: (data) => request({
+    url: '/hotel/open/alipay-auth/getUserInfo',
+    method: 'post',
+    data,
+    skipAuthRefresh: true,
+  }),
 }

+ 1 - 0
forge-h5-ui/src/pages/hotel/customer/cart.vue

@@ -336,6 +336,7 @@ function goCheckout() {
 /* 结算按钮 */
 .c-checkout-btn {
   width: 100%;
+  box-sizing: border-box;
   padding: 28rpx;
   border-radius: 48rpx;
   background: var(--h-pri, #2D5016);

+ 5 - 33
forge-h5-ui/src/pages/hotel/customer/order-confirm.vue

@@ -56,23 +56,13 @@
       </view>
     </view>
 
-    <!-- 支付方式 -->
-    <view class="oc-section">
-      <view class="oc-section-title">💳 支付方式</view>
-      <view class="oc-pay-info">
-        <text class="oc-pay-icon">💚</text>
-        <text class="oc-pay-name">微信支付</text>
-      </view>
-      <view class="oc-pay-hint">扫码环境自动判断,无需手动选择</view>
-    </view>
-
     <!-- 提交按钮 -->
     <view class="oc-submit-btn" @click="submitOrder">确认支付 ¥{{ grandTotal }}</view>
   </view>
 </template>
 
 <script setup>
-import { computed, ref } from 'vue'
+import { computed, ref, onMounted } from 'vue'
 import { useHotelOrderStore } from '@/store'
 import api from '@/api'
 
@@ -84,8 +74,9 @@ const cartItems = computed(() => orderStore.cartItems)
 const deliveryFee = computed(() => orderStore.deliveryFee)
 const grandTotal = computed(() => (orderStore.cartTotal + deliveryFee.value).toFixed(2))
 
-const contactName = ref('')
-const contactPhone = ref('')
+// 从 Store 读取授权信息,自动填入
+const contactName = ref(orderStore.contactName || '')
+const contactPhone = ref(orderStore.contactPhone || '')
 const deliveryTimeType = ref('ASAP')
 const submitting = ref(false)
 
@@ -292,29 +283,10 @@ async function submitOrder() {
   color: var(--h-warn, #D97706);
 }
 
-/* 支付方式 */
-.oc-pay-info {
-  display: flex;
-  align-items: center;
-  gap: 12rpx;
-  padding: 12rpx 0;
-  font-size: 28rpx;
-  font-weight: 600;
-  color: var(--h-succ, #16A34A);
-}
-
-.oc-pay-icon {
-  font-size: 40rpx;
-}
-
-.oc-pay-hint {
-  font-size: 22rpx;
-  color: var(--h-txt-light, #999);
-}
-
 /* 提交按钮 */
 .oc-submit-btn {
   width: 100%;
+  box-sizing: border-box;
   padding: 28rpx;
   border-radius: 48rpx;
   background: var(--h-pri, #2D5016);

+ 182 - 67
forge-h5-ui/src/pages/hotel/customer/pay.vue

@@ -25,15 +25,6 @@
       <text class="pc-label">订单已超时,请重新下单</text>
     </view>
 
-    <!-- 支付方式 -->
-    <view v-if="!loading" class="pay-method-card">
-      <view class="pm-row">
-        <text class="pm-icon">{{ payIcon }}</text>
-        <text class="pm-name">{{ payName }}</text>
-      </view>
-      <view class="pm-hint">✅ 已自动识别支付环境</view>
-    </view>
-
     <!-- 订单信息 -->
     <view v-if="!loading && orderId" class="pay-info-card">
       <view class="pi-line">
@@ -79,10 +70,8 @@ const loading = ref(true)
 let countdownTimer = null
 let pollTimer = null
 
-// 支付环境自动识别
+// 支付环境自动识别(paySource 用于 doPay 判断支付渠道)
 const paySource = ref('MOCK')
-const payIcon = ref('🧪')
-const payName = ref('模拟支付')
 
 onMounted(() => {
   const pages = getCurrentPages()
@@ -99,6 +88,19 @@ onMounted(() => {
         orderNo.value = data.orderNo || ''
         roomNo.value = data.roomNo || orderStore.roomInfo?.roomNo || ''
         amount.value = data.totalAmount ? String(data.totalAmount) : '0.00'
+        // 如果订单已支付成功(从支付宝返回后重新进入),直接跳转
+        if (data.payStatus === 1) {
+          loading.value = false
+          paySuccess.value = true
+          stopCountdown()
+          uni.showToast({ title: '支付成功', icon: 'success' })
+          setTimeout(function() {
+            uni.redirectTo({
+              url: '/pages/hotel/customer/order-status?orderId=' + orderId.value,
+            })
+          }, 1200)
+          return
+        }
         // 根据后端返回的支付截止时间计算剩余秒数
         if (data.payExpireTime) {
           var expireTime = new Date(data.payExpireTime).getTime()
@@ -109,6 +111,8 @@ onMounted(() => {
       }
       loading.value = false
       startCountdown()
+      // 订单未支付时,自动启动轮询检测支付状态(用于从支付宝返回后的场景)
+      startPayPolling()
     }).catch(function() {
       loading.value = false
       uni.showToast({ title: '加载订单信息失败', icon: 'none' })
@@ -120,24 +124,16 @@ onMounted(() => {
   // 自动识别支付环境
   // #ifdef MP-WEIXIN
   paySource.value = 'WECHAT'
-  payIcon.value = '💚'
-  payName.value = '微信支付'
   // #endif
   // #ifdef MP-ALIPAY
   paySource.value = 'ALIPAY'
-  payIcon.value = '💙'
-  payName.value = '支付宝支付'
   // #endif
   // #ifdef H5
   const ua = navigator.userAgent.toLowerCase()
   if (ua.includes('micromessenger')) {
     paySource.value = 'WECHAT'
-    payIcon.value = '💚'
-    payName.value = '微信支付'
-  } else if (ua.includes('alipayclient')) {
+  } else {
     paySource.value = 'ALIPAY'
-    payIcon.value = '💙'
-    payName.value = '支付宝支付'
   }
   // #endif
 
@@ -168,6 +164,13 @@ function stopCountdown() {
   }
 }
 
+function stopPolling() {
+  if (pollTimer) {
+    clearTimeout(pollTimer)
+    pollTimer = null
+  }
+}
+
 /** 倒计时格式化 */
 const countdownText = computed(() => {
   const m = Math.floor(remainSeconds.value / 60)
@@ -186,32 +189,175 @@ async function doPay() {
   paying.value = true
 
   try {
-    const tenantId = orderStore.tenantId
+    var tenantId = orderStore.tenantId
 
-    // 开发阶段:使用模拟支付
-    // TODO: 对接真实支付时,先调用 hotelPayCreate 获取支付参数,再调用平台支付 SDK
-    const res = await api.hotelPayMockSuccess(orderId.value, tenantId)
-    const data = res?.data || res
+    // 先调用后端创建支付流水,获取 tradeNo
+    var createRes = await api.hotelPayCreate(orderId.value, tenantId, paySource.value)
+    var createData = createRes?.data || createRes
+
+    if (!createData) {
+      paying.value = false
+      uni.showToast({ title: '创建支付失败', icon: 'none' })
+      return
+    }
+
+    // 根据支付渠道拉起支付
+    if (paySource.value === 'ALIPAY' && createData.payForm) {
+      // 支付宝 H5 手机网站支付:后端返回 payForm(HTML 表单),渲染后自动提交跳转支付宝收银台
+      // #ifdef H5
+      var payContainer = document.getElementById('alipay-pay-form')
+      if (!payContainer) {
+        payContainer = document.createElement('div')
+        payContainer.id = 'alipay-pay-form'
+        payContainer.style.display = 'none'
+        document.body.appendChild(payContainer)
+      }
+      payContainer.innerHTML = createData.payForm
+      var form = payContainer.querySelector('form')
+      if (form) {
+        form.submit()
+      } else {
+        paying.value = false
+        uni.showToast({ title: '支付表单异常', icon: 'none' })
+      }
+      // 提交后页面会跳转到支付宝收银台,用户支付完成后通过 return-url 返回
+      // 同时启动轮询,用户返回后自动检测支付状态
+      startPayPolling()
+      // #endif
+      // #ifdef MP-ALIPAY
+      // 支付宝小程序:调用 my.tradePay 拉起收银台
+      my.tradePay({
+        tradeNO: createData.tradeNo,
+        success: function(res) {
+          startPayPolling()
+        },
+        fail: function(err) {
+          paying.value = false
+          uni.showToast({ title: '支付取消或失败', icon: 'none' })
+        }
+      })
+      // #endif
+    } else if (paySource.value === 'ALIPAY' && createData.tradeNo) {
+      // 支付宝小程序环境:有 tradeNo 无 payForm,用 my.tradePay
+      // #ifdef MP-ALIPAY
+      my.tradePay({
+        tradeNO: createData.tradeNo,
+        success: function(res) {
+          startPayPolling()
+        },
+        fail: function(err) {
+          paying.value = false
+          uni.showToast({ title: '支付取消或失败', icon: 'none' })
+        }
+      })
+      // #endif
+      // #ifdef H5
+      // H5 环境不应只有 tradeNo,启动轮询兜底
+      startPayPolling()
+      // #endif
+    } else {
+      // MOCK 模式:直接调用模拟支付(开发调试用)
+      var mockRes = await api.hotelPayMockSuccess(orderId.value, tenantId)
+      var mockData = mockRes?.data || mockRes
+
+      if (mockData && mockData.payStatus === 1) {
+        paySuccess.value = true
+        stopCountdown()
+        uni.showToast({ title: '支付成功', icon: 'success' })
+        setTimeout(function() {
+          uni.redirectTo({
+            url: '/pages/hotel/customer/order-status?orderId=' + orderId.value,
+          })
+        }, 1200)
+      } else {
+        paying.value = false
+        uni.showToast({ title: '支付失败,请重试', icon: 'none' })
+      }
+    }
+  } catch (err) {
+    paying.value = false
+    uni.showToast({ title: err?.message || '支付失败', icon: 'none' })
+  }
+}
+
+/** 支付后轮询后端查询真实支付状态 */
+function startPayPolling() {
+  stopPolling()
+  var pollCount = 0
+  var maxPoll = 60 // 最多轮询60次,每次2秒,共120秒
+  // 延迟3秒后开始第一次轮询,给支付宝异步回调留出处理时间
+  pollTimer = setTimeout(function poll() {
+    pollCount++
+    api.hotelPayStatus(orderId.value, orderStore.tenantId).then(function(res) {
+      var data = res?.data || res
+      if (data && data.payStatus === 1) {
+        // 支付成功
+        stopPolling()
+        paySuccess.value = true
+        paying.value = false
+        stopCountdown()
+        uni.showToast({ title: '支付成功', icon: 'success' })
+        setTimeout(function() {
+          uni.redirectTo({
+            url: '/pages/hotel/customer/order-status?orderId=' + orderId.value,
+          })
+        }, 1200)
+      } else if (pollCount >= maxPoll) {
+        // 轮询超时,主动查询支付宝侧订单状态(底机制)
+        stopPolling()
+        paying.value = false
+        queryAlipayTradeStatus()
+      } else {
+        // 未支付,继续轮询
+        pollTimer = setTimeout(poll, 2000)
+      }
+    }).catch(function() {
+      // 静默失败,继续轮询
+      if (pollCount < maxPoll) {
+        pollTimer = setTimeout(poll, 2000)
+      } else {
+        // 轮询超时且请求失败,也尝试主动查询支付宝
+        stopPolling()
+        paying.value = false
+        queryAlipayTradeStatus()
+      }
+    })
+  }, pollCount === 0 ? 3000 : 2000)
+}
 
+/** 轮询超时后,主动查询支付宝侧订单状态(底机制) */
+function queryAlipayTradeStatus() {
+  uni.showLoading({ title: '正在确认支付结果...' })
+  api.hotelPayAlipayQuery(orderStore.tenantId, orderId.value).then(function(res) {
+    uni.hideLoading()
+    var data = res?.data || res
     if (data && data.payStatus === 1) {
+      // 支付宝侧已支付,补偿成功
       paySuccess.value = true
+      paying.value = false
       stopCountdown()
       uni.showToast({ title: '支付成功', icon: 'success' })
-
-      // 延迟跳转到订单状态页
-      setTimeout(() => {
+      setTimeout(function() {
         uni.redirectTo({
           url: '/pages/hotel/customer/order-status?orderId=' + orderId.value,
         })
       }, 1200)
     } else {
-      paying.value = false
-      uni.showToast({ title: '支付失败,请重试', icon: 'none' })
+      // 支付宝侧也未支付
+      uni.showModal({
+        title: '支付结果未知',
+        content: '暂时无法确认支付状态,请稍后在订单列表中查看。如已扣款请联系客服。',
+        showCancel: false
+      })
     }
-  } catch (err) {
-    paying.value = false
-    uni.showToast({ title: err?.message || '支付失败', icon: 'none' })
-  }
+  }).catch(function() {
+    uni.hideLoading()
+    uni.showModal({
+      title: '查询失败',
+      content: '网络异常,请稍后在订单列表中查看支付结果。',
+      showCancel: false
+    })
+  })
 }
 
 function goBack() {
@@ -307,38 +453,6 @@ function goBack() {
   font-variant-numeric: tabular-nums;
 }
 
-/* 支付方式卡片 */
-.pay-method-card {
-  width: 100%;
-  background: #fff;
-  border-radius: var(--h-rad, 20rpx);
-  padding: 32rpx 28rpx;
-  margin-bottom: 20rpx;
-  box-shadow: var(--h-shd, 0 2rpx 8rpx rgba(0, 0, 0, 0.04));
-}
-
-.pm-row {
-  display: flex;
-  align-items: center;
-  gap: 16rpx;
-  margin-bottom: 12rpx;
-}
-
-.pm-icon {
-  font-size: 48rpx;
-}
-
-.pm-name {
-  font-size: 32rpx;
-  font-weight: 700;
-  color: var(--h-txt, #1A1A1A);
-}
-
-.pm-hint {
-  font-size: 22rpx;
-  color: var(--h-succ, #16A34A);
-}
-
 /* 订单信息 */
 .pay-info-card {
   width: 100%;
@@ -368,6 +482,7 @@ function goBack() {
 /* 支付按钮 */
 .pay-btn {
   width: 100%;
+  box-sizing: border-box;
   padding: 28rpx;
   border-radius: 48rpx;
   background: #07C160;

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

@@ -48,6 +48,20 @@
         </view>
 
         <view class="rc-start-btn" @click="goToMenu">开始点餐</view>
+
+        <!-- 支付宝授权区域 -->
+        <!-- #ifdef MP-ALIPAY -->
+        <view class="rc-auth-section">
+          <!-- 姓名已自动获取,手机号需手动授权 -->
+          <view v-if="!authPhone" class="rc-auth-btn" @click="doGetPhone">
+            <text class="rc-auth-icon">📱</text>
+            <text>授权手机号(下单自动填入)</text>
+          </view>
+          <view v-else class="rc-auth-ok">
+            <text>✓ 姓名:{{ authName || '已获取' }} | 手机号:{{ authPhone }}</text>
+          </view>
+        </view>
+        <!-- #endif -->
       </view>
 
       <view class="rc-wrong-link" @click="handleWrongRoom">
@@ -71,6 +85,10 @@ const businessHoursText = ref('10:00 - 22:00')
 const deliveryTime = ref('25-35')
 const deliveryFee = ref(0)
 
+// 支付宝授权相关
+const authName = ref('')
+const authPhone = ref('')
+
 let shortCode = ''
 let sign = ''
 
@@ -124,6 +142,9 @@ async function loadRoomInfo() {
     deliveryTime.value = '25-35'
 
     loading.value = false
+
+    // 页面加载完成后,自动静默获取用户姓名(auth_base 无需用户点击)
+    autoFetchUserName()
   } catch (err) {
     loading.value = false
     errorMsg.value = err?.message || '无法识别该二维码,请检查是否有效'
@@ -145,6 +166,82 @@ function handleWrongRoom() {
     showCancel: false,
   })
 }
+
+/** 自动静默获取用户姓名(auth_base 无需用户交互) */
+async function autoFetchUserName() {
+  // 如果 Store 中已有姓名,跳过
+  if (orderStore.contactName) {
+    authName.value = orderStore.contactName
+    return
+  }
+
+  try {
+    // #ifdef MP-ALIPAY
+    var authCode = await new Promise(function(resolve, reject) {
+      my.getAuthCode({
+        scopes: 'auth_base',
+        success: function(res) { resolve(res.authCode) },
+        fail: function(err) { reject(err) }
+      })
+    })
+
+    if (authCode) {
+      var res = await api.hotelAlipayAuth({
+        authCode: authCode,
+        phoneResponse: ''
+      })
+      var data = res?.data || res
+      if (data && data.userName) {
+        authName.value = data.userName
+        // 存入 Store,下单时自动填入
+        orderStore.setContactInfo(data.userName, orderStore.contactPhone || '')
+      }
+    }
+    // #endif
+  } catch (err) {
+    // 静默失败,不影响正常流程
+    console.log('自动获取姓名失败(非关键):', err?.message || err)
+  }
+}
+
+/** 手动授权获取手机号 */
+async function doGetPhone() {
+  try {
+    // #ifdef MP-ALIPAY
+    var phoneResponse = await new Promise(function(resolve, reject) {
+      my.getPhoneNumber({
+        success: function(res) { resolve(res.response) },
+        fail: function(err) {
+          console.log('用户拒绝授权手机号:', err)
+          resolve('')
+        }
+      })
+    })
+
+    if (!phoneResponse) {
+      uni.showToast({ title: '授权取消', icon: 'none' })
+      return
+    }
+
+    // 发送到后端解密手机号
+    var res = await api.hotelAlipayAuth({
+      authCode: '',
+      phoneResponse: phoneResponse
+    })
+    var data = res?.data || res
+
+    if (data && data.phone) {
+      authPhone.value = data.phone
+      // 存入 Store,下单时自动填入
+      orderStore.setContactInfo(authName.value || orderStore.contactName, data.phone)
+      uni.showToast({ title: '授权成功', icon: 'success' })
+    }
+    // #endif
+  } catch (err) {
+    console.error('获取手机号失败:', err)
+    uni.showToast({ title: err?.message || '授权失败', icon: 'none' })
+  }
+}
 </script>
 
 <style lang="scss" scoped>
@@ -360,4 +457,36 @@ function handleWrongRoom() {
   font-size: 24rpx;
   color: var(--h-txt-light, #999);
 }
+
+/* 授权区域 */
+.rc-auth-section {
+  width: 100%;
+  margin-top: 24rpx;
+}
+
+.rc-auth-btn {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 12rpx;
+  padding: 20rpx;
+  border-radius: 40rpx;
+  border: 2rpx dashed var(--h-pri, #2D5016);
+  background: var(--h-acc-lt, #E8F0E3);
+  font-size: 24rpx;
+  color: var(--h-pri, #2D5016);
+  font-weight: 500;
+}
+
+.rc-auth-btn:active {
+  opacity: 0.8;
+}
+
+.rc-auth-ok {
+  text-align: center;
+  padding: 16rpx;
+  font-size: 24rpx;
+  color: var(--h-succ, #16A34A);
+  font-weight: 500;
+}
 </style>

+ 13 - 1
forge-h5-ui/src/store/modules/hotel-order.js

@@ -31,6 +31,12 @@ export const useHotelOrderStore = defineStore('hotel-order', {
 
     /** 订单备注 */
     orderNote: '',
+
+    /** 授权获取的联系人姓名(支付宝授权) */
+    contactName: '',
+
+    /** 授权获取的联系人手机号(支付宝授权) */
+    contactPhone: '',
   }),
 
   getters: {
@@ -116,10 +122,16 @@ export const useHotelOrderStore = defineStore('hotel-order', {
     setOrderNote(note) {
       this.orderNote = note
     },
+
+    /** 设置授权联系人信息 */
+    setContactInfo(name, phone) {
+      if (name) this.contactName = name
+      if (phone) this.contactPhone = phone
+    },
   },
 
   persist: {
     key: 'hotel-order',
-    pick: ['tenantId', 'roomInfo', 'hotelConfig', 'cartItems', 'orderNote'],
+    pick: ['tenantId', 'roomInfo', 'hotelConfig', 'cartItems', 'orderNote', 'contactName', 'contactPhone'],
   },
 })

+ 0 - 3
forge-h5-ui/vite.config.js

@@ -3,7 +3,6 @@ const require = createRequire(import.meta.url)
 const uniPlugin = require('@dcloudio/vite-plugin-uni')
 
 import { defineConfig, loadEnv } from 'vite'
-import basicSsl from '@vitejs/plugin-basic-ssl'
 import Unocss from 'unocss/vite'
 import AutoImport from 'unplugin-auto-import/vite'
 import Components from 'unplugin-vue-components/vite'
@@ -23,7 +22,6 @@ export default defineConfig(({ mode }) => {
     base: VITE_PUBLIC_PATH || '/',
     plugins: [
       uni(),
-      basicSsl(),
       Unocss(),
       AutoImport({
         imports: ['vue'],
@@ -56,7 +54,6 @@ export default defineConfig(({ mode }) => {
       port: VITE_HTTP_PORT || 3009,
       open: false,
       host: '0.0.0.0',
-      https: true,
       proxy: {
         // Flowable 流程服务代理:待办、审批、流程历史等接口默认走独立 flow-server。
         [`${requestPrefix}/api/flow`]: {

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


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


+ 7 - 0
forge-server/forge-business/forge-hotel/pom.xml

@@ -54,6 +54,13 @@
             <artifactId>forge-starter-excel</artifactId>
         </dependency>
 
+        <!-- 支付宝 SDK -->
+        <dependency>
+            <groupId>com.alipay.sdk</groupId>
+            <artifactId>alipay-sdk-java</artifactId>
+            <version>4.39.79.ALL</version>
+        </dependency>
+
         <!-- ZXing 二维码生成 -->
         <dependency>
             <groupId>com.google.zxing</groupId>

+ 90 - 10
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/open/HotelPayController.java

@@ -1,17 +1,23 @@
 package com.mdframe.forge.business.core.hotel.controller.open;
 
+import com.alipay.api.AlipayClient;
+import com.alipay.api.internal.util.AlipaySignature;
+import com.mdframe.forge.business.core.hotel.pay.config.AlipayConfig;
 import com.mdframe.forge.business.core.hotel.pay.service.HotelPayService;
 import com.mdframe.forge.business.core.hotel.pay.vo.PayResultVO;
 import com.mdframe.forge.starter.core.domain.RespInfo;
 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;
-import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
+import jakarta.servlet.http.HttpServletRequest;
+import java.util.HashMap;
+import java.util.Iterator;
 import java.util.Map;
 
 /**
@@ -19,6 +25,7 @@ import java.util.Map;
  * <p>
  * 路径 /hotel/open/pay/** 已在 SaTokenConfig 中排除登录校验和 API 权限校验。
  */
+@Slf4j
 @RestController
 @RequestMapping("/hotel/open/pay")
 public class HotelPayController {
@@ -26,6 +33,12 @@ public class HotelPayController {
     @Autowired
     private HotelPayService payService;
 
+    @Autowired
+    private AlipayClient alipayClient;
+
+    @Autowired
+    private AlipayConfig alipayConfig;
+
     /**
      * 发起支付(创建支付流水,返回支付参数)。
      *
@@ -89,24 +102,91 @@ public class HotelPayController {
     }
 
     /**
-     * 支付回调通知(微信/支付宝异步通知入口,当前为 Mock 占位)。
+     * 支付回调通知(支付宝异步通知入口)。
+     * <p>
+     * 支付宝会以 POST form 方式推送通知参数,需要验签后处理。
+     * 验签通过后返回 "success",否则返回 "fail"(支付宝会重试)。
      * <p>
-     * TODO: 对接真实支付时,此接口需要验签、解密,并返回支付平台要求的固定格式响应。
+     * 安全机制:
+     * - 验签:RSA2 签名验证
+     * - 金额校验:在 Service 层验证回调金额与订单金额一致
+     * - app_id 校验:在 Service 层验证回调来源
+     * - 幂等性:在 Service 层用数据库级幂等更新防止重复处理
      *
-     * @param outTradeNo 商户订单号
-     * @param tradeNo    第三方交易号
-     * @param success    是否成功
+     * @param request HTTP 请求(包含支付宝回调的所有参数)
      * @return 响应
      */
     @PostMapping("/notify")
-    public String payNotify(@RequestParam(required = false) String outTradeNo,
-                            @RequestParam(required = false) String tradeNo,
-                            @RequestParam(required = false, defaultValue = "true") boolean success) {
+    public String payNotify(HttpServletRequest request) {
         try {
-            payService.handlePayCallback(outTradeNo, tradeNo, success, "mock notify");
+            // 获取支付宝回调的所有参数
+            Map<String, String> params = new HashMap<String, String>();
+            Map<String, String[]> requestParams = request.getParameterMap();
+            Iterator<String> iter = requestParams.keySet().iterator();
+            while (iter.hasNext()) {
+                String name = iter.next();
+                String[] values = requestParams.get(name);
+                String valueStr = "";
+                for (int i = 0; i < values.length; i++) {
+                    valueStr = (i == values.length - 1) ? valueStr + values[i] : valueStr + values[i] + ",";
+                }
+                params.put(name, valueStr);
+            }
+
+            log.info("收到支付宝回调通知: {}", params);
+
+            // 验签
+            boolean signVerified;
+            try {
+                signVerified = AlipaySignature.rsaCheckV1(params, alipayConfig.getAlipayPublicKey(), "UTF-8", "RSA2");
+            } catch (Exception e) {
+                log.error("支付宝回调验签异常", e);
+                return "fail";
+            }
+
+            if (!signVerified) {
+                log.warn("支付宝回调验签失败: params={}", params);
+                return "fail";
+            }
+
+            // 验签通过,提取关键参数
+            String outTradeNo = params.get("out_trade_no");
+            String tradeNo = params.get("trade_no");
+            String tradeStatus = params.get("trade_status");
+            String totalAmount = params.get("total_amount");
+            String appId = params.get("app_id");
+
+            log.info("支付宝回调验签通过: outTradeNo={}, tradeNo={}, tradeStatus={}, totalAmount={}",
+                    outTradeNo, tradeNo, tradeStatus, totalAmount);
+
+            // 判断交易状态
+            if ("TRADE_SUCCESS".equals(tradeStatus) || "TRADE_FINISHED".equals(tradeStatus)) {
+                payService.handlePayCallback(outTradeNo, tradeNo, true, totalAmount, appId, params.toString());
+            } else {
+                log.warn("支付宝回调交易未成功: tradeStatus={}", tradeStatus);
+                payService.handlePayCallback(outTradeNo, tradeNo, false, totalAmount, appId, params.toString());
+            }
+
             return "success";
         } catch (Exception e) {
+            log.error("处理支付宝回调异常", e);
+            // 返回 fail 让支付宝重试,防止因服务器异常导致回调丢失
             return "fail";
         }
     }
+
+    /**
+     * 主动查询支付宝订单状态(兖底机制)。
+     * 当异步回调丢失时,前端可调用此接口主动查询支付宝侧的支付结果。
+     *
+     * @param tenantId 租户ID
+     * @param orderId  订单ID
+     * @return 支付结果
+     */
+    @GetMapping("/alipayQuery")
+    public RespInfo<PayResultVO> queryAlipayTradeStatus(@RequestParam Long tenantId,
+                                                         @RequestParam Long orderId) {
+        PayResultVO result = payService.queryAlipayTradeStatus(tenantId, orderId);
+        return RespInfo.success(result);
+    }
 }

+ 34 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/mapper/HotelPayLogMapper.java

@@ -1,10 +1,13 @@
 package com.mdframe.forge.business.core.hotel.pay.mapper;
 
+import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.mdframe.forge.business.core.hotel.pay.domain.HotelPayLog;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 
+import java.time.LocalDateTime;
+
 /**
  * 酒店支付流水Mapper。
  */
@@ -13,9 +16,40 @@ public interface HotelPayLogMapper extends BaseMapper<HotelPayLog> {
 
     /**
      * 根据商户订单号查询支付流水。
+     * 忽略租户拦截:out_trade_no 全局唯一,支付宝回调时无租户上下文。
      *
      * @param outTradeNo 商户订单号
      * @return 支付流水
      */
+    @InterceptorIgnore(tenantLine = "true")
     HotelPayLog selectByOutTradeNo(@Param("outTradeNo") String outTradeNo);
+
+    /**
+     * 根据订单ID查询最新的支付流水。
+     * 忽略租户拦截:主动查询时无租户上下文(已在 TenantContextHolder 中设置)。
+     *
+     * @param orderId 订单ID
+     * @return 最新的支付流水
+     */
+    HotelPayLog selectLatestByOrderId(@Param("orderId") Long orderId);
+
+    /**
+     * 数据库级幂等更新:仅当 pay_status != 1(未成功)时才更新为成功。
+     * 利用数据库行锁 + WHERE 条件保证原子性,防止并发回调重复处理。
+     * 忽略租户拦截:支付宝回调时无租户上下文。
+     *
+     * @param id         支付流水ID
+     * @param tradeNo    支付宝交易号
+     * @param payTime    支付时间
+     * @param notifyTime 通知时间
+     * @param notifyContent 通知原文
+     * @return 影响行数(1=更新成功,0=已处理过)
+     */
+    @InterceptorIgnore(tenantLine = "true")
+    int updatePayStatusSuccessByIdempotent(
+            @Param("id") Long id,
+            @Param("tradeNo") String tradeNo,
+            @Param("payTime") LocalDateTime payTime,
+            @Param("notifyTime") LocalDateTime notifyTime,
+            @Param("notifyContent") String notifyContent);
 }

+ 20 - 5
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/HotelPayService.java

@@ -28,13 +28,18 @@ public interface HotelPayService {
 
     /**
      * 处理支付回调(第三方支付平台异步通知)。
+     * <p>
+     * 安全机制:金额校验、app_id 校验、数据库级幂等。
      *
-     * @param outTradeNo 商户订单号
-     * @param tradeNo    第三方交易号
-     * @param success    是否支付成功
-     * @param rawContent 回调原文
+     * @param outTradeNo  商户订单号
+     * @param tradeNo     第三方交易号
+     * @param success     是否支付成功
+     * @param totalAmount 回调中的支付金额(元),用于校验
+     * @param appId       回调中的 app_id,用于校验
+     * @param rawContent  回调原文
      */
-    void handlePayCallback(String outTradeNo, String tradeNo, boolean success, String rawContent);
+    void handlePayCallback(String outTradeNo, String tradeNo, boolean success,
+                           String totalAmount, String appId, String rawContent);
 
     /**
      * 查询支付状态。
@@ -50,4 +55,14 @@ public interface HotelPayService {
      * @param orderId 订单ID
      */
     void cancelTimeoutOrder(Long orderId);
+
+    /**
+     * 主动查询支付宝订单状态(兜底机制)。
+     * 当异步回调丢失时,前端可调用此接口主动查询支付宝侧的支付结果。
+     *
+     * @param tenantId  租户ID
+     * @param orderId   订单ID
+     * @return 支付结果
+     */
+    PayResultVO queryAlipayTradeStatus(Long tenantId, Long orderId);
 }

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

@@ -1,8 +1,14 @@
 package com.mdframe.forge.business.core.hotel.pay.service.impl;
 
+import com.alipay.api.AlipayClient;
+import com.alipay.api.request.AlipayTradeQueryRequest;
+import com.alipay.api.request.AlipayTradeWapPayRequest;
+import com.alipay.api.response.AlipayTradeQueryResponse;
+import com.alipay.api.response.AlipayTradeWapPayResponse;
 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.mapper.HotelOrderMapper;
+import com.mdframe.forge.business.core.hotel.pay.config.AlipayConfig;
 import com.mdframe.forge.business.core.hotel.pay.domain.HotelPayLog;
 import com.mdframe.forge.business.core.hotel.pay.mapper.HotelPayLogMapper;
 import com.mdframe.forge.business.core.hotel.notification.manager.SseEmitterManager;
@@ -16,6 +22,7 @@ import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.time.format.DateTimeFormatter;
 import java.util.HashMap;
@@ -37,6 +44,12 @@ public class HotelPayServiceImpl implements HotelPayService {
     @Autowired
     private SseEmitterManager sseEmitterManager;
 
+    @Autowired
+    private AlipayClient alipayClient;
+
+    @Autowired
+    private AlipayConfig alipayConfig;
+
     /**
      * 服务类型标识,用于判断当前是 AdminServer 还是 AppServer。
      * 在 application.yml 中配置:server.type: admin 或 app
@@ -93,9 +106,45 @@ public class HotelPayServiceImpl implements HotelPayService {
         payParams.put("payAmount", order.getTotalAmount());
         payParams.put("paySource", payLog.getPaySource());
 
-        // TODO: 对接真实支付时,根据 paySource 调用微信/支付宝 SDK 生成支付参数
-        // if ("WECHAT".equals(paySource)) { ... wxpay unified order ... }
-        // if ("ALIPAY".equals(paySource)) { ... alipay trade create ... }
+        // 根据 paySource 调用不同支付渠道
+        if ("ALIPAY".equals(paySource)) {
+            // 支付宝 H5 手机网站支付:调用 alipay.trade.wap.pay
+            try {
+                AlipayTradeWapPayRequest request = new AlipayTradeWapPayRequest();
+                request.setNotifyUrl(alipayConfig.getNotifyUrl());
+                // 支付完成后跳转回 H5 支付页(自动检测已支付状态并跳转订单详情页)
+                String returnUrl = alipayConfig.getReturnUrl();
+                if (returnUrl != null && !returnUrl.isEmpty()) {
+                    // 动态追加 orderId 参数
+                    String separator = returnUrl.contains("?") ? "&" : "?";
+                    request.setReturnUrl(returnUrl + separator + "orderId=" + orderId);
+                }
+                String bizContent = "{\"out_trade_no\":\"" + outTradeNo + "\","
+                        + "\"total_amount\":\"" + order.getTotalAmount().toPlainString() + "\","
+                        + "\"subject\":\"酒店客房送餐-" + order.getOrderNo() + "\","
+                        + "\"product_code\":\"QUICK_WAP_WAY\","
+                        + "\"timeout_express\":\"15m\"}";
+                request.setBizContent(bizContent);
+                AlipayTradeWapPayResponse response = alipayClient.pageExecute(request);
+                if (response.isSuccess()) {
+                    String formHtml = response.getBody();
+                    payParams.put("payChannel", "ALIPAY");
+                    payParams.put("payForm", formHtml);
+                    log.info("支付宝H5支付创建成功: outTradeNo={}", outTradeNo);
+                } else {
+                    log.error("支付宝H5支付创建失败: code={}, msg={}", response.getCode(), response.getMsg());
+                    throw new BusinessException("支付宝创建支付失败: " + response.getSubMsg());
+                }
+            } catch (BusinessException e) {
+                throw e;
+            } catch (Exception e) {
+                log.error("调用支付宝SDK异常: orderId={}", orderId, e);
+                throw new BusinessException("调用支付宝支付失败: " + e.getMessage());
+            }
+        } else {
+            // MOCK 模式:只返回基础参数
+            payParams.put("payChannel", "MOCK");
+        }
 
         return payParams;
     }
@@ -123,29 +172,78 @@ public class HotelPayServiceImpl implements HotelPayService {
 
     // ==================== 支付回调 ====================
 
+    /**
+     * 处理支付回调(第三方支付平台异步通知)。
+     * <p>
+     * 安全机制:
+     * 1. 金额校验:验证回调金额与订单金额一致
+     * 2. app_id 校验:验证回调来源
+     * 3. 数据库级幂等:用 UPDATE WHERE pay_status != 1 防止并发重复处理
+     *
+     * @param outTradeNo  商户订单号
+     * @param tradeNo     第三方交易号
+     * @param success     是否支付成功
+     * @param totalAmount 回调中的支付金额(元),用于校验
+     * @param appId       回调中的 app_id,用于校验
+     * @param rawContent  回调原文
+     */
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public void handlePayCallback(String outTradeNo, String tradeNo, boolean success, String rawContent) {
+    public void handlePayCallback(String outTradeNo, String tradeNo, boolean success,
+                                  String totalAmount, String appId, String rawContent) {
+        // 查询支付流水(忽略租户拦截,支付宝回调无租户上下文)
         HotelPayLog payLog = payLogMapper.selectByOutTradeNo(outTradeNo);
         if (payLog == null) {
             log.warn("支付回调: 找不到支付流水, outTradeNo={}", outTradeNo);
             return;
         }
 
-        // 幂等校验:如果已经处理过,直接返回
-        if (payLog.getPayStatus() == HotelOrderConstants.PAY_STATUS_SUCCESS) {
-            log.info("支付回调: 已处理过, outTradeNo={}", outTradeNo);
+        // 用 payLog 中的 tenantId 设置租户上下文,后续所有 DB 操作都需要租户上下文
+        TenantContextHolder.executeWithTenant(payLog.getTenantId(), new Runnable() {
+            @Override
+            public void run() {
+                processPayCallback(payLog, tradeNo, success, totalAmount, appId, rawContent);
+            }
+        });
+    }
+
+    /**
+     * 在租户上下文中处理支付回调。
+     */
+    private void processPayCallback(HotelPayLog payLog, String tradeNo, boolean success,
+                                    String totalAmount, String appId, String rawContent) {
+        // 安全校验 1:验证 app_id
+        if (appId != null && !appId.isEmpty() && !appId.equals(alipayConfig.getAppId())) {
+            log.error("支付回调: app_id不匹配, expected={}, actual={}", alipayConfig.getAppId(), appId);
             return;
         }
 
+        // 安全校验 2:验证支付金额
+        if (success && totalAmount != null && !totalAmount.isEmpty()) {
+            try {
+                BigDecimal callbackAmount = new BigDecimal(totalAmount);
+                if (callbackAmount.compareTo(payLog.getPayAmount()) != 0) {
+                    log.error("支付回调: 金额不匹配, expected={}, actual={}, outTradeNo={}",
+                            payLog.getPayAmount(), callbackAmount, payLog.getOutTradeNo());
+                    return;
+                }
+            } catch (NumberFormatException e) {
+                log.error("支付回调: 金额格式异常, totalAmount={}", totalAmount, e);
+                return;
+            }
+        }
+
         if (success) {
-            // 支付成功
-            payLog.setTradeNo(tradeNo);
-            payLog.setPayStatus(HotelOrderConstants.PAY_STATUS_SUCCESS);
-            payLog.setPayTime(LocalDateTime.now());
-            payLog.setNotifyTime(LocalDateTime.now());
-            payLog.setNotifyContent(rawContent);
-            payLogMapper.updateById(payLog);
+            // 数据库级幂等更新:仅当 pay_status != 1 时才更新,利用数据库行锁保证原子性
+            LocalDateTime now = LocalDateTime.now();
+            int updated = payLogMapper.updatePayStatusSuccessByIdempotent(
+                    payLog.getId(), tradeNo, now, now, rawContent);
+
+            if (updated == 0) {
+                // 影响行数为 0,说明已经被其他回调处理过了(幂等)
+                log.info("支付回调: 已处理过(幂等), outTradeNo={}", payLog.getOutTradeNo());
+                return;
+            }
 
             // 更新订单状态
             HotelOrder order = orderMapper.selectById(payLog.getOrderId());
@@ -154,23 +252,21 @@ public class HotelPayServiceImpl implements HotelPayService {
                 if (order.getPayStatus() == null || order.getPayStatus() != HotelOrderConstants.PAY_STATUS_SUCCESS) {
                     order.setStatus(HotelOrderConstants.STATUS_PLACED);
                     order.setPayStatus(HotelOrderConstants.PAY_STATUS_SUCCESS);
-                    order.setPayTime(LocalDateTime.now());
+                    order.setPayTime(now);
                     order.setPayTradeNo(tradeNo);
                     order.setPaidAmount(payLog.getPayAmount());
                     order.setPaySource(payLog.getPaySource());
                     orderMapper.updateById(order);
-                    log.info("支付成功: orderId={}, orderNo={}, tradeNo={}", order.getId(), order.getOrderNo(), tradeNo);
+                    log.info("支付成功: orderId={}, orderNo={}, tradeNo={}, amount={}",
+                            order.getId(), order.getOrderNo(), tradeNo, payLog.getPayAmount());
 
                     // 发布新订单通知到SSE(触发PC端声音提醒)
                     try {
-                        // 使用 @Value 注入的 serverType 判断当前服务类型
                         if ("app".equals(serverType)) {
-                            // AppServer 服务:通过 HTTP 调用 AdminServer 的远程通知接口
                             log.info("检测到 AppServer 服务 (serverType={}),通过 HTTP 调用 AdminServer 推送 SSE 通知", serverType);
                             notifyRemoteAdminServer(order.getId(), order.getOrderNo(), order.getRoomNo(),
                                     order.getContactName(), order.getTotalAmount() != null ? order.getTotalAmount().toPlainString() : "0");
                         } else {
-                            // AdminServer 服务:直接推送 SSE 通知
                             log.info("检测到 AdminServer 服务 (serverType={}),直接推送 SSE 通知", serverType);
                             sseEmitterManager.notifyNewOrder(
                                     order.getId(),
@@ -188,11 +284,11 @@ public class HotelPayServiceImpl implements HotelPayService {
             }
         } else {
             // 支付失败
-            payLog.setPayStatus(2); // 支付失败
+            payLog.setPayStatus(2);
             payLog.setNotifyTime(LocalDateTime.now());
             payLog.setNotifyContent(rawContent);
             payLogMapper.updateById(payLog);
-            log.warn("支付失败: outTradeNo={}", outTradeNo);
+            log.warn("支付失败: outTradeNo={}", payLog.getOutTradeNo());
         }
     }
 
@@ -225,6 +321,67 @@ public class HotelPayServiceImpl implements HotelPayService {
         return vo;
     }
 
+    // ==================== 主动查询支付宝订单状态(兖底机制) ====================
+
+    @Override
+    public PayResultVO queryAlipayTradeStatus(Long tenantId, Long orderId) {
+        return TenantContextHolder.executeWithTenant(tenantId,
+                new java.util.function.Supplier<PayResultVO>() {
+                    @Override
+                    public PayResultVO get() {
+                        return doQueryAlipayTradeStatus(orderId);
+                    }
+                });
+    }
+
+    private PayResultVO doQueryAlipayTradeStatus(Long orderId) {
+        HotelOrder order = orderMapper.selectById(orderId);
+        if (order == null) {
+            throw new BusinessException("订单不存在");
+        }
+
+        // 如果订单已经支付成功,直接返回
+        if (order.getPayStatus() != null && order.getPayStatus() == HotelOrderConstants.PAY_STATUS_SUCCESS) {
+            return queryPayStatus(orderId);
+        }
+
+        // 查找该订单的最新支付流水,获取真实的 outTradeNo
+        HotelPayLog payLog = payLogMapper.selectLatestByOrderId(orderId);
+        if (payLog == null || payLog.getOutTradeNo() == null) {
+            log.warn("主动查询: 找不到支付流水, orderId={}", orderId);
+            return queryPayStatus(orderId);
+        }
+
+        String outTradeNo = payLog.getOutTradeNo();
+
+        try {
+            AlipayTradeQueryRequest request = new AlipayTradeQueryRequest();
+            request.setBizContent("{\"out_trade_no\":\"" + outTradeNo + "\"}");
+            AlipayTradeQueryResponse response = alipayClient.execute(request);
+
+            if (response.isSuccess()) {
+                String tradeStatus = response.getTradeStatus();
+                log.info("主动查询支付宝订单状态: outTradeNo={}, tradeStatus={}", outTradeNo, tradeStatus);
+
+                if ("TRADE_SUCCESS".equals(tradeStatus) || "TRADE_FINISHED".equals(tradeStatus)) {
+                    // 支付宝侧已支付成功,但本地未更新,触发回调处理
+                    log.warn("支付宝侧已支付但本地未更新,触发补偿: outTradeNo={}, tradeNo={}",
+                            outTradeNo, response.getTradeNo());
+                    handlePayCallback(outTradeNo, response.getTradeNo(), true,
+                            response.getTotalAmount(), alipayConfig.getAppId(),
+                            "[主动查询补偿] tradeStatus=" + tradeStatus);
+                }
+            } else {
+                log.warn("主动查询支付宝订单失败: code={}, msg={}", response.getCode(), response.getSubMsg());
+            }
+        } catch (Exception e) {
+            log.error("主动查询支付宝订单异常: orderId={}", orderId, e);
+        }
+
+        // 无论查询结果如何,都返回当前本地状态
+        return queryPayStatus(orderId);
+    }
+
     // ==================== 取消超时订单 ====================
 
     @Override
@@ -275,11 +432,13 @@ public class HotelPayServiceImpl implements HotelPayService {
         // 执行回调处理(在租户上下文中执行)
         final String finalOutTradeNo = outTradeNo;
         final String finalMockTradeNo = mockTradeNo;
+        final String finalAmount = order.getTotalAmount().toPlainString();
         TenantContextHolder.executeWithTenant(order.getTenantId(),
                 new Runnable() {
                     @Override
                     public void run() {
                         handlePayCallback(finalOutTradeNo, finalMockTradeNo, true,
+                                finalAmount, alipayConfig.getAppId(),
                                 "{\"mock\": true, \"trade_no\": \"" + finalMockTradeNo + "\"}");
                     }
                 });

+ 27 - 0
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/pay/HotelPayLogMapper.xml

@@ -36,4 +36,31 @@
         LIMIT 1
     </select>
 
+    <!-- 根据订单ID查询最新的支付流水 -->
+    <select id="selectLatestByOrderId" resultMap="PayLogMap">
+        SELECT id, order_id, order_no, pay_channel, pay_source, trade_no,
+               out_trade_no, pay_amount, pay_status, pay_time, notify_time,
+               notify_content, refund_no, refund_time, refund_amount,
+               tenant_id, del_flag, create_time, update_time
+        FROM hotel_pay_log
+        WHERE del_flag = 0
+          AND order_id = #{orderId}
+        ORDER BY create_time DESC
+        LIMIT 1
+    </select>
+
+    <!-- 数据库级幂等更新:仅当 pay_status != 1 时才更新为成功 -->
+    <update id="updatePayStatusSuccessByIdempotent">
+        UPDATE hotel_pay_log
+        SET trade_no = #{tradeNo},
+            pay_status = 1,
+            pay_time = #{payTime},
+            notify_time = #{notifyTime},
+            notify_content = #{notifyContent},
+            update_time = NOW()
+        WHERE id = #{id}
+          AND del_flag = 0
+          AND pay_status != 1
+    </update>
+
 </mapper>

+ 129 - 15
forge-server/forge-business/forge-hotel/酒店模块迁移跟踪.md

@@ -71,6 +71,28 @@ order/                                  ✅ 订单管理子包(独立于已有
 │       └── HotelOrderServiceImpl.java  ✅ 完整状态机 + 退单流程 + 支付超时处理
 ├── controller/
 │   └── HotelOrderController.java       ✅ 15+ 个 REST 接口
+
+pay/                                    ✅ 支付系统子包(支付宝沙箱 H5 WAP Pay + 安全加固)
+├── config/
+│   └── AlipayConfig.java               ✅ 支付宝配置(appId/密钥/网关/回调地址,@ConfigurationProperties)
+├── constant/
+│   └── HotelPayConstants.java          ✅ 支付常量(payStatus 0/1/3、paySource MOCK/WECHAT/ALIPAY)
+├── domain/
+│   └── HotelPayLog.java                ✅ 支付流水实体(outTradeNo/tradeNo/payAmount/payStatus)
+── dto/
+│   └── PayCreateDTO.java               ✅ 创建支付请求(orderId + paySource)
+├── vo/
+│   └── PayResultVO.java                ✅ 支付结果视图(payStatus/payForm/tradeNo)
+├── mapper/
+│   ├── HotelPayLogMapper.java          ✅ 含 @InterceptorIgnore + 幂等更新 + 按orderId查询
+│   └── xml/HotelPayLogMapper.xml       ✅ 自定义 SQL(幂等 UPDATE WHERE pay_status != 1)
+├── service/
+│   ├── HotelPayService.java            ✅ 支付接口(createPay/handlePayCallback/queryAlipayTradeStatus)
+│   └── impl/
+│       └── HotelPayServiceImpl.java    ✅ 支付宝 WAP Pay + 回调处理 + 安全加固(5层保障)
+├── controller/
+│   └── open/
+│       └── HotelPayController.java     ✅ 支付接口(create/payNotify/returnUrl/status/alipayQuery/mockSuccess)
 ```
 forge-admin-ui/src/views/hotel/
 ├── qrcode.vue                          ✅ 二维码管理页面(AiCrudPage)
@@ -91,8 +113,8 @@ forge-h5-ui/src/pages/hotel/
     ├── menu.vue                        ✅ 点餐菜单页 Tab1(配送信息栏/Banner/搜索/分类/菜品列表/购物车浮层)
     ├── dish-detail.vue                 ✅ 菜品详情页(大图/规格选择/加料/备注/数量/加入购物车)
     ├── cart.vue                        ✅ 购物车页 Tab3(商品列表/数量控制/备注/费用汇总/结算)
-    ├── order-confirm.vue               ✅ 确认下单页(配送信息/送达时间/明细/支付方式/提交
-    ├── pay.vue                         ✅ 支付确认页(后端获取金额/模拟微信支付/倒计时/返回菜单
+    ├── order-confirm.vue               ✅ 确认下单页(配送信息/送达时间/明细/提交,已移除硬编码支付方式)
+    ├── pay.vue                         ✅ 支付确认页(支付宝 H5 WAP Pay + 轮询 + 主动查询兜底 + 倒计时
     ├── order-status.vue                ✅ 订单状态页 Tab2(进度条/状态卡片/10秒轮询/自定义退单弹窗)
     └── orders.vue                      ✅ 订单列表页 Tab2(订单卡片列表/分页加载/状态标签/待支付去支付按钮)
 
@@ -106,7 +128,7 @@ forge-h5-ui/src/store/modules/
 └── hotel-order.js                      ✅ Pinia 购物车 Store(roomInfo/cartItems/orderNote,持久化)
 
 forge-h5-ui/src/api/
-└── index.js                            ✅ 新增 9 个顾客端 API(扫码/分类/菜品/订单CRUD/退单)
+└── index.js                            ✅ 新增 12+ 个顾客端 API(扫码/分类/菜品/订单CRUD/退单/支付/支付宝查询
 ```
 
 ---
@@ -123,6 +145,7 @@ forge-h5-ui/src/api/
 | 6 | hotelconfig(酒店基础配置) | ❌ | ❌ | ❌ | — | **暂缓**(暂不需要,配送费 + 预计配送时长待讨论) |
 | 7 | dish(菜品管理) | ✅ | ✅ | ✅ | — | **核心已完成**(加料/日志待开发) |
 | 8 | order(订单管理) | ✅ | ✅ | ✅ | ✅ | **全部完成**(后端+PC管理端+H5顾客端+支付系统) |
+| 8.1 | pay(支付系统) | ✅ | ✅ | — | ✅ | **全部完成**(支付宝沙箱 H5 WAP Pay + 安全加固 + 前端轮询兜底) |
 | 9 | notification(实时通知) | ✅ | — | ✅ | — | **已完成**(SSE + Web Audio声音 + 桌面通知 + 防抖合并) |
 
 ---
@@ -298,10 +321,72 @@ forge-h5-ui/src/api/
   - 原因输入弹窗(拒单/退单审核共用)
 - [x] API: `forge-admin-ui/src/api/hotel.js` 中追加 14 个订单接口函数
 
+#### 支付系统(pay 子包)⭐⭐⭐
+
+> **状态**: 全部完成。支付宝沙箱 H5 WAP Pay 全链路集成 + 5 层安全加固 + 前端轮询兜底。
+
+##### 后端
+
+- [x] 配置:`AlipayConfig.java` — 支付宝配置(appId/privateKey/alipayPublicKey/gatewayUrl/notifyUrl/returnUrl,@ConfigurationProperties 注入)
+- [x] 常量:`HotelPayConstants.java` — 支付状态常量(0=待支付/1=成功/3=超时)+ 支付渠道(MOCK/WECHAT/ALIPAY)
+- [x] 实体:`HotelPayLog.java` — 支付流水(outTradeNo/tradeNo/payAmount/payStatus/payTime/notifyContent)
+- [x] DTO: `PayCreateDTO.java` — 创建支付请求(orderId + paySource)
+- [x] VO: `PayResultVO.java` — 支付结果(payStatus/payForm/tradeNo)
+- [x] Mapper: `HotelPayLogMapper.java` + XML
+  - `@InterceptorIgnore(tenantLine = "true")` 绕过租户拦截(回调无登录态)
+  - `updatePayStatusSuccessByIdempotent` — 数据库级幂等更新(`UPDATE WHERE pay_status != 1`)
+  - `selectLatestByOrderId` — 按订单 ID 查最新支付流水(主动查询兜底用)
+- [x] Service: `HotelPayService.java` — 支付接口(createPay/handlePayCallback/queryAlipayTradeStatus/queryPayStatus)
+- [x] Service: `HotelPayServiceImpl.java` — 完整支付实现
+  - `createPay()` — 根据 paySource 创建支付(ALIPAY → AlipayTradeWapPayRequest → 返回 payForm HTML)
+  - `handlePayCallback()` — 支付宝异步回调处理(拆分两步:查 payLog 获取 tenantId → TenantContextHolder.executeWithTenant 包裹业务处理)
+  - `queryAlipayTradeStatus()` — 主动查询支付宝侧订单状态(回调丢失时的兜底机制)
+- [x] Controller: `HotelPayController.java` — 5 个接口
+  - `POST /hotel/open/pay/create` — 创建支付(返回 payForm/tradeNo)
+  - `POST /hotel/open/pay/payNotify` — 支付宝异步回调(签名验证 + 金额校验 + app_id 校验 + 幂等更新 + 异常返回 fail 触发重试)
+  - `GET /hotel/open/pay/returnUrl` — 支付宝同步跳转(前端轮询检测支付状态)
+  - `GET /hotel/open/pay/status` — 查询支付状态(前端轮询用)
+  - `GET /hotel/open/pay/alipayQuery` — 主动查询支付宝侧订单状态(兜底机制)
+  - `POST /hotel/open/pay/mockSuccess` — Mock 支付(开发调试用)
+
+##### 数据库
+
+- [x] 建表:`hotel_pay_log`(IF NOT EXISTS)— 支付流水表(outTradeNo/tradeNo/payAmount/payStatus/payTime/notifyTime/notifyContent)
+- [x] 迁移脚本:`V1.0.106__add_hotel_pay_log_table.sql`
+
+##### 安全加固(5 层保障)
+
+| # | 保障层 | 说明 |
+|---|--------|------|
+| 1 | RSA2 签名验证 | `AlipaySignature.rsaCheckV1` 验证回调签名,防伪造 |
+| 2 | app_id 校验 | 验证回调中的 app_id 与配置一致,防跨应用攻击 |
+| 3 | 金额校验 | 回调金额与订单金额比对,不一致则拒绝 |
+| 4 | 数据库级幂等 | `UPDATE WHERE pay_status != 1` 利用行锁保证原子性,防并发重复处理 |
+| 5 | 主动查询兜底 | 前端轮询超时后调用 `/alipayQuery` 主动查支付宝侧状态,补偿丢失的回调 |
+
+##### 异常场景覆盖
+
+| 场景 | 防护措施 |
+|------|----------|
+| 事务处理中断电 | `@Transactional` 保证原子性,全部回滚 |
+| 回调时网络中断 | 返回 "fail",支付宝 24h 内自动重试(约 8 次) |
+| 并发重复回调 | 数据库 `UPDATE WHERE pay_status != 1` 行锁幂等 |
+| 回调全部丢失 | 前端 `/alipayQuery` 主动查支付宝侧订单状态 |
+| 服务器长时间宕机 | 支付宝重试 + 恢复后自动处理 |
+
+##### 前端(pay.vue)
+
+- [x] 支付宝 H5 WAP Pay:后端返回 payForm(HTML 表单),前端渲染后自动提交跳转支付宝收银台
+- [x] 支付环境自动识别:条件编译(MP-WEIXIN/MP-ALIPAY/H5)+ UA 检测(微信内置浏览器 → 微信支付,其他 → 支付宝)
+- [x] 递归 setTimeout 轮询:首次延迟 3 秒,间隔 2 秒,最多 60 次(120 秒),检测支付状态
+- [x] 轮询超时兜底:超时后调用 `/alipayQuery` 主动查询支付宝侧订单状态
+- [x] 页面加载自动轮询:从支付宝返回后自动检测支付状态(无需用户操作)
+- [x] 已支付直接跳转:订单 payStatus=1 时直接跳转订单状态页
+
 #### H5 顾客端(forge-h5-ui)
 
 - [x] 基础设施:
-  - `api/index.js` — 新增 9 个顾客端 API 函数
+  - `api/index.js` — 新增 12+ 个顾客端 API 函数(含支付/支付宝查询)
   - `store/modules/hotel-order.js` — Pinia 购物车 Store(roomInfo/cartItems/orderNote,persist 持久化)
   - `styles/hotel-theme.css` — 亚朵深墨绿主题 CSS 变量(`--h-pri: #2D5016` 等)
   - `components/hotel/HotelTabBar.vue` — 3-tab 自定义 TabBar(点餐/订单/购物车),使用 `uni.reLaunch` 导航(已移除"我的"tab,避免与系统 tabBar 冲突)
@@ -319,14 +404,23 @@ forge-h5-ui/src/api/
 - [x] 页面:`forge-h5-ui/src/pages/hotel/customer/cart.vue` — 购物车页(Tab3)
   - 商品列表(图片/规格描述/数量控制)、订单备注
   - 费用汇总(菜品小计 + 配送费)、结算按钮
+  - **已修复**: 结算按钮添加 `box-sizing: border-box` 防止越界
 - [x] 页面:`forge-h5-ui/src/pages/hotel/customer/order-confirm.vue` — 确认下单页
   - 配送信息(房间号/联系人/联系电话)、送达时间选择(立即/30分钟/1小时)
-  - 订单明细、支付方式(微信自动匹配)、提交订单(调用 `/hotel/order` 创建)
+  - 订单明细、提交订单(调用 `/hotel/order` 创建)
   - **已修复**: `unitPrice` 字段从 `item.price`(基础价)改为 `item.unitPrice`(含规格/加料加价)
+  - **已清理**: 移除硬编码的"支付方式"卡片(非小程序自动适配)
+  - **已修复**: 提交按钮添加 `box-sizing: border-box` 防止越界
 - [x] 页面:`forge-h5-ui/src/pages/hotel/customer/pay.vue` — 支付确认页
-  - 金额展示(从后端 API 获取真实金额,非 URL 参数)、微信支付模拟、支付成功后跳转订单状态页
+  - 金额展示(从后端 API 获取真实金额,非 URL 参数)
+  - 支付宝 H5 WAP Pay(payForm 渲染 + 自动提交跳转收银台)
+  - 支付环境自动识别(条件编译 + UA 检测)
+  - 递归 setTimeout 轮询(首次 3 秒延迟,间隔 2 秒,最多 120 秒)
+  - 轮询超时兜底(主动查询支付宝侧订单状态)
   - 返回修改按钮(redirectTo 到菜单页,因购物车已清空)
   - loading 状态、支付倒计时
+  - **已清理**: 移除"支付方式"卡片(非小程序自动适配)
+  - **已修复**: 支付按钮添加 `box-sizing: border-box` 防止越界
 - [x] 页面:`forge-h5-ui/src/pages/hotel/customer/order-status.vue` — 订单状态页(Tab2)
   - 状态大卡片(图标 + 文本 + 提示,渐变色按状态区分)
   - 水平进度条(已下单→已接单→备餐中→已出餐→配送中→已送达)
@@ -393,11 +487,13 @@ forge-h5-ui/src/api/
 
 #### 后端
 
-- [x] Manager: `SseEmitterManager.java` — SSE 长连接管理 + 广播(ConcurrentHashMap + AtomicInteger)
+- [x] Manager: `SseEmitterManager.java` — SSE 长连接管理 + 广播(ConcurrentHashMap + AtomicInteger)+ 远程接收(receiveNewOrderFromRemote)
 - [x] Service: `OrderNotificationService.java` — 通知广播服务(notifyNewOrder + notifyRefundRequest)
-- [x] Controller: `NotificationController.java` — SSE 端点(subscribe/unsubscribe/connections)
-- [x] 集成: `HotelPayServiceImpl.java` — 支付成功后发布 NEW_ORDER 通知
+- [x] Controller: `NotificationController.java` — SSE 端点(subscribe/unsubscribe/connections/remote/notifyNewOrder
+- [x] 集成: `HotelPayServiceImpl.java` — 支付成功后发布 NEW_ORDER 通知(支持跨服务 HTTP 调用)
 - [x] 集成: `HotelOrderServiceImpl.java` — 客户申请退单时发布 REFUND_REQUEST 通知
+- [x] 跨服务通信: 8583(AppServer)支付成功后通过 HTTP POST 调用 8580(AdminServer)`/hotel/notification/remote/notifyNewOrder` 接口,解决双服务 SSE 内存不共享问题
+- [x] Sa-Token 白名单: 添加 `/hotel/notification/remote/**` 到免登录路径(服务间调用无登录态)
 
 #### 数据库
 
@@ -405,16 +501,21 @@ forge-h5-ui/src/api/
 
 #### 前端
 
-- [x] Composable: `useOrderNotification.js` — SSE 订阅 + Web Audio 声音 + 桌面通知 + 防抖合并
-- [x] 集成: `order.vue` — 接入 SSE 通知(声音提醒 + Naive UI 通知 + 看板/列表自动刷新 + 连接状态指示器)
+- [x] Composable: `useOrderNotification.js` — SSE 订阅 + MP3 声音 + 桌面通知 + 防抖合并 + Chrome 自动播放策略绕过
+- [x] 集成: `order.vue` — 接入 SSE 通知(声音提醒 + Naive UI 通知 + 看板/列表自动刷新 + 连接状态指示器 + 页面刷新自动检测待接单订单)
+- [x] 音频文件: `forge-admin-ui/public/女生-订单提醒.mp3` — MP3 语音提醒文件
 
 #### 技术决策
 
 - 不使用 Redis Pub/Sub(单 JVM 部署,直接调用 SseEmitterManager 即可,省去中间层)
 - SSE 端点使用 `@SaIgnore` 跳过登录校验(EventSource 无法携带 Authorization Header)
-- 声音使用 Web Audio API 合成(无需音频文件,新订单三声急促、退单两声低沉)
+- 声音使用 MP3 文件(`/女生-订单提醒.mp3`),通过 Audio 对象播放
 - 3 秒防抖窗口合并多个同时到达的通知(避免弹窗叠加)
 - 绿色呼吸灯指示 SSE 连接状态(看板工具栏右侧)
+- **跨服务通信**:PC端(8580 AdminServer)和移动端(8583 AppServer)是独立 JVM 进程,SSE 连接存储在各自内存中。8583 支付成功后通过 HTTP POST 调用 8580 的 `/hotel/notification/remote/notifyNewOrder` 接口,由 8580 推送 SSE 事件给 PC 端
+- **服务类型判断**:通过 `@Value("${server.type:admin}")` 注入自定义配置属性判断当前服务类型(`admin` 或 `app`),在两个服务的 `application.yml` 中分别配置 `server.type: admin/app`
+- **Chrome 自动播放策略**:页面加载时用户未交互,`audio.play()` 会被阻止。通过监听首次 `click/keydown` 事件,将播放失败的声音加入待播放队列,用户首次交互后自动补播
+- **页面刷新触发语音**:通过 `watch(connected, ...)` 监听 SSE 连接状态,连接成功后自动查询待接单订单(status=1)并触发语音提醒。重置按钮不触发
 
 ---
 
@@ -472,6 +573,12 @@ forge-h5-ui/src/api/
 | `hotel_order` | 订单主表(23+字段 + 4索引,V1.0.105 新增 payStatus/payExpireTime/paySource) | V1.0.104 + V1.0.105 |
 | `hotel_order_item` | 订单明细表(13字段 + 1索引) | V1.0.104 |
 
+### 已完成(pay 模块)
+
+| 表名 | 说明 | 迁移脚本 |
+|------|------|--------|
+| `hotel_pay_log` | 支付流水表(outTradeNo/tradeNo/payAmount/payStatus/payTime/notifyContent) | V1.0.106 |
+
 ### 待创建
 
 | 表名 | 说明 | 所属模块 |
@@ -543,8 +650,8 @@ forge-h5-ui/src/api/
 | 点餐菜单 | `forge-h5-ui/src/pages/hotel/customer/menu.vue` | Tab1 | 分类导航、菜品列表、搜索、购物车浮层 |
 | 菜品详情 | `forge-h5-ui/src/pages/hotel/customer/dish-detail.vue` | 子页 | 大图、规格选择、加料、备注、加入购物车 |
 | 购物车 | `forge-h5-ui/src/pages/hotel/customer/cart.vue` | Tab3 | 商品列表、数量控制、备注、费用汇总、结算 |
-| 确认下单 | `forge-h5-ui/src/pages/hotel/customer/order-confirm.vue` | 子页 | 配送信息、送达时间、明细、提交订单 |
-| 支付确认 | `forge-h5-ui/src/pages/hotel/customer/pay.vue` | 子页 | 后端获取金额、微信支付模拟、倒计时、返回菜单 |
+| 确认下单 | `forge-h5-ui/src/pages/hotel/customer/order-confirm.vue` | 子页 | 配送信息、送达时间、明细、提交订单(已移除硬编码支付方式) |
+| 支付确认 | `forge-h5-ui/src/pages/hotel/customer/pay.vue` | 子页 | 支付宝 H5 WAP Pay + 轮询 + 主动查询兜底 + 倒计时 |
 | 订单状态 | `forge-h5-ui/src/pages/hotel/customer/order-status.vue` | Tab2 | 进度条、状态卡片、10秒轮询、自定义退单弹窗 |
 | 历史订单 | `forge-h5-ui/src/pages/hotel/customer/orders.vue` | Tab2 | 订单卡片列表、分页加载、状态标签、待支付"去支付"按钮 |
 
@@ -585,7 +692,14 @@ forge-h5-ui/src/api/
 | H5 购物车 Store | Pinia + `pinia-plugin-persistedstate` 持久化,`specKey` 合并相同规格商品 |
 | H5 订单轮询 | `order-status.vue` 对未完成订单每 10 秒轮询刷新,`onUnmounted` 清理定时器 |
 | H5 后端对接 | 订单状态使用数字 0~10,后端不信任前端价格(自动从数据库重算),`unitPrice` 含规格/加料加价 |
+| 支付宝 H5 WAP Pay | 使用 `AlipayTradeWapPayRequest` + `pageExecute()` 返回 HTML 表单,前端渲染后自动提交跳转支付宝收银台 |
+| 支付回调租户上下文 | 支付宝回调无登录态/租户上下文,需从 payLog 提取 tenantId 后用 `TenantContextHolder.executeWithTenant()` 包裹后续 DB 操作 |
+| 支付回调幂等 | `UPDATE WHERE pay_status != 1` 利用数据库行锁保证原子性,替代 Java 层 if 检查 |
+| 支付回调异常处理 | catch 块返回 "fail" 让支付宝自动重试(之前返回 "success" 导致异常时支付宝不重试) |
+| 支付轮询机制 | 递归 setTimeout(非 setInterval),首次延迟 3 秒给回调留处理时间,超时后主动查支付宝兜底 |
+| vConsole 调试工具 | 开发阶段通过 CDN + 环境变量开启,联调完成后从 `index.html`、`App.vue`、`.env.production` 三处彻底移除 |
+| H5 按钮越界修复 | `width: 100%` + `padding` 组合需配合 `box-sizing: border-box` 防止溢出(cart/order-confirm/pay 三个页面) |
 
 ---
 
-*文档最后更新:2026-08-18(实时通知完成:SSE推送+Web Audio声音提醒+桌面通知+防抖合并+连接状态指示器,待开发:加料/日志)*
+*文档最后更新:2026-09-01(支付系统完成:支付宝沙箱 H5 WAP Pay + 5层安全加固 + 前端轮询兜底 + H5 UI 清理(vConsole 移除/支付方式卡片移除/按钮越界修复),待开发:加料/日志)*

+ 9 - 0
forge-server/forge-framework/forge-starter-parent/forge-starter-core/src/main/java/com/mdframe/forge/starter/core/exception/GlobalExceptionHandler.java

@@ -14,6 +14,7 @@ import org.springframework.web.bind.MissingServletRequestParameterException;
 import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.ResponseStatus;
 import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
 import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
 import org.springframework.web.multipart.MaxUploadSizeExceededException;
 import org.springframework.web.servlet.NoHandlerFoundException;
@@ -182,6 +183,14 @@ public class GlobalExceptionHandler {
     }
 
     /**
+     * 处理异步请求超时异常(SSE 连接超时属于正常行为,不应报 ERROR)。
+     */
+    @ExceptionHandler(AsyncRequestTimeoutException.class)
+    public void handleAsyncRequestTimeoutException(AsyncRequestTimeoutException e, HttpServletRequest request) {
+        log.debug("SSE连接超时(正常行为,EventSource会自动重连): URI={}", request.getRequestURI());
+    }
+
+    /**
      * 处理访问拒绝异常
      */
     @ExceptionHandler(AccessDeniedException.class)