2026-08-04-virtual-payment-refund-followup.md 12 KB

虚拟支付退款闭环(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 端接入 §六;消息推送回调:虚拟支付回调


两个已知缺口

# 缺口 文件 现状
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 替换为:

} else if (outTradeNo.startsWith("ORD")) {
    membershipService.refundByOrderNo(outTradeNo);
}

测试(VirtualPayServiceTest 新增)

@Test
void handleRefundNotify_membershipPrefixCallsRefundByOrderNo() {
    Map<String, Object> payload = new HashMap<>();
    payload.put("OutTradeNo", "ORD20260804001");

    Map<String, Object> result = virtualPayService.handleRefundNotify(payload);

    assertEquals(0, result.get("ErrCode"));
    assertEquals("success", result.get("ErrMsg"));
    verify(membershipService, times(1)).refundByOrderNo("ORD20260804001");
}

验证步骤

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

提交

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):

    {
    "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 分支)之前新增:

} else if ("xpay_subscribe_ios_refund_query_notify".equals(event)) {
    return virtualPayService.handleIosRefundQuery(payload);
}

改动二:VirtualPayService.handleIosRefundQuery() 新增

public Map<String, Object> handleIosRefundQuery(Map<String, Object> 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 新增辅助方法

private Map<String, Object> buildIosRefundQueryResult(int resultCode, String resultInfo, String evidence) {
    Map<String, Object> result = new HashMap<>();
    result.put("ErrCode", 0);
    result.put("ErrMsg", "success");
    Map<String, Object> 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) 的字段名改为官方协议:

private Map<String, Object> iosRefundBaseResponse() {
    Map<String, Object> result = new HashMap<>();
    result.put("ErrCode", 0);
    result.put("ErrMsg", "success");
    Map<String, Object> queryResponse = new HashMap<>();
    queryResponse.put("result_code", 1);
    queryResponse.put("result_info", "拒绝退款");
    queryResponse.put("evidence", "系统未就绪,暂无法处理退款问询");
    result.put("IosRefundQueryResponse", queryResponse);
    return result;
}

注意:此端点实际收不到微信推送(官方协议确认问询走 /notify),保留供未来可能的扩展或测试用。

测试(VirtualPayServiceTest 新增)

@Test
void handleIosRefundQuery_paidOrderWithoutDeliveryAgreesRefund() {
    Map<String, Object> 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<String, Object> result = virtualPayService.handleIosRefundQuery(payload);

    assertEquals(0, result.get("ErrCode"));
    assertNotNull(result.get("IosRefundQueryResponse"));
    Map<String, Object> resp = (Map<String, Object>) result.get("IosRefundQueryResponse");
    assertEquals(0, resp.get("result_code"));  // 建议退款
}

@Test
void handleIosRefundQuery_deliveredOrderRefusesRefund() {
    Map<String, Object> 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<String, Object> result = virtualPayService.handleIosRefundQuery(payload);

    Map<String, Object> resp = (Map<String, Object>) result.get("IosRefundQueryResponse");
    assertEquals(1, resp.get("result_code"));  // 拒绝退款
}

验证步骤

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

提交

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"