修复虚拟支付
This commit is contained in:
@@ -167,7 +167,7 @@ func (s *OrderService) XPayNotify(ctx context.Context, raw []byte) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// verifyXPayOrderPaid 反查微信侧订单真实性:消息推送无签名,发货前必须通过
|
// verifyXPayOrderPaid 反查微信侧订单真实性:消息推送无签名,发货前必须通过
|
||||||
// query_order(带 HMAC 签名的服务端 API)确认订单已真实支付、金额与道具一致,
|
// query_order(带 HMAC 签名的服务端 API)确认订单已真实支付、实付金额一致,
|
||||||
// 防止伪造发货推送零成本获取权益。任一校验不通过返回错误(fail-closed,触发微信重试)。
|
// 防止伪造发货推送零成本获取权益。任一校验不通过返回错误(fail-closed,触发微信重试)。
|
||||||
func (s *OrderService) verifyXPayOrderPaid(ctx context.Context, order *mysqlmodel.Order, deliver *wechat.XPayGoodsDeliverNotify) *errcode.Error {
|
func (s *OrderService) verifyXPayOrderPaid(ctx context.Context, order *mysqlmodel.Order, deliver *wechat.XPayGoodsDeliverNotify) *errcode.Error {
|
||||||
if deliver.OpenID == "" {
|
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",
|
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
|
return errcode.OrderRealityCheckFailed
|
||||||
}
|
}
|
||||||
// 反查金额与道具必须与下单快照一致(资损防线)。
|
// 反查金额必须与下单快照一致(资损防线)。
|
||||||
if info.PayFee != order.PayAmount {
|
if info.Order.PaidFee != order.PayAmount {
|
||||||
slog.ErrorContext(ctx, "xpay deliver: query amount mismatch",
|
slog.ErrorContext(ctx, "xpay deliver: query amount mismatch",
|
||||||
"order_no", order.OrderNo, "query_amount", info.PayFee, "order_amount", order.PayAmount)
|
"order_no", order.OrderNo, "query_amount", info.Order.PaidFee, "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)
|
|
||||||
return errcode.OrderRealityCheckFailed
|
return errcode.OrderRealityCheckFailed
|
||||||
}
|
}
|
||||||
|
// 道具一致性由推送报文层校验(deliver.GoodsInfo.ProductID 对订单快照);
|
||||||
|
// query_order 不回传 product_id,无法在此二次核对,故不再重复校验。
|
||||||
return nil
|
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",
|
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
|
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",
|
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 errcode.OrderRealityCheckFailed
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+61
-29
@@ -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 {
|
type XPayOrderInfo struct {
|
||||||
OrderID string `json:"order_id"` // 商户订单号
|
ErrCode int `json:"errcode"`
|
||||||
WxOrderID string `json:"wx_order_id"` // 微信订单号
|
ErrMsg string `json:"errmsg"`
|
||||||
OpenID string `json:"openid"` // 用户 openid
|
Order XPayOrder `json:"order"`
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 虚拟支付订单支付状态(query_order 返回的 status 字段,以微信官方枚举为准,联调时校正)。
|
// 虚拟支付订单状态(query_order 返回的 order.status,数字枚举)。
|
||||||
const (
|
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 返回的订单状态是否为「已支付」。
|
// 虚拟支付订单类型(query_order 返回的 order.order_type)。
|
||||||
// 白名单判断:仅命中已知成功枚举返回 true;未知状态/空串一律视为未支付,
|
const (
|
||||||
// 供发货链路 fail-closed 使用(判定为未支付的推送会被拒绝并交由微信重试)。
|
XPayOrderTypeNormal = 0 // 普通虚拟支付
|
||||||
func IsOrderPaidStatus(status string) bool {
|
XPayOrderTypeRefund = 1 // 普通退款
|
||||||
return status == XPayOrderStatusPaid
|
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 字段,以微信官方枚举为准,联调时校正)。
|
// IsOrderRefundedStatus 判断微信侧订单是否已退款(5 订单已退款 / 8 用户退款完成)。
|
||||||
const (
|
// 白名单判断,fail-closed,供退款链路使用(判定为未退款的推送会被拒绝并交由微信重试)。
|
||||||
XPayOrderRefundedStatus = "REFUNDED" // 已退款
|
func IsOrderRefundedStatus(status int) bool {
|
||||||
)
|
return status == XPayOrderStatusRefunded || status == XPayOrderStatusUserRefunded
|
||||||
|
|
||||||
// IsOrderRefundedStatus 判断 query_order 返回的订单退款状态是否为「已退款」。
|
|
||||||
// 白名单判断:仅命中已知成功枚举返回 true;未知状态/空串一律视为未退款,
|
|
||||||
// 供退款链路 fail-closed 使用(判定为未退款的推送会被拒绝并交由微信重试)。
|
|
||||||
func IsOrderRefundedStatus(status string) bool {
|
|
||||||
return status == XPayOrderRefundedStatus
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 虚拟支付退款推送的退款状态(RefundStatus 字段,以微信官方枚举为准,联调时校正)。
|
// 虚拟支付退款推送的退款状态(RefundStatus 字段,以微信官方枚举为准,联调时校正)。
|
||||||
|
|||||||
@@ -148,25 +148,85 @@ func TestParseNotifyAndResponse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestIsOrderPaidStatus 校验订单支付状态白名单判断:仅已知成功枚举为已支付,未知/空串按未支付。
|
// TestIsOrderPaidStatus 校验订单支付状态白名单判断:仅「已支付及之后」为已支付,
|
||||||
|
// 未支付/已退款/已关闭等一律按未支付(fail-closed)。
|
||||||
func TestIsOrderPaidStatus(t *testing.T) {
|
func TestIsOrderPaidStatus(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
status string
|
status int
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{XPayOrderStatusPaid, true},
|
{XPayOrderStatusPaid, true},
|
||||||
{"NOTPAY", false},
|
{XPayOrderStatusProviding, true},
|
||||||
{"CLOSED", false},
|
{XPayOrderStatusProvided, true},
|
||||||
{"UNKNOWN", false},
|
{XPayOrderStatusInit, false},
|
||||||
{"", false},
|
{XPayOrderStatusCreated, false},
|
||||||
|
{XPayOrderStatusRefunded, false},
|
||||||
|
{XPayOrderStatusClosed, false},
|
||||||
|
{XPayOrderStatusRefundFailed, false},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
if got := IsOrderPaidStatus(c.status); got != c.want {
|
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 校验环境自动选择规则:只填现网/只填沙箱/两把都填/两把都空。
|
// TestResolveEnvKey 校验环境自动选择规则:只填现网/只填沙箱/两把都填/两把都空。
|
||||||
func TestResolveEnvKey(t *testing.T) {
|
func TestResolveEnvKey(t *testing.T) {
|
||||||
// 只填现网 -> 现网
|
// 只填现网 -> 现网
|
||||||
|
|||||||
Reference in New Issue
Block a user