261 lines
11 KiB
Go
261 lines
11 KiB
Go
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
|
||
}
|
||
state, err := s.loadOrCreateBackfillState(ctx, activity)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
cursorTime := activity.StartTime
|
||
if state.LastOccurredAt != nil {
|
||
cursorTime = *state.LastOccurredAt
|
||
}
|
||
if lastID > 0 && state.LastOutboxID == 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
|
||
}
|
||
state, err = s.adoptBackfillCursor(ctx, activity.ID, cursor)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
cursorTime = cursor.OccurredAt
|
||
} else if lastID > 0 && lastID != state.LastOutboxID {
|
||
return nil, NewAppError(http.StatusConflict, "backfill_cursor_conflict", "lastId does not match the persisted backfill cursor")
|
||
}
|
||
lastID = state.LastOutboxID
|
||
startingLastID := lastID
|
||
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
|
||
cursorTime = row.OccurredAt
|
||
}
|
||
if err := s.persistBackfillState(ctx, activity.ID, startingLastID, result.NextLastID, cursorTime, result.Scanned, result.Accepted, result.Finished); err != nil {
|
||
return nil, err
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// requireBackfillIndex 只接受迁移 058 创建的完整 BTREE 列序;VARCHAR 前缀索引也会拒绝,
|
||
// 因为它不能提供与完整等值前缀相同的执行计划保证。缺表、缺索引或列序漂移统一 fail-closed。
|
||
func (s *Service) requireBackfillIndex(ctx context.Context) error {
|
||
return requireBackfillIndexOnDB(s.db.WithContext(ctx))
|
||
}
|