package yumigiftchallenge import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "gorm.io/gorm" "gorm.io/gorm/clause" ) var errMalformedGiftPayload = errors.New("malformed yumi gift challenge payload") // ProcessGiftPayload 解析 RocketMQ 原始消息并复用活动入账逻辑。 func (s *Service) ProcessGiftPayload(ctx context.Context, payload string) error { payload = strings.TrimSpace(payload) if payload == "" { return nil } event, err := decodeGiftEvent(payload, s.cfg.YumiGiftChallenge.RocketMQ.Tag) if err != nil { return fmt.Errorf("%w: %v", errMalformedGiftPayload, err) } return s.ProcessGiftEvent(ctx, event) } // ProcessGiftEvent 只接受普通 GOLD 非背包礼物;幸运礼物和 MAGIC 在金额判断前强制排除。 func (s *Service) ProcessGiftEvent(ctx context.Context, event giftEvent) error { trackID := strings.TrimSpace(event.TrackID.String()) userID := event.SendUserID.Int64() giftID := event.GiftConfig.ID.Int64() if trackID == "" || userID <= 0 || giftID <= 0 || event.CreateTime.Int64() <= 0 { return nil } origin := strings.ToUpper(strings.TrimSpace(event.SysOrigin)) if origin == "" || origin != defaultSysOrigin { return nil } giftType := strings.ToUpper(strings.TrimSpace(event.GiftConfig.Type)) giftTab := strings.ToUpper(strings.TrimSpace(event.GiftConfig.GiftTab)) acceptCount, acceptsValid := countDistinctAcceptUsers(event.Accepts) quantity, giftCandy := event.Quantity.Int64(), event.GiftConfig.GiftCandy // give_gift_v3 不保证存在可直接信任的 actualAmount;礼物主流水总消耗口径是 // 单价 × 数量 × 去重有效收礼人数。缺失收礼人必须忽略,不能默认按 1 人计分。 if event.BagGift == nil || *event.BagGift || (event.GiftValue.Bag != nil && *event.GiftValue.Bag) || strings.TrimSpace(giftTab) == "" || !acceptsValid || quantity <= 0 || !giftCandy.Positive() { return nil } amount, err := giftCandy.MultiplyInt(quantity) if err != nil { return nil } amount, err = amount.MultiplyInt(int64(acceptCount)) if err != nil || !amount.Positive() { return nil } if giftType != "GOLD" || giftTab == "LUCKY_GIFT" || giftTab == "MAGIC" { return nil } eventTime := time.UnixMilli(event.CreateTime.Int64()) var activity model.YumiGiftChallengeActivity err = s.db.WithContext(ctx).Where( "sys_origin = ? AND enabled = ? AND start_time <= ? AND end_time > ?", origin, true, eventTime, eventTime, ).Order("start_time DESC").First(&activity).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil } if err != nil { return err } location, err := resolveLocation(activity.Timezone) if err != nil { return err } statDate := dateOnly(eventTime, location) periodKey := dateKey(statDate, location) return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 热链路只持有共享门闩锁,同一活动的送礼事件可以并发;结算使用排他锁, // 会等待已经进入事务的事件提交,再冻结确定的边界。 var dailyHeader model.YumiGiftChallengePeriodSettlement if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where( "activity_id = ? AND period_type = ? AND period_key = ?", activity.ID, PeriodDaily, periodKey, ).First(&dailyHeader).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil } return err } var overallHeader model.YumiGiftChallengePeriodSettlement if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where( "activity_id = ? AND period_type = ? AND period_key = ?", activity.ID, PeriodOverall, PeriodOverall, ).First(&overallHeader).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil } return err } // 总榜门闩关闭后整笔拒绝;日榜门闩关闭只阻止日榜和当日任务,合法迟到礼物仍计入尚未冻结的总榜。 if overallHeader.Status != StatusNotStarted { return nil } ledgerID, err := utils.NextID() if err != nil { return err } ledger := model.YumiGiftChallengeGiftLedger{ ID: ledgerID, ActivityID: activity.ID, SourceEventTrackID: trackID, UserID: userID, GiftID: giftID, GiftTab: giftTab, Amount: amount, EventTime: eventTime, StatDate: periodKey, CreateTime: time.Now(), } if err := tx.Create(&ledger).Error; err != nil { if isDuplicateKey(err) { return nil } return err } now := time.Now() // score_reached_time 取已消费事件时间的最大值;乱序迟到事件不能把并列分数的到达时间倒拨, // 从而保证 score DESC、reached_time ASC、user_id ASC 的排序稳定。 if err := tx.Exec(` INSERT INTO yumi_gift_challenge_user_score (activity_id, user_id, score, score_reached_time, create_time, update_time) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE score = score + VALUES(score), score_reached_time = GREATEST(score_reached_time, VALUES(score_reached_time)), update_time = VALUES(update_time)`, activity.ID, userID, amount, eventTime, now, now).Error; err != nil { return err } if dailyHeader.Status == StatusNotStarted { if err := tx.Exec(` INSERT INTO yumi_gift_challenge_user_daily_score (activity_id, stat_date, user_id, score, score_reached_time, create_time, update_time) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE score = score + VALUES(score), score_reached_time = GREATEST(score_reached_time, VALUES(score_reached_time)), update_time = VALUES(update_time)`, activity.ID, periodKey, userID, amount, eventTime, now, now).Error; err != nil { return err } if err := s.ensureTaskSnapshotsTx(tx, activity, periodKey, userID); err != nil { return err } // 每个事件只执行一次本更新,因为 ledger 唯一键已在同一事务内成功占位。 if err := tx.Exec(` UPDATE yumi_gift_challenge_user_task_daily SET progress_value = LEAST(target_value, progress_value + ?), completed_time = CASE WHEN completed_time IS NULL AND progress_value + ? >= target_value THEN ? ELSE completed_time END, update_time = ? WHERE activity_id = ? AND stat_date = ? AND user_id = ? AND task_type = ?`, amount, amount, eventTime, now, activity.ID, periodKey, userID, TaskSendGiftGold).Error; err != nil { return err } } return nil }) } func countDistinctAcceptUsers(users []*giftEventUser) (int, bool) { if len(users) == 0 { return 0, false } seen := make(map[int64]struct{}, len(users)) for _, user := range users { if user == nil { return 0, false } id := user.AcceptUserID.Int64() if id <= 0 { // 任一收礼元素不完整都说明事件口径不可信,整条拒绝而不是静默少算人数。 return 0, false } seen[id] = struct{}{} } return len(seen), len(seen) > 0 } func decodeGiftEvent(payload, expectedTag string) (giftEvent, error) { var event giftEvent if err := json.Unmarshal([]byte(payload), &event); err != nil { return event, err } if event.TrackID.String() != "" || event.SendUserID.Int64() > 0 || event.GiftConfig.ID.Int64() > 0 { return event, nil } var envelope messageEnvelope if err := json.Unmarshal([]byte(payload), &envelope); err != nil { return event, err } if tag := strings.TrimSpace(envelope.Tag); tag != "" && strings.TrimSpace(expectedTag) != "" && tag != strings.TrimSpace(expectedTag) { return giftEvent{}, nil } body := strings.TrimSpace(string(envelope.Body)) if body == "" || body == "null" { return giftEvent{}, nil } if strings.HasPrefix(body, `"`) { if err := json.Unmarshal(envelope.Body, &body); err != nil { return event, err } } if err := json.Unmarshal([]byte(body), &event); err != nil { return event, err } return event, nil }