545 lines
17 KiB
Go

package voiceroomrocket
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type launchResult struct {
Launched bool
Launch model.VoiceRoomRocketLaunch
}
// ProcessGiftPayload 解析送礼消息原文并按火箭规则加能量。
func (s *Service) ProcessGiftPayload(ctx context.Context, payload string) (*ProcessGiftResponse, error) {
payload = strings.TrimSpace(payload)
if payload == "" {
return &ProcessGiftResponse{Processed: false, Reason: "empty_payload"}, nil
}
event, err := decodeGiftEventPayload(payload, s.cfg.VoiceRoomRocket.MQ.Tag)
if err != nil {
return nil, err
}
return s.ProcessGiftEvent(ctx, event, payload)
}
// ProcessGiftEvent 处理一条已经解析的送礼事件。
func (s *Service) ProcessGiftEvent(ctx context.Context, event giftEvent, rawPayload string) (*ProcessGiftResponse, error) {
trackID := strings.TrimSpace(event.TrackID.String())
sysOrigin := s.normalizeSysOrigin(event.SysOrigin)
roomID := event.RoomID.Int64()
sendUserID := event.SendUserID.Int64()
if trackID == "" || roomID <= 0 || sendUserID <= 0 {
return &ProcessGiftResponse{Processed: false, Reason: "missing_required_fields"}, nil
}
eventTime := resolveMillisTime(event.CreateTime.Int64(), time.Now())
snapshot, err := s.loadConfig(ctx, sysOrigin)
if err != nil {
return nil, err
}
if !snapshot.Enabled {
return &ProcessGiftResponse{Processed: false, Reason: "rocket_disabled"}, nil
}
rawEnergy, ignoreReason := resolveGiftEnergy(event, snapshot)
if rawEnergy <= 0 {
if ignoreReason == "" {
ignoreReason = "zero_energy"
}
if err := s.createIgnoredGiftLog(ctx, sysOrigin, trackID, roomID, sendUserID, event, rawPayload, eventTime, ignoreReason); err != nil {
return nil, err
}
return &ProcessGiftResponse{Processed: false, Reason: ignoreReason}, nil
}
unlock, err := s.acquireRoomLock(ctx, sysOrigin, roomID)
if err != nil {
return nil, err
}
if unlock != nil {
defer unlock()
}
local := resolveLocation(snapshot.Timezone)
day := dayKey(eventTime, local)
resp := &ProcessGiftResponse{
Processed: false,
RoomID: roomID,
DayKey: day,
}
var result launchResult
err = s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
statusRow, err := s.loadOrInitStatus(ctx, tx, sysOrigin, roomID, day, eventTime.In(local))
if err != nil {
return err
}
resp.RoundNo = statusRow.RoundNo
resp.Level = statusRow.CurrentLevel
logID, err := utils.NextID()
if err != nil {
return err
}
giftID := event.GiftConfig.ID.Int64()
var giftIDPtr *int64
if giftID > 0 {
giftIDPtr = &giftID
}
remain := statusRow.NeedEnergy - statusRow.CurrentEnergy
if remain < 0 {
remain = 0
}
accepted := rawEnergy
if accepted > remain {
accepted = remain
}
if accepted <= 0 {
record := model.VoiceRoomRocketGiftEventLog{
ID: logID,
SysOrigin: sysOrigin,
TrackID: trackID,
RoomID: roomID,
SendUserID: sendUserID,
GiftID: giftIDPtr,
GiftType: strings.TrimSpace(event.GiftConfig.Type),
GiftTab: strings.TrimSpace(event.GiftConfig.GiftTab),
RawEnergy: rawEnergy,
AcceptedEnergy: 0,
DayKey: statusRow.DayKey,
RoundNo: statusRow.RoundNo,
Level: statusRow.CurrentLevel,
Status: eventStatusIgnored,
RawPayload: truncateString(rawPayload, 16*1024*1024),
ErrorMessage: "level_already_full",
EventTime: eventTime,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
if err := tx.Create(&record).Error; err != nil {
if isDuplicateKeyErr(err) {
resp.Reason = "duplicate_event"
return nil
}
return err
}
resp.Reason = "level_already_full"
return nil
}
now := time.Now()
record := model.VoiceRoomRocketGiftEventLog{
ID: logID,
SysOrigin: sysOrigin,
TrackID: trackID,
RoomID: roomID,
SendUserID: sendUserID,
GiftID: giftIDPtr,
GiftType: strings.TrimSpace(event.GiftConfig.Type),
GiftTab: strings.TrimSpace(event.GiftConfig.GiftTab),
RawEnergy: rawEnergy,
AcceptedEnergy: accepted,
DayKey: statusRow.DayKey,
RoundNo: statusRow.RoundNo,
Level: statusRow.CurrentLevel,
Status: eventStatusSuccess,
RawPayload: truncateString(rawPayload, 16*1024*1024),
EventTime: eventTime,
CreateTime: now,
UpdateTime: now,
}
if err := tx.Create(&record).Error; err != nil {
if isDuplicateKeyErr(err) {
resp.Reason = "duplicate_event"
return nil
}
return err
}
if err := s.addContribution(ctx, tx, statusRow, sendUserID, accepted, eventTime); err != nil {
return err
}
newEnergy := statusRow.CurrentEnergy + accepted
statusUpdates := map[string]any{
"current_energy": newEnergy,
"last_event_time": eventTime,
"update_time": now,
}
if newEnergy >= statusRow.NeedEnergy {
statusUpdates["status"] = statusLaunching
}
if err := tx.Model(&model.VoiceRoomRocketStatus{}).
Where("id = ?", statusRow.ID).
Updates(statusUpdates).Error; err != nil {
return err
}
statusRow.CurrentEnergy = newEnergy
statusRow.LastEventTime = &eventTime
resp.Processed = true
resp.AcceptedEnergy = accepted
if err := s.updateRedisRank(ctx, statusRow, sendUserID, accepted); err != nil {
return err
}
if newEnergy >= statusRow.NeedEnergy {
if err := s.markLevelReachedLocked(ctx, tx, statusRow, sendUserID, now); err != nil {
return err
}
launch, err := s.scheduleLaunchLocked(ctx, tx, statusRow, sendUserID, now)
if err != nil {
return err
}
result = launch
resp.Launched = launch.Launched
resp.LaunchNo = launch.Launch.LaunchNo
}
return nil
})
if err != nil {
return nil, err
}
if result.Launched {
_ = s.notifyLaunch(context.Background(), result.Launch)
} else if resp.Processed {
_ = s.notifyStatusUpdate(context.Background(), sysOrigin, roomID, sendUserID)
}
return resp, nil
}
func (s *Service) createIgnoredGiftLog(ctx context.Context, sysOrigin, trackID string, roomID, sendUserID int64, event giftEvent, rawPayload string, eventTime time.Time, reason string) error {
id, err := utils.NextID()
if err != nil {
return err
}
giftID := event.GiftConfig.ID.Int64()
var giftIDPtr *int64
if giftID > 0 {
giftIDPtr = &giftID
}
now := time.Now()
record := model.VoiceRoomRocketGiftEventLog{
ID: id,
SysOrigin: sysOrigin,
TrackID: trackID,
RoomID: roomID,
SendUserID: sendUserID,
GiftID: giftIDPtr,
GiftType: strings.TrimSpace(event.GiftConfig.Type),
GiftTab: strings.TrimSpace(event.GiftConfig.GiftTab),
Status: eventStatusIgnored,
RawPayload: truncateString(rawPayload, 16*1024*1024),
ErrorMessage: reason,
EventTime: eventTime,
CreateTime: now,
UpdateTime: now,
}
err = s.repo.DB.WithContext(ctx).Create(&record).Error
if err != nil && !isDuplicateKeyErr(err) {
return err
}
return nil
}
func (s *Service) addContribution(ctx context.Context, tx *gorm.DB, statusRow model.VoiceRoomRocketStatus, userID int64, accepted int64, eventTime time.Time) error {
var row model.VoiceRoomRocketContribution
err := tx.WithContext(ctx).
Where(
"sys_origin = ? AND room_id = ? AND day_key = ? AND round_no = ? AND level = ? AND user_id = ?",
statusRow.SysOrigin, statusRow.RoomID, statusRow.DayKey, statusRow.RoundNo, statusRow.CurrentLevel, userID,
).
First(&row).Error
now := time.Now()
if errors.Is(err, gorm.ErrRecordNotFound) {
id, idErr := utils.NextID()
if idErr != nil {
return idErr
}
row = model.VoiceRoomRocketContribution{
ID: id,
SysOrigin: statusRow.SysOrigin,
RoomID: statusRow.RoomID,
DayKey: statusRow.DayKey,
RoundNo: statusRow.RoundNo,
Level: statusRow.CurrentLevel,
UserID: userID,
ScoreEnergy: accepted,
GiftCount: 1,
FirstContributeTime: eventTime,
LastContributeTime: eventTime,
CreateTime: now,
UpdateTime: now,
}
return tx.Create(&row).Error
}
if err != nil {
return err
}
return tx.Model(&model.VoiceRoomRocketContribution{}).
Where("id = ?", row.ID).
Updates(map[string]any{
"score_energy": gorm.Expr("score_energy + ?", accepted),
"gift_count": gorm.Expr("gift_count + 1"),
"last_contribute_time": eventTime,
"update_time": now,
}).Error
}
func (s *Service) markLevelReachedLocked(ctx context.Context, tx *gorm.DB, statusRow model.VoiceRoomRocketStatus, igniteUserID int64, now time.Time) error {
return tx.WithContext(ctx).Model(&model.VoiceRoomRocketLevelRound{}).
Where(
"sys_origin = ? AND room_id = ? AND day_key = ? AND round_no = ? AND level = ?",
statusRow.SysOrigin, statusRow.RoomID, statusRow.DayKey, statusRow.RoundNo, statusRow.CurrentLevel,
).
Updates(map[string]any{
"final_energy": statusRow.NeedEnergy,
"status": levelRoundStatusReached,
"ignite_user_id": igniteUserID,
"reach_time": now,
"update_time": now,
}).Error
}
func (s *Service) scheduleLaunchLocked(ctx context.Context, tx *gorm.DB, statusRow model.VoiceRoomRocketStatus, igniteUserID int64, now time.Time) (launchResult, error) {
if s.repo.Redis == nil {
return s.launchLocked(ctx, tx, statusRow, igniteUserID, now)
}
due := now.Add(time.Duration(defaultLaunchDelaySeconds) * time.Second)
if err := s.repo.Redis.ZAdd(ctx, s.launchDueZSetKey(), redis.Z{
Score: float64(due.Unix()),
Member: strconv.FormatInt(statusRow.ID, 10),
}).Err(); err != nil {
return launchResult{}, err
}
return launchResult{Launched: false}, nil
}
func (s *Service) launchLocked(ctx context.Context, tx *gorm.DB, statusRow model.VoiceRoomRocketStatus, igniteUserID int64, now time.Time) (launchResult, error) {
top1UserID, err := s.resolveTop1UserID(ctx, tx, statusRow)
if err != nil {
return launchResult{}, err
}
launchID, err := utils.NextID()
if err != nil {
return launchResult{}, err
}
launchNo := fmt.Sprintf("VRR%d", launchID)
roomAccount := s.resolveRoomAccount(ctx, statusRow.RoomID)
launch := model.VoiceRoomRocketLaunch{
ID: launchID,
LaunchNo: launchNo,
SysOrigin: statusRow.SysOrigin,
RoomID: statusRow.RoomID,
RoomAccount: roomAccount,
DayKey: statusRow.DayKey,
RoundNo: statusRow.RoundNo,
Level: statusRow.CurrentLevel,
FinalEnergy: statusRow.NeedEnergy,
Top1UserID: top1UserID,
IgniteUserID: &igniteUserID,
InRoomSnapshotStatus: inRoomSnapshotPending,
LaunchTime: now,
CreateTime: now,
UpdateTime: now,
}
if err := tx.Create(&launch).Error; err != nil {
if isDuplicateKeyErr(err) {
return launchResult{Launched: false}, nil
}
return launchResult{}, err
}
if err := tx.Model(&model.VoiceRoomRocketLevelRound{}).
Where(
"sys_origin = ? AND room_id = ? AND day_key = ? AND round_no = ? AND level = ?",
statusRow.SysOrigin, statusRow.RoomID, statusRow.DayKey, statusRow.RoundNo, statusRow.CurrentLevel,
).
Updates(map[string]any{
"final_energy": statusRow.NeedEnergy,
"status": levelRoundStatusLaunched,
"top1_user_id": top1UserID,
"ignite_user_id": igniteUserID,
"reach_time": gorm.Expr("COALESCE(reach_time, ?)", now),
"launch_time": now,
"update_time": now,
}).Error; err != nil {
return launchResult{}, err
}
if err := s.createImmediateRewardRecords(ctx, tx, launch, top1UserID, igniteUserID, now); err != nil {
return launchResult{}, err
}
if err := s.enqueueInRoomReward(ctx, launch, now); err != nil {
return launchResult{}, err
}
nextRound := statusRow.RoundNo
nextLevel := statusRow.CurrentLevel + 1
if nextLevel > defaultMaxLevel {
nextRound++
nextLevel = 1
}
nextLevelConfig, err := s.loadLevelConfig(ctx, statusRow.SysOrigin, nextLevel)
if err != nil {
return launchResult{}, err
}
if err := tx.Model(&model.VoiceRoomRocketStatus{}).
Where("id = ?", statusRow.ID).
Updates(map[string]any{
"round_no": nextRound,
"current_level": nextLevel,
"current_energy": 0,
"need_energy": nextLevelConfig.NeedEnergy,
"status": statusCharging,
"level_start_time": now,
"update_time": now,
}).Error; err != nil {
return launchResult{}, err
}
if err := s.ensureLevelRound(ctx, tx, statusRow.SysOrigin, statusRow.RoomID, statusRow.DayKey, nextRound, nextLevel, nextLevelConfig.NeedEnergy, now); err != nil {
return launchResult{}, err
}
return launchResult{Launched: true, Launch: launch}, nil
}
func (s *Service) resolveTop1UserID(ctx context.Context, tx *gorm.DB, statusRow model.VoiceRoomRocketStatus) (*int64, error) {
var row model.VoiceRoomRocketContribution
err := tx.WithContext(ctx).
Where(
"sys_origin = ? AND room_id = ? AND day_key = ? AND round_no = ? AND level = ?",
statusRow.SysOrigin, statusRow.RoomID, statusRow.DayKey, statusRow.RoundNo, statusRow.CurrentLevel,
).
Order("score_energy DESC, last_contribute_time ASC, user_id ASC").
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row.UserID, nil
}
func (s *Service) acquireRoomLock(ctx context.Context, sysOrigin string, roomID int64) (func(), error) {
if s.repo.Redis == nil {
return nil, nil
}
ttl := time.Duration(s.cfg.VoiceRoomRocket.RoomLockTTLSeconds) * time.Second
if ttl <= 0 {
ttl = defaultRoomLockTTL
}
key := roomLockKey(sysOrigin, roomID)
token := strconv.FormatInt(time.Now().UnixNano(), 10)
for i := 0; i < 20; i++ {
ok, err := s.repo.Redis.SetNX(ctx, key, token, ttl).Result()
if err != nil {
return nil, NewAppError(http.StatusServiceUnavailable, "rocket_lock_failed", err.Error())
}
if ok {
return func() { _ = s.releaseRoomLock(context.Background(), key, token) }, nil
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(50 * time.Millisecond):
}
}
return nil, NewAppError(http.StatusConflict, "rocket_room_busy", "room rocket is busy")
}
func (s *Service) releaseRoomLock(ctx context.Context, key string, token string) error {
if s.repo.Redis == nil {
return nil
}
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end
return 0`
return s.repo.Redis.Eval(ctx, script, []string{key}, token).Err()
}
func (s *Service) updateRedisRank(ctx context.Context, statusRow model.VoiceRoomRocketStatus, userID int64, accepted int64) error {
if s.repo.Redis == nil || accepted <= 0 {
return nil
}
return s.repo.Redis.ZIncrBy(ctx, rankRedisKey(statusRow.SysOrigin, statusRow.RoomID, statusRow.DayKey, statusRow.RoundNo, statusRow.CurrentLevel), float64(accepted), strconv.FormatInt(userID, 10)).Err()
}
func (s *Service) enqueueInRoomReward(ctx context.Context, launch model.VoiceRoomRocketLaunch, now time.Time) error {
if s.repo.Redis == nil {
return nil
}
delaySeconds := defaultInRoomRewardDelaySeconds
if snapshot, err := s.loadConfig(ctx, launch.SysOrigin); err == nil && snapshot.InRoomRewardDelaySeconds > 0 {
delaySeconds = snapshot.InRoomRewardDelaySeconds
}
due := now.Add(time.Duration(delaySeconds) * time.Second)
key := strings.TrimSpace(s.cfg.VoiceRoomRocket.InRoomRewardDueZSetKey)
if key == "" {
key = "voice_room:rocket:in_room_due"
}
return s.repo.Redis.ZAdd(ctx, key, redis.Z{
Score: float64(due.Unix()),
Member: launch.LaunchNo,
}).Err()
}
func decodeGiftEventPayload(payload string, expectedTag string) (giftEvent, error) {
var event giftEvent
if err := json.Unmarshal([]byte(payload), &event); err != nil {
return event, err
}
if event.hasBusinessFields() {
return event, nil
}
var envelope messageEventEnvelope
if err := json.Unmarshal([]byte(payload), &envelope); err != nil {
return event, err
}
if strings.TrimSpace(expectedTag) != "" &&
strings.TrimSpace(envelope.Tag) != "" &&
strings.TrimSpace(expectedTag) != strings.TrimSpace(envelope.Tag) {
return giftEvent{}, nil
}
bodyPayload, ok, err := envelope.bodyPayload()
if err != nil || !ok {
return event, err
}
if err := json.Unmarshal([]byte(bodyPayload), &event); err != nil {
return event, err
}
return event, nil
}
func (e messageEventEnvelope) bodyPayload() (string, bool, error) {
raw := strings.TrimSpace(string(e.Body))
if raw == "" || raw == "null" {
return "", false, nil
}
if strings.HasPrefix(raw, "\"") {
var body string
if err := json.Unmarshal(e.Body, &body); err != nil {
return "", false, err
}
body = strings.TrimSpace(body)
return body, body != "", nil
}
return raw, true, nil
}
func (e giftEvent) hasBusinessFields() bool {
return strings.TrimSpace(e.TrackID.String()) != "" ||
e.SendUserID.Int64() > 0 ||
e.RoomID.Int64() > 0 ||
e.GiftConfig.ID.Int64() > 0
}