2026-07-18 15:32:58 +08:00

222 lines
7.7 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 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
}