bindbox-game/internal/api/pay/wechat_notify.go

396 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(&notifyAck{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(&notifyAck{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(&notifyAck{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
}