瀏覽代碼

feat: 会员退款ORD分支 + iOS退款问询按官方协议接入

Task A: 会员退款ORD分支
- MembershipService: 新增 refundByOrderNo(orderNo) 退单+降级管理员
- VirtualPayService: handleRefundNotify ORD 分支调用 membershipService.refundByOrderNo

Task B: iOS退款问询按官方协议接入
- VirtualPayController: notify() 新增 xpay_subscribe_ios_refund_query_notify 事件分发
- VirtualPayService: 新增 handleIosRefundQuery 按 pay_order_id 路由+判定退款
- VirtualPayController: iosRefundBaseResponse 字段修正为官方 IosRefundQueryResponse 协议
- 测试: 新增5个用例覆盖ORD退款路由+iOS问询(同意/拒绝/不存在/已退款)
asus 1 月之前
父節點
當前提交
341ad448dc

+ 10 - 3
cfc-backend/src/main/java/com/etotem/cfc/controller/VirtualPayController.java

@@ -93,6 +93,8 @@ public class VirtualPayController {
                 return virtualPayService.handleDeliverNotify(payload);
             } else if ("xpay_refund_notify".equals(event)) {
                 return virtualPayService.handleRefundNotify(payload);
+            } else if ("xpay_subscribe_ios_refund_query_notify".equals(event)) {
+                return virtualPayService.handleIosRefundQuery(payload);
             } else {
                 // 未知事件(含 xpay_coin_pay_notify):记日志忽略,道具直购不涉及
                 log.info("虚拟支付推送未知事件,忽略: event={}", event);
@@ -185,11 +187,16 @@ public class VirtualPayController {
         }
     }
 
-    /** iOS 退款问询最低响应(协议规定应答字段,未接入时默认不同意由 Apple 裁决) */
+    /** iOS 退款问询最低响应(官方协议 IosRefundQueryResponse,未接入时默认拒绝由 Apple 裁决) */
     private Map<String, Object> iosRefundBaseResponse() {
         Map<String, Object> result = new HashMap<>();
-        result.put("IsAgree", false);
-        result.put("Reason", "SERVER_NOT_IMPLEMENTED");
+        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;
     }
 }

+ 56 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -15,6 +15,7 @@ import java.util.Date;
 import java.util.List;
 import java.util.stream.Collectors;
 
