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