package pay import ( "context" "encoding/json" "fmt" "net/http" "strings" "time" "bindbox-game/configs" "bindbox-game/internal/code" "bindbox-game/internal/pkg/core" lotterynotify "bindbox-game/internal/pkg/notify" pay "bindbox-game/internal/pkg/pay" pkgutils "bindbox-game/internal/pkg/utils" "bindbox-game/internal/pkg/wechat" "bindbox-game/internal/repository/mysql/model" strat "bindbox-game/internal/service/activity/strategy" usersvc "bindbox-game/internal/service/user" "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 } tx := &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 := h.writeDB.PaymentTransactions.WithContext(ctx.RequestContext()).Create(tx); err != nil { ctx.AbortWithError(core.Error(http.StatusInternalServerError, 150006, err.Error())) return } // 记录事件 if notification != nil && notification.ID != "" { if existed == nil { _ = h.writeDB.PaymentNotifyEvents.WithContext(ctx.RequestContext()).Create(&model.PaymentNotifyEvents{ NotifyID: notification.ID, ResourceType: notification.ResourceType, EventType: notification.EventType, Summary: notification.Summary, Raw: rawStr, Processed: false, }) } } _, err = h.writeDB.Orders.WithContext(ctx.RequestContext()).Where(h.writeDB.Orders.OrderNo.Eq(*transaction.OutTradeNo), h.writeDB.Orders.Status.Eq(1)).Updates(map[string]any{ h.writeDB.Orders.Status.ColumnName().String(): 2, h.writeDB.Orders.PaidAt.ColumnName().String(): paidAt, }) if err != nil { ctx.AbortWithError(core.Error(http.StatusInternalServerError, 150005, err.Error())) return } // 触发任务中心逻辑 (如有效邀请检测) if err := h.task.OnOrderPaid(ctx.RequestContext(), order.UserID, order.ID); err != nil { h.logger.Error("TaskCenter OnOrderPaid failed", zap.Error(err)) } ord, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.OrderNo.Eq(*transaction.OutTradeNo)).First() // 支付成功后扣减优惠券余额(优先使用结构化明细表),如无明细再降级解析备注 if ord != nil { var ocnt int64 _ = h.repo.GetDbR().Raw("SELECT COUNT(*) FROM order_coupons WHERE order_id=?", ord.ID).Scan(&ocnt).Error if ocnt > 0 { _ = h.user.DeductCouponsForPaidOrder(ctx.RequestContext(), ord.UserID, ord.ID, paidAt) } // 从备注解析所有优惠券使用片段 |c:: remark := ord.Remark parts := strings.Split(remark, "|") for _, seg := range parts { if ocnt > 0 { break } // 已使用结构化明细,跳过备注解析 if strings.HasPrefix(seg, "c:") { // seg: c:: xs := strings.Split(seg, ":") if len(xs) == 3 { // 解析ID与金额 // 并根据券类型处理余额扣减或一次性核销 // xs[1] user_coupon_id, xs[2] applied_amount // 查找用户券与模板 var uc *model.UserCoupons uc, _ = h.readDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.UserCoupons.ID.Eq(parseInt64(xs[1])), h.readDB.UserCoupons.UserID.Eq(ord.UserID)).First() if uc != nil { // 幂等保护:已核销且已绑定本订单则跳过 if uc.Status == 2 && uc.UsedOrderID == ord.ID { continue } sc, _ := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.SystemCoupons.ID.Eq(uc.CouponID), h.readDB.SystemCoupons.Status.Eq(1)).First() applied := parseInt64(xs[2]) if sc != nil { if sc.DiscountType == 1 { // 金额券,扣减余额 // 读取余额 var bal int64 _ = h.repo.GetDbR().Raw("SELECT COALESCE(balance_amount,0) FROM user_coupons WHERE id=?", uc.ID).Scan(&bal).Error newBal := bal - applied if newBal < 0 { newBal = 0 } if newBal == 0 { _, _ = h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.UserCoupons.ID.Eq(uc.ID)).Updates(map[string]any{ "balance_amount": newBal, h.readDB.UserCoupons.Status.ColumnName().String(): 2, h.readDB.UserCoupons.UsedOrderID.ColumnName().String(): ord.ID, h.readDB.UserCoupons.UsedAt.ColumnName().String(): paidAt, }) } else { _, _ = h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.UserCoupons.ID.Eq(uc.ID)).Updates(map[string]any{ "balance_amount": newBal, h.readDB.UserCoupons.UsedOrderID.ColumnName().String(): ord.ID, h.readDB.UserCoupons.UsedAt.ColumnName().String(): paidAt, }) } } else { // 满减/折扣券,一次性核销 _, _ = h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.UserCoupons.ID.Eq(uc.ID)).Updates(map[string]any{ h.readDB.UserCoupons.Status.ColumnName().String(): 2, h.readDB.UserCoupons.UsedOrderID.ColumnName().String(): ord.ID, h.readDB.UserCoupons.UsedAt.ColumnName().String(): paidAt, }) } } } } } } } if ord != nil { func() { cfg, _ := h.readDB.SystemConfigs.WithContext(ctx.RequestContext()).Where(h.readDB.SystemConfigs.ConfigKey.Eq("points_reward_per_cent")).First() rate := int64(0) if cfg != nil { var r int64 _, _ = fmt.Sscanf(cfg.ConfigValue, "%d", &r) if r > 0 { rate = r } } if rate > 0 && ord.ActualAmount > 0 { reward := ord.ActualAmount * rate _ = h.user.AddPointsWithAction(ctx.RequestContext(), ord.UserID, reward, "pay_reward", ord.OrderNo, "pay_reward", nil, nil) } }() } if ord != nil && ord.SourceType == 2 { iss := parseIssueIDFromRemark(ord.Remark) aid := parseActivityIDFromRemark(ord.Remark) dc := parseCountFromRemark(ord.Remark) if dc <= 0 { dc = 1 } act, _ := h.readDB.Activities.WithContext(ctx.RequestContext()).Where(h.readDB.Activities.ID.Eq(aid)).First() // 【修复】一番赏玩法:无论 instant 还是 scheduled 模式,支付成功后都要先占用位置 if act != nil && act.PlayType == "ichiban" { idxs, cnts := parseSlotsCountsFromRemark(ord.Remark) fmt.Printf("[支付回调-一番赏占位] 解析格位 idxs=%v cnts=%v 订单备注=%s 模式=%s\n", idxs, cnts, ord.Remark, act.DrawMode) 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(ord.Remark) }() fmt.Printf("[支付回调-一番赏占位] 获取格位 slot=%d\n", slot) if slot < 0 { fmt.Printf("[支付回调-一番赏占位] ❌ 格位无效,跳出循环\n") break } var cnt int64 cnt, _ = h.readDB.IssuePositionClaims.WithContext(ctx.RequestContext()).Where(h.readDB.IssuePositionClaims.IssueID.Eq(iss), h.readDB.IssuePositionClaims.SlotIndex.Eq(slot)).Count() fmt.Printf("[支付回调-一番赏占位] 检查格位占用 issueID=%d slot=%d 已占用数=%d\n", iss, slot, cnt) if cnt > 0 { fmt.Printf("[支付回调-一番赏占位] ❌ 格位已被占用,跳出循环并退款\n") // 标记订单为退款状态并处理退款 wc, e := pay.NewWechatPayClient(ctx.RequestContext()) if e == nil { refundNo := fmt.Sprintf("R%s-%d", ord.OrderNo, time.Now().Unix()) refundID, status, e2 := wc.RefundOrder(ctx.RequestContext(), ord.OrderNo, refundNo, ord.ActualAmount, ord.ActualAmount, "slot_unavailable") if e2 == nil { _ = h.writeDB.PaymentRefunds.WithContext(ctx.RequestContext()).Create(&model.PaymentRefunds{OrderID: ord.ID, OrderNo: ord.OrderNo, RefundNo: refundNo, Channel: "wechat_jsapi", Status: status, AmountRefund: ord.ActualAmount, Reason: "slot_unavailable"}) _ = h.writeDB.UserPointsLedger.WithContext(ctx.RequestContext()).Create(&model.UserPointsLedger{UserID: ord.UserID, Action: "refund_amount", Points: ord.ActualAmount / 100, RefTable: "payment_refund", RefID: refundID}) _, _ = h.writeDB.Orders.WithContext(ctx.RequestContext()).Where(h.writeDB.Orders.ID.Eq(ord.ID)).Updates(map[string]any{h.writeDB.Orders.Status.ColumnName().String(): 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, "slot_unavailable", status).Error } } break } // 尝试创建占用记录,利用唯一索引防止并发 err := h.writeDB.IssuePositionClaims.WithContext(ctx.RequestContext()).Create(&model.IssuePositionClaims{IssueID: iss, SlotIndex: slot, UserID: ord.UserID, OrderID: ord.ID}) if err != nil { fmt.Printf("[支付回调-一番赏占位] ❌ 创建格位占用失败 err=%v\n", err) // 同样处理退款 wc, e := pay.NewWechatPayClient(ctx.RequestContext()) if e == nil { refundNo := fmt.Sprintf("R%s-%d", ord.OrderNo, time.Now().Unix()) refundID, status, e2 := wc.RefundOrder(ctx.RequestContext(), ord.OrderNo, refundNo, ord.ActualAmount, ord.ActualAmount, "slot_concurrent_conflict") if e2 == nil { _ = h.writeDB.PaymentRefunds.WithContext(ctx.RequestContext()).Create(&model.PaymentRefunds{OrderID: ord.ID, OrderNo: ord.OrderNo, RefundNo: refundNo, Channel: "wechat_jsapi", Status: status, AmountRefund: ord.ActualAmount, Reason: "slot_concurrent_conflict"}) _ = h.writeDB.UserPointsLedger.WithContext(ctx.RequestContext()).Create(&model.UserPointsLedger{UserID: ord.UserID, Action: "refund_amount", Points: ord.ActualAmount / 100, RefTable: "payment_refund", RefID: refundID}) _, _ = h.writeDB.Orders.WithContext(ctx.RequestContext()).Where(h.writeDB.Orders.ID.Eq(ord.ID)).Updates(map[string]any{h.writeDB.Orders.Status.ColumnName().String(): 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, "slot_concurrent_conflict", status).Error } } break } fmt.Printf("[支付回调-一番赏占位] ✅ 格位占用成功 issueID=%d slot=%d userID=%d orderID=%d\n", iss, slot, ord.UserID, ord.ID) } } // instant 模式才立即开奖发奖 if act != nil && act.DrawMode == "instant" { logs, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityDrawLogs.OrderID.Eq(ord.ID)).Find() done := int64(len(logs)) // 解析道具卡ID icID := parseItemCardIDFromRemark(ord.Remark) fmt.Printf("[支付回调-抽奖] 开始处理 活动ID=%d 期ID=%d 次数=%d 道具卡ID=%d 玩法=%s 已完成=%d\n", aid, iss, dc, icID, act.PlayType, done) if act.PlayType == "ichiban" { idxs, cnts := parseSlotsCountsFromRemark(ord.Remark) fmt.Printf("[支付回调-抽奖] 解析格位 idxs=%v cnts=%v 订单备注=%s\n", idxs, cnts, ord.Remark) rem := make([]int64, len(cnts)) copy(rem, cnts) cur := 0 for i := done; i < dc; i++ { fmt.Printf("[支付回调-抽奖] 循环开始 i=%d dc=%d\n", i, dc) 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(ord.Remark) }() fmt.Printf("[支付回调-抽奖] 获取格位 slot=%d\n", slot) if slot < 0 { fmt.Printf("[支付回调-抽奖] ❌ 格位无效,跳出循环\n") break } // 位置已在上面占用,这里直接选择奖品 // Use Commitment Seed (via SelectItemBySlot internal logic) rid, proof, e2 := strat.NewIchiban(h.readDB, h.writeDB).SelectItemBySlot(ctx.RequestContext(), aid, iss, slot) fmt.Printf("[支付回调-抽奖] SelectItemBySlot 结果 rid=%d err=%v\n", rid, e2) if e2 != nil || rid <= 0 { fmt.Printf("[支付回调-抽奖] ❌ SelectItemBySlot 失败,跳出循环\n") break } rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First() if rw == nil { fmt.Printf("[支付回调-抽奖] ❌ 奖品设置为空,跳出循环\n") break } fmt.Printf("[支付回调-抽奖] 发放奖品 rid=%d 奖品名=%s\n", rid, rw.Name) // 【先记录日志,再发奖】确保日志创建成功后再发奖,防止重复 log := &model.ActivityDrawLogs{UserID: ord.UserID, IssueID: iss, OrderID: ord.ID, RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1} if errLog := h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(log); errLog != nil { fmt.Printf("[支付回调-抽奖] ❌ 创建开奖日志失败 err=%v,可能已存在,跳过\n", errLog) continue } // 保存抽奖凭据(种子数据)供用户验证 _ = strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, log.ID, iss, ord.UserID, proof) _ = strat.NewIchiban(h.readDB, h.writeDB).GrantReward(ctx.RequestContext(), ord.UserID, rid) // 道具卡效果处理 fmt.Printf("[支付回调-道具卡] 开始检查 活动允许道具卡=%t 道具卡ID=%d\n", act.AllowItemCards, icID) if act.AllowItemCards && icID > 0 { uic, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.UserItemCards.ID.Eq(icID), h.readDB.UserItemCards.UserID.Eq(ord.UserID), h.readDB.UserItemCards.Status.Eq(1)).First() if uic != nil { fmt.Printf("[支付回调-道具卡] 找到用户道具卡 ID=%d CardID=%d Status=%d\n", uic.ID, uic.CardID, uic.Status) ic, _ := h.readDB.SystemItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.SystemItemCards.ID.Eq(uic.CardID), h.readDB.SystemItemCards.Status.Eq(1)).First() now := time.Now() if ic != nil && !uic.ValidStart.After(now) && !uic.ValidEnd.Before(now) { scopeOK := (ic.ScopeType == 1) || (ic.ScopeType == 3 && ic.ActivityID == aid) || (ic.ScopeType == 4 && ic.IssueID == iss) fmt.Printf("[支付回调-道具卡] 系统道具卡 EffectType=%d RewardMultiplierX1000=%d ScopeType=%d scopeOK=%t\n", ic.EffectType, ic.RewardMultiplierX1000, ic.ScopeType, scopeOK) if scopeOK { if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 { fmt.Printf("[支付回调-道具卡] ✅ 应用双倍奖励 奖品ID=%d 奖品名=%s\n", rid, rw.Name) _ = strat.NewIchiban(h.readDB, h.writeDB).GrantReward(ctx.RequestContext(), ord.UserID, rid) } // 核销道具卡 fmt.Printf("[支付回调-道具卡] 核销道具卡 用户道具卡ID=%d\n", icID) _, _ = h.writeDB.UserItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.UserItemCards.ID.Eq(icID), h.readDB.UserItemCards.UserID.Eq(ord.UserID), h.readDB.UserItemCards.Status.Eq(1)).Updates(map[string]any{ h.readDB.UserItemCards.Status.ColumnName().String(): 2, h.readDB.UserItemCards.UsedDrawLogID.ColumnName().String(): log.ID, h.readDB.UserItemCards.UsedActivityID.ColumnName().String(): aid, h.readDB.UserItemCards.UsedIssueID.ColumnName().String(): iss, h.readDB.UserItemCards.UsedAt.ColumnName().String(): now, }) } } } else { fmt.Printf("[支付回调-道具卡] ❌ 未找到用户道具卡 用户ID=%d 道具卡ID=%d\n", ord.UserID, icID) } } } } else { sel := strat.NewDefault(h.readDB, h.writeDB) for i := done; i < dc; i++ { rid, proof, e2 := sel.SelectItem(ctx.RequestContext(), aid, iss, ord.UserID) if e2 != nil || rid <= 0 { break } rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First() if rw == nil { break } // 【先记录日志,再发奖】确保日志创建成功后再发奖,防止重复 log := &model.ActivityDrawLogs{UserID: ord.UserID, IssueID: iss, OrderID: ord.ID, RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1} if errLog := h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(log); errLog != nil { fmt.Printf("[支付回调-默认玩法] ❌ 创建开奖日志失败 err=%v,可能已存在,跳过\n", errLog) break } // 保存抽奖凭据(种子数据)供用户验证 _ = strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, log.ID, iss, ord.UserID, proof) _, errGrant := h.user.GrantRewardToOrder(ctx.RequestContext(), ord.UserID, usersvc.GrantRewardToOrderRequest{OrderID: ord.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &aid, RewardID: &rid, Remark: rw.Name}) if errGrant != nil { fmt.Printf("[支付回调-默认玩法] ❌ 发奖失败 err=%v,执行退款\n", errGrant) // 发奖失败,执行退款 wc, e := pay.NewWechatPayClient(ctx.RequestContext()) if e == nil { refundNo := fmt.Sprintf("R%s-%d", ord.OrderNo, time.Now().Unix()) refundID, status, e2 := wc.RefundOrder(ctx.RequestContext(), ord.OrderNo, refundNo, ord.ActualAmount, ord.ActualAmount, "grant_reward_failed") if e2 == nil { _ = h.writeDB.PaymentRefunds.WithContext(ctx.RequestContext()).Create(&model.PaymentRefunds{OrderID: ord.ID, OrderNo: ord.OrderNo, RefundNo: refundNo, Channel: "wechat_jsapi", Status: status, AmountRefund: ord.ActualAmount, Reason: "grant_reward_failed"}) _ = h.writeDB.UserPointsLedger.WithContext(ctx.RequestContext()).Create(&model.UserPointsLedger{UserID: ord.UserID, Action: "refund_amount", Points: ord.ActualAmount / 100, RefTable: "payment_refund", RefID: refundID}) _, _ = h.writeDB.Orders.WithContext(ctx.RequestContext()).Where(h.writeDB.Orders.ID.Eq(ord.ID)).Updates(map[string]any{h.writeDB.Orders.Status.ColumnName().String(): 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, "grant_reward_failed", status).Error // 标记开奖日志为无效或失败(可选,视业务需求而定,这里暂不删除日志以便追溯) } } break } // 道具卡效果处理 icID := parseItemCardIDFromRemark(ord.Remark) fmt.Printf("[支付回调-道具卡] 开始检查 活动允许道具卡=%t 道具卡ID=%d\n", act.AllowItemCards, icID) if act.AllowItemCards && icID > 0 { uic, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.UserItemCards.ID.Eq(icID), h.readDB.UserItemCards.UserID.Eq(ord.UserID), h.readDB.UserItemCards.Status.Eq(1)).First() if uic != nil { fmt.Printf("[支付回调-道具卡] 找到用户道具卡 ID=%d CardID=%d Status=%d\n", uic.ID, uic.CardID, uic.Status) ic, _ := h.readDB.SystemItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.SystemItemCards.ID.Eq(uic.CardID), h.readDB.SystemItemCards.Status.Eq(1)).First() now := time.Now() if ic != nil && !uic.ValidStart.After(now) && !uic.ValidEnd.Before(now) { scopeOK := (ic.ScopeType == 1) || (ic.ScopeType == 3 && ic.ActivityID == aid) || (ic.ScopeType == 4 && ic.IssueID == iss) fmt.Printf("[支付回调-道具卡] 系统道具卡 EffectType=%d RewardMultiplierX1000=%d ScopeType=%d scopeOK=%t\n", ic.EffectType, ic.RewardMultiplierX1000, ic.ScopeType, scopeOK) if scopeOK { if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 { fmt.Printf("[支付回调-道具卡] ✅ 应用双倍奖励 奖品ID=%d 奖品名=%s\n", rid, rw.Name) _, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), ord.UserID, usersvc.GrantRewardToOrderRequest{OrderID: ord.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &aid, RewardID: &rid, Remark: rw.Name + "(倍数)"}) } // 核销道具卡 fmt.Printf("[支付回调-道具卡] 核销道具卡 用户道具卡ID=%d\n", icID) _, _ = h.writeDB.UserItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.UserItemCards.ID.Eq(icID), h.readDB.UserItemCards.UserID.Eq(ord.UserID), h.readDB.UserItemCards.Status.Eq(1)).Updates(map[string]any{ h.readDB.UserItemCards.Status.ColumnName().String(): 2, h.readDB.UserItemCards.UsedDrawLogID.ColumnName().String(): log.ID, h.readDB.UserItemCards.UsedActivityID.ColumnName().String(): aid, h.readDB.UserItemCards.UsedIssueID.ColumnName().String(): iss, h.readDB.UserItemCards.UsedAt.ColumnName().String(): now, }) } } } else { fmt.Printf("[支付回调-道具卡] ❌ 未找到用户道具卡 用户ID=%d 道具卡ID=%d\n", ord.UserID, icID) } } } } // 【开奖后虚拟发货】即时开奖完成后上传虚拟发货 go func(orderID int64, orderNo string, userID int64, actName string, playType string) { bgCtx := context.Background() drawLogs, _ := h.readDB.ActivityDrawLogs.WithContext(bgCtx).Where(h.readDB.ActivityDrawLogs.OrderID.Eq(orderID)).Find() if len(drawLogs) == 0 { fmt.Printf("[即时开奖-虚拟发货] 没有开奖记录,跳过 order_id=%d\n", orderID) return } // 收集赏品名称 var rewardNames []string for _, lg := range drawLogs { if rw, _ := h.readDB.ActivityRewardSettings.WithContext(bgCtx).Where(h.readDB.ActivityRewardSettings.ID.Eq(lg.RewardID)).First(); rw != nil { rewardNames = append(rewardNames, rw.Name) } } itemsDesc := actName + " " + orderNo + " 盲盒赏品: " + strings.Join(rewardNames, ", ") itemsDesc = pkgutils.TruncateBytes(itemsDesc, 120) // 获取支付交易信息 var tx *model.PaymentTransactions tx, _ = h.readDB.PaymentTransactions.WithContext(bgCtx).Where(h.readDB.PaymentTransactions.OrderNo.Eq(orderNo)).First() if tx == nil || tx.TransactionID == "" { fmt.Printf("[即时开奖-虚拟发货] 没有支付交易记录,跳过 order_no=%s\n", orderNo) return } // 获取用户openid var u *model.Users u, _ = h.readDB.Users.WithContext(bgCtx).Where(h.readDB.Users.ID.Eq(userID)).First() payerOpenid := "" if u != nil { payerOpenid = u.Openid } fmt.Printf("[即时开奖-虚拟发货] 上传 order_no=%s transaction_id=%s items_desc=%s\n", orderNo, tx.TransactionID, itemsDesc) if err := wechat.UploadVirtualShippingForBackground(bgCtx, &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret}, tx.TransactionID, orderNo, payerOpenid, itemsDesc); err != nil { fmt.Printf("[即时开奖-虚拟发货] 上传失败: %v\n", err) } // 【开奖后推送通知】只有一番赏才发送 if playType == "ichiban" { notifyCfg := &lotterynotify.WechatNotifyConfig{ AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret, LotteryResultTemplateID: c.Wechat.LotteryResultTemplateID, } _ = lotterynotify.SendLotteryResultNotification(bgCtx, notifyCfg, payerOpenid, actName, rewardNames, orderNo, time.Now()) } }(ord.ID, ord.OrderNo, ord.UserID, act.Name, act.PlayType) } } if ord != nil { var itemsDesc string if xs, _ := h.readDB.OrderItems.WithContext(ctx.RequestContext()).Where(h.readDB.OrderItems.OrderID.Eq(ord.ID)).Find(); len(xs) > 0 { var parts []string for _, it := range xs { parts = append(parts, it.Title+"*"+func(q int64) string { return fmt.Sprintf("%d", q) }(it.Quantity)) } s := strings.Join(parts, ", ") itemsDesc = pkgutils.TruncateRunes(s, 120) } else { itemsDesc = "订单" + ord.OrderNo } payerOpenid := "" if transaction.Payer != nil && transaction.Payer.Openid != nil { payerOpenid = *transaction.Payer.Openid } // 抽奖订单(2)和对对碰订单(3)在开奖/结算后发货,非此类订单在支付后立即发货 if ord.SourceType != 2 && ord.SourceType != 3 { if transaction.TransactionId != nil && *transaction.TransactionId != "" { fmt.Printf("[支付回调] 虚拟发货 尝试上传 order_id=%d order_no=%s user_id=%d transaction_id=%s items_desc=%s payer_openid=%s\n", ord.ID, ord.OrderNo, ord.UserID, *transaction.TransactionId, itemsDesc, payerOpenid) if err := wechat.UploadVirtualShippingWithFallback(ctx, &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret}, *transaction.TransactionId, ord.OrderNo, payerOpenid, itemsDesc, time.Now()); err != nil { fmt.Printf("[支付回调] 虚拟发货上传失败: %v\n", err) } } } else { fmt.Printf("[支付回调] 抽奖/对对碰订单跳过虚拟发货,将在开奖/结算后发货 order_id=%d order_no=%s source_type=%d\n", ord.ID, ord.OrderNo, ord.SourceType) } } // 标记事件处理完成 if notification != nil && notification.ID != "" { _, _ = h.writeDB.PaymentNotifyEvents.WithContext(ctx.RequestContext()).Where(h.readDB.PaymentNotifyEvents.NotifyID.Eq(notification.ID)).Updates(map[string]any{ h.writeDB.PaymentNotifyEvents.Processed.ColumnName().String(): true, }) } ctx.Payload(¬ifyAck{Code: "SUCCESS", Message: "OK"}) } } 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 }