377 lines
13 KiB
Go
377 lines
13 KiB
Go
package activity
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"bindbox-game/configs"
|
||
"bindbox-game/internal/pkg/notify"
|
||
"bindbox-game/internal/pkg/util/remark"
|
||
"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"
|
||
)
|
||
|
||
// ProcessOrderLottery 处理订单开奖(统原子化高性能幂等逻辑)
|
||
func (s *service) ProcessOrderLottery(ctx context.Context, orderID int64) error {
|
||
s.logger.Info("开始原子化处理订单开奖", zap.Int64("order_id", orderID))
|
||
|
||
// 1. Redis 分布式锁:强制同一个订单串行处理,防止并发竞态引起的超发/漏发
|
||
lockKey := fmt.Sprintf("lock:lottery:order:%d", orderID)
|
||
locked, err := s.redis.SetNX(ctx, lockKey, "1", 120*time.Second).Result()
|
||
if err != nil {
|
||
return fmt.Errorf("分布式锁获取异常: %w", err)
|
||
}
|
||
if !locked {
|
||
s.logger.Info("订单开奖锁已存在,跳过本次处理", zap.Int64("order_id", orderID))
|
||
return nil
|
||
}
|
||
defer s.redis.Del(ctx, lockKey)
|
||
|
||
// 2. 批量预加载快照:将后续循环所需的查询合并为一次性加载
|
||
order, err := s.readDB.Orders.WithContext(ctx).Where(s.readDB.Orders.ID.Eq(orderID)).First()
|
||
if err != nil || order == nil {
|
||
return fmt.Errorf("订单查询失败或不存在")
|
||
}
|
||
|
||
// 状态前校验:仅处理已支付且未取消的抽奖订单
|
||
// SourceType: 2=Common Lottery (WeChat/Points mixed), 4=Game Pass (Pure)
|
||
if order.Status != 2 || (order.SourceType != 2 && order.SourceType != 4) {
|
||
return nil
|
||
}
|
||
|
||
rmk := remark.Parse(order.Remark)
|
||
aid := rmk.ActivityID
|
||
iss := rmk.IssueID
|
||
dc := rmk.Count
|
||
icID := rmk.ItemCardID
|
||
if aid <= 0 || iss <= 0 {
|
||
return fmt.Errorf("订单备注关键信息缺失")
|
||
}
|
||
|
||
orderNo := order.OrderNo
|
||
userID := order.UserID
|
||
|
||
// 2.1 批量加载配置
|
||
act, _ := s.readDB.Activities.WithContext(ctx).Where(s.readDB.Activities.ID.Eq(aid)).First()
|
||
actName := "活动"
|
||
playType := "default"
|
||
if act != nil {
|
||
actName = act.Name
|
||
playType = act.PlayType
|
||
}
|
||
|
||
rewardSettings, _ := s.readDB.ActivityRewardSettings.WithContext(ctx).Where(s.readDB.ActivityRewardSettings.IssueID.Eq(iss)).Find()
|
||
rewardMap := make(map[int64]*model.ActivityRewardSettings)
|
||
productIDs := make([]int64, 0, len(rewardSettings))
|
||
for _, r := range rewardSettings {
|
||
rewardMap[r.ID] = r
|
||
if r.ProductID > 0 {
|
||
productIDs = append(productIDs, r.ProductID)
|
||
}
|
||
}
|
||
// 批量预加载商品名称
|
||
productNameMap := make(map[int64]string)
|
||
if len(productIDs) > 0 {
|
||
products, _ := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.In(productIDs...)).Find()
|
||
for _, p := range products {
|
||
productNameMap[p.ID] = p.Name
|
||
}
|
||
}
|
||
|
||
// 2.2 批量加载已有记录
|
||
existingLogs, _ := s.readDB.ActivityDrawLogs.WithContext(ctx).Where(s.readDB.ActivityDrawLogs.OrderID.Eq(orderID)).Order(s.readDB.ActivityDrawLogs.DrawIndex).Find()
|
||
logMap := make(map[int64]*model.ActivityDrawLogs)
|
||
for _, l := range existingLogs {
|
||
logMap[l.DrawIndex] = l
|
||
}
|
||
|
||
existingInventory, _ := s.readDB.UserInventory.WithContext(ctx).Where(s.readDB.UserInventory.OrderID.Eq(orderID)).Find()
|
||
invCountMap := make(map[int64]int64)
|
||
invTotalCount := int64(len(existingInventory)) // 总发放数量(用于无限赏模式检测)
|
||
for _, inv := range existingInventory {
|
||
invCountMap[inv.RewardID]++
|
||
}
|
||
|
||
// 2.3 策略准备
|
||
var sel strat.ActivityDrawStrategy
|
||
if act != nil && act.PlayType == "ichiban" {
|
||
sel = strat.NewIchiban(s.readDB, s.writeDB)
|
||
} else {
|
||
sel = strat.NewDefault(s.readDB, s.writeDB)
|
||
}
|
||
|
||
// 3. 核心循环:基于内存快照进行原子发放
|
||
// 开始开奖循环
|
||
for i := int64(0); i < dc; i++ {
|
||
log, exists := logMap[i]
|
||
if !exists {
|
||
// 选品:如果日志不存在,则根据策略选出一个奖品 ID
|
||
var rid int64
|
||
var proof map[string]any
|
||
var selErr error
|
||
|
||
if act != nil && act.PlayType == "ichiban" {
|
||
slot := rmk.GetSlotAtIndex(i)
|
||
if slot < 0 {
|
||
s.logger.Warn("一番赏格位无效,跳过", zap.Int64("draw_index", i), zap.Int64("slot", slot))
|
||
continue
|
||
}
|
||
rid, proof, selErr = sel.SelectItemBySlot(ctx, aid, iss, slot)
|
||
} else {
|
||
// 使用预加载的数据进行选品,避免每次循环都查询数据库
|
||
if defSel, ok := sel.(*strat.DefaultStrategy); ok {
|
||
rid, proof, selErr = defSel.SelectItemFromCache(rewardSettings, act.CommitmentSeedMaster, iss, order.UserID)
|
||
} else {
|
||
rid, proof, selErr = sel.SelectItem(ctx, aid, iss, order.UserID)
|
||
}
|
||
}
|
||
|
||
if selErr != nil || rid <= 0 {
|
||
s.logger.Error("选品失败", zap.Int64("order_id", orderID), zap.Int64("draw_index", i), zap.Int64("rid", rid), zap.Error(selErr))
|
||
continue
|
||
}
|
||
|
||
rw := rewardMap[rid]
|
||
if rw == nil {
|
||
s.logger.Warn("奖品配置不存在,跳过", zap.Int64("draw_index", i), zap.Int64("reward_id", rid))
|
||
continue
|
||
}
|
||
|
||
// 写入开奖日志 (依靠数据库 (order_id, draw_index) 唯一键保证绝对幂等)
|
||
log = &model.ActivityDrawLogs{
|
||
UserID: order.UserID, IssueID: iss, OrderID: order.ID,
|
||
RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1, DrawIndex: i,
|
||
}
|
||
if errLog := s.writeDB.ActivityDrawLogs.WithContext(ctx).Create(log); errLog != nil {
|
||
// 并发情况:回退并尝试复用已有记录
|
||
log, _ = s.readDB.ActivityDrawLogs.WithContext(ctx).Where(s.readDB.ActivityDrawLogs.OrderID.Eq(orderID), s.readDB.ActivityDrawLogs.DrawIndex.Eq(i)).First()
|
||
if log == nil {
|
||
s.logger.Error("创建开奖日志失败且无法复用", zap.Int64("draw_index", i), zap.Error(errLog))
|
||
continue
|
||
}
|
||
}
|
||
_ = strat.SaveDrawReceipt(ctx, s.writeDB, log.ID, iss, order.UserID, proof)
|
||
logMap[i] = log
|
||
}
|
||
}
|
||
|
||
// 4. 批量发放奖励(单事务处理,大幅提升性能)
|
||
// 收集所有需要发放的奖励项
|
||
var batchItems []usersvc.BatchRewardItem
|
||
var effectLogs []*model.ActivityDrawLogs // 需要处理道具卡效果的日志
|
||
|
||
// 无限赏模式下使用总数检测(因为inventory.RewardID=0)
|
||
// 如果已发放总数已达到开奖数量,说明已完成发放,跳过后续逻辑
|
||
if invTotalCount >= dc {
|
||
s.logger.Info("奖励已全部发放,跳过重复发放", zap.Int64("order_id", orderID), zap.Int64("dc", dc), zap.Int64("invTotalCount", invTotalCount))
|
||
} else {
|
||
for i := int64(0); i < dc; i++ {
|
||
log, ok := logMap[i]
|
||
if !ok || log == nil {
|
||
continue
|
||
}
|
||
|
||
// 统计该 RewardID 应得数量
|
||
needed := int64(0)
|
||
for j := int64(0); j <= i; j++ {
|
||
if l, ok := logMap[j]; ok && l.RewardID == log.RewardID {
|
||
needed++
|
||
}
|
||
}
|
||
|
||
// 检查是否需要发放
|
||
if invCountMap[log.RewardID] < needed {
|
||
rw := rewardMap[log.RewardID]
|
||
if rw != nil {
|
||
var rewardIDRef *int64
|
||
if act != nil && act.PlayType == "ichiban" {
|
||
rewardIDRef = &log.RewardID
|
||
}
|
||
batchItems = append(batchItems, usersvc.BatchRewardItem{
|
||
ProductID: rw.ProductID,
|
||
RewardID: rewardIDRef,
|
||
ActivityID: aid,
|
||
Remark: productNameMap[rw.ProductID],
|
||
})
|
||
invCountMap[log.RewardID]++ // 内存计数同步
|
||
|
||
// 记录需要处理道具卡效果的日志
|
||
if act != nil && act.AllowItemCards && icID > 0 {
|
||
effectLogs = append(effectLogs, log)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 批量发放奖励(单个事务)
|
||
if len(batchItems) > 0 {
|
||
_, errGrant := s.user.BatchGrantRewardsToOrder(ctx, order.UserID, order.ID, batchItems)
|
||
if errGrant != nil {
|
||
s.logger.Error("批量发放奖励失败", zap.Int64("order_id", orderID), zap.Int("count", len(batchItems)), zap.Error(errGrant))
|
||
} else {
|
||
// 处理道具卡效果 - 只对第一发生效(翻倍卡只翻倍一次)
|
||
if len(effectLogs) > 0 {
|
||
s.applyItemCardEffect(ctx, icID, aid, iss, effectLogs[0])
|
||
}
|
||
}
|
||
}
|
||
|
||
s.logger.Info("开奖完成", zap.Int64("order_id", orderID), zap.Int("completed", len(logMap)))
|
||
|
||
// 5. 异步触发外部同步逻辑 (微信虚拟发货/通知)
|
||
if order.IsConsumed == 0 {
|
||
go s.TriggerVirtualShipping(context.Background(), orderID, orderNo, userID, aid, actName, playType)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// TriggerVirtualShipping 触发虚拟发货同步到微信
|
||
func (s *service) TriggerVirtualShipping(ctx context.Context, orderID int64, orderNo string, userID int64, aid int64, actName string, playType string) {
|
||
drawLogs, _ := s.readDB.ActivityDrawLogs.WithContext(ctx).Where(s.readDB.ActivityDrawLogs.OrderID.Eq(orderID)).Find()
|
||
if len(drawLogs) == 0 {
|
||
return
|
||
}
|
||
// 批量获取 reward 信息
|
||
rewardIDs := make([]int64, 0, len(drawLogs))
|
||
for _, lg := range drawLogs {
|
||
rewardIDs = append(rewardIDs, lg.RewardID)
|
||
}
|
||
rewards, _ := s.readDB.ActivityRewardSettings.WithContext(ctx).Where(s.readDB.ActivityRewardSettings.ID.In(rewardIDs...)).Find()
|
||
rewardMap := make(map[int64]*model.ActivityRewardSettings)
|
||
productIDs := make([]int64, 0, len(rewards))
|
||
for _, r := range rewards {
|
||
rewardMap[r.ID] = r
|
||
if r.ProductID > 0 {
|
||
productIDs = append(productIDs, r.ProductID)
|
||
}
|
||
}
|
||
// 批量获取商品信息,通过 ProductID 关联查询名称
|
||
productMap := make(map[int64]*model.Products)
|
||
if len(productIDs) > 0 {
|
||
products, _ := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.In(productIDs...)).Find()
|
||
for _, p := range products {
|
||
productMap[p.ID] = p
|
||
}
|
||
}
|
||
// 构建奖品名称列表,使用商品名称
|
||
var rewardNames []string
|
||
for _, lg := range drawLogs {
|
||
if rw, ok := rewardMap[lg.RewardID]; ok && rw != nil {
|
||
name := ""
|
||
// 使用商品名称
|
||
if rw.ProductID > 0 {
|
||
if p, ok := productMap[rw.ProductID]; ok && p != nil && p.Name != "" {
|
||
name = p.Name
|
||
}
|
||
}
|
||
if name != "" {
|
||
rewardNames = append(rewardNames, name)
|
||
}
|
||
}
|
||
}
|
||
itemsDesc := actName + " " + orderNo + " 盲盒赏品: " + strings.Join(rewardNames, ", ")
|
||
itemsDesc = utf8SafeTruncate(itemsDesc, 110) // 微信限制 128 字节,我们保守一点截断到 110
|
||
tx, _ := s.readDB.PaymentTransactions.WithContext(ctx).Where(s.readDB.PaymentTransactions.OrderNo.Eq(orderNo)).First()
|
||
if tx == nil || tx.TransactionID == "" {
|
||
return
|
||
}
|
||
u, _ := s.readDB.Users.WithContext(ctx).Where(s.readDB.Users.ID.Eq(userID)).First()
|
||
payerOpenid := ""
|
||
if u != nil {
|
||
payerOpenid = u.Openid
|
||
}
|
||
c := configs.Get()
|
||
cfg := &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret}
|
||
errUpload := wechat.UploadVirtualShippingForBackground(ctx, cfg, tx.TransactionID, orderNo, payerOpenid, itemsDesc)
|
||
|
||
// 如果发货成功,或者微信提示已经发过货了(10060003),则标记本地订单为已履约
|
||
if errUpload == nil || strings.Contains(errUpload.Error(), "10060003") {
|
||
_, _ = s.writeDB.Orders.WithContext(ctx).Where(s.readDB.Orders.ID.Eq(orderID)).Update(s.readDB.Orders.IsConsumed, 1)
|
||
if errUpload != nil {
|
||
s.logger.Info("[虚拟发货] 微信反馈已处理,更新本地标记", zap.String("order_no", orderNo))
|
||
}
|
||
} else if errUpload != nil {
|
||
s.logger.Error("[虚拟发货] 上传失败", zap.Error(errUpload), zap.String("order_no", orderNo))
|
||
}
|
||
|
||
if playType == "ichiban" {
|
||
notifyCfg := ¬ify.WechatNotifyConfig{
|
||
AppID: c.Wechat.AppID,
|
||
AppSecret: c.Wechat.AppSecret,
|
||
LotteryResultTemplateID: c.Wechat.LotteryResultTemplateID,
|
||
}
|
||
_ = notify.SendLotteryResultNotification(ctx, notifyCfg, payerOpenid, actName, rewardNames, orderNo, time.Now())
|
||
}
|
||
}
|
||
|
||
func (s *service) applyItemCardEffect(ctx context.Context, icID int64, aid int64, iss int64, log *model.ActivityDrawLogs) {
|
||
uic, _ := s.readDB.UserItemCards.WithContext(ctx).Where(s.readDB.UserItemCards.ID.Eq(icID), s.readDB.UserItemCards.UserID.Eq(log.UserID), s.readDB.UserItemCards.Status.Eq(1)).First()
|
||
if uic == nil {
|
||
return
|
||
}
|
||
ic, _ := s.readDB.SystemItemCards.WithContext(ctx).Where(s.readDB.SystemItemCards.ID.Eq(uic.CardID), s.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)
|
||
if scopeOK {
|
||
if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 {
|
||
rw, _ := s.readDB.ActivityRewardSettings.WithContext(ctx).Where(s.readDB.ActivityRewardSettings.ID.Eq(log.RewardID)).First()
|
||
if rw != nil {
|
||
// 获取商品名称
|
||
prodName := ""
|
||
if prod, _ := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.Eq(rw.ProductID)).First(); prod != nil {
|
||
prodName = prod.Name
|
||
}
|
||
_, _ = s.user.GrantRewardToOrder(ctx, log.UserID, usersvc.GrantRewardToOrderRequest{OrderID: log.OrderID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &aid, RewardID: &log.RewardID, Remark: prodName + "(倍数)"})
|
||
}
|
||
}
|
||
_, _ = s.writeDB.UserItemCards.WithContext(ctx).Where(s.readDB.UserItemCards.ID.Eq(icID)).Updates(map[string]any{
|
||
"status": 2,
|
||
"used_draw_log_id": log.ID,
|
||
"used_activity_id": aid,
|
||
"used_issue_id": iss,
|
||
"used_at": now,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
func parseInt64(s string) int64 {
|
||
var n int64
|
||
for i := 0; i < len(s); i++ {
|
||
if s[i] < '0' || s[i] > '9' {
|
||
break
|
||
}
|
||
n = n*10 + int64(s[i]-'0')
|
||
}
|
||
return n
|
||
}
|
||
|
||
// utf8SafeTruncate 按字节长度截断,并过滤掉无效/损坏的 UTF-8 字符
|
||
func utf8SafeTruncate(s string, n int) string {
|
||
r := []rune(s)
|
||
var res []rune
|
||
var byteLen int
|
||
for _, val := range r {
|
||
// 过滤掉无效字符标识 \ufffd,防止微信 API 报错
|
||
if val == '\uFFFD' {
|
||
continue
|
||
}
|
||
l := len(string(val))
|
||
if byteLen+l > n {
|
||
break
|
||
}
|
||
res = append(res, val)
|
||
byteLen += l
|
||
}
|
||
return string(res)
|
||
}
|