164 lines
6.2 KiB
Go
164 lines
6.2 KiB
Go
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
|
||
}
|