refactor: 重构抽奖逻辑以支持可验证凭据 feat(redis): 集成Redis客户端并添加配置支持 fix: 修复订单取消时的优惠券和库存处理逻辑 docs: 添加对对碰游戏前端对接指南和示例JSON test: 添加对对碰游戏模拟测试和验证逻辑
740 lines
32 KiB
Go
740 lines
32 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"
|
||
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(¬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
|
||
}
|
||
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(¬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
|
||
}
|