|
|
@@ -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"`
|