package service 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" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat" "github.com/redis/go-redis/v9" ) // VirtualOrderCreate 创建虚拟支付订单:本地落单(pay_channel=2)并返回前端调起 // wx.requestVirtualPayment 所需的 signData / paySig / signature。 func (s *OrderService) VirtualOrderCreate(ctx context.Context, userID int64, request *dto.VirtualOrderCreateRequest) (*dto.VirtualOrderCreateResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.VirtualOrderCreate") defer span.End() if !wechat.IsVirtualPayEnabled() { slog.WarnContext(ctx, "virtual order create: virtual pay not enabled", "user_id", userID) return nil, errcode.VirtualPayNotEnabled } // 订单记录当前激活的虚拟支付环境(现网 0 / 沙箱 1),发货推送按此校验。 xpayEnv, verr := wechat.GetVirtualPayEnv() if verr != nil { slog.ErrorContext(ctx, "virtual order create: resolve pay env failed", "user_id", userID, "error", verr) return nil, errcode.VirtualPayNotEnabled } goods, err := s.goodsDao.GoodsGetByCode(ctx, request.GoodsCode) if err != nil { if isNotFound(err) { slog.InfoContext(ctx, "virtual order create: goods not found", "user_id", userID, "goods_code", request.GoodsCode) return nil, errcode.GoodsNotFound } slog.ErrorContext(ctx, "virtual order create: query goods failed", "goods_code", request.GoodsCode, "error", err) return nil, errcode.DatabaseError } if goods.Status != GoodsStatusOnShelf { slog.InfoContext(ctx, "virtual order create: goods off shelf", "user_id", userID, "goods_code", request.GoodsCode) return nil, errcode.GoodsOffShelf } if goods.ProductID == "" || goods.ProductStatus != GoodsProductPublished { slog.WarnContext(ctx, "virtual order create: goods not published as xpay product", "user_id", userID, "goods_code", request.GoodsCode, "product_id", goods.ProductID, "product_status", goods.ProductStatus) return nil, errcode.VirtualPayGoodsNotPublished } // iOS 端微信限制最低支付 1 元(100 分),低于此金额调起支付必失败,提前拒绝以降低垃圾订单。 // 注意:platform 由前端上报(不可作为安全边界,可伪造为 android 绕过此限制); // 此校验仅作体验优化,不构成资损防线——真正的资损防线是发货推送 verifyXPayOrderPaid // 对微信侧订单真实性、金额、道具的反查。低金额商品在 iOS 上的垃圾订单靠过期清理兜底。 if request.Platform == XPayPlatformIOS && goods.DiscountPrice < 100 { slog.InfoContext(ctx, "virtual order create: rejected low amount on ios", "user_id", userID, "goods_code", request.GoodsCode, "pay_amount", goods.DiscountPrice) return nil, errcode.VirtualPayAmountTooLow } // 虚拟支付 signature 依赖用户 session_key(登录时缓存),缺失说明需重新登录。 sessionKey, serr := redisdao.AuthSessionKeyGet(ctx, userID) if serr != nil { if serr == redis.Nil { slog.WarnContext(ctx, "virtual order create: session key missing", "user_id", userID) return nil, errcode.UnauthorizedTokenError.WithDetails("缺少 session_key,请重新登录") } slog.ErrorContext(ctx, "virtual order create: get session key failed", "user_id", userID, "error", serr) return nil, errcode.CacheError } orderNo := genOrderNo() expireTime := nowUnix() + orderPayTTLSeconds payAmount := goods.DiscountPrice platform := request.Platform order := &mysqlmodel.Order{ OrderNo: orderNo, UserID: userID, GoodsID: goods.ID, GoodsName: goods.GoodsName, UnitPrice: goods.DiscountPrice, TotalAmount: payAmount, PayAmount: payAmount, PayChannel: PayChannelVirtual, OrderStatus: OrderStatusPending, MemberDays: goods.MemberDays, GoodsType: goods.GoodsType, QuotaValidDays: goods.ValidDays, CosQuota: goods.CosQuota, AIRecommendQuota: goods.AIRecommendQuota, ClosetShareQuota: goods.ClosetShareQuota, OutfitShareQuota: goods.OutfitShareQuota, ExpireTime: expireTime, XPayProductID: goods.ProductID, XPayPlatform: platform, XPayEnv: int8(xpayEnv), } if cerr := s.orderDao.OrderCreate(ctx, order); cerr != nil { slog.ErrorContext(ctx, "virtual order create: save order failed", "order_no", orderNo, "user_id", userID, "goods_code", request.GoodsCode, "error", cerr) return nil, errcode.VirtualOrderCreateFailed } sign, perr := wechat.BuildPaymentSign( global.VirtualPaySetting.OfferID, goods.ProductID, orderNo, "", sessionKey, payAmount, ) if perr != nil { slog.ErrorContext(ctx, "virtual order create: build payment sign failed", "order_no", orderNo, "user_id", userID, "error", perr) return nil, errcode.VirtualOrderCreateFailed } slog.InfoContext(ctx, "virtual order created", "order_no", orderNo, "user_id", userID, "goods_code", request.GoodsCode, "pay_amount", payAmount, "env", xpayEnv) return &dto.VirtualOrderCreateResponse{ OrderNo: orderNo, PayAmount: payAmount, ExpireTime: expireTime, SignData: sign.SignData, PaySig: sign.PaySig, Signature: sign.Signature, }, nil } // ---- 消息推送回调 ---- // XPayNotify 处理虚拟支付消息推送(发货/退款/iOS 退款问询/其它),返回应答报文。 func (s *OrderService) XPayNotify(ctx context.Context, raw []byte) []byte { ctx, span := global.Tracer.Start(ctx, "svc.XPayNotify") defer span.End() base, err := wechat.ParseNotify(raw) if err != nil { slog.ErrorContext(ctx, "xpay notify parse failed", "error", err) return wechat.BuildNotifyResponse(false) } switch base.Event { case wechat.EventGoodsDeliverNotify: var deliver wechat.XPayGoodsDeliverNotify if err := json.Unmarshal(raw, &deliver); err != nil { slog.ErrorContext(ctx, "xpay deliver notify decode failed", "error", err) return wechat.BuildNotifyResponse(false) } if e := s.handleGoodsDeliver(ctx, &deliver, string(raw)); e != nil { // 内部失败分支已各自记录 Error 日志,这里仅保留调用链痕迹,避免重复告警。 slog.DebugContext(ctx, "xpay deliver notify handle failed", "error", e) return wechat.BuildNotifyResponse(false) } return wechat.BuildNotifyResponse(true) case wechat.EventRefundNotify: var refund wechat.XPayRefundNotify if err := json.Unmarshal(raw, &refund); err != nil { slog.ErrorContext(ctx, "xpay refund notify decode failed", "error", err) return wechat.BuildNotifyResponse(false) } 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) } return wechat.BuildNotifyResponse(true) case wechat.EventIOSRefundQueryNotify: var query wechat.XPayIOSRefundQueryNotify if err := json.Unmarshal(raw, &query); err != nil { slog.ErrorContext(ctx, "xpay ios refund query decode failed", "error", err) return wechat.BuildNotifyResponse(false) } return s.handleIOSRefundQuery(ctx, &query) default: // 投诉/代币/风控等推送:结构化记录即可,暂不实现业务。 slog.WarnContext(ctx, "xpay notify ignored", "event", base.Event, "out_trade_no", base.OutTradeNo) return wechat.BuildNotifyResponse(true) } } // verifyXPayOrderPaid 反查微信侧订单真实性:消息推送无签名,发货前必须通过 // query_order(带 HMAC 签名的服务端 API)确认订单已真实支付、实付金额一致, // 防止伪造发货推送零成本获取权益。任一校验不通过返回错误(fail-closed,触发微信重试)。 func (s *OrderService) verifyXPayOrderPaid(ctx context.Context, order *mysqlmodel.Order, deliver *wechat.XPayGoodsDeliverNotify) *errcode.Error { if deliver.OpenID == "" { slog.ErrorContext(ctx, "xpay deliver: empty openid", "order_no", order.OrderNo) return errcode.OrderRealityCheckFailed } info, err := wechat.QueryOrder(ctx, deliver.OpenID, deliver.OutTradeNo, "") if err != nil { slog.ErrorContext(ctx, "xpay deliver: query order failed", "order_no", order.OrderNo, "error", err) return errcode.OrderRealityCheckFailed } // 微信侧无支付记录/未支付:伪造推送或数据异常,拒绝发放。 if !wechat.IsOrderPaidStatus(info.Order.Status) { slog.ErrorContext(ctx, "xpay deliver: order not paid in wechat side", "order_no", order.OrderNo, "query_status", info.Order.Status) return errcode.OrderRealityCheckFailed } // 反查金额必须与下单快照一致(资损防线)。 if info.Order.PaidFee != order.PayAmount { slog.ErrorContext(ctx, "xpay deliver: query amount mismatch", "order_no", order.OrderNo, "query_amount", info.Order.PaidFee, "order_amount", order.PayAmount) return errcode.OrderRealityCheckFailed } // 道具一致性由推送报文层校验(deliver.GoodsInfo.ProductID 对订单快照); // query_order 不回传 product_id,无法在此二次核对,故不再重复校验。 return nil } // verifyXPayRefund 反查微信侧退款真实性:退款推送无签名,回退权益前必须通过 // query_order(带 HMAC 签名的服务端 API)确认订单在微信侧已真实退款、金额一致, // 防止伪造退款推送回收权益。任一校验不通过返回错误(fail-closed,触发微信重试)。 func (s *OrderService) verifyXPayRefund(ctx context.Context, order *mysqlmodel.Order, refund *wechat.XPayRefundNotify) *errcode.Error { // 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, openid, order.OrderNo, "") if err != nil { slog.ErrorContext(ctx, "xpay refund: query order failed", "order_no", order.OrderNo, "error", err) return errcode.OrderRealityCheckFailed } // 微信侧无退款记录/未退款:伪造推送或数据异常,拒绝回退权益。 if !wechat.IsOrderRefundedStatus(info.Order.Status) { slog.ErrorContext(ctx, "xpay refund: order not refunded in wechat side", "order_no", order.OrderNo, "query_status", info.Order.Status) return errcode.OrderRealityCheckFailed } // 反查退款金额必须与推送一致(资损防线)。 // 退款单用 refund_fee;支付单退款后用 paid_fee - left_fee 推算累计已退金额。 refundedFee := info.Order.RefundFee if refundedFee <= 0 { refundedFee = info.Order.PaidFee - info.Order.LeftFee } if refundedFee != refund.RefundFee { slog.ErrorContext(ctx, "xpay refund: refund amount mismatch", "order_no", order.OrderNo, "query_refunded_fee", refundedFee, "notify_refund_fee", refund.RefundFee) return errcode.OrderRealityCheckFailed } return nil } // handleGoodsDeliver 处理道具发货推送:幂等锁 + 金额/道具校验 + 条件流转 + 发放权益。 func (s *OrderService) handleGoodsDeliver(ctx context.Context, deliver *wechat.XPayGoodsDeliverNotify, raw string) *errcode.Error { lockKey := fmtLockKey(redismodel.KeyLockPayCallback, deliver.OutTradeNo) lockVal := genUUID() ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second) if lerr != nil { slog.ErrorContext(ctx, "xpay deliver: acquire lock failed", "out_trade_no", deliver.OutTradeNo, "error", lerr) return nil // 锁不可用(Redis 故障):不重复处理,交由微信重试/持锁者。 } if !ok { slog.DebugContext(ctx, "xpay deliver: concurrent callback, skip", "out_trade_no", deliver.OutTradeNo) return nil // 并发回调,交由持锁者处理。 } defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }() order, gerr := s.orderDao.OrderGetByOrderNo(ctx, deliver.OutTradeNo) if gerr != nil { if isNotFound(gerr) { slog.ErrorContext(ctx, "xpay deliver: order not found", "out_trade_no", deliver.OutTradeNo) return errcode.OrderRealityCheckFailed } slog.ErrorContext(ctx, "xpay deliver: query order failed", "out_trade_no", deliver.OutTradeNo, "error", gerr) return errcode.DatabaseError } // 仅虚拟支付订单走此链路,防止与普通支付回调串单。 if order.PayChannel != PayChannelVirtual { slog.ErrorContext(ctx, "xpay deliver: order not virtual pay", "order_no", order.OrderNo, "pay_channel", order.PayChannel) return errcode.OrderRealityCheckFailed } // 防篡改:道具 ID 与实付金额必须与下单快照一致。 if deliver.GoodsInfo.ProductID != order.XPayProductID { slog.ErrorContext(ctx, "xpay deliver: product mismatch", "order_no", order.OrderNo, "notify_product", deliver.GoodsInfo.ProductID, "order_product", order.XPayProductID) return errcode.OrderRealityCheckFailed } if deliver.GoodsInfo.ActualPrice != order.PayAmount { slog.ErrorContext(ctx, "xpay deliver: amount mismatch", "order_no", order.OrderNo, "notify_amount", deliver.GoodsInfo.ActualPrice, "order_amount", order.PayAmount) return errcode.OrderRealityCheckFailed } // 防环境串单:沙箱推送不得发放现网订单。 if int8(deliver.Env) != order.XPayEnv { slog.ErrorContext(ctx, "xpay deliver: env mismatch", "order_no", order.OrderNo, "notify_env", deliver.Env, "order_env", order.XPayEnv) return errcode.OrderRealityCheckFailed } // 订单真实性反查:消息推送无签名,必须反查微信侧订单确认真实支付, // 防止伪造发货推送零成本获取权益;未通过返回错误 -> 应答 ErrCode:1,微信重试。 if verr := s.verifyXPayOrderPaid(ctx, order, deliver); verr != nil { return verr } // 条件流转:待支付 -> 已支付,防重复回调。 updated, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, deliver.OutTradeNo, OrderStatusPending, OrderStatusPaid) if uerr != nil { slog.ErrorContext(ctx, "xpay deliver: update order status failed", "out_trade_no", deliver.OutTradeNo, "error", uerr) return errcode.DatabaseError } if updated { slog.InfoContext(ctx, "xpay deliver: order paid", "order_no", order.OrderNo, "user_id", order.UserID, "pay_amount", order.PayAmount) s.finalizeVirtualPaidOrder(ctx, order, deliver.WeChatPayInfo.PaidTime, deliver.WeChatPayInfo.TransactionID, raw) return nil } // 流转失败:处理「订单已过期但用户实际已付款」的竞态(与普通支付 PayNotify 一致)。 current, gerr := s.orderDao.OrderGetByOrderNo(ctx, deliver.OutTradeNo) if gerr != nil { if isNotFound(gerr) { slog.WarnContext(ctx, "xpay deliver: order disappeared after status conflict", "out_trade_no", deliver.OutTradeNo) return nil } slog.ErrorContext(ctx, "xpay deliver: reload order failed", "out_trade_no", deliver.OutTradeNo, "error", gerr) return errcode.DatabaseError } switch current.OrderStatus { case OrderStatusPaid: // 已被并发回调处理,兜底补发(幂等)。 if uerr := s.orderDao.OrderUpdateByOrderNo(ctx, current.OrderNo, map[string]any{ "pay_time": resolvePaidTime(deliver.WeChatPayInfo.PaidTime), "transaction_id": deliver.WeChatPayInfo.TransactionID, "callback_raw": raw, }); uerr != nil { slog.ErrorContext(ctx, "xpay deliver: reconcile paid order pay info failed", "order_no", current.OrderNo, "error", uerr) } slog.InfoContext(ctx, "xpay deliver: order already paid, reconcile benefits", "order_no", current.OrderNo, "user_id", current.UserID) s.grantOrderBenefits(ctx, current) case OrderStatusExpired: // 订单被系统过期但用户已付款:过期 -> 已支付并补发货。 if ok, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, deliver.OutTradeNo, OrderStatusExpired, OrderStatusPaid); uerr != nil { slog.ErrorContext(ctx, "xpay deliver: compensate expired order failed", "out_trade_no", deliver.OutTradeNo, "error", uerr) return errcode.DatabaseError } else if ok { slog.InfoContext(ctx, "xpay deliver: expired order compensated", "order_no", current.OrderNo, "user_id", current.UserID) s.finalizeVirtualPaidOrder(ctx, current, deliver.WeChatPayInfo.PaidTime, deliver.WeChatPayInfo.TransactionID, raw) } else { slog.DebugContext(ctx, "xpay deliver: expired order already handled by another callback", "out_trade_no", deliver.OutTradeNo) } case OrderStatusCancelled: // 防御分支:历史上已取消的虚拟支付订单用户仍完成了支付,补发货避免「钱已扣、权益不到账」。 // 当前虚拟支付订单已禁止取消,此分支兜底存量数据与并发竞态。 if ok, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, deliver.OutTradeNo, OrderStatusCancelled, OrderStatusPaid); uerr != nil { slog.ErrorContext(ctx, "xpay deliver: compensate cancelled order failed", "out_trade_no", deliver.OutTradeNo, "error", uerr) return errcode.DatabaseError } else if ok { slog.InfoContext(ctx, "xpay deliver: cancelled order compensated", "order_no", current.OrderNo, "user_id", current.UserID) s.finalizeVirtualPaidOrder(ctx, current, deliver.WeChatPayInfo.PaidTime, deliver.WeChatPayInfo.TransactionID, raw) } else { slog.DebugContext(ctx, "xpay deliver: cancelled order already handled by another callback", "out_trade_no", deliver.OutTradeNo) } } return nil } // 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", "order_no", orderNo, "error", lerr) return nil // 锁不可用(Redis 故障):不重复处理,交由微信重试/持锁者。 } if !ok { slog.DebugContext(ctx, "xpay refund: concurrent callback, skip", "order_no", orderNo) return nil } defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }() 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 } // 已终态则幂等返回;同时补一次流水回写,覆盖此前「订单已置 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 } // 订单真实性反查:退款推送无签名,回退权益前必须反查微信侧确认真实退款, // 防止伪造退款推送回收权益;未通过返回错误 -> 应答 ErrCode:1,微信重试。 if verr := s.verifyXPayRefund(ctx, order, refund); verr != nil { return verr } // 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, "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 回收(回收链路保证「已消耗部分不回退」)。 func (s *OrderService) handleIOSRefundQuery(ctx context.Context, query *wechat.XPayIOSRefundQueryNotify) []byte { order, err := s.orderDao.OrderGetByOrderNo(ctx, query.OutTradeNo) if err != nil { if isNotFound(err) { slog.ErrorContext(ctx, "xpay ios refund query: order not found", "out_trade_no", query.OutTradeNo) } else { slog.ErrorContext(ctx, "xpay ios refund query: query order failed", "out_trade_no", query.OutTradeNo, "error", err) } return wechat.BuildIOSRefundQueryResponse(1, "拒绝退款", "订单号 "+query.OutTradeNo+" 在本地无记录,无法确认权益使用情况,拒绝退款") } // 已退款:款项已退回,再次建议退款会造成重复退款资损。 if order.OrderStatus == OrderStatusRefunded { slog.InfoContext(ctx, "xpay ios refund query: reject, order already refunded", "order_no", order.OrderNo) return wechat.BuildIOSRefundQueryResponse(1, "拒绝退款", "订单 "+order.OrderNo+" 已处于已退款状态,不支持重复退款") } // 权益已消耗则拒绝退款,防止「已使用权益后白嫖退款」。 if s.iosRefundBenefitsConsumed(ctx, order) { slog.InfoContext(ctx, "xpay ios refund query: reject, benefits consumed", "order_no", order.OrderNo, "user_id", order.UserID) return wechat.BuildIOSRefundQueryResponse(1, "拒绝退款", "订单 "+order.OrderNo+" 发放的权益已被使用(上传额度已消耗或会员已生效),不支持退款") } slog.InfoContext(ctx, "xpay ios refund query: suggest refund, benefits unused", "order_no", order.OrderNo, "user_id", order.UserID) return wechat.BuildIOSRefundQueryResponse(0, "建议退款", "订单 "+order.OrderNo+" 发放的权益尚未使用,建议退款") } // iosRefundBenefitsConsumed 判断订单发放的权益是否已被消耗(用于 iOS 退款问询拒绝策略)。 // 额度类:Cos 凭证已用 >0 视为已消耗;会员类:会员已生效(在有效期内)视为已使用。 func (s *OrderService) iosRefundBenefitsConsumed(ctx context.Context, order *mysqlmodel.Order) bool { // 额度类:该订单发放的 Cos 凭证已消耗则视为已使用。 if order.CosQuota > 0 { grants, err := mysqldao.NewQuotaGrantDao().QuotaGrantListByUserIDAndType(ctx, order.UserID, QuotaTypeCos) if err != nil { slog.WarnContext(ctx, "xpay ios refund query: list cos grants failed, treat as not consumed", "order_no", order.OrderNo, "user_id", order.UserID, "error", err) } else { for _, g := range grants { if g.RefID == order.OrderNo && g.Used > 0 { return true } } } } // 会员类:会员已生效(vip_expire 在未来且非非会员),说明用户已开始享受会员权益。 if order.MemberDays > 0 { if user, err := s.userDao.UserGetByID(ctx, order.UserID); err == nil { if user.VIPExpire > nowUnix() && user.VIPType != VIPTypeNone { return true } } else { slog.WarnContext(ctx, "xpay ios refund query: get user failed, treat vip as not consumed", "order_no", order.OrderNo, "user_id", order.UserID, "error", err) } } return false } // finalizeVirtualPaidOrder 虚拟支付发货:记录支付信息、发放权益并发送通知。 // paidTime/transactionID 由调用方提供:推送路径取推送报文,查单补偿路径取 query_order 结果。 func (s *OrderService) finalizeVirtualPaidOrder(ctx context.Context, order *mysqlmodel.Order, paidTime int64, transactionID, raw string) { if uerr := s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{ "pay_time": resolvePaidTime(paidTime), "transaction_id": transactionID, "callback_raw": raw, }); uerr != nil { slog.ErrorContext(ctx, "finalize virtual paid order: update pay info failed", "order_no", order.OrderNo, "error", uerr) } s.grantOrderBenefits(ctx, order) sendNotification(ctx, order.UserID, 0, NotificationTypeOrder, "订单支付成功", "你购买的「"+order.GoodsName+"」已到账") } // resolvePaidTime 兜底支付时间:推送字段缺失/非法(<=0)时用当前时间,避免 pay_time 写入 0。 func resolvePaidTime(paidTime int64) int64 { if paidTime <= 0 { return nowUnix() } return paidTime }