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

514 lines
16 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/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 {
// 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 或直接在这里处理)
// 注意Service 内部也需要支持传入 tx 才能保证原子性,如果 Service 不支持,则核心逻辑应内联或重构
// 这里假设 h.user.DeductCouponsForPaidOrder 内部使用的是全局 DB 或有事务支持
// 如果无法确保 Service 事务一致,核心逻辑应由 tx 直接操作
_ = h.user.DeductCouponsForPaidOrder(ctx.RequestContext(), order.UserID, order.ID, paidAt)
// 4. 解析备注并核销优惠券 (补足 DeductCoupons 可能遗漏的备注片段逻辑)
remark := order.Remark
parts := strings.Split(remark, "|")
for _, seg := range parts {
if strings.HasPrefix(seg, "c:") {
xs := strings.Split(seg, ":")
if len(xs) == 3 {
ucID := parseInt64(xs[1])
applied := parseInt64(xs[2])
uc, _ := tx.UserCoupons.WithContext(ctx.RequestContext()).Where(tx.UserCoupons.ID.Eq(ucID), tx.UserCoupons.UserID.Eq(order.UserID)).First()
if uc != nil && !(uc.Status == 2 && uc.UsedOrderID == order.ID) {
sc, _ := tx.SystemCoupons.WithContext(ctx.RequestContext()).Where(tx.SystemCoupons.ID.Eq(uc.CouponID), tx.SystemCoupons.Status.Eq(1)).First()
if sc != nil {
if sc.DiscountType == 1 { // 金额券
newBal := uc.BalanceAmount - applied
if newBal < 0 {
newBal = 0
}
upd := map[string]any{"balance_amount": newBal, "used_order_id": order.ID, "used_at": paidAt}
if newBal == 0 {
upd["status"] = 2
}
_, _ = tx.UserCoupons.WithContext(ctx.RequestContext()).Where(tx.UserCoupons.ID.Eq(uc.ID)).Updates(upd)
} else { // 折扣券
_, _ = tx.UserCoupons.WithContext(ctx.RequestContext()).Where(tx.UserCoupons.ID.Eq(uc.ID)).Updates(map[string]any{"status": 2, "used_order_id": order.ID, "used_at": paidAt})
}
}
}
}
}
}
// 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 {
aid := parseActivityIDFromRemark(order.Remark)
iss := parseIssueIDFromRemark(order.Remark)
dc := parseCountFromRemark(order.Remark)
act, _ := tx.Activities.WithContext(ctx.RequestContext()).Where(tx.Activities.ID.Eq(aid)).First()
if act != nil && act.PlayType == "ichiban" {
idxs, cnts := parseSlotsCountsFromRemark(order.Remark)
rem := make([]int64, len(cnts))
copy(rem, cnts)
cur := 0
for i := int64(0); i < dc; i++ {
slot := func() int64 {
if len(idxs) > 0 && len(idxs) == len(rem) {
for cur < len(rem) && rem[cur] == 0 {
cur++
}
if cur >= len(rem) {
return -1
}
rem[cur]--
return idxs[cur] - 1
}
return parseSlotFromRemark(order.Remark)
}()
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)
}
}
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
}
actID := parseActivityIDFromRemark(ord.Remark)
act, _ := h.readDB.Activities.WithContext(bgCtx).Where(h.readDB.Activities.ID.Eq(actID)).First()
if ord.SourceType == 2 && act != nil && act.DrawMode == "instant" {
_ = h.activity.ProcessOrderLottery(bgCtx, ord.ID)
} else if ord.SourceType != 2 && 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 != "" {
_ = wechat.UploadVirtualShippingWithFallback(ctx, &wechat.WechatConfig{AppID: configs.Get().Wechat.AppID, AppSecret: configs.Get().Wechat.AppSecret}, txID, ord.OrderNo, payerOpenid, itemsDesc, time.Now())
}
}
}()
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())
iss := parseIssueIDFromRemark(ord.Remark)
refundID, 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.UserPointsLedger.WithContext(ctx).Create(&model.UserPointsLedger{UserID: ord.UserID, Action: "refund_amount", Points: ord.ActualAmount / 100, RefTable: "payment_refund", RefID: refundID})
_, _ = 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 parseIssueIDFromRemark(remark string) int64 {
if remark == "" {
return 0
}
parts := strings.Split(remark, "|")
for _, p := range parts {
if strings.HasPrefix(p, "issue:") {
return parseInt64(p[6:])
}
}
return 0
}
func parseActivityIDFromRemark(remark string) int64 {
if remark == "" {
return 0
}
parts := strings.Split(remark, "|")
for _, p := range parts {
if strings.HasPrefix(p, "lottery:activity:") {
return parseInt64(p[len("lottery:activity:"):])
}
}
return 0
}
func parseCountFromRemark(remark string) int64 {
if remark == "" {
return 1
}
parts := strings.Split(remark, "|")
for _, p := range parts {
if strings.HasPrefix(p, "count:") {
n := parseInt64(p[6:])
if n <= 0 {
return 1
}
return n
}
}
return 1
}
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
}
func parseSlotsCountsFromRemark(remark string) ([]int64, []int64) {
if remark == "" {
return nil, nil
}
parts := strings.Split(remark, "|")
for _, p := range parts {
if strings.HasPrefix(p, "slots:") {
pairs := p[6:]
var idxs []int64
var cnts []int64
start := 0
for start <= len(pairs) {
end := start
for end < len(pairs) && pairs[end] != ',' {
end++
}
if end > start {
a := pairs[start:end]
var x, y int64
j := 0
for j < len(a) && a[j] >= '0' && a[j] <= '9' {
x = x*10 + int64(a[j]-'0')
j++
}
if j < len(a) && a[j] == ':' {
j++
for j < len(a) && a[j] >= '0' && a[j] <= '9' {
y = y*10 + int64(a[j]-'0')
j++
}
}
if y > 0 {
idxs = append(idxs, x+1)
cnts = append(cnts, y)
}
}
start = end + 1
}
return idxs, cnts
}
}
return nil, nil
}
func parseSlotFromRemark(remark string) int64 {
if remark == "" {
return -1
}
p := 0
for i := 0; i < len(remark); i++ {
if remark[i] == '|' {
seg := remark[p:i]
if len(seg) > 5 && seg[:5] == "slot:" {
var n int64
for j := 5; j < len(seg); j++ {
c := seg[j]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
return n
}
p = i + 1
}
}
if p < len(remark) {
seg := remark[p:]
if len(seg) > 5 && seg[:5] == "slot:" {
var n int64
for j := 5; j < len(seg); j++ {
c := seg[j]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
return n
}
}
return -1
}
func parseItemCardIDFromRemark(remark string) int64 {
// remark segments separated by '|', find segment starting with "itemcard:"
p := 0
for i := 0; i < len(remark); i++ {
if remark[i] == '|' {
seg := remark[p:i]
if len(seg) > 9 && seg[:9] == "itemcard:" {
var n int64
for j := 9; j < len(seg); j++ {
c := seg[j]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
if n > 0 {
return n
}
}
p = i + 1
}
}
// 检查最后一段
if p < len(remark) {
seg := remark[p:]
if len(seg) > 9 && seg[:9] == "itemcard:" {
var n int64
for j := 9; j < len(seg); j++ {
c := seg[j]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
if n > 0 {
return n
}
}
}
return 0
}