tasks.md 14 KB

任务拆分 — 酒店支付 Mock 免付款资金红线收口

拆分顺序:数据模型 → 接口协议 → 底层实现 → 上层编排 → 入口层 每个任务 = 可独立提交的原子变更(3-5 个文件) 每个任务必须精确到文件路径和函数签名


⚠️ 状态说明

本文件为补写文档。全部 Task 已在 Spec 编写之前执行完毕,勾选状态反映的是已发生的事实,不是待办计划。 资金类变更的人工审查门禁见 spec.md 第 12 / 13 节,尚未签署

与原方案的三处差异见 spec.md 10.1,本文在各 Task 下就地标注。


前置条件

  • 确认 mockSuccess 落在 Sa-Token 白名单 /hotel/open/** 内(SaTokenConfig L79 .notMatch("/hotel/open/**")、L104 .excludePathPatterns("/hotel/open/**")),不能靠加鉴权解决 —— 顾客端支付本身必须免登录
  • 确认用户硬约束:「调整不可影响我开发时候的 h5 的测试」→ 只能走 spec.md 3.6.7 的三类手法(条件编译 / 配置开关 / 纯增量),禁止直接替换现有实现
  • 确认微信支付已决策暂缓上架(商户号未申请),但后端三端共用,WECHAT 渠道仍须显式阻断而非静默降级
  • 确认无数据库结构变更 → 不需 Flyway 脚本
  • 确认 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 级资金安全告警)
  • 关键签名:

    @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<String, Object> 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,与实际不符)。