bindbox-game/internal/api/pay/wechat_notify.go
邹方成 e2782a69d3 feat: 添加对对碰游戏功能与Redis支持
refactor: 重构抽奖逻辑以支持可验证凭据
feat(redis): 集成Redis客户端并添加配置支持
fix: 修复订单取消时的优惠券和库存处理逻辑
docs: 添加对对碰游戏前端对接指南和示例JSON
test: 添加对对碰游戏模拟测试和验证逻辑
2025-12-21 17:31:32 +08:00

740 lines
32 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"
lotterynotify "bindbox-game/internal/pkg/notify"
pay "bindbox-game/internal/pkg/pay"
"bindbox-game/internal/pkg/wechat"
"bindbox-game/internal/repository/mysql/model"
strat "bindbox-game/internal/service/activity/strategy"
usersvc "bindbox-game/internal/service/user"
"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
}
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
}
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:<id>:<applied>
remark := ord.Remark
parts := strings.Split(remark, "|")
for _, seg := range parts {
if ocnt > 0 {
break
} // 已使用结构化明细,跳过备注解析
if strings.HasPrefix(seg, "c:") {
// seg: c:<id>:<amount>
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
}
// 位置已在上面占用,这里直接选择奖品
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) {
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, ", ")
if len(itemsDesc) > 120 {
itemsDesc = 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)
}
// 【开奖后推送通知】
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)
}
}
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, ", ")
if len(s) > 120 {
s = s[:120]
}
itemsDesc = s
} else {
itemsDesc = "订单" + ord.OrderNo
}
payerOpenid := ""
if transaction.Payer != nil && transaction.Payer.Openid != nil {
payerOpenid = *transaction.Payer.Openid
}
// 抽奖订单在开奖后发货,非抽奖订单在支付后立即发货
if ord.SourceType != 2 {
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\n", ord.ID, ord.OrderNo)
}
}
// 标记事件处理完成
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(&notifyAck{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
}