378 lines
12 KiB
Go
378 lines
12 KiB
Go
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 := ¬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 {
|
||
_, _ = 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
|
||
}
|