状态: 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 端点) |
VirtualPayService.handleRefundNotify 的 ORD 前缀分支:调 MembershipService.refundByOrderNo(orderNo) 完成退款处理(幂等)。
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)。
handleRefundNotify() L315-317 替换为:
} else if (outTradeNo.startsWith("ORD")) {
membershipService.refundByOrderNo(outTradeNo);
}
@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分支接入 退单+降级会员等级"
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 决定,应答仅供参考
在 L96(else 分支)之前新增:
} else if ("xpay_subscribe_ios_refund_query_notify".equals(event)) {
return virtualPayService.handleIosRefundQuery(payload);
}
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 + ",未产生实际使用,建议退款");
}
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;
}
将 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),保留供未来可能的扩展或测试用。
@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"
xpay_subscribe_ios_refund_query_notify → notify() 分发 → 返回正确应答结构(ErrCode/ErrMsg/IosRefundQueryResponse.result_code/result_info/evidence)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"