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

164 lines
6.2 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 gameking
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
)
const (
settlementDispatchBatch = 4
settlementDueBatch = 10
staleDeliveryBatch = 20
)
// Settle 在结算截点后冻结 TYCOON/OVERALL Top30并发送尚未开始的 PENDING 奖励。
// 重复调用不会重建快照,也不会自动重试 FAILED/UNKNOWN。
func (s *Service) Settle(ctx context.Context, activityID int64) error {
if activityID <= 0 {
return NewAppError(http.StatusBadRequest, "invalid_activity_id", "activityId is required")
}
if err := s.freezeSettlement(ctx, activityID); err != nil {
return err
}
var items []model.YumiGameKingDeliveryItem
if err := s.db.WithContext(ctx).
Where("activity_id = ? AND owner_type = ? AND delivery_status = ?", activityID, OwnerSettlement, DeliveryPending).
Order("id ASC").Limit(settlementDispatchBatch).Find(&items).Error; err != nil {
return err
}
// 人工结算同样限制每次最多发送 4 组,最坏外部调用约 80 秒并为 DB 留出余量;
// 剩余 PENDING 由后续 settle-due 批次继续恢复FAILED/UNKNOWN 不会自动发送。
var lastErr error
for _, item := range items {
if err := s.dispatchDeliveryItem(ctx, item.ID, false); err != nil {
lastErr = err
}
}
return lastErr
}
func (s *Service) freezeSettlement(ctx context.Context, activityID int64) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var activity model.YumiGameKingActivity
// 排他活动锁与事件/抽奖共享锁构成冻结闸门:先进入的业务事务完成后,状态在同一
// 事务切为 PROCESSING此后新事件会在共享锁放行后看到非 NOT_STARTED 并拒绝入账。
if err := withWriteLock(tx).Where("id = ?", activityID).First(&activity).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return NewAppError(http.StatusNotFound, "activity_not_found", "game king activity was not found")
}
return err
}
now := time.Now()
if !activity.Enabled {
return NewAppError(http.StatusConflict, "activity_disabled", "disabled activity cannot be settled")
}
if now.Before(activity.SettlementTime) {
return NewAppError(http.StatusConflict, "settlement_not_due", "activity settlement time has not arrived")
}
if activity.SettlementStatus != SettlementNotStarted {
return nil
}
if err := tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activity.ID).
Updates(map[string]any{"settlement_status": SettlementProcessing, "update_time": now}).Error; err != nil {
return err
}
var users []model.YumiGameKingUser
if err := tx.Where("activity_id = ? AND total_consumed > 0", activity.ID).
Order("total_consumed DESC, consume_reached_time ASC, user_id ASC").Limit(30).Find(&users).Error; err != nil {
return err
}
if len(users) == 0 {
return tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activity.ID).
Updates(map[string]any{"settlement_status": SettlementCompleted, "update_time": now}).Error
}
var rewards []model.YumiGameKingRankReward
if err := tx.Where("activity_id = ?", activity.ID).Order("start_rank ASC").Find(&rewards).Error; err != nil {
return err
}
if len(rewards) != len(fixedRankRanges) {
return NewAppError(http.StatusConflict, "rank_reward_config_invalid", "six rank reward tiers are required for settlement")
}
for index, user := range users {
rank := index + 1
reward, ok := rankRewardFor(rewards, rank)
if !ok {
return NewAppError(http.StatusConflict, "rank_reward_config_invalid", "rank reward tier does not cover Top30")
}
recordID, err := utils.NextID()
if err != nil {
return err
}
itemID, err := utils.NextID()
if err != nil {
return err
}
businessNo := fmt.Sprintf("YUMI_GAME_KING_SETTLEMENT:%d:%d", activity.ID, rank)
record := model.YumiGameKingSettlementRecord{
ID: recordID, ActivityID: activity.ID, UserID: user.UserID, RankNo: rank,
TotalConsumed: user.TotalConsumed, RankRewardID: reward.ID, ResourceGroupID: reward.ResourceGroupID,
BusinessNo: businessNo, DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
}
if err := tx.Create(&record).Error; err != nil {
return err
}
if err := tx.Create(&model.YumiGameKingDeliveryItem{
ID: itemID, OwnerType: OwnerSettlement, OwnerID: record.ID, ActivityID: activity.ID,
UserID: user.UserID, ResourceGroupID: reward.ResourceGroupID,
DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
}).Error; err != nil {
return err
}
}
return nil
})
}
func rankRewardFor(rows []model.YumiGameKingRankReward, rank int) (model.YumiGameKingRankReward, bool) {
for _, row := range rows {
if rank >= row.StartRank && rank <= row.EndRank {
return row, true
}
}
return model.YumiGameKingRankReward{}, false
}
// SettleDue 是外部 cron 的唯一批量触发业务方法;本仓库不启动 ticker。
func (s *Service) SettleDue(ctx context.Context) (*SettleDueResult, error) {
// 先把过期 PROCESSING 保守转 UNKNOWN只做状态修复不盲发。
if err := s.markStaleProcessingUnknown(ctx, staleDeliveryBatch); err != nil {
return nil, err
}
var activities []model.YumiGameKingActivity
if err := s.db.WithContext(ctx).
Where("enabled = ? AND settlement_time <= ? AND settlement_status = ?", true, time.Now(), SettlementNotStarted).
Order("settlement_time ASC, id ASC").Limit(settlementDueBatch).Find(&activities).Error; err != nil {
return nil, err
}
result := &SettleDueResult{FailedIDs: []string{}}
for _, activity := range activities {
result.Scanned++
// 批量端点只快速冻结排名并生成 PENDING不在单个活动内串行发送 Top30。
if err := s.freezeSettlement(ctx, activity.ID); err != nil {
result.FailedIDs = append(result.FailedIDs, strconv.FormatInt(activity.ID, 10))
continue
}
result.Settled++
}
// 每次最多领取 4 个全局 PENDING单项 dispatch 硬超时 20 秒,外部调用上界约
// 80 秒,为冻结/状态修复留约 40 秒chatapp-cron 应配置 >=120 秒超时。
if _, err := s.RecoverPendingDeliveries(ctx, settlementDispatchBatch); err != nil {
return nil, err
}
return result, nil
}