2026-07-18 18:56:15 +08:00

261 lines
11 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"
"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))
}