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(¬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 { // 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(¬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 } 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(¬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()) 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 }