# 虚拟支付退款闭环(follow-up) **状态:** pending **前置:** 2026-08-02-virtual-payment-implementation.md(Tasks 1–12 全部 DONE) **设计依据:** `specs/2026-08-02-virtual-payment-design.md` §7 退款与异常处理 **官方文档:** 微信 iOS 端虚拟支付退款查询:[iOS 端接入 §六](https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/virtual-payment/ios.html);消息推送回调:[虚拟支付回调](https://developers.weixin.qq.com/miniprogram/dev/wxcloudservice/wxcloud/guide/wechatpay/virtual-payment-callback.html) --- ## 两个已知缺口 | # | 缺口 | 文件 | 现状 | |---|------|------|------| | 1 | 会员退款 ORD 分支未实现 | `VirtualPayService.handleRefundNotify` L315-317 | `TODO 后续`,仅 warn 日志 | | 2 | iOS 退款问询未按官方协议实现 | `VirtualPayController.iosRefundQuery` L108-135 | 返回错误字段 `{IsAgree:false}`,**实际收不到微信推送**(问询走 `/notify` 端点) | --- ## Task A:会员退款 ORD 分支(handleRefundNotify → MembershipService.refundByOrderNo) ### 目标 `VirtualPayService.handleRefundNotify` 的 ORD 前缀分支:调 `MembershipService.refundByOrderNo(orderNo)` 完成退款处理(幂等)。 ### 实现规格(MembershipService 新增方法) `MembershipService.refundByOrderNo(String orderNo)`: ``` 1. PaymentOrder order = getByOrderNo(orderNo) → null → log.warn("会员退款失败,订单不存在: orderNo={}", orderNo); return; 2. "refunded".equals(order.getStatus()) → log.info("会员退款重复推送,订单已 refunded: orderNo={}", orderNo); return; 3. order.setStatus("refunded"); order.setUpdatedAt(new Date()); paymentOrderMapper.updateById(order); 4. FamilyMembership membership = membershipMapper.selectOne( eq FamilyMembership::getOrderNo, orderNo) → != null && !"refunded".equals(paymentStatus) → setPaymentStatus("refunded"); setUpdatedAt; updateById 5. 降级管理员:Family family = familyMapper.selectById(order.getFamilyId()) → family != null && family.getCreatorId() != null: User adminUser = userMapper.selectById(creatorId) → adminUser != null && order.getLevelCode().equals(adminUser.getMemberLevel()): Long activeCount = membershipMapper.selectCount( eq familyId, eq paymentStatus "paid", ne orderNo, gt endDate now) → activeCount == 0: adminUser.setMemberLevel("FREE"); setMemberExpireTime(null); setUpdatedAt; updateById ``` **注意:** MembershipService 现无 `@Slf4j`(L18 `@Service` 但无 `@Slf4j`),需补注解才能用 `log.warn`/`log.info`。同理 `expireMember` 已用相同模式(L457-471)。 ### VirtualPayService 改动 `handleRefundNotify()` L315-317 替换为: ```java } else if (outTradeNo.startsWith("ORD")) { membershipService.refundByOrderNo(outTradeNo); } ``` ### 测试(VirtualPayServiceTest 新增) ```java @Test void handleRefundNotify_membershipPrefixCallsRefundByOrderNo() { Map payload = new HashMap<>(); payload.put("OutTradeNo", "ORD20260804001"); Map result = virtualPayService.handleRefundNotify(payload); assertEquals(0, result.get("ErrCode")); assertEquals("success", result.get("ErrMsg")); verify(membershipService, times(1)).refundByOrderNo("ORD20260804001"); } ``` ### 验证步骤 ```bash cd cfc-backend JAVA_HOME="/c/Program Files/Java/jdk1.8.0_341" mvn clean compile -q JAVA_HOME="/c/Program Files/Java/jdk1.8.0_341" mvn test -Dtest=VirtualPayServiceTest -q ``` ### 提交 ```bash git add cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java \ cfc-backend/src/main/java/com/etotem/cfc/service/VirtualPayService.java \ cfc-backend/src/test/java/com/etotem/cfc/service/VirtualPayServiceTest.java git commit -m "feat: 会员退款ORD分支接入 退单+降级会员等级" ``` --- ## Task B:iOS 退款问询接入(notify() 事件分发) ### 官方协议要点(经 libarary 逐字核实) - 事件名:`Event = "xpay_subscribe_ios_refund_query_notify"` - **走消息推送事件(`/notify` 端点),不是独立端点** - 请求体扁平字段:`pay_order_id`(= outTradeNo,业务订单号)、`refund_request_reason`、`provide_status`、`refund_time`、`order_time`、`product_id`、`p_count`、`consumption_status`、`service_type` 等 - 应答格式(**非** `IsAgree/Reason`): ```json { "ErrCode": 0, "ErrMsg": "success", "IosRefundQueryResponse": { "result_code": 0, // 0=建议退款,1=拒绝退款 "result_info": "同意退款", "evidence": "订单未发货,建议退款" // 必须具体,不能为空或仅"不同意" } } ``` - `result_code` 含义:`0` = 建议退款(放过),`1` = 拒绝退款(拦截) - 3 秒内应答;3 次未应答 → "不确定"交 Apple 裁决;开发者**无法主动返回"不确定"** - 最终是否退款由 Apple 决定,应答仅供参考 ### 改动一:VirtualPayController.notify() 新增事件分发 在 L96(else 分支)之前新增: ```java } else if ("xpay_subscribe_ios_refund_query_notify".equals(event)) { return virtualPayService.handleIosRefundQuery(payload); } ``` ### 改动二:VirtualPayService.handleIosRefundQuery() 新增 ```java public Map handleIosRefundQuery(Map payload) { String payOrderId = getStringIgnoreCase(payload, "pay_order_id"); String reason = getStringIgnoreCase(payload, "refund_request_reason"); String provideStatus = getStringIgnoreCase(payload, "provide_status"); log.info("iOS退款问询: payOrderId={}, reason={}, provideStatus={}", payOrderId, reason, provideStatus); if (payOrderId == null || payOrderId.isEmpty()) { log.warn("iOS退款问询缺少pay_order_id,拒绝退款"); return buildIosRefundQueryResult(1, "拒绝退款", "缺少订单号,无法核实"); } // 按订单前缀路由查状态(与 handleDeliverNotify 同逻辑) boolean exists = false; boolean alreadyRefunded = false; String levelInfo = ""; try { if (payOrderId.startsWith("A")) { AssessmentOrder order = assessmentOrderService.getByOrderNo(payOrderId); if (order != null) { exists = true; alreadyRefunded = "refunded".equals(order.getStatus()); levelInfo = "测评订单,状态=" + order.getStatus(); } } else if (payOrderId.startsWith("ORD")) { PaymentOrder order = membershipService.getByOrderNo(payOrderId); if (order != null) { exists = true; alreadyRefunded = "refunded".equals(order.getStatus()); levelInfo = "会员订单,等级=" + order.getLevelCode() + ",状态=" + order.getStatus(); } } else if (payOrderId.startsWith("SUB")) { MemberSubscriptionOrder order = memberSubscriptionService.getByOrderNo(payOrderId); if (order != null) { exists = true; alreadyRefunded = "refunded".equals(order.getStatus()); levelInfo = "订阅订单,等级=" + order.getOrderNo() + ",状态=" + order.getStatus(); } } } catch (Exception e) { log.error("iOS退款问询查单异常: payOrderId={}", payOrderId, e); return buildIosRefundQueryResult(1, "拒绝退款", "系统异常无法核实订单"); } if (!exists) { return buildIosRefundQueryResult(1, "拒绝退款", "订单不存在: " + payOrderId); } if (alreadyRefunded) { return buildIosRefundQueryResult(0, "同意退款", "订单已退款: " + levelInfo); } // 已发货 → 建议拒绝(已使用,回收困难) if ("1".equals(provideStatus)) { return buildIosRefundQueryResult(1, "拒绝退款", levelInfo + ",已发货使用,不建议退款"); } // 未发货 → 建议退款 return buildIosRefundQueryResult(0, "同意退款", levelInfo + ",未产生实际使用,建议退款"); } ``` ### 改动三:VirtualPayService 新增辅助方法 ```java private Map buildIosRefundQueryResult(int resultCode, String resultInfo, String evidence) { Map result = new HashMap<>(); result.put("ErrCode", 0); result.put("ErrMsg", "success"); Map queryResponse = new HashMap<>(); queryResponse.put("result_code", resultCode); queryResponse.put("result_info", resultInfo); queryResponse.put("evidence", evidence); result.put("IosRefundQueryResponse", queryResponse); return result; } ``` ### 改动四:VirtualPayController.iosRefundQuery() 修正最低响应 将 `iosRefundBaseResponse()` (L188-194) 的字段名改为官方协议: ```java private Map iosRefundBaseResponse() { Map result = new HashMap<>(); result.put("ErrCode", 0); result.put("ErrMsg", "success"); Map queryResponse = new HashMap<>(); queryResponse.put("result_code", 1); queryResponse.put("result_info", "拒绝退款"); queryResponse.put("evidence", "系统未就绪,暂无法处理退款问询"); result.put("IosRefundQueryResponse", queryResponse); return result; } ``` > **注意**:此端点实际收不到微信推送(官方协议确认问询走 `/notify`),保留供未来可能的扩展或测试用。 ### 测试(VirtualPayServiceTest 新增) ```java @Test void handleIosRefundQuery_paidOrderWithoutDeliveryAgreesRefund() { Map payload = new HashMap<>(); payload.put("pay_order_id", "ORD20260804001"); payload.put("refund_request_reason", "UNINTENDED_PURCHASE"); payload.put("provide_status", "0"); PaymentOrder order = new PaymentOrder(); order.setOrderNo("ORD20260804001"); order.setLevelCode("FAMILY"); order.setStatus("paid"); when(membershipService.getByOrderNo("ORD20260804001")).thenReturn(order); Map result = virtualPayService.handleIosRefundQuery(payload); assertEquals(0, result.get("ErrCode")); assertNotNull(result.get("IosRefundQueryResponse")); Map resp = (Map) result.get("IosRefundQueryResponse"); assertEquals(0, resp.get("result_code")); // 建议退款 } @Test void handleIosRefundQuery_deliveredOrderRefusesRefund() { Map payload = new HashMap<>(); payload.put("pay_order_id", "A20260804001"); payload.put("provide_status", "1"); AssessmentOrder order = new AssessmentOrder(); order.setOrderNo("A20260804001"); order.setStatus("paid"); when(assessmentOrderService.getByOrderNo("A20260804001")).thenReturn(order); Map result = virtualPayService.handleIosRefundQuery(payload); Map resp = (Map) result.get("IosRefundQueryResponse"); assertEquals(1, resp.get("result_code")); // 拒绝退款 } ``` ### 验证步骤 ```bash cd cfc-backend JAVA_HOME="/c/Program Files/Java/jdk1.8.0_341" mvn clean compile -q JAVA_HOME="/c/Program Files/Java/jdk1.8.0_341" mvn test -Dtest=VirtualPayServiceTest -q ``` ### 提交 ```bash git add cfc-backend/src/main/java/com/etotem/cfc/controller/VirtualPayController.java \ cfc-backend/src/main/java/com/etotem/cfc/service/VirtualPayService.java \ cfc-backend/src/test/java/com/etotem/cfc/service/VirtualPayServiceTest.java git commit -m "feat: iOS退款问询按官方协议接入 notify事件分发+IosRefundQueryResponse" ``` --- ## 验收标准 - [ ] 会员退款推送 ORD 前缀 → PaymentOrder 置 refunded + FamilyMembership 置 refunded + 无其他有效会员时管理员降级 FREE - [ ] iOS 退款问询事件 `xpay_subscribe_ios_refund_query_notify` → notify() 分发 → 返回正确应答结构(ErrCode/ErrMsg/IosRefundQueryResponse.result_code/result_info/evidence) - [ ] 已退款重复推送幂等(不再重复降级) - [ ] 测评/订阅订单 iOS 问询可按 pay_order_id 路由 - [ ] `mvn clean compile` 通过;路由无冲突(/api/internal/virtual-pay/notify 仅 1 个 POST) - [ ] `VirtualPayServiceTest` 全部通过(原有 14 用例 + 新增 4 用例) --- ## 约束(与原计划一致) - 接口统一 `@PostMapping`;响应推送回调用 Map,不用 Result - `@Resource` DI,字段名匹配 Bean Name;新增类前 grep Bean 命名冲突 - 数据库无新迁移(不改表结构) - `VirtualPayService` → `MembershipService` / `MemberSubscriptionService` 单向注入(不新增循环) - 编译/测试用 `JAVA_HOME="/c/Program Files/Java/jdk1.8.0_341"`