From d87f904e335e10e9720961d08819e4aeea242718 Mon Sep 17 00:00:00 2001 From: zhengchiliu <857493511@qq.com> Date: Mon, 14 Sep 2026 01:52:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=99=9A=E6=8B=9F=E6=94=AF?= =?UTF-8?q?=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/service/virtualpay.go | 32 ++++++------ pkg/wechat/virtualpay.go | 90 +++++++++++++++++++++++----------- pkg/wechat/virtualpay_test.go | 74 +++++++++++++++++++++++++--- 3 files changed, 145 insertions(+), 51 deletions(-) diff --git a/internal/service/virtualpay.go b/internal/service/virtualpay.go index db53220..03bdd6c 100644 --- a/internal/service/virtualpay.go +++ b/internal/service/virtualpay.go @@ -167,7 +167,7 @@ func (s *OrderService) XPayNotify(ctx context.Context, raw []byte) []byte { } // verifyXPayOrderPaid 反查微信侧订单真实性:消息推送无签名,发货前必须通过 -// query_order(带 HMAC 签名的服务端 API)确认订单已真实支付、金额与道具一致, +// query_order(带 HMAC 签名的服务端 API)确认订单已真实支付、实付金额一致, // 防止伪造发货推送零成本获取权益。任一校验不通过返回错误(fail-closed,触发微信重试)。 func (s *OrderService) verifyXPayOrderPaid(ctx context.Context, order *mysqlmodel.Order, deliver *wechat.XPayGoodsDeliverNotify) *errcode.Error { if deliver.OpenID == "" { @@ -183,22 +183,19 @@ func (s *OrderService) verifyXPayOrderPaid(ctx context.Context, order *mysqlmode } // 微信侧无支付记录/未支付:伪造推送或数据异常,拒绝发放。 - if !wechat.IsOrderPaidStatus(info.Status) { + if !wechat.IsOrderPaidStatus(info.Order.Status) { slog.ErrorContext(ctx, "xpay deliver: order not paid in wechat side", - "order_no", order.OrderNo, "query_status", info.Status) + "order_no", order.OrderNo, "query_status", info.Order.Status) return errcode.OrderRealityCheckFailed } - // 反查金额与道具必须与下单快照一致(资损防线)。 - if info.PayFee != order.PayAmount { + // 反查金额必须与下单快照一致(资损防线)。 + if info.Order.PaidFee != order.PayAmount { slog.ErrorContext(ctx, "xpay deliver: query amount mismatch", - "order_no", order.OrderNo, "query_amount", info.PayFee, "order_amount", order.PayAmount) - return errcode.OrderRealityCheckFailed - } - if info.ProductID != order.XPayProductID { - slog.ErrorContext(ctx, "xpay deliver: query product mismatch", - "order_no", order.OrderNo, "query_product", info.ProductID, "order_product", order.XPayProductID) + "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 } @@ -219,15 +216,20 @@ func (s *OrderService) verifyXPayRefund(ctx context.Context, order *mysqlmodel.O } // 微信侧无退款记录/未退款:伪造推送或数据异常,拒绝回退权益。 - if !wechat.IsOrderRefundedStatus(info.RefundStatus) { + if !wechat.IsOrderRefundedStatus(info.Order.Status) { slog.ErrorContext(ctx, "xpay refund: order not refunded in wechat side", - "order_no", order.OrderNo, "refund_status", info.RefundStatus) + "order_no", order.OrderNo, "query_status", info.Order.Status) return errcode.OrderRealityCheckFailed } // 反查退款金额必须与推送一致(资损防线)。 - if info.RefundFee != refund.RefundFee { + // 退款单用 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_refund_fee", info.RefundFee, "notify_refund_fee", refund.RefundFee) + "order_no", order.OrderNo, "query_refunded_fee", refundedFee, "notify_refund_fee", refund.RefundFee) return errcode.OrderRealityCheckFailed } return nil diff --git a/pkg/wechat/virtualpay.go b/pkg/wechat/virtualpay.go index a1c3e7d..16e352a 100644 --- a/pkg/wechat/virtualpay.go +++ b/pkg/wechat/virtualpay.go @@ -289,43 +289,75 @@ func (c *virtualPayClient) callXPay(ctx context.Context, uri string, body any, o // ---- 订单查询 / 退款 ---- -// XPayOrderInfo 虚拟支付订单查询结果。 +// XPayOrder query_order 响应中的 order 子对象(现金单)。 +// 注意两点官方约定(易错):订单信息包在 order 里(不是平铺在顶层);status 为数字枚举。 +type XPayOrder struct { + OrderID string `json:"order_id"` // 订单号(即下单时的 outTradeNo) + WxOrderID string `json:"wx_order_id"` // 微信内部单号 + Status int `json:"status"` // 当前状态,见 XPayOrderStatus* 常量 + BizType int `json:"biz_type"` // 业务类型 0=短剧 + OrderFee int64 `json:"order_fee"` // 订单金额(分) + PaidFee int64 `json:"paid_fee"` // 用户支付金额(分) + OrderType int `json:"order_type"` // 订单类型,见 XPayOrderType* 常量 + RefundFee int64 `json:"refund_fee"` // 退款单类型时的退款金额(分) + LeftFee int64 `json:"left_fee"` // 支付单退款后剩余金额(分) + PaidTime int64 `json:"paid_time"` // 支付/退款时间(秒) + ProvideTime int64 `json:"provide_time"` // 发货时间(秒) + BizMeta string `json:"biz_meta"` // 订单创建时透传的信息(下单时的 attach) + EnvType int `json:"env_type"` // 环境类型 1=现网 2=沙箱 + ChannelOrderID string `json:"channel_order_id"` // 渠道单号(用户支付详情页的商户单号) + WxpayOrderID string `json:"wxpay_order_id"` // 微信支付交易单号 + SettTime int64 `json:"sett_time"` // 结算时间(秒,>0 表示结算成功) + SettState int `json:"sett_state"` // 结算状态 0/3=未开始或待结算 1=结算中 2=结算成功 +} + +// XPayOrderInfo query_order 响应体。 type XPayOrderInfo struct { - OrderID string `json:"order_id"` // 商户订单号 - WxOrderID string `json:"wx_order_id"` // 微信订单号 - OpenID string `json:"openid"` // 用户 openid - ProductID string `json:"product_id"` // 道具 ID - Quantity int64 `json:"quantity"` // 购买数量 - PayFee int64 `json:"pay_fee"` // 实付金额(分) - Status string `json:"status"` // 订单状态(以官方枚举为准) - RefundStatus string `json:"refund_status"` // 退款状态(以官方枚举为准,待确认字段名) - RefundFee int64 `json:"refund_fee"` // 退款金额(分,待确认字段名) - ErrCode int `json:"errcode"` - ErrMsg string `json:"errmsg"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + Order XPayOrder `json:"order"` } -// 虚拟支付订单支付状态(query_order 返回的 status 字段,以微信官方枚举为准,联调时校正)。 +// 虚拟支付订单状态(query_order 返回的 order.status,数字枚举)。 const ( - XPayOrderStatusPaid = "PAID" // 已支付 + XPayOrderStatusInit = 0 // 订单初始化(未创建成功,不可支付) + XPayOrderStatusCreated = 1 // 订单创建成功(未支付) + XPayOrderStatusPaid = 2 // 订单已支付,待发货 + XPayOrderStatusProviding = 3 // 订单发货中 + XPayOrderStatusProvided = 4 // 订单已发货 + XPayOrderStatusRefunded = 5 // 订单已退款 + XPayOrderStatusClosed = 6 // 订单已关闭 + XPayOrderStatusRefundFailed = 7 // 订单退款失败 + XPayOrderStatusUserRefunded = 8 // 用户退款完成 + XPayOrderStatusAdRecovered = 9 // 回收广告金完成 + XPayOrderStatusSettleBack = 10 // 分账回退完成 ) -// IsOrderPaidStatus 判断 query_order 返回的订单状态是否为「已支付」。 -// 白名单判断:仅命中已知成功枚举返回 true;未知状态/空串一律视为未支付, -// 供发货链路 fail-closed 使用(判定为未支付的推送会被拒绝并交由微信重试)。 -func IsOrderPaidStatus(status string) bool { - return status == XPayOrderStatusPaid +// 虚拟支付订单类型(query_order 返回的 order.order_type)。 +const ( + XPayOrderTypeNormal = 0 // 普通虚拟支付 + XPayOrderTypeRefund = 1 // 普通退款 + XPayOrderTypeIOSPay = 7 // 苹果 iOS 支付 + XPayOrderTypeIOSRefund = 8 // 苹果 iOS 退款 +) + +// IsOrderPaidStatus 判断微信侧订单是否已支付(2 已支付待发货 / 3 发货中 / 4 已发货)。 +// 白名单判断:仅命中「已支付及之后」的枚举返回 true,其余(未支付/已退款/已关闭/未知) +// 一律视为未支付,供发货链路 fail-closed 使用(判定为未支付的推送会被拒绝并交由微信重试)。 +// 3/4 纳入白名单是为了让「已发货后的重试推送」仍能通过反查(幂等补发货场景)。 +func IsOrderPaidStatus(status int) bool { + switch status { + case XPayOrderStatusPaid, XPayOrderStatusProviding, XPayOrderStatusProvided: + return true + default: + return false + } } -// 虚拟支付订单退款状态(query_order 返回的 refund_status 字段,以微信官方枚举为准,联调时校正)。 -const ( - XPayOrderRefundedStatus = "REFUNDED" // 已退款 -) - -// IsOrderRefundedStatus 判断 query_order 返回的订单退款状态是否为「已退款」。 -// 白名单判断:仅命中已知成功枚举返回 true;未知状态/空串一律视为未退款, -// 供退款链路 fail-closed 使用(判定为未退款的推送会被拒绝并交由微信重试)。 -func IsOrderRefundedStatus(status string) bool { - return status == XPayOrderRefundedStatus +// IsOrderRefundedStatus 判断微信侧订单是否已退款(5 订单已退款 / 8 用户退款完成)。 +// 白名单判断,fail-closed,供退款链路使用(判定为未退款的推送会被拒绝并交由微信重试)。 +func IsOrderRefundedStatus(status int) bool { + return status == XPayOrderStatusRefunded || status == XPayOrderStatusUserRefunded } // 虚拟支付退款推送的退款状态(RefundStatus 字段,以微信官方枚举为准,联调时校正)。 diff --git a/pkg/wechat/virtualpay_test.go b/pkg/wechat/virtualpay_test.go index f34d8f0..ab0aa8d 100644 --- a/pkg/wechat/virtualpay_test.go +++ b/pkg/wechat/virtualpay_test.go @@ -148,25 +148,85 @@ func TestParseNotifyAndResponse(t *testing.T) { } } -// TestIsOrderPaidStatus 校验订单支付状态白名单判断:仅已知成功枚举为已支付,未知/空串按未支付。 +// TestIsOrderPaidStatus 校验订单支付状态白名单判断:仅「已支付及之后」为已支付, +// 未支付/已退款/已关闭等一律按未支付(fail-closed)。 func TestIsOrderPaidStatus(t *testing.T) { cases := []struct { - status string + status int want bool }{ {XPayOrderStatusPaid, true}, - {"NOTPAY", false}, - {"CLOSED", false}, - {"UNKNOWN", false}, - {"", false}, + {XPayOrderStatusProviding, true}, + {XPayOrderStatusProvided, true}, + {XPayOrderStatusInit, false}, + {XPayOrderStatusCreated, false}, + {XPayOrderStatusRefunded, false}, + {XPayOrderStatusClosed, false}, + {XPayOrderStatusRefundFailed, false}, } for _, c := range cases { if got := IsOrderPaidStatus(c.status); got != c.want { - t.Fatalf("IsOrderPaidStatus(%q) = %v, want %v", c.status, got, c.want) + t.Fatalf("IsOrderPaidStatus(%d) = %v, want %v", c.status, got, c.want) } } } +// TestIsOrderRefundedStatus 校验退款状态白名单判断:仅已退款/用户退款完成为已退款。 +func TestIsOrderRefundedStatus(t *testing.T) { + cases := []struct { + status int + want bool + }{ + {XPayOrderStatusRefunded, true}, + {XPayOrderStatusUserRefunded, true}, + {XPayOrderStatusPaid, false}, + {XPayOrderStatusRefundFailed, false}, + {XPayOrderStatusCreated, false}, + } + for _, c := range cases { + if got := IsOrderRefundedStatus(c.status); got != c.want { + t.Fatalf("IsOrderRefundedStatus(%d) = %v, want %v", c.status, got, c.want) + } + } +} + +// TestXPayOrderInfoUnmarshal 校验 query_order 响应解析:订单字段包在 order 子对象内,status 为数字枚举。 +// 回归防护:早期实现把字段平铺在顶层且 status 定义为 string,导致反查恒失败、订单永远停在待支付。 +func TestXPayOrderInfoUnmarshal(t *testing.T) { + raw := []byte(`{ + "errcode": 0, + "errmsg": "", + "order": { + "order_id": "OD20260914014559fc1cfee91b3748a2", + "wx_order_id": "wx_order_1", + "status": 2, + "order_fee": 990, + "paid_fee": 990, + "order_type": 0, + "refund_fee": 0, + "left_fee": 990, + "env_type": 2, + "biz_meta": "monthly_vip" + } + }`) + var info XPayOrderInfo + if err := json.Unmarshal(raw, &info); err != nil { + t.Fatalf("unmarshal query_order: %v", err) + } + if info.Order.Status != XPayOrderStatusPaid { + t.Fatalf("status = %d, want %d", info.Order.Status, XPayOrderStatusPaid) + } + if info.Order.PaidFee != 990 { + t.Fatalf("paid_fee = %d, want 990", info.Order.PaidFee) + } + if info.Order.OrderID != "OD20260914014559fc1cfee91b3748a2" { + t.Fatalf("order_id = %q", info.Order.OrderID) + } + if !IsOrderPaidStatus(info.Order.Status) { + t.Fatalf("IsOrderPaidStatus(%d) = false, want true", info.Order.Status) + } +} + // TestResolveEnvKey 校验环境自动选择规则:只填现网/只填沙箱/两把都填/两把都空。 func TestResolveEnvKey(t *testing.T) { // 只填现网 -> 现网