开发时间:2026-08-17 最后校正:2026-09-07
功能模块:酒店顾客端支付流程本文档职责:支付模块的实现权威——回答「钱怎么走、协议差在哪里、哪里有资金安全风险」。具体包括:支付环境实际支持矩阵、
wap.payvstrade.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 根据代码真相校正。重点看【一、功能概述】的支付环境实际支持矩阵和【十、已知问题】的资金红线。
实现了酒店顾客端完整的支付流程,支持先支付后接单的业务模式。
AlipayTradeWapPayRequest + pageExecute() 返回 HTML 表单,前端渲染后自动提交跳转收银台/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)。
文件:forge-server/db/migration/V1.0.105__add_hotel_order_payment_fields.sql
MySQL 8.0 兼容:使用
information_schema+PREPARE/EXECUTE模式替代 MariaDB 专有的IF NOT EXISTS扩展语法。
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 COLUMNidx_pay_expire:加速支付超时订单扫描(pay_status, pay_expire_time)uk_order_no:已在 V1.0.104 中创建,不重复记录每笔支付的完整流水,包括:
WECHAT_MP / ALIPAY_MP 仅在字典与字段注释中预留,后端无任何分支写入这两个值。新增字典类型:
hotel_pay_status:支付状态(0=待支付, 1=已支付, 2=已退款, 3=支付超时已取消)hotel_pay_source:支付来源(WECHAT/ALIPAY/H5/MOCK 等)hotel_order_status:补充 0=待支付、10=超时取消文件:HotelOrderConstants.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 // 支付超时时间(分钟)
新增字段:
payStatus(Integer):支付状态payExpireTime(LocalDateTime):支付截止时间payTime(LocalDateTime):实际支付时间payTradeNo(String):支付交易号paidAmount(BigDecimal):实付金额paySource(String):支付来源移除字段:
payMethod(已废弃,改用 paySource)支付流水实体,包含完整的支付记录字段。
新增方法:
List<Long> selectExpiredUnpaidOrderIds()
查询所有超时未支付的订单 ID。
resultMap 和 OrderColumns:添加支付相关字段映射selectDashboard、countTodayOrders、sumTodayRevenue:改为只统计已支付订单(pay_status = 1)selectOrderPage:筛选条件从 payMethod 改为 payStatus、paySourceselectExpiredUnpaidOrderIds:查询超时订单新增方法:
int incrementSales(@Param("dishId") Long dishId, @Param("quantity") Integer quantity)
原子更新菜品销量。
新增 SQL:
<update id="incrementSales">
UPDATE hotel_dish
SET sales = IFNULL(sales, 0) + #{quantity},
update_time = NOW()
WHERE id = #{dishId} AND del_flag = 0
</update>
HotelPayLog selectByOutTradeNo(@Param("outTradeNo") String outTradeNo)
根据商户订单号查询支付流水。
orderCreate() 方法重构:
STATUS_PENDING_PAY(待支付)payStatus = PAY_STATUS_PENDINGpayExpireTime = now + 15 分钟payMethod 字段赋值generateOrderNo() 并发修复:
selectCount)incrementDishSales() 并发修复:
dishMapper.incrementSales() 直接执行 SET sales = IFNULL(sales, 0) + #{quantity}customerRefund() 新增逻辑:
STATUS_PAY_TIMEOUT支付服务接口,定义核心方法:
Map<String, Object> 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)
createPay():
String payChannel = resolvePayChannel(paySource) 在写流水之前执行,非白名单渠道在此即被拒绝,不会先落一条 MOCK 流水再报错PAY + 时间戳 + 订单ID后6位)hotel_pay_log,pay_channel 取已解析的 payChannel)payChannel 判断,禁止用原始 paySource(否则 ALIPAY_MP 会漏进 else 分支被静默置为 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,兼容历史数据取 totalAmountpaySource=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():
handlePayCallback():
cancelTimeoutOrder():
STATUS_PAY_TIMEOUTPAY_STATUS_TIMEOUTqueryPayStatus():
路径:/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 已落地):
// 1. /create 的 paySource 去掉 required=false + defaultValue="MOCK",改为必传
// 本接口免登录,缺省即免付款属于资金红线(AGENTS.md 5.9)
@PostMapping("/create")
public RespInfo<Map<String, Object>> createPay(@RequestParam Long tenantId,
@RequestParam Long orderId,
@RequestParam String paySource) { ... }
// 2. /mockSuccess 方法首行拦截(放在 Controller 而非 Service,保证开关语义单点)
@PostMapping("/mockSuccess")
public RespInfo<PayResultVO> 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 日志红线;但若后续回调参数扩展出买家实名信息,需同步脱敏。
@Scheduled(fixedDelay = 120000, initialDelay = 30000)
public void cancelExpiredOrders()
TenantContextHolder.executeIgnore() 跨租户扫描payService.cancelTimeoutOrder() 取消文件:forge-h5-ui/src/api/index.js
新增支付相关 API(实际写法,统一用 request({ url, method, params }),不是 request.post(url, data)):
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。
文件:forge-h5-ui/src/pages/hotel/customer/pay.vue
核心功能:
payChannel 分流,2026-09-07 已改造):⚠️ 分流依据从
paySource改为createData.payChannel。原写法用前端自算的paySource判断,导致后端一旦返回ALIPAY_MP就漏进 else 分支被当成 Mock。
| 分支 | 条件 | 实际行为 |
|---|---|---|
① payChannel==='ALIPAY' && payForm 的 #ifdef H5 |
H5 浏览器 | ✅ 将 payForm 写入动态创建的 div#alipay-pay-form,取到 <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() 主动查支付宝侧状态,失败弹「支付结果未知」modalgoBack() 先 uni.showModal 确认 → hotelOrderDetail 恢复购物车 → 调 hotelAbandonOrder(物理删除)UI 元素:
box-sizing: border-box 防越界)⚠️ 支付金额不依赖 URL 参数:页面通过
api.hotelOrderDetail()从后端取真实金额(购物车清空后 URL 参数会失效)。
文件:forge-h5-ui/src/pages/hotel/customer/order-status.vue
新增状态处理:
isFinished() 返回 true(订单结束)文件:forge-h5-ui/src/pages/hotel/customer/orders.vue
状态标签更新:
statusLabel(status) {
const map = {
0: '待支付', 1: '待接单', 2: '已接单', 3: '备餐中',
4: '已出餐', 5: '配送中', 6: '已完成',
7: '已拒单', 8: '退单审核中', 9: '已退单',
10: '超时取消',
}
return map[status] || '未知'
}
状态颜色:
.o-st--0:黄色背景(待支付).o-st--10:灰色背景(超时取消)待支付订单特殊处理:
文件:forge-h5-ui/src/pages/hotel/customer/order-confirm.vue
更新跳转逻辑:
orderId、tenantId、amount问题:多个订单同时生成可能产生重复订单号
解决方案:
order_no 添加唯一索引generateOrderNo() 添加 3 次重试selectCount 验证唯一性问题:先查后写导致并发时销量丢失
解决方案:
SET sales = IFNULL(sales, 0) + #{quantity}顾客 H5前端 后端API 数据库
| | | |
|--下单----------->| | |
| |--创建订单-------->| |
| | |--插入订单(status=0)|
| | |<-------------------|
| |<--返回orderId-----| |
| | | |
|--跳转支付页----->| | |
| |--创建支付-------->| |
| | |--插入pay_log |
| | |<-------------------|
| |<--返回支付参数----| |
| | | |
|--点击支付------->| | |
| |--模拟支付成功---->| |
| | |--更新pay_log |
| | |--更新订单(status=1)|
| | |<-------------------|
| |<--返回支付结果----| |
| | | |
|--跳转订单状态--->| | |
| |--轮询支付状态---->| |
| | |--查询订单 |
| | |<-------------------|
| |<--返回已支付------| |
| | | |
当前障碍:ALIPAY 渠道走 AlipayTradeWapPayRequest,返回的是 HTML 表单字符串,小程序无法提交表单,且拿不到 tradeNo。
方案:新增 ALIPAY_MP 渠道(纯增量分支,不影响 H5):
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)。
HotelPayServiceImpl.createPay() 新增 WECHAT_MP 分支,调 JSAPI 下单(需 openid + 商户号)prepay_id + 签名参数#ifdef MP-WEIXIN 分支调 wx.requestPayment()AlipaySignature)refundOrder() 只有支付宝 + MOCK 两条路)⚠️ 后端三端共用:即使微信暂缓上架,
resolvePayChannel也必须对WECHAT_MP显式抛BusinessException(如「微信支付暂未开通,请使用支付宝支付」),否则会静默降级到 Mock 造成免付款。
实际配置前缀是 hotel.alipay.*,不是 forge.pay.*(见 AlipayConfig 的 @ConfigurationProperties):
# ===== 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(需同步处理已入库的历史密钥轮换)。
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/domain/HotelPayLog.javaforge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/mapper/HotelPayLogMapper.javaforge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/pay/HotelPayLogMapper.xmlforge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/HotelPayService.javaforge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/impl/HotelPayServiceImpl.javaforge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/vo/PayResultVO.javaforge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/open/HotelPayController.javaforge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/task/PayTimeoutTask.javaforge-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 的资金安全开关)forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/constant/HotelOrderConstants.javaforge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/domain/HotelOrder.javaforge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/mapper/HotelOrderMapper.javaforge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/order/HotelOrderMapper.xmlforge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelDishMapper.javaforge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelDishMapper.xmlforge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/service/impl/HotelOrderServiceImpl.javaforge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/vo/HotelOrderVO.java2026-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块
forge-server/db/migration/V1.0.105__add_hotel_order_payment_fields.sql无
forge-h5-ui/src/api/index.jsforge-h5-ui/src/pages/hotel/customer/pay.vueforge-h5-ui/src/pages/hotel/customer/order-status.vueforge-h5-ui/src/pages/hotel/customer/orders.vueforge-h5-ui/src/pages/hotel/customer/order-confirm.vue正常支付流程:
超时取消:
主动取消:
重复支付防护:
订单号唯一性:
菜品销量准确性:
支付超时边界:
库存不足:
原风险: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 时重点核对):
createPay 显式拒绝 MOCK」被更优实现取代:原方案是先 resolvePayChannel 再 if ("MOCK".equals(channel) && !mockEnabled) throw。实际把开关判定下沉到 mockOrReject,好处是开关只在一个方法里读,不会出现「resolvePayChannel 放行、createPay 又拒」的双重判定走形;且每个拒绝分支自带面向用户的差异化文案(微信未开通 / 渠道为空 / 模拟已禁用 / 不支持的渠道),而不是统一的「支付渠道未开通」。WECHAT 的开关依赖不同:原方案靠 wechatPayProperties.isEnabled() 判断。实际未引入 wechatPayProperties(微信支付完全未实现,新增一个永远为 false 的配置项只会多一个误配风险面),直接走 mockOrReject。第 4 批实现微信支付时再引入该开关。验证结果(2026-09-07):
mvn compile BUILD SUCCESS;forge-hotel 单模块 97 个源文件编译通过GetProblems 12 个改动文件 No errors foundpnpm build:h5 Build complete(ExitCode=0)hotel 块逐行核对一致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 补齐并完成人工审查前,本节不算真正关闭。详见酒店模块需求缺口清单.md3.3。
HotelOrderServiceImpl.requireOrder() 仅校验 tenantId 一致,不校验订单是否属于当前 stayId。同租户任意住客拿到 orderId 即可退单 / 物理删除 / 查看他人订单。小程序化后由平台 user_id/openid → stayId 映射收口,详见 酒店模块需求缺口清单.md 3.2 R2。
AlipayAuthController 拿到的是真实手机号,按 AGENTS.md 5.9 禁止在日志中打印。已落地:
AlipayAuthController 新增私有 maskPhone(),保留前 3 后 4、中间 ****,长度不足时全部脱敏AlipayAuthServiceImpl 不再把支付宝返回的密文报文整包 log.warn(密文可被离线解密,等同泄露),改为只记 responseLength⚠️ 本项不属资金流转,但同属 5.9 安全红线(日志敏感信息),随第 0 批一并收口。
数据库轮询方案最大延迟 2 分钟(PayTimeoutTask 的 fixedDelay=120000),对业务影响可接受。
/alipayQuery 兜底,可考虑自建重试队列| 原文 | 实际 |
|---|---|
✅ 已实现:HotelPayService.refundOrder() 走 AlipayTradeRefundRequest 全额退款,失败标记 refund_status=2 可重试;4 个自动触发点(拒单/顾客退单/前台退单/审核通过)+ 手动退款 POST /hotel/order/refund |
|
✅ 已实现:catch 块返回 "fail" 让支付宝自动重试(之前返回 "success" 导致异常时不重试) |
|
✅ 已支付订单可走完整退单流程(customerRefund / 前台退单 / 拒单) |
本次开发完成了酒店订单支付系统的核心功能,包括:
| 事项 | 本文档落点 | 状态 |
|---|---|---|
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 未补、人工审查未做),不要把「代码已落地」误读为「变更已验收」。