feat(order): 重构退款全流程并新增申请退款中状态

- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水
- 退款失败回退订单至已支付并恢复被取消的权益发放任务
- 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正
- 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份
- 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录
- 同步更新 README 与 Stitch 设计文档
This commit is contained in:
2026-09-16 03:44:09 +08:00
parent 89b59c7b91
commit b94dd6dffb
29 changed files with 2141 additions and 158 deletions
+146 -27
View File
@@ -4,7 +4,9 @@ import (
"context"
"encoding/json"
"log/slog"
"strconv"
"time"
"unicode/utf8"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
@@ -165,7 +167,7 @@ func (s *OrderService) XPayNotify(ctx context.Context, raw []byte) []byte {
slog.ErrorContext(ctx, "xpay refund notify decode failed", "error", err)
return wechat.BuildNotifyResponse(false)
}
if e := s.handleRefundNotify(ctx, &refund); e != nil {
if e := s.handleRefundNotify(ctx, &refund, string(raw)); e != nil {
// 内部失败分支已各自记录 Error 日志,这里仅保留调用链痕迹,避免重复告警。
slog.DebugContext(ctx, "xpay refund notify handle failed", "error", e)
return wechat.BuildNotifyResponse(false)
@@ -224,12 +226,24 @@ func (s *OrderService) verifyXPayOrderPaid(ctx context.Context, order *mysqlmode
// query_order(带 HMAC 签名的服务端 API)确认订单在微信侧已真实退款、金额一致,
// 防止伪造退款推送回收权益。任一校验不通过返回错误(fail-closed,触发微信重试)。
func (s *OrderService) verifyXPayRefund(ctx context.Context, order *mysqlmodel.Order, refund *wechat.XPayRefundNotify) *errcode.Error {
if refund.OpenID == "" {
slog.ErrorContext(ctx, "xpay refund: empty openid", "order_no", order.OrderNo)
// query_order 必需 openid:推送未携带时回退用订单归属用户的 openid(订单表不冗余该字段,按 user_id 取)。
openid := refund.OpenID
if openid == "" {
user, uerr := s.userDao.UserGetByID(ctx, order.UserID)
if uerr != nil {
slog.ErrorContext(ctx, "xpay refund: get user for openid failed",
"order_no", order.OrderNo, "user_id", order.UserID, "error", uerr)
return errcode.OrderRealityCheckFailed
}
openid = user.WxOpenID
}
if openid == "" {
slog.ErrorContext(ctx, "xpay refund: empty openid", "order_no", order.OrderNo, "user_id", order.UserID)
return errcode.OrderRealityCheckFailed
}
info, err := wechat.QueryOrder(ctx, refund.OpenID, refund.OutTradeNo, "")
// 按订单号反查(而非推送中的订单号字段),保证查询目标与本地订单严格一致。
info, err := wechat.QueryOrder(ctx, openid, order.OrderNo, "")
if err != nil {
slog.ErrorContext(ctx, "xpay refund: query order failed",
"order_no", order.OrderNo, "error", err)
@@ -372,41 +386,71 @@ func (s *OrderService) handleGoodsDeliver(ctx context.Context, deliver *wechat.X
return nil
}
// handleRefundNotify 处理退款推送:复用退款成功回退权益链路(settleRefundSuccess)。
func (s *OrderService) handleRefundNotify(ctx context.Context, refund *wechat.XPayRefundNotify) *errcode.Error {
lockKey := fmtLockKey(redismodel.KeyLockRefundCallback, refund.RefundOrderID)
// handleRefundNotify 处理退款推送(xpay_refund_notify):按官方字段驱动状态流转。
//
// 官方报文用 MchOrderId 定位订单、RetCode 判定结果(0=成功,非 0=失败,原因见 RetMsg)。
// 拿不到订单号或结果不可判定时一律应答失败(ErrCode:1),由微信按 2/4/8/16… 最多重试 15 次,
// 避免「静默成功」导致订单永久卡在「申请退款中」;成功路径仍强制反查 query_order 确认真实退款
// (推送无签名),防止伪造推送回收权益。
func (s *OrderService) handleRefundNotify(ctx context.Context, refund *wechat.XPayRefundNotify, raw string) *errcode.Error {
orderNo := refund.OrderNo()
if orderNo == "" {
slog.ErrorContext(ctx, "xpay refund: empty order no in notify",
"mch_order_id", refund.MchOrderID, "out_trade_no", refund.OutTradeNo,
"mch_refund_id", refund.MchRefundID, "wx_refund_id", refund.WxRefundID,
"retry_times", refund.RetryTimes)
return errcode.OrderRealityCheckFailed // 应答失败 -> 微信重试。
}
lockKey := fmtLockKey(redismodel.KeyLockRefundCallback, orderNo)
lockVal := genUUID()
ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second)
if lerr != nil {
slog.ErrorContext(ctx, "xpay refund: acquire lock failed", "out_refund_no", refund.RefundOrderID, "error", lerr)
slog.ErrorContext(ctx, "xpay refund: acquire lock failed", "order_no", orderNo, "error", lerr)
return nil // 锁不可用(Redis 故障):不重复处理,交由微信重试/持锁者。
}
if !ok {
slog.DebugContext(ctx, "xpay refund: concurrent callback, skip", "out_refund_no", refund.RefundOrderID)
slog.DebugContext(ctx, "xpay refund: concurrent callback, skip", "order_no", orderNo)
return nil
}
defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
// 白名单判断:仅「退款成功」才回退权益;空串/未知/中间状态仅记录,防止误回退
// (退款推送按官方文档在退款完成时触发,状态枚举联调时以官方为准)。
if refund.RefundStatus != wechat.XPayRefundStatusSuccess {
slog.WarnContext(ctx, "xpay refund notify not success, skip revoke",
"order_no", refund.OutTradeNo, "refund_order_id", refund.RefundOrderID,
"refund_status", refund.RefundStatus, "refund_fee", refund.RefundFee)
succ, determinable := refund.Succeeded()
if !determinable {
slog.ErrorContext(ctx, "xpay refund: refund result not determinable, ask wechat to retry",
"order_no", orderNo, "mch_refund_id", refund.MchRefundID, "wx_refund_id", refund.WxRefundID,
"retry_times", refund.RetryTimes)
return errcode.OrderRealityCheckFailed
}
order, gerr := s.orderDao.OrderGetByOrderNo(ctx, orderNo)
if gerr != nil {
if isNotFound(gerr) {
slog.ErrorContext(ctx, "xpay refund: order not found", "order_no", orderNo)
return errcode.OrderRealityCheckFailed
}
slog.ErrorContext(ctx, "xpay refund: query order failed", "order_no", orderNo, "error", gerr)
return errcode.DatabaseError
}
if !succ {
// 退款失败:订单条件回退「6 申请退款中 -> 2 已支付」,允许用户重新申请;
// 本地流水置失败并记录微信失败原因(无本地流水如 iOS 走 App Store 退款时跳过)。
reverted, rerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, order.OrderNo, OrderStatusRefunding, OrderStatusPaid)
if rerr != nil {
slog.ErrorContext(ctx, "xpay refund: revert order status failed",
"order_no", order.OrderNo, "mch_refund_id", refund.MchRefundID, "error", rerr)
}
s.markRefundFlowFailed(ctx, order, refund, raw)
slog.WarnContext(ctx, "xpay refund failed, order reverted to paid",
"order_no", order.OrderNo, "mch_refund_id", refund.MchRefundID, "ret_code", refundRetCodeText(refund),
"ret_msg", refund.RetMsg, "refund_fee", refund.RefundFee, "order_reverted", reverted)
return nil
}
order, gerr := s.orderDao.OrderGetByOrderNo(ctx, refund.OutTradeNo)
if gerr != nil {
if isNotFound(gerr) {
slog.ErrorContext(ctx, "xpay refund: order not found", "out_trade_no", refund.OutTradeNo)
return nil
}
slog.ErrorContext(ctx, "xpay refund: query order failed", "out_trade_no", refund.OutTradeNo, "error", gerr)
return errcode.DatabaseError
}
// 已终态则幂等返回。
// 已终态则幂等返回;同时补一次流水回写,覆盖此前「订单已置 5 但流水未同步」的中途失败。
if order.OrderStatus == OrderStatusRefunded {
s.markRefundFlowSuccess(ctx, order, refund.WxRefundID, raw)
slog.DebugContext(ctx, "xpay refund: order already refunded, skip", "order_no", order.OrderNo)
return nil
}
@@ -417,15 +461,90 @@ func (s *OrderService) handleRefundNotify(ctx context.Context, refund *wechat.XP
return verr
}
// settleRefundSuccess 内部含条件流转(Paid -> Refunded),重复推送不会重复回退。
// settleRefundSuccess 内部含条件流转(Paid/Refunding -> Refunded),重复推送不会重复回退。
// 内部失败已由 settleRefundSuccess 的使用方日志覆盖:此处接收返回值并记录,避免静默。
if serr := s.settleRefundSuccess(ctx, order, refund.RefundFee); serr != nil {
slog.ErrorContext(ctx, "xpay refund: settle refund success failed",
"order_no", order.OrderNo, "refund_order_id", refund.RefundOrderID, "error", serr)
"order_no", order.OrderNo, "mch_refund_id", refund.MchRefundID, "error", serr)
}
// 订单已置 5:同步本地申请流水为成功,并回写微信退款单号与推送原始报文便于对账。
s.markRefundFlowSuccess(ctx, order, refund.WxRefundID, raw)
return nil
}
// refundRetCodeText 退款结果文本(日志与失败原因用):优先 RetCode,旧版式回退 RefundStatus。
func refundRetCodeText(refund *wechat.XPayRefundNotify) string {
if refund.RetCode != nil {
return "RetCode=" + strconv.Itoa(*refund.RetCode)
}
return "RefundStatus=" + refund.RefundStatus
}
// markRefundFlowSuccess 尽力把订单关联的退款流水置为成功(best-effort,仅记日志不返回错误),
// 同时回写微信退款单号与推送原始报文(对账/审计用)。无本地流水(如 iOS 走 App Store 退款)时静默跳过。
func (s *OrderService) markRefundFlowSuccess(ctx context.Context, order *mysqlmodel.Order, wechatRefundNo, raw string) {
flow, ferr := s.refundDao.RefundFlowLogGetByOrderNo(ctx, order.OrderNo)
if ferr != nil {
if !isNotFound(ferr) {
slog.ErrorContext(ctx, "xpay refund: query refund flow failed", "order_no", order.OrderNo, "error", ferr)
}
return
}
updates := map[string]any{"status": RefundStatusSuccess, "success_time": nowUnix()}
if wechatRefundNo != "" {
updates["wechat_refund_no"] = wechatRefundNo
}
if raw != "" {
updates["callback_raw"] = truncateCallbackRaw(raw)
}
if _, uerr := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, flow.RefundNo, flow.Status, updates); uerr != nil {
slog.ErrorContext(ctx, "xpay refund: sync refund flow success failed",
"order_no", order.OrderNo, "refund_no", flow.RefundNo, "error", uerr)
}
}
// markRefundFlowFailed 尽力把订单关联的退款流水置为失败并记录微信失败原因(best-effort),无本地流水时跳过。
func (s *OrderService) markRefundFlowFailed(ctx context.Context, order *mysqlmodel.Order, refund *wechat.XPayRefundNotify, raw string) {
flow, ferr := s.refundDao.RefundFlowLogGetByOrderNo(ctx, order.OrderNo)
if ferr != nil {
if !isNotFound(ferr) {
slog.ErrorContext(ctx, "xpay refund: query refund flow failed", "order_no", order.OrderNo, "error", ferr)
}
return
}
if flow.Status == RefundStatusSuccess {
return // 已成功,不用失败态覆盖。
}
failReason := "虚拟支付退款失败:" + refundRetCodeText(refund)
if refund.RetMsg != "" {
failReason += "-" + refund.RetMsg
}
updates := map[string]any{"status": RefundStatusFailed, "fail_reason": failReason}
if refund.WxRefundID != "" {
updates["wechat_refund_no"] = refund.WxRefundID
}
if raw != "" {
updates["callback_raw"] = truncateCallbackRaw(raw)
}
if _, uerr := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, flow.RefundNo, flow.Status, updates); uerr != nil {
slog.ErrorContext(ctx, "xpay refund: mark refund flow failed",
"order_no", order.OrderNo, "refund_no", flow.RefundNo, "error", uerr)
}
}
// truncateCallbackRaw 截断回调原始报文,避免超长报文写入退款流水(按 rune 安全截断,防止产生非法 UTF-8)。
func truncateCallbackRaw(raw string) string {
const maxCallbackRawLen = 4096
if len(raw) <= maxCallbackRawLen {
return raw
}
cut := raw[:maxCallbackRawLen]
for len(cut) > 0 && !utf8.ValidString(cut) {
cut = cut[:len(cut)-1]
}
return cut
}
// handleIOSRefundQuery 处理 iOS 退款问询(3 秒内应答,仅本地查询)。
// 按权益使用情况细化:权益已消耗则拒绝退款(result_code=1 + evidence),未消耗则建议退款;
// 建议退款后权益由后续退款推送 settleRefundSuccess 回收(回收链路保证「已消耗部分不回退」)。