396 lines
15 KiB
Go
396 lines
15 KiB
Go
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
|
||
}
|