bindbox-game/internal/service/activity/lottery_process.go

378 lines
12 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 activity
import (
"context"
"fmt"
"strings"
"time"
"bindbox-game/configs"
"bindbox-game/internal/pkg/notify"
"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", 30*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("订单查询失败或不存在")
}
// 状态前校验:仅处理已支付且未取消的抽奖订单
if order.Status != 2 || order.SourceType != 2 {
return nil
}
aid := extractActivityID(order.Remark)
iss := extractIssueID(order.Remark)
dc := extractCount(order.Remark)
icID := extractItemCardID(order.Remark)
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)
for _, r := range rewardSettings {
rewardMap[r.ID] = r
}
// 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)
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)
}
idxs, cnts := parseSlotsCountsFromRemark(order.Remark)
// 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 := getSlotForIndex(i, idxs, cnts)
if slot < 0 {
continue
}
rid, proof, selErr = sel.SelectItemBySlot(ctx, aid, iss, slot)
} else {
rid, proof, selErr = sel.SelectItem(ctx, aid, iss, order.UserID)
}
if selErr != nil || rid <= 0 {
s.logger.Error("选品失败", zap.Int64("draw_index", i), zap.Error(selErr))
continue
}
rw := rewardMap[rid]
if rw == nil {
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 {
continue
}
}
_ = strat.SaveDrawReceipt(ctx, s.writeDB, log.ID, iss, order.UserID, proof)
logMap[i] = log
}
// 补发奖励校验:统计该 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 {
_, errGrant := s.user.GrantRewardToOrder(ctx, order.UserID, usersvc.GrantRewardToOrderRequest{
OrderID: order.ID, ProductID: rw.ProductID, Quantity: 1,
ActivityID: &aid, RewardID: &log.RewardID, Remark: rw.Name,
})
if errGrant == nil {
invCountMap[log.RewardID]++ // 内存计数同步
// 处理道具卡
if act != nil && act.AllowItemCards && icID > 0 {
s.applyItemCardEffect(ctx, icID, aid, iss, log)
}
} else {
s.logger.Error("奖励原子发放失败", zap.Int64("draw_index", i), zap.Error(errGrant))
}
}
}
}
// 4. 异步触发外部同步逻辑 (微信虚拟发货/通知)
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
}
var rewardNames []string
for _, lg := range drawLogs {
if rw, _ := s.readDB.ActivityRewardSettings.WithContext(ctx).Where(s.readDB.ActivityRewardSettings.ID.Eq(lg.RewardID)).First(); rw != nil {
rewardNames = append(rewardNames, rw.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 := &notify.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 {
_, _ = s.user.GrantRewardToOrder(ctx, log.UserID, usersvc.GrantRewardToOrderRequest{OrderID: log.OrderID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &aid, RewardID: &log.RewardID, Remark: rw.Name + "(倍数)"})
}
}
_, _ = 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 extractActivityID(remark string) int64 { return parseActivityIDFromRemark(remark) }
func extractIssueID(remark string) int64 { return parseIssueIDFromRemark(remark) }
func extractCount(remark string) int64 { return parseCountFromRemark(remark) }
func extractItemCardID(remark string) int64 { return parseItemCardIDFromRemark(remark) }
func parseActivityIDFromRemark(remark string) int64 {
parts := strings.Split(remark, "|")
for _, p := range parts {
if strings.HasPrefix(p, "lottery:activity:") {
return parseInt64(p[17:])
}
if strings.HasPrefix(p, "activity:") {
return parseInt64(p[9:])
}
}
return 0
}
func parseIssueIDFromRemark(remark string) int64 {
parts := strings.Split(remark, "|")
for _, p := range parts {
if strings.HasPrefix(p, "issue:") {
return parseInt64(p[6:])
}
}
return 0
}
func parseCountFromRemark(remark string) int64 {
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 parseItemCardIDFromRemark(remark string) int64 {
parts := strings.Split(remark, "|")
for _, seg := range parts {
if len(seg) > 9 && seg[:9] == "itemcard:" {
return parseInt64(seg[9:])
}
}
return 0
}
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)
}
func parseSlotsCountsFromRemark(remark string) ([]int64, []int64) {
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 getSlotForIndex(drawIndex int64, idxs []int64, cnts []int64) int64 {
var current int64
for i := 0; i < len(idxs); i++ {
if drawIndex < current+cnts[i] {
return idxs[i] - 1
}
current += cnts[i]
}
return -1
}