516 lines
24 KiB
Go
516 lines
24 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"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); 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 {
|
|
if refund.OpenID == "" {
|
|
slog.ErrorContext(ctx, "xpay refund: empty openid", "order_no", order.OrderNo)
|
|
return errcode.OrderRealityCheckFailed
|
|
}
|
|
|
|
info, err := wechat.QueryOrder(ctx, refund.OpenID, refund.OutTradeNo, "")
|
|
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 处理退款推送:复用退款成功回退权益链路(settleRefundSuccess)。
|
|
func (s *OrderService) handleRefundNotify(ctx context.Context, refund *wechat.XPayRefundNotify) *errcode.Error {
|
|
lockKey := fmtLockKey(redismodel.KeyLockRefundCallback, refund.RefundOrderID)
|
|
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)
|
|
return nil // 锁不可用(Redis 故障):不重复处理,交由微信重试/持锁者。
|
|
}
|
|
if !ok {
|
|
slog.DebugContext(ctx, "xpay refund: concurrent callback, skip", "out_refund_no", refund.RefundOrderID)
|
|
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)
|
|
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
|
|
}
|
|
// 已终态则幂等返回。
|
|
if order.OrderStatus == OrderStatusRefunded {
|
|
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 -> 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)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|