package lottery import ( "context" "errors" "gorm.io/gorm" "gorm.io/gorm/clause" ) // chanceService 是 ChanceService 的默认实现。 type chanceService struct { db *gorm.DB } // NewChanceService 用注入的 *gorm.DB 构造一个 ChanceService。 func NewChanceService(db *gorm.DB) ChanceService { return &chanceService{db: db} } // Grant 记录一次次数入账,幂等键 = (activity_id, source, source_ref)。 // 幂等策略: // 1. INSERT lottery_chance_grant,靠 UNIQUE(activity_id, source, source_ref) 触发冲突 // 2. 冲突视为"已发过",直接返回 nil 不重复发放 // 3. 未冲突 → UPSERT lottery_chance_balance 累加 remaining // // 关键正确性:两步必须在同一事务内。这样第 (1) 成功即证明是首次入账, // 才走第 (2);第 (1) 冲突则直接跳过 (2),balance 不会双加。 func (s *chanceService) Grant(ctx context.Context, userId, activityId int64, source, sourceRef string, amount int) error { if amount <= 0 { return nil } return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { grant := ChanceGrant{ UserId: userId, ActivityId: activityId, Source: source, SourceRef: sourceRef, Amount: amount, } // OnConflict DoNothing 依赖 UNIQUE(activity_id, source, source_ref)。 res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&grant) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { // 幂等命中:已经发过,balance 不动。 return nil } // 未冲突 → 累加余额(upsert balance 行)。 balance := ChanceBalance{ UserId: userId, ActivityId: activityId, Remaining: int64(amount), TotalEarned: int64(amount), } return tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "user_id"}, {Name: "activity_id"}}, DoUpdates: clause.Assignments(map[string]interface{}{ "remaining": gorm.Expr("`lottery_chance_balance`.`remaining` + ?", amount), "total_earned": gorm.Expr("`lottery_chance_balance`.`total_earned` + ?", amount), }), }).Create(&balance).Error }) } // Consume 在事务内以 SELECT ... FOR UPDATE 锁住 chance_balance 行后 -1。 // 剩余为 0 时返回 ErrNoChances,调用方直接回滚事务,不写 draw。 func (s *chanceService) Consume(ctx context.Context, tx *gorm.DB, userId, activityId int64) (int64, error) { if tx == nil { return 0, errors.New("Consume requires a transaction handle") } var balance ChanceBalance err := tx.WithContext(ctx). Clauses(clause.Locking{Strength: "UPDATE"}). Where("user_id = ? AND activity_id = ?", userId, activityId). First(&balance).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return 0, ErrNoChances } return 0, err } if balance.Remaining <= 0 { return 0, ErrNoChances } // 累加 spent,扣减 remaining,一条 SQL 完成。 updateErr := tx.WithContext(ctx). Model(&ChanceBalance{}). Where("id = ? AND remaining > 0", balance.Id). Updates(map[string]interface{}{ "remaining": gorm.Expr("`remaining` - 1"), "total_spent": gorm.Expr("`total_spent` + 1"), }).Error if updateErr != nil { return 0, updateErr } return balance.Remaining - 1, nil } // Query 只读,返回用户在活动下的剩余次数。未初始化过 balance 行时返回 0。 func (s *chanceService) Query(ctx context.Context, userId, activityId int64) (int64, error) { var balance ChanceBalance err := s.db.WithContext(ctx). Where("user_id = ? AND activity_id = ?", userId, activityId). First(&balance).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return 0, nil } return 0, err } if balance.Remaining < 0 { return 0, nil } return balance.Remaining, nil }