package pay import ( "context" "encoding/json" "fmt" "net/http" "strings" "time" "bindbox-game/configs" "bindbox-game/internal/code" "bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/pay" "bindbox-game/internal/pkg/util/remark" "bindbox-game/internal/pkg/wechat" "bindbox-game/internal/repository/mysql/dao" "bindbox-game/internal/repository/mysql/model" "go.uber.org/zap" "github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers" "github.com/wechatpay-apiv3/wechatpay-go/core/downloader" "github.com/wechatpay-apiv3/wechatpay-go/core/notify" "github.com/wechatpay-apiv3/wechatpay-go/services/payments" "github.com/wechatpay-apiv3/wechatpay-go/utils" ) type notifyAck struct { Code string `json:"code"` Message string `json:"message"` } // WechatNotify 微信支付回调通知处理 // 入参:微信官方通知,验签并解密resource,推进订单状态为已支付 // 幂等:若订单已为已支付则直接ACK // @Summary 微信支付回调通知 // @Description 接收微信支付结果通知,验证签名并处理订单状态 // @Tags Pay // @Accept json // @Produce json // @Success 200 {object} notifyAck "处理成功" // @Failure 400 {object} notifyAck "请求参数错误" // @Failure 500 {object} notifyAck "服务器内部错误" // @Router /pay/wechat/notify [post] func (h *handler) WechatNotify() core.HandlerFunc { return func(ctx core.Context) { c := configs.Get() if c.WechatPay.ApiV3Key == "" { ctx.AbortWithError(core.Error(http.StatusBadRequest, 150000, "wechat pay config incomplete")) return } var handler *notify.Handler if c.WechatPay.PublicKeyID != "" && c.WechatPay.PublicKeyPath != "" { pubKey, err := utils.LoadPublicKeyWithPath(c.WechatPay.PublicKeyPath) if err != nil { ctx.AbortWithError(core.Error(http.StatusInternalServerError, 150001, err.Error())) return } handler = notify.NewNotifyHandler(c.WechatPay.ApiV3Key, verifiers.NewSHA256WithRSAPubkeyVerifier(c.WechatPay.PublicKeyID, *pubKey)) } else { if c.WechatPay.MchID == "" || c.WechatPay.SerialNo == "" || c.WechatPay.PrivateKeyPath == "" { ctx.AbortWithError(core.Error(http.StatusBadRequest, 150000, "wechat pay config incomplete")) return } mchPrivateKey, err := utils.LoadPrivateKeyWithPath(c.WechatPay.PrivateKeyPath) if err != nil { ctx.AbortWithError(core.Error(http.StatusInternalServerError, 150002, err.Error())) return } if err := downloader.MgrInstance().RegisterDownloaderWithPrivateKey(ctx.RequestContext(), mchPrivateKey, c.WechatPay.SerialNo, c.WechatPay.MchID, c.WechatPay.ApiV3Key); err != nil { ctx.AbortWithError(core.Error(http.StatusInternalServerError, 150003, err.Error())) return } certificateVisitor := downloader.MgrInstance().GetCertificateVisitor(c.WechatPay.MchID) handler = notify.NewNotifyHandler(c.WechatPay.ApiV3Key, verifiers.NewSHA256WithRSAVerifier(certificateVisitor)) } var transaction payments.Transaction notification, err := handler.ParseNotifyRequest(ctx.RequestContext(), ctx.Request(), &transaction) if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error())) return } // 事件去重处理 var existed *model.PaymentNotifyEvents if notification != nil && notification.ID != "" { existed, _ = h.readDB.PaymentNotifyEvents.WithContext(ctx.RequestContext()).Where(h.readDB.PaymentNotifyEvents.NotifyID.Eq(notification.ID)).First() if existed != nil && existed.Processed { ctx.Payload(¬ifyAck{Code: "SUCCESS", Message: "OK"}) return } } if transaction.OutTradeNo == nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "missing out_trade_no")) return } paidAt := time.Now() if transaction.SuccessTime != nil { if t, err := time.Parse(time.RFC3339, *transaction.SuccessTime); err == nil { paidAt = t } } rawStr := func() string { b, _ := json.Marshal(transaction); return string(b) }() order, err := h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.OrderNo.Eq(*transaction.OutTradeNo)).First() if err != nil || order == nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "order not found for out_trade_no")) return } // 7. 使用全局事务处理后续业务逻辑 err = h.writeDB.Transaction(func(tx *dao.Query) error { // 【审计快照】步骤0:采集消费前快照 if h.snapshot != nil { beforeSnapshot, snapErr := h.snapshot.CaptureUserState(ctx.RequestContext(), order.UserID) if snapErr == nil && beforeSnapshot != nil { _ = h.snapshot.SaveSnapshot(ctx.RequestContext(), order.ID, order.OrderNo, order.UserID, 1, beforeSnapshot) } } // 1. 创建支付交易记录 payTx := &model.PaymentTransactions{ OrderID: order.ID, OrderNo: *transaction.OutTradeNo, Channel: "wechat_jsapi", TransactionID: func() string { if transaction.TransactionId != nil { return *transaction.TransactionId } return "" }(), AmountTotal: func() int64 { if transaction.Amount != nil && transaction.Amount.Total != nil { return *transaction.Amount.Total } return 0 }(), SuccessTime: paidAt, Raw: rawStr, } if err := tx.PaymentTransactions.WithContext(ctx.RequestContext()).Create(payTx); err != nil { return err } // 2. 只有在此事务中将订单状态更新为已支付(2) res, err := tx.Orders.WithContext(ctx.RequestContext()).Where(tx.Orders.OrderNo.Eq(*transaction.OutTradeNo), tx.Orders.Status.Eq(1)).Updates(map[string]any{ tx.Orders.Status.ColumnName().String(): 2, tx.Orders.PaidAt.ColumnName().String(): paidAt, }) if err != nil { return err } if res.RowsAffected == 0 { // 检查现有状态,如果是已支付,则可能是并发导致的,业务层面幂等跳过 cur, _ := tx.Orders.WithContext(ctx.RequestContext()).Where(tx.Orders.OrderNo.Eq(*transaction.OutTradeNo)).First() if cur != nil && cur.Status >= 2 { return nil // 幂等处理 } return fmt.Errorf("order status update failed or order already processed") } // 3. 优惠券扣减 (传递 tx 给 service 保证原子性) if err := h.user.DeductCouponsForPaidOrder(ctx.RequestContext(), tx, order.UserID, order.ID, paidAt); err != nil { h.logger.Error("优惠券扣减失败", zap.Error(err), zap.Int64("order_id", order.ID)) // 优惠券扣减失败通常不应阻塞主支付流程,但既然用了事务,这里如果返回 err 会回滚。 // 如果希望即便扣券失败也完成订单,可以记录日志后返回 nil。 // 这里遵循严格模式:如果扣券配置了但失败,回滚并让微信重试可能更安全。 return err } // 5. 积分奖励 func() { cfg, _ := tx.SystemConfigs.WithContext(ctx.RequestContext()).Where(tx.SystemConfigs.ConfigKey.Eq("points_reward_per_cent")).First() rate := int64(0) if cfg != nil { var r int64 _, _ = fmt.Sscanf(cfg.ConfigValue, "%d", &r) rate = r } if rate > 0 && order.ActualAmount > 0 { reward := order.ActualAmount * rate _ = h.user.AddPointsWithAction(ctx.RequestContext(), order.UserID, reward, "pay_reward", order.OrderNo, "pay_reward", nil, nil) } }() // 6. 一番赏占位 (事务内处理) if order.SourceType == 2 { rmk := remark.Parse(order.Remark) aid := rmk.ActivityID iss := rmk.IssueID dc := rmk.Count act, _ := tx.Activities.WithContext(ctx.RequestContext()).Where(tx.Activities.ID.Eq(aid)).First() if act != nil && act.PlayType == "ichiban" { rem := make([]int64, len(rmk.Slots)) for i, s := range rmk.Slots { rem[i] = s.Count } cur := 0 for i := int64(0); i < dc; i++ { slot := func() int64 { if len(rmk.Slots) > 0 { for cur < len(rem) && rem[cur] == 0 { cur++ } if cur >= len(rem) { return -1 } rem[cur]-- return rmk.Slots[cur].SlotIndex } return -1 }() if slot < 0 { break } // 检查是否已被占用 (事务内) cnt, _ := tx.IssuePositionClaims.WithContext(ctx.RequestContext()).Where(tx.IssuePositionClaims.IssueID.Eq(iss), tx.IssuePositionClaims.SlotIndex.Eq(slot)).Count() if cnt > 0 { return fmt.Errorf("slot_unavailable") } err := tx.IssuePositionClaims.WithContext(ctx.RequestContext()).Create(&model.IssuePositionClaims{IssueID: iss, SlotIndex: slot, UserID: order.UserID, OrderID: order.ID}) if err != nil { return err } } } } // 7. 标记通知事件为已处理 if notification != nil && notification.ID != "" { if existed == nil { _ = tx.PaymentNotifyEvents.WithContext(ctx.RequestContext()).Create(&model.PaymentNotifyEvents{ NotifyID: notification.ID, ResourceType: notification.ResourceType, EventType: notification.EventType, Summary: notification.Summary, Raw: rawStr, Processed: true, }) } else { _, _ = tx.PaymentNotifyEvents.WithContext(ctx.RequestContext()).Where(tx.PaymentNotifyEvents.NotifyID.Eq(notification.ID)).Update(tx.PaymentNotifyEvents.Processed, true) } } // 【审计快照】步骤N:采集消费后快照 if h.snapshot != nil { afterSnapshot, snapErr := h.snapshot.CaptureUserState(ctx.RequestContext(), order.UserID) if snapErr == nil && afterSnapshot != nil { _ = h.snapshot.SaveSnapshot(ctx.RequestContext(), order.ID, order.OrderNo, order.UserID, 2, afterSnapshot) } } return nil }) // 处理事务结果 if err != nil { if err.Error() == "slot_unavailable" { // 处理一番赏位置冲突退款 (事务外异步处理,避免长事务) go h.handleRefund(context.Background(), order, "ichiban_slot_conflict") ctx.Payload(¬ifyAck{Code: "SUCCESS", Message: "OK"}) return } ctx.AbortWithError(core.Error(http.StatusInternalServerError, 150005, err.Error())) return } // 8. 异步触发外部同步逻辑 (无需事务) go func() { bgCtx := context.Background() // 触发任务中心逻辑 _ = h.task.OnOrderPaid(bgCtx, order.UserID, order.ID) // 抽奖或发货逻辑 ord, _ := h.readDB.Orders.WithContext(bgCtx).Where(h.readDB.Orders.ID.Eq(order.ID)).First() if ord == nil { return } rmk := remark.Parse(ord.Remark) act, _ := h.readDB.Activities.WithContext(bgCtx).Where(h.readDB.Activities.ID.Eq(rmk.ActivityID)).First() if ord.SourceType == 2 && act != nil && act.DrawMode == "instant" { _ = h.activity.ProcessOrderLottery(bgCtx, ord.ID) } else if ord.SourceType == 4 { // 次数卡发放 var pkgID int64 var count int32 = 1 parts := strings.Split(ord.Remark, "|") for _, p := range parts { if strings.HasPrefix(p, "pkg_id:") { _, _ = fmt.Sscanf(p, "pkg_id:%d", &pkgID) } else if strings.HasPrefix(p, "count:") { _, _ = fmt.Sscanf(p, "count:%d", &count) } } if pkgID > 0 { if err := h.user.GrantGamePass(bgCtx, ord.UserID, pkgID, count, ord.OrderNo); err != nil { h.logger.Error("Failed to grant game pass", zap.Error(err), zap.String("order_no", ord.OrderNo)) } } else { h.logger.Error("Game pass package ID not found in remark", zap.String("order_no", ord.OrderNo), zap.String("remark", ord.Remark)) } // 虚拟发货通知 payerOpenid := "" if transaction.Payer != nil && transaction.Payer.Openid != nil { payerOpenid = *transaction.Payer.Openid } itemsDesc := "次数卡 " + ord.OrderNo if txID := func() string { if transaction.TransactionId != nil { return *transaction.TransactionId } return "" }(); txID != "" { if err := wechat.UploadVirtualShippingForBackground(bgCtx, &wechat.WechatConfig{AppID: configs.Get().Wechat.AppID, AppSecret: configs.Get().Wechat.AppSecret}, txID, ord.OrderNo, payerOpenid, itemsDesc); err != nil { h.logger.Error("次数卡虚拟发货失败", zap.Error(err), zap.String("order_no", ord.OrderNo)) } else { h.logger.Info("次数卡虚拟发货成功", zap.String("order_no", ord.OrderNo)) } } } else if ord.SourceType == 3 { // 对对碰订单虚拟发货(初始支付成功通知) payerOpenid := "" if transaction.Payer != nil && transaction.Payer.Openid != nil { payerOpenid = *transaction.Payer.Openid } itemsDesc := "对对碰游戏 " + ord.OrderNo if txID := func() string { if transaction.TransactionId != nil { return *transaction.TransactionId } return "" }(); txID != "" { if err := wechat.UploadVirtualShippingForBackground(bgCtx, &wechat.WechatConfig{AppID: configs.Get().Wechat.AppID, AppSecret: configs.Get().Wechat.AppSecret}, txID, ord.OrderNo, payerOpenid, itemsDesc); err != nil { h.logger.Error("对对碰虚拟发货失败", zap.Error(err), zap.String("order_no", ord.OrderNo)) } else { h.logger.Info("对对碰虚拟发货成功", zap.String("order_no", ord.OrderNo)) } } } else if ord.SourceType != 2 { // 普通商品虚拟发货 payerOpenid := "" if transaction.Payer != nil && transaction.Payer.Openid != nil { payerOpenid = *transaction.Payer.Openid } itemsDesc := "订单" + ord.OrderNo if txID := func() string { if transaction.TransactionId != nil { return *transaction.TransactionId } return "" }(); txID != "" { if err := wechat.UploadVirtualShippingForBackground(bgCtx, &wechat.WechatConfig{AppID: configs.Get().Wechat.AppID, AppSecret: configs.Get().Wechat.AppSecret}, txID, ord.OrderNo, payerOpenid, itemsDesc); err != nil { h.logger.Error("商户订单虚拟发货失败", zap.Error(err), zap.String("order_no", ord.OrderNo)) } else { h.logger.Info("商户订单虚拟发货成功", zap.String("order_no", ord.OrderNo)) } } } }() ctx.Payload(¬ifyAck{Code: "SUCCESS", Message: "OK"}) } } // handleRefund 处理一番赏冲突退款 func (h *handler) handleRefund(ctx context.Context, ord *model.Orders, reason string) { wc, e := pay.NewWechatPayClient(ctx) if e != nil { h.logger.Error("Failed to create wechat pay client for refund", zap.Error(e)) return } refundNo := fmt.Sprintf("R%s-%d", ord.OrderNo, time.Now().Unix()) rmk := remark.Parse(ord.Remark) iss := rmk.IssueID _, status, e2 := wc.RefundOrder(ctx, ord.OrderNo, refundNo, ord.ActualAmount, ord.ActualAmount, reason) if e2 != nil { h.logger.Error("Refund failed", zap.Error(e2), zap.String("order_no", ord.OrderNo)) return } _ = h.writeDB.PaymentRefunds.WithContext(ctx).Create(&model.PaymentRefunds{OrderID: ord.ID, OrderNo: ord.OrderNo, RefundNo: refundNo, Channel: "wechat_jsapi", Status: status, AmountRefund: ord.ActualAmount, Reason: reason}) _, _ = h.writeDB.Orders.WithContext(ctx).Where(h.writeDB.Orders.ID.Eq(ord.ID)).Update(h.writeDB.Orders.Status, 4) _ = h.repo.GetDbW().Exec("INSERT INTO lottery_refund_logs(issue_id, order_id, user_id, amount, coupon_type, coupon_amount, reason, status) VALUES(?,?,?,?,?,?,?,?)", iss, ord.ID, ord.UserID, ord.ActualAmount, "", 0, reason, status).Error } func parseInt64(s string) int64 { var n int64 for i := 0; i < len(s); i++ { c := s[i] if c < '0' || c > '9' { break } n = n*10 + int64(c-'0') } return n }