+@Slf4j
 @Service
 public class MembershipService implements MembershipServiceInterface {
 
@@ -451,6 +452,61 @@ public class MembershipService implements MembershipServiceInterface {
         memberUpgradeRecordMapper.insert(record);
     }
 
+    /**
+     * 会员退款处理(ORD前缀订单)— 幂等
+     * 1. 查订单 + 幂等检查(已 refunded 直接返回)
+     * 2. 订单置 refunded
+     * 3. 关联 FamilyMembership 置 refunded
+     * 4. 无其他有效会员时,降级管理员为 FREE
+     */
+    public void refundByOrderNo(String orderNo) {
+        PaymentOrder order = paymentOrderMapper.selectOne(
+            new LambdaQueryWrapper<PaymentOrder>().eq(PaymentOrder::getOrderNo, orderNo));
+        if (order == null) {
+            log.warn("会员退款失败,订单不存在: orderNo={}", orderNo);
+            return;
+        }
+        if ("refunded".equals(order.getStatus())) {
+            log.info("会员退款重复推送,已处理: orderNo={}", orderNo);
+            return;
+        }
+
+        // 订单置 refunded
+        order.setStatus("refunded");
+        order.setUpdatedAt(new Date());
+        paymentOrderMapper.updateById(order);
+
+        // FamilyMembership 置 refunded
+        FamilyMembership membership = membershipMapper.selectOne(
+            new LambdaQueryWrapper<FamilyMembership>().eq(FamilyMembership::getOrderNo, orderNo));
+        if (membership != null && !"refunded".equals(membership.getPaymentStatus())) {
+            membership.setPaymentStatus("refunded");
+            membership.setUpdatedAt(new Date());
+            membershipMapper.updateById(membership);
+        }
+
+        // 降级管理员:若无其他有效付费会员,降级为 FREE
+        Family family = familyMapper.selectById(order.getFamilyId());
+        if (family != null && family.getCreatorId() != null) {
+            User adminUser = userMapper.selectById(family.getCreatorId());
+            if (adminUser != null && order.getLevelCode().equals(adminUser.getMemberLevel())) {
+                Long activePaidCount = membershipMapper.selectCount(
+                    new LambdaQueryWrapper<FamilyMembership>()
+                        .eq(FamilyMembership::getFamilyId, order.getFamilyId())
+                        .eq(FamilyMembership::getPaymentStatus, "paid")
+                        .ne(FamilyMembership::getOrderNo, orderNo)
+                        .gt(FamilyMembership::getEndDate, new Date()));
+                if (activePaidCount == 0) {
+                    adminUser.setMemberLevel("FREE");
+                    adminUser.setMemberExpireTime(null);
+                    adminUser.setUpdatedAt(new Date());
+                    userMapper.updateById(adminUser);
+                    log.info("会员退款降级管理员: userId={}, familyId={}", adminUser.getId(), order.getFamilyId());
+                }
+            }
+        }
+    }
+
     /**
      * 使会员过期(降级为FREE)
      */

+ 82 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/VirtualPayService.java

@@ -294,7 +294,7 @@ public class VirtualPayService {
 
     /**
      * 处理虚拟支付退款推送(xpay_refund_notify)。
-     * 按 OutTradeNo 前缀路由:A→测评退款,SUB→订阅退款,ORD→TODO
+     * 按 OutTradeNo 前缀路由:A→测评退款,ORD→会员退款,SUB→订阅退款。
      */
     public Map<String, Object> handleRefundNotify(Map<String, Object> payload) {
         try {
@@ -313,8 +313,7 @@ public class VirtualPayService {
                 // 订阅退款:订单置 refunded + 订阅置 expired(iOS App Store 退款场景)
                 memberSubscriptionService.refundByOrderNo(outTradeNo);
             } else if (outTradeNo.startsWith("ORD")) {
-                // TODO 后续: 会员退款处理
-                log.warn("虚拟支付退款推送暂未接入会员处理: outTradeNo={}", outTradeNo);
+                membershipService.refundByOrderNo(outTradeNo);
             } else {
                 log.warn("虚拟支付退款推送未知订单前缀: outTradeNo={}", outTradeNo);
             }
@@ -325,6 +324,86 @@ public class VirtualPayService {
         }
     }
 
+    /**
+     * 处理 iOS App Store 退款问询(xpay_subscribe_ios_refund_query_notify)。
+     * 微信推送 pay_order_id(= outTradeNo),3秒内应答是否同意退款。
+     * 按订单前缀路由:A→测评,ORD→会员,SUB→订阅。
+     *
+     * <p>官方协议:result_code=0 建议退款,1 拒绝退款;evidence 必须具体。
+     */
+    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.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 + ",未产生实际使用,建议退款");
+    }
+
+    /** 构建 iOS 退款问询应答(官方协议 IosRefundQueryResponse 结构) */
+    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;
+    }
+
     /**
      * 查询虚拟支付订单实际支付状态(防 success 回调丢失的兜底,spec §7)。
      * 按官方规范:pay_sig = to_hex(hmac_sha256(appKey, "/xpay/query_order&" + post_body)),

+ 94 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/VirtualPayServiceTest.java

@@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -297,6 +298,99 @@ class VirtualPayServiceTest {
         assertEquals(expectedPaySig, virtualPayService.buildQueryOrderPaySig(postBody));
     }
 
+    @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");
+    }
+
+    @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"));
+        @SuppressWarnings("unchecked")
+        Map<String, Object> resp = (Map<String, Object>) result.get("IosRefundQueryResponse");
+        assertEquals(0, resp.get("result_code"));  // 建议退款
+        assertEquals("同意退款", resp.get("result_info"));
+        assertNotNull(resp.get("evidence"));
+    }
+
+    @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);
+
+        assertEquals(0, result.get("ErrCode"));
+        @SuppressWarnings("unchecked")
+        Map<String, Object> resp = (Map<String, Object>) result.get("IosRefundQueryResponse");
+        assertEquals(1, resp.get("result_code"));  // 拒绝退款
+        assertEquals("拒绝退款", resp.get("result_info"));
+    }
+
+    @Test
+    void handleIosRefundQuery_unknownOrderRefusesRefund() {
+        Map<String, Object> payload = new HashMap<>();
+        payload.put("pay_order_id", "ORD_NONEXIST");
+        payload.put("provide_status", "0");
+
+        when(membershipService.getByOrderNo("ORD_NONEXIST")).thenReturn(null);
+
+        Map<String, Object> result = virtualPayService.handleIosRefundQuery(payload);
+
+        assertEquals(0, result.get("ErrCode"));
+        @SuppressWarnings("unchecked")
+        Map<String, Object> resp = (Map<String, Object>) result.get("IosRefundQueryResponse");
+        assertEquals(1, resp.get("result_code"));  // 拒绝退款
+        assertTrue(resp.get("evidence").toString().contains("订单不存在"));
+    }
+
+    @Test
+    void handleIosRefundQuery_alreadyRefundedAgreesRefund() {
+        Map<String, Object> payload = new HashMap<>();
+        payload.put("pay_order_id", "SUB20260804001");
+        payload.put("provide_status", "1");
+
+        MemberSubscriptionOrder order = new MemberSubscriptionOrder();
+        order.setOrderNo("SUB20260804001");
+        order.setStatus("refunded");
+        when(memberSubscriptionService.getByOrderNo("SUB20260804001")).thenReturn(order);
+
+        Map<String, Object> result = virtualPayService.handleIosRefundQuery(payload);
+
+        assertEquals(0, result.get("ErrCode"));
+        @SuppressWarnings("unchecked")
+        Map<String, Object> resp = (Map<String, Object>) result.get("IosRefundQueryResponse");
+        assertEquals(0, resp.get("result_code"));  // 建议退款(已退款)
+        assertTrue(resp.get("evidence").toString().contains("已退款"));
+    }
+
     private void setField(String name, Object value) throws Exception {
         Field field = VirtualPayService.class.getDeclaredField(name);
         field.setAccessible(true);

+ 2 - 1
docs/superpowers/PROJECT-OVERVIEW.md

@@ -2,7 +2,7 @@
 
 **文档版本:** v2.4
 **日期:** 2026-08-04
-**状态:** 已确认(v2.1 Phase 2-4 全栈完成)+ 虚拟支付改造(已实现·联调中)
+**状态:** 已确认(v2.1 Phase 2-4 全栈完成)+ 虚拟支付改造(Tasks 1-12 已完成,退款闭环实施中)
 **维护:** 所有需求变更需更新本文档
 
 ---
@@ -387,6 +387,7 @@
 | `2026-07-28-membership-price-tier-update.md` | ✅ 全栈完成(FREE/FAMILY/PREMIUM 三等级,7 次提交) | 会员等级价格体系更新 |
 | `2026-07-31-family-members-redesign.md` | 🟡 计划中 | 家庭成员页面重新设计 |
 | `2026-08-02-virtual-payment-implementation.md` | 🟢 已实施 | 小程序虚拟支付接入实施计划(12 Tasks:建表/签名/推送/三链路改造/前端/管理端) |
+| `2026-08-04-virtual-payment-refund-followup.md` | 🔵 pending | 虚拟支付退款闭环 follow-up(会员退款ORD分支 + iOS退款问询按官方协议接入) |
 
 ### 计划与设计文档(specs/)
 

+ 293 - 0
docs/superpowers/plans/2026-08-04-virtual-payment-refund-followup.md

@@ -0,0 +1,293 @@
+# 虚拟支付退款闭环(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<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");
+}
+```
+
+### 验证步骤
+
+```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<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 新增辅助方法
+
+```java
+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) 的字段名改为官方协议:
+
+```java
+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 新增)
+
+```java
+@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"));  // 拒绝退款
+}
+```
+
+### 验证步骤
+
+```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"`