# 酒店订单支付系统开发文档 > 开发时间:2026-08-17 最后校正:2026-09-07 > 功能模块:酒店顾客端支付流程 > **本文档职责**:**支付模块的实现权威**——回答「钱怎么走、协议差在哪里、哪里有资金安全风险」。具体包括:支付环境实际支持矩阵、`wap.pay` vs `trade.create` 协议差异、Mapper/Service/SQL 与并发修复、支付时序图、回调幂等与竞态、退款链路、`ALIPAY_MP` 方案代码、Mock 资金红线修复代码、配置项与文件清单。 > > **不在本文档维护**(避免双份失真;四份酒店文档均在 `forge-server/forge-business/forge-hotel/` **同一目录**): > - 需求验收对照、P0/P1 缺口台账、第 0~6 批补齐顺序 → `酒店模块需求缺口清单.md` > - 二维码 URL、扫码双端识别、17 个开放接口全清单 → `酒店二维码模块开发文档.md` > - 逐类代码地图、jeeplus→Forge 适配速查、表↔Flyway 版本映射 → `酒店模块迁移跟踪.md` > > 📁 本文档原位于仓库根目录,**2026-09-07 已用 `git mv` 移入 `forge-server/forge-business/forge-hotel/`**,与其余三份同目录,交叉引用统一用文件名。 > ⚠️ **本文档 v1.0 多处描述与实际代码不符,已于 2026-09-07 根据代码真相校正**。重点看【一、功能概述】的支付环境实际支持矩阵和【十、已知问题】的资金红线。 --- ## 一、功能概述 实现了酒店顾客端完整的支付流程,支持**先支付后接单**的业务模式。 ### 核心特性 - ✅ **支付前置**:订单创建后进入"待支付"状态,支付成功后才进入"待接单" - ✅ **15 分钟超时**:未支付订单自动取消,定时任务扫描处理 - ✅ **并发安全**:订单号生成、菜品销量累加均解决并发问题 - ✅ **支付流水**:完整的支付日志记录,支持对账 - ✅ **多状态管理**:待支付(0) → 已支付/待接单(1) → 后续流程;超时取消(10) - ✅ **支付宝 H5 WAP Pay**:`AlipayTradeWapPayRequest` + `pageExecute()` 返回 HTML 表单,前端渲染后自动提交跳转收银台 - ✅ **回调容灾**:异步回调验签(RSA2)+ 前端轮询 + `/alipayQuery` 主动查询兜底 - ✅ **全额退款**:`AlipayTradeRefundRequest`,4 个自动触发点 + 手动退款接口 ### ⚠️ 支付环境实际支持矩阵(原文「四种支付环境」说法错误) | 环境 | 声称 | **实际** | 原因 | |---|---|---|---| | H5(浏览器) | ✅ | ✅ **可用** | `ALIPAY` 渠道走 `wap.pay` 返 `payForm`,`#ifdef H5` 分支渲染表单并提交 | | Mock | ✅ | ⚠️ **仅开发环境可用**(2026-09-07 已收口) | `hotel.pay.mock-enabled` 默认 `false`;关闭时 `POST /hotel/open/pay/mockSuccess` 首行即抛「模拟支付已禁用」。dev 覆盖为 `true`,**H5 调试行为零变化**。见 10.1 | | 微信小程序 | ✅ | ❌ **未实现,但已不再免付款** | 后端无 JSAPI 下单分支;`resolvePayChannel` 对 `WECHAT`/`WECHAT_MP` 走 `mockOrReject()`,生产环境显式抛「微信支付暂未开通」,`pay.vue` 落到新增的 else 分支弹「暂无法在线支付」 | | 支付宝小程序 | ✅ | ❌ **仍不可用** | 后端 `ALIPAY` 渠道只 `put("payForm", ...)`,**不返 `tradeNo`**;前端 `#ifdef MP-ALIPAY` 分支执行 `my.tradePay({ tradeNO: undefined })` → 必然 fail | **结论**:真正能跑的只有 **H5(ALIPAY)+ 开发环境 Mock 两种**。支付宝小程序需新增 `ALIPAY_MP` 的 `trade.create` 分支(见 **7.1**)。 > 补充说明:`AlipayTradeWapPayRequest.pageExecute()` 是**本地签名即成功、不调网关**,用户实际付款前不创建交易,因此**没有 `trade_no`**。支付宝小程序的 `my.tradePay` 必须配 `alipay.trade.create`(强制要求 `buyer_id`)。 > 🔴 **2026-09-07 收口后必须注意的一个语义陷阱**:`resolvePayChannel("ALIPAY_MP")` 现在**返回 `"ALIPAY"`**(与 `ALIPAY` 归为同一渠道),目的是让 `createPay` 不再因 `paySource` 为 `ALIPAY_MP` 而误落 MOCK。但 `createPay` 内**仍只有 `wap.pay` 一条实现**,所以 `ALIPAY_MP` 目前拿到的是 `payForm`(小程序无法提交表单)—— 行为与改造前一致,**仍不可用,只是不再免付款**。第 3 批接入时必须按 7.1 补 `AlipayTradeCreateRequest` 分支,且分流条件要用**原始 `paySource`**(`payChannel` 已无法区分 `ALIPAY` 与 `ALIPAY_MP`)。 --- ## 二、数据库变更 ### 2.1 Flyway 迁移脚本 **文件**:`forge-server/db/migration/V1.0.105__add_hotel_order_payment_fields.sql` > MySQL 8.0 兼容:使用 `information_schema` + `PREPARE/EXECUTE` 模式替代 MariaDB 专有的 `IF NOT EXISTS` 扩展语法。 #### hotel_order 表新增字段 ```sql pay_status TINYINT -- 支付状态:0=待支付, 1=已支付, 2=已退款, 3=支付超时已取消 pay_expire_time DATETIME -- 支付截止时间 pay_time DATETIME -- 实际支付时间 pay_trade_no VARCHAR(128) -- 第三方支付交易号 paid_amount DECIMAL(10,2) -- 实付金额 pay_source VARCHAR(32) -- 支付来源:WECHAT/ALIPAY/H5/MOCK ``` #### 清理遗留字段 - `pay_method`:V1.0.104 创建但实体已移除,迁移脚本自动 DROP COLUMN #### 新增索引 - `idx_pay_expire`:加速支付超时订单扫描(`pay_status`, `pay_expire_time`) - `uk_order_no`:已在 V1.0.104 中创建,不重复 #### hotel_pay_log 表 记录每笔支付的完整流水,包括: - 订单关联(order_id, order_no) - 支付渠道(pay_channel: WECHAT/ALIPAY/MOCK) - 支付来源(pay_source: WECHAT/ALIPAY/H5/WECHAT_MP/ALIPAY_MP/MOCK) > ⚠️ **实际只写入 ALIPAY 与 MOCK 两种**。`WECHAT_MP` / `ALIPAY_MP` 仅在字典与字段注释中预留,**后端无任何分支写入这两个值**。 - 交易流水号(out_trade_no: 商户生成,trade_no: 第三方返回) - 支付金额、状态、时间等 #### 字典数据 新增字典类型: - `hotel_pay_status`:支付状态(0=待支付, 1=已支付, 2=已退款, 3=支付超时已取消) - `hotel_pay_source`:支付来源(WECHAT/ALIPAY/H5/MOCK 等) - `hotel_order_status`:补充 0=待支付、10=超时取消 --- ## 三、后端变更 ### 3.1 常量定义 **文件**:`HotelOrderConstants.java` ```java // 订单状态 STATUS_PENDING_PAY = 0 // 待支付(新增) STATUS_PLACED = 1 // 待接单(已支付) STATUS_PAY_TIMEOUT = 10 // 超时取消(新增) // 支付状态 PAY_STATUS_PENDING = 0 // 待支付 PAY_STATUS_SUCCESS = 1 // 已支付 PAY_STATUS_TIMEOUT = 2 // 支付超时 // 支付来源 PAY_SOURCE_WECHAT = "WECHAT" PAY_SOURCE_ALIPAY = "ALIPAY" PAY_SOURCE_H5 = "H5" PAY_SOURCE_MOCK = "MOCK" // 超时配置 PAY_TIMEOUT_MINUTES = 15 // 支付超时时间(分钟) ``` ### 3.2 实体类 #### HotelOrder.java 新增字段: - `payStatus`(Integer):支付状态 - `payExpireTime`(LocalDateTime):支付截止时间 - `payTime`(LocalDateTime):实际支付时间 - `payTradeNo`(String):支付交易号 - `paidAmount`(BigDecimal):实付金额 - `paySource`(String):支付来源 移除字段: - `payMethod`(已废弃,改用 paySource) #### HotelPayLog.java(新建) 支付流水实体,包含完整的支付记录字段。 ### 3.3 Mapper 层 #### HotelOrderMapper.java 新增方法: ```java List selectExpiredUnpaidOrderIds() ``` 查询所有超时未支付的订单 ID。 #### HotelOrderMapper.xml - `resultMap` 和 `OrderColumns`:添加支付相关字段映射 - `selectDashboard`、`countTodayOrders`、`sumTodayRevenue`:改为只统计已支付订单(`pay_status = 1`) - `selectOrderPage`:筛选条件从 `payMethod` 改为 `payStatus`、`paySource` - 新增 `selectExpiredUnpaidOrderIds`:查询超时订单 #### HotelDishMapper.java 新增方法: ```java int incrementSales(@Param("dishId") Long dishId, @Param("quantity") Integer quantity) ``` 原子更新菜品销量。 #### HotelDishMapper.xml 新增 SQL: ```xml UPDATE hotel_dish SET sales = IFNULL(sales, 0) + #{quantity}, update_time = NOW() WHERE id = #{dishId} AND del_flag = 0 ``` #### HotelPayLogMapper.java(新建) ```java HotelPayLog selectByOutTradeNo(@Param("outTradeNo") String outTradeNo) ``` 根据商户订单号查询支付流水。 ### 3.4 Service 层 #### HotelOrderServiceImpl.java **orderCreate() 方法重构**: - 订单创建后状态设为 `STATUS_PENDING_PAY`(待支付) - 设置 `payStatus = PAY_STATUS_PENDING` - 设置 `payExpireTime = now + 15 分钟` - 移除 `payMethod` 字段赋值 **generateOrderNo() 并发修复**: - 添加 3 次重试机制 - 每次重试前检查唯一性(`selectCount`) - 防御性唯一性检查:插入前再次验证订单号是否已存在 **incrementDishSales() 并发修复**: - 从"先查后写"改为原子 SQL 更新 - 调用 `dishMapper.incrementSales()` 直接执行 `SET sales = IFNULL(sales, 0) + #{quantity}` **customerRefund() 新增逻辑**: - 待支付订单(status=0):直接取消,状态改为 `STATUS_PAY_TIMEOUT` - 已支付订单(status=1):进入退单流程 #### HotelPayService.java(新建) 支付服务接口,定义核心方法: ```java Map createPay(Long orderId, String paySource) PayResultVO mockPaySuccess(Long orderId) void handlePayCallback(String outTradeNo, String tradeNo, boolean success, String rawContent) PayResultVO queryPayStatus(Long orderId) void cancelTimeoutOrder(Long orderId) ``` #### HotelPayServiceImpl.java(新建) **createPay()**: - 校验订单状态(必须为待支付) - 检查是否已过期 - **渠道解析前置(2026-09-07 新增)**:`String payChannel = resolvePayChannel(paySource)` 在写流水**之前**执行,非白名单渠道在此即被拒绝,不会先落一条 MOCK 流水再报错 - 生成商户订单号(`PAY + 时间戳 + 订单ID后6位`) - 创建支付流水(`hotel_pay_log`,`pay_channel` 取已解析的 `payChannel`) - 分流调用支付渠道:**一律用 `payChannel` 判断,禁止用原始 `paySource`**(否则 `ALIPAY_MP` 会漏进 else 分支被静默置为 MOCK) - 返回支付参数(Mock 模式返回 `payChannel=MOCK` + 基础参数) **resolvePayChannel(paySource)(私有,资金红线单一入口)**: | 入参 | 开关开启(dev) | 开关关闭(生产) | |---|---|---| | `ALIPAY` / `ALIPAY_MP` | 返回 `"ALIPAY"` | 返回 `"ALIPAY"` | | `WECHAT` / `WECHAT_MP` | 返回 `"MOCK"` | 抛「微信支付暂未开通,请使用支付宝支付或到前台付款」 | | `null` / 空白 | 返回 `"MOCK"` | 抛「支付渠道不能为空」 | | `MOCK` / `H5` | 返回 `"MOCK"` | 抛「模拟支付已禁用」 | | 其它未知值 | 返回 `"MOCK"` | 抛「不支持的支付渠道: xxx」 | **mockOrReject(reason)(私有,单一收口点)**:`hotelPayConfig.isMockEnabled()` 为 true 返回 `"MOCK"`,否则抛 `BusinessException(reason)`。**全仓不允许再出现裸 `return "MOCK"` 的兜底分支** —— 改造前 `resolvePayChannel` 有**两处**静默降级,正是免付款漏洞的成因。 **refundOrder(orderId)**: - 前置校验:仅 `payStatus=已支付` 可退;`refundStatus=已成功` 拦截重复退款;退款金额优先取 `paidAmount`,兼容历史数据取 `totalAmount` - **渠道判定必须显式(2026-09-07 修复的潜在资损点)**: - `paySource=MOCK` **或** 流水 `pay_channel=MOCK` **或** 无支付流水 → 无真实资金流,仅回写退款状态(`markRefundSuccess`) - `pay_channel=ALIPAY` → 走 `alipay.trade.refund` 全额退款 - 其余有流水但渠道未接入 → **抛「该支付渠道暂不支持在线退款,请联系前台人工处理」**,禁止静默标记成功 - 🔴 改造前用的是 `!"ALIPAY".equals(payChannel)` 兜底判 MOCK,**未来接入微信后微信订单退款会被误判为无资金流而直接标记成功 → 真实资损** - 退款请求号 `outRequestNo = outTradeNo + REFUND_REQUEST_NO_SUFFIX` 保持稳定,重试时命中支付宝幂等 - 失败/异常路径统一走 `reconcileRefund()` 对账(先按 `out_request_no` 查退款记录,再按 `TRADE_CLOSED` 兜底),对账未确认才 `markRefundResult(false)` 置可重试状态 - `markRefundSuccess()` 会**同步回写 `hotel_pay_log`** 的 `refund_no` / `refund_amount` / `refund_time` / `pay_status`(此前从未写入,导致资金流水与订单状态长期不一致、无法对账) **mockPaySuccess()**: - 模拟支付成功流程 - 创建 Mock 支付流水(如果不存在) - 触发支付回调处理 - ⚠️ Service 层**不再自行校验开关**,拦截统一放在 Controller 首行(见 3.5),避免开关语义分散 **handlePayCallback()**: - 幂等校验:已处理的订单直接返回 - 支付成功:更新流水状态、更新订单状态(0→1)、记录支付时间和交易号 - 支付失败:更新流水状态为失败 **cancelTimeoutOrder()**: - 更新订单状态为 `STATUS_PAY_TIMEOUT` - 更新支付状态为 `PAY_STATUS_TIMEOUT` **queryPayStatus()**: - 返回订单支付状态、金额、时间等信息 ### 3.5 Controller 层 #### HotelPayController.java(新建) 路径:`/hotel/open/pay/**`(免登录,已在 SaToken 白名单中) 接口列表: ``` POST /create 发起支付(创建支付流水) POST /mockSuccess 模拟支付成功(仅开发环境,受 hotel.pay.mock-enabled 控制) GET /status 查询支付状态(前端轮询) POST /notify 支付宝异步通知入口(RSA2 验签,失败返回 "fail" 让支付宝重试) GET /alipayQuery 主动查询支付宝侧订单状态(回调丢失时的兜底) ``` 所有接口都需要传入 `tenantId` 参数,通过 `TenantContextHolder.executeWithTenant()` 在指定租户上下文中执行。 **两处资金红线加固(2026-09-07 已落地)**: ```java // 1. /create 的 paySource 去掉 required=false + defaultValue="MOCK",改为必传 // 本接口免登录,缺省即免付款属于资金红线(AGENTS.md 5.9) @PostMapping("/create") public RespInfo> createPay(@RequestParam Long tenantId, @RequestParam Long orderId, @RequestParam String paySource) { ... } // 2. /mockSuccess 方法首行拦截(放在 Controller 而非 Service,保证开关语义单点) @PostMapping("/mockSuccess") public RespInfo mockPaySuccess(@RequestParam Long tenantId, @RequestParam Long orderId) { if (!hotelPayConfig.isMockEnabled()) { log.warn("模拟支付请求被拒绝(hotel.pay.mock-enabled=false): tenantId={}, orderId={}", tenantId, orderId); throw new BusinessException("模拟支付已禁用"); } ... } ``` > `/notify` 的 `log.info("收到支付宝回调通知: {}", params)` 打印的是**支付宝回调全量参数**(含 `buyer_id`、金额、交易号),不含手机号/身份证/银行卡,未违反 5.9 日志红线;但若后续回调参数扩展出买家实名信息,需同步脱敏。 ### 3.6 定时任务 #### PayTimeoutTask.java(新建) ```java @Scheduled(fixedDelay = 120000, initialDelay = 30000) public void cancelExpiredOrders() ``` - 每 2 分钟执行一次(启动后 30 秒开始) - 使用 `TenantContextHolder.executeIgnore()` 跨租户扫描 - 查询所有超时未支付的订单 ID - 逐条调用 `payService.cancelTimeoutOrder()` 取消 --- ## 四、前端变更(H5) ### 4.1 API 层 **文件**:`forge-h5-ui/src/api/index.js` 新增支付相关 API(实际写法,统一用 `request({ url, method, params })`,**不是** `request.post(url, data)`): ```javascript hotelPayCreate: (orderId, tenantId, paySource) => request({ url: '/hotel/open/pay/create', method: 'post', params: { orderId, tenantId, paySource } }) hotelPayMockSuccess: (orderId, tenantId) => request({ url: '/hotel/open/pay/mockSuccess', method: 'post', params: { orderId, tenantId } }) hotelPayStatus: (tenantId, orderId) => request({ url: '/hotel/open/pay/status', method: 'get', params: { tenantId, orderId } }) hotelPayAlipayQuery: (tenantId, orderId) => request({ url: '/hotel/open/pay/alipayQuery', method: 'get', params: { tenantId, orderId } }) ``` > ⚠️ 后端 `createPay` 用 `@RequestParam` 接收,所以三个参数均走 **query string**而非 request body。 ### 4.2 支付页面 **文件**:`forge-h5-ui/src/pages/hotel/customer/pay.vue` **核心功能**: - **15 分钟倒计时**:显示剩余时间(分:秒),超时自动跳转 - **支付分支(按后端返回的 `payChannel` 分流,2026-09-07 已改造)**: > ⚠️ **分流依据从 `paySource` 改为 `createData.payChannel`**。原写法用前端自算的 `paySource` 判断,导致后端一旦返回 `ALIPAY_MP` 就漏进 else 分支被当成 Mock。 | 分支 | 条件 | 实际行为 | |---|---|---| | ① `payChannel==='ALIPAY' && payForm` 的 `#ifdef H5` | H5 浏览器 | ✅ 将 `payForm` 写入动态创建的 `div#alipay-pay-form`,取到 `
` 后 `form.submit()` 跳收银台,随后 `startPayPolling()` | | ① 同分支的 `#ifdef MP-ALIPAY` | 支付宝小程序 | ❌ 执行 `my.tradePay({ tradeNO: createData.tradeNo })`,但此分支下 `tradeNo` **是 undefined**(后端只返 `payForm`)→ 必然 fail → 提示「支付取消或失败」。**第 3 批补 `trade.create` 后自然修好** | | ② `payChannel==='ALIPAY' && tradeNo` | 有 `tradeNo` 无 `payForm` | `#ifdef MP-ALIPAY` 走 `my.tradePay`;`#ifdef H5` 仅 `startPayPolling()` 兜底。**当前后端不会返回这个组合**,属预留分支 | | ③ `payChannel==='MOCK'` | 仅 `hotel.pay.mock-enabled=true` 时可能 | ✅ 调 `hotelPayMockSuccess` → 「支付成功」。**生产环境本分支永不命中** | | ④ `else`(2026-09-07 新增) | 后端未返回可用支付参数 | ✅ `uni.showModal({ title: '暂无法在线支付', content: '当前支付方式暂不可用,请使用支付宝完成支付,或联系前台办理付款。' })`,**不再兜底调 `hotelPayMockSuccess`** | > 🔴 **关于分支 ③④ 的关键纠正**:原方案写的是「**删除** else Mock 兜底分支」,**实际落地为「拆分而非删除」**。原因:直接删除会断掉 dev 环境的 H5 Mock 调试链路(用户硬约束「不可影响开发时的 H5 测试」)。拆分后:原逻辑收敛到 `payChannel==='MOCK'` 分支(只在后端开关开启时可达),新增的 `else` 负责显式报错。**生产环境行为与原方案一致,dev 行为零变化**。 - **轮询机制**:`startPayPolling()` 递归 setTimeout(非 setInterval),首次延迟 3 秒,间隔 2 秒,最多 60 次(120 秒),`data.payStatus === 1` 判定成功 - **兜底**:轮询超时后调 `queryAlipayTradeStatus()` 主动查支付宝侧状态,失败弹「支付结果未知」modal - **放弃支付**:`goBack()` 先 `uni.showModal` 确认 → `hotelOrderDetail` 恢复购物车 → 调 `hotelAbandonOrder`(**物理删除**) **UI 元素**: - 订单信息展示(订单号、金额、房间号) - 倒计时显示(红色高亮) - ~~支付方式选择~~ → **已移除**(改为按运行环境自动适配,页面上不展示支付方式卡片) - 支付按钮(已加 `box-sizing: border-box` 防越界) > ⚠️ **支付金额不依赖 URL 参数**:页面通过 `api.hotelOrderDetail()` 从后端取真实金额(购物车清空后 URL 参数会失效)。 ### 4.3 订单状态页 **文件**:`forge-h5-ui/src/pages/hotel/customer/order-status.vue` 新增状态处理: - **状态 0(待支付)**: - 图标:💳 - 文案:"等待支付" - 提示:"请在 15 分钟内完成支付" - 按钮:"去支付"(跳转到支付页) - **状态 10(超时取消)**: - 图标:⏰ - 文案:"支付超时,订单已取消" - 提示:"您可以重新下单" - `isFinished()` 返回 true(订单结束) ### 4.4 订单列表页 **文件**:`forge-h5-ui/src/pages/hotel/customer/orders.vue` **状态标签更新**: ```javascript statusLabel(status) { const map = { 0: '待支付', 1: '待接单', 2: '已接单', 3: '备餐中', 4: '已出餐', 5: '配送中', 6: '已完成', 7: '已拒单', 8: '退单审核中', 9: '已退单', 10: '超时取消', } return map[status] || '未知' } ``` **状态颜色**: - `.o-st--0`:黄色背景(待支付) - `.o-st--10`:灰色背景(超时取消) **待支付订单特殊处理**: - 显示"去支付"按钮 - 点击卡片跳转到支付页(而非订单状态页) ### 4.5 订单确认页 **文件**:`forge-h5-ui/src/pages/hotel/customer/order-confirm.vue` 更新跳转逻辑: - 下单成功后跳转到支付页(而非订单状态页) - 传递参数:`orderId`、`tenantId`、`amount` --- ## 五、并发问题修复 ### 5.1 订单号并发 **问题**:多个订单同时生成可能产生重复订单号 **解决方案**: 1. 数据库层面:`order_no` 添加唯一索引 2. 代码层面:`generateOrderNo()` 添加 3 次重试 3. 防御性检查:插入前 `selectCount` 验证唯一性 ### 5.2 菜品销量并发 **问题**:先查后写导致并发时销量丢失 **解决方案**: - 使用原子 SQL 更新:`SET sales = IFNULL(sales, 0) + #{quantity}` - 避免"读取-计算-写入"的竞态条件 --- ## 六、支付流程时序图 ``` 顾客 H5前端 后端API 数据库 | | | | |--下单----------->| | | | |--创建订单-------->| | | | |--插入订单(status=0)| | | |<-------------------| | |<--返回orderId-----| | | | | | |--跳转支付页----->| | | | |--创建支付-------->| | | | |--插入pay_log | | | |<-------------------| | |<--返回支付参数----| | | | | | |--点击支付------->| | | | |--模拟支付成功---->| | | | |--更新pay_log | | | |--更新订单(status=1)| | | |<-------------------| | |<--返回支付结果----| | | | | | |--跳转订单状态--->| | | | |--轮询支付状态---->| | | | |--查询订单 | | | |<-------------------| | |<--返回已支付------| | | | | | ``` --- ## 七、待对接的真实支付渠道 ### 7.1 支付宝小程序(优先级最高,沙箱可验证) **当前障碍**:`ALIPAY` 渠道走 `AlipayTradeWapPayRequest`,返回的是 HTML 表单字符串,**小程序无法提交表单**,且拿不到 `tradeNo`。 **方案:新增 `ALIPAY_MP` 渠道**(纯增量分支,不影响 H5): ```java if ("ALIPAY_MP".equals(paySource)) { AlipayTradeCreateRequest request = new AlipayTradeCreateRequest(); request.setNotifyUrl(alipayConfig.getNotifyUrl()); request.setBizContent("{" + "\"out_trade_no\":\"" + outTradeNo + "\"," + "\"total_amount\":\"" + order.getTotalAmount().toPlainString() + "\"," + "\"subject\":\"酒店客房送餐-" + order.getOrderNo() + "\"," + "\"buyer_id\":\"" + alipayUserId + "\"," + "\"timeout_express\":\"15m\"}"); AlipayTradeCreateResponse response = alipayClient.execute(request); if (!response.isSuccess()) { throw new BusinessException("支付宝创建交易失败: " + response.getSubMsg()); } payParams.put("payChannel", "ALIPAY_MP"); payParams.put("tradeNo", response.getTradeNo()); } ``` **`buyer_id` 来源**:已有的 `AlipayAuthController` `/hotel/open/alipay-auth/getUserInfo`(`my.getAuthCode` → authCode → 支付宝 `user_id`),`room-confirm.vue` L376-396 已在调用。链路完整可串。 **前端**:`pay.vue` 的 `#ifdef MP-ALIPAY` 分支只走 `tradeNo`,**不碰 `payForm`**。 **验证**:沙箱支付**只能在支付宝 IDE 内验证**,真机打不到沙箱网关(`openapi-sandbox.dl.alipaydev.com`)。 ### 7.2 微信小程序(商户号未开通,已暂缓) 1. `HotelPayServiceImpl.createPay()` 新增 `WECHAT_MP` 分支,调 JSAPI 下单(需 `openid` + 商户号) 2. 返回 `prepay_id` + 签名参数 3. 前端 `#ifdef MP-WEIXIN` 分支调 `wx.requestPayment()` 4. 微信异步通知 → 验签(**微信验签算法与支付宝不同**,不能复用 `AlipaySignature`) 5. 退款走微信退款接口(当前 `refundOrder()` 只有支付宝 + MOCK 两条路) > ⚠️ **后端三端共用**:即使微信暂缓上架,`resolvePayChannel` 也必须对 `WECHAT` / `WECHAT_MP` **显式抛 `BusinessException`**(如「微信支付暂未开通,请使用支付宝支付」),否则会静默降级到 Mock 造成免付款。 ### 7.3 配置项 **实际配置前缀是 `hotel.alipay.*`,不是 `forge.pay.*`**(见 `AlipayConfig` 的 `@ConfigurationProperties`): ```yaml # ===== application.yml(生产默认,两份服务均已落地)===== hotel: qr: base-url: ${FORGE_HOTEL_QR_BASE_URL:} # 无默认值→缺环境变量时为空串,二维码不可扫 path: ${FORGE_HOTEL_QR_PATH:/r/} # 小程序载体用 /r/,禁止配成 / pay: # AGENTS.md 5.9 资金红线:生产必须为 false mock-enabled: ${FORGE_HOTEL_PAY_MOCK_ENABLED:false} alipay: app-id: <上线时换正式 APPID> private-key: <禁止提交真实密钥> alipay-public-key: <禁止提交真实密钥> gateway-url: <上线时换正式网关> notify-url: <上线时换备案域名>/hotel/open/pay/notify return-url: <仅 forge-app-server 有> sandbox: false # ===== application-dev.yml(开发环境覆盖,行为零变化)===== hotel: qr: base-url: http://192.168.10.4:3001 path: "/#/pages/hotel/scan-bind" # dev 保持 H5 hash 路由旧值 pay: mock-enabled: true # 开发环境允许模拟支付 alipay: app-id: 9021000166699753 # 沙箱 APPID gateway-url: https://openapi-sandbox.dl.alipaydev.com/gateway.do notify-url: http://t2aaba9f.natappfree.cc/hotel/open/pay/notify sandbox: true ``` > ✅ **`hotel.pay.mock-enabled` 已于 2026-09-07 落地**,环境变量名是 **`FORGE_HOTEL_PAY_MOCK_ENABLED`**(本文档旧版误写为 `FORGE_HOTEL_PAY_MOCK`,已修正)。读取方:`pay/config/HotelPayConfig.java`,用 `@Value("${hotel.pay.mock-enabled:false}")` —— **代码级默认值也是 `false`**,即使 yml 整块缺失也不会误开。`@PostConstruct` 启动即打印开关状态,**开启时用 WARN 级别告警**,避免误配到生产无人察觉。 > 🔴 **六份 yml 必须同步**:`forge-admin-server` 与 `forge-app-server` 各有 `application.yml` / `application-dev.yml` / `application-dev.example.yml` 共 **6 份**,均已写入 `hotel.qr.base-url` / `hotel.qr.path` / `hotel.pay.mock-enabled`。 > > ⚠️ **改造前两份生产 `application.yml` 完全没有 `hotel` 块**,而 `HotelQrCodeServiceImpl` 的 `@Value("${hotel.qr.base-url}")` **无默认值** → 以生产 profile 启动**直接崩**。已一并补齐。 上线时切正式:`sandbox: false` + `gateway-url` 换线上网关 + `notify-url` 换备案域名。 > ⚠️ **`forge-app-server` 多一份 `return-url`**,当前值是 hash 路由 `http://192.168.10.4:3001/#/pages/hotel/customer/pay`,**支付宝同步回跳会丢参数**(第 3 批修)。 > > ⚠️ **`notify-url` 当前用 natapp 免费域名,重启即失效**,回调不稳。开发期建议以 `/alipayQuery` 主动查询为主。 > > 🔴 **已提交的沙箱密钥(遗留问题,不属本次改造引入)**:两份 `application-dev.yml` 内直接写了沙箱应用的 `private-key` / `alipay-public-key` 明文,且 `git check-ignore` 证实 **`application-dev.yml` 未被忽略**(AGENTS.md 2.4 把它归为「本地配置」,与实际不符)。虽为支付宝**沙箱**密钥(无真实资金风险),但已命中 5.9「禁止硬编码密钥」红线。建议单独提案:改为 `${FORGE_HOTEL_ALIPAY_PRIVATE_KEY:}` 占位 + 把 `application-dev.yml` 加入 `.gitignore`(需同步处理已入库的历史密钥轮换)。 --- ## 八、文件清单 ### 后端新增文件 1. `forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/domain/HotelPayLog.java` 2. `forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/mapper/HotelPayLogMapper.java` 3. `forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/pay/HotelPayLogMapper.xml` 4. `forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/HotelPayService.java` 5. `forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/impl/HotelPayServiceImpl.java` 6. `forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/vo/PayResultVO.java` 7. `forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/open/HotelPayController.java` 8. `forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/task/PayTimeoutTask.java` 9. `forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/config/HotelPayConfig.java`(**2026-09-07 新增**,读 `hotel.pay.mock-enabled` 的资金安全开关) ### 后端修改文件 1. `forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/constant/HotelOrderConstants.java` 2. `forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/domain/HotelOrder.java` 3. `forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/mapper/HotelOrderMapper.java` 4. `forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/order/HotelOrderMapper.xml` 5. `forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelDishMapper.java` 6. `forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelDishMapper.xml` 7. `forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/service/impl/HotelOrderServiceImpl.java` 8. `forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/vo/HotelOrderVO.java` > **2026-09-07 资金红线收口额外修改(第 0 批)**: > - `pay/service/impl/HotelPayServiceImpl.java` — 渠道解析前置、新增 `resolvePayChannel` / `mockOrReject`、`refundOrder` 渠道显式判定 > - `controller/open/HotelPayController.java` — `paySource` 改必传、`mockSuccess` 首行开关拦截 > - `controller/open/AlipayAuthController.java` — 新增 `maskPhone()`(保留前3后4) > - `service/impl/AlipayAuthServiceImpl.java` — 密文报文不再整包 `log.warn`,只记 `responseLength` > - **6 份 yml**:`forge-admin-server` 与 `forge-app-server` 各 `application.yml` / `application-dev.yml` / `application-dev.example.yml`,新增 `hotel.pay.mock-enabled`,并补齐生产 `application.yml` 缺失的整个 `hotel` 块 ### 数据库脚本 1. `forge-server/db/migration/V1.0.105__add_hotel_order_payment_fields.sql` ### 前端新增文件 无 ### 前端修改文件 1. `forge-h5-ui/src/api/index.js` 2. `forge-h5-ui/src/pages/hotel/customer/pay.vue` 3. `forge-h5-ui/src/pages/hotel/customer/order-status.vue` 4. `forge-h5-ui/src/pages/hotel/customer/orders.vue` 5. `forge-h5-ui/src/pages/hotel/customer/order-confirm.vue` --- ## 九、测试建议 ### 9.1 功能测试 1. **正常支付流程**: - 下单 → 跳转支付页 → 点击支付 → 支付成功 → 订单状态变为"待接单" 2. **超时取消**: - 下单 → 等待 15 分钟 → 订单自动取消 → 状态变为"超时取消" 3. **主动取消**: - 下单 → 在支付页点击"取消订单" → 订单状态变为"超时取消" 4. **重复支付防护**: - 已支付订单再次调用支付接口 → 返回"订单已支付"错误 ### 9.2 并发测试 1. **订单号唯一性**: - 同时创建 100 个订单 → 验证订单号无重复 2. **菜品销量准确性**: - 同时完成 10 个包含同一菜品的订单 → 验证销量累加正确 ### 9.3 边界测试 1. **支付超时边界**: - 在第 14 分 59 秒支付 → 应该成功 - 在第 15 分 01 秒支付 → 应该返回"订单已超时" 2. **库存不足**: - 下单时菜品库存不足 → 应该返回错误 --- ## 十、已知问题与优化建议 ### 10.1 已知问题 #### ~~🔴 P0 资金红线:Mock 支付接口免登录暴露~~ ✅ 已收口(2026-09-07) **原风险**:`POST /hotel/open/pay/mockSuccess` 落在 Sa-Token 白名单 `/hotel/open/**` 内,**完全免登录、无任何开关保护**。任何人不需任何凭证,只要能访问服务,枚举 `orderId` + `tenantId` 发一个 POST 就能把任意订单置为已支付(**免付款**),并触发后续接单、备餐、SSE 语音通知全链路。 **加重因素**:`createPay` 的 `paySource` 参数写的是 `@RequestParam(required = false, defaultValue = "MOCK")` —— **不传参数就默认走免付款渠道**。 **与小程序是否上架无关** —— 这是接口级漏洞,只要服务对公网可达就能被利用。 **实际落地(与原方案的差异已标注)**: | # | 处置 | 代码位置 | |---|---|---| | 1 | 新增 `HotelPayConfig`,`@Value("${hotel.pay.mock-enabled:false}")`;`@PostConstruct` 启动打印开关状态,开启时 WARN 告警 | `pay/config/HotelPayConfig.java`(新建) | | 2 | `mockSuccess` 首行拦截:`if (!hotelPayConfig.isMockEnabled())` → WARN + 抛 `BusinessException("模拟支付已禁用")`。**拦截放 Controller 而非 Service**,保证开关语义单点 | `HotelPayController.mockPaySuccess()` L83-86 | | 3 | `createPay` 去掉 `required=false` + `defaultValue="MOCK"`,改必传 | `HotelPayController.createPay()` L57-59 | | 4 | **渠道解析前置**:`resolvePayChannel(paySource)` 提到写流水之前,后续分流一律用 `payChannel` | `HotelPayServiceImpl.createPay()` L118 / L140 | | 5 | 抽 `mockOrReject(reason)` **单一收口点**,`resolvePayChannel` 全部 4 个非支付宝分支都走它,**全仓不再有裸 `return "MOCK"`** | `HotelPayServiceImpl` L821-851 | | 6 | 前端 `pay.vue` else 分支 —— ⚠️ **拆分而非删除**(原方案写「必须删除」,但删除会断掉 dev H5 Mock 调试链路):改判 `createData.payChannel`,`else if (payChannel==='MOCK')` 保留原逻辑,新 `else` 用 `uni.showModal` 显式报错 | `pay.vue` L204-286 | | 7 | 退款渠道误判(**改造中新发现的潜在资损**):原 `!"ALIPAY".equals(payChannel)` 兜底判 MOCK → 改为显式判定三种无资金流情形,其余非 ALIPAY 抛「请联系前台人工处理」 | `HotelPayServiceImpl.refundOrder()` L564-582 | **与原方案的两处实质差异**(review 时重点核对): 1. **方案里的「`createPay` 显式拒绝 MOCK」被更优实现取代**:原方案是先 `resolvePayChannel` 再 `if ("MOCK".equals(channel) && !mockEnabled) throw`。实际把开关判定**下沉到 `mockOrReject`**,好处是开关只在一个方法里读,不会出现「`resolvePayChannel` 放行、`createPay` 又拒」的双重判定走形;且每个拒绝分支自带**面向用户的差异化文案**(微信未开通 / 渠道为空 / 模拟已禁用 / 不支持的渠道),而不是统一的「支付渠道未开通」。 2. **`WECHAT` 的开关依赖不同**:原方案靠 `wechatPayProperties.isEnabled()` 判断。实际**未引入** `wechatPayProperties`(微信支付完全未实现,新增一个永远为 false 的配置项只会多一个误配风险面),直接走 `mockOrReject`。第 4 批实现微信支付时再引入该开关。 **验证结果(2026-09-07)**: - 后端全 reactor `mvn compile` **BUILD SUCCESS**;`forge-hotel` 单模块 97 个源文件编译通过 - `GetProblems` 12 个改动文件 **No errors found** - H5 `pnpm build:h5` **Build complete**(ExitCode=0) - 6 份 yml 的 `hotel` 块逐行核对一致 - **dev 环境行为零变化**:`mock-enabled=true` + `qr.path` 保持 hash 路由旧值,既有扫码与 Mock 支付链路不受影响 > 🔴 **AGENTS.md 5.9 合规状态:未闭环**。本节属资金类变更,**代码已落地但变更 Spec 尚未补写、人工审查尚未执行**。`code-copilot/rules/security.md` 要求「涉及资金变更的逻辑,必须在 spec 中明确标注,**人工审查后方可编码**」—— 本次是**先编码后补审查**,属流程倒置。建议变更名 `hotel-pay-mock-hardening`,补 `spec.md`(第 8 节标注资金风险 + 第 11 节回填实际改动文件 + 第 13 节 HARD-GATE 留空待填确认人/确认时间)。在 Spec 补齐并完成人工审查前,**本节不算真正关闭**。详见 `酒店模块需求缺口清单.md` 3.3。 #### 🟡 P1 订单归属校验缺失(R2) `HotelOrderServiceImpl.requireOrder()` 仅校验 `tenantId` 一致,**不校验订单是否属于当前 `stayId`**。同租户任意住客拿到 `orderId` 即可退单 / 物理删除 / 查看他人订单。小程序化后由平台 `user_id`/`openid` → `stayId` 映射收口,详见 `酒店模块需求缺口清单.md` 3.2 R2。 #### ~~🟡 P1 手机号日志未脱敏~~ ✅ 已修复(2026-09-07) `AlipayAuthController` 拿到的是真实手机号,按 AGENTS.md 5.9 禁止在日志中打印。已落地: - `AlipayAuthController` 新增私有 `maskPhone()`,保留**前 3 后 4**、中间 `****`,长度不足时全部脱敏 - `AlipayAuthServiceImpl` 不再把支付宝返回的**密文报文整包** `log.warn`(密文可被离线解密,等同泄露),改为只记 `responseLength` > ⚠️ 本项**不属资金流转**,但同属 5.9 安全红线(日志敏感信息),随第 0 批一并收口。 #### 🟢 P2 定时任务精度 数据库轮询方案最大延迟 2 分钟(`PayTimeoutTask` 的 `fixedDelay=120000`),对业务影响可接受。 ### 10.2 优化建议 1. **支付对账**:添加每日对账任务,比对本地流水与第三方支付平台数据 2. **支付统计**:添加支付成功率、支付金额等统计报表 3. **回调重试**:当前依赖支付宝 24h 内自动重试(约 8 次)+ `/alipayQuery` 兜底,可考虑自建重试队列 ### 10.3 已修正的过时描述 | 原文 | 实际 | |---|---| | ~~「退款流程:完善已支付订单的退款流程(当前只支持未支付取消)」~~ | ✅ **已实现**:`HotelPayService.refundOrder()` 走 `AlipayTradeRefundRequest` 全额退款,失败标记 `refund_status=2` 可重试;4 个自动触发点(拒单/顾客退单/前台退单/审核通过)+ 手动退款 `POST /hotel/order/refund` | | ~~「支付回调重试:添加重试机制」~~ | ✅ **已实现**:catch 块返回 `"fail"` 让支付宝自动重试(之前返回 `"success"` 导致异常时不重试) | | ~~「当前只支持未支付取消」~~ | ✅ 已支付订单可走完整退单流程(`customerRefund` / 前台退单 / 拒单) | --- ## 十一、总结 本次开发完成了酒店订单支付系统的核心功能,包括: - ✅ 数据库结构设计(支付字段、流水表、索引) - ✅ 后端支付服务(支付宝 H5 WAP Pay、回调处理、超时取消、全额退款) - ✅ 前端支付页面(倒计时、条件编译分支、轮询 + 主动查询兜底) - ✅ 并发问题修复(订单号、菜品销量) - ✅ 订单状态管理(待支付、已支付、超时取消) ### ✅ 已完成(2026-09-07,第 0 批 Mock 资金红线收口) | 事项 | 本文档落点 | 状态 | |---|---|---| | `hotel.pay.mock-enabled` 开关(6 份 yml)+ `HotelPayConfig` | **7.3** / **10.1** | ✅ 已落地 | | `mockSuccess` 首行拦截 | **3.5** / **10.1** | ✅ 已落地 | | `createPay` 的 `paySource` 去 `defaultValue="MOCK"` | **3.5** / **10.1** | ✅ 已落地 | | 渠道解析前置 + `mockOrReject` 单一收口(WECHAT 显式阻断) | **3.4** / **10.1** | ✅ 已落地 | | `pay.vue` else Mock 兜底 —— **拆分而非删除** | **4.2** / **10.1** | ✅ 已落地(与原方案有出入,见 4.2 纠正说明) | | 退款渠道显式判定(新发现的潜在资损) | **3.4** / **10.1** | ✅ 已落地 | | 手机号日志脱敏 + 密文报文不再整包输出 | **10.1** | ✅ 已落地 | | 两份生产 `application.yml` 补齐缺失的 `hotel` 块(否则生产 profile 启动即崩) | **7.3** | ✅ 已落地 | | 🔴 **变更 Spec 补写 + 人工审查**(AGENTS.md 5.9 HARD-GATE) | **10.1** 末尾声明 | ❌ **未闭环**,建议变更名 `hotel-pay-mock-hardening` | ### ⚠️ 上线前仍必须完成的事项 > 📌 **排期与批次归属的唯一权威**:完整的第 0~6 批补齐顺序(含依赖关系、变更提案命名、人工审查要求)见 `forge-server/forge-business/forge-hotel/酒店模块需求缺口清单.md` **第九节**。本表只列与**支付实现**直接相关的条目及其在本文档的落点,不重复维护排期。 | 优先级 | 事项 | 本文档落点 | 缺口清单批次 | |---|---|---|---| | 🔴 P0 | **补写变更 Spec 并完成人工审查**(代码已先行,5.9 合规未闭环) | **10.1** 末尾声明 | 第 0 批尾巴 | | 🟡 P1 | `ALIPAY_MP` 的 `trade.create` 分支(否则支付宝小程序支付不可用)。⚠️ 分流要用**原始 `paySource`**,因 `payChannel` 已将两者归为 `ALIPAY` | **7.1**(含 `buyer_id` 来源与沙箱验证限制) | 第 3 批 | | 🟡 P1 | `return-url` hash 路由丢参 / `notify-url` natapp 域名不稳 | **7.3** | 第 3 批 | | 🟡 P1 | 订单归属校验(R2) | —(属身份体系,非支付协议) | 第 3 批,见缺口清单 3.2 R2 | | 🟡 P1 | 沙箱密钥从 `application-dev.yml` 外提(已入库,命中 5.9 硬编码密钥红线) | **7.3** 末尾警告 | 建议单独提案 | | 🟢 P2 | 微信支付完整实现(商户号未开通,已暂缓);当前已显式阻断不再免付款,但**退款仍会抛「请联系前台」** | **7.2** | 第 4 批 #25 | > 原文「当前系统已支持完整的 Mock 支付流程,后续可**无缝**对接微信支付、支付宝小程序支付」的说法**不成立**:支付宝小程序需新增 `ALIPAY_MP` 渠道(`wap.pay` 与 `trade.create` 是两个不同协议),微信需从零实现 JSAPI 下单 + 独立验签 + 退款,均非「无缝」。 --- **文档版本**:v1.4(**第 0 批 Mock 资金红线收口已落地,全文从「修复方案」改写为「实现真相」**:一节支付矩阵三行重写 + 新增 `ALIPAY_MP` 归并语义陷阱;3.4 补 `resolvePayChannel` 真值表 / `mockOrReject` / `refundOrder` 渠道判定;3.5 补 `/alipayQuery` 与两处加固代码;4.2 分流依据改为 `payChannel` 并纠正「删除 else」→「拆分 else」;7.3 重写为生产/dev 双 yml 实例 + **修正环境变量名 `FORGE_HOTEL_PAY_MOCK`→`FORGE_HOTEL_PAY_MOCK_ENABLED`** + 登记已入库沙箱密钥;八 文件清单补 `HotelPayConfig` 与第 0 批修改项;10.1 改为已收口 + **显式声明 5.9 合规未闭环**;十一 拆为「已完成」/「仍必须完成」两表) **上版**:v1.3(文首新增职责声明;从仓库根目录移入 `forge-server/forge-business/forge-hotel/`;修正内部引用 7.2 → 7.1;十一节上线事项表改为指向缺口清单第九节的排期指针) **最后更新**:2026-09-07 **维护人员**:AI Assistant > 本文档 **7.1 / 7.3 / 10.1** 是缺口清单 3.3 / 3.6.5 指针的目标章节,为支付协议与 Mock 资金红线**实现真相的唯一权威副本**。 > > 🔴 读本文档时请同时注意:10.1 末尾的 **AGENTS.md 5.9 合规声明仍未闭环**(Spec 未补、人工审查未做),不要把「代码已落地」误读为「变更已验收」。