378 lines
12 KiB
Go
378 lines
12 KiB
Go
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()
|
|
}
|
|
|
|
// UpdateTournament فیلدهای یک تورنومنت را ویرایش میکند (ثبتنامها دستنخورده).
|
|
func (s *Service) UpdateTournament(ctx context.Context, id int64, t Tournament) error {
|
|
prizes, _ := json.Marshal(t.Prizes)
|
|
_, err := s.db.ExecContext(ctx,
|
|
`UPDATE tournaments SET title=?, description=?, entry_fee=?, prizes=?, starts_at=?, ends_at=?
|
|
WHERE id=?`,
|
|
t.Title, t.Description, t.EntryFee, string(prizes),
|
|
t.StartsAt.UTC().Format(tsLayout), t.EndsAt.UTC().Format(tsLayout), id)
|
|
return err
|
|
}
|
|
|
|
// 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)
|
|
}
|