# 任务拆分 — 酒店支付 Mock 免付款资金红线收口 > 拆分顺序:数据模型 → 接口协议 → 底层实现 → 上层编排 → 入口层 > 每个任务 = 可独立提交的原子变更(3-5 个文件) > 每个任务必须精确到文件路径和函数签名 --- ## ⚠️ 状态说明 **本文件为补写文档。全部 Task 已在 Spec 编写之前执行完毕**,勾选状态反映的是**已发生的事实**,不是待办计划。 资金类变更的人工审查门禁见 `spec.md` 第 12 / 13 节,**尚未签署**。 与原方案的三处差异见 `spec.md` **10.1**,本文在各 Task 下就地标注。 --- ## 前置条件 - [x] 确认 `mockSuccess` 落在 Sa-Token 白名单 `/hotel/open/**` 内(`SaTokenConfig` L79 `.notMatch("/hotel/open/**")`、L104 `.excludePathPatterns("/hotel/open/**")`),**不能靠加鉴权解决** —— 顾客端支付本身必须免登录 - [x] 确认用户硬约束:「调整**不可影响我开发时候的 h5 的测试**」→ 只能走 `spec.md` 3.6.7 的三类手法(条件编译 / 配置开关 / 纯增量),**禁止直接替换现有实现** - [x] 确认微信支付已决策暂缓上架(商户号未申请),但后端三端共用,`WECHAT` 渠道仍须**显式阻断**而非静默降级 - [x] 确认无数据库结构变更 → 不需 Flyway 脚本 - [x] 确认 6 份 yml 与第 2 批(二维码 URL 配置化)共享同一 `hotel:` 块 → 回滚只可删 `pay.mock-enabled` 一行 --- ## Task 1: 新增支付安全开关配置类 - **目标**: 用**代码级默认 `false`** 的配置开关控制模拟支付,yml 整块缺失也不会误开 - **涉及文件**: - `forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/config/HotelPayConfig.java` — **新建**(L22-49),`@Configuration` + `@Getter` + `@Slf4j`,读 `hotel.pay.mock-enabled`,`@PostConstruct` 打印开关状态(开启时 WARN 级资金安全告警) - **关键签名**: ```java @Slf4j @Getter @Configuration public class HotelPayConfig { /** 默认 false:生产环境模拟支付一律拒绝,未知/未接入渠道显式报错,不再静默降级为 MOCK。 */ @Value("${hotel.pay.mock-enabled:false}") private boolean mockEnabled; @PostConstruct public void logMockSwitch() { } } ``` - **决策依据**: `spec.md` D2 —— 默认值写在代码里而非只靠 yml,6 份 yml 分散在两个服务,任一遗漏都安全 --- ## Task 2: 抽出渠道解析单一收口点 - **目标**: 全仓只保留**一个**能返回 `"MOCK"` 的位置,把审查面收敛到 6 行 - **涉及文件**: - `.../pay/service/impl/HotelPayServiceImpl.java` — 修改,L60 注入 `HotelPayConfig`;L821-836 重写 `resolvePayChannel` 为全渠道显式判定;L846-851 新增 `mockOrReject` - **关键签名**: ```java /** 渠道解析:只有 ALIPAY / ALIPAY_MP 返回 "ALIPAY",其余全部经 mockOrReject 判定 */ private String resolvePayChannel(String paySource) { } /** 全仓唯一允许返回 "MOCK" 的位置:开关开启返回 MOCK,否则抛业务异常 */ private String mockOrReject(String reason) { if (hotelPayConfig.isMockEnabled()) { return "MOCK"; } throw new BusinessException(reason); } ``` - **渠道判定表**(漏一个分支 = 免付款,审查重点见 `spec.md` 8.1 ①): | 入参 | 返回 | |---|---| | `ALIPAY` / `ALIPAY_MP` | `"ALIPAY"` | | `WECHAT` / `WECHAT_MP` | `mockOrReject("微信支付暂未开通,请使用支付宝支付或到前台付款")` | | `null` / 空串 / 全空格 | `mockOrReject("支付渠道不能为空")` | | `MOCK` / `H5` | `mockOrReject("模拟支付已禁用")` | | 其它任意值 | `mockOrReject("不支持的支付渠道: " + paySource)` | - 🔴 **与原方案差异 ①**(见 `spec.md` 10.1):原方案是「在 `createPay` 入口显式拒绝 `MOCK`」,实际下沉为统一收口。原因:入口拒绝只能挡住 `paySource=MOCK`,**挡不住微信分支与兜底分支的两处静默降级**(改造前实测有两处,原缺口清单只记录了一处) --- ## Task 3: createPay 渠道解析前置 + 分流依据修正 - **目标**: 非白名单渠道在写流水**之前**即被拒绝,且分流不再误判 `ALIPAY_MP` - **涉及文件**: - `.../pay/service/impl/HotelPayServiceImpl.java` — 修改,`createPay` L117-119 渠道解析前置到 `payLogMapper.insert` 之前;L138-140 分流判断由原始 `paySource` 改为已解析的 `payChannel` - **关键签名**: ```java @Override @Transactional(rollbackFor = Exception.class) public Map createPay(Long orderId, String paySource) { // ... // 渠道解析前置:非白名单渠道在此即被拒绝,杜绝静默降级为 MOCK 造成免付款 String payChannel = resolvePayChannel(paySource); payLog.setPayChannel(payChannel); // ... // 根据已解析的 payChannel 调用支付渠道。 // 禁止用原始 paySource 判断:否则 ALIPAY_MP 会漏进 else 分支被静默置为 MOCK。 if ("ALIPAY".equals(payChannel)) { /* alipay.trade.wap.pay */ } else { payParams.put("payChannel", "MOCK"); } } ``` - **修复的隐患**: `resolvePayChannel("ALIPAY_MP")` 返回 `"ALIPAY"`,若继续用原始 `paySource` 分流,`ALIPAY_MP` 会落进 else 被置为 `MOCK` → 免付款 - ⚠️ **语义陷阱**(后续维护必读,见 `spec.md` 2.4):未来补 `alipay.trade.create` 分支时,**分流必须用原始 `paySource`**,不能用 `payChannel` --- ## Task 4: 接口层约束收紧 - **目标**: `paySource` 必传 + `mockSuccess` 开关关闭时首行拒绝 - **涉及文件**: - `.../controller/open/HotelPayController.java` — 修改,L44-45 注入 `HotelPayConfig`;L56-59 `createPay` 去掉 `defaultValue = "MOCK"`;L80-86 `mockPaySuccess` 首行拦截 - **关键签名**: ```java @PostMapping("/create") public RespInfo> createPay(@RequestParam Long tenantId, @RequestParam Long orderId, @RequestParam String paySource) { } // ← 去 defaultValue @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("模拟支付已禁用"); } // ... 进入租户上下文 } ``` - **要点**: 拦截放在**进入 `TenantContextHolder.executeWithTenant` 之前**,被拒请求不触发任何 DB 操作 - **接口路径与出入参结构均未变更**,前端 `api/index.js` 无需改动 --- ## Task 5: 退款渠道显式判定(防未来资损) - **目标**: 禁用「非 ALIPAY 即 MOCK」兜底,改为显式三分支 - **涉及文件**: - `.../pay/service/impl/HotelPayServiceImpl.java` — 修改,`refundOrder` L564-582 - **关键签名**: ```java @Override public void refundOrder(Long orderId) { // ... 前置校验:仅已支付可退、不可重复退、金额合法 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(order, payLog, refundAmount, "MOCK_REFUND_" + orderId, new Date()); return; // 无真实资金流,仅回写状态 } if (!"ALIPAY".equals(paidChannel)) { log.error("退款渠道未接入,需人工处理: orderId={}, orderNo={}, payChannel={}", ...); throw new BusinessException("该支付渠道暂不支持在线退款,请联系前台人工处理"); } // → alipay.trade.refund + reconcileRefund 对账 } ``` - 🔴 **决策依据**: `spec.md` D5 —— 改造前用 `!"ALIPAY".equals(payChannel)` 兜底,未来接入微信后**微信订单退款会被误判为无资金流而直接标记成功,造成真实资损** - 🔴 **遗留风险(需人工审查,`spec.md` 8.1 ② / Q1)**:`payLog == null` 也直接标记退款成功,属历史数据兼容的妥协,存在「真实支付但流水丢失」被误判的风险 --- ## Task 6: 前端 else 分支拆分 - **目标**: 禁止前端兜底调用 `mockSuccess`,同时保证 dev 调试行为零变化 - **涉及文件**: - `forge-h5-ui/src/pages/hotel/customer/pay.vue` — 修改,`handlePay()` L258-286 - **关键签名**: ```js // 根据后端返回的 payChannel 拉起支付(不用 paySource 判断,ALIPAY_MP 同样走支付宝分支) if (createData.payChannel === 'ALIPAY' && createData.payForm) { // #ifdef H5 → 渲染 payForm 自动提交 // #ifdef MP-ALIPAY → my.tradePay({ tradeNO: createData.tradeNo }) } else if (createData.payChannel === 'ALIPAY' && createData.tradeNo) { // 支付宝小程序:有 tradeNo 无 payForm } else if (createData.payChannel === 'MOCK') { // ← 保留(拆分而非删除) var mockRes = await api.hotelPayMockSuccess(orderId.value, tenantId) // ... } else { // ← 新增 // 禁止在此兜底调用 hotelPayMockSuccess —— 那等于绕过资金校验免付款 paying.value = false uni.showModal({ title: '暂无法在线支付', content: '当前支付方式暂不可用,请使用支付宝完成支付,或联系前台办理付款。', showCancel: false }) } ``` - 🔴 **与原方案差异 ②**(见 `spec.md` 10.1):原方案是**删除** else MOCK 分支,实际改为**拆分**。原因:满足「不可影响 H5 开发测试」硬约束 —— 删除会让 dev 环境的 Mock 调试流程失效 - 🔴 **遗留风险(需人工审查,`spec.md` 8.1 ③ / Q2)**:代码里**仍存在调用 `hotelPayMockSuccess` 的路径**,生产环境依赖后端开关作为唯一防线 --- ## Task 7: 日志脱敏 - **目标**: 收口 `AGENTS.md` 5.9「禁止在日志中打印手机号」 - **涉及文件**: - `.../controller/open/AlipayAuthController.java` — 修改,L56 日志改调 `maskPhone(vo.getPhone())`,L66 新增 `private String maskPhone(String phone)` - `.../pay/service/impl/AlipayAuthServiceImpl.java` — 修改,L70 由打印授权响应原文改为只记 `responseLength` - **关键签名**: ```java log.info("支付宝用户授权完成: userName={}, phone={}", vo.getUserName(), maskPhone(vo.getPhone())); private String maskPhone(String phone) { } // 保留前3后4,中间 **** log.warn("支付宝手机号获取暂未实现,responseLength={}", response != null ? response.length() : 0); ``` - **决策依据**: `spec.md` D7 —— 与资金安全同属安全红线,一并收口成本最低;**响应体不变**,仅日志变更 --- ## Task 8: 6 份 yml 配置落地 - **目标**: 生产默认严格、dev 覆盖宽松,两服务同步 - **涉及文件**(admin-server + app-server 各 3 份): - `forge-server/forge-admin-server/src/main/resources/application.yml` — 修改,L239 - `forge-server/forge-admin-server/src/main/resources/application-dev.yml` — 修改,L104 - `forge-server/forge-admin-server/src/main/resources/application-dev.example.yml` — 修改,L102 - `forge-server/forge-app-server/src/main/resources/application.yml` — 修改,L121 - `forge-server/forge-app-server/src/main/resources/application-dev.yml` — 修改,L83 - `forge-server/forge-app-server/src/main/resources/application-dev.example.yml` — 修改,L83 - **配置内容**: ```yaml # 生产 application.yml(两份服务一致) hotel: pay: mock-enabled: ${FORGE_HOTEL_PAY_MOCK_ENABLED:false} # application-dev.yml / application-dev.example.yml(两份服务一致) hotel: pay: mock-enabled: true ``` - ⚠️ **环境变量名易错点**:正确名是 `FORGE_HOTEL_PAY_MOCK_ENABLED`,写成 `FORGE_HOTEL_PAY_MOCK` **不报错**,只静默退回 `false` - ⚠️ **共享块警告**(见 `spec.md` 7.3):这 6 份 yml 的同一 `hotel:` 块同时承载第 2 批的 `hotel.qr.*` 与 `hotel.alipay.*`。**回滚本批只可删 `pay.mock-enabled` 一行**,禁止整块回退 —— 否则连带破坏第 2 批,且两份生产 `application.yml` 会因 `@Value("${hotel.qr.base-url}")` 无默认值而**启动即崩** --- ## 未纳入本变更的事项 | # | 事项 | 原因 | 归属 | |---|---|---|---| | ① | 🔴 **未引入** `wechatPayProperties` 配置类占位 | 微信支付已决策暂缓,空配置类属无效代码;`resolvePayChannel` 的微信分支已足够阻断 | 🔴 **与原方案差异 ③**,见 `spec.md` 10.1 | | ② | 不实现 `ALIPAY_MP` 渠道(`alipay.trade.create`) | 属第 3 批 | `hotel-alipay-mp-adaptation`(待建) | | ③ | 不做二维码 URL 配置化 | 属第 2 批,**不涉资金,不单独建 Spec** | 已落地,记录在 `酒店二维码模块开发文档.md` | | ④ | 不处理 `application-dev.yml` 明文沙箱密钥 | **性质不同**(一个是资金逻辑,一个是密钥泄露),不可混入 | `hotel-alipay-secret-externalize` | | ⑤ | 不补单元测试 | `forge-hotel` 无测试基础设施(无 `src/test`) | 见 `spec.md` 8.5,需审查确认是否可接受 | --- ## 验证记录(已执行) | 项 | 结果 | |---|---| | 后端全 reactor `mvn compile` | ✅ BUILD SUCCESS | | H5 `pnpm build:h5` | ✅ Build complete | | `GetProblems` 12 个改动文件 | ✅ No errors | | 6 份 yml 逐行核对一致 | ✅ 一致 | | **dev 环境行为** | ✅ **零变化**(开关 `true`,全部分支走向与改造前一致) | | 单元测试 | ❌ 未执行(见上表 ⑤) | | 人工审查 | 🔴 **待执行**(`spec.md` 第 12 / 13 节) | > 环境事实:`mvn` 不在 PATH,实际位于 `D:\java_install\apache-maven-3.9.16\bin\mvn.cmd`;仓库根 `forge/` 是**空目录**,真实 Maven 根为 `forge-server/`(`AGENTS.md` 2.2 仍写 `cd forge && mvn clean install`,与实际不符)。