package economy import ( "context" "database/sql" "errors" "net/url" "strings" "sync" "time" ) // خطاهای دامنه. var ( ErrTooSoon = errors.New("daily reward not ready") ErrAdNotVerified = errors.New("ad not verified") ErrAdCap = errors.New("daily ad cap reached") ErrAdDuplicate = errors.New("ad reward already claimed") ErrNotFound = errors.New("item not found") ErrAlreadyOwned = errors.New("already owned") ErrNotOwned = errors.New("card not owned") ErrIAPInvalid = errors.New("purchase not valid") ) const tsLayout = "2006-01-02 15:04:05" func nowUTC() time.Time { return time.Now().UTC() } func nowStr() string { return nowUTC().Format(tsLayout) } func parseTS(s string) time.Time { t, _ := time.Parse(tsLayout, s) return t } // Service سرویس اقتصاد بازی. type Service struct { db *sql.DB ad AdVerifier iap IAPVerifier publicURL string // مبدأِ عمومیِ سرور برای ساختِ URL کاملِ تصاویر mu sync.RWMutex // محافظ کشِ کاتالوگ cat Catalog // کشِ کاتالوگ خوانده‌شده از DB } func New(db *sql.DB, ad AdVerifier, iap IAPVerifier) *Service { return &Service{db: db, ad: ad, iap: iap} } // SetPublicURL مبدأِ عمومیِ سرور را تنظیم می‌کند (برای URLهای کاملِ تصاویر). func (s *Service) SetPublicURL(u string) { s.publicURL = strings.TrimRight(u, "/") } // imgURL یک URLِ کاملِ URL-safe برای فایلی در پوشه‌ی [dir] می‌سازد ("" اگر نام خالی // باشد). نامِ فایل encode می‌شود تا فاصله/کاراکترِ خاص مشکلی ایجاد نکند. func (s *Service) imgURL(dir, name string) string { if name == "" { return "" } return s.publicURL + "/" + dir + "/" + url.PathEscape(name) } // Profile وضعیت اقتصادی کاربر. type Profile struct { Coins int64 `json:"coins"` Tickets int64 `json:"tickets"` XP int64 `json:"xp"` Trophies int `json:"trophies"` Level int `json:"level"` XPInto int64 `json:"xp_into_level"` XPNeed int64 `json:"xp_for_next"` VIP bool `json:"vip"` VIPUntil *string `json:"vip_until"` SelectedCard string `json:"selected_card"` SelectedCarpet string `json:"selected_carpet"` SelectedFrame string `json:"selected_frame"` SelectedAvatar string `json:"selected_avatar"` // شناسه‌ی شخصیتِ انتخابی (برای وضعیتِ انتخاب در فروشگاه) SelectedAvatarImg string `json:"selected_avatar_img"` // URL کاملِ تصویرِ شخصیت — خالی ⇒ آواتارِ تصادفی SelectedCarpetImg string `json:"selected_carpet_img"` // URL کاملِ تصویرِ فرش — خالی ⇒ میزِ کلاسیک RankPoints int64 `json:"rank_points"` RankTier string `json:"rank_tier"` // bronze..king RankIndex int `json:"rank_index"` // ۰..۴ Avatar string `json:"avatar"` // seed آواتار (برای نمایش سرِ میز) DailyStreak int `json:"daily_streak"` BestStreak int `json:"best_streak"` DailyReady bool `json:"daily_ready"` // آیا امروز سکه‌ی روزانه قابلِ دریافت است NextDaily int64 `json:"next_daily"` // جایزه‌ی روزانه‌ی بعدی (پیش‌نمایش) } // GetProfile وضعیت اقتصادی کاربر را می‌خواند. func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error) { var p Profile var vipUntil, avatar, lastDaily, avatarImg sql.NullString err := s.db.QueryRowContext(ctx, `SELECT coins, tickets, xp, trophies, vip_until, selected_card, selected_carpet, selected_frame, selected_avatar, (SELECT id || '.' || img_ext FROM avatars WHERE id = users.selected_avatar AND enabled = 1), rank_points, avatar, daily_streak, best_streak, last_daily_at FROM users WHERE id = ?`, userID). Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.SelectedCarpet, &p.SelectedFrame, &p.SelectedAvatar, &avatarImg, &p.RankPoints, &avatar, &p.DailyStreak, &p.BestStreak, &lastDaily) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } p.Avatar = avatar.String p.SelectedAvatarImg = s.imgURL("avatars", avatarImg.String) // فرش: 'classic' تصویری ندارد (میزِ ترسیمی)؛ بقیه از /carpets/.jpg. if p.SelectedCarpet != "" && p.SelectedCarpet != "classic" { p.SelectedCarpetImg = s.imgURL("carpets", p.SelectedCarpet+".jpg") } p.Level, p.XPInto, p.XPNeed = LevelInfo(p.XP) p.RankTier, p.RankIndex = RankTier(p.RankPoints) if vipUntil.Valid { p.VIPUntil = &vipUntil.String p.VIP = parseTS(vipUntil.String).After(nowUTC()) } // وضعیتِ سکه‌ی روزانه: اگر امروز (تهران) نگرفته، قابلِ دریافت است. اگر یک روز // جا افتاده باشد، استریکِ بعدی از ۱ شروع می‌شود. today := tehranDate(nowUTC()) nextStreak := p.DailyStreak + 1 if lastDaily.Valid { lastDate := tehranDate(parseTS(lastDaily.String)) p.DailyReady = lastDate != today if lastDate != tehranDate(nowUTC().Add(-24*time.Hour)) && lastDate != today { nextStreak = 1 // روزی جا افتاده ⇒ ریست } } else { p.DailyReady = true nextStreak = 1 } p.NextDaily = streakReward(nextStreak) return &p, nil } func (s *Service) isVIP(ctx context.Context, userID int64) bool { var vu sql.NullString if err := s.db.QueryRowContext(ctx, `SELECT vip_until FROM users WHERE id = ?`, userID).Scan(&vu); err != nil { return false } return vu.Valid && parseTS(vu.String).After(nowUTC()) } // tehranDate تاریخِ تقویمیِ تهران (YYYY-MM-DD) را برمی‌گرداند. مرزِ «روز» برای // استریک، نیمه‌شبِ تهران است تا با حسِ کاربر بخواند. func tehranDate(t time.Time) string { return t.In(iranZone).Format("2006-01-02") } // streakReward جایزه‌ی روزِ n اُمِ استریک: پایه + پله (سقفِ روز ۷) + پاداشِ هفتگی. func streakReward(streak int) int64 { n := streak if n > 7 { n = 7 } r := int64(DailyReward) + int64(n-1)*50 // روز۱=۲۰۰ … روز۷=۵۰۰ if streak%7 == 0 { // هر ۷ روز پیاپی، پاداشِ ویژه r += 500 } return r } // ClaimDaily سکه‌ی روزانه را (یک‌بار در هر روزِ تقویمیِ تهران) می‌دهد و استریک را // به‌روزرسانی می‌کند. برمی‌گرداند: جایزه، استریکِ جدید، بهترین استریک. func (s *Service) ClaimDaily(ctx context.Context, userID int64) (reward int64, streak int, best int, err error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return 0, 0, 0, err } defer tx.Rollback() var last sql.NullString if err = tx.QueryRowContext(ctx, `SELECT last_daily_at, daily_streak, best_streak FROM users WHERE id = ?`, userID). Scan(&last, &streak, &best); err != nil { return 0, 0, 0, err } today := tehranDate(nowUTC()) if last.Valid { lastDate := tehranDate(parseTS(last.String)) if lastDate == today { return 0, 0, 0, ErrTooSoon // امروز قبلاً گرفته } if lastDate == tehranDate(nowUTC().Add(-24*time.Hour)) { streak++ // دیروز گرفته ⇒ ادامه‌ی استریک } else { streak = 1 // یک روز جا افتاده ⇒ ریست } } else { streak = 1 } if streak > best { best = streak } reward = streakReward(streak) if _, err = tx.ExecContext(ctx, `UPDATE users SET last_daily_at = ?, daily_streak = ?, best_streak = ? WHERE id = ?`, nowStr(), streak, best, userID); err != nil { return 0, 0, 0, err } if err = adjustTx(ctx, tx, userID, CurrencyCoin, reward, "daily_reward", ""); err != nil { return 0, 0, 0, err } if err = tx.Commit(); err != nil { return 0, 0, 0, err } return reward, streak, best, nil } // ClaimAdReward پس از تأیید تبلیغ rewarded، سکه می‌دهد (با ضد تکرار و سقف روزانه). func (s *Service) ClaimAdReward(ctx context.Context, userID int64, token string) (int64, error) { ok, err := s.ad.Verify(ctx, userID, token) if err != nil || !ok { return 0, ErrAdNotVerified } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return 0, err } defer tx.Rollback() // سقف روزانه since := nowUTC().Add(-24 * time.Hour).Format(tsLayout) var cnt int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM ad_rewards WHERE user_id = ? AND created_at > ?`, userID, since).Scan(&cnt); err != nil { return 0, err } if cnt >= AdDailyCap { return 0, ErrAdCap } // ثبت توکن یکتا (ضد تکرار) if _, err := tx.ExecContext(ctx, `INSERT INTO ad_rewards (user_id, token, amount) VALUES (?, ?, ?)`, userID, token, AdReward); err != nil { if isUnique(err) { return 0, ErrAdDuplicate } return 0, err } if err := adjustTx(ctx, tx, userID, CurrencyCoin, AdReward, "ad_reward", token); err != nil { return 0, err } if err := tx.Commit(); err != nil { return 0, err } return AdReward, nil } // BuyCard اسکین کارت را با سکه باز می‌کند. func (s *Service) BuyCard(ctx context.Context, userID int64, cardID string) error { skin := s.findCardSkin(cardID) if skin == nil { return ErrNotFound } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() // آیا قبلاً مالک است؟ var exists int _ = tx.QueryRowContext(ctx, `SELECT 1 FROM user_cards WHERE user_id = ? AND card_id = ?`, userID, cardID).Scan(&exists) if exists == 1 { return ErrAlreadyOwned } if skin.PriceCoins > 0 { if err := adjustTx(ctx, tx, userID, CurrencyCoin, -skin.PriceCoins, "buy_card", cardID); err != nil { return err } } if _, err := tx.ExecContext(ctx, `INSERT INTO user_cards (user_id, card_id) VALUES (?, ?)`, userID, cardID); err != nil { return err } // خرید = مالکیت + انتخابِ خودکار (به‌عنوانِ پیش‌فرض فعال شود). if _, err := tx.ExecContext(ctx, `UPDATE users SET selected_card = ? WHERE id = ?`, cardID, userID); err != nil { return err } return tx.Commit() } // SelectCard کارت انتخابی کاربر را تنظیم می‌کند (باید مالکش باشد یا کارت پیش‌فرض). func (s *Service) SelectCard(ctx context.Context, userID int64, cardID string) error { if s.findCardSkin(cardID) == nil { return ErrNotFound } if cardID != "simple" { var exists int _ = s.db.QueryRowContext(ctx, `SELECT 1 FROM user_cards WHERE user_id = ? AND card_id = ?`, userID, cardID).Scan(&exists) if exists != 1 { return ErrNotOwned } } _, err := s.db.ExecContext(ctx, `UPDATE users SET selected_card = ? WHERE id = ?`, cardID, userID) return err } // OwnedCards فهرست کارت‌های متعلق به کاربر (شامل کارت پیش‌فرض). func (s *Service) OwnedCards(ctx context.Context, userID int64) ([]string, error) { rows, err := s.db.QueryContext(ctx, `SELECT card_id FROM user_cards WHERE user_id = ?`, userID) if err != nil { return nil, err } defer rows.Close() owned := []string{"simple"} for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return nil, err } if id != "simple" { owned = append(owned, id) } } return owned, rows.Err() } // addXPTx مقدار XP را با اعمال بوستر فعال اضافه می‌کند. func (s *Service) addXPTx(ctx context.Context, tx *sql.Tx, userID int64, base int) error { mult := 1 var boostUntil sql.NullString var m int if err := tx.QueryRowContext(ctx, `SELECT xp_boost_mult, xp_boost_until FROM users WHERE id = ?`, userID).Scan(&m, &boostUntil); err == nil { if boostUntil.Valid && parseTS(boostUntil.String).After(nowUTC()) && m > 1 { mult = m } } _, err := tx.ExecContext(ctx, `UPDATE users SET xp = xp + ? WHERE id = ?`, base*mult, userID) return err } // PurchaseRequest درخواست تأیید خرید IAP. type PurchaseRequest struct { Store string `json:"store"` // bazaar | myket Kind string `json:"kind"` // coin | ticket | booster | vip ProductID string `json:"product_id"` // شناسه بسته در کاتالوگ (SKU فروشگاه) Token string `json:"token"` // purchaseToken // برای تأیید امضای آفلاین (کافه‌بازار): متنِ خامِ خرید و امضای RSA (base64). PurchaseData string `json:"purchase_data"` Signature string `json:"signature"` } // VerifyPurchase خرید را تأیید و در صورت معتبر بودن اعتبار/بلیط/VIP/بوستر می‌دهد (idempotent). func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req PurchaseRequest) error { // ابتدا بسته را از کاتالوگ می‌یابیم تا پاداش و SKU (شناسه‌ی محصول در فروشگاه) // را به‌دست آوریم؛ سپس با همان SKU خرید را تأیید می‌کنیم. var coins, tickets int64 var vipDays int var priceToman int var booster *Booster var sku string switch req.Kind { case "coin": p := s.findCoinPackage(req.ProductID) if p == nil { return ErrNotFound } coins, vipDays, priceToman, sku = p.Coins, p.VIPDays, p.PriceToman, p.SKU if s.isVIP(ctx, userID) { coins += coins / 10 // پاداش ۱۰٪ VIP (قیمت را تغییر نمی‌دهد) } case "ticket": p := s.findTicketPackage(req.ProductID) if p == nil { return ErrNotFound } tickets, priceToman, sku = p.Tickets, p.PriceToman, p.SKU case "booster": booster = s.findBooster(req.ProductID) if booster == nil { return ErrNotFound } priceToman, sku = booster.PriceToman, booster.SKU case "vip": p := s.findVIPPackage(req.ProductID) if p == nil { return ErrNotFound } vipDays, priceToman, sku = p.Months*30, p.PriceToman, p.SKU // هر ماه = ۳۰ روز default: return ErrNotFound } if sku == "" { sku = req.ProductID // اگر SKU تنظیم نشده، از شناسه‌ی کاتالوگ استفاده می‌شود } ok, err := s.iap.Verify(ctx, IAPProof{ Store: req.Store, ProductID: sku, Token: req.Token, PurchaseData: req.PurchaseData, Signature: req.Signature, }) if err != nil || !ok { return ErrIAPInvalid } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() // ثبت خرید (یکتا بودن store+token ⇒ ضد دوباره‌اعتبارسنجی) if _, err := tx.ExecContext(ctx, `INSERT INTO purchases (user_id, store, product_id, purchase_token, status, coins, tickets, vip_days, kind, price_toman) VALUES (?, ?, ?, ?, 'verified', ?, ?, ?, ?, ?)`, userID, req.Store, req.ProductID, req.Token, coins, tickets, vipDays, req.Kind, priceToman); err != nil { if isUnique(err) { return ErrAlreadyOwned // قبلاً پردازش شده } return err } if coins > 0 { if err := adjustTx(ctx, tx, userID, CurrencyCoin, coins, "purchase_coin", req.ProductID); err != nil { return err } } if tickets > 0 { if err := adjustTx(ctx, tx, userID, CurrencyTicket, tickets, "purchase_ticket", req.ProductID); err != nil { return err } } if vipDays > 0 { if err := extendVIPTx(ctx, tx, userID, vipDays); err != nil { return err } } if booster != nil { until := laterOf(nowUTC(), time.Time{}).Add(time.Duration(booster.Hours) * time.Hour).Format(tsLayout) if _, err := tx.ExecContext(ctx, `UPDATE users SET xp_boost_mult = ?, xp_boost_until = ? WHERE id = ?`, booster.Multiplier, until, userID); err != nil { return err } } return tx.Commit() } // extendVIPTx زمان VIP را تمدید می‌کند (از بیشینهٔ اکنون و پایان فعلی). func extendVIPTx(ctx context.Context, tx *sql.Tx, userID int64, days int) error { var cur sql.NullString if err := tx.QueryRowContext(ctx, `SELECT vip_until FROM users WHERE id = ?`, userID).Scan(&cur); err != nil { return err } base := nowUTC() if cur.Valid { if t := parseTS(cur.String); t.After(base) { base = t } } until := base.Add(time.Duration(days) * 24 * time.Hour).Format(tsLayout) _, err := tx.ExecContext(ctx, `UPDATE users SET vip_until = ? WHERE id = ?`, until, userID) return err } // GrantVIPDays به‌صورت دستی (ادمین) اشتراک VIP را تمدید می‌کند. func (s *Service) GrantVIPDays(ctx context.Context, userID int64, days int) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if err := extendVIPTx(ctx, tx, userID, days); err != nil { return err } return tx.Commit() } // GrantCard به‌صورت دستی (ادمین) یک اسکین کارت را برای کاربر باز می‌کند. func (s *Service) GrantCard(ctx context.Context, userID int64, cardID string) error { if s.findCardSkin(cardID) == nil { return ErrNotFound } _, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO user_cards (user_id, card_id) VALUES (?, ?)`, userID, cardID) return err } func laterOf(a, b time.Time) time.Time { if a.After(b) { return a } return b } // --- تسویه‌ی داخل بازی --- // ChargeEntry ورودی میز را از کاربر کسر می‌کند. func (s *Service) ChargeEntry(ctx context.Context, userID, entry int64, ref string) error { if entry <= 0 { return nil } return s.Adjust(ctx, userID, CurrencyCoin, -entry, "table_entry", ref) } // Refund ورودی میز را بازمی‌گرداند (در صورت لغو بازی). func (s *Service) Refund(ctx context.Context, userID, entry int64, ref string) error { if entry <= 0 { return nil } return s.Adjust(ctx, userID, CurrencyCoin, entry, "table_refund", ref) } // AwardWinner جایزه، XP و جام را به برنده می‌دهد. func (s *Service) AwardWinner(ctx context.Context, userID, prize int64, xp, trophy int, ref string) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if prize > 0 { if err := adjustTx(ctx, tx, userID, CurrencyCoin, prize, "table_prize", ref); err != nil { return err } } if xp > 0 { if err := s.addXPTx(ctx, tx, userID, xp); err != nil { return err } } if trophy > 0 { if _, err := tx.ExecContext(ctx, `UPDATE users SET trophies = trophies + ? WHERE id = ?`, trophy, userID); err != nil { return err } } return tx.Commit() } // RecordGame نتیجه‌ی بازی را در تاریخچه ثبت می‌کند. func (s *Service) RecordGame(ctx context.Context, room, playersJSON string, winnerTeam int, kot bool) error { _, err := s.db.ExecContext(ctx, `INSERT INTO game_history (room, players_json, winner_team, kot) VALUES (?, ?, ?, ?)`, room, playersJSON, winnerTeam, boolToInt(kot)) return err } func boolToInt(b bool) int { if b { return 1 } return 0 } func isUnique(err error) bool { return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique") }