拆分顺序:数据模型 → 接口协议 → 底层实现 → 上层编排 → 入口层 每个任务 = 可独立提交的原子变更(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/**")),不能靠加鉴权解决 —— 顾客端支付本身必须免登录spec.md 3.6.7 的三类手法(条件编译 / 配置开关 / 纯增量),禁止直接替换现有实现WECHAT 渠道仍须显式阻断而非静默降级hotel: 块 → 回滚只可删 pay.mock-enabled 一行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 分散在两个服务,任一遗漏都安全
"MOCK" 的位置,把审查面收敛到 6 行.../pay/service/impl/HotelPayServiceImpl.java — 修改,L60 注入 HotelPayConfig;L821-836 重写 resolvePayChannel 为全渠道显式判定;L846-851 新增 mockOrReject/** 全仓唯一允许返回 "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,不能用 payChannelpaySource 必传 + mockSuccess 开关关闭时首行拒绝.../controller/open/HotelPayController.java — 修改,L44-45 注入 HotelPayConfig;L56-59 createPay 去掉 defaultValue = "MOCK";L80-86 mockPaySuccess 首行拦截@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 也直接标记退款成功,属历史数据兼容的妥协,存在「真实支付但流水丢失」被误判的风险mockSuccess,同时保证 dev 调试行为零变化forge-h5-ui/src/pages/hotel/customer/pay.vue — 修改,handlePay() L258-286js
// 根据后端返回的 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 的路径,生产环境依赖后端开关作为唯一防线AGENTS.md 5.9「禁止在日志中打印手机号」.../controller/open/AlipayAuthController.java — 修改,L56 日志改调 maskPhone(vo.getPhone()),L66 新增 private String maskPhone(String phone).../pay/service/impl/AlipayAuthServiceImpl.java — 修改,L70 由打印授权响应原文改为只记 responseLengthprivate 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 不报错,只静默退回 falsespec.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.md2.2 仍写cd forge && mvn clean install,与实际不符)。