package gameking import ( "context" "encoding/json" "fmt" "net/http" "strings" "time" "chatapp3-golang/internal/model" "chatapp3-golang/internal/service/taskcenter" "chatapp3-golang/internal/utils" "gorm.io/gorm" "gorm.io/gorm/clause" ) // ConsumeGameEvent 实现 taskcenter.GameConsumeSink。GAME_CONSUME_GOLD 是唯一统计入口, // 不再允许后台配置钱包 eventType 或把非游戏支出混入活动。 func (s *Service) ConsumeGameEvent(ctx context.Context, event taskcenter.ValidatedEvent) error { if !strings.EqualFold(event.EventType, taskcenter.EventTypeGameConsumeGold) { return nil } if event.UserID <= 0 || event.DeltaValue <= 0 || strings.TrimSpace(event.EventID) == "" { return NewAppError(http.StatusBadRequest, "invalid_game_consume_event", "game consume event requires eventId, userId and positive deltaValue") } event.SysOrigin = normalizeSysOrigin(event.SysOrigin) event.EventID = strings.TrimSpace(event.EventID) var activities []model.YumiGameKingActivity if err := s.db.WithContext(ctx). Where("sys_origin = ? AND enabled = ? AND settlement_status = ? AND start_time <= ? AND end_time > ?", event.SysOrigin, true, SettlementNotStarted, event.OccurredAt, event.OccurredAt). Order("start_time ASC"). Find(&activities).Error; err != nil { return err } for _, activity := range activities { if _, err := s.applyEventToActivity(ctx, activity.ID, event); err != nil { return err } } return nil } // applyEventToActivity 在一个事务内完成账本幂等、总榜和活动时区日榜聚合。 func (s *Service) applyEventToActivity(ctx context.Context, activityID int64, event taskcenter.ValidatedEvent) (bool, error) { accepted := false err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var activity model.YumiGameKingActivity // 事件仅持有共享活动锁:不同用户可并发入账;结算使用排他锁并等待所有已进入的 // 事件事务完成,从而在切换 settlement_status 时形成明确冻结边界。 if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where("id = ?", activityID).First(&activity).Error; err != nil { return err } // 结算事务先锁活动并切换状态;此处复核状态和 [start,end),因此结算冻结后到达的 // 延迟 MQ/补数不会让 Top30 继续漂移。 if !activity.Enabled || activity.SettlementStatus != SettlementNotStarted || event.SysOrigin != activity.SysOrigin || event.OccurredAt.Before(activity.StartTime) || !event.OccurredAt.Before(activity.EndTime) { return nil } ledgerID, err := utils.NextID() if err != nil { return err } payloadJSON, _ := json.Marshal(event.Payload) ledger := model.YumiGameKingConsumeLedger{ ID: ledgerID, ActivityID: activity.ID, EventID: event.EventID, UserID: event.UserID, SysOrigin: event.SysOrigin, Amount: event.DeltaValue, EventTime: event.OccurredAt, PayloadJSON: string(payloadJSON), CreateTime: time.Now(), } result := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "activity_id"}, {Name: "event_id"}}, DoNothing: true, }).Create(&ledger) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return nil } now := time.Now() if err := addOverallConsumeTx(tx, activity.ID, event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil { return err } _, location, err := normalizeTimezone(activity.Timezone) if err != nil { return err } if err := addDailyConsumeTx(tx, activity.ID, dateKey(event.OccurredAt, location), event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil { return err } accepted = true return nil }) return accepted, err } func addOverallConsumeTx(tx *gorm.DB, activityID, userID, amount int64, eventTime, now time.Time) error { row := model.YumiGameKingUser{ ActivityID: activityID, UserID: userID, TotalConsumed: amount, UsedChances: 0, ConsumeReachedTime: eventTime, CreateTime: now, UpdateTime: now, } // 首笔事件不能“SELECT 未命中后 INSERT”:同一用户并发首笔会发生 PK 冲突或死锁, // 同步上报若不重试就会漏计。单条 upsert 由聚合主键串行累加,并且不覆盖 used_chances。 return tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "activity_id"}, {Name: "user_id"}}, DoUpdates: clause.Assignments(map[string]any{ "total_consumed": gorm.Expr("total_consumed + ?", amount), "consume_reached_time": gorm.Expr("GREATEST(consume_reached_time, ?)", eventTime), "update_time": now, }), }).Create(&row).Error } func addDailyConsumeTx(tx *gorm.DB, activityID int64, statDate time.Time, userID, amount int64, eventTime, now time.Time) error { row := model.YumiGameKingUserDaily{ ActivityID: activityID, StatDate: statDate, UserID: userID, TotalConsumed: amount, ConsumeReachedTime: eventTime, CreateTime: now, UpdateTime: now, } return tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "activity_id"}, {Name: "stat_date"}, {Name: "user_id"}}, DoUpdates: clause.Assignments(map[string]any{ "total_consumed": gorm.Expr("total_consumed + ?", amount), "consume_reached_time": gorm.Expr("GREATEST(consume_reached_time, ?)", eventTime), "update_time": now, }), }).Create(&row).Error } type archivedTaskEvent struct { DeltaValue int64 `json:"delta_value"` Payload map[string]any `json:"payload"` } const taskCenterGameKingBackfillIndex = "idx_task_center_game_king_backfill" type backfillIndexColumn struct { ColumnName string `gorm:"column:column_name"` SeqInIndex int `gorm:"column:seq_in_index"` SubPart *int64 `gorm:"column:sub_part"` IndexType string `gorm:"column:index_type"` IsVisible string `gorm:"column:is_visible"` } // Backfill 从任务中心的权威事件归档表按主键游标补数。调用方只控制 lastId/limit, // 不能提交 eventType 或钱包口径;查询始终绑定复合索引并限制到活动 [start,end)。 func (s *Service) Backfill(ctx context.Context, activityID, lastID int64, limit int) (*BackfillResult, error) { // 生产归档表约 35M 行;索引若未独立完成,后续游标定位或范围扫描可能退化成全表扫描。 // 因此元数据门禁必须位于任何活动/游标查询之前,并且每次调用重新检查,在线 DDL 完成后 // 无需重启即可恢复补数。information_schema 查询只读取固定索引的少量元数据行。 if err := s.requireBackfillIndex(ctx); err != nil { return nil, err } activity, err := s.loadActivity(ctx, activityID) if err != nil { return nil, err } // 游标只有在仍可入账时才允许推进;否则 settled/disabled 活动会扫描到底却全部 // accepted=false,给运营造成“补数成功”的假象。结束后等待结算期间仍允许补数。 if !activity.Enabled { return nil, NewAppError(http.StatusConflict, "activity_disabled", "disabled activity cannot be backfilled") } if activity.SettlementStatus != SettlementNotStarted { return nil, NewAppError(http.StatusConflict, "settlement_frozen", "settlement has frozen activity consumption") } if limit <= 0 { limit = defaultBackfillLimit } if limit > maxBackfillLimit { limit = maxBackfillLimit } if lastID < 0 { lastID = 0 } cursorTime := activity.StartTime if lastID > 0 { var cursor model.TaskCenterEventArchiveOutbox if err := s.db.WithContext(ctx). Where("id = ? AND sys_origin = ? AND event_type = ? AND occurred_at >= ? AND occurred_at < ?", lastID, activity.SysOrigin, taskcenter.EventTypeGameConsumeGold, activity.StartTime, activity.EndTime). First(&cursor).Error; err != nil { if isRecordNotFound(err) { return nil, NewAppError(http.StatusBadRequest, "invalid_backfill_cursor", "lastId does not belong to this activity event window") } return nil, err } cursorTime = cursor.OccurredAt } var rows []model.TaskCenterEventArchiveOutbox if err := s.db.WithContext(ctx). Where("sys_origin = ? AND event_type = ? AND occurred_at >= ? AND occurred_at < ? AND (occurred_at > ? OR (occurred_at = ? AND id > ?))", activity.SysOrigin, taskcenter.EventTypeGameConsumeGold, activity.StartTime, activity.EndTime, cursorTime, cursorTime, lastID). Order("occurred_at ASC, id ASC").Limit(limit + 1).Find(&rows).Error; err != nil { return nil, err } finished := len(rows) <= limit if len(rows) > limit { rows = rows[:limit] } result := &BackfillResult{EventType: taskcenter.EventTypeGameConsumeGold, Finished: finished, NextLastID: lastID} for _, row := range rows { var archived archivedTaskEvent if err := json.Unmarshal([]byte(row.RawJSON), &archived); err != nil || archived.DeltaValue <= 0 { return nil, NewAppError(http.StatusUnprocessableEntity, "invalid_archived_event", fmt.Sprintf("task center archive row %d has no valid delta_value", row.ID)) } accepted, err := s.applyEventToActivity(ctx, activity.ID, taskcenter.ValidatedEvent{ SysOrigin: activity.SysOrigin, EventID: row.EventID, EventType: taskcenter.EventTypeGameConsumeGold, UserID: row.UserID, DeltaValue: archived.DeltaValue, OccurredAt: row.OccurredAt, Payload: archived.Payload, }) if err != nil { return nil, err } if !accepted { var existing int64 if err := s.db.WithContext(ctx).Model(&model.YumiGameKingConsumeLedger{}). Where("activity_id = ? AND event_id = ?", activity.ID, row.EventID).Count(&existing).Error; err != nil { return nil, err } if existing == 0 { // 入口校验后结算可能在本页中途取得排他锁。未命中 ledger 说明这条不是幂等 // 重复而是已被冻结拒绝;整页返回冲突且不暴露推进后的 cursor,调用方沿旧游标重试。 return nil, NewAppError(http.StatusConflict, "backfill_window_closed", "activity stopped accepting consumption during backfill") } } result.Scanned++ if accepted { result.Accepted++ } result.NextLastID = row.ID } return result, nil } // requireBackfillIndex 只接受迁移 058 创建的完整 BTREE 列序;VARCHAR 前缀索引也会拒绝, // 因为它不能提供与完整等值前缀相同的执行计划保证。缺表、缺索引或列序漂移统一 fail-closed。 func (s *Service) requireBackfillIndex(ctx context.Context) error { var columns []backfillIndexColumn if err := s.db.WithContext(ctx).Raw(` SELECT COLUMN_NAME, SEQ_IN_INDEX, SUB_PART, INDEX_TYPE, IS_VISIBLE FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'task_center_event_archive_outbox' AND INDEX_NAME = ? ORDER BY SEQ_IN_INDEX ASC`, taskCenterGameKingBackfillIndex).Scan(&columns).Error; err != nil { return NewAppError(http.StatusServiceUnavailable, "backfill_index_check_failed", "cannot verify the required task center backfill index") } required := [...]string{"sys_origin", "event_type", "occurred_at", "id"} if len(columns) == len(required) { valid := true for index, requiredName := range required { column := columns[index] if column.SeqInIndex != index+1 || !strings.EqualFold(column.ColumnName, requiredName) || column.SubPart != nil || !strings.EqualFold(column.IndexType, "BTREE") || !strings.EqualFold(column.IsVisible, "YES") { valid = false break } } if valid { return nil } } return NewAppError( http.StatusServiceUnavailable, "backfill_index_required", "migration 058 backfill index (sys_origin,event_type,occurred_at,id) must be online before backfill", ) }