status: review created: 2026-09-07 complexity: 🔴复杂 需求依据:
forge-server/forge-business/forge-hotel/酒店模块需求缺口清单.md3.3 / 第九节第 0 批 实现权威:forge-server/forge-business/forge-hotel/酒店订单支付系统开发文档.md10.1 规则依据:AGENTS.md5.9 安全红线、code-copilot/rules/security.md第 2 节
本 Spec 为补写文档,不是事前提案。
| 项 | 状态 |
|---|---|
| 代码实现 | ✅ 已落地并通过 mvn compile / pnpm build:h5 / GetProblems 验证 |
| Spec 编写 | 🔴 代码落地之后才补写(即本文件) |
| 人工审查 | 🔴 尚未执行 |
| HARD-GATE 签署 | 🔴 第 13 节 确认时间 / 确认人 留空,只能由需求方本人手填 |
code-copilot/rules/security.md 第 2 节要求「涉及资金变更的逻辑,必须在 spec 中明确标注,人工审查后方可编码」。本次是先编码、后补审查,属 AGENTS.md 5.9 的流程倒置。补写本 Spec 的目的是把已发生的改动完整登记、把资金风险点显式摊开供审查,不等于合规已完成 —— 合规闭环以第 13 节签署为准。
顾客端支付链路存在免付款下单漏洞,任何人构造 HTTP 请求即可把订单置为「已支付」而不产生任何真实资金流:
| 环节 | 位置(改造前) | 缺陷 |
|---|---|---|
| 接口暴露 | HotelPayController @RequestMapping("/hotel/open/pay") |
落在 SaTokenConfig 白名单 /hotel/open/** 内,mockSuccess 完全免登录 |
| 缺省渠道 | createPay 的 @RequestParam(defaultValue = "MOCK") String paySource |
不传 paySource 即默认走 Mock |
| 静默降级 ① | resolvePayChannel 微信分支 |
WECHAT / WECHAT_MP 直接 return "MOCK" |
| 静默降级 ② | resolvePayChannel 兜底分支 |
任何未知渠道值直接 return "MOCK" |
| 前端兜底 | pay.vue handlePay() 的 else |
后端未返回可用支付参数时,兜底调用 hotelPayMockSuccess |
| 退款误判 | refundOrder 用 !"ALIPAY".equals(payChannel) 判定「无资金流」 |
未来接入微信后,微信订单退款会被误判为无资金流而直接标记成功 → 真实资损 |
攻击面:POST /hotel/open/pay/mockSuccess?tenantId=1&orderId=X 无需任何凭证。
MOCK 的路径ALIPAY_MP 渠道(alipay.trade.create,属第 3 批)application-dev.yml 明文沙箱密钥(独立红线,见 code-copilot/changes/hotel-alipay-secret-externalize/)pay.vue handlePay()
└─ POST /hotel/open/pay/create?tenantId&orderId&paySource [免登录]
└─ HotelPayController.createPay() L56-68
└─ TenantContextHolder.executeWithTenant(tenantId, ...)
└─ HotelPayServiceImpl.createPay(orderId, paySource) L91-182
├─ resolvePayChannel(paySource) L821-836 ← 单一收口点
│ └─ mockOrReject(reason) L846-851 ← 全仓唯一允许返回 "MOCK" 的位置
├─ "ALIPAY" → alipay.trade.wap.pay → payForm
└─ else → payChannel=MOCK
├─ payChannel=ALIPAY + payForm → 渲染表单自动提交(H5)/ my.tradePay(MP)
├─ payChannel=MOCK → POST /hotel/open/pay/mockSuccess [免登录]
└─ else → uni.showModal 报错(禁止兜底调 mockSuccess)
POST /hotel/open/pay/notify → 验签 → handlePayCallback → processPayCallback
refundOrder(orderId) L543-628 → 渠道显式判定 → alipay.trade.refund / 仅回写状态 / 抛异常
① HotelPayConfig(新建,pay/config/HotelPayConfig.java L22-49)
@Value("${hotel.pay.mock-enabled:false}")
private boolean mockEnabled;
@PostConstruct
public void logMockSwitch() { /* true → WARN 资金安全告警;false → INFO */ }
关键:代码级默认值是 false,不依赖 yml。即使 6 份 yml 的 hotel 块整体缺失,也不会误开。
② mockOrReject 单一收口点(HotelPayServiceImpl L846-851)
private String mockOrReject(String reason) {
if (hotelPayConfig.isMockEnabled()) {
return "MOCK";
}
throw new BusinessException(reason);
}
③ resolvePayChannel 全渠道显式判定(L821-836)
入参 paySource |
返回 | 说明 |
|---|---|---|
ALIPAY / ALIPAY_MP |
"ALIPAY" |
归为同一 payChannel |
WECHAT / WECHAT_MP |
mockOrReject("微信支付暂未开通,请使用支付宝支付或到前台付款") |
商户号未申请 |
null / 空串 / 全空格 |
mockOrReject("支付渠道不能为空") |
|
MOCK / H5 |
mockOrReject("模拟支付已禁用") |
|
| 其它任意值 | mockOrReject("不支持的支付渠道: " + paySource) |
兜底不再降级 |
④ createPay 渠道解析前置(L117-119 / L138-140)
payLog 之前先 resolvePayChannel(paySource),非白名单渠道在此即被拒绝,不会留下 pay_channel=MOCK 的脏流水payChannel,注释明确「禁止用原始 paySource 判断:否则 ALIPAY_MP 会漏进 else 分支被静默置为 MOCK」⑤ HotelPayController.createPay 去 defaultValue(L56-59)
@RequestParam String paySource // 改造前:@RequestParam(defaultValue = "MOCK") String paySource
⑥ HotelPayController.mockPaySuccess 首行拦截(L80-86)
if (!hotelPayConfig.isMockEnabled()) {
log.warn("模拟支付请求被拒绝(hotel.pay.mock-enabled=false): tenantId={}, orderId={}", tenantId, orderId);
throw new BusinessException("模拟支付已禁用");
}
拦截放在进入租户上下文之前,被拒请求不会触发任何 DB 操作。
⑦ refundOrder 渠道显式判定(L564-582)
HotelPayLog payLog = payLogMapper.selectLatestByOrderId(orderId);
String paidChannel = payLog != null ? payLog.getPayChannel() : null;
boolean isMockPaid = HotelOrderConstants.PAY_SOURCE_MOCK.equals(order.getPaySource())
|| "MOCK".equals(paidChannel);
if (isMockPaid || payLog == null) {
markRefundSuccess(...); // 无真实资金流,仅回写状态
return;
}
if (!"ALIPAY".equals(paidChannel)) {
log.error("退款渠道未接入,需人工处理: ...");
throw new BusinessException("该支付渠道暂不支持在线退款,请联系前台人工处理");
}
// → alipay.trade.refund
⑧ pay.vue else 分支拆分(L258-286)
else if (createData.payChannel === 'MOCK') —— 保留原调试逻辑,注释说明「后端 hotel.pay.mock-enabled=true 时才会返回 payChannel=MOCK,因此本分支在生产环境永远不会命中,既有 H5 调试行为保持不变」else —— uni.showModal('暂无法在线支付'),注释明确「禁止在此兜底调用 hotelPayMockSuccess —— 那等于绕过资金校验免付款」⑨ 日志脱敏(顺带收口)
AlipayAuthController L56 改为 maskPhone(vo.getPhone()),L66 新增 private String maskPhone(String phone)AlipayAuthServiceImpl L70 改为只记 responseLength,不再打印授权响应原文| # | 发现 | 处置 |
|---|---|---|
| ① | 两份生产 application.yml 完全没有 hotel 块,而 @Value("${hotel.qr.base-url}") 无默认值 → 生产 profile 启动即崩 |
已补齐 hotel 块(属第 2 批范围,但与本批共享同 6 份 yml) |
| ② | createPay 分流原用原始 paySource 判断 → ALIPAY_MP 会漏进 else 被静默置为 MOCK |
已改为用解析后的 payChannel |
| ③ | resolvePayChannel 有两处静默降级(微信分支 + 兜底分支),原缺口清单只记录了一处 |
两处统一收敛到 mockOrReject |
| ④ | refundOrder 用「非 ALIPAY 即 MOCK」兜底 → 未来接微信造成真实资损 |
已改显式判定 |
| ⑤ | 🔴 application-dev.yml 未被 .gitignore 忽略(git check-ignore 退出码 1),其中含支付宝沙箱 private-key / alipay-public-key 明文 |
不在本变更范围,另立 hotel-alipay-secret-externalize |
resolvePayChannel("ALIPAY_MP") 返回的是 "ALIPAY",不是 "ALIPAY_MP"。因此未来补 alipay.trade.create 分支时,分流必须用原始 paySource,不能用 payChannel;而本次改造恰恰要求 createPay 现有分流用 payChannel。二者不矛盾(现有分流只区分「支付宝 vs 其它」),但新增 trade.create 时必须重新审视这两处判断依据。
hotel.pay.mock-enabled(代码级默认 false)控制模拟支付是否可用;启动时打印开关状态,开启时 WARN 级资金安全告警mockOrReject(reason),开关关闭时抛 BusinessException,开启时返回 "MOCK";全仓禁止裸 return "MOCK"mockSuccess 接口拦截 —— 开关关闭时首行拒绝,不进入租户上下文、不触发 DB 操作paySource 必传 —— 移除 defaultValue = "MOCK",缺省直接 400payChannel === 'MOCK' 分支(dev 零变化),新增 else 显式报错,禁止兜底调 mockSuccess| 规则 | 内容 |
|---|---|
| R1 | 生产环境(application.yml)mock-enabled 必须为 false,通过 ${FORGE_HOTEL_PAY_MOCK_ENABLED:false} 允许环境变量覆盖但默认严格 |
| R2 | 开发环境(application-dev.yml / application-dev.example.yml)mock-enabled: true,保证既有 H5 调试流程零变化 |
| R3 | 合法 paySource 白名单:ALIPAY、ALIPAY_MP、WECHAT、WECHAT_MP、MOCK、H5;其余一律「不支持的支付渠道」 |
| R4 | WECHAT / WECHAT_MP 在开关关闭时的报错文案必须给出可行替代路径(「请使用支付宝支付或到前台付款」),不能只说「不支持」 |
| R5 | 退款仅在 pay_status = 已支付 时可发起;已退款成功不可重复发起 |
| R6 | 无支付流水也直接标记退款成功 —— 这是历史数据兼容的妥协(见第 8 节风险 ③) |
| R7 | 全仓禁止裸 return "MOCK";新增渠道必须显式接入 resolvePayChannel,禁止依赖兜底分支 |
| 操作 | 表名 | 字段/索引 | 说明 |
|---|---|---|---|
| — | — | — | 无数据库变更,无 Flyway 脚本 |
说明:
hotel_pay_log.pay_channel字段沿用现有定义,只是取值范围被收紧(生产环境不再产生MOCK值)。历史pay_channel=MOCK的存量数据由 R6 兼容。
| 操作 | 接口 | 方法 | 变更内容 |
|---|---|---|---|
| 修改 | /hotel/open/pay/create |
POST | paySource 由 @RequestParam(defaultValue = "MOCK") 改为必传 @RequestParam;非法渠道返回业务异常而非静默降级 |
| 修改 | /hotel/open/pay/mockSuccess |
POST | 新增开关拦截:hotel.pay.mock-enabled=false 时抛 BusinessException("模拟支付已禁用") 并打 WARN 日志 |
| 不变 | /hotel/open/pay/status |
GET | 无变更 |
| 不变 | /hotel/open/pay/notify |
POST | 无变更(验签 / 金额 / app_id / 幂等四层校验沿用) |
| 不变 | /hotel/open/pay/alipayQuery |
GET | 无变更 |
| 修改(内部) | HotelPayService.refundOrder |
— | 渠道判定由「非 ALIPAY 即 MOCK」改为显式三分支 |
| 修改(内部) | AlipayAuthController.getUserInfo |
— | 日志手机号脱敏,响应体不变 |
接口路径与出入参结构均未变更,仅约束收紧。前端 api/index.js 无需改动。
| 文件 | 类型 |
|---|---|
forge-hotel/.../pay/config/HotelPayConfig.java |
新建 |
forge-hotel/.../pay/service/impl/HotelPayServiceImpl.java |
修改(注入 + createPay + resolvePayChannel + mockOrReject + refundOrder) |
forge-hotel/.../controller/open/HotelPayController.java |
修改(createPay 去默认值 + mockSuccess 拦截 + 注入) |
forge-hotel/.../controller/open/AlipayAuthController.java |
修改(maskPhone) |
forge-hotel/.../pay/service/impl/AlipayAuthServiceImpl.java |
修改(日志脱敏) |
forge-admin-server/src/main/resources/application.yml |
修改(新增 hotel.pay.mock-enabled) |
forge-admin-server/src/main/resources/application-dev.yml |
修改(同上,值 true) |
forge-admin-server/src/main/resources/application-dev.example.yml |
修改(同上,值 true) |
forge-app-server/src/main/resources/application.yml |
修改(同上) |
forge-app-server/src/main/resources/application-dev.yml |
修改(同上,值 true) |
forge-app-server/src/main/resources/application-dev.example.yml |
修改(同上,值 true) |
| 文件 | 类型 |
|---|---|
forge-h5-ui/src/pages/hotel/customer/pay.vue |
修改(handlePay else 分支拆分) |
application.yml / application-dev.yml / application-dev.example.yml(admin-server + app-server 各 3 份)同时承载:
hotel.pay.mock-enabledhotel.qr.base-url、hotel.qr.path、hotel.alipay.*两批改动落在同一 hotel: 块内。回滚本批时只可删除 pay.mock-enabled 一行,禁止整块回退,否则会连带破坏第 2 批并导致生产 profile 启动崩溃(见 2.3 发现 ①)。
forge-admin-ui):零改动true,全部分支走向与改造前一致)⚠️ 本变更属资金类变更(AGENTS.md 5.9 /
code-copilot/rules/security.md第 2 节),以下每一项都需人工审查确认。
① mockOrReject 单一收口是否覆盖全部渠道 —— 漏一个分支 = 免付款
当前 resolvePayChannel(L821-836)覆盖:ALIPAY / ALIPAY_MP / WECHAT / WECHAT_MP / null / 空串 / 全空格 / MOCK / H5 / 其它任意值(兜底)。
审查要点:兜底分支 return mockOrReject("不支持的支付渠道: " + paySource) 是最后一道网,请确认它确实无法被绕过(例如 paySource 传入超长字符串、含控制字符、大小写变体 alipay)。
注:
"alipay"(小写)会落到兜底分支被拒,因为判定用的是"ALIPAY".equals(paySource)严格相等。这是期望行为(前端只会传大写常量),但需确认前端不存在传小写的路径。
② refundOrder 三种「无资金流」判定边界(L564-576)
paySource = MOCK → 仅回写状态
流水 pay_channel = MOCK → 仅回写状态
无流水(payLog == null) → 仅回写状态 ← 🔴 风险点
审查要点:「无流水也直接标记退款成功」是历史数据兼容的妥协。理论上存在「用户真实支付了、但 hotel_pay_log 流水丢失/被删」的场景,此时系统会标记退款成功而实际未退钱给用户,属客诉与合规风险。
可选加固方向(本次未做,需你决策是否追加):
alipay.trade.query 用 order_no 反查支付宝侧是否存在交易③ pay.vue else 采「拆分」而非「删除」(L258-286)
保留了 payChannel === 'MOCK' 分支,即代码里仍存在调用 hotelPayMockSuccess 的路径。生产环境后端不会返回 payChannel=MOCK,故不会命中;但:
MOCK,前端会照旧免付款走通import.meta.env 做二次隔离本次选择「拆分」是为满足用户硬约束「不可影响 H5 开发测试」—— 删除该分支会让 dev 环境的 Mock 调试流程失效。
| # | 风险 | 缓解 |
|---|---|---|
| ④ | 环境变量名写错静默失效:正确名是 FORGE_HOTEL_PAY_MOCK_ENABLED,写成 FORGE_HOTEL_PAY_MOCK 不报错,只静默退回 false |
生产默认 false 是安全侧,误配只会导致「Mock 不可用」而非「Mock 被开」;但 dev 环境误配会让调试失效,已在 6 份 yml 注释中标注 |
| ⑤ | 开关是进程级而非租户级:一旦生产误开,全租户同时暴露 | 启动时 WARN 级「【资金安全告警】」日志,便于监控告警接入 |
| ⑥ | mockSuccess 仍在 Sa-Token 白名单内,开关是唯一防线 |
生产 false + 代码级默认 false 双保险;彻底移除该接口会破坏 dev 调试,故保留 |
| ⑦ | 本批与第 2 批共享 6 份 yml,回滚易误伤 | 见 7.3,回滚只删 pay.mock-enabled 一行 |
| ⑧ | 🔴 application-dev.yml 明文沙箱密钥已入库 |
不在本变更范围,见 hotel-alipay-secret-externalize |
refundOrder 改造后,非 ALIPAY 非 MOCK 渠道的退款请求会抛异常(改造前静默标记成功)。这会改变前台「手动退款」按钮的行为:未来接入微信后,微信订单点退款会看到「请联系前台人工处理」而非「退款成功」。这是期望行为(防止资损),但需在接入微信时同步实现在线退款。
resolvePayChannel 全渠道分支(6 类入参 × 开关 2 态 = 12 组)mockOrReject 开关两态mockPaySuccess 开关关闭时拦截createPay 缺省 paySource / 非法 paySource / ALIPAY / ALIPAY_MPrefundOrder 三分支(MOCK / ALIPAY / 其它)+ 无流水resolvePayChannel、mockOrReject、refundOrder 渠道判定段)酒店订单支付系统开发文档.md 10.1 记录(后端全 reactor mvn compile BUILD SUCCESS、H5 pnpm build:h5 Build complete、GetProblems 12 个文件 No errors、6 份 yml 逐行核对一致、dev 行为零变化)forge-hotel 模块当前无测试基础设施(无 src/test),本次未新建。若审查要求补测,需先解决模块测试脚手架问题,属独立工作量refundOrder 遇到「已支付但无流水」时,是维持「直接标记退款成功」,还是改为抛异常要求人工核查 / 调 alipay.trade.query 反查?pay.vue 的 payChannel === 'MOCK' 分支是否需要再加一层前端环境隔离(import.meta.env.DEV),还是接受「后端开关为唯一防线」?paySource 的路径(当前后端严格大写相等匹配)。以上三项全部解决并签署第 13 节后,本变更方可从
review进入done。
| # | 决策 | 理由 | 被否方案 |
|---|---|---|---|
| D1 | 用配置开关而非删除 Mock 能力 | 用户硬约束「不可影响 H5 开发测试」;删除会让 dev 调试流程失效 | 直接删除 mockSuccess 接口 |
| D2 | 开关默认值写在代码里(@Value("${...:false}"))而非只靠 yml |
yml 整块缺失也不会误开;6 份 yml 分散在两个服务,任一遗漏都安全 | 只靠 yml 配置 |
| D3 | 抽 mockOrReject 单一收口点 |
改造前有两处静默降级,正是漏洞成因;收口后全仓只有一个位置能返回 "MOCK",审查面收敛到 6 行 |
在每个分支各写一遍 if (mockEnabled) |
| D4 | ALIPAY 与 ALIPAY_MP 归为同一 payChannel |
两者都是支付宝,风控与对账口径一致;具体协议差异(wap.pay vs trade.create)留给第 3 批 |
保留为两个独立 payChannel |
| D5 | 退款禁用「非 ALIPAY 即 MOCK」兜底 | 未来接微信后会误判无资金流 → 真实资损;宁可抛异常要求人工介入 | 维持原兜底 |
| D6 | 生产 yml 用 ${FORGE_HOTEL_PAY_MOCK_ENABLED:false} 而非硬编码 false |
保留应急开关能力,默认侧安全 | 硬编码 false |
| D7 | 日志脱敏(maskPhone / responseLength)纳入本批 |
命中 AGENTS.md 5.9「禁止在日志中打印手机号」,与资金安全同属安全红线,一并收口成本最低 | 另立变更 |
原缺口清单第 0 批的方案描述与实际落地存在三处差异:
| # | 原方案 | 实际落地 | 原因 |
|---|---|---|---|
| ① | 在 createPay 入口显式拒绝 MOCK |
下沉为 resolvePayChannel + mockOrReject 统一收口 |
入口拒绝只能挡住 paySource=MOCK,挡不住微信分支与兜底分支的静默降级;下沉后覆盖面完整 |
| ② | pay.vue 的 else MOCK 分支删除 |
改为拆分:保留 payChannel === 'MOCK' 分支 + 新增 else 报错 |
满足「不可影响 H5 开发测试」硬约束 |
| ③ | 引入 wechatPayProperties 配置类占位 |
未引入 | 微信支付已决策暂缓,空配置类属无效代码;resolvePayChannel 的微信分支已足够阻断 |
⚠️ 本表为回填实际改动文件(代码先于 Spec 落地),非计划文件清单。文件清单已用
git status --porcelain+git diff核实。
| Task | 状态 | 实际改动文件 | 备注 |
|---|---|---|---|
| Task 1 配置开关 | ✅ 已执行 | pay/config/HotelPayConfig.java(新建,L22-49) |
代码级默认 false + @PostConstruct 告警 |
| Task 2 单一收口点 | ✅ 已执行 | pay/service/impl/HotelPayServiceImpl.java(L821-836 resolvePayChannel、L846-851 mockOrReject、L60 注入) |
全仓唯一返回 "MOCK" 的位置 |
| Task 3 渠道解析前置 | ✅ 已执行 | 同上(L117-119 前置、L138-140 分流改用 payChannel) |
修复 ALIPAY_MP 漏进 MOCK |
| Task 4 接口约束收紧 | ✅ 已执行 | controller/open/HotelPayController.java(L56-59 去 defaultValue、L80-86 首行拦截、L44-45 注入) |
拦截在租户上下文之前 |
| Task 5 退款显式判定 | ✅ 已执行 | HotelPayServiceImpl.java(L564-582) |
三分支:MOCK/无流水 → 回写;ALIPAY → 真实退款;其它 → 抛异常 |
| Task 6 前端 else 拆分 | ✅ 已执行 | forge-h5-ui/src/pages/hotel/customer/pay.vue(L258-286) |
保留 MOCK 分支 + 新增 else showModal |
| Task 7 日志脱敏 | ✅ 已执行 | controller/open/AlipayAuthController.java(L56 调用、L66 maskPhone)、pay/service/impl/AlipayAuthServiceImpl.java(L70 只记 responseLength) |
响应体不变 |
| Task 8 yml 配置落地 | ✅ 已执行 | admin-server + app-server 各 3 份:application.yml(L239 / L121,${FORGE_HOTEL_PAY_MOCK_ENABLED:false})、application-dev.yml(L104 / L83,true)、application-dev.example.yml(L102 / L83,true) |
⚠️ 与第 2 批共享同一 hotel: 块,见 7.3 |
| 验证 | ✅ 已通过 | — | 后端全 reactor mvn compile BUILD SUCCESS;H5 pnpm build:h5 Build complete;GetProblems 12 个文件 No errors;6 份 yml 逐行核对一致;dev 行为零变化 |
| 单元测试 | ❌ 未执行 | — | forge-hotel 无测试基础设施,见 8.5 |
| 人工审查 | 🔴 待执行 | — | 见第 12 节 |
🔴 尚未审查。 本节须由审查人填写,AI 不得代填。
审查时请对以下清单逐条给出「通过 / 需修改」结论:
mockOrReject 覆盖面是否完整(漏一个分支 = 免付款)refundOrder「无流水直接标记退款成功」是否可接受(Q1)pay.vue 保留 MOCK 分支是否可接受(Q2)审查结论:(待填)
审查人:(待填)
审查日期:(待填)
⚠️ 本节是
AGENTS.md5.9 与code-copilot/rules/security.md第 2 节要求的资金类变更强制门禁。 只能由需求方本人手填,AI 助手不得代签、不得填入推测值。 未签署前,本变更状态停留在review,不得进入done,不得归档(/archive)。
确认范围声明(签署即表示已阅读并认可以下内容):徐滕