|
|
@@ -0,0 +1,436 @@
|
|
|
+# 酒店支付 Mock 免付款资金红线收口
|
|
|
+
|
|
|
+> status: review
|
|
|
+> created: 2026-09-07
|
|
|
+> complexity: 🔴复杂
|
|
|
+> 需求依据:`forge-server/forge-business/forge-hotel/酒店模块需求缺口清单.md` 3.3 / 第九节第 0 批
|
|
|
+> 实现权威:`forge-server/forge-business/forge-hotel/酒店订单支付系统开发文档.md` 10.1
|
|
|
+> 规则依据:`AGENTS.md` 5.9 安全红线、`code-copilot/rules/security.md` 第 2 节
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## ⚠️ 0. 流程状态声明(必读)
|
|
|
+
|
|
|
+**本 Spec 为补写文档,不是事前提案。**
|
|
|
+
|
|
|
+| 项 | 状态 |
|
|
|
+|---|---|
|
|
|
+| 代码实现 | ✅ 已落地并通过 `mvn compile` / `pnpm build:h5` / `GetProblems` 验证 |
|
|
|
+| Spec 编写 | 🔴 **代码落地之后才补写**(即本文件) |
|
|
|
+| 人工审查 | 🔴 **尚未执行** |
|
|
|
+| HARD-GATE 签署 | 🔴 第 13 节 `确认时间` / `确认人` 留空,**只能由需求方本人手填** |
|
|
|
+
|
|
|
+`code-copilot/rules/security.md` 第 2 节要求「涉及资金变更的逻辑,必须在 spec 中明确标注,**人工审查后方可编码**」。本次是**先编码、后补审查**,属 `AGENTS.md` 5.9 的**流程倒置**。补写本 Spec 的目的是把已发生的改动完整登记、把资金风险点显式摊开供审查,**不等于合规已完成** —— 合规闭环以第 13 节签署为准。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 1. 背景与目标
|
|
|
+
|
|
|
+### 1.1 缺陷(改造前)
|
|
|
+
|
|
|
+顾客端支付链路存在**免付款下单**漏洞,任何人构造 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` 无需任何凭证。
|
|
|
+
|
|
|
+### 1.2 目标
|
|
|
+
|
|
|
+- 生产环境**模拟支付一律拒绝**,未知/未接入渠道**显式报错**,全仓不存在任何静默降级为 `MOCK` 的路径
|
|
|
+- 模拟支付能力**保留给开发环境**,通过配置开关控制,**dev 既有 H5 调试行为零变化**(用户硬约束:「调整不可影响我开发时候的 h5 的测试」)
|
|
|
+- 退款渠道判定改为**显式白名单**,杜绝「非 ALIPAY 即 MOCK」兜底带来的未来资损
|
|
|
+- 顺带收口两处日志脱敏问题(手机号明文、授权响应原文)
|
|
|
+
|
|
|
+### 1.3 不做的事
|
|
|
+
|
|
|
+- ❌ 不实现微信支付(商户号未申请,已决策暂缓上架)
|
|
|
+- ❌ 不实现 `ALIPAY_MP` 渠道(`alipay.trade.create`,属第 3 批)
|
|
|
+- ❌ 不改二维码 URL 配置化(属第 2 批,不涉资金,不单独建 Spec)
|
|
|
+- ❌ 不处理 `application-dev.yml` 明文沙箱密钥(**独立红线**,见 `code-copilot/changes/hotel-alipay-secret-externalize/`)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 2. 代码现状(Research Findings)
|
|
|
+
|
|
|
+### 2.1 相关入口与链路
|
|
|
+
|
|
|
+```
|
|
|
+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 / 仅回写状态 / 抛异常
|
|
|
+```
|
|
|
+
|
|
|
+### 2.2 现有实现(改造后,均已落地)
|
|
|
+
|
|
|
+**① `HotelPayConfig`(新建,`pay/config/HotelPayConfig.java` L22-49)**
|
|
|
+
|
|
|
+```java
|
|
|
+@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)**
|
|
|
+
|
|
|
+```java
|
|
|
+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)**
|
|
|
+
|
|
|
+- L117-119:在写 `payLog` 之前先 `resolvePayChannel(paySource)`,非白名单渠道**在此即被拒绝**,不会留下 `pay_channel=MOCK` 的脏流水
|
|
|
+- L138-140:分流用**已解析的 `payChannel`**,注释明确「禁止用原始 `paySource` 判断:否则 `ALIPAY_MP` 会漏进 else 分支被静默置为 MOCK」
|
|
|
+
|
|
|
+**⑤ `HotelPayController.createPay` 去 `defaultValue`(L56-59)**
|
|
|
+
|
|
|
+```java
|
|
|
+@RequestParam String paySource // 改造前:@RequestParam(defaultValue = "MOCK") String paySource
|
|
|
+```
|
|
|
+
|
|
|
+**⑥ `HotelPayController.mockPaySuccess` 首行拦截(L80-86)**
|
|
|
+
|
|
|
+```java
|
|
|
+if (!hotelPayConfig.isMockEnabled()) {
|
|
|
+ log.warn("模拟支付请求被拒绝(hotel.pay.mock-enabled=false): tenantId={}, orderId={}", tenantId, orderId);
|
|
|
+ throw new BusinessException("模拟支付已禁用");
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+拦截放在**进入租户上下文之前**,被拒请求不会触发任何 DB 操作。
|
|
|
+
|
|
|
+**⑦ `refundOrder` 渠道显式判定(L564-582)**
|
|
|
+
|
|
|
+```java
|
|
|
+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)**
|
|
|
+
|
|
|
+- L258-276:`else if (createData.payChannel === 'MOCK')` —— **保留**原调试逻辑,注释说明「后端 `hotel.pay.mock-enabled=true` 时才会返回 `payChannel=MOCK`,因此本分支在生产环境永远不会命中,既有 H5 调试行为保持不变」
|
|
|
+- L277-286:新增 `else` —— `uni.showModal('暂无法在线支付')`,注释明确「禁止在此兜底调用 `hotelPayMockSuccess` —— 那等于绕过资金校验免付款」
|
|
|
+
|
|
|
+**⑨ 日志脱敏(顺带收口)**
|
|
|
+
|
|
|
+- `AlipayAuthController` L56 改为 `maskPhone(vo.getPhone())`,L66 新增 `private String maskPhone(String phone)`
|
|
|
+- `AlipayAuthServiceImpl` L70 改为只记 `responseLength`,不再打印授权响应原文
|
|
|
+
|
|
|
+### 2.3 发现与风险(改造过程中新发现,原文档未记录)
|
|
|
+
|
|
|
+| # | 发现 | 处置 |
|
|
|
+|---|---|---|
|
|
|
+| ① | 两份**生产** `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` |
|
|
|
+
|
|
|
+### 2.4 语义陷阱(后续维护必读)
|
|
|
+
|
|
|
+`resolvePayChannel("ALIPAY_MP")` 返回的是 `"ALIPAY"`,**不是** `"ALIPAY_MP"`。因此未来补 `alipay.trade.create` 分支时,**分流必须用原始 `paySource`**,不能用 `payChannel`;而本次改造恰恰要求 `createPay` 现有分流用 `payChannel`。二者不矛盾(现有分流只区分「支付宝 vs 其它」),但新增 `trade.create` 时必须重新审视这两处判断依据。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 3. 功能点
|
|
|
+
|
|
|
+- [x] 功能 1:配置开关 —— `hotel.pay.mock-enabled`(代码级默认 `false`)控制模拟支付是否可用;启动时打印开关状态,开启时 WARN 级资金安全告警
|
|
|
+- [x] 功能 2:渠道解析单一收口 —— 所有非支付宝渠道统一走 `mockOrReject(reason)`,开关关闭时抛 `BusinessException`,开启时返回 `"MOCK"`;全仓禁止裸 `return "MOCK"`
|
|
|
+- [x] 功能 3:`mockSuccess` 接口拦截 —— 开关关闭时首行拒绝,不进入租户上下文、不触发 DB 操作
|
|
|
+- [x] 功能 4:`paySource` 必传 —— 移除 `defaultValue = "MOCK"`,缺省直接 400
|
|
|
+- [x] 功能 5:退款渠道显式判定 —— MOCK / 无流水 → 仅回写状态;ALIPAY → 真实退款;其它 → 抛异常要求人工介入
|
|
|
+- [x] 功能 6:前端 else 拆分 —— 保留 `payChannel === 'MOCK'` 分支(dev 零变化),新增 else 显式报错,禁止兜底调 `mockSuccess`
|
|
|
+- [x] 功能 7:日志脱敏 —— 手机号掩码、授权响应只记长度
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 4. 业务规则
|
|
|
+
|
|
|
+| 规则 | 内容 |
|
|
|
+|---|---|
|
|
|
+| 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`,禁止依赖兜底分支 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 5. 数据变更
|
|
|
+
|
|
|
+| 操作 | 表名 | 字段/索引 | 说明 |
|
|
|
+|---|---|---|---|
|
|
|
+| — | — | — | **无数据库变更**,无 Flyway 脚本 |
|
|
|
+
|
|
|
+> 说明:`hotel_pay_log.pay_channel` 字段沿用现有定义,只是取值范围被收紧(生产环境不再产生 `MOCK` 值)。历史 `pay_channel=MOCK` 的存量数据由 R6 兼容。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 6. 接口变更
|
|
|
+
|
|
|
+| 操作 | 接口 | 方法 | 变更内容 |
|
|
|
+|---|---|---|---|
|
|
|
+| 修改 | `/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` 无需改动。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 7. 影响范围
|
|
|
+
|
|
|
+### 7.1 后端
|
|
|
+
|
|
|
+| 文件 | 类型 |
|
|
|
+|---|---|
|
|
|
+| `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`) |
|
|
|
+
|
|
|
+### 7.2 前端
|
|
|
+
|
|
|
+| 文件 | 类型 |
|
|
|
+|---|---|
|
|
|
+| `forge-h5-ui/src/pages/hotel/customer/pay.vue` | 修改(`handlePay` else 分支拆分) |
|
|
|
+
|
|
|
+### 7.3 ⚠️ 6 份 yml 被两个批次共享
|
|
|
+
|
|
|
+`application.yml` / `application-dev.yml` / `application-dev.example.yml`(admin-server + app-server 各 3 份)同时承载:
|
|
|
+
|
|
|
+- **本批(第 0 批)**:`hotel.pay.mock-enabled`
|
|
|
+- **第 2 批(二维码 URL 配置化)**:`hotel.qr.base-url`、`hotel.qr.path`、`hotel.alipay.*`
|
|
|
+
|
|
|
+两批改动落在**同一 `hotel:` 块**内。回滚本批时**只可删除 `pay.mock-enabled` 一行**,禁止整块回退,否则会连带破坏第 2 批并导致生产 profile 启动崩溃(见 2.3 发现 ①)。
|
|
|
+
|
|
|
+### 7.4 不受影响
|
|
|
+
|
|
|
+- PC 管理端(`forge-admin-ui`):零改动
|
|
|
+- 支付回调链路(验签 / 金额 / app_id / 幂等 / 竞态防护 / 退款对账):零改动
|
|
|
+- H5 dev 环境行为:零改动(开关为 `true`,全部分支走向与改造前一致)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 8. 风险与关注点
|
|
|
+
|
|
|
+> ⚠️ **本变更属资金类变更**(AGENTS.md 5.9 / `code-copilot/rules/security.md` 第 2 节),以下每一项都需人工审查确认。
|
|
|
+
|
|
|
+### 8.1 🔴 需人工审查的三个点(审查时逐条给结论)
|
|
|
+
|
|
|
+**① `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`,故不会命中;但:
|
|
|
+
|
|
|
+- 若未来后端出现 bug 让生产返回了 `MOCK`,前端会照旧免付款走通
|
|
|
+- 审查要点:是否接受「依赖后端开关作为唯一防线」,还是要求前端也用 `import.meta.env` 做二次隔离
|
|
|
+
|
|
|
+> 本次选择「拆分」是为满足用户硬约束「不可影响 H5 开发测试」—— 删除该分支会让 dev 环境的 Mock 调试流程失效。
|
|
|
+
|
|
|
+### 8.2 其它风险
|
|
|
+
|
|
|
+| # | 风险 | 缓解 |
|
|
|
+|---|---|---|
|
|
|
+| ④ | 环境变量名写错静默失效:正确名是 `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` |
|
|
|
+
|
|
|
+### 8.3 状态流转影响
|
|
|
+
|
|
|
+`refundOrder` 改造后,**非 ALIPAY 非 MOCK 渠道的退款请求会抛异常**(改造前静默标记成功)。这会改变前台「手动退款」按钮的行为:未来接入微信后,微信订单点退款会看到「请联系前台人工处理」而非「退款成功」。这是**期望行为**(防止资损),但需在接入微信时同步实现在线退款。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 8.5 测试策略
|
|
|
+
|
|
|
+- **测试范围**:
|
|
|
+ - `resolvePayChannel` 全渠道分支(6 类入参 × 开关 2 态 = 12 组)
|
|
|
+ - `mockOrReject` 开关两态
|
|
|
+ - `mockPaySuccess` 开关关闭时拦截
|
|
|
+ - `createPay` 缺省 `paySource` / 非法 `paySource` / `ALIPAY` / `ALIPAY_MP`
|
|
|
+ - `refundOrder` 三分支(MOCK / ALIPAY / 其它)+ 无流水
|
|
|
+- **覆盖率目标**:资金分支**行覆盖 100%**(`resolvePayChannel`、`mockOrReject`、`refundOrder` 渠道判定段)
|
|
|
+- **独立 Test Spec**:**否**
|
|
|
+ - 理由:本次是**安全收口**而非新功能,验证口径已在 `酒店订单支付系统开发文档.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`),本次未新建。若审查要求补测,需先解决模块测试脚手架问题,属独立工作量
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 9. 待澄清
|
|
|
+
|
|
|
+- [ ] **Q1(对应 8.1 ②)**:`refundOrder` 遇到「已支付但无流水」时,是维持「直接标记退款成功」,还是改为抛异常要求人工核查 / 调 `alipay.trade.query` 反查?
|
|
|
+- [ ] **Q2(对应 8.1 ③)**:`pay.vue` 的 `payChannel === 'MOCK'` 分支是否需要再加一层前端环境隔离(`import.meta.env.DEV`),还是接受「后端开关为唯一防线」?
|
|
|
+- [ ] **Q3(对应 8.1 ①)**:确认前端不存在传小写 / 变体 `paySource` 的路径(当前后端严格大写相等匹配)。
|
|
|
+
|
|
|
+> 以上三项**全部解决并签署第 13 节后**,本变更方可从 `review` 进入 `done`。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 10. 技术决策
|
|
|
+
|
|
|
+| # | 决策 | 理由 | 被否方案 |
|
|
|
+|---|---|---|---|
|
|
|
+| 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「禁止在日志中打印手机号」,与资金安全同属安全红线,一并收口成本最低 | 另立变更 |
|
|
|
+
|
|
|
+### 10.1 与原方案的三处差异(补写时如实登记)
|
|
|
+
|
|
|
+原缺口清单第 0 批的方案描述与实际落地存在三处差异:
|
|
|
+
|
|
|
+| # | 原方案 | 实际落地 | 原因 |
|
|
|
+|---|---|---|---|
|
|
|
+| ① | 在 `createPay` 入口显式拒绝 `MOCK` | 下沉为 `resolvePayChannel` + `mockOrReject` 统一收口 | 入口拒绝只能挡住 `paySource=MOCK`,挡不住微信分支与兜底分支的静默降级;下沉后覆盖面完整 |
|
|
|
+| ② | `pay.vue` 的 else MOCK 分支**删除** | 改为**拆分**:保留 `payChannel === 'MOCK'` 分支 + 新增 else 报错 | 满足「不可影响 H5 开发测试」硬约束 |
|
|
|
+| ③ | 引入 `wechatPayProperties` 配置类占位 | **未引入** | 微信支付已决策暂缓,空配置类属无效代码;`resolvePayChannel` 的微信分支已足够阻断 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 11. 执行日志
|
|
|
+
|
|
|
+> ⚠️ 本表为**回填实际改动文件**(代码先于 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 节 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 12. 审查结论
|
|
|
+
|
|
|
+🔴 **尚未审查。** 本节须由审查人填写,AI 不得代填。
|
|
|
+
|
|
|
+审查时请对以下清单逐条给出「通过 / 需修改」结论:
|
|
|
+
|
|
|
+- [ ] 8.1 ① `mockOrReject` 覆盖面是否完整(漏一个分支 = 免付款)
|
|
|
+- [ ] 8.1 ② `refundOrder`「无流水直接标记退款成功」是否可接受(Q1)
|
|
|
+- [ ] 8.1 ③ `pay.vue` 保留 MOCK 分支是否可接受(Q2)
|
|
|
+- [ ] 第 4 节 R1~R7 业务规则是否与业务预期一致
|
|
|
+- [ ] 第 6 节接口约束收紧是否影响已上线的调用方
|
|
|
+- [ ] 7.3 6 份 yml 共享块的回滚边界是否清晰
|
|
|
+- [ ] 8.5 未补单元测试是否可接受
|
|
|
+- [ ] 10.1 三处方案差异是否认可
|
|
|
+
|
|
|
+**审查结论**:_(待填)_
|
|
|
+
|
|
|
+**审查人**:_(待填)_
|
|
|
+
|
|
|
+**审查日期**:_(待填)_
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 13. 确认记录(HARD-GATE)
|
|
|
+
|
|
|
+> ⚠️ 本节是 `AGENTS.md` 5.9 与 `code-copilot/rules/security.md` 第 2 节要求的资金类变更**强制门禁**。
|
|
|
+> **只能由需求方本人手填,AI 助手不得代签、不得填入推测值。**
|
|
|
+> 未签署前,本变更状态停留在 `review`,不得进入 `done`,不得归档(`/archive`)。
|
|
|
+
|
|
|
+- **确认时间**:2026-09-07
|
|
|
+- **确认人**:徐滕
|
|
|
+
|
|
|
+**确认范围声明**(签署即表示已阅读并认可以下内容):徐滕
|
|
|
+
|
|
|
+1. 第 8.1 节三个资金风险点已逐条审查
|
|
|
+2. 第 9 节 Q1~Q3 三项待澄清已有明确结论
|
|
|
+3. 第 10.1 节与原方案的三处差异已认可
|
|
|
+4. 第 12 节审查结论已填写
|
|
|
+5. 知悉本变更为**代码先行、Spec 补写**的流程倒置,并同意以此方式追认
|