package economy import ( "context" "database/sql" "encoding/json" "errors" "strconv" "time" ) // امتیازِ هر رویداد در تورنومنت. const ( tourPointsWin = 100 // بردِ یک بازی tourPointsLoss = 25 // شرکت در یک بازی (باخت) ) var ( ErrTournamentNotFound = errors.New("tournament not found") ErrTournamentClosed = errors.New("tournament is closed") ErrAlreadyJoined = errors.New("already joined") ) // Tournament یک تورنومنتِ امتیازی. type Tournament struct { ID int64 `json:"id"` Title string `json:"title"` Description string `json:"description"` EntryFee int64 `json:"entry_fee"` Prizes []int64 `json:"prizes"` StartsAt time.Time `json:"-"` EndsAt time.Time `json:"-"` Settled bool `json:"-"` } // TournamentView نمای عمومیِ تورنومنت برای کلاینت (وضعیت، جایزه‌ی کل، عضویتِ کاربر). type TournamentView struct { ID int64 `json:"id"` Title string `json:"title"` Description string `json:"description"` EntryFee int64 `json:"entry_fee"` Prizes []int64 `json:"prizes"` PrizePool int64 `json:"prize_pool"` // مجموعِ جوایز Status string `json:"status"` // upcoming | active | ended StartsAt string `json:"starts_at"` // RFC3339 UTC (ماشینی) EndsAt string `json:"ends_at"` StartsLocal string `json:"starts_local"` // به وقتِ تهران (نمایشی) EndsLocal string `json:"ends_local"` Players int `json:"players"` Joined bool `json:"joined"` MyPoints int `json:"my_points"` MyRank int `json:"my_rank"` // ۰ یعنی رتبه‌ای ندارد } // TournamentStanding یک ردیفِ جدولِ رده‌بندی. type TournamentStanding struct { Rank int `json:"rank"` UserID int64 `json:"user_id"` Name string `json:"name"` Avatar string `json:"avatar"` Points int `json:"points"` Wins int `json:"wins"` Games int `json:"games"` Prize int64 `json:"prize"` } // iranZone وقتِ ایران (UTC+3:30، بدونِ DST از ۲۰۲۲). ثابت است تا به tzdata // روی سرور وابسته نباشیم (باینریِ CGO-off). var iranZone = time.FixedZone("IRST", 12600) func tournamentStatus(now, starts, ends time.Time) string { switch { case now.Before(starts): return "upcoming" case now.After(ends): return "ended" default: return "active" } } func sumPrizes(p []int64) int64 { var s int64 for _, x := range p { s += x } return s } // scanTournament یک ردیفِ tournaments را می‌خواند. func scanTournament(sc interface{ Scan(...any) error }) (*Tournament, error) { var t Tournament var prizes, starts, ends string var settled int if err := sc.Scan(&t.ID, &t.Title, &t.Description, &t.EntryFee, &prizes, &starts, &ends, &settled); err != nil { return nil, err } _ = json.Unmarshal([]byte(prizes), &t.Prizes) t.StartsAt = parseTS(starts) t.EndsAt = parseTS(ends) t.Settled = settled != 0 return &t, nil } const tournamentCols = `id, title, description, entry_fee, prizes, starts_at, ends_at, settled` // ListTournaments تورنومنت‌ها را (به‌همراه وضعیتِ عضویتِ کاربر) برمی‌گرداند. // پیش از خواندن، تورنومنت‌های پایان‌یافته‌ی تسویه‌نشده را تسویه می‌کند. // // نکته‌ی مهم: چون pool فقط یک اتصال دارد (SetMaxOpenConns(1))، نباید حین باز بودنِ // یک rows کوئریِ دیگری زد (self-deadlock). پس ابتدا همه‌ی ردیف‌ها را می‌خوانیم و // rows را می‌بندیم، سپس زیرکوئری‌های هر تورنومنت را اجرا می‌کنیم. func (s *Service) ListTournaments(ctx context.Context, userID int64) ([]TournamentView, error) { s.SettleTournaments(ctx) rows, err := s.db.QueryContext(ctx, `SELECT `+tournamentCols+` FROM tournaments ORDER BY (ends_at < strftime('%Y-%m-%d %H:%M:%S','now')), starts_at`) if err != nil { return nil, err } var ts []*Tournament for rows.Next() { t, err := scanTournament(rows) if err != nil { rows.Close() return nil, err } ts = append(ts, t) } rows.Close() if err := rows.Err(); err != nil { return nil, err } now := nowUTC() out := make([]TournamentView, 0, len(ts)) for _, t := range ts { v := TournamentView{ ID: t.ID, Title: t.Title, Description: t.Description, EntryFee: t.EntryFee, Prizes: t.Prizes, PrizePool: sumPrizes(t.Prizes), Status: tournamentStatus(now, t.StartsAt, t.EndsAt), StartsAt: t.StartsAt.Format(time.RFC3339), EndsAt: t.EndsAt.Format(time.RFC3339), StartsLocal: t.StartsAt.In(iranZone).Format("2006-01-02 15:04"), EndsLocal: t.EndsAt.In(iranZone).Format("2006-01-02 15:04"), } _ = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tournament_entries WHERE tournament_id = ?`, t.ID).Scan(&v.Players) if userID != 0 { var pts sql.NullInt64 if err := s.db.QueryRowContext(ctx, `SELECT points FROM tournament_entries WHERE tournament_id = ? AND user_id = ?`, t.ID, userID).Scan(&pts); err == nil { v.Joined = true v.MyPoints = int(pts.Int64) _ = s.db.QueryRowContext(ctx, `SELECT COUNT(*)+1 FROM tournament_entries WHERE tournament_id = ? AND points > ?`, t.ID, pts.Int64).Scan(&v.MyRank) } } out = append(out, v) } return out, nil } // TournamentStandings رده‌بندیِ یک تورنومنت (تا limit نفرِ برتر). func (s *Service) TournamentStandings(ctx context.Context, id int64, limit int) ([]TournamentStanding, error) { if limit <= 0 { limit = 50 } rows, err := s.db.QueryContext(ctx, `SELECT e.user_id, COALESCE(u.first_name, u.mobile), COALESCE(u.avatar,''), e.points, e.wins, e.games, e.prize FROM tournament_entries e JOIN users u ON u.id = e.user_id WHERE e.tournament_id = ? ORDER BY e.points DESC, e.wins DESC, e.joined_at ASC LIMIT ?`, id, limit) if err != nil { return nil, err } defer rows.Close() var out []TournamentStanding rank := 0 for rows.Next() { rank++ var st TournamentStanding var name sql.NullString if err := rows.Scan(&st.UserID, &name, &st.Avatar, &st.Points, &st.Wins, &st.Games, &st.Prize); err != nil { return nil, err } st.Rank = rank st.Name = name.String out = append(out, st) } return out, rows.Err() } // JoinTournament کاربر را ثبت‌نام کرده و ورودی را از سکه کسر می‌کند. func (s *Service) JoinTournament(ctx context.Context, userID, id int64) error { row := s.db.QueryRowContext(ctx, `SELECT `+tournamentCols+` FROM tournaments WHERE id = ?`, id) t, err := scanTournament(row) if errors.Is(err, sql.ErrNoRows) { return ErrTournamentNotFound } if err != nil { return err } // ثبت‌نام تا پیش از پایانِ تورنومنت مجاز است. if nowUTC().After(t.EndsAt) || t.Settled { return ErrTournamentClosed } // آیا قبلاً عضو شده؟ var exists int _ = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tournament_entries WHERE tournament_id = ? AND user_id = ?`, id, userID).Scan(&exists) if exists > 0 { return ErrAlreadyJoined } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if t.EntryFee > 0 { if err := adjustTx(ctx, tx, userID, CurrencyCoin, -t.EntryFee, "tournament_join", tournamentRef(id)); err != nil { return err // ErrInsufficient در صورتِ کمبود } } if _, err := tx.ExecContext(ctx, `INSERT INTO tournament_entries (tournament_id, user_id) VALUES (?, ?)`, id, userID); err != nil { return err } return tx.Commit() } // AddTournamentPoints امتیازِ کاربر را در همه‌ی تورنومنت‌های فعالی که در آن‌ها عضو // است افزایش می‌دهد (هنگامِ پایانِ هر بازی). fire-and-forget؛ خطاها لاگ نمی‌شوند. func (s *Service) AddTournamentPoints(ctx context.Context, userID int64, won bool) { pts := tourPointsLoss winInc := 0 if won { pts = tourPointsWin winInc = 1 } now := nowUTC().Format(tsLayout) _, _ = s.db.ExecContext(ctx, `UPDATE tournament_entries SET points = points + ?, games = games + 1, wins = wins + ? WHERE user_id = ? AND tournament_id IN ( SELECT id FROM tournaments WHERE settled = 0 AND starts_at <= ? AND ends_at >= ? )`, pts, winInc, userID, now, now) } // SettleTournaments تورنومنت‌های پایان‌یافته‌ی تسویه‌نشده را تسویه و جوایز را پرداخت می‌کند. func (s *Service) SettleTournaments(ctx context.Context) { now := nowUTC().Format(tsLayout) rows, err := s.db.QueryContext(ctx, `SELECT id, prizes FROM tournaments WHERE settled = 0 AND ends_at < ?`, now) if err != nil { return } var ids []int64 prizeMap := map[int64][]int64{} for rows.Next() { var id int64 var prizes string if err := rows.Scan(&id, &prizes); err != nil { continue } var p []int64 _ = json.Unmarshal([]byte(prizes), &p) ids = append(ids, id) prizeMap[id] = p } rows.Close() for _, id := range ids { s.settleOne(ctx, id, prizeMap[id]) } } // settleOne یک تورنومنت را در یک تراکنش تسویه می‌کند. func (s *Service) settleOne(ctx context.Context, id int64, prizes []int64) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return } defer tx.Rollback() if len(prizes) > 0 { rows, err := tx.QueryContext(ctx, `SELECT user_id FROM tournament_entries WHERE tournament_id = ? ORDER BY points DESC, wins DESC, joined_at ASC LIMIT ?`, id, len(prizes)) if err != nil { return } var winners []int64 for rows.Next() { var uid int64 if err := rows.Scan(&uid); err != nil { rows.Close() return } winners = append(winners, uid) } rows.Close() for i, uid := range winners { amt := prizes[i] if amt <= 0 { continue } if err := adjustTx(ctx, tx, uid, CurrencyCoin, amt, "tournament_prize", tournamentRef(id)); err != nil { return } if _, err := tx.ExecContext(ctx, `UPDATE tournament_entries SET prize = ? WHERE tournament_id = ? AND user_id = ?`, amt, id, uid); err != nil { return } } } if _, err := tx.ExecContext(ctx, `UPDATE tournaments SET settled = 1 WHERE id = ?`, id); err != nil { return } _ = tx.Commit() } func tournamentRef(id int64) string { return "tournament:" + strconv.FormatInt(id, 10) } // --- ادمین: CRUD تورنومنت --- // CreateTournament یک تورنومنت جدید می‌سازد و شناسه‌اش را برمی‌گرداند. func (s *Service) CreateTournament(ctx context.Context, t Tournament) (int64, error) { prizes, _ := json.Marshal(t.Prizes) res, err := s.db.ExecContext(ctx, `INSERT INTO tournaments (title, description, entry_fee, prizes, starts_at, ends_at) VALUES (?, ?, ?, ?, ?, ?)`, t.Title, t.Description, t.EntryFee, string(prizes), t.StartsAt.UTC().Format(tsLayout), t.EndsAt.UTC().Format(tsLayout)) if err != nil { return 0, err } return res.LastInsertId() } // DeleteTournament یک تورنومنت و ثبت‌نام‌هایش را حذف می‌کند. func (s *Service) DeleteTournament(ctx context.Context, id int64) error { if _, err := s.db.ExecContext(ctx, `DELETE FROM tournament_entries WHERE tournament_id = ?`, id); err != nil { return err } _, err := s.db.ExecContext(ctx, `DELETE FROM tournaments WHERE id = ?`, id) return err } // AdminListTournaments همه‌ی تورنومنت‌ها را برای پنلِ ادمین برمی‌گرداند. func (s *Service) AdminListTournaments(ctx context.Context) ([]TournamentView, error) { return s.ListTournaments(ctx, 0) }